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