@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.
- package/CHANGELOG.md +69 -0
- package/README.md +2 -2
- package/SKILL.md +1 -1
- package/bridges/antigravity-cli-bridge/SKILL.md +1 -1
- package/bridges/antigravity-cli-bridge/bin/agy-review.sh +21 -3
- package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +3 -2
- package/bridges/antigravity-cli-bridge/bin/agy.sh +20 -2
- package/bridges/antigravity-cli-bridge/bin/agy.test.mjs +3 -2
- package/bridges/antigravity-cli-bridge/capability.json +1 -1
- package/bridges/codex-cli-bridge/SKILL.md +1 -1
- package/bridges/codex-cli-bridge/bin/codex-exec.sh +20 -2
- package/bridges/codex-cli-bridge/bin/codex-exec.test.mjs +3 -2
- package/bridges/codex-cli-bridge/bin/codex-review.sh +21 -3
- package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +3 -2
- package/bridges/codex-cli-bridge/capability.json +1 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/agents/changelog-skeleton.md +1 -0
- package/references/agents/gate-triage.md +1 -0
- package/references/agents/mechanical-sweep.md +1 -0
- package/references/hooks/gate-approve.mjs +120 -18
- package/references/modes/hook.md +6 -1
- package/references/modes/recommendations.md +8 -6
- package/references/modes/setup.md +7 -2
- package/references/modes/upgrade.md +9 -9
- package/references/modes/velocity.md +4 -2
- package/tools/ack-write.mjs +186 -0
- package/tools/doc-parity.mjs +14 -0
- package/tools/fs-safe.mjs +28 -5
- package/tools/gate-hook.mjs +193 -4
- package/tools/recommendations.mjs +171 -17
- package/tools/setup-backends.mjs +48 -3
- package/tools/velocity-profile.mjs +13 -2
package/tools/doc-parity.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/tools/gate-hook.mjs
CHANGED
|
@@ -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
|
|
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.
|
|
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;
|