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:
Michal
2026-05-07 13:13:33 +01:00
parent 20a541a5d6
commit db57bb5856
9 changed files with 508 additions and 6 deletions

View 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`;
}

View File

@@ -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 });