Public: anyone can read this; the crew can change it.
The MCP answers the decisions waiting for the owner as a ui:// card with ship and decline on it #11852
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/11816-the-mcp-answers-the-decisions-waiting-fo → main 3 commits fork 2 threads · 2 unresolved
4 files
changed +175
−2
docs/MCP.md +1 −1
| @@ -518,7 +518,7 @@ answers | ||
| 518 | 518 | | --- | --- | --- | |
| 519 | 519 | | `ui://forge/app` | `apps`, `new_app` | Your apps, or the app just made: live or not, what is working, a Tell box. | |
| 520 | 520 | | `ui://forge/run` | — | Legacy running-now list; `now` draws `crew` instead. | |
| 521 | | `ui://forge/asks` | `asks`, `asks_act` | One row per waiting decision with its button; after a press, the receipt. | | |
| 521 | | `ui://forge/asks` | `asks`, `asks_act` | One row per waiting decision with its button; a press plans, the Confirm card (the page's own, e.g. "Close 1") does it, then the receipt. | | |
| 522 | 522 | | `ui://forge/wave` | `waves` | One wave: headline, highlights with their why, pull numbers. | |
| 523 | 523 | | `ui://forge/map` | `map` | The plan: per pillar ready, working and waiting counts, the first ready issues. | |
| 524 | 524 | | `ui://forge/feed` | `feed` | The newest timeline items, at most 20, each linked; how many are new. | |
src/http/mcp/tools/asks_act.ts +4 −1
| @@ -49,7 +49,10 @@ export const tool: Tool = { | ||
| 49 | 49 | } |
| 50 | 50 | if (args["dryRun"] !== false) { |
| 51 | 51 | const plan = previewAsksBulk(ctx.app, user, args["action"], args["items"], args["reason"]); |
| 52 | return plan.ok ? good(plan.value) : bad(plan.error.code, plan.error.message); | |
| 52 | // The card's Confirm button re-sends these with `dryRun: false`: the page's own two steps. | |
| 53 | return plan.ok | |
| 54 | ? { ...good(plan.value), card: { action: args["action"], items: args["items"], reason: args["reason"] } } | |
| 55 | : bad(plan.error.code, plan.error.message); | |
| 53 | 56 | } |
| 54 | 57 | const done = await applyAsksBulk(ctx.app, user, args["action"], args["items"], args["reason"]); |
| 55 | 58 | return done.ok ? good({ ...done.value, receipt: receiptOf(done.value) }) : bad(done.error.code, done.error.message); |
src/web/studio/cards.ts +26
| @@ -9,6 +9,7 @@ | ||
| 9 | 9 | |
| 10 | 10 | import { html, raw } from "../html.ts"; |
| 11 | 11 | import { timeAgo } from "../ui.ts"; |
| 12 | import { VERB_WORDS } from "../../actions/asks_words.ts"; | |
| 12 | 13 | import { span, STAGE_LABEL, verdict, type PipelineKey, type PipelineStage } from "../../flow/pipeline.ts"; |
| 13 | 14 | |
| 14 | 15 | export type CardKind = "app" | "run" | "asks" | "wave" | "map" | "feed" | "pull" | "pulls" | "runs" | "asked" | "explore" | "issue" | "flow" | "scorecard" | "crew" | "board" | "forks" | "milestones" | "release" | "releases" | "search" | "issues" | "forges" | "held" | "drill" | "reports" | "usage" | "health" | "capacity"; |
| @@ -415,6 +416,31 @@ export function cardFor( | ||
| 415 | 416 | // A plan (`dryRun`) or a `listen` has no `receipt`. |
| 416 | 417 | const v = obj(value); |
| 417 | 418 | const operation = text(v["id"]); |
| 419 | // A plan (`dryRun`) is the page's confirm: what each ask would become, and the one button that does it. | |
| 420 | if (text(v["receipt"]) === "" && obj(v["counts"])["change"] !== undefined && Array.isArray(v["rows"])) { | |
| 421 | const verb = VERB_WORDS[text(extra["action"])] ?? text(extra["action"]); | |
| 422 | const items = list(extra["items"]).map(text).filter((i) => i !== "").slice(0, 100); // asks_act accepts at most 100 | |
| 423 | const change = Number(obj(v["counts"])["change"]); | |
| 424 | const skipped = list(v["skipped"]).map(obj); | |
| 425 | const shown = (x: unknown): string => (x === null || x === undefined ? "nobody" : Array.isArray(x) ? x.map(text).join(", ") : text(x)); | |
| 426 | for (const r of list(v["rows"]).map(obj).slice(0, LIMIT)) | |
| 427 | rows.push({ label: text(r["label"]), value: `${shown(r["before"])} → ${shown(r["after"])}${text(r["reason"]) === "" ? "" : ` · ${text(r["reason"])}`} · ${text(r["outcome"])}` }); | |
| 428 | for (const s of skipped.slice(0, LIMIT)) rows.push({ label: text(s["item"]), value: `${text(s["reason"])} · skipped` }); | |
| 429 | const refused = (Number(obj(v["counts"])["refused"]) || 0) + skipped.length; | |
| 430 | if (change > 0 && items.length > 0) | |
| 431 | actions.push({ | |
| 432 | label: `${verb} ${change}`, | |
| 433 | tool: "asks_act", | |
| 434 | args: { action: text(extra["action"]), items, ...(text(extra["reason"]) === "" ? {} : { reason: text(extra["reason"]) }), dryRun: false }, | |
| 435 | }); | |
| 436 | return { | |
| 437 | kind, | |
Forwards every item the caller sent, uncapped and unvalidated, into the button args. Slice to 100 (asks_act's limit) and drop empty strings; add a test. |
||
| 438 | title: "Confirm", | |
| 439 | status: `${verb}: ${change} of ${items.length} · ${refused} refused or skipped · ${v["reversible"] === true ? "undo afterwards" : "not undone afterwards"}`, | |
| 440 | rows, | |
| 441 | actions, | |
| 442 | }; | |
| 443 | } | |
| 418 | 444 | if (text(v["receipt"]) === "" || operation === "") return undefined; |
| 419 | 445 | rows.push({ label: "Receipt", value: text(v["receipt"]) }); |
| 420 | 446 | if (v["undoOf"] == null && v["undoneBy"] == null) |
test/mcp-asks-card-decide.test.ts added +144
| @@ -0,0 +1,144 @@ | ||
| 1 | /** | |
| 2 | * The inbox card decides (#11816): `asks` lists what waits, the card's Decline | |
| 3 | * is a plan whose card carries the page's confirm, pressing it settles with | |
| 4 | * the page's receipt, and the page then shows the result. The token's scope | |
| 5 | * is the only authority: a read-only token plans but cannot confirm. | |
| 6 | */ | |
| 7 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { receiptSentence } from "../src/actions/asks_words.ts"; | |
| 12 | import { openIssue } from "../src/actions/issues.ts"; | |
| 13 | import { createRepo } from "../src/actions/repos.ts"; | |
| 14 | import { createApp, type App } from "../src/app.ts"; | |
| 15 | import { getIssueById, setAssignee } from "../src/core/issues.ts"; | |
| 16 | import { createToken } from "../src/core/tokens.ts"; | |
| 17 | import { createUser } from "../src/core/users.ts"; | |
| 18 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 19 | import { quietLogs } from "./helpers/globals.ts"; | |
| 20 | ||
| 21 | quietLogs(); | |
| 22 | ||
| 23 | let dataDir = ""; | |
| 24 | let app: App; | |
| 25 | let servers: Servers; | |
| 26 | let rw = ""; | |
| 27 | let ro = ""; | |
| 28 | let cookie = ""; | |
| 29 | let issueId = ""; | |
| 30 | let owner: any; | |
| 31 | let repoRow: any; | |
| 32 | ||
| 33 | const rpc = async (token: string, name: string, args: Record<string, unknown>) => { | |
| 34 | const res = await fetch(`${servers.url}/mcp`, { | |
| 35 | method: "POST", | |
| 36 | headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` }, | |
| 37 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }), | |
| 38 | }); | |
| 39 | return (await res.json()) as any; | |
| 40 | }; | |
| 41 | const page = async (): Promise<string> => | |
| 42 | (await (await fetch(`${servers.url}/asks`, { headers: { cookie: `${cookie}; forge_mode=studio` } })).text()).replace(/\s+/g, " "); | |
| 43 | ||
| 44 | beforeAll(async () => { | |
| 45 | dataDir = await mkdtemp(join(tmpdir(), "forge-asks-card-")); | |
| 46 | app = createApp({ dataDir, port: 0 }); | |
| 47 | servers = await serve(app); | |
| 48 | const made = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-long-card-passphrase", adminIfFirst: true }); | |
| 49 | if (!made.ok) throw new Error(made.error); | |
| 50 | rw = createToken(app.db, made.value.id, "rw", "rw").token; | |
| 51 | ro = createToken(app.db, made.value.id, "ro", "ro").token; | |
| 52 | const res = await fetch(`${servers.url}/signin`, { method: "POST", body: new URLSearchParams({ username: "alice", password: "a-long-card-passphrase" }), redirect: "manual" }); | |
| 53 | cookie = (res.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 54 | const repo = await createRepo(app, made.value, { name: "inbox", visibility: "public" }); | |
| 55 | if (!repo.ok) throw new Error(String(repo.error)); | |
| 56 | const issue = openIssue(app, made.value, repo.value, { title: "Wipe the board", body: "" }); | |
| 57 | if (!issue.ok) throw new Error(String(issue.error)); | |
| 58 | setAssignee(app.db, issue.value.id, made.value.id); | |
| 59 | issueId = issue.value.id; | |
| 60 | owner = made.value; | |
| 61 | repoRow = repo.value; | |
| 62 | }, 30_000); | |
| 63 | ||
| 64 | afterAll(async () => { | |
| 65 | await servers.stop(); | |
| 66 | app.close(); | |
| 67 | await rm(dataDir, { recursive: true, force: true }); | |
| 68 | }); | |
| 69 | ||
| 70 | test("list, plan, confirm from the card, and the page shows the result", async () => { | |
| 71 | const listed = await rpc(rw, "asks", {}); | |
| 72 | const row = listed.result.structuredContent.card.rows.find((r: any) => r.actions?.[0]?.args?.items?.includes(`assigned:${issueId}`)); | |
| 73 | expect(row.actions[0].label).toBe("Decline"); | |
| 74 | expect(await page()).toContain("Wipe the board"); | |
| 75 | ||
| 76 | // Pressing Decline plans: nothing changes, and the card is the page's confirm. | |
| 77 | const press = row.actions[0]; | |
| 78 | const planned = await rpc(rw, press.tool, press.args); | |
| 79 | expect(getIssueById(app.db, issueId)!.state).toBe("open"); | |
| 80 | const confirm = planned.result.structuredContent.card; | |
| 81 | expect(confirm.title).toBe("Confirm"); | |
| 82 | expect(confirm.status).toBe("Close: 1 of 1 · 0 refused or skipped · undo afterwards"); | |
| 83 | expect(confirm.rows[0].value).toContain("change"); | |
| 84 | expect(confirm.actions.map((a: any) => a.label)).toEqual(["Close 1"]); | |
| 85 | expect(confirm.actions[0].args).toEqual({ action: "close", items: [`assigned:${issueId}`], dryRun: false }); | |
| 86 | ||
| 87 | // A read-only token may plan, but the confirm is refused: the card widens nothing. | |
| 88 | const refused = await rpc(ro, confirm.actions[0].tool, confirm.actions[0].args); | |
| 89 | expect(refused.result.isError).toBe(true); | |
| 90 | expect(getIssueById(app.db, issueId)!.state).toBe("open"); | |
| 91 | ||
| 92 | // Confirming returns the page's receipt and the page shows it. | |
| 93 | const done = await rpc(rw, confirm.actions[0].tool, confirm.actions[0].args); | |
| 94 | const receipt = receiptSentence("close", 1, false); | |
| 95 | expect(JSON.parse(done.result.content[0].text).receipt).toBe(receipt); | |
| 96 | expect(done.result.structuredContent.card.status).toBe(receipt); | |
| 97 | expect(getIssueById(app.db, issueId)!.state).toBe("closed"); | |
| 98 | expect(await page()).not.toContain("Wipe the board"); | |
| 99 | }); | |
| 100 | ||
| 101 | test("review: reason, nothing-changes, ok:false, the 100 cap, and the page's words", async () => { | |
| 102 | const fresh = openIssue(app, owner, repoRow, { title: "Second chore", body: "" }); | |
| 103 | if (!fresh.ok) throw new Error(String(fresh.error)); | |
| 104 | setAssignee(app.db, fresh.value.id, owner.id); | |
| 105 | issueId = fresh.value.id; | |
| 106 | // Box 5: the card's row says what the page says for the same ask. | |
| 107 | const listed = await rpc(rw, "asks", {}); | |
| 108 | const row = listed.result.structuredContent.card.rows.find((r: any) => r.actions?.[0]?.args?.items?.includes(`assigned:${issueId}`)); | |
| 109 | const html = await page(); | |
| 110 | expect(row.label).toContain("Second chore"); | |
| 111 | expect(html).toContain("Second chore"); | |
| 112 | expect(row.label.toLowerCase()).toContain("assigned to you"); | |
| 113 | expect(html).toContain("Assigned to you"); | |
| 114 | ||
| 115 | // Box 1: a reason rides on the Confirm args. | |
| 116 | const withReason = await rpc(rw, "asks_act", { action: "close", items: [`assigned:${issueId}`], reason: "not now" }); | |
| 117 | const c1 = withReason.result.structuredContent.card; | |
| 118 | expect(c1.actions[0].args).toEqual({ action: "close", items: [`assigned:${issueId}`], reason: "not now", dryRun: false }); | |
| 119 | ||
| 120 | // Box 2: nothing changes -> skipped rows, the count, no button. | |
| 121 | const none = await rpc(rw, "asks_act", { action: "close", items: ["assigned:nosuchask"] }); | |
| 122 | const c2 = none.result.structuredContent.card; | |
| 123 | expect(c2.actions).toEqual([]); | |
| 124 | expect(c2.status).toMatch(/^Close: 0 of 1 · [1-9]\d* refused or skipped · /); | |
| 125 | expect(c2.status).not.toContain("NaN"); | |
| 126 | expect(c2.rows.length).toBeGreaterThan(0); | |
| 127 | ||
| 128 | // Box 3: a refused plan returns the error and no card. | |
| 129 | const bad = await rpc(rw, "asks_act", { action: "close", items: [] }); | |
| 130 | expect(bad.result.isError).toBe(true); | |
| 131 | expect(bad.result.structuredContent?.card).toBeUndefined(); | |
| 132 | ||
| 133 | // Box 4: the confirm hands back at most 100 items, none empty. | |
| 134 | const { cardFor } = await import("../src/web/studio/cards.ts"); | |
| 135 | const many = Array.from({ length: 150 }, (_, n) => (n % 10 === 0 ? "" : `assigned:x${n}`)); | |
| 136 | const plan = { counts: { change: 1, refused: 0 }, rows: [{ label: "a", before: "x", after: "y", outcome: "change" }], skipped: [], reversible: true }; | |
| 137 | const capped = cardFor("asks_act", plan, "", { action: "close", items: many })!; | |
| 138 | const sent = capped.actions[0]!.args!["items"] as string[]; | |
| 139 | expect(sent.length).toBe(100); | |
| 140 | expect(sent.includes("")).toBe(false); | |
| 141 | // A missing `refused` count never prints NaN. | |
| 142 | const noCount = cardFor("asks_act", { ...plan, counts: { change: 1 } }, "", { action: "close", items: ["assigned:a"] })!; | |
| 143 | expect(noCount.status).toContain("0 refused or skipped"); | |
| 144 | }); |
new above
offline
Number(counts.refused)is NaN if the field is missing, which would print 'NaN refused or skipped'. Default it to 0. The skipped-only and reason paths in this block have no test (see review body).