@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,300 @@
1
+ #!/usr/bin/env node
2
+ // gate-approve.mjs — the agent-workflow PreToolUse gate-approval hook. Bundled at
3
+ // references/hooks/gate-approve.mjs; PLACED (copied) by `/agent-workflow-kit hook` to
4
+ // <project>/.claude/hooks/agent-workflow-gates.mjs and wired as a `PreToolUse` "Bash" hook in
5
+ // .claude/settings.json.
6
+ //
7
+ // SELF-CONTAINED by contract: the placed copy must run on machines without the kit — no kit
8
+ // imports, dependency-free, Node >= 18, no side effects on import. Two constants are baked
9
+ // FROZEN COPIES of the velocity-profile.mjs exports (a placed file cannot import the kit):
10
+ // SEEDED_READONLY_CORE ≡ UNIVERSAL_READONLY_ALLOWLIST and RESIDUAL_FORMS ≡
11
+ // RUNTIME_RESIDUAL_FORMS — drift-guarded by the kit's test/gate-hook-core-parity.test.mjs.
12
+ //
13
+ // It reads <project root>/docs/ai/gates.json LIVE on every invocation (AD-035: one declaration,
14
+ // two consumers — editing gates.json never requires re-wiring). The project root resolves from
15
+ // $CLAUDE_PROJECT_DIR (the stdin `cwd` may be a subdirectory), falling back to the stdin `cwd`
16
+ // only when the env is absent.
17
+ //
18
+ // Decision ladder for every Bash PreToolUse call — first match wins:
19
+ // (a) declared-gate EXACT match → allow. Byte-identical (leading/trailing trim only — no
20
+ // whitespace collapsing, no quote/glob/variable interpretation, no prefix or pattern
21
+ // matching: patterns are exactly what made AD-021 auto-seeding fragile) to one declared
22
+ // gate `cmd`, AND the stdin `cwd` realpath-resolves to the project root (gates run FROM
23
+ // THE ROOT by contract — the same bytes from a subdirectory resolve relative paths
24
+ // differently), AND permission_mode is `default`/`acceptEdits` (an allow never loosens
25
+ // `plan`/`bypassPermissions`).
26
+ // (b) residual guard → ask. The command's leading tokens match the SEEDED read-only core AND
27
+ // the command carries a documented runtime residual (output redirection, command
28
+ // substitution, the bounded `--output` write-flag family) that a settings-level allow
29
+ // rule cannot see. Most-restrictive-wins: this surfaces a human prompt even where a
30
+ // seeded allow rule would have silently approved. Detection is deliberately string-level
31
+ // and conservative (no shell parsing in a dependency-free hook): a quoted metacharacter
32
+ // may over-ASK, never under-allow. Covers the kit-SEEDED core only, never arbitrary
33
+ // user-added rules.
34
+ // (c) everything else → NO decision: exit 0, no output — the normal permission flow proceeds
35
+ // unchanged. The hook NEVER emits `deny`.
36
+ //
37
+ // Fail-safe invariant, decoupled per function: a DECLARATION anomaly (missing / unreadable /
38
+ // malformed / schema-invalid gates.json) disables ONLY exact-gate approval (a) — the residual
39
+ // guard (b) needs no declaration and keeps running (a broken gates.json must not silently
40
+ // reopen the seeded-allowlist hole). Only an INPUT anomaly (unparseable stdin, non-Bash
41
+ // tool_name) disables the whole hook. EVERY anomaly path exits 0 — never exit 2 (exit 2 is an
42
+ // immediate block; a hook that fires on every Bash call must never become the blocker or the
43
+ // noise — run-gates.mjs already yells about a broken declaration at its own point of use).
44
+ //
45
+ // Trust posture: this hook only removes the PROMPT for commands the human already declared in
46
+ // gates.json — the same trust boundary as run-gates.mjs, which executes those commands with the
47
+ // caller's privileges. gates.json is thereby a privileged file; an invalid declaration approves
48
+ // NOTHING (strict-parse-or-no-decision).
49
+
50
+ import { readFileSync, realpathSync } from 'node:fs';
51
+ import { join } from 'node:path';
52
+ import { pathToFileURL } from 'node:url';
53
+
54
+ export const HOOK_EVENT_NAME = 'PreToolUse';
55
+ export const BASH_TOOL_NAME = 'Bash';
56
+ export const GATES_REL = 'docs/ai/gates.json';
57
+ export const PROJECT_DIR_ENV = 'CLAUDE_PROJECT_DIR';
58
+ export const DECISION_ALLOW = 'allow';
59
+ export const DECISION_ASK = 'ask';
60
+ // The only modes an `allow` may be emitted under — an allowlist, so an unknown future mode is
61
+ // fenced closed by default. The residual guard (b) is NOT mode-fenced: an `ask` never loosens.
62
+ export const ALLOW_PERMISSION_MODES = Object.freeze(['default', 'acceptEdits']);
63
+
64
+ // Baked frozen copy of velocity-profile.mjs UNIVERSAL_READONLY_ALLOWLIST (the audited read-only
65
+ // core velocity seeds as settings allow rules). Drift-guarded — never edit here alone.
66
+ export const SEEDED_READONLY_CORE = Object.freeze([
67
+ 'Bash(git status:*)',
68
+ 'Bash(git diff:*)',
69
+ 'Bash(git log:*)',
70
+ 'Bash(git show:*)',
71
+ 'Bash(git ls-files:*)',
72
+ 'Bash(git check-ignore:*)',
73
+ 'Bash(git branch --list:*)',
74
+ 'Bash(npm view:*)',
75
+ 'Bash(npm ls:*)',
76
+ 'Bash(npm outdated:*)',
77
+ 'Bash(ls:*)',
78
+ 'Bash(cat:*)',
79
+ 'Bash(head:*)',
80
+ 'Bash(tail:*)',
81
+ 'Bash(wc:*)',
82
+ 'Bash(readlink:*)',
83
+ 'Bash(which:*)',
84
+ 'Bash(grep:*)',
85
+ ]);
86
+
87
+ // Baked frozen copy of velocity-profile.mjs RUNTIME_RESIDUAL_FORMS (the documented residual a
88
+ // settings-level allow rule cannot see). Drift-guarded — never edit here alone.
89
+ export const RESIDUAL_FORMS = Object.freeze({
90
+ writeRedirections: Object.freeze(['>', '>>', '1>', '2>', '&>', '>|']),
91
+ // `$(…)` + backtick + process substitution `<(…)` all RUN a nested command (`>(…)` is caught by
92
+ // the `>` redirection scan). Bare `<` is input redirection (reads a file — read-only commands may
93
+ // already do that), so it is deliberately NOT here.
94
+ commandSubstitutions: Object.freeze(['$(', '`', '<(']),
95
+ // The `--output` write-flag family, matched as a raw SUBSTRING of the whole command (never a
96
+ // whitespace-token check): the hook sees the pre-shell command string, so `--output=f`,
97
+ // `"--output=f"`, `'--output' f`, and `\--output` must all trip it — over-asking on a benign
98
+ // `--output-indicator` is the safe direction (never under-allow a real write flag through quotes).
99
+ boundedWriteFlags: Object.freeze(['--output']),
100
+ });
101
+
102
+ export const RESIDUAL_CLASS_REDIRECTION = 'output redirection';
103
+ export const RESIDUAL_CLASS_SUBSTITUTION = 'command substitution';
104
+ export const RESIDUAL_CLASS_OUTPUT_FLAG = 'a bounded --output write flag';
105
+
106
+ const EXIT_OK = 0;
107
+ const UTF8 = 'utf8';
108
+ const BASH_ALLOW_PATTERN = /^Bash\((.+):\*\)$/u;
109
+ const WHITESPACE_PATTERN = /\s+/u;
110
+ const GATE_ID_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
111
+ const GATE_CMD_NEWLINE_PATTERN = /[\r\n]/u;
112
+ const GATE_KEYS = Object.freeze(['id', 'title', 'cmd']);
113
+ const README_KEY = '_README';
114
+ const GATES_KEY = 'gates';
115
+
116
+ // ── declaration (validation parity with run-gates.mjs) ────────────────────────────────
117
+
118
+ // Self-contained restatement of run-gates.mjs `validateDeclaration`, at EXACT parity including
119
+ // the optional `_README` string — a declaration the runner accepts is never rejected here (else
120
+ // every template-shaped declaration would approve nothing), and one the runner rejects never
121
+ // approves anything. Returns a boolean shape instead of throwing: anomalies go dark, not loud.
122
+ export const validateDeclarationShape = (parsed) => {
123
+ const invalid = { ok: false };
124
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return invalid;
125
+ for (const key of Object.keys(parsed)) {
126
+ if (key !== README_KEY && key !== GATES_KEY) return invalid;
127
+ }
128
+ if (parsed[README_KEY] !== undefined && typeof parsed[README_KEY] !== 'string') return invalid;
129
+ if (!Array.isArray(parsed[GATES_KEY])) return invalid;
130
+ const seenIds = new Set();
131
+ for (const gate of parsed[GATES_KEY]) {
132
+ if (gate === null || typeof gate !== 'object' || Array.isArray(gate)) return invalid;
133
+ for (const key of Object.keys(gate)) {
134
+ if (!GATE_KEYS.includes(key)) return invalid;
135
+ }
136
+ for (const key of GATE_KEYS) {
137
+ if (typeof gate[key] !== 'string' || gate[key].trim() === '') return invalid;
138
+ }
139
+ if (GATE_CMD_NEWLINE_PATTERN.test(gate.cmd)) return invalid;
140
+ if (!GATE_ID_PATTERN.test(gate.id)) return invalid;
141
+ if (seenIds.has(gate.id)) return invalid;
142
+ seenIds.add(gate.id);
143
+ }
144
+ return { ok: true, gates: parsed[GATES_KEY] };
145
+ };
146
+
147
+ // Read + strictly validate the LIVE declaration. Any declaration anomaly returns null — ladder
148
+ // (a) goes dark while the residual guard (b) keeps running (the decoupled fail-safe invariant).
149
+ export const readDeclarationGates = (projectRoot, deps = {}) => {
150
+ const readFile = deps.readFile ?? readFileSync;
151
+ if (typeof projectRoot !== 'string' || projectRoot === '') return null;
152
+ try {
153
+ const validated = validateDeclarationShape(JSON.parse(readFile(join(projectRoot, GATES_REL), UTF8)));
154
+ return validated.ok ? validated.gates : null;
155
+ } catch {
156
+ return null;
157
+ }
158
+ };
159
+
160
+ // ── seeded-core prefix + residual detection (ladder b) ────────────────────────────────
161
+
162
+ const getAllowCommandPrefix = (pattern) => {
163
+ const match = pattern.match(BASH_ALLOW_PATTERN);
164
+ return match ? match[1] : undefined;
165
+ };
166
+
167
+ const tokenizeCommand = (command) => command.trim().split(WHITESPACE_PATTERN).filter(Boolean);
168
+
169
+ const CORE_PREFIX_TOKEN_LISTS = Object.freeze(
170
+ SEEDED_READONLY_CORE.map(getAllowCommandPrefix)
171
+ .filter(Boolean)
172
+ .map((prefix) => Object.freeze(tokenizeCommand(prefix))),
173
+ );
174
+
175
+ // `Bash(git status:*)` approves `git status <anything>` at a word boundary — the guard matches
176
+ // the same way: a core pattern's tokens must be the command's LEADING tokens exactly.
177
+ export const matchSeededCorePrefix = (command) => {
178
+ const tokens = tokenizeCommand(command);
179
+ const matched = CORE_PREFIX_TOKEN_LISTS.find(
180
+ (prefixTokens) =>
181
+ prefixTokens.length <= tokens.length && prefixTokens.every((token, index) => tokens[index] === token),
182
+ );
183
+ return matched ? matched.join(' ') : null;
184
+ };
185
+
186
+ // String-level, conservative: the hook sees the PRE-SHELL command string, so every class is a raw
187
+ // substring scan (never a whitespace-token check — a token check misses `"--output=f"` / `'>' f`
188
+ // where the quotes are still in the string but the shell will strip them). A quoted metacharacter
189
+ // or write flag may over-ASK, never under-allow (no shell parsing in a dependency-free hook).
190
+ export const detectResidualClasses = (command) => {
191
+ const classes = [];
192
+ if (RESIDUAL_FORMS.writeRedirections.some((form) => command.includes(form))) {
193
+ classes.push(RESIDUAL_CLASS_REDIRECTION);
194
+ }
195
+ if (RESIDUAL_FORMS.commandSubstitutions.some((form) => command.includes(form))) {
196
+ classes.push(RESIDUAL_CLASS_SUBSTITUTION);
197
+ }
198
+ if (RESIDUAL_FORMS.boundedWriteFlags.some((flag) => command.includes(flag))) {
199
+ classes.push(RESIDUAL_CLASS_OUTPUT_FLAG);
200
+ }
201
+ return classes;
202
+ };
203
+
204
+ // ── the decision ladder (pure core) ───────────────────────────────────────────────────
205
+
206
+ export const decideBashCall = ({ command, permissionMode, cwdIsProjectRoot, gates }) => {
207
+ const trimmed = command.trim();
208
+ // (a) declared-gate exact match — all three invariants (declaration valid, cwd = project
209
+ // root, mode in the allow fence) must hold; otherwise fall through, never error.
210
+ if (Array.isArray(gates) && cwdIsProjectRoot === true && ALLOW_PERMISSION_MODES.includes(permissionMode)) {
211
+ const declaredGate = gates.find((gate) => gate.cmd.trim() === trimmed);
212
+ if (declaredGate !== undefined) {
213
+ return {
214
+ permissionDecision: DECISION_ALLOW,
215
+ permissionDecisionReason: `agent-workflow gates: byte-exact match of declared gate "${declaredGate.id}" (${GATES_REL}), invoked from the project root`,
216
+ };
217
+ }
218
+ }
219
+ // (b) residual guard — no declaration needed; not mode-fenced (an ask never loosens).
220
+ const corePrefix = matchSeededCorePrefix(trimmed);
221
+ if (corePrefix !== null) {
222
+ const residualClasses = detectResidualClasses(trimmed);
223
+ if (residualClasses.length > 0) {
224
+ return {
225
+ permissionDecision: DECISION_ASK,
226
+ permissionDecisionReason: `agent-workflow residual guard: read-only-seeded "${corePrefix}" carries ${residualClasses.join(' + ')} — a settings allow rule cannot see runtime shape; confirm by hand`,
227
+ };
228
+ }
229
+ }
230
+ // (c) no decision — the normal permission flow proceeds unchanged.
231
+ return null;
232
+ };
233
+
234
+ // ── stdin-driven main ─────────────────────────────────────────────────────────────────
235
+
236
+ const resolveRealpath = (maybePath, realpath) => {
237
+ if (typeof maybePath !== 'string' || maybePath === '') return null;
238
+ try {
239
+ return realpath(maybePath);
240
+ } catch {
241
+ return null;
242
+ }
243
+ };
244
+
245
+ // Parse one PreToolUse stdin payload and run the ladder. Returns the decision object or null
246
+ // (null → no output, exit 0). Injectable deps keep the tests hermetic.
247
+ export const runHook = (rawInput, deps = {}) => {
248
+ const env = deps.env ?? process.env;
249
+ const realpath = deps.realpath ?? realpathSync;
250
+ const parseInput = () => {
251
+ try {
252
+ return JSON.parse(rawInput);
253
+ } catch {
254
+ return null;
255
+ }
256
+ };
257
+ const input = parseInput();
258
+ // Input anomaly → the WHOLE hook goes dark (distinct from a declaration anomaly, which only
259
+ // darkens ladder (a)).
260
+ if (input === null || typeof input !== 'object' || Array.isArray(input)) return null;
261
+ if (input.tool_name !== BASH_TOOL_NAME) return null;
262
+ const command = input.tool_input === null || typeof input.tool_input !== 'object' ? undefined : input.tool_input.command;
263
+ if (typeof command !== 'string' || command.trim() === '') return null;
264
+
265
+ const envRoot = env[PROJECT_DIR_ENV];
266
+ const projectRoot =
267
+ typeof envRoot === 'string' && envRoot !== '' ? envRoot : typeof input.cwd === 'string' ? input.cwd : null;
268
+ const rootReal = resolveRealpath(projectRoot, realpath);
269
+ const cwdReal = resolveRealpath(input.cwd, realpath);
270
+ return decideBashCall({
271
+ command,
272
+ permissionMode: input.permission_mode,
273
+ cwdIsProjectRoot: rootReal !== null && cwdReal !== null && rootReal === cwdReal,
274
+ gates: readDeclarationGates(projectRoot, deps),
275
+ });
276
+ };
277
+
278
+ export const formatDecision = (decision) =>
279
+ JSON.stringify({ hookSpecificOutput: { hookEventName: HOOK_EVENT_NAME, ...decision } });
280
+
281
+ const readStdin = async () => {
282
+ const chunks = [];
283
+ for await (const chunk of process.stdin) chunks.push(chunk);
284
+ return Buffer.concat(chunks).toString(UTF8);
285
+ };
286
+
287
+ export const main = async () => {
288
+ // Exit 0 on EVERY path — never exit 2 (an immediate block), never a top-level throw: this
289
+ // fires on every Bash call and must never become the blocker or the noise.
290
+ try {
291
+ const decision = runHook(await readStdin());
292
+ if (decision !== null) process.stdout.write(`${formatDecision(decision)}\n`);
293
+ } catch {
294
+ // Deliberately dark: anomalies are the runner's job to report at its own point of use.
295
+ }
296
+ return EXIT_OK;
297
+ };
298
+
299
+ const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
300
+ if (isDirectRun) main().then((code) => process.exit(code));
@@ -18,8 +18,9 @@ Every AI agent working on this project **must** adhere to this protocol before w
18
18
  ### 1.1. Start of Session
