diff --git a/README.md b/README.md index 2945fea..b17aac6 100644 --- a/README.md +++ b/README.md @@ -38,9 +38,9 @@ looks like it worked while proving nothing about this commit. Every command below runs the working tree. If you installed NodeProof from npm instead of cloning it, read every `node dist/cli.js` below as `npx proofloop`. -![node dist/cli.js init then gate in a throwaway project: the gate FAILS (exit 1) while add() is wrong, and PASSES (exit 0) only after the one-line fix](docs/media/gate-demo.gif) +![node dist/cli.js init then gate in a throwaway project: the gate FAILS (exit 1) while add() is wrong, and PASSES (exit 0) only after the one-line fix](docs/media/gate-demo-narrow.gif) -*Scripted terminal session, replayed frame-by-frame — every output line is verbatim captured stdout from real `node dist/cli.js` runs of this working tree in a throwaway project (only the temp path is shortened; full transcript: [`docs/media/gate-demo-transcript.txt`](docs/media/gate-demo-transcript.txt)). Regenerate: `node scripts/record-gate-demo.mjs`.* +*Scripted terminal session, replayed frame-by-frame — every output line is verbatim captured stdout from real `node dist/cli.js` runs of this working tree in a throwaway project (only the temp path is shortened, and lines are wrapped at 34 columns so the text stays readable on a phone; transcript of this clip: [`docs/media/gate-demo-narrow-transcript.txt`](docs/media/gate-demo-narrow-transcript.txt); the earlier full-width recording is kept as [`docs/media/gate-demo.gif`](docs/media/gate-demo.gif) with its [transcript](docs/media/gate-demo-transcript.txt)). Regenerate: `node scripts/record-gate-demo.mjs --narrow`.* Zero runtime dependencies. Node >= 20. Works on any repo. diff --git a/docs/media/gate-demo-narrow-transcript.txt b/docs/media/gate-demo-narrow-transcript.txt new file mode 100644 index 0000000..a5bf6d4 --- /dev/null +++ b/docs/media/gate-demo-narrow-transcript.txt @@ -0,0 +1,80 @@ +$ ls # throwaway project; the agent just claimed “done” +app.js package.json test.js + +$ node dist/cli.js init +proofloop init: detected generic web app (no framework markers detected). +proofloop init: wrote ~/tiny-app\proofloop.config.json +proofloop init: package scripts updated in ~/tiny-app\package.json. +proofloop init: wrote ~/tiny-app\.proofloop\manifest.json + +Next steps: + 1. Add real proof checks to proofloop.config.json gate.checks, e.g.: + { "name": "build", "command": "npm run build" } + { "name": "tests", "command": "npm test" } + 2. Run `proofloop doctor` to confirm you're ready. + 3. Paste `proofloop prompt` into your coding agent, then run `proofloop gate` to prove the work. + 4. `proofloop hooks install` to make your agent refuse fake "done". + 5. `proofloop resume --dense` shows the next action when the loop stops. + +$ node dist/cli.js gate # the gate decides whether “done” is true +proofloop gate: no checks configured; falling back to `npm test` (package.json has a test script). +proofloop gate: running check "npm test" -> npm test + +> tiny-app@1.0.0 test +> node test.js + +proofloop gate: check "npm test" FAILED (2640ms, exit 1) +proofloop gate: FAILED + state: ~/tiny-app\.proofloop\gate-state.json + ts: 2026-10-16T15:09:56.875Z + source: npm-test-fallback + checks: + - [FAIL] npm test (2640ms) + +node:assert:150 + throw new AssertionError(obj); + ^ + +AssertionError [ERR_ASSERTION]: Expected values to be strictly equal: + +-1 !== 5 + + at Object. (~/tiny-app\test.js:3:8) + at Module._compile (node:internal/modules/cjs/loader:1705:14) + at Object..js (node:internal/modules/cjs/loader:1838:10) + at Module.load (node:internal/modules/cjs/loader:1441:32) + at Function._load (node:internal/modules/cjs/loader:1263:12) + at TracingChannel.traceSync (node:diagnostics_channel:328:14) + at wrapModuleLoad (node:internal/modules/cjs/loader:237:24) + at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:171:5) + at node:internal/main/run_main_module:36:49 { + generatedMessage: true, + code: 'ERR_ASSERTION', + actual: -1, + expected: 5, + operator: 'strictEqual', + diff: 'simple' +} + +Node.js v22.22.2 + +$ # one-line fix in app.js: +-module.exports = { add: (a, b) => a - b }; ++module.exports = { add: (a, b) => a + b }; + +$ node dist/cli.js gate +proofloop gate: no checks configured; falling back to `npm test` (package.json has a test script). +proofloop gate: running check "npm test" -> npm test + +> tiny-app@1.0.0 test +> node test.js + +test ok: add(2,3)=5 +proofloop gate: check "npm test" PASSED (2760ms, exit 0) +proofloop gate: PASSED + state: ~/tiny-app\.proofloop\gate-state.json + ts: 2026-10-16T15:10:00.090Z + source: npm-test-fallback + checks: + - [pass] npm test (2760ms) + diff --git a/docs/media/gate-demo-narrow.gif b/docs/media/gate-demo-narrow.gif new file mode 100644 index 0000000..7562531 Binary files /dev/null and b/docs/media/gate-demo-narrow.gif differ diff --git a/scripts/record-gate-demo.mjs b/scripts/record-gate-demo.mjs index 56c6422..21dd9ff 100644 --- a/scripts/record-gate-demo.mjs +++ b/scripts/record-gate-demo.mjs @@ -38,9 +38,25 @@ const { chromium } = await import("playwright").catch(() => { process.exit(1); }); +/** + * Phone variant: node scripts/record-gate-demo.mjs --narrow + * Same real runs, replayed on a 648x900 canvas at 32px so the text is still + * readable when GitHub scales the image to a ~324 css px phone column (cap + * height about 10 css px; the 960px clip lands near 3). Lines are wrapped at + * 34 columns on word boundaries (a token longer than a row is split), never + * inside FAILED / PASSED; the characters are unchanged, and the script aborts + * if re-joining the wrapped rows does not give back every transcript line. + * Output: docs/media/gate-demo-narrow.gif (+ docs/media/gate-demo-narrow-transcript.txt, + * the fresh run it replays; the wide clip and its transcript are not touched). + * NARROW_WRAP_JSON= also dumps the wrapped rows for an external check. + */ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); -const OUT_GIF = path.join(ROOT, "docs/media/gate-demo.gif"); -const OUT_TXT = path.join(ROOT, "docs/media/gate-demo-transcript.txt"); +const NARROW = process.argv.includes("--narrow"); +const OUT_GIF = path.join(ROOT, NARROW ? "docs/media/gate-demo-narrow.gif" : "docs/media/gate-demo.gif"); +const OUT_TXT = path.join( + ROOT, + NARROW ? "docs/media/gate-demo-narrow-transcript.txt" : "docs/media/gate-demo-transcript.txt", +); // --- 1. Throwaway fixture: a tiny app whose test is honest and whose code is wrong. const fixture = await fs.mkdtemp(path.join(os.tmpdir(), "proofloop-demo-")); @@ -96,6 +112,141 @@ push(gatePass); await fs.mkdir(path.dirname(OUT_TXT), { recursive: true }); await fs.writeFile(OUT_TXT, lines.map((l) => l.s).join("\n") + "\n"); +// --- 3b. Phone variant (--narrow): wrap, replay on a 648x900 canvas, build the GIF, stop. +const COLS = 34; +const IND = " "; +// Greedy word wrap. A soft break drops the spaces at the break (kept in `join`), +// a token longer than a row is split (join ""), so rows can always be re-joined exactly. +function wrapLine(s) { + if (s.length <= COLS) return [{ text: s, join: "" }]; + const rows = []; + let cur = ""; + let pend = ""; + let join = ""; + const flush = (next) => { + rows.push({ text: cur, join }); + cur = IND; + join = next; + }; + for (const tok of s.match(/ +|[^ ]+/g)) { + if (tok[0] === " ") { + pend += tok; + continue; + } + if (cur.length + pend.length + tok.length <= COLS) { + cur += pend + tok; + pend = ""; + continue; + } + if (cur.trim()) flush(pend); + else cur += pend; + pend = ""; + let w = tok; + while (cur.length + w.length > COLS) { + const room = COLS - cur.length; + cur += w.slice(0, room); + w = w.slice(room); + flush(""); + } + cur += w; + } + rows.push({ text: cur + pend, join }); + return rows; +} +if (NARROW) { + const esc = (x) => x.replace(/&/g, "&").replace(//g, ">"); + const rows = []; + for (const [src, l] of lines.entries()) { + const w = wrapLine(l.s); + const back = w.map((r, i) => (i ? r.join + r.text.slice(IND.length) : r.text)).join(""); + if (back !== l.s) throw new Error(`wrap changed a transcript line: ${JSON.stringify(l.s)}`); + for (const word of ["FAILED", "PASSED"]) { + const n = (x) => x.split(word).length - 1; + if (n(l.s) !== w.reduce((a, r) => a + n(r.text), 0)) throw new Error(`${word} split across rows: ${JSON.stringify(l.s)}`); + } + for (const r of w) rows.push({ ...r, src, t: l.t, verdict: /^proofloop gate: (FAILED|PASSED)$/.test(l.s) }); + } + if (process.env.NARROW_WRAP_JSON) { + await fs.writeFile( + process.env.NARROW_WRAP_JSON, + JSON.stringify({ cols: COLS, indent: IND.length, lines: lines.map((l) => l.s), rows }, null, 1), + ); + } + const W = 648, H = 900, FONT = 32, PITCH = 45, PAD = 24; + const visible = Math.floor((H - 2 * PAD) / PITCH); + const rowHtml = (r) => { + const cls = r.t + (/FAILED/.test(r.text) ? " fail" : "") + (/PASSED/.test(r.text) ? " pass" : ""); + return `
${esc(r.text) || " "}
`; + }; + const page = ` + +
${rows.map(rowHtml).join("")}
+`; + // 3 rows per frame, but a frame always ends on a verdict row; verdict frames and the last frame are held. + const PER_FRAME = 3, FRAME_S = 0.36, HOLD_S = 2.4; + const marks = []; + let since = 0; + rows.forEach((r, i) => { + if (++since === PER_FRAME || r.verdict || i === rows.length - 1) { + marks.push(i + 1); + since = 0; + } + }); + const frameDir = await fs.mkdtemp(path.join(os.tmpdir(), "gate-frames-")); + const browser = await chromium.launch(); + const pg = await browser.newPage({ viewport: { width: W, height: H } }); + await pg.setContent(page); + // rows are display:none until revealed, so show them all once to measure each row's real width + const overflow = await pg.evaluate(() => + [...document.querySelectorAll(".ln")].filter((el) => { + el.style.display = "block"; + return el.scrollWidth > el.clientWidth; + }).length, + ); + if (overflow) throw new Error(`${overflow} wrapped row(s) are wider than the canvas`); + const list = ["ffconcat version 1.0"]; + for (const [k, n] of marks.entries()) { + await pg.evaluate((m) => window.reveal(m), n); + const f = `f${String(k).padStart(4, "0")}.png`; + await pg.screenshot({ path: path.join(frameDir, f) }); + const hold = rows[n - 1].verdict || k === marks.length - 1; + list.push(`file ${f}`, `duration ${hold ? HOLD_S : FRAME_S}`); + } + await browser.close(); + await fs.writeFile(path.join(frameDir, "list.txt"), list.join("\n") + "\n"); + await fs.mkdir(path.dirname(OUT_GIF), { recursive: true }); + execSync( + `ffmpeg -y -f concat -safe 0 -i list.txt -vf "split[s0][s1];[s0]palettegen=max_colors=16:stats_mode=full[p];[s1][p]paletteuse=dither=none" -fps_mode vfr -final_delay ${HOLD_S * 100} "${OUT_GIF}"`, + { cwd: frameDir, stdio: "pipe" }, + ); + await fs.rm(frameDir, { recursive: true, force: true }); + await fs.rm(fixture, { recursive: true, force: true }); + const bytes = (await fs.stat(OUT_GIF)).size; + console.log(`gate refused (exit 1) then passed (exit 0); ${rows.length} wrapped rows, ${marks.length} frames`); + console.log(`clip: ${path.relative(ROOT, OUT_GIF)} (${bytes} bytes)`); + console.log(`transcript: ${path.relative(ROOT, OUT_TXT)}`); + // no larger than the 960px clip it sits beside (1,451,386 bytes) + if (bytes > 1_451_386) { + console.error("FAILED: narrow gif is larger than the wide one"); + process.exit(1); + } + process.exit(0); +} + // --- 4. Frame-by-frame replay: reveal one line per frame at 8 fps, with holds // after the refusal and the pass so both verdicts are readable. const esc = (s) => s.replace(/&/g, "&").replace(//g, ">");