feat(mcpd+mcplocal+cli): propose-learnings system skill, propose_skill MCP tool, mcpctl review
Phase 4 of the Skills + Revisions + Proposals work. Closes the reflexive
loop: Claude sessions can now propose back content (prompts or skills)
that maintainers triage via a CLI queue. The system documents itself
to Claude through the same mechanism it documents to humans.
## What's added
### propose-learnings global skill (mcpd bootstrap)
- src/mcpd/src/bootstrap/system-skills.ts — idempotent upsert, mirrors
system-project.ts. Single skill seeded today: `propose-learnings`,
~430 words, explains when to engage with propose_prompt vs
propose_skill, what makes a good proposal, what NOT to propose, and
the review→approve flow. Priority 9, global scope.
- main.ts: `bootstrapSystemSkills(prisma)` called right after
`bootstrapSystemProject`.
### gate-encouragement-propose system prompt
- system-project.ts gains a new gate prompt (priority 10, alongside the
other gate-* prompts) that nudges Claude to call propose_prompt when
it discovers a project-specific lesson. Pairs with the propose-learnings
skill — the prompt is the trigger, the skill is the manual.
### propose_skill MCP tool (mcplocal)
- proxymodel/plugins/gate.ts: new virtual tool registered alongside
propose_prompt. Posts to /api/v1/proposals (the new endpoint from
PR-2) with resourceType='skill'. Tool description steers Claude
toward propose_prompt for project-specific knowledge and reserves
propose_skill for cross-cutting cases. propose_prompt's tool
description is also expanded to point at the propose-learnings skill
for guidance — the bare "creates a pending request" copy was bland
enough that nothing in Claude's prior would actually make it engage.
### mcpctl review CLI
- New top-level command in src/cli/src/commands/review.ts.
Subcommands:
mcpctl review pending List pending proposals
mcpctl review next Show oldest pending
mcpctl review show <id> Full detail
mcpctl review approve <id> POST /proposals/:id/approve
mcpctl review reject <id> --reason "..."
mcpctl review diff <id> Side-by-side current vs proposed
- Wired into src/cli/src/index.ts. Registered after createApproveCommand
to keep the existing project-ops `mcpctl approve promptrequest`
command working (legacy) while the new review surface is the
preferred path.
## Tests touched
- bootstrap-system-project.test.ts already counts via
getSystemPromptNames() length, so it picked up the new prompt
automatically; only the priority assertion needed nothing — the
new prompt starts with `gate-` so the existing `gate-* → priority 10`
invariant validates it.
- system-prompt-validation.test.ts: bumped expected length from 11→12
and added a `toContain('gate-encouragement-propose')` assertion.
Full suite: 158 test files / 2127 tests green.
## What's NOT in this PR
- A SkillService mock-based test for the proposal approval handler —
the PromptService approval handler is structurally identical and
already covered; the database-backed integration is exercised in
PR-2's tests.
- Changes to mcplocal's existing handleProposePrompt URL — it still
POSTs to the legacy /api/v1/projects/.../promptrequests endpoint,
which works because PR-2 left that route in place. PR-7 will
cut mcplocal over to /api/v1/proposals along with the
PromptRequest table rename + drop.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
220
src/cli/src/commands/review.ts
Normal file
220
src/cli/src/commands/review.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
import { Command } from 'commander';
|
||||
import type { ApiClient } from '../api-client.js';
|
||||
|
||||
/**
|
||||
* `mcpctl review` — triage UX for the proposal queue. Wraps the
|
||||
* /api/v1/proposals endpoints so reviewers don't have to hand-curl the
|
||||
* API. Subcommands:
|
||||
*
|
||||
* mcpctl review pending List pending proposals
|
||||
* mcpctl review next Show oldest pending
|
||||
* mcpctl review show <id> Full detail of one proposal
|
||||
* mcpctl review approve <id> Approve (creates resource + revision)
|
||||
* mcpctl review reject <id> --reason Reject with reviewer note
|
||||
* mcpctl review diff <id> Diff proposal body vs current resource
|
||||
*/
|
||||
|
||||
interface Proposal {
|
||||
id: string;
|
||||
resourceType: 'prompt' | 'skill';
|
||||
name: string;
|
||||
body: Record<string, unknown>;
|
||||
projectId: string | null;
|
||||
agentId: string | null;
|
||||
createdBySession: string | null;
|
||||
createdByUserId: string | null;
|
||||
status: 'pending' | 'approved' | 'rejected';
|
||||
reviewerNote: string;
|
||||
approvedRevisionId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
project?: { name: string } | null;
|
||||
agent?: { name: string } | null;
|
||||
}
|
||||
|
||||
export interface ReviewCommandDeps {
|
||||
client: ApiClient;
|
||||
log: (...args: unknown[]) => void;
|
||||
}
|
||||
|
||||
export function createReviewCommand(deps: ReviewCommandDeps): Command {
|
||||
const { client, log } = deps;
|
||||
|
||||
const cmd = new Command('review').description('Triage proposed prompts and skills');
|
||||
|
||||
cmd.command('pending')
|
||||
.alias('list')
|
||||
.description('List pending proposals')
|
||||
.option('--type <kind>', 'Filter by resource type: prompt or skill')
|
||||
.action(async (opts: { type?: string }) => {
|
||||
const params = new URLSearchParams({ status: 'pending' });
|
||||
if (opts.type) params.set('resourceType', opts.type);
|
||||
const proposals = await client.get<Proposal[]>(`/api/v1/proposals?${params.toString()}`);
|
||||
if (proposals.length === 0) {
|
||||
log('No pending proposals.');
|
||||
return;
|
||||
}
|
||||
log(formatTable(proposals));
|
||||
});
|
||||
|
||||
cmd.command('next')
|
||||
.description('Show the oldest pending proposal')
|
||||
.option('--type <kind>', 'Filter by resource type: prompt or skill')
|
||||
.action(async (opts: { type?: string }) => {
|
||||
const params = new URLSearchParams({ status: 'pending' });
|
||||
if (opts.type) params.set('resourceType', opts.type);
|
||||
const proposals = await client.get<Proposal[]>(`/api/v1/proposals?${params.toString()}`);
|
||||
if (proposals.length === 0) {
|
||||
log('No pending proposals.');
|
||||
return;
|
||||
}
|
||||
// /api/v1/proposals returns latest-first; we want the oldest pending.
|
||||
const oldest = proposals[proposals.length - 1] as Proposal;
|
||||
log(formatDetail(oldest));
|
||||
});
|
||||
|
||||
cmd.command('show')
|
||||
.description('Show full detail of a proposal')
|
||||
.argument('<id>', 'Proposal ID')
|
||||
.action(async (id: string) => {
|
||||
const proposal = await client.get<Proposal>(`/api/v1/proposals/${id}`);
|
||||
log(formatDetail(proposal));
|
||||
});
|
||||
|
||||
cmd.command('approve')
|
||||
.description('Approve a pending proposal (creates the resource + initial revision)')
|
||||
.argument('<id>', 'Proposal ID')
|
||||
.action(async (id: string) => {
|
||||
const updated = await client.post<Proposal>(`/api/v1/proposals/${id}/approve`, {});
|
||||
log(`approved proposal '${updated.name}' (resourceType: ${updated.resourceType})`);
|
||||
if (updated.approvedRevisionId) {
|
||||
log(` resulting revision: ${updated.approvedRevisionId}`);
|
||||
}
|
||||
});
|
||||
|
||||
cmd.command('reject')
|
||||
.description('Reject a pending proposal with a reviewer note')
|
||||
.argument('<id>', 'Proposal ID')
|
||||
.option('--reason <text>', 'Reviewer note explaining the rejection')
|
||||
.action(async (id: string, opts: { reason?: string }) => {
|
||||
if (!opts.reason) {
|
||||
throw new Error('--reason is required when rejecting a proposal');
|
||||
}
|
||||
await client.post(`/api/v1/proposals/${id}/reject`, { reviewerNote: opts.reason });
|
||||
log(`rejected proposal ${id}`);
|
||||
});
|
||||
|
||||
cmd.command('diff')
|
||||
.description('Show what would change if this proposal were approved')
|
||||
.argument('<id>', 'Proposal ID')
|
||||
.action(async (id: string) => {
|
||||
const proposal = await client.get<Proposal>(`/api/v1/proposals/${id}`);
|
||||
const proposedContent = (proposal.body as { content?: string }).content ?? '';
|
||||
|
||||
// Find existing resource (if any) to diff against. Both prompts and
|
||||
// skills are scoped by (name, projectId|agentId|null=global).
|
||||
let existingContent: string | null = null;
|
||||
const projectName = proposal.project?.name;
|
||||
const agentName = proposal.agent?.name;
|
||||
try {
|
||||
if (proposal.resourceType === 'prompt') {
|
||||
const params = new URLSearchParams();
|
||||
if (projectName) params.set('project', projectName);
|
||||
const list = await client.get<Array<{ name: string; content: string }>>(`/api/v1/prompts?${params.toString()}`);
|
||||
const match = list.find((p) => p.name === proposal.name);
|
||||
if (match) existingContent = match.content;
|
||||
} else {
|
||||
const params = new URLSearchParams();
|
||||
if (projectName) params.set('project', projectName);
|
||||
else if (agentName) params.set('agent', agentName);
|
||||
const list = await client.get<Array<{ name: string; content: string }>>(`/api/v1/skills?${params.toString()}`);
|
||||
const match = list.find((s) => s.name === proposal.name);
|
||||
if (match) existingContent = match.content;
|
||||
}
|
||||
} catch {
|
||||
// 404 from no project / agent means nothing to diff against.
|
||||
}
|
||||
|
||||
if (existingContent === null) {
|
||||
log(`Proposal would create a new ${proposal.resourceType} '${proposal.name}'.`);
|
||||
log('');
|
||||
log('--- proposed body ---');
|
||||
log(proposedContent);
|
||||
return;
|
||||
}
|
||||
|
||||
log(`Proposal would update the existing ${proposal.resourceType} '${proposal.name}'.`);
|
||||
log('');
|
||||
log('--- current ---');
|
||||
log(existingContent);
|
||||
log('--- proposed ---');
|
||||
log(proposedContent);
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
// ── Formatting ──
|
||||
|
||||
function formatTable(proposals: Proposal[]): string {
|
||||
const lines: string[] = [];
|
||||
const idW = Math.max(2, ...proposals.map((p) => p.id.length));
|
||||
const typeW = 6; // 'skill' / 'prompt'
|
||||
const nameW = Math.max(4, ...proposals.map((p) => p.name.length));
|
||||
const scopeW = Math.max(5, ...proposals.map((p) => scopeLabel(p).length));
|
||||
const sessW = 8;
|
||||
|
||||
const header = `${pad('ID', idW)} ${pad('TYPE', typeW)} ${pad('NAME', nameW)} ${pad('SCOPE', scopeW)} ${pad('SESSION', sessW)} AGE`;
|
||||
lines.push(header);
|
||||
for (const p of proposals) {
|
||||
const age = ageOf(p.createdAt);
|
||||
lines.push(
|
||||
`${pad(p.id, idW)} ${pad(p.resourceType, typeW)} ${pad(p.name, nameW)} ${pad(scopeLabel(p), scopeW)} ${pad((p.createdBySession ?? '—').slice(0, 8), sessW)} ${age}`,
|
||||
);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function formatDetail(p: Proposal): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(`=== Proposal: ${p.name} (${p.resourceType}) ===`);
|
||||
lines.push(`ID: ${p.id}`);
|
||||
lines.push(`Status: ${p.status}`);
|
||||
lines.push(`Scope: ${scopeLabel(p)}`);
|
||||
lines.push(`Created: ${p.createdAt} (session ${p.createdBySession ?? '—'})`);
|
||||
if (p.reviewerNote) lines.push(`Reviewer note: ${p.reviewerNote}`);
|
||||
if (p.approvedRevisionId) lines.push(`Approved as revision: ${p.approvedRevisionId}`);
|
||||
lines.push('');
|
||||
lines.push('--- body ---');
|
||||
const content = (p.body as { content?: string }).content;
|
||||
if (typeof content === 'string') {
|
||||
lines.push(content);
|
||||
} else {
|
||||
lines.push(JSON.stringify(p.body, null, 2));
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function scopeLabel(p: Proposal): string {
|
||||
if (p.project?.name) return `project:${p.project.name}`;
|
||||
if (p.agent?.name) return `agent:${p.agent.name}`;
|
||||
return 'global';
|
||||
}
|
||||
|
||||
function pad(s: string, w: number): string {
|
||||
if (s.length >= w) return s;
|
||||
return s + ' '.repeat(w - s.length);
|
||||
}
|
||||
|
||||
function ageOf(iso: string): string {
|
||||
const t = Date.parse(iso);
|
||||
if (Number.isNaN(t)) return '?';
|
||||
const sec = Math.floor((Date.now() - t) / 1000);
|
||||
if (sec < 60) return `${String(sec)}s`;
|
||||
const min = Math.floor(sec / 60);
|
||||
if (min < 60) return `${String(min)}m`;
|
||||
const hr = Math.floor(min / 60);
|
||||
if (hr < 24) return `${String(hr)}h`;
|
||||
const days = Math.floor(hr / 24);
|
||||
return `${String(days)}d`;
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import { createChatCommand } from './commands/chat.js';
|
||||
import { createChatLlmCommand } from './commands/chat-llm.js';
|
||||
import { createMigrateCommand } from './commands/migrate.js';
|
||||
import { createRotateCommand } from './commands/rotate.js';
|
||||
import { createReviewCommand } from './commands/review.js';
|
||||
import { ApiClient, ApiError } from './api-client.js';
|
||||
import { loadConfig } from './config/index.js';
|
||||
import { loadCredentials } from './auth/index.js';
|
||||
@@ -268,6 +269,11 @@ export function createProgram(): Command {
|
||||
program.addCommand(createAttachServerCommand(projectOpsDeps), { hidden: true });
|
||||
program.addCommand(createDetachServerCommand(projectOpsDeps), { hidden: true });
|
||||
program.addCommand(createApproveCommand(projectOpsDeps));
|
||||
// PR-4: reviewer queue for proposed prompts + skills.
|
||||
program.addCommand(createReviewCommand({
|
||||
client,
|
||||
log: (...args) => console.log(...args),
|
||||
}));
|
||||
program.addCommand(createMcpCommand({
|
||||
getProject: () => program.opts().project as string | undefined,
|
||||
}), { hidden: true });
|
||||
|
||||
Reference in New Issue
Block a user