done-done-axi 0.5.2 → 0.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -99
- package/dist/cli.js +245 -55
- package/dist/config.js +4 -1
- package/dist/proof.js +58 -0
- package/docs/index.html +100 -76
- package/docs/manual-cli.md +76 -0
- package/docs/media/demo.mjs +40 -0
- package/docs/media/demo.png +0 -0
- package/docs/reference.md +41 -8
- package/package.json +1 -1
- package/skills/done-done/SKILL.md +2 -1
package/dist/cli.js
CHANGED
|
@@ -10,7 +10,7 @@ import { parseArgs } from 'node:util';
|
|
|
10
10
|
import { encode } from '@toon-format/toon';
|
|
11
11
|
import { Document, parseDocument } from 'yaml';
|
|
12
12
|
import { ACPX_INSTALL, agentChoices, agentPath, agentRun, askAgent, editOnlyReason, findBin, JUDGES, modelFor, noAgent, noJudge, notStartable, routeOf, selectAgent, selectFallback, selectJudge, toollessReason } from './agents.js';
|
|
13
|
-
import { AGENTS, CHEAP, globalConfigText, home, inputPattern, isAgent, isJudgeRoute, loadConfig, parseDuration, UsageError } from './config.js';
|
|
13
|
+
import { AGENTS, CHEAP, globalConfigText, home, inputPattern, isAgent, isJudgeRoute, loadConfig, parseDuration, tilde, UsageError } from './config.js';
|
|
14
14
|
import { backend, judge, judgeable, onPath, quote, screen } from './judge.js';
|
|
15
15
|
import { startInstance, stopInstance } from './instance.js';
|
|
16
16
|
import { attemptProblem, changedSince, commitFix, dirtyTracked, FIX_SCHEMA, inWorktree, maintainSteps, named, pathspec, readerProblem, repoState, triage } from './maintain.js';
|
|
@@ -22,8 +22,9 @@ import { lint } from './lint.js';
|
|
|
22
22
|
import { look } from './look.js';
|
|
23
23
|
import { scanSecrets } from './secrets.js';
|
|
24
24
|
import { mapChange } from './affected.js';
|
|
25
|
-
import { codeOnly, codeOnlyJourneys, configFault, MARK, namedJourney, releaseArtifacts, RULE, runsFault } from './proof.js';
|
|
25
|
+
import { codeOnly, codeOnlyJourneys, configFault, MARK, namedJourney, releaseArtifacts, RULE, runsFault, STEPS_FILE, STEPS_SHAPE, stepRecordFault, stepsFault } from './proof.js';
|
|
26
26
|
const PASS = 0, FAIL = 1, USAGE = 2, INCONCLUSIVE = 3, WAITING = 4;
|
|
27
|
+
const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
27
28
|
// The shape of a journey, so a new user or agent can write one without opening the reference.
|
|
28
29
|
// done-done sets DONE_DONE_EVIDENCE_ROOT (the run's own folder) and replaces ${RUN_ID}, so the example works for any evidence root.
|
|
29
30
|
const journeyExample = 'journeys: { smoke: { command: "<drive the core user journey on the real app> > $DONE_DONE_EVIDENCE_ROOT/${RUN_ID}.log", core: true } }';
|
|
@@ -33,6 +34,8 @@ const JOURNEYS_TEXT = `journeys (.done-done.yaml): the command runs with DONE_DO
|
|
|
33
34
|
real product: a journey whose command only runs tests, typecheck, lint or a build (npm test, yarn run check, tsc, pytest, or a test or guard
|
|
34
35
|
script such as bash tests/x.test.sh or node scripts/floor-guard.mjs) is code-only;
|
|
35
36
|
init --journey refuses it, and check, receipt and gate refuse proof that comes only from code-only runs or a config of only code-only journeys
|
|
37
|
+
step record: the journey also writes steps.json into $DONE_DONE_EVIDENCE_ROOT, shaped ${STEPS_SHAPE};
|
|
38
|
+
check, receipt and gate refuse a claim that no cited run's step record covers (a run recorded before step records only warns)
|
|
36
39
|
core (journeys.<name>.core: true): the core user journey, the one thing a user cannot live without, driven end to end on the real target
|
|
37
40
|
(for a remote terminal: pair, open a pane, type, see the output); doctor requires one, here or as a "(core journey)" entry in the verify skill's feature map
|
|
38
41
|
upgrade (journeys.<name>.upgrade: true): the release path users take: install the previous shipped version, upgrade over it in place, then drive the
|
|
@@ -57,11 +60,17 @@ const RUNS_TEXT = `runs: each verify or maintain run lives in $DONE_DONE_HOME/ru
|
|
|
57
60
|
hashed before and after the drive; the check step fails naming each changed, appeared or
|
|
58
61
|
disappeared path (never file contents), and the before/after hash lists go into the run record; no key: unchanged
|
|
59
62
|
gate: a judge fail or unsure (or an agent drive with no judged capture) parks the run as awaiting_approval (exit 4); nothing moves it
|
|
60
|
-
past the judge: respond --action fix ends it fail, with no receipt (
|
|
63
|
+
past the judge: respond --action fix ends it fail, with no receipt (skip ends it fail too, without the fix; approve answers only a spend gate)
|
|
61
64
|
auto_fix: { verify: <n> } (config; 0, the default, reports only): the judge sorts each wrong screen (fix or ask; an ask, an unsure
|
|
62
65
|
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
|
|
63
66
|
ignored files copied in, and verify runs again there, up to n times. Result: changed (the fix on a done-done/verify-<run-id>
|
|
64
67
|
branch, your HEAD still fails; exit 1) or blocked (dirty checkout, a guard, or every attempt used; exit 1)`;
|
|
68
|
+
const SIGNOFF_TEXT = `sign-off (.done-done.yaml): the board where the person answers. done-done writes the message (contract v1) and, with the opt-in, posts it:
|
|
69
|
+
signoff.post: true lets every signoff post to the board; --post posts one run. Without it, signoff only writes the message and prints the post command.
|
|
70
|
+
signoff.board names the board file (default: the nearest .sign-off/ board sign-off-axi finds); --board names it for one run.
|
|
71
|
+
signoff --wait waits for the answer to the card that run posted (--timeout, default 10m) and feeds it back to that run: a gate's
|
|
72
|
+
approve, skip or fix ends or resumes the run, exactly as done-done respond does. The card (its key and board) is recorded on the run,
|
|
73
|
+
so status shows it. The board is sign-off's own: done-done posts through sign-off-axi and never edits a board.`;
|
|
65
74
|
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.
|
|
66
75
|
locate: the verify skill (.agents/skills/done-done-verify-<app>/SKILL.md); none: blocked, create it with done-done init
|
|
67
76
|
hygiene: every local link in the skill resolves; features/README.md lists each features/*.md file once
|
|
@@ -121,8 +130,11 @@ const FLAGS = {
|
|
|
121
130
|
'--mock <screen>=<mock>': 'judge this screen next to its approved mock (repeat per screen; the screen is judged even when not listed)',
|
|
122
131
|
'--step <name>': 'the step log to show (verify: resolve, drive, check, judge, gate, receipt, report; maintain: locate, hygiene, source-wave, reconcile, triage, fix, report)',
|
|
123
132
|
'--feature <name>': 'with no journey, the author agent drives this feature of the verify skill (features/<name>.md); default: the only feature, else the agent picks the one the outcome is about',
|
|
124
|
-
'--action <approve|skip|fix>': 'at a spend gate, approve: spend and run the journey; skip: end the run with nothing spent. At the judge gate only fix:
|
|
133
|
+
'--action <approve|skip|fix>': 'at a spend gate, approve: spend and run the journey; skip: end the run with nothing spent. At the judge gate only fix or skip: fix ends it fail, then the product or the verify skill is fixed (done-done maintain checks it against the source); skip ends it fail without the fix',
|
|
125
134
|
'--reason "<why>"': 'why (required for approve and skip at a spend gate); kept in the run with who answered',
|
|
135
|
+
'--post': 'with signoff: post the message to the sign-off board through sign-off-axi and record the card on the run (the opt-in: without it, signoff only writes the message)',
|
|
136
|
+
'--board <file>': 'with signoff: the board file to post to (default: the nearest .sign-off/ board sign-off-axi finds)',
|
|
137
|
+
'--timeout <duration>': 'with signoff --wait: how long to wait for the answer (default 10m)',
|
|
126
138
|
'--limit <n>': 'how many past runs to list (default all)',
|
|
127
139
|
'--out <file.png>': 'where look writes the screenshot; its page-check record goes next to it as <file>.dom.json (default: look.png in $DONE_DONE_EVIDENCE_ROOT inside a journey, else in the current folder)',
|
|
128
140
|
'--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',
|
|
@@ -286,9 +298,13 @@ const COMMANDS = {
|
|
|
286
298
|
more: [RUNS_TEXT],
|
|
287
299
|
},
|
|
288
300
|
signoff: {
|
|
289
|
-
usage: [
|
|
290
|
-
|
|
291
|
-
|
|
301
|
+
usage: [
|
|
302
|
+
['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; without the opt-in it prints the post command and never posts"],
|
|
303
|
+
['done-done signoff --post [--board <file>]', 'post that message to the sign-off board through sign-off-axi and record the card on the run'],
|
|
304
|
+
['done-done signoff --wait [--timeout <duration>]', 'wait for the answer to the card this run posted and feed it back to that run'],
|
|
305
|
+
],
|
|
306
|
+
flags: ['--run <run-id>', '--post', '--board <file>', '--wait', '--timeout <duration>'],
|
|
307
|
+
more: [RUNS_TEXT, SIGNOFF_TEXT],
|
|
292
308
|
},
|
|
293
309
|
history: {
|
|
294
310
|
usage: [['done-done history [--limit <n>]', 'list past runs, newest first']],
|
|
@@ -430,7 +446,26 @@ function openRun(repo, cfg, journey, command, id) {
|
|
|
430
446
|
const runId = id ?? newRunId(h.commit);
|
|
431
447
|
mkdirSync(cfg.evidence.root, { recursive: true });
|
|
432
448
|
mkdirSync(runDir(cfg, runId));
|
|
433
|
-
return { schema: 1, run_id: runId, journey, command, ...h, started_at: new Date().toISOString(), ended_at: null, exit: null, files: [] };
|
|
449
|
+
return { schema: 1, run_id: runId, journey, command, ...h, started_at: new Date().toISOString(), ended_at: null, exit: null, files: [], tool: VERSION };
|
|
450
|
+
}
|
|
451
|
+
// The step record a run's journey (or driving agent) wrote beside its captures; it is a record, not a capture, so it is not a run file.
|
|
452
|
+
function readSteps(cfg, rec) {
|
|
453
|
+
delete rec.steps;
|
|
454
|
+
delete rec.steps_error;
|
|
455
|
+
const file = join(runDir(cfg, rec.run_id), STEPS_FILE);
|
|
456
|
+
if (!existsSync(file))
|
|
457
|
+
return;
|
|
458
|
+
try {
|
|
459
|
+
const steps = JSON.parse(readFileSync(file, 'utf8'));
|
|
460
|
+
const bad = stepRecordFault(steps);
|
|
461
|
+
if (bad)
|
|
462
|
+
rec.steps_error = bad;
|
|
463
|
+
else
|
|
464
|
+
rec.steps = steps;
|
|
465
|
+
}
|
|
466
|
+
catch (e) {
|
|
467
|
+
rec.steps_error = `it is not JSON (${e.message})`;
|
|
468
|
+
}
|
|
434
469
|
}
|
|
435
470
|
// The first line of a run's log that names a failure, trimmed: what a non-zero exit is about. Empty when none does.
|
|
436
471
|
function firstFailure(log) {
|
|
@@ -441,7 +476,9 @@ function firstFailure(log) {
|
|
|
441
476
|
}
|
|
442
477
|
function closeRun(repo, cfg, rec, log) {
|
|
443
478
|
const now = head(repo);
|
|
479
|
+
readSteps(cfg, rec);
|
|
444
480
|
rec.files = captured(cfg, rec.run_id)
|
|
481
|
+
.filter((p) => basename(p) !== STEPS_FILE || dirname(p) !== rec.run_id)
|
|
445
482
|
.sort()
|
|
446
483
|
.map((p) => {
|
|
447
484
|
const b = readFileSync(join(cfg.evidence.root, p));
|
|
@@ -802,7 +839,7 @@ async function runCheck(argv, run) {
|
|
|
802
839
|
continue;
|
|
803
840
|
}
|
|
804
841
|
const rec = (atNow.length ? atNow : claims).reduce((a, b) => (b.started_at > a.started_at ? b : a));
|
|
805
|
-
runs.set(rec.run_id, { run_id: rec.run_id, journey: rec.journey, command: rec.command, ...(rec.feature && { feature: rec.feature }) });
|
|
842
|
+
runs.set(rec.run_id, { run_id: rec.run_id, journey: rec.journey, command: rec.command, ...(rec.feature && { feature: rec.feature }), ...(rec.tool && { tool: rec.tool }), ...(rec.steps && { steps: rec.steps }), ...(rec.steps_error && { steps_error: rec.steps_error }) });
|
|
806
843
|
// Only a capture tied to a run record can reach the judge, so a stray cited file is never sent.
|
|
807
844
|
const c = { name, bytes, sha256: hash, media: v };
|
|
808
845
|
if (v.status === 'ok' && !record && pair)
|
|
@@ -835,6 +872,15 @@ async function runCheck(argv, run) {
|
|
|
835
872
|
const codeRuns = runs.size ? runsFault([...runs.values()]) : null;
|
|
836
873
|
if (codeRuns)
|
|
837
874
|
findings.push({ level: 'fail', why: codeRuns });
|
|
875
|
+
// The user steps behind the captures: a cited real run needs a step record covering the claim. Runs from before step records warn.
|
|
876
|
+
const warnings = [];
|
|
877
|
+
if (claim && runs.size && !codeRuns) {
|
|
878
|
+
const s = stepsFault([...runs.values()], claim.outcome);
|
|
879
|
+
if (s.fault)
|
|
880
|
+
findings.push({ level: 'fail', why: s.fault });
|
|
881
|
+
if (s.warning)
|
|
882
|
+
warnings.push(s.warning);
|
|
883
|
+
}
|
|
838
884
|
// Reuse: fail only when every cited file was already used for a different claim id. The ledger lives in the
|
|
839
885
|
// evidence root next to the run records it guards: one per app (repo), shared by every checkout of it, never in git.
|
|
840
886
|
const ledger = join(cfg.evidence.root, '.done-done', 'ledger');
|
|
@@ -890,12 +936,12 @@ async function runCheck(argv, run) {
|
|
|
890
936
|
if (j.notJudged.length)
|
|
891
937
|
judged.not_judged = j.notJudged;
|
|
892
938
|
judged.note = 'PNG and JPEG screens and text captures (logs, JSON, CLI output) are judged; video and other files are not';
|
|
893
|
-
const rep = report(findings, files, { cfg, repo, claim: claim.outcome, source: values.from, citationBase: values['citation-base'], judge: judged, judgeLevel });
|
|
939
|
+
const rep = report(findings, files, { cfg, repo, claim: claim.outcome, source: values.from, citationBase: values['citation-base'], judge: judged, judgeLevel, warnings });
|
|
894
940
|
if (rep.code === PASS) {
|
|
895
941
|
mkdirSync(dirname(ledger), { recursive: true });
|
|
896
942
|
appendFileSync(ledger, hashes.filter((h) => !used.get(h)?.has(claimId)).map((h) => `${h} ${claimId}\n`).join(''));
|
|
897
943
|
}
|
|
898
|
-
return { code: rep.code, result: rep.result, judge: judged, judgeBlock, claim: claim?.outcome, skill: claim?.skill, files, cited, runs: [...runs.values()], head: now, findings };
|
|
944
|
+
return { code: rep.code, result: rep.result, judge: judged, judgeBlock, claim: claim?.outcome, skill: claim?.skill, files, cited, runs: [...runs.values()], head: now, findings, warnings };
|
|
899
945
|
}
|
|
900
946
|
async function cmdCheck(argv) {
|
|
901
947
|
const r = await runCheck(argv);
|
|
@@ -933,8 +979,13 @@ function receiptFault(rec, short) {
|
|
|
933
979
|
if (badJudge)
|
|
934
980
|
return `receipt for ${short} has a malformed judge block`;
|
|
935
981
|
const code = runsFault(rec.runs);
|
|
936
|
-
|
|
982
|
+
if (code)
|
|
983
|
+
return `receipt for ${short} is no real-product proof: ${code}`;
|
|
984
|
+
const steps = stepsFault(rec.runs, rec.claim);
|
|
985
|
+
return steps.fault ? `receipt for ${short} has no step record covering its claim: ${steps.fault}` : null;
|
|
937
986
|
}
|
|
987
|
+
// The warning a well-formed receipt still carries: its runs were recorded before step records.
|
|
988
|
+
const receiptWarning = (rec) => stepsFault(rec.runs, rec.claim).warning;
|
|
938
989
|
// Verify a recorded receipt for `headArg`, which may be a full sha or a ref (HEAD, a branch or a tag).
|
|
939
990
|
// Read-only, so a missing or changed record is a failure, not a usage error.
|
|
940
991
|
function verifyReceipt(repo, cfg, headArg) {
|
|
@@ -986,7 +1037,7 @@ function verifyReceipt(repo, cfg, headArg) {
|
|
|
986
1037
|
}
|
|
987
1038
|
if (bad.length)
|
|
988
1039
|
return fail(`cited evidence no longer matches the receipt: ${bad.join('; ')}`);
|
|
989
|
-
return { code: PASS, result: { command: 'receipt', result: 'pass', head: commit, tree: rec.tree, claim: rec.claim, ...(rec.skill && { skill: rec.skill }), files: rec.files.length, at: rec.at, schema: rec.schema, judge: j, help: ['This receipt is valid for this head.'] } };
|
|
1040
|
+
return { code: PASS, result: { command: 'receipt', result: 'pass', head: commit, tree: rec.tree, claim: rec.claim, ...(rec.skill && { skill: rec.skill }), files: rec.files.length, at: rec.at, schema: rec.schema, judge: j, ...(receiptWarning(rec) && { warnings: [receiptWarning(rec)] }), help: ['This receipt is valid for this head.'] } };
|
|
990
1041
|
}
|
|
991
1042
|
async function cmdReceipt(argv) {
|
|
992
1043
|
const vi = argv.indexOf('--verify');
|
|
@@ -1024,7 +1075,7 @@ function recordReceipt(repo, cfg, r) {
|
|
|
1024
1075
|
throw new UsageError('receipt: the check cited no evidence files, so no receipt was written; a receipt must bind at least one cited evidence file', 'Cite real proof: done-done receipt --outcome "<claim>" <files...>');
|
|
1025
1076
|
const rec = { schema: 2, repo: cfg.app, head: r.head.commit, tree: r.head.tree, claim: r.claim ?? '', ...(r.skill && { skill: r.skill }), result: 'pass', at: new Date().toISOString(), files: r.cited, runs: r.runs, judge: r.judgeBlock };
|
|
1026
1077
|
const path = writeReceipt(cfg, rec);
|
|
1027
|
-
const result = { command: 'receipt', result: 'pass', head: r.head.commit, tree: r.head.tree, claim: rec.claim, ...(rec.skill && { skill: rec.skill }), files: rec.files.length, receipt: shown(repo, path) };
|
|
1078
|
+
const result = { command: 'receipt', result: 'pass', head: r.head.commit, tree: r.head.tree, claim: rec.claim, ...(rec.skill && { skill: rec.skill }), files: rec.files.length, receipt: shown(repo, path), ...(r.warnings.length && { warnings: r.warnings }) };
|
|
1028
1079
|
return { code: PASS, result: { ...result, ...(r.judge && { judge: r.judge }), help: [`Verify this receipt (merge gate): done-done receipt --verify ${r.head.commit}`] } };
|
|
1029
1080
|
}
|
|
1030
1081
|
// Why evidence from `from` no longer proves `to`, or '' if it still does.
|
|
@@ -1070,6 +1121,8 @@ function checkHelp(code, findings, files, { cfg, repo, claim, source, citationBa
|
|
|
1070
1121
|
? `${absent} named file${absent > 1 ? 's are' : ' is'} not in this checkout. If the evidence is in another folder, check against it: ${form}`
|
|
1071
1122
|
: `${absent} named file${absent > 1 ? 's are' : ' is'} not under --citation-base ${arg(citationBase)} either: check the paths in the Checks block`);
|
|
1072
1123
|
}
|
|
1124
|
+
if (findings.some((f) => f.why.includes('step record')))
|
|
1125
|
+
help.push(`Have the journey write ${STEPS_FILE} into $DONE_DONE_EVIDENCE_ROOT, shaped ${STEPS_SHAPE}, naming the steps and the result the claim is about; then re-run it (${captureHint(cfg)}) and cite the new files`);
|
|
1073
1126
|
// Proof refused as code-only needs a journey that drives the product, not a re-capture of the same run.
|
|
1074
1127
|
if (findings.some((f) => f.why.endsWith(RULE)))
|
|
1075
1128
|
help.push(`Capture the real product: ${captureHint(cfg).startsWith('done-done init') ? captureHint(cfg) : `${captureHint(cfg)}, or ${RECORD_REAL}`}, then cite its files`);
|
|
@@ -1101,6 +1154,8 @@ function report(findings, files, ctx) {
|
|
|
1101
1154
|
result.findings = rows.items;
|
|
1102
1155
|
if (rows.omitted)
|
|
1103
1156
|
result.findings_omitted = rows.omitted;
|
|
1157
|
+
if (ctx.warnings?.length)
|
|
1158
|
+
result.warnings = ctx.warnings;
|
|
1104
1159
|
result.help = checkHelp(code, findings, files, ctx);
|
|
1105
1160
|
return { code, result };
|
|
1106
1161
|
}
|
|
@@ -1330,11 +1385,12 @@ ${inst}${cells}${dom}${before}
|
|
|
1330
1385
|
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.
|
|
1331
1386
|
Never add steps or data to make the outcome look true: capture what that path shows, even when it contradicts the outcome.
|
|
1332
1387
|
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.
|
|
1333
|
-
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.
|
|
1334
|
-
4.
|
|
1388
|
+
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 but the step record of step 4, and never edit a capture.
|
|
1389
|
+
4. Write ${STEPS_FILE} into ${evidence} (also in $DONE_DONE_EVIDENCE_ROOT), the record of what you did, as JSON shaped ${STEPS_SHAPE}: the app version you drove (its package or build version, else the commit), the state it started from, each step a user takes in your own words, and what a user can see at the end. Write what you did, never what the outcome needs.
|
|
1390
|
+
5. Stop everything you started.
|
|
1335
1391
|
|
|
1336
1392
|
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.
|
|
1337
|
-
Keep working until all
|
|
1393
|
+
Keep working until all five steps are done: your next message without a tool call ends the drive and must be the answer.
|
|
1338
1394
|
If you cannot launch the app or reach the feature, say why in blocker and save no capture.
|
|
1339
1395
|
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),
|
|
1340
1396
|
for example {"feature":"features/<name>.md","captures":["<name>.png"],"blocker":""}.`;
|
|
@@ -1906,6 +1962,12 @@ const reapJourney = (dir, run) => {
|
|
|
1906
1962
|
if (run.runner?.instance_pgid && createOnce(join(dir, 'instance.killed')))
|
|
1907
1963
|
killGroup(run.runner.instance_pgid);
|
|
1908
1964
|
};
|
|
1965
|
+
// The exit code a run's state means: 0 pass, 1 fail, 2 stopped, 3 inconclusive, 4 still running or waiting at its gate.
|
|
1966
|
+
const runCode = (run, status) => status === 'pending' || status === 'running' || status === 'awaiting_approval' ? WAITING
|
|
1967
|
+
: run.stopped && status === 'failed' ? USAGE
|
|
1968
|
+
: status === 'cancelled' || status === 'failed' || run.result === 'fail' || run.result === 'drift' || (run.kind === 'verify' && (run.result === 'changed' || run.result === 'blocked')) ? FAIL
|
|
1969
|
+
: run.result === 'inconclusive' || run.result === 'blocked' ? INCONCLUSIVE
|
|
1970
|
+
: PASS;
|
|
1909
1971
|
// One run as status shows it. A run still marked running whose runner is gone is shown as failed at its step.
|
|
1910
1972
|
// `gone`: the caller saw its runner child exit, which also covers a runner that died before it wrote its pid.
|
|
1911
1973
|
function showRun(dir, cfg, command, gone = false) {
|
|
@@ -1920,7 +1982,7 @@ function showRun(dir, cfg, command, gone = false) {
|
|
|
1920
1982
|
reason = `the runner died at step ${at?.name}${run.runner ? ` (pid ${run.runner.pid}, last heartbeat ${beat}s ago)` : ' before it started'}`;
|
|
1921
1983
|
reapJourney(dir, run);
|
|
1922
1984
|
}
|
|
1923
|
-
const code =
|
|
1985
|
+
const code = runCode(run, status);
|
|
1924
1986
|
const drive = run.journey ?? (run.feature ? `--feature ${arg(run.feature)}` : '');
|
|
1925
1987
|
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)}` : ''}`;
|
|
1926
1988
|
const findings = cap(run.findings ?? []);
|
|
@@ -1944,6 +2006,8 @@ function showRun(dir, cfg, command, gone = false) {
|
|
|
1944
2006
|
result.response = run.response;
|
|
1945
2007
|
if (run.spend)
|
|
1946
2008
|
result.spend = run.spend;
|
|
2009
|
+
if (run.signoff)
|
|
2010
|
+
result.signoff = run.signoff;
|
|
1947
2011
|
if (ev.items.length)
|
|
1948
2012
|
result.evidence = ev.items;
|
|
1949
2013
|
if (ev.omitted)
|
|
@@ -2091,7 +2155,8 @@ async function cmdVerify(argv) {
|
|
|
2091
2155
|
}
|
|
2092
2156
|
// Run the steps in this process (--foreground), or in a detached runner with no pipe back to this process, so the
|
|
2093
2157
|
// runner goes on when this command or its shell ends; --wait (the default) holds this command until the run ends.
|
|
2094
|
-
|
|
2158
|
+
// Start the run's runner and wait for it to end; the caller shows the run.
|
|
2159
|
+
async function startRunner(dir, how) {
|
|
2095
2160
|
if (how.foreground) {
|
|
2096
2161
|
const stop = heartbeat(dir);
|
|
2097
2162
|
try {
|
|
@@ -2100,7 +2165,7 @@ async function launch(dir, cfg, command, how) {
|
|
|
2100
2165
|
finally {
|
|
2101
2166
|
stop();
|
|
2102
2167
|
}
|
|
2103
|
-
return
|
|
2168
|
+
return { gone: false };
|
|
2104
2169
|
}
|
|
2105
2170
|
const fd = openSync(join(dir, 'logs', 'runner.log'), 'a');
|
|
2106
2171
|
const child = spawn(process.execPath, [CLI_PATH, '__runner', dir], { cwd: loadRun(dir).repo, detached: true, stdio: ['ignore', fd, fd] });
|
|
@@ -2108,7 +2173,11 @@ async function launch(dir, cfg, command, how) {
|
|
|
2108
2173
|
closeSync(fd);
|
|
2109
2174
|
if (!how['no-wait'])
|
|
2110
2175
|
await waitRun(dir, child.pid);
|
|
2111
|
-
return
|
|
2176
|
+
return { gone: !alive(child.pid) };
|
|
2177
|
+
}
|
|
2178
|
+
async function launch(dir, cfg, command, how) {
|
|
2179
|
+
const { gone } = await startRunner(dir, how);
|
|
2180
|
+
return showRun(dir, cfg, command, gone);
|
|
2112
2181
|
}
|
|
2113
2182
|
async function cmdMaintain(argv) {
|
|
2114
2183
|
const { values } = parseArgs({ args: argv, options: { wait: { type: 'boolean' }, 'no-wait': { type: 'boolean' }, foreground: { type: 'boolean' } } });
|
|
@@ -2348,20 +2417,12 @@ function answeredBy(repo) {
|
|
|
2348
2417
|
}
|
|
2349
2418
|
}
|
|
2350
2419
|
// Answer a run waiting at its gate. At a spend gate, approve runs the journey and skip ends the run with nothing spent. At
|
|
2351
|
-
// the judge gate only fix
|
|
2352
|
-
//
|
|
2353
|
-
//
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
if (action !== 'approve' && action !== 'skip' && action !== 'fix')
|
|
2358
|
-
throw new UsageError('respond needs --action approve, skip or fix', 'Run: done-done respond --run <run-id> --action <approve|skip|fix> [--reason "<why>"]');
|
|
2359
|
-
const reason = values.reason?.replace(/\s+/g, ' ').trim() ?? '';
|
|
2360
|
-
const repo = repoRoot();
|
|
2361
|
-
const cfg = loadConfig(repo);
|
|
2362
|
-
const root = runsRoot(cfg.app);
|
|
2363
|
-
const id = pickId(root, repo, 'respond', cfg, values.run);
|
|
2364
|
-
const dir = join(root, id);
|
|
2420
|
+
// the judge gate only fix and skip answer: fix ends the run fail (the product or the verify skill is fixed next, and the run
|
|
2421
|
+
// goes again), skip ends it fail too, without the fix. Nothing moves a run past its judge, so approve never answers it and
|
|
2422
|
+
// no receipt is ever written without a judge pass. The runner has exited, so this command finishes the run; the respond
|
|
2423
|
+
// lock lets only one answer (or an abort) close it.
|
|
2424
|
+
// A sign-off board answer and a terminal answer both come through here, so they change the run the same way.
|
|
2425
|
+
async function answerGate(dir, id, action, reason, by) {
|
|
2365
2426
|
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}`);
|
|
2366
2427
|
if (!createOnce(join(dir, 'respond.lock')))
|
|
2367
2428
|
throw notAtGate(loadRun(dir));
|
|
@@ -2371,12 +2432,11 @@ async function cmdRespond(argv) {
|
|
|
2371
2432
|
throw notAtGate(run);
|
|
2372
2433
|
if (run.gate.spend && action === 'fix')
|
|
2373
2434
|
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>"`);
|
|
2374
|
-
if (!run.gate.spend && action
|
|
2375
|
-
throw new UsageError(`run ${id} is at its judge gate, which takes only --action fix: done-done never moves a run past its judge, and writes no receipt without a judge pass`, `Run: done-done respond --run ${id} --action fix, then fix the product or the verify skill's recipe, commit, and run again`);
|
|
2435
|
+
if (!run.gate.spend && action === 'approve')
|
|
2436
|
+
throw new UsageError(`run ${id} is at its judge gate, which takes only --action fix or skip: done-done never moves a run past its judge, and writes no receipt without a judge pass`, `Run: done-done respond --run ${id} --action fix, then fix the product or the verify skill's recipe, commit, and run again`);
|
|
2376
2437
|
if (action !== 'fix' && !reason)
|
|
2377
2438
|
throw new UsageError(`respond --action ${action} needs --reason "<why>": the spend answer is recorded with who and why`, `Run: done-done respond --run ${id} --action ${action} --reason "<why>"`);
|
|
2378
2439
|
const gate = run.steps.find((s) => s.name === 'gate');
|
|
2379
|
-
const by = answeredBy(repo);
|
|
2380
2440
|
run.response = { action, by, ...(reason && { reason }), at: now() };
|
|
2381
2441
|
logEvent(dir, { step: 'gate', response: action, by, ...(reason && { reason }) });
|
|
2382
2442
|
// The spend gate: approve runs the journey in a new runner; skip ends the run with nothing spent.
|
|
@@ -2385,34 +2445,106 @@ async function cmdRespond(argv) {
|
|
|
2385
2445
|
if (action !== 'approve') {
|
|
2386
2446
|
setStep(dir, run, drive, 'failed');
|
|
2387
2447
|
finishRun(dir, run, 'fail', `gate: ${by} chose ${action}: the journey was not run, so nothing was spent`);
|
|
2388
|
-
return
|
|
2448
|
+
return { gone: false };
|
|
2389
2449
|
}
|
|
2390
2450
|
Object.assign(run, { spend: { by, reason, at: run.response.at }, status: 'pending', gate: undefined, response: undefined, runner: undefined });
|
|
2391
2451
|
setStep(dir, run, drive, 'pending');
|
|
2392
2452
|
for (const lock of ['runner.lock', 'respond.lock'])
|
|
2393
2453
|
rmSync(join(dir, lock));
|
|
2394
|
-
return
|
|
2454
|
+
return await startRunner(dir, {});
|
|
2395
2455
|
}
|
|
2396
2456
|
setStep(dir, run, gate, 'failed');
|
|
2397
|
-
finishRun(dir, run, 'fail', `gate: ${by} chose
|
|
2398
|
-
return
|
|
2457
|
+
finishRun(dir, run, 'fail', `gate: ${by} chose ${action}: ${reason || run.gate.findings[0].what}`);
|
|
2458
|
+
return { gone: false };
|
|
2399
2459
|
}
|
|
2400
2460
|
catch (e) {
|
|
2401
2461
|
rmSync(join(dir, 'respond.lock'), { force: true });
|
|
2402
2462
|
throw e;
|
|
2403
2463
|
}
|
|
2404
2464
|
}
|
|
2465
|
+
// Answer a run waiting at its gate, from the terminal.
|
|
2466
|
+
async function cmdRespond(argv) {
|
|
2467
|
+
const { values } = parseArgs({ args: argv, options: { run: { type: 'string' }, action: { type: 'string' }, reason: { type: 'string' } } });
|
|
2468
|
+
const action = values.action;
|
|
2469
|
+
if (action !== 'approve' && action !== 'skip' && action !== 'fix')
|
|
2470
|
+
throw new UsageError('respond needs --action approve, skip or fix', 'Run: done-done respond --run <run-id> --action <approve|skip|fix> [--reason "<why>"]');
|
|
2471
|
+
const reason = values.reason?.replace(/\s+/g, ' ').trim() ?? '';
|
|
2472
|
+
const repo = repoRoot();
|
|
2473
|
+
const cfg = loadConfig(repo);
|
|
2474
|
+
const root = runsRoot(cfg.app);
|
|
2475
|
+
const id = pickId(root, repo, 'respond', cfg, values.run);
|
|
2476
|
+
const dir = join(root, id);
|
|
2477
|
+
const answered = await answerGate(dir, id, action, reason, answeredBy(repo));
|
|
2478
|
+
return showRun(dir, cfg, 'respond', answered.gone);
|
|
2479
|
+
}
|
|
2480
|
+
// The sign-off board's own CLI (github.com/umeranjum17/sign-off): done-done posts through it and never edits a board.
|
|
2481
|
+
// It prints one document: `field: value` lines, a table and a help list.
|
|
2482
|
+
function signoffRun(args, cwd) {
|
|
2483
|
+
const r = spawnSync('sign-off-axi', args, { cwd, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 });
|
|
2484
|
+
if (r.error?.code === 'ENOENT')
|
|
2485
|
+
throw new UsageError('sign-off-axi is not on PATH, so nothing was posted', 'Install the board CLI: npm install -g sign-off-axi, then run this again');
|
|
2486
|
+
return { code: r.status ?? 1, out: `${r.stdout ?? ''}${r.stderr ?? ''}` };
|
|
2487
|
+
}
|
|
2488
|
+
// One field of that document: the value of the first line that starts with `name:`.
|
|
2489
|
+
const signoffField = (out, name) => {
|
|
2490
|
+
const line = out.split('\n').find((l) => l.startsWith(`${name}:`));
|
|
2491
|
+
if (!line)
|
|
2492
|
+
return '';
|
|
2493
|
+
const raw = line.slice(name.length + 1).trim();
|
|
2494
|
+
return raw.startsWith('"') && raw.endsWith('"') && raw.length > 1 ? raw.slice(1, -1) : raw;
|
|
2495
|
+
};
|
|
2496
|
+
// Its help line: the value of the first `help[n]:` line.
|
|
2497
|
+
const signoffHelp = (out) => {
|
|
2498
|
+
const line = out.split('\n').find((l) => l.startsWith('help['));
|
|
2499
|
+
if (!line)
|
|
2500
|
+
return '';
|
|
2501
|
+
const raw = line.slice(line.indexOf(':') + 1).trim();
|
|
2502
|
+
return raw.startsWith('"') && raw.endsWith('"') && raw.length > 1 ? raw.slice(1, -1) : raw;
|
|
2503
|
+
};
|
|
2504
|
+
// Post the message to the board and read back the card it made.
|
|
2505
|
+
function postToBoard(cwd, board, kind, file, key) {
|
|
2506
|
+
const { code, out } = signoffRun(['post', ...(board ? [board] : []), '--kind', kind, '--from', file], cwd);
|
|
2507
|
+
if (code !== 0)
|
|
2508
|
+
throw new UsageError(signoffField(out, 'error') || `sign-off-axi post ended with exit ${code}`, signoffHelp(out) || 'Read it: sign-off-axi post --help');
|
|
2509
|
+
const shown = out.split('\n').some((l) => { const t = l.trim(); return t === key || t.startsWith(`${key},`) || t.startsWith(`${key} `); });
|
|
2510
|
+
if (!shown)
|
|
2511
|
+
throw new UsageError(`sign-off-axi posted without the card ${key}, so the run has no sign-off card`, 'Read the board: sign-off-axi list');
|
|
2512
|
+
return { board: signoffField(out, 'board') || board, key };
|
|
2513
|
+
}
|
|
2514
|
+
// Wait for the answer to a card on the board and read it back.
|
|
2515
|
+
function waitForAnswer(cwd, board, key, seconds) {
|
|
2516
|
+
const { code, out } = signoffRun(['wait', ...(board ? [board] : []), '--key', key, '--json', '--timeout', String(seconds)], cwd);
|
|
2517
|
+
if (code !== 0)
|
|
2518
|
+
throw new UsageError(signoffField(out, 'error') || `sign-off-axi wait ended with exit ${code}`, signoffHelp(out) || `Wait again: done-done signoff --wait`);
|
|
2519
|
+
let answers;
|
|
2520
|
+
try {
|
|
2521
|
+
answers = JSON.parse(out);
|
|
2522
|
+
}
|
|
2523
|
+
catch {
|
|
2524
|
+
throw new UsageError(`sign-off-axi wait printed no answer for the card ${key}`, `Read it: sign-off-axi answers --key ${key}`);
|
|
2525
|
+
}
|
|
2526
|
+
const answer = answers[key];
|
|
2527
|
+
if (!answer)
|
|
2528
|
+
throw new UsageError(`the board card ${key} has no answer`, `Read it: sign-off-axi answers --key ${key}`);
|
|
2529
|
+
return { value: answer.answer, ...(answer.label && { label: answer.label }), ...(answer.at && { at: answer.at }), ...(answer.note && { note: answer.note }) };
|
|
2530
|
+
}
|
|
2405
2531
|
// sign-off's communication contract v1 (github.com/umeranjum17/sign-off, docs/contract.md): done-done sends `gate` and
|
|
2406
2532
|
// `evidence`, schema 1. Each value is checked against the contract's limits first, so a run that cannot make a valid
|
|
2407
|
-
// message writes no file. done-done never posts it: sign-off is the user's choice, so the help line
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2533
|
+
// message writes no file. Without the opt-in done-done never posts it: sign-off is the user's choice, so the help line
|
|
2534
|
+
// names the post command. With the opt-in (--post, or signoff.post in .done-done.yaml) done-done posts it through
|
|
2535
|
+
// sign-off-axi, records the card on the run and can wait for the answer to feed back to that run.
|
|
2536
|
+
// The contract fixes a gate's options: approve (Approve), skip (Skip the judge) and fix (Fix first), and it takes two or
|
|
2537
|
+
// three of them. A spend gate answers approve or skip; a judge gate answers fix or skip, never approve: nothing moves a
|
|
2538
|
+
// run past its judge.
|
|
2539
|
+
const GATE_LABELS = { approve: 'Approve', skip: 'Skip the judge', fix: 'Fix first' };
|
|
2540
|
+
async function cmdSignoff(argv) {
|
|
2541
|
+
const { values } = parseArgs({ args: argv, options: { run: { type: 'string' }, post: { type: 'boolean' }, board: { type: 'string' }, wait: { type: 'boolean' }, timeout: { type: 'string' } } });
|
|
2411
2542
|
const repo = repoRoot();
|
|
2412
2543
|
const cfg = loadConfig(repo);
|
|
2413
2544
|
const root = runsRoot(cfg.app);
|
|
2414
2545
|
const id = pickId(root, repo, 'signoff', cfg, values.run);
|
|
2415
|
-
const
|
|
2546
|
+
const dir = join(root, id);
|
|
2547
|
+
let run = loadRun(dir);
|
|
2416
2548
|
const refuse = (what, help) => { throw new UsageError(`run ${id} cannot make a valid sign-off message, so no file was written: ${what}`, help); };
|
|
2417
2549
|
const text = (field, v, max, help = `Read it: done-done status --run ${id}`) => {
|
|
2418
2550
|
const n = [...v].length;
|
|
@@ -2429,6 +2561,44 @@ function cmdSignoff(argv) {
|
|
|
2429
2561
|
// names the capture file alone: strip any <evidence root>/<one run folder>/ prefix.
|
|
2430
2562
|
const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
2431
2563
|
const bare = (s) => s.replaceAll(new RegExp(`${esc(cfg.evidence.root)}${esc(sep)}[^${esc(sep)}]+${esc(sep)}`, 'g'), '');
|
|
2564
|
+
// The board: the one the user names (--board, or signoff.board), else the nearest .sign-off/ board sign-off-axi finds.
|
|
2565
|
+
const boardName = values.board ?? cfg.signoff.board;
|
|
2566
|
+
const board = boardName ? resolve(repo, tilde(boardName)) : '';
|
|
2567
|
+
// The opt-in: --post posts this run, signoff.post in .done-done.yaml posts every run. Without it nothing is posted.
|
|
2568
|
+
const posts = values.post === true || cfg.signoff.post;
|
|
2569
|
+
// Wait for the answer to the card this run posted and feed it back to that run.
|
|
2570
|
+
const awaitCard = async (card) => {
|
|
2571
|
+
const limit = parseDuration(values.timeout ?? '10m');
|
|
2572
|
+
if (limit === null)
|
|
2573
|
+
throw new UsageError(`--timeout ${values.timeout} is not a duration such as 90s, 10m or 1h`, 'Run: done-done signoff --wait --timeout 10m');
|
|
2574
|
+
const answer = waitForAnswer(repo, board || tilde(card.board), card.key, Math.round(limit / 1000));
|
|
2575
|
+
card.answer = { value: answer.value, ...(answer.label && { label: answer.label }), ...(answer.at && { at: answer.at }), ...(answer.note && { note: answer.note }) };
|
|
2576
|
+
run = loadRun(dir);
|
|
2577
|
+
run.signoff = card;
|
|
2578
|
+
saveRun(dir, run);
|
|
2579
|
+
logEvent(dir, { step: 'signoff', answered: card.key, answer: answer.value });
|
|
2580
|
+
if (card.kind === 'gate') {
|
|
2581
|
+
const action = answer.value;
|
|
2582
|
+
if (action !== 'approve' && action !== 'skip' && action !== 'fix')
|
|
2583
|
+
throw new UsageError(`the board card ${card.key} was answered ${action}, which is not an action of done-done respond`, `Read it: done-done status --run ${id}`);
|
|
2584
|
+
const reason = answer.note?.replace(/\s+/g, ' ').trim() || `answered ${action} on the sign-off board card ${card.key}`;
|
|
2585
|
+
await answerGate(dir, id, action, reason, answeredBy(repo));
|
|
2586
|
+
}
|
|
2587
|
+
run = loadRun(dir);
|
|
2588
|
+
emit({
|
|
2589
|
+
command: 'signoff', run_id: id, kind: card.kind, file: card.file, board: card.board,
|
|
2590
|
+
card: { key: card.key, at: card.at }, answer: card.answer,
|
|
2591
|
+
run: { status: shownStatus(run), ...(run.result && { result: run.result }), ...(run.reason && { reason: run.reason }) },
|
|
2592
|
+
help: [`Read the run: done-done status --run ${id}`],
|
|
2593
|
+
});
|
|
2594
|
+
return runCode(run, shownStatus(run));
|
|
2595
|
+
};
|
|
2596
|
+
// Without the opt-in, --wait only reads the card the run already posted; it never posts one.
|
|
2597
|
+
if (values.wait && !posts) {
|
|
2598
|
+
if (!run.signoff)
|
|
2599
|
+
throw new UsageError(`run ${id} has no sign-off card, so there is no answer to wait for`, `Post it first: done-done signoff --post --run ${id}`);
|
|
2600
|
+
return await awaitCard(run.signoff);
|
|
2601
|
+
}
|
|
2432
2602
|
let msg, file;
|
|
2433
2603
|
if (run.kind === 'verify' && run.status === 'awaiting_approval' && run.gate) {
|
|
2434
2604
|
const { findings, spend, head } = run.gate;
|
|
@@ -2437,8 +2607,8 @@ function cmdSignoff(argv) {
|
|
|
2437
2607
|
contract: 1, kind: 'gate', schema: 1, key: `dd-gate-${id}`, filer: 'done-done',
|
|
2438
2608
|
title: text('title', spend ? `Spend ${spend} on the ${cfg.app} ${drove} run?` : `End the ${cfg.app} ${drove} run its judge did not pass, to fix it?`, 160),
|
|
2439
2609
|
about: text('about', `${bare(findings[0].what)}${more}`, 500), run: runRef(head.commit),
|
|
2440
|
-
//
|
|
2441
|
-
options: (spend ? ['approve', 'skip'] : ['fix']).map((value) => ({ value, label: GATE_LABELS[value] })),
|
|
2610
|
+
// The contract's gate vocabulary: a spend gate takes approve or skip, a judge gate fix or skip, and nothing else.
|
|
2611
|
+
options: (spend ? ['approve', 'skip'] : ['fix', 'skip']).map((value) => ({ value, label: GATE_LABELS[value] })),
|
|
2442
2612
|
recommend_value: spend ? 'approve' : 'fix', close: 'done',
|
|
2443
2613
|
};
|
|
2444
2614
|
file = join(root, id, 'signoff-gate.json');
|
|
@@ -2499,7 +2669,23 @@ function cmdSignoff(argv) {
|
|
|
2499
2669
|
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}`);
|
|
2500
2670
|
writeFileSync(file, `${JSON.stringify(msg, null, 2)}\n`);
|
|
2501
2671
|
const at = citable(repo, file);
|
|
2502
|
-
|
|
2672
|
+
if (!posts) {
|
|
2673
|
+
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)}`] });
|
|
2674
|
+
return PASS;
|
|
2675
|
+
}
|
|
2676
|
+
// Post it through the board's own CLI and record the card it made, so the claim traces to the run and its card.
|
|
2677
|
+
const posted = postToBoard(repo, board, String(msg.kind), at, String(msg.key));
|
|
2678
|
+
const card = { key: posted.key, kind: msg.kind, board: posted.board, file: at, at: now() };
|
|
2679
|
+
run.signoff = card;
|
|
2680
|
+
saveRun(dir, run);
|
|
2681
|
+
logEvent(dir, { step: 'signoff', posted: card.key, kind: card.kind });
|
|
2682
|
+
if (values.wait)
|
|
2683
|
+
return await awaitCard(card);
|
|
2684
|
+
emit({
|
|
2685
|
+
command: 'signoff', run_id: id, kind: card.kind, file: at, board: card.board,
|
|
2686
|
+
card: { key: card.key, at: card.at },
|
|
2687
|
+
help: [`Wait for the answer: done-done signoff --wait --run ${id}`, `Read the card: sign-off-axi answers --key ${card.key}`],
|
|
2688
|
+
});
|
|
2503
2689
|
return PASS;
|
|
2504
2690
|
}
|
|
2505
2691
|
function runsOf(repo, cfg) {
|
|
@@ -3657,7 +3843,7 @@ async function cmdLook(argv) {
|
|
|
3657
3843
|
// The folder of a branch where a receipt copied from the evidence root is committed, for `gate`.
|
|
3658
3844
|
const GATE_DIR = '.done-done-receipts';
|
|
3659
3845
|
// The reason no committed receipt vouches for `tip`, or null when one does. Reads git objects only: no config, no judge, no evidence files.
|
|
3660
|
-
function gateFault(repo, tip, dir, pushed) {
|
|
3846
|
+
function gateFault(repo, tip, dir, pushed, warnings) {
|
|
3661
3847
|
const short = tip.slice(0, 12);
|
|
3662
3848
|
const names = git(repo, '-c', 'core.quotepath=off', 'ls-tree', '-r', '--name-only', tip, '--', dir).split('\n').filter((n) => n.endsWith('.json'));
|
|
3663
3849
|
if (!names.length)
|
|
@@ -3689,6 +3875,9 @@ function gateFault(repo, tip, dir, pushed) {
|
|
|
3689
3875
|
const at = Date.parse(rec.at);
|
|
3690
3876
|
if (!(Math.floor(at / 1000) <= Math.floor(pushed / 1000)))
|
|
3691
3877
|
return `receipt time ${rec.at} is not before the push (${new Date(pushed).toISOString()})`;
|
|
3878
|
+
const warn = receiptWarning(rec);
|
|
3879
|
+
if (warn)
|
|
3880
|
+
warnings.push(warn);
|
|
3692
3881
|
return null;
|
|
3693
3882
|
})();
|
|
3694
3883
|
if (!why)
|
|
@@ -3713,8 +3902,9 @@ function cmdGate(argv) {
|
|
|
3713
3902
|
const pushed = values.pushed === undefined ? Number(git(repo, 'log', '-1', '--format=%ct', tip)) * 1000 : Date.parse(values.pushed);
|
|
3714
3903
|
if (Number.isNaN(pushed))
|
|
3715
3904
|
throw new UsageError(`gate: --pushed ${values.pushed} is not a time`, 'Pass an ISO time such as 2026-10-20T09:05:56Z');
|
|
3716
|
-
const
|
|
3717
|
-
|
|
3905
|
+
const warnings = [];
|
|
3906
|
+
const why = gateFault(repo, tip, (values.dir ?? GATE_DIR).replace(/\/+$/, ''), pushed, warnings);
|
|
3907
|
+
console.log(why === null ? ['PASS', ...warnings.map((w) => `WARN: ${w}`)].join('\n') : `FAIL: ${why}`);
|
|
3718
3908
|
return why === null ? PASS : FAIL;
|
|
3719
3909
|
}
|
|
3720
3910
|
function cmdLint(argv) {
|
|
@@ -4003,13 +4193,13 @@ async function main(argv) {
|
|
|
4003
4193
|
if (cmd === 'history')
|
|
4004
4194
|
return cmdHistory(rest);
|
|
4005
4195
|
if (cmd === 'signoff')
|
|
4006
|
-
return cmdSignoff(rest);
|
|
4196
|
+
return await cmdSignoff(rest);
|
|
4007
4197
|
if (cmd === '__runner') {
|
|
4008
4198
|
await runSteps(rest[0]);
|
|
4009
4199
|
return PASS;
|
|
4010
4200
|
}
|
|
4011
4201
|
if (cmd === '--version') {
|
|
4012
|
-
console.log(
|
|
4202
|
+
console.log(VERSION);
|
|
4013
4203
|
return PASS;
|
|
4014
4204
|
}
|
|
4015
4205
|
if (cmd === '--help' || cmd === '-h' || cmd === 'help') {
|
package/dist/config.js
CHANGED
|
@@ -76,12 +76,14 @@ const KEYS = {
|
|
|
76
76
|
'checks.upstream_catch_up': { scope: 'repo', type: 'boolean' },
|
|
77
77
|
'checks.moved_from': { scope: 'repo', type: 'boolean' },
|
|
78
78
|
'checks.generated_paths': { scope: 'repo', type: 'string[]' },
|
|
79
|
+
'signoff.post': { scope: 'repo', type: 'boolean' },
|
|
80
|
+
'signoff.board': { scope: 'repo', type: 'string' },
|
|
79
81
|
'judge.model': { scope: 'global', type: 'string' },
|
|
80
82
|
'judge.route': { scope: 'global', type: 'route' },
|
|
81
83
|
'judge.floor': { scope: 'global', type: 'number' },
|
|
82
84
|
'judge.env_file': { scope: 'global', type: 'string' },
|
|
83
85
|
};
|
|
84
|
-
const SECTIONS = new Set(['evidence', 'secrets', 'checks', 'judge']);
|
|
86
|
+
const SECTIONS = new Set(['evidence', 'secrets', 'checks', 'judge', 'signoff']);
|
|
85
87
|
const MAPS = new Set(['auto_fix', 'agent_config', 'agent_args_override', 'agent_path_override']);
|
|
86
88
|
export const home = () => process.env.DONE_DONE_HOME || join(homedir(), '.done-done');
|
|
87
89
|
// A duration such as 90s, 30m or 1h, in milliseconds; null when the text is not one or is not a delay a timer can run.
|
|
@@ -281,6 +283,7 @@ function defaultConfig(repo) {
|
|
|
281
283
|
evidence: { local_root: join(home(), 'evidence'), root: '', allow_dirty: false, ignore_paths: [] },
|
|
282
284
|
secrets: { patterns: {}, allow: [] },
|
|
283
285
|
checks: { fix_title: '^(fix|bugfix|hotfix)\\b', upstream_catch_up: false, moved_from: false, generated_paths: [] },
|
|
286
|
+
signoff: { post: false, board: '' },
|
|
284
287
|
judge: { route: '', model: '', floor: 0.7, env_file: '' },
|
|
285
288
|
trusted: false,
|
|
286
289
|
from: {},
|