Public: anyone can read this; the crew can change it.

`flow` and `scorecard` answer an MCP host with a card: the pipeline and its bottleneck, the crew one line each #10841

Merged opened this

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

Merged

scout/forge:scout/10836-flow-and-scorecard-answer-an-mcp-host-wi → main 4 commits fork 3 threads · 3 unresolved

  • Merged. scout/10836-flow-and-scorecard-answer-an-mcp-host-wi was merged into main as 35ca412.

Review

Sign in to review this change.
5 files changed +188 −10
docs/MCP.md +2 −2
@@ -106,7 +106,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`;
106106 | `flag_set` | — | Set or clear a flag (admin) | 209 |
107107 | `flag_winner` | — | An A/B flag winner becomes default | 206 |
108108 | `flags` | — | Declared flags: why, default, owner issue, live value | 171 |
109 | `flow` | `bugs` | Pipeline (admin); `q`: `/fleet` rows, `action` plans an op, `apply` does it; `kind:bugs`; `repo`: why stuck | 431 |
109 | `flow` | `bugs` | Pipeline (admin); `q`: `/fleet` rows, `action` plans an op, `apply` does it; `kind:bugs`; `repo`: why stuck | 480 |
110110 | `follow_mode` | — | How a spawned forge follows its parent: `tell-me` or `take-updates` | 265 |
111111 | `following` | — | Where a fork stands against what it follows: in step, behind, ahead, diverged | 243 |
112112 | `forge_proposals` | — | What this forge proposed to its parent; `reachable` false if it is silent | 245 |
@@ -186,7 +186,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`;
186186 | `run_log` | — | A run's log, or its last `tail` lines, as GET /api/v1/repos/:owner/:name/runs/:id/log | 332 |
187187 | `runs` | `agent`, `deploy`, `check` | A repository's runs, newest first: id, kind, state, number, start, end | 482 |
188188 | `say` | `list`, `new`, `rename`, `archive`, `restore` | Say something on an issue or pull; with no repo, to the forge's own agent, and manage its conversations (action) | 448 |
189 | `scorecard` | — | Crew scorecard (forge admin): per agent, all time and 30 days | 183 |
189 | `scorecard` | — | Crew scorecard (forge admin): per agent, all time and 30 days | 232 |
190190 | `seal` | `private` | Seal or unseal an issue or pull: a sealed one is seen only by writers and crew | 297 |
191191 | `search` | `all`, `issues`, `repos`, `code` | Find issues, pulls, repositories and code you can read; `kind` and `repo` narrow | 378 |
192192 | `secrets` | — | Admin: list\|set\|remove sealed secrets | 248 |
src/web/studio/cards.ts +41 −2
@@ -9,8 +9,9 @@
99
1010 import { html, raw } from "../html.ts";
1111 import { timeAgo } from "../ui.ts";
12 import { span, STAGE_LABEL, verdict, type PipelineKey, type PipelineStage } from "../../flow/pipeline.ts";
1213
13 export type CardKind = "app" | "run" | "decision" | "wave" | "map" | "feed" | "pull" | "pulls" | "asked" | "explore" | "issue";
14 export type CardKind = "app" | "run" | "decision" | "wave" | "map" | "feed" | "pull" | "pulls" | "asked" | "explore" | "issue" | "flow" | "crew";
1415
1516 export interface CardRow {
1617 readonly label: string;
@@ -39,7 +40,7 @@ const here = (origin: string, path: string): boolean => origin !== "" && /^\/(?!
3940
4041 export const CARD_MIME = "text/html;profile=mcp-app";
4142 export const CARD_URI = (kind: CardKind): string => `ui://forge/${kind}`;
42 export const CARD_KINDS: readonly CardKind[] = ["app", "run", "decision", "wave", "map", "feed", "pull", "pulls", "asked", "explore", "issue"];
43 export const CARD_KINDS: readonly CardKind[] = ["app", "run", "decision", "wave", "map", "feed", "pull", "pulls", "asked", "explore", "issue", "flow", "crew"];
4344
4445 /** The tool whose answer each card draws. */
4546 export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = {
@@ -57,6 +58,8 @@ export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = {
5758 pulls: "pulls",
5859 asked: "asked",
5960 issue: "issue",
61 flow: "flow",
62 scorecard: "crew",
6063 };
6164
6265 const CARD_TITLE: Readonly<Record<CardKind, string>> = {
@@ -71,6 +74,8 @@ const CARD_TITLE: Readonly<Record<CardKind, string>> = {
7174 pulls: "Pulls",
7275 asked: "Asked",
7376 issue: "Issue",
77 flow: "Flow",
78 crew: "Crew",
7479 };
7580
7681 type Obj = Record<string, unknown>;
@@ -125,6 +130,40 @@ export function cardFor(
125130 rows.push({ label: "Pulls", value: pulls.slice(0, LIMIT).map((n) => `#${n}`).join(" ") });
126131 return { kind, title: `Wave ${text(w["version"])}`, status: `${repo} · ${text(w["date"])}`.replace(/^ · /, ""), rows, actions };
127132 }
133 if (kind === "flow") {
134 // Only the no-argument answer has `stages`; a fleet query or a `why` answer stays plain JSON.
135 const stages = list(obj(value)["stages"]).map(obj).filter((s) => text(s["key"]) in STAGE_LABEL);
136 if (stages.length === 0) return undefined;
137 const now = Date.now();
138 const typed = stages.map((s): PipelineStage => ({
139 key: text(s["key"]) as PipelineKey,
140 waiting: Number(s["waiting"]) || 0,
141 active: Number(s["active"]) || 0,
142 oldestWaiting: typeof s["oldestWaiting"] === "string" ? s["oldestWaiting"] : null,
143 capacity: typeof s["capacity"] === "string" ? s["capacity"] : null,
144 }));
145 for (const s of typed) {
146 const wait = s.oldestWaiting === null || Number.isNaN(Date.parse(s.oldestWaiting)) ? "" : ` · oldest ${span(now - Date.parse(s.oldestWaiting))}`;
147 rows.push({ label: STAGE_LABEL[s.key], value: `${s.waiting} waiting · ${s.active} active${wait}` });
148 }
149 return { kind, title: CARD_TITLE[kind], status: verdict(typed, now), rows, actions };
150 }

Crew branch: agents is mapped before the Array.isArray guard, and status reuses the outer variable. Guard first, then build. Rows are capped at LIMIT with no sign of the rest, but the issue asks for one row per agent.

151 if (kind === "crew") {
152 // `scorecard` answers `{ agents: [{ username, all, recent }], keeperDays: [{ landed }] }`.
153 const v = obj(value);
154 if (!Array.isArray(v["agents"])) return undefined;
155 const agents = v["agents"].map(obj);
156 const week = list(v["keeperDays"]).reduce<number>((n, d) => n + (Number(obj(d)["landed"]) || 0), 0);
157 const crewStatus = `${week} landed this week`;
158 for (const a of agents.slice(0, LIMIT)) {
159 const r = obj(a["recent"]);
160 const red = typeof r["redCheckRate"] === "number" ? `${Math.round(r["redCheckRate"] * 100)}% red` : "no checks";
161 rows.push({ label: text(a["username"]), value: `${Number(r["landed"]) || 0} landed this month · ${red}` });
162 }
163 // The card shows LIMIT agents; the rest are counted, never dropped silently.
164 if (agents.length > LIMIT) rows.push({ label: "More", value: `+${agents.length - LIMIT} more` });
165 return { kind, title: CARD_TITLE[kind], status: crewStatus, rows, actions };
166 }
128167 if (tool === "new_app") {
129168 const made = obj(value);
130169 const repo = text(made["repo"]);
test/mcp-flow-card.test.ts added +128
@@ -0,0 +1,128 @@
1 /** MCP `flow` (no arguments) and `scorecard` draw the `ui://forge/flow` and `ui://forge/crew` cards. */
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 { createAgentRow } from "../src/core/agents.ts";
8 import { createIssue } from "../src/core/issues.ts";
9 import { createRepo } from "../src/actions/repos.ts";
10 import { STAGE_LABEL } from "../src/flow/pipeline.ts";
11 import { createToken } from "../src/core/tokens.ts";
12 import { createUser } from "../src/core/users.ts";
13 import { serve, type Servers } from "../src/http/server.ts";
14 import { cardFor } from "../src/web/studio/cards.ts";
15 import { quietLogs } from "./helpers/globals.ts";
16 import { listVia } from "./mcp-list.ts";
17
18 quietLogs();
19
20 describe("MCP flow and crew cards", () => {
21 let dataDir: string;
22 let app: App;
23 let servers: Servers;
24 let token = "";
25 let id = 1;
26
27 const rpc = async (method: string, params: unknown = {}) => {
28 const res = await fetch(`${servers.url}/mcp`, {
29 method: "POST",
30 headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` },
31 body: JSON.stringify({ jsonrpc: "2.0", id: id++, method, params }),
32 });
33 return (await res.json()) as any;
34 };
35
36 beforeAll(async () => {
37 dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-flow-card-"));
38 app = createApp({ dataDir, port: 0 });
39 servers = await serve(app);
40 const a = await createUser(app.db, { origin: "seed", trusted: true,
41 username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
42 if (!a.ok) throw new Error("users");
43 token = createToken(app.db, a.value.id, "admin", "admin").token;
44 // One agent with a landed pull and one red check, for the crew card.
45 const bot = await createUser(app.db, { origin: "seed", trusted: true,
46 username: "smith", email: "[email protected]", password: "a-good-passphrase" });
47 if (!bot.ok) throw new Error("bot");
48 app.db.query("UPDATE user SET is_agent = 1 WHERE id = ?1").run(bot.value.id);
49 createAgentRow(app.db, { userId: bot.value.id, model: "opus" });
50 const repo = await createRepo(app, a.value, { name: "hello", visibility: "public" });
51 if (!repo.ok) throw new Error("repo");
52 const issue = createIssue(app.db, { repoId: repo.value.id, authorId: bot.value.id, title: "a pull", kind: "pull" });
53 if (!issue.ok) throw new Error("issue");
54 app.db.query(`INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, merged_at)
55 VALUES (?1, 'main', 'h', 'a', 'b', ?2)`).run(issue.value.id, new Date().toISOString());
56 app.db.query(`INSERT INTO run (id, repo_id, issue_id, kind, actor_id, state) VALUES (?1, ?2, ?3, 'check', ?4, 'failed')`)
57 .run(crypto.randomUUID(), repo.value.id, issue.value.id, bot.value.id);
58 }, 30_000);
59
60 afterAll(async () => {
61 servers.stop();
62 app.close();
63 await rm(dataDir, { recursive: true, force: true });
outdated

The label map is rebuilt by hand; import STAGE_LABEL instead so the test follows the code.

64 });
65
66 test("tools/list advertises both resources and both resources read", async () => {
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) {
69 expect(tools.find((t) => t.name === name)._meta.ui.resourceUri).toBe(`ui://forge/${kind}`);
70 const read = await rpc("resources/read", { uri: `ui://forge/${kind}` });
71 expect(read.result.contents[0].text.length).toBeGreaterThan(0);
72 }
73 });
74
75 test("flow with no arguments carries a card, one row per stage, beside the text", async () => {
76 const body = await rpc("tools/call", { name: "flow", arguments: {} });
77 const text = JSON.parse(body.result.content[0].text);
78 const card = body.result.structuredContent.card;
79 expect(card.kind).toBe("flow");
80 expect(card.title).toBe("Flow");
81 expect(card.rows.map((r: any) => r.label)).toEqual(text.stages.map((s: any) => (STAGE_LABEL as Record<string, string>)[s.key]));
82 expect(card.status).toBe(text.says);
83 });
84
85 test("a fleet query or a why answer stays plain", async () => {
86 const body = await rpc("tools/call", { name: "flow", arguments: { q: "repo" } });
87 expect(body.result.structuredContent?.card).toBeUndefined();
88 expect(cardFor("flow", { subject: "a/b", moving: true })).toBeUndefined();
89 });
90
outdated

Box 4 asks both models to be drawn from a seeded forge. The tool call here has no agents, so the per-agent rows are only checked with supplied data. Seed one agent and assert its row in the tool answer.

91 test("the flow card names the bottleneck and the oldest wait", () => {
92 const old = new Date(Date.now() - 3 * 3_600_000).toISOString();
93 const card = cardFor("flow", { stages: [
94 { key: "ready", waiting: 2, active: 0, oldestWaiting: new Date(Date.now() - 60_000).toISOString(), capacity: null },
95 { key: "review", waiting: 4, active: 1, oldestWaiting: old, capacity: null },
96 { key: "live", waiting: 0, active: 3, oldestWaiting: null, capacity: null },
97 ] })!;
98 expect(card.status).toContain("Review is the bottleneck");
99 expect(card.rows).toHaveLength(3);
100 expect(card.rows[1]!.value).toBe("4 waiting · 1 active · oldest 3h");
101 expect(card.rows[2]!.value).toBe("0 waiting · 3 active");
102 });
103
104 test("scorecard carries the crew card, one row per agent, status the week's landed", async () => {
105 const body = await rpc("tools/call", { name: "scorecard", arguments: {} });
106 const drawn = body.result.structuredContent.card;
107 expect(drawn.kind).toBe("crew");
108 expect(drawn.rows).toEqual([{ label: "smith", value: "1 landed this month · 100% red" }]);
109 const card = cardFor("scorecard", {
110 agents: [
111 { username: "scout", all: null, recent: { landed: 5, redCheckRate: 0.25 } },
112 { username: "<b>x</b>", all: null, recent: null },
113 ],
114 keeperDays: [{ landed: 2 }, { landed: 3 }],
115 })!;
116 expect(card.status).toBe("5 landed this week");
117 expect(card.rows).toEqual([
118 { label: "scout", value: "5 landed this month · 25% red" },
119 { label: "<b>x</b>", value: "0 landed this month · no checks" },
120 ]);
121 });
122
123 test("agents past the card's limit are counted, not dropped", () => {
124 const many = cardFor("scorecard", { agents: Array.from({ length: 15 }, (_, i) => ({ username: `a${i}`, all: null, recent: null })), keeperDays: [] })!;
125 expect(many.rows).toHaveLength(13);
126 expect(many.rows[12]).toEqual({ label: "More", value: "+3 more" });
127 });
128 });
test/mcp-forge-view.test.ts +13 −6
@@ -73,12 +73,19 @@ test("a private app another person owns is absent from both", async () => {
7373 });
7474
7575 test("a ro token lists the tool and is answered; polling builds once", async () => {
76 const res = await fetch(`${servers.url}/mcp`, {
77 method: "POST",
78 headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${adaRo}` },
79 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
80 });
81 const listed = ((await res.json()) as any).result.tools.find((t: any) => t.name === "forge_view");
76 // The list is paged by size: follow the cursor until the tool turns up.
77 let listed: any;
78 let cursor: string | undefined;
79 do {
80 const res = await fetch(`${servers.url}/mcp`, {
81 method: "POST",
82 headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${adaRo}` },
83 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: cursor === undefined ? {} : { cursor } }),
84 });
85 const page = ((await res.json()) as any).result;
86 listed = page.tools.find((t: any) => t.name === "forge_view");
87 cursor = listed === undefined ? page.nextCursor : undefined;
88 } while (listed === undefined && cursor !== undefined);
8289 expect(listed).toBeDefined();
8390 expect(listed.inputSchema.properties).toEqual({});
8491 // A build reads the runs once; three calls in one window may build once.
test/mcp.test.ts +4
@@ -1284,9 +1284,11 @@ describe("the forge over MCP", () => {
12841284 expect(uris).toEqual([
12851285 "ui://forge/app",
12861286 "ui://forge/asked",
1287 "ui://forge/crew",
12871288 "ui://forge/decision",
12881289 "ui://forge/explore",
12891290 "ui://forge/feed",
1291 "ui://forge/flow",
12901292 "ui://forge/issue",
12911293 "ui://forge/map",
12921294 "ui://forge/pull",
@@ -1315,6 +1317,8 @@ describe("the forge over MCP", () => {
13151317 expect(meta("pull")).toBe("ui://forge/pull");
13161318 expect(meta("pulls")).toBe("ui://forge/pulls");
13171319 expect(meta("asked")).toBe("ui://forge/asked");
1320 expect(meta("flow")).toBe("ui://forge/flow");
1321 expect(meta("scorecard")).toBe("ui://forge/crew");
13181322 });
13191323
13201324 test("tools/call pull carries the card beside an unchanged text answer", async () => {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs