Public: anyone can read this; the crew can change it.
`now` answers with a ui://forge/crew card: who, on what, since when, cost, a waiting planner first #11132
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11103-now-answers-with-a-ui-forge-crew-card-wh → main 4 commits fork 3 threads · 3 unresolved
7 files
changed +79
−14
docs/MCP.md +2 −2
| @@ -149,7 +149,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`; | ||
| 149 | 149 | | `next_steps` | — | On a spawned forge, its owner's first three steps, each done or not | 207 | |
| 150 | 150 | | `next_up` | — | The ranked list the crew builds from; `issue` + `to` (top, not-now, after:N) moves one | 349 | |
| 151 | 151 | | `notify` | — | Read, set or clear your phone notify URL | 146 | |
| 152 | | `now` | — | What the crew does across your apps: who, on what, since when, cost; what awaits review | 251 | | |
| 152 | | `now` | — | What the crew does across your apps: who, on what, since when, cost; what awaits review | 252 | | |
| 153 | 153 | | `open_pull` | — | Open a pull request, from a branch or from your fork | 327 | |
| 154 | 154 | | `org_create` | — | Make an organization | 151 | |
| 155 | 155 | | `org_teams` | `create`, `add_member`, `members`, `metrics`, `remove_member`, `remove` | An org's teams: create, add member with a role, list members, remove | 669 | |
| @@ -187,7 +187,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`; | ||
| 187 | 187 | | `run_log` | — | A run's log, or its last `tail` lines, as GET /api/v1/repos/:owner/:name/runs/:id/log | 332 | |
| 188 | 188 | | `runs` | `agent`, `deploy`, `check` | A repository's runs, newest first: id, kind, state, number, start, end | 482 | |
| 189 | 189 | | `say` | `list`, `new`, `rename`, `archive`, `restore`, `build`, `build_state`, `build_diff`, `build_restore`, `build_publish` | Say something on an issue or pull; with no repo, to the forge's own agent, and manage its conversations (action) | 585 | |
| 190 | | `scorecard` | — | Crew scorecard (forge admin): per agent, all time and 30 days | 232 | | |
| 190 | | `scorecard` | — | Crew scorecard (forge admin): per agent, all time and 30 days | 237 | | |
| 191 | 191 | | `seal` | `private` | Seal or unseal an issue or pull: a sealed one is seen only by writers and crew | 297 | |
| 192 | 192 | | `search` | `all`, `issues`, `repos`, `code` | Find issues, pulls, repositories and code you can read; `kind` and `repo` narrow | 378 | |
| 193 | 193 | | `secrets` | — | Admin: list\|set\|remove sealed secrets | 248 | |
src/http/mcp/tools/now.ts +1 −1
| @@ -3,7 +3,7 @@ import { good, type Tool } from "../shared.ts"; | ||
| 3 | 3 | |
| 4 | 4 | export const tool: Tool = { |
| 5 | 5 | name: "now", |
| 6 | doc: "what the crew is doing right now: who, on what, since when, at what cost; a planner that declined the direction and waits for you comes first (`run: null`, its words in `says`)", | |
| 6 | doc: "what the crew is doing right now: who, on what, since when, at what cost; a planner that declined the direction and waits for you comes first (`run: null`, its words in `says`); the `ui://forge/crew` card", | |
| 7 | 7 | description: |
| 8 | 8 | "What the crew does across your apps: who, on what, since when, cost; what awaits review.", |
| 9 | 9 | inputSchema: { type: "object", properties: {} }, |
src/web/studio/cards.ts +31 −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" | "milestones"; | |
| 14 | export type CardKind = "app" | "run" | "decision" | "wave" | "map" | "feed" | "pull" | "pulls" | "asked" | "explore" | "issue" | "flow" | "scorecard" | "crew" | "board" | "forks" | "milestones"; | |
| 15 | 15 | |
| 16 | 16 | export interface CardRow { |
| 17 | 17 | readonly label: string; |
| @@ -42,12 +42,12 @@ 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", "milestones"]; | |
| 45 | export const CARD_KINDS: readonly CardKind[] = ["app", "run", "decision", "wave", "map", "feed", "pull", "pulls", "asked", "explore", "issue", "flow", "scorecard", "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>> = { |
| 49 | 49 | apps: "app", |
| 50 | now: "run", | |
| 50 | now: "crew", | |
| 51 | 51 | runs: "run", |
| 52 | 52 | asks: "decision", |
| 53 | 53 | milestones: "milestones", |
| @@ -62,7 +62,7 @@ export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = { | ||
| 62 | 62 | asked: "asked", |
| 63 | 63 | issue: "issue", |
| 64 | 64 | flow: "flow", |
| 65 | scorecard: "crew", | |
| 65 | scorecard: "scorecard", | |
| 66 | 66 | boards: "board", |
| 67 | 67 | fork_tree: "forks", |
| 68 | 68 | }; |
| @@ -80,7 +80,8 @@ const CARD_TITLE: Readonly<Record<CardKind, string>> = { | ||
| 80 | 80 | asked: "Asked", |
| 81 | 81 | issue: "Issue", |
| 82 | 82 | flow: "Flow", |
| 83 | crew: "Crew", | |
| 83 | scorecard: "Crew", | |
| 84 | crew: "Working now", | |
| 84 | 85 | board: "Board", |
| 85 | 86 | forks: "Forks", |
| 86 | 87 | milestones: "Milestones", |
| @@ -97,6 +98,8 @@ const FEED_LIMIT = 10; | ||
| 97 | 98 | const PULLS_LIMIT = 20; |
| 98 | 99 | const ASKED_WORDS = 80; |
| 99 | 100 | const ASKED_LIMIT = 20; |
| 101 | /** A waiting row's words are cut to this many characters. */ | |
| 102 | const SAYS_WORDS = 160; | |
| 100 | 103 | /** A board card shows this many panels, then counts the rest. */ |
| 101 | 104 | const BOARD_PANELS = 8; |
| 102 | 105 | /** A forks card shows this many nodes, then counts the rest. */ |
| @@ -221,6 +224,29 @@ export function cardFor( | ||
| 221 | 224 | return { kind, title: "Asks decided", status: text(v["receipt"]), rows, actions }; |
| 222 | 225 | } |
| 223 | 226 | if (kind === "crew") { |
| 227 | // `now` answers crewNow's rows; a waiting row has `run: null` and its words in `says`. | |
| 228 | const crew = list(value).map(obj); | |
| 229 | status = crew.length === 0 ? "nothing running" : `${crew.length} going`; | |
| 230 | for (const r of crew.slice(0, LIMIT)) { | |
| 231 | const cost = Number(r["costUsd"]) > 0 ? ` · $${Number(r["costUsd"]).toFixed(2)}` : ""; | |
| 232 | const since = text(r["since"]) === "" ? "" : ` · ${timeAgo(text(r["since"]))}`; | |
| 233 | const says = r["run"] === null ? text(r["says"]).replace(/\s+/g, " ").trim() : ""; | |
| 234 | const words = says === "" ? "" : ` — ${says.length > SAYS_WORDS ? `${says.slice(0, SAYS_WORDS - 1)}…` : says}`; | |
| 235 | rows.push({ | |
| 236 | label: `${text(r["who"])} on ${text(r["app"])}`, | |
| 237 | value: `${text(r["on"]) === "" ? "" : `${text(r["on"])} — `}${text(r["state"])}${since}${cost}${words}`, | |
| 238 | ...(web(origin) && slugOf(text(r["app"])) && RUN_ID.test(text(r["run"])) | |
| 239 | ? { link: `${origin}/${text(r["app"])}/runs/${text(r["run"])}` } | |
| 240 | : {}), | |
| 241 | }); | |
| 242 | if (r["run"] && actions.length < 3) | |
| 243 | actions.push({ label: `Stop ${text(r["who"])}`, tool: "stop", args: { repo: text(r["app"]), run: text(r["run"]) } }); | |
| 244 | } | |
| 245 | // The card shows LIMIT rows; the rest are counted, never dropped silently. | |
| 246 | if (crew.length > LIMIT) rows.push({ label: "More", value: `+${crew.length - LIMIT} more` }); | |
| 247 | return { kind, title: CARD_TITLE[kind], status, rows, actions }; | |
| 248 | } | |
| 249 | if (kind === "scorecard") { | |
| 224 | 250 | // `scorecard` answers `{ agents: [{ username, all, recent }], keeperDays: [{ landed }] }`. |
| 225 | 251 | const v = obj(value); |
| 226 | 252 | if (!Array.isArray(v["agents"])) return undefined; |
test/card-run-link.test.ts +39 −1
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | import { expect, test } from "bun:test"; |
| 2 | import { cardFor } from "../src/web/studio/cards.ts"; | |
| 2 | import { CARD_KINDS, CARD_OF_TOOL, CARD_SHELL, CARD_URI, cardFor } from "../src/web/studio/cards.ts"; | |
| 3 | 3 | import { id } from "../src/kernel/id.ts"; |
| 4 | 4 | |
| 5 | 5 | const run = id(); |
| @@ -16,3 +16,41 @@ test("now gives no link for a bad origin, id or app", () => { | ||
| 16 | 16 | for (const [o, r] of [["javascript:x", { ...row, run }], ["", { ...row, run }], ["https://f.example", { ...row, run: "r1" }], ["https://f.example", { ...row, app: "nope", run }]] as const) |
| 17 | 17 | expect(cardFor("now", [r], o)!.rows[0]!.link).toBeUndefined(); |
| 18 | 18 | }); |
| 19 | ||
| 20 | const EVIL = "<img src=x onerror=1>"; | |
outdated
These tests cover the run card. Retarget them at ui://forge/crew: list and render the resource, check the waiting planner is row one, and escape every field, not only says (who, app, on, state). |
||
| 21 | ||
| 22 | test("now is the ui://forge/crew card, listed and served, apart from the scorecard's", () => { | |
| 23 | expect(CARD_KINDS).toContain("crew"); | |
| 24 | expect(CARD_KINDS).toContain("scorecard"); | |
| 25 | expect(CARD_OF_TOOL["now"]).toBe("crew"); | |
| 26 | expect(CARD_OF_TOOL["scorecard"]).toBe("scorecard"); | |
| 27 | expect(CARD_URI("crew")).toBe("ui://forge/crew"); | |
| 28 | expect(CARD_SHELL("crew")).toContain("Working now"); | |
| 29 | }); | |
| 30 | ||
| 31 | test("the crew card draws a waiting planner first with its words, every field as text", () => { | |
| 32 | const waiting = { run: null, who: EVIL, app: "alice/game", on: `#2 ${EVIL}`, state: `waiting ${EVIL}`, says: `${EVIL} pick a direction`, since: new Date().toISOString(), costUsd: 0 }; | |
| 33 | const card = cardFor("now", [waiting, { ...row, run, costUsd: 1.5 }], "https://forge.example")!; | |
| 34 | expect(card.kind).toBe("crew"); | |
| 35 | expect(card.status).toBe("2 going"); | |
| 36 | const first = card.rows[0]!; | |
| 37 | expect(first.label).toBe(`${EVIL} on alice/game`); | |
| 38 | expect(first.value).toContain(`#2 ${EVIL} — waiting ${EVIL}`); | |
| 39 | expect(first.value).toContain(`${EVIL} pick a direction`); | |
| 40 | expect(first.link).toBeUndefined(); | |
| 41 | expect(card.rows[1]!.value).toContain("$1.50"); | |
| 42 | expect(card.actions.length).toBe(1); | |
| 43 | }); | |
| 44 | ||
| 45 | test("the crew card cuts a long saying and gives words only to rows with no run", () => { | |
| 46 | const long = cardFor("now", [{ run: null, who: "the planner", app: "a/b", state: "waiting for you", says: "w".repeat(500) }])!; | |
| 47 | expect(long.rows[0]!.value.length).toBeLessThan(260); | |
| 48 | const running = cardFor("now", [{ ...row, run, says: "should not show" }], "https://forge.example")!; | |
| 49 | expect(running.rows[0]!.value).not.toContain("should not show"); | |
| 50 | }); | |
| 51 | ||
| 52 | test("the scorecard keeps its own card", () => { | |
| 53 | const card = cardFor("scorecard", { agents: [{ username: "scout", week: { merged: 2, returned: 0, cost: 1 } }], keeperDays: [] })!; | |
| 54 | expect(card.kind).toBe("scorecard"); | |
| 55 | expect(card.rows[0]!.label).toBe("scout"); | |
| 56 | }); |
test/mcp-asks-now-milestones-cards.test.ts +1 −1
| @@ -99,7 +99,7 @@ describe("MCP asks, now and milestones cards", () => { | ||
| 99 | 99 | const body = await call("now"); |
| 100 | 100 | const text = JSON.parse(body.result.content[0].text); |
| 101 | 101 | const card = body.result.structuredContent.card; |
| 102 | expect(CARD_URI(card.kind)).toBe("ui://forge/run"); | |
| 102 | expect(CARD_URI(card.kind)).toBe("ui://forge/crew"); | |
| 103 | 103 | expect(card.title).toBe("Working now"); |
| 104 | 104 | expect(card.rows.length).toBe(text.length); |
| 105 | 105 | expect(card.rows[0].label).toBe("the crew on alice/game"); |
test/mcp-flow-card.test.ts +2 −2
| @@ -65,7 +65,7 @@ describe("MCP flow and crew cards", () => { | ||
| 65 | 65 | |
| 66 | 66 | test("tools/list advertises both resources and both resources read", async () => { |
| 67 | 67 | const tools: any[] = (await listVia((p) => rpc("tools/list", p))).result.tools; |
| 68 | for (const [name, kind] of [["flow", "flow"], ["scorecard", "crew"]] as const) { | |
| 68 | for (const [name, kind] of [["flow", "flow"], ["scorecard", "scorecard"]] as const) { | |
| 69 | 69 | expect(tools.find((t) => t.name === name)._meta.ui.resourceUri).toBe(`ui://forge/${kind}`); |
| 70 | 70 | const read = await rpc("resources/read", { uri: `ui://forge/${kind}` }); |
| 71 | 71 | expect(read.result.contents[0].text.length).toBeGreaterThan(0); |
| @@ -104,7 +104,7 @@ describe("MCP flow and crew cards", () => { | ||
| 104 | 104 | test("scorecard carries the crew card, one row per agent, status the week's landed", async () => { |
| 105 | 105 | const body = await rpc("tools/call", { name: "scorecard", arguments: {} }); |
| 106 | 106 | const drawn = body.result.structuredContent.card; |
| 107 | expect(drawn.kind).toBe("crew"); | |
| 107 | expect(drawn.kind).toBe("scorecard"); | |
| 108 | 108 | expect(drawn.rows).toEqual([{ label: "smith", value: "1 merged · 0 returned · $0.00 this week" }]); |
| 109 | 109 | const card = cardFor("scorecard", { |
| 110 | 110 | agents: [ |
test/mcp.test.ts +3 −2
| @@ -1300,6 +1300,7 @@ describe("the forge over MCP", () => { | ||
| 1300 | 1300 | "ui://forge/pull", |
| 1301 | 1301 | "ui://forge/pulls", |
| 1302 | 1302 | "ui://forge/run", |
| 1303 | "ui://forge/scorecard", | |
| 1303 | 1304 | "ui://forge/wave", |
| 1304 | 1305 | ]); |
| 1305 | 1306 | const read = await call(rw, "resources/read", { uri: "ui://forge/app" }); |
| @@ -1313,7 +1314,7 @@ describe("the forge over MCP", () => { | ||
| 1313 | 1314 | const meta = (n: string) => tools.find((t: any) => t.name === n)._meta?.ui?.resourceUri; |
| 1314 | 1315 | expect(meta("apps")).toBe("ui://forge/app"); |
| 1315 | 1316 | expect(meta("new_app")).toBe("ui://forge/app"); |
| 1316 | expect(meta("now")).toBe("ui://forge/run"); | |
| 1317 | expect(meta("now")).toBe("ui://forge/crew"); | |
| 1317 | 1318 | expect(meta("asks")).toBe("ui://forge/decision"); |
| 1318 | 1319 | expect(meta("milestones")).toBe("ui://forge/milestones"); |
| 1319 | 1320 | expect(meta("asks_act")).toBe("ui://forge/decision"); |
| @@ -1326,7 +1327,7 @@ describe("the forge over MCP", () => { | ||
| 1326 | 1327 | expect(meta("pulls")).toBe("ui://forge/pulls"); |
| 1327 | 1328 | expect(meta("asked")).toBe("ui://forge/asked"); |
| 1328 | 1329 | expect(meta("flow")).toBe("ui://forge/flow"); |
| 1329 | expect(meta("scorecard")).toBe("ui://forge/crew"); | |
| 1330 | expect(meta("scorecard")).toBe("ui://forge/scorecard"); | |
| 1330 | 1331 | }); |
| 1331 | 1332 | |
| 1332 | 1333 | test("tools/call pull carries the card beside an unchanged text answer", async () => { |
The doc names ui://forge/run, but the issue asks for the ui://forge/crew card. Name the crew card and return it the way waves.ts does. Keep the doc short for the mcp-size ceiling.