@sabaiway/agent-workflow-kit 1.46.0 → 1.48.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 (34) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/README.md +3 -3
  3. package/SKILL.md +1 -1
  4. package/bridges/antigravity-cli-bridge/SKILL.md +1 -1
  5. package/bridges/antigravity-cli-bridge/bin/agy-review.sh +6 -1
  6. package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +6 -0
  7. package/bridges/antigravity-cli-bridge/capability.json +5 -2
  8. package/bridges/codex-cli-bridge/SKILL.md +1 -1
  9. package/bridges/codex-cli-bridge/bin/codex-exec.sh +20 -0
  10. package/bridges/codex-cli-bridge/bin/codex-exec.test.mjs +50 -0
  11. package/bridges/codex-cli-bridge/bin/codex-review.sh +1 -1
  12. package/bridges/codex-cli-bridge/capability.json +5 -2
  13. package/capability.json +1 -1
  14. package/package.json +1 -1
  15. package/references/agents/changelog-skeleton.md +1 -0
  16. package/references/agents/gate-triage.md +1 -0
  17. package/references/agents/mechanical-sweep.md +1 -0
  18. package/references/hooks/gate-approve.mjs +120 -18
  19. package/references/modes/hook.md +6 -1
  20. package/references/modes/recommendations.md +17 -6
  21. package/references/modes/review-ledger.md +2 -1
  22. package/references/modes/upgrade.md +2 -1
  23. package/references/modes/velocity.md +4 -2
  24. package/references/shared/report-footer.md +14 -0
  25. package/references/templates/agent_rules.md +1 -1
  26. package/tools/ack-write.mjs +186 -0
  27. package/tools/detect-backends.mjs +6 -0
  28. package/tools/doc-parity.mjs +7 -0
  29. package/tools/gate-hook.mjs +193 -4
  30. package/tools/procedures.mjs +6 -1
  31. package/tools/recommendations.mjs +171 -17
  32. package/tools/review-ledger-write.mjs +112 -6
  33. package/tools/review-ledger.mjs +1 -1
  34. package/tools/velocity-profile.mjs +25 -12
@@ -19,10 +19,11 @@
19
19
  // below) and VERDICT-FIRST (D1): every non-optimal state opens with ONE composed verdict line.
20
20
  // Registry strings are frozen tool DATA, fact-true, one line under the shape cap (D2); posture/
21
21
  // risk prose lives in the mode doc at the consent moment (D3). A probe failure is a stated
22
- // skipped-item line — never a crash, never a fabricated item. The sandbox-lane item is HAND-APPLY
23
- // by design (bridge council 2026-07-11, both backends concur): the kit never seeds
24
- // sandbox.network.allowedDomains / filesystem.allowWrite; its convergence is a NEUTRAL
25
- // fingerprint-bound acknowledgement, never a security key (D4).
22
+ // skipped-item line — never a crash, never a fabricated item. The kit never seeds
23
+ // sandbox.network.allowedDomains / filesystem.allowWrite (HAND-APPLY territory; bridge council
24
+ // 2026-07-11, both backends concur); the sandbox-lane item's convergence is a NEUTRAL
25
+ // fingerprint-bound acknowledgement recorded by the consent-gated ack writer into the family-owned
26
+ // docs/ai/acks.json (AD-055 relocated it off the host settings schema), never a security key (D4).
26
27
  //
27
28
  // Read-only: never writes, never commits, never runs a subscription CLI. The reused probes are all
28
29
  // exported read-only surfaces of their owning tools (velocity/autonomy/doctor/backends/recipes/
@@ -55,6 +56,7 @@ import { probeSandboxMasks, needsMasksApply } from './sandbox-masks.mjs';
55
56
  import { shellQuoteArg } from './review-state.mjs';
56
57
  import { loadConfig } from './orchestration-config.mjs';
57
58
  import { DEFAULT_BUNDLE_ROOT, SETTINGS_FILENAME, settingsPath, parseSettings, duplicateKeys } from './bridge-settings-read.mjs';
59
+ import { assertContainedRealPath } from './fs-safe.mjs';
58
60
 
59
61
  const HERE = dirname(fileURLToPath(import.meta.url));
60
62
  const toolPath = (rel) => join(HERE, rel);
@@ -95,6 +97,9 @@ export const SEVERITIES = Object.freeze({
95
97
  'review-recipe': SEVERITY_ATTENTION,
96
98
  'gates-declaration': SEVERITY_OPTIONAL,
97
99
  'gate-hook': SEVERITY_OPTIONAL,
100
+ 'read-lane': SEVERITY_OPTIONAL,
101
+ 'read-lane.stale': SEVERITY_ATTENTION,
102
+ 'read-lane.missing': SEVERITY_ATTENTION,
98
103
  'family-freshness': SEVERITY_ATTENTION,
99
104
  'sandbox-masks': SEVERITY_OPTIONAL,
100
105
  'agy-adddir': SEVERITY_OPTIONAL,
@@ -147,6 +152,9 @@ export const WHATS = Object.freeze({
147
152
  'review-recipe': '{degraded}',
148
153
  'gates-declaration': 'no declared gate matrix (docs/ai/gates.json absent or empty) — gates prompt one by one; the apply PREVIEWS its --apply line, writes nothing',
149
154
  'gate-hook': '{n} declared gate(s) prompt per run — the gate-approval hook is not wired',
155
+ 'read-lane': 'the gate hook is wired but the read-only compound lane is off — pipes/chains of seeded reads still prompt one by one',
156
+ 'read-lane.stale': 'the read-lane is ON but the placed gate hook is stale — an old hook never reads lanes.json, so the lane is silently dark; reseed it',
157
+ 'read-lane.missing': 'the gate hook is wired but its placed file is missing — every Bash call errors and the read-lane is dark; re-place it',
150
158
  'family-freshness': '{parts}',
151
159
  'sandbox-masks': '{n} sandbox device mask(s) clutter git status — the managed exclude block is absent or stale',
152
160
  'sandbox-masks.stale-real': '{n} sandbox device mask(s) clutter git status — the exclude block is stale; {m} fenced entr(ies) are REAL paths (a fresh apply drops them)',
@@ -197,6 +205,7 @@ export const BENEFITS = Object.freeze({
197
205
  'review-recipe': 'review coverage — the review recipe you configured actually runs instead of silently degrading',
198
206
  'gates-declaration': 'velocity — your project’s gates run as ONE declared batch with a PASS/FAIL table',
199
207
  'gate-hook': 'velocity — your own declared gate commands auto-approve byte-exactly (opt-in PreToolUse hook)',
208
+ 'read-lane': 'velocity — pipes/chains of your seeded read-only commands auto-approve instead of prompting (opt-in, conservatively classified)',
200
209
  'family-freshness': 'currency — placed family members carry the latest shipped fixes and features',
201
210
  'sandbox-masks': 'zero clutter — git status shows only your changes (the review domain already ignores the masks by construction)',
202
211
  'agy-adddir': 'large reviews — an oversized agy code review offloads to a staging dir instead of refusing',
@@ -334,6 +343,43 @@ const probeGates = ({ root, deps, add, skip }) => {
334
343
  }
335
344
  };
336
345
 
346
+ // The read-lane offer (AD-055 Part II, Help-through-Recommendations): once the gate hook is PLACED
347
+ // and WIRED, offer to enable the opt-in read-only compound lane. Mutually exclusive with the
348
+ // gate-hook item BY CONSTRUCTION — that item keys on `!wired`, this one on `wired` — so an unplaced
349
+ // or un-wired hook is covered there, never double-offered here. The apply is the gate-hook
350
+ // --read-lane PREVIEW one-liner (its own currency check + posture note fire at the writer; it may
351
+ // prompt once — it IS a consent flow). Converges once lanes.json enables the lane.
352
+ const probeReadLane = ({ root, deps, add, skip }) => {
353
+ try {
354
+ const sg = surveyGateHook(root, deps);
355
+ if (sg.error) throw new Error(sg.error);
356
+ if (!sg.wired) return; // not wired → the gate-hook item covers (no double-fire)
357
+ if (!sg.filePlaced) {
358
+ // Wired but the placed hook FILE is missing — the hook errors on every Bash call and the lane
359
+ // is silently dark; surface it as attention with a place-first recovery (council R2-M2).
360
+ add('read-lane', fillTemplate(WHATS['read-lane.missing'], {}), `node ${q(toolPath('gate-hook.mjs'))} --apply --cwd ${q(root)}`, 'read-lane.missing');
361
+ return;
362
+ }
363
+ if (readReadLaneToggle(root, deps)) {
364
+ // The lane is ON: converged IFF the placed hook is byte-current — a stale (pre-1.48) hook never
365
+ // reads lanes.json, so the enabled lane is a silent no-op the user must reseed (council B7). The
366
+ // rm target is ABSOLUTE (council R2-M3) so running the recovery from any cwd can only delete this
367
+ // repo's hook.
368
+ if (isPlacedHookCurrent(root, deps)) return; // converged
369
+ add(
370
+ 'read-lane',
371
+ fillTemplate(WHATS['read-lane.stale'], {}),
372
+ `HAND-APPLY: rm ${q(join(root, GATE_HOOK_REL))}, then node ${q(toolPath('gate-hook.mjs'))} --apply --cwd ${q(root)}`,
373
+ 'read-lane.stale',
374
+ );
375
+ return;
376
+ }
377
+ add('read-lane', fillTemplate(WHATS['read-lane'], {}), `node ${q(toolPath('gate-hook.mjs'))} --read-lane --cwd ${q(root)}`);
378
+ } catch (err) {
379
+ skip('read-lane', err);
380
+ }
381
+ };
382
+
337
383
  const probeFamilyFreshness = ({ deps, add, skip }) => {
338
384
  try {
339
385
  const survey = deps.surveyFamily ?? surveyFamily;
@@ -485,15 +531,102 @@ export const recipeFingerprint = ({ hosts, dirs, home }) => {
485
531
  return createHash('sha256').update(canonical).digest('hex').slice(0, 16);
486
532
  };
487
533
 
488
- // The kit-owned neutral ack namespace (D4): project-scoped, hand-applicable as one line, read from
489
- // BOTH settings scopes; the sandbox/permissions security keys are NEVER consulted as an ack.
534
+ // The kit-owned neutral ack store (D4; AD-055 Part I): a FAMILY-OWNED strict-JSON file no host
535
+ // validator guards top-level key `sandboxLaneAck` (+ optional `_README`), unknown keys tolerated
536
+ // on read (future acks are siblings). This is the PRIMARY ack channel; the legacy settings-scope
537
+ // keys below are read for one deprecation window. The sandbox/permissions security keys are NEVER
538
+ // consulted as an ack.
539
+ export const ACKS_FILE = 'docs/ai/acks.json';
540
+ export const ACKS_LANE_KEY = 'sandboxLaneAck';
541
+
542
+ // The opt-in read-lane toggle file (AD-055 Part II) — the SAME kit-owned docs/ai/lanes.json the
543
+ // placed hook reads live. The read-lane item offers to enable it once the hook is placed+wired.
544
+ export const LANES_FILE = 'docs/ai/lanes.json';
545
+ export const READ_LANE_KEY = 'readLane';
546
+
547
+ // The placed gate hook + the bundled runtime the read-lane item byte-compares for currency (AD-055
548
+ // Part II, council B7): an enabled lane over a STALE hook is a silent no-op — the pre-1.48 hook never
549
+ // reads lanes.json — so it must surface as an ATTENTION reseed, not a silent convergence.
550
+ const GATE_HOOK_REL = '.claude/hooks/agent-workflow-gates.mjs';
551
+ const BUNDLED_HOOK_ABS = join(HERE, '..', 'references', 'hooks', 'gate-approve.mjs');
552
+
553
+ // Byte-compare the placed gate hook against the bundled runtime. A read error (an unreadable placed
554
+ // hook, a broken kit bundle) propagates → the probe states a skip, never a wrong currency verdict.
555
+ const isPlacedHookCurrent = (root, deps) => {
556
+ const readFile = deps.readFile ?? readFileSync;
557
+ const placed = readFile(join(root, GATE_HOOK_REL), 'utf8');
558
+ const bundle = readFile(deps.bundledHookPath ?? BUNDLED_HOOK_ABS, 'utf8');
559
+ return placed === bundle;
560
+ };
561
+
562
+ // The LEGACY neutral ack namespace (pre-AD-055): read from BOTH settings scopes until the next kit
563
+ // MAJOR (2.0.0) so a never-migrated host stays converged across the deprecation window (Decisions 3).
490
564
  export const SANDBOX_LANE_ACK_PARENT = 'agentWorkflow';
491
565
  export const SANDBOX_LANE_ACK_KEY = 'sandboxLaneAck';
492
566
 
567
+ // Read the family-owned ack store. An ABSENT file (or absent docs/ai) is the NORMAL not-yet-acked
568
+ // state → null (plain fall-through, never a skip). A parse/IO error on an EXISTING file THROWS — the
569
+ // probe's catch turns it into a stated skip line (Decisions 2). A non-object root is a malformed
570
+ // store (fail-closed skip); a non-string value at the key is tolerated → null (the item re-fires).
571
+ // The WHOLE path chain (root / docs / ai / acks.json) is guarded WITHOUT following symlinks
572
+ // BEFORE any read: a symlinked ANCESTOR could otherwise read an ack from OUTSIDE the project (the
573
+ // writer refuses such a deployment — the reader must too), a symlinked/dangling LEAF must not read as
574
+ // not-yet-acked, and a non-regular target (FIFO/dir/device) is a fail-closed SKIP — never read it (a
575
+ // FIFO would BLOCK the advisor). ENOENT-safe: an absent file/dir is the NORMAL not-yet-acked null.
576
+ const readAcksLane = (root, deps) => {
577
+ const readFile = deps.readFile ?? readFileSync;
578
+ const lstat = deps.lstat ?? lstatSync;
579
+ const absPath = join(root, ACKS_FILE);
580
+ let st;
581
+ try {
582
+ assertContainedRealPath(root, absPath, { lstat }); // symlinked root/ancestor/leaf or escape → throws
583
+ st = lstat(absPath);
584
+ } catch (err) {
585
+ if (err?.code === 'ENOENT') return null; // genuinely absent (file or docs/ai) — normal not-yet-acked
586
+ throw err; // a symlinked ancestor/leaf, an escape, or a real IO error — stated skip
587
+ }
588
+ if (!st.isFile()) {
589
+ throw new Error(`${ACKS_FILE} is not a regular file — refusing to read it`);
590
+ }
591
+ const parsed = JSON.parse(readFile(absPath, 'utf8'));
592
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
593
+ throw new Error(`${ACKS_FILE}: expected a JSON object`);
594
+ }
595
+ const value = parsed[ACKS_LANE_KEY];
596
+ return typeof value === 'string' ? value : null;
597
+ };
598
+
599
+ // Read the opt-in read-lane toggle for the read-lane item. An ABSENT file (or absent docs/ai) →
600
+ // false (the lane is off — offer it). `readLane === true` → enabled (converged). A parse/IO error on
601
+ // an EXISTING file, a symlinked ancestor/leaf, an escape, or a non-object root THROWS — the probe
602
+ // turns it into a stated skip (a BROKEN toggle the writer would refuse to overwrite is not "off").
603
+ // A present-but-non-boolean `readLane` is a valid store the writer merges → false (offer), never a skip.
604
+ const readReadLaneToggle = (root, deps) => {
605
+ const readFile = deps.readFile ?? readFileSync;
606
+ const lstat = deps.lstat ?? lstatSync;
607
+ const absPath = join(root, LANES_FILE);
608
+ let st;
609
+ try {
610
+ assertContainedRealPath(root, absPath, { lstat }); // symlinked root/ancestor/leaf or escape → throws
611
+ st = lstat(absPath);
612
+ } catch (err) {
613
+ if (err?.code === 'ENOENT') return false; // genuinely absent — the lane is off, offer it
614
+ throw err; // a symlinked ancestor/leaf, an escape, or a real IO error — stated skip
615
+ }
616
+ if (!st.isFile()) {
617
+ throw new Error(`${LANES_FILE} is not a regular file — refusing to read it`);
618
+ }
619
+ const parsed = JSON.parse(readFile(absPath, 'utf8'));
620
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
621
+ throw new Error(`${LANES_FILE}: expected a JSON object`);
622
+ }
623
+ return parsed[READ_LANE_KEY] === true;
624
+ };
625
+
493
626
  // D3: the risk-marked keys — every key here has a per-item posture note in the mode doc, surfaced
494
627
  // at the consent moment; the static contract test asserts EXACT bidirectional coverage
495
628
  // (risk-marked keys == mode-doc note keys — a dropped note goes red, not silent).
496
- export const RISK_NOTED_KEYS = Object.freeze(['agy-adddir', 'sandbox-lane']);
629
+ export const RISK_NOTED_KEYS = Object.freeze(['agy-adddir', 'sandbox-lane', 'read-lane']);
497
630
 
498
631
  const probeSandboxLane = ({ root, deps, add, skip }) => {
499
632
  try {
@@ -522,14 +655,28 @@ const probeSandboxLane = ({ root, deps, add, skip }) => {
522
655
  if (!dirs.includes(resolved)) dirs.push(resolved);
523
656
  }
524
657
  const fingerprint = recipeFingerprint({ hosts, dirs, home });
525
- // Convergence is the NEUTRAL fingerprint-bound acknowledgement, read from BOTH scopes — a
526
- // changed recipe (hosts, dirs, or an env override) re-fires the item (D4).
527
- const acks = [settings.data?.[SANDBOX_LANE_ACK_PARENT]?.[SANDBOX_LANE_ACK_KEY], localSettings.data?.[SANDBOX_LANE_ACK_PARENT]?.[SANDBOX_LANE_ACK_KEY]];
658
+ // Convergence is the NEUTRAL fingerprint-bound acknowledgement: the item converges iff the
659
+ // CURRENT fingerprint equals the ack in ANY consulted store the family-owned acks.json FIRST,
660
+ // then the legacy settings scopes; a stale value in one store is ignored when another matches
661
+ // (Decisions 2). A changed recipe (hosts, dirs, or an env override) re-fires the item (D4).
662
+ const acks = [
663
+ readAcksLane(root, deps),
664
+ settings.data?.[SANDBOX_LANE_ACK_PARENT]?.[SANDBOX_LANE_ACK_KEY],
665
+ localSettings.data?.[SANDBOX_LANE_ACK_PARENT]?.[SANDBOX_LANE_ACK_KEY],
666
+ ];
528
667
  if (acks.includes(fingerprint)) return; // the acknowledged recipe — the item converged
668
+ // The item joins the CONSENT-GATED WRITER class (Decisions 4): the apply is the ack writer's
669
+ // PREVIEW one-liner (pure executable, cwd-independent), carrying the neutral fingerprint — never
670
+ // a security key. The LIVE recipe (egress hosts + resolved writable dirs) rides a separate
671
+ // rendered `recipe:` line (the fill source for the mode doc's lane-(2) hand-apply block); the
672
+ // fingerprint encodes it, so a changed recipe re-fires with a fresh command.
673
+ const recipe = `egress hosts [${hosts.join(', ')}]; writable state dirs [${dirs.join(', ')}] (observed-minimal; a blocked host names itself at run time)`;
529
674
  add(
530
675
  'sandbox-lane',
531
676
  fillTemplate(WHATS['sandbox-lane'], {}),
532
- `HAND-APPLY (a neutral acknowledgement, never a security key): recipe — egress hosts [${hosts.join(', ')}]; writable state dirs [${dirs.join(', ')}] (observed-minimal; a blocked host names itself at run time); what to DO with it per host class: the mode doc's sandbox-lanes section; once handled, record the ack: set "${SANDBOX_LANE_ACK_PARENT}"."${SANDBOX_LANE_ACK_KEY}" to "${fingerprint}" in .claude/settings.json or settings.local.json — a MERGE into the existing ${SANDBOX_LANE_ACK_PARENT} object (keep its other keys; create it only if absent)`,
677
+ `node ${q(toolPath('ack-write.mjs'))} --fingerprint ${fingerprint} --cwd ${q(root)}`,
678
+ 'sandbox-lane',
679
+ recipe,
533
680
  );
534
681
  } catch (err) {
535
682
  skip('sandbox-lane', err);
@@ -543,6 +690,7 @@ const PROBES = Object.freeze([
543
690
  probeSandboxProvision,
544
691
  probeReviewRecipe,
545
692
  probeGates,
693
+ probeReadLane,
546
694
  probeFamilyFreshness,
547
695
  probeMasksItem,
548
696
  probeAgyAdddir,
@@ -560,18 +708,22 @@ export const buildRecommendations = ({ cwd, deps = {} } = {}) => {
560
708
  // violation surfaces through the stated-skip lane, never a crash, never a rendered violation.
561
709
  // severityKey defaults to the item key; a per-site arm passes its `<key>.<variant>` entry when
562
710
  // its class differs from the base (the invalid-env attention arm).
563
- const add = (key, what, apply, severityKey = key) => {
711
+ // `detail` (optional) is an extra rendered `recipe:` line — factual context that is TOO LONG for
712
+ // the capped WHAT and does NOT belong in the pure-command apply (the sandbox-lane live recipe:
713
+ // egress hosts + resolved writable dirs). Single-line like apply; absent for every other item.
714
+ const add = (key, what, apply, severityKey = key, detail = null) => {
564
715
  const problems = [];
565
716
  if (!(key in BENEFITS)) problems.push(`unregistered item key ${JSON.stringify(key)}`);
566
717
  if (!(severityKey in SEVERITIES)) problems.push(`unregistered severity key ${JSON.stringify(severityKey)}`);
567
718
  if (/[\r\n]/.test(what)) problems.push('WHAT is not a single line');
568
719
  else if (what.length > ITEM_LINE_CAP) problems.push(`WHAT exceeds the ${ITEM_LINE_CAP}-char cap (${what.length})`);
569
720
  if (/[\r\n]/.test(apply)) problems.push('apply is not a single line');
721
+ if (detail != null && /[\r\n]/.test(detail)) problems.push('recipe detail is not a single line');
570
722
  if (problems.length > 0) {
571
723
  skip(key, new Error(`item shape violation — ${problems.join('; ')}`));
572
724
  return;
573
725
  }
574
- items.push({ key, severity: SEVERITIES[severityKey], what, benefit: BENEFITS[key], apply });
726
+ items.push({ key, severity: SEVERITIES[severityKey], what, benefit: BENEFITS[key], apply, detail });
575
727
  };
576
728
  for (const probe of deps.probes ?? PROBES) probe({ root, deps, add, skip });
577
729
  return { root, items, skips };
@@ -599,6 +751,7 @@ export const formatRecommendations = ({ items, skips }) => {
599
751
  ordered.forEach((item, i) => {
600
752
  lines.push(`${i + 1}. ${SEVERITY_LABELS[item.severity] ?? SEVERITY_LABELS[SEVERITY_OPTIONAL]}: ${item.what}`);
601
753
  lines.push(` benefit: ${item.benefit}`);
754
+ if (item.detail) lines.push(` recipe: ${item.detail}`);
602
755
  lines.push(` apply: ${item.apply}`);
603
756
  });
604
757
  }
@@ -615,15 +768,16 @@ Usage:
615
768
 
616
769
  Computes the deterministic Recommendations section every kit upgrade ends with — VERDICT-FIRST:
617
770
  one composed verdict line opens every non-optimal render, then per item {severity · what is
618
- sub-optimal · the benefit in one plain line · the exact consent-gated apply one-liner}. --cwd is
771
+ sub-optimal · the benefit in one plain line · an optional \`recipe:\` line (the sandbox-lane live
772
+ recipe only) · the exact consent-gated apply one-liner}. --cwd is
619
773
  REQUIRED (the target project is explicit, never inferred from the shell's current directory). The
620
774
  section renders present-even-when-empty ("${RECOMMENDATIONS_EMPTY_LINE}"); a probe failure is a
621
775
  stated skipped-item line. Apply lines are cwd-independent (absolute tool paths, a pinned --cwd;
622
776
  the doctor item pins via a cd prefix; the ONE exception is the set-autonomy item — a
623
777
  conversational skill invocation labeled "run IN the target project") and preserve each writer's
624
- own consent semantics; the sandbox-lane item is HAND-APPLY by design — this tool and the kit
625
- writers never seed sandbox network/filesystem allowances, and its convergence is a neutral
626
- fingerprint acknowledgement, never a security key.
778
+ own consent semantics; the kit never seeds sandbox network/filesystem allowances (HAND-APPLY
779
+ territory), and the sandbox-lane convergence is a neutral fingerprint acknowledgement recorded by
780
+ the consent-gated ack writer into docs/ai/acks.json (never a security key).
627
781
 
628
782
  Read-only: never writes, never commits, never runs a subscription CLI. Exit codes: 0 report
629
783
  rendered (items or empty); 1 error; 2 usage.`;
@@ -406,7 +406,91 @@ export const recordTriage = (params, deps = {}) => {
406
406
  return appendRecord(ledgerPath, record, deps);
407
407
  };
408
408
 
409
- // ── CLI (record / classify) ──────────────────────────────────────────────────────────────────────
409
+ // ── batch (D4/D5 — one invocation applies an ordered list of record/classify/override ops) ────────
410
+ // The prompt-economy lane (WRITER-BURST-BATCH): the ledger triad a records stage would otherwise
411
+ // fire one writer call at a time — records, classifications, overrides — rides ONE `batch`
412
+ // invocation. Every op runs the SAME single-verb code path (no forked validator, D4), so a batch of
413
+ // N ops is record-equivalent to the same N single-verb invocations. TWO passes (D5): pass 1
414
+ // validates the WHOLE envelope structurally with ZERO writes (a bad envelope stops before any op
415
+ // runs); pass 2 applies the ops sequentially — a DOMAIN failure (a tooth, a missing receipt, a
416
+ // malformed record) stops the batch with an honest report, and the ops already applied stay recorded
417
+ // (the ledger is append-only — no rollback pretense).
418
+
419
+ // The verbs a batch operation may carry — the SAME functions the single verbs dispatch.
420
+ const BATCH_VERBS = new Set(['record', 'classify', 'override']);
421
+
422
+ // Pass 1 — structural envelope validation, ZERO writes (a usage failure, exit 2). Deep per-op payload
423
+ // validity is NOT checked here (that is pass 2's single-verb code path — checking it here would fork
424
+ // the validator); pass 1 proves only the envelope shape + a known verb per op, so no raw TypeError
425
+ // and no silent success reaches pass 2.
426
+ export const validateBatchEnvelope = (payload) => {
427
+ if (payload == null || typeof payload !== 'object' || Array.isArray(payload)) {
428
+ throw usageFail('batch payload must be an object of the form { "operations": [ … ] }');
429
+ }
430
+ const operations = payload.operations;
431
+ if (!Array.isArray(operations)) throw usageFail('batch payload.operations must be an array of operations');
432
+ if (operations.length === 0) throw usageFail('batch payload.operations is empty — nothing to record');
433
+ operations.forEach((op, i) => {
434
+ if (op == null || typeof op !== 'object' || Array.isArray(op)) {
435
+ throw usageFail(`batch operation [${i}] must be an object of the form { "verb": …, … }`);
436
+ }
437
+ if (!BATCH_VERBS.has(op.verb)) {
438
+ throw usageFail(`batch operation [${i}] names an unknown verb "${op.verb}" (expected: record | classify | override)`);
439
+ }
440
+ // The batch resolves cwd/env from the INVOCATION, never per operation — a per-op override could
441
+ // read one project's state and write another's ledger. Reject it loudly.
442
+ for (const forbidden of ['cwd', 'env']) {
443
+ if (forbidden in op) {
444
+ throw usageFail(`batch operation [${i}] carries a "${forbidden}" field — the batch resolves cwd/env from the invocation, never per operation`);
445
+ }
446
+ }
447
+ // fromReceipts must be a real boolean: a string "false" is truthy and would silently enable the
448
+ // draft. Reject any non-boolean when the field is present.
449
+ if ('fromReceipts' in op && typeof op.fromReceipts !== 'boolean') {
450
+ throw usageFail(`batch operation [${i}] has a non-boolean fromReceipts (${JSON.stringify(op.fromReceipts)}) — it must be the literal true or false`);
451
+ }
452
+ if ('fromReceipts' in op && op.verb !== 'record') {
453
+ throw usageFail(`batch operation [${i}] sets fromReceipts on "${op.verb}" — it applies only to record (even the literal false does not belong here)`);
454
+ }
455
+ });
456
+ return operations;
457
+ };
458
+
459
+ // Apply ONE operation through the SAME single-verb code path main() dispatches (including the
460
+ // --from-receipts backends[] draft for a record op). deps is threaded so a batch is as hermetic as
461
+ // the single verbs.
462
+ const applyOperation = (op, { cwd, env }, deps = {}) => {
463
+ const { verb, fromReceipts, ...payload } = op;
464
+ // The invocation's cwd/env WIN (spread last) — validateBatchEnvelope already forbids them in the
465
+ // payload, so this is defense-in-depth against a direct runBatch call bypassing the preflight.
466
+ if (verb === 'classify') return recordTriage({ ...payload, cwd, env }, deps);
467
+ if (verb === 'override') return recordOverride({ ...payload, cwd, env }, deps);
468
+ const params = { ...payload };
469
+ if (fromReceipts) {
470
+ const state = deps.buildState ? deps.buildState({ cwd, env }) : buildState({ cwd, env });
471
+ params.backends = draftBackendsFromReceipts({ state, findings: params.findings, explicitBackends: params.backends ?? [] }, deps);
472
+ }
473
+ return recordRound({ ...params, cwd, env }, deps);
474
+ };
475
+
476
+ // runBatch({ cwd, env, operations }, deps) → { applied: [ per-op results ], count }. Pass 2: apply
477
+ // sequentially, fail-fast on the first typed STOP; the ops already appended stay recorded.
478
+ export const runBatch = ({ cwd = process.cwd(), env = process.env, operations }, deps = {}) => {
479
+ const applied = [];
480
+ for (const [i, operation] of operations.entries()) {
481
+ try {
482
+ applied.push(applyOperation(operation, { cwd, env }, deps));
483
+ } catch (err) {
484
+ // Honest partial-success (D5): the STOP itself names the failing op index + the applied count.
485
+ // The ledger is append-only, so those ops are durable — resume by re-running the REMAINING ops.
486
+ err.message = `${err.message} [batch stopped at operation [${i}]: ${applied.length} of ${operations.length} operation(s) recorded before the stop and durable (append-only ledger); re-run the remaining operations]`;
487
+ throw err;
488
+ }
489
+ }
490
+ return { applied, count: applied.length };
491
+ };
492
+
493
+ // ── CLI (record / classify / override / batch) ─────────────────────────────────────────────────────
410
494
 
411
495
  const HELP = `review-ledger-write — the review-round ledger WRITER (agent-workflow family, AD-045 + AD-047 + AD-048).
412
496
 
@@ -414,6 +498,7 @@ Usage:
414
498
  node review-ledger-write.mjs record --json '<round-payload>' [--from-receipts] [--cwd <dir>]
415
499
  node review-ledger-write.mjs classify --json '<triage-payload>' [--cwd <dir>]
416
500
  node review-ledger-write.mjs override --json '<override-payload>' [--cwd <dir>]
501
+ node review-ledger-write.mjs batch --json '<{ "operations": [ … ] }>' [--cwd <dir>]
417
502
  (every verb also accepts --json @<file> — the payload read from a file, keeping the command
418
503
  line PLAIN: an inline JSON argv falls outside plain-invocation allow heuristics and prompts)
419
504
 
@@ -448,10 +533,24 @@ override appends one override record — the LOUD, durable waiver the gates cons
448
533
  sanctions, SEGMENT-scoped — it dies at the next commit, D4).
449
534
  REFUSES for a loop that is not the in-flight plan. QUARANTINE (a flaky/timed-out probe)
450
535
  has NO override lane.
536
+ batch applies an ordered list of record / classify / override operations in ONE invocation — the
537
+ prompt-economy lane for a records stage (one writer call, not one per op). The payload is
538
+ { "operations": [ { "verb": "record"|"classify"|"override", …that verb's payload } ] }; a
539
+ record op may carry "fromReceipts": true (the SAME draft as --from-receipts). Each op runs
540
+ the SAME per-verb code path (no forked validator), so a batch of N ops is record-equivalent
541
+ to the same N single invocations. TWO passes: the WHOLE envelope is validated structurally
542
+ first with ZERO writes (a bad envelope stops before any op runs); then ops apply sequentially
543
+ and fail-fast on the first typed STOP — the ops already applied stay recorded (the ledger is
544
+ append-only, no rollback), and the STOP names the failing op index + the applied count.
545
+ Resume by re-running the REMAINING operations.
451
546
 
452
547
  The read-only checker is a SEPARATE tool: node review-ledger.mjs --check / --status / --json / --telemetry.
453
548
  Exit codes: 0 written; 1 a typed STOP (teeth / malformed / missing receipt / fs error); 2 usage.`;
454
549
 
550
+ // The CLI subcommand set — the SINGLE source the dispatch and the doc-contract pin both read, so a
551
+ // documented verb list can never lag the dispatch (Phase 2.3 — the contract-test class).
552
+ export const SUBCOMMANDS = ['record', 'classify', 'override', 'batch'];
553
+
455
554
  const parseArgs = (argv) => {
456
555
  const opts = { cwd: undefined, json: undefined, fromReceipts: false };
457
556
  for (let i = 0; i < argv.length; i += 1) {
@@ -500,11 +599,18 @@ export const main = (argv, ctx = {}) => {
500
599
  try {
501
600
  if (argv.includes('--help') || argv.includes('-h') || argv.length === 0) return { code: argv.length === 0 ? 2 : 0, stdout: HELP, stderr: '' };
502
601
  const sub = argv[0];
503
- if (sub !== 'record' && sub !== 'classify' && sub !== 'override') throw usageFail(`unknown subcommand "${sub}" (expected: record | classify | override)`);
602
+ if (!SUBCOMMANDS.includes(sub)) throw usageFail(`unknown subcommand "${sub}" (expected: ${SUBCOMMANDS.join(' | ')})`);
504
603
  const opts = parseArgs(argv.slice(1));
505
- if (opts.fromReceipts && sub !== 'record') throw usageFail('--from-receipts applies only to `record`');
604
+ if (opts.fromReceipts && sub !== 'record') throw usageFail('--from-receipts applies only to `record` (a batch carries fromReceipts per operation)');
506
605
  const payload = parsePayload(opts.json);
507
606
  const cwd = opts.cwd ?? cwd0;
607
+ if (sub === 'batch') {
608
+ // Pass 1 (structural, ZERO writes) → pass 2 (sequential apply, fail-fast — prior ops durable).
609
+ const operations = validateBatchEnvelope(payload);
610
+ const { applied, count } = runBatch({ cwd, env, operations });
611
+ const writtenPath = applied.length ? applied[applied.length - 1].writtenPath : '(none)';
612
+ return { code: 0, stdout: `review-ledger-write: recorded ${count} operation(s) in one batch → ${writtenPath}`, stderr: '' };
613
+ }
508
614
  if (opts.fromReceipts) {
509
615
  // Draft backends[] from the current-fingerprint receipts + the supplied findings; origins /
510
616
  // findings stay explicit. The drafted array then rides the normal recordRound teeth.
@@ -513,10 +619,10 @@ export const main = (argv, ctx = {}) => {
513
619
  }
514
620
  const result =
515
621
  sub === 'record'
516
- ? recordRound({ cwd, env, ...payload })
622
+ ? recordRound({ ...payload, cwd, env })
517
623
  : sub === 'classify'
518
- ? recordTriage({ cwd, env, ...payload })
519
- : recordOverride({ cwd, env, ...payload });
624
+ ? recordTriage({ ...payload, cwd, env })
625
+ : recordOverride({ ...payload, cwd, env });
520
626
  return { code: 0, stdout: `review-ledger-write: recorded a ${result.record.kind} for loop "${result.record.loop}" round ${result.record.round} → ${result.writtenPath}`, stderr: '' };
521
627
  } catch (err) {
522
628
  return { code: err.exitCode ?? 1, stdout: '', stderr: `review-ledger-write: ${err.message}` };
@@ -568,7 +568,7 @@ gated commit, so the round-counter reset is earned, never declared.
568
568
  rounds, override usage by scope, gate-run counts (quality-green / red-by-gate), fold runs,
569
569
  observed-red receipts, quarantined probes. Counts only — interpretation stays with you.
570
570
 
571
- The writer is a SEPARATE tool (review-ledger-write.mjs record/classify/override) — this read-only
571
+ The writer is a SEPARATE tool (review-ledger-write.mjs record/classify/override/batch) — this read-only
572
572
  checker never imports it. Sandbox-safe: runs fully inside an OS sandbox (fs + git reads, no
573
573
  network) — the D4 sandbox lane. Human residual: git commit --no-verify, ledger-file editing, and
574
574
  forged counts remain possible — a self-discipline mechanism, not a security boundary.
@@ -163,9 +163,12 @@ const RUN_GATES_CWD_FLAG = '--cwd';
163
163
  // ── the opt-in --bridge-tier (AD-044 Plan 4, Decision 2) ────────────────────────────────
164
164
  // The FROZEN membership source: review-role wrapper NAMES only, spelled bare — that is how the
165
165
  // wrappers are invoked (setup-backends places them as symlinks in ~/.local/bin). Roles come from
166
- // THIS constant, never from the detector's wrapperCmds (which carries no role labels):
167
- // codex-exec (execution role) and agy-run (probe role) are deliberately ABSENT delegated
168
- // execution keeps its human prompt; the velocity pain this tier closes is council REVIEW runs.
166
+ // THIS constant, never from the detector's wrapperCmds (which carries no role labels): codex-exec
167
+ // (execution role) and agy-run (probe role) are deliberately ABSENT. The velocity pain this tier
168
+ // closes is council REVIEW runs; codex-exec's nested-sandbox recovery is handled the canon way
169
+ // route it outside ON the OBSERVED failure (orchestration.md §5), NOT a preemptive tier seed (AD-054
170
+ // final council: a blanket exclusion at opt-in contradicts «never a preemptive blanket»), guided by
171
+ // codex-exec.sh's own nested-sandbox detection hint.
169
172
  export const BRIDGE_REVIEW_WRAPPERS = Object.freeze(['codex-review', 'agy-review']);
170
173
  // Only the `code` review mode is auto-allowed (codex R2, Segment A): a bare `Bash(<wrapper>:*)`
171
174
  // prefix would also cover the plan/diff file-argument modes, whose targets can point OUTSIDE the
@@ -190,13 +193,12 @@ const isQuotedGroundingToken = (token) => {
190
193
  };
191
194
 
192
195
  /**
193
- * Derive the opt-in bridge-wrappers tier: one bare-name wildcard allow rule per PLACED review
194
- * wrapper (the frozen constant is the membership source; placement is a probe, never a role
195
- * source), the SAME wrapper names for sandbox.excludedCommands (the harness runs an excluded
196
- * command OUTSIDE the sandbox, so a plain allowlisted invocation needs no sandbox-bypass
197
- * approval — the zero-prompt wiring), and the grounding pre-step rule in its rendered quoted
198
- * byte-form (derived only when ≥1 review bridge is placed; an unseedable kit path is a stated
199
- * skip). An absent bridge is a stated skip — its entry never derives.
196
+ * Derive the opt-in bridge-wrappers tier: one code-mode allow rule per PLACED review wrapper (the
197
+ * frozen constant is the membership source; placement is a probe, never a role source), the SAME
198
+ * wrapper names for sandbox.excludedCommands (the harness runs an excluded command OUTSIDE the
199
+ * sandbox, so a plain allowlisted invocation needs no sandbox-bypass approval — the zero-prompt
200
+ * wiring), and the grounding pre-step rule in its rendered quoted byte-form (derived only when
201
+ * agy-review is placed; an unseedable kit path is a stated skip). An absent bridge is a stated skip.
200
202
  */
201
203
  export const deriveBridgeTierAllowlist = ({ findWrapper, groundingAbsPath } = {}) => {
202
204
  const probe = findWrapper ?? ((cmd) => findOnPath(cmd).state === WRAPPER_PLACED);
@@ -253,8 +255,19 @@ export const RUNTIME_RESIDUAL_FORMS = Object.freeze({
253
255
  writeRedirections: Object.freeze(['>', '>>', '1>', '2>', '&>', '>|']),
254
256
  // `$(…)` + backtick + process substitution `<(…)` all RUN a nested command (`>(…)` is caught by
255
257
  // the `>` redirection scan). Bare `<` is input redirection (reads a file — read-only commands may
256
- // already do that), so it is deliberately NOT here.
257
- commandSubstitutions: Object.freeze(['$(', '`', '<(']),
258
+ // already do that), so it is deliberately NOT here. The bash-5.3 function substitutions `${ cmd; }`
259
+ // (a blank — space/tab/newline/CR — right after `${`) and `${| cmd; }` (runs a command, assigns
260
+ // REPLY) also execute a nested command — matched as the literal openers `${ ` / `${\t` / `${\n` /
261
+ // `${\r` / `${|` (AD-055 Part II; the `\r` opener guards CRLF payloads — council agy nit). An
262
+ // ordinary `${VAR}` parameter expansion has NO blank after `${`, so it trips none of these — kept
263
+ // rung-(b)-silent on a settings-allowed single (rung (c) excludes all `$`).
264
+ commandSubstitutions: Object.freeze(['$(', '`', '<(', '${ ', '${\t', '${\n', '${\r', '${|']),
265
+ // A backslash immediately before a newline/CR is a bash LINE CONTINUATION: bash removes it and
266
+ // splices the two lines into ONE word, which can reconstruct a residual token (`--output`, `$(`,
267
+ // `${ …; }`) that a raw substring scan on the pre-splice string misses (`--outp\<newline>ut=f` →
268
+ // `--output=f`). Guards a settings-allowed SINGLE (rung c already forbids backslash in every
269
+ // segment). AD-055 Part II council fold (codex B3).
270
+ lineContinuations: Object.freeze(['\\\n', '\\\r']),
258
271
  // The `--output` write-flag family, matched as a raw SUBSTRING of the whole command (never a
259
272
  // whitespace-token check): the hook sees the pre-shell command string, so `--output=f`,
260
273
  // `"--output=f"`, `'--output' f`, and `\--output` must all trip it — over-asking on a benign