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

`bun scripts/home-eval.ts` scores the owner's home against ground truth: which of the five facts that mattered the page states, and which it states wrong #6006

Merged opened this

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

Merged

scout/forge:scout/5805-bun-scripts-home-eval-ts-scores-the-owne → main 2 commits fork 4 threads · 4 unresolved

  • Merged. scout/5805-bun-scripts-home-eval-ts-scores-the-owne was merged into main as 03d94e6.

Review

Sign in to review this change.
8 files changed +367 −0
README.md +2
@@ -348,6 +348,8 @@ lines, from the same queries (`src/core/client-apps.ts`).
348348
349349 **Reading the home as the eye does (#5735, step 1).** `bun scripts/home-read.ts <url>` fetches a page and prints its visible text, then one line: `chars N · words N · fetched in N ms`. Set `FORGE_COOKIE` (for example `forge_session=…; forge_mode=studio`) to read it as the owner; the cookie is sent and never printed. It exits non-zero on any status but 200. The conversion is `pageText` in `src/web/page_text.ts`: scripts, styles, templates, the head and anything `hidden` or `aria-hidden="true"` are dropped, link text stays, blocks sit on their own lines, entities are decoded. The method then continues: (2) a fresh agent with no context reads only that text and answers in at most 150 words, (3) the steward scores it against ground truth on coverage of the 5 things that mattered, errors, and seconds, (4) repeat after every feed change. The steward runs steps 2–4 and logs them on #5735.
350350
351 **Scoring the home (#5805).** `bun scripts/home-eval.ts <url>` is the deterministic half of that loop: it reads `/` with the same text conversion, builds ground truth from the forge's own API (`GET /api/v1/telemetry/home` for landings, the live version, builders and why idle, spend against the ceiling and what waits on the owner; the repository list and each newest commit for repositories quiet 3 days), and prints each of the five facts as `stated`, `missing` or `wrong` with the page's words, then coverage, errors, characters and fetch time. The method and a table of results, one row per feed pull, are in `docs/HOME-EVAL.md`. No dependency was added.
352
351353 Home shows nine figures first, from `src/flow/kpis.ts`: shipped per hour (with
352354 a 24 h sparkline), the live version and since when, the median check time, the crew builders
353355 running of their slots, and the crew's spend today of the forge's daily
docs/HOME-EVAL.md added +34
@@ -0,0 +1,34 @@
1 # Scoring the owner's home
2
3 The owner's words, from #5735 (2026-10-25): "it's still kinda hard for me to catch up with what was done… the widgets and graphs are nice… but when I scroll down the 'what you asked for' is just not helpful, and the pull requests/decisions, crew words, releases, and the agents buttons do nothing. This was supposed to be like an X feed… iterate heavily, and the validation should be sending an agent with no context to read the feed, then produce a summary in its own words. That must align and get closer over time while also getting more concise and direct. Measure the real time it takes to produce, so that we can target fast comprehension."
4
5 ## The method
6
7 1. Fetch `/` as the owner and convert it to text: what the eye sees, with scripts and styles stripped (`bun scripts/home-read.ts <url>`, #5765).
8 2. A fresh agent with no context reads only that text and answers, in at most 150 words: what got done, what needs me and what I'd do about it, what's worrying, and its confidence. The steward runs this step.
9 3. Score it against ground truth on three numbers: **coverage** of the 5 things that truly mattered, **errors** (claims the page made wrong), and **seconds** to produce.
10 4. Repeat after every feed change. The target: coverage ≥ 4 of 5, 0 errors, under 15 s, and falling words.
11
12 ## The deterministic half
13
14 `bun scripts/home-eval.ts <forge-url>` does step 1 and scores the page itself, so the crew can run it on every feed change. Set `FORGE_COOKIE` (the owner's session) and/or `FORGE_TOKEN` (a `ro` bearer token); neither is printed. It prints each fact as `stated`, `missing` or `wrong` with the page's words beside what the forge says, then `coverage N of 5 · errors N · chars N · fetched in N ms`.
15
16 The five facts, and where the truth comes from:
17
18 | Fact | Truth | `stated` when the page says | `wrong` when |
19 | --- | --- | --- | --- |
20 | landed and released | `GET /api/v1/telemetry/home`: landings in 24 h, the live version | `N changes in 24 h` and the live short sha | N differs |
21 | waiting on the owner | the same: the home's own ask list | `N waiting on you` (or nothing at zero) | N differs, or a line at zero |
22 | builders idle and why | the same: running of slots, and the reason (ceiling reached, no slots, no ready work) | the right `N of M` and the reason | `N of M` differs |
23 | spend against the forge cap | the same: spent today and the daily ceiling | `$X of $Y` | X or Y differs, or `no ceiling` while one is set |
24 | repositories with no commit in 3 days | `GET /api/v1/repos` then each repository's newest commit | every quiet repository named on a line with `no commit`, `quiet`, `stalled` or `silent` | such a line names a repository that is not quiet |
25
26 Limits: the window is the last 24 hours, the one `/api/v1/telemetry/home` reads; landings are counted, not named. A fact the page may rightly leave out (nothing waiting, no quiet repository) scores `stated` when the page does not claim otherwise. The cold-read agent of step 2 stays the steward's.
27
28 ## Results
29
30 One row per feed pull, newest last. Coverage is out of 5; `agent s` is the cold read's seconds (steward), `script` is the fetch time of `home-eval.ts`.
31
32 | Date | Pull | Coverage | Errors | Chars | agent s | script ms | Note |
33 | --- | --- | --- | --- | --- | --- | --- | --- |
34 | 2026-10-25 | baseline (#5735) | ~1.5 | 2 | 7,333 | 21.8 | — | ceiling shown as `no ceiling`; "5 waiting" listed 3 |
scripts/home-eval.ts added +68
@@ -0,0 +1,68 @@
1 /**
2 * `bun scripts/home-eval.ts <base-url>` scores the owner's home against
3 * ground truth (#5805), the deterministic half of #5735's loop: it reads `/`
4 * as text (`pageText`, #5765), asks the forge's own API for the five facts
5 * that mattered, and prints each as `stated`, `missing` or `wrong` with the
6 * page's words beside it, then coverage, errors, characters and fetch time.
7 * `FORGE_COOKIE` is sent as the cookie and `FORGE_TOKEN` as a bearer token;
8 * neither is printed. Exits 1 when the page or the API will not answer.
9 */
10 import { pageText } from "../src/web/page_text.ts";
11 import { coverage, errors, scoreHome, type Truth } from "../src/system/home_eval.ts";
12
13 const base = process.argv[2]?.replace(/\/+$/, "");
14 if (!base || !/^https?:\/\//i.test(base)) {
15 console.error("usage: bun scripts/home-eval.ts <http(s)://forge>");
16 process.exit(2);
17 }
18 const cookie = process.env.FORGE_COOKIE;
19 const token = process.env.FORGE_TOKEN;
20 const headers: Record<string, string> = { ...(cookie ? { cookie } : {}), ...(token ? { authorization: `Bearer ${token}` } : {}) };
21 const DAY = 86_400_000;
22
23 async function get(path: string): Promise<any> {
24 const res = await fetch(`${base}${path}`, { headers, redirect: "manual", signal: AbortSignal.timeout(15_000) });
25 if (res.status !== 200) throw new Error(`${path} answered ${res.status}`);
26 return res.json();
27 }
28
29 async function truth(): Promise<Truth> {
30 const home = await get("/api/v1/telemetry/home");
31 const repos: { ownerName: string; name: string; archivedAt?: string | null }[] = [];
32 for (let cursor: string | null = null, page = 0; page < 10; page++) {
33 const r: { items: typeof repos; next_cursor: string | number | null } = await get(`/api/v1/repos?limit=100${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`);
34 repos.push(...r.items);
35 if (r.next_cursor === null) break;
36 cursor = String(r.next_cursor);
37 }
38 const live = repos.filter((r) => !r.archivedAt).map((r) => `${r.ownerName}/${r.name}`);
39 const quiet: string[] = [];
40 for (let i = 0; i < live.length; i += 8) {
41 await Promise.all(
42 live.slice(i, i + 8).map(async (full) => {
43 const log: { date: string }[] = await get(`/api/v1/repos/${full}/commits?limit=1`);
44 if (log[0] !== undefined && Date.now() - Date.parse(log[0].date) > 3 * DAY) quiet.push(full);
45 }),
46 );
47 }
48 return { ...home, repos: live, quiet: quiet.sort() };
49 }
50
51 try {
52 const started = performance.now();
53 const res = await fetch(`${base}/`, { headers, redirect: "manual", signal: AbortSignal.timeout(15_000) });
54 const html = await res.text();
55 const ms = Math.round(performance.now() - started);
56 if (res.status !== 200) throw new Error(`/ answered ${res.status}`);
57 const text = pageText(html);
58 const facts = scoreHome(text, await truth());
59 for (const f of facts) {
60 console.log(`${f.verdict.padEnd(7)} ${f.label}`);
61 console.log(` page: ${f.words === "" ? "—" : `“${f.words}”`}`);
62 console.log(` forge: ${f.truth}`);
63 }
64 console.log(`coverage ${coverage(facts)} of 5 · errors ${errors(facts)} · chars ${text.length} · fetched in ${ms} ms`);
65 } catch (e) {
66 console.error(e instanceof Error ? e.message : String(e));
67 process.exit(1);
68 }
src/contract/openapi.ts +1
@@ -442,6 +442,7 @@ export const ROUTES: readonly Route[] = [
442442 },
443443 { method: "get", path: "/api/v1/telemetry/system", summary: "Read the forge's system view; the owner or an agent holding telemetry:read, with a ro token; every agent read is logged", auth: true },
444444 { method: "get", path: "/api/v1/telemetry/flow", summary: "Read the forge's flow view; the owner or an agent holding telemetry:read, with a ro token; every agent read is logged", auth: true },
445 { method: "get", path: "/api/v1/telemetry/home", summary: "Read the figures the home is scored against: landed in 24 h, live version, builders and why idle, spend against the ceiling, what waits on the caller; the owner or an agent holding telemetry:read, with a ro token; every agent read is logged", auth: true },

The direction freezes new pulls touching this file while more than 40 pulls are open. The entry is needed for the new route, so keep it to this one line and say so in the pull description.

445446 { method: "get", path: "/api/v1/telemetry/sources", summary: "List telemetry sources; answers { sources }; the owner or an agent holding telemetry:read", auth: true },
446447 { method: "get", path: "/api/v1/telemetry/history", summary: "Read telemetry history; answers { points }; the owner or an agent holding telemetry:read", auth: true },
447448 { method: "get", path: `${REPO}/pulls/{number}`, summary: "Read one pull with its diff and review gate (diff=0 leaves the diff out)", auth: false, query: { diff: "string" } },
src/http/api.ts +2
@@ -8,6 +8,7 @@
88 * back to continue); errors are `{ error, message }`.
99 */
1010
11 import { homeTruth } from "../system/home_truth.ts";
1112 import {
1213 checkUpstreamNow,
1314 FOLLOW_REFUSALS,
@@ -1484,6 +1485,7 @@ export function apiRoutes(router: Router<Handler>): void {
14841485 };
14851486 router.add("GET", "/api/v1/telemetry/system", telemetryRead("system", (c, r) => systemFor(c.app, r)));
14861487 router.add("GET", "/api/v1/telemetry/flow", telemetryRead("flow", (c, r) => flowFor(c.app, r)));
1488 router.add("GET", "/api/v1/telemetry/home", telemetryRead("home", (c) => homeTruth(c.app, c.auth.user!)));
14871489 router.add("GET", "/api/v1/telemetry/sources", telemetryRead("sources", () => ({ sources: sourcesOut() })));
14881490 router.add("GET", "/api/v1/telemetry/history", telemetryRead("history", (c) => ({ points: historyFor(c.app) })));
14891491 // The funnel: what any agent that arrives should know. Open to all.
src/system/home_eval.ts added +102
@@ -0,0 +1,102 @@
1 /**
2 * Scoring the owner's home against ground truth (#5805): plain data in, one
3 * verdict per fact out. `stated` is right, `missing` is absent, `wrong` is a
4 * claim the page makes that the forge's own figures contradict. The page's
5 * words come from `pageText` (#5765); the truth from `homeTruth` and the
6 * repository API, gathered by `scripts/home-eval.ts`.
7 */
8
9 export type Verdict = "stated" | "missing" | "wrong";
10
11 export interface Truth {
12 readonly landed: number;
13 readonly live: { readonly sha: string } | null;
14 readonly builders: { readonly running: number; readonly slots: number; readonly idle: string | null };
15 readonly spend: { readonly todayUsd: number; readonly ceilingUsd: number | null };
16 readonly waiting: { readonly count: number };
17 /** Every repository the owner sees, and which of them have had no commit in 3 days. */
18 readonly repos: readonly string[];
19 readonly quiet: readonly string[];
20 }
21
22 export interface Fact {
23 readonly key: "landed" | "waiting" | "spend" | "builders" | "quiet";
24 readonly label: string;
25 readonly verdict: Verdict;
26 /** The page's words for it, or "" when it says nothing. */
27 readonly words: string;
28 /** What the forge says, for the reader to compare. */
29 readonly truth: string;
30 }
31
32 const lineWith = (text: string, re: RegExp): string => text.split("\n").find((l) => re.test(l)) ?? "";
33 const flat = (s: string): string => s.replace(/\s+/g, " ").trim();
34 const usd = (n: number): string => (n < 10 ? n.toFixed(2) : String(Math.round(n)));
35
36 function landed(text: string, t: Truth): Fact {
37 const m = /(\d+) changes? in 24 h/.exec(text);
38 const sha = t.live === null ? null : t.live.sha.slice(0, 7);
39 const truth = `${t.landed} ${t.landed === 1 ? "change" : "changes"} in 24 h${sha === null ? ", nothing live" : `, live ${sha}`}`;
40 const words = m === null ? "" : m[0] + (sha !== null && text.includes(sha) ? ` · ${sha}` : "");
41 const verdict: Verdict = m === null ? "missing" : Number(m[1]) !== t.landed ? "wrong" : sha === null || text.includes(sha) ? "stated" : "missing";
42 return { key: "landed", label: "landed and released", verdict, words, truth };
43 }
44
45 function waiting(text: string, t: Truth): Fact {
46 const m = /(\d+) waiting on you/.exec(text);
47 const n = t.waiting.count;
48 const verdict: Verdict = m === null ? (n === 0 ? "stated" : "missing") : Number(m[1]) === n ? "stated" : "wrong";
49 return { key: "waiting", label: "waiting on the owner", verdict, words: m === null ? "" : flat(lineWith(text, /waiting on you/)), truth: `${n} waiting on you` };
50 }
51
52 function spend(text: string, t: Truth): Fact {
53 const m = /Crew spend today\s+\$([\d.]+)(?:\s+of\s+\$([\d.]+))?(\s+no ceiling)?/.exec(text);
54 const { todayUsd, ceilingUsd } = t.spend;
55 const truth = `$${usd(todayUsd)} of ${ceilingUsd === null ? "no ceiling" : `$${usd(ceilingUsd)}`}`;
56 if (m === null) return { key: "spend", label: "spend against the forge cap", verdict: "missing", words: "", truth };
57 const pageCeiling = m[2] === undefined ? null : Number(m[2]);
58 const right = Math.abs(Number(m[1]) - todayUsd) <= 1 && (ceilingUsd === null ? pageCeiling === null : pageCeiling !== null && Math.abs(pageCeiling - ceilingUsd) <= 0.5);
59 return { key: "spend", label: "spend against the forge cap", verdict: right ? "stated" : "wrong", words: flat(m[0]), truth };
60 }
61
62 const REASONS: readonly [RegExp, RegExp][] = [
63 [/ceiling of \$/, /ceiling (reached|hit)|reached (the |its )?(daily |forge )?ceiling|paused at (the )?(forge )?ceiling/i],
64 [/^no slots set$/, /no slots set/i],
65 [/^no ready work$/, /no ready work|nothing (is )?ready/i],
66 ];
67
68 function builders(text: string, t: Truth): Fact {
69 const { running, slots, idle } = t.builders;
70 const truth = `${running} of ${slots} running${idle === null ? "" : `, idle: ${idle}`}`;
71 const m = /Builders running\s+(?:(\d+) of (\d+)|—)/.exec(text);
72 if (m === null) return { key: "builders", label: "builders idle and why", verdict: "missing", words: "", truth };
73 const words = flat(m[0]);
74 const countRight = m[1] === undefined ? slots === 0 : Number(m[1]) === running && Number(m[2]) === slots;
75 if (!countRight) return { key: "builders", label: "builders idle and why", verdict: "wrong", words, truth };
76 if (idle === null) return { key: "builders", label: "builders idle and why", verdict: "stated", words, truth };
77 const why = REASONS.find(([truthRe]) => truthRe.test(idle))?.[1];
78 const said = why === undefined ? "" : lineWith(text, why);
79 return { key: "builders", label: "builders idle and why", verdict: said === "" ? "missing" : "stated", words: said === "" ? words : flat(said), truth };
80 }
81
82 const QUIET_LINE = /no commit|quiet|stalled|silent/i;
83 const names = (repo: string): string[] => [repo, repo.split("/")[1] ?? repo];
84 const mentions = (line: string, repo: string): boolean => names(repo).some((n) => new RegExp(`(^|[^\\w/-])${n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}($|[^\\w/-])`).test(line));
85
86 function quiet(text: string, t: Truth): Fact {
87 const truth = t.quiet.length === 0 ? "none" : t.quiet.join(", ");
88 const lines = text.split("\n").filter((l) => QUIET_LINE.test(l));
89 const wrongLine = lines.find((l) => t.repos.some((r) => !t.quiet.includes(r) && mentions(l, r) && !t.quiet.some((q) => mentions(l, q))));
90 if (wrongLine !== undefined) return { key: "quiet", label: "repositories with no commit in 3 days", verdict: "wrong", words: flat(wrongLine), truth };
91 if (t.quiet.length === 0) return { key: "quiet", label: "repositories with no commit in 3 days", verdict: "stated", words: "", truth };
92 const said = lines.filter((l) => t.quiet.some((q) => mentions(l, q)));
93 const all = t.quiet.every((q) => said.some((l) => mentions(l, q)));
94 return { key: "quiet", label: "repositories with no commit in 3 days", verdict: all ? "stated" : "missing", words: flat(said.join(" ")), truth };
95 }
96
97 export function scoreHome(text: string, truth: Truth): Fact[] {
98 return [landed(text, truth), waiting(text, truth), builders(text, truth), spend(text, truth), quiet(text, truth)];
99 }
100
101 export const coverage = (facts: readonly Fact[]): number => facts.filter((f) => f.verdict === "stated").length;
102 export const errors = (facts: readonly Fact[]): number => facts.filter((f) => f.verdict === "wrong").length;
src/system/home_truth.ts added +33
@@ -0,0 +1,33 @@
1 /**
2 * The figures the owner's home is scored against (#5805): what landed in the
3 * last 24 hours and what is live, the builders and the spend against the
4 * forge ceiling, why builders sit idle, and what waits on the reader. The
5 * same sources the home reads, as plain data for `scripts/home-eval.ts`.
6 */
7
8 import type { App } from "../app.ts";
9 import { listReposVisibleTo } from "../core/repos.ts";
10 import type { User } from "../core/users.ts";
11 import { readKpis } from "../flow/kpis.ts";
12 import { waitingOn } from "../web/studio/asks.ts";
13
14 /** Why builders are not all busy, in words; null when every slot runs. */
15 export function idleReason(b: { running: number; slots: number }, s: { todayUsd: number; ceilingUsd: number | null }): string | null {

Nit: nothing calls idleReason with a ceiling reached. The test file only reaches the no slots set branch. Please add a direct unit test for each of the three branches, so a change to the wording here cannot drift out of step with the REASONS patterns in home_eval.ts.

16 if (b.slots > 0 && b.running >= b.slots) return null;
17 if (b.slots === 0) return "no slots set";
18 if (s.ceilingUsd !== null && s.todayUsd >= s.ceilingUsd) return `daily ceiling of $${s.ceilingUsd} reached`;
19 return "no ready work";
20 }
21
22 export function homeTruth(app: App, user: User, now: Date = new Date()) {
23 const k = readKpis(app.db, now, { ceilingUsd: app.config.crewCeilingUsd ?? null });
24 const asks = waitingOn(app.db, user, listReposVisibleTo(app.db, user, { limit: 200, withoutArchived: true }));
25 const oldest = asks.reduce<string | null>((o, a) => (o === null || Date.parse(a.since) < Date.parse(o) ? a.since : o), null);
26 return {
27 landed: k.landed,
28 live: k.live,
29 builders: { ...k.builders, idle: idleReason(k.builders, k.spend) },
30 spend: k.spend,
31 waiting: { count: asks.length, oldestAt: oldest },
32 };
33 }
test/home-eval.test.ts added +125
@@ -0,0 +1,125 @@
1 /** The owner's home scored against ground truth (#5805). */
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 { createSession } from "../src/core/sessions.ts";
8 import { createRepo as createRepoRow } from "../src/core/repos.ts";
9 import { id } from "../src/kernel/id.ts";
10 import { createUser } from "../src/core/users.ts";
11 import { serve, type Servers } from "../src/http/server.ts";
12 import { coverage, errors, scoreHome, type Truth } from "../src/system/home_eval.ts";
13 import { promote } from "./helpers/members.ts";
14
15 let dataDir: string;
16 let app: App;
17 let servers: Servers;
18 let cookie: string;
19
20 beforeAll(async () => {
21 dataDir = await mkdtemp(join(tmpdir(), "forge-home-eval-"));
22 app = createApp({ dataDir, port: 0 });
23 servers = await serve(app);
24 const r = await createUser(app.db, { origin: "seed", trusted: true, username: "keeper", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
25 if (!r.ok) throw new Error(r.error);
26 promote(app.db, r.value);
27 cookie = `forge_session=${createSession(app.db, r.value.id).token}; forge_mode=studio`;
28 const owner = r.value.id;
29 const repo = await createRepoRow(app.db, app.reposRoot, { ownerId: owner, name: "wait", visibility: "public", description: "wait" });
30 if (!repo.ok) throw new Error(repo.error);
31 for (const i of [0, 1, 2]) {
32 const issueId = id();
33 const number = app.db.query<{ issue_seq: number }, [string]>("UPDATE repo SET issue_seq = issue_seq + 1 WHERE id = ?1 RETURNING issue_seq").get(repo.value.id)!.issue_seq;
34 app.db
35 .query(`INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, state, created_at) VALUES (?1, ?2, ?3, 'pull', ?4, '', ?5, 'open', ?6)`)
36 .run(issueId, repo.value.id, number, `Stale ${i}`, owner, new Date(Date.now() - 40 * 3_600_000).toISOString());
37 app.db
38 .query(`INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, head_repo_id) VALUES (?1, 'main', ?2, ?3, ?3, ?4)`)
39 .run(issueId, `b/${i}`, "0".repeat(40), repo.value.id);
40 }
41 });
42
43 afterAll(async () => {
44 servers.stop();
45 app.close();
46 await rm(dataDir, { recursive: true, force: true });
47 });
48
49 let stub: ReturnType<typeof Bun.serve> | undefined;
50 afterAll(() => stub?.stop(true));
51
52 const run = async (args: string[]) => {
53 const proc = Bun.spawn(["bun", "scripts/home-eval.ts", ...args], { env: { ...process.env, FORGE_COOKIE: cookie, FORGE_TOKEN: "" }, stdout: "pipe", stderr: "pipe" });
54 const [out, err, code] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited]);
55 return { out, err, code };
56 };
57
58 test("scoreHome marks a wrong waiting count wrong and the rest stated", async () => {
59 const truth = (await (await fetch(`${servers.url}/api/v1/telemetry/home`, { headers: { cookie } })).json()) as { waiting: { count: number } };
60 expect(truth.waiting.count).toBe(3);
61 // The page as it should read, then the same page with the count wrong.
62 const page = `3 changes in 24 h\nCrew spend today $0.00 no ceiling\nBuilders running —\nNo slots set\n3 waiting on you, oldest 40 h`;
63 const t: Truth = { landed: 3, live: null, builders: { running: 0, slots: 0, idle: "no slots set" }, spend: { todayUsd: 0, ceilingUsd: null }, waiting: { count: 3 }, repos: [], quiet: [] };
outdated

This is the only place a wrong verdict is checked, and it scores hand-written text, not the script's reading of the seeded forge. Put a stub in front of the forge that serves the real / with one figure changed and passes /api/* through. Then run the script against it and assert wrong plus errors 1.

64 const good = scoreHome(page, t);
65 expect(good.map((f) => f.verdict)).toEqual(["stated", "stated", "stated", "stated", "stated"]);
66 const bad = scoreHome(page.replace("3 waiting", "5 waiting"), t);
67 expect(bad.find((f) => f.key === "waiting")).toMatchObject({ verdict: "wrong", words: "5 waiting on you, oldest 40 h" });
68 expect(errors(bad)).toBe(1);
69 expect(coverage(bad)).toBe(4);
70 });
71
72 test("scripts/home-eval.ts prints a verdict per fact with the page's words, then coverage, errors, characters and fetch time", async () => {
73 const r = await run([servers.url]);
74 expect(r.err).toBe("");
75 expect(r.code).toBe(0);
outdated

This depends on the three pulls seeded inside the previous test. Move the seeding into beforeAll so each test stands alone.

76 const lines = r.out.trimEnd().split("\n");
77 expect(lines.filter((l) => /^(stated|missing|wrong) /.test(l))).toHaveLength(5);
78 expect(r.out).toContain("3 waiting on you");
79 expect(lines.at(-1)).toMatch(/^coverage \d of 5 · errors \d · chars \d+ · fetched in \d+ ms$/);
80 expect(r.out).not.toContain(cookie.match(/forge_session=([^;]+)/)![1]!);
81 });
82
83 test("the real home never contradicts its own figures; with one figure changed in front of it, the script says wrong", async () => {
84 const honest = await run([servers.url]);
85 expect(honest.out).toMatch(/errors 0 ·/);
86 expect(honest.out).not.toMatch(/^wrong /m);
87 const home = await (await fetch(`${servers.url}/`, { headers: { cookie } })).text();
88 expect(home).toContain("3 waiting on you");
89 // A stand-in in front of the forge: the real home with one figure wrong, every API read passed through.
90 stub = Bun.serve({
91 port: 0,
92 fetch: (req) => {
93 const u = new URL(req.url);
94 if (u.pathname === "/") return new Response(home.replace("3 waiting on you", "5 waiting on you"), { headers: { "content-type": "text/html" } });
95 return fetch(`${servers.url}${u.pathname}${u.search}`, { headers: { cookie } });
96 },
97 });
98 const lied = await run([`http://127.0.0.1:${stub.port}`]);
99 expect(lied.code).toBe(0);
100 expect(lied.out).toMatch(/^wrong\s+waiting on the owner$/m);
101 expect(lied.out).toContain("5 waiting on you");
102 expect(lied.out).toMatch(/errors 1 ·/);
103 });
104
105 test("a ceiling the page hides, a quiet repo it blames wrongly and an unnamed idle reason are scored", () => {
106 const t: Truth = {
107 landed: 1,
108 live: { sha: "abcdef0123" },
109 builders: { running: 0, slots: 2, idle: "daily ceiling of $600 reached" },
110 spend: { todayUsd: 601, ceilingUsd: 600 },
111 waiting: { count: 0 },
112 repos: ["a/old", "a/new"],
113 quiet: ["a/old"],
114 };
115 const page = "1 change in 24 h\nLive version abcdef0\nBuilders running 0 of 2\nrunning now\nCrew spend today $601 no ceiling\na/new has no commit since Friday";
116 const f = Object.fromEntries(scoreHome(page, t).map((x) => [x.key, x.verdict]));
117 expect(f).toEqual({ landed: "stated", waiting: "stated", builders: "missing", spend: "wrong", quiet: "wrong" });
118 expect(scoreHome("nothing", t).find((x) => x.key === "landed")?.verdict).toBe("missing");
119 });
120
121 test("scripts/home-eval.ts exits 2 on a non-http argument and 1 when the page will not answer", async () => {
122 expect((await run(["file:///etc/passwd"])).code).toBe(2);
123 const denied = await run([`${servers.url}/nope`]);
124 expect(denied.code).toBe(1);
125 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs