diff --git a/.gitignore b/.gitignore
index 7714ca7..97a5668 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,6 +8,7 @@ coverage/
*.tgz
.DS_Store
docs/preview.html
+docs/hero.html
pi-warden.md
AGENTS.md
.pi-warden/audit-report.html
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f463699..fd9a304 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,12 @@ How to keep this current: add the entry in the same pull request as the change,
+## 0.78.1
+
+### Docs
+
+- `docs/hero.png` is now rendered by `scripts/render-hero.mjs` (`npm run hero`), and every number in it comes from the 2026-09-24 field report (759 sessions); the install line reads `pi install npm:pi-warden`.
+
## 0.78.0
### Added
diff --git a/README.md b/README.md
index e9b2f5f..c9a7b78 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@
-
+
diff --git a/docs/hero.png b/docs/hero.png
index 7755d5f..35413c0 100644
Binary files a/docs/hero.png and b/docs/hero.png differ
diff --git a/package.json b/package.json
index 954d88e..b99d830 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "pi-warden",
- "version": "0.78.0",
+ "version": "0.78.1",
"description": "Makes the Pi agent follow your project's rules. Jev judges every write against your pi-warden.md and quotes the broken rule back to the agent, names slop, breaks stuck loops, calls out unverified done claims, compresses large tool output, and holds the rare destructive command. Built on pi-typesafe.",
"type": "module",
"license": "MIT",
@@ -58,6 +58,7 @@
"test:live": "node --env-file-if-exists=.env scripts/live-smoke.mjs",
"dev:pi": "node scripts/dev-pi.mjs",
"preview": "node scripts/render-preview.mjs",
+ "hero": "node scripts/render-hero.mjs",
"check": "npm run typecheck && npm test && npm run build",
"prepack": "npm run build",
"eval:ab": "node scripts/eval-ab.mjs",
diff --git a/scripts/render-hero.mjs b/scripts/render-hero.mjs
new file mode 100644
index 0000000..07ca911
--- /dev/null
+++ b/scripts/render-hero.mjs
@@ -0,0 +1,162 @@
+// Renders docs/hero.png (the README banner) from an HTML template written to docs/hero.html.
+// Usage: node scripts/render-hero.mjs [path-to-chrome] (any Chrome/Chromium headless binary)
+import { execFileSync } from 'node:child_process';
+import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
+import { homedir } from 'node:os';
+import { fileURLToPath } from 'node:url';
+import { join } from 'node:path';
+
+// ---------------------------------------------------------------------------------------------------------------
+// Every number in the image. Each one is stated in a file in this repo; none is computed here.
+// FIELD = eval/reports/2026-09-24-field-usage/README.md (raw counts in usage.json next to it).
+const numbers = {
+ sessions: 759, // FIELD line 13, Totals table: "Sessions | 759"
+ from: '2026-09-16', // FIELD line 1, title: "Field usage, 2026-09-16 to 2026-09-24"
+ to: '2026-09-24', // FIELD line 1, same title
+ doneNudged: 75, // FIELD line 22: "It nudged 75 times when an agent said it was done with no passing test ..."
+ doneRanCheck: 57, // FIELD line 22: "In 57 of those (76%), the agent's next few calls ran a check."
+ doneRanCheckPct: 76, // FIELD line 22: same sentence, "(76%)"
+ nextMessages: 'four', // FIELD line 49: "one of the agent's next four messages ran a verification command"
+ holdsWithOutcome: 65, // FIELD line 24: "Of the 65 holds with a recorded outcome"
+ holdsSaferRoute: 40, // FIELD line 24: "the agent took a safer route 40 times"
+ holdsApproved: 24, // FIELD line 24: "the user approved 24"
+ holdsDeclined: 1, // FIELD line 24: "and declined 1"
+ ruleSteers: 349, // FIELD line 26: "349 steers named a project rule, spread over 36 rules"
+ rulesDistinct: 36, // FIELD line 26: same sentence
+ doneSteerScore: '0.99', // eval/reports/2026-09-26-waste-nudges/report.md line 166: "reports completion (0.99) after 1 file change"
+ ruleSteerScore: '0.88', // docs/examples.md line 20: "No hardcoded secrets" (0.88)
+};
+// ---------------------------------------------------------------------------------------------------------------
+
+// Example texts, all verbatim from the repo:
+// - loop "Warden catches it": the done-check steer in eval/reports/2026-09-26-waste-nudges/report.md line 166 (cut at ";").
+// - loop "Agent is told why": the same steer continues with doneNudge()'s text, src/done.ts line 270.
+// - steer strip: docs/examples.md lines 14-32 (the write, the steer, the fixed line), also shown in docs/preview.png.
+const doneSteer = `pi-warden: reports completion (${numbers.doneSteerScore}) after 1 file change with no test, build, or lint run since the last change`;
+const doneNudge = "Run the project's tests, build, or lint (whatever exists) on what you changed.";
+const written = '"SUPABASE_DB_URL", "postgresql://[local default]"';
+const ruleSteer = `pi-warden: the content just written to scripts/demo_rail_decisions.py violates project rule: "No hardcoded secrets" (${numbers.ruleSteerScore}): Source and config code must not contain passwords, API keys, tokens, or connection URLs … Fix it in your next edit.`;
+const fixed = 'DSN = os.getenv("SUPABASE_DB_URL")';
+
+const root = fileURLToPath(new URL('../', import.meta.url));
+const out = join(root, 'docs', 'hero.png');
+const html = join(root, 'docs', 'hero.html');
+const [width, height] = [1672, 941];
+
+const esc = text => text.replace(/&/g, '&').replace(//g, '>');
+const n = value => value.toLocaleString('en-US');
+
+const page = `
+
Your agent said *done.* It wasn't.
+
pi-warden tells the agent what it got wrong. The agent fixes it. You don't have to.
+
+
+
+
+
Agent acts
edits a file, then says it is done. No check ran after the edit.
+
Warden catches it
${esc(doneSteer)}
+
Agent is told why
${esc(doneNudge)}
+
Agent fixes itself
runs the tests, build, or lint, and reports the real result.
+
no human in the loop
+
+
+
+
+
When the agent said done with nothing checked
+
+
+
${n(numbers.doneNudged)}
times it was told
+
${n(numbers.doneRanCheck)}
it ran a check next (${numbers.doneRanCheckPct}%)
+
+
"Ran a check": one of the agent's next ${numbers.nextMessages} messages ran a test, build, or lint.
+
+
+
+
+
After Warden held a risky action${n(numbers.holdsWithOutcome)} with a known outcome
+
+
${n(numbers.holdsSaferRoute)}agent found a safer way
+
${n(numbers.holdsApproved)}you approved it
+
${n(numbers.holdsDeclined)}you said no
+
+
+
+
+
${n(numbers.ruleSteers)}
+
times the agent was told which project rule it broke, over ${n(numbers.rulesDistinct)} rules.
+
+
+
+
+
+
wroteDSN = os.getenv(${esc(written)})
+
told${esc(ruleSteer)}
+
next turn${esc(fixed)}
+
+
+
+`;
+
+mkdirSync(join(root, 'docs'), { recursive: true });
+writeFileSync(html, page);
+
+const candidates = [
+ process.argv[2],
+ process.env.CHROME_BIN,
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
+ ...['1243', '1234', '1223', '1200'].map(v => join(homedir(), 'Library', 'Caches', 'ms-playwright', `chromium_headless_shell-${v}`, 'chrome-headless-shell-mac-arm64', 'chrome-headless-shell')),
+].filter(Boolean);
+const chrome = candidates.find(path => existsSync(path));
+if (!chrome) {
+ console.error('No Chrome binary found. Pass one as the first argument or set CHROME_BIN. The HTML is at docs/hero.html.');
+ process.exit(1);
+}
+execFileSync(chrome, [
+ '--headless', '--disable-gpu', '--hide-scrollbars', '--force-device-scale-factor=1',
+ `--window-size=${width},${height}`, `--screenshot=${out}`, `file://${html}`,
+], { stdio: 'ignore' });
+console.log(`wrote ${out}`);