Public: anyone can read this; the crew can change it.
asks, now and milestones answer with their ui:// card, like apps, flow and waves already do #11080
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11066-asks-now-and-milestones-answer-with-thei → main 2 commits fork
4 files
changed +162
−8
docs/MCP.md +2 −2
| @@ -58,7 +58,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`; | ||
| 58 | 58 | | `archive` | — | Archive or wake a repository (`archived`) | 332 | |
| 59 | 59 | | `asked` | — | What people asked the crew for: unmerged by default, oldest first | 372 | |
| 60 | 60 | | `asks` | — | What waits on you, most important first; saved filter applies | 457 | |
| 61 | | `asks_act` | `dismiss`, `decline`, `mute`, `listen`, `undo`, `ship`, `close`, `replan` | Decide asks (`items`): dismiss, ship, close (`reason`), mute, replan; `listen` (`repo`) unmutes; `undo` (`operation`) | 486 | | |
| 61 | | `asks_act` | `dismiss`, `decline`, `mute`, `listen`, `undo`, `ship`, `close`, `replan` | Decide asks (`items`): dismiss, ship, close (`reason`), mute, replan; `listen` (`repo`) unmutes; `undo` (`operation`) | 539 | | |
| 62 | 62 | | `asks_decide` | — | Answer a hand-over: the decider (`parked:<id>`), or `give_back` to its builder (`handover:`/`assigned:<id>`) | 270 | |
| 63 | 63 | | `asks_proposal` | — | Act on a wave proposal: `approve`, `redirect` (`words`), `later` (`again`) | 315 | |
| 64 | 64 | | `asks_undecide` | — | Put a set-aside card back on your board, within a day | 208 | |
| @@ -139,7 +139,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`; | ||
| 139 | 139 | | `map` | — | An app's plan: ready or waiting, critical path, crew, parkedTwice; `done` adds closed; `first: N` ranks N first | 375 | |
| 140 | 140 | | `merge` | — | Land a pull, as its page's Merge button does; a person's only | 319 | |
| 141 | 141 | | `migrate` | — | Bring a repo in by public `url` (`name`, `crew`), history and all | 269 | |
| 142 | | `milestones` | — | How far each ask is | 385 | | |
| 142 | | `milestones` | — | How far each ask is | 391 | | |
| 143 | 143 | | `mint_invite` | — | Mint a one-use invite link, shown once, valid `days` (1–30, default 7) | 225 | |
| 144 | 144 | | `mirror_now` | — | Admin: bring the snapshot mirror level now | 129 | |
| 145 | 145 | | `mirror_set` | — | Admin: set or forget the snapshot mirror | 296 | |
src/web/studio/cards.ts +23 −5
| @@ -11,7 +11,7 @@ import { html, raw } from "../html.ts"; | ||
| 11 | 11 | import { timeAgo } from "../ui.ts"; |
| 12 | 12 | import { span, STAGE_LABEL, verdict, type PipelineKey, type PipelineStage } from "../../flow/pipeline.ts"; |
| 13 | 13 | |
| 14 | export type CardKind = "app" | "run" | "decision" | "wave" | "map" | "feed" | "pull" | "pulls" | "asked" | "explore" | "issue" | "flow" | "crew" | "board" | "forks"; | |
| 14 | export type CardKind = "app" | "run" | "decision" | "wave" | "map" | "feed" | "pull" | "pulls" | "asked" | "explore" | "issue" | "flow" | "crew" | "board" | "forks" | "milestones"; | |
| 15 | 15 | |
| 16 | 16 | export interface CardRow { |
| 17 | 17 | readonly label: string; |
| @@ -42,7 +42,7 @@ const here = (origin: string, path: string): boolean => origin !== "" && /^\/(?! | ||
| 42 | 42 | |
| 43 | 43 | export const CARD_MIME = "text/html;profile=mcp-app"; |
| 44 | 44 | export const CARD_URI = (kind: CardKind): string => `ui://forge/${kind}`; |
| 45 | export const CARD_KINDS: readonly CardKind[] = ["app", "run", "decision", "wave", "map", "feed", "pull", "pulls", "asked", "explore", "issue", "flow", "crew", "board", "forks"]; | |
| 45 | export const CARD_KINDS: readonly CardKind[] = ["app", "run", "decision", "wave", "map", "feed", "pull", "pulls", "asked", "explore", "issue", "flow", "crew", "board", "forks", "milestones"]; | |
| 46 | 46 | |
| 47 | 47 | /** The tool whose answer each card draws. */ |
| 48 | 48 | export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = { |
| @@ -50,7 +50,8 @@ export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = { | ||
| 50 | 50 | now: "run", |
| 51 | 51 | runs: "run", |
| 52 | 52 | asks: "decision", |
| 53 | milestones: "wave", | |
| 53 | milestones: "milestones", | |
| 54 | asks_act: "decision", | |
| 54 | 55 | waves: "wave", |
| 55 | 56 | map: "map", |
| 56 | 57 | feed: "feed", |
| @@ -82,6 +83,7 @@ const CARD_TITLE: Readonly<Record<CardKind, string>> = { | ||
| 82 | 83 | crew: "Crew", |
| 83 | 84 | board: "Board", |
| 84 | 85 | forks: "Forks", |
| 86 | milestones: "Milestones", | |
| 85 | 87 | }; |
| 86 | 88 | |
| 87 | 89 | type Obj = Record<string, unknown>; |
| @@ -207,6 +209,17 @@ export function cardFor( | ||
| 207 | 209 | if (v["truncated"] === true) rows.push({ label: "Truncated", value: "more forks exist than the tool returned; ask with a larger limit or a smaller depth" }); |
| 208 | 210 | return { kind, title: CARD_TITLE[kind], status: `${nodes.length} repositor${nodes.length === 1 ? "y" : "ies"} · from ${text(v["root"])}`, rows, actions }; |
| 209 | 211 | } |
| 212 | if (tool === "asks_act") { | |
| 213 | // Only a done bulk action has a card: its receipt, and the undo that takes it back. | |
| 214 | // A plan (`dryRun`) or a `listen` has no `receipt`. | |
| 215 | const v = obj(value); | |
| 216 | const operation = text(v["id"]); | |
| 217 | if (text(v["receipt"]) === "" || operation === "") return undefined; | |
| 218 | rows.push({ label: "Receipt", value: text(v["receipt"]) }); | |
| 219 | if (v["undoOf"] == null && v["undoneBy"] == null) | |
| 220 | actions.push({ label: "Undo", tool: "asks_act", args: { action: "undo", operation } }); | |
| 221 | return { kind, title: "Asks decided", status: text(v["receipt"]), rows, actions }; | |
| 222 | } | |
| 210 | 223 | if (kind === "crew") { |
| 211 | 224 | // `scorecard` answers `{ agents: [{ username, all, recent }], keeperDays: [{ landed }] }`. |
| 212 | 225 | const v = obj(value); |
| @@ -378,9 +391,10 @@ export function cardFor( | ||
| 378 | 391 | status = runs.length === 0 ? "nothing running" : `${runs.length} going`; |
| 379 | 392 | for (const r of runs.slice(0, LIMIT)) { |
| 380 | 393 | const cost = Number(r["costUsd"]) > 0 ? ` · $${Number(r["costUsd"]).toFixed(2)}` : ""; |
| 394 | const since = text(r["since"]) === "" ? "" : ` · ${timeAgo(text(r["since"]))}`; | |
| 381 | 395 | rows.push({ |
| 382 | 396 | label: `${text(r["who"])} on ${text(r["app"])}`, |
| 383 | value: `${text(r["on"])} — ${text(r["state"])}${cost}`, | |
| 397 | value: `${text(r["on"]) === "" ? "" : `${text(r["on"])} — `}${text(r["state"])}${since}${cost}`, | |
| 384 | 398 | ...(web(origin) && /^[^/]+\/[^/]+$/.test(text(r["app"])) && RUN_ID.test(text(r["run"])) |
| 385 | 399 | ? { link: `${origin}/${text(r["app"])}/runs/${text(r["run"])}` } |
| 386 | 400 | : {}), |
| @@ -396,12 +410,13 @@ export function cardFor( | ||
| 396 | 410 | const repo = text(a["repo"]); |
| 397 | 411 | const number = Number(a["number"]); |
| 398 | 412 | const page = a["numberOf"] === "pull" ? "pulls" : a["numberOf"] === "issue" ? "issues" : ""; |
| 413 | const at = text(a["link"]); | |
| 399 | 414 | rows.push({ |
| 400 | 415 | label: text(a["line"]), |
| 401 | 416 | value: `${text(words["decision"])} ${list(words["options"]).map(text).join(" / ")}`.trim(), |
| 402 | 417 | ...(web(origin) && slugOf(repo) && Number.isInteger(number) && number > 0 && page !== "" |
| 403 | 418 | ? { link: `${origin}/${repo}/${page}/${number}` } |
| 404 | : {}), | |
| 419 | : web(origin) && here(origin, at) ? { link: origin + at } : {}), | |
| 405 | 420 | }); |
| 406 | 421 | } |
| 407 | 422 | const first = asks[0]; |
| @@ -414,6 +429,7 @@ export function cardFor( | ||
| 414 | 429 | if (kindOf === "look" || kindOf === "review") |
| 415 | 430 | actions.push({ label: "Approve", tool: "asks_act", args: { action: "ship", items } }); |
| 416 | 431 | actions.push({ label: "Decline", tool: "asks_act", args: { action: "close", items } }); |
| 432 | actions.push({ label: "Dismiss", tool: "asks_act", args: { action: "dismiss", items } }); | |
| 417 | 433 | const repo = text(first["repo"]); |
| 418 | 434 | const number = Number(first["number"]); |
| 419 | 435 | if (repo !== "" && Number.isInteger(number) && number > 0) |
| @@ -486,6 +502,8 @@ export function cardFor( | ||
| 486 | 502 | }); |
| 487 | 503 | } |
| 488 | 504 | } else { |
| 505 | // One milestone opened (`issue`) answers without `limbs`: plain JSON. | |
| 506 | if (!Array.isArray(obj(value)["limbs"]) || "buildingNow" in obj(value)) return undefined; | |
| 489 | 507 | const limbs = list(obj(value)["limbs"]).map(obj); |
| 490 | 508 | const repo = obj(obj(value)["repo"]); |
| 491 | 509 | const slug = `${text(repo["owner"])}/${text(repo["name"])}`; |
test/mcp-asks-now-milestones-cards.test.ts added +134
| @@ -0,0 +1,134 @@ | ||
| 1 | /** MCP `asks`, `now` and `milestones` answer with their ui:// card beside the text: URI, title, rows, rooted links. */ | |
| 2 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import { openIssue } from "../src/actions/issues.ts"; | |
| 8 | import { createRepo } from "../src/actions/repos.ts"; | |
| 9 | import { newApp } from "../src/actions/apps.ts"; | |
| 10 | import { setAssignee } from "../src/core/issues.ts"; | |
| 11 | import { claimRun, createRun } from "../src/core/runs.ts"; | |
| 12 | import { createToken } from "../src/core/tokens.ts"; | |
| 13 | import { createUser } from "../src/core/users.ts"; | |
| 14 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 15 | import { CARD_URI, cardFor } from "../src/web/studio/cards.ts"; | |
| 16 | import { quietLogs } from "./helpers/globals.ts"; | |
| 17 | import { seedMilestones } from "./fixtures/milestones-seed.ts"; | |
| 18 | import { listVia } from "./mcp-list.ts"; | |
| 19 | ||
| 20 | quietLogs(); | |
| 21 | ||
| 22 | describe("MCP asks, now and milestones cards", () => { | |
| 23 | let dataDir: string; | |
| 24 | let app: App; | |
| 25 | let servers: Servers; | |
| 26 | let token = ""; | |
| 27 | ||
| 28 | const call = async (name: string, args: Record<string, unknown> = {}) => { | |
| 29 | const res = await fetch(`${servers.url}/mcp`, { | |
| 30 | method: "POST", | |
| 31 | headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` }, | |
| 32 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }), | |
| 33 | }); | |
| 34 | return (await res.json()) as any; | |
| 35 | }; | |
| 36 | const rooted = (origin: string, rows: { link?: string }[]) => { | |
| 37 | for (const r of rows) if (r.link !== undefined) expect(r.link.startsWith(`${origin}/`) && !r.link.startsWith(`${origin}//`)).toBe(true); | |
| 38 | }; | |
| 39 | ||
| 40 | beforeAll(async () => { | |
| 41 | dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-three-cards-")); | |
| 42 | app = createApp({ dataDir, port: 0, disk: () => ({ freeBytes: 95, totalBytes: 100 }) }); | |
| 43 | servers = await serve(app); | |
| 44 | const a = await createUser(app.db, { origin: "seed", trusted: true, | |
| 45 | username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true }); | |
| 46 | if (!a.ok) throw new Error("users"); | |
| 47 | token = createToken(app.db, a.value.id, "ro", "ro").token; | |
| 48 | const made0 = await newApp(app, a.value, { name: "game", description: "x", visibility: "private" }); | |
| 49 | if (!made0.ok) throw new Error(String(made0.error)); | |
| 50 | const made = { value: made0.value.repo }; | |
| 51 | const issue = openIssue(app, a.value, made.value, { title: "Sweep the path", body: "" }); | |
| 52 | if (!issue.ok) throw new Error(String(issue.error)); | |
| 53 | setAssignee(app.db, issue.value.id, a.value.id); | |
| 54 | const run = createRun(app.db, { repoId: made.value.id, kind: "agent", issueId: issue.value.id, reason: "issue" }); | |
| 55 | claimRun(app.db, run.id); | |
| 56 | const plan = await createRepo(app, a.value, { name: "plan", visibility: "private" }); | |
| 57 | if (!plan.ok) throw new Error(String(plan.error)); | |
| 58 | seedMilestones(app.db, plan.value.id, a.value.id, new Date()); | |
| 59 | }, 30_000); | |
| 60 | ||
| 61 | afterAll(async () => { | |
| 62 | servers.stop(); | |
| 63 | app.close(); | |
| 64 | await rm(dataDir, { recursive: true, force: true }); | |
| 65 | }); | |
| 66 | ||
| 67 | test("asks: the decision card, a row per ask with a rooted link, and actions that carry the row's items", async () => { | |
| 68 | const body = await call("asks"); | |
| 69 | const text = JSON.parse(body.result.content[0].text); | |
| 70 | const card = body.result.structuredContent.card; | |
| 71 | expect(CARD_URI(card.kind)).toBe("ui://forge/decision"); | |
| 72 | expect(card.title).toBe("Waiting on you"); | |
| 73 | expect(card.rows.length).toBe(text.rows.length); | |
| 74 | expect(card.rows.map((r: any) => r.label)).toEqual(text.rows.map((r: any) => r.line)); | |
| 75 | expect(card.rows.some((r: any) => r.link === `${servers.url}/alice/game/issues/${text.rows.find((x: any) => x.numberOf === "issue")?.number}`)).toBe(true); | |
| 76 | rooted(servers.url, card.rows); | |
| 77 | const act = card.actions.filter((a: any) => a.tool === "asks_act"); | |
| 78 | expect(act.map((a: any) => a.args.action)).toEqual(expect.arrayContaining(["close", "dismiss"])); | |
| 79 | for (const a of act) expect(a.args.items).toEqual(text.rows[0].items); | |
| 80 | // The tools/list entry names the card. | |
| 81 | const list = await listVia(async (p) => (await fetch(`${servers.url}/mcp`, { method: "POST", | |
| 82 | headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` }, | |
| 83 | body: JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/list", params: p }) })).json()); | |
| 84 | const by = (n: string) => list.result.tools.find((t: any) => t.name === n)?._meta?.ui?.resourceUri; | |
| 85 | expect(by("asks")).toBe("ui://forge/decision"); | |
| 86 | expect(by("milestones")).toBe("ui://forge/milestones"); | |
| 87 | }); | |
| 88 | ||
| 89 | test("asks_act: a done action's receipt carries Undo with its operation; a plan has no card", () => { | |
| 90 | expect(cardFor("asks_act", { wouldDismiss: ["x"] }, "https://forge.test")).toBeUndefined(); | |
| 91 | const done = cardFor("asks_act", { id: "op1", receipt: "Dismissed 2 asks", undoOf: null, undoneBy: null }, "https://forge.test")!; | |
| 92 | expect(done.rows).toEqual([{ label: "Receipt", value: "Dismissed 2 asks" }]); | |
| 93 | expect(done.actions).toEqual([{ label: "Undo", tool: "asks_act", args: { action: "undo", operation: "op1" } }]); | |
| 94 | const undone = cardFor("asks_act", { id: "op2", receipt: "Undid it", undoOf: "op1", undoneBy: null }, "")!; | |
| 95 | expect(undone.actions).toEqual([]); | |
| 96 | }); | |
| 97 | ||
| 98 | test("now: the run card, who on what and since when, links rooted; a planner waiting on you is the first row", async () => { | |
| 99 | const body = await call("now"); | |
| 100 | const text = JSON.parse(body.result.content[0].text); | |
| 101 | const card = body.result.structuredContent.card; | |
| 102 | expect(CARD_URI(card.kind)).toBe("ui://forge/run"); | |
| 103 | expect(card.title).toBe("Working now"); | |
| 104 | expect(card.rows.length).toBe(text.length); | |
| 105 | expect(card.rows[0].label).toBe("the crew on alice/game"); | |
| 106 | expect(card.rows[0].value).toContain("Sweep the path"); | |
| 107 | expect(card.rows[0].value).toContain("just now"); | |
| 108 | expect(card.rows[0].link).toBe(`${servers.url}/alice/game/runs/${text[0].run}`); | |
| 109 | rooted(servers.url, card.rows); | |
| 110 | const waiting = cardFor("now", [ | |
| 111 | { run: null, app: "alice/game", who: "the planner", on: "#3 Plan", state: "waiting for you", since: new Date().toISOString(), costUsd: 0 }, | |
| 112 | { run: "0".repeat(26), app: "alice/game", who: "smith", on: null, state: "running", since: new Date().toISOString(), costUsd: 1.5 }, | |
| 113 | ], "https://forge.test")!; | |
| 114 | expect(waiting.rows[0]!.label).toBe("the planner on alice/game"); | |
| 115 | expect(waiting.rows[0]!.link).toBeUndefined(); | |
| 116 | expect(waiting.rows[1]!.value).toContain("$1.50"); | |
| 117 | expect(waiting.rows[1]!.value.startsWith(" —")).toBe(false); | |
| 118 | }); | |
| 119 | ||
| 120 | test("milestones: the milestones card, done/total and a rooted link each; one milestone opened has no card", async () => { | |
| 121 | const body = await call("milestones", { app: "alice/plan" }); | |
| 122 | const text = JSON.parse(body.result.content[0].text); | |
| 123 | const card = body.result.structuredContent.card; | |
| 124 | expect(CARD_URI(card.kind)).toBe("ui://forge/milestones"); | |
| 125 | expect(card.title).toBe("Milestones"); | |
| 126 | expect(card.rows.length).toBeGreaterThan(0); | |
| 127 | expect(card.rows[0].label).toBe(`#${text.limbs[0].number} ${text.limbs[0].name}`); | |
| 128 | expect(card.rows[0].value).toContain(`${text.limbs[0].done}/${text.limbs[0].total} done`); | |
| 129 | expect(card.rows[0].link).toBe(`${servers.url}/alice/plan/milestones/${text.limbs[0].number}`); | |
| 130 | rooted(servers.url, card.rows); | |
| 131 | const one = await call("milestones", { app: "alice/plan", issue: text.limbs[0].number }); | |
| 132 | expect(one.result.structuredContent).toBeUndefined(); | |
| 133 | }); | |
| 134 | }); |
test/mcp.test.ts +3 −1
| @@ -1296,6 +1296,7 @@ describe("the forge over MCP", () => { | ||
| 1296 | 1296 | "ui://forge/forks", |
| 1297 | 1297 | "ui://forge/issue", |
| 1298 | 1298 | "ui://forge/map", |
| 1299 | "ui://forge/milestones", | |
| 1299 | 1300 | "ui://forge/pull", |
| 1300 | 1301 | "ui://forge/pulls", |
| 1301 | 1302 | "ui://forge/run", |
| @@ -1314,7 +1315,8 @@ describe("the forge over MCP", () => { | ||
| 1314 | 1315 | expect(meta("new_app")).toBe("ui://forge/app"); |
| 1315 | 1316 | expect(meta("now")).toBe("ui://forge/run"); |
| 1316 | 1317 | expect(meta("asks")).toBe("ui://forge/decision"); |
| 1317 | expect(meta("milestones")).toBe("ui://forge/wave"); | |
| 1318 | expect(meta("milestones")).toBe("ui://forge/milestones"); | |
| 1319 | expect(meta("asks_act")).toBe("ui://forge/decision"); | |
| 1318 | 1320 | expect(meta("map")).toBe("ui://forge/map"); |
| 1319 | 1321 | expect(meta("boards")).toBe("ui://forge/board"); |
| 1320 | 1322 | expect(meta("feed")).toBe("ui://forge/feed"); |