19
19
  Read in order, then confirm before starting:
20
20
  1. `docs/ai/handover.md` — where we left off.
21
- 2. `docs/ai/active_plan.md` — pick ONE task from "Immediate Priority".
22
- 3. Confirm with the user: *"I'm taking task X. Confirm?"*
21
+ 2. `docs/ai/orchestration.json` — the CONFIGURED orchestration recipes (per activity/slot). Honor them: a silent recipe downgrade is a forbidden substitution.
22
+ 3. `docs/ai/active_plan.md` pick ONE task from "Immediate Priority".
23
+ 4. Confirm with the user: *"I'm taking task X. Confirm?"*
23
24
 
24
25
  ### 1.2. During Work
25
26
  **Before any feature:** read the relevant page spec (`docs/ai/pages/<page>.md`). If behaviour changes, update the spec FIRST so docs and code never diverge.
@@ -14,6 +14,7 @@ maxLines: 80
14
14
 
15
15
  **Last session:** {{DATE}}
16
16
  **Branch:** {{BRANCH}}
17
+ **Active recipes:** not recorded yet — paste the configured-recipe line (composed from `docs/ai/orchestration.json`) and refresh it whenever the config changes.
17
18
 
18
19
  ## What was done last session
19
20
 
@@ -119,12 +119,19 @@ const CATALOG = [
119
119
  kind: WRITER,
120
120
  oneLine: 'Place bundled cheap-model subagent definitions for mechanical work — sweeps, changelog skeletons, gate triage (Claude Code; opt-in; preview first).',
121
121
  },
