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

Merged opened this

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

Merged

mason/forge:mason/11481-the-claude-code-and-opencode-commands-ga → main 2 commits fork 2 threads · 2 unresolved

  • Merged. mason/11481-the-claude-code-and-opencode-commands-ga was merged into main as e409fe8.

Review

Sign in to review this change.
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 &&
88
99 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
1010 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`.
1212
1313 **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.
1414
@@ -35,4 +35,6 @@ the pull that closes it, and the outcome sentence: landed, who is building,
3535 parked and why, failed), flagged ones first, through MCP `asked` with
3636 `show: "all"`; with an app, only that app's asks.
3737
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
3840 `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 @@
11 ---
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>
44 ---
55
66 Use the `forge` MCP server's tools for `$ARGUMENTS`.
@@ -18,5 +18,8 @@ Use the `forge` MCP server's tools for `$ARGUMENTS`.
1818 - `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).
1919 - `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.
2020 - `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.

held requires app; "when one is known" leaves the unknown case undefined. Say to ask which app, or take held <app>.

2124
2225 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`
44 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):
55 `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
66 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`.
88 `/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.
99
1010 `/forge plan` shows the planner's next wave (the first wave-proposal row of `asks`);
@@ -36,4 +36,6 @@ neither set, it registers nothing.
3636
3737 Code Mode can also read `GET /contract/openapi.json`.
3838
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
3941 `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 @@
11 /**
22 * 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
44 * to skip the OAuth sign-in.
55 * No logic lives here: the tools are the forge's own (`docs/MCP.md`).
66 */
@@ -21,6 +21,9 @@ const COMMAND = `Use the forge MCP tools for: $ARGUMENTS
2121 - 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).
2222 - log <app> <run>: call \`run_log\` with repo, run (the run id) and \`tail\` 40; relay the lines verbatim.
2323 - 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: held needs app; say what to do when none is given.

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.
2427 Never merge: a person lands changes.`;
2528
2629 export const Forge: Plugin = async () => ({
@@ -39,7 +42,7 @@ export const Forge: Plugin = async () => ({
3942 };
4043 config["command"] = {
4144 ...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 },
4346 };
4447 },
4548 });
test/plugins.test.ts +36 −2
@@ -2,6 +2,7 @@
22 import { describe, expect, test } from "bun:test";
33 import { readFileSync } from "node:fs";
44 import { toolNames } from "../src/http/mcp.ts";
5 import { tool as heldTool } from "../src/http/mcp/tools/held.ts";
56 import { tool as askedTool } from "../src/http/mcp/tools/asked.ts";
67 import { ASKED_STATES, askedJson, type Asked } from "../src/core/asked.ts";
78
@@ -21,7 +22,7 @@ const opencodeText = (): string => {
2122 const backticked = (text: string): string[] => [...text.matchAll(/`([a-z][a-z0-9_]*)`/g)].map((m) => m[1]!);
2223
2324 /** 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"]);
2526
2627 /** Tools the command text tells the agent to call: every backticked name that is not an argument. */
2728 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[] } =>
200201
201202 describe("the two plugin commands offer the same verbs", () => {
202203 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"]);
204205 expect(verbDiff(claudeText(), opencodeText())).toEqual({ onlyA: [], onlyB: [] });
205206 });
206207
@@ -212,6 +213,39 @@ describe("the two plugin commands offer the same verbs", () => {
212213 });
213214 });
214215
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
215249 describe("the repository is a Claude Code marketplace", () => {
216250 test("marketplace.json lists forge, whose directory holds a plugin.json with the same name and version", () => {
217251 const market = JSON.parse(read(".claude-plugin/marketplace.json"));

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs