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/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 (approve and skip answer only a spend gate)
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: end it fail, then fix the product or the verify skill (done-done maintain checks it against the source)',
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: [['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"]],
290
- flags: ['--run <run-id>'],
291
- more: [RUNS_TEXT],
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
- return code ? `receipt for ${short} is no real-product proof: ${code}` : null;
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. Stop everything you started.
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 four steps are done: your next message without a tool call ends the drive and must be the answer.
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 = status === 'pending' || status === 'running' || status === 'awaiting_approval' ? WAITING : run.stopped && status === 'failed' ? USAGE : status === 'cancelled' || status === 'failed' || run.result === 'fail' || run.result === 'drift' || (run.kind === 'verify' && (run.result === 'changed' || run.result === 'blocked')) ? FAIL : run.result === 'inconclusive' || run.result === 'blocked' ? INCONCLUSIVE : PASS;
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
- async function launch(dir, cfg, command, how) {
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 showRun(dir, cfg, command);
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 showRun(dir, cfg, command, !alive(child.pid));
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 answers: it ends the run fail, since nothing moves a run past its judge and no receipt is ever
2352
- // written without a judge pass. The runner has exited, so this command finishes the run; the respond lock lets only one
2353
- // answer (or an abort) close it.
2354
- async function cmdRespond(argv) {
2355
- const { values } = parseArgs({ args: argv, options: { run: { type: 'string' }, action: { type: 'string' }, reason: { type: 'string' } } });
2356
- const action = values.action;
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 !== 'fix')
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 showRun(dir, cfg, 'respond');
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 launch(dir, cfg, 'respond', {});
2454
+ return await startRunner(dir, {});
2395
2455
  }
2396
2456
  setStep(dir, run, gate, 'failed');
2397
- finishRun(dir, run, 'fail', `gate: ${by} chose fix: ${reason || run.gate.findings[0].what}`);
2398
- return showRun(dir, cfg, 'respond');
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 names the post command.
2408
- const GATE_LABELS = { approve: 'Approve the spend', skip: 'Skip the spend', fix: 'Fix first' };
2409
- function cmdSignoff(argv) {
2410
- const { values } = parseArgs({ args: argv, options: { run: { type: 'string' } } });
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 run = loadRun(join(root, id));
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
- // A spend gate takes approve or skip; the judge gate only fix, since nothing moves a run past its judge.
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
- 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)}`] });
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 why = gateFault(repo, tip, (values.dir ?? GATE_DIR).replace(/\/+$/, ''), pushed);
3717
- console.log(why === null ? 'PASS' : `FAIL: ${why}`);
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(JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version);
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: {},