@sabaiway/agent-workflow-kit 1.39.0 → 1.41.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.
@@ -15,11 +15,18 @@
15
15
  // EXECUTE- / FEEDBACK-, or a name containing PROMPT / prompt / handoff); when the tree is
16
16
  // clean (nothing to review); when the cwd is not a git work tree (nothing to fingerprint);
17
17
  // and when EVERY recipe-named backend has a current-fingerprint receipt with acceptable
18
- // grounding (fresh:true, artifact "code", grounded:true).
19
- // exit 1 when a recipe-named backend has no current-fingerprint receipt — including the
20
- // stale-after-edit case (any tracked/untracked change after the review moves the
21
- // fingerprint) or when its only current receipts carry grounded:false (an ungrounded
22
- // agy review under reviewed/council never satisfies the gate).
18
+ // grounding (fresh:true, artifact "code", grounded:true) OR is degraded-exempt: the current
19
+ // plan-execution SEGMENT's latest review-ledger round records that backend degraded:true at
20
+ // the current tree fingerprint, with >= 1 non-degraded recipe-named backend present with a
21
+ // current grounded receipt and the ledger reading clean (AD-050; MIRRORS review-ledger
22
+ // decideStop's degraded handling presence, not unanimity, never a 0/0-counts gate).
23
+ // exit 1 when a recipe-named backend has no current-fingerprint receipt AND is not degraded-exempt —
24
+ // including the stale-after-edit case (any tracked/untracked change after the review moves the
25
+ // fingerprint) — or when its only current receipts carry grounded:false AND it is not
26
+ // degraded-exempt (an ungrounded agy review under reviewed/council never satisfies the gate on
27
+ // its own — but a recorded current-tree degrade still exempts it). An unreadable/malformed
28
+ // review-ledger DENIES the degraded exemption (fail-closed) but NEVER fails a tree whose
29
+ // receipts independently satisfy the gate (that stays exit 0, the ledger issue surfaced).
23
30
  // Informational receipts NEVER satisfy (nor fail) the tree check: plan/diff-mode receipts
24
31
  // (artifact ≠ "code") and continuations (fresh:false — agy --continue/--conversation cannot attest
25
32
  // a folded tree; only a fresh grounded re-run mints a gate-satisfying receipt).
@@ -46,12 +53,20 @@ import { createHash } from 'node:crypto';
46
53
  import { detectBackends } from './detect-backends.mjs';
47
54
  import { resolveActivityRecipe, planRecipe, DISPLAY_ALIASES } from './recipes.mjs';
48
55
  import { CONFIG_REL, fail, loadConfig } from './orchestration-config.mjs';
56
+ // The NEUTRAL ledger read-core (AD-050): review-state reads the review-ledger ONLY for the degraded
57
+ // exemption, through the neutral core — never review-ledger.mjs (which imports THIS module, the cycle).
58
+ import { resolveLedgerPath, resolveBase, readLedger, filterSegmentRecords, roundSequenceIntact } from './review-ledger-core.mjs';
49
59
 
50
60
  export const RECEIPTS_BASENAME = 'agent-workflow-review-receipts.jsonl';
51
61
  export const PLANS_REL = 'docs/plans';
52
62
  const ACTIVITY = 'plan-execution';
53
63
  const SLOT = 'review';
54
64
  const GIT_MAX_BUFFER = 256 * 1024 * 1024; // a full-tree diff can be large; never truncate silently
65
+ // --await (BUGFREE-3 / AD-049, item (d)) bounds + poll cadence. The default timeout is generous —
66
+ // a real grounded bridge review can take minutes — and every value is overridable (--timeout / the
67
+ // injectable clock) so hermetic tests never spend wall-clock.
68
+ export const DEFAULT_AWAIT_TIMEOUT_S = 900;
69
+ export const AWAIT_POLL_MS = 5000;
55
70
 
56
71
  // ── git plumbing (read-only queries; injectable for tests) ─────────────────────────
57
72
 
@@ -231,6 +246,45 @@ export const backendReceiptStatus = (receipts, backend, fingerprint) => {
231
246
  return { state: own.length > 0 ? 'stale' : 'missing', verdict: null, grounded: null, timestamp: null };
232
247
  };
233
248
 
249
+ // ── the degraded exemption (AD-050): read the review-ledger for a recorded current-tree degrade ────
250
+
251
+ // degradedExemptSet(args) → the Set of recipe-named backends EXEMPT from --check because the current
252
+ // segment's LATEST round records them degraded at the current tree fingerprint. It MIRRORS review-ledger
253
+ // decideStop's degraded handling: a backend WITHOUT a current grounded code receipt is exempt IFF
254
+ // (i) exactly one plan is in flight (else the loop is ambiguous — the exempt set is empty, NO fail-closed
255
+ // exit-1 arm) AND the ledger reads clean (a readError / malformed line DENIES the exemption, fail-closed);
256
+ // (ii) the segment (activity=plan-execution, loop, base=resolveBase) has >=1 round with an intact
257
+ // sequence; (iii) its LATEST round records THAT backend degraded; (iv) that round's fingerprint equals
258
+ // the CURRENT tree (the degrade attests THIS tree); (v) >=1 NON-degraded recipe-named backend is present
259
+ // with a current grounded receipt (never everyone degraded). It is VERDICT-BLIND — it mirrors only the
260
+ // PRESENCE half of decideStop (nonDegradedReq >= 1), never its 0/0 counts (Decision 7).
261
+ export const degradedExemptSet = ({ records, readError, malformed, base, plans, currentFingerprint, requiredBackends, backends }) => {
262
+ const empty = new Set();
263
+ if (plans.length !== 1) return empty; // (i) ambiguous loop → exemption suppressed (no fail-closed exit-1 arm)
264
+ if (readError || malformed > 0) return empty; // fail-closed: a corrupt ledger denies the exemption
265
+ if (currentFingerprint == null) return empty;
266
+ const loop = plans[0].replace(/\.md$/, '');
267
+ const rounds = filterSegmentRecords(records, { activity: ACTIVITY, loop, base }).filter((r) => r.kind === 'round');
268
+ if (rounds.length === 0) return empty; // (ii) empty segment → nothing recorded yet
269
+ if (!roundSequenceIntact(rounds)) return empty; // (ii) corrupt sequence → fail closed
270
+ const latest = rounds[rounds.length - 1];
271
+ if (latest.fingerprint !== currentFingerprint) return empty; // (iv) the degrade must attest THIS tree
272
+ // Mirror decideStop's PRESENCE discipline (review-ledger.mjs): EVERY recipe-named backend must be IN
273
+ // the latest round (allPresent) — a backend absent from the round reviewed nothing there, so a stray
274
+ // current receipt for a NON-recorded backend can never justify the exemption (codex R1: else a
275
+ // degrade-only round `[{agy degraded}]` + any current codex receipt would exempt agy, disagreeing
276
+ // with review-ledger, whose decideStop fails allPresent on the absent codex).
277
+ const entryFor = (rb) => latest.backends.find((b) => b.backend === rb);
278
+ if (!requiredBackends.every((rb) => entryFor(rb) !== undefined)) return empty;
279
+ const receiptCurrent = new Set(backends.filter((b) => b.state === 'current').map((b) => b.backend));
280
+ // (v) >=1 non-degraded recipe-named backend PRESENT in the latest round with a current grounded
281
+ // receipt — never all degraded (mirrors decideStop's nonDegradedReq >= 1, plus review-state's own
282
+ // "it really reviewed" = a current receipt).
283
+ if (!requiredBackends.some((rb) => { const e = entryFor(rb); return e && !e.degraded && receiptCurrent.has(rb); })) return empty;
284
+ // (iii) exempt each recipe-named backend the latest round records degraded.
285
+ return new Set(requiredBackends.filter((rb) => { const e = entryFor(rb); return e && e.degraded === true; }));
286
+ };
287
+
234
288
  // ── the check + report core ─────────────────────────────────────────────────────────
235
289
 
236
290
  // buildState({ cwd, env, detect }) → everything both renders need. Pure I/O at the edges.
@@ -257,6 +311,13 @@ export const buildState = ({ cwd, env = process.env, detect = detectBackends } =
257
311
  const receiptsPath = resolveReceiptsPath(cwd, env);
258
312
  const { receipts, malformed } = receiptsPath ? readReceipts(receiptsPath) : { receipts: [], malformed: 0 };
259
313
  const backends = requiredBackends.map((b) => ({ backend: b, ...backendReceiptStatus(receipts, b, fingerprint) }));
314
+ // The degraded exemption (AD-050): read the review-ledger ONLY here, ONLY for the exemption — the
315
+ // whole gate never depends on the ledger (a corrupt ledger fails the exemption CLOSED, never a tree
316
+ // whose receipts independently satisfy the gate; Decision 3). base/ledger locate the current segment.
317
+ const base = resolveBase(cwd);
318
+ const ledgerPath = resolveLedgerPath(cwd, env);
319
+ const { records, malformed: ledgerMalformed, readError: ledgerReadError } = ledgerPath ? readLedger(ledgerPath) : { records: [], malformed: 0 };
320
+ const degradedExempt = [...degradedExemptSet({ records, readError: ledgerReadError, malformed: ledgerMalformed, base, plans, currentFingerprint: fingerprint, requiredBackends, backends })];
260
321
  return {
261
322
  resolved,
262
323
  configSource,
@@ -268,6 +329,11 @@ export const buildState = ({ cwd, env = process.env, detect = detectBackends } =
268
329
  receiptsPath,
269
330
  receiptCount: receipts.length,
270
331
  malformed,
332
+ base,
333
+ ledgerPath,
334
+ ledgerMalformed: ledgerMalformed ?? 0,
335
+ ledgerReadError: ledgerReadError ?? null,
336
+ degradedExempt,
271
337
  detectionWarning,
272
338
  };
273
339
  };
@@ -297,16 +363,31 @@ export const decideCheck = (state) => {
297
363
  // cannot fail the gate by itself (a forged/corrupt line must not brick commits), but the check
298
364
  // line always names it so a PASS over a partially-corrupt file is visible.
299
365
  const malformedNote = state.malformed > 0 ? ` — ${state.malformed} malformed receipt line(s) ignored; inspect ${state.receiptsPath}` : '';
300
- const failing = state.backends.filter((b) => b.state !== 'current');
366
+ // The review-ledger is consulted ONLY for the degraded exemption; a corrupt ledger DENIES it
367
+ // (fail-closed) but never fails a tree the receipts independently satisfy — surfaced either way
368
+ // (No-silent-failures; Decision 3).
369
+ const ledgerNote = state.ledgerReadError
370
+ ? ` — review ledger unreadable (${state.ledgerReadError}); degraded exemption unavailable (fail-closed) — inspect ${state.ledgerPath}`
371
+ : state.ledgerMalformed > 0
372
+ ? ` — review ledger has ${state.ledgerMalformed} malformed line(s); degraded exemption unavailable (fail-closed) — inspect ${state.ledgerPath}`
373
+ : '';
374
+ // The degraded exemption (AD-050): a backend recorded degraded for the current tree is excluded from
375
+ // `failing` (it reviewed nothing to receipt — MIRRORS decideStop excluding a degraded backend). It
376
+ // stays verdict-blind: the exemption proves the degrade was RECORDED, never that the tree converged.
377
+ const exempt = new Set(state.degradedExempt);
378
+ const failing = state.backends.filter((b) => b.state !== 'current' && !exempt.has(b.backend));
301
379
  if (failing.length === 0) {
302
- return { code: 0, reason: `every recipe-named backend has a fresh grounded receipt for the current tree (${state.requiredBackends.join(' + ')})${malformedNote}` };
380
+ if (exempt.size === 0) {
381
+ return { code: 0, reason: `every recipe-named backend has a fresh grounded receipt for the current tree (${state.requiredBackends.join(' + ')})${malformedNote}${ledgerNote}` };
382
+ }
383
+ return { code: 0, reason: `every recipe-named backend reviewed the current tree (${state.requiredBackends.join(' + ')}) — degraded-exempt (recorded degraded for the current tree in the review ledger): ${[...exempt].join(', ')}${malformedNote}${ledgerNote}` };
303
384
  }
304
385
  const parts = failing.map((b) => {
305
386
  if (b.state === 'ungrounded') return `${b.backend}: only ungrounded receipts for the current tree — re-run grounded (--facts)`;
306
387
  if (b.state === 'stale') return `${b.backend}: receipts exist but none matches the current tree (edited after review) — run a fresh review`;
307
388
  return `${b.backend}: no receipt — run its review wrapper`;
308
389
  });
309
- return { code: 1, reason: `${parts.join('; ')}${malformedNote}` };
390
+ return { code: 1, reason: `${parts.join('; ')}${malformedNote}${ledgerNote}` };
310
391
  };
311
392
 
312
393
  // ── rendering ───────────────────────────────────────────────────────────────────────
@@ -324,7 +405,11 @@ const formatHuman = (state, check) => {
324
405
  else if (state.clean === true) lines.push(' tree: clean (nothing to review)');
325
406
  else lines.push(` tree fingerprint: ${state.fingerprint}`);
326
407
  lines.push(` receipts: ${state.receiptsPath ?? '(unresolvable — no git dir)'} (${state.receiptCount} line(s)${state.malformed ? `, ${state.malformed} malformed — inspect the file` : ''})`);
408
+ if (state.ledgerReadError) lines.push(` ⚠ review ledger unreadable (${state.ledgerReadError}) — degraded exemption unavailable`);
409
+ else if (state.ledgerMalformed) lines.push(` ⚠ review ledger: ${state.ledgerMalformed} malformed line(s) — degraded exemption unavailable`);
410
+ const exempt = new Set(state.degradedExempt);
327
411
  for (const b of state.backends) {
412
+ const exemptTag = exempt.has(b.backend) ? ' — degraded-exempt (recorded degraded in the review ledger for the current tree)' : '';
328
413
  const detail =
329
414
  b.state === 'current'
330
415
  ? `current (verdict: ${b.verdict}, grounded, ${b.timestamp ?? '?'})`
@@ -333,7 +418,7 @@ const formatHuman = (state, check) => {
333
418
  : b.state === 'stale'
334
419
  ? 'stale — no receipt matches the current tree (edited after review)'
335
420
  : 'missing — no receipt from this backend';
336
- lines.push(` ${STATE_GLYPH[b.state]} ${b.backend}: ${detail}`);
421
+ lines.push(` ${exempt.has(b.backend) ? '⊘' : STATE_GLYPH[b.state]} ${b.backend}: ${detail}${exemptTag}`);
337
422
  }
338
423
  lines.push(` check: ${check.code === 0 ? 'PASS' : 'FAIL'} — ${check.reason}`);
339
424
  return lines.join('\n');
@@ -352,9 +437,10 @@ presence + verdict + grounding for the CURRENT tree. Plan/diff-mode receipts and
352
437
  (fresh:false) are informational-only — they never satisfy the tree check.
353
438
 
354
439
  --check exits 0/1 per the normative contract in the tool header: 0 for solo / no plan in flight /
355
- a clean tree / not-a-git-tree / all recipe-named backends receipted-current-and-grounded; 1 when a
356
- recipe-named backend is missing, stale (edited after review), or grounded:false under
357
- reviewed/council. Declare it as a project gate by hand (docs/ai/gates.json) or via the
440
+ a clean tree / not-a-git-tree / all recipe-named backends receipted-current-and-grounded OR
441
+ degraded-exempt (a recorded current-tree degrade in the review-ledger for that backend; AD-050); 1 when
442
+ a recipe-named backend is missing, stale (edited after review), or grounded:false under reviewed/council
443
+ AND is not degraded-exempt. Declare it as a project gate by hand (docs/ai/gates.json) or via the
358
444
  explicit-consent seeder (tools/seed-gates.mjs) — never without consent.
359
445
 
360
446
  Read-only: never writes, never commits, never runs a subscription CLI; spawns read-only git queries.
@@ -387,11 +473,74 @@ export const main = (argv, ctx = {}) => {
387
473
  }
388
474
  };
389
475
 
390
- const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
391
- if (isDirectRun) {
392
- const r = main(process.argv.slice(2));
476
+ // ── --await: block until every recipe-named backend has receipted the current tree ─────
477
+ // (BUGFREE-3 / AD-049, item (d)). It waits for every recipe-named backend to be SATISFIED — a fresh
478
+ // grounded current-tree receipt, OR (AD-050) the degraded exemption: once a current-tree degrade is
479
+ // RECORDED in the review-ledger, --await stops waiting for that backend and returns READY (before,
480
+ // it waited forever for a receipt that never comes). It inherits the exemption for FREE — it polls
481
+ // the SAME decideCheck(buildState()) `--check` computes. The completion signal is the RECEIPT (i.e.
482
+ // `--check` would PASS), NEVER a process event — a harness "completed" notification fires early and a
483
+ // bridge's output late-flushes, so polling a pid/receipt-file is the durable mechanization of
484
+ // receipts-not-pgrep. Stays read-only (it only re-reads state — now the ledger too, a few KB per
485
+ // tick); the clock is injectable (ctx.now / ctx.sleep / ctx.pollMs) so hermetic tests never spend
486
+ // wall-clock.
487
+
488
+ const AWAIT_ALLOWED_ARGS = new Set(['--await', '--timeout']);
489
+
490
+ const parseAwaitTimeoutS = (argv) => {
491
+ const i = argv.indexOf('--timeout');
492
+ if (i === -1) return DEFAULT_AWAIT_TIMEOUT_S;
493
+ const raw = argv[i + 1];
494
+ if (!raw || !/^\d+$/.test(raw) || Number(raw) < 1) throw fail(2, '--timeout requires a positive integer number of seconds');
495
+ return Number(raw);
496
+ };
497
+
498
+ export const mainAwait = async (argv, ctx = {}) => {
499
+ const cwd = ctx.cwd ?? process.cwd();
500
+ const env = ctx.env ?? process.env;
501
+ const detect = ctx.detect ?? detectBackends;
502
+ const now = ctx.now ?? (() => Date.now());
503
+ const sleep = ctx.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
504
+ const pollMs = ctx.pollMs ?? AWAIT_POLL_MS;
505
+ try {
506
+ for (let i = 0; i < argv.length; i += 1) {
507
+ const a = argv[i];
508
+ if (a === '--timeout') { i += 1; continue; } // its value is consumed by parseAwaitTimeoutS
509
+ if (!AWAIT_ALLOWED_ARGS.has(a)) throw fail(2, `--await accepts only --timeout <s> (got ${a})`);
510
+ }
511
+ const timeoutS = parseAwaitTimeoutS(argv);
512
+ const timeoutMs = timeoutS * 1000;
513
+ const start = now();
514
+ // Poll the SAME normative decision --check computes: ready == `--check` would pass (solo / no
515
+ // plan / a clean tree / not-a-git-tree all resolve instantly — nothing to await). Re-read state
516
+ // every poll so a landed receipt (or a tree edit that re-staled one) is seen fresh. The DEADLINE
517
+ // is checked BEFORE readiness (codex council R2): once elapsed reaches the timeout the await is
518
+ // over, so a receipt that only lands AT/after the deadline never flips it to READY; and each
519
+ // sleep is BOUNDED to the remaining time so a full poll interval can never overshoot the timeout.
520
+ let lastReason = 'no poll completed before the deadline';
521
+ for (;;) {
522
+ const elapsed = now() - start;
523
+ if (elapsed >= timeoutMs) return { code: 1, stdout: '', stderr: `review-state --await: TIMEOUT after ${timeoutS}s — ${lastReason}` };
524
+ const check = decideCheck(buildState({ cwd, env, detect }));
525
+ lastReason = check.reason;
526
+ if (check.code === 0) return { code: 0, stdout: `review-state --await: READY — ${check.reason}`, stderr: '' };
527
+ await sleep(Math.min(pollMs, timeoutMs - elapsed));
528
+ }
529
+ } catch (err) {
530
+ return { code: err.exitCode ?? 1, stdout: '', stderr: `review-state: ${err.message}` };
531
+ }
532
+ };
533
+
534
+ const emitResult = (r) => {
393
535
  // Exact writes + a natural exit: process.exit() can truncate unflushed piped stdio (codex R2).
394
536
  if (r.stdout) process.stdout.write(r.stdout.endsWith('\n') ? r.stdout : `${r.stdout}\n`);
395
537
  if (r.stderr) process.stderr.write(r.stderr.endsWith('\n') ? r.stderr : `${r.stderr}\n`);
396
538
  process.exitCode = r.code;
539
+ };
540
+
541
+ const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
542
+ if (isDirectRun) {
543
+ const argv = process.argv.slice(2);
544
+ if (argv.includes('--await')) mainAwait(argv).then(emitResult);
545
+ else emitResult(main(argv));
397
546
  }
@@ -37,6 +37,13 @@ import { spawnSync } from 'node:child_process';
37
37
  import { pathToFileURL } from 'node:url';
38
38
  import { computeTreeFingerprint } from './review-state.mjs';
39
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';
40
47
 
41
48
  // The per-project declaration (strict JSON, hand-editable). cwd-relative — errors show a path the
42
49
  // user can open (the orchestration-config CONFIG_REL idiom).
@@ -74,6 +81,9 @@ const USAGE = [
74
81
  '--record additionally mints ONE v4 gate-run record into the review ledger via its sole writer',
75
82
  '(the D5 green-baseline receipt; needs a single in-flight plan): the full declaration + what ran',
76
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.',
77
87
  `Exit codes: 0 ok · 1 gate failure · 2 usage · 3 missing declaration · 4 empty gates list ·`,
78
88
  '5 malformed/invalid declaration · 6 bash unavailable · 7 --record asked but the record failed.',
79
89
  ].join('\n');
@@ -174,8 +184,16 @@ export const selectGates = (gates, onlyIds) => {
174
184
  // Spawn one gate cmd via bash from the project root. `cmd` is a BASH command line by contract
175
185
  // (the declaration's _README states it): this repo's own gate matrix needs brace+glob expansion,
176
186
  // which /bin/sh does not perform — hence bash explicitly, never the platform default shell.
177
- export const spawnGateViaBash = (cmd, cwd) =>
178
- spawnSync('bash', ['-c', cmd], { cwd, encoding: 'utf8', maxBuffer: MAX_GATE_OUTPUT_BYTES });
187
+ // NODE_TEST_CONTEXT is stripped (mirroring the fold suite's childTestEnv): a `node --test` gate spawned
188
+ // while run-gates is itself running under a parent test context would otherwise inherit it, hit Node's
189
+ // recursive-run guard, silently skip every file, and exit 0 — a vacuous false green. Stripping it also
190
+ // makes the plain gate env-equivalent to the fold suite, so the (a) suite-run credit's exit-0 truthfully
191
+ // predicts a plain-spawn exit-0 (the one remaining, documented residual is NODE_V8_COVERAGE).
192
+ export const spawnGateViaBash = (cmd, cwd) => {
193
+ const env = { ...process.env };
194
+ delete env.NODE_TEST_CONTEXT;
195
+ return spawnSync('bash', ['-c', cmd], { cwd, env, encoding: 'utf8', maxBuffer: MAX_GATE_OUTPUT_BYTES });
196
+ };
179
197
 
180
198
  // The command the bash preflight runs before ANY gate: proves bash itself spawns on this host,
181
199
  // so "no bash" is one loud exit-6 error up front — never a per-gate spawn-failure cascade.
@@ -188,10 +206,19 @@ const trimTrailingNewline = (text) => text.replace(/\n$/, '');
188
206
 
189
207
  // Run the selected gates sequentially (declaration order). A green gate logs one PASS line; a
190
208
  // failing gate logs FAIL + its captured stdout/stderr VERBATIM (triage without re-running).
191
- export const runGates = (gates, { cwd, spawn = spawnGateViaBash, now = Date.now, log = console.log }) => {
209
+ export const runGates = (gates, { cwd, spawn = spawnGateViaBash, now = Date.now, log = console.log, credit = null }) => {
192
210
  const results = [];
193
211
  for (const gate of gates) {
194
212
  log(`── ${gate.id} — ${gate.title}`);
213
+ // (a) the one-suite-run credit: for the unit-tests gate, if the fold runner already ran this exact
214
+ // command green at the current tree, CREDIT it instead of re-spawning (no quality loss — same
215
+ // execution, same tree, recorded once). Any mismatch → credit is null → the normal spawn below.
216
+ const credited = credit ? credit(gate) : null;
217
+ if (credited) {
218
+ log(' PASS (credited from the fold-completeness suite run — no re-spawn)');
219
+ results.push({ id: gate.id, title: gate.title, ok: true, code: 0, elapsedMs: 0, credited: true });
220
+ continue;
221
+ }
195
222
  const startedAt = now();
196
223
  const res = spawn(gate.cmd, cwd);
197
224
  const elapsedMs = now() - startedAt;
@@ -271,6 +298,7 @@ export const runCli = (argv, deps = {}) => {
271
298
  now,
272
299
  record = recordGateRun,
273
300
  fingerprint = computeTreeFingerprint,
301
+ foldCredit = foldSuiteCredit,
274
302
  } = deps;
275
303
  try {
276
304
  const opts = parseArgs(argv);
@@ -305,7 +333,18 @@ export const runCli = (argv, deps = {}) => {
305
333
  return EXIT.noBash;
306
334
  }
307
335
  const fingerprintBefore = opts.record ? fingerprint(projectDir) : null;
308
- const results = runGates(selected, { cwd: projectDir, spawn, log, now });
336
+ // (a) the one-suite-run credit ONLY in --record mode (the fold-loop flow), ONLY the unit-tests
337
+ // gate, ONLY when it is the FIRST selected gate (a gate that ran BEFORE it could have side-effected
338
+ // an ignored/out-of-tree artifact unit-tests depends on WITHOUT moving the fingerprint, so a
339
+ // later-positioned unit-tests must re-spawn — never credit a state the real matrix might fail), and
340
+ // ONLY when the fold evidence is fingerprint-bound + exit-0 (foldCredit) AND its recorded cmd EQUALS
341
+ // this gate's cmd (the --only-subset defense). Any mismatch → credit is null → the normal spawn.
342
+ let credit = null;
343
+ if (opts.record && selected[0]?.id === UNIT_TESTS_GATE_ID) {
344
+ const fold = foldCredit({ cwd: projectDir, env, fingerprint: fingerprintBefore });
345
+ credit = (gate) => (gate.id === UNIT_TESTS_GATE_ID && fold.credited && fold.evidence.cmd === gate.cmd ? fold : null);
346
+ }
347
+ const results = runGates(selected, { cwd: projectDir, spawn, log, now, credit });
309
348
  for (const line of formatTable(results)) log(line);
310
349
  const allGreen = results.every((result) => result.ok);
311
350
  // The gate-run record (D5): minted for green AND red runs alike (an honest red is telemetry
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env node
2
+ // sarif.mjs — a dependency-free SARIF (2.1.0) reader for the OPTIONAL advisory findings surface
3
+ // (BUGFREE-3, AD-049, step 1.4 — the reviewdog pattern). Findings are ADVISORY ONLY: they are NEVER
4
+ // recorded on a fold run record (SARIF stays entirely out of the fold result schema — no v4 quartet
5
+ // coupling, no sequencing dependency) and NEVER gate-blocking (fold-completeness --check never reads
6
+ // SARIF). A malformed SARIF fails the advisory READ loudly (a nonzero exit on the advisory verb), but
7
+ // the fold gate — fold-completeness --check — is unaffected. No side effects on import; Node >= 18.
8
+
9
+ export const SARIF_STOP = 'SARIF_STOP';
10
+ const stop = (message) => Object.assign(new Error(`[agent-workflow-kit] ${message}`), { name: 'SarifStop', code: SARIF_STOP });
11
+
12
+ // parseSarif(text) → { findings: [{ ruleId, level, message, file, line }] }. THROWS on a malformed
13
+ // SARIF (not JSON, or no runs[] array) — the advisory read is LOUD, never a silent empty result. A
14
+ // well-formed run with zero results yields an empty findings list (a clean advisory).
15
+ export const parseSarif = (text) => {
16
+ let doc;
17
+ try {
18
+ doc = JSON.parse(text);
19
+ } catch (err) {
20
+ throw stop(`SARIF is not valid JSON (${err.message})`);
21
+ }
22
+ if (!doc || typeof doc !== 'object' || !Array.isArray(doc.runs)) {
23
+ throw stop('SARIF has no runs[] array — not a SARIF 2.1.0 document');
24
+ }
25
+ const findings = [];
26
+ for (const run of doc.runs) {
27
+ const results = Array.isArray(run?.results) ? run.results : [];
28
+ for (const r of results) {
29
+ const loc = r?.locations?.[0]?.physicalLocation ?? {};
30
+ findings.push({
31
+ ruleId: typeof r?.ruleId === 'string' ? r.ruleId : '(no rule)',
32
+ level: typeof r?.level === 'string' ? r.level : 'warning',
33
+ message: typeof r?.message?.text === 'string' ? r.message.text : '',
34
+ file: typeof loc?.artifactLocation?.uri === 'string' ? loc.artifactLocation.uri : null,
35
+ line: Number.isInteger(loc?.region?.startLine) ? loc.region.startLine : null,
36
+ });
37
+ }
38
+ }
39
+ return { findings };
40
+ };
41
+
42
+ // renderSarifFindings(findings) → a human advisory block (one line per finding). Empty → a stated
43
+ // "no findings" note. Always advisory — the caller never blocks a gate on this.
44
+ export const renderSarifFindings = (findings) => {
45
+ if (findings.length === 0) return 'SARIF advisory: no findings (advisory only — never gate-blocking).';
46
+ const lines = [`SARIF advisory: ${findings.length} finding(s) (advisory only — never gate-blocking):`];
47
+ for (const f of findings) {
48
+ const at = f.file ? `${f.file}${f.line != null ? `:${f.line}` : ''}` : '(no location)';
49
+ lines.push(` [${f.level}] ${f.ruleId} — ${at}${f.message ? ` — ${f.message}` : ''}`);
50
+ }
51
+ return lines.join('\n');
52
+ };
@@ -0,0 +1,219 @@
1
+ #!/usr/bin/env node
2
+ // verification-profile.mjs — read-only schema/read core for the per-project VERIFICATION PROFILE
3
+ // (docs/ai/verification-profile.json), the language-independence contract (BUGFREE-3, AD-049).
4
+ // Companion read-core to the fold-completeness runner. NO writer — the runner is the sole
5
+ // tree-toucher. An ABSENT profile reproduces today's exact behaviour (V8 coverage + node:test
6
+ // TAP-on-stdout); the profile only generalizes the coverage SOURCE, the single-test RESULT FORMAT,
7
+ // and an optional SARIF path — coverage/probe INPUTS, never the suite command.
8
+ //
9
+ // The suite COMMAND is deliberately NOT a profile field: it stays the docs/ai/gates.json unit-tests
10
+ // cmd so the fold run and the unit-tests gate share command-identity (the (a) suite-evidence credit
11
+ // requires the SAME command).
12
+ //
13
+ // Path safety (Decision 4; grounding.mjs assertScratchDestination model): every profile-declared
14
+ // artifact path MUST be gitignored or outside the work tree, checked on the REALPATH (a symlink leaf
15
+ // is refused). An in-tree, not-ignored file the suite writes would move the review fingerprint the
16
+ // run binds to; a symlinked path could route the write onto a tracked file. validateProfile fails
17
+ // CLOSED on any such path.
18
+ //
19
+ // Dependency-free, Node >= 18. No side effects on import.
20
+
21
+ import { readFileSync, lstatSync, realpathSync } from 'node:fs';
22
+ import { join, resolve, relative, isAbsolute, dirname, basename } from 'node:path';
23
+ import { spawnSync } from 'node:child_process';
24
+ import { fail } from './orchestration-config.mjs';
25
+
26
+ // cwd-relative so error prefixes show a path the user can open, never an absolute temp/host path.
27
+ export const PROFILE_REL = 'docs/ai/verification-profile.json';
28
+ export const PROFILE_SCHEMA_VERSION = 1;
29
+
30
+ export const COVERAGE_KINDS = new Set(['v8', 'lcov']);
31
+ export const RESULT_FORMATS = new Set(['tap-stdout', 'tap-file', 'junit-xml']);
32
+ // File-based formats must be TOLD where to write, so their argv carries {resultPath} (the runner
33
+ // substitutes a fresh out-of-tree path per probe and reads THAT back). tap-stdout reads stdout.
34
+ export const FILE_BASED_FORMATS = new Set(['tap-file', 'junit-xml']);
35
+ export const RESULT_PATH_TOKEN = '{resultPath}';
36
+ export const FILE_TOKEN = '{file}';
37
+ export const PATTERN_TOKEN = '{pattern}';
38
+
39
+ const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
40
+ const isNonEmptyString = (v) => typeof v === 'string' && v.length > 0;
41
+ const isNonEmptyStringArray = (v) => Array.isArray(v) && v.length > 0 && v.every((x) => typeof x === 'string');
42
+
43
+ // Unknown-key-fails-closed. Returns a reason string or null.
44
+ const unknownKeyReason = (obj, allowed, where) => {
45
+ for (const k of Object.keys(obj)) {
46
+ if (!allowed.has(k)) return `${where}: unknown key "${k}" (allowed: ${[...allowed].join(', ')})`;
47
+ }
48
+ return null;
49
+ };
50
+
51
+ const COVERAGE_KEYS = new Set(['kind', 'lcovPath']);
52
+ const SINGLE_TEST_KEYS = new Set(['argv', 'resultFormat']);
53
+ const FINDINGS_KEYS = new Set(['sarifPath']);
54
+ const TOP_KEYS = new Set(['_README', 'schema', 'coverage', 'singleTest', 'findings']);
55
+
56
+ const defaultGitLine = (args, cwd) => {
57
+ const r = spawnSync('git', args, { cwd, encoding: 'utf8', windowsHide: true });
58
+ return r.error || r.status == null ? null : { status: r.status, stdout: r.stdout ?? '' };
59
+ };
60
+
61
+ // Reason string when the declared path is unsafe, else null. Unsafe = a symlink leaf, or an
62
+ // in-work-tree path that is NOT gitignored (writing there moves the review fingerprint; a tracked
63
+ // path reads as not-ignored too). Checked on the REAL destination (parent realpath-resolved) so a
64
+ // symlinked parent cannot route an "outside/ignored" path back onto a tracked file. Outside any git
65
+ // tree → safe. deps.{gitLine,lstat,realpath} are injectable for hermetic tests.
66
+ export const declaredPathUnsafeReason = (label, p, cwd, deps = {}) => {
67
+ const gitLine = deps.gitLine ?? defaultGitLine;
68
+ const lstat = deps.lstat ?? lstatSync;
69
+ const realpath = deps.realpath ?? realpathSync;
70
+ if (!isNonEmptyString(p)) return `${label} must be a non-empty string`;
71
+ const lexical = isAbsolute(p) ? p : resolve(cwd, p);
72
+ let leaf = null;
73
+ try {
74
+ leaf = lstat(lexical);
75
+ } catch {
76
+ leaf = null; // absent → a fresh file; the parent is realpath-checked below
77
+ }
78
+ if (leaf && leaf.isSymbolicLink()) {
79
+ return `${label} "${p}" is a symlink — refused (the write would follow it onto another file; name the real path)`;
80
+ }
81
+ let realParent;
82
+ try {
83
+ realParent = realpath(dirname(lexical));
84
+ } catch {
85
+ return `${label} "${p}" parent directory does not exist — create the (gitignored) output dir first, or fix the path`;
86
+ }
87
+ const full = join(realParent, basename(lexical));
88
+ const top = gitLine(['rev-parse', '--show-toplevel'], cwd);
89
+ if (top == null || top.status !== 0) return null; // not a git work tree → not fingerprint-relevant
90
+ // Realpath the repo root too so both sides of relative() are physical — a checkout opened via a
91
+ // symlink must not misclassify an in-tree path as OUTSIDE. Fail CLOSED if it can't resolve.
92
+ let root;
93
+ try {
94
+ root = realpath(top.stdout.replace(/\r?\n$/, ''));
95
+ } catch {
96
+ return `${label} "${p}" — cannot resolve the repo root's real path (fail closed)`;
97
+ }
98
+ const rel = relative(root, full);
99
+ if (rel.startsWith('..') || isAbsolute(rel)) return null; // outside the work tree → safe
100
+ const ignored = gitLine(['check-ignore', '-q', '--', rel], root);
101
+ if (ignored == null || ignored.status !== 0) {
102
+ return `${label} "${p}" is an in-tree path that is not gitignored — the suite writing there would move the review fingerprint the run binds to; gitignore it (or place it outside the repo)`;
103
+ }
104
+ return null;
105
+ };
106
+
107
+ // validateProfile(obj, { cwd, gitLine, lstat, realpath }?) → { ok, reason }. Strict schema first;
108
+ // then, ONLY when `cwd` is supplied, the declared-path safety check (Decision 4). A pure-schema unit
109
+ // test omits `cwd`; loadProfile passes it so malformed OR unsafe both fail closed.
110
+ export const validateProfile = (obj, ctx = {}) => {
111
+ if (!isPlainObject(obj)) return { ok: false, reason: `${PROFILE_REL}: must be a JSON object` };
112
+ const topUnknown = unknownKeyReason(obj, TOP_KEYS, PROFILE_REL);
113
+ if (topUnknown) return { ok: false, reason: topUnknown };
114
+ if (obj.schema !== PROFILE_SCHEMA_VERSION) return { ok: false, reason: `${PROFILE_REL}: schema must be ${PROFILE_SCHEMA_VERSION}` };
115
+ if (obj._README !== undefined && typeof obj._README !== 'string') return { ok: false, reason: `${PROFILE_REL}: "_README" must be a string` };
116
+
117
+ // coverage (optional; absent → V8). lcovPath REQUIRED iff lcov, forbidden otherwise.
118
+ if (obj.coverage !== undefined) {
119
+ if (!isPlainObject(obj.coverage)) return { ok: false, reason: `${PROFILE_REL}: coverage must be an object` };
120
+ const ck = unknownKeyReason(obj.coverage, COVERAGE_KEYS, `${PROFILE_REL}: coverage`);
121
+ if (ck) return { ok: false, reason: ck };
122
+ if (!COVERAGE_KINDS.has(obj.coverage.kind)) return { ok: false, reason: `${PROFILE_REL}: coverage.kind must be one of ${[...COVERAGE_KINDS].join(', ')}` };
123
+ if (obj.coverage.kind === 'lcov') {
124
+ if (!isNonEmptyString(obj.coverage.lcovPath)) return { ok: false, reason: `${PROFILE_REL}: coverage.lcovPath (a non-empty path) is required when coverage.kind is "lcov"` };
125
+ } else if (obj.coverage.lcovPath !== undefined) {
126
+ return { ok: false, reason: `${PROFILE_REL}: coverage.lcovPath is only valid when coverage.kind is "lcov"` };
127
+ }
128
+ }
129
+
130
+ // singleTest (optional; absent → default node:test argv + tap-stdout). argv must carry {file} +
131
+ // {pattern}; a file-based resultFormat additionally requires {resultPath}.
132
+ if (obj.singleTest !== undefined) {
133
+ if (!isPlainObject(obj.singleTest)) return { ok: false, reason: `${PROFILE_REL}: singleTest must be an object` };
134
+ const sk = unknownKeyReason(obj.singleTest, SINGLE_TEST_KEYS, `${PROFILE_REL}: singleTest`);
135
+ if (sk) return { ok: false, reason: sk };
136
+ if (!isNonEmptyStringArray(obj.singleTest.argv)) return { ok: false, reason: `${PROFILE_REL}: singleTest.argv must be a non-empty array of strings` };
137
+ const argvJoined = obj.singleTest.argv.join(' ');
138
+ if (!argvJoined.includes(FILE_TOKEN)) return { ok: false, reason: `${PROFILE_REL}: singleTest.argv must carry a ${FILE_TOKEN} placeholder (the runner substitutes the test file)` };
139
+ if (!argvJoined.includes(PATTERN_TOKEN)) return { ok: false, reason: `${PROFILE_REL}: singleTest.argv must carry a ${PATTERN_TOKEN} placeholder (the runner substitutes the test-name pattern)` };
140
+ const rf = obj.singleTest.resultFormat;
141
+ if (rf !== undefined && !RESULT_FORMATS.has(rf)) return { ok: false, reason: `${PROFILE_REL}: singleTest.resultFormat must be one of ${[...RESULT_FORMATS].join(', ')}` };
142
+ if (FILE_BASED_FORMATS.has(rf) && !argvJoined.includes(RESULT_PATH_TOKEN)) {
143
+ return { ok: false, reason: `${PROFILE_REL}: singleTest.resultFormat "${rf}" is file-based — singleTest.argv must carry a ${RESULT_PATH_TOKEN} placeholder (the runner substitutes a fresh out-of-tree result path per probe)` };
144
+ }
145
+ }
146
+
147
+ // findings (optional; an empty object is valid — no SARIF path declared).
148
+ if (obj.findings !== undefined) {
149
+ if (!isPlainObject(obj.findings)) return { ok: false, reason: `${PROFILE_REL}: findings must be an object` };
150
+ const fk = unknownKeyReason(obj.findings, FINDINGS_KEYS, `${PROFILE_REL}: findings`);
151
+ if (fk) return { ok: false, reason: fk };
152
+ if (obj.findings.sarifPath !== undefined && !isNonEmptyString(obj.findings.sarifPath)) {
153
+ return { ok: false, reason: `${PROFILE_REL}: findings.sarifPath must be a non-empty string when present` };
154
+ }
155
+ }
156
+
157
+ // Declared-path safety — only when a cwd is supplied (Decision 4).
158
+ if (ctx.cwd != null) {
159
+ const deps = { gitLine: ctx.gitLine, lstat: ctx.lstat, realpath: ctx.realpath };
160
+ if (obj.coverage?.kind === 'lcov') {
161
+ const r = declaredPathUnsafeReason('coverage.lcovPath', obj.coverage.lcovPath, ctx.cwd, deps);
162
+ if (r) return { ok: false, reason: `${PROFILE_REL}: ${r}` };
163
+ }
164
+ if (obj.findings?.sarifPath !== undefined) {
165
+ const r = declaredPathUnsafeReason('findings.sarifPath', obj.findings.sarifPath, ctx.cwd, deps);
166
+ if (r) return { ok: false, reason: `${PROFILE_REL}: ${r}` };
167
+ }
168
+ }
169
+ return { ok: true };
170
+ };
171
+
172
+ // resolvers — env WINS over the profile (ad-hoc override precedence, Decision 3)
173
+
174
+ export const resolveCoverage = (profile) => {
175
+ const kind = profile?.coverage?.kind ?? 'v8';
176
+ return { kind, lcovPath: kind === 'lcov' ? profile.coverage.lcovPath : null };
177
+ };
178
+
179
+ // argv precedence: AW_FOLD_BOUND_CMD (applied in the runner's resolveBoundArgv, keeping the
180
+ // malformed-override refusal one home) > profile.singleTest.argv > built-in default. This resolver
181
+ // returns the PROFILE view only: argv = the profile template or null → runner default.
182
+ export const resolveSingleTest = (profile) => ({
183
+ argv: profile?.singleTest?.argv ?? null,
184
+ resultFormat: profile?.singleTest?.resultFormat ?? 'tap-stdout',
185
+ });
186
+
187
+ export const resolveSarifPath = (profile) => profile?.findings?.sarifPath ?? null;
188
+
189
+ // IO — config errors → loud fail(1); an absent FILE → defaults path, NOT an error
190
+
191
+ // loadProfile(cwd, deps?) → { profile, source }. Absent FILE → { profile: null, source: 'none' }.
192
+ // A directory / dangling symlink / permission error is PRESENT-but-unreadable → loud fail(1), never
193
+ // silently treated as absent. Malformed JSON, schema-invalid, or an unsafe declared path → fail(1).
194
+ export const loadProfile = (cwd, deps = {}) => {
195
+ const readFile = deps.readFile ?? readFileSync;
196
+ const lstat = deps.lstat ?? lstatSync;
197
+ const full = join(cwd, PROFILE_REL);
198
+ try {
199
+ lstat(full);
200
+ } catch (err) {
201
+ if (err && err.code === 'ENOENT') return { profile: null, source: 'none' };
202
+ throw fail(1, `${PROFILE_REL}: unreadable (${(err && err.code) || (err && err.message) || err})`);
203
+ }
204
+ let raw;
205
+ try {
206
+ raw = readFile(full, 'utf8');
207
+ } catch (err) {
208
+ throw fail(1, `${PROFILE_REL}: unreadable (${(err && err.code) || (err && err.message) || err})`);
209
+ }
210
+ let parsed;
211
+ try {
212
+ parsed = JSON.parse(raw);
213
+ } catch (err) {
214
+ throw fail(1, `${PROFILE_REL}: malformed JSON (${err.message})`);
215
+ }
216
+ const v = validateProfile(parsed, { cwd, gitLine: deps.gitLine, lstat: deps.lstat, realpath: deps.realpath });
217
+ if (!v.ok) throw fail(1, v.reason);
218
+ return { profile: parsed, source: PROFILE_REL };
219
+ };