done-done-axi 0.3.2 → 0.4.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,7 +1,7 @@
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, cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
4
+ import { accessSync, appendFileSync, closeSync, constants, cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readdirSync, readFileSync, realpathSync, renameSync, rmdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
5
5
  import { homedir, tmpdir, userInfo } from 'node:os';
6
6
  import { basename, dirname, isAbsolute, join, matchesGlob, relative, resolve, sep } from 'node:path';
7
7
  import { fileURLToPath, pathToFileURL } from 'node:url';
@@ -9,11 +9,11 @@ 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, agentPath, agentRun, askAgent, editOnlyReason, findBin, JUDGES, modelFor, noAgent, noJudge, notStartable, routeOf, selectAgent, selectJudge } from './agents.js';
12
+ import { ACPX_INSTALL, agentChoices, agentPath, agentRun, askAgent, editOnlyReason, findBin, JUDGES, modelFor, noAgent, noJudge, notStartable, routeOf, selectAgent, selectJudge, toollessReason } from './agents.js';
13
13
  import { AGENTS, CHEAP, globalConfigText, home, inputPattern, isAgent, loadConfig, parseDuration, UsageError } from './config.js';
14
14
  import { backend, judge, judgeable, onPath, quote, screen } from './judge.js';
15
15
  import { startInstance, stopInstance } from './instance.js';
16
- import { attemptProblem, changedSince, commitFix, dirtyTracked, FIX_SCHEMA, inWorktree, maintainSteps, named, pathspec, repoState, triage } from './maintain.js';
16
+ import { attemptProblem, changedSince, commitFix, dirtyTracked, FIX_SCHEMA, inWorktree, maintainSteps, named, pathspec, readerProblem, repoState, triage } from './maintain.js';
17
17
  import { cellEnv, cellName, cellSuffix, hasMatrix, matrixManifest, splitMatrix } from './matrix.js';
18
18
  import { design, KINDS } from './design.js';
19
19
  import { checkMedia } from './media.js';
@@ -37,7 +37,7 @@ const CHECKS_TEXT = `Checks block (--from): the newest "## Checks" heading, then
37
37
  evidence: <the evidence file paths, or "not proven: <cause>">`;
38
38
  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,
39
39
  agents/<n>-<name>.json for each agent call) and goes on when the command that started it ends;
40
- verify steps: resolve, drive, check, judge (skipped with --no-judge), triage and fix (only with auto_fix.verify above 0), gate, receipt, report;
40
+ verify steps: resolve, drive, check, judge (always; skipped only after a failed check), triage and fix (only with auto_fix.verify above 0), gate, receipt, report;
41
41
  maintain steps: locate, hygiene, source-wave, reconcile, triage and fix (only with auto_fix.maintain above 0), report
42
42
  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
43
43
  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
@@ -45,9 +45,8 @@ const RUNS_TEXT = `runs: each verify or maintain run lives in $DONE_DONE_HOME/ru
45
45
  inputs (.done-done.yaml): repo-relative paths or globs the drive must leave alone (fixtures, data files);
46
46
  hashed before and after the drive; the check step fails naming each changed, appeared or
47
47
  disappeared path (never file contents), and the before/after hash lists go into the run record; no key: unchanged
48
- 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
49
- respond: approve or skip (each needs --reason) ends the run overridden, never pass, and its receipt says who and why, so
50
- receipt --verify refuses it; fix ends the run fail
48
+ gate: a judge fail or unsure (or an agent drive with no judged capture) parks the run as awaiting_approval (exit 4); nothing moves it
49
+ past the judge: respond --action fix ends it fail, with no receipt (approve and skip answer only a spend gate)
51
50
  auto_fix: { verify: <n> } (config; 0, the default, reports only): the judge sorts each wrong screen (fix or ask; an ask, an unsure
52
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
53
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>
@@ -58,31 +57,32 @@ const MAINTAIN_TEXT = `maintain reads the verify skill and the source; HEAD, git
58
57
  source-wave: one fresh read-only reader per feature file at the configured agent (agent, agent_config) explains it from source
59
58
  reconcile: each file:line the skill or a reader cites must exist (a bare file name matches a tracked file); one tool-less call
60
59
  merges the recipes and names recently changed source (git log, 30 days) that no feature covers. Result: clean (exit 0),
61
- drift with findings (exit 1), or blocked (no skill, a reader that did not answer, a repo that changed, or no agent: the
62
- readers and the merge call do not run, the plain checks still do; exit 3)
60
+ drift with findings (exit 1), or blocked (no skill, a reader that did not answer, or a repo that changed; exit 3). maintain
61
+ never runs without its agent: none set, or one that cannot start or cannot read with its tools off, stops it first (exit 2)
63
62
  auto_fix: { maintain: <n> } (config, as no-mistakes; 0, the default, reports only) lets done-done fix drift up to n times:
64
- triage: the judge the user set (never the agent that fixes) sorts each drift finding: the skill is out of date, or the product
63
+ triage: the judge the user set sorts each drift finding: the skill is out of date, or the product
65
64
  may be wrong; a product finding is ask-user, so the run ends blocked and the user decides (maintain never edits product code)
66
65
  fix: the agent the user set edits only the verify skill, in a done-done worktree at HEAD; a change outside the skill folder,
67
66
  a commit by the agent or a change to your checkout ends it blocked, and the worktree is dropped. maintain then runs again there.
68
67
  Result: changed (a clean re-run; one commit of the skill folder on a done-done/maintain-<run-id> branch, your branch and checkout
69
68
  untouched; exit 0), or blocked (ask-user findings, a guard, or every attempt used, with the findings that remain; no branch)`;
70
69
  const DOCTOR_TEXT = `doctor changes nothing except the --probe call record (the agent itself may keep its own session files) and exits 0 when every required item is ok, 2 otherwise.
71
- required: git (inside a repository), config (a valid .done-done.yaml), evidence_root (writable).
72
- optional: verify_skill (.agents/skills/verify-<app>/), agent (the agent the user set and its model; missing when none is
73
- set, with the command that saves the user's choice), agent_settings (agent, model, effort,
74
- 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
75
- executable claude binary and the judge kit (its sign-in is checked only when a capture is judged), for answerer:<agent> that agent's CLI and the kit, for openai:key
76
- 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),
70
+ required: git (inside a repository), config (a valid .done-done.yaml), evidence_root (writable), agent (the agent the user
71
+ set and its model; missing when none is set, with the command that saves the user's choice), judge (the judge route is ready:
72
+ for claude-code an executable claude binary and the judge kit, for answerer:<agent> that agent's CLI and the
73
+ kit, for openai:key OPENAI_API_KEY and the kit; doctor does not call the model; missing when none is set, with the command
74
+ that saves the user's choice).
75
+ also listed: verify_skill (.agents/skills/verify-<app>/), agent_settings (agent, model, effort,
76
+ args, path and agent_timeout, each with the file it came from: the global config, .done-done.yaml or default),
77
77
  judge_choice (the judge, and how far it is from the author: another agent, another model, or session-only), ffprobe (video evidence only).
78
78
  --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
79
79
  $DONE_DONE_HOME/agents/) and then requires a valid answer.
80
- doctor lists every check; --full changes nothing. Missing optionals never fail the run.`;
80
+ doctor lists every check; --full changes nothing. A missing verify_skill or ffprobe, or a warning, never fails it.`;
81
81
  const CONFIG_TEXT = `config: ~/.done-done/config.yaml (global; DONE_DONE_HOME moves it) and .done-done.yaml (the repo), repo over global.
82
82
  agent (either file): claude, codex, opencode, pi, gemini or acp:<target>, set by the user (done-done init --agent); done-done never picks one.
83
83
  global only: agent_config.<agent>.{model,effort} (effort: minimal, low, medium, high, xhigh, max), agent_args_override.<agent>
84
84
  (extra flags), agent_path_override.<agent> (binary), acpx_path, agent_timeout (one agent call, default 30m; a streaming agent that prints nothing for 5m is stopped early),
85
- evidence.local_root, judge.{route,model,allow_same} (the judge, set by the user with
85
+ evidence.local_root, judge.{route,model,floor} (the judge and shared confidence floor, set by the user with
86
86
  init --judge; never picked). done-done doctor shows where each agent setting came from.`;
87
87
  // One table drives `--help` and every `<command> --help`: each command's usage lines and the flags it takes.
88
88
  const FLAGS = {
@@ -95,8 +95,6 @@ const FLAGS = {
95
95
  '--trusted-ref <ref>': 'read .done-done.yaml from this git ref instead of the working tree (use in CI)',
96
96
  '--citation-base <dir>': 'resolve evidence citations against this directory (default: cwd); absolute citations stay unchanged; does not change repo, config or run storage',
97
97
  '--claim-id <id>': 'the claim the evidence proves; evidence already used for another claim id is reuse (default: the current branch, or the PR branch with --from <PR URL>; required on a detached HEAD)',
98
- '--judge': 'the judge the user set (judge.route, never the agent and model that did the work) says whether each PNG/JPEG capture shows the outcome (verify does this by default; check and receipt only with --judge or judge.enabled: true)',
99
- '--no-judge': 'skip the judge, only when the user chooses to (or judge.enabled: false in .done-done.yaml)',
100
98
  '--verify <head>': 'read-only: verify the receipt recorded for <head>',
101
99
  '--outcome "<what a user can now do>"': 'the outcome the run must prove',
102
100
  '--wait': 'wait until the run ends (the default)',
@@ -107,13 +105,13 @@ const FLAGS = {
107
105
  '--mock <screen>=<mock>': 'judge this screen next to its approved mock (repeat per screen; the screen is judged even when not listed)',
108
106
  '--step <name>': 'the step log to show (verify: resolve, drive, check, judge, gate, receipt, report; maintain: locate, hygiene, source-wave, reconcile, triage, fix, report)',
109
107
  '--feature <name>': 'with no journey, the author agent drives this feature of the verify skill (features/<name>.md); default: the only feature, else the agent picks the one the outcome is about',
110
- '--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)',
111
- '--reason "<why>"': 'why (required for approve and skip); kept in the run and its receipt with who answered',
108
+ '--action <approve|skip|fix>': 'at a spend gate, approve: spend and run the journey; skip: end the run with nothing spent. At the judge gate only fix: end it fail, then fix the product or the verify skill (done-done maintain checks it against the source)',
109
+ '--reason "<why>"': 'why (required for approve and skip at a spend gate); kept in the run with who answered',
112
110
  '--limit <n>': 'how many past runs to list (default all)',
113
111
  '--out <file.png>': 'where look writes the screenshot; its page-check record goes next to it as <file>.dom.json (default: look.png in $DONE_DONE_EVIDENCE_ROOT inside a journey, else in the current folder)',
114
112
  '--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',
115
113
  '--model <model>': 'with --agent: that agent\'s model, saved as agent_config.<agent>.model (opencode: provider/model)',
116
- '--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`,
114
+ '--judge <agent>': `the judge done-done starts (${JUDGES.join(' or ')}), saved as judge.route in ~/.done-done/config.yaml (any agent and model, the author's own too); without it, init asks in a terminal`,
117
115
  '--judge-model <model>': 'with --judge <agent>: the judge\'s model, saved as judge.model',
118
116
  '--hook <agent>': 'also add a SessionStart hook to .claude/settings.json (claude), .codex/hooks.json (codex) or .gemini/settings.json (gemini) that shows `done-done` to each session, or a plugin to .opencode/plugins (opencode) or an extension to .pi/extensions (pi) that adds it to the first message of each session (opt-in)',
119
117
  };
@@ -122,9 +120,9 @@ const COMMANDS = {
122
120
  init: {
123
121
  usage: [
124
122
  ['done-done init', 'write a minimal .done-done.yaml (evidence lives outside the repo) and, when absent, a commented ~/.done-done/config.yaml'],
125
- ['done-done init', 'scaffold .agents/skills/verify-<app> from this repo (never overwrites)'],
123
+ ['done-done init', 'have the agent write .agents/skills/verify-<app> from this repo\'s own proof paths (never overwrites; no agent, no skill)'],
126
124
  ['done-done init --agent opencode --model opencode-go/muse-spark-1.3-contributor', 'set the coding agent done-done starts and its model (in a terminal, init asks when none is set)'],
127
- ['done-done init --judge claude --judge-model claude-haiku-5-5', 'set the judge done-done starts and its model (in a terminal, init asks when none is set)'],
125
+ ['done-done init --judge pi --judge-model openai-codex/gpt-6-luna', 'set the judge done-done starts and its model (in a terminal, init asks when none is set)'],
128
126
  ['done-done init --hook claude', 'also show this repo\'s done-done state at each Claude Code session start'],
129
127
  ['done-done init --hook codex', 'also show this repo\'s done-done state at each Codex session start'],
130
128
  ['done-done init --hook opencode', 'also add this repo\'s done-done state to the first message of each opencode session'],
@@ -154,28 +152,28 @@ const COMMANDS = {
154
152
  },
155
153
  check: {
156
154
  usage: [
157
- ['done-done check --outcome "<claim>" <files...> [--claim-id <id>] [--trusted-ref <ref>] [--citation-base <dir>] [--judge]', 'check evidence files against a claim'],
158
- ['done-done check --from <file | - | PR URL> [--claim-id <id>] [--trusted-ref <ref>] [--citation-base <dir>] [--judge]', "check a claim's Checks block"],
155
+ ['done-done check --outcome "<claim>" <files...> [--claim-id <id>] [--trusted-ref <ref>] [--citation-base <dir>]', 'check evidence files against a claim'],
156
+ ['done-done check --from <file | - | PR URL> [--claim-id <id>] [--trusted-ref <ref>] [--citation-base <dir>]', "check a claim's Checks block"],
159
157
  ],
160
- flags: ['--outcome "<claim>"', '--from <file | - | PR URL>', '--claim-id <id>', '--trusted-ref <ref>', '--citation-base <dir>', '--judge'],
158
+ flags: ['--outcome "<claim>"', '--from <file | - | PR URL>', '--claim-id <id>', '--trusted-ref <ref>', '--citation-base <dir>'],
161
159
  more: [CHECKS_TEXT],
162
160
  },
163
161
  receipt: {
164
162
  usage: [
165
- ['done-done receipt --outcome "<claim>" <files...> [--claim-id <id>] [--trusted-ref <ref>] [--citation-base <dir>] [--judge]', 'record a passing check for HEAD'],
166
- ['done-done receipt --from <file | - | PR URL> [--claim-id <id>] [--trusted-ref <ref>] [--citation-base <dir>] [--judge]', "record from a claim's Checks block"],
163
+ ['done-done receipt --outcome "<claim>" <files...> [--claim-id <id>] [--trusted-ref <ref>] [--citation-base <dir>]', 'record a passing check for HEAD'],
164
+ ['done-done receipt --from <file | - | PR URL> [--claim-id <id>] [--trusted-ref <ref>] [--citation-base <dir>]', "record from a claim's Checks block"],
167
165
  ['done-done receipt --verify <head>', 'read-only: verify a recorded receipt'],
168
166
  ],
169
- flags: ['--outcome "<claim>"', '--from <file | - | PR URL>', '--claim-id <id>', '--trusted-ref <ref>', '--citation-base <dir>', '--judge', '--verify <head>'],
167
+ flags: ['--outcome "<claim>"', '--from <file | - | PR URL>', '--claim-id <id>', '--trusted-ref <ref>', '--citation-base <dir>', '--verify <head>'],
170
168
  more: [CHECKS_TEXT],
171
169
  },
172
170
  verify: {
173
171
  usage: [
174
- ['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'],
175
- ['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'],
172
+ ['done-done verify [<journey>] --outcome "<what a user can now do>" [--claim-id <id>]', 'drive the journey in a run that outlives this command, check its evidence, judge each capture and record a receipt'],
173
+ ['done-done verify --outcome "<claim>" [--feature <name>]', 'with no journey, the author agent drives a verify skill feature and the judge the user set judges its captures'],
176
174
  ['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)'],
177
175
  ],
178
- flags: ['--outcome "<what a user can now do>"', '--feature <name>', '--claim-id <id>', '--judge', '--no-judge', '--wait', '--no-wait', '--foreground'],
176
+ flags: ['--outcome "<what a user can now do>"', '--feature <name>', '--claim-id <id>', '--wait', '--no-wait', '--foreground'],
179
177
  more: [RUNS_TEXT],
180
178
  },
181
179
  maintain: {
@@ -186,7 +184,7 @@ const COMMANDS = {
186
184
  design: {
187
185
  usage: [['done-done design <screens...> [--mock <screen>=<mock>]', 'a judge looks at each PNG/JPEG screen, alone or next to its mock, and fails it on visual defects']],
188
186
  flags: ['--mock <screen>=<mock>'],
189
- more: [`design: one judge request per screen (the judge the user set, never the author); it fails a screen on any defect it names:
187
+ more: [`design: one judge request per screen (the judge the user set); it fails a screen on any defect it names:
190
188
  ${Object.keys(KINDS).join(', ')}; capture each variant (dark, small width, large font) as its own screen; exit 0 every screen
191
189
  passed, 1 a screen failed, 3 none failed but one is unsure`],
192
190
  },
@@ -215,15 +213,15 @@ const COMMANDS = {
215
213
  },
216
214
  respond: {
217
215
  usage: [
218
- ['done-done respond [--run <run-id>] --action approve|skip --reason "<why>"', 'move a run on past its gate; it ends overridden, never pass'],
219
- ['done-done respond [--run <run-id>] --action fix', 'end a run waiting at its gate as fail'],
216
+ ['done-done respond [--run <run-id>] --action approve|skip --reason "<why>"', 'answer a spend gate: spend and run the journey, or end with nothing spent'],
217
+ ['done-done respond [--run <run-id>] --action fix', 'end a run waiting at its judge gate as fail (nothing moves it past the judge)'],
220
218
  ],
221
219
  flags: ['--run <run-id>', '--action <approve|skip|fix>', '--reason "<why>"'],
222
220
  more: [RUNS_TEXT],
223
221
  },
224
222
  rerun: {
225
- usage: [['done-done rerun [--run <run-id>] [--wait | --no-wait] [--no-judge]', 'run again from the failed step, reusing steps whose inputs did not change; the judge runs as the source run did (--no-judge turns it off); wait (default) or return at once (exit 4)']],
226
- flags: ['--run <run-id>', '--wait', '--no-wait', '--no-judge'],
223
+ usage: [['done-done rerun [--run <run-id>] [--wait | --no-wait]', 'run again from the failed step, reusing steps whose inputs did not change; the judge always runs; wait (default) or return at once (exit 4)']],
224
+ flags: ['--run <run-id>', '--wait', '--no-wait'],
227
225
  more: [RUNS_TEXT],
228
226
  },
229
227
  abort: {
@@ -244,18 +242,18 @@ const COMMANDS = {
244
242
  };
245
243
  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');
246
244
  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)
247
- 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)`;
245
+ exit codes: 0 pass, 1 fail, 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)`;
248
246
  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>
249
247
  usage:
250
248
  ${rows([['done-done', "show this repo's config state and the next step"], ...Object.values(COMMANDS).flatMap((c) => c.usage)], 46)}
251
249
  ${GLOBAL_TEXT}
252
250
  judge: done-done's own agent, a fresh one on your own plan through BYOKit, says whether each PNG/JPEG capture shows
253
- the outcome; a wrong screen fails, an unsure answer is inconclusive. verify judges by default (judge.enabled: false turns
254
- that off; --judge turns it on for one run); rerun repeats its source run's judge choice (--no-judge turns it off for a run);
255
- check and receipt start no model unless --judge or judge.enabled: true. The judge is the one the user set (judge.route:
256
- claude-code; answerer:<agent> through the CLI of opencode, pi or gemini with every tool off; or openai:key with judge.model
257
- on your own OPENAI_API_KEY), never the agent and model that did the work; with none set, done-done asks in a terminal or
258
- stops (exit 2) with the command that saves it.
251
+ the outcome; a wrong screen fails, an unsure answer is inconclusive. check, receipt, verify, rerun and design always start
252
+ it, and there is no way to turn it off: a pass needs a judge pass on at least one capture. The judge is the one the user set
253
+ (judge.route: claude-code; answerer:<agent> through the CLI of opencode, pi or gemini with every tool off; or openai:key with
254
+ judge.model on your own OPENAI_API_KEY), whichever agent and model the user picks; with none set, done-done asks in a
255
+ terminal or stops (exit 2) with the command that saves it. A judge that fails or does not answer stops the command with its
256
+ cause (exit 2), never a result.
259
257
 
260
258
  ${JOURNEYS_TEXT}
261
259
  ${CHECKS_TEXT}
@@ -392,7 +390,8 @@ function closeRun(repo, cfg, rec, log) {
392
390
  const rows = cap(evidence);
393
391
  const result = {
394
392
  command: 'run',
395
- result: reason ? 'fail' : 'pass',
393
+ // A capture that ran is recorded, never a pass: only the judged check decides whether it proves anything.
394
+ result: reason ? 'fail' : 'recorded',
396
395
  run_id: rec.run_id,
397
396
  commit: rec.commit,
398
397
  dirty: rec.dirty,
@@ -584,11 +583,13 @@ function loadRecords(cfg) {
584
583
  return readdirSync(dir).filter((n) => n.endsWith('.json')).map((n) => JSON.parse(readFileSync(join(dir, n), 'utf8'))).filter((r) => r.ended_at);
585
584
  }
586
585
  // Run a check and return its result without printing it, so `check` and `receipt` share one path.
586
+ // The judge always runs: with no agent or judge set it asks (in a terminal) or stops before any result, and a judge that
587
+ // does not answer stops it with its cause. The plain checks are steps of the judged result, never a result alone.
587
588
  // 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.
588
589
  async function runCheck(argv, run) {
589
590
  const { values, positionals } = parseArgs({
590
591
  args: argv,
591
- options: { outcome: { type: 'string' }, from: { type: 'string' }, 'claim-id': { type: 'string' }, 'trusted-ref': { type: 'string' }, 'citation-base': { type: 'string' }, judge: { type: 'boolean' } },
592
+ options: { outcome: { type: 'string' }, from: { type: 'string' }, 'claim-id': { type: 'string' }, 'trusted-ref': { type: 'string' }, 'citation-base': { type: 'string' } },
592
593
  allowPositionals: true,
593
594
  });
594
595
  if (values['citation-base']?.trim() === '')
@@ -629,12 +630,8 @@ async function runCheck(argv, run) {
629
630
  const title = src.title ?? git(repo, 'log', '-1', '--format=%s');
630
631
  claim = claimFromChecks(src.body, title, repo, cfg, findings);
631
632
  }
632
- if (claim?.notProven) {
633
- const allowed = cfg.checks.not_proven === 'allow' && cfg.trusted;
634
- const all = findings.concat(allowed ? [] : [{ level: 'inconclusive', why: `not proven: ${claim.notProven}` }]);
635
- const rep = report(all, [], { cfg, repo, claim: claim.outcome, source: values.from, citationBase: values['citation-base'], notProven: true }, allowed ? 'not proven (allowed by trusted config)' : '');
636
- return { code: rep.code, result: rep.result, claim: claim?.outcome, files: [], cited: [], head: now, findings: all };
637
- }
633
+ if (claim?.notProven)
634
+ throw new UsageError(`check: the judge was not asked, so there is no verdict: not proven: ${claim.notProven}`, 'Capture real proof, replace "not proven:" with its files, and re-run this check');
638
635
  const files = (claim?.files ?? []).map((name) => values['citation-base'] === undefined ? name : resolve(values['citation-base'], name));
639
636
  const records = loadRecords(cfg);
640
637
  const seen = new Map();
@@ -757,46 +754,43 @@ async function runCheck(argv, run) {
757
754
  const others = (h) => [...(used.get(h) ?? [])].filter((k) => k !== claimId);
758
755
  if (hashes.length && hashes.every((h) => others(h).length))
759
756
  findings.push({ level: 'fail', why: `every evidence file was already used as proof of a different claim (claim id ${[...new Set(hashes.flatMap(others))].join(', ')}), not this one (claim id ${claimId}, from ${idFrom}); if it is the same claim, pass --claim-id with its id` });
760
- let judged;
757
+ // A failed plain check is no verdict: the judge is not asked, so the check stops with every cause and no result.
758
+ const failed = findings.filter((f) => f.level === 'fail');
759
+ if (failed.length || !claim) {
760
+ const ctx = { cfg, repo, claim: claim?.outcome, source: values.from, citationBase: values['citation-base'] };
761
+ throw new UsageError(`check: the judge was not asked, so there is no verdict: ${failed.map((f) => f.why).join('; ')}`, checkHelp(FAIL, findings, files, ctx)[0]);
762
+ }
761
763
  let judgeBlock;
762
764
  let judgeLevel;
763
- // A verify run judges as the run says; check and receipt only with --judge or judge.enabled: true.
764
- if ((run ? run.judge : values.judge || cfg.judge.enabled === true) && claim) {
765
- // A judge pass never overrides a failed deterministic check: on a fail, no capture is judged.
766
- if (findings.some((f) => f.level === 'fail'))
767
- judged = { skipped: 'the check already failed, so no capture was judged' };
768
- else {
769
- // Only a capture the judge reads needs the user's agent (the judge is kept apart from it).
770
- if (captures.some(judgeable))
771
- await withChoice('agent', cfg);
772
- if (captures.some(judgeable))
773
- await withChoice('judge', cfg);
774
- if (captures.some(judgeable))
775
- run?.judging(findings.length ? INCONCLUSIVE : PASS, [...findings]);
776
- const j = await judge(cfg, () => selectJudge(cfg, run?.author ? agentRun(cfg, run.author.agent, run.author.model) : selectAgent(cfg)), claim.outcome, captures, run ? join(run.dir, 'agents') : join(home(), 'agents'));
777
- // The judge's next step leads the help when its verdict decides the result.
778
- if (j.judged.some((r) => r.verdict === 'fail'))
779
- judgeLevel = 'fail';
780
- else if (!findings.length && j.judged.some((r) => r.verdict === 'unsure'))
781
- judgeLevel = 'inconclusive';
782
- const shots = new Set(captures.filter(screen).map((c) => c.name));
783
- for (const r of j.judged.filter((r) => r.verdict !== 'pass'))
784
- 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 });
785
- const rows = cap(j.judged);
786
- const { choice } = j;
787
- 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) };
788
- if (choice && j.judged.length) {
789
- const byName = new Map(captures.map((c) => [c.name, receiptPath(repo, cfg, cite(c.name))]));
790
- judgeBlock = { route: choice.route, model: choice.model, independence: choice.independence, author: choice.author, verdicts: j.judged.map((r) => ({ path: byName.get(r.path), sha256: r.sha256, verdict: r.verdict })) };
791
- }
792
- if (rows.omitted)
793
- judged.judged_omitted = rows.omitted;
794
- if (j.notJudged.length)
795
- judged.not_judged = j.notJudged;
796
- judged.note = 'PNG and JPEG screens and text captures (logs, JSON, CLI output) are judged; video and other files are not';
797
- }
765
+ if (!captures.some(judgeable))
766
+ throw new UsageError(`check: the judge was not asked, so there is no verdict: ${[...findings.map((f) => f.why), 'no cited capture is one the judge reads (a PNG or JPEG screen, or a text capture, from a done-done run)'].join('; ')}`, 'Cite a screen or a text capture of the outcome from a done-done run, then re-run this check');
767
+ // No result without the judge: once the plain checks pass, with none set it asks the user, or stops with the command that saves one.
768
+ await withChoice('judge', cfg);
769
+ run?.judging(findings.length ? INCONCLUSIVE : PASS, [...findings]);
770
+ const j = await judge(cfg, () => selectJudge(cfg, run?.author ? agentRun(cfg, run.author.agent, run.author.model) : judgeAuthor(cfg)), claim.outcome, captures, run ? join(run.dir, 'agents') : join(home(), 'agents'));
771
+ // The judge's next step leads the help when its verdict decides the result.
772
+ if (j.judged.some((r) => r.verdict === 'fail'))
773
+ judgeLevel = 'fail';
774
+ else if (!findings.length && j.judged.some((r) => r.verdict === 'unsure'))
775
+ judgeLevel = 'inconclusive';
776
+ const shots = new Set(captures.filter(screen).map((c) => c.name));
777
+ for (const r of j.judged.filter((r) => r.verdict !== 'pass'))
778
+ 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, ...(r.seen && { seen: r.seen }) });
779
+ for (const part of j.missing)
780
+ findings.push({ level: 'inconclusive', why: `the outcome names ${part}, but no passing capture shows it: capture ${part} and cite that capture` });
781
+ const rows = cap(j.judged);
782
+ const { choice } = j;
783
+ const judged = { ...(choice && { route: choice.route, model: choice.model, independence: choice.independence, author: choice.author.agent ? `${choice.author.agent} (model ${choice.author.model || 'none'})` : 'no agent set' }), judged: rows.items.map(({ sha256, ...r }) => r) };
784
+ if (choice) {
785
+ const byName = new Map(captures.map((c) => [c.name, receiptPath(repo, cfg, cite(c.name))]));
786
+ judgeBlock = { route: choice.route, model: choice.model, independence: choice.independence, author: choice.author, verdicts: j.judged.map((r) => ({ path: byName.get(r.path), sha256: r.sha256, verdict: r.verdict, ...(r.seen && { seen: r.seen }) })) };
798
787
  }
799
- const rep = report(findings, files, { cfg, repo, claim: claim?.outcome, source: values.from, citationBase: values['citation-base'], judge: judged, judgeLevel });
788
+ if (rows.omitted)
789
+ judged.judged_omitted = rows.omitted;
790
+ if (j.notJudged.length)
791
+ judged.not_judged = j.notJudged;
792
+ judged.note = 'PNG and JPEG screens and text captures (logs, JSON, CLI output) are judged; video and other files are not';
793
+ const rep = report(findings, files, { cfg, repo, claim: claim.outcome, source: values.from, citationBase: values['citation-base'], judge: judged, judgeLevel });
800
794
  if (rep.code === PASS) {
801
795
  mkdirSync(dirname(ledger), { recursive: true });
802
796
  appendFileSync(ledger, hashes.filter((h) => !used.get(h)?.has(claimId)).map((h) => `${h} ${claimId}\n`).join(''));
@@ -848,25 +842,16 @@ function verifyReceipt(repo, cfg, headArg) {
848
842
  catch (e) {
849
843
  return fail(`receipt for ${short} is malformed (${e.message})`);
850
844
  }
851
- if (!rec || typeof rec !== 'object' || (rec.schema !== 1 && rec.schema !== 2) || (rec.result !== 'pass' && rec.result !== 'overridden') || !Array.isArray(rec.files) || !rec.files.length)
845
+ if (!rec || typeof rec !== 'object' || rec.schema !== 2 || rec.result !== 'pass' || !Array.isArray(rec.files) || !rec.files.length)
852
846
  return fail(`receipt for ${short} is malformed`);
853
847
  if (!rec.files.every((f) => f && typeof f === 'object' && typeof f.path === 'string' && typeof f.sha256 === 'string'))
854
848
  return fail(`receipt for ${short} is malformed`);
855
- // A schema 2 judge block must name its judge, and pass only captures this receipt cites. An overridden receipt keeps the
856
- // verdicts that did not pass, or no block when the judge judged no capture.
849
+ // The judge block must name its judge, and pass only captures this receipt cites.
857
850
  const j = rec.judge;
858
- const o = rec.override;
859
- const overridden = rec.result === 'overridden';
860
851
  const badJudge = !j || typeof j !== 'object' || typeof j.route !== 'string' || typeof j.model !== 'string' || !['agent', 'model', 'session-only'].includes(j.independence) || !Array.isArray(j.verdicts) || !j.verdicts.length
861
- || !j.verdicts.every((v) => v && (overridden ? ['pass', 'fail', 'unsure'] : ['pass']).includes(v.verdict) && rec.files.some((f) => f.path === v.path && f.sha256 === v.sha256));
862
- if (overridden ? j !== undefined && badJudge : rec.schema === 2 && badJudge)
852
+ || !j.verdicts.every((v) => v && v.verdict === 'pass' && rec.files.some((f) => f.path === v.path && f.sha256 === v.sha256));
853
+ if (badJudge)
863
854
  return fail(`receipt for ${short} has a malformed judge block`);
864
- // A caller can move a run past its gate, but never turn it into a pass: an overridden receipt always fails here.
865
- if (overridden) {
866
- if (rec.schema !== 2 || !o || typeof o !== 'object' || !['approve', 'skip'].includes(o.action) || typeof o.by !== 'string' || typeof o.reason !== 'string' || !o.reason)
867
- return fail(`receipt for ${short} has a malformed override`);
868
- 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>"'] } };
869
- }
870
855
  if (rec.head !== commit)
871
856
  return fail(`receipt records head ${String(rec.head).slice(0, 12)}, not ${short}`);
872
857
  const now = head(repo);
@@ -892,7 +877,7 @@ function verifyReceipt(repo, cfg, headArg) {
892
877
  }
893
878
  if (bad.length)
894
879
  return fail(`cited evidence no longer matches the receipt: ${bad.join('; ')}`);
895
- 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.'] } };
880
+ 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, judge: j, help: ['This receipt is valid for this head.'] } };
896
881
  }
897
882
  async function cmdReceipt(argv) {
898
883
  const vi = argv.indexOf('--verify');
@@ -913,17 +898,22 @@ async function cmdReceipt(argv) {
913
898
  }
914
899
  if (!rest.length)
915
900
  throw new UsageError('receipt needs a claim (--outcome "<claim>" <files...> or --from <file|->) or --verify <head>');
901
+ assertCleanForReceipt(repo);
916
902
  return emitted(recordReceipt(repo, cfg, await runCheck(rest)));
917
903
  }
904
+ function assertCleanForReceipt(repo) {
905
+ if (head(repo).dirty)
906
+ throw new UsageError('receipt: tracked files have uncommitted changes, so no receipt was written; commit or revert them, then re-run receipt', 'Commit the tracked changes, then: done-done receipt --from <file|->');
907
+ }
918
908
  // Write the receipt for a check that passed on a clean tree and cited evidence, or say why none was written.
919
909
  function recordReceipt(repo, cfg, r) {
920
- if (r.code !== PASS)
921
- return { code: r.code, result: { ...r.result, command: 'receipt', reason: 'the check did not pass, so no receipt was written' } };
922
- if (r.head.dirty)
923
- return { code: FAIL, result: { command: 'receipt', result: 'fail', head: r.head.commit, claim: r.claim, reason: 'tracked files have uncommitted changes; commit or revert them, then re-run receipt', help: ['Commit the tracked changes, then: done-done receipt --from <file|->'] } };
910
+ if (r.code !== PASS || !r.judgeBlock)
911
+ return { code: r.code === PASS ? INCONCLUSIVE : r.code, result: { ...r.result, command: 'receipt', reason: 'the check did not pass its judge, so no receipt was written' } };
912
+ assertCleanForReceipt(repo);
913
+ // A missing citation is not a verdict: the judge passed, but the receipt cannot bind it, so it stops like a judge error.
924
914
  if (!r.cited.length)
925
- return { code: FAIL, result: { command: 'receipt', result: 'fail', head: r.head.commit, claim: r.claim, reason: 'the check cited no evidence files, so no receipt was written; a receipt must bind at least one cited evidence file', help: ['Cite real proof: done-done receipt --outcome "<claim>" <files...>'] } };
926
- const rec = { schema: r.judgeBlock ? 2 : 1, repo: cfg.app, head: r.head.commit, tree: r.head.tree, claim: r.claim ?? '', result: 'pass', at: new Date().toISOString(), files: r.cited, ...(r.judgeBlock && { judge: r.judgeBlock }) };
915
+ throw new UsageError('receipt: the check cited no evidence files, so no receipt was written; a receipt must bind at least one cited evidence file', 'Cite real proof: done-done receipt --outcome "<claim>" <files...>');
916
+ const rec = { schema: 2, repo: cfg.app, head: r.head.commit, tree: r.head.tree, claim: r.claim ?? '', result: 'pass', at: new Date().toISOString(), files: r.cited, judge: r.judgeBlock };
927
917
  const path = writeReceipt(cfg, rec);
928
918
  const result = { command: 'receipt', result: 'pass', head: r.head.commit, tree: r.head.tree, claim: rec.claim, files: rec.files.length, receipt: shown(repo, path) };
929
919
  return { code: PASS, result: { ...result, ...(r.judge && { judge: r.judge }), help: [`Verify this receipt (CI or merge): done-done receipt --verify ${r.head.commit}`] } };
@@ -946,9 +936,7 @@ function staleness(repo, cfg, from, to) {
946
936
  }
947
937
  // Quote an argument only when a shell would split it.
948
938
  const arg = (a) => (/^[\w./@:+-]+$/.test(a) ? a : `'${a.replaceAll("'", `'\\''`)}'`);
949
- function checkHelp(code, findings, files, { cfg, repo, claim, source, citationBase, notProven, judgeLevel }) {
950
- if (notProven)
951
- return ['Capture real proof and replace "not proven:", or set checks.not_proven: allow and re-check from a trusted ref'];
939
+ function checkHelp(code, findings, files, { cfg, repo, claim, source, citationBase, judgeLevel }) {
952
940
  if (judgeLevel === 'fail' && code === FAIL)
953
941
  return [`Capture the screen the outcome describes, then re-run this check: ${captureHint(cfg)}`];
954
942
  if (judgeLevel && code === INCONCLUSIVE)
@@ -977,7 +965,7 @@ function checkHelp(code, findings, files, { cfg, repo, claim, source, citationBa
977
965
  help.push(`Commit your change, re-capture with ${capture}, then check the new files`);
978
966
  return help;
979
967
  }
980
- function report(findings, files, ctx, note = '') {
968
+ function report(findings, files, ctx) {
981
969
  const code = findings.some((f) => f.level === 'fail') ? FAIL : findings.length ? INCONCLUSIVE : PASS;
982
970
  const result = {
983
971
  command: 'check',
@@ -987,8 +975,6 @@ function report(findings, files, ctx, note = '') {
987
975
  result.claim = ctx.claim;
988
976
  if (ctx.source)
989
977
  result.source = ctx.source;
990
- if (note)
991
- result.note = note;
992
978
  if (files.length) {
993
979
  const ev = cap(files);
994
980
  result.files = files.length;
@@ -998,7 +984,7 @@ function report(findings, files, ctx, note = '') {
998
984
  }
999
985
  if (ctx.judge)
1000
986
  result.judge = ctx.judge;
1001
- const rows = cap(findings.map((f) => ({ level: f.level, why: f.why })));
987
+ const rows = cap(findings.map((f) => ({ level: f.level, why: f.why, ...(f.seen && { seen: f.seen }) })));
1002
988
  if (rows.items.length)
1003
989
  result.findings = rows.items;
1004
990
  if (rows.omitted)
@@ -1213,7 +1199,7 @@ async function agentDrive(repo, cfg, feature) {
1213
1199
  // With a verify skill and no journey, the next step can be an agent drive.
1214
1200
  const driveHint = (repo, cfg) => existsSync(join(repo, cfg.skill, 'SKILL.md'))
1215
1201
  ? ['Have your coding agent drive a verify skill feature: done-done verify --outcome "<what a user can now do>" [--feature <name>]'] : [];
1216
- // The page-check record shape and how to collect it with chrome-devtools-axi, for the drive prompt and the verify skill scaffold.
1202
+ // The page-check record shape and how to collect it with chrome-devtools-axi, for the drive prompt.
1217
1203
  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>}]}'
1218
1204
  + `; 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)`;
1219
1205
  function drivePrompt(run, at, cfg, evidence, scratch, base = '') {
@@ -1311,7 +1297,7 @@ async function runSteps(dir) {
1311
1297
  // 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`.
1312
1298
  let quiet = null;
1313
1299
  const set = (s, status, extra = {}) => quiet ?? setStep(dir, run, s, status, extra);
1314
- // A step that starts skips the earlier steps still pending (the judge when it is off, a fix that was not needed).
1300
+ // A step that starts skips the earlier steps still pending (the judge after a failed check, a fix that was not needed).
1315
1301
  const begin = (name) => {
1316
1302
  for (const s of quiet ? [] : run.steps.slice(0, run.steps.findIndex((s) => s.name === name)))
1317
1303
  if (s.status === 'pending')
@@ -1541,7 +1527,7 @@ async function runSteps(dir) {
1541
1527
  return { end: { result: 'fail', reason: `check: input guard: ${diffs.map((d) => d.why).join('; ')}` }, asks: [], fixable: [] };
1542
1528
  }
1543
1529
  }
1544
- // The check's own findings end the check step; the judge, when on and the check did not fail, is its own step.
1530
+ // The check's own findings end the check step; the judge, when the check did not fail, is its own step.
1545
1531
  let checkFindings = 0;
1546
1532
  const judging = (code, findings) => {
1547
1533
  checkFindings = findings.length;
@@ -1550,7 +1536,7 @@ async function runSteps(dir) {
1550
1536
  };
1551
1537
  // A fix worktree is detached, so it checks for the claim id of the user's branch.
1552
1538
  const claimId = run.claim_id ?? (fix ? git(run.repo, 'branch', '--show-current') : null);
1553
- const checked = await runCheck(['--outcome', run.claim, ...files, ...(claimId ? ['--claim-id', claimId] : [])], { dir, judge: !!run.judge, judging, author: run.author, repo: at, cfg });
1539
+ const checked = await runCheck(['--outcome', run.claim, ...files, ...(claimId ? ['--claim-id', claimId] : [])], { dir, judging, author: run.author, repo: at, cfg });
1554
1540
  const logged = { ...checked.result, findings: checked.findings };
1555
1541
  delete logged.findings_omitted;
1556
1542
  log('check', logged);
@@ -1562,14 +1548,11 @@ async function runSteps(dir) {
1562
1548
  }
1563
1549
  else
1564
1550
  set(step, checked.code === PASS ? 'completed' : 'failed', { exit: checked.code, findings: checked.findings });
1565
- // 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
1566
- // waits for the caller (respond), who can move it on but never make it a pass. The runner ends here.
1567
- const clean = judgeRan ? checkFindings === 0 : checked.code === PASS;
1568
- const notJudged = judgeRan && run.journey === null ? (checked.judge?.not_judged ?? []) : [];
1569
- 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` }))]
1570
- : run.journey !== null ? []
1571
- : 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' }]
1572
- : [{ level: 'inconclusive', why: 'the judge is off (--no-judge), and an agent drive passes only with a judge pass' }];
1551
+ // The gate: when the check itself is clean but the judge did not pass a capture (or read none, or not all of an agent's),
1552
+ // the run waits for the caller (respond), who can move it on but never make it a pass. The runner ends here.
1553
+ const clean = judgeRan && checkFindings === 0;
1554
+ const notJudged = clean && run.journey === null ? (checked.judge?.not_judged ?? []) : [];
1555
+ const asks = !clean ? [] : [...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` }))];
1573
1556
  // With auto_fix.verify, screens the judge calls wrong (and nothing it was unsure of) are a product failure to fix.
1574
1557
  if (tries && asks.length && asks.every((f) => f.level === 'fail'))
1575
1558
  return { checked, end: { result: 'fail', reason: `judge: ${asks[0].why}` }, asks: [], fixable: wrongScreens(asks) };
@@ -1588,8 +1571,6 @@ async function runSteps(dir) {
1588
1571
  p.end = { ...p.end, reason: `${p.end.reason}; auto_fix.verify does not re-run a spends journey` };
1589
1572
  if (tries && p.end && p.fixable.length && !journey?.spends) {
1590
1573
  const sorted = await sortJudged(run, cfg, failures(p.fixable), join(dir, 'agents', 'triage.json'), h);
1591
- if (typeof sorted === 'string')
1592
- return finish('blocked', `triage: ${sorted}`);
1593
1574
  if (sorted.length)
1594
1575
  p = { checked: p.checked, asks: sorted, fixable: [] };
1595
1576
  else {
@@ -1616,8 +1597,7 @@ async function runSteps(dir) {
1616
1597
  const wrong = p.asks.filter((f) => f.level === 'fail');
1617
1598
  if (tries && wrong.length && wrong.length < p.asks.length) {
1618
1599
  const sorted = await sortJudged(run, cfg, failures(wrongScreens(wrong)), join(dir, 'agents', `triage${fixed ? '-gate' : ''}.json`), h, true);
1619
- if (typeof sorted !== 'string')
1620
- p.asks = [...sorted, ...p.asks.filter((f) => f.level !== 'fail')];
1600
+ p.asks = [...sorted, ...p.asks.filter((f) => f.level !== 'fail')];
1621
1601
  }
1622
1602
  begin('gate');
1623
1603
  const findings = p.asks.map((f, i) => ({ id: `gate-${i + 1}`, severity: f.level === 'fail' ? 'error' : 'warning', what: f.why, action: 'ask-user' }));
@@ -1644,7 +1624,7 @@ async function runSteps(dir) {
1644
1624
  s.status = 'skipped';
1645
1625
  const name = step?.name ?? 'setup';
1646
1626
  const text = errorText(e);
1647
- Object.assign(run, { status: 'failed', reason: text.startsWith(`${name}:`) ? text : `${name}: ${text}` });
1627
+ Object.assign(run, { status: 'failed', reason: text.startsWith(`${name}:`) ? text : `${name}: ${text}`, ...(e instanceof UsageError && { stopped: true }) });
1648
1628
  if (step)
1649
1629
  set(step, 'failed');
1650
1630
  else {
@@ -1671,19 +1651,20 @@ const VERIFY_TRIAGE = {
1671
1651
  },
1672
1652
  fix: 'fix',
1673
1653
  };
1674
- // The judge the user set (never the fixer's agent) sorts the judge's failures before each fix: [] sends them all to the fixer;
1675
- // 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.
1654
+ // The judge the user set sorts the judge's failures before each fix: [] sends them all to the fixer;
1655
+ // with any ask (or unsure answer), or `park`, every judge failure goes to the gate with its class. A judge that does not
1656
+ // answer throws, so the run fails with the cause.
1676
1657
  async function sortJudged(run, cfg, open, record, h, park = false) {
1677
1658
  const judged = open.filter((f) => f.kind === 'judge');
1678
1659
  if (!judged.length)
1679
1660
  return [];
1680
1661
  const step = h.begin('triage');
1681
- const choice = selectJudge(cfg, selectAgent(cfg), true);
1662
+ const choice = selectJudge(cfg, selectAgent(cfg));
1682
1663
  const asks = await triage(cfg, choice, judged, VERIFY_TRIAGE, { app: cfg.app, outcome: run.claim, findings: judged }, record);
1683
1664
  h.log('triage', { judge: { route: choice.route, model: choice.model, independence: choice.independence }, ask_user: asks });
1684
- h.set(step, typeof asks === 'string' ? 'failed' : 'completed');
1685
- if (typeof asks === 'string' || !(asks.length || park))
1686
- return typeof asks === 'string' ? asks : [];
1665
+ h.set(step, 'completed');
1666
+ if (!(asks.length || park))
1667
+ return [];
1687
1668
  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 }));
1688
1669
  }
1689
1670
  // auto_fix.verify: the user's agent fixes product code in a done-done worktree at the verified head, done-done commits it and
@@ -1769,8 +1750,6 @@ Answer with summary (what you changed, in one line) and blocker ("" when you fix
1769
1750
  if (next.end && !next.fixable.length)
1770
1751
  return blocked(`the re-run after attempt ${k} did not pass: ${next.end.reason}`, []);
1771
1752
  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; }) : [];
1772
- if (typeof sorted === 'string')
1773
- return blocked(`triage: ${sorted}`, failures(next.fixable));
1774
1753
  if (!next.end || sorted.length) {
1775
1754
  run.fix.branch = `done-done/verify-${run.run_id}`;
1776
1755
  git(repo, 'branch', run.fix.branch, made.commit);
@@ -1827,11 +1806,9 @@ function showRun(dir, cfg, command, gone = false) {
1827
1806
  reason = `the runner died at step ${at?.name}${run.runner ? ` (pid ${run.runner.pid}, last heartbeat ${beat}s ago)` : ' before it started'}`;
1828
1807
  reapJourney(dir, run);
1829
1808
  }
1830
- // An overridden run is never a pass: it exits 1 like a fail.
1831
- 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;
1809
+ const code = status === 'pending' || status === 'running' || status === 'awaiting_approval' ? WAITING : run.stopped && status === 'failed' ? USAGE : status === 'cancelled' || status === 'failed' || run.result === 'fail' || run.result === 'drift' || (run.kind === 'verify' && (run.result === 'changed' || run.result === 'blocked')) ? FAIL : run.result === 'inconclusive' || run.result === 'blocked' ? INCONCLUSIVE : PASS;
1832
1810
  const drive = run.journey ?? (run.feature ? `--feature ${arg(run.feature)}` : '');
1833
- const judgeFlag = run.judge === false && cfg.judge.enabled !== false ? ' --no-judge' : run.judge && cfg.judge.enabled === false ? ' --judge' : '';
1834
- 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}`;
1811
+ 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)}` : ''}`;
1835
1812
  const findings = cap(run.findings ?? []);
1836
1813
  const ev = cap(rec ? evidenceOf(run.repo, cfg, rec) : []);
1837
1814
  const result = { command, run_id: run.run_id, kind: run.kind, status };
@@ -1883,25 +1860,22 @@ function showRun(dir, cfg, command, gone = false) {
1883
1860
  `Their no: done-done respond --run ${id} --action skip --reason "<why they said no>" (ends fail, nothing spent)`,
1884
1861
  ]
1885
1862
  : status === 'awaiting_approval' ? [
1886
- 'Read the findings and look at the captures (evidence above), then answer the gate:',
1887
- `done-done respond --run ${id} --action approve --reason "<why they do show the outcome>" (ends overridden, never pass)`,
1888
- `done-done respond --run ${id} --action skip --reason "<why to go on without a judge pass>" (ends overridden, never pass)`,
1863
+ 'The judge did not pass this run, and nothing moves a run past its judge. Read the findings and look at the captures (evidence above), then end it:',
1889
1864
  `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})`,
1890
1865
  ]
1891
- : 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}`]
1892
- : code === WAITING ? [`The run goes on without this command; read it later: done-done status --run ${run.run_id}`]
1893
- : 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}`]
1894
- : 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}`]
1895
- : 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}`]
1896
- : 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}`]
1897
- : /^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}`]
1898
- : /^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}`]
1899
- : /^locate: no verify skill/.test(run.reason ?? '') ? ['Create the verify skill: done-done init']
1900
- : /^locate:/.test(run.reason ?? '') ? [`Make ${cfg.skill} a real folder inside the repository (not a link), commit, then run again: ${again}`]
1901
- : [`Read the step logs in ${shown(run.repo, join(dir, 'logs'))}`, `Fix the cause, then run again: ${again}`])
1902
- : code === PASS ? [`Verify this receipt (CI or merge): done-done receipt --verify ${rec.commit}`]
1903
- : status === 'cancelled' ? [...(run.kind === 'maintain' ? [] : [`Start again from the failed step: done-done rerun --run ${run.run_id}`]), `List past runs: done-done history`]
1904
- : [`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}`];
1866
+ : code === WAITING ? [`The run goes on without this command; read it later: done-done status --run ${run.run_id}`]
1867
+ : 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}`]
1868
+ : 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}`]
1869
+ : 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}`]
1870
+ : 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}`]
1871
+ : /^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}`]
1872
+ : /^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}`]
1873
+ : /^locate: no verify skill/.test(run.reason ?? '') ? ['Create the verify skill: done-done init']
1874
+ : /^locate:/.test(run.reason ?? '') ? [`Make ${cfg.skill} a real folder inside the repository (not a link), commit, then run again: ${again}`]
1875
+ : [`Read the step logs in ${shown(run.repo, join(dir, 'logs'))}`, `Fix the cause, then run again: ${again}`])
1876
+ : code === PASS ? [`Verify this receipt (CI or merge): done-done receipt --verify ${rec.commit}`]
1877
+ : status === 'cancelled' ? [...(run.kind === 'maintain' ? [] : [`Start again from the failed step: done-done rerun --run ${run.run_id}`]), `List past runs: done-done history`]
1878
+ : [`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}`];
1905
1879
  emit(result);
1906
1880
  return code;
1907
1881
  }
@@ -1909,12 +1883,15 @@ async function waitRun(dir, pid) {
1909
1883
  while (['pending', 'running'].includes(loadRun(dir).status) && alive(pid))
1910
1884
  await new Promise((r) => setTimeout(r, 200));
1911
1885
  }
1912
- // The judge a command will start: the user's agent and judge (asked for when unset), refused when it is the author.
1913
- async function preflightJudge(cfg, needsAgent = true, fix = false) {
1886
+ // The judge a command will start: the user's agent and judge, asked for when unset.
1887
+ async function preflightJudge(cfg, needsAgent = true) {
1914
1888
  if (needsAgent)
1915
1889
  await withChoice('agent', cfg);
1916
1890
  await withChoice('judge', cfg);
1917
- return selectJudge(cfg, cfg.agent ? selectAgent(cfg) : NO_AUTHOR, fix);
1891
+ return selectJudge(cfg, judgeAuthor(cfg));
1892
+ }
1893
+ function judgeAuthor(cfg) {
1894
+ return cfg.agent ? selectAgent(cfg) : NO_AUTHOR;
1918
1895
  }
1919
1896
  const NO_AUTHOR = { agent: '', model: '' };
1920
1897
  // 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
@@ -1928,23 +1905,20 @@ function refuseParked(cfg, repo, claim) {
1928
1905
  if (!parked.length)
1929
1906
  return;
1930
1907
  const id = parked[0];
1931
- 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}`);
1908
+ 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 fix (approve|skip --reason "<why>" at a spend gate), or stop it: done-done abort --run ${id}`);
1932
1909
  }
