done-done-axi 0.3.0 → 0.3.2
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 +10 -7
- package/dist/agents.js +133 -23
- package/dist/cli.js +165 -47
- package/dist/config.js +20 -18
- package/dist/dom.js +57 -5
- package/dist/judge.js +34 -74
- package/dist/look.js +116 -0
- package/docs/reference.md +62 -23
- package/docs/releasing.md +12 -0
- package/package.json +1 -1
- package/skills/done-done/SKILL.md +10 -0
package/dist/cli.js
CHANGED
|
@@ -4,7 +4,7 @@ import { createHash, randomBytes } from 'node:crypto';
|
|
|
4
4
|
import { accessSync, appendFileSync, closeSync, constants, cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readdirSync, readFileSync, realpathSync, renameSync, 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
|
-
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
8
8
|
import { createInterface } from 'node:readline/promises';
|
|
9
9
|
import { parseArgs } from 'node:util';
|
|
10
10
|
import { encode } from '@toon-format/toon';
|
|
@@ -17,7 +17,8 @@ import { attemptProblem, changedSince, commitFix, dirtyTracked, FIX_SCHEMA, inWo
|
|
|
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';
|
|
20
|
-
import { domIssues, isDomRecord, SQUASHED_TEXT } from './dom.js';
|
|
20
|
+
import { domIssues, domRecord, isDomRecord, lookIssues, SQUASHED_TEXT } from './dom.js';
|
|
21
|
+
import { look } from './look.js';
|
|
21
22
|
import { scanSecrets } from './secrets.js';
|
|
22
23
|
const PASS = 0, FAIL = 1, USAGE = 2, INCONCLUSIVE = 3, WAITING = 4;
|
|
23
24
|
// The shape of a journey, so a new user or agent can write one without opening the reference.
|
|
@@ -71,7 +72,7 @@ const DOCTOR_TEXT = `doctor changes nothing except the --probe call record (the
|
|
|
71
72
|
optional: verify_skill (.agents/skills/verify-<app>/), agent (the agent the user set and its model; missing when none is
|
|
72
73
|
set, with the command that saves the user's choice), agent_settings (agent, model, effort,
|
|
73
74
|
args, path and agent_timeout, each with the file it came from: the global config, .done-done.yaml or default), judge (the judge route is ready: for claude-code an
|
|
74
|
-
executable claude binary
|
|
75
|
+
executable claude binary and the judge kit (its sign-in is checked only when a capture is judged), for answerer:<agent> that agent's CLI and the kit, for openai:key
|
|
75
76
|
OPENAI_API_KEY and the kit; doctor does not call the model; missing when none is set, with the command that saves the user's choice),
|
|
76
77
|
judge_choice (the judge, and how far it is from the author: another agent, another model, or session-only), ffprobe (video evidence only).
|
|
77
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
|
|
@@ -80,8 +81,8 @@ const DOCTOR_TEXT = `doctor changes nothing except the --probe call record (the
|
|
|
80
81
|
const CONFIG_TEXT = `config: ~/.done-done/config.yaml (global; DONE_DONE_HOME moves it) and .done-done.yaml (the repo), repo over global.
|
|
81
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.
|
|
82
83
|
global only: agent_config.<agent>.{model,effort} (effort: minimal, low, medium, high, xhigh, max), agent_args_override.<agent>
|
|
83
|
-
(extra flags), agent_path_override.<agent> (binary), acpx_path, agent_timeout (one agent call, default 30m),
|
|
84
|
-
evidence.local_root, judge.{route,model,allow_same
|
|
84
|
+
(extra flags), agent_path_override.<agent> (binary), acpx_path, agent_timeout (one agent call, default 30m; a streaming agent that prints nothing for 5m is stopped early),
|
|
85
|
+
evidence.local_root, judge.{route,model,allow_same} (the judge, set by the user with
|
|
85
86
|
init --judge; never picked). done-done doctor shows where each agent setting came from.`;
|
|
86
87
|
// One table drives `--help` and every `<command> --help`: each command's usage lines and the flags it takes.
|
|
87
88
|
const FLAGS = {
|
|
@@ -92,6 +93,7 @@ const FLAGS = {
|
|
|
92
93
|
'--outcome "<claim>"': 'the outcome the evidence files prove; the files follow as arguments',
|
|
93
94
|
'--from <file | - | PR URL>': 'read the claim from the newest "## Checks" block of a file, stdin or a PR body',
|
|
94
95
|
'--trusted-ref <ref>': 'read .done-done.yaml from this git ref instead of the working tree (use in CI)',
|
|
96
|
+
'--citation-base <dir>': 'resolve evidence citations against this directory (default: cwd); absolute citations stay unchanged; does not change repo, config or run storage',
|
|
95
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)',
|
|
96
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)',
|
|
97
99
|
'--no-judge': 'skip the judge, only when the user chooses to (or judge.enabled: false in .done-done.yaml)',
|
|
@@ -108,6 +110,7 @@ const FLAGS = {
|
|
|
108
110
|
'--action <approve|skip|fix>': 'approve: the captures do show the outcome; skip: go on without a judge pass; both end the run overridden; fix: end it fail, then fix the product or the verify skill (done-done maintain checks it against the source)',
|
|
109
111
|
'--reason "<why>"': 'why (required for approve and skip); kept in the run and its receipt with who answered',
|
|
110
112
|
'--limit <n>': 'how many past runs to list (default all)',
|
|
113
|
+
'--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)',
|
|
111
114
|
'--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',
|
|
112
115
|
'--model <model>': 'with --agent: that agent\'s model, saved as agent_config.<agent>.model (opencode: provider/model)',
|
|
113
116
|
'--judge <agent>': `the judge done-done starts (${JUDGES.join(' or ')}), saved as judge.route in ~/.done-done/config.yaml; never the agent and model that did the work; without it, init asks in a terminal`,
|
|
@@ -151,19 +154,19 @@ const COMMANDS = {
|
|
|
151
154
|
},
|
|
152
155
|
check: {
|
|
153
156
|
usage: [
|
|
154
|
-
['done-done check --outcome "<claim>" <files...> [--claim-id <id>] [--trusted-ref <ref>] [--judge]', 'check evidence files against a claim'],
|
|
155
|
-
['done-done check --from <file | - | PR URL> [--claim-id <id>] [--trusted-ref <ref>] [--judge]', "check a claim's Checks block"],
|
|
157
|
+
['done-done check --outcome "<claim>" <files...> [--claim-id <id>] [--trusted-ref <ref>] [--citation-base <dir>] [--judge]', 'check evidence files against a claim'],
|
|
158
|
+
['done-done check --from <file | - | PR URL> [--claim-id <id>] [--trusted-ref <ref>] [--citation-base <dir>] [--judge]', "check a claim's Checks block"],
|
|
156
159
|
],
|
|
157
|
-
flags: ['--outcome "<claim>"', '--from <file | - | PR URL>', '--claim-id <id>', '--trusted-ref <ref>', '--judge'],
|
|
160
|
+
flags: ['--outcome "<claim>"', '--from <file | - | PR URL>', '--claim-id <id>', '--trusted-ref <ref>', '--citation-base <dir>', '--judge'],
|
|
158
161
|
more: [CHECKS_TEXT],
|
|
159
162
|
},
|
|
160
163
|
receipt: {
|
|
161
164
|
usage: [
|
|
162
|
-
['done-done receipt --outcome "<claim>" <files...> [--claim-id <id>] [--trusted-ref <ref>] [--judge]', 'record a passing check for HEAD'],
|
|
163
|
-
['done-done receipt --from <file | - | PR URL> [--claim-id <id>] [--trusted-ref <ref>] [--judge]', "record from a claim's Checks block"],
|
|
165
|
+
['done-done receipt --outcome "<claim>" <files...> [--claim-id <id>] [--trusted-ref <ref>] [--citation-base <dir>] [--judge]', 'record a passing check for HEAD'],
|
|
166
|
+
['done-done receipt --from <file | - | PR URL> [--claim-id <id>] [--trusted-ref <ref>] [--citation-base <dir>] [--judge]', "record from a claim's Checks block"],
|
|
164
167
|
['done-done receipt --verify <head>', 'read-only: verify a recorded receipt'],
|
|
165
168
|
],
|
|
166
|
-
flags: ['--outcome "<claim>"', '--from <file | - | PR URL>', '--claim-id <id>', '--trusted-ref <ref>', '--judge', '--verify <head>'],
|
|
169
|
+
flags: ['--outcome "<claim>"', '--from <file | - | PR URL>', '--claim-id <id>', '--trusted-ref <ref>', '--citation-base <dir>', '--judge', '--verify <head>'],
|
|
167
170
|
more: [CHECKS_TEXT],
|
|
168
171
|
},
|
|
169
172
|
verify: {
|
|
@@ -186,6 +189,14 @@ const COMMANDS = {
|
|
|
186
189
|
more: [`design: one judge request per screen (the judge the user set, never the author); it fails a screen on any defect it names:
|
|
187
190
|
${Object.keys(KINDS).join(', ')}; capture each variant (dark, small width, large font) as its own screen; exit 0 every screen
|
|
188
191
|
passed, 1 a screen failed, 3 none failed but one is unsure`],
|
|
192
|
+
},
|
|
193
|
+
look: {
|
|
194
|
+
usage: [['done-done look <url> [--out <file.png>]', 'open a page in headless Chrome at a phone viewport, capture it with its page-check record, and fail on any squashed, clipped or cut-off UI']],
|
|
195
|
+
flags: ['--out <file.png>'],
|
|
196
|
+
more: [`look: one deterministic step a journey calls as its command or inside its script; no judge, no model. It measures horizontal
|
|
197
|
+
overflow, squashed text, text clipped by its container and elements cut off by the left, right or top edge, writes the
|
|
198
|
+
screenshot and its .dom.json record, and exits 1 naming each element, kind and text, 0 when there is none. A hit always
|
|
199
|
+
fails: never wrap it in || true. Needs Chrome or Chromium (DONE_DONE_CHROME names the binary)`],
|
|
189
200
|
},
|
|
190
201
|
status: {
|
|
191
202
|
usage: [['done-done status [--run <run-id>]', "show a run's steps, result, findings and next step"]],
|
|
@@ -279,6 +290,10 @@ function captureHint(cfg) {
|
|
|
279
290
|
return `done-done run <journey> (${names.join(', ')})`;
|
|
280
291
|
return 'done-done run -- <capture command>';
|
|
281
292
|
}
|
|
293
|
+
// The words after --, as the one shell command string a run executes. One word is already a whole command,
|
|
294
|
+
// so it stays verbatim (the documented `-- '<shell command>'` recipe); several words are argv, and each one
|
|
295
|
+
// is quoted so spaces and shell metacharacters inside a word survive the shell that runs the string.
|
|
296
|
+
const commandOf = (words) => (words.length === 1 ? words[0] : words.map(quote).join(' '));
|
|
282
297
|
// A path as the user should see it: repo-relative inside the repo, else with the home directory as ~.
|
|
283
298
|
const outside = (r) => r === '..' || r.startsWith(`..${sep}`) || isAbsolute(r);
|
|
284
299
|
const shown = (repo, p) => {
|
|
@@ -344,7 +359,14 @@ function openRun(repo, cfg, journey, command, id) {
|
|
|
344
359
|
mkdirSync(runDir(cfg, runId));
|
|
345
360
|
return { schema: 1, run_id: runId, journey, command, ...h, started_at: new Date().toISOString(), ended_at: null, exit: null, files: [] };
|
|
346
361
|
}
|
|
347
|
-
|
|
362
|
+
// The first line of a run's log that names a failure, trimmed: what a non-zero exit is about. Empty when none does.
|
|
363
|
+
function firstFailure(log) {
|
|
364
|
+
if (!existsSync(log))
|
|
365
|
+
return '';
|
|
366
|
+
const line = readFileSync(log, 'utf8').split('\n').map((l) => l.replace(/\x1b\[[\d;]*m/g, '').trim()).find((l) => /\b(FAIL|FAILED|Error|failed|timed out)\b/.test(l));
|
|
367
|
+
return line ? line.slice(0, 200) : '';
|
|
368
|
+
}
|
|
369
|
+
function closeRun(repo, cfg, rec, log) {
|
|
348
370
|
const now = head(repo);
|
|
349
371
|
rec.files = captured(cfg, rec.run_id)
|
|
350
372
|
.sort()
|
|
@@ -360,8 +382,10 @@ function closeRun(repo, cfg, rec) {
|
|
|
360
382
|
let reason = '';
|
|
361
383
|
if (now.commit !== rec.commit)
|
|
362
384
|
reason = `HEAD moved from ${rec.commit} to ${now.commit} during the run; evidence is not tied to one commit`;
|
|
363
|
-
else if (rec.exit !== null && rec.exit !== 0)
|
|
364
|
-
|
|
385
|
+
else if (rec.exit !== null && rec.exit !== 0) {
|
|
386
|
+
const failure = log ? firstFailure(log) : '';
|
|
387
|
+
reason = `the run command exited ${rec.exit}${failure ? `: ${failure}` : ''}`;
|
|
388
|
+
}
|
|
365
389
|
else if (!rec.files.length)
|
|
366
390
|
reason = `the run wrote no files into its folder ${shown(repo, runDir(cfg, rec.run_id))}; the command gets it as $DONE_DONE_EVIDENCE_ROOT and its run id as $DONE_DONE_RUN_ID (or \${RUN_ID} in the command)`;
|
|
367
391
|
const evidence = FULL ? rec.files.map((f) => ({ path: rel(f.path), sha256: f.sha256, bytes: f.bytes })) : rec.files.map((f) => rel(f.path));
|
|
@@ -419,7 +443,7 @@ async function cmdRun(argv) {
|
|
|
419
443
|
const spends = journey ? cfg.journeys[journey]?.spends : undefined;
|
|
420
444
|
if (spends)
|
|
421
445
|
throw new UsageError(`journey ${journey} spends (${spends}), so run refuses it: nothing ran`, `Ask the user, then: done-done verify ${journey} --outcome "<what a user can now do>"`);
|
|
422
|
-
let command = dash < 0 ? null : argv.slice(dash + 1)
|
|
446
|
+
let command = dash < 0 ? null : commandOf(argv.slice(dash + 1));
|
|
423
447
|
if (journey && !command) {
|
|
424
448
|
const j = cfg.journeys[journey];
|
|
425
449
|
if (!j)
|
|
@@ -476,14 +500,20 @@ async function cmdRun(argv) {
|
|
|
476
500
|
const matrix = hasMatrix(cfg.matrix) ? splitMatrix(cfg.matrix) : null;
|
|
477
501
|
const failed = [];
|
|
478
502
|
rec.exit = 0;
|
|
503
|
+
// The run's log lives with its run store, never in the evidence folder the judge reads: a failure names its first failing line.
|
|
504
|
+
const logFile = join(runsRoot(cfg.app), rec.run_id, 'logs', 'run.log');
|
|
505
|
+
mkdirSync(dirname(logFile), { recursive: true });
|
|
479
506
|
for (const cell of matrix?.required ?? [{}]) {
|
|
480
507
|
// The journey's own stdout goes to our stderr, never our stdout: stdout carries only done-done's
|
|
481
508
|
// result document (TOON or --json), so a noisy journey cannot break `run --json | JSON.parse`.
|
|
482
509
|
const r = await new Promise((done) => {
|
|
483
|
-
spawn(expanded, {
|
|
484
|
-
cwd: repo, shell: true, stdio: ['inherit',
|
|
510
|
+
const child = spawn(expanded, {
|
|
511
|
+
cwd: repo, shell: true, stdio: ['inherit', 'pipe', 'pipe'],
|
|
485
512
|
env: { ...process.env, DONE_DONE_RUN_ID: rec.run_id, DONE_DONE_EVIDENCE_ROOT: runDir(cfg, rec.run_id), ...inst?.env, ...(matrix ? cellEnv(cell) : {}) },
|
|
486
|
-
})
|
|
513
|
+
});
|
|
514
|
+
for (const out of [child.stdout, child.stderr])
|
|
515
|
+
out?.on('data', (b) => { process.stderr.write(b); appendFileSync(logFile, b); });
|
|
516
|
+
child.on('error', () => done(null)).on('close', done);
|
|
487
517
|
});
|
|
488
518
|
const code = r ?? 1;
|
|
489
519
|
if (code !== 0) {
|
|
@@ -493,7 +523,7 @@ async function cmdRun(argv) {
|
|
|
493
523
|
}
|
|
494
524
|
}
|
|
495
525
|
reap();
|
|
496
|
-
const sealed = closeRun(repo, cfg, rec);
|
|
526
|
+
const sealed = closeRun(repo, cfg, rec, logFile);
|
|
497
527
|
if (matrix && failed.length)
|
|
498
528
|
return emitted({ code: FAIL, result: { ...sealed.result, reason: `the run command failed for cell ${failed.join('; ')}` } });
|
|
499
529
|
return emitted(sealed);
|
|
@@ -558,9 +588,11 @@ function loadRecords(cfg) {
|
|
|
558
588
|
async function runCheck(argv, run) {
|
|
559
589
|
const { values, positionals } = parseArgs({
|
|
560
590
|
args: argv,
|
|
561
|
-
options: { outcome: { type: 'string' }, from: { type: 'string' }, 'claim-id': { type: 'string' }, 'trusted-ref': { type: 'string' }, judge: { type: 'boolean' } },
|
|
591
|
+
options: { outcome: { type: 'string' }, from: { type: 'string' }, 'claim-id': { type: 'string' }, 'trusted-ref': { type: 'string' }, 'citation-base': { type: 'string' }, judge: { type: 'boolean' } },
|
|
562
592
|
allowPositionals: true,
|
|
563
593
|
});
|
|
594
|
+
if (values['citation-base']?.trim() === '')
|
|
595
|
+
throw new UsageError('--citation-base needs a non-empty directory');
|
|
564
596
|
if (!!values.outcome === !!values.from)
|
|
565
597
|
throw new UsageError('check needs exactly one of --outcome or --from');
|
|
566
598
|
if (values.from && positionals.length)
|
|
@@ -600,10 +632,10 @@ async function runCheck(argv, run) {
|
|
|
600
632
|
if (claim?.notProven) {
|
|
601
633
|
const allowed = cfg.checks.not_proven === 'allow' && cfg.trusted;
|
|
602
634
|
const all = findings.concat(allowed ? [] : [{ level: 'inconclusive', why: `not proven: ${claim.notProven}` }]);
|
|
603
|
-
const rep = report(all, [], { cfg, repo, claim: claim.outcome, source: values.from, notProven: true }, allowed ? 'not proven (allowed by trusted config)' : '');
|
|
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)' : '');
|
|
604
636
|
return { code: rep.code, result: rep.result, claim: claim?.outcome, files: [], cited: [], head: now, findings: all };
|
|
605
637
|
}
|
|
606
|
-
const files = claim?.files ?? [];
|
|
638
|
+
const files = (claim?.files ?? []).map((name) => values['citation-base'] === undefined ? name : resolve(values['citation-base'], name));
|
|
607
639
|
const records = loadRecords(cfg);
|
|
608
640
|
const seen = new Map();
|
|
609
641
|
const hashes = [];
|
|
@@ -616,8 +648,21 @@ async function runCheck(argv, run) {
|
|
|
616
648
|
};
|
|
617
649
|
const befores = new Map();
|
|
618
650
|
const heads = [];
|
|
651
|
+
// Citations are the paths `run` printed: repo-relative inside the repo, absolute outside it.
|
|
652
|
+
// Resolve a relative citation against the repo root first, so a check from a subdirectory
|
|
653
|
+
// finds the same evidence (a worker cwd once false-failed real proof as missing/ENOENT),
|
|
654
|
+
// then against the caller's cwd, so a shell-relative path typed for this shell still works.
|
|
655
|
+
const cite = (name) => {
|
|
656
|
+
if (isAbsolute(name))
|
|
657
|
+
return name;
|
|
658
|
+
const atRoot = resolve(repo, name);
|
|
659
|
+
if (existsSync(atRoot))
|
|
660
|
+
return atRoot;
|
|
661
|
+
const atCwd = resolve(name);
|
|
662
|
+
return existsSync(atCwd) ? atCwd : atRoot;
|
|
663
|
+
};
|
|
619
664
|
for (const name of files) {
|
|
620
|
-
const path =
|
|
665
|
+
const path = cite(name);
|
|
621
666
|
let bytes;
|
|
622
667
|
try {
|
|
623
668
|
if (!statSync(path).isFile())
|
|
@@ -625,7 +670,9 @@ async function runCheck(argv, run) {
|
|
|
625
670
|
bytes = readFileSync(path);
|
|
626
671
|
}
|
|
627
672
|
catch (e) {
|
|
628
|
-
|
|
673
|
+
const atCwd = !isAbsolute(name) && resolve(name) !== path ? ` or ${resolve(name)}` : '';
|
|
674
|
+
const missing = e.code !== 'ENOENT' ? undefined : under(cfg.evidence.root, path) === null ? 'checkout' : 'run';
|
|
675
|
+
findings.push({ level: 'fail', why: `${name}: no such file (looked for ${path}${atCwd}${e.code ? `, ${e.code}` : ''})`, missing });
|
|
629
676
|
continue;
|
|
630
677
|
}
|
|
631
678
|
const v = checkMedia(path, bytes);
|
|
@@ -680,7 +727,7 @@ async function runCheck(argv, run) {
|
|
|
680
727
|
if (stale)
|
|
681
728
|
findings.push({ level: 'fail', why: `${name}: ${stale} (run ${rec.run_id})` });
|
|
682
729
|
if (rec.dirty && !cfg.evidence.allow_dirty)
|
|
683
|
-
findings.push({ level: 'fail', why: `${name}: captured from a tree with uncommitted changes (run ${rec.run_id})` });
|
|
730
|
+
findings.push({ level: 'fail', why: `${name}: captured from a tree with uncommitted changes (run ${rec.run_id}); commit them, then capture again` });
|
|
684
731
|
if (rec.exit !== null && rec.exit !== 0)
|
|
685
732
|
findings.push({ level: 'fail', why: `${name}: its run command exited ${rec.exit} (run ${rec.run_id})` });
|
|
686
733
|
}
|
|
@@ -690,16 +737,16 @@ async function runCheck(argv, run) {
|
|
|
690
737
|
findings.push({ level: 'inconclusive', why: `${c.name}: no base capture to compare with: the drive at base_ref ${rec.base.ref} saved no ${join(BEFORE, inRoot ? relative(rec.run_id, inRoot) : c.name)}${rec.base.error ? ` (${rec.base.error})` : ''}`, file: c.name });
|
|
691
738
|
// The capture matrix: every required cell needs a capture named with its suffix, never silently missing.
|
|
692
739
|
if (hasMatrix(cfg.matrix))
|
|
693
|
-
for (const c of matrixManifest(cfg.matrix, files.filter((f) => (!cfg.dom || !isDomRecord(f)) && !pairOf(under(cfg.evidence.root,
|
|
740
|
+
for (const c of matrixManifest(cfg.matrix, files.filter((f) => (!cfg.dom || !isDomRecord(f)) && !pairOf(under(cfg.evidence.root, cite(f))))).cells.filter((c) => !c.files.length))
|
|
694
741
|
findings.push({ level: 'fail', why: `cell ${c.cell} has no capture: name one file *${c.suffix}* (journeys name captures with DONE_DONE_CELL)` });
|
|
695
742
|
// Reuse: fail only when every cited file was already used for a different claim id. The ledger lives in the
|
|
696
743
|
// evidence root next to the run records it guards: one per app (repo), shared by every checkout of it, never in git.
|
|
697
744
|
const ledger = join(cfg.evidence.root, '.done-done', 'ledger');
|
|
698
745
|
const given = values['claim-id']?.replace(/\s+/g, ' ').trim();
|
|
699
|
-
|
|
700
|
-
const
|
|
701
|
-
|
|
702
|
-
|
|
746
|
+
// A detached HEAD (a worktree, a PR checkout) has no branch, so its claim is the commit it checks.
|
|
747
|
+
const branch = git(repo, 'branch', '--show-current');
|
|
748
|
+
const claimId = given || prBranch || branch || (claim ? now.commit : '');
|
|
749
|
+
const idFrom = given ? '--claim-id' : prBranch ? 'the PR branch' : branch ? 'the current branch' : 'the detached HEAD commit';
|
|
703
750
|
const used = new Map();
|
|
704
751
|
if (existsSync(ledger))
|
|
705
752
|
for (const line of readFileSync(ledger, 'utf8').split('\n')) {
|
|
@@ -739,7 +786,7 @@ async function runCheck(argv, run) {
|
|
|
739
786
|
const { choice } = j;
|
|
740
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) };
|
|
741
788
|
if (choice && j.judged.length) {
|
|
742
|
-
const byName = new Map(captures.map((c) => [c.name, receiptPath(repo, cfg,
|
|
789
|
+
const byName = new Map(captures.map((c) => [c.name, receiptPath(repo, cfg, cite(c.name))]));
|
|
743
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 })) };
|
|
744
791
|
}
|
|
745
792
|
if (rows.omitted)
|
|
@@ -749,7 +796,7 @@ async function runCheck(argv, run) {
|
|
|
749
796
|
judged.note = 'PNG and JPEG screens and text captures (logs, JSON, CLI output) are judged; video and other files are not';
|
|
750
797
|
}
|
|
751
798
|
}
|
|
752
|
-
const rep = report(findings, files, { cfg, repo, claim: claim?.outcome, source: values.from, judge: judged, judgeLevel });
|
|
799
|
+
const rep = report(findings, files, { cfg, repo, claim: claim?.outcome, source: values.from, citationBase: values['citation-base'], judge: judged, judgeLevel });
|
|
753
800
|
if (rep.code === PASS) {
|
|
754
801
|
mkdirSync(dirname(ledger), { recursive: true });
|
|
755
802
|
appendFileSync(ledger, hashes.filter((h) => !used.get(h)?.has(claimId)).map((h) => `${h} ${claimId}\n`).join(''));
|
|
@@ -832,8 +879,8 @@ function verifyReceipt(repo, cfg, headArg) {
|
|
|
832
879
|
try {
|
|
833
880
|
bytes = readFileSync(f.path.startsWith(IN_EVIDENCE) ? join(cfg.evidence.root, f.path.slice(IN_EVIDENCE.length)) : resolve(repo, f.path));
|
|
834
881
|
}
|
|
835
|
-
catch {
|
|
836
|
-
bad.push(`${f.path}:
|
|
882
|
+
catch (e) {
|
|
883
|
+
bad.push(`${f.path}: no such file (looked for ${f.path.startsWith(IN_EVIDENCE) ? join(cfg.evidence.root, f.path.slice(IN_EVIDENCE.length)) : resolve(repo, f.path)}${e.code ? `, ${e.code}` : ''})`);
|
|
837
884
|
continue;
|
|
838
885
|
}
|
|
839
886
|
if (sha256(bytes) !== f.sha256) {
|
|
@@ -899,7 +946,7 @@ function staleness(repo, cfg, from, to) {
|
|
|
899
946
|
}
|
|
900
947
|
// Quote an argument only when a shell would split it.
|
|
901
948
|
const arg = (a) => (/^[\w./@:+-]+$/.test(a) ? a : `'${a.replaceAll("'", `'\\''`)}'`);
|
|
902
|
-
function checkHelp(code, files, { cfg, repo, claim, source, notProven, judgeLevel }) {
|
|
949
|
+
function checkHelp(code, findings, files, { cfg, repo, claim, source, citationBase, notProven, judgeLevel }) {
|
|
903
950
|
if (notProven)
|
|
904
951
|
return ['Capture real proof and replace "not proven:", or set checks.not_proven: allow and re-check from a trusted ref'];
|
|
905
952
|
if (judgeLevel === 'fail' && code === FAIL)
|
|
@@ -908,15 +955,27 @@ function checkHelp(code, files, { cfg, repo, claim, source, notProven, judgeLeve
|
|
|
908
955
|
return ['Look at the capture yourself, or capture a clearer screen of the outcome and re-run this check'];
|
|
909
956
|
if (code === PASS) {
|
|
910
957
|
// The CI gate needs a PR on a remote; without one, a receipt records the pass locally.
|
|
958
|
+
const citationArgs = citationBase === undefined ? '' : ` --citation-base ${arg(citationBase)}`;
|
|
911
959
|
if (spawnSync('git', ['remote', 'get-url', 'origin'], { cwd: repo }).status === 0)
|
|
912
|
-
return [
|
|
960
|
+
return [`Gate CI on this proof: done-done check --from <PR URL> --trusted-ref origin/main${citationArgs}`];
|
|
913
961
|
const claimArgs = source ? ['--from', source] : ['--outcome', claim ?? '', ...files];
|
|
914
|
-
return [`Record this pass for HEAD: done-done receipt ${claimArgs.map(arg).join(' ')}`];
|
|
962
|
+
return [`Record this pass for HEAD: done-done receipt ${claimArgs.map(arg).join(' ')}${citationArgs}`];
|
|
915
963
|
}
|
|
916
964
|
const capture = captureHint(cfg);
|
|
917
965
|
if (code === INCONCLUSIVE)
|
|
918
966
|
return [`Capture this proof through a run so it is tied to a commit, then re-run this check: ${capture}`];
|
|
919
|
-
|
|
967
|
+
// A named file outside this checkout is not lost: point at the folder that holds it first. Re-capture is for proof the run should have written here.
|
|
968
|
+
const help = [];
|
|
969
|
+
const absent = findings.filter((f) => f.missing === 'checkout').length;
|
|
970
|
+
if (absent) {
|
|
971
|
+
const form = source ? `done-done check --from ${arg(source)} --citation-base <dir>` : 'done-done check --outcome <claim> <files...> --citation-base <dir>';
|
|
972
|
+
help.push(citationBase === undefined
|
|
973
|
+
? `${absent} named file${absent > 1 ? 's are' : ' is'} not in this checkout. If the evidence is in another folder, check against it: ${form}`
|
|
974
|
+
: `${absent} named file${absent > 1 ? 's are' : ' is'} not under --citation-base ${arg(citationBase)} either: check the paths in the Checks block`);
|
|
975
|
+
}
|
|
976
|
+
if (findings.some((f) => f.level === 'fail' && f.missing !== 'checkout'))
|
|
977
|
+
help.push(`Commit your change, re-capture with ${capture}, then check the new files`);
|
|
978
|
+
return help;
|
|
920
979
|
}
|
|
921
980
|
function report(findings, files, ctx, note = '') {
|
|
922
981
|
const code = findings.some((f) => f.level === 'fail') ? FAIL : findings.length ? INCONCLUSIVE : PASS;
|
|
@@ -944,7 +1003,7 @@ function report(findings, files, ctx, note = '') {
|
|
|
944
1003
|
result.findings = rows.items;
|
|
945
1004
|
if (rows.omitted)
|
|
946
1005
|
result.findings_omitted = rows.omitted;
|
|
947
|
-
result.help = checkHelp(code, files, ctx);
|
|
1006
|
+
result.help = checkHelp(code, findings, files, ctx);
|
|
948
1007
|
return { code, result };
|
|
949
1008
|
}
|
|
950
1009
|
const CLI_PATH = fileURLToPath(import.meta.url);
|
|
@@ -1370,7 +1429,7 @@ async function runSteps(dir) {
|
|
|
1370
1429
|
if (skillFeatures(at, cfg).includes(picked))
|
|
1371
1430
|
run.feature ??= picked;
|
|
1372
1431
|
log('drive', { agent: author.agent, model: author.model, feature: run.feature ?? 'none', ...(a.ok ? { captures: answer?.captures, blocker } : { error: a.error }), record: a.record ?? a.recordError });
|
|
1373
|
-
const why = !a.ok ? `${author.agent} did not finish the drive: ${a.error}`
|
|
1432
|
+
const why = !a.ok ? `${author.agent} did not finish the drive: ${a.error}${a.record ? `; call record ${a.record}` : ''}`
|
|
1374
1433
|
: blocker ? `${author.agent} could not drive the feature: ${blocker}`
|
|
1375
1434
|
: changed ? `${author.agent} changed tracked files in the repo during the drive (see git status), so its captures are not of the committed code`
|
|
1376
1435
|
: sealed.code !== PASS ? sealed.reason : '';
|
|
@@ -1968,11 +2027,33 @@ async function cmdMaintain(argv) {
|
|
|
1968
2027
|
logEvent(dir, { status: 'pending' });
|
|
1969
2028
|
return launch(dir, cfg, 'maintain', values);
|
|
1970
2029
|
}
|
|
2030
|
+
// `run` writes an evidence record, not a verify run: status shows it too, so the run id that `run` printed resolves here.
|
|
2031
|
+
function showRecord(repo, cfg, rec) {
|
|
2032
|
+
const rel = (p) => citable(repo, join(cfg.evidence.root, p));
|
|
2033
|
+
const evidence = FULL ? rec.files.map((f) => ({ path: rel(f.path), sha256: f.sha256, bytes: f.bytes })) : rec.files.map((f) => rel(f.path));
|
|
2034
|
+
const rows = cap(evidence);
|
|
2035
|
+
emit({
|
|
2036
|
+
command: 'status',
|
|
2037
|
+
run_id: rec.run_id,
|
|
2038
|
+
commit: rec.commit,
|
|
2039
|
+
dirty: rec.dirty,
|
|
2040
|
+
files: rec.files.length,
|
|
2041
|
+
record: shown(repo, join(recordsDir(cfg), `${rec.run_id}.json`)),
|
|
2042
|
+
evidence: rows.items,
|
|
2043
|
+
...(rows.omitted && { evidence_omitted: rows.omitted }),
|
|
2044
|
+
help: [rec.files.length ? `Check this proof: done-done check --outcome "<what you proved>" ${rel(rec.files[0].path)}` : `Read the run's folder: ${shown(repo, runDir(cfg, rec.run_id))}`],
|
|
2045
|
+
});
|
|
2046
|
+
return PASS;
|
|
2047
|
+
}
|
|
1971
2048
|
function cmdStatus(argv) {
|
|
1972
2049
|
const { values } = parseArgs({ args: argv, options: { run: { type: 'string' } } });
|
|
1973
2050
|
const repo = repoRoot();
|
|
1974
2051
|
const cfg = loadConfig(repo);
|
|
1975
2052
|
const root = runsRoot(cfg.app);
|
|
2053
|
+
const verify = values.run ? /^[\w-]+$/.test(values.run) && existsSync(join(root, values.run, 'run.json')) : runsIn(root).some((r) => r.repo === repo);
|
|
2054
|
+
const rec = verify ? undefined : loadRecords(cfg).filter((r) => !values.run || r.run_id === values.run).sort((a, b) => a.started_at.localeCompare(b.started_at)).pop();
|
|
2055
|
+
if (rec)
|
|
2056
|
+
return showRecord(repo, cfg, rec);
|
|
1976
2057
|
return showRun(join(root, pickId(root, repo, 'status', cfg, values.run)), cfg, 'status');
|
|
1977
2058
|
}
|
|
1978
2059
|
async function cmdAttach(argv) {
|
|
@@ -2352,7 +2433,7 @@ function ensureIgnored(repo, rel) {
|
|
|
2352
2433
|
return true;
|
|
2353
2434
|
}
|
|
2354
2435
|
// `init` also scaffolds the project's verify skill from the repo's own files: a port of the
|
|
2355
|
-
//
|
|
2436
|
+
// the create-verification-skill interview (surface, run, drive, observe, isolate).
|
|
2356
2437
|
// Every line is an action: a detected handle gets concrete drive and capture steps, and a blank detection cannot
|
|
2357
2438
|
// fill gets the steps to find it and record it there, never an invented command. An existing skill is never
|
|
2358
2439
|
// overwritten, so re-run is idempotent.
|
|
@@ -2897,6 +2978,9 @@ function checkJudge(cfg, agent, model) {
|
|
|
2897
2978
|
if (cfg.agent)
|
|
2898
2979
|
selectJudge({ ...cfg, judge: { ...cfg.judge, route: routeOf(agent), model } }, selectAgent(cfg));
|
|
2899
2980
|
}
|
|
2981
|
+
const commitFirst = (skill) => `.done-done.yaml and ${skill} before your first capture: check refuses proof from a tree with uncommitted changes`;
|
|
2982
|
+
// An absolute path inside the repo in a recipe: the recipe is committed, so it would name this checkout only.
|
|
2983
|
+
const absoluteInside = (command, repo) => [...command.matchAll(/(?:^|[\s"'=])(\/[^\s"'`;|&<>]*)/g)].map((m) => m[1]).find((p) => p === repo || p.startsWith(`${repo}/`));
|
|
2900
2984
|
async function cmdInit(argv) {
|
|
2901
2985
|
const dash = argv.indexOf('--');
|
|
2902
2986
|
const { values } = parseArgs({
|
|
@@ -2963,8 +3047,11 @@ async function cmdInit(argv) {
|
|
|
2963
3047
|
};
|
|
2964
3048
|
if (journey !== null) {
|
|
2965
3049
|
// Record the capture recipe in the repo so any checkout can re-run the proof.
|
|
2966
|
-
// The command is
|
|
2967
|
-
const command = parts
|
|
3050
|
+
// The command is built once, the same way `run --` builds it, so the recipe re-runs exactly; ${RUN_ID} in it becomes the run id.
|
|
3051
|
+
const command = commandOf(parts);
|
|
3052
|
+
const inside = absoluteInside(command, repo);
|
|
3053
|
+
if (inside)
|
|
3054
|
+
throw new UsageError(`init --journey ${journey}: ${inside} is an absolute path inside this repo, and the recipe is committed to .done-done.yaml, so other checkouts cannot run it`, `Name it relative to the repo, e.g.: done-done init --journey ${journey} -- ${command.replaceAll(inside, relative(repo, inside) || '.')}`);
|
|
2968
3055
|
const doc = existed ? parseDocument(readFileSync(file, 'utf8')) : new Document({ app: cfg.app });
|
|
2969
3056
|
const top = (doc.toJS() ?? {});
|
|
2970
3057
|
if (typeof top !== 'object' || Array.isArray(top))
|
|
@@ -3000,7 +3087,7 @@ async function cmdInit(argv) {
|
|
|
3000
3087
|
gitignore: added ? `added ${rel.replace(/\/+$/, '')}/` : 'unchanged',
|
|
3001
3088
|
verify_skill: verify,
|
|
3002
3089
|
...agentHook.out,
|
|
3003
|
-
help: [`Capture proof: done-done run ${journey}, then: ${check}`, ...agentHook.help, ...ag.help, ...jg.help],
|
|
3090
|
+
help: [`Capture proof: done-done run ${journey}, then: ${check}`, ...agentHook.help, ...ag.help, ...jg.help, `Commit ${commitFirst(fresh.skill)}`],
|
|
3004
3091
|
});
|
|
3005
3092
|
return ag.code;
|
|
3006
3093
|
}
|
|
@@ -3045,6 +3132,7 @@ app: ${JSON.stringify(cfg.app)}
|
|
|
3045
3132
|
...agentHook.help,
|
|
3046
3133
|
...ag.help,
|
|
3047
3134
|
...jg.help,
|
|
3135
|
+
`Commit ${commitFirst(cfg.skill)}`,
|
|
3048
3136
|
],
|
|
3049
3137
|
});
|
|
3050
3138
|
return ag.code;
|
|
@@ -3086,6 +3174,8 @@ function writableDir(p) {
|
|
|
3086
3174
|
}
|
|
3087
3175
|
}
|
|
3088
3176
|
const PROBE_SCHEMA = { type: 'object', properties: { ok: { type: 'boolean' } }, required: ['ok'], additionalProperties: false };
|
|
3177
|
+
// A tool-less probe starts in seconds; an agent silent for this long is stuck, so doctor says why instead of waiting the agent_timeout.
|
|
3178
|
+
const PROBE_IDLE_MS = 30_000;
|
|
3089
3179
|
const unknownRow = (check, detail = 'unknown without a valid config', fix = '') => ({ check, status: 'missing', detail, fix });
|
|
3090
3180
|
// Each effective setting of the agent and the file it came from: the global config, the repo's .done-done.yaml, or the built-in default.
|
|
3091
3181
|
function agentSettings(cfg, agent) {
|
|
@@ -3257,7 +3347,7 @@ async function cmdDoctor(argv) {
|
|
|
3257
3347
|
check: 'judge_choice',
|
|
3258
3348
|
status: apartOk ? 'ok' : 'warning',
|
|
3259
3349
|
detail: `${choice.route} (model ${choice.model}; from config) judges the author ${choice.author.agent} (model ${choice.author.model || 'none'}): ${apart}${accepted}`,
|
|
3260
|
-
fix: apartOk ? '' : `Ask the user whether to judge with another agent (${JUDGES.join(' or ')}) and save it: done-done init --judge <agent> --judge-model <model>`,
|
|
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>`,
|
|
3261
3351
|
});
|
|
3262
3352
|
}
|
|
3263
3353
|
if (values.probe && !agent)
|
|
@@ -3268,10 +3358,10 @@ async function cmdDoctor(argv) {
|
|
|
3268
3358
|
else if (values.probe && agent) {
|
|
3269
3359
|
const record = join(home(), 'agents', `probe-${new Date().toISOString().replace(/[:.]/g, '-')}-${agent.agent}.json`);
|
|
3270
3360
|
const probe = !agent.path ? { ok: false, error: notFound, fix: installFix }
|
|
3271
|
-
: await askAgent({ ...agent, prompt: 'This is a connectivity probe from done-done doctor. Use no tools. Answer with ok set to true.', schema: PROBE_SCHEMA, cwd: repo ?? process.cwd(), tools: 'none', record });
|
|
3361
|
+
: await askAgent({ ...agent, prompt: 'This is a connectivity probe from done-done doctor. Use no tools. Answer with ok set to true.', schema: PROBE_SCHEMA, cwd: repo ?? process.cwd(), tools: 'none', record, idleMs: PROBE_IDLE_MS });
|
|
3272
3362
|
checks.push(probe.ok && probe.answer?.ok === true
|
|
3273
3363
|
? { check: 'probe', status: 'ok', detail: `${agent.agent} (${modelPhrase(agent.agent, agent.model)}) answered the schema question; ${probe.recordError ? `the call record could not be written: ${probe.recordError}` : `record ${record}`}`, fix: '' }
|
|
3274
|
-
: { check: 'probe', status: 'missing', detail: `${agent.agent} did not answer: ${probe.error ?? `answer ${JSON.stringify(probe.answer)}`}${probe.record ? `; record ${record}` : ''}${probe.recordError ? `; the call record could not be written: ${probe.recordError}` : ''}`, fix: probe.fix ?? `Check that ${agent.agent} is signed in${agent.model ? ` and can use model ${agent.model}` : ''}, or set agent_config.${agent.agent}.model` });
|
|
3364
|
+
: { check: 'probe', status: 'missing', detail: `${agent.agent} did not answer: ${probe.error ?? `answer ${JSON.stringify(probe.answer)}`}${probe.record ? `; record ${record}` : ''}${probe.recordError ? `; the call record could not be written: ${probe.recordError}` : ''}`, fix: probe.fix ?? `${probe.stderr ? `${agent.agent} stderr: ${probe.stderr}. ` : ''}Check that ${agent.agent} is signed in${agent.model ? ` and can use model ${agent.model}` : ''}, or set agent_config.${agent.agent}.model` });
|
|
3275
3365
|
}
|
|
3276
3366
|
if (agent)
|
|
3277
3367
|
checks.push({ check: 'agent_settings', status: 'ok', detail: agentSettings(agentCfg, agent.agent), fix: '' });
|
|
@@ -3296,6 +3386,32 @@ async function cmdDoctor(argv) {
|
|
|
3296
3386
|
return missingRequired.length ? USAGE : PASS;
|
|
3297
3387
|
}
|
|
3298
3388
|
// The design check: every named screen, and each --mock screen, judged for visual defects.
|
|
3389
|
+
async function cmdLook(argv) {
|
|
3390
|
+
const { values, positionals } = parseArgs({ args: argv, options: { out: { type: 'string' } }, allowPositionals: true });
|
|
3391
|
+
if (positionals.length !== 1)
|
|
3392
|
+
throw new UsageError('look needs one page URL', 'Re-run with the page to check: done-done look <url>');
|
|
3393
|
+
// A path to a page on disk opens as its file:// URL.
|
|
3394
|
+
const url = !/^[a-z][\w+.-]*:/i.test(positionals[0]) && existsSync(positionals[0]) ? pathToFileURL(resolve(positionals[0])).href : positionals[0];
|
|
3395
|
+
const capture = resolve(values.out ?? join(process.env.DONE_DONE_EVIDENCE_ROOT || '.', 'look.png'));
|
|
3396
|
+
if (!capture.endsWith('.png'))
|
|
3397
|
+
throw new UsageError(`--out ${values.out} must be a .png file`);
|
|
3398
|
+
mkdirSync(dirname(capture), { recursive: true });
|
|
3399
|
+
const record = await look(url, capture, 390, 844);
|
|
3400
|
+
const hits = lookIssues(record);
|
|
3401
|
+
const shown = cap(hits);
|
|
3402
|
+
emit({
|
|
3403
|
+
command: 'look',
|
|
3404
|
+
result: hits.length ? 'fail' : 'pass',
|
|
3405
|
+
url,
|
|
3406
|
+
viewport: '390x844',
|
|
3407
|
+
capture,
|
|
3408
|
+
record: domRecord(capture),
|
|
3409
|
+
hits: shown.items,
|
|
3410
|
+
...(shown.omitted && { hits_omitted: shown.omitted }),
|
|
3411
|
+
help: hits.length ? [`Fix each element named above so it fits the 390 px wide screen, then re-run: done-done look ${quote(positionals[0])}${values.out ? ` --out ${quote(values.out)}` : ''}`] : ['No squashed, clipped or cut-off UI on this page; look at the capture to check the rest'],
|
|
3412
|
+
});
|
|
3413
|
+
return hits.length ? FAIL : PASS;
|
|
3414
|
+
}
|
|
3299
3415
|
async function cmdDesign(argv) {
|
|
3300
3416
|
const { values, positionals } = parseArgs({ args: argv, options: { mock: { type: 'string', multiple: true } }, allowPositionals: true });
|
|
3301
3417
|
const mocks = new Map();
|
|
@@ -3417,6 +3533,8 @@ async function main(argv) {
|
|
|
3417
3533
|
return await cmdMaintain(rest);
|
|
3418
3534
|
if (cmd === 'design')
|
|
3419
3535
|
return await cmdDesign(rest);
|
|
3536
|
+
if (cmd === 'look')
|
|
3537
|
+
return await cmdLook(rest);
|
|
3420
3538
|
if (cmd === 'status')
|
|
3421
3539
|
return cmdStatus(rest);
|
|
3422
3540
|
if (cmd === 'attach')
|
package/dist/config.js
CHANGED
|
@@ -70,11 +70,10 @@ const KEYS = {
|
|
|
70
70
|
'judge.enabled': { scope: 'repo', type: 'boolean' },
|
|
71
71
|
'judge.model': { scope: 'global', type: 'string' },
|
|
72
72
|
'judge.route': { scope: 'global', type: 'route' },
|
|
73
|
-
'judge.claude_code': { scope: 'global', type: 'claude_code' },
|
|
74
73
|
'judge.allow_same': { scope: 'global', type: 'boolean' },
|
|
75
74
|
};
|
|
76
75
|
const SECTIONS = new Set(['evidence', 'secrets', 'checks', 'judge']);
|
|
77
|
-
const MAPS = new Set(['auto_fix', 'agent_config', 'agent_args_override', 'agent_path_override'
|
|
76
|
+
const MAPS = new Set(['auto_fix', 'agent_config', 'agent_args_override', 'agent_path_override']);
|
|
78
77
|
export const home = () => process.env.DONE_DONE_HOME || join(homedir(), '.done-done');
|
|
79
78
|
// A duration such as 90s, 30m or 1h, in milliseconds; null when the text is not one or is not a delay a timer can run.
|
|
80
79
|
export const parseDuration = (text) => {
|
|
@@ -118,6 +117,7 @@ const isRegex = (v) => {
|
|
|
118
117
|
return false;
|
|
119
118
|
}
|
|
120
119
|
};
|
|
120
|
+
const leftoverNoticed = new Set();
|
|
121
121
|
function apply(target, text, file, scope) {
|
|
122
122
|
if (text === undefined)
|
|
123
123
|
return;
|
|
@@ -129,6 +129,14 @@ function apply(target, text, file, scope) {
|
|
|
129
129
|
throw new UsageError(`${file}: ${e.message.split('\n')[0]}`);
|
|
130
130
|
}
|
|
131
131
|
for (const [key, value] of flatten(doc, file)) {
|
|
132
|
+
// The claude judge runs the user's own claude sign-in now, so the folder key of older configs is only a leftover.
|
|
133
|
+
if (key === 'judge.claude_code') {
|
|
134
|
+
if (!leftoverNoticed.has(file)) {
|
|
135
|
+
leftoverNoticed.add(file);
|
|
136
|
+
process.stderr.write(`judge.claude_code is no longer used (the judge uses your normal claude sign-in); delete it from ${file}\n`);
|
|
137
|
+
}
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
132
140
|
const spec = KEYS[key];
|
|
133
141
|
if (!spec)
|
|
134
142
|
throw new UsageError(`${file}: unknown key ${key}`);
|
|
@@ -140,16 +148,15 @@ function apply(target, text, file, scope) {
|
|
|
140
148
|
: spec.type === 'inputs' ? Array.isArray(value) && value.every((v) => typeof v === 'string' && inputPattern(v) !== '' && !isAbsolute(v) && !v.split('/').includes('..'))
|
|
141
149
|
: spec.type === 'agent' ? isAgent(value)
|
|
142
150
|
: spec.type === 'route' ? typeof value === 'string' && (value === 'claude-code' || Object.hasOwn(KEY_ROUTES, value) || ANSWERER_JUDGES.some((a) => value === `answerer:${a}`))
|
|
143
|
-
: spec.type === '
|
|
144
|
-
: spec.type === '
|
|
145
|
-
: spec.type === '
|
|
146
|
-
: spec.type === '
|
|
147
|
-
: spec.type === '
|
|
148
|
-
: spec.type === '
|
|
149
|
-
: spec.type === '
|
|
150
|
-
: spec.type === '
|
|
151
|
-
:
|
|
152
|
-
: isMap(value) && Object.values(value).every((j) => j && typeof j.command === 'string' && (j.spends === undefined || (typeof j.spends === 'string' && j.spends.trim() !== '')));
|
|
151
|
+
: spec.type === 'agent_config' ? isMap(value) && Object.entries(value).every(([a, c]) => isAgent(a) && isMap(c) && Object.entries(c).every(([k, m]) => ['model', 'effort'].includes(k) && typeof m === 'string'))
|
|
152
|
+
: spec.type === 'args' ? isMap(value) && Object.entries(value).every(([a, v]) => AGENTS.includes(a) && Array.isArray(v) && v.every((x) => typeof x === 'string'))
|
|
153
|
+
: spec.type === 'paths' ? isMap(value) && Object.entries(value).every(([a, v]) => AGENTS.includes(a) && isBin(v))
|
|
154
|
+
: spec.type === 'bin' ? isBin(value)
|
|
155
|
+
: spec.type === 'duration' ? typeof value === 'string' && parseDuration(value) !== null
|
|
156
|
+
: spec.type === 'auto_fix' ? isMap(value) && Object.entries(value).every(([k, n]) => AUTO_FIX_STEPS.includes(k) && Number.isInteger(n) && n >= 0)
|
|
157
|
+
: spec.type === 'matrix' ? !checkMatrix(value)
|
|
158
|
+
: spec.type === 'regexes' ? (key === 'secrets.allow' ? Array.isArray(value) : isMap(value)) && Object.values(value).every(isRegex)
|
|
159
|
+
: isMap(value) && Object.values(value).every((j) => j && typeof j.command === 'string' && (j.spends === undefined || (typeof j.spends === 'string' && j.spends.trim() !== '')));
|
|
153
160
|
const want = {
|
|
154
161
|
journeys: 'a map of name: {command: <string>, spends: <what a run spends>}',
|
|
155
162
|
instance: '{command: <string>, ready: {url: <http url>} or {command: <shell command>}, ready_timeout: <duration>}',
|
|
@@ -163,7 +170,6 @@ function apply(target, text, file, scope) {
|
|
|
163
170
|
regexes: key === 'secrets.allow' ? 'a list of valid regexes' : 'a map of <name>: <valid regex>',
|
|
164
171
|
auto_fix: `a map of <step>: <attempts, 0 or more> for ${AUTO_FIX_STEPS.join(', ')}`,
|
|
165
172
|
route: `claude-code (for claude), answerer:<agent> for one of ${ANSWERER_JUDGES.join(', ')}, or a key route (${Object.keys(KEY_ROUTES).join(', ')}); an acp:<target> agent cannot judge, since done-done cannot send it an image`,
|
|
166
|
-
claude_code: 'a map with config_dir, a string (the claude binary is agent_path_override.claude)',
|
|
167
173
|
inputs: 'a list of repo-relative paths or globs, not absolute and with no .. segment',
|
|
168
174
|
};
|
|
169
175
|
if (key === 'judge.route' && (value === 'auto' || value === ''))
|
|
@@ -262,7 +268,7 @@ function defaultConfig(repo) {
|
|
|
262
268
|
evidence: { local_root: join(home(), 'evidence'), root: '', allow_dirty: false, ignore_paths: [] },
|
|
263
269
|
secrets: { patterns: {}, allow: [] },
|
|
264
270
|
checks: { not_proven: 'inconclusive', fix_title: '^(fix|bugfix|hotfix)\\b' },
|
|
265
|
-
judge: { route: '', model: '', allow_same: false
|
|
271
|
+
judge: { route: '', model: '', allow_same: false },
|
|
266
272
|
trusted: false,
|
|
267
273
|
from: {},
|
|
268
274
|
};
|
|
@@ -280,7 +286,6 @@ export function loadConfig(repo, projectText) {
|
|
|
280
286
|
cfg.acpx_path = tilde(cfg.acpx_path);
|
|
281
287
|
for (const a of Object.keys(cfg.agent_path_override))
|
|
282
288
|
cfg.agent_path_override[a] = tilde(cfg.agent_path_override[a]);
|
|
283
|
-
cfg.judge.claude_code.config_dir = tilde(cfg.judge.claude_code.config_dir);
|
|
284
289
|
// A relative root is under the repo (and dies with the checkout); an absolute one is used as-is.
|
|
285
290
|
cfg.evidence.root = cfg.evidence.root ? resolve(repo, cfg.evidence.root) : join(local, cfg.app);
|
|
286
291
|
return cfg;
|
|
@@ -345,8 +350,5 @@ judge:
|
|
|
345
350
|
model: ""
|
|
346
351
|
# Accept a judge that is the author's own agent and model in a fresh session.
|
|
347
352
|
allow_same: false
|
|
348
|
-
claude_code:
|
|
349
|
-
# The claude-code judge's own Claude sign-in folder, never your ~/.claude.
|
|
350
|
-
config_dir: ${short(join(home(), 'claude'))}
|
|
351
353
|
`;
|
|
352
354
|
}
|