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

`scorecard` over MCP says merged, returned and cost this week per member, the same words as /crew #10913

Merged opened this

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

Merged

mason/forge:mason/10905-scorecard-over-mcp-says-merged-returned → main 4 commits fork 3 threads · 3 unresolved

  • Merged. mason/10905-scorecard-over-mcp-says-merged-returned was merged into main as 1c50c59.

Review

Sign in to review this change.
7 files changed +94 −8
docs/features/crew-week-card.md +1
@@ -5,6 +5,7 @@ One line per crew member for the last 7 days: pulls **merged**, pulls **returned
55 - **Page:** the "This week" block on Settings → Crew (`/settings/crew`), above the scorecards.
66 - **API:** `GET /api/v1/crew/week` → `{ days, members[{ username, merged, returned, spentUsd, idle }] }`.
77 - **MCP:** `crew` with `{ kind: "week" }`, the same JSON.
8 - **MCP `scorecard`:** each `agents[]` entry carries `week { merged, returned, cost }`, the same figures; `cost` is USD and is `0` for an idle member, never the word `idle`. The crew card in the studio shows it as `N merged · N returned · $X this week`, the week alone.
89 - **Terminal:** **Crew this week** in the palette (`j`/`k` to move).
910 - **Who sees it:** forge admins only, on all four doors; per-agent spend is theirs. A non-admin gets 404 on the page, 403 from the API and MCP, and one sentence in the terminal.
1011 - **Cost:** three grouped reads regardless of crew size; a test counts the statements.
src/actions/scorecard.ts +10
@@ -17,6 +17,7 @@ import { wastedDays, type WastedDay } from "../core/wasted_days.ts";
1717 import { listAgents } from "../core/agents.ts";
1818 import { keeperDays, type KeeperDay } from "../flow/keeper_rate.ts";
1919 import type { User } from "../core/users.ts";
20 import { weekCard } from "../crew/week_card.ts";
2021
2122 export interface AgentScorecard {
2223 readonly username: string;
@@ -24,6 +25,8 @@ export interface AgentScorecard {
2425 readonly all: Scorecard | null;
2526 /** The last `RECENT_DAYS`; null when it opened none in that time. */
2627 readonly recent: Scorecard | null;
28 /** The last 7 days, the same line /crew shows: merged, returned, cost in USD. */
29 readonly week: { readonly merged: number; readonly returned: number; readonly cost: number };
2730 readonly splits: readonly (Scorecard & {
2831 readonly model: string;
2932 readonly prompt: string;
@@ -57,6 +60,7 @@ export function readScorecards(
5760 const recent = crewScorecards(app.db, window);
5861 const splits = crewScorecardSplits(app.db);
5962 const recentSplits = crewScorecardSplits(app.db, window);
63 const week = new Map(weekCard(app.db).map((l) => [l.username, l]));
6064 const key = (p: SplitScorecard) => `${p.model}\u0000${p.promptVersion}`;
6165 return {
6266 modelDays: modelDays(app.db),
@@ -69,10 +73,16 @@ export function readScorecards(
6973 const lately = new Map((recentSplits.get(id) ?? []).map((p) => [key(p), p]));
7074 const a = all.get(id);
7175 const r = recent.get(id);
76 const w = week.get(agent.user.username);
7277 return {
7378 username: agent.user.username,
7479 all: a === undefined ? null : figures(a),
7580 recent: r === undefined ? null : figures(r),
81 week: {
outdated

Look up week.get(username) once into a const instead of three times.

82 merged: w?.merged ?? 0,
83 returned: w?.returned ?? 0,
84 cost: w?.spentUsd ?? 0,
85 },
7686 splits: (splits.get(id) ?? []).map((p) => {
7787 const l = lately.get(key(p));
7888 return {
src/http/mcp/tools/scorecard.ts +1 −1
@@ -8,7 +8,7 @@ export const tool: Tool = {
88 "Crew scorecard (forge admin): per agent, all time and 30 days.",
99 inputSchema: { type: "object", properties: {} },
1010 writes: false,
11 doc: "(forge admin) no input: the crew's scorecard per agent, all time and 30 days: landed, review rounds, red rate, reverts",
11 doc: "(forge admin) no input: the crew's scorecard per agent, all time and 30 days: landed, review rounds, red rate, reverts; week {merged, returned, cost}",
1212 adminOnly: true,
1313 run(ctx, user) {
1414 const card = readScorecards(ctx.app, user);
src/web/studio/cards.ts +3 −3
@@ -166,9 +166,9 @@ export function cardFor(
166166 const week = list(v["keeperDays"]).reduce<number>((n, d) => n + (Number(obj(d)["landed"]) || 0), 0);
167167 const crewStatus = `${week} landed this week`;
168168 for (const a of agents.slice(0, LIMIT)) {
169 const r = obj(a["recent"]);
170 const red = typeof r["redCheckRate"] === "number" ? `${Math.round(r["redCheckRate"] * 100)}% red` : "no checks";
171 rows.push({ label: text(a["username"]), value: `${Number(r["landed"]) || 0} landed this month · ${red}` });
169 const w = obj(a["week"]);
170 const wk = `${Number(w["merged"]) || 0} merged · ${Number(w["returned"]) || 0} returned · $${(Number(w["cost"]) || 0).toFixed(2)} this week`;
171 rows.push({ label: text(a["username"]), value: wk });
172172 }
173173 // The card shows LIMIT agents; the rest are counted, never dropped silently.
outdated

Principle 1/8: 'landed this month' next to 'merged ... this week' in one row, under a status that says 'landed this week', reads as contradictory. One window per row; drop the month landed or show the week alone.

174174 if (agents.length > LIMIT) rows.push({ label: "More", value: `+${agents.length - LIMIT} more` });
test/crew-scorecard-api.test.ts +1 −1
@@ -109,7 +109,7 @@ describe("the crew scorecard over the API and MCP", () => {
109109 expect(smith.splits[0]).toMatchObject({ model: "", prompt: "", opened: 1, meanLandMs: null });
110110 expect(smith.splits[0].recent.opened).toBe(1);
111111 const idle = body.agents.find((a) => a.username === "idle");
112 expect(idle).toEqual({ username: "idle", all: null, recent: null, splits: [] });
112 expect(idle).toEqual({ username: "idle", all: null, recent: null, week: { merged: 0, returned: 0, cost: 0 }, splits: [] });
113113 });
114114
115115 test("wastedDays rides on the API and the MCP tool", async () => {
test/crew-scorecard-week.test.ts added +75
@@ -0,0 +1,75 @@
1 /** MCP `scorecard` carries each member's week {merged, returned, cost}: the numbers /crew shows (#10905). */
2 import { afterAll, beforeAll, 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 { createSession } from "../src/core/sessions.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 { seedAdmin } from "./helpers/admin.ts";
15 import { quietLogs } from "./helpers/globals.ts";
16 import { cardFor } from "../src/web/studio/cards.ts";
17
18 quietLogs();
19 let dir: string, app: App, servers: Servers, token: string, cookie: string;
20
21 beforeAll(async () => {
22 dir = await mkdtemp(join(tmpdir(), "forge-scorecard-week-"));
23 app = createApp({ dataDir: dir, port: 0 });
24 servers = await serve(app);
25 const ada = await seedAdmin(app, { username: "ada", email: "[email protected]" });
26 if (!ada.ok) throw new Error(ada.error);
27 const repo = await createRepo(app, ada.value, { name: "hello", visibility: "public" });
28 if (!repo.ok) throw new Error(String(repo.error));
29 const bot = async (username: string) => {
30 const b = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase" });
31 if (!b.ok) throw new Error(String(b.error));
32 app.db.query("UPDATE user SET is_agent = 1 WHERE id = ?1").run(b.value.id);
33 createAgentRow(app.db, { userId: b.value.id, model: "opus" });
34 return b.value;
35 };
36 const rev = await bot("rev");
37 const smith = await bot("smith");
38 const at = new Date(Date.now() - 86_400_000).toISOString();
39 for (const merged of [true, false]) {
40 const i = createIssue(app.db, { repoId: repo.value.id, authorId: smith.id, title: "p", kind: "pull" });
41 if (!i.ok) throw new Error(String(i.error));
42 app.db.query(`INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, merged_at) VALUES (?1,'main','h','a','b',?2)`).run(i.value.id, merged ? at : null);
43 if (!merged)
44 app.db.query(`INSERT INTO review (id, issue_id, author_id, state, created_at) VALUES (?1,?2,?3,'changes_requested',?4)`).run(crypto.randomUUID(), i.value.id, rev.id, at);
45 }
46 const run = crypto.randomUUID();
47 app.db.query(`INSERT INTO run (id, repo_id, kind, actor_id, state) VALUES (?1,?2,'agent',?3,'ok')`).run(run, repo.value.id, smith.id);
48 app.db.query(`INSERT INTO usage (run_id, repo_id, agent_id, subscription_id, kind, cost_usd, outcome, at) VALUES (?1,?2,?3,'platform','build',1.25,'ok',?4)`).run(run, repo.value.id, smith.id, at);
49 token = createToken(app.db, ada.value.id, "t", "admin").token;
50 cookie = `forge_session=${createSession(app.db, ada.value.id).token}`;
51 });
52 afterAll(async () => {
53 await servers.stop();
54 app.close();
55 await rm(dir, { recursive: true, force: true });
56 });
57
58 test("MCP scorecard, the card and the /crew page agree on merged, returned and cost", async () => {
59 const res = await fetch(`${servers.url}/mcp`, {
60 method: "POST",
61 headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` },
62 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "scorecard", arguments: {} } }),
63 }).then((r) => r.json() as Promise<any>);
64 const card = JSON.parse(res.result.content[0].text);
65 expect(card.agents.find((a: any) => a.username === "smith").week).toEqual({ merged: 1, returned: 1, cost: 1.25 });
66 expect(card.agents.find((a: any) => a.username === "rev").week).toEqual({ merged: 0, returned: 0, cost: 0 });
67 const page = await (await fetch(`${servers.url}/settings/crew`, { headers: { cookie } })).text();
68 const t = page.slice(page.indexOf('id="crew-week"'));

The regex /\>1<[\s\S]>1<[\s\S]\$1\.25/ is loose and passes on wrong cells. Assert each cell of the smith row.

69 const row = t.slice(t.indexOf("smith"), t.indexOf("</tr>", t.indexOf("smith")));
70 const cells = [...row.matchAll(/<td class="num">([\s\S]*?)<\/td>/g)].map((m) => m[1]!.replace(/<[^>]*>/g, "").trim());
71 expect(cells[0]).toBe("1");
72 expect(cells[1]).toMatch(/^1\b/);
73 expect(cells[2]).toBe("$1.25");
74 expect(JSON.stringify(cardFor("scorecard", card))).toContain("1 merged · 1 returned · $1.25 this week");
75 });
test/mcp-flow-card.test.ts +3 −3
@@ -105,7 +105,7 @@ describe("MCP flow and crew cards", () => {
105105 const body = await rpc("tools/call", { name: "scorecard", arguments: {} });
106106 const drawn = body.result.structuredContent.card;
107107 expect(drawn.kind).toBe("crew");
108 expect(drawn.rows).toEqual([{ label: "smith", value: "1 landed this month · 100% red" }]);
108 expect(drawn.rows).toEqual([{ label: "smith", value: "1 merged · 0 returned · $0.00 this week" }]);
109109 const card = cardFor("scorecard", {
110110 agents: [
111111 { username: "scout", all: null, recent: { landed: 5, redCheckRate: 0.25 } },
@@ -115,8 +115,8 @@ describe("MCP flow and crew cards", () => {
115115 })!;
116116 expect(card.status).toBe("5 landed this week");
117117 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" },
118 { label: "scout", value: "0 merged · 0 returned · $0.00 this week" },
119 { label: "<b>x</b>", value: "0 merged · 0 returned · $0.00 this week" },
120120 ]);
121121 });
122122

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs