@sabaiway/agent-workflow-kit 2.1.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/CHANGELOG.md +94 -1
  2. package/README.md +7 -5
  3. package/SKILL.md +16 -8
  4. package/bin/install.mjs +11 -2
  5. package/bridges/antigravity-cli-bridge/SKILL.md +8 -1
  6. package/bridges/antigravity-cli-bridge/bin/agy-review-honesty.test.mjs +163 -0
  7. package/bridges/antigravity-cli-bridge/bin/agy-review-model-screen.test.mjs +76 -0
  8. package/bridges/antigravity-cli-bridge/bin/agy-review.sh +80 -15
  9. package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +171 -22
  10. package/bridges/antigravity-cli-bridge/bin/agy.test.mjs +59 -41
  11. package/bridges/antigravity-cli-bridge/capability.json +3 -2
  12. package/bridges/codex-cli-bridge/SKILL.md +8 -1
  13. package/bridges/codex-cli-bridge/bin/codex-exec.test.mjs +67 -22
  14. package/bridges/codex-cli-bridge/bin/codex-review-honesty.test.mjs +156 -0
  15. package/bridges/codex-cli-bridge/bin/codex-review.sh +71 -10
  16. package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +158 -27
  17. package/bridges/codex-cli-bridge/capability.json +3 -2
  18. package/capability.json +1 -1
  19. package/migrations/3.0.0-hardened-core-loop.md +44 -0
  20. package/migrations/README.md +1 -1
  21. package/package.json +2 -2
  22. package/references/hooks/gate-approve.mjs +1 -1
  23. package/references/modes/bootstrap.md +3 -3
  24. package/references/modes/commit-guard.md +19 -0
  25. package/references/modes/core-evidence.md +15 -0
  26. package/references/modes/coverage-check.md +12 -0
  27. package/references/modes/doc-parity.md +7 -6
  28. package/references/modes/gates.md +7 -6
  29. package/references/modes/grounding.md +1 -2
  30. package/references/modes/hook.md +1 -1
  31. package/references/modes/recommendations.md +1 -0
  32. package/references/modes/review-state.md +3 -3
  33. package/references/modes/upgrade.md +6 -6
  34. package/references/modes/velocity.md +2 -2
  35. package/references/modes/worktrees.md +120 -0
  36. package/references/scripts/archive-decisions.mjs +3 -3
  37. package/references/scripts/archive-decisions.test.mjs +5 -5
  38. package/references/scripts/check-docs-size-cli.test.mjs +41 -0
  39. package/references/scripts/check-docs-size.mjs +46 -29
  40. package/references/scripts/install-git-hooks-repo-exec.test.mjs +82 -0
  41. package/references/scripts/install-git-hooks.mjs +90 -18
  42. package/references/scripts/install-git-hooks.test.mjs +102 -0
  43. package/references/scripts/migrate-gates-branches.test.mjs +157 -0
  44. package/references/scripts/migrate-gates.mjs +395 -0
  45. package/references/scripts/migrate-gates.test.mjs +284 -0
  46. package/references/templates/agent_rules.md +2 -2
  47. package/tools/ack-write.mjs +1 -1
  48. package/tools/atomic-write.mjs +2 -2
  49. package/tools/autonomy-config.mjs +1 -1
  50. package/tools/autonomy-doctor.mjs +2 -2
  51. package/tools/autonomy-write.mjs +1 -1
  52. package/tools/bridge-settings-read.mjs +1 -1
  53. package/tools/bridge-settings.mjs +1 -1
  54. package/tools/changed-surface.mjs +16 -77
  55. package/tools/cheap-agents.mjs +2 -2
  56. package/tools/commands.mjs +26 -10
  57. package/tools/commit-guard.mjs +167 -0
  58. package/tools/core-evidence.mjs +914 -0
  59. package/tools/coverage-check.mjs +260 -0
  60. package/tools/delegation.mjs +1 -1
  61. package/tools/detect-backends.mjs +3 -3
  62. package/tools/doc-parity.mjs +11 -27
  63. package/tools/engine-source.mjs +1 -1
  64. package/tools/family-members.mjs +1 -1
  65. package/tools/family-registry.mjs +1 -1
  66. package/tools/fs-safe.mjs +1 -1
  67. package/tools/gate-hook.mjs +1 -1
  68. package/tools/{seed-gates.mjs → gates-init.mjs} +58 -52
  69. package/tools/grounding.mjs +19 -96
  70. package/tools/hide-footprint.mjs +1 -1
  71. package/tools/inject-methodology.mjs +72 -41
  72. package/tools/known-footprint.mjs +1 -1
  73. package/tools/labels.mjs +1 -1
  74. package/tools/lcov.mjs +6 -10
  75. package/tools/lens-region.mjs +1 -1
  76. package/tools/manifest/validate.mjs +19 -0
  77. package/tools/migrate-adr-store.mjs +5 -5
  78. package/tools/orchestration-config.mjs +1 -1
  79. package/tools/orchestration-write.mjs +1 -1
  80. package/tools/presentation.mjs +1 -1
  81. package/tools/procedures.mjs +3 -3
  82. package/tools/recipes.mjs +62 -11
  83. package/tools/recommendations.mjs +97 -14
  84. package/tools/release-scan.mjs +10 -3
  85. package/tools/renderers.mjs +1 -1
  86. package/tools/review-state.mjs +291 -343
  87. package/tools/run-gates.mjs +216 -92
  88. package/tools/sandbox-masks.mjs +14 -14
  89. package/tools/semver-lite.mjs +1 -1
  90. package/tools/set-autonomy.mjs +1 -1
  91. package/tools/set-recipe.mjs +2 -2
  92. package/tools/setup-backends.mjs +1 -1
  93. package/tools/surface.mjs +1 -1
  94. package/tools/uninstall.mjs +1 -1
  95. package/tools/velocity-profile.mjs +9 -9
  96. package/tools/view-model.mjs +1 -1
  97. package/tools/worktrees.mjs +2292 -0
  98. package/references/modes/fold-completeness.md +0 -30
  99. package/references/modes/review-ledger.md +0 -34
  100. package/references/templates/verification-profile.json +0 -10
  101. package/tools/fold-completeness-run.mjs +0 -1120
  102. package/tools/fold-completeness.mjs +0 -672
  103. package/tools/review-ledger-core.mjs +0 -428
  104. package/tools/review-ledger-write.mjs +0 -647
  105. package/tools/review-ledger.mjs +0 -630
  106. package/tools/sarif.mjs +0 -52
  107. package/tools/verification-profile.mjs +0 -219