122
+ {
123
+ key: 'hook',
124
+ invocation: invocationOf('hook'),
125
+ group: 'Configure',
126
+ kind: WRITER,
127
+ oneLine: 'Auto-approve your own declared gate commands (docs/ai/gates.json) via a Claude Code hook — exact matches only, previews first (opt-in).',
128
+ },
122
129
  {
123
130
  key: 'recipes',
124
131
  invocation: invocationOf('recipes'),
125
132
  group: 'Orchestrate',
126
133
  kind: READ_ONLY,
127
- oneLine: 'See the orchestration recipes (Solo / Reviewed / Council / Delegated) and which one fits this environment.',
134
+ oneLine: 'See the orchestration recipes (Solo / Reviewed / Council / Delegated), which one fits this environment, and the configured per-activity line to paste at session start.',
128
135
  },
129
136
  {
130
137
  key: 'procedures',
@@ -140,6 +147,20 @@ const CATALOG = [
140
147
  kind: WRITER,
141
148
  oneLine: 'Set the orchestration recipe for an activity from plain language — previews the change, then writes the config when you confirm.',
142
149
  },
150
+ {
151
+ key: 'review-state',
152
+ invocation: invocationOf('review-state'),
153
+ group: 'Orchestrate',
154
+ kind: READ_ONLY,
155
+ oneLine: 'Check that every configured review backend has receipted the current uncommitted tree with a fresh grounded review; --check turns it into a gate exit code.',
156
+ },
157
+ {
158
+ key: 'grounding',
159
+ invocation: invocationOf('grounding'),
160
+ group: 'Orchestrate',
161
+ kind: WRITER,
162
+ oneLine: 'Assemble the verified-facts payload a grounded review runs against — the entry-point Hard Constraints plus a plan’s decision sections; prints it, or writes ONE scratch file with --out.',
163
+ },
143
164
  ];
