voyage/lib/verification/done-line.mjs
Kjell Tore Guttormsen abc3083c1e
feat(execute): one fresh helper per step, a controller on the diff, proof by command
/trekexecute no longer builds steps itself. Each plan step is built by one
fresh helper (new agent step-implementer: opus, effort high, no Agent tool)
and judged by one controller (a general helper on opus) that reads the
diff from a recorded start_sha and gives two verdicts, spec and quality.
Fix rounds go up, maximum 5: three fixes by a fresh helper, then back to
the plan, then to the operator. No cheaper-model ladder.

Proof is a command, never the model's word:
- lib/verification/red-proof.mjs: the step's test must fail at the start
  point and pass at the result (exit 0/1/2; 2 is never green). Reproduces
  the 02243c6 red-first measurement (6 of 55) through the shim fixture.
- lib/verification/test-integrity.mjs: the floor guard on the diff
  (removed asserts, skipped/deleted tests, muted checks, stubs, loosened
  thresholds). Over the last 150 commits: 149 clean, 1 real finding.
- lib/verification/done-line.mjs: DONE step N only when the test command
  exits 0.

Test first is required from plan_version 1.8. The test lock (K8) is used
through its CLI and reported as not-locked while it is missing. A run
stops only on a closed list of four classes; every other call goes into a
decision log with its cost if wrong. Without a passing run-the-thing
criterion the result is not verified end to end, never green.

Handover 5 gains optional per-step start_sha, report_path, red_proof,
test_lock, fix_rounds (0-5) and run-level decisions and end_to_end; old
progress files still validate and resume.

No isolation on the helper: measured, a worktree-isolated subagent
branches from the default branch (02f320f), not the executor's HEAD
(2c89a6b), so it would miss the earlier steps.

Borrowings credited in commands/trekexecute.md ## Credits (Superpowers,
ai-native-sdlc, Kiro, GitHub Spec Kit, agent-skills, Cherny).

Tests: 1411 -> 1457 (1455 pass, 0 fail, 2 skipped).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 19:58:18 +02:00

96 lines
5.1 KiB
JavaScript

// 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 <N>: `<command>` exit 0 at <HEAD sha> <ISO time>
//
// 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, a working tree that is not a git repo.
//
// `--check --step <N> --report <file>`: exit 0 when the report carries a
// well-formed line for step N, 1 when it does not. A hand-typed "DONE" is not
// the script's line.
//
// 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, realpathSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { allowedCommand, screenCommand } from './criteria-runner.mjs';
import { commandEnv, DEFAULT_TIMEOUT_MS, repoRoot } from './red-proof.mjs';
export function doneLineRe(step) {
return new RegExp(`^DONE step ${step}: \`.+\` exit 0 at [0-9a-f]{40} \\d{4}-\\d{2}-\\d{2}T\\S+$`, 'm');
}
export function hasDoneLine(reportPath, step) {
if (!existsSync(reportPath)) return false;
return doneLineRe(step).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}` };
const r = spawnSync('/bin/sh', ['-c', cmd], { cwd: dir, env: commandEnv(), encoding: 'utf8', timeout: timeoutMs, maxBuffer: 64 * 1024 * 1024 });
if (r.error || r.status === null || r.status === 126 || r.status === 127) {
return { status: 'not-run', reason: `the command did not run to an exit code (${r.error?.message ?? r.signal ?? `exit ${r.status}`})`, 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 <N> --cmd "<test command>" --report <file> [--cwd <dir>] [--json]\n' +
' done-line.mjs --check --step <N> --report <file>';
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 === '--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);
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;
}
function isMain() {
try { return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)); }
catch { return false; }
}
if (process.argv[1] && isMain()) process.exitCode = main(process.argv.slice(2));