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/README.md +15 -15
- package/dist/agents.js +19 -12
- package/dist/cli.js +449 -431
- package/dist/config.js +30 -27
- package/dist/design.js +27 -44
- package/dist/judge.js +110 -57
- package/dist/maintain.js +16 -14
- package/docs/media/hero-dark.svg +186 -154
- package/docs/media/hero-light.svg +186 -154
- package/docs/media/make.mjs +7 -2
- package/docs/media/pass-dark.svg +22 -8
- package/docs/media/pass-light.svg +22 -8
- package/docs/media/reasons-dark.svg +15 -23
- package/docs/media/reasons-light.svg +15 -23
- package/docs/media/run-dark.svg +6 -6
- package/docs/media/run-light.svg +6 -6
- package/docs/reference.md +52 -64
- package/integrations/firstmate/brief-include.md +5 -3
- package/integrations/firstmate/merge-gate +2 -2
- package/package.json +4 -6
- package/skills/done-done/SKILL.md +7 -6
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
|
|
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);
|
|
49
|
-
|
|
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
|
|
62
|
-
|
|
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
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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.
|
|
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,
|
|
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:
|
|
111
|
-
'--reason "<why>"': 'why (required for approve and skip); kept in the run
|
|
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
|
|
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', '
|
|
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
|
|
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>]
|
|
158
|
-
['done-done check --from <file | - | PR URL> [--claim-id <id>] [--trusted-ref <ref>] [--citation-base <dir>]
|
|
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>'
|
|
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>]
|
|
166
|
-
['done-done receipt --from <file | - | PR URL> [--claim-id <id>] [--trusted-ref <ref>] [--citation-base <dir>]
|
|
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>', '--
|
|
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>]
|
|
175
|
-
['done-done verify --outcome "<claim>" [--feature <name>]', 'with no journey, the author agent drives a verify skill feature and the judge
|
|
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>', '--
|
|
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
|
|
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>"', '
|
|
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]
|
|
226
|
-
flags: ['--run <run-id>', '--wait', '--no-wait'
|
|
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
|
|
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
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
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' }
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
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
|
-
|
|
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' ||
|
|
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
|
-
//
|
|
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 &&
|
|
862
|
-
if (
|
|
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,
|
|
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
|
-
|
|
923
|
-
|
|
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
|
-
|
|
926
|
-
const rec = { schema:
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
1568
|
-
const notJudged =
|
|
1569
|
-
const asks = !clean ? [] :
|
|
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
|
-
|
|
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
|
|
1675
|
-
// with any ask (or unsure answer), or `park`, every judge failure goes to the gate with its class. A
|
|
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)
|
|
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,
|
|
1685
|
-
if (
|
|
1686
|
-
return
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
:
|
|
1892
|
-
:
|
|
1893
|
-
: run.
|
|
1894
|
-
: run.
|
|
1895
|
-
: run.
|
|
1896
|
-
: run.
|
|
1897
|
-
: /^
|
|
1898
|
-
: /^
|
|
1899
|
-
: /^locate
|
|
1900
|
-
:
|
|
1901
|
-
|
|
1902
|
-
:
|
|
1903
|
-
:
|
|
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
|
|
1913
|
-
async function preflightJudge(cfg, needsAgent = true
|
|
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,
|
|
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
|
|
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,
|
|
1934
|
-
async function preflightVerify(cfg,
|
|
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
|
-
|
|
1938
|
-
|
|
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' },
|
|
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
|
|
1977
|
-
|
|
1978
|
-
|
|
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,
|
|
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
|
-
//
|
|
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
|
|
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' }
|
|
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
|
|
2136
|
-
|
|
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,
|
|
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.
|
|
2244
|
-
//
|
|
2245
|
-
//
|
|
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
|
|
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
|
-
|
|
2287
|
-
|
|
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
|
|
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?` : `
|
|
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
|
|
2342
|
-
options: (spend ? ['approve', 'skip'] : ['
|
|
2343
|
-
recommend_value:
|
|
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
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
}
|
|
2450
|
-
|
|
2451
|
-
const
|
|
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
|
-
|
|
2454
|
-
const
|
|
2455
|
-
//
|
|
2456
|
-
|
|
2457
|
-
|
|
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
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
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
|
-
|
|
2485
|
-
}
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
const
|
|
2491
|
-
const
|
|
2492
|
-
const
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
const
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
const
|
|
2500
|
-
|
|
2501
|
-
.
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
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
|
|
2570
|
-
|
|
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
|
|
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
|
-
...
|
|
2576
|
-
...(
|
|
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} ${
|
|
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
|
-
|
|
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
|
-
${
|
|
2541
|
+
${launch}
|
|
2590
2542
|
|
|
2591
|
-
Ready
|
|
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: ${
|
|
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
|
-
|
|
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,
|
|
2621
|
-
|
|
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,
|
|
2624
|
-
const index =
|
|
2625
|
-
const more =
|
|
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
|
-
|
|
2645
|
-
//
|
|
2646
|
-
|
|
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 '
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
const
|
|
2656
|
-
|
|
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
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
|
|
2664
|
-
|
|
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
|
-
|
|
2667
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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:
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
3200
|
-
// required; the verify skill
|
|
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)
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|