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

Merged opened this

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

Merged

torque/forge:torque/7373-the-claude-code-and-opencode-plugins-ask → main 2 commits fork

  • Merged. torque/7373-the-claude-code-and-opencode-plugins-ask was merged into main as 9c1734a.

Review

Sign in to review this change.
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`
44 (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
55 (`claude --plugin-dir plugins/claude-code`). It registers the forge's MCP
66 server and a `/forge` command: `tell`, `status`, `decide`, `plan`, `preview`,
7 `pulls`, `runs`, `log`.
7 `pulls`, `runs`, `log`, `asked`.
88
99 `/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.
1010
@@ -17,4 +17,10 @@ and the last three finished, one line each (kind, issue or pull, state, how
1717 long), through MCP `runs`. `/forge log <app> <run>` relays that run's last 40
1818 lines verbatim, through MCP `run_log`.
1919
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
2026 `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, 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]
44 ---
55
66 Use the `forge` MCP server's tools for `$ARGUMENTS`.
@@ -13,5 +13,6 @@ Use the `forge` MCP server's tools for `$ARGUMENTS`.
1313 - `pulls <app>`: call `pulls` with `repo` = the app (owner/name); list the open pulls, one line each with its check.
1414 - `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).
1515 - `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.
1617
1718 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`
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 && cp plugins/opencode/forge.ts .opencode/plugin/ && FORGE_URL=<url> FORGE_TOKEN=<token> opencode`. It registers the
66 forge's MCP server and the `forge` command: `tell`, `status`, `decide`, `plan`,
7 `preview`, `pulls`, `runs`, `log`.
7 `preview`, `pulls`, `runs`, `log`, `asked`.
88 `/forge plan` shows the planner's next wave (the first wave-proposal row of `asks`);
99 `/forge plan approve`, `redirect <words>` or `later` steers it through `asks_proposal`,
1010 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
1818 long), through MCP `runs`. `/forge log <app> <run>` relays that run's last 40
1919 lines verbatim, through MCP `run_log`.
2020
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
2127 Code Mode can also read `GET /contract/openapi.json`.
2228
2329 `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 @@
11 /**
22 * 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.
44 * No logic lives here: the tools are the forge's own (`docs/MCP.md`).
55 */
66 import type { Plugin } from "@opencode-ai/plugin";
@@ -14,6 +14,7 @@ const COMMAND = `Use the forge MCP tools for: $ARGUMENTS
1414 - pulls <app>: call \`pulls\` with repo; list the open pulls, one line each with its check.
1515 - 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).
1616 - 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.
1718 Never merge: a person lands changes.`;
1819
1920 export const Forge: Plugin = async () => ({
@@ -31,7 +32,7 @@ export const Forge: Plugin = async () => ({
3132 };
3233 config["command"] = {
3334 ...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 },
3536 };
3637 },
3738 });
src/core/asked.ts +3 −1
@@ -27,7 +27,9 @@ import { getRepo, listReposWritableTo, type Repo } from "./repos.ts";
2727 import { listActiveRuns } from "./runs.ts";
2828 import type { User } from "./users.ts";
2929
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];
3133
3234 export interface AskedPull {
3335 /** The pull's issue id, for reads; not part of the JSON. */
test/plugins.test.ts +56 −2
@@ -2,6 +2,8 @@
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 askedTool } from "../src/http/mcp/tools/asked.ts";
6 import { ASKED_STATES, askedJson, type Asked } from "../src/core/asked.ts";
57
68 const read = (path: string): string => readFileSync(new URL(`../${path}`, import.meta.url), "utf8");
79
@@ -19,7 +21,7 @@ const opencodeText = (): string => {
1921 const backticked = (text: string): string[] => [...text.matchAll(/`([a-z][a-z0-9_]*)`/g)].map((m) => m[1]!);
2022
2123 /** 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"]);
2325
2426 /** Tools the command text tells the agent to call: every backticked name that is not an argument. */
2527 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", () => {
5961 });
6062 });
6163
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
62116 test("a renamed tool is caught", () => {
63117 expect(toolsNamed("call `tell_renamed` with `repo`").filter((n) => !tools.has(n))).toEqual(["tell_renamed"]);
64118 expect(backticked("`a_b` and `C`")).toEqual(["a_b"]);
@@ -120,7 +174,7 @@ const verbDiff = (a: string, b: string): { onlyA: string[]; onlyB: string[] } =>
120174
121175 describe("the two plugin commands offer the same verbs", () => {
122176 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"]);
124178 expect(verbDiff(claudeText(), opencodeText())).toEqual({ onlyA: [], onlyB: [] });
125179 });
126180

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs