done-done-axi 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,20 +1,24 @@
1
1
  #!/usr/bin/env node
2
2
  import { execFileSync, spawn, spawnSync } from 'node:child_process';
3
3
  import { createHash, randomBytes } from 'node:crypto';
4
- import { accessSync, appendFileSync, closeSync, constants, existsSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
5
- import { homedir, userInfo } from 'node:os';
6
- import { dirname, isAbsolute, join, matchesGlob, relative, resolve, sep } from 'node:path';
4
+ import { accessSync, appendFileSync, closeSync, constants, cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
5
+ import { homedir, tmpdir, userInfo } from 'node:os';
6
+ import { basename, dirname, isAbsolute, join, matchesGlob, relative, resolve, sep } from 'node:path';
7
7
  import { fileURLToPath } from 'node:url';
8
8
  import { createInterface } from 'node:readline/promises';
9
9
  import { parseArgs } from 'node:util';
10
10
  import { encode } from '@toon-format/toon';
11
11
  import { Document, parseDocument } from 'yaml';
12
- import { ACPX_INSTALL, agentChoices, agentRun, askAgent, findBin, JUDGES, modelFor, noAgent, noJudge, notStartable, routeOf, selectAgent, selectJudge, supported } from './agents.js';
13
- import { AGENTS, CHEAP, globalConfigText, home, isAgent, loadConfig, parseDuration, UsageError } from './config.js';
14
- import { backend, judge, judgeable, onPath, quote } from './judge.js';
15
- import { maintainSteps, refuseSameSession } from './maintain.js';
12
+ import { ACPX_INSTALL, agentChoices, agentPath, agentRun, askAgent, editOnlyReason, findBin, JUDGES, modelFor, noAgent, noJudge, notStartable, routeOf, selectAgent, selectJudge } from './agents.js';
13
+ import { AGENTS, CHEAP, globalConfigText, home, inputPattern, isAgent, loadConfig, parseDuration, UsageError } from './config.js';
14
+ import { backend, judge, judgeable, onPath, quote, screen } from './judge.js';
15
+ import { startInstance, stopInstance } from './instance.js';
16
+ import { attemptProblem, changedSince, commitFix, dirtyTracked, FIX_SCHEMA, inWorktree, maintainSteps, named, pathspec, repoState, triage } from './maintain.js';
17
+ import { cellEnv, cellName, cellSuffix, hasMatrix, matrixManifest, splitMatrix } from './matrix.js';
16
18
  import { design, KINDS } from './design.js';
17
19
  import { checkMedia } from './media.js';
20
+ import { domIssues, isDomRecord, SQUASHED_TEXT } from './dom.js';
21
+ import { scanSecrets } from './secrets.js';
18
22
  const PASS = 0, FAIL = 1, USAGE = 2, INCONCLUSIVE = 3, WAITING = 4;
19
23
  // The shape of a journey, so a new user or agent can write one without opening the reference.
20
24
  // done-done sets DONE_DONE_EVIDENCE_ROOT (the run's own folder) and replaces ${RUN_ID}, so the example works for any evidence root.
@@ -22,7 +26,9 @@ const journeyExample = 'journeys: { smoke: { command: "<drive the real app> > $D
22
26
  const JOURNEYS_TEXT = `journeys (.done-done.yaml): the command runs with DONE_DONE_EVIDENCE_ROOT (this run's own new folder in the evidence
23
27
  root) and DONE_DONE_RUN_ID set, and \${RUN_ID} in it is replaced; it must write its files into that folder, e.g.
24
28
  ${journeyExample}
25
- timeout (journeys.<name>.timeout): how long a verify run may drive the journey, e.g. 90s or 30m (default 30m); past it the journey is killed and the run is inconclusive`;
29
+ spends (journeys.<name>.spends): what a run spends, e.g. "one live generation"; verify parks the journey until the user approves that run (respond --action approve)
30
+ timeout (journeys.<name>.timeout): how long a verify run may drive the journey, e.g. 90s or 30m (default 30m); past it the journey is killed and the run is inconclusive
31
+ matrix (.done-done.yaml): with theme, width or form axes the journey runs once per required cell with DONE_DONE_THEME, DONE_DONE_WIDTH, DONE_DONE_FORM and DONE_DONE_CELL (the capture name suffix) set; name each capture with its suffix, so every cell is captured or skipped with a reason`;
26
32
  const CHECKS_TEXT = `Checks block (--from): the newest "## Checks" heading, then these four keys, each at the start of a line:
27
33
  outcome: <what a user can now do>
28
34
  repro: <command that failed before and passes now, or n/a; a fix PR needs one>
@@ -30,14 +36,21 @@ const CHECKS_TEXT = `Checks block (--from): the newest "## Checks" heading, then
30
36
  evidence: <the evidence file paths, or "not proven: <cause>">`;
31
37
  const RUNS_TEXT = `runs: each verify or maintain run lives in $DONE_DONE_HOME/runs/<app>/<run-id> (run.json, events.jsonl, logs/<step>.log,
32
38
  agents/<n>-<name>.json for each agent call) and goes on when the command that started it ends;
33
- verify steps: resolve, drive, check, judge (unless --no-judge), gate, receipt, report;
39
+ verify steps: resolve, drive, check, judge (skipped with --no-judge), triage and fix (only with auto_fix.verify above 0), gate, receipt, report;
34
40
  maintain steps: locate, hygiene, source-wave, reconcile, triage and fix (only with auto_fix.maintain above 0), report
35
41
  drive: the journey when there is one; else the author agent (agent: in config) drives one feature of the verify skill the way a user
36
- does and saves screenshots into the run's evidence folder; it may run commands and edit files only in the repo and that folder where
42
+ does and saves captures into the run's evidence folder; it may run commands and edit files only in the repo and that folder where
37
43
  its CLI can limit it (pi cannot), and a drive that changes a tracked file fails
44
+ inputs (.done-done.yaml): repo-relative paths or globs the drive must leave alone (fixtures, data files);
45
+ hashed before and after the drive; the check step fails naming each changed, appeared or
46
+ disappeared path (never file contents), and the before/after hash lists go into the run record; no key: unchanged
38
47
  gate: a judge fail or unsure (or an agent drive with no judged capture) parks the run as awaiting_approval (exit 4); answer it with
39
48
  respond: approve or skip (each needs --reason) ends the run overridden, never pass, and its receipt says who and why, so
40
- receipt --verify refuses it; fix ends the run fail`;
49
+ receipt --verify refuses it; fix ends the run fail
50
+ auto_fix: { verify: <n> } (config; 0, the default, reports only): the judge sorts each wrong screen (fix or ask; an ask, an unsure
51
+ answer or a mixed result parks at the gate), then the agent the user set fixes the product in a done-done worktree with your
52
+ ignored files copied in, and verify runs again there, up to n times. Result: changed (the fix on a done-done/verify-<run-id>
53
+ branch, your HEAD still fails; exit 1) or blocked (dirty checkout, a guard, or every attempt used; exit 1)`;
41
54
  const MAINTAIN_TEXT = `maintain reads the verify skill and the source; HEAD, git status and the content of changed files must be the same before and after.
42
55
  locate: the verify skill (.agents/skills/verify-<app>/SKILL.md); none: blocked, create it with done-done init
43
56
  hygiene: every local link in the skill resolves; features/README.md lists each features/*.md file once
@@ -59,7 +72,7 @@ const DOCTOR_TEXT = `doctor changes nothing except the --probe call record (the
59
72
  set, with the command that saves the user's choice), agent_settings (agent, model, effort,
60
73
  args, path and agent_timeout, each with the file it came from: the global config, .done-done.yaml or default), judge (the judge route is ready: for claude-code an
61
74
  executable claude binary, a sign-in folder and the judge kit, for answerer:<agent> that agent's CLI and the kit, for openai:key
62
- OPENAI_API_KEY and the kit; the model is never called; missing when none is set, with the command that saves the user's choice),
75
+ OPENAI_API_KEY and the kit; doctor does not call the model; missing when none is set, with the command that saves the user's choice),
63
76
  judge_choice (the judge, and how far it is from the author: another agent, another model, or session-only), ffprobe (video evidence only).
64
77
  --probe also asks the agent one JSON-schema question (one call at the agent's model: its cheap default where it has one, else its own; set agent_config.<agent>.model to choose; its record goes to
65
78
  $DONE_DONE_HOME/agents/) and then requires a valid answer.
@@ -95,7 +108,7 @@ const FLAGS = {
95
108
  '--action <approve|skip|fix>': 'approve: the captures do show the outcome; skip: go on without a judge pass; both end the run overridden; fix: end it fail, then fix the product or the verify skill (done-done maintain checks it against the source)',
96
109
  '--reason "<why>"': 'why (required for approve and skip); kept in the run and its receipt with who answered',
97
110
  '--limit <n>': 'how many past runs to list (default all)',
98
- '--agent <agent>': 'the coding agent done-done starts (claude, opencode, pi, gemini or acp:<target>), saved as agent in ~/.done-done/config.yaml; without it, init asks in a terminal',
111
+ '--agent <agent>': 'the coding agent done-done starts (claude, codex, opencode, pi, gemini or acp:<target>), saved as agent in ~/.done-done/config.yaml; without it, init asks in a terminal',
99
112
  '--model <model>': 'with --agent: that agent\'s model, saved as agent_config.<agent>.model (opencode: provider/model)',
100
113
  '--judge <agent>': `the judge done-done starts (${JUDGES.join(' or ')}), saved as judge.route in ~/.done-done/config.yaml; never the agent and model that did the work; without it, init asks in a terminal`,
101
114
  '--judge-model <model>': 'with --judge <agent>: the judge\'s model, saved as judge.model',
@@ -155,7 +168,7 @@ const COMMANDS = {
155
168
  },
156
169
  verify: {
157
170
  usage: [
158
- ['done-done verify [<journey>] --outcome "<what a user can now do>" [--claim-id <id>] [--no-judge]', 'drive the journey in a run that outlives this command, check its evidence, judge each screenshot and record a receipt'],
171
+ ['done-done verify [<journey>] --outcome "<what a user can now do>" [--claim-id <id>] [--no-judge]', 'drive the journey in a run that outlives this command, check its evidence, judge each capture and record a receipt'],
159
172
  ['done-done verify --outcome "<claim>" [--feature <name>]', 'with no journey, the author agent drives a verify skill feature and the judge (never the author) judges its captures'],
160
173
  ['done-done verify [<journey>] --outcome "<claim>" --no-wait | --wait | --foreground', 'return at once, wait until the run ends or waits at its gate (default), or run in this process (CI)'],
161
174
  ],
@@ -207,6 +220,11 @@ const COMMANDS = {
207
220
  flags: ['--run <run-id>'],
208
221
  more: [RUNS_TEXT],
209
222
  },
223
+ signoff: {
224
+ usage: [['done-done signoff [--run <run-id>]', "write the run's sign-off message (contract v1): a gate for a run waiting at its gate, evidence for a finished run; it prints the post command and never posts"]],
225
+ flags: ['--run <run-id>'],
226
+ more: [RUNS_TEXT],
227
+ },
210
228
  history: {
211
229
  usage: [['done-done history [--limit <n>]', 'list past runs, newest first']],
212
230
  flags: ['--limit <n>'],
@@ -214,9 +232,9 @@ const COMMANDS = {
214
232
  },
215
233
  };
216
234
  const rows = (pairs, width = Math.max(...pairs.map(([a]) => a.length)) + 3) => pairs.map(([a, b]) => ` ${a}${' '.repeat(Math.max(3, width - a.length))}${b}`).join('\n');
217
- const GLOBAL_TEXT = `global flags: --json (JSON instead of TOON), --full (do not truncate lists), --help (this command's usage)
235
+ const GLOBAL_TEXT = `global flags: --json (JSON instead of TOON), --full (do not truncate lists), --help (this command's usage), --version (done-done's version)
218
236
  exit codes: 0 pass, 1 fail (also a run overridden at its gate), 2 usage or config error, 3 inconclusive, 4 the run is still running or a verify run waits at its gate (verify, maintain, rerun, status, respond)`;
219
- const USAGE_TEXT = `install: npm install -g --prefix ~/.local --allow-git=all github:umeranjum17/done-done-axi (installs done-done-axi and done-done), or run npx -y --allow-git=all github:umeranjum17/done-done-axi <command>
237
+ const USAGE_TEXT = `install: npm install -g --prefix ~/.local done-done-axi (installs done-done-axi and done-done), or run npx -y done-done-axi <command>
220
238
  usage:
221
239
  ${rows([['done-done', "show this repo's config state and the next step"], ...Object.values(COMMANDS).flatMap((c) => c.usage)], 46)}
222
240
  ${GLOBAL_TEXT}
@@ -335,6 +353,8 @@ function closeRun(repo, cfg, rec) {
335
353
  return { path: p, sha256: sha256(b), bytes: b.length };
336
354
  });
337
355
  rec.ended_at = new Date().toISOString();
356
+ if (hasMatrix(cfg.matrix))
357
+ rec.matrix = matrixManifest(cfg.matrix, rec.files.map((f) => f.path));
338
358
  const record = writeRecord(cfg, rec);
339
359
  const rel = (p) => citable(repo, join(cfg.evidence.root, p));
340
360
  let reason = '';
@@ -360,6 +380,8 @@ function closeRun(repo, cfg, rec) {
360
380
  result.evidence = rows.items;
361
381
  if (rows.omitted)
362
382
  result.evidence_omitted = rows.omitted;
383
+ if (rec.matrix)
384
+ result.matrix = rec.matrix;
363
385
  result.help = [reason
364
386
  ? `Fix the capture, commit, then re-run: ${captureHint(cfg)}`
365
387
  : `Check this proof: done-done check --outcome "<what you proved>" ${rel(rec.files[0].path)}`];
@@ -373,7 +395,7 @@ function closeRun(repo, cfg, rec) {
373
395
  }
374
396
  return { code: reason ? FAIL : PASS, result, reason };
375
397
  }
376
- function cmdRun(argv) {
398
+ async function cmdRun(argv) {
377
399
  const dash = argv.indexOf('--');
378
400
  const { values, positionals } = parseArgs({
379
401
  args: dash < 0 ? argv : argv.slice(0, dash),
@@ -394,6 +416,9 @@ function cmdRun(argv) {
394
416
  const journey = positionals[0] ?? null;
395
417
  if (positionals.length > 1)
396
418
  throw new UsageError('run takes at most one journey name');
419
+ const spends = journey ? cfg.journeys[journey]?.spends : undefined;
420
+ if (spends)
421
+ throw new UsageError(`journey ${journey} spends (${spends}), so run refuses it: nothing ran`, `Ask the user, then: done-done verify ${journey} --outcome "<what a user can now do>"`);
397
422
  let command = dash < 0 ? null : argv.slice(dash + 1).join(' ');
398
423
  if (journey && !command) {
399
424
  const j = cfg.journeys[journey];
@@ -420,14 +445,58 @@ function cmdRun(argv) {
420
445
  throw new UsageError('run needs a journey, a command after --, or --open');
421
446
  const expanded = command.replaceAll('${RUN_ID}', rec.run_id);
422
447
  rec.command = expanded;
423
- // The journey's own stdout goes to our stderr, never our stdout: stdout carries only done-done's
424
- // result document (TOON or --json), so a noisy journey cannot break `run --json | JSON.parse`.
425
- const r = spawnSync(expanded, {
426
- cwd: repo, shell: true, stdio: ['inherit', process.stderr, 'inherit'],
427
- env: { ...process.env, DONE_DONE_RUN_ID: rec.run_id, DONE_DONE_EVIDENCE_ROOT: runDir(cfg, rec.run_id) },
428
- });
429
- rec.exit = r.status ?? 1;
430
- return emitted(closeRun(repo, cfg, rec));
448
+ // With an instance block the app starts first on its own port, so the recipe uses
449
+ // $DONE_DONE_BASE_URL instead of a hard-coded port; without one this is today's behaviour.
450
+ let inst;
451
+ let scratch;
452
+ const SIGS = ['SIGINT', 'SIGTERM', 'SIGHUP'];
453
+ const reap = (sig) => {
454
+ if (inst)
455
+ stopInstance(inst.pgid);
456
+ if (scratch)
457
+ rmSync(scratch, { recursive: true, force: true });
458
+ if (sig)
459
+ process.kill(process.pid, sig);
460
+ };
461
+ if (cfg.instance) {
462
+ scratch = mkdtempSync(join(tmpdir(), 'done-done-instance-'));
463
+ try {
464
+ inst = await startInstance(repo, cfg.instance, rec.run_id, scratch, process.env, join(runsRoot(cfg.app), rec.run_id, 'logs', 'instance.log'));
465
+ for (const s of SIGS)
466
+ process.once(s, reap);
467
+ }
468
+ catch (e) {
469
+ rmSync(scratch, { recursive: true, force: true });
470
+ rec.exit = 1;
471
+ const closed = closeRun(repo, cfg, rec);
472
+ return emitted({ code: closed.code, result: { ...closed.result, reason: `instance: ${e.message}` } });
473
+ }
474
+ }
475
+ // With a matrix block the journey runs once per required cell with that cell in env.
476
+ const matrix = hasMatrix(cfg.matrix) ? splitMatrix(cfg.matrix) : null;
477
+ const failed = [];
478
+ rec.exit = 0;
479
+ for (const cell of matrix?.required ?? [{}]) {
480
+ // The journey's own stdout goes to our stderr, never our stdout: stdout carries only done-done's
481
+ // result document (TOON or --json), so a noisy journey cannot break `run --json | JSON.parse`.
482
+ const r = await new Promise((done) => {
483
+ spawn(expanded, {
484
+ cwd: repo, shell: true, stdio: ['inherit', process.stderr, 'inherit'],
485
+ env: { ...process.env, DONE_DONE_RUN_ID: rec.run_id, DONE_DONE_EVIDENCE_ROOT: runDir(cfg, rec.run_id), ...inst?.env, ...(matrix ? cellEnv(cell) : {}) },
486
+ }).on('error', () => done(null)).on('close', done);
487
+ });
488
+ const code = r ?? 1;
489
+ if (code !== 0) {
490
+ if (!failed.length)
491
+ rec.exit = code;
492
+ failed.push(`${cellName(cell)} (exit ${code})`);
493
+ }
494
+ }
495
+ reap();
496
+ const sealed = closeRun(repo, cfg, rec);
497
+ if (matrix && failed.length)
498
+ return emitted({ code: FAIL, result: { ...sealed.result, reason: `the run command failed for cell ${failed.join('; ')}` } });
499
+ return emitted(sealed);
431
500
  }
432
501
  // The newest "## Checks" block: outcome, repro, proof and evidence lines.
433
502
  function claimFromChecks(body, title, repo, cfg, out) {
@@ -463,7 +532,7 @@ function claimFromChecks(body, title, repo, cfg, out) {
463
532
  // A PR body also names its head branch, which is the claim a CI checkout (a detached HEAD) is checking.
464
533
  function readSource(from) {
465
534
  if (from === '-')
466
- return { body: readFileSync(0, 'utf8'), title: '' };
535
+ return { body: readFileSync(0, 'utf8') };
467
536
  if (/^https?:\/\//.test(from)) {
468
537
  const r = spawnSync('gh', ['pr', 'view', from, '--json', 'body,title,headRefName'], { encoding: 'utf8' });
469
538
  if (r.error || r.status !== 0)
@@ -472,7 +541,7 @@ function readSource(from) {
472
541
  return { body: pr.body, title: pr.title, branch: pr.headRefName };
473
542
  }
474
543
  try {
475
- return { body: readFileSync(from, 'utf8'), title: '' };
544
+ return { body: readFileSync(from, 'utf8') };
476
545
  }
477
546
  catch (e) {
478
547
  throw new UsageError(`cannot read ${from}: ${e.message}`);
@@ -485,7 +554,7 @@ function loadRecords(cfg) {
485
554
  return readdirSync(dir).filter((n) => n.endsWith('.json')).map((n) => JSON.parse(readFileSync(join(dir, n), 'utf8'))).filter((r) => r.ended_at);
486
555
  }
487
556
  // Run a check and return its result without printing it, so `check` and `receipt` share one path.
488
- // run: a verify run's folder (the judge's call records go to its agents/) and the hook that starts its judge step.
557
+ // run: a verify run's folder (the judge's call records go to its agents/), its judge-step hook, and the checkout and config it checks.
489
558
  async function runCheck(argv, run) {
490
559
  const { values, positionals } = parseArgs({
491
560
  args: argv,
@@ -498,7 +567,7 @@ async function runCheck(argv, run) {
498
567
  throw new UsageError('--from takes no file arguments; the Checks block names them');
499
568
  if (values['claim-id']?.trim() === '')
500
569
  throw new UsageError('--claim-id needs a non-empty id');
501
- const repo = repoRoot();
570
+ const repo = run?.repo ?? repoRoot();
502
571
  let projectText = null;
503
572
  if (values['trusted-ref']) {
504
573
  try {
@@ -511,7 +580,7 @@ async function runCheck(argv, run) {
511
580
  projectText = '';
512
581
  }
513
582
  }
514
- const cfg = loadConfig(repo, projectText);
583
+ const cfg = run?.cfg ?? loadConfig(repo, projectText);
515
584
  const now = head(repo);
516
585
  const findings = [];
517
586
  let claim;
@@ -524,7 +593,9 @@ async function runCheck(argv, run) {
524
593
  else {
525
594
  const src = readSource(values.from);
526
595
  prBranch = src.branch;
527
- claim = claimFromChecks(src.body, src.title, repo, cfg, findings);
596
+ // A local file or stdin has no PR title, so the HEAD commit subject is the title.
597
+ const title = src.title ?? git(repo, 'log', '-1', '--format=%s');
598
+ claim = claimFromChecks(src.body, title, repo, cfg, findings);
528
599
  }
529
600
  if (claim?.notProven) {
530
601
  const allowed = cfg.checks.not_proven === 'allow' && cfg.trusted;
@@ -538,6 +609,13 @@ async function runCheck(argv, run) {
538
609
  const hashes = [];
539
610
  const cited = [];
540
611
  const captures = [];
612
+ // base_ref: a capture in <run id>/before/ of a run that drove its base ref pairs with <run id>/<the same name>.
613
+ const pairOf = (inRoot) => {
614
+ const [id, dir, ...rest] = inRoot?.split(sep) ?? [];
615
+ return dir === BEFORE && rest.length && records.some((r) => r.run_id === id && r.base) ? join(id, ...rest) : null;
616
+ };
617
+ const befores = new Map();
618
+ const heads = [];
541
619
  for (const name of files) {
542
620
  const path = resolve(name);
543
621
  let bytes;
@@ -556,11 +634,19 @@ async function runCheck(argv, run) {
556
634
  const hash = sha256(bytes);
557
635
  hashes.push(hash);
558
636
  cited.push({ path: receiptPath(repo, cfg, path), sha256: hash });
559
- if (seen.has(hash))
637
+ // A page-check record is a capture's companion: two clean cells of one page write the same bytes, and the judge never reads one.
638
+ const record = !!cfg.dom && isDomRecord(name);
639
+ const inRoot = under(cfg.evidence.root, path);
640
+ const pair = pairOf(inRoot);
641
+ if (cfg.dom && !pair && ['png', 'jpeg', 'webp', 'gif', 'mp4', 'webm'].includes(v.type))
642
+ for (const why of domIssues(cfg.dom, path))
643
+ findings.push({ level: 'fail', why: `${name}: ${why}` });
644
+ // A base capture may equal its head capture byte for byte: the pair's judge, not this check, says the screen did not change.
645
+ if (seen.has(hash) && !record && !pair)
560
646
  findings.push({ level: 'fail', why: `${name}: byte-identical to ${seen.get(hash)}` });
561
- seen.set(hash, name);
647
+ if (!pair)
648
+ seen.set(hash, name);
562
649
  // A file in the evidence root is claimed by the record that lists its path; a copy elsewhere, by its bytes.
563
- const inRoot = under(cfg.evidence.root, path);
564
650
  const claims = records.filter((r) => r.files.some((f) => f.sha256 === hash && (inRoot === null || f.path === inRoot)));
565
651
  if (!claims.length && inRoot !== null) {
566
652
  const edited = records.filter((r) => r.files.some((f) => f.path === inRoot));
@@ -582,8 +668,14 @@ async function runCheck(argv, run) {
582
668
  }
583
669
  const rec = claims[0];
584
670
  // Only a capture tied to a run record can reach the judge, so a stray cited file is never sent.
585
- if (v.status === 'ok')
586
- captures.push({ name, bytes, sha256: hash, media: v });
671
+ const c = { name, bytes, sha256: hash, media: v };
672
+ if (v.status === 'ok' && !record && pair)
673
+ befores.set(pair, c);
674
+ else if (v.status === 'ok' && !record) {
675
+ captures.push(c);
676
+ if (rec.base)
677
+ heads.push([c, inRoot, rec]);
678
+ }
587
679
  const stale = staleness(repo, cfg, rec.commit, now.commit);
588
680
  if (stale)
589
681
  findings.push({ level: 'fail', why: `${name}: ${stale} (run ${rec.run_id})` });
@@ -592,6 +684,14 @@ async function runCheck(argv, run) {
592
684
  if (rec.exit !== null && rec.exit !== 0)
593
685
  findings.push({ level: 'fail', why: `${name}: its run command exited ${rec.exit} (run ${rec.run_id})` });
594
686
  }
687
+ // A head capture of a base_ref run with no base capture of its name is reported, then judged alone.
688
+ for (const [c, inRoot, rec] of heads)
689
+ if (!(c.before = befores.get(inRoot ?? '')))
690
+ findings.push({ level: 'inconclusive', why: `${c.name}: no base capture to compare with: the drive at base_ref ${rec.base.ref} saved no ${join(BEFORE, inRoot ? relative(rec.run_id, inRoot) : c.name)}${rec.base.error ? ` (${rec.base.error})` : ''}`, file: c.name });
691
+ // The capture matrix: every required cell needs a capture named with its suffix, never silently missing.
692
+ if (hasMatrix(cfg.matrix))
693
+ for (const c of matrixManifest(cfg.matrix, files.filter((f) => (!cfg.dom || !isDomRecord(f)) && !pairOf(under(cfg.evidence.root, resolve(f))))).cells.filter((c) => !c.files.length))
694
+ findings.push({ level: 'fail', why: `cell ${c.cell} has no capture: name one file *${c.suffix}* (journeys name captures with DONE_DONE_CELL)` });
595
695
  // Reuse: fail only when every cited file was already used for a different claim id. The ledger lives in the
596
696
  // evidence root next to the run records it guards: one per app (repo), shared by every checkout of it, never in git.
597
697
  const ledger = join(cfg.evidence.root, '.done-done', 'ledger');
@@ -632,8 +732,9 @@ async function runCheck(argv, run) {
632
732
  judgeLevel = 'fail';
633
733
  else if (!findings.length && j.judged.some((r) => r.verdict === 'unsure'))
634
734
  judgeLevel = 'inconclusive';
735
+ const shots = new Set(captures.filter(screen).map((c) => c.name));
635
736
  for (const r of j.judged.filter((r) => r.verdict !== 'pass'))
636
- findings.push({ level: r.verdict === 'fail' ? 'fail' : 'inconclusive', why: `${r.path}: judge says ${r.verdict === 'fail' ? 'wrong screen' : 'unsure'}: ${r.reason}` });
737
+ findings.push({ level: r.verdict === 'fail' ? 'fail' : 'inconclusive', why: `${r.path}: judge says ${r.verdict !== 'fail' ? 'unsure' : shots.has(r.path) ? 'wrong screen' : 'wrong output'}: ${r.reason}`, file: r.path });
637
738
  const rows = cap(j.judged);
638
739
  const { choice } = j;
639
740
  judged = { ...(choice && { route: choice.route, model: choice.model, independence: choice.independence, author: `${choice.author.agent} (model ${choice.author.model || 'none'})` }), judged: rows.items.map(({ sha256, ...r }) => r) };
@@ -645,7 +746,7 @@ async function runCheck(argv, run) {
645
746
  judged.judged_omitted = rows.omitted;
646
747
  if (j.notJudged.length)
647
748
  judged.not_judged = j.notJudged;
648
- judged.note = 'only PNG and JPEG captures are judged; text, log and video evidence are not';
749
+ judged.note = 'PNG and JPEG screens and text captures (logs, JSON, CLI output) are judged; video and other files are not';
649
750
  }
650
751
  }
651
752
  const rep = report(findings, files, { cfg, repo, claim: claim?.outcome, source: values.from, judge: judged, judgeLevel });
@@ -690,10 +791,7 @@ function verifyReceipt(repo, cfg, headArg) {
690
791
  }
691
792
  const short = commit.slice(0, 12);
692
793
  const path = join(receiptsDir(cfg), `${commit}.json`);
693
- const fail = (reason) => {
694
- emit({ command: 'receipt', result: 'fail', head: commit, reason, help: ['Record a passing check for this head: done-done receipt --from <file|->'] });
695
- return FAIL;
696
- };
794
+ const fail = (reason) => ({ code: FAIL, result: { command: 'receipt', result: 'fail', head: commit, reason, help: ['Record a passing check for this head: done-done receipt --from <file|->'] } });
697
795
  if (!existsSync(path))
698
796
  return fail(`no receipt for ${short} in ${receiptsDir(cfg)}`);
699
797
  let rec;
@@ -720,8 +818,7 @@ function verifyReceipt(repo, cfg, headArg) {
720
818
  if (overridden) {
721
819
  if (rec.schema !== 2 || !o || typeof o !== 'object' || !['approve', 'skip'].includes(o.action) || typeof o.by !== 'string' || typeof o.reason !== 'string' || !o.reason)
722
820
  return fail(`receipt for ${short} has a malformed override`);
723
- emit({ command: 'receipt', result: 'overridden', head: commit, claim: rec.claim, override: o, judge: j, help: [`This head is not proven: the judge did not pass it, and ${o.by} chose ${o.action} (${o.reason})`, 'Capture proof the judge passes, then: done-done verify --outcome "<what a user can now do>"'] });
724
- return FAIL;
821
+ return { code: FAIL, result: { command: 'receipt', result: 'overridden', head: commit, claim: rec.claim, override: o, judge: j, help: [`This head is not proven: the judge did not pass it, and ${o.by} chose ${o.action} (${o.reason})`, 'Capture proof the judge passes, then: done-done verify --outcome "<what a user can now do>"'] } };
725
822
  }
726
823
  if (rec.head !== commit)
727
824
  return fail(`receipt records head ${String(rec.head).slice(0, 12)}, not ${short}`);
@@ -748,8 +845,7 @@ function verifyReceipt(repo, cfg, headArg) {
748
845
  }
749
846
  if (bad.length)
750
847
  return fail(`cited evidence no longer matches the receipt: ${bad.join('; ')}`);
751
- emit({ command: 'receipt', result: 'pass', head: commit, tree: rec.tree, claim: rec.claim, files: rec.files.length, at: rec.at, schema: rec.schema, ...(rec.schema === 2 && { judge: j }), help: ['This receipt is valid for this head.'] });
752
- return PASS;
848
+ return { code: PASS, result: { command: 'receipt', result: 'pass', head: commit, tree: rec.tree, claim: rec.claim, files: rec.files.length, at: rec.at, schema: rec.schema, ...(rec.schema === 2 && { judge: j }), help: ['This receipt is valid for this head.'] } };
753
849
  }
754
850
  async function cmdReceipt(argv) {
755
851
  const vi = argv.indexOf('--verify');
@@ -766,7 +862,7 @@ async function cmdReceipt(argv) {
766
862
  if (verifyHead) {
767
863
  if (rest.length)
768
864
  throw new UsageError('receipt --verify takes no other arguments');
769
- return verifyReceipt(repo, cfg, verifyHead);
865
+ return emitted(verifyReceipt(repo, cfg, verifyHead));
770
866
  }
771
867
  if (!rest.length)
772
868
  throw new UsageError('receipt needs a claim (--outcome "<claim>" <files...> or --from <file|->) or --verify <head>');
@@ -856,7 +952,7 @@ const HEARTBEAT_MS = 5_000;
856
952
  // A run that never recorded its runner's pid this long after it was created has no runner.
857
953
  const STALE_MS = 60_000;
858
954
  const runsRoot = (app) => join(home(), 'runs', app);
859
- const STEPS = ['resolve', 'drive', 'check', 'judge', 'gate', 'receipt', 'report'];
955
+ const verifySteps = (cfg) => ['resolve', 'drive', 'check', 'judge', ...(cfg.auto_fix.verify ? ['triage', 'fix'] : []), 'gate', 'receipt', 'report'];
860
956
  // Commands an agent drive must not run: they answer a gate, write a receipt or start a run that can write one.
861
957
  const DRIVE_REFUSED = ['respond', 'receipt', 'verify', 'rerun', 'maintain'];
862
958
  // One agent drive may launch the app, drive it and save captures; past this it is killed with everything it started.
@@ -885,9 +981,18 @@ const createOnce = (path) => {
885
981
  }
886
982
  };
887
983
  const evidenceOf = (repo, cfg, rec) => rec.files.map((f) => citable(repo, join(cfg.evidence.root, f.path)));
984
+ // Every run of an app, from any checkout of it. A run.json that cannot be read stops the command, never skipped silently.
985
+ const runsIn = (root) => (existsSync(root) ? readdirSync(root).filter((n) => existsSync(join(root, n, 'run.json'))).map((n) => {
986
+ try {
987
+ return loadRun(join(root, n));
988
+ }
989
+ catch (e) {
990
+ throw new UsageError(`cannot read the run record ${join(root, n, 'run.json')}: ${e.message}`, `Fix or remove ${join(root, n)}, then run this again`);
991
+ }
992
+ }) : []);
888
993
  // Default run: the newest run of this checkout, as status does.
889
994
  function pickId(root, repo, cmd, cfg, id) {
890
- const mine = existsSync(root) ? readdirSync(root).filter((n) => existsSync(join(root, n, 'run.json'))).map((n) => loadRun(join(root, n))).filter((r) => r.repo === repo) : [];
995
+ const mine = runsIn(root).filter((r) => r.repo === repo);
891
996
  const found = id ?? mine.sort((a, b) => a.created.localeCompare(b.created)).pop()?.run_id;
892
997
  if (!found)
893
998
  throw new UsageError(`no runs for ${cfg.app} in this checkout yet`, `Start one: done-done verify ${Object.keys(cfg.journeys).length > 1 ? '<journey> ' : ''}--outcome "<what a user can now do>"`);
@@ -910,6 +1015,106 @@ function skillHash(repo, cfg) {
910
1015
  return '';
911
1016
  }
912
1017
  }
1018
+ // Every file the guard can see: tracked files plus untracked ones, git-ignored ones included.
1019
+ function guardCandidates(at) {
1020
+ let listed;
1021
+ try {
1022
+ listed = execFileSync('git', ['ls-files', '-z', '--cached', '--others'], { cwd: at, encoding: 'utf8', maxBuffer: Infinity, stdio: ['ignore', 'pipe', 'pipe'] });
1023
+ }
1024
+ catch (e) {
1025
+ throw new Error(`input guard: cannot list the repo files: ${e.message.split('\n')[0]}`);
1026
+ }
1027
+ return [...new Set(listed.split('\0').filter(Boolean))].sort();
1028
+ }
1029
+ // Node's matchesGlob skips dotfiles, so a watched glob matches them itself: '**' spans folders, '*' and '?' stay in one.
1030
+ const GLOB_TOKEN = /(\*\*\/|\*\*|\*|\?|\{[^}]*\}|\[[^\]]*\])/;
1031
+ const globEscape = (s) => s.replace(/[.+^$()|\\]/g, '\\$&');
1032
+ function guardGlob(p) {
1033
+ const part = (t, i) => {
1034
+ if (i % 2 === 0)
1035
+ return globEscape(t);
1036
+ if (t === '**/')
1037
+ return '(?:.*/)?';
1038
+ if (t === '**')
1039
+ return '.*';
1040
+ if (t === '*')
1041
+ return '[^/]*';
1042
+ if (t === '?')
1043
+ return '[^/]';
1044
+ return t[0] === '{' ? `(?:${t.slice(1, -1).split(',').map(globEscape).join('|')})` : t;
1045
+ };
1046
+ return new RegExp(`^${p.split(GLOB_TOKEN).map(part).join('')}$`);
1047
+ }
1048
+ function guardSnapshot(at, patterns) {
1049
+ if (!patterns.length)
1050
+ return [];
1051
+ let candidates;
1052
+ const listed = () => (candidates ??= guardCandidates(at));
1053
+ let links;
1054
+ const folderLinks = () => (links ??= listed().filter((c) => lstatSync(join(at, c), { throwIfNoEntry: false })?.isSymbolicLink() && statSync(join(at, c), { throwIfNoEntry: false })?.isDirectory()));
1055
+ const paths = new Set();
1056
+ for (const raw of patterns) {
1057
+ const p = inputPattern(raw);
1058
+ const glob = /[*?{\[]/.test(p);
1059
+ const folder = !glob && existsSync(join(at, p)) && statSync(join(at, p)).isDirectory();
1060
+ if (glob || folder) {
1061
+ const base = glob ? p.slice(0, p.search(/[*?{\[]/)).replace(/\/[^/]*$/, '') : p;
1062
+ const link = folderLinks().find((s) => base === '' || s === base || s.startsWith(`${base}/`) || base.startsWith(`${s}/`));
1063
+ if (link)
1064
+ throw new Error(`input guard: ${p} reaches ${link}, a symlink to a folder`);
1065
+ }
1066
+ if (glob) {
1067
+ const re = guardGlob(p);
1068
+ for (const c of listed())
1069
+ if (c === p || c.startsWith(`${p}/`) || re.test(c))
1070
+ paths.add(c);
1071
+ }
1072
+ else if (folder) {
1073
+ for (const c of listed())
1074
+ if (c.startsWith(`${p}/`))
1075
+ paths.add(c);
1076
+ }
1077
+ else
1078
+ paths.add(p);
1079
+ }
1080
+ const root = realpathSync(at);
1081
+ return [...paths].sort().map((path) => {
1082
+ const full = join(at, path);
1083
+ const st = statSync(full, { throwIfNoEntry: false });
1084
+ if (!st)
1085
+ return { path, sha256: null };
1086
+ if (!st.isFile())
1087
+ throw new Error(`input guard: ${path} is not a plain file`);
1088
+ if (!realpathSync(full).startsWith(`${root}${sep}`))
1089
+ throw new Error(`input guard: ${path} links outside the repo`);
1090
+ try {
1091
+ return { path, sha256: sha256(readFileSync(full)) };
1092
+ }
1093
+ catch (e) {
1094
+ throw new Error(`input guard: cannot read ${path}: ${e.message.split('\n')[0]}`);
1095
+ }
1096
+ });
1097
+ }
1098
+ // Each difference names only its path, never file contents.
1099
+ function guardDiff(before, after) {
1100
+ const a = new Map(after.map((f) => [f.path, f.sha256]));
1101
+ const b = new Map(before.map((f) => [f.path, f.sha256]));
1102
+ const out = [];
1103
+ const push = (path, verb) => out.push({ path, why: `${path}: ${verb} during the drive` });
1104
+ for (const [path, h] of b) {
1105
+ const g = a.get(path);
1106
+ if (h === null && g !== null && g !== undefined)
1107
+ push(path, 'appeared');
1108
+ else if (h !== null && g !== null && g !== undefined && g !== h)
1109
+ push(path, 'changed');
1110
+ else if (h !== null && (g === null || g === undefined))
1111
+ push(path, 'disappeared');
1112
+ }
1113
+ for (const [path, g] of a)
1114
+ if (g !== null && !b.has(path))
1115
+ push(path, 'appeared');
1116
+ return out.sort((x, y) => (x.why < y.why ? -1 : 1));
1117
+ }
913
1118
  // The drive's input is the journey command, or for an agent drive the author agent and model that drove it.
914
1119
  function inputsOf(repo, cfg, journey, author) {
915
1120
  const h = head(repo);
@@ -939,33 +1144,68 @@ async function agentDrive(repo, cfg, feature) {
939
1144
  // The author is the agent the user set; with none, ask (in a terminal) or stop with the command that saves the answer.
940
1145
  await withChoice('agent', cfg);
941
1146
  const a = selectAgent(cfg);
942
- if (!a.path || !supported(a.agent))
943
- throw new UsageError(`verify needs an agent to drive the app: ${a.path ? `${a.agent} cannot be started by done-done yet` : notStartable(a.agent, a.override)}`, `Install ${a.agent}, or save the agent the user chooses: done-done init --agent <agent> --model <model>; or record a journey: done-done init --journey <name> -- <command>`);
1147
+ if (!a.path)
1148
+ throw new UsageError(`verify needs an agent to drive the app: ${notStartable(a.agent, a.override)}`, `Install ${a.agent}, or save the agent the user chooses: done-done init --agent <agent> --model <model>; or record a journey: done-done init --journey <name> -- <command>`);
944
1149
  if (a.agent.startsWith('acp:'))
945
- throw new UsageError(`${a.agent} cannot drive the app: done-done runs ACP agents with every tool denied`, `Set agent: to one of ${AGENTS.filter(supported).join(', ')}, or record a journey: done-done init --journey <name> -- <command>`);
1150
+ throw new UsageError(`${a.agent} cannot drive the app: done-done runs ACP agents with every tool denied`, `Set agent: to one of ${AGENTS.join(', ')}, or record a journey: done-done init --journey <name> -- <command>`);
946
1151
  return { author: { agent: a.agent, model: a.model }, feature: name ?? (features.length === 1 ? features[0] : undefined) };
947
1152
  }
948
1153
  // The drive prompt: the claim, where the verify skill and its feature recipe are, and where captures go. The judge never sees it.
949
1154
  // With a verify skill and no journey, the next step can be an agent drive.
950
1155
  const driveHint = (repo, cfg) => existsSync(join(repo, cfg.skill, 'SKILL.md'))
951
1156
  ? ['Have your coding agent drive a verify skill feature: done-done verify --outcome "<what a user can now do>" [--feature <name>]'] : [];
952
- function drivePrompt(run, cfg, evidence, scratch) {
953
- const skill = join(run.repo, cfg.skill);
1157
+ // The page-check record shape and how to collect it with chrome-devtools-axi, for the drive prompt and the verify skill scaffold.
1158
+ const DOM_RECORD = '{"url": "<page url>", "overflow": <true when content is wider than the viewport>, "overflowing": ["<element>", ...], "console_errors": ["<message>", ...], "failed_requests": [{"url": "<url>", "status": <HTTP status, 0 when none>}], "squashed_text": [{"element": "<element>", "text": "<text>", "chars": <width in characters>, "lines": <n>, "words": <n>}]}'
1159
+ + `; with your browser tool (for example chrome-devtools-axi): overflow from eval "() => { const w = document.documentElement.clientWidth; return { overflow: document.documentElement.scrollWidth > w, overflowing: [...document.querySelectorAll('body *')].filter((e) => e.getBoundingClientRect().right > w + 1).slice(0, 10).map((e) => e.tagName.toLowerCase() + (e.id ? '#' + e.id : '')) } }", console errors from console --type error, failed requests (status 400 or more, or no response) from network, squashed_text from eval "${SQUASHED_TEXT}" exactly as written (in every frame, an embedded one too)`;
1160
+ function drivePrompt(run, at, cfg, evidence, scratch, base = '') {
1161
+ const skill = join(at, cfg.skill);
954
1162
  const recipe = run.feature ? `the feature recipe ${join(skill, 'features', `${run.feature}.md`)}` : `its feature map ${join(skill, 'features', 'README.md')}, then pick the one feature the outcome is about`;
1163
+ const inst = cfg.instance ? `\n\nThe app is already running at $DONE_DONE_BASE_URL (started by done-done on its own port with its own HOME; DONE_DONE_PORT is its port). Drive that instance: do not start your own server or hard-code a port.` : '';
1164
+ const matrix = hasMatrix(cfg.matrix) ? splitMatrix(cfg.matrix) : null;
1165
+ const cells = !matrix ? '' : `\n\nThe repo asks for every theme x width x form cell below: drive each cell (theme and width are the recipe's job) and save one PNG per cell whose file name holds its suffix, e.g. ${evidence}/home.<suffix>.png:\n${matrix.required.map((c) => `- ${cellName(c)}: *${cellSuffix(c)}*`).join('\n')}${matrix.skipped.length ? `\nSkipped (capture nothing for these):\n${matrix.skipped.map((s) => `- ${cellName(s.cell)} (${s.reason})`).join('\n')}` : ''}`;
1166
+ const dom = cfg.dom ? `\n\nThe repo checks each page: next to every capture write its page-check record, the capture's name with .dom.json for its extension (e.g. home.dom.json for home.png), as the verify skill's Evidence section says: ${DOM_RECORD}. Record what the page shows; never fix the page.` : '';
1167
+ const before = base ? `\n\nYour working directory is the base ref ${base}, before the change, so the outcome is not expected to hold here and that is no blocker: drive the same path and save the same screens under the same file names, showing them as they are before the change.` : '';
955
1168
  return `You drive the real ${run.app} app the way a user does, to capture proof of one outcome. Someone else judges your captures.
956
1169
 
957
1170
  Outcome to prove: ${run.claim}
958
-
1171
+ ${inst}${cells}${dom}${before}
959
1172
  1. Read the verify skill ${join(skill, 'SKILL.md')} (how to launch, check, drive, capture and clean up this app) and ${recipe}.
960
- 2. Launch the app as the skill says, then drive that feature through the real user path, step by step as its recipe says.
1173
+ 2. ${cfg.instance ? 'Drive that running instance' : 'Launch the app as the skill says, then drive'} that feature through the real user path, step by step as its recipe says.
961
1174
  Never add steps or data to make the outcome look true: capture what that path shows, even when it contradicts the outcome.
962
- 3. Save PNG screenshots of what a user sees, the action and the resulting state, into ${evidence} (also in $DONE_DONE_EVIDENCE_ROOT). Save nothing else there, and never edit a capture.
1175
+ 3. Save PNG screenshots of what a user sees once the path is done (the resulting state the outcome is about) into ${evidence} (also in $DONE_DONE_EVIDENCE_ROOT); when the outcome shows in text (a CLI's output, a log or JSON), save that text there as a .txt, .log or .json file instead.
1176
+ The judge checks every capture there against the outcome, so a capture of an earlier step (such as an empty or half-filled form, or text from before the outcome) fails the drive: keep those in ${scratch}. Save nothing else there, and never edit a capture.
963
1177
  4. Stop everything you started.
964
1178
 
965
1179
  Do not change any file in the repository. Do not run done-done: it refuses inside a drive, and done-done answers the run after you stop. Put logs and other scratch files in ${scratch} (also in $TMPDIR), never in /tmp.
966
1180
  Keep working until all four steps are done: your next message without a tool call ends the drive and must be the answer.
967
1181
  If you cannot launch the app or reach the feature, say why in blocker and save no capture.
968
- Answer with feature (the feature file you drove, such as features/<name>.md), captures (the file names you saved) and blocker ("" when you drove it).`;
1182
+ Answer with feature (the feature file you drove, such as features/<name>.md), captures (the file names you saved) and blocker ("" when you drove it),
1183
+ for example {"feature":"features/<name>.md","captures":["<name>.png"],"blocker":""}.`;
1184
+ }
1185
+ // base_ref: the same drive at the base ref, in a done-done worktree removed after, with the same run id, instance and matrix env;
1186
+ // its captures go to the run folder's before/. A failed base drive is recorded, not the run's failure: the check reports each
1187
+ // head capture it left without a pair.
1188
+ const BEFORE = 'before';
1189
+ async function driveBase(run, cfg, dir, rec, fix, drive) {
1190
+ const base = (rec.base = { ref: cfg.base_ref, commit: '' });
1191
+ const evidence = join(runDir(cfg, rec.run_id), BEFORE);
1192
+ const wt = join(dir, `${fix}base`);
1193
+ try {
1194
+ base.commit = git(run.repo, 'rev-parse', '--verify', '--end-of-options', `${cfg.base_ref}^{commit}`);
1195
+ mkdirSync(evidence, { recursive: true });
1196
+ const error = await inWorktree(run.repo, wt, async () => {
1197
+ if (!cfg.instance)
1198
+ return drive(wt, evidence, {});
1199
+ const inst = await startInstance(wt, cfg.instance, rec.run_id, join(dir, `${fix}base-tmp`), process.env, join(dir, 'logs', `${fix}base-instance.log`));
1200
+ run.runner.instance_pgid = inst.pgid;
1201
+ return drive(wt, evidence, inst.env).finally(() => stopInstance(inst.pgid));
1202
+ }, base.commit);
1203
+ if (error)
1204
+ Object.assign(base, { error });
1205
+ }
1206
+ catch (e) {
1207
+ Object.assign(base, { error: base.commit ? errorText(e) : `base_ref ${cfg.base_ref} names no commit in this repo` });
1208
+ }
969
1209
  }
970
1210
  function saveRun(dir, run) {
971
1211
  run.updated = now();
@@ -1009,9 +1249,17 @@ async function runSteps(dir) {
1009
1249
  saveRun(dir, run);
1010
1250
  }, HEARTBEAT_MS);
1011
1251
  let step;
1012
- const set = (s, status, extra = {}) => setStep(dir, run, s, status, extra);
1013
- const begin = (name) => (set((step = run.steps.find((s) => s.name === name)), 'running'), step);
1014
- const log = (name, result) => writeFileSync(join(dir, 'logs', `${name}.log`), `${encode(result)}\n`);
1252
+ // A fix's re-run (auto_fix.verify) is quiet: it leaves the run's steps as they are, and its step logs go to `quiet`.
1253
+ let quiet = null;
1254
+ const set = (s, status, extra = {}) => quiet ?? setStep(dir, run, s, status, extra);
1255
+ // A step that starts skips the earlier steps still pending (the judge when it is off, a fix that was not needed).
1256
+ const begin = (name) => {
1257
+ for (const s of quiet ? [] : run.steps.slice(0, run.steps.findIndex((s) => s.name === name)))
1258
+ if (s.status === 'pending')
1259
+ s.status = 'skipped';
1260
+ return (set((step = run.steps.find((s) => s.name === name)), 'running'), step);
1261
+ };
1262
+ const log = (name, result) => quiet ? void (quiet[name] = result) : writeFileSync(join(dir, 'logs', `${name}.log`), `${encode(result)}\n`);
1015
1263
  const finish = (result, reason) => {
1016
1264
  step = run.steps.find((s) => s.name === 'report');
1017
1265
  finishRun(dir, run, result, reason);
@@ -1040,116 +1288,296 @@ async function runSteps(dir) {
1040
1288
  throw new Error(`journeys.${run.journey}.timeout must be a duration such as 30m or 90s, not ${timeout}`);
1041
1289
  if (!reused)
1042
1290
  set(step, 'completed');
1043
- let rec;
1044
- if (done('drive') && run.record) {
1045
- begin('drive');
1046
- rec = JSON.parse(readFileSync(run.record, 'utf8'));
1047
- set(step, 'completed');
1048
- }
1049
- else if (run.journey === null) {
1050
- // The author agent drives the feature in its own process group, so a timeout, an abort or a dead runner stops it
1051
- // and the app it launched; a drive that changed tracked files would prove code that is not committed.
1052
- begin('drive');
1053
- const author = run.author;
1054
- rec = { ...openRun(run.repo, cfg, null, null, run.run_id), author };
1055
- const evidence = runDir(cfg, rec.run_id);
1056
- const tracked = () => sha256(execFileSync('git', ['diff', 'HEAD', '--binary'], { cwd: run.repo, maxBuffer: Infinity, stdio: ['ignore', 'pipe', 'pipe'] }));
1057
- const before = tracked();
1058
- const scratch = join(dir, 'tmp');
1059
- mkdirSync(scratch, { recursive: true });
1060
- const a = await askAgent({
1061
- ...agentRun(cfg, author.agent, author.model), prompt: drivePrompt(run, cfg, evidence, scratch), schema: DRIVE_SCHEMA, cwd: run.repo, tools: 'edit',
1062
- record: join(dir, 'agents', 'drive.json'), dirs: [evidence, scratch], env: { DONE_DONE_RUN_ID: rec.run_id, DONE_DONE_DRIVE: rec.run_id, DONE_DONE_EVIDENCE_ROOT: evidence, TMPDIR: scratch },
1063
- onSpawn: spawned,
1064
- });
1065
- const answer = a.answer;
1066
- const blocker = String(answer?.blocker ?? '').trim();
1067
- rec.exit = a.ok && !blocker ? 0 : 1;
1068
- const changed = tracked() !== before;
1069
- const sealed = closeRun(run.repo, cfg, rec);
1070
- run.record = join(recordsDir(cfg), `${rec.run_id}.json`);
1071
- const picked = typeof answer?.feature === 'string' ? answer.feature.replace(/^.*features\//, '').replace(/\.md$/, '') : '';
1072
- if (skillFeatures(run.repo, cfg).includes(picked))
1073
- run.feature ??= picked;
1074
- log('drive', { agent: author.agent, model: author.model, feature: run.feature ?? 'none', ...(a.ok ? { captures: answer?.captures, blocker } : { error: a.error }), record: a.record ?? a.recordError });
1075
- const why = !a.ok ? `${author.agent} did not finish the drive: ${a.error}`
1076
- : blocker ? `${author.agent} could not drive the feature: ${blocker}`
1077
- : changed ? `${author.agent} changed tracked files in the repo during the drive (see git status), so its captures are not of the committed code`
1078
- : sealed.code !== PASS ? sealed.reason : '';
1079
- set(step, why ? 'failed' : 'completed', { exit: rec.exit, outputs: evidenceOf(run.repo, cfg, rec) });
1080
- if (why)
1081
- return finish('fail', `drive: ${why}`);
1291
+ // A journey that spends real money or credits never runs until the user approved spending for this run.
1292
+ if (journey?.spends && !run.spend && !done('drive')) {
1293
+ const cells = hasMatrix(cfg.matrix) ? splitMatrix(cfg.matrix).required.length : 0;
1294
+ const what = `journey ${run.journey} spends (${journey.spends}), so it runs only once the user approves spending for this run` + (cells ? `; the approval covers every matrix cell, so it runs ${cells} times, once per cell` : '');
1295
+ run.gate = { findings: [{ id: 'spend-1', severity: 'warning', what, action: 'ask-user' }], cited: [], head: head(run.repo), spend: journey.spends };
1296
+ run.status = 'awaiting_approval';
1297
+ set((step = run.steps.find((s) => s.name === 'drive')), 'awaiting_approval', { findings: run.gate.findings });
1298
+ return;
1082
1299
  }
1083
- else {
1300
+ // One drive, check and judge of the claim at `at`: the user's checkout, or (`fix` names the re-run) a fix worktree.
1301
+ // With auto_fix.verify, a product failure (a drive the product stopped, a screen the judge calls wrong) may be fixed.
1302
+ const tries = cfg.auto_fix.verify ?? 0;
1303
+ const pass = async (at, fix = '') => {
1304
+ const id = fix ? `${run.run_id}-${fix}` : run.run_id;
1305
+ const fail = (reason, fixable = [], result = 'fail') => ({ end: { result, reason: `drive: ${reason}` }, asks: [], fixable });
1306
+ const reusing = !fix && done('drive') && !!run.record;
1084
1307
  begin('drive');
1085
- rec = openRun(run.repo, cfg, run.journey, command.replaceAll('${RUN_ID}', run.run_id), run.run_id);
1086
- const fd = openSync(join(dir, 'logs', 'drive.log'), 'a');
1087
- // An async child keeps the heartbeat going while the journey runs. The journey gets its own process group, so a
1088
- // timeout or a dead runner can kill it and everything it started.
1089
- const child = spawn(rec.command, { cwd: run.repo, shell: true, detached: true, stdio: ['ignore', fd, fd], env: { ...process.env, DONE_DONE_RUN_ID: rec.run_id, DONE_DONE_EVIDENCE_ROOT: runDir(cfg, rec.run_id) } });
1090
- run.runner.journey_pgid = child.pid;
1091
- saveRun(dir, run);
1092
- let timedOut = false;
1093
- const timer = setTimeout(() => {
1094
- timedOut = true;
1095
- killGroup(child.pid);
1096
- }, limit);
1097
- rec.exit = await new Promise((done) => child.on('close', (code) => done(code ?? 1)));
1098
- clearTimeout(timer);
1099
- closeSync(fd);
1100
- const sealed = closeRun(run.repo, cfg, rec);
1101
- run.record = join(recordsDir(cfg), `${rec.run_id}.json`);
1102
- set(step, sealed.code === PASS ? 'completed' : 'failed', { exit: rec.exit, outputs: evidenceOf(run.repo, cfg, rec) });
1103
- if (timedOut)
1104
- return finish('inconclusive', `drive: journey timed out after ${timeout}`);
1105
- if (sealed.code !== PASS)
1106
- return finish('fail', `drive: ${sealed.reason}`);
1107
- }
1108
- begin('check');
1109
- const files = rec.files.map((f) => join(cfg.evidence.root, f.path));
1110
- // The check's own findings end the check step; the judge, when on and the check did not fail, is its own step.
1111
- let checkFindings = 0;
1112
- const judging = (code, findings) => {
1113
- checkFindings = findings.length;
1114
- set(step, code === PASS ? 'completed' : 'failed', { exit: code, findings });
1115
- begin('judge');
1308
+ const guardBefore = reusing ? [] : guardSnapshot(at, cfg.inputs);
1309
+ const scanRun = (files) => scanSecrets([...files, join(dir, 'logs'), join(dir, 'agents')], cfg.secrets);
1310
+ const secretWhy = (l) => `${shown(run.repo, l.file)}:${l.line}: holds a secret (${l.pattern}); remove it, or allow a known-safe match with secrets.allow in .done-done.yaml`;
1311
+ const evidenceFiles = () => rec.files.map((f) => join(cfg.evidence.root, f.path));
1312
+ // Every failed drive is scanned too: a leak replaces the reason, so unscanned agent or instance text never echoes a secret, and a leaking log excerpt never reaches the fix agent.
1313
+ const failScanned = (reason, files, fixable = [], result = 'fail') => {
1314
+ const leak = scanRun(files)[0];
1315
+ return leak ? fail(`secrets: ${secretWhy(leak)}`) : fail(reason, fixable, result);
1316
+ };
1317
+ let rec;
1318
+ if (reusing) {
1319
+ rec = JSON.parse(readFileSync(run.record, 'utf8'));
1320
+ set(step, 'completed');
1321
+ }
1322
+ else if (run.journey === null) {
1323
+ // The author agent drives the feature in its own process group, so a timeout, an abort or a dead runner stops it
1324
+ // and the app it launched; a drive that changed tracked files would prove code that is not committed.
1325
+ const author = run.author;
1326
+ rec = { ...openRun(at, cfg, null, null, id), author };
1327
+ const evidence = runDir(cfg, rec.run_id);
1328
+ const tracked = () => sha256(execFileSync('git', ['diff', 'HEAD', '--binary'], { cwd: at, maxBuffer: Infinity, stdio: ['ignore', 'pipe', 'pipe'] }));
1329
+ const before = tracked();
1330
+ const scratch = join(dir, 'tmp');
1331
+ mkdirSync(scratch, { recursive: true });
1332
+ // With an instance block the app is already running at $DONE_DONE_BASE_URL; the drive uses it, never a fixed port.
1333
+ // The agent keeps its own HOME and XDG folders (its login lives there); only the app runs isolated.
1334
+ let instPort = {};
1335
+ if (cfg.instance) {
1336
+ try {
1337
+ const inst = await startInstance(at, cfg.instance, rec.run_id, scratch, process.env, join(dir, 'logs', `${fix}instance.log`));
1338
+ run.runner.instance_pgid = inst.pgid;
1339
+ saveRun(dir, run);
1340
+ instPort = inst.env;
1341
+ }
1342
+ catch (e) {
1343
+ set(step, 'failed');
1344
+ return failScanned(`instance: ${e.message}`, []);
1345
+ }
1346
+ }
1347
+ const drive = (where, evidence, env, base = '') => askAgent({
1348
+ ...agentRun(cfg, author.agent, author.model), prompt: drivePrompt(run, where, cfg, evidence, scratch, base), schema: DRIVE_SCHEMA, cwd: where, tools: 'edit',
1349
+ record: join(dir, 'agents', `${fix}${base ? 'base-' : ''}drive.json`), dirs: [evidence, scratch], env: { DONE_DONE_RUN_ID: rec.run_id, DONE_DONE_DRIVE: rec.run_id, DONE_DONE_EVIDENCE_ROOT: evidence, TMPDIR: scratch, ...env },
1350
+ onSpawn: spawned,
1351
+ });
1352
+ const a = await drive(at, evidence, instPort);
1353
+ if (run.runner?.instance_pgid)
1354
+ stopInstance(run.runner.instance_pgid);
1355
+ const answer = a.answer;
1356
+ const blocker = String(answer?.blocker ?? '').trim();
1357
+ rec.exit = a.ok && !blocker ? 0 : 1;
1358
+ const changed = tracked() !== before;
1359
+ if (cfg.base_ref && !rec.exit && !changed)
1360
+ await driveBase(run, cfg, dir, rec, fix, async (where, evidence, env) => {
1361
+ // The same drive: the head's verify skill, copied into the base worktree, where the agent may read it.
1362
+ cpSync(join(at, cfg.skill), join(where, cfg.skill), { recursive: true, force: true });
1363
+ const b = await drive(where, evidence, env, cfg.base_ref);
1364
+ return b.ok ? String(b.answer?.blocker ?? '').trim() : `${author.agent} did not finish the drive: ${b.error}`;
1365
+ });
1366
+ const sealed = closeRun(at, cfg, rec);
1367
+ if (!fix)
1368
+ run.record = join(recordsDir(cfg), `${rec.run_id}.json`);
1369
+ const picked = typeof answer?.feature === 'string' ? answer.feature.replace(/^.*features\//, '').replace(/\.md$/, '') : '';
1370
+ if (skillFeatures(at, cfg).includes(picked))
1371
+ run.feature ??= picked;
1372
+ log('drive', { agent: author.agent, model: author.model, feature: run.feature ?? 'none', ...(a.ok ? { captures: answer?.captures, blocker } : { error: a.error }), record: a.record ?? a.recordError });
1373
+ const why = !a.ok ? `${author.agent} did not finish the drive: ${a.error}`
1374
+ : blocker ? `${author.agent} could not drive the feature: ${blocker}`
1375
+ : changed ? `${author.agent} changed tracked files in the repo during the drive (see git status), so its captures are not of the committed code`
1376
+ : sealed.code !== PASS ? sealed.reason : '';
1377
+ set(step, why ? 'failed' : 'completed', { exit: rec.exit, outputs: evidenceOf(run.repo, cfg, rec) });
1378
+ // Only a blocker says the product stopped the drive; a lost or bad answer is the agent's own failure.
1379
+ if (why)
1380
+ return failScanned(why, evidenceFiles(), a.ok && blocker && !changed ? [{ kind: 'drive', file: `features/${run.feature || picked || 'README'}.md`, what: why }] : []);
1381
+ }
1382
+ else {
1383
+ rec = openRun(at, cfg, run.journey, command.replaceAll('${RUN_ID}', id), id);
1384
+ const logFile = join(dir, 'logs', `${fix}drive.log`);
1385
+ const fd = openSync(logFile, 'a');
1386
+ // With an instance block the app starts first on its own port; the journey talks to $DONE_DONE_BASE_URL.
1387
+ let instEnv = {};
1388
+ if (cfg.instance) {
1389
+ try {
1390
+ const scratch = join(dir, 'tmp');
1391
+ mkdirSync(scratch, { recursive: true });
1392
+ const inst = await startInstance(at, cfg.instance, rec.run_id, scratch, process.env, join(dir, 'logs', `${fix}instance.log`));
1393
+ run.runner.instance_pgid = inst.pgid;
1394
+ instEnv = inst.env;
1395
+ }
1396
+ catch (e) {
1397
+ closeSync(fd);
1398
+ set(step, 'failed');
1399
+ return failScanned(`instance: ${e.message}`, []);
1400
+ }
1401
+ }
1402
+ // With a matrix block the journey runs once per required cell with that cell in env.
1403
+ const matrix = hasMatrix(cfg.matrix) ? splitMatrix(cfg.matrix).required : null;
1404
+ const failed = [];
1405
+ rec.exit = 0;
1406
+ let timedOut = false;
1407
+ let running;
1408
+ // The timeout bounds the whole verify run's journey: it kills the cell running now and stops the loop.
1409
+ const timer = setTimeout(() => {
1410
+ timedOut = true;
1411
+ if (running)
1412
+ killGroup(running);
1413
+ }, limit);
1414
+ for (const cell of matrix ?? [{}]) {
1415
+ // An async child keeps the heartbeat going while the journey runs. The journey gets its own process group, so a
1416
+ // timeout or a dead runner can kill it and everything it started.
1417
+ const child = spawn(rec.command, { cwd: at, shell: true, detached: true, stdio: ['ignore', fd, fd], env: { ...process.env, DONE_DONE_RUN_ID: rec.run_id, DONE_DONE_EVIDENCE_ROOT: runDir(cfg, rec.run_id), ...instEnv, ...(matrix ? cellEnv(cell) : {}) } });
1418
+ run.runner.journey_pgid = child.pid;
1419
+ running = child.pid;
1420
+ saveRun(dir, run);
1421
+ const code = await new Promise((done) => child.on('close', (c) => done(c ?? 1)));
1422
+ running = undefined;
1423
+ if (timedOut) {
1424
+ failed.push(`${cellName(cell)} (timed out after ${timeout})`);
1425
+ if (!rec.exit)
1426
+ rec.exit = 1;
1427
+ break;
1428
+ }
1429
+ if (code !== 0) {
1430
+ if (!rec.exit)
1431
+ rec.exit = code;
1432
+ failed.push(`${cellName(cell)} (exit ${code})`);
1433
+ }
1434
+ }
1435
+ clearTimeout(timer);
1436
+ if (run.runner?.instance_pgid)
1437
+ stopInstance(run.runner.instance_pgid);
1438
+ if (cfg.base_ref && !failed.length)
1439
+ await driveBase(run, cfg, dir, rec, fix, async (where, evidence, env) => {
1440
+ for (const cell of matrix ?? [{}]) {
1441
+ const child = spawn(rec.command, { cwd: where, shell: true, detached: true, stdio: ['ignore', fd, fd], env: { ...process.env, DONE_DONE_RUN_ID: rec.run_id, DONE_DONE_EVIDENCE_ROOT: evidence, ...env, ...(matrix ? cellEnv(cell) : {}) } });
1442
+ spawned(child.pid);
1443
+ const timer = setTimeout(() => killGroup(child.pid), limit);
1444
+ const code = await new Promise((done) => child.on('close', (c) => done(c ?? 1)));
1445
+ clearTimeout(timer);
1446
+ if (code)
1447
+ return `the journey exited ${code} for cell ${cellName(cell)}`;
1448
+ }
1449
+ return '';
1450
+ });
1451
+ closeSync(fd);
1452
+ const sealed = closeRun(at, cfg, rec);
1453
+ if (!fix)
1454
+ run.record = join(recordsDir(cfg), `${rec.run_id}.json`);
1455
+ set(step, failed.length || sealed.code !== PASS ? 'failed' : 'completed', { exit: rec.exit, outputs: evidenceOf(run.repo, cfg, rec) });
1456
+ if (timedOut)
1457
+ return failScanned(`journey timed out after ${timeout}`, evidenceFiles(), [], 'inconclusive');
1458
+ if (matrix && failed.length)
1459
+ return failScanned(`the journey command failed for cell ${failed.join('; ')}`, evidenceFiles());
1460
+ if (sealed.code !== PASS)
1461
+ return failScanned(sealed.reason, evidenceFiles(), tries && /^the run command exited/.test(sealed.reason) ? [{ kind: 'drive', file: run.journey, what: `the journey ${sealed.reason}; the end of its output:\n${readFileSync(logFile, 'utf8').slice(-2000)}` }] : []);
1462
+ }
1463
+ if (!reusing && cfg.inputs.length)
1464
+ run.guarded = { patterns: cfg.inputs, before: guardBefore, after: guardSnapshot(at, cfg.inputs) };
1465
+ begin('check');
1466
+ const files = evidenceFiles();
1467
+ // No secret may sit in the evidence or the run's logs: a hit fails the run naming the file, line and pattern, never the value.
1468
+ const leaks = scanRun(files).map((l) => ({ level: 'fail', why: secretWhy(l) }));
1469
+ if (leaks.length) {
1470
+ log('check', { result: 'fail', findings: leaks });
1471
+ set(step, 'failed', { exit: FAIL, findings: leaks });
1472
+ return { end: { result: 'fail', reason: `secrets: ${leaks[0].why}` }, asks: [], fixable: [] };
1473
+ }
1474
+ // The input guard: the drive must leave the watched inputs alone. A difference fails the check step,
1475
+ // naming each path but never file contents; the before/after hash lists stay in the run record.
1476
+ if (run.guarded) {
1477
+ const diffs = guardDiff(run.guarded.before, run.guarded.after);
1478
+ if (diffs.length) {
1479
+ const findings = diffs.map((d) => ({ level: 'fail', why: d.why }));
1480
+ set(step, 'failed', { exit: 1, findings });
1481
+ log('check', { result: 'fail', findings });
1482
+ return { end: { result: 'fail', reason: `check: input guard: ${diffs.map((d) => d.why).join('; ')}` }, asks: [], fixable: [] };
1483
+ }
1484
+ }
1485
+ // The check's own findings end the check step; the judge, when on and the check did not fail, is its own step.
1486
+ let checkFindings = 0;
1487
+ const judging = (code, findings) => {
1488
+ checkFindings = findings.length;
1489
+ set(step, code === PASS ? 'completed' : 'failed', { exit: code, findings });
1490
+ begin('judge');
1491
+ };
1492
+ // A fix worktree is detached, so it checks for the claim id of the user's branch.
1493
+ const claimId = run.claim_id ?? (fix ? git(run.repo, 'branch', '--show-current') : null);
1494
+ const checked = await runCheck(['--outcome', run.claim, ...files, ...(claimId ? ['--claim-id', claimId] : [])], { dir, judge: !!run.judge, judging, author: run.author, repo: at, cfg });
1495
+ const logged = { ...checked.result, findings: checked.findings };
1496
+ delete logged.findings_omitted;
1497
+ log('check', logged);
1498
+ const judgeRan = step.name === 'judge';
1499
+ const judgeFindings = judgeRan ? checked.findings.slice(checkFindings) : [];
1500
+ if (judgeRan) {
1501
+ log('judge', checked.judge);
1502
+ set(step, judgeFindings.length ? 'failed' : 'completed', { exit: judgeFindings.length ? checked.code : PASS, findings: judgeFindings });
1503
+ }
1504
+ else
1505
+ set(step, checked.code === PASS ? 'completed' : 'failed', { exit: checked.code, findings: checked.findings });
1506
+ // The gate: when the check itself is clean but the judge did not pass a capture (or judged none of an agent's), the run
1507
+ // waits for the caller (respond), who can move it on but never make it a pass. The runner ends here.
1508
+ const clean = judgeRan ? checkFindings === 0 : checked.code === PASS;
1509
+ const notJudged = judgeRan && run.journey === null ? (checked.judge?.not_judged ?? []) : [];
1510
+ const asks = !clean ? [] : judgeRan ? [...judgeFindings, ...notJudged.map((name) => ({ level: 'inconclusive', why: `${name}: the judge did not read this capture, and an agent drive passes only with a judge pass on every capture` }))]
1511
+ : run.journey !== null ? []
1512
+ : run.judge ? [{ level: 'inconclusive', why: 'the judge judged no capture (only PNG, JPEG and text captures are judged), and an agent drive passes only with a judge pass' }]
1513
+ : [{ level: 'inconclusive', why: 'the judge is off (--no-judge), and an agent drive passes only with a judge pass' }];
1514
+ // With auto_fix.verify, screens the judge calls wrong (and nothing it was unsure of) are a product failure to fix.
1515
+ if (tries && asks.length && asks.every((f) => f.level === 'fail'))
1516
+ return { checked, end: { result: 'fail', reason: `judge: ${asks[0].why}` }, asks: [], fixable: wrongScreens(asks) };
1517
+ if (asks.length)
1518
+ return { checked, asks, fixable: [] };
1519
+ if (checked.code !== PASS)
1520
+ return { checked, end: { result: checked.code === FAIL ? 'fail' : 'inconclusive', reason: `${judgeFindings.length ? 'judge' : 'check'}: ${(judgeFindings[0] ?? checked.findings[0])?.why ?? 'did not pass'}` }, asks: [], fixable: [] };
1521
+ return { checked, asks: [], fixable: [] };
1116
1522
  };
1117
- const checked = await runCheck(['--outcome', run.claim, ...files, ...(run.claim_id ? ['--claim-id', run.claim_id] : [])], { dir, judge: !!run.judge, judging, author: run.author });
1118
- const logged = { ...checked.result, findings: checked.findings };
1119
- delete logged.findings_omitted;
1120
- log('check', logged);
1121
- const judgeRan = step.name === 'judge';
1122
- const judgeFindings = judgeRan ? checked.findings.slice(checkFindings) : [];
1123
- if (judgeRan) {
1124
- log('judge', checked.judge);
1125
- set(step, judgeFindings.length ? 'failed' : 'completed', { exit: judgeFindings.length ? checked.code : PASS, findings: judgeFindings });
1523
+ const h = { begin, set, log, spawned };
1524
+ let p = await pass(run.repo);
1525
+ let fixed = false;
1526
+ // auto_fix.verify: the judge sorts the product failures first (an ask parks the run at the gate); then a checkout with
1527
+ // uncommitted tracked changes is never fixed, since the fix starts from the committed head.
1528
+ if (tries && p.end && p.fixable.length && journey?.spends)
1529
+ p.end = { ...p.end, reason: `${p.end.reason}; auto_fix.verify does not re-run a spends journey` };
1530
+ if (tries && p.end && p.fixable.length && !journey?.spends) {
1531
+ const sorted = await sortJudged(run, cfg, failures(p.fixable), join(dir, 'agents', 'triage.json'), h);
1532
+ if (typeof sorted === 'string')
1533
+ return finish('blocked', `triage: ${sorted}`);
1534
+ if (sorted.length)
1535
+ p = { checked: p.checked, asks: sorted, fixable: [] };
1536
+ else {
1537
+ const dirty = dirtyTracked(run.repo);
1538
+ if (dirty.length) {
1539
+ run.findings = failures(p.fixable);
1540
+ return finish('blocked', `dirty: uncommitted changes to tracked files (${named(dirty)}), so done-done does not fix this run (${p.end.reason}); commit them, or set auto_fix: { verify: 0 }, then run again`);
1541
+ }
1542
+ const f = await fixVerify(run, cfg, dir, p, tries, h, async (at, fix) => { const logs = (quiet = {}); const fixStep = step; try {
1543
+ return { ...(await pass(at, fix)), logs };
1544
+ }
1545
+ finally {
1546
+ quiet = null;
1547
+ step = fixStep;
1548
+ } });
1549
+ if ('blocked' in f)
1550
+ return finish('blocked', `fix: ${f.blocked}`);
1551
+ p = f;
1552
+ fixed = true;
1553
+ }
1126
1554
  }
1127
- else
1128
- set(step, checked.code === PASS ? 'completed' : 'failed', { exit: checked.code, findings: checked.findings });
1129
- // The gate: when the check itself is clean but the judge did not pass a capture (or judged none of an agent's), the run
1130
- // waits for the caller (respond), who can move it on but never make it a pass. The runner ends here.
1131
- const clean = judgeRan ? checkFindings === 0 : checked.code === PASS;
1132
- const notJudged = judgeRan && run.journey === null ? (checked.judge?.not_judged ?? []) : [];
1133
- const asks = !clean ? [] : judgeRan ? [...judgeFindings, ...notJudged.map((name) => ({ level: 'inconclusive', why: `${name}: the judge did not read this capture, and an agent drive passes only with a judge pass on every capture` }))]
1134
- : run.journey === null ? [{ level: 'inconclusive', why: 'the judge judged no capture (only PNG and JPEG captures are judged), and an agent drive passes only with a judge pass' }] : [];
1135
- if (asks.length) {
1555
+ if (p.asks.length) {
1556
+ // auto_fix.verify never fixes a mixed result (wrong screens and unsure ones): the judge sorts the wrong ones, then all park.
1557
+ const wrong = p.asks.filter((f) => f.level === 'fail');
1558
+ if (tries && wrong.length && wrong.length < p.asks.length) {
1559
+ const sorted = await sortJudged(run, cfg, failures(wrongScreens(wrong)), join(dir, 'agents', `triage${fixed ? '-gate' : ''}.json`), h, true);
1560
+ if (typeof sorted !== 'string')
1561
+ p.asks = [...sorted, ...p.asks.filter((f) => f.level !== 'fail')];
1562
+ }
1136
1563
  begin('gate');
1137
- const findings = asks.map((f, i) => ({ id: `gate-${i + 1}`, severity: f.level === 'fail' ? 'error' : 'warning', what: f.why, action: 'ask-user' }));
1564
+ const findings = p.asks.map((f, i) => ({ id: `gate-${i + 1}`, severity: f.level === 'fail' ? 'error' : 'warning', what: f.why, action: 'ask-user' }));
1565
+ const checked = p.checked;
1138
1566
  run.gate = { findings, cited: checked.cited, head: { commit: checked.head.commit, tree: checked.head.tree }, ...(checked.judgeBlock && { judge: checked.judgeBlock }) };
1139
1567
  run.status = 'awaiting_approval';
1140
1568
  set(step, 'awaiting_approval', { findings });
1141
1569
  return;
1142
1570
  }
1143
- if (checked.code !== PASS)
1144
- return finish(checked.code === FAIL ? 'fail' : 'inconclusive', `${judgeFindings.length ? 'judge' : 'check'}: ${(judgeFindings[0] ?? checked.findings[0])?.why ?? 'did not pass'}`);
1571
+ if (p.end)
1572
+ return finish(p.end.result, p.end.reason);
1145
1573
  begin('receipt');
1146
- const receipt = recordReceipt(run.repo, cfg, checked);
1574
+ const receipt = recordReceipt(run.repo, cfg, p.checked);
1147
1575
  log('receipt', receipt.result);
1148
1576
  run.receipt = receipt.result.receipt;
1149
1577
  set(step, receipt.code === PASS ? 'completed' : 'failed', { exit: receipt.code });
1150
1578
  if (receipt.code !== PASS)
1151
1579
  return finish('fail', `receipt: ${receipt.result.reason}`);
1152
- finish('pass');
1580
+ finish(fixed ? 'changed' : 'pass', fixed ? `your HEAD failed; the fix passes, on ${run.fix.branch}` : undefined);
1153
1581
  }
1154
1582
  catch (e) {
1155
1583
  for (const s of run.steps)
@@ -1169,6 +1597,142 @@ async function runSteps(dir) {
1169
1597
  clearInterval(beat);
1170
1598
  }
1171
1599
  }
1600
+ const failures = (list) => list.map((f, i) => ({ id: `F${i + 1}`, ...f }));
1601
+ const wrongScreens = (asks) => asks.map((f) => ({ kind: 'judge', file: f.file ?? '', what: f.why }));
1602
+ // What a verify fix never changes: the verify skill, .done-done.yaml, tests, test-runner configs and the checkout's copied ignored files. Other files a
1603
+ // journey runs are not guarded, so review the fix branch.
1604
+ const TEST_FILE = /(^|\/)(test|tests|__tests__|e2e|spec)\/|\.(test|spec)\.[^/]*$|_test\.(go|py)$|(^|\/)test_[^/]*\.py$|(^|\/)((playwright|jest|vitest|cypress)\.config\.[^/]*|cypress\.json)$/;
1605
+ // The judge's question for a screen it called wrong: the claim is the truth unless it looks wrong or needs a decision.
1606
+ const VERIFY_TRIAGE = {
1607
+ question: 'is a screen the judge called wrong for an outcome claim: the claim says what a user can now do, and the screen did not show it. Which side should change? The claim is the truth unless it looks wrong, ambiguous or in need of a product decision.',
1608
+ options: {
1609
+ fix: 'The product clearly misses a clear claim, so the product code should change to make the outcome hold.',
1610
+ ask: 'The claim may be wrong, is ambiguous, or the change needs a product decision, so a person must decide.',
1611
+ unclear: 'The text cannot settle which side should change.',
1612
+ },
1613
+ fix: 'fix',
1614
+ };
1615
+ // The judge the user set (never the fixer's agent) sorts the judge's failures before each fix: [] sends them all to the fixer;
1616
+ // with any ask (or unsure answer), or `park`, every judge failure goes to the gate with its class. A string: why the judge did not answer.
1617
+ async function sortJudged(run, cfg, open, record, h, park = false) {
1618
+ const judged = open.filter((f) => f.kind === 'judge');
1619
+ if (!judged.length)
1620
+ return [];
1621
+ const step = h.begin('triage');
1622
+ const choice = selectJudge(cfg, selectAgent(cfg), true);
1623
+ const asks = await triage(cfg, choice, judged, VERIFY_TRIAGE, { app: cfg.app, outcome: run.claim, findings: judged }, record);
1624
+ h.log('triage', { judge: { route: choice.route, model: choice.model, independence: choice.independence }, ask_user: asks });
1625
+ h.set(step, typeof asks === 'string' ? 'failed' : 'completed');
1626
+ if (typeof asks === 'string' || !(asks.length || park))
1627
+ return typeof asks === 'string' ? asks : [];
1628
+ return judged.map((f) => ({ level: 'fail', why: `${(asks.find((x) => x.id === f.id) ?? f).what}; triage: ${asks.some((x) => x.id === f.id) ? 'ask' : 'fix'}`, file: f.file }));
1629
+ }
1630
+ // auto_fix.verify: the user's agent fixes product code in a done-done worktree at the verified head, done-done commits it and
1631
+ // `again` verifies there, up to `tries` times; a pass or a gate keeps a done-done/verify-<run-id> branch, never the user's.
1632
+ async function fixVerify(run, cfg, dir, first, tries, h, again) {
1633
+ const fixStep = h.begin('fix');
1634
+ const repo = run.repo;
1635
+ const skill = relative(repo, join(repo, cfg.skill));
1636
+ const agent = selectAgent(cfg);
1637
+ // done-done's own folders in the repo (an evidence root, DONE_DONE_HOME): a re-run writes there, so they are never the user's checkout.
1638
+ const own = [cfg.evidence.root, home()].map((p) => under(repo, p)).filter((p) => p !== null);
1639
+ const checkout = repoState(repo, own);
1640
+ const ignored = git(repo, 'ls-files', '-z', '--others', '--ignored', '--exclude-standard', '--directory', ...pathspec(own)).split('\0').filter(Boolean);
1641
+ const carried = (p) => ignored.some((c) => p === c || (c.endsWith('/') && p.startsWith(c)));
1642
+ const off = (p) => p === '.done-done.yaml' || p.startsWith(`${skill}/`) || TEST_FILE.test(p) || carried(p);
1643
+ const wt = join(dir, 'fix');
1644
+ const scratch = join(dir, 'tmp');
1645
+ const rounds = [];
1646
+ let open = failures(first.fixable);
1647
+ const record = JSON.parse(readFileSync(run.record, 'utf8'));
1648
+ // The checkout's ignored state (node_modules, .env.local) is copied, never linked, into the worktree, and copied again over
1649
+ // whatever the fixer did to it before each re-run, so the re-run sees the fix commit with the user's own ignored files.
1650
+ // ponytail: a full copy each round (a clone where the file system can); a huge node_modules on ext4 is slow, link read-only if it bites.
1651
+ const carry = () => {
1652
+ git(wt, 'clean', '-fdqX');
1653
+ for (const p of ignored) {
1654
+ try {
1655
+ cpSync(join(repo, p), join(wt, p), { recursive: true, verbatimSymlinks: true, mode: constants.COPYFILE_FICLONE });
1656
+ }
1657
+ catch (e) {
1658
+ return `copy of your ignored ${p} into the fix worktree failed: ${errorText(e)}`;
1659
+ }
1660
+ }
1661
+ };
1662
+ const attempts = async (start) => {
1663
+ let base = start;
1664
+ const copied = carry();
1665
+ if (copied)
1666
+ return (h.set(fixStep, 'failed'), run.findings = open, { blocked: copied });
1667
+ for (let k = 1; k <= tries; k++) {
1668
+ const step = h.begin('fix');
1669
+ const round = { attempt: k, failures: open };
1670
+ rounds.push(round);
1671
+ const close = (status) => (h.log('fix', { agent: agent.agent, model: agent.model, worktree: wt, rounds }), h.set(step, status));
1672
+ const blocked = (reason, findings = open) => (Object.assign(round, { blocked: reason }), close('failed'), (run.findings = findings), { blocked: reason });
1673
+ const drive = run.journey === null ? `It drove the verify skill feature ${skill}/features/${run.feature ?? '<the one the outcome is about>'}.md the way a user does (${skill}/SKILL.md says how the app is launched).` : `It drove the app with this journey command: ${cfg.journeys[run.journey]?.command}`;
1674
+ const prompt = `You fix the ${cfg.app} app in this checkout, your working directory. done-done verify drove the real app to prove the outcome below, and it failed.
1675
+ ${drive}
1676
+
1677
+ Outcome to prove: ${run.claim}
1678
+
1679
+ Failures (JSON): ${JSON.stringify(open)}
1680
+
1681
+ Change the product's code so the outcome holds when a user takes that path. Never change the verify skill (${skill}/), .done-done.yaml, a test file (test/, tests/, __tests__/, e2e/, spec/, *.test.*, *.spec.*, *_test.go, *_test.py, test_*.py) or a test-runner config, and never make the outcome only look true. Do not commit, and do not run done-done. Put scratch files in ${scratch} (also in $TMPDIR).
1682
+
1683
+ Answer with summary (what you changed, in one line) and blocker ("" when you fixed it, else why you could not).`;
1684
+ mkdirSync(scratch, { recursive: true });
1685
+ // A re-run's edits and new files are not the fixer's to commit. Ignored ones (a build) stay.
1686
+ git(wt, 'reset', '-q', '--hard');
1687
+ git(wt, 'clean', '-fdq');
1688
+ const a = await askAgent({ ...agent, prompt, schema: FIX_SCHEMA, cwd: wt, tools: 'edit', record: join(dir, 'agents', `fix-${k}.json`), dirs: [scratch], env: { DONE_DONE_DRIVE: run.run_id, TMPDIR: scratch }, onSpawn: h.spawned });
1689
+ Object.assign(round, { record: a.record ?? a.recordError, ...(a.ok ? { summary: a.answer?.summary, blocker: a.answer?.blocker } : { error: a.error }) });
1690
+ // The diff guard: product code only, in the worktree only; anything else drops the attempt with the worktree.
1691
+ const changed = (round.changed = changedSince(wt, base, false));
1692
+ const problem = attemptProblem(repo, checkout, wt, base, agent.agent, a, changed, changed.filter(off), 'the verify skill, .done-done.yaml, a test file, a test-runner config or a copied ignored file of your checkout, which a fix never changes', 'the product', own);
1693
+ if (problem)
1694
+ return blocked(problem);
1695
+ if (!changed.length)
1696
+ return blocked(`${agent.agent} changed nothing`);
1697
+ const made = commitFix(wt, base, `fix: done-done verify fixes ${run.claim.slice(0, 60)}`, ['.']);
1698
+ if (typeof made === 'string')
1699
+ return blocked(made);
1700
+ if (made.files.some(off))
1701
+ return blocked('the commit holds the verify skill, .done-done.yaml, a test file, a test-runner config or a copied ignored file (a commit hook added them), so no branch is made');
1702
+ base = round.commit = made.commit;
1703
+ run.fix = { commit: made.commit, record: join(recordsDir(cfg), `${run.run_id}-fix${k}.json`) };
1704
+ // Verify again at the fix commit; its records are named fix<k>-, and the judge sorts its failures before the next fix.
1705
+ const recopied = carry();
1706
+ if (recopied)
1707
+ return blocked(recopied);
1708
+ const next = await again(wt, `fix${k}`);
1709
+ round.rerun = { result: next.end?.result ?? (next.asks.length ? 'gate' : 'pass'), reason: next.end?.reason, ...next.logs };
1710
+ if (next.end && !next.fixable.length)
1711
+ return blocked(`the re-run after attempt ${k} did not pass: ${next.end.reason}`, []);
1712
+ const sorted = next.end ? await sortJudged(run, cfg, failures(next.fixable), join(dir, 'agents', `triage-fix${k}.json`), h).catch((e) => { close('failed'); throw e; }) : [];
1713
+ if (typeof sorted === 'string')
1714
+ return blocked(`triage: ${sorted}`, failures(next.fixable));
1715
+ if (!next.end || sorted.length) {
1716
+ run.fix.branch = `done-done/verify-${run.run_id}`;
1717
+ git(repo, 'branch', run.fix.branch, made.commit);
1718
+ close('completed');
1719
+ return sorted.length ? { checked: next.checked, asks: sorted, fixable: [] } : next;
1720
+ }
1721
+ open = failures(next.fixable);
1722
+ close('completed');
1723
+ }
1724
+ run.findings = open;
1725
+ return { blocked: `auto_fix.verify allows ${tries} ${tries === 1 ? 'attempt' : 'attempts'}, and the re-run still fails after them` };
1726
+ };
1727
+ return inWorktree(repo, wt, async (start) => {
1728
+ try {
1729
+ return await attempts(start);
1730
+ }
1731
+ finally {
1732
+ git(wt, 'clean', '-fdqX');
1733
+ }
1734
+ }, record.commit);
1735
+ }
1172
1736
  // EPERM: the pid is alive but not ours to signal. A pid reused by another process still reads as alive: the known limit of a pid check.
1173
1737
  const alive = (pid) => {
1174
1738
  try {
@@ -1187,6 +1751,8 @@ const shownStatus = (run) => ((run.status === 'pending' || run.status === 'runni
1187
1751
  const reapJourney = (dir, run) => {
1188
1752
  if (run.runner?.journey_pgid && createOnce(join(dir, 'journey.killed')))
1189
1753
  killGroup(run.runner.journey_pgid);
1754
+ if (run.runner?.instance_pgid && createOnce(join(dir, 'instance.killed')))
1755
+ killGroup(run.runner.instance_pgid);
1190
1756
  };
1191
1757
  // One run as status shows it. A run still marked running whose runner is gone is shown as failed at its step.
1192
1758
  // `gone`: the caller saw its runner child exit, which also covers a runner that died before it wrote its pid.
@@ -1203,7 +1769,7 @@ function showRun(dir, cfg, command, gone = false) {
1203
1769
  reapJourney(dir, run);
1204
1770
  }
1205
1771
  // An overridden run is never a pass: it exits 1 like a fail.
1206
- const code = status === 'pending' || status === 'running' || status === 'awaiting_approval' ? WAITING : status === 'cancelled' || status === 'failed' || run.result === 'fail' || run.result === 'overridden' || run.result === 'drift' ? FAIL : run.result === 'inconclusive' || run.result === 'blocked' ? INCONCLUSIVE : PASS;
1772
+ const code = status === 'pending' || status === 'running' || status === 'awaiting_approval' ? WAITING : status === 'cancelled' || status === 'failed' || run.result === 'fail' || run.result === 'overridden' || run.result === 'drift' || (run.kind === 'verify' && (run.result === 'changed' || run.result === 'blocked')) ? FAIL : run.result === 'inconclusive' || run.result === 'blocked' ? INCONCLUSIVE : PASS;
1207
1773
  const drive = run.journey ?? (run.feature ? `--feature ${arg(run.feature)}` : '');
1208
1774
  const judgeFlag = run.judge === false && cfg.judge.enabled !== false ? ' --no-judge' : run.judge && cfg.judge.enabled === false ? ' --judge' : '';
1209
1775
  const again = run.kind === 'maintain' ? 'done-done maintain' : `done-done verify ${drive ? `${drive} ` : ''}--outcome ${arg(run.claim)}${run.claim_id ? ` --claim-id ${arg(run.claim_id)}` : ''}${judgeFlag}`;
@@ -1226,10 +1792,14 @@ function showRun(dir, cfg, command, gone = false) {
1226
1792
  result.findings = run.gate.findings;
1227
1793
  if (run.response)
1228
1794
  result.response = run.response;
1795
+ if (run.spend)
1796
+ result.spend = run.spend;
1229
1797
  if (ev.items.length)
1230
1798
  result.evidence = ev.items;
1231
1799
  if (ev.omitted)
1232
1800
  result.evidence_omitted = ev.omitted;
1801
+ if (rec?.matrix)
1802
+ result.matrix = rec.matrix;
1233
1803
  if (run.receipt)
1234
1804
  result.receipt = run.receipt;
1235
1805
  if (run.fix)
@@ -1248,25 +1818,31 @@ function showRun(dir, cfg, command, gone = false) {
1248
1818
  result.run = shown(run.repo, dir);
1249
1819
  const id = run.run_id;
1250
1820
  result.help =
1251
- status === 'awaiting_approval' ? [
1252
- 'Read the findings and look at the captures (evidence above), then answer the gate:',
1253
- `done-done respond --run ${id} --action approve --reason "<why they do show the outcome>" (ends overridden, never pass)`,
1254
- `done-done respond --run ${id} --action skip --reason "<why to go on without a judge pass>" (ends overridden, never pass)`,
1255
- `done-done respond --run ${id} --action fix (ends fail; fix the product, or the verify skill's recipe (done-done maintain checks it against the source), commit, then: done-done rerun --run ${id})`,
1821
+ status === 'awaiting_approval' && run.gate?.spend ? [
1822
+ `Ask the user whether to spend ${run.gate.spend} on this run; never decide it for them. Their yes runs the journey:`,
1823
+ `done-done respond --run ${id} --action approve --reason "<who approved the spend>"`,
1824
+ `Their no: done-done respond --run ${id} --action skip --reason "<why they said no>" (ends fail, nothing spent)`,
1256
1825
  ]
1257
- : run.result === 'overridden' ? [`This head is not proven: ${run.response?.by} chose ${run.response?.action} past the judge, and receipt --verify refuses this receipt`, `Capture proof the judge passes: ${again}`]
1258
- : code === WAITING ? [`The run goes on without this command; read it later: done-done status --run ${run.run_id}`]
1259
- : run.kind === 'maintain' && status === 'completed' ? (run.result === 'clean' ? [`The verify skill matches the source; read the readers' recipes: done-done logs --step reconcile --run ${run.run_id}`]
1260
- : run.result === 'changed' ? [`Review the fix on its own branch (your branch is untouched): git show ${run.fix.branch}`, `Take it: git merge ${run.fix.branch}, or drop it: git branch -D ${run.fix.branch}`]
1261
- : run.result === 'drift' ? [`Fix the findings in ${cfg.skill} (--full lists them all), commit, then run again: ${again}`, `Or let done-done's agent fix them on a branch: set auto_fix: { maintain: 2 } in .done-done.yaml, then run again: ${again}`]
1262
- : /^ask-user:/.test(run.reason ?? '') ? ['Ask the user about each ask-user finding: is the product or the skill wrong? Never decide it for them', `Product wrong: fix the product (maintain never edits product code). Skill wrong: fix ${cfg.skill}, commit, then run again: ${again}`]
1263
- : /^fix:/.test(run.reason ?? '') ? [`Read what each fix attempt did: done-done logs --step fix --run ${run.run_id}`, `Fix the findings in ${cfg.skill} by hand, commit, then run again: ${again}`]
1264
- : /^locate: no verify skill/.test(run.reason ?? '') ? ['Create the verify skill: done-done init']
1265
- : /^locate:/.test(run.reason ?? '') ? [`Make ${cfg.skill} a real folder inside the repository (not a link), commit, then run again: ${again}`]
1266
- : [`Read the step logs in ${shown(run.repo, join(dir, 'logs'))}`, `Fix the cause, then run again: ${again}`])
1267
- : code === PASS ? [`Verify this receipt (CI or merge): done-done receipt --verify ${rec.commit}`]
1268
- : status === 'cancelled' ? [...(run.kind === 'maintain' ? [] : [`Start again from the failed step: done-done rerun --run ${run.run_id}`]), `List past runs: done-done history`]
1269
- : [`Read the step logs in ${shown(run.repo, join(dir, 'logs'))}`, ...(run.kind === 'maintain' ? [] : [`Fix the cause, commit, then run again from the failed step: done-done rerun --run ${run.run_id}`]), `Or start a new run: ${again}`];
1826
+ : status === 'awaiting_approval' ? [
1827
+ 'Read the findings and look at the captures (evidence above), then answer the gate:',
1828
+ `done-done respond --run ${id} --action approve --reason "<why they do show the outcome>" (ends overridden, never pass)`,
1829
+ `done-done respond --run ${id} --action skip --reason "<why to go on without a judge pass>" (ends overridden, never pass)`,
1830
+ `done-done respond --run ${id} --action fix (ends fail; fix the product, or the verify skill's recipe (done-done maintain checks it against the source), commit, then: done-done rerun --run ${id})`,
1831
+ ]
1832
+ : run.result === 'overridden' ? [`This head is not proven: ${run.response?.by} chose ${run.response?.action} past the judge, and receipt --verify refuses this receipt`, `Capture proof the judge passes: ${again}`]
1833
+ : code === WAITING ? [`The run goes on without this command; read it later: done-done status --run ${run.run_id}`]
1834
+ : run.kind === 'verify' && /^fix:/.test(run.reason ?? '') ? [`Read what each fix attempt did: done-done logs --step fix --run ${id}`, `Fix the product by hand, commit, then run again: ${again}`]
1835
+ : run.result === 'changed' ? [`Review the fix on its own branch (your branch is untouched): git show ${run.fix.branch}`, `Take it: git merge ${run.fix.branch}, or drop it: git branch -D ${run.fix.branch}`]
1836
+ : run.kind === 'maintain' && status === 'completed' ? (run.result === 'clean' ? [`The verify skill matches the source; read the readers' recipes: done-done logs --step reconcile --run ${run.run_id}`]
1837
+ : run.result === 'drift' ? [`Fix the findings in ${cfg.skill} (--full lists them all), commit, then run again: ${again}`, `Or let done-done's agent fix them on a branch: set auto_fix: { maintain: 2 } in .done-done.yaml, then run again: ${again}`]
1838
+ : /^ask-user:/.test(run.reason ?? '') ? ['Ask the user about each ask-user finding: is the product or the skill wrong? Never decide it for them', `Product wrong: fix the product (maintain never edits product code). Skill wrong: fix ${cfg.skill}, commit, then run again: ${again}`]
1839
+ : /^fix:/.test(run.reason ?? '') ? [`Read what each fix attempt did: done-done logs --step fix --run ${run.run_id}`, `Fix the findings in ${cfg.skill} by hand, commit, then run again: ${again}`]
1840
+ : /^locate: no verify skill/.test(run.reason ?? '') ? ['Create the verify skill: done-done init']
1841
+ : /^locate:/.test(run.reason ?? '') ? [`Make ${cfg.skill} a real folder inside the repository (not a link), commit, then run again: ${again}`]
1842
+ : [`Read the step logs in ${shown(run.repo, join(dir, 'logs'))}`, `Fix the cause, then run again: ${again}`])
1843
+ : code === PASS ? [`Verify this receipt (CI or merge): done-done receipt --verify ${rec.commit}`]
1844
+ : status === 'cancelled' ? [...(run.kind === 'maintain' ? [] : [`Start again from the failed step: done-done rerun --run ${run.run_id}`]), `List past runs: done-done history`]
1845
+ : [`Read the step logs in ${shown(run.repo, join(dir, 'logs'))}`, ...(run.kind === 'maintain' ? [] : [`Fix the cause, commit, then run again from the failed step: done-done rerun --run ${run.run_id}`]), `Or start a new run: ${again}`];
1270
1846
  emit(result);
1271
1847
  return code;
1272
1848
  }
@@ -1275,10 +1851,36 @@ async function waitRun(dir, pid) {
1275
1851
  await new Promise((r) => setTimeout(r, 200));
1276
1852
  }
1277
1853
  // The judge a command will start: the user's agent and judge (asked for when unset), refused when it is the author.
1278
- async function preflightJudge(cfg) {
1279
- await withChoice('agent', cfg);
1854
+ async function preflightJudge(cfg, needsAgent = true, fix = false) {
1855
+ if (needsAgent)
1856
+ await withChoice('agent', cfg);
1280
1857
  await withChoice('judge', cfg);
1281
- return selectJudge(cfg, selectAgent(cfg));
1858
+ return selectJudge(cfg, cfg.agent ? selectAgent(cfg) : NO_AUTHOR, fix);
1859
+ }
1860
+ const NO_AUTHOR = { agent: '', model: '' };
1861
+ // A run waiting at its gate is the user's call, so a new verify or rerun is refused until the user answers that run or it is
1862
+ // aborted: starting another would go around its gate. Only a run of this checkout counts, keyed by its claim id, else the branch it
1863
+ // started on; a run with neither (recorded by an older build) blocks every new run in this checkout.
1864
+ function refuseParked(cfg, repo, claim) {
1865
+ const parked = runsIn(runsRoot(cfg.app)).filter((r) => {
1866
+ const key = r.claim_id ?? r.branch;
1867
+ return r.kind === 'verify' && r.status === 'awaiting_approval' && r.repo === repo && (key === claim || key === undefined);
1868
+ }).map((r) => r.run_id);
1869
+ if (!parked.length)
1870
+ return;
1871
+ const id = parked[0];
1872
+ throw new UsageError(`run ${parked.join(', ')} waiting at its gate for the user's answer blocks this run, which would go around that gate`, `Show the user its findings and captures (done-done status --run ${id}), then answer with their choice: done-done respond --run ${id} --action approve|skip|fix [--reason "<why>"], or stop it: done-done abort --run ${id}`);
1873
+ }
1874
+ // Before a verify starts: its judge, and with auto_fix.verify an agent that can edit and a judge that is never that agent.
1875
+ async function preflightVerify(cfg, judging) {
1876
+ if (cfg.auto_fix.verify) {
1877
+ await withChoice('agent', cfg);
1878
+ if (cfg.agent.startsWith('acp:'))
1879
+ throw new UsageError(`${cfg.agent} cannot fix the product: done-done runs ACP agents with every tool denied`, `Set auto_fix.verify: 0 to report only, or set agent: to one of ${AGENTS.join(', ')}`);
1880
+ }
1881
+ if (!judging)
1882
+ return;
1883
+ await preflightJudge(cfg, true, !!cfg.auto_fix.verify);
1282
1884
  }
1283
1885
  async function cmdVerify(argv) {
1284
1886
  const { values, positionals } = parseArgs({
@@ -1307,21 +1909,22 @@ async function cmdVerify(argv) {
1307
1909
  throw new UsageError(`unknown journey ${journey}; .done-done.yaml defines: ${names.join(', ') || 'none'}`);
1308
1910
  if (values['claim-id']?.trim() === '')
1309
1911
  throw new UsageError('--claim-id needs a non-empty id');
1310
- if (!values['claim-id'] && !git(repo, 'branch', '--show-current'))
1912
+ const branch = git(repo, 'branch', '--show-current');
1913
+ if (!values['claim-id'] && !branch)
1311
1914
  throw new UsageError('HEAD is detached, so no branch names this claim; pass --claim-id <id>');
1915
+ refuseParked(cfg, repo, values['claim-id'] ?? branch);
1312
1916
  const drive = journey === null ? await agentDrive(repo, cfg, values.feature) : undefined;
1313
1917
  // verify starts done-done's own judge by default; --no-judge or judge.enabled: false turns it off. An agent drive is judged too, since the author never grades its own captures.
1314
1918
  const judging = !values['no-judge'] && (!!drive || !!values.judge || cfg.judge.enabled !== false);
1315
1919
  // No judge set, or one config forbids (the author's own agent and model), is a usage error now, not a failed run later.
1316
- if (judging)
1317
- await preflightJudge(cfg);
1920
+ await preflightVerify(cfg, judging);
1318
1921
  const id = newRunId(git(repo, 'rev-parse', 'HEAD'));
1319
1922
  const dir = join(runsRoot(cfg.app), id);
1320
1923
  mkdirSync(join(dir, 'logs'), { recursive: true });
1321
1924
  const created = now();
1322
1925
  const run = {
1323
- schema: 2, run_id: id, kind: 'verify', app: cfg.app, repo, journey, ...drive, claim: values.outcome, claim_id: values['claim-id'] ?? null, judge: judging, status: 'pending',
1324
- steps: STEPS.map((name) => ({ name, status: 'pending' })), created, updated: created,
1926
+ schema: 2, run_id: id, kind: 'verify', app: cfg.app, repo, journey, ...drive, claim: values.outcome, claim_id: values['claim-id'] ?? null, branch, judge: judging, status: 'pending',
1927
+ steps: verifySteps(cfg).map((name) => ({ name, status: 'pending' })), created, updated: created,
1325
1928
  inputs: inputsOf(repo, cfg, journey, drive?.author),
1326
1929
  };
1327
1930
  saveRun(dir, run);
@@ -1353,8 +1956,8 @@ async function cmdMaintain(argv) {
1353
1956
  // auto_fix: the agent the user set fixes the skill and the judge the user set (never that agent and model) triages, so both are set now.
1354
1957
  if (cfg.auto_fix.maintain) {
1355
1958
  if (cfg.agent.startsWith('acp:'))
1356
- throw new UsageError(`${cfg.agent} cannot fix the verify skill: done-done runs ACP agents with every tool denied`, `Set auto_fix.maintain: 0 to report only, or set agent: to one of ${AGENTS.filter(supported).join(', ')}`);
1357
- refuseSameSession(await preflightJudge(cfg));
1959
+ throw new UsageError(`${cfg.agent} cannot fix the verify skill: done-done runs ACP agents with every tool denied`, `Set auto_fix.maintain: 0 to report only, or set agent: to one of ${AGENTS.join(', ')}`);
1960
+ await preflightJudge(cfg, true, true);
1358
1961
  }
1359
1962
  const id = newRunId(git(repo, 'rev-parse', 'HEAD'));
1360
1963
  const dir = join(runsRoot(cfg.app), id);
@@ -1434,30 +2037,33 @@ async function cmdRerun(argv) {
1434
2037
  const open = shownStatus(src);
1435
2038
  if (open === 'pending' || open === 'running')
1436
2039
  throw new UsageError(`run ${srcId} is still ${open}, so it cannot be rerun while its runner lives`, `Read it: done-done status --run ${srcId}; stop it first: done-done abort --run ${srcId}`);
1437
- const group = src.runner?.journey_pgid;
2040
+ const group = src.runner?.journey_pgid ?? src.runner?.instance_pgid;
1438
2041
  if (['pending', 'running'].includes(src.status) && group && alive(-group))
1439
2042
  throw new UsageError(`the journey of run ${srcId} is still running, so it cannot be rerun yet`, `Read it: done-done status --run ${srcId}`);
2043
+ // The rerun keeps its source's claim and branch, so the gate it is checked against is the source's.
2044
+ refuseParked(cfg, src.repo, src.claim_id ?? src.branch);
1440
2045
  // A source run with no journey is driven again by the author agent config names now; schema 2 runs before PR 4 always had a journey.
1441
2046
  const journey = src.journey ?? null;
1442
2047
  const drive = journey === null ? await agentDrive(src.repo, cfg, src.feature) : undefined;
1443
- const inputs = inputsOf(src.repo, loadConfig(src.repo), journey, drive?.author);
2048
+ const srcCfg = loadConfig(src.repo);
2049
+ const inputs = inputsOf(src.repo, srcCfg, journey, drive?.author);
1444
2050
  const same = !!src.inputs && ['head', 'journey', 'skill'].every((k) => src.inputs[k] === inputs[k]);
1445
- const driveReuse = same && drivesClean(src.record, src.inputs?.head);
2051
+ // A reused drive keeps the guard it ran under, so it is only reused when the watched inputs are the same ones.
2052
+ const driveReuse = same && drivesClean(src.record, src.inputs?.head) && JSON.stringify(src.guarded?.patterns ?? []) === JSON.stringify(srcCfg.inputs);
1446
2053
  const reused = (name) => (name === 'resolve' && same) || (name === 'drive' && driveReuse);
1447
2054
  // A rerun judges as its source did; --no-judge turns it off for this run. A missing or forbidden judge is a usage
1448
2055
  // error here too.
1449
2056
  const judging = !values['no-judge'] && !!src.judge;
1450
- if (judging)
1451
- await preflightJudge(cfg);
2057
+ await preflightVerify(cfg, judging);
1452
2058
  const id = newRunId(git(repo, 'rev-parse', 'HEAD'));
1453
2059
  const dir = join(root, id);
1454
2060
  mkdirSync(join(dir, 'logs'), { recursive: true });
1455
- const steps = STEPS.map((name) => {
2061
+ const steps = verifySteps(cfg).map((name) => {
1456
2062
  const s = src.steps.find((x) => x.name === name);
1457
2063
  return s && reused(name) && s.status === 'completed' ? { ...s } : { name, status: 'pending' };
1458
2064
  });
1459
2065
  const created = now();
1460
- const run = { schema: 2, run_id: id, kind: src.kind, app: cfg.app, repo: src.repo, journey, ...drive, claim: src.claim, claim_id: src.claim_id, judge: judging, status: 'pending', inputs, steps, created, updated: created, ...(driveReuse && src.record && { record: src.record }) };
2066
+ const run = { schema: 2, run_id: id, kind: src.kind, app: cfg.app, repo: src.repo, journey, ...drive, claim: src.claim, claim_id: src.claim_id, branch: src.branch, judge: judging, status: 'pending', inputs, steps, created, updated: created, ...(driveReuse && src.record && { record: src.record }), ...(driveReuse && src.guarded && { guarded: src.guarded }) };
1461
2067
  saveRun(dir, run);
1462
2068
  for (const s of steps) {
1463
2069
  const f = join(root, srcId, 'logs', `${s.name}.log`);
@@ -1490,28 +2096,43 @@ async function cmdAbort(argv) {
1490
2096
  if (closed(latest))
1491
2097
  return FAIL;
1492
2098
  // A run waiting at its gate has no runner left; the respond lock keeps abort and respond from both closing it.
1493
- const parked = latest.status === 'awaiting_approval';
1494
- if (parked && !createOnce(join(dir, 'respond.lock'))) {
2099
+ const gated = latest.status === 'awaiting_approval';
2100
+ const answering = () => {
1495
2101
  emit({ command: 'abort', run_id: id, status: latest.status, reason: `run ${id} is being answered at its gate, so it was not cancelled`, help: [`Read the run: done-done status --run ${id}`] });
1496
2102
  return FAIL;
2103
+ };
2104
+ if (gated && !createOnce(join(dir, 'respond.lock')))
2105
+ return answering();
2106
+ const read = () => { try {
2107
+ return loadRun(dir);
1497
2108
  }
1498
- if (!parked && latest.runner && !isOurRunner(latest.runner.pid, dir)) {
1499
- emit({ command: 'abort', run_id: id, status: latest.status, reason: `could not confirm pid ${latest.runner.pid} is this run's runner, so nothing was killed or cancelled`, help: [`Read the run: done-done status --run ${id}`] });
2109
+ catch (e) {
2110
+ if (gated)
2111
+ rmSync(join(dir, 'respond.lock'), { force: true });
2112
+ throw e;
2113
+ } };
2114
+ const current = read();
2115
+ if (gated && current.status !== 'awaiting_approval') {
2116
+ rmSync(join(dir, 'respond.lock'), { force: true });
2117
+ return answering();
2118
+ }
2119
+ if (!gated && current.runner && !isOurRunner(current.runner.pid, dir)) {
2120
+ emit({ command: 'abort', run_id: id, status: current.status, reason: `could not confirm pid ${current.runner.pid} is this run's runner, so nothing was killed or cancelled`, help: [`Read the run: done-done status --run ${id}`] });
1500
2121
  return FAIL;
1501
2122
  }
1502
- if (!parked && latest.runner) {
2123
+ if (!gated && current.runner) {
1503
2124
  try {
1504
- process.kill(-latest.runner.pid, 'SIGKILL');
2125
+ process.kill(-current.runner.pid, 'SIGKILL');
1505
2126
  }
1506
2127
  catch {
1507
2128
  try {
1508
- process.kill(latest.runner.pid, 'SIGKILL');
2129
+ process.kill(current.runner.pid, 'SIGKILL');
1509
2130
  }
1510
2131
  catch { /* exited since the check */ }
1511
2132
  }
1512
- await waitGone(latest.runner.pid);
2133
+ await waitGone(current.runner.pid);
1513
2134
  }
1514
- const after = loadRun(dir);
2135
+ const after = read();
1515
2136
  if (closed(after, after.status))
1516
2137
  return FAIL;
1517
2138
  try {
@@ -1522,7 +2143,8 @@ async function cmdAbort(argv) {
1522
2143
  saveRun(dir, after);
1523
2144
  }
1524
2145
  catch (e) {
1525
- rmSync(join(dir, 'respond.lock'), { force: true });
2146
+ if (gated)
2147
+ rmSync(join(dir, 'respond.lock'), { force: true });
1526
2148
  throw e;
1527
2149
  }
1528
2150
  emit({ command: 'abort', run_id: id, status: 'cancelled', reason: after.reason, help: [`Read the run: done-done status --run ${id}`, ...startAgain(after)] });
@@ -1540,7 +2162,7 @@ function answeredBy(repo) {
1540
2162
  // Answer a run waiting at its gate. approve and skip write the receipt as overridden (with who and why) and end the run
1541
2163
  // overridden, never pass; fix ends it fail. The runner has exited, so this command finishes the run; the respond lock
1542
2164
  // lets only one answer (or an abort) close it.
1543
- function cmdRespond(argv) {
2165
+ async function cmdRespond(argv) {
1544
2166
  const { values } = parseArgs({ args: argv, options: { run: { type: 'string' }, action: { type: 'string' }, reason: { type: 'string' } } });
1545
2167
  const action = values.action;
1546
2168
  if (action !== 'approve' && action !== 'skip' && action !== 'fix')
@@ -1553,14 +2175,33 @@ function cmdRespond(argv) {
1553
2175
  const root = runsRoot(cfg.app);
1554
2176
  const id = pickId(root, repo, 'respond', cfg, values.run);
1555
2177
  const dir = join(root, id);
1556
- const run = loadRun(dir);
1557
- if (run.status !== 'awaiting_approval' || !run.gate || !createOnce(join(dir, 'respond.lock')))
1558
- throw new UsageError(`run ${id} is not waiting at its gate (it is ${run.status === 'awaiting_approval' ? 'being answered' : shownStatus(run)})`, `Read it: done-done status --run ${id}`);
2178
+ const notAtGate = (r) => new UsageError(`run ${id} is not waiting at its gate (it is ${r.status === 'awaiting_approval' ? 'being answered' : shownStatus(r)})`, `Read it: done-done status --run ${id}`);
2179
+ if (!createOnce(join(dir, 'respond.lock')))
2180
+ throw notAtGate(loadRun(dir));
1559
2181
  try {
2182
+ const run = loadRun(dir);
2183
+ if (run.status !== 'awaiting_approval' || !run.gate)
2184
+ throw notAtGate(run);
2185
+ if (run.gate.spend && action === 'fix')
2186
+ throw new UsageError(`run ${id} is at a spend gate, which takes --action approve or skip`, `Run: done-done respond --run ${id} --action approve|skip --reason "<why>"`);
1560
2187
  const gate = run.steps.find((s) => s.name === 'gate');
1561
2188
  const by = answeredBy(repo);
1562
2189
  run.response = { action, by, ...(reason && { reason }), at: now() };
1563
2190
  logEvent(dir, { step: 'gate', response: action, by, ...(reason && { reason }) });
2191
+ // The spend gate: approve runs the journey in a new runner; skip or fix ends the run with nothing spent.
2192
+ if (run.gate.spend) {
2193
+ const drive = run.steps.find((s) => s.name === 'drive');
2194
+ if (action !== 'approve') {
2195
+ setStep(dir, run, drive, 'failed');
2196
+ finishRun(dir, run, 'fail', `gate: ${by} chose ${action}: the journey was not run, so nothing was spent`);
2197
+ return showRun(dir, cfg, 'respond');
2198
+ }
2199
+ Object.assign(run, { spend: { by, reason, at: run.response.at }, status: 'pending', gate: undefined, response: undefined, runner: undefined });
2200
+ setStep(dir, run, drive, 'pending');
2201
+ for (const lock of ['runner.lock', 'respond.lock'])
2202
+ rmSync(join(dir, lock));
2203
+ return launch(dir, cfg, 'respond', {});
2204
+ }
1564
2205
  if (action === 'fix') {
1565
2206
  setStep(dir, run, gate, 'failed');
1566
2207
  finishRun(dir, run, 'fail', `gate: ${by} chose fix: ${reason || run.gate.findings[0].what}`);
@@ -1581,6 +2222,109 @@ function cmdRespond(argv) {
1581
2222
  throw e;
1582
2223
  }
1583
2224
  }
2225
+ // sign-off's communication contract v1 (github.com/umeranjum17/sign-off, docs/contract.md): done-done sends `gate` and
2226
+ // `evidence`, schema 1. Each value is checked against the contract's limits first, so a run that cannot make a valid
2227
+ // message writes no file. done-done never posts it: sign-off is the user's choice, so the help line names the post command.
2228
+ const GATE_LABELS = { approve: 'Approve', skip: 'Skip the judge', fix: 'Fix first' };
2229
+ function cmdSignoff(argv) {
2230
+ const { values } = parseArgs({ args: argv, options: { run: { type: 'string' } } });
2231
+ const repo = repoRoot();
2232
+ const cfg = loadConfig(repo);
2233
+ const root = runsRoot(cfg.app);
2234
+ const id = pickId(root, repo, 'signoff', cfg, values.run);
2235
+ const run = loadRun(join(root, id));
2236
+ const refuse = (what, help) => { throw new UsageError(`run ${id} cannot make a valid sign-off message, so no file was written: ${what}`, help); };
2237
+ const text = (field, v, max, help = `Read it: done-done status --run ${id}`) => {
2238
+ const n = [...v].length;
2239
+ if (!/\S/.test(v) || n > max)
2240
+ refuse(`${field} is ${n} characters, and the contract takes 1-${max}`, help);
2241
+ if (/[\u0000-\u0009\u000b-\u001f\u007f-\u009f]|\p{Cs}/u.test(v))
2242
+ refuse(`${field} holds a control character or half of a character`, help);
2243
+ return v;
2244
+ };
2245
+ // At most 116 characters, so dd-evidence-<run id> is still a key-set id (1-128).
2246
+ const runRef = (commit) => /^[A-Za-z0-9._-]{1,116}$/.test(id) && /^[0-9a-f]{40}$/.test(commit) ? { run_id: id, commit } : refuse(`run_id ${id} or commit ${commit} is not in the contract's shape`, 'Start a new run: done-done verify --outcome "<what a user can now do>"');
2247
+ const drove = run.journey ?? run.feature ?? 'agent drive';
2248
+ // A finding names the folder that captured it (this run's, a -fixN pass's or a reused source run's), so the message
2249
+ // names the capture file alone: strip any <evidence root>/<one run folder>/ prefix.
2250
+ const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
2251
+ const bare = (s) => s.replaceAll(new RegExp(`${esc(cfg.evidence.root)}${esc(sep)}[^${esc(sep)}]+${esc(sep)}`, 'g'), '');
2252
+ let msg, file;
2253
+ if (run.kind === 'verify' && run.status === 'awaiting_approval' && run.gate) {
2254
+ const { findings, spend, head } = run.gate;
2255
+ const more = findings.length > 1 ? ` (and ${findings.length - 1} more)` : '';
2256
+ msg = {
2257
+ contract: 1, kind: 'gate', schema: 1, key: `dd-gate-${id}`, filer: 'done-done',
2258
+ title: text('title', spend ? `Spend ${spend} on the ${cfg.app} ${drove} run?` : `Move the ${cfg.app} ${drove} run on past its judge?`, 160),
2259
+ about: text('about', `${bare(findings[0].what)}${more}`, 500), run: runRef(head.commit),
2260
+ // A spend gate takes approve or skip only (respond refuses fix there).
2261
+ options: (spend ? ['approve', 'skip'] : ['approve', 'skip', 'fix']).map((value) => ({ value, label: GATE_LABELS[value] })),
2262
+ recommend_value: findings.some((f) => f.severity === 'error') ? 'fix' : 'approve', close: 'done',
2263
+ };
2264
+ file = join(root, id, 'signoff-gate.json');
2265
+ }
2266
+ else if (run.kind === 'verify' && run.status === 'completed' && run.record) {
2267
+ // A run auto_fix.verify fixed proves the fix pass, never the first failing pass: the receipt is bound to the fix commit.
2268
+ const fixed = run.result === 'changed' && run.fix?.record ? run.fix : undefined;
2269
+ // The message sits in the folder of the run that made the captures (a rerun that reused its drive sends its source's),
2270
+ // so each media path is relative to it and stays inside it.
2271
+ const again = `Capture it again: done-done rerun --run ${id}`;
2272
+ let rec, folder;
2273
+ try {
2274
+ rec = JSON.parse(readFileSync(fixed?.record ?? run.record, 'utf8'));
2275
+ }
2276
+ catch {
2277
+ return refuse('its run record is missing or unreadable', again);
2278
+ }
2279
+ try {
2280
+ folder = realpathSync(runDir(cfg, rec.run_id));
2281
+ }
2282
+ catch {
2283
+ return refuse('its run folder is missing', again);
2284
+ }
2285
+ if (!under(realpathSync(cfg.evidence.root), folder))
2286
+ refuse(`its run folder ${rec.run_id} is outside the evidence root`, again);
2287
+ const names = rec.files.map((f) => {
2288
+ let p, bytes;
2289
+ try {
2290
+ p = realpathSync(join(cfg.evidence.root, f.path));
2291
+ bytes = readFileSync(p);
2292
+ }
2293
+ catch {
2294
+ return refuse(`${f.path} is missing`, again);
2295
+ }
2296
+ if (!under(folder, p) || sha256(bytes) !== f.sha256)
2297
+ refuse(`${f.path} left the run folder or changed after the run`, again);
2298
+ return { name: relative(folder, p), media: /\.(png|jpe?g|webp|mp4|webm)$/i.test(p) && f.bytes <= 50_000_000 };
2299
+ });
2300
+ if (!names.length)
2301
+ refuse('the run recorded no captures, so it has no evidence to send', `Read it: done-done status --run ${id}`);
2302
+ const media = names.filter((f) => f.media).slice(0, 12).map((f) => f.name);
2303
+ const shown = names.slice(0, 12).map((f) => f.name).join(', ') + (names.length > 12 ? `, and ${names.length - 12} more in the run folder` : '');
2304
+ const redo = 'Start a run whose --outcome fits: done-done verify --outcome "<what a user can now do>"';
2305
+ msg = {
2306
+ contract: 1, kind: 'evidence', schema: 1, key: `dd-evidence-${id}`, filer: 'done-done',
2307
+ title: text('title', `${cfg.app} ${drove}: verify ${run.result}`, 160), about: text('about (the outcome)', run.claim, 500, redo),
2308
+ ...(media.length && { media }),
2309
+ run: {
2310
+ ...runRef(rec.commit), outcome: text('outcome', run.claim, 1000, redo),
2311
+ repro: text('repro', [`done-done verify`, run.journey ?? (run.feature && `--feature ${run.feature}`), `--outcome "${run.claim}" at ${rec.commit}`].filter(Boolean).join(' ') + (fixed?.branch ? ` on branch ${fixed.branch}` : ''), 1000, redo),
2312
+ proof: text('proof', `${existsSync(join(repo, cfg.skill)) ? basename(cfg.skill) : 'no verify skill'}: done-done verify ${drove}, run ${id}: ${run.result}${run.reason ? ` (${run.reason})` : ''}`, 1000),
2313
+ evidence: text('evidence', shown, 1000),
2314
+ },
2315
+ };
2316
+ file = join(folder, 'signoff-evidence.json');
2317
+ }
2318
+ else
2319
+ return refuse(`it is ${shownStatus(run)}${run.kind === 'verify' ? '' : ` (a ${run.kind} run)`}; signoff writes a gate for a verify run waiting at its gate, or evidence for a finished verify run`, `Read it: done-done status --run ${id}`);
2320
+ writeFileSync(file, `${JSON.stringify(msg, null, 2)}\n`);
2321
+ const at = citable(repo, file);
2322
+ emit({ command: 'signoff', run_id: id, kind: msg.kind, file: at, help: [`Post it to the sign-off board (done-done never posts): npx -y sign-off-axi post --kind ${msg.kind} --from ${arg(at)}`] });
2323
+ return PASS;
2324
+ }
2325
+ function runsOf(repo, cfg) {
2326
+ return runsIn(runsRoot(cfg.app)).filter((r) => r.repo === repo).sort((a, b) => b.created.localeCompare(a.created));
2327
+ }
1584
2328
  function cmdHistory(argv) {
1585
2329
  const { values } = parseArgs({ args: argv, options: { limit: { type: 'string' } } });
1586
2330
  if (values.limit !== undefined && (!/^\d+$/.test(values.limit) || Number(values.limit) <= 0))
@@ -1588,9 +2332,7 @@ function cmdHistory(argv) {
1588
2332
  const n = values.limit === undefined ? Infinity : Number(values.limit);
1589
2333
  const repo = repoRoot();
1590
2334
  const cfg = loadConfig(repo);
1591
- const root = runsRoot(cfg.app);
1592
- const mine = existsSync(root) ? readdirSync(root).filter((x) => existsSync(join(root, x, 'run.json'))).map((x) => loadRun(join(root, x))).filter((r) => r.repo === repo).sort((a, b) => b.created.localeCompare(a.created)) : [];
1593
- const matched = mine.slice(0, n);
2335
+ const matched = runsOf(repo, cfg).slice(0, n);
1594
2336
  const rows = cap(matched.map((r) => ({ run_id: r.run_id, kind: r.kind, status: shownStatus(r), ...(r.result && { result: r.result }), ...(r.kind === 'verify' && { claim: r.claim, journey: r.journey }), created: r.created })));
1595
2337
  emit({ command: 'history', runs: matched.length, history: rows.items, ...(rows.omitted && { history_omitted: rows.omitted }), help: rows.items.length ? [`Read the newest run: done-done status --run ${rows.items[0].run_id}`] : ['Start one: done-done verify --outcome "<what a user can now do>"'] });
1596
2338
  return PASS;
@@ -1611,8 +2353,9 @@ function ensureIgnored(repo, rel) {
1611
2353
  }
1612
2354
  // `init` also scaffolds the project's verify skill from the repo's own files: a port of the
1613
2355
  // fleet create-verification-skill interview (surface, run, drive, observe, isolate).
1614
- // Scaffold only: where only a live drive can fill a blank, a TODO marker stands in place of
1615
- // an invented command. An existing skill is never overwritten, so re-run is idempotent.
2356
+ // Every line is an action: a detected handle gets concrete drive and capture steps, and a blank detection cannot
2357
+ // fill gets the steps to find it and record it there, never an invented command. An existing skill is never
2358
+ // overwritten, so re-run is idempotent.
1616
2359
  function headText(p, max = 12000) {
1617
2360
  try {
1618
2361
  if (!lstatSync(p).isFile())
@@ -1623,6 +2366,8 @@ function headText(p, max = 12000) {
1623
2366
  return '';
1624
2367
  }
1625
2368
  }
2369
+ // Where a drive stops and how it records what it found, for every step that asks it to find something.
2370
+ const RECORD = 'once the drive works, write what you found here in place of this instruction (after the drive: a `done-done verify` drive changes no repo file)';
1626
2371
  const slug = (t) => t.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'feature';
1627
2372
  const SKIP_DIRS = new Set(['node_modules', 'dist', 'build', 'out', 'vendor', 'coverage', 'target', 'venv', '__pycache__', 'bower_components']);
1628
2373
  const SOURCE_FILE = /\.(m?[jt]s|py|go|rb|rs|php)$/;
@@ -1667,60 +2412,86 @@ function detectApp(repo) {
1667
2412
  let text = `${scripts.dev ?? ''}\n${scripts.start ?? ''}\n${make}`;
1668
2413
  for (const f of sourceFiles(repo, 40))
1669
2414
  text += `\n${headText(f, 6000)}`;
1670
- const routes = [...text.matchAll(/\b(app|router|server|api|fastify|routes|r)\.(get|post|put|patch|delete|del|use)\(\s*['"`](\/[^'"`\s]*)/g)]
1671
- .map((m) => `${m[2].toUpperCase()} ${m[3]}`)
2415
+ const routes = [...text.matchAll(/\b(app|router|server|api|fastify|routes|r)\.(get|post|put|patch|delete|del)\(\s*['"`](\/[^'"`\s]*)/g)]
2416
+ .map((m) => `${m[2] === 'del' ? 'DELETE' : m[2].toUpperCase()} ${m[3]}`)
1672
2417
  .filter((r, i, a) => a.indexOf(r) === i);
1673
2418
  const commands = [...text.matchAll(/\.command\(\s*['"`]([^'"`\s\]]+)/g)]
1674
2419
  .map((m) => m[1])
1675
2420
  .filter((c, i, a) => a.indexOf(c) === i);
1676
- const web = has('react', 'next', 'vue', 'nuxt', 'svelte', 'solid', 'vite', 'webpack', 'playwright', '@playwright/test', 'cypress', 'puppeteer');
2421
+ const browserTests = has('playwright', '@playwright/test', 'cypress', 'puppeteer');
2422
+ const web = browserTests || has('react', 'next', 'vue', 'nuxt', 'svelte', 'solid', 'vite', 'webpack');
1677
2423
  const cliish = bins.length > 0 || commands.length > 0 || has('commander', 'yargs', 'oclif', 'cac');
1678
2424
  const api = routes.length > 0 || has('express', 'fastify', 'koa', 'hono');
1679
2425
  const surface = web ? 'web UI' : cliish ? 'CLI' : api ? 'API/service' : 'app (unrecognized surface)';
1680
2426
  const launch = scripts.dev ? `npm run dev (${scripts.dev})`
1681
2427
  : scripts.start ? `npm start (${scripts.start})`
1682
2428
  : bins.length ? `node ${bins[0]} --help`
1683
- : make ? 'TODO(live drive): pick the dev target from the Makefile and record it here'
1684
- : 'TODO(live drive): how the app starts locally (no npm script, bin or Makefile found)';
2429
+ : make ? `Read the Makefile for the target that starts the app (dev, run, serve or start) and start it with \`make <target>\`; ${RECORD}.`
2430
+ : `No start script, bin or Makefile was found: read README.md and the entry file for how the app starts, and start it; ${RECORD}.`;
1685
2431
  const port = `${scripts.dev ?? ''} ${scripts.start ?? ''}`.match(/(?:localhost:|--port[= ])(\d{4,5})/)?.[1];
1686
- const ready = port
1687
- ? `http://localhost:${port} answers (TODO(live drive): confirm the ready log line)`
1688
- : web || api
1689
- ? 'TODO(live drive): the URL and ready log line the server prints'
1690
- : 'TODO(live drive): how to tell the app is ready (log line, port, prompt)';
1691
- const harness = has('playwright', '@playwright/test', 'cypress', 'puppeteer')
1692
- ? 'browser/CDP (Playwright)'
1693
- : cliish ? 'tmux/PTY'
1694
- : api ? 'plain HTTP (curl)'
1695
- : 'TODO(live drive)';
2432
+ const driver = browserTests ? 'browser' : cliish ? 'tty' : api ? 'curl' : web ? 'browser' : 'shell';
2433
+ const ready = port && (driver === 'browser' || driver === 'curl')
2434
+ ? `\`curl -sS -o /dev/null http://localhost:${port}/\` exits 0. BASE below is http://localhost:${port}`
2435
+ : driver === 'browser' || driver === 'curl'
2436
+ ? `the launch output prints the app URL and \`curl -sS -o /dev/null <that URL>\` exits 0. BASE below is that URL; ${RECORD}`
2437
+ : 'the launch command runs with no error (exit 0, or a prompt waiting for input)';
2438
+ const harness = browserTests ? 'browser/CDP (Playwright)' : {
2439
+ tty: 'tmux/PTY',
2440
+ curl: 'plain HTTP (curl)',
2441
+ browser: 'a browser tool (for example chrome-devtools-axi)',
2442
+ shell: 'a shell (run the launch command and read its output)',
2443
+ }[driver];
1696
2444
  const observe = web
1697
- ? 'screenshots of the action and the resulting state, plus the console log'
2445
+ ? 'screenshots of the resulting state the outcome is about'
1698
2446
  : cliish
1699
2447
  ? 'terminal transcripts, exit codes and side effects (files written, rows inserted)'
1700
2448
  : api
1701
2449
  ? 'response bodies, status codes and side effects (rows inserted, messages sent)'
1702
- : 'TODO(live drive): what evidence this surface can capture';
2450
+ : 'screenshots of a page it shows, or its output saved as text when the outcome is text';
1703
2451
  const isolate = web || port
1704
- ? 'TODO(live drive): confirm two instances run side by side (ports, data dirs, profiles); until then drive one instance at a time'
2452
+ ? 'Drive one instance at a time unless .done-done.yaml has an instance block, which gives each run its own port and HOME'
1705
2453
  : 'each run uses its own invocation; never drive a shared session the user owns';
2454
+ const runCli = (args) => bins.length ? `\`node ${bins[0]} ${args}\``
2455
+ : scripts.start ? `\`npm start -- ${args}\``
2456
+ : `the launch command from Launch with \`${args}\``;
2457
+ const shot = (s) => `screenshot the resulting page to $DONE_DONE_EVIDENCE_ROOT/${s}.png`;
2458
+ const saved = (s) => `save the output and exit status to $DONE_DONE_EVIDENCE_ROOT/${s}.txt`;
2459
+ const open = 'open BASE in the browser, take a snapshot and act on the page by its visible labels';
2460
+ const params = 'replace each :param or ${...} in the path with a real value from the handler or seed data';
2461
+ const spends = 'a route that charges money or calls a paid outside service must be declared under `spends:` and never sent without the user approval';
2462
+ const found = [
2463
+ ...(driver === 'browser' || driver === 'curl' ? routes : []).map((r) => {
2464
+ const [method, path] = r.split(' ');
2465
+ const writes = method === 'GET' ? '' : `; ${spends}`;
2466
+ const drive = driver === 'curl' ? `send it with \`curl -sS -X ${method} -w '\\nstatus %{http_code}\\n' "BASE${path}"\`, with the body its handler reads; ${params} (search the source for "${path}")${writes}`
2467
+ : method === 'GET' ? `${open.replace('BASE', `BASE${path}`)}; ${params}`
2468
+ : `find the form or button that sends ${r} (search the source for "${path}"), ${params}, then fill and submit it as a user does${writes}`;
2469
+ return { base: slug(r), title: r, reach: `the ${r} route`, drive };
2470
+ }),
2471
+ ...(driver === 'tty' ? commands : []).map((c) => ({ base: slug(c), title: `\`${c}\` command`, reach: `the CLI run with \`${c}\``, drive: `run ${runCli(`${c} --help`)} to learn its arguments, then ${runCli(`${c} <arguments>`)} as a user does` })),
2472
+ ];
2473
+ const fallback = {
2474
+ browser: { title: 'Main page', reach: 'BASE, the first page a user opens', drive: open },
2475
+ curl: { title: 'Main request', reach: 'the first endpoint README.md shows a client calling', drive: `read README.md and the entry file for the first endpoint a client calls, then send it with \`curl -sS -X <METHOD> -w '\\nstatus %{http_code}\\n' "BASE<path>"\`, with the body its handler reads; ${spends}; ${RECORD}` },
2476
+ tty: { title: 'Main command', reach: 'what README.md says a user runs first', drive: `read README.md for the first command a user runs, then run it with its arguments as a user does; ${RECORD}` },
2477
+ shell: { title: 'Main journey', reach: 'what README.md says a user runs first', drive: `read README.md and the entry file for what a user runs first, then run it in a shell as a user does; ${RECORD}` },
2478
+ }[driver];
2479
+ if (!found.length)
2480
+ found.push({ base: 'main', ...fallback });
1706
2481
  const features = [];
1707
- for (const f of [
1708
- ...routes.map((r) => ({ base: slug(r), title: r, reach: `the ${r} route`, command: false })),
1709
- ...commands.map((c) => ({ base: slug(c), title: `\`${c}\` command`, reach: `the CLI run with \`${c}\``, command: true })),
1710
- ]) {
2482
+ for (const f of found) {
1711
2483
  let s = f.base;
1712
2484
  for (let n = 2; features.some((g) => g.slug === s); n++)
1713
2485
  s = `${f.base}-${n}`;
1714
- features.push({ slug: s, title: f.title, reach: f.reach, command: f.command });
2486
+ features.push({ slug: s, title: f.title, reach: f.reach, drive: f.drive, capture: (driver === 'browser' ? shot : saved)(s) });
1715
2487
  }
1716
- const commandsFound = features.filter((f) => f.command);
1717
- const shown = [...features.filter((f) => !f.command).slice(0, commandsFound.length ? 4 : 5), ...commandsFound].slice(0, 5);
2488
+ const shown = features.slice(0, 5);
1718
2489
  return { surface, launch, ready, harness, observe, isolate, features: shown, omitted: features.length - shown.length };
1719
2490
  }
1720
2491
  function verifySkillMd(app, d) {
1721
2492
  const name = `verify-${app}`;
1722
2493
  const found = [
1723
- ...(d.features.length ? d.features.map((f) => `- ${f.title}: ${f.reach}`) : ['- TODO(live drive): no routes or commands found; map the first feature on the first live drive']),
2494
+ ...d.features.map((f) => `- ${f.title}: ${f.reach}`),
1724
2495
  ...(d.omitted ? [`- ${d.omitted} more found and not listed; add them by hand or run maintain later.`] : []),
1725
2496
  ];
1726
2497
  return `---
@@ -1730,13 +2501,14 @@ description: Drive the ${app} ${d.surface} the way a user does and capture proof
1730
2501
 
1731
2502
  # ${name}
1732
2503
 
1733
- Scaffolded by \`done-done init\` from this repo's own files (surface: ${d.surface}). Lines marked TODO need one live drive before they can be trusted.
2504
+ Scaffolded by \`done-done init\` from this repo's own files (surface: ${d.surface}). Where a step says to find something, the first drive finds it and records it there.
1734
2505
 
1735
2506
  ## Launch
1736
2507
 
1737
2508
  ${d.launch}
1738
2509
 
1739
2510
  Ready when ${d.ready}. Tear down what you started; never kill by process name.
2511
+ When the repo's .done-done.yaml has an instance block, done-done already runs the app at $DONE_DONE_BASE_URL (its own port and HOME): BASE is then that URL, never a hard-coded port.
1740
2512
 
1741
2513
  ## Doctor
1742
2514
 
@@ -1750,7 +2522,14 @@ ${found.join('\n')}
1750
2522
 
1751
2523
  ## Evidence
1752
2524
 
1753
- Capture ${d.observe}. Exercise the real user path, not internal setters; capture the action and the resulting state, not just the final screen; verify side effects alongside what is visible. Evidence goes to the run folder \`done-done run\` prints.
2525
+ Capture ${d.observe}. Exercise the real user path, not internal setters; done-done's judge checks every capture in the run folder against the outcome, so save only captures of the resulting state there and keep any of earlier steps outside it; verify side effects alongside what is visible. Evidence goes to the run folder \`done-done run\` prints. Every drive uses these done-done primitives:
2526
+
2527
+ - Matrix: when .done-done.yaml sets a matrix block, each journey runs once per theme x width x form cell with DONE_DONE_THEME, DONE_DONE_WIDTH, DONE_DONE_FORM and DONE_DONE_CELL set: name each capture with its cell suffix, so every cell is captured or skipped with a reason, never silently missing.
2528
+ - DOM: when .done-done.yaml sets a dom block, write next to each capture its page-check record, the capture's name with .dom.json for its extension (home.dark.390w.png -> home.dark.390w.dom.json): ${DOM_RECORD}.
2529
+ - Text: for a text outcome, save the CLI output, log or JSON itself as a .txt, .log or .json capture; the check needs it to hold text and JSON to parse.
2530
+ - Secrets: never write a secret (key, token, password, Bearer header) into evidence or logs; verify scans them and fails the run on a hit.
2531
+ - Spends: a journey with \`spends:\` runs only after the user approves the spend at verify's gate; never run it another way.
2532
+ - Before/after: with \`base_ref\` set, verify also drives the base ref into the run folder's before/; save the same captures under the same names there, so each pairs with its base capture.
1754
2533
 
1755
2534
  ## Cleanup
1756
2535
 
@@ -1758,12 +2537,10 @@ Stop the instances this run started and remove scratch state. Evidence survives
1758
2537
  `;
1759
2538
  }
1760
2539
  function verifyFeatureMd(app, d, f) {
1761
- return `# ${f.title}\n\nPart of the ${app} verification map; reached at ${f.reach}.\n\n## Sub-features\n\nTODO(live drive): list what this covers.\n\n## How to get to it (user POV)\n\n${f.reach}.\n\n## Driving it with ${d.harness}\n\nTODO(live drive): the exact selectors or commands that drive it.\n\n## Gotchas\n\nTODO(live drive): what surprised the first live drive.\n`;
2540
+ return `# ${f.title}\n\nPart of the ${app} verification map; reached at ${f.reach}.\n\n## Sub-features\n\nList each thing a user can do here; ${RECORD}.\n\n## How to get to it (user POV)\n\n${f.reach}.\n\n## Driving it with ${d.harness}\n\n1. Launch the app as SKILL.md says and wait until it is ready.\n2. ${f.drive[0].toUpperCase()}${f.drive.slice(1)}.\n3. Capture: ${f.capture}, as SKILL.md's Evidence section says.\n4. Stop what you started.\n\n## Gotchas\n\nWrite here what surprised a drive (a slow load, a login wall, a label that moved) and what worked instead, after the drive.\n`;
1762
2541
  }
1763
2542
  function verifyFeaturesMd(app, d) {
1764
- const index = d.features.length
1765
- ? d.features.map((f) => `- [${f.title}](${f.slug}.md): ${f.reach}`).join('\n')
1766
- : 'TODO(live drive): no features found in source; map the first one here.';
2543
+ const index = d.features.map((f) => `- [${f.title}](${f.slug}.md): ${f.reach}`).join('\n');
1767
2544
  const more = d.omitted ? `\n\n${d.omitted} more found and not listed; add them by hand or run maintain later.` : '';
1768
2545
  return `# ${app} features\n\nThe repo's maintained verification map; a proof that drives one entry point is incomplete when this lists others.\n\n${index}${more}\n`;
1769
2546
  }
@@ -1950,7 +2727,7 @@ function writeGlobal() {
1950
2727
  }
1951
2728
  // The user's agent choice, checked before anything is written.
1952
2729
  function checkChoice(agent, model) {
1953
- if (!isAgent(agent) || !supported(agent))
2730
+ if (!isAgent(agent))
1954
2731
  throw new UsageError(`done-done cannot start ${agent}; it starts ${agentChoices().join(', ')}`, noAgent().help);
1955
2732
  if (agent === 'opencode' && model && !model.includes('/'))
1956
2733
  throw new UsageError(`--model ${model}: an opencode model is provider/model, e.g. ${CHEAP.opencode.model}`);
@@ -1984,30 +2761,61 @@ function saveChoice(kind, { agent, model }) {
1984
2761
  renameSync(`${file}.tmp`, file);
1985
2762
  return `${kind === 'agent' ? agent : routeOf(agent)}${model ? ` (model ${model})` : ''} saved to ${file}`;
1986
2763
  }
2764
+ // Why a typed model is not one opencode lists, or '' when it is listed or unchecked (only opencode is checked; pi's list comes back cut short on a pipe).
2765
+ function unknownModel(agent, model, cfg) {
2766
+ if (agent !== 'opencode')
2767
+ return '';
2768
+ const bin = agentPath(agent, cfg);
2769
+ if (!bin) {
2770
+ process.stderr.write(`${model} is saved unchecked because ${agent} is not on PATH; run 'done-done doctor --probe' to check it\n`);
2771
+ return '';
2772
+ }
2773
+ const r = spawnSync(bin, ['models'], { encoding: 'utf8', timeout: 30_000 });
2774
+ if (r.status !== 0) {
2775
+ process.stderr.write(`${agent} models failed (${(r.error?.message || r.stderr || `exit ${r.status}`).trim().split('\n').at(-1)}), so ${model} is saved unchecked\n`);
2776
+ return '';
2777
+ }
2778
+ // opencode prints one provider/model per line.
2779
+ return r.stdout.split('\n').map((l) => l.trim()).includes(model) ? '' : `${model} is not a model ${agent} lists (${agent} models)`;
2780
+ }
1987
2781
  const interactive = () => !!process.stdin.isTTY && !!process.stderr.isTTY;
1988
2782
  const missing = (kind) => (kind === 'agent' ? noAgent() : noJudge());
1989
2783
  // In a terminal the user picks the agent (or the judge) and its model from a list; done-done never picks for them.
1990
2784
  async function pick(kind, cfg) {
1991
2785
  const rl = createInterface({ input: process.stdin, output: process.stderr });
1992
- const closed = new AbortController();
1993
- rl.on('close', () => closed.abort());
1994
- const ask = async (q) => (await rl.question(q, { signal: closed.signal })).trim();
2786
+ // Lines are read in order, so an answer typed while done-done checks a model list is kept, not dropped.
2787
+ // ponytail: each pick has its own interface, so answers typed ahead past the agent list into the judge list are dropped; share one interface if that matters.
2788
+ const lines = rl[Symbol.asyncIterator]();
2789
+ const ask = async (q) => {
2790
+ rl.setPrompt(q);
2791
+ rl.prompt();
2792
+ const line = await lines.next();
2793
+ if (line.done)
2794
+ throw new Error('the terminal closed');
2795
+ return line.value.trim();
2796
+ };
1995
2797
  try {
1996
- const menu = kind === 'agent' ? [...AGENTS.filter(supported), ACP_TARGET] : JUDGES;
2798
+ const menu = kind === 'agent' ? [...AGENTS, ACP_TARGET] : JUDGES;
1997
2799
  const label = (a) => (a === ACP_TARGET ? `${a} (an agent your acpx bridge starts)` : a);
1998
- const head = kind === 'agent' ? 'done-done starts a coding agent you choose, and none is set. Agents:' : `done-done judges each screenshot with an agent you choose, never the one that did the work (${cfg.agent || 'not set'}), and none is set. Judges:`;
2800
+ const doer = cfg.agent ? `the one that did the work (${cfg.agent})` : 'the agent that does the work';
2801
+ const head = kind === 'agent' ? 'done-done starts a coding agent you choose, and none is set. Agents:' : `done-done judges each capture with an agent you choose, never ${doer}, and none is set. Judges:`;
1999
2802
  process.stderr.write(`${head}\n${menu.map((a, i) => ` ${i + 1}) ${label(a)}\n`).join('')}`);
2000
2803
  for (;;) {
2001
2804
  let agent = '';
2002
2805
  while (!agent) {
2003
- const n = Number(await ask(`${kind} [1-${menu.length}]: `));
2004
- if (!Number.isInteger(n) || n < 1 || n > menu.length)
2806
+ const said = await ask(`${kind} [1-${menu.length}]: `);
2807
+ const n = Number(said);
2808
+ if (!Number.isInteger(n) || n < 1 || n > menu.length) {
2809
+ process.stderr.write(`${said || 'nothing'} is not on the list; type a number from 1 to ${menu.length}\n`);
2005
2810
  continue;
2811
+ }
2006
2812
  agent = menu[n - 1];
2007
2813
  if (agent !== ACP_TARGET)
2008
2814
  continue;
2009
2815
  const target = await ask('acp target, e.g. claude-code: ');
2010
2816
  agent = isAgent(`acp:${target}`) ? `acp:${target}` : '';
2817
+ if (!agent)
2818
+ process.stderr.write(`${target || 'nothing'} is not an acp target name; pick again\n`);
2011
2819
  }
2012
2820
  const cheap = CHEAP[agent]?.model;
2013
2821
  const who = kind === 'agent' ? agent : `the ${agent} judge`;
@@ -2022,11 +2830,20 @@ async function pick(kind, cfg) {
2022
2830
  const n = typed ? '2' : await ask('model [1-2]: ');
2023
2831
  if (n === '1')
2024
2832
  model = cheap ?? '';
2025
- else if (n === '2') {
2026
- const typedModel = await ask('model: ');
2027
- if (typedModel && (agent !== 'opencode' || typedModel.includes('/')))
2028
- model = typedModel;
2029
- }
2833
+ else if (n === '2')
2834
+ while (model === undefined) {
2835
+ const typedModel = await ask('model: ');
2836
+ const why = !typedModel ? 'no model typed' : agent === 'opencode' && !typedModel.includes('/') ? `${typedModel} is not provider/model` : unknownModel(agent, typedModel, cfg);
2837
+ if (why)
2838
+ process.stderr.write(`${why}; type it again\n`);
2839
+ else {
2840
+ model = typedModel;
2841
+ if (agent !== 'opencode')
2842
+ process.stderr.write(`the model was not checked because ${agent} has no model list; run 'done-done doctor --probe' to check it\n`);
2843
+ }
2844
+ }
2845
+ else
2846
+ process.stderr.write(`${n || 'nothing'} is not 1 or 2\n`);
2030
2847
  }
2031
2848
  if (kind === 'agent')
2032
2849
  return { agent, model };
@@ -2367,8 +3184,6 @@ async function cmdDoctor(argv) {
2367
3184
  }
2368
3185
  }
2369
3186
  const agent = agentCfg?.agent ? selectAgent(agentCfg) : null;
2370
- const startableList = AGENTS.filter(supported);
2371
- const startable = startableList.length > 1 ? `${startableList.slice(0, -1).join(', ')} and ${startableList.at(-1)}` : (startableList[0] ?? 'no agent');
2372
3187
  // An acp:<target> agent is missing when its acpx bridge is: say which, and how to install it.
2373
3188
  const acpx = agent?.agent.startsWith('acp:') ? findBin(agentCfg.acpx_path) : undefined;
2374
3189
  const override = agent && agentCfg.agent_path_override[agent.agent];
@@ -2376,7 +3191,6 @@ async function cmdDoctor(argv) {
2376
3191
  : notStartable(agent?.agent ?? '', override || '');
2377
3192
  const askFix = `ask the user which agent to use and save it: done-done init --agent <agent> --model <model>`;
2378
3193
  const installFix = acpx === undefined ? `Install ${agent?.agent}, or ${askFix}` : ACPX_INSTALL;
2379
- const switchFix = `done-done starts ${startable}: ${askFix}`;
2380
3194
  if (repoFileInvalid) {
2381
3195
  checks.push(unknownRow('agent'));
2382
3196
  }
@@ -2390,27 +3204,26 @@ async function cmdDoctor(argv) {
2390
3204
  const what = `${agent.agent} (${modelPhrase(agent.agent, agent.model)}; from config)`;
2391
3205
  checks.push(!agent.path
2392
3206
  ? { check: 'agent', status: 'missing', detail: `${what}: ${notFound}`, fix: installFix }
2393
- : !supported(agent.agent)
2394
- ? { check: 'agent', status: 'missing', detail: `${what} at ${agent.path}, but done-done cannot start ${agent.agent} yet (${startable} for now)`, fix: switchFix }
2395
- : { check: 'agent', status: 'ok', detail: `${what} at ${agent.path}`, fix: '' });
3207
+ : { check: 'agent', status: 'ok', detail: `${what} at ${agent.path}`, fix: '' });
2396
3208
  if (agent.agent === 'pi')
2397
3209
  checks.push({ check: 'tool_gate', status: 'warning', detail: 'pi has no permission gate: every pi tool runs without approval', fix: '' });
3210
+ // codex's sandbox covers its shell too, so a drive's recipe tool writes only where the drive may.
3211
+ if (agent.agent === 'codex')
3212
+ checks.push({ check: 'tool_gate', status: 'warning', detail: "codex's drive sandbox lets its commands write only the repo, the run's evidence and scratch folders, $TMPDIR and /tmp; a tool that writes elsewhere needs its folder added with agent_args_override.codex: [--add-dir, <folder>] (see docs/reference.md, Drive by agent)", fix: '' });
2398
3213
  }
2399
3214
  // The judge the user set (judge.route), kept apart from the agent.
2400
3215
  let choice;
2401
3216
  let choiceError;
2402
- if (cfg && agent)
3217
+ if (cfg)
2403
3218
  try {
2404
- choice = selectJudge(cfg, agent);
3219
+ choice = selectJudge(cfg, agent ?? NO_AUTHOR);
2405
3220
  }
2406
3221
  catch (e) {
2407
3222
  choiceError = e;
2408
3223
  }
2409
- // With no agent set the judge is unknown too: it is kept apart from that agent.
2410
- const noAuthorRow = (check) => (cfg && agentCfg && !agent ? unknownRow(check, 'unknown until the agent is set, since the judge is kept apart from it', noAgent().help) : unknownRow(check));
2411
3224
  const choiceRow = (check) => ({ check, status: 'missing', detail: choiceError.message, fix: choiceError.help ?? 'Fix the judge config, then re-run: done-done doctor' });
2412
- if (!cfg || !agent) {
2413
- checks.push(noAuthorRow('judge'));
3225
+ if (!cfg) {
3226
+ checks.push(unknownRow('judge'));
2414
3227
  }
2415
3228
  else if (!choice) {
2416
3229
  checks.push(choiceRow('judge'));
@@ -2418,7 +3231,7 @@ async function cmdDoctor(argv) {
2418
3231
  else {
2419
3232
  try {
2420
3233
  const { detail } = await backend(cfg, choice);
2421
- checks.push({ check: 'judge', status: 'ok', detail: `${choice.route} (model ${choice.model}): ${detail}; the model is never called`, fix: '' });
3234
+ checks.push({ check: 'judge', status: 'ok', detail: `${choice.route} (model ${choice.model}): ${detail}; doctor does not call the model`, fix: '' });
2422
3235
  }
2423
3236
  catch (e) {
2424
3237
  checks.push({ check: 'judge', status: 'missing', detail: e.message, fix: e.help ?? 'Re-run done-done doctor after fixing the error above' });
@@ -2431,7 +3244,7 @@ async function cmdDoctor(argv) {
2431
3244
  // How far the judge is from the author, as selectJudge picks it: another agent is ok; the author's agent at another
2432
3245
  // model or in a fresh session runs too, so it is a warning that never fails doctor.
2433
3246
  if (!cfg || !agent) {
2434
- checks.push(noAuthorRow('judge_choice'));
3247
+ checks.push(unknownRow('judge_choice', cfg && agentCfg ? 'unknown until the agent is set, since the judge is kept apart from it' : undefined));
2435
3248
  }
2436
3249
  else if (!choice) {
2437
3250
  checks.push(choiceRow('judge_choice'));
@@ -2449,11 +3262,13 @@ async function cmdDoctor(argv) {
2449
3262
  }
2450
3263
  if (values.probe && !agent)
2451
3264
  checks.push(unknownRow('probe'));
2452
- if (values.probe && agent) {
3265
+ const toolless = agent?.path ? editOnlyReason(agent.agent) : '';
3266
+ if (values.probe && agent && toolless)
3267
+ checks.push({ check: 'probe', status: 'warning', detail: `${toolless}; ${agent.agent} is not probed, its edit steps run through verify`, fix: '' });
3268
+ else if (values.probe && agent) {
2453
3269
  const record = join(home(), 'agents', `probe-${new Date().toISOString().replace(/[:.]/g, '-')}-${agent.agent}.json`);
2454
3270
  const probe = !agent.path ? { ok: false, error: notFound, fix: installFix }
2455
- : !supported(agent.agent) ? { ok: false, error: `done-done cannot start ${agent.agent} yet (${startable} for now)`, fix: switchFix }
2456
- : await askAgent({ ...agent, prompt: 'This is a connectivity probe from done-done doctor. Use no tools. Answer with ok set to true.', schema: PROBE_SCHEMA, cwd: repo ?? process.cwd(), tools: 'none', record });
3271
+ : await askAgent({ ...agent, prompt: 'This is a connectivity probe from done-done doctor. Use no tools. Answer with ok set to true.', schema: PROBE_SCHEMA, cwd: repo ?? process.cwd(), tools: 'none', record });
2457
3272
  checks.push(probe.ok && probe.answer?.ok === true
2458
3273
  ? { check: 'probe', status: 'ok', detail: `${agent.agent} (${modelPhrase(agent.agent, agent.model)}) answered the schema question; ${probe.recordError ? `the call record could not be written: ${probe.recordError}` : `record ${record}`}`, fix: '' }
2459
3274
  : { check: 'probe', status: 'missing', detail: `${agent.agent} did not answer: ${probe.error ?? `answer ${JSON.stringify(probe.answer)}`}${probe.record ? `; record ${record}` : ''}${probe.recordError ? `; the call record could not be written: ${probe.recordError}` : ''}`, fix: probe.fix ?? `Check that ${agent.agent} is signed in${agent.model ? ` and can use model ${agent.model}` : ''}, or set agent_config.${agent.agent}.model` });
@@ -2512,7 +3327,7 @@ async function cmdDesign(argv) {
2512
3327
  repo = process.cwd();
2513
3328
  }
2514
3329
  const cfg = loadConfig(repo);
2515
- const choice = await preflightJudge(cfg);
3330
+ const choice = await preflightJudge(cfg, false);
2516
3331
  const judged = await design(cfg, choice, screens, join(home(), 'agents'));
2517
3332
  const count = (v) => judged.filter((j) => j.verdict === v).length;
2518
3333
  const code = count('fail') ? FAIL : count('unsure') ? INCONCLUSIVE : PASS;
@@ -2522,7 +3337,7 @@ async function cmdDesign(argv) {
2522
3337
  emit({
2523
3338
  command: 'design',
2524
3339
  result: code === PASS ? 'pass' : code === FAIL ? 'fail' : 'inconclusive',
2525
- judge: { route: choice.route, model: choice.model, independence: choice.independence, author: `${choice.author.agent} (model ${choice.author.model || 'none'})` },
3340
+ judge: { route: choice.route, model: choice.model, independence: choice.independence, author: choice.author.agent ? `${choice.author.agent} (model ${choice.author.model || 'none'})` : 'no agent set' },
2526
3341
  counts: { pass: count('pass'), fail: count('fail'), unsure: count('unsure') },
2527
3342
  screens: rows.items,
2528
3343
  ...(rows.omitted && { screens_omitted: rows.omitted }),
@@ -2552,12 +3367,16 @@ function cmdBare() {
2552
3367
  app: cfg.app,
2553
3368
  evidence_root: shown(repo, cfg.evidence.root),
2554
3369
  journeys,
2555
- runs: loadRecords(cfg).length,
3370
+ runs: runsOf(repo, cfg).length,
2556
3371
  };
2557
3372
  const check = '`done-done check --outcome "<what you proved>" <files it recorded>`';
2558
- result.help = journeys.length
2559
- ? [`Capture proof with \`done-done run ${journeys[0]}\`, then check it with ${check}`]
2560
- : [...driveHint(repo, cfg), `Add a journey to .done-done.yaml (${journeyExample}), commit, then \`done-done run smoke\` and ${check}`];
3373
+ const receipt = verifyReceipt(repo, cfg, 'HEAD');
3374
+ result.receipt = receipt.code === PASS ? `valid for HEAD ${String(receipt.result.head).slice(0, 12)}` : receipt.result.reason ?? receipt.result.result;
3375
+ result.help = receipt.code === PASS
3376
+ ? [`HEAD has a valid receipt for "${receipt.result.claim}"; prove the next commit with done-done verify --outcome "<what a user can now do>"`]
3377
+ : journeys.length
3378
+ ? [`Capture proof with \`done-done run ${journeys[0]}\`, then check it with ${check}`]
3379
+ : [...driveHint(repo, cfg), `Add a journey to .done-done.yaml (${journeyExample}), commit, then \`done-done run smoke\` and ${check}`];
2561
3380
  emit(result);
2562
3381
  return PASS;
2563
3382
  }
@@ -2581,13 +3400,13 @@ async function main(argv) {
2581
3400
  }
2582
3401
  try {
2583
3402
  if (process.env.DONE_DONE_DRIVE && DRIVE_REFUSED.includes(cmd))
2584
- throw new UsageError(`${cmd} cannot run inside an agent drive: the agent that did the work never answers a gate, writes a receipt or starts a run`, 'Stop the drive and report what you captured; done-done answers the run and writes the receipt');
3403
+ throw new UsageError(`${cmd} refuses to run inside an agent drive (DONE_DONE_DRIVE is set), so the agent does not answer its own gate, write a receipt or start a run by accident; this guards against accidents and is not a sandbox`, 'Stop the drive and report what you captured; done-done answers the run and writes the receipt');
2585
3404
  if (cmd === 'init')
2586
3405
  return await cmdInit(rest);
2587
3406
  if (cmd === 'doctor')
2588
3407
  return await cmdDoctor(rest);
2589
3408
  if (cmd === 'run')
2590
- return cmdRun(rest);
3409
+ return await cmdRun(rest);
2591
3410
  if (cmd === 'check')
2592
3411
  return await cmdCheck(rest);
2593
3412
  if (cmd === 'receipt')
@@ -2609,13 +3428,19 @@ async function main(argv) {
2609
3428
  if (cmd === 'abort')
2610
3429
  return cmdAbort(rest);
2611
3430
  if (cmd === 'respond')
2612
- return cmdRespond(rest);
3431
+ return await cmdRespond(rest);
2613
3432
  if (cmd === 'history')
2614
3433
  return cmdHistory(rest);
3434
+ if (cmd === 'signoff')
3435
+ return cmdSignoff(rest);
2615
3436
  if (cmd === '__runner') {
2616
3437
  await runSteps(rest[0]);
2617
3438
  return PASS;
2618
3439
  }
3440
+ if (cmd === '--version') {
3441
+ console.log(JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version);
3442
+ return PASS;
3443
+ }
2619
3444
  if (cmd === '--help' || cmd === '-h' || cmd === 'help') {
2620
3445
  console.log(USAGE_TEXT);
2621
3446
  return PASS;