Public: anyone can read this; the crew can change it.
The Claude Code and OpenCode plugins' `asked [app]` says what you asked the crew and what became of each, through MCP `asked` #7459
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/7373-the-claude-code-and-opencode-plugins-ask → main 2 commits fork
6 files
changed +79
−9
plugins/claude-code/README.md +7 −1
| @@ -4,7 +4,7 @@ Set `FORGE_URL` (the forge's address, no trailing slash) and `FORGE_TOKEN` | ||
| 4 | 4 | (an `rw` token; `ro` reads only; make one at `/settings/tokens` on the forge, which also shows this line filled in), then add this directory as a plugin |
| 5 | 5 | (`claude --plugin-dir plugins/claude-code`). It registers the forge's MCP |
| 6 | 6 | server and a `/forge` command: `tell`, `status`, `decide`, `plan`, `preview`, |
| 7 | `pulls`, `runs`, `log`. | |
| 7 | `pulls`, `runs`, `log`, `asked`. | |
| 8 | 8 | |
| 9 | 9 | `/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. |
| 10 | 10 | |
| @@ -17,4 +17,10 @@ and the last three finished, one line each (kind, issue or pull, state, how | ||
| 17 | 17 | long), through MCP `runs`. `/forge log <app> <run>` relays that run's last 40 |
| 18 | 18 | lines verbatim, through MCP `run_log`. |
| 19 | 19 | |
| 20 | `/forge asked [app]` says what you asked the crew for and what became of | |
| 21 | each, shipped ones included: one line per ask (what, waiting/building/shipped/closed, | |
| 22 | the pull that closes it, and the outcome sentence: landed, who is building, | |
| 23 | parked and why, failed), flagged ones first, through MCP `asked` with | |
| 24 | `show: "all"`; with an app, only that app's asks. | |
| 25 | ||
| 20 | 26 | `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, plan, preview, pulls, runs, log | |
| 3 | argument-hint: tell <app> <words> | status | decide <approve|redirect|decline> [words] | plan [approve|redirect <words>|later] | preview <url> | pulls <app> | runs <app> | log <app> <run> | |
| 2 | description: Talk to your forge: tell, status, decide, plan, preview, pulls, runs, log, asked | |
| 3 | argument-hint: tell <app> <words> | status | decide <approve|redirect|decline> [words] | plan [approve|redirect <words>|later] | preview <url> | pulls <app> | runs <app> | log <app> <run> | asked [app] | |
| 4 | 4 | --- |
| 5 | 5 | |
| 6 | 6 | Use the `forge` MCP server's tools for `$ARGUMENTS`. |
| @@ -13,5 +13,6 @@ Use the `forge` MCP server's tools for `$ARGUMENTS`. | ||
| 13 | 13 | - `pulls <app>`: call `pulls` with `repo` = the app (owner/name); list the open pulls, one line each with its check. |
| 14 | 14 | - `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). |
| 15 | 15 | - `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. |
| 16 | - `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. | |
| 16 | 17 | |
| 17 | 18 | Keep what you relay short. Never merge: a person lands changes. |
plugins/opencode/README.md +7 −1
| @@ -4,7 +4,7 @@ Copy `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 && cp plugins/opencode/forge.ts .opencode/plugin/ && FORGE_URL=<url> FORGE_TOKEN=<token> opencode`. It registers the |
| 6 | 6 | forge's MCP server and the `forge` command: `tell`, `status`, `decide`, `plan`, |
| 7 | `preview`, `pulls`, `runs`, `log`. | |
| 7 | `preview`, `pulls`, `runs`, `log`, `asked`. | |
| 8 | 8 | `/forge plan` shows the planner's next wave (the first wave-proposal row of `asks`); |
| 9 | 9 | `/forge plan approve`, `redirect <words>` or `later` steers it through `asks_proposal`, |
| 10 | 10 | applied only after you say yes. Only the owner may act on a proposal. |
| @@ -18,6 +18,12 @@ and the last three finished, one line each (kind, issue or pull, state, how | ||
| 18 | 18 | long), through MCP `runs`. `/forge log <app> <run>` relays that run's last 40 |
| 19 | 19 | lines verbatim, through MCP `run_log`. |
| 20 | 20 | |
| 21 | `/forge asked [app]` says what you asked the crew for and what became of | |
| 22 | each, shipped ones included: one line per ask (what, waiting/building/shipped/closed, | |
| 23 | the pull that closes it, and the outcome sentence: landed, who is building, | |
| 24 | parked and why, failed), flagged ones first, through MCP `asked` with | |
| 25 | `show: "all"`; with an app, only that app's asks. | |
| 26 | ||
| 21 | 27 | Code Mode can also read `GET /contract/openapi.json`. |
| 22 | 28 | |
| 23 | 29 | `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 +3 −2
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * forge for OpenCode: registers the forge's MCP server and a `forge` |
| 3 | * command (tell, status, decide, plan, preview, pulls, runs, log). Set FORGE_URL and FORGE_TOKEN. | |
| 3 | * command (tell, status, decide, plan, preview, pulls, runs, log, asked). Set FORGE_URL and FORGE_TOKEN. | |
| 4 | 4 | * No logic lives here: the tools are the forge's own (`docs/MCP.md`). |
| 5 | 5 | */ |
| 6 | 6 | import type { Plugin } from "@opencode-ai/plugin"; |
| @@ -14,6 +14,7 @@ const COMMAND = `Use the forge MCP tools for: $ARGUMENTS | ||
| 14 | 14 | - pulls <app>: call \`pulls\` with repo; list the open pulls, one line each with its check. |
| 15 | 15 | - 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). |
| 16 | 16 | - log <app> <run>: call \`run_log\` with repo, run (the run id) and \`tail\` 40; relay the lines verbatim. |
| 17 | - 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. | |
| 17 | 18 | Never merge: a person lands changes.`; |
| 18 | 19 | |
| 19 | 20 | export const Forge: Plugin = async () => ({ |
| @@ -31,7 +32,7 @@ export const Forge: Plugin = async () => ({ | ||
| 31 | 32 | }; |
| 32 | 33 | config["command"] = { |
| 33 | 34 | ...config["command"], |
| 34 | forge: { description: "Tell, status, decide, plan, preview, pulls, runs or log on your forge", template: COMMAND }, | |
| 35 | forge: { description: "Tell, status, decide, plan, preview, pulls, runs, log or asked on your forge", template: COMMAND }, | |
| 35 | 36 | }; |
| 36 | 37 | }, |
| 37 | 38 | }); |
src/core/asked.ts +3 −1
| @@ -27,7 +27,9 @@ import { getRepo, listReposWritableTo, type Repo } from "./repos.ts"; | ||
| 27 | 27 | import { listActiveRuns } from "./runs.ts"; |
| 28 | 28 | import type { User } from "./users.ts"; |
| 29 | 29 | |
| 30 | export type AskedState = "waiting" | "building" | "shipped" | "closed"; | |
| 30 | /** Every state an ask can be in, in the order it moves through them. */ | |
| 31 | export const ASKED_STATES = ["waiting", "building", "shipped", "closed"] as const; | |
| 32 | export type AskedState = (typeof ASKED_STATES)[number]; | |
| 31 | 33 | |
| 32 | 34 | export interface AskedPull { |
| 33 | 35 | /** The pull's issue id, for reads; not part of the JSON. */ |
test/plugins.test.ts +56 −2
| @@ -2,6 +2,8 @@ | ||
| 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 askedTool } from "../src/http/mcp/tools/asked.ts"; | |
| 6 | import { ASKED_STATES, askedJson, type Asked } from "../src/core/asked.ts"; | |
| 5 | 7 | |
| 6 | 8 | const read = (path: string): string => readFileSync(new URL(`../${path}`, import.meta.url), "utf8"); |
| 7 | 9 | |
| @@ -19,7 +21,7 @@ const opencodeText = (): string => { | ||
| 19 | 21 | const backticked = (text: string): string[] => [...text.matchAll(/`([a-z][a-z0-9_]*)`/g)].map((m) => m[1]!); |
| 20 | 22 | |
| 21 | 23 | /** 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", "words", "id", "verb", "proposal", "approve", "later", "url", "tail"]); | |
| 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", "show", "all", "outcomeText"]); | |
| 23 | 25 | |
| 24 | 26 | /** Tools the command text tells the agent to call: every backticked name that is not an argument. */ |
| 25 | 27 | const toolsNamed = (text: string): string[] => [...new Set(backticked(text))].filter((n) => !NOT_TOOLS.has(n)); |
| @@ -59,6 +61,58 @@ describe("what the crew is doing on an app", () => { | ||
| 59 | 61 | }); |
| 60 | 62 | }); |
| 61 | 63 | |
| 64 | describe("what you asked the crew for", () => { | |
| 65 | for (const [label, text] of [ | |
| 66 | ["Claude Code", claudeText], | |
| 67 | ["OpenCode", opencodeText], | |
| 68 | ] as const) { | |
| 69 | test(`${label} names asked, which the forge lists`, () => { | |
| 70 | expect(toolsNamed(text())).toContain("asked"); | |
| 71 | expect(tools.has("asked")).toBe(true); | |
| 72 | expect(text()).toMatch(/asked \[app\]/); | |
| 73 | expect(text()).toContain("flagged ones first"); | |
| 74 | expect(text()).toContain("`outcomeText`"); | |
| 75 | }); | |
| 76 | ||
| 77 | test(`${label} passes only arguments asked takes, and lists exactly the states it returns`, () => { | |
| 78 | const line = text().split("\n").find((l) => /^- `?asked \[app\]/.test(l))!; | |
| 79 | const props = askedTool.inputSchema["properties"] as Record<string, { enum?: string[] }>; | |
| 80 | expect(Object.keys(props)).toContain("repo"); | |
| 81 | expect(line).toMatch(/`show`\s*(=\s*)?`all`/); | |
| 82 | expect(props["show"]!.enum).toContain("all"); | |
| 83 | const listed = /its state \(([^)]*)\)/.exec(line)![1]!.split(/,\s*|\s+or\s+/); | |
| 84 | expect(listed).toEqual([...ASKED_STATES]); | |
| 85 | }); | |
| 86 | } | |
| 87 | ||
| 88 | test("the outcome sentence the texts name is a field asked returns, and says why a parked ask waits", () => { | |
| 89 | const parked = { | |
| 90 | source: "issue", | |
| 91 | repo: { ownerName: "ada", name: "notes" }, | |
| 92 | issue: { number: 7, title: "Export to CSV", state: "open" }, | |
| 93 | askedBy: "ada", | |
| 94 | askedAt: "2026-10-01T00:00:00Z", | |
| 95 | pulls: [], | |
| 96 | state: "waiting", | |
| 97 | stale: true, | |
| 98 | flagged: true, | |
| 99 | lastCrewAt: null, | |
| 100 | doneBy: [], | |
| 101 | outcome: { kind: "parked", note: "needs a schema decision" }, | |
| 102 | } as unknown as Asked; | |
| 103 | expect(askedJson(parked).outcomeText).toBe("parked: needs a schema decision"); | |
| 104 | }); | |
| 105 | ||
| 106 | test("a command that drops asked is caught", () => { | |
| 107 | expect(toolsNamed("- asked [app]: call `asks` with `repo`")).not.toContain("asked"); | |
| 108 | }); | |
| 109 | ||
| 110 | test("both READMEs and the argument hint list it", () => { | |
| 111 | expect(read("plugins/claude-code/commands/forge.md")).toMatch(/argument-hint:.*\| asked \[app\]/); | |
| 112 | for (const p of ["plugins/claude-code/README.md", "plugins/opencode/README.md"]) expect(read(p)).toContain("`/forge asked [app]`"); | |
| 113 | }); | |
| 114 | }); | |
| 115 | ||
| 62 | 116 | test("a renamed tool is caught", () => { |
| 63 | 117 | expect(toolsNamed("call `tell_renamed` with `repo`").filter((n) => !tools.has(n))).toEqual(["tell_renamed"]); |
| 64 | 118 | expect(backticked("`a_b` and `C`")).toEqual(["a_b"]); |
| @@ -120,7 +174,7 @@ const verbDiff = (a: string, b: string): { onlyA: string[]; onlyB: string[] } => | ||
| 120 | 174 | |
| 121 | 175 | describe("the two plugin commands offer the same verbs", () => { |
| 122 | 176 | test("Claude Code and OpenCode have the same set", () => { |
| 123 | expect(verbsOf(claudeText())).toEqual(["decide", "log", "plan", "preview", "pulls", "runs", "status", "tell"]); | |
| 177 | expect(verbsOf(claudeText())).toEqual(["asked", "decide", "log", "plan", "preview", "pulls", "runs", "status", "tell"]); | |
| 124 | 178 | expect(verbDiff(claudeText(), opencodeText())).toEqual({ onlyA: [], onlyB: [] }); |
| 125 | 179 | }); |
| 126 | 180 |
new above
offline