144
165
 
145
166
  // Deep-freeze: freeze the array AND every entry, so the catalog is genuinely immutable at runtime
@@ -164,8 +185,8 @@ export const kindOf = (key) => byKey.get(key)?.kind ?? null;
164
185
  // (`upgrade`) OR the full slash form (`/agent-workflow-kit upgrade`); the first word is significant
165
186
  // and trailing args are ignored. Precise semantics:
166
187
  // undefined / null / '' / whitespace-only / the exact bare invocation → 'bootstrap'
167
- // a known first token (upgrade/status/setup/backends/recipes/procedures/velocity/uninstall/help)
168
- // → that mode
188
+ // a known first token (upgrade/status/setup/backends/recipes/procedures/velocity/agents/hook/
189
+ // gates/set-recipe/uninstall/help) → that mode
169
190
  // anything else (unrecognized / ambiguous) → 'help' (read-only — NEVER a writer/guarded mode)
170
191
  export const routeInvocation = (token) => {
171
192
  if (token == null) return BARE_INVOCATION_MODE;
@@ -88,6 +88,7 @@ const RAW_BACKENDS = [
88
88
  ],
89
89
  grounding: 'automatic — the wrapper precomputes the full working-tree change set (repo map, status, diffs, untracked contents) and codex auto-merges the root AGENTS.md; no grounding flags',
90
90
  continue: [],
91
+ receipt: 'side effect — a successful review appends one JSON receipt line to <git dir>/agent-workflow-review-receipts.jsonl (AW_REVIEW_RECEIPTS overrides): fingerprint = sha256 over the canonical uncommitted-state payload (staged diff + unstaged diff + untracked-not-ignored contents — the review-payload domain) in code mode, the artifact-file sha256 in plan mode; verdict parsed from the mandated literal verdict line (schema mode: the verdict field); always fresh:true (one-shot) + grounded:true (native AGENTS.md auto-merge, factsHash null); a write failure warns, never fails the review',
91
92
  },
