@sabaiway/agent-workflow-kit 1.47.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.
@@ -0,0 +1,186 @@
1
+ #!/usr/bin/env node
2
+ // ack-write.mjs — the consent-gated writer for the family-owned neutral ack store
3
+ // (docs/ai/acks.json, AD-055 Part I). The upgrade Recommendations `sandbox-lane` item renders THIS
4
+ // tool's PREVIEW one-liner; the preview prints the exact `--apply` command; the agent runs `--apply`
5
+ // only after the mode doc's §3 informed-consent confirmation. It records a NEUTRAL recipe fingerprint
6
+ // acknowledgement — never a security key; the kit never writes sandbox network/filesystem allowances.
7
+ //
8
+ // Family writer discipline (velocity / orchestration-write / gate-hook), verbatim:
9
+ // • preview-then-mutate — `--dry-run` is the DEFAULT and writes nothing; `--apply` writes;
10
+ // • deployment-gated — REFUSES an absent docs/ai with a named recovery pointer (run init/bootstrap);
11
+ // • creates docs/ai/acks.json (and nothing else) if absent;
12
+ // • merge-preserve — every existing key (a hand-authored `_README`, future sibling acks) is kept;
13
+ // only the `sandboxLaneAck` key is set;
14
+ // • symlink / non-regular target → STOP (never write through a link or clobber a device/dir);
15
+ // • fail-closed on malformed existing JSON (never overwrite an unparseable store);
16
+ // • atomic — exclusive-create *.tmp + rename (no partial-write state); last-writer-wins;
17
+ // • never commits.
18
+ //
19
+ // Dependency-free beyond the kit's own atomic-write core + the shared ack constants, Node >= 18. No
20
+ // side effects on import (the isDirectRun idiom).
21
+
22
+ import { lstatSync, readFileSync } from 'node:fs';
23
+ import { dirname, join, resolve } from 'node:path';
24
+ import { fileURLToPath, pathToFileURL } from 'node:url';
25
+ import { ACKS_FILE, ACKS_LANE_KEY } from './recommendations.mjs';
26
+ import { assertDocsAiDeployment, writeDocsAiFileAtomic, lstatNoFollow } from './atomic-write.mjs';
27
+ import { shellQuoteArg } from './review-state.mjs';
28
+
29
+ const HERE = dirname(fileURLToPath(import.meta.url));
30
+ export const ACK_WRITE_TOOL = join(HERE, 'ack-write.mjs');
31
+
32
+ const ERROR_PREFIX = '[agent-workflow-kit]';
33
+ const EXIT_OK = 0;
34
+ const EXIT_PRECONDITION = 1;
35
+ const EXIT_USAGE = 2;
36
+ const JSON_INDENT = 2;
37
+ // The recipeFingerprint shape (recommendations.mjs: sha256 hex sliced to 16) — a fail-closed guard so
38
+ // the store never records a malformed or injected value.
39
+ export const FINGERPRINT_PATTERN = /^[0-9a-f]{16}$/u;
40
+
41
+ export const ACK_WRITE_STOP = 'ACK_WRITE_STOP';
42
+ export const stop = (message) =>
43
+ Object.assign(new Error(`${ERROR_PREFIX} ${message}`), { name: 'AckWriteStop', code: ACK_WRITE_STOP, exitCode: EXIT_PRECONDITION });
44
+ const usageFail = (message) => Object.assign(new Error(message), { exitCode: EXIT_USAGE });
45
+
46
+ const q = shellQuoteArg;
47
+
48
+ // Read + parse the existing store (already known to be a regular file). ENOENT (a TOCTOU vanish) is
49
+ // treated as absent; malformed JSON / a non-object root FAILS CLOSED — never overwrite an unparseable
50
+ // store.
51
+ const readExistingStore = (absPath, deps) => {
52
+ const readFile = deps.readFile ?? readFileSync;
53
+ let text;
54
+ try {
55
+ text = readFile(absPath, 'utf8');
56
+ } catch (err) {
57
+ if (err?.code === 'ENOENT') return {};
58
+ throw stop(`cannot read ${ACKS_FILE} (${err?.code ?? err?.message}) — refusing to overwrite it`);
59
+ }
60
+ let parsed;
61
+ try {
62
+ parsed = JSON.parse(text);
63
+ } catch {
64
+ throw stop(`${ACKS_FILE} is not valid JSON — refusing to overwrite it (fix or delete it, then re-run)`);
65
+ }
66
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
67
+ throw stop(`${ACKS_FILE} is not a JSON object — refusing to overwrite it`);
68
+ }
69
+ return parsed;
70
+ };
71
+
72
+ // Target gate: a symlinked / non-regular acks.json is a STOP (checked BEFORE any read so a FIFO can
73
+ // never block the reader). Returns { existed, existing } — the merge base.
74
+ const preflightTarget = (absPath, deps) => {
75
+ const lstat = deps.lstat ?? lstatSync;
76
+ const st = lstatNoFollow(absPath, lstat);
77
+ if (st === null) return { existed: false, existing: {} };
78
+ if (st.isSymbolicLink()) throw stop(`${ACKS_FILE} is a symlink — refusing to write through it`);
79
+ if (!st.isFile()) throw stop(`${ACKS_FILE} exists but is not a regular file — refusing to touch it`);
80
+ return { existed: true, existing: readExistingStore(absPath, deps) };
81
+ };
82
+
83
+ // Pure preflight (both dry-run and apply). Validates the fingerprint, refuses an absent deployment,
84
+ // gates the target, and computes the merge — no writes.
85
+ export const planAckWrite = ({ cwd, fingerprint }, deps = {}) => {
86
+ // typeof BEFORE RegExp.test — .test() coerces its arg to a string, so a number or a single-element
87
+ // array of 16 hex chars would otherwise pass the guard and be written as a non-string ack the
88
+ // reader then ignores.
89
+ if (typeof fingerprint !== 'string' || !FINGERPRINT_PATTERN.test(fingerprint)) {
90
+ throw usageFail(`--fingerprint must be a 16-char lowercase hex fingerprint (got ${JSON.stringify(fingerprint ?? null)})`);
91
+ }
92
+ const root = resolve(cwd);
93
+ assertDocsAiDeployment(root, deps, { stop, noun: 'the neutral sandbox-lane ack', rel: ACKS_FILE });
94
+ const absPath = join(root, ACKS_FILE);
95
+ const { existed, existing } = preflightTarget(absPath, deps);
96
+ const alreadyAcked = existing[ACKS_LANE_KEY] === fingerprint;
97
+ const merged = { ...existing, [ACKS_LANE_KEY]: fingerprint };
98
+ const otherKeys = Object.keys(existing).filter((k) => k !== ACKS_LANE_KEY);
99
+ return { root, absPath, fingerprint, existed, existing, merged, alreadyAcked, otherKeys };
100
+ };
101
+
102
+ export const writeAck = ({ cwd, fingerprint, dryRun = true } = {}, deps = {}) => {
103
+ const plan = planAckWrite({ cwd, fingerprint }, deps);
104
+ if (dryRun) return { wrote: false, dryRun: true, ...plan };
105
+ const body = `${JSON.stringify(plan.merged, null, JSON_INDENT)}\n`;
106
+ writeDocsAiFileAtomic(plan.root, ACKS_FILE, body, deps, { stop, noun: 'the neutral sandbox-lane ack' });
107
+ return { wrote: true, dryRun: false, ...plan };
108
+ };
109
+
110
+ // ── report ──────────────────────────────────────────────────────────────────────────────
111
+ export const applyCommand = (root, fingerprint) =>
112
+ `node ${q(ACK_WRITE_TOOL)} --fingerprint ${fingerprint} --cwd ${q(root)} --apply`;
113
+
114
+ export const formatResult = (result) => {
115
+ const merge = result.otherKeys.length > 0 ? ` (merge-preserving ${result.otherKeys.length} existing key(s))` : '';
116
+ if (result.dryRun) {
117
+ if (result.alreadyAcked) {
118
+ return [
119
+ `agent-workflow ack — DRY RUN: ${ACKS_FILE} already records this recipe fingerprint (${result.fingerprint}) — nothing to do.`,
120
+ ].join('\n');
121
+ }
122
+ return [
123
+ `agent-workflow ack — DRY RUN (no changes; re-run with --apply)`,
124
+ ` - would ${result.existed ? 'set' : 'create'} ${ACKS_FILE} "${ACKS_LANE_KEY}" = "${result.fingerprint}"${merge}`,
125
+ ` - this is a NEUTRAL recipe acknowledgement, never a security key.`,
126
+ ` to apply: ${applyCommand(result.root, result.fingerprint)}`,
127
+ ].join('\n');
128
+ }
129
+ return [
130
+ `agent-workflow ack — APPLY`,
131
+ ` - ${ACKS_FILE}: "${ACKS_LANE_KEY}" = "${result.fingerprint}"${merge}`,
132
+ ].join('\n');
133
+ };
134
+
135
+ // ── CLI ─────────────────────────────────────────────────────────────────────────────────
136
+ const USAGE = `usage: ack-write --fingerprint <16-hex> [--dry-run | --apply] [--cwd <dir>] [--help]
137
+
138
+ Records the NEUTRAL sandbox-lane recipe acknowledgement into ${ACKS_FILE} (the family-owned ack
139
+ store — no host settings validator guards it). Default is --dry-run (a preview; writes nothing) and
140
+ prints the exact --apply command. --apply merges "${ACKS_LANE_KEY}" into ${ACKS_FILE}, preserving
141
+ every existing key. Refuses an absent docs/ai deployment; never a security key; never commits.`;
142
+
143
+ export const parseArgs = (argv) => {
144
+ const opts = { fingerprint: undefined, dryRunFlag: false, apply: false, cwd: undefined, help: false };
145
+ for (let i = 0; i < argv.length; i += 1) {
146
+ const arg = argv[i];
147
+ if (arg === '--help' || arg === '-h') opts.help = true;
148
+ else if (arg === '--dry-run') opts.dryRunFlag = true;
149
+ else if (arg === '--apply') opts.apply = true;
150
+ else if (arg === '--fingerprint') {
151
+ i += 1;
152
+ if (argv[i] === undefined || argv[i].startsWith('-')) throw usageFail('--fingerprint needs a value');
153
+ opts.fingerprint = argv[i];
154
+ } else if (arg === '--cwd') {
155
+ i += 1;
156
+ if (argv[i] === undefined || argv[i].startsWith('-')) throw usageFail('--cwd needs a directory argument');
157
+ opts.cwd = argv[i];
158
+ } else {
159
+ throw usageFail(`unknown argument: ${arg}`);
160
+ }
161
+ }
162
+ if (opts.dryRunFlag && opts.apply) throw usageFail('--dry-run and --apply cannot be used together');
163
+ return { help: opts.help, fingerprint: opts.fingerprint, dryRun: !opts.apply, cwd: opts.cwd };
164
+ };
165
+
166
+ export const main = (argv = process.argv.slice(2), deps = {}) => {
167
+ const log = deps.log ?? console.log;
168
+ const errlog = deps.errlog ?? console.error;
169
+ try {
170
+ const args = parseArgs(argv);
171
+ if (args.help) {
172
+ log(USAGE);
173
+ return EXIT_OK;
174
+ }
175
+ const result = writeAck({ cwd: args.cwd ?? deps.cwd ?? process.cwd(), fingerprint: args.fingerprint, dryRun: args.dryRun }, deps);
176
+ log(formatResult(result));
177
+ return EXIT_OK;
178
+ } catch (err) {
179
+ errlog(err?.message ?? String(err));
180
+ if (err?.exitCode === EXIT_USAGE) errlog(USAGE);
181
+ return err?.exitCode ?? EXIT_PRECONDITION;
182
+ }
183
+ };
184
+
185
+ const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
186
+ if (isDirectRun) process.exit(main(process.argv.slice(2)));
@@ -34,6 +34,7 @@ import {
34
34
  VERDICT_NOTHING_BROKEN,
35
35
  VERDICT_OPTIONAL_TEMPLATE,
36
36
  VERDICT_SKIPS_TEMPLATE,
37
+ ACKS_FILE,
37
38
  } from './recommendations.mjs';