1933
- // Before a verify starts: its judge, and with auto_fix.verify an agent that can edit and a judge that is never that agent.
1934
- async function preflightVerify(cfg, judging) {
1935
- if (cfg.auto_fix.verify) {
1910
+ // Before a verify starts: its judge, plus the agent when it drives (no journey) or fixes (auto_fix.verify, an agent that can edit).
1911
+ async function preflightVerify(cfg, agentDrives) {
1912
+ if (agentDrives || cfg.auto_fix.verify)
1936
1913
  await withChoice('agent', cfg);
1937
- if (cfg.agent.startsWith('acp:'))
1938
- 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(', ')}`);
1939
- }
1940
- if (!judging)
1941
- return;
1942
- await preflightJudge(cfg, true, !!cfg.auto_fix.verify);
1914
+ if (cfg.auto_fix.verify && cfg.agent.startsWith('acp:'))
1915
+ 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(', ')}`);
1916
+ await preflightJudge(cfg, false);
1943
1917
  }
1944
1918
  async function cmdVerify(argv) {
1945
1919
  const { values, positionals } = parseArgs({
1946
1920
  args: argv,
1947
- options: { outcome: { type: 'string' }, feature: { type: 'string' }, 'claim-id': { type: 'string' }, judge: { type: 'boolean' }, 'no-judge': { type: 'boolean' }, wait: { type: 'boolean' }, 'no-wait': { type: 'boolean' }, foreground: { type: 'boolean' } },
1921
+ options: { outcome: { type: 'string' }, feature: { type: 'string' }, 'claim-id': { type: 'string' }, wait: { type: 'boolean' }, 'no-wait': { type: 'boolean' }, foreground: { type: 'boolean' } },
1948
1922
  allowPositionals: true,
1949
1923
  });
1950
1924
  if (!values.outcome?.trim())
@@ -1953,8 +1927,6 @@ async function cmdVerify(argv) {
1953
1927
  throw new UsageError('verify takes at most one journey name');
1954
1928
  if (positionals.length && values.feature !== undefined)
1955
1929
  throw new UsageError('pass a journey or --feature, not both: a journey drives by its command, --feature by the author agent');
1956
- if (values.judge && values['no-judge'])
1957
- throw new UsageError('pass only one of --judge and --no-judge');
1958
1930
  if ([values.wait, values['no-wait'], values.foreground].filter(Boolean).length > 1)
1959
1931
  throw new UsageError('pass only one of --wait, --no-wait and --foreground');
1960
1932
  const repo = repoRoot();
@@ -1973,16 +1945,15 @@ async function cmdVerify(argv) {
1973
1945
  throw new UsageError('HEAD is detached, so no branch names this claim; pass --claim-id <id>');
1974
1946
  refuseParked(cfg, repo, values['claim-id'] ?? branch);
1975
1947
  const drive = journey === null ? await agentDrive(repo, cfg, values.feature) : undefined;
1976
- // 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.
1977
- const judging = !values['no-judge'] && (!!drive || !!values.judge || cfg.judge.enabled !== false);
1978
- // No judge set, or one config forbids (the author's own agent and model), is a usage error now, not a failed run later.
1979
- await preflightVerify(cfg, judging);
1948
+ // verify always runs the judge the user set, the author's own agent and model included; the judge is settled here, so a
1949
+ // run never fails later for one it never had.
1950
+ await preflightVerify(cfg, journey === null);
1980
1951
  const id = newRunId(git(repo, 'rev-parse', 'HEAD'));
1981
1952
  const dir = join(runsRoot(cfg.app), id);
1982
1953
  mkdirSync(join(dir, 'logs'), { recursive: true });
1983
1954
  const created = now();
1984
1955
  const run = {
1985
- 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',
1956
+ schema: 2, run_id: id, kind: 'verify', app: cfg.app, repo, journey, ...drive, claim: values.outcome, claim_id: values['claim-id'] ?? null, branch, status: 'pending',
1986
1957
  steps: verifySteps(cfg).map((name) => ({ name, status: 'pending' })), created, updated: created,
1987
1958
  inputs: inputsOf(repo, cfg, journey, drive?.author),
1988
1959
  };
@@ -2012,11 +1983,15 @@ async function cmdMaintain(argv) {
2012
1983
  const repo = repoRoot();
2013
1984
  const cfg = loadConfig(repo);
2014
1985
  await withChoice('agent', cfg);
2015
- // 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.
1986
+ // maintain never reports without its agent: one that cannot start or cannot read stops it here, before any run.
1987
+ const why = readerProblem(selectAgent(cfg));
1988
+ if (why)
1989
+ throw new UsageError(`maintain: ${why.replace(/^source-wave: /, '')}`, 'Install the agent or ask the user for another and save it: done-done init --agent <agent>');
1990
+ // auto_fix: the agent the user set fixes the skill and the judge the user set triages, so both are set now.
2016
1991
  if (cfg.auto_fix.maintain) {
2017
1992
  if (cfg.agent.startsWith('acp:'))
2018
1993
  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(', ')}`);
2019
- await preflightJudge(cfg, true, true);
1994
+ await preflightJudge(cfg);
2020
1995
  }
2021
1996
  const id = newRunId(git(repo, 'rev-parse', 'HEAD'));
2022
1997
  const dir = join(runsRoot(cfg.app), id);
@@ -2105,7 +2080,7 @@ const drivesClean = (record, head) => {
2105
2080
  }
2106
2081
  };
2107
2082
  async function cmdRerun(argv) {
2108
- const { values } = parseArgs({ args: argv, options: { run: { type: 'string' }, wait: { type: 'boolean' }, 'no-wait': { type: 'boolean' }, 'no-judge': { type: 'boolean' } } });
2083
+ const { values } = parseArgs({ args: argv, options: { run: { type: 'string' }, wait: { type: 'boolean' }, 'no-wait': { type: 'boolean' } } });
2109
2084
  if (values.wait && values['no-wait'])
2110
2085
  throw new UsageError('pass only one of --wait and --no-wait');
2111
2086
  const repo = repoRoot();
@@ -2132,10 +2107,8 @@ async function cmdRerun(argv) {
2132
2107
  // A reused drive keeps the guard it ran under, so it is only reused when the watched inputs are the same ones.
2133
2108
  const driveReuse = same && drivesClean(src.record, src.inputs?.head) && JSON.stringify(src.guarded?.patterns ?? []) === JSON.stringify(srcCfg.inputs);
2134
2109
  const reused = (name) => (name === 'resolve' && same) || (name === 'drive' && driveReuse);
2135
- // A rerun judges as its source did; --no-judge turns it off for this run. A missing or forbidden judge is a usage
2136
- // error here too.
2137
- const judging = !values['no-judge'] && !!src.judge;
2138
- await preflightVerify(cfg, judging);
2110
+ // A rerun always judges, whatever its source run did. A missing or forbidden judge is a usage error here too.
2111
+ await preflightVerify(cfg, src.journey === null);
2139
2112
  const id = newRunId(git(repo, 'rev-parse', 'HEAD'));
2140
2113
  const dir = join(root, id);
2141
2114
  mkdirSync(join(dir, 'logs'), { recursive: true });
@@ -2144,7 +2117,7 @@ async function cmdRerun(argv) {
2144
2117
  return s && reused(name) && s.status === 'completed' ? { ...s } : { name, status: 'pending' };
2145
2118
  });
2146
2119
  const created = now();
2147
- 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 }) };
2120
+ 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, status: 'pending', inputs, steps, created, updated: created, ...(driveReuse && src.record && { record: src.record }), ...(driveReuse && src.guarded && { guarded: src.guarded }) };
2148
2121
  saveRun(dir, run);
2149
2122
  for (const s of steps) {
2150
2123
  const f = join(root, srcId, 'logs', `${s.name}.log`);
@@ -2240,17 +2213,16 @@ function answeredBy(repo) {
2240
2213
  return userInfo().username;
2241
2214
  }
2242
2215
  }
2243
- // Answer a run waiting at its gate. approve and skip write the receipt as overridden (with who and why) and end the run
2244
- // overridden, never pass; fix ends it fail. The runner has exited, so this command finishes the run; the respond lock
2245
- // lets only one answer (or an abort) close it.
2216
+ // Answer a run waiting at its gate. At a spend gate, approve runs the journey and skip ends the run with nothing spent. At
2217
+ // the judge gate only fix answers: it ends the run fail, since nothing moves a run past its judge and no receipt is ever
2218
+ // written without a judge pass. The runner has exited, so this command finishes the run; the respond lock lets only one
2219
+ // answer (or an abort) close it.
2246
2220
  async function cmdRespond(argv) {
2247
2221
  const { values } = parseArgs({ args: argv, options: { run: { type: 'string' }, action: { type: 'string' }, reason: { type: 'string' } } });
2248
2222
  const action = values.action;
2249
2223
  if (action !== 'approve' && action !== 'skip' && action !== 'fix')
2250
2224
  throw new UsageError('respond needs --action approve, skip or fix', 'Run: done-done respond --run <run-id> --action <approve|skip|fix> [--reason "<why>"]');
2251
2225
  const reason = values.reason?.replace(/\s+/g, ' ').trim() ?? '';
2252
- if (action !== 'fix' && !reason)
2253
- throw new UsageError(`respond --action ${action} needs --reason "<why>": moving past the judge is recorded with who and why`, `Run: done-done respond --run <run-id> --action ${action} --reason "<why>"`);
2254
2226
  const repo = repoRoot();
2255
2227
  const cfg = loadConfig(repo);
2256
2228
  const root = runsRoot(cfg.app);
@@ -2265,11 +2237,15 @@ async function cmdRespond(argv) {
2265
2237
  throw notAtGate(run);
2266
2238
  if (run.gate.spend && action === 'fix')
2267
2239
  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>"`);
2240
+ if (!run.gate.spend && action !== 'fix')
2241
+ throw new UsageError(`run ${id} is at its judge gate, which takes only --action fix: done-done never moves a run past its judge, and writes no receipt without a judge pass`, `Run: done-done respond --run ${id} --action fix, then fix the product or the verify skill's recipe, commit, and run again`);
2242
+ if (action !== 'fix' && !reason)
2243
+ throw new UsageError(`respond --action ${action} needs --reason "<why>": the spend answer is recorded with who and why`, `Run: done-done respond --run ${id} --action ${action} --reason "<why>"`);
2268
2244
  const gate = run.steps.find((s) => s.name === 'gate');
2269
2245
  const by = answeredBy(repo);
2270
2246
  run.response = { action, by, ...(reason && { reason }), at: now() };
2271
2247
  logEvent(dir, { step: 'gate', response: action, by, ...(reason && { reason }) });
2272
- // The spend gate: approve runs the journey in a new runner; skip or fix ends the run with nothing spent.
2248
+ // The spend gate: approve runs the journey in a new runner; skip ends the run with nothing spent.
2273
2249
  if (run.gate.spend) {
2274
2250
  const drive = run.steps.find((s) => s.name === 'drive');
2275
2251
  if (action !== 'approve') {
@@ -2283,19 +2259,8 @@ async function cmdRespond(argv) {
2283
2259
  rmSync(join(dir, lock));
2284
2260
  return launch(dir, cfg, 'respond', {});
2285
2261
  }
2286
- if (action === 'fix') {
2287
- setStep(dir, run, gate, 'failed');
2288
- finishRun(dir, run, 'fail', `gate: ${by} chose fix: ${reason || run.gate.findings[0].what}`);
2289
- return showRun(dir, cfg, 'respond');
2290
- }
2291
- setStep(dir, run, gate, 'completed');
2292
- const step = run.steps.find((s) => s.name === 'receipt');
2293
- setStep(dir, run, step, 'running');
2294
- const { gate: g } = run;
2295
- const rec = { schema: 2, repo: cfg.app, head: g.head.commit, tree: g.head.tree, claim: run.claim, result: 'overridden', at: now(), files: g.cited, ...(g.judge && { judge: g.judge }), override: { action, by, reason, at: run.response.at } };
2296
- run.receipt = shown(repo, writeReceipt(cfg, rec));
2297
- setStep(dir, run, step, 'completed');
2298
- finishRun(dir, run, 'overridden', `gate: ${by} chose ${action}: ${reason}`);
2262
+ setStep(dir, run, gate, 'failed');
2263
+ finishRun(dir, run, 'fail', `gate: ${by} chose fix: ${reason || run.gate.findings[0].what}`);
2299
2264
  return showRun(dir, cfg, 'respond');
2300
2265
  }
2301
2266
  catch (e) {
@@ -2306,7 +2271,7 @@ async function cmdRespond(argv) {
2306
2271
  // sign-off's communication contract v1 (github.com/umeranjum17/sign-off, docs/contract.md): done-done sends `gate` and
2307
2272
  // `evidence`, schema 1. Each value is checked against the contract's limits first, so a run that cannot make a valid
2308
2273
  // message writes no file. done-done never posts it: sign-off is the user's choice, so the help line names the post command.
2309
- const GATE_LABELS = { approve: 'Approve', skip: 'Skip the judge', fix: 'Fix first' };
2274
+ const GATE_LABELS = { approve: 'Approve the spend', skip: 'Skip the spend', fix: 'Fix first' };
2310
2275
  function cmdSignoff(argv) {
2311
2276
  const { values } = parseArgs({ args: argv, options: { run: { type: 'string' } } });
2312
2277
  const repo = repoRoot();
@@ -2336,11 +2301,11 @@ function cmdSignoff(argv) {
2336
2301
  const more = findings.length > 1 ? ` (and ${findings.length - 1} more)` : '';
2337
2302
  msg = {
2338
2303
  contract: 1, kind: 'gate', schema: 1, key: `dd-gate-${id}`, filer: 'done-done',
2339
- title: text('title', spend ? `Spend ${spend} on the ${cfg.app} ${drove} run?` : `Move the ${cfg.app} ${drove} run on past its judge?`, 160),
2304
+ title: text('title', spend ? `Spend ${spend} on the ${cfg.app} ${drove} run?` : `End the ${cfg.app} ${drove} run its judge did not pass, to fix it?`, 160),
2340
2305
  about: text('about', `${bare(findings[0].what)}${more}`, 500), run: runRef(head.commit),
2341
- // A spend gate takes approve or skip only (respond refuses fix there).
2342
- options: (spend ? ['approve', 'skip'] : ['approve', 'skip', 'fix']).map((value) => ({ value, label: GATE_LABELS[value] })),
2343
- recommend_value: findings.some((f) => f.severity === 'error') ? 'fix' : 'approve', close: 'done',
2306
+ // A spend gate takes approve or skip; the judge gate only fix, since nothing moves a run past its judge.
2307
+ options: (spend ? ['approve', 'skip'] : ['fix']).map((value) => ({ value, label: GATE_LABELS[value] })),
2308
+ recommend_value: spend ? 'approve' : 'fix', close: 'done',
2344
2309
  };
2345
2310
  file = join(root, id, 'signoff-gate.json');
2346
2311
  }
@@ -2432,197 +2397,176 @@ function ensureIgnored(repo, rel) {
2432
2397
  appendFileSync(file, `${text && !text.endsWith('\n') ? '\n' : ''}${entry}\n`);
2433
2398
  return true;
2434
2399
  }
2435
- // `init` also scaffolds the project's verify skill from the repo's own files: a port of the
2436
- // the create-verification-skill interview (surface, run, drive, observe, isolate).
2437
- // Every line is an action: a detected handle gets concrete drive and capture steps, and a blank detection cannot
2438
- // fill gets the steps to find it and record it there, never an invented command. An existing skill is never
2439
- // overwritten, so re-run is idempotent.
2440
- function headText(p, max = 12000) {
2441
- try {
2442
- if (!lstatSync(p).isFile())
2443
- return '';
2444
- return readFileSync(p, 'utf8').slice(0, max);
2445
- }
2446
- catch {
2447
- return '';
2448
- }
2449
- }
2450
- // Where a drive stops and how it records what it found, for every step that asks it to find something.
2451
- 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)';
2400
+ const STR = { type: 'string' };
2401
+ const SCAFFOLD_SCHEMA = {
2402
+ type: 'object',
2403
+ properties: {
2404
+ surface: STR,
2405
+ launch: { type: 'object', properties: { steps: STR, source: STR, safe: { type: 'boolean' }, why: STR }, required: ['steps', 'source', 'safe', 'why'], additionalProperties: false },
2406
+ ready: STR,
2407
+ harness: STR,
2408
+ observe: STR,
2409
+ isolate: STR,
2410
+ features: { type: 'array', minItems: 1, items: { type: 'object', properties: { title: STR, reach: STR, drive: STR, source: STR, capture: { type: 'string', enum: ['screenshot', 'text'] } }, required: ['title', 'reach', 'drive', 'source', 'capture'], additionalProperties: false } },
2411
+ },
2412
+ required: ['surface', 'launch', 'ready', 'harness', 'observe', 'isolate', 'features'],
2413
+ additionalProperties: false,
2414
+ };
2415
+ const NO_EDIT = 'The drive changes no repo file: note findings in the run\'s report; after the drive, run done-done maintain to see what drifted, then fix the skill';
2416
+ const MAX_FEATURES = 5;
2452
2417
  const slug = (t) => t.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'feature';
2453
- const SKIP_DIRS = new Set(['node_modules', 'dist', 'build', 'out', 'vendor', 'coverage', 'target', 'venv', '__pycache__', 'bower_components']);
2454
- const SOURCE_FILE = /\.(m?[jt]s|py|go|rb|rs|php)$/;
2455
- // Source files under `dir`, skipping hidden dirs (.git, .venv, .next, ...) and dependency and build output. The cap
2456
- // applies while walking, so a large vendored tree never fills the sample before the app's own files are reached.
2457
- function sourceFiles(dir, max, out = []) {
2418
+ // The agent's text as one sentence: its own final period is dropped so the template adds exactly one.
2419
+ const clause = (s) => s.trim().replace(/\.+$/, '');
2420
+ // A cited path as the tracked file list spells it: no leading ./ and no :line suffix.
2421
+ const cited = (s) => s.trim().replace(/^\.\//, '').replace(/:\d+(-\d+)?$/, '');
2422
+ // ponytail: the agent sees at most CONTEXT_CHARS of file text, each file cut at FILE_CHARS, ranked by path only
2423
+ // (manifests, proof scripts, source); the prompt names what was left out. Rank by content when a repo needs more.
2424
+ const CONTEXT_CHARS = 150_000;
2425
+ const FILE_CHARS = 20_000;
2426
+ const LISTED = 500;
2427
+ const MANIFEST = /^(readme(\.\w+)?|package\.json|makefile|justfile|procfile|pyproject\.toml|cargo\.toml|go\.mod|docker-compose\.ya?ml|dockerfile|\.done-done\.yaml)$/i;
2428
+ const SECRET = /(^|\/)\.env(\.[^/]*)?$|secret|credential|token|password|\.(pem|key|p12)$/i;
2429
+ const DOCKERFILE = /(^|\/)dockerfile(\.[^/]*)?$/i;
2430
+ const PROOF = /(^|[/._-])(scripts?|verify|e2e|smoke|proof|stub|fake|demo)([/._-]|$)/i;
2431
+ const SOURCE_FILE = /\.([cm]?[jt]sx?|py|go|rb|rs|php|sh|vue|svelte|html|css|java|kt|swift|c|h|cpp|cs|dart|scala|ex)$/;
2432
+ const DATA_FILE = /\.(json|ya?ml|md)$/i;
2433
+ // A tracked regular file's text, or undefined for a symlink, a binary, a file over 1 MB or one that cannot be read.
2434
+ function repoText(p) {
2458
2435
  try {
2459
- for (const e of readdirSync(dir, { withFileTypes: true })) {
2460
- if (out.length >= max)
2461
- break;
2462
- const p = join(dir, e.name);
2463
- if (e.isDirectory()) {
2464
- if (!e.name.startsWith('.') && !SKIP_DIRS.has(e.name) && !(e.name === 'components' && dir.endsWith(`${sep}src`)))
2465
- sourceFiles(p, max, out);
2466
- }
2467
- else if (e.isFile() && SOURCE_FILE.test(e.name))
2468
- out.push(p);
2469
- }
2470
- }
2471
- catch {
2472
- // An unreadable directory contributes nothing; the scan runs on the rest.
2473
- }
2474
- return out;
2475
- }
2476
- // Surface, run, drive, observe and features read from package.json, Makefile, routes,
2477
- // CLI commands and docs: the repo's own files, never invention.
2478
- function detectApp(repo) {
2479
- let pkg = {};
2480
- try {
2481
- pkg = JSON.parse(headText(join(repo, 'package.json'), Infinity) || '{}');
2436
+ const st = lstatSync(p);
2437
+ if (!st.isFile() || st.size > 1 << 20)
2438
+ return undefined;
2439
+ const text = readFileSync(p, 'utf8');
2440
+ return text.includes('\0') ? undefined : text;
2482
2441
  }
2483
2442
  catch {
2484
- pkg = {};
2485
- }
2486
- if (!pkg || typeof pkg !== 'object' || Array.isArray(pkg))
2487
- pkg = {};
2488
- const scripts = pkg.scripts && typeof pkg.scripts === 'object' ? pkg.scripts : {};
2489
- const deps = Object.keys({ ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) });
2490
- const has = (...names) => names.some((n) => deps.includes(n));
2491
- const bins = typeof pkg.bin === 'string' ? [pkg.bin] : pkg.bin && typeof pkg.bin === 'object' ? Object.values(pkg.bin).filter((v) => typeof v === 'string') : [];
2492
- const make = headText(join(repo, 'Makefile'), 4000);
2493
- let text = `${scripts.dev ?? ''}\n${scripts.start ?? ''}\n${make}`;
2494
- for (const f of sourceFiles(repo, 40))
2495
- text += `\n${headText(f, 6000)}`;
2496
- const routes = [...text.matchAll(/\b(app|router|server|api|fastify|routes|r)\.(get|post|put|patch|delete|del)\(\s*['"`](\/[^'"`\s]*)/g)]
2497
- .map((m) => `${m[2] === 'del' ? 'DELETE' : m[2].toUpperCase()} ${m[3]}`)
2498
- .filter((r, i, a) => a.indexOf(r) === i);
2499
- const commands = [...text.matchAll(/\.command\(\s*['"`]([^'"`\s\]]+)/g)]
2500
- .map((m) => m[1])
2501
- .filter((c, i, a) => a.indexOf(c) === i);
2502
- const browserTests = has('playwright', '@playwright/test', 'cypress', 'puppeteer');
2503
- const web = browserTests || has('react', 'next', 'vue', 'nuxt', 'svelte', 'solid', 'vite', 'webpack');
2504
- const cliish = bins.length > 0 || commands.length > 0 || has('commander', 'yargs', 'oclif', 'cac');
2505
- const api = routes.length > 0 || has('express', 'fastify', 'koa', 'hono');
2506
- const surface = web ? 'web UI' : cliish ? 'CLI' : api ? 'API/service' : 'app (unrecognized surface)';
2507
- const launch = scripts.dev ? `npm run dev (${scripts.dev})`
2508
- : scripts.start ? `npm start (${scripts.start})`
2509
- : bins.length ? `node ${bins[0]} --help`
2510
- : make ? `Read the Makefile for the target that starts the app (dev, run, serve or start) and start it with \`make <target>\`; ${RECORD}.`
2511
- : `No start script, bin or Makefile was found: read README.md and the entry file for how the app starts, and start it; ${RECORD}.`;
2512
- const port = `${scripts.dev ?? ''} ${scripts.start ?? ''}`.match(/(?:localhost:|--port[= ])(\d{4,5})/)?.[1];
2513
- const driver = browserTests ? 'browser' : cliish ? 'tty' : api ? 'curl' : web ? 'browser' : 'shell';
2514
- const ready = port && (driver === 'browser' || driver === 'curl')
2515
- ? `\`curl -sS -o /dev/null http://localhost:${port}/\` exits 0. BASE below is http://localhost:${port}`
2516
- : driver === 'browser' || driver === 'curl'
2517
- ? `the launch output prints the app URL and \`curl -sS -o /dev/null <that URL>\` exits 0. BASE below is that URL; ${RECORD}`
2518
- : 'the launch command runs with no error (exit 0, or a prompt waiting for input)';
2519
- const harness = browserTests ? 'browser/CDP (Playwright)' : {
2520
- tty: 'tmux/PTY',
2521
- curl: 'plain HTTP (curl)',
2522
- browser: 'a browser tool (for example chrome-devtools-axi)',
2523
- shell: 'a shell (run the launch command and read its output)',
2524
- }[driver];
2525
- const observe = web
2526
- ? 'screenshots of the resulting state the outcome is about'
2527
- : cliish
2528
- ? 'terminal transcripts, exit codes and side effects (files written, rows inserted)'
2529
- : api
2530
- ? 'response bodies, status codes and side effects (rows inserted, messages sent)'
2531
- : 'screenshots of a page it shows, or its output saved as text when the outcome is text';
2532
- const isolate = web || port
2533
- ? 'Drive one instance at a time unless .done-done.yaml has an instance block, which gives each run its own port and HOME'
2534
- : 'each run uses its own invocation; never drive a shared session the user owns';
2535
- const runCli = (args) => bins.length ? `\`node ${bins[0]} ${args}\``
2536
- : scripts.start ? `\`npm start -- ${args}\``
2537
- : `the launch command from Launch with \`${args}\``;
2538
- const shot = (s) => `screenshot the resulting page to $DONE_DONE_EVIDENCE_ROOT/${s}.png`;
2539
- const saved = (s) => `save the output and exit status to $DONE_DONE_EVIDENCE_ROOT/${s}.txt`;
2540
- const open = 'open BASE in the browser, take a snapshot and act on the page by its visible labels';
2541
- const params = 'replace each :param or ${...} in the path with a real value from the handler or seed data';
2542
- 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';
2543
- const found = [
2544
- ...(driver === 'browser' || driver === 'curl' ? routes : []).map((r) => {
2545
- const [method, path] = r.split(' ');
2546
- const writes = method === 'GET' ? '' : `; ${spends}`;
2547
- 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}`
2548
- : method === 'GET' ? `${open.replace('BASE', `BASE${path}`)}; ${params}`
2549
- : `find the form or button that sends ${r} (search the source for "${path}"), ${params}, then fill and submit it as a user does${writes}`;
2550
- return { base: slug(r), title: r, reach: `the ${r} route`, drive };
2551
- }),
2552
- ...(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` })),
2553
- ];
2554
- const fallback = {
2555
- browser: { title: 'Main page', reach: 'BASE, the first page a user opens', drive: open },
2556
- 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}` },
2557
- 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}` },
2558
- 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}` },
2559
- }[driver];
2560
- if (!found.length)
2561
- found.push({ base: 'main', ...fallback });
2562
- const features = [];
2563
- for (const f of found) {
2564
- let s = f.base;
2565
- for (let n = 2; features.some((g) => g.slug === s); n++)
2566
- s = `${f.base}-${n}`;
2567
- features.push({ slug: s, title: f.title, reach: f.reach, drive: f.drive, capture: (driver === 'browser' ? shot : saved)(s) });
2443
+ return undefined;
2444
+ }
2445
+ }
2446
+ // The repo as the agent sees it: the tracked file list (without the skill folder) and the text of the files that
2447
+ // say how the app starts and how the repo proves it.
2448
+ function repoContext(repo, skill) {
2449
+ const listed = execFileSync('git', ['ls-files', '-z'], { cwd: repo, encoding: 'utf8', maxBuffer: Infinity, stdio: ['ignore', 'pipe', 'pipe'] });
2450
+ const own = `${skill.replace(/\\/g, '/').replace(/\/+$/, '')}/`;
2451
+ const files = listed.split('\0').filter((f) => f && !f.startsWith(own));
2452
+ const rank = (f) => (MANIFEST.test(f) ? 0 : DATA_FILE.test(f) ? 3 : PROOF.test(f) || /(^|\/)bin\//.test(f) || DOCKERFILE.test(f) ? 1 : SOURCE_FILE.test(f) ? 2 : 3);
2453
+ const shown = (f) => !SECRET.test(f) && rank(f) < 3;
2454
+ let total = 0;
2455
+ const parts = [];
2456
+ const read = new Set();
2457
+ const left = [];
2458
+ for (const f of files.filter(shown).sort((a, b) => rank(a) - rank(b))) {
2459
+ const body = total < CONTEXT_CHARS ? repoText(join(repo, f)) : undefined;
2460
+ const kept = body === undefined ? undefined : body.length > FILE_CHARS ? `${body.slice(0, FILE_CHARS)}\n[truncated after ${FILE_CHARS} characters]` : body;
2461
+ if (kept === undefined || total + kept.length > CONTEXT_CHARS) {
2462
+ left.push(f);
2463
+ continue;
2464
+ }
2465
+ total += kept.length;
2466
+ read.add(f);
2467
+ parts.push(`File ${f}:\n${kept}`);
2568
2468
  }
2569
- const shown = features.slice(0, 5);
2570
- return { surface, launch, ready, harness, observe, isolate, features: shown, omitted: features.length - shown.length };
2469
+ const list = `Tracked files (${files.length}${files.length > LISTED ? `, the first ${LISTED} shown` : ''}): ${files.slice(0, LISTED).join(', ') || 'none'}`;
2470
+ const out = left.length ? `\n\n[left out: ${left.length} files cannot be read or pass the ${CONTEXT_CHARS}-character cap: ${left.slice(0, 20).join(', ')}${left.length > 20 ? `, +${left.length - 20} more` : ''}]` : '';
2471
+ return { files: new Set(files), read, text: `${list}\n\n${parts.join('\n\n')}${out}` };
2571
2472
  }
2572
- function verifySkillMd(app, d) {
2473
+ function scaffoldPrompt(cfg, context, toolless) {
2474
+ return `You write the verify skill for the app ${cfg.app}, which \`done-done init\` saves at ${cfg.skill}: how an agent launches this repo's app safely, drives it the way a user does, and captures proof that it works. Do not edit, create or delete any file, and do not run the app or any command.
2475
+ ${toolless ? "You have no tools: the repo's tracked file list and the text of its key files are below, and they are all you can read." : 'The repository is your working directory: read the files below first, then any other file you need.'} Use only what the repo shows: never invent a command, path, flag, route or label. Never open a secret-looking file: a .env or .env.* file, a path whose name contains secret, credential, token or password, or a *.pem, *.key or *.p12 file. Never write a secret value into steps or why; name the environment variable instead.
2476
+
2477
+ - launch: how to start the app for a proof. Prefer the repo's own proof path: a verify, e2e, smoke or demo script, or a journey in .done-done.yaml, that runs the app with a stub, fake or demo engine and its own state (a temporary HOME or data folder), the way the repo proves itself; copy its exact command and environment flags. {steps: the exact command on one line, with every environment flag, whose first command runs source (or passes it to an interpreter such as bash, node or npx tsx), source: the repo file they come from (for a .done-done.yaml journey, the script or file that journey runs, never .done-done.yaml itself), safe: true only when these steps use a stub or demo mode or isolated state, so no real account, paid service or the owner's own state is touched, why: what makes it safe, or when safe is false what it would touch}. When the repo has no such path, give the closest launch with safe false; never present a command that uses real accounts or the owner's own state (for example a start script that runs the real engine) as safe.
2478
+ - surface: what a user drives: web UI, CLI, API/service, desktop app or other.
2479
+ - ready: the check that shows the launched app is ready to drive (a URL that answers, a line it prints, an exit code).
2480
+ - harness: what drives it: a browser tool, a terminal (tmux or PTY), curl, or a shell.
2481
+ - observe: the proof to capture: screenshots of the resulting state, terminal transcripts and exit codes, response bodies, files written.
2482
+ - isolate: how each run keeps its own instance and state, and never drives a session or account the user owns.
2483
+ - features: up to ${MAX_FEATURES} things a user does with the app, the most important first, each {title, reach: how a user gets there, drive: the concrete steps through the repo's real handles (the command and its arguments, the route, the page label, the journey name), source: the repo file that shows the feature, capture: screenshot for a screen, text for output}.
2484
+ Each source is one path exactly as the tracked file list spells it, with no other words (scripts/verify/drive.sh, never a list or a note); launch.source may be empty only when safe is false.
2485
+
2486
+ ${context}`;
2487
+ }
2488
+ // Why an answer cannot be rendered into the skill, or '' when it can. A citation must be a tracked file the agent was
2489
+ // shown: shown is every tracked file for claude, which reads the repo itself, and the text plain code gave a tool-less agent.
2490
+ function scaffoldProblem(a, tracked, shown) {
2491
+ const s = a;
2492
+ const text = (v) => typeof v === 'string' && clause(v) !== '';
2493
+ const citation = (p) => {
2494
+ const c = cited(p);
2495
+ if (!tracked.has(c))
2496
+ return `cites ${c}, which is not a tracked file`;
2497
+ if (SECRET.test(c))
2498
+ return `cites ${c}, a secret-looking file, which can not be a source`;
2499
+ return shown.has(c) ? '' : `cites ${c}, whose text is not in the prompt`;
2500
+ };
2501
+ for (const k of ['surface', 'ready', 'harness', 'observe', 'isolate'])
2502
+ if (!text(s[k]))
2503
+ return `missing ${k}`;
2504
+ if (!s.launch || typeof s.launch !== 'object' || !text(s.launch.steps) || !text(s.launch.why) || typeof s.launch.safe !== 'boolean' || typeof s.launch.source !== 'string')
2505
+ return 'missing launch steps, source, safe or why';
2506
+ const launchCite = cited(s.launch.source) ? citation(s.launch.source) : '';
2507
+ if (launchCite)
2508
+ return `the launch ${launchCite}`;
2509
+ if (!Array.isArray(s.features) || !s.features.length)
2510
+ return 'has no features';
2511
+ for (const f of s.features) {
2512
+ if (!f || typeof f !== 'object' || !text(f.title) || !text(f.reach) || !text(f.drive) || !text(f.source) || !['screenshot', 'text'].includes(f.capture))
2513
+ return 'has a feature without a title, reach, drive, source or capture';
2514
+ const why = citation(f.source);
2515
+ if (why)
2516
+ return `the feature "${f.title}" ${why}`;
2517
+ }
2518
+ return '';
2519
+ }
2520
+ function verifySkillMd(app, agent, s, isolated, features, omitted) {
2573
2521
  const name = `verify-${app}`;
2522
+ const source = cited(s.launch.source);
2523
+ const launch = isolated
2524
+ ? `${s.launch.steps.trim()}\n\nSafe: ${clause(s.launch.why)} (from \`${source}\`).`
2525
+ : `Not isolated: ${clause(s.launch.why)}. The launch below is not shown to use a stub or demo engine with its own state, so it may use real accounts or the owner's own state. ${NO_EDIT}.\n\n${s.launch.steps.trim()}${source ? `\n\nFrom \`${source}\`.` : ''}`;
2574
2526
  const found = [
2575
- ...d.features.map((f) => `- ${f.title}: ${f.reach}`),
2576
- ...(d.omitted ? [`- ${d.omitted} more found and not listed; add them by hand or run maintain later.`] : []),
2527
+ ...features.map((f) => `- [${f.title}](features/${f.slug}.md): ${clause(f.reach)} (\`${cited(f.source)}\`)`),
2528
+ ...(omitted ? [`- ${omitted} more found and not listed; add them by hand or run maintain later.`] : []),
2577
2529
  ];
2578
2530
  return `---
2579
- name: ${name}
2580
- description: Drive the ${app} ${d.surface} the way a user does and capture proof it works. Use when proving a ${app} change, capturing evidence, or keeping the feature map honest.
2531
+ name: ${JSON.stringify(name)}
2532
+ description: ${JSON.stringify(`Drive the ${app} ${s.surface} the way a user does and capture proof it works. Use when proving a ${app} change, capturing evidence, or keeping the feature map honest.`)}
2581
2533
  ---
2582
2534
 
2583
2535
  # ${name}
2584
2536
 
2585
- 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.
2537
+ Written by \`done-done init\` through ${agent} from this repo's own files (surface: ${s.surface}). ${NO_EDIT}.
2586
2538
 
2587
2539
  ## Launch
2588
2540
 
2589
- ${d.launch}
2541
+ ${launch}
2590
2542
 
2591
- Ready when ${d.ready}. Tear down what you started; never kill by process name.
2543
+ Ready: ${clause(s.ready)}. Tear down what you started; never kill by process name.
2544
+ Isolation: ${clause(s.isolate)}.
2592
2545
  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.
2593
2546
 
2594
- ## Doctor
2595
-
2596
- Read-only: is this instance worth driving? Process up, right build, port owned by us, auth valid. ${d.isolate}.
2597
-
2598
2547
  ## Drive
2599
2548
 
2600
- Harness: ${d.harness}. Prefer the repo's real handles (routes, commands, labels) over coordinates:
2549
+ Harness: ${clause(s.harness)}. Drive through the repo's real handles that each feature file names:
2601
2550
 
2602
2551
  ${found.join('\n')}
2603
2552
 
2604
2553
  ## Evidence
2605
2554
 
2606
- 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:
2607
-
2608
- - 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.
2609
- - 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}.
2610
- - 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.
2611
- - Secrets: never write a secret (key, token, password, Bearer header) into evidence or logs; verify scans them and fails the run on a hit.
2612
- - Spends: a journey with \`spends:\` runs only after the user approves the spend at verify's gate; never run it another way.
2613
- - 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.
2555
+ What to capture: ${clause(s.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.
2614
2556
 
2615
2557
  ## Cleanup
2616
2558
 
2617
2559
  Stop the instances this run started and remove scratch state. Evidence survives cleanup: never delete the run folder.
2618
2560
  `;
2619
2561
  }
2620
- function verifyFeatureMd(app, d, f) {
2621
- 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`;
2562
+ function verifyFeatureMd(app, s, f) {
2563
+ const capture = f.capture === 'screenshot' ? `screenshot the resulting page to $DONE_DONE_EVIDENCE_ROOT/${f.slug}.png` : `save the output and exit status to $DONE_DONE_EVIDENCE_ROOT/${f.slug}.txt`;
2564
+ const drive = clause(f.drive);
2565
+ return `# ${f.title}\n\nPart of the ${app} verification map. Source: \`${cited(f.source)}\`.\n\n## How to get to it (user POV)\n\n${clause(f.reach)}.\n\n## Driving it\n\nHarness: ${clause(s.harness)}.\n\n1. Launch the app as SKILL.md says and wait until it is ready.\n2. ${drive[0].toUpperCase()}${drive.slice(1)}.\n3. Capture: ${capture}, as SKILL.md's Evidence section says.\n4. Stop what you started.\n\n${NO_EDIT}.\n`;
2622
2566
  }
2623
- function verifyFeaturesMd(app, d) {
2624
- const index = d.features.map((f) => `- [${f.title}](${f.slug}.md): ${f.reach}`).join('\n');
2625
- const more = d.omitted ? `\n\n${d.omitted} more found and not listed; add them by hand or run maintain later.` : '';
2567
+ function verifyFeaturesMd(app, features, omitted) {
2568
+ const index = features.map((f) => `- [${f.title}](${f.slug}.md): ${clause(f.reach)}`).join('\n');
2569
+ const more = omitted ? `\n\n${omitted} more found and not listed; add them by hand or run maintain later.` : '';
2626
2570
  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`;
2627
2571
  }
2628
2572
  // The first existing component on the way from the repo to `p` that is a symlink, if any: a cloned repo's symlink
@@ -2641,33 +2585,115 @@ function symlinkOnPath(repo, p) {
2641
2585
  }
2642
2586
  return undefined;
2643
2587
  }
2644
- // Writes only missing files; an existing skill is left byte-identical. SKILL.md is written last, so it marks a
2645
- // finished scaffold. Every write target is checked before the first write.
2646
- function scaffoldVerify(repo, cfg) {
2588
+ const SCAFFOLD_HELP = 'The verify skill was not written: fix the cause verify_skill names, then re-run: done-done init';
2589
+ // All-or-nothing: when any target (SKILL.md, features/README.md, a features file) exists as a file or directory,
2590
+ // nothing is written and the paths are named; otherwise every file is written into a temp dir inside the skill's
2591
+ // parent and moved into place, so a failed write leaves no partial skill. SKILL.md is moved last, so it marks a
2592
+ // finished scaffold. The skill folder is checked for symlinks before the agent is asked, and every target before
2593
+ // the first write. failed: the agent or the write failed, so init fails and the user fixes the cause.
2594
+ async function scaffoldVerify(repo, cfg) {
2647
2595
  const dir = join(repo, cfg.skill);
2596
+ const linked = (ps) => ps.map((p) => symlinkOnPath(repo, p)).find((l) => l !== undefined);
2597
+ const skipLink = (link) => ({ status: `skipped, ${shown(repo, link)} is a symlink; write it by hand or remove the link` });
2598
+ const blocked = (ps) => ({ status: `skipped: ${ps.map((p) => shown(repo, p)).join(', ')} already exist; delete the skill folder to regenerate` });
2599
+ let link = linked([join(dir, 'features', 'README.md'), join(dir, 'SKILL.md')]);
2600
+ if (link !== undefined)
2601
+ return skipLink(link);
2648
2602
  if (existsSync(join(dir, 'SKILL.md')))
2649
- return 'unchanged';
2650
- const d = detectApp(repo);
2651
- const files = new Map([[join('features', 'README.md'), verifyFeaturesMd(cfg.app, d)]]);
2652
- for (const f of d.features)
2653
- files.set(join('features', `${f.slug}.md`), verifyFeatureMd(cfg.app, d, f));
2654
- files.set('SKILL.md', verifySkillMd(cfg.app, d));
2655
- const targets = [...files].map(([rel, body]) => ({ p: join(dir, rel), body }));
2656
- const link = targets.map(({ p }) => symlinkOnPath(repo, p)).find((l) => l !== undefined);
2603
+ return blocked([join(dir, 'SKILL.md')]);
2604
+ if (existsSync(join(dir, 'features', 'README.md')))
2605
+ return blocked([join(dir, 'features', 'README.md')]);
2606
+ // No agent: init's agent row already asks the user to set one.
2607
+ if (!cfg.agent)
2608
+ return { status: 'not written: no agent is set, and the agent writes the verify skill' };
2609
+ const agent = selectAgent(cfg);
2610
+ // As in maintain, only claude reads the repo with its own tools; every other agent gets the files from plain code.
2611
+ const toolless = agent.agent !== 'claude';
2612
+ if (toolless && toollessReason(agent.agent))
2613
+ return { status: `not written: ${toollessReason(agent.agent)}; set the verify skill agent to claude, opencode, pi or acp with done-done init --agent <agent> --model <model>`, failed: true };
2614
+ const context = repoContext(repo, cfg.skill);
2615
+ // Every schema-valid answer cites tracked files, so with none there is nothing to ask for.
2616
+ if (!context.files.size)
2617
+ return { status: "not written: the repo has no tracked files to cite; commit the repo's files, then re-run init", failed: true };
2618
+ const record = join(home(), 'agents', `init-${new Date().toISOString().replace(/[:.]/g, '-')}-${agent.agent.replace(/[^\w-]/g, '-')}.json`);
2619
+ const a = await askAgent({ ...agent, prompt: scaffoldPrompt(cfg, context.text, toolless), schema: SCAFFOLD_SCHEMA, cwd: repo, tools: toolless ? 'none' : 'read', record });
2620
+ const where = a.record ? `; record ${a.record}` : a.recordError ? `; the call record could not be written: ${a.recordError}` : '';
2621
+ if (!a.ok)
2622
+ return { status: `not written: ${agent.agent} did not answer: ${a.error}; done-done doctor --probe checks the agent${where}`, failed: true };
2623
+ const problem = scaffoldProblem(a.answer, context.files, toolless ? context.read : context.files);
2624
+ if (problem)
2625
+ return { status: `not written: ${agent.agent}'s answer is unusable: ${problem}${where}`, failed: true };
2626
+ const s = a.answer;
2627
+ const source = cited(s.launch.source);
2628
+ const isolated = s.launch.safe && source !== '' && !MANIFEST.test(basename(source));
2629
+ const features = [];
2630
+ for (const f of s.features.slice(0, MAX_FEATURES)) {
2631
+ let n = slug(f.title);
2632
+ for (let i = 2; n === 'readme' || features.some((g) => g.slug === n); i++)
2633
+ n = `${slug(f.title)}-${i}`;
2634
+ features.push({ ...f, slug: n });
2635
+ }
2636
+ const omitted = s.features.length - features.length;
2637
+ const files = new Map([[join('features', 'README.md'), verifyFeaturesMd(cfg.app, features, omitted)]]);
2638
+ for (const f of features)
2639
+ files.set(join('features', `${f.slug}.md`), verifyFeatureMd(cfg.app, s, f));
2640
+ files.set('SKILL.md', verifySkillMd(cfg.app, agent.agent, s, isolated, features, omitted));
2641
+ const targets = [...files].map(([rel, body]) => ({ rel, p: join(dir, rel), body }));
2642
+ link = linked(targets.map(({ p }) => p));
2657
2643
  if (link !== undefined)
2658
- return `skipped, ${shown(repo, link)} is a symlink; write it by hand or remove the link`;
2659
- for (const { p, body } of targets) {
2660
- if (existsSync(p))
2661
- continue;
2662
- try {
2663
- mkdirSync(dirname(p), { recursive: true });
2664
- writeFileSync(p, body);
2644
+ return skipLink(link);
2645
+ const parent = dirname(dir);
2646
+ let tmp;
2647
+ const moved = [];
2648
+ const made = [];
2649
+ const mk = (d) => {
2650
+ const missing = [];
2651
+ for (let c = d; !existsSync(c); c = dirname(c))
2652
+ missing.unshift(c);
2653
+ made.push(...missing);
2654
+ mkdirSync(d, { recursive: true });
2655
+ };
2656
+ const undo = () => {
2657
+ for (const p of [...moved, ...(tmp ? [tmp] : [])]) {
2658
+ try {
2659
+ rmSync(p, { recursive: true, force: true });
2660
+ }
2661
+ catch { }
2665
2662
  }
2666
- catch (e) {
2667
- return `skipped, ${shown(repo, p)} could not be written (${e.message}); fix it and re-run init`;
2663
+ for (const d of made.reverse()) {
2664
+ try {
2665
+ rmdirSync(d);
2666
+ }
2667
+ catch { }
2668
+ }
2669
+ };
2670
+ try {
2671
+ mk(parent);
2672
+ tmp = mkdtempSync(join(parent, `${basename(dir)}.tmp-`));
2673
+ for (const { rel, body } of targets) {
2674
+ mkdirSync(dirname(join(tmp, rel)), { recursive: true });
2675
+ writeFileSync(join(tmp, rel), body);
2668
2676
  }
2677
+ const have = targets.filter(({ p }) => existsSync(p)).map(({ p }) => p);
2678
+ if (have.length) {
2679
+ undo();
2680
+ return blocked(have);
2681
+ }
2682
+ for (const { rel, p } of targets) {
2683
+ mk(dirname(p));
2684
+ renameSync(join(tmp, rel), p);
2685
+ moved.push(p);
2686
+ }
2687
+ }
2688
+ catch (e) {
2689
+ undo();
2690
+ return { status: `not written: the verify skill could not be written (${e.message}); nothing was left behind, so fix the cause and re-run init`, failed: true };
2669
2691
  }
2670
- return 'written';
2692
+ try {
2693
+ rmSync(tmp, { recursive: true, force: true });
2694
+ }
2695
+ catch { }
2696
+ return { status: `written by ${agent.agent}${isolated ? '' : '; its launch is not isolated, so read Launch before a drive'}` };
2671
2697
  }
2672
2698
  // The session-start hook `init --hook <agent>` adds; the agent shows its stdout to the session.
2673
2699
  // Without done-done on PATH it prints nothing and exits 0, so it never breaks a session.
@@ -2878,8 +2904,7 @@ async function pick(kind, cfg) {
2878
2904
  try {
2879
2905
  const menu = kind === 'agent' ? [...AGENTS, ACP_TARGET] : JUDGES;
2880
2906
  const label = (a) => (a === ACP_TARGET ? `${a} (an agent your acpx bridge starts)` : a);
2881
- const doer = cfg.agent ? `the one that did the work (${cfg.agent})` : 'the agent that does the work';
2882
- 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:`;
2907
+ 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, and none is set. Judges:';
2883
2908
  process.stderr.write(`${head}\n${menu.map((a, i) => ` ${i + 1}) ${label(a)}\n`).join('')}`);
2884
2909
  for (;;) {
2885
2910
  let agent = '';
@@ -2966,8 +2991,7 @@ async function withChoice(kind, cfg) {
2966
2991
  cfg.judge.model = choice.model;
2967
2992
  }
2968
2993
  }
2969
- // The user's judge choice, checked before anything is written: an agent that can judge, and never the author's own
2970
- // agent and model (selectJudge refuses that pair) unless judge.allow_same.
2994
+ // The user's judge choice, checked before anything is written: an agent done-done can judge through, with a model.
2971
2995
  function checkJudge(cfg, agent, model) {
2972
2996
  if (!JUDGES.includes(agent))
2973
2997
  throw new UsageError(`done-done cannot judge through ${agent}; it judges through ${JUDGES.join(', ')}`, noJudge().help);
@@ -2975,8 +2999,6 @@ function checkJudge(cfg, agent, model) {
2975
2999
  throw new UsageError(`--judge-model ${model}: an opencode model is provider/model, e.g. ${CHEAP.opencode.model}`);
2976
3000
  if (!model && !modelFor(cfg, agent))
2977
3001
  throw new UsageError(`judge: ${agent} has no default model, so a judge needs one`, `Save the judge with its model: done-done init --judge ${agent} --judge-model <model>`);
2978
- if (cfg.agent)
2979
- selectJudge({ ...cfg, judge: { ...cfg.judge, route: routeOf(agent), model } }, selectAgent(cfg));
2980
3002
  }
2981
3003
  const commitFirst = (skill) => `.done-done.yaml and ${skill} before your first capture: check refuses proof from a tree with uncommitted changes`;
2982
3004
  // An absolute path inside the repo in a recipe: the recipe is committed, so it would name this checkout only.
@@ -3016,7 +3038,7 @@ async function cmdInit(argv) {
3016
3038
  const choice = values.agent !== undefined ? { agent: values.agent, model: values.model ?? '' } : !cfg.agent && interactive() ? await pick('agent', cfg) : undefined;
3017
3039
  if (choice)
3018
3040
  cfg.agent_config[choice.agent] = { ...cfg.agent_config[choice.agent], ...(choice.model && { model: choice.model }) };
3019
- // The judge likewise: --judge, else with none set the user's pick in a terminal; checked against the agent above.
3041
+ // The judge likewise: --judge, else with none set the user's pick in a terminal; checked that done-done can judge through it.
3020
3042
  const judgeChoice = values.judge !== undefined ? { agent: values.judge, model: values['judge-model'] ?? (cfg.judge.route === routeOf(values.judge) ? cfg.judge.model : '') } : !cfg.judge.route && interactive() ? await pick('judge', { ...cfg, agent: choice?.agent ?? cfg.agent }) : undefined;
3021
3043
  if (judgeChoice)
3022
3044
  checkJudge({ ...cfg, agent: choice?.agent ?? cfg.agent }, judgeChoice.agent, judgeChoice.model);
@@ -3030,7 +3052,7 @@ async function cmdInit(argv) {
3030
3052
  return { out: { agent: `unchanged (${cfg.agent})` }, help: [], code: PASS };
3031
3053
  return { out: { agent: 'not set' }, help: [`${noAgent().message}. ${noAgent().help}`], code: USAGE };
3032
3054
  };
3033
- // A judge not set is not an init failure: verify asks for it when it needs one.
3055
+ // A judge not set is not an init failure, since init gives no result: every command that does asks for it first.
3034
3056
  const writeJudge = () => judgeChoice ? { out: { judge: saveChoice('judge', judgeChoice) }, help: [] }
3035
3057
  : cfg.judge.route ? { out: { judge: `unchanged (${cfg.judge.route})` }, help: [] }
3036
3058
  : { out: { judge: 'not set' }, help: [`${noJudge().message}. ${noJudge().help}`] };
@@ -3069,14 +3091,15 @@ async function cmdInit(argv) {
3069
3091
  const added = !!rel && ensureIgnored(repo, rel);
3070
3092
  const root = shown(repo, fresh.evidence.root);
3071
3093
  const check = 'done-done check --outcome "<what you proved>" <files it recorded>';
3072
- const verify = scaffoldVerify(repo, fresh);
3073
- const agentHook = writeHook();
3074
3094
  const globalConfig = writeGlobal();
3075
3095
  const ag = writeAgent();
3076
3096
  const jg = writeJudge();
3097
+ const verify = await scaffoldVerify(repo, loadConfig(repo));
3098
+ const agentHook = writeHook();
3099
+ const code = ag.code !== PASS ? ag.code : verify.failed ? FAIL : PASS;
3077
3100
  emit({
3078
3101
  command: 'init',
3079
- result: ag.code === PASS ? 'pass' : 'fail',
3102
+ result: code === PASS ? 'pass' : 'fail',
3080
3103
  config: existed ? 'updated' : 'written',
3081
3104
  global_config: globalConfig,
3082
3105
  ...ag.out,
@@ -3085,11 +3108,11 @@ async function cmdInit(argv) {
3085
3108
  app: fresh.app,
3086
3109
  evidence_root: root,
3087
3110
  gitignore: added ? `added ${rel.replace(/\/+$/, '')}/` : 'unchanged',
3088
- verify_skill: verify,
3111
+ verify_skill: verify.status,
3089
3112
  ...agentHook.out,
3090
- help: [`Capture proof: done-done run ${journey}, then: ${check}`, ...agentHook.help, ...ag.help, ...jg.help, `Commit ${commitFirst(fresh.skill)}`],
3113
+ help: [`Capture proof: done-done run ${journey}, then: ${check}`, ...agentHook.help, ...ag.help, ...jg.help, ...(verify.failed ? [SCAFFOLD_HELP] : []), `Commit ${commitFirst(fresh.skill)}`],
3091
3114
  });
3092
- return ag.code;
3115
+ return code;
3093
3116
  }
3094
3117
  if (!existed) {
3095
3118
  // No evidence.root: proof goes to <local_root>/<app>, outside the repo, so it outlives this checkout.
@@ -3108,14 +3131,15 @@ app: ${JSON.stringify(cfg.app)}
3108
3131
  const added = !!rel && ensureIgnored(repo, rel);
3109
3132
  const root = shown(repo, cfg.evidence.root);
3110
3133
  const check = 'done-done check --outcome "<what you proved>" <files it recorded>';
3111
- const verify = scaffoldVerify(repo, cfg);
3112
- const agentHook = writeHook();
3113
3134
  const globalConfig = writeGlobal();
3114
3135
  const ag = writeAgent();
3115
3136
  const jg = writeJudge();
3137
+ const verify = await scaffoldVerify(repo, loadConfig(repo));
3138
+ const agentHook = writeHook();
3139
+ const code = ag.code !== PASS ? ag.code : verify.failed ? FAIL : PASS;
3116
3140
  emit({
3117
3141
  command: 'init',
3118
- result: ag.code === PASS ? 'pass' : 'fail',
3142
+ result: code === PASS ? 'pass' : 'fail',
3119
3143
  config: existed ? 'unchanged' : 'written',
3120
3144
  global_config: globalConfig,
3121
3145
  ...ag.out,
@@ -3123,7 +3147,7 @@ app: ${JSON.stringify(cfg.app)}
3123
3147
  app: cfg.app,
3124
3148
  evidence_root: root,
3125
3149
  gitignore: added ? `added ${rel.replace(/\/+$/, '')}/` : 'unchanged',
3126
- verify_skill: verify,
3150
+ verify_skill: verify.status,
3127
3151
  ...agentHook.out,
3128
3152
  help: [
3129
3153
  ...(Object.keys(cfg.journeys).length
@@ -3132,10 +3156,11 @@ app: ${JSON.stringify(cfg.app)}
3132
3156
  ...agentHook.help,
3133
3157
  ...ag.help,
3134
3158
  ...jg.help,
3159
+ ...(verify.failed ? [SCAFFOLD_HELP] : []),
3135
3160
  `Commit ${commitFirst(cfg.skill)}`,
3136
3161
  ],
3137
3162
  });
3138
- return ag.code;
3163
+ return code;
3139
3164
  }
3140
3165
  // The exact next command for a usage or config error, so an agent can recover without reading --help.
3141
3166
  function errorHelp(msg, cmd) {
@@ -3196,8 +3221,8 @@ function agentSettings(cfg, agent) {
3196
3221
  ].join('; ');
3197
3222
  }
3198
3223
  const modelPhrase = (agent, model) => (model ? `model ${model}` : `its own default model; set agent_config.${agent}.model to choose one`);
3199
- // Is this repo worth driving: git, a valid config and a writable evidence root are
3200
- // required; the verify skill, an agent CLI, the judge binary and ffprobe are optional.
3224
+ // Is this repo worth driving: git, a valid config, a writable evidence root, the agent and the judge are
3225
+ // required; the verify skill and ffprobe are listed but never fail it.
3201
3226
  // doctor changes nothing except the --probe call record (the agent itself may keep its own session files): a missing repo or config is a finding, never a crash.
3202
3227
  async function cmdDoctor(argv) {
3203
3228
  let values;
@@ -3301,12 +3326,12 @@ async function cmdDoctor(argv) {
3301
3326
  if (agent.agent === 'codex')
3302
3327
  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: '' });
3303
3328
  }
3304
- // The judge the user set (judge.route), kept apart from the agent.
3329
+ // The judge the user set (judge.route).
3305
3330
  let choice;
3306
3331
  let choiceError;
3307
3332
  if (cfg)
3308
3333
  try {
3309
- choice = selectJudge(cfg, agent ?? NO_AUTHOR);
3334
+ choice = selectJudge(cfg, judgeAuthor(cfg));
3310
3335
  }
3311
3336
  catch (e) {
3312
3337
  choiceError = e;
@@ -3331,24 +3356,16 @@ async function cmdDoctor(argv) {
3331
3356
  checks.push(ffprobe
3332
3357
  ? { check: 'ffprobe', status: 'ok', detail: `found at ${ffprobe}`, fix: '' }
3333
3358
  : { check: 'ffprobe', status: 'missing', detail: 'no ffprobe on PATH; video evidence is unverified, never a pass', fix: 'Install ffprobe (ffmpeg) to verify video evidence' });
3334
- // How far the judge is from the author, as selectJudge picks it: another agent is ok; the author's agent at another
3335
- // model or in a fresh session runs too, so it is a warning that never fails doctor.
3359
+ // How far the judge is from the author: shown, never refused, since the user's choice runs whatever it is.
3336
3360
  if (!cfg || !agent) {
3337
- checks.push(unknownRow('judge_choice', cfg && agentCfg ? 'unknown until the agent is set, since the judge is kept apart from it' : undefined));
3361
+ checks.push(unknownRow('judge_choice', cfg && agentCfg ? 'unknown until the agent is set' : undefined));
3338
3362
  }
3339
3363
  else if (!choice) {
3340
3364
  checks.push(choiceRow('judge_choice'));
3341
3365
  }
3342
3366
  else {
3343
3367
  const apart = { agent: 'a different agent from the author', model: 'the author\'s agent at a different model', 'session-only': 'the author\'s agent and model in a fresh session (session-only independence)' }[choice.independence];
3344
- const accepted = choice.independence === 'session-only' && cfg.judge.allow_same ? '; judge.allow_same accepts it' : '';
3345
- const apartOk = choice.independence === 'agent';
3346
- checks.push({
3347
- check: 'judge_choice',
3348
- status: apartOk ? 'ok' : 'warning',
3349
- detail: `${choice.route} (model ${choice.model}; from config) judges the author ${choice.author.agent} (model ${choice.author.model || 'none'}): ${apart}${accepted}`,
3350
- fix: apartOk ? '' : `Ask the user whether to judge with another agent or model (${JUDGES.join(' or ')}) and save it: done-done init --judge <agent> --judge-model <model>`,
3351
- });
3368
+ checks.push({ check: 'judge_choice', status: 'ok', detail: `${choice.route} (model ${choice.model}; from config) judges the author ${choice.author.agent} (model ${choice.author.model || 'none'}): ${apart}`, fix: '' });
3352
3369
  }
3353
3370
  if (values.probe && !agent)
3354
3371
  checks.push(unknownRow('probe'));
@@ -3365,7 +3382,7 @@ async function cmdDoctor(argv) {
3365
3382
  }
3366
3383
  if (agent)
3367
3384
  checks.push({ check: 'agent_settings', status: 'ok', detail: agentSettings(agentCfg, agent.agent), fix: '' });
3368
- const required = ['git', 'config', 'evidence_root', ...(values.probe ? ['probe'] : [])];
3385
+ const required = ['git', 'config', 'evidence_root', 'agent', 'judge', ...(values.probe ? ['probe'] : [])];
3369
3386
  const missingRequired = checks.filter((c) => c.status === 'missing' && required.includes(c.check));
3370
3387
  const result = { command: 'doctor', result: missingRequired.length ? 'fail' : 'pass', checks };
3371
3388
  // Several missing items can share one fix (set up the config once); name each fix only once.
@@ -3401,7 +3418,8 @@ async function cmdLook(argv) {
3401
3418
  const shown = cap(hits);
3402
3419
  emit({
3403
3420
  command: 'look',
3404
- result: hits.length ? 'fail' : 'pass',
3421
+ // look measures the layout; it gives no verdict on the outcome, which only the judged check does.
3422
+ result: hits.length ? 'fail' : 'clean',
3405
3423
  url,
3406
3424
  viewport: '390x844',
3407
3425
  capture,