92
93
  },
93
94
  bin: 'codex',
@@ -120,6 +121,7 @@ const RAW_BACKENDS = [
120
121
  'agy-review --continue [--decided @f] [--focus "…"]',
121
122
  'agy-review --conversation <id> [--decided @f] [--focus "…"]',
122
123
  ],
124
+ receipt: "side effect — a successful review appends one JSON receipt line to <git dir>/agent-workflow-review-receipts.jsonl (AW_REVIEW_RECEIPTS overrides; plan/diff outside a git tree: warn + skip unless overridden): fingerprint = sha256 over the canonical uncommitted-state payload (staged diff + unstaged diff + untracked-not-ignored contents — the review-payload domain) in code mode, the artifact-file sha256 in plan/diff mode; verdict recorded verbatim from the mandated '### Verdict' section (SHIP / SHIP WITH NITS / REWORK); grounded = whether a NON-EMPTY --facts payload was supplied (an empty payload records grounded:false — fail-closed, the state gate rejects it), factsHash = sha256 of the facts payload; a continuation receipt is fresh:false (informational-only — it cannot attest the folded tree); a write failure warns, never fails the review",
123
125
  },
124
126
  },
125
127
  bin: 'agy',
@@ -44,6 +44,10 @@ import {
44
44
  readSettingsFile,
45
45
  resolveEffectiveMode,
46
46
  } from './velocity-profile.mjs';