@@ -18,38 +18,31 @@
18
18
  // Honest outcomes — each distinct, never a silent green (the exit-code table + the summary-line
19
19
  // schema are pinned by run-gates.test.mjs):
20
20
  // 0 ok · 1 gate failure · 2 usage · 3 missing declaration · 4 empty gates list ·
21
- // 5 malformed/invalid declaration · 6 bash unavailable · 7 --record asked but the gate-run
22
- // record could not be written. Gate `cmd` lines are BASH command lines (brace/glob expansion);
23
- // a host without bash gets the loud exit-6 preflight error, never a silent reinterpretation
24
- // under another shell.
21
+ // 5 malformed/invalid declaration · 6 bash unavailable · 8 --final receipt not written.
22
+ // Gate `cmd` lines are BASH command lines (brace/glob expansion); a host without bash gets the
23
+ // loud exit-6 preflight error, never a silent reinterpretation under another shell.
25
24
  //
26
- // The runner itself WRITES NOTHING by default. `--record` (BUGFREE-2 / AD-048, D5) mints ONE
27
- // v4 `gate-run` record the green-baseline receipt the review-ledger writer's D5 tooth consumes —
28
- // by DELEGATING to the ledger's sole writer (recordGateRun in review-ledger-write.mjs): this
29
- // runner never opens the ledger file itself (an import/structure pin holds the boundary). The
30
- // record carries the FULL declaration + exactly what ran (a --only subset records honestly as a
31
- // subset — it never satisfies quality-green) + the tree fingerprint BEFORE and AFTER the run (a
32
- // mutating gate attests no particular tree). Dependency-free, Node >= 18. No side effects on import.
25
+ // The runner itself WRITES NOTHING on a plain run. `--final` (D3(a)) is the ONE writing mode:
26
+ // every attempt lands in the core-evidence store via its sole writer. Dependency-free.
27
+ // No side effects on import.
33
28
 
34
- import { readFileSync, lstatSync } from 'node:fs';
35
- import { join } from 'node:path';
29
+ import { readFileSync, lstatSync, unlinkSync, realpathSync } from 'node:fs';
30
+ import { join, isAbsolute } from 'node:path';
36
31
  import { spawnSync } from 'node:child_process';
37
- import { pathToFileURL } from 'node:url';
32
+ import { pathToFileURL, fileURLToPath } from 'node:url';
33
+ import { createHash, randomUUID } from 'node:crypto';
38
34
  import { computeTreeFingerprint } from './review-state.mjs';
39
- import { recordGateRun } from './review-ledger-write.mjs';
40
- // (a) BUGFREE-3 / AD-049: the one-suite-run credit. The fold runner already spawned the `unit-tests`
41
- // suite under coverage; --record can CREDIT that gate from the recorded evidence instead of
42
- // re-spawning it minutes later — read-only (the read core, never the tree-toucher).
43
- import { foldSuiteCredit } from './fold-completeness.mjs';
44
-
45
- // The gate id the (a) credit applies to — the SAME command the fold runner resolves as its suite.
46
- export const UNIT_TESTS_GATE_ID = 'unit-tests';
35
+ // The D3(a) final receipt rides the core-evidence SOLE WRITER (the sole-writer boundary — this
36
+ // runner never opens the store itself) + the canonical per-kind serialization its hashes bind.
37
+ import { appendEvidenceRecord, resolveEvidencePath, readEvidence, canonicalKindSerialization, EVIDENCE_SCHEMA_VERSION } from './core-evidence.mjs';
38
+ import { LCOV_BASENAME } from './coverage-check.mjs';
47
39
 
48
40
  // The per-project declaration (strict JSON, hand-editable). cwd-relative — errors show a path the
49
41
  // user can open (the orchestration-config CONFIG_REL idiom).
50
42
  export const GATES_REL = 'docs/ai/gates.json';
51
43
 
52
44
  // The full exit-code table — one distinct code per honest outcome (never a silent green).
45
+ // 7 is RETIRED (the deleted --record arm's outcome) — never reused for a new meaning.
53
46
  export const EXIT = Object.freeze({
54
47
  ok: 0,
55
48
  fail: 1,
@@ -58,10 +51,9 @@ export const EXIT = Object.freeze({
58
51
  empty: 4,
59
52
  malformed: 5,
60
53
  noBash: 6,
61
- // --record was asked for but the gate-run record could not be written (no in-flight loop, a
62
- // malformed ledger, an fs refusal): the invocation's contract included a ledger receipt — not
63
- // delivering it is its own loud outcome, never folded into ok/fail.
64
- recordFailed: 7,
54
+ // --final could not write its receipt (a corrupt store, an fs refusal): green gates WITHOUT a
55
+ // written receipt never read as success (D3(a)).
56
+ finalFailed: 8,
65
57
  });
66
58
 
67
59
  // A tagged failure carrying its process exit code (the shared orchestration-config idiom).
@@ -74,21 +66,23 @@ const SPAWN_FAILED_CODE = -1;
74
66
  const MAX_GATE_OUTPUT_BYTES = 64 * 1024 * 1024;
75
67
 
76
68
  const USAGE = [
77
- 'usage: run-gates.mjs [--cwd <dir>] [--only <id>]... [--record] [--help]',
69
+ 'usage: run-gates.mjs [--cwd <dir>] [--only <id>]... [--final] [--help]',
70
+ '',
71
+ '--final runs the FULL declared matrix as the D3(a) final verification run: it refuses a',
72
+ 'declaration lacking the canonical core checks (the review-state + coverage-check gates),',
73
+ 'deletes the stale git-dir lcov first, exports AW_GIT_DIR to every gate cmd, records EVERY',
74
+ 'attempt (start + completed green/red) in the core-evidence store, and binds the receipt to',
75
+ '{ fingerprint before/after, the full declaration, per-gate results, the canonical red-proof +',
76
+ 'degrade evidence hashes, the lcov sha }. --final refuses --only (a subset never attests).',
78
77
  '',
79
78
  `Runs the gates declared in <cwd>/${GATES_REL} (one bash command line each, project root as cwd).`,
80
79
  'Prints a per-gate PASS/FAIL table + one machine-readable summary line; exit 0 iff all green.',
81
- '--record additionally mints ONE v4 gate-run record into the review ledger via its sole writer',
82
- '(the D5 green-baseline receipt; needs a single in-flight plan): the full declaration + what ran',
83
- '+ the pre/post tree fingerprints. A red run records honestly; a --only subset records as a subset.',
84
- 'In --record mode the unit-tests gate is CREDITED (not re-spawned) when it is the FIRST declared gate',
85
- 'AND the fold-completeness runner already ran that EXACT command green at the current tree',
86
- '(fingerprint-bound + exit-0 + cmd-identity); positioned after another gate, it always re-spawns.',
87
80
  'Sandbox-safe: the runner itself needs no network and writes only repo-local state — the D4 sandbox',
88
81
  'lane; each DECLARED gate command is the project\'s own, so ITS sandbox-safety is command-shape',
89
82
  'dependent (first try the sandbox-safe shape — cache under $TMPDIR, offline/notifier off).',
90
83
  `Exit codes: 0 ok · 1 gate failure · 2 usage · 3 missing declaration · 4 empty gates list ·`,
91
- '5 malformed/invalid declaration · 6 bash unavailable · 7 --record asked but the record failed.',
84
+ '5 malformed/invalid declaration · 6 bash unavailable ·',
85
+ '8 --final asked but its receipt could not be written (green gates never read as success without it).',
92
86
  ].join('\n');
93
87
 
94
88
  // ── declaration validation (malformed → exit 5, loud `path: reason`) ─────────────────
@@ -187,13 +181,11 @@ export const selectGates = (gates, onlyIds) => {
187
181
  // Spawn one gate cmd via bash from the project root. `cmd` is a BASH command line by contract
188
182
  // (the declaration's _README states it): this repo's own gate matrix needs brace+glob expansion,
189
183
  // which /bin/sh does not perform — hence bash explicitly, never the platform default shell.
190
- // NODE_TEST_CONTEXT is stripped (mirroring the fold suite's childTestEnv): a `node --test` gate spawned
191
- // while run-gates is itself running under a parent test context would otherwise inherit it, hit Node's
192
- // recursive-run guard, silently skip every file, and exit 0 — a vacuous false green. Stripping it also
193
- // makes the plain gate env-equivalent to the fold suite, so the (a) suite-run credit's exit-0 truthfully
194
- // predicts a plain-spawn exit-0 (the one remaining, documented residual is NODE_V8_COVERAGE).
195
- export const spawnGateViaBash = (cmd, cwd) => {
196
- const env = { ...process.env };
184
+ // NODE_TEST_CONTEXT is stripped: a `node --test` gate spawned while run-gates is itself running
185
+ // under a parent test context would otherwise inherit it, hit Node's recursive-run guard, silently
186
+ // skip every file, and exit 0 — a vacuous false green.
187
+ export const spawnGateViaBash = (cmd, cwd, extraEnv = {}) => {
188
+ const env = { ...process.env, ...extraEnv };
197
189
  delete env.NODE_TEST_CONTEXT;
198
190
  return spawnSync('bash', ['-c', cmd], { cwd, env, encoding: 'utf8', maxBuffer: MAX_GATE_OUTPUT_BYTES });
199
191
  };
@@ -209,26 +201,17 @@ const trimTrailingNewline = (text) => text.replace(/\n$/, '');
209
201
 
210
202
  // Run the selected gates sequentially (declaration order). A green gate logs one PASS line; a
211
203
  // failing gate logs FAIL + its captured stdout/stderr VERBATIM (triage without re-running).
212
- export const runGates = (gates, { cwd, spawn = spawnGateViaBash, now = Date.now, log = console.log, credit = null }) => {
204
+ export const runGates = (gates, { cwd, spawn = spawnGateViaBash, now = Date.now, log = console.log }) => {
213
205
  const results = [];
214
206
  for (const gate of gates) {
215
207
  log(`── ${gate.id} — ${gate.title}`);
216
- // (a) the one-suite-run credit: for the unit-tests gate, if the fold runner already ran this exact
217
- // command green at the current tree, CREDIT it instead of re-spawning (no quality loss — same
218
- // execution, same tree, recorded once). Any mismatch → credit is null → the normal spawn below.
219
- const credited = credit ? credit(gate) : null;
220
- if (credited) {
221
- log(' PASS (credited from the fold-completeness suite run — no re-spawn)');
222
- results.push({ id: gate.id, title: gate.title, ok: true, code: 0, elapsedMs: 0, credited: true });
223
- continue;
224
- }
225
208
  const startedAt = now();
226
209
  const res = spawn(gate.cmd, cwd);
227
210
  const elapsedMs = now() - startedAt;
228
211
  const spawnError = res.error ? `spawn error: ${res.error.code || res.error.message}` : null;
229
212
  const ok = spawnError === null && res.status === 0;
230
213
  const code = spawnError === null ? res.status : SPAWN_FAILED_CODE;
231
- results.push({ id: gate.id, title: gate.title, ok, code, elapsedMs });
214
+ results.push({ id: gate.id, title: gate.title, ok, code, elapsedMs, stdout: res.stdout ? String(res.stdout) : '' });
232
215
  if (ok) {
233
216
  log(` PASS (${formatSeconds(elapsedMs)})`);
234
217
  } else {
@@ -264,7 +247,7 @@ export const composeSummaryLine = ({ status, results = [] }) => {
264
247
  // ── CLI ───────────────────────────────────────────────────────────────────────────────
265
248
 
266
249
  const parseArgs = (argv) => {
267
- const opts = { cwd: null, only: [], record: false, help: false };
250
+ const opts = { cwd: null, only: [], final: false, help: false };
268
251
  for (let i = 0; i < argv.length; i += 1) {
269
252
  const arg = argv[i];
270
253
  if (arg === '--help' || arg === '-h') {
@@ -277,15 +260,53 @@ const parseArgs = (argv) => {
277
260
  i += 1;
278
261
  if (argv[i] === undefined) throw fail(EXIT.usage, '--only requires a gate id argument');
279
262
  opts.only.push(argv[i]);
280
- } else if (arg === '--record') {
281
- opts.record = true;
263
+ } else if (arg === '--final') {
264
+ opts.final = true;
282
265
  } else {
283
266
  throw fail(EXIT.usage, `unknown argument "${arg}"\n${USAGE}`);
284
267
  }
285
268
  }
269
+ if (opts.final && opts.only.length > 0) {
270
+ throw fail(EXIT.usage, '--final refuses --only — a subset never attests (the D3(a) receipt binds the FULL declaration)');
271
+ }
286
272
  return opts;
287
273
  };
288
274
 
275
+ // The canonical core checks a --final declaration must carry (D3(a)), matched as STRICT FULL
276
+ // commands: `node` + ONE (quoted or bare) path token + the exact tool basename + ` --check` +
277
+ // END — and the path token must REALPATH-RESOLVE to the kit's OWN tool (the canonical sibling of
278
+ // this runner). Masked forms (`--check --help`, `--check || true`, prefix commands) never match
279
+ // the shape; a lookalike file that merely carries the basename — whatever it prints — never
280
+ // resolves to the canonical tool. Any form that DOES resolve (bare, relative, absolute, quoted)
281
+ // is accepted, so the anchor adds no false refusals.
282
+ const coreCheckRe = (basename) => new RegExp(`^node\\s+(?:"((?:[^"]*[/\\\\])?${basename})"|((?:[^\\s"]*[/\\\\])?${basename}))\\s+--check$`);
283
+ const FINAL_CORE_CHECKS = [
284
+ { name: 'review-state', re: coreCheckRe('review-state\\.mjs'), canonical: fileURLToPath(new URL('./review-state.mjs', import.meta.url)) },
285
+ { name: 'coverage-check', re: coreCheckRe('coverage-check\\.mjs'), canonical: fileURLToPath(new URL('./coverage-check.mjs', import.meta.url)) },
286
+ ];
287
+ const matchesCanonicalCheck = (check, cmd, projectDir) => {
288
+ const m = check.re.exec(cmd.trim());
289
+ if (!m) return false;
290
+ const token = m[1] ?? m[2];
291
+ const abs = isAbsolute(token) ? token : join(projectDir, token);
292
+ try {
293
+ return realpathSync(abs) === realpathSync(check.canonical);
294
+ } catch {
295
+ return false; // unresolvable → never canonical (fail closed)
296
+ }
297
+ };
298
+
299
+ // isFinalCapableDeclaration(gates, projectDir) → whether --final would accept this declaration
300
+ // (every canonical core check present + the checker LAST) — the ONE home consumers (the
301
+ // recommendations guard-install probe) read instead of re-deriving the rule.
302
+ export const isFinalCapableDeclaration = (gates, projectDir) => {
303
+ if (!Array.isArray(gates) || gates.length === 0) return false;
304
+ const missing = FINAL_CORE_CHECKS.filter((c) => !gates.some((g) => matchesCanonicalCheck(c, g.cmd, projectDir)));
305
+ if (missing.length > 0) return false;
306
+ return matchesCanonicalCheck(FINAL_CORE_CHECKS[1], gates[gates.length - 1].cmd, projectDir);
307
+ };
308
+ const sha256Hex = (data) => createHash('sha256').update(data).digest('hex');
309
+
289
310
  // The full CLI, dependency-injected for hermetic tests. Returns the process exit code; the two
290
311
  // output sinks split human-facing report (log) from error channel (logError). The summary line is
291
312
  // emitted via `log` as the final line of every non-usage outcome.
@@ -299,9 +320,7 @@ export const runCli = (argv, deps = {}) => {
299
320
  readFile,
300
321
  lstat,
301
322
  now,
302
- record = recordGateRun,
303
323
  fingerprint = computeTreeFingerprint,
304
- foldCredit = foldSuiteCredit,
305
324
  } = deps;
306
325
  try {
307
326
  const opts = parseArgs(argv);
@@ -326,6 +345,39 @@ export const runCli = (argv, deps = {}) => {
326
345
  return EXIT.empty;
327
346
  }
328
347
  const selected = selectGates(declaration.gates, opts.only);
348
+ // AW_GIT_DIR rides EVERY gate child inside a git tree (plain and --only alike): declared
349
+ // cmds reference fixed git-dir artifacts (the unit-tests lcov destination) — a plain red-run
350
+ // must exercise the SAME cmd line --final will, never a broken-only-outside---final variant.
351
+ const dirRes = spawnSync('git', ['rev-parse', '--absolute-git-dir'], { cwd: projectDir, windowsHide: true });
352
+ const gitDir = dirRes.error || dirRes.status !== 0 ? null : dirRes.stdout.toString('utf8').replace(/\r?\n$/, '');
353
+ let gateSpawn = gitDir === null ? spawn : (cmd, cwd2) => spawn(cmd, cwd2, { AW_GIT_DIR: gitDir });
354
+ // ── --final preflight (D3(a)): the declaration must carry the canonical core checks; the TRUE
355
+ // git dir resolves AW_GIT_DIR; the stale lcov dies BEFORE the suite so it is never consumed.
356
+ if (opts.final) {
357
+ const missing = FINAL_CORE_CHECKS.filter((c) => !declaration.gates.some((g) => matchesCanonicalCheck(c, g.cmd, projectDir)));
358
+ if (missing.length > 0) {
359
+ throw fail(EXIT.malformed, `--final refuses a weakened declaration — missing the canonical core check(s): ${missing.map((c) => c.name).join(', ')} (each must be ONE plain --check invocation of the kit's OWN tool in ${GATES_REL} — a masked form, a compound, or a lookalike path never counts)`);
360
+ }
361
+ const lastGate = declaration.gates[declaration.gates.length - 1];
362
+ if (!matchesCanonicalCheck(FINAL_CORE_CHECKS[1], lastGate.cmd, projectDir)) {
363
+ throw fail(EXIT.malformed, `--final refuses the declaration — the CANONICAL coverage-check gate must be the LAST declared gate (nothing may run after the checker consumed the lcov; "${lastGate.id}" is declared last)`);
364
+ }
365
+ if (gitDir === null) throw fail(EXIT.fail, '--final needs a git work tree (cannot resolve the git dir)');
366
+ try {
367
+ unlinkSync(join(gitDir, LCOV_BASENAME)); // a stale lcov is never consumed
368
+ } catch (err) {
369
+ if (err.code !== 'ENOENT') {
370
+ // Anything but "absent" leaves a readable stale artifact behind — fail closed BEFORE
371
+ // the attempt starts (a swallowed delete error could fake coverage).
372
+ logError(`[run-gates] --final could not delete the stale lcov at ${join(gitDir, LCOV_BASENAME)}: ${err.message} (fail closed)`);
373
+ log(composeSummaryLine({ status: 'fail' }));
374
+ return EXIT.finalFailed;
375
+ }
376
+ }
377
+ // ONE lcov path for delete/check/hash/guard: the fixed git-dir file is FORCED into every
378
+ // gate child env — a stray host AW_LCOV_FILE can never desync the checker from the receipt.
379
+ gateSpawn = (cmd, cwd2) => spawn(cmd, cwd2, { AW_GIT_DIR: gitDir, AW_LCOV_FILE: join(gitDir, LCOV_BASENAME) });
380
+ }
329
381
  const probe = spawn(BASH_PROBE_CMD, projectDir);
330
382
  if (probe.error && probe.error.code === 'ENOENT') {
331
383
  logError(
@@ -335,51 +387,123 @@ export const runCli = (argv, deps = {}) => {
335
387
  log(composeSummaryLine({ status: 'no-bash' }));
336
388
  return EXIT.noBash;
337
389
  }
338
- const fingerprintBefore = opts.record ? fingerprint(projectDir) : null;
339
- // (a) the one-suite-run credit — ONLY in --record mode (the fold-loop flow), ONLY the unit-tests
340
- // gate, ONLY when it is the FIRST selected gate (a gate that ran BEFORE it could have side-effected
341
- // an ignored/out-of-tree artifact unit-tests depends on WITHOUT moving the fingerprint, so a
342
- // later-positioned unit-tests must re-spawn — never credit a state the real matrix might fail), and
343
- // ONLY when the fold evidence is fingerprint-bound + exit-0 (foldCredit) AND its recorded cmd EQUALS
344
- // this gate's cmd (the --only-subset defense). Any mismatch → credit is null → the normal spawn.
345
- let credit = null;
346
- if (opts.record && selected[0]?.id === UNIT_TESTS_GATE_ID) {
347
- const fold = foldCredit({ cwd: projectDir, env, fingerprint: fingerprintBefore });
348
- credit = (gate) => (gate.id === UNIT_TESTS_GATE_ID && fold.credited && fold.evidence.cmd === gate.cmd ? fold : null);
390
+ // --final needs the pre-run fingerprint (the receipt binds before == after == current).
391
+ const finalFingerprintBefore = opts.final ? fingerprint(projectDir) : null;
392
+ const finalAttempt = opts.final ? randomUUID() : null;
393
+ let finalError = null;
394
+ let startEvidenceHashes = null;
395
+ if (opts.final) {
396
+ try {
397
+ appendEvidenceRecord({
398
+ path: resolveEvidencePath(projectDir, env),
399
+ record: { schema: EVIDENCE_SCHEMA_VERSION, kind: 'final-start', fingerprint: finalFingerprintBefore, attempt: finalAttempt, timestamp: new Date().toISOString() },
400
+ });
401
+ // The drift tooth's anchor: the canonical red-proof/degrade serializations BEFORE any
402
+ // gate runs — no legitimate writer appends those kinds DURING a final run, so any change
403
+ // by receipt time is an integrity failure, never a green.
404
+ const { records } = readEvidence(resolveEvidencePath(projectDir, env));
405
+ startEvidenceHashes = {
406
+ redProof: sha256Hex(canonicalKindSerialization(records, 'red-proof')),
407
+ degrade: sha256Hex(canonicalKindSerialization(records, 'degrade')),
408
+ };
409
+ } catch (err) {
410
+ // EVERY attempt is recorded — an unwritable store refuses the whole attempt up front
411
+ // (green gates without a written receipt never read as success).
412
+ logError(`[run-gates] --final could not record the attempt start: ${err.message}`);
413
+ log(composeSummaryLine({ status: 'fail' }));
414
+ return EXIT.finalFailed;
415
+ }
349
416
  }
350
- const results = runGates(selected, { cwd: projectDir, spawn, log, now, credit });
417
+ const results = runGates(selected, { cwd: projectDir, spawn: gateSpawn, log, now });
351
418
  for (const line of formatTable(results)) log(line);
352
419
  const allGreen = results.every((result) => result.ok);
353
- // The gate-run record (D5): minted for green AND red runs alike (an honest red is telemetry
354
- // fuel), via the ledger's sole writer. Emitted BEFORE the summary line the machine summary
355
- // stays the LAST line of every non-usage outcome (pinned).
356
- let recordError = null;
357
- if (opts.record) {
358
- const failing = results.filter((result) => !result.ok);
420
+ if (opts.final) {
421
+ // The checker's verbatim diagnostics surface even on greenskipped-no-lcov and the
422
+ // out-of-domain/unsupported lists must never vanish into a suppressed green stdout.
423
+ const checkerRow = results[results.length - 1];
424
+ if (checkerRow?.ok && checkerRow.stdout) {
425
+ log('── coverage-check diagnostics (verbatim)');
426
+ log(trimTrailingNewline(checkerRow.stdout));
427
+ }
428
+ // The completed attempt (D3(a)): status green/red DERIVED from results + integrity, the
429
+ // FULL declaration, the per-gate results, the evidence hashes over the CANONICAL
430
+ // authoritative serializations, and the lcov sha the CHECKER printed for the bytes it
431
+ // actually read — with an end re-hash agreement check (the checker's own children are the
432
+ // one write window that survives "coverage-check runs last").
359
433
  try {
360
- const { writtenPath } = record({
361
- cwd: projectDir,
362
- env,
363
- declared: declaration.gates.map(({ id, cmd }) => ({ id, cmd })),
364
- results: results.map(({ id, ok, code }) => ({ id, ok, code })),
365
- summary: {
366
- status: allGreen ? 'ok' : 'fail',
367
- gates: results.length,
368
- passed: results.length - failing.length,
369
- failed: failing.length,
370
- failedIds: failing.map((result) => result.id),
434
+ const storePath = resolveEvidencePath(projectDir, env);
435
+ const endRead = readEvidence(storePath);
436
+ const endBroken = Boolean(endRead.readError) || (endRead.malformed ?? 0) > 0;
437
+ let integrityFailure = null;
438
+ if (endBroken) {
439
+ integrityFailure = 'the evidence store became unreadable under the final run';
440
+ } else {
441
+ const endHashes = {
442
+ redProof: sha256Hex(canonicalKindSerialization(endRead.records, 'red-proof')),
443
+ degrade: sha256Hex(canonicalKindSerialization(endRead.records, 'degrade')),
444
+ };
445
+ if (endHashes.redProof !== startEvidenceHashes.redProof || endHashes.degrade !== startEvidenceHashes.degrade) {
446
+ integrityFailure = 'the evidence store moved under the final run (the canonical red-proof/degrade serialization changed)';
447
+ }
448
+ }
449
+ // Exactly ONE full machine line binds the receipt — an unanchored first-match would let
450
+ // an injected/duplicated line shadow the real one and skip the end re-hash.
451
+ const shaLineRe = /^coverage-check: lcov-sha256=([0-9a-f]{64}|none)$/;
452
+ const shaLines = String(checkerRow?.stdout ?? '').split(/\r?\n/).filter((l) => shaLineRe.test(l));
453
+ const shaValue = shaLines.length === 1 ? shaLineRe.exec(shaLines[0])[1] : null;
454
+ const lcovSha256 = shaValue !== null && shaValue !== 'none' ? shaValue : null;
455
+ if (allGreen && integrityFailure === null) {
456
+ if (shaLines.length !== 1) {
457
+ integrityFailure = shaLines.length === 0
458
+ ? 'the coverage-check gate printed no lcov-sha256 line — the consumed lcov is unknowable (fail closed)'
459
+ : `the coverage-check gate printed ${shaLines.length} lcov-sha256 lines — exactly ONE full machine line binds the receipt`;
460
+ } else if (lcovSha256 !== null) {
461
+ let endSha = null;
462
+ try {
463
+ const st = lstatSync(join(gitDir, LCOV_BASENAME));
464
+ if (st.isFile()) endSha = sha256Hex(readFileSync(join(gitDir, LCOV_BASENAME)));
465
+ } catch { /* vanished → disagreement below */ }
466
+ if (endSha !== lcovSha256) integrityFailure = 'the lcov moved under the checker (the end re-hash differs from the checker-read bytes)';
467
+ }
468
+ }
469
+ const status = allGreen && integrityFailure === null ? 'green' : 'red';
470
+ appendEvidenceRecord({
471
+ path: storePath,
472
+ record: {
473
+ schema: EVIDENCE_SCHEMA_VERSION,
474
+ kind: 'final',
475
+ status,
476
+ attempt: finalAttempt,
477
+ fingerprintBefore: finalFingerprintBefore,
478
+ fingerprintAfter: fingerprint(projectDir),
479
+ declared: declaration.gates.map(({ id, cmd }) => ({ id, cmd })),
480
+ results: results.map(({ id, ok, code }) => ({ id, ok, code })),
481
+ evidenceHashes: endBroken
482
+ ? startEvidenceHashes
483
+ : {
484
+ redProof: sha256Hex(canonicalKindSerialization(endRead.records, 'red-proof')),
485
+ degrade: sha256Hex(canonicalKindSerialization(endRead.records, 'degrade')),
486
+ },
487
+ lcovSha256,
488
+ integrityFailure,
489
+ timestamp: new Date().toISOString(),
371
490
  },
372
- fingerprintBefore,
373
- fingerprintAfter: fingerprint(projectDir),
374
491
  });
375
- log(`[run-gates] gate-run recorded → ${writtenPath}`);
492
+ if (integrityFailure) {
493
+ finalError = new Error(integrityFailure);
494
+ logError(`[run-gates] --final integrity failure: ${integrityFailure} — the receipt is RED`);
495
+ }
496
+ log(`[run-gates] final receipt recorded (${status}) → ${storePath}`);
497
+ if (status === 'green' && lcovSha256 === null) {
498
+ logError(`[run-gates] --final consumed NO lcov — the coverage arm ran skipped-no-lcov; the receipt records lcovSha256:null (the declared unit-tests gate must produce <git-dir>/${LCOV_BASENAME})`);
499
+ }
376
500
  } catch (err) {
377
- recordError = err;
378
- logError(`[run-gates] --record failed: ${err.message}`);
501
+ finalError = err;
502
+ logError(`[run-gates] --final could not write its receipt: ${err.message}`);
379
503
  }
380
504
  }
381
505
  log(composeSummaryLine({ status: allGreen ? 'ok' : 'fail', results }));
382
- if (recordError) return EXIT.recordFailed;
506
+ if (finalError) return EXIT.finalFailed;
383
507
  return allGreen ? EXIT.ok : EXIT.fail;
384
508
  } catch (err) {
385
509
  logError(`[run-gates] ${err.message}`);
@@ -31,7 +31,7 @@
31
31
  // from the hidden-mode reconcile's block (hide-footprint.mjs — that lane noops on visible
32
32
  // deployments; this one serves them too). A malformed fence (start without end / duplicated
33
33
  // markers) fails CLOSED: loud report, file untouched. Patterns are root-anchored (`/rel`),
34
- // glob-metacharacters escaped, NUL-safe walk. Dependency-free, Node >= 18; no side effects on
34
+ // glob-metacharacters escaped, NUL-safe walk. Dependency-free, Node >= 22; no side effects on
35
35
  // import (the isDirectRun idiom).
36
36
 
37
37
  import { lstatSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
@@ -69,7 +69,7 @@ const listUntrackedUnfilteredZ = (root) => {
69
69
  // name can never shadow a deeper real path), glob metacharacters + leading-! / leading-# handled
70
70
  // by the anchor, trailing whitespace escaped (gitignore trims it otherwise).
71
71
  // Trailing SPACES are escapable in gitignore (`\ `); a trailing TAB is not expressible — such a
72
- // name is classified unrenderable by the derivation (codex R12), so it never reaches this renderer.
72
+ // name is classified unrenderable by the derivation, so it never reaches this renderer.
73
73
  export const toExcludePattern = (rel) => {
74
74
  const escaped = rel.replace(/([\\*?[\]])/g, '\\$1').replace(/ +$/, (m) => '\\ '.repeat(m.length));
75
75
  return `/${escaped}`;
@@ -79,7 +79,7 @@ export const toExcludePattern = (rel) => {
79
79
  // lstat class is never-committable (char/block/FIFO/socket). Everything else is refused by
80
80
  // construction — tracked paths never appear in an --others walk; regular/dir/symlink/missing fail
81
81
  // the predicate. A CR/LF-carrying mask NAME cannot be expressed as ONE gitignore rule — rendering
82
- // it would split into several rules and could hide unrelated committable paths (codex R3) — so it
82
+ // it would split into several rules and could hide unrelated committable paths — so it
83
83
  // is a LOUD unrenderable skip, never written (the review domain already ignores the mask itself).
84
84
  export const deriveMasks = ({ root, lstat = lstatSync, listUntracked = listUntrackedUnfilteredZ } = {}) => {
85
85
  const entries = listUntracked(root);
@@ -95,7 +95,7 @@ export const deriveMasks = ({ root, lstat = lstatSync, listUntracked = listUntra
95
95
  }
96
96
  if (!isNeverCommittableStat(stat)) continue;
97
97
  // CR/LF anywhere, or a trailing TAB (inexpressible in gitignore — only trailing SPACES escape
98
- // as `\ `; codex R12), make the name unrenderable as ONE exclude rule.
98
+ // as `\ `), make the name unrenderable as ONE exclude rule.
99
99
  if (/[\r\n]/.test(rel) || /\t$/.test(rel)) unrenderable.push(rel);
100
100
  else masks.push(rel);
101
101
  }
@@ -115,11 +115,11 @@ export const findMasksFence = (lines) => {
115
115
 
116
116
  // A fenced pattern back to its relative path (the derivation writes only `/rel` root-anchored
117
117
  // escaped forms; unescape is its exact inverse — the escaped-space form `\ ` included, so a
118
- // trailing-space mask round-trips; codex R1). Non-pattern lines (blank/comment) are skipped;
118
+ // trailing-space mask round-trips). Non-pattern lines (blank/comment) are skipped;
119
119
  // only UNESCAPED trailing whitespace is insignificant (gitignore semantics).
120
120
  const patternToRel = (line) => {
121
121
  // A trailing CR (a CRLF-saved exclude file) strips FIRST — it would otherwise ride into the rel
122
- // name and false-ENOENT the revalidation lstat (agy R5); then only UNESCAPED trailing
122
+ // name and false-ENOENT the revalidation lstat; then only UNESCAPED trailing
123
123
  // whitespace is insignificant (gitignore semantics — an escaped `\ ` survives).
124
124
  const t = line.replace(/\r$/, '').replace(/^[ \t]+/, '').replace(/(?<!\\)[ \t]+$/, '');
125
125
  if (t === '' || t.startsWith('#') || !t.startsWith('/')) return null;
@@ -156,13 +156,13 @@ export const probeSandboxMasks = ({ cwd = process.cwd(), lstat = lstatSync, list
156
156
  const commonDirRaw = gitLine(['rev-parse', '--git-common-dir'], cwd);
157
157
  if (gitPathRaw == null || commonDirRaw == null) return null;
158
158
  const excludeFile = resolve(cwd, gitPathRaw);
159
- // The write-containment root (codex R2): info/exclude lives in the COMMON git dir (worktree-safe),
159
+ // The write-containment root: info/exclude lives in the COMMON git dir (worktree-safe),
160
160
  // and the apply must never write through a symlinked component or a non-regular leaf.
161
161
  const gitCommonDir = resolve(cwd, commonDirRaw);
162
162
  const derived = deriveMasks({ root, lstat, listUntracked });
163
163
  if (derived == null) return null;
164
164
  const { masks, unrenderable } = derived;
165
- // The WHOLE chain is guarded BEFORE any read (codex R7+R8): a symlinked exclude leaf OR a
165
+ // The WHOLE chain is guarded BEFORE any read: a symlinked exclude leaf OR a
166
166
  // symlinked parent (.git/info) must never be read through, and a FIFO/socket leaf would HANG
167
167
  // the read-only probe — parent-chain containment + leaf lstat first, fail closed on everything
168
168
  // but a plain absent file, then read the existing REGULAR content. The apply re-checks
@@ -190,7 +190,7 @@ export const probeSandboxMasks = ({ cwd = process.cwd(), lstat = lstatSync, list
190
190
  content = readFile(excludeFile, 'utf8');
191
191
  } catch (err) {
192
192
  // The file existed a moment ago — ANY read failure now fails CLOSED (an apply over a misread
193
- // "empty" file would overwrite hand content outside the managed block; codex R4).
193
+ // "empty" file would overwrite hand content outside the managed block).
194
194
  throw fail(1, `cannot read ${excludeFile} (${err?.code ?? err?.message ?? err}) — refusing to treat an unreadable exclude file as empty`);
195
195
  }
196
196
  }
@@ -222,7 +222,7 @@ export const planApply = (probe, { clear = false } = {}) => {
222
222
  }
223
223
  // --clear takes PRECEDENCE over the derivation (codex, Segment-A receipt): it means "remove the
224
224
  // managed block", even while masks are visible — re-writing the block instead of the requested
225
- // clear would be a silent divergence. An EMPTY fence (markers, no entries) clears too (codex R8);
225
+ // clear would be a silent divergence. An EMPTY fence (markers, no entries) clears too;
226
226
  // no fence at all is a stated no-op.
227
227
  if (clear && probe.fence.state !== 'ok') {
228
228
  return { action: 'noop', reason: 'no managed block present — nothing to clear' };
@@ -236,7 +236,7 @@ export const planApply = (probe, { clear = false } = {}) => {
236
236
  }
237
237
  const masks = clear ? [] : probe.masks;
238
238
  const block = masks.length === 0 ? [] : [MASKS_FENCE_START, ...masks.map(toExcludePattern), MASKS_FENCE_END];
239
- // EVERY hand byte outside the managed fence is preserved EXACTLY (codex R5+R6): the first-apply
239
+ // EVERY hand byte outside the managed fence is preserved EXACTLY: the first-apply
240
240
  // append adds at most ONE separator newline, and the existing-block replace splices via RAW
241
241
  // string offsets around the fence lines — a split/join('\n') rebuild would silently normalize
242
242
  // CRLF hand content outside the block. Only the fenced block itself is ours (always LF).
@@ -325,18 +325,18 @@ export const main = (argv, ctx = {}) => {
325
325
  return { code: 0, stdout: formatProbe(probe), stderr: '' };
326
326
  }
327
327
  const plan = planApply(probe, { clear: argv.includes('--clear') });
328
- // The unrenderable skips stay LOUD on EVERY apply path (codex R4) — the same warning the
328
+ // The unrenderable skips stay LOUD on EVERY apply path — the same warning the
329
329
  // probe prints; the renderable subset still applies (a refusal would block the useful masks).
330
330
  const skipWarnings = probe.unrenderable
331
331
  .map((rel) => `sandbox-masks: ⚠ mask name carries a newline or ends with a tab and cannot be expressed as ONE exclude rule — NOT written (the review domain already ignores the mask itself): ${JSON.stringify(rel)}`)
332
332
  .join('\n');
333
- // A refusal carries the unrenderable warnings too (codex R5) — they are never discarded.
333
+ // A refusal carries the unrenderable warnings too — they are never discarded.
334
334
  if (plan.action === 'refuse') throw fail(1, skipWarnings ? `${plan.reason}\n${skipWarnings}` : plan.reason);
335
335
  if (plan.action === 'noop') return { code: 0, stdout: `sandbox-masks: ${plan.reason}`, stderr: skipWarnings };
336
336
  const writeFile = ctx.deps?.writeFile ?? writeFileSync;
337
337
  const mkdir = ctx.deps?.mkdir ?? mkdirSync;
338
338
  const lstat = ctx.deps?.lstat ?? lstatSync;
339
- // The write never follows a symlink or clobbers a non-regular leaf (codex R2): every component
339
+ // The write never follows a symlink or clobbers a non-regular leaf: every component
340
340
  // from the common git dir down is guarded, and an existing exclude leaf must be a regular file.
341
341
  try {
342
342
  assertContainedRealPath(probe.gitCommonDir, probe.excludeFile, { lstat });
@@ -7,7 +7,7 @@
7
7
  // freshness probe that cannot parse a version must degrade to "unknown" — never to a false ordering
8
8
  // claim in either direction (INV-B). No `let`: a small functional comparison (AGENTS.md §2.3).
9
9
  //
10
- // Pure, no imports, no side effects, Node >= 18.
10
+ // Pure, no imports, no side effects, Node >= 22.
11
11
 
12
12
  export const parseSemver = (str) => {
13
13
  const m = typeof str === 'string' ? str.trim().match(/^(\d+)\.(\d+)\.(\d+)/) : null;
@@ -19,7 +19,7 @@
19
19
  // (malformed/unreadable policy) or a write STOP (no deployment / symlinked leaf). main(argv, ctx) →
20
20
  // { code, stdout, stderr }; cwd / fs are injectable for host-independent tests.
21
21
  //
22
- // Dependency-free, Node >= 18. No side effects on import (the isDirectRun idiom).
22
+ // Dependency-free, Node >= 22. No side effects on import (the isDirectRun idiom).
23
23
 
24
24
  import { readFileSync, lstatSync } from 'node:fs';
25
25
  import { pathToFileURL } from 'node:url';
@@ -19,7 +19,7 @@
19
19
  // or a write STOP (no deployment / symlinked leaf). main(argv, ctx) → { code, stdout, stderr }; cwd /
20
20
  // env / home / detect / fs are injectable for host-independent tests.
21
21
  //
22
- // Dependency-free, Node >= 18. No side effects on import (the isDirectRun idiom).
22
+ // Dependency-free, Node >= 22. No side effects on import (the isDirectRun idiom).
23
23
 
24
24
  import { readFileSync, lstatSync } from 'node:fs';
25
25
  import { homedir } from 'node:os';
@@ -210,7 +210,7 @@ export const main = (argv, ctx = {}) => {
210
210
  const { writtenPath } = writeConfig(cwd, after, ctx);
211
211
  const fileBody = serializeConfig(after);
212
212
  // The echoed handover line carries the SAME autonomy levels recipes --active-line renders —
213
- // sync facts (no render-check needed for the cells; codex R1, Segment B). A malformed policy
213
+ // sync facts (no render-check needed for the cells; Segment B). A malformed policy
214
214
  // surfaces loudly through the line's own MALFORMED segment.
215
215
  const autonomyFacts = (() => {
216
216
  try {
@@ -21,7 +21,7 @@
21
21
  // wrappers are POSIX .sh — report unsupported / use WSL and mutate nothing. Every fs primitive is
22
22
  // injectable (deps.*) so the whole module is unit-testable without touching the real filesystem.
23
23
  //
24
- // Dependency-free, Node >= 18.
24
+ // Dependency-free, Node >= 22.
25
25
 
26
26
  import {
27
27
  existsSync, lstatSync, statSync, readdirSync, readlinkSync, symlinkSync, mkdirSync, copyFileSync,
package/tools/surface.mjs CHANGED
@@ -6,7 +6,7 @@
6
6
  // `/agent-workflow-kit status` surface always consumes `--json` and localizes itself.
7
7
  //
8
8
  // Pure + fully injectable (argv / env / isTTY / columns / platform are inputs), no side effects on
9
- // import, Node >= 18. Tested in isolation against the §4.5 table.
9
+ // import, Node >= 22. Tested in isolation against the §4.5 table.
10
10
 
11
11
  export const FORMATS = Object.freeze(['auto', 'plain', 'ansi', 'json']);
12
12
  export const FORMAT_ENV = 'AGENT_WORKFLOW_FORMAT';
@@ -19,7 +19,7 @@
19
19
  // untouched and reported, never removed.
20
20
  //
21
21
  // Pure planner (buildPlan) + guarded executor (executePlan), both dependency-injectable so the whole
22
- // module is unit-testable without the real filesystem. Dependency-free, Node >= 18. No side effects on
22
+ // module is unit-testable without the real filesystem. Dependency-free, Node >= 22. No side effects on
23
23
  // import (the isDirectRun idiom).
24
24
 
25
25
  import { existsSync, statSync, lstatSync, readlinkSync, readFileSync, readdirSync, realpathSync } from 'node:fs';