Public: anyone can read this; the crew can change it.
The Claude Code and OpenCode commands gain waves, held and forks, in the same words as the pages #11499
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/11481-the-claude-code-and-opencode-commands-ga → main 2 commits fork 2 threads · 2 unresolved
5 files
changed +52
−8
plugins/claude-code/README.md +3 −1
| @@ -8,7 +8,7 @@ claude plugin marketplace add $FORGE_URL/plugins/claude-code/marketplace.json && | ||
| 8 | 8 | |
| 9 | 9 | The forge serves that marketplace manifest (name `forge`, one plugin `forge`, fetched by Claude Code as the `plugins/claude-code` subdirectory of the forge's public source repository). Inside Claude Code, the same from the repository: `/plugin marketplace add $FORGE_URL/trevato/forge.git` then `/plugin install forge@forge`. Then set `FORGE_URL` (the forge's address, no trailing slash) in your environment. From a clone, `claude --plugin-dir plugins/claude-code` loads it locally instead. It registers the forge's MCP |
| 10 | 10 | server and a `/forge` command: `new`, `tell`, `status`, `decide`, `archive`, `undo`, `plan`, `preview`, |
| 11 | `pulls`, `runs`, `log`, `asked`. | |
| 11 | `pulls`, `runs`, `log`, `asked`, `waves`, `held`, `forks`. | |
| 12 | 12 | |
| 13 | 13 | **Sign in (nothing to copy).** The `forge` server sends no Authorization header, so Claude Code takes the forge's OAuth sign-in: open `/mcp` in Claude Code, choose `forge`, and approve in the browser. |
| 14 | 14 | |
| @@ -35,4 +35,6 @@ the pull that closes it, and the outcome sentence: landed, who is building, | ||
| 35 | 35 | parked and why, failed), flagged ones first, through MCP `asked` with |
| 36 | 36 | `show: "all"`; with an app, only that app's asks. |
| 37 | 37 | |
| 38 | `/forge waves <app>` relays the app's newest three waves (version, date, headline), through MCP `waves`. `/forge held` says how many themes the Later gate holds and which are done, through MCP `held`; it only reads. `/forge forks <app>` says in two lines what the app follows upstream and who follows it downstream, through MCP `forks` with `kind: "chain"`. None of them lands or closes anything. | |
| 39 | ||
| 38 | 40 | `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 +5 −2
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | --- |
| 2 | description: Talk to your forge: new, tell, status, decide, archive, undo, plan, preview, pulls, runs, log, asked | |
| 3 | argument-hint: new <name> <words> | tell <app> <words> | status | decide <approve|redirect|decline> [words] | decide all <dismiss|ship|close> [kind] | archive <app> | undo <operation> | plan [approve|redirect <words>|later] | preview <url> | pulls <app> | runs <app> | log <app> <run> | asked [app] | |
| 2 | description: Talk to your forge: new, tell, status, decide, archive, undo, plan, preview, pulls, runs, log, asked, waves, held, forks | |
| 3 | argument-hint: new <name> <words> | tell <app> <words> | status | decide <approve|redirect|decline> [words] | decide all <dismiss|ship|close> [kind] | archive <app> | undo <operation> | plan [approve|redirect <words>|later] | preview <url> | pulls <app> | runs <app> | log <app> <run> | asked [app] | waves <app> | held | forks <app> | |
| 4 | 4 | --- |
| 5 | 5 | |
| 6 | 6 | Use the `forge` MCP server's tools for `$ARGUMENTS`. |
| @@ -18,5 +18,8 @@ Use the `forge` MCP server's tools for `$ARGUMENTS`. | ||
| 18 | 18 | - `runs <app>`: call `runs` with `repo` = the app (owner/name); say, one line each, the running runs and the last three finished ones: kind, issue or pull number, state, and how long (from `startedAt` to `endedAt`, or to now while running). |
| 19 | 19 | - `log <app> <run>`: call `run_log` with `repo` = the app, `run` = the run id and `tail` = 40; relay the lines verbatim, in a code block. |
| 20 | 20 | - `asked [app]`: call `asked` with `show` = `all`, and `repo` = the app (owner/name) when one is given; say, one line each, flagged ones first, what was asked (its title and number), its state (waiting, building, shipped or closed), the pull that closes it (none yet: say so) and the `outcomeText` sentence, so a parked or failed ask says why. |
| 21 | - `waves <app>`: call `waves` with `repo` = the app (owner/name); relay the newest three waves, one line each: version, date, headline. Read-only: it lands nothing. | |
| 22 | - `held`: call `held` with `app` = the app the conversation names (owner/name); when none is named, ask which app before calling, since `held` needs one (never `apply`); say in two sentences how many themes the Later gate holds and which are done (the issues in `approve`, found done on main). Read-only: never close or land anything; closing is the person's, on the page. | |
| 23 | - `forks <app>`: call `forks` with `repo` = the app (owner/name) and `kind: "chain"`; say in two lines what it follows upstream (its parent and grandparent, or that it follows none) and who follows it downstream (its children, or none), noting a severed link. Read-only. | |
| 21 | 24 | |
| 22 | 25 | Keep what you relay short. Never merge: a person lands changes. |
plugins/opencode/README.md +3 −1
| @@ -4,7 +4,7 @@ Fetch `forge.ts` into `.opencode/plugin/` (or list this package under `plugin` | ||
| 4 | 4 | in `opencode.json`), and set `FORGE_URL` and `FORGE_TOKEN` (make the token at `/settings/tokens` on the forge, which shows the line with its own address filled in): |
| 5 | 5 | `mkdir -p .opencode/plugin && curl -fsSo .opencode/plugin/forge.ts <forge>/plugins/opencode/forge.ts && FORGE_URL=<forge> FORGE_TOKEN=<token> opencode`, where `<forge>` is the forge's own origin (it serves this file). It registers the |
| 6 | 6 | forge's MCP server and the `forge` command: `new`, `tell`, `status`, `decide`, `archive`, `undo`, `plan`, |
| 7 | `preview`, `pulls`, `runs`, `log`, `asked`. | |
| 7 | `preview`, `pulls`, `runs`, `log`, `asked`, `waves`, `held`, `forks`. | |
| 8 | 8 | `/forge archive <app>` plans archiving the app, then archives it after you say yes (put away, not deleted; the receipt says how to wake it). `/forge decide all <dismiss|ship|close> [kind]` plans the same action over every waiting ask (of that kind), says how many and which, applies after a yes and prints the `operation` id; `/forge undo <operation>` reverses it. |
| 9 | 9 | |
| 10 | 10 | `/forge plan` shows the planner's next wave (the first wave-proposal row of `asks`); |
| @@ -36,4 +36,6 @@ neither set, it registers nothing. | ||
| 36 | 36 | |
| 37 | 37 | Code Mode can also read `GET /contract/openapi.json`. |
| 38 | 38 | |
| 39 | `/forge waves <app>` relays the app's newest three waves (version, date, headline), through MCP `waves`. `/forge held` says how many themes the Later gate holds and which are done, through MCP `held`; it only reads. `/forge forks <app>` says in two lines what the app follows upstream and who follows it downstream, through MCP `forks` with `kind: "chain"`. None of them lands or closes anything. | |
| 40 | ||
| 39 | 41 | `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/opencode/forge.ts +5 −2
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * forge for OpenCode: registers the forge's MCP server and a `forge` |
| 3 | * command (new, tell, status, decide, archive, undo, plan, preview, pulls, runs, log, asked). Set FORGE_URL, and FORGE_TOKEN too | |
| 3 | * command (new, tell, status, decide, archive, undo, plan, preview, pulls, runs, log, asked, waves, held, forks). Set FORGE_URL, and FORGE_TOKEN too | |
| 4 | 4 | * to skip the OAuth sign-in. |
| 5 | 5 | * No logic lives here: the tools are the forge's own (`docs/MCP.md`). |
| 6 | 6 | */ |
| @@ -21,6 +21,9 @@ const COMMAND = `Use the forge MCP tools for: $ARGUMENTS | ||
| 21 | 21 | - runs <app>: call \`runs\` with repo; say, one line each, the running runs and the last three finished: kind, issue or pull number, state, how long (startedAt to endedAt, or to now). |
| 22 | 22 | - log <app> <run>: call \`run_log\` with repo, run (the run id) and \`tail\` 40; relay the lines verbatim. |
| 23 | 23 | - asked [app]: call \`asked\` with \`show\` \`all\`, and repo when an app is given; say, one line each, flagged ones first, what was asked (title and number), its state (waiting, building, shipped or closed), the pull that closes it and the \`outcomeText\` sentence, so a parked or failed ask says why. |
| 24 | - waves <app>: call \`waves\` with repo; relay the newest three waves, one line each: version, date, headline. Read-only: it lands nothing. | |
| 25 | - held: call \`held\` with app = the app the conversation names; when none is named, ask which app before calling, since \`held\` needs one (never \`apply\`); say in two sentences how many themes the Later gate holds and which are done (the issues in \`approve\`). Read-only: never close or land anything. | |
outdated
Same: |
||
| 26 | - forks <app>: call \`forks\` with repo and kind "chain"; say in two lines what it follows upstream (parent, grandparent, or none) and who follows it downstream (children, or none), noting a severed link. Read-only. | |
| 24 | 27 | Never merge: a person lands changes.`; |
| 25 | 28 | |
| 26 | 29 | export const Forge: Plugin = async () => ({ |
| @@ -39,7 +42,7 @@ export const Forge: Plugin = async () => ({ | ||
| 39 | 42 | }; |
| 40 | 43 | config["command"] = { |
| 41 | 44 | ...config["command"], |
| 42 | forge: { description: "New, tell, status, decide, archive, undo, plan, preview, pulls, runs, log or asked on your forge", template: COMMAND }, | |
| 45 | forge: { description: "New, tell, status, decide, archive, undo, plan, preview, pulls, runs, log, asked, waves, held or forks on your forge", template: COMMAND }, | |
| 43 | 46 | }; |
| 44 | 47 | }, |
| 45 | 48 | }); |
test/plugins.test.ts +36 −2
| @@ -2,6 +2,7 @@ | ||
| 2 | 2 | import { describe, expect, test } from "bun:test"; |
| 3 | 3 | import { readFileSync } from "node:fs"; |
| 4 | 4 | import { toolNames } from "../src/http/mcp.ts"; |
| 5 | import { tool as heldTool } from "../src/http/mcp/tools/held.ts"; | |
| 5 | 6 | import { tool as askedTool } from "../src/http/mcp/tools/asked.ts"; |
| 6 | 7 | import { ASKED_STATES, askedJson, type Asked } from "../src/core/asked.ts"; |
| 7 | 8 | |
| @@ -21,7 +22,7 @@ const opencodeText = (): string => { | ||
| 21 | 22 | const backticked = (text: string): string[] => [...text.matchAll(/`([a-z][a-z0-9_]*)`/g)].map((m) => m[1]!); |
| 22 | 23 | |
| 23 | 24 | /** Backticked words that are the server or an argument, not a tool. */ |
| 24 | const NOT_TOOLS = new Set(["forge", "repo", "text", "context", "page", "run", "commit", "said", "action", "ship", "close", "status", "words", "id", "verb", "proposal", "approve", "later", "url", "tail", "name", "description", "show", "all", "outcomeText", "items", "operation", "archived", "kind", "undo"]); | |
| 25 | const NOT_TOOLS = new Set(["forge", "repo", "text", "context", "page", "run", "commit", "said", "action", "ship", "close", "status", "words", "id", "verb", "proposal", "approve", "later", "url", "tail", "name", "description", "show", "all", "outcomeText", "items", "operation", "archived", "kind", "undo", "app", "apply"]); | |
| 25 | 26 | |
| 26 | 27 | /** Tools the command text tells the agent to call: every backticked name that is not an argument. */ |
| 27 | 28 | const toolsNamed = (text: string): string[] => [...new Set(backticked(text))].filter((n) => !NOT_TOOLS.has(n)); |
| @@ -200,7 +201,7 @@ const verbDiff = (a: string, b: string): { onlyA: string[]; onlyB: string[] } => | ||
| 200 | 201 | |
| 201 | 202 | describe("the two plugin commands offer the same verbs", () => { |
| 202 | 203 | test("Claude Code and OpenCode have the same set", () => { |
| 203 | expect(verbsOf(claudeText())).toEqual(["archive", "asked", "decide", "log", "new", "plan", "preview", "pulls", "runs", "status", "tell", "undo"]); | |
| 204 | expect(verbsOf(claudeText())).toEqual(["archive", "asked", "decide", "forks", "held", "log", "new", "plan", "preview", "pulls", "runs", "status", "tell", "undo", "waves"]); | |
| 204 | 205 | expect(verbDiff(claudeText(), opencodeText())).toEqual({ onlyA: [], onlyB: [] }); |
| 205 | 206 | }); |
| 206 | 207 | |
| @@ -212,6 +213,39 @@ describe("the two plugin commands offer the same verbs", () => { | ||
| 212 | 213 | }); |
| 213 | 214 | }); |
| 214 | 215 | |
| 216 | describe("waves, held and forks reach the story, the Later gate and the fork chain", () => { | |
| 217 | for (const [label, text] of [ | |
| 218 | ["Claude Code", claudeText], | |
| 219 | ["OpenCode", opencodeText], | |
| 220 | ] as const) { | |
| 221 | test(`${label} names the three read tools, never applies, and lands nothing`, () => { | |
| 222 | const t = text(); | |
| 223 | expect(toolsNamed(t)).toEqual(expect.arrayContaining(["waves", "held", "forks"])); | |
| 224 | for (const tool of ["waves", "held", "forks"]) expect(tools.has(tool)).toBe(true); | |
| 225 | for (const verb of ["waves <app>", "held", "forks <app>"]) { | |
| 226 | const line = t.split("\n").find((l) => l.startsWith(`- ${verb}:`) || l.startsWith(`- \`${verb}\`:`))!; | |
| 227 | expect(line).toContain("Read-only"); | |
| 228 | } | |
| 229 | const heldLine = t.split("\n").find((l) => /^- `?held`?:/.test(l))!; | |
| 230 | expect(heldLine).toContain("never"); | |
| 231 | expect(heldLine).toContain("`apply`"); | |
| 232 | expect(heldLine).toMatch(/when none is named, ask which app/); | |
| 233 | expect(heldTool.inputSchema["required"]).toContain("app"); | |
| 234 | expect(t.split("\n").find((l) => /^- `?forks <app>/.test(l))).toContain("chain"); | |
| 235 | }); | |
| 236 | } | |
| 237 | ||
| 238 | test("argument hint, description and both READMEs list them", () => { | |
| 239 | expect(read("plugins/claude-code/commands/forge.md")).toMatch(/description:.*waves, held, forks/); | |
| 240 | expect(read("plugins/claude-code/commands/forge.md")).toMatch(/argument-hint:.*waves <app> \| held \| forks <app>/); | |
| 241 | expect(read("plugins/opencode/forge.ts")).toMatch(/description: "[^"]*waves, held or forks/); | |
| 242 | for (const p of ["plugins/claude-code/README.md", "plugins/opencode/README.md"]) { | |
| 243 | const r = read(p); | |
| 244 | for (const v of ["`/forge waves <app>`", "`/forge held`", "`/forge forks <app>`"]) expect(r).toContain(v); | |
| 245 | } | |
| 246 | }); | |
| 247 | }); | |
| 248 | ||
| 215 | 249 | describe("the repository is a Claude Code marketplace", () => { |
| 216 | 250 | test("marketplace.json lists forge, whose directory holds a plugin.json with the same name and version", () => { |
| 217 | 251 | const market = JSON.parse(read(".claude-plugin/marketplace.json")); |
heldrequiresapp; "when one is known" leaves the unknown case undefined. Say to ask which app, or takeheld <app>.