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

/crew shows a scorecard row per team, pooled from its members #3227

Merged opened this

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

Merged

scout/forge:scout/3204-crew-shows-a-scorecard-row-per-team-pool → main 2 commits fork 2 threads · 2 unresolved

  • Merged. scout/3204-crew-shows-a-scorecard-row-per-team-pool was merged into main as 4261587.

Review

Sign in to review this change.
5 files changed +320 −27
README.md +6 −1
@@ -27,6 +27,9 @@ claim state and the same apps-running note, never a claim link.
2727 Each agent's scorecard — pulls landed of opened, review rounds, red checks,
2828 reverts, time to land — is on Settings → Crew (`/settings/crew`) and in the
2929 terminal's palette as "Crew scorecard per agent"; both are a forge admin's only.
30 Under the agents, each team that has a pull has one pooled row (its members'
31 counts added, the ratios taken from the sums; an agent on two teams counts in
32 both): `data-team` rows on the page, "team <name>" rows in that terminal screen.
3033 Landed, review rounds and red checks stand as the last 30 days (pulls opened
3134 since then) beside all time, so tuning an agent shows as movement; reverts and
3235 time to land are all time. Under each agent, a sub-row per model and prompt
@@ -938,7 +941,9 @@ share a version and editing the brief starts a new one). Settings → Crew shows
938941 the scorecard cut both ways ("Per model", "Per prompt version": runs, landed,
939942 failed, spent, per run), and the terminal's `Crew scorecard` command shows the
940943 same to an admin. The scorecard of pulls landed, review rounds and red checks
941 is cut the same way as sub-rows under each agent (see above). Runs from before this was recorded show as *default* /
944 is cut the same way as sub-rows under each agent (see above); a team's pooled
945 row is not cut by model, and is under the agents on the page and in the
946 terminal's "Crew scorecard per agent" (see above). Runs from before this was recorded show as *default* /
942947 *not recorded*; they are not backfilled.
943948
944949 **Tell the crew from any page.** In a repository you may direct, the web palette
src/core/scorecard.ts +90 −21
@@ -20,6 +20,7 @@
2020 */
2121
2222 import type { Database } from "bun:sqlite";
23 import { listTeams } from "./teams.ts";
2324
2425 export interface Scorecard {
2526 /** Pulls the agent opened. */
@@ -71,6 +72,63 @@ export function crewScorecards(
7172 return new Map(scorecardRows(db, window, false).map((r) => [r.authorId, r.card]));
7273 }
7374
75 /**
76 * Each team's scorecard, keyed by team slug: its members' counts pooled, then
77 * the ratios taken from the pooled counts (never an average of the members'
78 * ratios, which would weigh a one-pull agent like a fifty-pull one). A team
79 * whose members opened no pull in the window is absent. An agent on two
80 * teams counts toward both.
81 */
82 export function teamScorecards(
83 db: Database,
84 window: ScorecardWindow = {},
85 ): Map<string, Scorecard> {
86 const byAgent = new Map(scorecardRows(db, window, false).map((r) => [r.authorId, r]));
87 const out = new Map<string, Scorecard>();
88 for (const team of listTeams(db)) {
89 const sum = { opened: 0, landed: 0, rounds: 0, ok: 0, failed: 0, reverts: 0, landDays: 0 };
90 let any = false;
91 for (const id of team.members) {
92 const r = byAgent.get(id);
93 if (r === undefined) continue;
94 any = true;
95 sum.opened += r.card.opened;
96 sum.landed += r.card.landed;
97 sum.rounds += r.rounds;
98 sum.ok += r.checksOk;
99 sum.failed += r.checksFailed;
100 sum.reverts += r.card.reverts;
101 sum.landDays += r.landDays;
102 }
103 if (any) out.set(team.slug, toCard(sum));
104 }
105 return out;
106 }
107
108 interface Counts {
109 opened: number;
110 landed: number;
111 rounds: number;
112 ok: number;
113 failed: number;
114 reverts: number;
115 /** Days from opening to merge, summed over landed pulls. */
116 landDays: number;
117 }
118
119 function toCard(c: Counts): Scorecard {
120 const checks = c.ok + c.failed;
121 return {
122 opened: c.opened,
123 landed: c.landed,
124 reviewRounds: c.landed === 0 ? null : c.rounds / c.landed,
125 redCheckRate: checks === 0 ? null : c.failed / checks,
126 reverts: c.reverts,
127 meanLandMs:
128 c.landed === 0 ? null : Math.max(0, Math.round((c.landDays / c.landed) * 86_400_000)),
129 };
130 }
131
74132 /**
75133 * Each agent's scorecard cut by (model, prompt version), keyed by user id.
76134 * A pull belongs to the pairing of the agent's latest run on it; a pull with
@@ -101,7 +159,16 @@ function scorecardRows(
101159 db: Database,
102160 window: ScorecardWindow,
103161 split: boolean,
104 ): { authorId: string; model: string; promptVersion: string; card: Scorecard }[] {
162 ): {
163 authorId: string;
164 model: string;
165 promptVersion: string;
166 card: Scorecard;
167 rounds: number;
168 checksOk: number;
169 checksFailed: number;
170 landDays: number;
171 }[] {
105172 // created_at is stored as ISO-8601 UTC, so the string compare is the time compare.
106173 // An invalid Date throws here rather than widen the window to all time.
107174 const since = window.since === undefined ? null : window.since.toISOString();
@@ -117,7 +184,7 @@ function scorecardRows(
117184 checks_ok: number;
118185 checks_failed: number;
119186 reverts: number;
120 mean_land_days: number | null;
187 land_days: number | null;
121188 },
122189 [string | null]
123190 >(
@@ -140,9 +207,9 @@ function scorecardRows(
140207 SELECT 1 FROM pull AS back
141208 WHERE back.reverts_issue_id = issue.id
142209 AND back.merged_at IS NOT NULL)) AS reverts,
143 AVG(CASE WHEN pull.merged_at IS NOT NULL
210 SUM(CASE WHEN pull.merged_at IS NOT NULL
144211 THEN julianday(pull.merged_at) - julianday(issue.created_at) END)
145 AS mean_land_days
212 AS land_days
146213 FROM issue
147214 JOIN pull ON pull.issue_id = issue.id
148215 JOIN user ON user.id = issue.author_id AND user.is_agent = 1
@@ -151,23 +218,25 @@ function scorecardRows(
151218 )
152219 .all(since);
153220 return rows.map((r) => {
154 const checks = r.checks_ok + r.checks_failed;
155 return {
156 authorId: r.author_id,
157 model: r.model,
158 promptVersion: r.prompt_version,
159 card: {
160 opened: r.opened,
161 landed: r.landed,
162 reviewRounds: r.landed === 0 ? null : r.rounds / r.landed,
163 redCheckRate: checks === 0 ? null : r.checks_failed / checks,
164 reverts: r.reverts,
165 meanLandMs:
166 r.mean_land_days === null
167 ? null
168 : Math.max(0, Math.round(r.mean_land_days * 86_400_000)),
169 },
170 };
221 const counts = {
222 opened: r.opened,
223 landed: r.landed,
224 rounds: r.rounds,
225 ok: r.checks_ok,
226 failed: r.checks_failed,
227 reverts: r.reverts,
228 landDays: r.land_days ?? 0,
229 };
230 return {
231 authorId: r.author_id,
232 model: r.model,
233 promptVersion: r.prompt_version,
234 card: toCard(counts),
235 rounds: r.rounds,
236 checksOk: r.checks_ok,
237 checksFailed: r.checks_failed,
238 landDays: counts.landDays,
239 };
171240 });
172241 }
173242
src/tui/screens/scorecard.ts +19 −3
@@ -11,6 +11,7 @@ import { listAllAgents } from "../../actions/crew.ts";
1111 import {
1212 crewScorecards,
1313 crewScorecardSplits,
14 teamScorecards,
1415 RECENT_DAYS,
1516 recentWindow,
1617 spanText,
@@ -19,6 +20,7 @@ import {
1920 type SplitScorecard,
2021 } from "../../core/scorecard.ts";
2122 import type { Agent } from "../../core/agents.ts";
23 import { listTeams, type Team } from "../../core/teams.ts";
2224 import type { Ctx, Screen } from "../app/app.ts";
2325 import { binding } from "../app/keys.ts";
2426 import { style } from "../term/style.ts";
@@ -58,7 +60,8 @@ function figures(
5860 * zeros. Each windowed figure reads "<recent>d … · all …", the page's order.
5961 * Under an agent, one indented row per model · prompt version its pulls ran
6062 * on, with the same figures ("unrecorded" for pulls from before runs
61 * recorded them).
63 * recorded them). Then one row per team that has a card, its members'
64 * counts pooled (an agent on two teams counts in both), under the agents.
6265 */
6366 export function scorecardRows(
6467 agents: readonly Agent[],
@@ -66,8 +69,11 @@ export function scorecardRows(
6669 recent: ReadonlyMap<string, Scorecard>,
6770 splits: ReadonlyMap<string, readonly SplitScorecard[]> = new Map(),
6871 recentSplits: ReadonlyMap<string, readonly SplitScorecard[]> = new Map(),
72 teams: readonly Team[] = [],
73 teamCards: ReadonlyMap<string, Scorecard> = new Map(),
74 recentTeamCards: ReadonlyMap<string, Scorecard> = new Map(),
6975 ): ListRow[] {
70 return agents.flatMap((agent) => {
76 const agentRows = agents.flatMap((agent) => {
7177 const s = cards.get(agent.user.id);
7278 if (s === undefined)
7379 return [{ title: agent.user.username, secondary: "no pull yet" }];
@@ -81,6 +87,13 @@ export function scorecardRows(
8187 ),
8288 ];
8389 });
90 const teamRows = teams.flatMap((team) => {
91 const s = teamCards.get(team.slug);
92 return s === undefined
93 ? []
94 : [figures(`team ${team.name}`, recentTeamCards.get(team.slug), s)];
95 });
96 return [...agentRows, ...teamRows];
8497 }
8598
8699 export function scorecardScreen(f: Session, ctx: Ctx): Screen {
@@ -96,6 +109,9 @@ export function scorecardScreen(f: Session, ctx: Ctx): Screen {
96109 crewScorecards(f.db, recentWindow()),
97110 crewScorecardSplits(f.db),
98111 crewScorecardSplits(f.db, recentWindow()),
112 listTeams(f.db),
113 teamScorecards(f.db),
114 teamScorecards(f.db, recentWindow()),
99115 )
100116 : [];
101117 return {
@@ -109,7 +125,7 @@ export function scorecardScreen(f: Session, ctx: Ctx): Screen {
109125 : rows.length === 0
110126 ? "No agents yet."
111127 : `Over the last ${RECENT_DAYS} days (${RECENT_DAYS}d) and all time, ` +
112 "as Settings → Crew shows it.";
128 "as Settings → Crew shows it, with a pooled row per team.";
113129 s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
114130 drawList(s, list!, c, state, rows);
115131 },
src/web/pages/crew.ts +20 −2
@@ -27,6 +27,7 @@ import {
2727 import {
2828 crewScorecards,
2929 crewScorecardSplits,
30 teamScorecards,
3031 splitLabel,
3132 RECENT_DAYS,
3233 recentWindow,
@@ -34,6 +35,7 @@ import {
3435 type Scorecard,
3536 type SplitScorecard,
3637 } from "../../core/scorecard.ts";
38 import { listTeams, type Team } from "../../core/teams.ts";
3739 import { crewFor } from "../../crew/dispatch.ts";
3840 import { MODELS } from "../../crew/models.ts";
3941 import type { Ctx } from "../../http/context.ts";
@@ -247,6 +249,9 @@ const pairKey = (p: SplitScorecard) => `${p.model}\0${p.promptVersion}`;
247249 */
248250 function scorecardCard(
249251 agents: readonly Agent[],
252 teams: readonly Team[],

Three team parameters were slotted ahead of cards/recent/splits. Eight positional args of near-identical Map types is easy to swap silently; group them in one object or append them last, as the TUI does.

253 teamCards: ReadonlyMap<string, Scorecard>,
254 recentTeamCards: ReadonlyMap<string, Scorecard>,
250255 cards: ReadonlyMap<string, Scorecard>,
251256 recent: ReadonlyMap<string, Scorecard>,
252257 splits: ReadonlyMap<string, readonly SplitScorecard[]>,
@@ -284,7 +289,7 @@ function scorecardCard(
284289 <table class="table" id="crew-scorecard" data-sticky-lead>
285290 <thead>
286291 <tr>
287 <th>Agent</th>
292 <th>Agent / team</th>
288293 ${windowHead("Pulls landed", lately)}
289294 ${windowHead("Pulls landed", "all time")}
290295 ${windowHead("Review rounds", lately)}
@@ -320,6 +325,12 @@ function scorecardCard(
320325 </tr>`;
321326 })}`;
322327 })}
328 ${teams
329 .filter((team) => teamCards.has(team.slug))
330 .map((team) => html`<tr data-team="${team.slug}">

The only cue that this is a team is a small muted word. Add a tokenised separator or tone for [data-team] rows (a top border from existing tokens) so the group reads at a glance on 360px.

331 <td>${team.name} <span class="muted small">team</span></td>
332 ${figures(recentTeamCards.get(team.slug), teamCards.get(team.slug))}
333 </tr>`)}
323334 </tbody>
324335 </table>
325336 </div>`,
@@ -329,7 +340,8 @@ function scorecardCard(
329340 head: html`${icon("activity")}
330341 <h2>Scorecard</h2>
331342 <span class="muted small"
332 >Over the pulls each agent opened in the last ${RECENT_DAYS} days
343 >Over the pulls each agent opened, and each team's members pooled (an
344 agent on two teams counts in both), in the last ${RECENT_DAYS} days
333345 and over all time: review rounds are change requests per landed pull, red checks
334346 the share of finished checks that failed; reverts (the landed pulls
335347 a merged pull took back) and time to land (the mean from opening to
@@ -401,6 +413,9 @@ function crewView(ctx: Ctx, values: AgentValues, error?: string): Html {
401413 const listed = listAllAgents(ctx.app, ctx.auth.user);
402414 const agents = listed.ok ? listed.value : [];
403415 const totals = agentTotals(ctx.app.db);
416 const teams = listTeams(ctx.app.db);
417 const teamCards = teamScorecards(ctx.app.db);
418 const recentTeamCards = teamScorecards(ctx.app.db, recentWindow());
404419 const scorecards = crewScorecards(ctx.app.db);
405420 const recentScorecards = crewScorecards(ctx.app.db, recentWindow());
406421 const splitScorecards = crewScorecardSplits(ctx.app.db);
@@ -505,6 +520,9 @@ function crewView(ctx: Ctx, values: AgentValues, error?: string): Html {
505520 ${when(agents.length > 0, () =>
506521 scorecardCard(
507522 agents,
523 teams,
524 teamCards,
525 recentTeamCards,
508526 scorecards,
509527 recentScorecards,
510528 splitScorecards,
test/crew-team-scorecard.test.ts added +185
@@ -0,0 +1,185 @@
1 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2 import { mkdtemp, rm } from "node:fs/promises";
3 import { tmpdir } from "node:os";
4 import { join } from "node:path";
5 import { createApp, type App } from "../src/app.ts";
6 import { createAgentRow } from "../src/core/agents.ts";
7 import { createIssue } from "../src/core/issues.ts";
8 import { submitReview } from "../src/core/reviews.ts";
9 import { claimRun, createRun, finishRun } from "../src/core/runs.ts";
10 import { crewScorecards, teamScorecards, recentWindow } from "../src/core/scorecard.ts";
11 import { listTeams, saveTeam } from "../src/core/teams.ts";
12 import { createSession } from "../src/core/sessions.ts";
13 import { createUser, getUserByName } from "../src/core/users.ts";
14 import { serve, type Servers } from "../src/http/server.ts";
15 import { createForgeApp } from "../src/tui/forge.ts";
16 import { headlessTerminal } from "../src/tui/term/terminal.ts";
17 import { settleApp } from "./helpers/tui.ts";
18 import { createRepo } from "../src/core/repos.ts";
19 import { quietLogs } from "./helpers/globals.ts";
20
21 quietLogs();
22
23 describe("a scorecard row per team", () => {
24 let dataDir: string;
25 let app: App;
26 let servers: Servers;
27 let repoId = "";
28 let reviewer = "";
29 const ids: Record<string, string> = {};
30
31 function pull(author: string, title: string, merged: boolean, hours = 2, openedAt?: string) {
32 const issue = createIssue(app.db, { repoId, authorId: ids[author]!, title, kind: "pull" });
33 if (!issue.ok) throw new Error(issue.error);
34 const opened = openedAt ?? "2026-10-01T00:00:00.000Z";
35 app.db.query(`UPDATE issue SET created_at = ?2 WHERE id = ?1`).run(issue.value.id, opened);
36 const mergedAt = merged ? new Date(Date.parse(opened) + hours * 3_600_000).toISOString() : null;
37 app.db
38 .query(
39 `INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, merged_at)
40 VALUES (?1, 'main', ?2, 'a', 'b', ?3)`,
41 )
42 .run(issue.value.id, `h${issue.value.number}`, mergedAt);
43 return issue.value.id;
44 }
45 const check = (issueId: string, state: "ok" | "failed") => {
46 const run = createRun(app.db, { repoId, kind: "check", issueId });
47 claimRun(app.db, run.id);
48 finishRun(app.db, run.id, { state, summary: state });
49 };
50 const change = (issueId: string) =>
51 submitReview(app.db, {
52 issueId,
53 authorId: reviewer,
54 state: "changes_requested",
55 body: "",
56 commitOid: "b",
57 });
58
59 beforeAll(async () => {
60 dataDir = await mkdtemp(join(tmpdir(), "forge-team-scorecard-"));
61 app = createApp({ dataDir, port: 0 });
62 for (const name of ["boss", "a1", "a2", "b1", "idle"]) {
63 const made = await createUser(app.db, {
64 origin: "seed",
65 trusted: true,
66 username: name,
67 email: `${name}@forge.local`,
68 password: "a-good-passphrase",
69 adminIfFirst: name === "boss",
70 });
71 if (!made.ok) throw new Error(made.error);
72 ids[name] = made.value.id;
73 if (name !== "boss") app.db.query("UPDATE user SET is_agent = 1 WHERE id = ?1").run(made.value.id);
74 if (name !== "boss") createAgentRow(app.db, { userId: made.value.id, model: "opus" });
75 }
76 reviewer = ids.boss!;
77 const repo = await createRepo(app.db, app.reposRoot, { ownerId: reviewer, name: "hello" });
78 if (!repo.ok) throw new Error(repo.error);
79 repoId = repo.value.id;
80 const [one, two, three] = listTeams(app.db).map((t) => t.slug);
81 saveTeam(app.db, one!, { charter: "", dial: 0, members: [ids.a1!, ids.a2!] });
82 saveTeam(app.db, two!, { charter: "", dial: 0, members: [ids.b1!] });
83 saveTeam(app.db, three!, { charter: "", dial: 0, members: [ids.idle!] });
84 });
85
86 afterAll(async () => {
87 servers?.stop();
88 app.close();
89 await rm(dataDir, { recursive: true, force: true });
90 });
91
92 test("ratios come from pooled counts, not an average of ratios", () => {
93 // a1: one landed pull, 1 change request, 1 failed of 1 check (rate 1.0).
94 const p1 = pull("a1", "a1 one", true, 2);
95 change(p1);
96 check(p1, "failed");
97 // a2: three landed pulls, no change requests, 0 failed of 3 checks (rate 0).
98 for (const n of [1, 2, 3]) check(pull("a2", `a2 ${n}`, true, 6), "ok");
99 // b1: one landed, one open.
100 pull("b1", "b1 one", true, 4);
101 pull("b1", "b1 open", false);
102
103 const [one, two, three] = listTeams(app.db).map((t) => t.slug);
104 const cards = teamScorecards(app.db);
105 const a = cards.get(one!)!;
106 expect([a.opened, a.landed]).toEqual([4, 4]);
107 expect(a.reviewRounds).toBe(0.25); // 1 ÷ 4, not mean(1, 0)
108 expect(a.redCheckRate).toBe(0.25); // 1 of 4 finished, not mean(1, 0)
109 expect(a.meanLandMs).toBe(5 * 3_600_000); // (2 + 6 + 6 + 6) ÷ 4
110 const b = cards.get(two!)!;
111 expect([b.opened, b.landed, b.reviewRounds, b.redCheckRate]).toEqual([2, 1, 0, null]);
112 expect(b.meanLandMs).toBe(4 * 3_600_000);
113 // A team with no pulls has no card; the page says dashes.
114 expect(cards.has(three!)).toBe(false);
115 // The members' own cards are unchanged by pooling.
116 expect(crewScorecards(app.db).get(ids.a1!)!.reviewRounds).toBe(1);
117 // Oct 1 is inside the last 30 days; the idle team is still absent.
118 expect(teamScorecards(app.db, recentWindow()).has(three!)).toBe(false);
119 });
120
121 const cookieOf = (name: string) =>
122 `forge_session=${createSession(app.db, ids[name]!).token}`;
123
124 test("the page shows a team row with its pooled figures, to an admin only", async () => {
125 app.db.query("UPDATE user SET is_admin = 1 WHERE id = ?1").run(ids.boss!);
126 servers = await serve(app);
127 const [one, , three] = listTeams(app.db).map((t) => t.slug);
128 const page = await fetch(`${servers.url}/settings/crew`, {
129 headers: { cookie: cookieOf("boss") },
130 }).then((r) => r.text());
131 expect(page).toContain("<th>Agent / team</th>");
132 const table = page.slice(page.indexOf(`id="crew-scorecard"`));
133 const row = table.slice(
134 table.indexOf(`data-team="${one}"`),
135 table.indexOf("</tr>", table.indexOf(`data-team="${one}"`)),
136 );
137 const cells = [...row.matchAll(/<td class="num">([\s\S]*?)<\/td>/g)].map((m) =>
138 m[1]!.replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim(),
139 );
140 // 30 days beside all time: landed, review rounds, red checks; then reverts.
141 expect(cells.slice(0, 7)).toEqual(["4 of 4", "4 of 4", "0.3", "0.3", "25%", "25%", "0"]);
142 // A team without a card has no row, and the team rows sit under the agents.
143 expect(table).not.toContain(`data-team="${three}"`);
144 expect(table.indexOf("a1")).toBeLessThan(table.indexOf(`data-team="${one}"`));
145 // Someone who is not an admin gets no scorecard.
146 const other = await fetch(`${servers.url}/settings/crew`, {
147 headers: { cookie: cookieOf("a1") },
148 });
149 expect(await other.text()).not.toContain("crew-scorecard");
150 });
151
152 test("the terminal shows the team rows to an admin, and nothing to anyone else", async () => {
153 const terminal = (username: string) => {
154 const user = getUserByName(app.db, username)!;
155 const { app: tui } = createForgeApp(
156 app,
157 { sshUsername: "t", remoteAddress: "127.0.0.1", user },
158 { terminal: headlessTerminal(160, 40), exitHooks: false },
159 );
160 tui.start();
161 return tui;
162 };
163 const open = async (username: string) => {
164 const tui = terminal(username);
165 const key = (k: string, ctrl = false) =>
166 tui.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false });
167 key("k", true);
168 for (const ch of "Crew scorecard per agent") key(ch);
169 await settleApp(tui);
170 key("enter");
171 await settleApp(tui);
172 const text = tui.screen.toText();
173 tui.stop();
174 return text;
175 };
176 const [one, , three] = listTeams(app.db);
177 const admin = await open("boss");
178 expect(admin).toContain(`team ${one!.name}`);
179 expect(admin).toContain("all 4 of 4");
180 expect(admin).toContain("all 0.3");
181 expect(admin).not.toContain(`team ${three!.name}`);
182 const other = await open("a1");
183 expect(other).not.toContain(`team ${one!.name}`);
184 });
185 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs