Public: anyone can read this; the crew can change it.
The Claude Code plugin's /forge gains `plan`: show the planner's wave and nod or redirect it #5346
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5321-the-claude-code-plugin-s-forge-gains-pla → main 2 commits fork 1 thread · 1 unresolved
5 files
changed +30
−7
plugins/claude-code/.claude-plugin/plugin.json +1 −1
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "name": "forge", |
| 3 | 3 | "version": "0.1.0", |
| 4 | "description": "Your forge inside Claude Code: its MCP server and a /forge command (tell, status, decide).", | |
| 4 | "description": "Your forge inside Claude Code: its MCP server and a /forge command (tell, status, decide, plan).", | |
| 5 | 5 | "mcpServers": { |
| 6 | 6 | "forge": { |
| 7 | 7 | "type": "http", |
plugins/claude-code/README.md +3 −1
| @@ -3,6 +3,8 @@ | ||
| 3 | 3 | Set `FORGE_URL` (the forge's address, no trailing slash) and `FORGE_TOKEN` |
| 4 | 4 | (an `rw` token; `ro` reads only), then add this directory as a plugin |
| 5 | 5 | (`claude --plugin-dir plugins/claude-code`). It registers the forge's MCP |
| 6 | server and a `/forge` command: `tell`, `status`, `decide`. | |
| 6 | server and a `/forge` command: `tell`, `status`, `decide`, `plan`. | |
| 7 | ||
| 8 | `/forge plan` shows the planner's proposed wave in three lines. `/forge plan approve`, `/forge plan redirect <words>` or `/forge plan later` answers it, after you confirm. | |
| 7 | 9 | |
| 8 | 10 | `test/plugins.test.ts` fails when this plugin names an MCP tool the forge does not list, so a rename in `src/http/mcp.ts` cannot leave it behind. |
plugins/claude-code/commands/forge.md +3 −2
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | --- |
| 2 | description: Talk to your forge: tell, status, decide | |
| 3 | argument-hint: tell <app> <words> | status | decide <approve|redirect|decline> [words] | |
| 2 | description: Talk to your forge: tell, status, decide, plan | |
| 3 | argument-hint: tell <app> <words> | status | decide <approve|redirect|decline> [words] | plan [approve|redirect <words>|later] | |
| 4 | 4 | --- |
| 5 | 5 | |
| 6 | 6 | Use the `forge` MCP server's tools for `$ARGUMENTS`. |
| @@ -8,5 +8,6 @@ Use the `forge` MCP server's tools for `$ARGUMENTS`. | ||
| 8 | 8 | - `tell <app> <words>`: call `tell` with `repo` = the app (owner/name) and `text` = the words. Pass `context` with `page` (if known), `run`, `commit` (the current `git rev-parse HEAD`) and `said` (the person's own words, verbatim). |
| 9 | 9 | - `status`: call `now` and `asks`; say in three sentences what is running and what waits on the person. |
| 10 | 10 | - `decide approve|redirect|decline`: call `asks`, take the first row; approve → `asks_act` with action `ship`, decline → `asks_act` with action `close`, redirect → `say` with the person's words. Plan first (`dryRun` defaults to true), show what would change, and apply (`dryRun: false`) only after the person says yes. |
| 11 | - `plan`: call `asks`, take the first row whose kind is `proposal` (none: say no wave waits). Show it in three lines: the wave's title, what it holds, why now. With `approve`, `redirect <words>` or `later`, ask the person to confirm, and only after they say yes call `asks_proposal` with `id` = that row's `proposal.id` (the proposal's own id, not the row's `id`, which starts `proposal:`), `verb` = the word, and `words` for redirect. | |
| 11 | 12 | |
| 12 | 13 | Keep what you relay short. Never merge: a person lands changes. |
test/plugins.test.ts +10 −1
| @@ -19,7 +19,7 @@ const opencodeText = (): string => { | ||
| 19 | 19 | const backticked = (text: string): string[] => [...text.matchAll(/`([a-z][a-z0-9_]*)`/g)].map((m) => m[1]!); |
| 20 | 20 | |
| 21 | 21 | /** Backticked words that are the server or an argument, not a tool. */ |
| 22 | const NOT_TOOLS = new Set(["forge", "repo", "text", "context", "page", "run", "commit", "said", "action", "ship", "close", "status"]); | |
| 22 | const NOT_TOOLS = new Set(["forge", "repo", "text", "context", "page", "run", "commit", "said", "action", "ship", "close", "status", "words", "id", "verb", "proposal", "approve", "later"]); | |
| 23 | 23 | |
| 24 | 24 | /** Tools the command text tells the agent to call: every backticked name that is not an argument. */ |
| 25 | 25 | const toolsNamed = (text: string): string[] => [...new Set(backticked(text))].filter((n) => !NOT_TOOLS.has(n)); |
| @@ -41,3 +41,12 @@ test("a renamed tool is caught", () => { | ||
| 41 | 41 | expect(toolsNamed("call `tell_renamed` with `repo`").filter((n) => !tools.has(n))).toEqual(["tell_renamed"]); |
| 42 | 42 | expect(backticked("`a_b` and `C`")).toEqual(["a_b"]); |
| 43 | 43 | }); |
| 44 | ||
| 45 | test("the Claude Code command names plan's tools, and the forge lists them", () => { | |
| 46 | const named = toolsNamed(claudeText()); | |
| 47 | for (const tool of ["asks", "asks_proposal"]) { | |
| 48 | expect(named).toContain(tool); | |
| 49 | expect(tools.has(tool)).toBe(true); | |
| 50 | } | |
| 51 | expect(claudeText()).toContain("`proposal.id`"); | |
| 52 | }); |
test/proposal-card.test.ts +13 −2
| @@ -185,11 +185,11 @@ describe("a wave proposal is one inbox card", () => { | ||
| 185 | 185 | }); |
| 186 | 186 | return { status: res.status, body: (await res.json()) as any }; |
| 187 | 187 | }; |
| 188 | const mcp = async (token: string, args: unknown) => { | |
| 188 | const mcp = async (token: string, args: unknown, name = "asks_proposal") => { | |
| 189 | 189 | const res = await fetch(`${servers.url}/mcp`, { |
| 190 | 190 | method: "POST", |
| 191 | 191 | headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` }, |
| 192 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "asks_proposal", arguments: args } }), | |
| 192 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }), | |
| 193 | 193 | }); |
| 194 | 194 | const body = (await res.json()) as any; |
| 195 | 195 | return { error: body.result.isError === true, text: body.result.content[0].text as string }; |
| @@ -227,6 +227,17 @@ describe("a wave proposal is one inbox card", () => { | ||
| 227 | 227 | expect(getProposal(app.db, id)!.state).toBe("redirected"); |
| 228 | 228 | }); |
| 229 | 229 | |
| 230 | test("MCP: the id /forge plan passes, the asks row's proposal.id, acts; the row id does not", async () => { | |
| 231 | const id = propose("Plan wave"); | |
| 232 | const asks = await mcp(rw, {}, "asks"); | |
| 233 | const row = JSON.parse(asks.text).rows?.find?.((r: any) => r.kind === "proposal" && r.proposal?.id === id); | |
| 234 | expect(row).toBeDefined(); | |
| 235 | expect(row.id).toBe(`proposal:${id}`); | |
| 236 | expect((await mcp(rw, { id: row.id, verb: "later" })).error).toBe(true); | |
| 237 | expect((await mcp(rw, { id: row.proposal.id, verb: "later" })).error).toBe(false); | |
| 238 | expect(getProposal(app.db, id)!.state).toBe("later"); | |
| 239 | }); | |
| 240 | ||
| 230 | 241 | test("terminal: a wave is a row on the On you card; y approves, l puts aside, B brings back", async () => { |
| 231 | 242 | const a = propose("Tui approve wave"); |
| 232 | 243 | const b = propose("Tui later wave"); |
new above
offline
High: the
asksrow'sidfor a proposal isproposal:<ulid>, butasks_proposallooks the id up raw (getProposalinsrc/core/proposals.ts:54), so this call always answersnot_found. Sayid= the row'sproposal.idinstead, and add a test that fails while it reads the row id.