38
39
 
39
40
  const KIT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
@@ -43,6 +44,7 @@ const FOLD_DOC = 'references/modes/fold-completeness.md';
43
44
  const AUTONOMY_DOCTOR_DOC = 'references/modes/autonomy-doctor.md';
44
45
  const RECOMMENDATIONS_DOC = 'references/modes/recommendations.md';
45
46
  const UPGRADE_DOC = 'references/modes/upgrade.md';
47
+ const VELOCITY_DOC = 'references/modes/velocity.md';
46
48
 
47
49
  // A typed usage failure (exit 2) for the CLI parser — the codebase's typed-error idiom (no classes).
48
50
  const usageFail = (message) => Object.assign(new Error(message), { exitCode: 2 });
@@ -92,6 +94,11 @@ export const BINDINGS = Object.freeze([
92
94
  valueBinding('verdict-nothing-broken', VERDICT_NOTHING_BROKEN, VERDICT_NOTHING_BROKEN, [RECOMMENDATIONS_DOC, UPGRADE_DOC]),
93
95
  valueBinding('verdict-optional', VERDICT_OPTIONAL_TEMPLATE, VERDICT_OPTIONAL_TEMPLATE, [RECOMMENDATIONS_DOC, UPGRADE_DOC]),
94
96
  valueBinding('verdict-skips', VERDICT_SKIPS_TEMPLATE, VERDICT_SKIPS_TEMPLATE, [RECOMMENDATIONS_DOC, UPGRADE_DOC]),
97
+ // The ack-store apply target (AD-055 Part I): the family-owned docs/ai/acks.json path — a
98
+ // drift-guarded constant so the mode docs' ack-store references cannot silently outdate the code
99
+ // (the incident's "mode-doc apply text stays in lockstep" acceptance as a mechanism, not prose).
100
+ // Bound in BOTH docs that name the path (recommendations.md + velocity.md).
101
+ valueBinding('acks-file', ACKS_FILE, ACKS_FILE, [RECOMMENDATIONS_DOC, VELOCITY_DOC]),
95
102
  ].map((b) => Object.freeze(b)));
96
103
 
97
104
  // ── the pure checker (readText is injectable for hermetic tests) ────────────────────────
@@ -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;