@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.
- package/CHANGELOG.md +42 -0
- package/README.md +2 -2
- package/SKILL.md +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/velocity.md +4 -2
- package/tools/ack-write.mjs +186 -0
- package/tools/doc-parity.mjs +7 -0
- package/tools/gate-hook.mjs +193 -4
- package/tools/recommendations.mjs +171 -17
- package/tools/velocity-profile.mjs +13 -2
|
@@ -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)));
|
package/tools/doc-parity.mjs
CHANGED
|
@@ -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) ────────────────────────
|
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;
|