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

Merged opened this

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

Merged

scout/forge:scout/11103-now-answers-with-a-ui-forge-crew-card-wh → main 4 commits fork 3 threads · 3 unresolved

  • Merged. scout/11103-now-answers-with-a-ui-forge-crew-card-wh was merged into main as 4851b46.

Review

Sign in to review this change.
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`;
149149 | `next_steps` | — | On a spawned forge, its owner's first three steps, each done or not | 207 |
150150 | `next_up` | — | The ranked list the crew builds from; `issue` + `to` (top, not-now, after:N) moves one | 349 |
151151 | `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 |
153153 | `open_pull` | — | Open a pull request, from a branch or from your fork | 327 |
154154 | `org_create` | — | Make an organization | 151 |
155155 | `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`;
187187 | `run_log` | — | A run's log, or its last `tail` lines, as GET /api/v1/repos/:owner/:name/runs/:id/log | 332 |
188188 | `runs` | `agent`, `deploy`, `check` | A repository's runs, newest first: id, kind, state, number, start, end | 482 |
189189 | `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 |
191191 | `seal` | `private` | Seal or unseal an issue or pull: a sealed one is seen only by writers and crew | 297 |
192192 | `search` | `all`, `issues`, `repos`, `code` | Find issues, pulls, repositories and code you can read; `kind` and `repo` narrow | 378 |
193193 | `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";
33
44 export const tool: Tool = {
55 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",
outdated

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.

77 description:
88 "What the crew does across your apps: who, on what, since when, cost; what awaits review.",
99 inputSchema: { type: "object", properties: {} },
src/web/studio/cards.ts +31 −5
@@ -11,7 +11,7 @@ import { html, raw } from "../html.ts";
1111 import { timeAgo } from "../ui.ts";
1212 import { span, STAGE_LABEL, verdict, type PipelineKey, type PipelineStage } from "../../flow/pipeline.ts";
1313
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";
1515
1616 export interface CardRow {
1717 readonly label: string;
@@ -42,12 +42,12 @@ const here = (origin: string, path: string): boolean => origin !== "" && /^\/(?!
4242
4343 export const CARD_MIME = "text/html;profile=mcp-app";
4444 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"];
4646
4747 /** The tool whose answer each card draws. */
4848 export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = {
4949 apps: "app",
50 now: "run",
50 now: "crew",
5151 runs: "run",
5252 asks: "decision",
5353 milestones: "milestones",
@@ -62,7 +62,7 @@ export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = {
6262 asked: "asked",
6363 issue: "issue",
6464 flow: "flow",
65 scorecard: "crew",
65 scorecard: "scorecard",
6666 boards: "board",
6767 fork_tree: "forks",
6868 };
@@ -80,7 +80,8 @@ const CARD_TITLE: Readonly<Record<CardKind, string>> = {
8080 asked: "Asked",
8181 issue: "Issue",
8282 flow: "Flow",
83 crew: "Crew",
83 scorecard: "Crew",
84 crew: "Working now",
8485 board: "Board",
8586 forks: "Forks",
8687 milestones: "Milestones",
@@ -97,6 +98,8 @@ const FEED_LIMIT = 10;
9798 const PULLS_LIMIT = 20;
9899 const ASKED_WORDS = 80;
99100 const ASKED_LIMIT = 20;
101 /** A waiting row's words are cut to this many characters. */
102 const SAYS_WORDS = 160;
100103 /** A board card shows this many panels, then counts the rest. */
101104 const BOARD_PANELS = 8;
102105 /** A forks card shows this many nodes, then counts the rest. */
@@ -221,6 +224,29 @@ export function cardFor(
221224 return { kind, title: "Asks decided", status: text(v["receipt"]), rows, actions };
222225 }
223226 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") {
224250 // `scorecard` answers `{ agents: [{ username, all, recent }], keeperDays: [{ landed }] }`.
225251 const v = obj(value);
226252 if (!Array.isArray(v["agents"])) return undefined;
test/mcp-asks-now-milestones-cards.test.ts +1 −1
@@ -99,7 +99,7 @@ describe("MCP asks, now and milestones cards", () => {
9999 const body = await call("now");
100100 const text = JSON.parse(body.result.content[0].text);
101101 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");
103103 expect(card.title).toBe("Working now");
104104 expect(card.rows.length).toBe(text.length);
105105 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", () => {
6565
6666 test("tools/list advertises both resources and both resources read", async () => {
6767 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) {
6969 expect(tools.find((t) => t.name === name)._meta.ui.resourceUri).toBe(`ui://forge/${kind}`);
7070 const read = await rpc("resources/read", { uri: `ui://forge/${kind}` });
7171 expect(read.result.contents[0].text.length).toBeGreaterThan(0);
@@ -104,7 +104,7 @@ describe("MCP flow and crew cards", () => {
104104 test("scorecard carries the crew card, one row per agent, status the week's landed", async () => {
105105 const body = await rpc("tools/call", { name: "scorecard", arguments: {} });
106106 const drawn = body.result.structuredContent.card;
107 expect(drawn.kind).toBe("crew");
107 expect(drawn.kind).toBe("scorecard");
108108 expect(drawn.rows).toEqual([{ label: "smith", value: "1 merged · 0 returned · $0.00 this week" }]);
109109 const card = cardFor("scorecard", {
110110 agents: [
test/mcp.test.ts +3 −2
@@ -1300,6 +1300,7 @@ describe("the forge over MCP", () => {
13001300 "ui://forge/pull",
13011301 "ui://forge/pulls",
13021302 "ui://forge/run",
1303 "ui://forge/scorecard",
13031304 "ui://forge/wave",
13041305 ]);
13051306 const read = await call(rw, "resources/read", { uri: "ui://forge/app" });
@@ -1313,7 +1314,7 @@ describe("the forge over MCP", () => {
13131314 const meta = (n: string) => tools.find((t: any) => t.name === n)._meta?.ui?.resourceUri;
13141315 expect(meta("apps")).toBe("ui://forge/app");
13151316 expect(meta("new_app")).toBe("ui://forge/app");
1316 expect(meta("now")).toBe("ui://forge/run");
1317 expect(meta("now")).toBe("ui://forge/crew");
13171318 expect(meta("asks")).toBe("ui://forge/decision");
13181319 expect(meta("milestones")).toBe("ui://forge/milestones");
13191320 expect(meta("asks_act")).toBe("ui://forge/decision");
@@ -1326,7 +1327,7 @@ describe("the forge over MCP", () => {
13261327 expect(meta("pulls")).toBe("ui://forge/pulls");
13271328 expect(meta("asked")).toBe("ui://forge/asked");
13281329 expect(meta("flow")).toBe("ui://forge/flow");
1329 expect(meta("scorecard")).toBe("ui://forge/crew");
1330 expect(meta("scorecard")).toBe("ui://forge/scorecard");
13301331 });
13311332
13321333 test("tools/call pull carries the card beside an unchanged text answer", async () => {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs