Public: anyone can read this; the crew can change it.
The scorecard splits each crew member's figures by the model and prompt that ran #3189
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3169-the-scorecard-splits-each-crew-member-s → main 2 commits fork
6 files
changed +226
−43
README.md +9 −2
| @@ -29,7 +29,13 @@ reverts, time to land — is on Settings → Crew (`/settings/crew`) and in the | ||
| 29 | 29 | terminal's palette as "Crew scorecard per agent"; both are a forge admin's only. |
| 30 | 30 | Landed, review rounds and red checks stand as the last 30 days (pulls opened |
| 31 | 31 | since then) beside all time, so tuning an agent shows as movement; reverts and |
| 32 | time to land are all time. | |
| 32 | time to land are all time. Under each agent, a sub-row per model and prompt | |
| 33 | version its pulls ran on (the agent's latest run on the pull decides) carries | |
| 34 | the same figures, so a change of model or brief shows against the one it | |
| 35 | replaced; pulls opened before runs recorded either read "unrecorded", and are | |
| 36 | counted, never dropped. The sub-rows list every pairing ever used; a pairing | |
| 37 | unused in the last 30 days shows dashes there. The terminal shows them as | |
| 38 | indented `↳` rows. | |
| 33 | 39 | |
| 34 | 40 | Git hosting, code browsing, and a JSON API in one fast Bun process, with |
| 35 | 41 | **zero runtime dependencies** — the `package.json` dependency list is empty and |
| @@ -931,7 +937,8 @@ standing system-prompt text (not the issue or branch, so two runs on one brief | ||
| 931 | 937 | share a version and editing the brief starts a new one). Settings → Crew shows |
| 932 | 938 | the scorecard cut both ways ("Per model", "Per prompt version": runs, landed, |
| 933 | 939 | failed, spent, per run), and the terminal's `Crew scorecard` command shows the |
| 934 | same to an admin. Runs from before this was recorded show as *default* / | |
| 940 | 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* / | |
| 935 | 942 | *not recorded*; they are not backfilled. |
| 936 | 943 | |
| 937 | 944 | **Tell the crew from any page.** In a repository you may direct, the web palette |
src/core/scorecard.ts +69 −9
| @@ -49,11 +49,59 @@ export function recentWindow(now: number = Date.now()): ScorecardWindow { | ||
| 49 | 49 | return { since: new Date(now - RECENT_DAYS * 86_400_000) }; |
| 50 | 50 | } |
| 51 | 51 | |
| 52 | /** A scorecard for the pulls an agent opened on one model and prompt version. */ | |
| 53 | export interface SplitScorecard extends Scorecard { | |
| 54 | /** The model its latest run on the pull ran on; empty when unrecorded. */ | |
| 55 | readonly model: string; | |
| 56 | /** The prompt version of that run; empty when unrecorded. */ | |
| 57 | readonly promptVersion: string; | |
| 58 | } | |
| 59 | ||
| 60 | /** "model · prompt version"; a half the runs never recorded reads "unrecorded". */ | |
| 61 | export function splitLabel(p: SplitScorecard): string { | |
| 62 | const half = (v: string) => (v === "" ? "unrecorded" : v); | |
| 63 | return `${half(p.model)} · ${half(p.promptVersion)}`; | |
| 64 | } | |
| 65 | ||
| 52 | 66 | /** Each agent's scorecard, keyed by its user id: one grouped query. */ |
| 53 | 67 | export function crewScorecards( |
| 54 | 68 | db: Database, |
| 55 | 69 | window: ScorecardWindow = {}, |
| 56 | 70 | ): Map<string, Scorecard> { |
| 71 | return new Map(scorecardRows(db, window, false).map((r) => [r.authorId, r.card])); | |
| 72 | } | |
| 73 | ||
| 74 | /** | |
| 75 | * Each agent's scorecard cut by (model, prompt version), keyed by user id. | |
| 76 | * A pull belongs to the pairing of the agent's latest run on it; a pull with | |
| 77 | * no recorded run (opened before the migration) is the pairing ('', ''), | |
| 78 | * never dropped. Largest pairing first. | |
| 79 | */ | |
| 80 | export function crewScorecardSplits( | |
| 81 | db: Database, | |
| 82 | window: ScorecardWindow = {}, | |
| 83 | ): Map<string, SplitScorecard[]> { | |
| 84 | const out = new Map<string, SplitScorecard[]>(); | |
| 85 | for (const r of scorecardRows(db, window, true)) { | |
| 86 | const list = out.get(r.authorId) ?? []; | |
| 87 | list.push({ ...r.card, model: r.model, promptVersion: r.promptVersion }); | |
| 88 | out.set(r.authorId, list); | |
| 89 | } | |
| 90 | for (const list of out.values()) | |
| 91 | list.sort( | |
| 92 | (a, b) => | |
| 93 | b.opened - a.opened || | |
| 94 | a.model.localeCompare(b.model) || | |
| 95 | a.promptVersion.localeCompare(b.promptVersion), | |
| 96 | ); | |
| 97 | return out; | |
| 98 | } | |
| 99 | ||
| 100 | function scorecardRows( | |
| 101 | db: Database, | |
| 102 | window: ScorecardWindow, | |
| 103 | split: boolean, | |
| 104 | ): { authorId: string; model: string; promptVersion: string; card: Scorecard }[] { | |
| 57 | 105 | // created_at is stored as ISO-8601 UTC, so the string compare is the time compare. |
| 58 | 106 | // An invalid Date throws here rather than widen the window to all time. |
| 59 | 107 | const since = window.since === undefined ? null : window.since.toISOString(); |
| @@ -61,6 +109,8 @@ export function crewScorecards( | ||
| 61 | 109 | .query< |
| 62 | 110 | { |
| 63 | 111 | author_id: string; |
| 112 | model: string; | |
| 113 | prompt_version: string; | |
| 64 | 114 | opened: number; |
| 65 | 115 | landed: number; |
| 66 | 116 | rounds: number; |
| @@ -72,6 +122,8 @@ export function crewScorecards( | ||
| 72 | 122 | [string | null] |
| 73 | 123 | >( |
| 74 | 124 | `SELECT issue.author_id AS author_id, |
| 125 | ${split ? latest("model") : "''"} AS model, | |
| 126 | ${split ? latest("prompt_version") : "''"} AS prompt_version, | |
| 75 | 127 | COUNT(*) AS opened, |
| 76 | 128 | SUM(pull.merged_at IS NOT NULL) AS landed, |
| 77 | 129 | COALESCE(SUM(CASE WHEN pull.merged_at IS NOT NULL THEN |
| @@ -95,15 +147,16 @@ export function crewScorecards( | ||
| 95 | 147 | JOIN pull ON pull.issue_id = issue.id |
| 96 | 148 | JOIN user ON user.id = issue.author_id AND user.is_agent = 1 |
| 97 | 149 | WHERE ?1 IS NULL OR issue.created_at >= ?1 |
| 98 | GROUP BY issue.author_id`, | |
| 150 | GROUP BY issue.author_id${split ? ", model, prompt_version" : ""}`, | |
| 99 | 151 | ) |
| 100 | 152 | .all(since); |
| 101 | return new Map( | |
| 102 | rows.map((r) => { | |
| 153 | return rows.map((r) => { | |
| 103 | 154 | const checks = r.checks_ok + r.checks_failed; |
| 104 | return [ | |
| 105 | r.author_id, | |
| 106 | { | |
| 155 | return { | |
| 156 | authorId: r.author_id, | |
| 157 | model: r.model, | |
| 158 | promptVersion: r.prompt_version, | |
| 159 | card: { | |
| 107 | 160 | opened: r.opened, |
| 108 | 161 | landed: r.landed, |
| 109 | 162 | reviewRounds: r.landed === 0 ? null : r.rounds / r.landed, |
| @@ -114,9 +167,16 @@ export function crewScorecards( | ||
| 114 | 167 | ? null |
| 115 | 168 | : Math.max(0, Math.round(r.mean_land_days * 86_400_000)), |
| 116 | 169 | }, |
| 117 | ]; | |
| 118 | }), | |
| 119 | ); | |
| 170 | }; | |
| 171 | }); | |
| 172 | } | |
| 173 | ||
| 174 | /** The column of the agent's latest run on the pull; '' when it has none. `column` is a literal. */ | |
| 175 | function latest(column: "model" | "prompt_version"): string { | |
| 176 | return `COALESCE((SELECT run.${column} FROM run | |
| 177 | WHERE run.issue_id = issue.id AND run.kind = 'agent' | |
| 178 | AND run.actor_id = issue.author_id | |
| 179 | ORDER BY run.id DESC LIMIT 1), '')`; | |
| 120 | 180 | } |
| 121 | 181 | |
| 122 | 182 | /** A time to land in the largest unit that keeps it readable: both doors say it the same way. */ |
src/tui/screens/scorecard.ts +42 −15
| @@ -2,17 +2,21 @@ | ||
| 2 | 2 | * The crew's scorecard in the terminal, a forge admin's (#2677): each |
| 3 | 3 | * agent's pulls landed of opened, review rounds and red checks over the |
| 4 | 4 | * last `RECENT_DAYS` days beside all time, then reverts and time to land |
| 5 | * over all time, as Settings → Crew shows them (`crewScorecards`). The gate | |
| 5 | * over all time, as Settings → Crew shows them (`crewScorecards`), with a sub-row per | |
| 6 | * model · prompt version (`crewScorecardSplits`). The gate | |
| 6 | 7 | * is the page's: a forge admin; anyone else sees nothing. |
| 7 | 8 | */ |
| 8 | 9 | |
| 9 | 10 | import { listAllAgents } from "../../actions/crew.ts"; |
| 10 | 11 | import { |
| 11 | 12 | crewScorecards, |
| 13 | crewScorecardSplits, | |
| 12 | 14 | RECENT_DAYS, |
| 13 | 15 | recentWindow, |
| 14 | 16 | spanText, |
| 17 | splitLabel, | |
| 15 | 18 | type Scorecard, |
| 19 | type SplitScorecard, | |
| 16 | 20 | } from "../../core/scorecard.ts"; |
| 17 | 21 | import type { Agent } from "../../core/agents.ts"; |
| 18 | 22 | import type { Ctx, Screen } from "../app/app.ts"; |
| @@ -30,31 +34,52 @@ const rounds = (s: Scorecard | undefined) => | ||
| 30 | 34 | const red = (s: Scorecard | undefined) => |
| 31 | 35 | s?.redCheckRate == null ? "—" : `${Math.round(100 * s.redCheckRate)}%`; |
| 32 | 36 | |
| 37 | /** The figures of one scorecard, as a list row's second and third lines. */ | |
| 38 | function figures( | |
| 39 | title: string, | |
| 40 | r: Scorecard | undefined, | |
| 41 | s: Scorecard, | |
| 42 | ): ListRow { | |
| 43 | const d = `${RECENT_DAYS}d`; | |
| 44 | return { | |
| 45 | title, | |
| 46 | secondary: `landed ${d} ${landed(r)} · all ${landed(s)}`, | |
| 47 | meta: [ | |
| 48 | `review rounds ${d} ${rounds(r)} · all ${rounds(s)}`, | |
| 49 | `red checks ${d} ${red(r)} · all ${red(s)}`, | |
| 50 | `reverts ${s.reverts}`, | |
| 51 | `time to land ${s.meanLandMs === null ? "—" : spanText(s.meanLandMs)}`, | |
| 52 | ].join(" "), | |
| 53 | }; | |
| 54 | } | |
| 55 | ||
| 33 | 56 | /** |
| 34 | 57 | * One row per agent; one with no pull yet says so rather than a row of |
| 35 | 58 | * zeros. Each windowed figure reads "<recent>d … · all …", the page's order. |
| 59 | * Under an agent, one indented row per model · prompt version its pulls ran | |
| 60 | * on, with the same figures ("unrecorded" for pulls from before runs | |
| 61 | * recorded them). | |
| 36 | 62 | */ |
| 37 | 63 | export function scorecardRows( |
| 38 | 64 | agents: readonly Agent[], |
| 39 | 65 | cards: ReadonlyMap<string, Scorecard>, |
| 40 | 66 | recent: ReadonlyMap<string, Scorecard>, |
| 67 | splits: ReadonlyMap<string, readonly SplitScorecard[]> = new Map(), | |
| 68 | recentSplits: ReadonlyMap<string, readonly SplitScorecard[]> = new Map(), | |
| 41 | 69 | ): ListRow[] { |
| 42 | const d = `${RECENT_DAYS}d`; | |
| 43 | return agents.map((agent) => { | |
| 70 | return agents.flatMap((agent) => { | |
| 44 | 71 | const s = cards.get(agent.user.id); |
| 45 | 72 | if (s === undefined) |
| 46 | return { title: agent.user.username, secondary: "no pull yet" }; | |
| 47 | const r = recent.get(agent.user.id); | |
| 48 | return { | |
| 49 | title: agent.user.username, | |
| 50 | secondary: `landed ${d} ${landed(r)} · all ${landed(s)}`, | |
| 51 | meta: [ | |
| 52 | `review rounds ${d} ${rounds(r)} · all ${rounds(s)}`, | |
| 53 | `red checks ${d} ${red(r)} · all ${red(s)}`, | |
| 54 | `reverts ${s.reverts}`, | |
| 55 | `time to land ${s.meanLandMs === null ? "—" : spanText(s.meanLandMs)}`, | |
| 56 | ].join(" "), | |
| 57 | }; | |
| 73 | return [{ title: agent.user.username, secondary: "no pull yet" }]; | |
| 74 | const lately = new Map( | |
| 75 | (recentSplits.get(agent.user.id) ?? []).map((p) => [splitLabel(p), p]), | |
| 76 | ); | |
| 77 | return [ | |
| 78 | figures(agent.user.username, recent.get(agent.user.id), s), | |
| 79 | ...(splits.get(agent.user.id) ?? []).map((p) => | |
| 80 | figures(` ↳ ${splitLabel(p)}`, lately.get(splitLabel(p)), p), | |
| 81 | ), | |
| 82 | ]; | |
| 58 | 83 | }); |
| 59 | 84 | } |
| 60 | 85 | |
| @@ -69,6 +94,8 @@ export function scorecardScreen(f: Session, ctx: Ctx): Screen { | ||
| 69 | 94 | listed.value, |
| 70 | 95 | crewScorecards(f.db), |
| 71 | 96 | crewScorecards(f.db, recentWindow()), |
| 97 | crewScorecardSplits(f.db), | |
| 98 | crewScorecardSplits(f.db, recentWindow()), | |
| 72 | 99 | ) |
| 73 | 100 | : []; |
| 74 | 101 | return { |
src/web/pages/crew.ts +46 −16
| @@ -26,10 +26,13 @@ import { | ||
| 26 | 26 | } from "../../core/runs.ts"; |
| 27 | 27 | import { |
| 28 | 28 | crewScorecards, |
| 29 | crewScorecardSplits, | |
| 30 | splitLabel, | |
| 29 | 31 | RECENT_DAYS, |
| 30 | 32 | recentWindow, |
| 31 | 33 | spanText, |
| 32 | 34 | type Scorecard, |
| 35 | type SplitScorecard, | |
| 33 | 36 | } from "../../core/scorecard.ts"; |
| 34 | 37 | import { crewFor } from "../../crew/dispatch.ts"; |
| 35 | 38 | import { MODELS } from "../../crew/models.ts"; |
| @@ -229,19 +232,25 @@ function totalsCells(t: AgentTotals | undefined): Html { | ||
| 229 | 232 | </td>`; |
| 230 | 233 | } |
| 231 | 234 | |
| 235 | const pairKey = (p: SplitScorecard) => `${p.model}\0${p.promptVersion}`; | |
| 236 | ||
| 232 | 237 | /** |
| 233 | 238 | * The scorecard: how each agent's pulls fare once opened — so the owner |
| 234 | 239 | * can tune who does what. Landed, review rounds and red checks stand as the |
| 235 | 240 | * last 30 days beside all time, so a change to an agent shows as movement. |
| 236 | 241 | * What they cost is the table above's ("Per landed"), not repeated here. An |
| 237 | 242 | * agent with no pull yet is a row of dashes, never a row of misleading |
| 238 | * zeros. The agent's name stays in view while a phone scrolls the figures | |
| 243 | * zeros. Under each agent, one sub-row per model and prompt version its pulls | |
| 244 | * ran on, with the same figures; pulls from before runs recorded either read | |
| 245 | * "unrecorded". The agent's name stays in view while a phone scrolls the figures | |
| 239 | 246 | * sideways. |
| 240 | 247 | */ |
| 241 | 248 | function scorecardCard( |
| 242 | 249 | agents: readonly Agent[], |
| 243 | 250 | cards: ReadonlyMap<string, Scorecard>, |
| 244 | 251 | recent: ReadonlyMap<string, Scorecard>, |
| 252 | splits: ReadonlyMap<string, readonly SplitScorecard[]>, | |
| 253 | recentSplits: ReadonlyMap<string, readonly SplitScorecard[]>, | |
| 245 | 254 | ): Html { |
| 246 | 255 | const dash = html`<td class="num muted">—</td>`; |
| 247 | 256 | const lately = `${RECENT_DAYS} days`; |
| @@ -262,6 +271,14 @@ function scorecardCard( | ||
| 262 | 271 | s?.redCheckRate == null |
| 263 | 272 | ? dash |
| 264 | 273 | : html`<td class="num">${Math.round(100 * s.redCheckRate)}%</td>`; |
| 274 | const figures = (r: Scorecard | undefined, s: Scorecard | undefined) => | |
| 275 | html`${landed(r)}${landed(s)}${rounds(r)}${rounds(s)}${red(r)}${red(s)} | |
| 276 | ${s === undefined | |
| 277 | ? html`${dash}${dash}` | |
| 278 | : html`<td class="num">${s.reverts}</td> | |
| 279 | ${s.meanLandMs === null | |
| 280 | ? dash | |
| 281 | : html`<td class="num">${spanText(s.meanLandMs)}</td>`}`}`; | |
| 265 | 282 | return card( |
| 266 | 283 | html`<div class="table-scroll"> |
| 267 | 284 | <table class="table" id="crew-scorecard" data-sticky-lead> |
| @@ -282,21 +299,26 @@ function scorecardCard( | ||
| 282 | 299 | ${agents.map((agent) => { |
| 283 | 300 | const s = cards.get(agent.user.id); |
| 284 | 301 | const r = recent.get(agent.user.id); |
| 302 | const pairs = splits.get(agent.user.id) ?? []; | |
| 303 | const lately = new Map( | |
| 304 | (recentSplits.get(agent.user.id) ?? []).map((p) => [pairKey(p), p]), | |
| 305 | ); | |
| 285 | 306 | return html`<tr> |
| 286 | <td> | |
| 287 | ${userLink(agent.user.username, { | |
| 288 | avatar: false, | |
| 289 | mark: markOf(agent.user), | |
| 290 | })} | |
| 291 | </td> | |
| 292 | ${landed(r)}${landed(s)}${rounds(r)}${rounds(s)}${red(r)}${red(s)} | |
| 293 | ${s === undefined | |
| 294 | ? html`${dash}${dash}` | |
| 295 | : html`<td class="num">${s.reverts}</td> | |
| 296 | ${s.meanLandMs === null | |
| 297 | ? dash | |
| 298 | : html`<td class="num">${spanText(s.meanLandMs)}</td>`}`} | |
| 299 | </tr>`; | |
| 307 | <td> | |
| 308 | ${userLink(agent.user.username, { | |
| 309 | avatar: false, | |
| 310 | mark: markOf(agent.user), | |
| 311 | })} | |
| 312 | </td> | |
| 313 | ${figures(r, s)} | |
| 314 | </tr> | |
| 315 | ${pairs.map((p) => { | |
| 316 | const label = splitLabel(p); | |
| 317 | return html`<tr data-sub> | |
| 318 | <td class="muted small mono" title="${label}">${label}</td> | |
| 319 | ${figures(lately.get(pairKey(p)), p)} | |
| 320 | </tr>`; | |
| 321 | })}`; | |
| 300 | 322 | })} |
| 301 | 323 | </tbody> |
| 302 | 324 | </table> |
| @@ -381,6 +403,8 @@ function crewView(ctx: Ctx, values: AgentValues, error?: string): Html { | ||
| 381 | 403 | const totals = agentTotals(ctx.app.db); |
| 382 | 404 | const scorecards = crewScorecards(ctx.app.db); |
| 383 | 405 | const recentScorecards = crewScorecards(ctx.app.db, recentWindow()); |
| 406 | const splitScorecards = crewScorecardSplits(ctx.app.db); | |
| 407 | const recentSplitScorecards = crewScorecardSplits(ctx.app.db, recentWindow()); | |
| 384 | 408 | // Where the money goes today: every app running on its own, its spend |
| 385 | 409 | // against its ceiling. The ceilings are the one control on the drain, and |
| 386 | 410 | // this is the page where the crew is wielded, so they are read here. |
| @@ -479,7 +503,13 @@ function crewView(ctx: Ctx, values: AgentValues, error?: string): Html { | ||
| 479 | 503 | { pad: false }, |
| 480 | 504 | )} |
| 481 | 505 | ${when(agents.length > 0, () => |
| 482 | scorecardCard(agents, scorecards, recentScorecards), | |
| 506 | scorecardCard( | |
| 507 | agents, | |
| 508 | scorecards, | |
| 509 | recentScorecards, | |
| 510 | splitScorecards, | |
| 511 | recentSplitScorecards, | |
| 512 | ), | |
| 483 | 513 | )} |
| 484 | 514 | ${splitsCard( |
| 485 | 515 | "Per model", |
src/web/style/components.ts +8
| @@ -401,6 +401,14 @@ a.row:hover .row-title { color: var(--accent); } | ||
| 401 | 401 | position: sticky; left: 0; z-index: 1; background: var(--card); |
| 402 | 402 | } |
| 403 | 403 | |
| 404 | /* A sub-row (data-sub) is a split of the row above: indented under it, its | |
| 405 | label cut with an ellipsis so a long "model · prompt" cannot widen the | |
| 406 | sticky lead cell past a phone's width. */ | |
| 407 | .table tr[data-sub] td:first-child { | |
| 408 | padding-left: 24px; max-width: 14rem; | |
| 409 | overflow: hidden; text-overflow: ellipsis; white-space: nowrap; | |
| 410 | } | |
| 411 | ||
| 404 | 412 | /* ---- tabs (navigation links, never role=tab) ---- */ |
| 405 | 413 | .tabs { |
| 406 | 414 | display: flex; gap: 2px; align-items: stretch; |
test/crew-scorecard.test.ts +52 −1
| @@ -10,7 +10,11 @@ import { | ||
| 10 | 10 | createRun, |
| 11 | 11 | finishRun, |
| 12 | 12 | } from "../src/core/runs.ts"; |
| 13 | import { crewScorecards, recentWindow } from "../src/core/scorecard.ts"; | |
| 13 | import { | |
| 14 | crewScorecards, | |
| 15 | crewScorecardSplits, | |
| 16 | recentWindow, | |
| 17 | } from "../src/core/scorecard.ts"; | |
| 14 | 18 | import { createUser, getUserByName } from "../src/core/users.ts"; |
| 15 | 19 | import { createForgeApp } from "../src/tui/forge.ts"; |
| 16 | 20 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; |
| @@ -278,4 +282,51 @@ describe("a scorecard per crew member", () => { | ||
| 278 | 282 | expect(t.text()).not.toContain("all 2 of 3"); |
| 279 | 283 | t.stop(); |
| 280 | 284 | }); |
| 285 | ||
| 286 | test("one member on two models gets a row each, with their own landed counts", async () => { | |
| 287 | await fetch(`${base}/api/v1/agents`, { | |
| 288 | method: "POST", | |
| 289 | headers: { cookie, "content-type": "application/json" }, | |
| 290 | body: JSON.stringify({ username: "lee", model: "opus" }), | |
| 291 | }); | |
| 292 | const lee = getUserByName(app.db, "lee")!.id; | |
| 293 | const ran = (issueId: string, model: string, promptVersion: string) => { | |
| 294 | const run = createRun(app.db, { repoId, kind: "agent", issueId, actorId: lee }); | |
| 295 | claimRun(app.db, run.id); | |
| 296 | app.db | |
| 297 | .query(`UPDATE run SET model = ?2, prompt_version = ?3 WHERE id = ?1`) | |
| 298 | .run(run.id, model, promptVersion); | |
| 299 | }; | |
| 300 | const merged = "2026-10-05T00:00:00.000Z"; | |
| 301 | ran(pull(lee, "A", { mergedAt: merged }), "opus", "p1"); | |
| 302 | ran(pull(lee, "B", { mergedAt: merged }), "opus", "p1"); | |
| 303 | ran(pull(lee, "C", { mergedAt: merged }), "haiku", "p1"); | |
| 304 | ran(pull(lee, "D"), "haiku", "p1"); | |
| 305 | pull(lee, "Before the migration", { mergedAt: merged }); | |
| 306 | ||
| 307 | const rows = crewScorecardSplits(app.db).get(lee)!; | |
| 308 | const by = (m: string) => rows.find((r) => r.model === m)!; | |
| 309 | expect([by("opus").opened, by("opus").landed]).toEqual([2, 2]); | |
| 310 | expect([by("haiku").opened, by("haiku").landed]).toEqual([2, 1]); | |
| 311 | // A pull with no recorded run is shown as unrecorded, not dropped. | |
| 312 | expect([by("").opened, by("").landed, by("").promptVersion]).toEqual([1, 1, ""]); | |
| 313 | expect(rows.reduce((n, r) => n + r.opened, 0)).toBe(5); | |
| 314 | ||
| 315 | const page = await fetch(`${base}/settings/crew`, { headers: { cookie } }).then((r) => r.text()); | |
| 316 | const table = page.slice(page.indexOf(`id="crew-scorecard"`)); | |
| 317 | expect(table).toContain("opus"); | |
| 318 | expect(table).toContain("haiku"); | |
| 319 | expect(table).toContain("unrecorded"); | |
| 320 | ||
| 321 | // The terminal shows the same sub-rows under the member. | |
| 322 | const t = terminal("ada"); | |
| 323 | await t.palette("Crew scorecard per agent"); | |
| 324 | await t.enter(); | |
| 325 | const text = t.text(); | |
| 326 | expect(text).toContain("↳ opus · p1"); | |
| 327 | expect(text).toContain("↳ haiku · p1"); | |
| 328 | expect(text).toContain("↳ unrecorded · unrecorded"); | |
| 329 | expect(text).toContain("↳ opus · p1 landed 30d 2 of 2 · all 2 of 2"); | |
| 330 | t.stop(); | |
| 331 | }); | |
| 281 | 332 | }); |
new above
offline