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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/10905-scorecard-over-mcp-says-merged-returned → main 4 commits fork 3 threads · 3 unresolved
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 | ||
| 5 | 5 | - **Page:** the "This week" block on Settings → Crew (`/settings/crew`), above the scorecards. |
| 6 | 6 | - **API:** `GET /api/v1/crew/week` → `{ days, members[{ username, merged, returned, spentUsd, idle }] }`. |
| 7 | 7 | - **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. | |
| 8 | 9 | - **Terminal:** **Crew this week** in the palette (`j`/`k` to move). |
| 9 | 10 | - **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. |
| 10 | 11 | - **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"; | ||
| 17 | 17 | import { listAgents } from "../core/agents.ts"; |
| 18 | 18 | import { keeperDays, type KeeperDay } from "../flow/keeper_rate.ts"; |
| 19 | 19 | import type { User } from "../core/users.ts"; |
| 20 | import { weekCard } from "../crew/week_card.ts"; | |
| 20 | 21 | |
| 21 | 22 | export interface AgentScorecard { |
| 22 | 23 | readonly username: string; |
| @@ -24,6 +25,8 @@ export interface AgentScorecard { | ||
| 24 | 25 | readonly all: Scorecard | null; |
| 25 | 26 | /** The last `RECENT_DAYS`; null when it opened none in that time. */ |
| 26 | 27 | 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 }; | |
| 27 | 30 | readonly splits: readonly (Scorecard & { |
| 28 | 31 | readonly model: string; |
| 29 | 32 | readonly prompt: string; |
| @@ -57,6 +60,7 @@ export function readScorecards( | ||
| 57 | 60 | const recent = crewScorecards(app.db, window); |
| 58 | 61 | const splits = crewScorecardSplits(app.db); |
| 59 | 62 | const recentSplits = crewScorecardSplits(app.db, window); |
| 63 | const week = new Map(weekCard(app.db).map((l) => [l.username, l])); | |
| 60 | 64 | const key = (p: SplitScorecard) => `${p.model}\u0000${p.promptVersion}`; |
| 61 | 65 | return { |
| 62 | 66 | modelDays: modelDays(app.db), |
| @@ -69,10 +73,16 @@ export function readScorecards( | ||
| 69 | 73 | const lately = new Map((recentSplits.get(id) ?? []).map((p) => [key(p), p])); |
| 70 | 74 | const a = all.get(id); |
| 71 | 75 | const r = recent.get(id); |
| 76 | const w = week.get(agent.user.username); | |
| 72 | 77 | return { |
| 73 | 78 | username: agent.user.username, |
| 74 | 79 | all: a === undefined ? null : figures(a), |
| 75 | 80 | recent: r === undefined ? null : figures(r), |
| 81 | week: { | |
| 82 | merged: w?.merged ?? 0, | |
| 83 | returned: w?.returned ?? 0, | |
| 84 | cost: w?.spentUsd ?? 0, | |
| 85 | }, | |
| 76 | 86 | splits: (splits.get(id) ?? []).map((p) => { |
| 77 | 87 | const l = lately.get(key(p)); |
| 78 | 88 | return { |
src/http/mcp/tools/scorecard.ts +1 −1
| @@ -8,7 +8,7 @@ export const tool: Tool = { | ||
| 8 | 8 | "Crew scorecard (forge admin): per agent, all time and 30 days.", |
| 9 | 9 | inputSchema: { type: "object", properties: {} }, |
| 10 | 10 | 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}", | |
| 12 | 12 | adminOnly: true, |
| 13 | 13 | run(ctx, user) { |
| 14 | 14 | const card = readScorecards(ctx.app, user); |
src/web/studio/cards.ts +3 −3
| @@ -166,9 +166,9 @@ export function cardFor( | ||
| 166 | 166 | const week = list(v["keeperDays"]).reduce<number>((n, d) => n + (Number(obj(d)["landed"]) || 0), 0); |
| 167 | 167 | const crewStatus = `${week} landed this week`; |
| 168 | 168 | 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 }); | |
| 172 | 172 | } |
| 173 | 173 | // 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. |
||
| 174 | 174 | 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", () => { | ||
| 109 | 109 | expect(smith.splits[0]).toMatchObject({ model: "", prompt: "", opened: 1, meanLandMs: null }); |
| 110 | 110 | expect(smith.splits[0].recent.opened).toBe(1); |
| 111 | 111 | 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: [] }); | |
| 113 | 113 | }); |
| 114 | 114 | |
| 115 | 115 | 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", () => { | ||
| 105 | 105 | const body = await rpc("tools/call", { name: "scorecard", arguments: {} }); |
| 106 | 106 | const drawn = body.result.structuredContent.card; |
| 107 | 107 | 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" }]); | |
| 109 | 109 | const card = cardFor("scorecard", { |
| 110 | 110 | agents: [ |
| 111 | 111 | { username: "scout", all: null, recent: { landed: 5, redCheckRate: 0.25 } }, |
| @@ -115,8 +115,8 @@ describe("MCP flow and crew cards", () => { | ||
| 115 | 115 | })!; |
| 116 | 116 | expect(card.status).toBe("5 landed this week"); |
| 117 | 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" }, | |
| 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" }, | |
| 120 | 120 | ]); |
| 121 | 121 | }); |
| 122 | 122 |
Look up week.get(username) once into a const instead of three times.