// lib/verification/done-line.mjs // The done line (P9 (4)). A step is "done" only when this script has written // its line, and it writes the line only when the test command exits 0: // // DONE step : `` exit 0 at // // appended to the step's report file. Exit 0 = written, 1 = the command failed // (nothing written), 2 = could not run (nothing written; never green): no // --step, a command outside the test-runner allowlist, a command that cannot // start (not installed) or was killed, a working tree that is not a git repo. // With no shell, an exit 126/127 is the test program's own and a failure. // // `--check --step --report [--commit ]`: exit 0 when the report // carries a well-formed line for step N (at exactly that commit, with // --commit), 1 when it does not. /trekexecute writes the line right after the // step's checkpoint and checks it with --commit, so the line is bound to the // commit the test passed on. // // Adapted from obra/superpowers@8ca22db (MIT, (c) 2025 Jesse Vincent), // skills/executing-plans/scripts/task-done: the completion line is appended // only when the test command exits 0. import { appendFileSync, existsSync, readFileSync } from 'node:fs'; import { spawnSync } from 'node:child_process'; import { resolve } from 'node:path'; import { DEFAULT_TIMEOUT_MS, allowedCommand, runCommand, screenCommand, stoppedBy } from './criteria-runner.mjs'; import { repoRoot } from './red-proof.mjs'; import { isMain } from '../util/is-main.mjs'; export function doneLineRe(step, commit) { const sha = commit ? String(commit).replace(/[^0-9a-f]/g, '') : '[0-9a-f]{40}'; return new RegExp(`^DONE step ${step}: \`.+\` exit 0 at ${sha} \\d{4}-\\d{2}-\\d{2}T\\S+$`, 'm'); } /** * With `commit` (the step's checkpoint sha, full 40 hex), the line must name * exactly that commit. A regex cannot tell a typed line from a written one; the * commit binds the line to the state the test ran on (/trekexecute writes the * line right after the checkpoint, so HEAD is the step's own commit). */ export function hasDoneLine(reportPath, step, commit) { if (!existsSync(reportPath)) return false; return doneLineRe(step, commit).test(readFileSync(reportPath, 'utf8')); } /** @returns {{status: 'written'|'failed'|'not-run', reason: string, line?: string, exit?: number|null}} */ export function writeDoneLine({ cwd, step, cmd, report, timeoutMs = DEFAULT_TIMEOUT_MS }) { if (!/^\d+$/.test(String(step ?? ''))) return { status: 'not-run', reason: '--step must be a step number' }; if (!report) return { status: 'not-run', reason: '--report is required' }; // The plan's Verify command is relative to the repo root, as in red-proof.mjs. const dir = repoRoot(resolve(cwd ?? process.cwd())); if (!dir) return { status: 'not-run', reason: `${cwd ?? process.cwd()} is not inside a git repository` }; const allowed = allowedCommand(cmd, { cwd: dir }); if (!allowed.allowed) return { status: 'not-run', reason: `command not run: ${allowed.reason}` }; const screen = screenCommand(cmd); if (!screen.allowed) return { status: 'not-run', reason: `command blocked: ${screen.rule}` }; const head = spawnSync('git', ['rev-parse', '--verify', '--quiet', 'HEAD'], { cwd: dir, encoding: 'utf8' }); if (head.status !== 0) return { status: 'not-run', reason: `no git HEAD in ${dir}` }; // No shell: the words the screen checked are the words that run. const r = runCommand(cmd, { cwd: dir, timeoutMs }); // A stop is read once, by stoppedBy (round 8). const stopped = stoppedBy(r, { timeoutMs }); if (stopped) return { status: 'not-run', reason: `the command ${stopped.why}`, exit: r.status }; if (r.status !== 0) return { status: 'failed', reason: `the test command exited ${r.status}`, exit: r.status }; const line = `DONE step ${step}: \`${cmd}\` exit 0 at ${head.stdout.trim()} ${new Date().toISOString()}`; appendFileSync(report, `${line}\n`); return { status: 'written', reason: 'the test command exited 0', line, exit: 0 }; } const USAGE = 'usage: done-line.mjs --step --cmd "" --report [--cwd ] [--json]\n' + ' done-line.mjs --check --step --report [--commit ]'; export function main(argv) { const o = {}; let check = false; let json = false; for (let i = 0; i < argv.length; i++) { const a = argv[i]; if (a === '--step' && argv[i + 1]) o.step = argv[++i]; else if (a === '--cmd' && argv[i + 1]) o.cmd = argv[++i]; else if (a === '--report' && argv[i + 1]) o.report = resolve(argv[++i]); else if (a === '--cwd' && argv[i + 1]) o.cwd = resolve(argv[++i]); else if (a === '--timeout' && argv[i + 1]) o.timeoutMs = Number(argv[++i]); else if (a === '--check') check = true; else if (a === '--commit' && argv[i + 1]) o.commit = argv[++i]; else if (a === '--json') json = true; else { process.stderr.write(`done-line: unknown argument ${a}\n${USAGE}\n`); return 2; } } if (check) { if (!/^\d+$/.test(String(o.step ?? '')) || !o.report) { process.stderr.write(`done-line: --check needs --step and --report\n${USAGE}\n`); return 2; } const ok = hasDoneLine(o.report, o.step, o.commit); process.stdout.write(json ? `${JSON.stringify({ step: Number(o.step), done: ok })}\n` : `step ${o.step}: ${ok ? 'DONE line present' : 'no DONE line - not done'}\n`); return ok ? 0 : 1; } let r; try { r = writeDoneLine(o); } catch (e) { r = { status: 'not-run', reason: `done-line crashed: ${e.message}` }; } process.stdout.write(json ? `${JSON.stringify(r)}\n` : `done line: ${r.status.toUpperCase()} - ${r.line ?? r.reason}\n`); return r.status === 'written' ? 0 : r.status === 'failed' ? 1 : 2; } if (isMain(import.meta.url)) process.exitCode = main(process.argv.slice(2));