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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/5321-the-claude-code-plugin-s-forge-gains-pla → main 2 commits fork 1 thread · 1 unresolved

  • Merged. scout/5321-the-claude-code-plugin-s-forge-gains-pla was merged into main as 65efa27.

Review

Sign in to review this change.
5 files changed +30 −7
plugins/claude-code/.claude-plugin/plugin.json +1 −1
@@ -1,7 +1,7 @@
11 {
22 "name": "forge",
33 "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).",
55 "mcpServers": {
66 "forge": {
77 "type": "http",
plugins/claude-code/README.md +3 −1
@@ -3,6 +3,8 @@
33 Set `FORGE_URL` (the forge's address, no trailing slash) and `FORGE_TOKEN`
44 (an `rw` token; `ro` reads only), then add this directory as a plugin
55 (`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.
79
810 `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 @@
11 ---
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]
44 ---
55
66 Use the `forge` MCP server's tools for `$ARGUMENTS`.
@@ -8,5 +8,6 @@ Use the `forge` MCP server's tools for `$ARGUMENTS`.
88 - `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).
99 - `status`: call `now` and `asks`; say in three sentences what is running and what waits on the person.
1010 - `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.
outdated

High: the asks row's id for a proposal is proposal:<ulid>, but asks_proposal looks the id up raw (getProposal in src/core/proposals.ts:54), so this call always answers not_found. Say id = the row's proposal.id instead, and add a test that fails while it reads the row id.

1112
1213 Keep what you relay short. Never merge: a person lands changes.
test/plugins.test.ts +10 −1
@@ -19,7 +19,7 @@ const opencodeText = (): string => {
1919 const backticked = (text: string): string[] => [...text.matchAll(/`([a-z][a-z0-9_]*)`/g)].map((m) => m[1]!);
2020
2121 /** 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"]);
2323
2424 /** Tools the command text tells the agent to call: every backticked name that is not an argument. */
2525 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", () => {
4141 expect(toolsNamed("call `tell_renamed` with `repo`").filter((n) => !tools.has(n))).toEqual(["tell_renamed"]);
4242 expect(backticked("`a_b` and `C`")).toEqual(["a_b"]);
4343 });
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", () => {
185185 });
186186 return { status: res.status, body: (await res.json()) as any };
187187 };
188 const mcp = async (token: string, args: unknown) => {
188 const mcp = async (token: string, args: unknown, name = "asks_proposal") => {
189189 const res = await fetch(`${servers.url}/mcp`, {
190190 method: "POST",
191191 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 } }),
193193 });
194194 const body = (await res.json()) as any;
195195 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", () => {
227227 expect(getProposal(app.db, id)!.state).toBe("redirected");
228228 });
229229
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
230241 test("terminal: a wave is a row on the On you card; y approves, l puts aside, B brings back", async () => {
231242 const a = propose("Tui approve wave");
232243 const b = propose("Tui later wave");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs