@sabaiway/agent-workflow-kit 1.47.0 → 1.49.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 (33) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/README.md +2 -2
  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 +21 -3
  6. package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +3 -2
  7. package/bridges/antigravity-cli-bridge/bin/agy.sh +20 -2
  8. package/bridges/antigravity-cli-bridge/bin/agy.test.mjs +3 -2
  9. package/bridges/antigravity-cli-bridge/capability.json +1 -1
  10. package/bridges/codex-cli-bridge/SKILL.md +1 -1
  11. package/bridges/codex-cli-bridge/bin/codex-exec.sh +20 -2
  12. package/bridges/codex-cli-bridge/bin/codex-exec.test.mjs +3 -2
  13. package/bridges/codex-cli-bridge/bin/codex-review.sh +21 -3
  14. package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +3 -2
  15. package/bridges/codex-cli-bridge/capability.json +1 -1
  16. package/capability.json +1 -1
  17. package/package.json +1 -1
  18. package/references/agents/changelog-skeleton.md +1 -0
  19. package/references/agents/gate-triage.md +1 -0
  20. package/references/agents/mechanical-sweep.md +1 -0
  21. package/references/hooks/gate-approve.mjs +120 -18
  22. package/references/modes/hook.md +6 -1
  23. package/references/modes/recommendations.md +8 -6
  24. package/references/modes/setup.md +7 -2
  25. package/references/modes/upgrade.md +9 -9
  26. package/references/modes/velocity.md +4 -2
  27. package/tools/ack-write.mjs +186 -0
  28. package/tools/doc-parity.mjs +14 -0
  29. package/tools/fs-safe.mjs +28 -5
  30. package/tools/gate-hook.mjs +193 -4
  31. package/tools/recommendations.mjs +171 -17
  32. package/tools/setup-backends.mjs +48 -3
  33. package/tools/velocity-profile.mjs +13 -2
@@ -34,7 +34,9 @@ import {
34
34
  VERDICT_NOTHING_BROKEN,
35
35
  VERDICT_OPTIONAL_TEMPLATE,
36
36
  VERDICT_SKIPS_TEMPLATE,
37
+ ACKS_FILE,
37
38
  } from './recommendations.mjs';
39
+ import { SKIPPED_READONLY } from './setup-backends.mjs';
38
40
 
39
41
  const KIT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
40
42
 
@@ -43,6 +45,8 @@ const FOLD_DOC = 'references/modes/fold-completeness.md';
43
45
  const AUTONOMY_DOCTOR_DOC = 'references/modes/autonomy-doctor.md';
44
46
  const RECOMMENDATIONS_DOC = 'references/modes/recommendations.md';
45
47
  const UPGRADE_DOC = 'references/modes/upgrade.md';
48
+ const VELOCITY_DOC = 'references/modes/velocity.md';
49
+ const SETUP_DOC = 'references/modes/setup.md';
46
50
 
47
51
  // A typed usage failure (exit 2) for the CLI parser — the codebase's typed-error idiom (no classes).
48
52
  const usageFail = (message) => Object.assign(new Error(message), { exitCode: 2 });
@@ -92,6 +96,16 @@ export const BINDINGS = Object.freeze([
92
96
  valueBinding('verdict-nothing-broken', VERDICT_NOTHING_BROKEN, VERDICT_NOTHING_BROKEN, [RECOMMENDATIONS_DOC, UPGRADE_DOC]),
93
97
  valueBinding('verdict-optional', VERDICT_OPTIONAL_TEMPLATE, VERDICT_OPTIONAL_TEMPLATE, [RECOMMENDATIONS_DOC, UPGRADE_DOC]),
94
98
  valueBinding('verdict-skips', VERDICT_SKIPS_TEMPLATE, VERDICT_SKIPS_TEMPLATE, [RECOMMENDATIONS_DOC, UPGRADE_DOC]),
99
+ // The ack-store apply target (AD-055 Part I): the family-owned docs/ai/acks.json path — a
100
+ // drift-guarded constant so the mode docs' ack-store references cannot silently outdate the code
101
+ // (the incident's "mode-doc apply text stays in lockstep" acceptance as a mechanism, not prose).
102
+ // Bound in BOTH docs that name the path (recommendations.md + velocity.md).
103
+ valueBinding('acks-file', ACKS_FILE, ACKS_FILE, [RECOMMENDATIONS_DOC, VELOCITY_DOC]),
104
+ // The refresh read-only degrade outcome (REFRESH-EROFS-HONESTY / AD-056): the new skipped-readonly
105
+ // token must render in BOTH mode contracts that enumerate the placed-bridge refresh outcomes
106
+ // (setup.md owns --refresh-placed; upgrade.md pastes its lines) — a reworded doc dropping the
107
+ // outcome fails this pin plus the gate. The token tracks the exported SETUP constant.
108
+ valueBinding('refresh-skipped-readonly', SKIPPED_READONLY, SKIPPED_READONLY, [SETUP_DOC, UPGRADE_DOC]),
95
109
  ].map((b) => Object.freeze(b)));
96
110
 
97
111
  // ── the pure checker (readText is injectable for hermetic tests) ────────────────────────
package/tools/fs-safe.mjs CHANGED
@@ -17,7 +17,7 @@
17
17
 
18
18
  import {
19
19
  lstatSync, existsSync, mkdirSync, readdirSync, copyFileSync, readlinkSync, symlinkSync,
20
- rmSync, unlinkSync,
20
+ rmSync, unlinkSync, readFileSync,
21
21
  } from 'node:fs';
22
22
  import { dirname, join, resolve, relative, sep, isAbsolute } from 'node:path';
23
23
 
@@ -68,6 +68,27 @@ export const assertContainedRealPath = (root, dest, deps = {}) => {
68
68
  rel.split(sep).filter(Boolean).reduce(walk, root);
69
69
  };
70
70
 
71
+ // Read-only write-boundary tagging (REFRESH-EROFS-HONESTY / AD-056). A DESTINATION-side write failure
72
+ // of the read-only class is TAGGED (err.readonlyWriteBoundary) so the refresh-only driver can classify
73
+ // an equal-version repair-on-rerun that cannot write as a STATED skip — never a false red. The tag is
74
+ // applied ONLY around the three write primitives, so a READ-side failure (bundle read / dir listing)
75
+ // is never absorbed: the degrade classifies at the write boundary, not by a broad err.code sniff over
76
+ // the whole copy. EROFS is destination-side by nature; mkdir/symlink write only the dest; an
77
+ // EACCES/EPERM at copyFile (which also READS the source) is destination-provable ONLY when the source
78
+ // is readable — else it is a source-side read failure that must stay loud.
79
+ const READONLY_WRITE_ERRNOS = new Set(['EROFS', 'EACCES', 'EPERM']);
80
+ export const isReadonlyWriteBoundary = (err) => Boolean(err && err.readonlyWriteBoundary);
81
+ const throwTaggedReadonly = (err, primitive, src, readFile) => {
82
+ if (err && READONLY_WRITE_ERRNOS.has(err.code)) {
83
+ let destinationSide = err.code === 'EROFS' || primitive !== 'copyFile';
84
+ if (!destinationSide) {
85
+ try { readFile(src); destinationSide = true; } catch { destinationSide = false; }
86
+ }
87
+ if (destinationSide) err.readonlyWriteBoundary = true;
88
+ }
89
+ throw err;
90
+ };
91
+
71
92
  // Recursive refresh copy. Guards every dest via assertContainedRealPath first, then:
72
93
  // symlink src → additive: skip if dest exists, else mirror the link target.
73
94
  // directory src → mkdir -p dest, recurse.
@@ -80,20 +101,22 @@ export const copyTreeRefresh = (src, dest, root, deps = {}) => {
80
101
  const copyFile = deps.copyFile ?? copyFileSync;
81
102
  const readlink = deps.readlink ?? readlinkSync;
82
103
  const symlink = deps.symlink ?? symlinkSync;
104
+ const readFile = deps.readFile ?? readFileSync;
83
105
 
84
106
  assertContainedRealPath(root, dest, deps);
85
107
  const stat = lstat(src);
86
108
  if (stat.isSymbolicLink()) {
87
109
  if (exists(dest)) return;
88
- symlink(readlink(src), dest);
110
+ const target = readlink(src); // read-side (a readlink failure is never tagged as a write boundary)
111
+ try { symlink(target, dest); } catch (err) { throwTaggedReadonly(err, 'symlink', src, readFile); }
89
112
  } else if (stat.isDirectory()) {
90
- mkdir(dest);
113
+ try { mkdir(dest); } catch (err) { throwTaggedReadonly(err, 'mkdir', src, readFile); }
91
114
  for (const entry of readdir(src)) {
92
115
  copyTreeRefresh(join(src, entry), join(dest, entry), root, deps);
93
116
  }
94
117
  } else {
95
- mkdir(dirname(dest));
96
- copyFile(src, dest);
118
+ try { mkdir(dirname(dest)); } catch (err) { throwTaggedReadonly(err, 'mkdir', src, readFile); }
119
+ try { copyFile(src, dest); } catch (err) { throwTaggedReadonly(err, 'copyFile', src, readFile); }
97
120
  }
98
121
  };
99
122
 
@@ -31,8 +31,12 @@
31
31
  // preserved-customization discipline — no refresh-in-place, no marker/checksum machinery);
32
32
  // • never writes settings.local.json; never commits.
33
33
  //
34
+ // The `--read-lane` flag is a SEPARATE operation (AD-055 Part II): it enables the opt-in read-only
35
+ // compound lane in docs/ai/lanes.json after a currency check, and never touches settings or
36
+ // gates.json — see the read-lane writer section below.
37
+ //
34
38
  // Exit codes: 0 done / dry-run (incl. the report-only diverged-but-wired state); 1 precondition
35
- // STOP; 2 usage. Dependency-free beyond the kit's own velocity exports, Node >= 18. No side
39
+ // STOP; 2 usage. Dependency-free beyond the kit's own internal exports, Node >= 18. No side
36
40
  // effects on import.
37
41
 
38
42
  import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
@@ -49,8 +53,13 @@ import {
49
53
  readSettingsFile,
50
54
  resolveEffectiveMode,
51
55
  } from './velocity-profile.mjs';
56
+ import { assertDocsAiDeployment, writeDocsAiFileAtomic } from './atomic-write.mjs';
57
+ import { shellQuoteArg } from './review-state.mjs';
52
58
 
53
59
  const HERE = dirname(fileURLToPath(import.meta.url));
60
+ // This tool's own absolute path — the recovery / apply one-liners the read-lane writer prints.
61
+ export const GATE_HOOK_TOOL = fileURLToPath(import.meta.url);
62
+ const q = shellQuoteArg;
54
63
 
55
64
  export const BUNDLED_HOOK_PATH = resolve(HERE, '..', 'references', 'hooks', 'gate-approve.mjs');
56
65
  export const HOOKS_DIR = '.claude/hooks';
@@ -68,6 +77,11 @@ export const GATE_HOOK_UNSAFE_MODE = 'GATE_HOOK_UNSAFE_MODE';
68
77
  export const GATE_HOOK_MALFORMED = 'GATE_HOOK_MALFORMED';
69
78
  export const GATE_HOOK_DIVERGED = 'GATE_HOOK_DIVERGED';
70
79
  export const GATE_HOOK_BUNDLE = 'GATE_HOOK_BUNDLE';
80
+ // --read-lane (AD-055 Part II): the opt-in read-only compound lane toggle + its currency guard.
81
+ export const LANES_REL = 'docs/ai/lanes.json';
82
+ export const READ_LANE_KEY = 'readLane';
83
+ export const GATE_HOOK_STALE = 'GATE_HOOK_STALE'; // the placed hook is absent/stale — the lane cannot fire
84
+ export const GATE_HOOK_LANE = 'GATE_HOOK_LANE'; // a lanes.json write refusal (deployment/malformed)
71
85
 
72
86
  const EXIT_OK = 0;
73
87
  const EXIT_PRECONDITION = 1;
@@ -84,12 +98,21 @@ const TARGET_CURRENT = 'already-current';
84
98
  const TARGET_DIVERGED = 'diverged';
85
99
 
86
100
  const USAGE = `usage: gate-hook [--dry-run | --apply] [--cwd <dir>] [--help]
101
+ gate-hook --read-lane [--dry-run | --apply] [--cwd <dir>] (enable the opt-in read-only compound lane)
87
102
 
88
103
  Places the bundled PreToolUse gate-approval hook to ${HOOK_FILE_REL} and wires ONE
89
104
  PreToolUse "Bash" entry into ${SETTINGS_FILE}. Default is --dry-run (a preview; writes
90
105
  nothing). --apply writes. The hook auto-approves byte-exact declared gate cmds
91
106
  (docs/ai/gates.json, project root) and asks on seeded-read-only commands carrying the
92
- documented runtime residual. Never writes ${SETTINGS_LOCAL_FILE}; never commits.`;
107
+ documented runtime residual.
108
+
109
+ --read-lane enables the opt-in read-only COMPOUND lane in ${LANES_REL} ("${READ_LANE_KEY}": true) — the
110
+ hook then auto-approves compounds of the seeded read-only core carrying zero shell metaprogramming.
111
+ --apply --read-lane verifies the PLACED hook is the CURRENT bundle first (a pre-1.48 hook never reads
112
+ ${LANES_REL}) and refuses with the delete-to-reseed recovery otherwise. Never touches ${SETTINGS_FILE}
113
+ or docs/ai/gates.json.
114
+
115
+ Never writes ${SETTINGS_LOCAL_FILE}; never commits.`;
93
116
 
94
117
  export const fail = (exitCode, message) => Object.assign(new Error(message), { exitCode });
95
118
 
@@ -307,6 +330,166 @@ export const writeGateHook = ({ cwd, dryRun = true } = {}, deps = {}) => {
307
330
  return { wrote: placePlanned || wirePlanned, dryRun: false, ...base };
308
331
  };
309
332
 
333
+ // ── the read-lane writer (--read-lane; AD-055 Part II, Decisions 5/9) ──────────────────
334
+ // Enables the opt-in read-only COMPOUND lane by writing { "readLane": true } into the kit-owned
335
+ // docs/ai/lanes.json (a SEPARATE file — gates.json is never touched). Same family writer discipline
336
+ // as the base flow + ack-write (preview-then-mutate, deployment-gated, create-if-absent,
337
+ // merge-preserve, symlink/non-regular refusal, malformed-JSON fail-closed, atomic), PLUS a CURRENCY
338
+ // CHECK (Decisions 9): --apply refuses unless the PLACED hook is byte-identical to the current
339
+ // bundle — enabling a lane a pre-1.48 hook can never read would be a silent no-op the user paid
340
+ // consent for. The refusal names the delete-to-reseed recovery.
341
+
342
+ const laneStop = (message) => makeGateHookError(GATE_HOOK_LANE, message);
343
+
344
+ // Read the existing lanes.json (already gated as a regular file). ENOENT (a TOCTOU vanish) → absent
345
+ // {}; malformed JSON / a non-object root FAILS CLOSED — never overwrite an unparseable toggle file.
346
+ const readExistingLanes = (absPath, deps) => {
347
+ const readFile = deps.readFile ?? readFileSync;
348
+ let text;
349
+ try {
350
+ text = readFile(absPath, UTF8);
351
+ } catch (err) {
352
+ if (err?.code === 'ENOENT') return {};
353
+ throw laneStop(`cannot read ${LANES_REL} (${err?.code ?? err?.message}) — refusing to overwrite it`);
354
+ }
355
+ let parsed;
356
+ try {
357
+ parsed = JSON.parse(text);
358
+ } catch {
359
+ throw laneStop(`${LANES_REL} is not valid JSON — refusing to overwrite it (fix or delete it, then re-run)`);
360
+ }
361
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
362
+ throw laneStop(`${LANES_REL} is not a JSON object — refusing to overwrite it`);
363
+ }
364
+ return parsed;
365
+ };
366
+
367
+ // Target gate: a symlinked / non-regular lanes.json is a STOP checked BEFORE any read (a FIFO can
368
+ // never block the reader). Returns { existed, existing } — the merge base.
369
+ const preflightLaneTarget = (absPath, deps) => {
370
+ const fs = fsDeps(deps);
371
+ const st = lstatNoFollow(absPath, fs);
372
+ if (st === null) return { existed: false, existing: {} };
373
+ if (st.isSymbolicLink()) throw makeGateHookError(GATE_HOOK_SYMLINK, `${LANES_REL} is a symlink — refusing to write through it`);
374
+ if (!st.isFile()) throw makeGateHookError(GATE_HOOK_SYMLINK, `${LANES_REL} exists but is not a regular file — refusing to touch it`);
375
+ return { existed: true, existing: readExistingLanes(absPath, deps) };
376
+ };
377
+
378
+ export const applyGateHookCommand = (root) => `node ${q(GATE_HOOK_TOOL)} --apply --cwd ${q(root)}`;
379
+ export const applyReadLaneCommand = (root) => `node ${q(GATE_HOOK_TOOL)} --read-lane --cwd ${q(root)} --apply`;
380
+
381
+ // The currency check (Decisions 9): the PLACED hook must be byte-identical to the current bundle,
382
+ // else enabling the lane is a no-op. Returns { current, reason, refusal? }; a symlinked / non-regular
383
+ // placed hook is a hard STOP.
384
+ const checkHookCurrency = (root, bundleContent, fs) => {
385
+ const hookAbs = join(root, HOOK_FILE_REL);
386
+ const st = lstatNoFollow(hookAbs, fs);
387
+ if (st === null) {
388
+ return {
389
+ current: false,
390
+ reason: `${HOOK_FILE_REL} is not placed`,
391
+ refusal: `${HOOK_FILE_REL} is not placed — the read-lane cannot fire. Place the hook first:\n ${applyGateHookCommand(root)}\nthen re-run with --read-lane.`,
392
+ };
393
+ }
394
+ if (st.isSymbolicLink() || !st.isFile()) {
395
+ throw makeGateHookError(GATE_HOOK_SYMLINK, `${HOOK_FILE_REL} is not a regular file — refusing to touch it`);
396
+ }
397
+ if (fs.readFile(hookAbs, UTF8) !== bundleContent) {
398
+ return {
399
+ current: false,
400
+ reason: `${HOOK_FILE_REL} is not the current bundle`,
401
+ refusal: `${HOOK_FILE_REL} is NOT the current bundle — enabling the read-lane now would be a silent no-op (a pre-1.48 hook never reads ${LANES_REL}). Reseed it, then re-run with --read-lane:\n rm ${q(join(root, HOOK_FILE_REL))}\n ${applyGateHookCommand(root)}`,
402
+ };
403
+ }
404
+ return { current: true, reason: 'the placed hook is the current bundle' };
405
+ };
406
+
407
+ // Pure preflight (both dry-run and apply): deployment gate + the merge over the existing toggle file.
408
+ export const planReadLane = ({ cwd }, deps = {}) => {
409
+ const root = resolve(cwd);
410
+ assertDocsAiDeployment(root, deps, { stop: laneStop, noun: 'the read-lane toggle', rel: LANES_REL });
411
+ const absPath = join(root, LANES_REL);
412
+ const { existed, existing } = preflightLaneTarget(absPath, deps);
413
+ const already = existing[READ_LANE_KEY] === true;
414
+ const merged = { ...existing, [READ_LANE_KEY]: true };
415
+ const otherKeys = Object.keys(existing).filter((k) => k !== READ_LANE_KEY);
416
+ return { root, absPath, existed, existing, merged, already, otherKeys };
417
+ };
418
+
419
+ // A byte-current hook that is not WIRED never fires — enabling the lane against it is the same
420
+ // silent no-op the currency check guards (council B5). Wired-detection reuses the writer's own
421
+ // isHookWired over BOTH settings scopes (the hooks contract merges both).
422
+ const checkHookWired = (root, deps) => {
423
+ const project = readSettingsFile(join(root, SETTINGS_FILE), { ...deps, cwd: root });
424
+ const local = readSettingsFile(join(root, SETTINGS_LOCAL_FILE), { ...deps, cwd: root });
425
+ return isHookWired(project.data) || isHookWired(local.data);
426
+ };
427
+
428
+ export const writeReadLane = ({ cwd, dryRun = true } = {}, deps = {}) => {
429
+ const fs = fsDeps(deps);
430
+ const bundleContent = readBundledHook(deps);
431
+ const plan = planReadLane({ cwd: cwd ?? deps.cwd ?? process.cwd() }, deps);
432
+ const currency = checkHookCurrency(plan.root, bundleContent, fs);
433
+ const wired = checkHookWired(plan.root, deps);
434
+ const stamp = readStamp(join(plan.root, WORKFLOW_STAMP), fs);
435
+ const stampOk = stamp === EXPECTED_WORKFLOW_VERSION;
436
+ if (dryRun) return { wrote: false, dryRun: true, currency, wired, stamp, stampOk, ...plan };
437
+ if (!currency.current) throw makeGateHookError(GATE_HOOK_STALE, currency.refusal);
438
+ if (!wired) {
439
+ throw makeGateHookError(
440
+ GATE_HOOK_STALE,
441
+ `${HOOK_FILE_REL} is placed and current but NOT wired into ${SETTINGS_FILE} — the read-lane cannot fire. Wire it first:\n ${applyGateHookCommand(plan.root)}\nthen re-run with --read-lane.`,
442
+ );
443
+ }
444
+ if (!stampOk) {
445
+ throw makeGateHookError(
446
+ GATE_HOOK_STAMP,
447
+ `not a deployed agent-workflow project at lineage ${EXPECTED_WORKFLOW_VERSION} (found ${stamp ?? 'none'}) — run init/upgrade first`,
448
+ );
449
+ }
450
+ if (plan.already) return { wrote: false, dryRun: false, currency, wired, stamp, stampOk, ...plan };
451
+ const body = `${JSON.stringify(plan.merged, null, SETTINGS_JSON_INDENT)}\n`;
452
+ writeDocsAiFileAtomic(plan.root, LANES_REL, body, deps, { stop: laneStop, noun: 'the read-lane toggle' });
453
+ return { wrote: true, dryRun: false, currency, wired, stamp, stampOk, ...plan };
454
+ };
455
+
456
+ const READ_LANE_POSTURE =
457
+ "posture: an auto-allowed read chain runs UNATTENDED and can read any file you can — the SAME trust boundary as the AUDITED read-only core velocity seeds, extended to COMPOUNDS (and singles) of that core. The subset invariant holds against that AUDITED CORE (every segment is an audited-core read), so the lane is a standalone opt-in grant BOUNDED BY the audited core — never a command outside it; it auto-approves those core commands REGARDLESS of which you seeded as individual settings rules (not strictly a subset of your current settings). This consent is a PROJECT-PERSISTENT declaration in docs/ai/lanes.json — it applies to every future session and to subagents' Bash, and where docs/ai is committed, to every checkout. It bypasses the PROMPT only, never the OS sandbox; on engine builds where a hook allow supersedes an ask rule, the opt-in covers EXACTLY the audited read-only core — nothing else.";
458
+
459
+ export const formatReadLaneResult = (result) => {
460
+ const merge = result.otherKeys.length > 0 ? ` (merge-preserving ${result.otherKeys.length} existing key(s))` : '';
461
+ if (!result.dryRun) {
462
+ if (result.already) {
463
+ return `agent-workflow read-lane — APPLY: ${LANES_REL} already enables the read-lane — nothing to do (hook currency verified).`;
464
+ }
465
+ return [
466
+ 'agent-workflow read-lane — APPLY',
467
+ ` - ${LANES_REL}: "${READ_LANE_KEY}" = true${merge}`,
468
+ ' - hook currency verified: the placed hook is the current bundle — the lane is active for new Bash calls.',
469
+ ].join(LF);
470
+ }
471
+ const lines = [
472
+ result.already
473
+ ? `agent-workflow read-lane — DRY RUN: ${LANES_REL} already enables the read-lane ("${READ_LANE_KEY}": true) — nothing to do.`
474
+ : 'agent-workflow read-lane — DRY RUN (no changes; re-run with --apply --read-lane)',
475
+ ];
476
+ if (!result.already) lines.push(` - would ${result.existed ? 'set' : 'create'} ${LANES_REL} "${READ_LANE_KEY}" = true${merge}`);
477
+ lines.push(
478
+ result.currency.current
479
+ ? ' hook currency: current — the lane will fire once enabled.'
480
+ : ` hook currency: STALE — ${result.currency.reason}; --apply will REFUSE until you reseed the placed hook.`,
481
+ );
482
+ if (!result.wired) {
483
+ lines.push(` hook wiring: NOT wired into ${SETTINGS_FILE}; --apply will REFUSE until you wire it (${applyGateHookCommand(result.root)}).`);
484
+ }
485
+ if (!result.stampOk) {
486
+ lines.push(` deployment stamp: ${result.stamp ?? 'none'} ≠ lineage head ${EXPECTED_WORKFLOW_VERSION}; --apply will REFUSE until init/upgrade runs.`);
487
+ }
488
+ lines.push(READ_LANE_POSTURE);
489
+ if (!result.already) lines.push(` to apply: ${applyReadLaneCommand(result.root)}`);
490
+ return lines.join(LF);
491
+ };
492
+
310
493
  // ── report ────────────────────────────────────────────────────────────────────────────
311
494
 
312
495
  const TRUST_POSTURE_LINE =
@@ -346,12 +529,13 @@ export const formatResult = (result) => {
346
529
  // ── CLI ───────────────────────────────────────────────────────────────────────────────
347
530
 
348
531
  export const parseArgs = (argv) => {
349
- const opts = { dryRunFlag: false, apply: false, cwd: undefined, help: false };
532
+ const opts = { dryRunFlag: false, apply: false, cwd: undefined, help: false, readLane: false };
350
533
  for (let i = 0; i < argv.length; i += 1) {
351
534
  const arg = argv[i];
352
535
  if (arg === '--help' || arg === '-h') opts.help = true;
353
536
  else if (arg === '--dry-run') opts.dryRunFlag = true;
354
537
  else if (arg === '--apply') opts.apply = true;
538
+ else if (arg === '--read-lane') opts.readLane = true;
355
539
  else if (arg === '--cwd') {
356
540
  i += 1;
357
541
  if (argv[i] === undefined || argv[i].startsWith('-')) throw fail(EXIT_USAGE, '--cwd needs a directory argument');
@@ -361,7 +545,7 @@ export const parseArgs = (argv) => {
361
545
  }
362
546
  }
363
547
  if (opts.dryRunFlag && opts.apply) throw fail(EXIT_USAGE, '--dry-run and --apply cannot be used together');
364
- return { help: opts.help, dryRun: !opts.apply, cwd: opts.cwd };
548
+ return { help: opts.help, dryRun: !opts.apply, cwd: opts.cwd, readLane: opts.readLane };
365
549
  };
366
550
 
367
551
  export const main = (argv = process.argv.slice(2), deps = {}) => {
@@ -373,6 +557,11 @@ export const main = (argv = process.argv.slice(2), deps = {}) => {
373
557
  log(USAGE);
374
558
  return EXIT_OK;
375
559
  }
560
+ if (args.readLane) {
561
+ const laneResult = writeReadLane({ cwd: args.cwd ?? deps.cwd ?? process.cwd(), dryRun: args.dryRun }, deps);
562
+ log(formatReadLaneResult(laneResult));
563
+ return EXIT_OK;
564
+ }
376
565
  const result = writeGateHook({ cwd: args.cwd ?? deps.cwd ?? process.cwd(), dryRun: args.dryRun }, deps);
377
566
  log(formatResult(result));
378
567
  return EXIT_OK;