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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user