@sabaiway/agent-workflow-kit 1.28.0 → 1.30.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,387 @@
1
+ #!/usr/bin/env node
2
+ // gate-hook.mjs — the onboarding writer behind `/agent-workflow-kit hook`: places the bundled
3
+ // PreToolUse gate-approval hook runtime (references/hooks/gate-approve.mjs) to the project's
4
+ // .claude/hooks/agent-workflow-gates.mjs and wires ONE `PreToolUse` "Bash" entry into
5
+ // .claude/settings.json. The hook then auto-approves byte-exact invocations of the gates
6
+ // declared in docs/ai/gates.json (run from the project root) and asks on seeded-read-only
7
+ // commands carrying the documented runtime residual — see the runtime's own header.
8
+ //
9
+ // A separate SIBLING of velocity, deliberately NOT a velocity flag: velocity's test-pinned
10
+ // invariant is "writes ONLY .claude/settings.json", while this writer also places a file. It
11
+ // REUSES the exported velocity machinery (readSettingsFile / resolveEffectiveMode) rather than
12
+ // re-deriving it, and follows the family writer discipline verbatim:
13
+ // • preview-then-mutate — `--dry-run` is the DEFAULT and writes nothing; `--apply` writes;
14
+ // • deployment-gated — `--apply` STOPs unless docs/ai/.workflow-version equals the lineage
15
+ // head (a dry-run stays usable on any project);
16
+ // • symlink-safe — a symlinked `.claude` / `.claude/hooks` / target file / settings.json is
17
+ // a STOP on BOTH dry-run and apply (a dry-run never promises a write the apply refuses);
18
+ // • refuses `bypassPermissions` / unsafe modes in EITHER settings file;
19
+ // • merge-don't-clobber — foreign hooks/matchers/keys and existing permissions are preserved
20
+ // semantically; our entry is added idempotently (re-apply never duplicates). The reused
21
+ // readSettingsFile validates `permissions.*` ONLY, so this writer adds its OWN strict shape
22
+ // precondition on any existing `hooks` key — a malformed `hooks`/`hooks.PreToolUse[]` shape
23
+ // is a STOP with ZERO writes, never a merge-through-clobber;
24
+ // • place-then-wire ORDER — the hook file is placed BEFORE settings are wired (a
25
+ // wired-but-missing hook would error on every Bash call);
26
+ // • a target hook file with DIFFERENT content while our entry is NOT wired is a precondition
27
+ // STOP (no file write, no settings mutation — wiring an unknown script as a PreToolUse hook
28
+ // is exactly what consent must not slide past); the recovery is named: delete the file to
29
+ // reseed from the bundle. An identical file is `already current`; an already-wired entry
30
+ // pointing at a diverged file is REPORTED, never unwired or clobbered (the cheap-agents
31
+ // preserved-customization discipline — no refresh-in-place, no marker/checksum machinery);
32
+ // • never writes settings.local.json; never commits.
33
+ //
34
+ // 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
36
+ // effects on import.
37
+
38
+ import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
39
+ import { dirname, join, resolve } from 'node:path';
40
+ import { fileURLToPath, pathToFileURL } from 'node:url';
41
+ import {
42
+ CLAUDE_DIR,
43
+ EXPECTED_WORKFLOW_VERSION,
44
+ SETTINGS_FILE,
45
+ SETTINGS_LOCAL_FILE,
46
+ UNSAFE_BYPASS_MODE,
47
+ SAFE_DEFAULT_MODES,
48
+ WORKFLOW_STAMP,
49
+ readSettingsFile,
50
+ resolveEffectiveMode,
51
+ } from './velocity-profile.mjs';
52
+
53
+ const HERE = dirname(fileURLToPath(import.meta.url));
54
+
55
+ export const BUNDLED_HOOK_PATH = resolve(HERE, '..', 'references', 'hooks', 'gate-approve.mjs');
56
+ export const HOOKS_DIR = '.claude/hooks';
57
+ export const HOOK_FILE_REL = `${HOOKS_DIR}/agent-workflow-gates.mjs`;
58
+ export const PRE_TOOL_USE_EVENT = 'PreToolUse';
59
+ export const HOOK_MATCHER = 'Bash';
60
+ // The literal settings fragment (the plan/test fixture): $CLAUDE_PROJECT_DIR is resolved by
61
+ // Claude Code when it runs the hook command, so the entry survives project relocation.
62
+ export const HOOK_COMMAND = 'node "$CLAUDE_PROJECT_DIR/.claude/hooks/agent-workflow-gates.mjs"';
63
+ export const HOOK_TIMEOUT_SECONDS = 30;
64
+
65
+ export const GATE_HOOK_STAMP = 'GATE_HOOK_STAMP';
66
+ export const GATE_HOOK_SYMLINK = 'GATE_HOOK_SYMLINK';
67
+ export const GATE_HOOK_UNSAFE_MODE = 'GATE_HOOK_UNSAFE_MODE';
68
+ export const GATE_HOOK_MALFORMED = 'GATE_HOOK_MALFORMED';
69
+ export const GATE_HOOK_DIVERGED = 'GATE_HOOK_DIVERGED';
70
+ export const GATE_HOOK_BUNDLE = 'GATE_HOOK_BUNDLE';
71
+
72
+ const EXIT_OK = 0;
73
+ const EXIT_PRECONDITION = 1;
74
+ const EXIT_USAGE = 2;
75
+ const UTF8 = 'utf8';
76
+ const LF = '\n';
77
+ const CRLF = '\r\n';
78
+ const ERROR_PREFIX = '[agent-workflow-kit]';
79
+ const SETTINGS_JSON_INDENT = 2;
80
+ const JSON_NEWLINE_PATTERN = /\n/gu;
81
+
82
+ const TARGET_PLACE = 'place';
83
+ const TARGET_CURRENT = 'already-current';
84
+ const TARGET_DIVERGED = 'diverged';
85
+
86
+ const USAGE = `usage: gate-hook [--dry-run | --apply] [--cwd <dir>] [--help]
87
+
88
+ Places the bundled PreToolUse gate-approval hook to ${HOOK_FILE_REL} and wires ONE
89
+ PreToolUse "Bash" entry into ${SETTINGS_FILE}. Default is --dry-run (a preview; writes
90
+ nothing). --apply writes. The hook auto-approves byte-exact declared gate cmds
91
+ (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.`;
93
+
94
+ export const fail = (exitCode, message) => Object.assign(new Error(message), { exitCode });
95
+
96
+ export const makeGateHookError = (code, message) =>
97
+ Object.assign(new Error(`${ERROR_PREFIX} ${message}`), { name: 'GateHookError', code, exitCode: EXIT_PRECONDITION });
98
+
99
+ const fsDeps = (deps = {}) => ({
100
+ exists: deps.exists ?? deps.existsSync ?? existsSync,
101
+ lstat: deps.lstat ?? deps.lstatSync ?? lstatSync,
102
+ mkdir: deps.mkdir ?? deps.mkdirSync ?? mkdirSync,
103
+ readFile: deps.readFile ?? deps.readFileSync ?? readFileSync,
104
+ writeFile: deps.writeFile ?? deps.writeFileSync ?? writeFileSync,
105
+ });
106
+
107
+ const lstatNoFollow = (absPath, fs) => {
108
+ try {
109
+ return fs.lstat(absPath);
110
+ } catch (err) {
111
+ if (err && err.code === 'ENOENT') return null;
112
+ throw err;
113
+ }
114
+ };
115
+
116
+ const isJsonObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
117
+
118
+ // ── the bundle ────────────────────────────────────────────────────────────────────────
119
+
120
+ export const readBundledHook = (deps = {}) => {
121
+ const fs = fsDeps(deps);
122
+ const bundlePath = deps.bundlePath ?? BUNDLED_HOOK_PATH;
123
+ try {
124
+ return fs.readFile(bundlePath, UTF8);
125
+ } catch (err) {
126
+ throw makeGateHookError(GATE_HOOK_BUNDLE, `bundled hook runtime unreadable (${err.code ?? err.message}): ${bundlePath} — the kit install is incomplete`);
127
+ }
128
+ };
129
+
130
+ // ── preflight (read-only; velocity discipline) ────────────────────────────────────────
131
+
132
+ const readStamp = (absPath, fs) => {
133
+ try {
134
+ if (!fs.exists(absPath)) return null;
135
+ const stamp = String(fs.readFile(absPath, UTF8)).trim();
136
+ return stamp.length ? stamp : null;
137
+ } catch {
138
+ return null; // unreadable stamp == not a valid deployment stamp (apply STOPs; dry-run reports)
139
+ }
140
+ };
141
+
142
+ const assertDirSafe = (absPath, relPath, fs) => {
143
+ const stat = lstatNoFollow(absPath, fs);
144
+ if (stat === null) return;
145
+ if (stat.isSymbolicLink()) throw makeGateHookError(GATE_HOOK_SYMLINK, `${relPath} is a symlink — refusing to write through it`);
146
+ if (!stat.isDirectory()) throw makeGateHookError(GATE_HOOK_SYMLINK, `${relPath} exists but is not a directory — refusing to write through it`);
147
+ };
148
+
149
+ const assertSettingsWritable = (absPath, fs) => {
150
+ const stat = lstatNoFollow(absPath, fs);
151
+ if (stat !== null && !stat.isFile()) {
152
+ throw makeGateHookError(GATE_HOOK_SYMLINK, `${SETTINGS_FILE} exists but is not a regular file — refusing to clobber it`);
153
+ }
154
+ };
155
+
156
+ // Target plan: place | already-current | diverged. A symlinked / non-regular target is a STOP —
157
+ // never a write-through, never a comparison pretending it is a file.
158
+ const planTarget = (absPath, bundleContent, fs) => {
159
+ const stat = lstatNoFollow(absPath, fs);
160
+ if (stat === null) return { action: TARGET_PLACE };
161
+ if (stat.isSymbolicLink() || !stat.isFile()) {
162
+ throw makeGateHookError(GATE_HOOK_SYMLINK, `${HOOK_FILE_REL} exists but is not a regular file — refusing to touch it`);
163
+ }
164
+ return fs.readFile(absPath, UTF8) === bundleContent ? { action: TARGET_CURRENT } : { action: TARGET_DIVERGED };
165
+ };
166
+
167
+ // The reused readSettingsFile validates permissions.* ONLY — this writer scans and appends
168
+ // inside `hooks`, so any existing `hooks` key must be shape-valid BEFORE a write is planned:
169
+ // a malformed shape is a STOP with zero writes, never a merge-through-clobber.
170
+ const assertHooksShape = (data, relPath) => {
171
+ if (data === undefined || data.hooks === undefined) return;
172
+ if (!isJsonObject(data.hooks)) {
173
+ throw makeGateHookError(GATE_HOOK_MALFORMED, `${relPath}: "hooks" must be a JSON object`);
174
+ }
175
+ const entries = data.hooks[PRE_TOOL_USE_EVENT];
176
+ if (entries === undefined) return;
177
+ if (!Array.isArray(entries)) {
178
+ throw makeGateHookError(GATE_HOOK_MALFORMED, `${relPath}: "hooks.${PRE_TOOL_USE_EVENT}" must be an array`);
179
+ }
180
+ for (const entry of entries) {
181
+ if (!isJsonObject(entry)) {
182
+ throw makeGateHookError(GATE_HOOK_MALFORMED, `${relPath}: every "hooks.${PRE_TOOL_USE_EVENT}[]" entry must be a JSON object`);
183
+ }
184
+ if (entry.hooks !== undefined && (!Array.isArray(entry.hooks) || entry.hooks.some((hook) => !isJsonObject(hook)))) {
185
+ throw makeGateHookError(GATE_HOOK_MALFORMED, `${relPath}: "hooks.${PRE_TOOL_USE_EVENT}[].hooks" must be an array of objects`);
186
+ }
187
+ }
188
+ };
189
+
190
+ // Defensive scan (never throws): used for wired-detection in BOTH settings files — the local file
191
+ // is read-only for this writer, so its shape is never a STOP, just never a false match. An entry
192
+ // counts as OUR wiring only when it targets Bash (matcher "Bash") AND carries a command-type hook
193
+ // running HOOK_COMMAND — a same-command entry under a different matcher (e.g. "Write") is NOT our
194
+ // Bash approval hook, so it must not suppress the merge (else approvals/guard stay inactive while
195
+ // status reports wired).
196
+ export const isHookWired = (data) => {
197
+ const entries = isJsonObject(data) && isJsonObject(data.hooks) ? data.hooks[PRE_TOOL_USE_EVENT] : undefined;
198
+ if (!Array.isArray(entries)) return false;
199
+ return entries.some(
200
+ (entry) =>
201
+ isJsonObject(entry) &&
202
+ entry.matcher === HOOK_MATCHER &&
203
+ Array.isArray(entry.hooks) &&
204
+ entry.hooks.some((hook) => isJsonObject(hook) && hook.type === 'command' && hook.command === HOOK_COMMAND),
205
+ );
206
+ };
207
+
208
+ export const buildHookSettingsEntry = () => ({
209
+ matcher: HOOK_MATCHER,
210
+ hooks: [{ type: 'command', command: HOOK_COMMAND, timeout: HOOK_TIMEOUT_SECONDS }],
211
+ });
212
+
213
+ // Append our ONE entry; every foreign hook/matcher/key is carried over untouched. Only called
214
+ // when the entry is absent (idempotence lives in the caller's plan, not in dedup-on-write).
215
+ export const mergeHookEntry = (base) => {
216
+ const root = isJsonObject(base) ? base : {};
217
+ const hooks = isJsonObject(root.hooks) ? root.hooks : {};
218
+ const entries = Array.isArray(hooks[PRE_TOOL_USE_EVENT]) ? hooks[PRE_TOOL_USE_EVENT] : [];
219
+ return { ...root, hooks: { ...hooks, [PRE_TOOL_USE_EVENT]: [...entries, buildHookSettingsEntry()] } };
220
+ };
221
+
222
+ export const preflightGateHook = ({ cwd }, deps = {}) => {
223
+ const fs = fsDeps(deps);
224
+ const projectDir = cwd ?? deps.cwd ?? process.cwd();
225
+ const bundleContent = readBundledHook(deps);
226
+ const stamp = readStamp(join(projectDir, WORKFLOW_STAMP), fs);
227
+ const stampOk = stamp === EXPECTED_WORKFLOW_VERSION;
228
+ assertDirSafe(join(projectDir, CLAUDE_DIR), CLAUDE_DIR, fs);
229
+ assertDirSafe(join(projectDir, HOOKS_DIR), HOOKS_DIR, fs);
230
+ const target = planTarget(join(projectDir, HOOK_FILE_REL), bundleContent, fs);
231
+ assertSettingsWritable(join(projectDir, SETTINGS_FILE), fs);
232
+ const projectSettings = readSettingsFile(join(projectDir, SETTINGS_FILE), { ...deps, cwd: projectDir });
233
+ const localSettings = readSettingsFile(join(projectDir, SETTINGS_LOCAL_FILE), { ...deps, cwd: projectDir });
234
+ const { bypassPermissionsPresent, unsafeModePresent } = resolveEffectiveMode(projectSettings.data, localSettings.data);
235
+
236
+ if (bypassPermissionsPresent) {
237
+ throw makeGateHookError(GATE_HOOK_UNSAFE_MODE, `${UNSAFE_BYPASS_MODE} appears in Claude settings - refusing because it auto-approves Bash`);
238
+ }
239
+ if (unsafeModePresent) {
240
+ throw makeGateHookError(
241
+ GATE_HOOK_UNSAFE_MODE,
242
+ `an unsafe or unknown permissions.defaultMode is present in Claude settings - accepted modes: ${SAFE_DEFAULT_MODES.join(', ')}`,
243
+ );
244
+ }
245
+ assertHooksShape(projectSettings.data, SETTINGS_FILE);
246
+
247
+ const wiredInProject = isHookWired(projectSettings.data);
248
+ const wiredInLocal = isHookWired(localSettings.data);
249
+ // Diverged target + our entry NOT wired anywhere = STOP on BOTH dry-run and apply: applying
250
+ // would wire an unknown script as a PreToolUse hook — exactly what consent must not slide
251
+ // past. (Diverged + already wired is the user's own standing state: report-only, below.)
252
+ if (target.action === TARGET_DIVERGED && !wiredInProject && !wiredInLocal) {
253
+ throw makeGateHookError(
254
+ GATE_HOOK_DIVERGED,
255
+ `${HOOK_FILE_REL} exists with DIFFERENT content and is not wired — refusing to wire an unknown script as a PreToolUse hook; delete the file to reseed from the bundle, then re-run`,
256
+ );
257
+ }
258
+
259
+ return { projectDir, bundleContent, stamp, stampOk, target, projectSettings, localSettings, wiredInProject, wiredInLocal };
260
+ };
261
+
262
+ // ── the writer ────────────────────────────────────────────────────────────────────────
263
+
264
+ const formatJson = (data, eol) =>
265
+ `${JSON.stringify(data, null, SETTINGS_JSON_INDENT).replace(JSON_NEWLINE_PATTERN, eol)}${eol}`;
266
+
267
+ export const writeGateHook = ({ cwd, dryRun = true } = {}, deps = {}) => {
268
+ const fs = fsDeps(deps);
269
+ const preflight = preflightGateHook({ cwd: cwd ?? deps.cwd ?? process.cwd() }, deps);
270
+ const placePlanned = preflight.target.action === TARGET_PLACE;
271
+ const wirePlanned = !preflight.wiredInProject && !preflight.wiredInLocal;
272
+ const base = { placePlanned, wirePlanned, ...preflight };
273
+ if (dryRun) return { wrote: false, dryRun: true, ...base };
274
+
275
+ if (!preflight.stampOk) {
276
+ throw makeGateHookError(
277
+ GATE_HOOK_STAMP,
278
+ `not a deployed agent-workflow project at lineage ${EXPECTED_WORKFLOW_VERSION} (found ${preflight.stamp ?? 'none'}) — run init/upgrade first`,
279
+ );
280
+ }
281
+
282
+ // Place FIRST, then wire — a wired-but-missing hook would error on every Bash call.
283
+ const hookAbs = join(preflight.projectDir, HOOK_FILE_REL);
284
+ if (placePlanned) {
285
+ fs.mkdir(join(preflight.projectDir, HOOKS_DIR), { recursive: true });
286
+ fs.writeFile(hookAbs, preflight.bundleContent, UTF8);
287
+ }
288
+ if (wirePlanned) {
289
+ // Re-verify the target no-follow immediately BEFORE wiring: preflight read it earlier, and an
290
+ // external process could have swapped it (or slipped a symlink in) between preflight and now.
291
+ // Wiring settings to point at an unknown script is exactly what consent must not slide past, so
292
+ // confirm the on-disk bytes are still the bundle — abort with zero settings mutation otherwise.
293
+ const onDisk = lstatNoFollow(hookAbs, fs);
294
+ if (onDisk === null || onDisk.isSymbolicLink() || !onDisk.isFile() || fs.readFile(hookAbs, UTF8) !== preflight.bundleContent) {
295
+ throw makeGateHookError(
296
+ GATE_HOOK_DIVERGED,
297
+ `${HOOK_FILE_REL} changed after preflight — refusing to wire an unknown script as a PreToolUse hook (no settings change made); re-run`,
298
+ );
299
+ }
300
+ const merged = mergeHookEntry(preflight.projectSettings.data ?? {});
301
+ fs.writeFile(
302
+ join(preflight.projectDir, SETTINGS_FILE),
303
+ formatJson(merged, preflight.projectSettings.eol ?? LF),
304
+ UTF8,
305
+ );
306
+ }
307
+ return { wrote: placePlanned || wirePlanned, dryRun: false, ...base };
308
+ };
309
+
310
+ // ── report ────────────────────────────────────────────────────────────────────────────
311
+
312
+ const TRUST_POSTURE_LINE =
313
+ 'trust posture: the hook auto-approves ONLY byte-exact matches of the gate cmds declared in docs/ai/gates.json, invoked from the project root — gates.json is thereby a privileged file (whoever can edit it can get its commands auto-approved); an invalid declaration approves nothing.';
314
+
315
+ const formatTargetLine = (result) => {
316
+ if (result.target.action === TARGET_PLACE) return ` - ${HOOK_FILE_REL}: ${result.dryRun ? 'would place' : 'placed'}`;
317
+ if (result.target.action === TARGET_CURRENT) return ` - ${HOOK_FILE_REL}: already current`;
318
+ return ` - ${HOOK_FILE_REL}: diverged from the bundle — preserved, never clobbered (already wired; delete the file to reseed from the bundle)`;
319
+ };
320
+
321
+ const formatSettingsLine = (result) => {
322
+ if (result.wiredInProject) return ` - ${SETTINGS_FILE}: already wired`;
323
+ if (result.wiredInLocal) return ` - ${SETTINGS_FILE}: already wired via ${SETTINGS_LOCAL_FILE} — not duplicated`;
324
+ return ` - ${SETTINGS_FILE}: ${result.dryRun ? 'would wire' : 'wired'} the ${PRE_TOOL_USE_EVENT} "${HOOK_MATCHER}" entry`;
325
+ };
326
+
327
+ export const formatResult = (result) => {
328
+ const lines = [
329
+ result.dryRun
330
+ ? 'agent-workflow gate hook — DRY RUN (no changes; re-run with --apply)'
331
+ : 'agent-workflow gate hook — APPLY',
332
+ formatTargetLine(result),
333
+ formatSettingsLine(result),
334
+ TRUST_POSTURE_LINE,
335
+ ];
336
+ if (!result.stampOk) {
337
+ lines.push(`note: no current deployment stamp found (${result.stamp ?? 'none'}) — --apply will refuse until init/upgrade runs.`);
338
+ }
339
+ if (!result.dryRun && result.wrote) {
340
+ lines.push('settings hot-reload: the hook is active for new Bash calls — no session restart needed.');
341
+ lines.push('hidden-mode note: if this deployment is hidden, run the hide-footprint reconcile so the placed hook stays out of `git status` (the registry carries /.claude/hooks/).');
342
+ }
343
+ return lines.join(LF);
344
+ };
345
+
346
+ // ── CLI ───────────────────────────────────────────────────────────────────────────────
347
+
348
+ export const parseArgs = (argv) => {
349
+ const opts = { dryRunFlag: false, apply: false, cwd: undefined, help: false };
350
+ for (let i = 0; i < argv.length; i += 1) {
351
+ const arg = argv[i];
352
+ if (arg === '--help' || arg === '-h') opts.help = true;
353
+ else if (arg === '--dry-run') opts.dryRunFlag = true;
354
+ else if (arg === '--apply') opts.apply = true;
355
+ else if (arg === '--cwd') {
356
+ i += 1;
357
+ if (argv[i] === undefined || argv[i].startsWith('-')) throw fail(EXIT_USAGE, '--cwd needs a directory argument');
358
+ opts.cwd = argv[i];
359
+ } else {
360
+ throw fail(EXIT_USAGE, `unknown argument: ${arg}`);
361
+ }
362
+ }
363
+ 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 };
365
+ };
366
+
367
+ export const main = (argv = process.argv.slice(2), deps = {}) => {
368
+ const log = deps.log ?? console.log;
369
+ const errlog = deps.errlog ?? console.error;
370
+ try {
371
+ const args = parseArgs(argv);
372
+ if (args.help) {
373
+ log(USAGE);
374
+ return EXIT_OK;
375
+ }
376
+ const result = writeGateHook({ cwd: args.cwd ?? deps.cwd ?? process.cwd(), dryRun: args.dryRun }, deps);
377
+ log(formatResult(result));
378
+ return EXIT_OK;
379
+ } catch (err) {
380
+ errlog(err?.message ?? String(err));
381
+ if (err?.exitCode === EXIT_USAGE) errlog(USAGE);
382
+ return err?.exitCode ?? EXIT_PRECONDITION;
383
+ }
384
+ };
385
+
386
+ const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
387
+ if (isDirectRun) process.exit(main(process.argv.slice(2)));
@@ -0,0 +1,263 @@
1
+ #!/usr/bin/env node
2
+ // grounding.mjs — the grounded-review facts assembler behind `/agent-workflow-kit grounding`
3
+ // (AD-038). An ungrounded agy review GUESSES (AD-028); the grounding CONTRACT is mechanized
4
+ // (`agy-review --facts @f`, AD-033) but population was a manual chore — this tool emits the two
5
+ // mechanical halves of a facts payload so assembly is a command, not hand-copying:
6
+ //
7
+ // --constraints slice the root AGENTS.md `## 🚫 Hard Constraints` section, verbatim
8
+ // (exactly-one-match — 0 or >1 headings is a loud STOP, never a guess);
9
+ // --plan <path> extract the plan's decision-bearing canonical sections, verbatim + whole:
10
+ // `## Approach` (REQUIRED — its "What we are NOT doing" text rides inside;
11
+ // it is not a heading in canon) and `## Verification` (REQUIRED — STOP if
12
+ // missing), plus `## Decisions (locked)` (optional-if-absent, the engine §7
13
+ // heading this release adds); a DUPLICATE heading is always a STOP.
14
+ //
15
+ // Byte budget: the output honors the same AGY_MAX_PROMPT_BYTES contract the agy wrapper enforces
16
+ // (default 120000; the override may only TIGHTEN — above the OS single-argv ceiling ~131000 is
17
+ // rejected, mirroring agy-review.sh), MINUS --reserve-bytes <n> — the artifact share the caller
18
+ // expects `agy-review` to add around these facts. Overflow is TRIMMED tail-first with a loud
19
+ // in-band marker + stderr report (never a silent cut).
20
+ //
21
+ // Catalog honesty: this is a WRITER — `--out <path>` writes a file. Invariant: `--out` accepts
22
+ // only gitignored / out-of-repo scratch destinations and REFUSES a tracked path AND an in-repo
23
+ // not-ignored path (a new untracked file would itself move the review fingerprint the facts are
24
+ // about to ground). stdout is the default. It never commits and never runs a subscription CLI.
25
+ //
26
+ // Dependency-free, Node >= 18. No side effects on import (the isDirectRun idiom).
27
+
28
+ import { readFileSync, writeFileSync, lstatSync, realpathSync } from 'node:fs';
29
+ import { resolve, join, relative, isAbsolute, dirname, basename } from 'node:path';
30
+ import { pathToFileURL } from 'node:url';
31
+ import { spawnSync } from 'node:child_process';
32
+ import { fail } from './orchestration-config.mjs';
33
+
34
+ // The agy single-argv byte contract (mirrors agy-review.sh — the wrapper is the enforcement home).
35
+ export const DEFAULT_MAX_PROMPT_BYTES = 120000;
36
+ export const ARGV_HARD_MAX = 131000;
37
+
38
+ export const CONSTRAINTS_HEADING = /^## .*Hard Constraints$/;
39
+ export const PLAN_SECTIONS = [
40
+ { heading: '## Approach', optional: false },
41
+ { heading: '## Verification', optional: false },
42
+ { heading: '## Decisions (locked)', optional: true },
43
+ ];
44
+
45
+ // ── pure section slicing (exactly-one-match; the inject-methodology discipline) ────────
46
+
47
+ // Slice ONE `## `-level section (its heading line through the line before the next `## ` heading),
48
+ // verbatim. `heading` is a string (trimmed-line equality) or a RegExp over the trimmed line.
49
+ // 0 matches → null when optional, else STOP; >1 matches → always STOP (never guess which).
50
+ export const sliceSection = (text, heading, { optional = false, label = 'document' } = {}) => {
51
+ const lines = text.split('\n');
52
+ const matchesAt = [];
53
+ for (let i = 0; i < lines.length; i += 1) {
54
+ const trimmed = lines[i].trim();
55
+ if (typeof heading === 'string' ? trimmed === heading : heading.test(trimmed)) matchesAt.push(i);
56
+ }
57
+ const shown = typeof heading === 'string' ? heading : String(heading);
58
+ if (matchesAt.length === 0) {
59
+ if (optional) return null;
60
+ throw fail(1, `${label}: required section "${shown}" not found — STOP (nothing sliced)`);
61
+ }
62
+ if (matchesAt.length > 1) {
63
+ throw fail(1, `${label}: section "${shown}" appears ${matchesAt.length} times — must be exactly one; STOP (never guess which)`);
64
+ }
65
+ const start = matchesAt[0];
66
+ let end = lines.length;
67
+ for (let i = start + 1; i < lines.length; i += 1) {
68
+ if (/^## /.test(lines[i])) {
69
+ end = i;
70
+ break;
71
+ }
72
+ }
73
+ return lines.slice(start, end).join('\n').replace(/\n+$/, '\n');
74
+ };
75
+
76
+ // ── assembly ───────────────────────────────────────────────────────────────────────
77
+
78
+ export const assembleGrounding = ({ constraintsText = null, planText = null, planLabel = 'plan' } = {}) => {
79
+ const parts = [];
80
+ if (constraintsText != null) {
81
+ parts.push(sliceSection(constraintsText, CONSTRAINTS_HEADING, { label: 'AGENTS.md' }));
82
+ }
83
+ if (planText != null) {
84
+ for (const { heading, optional } of PLAN_SECTIONS) {
85
+ const section = sliceSection(planText, heading, { optional, label: planLabel });
86
+ if (section != null) parts.push(section);
87
+ }
88
+ }
89
+ return parts.join('\n');
90
+ };
91
+
92
+ // Trim `payload` to `budget` bytes, tail-first, with a loud in-band marker. The budget is a HARD
93
+ // ceiling: when it is smaller than the marker itself, the marker is truncated too (the output may
94
+ // never exceed `budget` — a facts file over the reserved share would push the final wrapper prompt
95
+ // past the argv ceiling; codex R1 finding). Returns { text, trimmedBytes }. Never a silent cut.
96
+ export const trimToBudget = (payload, budget) => {
97
+ const bytes = Buffer.byteLength(payload, 'utf8');
98
+ if (bytes <= budget) return { text: payload, trimmedBytes: 0 };
99
+ const marker = `\n[grounding] TRIMMED: the assembled facts exceeded the byte budget — tail dropped; tighten the sources or raise --reserve-bytes accounting.\n`;
100
+ const keep = Math.max(0, budget - Buffer.byteLength(marker, 'utf8'));
101
+ const joined = Buffer.from(payload, 'utf8').subarray(0, keep).toString('utf8').replace(/�+$/, '') + marker;
102
+ // Hard-cap the final text (covers budget < marker size): byte-slice, then drop any split rune.
103
+ const text = Buffer.from(joined, 'utf8').subarray(0, budget).toString('utf8').replace(/�+$/, '');
104
+ return { text, trimmedBytes: bytes - keep };
105
+ };
106
+
107
+ // ── the --out destination guard (gitignored / out-of-repo scratch ONLY) ────────────────
108
+
109
+ const gitLine = (args, cwd) => {
110
+ const r = spawnSync('git', args, { cwd, encoding: 'utf8', windowsHide: true });
111
+ return r.error || r.status == null ? null : { status: r.status, stdout: r.stdout ?? '' };
112
+ };
113
+
114
+ // STOP unless `outPath` is a safe scratch destination: outside any git work tree, or gitignored
115
+ // inside one. A TRACKED path and an in-repo NOT-ignored path are both refused (a facts file must
116
+ // never enter the tree it grounds — it would move the fingerprint and could be committed). The
117
+ // check runs on the REAL destination, never the lexical path (codex R1 finding): a symlink leaf is
118
+ // refused outright, and the parent directory is realpath-resolved first, so a gitignored or
119
+ // out-of-repo symlink can never route the write onto a tracked/in-repo file. Returns the resolved
120
+ // real path the caller must write to.
121
+ export const assertScratchDestination = (outPath, cwd) => {
122
+ const lexical = isAbsolute(outPath) ? outPath : resolve(cwd, outPath);
123
+ let leaf = null;
124
+ try {
125
+ leaf = lstatSync(lexical);
126
+ } catch {
127
+ leaf = null; // absent → a fresh file; the parent still gets realpath-checked below
128
+ }
129
+ if (leaf?.isSymbolicLink()) {
130
+ throw fail(1, `--out refuses a symlink destination (${outPath}) — the write would follow it onto another file; name the real scratch path`);
131
+ }
132
+ let realParent;
133
+ try {
134
+ realParent = realpathSync(dirname(lexical));
135
+ } catch {
136
+ throw fail(1, `--out parent directory does not exist (${dirname(lexical)}) — create the scratch dir first`);
137
+ }
138
+ const full = join(realParent, basename(lexical));
139
+ const top = gitLine(['rev-parse', '--show-toplevel'], cwd);
140
+ if (top == null || top.status !== 0) return full; // no git tree → plain scratch, fine
141
+ const root = top.stdout.replace(/\r?\n$/, '');
142
+ const rel = relative(root, full);
143
+ if (rel.startsWith('..') || isAbsolute(rel)) return full; // really outside the work tree → scratch
144
+ const tracked = gitLine(['ls-files', '--error-unmatch', '--', rel], root);
145
+ if (tracked != null && tracked.status === 0) {
146
+ throw fail(1, `--out refuses a TRACKED path (${rel}) — grounding output is scratch; write it to a gitignored path or outside the repo`);
147
+ }
148
+ const ignored = gitLine(['check-ignore', '-q', '--', rel], root);
149
+ if (ignored == null || ignored.status !== 0) {
150
+ throw fail(1, `--out refuses an in-repo path that is not gitignored (${rel}) — a new untracked file would move the review fingerprint; use a gitignored path or a location outside the repo`);
151
+ }
152
+ return full;
153
+ };
154
+
155
+ // ── CLI ────────────────────────────────────────────────────────────────────────────
156
+
157
+ const HELP = `grounding — grounded-review facts assembler for the agent-workflow family (AD-038).
158
+
159
+ Usage:
160
+ node grounding.mjs [--constraints] [--plan <path>] [--reserve-bytes <n>] [--out <path>]
161
+
162
+ --constraints slice the root AGENTS.md "Hard Constraints" section verbatim
163
+ (exactly one matching heading, else a loud STOP)
164
+ --plan <path> extract the plan's decision-bearing sections verbatim + whole:
165
+ "## Approach" + "## Verification" (REQUIRED — STOP if missing),
166
+ "## Decisions (locked)" when present; a duplicate heading is a STOP
167
+ --reserve-bytes <n> the artifact share agy-review will add around these facts — the output
168
+ budget becomes AGY_MAX_PROMPT_BYTES − n (loud tail-trim on overflow)
169
+ --out <path> write instead of stdout — gitignored / out-of-repo scratch ONLY
170
+ (a tracked or in-repo not-ignored path is refused)
171
+
172
+ Feed the output to the review wrapper: agy-review code --facts @<out>. AGY_MAX_PROMPT_BYTES
173
+ honors the wrapper's contract (default ${DEFAULT_MAX_PROMPT_BYTES}; may only tighten — above the
174
+ OS argv ceiling it is rejected). Writer honesty: --out writes ONE scratch file; nothing else is
175
+ ever written; never commits, never runs a subscription CLI.
176
+
177
+ Exit codes: 0 success; 2 usage; 1 STOP (missing/duplicate section, unreadable file, refused --out).`;
178
+
179
+ const parseArgs = (argv) => {
180
+ let constraints = false;
181
+ let plan = null;
182
+ let out = null;
183
+ let reserve = 0;
184
+ for (let i = 0; i < argv.length; i += 1) {
185
+ const a = argv[i];
186
+ if (a === '--constraints') constraints = true;
187
+ else if (a === '--plan') {
188
+ plan = argv[i + 1];
189
+ if (!plan || plan.startsWith('--')) throw fail(2, '--plan requires a <path>');
190
+ i += 1;
191
+ } else if (a === '--out') {
192
+ out = argv[i + 1];
193
+ if (!out || out.startsWith('--')) throw fail(2, '--out requires a <path>');
194
+ i += 1;
195
+ } else if (a === '--reserve-bytes') {
196
+ const raw = argv[i + 1];
197
+ if (!raw || !/^\d+$/.test(raw)) throw fail(2, '--reserve-bytes requires a non-negative integer');
198
+ reserve = Number(raw);
199
+ i += 1;
200
+ } else throw fail(2, `unknown argument: ${a}`);
201
+ }
202
+ if (!constraints && plan == null) throw fail(2, 'nothing to assemble — pass --constraints and/or --plan <path>');
203
+ return { constraints, plan, out, reserve };
204
+ };
205
+
206
+ const resolveBudget = (env, reserve) => {
207
+ const raw = env.AGY_MAX_PROMPT_BYTES ?? String(DEFAULT_MAX_PROMPT_BYTES);
208
+ if (!/^\d+$/.test(raw)) throw fail(2, `AGY_MAX_PROMPT_BYTES='${raw}' is not a non-negative integer`);
209
+ const max = Number(raw);
210
+ if (max > ARGV_HARD_MAX) {
211
+ throw fail(2, `AGY_MAX_PROMPT_BYTES=${max} exceeds the OS single-argv ceiling (~${ARGV_HARD_MAX}) — the override may only tighten (mirrors agy-review)`);
212
+ }
213
+ const budget = max - reserve;
214
+ if (budget <= 0) throw fail(2, `--reserve-bytes ${reserve} leaves no budget under AGY_MAX_PROMPT_BYTES=${max}`);
215
+ return budget;
216
+ };
217
+
218
+ export const main = (argv, ctx = {}) => {
219
+ const cwd = ctx.cwd ?? process.cwd();
220
+ const env = ctx.env ?? process.env;
221
+ try {
222
+ if (argv.includes('--help') || argv.includes('-h')) return { code: 0, stdout: HELP, stderr: '' };
223
+ const { constraints, plan, out, reserve } = parseArgs(argv);
224
+ const budget = resolveBudget(env, reserve);
225
+
226
+ const readOrStop = (path, label) => {
227
+ try {
228
+ return readFileSync(resolve(cwd, path), 'utf8');
229
+ } catch (err) {
230
+ throw fail(1, `${label} '${path}' is unreadable (${(err && err.code) || err}) — STOP`);
231
+ }
232
+ };
233
+ const constraintsText = constraints ? readOrStop('AGENTS.md', 'root AGENTS.md') : null;
234
+ const planText = plan != null ? readOrStop(plan, 'plan file') : null;
235
+
236
+ const payload = assembleGrounding({ constraintsText, planText, planLabel: plan ?? 'plan' });
237
+ const { text, trimmedBytes } = trimToBudget(payload, budget);
238
+ const stderr = trimmedBytes > 0
239
+ ? `[grounding] TRIM: assembled ${Buffer.byteLength(payload, 'utf8')} bytes > budget ${budget} (AGY_MAX_PROMPT_BYTES minus --reserve-bytes ${reserve}) — dropped ${trimmedBytes} tail bytes; the cut is marked in-band.`
240
+ : '';
241
+
242
+ if (out != null) {
243
+ const realOut = assertScratchDestination(out, cwd);
244
+ writeFileSync(realOut, text);
245
+ return { code: 0, stdout: `[grounding] wrote ${Buffer.byteLength(text, 'utf8')} bytes to ${out} — pass it as: agy-review code --facts @${out}`, stderr };
246
+ }
247
+ return { code: 0, stdout: text, stderr };
248
+ } catch (err) {
249
+ return { code: err.exitCode ?? 1, stdout: '', stderr: `grounding: ${err.message}` };
250
+ }
251
+ };
252
+
253
+ const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
254
+ if (isDirectRun) {
255
+ const r = main(process.argv.slice(2));
256
+ // Exact writes + a natural exit (codex R2): console.log would append a stray newline to the
257
+ // byte-exact facts payload, and process.exit() can truncate large piped stdout before Node
258
+ // flushes. The payload already ends with '\n' (sliceSection normalizes); only a non-payload
259
+ // message (help / the --out report) gains the terminating newline it lacks.
260
+ if (r.stdout) process.stdout.write(r.stdout.endsWith('\n') ? r.stdout : `${r.stdout}\n`);
261
+ if (r.stderr) process.stderr.write(r.stderr.endsWith('\n') ? r.stderr : `${r.stderr}\n`);
262
+ process.exitCode = r.code;
263
+ }
@@ -62,6 +62,7 @@ export const KIT_OWN_PATHS = [
62
62
  export const KNOWN_FOOTPRINT = [
63
63
  { pattern: '/.claude/skills/', owner: 'Claude Code', type: 'dir', falsePositiveRisk: false, note: 'local-dev skills; absorbs the AD-013 one-off' },
64
64
  { pattern: '/.claude/agents/', owner: 'Claude Code', type: 'dir', falsePositiveRisk: false, note: 'project subagent definitions (incl. the kit-placed cheap-lane vehicles)' },
65
+ { pattern: '/.claude/hooks/', owner: 'Claude Code', type: 'dir', falsePositiveRisk: false, note: 'project hooks (incl. the kit-placed gate-approval hook)' },
65
66
  { pattern: '/.cursor/rules/', owner: 'Cursor', type: 'dir', falsePositiveRisk: false, note: 'project rule files' },
66
67
  { pattern: '/.cursorrules', owner: 'Cursor (legacy)', type: 'file', falsePositiveRisk: true, note: 'legacy single-file rules' },
67
68
  { pattern: '/.codeium/', owner: 'Codeium/Windsurf', type: 'dir', falsePositiveRisk: false, note: 'home-scoped launchers live under ~/, out of scope' },
@@ -41,6 +41,7 @@ export const SETTINGS_LABELS = Object.freeze({
41
41
  recipes: 'recipes',
42
42
  attribution: 'attribution',
43
43
  velocity: 'velocity',
44
+ hook: 'gate hook',
44
45
  });
45
46
 
46
47
  // Glyph sets — Unicode for a capable terminal, an ASCII fallback for a narrow / Windows-legacy one.