47
+ // The gate-hook writer's own wired-detection + placed path — reused by the settings survey (one
48
+ // implementation; gate-hook imports only node builtins + velocity-profile, so no cycle).
49
+ import { HOOK_FILE_REL as GATE_HOOK_FILE_REL, isHookWired } from './gate-hook.mjs';
50
+ import { GATES_REL } from './run-gates.mjs';
47
51
  // The status vocabulary (manifestState constants, internal→public maps, display names, the no-leak
48
52
  // forbidden set) lives in the frozen labels.mjs LEAF (Plan §4.2 B1) so the import graph is acyclic —
49
53
  // nothing imports family-registry for vocabulary. Imported here for internal use; the public subset is
@@ -438,11 +442,32 @@ export const surveyVelocity = (dir, deps = {}) => {
438
442
  }
439
443
  };
440
444
 
445
+ // gate hook: the opt-in PreToolUse gate-approval hook (Mode: hook) — wired (the settings entry, in
446
+ // EITHER settings file: the hooks contract merges both), the placed hook file, and the gate
447
+ // declaration it consumes. Read-only; the wired-detection is REUSED from the writer (gate-hook.mjs
448
+ // isHookWired — one implementation, never a drifting copy).
449
+ export const surveyGateHook = (dir, deps = {}) => {
450
+ try {
451
+ const d = resolve(dir);
452
+ const exists = deps.exists ?? existsSync;
453
+ const project = readSettingsFile(join(d, SETTINGS_FILE), { ...deps, cwd: d });
454
+ const local = readSettingsFile(join(d, SETTINGS_LOCAL_FILE), { ...deps, cwd: d });
455
+ return {
456
+ wired: isHookWired(project.data) || isHookWired(local.data),
457
+ filePlaced: Boolean(exists(join(d, GATE_HOOK_FILE_REL))),
458
+ declarationPresent: Boolean(exists(join(d, GATES_REL))),
459
+ };
460
+ } catch (err) {
461
+ return { error: localizeError(err) };
462
+ }
463
+ };
464
+
441
465
  // the project-scoped settings survey (needs a project dir). Each area is independently localized-on-error.
442
466
  export const surveySettings = (dir, deps = {}) => ({
443
467
  recipes: surveyRecipes(dir, deps),
444
468
  attribution: surveyAttribution(dir, deps),
445
469
  velocity: surveyVelocity(dir, deps),
470
+ hook: surveyGateHook(dir, deps),
446
471
  });
447
472
 
448
473
  // bridges: HOST-scoped (no project needed). Wrapper command NAMES come from FAMILY_MEMBERS[].wrapperCmds