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

@@ -56,6 +56,12 @@ export function createGatePlugin(config: GatePluginConfig = {}): ProxyModelPlugi
ctx.registerTool(getProposeTool(), async (args, callCtx) => {
return handleProposePrompt(args, callCtx);
});
// PR-4: Register propose_skill alongside propose_prompt. Goes
// through the new /api/v1/proposals endpoint with resourceType='skill'.
ctx.registerTool(getProposeSkillTool(), async (args, callCtx) => {
return handleProposeSkill(args, callCtx);
});
},
async onSessionDestroy(ctx) {
@@ -191,12 +197,40 @@ function getReadPromptsTool(): ToolDefinition {
function getProposeTool(): ToolDefinition {
return {
name: 'propose_prompt',
description: 'Propose a new prompt for this project. Creates a pending request that must be approved by a user before becoming active.',
description:
'Propose a piece of project-specific knowledge as a new prompt. ' +
'Use when you discover a non-obvious convention, hidden constraint, ' +
'or lesson learned that future sessions on this project would benefit ' +
'from. The proposal enters a queue; a maintainer reviews it and ' +
'approves or rejects. See the propose-learnings skill for guidance ' +
'on what makes a good proposal and what NOT to propose.',
inputSchema: {
type: 'object',
properties: {
name: { type: 'string', description: 'Prompt name (lowercase alphanumeric with hyphens, e.g. "debug-guide")' },
content: { type: 'string', description: 'Prompt content text' },
content: { type: 'string', description: 'Prompt content text. Lead with the shape of the situation, not the resolution. Keep under 200 words.' },
},
required: ['name', 'content'],
},
};
}
function getProposeSkillTool(): ToolDefinition {
return {
name: 'propose_skill',
description:
'Propose a new Claude Code skill (a SKILL.md). Reserve for ' +
'cross-cutting knowledge — debugging discipline, release hygiene, ' +
'security review style — that would help across many projects, ' +
'not just this one. Skills have a larger blast radius than prompts ' +
'and are harder to scope; lean toward propose_prompt unless you ' +
'have a clear cross-project reason. See the propose-learnings skill.',
inputSchema: {
type: 'object',
properties: {
name: { type: 'string', description: 'Skill name (lowercase alphanumeric with hyphens, e.g. "debug-discipline")' },
content: { type: 'string', description: 'SKILL.md body. Markdown. The reviewer will see this as the canonical content of the skill.' },
description: { type: 'string', description: 'One-line description shown in mcpctl get skills listings' },
},
required: ['name', 'content'],
},
@@ -435,6 +469,48 @@ async function handleProposePrompt(
}
}
async function handleProposeSkill(
args: Record<string, unknown>,
ctx: PluginSessionContext,
): Promise<unknown> {
const name = args['name'] as string | undefined;
const content = args['content'] as string | undefined;
const description = typeof args['description'] === 'string' ? args['description'] : '';
if (!name || !content) {
throw new ToolError(-32602, 'Missing required arguments: name and content');
}
try {
// PR-4: Skills go through the new /api/v1/proposals endpoint
// (resourceType='skill'). The legacy /api/v1/projects/.../promptrequests
// path is prompt-only.
const body: Record<string, unknown> = {
resourceType: 'skill',
name,
project: ctx.projectName,
body: { content, description, priority: 5, files: {}, metadata: {} },
createdBySession: ctx.sessionId,
};
await ctx.postToMcpd('/api/v1/proposals', body);
return {
content: [
{
type: 'text',
text:
`Skill proposal "${name}" created successfully. ` +
`A maintainer will review it (mcpctl review next) and either ` +
`approve — at which point it becomes available to every machine ` +
`that runs mcpctl skills sync — or reject with a note. You will ` +
`not see the outcome in this session.`,
},
],
};
} catch (err) {
throw new ToolError(-32603, `Failed to propose skill: ${err instanceof Error ? err.message : String(err)}`);
}
}
// ── gated intercept handler ──
async function handleGatedIntercept(