@webpieces/ai-hook-rules 0.4.710 → 0.4.712

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.
Files changed (59) hide show
  1. package/package.json +2 -2
  2. package/src/adapters/detect-ai.d.ts +5 -4
  3. package/src/adapters/detect-ai.js +5 -4
  4. package/src/adapters/detect-ai.js.map +1 -1
  5. package/src/adapters/hook-core.js +9 -3
  6. package/src/adapters/hook-core.js.map +1 -1
  7. package/src/bin/codex-guard-presence.d.ts +86 -0
  8. package/src/bin/codex-guard-presence.js +150 -0
  9. package/src/bin/codex-guard-presence.js.map +1 -0
  10. package/src/bin/codex-trust.d.ts +99 -0
  11. package/src/bin/codex-trust.js +199 -0
  12. package/src/bin/codex-trust.js.map +1 -0
  13. package/src/bin/hook-registration.d.ts +200 -43
  14. package/src/bin/hook-registration.js +218 -89
  15. package/src/bin/hook-registration.js.map +1 -1
  16. package/src/bin/l0-allowlist.d.ts +0 -30
  17. package/src/bin/l0-allowlist.js +9 -67
  18. package/src/bin/l0-allowlist.js.map +1 -1
  19. package/src/bin/l0-decide.d.ts +43 -0
  20. package/src/bin/l0-decide.js +90 -0
  21. package/src/bin/l0-decide.js.map +1 -0
  22. package/src/bin/l0-ignored-tools.d.ts +38 -0
  23. package/src/bin/l0-ignored-tools.js +44 -0
  24. package/src/bin/l0-ignored-tools.js.map +1 -0
  25. package/src/bin/setup-config.d.ts +19 -0
  26. package/src/bin/setup-config.js +370 -0
  27. package/src/bin/setup-config.js.map +1 -0
  28. package/src/bin/setup.d.ts +56 -21
  29. package/src/bin/setup.js +125 -370
  30. package/src/bin/setup.js.map +1 -1
  31. package/src/bin/shim-audit-log.js +10 -1
  32. package/src/bin/shim-audit-log.js.map +1 -1
  33. package/src/bin/shim-deny-reason.js +4 -4
  34. package/src/bin/shim-deny-reason.js.map +1 -1
  35. package/src/bin/shim.d.ts +2 -0
  36. package/src/bin/shim.js +21 -1
  37. package/src/bin/shim.js.map +1 -1
  38. package/src/bin/upgrade-shim.d.ts +1 -1
  39. package/src/bin/upgrade-shim.js +12 -8
  40. package/src/bin/upgrade-shim.js.map +1 -1
  41. package/src/core/agent-event.d.ts +16 -0
  42. package/src/core/agent-event.js +17 -1
  43. package/src/core/agent-event.js.map +1 -1
  44. package/src/core/ai-type-context.d.ts +36 -0
  45. package/src/core/ai-type-context.js +45 -0
  46. package/src/core/ai-type-context.js.map +1 -0
  47. package/src/core/decision-log.js +9 -0
  48. package/src/core/decision-log.js.map +1 -1
  49. package/src/core/l0-matrix.js +14 -6
  50. package/src/core/l0-matrix.js.map +1 -1
  51. package/src/core/l0-tooling-doc.d.ts +3 -2
  52. package/src/core/l0-tooling-doc.js +22 -10
  53. package/src/core/l0-tooling-doc.js.map +1 -1
  54. package/src/core/rejection-log.js +5 -1
  55. package/src/core/rejection-log.js.map +1 -1
  56. package/src/index.d.ts +1 -1
  57. package/src/index.js +4 -1
  58. package/src/index.js.map +1 -1
  59. package/templates/ai-hook.sh +15 -1
@@ -1,8 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.ENV_SURFACE = exports.REGISTRATION_SURFACE = exports.SHIM_SURFACE = exports.SettingsRepair = exports.GUARDS_MATCHER = exports.RULES_MATCHER = exports.GUARDS_BIN = exports.RULES_BIN = exports.HookRegistrationEntry = exports.LEGACY_MARKER_REMOVE_AFTER = exports.LEGACY_GUARANTEE_ROOT_MARKER = void 0;
4
- exports.shimCommand = shimCommand;
5
- exports.guardHookEntry = guardHookEntry;
3
+ exports.ENV_SURFACE = exports.CODEX_REGISTRATION_SURFACE = exports.REGISTRATION_SURFACE = exports.SHIM_SURFACE = exports.SettingsRepair = exports.HARNESS_REGISTRATIONS = exports.CODEX_REGISTRATION = exports.CLAUDE_REGISTRATION = exports.HarnessRegistration = exports.GUARDS_BIN = exports.RULES_BIN = exports.HookRegistrationEntry = exports.LEGACY_MARKER_REMOVE_AFTER = exports.LEGACY_GUARANTEE_ROOT_MARKER = void 0;
6
4
  exports.isManagedCommand = isManagedCommand;
7
5
  exports.managedEntries = managedEntries;
8
6
  exports.registeredBins = registeredBins;
@@ -13,7 +11,6 @@ exports.addHookEntry = addHookEntry;
13
11
  exports.envStale = envStale;
14
12
  exports.applyManagedEnv = applyManagedEnv;
15
13
  exports.repairRegistration = repairRegistration;
16
- exports.projectSettingsPaths = projectSettingsPaths;
17
14
  exports.readSettings = readSettings;
18
15
  exports.writeSettings = writeSettings;
19
16
  exports.registrationStaleAt = registrationStaleAt;
@@ -27,12 +24,20 @@ const to_error_1 = require("../core/to-error");
27
24
  const shim_1 = require("./shim");
28
25
  const managed_env_1 = require("./managed-env");
29
26
  /**
30
- * THE INSTALLED HOOK SURFACE — two hooks, both ABSOLUTE, and the ONE place their spelling is defined.
27
+ * THE INSTALLED HOOK SURFACE — two hooks per harness, all ABSOLUTE, and the ONE place their spelling is
28
+ * defined. See `HarnessRegistration` below, which is what makes "per harness" data rather than four
29
+ * module constants that were only ever true of Claude Code.
31
30
  *
32
31
  * ─── ONE GOVERNOR: the MAIN tree judges every tree ─────────────────────────────────────────────────
33
32
  *
34
- * H1 sh "$CLAUDE_PROJECT_DIR/.claude/webpieces/ai-hook.sh" wp-ai-guards-hook Write|…|Bash|Read
35
- * H2 sh "$CLAUDE_PROJECT_DIR/.claude/webpieces/ai-hook.sh" wp-ai-rules-hook Write|Edit|MultiEdit
33
+ * Claude Code — .claude/settings.json
34
+ * H1 sh "$CLAUDE_PROJECT_DIR/.claude/webpieces/ai-hook.sh" wp-ai-guards-hook Write|…|Bash|Read
35
+ * H2 sh "$CLAUDE_PROJECT_DIR/.claude/webpieces/ai-hook.sh" wp-ai-rules-hook Write|Edit|MultiEdit
36
+ * Codex — .codex/hooks.json (the SAME `hooks.PreToolUse` JSON shape, measured)
37
+ * H1 sh "$PWD/.claude/webpieces/ai-hook.sh" wp-ai-guards-hook Bash|apply_patch
38
+ * H2 sh "$PWD/.claude/webpieces/ai-hook.sh" wp-ai-rules-hook apply_patch
39
+ *
40
+ * ONE shim file serves all four: `.claude/` there is a path, not a claim about who is calling.
36
41
  *
37
42
  * This REPLACES a three-hook form in which these two were RELATIVE (`sh ".claude/webpieces/ai-hook.sh"`)
38
43
  * and a third hook, L-1 `guarantee-root.sh`, existed solely to guarantee that relative path resolved.
@@ -109,43 +114,137 @@ class HookRegistrationEntry {
109
114
  exports.HookRegistrationEntry = HookRegistrationEntry;
110
115
  exports.RULES_BIN = 'wp-ai-rules-hook';
111
116
  exports.GUARDS_BIN = 'wp-ai-guards-hook';
112
- exports.RULES_MATCHER = 'Write|Edit|MultiEdit';
113
- // Guards match Bash (git/PR guards), Write|Edit|MultiEdit (file-scoped guards) AND Read — Read carries
114
- // no guard, but the guards hook owns the per-invocation audit log, so matching Read records every file
115
- // the AI opens (log-and-allow fast path in hook-core.ts; a Read is never blocked).
116
- exports.GUARDS_MATCHER = 'Write|Edit|MultiEdit|Bash|Read';
117
117
  /**
118
- * The guard-hook command`sh "$CLAUDE_PROJECT_DIR/.claude/webpieces/ai-hook.sh" <bin>`.
119
- *
120
- * ABSOLUTE. This replaced a RELATIVE spelling, and the reversal is the whole point of this release.
121
- *
122
- * The relative form existed to give each git tree its own release, binary and pin. **It never delivered
123
- * that.** Measured 2026-08-10: a linked worktree has no `node_modules`, so ai-hook.sh's upward walk
124
- * executes the PRIMARY's binary — `readlink -f` resolved a worktree agent's bin to
125
- * `<primary>/node_modules/@webpieces/ai-hook-rules`. A worktree ran its own SCRIPT and its own CONFIG,
126
- * never its own release. The property was fiction, and paying for it cost an entire guard layer: a
127
- * relative path only resolves at a tree root, so L-1 (guarantee-root.sh) had to deny every `cd` into a
128
- * project subdirectory, which produced the force-to-root bug class, the reaped-worktree `cd`
129
- * prescription, and a measured hard deadlock where L-1 told a worktree-isolated agent to `cd` to the
130
- * primary clone which the harness refuses for an isolated agent.
131
- *
132
- * Absolute resolves from ANY cwd, so L-1 has no job left and is deleted. One governor: the MAIN tree
133
- * judges every tree, which is what was already happening via the borrowed binary the design now says
134
- * so out loud, and `VersionSyncGuard` blocks the case where that is the wrong answer.
135
- *
136
- * Invoked via `sh <file>` rather than executed directly so a missing executable bit on the checked-in
137
- * shim (fresh clone, a filesystem that drops the bit, git core.fileMode quirks) can never break the hook
138
- * with a raw `Permission denied` on every tool call. Quoted to survive spaces in the path.
118
+ * ONE HARNESS'S registration surface where its hooks live, what they match, and how they name the
119
+ * shim. Data-only → a class, per CLAUDE.md.
120
+ *
121
+ * ─── Why this exists ──────────────────────────────────────────────────────────────────────────────
122
+ * The matchers and the shim command used to be four module constants written for Claude Code alone.
123
+ * Applied to Codex they are wrong in both halves, and the way they are wrong is SILENT:
124
+ *
125
+ * - MATCHER. Codex's file-editing tool is `apply_patch` (MEASURED, codex-cli 0.151.0). A matcher of
126
+ * `Write|Edit|MultiEdit` matches it never, so every file rule is unreachable while the settings file
127
+ * looks perfectly installed. Its shell tool IS called `Bash` Codex reuses Claude's name which is
128
+ * the trap: half the matcher works, so the hooks appear to be running.
129
+ * - ANCHOR. `$CLAUDE_PROJECT_DIR` does not exist in a Codex hook's environment (measured: 46 vars, no
130
+ * such key), so the command expands to `sh "/.claude/webpieces/ai-hook.sh"`, which dies — and per the
131
+ * hooks protocol a non-2 non-zero exit is a NON-BLOCKING error, i.e. a silent unguarded allow.
132
+ *
133
+ * Both halves of that were live in real repos, written by a Codex Desktop sync that transliterated the
134
+ * Claude setup. Making the registration per-harness DATA is what stops a future harness inheriting a
135
+ * matcher that was never true for it.
136
+ *
137
+ * ─── ONE SHIM, both harnesses ─────────────────────────────────────────────────────────────────────
138
+ * `SHIM_MARKER` is shared deliberately: `.claude/webpieces/ai-hook.sh` is the single fail-closed entry
139
+ * point, and moving or duplicating it would double the L0 allowlist regexes, the drift surfaces and the
140
+ * cures. The `.claude/` prefix is a path, not a claim about which agent is calling.
139
141
  */
140
- // webpieces-disable no-function-outside-class -- this module must load on a tree too broken to build a DI container (upgrade-shim.ts depends on that), so it is module-scope like its siblings shim.ts
141
- function shimCommand(bin) {
142
- return `sh "$CLAUDE_PROJECT_DIR/${shim_1.SHIM_MARKER}" ${bin}`;
143
- }
144
- /** The registration entry for one guard bin. */
145
- // webpieces-disable no-function-outside-class -- module-scope for the same dependency-free reason as shimCommand above
146
- function guardHookEntry(bin) {
147
- return new HookRegistrationEntry(bin === exports.RULES_BIN ? exports.RULES_MATCHER : exports.GUARDS_MATCHER, shimCommand(bin));
142
+ class HarnessRegistration {
143
+ aiType;
144
+ label;
145
+ rulesMatcher;
146
+ guardsMatcher;
147
+ settingsFiles;
148
+ shimAnchor;
149
+ managesEnv;
150
+ registrationSurface;
151
+ // eslint-disable-next-line @typescript-eslint/max-params
152
+ constructor(aiType,
153
+ /** How the installer and the drift report name this harness to a human. */
154
+ label,
155
+ /** Which tool names the RULES hook (code-style, file-scoped) must see. */
156
+ rulesMatcher,
157
+ /**
158
+ * Which tool names the GUARDS hook must see. Wider than the rules matcher by the shell tool and,
159
+ * for Claude Code, by `Read` — Read carries no guard, but the guards hook owns the
160
+ * per-invocation audit log, so matching it records every file the AI opens (log-and-allow fast
161
+ * path in hook-core.ts; a Read is never blocked). Codex has no Read tool at all: a read arrives
162
+ * as `Bash` running a pager, which the shell matcher already covers and read-parity synthesizes.
163
+ */
164
+ guardsMatcher,
165
+ /** The settings files this harness's hooks can be installed into, relative to the repo root. */
166
+ settingsFiles,
167
+ /**
168
+ * THE PREFIX THE SHIM PATH IS ANCHORED ON, and the reason each harness needs its own.
169
+ *
170
+ * **BYTE-STABILITY IS A HARD CONSTRAINT ON THE CODEX VALUE.** Codex trusts a hook entry
171
+ * TOFU — `~/.codex/config.toml` records a `trusted_hash` per entry — so ANY change to these bytes
172
+ * invalidates that trust and re-prompts the human, whose third option is `Continue without
173
+ * trusting (hooks won't run)`: one keystroke to a silently unguarded session. Changing this
174
+ * string is therefore never a cosmetic edit. The hash is NOT reproducible from outside Codex
175
+ * (16 encodings tried against a file we authored), so the installer can never repair trust for
176
+ * itself — see ./codex-trust.ts, which REPORTS and never writes.
177
+ */
178
+ shimAnchor,
179
+ /**
180
+ * True when this harness's settings file also carries the managed `env` block. Claude Code's
181
+ * settings `env` is inherited by every subagent, which is what makes it the right home for the
182
+ * Bash-cwd pin (see managed-env.ts). Codex has no equivalent surface — and needs none: its cwd
183
+ * is MEASURED not to drift at all (`cd x && pwd` prints x, the next call is back at the repo
184
+ * root, for the coordinator and for subagents alike).
185
+ */
186
+ managesEnv,
187
+ /** What `managedSurfaceDrift()` calls this harness's registration when it has moved. */
188
+ registrationSurface) {
189
+ this.aiType = aiType;
190
+ this.label = label;
191
+ this.rulesMatcher = rulesMatcher;
192
+ this.guardsMatcher = guardsMatcher;
193
+ this.settingsFiles = settingsFiles;
194
+ this.shimAnchor = shimAnchor;
195
+ this.managesEnv = managesEnv;
196
+ this.registrationSurface = registrationSurface;
197
+ }
198
+ /** Which matcher one guard bin registers under. */
199
+ matcherFor(bin) {
200
+ return bin === exports.RULES_BIN ? this.rulesMatcher : this.guardsMatcher;
201
+ }
202
+ /**
203
+ * The guard-hook command — `sh "<anchor>/.claude/webpieces/ai-hook.sh" <bin>`.
204
+ *
205
+ * ABSOLUTE, in both harnesses. Claude's anchor replaced a RELATIVE spelling and that reversal is
206
+ * documented at length in this file's header: relative resolves only at a tree root, a hook that
207
+ * cannot resolve exits 127, and per the hooks reference that is a NON-BLOCKING error — a silent
208
+ * unguarded allow. It also cost an entire guard layer (L-1) whose only job was to police the `cd`
209
+ * that made the relative path resolvable.
210
+ *
211
+ * Invoked via `sh <file>` rather than executed directly so a missing executable bit on the
212
+ * checked-in shim (fresh clone, a filesystem that drops the bit, git core.fileMode quirks) can never
213
+ * break the hook with a raw `Permission denied` on every tool call. Quoted to survive spaces.
214
+ */
215
+ shimCommand(bin) {
216
+ return `sh "${this.shimAnchor}/${shim_1.SHIM_MARKER}" ${bin}`;
217
+ }
218
+ /** The registration entry for one guard bin under this harness. */
219
+ entryFor(bin) {
220
+ return new HookRegistrationEntry(this.matcherFor(bin), this.shimCommand(bin));
221
+ }
222
+ /** This harness's settings files under one repo root, absolute. */
223
+ settingsPaths(projectRoot) {
224
+ return this.settingsFiles.map((file) => path.join(projectRoot, ...file.split('/')));
225
+ }
148
226
  }
227
+ exports.HarnessRegistration = HarnessRegistration;
228
+ exports.CLAUDE_REGISTRATION = new HarnessRegistration('claude-code', 'Claude Code', 'Write|Edit|MultiEdit', 'Write|Edit|MultiEdit|Bash|Read', ['.claude/settings.json', '.claude/settings.local.json'], '$CLAUDE_PROJECT_DIR', true, '.claude/settings.json hook registration');
229
+ /**
230
+ * Codex's registration, every value of it MEASURED against codex-cli 0.151.0 rather than assumed.
231
+ *
232
+ * `$PWD` is the anchor because there is no project-dir variable to use and none is needed: the payload
233
+ * `cwd` and the hook process's own `PWD` are both the repo root on EVERY call — for the coordinator and
234
+ * for subagents — and a `cd` inside one command never survives into the next. That was measured, not
235
+ * hoped for, and it is the same effect Claude Code gets from the managed
236
+ * `CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR` env entry. See `shimAnchor` for why these exact bytes are
237
+ * not free to change.
238
+ */
239
+ exports.CODEX_REGISTRATION = new HarnessRegistration('codex', 'Codex', 'apply_patch', 'Bash|apply_patch', ['.codex/hooks.json'], '$PWD', false, '.codex/hooks.json hook registration');
240
+ /**
241
+ * Every harness webpieces arms, in installer order.
242
+ *
243
+ * The drift check, the repair and the installer all iterate THIS, so a harness cannot be armed by the
244
+ * installer and then left unvalidated — which is exactly the state `.codex/hooks.json` was in before it
245
+ * was a managed surface: written by something else, silently wrong, and invisible to every check.
246
+ */
247
+ exports.HARNESS_REGISTRATIONS = [exports.CLAUDE_REGISTRATION, exports.CODEX_REGISTRATION];
149
248
  /**
150
249
  * True when this PreToolUse command is one webpieces owns — in ANY spelling it has ever shipped.
151
250
  *
@@ -194,9 +293,9 @@ function registeredBins(settings) {
194
293
  * whenever the GUARDS bin was present; it is retired, and a settings file still carrying it is STALE —
195
294
  * `repairRegistration()` removes it via isManagedCommand()'s legacy marker.
196
295
  */
197
- // webpieces-disable no-function-outside-class -- module-scope for the same dependency-free reason as shimCommand above
198
- function expectedEntries(bins) {
199
- return bins.map((bin) => guardHookEntry(bin));
296
+ // webpieces-disable no-function-outside-class -- module-scope for the same dependency-free reason as HarnessRegistration's siblings
297
+ function expectedEntries(harness, bins) {
298
+ return bins.map((bin) => harness.entryFor(bin));
200
299
  }
201
300
  /**
202
301
  * True when a settings file registers webpieces hooks in a shape this release does not expect: a
@@ -206,12 +305,12 @@ function expectedEntries(bins) {
206
305
  * carries no meaning and reordering must not read as drift.
207
306
  */
208
307
  // webpieces-disable no-function-outside-class -- module-scope for the same dependency-free reason as shimCommand above
209
- function registrationStale(settings) {
308
+ function registrationStale(harness, settings) {
210
309
  const bins = registeredBins(settings);
211
310
  if (bins.length === 0)
212
311
  return false;
213
312
  const have = managedEntries(settings);
214
- const want = expectedEntries(bins);
313
+ const want = expectedEntries(harness, bins);
215
314
  if (have.length !== want.length)
216
315
  return true;
217
316
  return want.some((w) => !have.some((h) => h.sameAs(w)));
@@ -310,29 +409,24 @@ function applyManagedEnv(settings) {
310
409
  * that skips half the surface is the failure mode `upgrade-shim.ts`'s header exists to prevent.
311
410
  */
312
411
  // webpieces-disable no-function-outside-class -- module-scope for the same dependency-free reason as shimCommand above
313
- function repairRegistration(settings) {
412
+ function repairRegistration(harness, settings) {
314
413
  const bins = registeredBins(settings);
315
414
  if (bins.length === 0)
316
415
  return false;
317
416
  let changed = false;
318
- if (registrationStale(settings)) {
417
+ if (registrationStale(harness, settings)) {
319
418
  dropManagedEntries(settings);
320
- for (const entry of expectedEntries(bins))
419
+ for (const entry of expectedEntries(harness, bins))
321
420
  addHookEntry(settings, entry);
322
421
  changed = true;
323
422
  }
324
- if (applyManagedEnv(settings))
423
+ // Only where the harness HAS that surface. Codex's hooks.json has no `env` block to manage, and
424
+ // inventing one there would write a key Codex does not read — a managed surface nothing consumes is
425
+ // a surface that can drift with no consequence and no way to notice.
426
+ if (harness.managesEnv && applyManagedEnv(settings))
325
427
  changed = true;
326
428
  return changed;
327
429
  }
328
- /** The two project settings files the installer can write. */
329
- // webpieces-disable no-function-outside-class -- module-scope for the same dependency-free reason as shimCommand above
330
- function projectSettingsPaths(projectRoot) {
331
- return [
332
- path.join(projectRoot, '.claude', 'settings.json'),
333
- path.join(projectRoot, '.claude', 'settings.local.json'),
334
- ];
335
- }
336
430
  // webpieces-disable no-function-outside-class -- module-scope for the same dependency-free reason as shimCommand above
337
431
  function readSettings(settingsPath) {
338
432
  if (!fs.existsSync(settingsPath))
@@ -354,14 +448,24 @@ function writeSettings(settingsPath, settings) {
354
448
  fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
355
449
  fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 4) + '\n');
356
450
  }
357
- /** True when either project settings file under `root` carries a stale registration. */
358
- // webpieces-disable no-function-outside-class -- module-scope for the same dependency-free reason as shimCommand above
359
- function registrationStaleAt(root) {
451
+ /**
452
+ * True when any settings file ONE HARNESS owns under `root` carries a stale registration.
453
+ *
454
+ * Per harness, not per repo, so the drift report can NAME which one moved — `.claude/settings.json` and
455
+ * `.codex/hooks.json` have different cures, and a fault that says only "the registration is stale" sends
456
+ * the reader to the wrong file half the time.
457
+ *
458
+ * A file that does not exist, or exists and registers no webpieces hooks, is never judged (see
459
+ * `registrationStale`). That is what keeps a repo which has never armed Codex from suddenly faulting on
460
+ * a `.codex/hooks.json` it does not have.
461
+ */
462
+ // webpieces-disable no-function-outside-class -- module-scope for the same dependency-free reason as HarnessRegistration's siblings
463
+ function registrationStaleAt(harness, root) {
360
464
  if (root === null)
361
465
  return false;
362
466
  // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
363
467
  try {
364
- return projectSettingsPaths(root).some((p) => registrationStale(readSettings(p)));
468
+ return harness.settingsPaths(root).some((p) => registrationStale(harness, readSettings(p)));
365
469
  }
366
470
  catch (err) {
367
471
  const error = (0, to_error_1.toError)(err);
@@ -369,14 +473,20 @@ function registrationStaleAt(root) {
369
473
  return false;
370
474
  }
371
475
  }
372
- /** True when either project settings file under `root` is missing the managed `env` entry. */
373
- // webpieces-disable no-function-outside-class -- module-scope for the same dependency-free reason as shimCommand above
476
+ /**
477
+ * True when either Claude project settings file under `root` is missing the managed `env` entry.
478
+ *
479
+ * CLAUDE-ONLY by construction, and stated as such rather than looped over the harnesses: `env` is a
480
+ * Claude Code settings surface, Codex has no equivalent, and Codex's cwd is measured not to drift, which
481
+ * is the whole thing the entry is for. See HarnessRegistration.managesEnv.
482
+ */
483
+ // webpieces-disable no-function-outside-class -- module-scope for the same dependency-free reason as HarnessRegistration's siblings
374
484
  function envStaleAt(root) {
375
485
  if (root === null)
376
486
  return false;
377
487
  // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
378
488
  try {
379
- return projectSettingsPaths(root).some((p) => envStale(readSettings(p)));
489
+ return exports.CLAUDE_REGISTRATION.settingsPaths(root).some((p) => envStale(readSettings(p)));
380
490
  }
381
491
  catch (err) {
382
492
  const error = (0, to_error_1.toError)(err);
@@ -402,49 +512,68 @@ class SettingsRepair {
402
512
  }
403
513
  }
404
514
  exports.SettingsRepair = SettingsRepair;
405
- /** Rewrite every stale project settings file under `root`; returns what changed, per file. */
406
- // webpieces-disable no-function-outside-class -- module-scope for the same dependency-free reason as shimCommand above
515
+ /**
516
+ * Rewrite every stale settings file under `root`, for EVERY harness; returns what changed, per file.
517
+ *
518
+ * Existing files only — this never CREATES a registration. Arming a harness is the installer's decision
519
+ * (`wp-install-ai-hooks`); this is the cure for one that has already been armed and has drifted, so a
520
+ * repo that has never armed Codex is left exactly as it was.
521
+ */
522
+ // webpieces-disable no-function-outside-class -- module-scope for the same dependency-free reason as HarnessRegistration's siblings
407
523
  function repairRegistrationAt(root) {
408
524
  const repairs = [];
409
- for (const settingsPath of projectSettingsPaths(root)) {
410
- if (!fs.existsSync(settingsPath))
411
- continue;
412
- const settings = readSettings(settingsPath);
413
- const neededRegistration = registrationStale(settings);
414
- const neededEnv = envStale(settings);
415
- if (!repairRegistration(settings))
416
- continue;
417
- writeSettings(settingsPath, settings);
418
- repairs.push(new SettingsRepair(settingsPath, neededRegistration, neededEnv));
525
+ for (const harness of exports.HARNESS_REGISTRATIONS) {
526
+ for (const settingsPath of harness.settingsPaths(root)) {
527
+ if (!fs.existsSync(settingsPath))
528
+ continue;
529
+ const settings = readSettings(settingsPath);
530
+ const neededRegistration = registrationStale(harness, settings);
531
+ const neededEnv = harness.managesEnv && envStale(settings);
532
+ if (!repairRegistration(harness, settings))
533
+ continue;
534
+ writeSettings(settingsPath, settings);
535
+ repairs.push(new SettingsRepair(settingsPath, neededRegistration, neededEnv));
536
+ }
419
537
  }
420
538
  return repairs;
421
539
  }
422
540
  /**
423
- * The THREE names the drift check reports, so a deny can say WHICH of them moved.
541
+ * The names the drift check reports, so a deny can say WHICH surface moved.
424
542
  *
425
- * There were four. `GUARANTEE_ROOT_SURFACE` is gone with L-1 itself: an absolutely-registered shim
426
- * resolves from any cwd, so there is no launch guarantee left to police and no second .sh file to keep
427
- * byte-locked. A settings file still carrying the retired H1 entry is not its own surface any more
428
- * it is ordinary REGISTRATION drift, which `registrationStaleAt()` already reports and
429
- * `repairRegistration()` already fixes by removing it.
543
+ * There were four, then three, and there are now FOUR again — but the fourth is not the one that was
544
+ * deleted. `GUARANTEE_ROOT_SURFACE` went with L-1 itself: an absolutely-registered shim resolves from
545
+ * any cwd, so there is no launch guarantee left to police and no second .sh file to keep byte-locked. A
546
+ * settings file still carrying the retired H1 entry is not its own surface it is ordinary REGISTRATION
547
+ * drift, which `registrationStaleAt()` reports and `repairRegistration()` fixes by removing it.
548
+ *
549
+ * The new fourth is `.codex/hooks.json`, and it is here because of what happened while it was NOT a
550
+ * managed surface: something else wrote it, with a matcher that matched no Codex file tool and a shim
551
+ * path anchored on a variable Codex does not set, and no check in this package could see it. A file the
552
+ * guards depend on and nothing validates is the exact shape of that incident.
553
+ *
554
+ * Each harness's registration surface is its own name, from HarnessRegistration.registrationSurface, so
555
+ * the deny sends the reader to the file that actually moved.
430
556
  */
431
557
  exports.SHIM_SURFACE = shim_1.SHIM_MARKER;
432
- exports.REGISTRATION_SURFACE = '.claude/settings.json hook registration';
558
+ exports.REGISTRATION_SURFACE = exports.CLAUDE_REGISTRATION.registrationSurface;
559
+ exports.CODEX_REGISTRATION_SURFACE = exports.CODEX_REGISTRATION.registrationSurface;
433
560
  exports.ENV_SURFACE = `.claude/settings.json env.${managed_env_1.BASH_CWD_ENV_KEY}`;
434
561
  /**
435
- * WHICH of the three managed surfaces disagree with this release — the input to fault S.
562
+ * WHICH of the managed surfaces disagree with this release — the input to fault S.
436
563
  *
437
- * All three are checked against the SAME root, resolved from the RUNNING MODULE (governingShimRoot),
564
+ * All of them are checked against the SAME root, resolved from the RUNNING MODULE (governingShimRoot),
438
565
  * never from cwd and never from `$CLAUDE_PROJECT_DIR`: the files we compare and the renderers we
439
566
  * compare them TO must come from one install, or the check straddles two trees and can never converge.
440
567
  */
441
- // webpieces-disable no-function-outside-class -- module-scope for the same dependency-free reason as shimCommand above
568
+ // webpieces-disable no-function-outside-class -- module-scope for the same dependency-free reason as HarnessRegistration's siblings
442
569
  function managedSurfaceDrift(root) {
443
570
  const drifted = [];
444
571
  if ((0, shim_1.committedShimStale)(root))
445
572
  drifted.push(exports.SHIM_SURFACE);
446
- if (registrationStaleAt(root))
447
- drifted.push(exports.REGISTRATION_SURFACE);
573
+ for (const harness of exports.HARNESS_REGISTRATIONS) {
574
+ if (registrationStaleAt(harness, root))
575
+ drifted.push(harness.registrationSurface);
576
+ }
448
577
  if (envStaleAt(root))
449
578
  drifted.push(exports.ENV_SURFACE);
450
579
  return drifted;
@@ -1 +1 @@
1
- {"version":3,"file":"hook-registration.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/bin/hook-registration.ts"],"names":[],"mappings":"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","sourcesContent":["import * as fs from 'fs';\nimport * as path from 'path';\n\nimport { toError } from '../core/to-error';\nimport { SHIM_MARKER, committedShimStale } from './shim';\nimport { BASH_CWD_ENV_KEY, BASH_CWD_ENV_VALUE } from './managed-env';\n\n/**\n * THE INSTALLED HOOK SURFACE — two hooks, both ABSOLUTE, and the ONE place their spelling is defined.\n *\n * ─── ONE GOVERNOR: the MAIN tree judges every tree ─────────────────────────────────────────────────\n *\n * H1 sh \"$CLAUDE_PROJECT_DIR/.claude/webpieces/ai-hook.sh\" wp-ai-guards-hook Write|…|Bash|Read\n * H2 sh \"$CLAUDE_PROJECT_DIR/.claude/webpieces/ai-hook.sh\" wp-ai-rules-hook Write|Edit|MultiEdit\n *\n * This REPLACES a three-hook form in which these two were RELATIVE (`sh \".claude/webpieces/ai-hook.sh\"`)\n * and a third hook, L-1 `guarantee-root.sh`, existed solely to guarantee that relative path resolved.\n *\n * ─── Why the relative experiment was reversed ──────────────────────────────────────────────────────\n * Relative registration was adopted so each git tree would be governed by \"its own release, binary and\n * pin\". MEASURED 2026-08-10: it never delivered that. A linked worktree has NO `node_modules`, so\n * ai-hook.sh's upward walk executes the PRIMARY's binary — `readlink -f` resolved a worktree agent's bin\n * to `<primary>/node_modules/@webpieces/ai-hook-rules`. A worktree ran its own SCRIPT and its own\n * CONFIG; it never ran its own release. Governance was already the primary's, in every tree, the whole\n * time.\n *\n * The price of that fiction was an entire guard layer. A relative path only resolves at a tree root, and\n * a hook that cannot resolve exits 127 — per the hooks reference a NON-BLOCKING error, i.e. a SILENT\n * UNGUARDED ALLOW. So L-1 had to deny every `cd` into a project subdirectory. That denial produced the\n * force-to-root bug class, the reaped-worktree `cd` prescription, and a measured hard deadlock: L-1 told\n * a worktree-isolated agent to `cd` to the primary clone, which the harness refuses for an isolated\n * agent, leaving it unable to stay or to follow the cure.\n *\n * An absolute path resolves from ANY cwd. So the launch guarantee is structural, L-1 has no job left and\n * is deleted, and `cd` into a subdirectory is simply allowed. What used to be true only by policing the\n * shell is now true by construction.\n *\n * ─── What replaces the property that was lost ──────────────────────────────────────────────────────\n * Nothing is lost that was ever delivered — but the case relative registration WANTED to handle (a tree\n * that genuinely needs a different @webpieces) is now DETECTED instead of silently mis-governed:\n * `VersionSyncGuard` (L1 row 8) blocks when a worktree's pin disagrees with the main tree's, and\n * prescribes either aligning the pins (same git hash → same tracked pin → install in each tree that has\n * a node_modules) or using a separate CLONE, which — unlike a worktree — gets its own GOVERNANCE. Note\n * what that does NOT say: a worktree may perfectly well have its own node_modules, and usually does the\n * moment anything installs in it. What it may not have is a DIFFERENT @webpieces version.\n *\n * ─── Why the registration is a DRIFT SURFACE, not just an install step ─────────────────────────────\n * Nothing used to validate `.claude/settings.json` at all, so a settings file left on a superseded form\n * silently changed who governs. The installed surface is THREE things (ai-hook.sh, the registration, and\n * the managed `env` entry — see managed-env.ts), all three are compared against this release, and\n * `wp-upgrade-shim` regenerates all three. A cure that fixes two of three is worse than no cure, because\n * it reports success.\n */\n\n/**\n * The RETIRED L-1 hook's committed path. Named here, in the one module that must still recognise it, and\n * matched ONLY by isManagedCommand() so repairRegistration() can delete the stale entry. There is no\n * guarantee-root.ts any more — this literal is all that remains of it, deliberately.\n *\n * It is a one-way RECOGNISER, not a shim: nothing emits it, `expectedEntries()` never returns it, and a\n * settings file carrying it is reported STALE. Its only job is to make the retired entry findable so it\n * can be REMOVED.\n */\nexport const LEGACY_GUARANTEE_ROOT_MARKER = '.claude/webpieces/guarantee-root.sh';\n\n/**\n * When this recogniser may be deleted, as a value rather than a comment nobody re-reads.\n *\n * A removal-only migration is still dead weight once no consumer can be carrying the old shape. The\n * hazard of deleting it EARLY is severe and silent — repair would stop stripping the retired entry, and\n * a hook registered against a deleted file exits 127, which the Claude Code hooks reference defines as a\n * NON-BLOCKING error, i.e. every `cd` unjudged while the cure reports success. So it gets a stated date\n * and a test that fails once the date passes, instead of an intention.\n *\n * `legacy-marker-expiry.spec.ts` reads this and fails after it, which is the reminder.\n */\nexport const LEGACY_MARKER_REMOVE_AFTER = '2026-12-01';\n\n/** One PreToolUse hook entry as webpieces registers it. Data-only → a class, per CLAUDE.md. */\nexport class HookRegistrationEntry {\n constructor(\n readonly matcher: string,\n readonly command: string,\n ) {}\n\n sameAs(other: HookRegistrationEntry): boolean {\n return this.matcher === other.matcher && this.command === other.command;\n }\n}\n\n// webpieces-disable no-any-unknown -- settings.json is opaque consumer JSON\nexport interface HookCommand { type: string; command: string; }\nexport interface HookEntry { matcher: string; hooks: HookCommand[]; }\nexport interface ClaudeSettings {\n hooks?: { PreToolUse?: HookEntry[] };\n // Claude Code's settings `env` block: every key is exported into the environment of the session AND\n // of every subagent it spawns. That inheritance is precisely why webpieces pins its managed entry\n // here rather than in a shell profile — see managed-env.ts.\n env?: Record<string, string>;\n // webpieces-disable no-any-unknown -- opaque settings bag; arbitrary keys allowed\n [key: string]: unknown;\n}\n\nexport const RULES_BIN = 'wp-ai-rules-hook';\nexport const GUARDS_BIN = 'wp-ai-guards-hook';\nexport const RULES_MATCHER = 'Write|Edit|MultiEdit';\n// Guards match Bash (git/PR guards), Write|Edit|MultiEdit (file-scoped guards) AND Read — Read carries\n// no guard, but the guards hook owns the per-invocation audit log, so matching Read records every file\n// the AI opens (log-and-allow fast path in hook-core.ts; a Read is never blocked).\nexport const GUARDS_MATCHER = 'Write|Edit|MultiEdit|Bash|Read';\n\n/**\n * The guard-hook command — `sh \"$CLAUDE_PROJECT_DIR/.claude/webpieces/ai-hook.sh\" <bin>`.\n *\n * ABSOLUTE. This replaced a RELATIVE spelling, and the reversal is the whole point of this release.\n *\n * The relative form existed to give each git tree its own release, binary and pin. **It never delivered\n * that.** Measured 2026-08-10: a linked worktree has no `node_modules`, so ai-hook.sh's upward walk\n * executes the PRIMARY's binary — `readlink -f` resolved a worktree agent's bin to\n * `<primary>/node_modules/@webpieces/ai-hook-rules`. A worktree ran its own SCRIPT and its own CONFIG,\n * never its own release. The property was fiction, and paying for it cost an entire guard layer: a\n * relative path only resolves at a tree root, so L-1 (guarantee-root.sh) had to deny every `cd` into a\n * project subdirectory, which produced the force-to-root bug class, the reaped-worktree `cd`\n * prescription, and a measured hard deadlock where L-1 told a worktree-isolated agent to `cd` to the\n * primary clone — which the harness refuses for an isolated agent.\n *\n * Absolute resolves from ANY cwd, so L-1 has no job left and is deleted. One governor: the MAIN tree\n * judges every tree, which is what was already happening via the borrowed binary — the design now says\n * so out loud, and `VersionSyncGuard` blocks the case where that is the wrong answer.\n *\n * Invoked via `sh <file>` rather than executed directly so a missing executable bit on the checked-in\n * shim (fresh clone, a filesystem that drops the bit, git core.fileMode quirks) can never break the hook\n * with a raw `Permission denied` on every tool call. Quoted to survive spaces in the path.\n */\n// webpieces-disable no-function-outside-class -- this module must load on a tree too broken to build a DI container (upgrade-shim.ts depends on that), so it is module-scope like its siblings shim.ts\nexport function shimCommand(bin: string): string {\n return `sh \"$CLAUDE_PROJECT_DIR/${SHIM_MARKER}\" ${bin}`;\n}\n\n/** The registration entry for one guard bin. */\n// webpieces-disable no-function-outside-class -- module-scope for the same dependency-free reason as shimCommand above\nexport function guardHookEntry(bin: string): HookRegistrationEntry {\n return new HookRegistrationEntry(bin === RULES_BIN ? RULES_MATCHER : GUARDS_MATCHER, shimCommand(bin));\n}\n\n/**\n * True when this PreToolUse command is one webpieces owns — in ANY spelling it has ever shipped.\n *\n * THE SINGLE MOST IMPORTANT LINE IN THIS RELEASE. `LEGACY_GUARANTEE_ROOT_MARKER` is matched here and\n * NOWHERE else: it is how `repairRegistration()` finds and REMOVES the retired H1 entry from a settings\n * file written by an older release. Drop it and the repair silently leaves a live L-1 hook registered\n * against a file this release deletes — exit 127, which the Claude Code hooks reference defines as a\n * NON-BLOCKING error, so every `cd` goes unjudged while `wp-upgrade-shim` reports success and no drift\n * check can name it. Removal-only, never emitted: it appears in no `expectedEntries()` result, so it is\n * a one-way migration and not a second accepted spelling.\n */\n// webpieces-disable no-function-outside-class -- module-scope for the same dependency-free reason as shimCommand above\nexport function isManagedCommand(command: string): boolean {\n return command.includes(SHIM_MARKER) || command.includes(LEGACY_GUARANTEE_ROOT_MARKER);\n}\n\n// webpieces-disable no-function-outside-class -- module-scope for the same dependency-free reason as shimCommand above\nfunction preToolUse(settings: ClaudeSettings): readonly HookEntry[] {\n return settings.hooks?.PreToolUse ?? [];\n}\n\n/** Every webpieces-managed entry of one settings file, flattened to matcher + command pairs. */\n// webpieces-disable no-function-outside-class -- module-scope for the same dependency-free reason as shimCommand above\nexport function managedEntries(settings: ClaudeSettings): readonly HookRegistrationEntry[] {\n const found: HookRegistrationEntry[] = [];\n for (const entry of preToolUse(settings)) {\n for (const hook of entry.hooks) {\n if (isManagedCommand(hook.command)) found.push(new HookRegistrationEntry(entry.matcher, hook.command));\n }\n }\n return found;\n}\n\n/**\n * Which guard bins this settings file registers, in installer order. A file registering NEITHER is not\n * a project (relative) install and is therefore never judged — a global/absolute install names the bin\n * path directly and carries no shim marker at all.\n */\n// webpieces-disable no-function-outside-class -- module-scope for the same dependency-free reason as shimCommand above\nexport function registeredBins(settings: ClaudeSettings): readonly string[] {\n const commands = managedEntries(settings).map((e: HookRegistrationEntry): string => e.command);\n return [GUARDS_BIN, RULES_BIN].filter((bin: string): boolean => commands.some((c: string): boolean => c.includes(bin)));\n}\n\n/**\n * The exact set of entries THIS RELEASE expects in a settings file that registers `bins`.\n *\n * ONE entry per registered bin, and nothing else. There used to be a third, `guarantee-root.sh`, added\n * whenever the GUARDS bin was present; it is retired, and a settings file still carrying it is STALE —\n * `repairRegistration()` removes it via isManagedCommand()'s legacy marker.\n */\n// webpieces-disable no-function-outside-class -- module-scope for the same dependency-free reason as shimCommand above\nexport function expectedEntries(bins: readonly string[]): readonly HookRegistrationEntry[] {\n return bins.map((bin: string): HookRegistrationEntry => guardHookEntry(bin));\n}\n\n/**\n * True when a settings file registers webpieces hooks in a shape this release does not expect: a\n * RELATIVE shim command, a leftover guarantee-root entry, a stray duplicate, or a wrong matcher.\n *\n * Compared as a SET, not a sequence: Claude Code runs all matching hooks in parallel, so array order\n * carries no meaning and reordering must not read as drift.\n */\n// webpieces-disable no-function-outside-class -- module-scope for the same dependency-free reason as shimCommand above\nexport function registrationStale(settings: ClaudeSettings): boolean {\n const bins = registeredBins(settings);\n if (bins.length === 0) return false;\n const have = managedEntries(settings);\n const want = expectedEntries(bins);\n if (have.length !== want.length) return true;\n return want.some((w: HookRegistrationEntry): boolean => !have.some((h: HookRegistrationEntry): boolean => h.sameAs(w)));\n}\n\n/** Drop every webpieces-managed PreToolUse command; returns true if anything was removed. */\n// webpieces-disable no-function-outside-class -- module-scope for the same dependency-free reason as shimCommand above\nexport function dropManagedEntries(settings: ClaudeSettings): boolean {\n const entries = settings.hooks?.PreToolUse;\n if (!entries) return false;\n let changed = false;\n const kept: HookEntry[] = [];\n for (const entry of entries) {\n const hooks = entry.hooks.filter((h: HookCommand): boolean => !isManagedCommand(h.command));\n if (hooks.length !== entry.hooks.length) changed = true;\n if (hooks.length > 0) kept.push({ matcher: entry.matcher, hooks });\n }\n if (changed) settings.hooks!.PreToolUse = kept;\n return changed;\n}\n\n/** Append one PreToolUse entry. */\n// webpieces-disable no-function-outside-class -- module-scope for the same dependency-free reason as shimCommand above\nexport function addHookEntry(settings: ClaudeSettings, entry: HookRegistrationEntry): void {\n if (!settings.hooks) settings.hooks = {};\n if (!Array.isArray(settings.hooks.PreToolUse)) settings.hooks.PreToolUse = [];\n settings.hooks.PreToolUse.push({ matcher: entry.matcher, hooks: [{ type: 'command', command: entry.command }] });\n}\n\n/** The settings `env` block, or null when the file carries none (or carries junk in its place). */\n// webpieces-disable no-function-outside-class -- module-scope for the same dependency-free reason as shimCommand above\nfunction settingsEnv(settings: ClaudeSettings): Record<string, string> | null {\n // webpieces-disable no-any-unknown -- settings.json is opaque consumer JSON; `env` is only typed after this check\n const env: unknown = settings.env;\n if (typeof env !== 'object' || env === null || Array.isArray(env)) return null;\n return env as Record<string, string>;\n}\n\n/**\n * True when a settings file that registers webpieces hooks is missing the managed `env` entry, or\n * carries any value other than the one required one.\n *\n * Gated on `registeredBins()` for the SAME reason `registrationStale()` is: a settings file that\n * registers no webpieces hooks is not a project install and is never judged — a global install names\n * the bin path directly and carries no shim marker at all, so there is nothing here to keep in step.\n *\n * NOTE what this entry is for NOW. It was originally justified by keeping the then-RELATIVE hook path\n * resolvable; that job is retired, because both hooks are absolute and resolve from any cwd. It is kept\n * for VERDICT STABILITY — a guard's answer must depend on the command, not on where an earlier `cd` left\n * the shell — and because settings `env` is inherited, every subagent shares that cwd and therefore that\n * verdict. See managed-env.ts, which states this at length.\n */\n// webpieces-disable no-function-outside-class -- module-scope for the same dependency-free reason as shimCommand above\nexport function envStale(settings: ClaudeSettings): boolean {\n if (registeredBins(settings).length === 0) return false;\n const env = settingsEnv(settings);\n return env === null || env[BASH_CWD_ENV_KEY] !== BASH_CWD_ENV_VALUE;\n}\n\n/**\n * Set the managed `env` entry IN PLACE, whatever was there before. Returns true when it changed.\n *\n * A user-set `\"0\"` is BROUGHT TO `\"1\"`, not honoured: this is a managed surface, and \"webpieces sets it\n * unless you disagreed\" would be a second, invisible spelling of the decision. Turning it off means\n * uninstalling the hooks, exactly as it does for every other managed thing.\n *\n * UNGATED on purpose — the installer calls it for the file it is writing hooks into, and the gate lives\n * at the caller (repairRegistration / applyHook), so a file with no webpieces hooks is never touched.\n */\n// webpieces-disable no-function-outside-class -- module-scope for the same dependency-free reason as shimCommand above\nexport function applyManagedEnv(settings: ClaudeSettings): boolean {\n const existing = settingsEnv(settings);\n if (existing !== null && existing[BASH_CWD_ENV_KEY] === BASH_CWD_ENV_VALUE) return false;\n const env = existing ?? {};\n env[BASH_CWD_ENV_KEY] = BASH_CWD_ENV_VALUE;\n settings.env = env;\n return true;\n}\n\n/**\n * Bring one settings object to the two-hook ABSOLUTE form AND the managed `env` entry IN PLACE. Returns\n * true when it changed anything.\n *\n * REMOVE-then-ADD for the hooks, never add-beside: two spellings of one registration is exactly the\n * compatibility shim the backwards-compat reviewer rejects, and leaving a RELATIVE entry beside the\n * absolute one would run two shims per call — the\n * straddle this whole change exists to delete.\n *\n * THE TWO REPAIRS ARE EVALUATED INDEPENDENTLY, and that is load-bearing rather than tidy: this used to\n * early-return on `!registrationStale(settings)`, so a repo whose hooks are already current but whose\n * `env` entry is missing — the state EVERY existing consumer is in the moment this release lands — would\n * have been reported as drifted by fault S and then left unrepaired by its own prescribed cure. A cure\n * that skips half the surface is the failure mode `upgrade-shim.ts`'s header exists to prevent.\n */\n// webpieces-disable no-function-outside-class -- module-scope for the same dependency-free reason as shimCommand above\nexport function repairRegistration(settings: ClaudeSettings): boolean {\n const bins = registeredBins(settings);\n if (bins.length === 0) return false;\n let changed = false;\n if (registrationStale(settings)) {\n dropManagedEntries(settings);\n for (const entry of expectedEntries(bins)) addHookEntry(settings, entry);\n changed = true;\n }\n if (applyManagedEnv(settings)) changed = true;\n return changed;\n}\n\n/** The two project settings files the installer can write. */\n// webpieces-disable no-function-outside-class -- module-scope for the same dependency-free reason as shimCommand above\nexport function projectSettingsPaths(projectRoot: string): readonly string[] {\n return [\n path.join(projectRoot, '.claude', 'settings.json'),\n path.join(projectRoot, '.claude', 'settings.local.json'),\n ];\n}\n\n// webpieces-disable no-function-outside-class -- module-scope for the same dependency-free reason as shimCommand above\nexport function readSettings(settingsPath: string): ClaudeSettings {\n if (!fs.existsSync(settingsPath)) return {};\n const raw = fs.readFileSync(settingsPath, 'utf8');\n if (raw.trim() === '') return {};\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return JSON.parse(raw) as ClaudeSettings;\n } catch (err: unknown) {\n const error = toError(err);\n throw new Error(`${settingsPath} has invalid JSON — fix it, then retry: ${error.message}`, { cause: error });\n }\n}\n\n// webpieces-disable no-function-outside-class -- module-scope for the same dependency-free reason as shimCommand above\nexport function writeSettings(settingsPath: string, settings: ClaudeSettings): void {\n fs.mkdirSync(path.dirname(settingsPath), { recursive: true });\n fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 4) + '\\n');\n}\n\n/** True when either project settings file under `root` carries a stale registration. */\n// webpieces-disable no-function-outside-class -- module-scope for the same dependency-free reason as shimCommand above\nexport function registrationStaleAt(root: string | null): boolean {\n if (root === null) return false;\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return projectSettingsPaths(root).some((p: string): boolean => registrationStale(readSettings(p)));\n } catch (err: unknown) {\n const error = toError(err);\n void error; // best-effort: unreadable/invalid settings counts as \"not stale\" so it never wedges a call\n return false;\n }\n}\n\n/** True when either project settings file under `root` is missing the managed `env` entry. */\n// webpieces-disable no-function-outside-class -- module-scope for the same dependency-free reason as shimCommand above\nexport function envStaleAt(root: string | null): boolean {\n if (root === null) return false;\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return projectSettingsPaths(root).some((p: string): boolean => envStale(readSettings(p)));\n } catch (err: unknown) {\n const error = toError(err);\n void error; // best-effort: unreadable/invalid settings counts as \"not stale\" so it never wedges a call\n return false;\n }\n}\n\n/**\n * WHAT was rewritten in ONE settings file. Data-only → a class, per CLAUDE.md.\n *\n * Two independent flags rather than one path, because the cure has to be able to SAY which repair it\n * made: \"rewrote the hook registration\" printed for a file whose registration was already current and\n * whose `env` entry was the only thing missing is a cure lying about its own work.\n */\nexport class SettingsRepair {\n constructor(\n readonly settingsPath: string,\n readonly registration: boolean,\n readonly env: boolean,\n ) {}\n}\n\n/** Rewrite every stale project settings file under `root`; returns what changed, per file. */\n// webpieces-disable no-function-outside-class -- module-scope for the same dependency-free reason as shimCommand above\nexport function repairRegistrationAt(root: string): readonly SettingsRepair[] {\n const repairs: SettingsRepair[] = [];\n for (const settingsPath of projectSettingsPaths(root)) {\n if (!fs.existsSync(settingsPath)) continue;\n const settings = readSettings(settingsPath);\n const neededRegistration = registrationStale(settings);\n const neededEnv = envStale(settings);\n if (!repairRegistration(settings)) continue;\n writeSettings(settingsPath, settings);\n repairs.push(new SettingsRepair(settingsPath, neededRegistration, neededEnv));\n }\n return repairs;\n}\n\n/**\n * The THREE names the drift check reports, so a deny can say WHICH of them moved.\n *\n * There were four. `GUARANTEE_ROOT_SURFACE` is gone with L-1 itself: an absolutely-registered shim\n * resolves from any cwd, so there is no launch guarantee left to police and no second .sh file to keep\n * byte-locked. A settings file still carrying the retired H1 entry is not its own surface any more —\n * it is ordinary REGISTRATION drift, which `registrationStaleAt()` already reports and\n * `repairRegistration()` already fixes by removing it.\n */\nexport const SHIM_SURFACE = SHIM_MARKER;\nexport const REGISTRATION_SURFACE = '.claude/settings.json hook registration';\nexport const ENV_SURFACE = `.claude/settings.json env.${BASH_CWD_ENV_KEY}`;\n\n/**\n * WHICH of the three managed surfaces disagree with this release — the input to fault S.\n *\n * All three are checked against the SAME root, resolved from the RUNNING MODULE (governingShimRoot),\n * never from cwd and never from `$CLAUDE_PROJECT_DIR`: the files we compare and the renderers we\n * compare them TO must come from one install, or the check straddles two trees and can never converge.\n */\n// webpieces-disable no-function-outside-class -- module-scope for the same dependency-free reason as shimCommand above\nexport function managedSurfaceDrift(root: string | null): readonly string[] {\n const drifted: string[] = [];\n if (committedShimStale(root)) drifted.push(SHIM_SURFACE);\n if (registrationStaleAt(root)) drifted.push(REGISTRATION_SURFACE);\n if (envStaleAt(root)) drifted.push(ENV_SURFACE);\n return drifted;\n}\n"]}
1
+ {"version":3,"file":"hook-registration.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/bin/hook-registration.ts"],"names":[],"mappings":"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","sourcesContent":["import * as fs from 'fs';\nimport * as path from 'path';\n\nimport { toError } from '../core/to-error';\nimport { AiType } from '../core/agent-event';\nimport { SHIM_MARKER, committedShimStale } from './shim';\nimport { BASH_CWD_ENV_KEY, BASH_CWD_ENV_VALUE } from './managed-env';\n\n/**\n * THE INSTALLED HOOK SURFACE — two hooks per harness, all ABSOLUTE, and the ONE place their spelling is\n * defined. See `HarnessRegistration` below, which is what makes \"per harness\" data rather than four\n * module constants that were only ever true of Claude Code.\n *\n * ─── ONE GOVERNOR: the MAIN tree judges every tree ─────────────────────────────────────────────────\n *\n * Claude Code — .claude/settings.json\n * H1 sh \"$CLAUDE_PROJECT_DIR/.claude/webpieces/ai-hook.sh\" wp-ai-guards-hook Write|…|Bash|Read\n * H2 sh \"$CLAUDE_PROJECT_DIR/.claude/webpieces/ai-hook.sh\" wp-ai-rules-hook Write|Edit|MultiEdit\n * Codex — .codex/hooks.json (the SAME `hooks.PreToolUse` JSON shape, measured)\n * H1 sh \"$PWD/.claude/webpieces/ai-hook.sh\" wp-ai-guards-hook Bash|apply_patch\n * H2 sh \"$PWD/.claude/webpieces/ai-hook.sh\" wp-ai-rules-hook apply_patch\n *\n * ONE shim file serves all four: `.claude/` there is a path, not a claim about who is calling.\n *\n * This REPLACES a three-hook form in which these two were RELATIVE (`sh \".claude/webpieces/ai-hook.sh\"`)\n * and a third hook, L-1 `guarantee-root.sh`, existed solely to guarantee that relative path resolved.\n *\n * ─── Why the relative experiment was reversed ──────────────────────────────────────────────────────\n * Relative registration was adopted so each git tree would be governed by \"its own release, binary and\n * pin\". MEASURED 2026-08-10: it never delivered that. A linked worktree has NO `node_modules`, so\n * ai-hook.sh's upward walk executes the PRIMARY's binary — `readlink -f` resolved a worktree agent's bin\n * to `<primary>/node_modules/@webpieces/ai-hook-rules`. A worktree ran its own SCRIPT and its own\n * CONFIG; it never ran its own release. Governance was already the primary's, in every tree, the whole\n * time.\n *\n * The price of that fiction was an entire guard layer. A relative path only resolves at a tree root, and\n * a hook that cannot resolve exits 127 — per the hooks reference a NON-BLOCKING error, i.e. a SILENT\n * UNGUARDED ALLOW. So L-1 had to deny every `cd` into a project subdirectory. That denial produced the\n * force-to-root bug class, the reaped-worktree `cd` prescription, and a measured hard deadlock: L-1 told\n * a worktree-isolated agent to `cd` to the primary clone, which the harness refuses for an isolated\n * agent, leaving it unable to stay or to follow the cure.\n *\n * An absolute path resolves from ANY cwd. So the launch guarantee is structural, L-1 has no job left and\n * is deleted, and `cd` into a subdirectory is simply allowed. What used to be true only by policing the\n * shell is now true by construction.\n *\n * ─── What replaces the property that was lost ──────────────────────────────────────────────────────\n * Nothing is lost that was ever delivered — but the case relative registration WANTED to handle (a tree\n * that genuinely needs a different @webpieces) is now DETECTED instead of silently mis-governed:\n * `VersionSyncGuard` (L1 row 8) blocks when a worktree's pin disagrees with the main tree's, and\n * prescribes either aligning the pins (same git hash → same tracked pin → install in each tree that has\n * a node_modules) or using a separate CLONE, which — unlike a worktree — gets its own GOVERNANCE. Note\n * what that does NOT say: a worktree may perfectly well have its own node_modules, and usually does the\n * moment anything installs in it. What it may not have is a DIFFERENT @webpieces version.\n *\n * ─── Why the registration is a DRIFT SURFACE, not just an install step ─────────────────────────────\n * Nothing used to validate `.claude/settings.json` at all, so a settings file left on a superseded form\n * silently changed who governs. The installed surface is THREE things (ai-hook.sh, the registration, and\n * the managed `env` entry — see managed-env.ts), all three are compared against this release, and\n * `wp-upgrade-shim` regenerates all three. A cure that fixes two of three is worse than no cure, because\n * it reports success.\n */\n\n/**\n * The RETIRED L-1 hook's committed path. Named here, in the one module that must still recognise it, and\n * matched ONLY by isManagedCommand() so repairRegistration() can delete the stale entry. There is no\n * guarantee-root.ts any more — this literal is all that remains of it, deliberately.\n *\n * It is a one-way RECOGNISER, not a shim: nothing emits it, `expectedEntries()` never returns it, and a\n * settings file carrying it is reported STALE. Its only job is to make the retired entry findable so it\n * can be REMOVED.\n */\nexport const LEGACY_GUARANTEE_ROOT_MARKER = '.claude/webpieces/guarantee-root.sh';\n\n/**\n * When this recogniser may be deleted, as a value rather than a comment nobody re-reads.\n *\n * A removal-only migration is still dead weight once no consumer can be carrying the old shape. The\n * hazard of deleting it EARLY is severe and silent — repair would stop stripping the retired entry, and\n * a hook registered against a deleted file exits 127, which the Claude Code hooks reference defines as a\n * NON-BLOCKING error, i.e. every `cd` unjudged while the cure reports success. So it gets a stated date\n * and a test that fails once the date passes, instead of an intention.\n *\n * `legacy-marker-expiry.spec.ts` reads this and fails after it, which is the reminder.\n */\nexport const LEGACY_MARKER_REMOVE_AFTER = '2026-12-01';\n\n/** One PreToolUse hook entry as webpieces registers it. Data-only → a class, per CLAUDE.md. */\nexport class HookRegistrationEntry {\n constructor(\n readonly matcher: string,\n readonly command: string,\n ) {}\n\n sameAs(other: HookRegistrationEntry): boolean {\n return this.matcher === other.matcher && this.command === other.command;\n }\n}\n\n// webpieces-disable no-any-unknown -- settings.json is opaque consumer JSON\nexport interface HookCommand { type: string; command: string; }\nexport interface HookEntry { matcher: string; hooks: HookCommand[]; }\n/**\n * One settings file as webpieces reads it. MEASURED: Codex's `.codex/hooks.json` uses the IDENTICAL\n * `hooks.PreToolUse[].hooks[].command` shape, so one reader, one writer and one repair serve both files\n * — the difference between the harnesses is entirely in the VALUES (matcher, shim anchor), which is what\n * `HarnessRegistration` carries. The `env` block below is Claude Code's alone.\n */\nexport interface ClaudeSettings {\n hooks?: { PreToolUse?: HookEntry[] };\n // Claude Code's settings `env` block: every key is exported into the environment of the session AND\n // of every subagent it spawns. That inheritance is precisely why webpieces pins its managed entry\n // here rather than in a shell profile — see managed-env.ts.\n env?: Record<string, string>;\n // webpieces-disable no-any-unknown -- opaque settings bag; arbitrary keys allowed\n [key: string]: unknown;\n}\n\nexport const RULES_BIN = 'wp-ai-rules-hook';\nexport const GUARDS_BIN = 'wp-ai-guards-hook';\n\n/**\n * ONE HARNESS'S registration surface — where its hooks live, what they match, and how they name the\n * shim. Data-only → a class, per CLAUDE.md.\n *\n * ─── Why this exists ──────────────────────────────────────────────────────────────────────────────\n * The matchers and the shim command used to be four module constants written for Claude Code alone.\n * Applied to Codex they are wrong in both halves, and the way they are wrong is SILENT:\n *\n * - MATCHER. Codex's file-editing tool is `apply_patch` (MEASURED, codex-cli 0.151.0). A matcher of\n * `Write|Edit|MultiEdit` matches it never, so every file rule is unreachable while the settings file\n * looks perfectly installed. Its shell tool IS called `Bash` — Codex reuses Claude's name — which is\n * the trap: half the matcher works, so the hooks appear to be running.\n * - ANCHOR. `$CLAUDE_PROJECT_DIR` does not exist in a Codex hook's environment (measured: 46 vars, no\n * such key), so the command expands to `sh \"/.claude/webpieces/ai-hook.sh\"`, which dies — and per the\n * hooks protocol a non-2 non-zero exit is a NON-BLOCKING error, i.e. a silent unguarded allow.\n *\n * Both halves of that were live in real repos, written by a Codex Desktop sync that transliterated the\n * Claude setup. Making the registration per-harness DATA is what stops a future harness inheriting a\n * matcher that was never true for it.\n *\n * ─── ONE SHIM, both harnesses ─────────────────────────────────────────────────────────────────────\n * `SHIM_MARKER` is shared deliberately: `.claude/webpieces/ai-hook.sh` is the single fail-closed entry\n * point, and moving or duplicating it would double the L0 allowlist regexes, the drift surfaces and the\n * cures. The `.claude/` prefix is a path, not a claim about which agent is calling.\n */\nexport class HarnessRegistration {\n // eslint-disable-next-line @typescript-eslint/max-params\n constructor(\n readonly aiType: AiType,\n /** How the installer and the drift report name this harness to a human. */\n readonly label: string,\n /** Which tool names the RULES hook (code-style, file-scoped) must see. */\n readonly rulesMatcher: string,\n /**\n * Which tool names the GUARDS hook must see. Wider than the rules matcher by the shell tool and,\n * for Claude Code, by `Read` — Read carries no guard, but the guards hook owns the\n * per-invocation audit log, so matching it records every file the AI opens (log-and-allow fast\n * path in hook-core.ts; a Read is never blocked). Codex has no Read tool at all: a read arrives\n * as `Bash` running a pager, which the shell matcher already covers and read-parity synthesizes.\n */\n readonly guardsMatcher: string,\n /** The settings files this harness's hooks can be installed into, relative to the repo root. */\n readonly settingsFiles: readonly string[],\n /**\n * THE PREFIX THE SHIM PATH IS ANCHORED ON, and the reason each harness needs its own.\n *\n * **BYTE-STABILITY IS A HARD CONSTRAINT ON THE CODEX VALUE.** Codex trusts a hook entry\n * TOFU — `~/.codex/config.toml` records a `trusted_hash` per entry — so ANY change to these bytes\n * invalidates that trust and re-prompts the human, whose third option is `Continue without\n * trusting (hooks won't run)`: one keystroke to a silently unguarded session. Changing this\n * string is therefore never a cosmetic edit. The hash is NOT reproducible from outside Codex\n * (16 encodings tried against a file we authored), so the installer can never repair trust for\n * itself — see ./codex-trust.ts, which REPORTS and never writes.\n */\n readonly shimAnchor: string,\n /**\n * True when this harness's settings file also carries the managed `env` block. Claude Code's\n * settings `env` is inherited by every subagent, which is what makes it the right home for the\n * Bash-cwd pin (see managed-env.ts). Codex has no equivalent surface — and needs none: its cwd\n * is MEASURED not to drift at all (`cd x && pwd` prints x, the next call is back at the repo\n * root, for the coordinator and for subagents alike).\n */\n readonly managesEnv: boolean,\n /** What `managedSurfaceDrift()` calls this harness's registration when it has moved. */\n readonly registrationSurface: string,\n ) {}\n\n /** Which matcher one guard bin registers under. */\n matcherFor(bin: string): string {\n return bin === RULES_BIN ? this.rulesMatcher : this.guardsMatcher;\n }\n\n /**\n * The guard-hook command — `sh \"<anchor>/.claude/webpieces/ai-hook.sh\" <bin>`.\n *\n * ABSOLUTE, in both harnesses. Claude's anchor replaced a RELATIVE spelling and that reversal is\n * documented at length in this file's header: relative resolves only at a tree root, a hook that\n * cannot resolve exits 127, and per the hooks reference that is a NON-BLOCKING error — a silent\n * unguarded allow. It also cost an entire guard layer (L-1) whose only job was to police the `cd`\n * that made the relative path resolvable.\n *\n * Invoked via `sh <file>` rather than executed directly so a missing executable bit on the\n * checked-in shim (fresh clone, a filesystem that drops the bit, git core.fileMode quirks) can never\n * break the hook with a raw `Permission denied` on every tool call. Quoted to survive spaces.\n */\n shimCommand(bin: string): string {\n return `sh \"${this.shimAnchor}/${SHIM_MARKER}\" ${bin}`;\n }\n\n /** The registration entry for one guard bin under this harness. */\n entryFor(bin: string): HookRegistrationEntry {\n return new HookRegistrationEntry(this.matcherFor(bin), this.shimCommand(bin));\n }\n\n /** This harness's settings files under one repo root, absolute. */\n settingsPaths(projectRoot: string): readonly string[] {\n return this.settingsFiles.map((file: string): string => path.join(projectRoot, ...file.split('/')));\n }\n}\n\nexport const CLAUDE_REGISTRATION = new HarnessRegistration(\n 'claude-code', 'Claude Code',\n 'Write|Edit|MultiEdit',\n 'Write|Edit|MultiEdit|Bash|Read',\n ['.claude/settings.json', '.claude/settings.local.json'],\n '$CLAUDE_PROJECT_DIR',\n true,\n '.claude/settings.json hook registration',\n);\n\n/**\n * Codex's registration, every value of it MEASURED against codex-cli 0.151.0 rather than assumed.\n *\n * `$PWD` is the anchor because there is no project-dir variable to use and none is needed: the payload\n * `cwd` and the hook process's own `PWD` are both the repo root on EVERY call — for the coordinator and\n * for subagents — and a `cd` inside one command never survives into the next. That was measured, not\n * hoped for, and it is the same effect Claude Code gets from the managed\n * `CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR` env entry. See `shimAnchor` for why these exact bytes are\n * not free to change.\n */\nexport const CODEX_REGISTRATION = new HarnessRegistration(\n 'codex', 'Codex',\n 'apply_patch',\n 'Bash|apply_patch',\n ['.codex/hooks.json'],\n '$PWD',\n false,\n '.codex/hooks.json hook registration',\n);\n\n/**\n * Every harness webpieces arms, in installer order.\n *\n * The drift check, the repair and the installer all iterate THIS, so a harness cannot be armed by the\n * installer and then left unvalidated — which is exactly the state `.codex/hooks.json` was in before it\n * was a managed surface: written by something else, silently wrong, and invisible to every check.\n */\nexport const HARNESS_REGISTRATIONS: readonly HarnessRegistration[] = [CLAUDE_REGISTRATION, CODEX_REGISTRATION];\n\n/**\n * True when this PreToolUse command is one webpieces owns — in ANY spelling it has ever shipped.\n *\n * THE SINGLE MOST IMPORTANT LINE IN THIS RELEASE. `LEGACY_GUARANTEE_ROOT_MARKER` is matched here and\n * NOWHERE else: it is how `repairRegistration()` finds and REMOVES the retired H1 entry from a settings\n * file written by an older release. Drop it and the repair silently leaves a live L-1 hook registered\n * against a file this release deletes — exit 127, which the Claude Code hooks reference defines as a\n * NON-BLOCKING error, so every `cd` goes unjudged while `wp-upgrade-shim` reports success and no drift\n * check can name it. Removal-only, never emitted: it appears in no `expectedEntries()` result, so it is\n * a one-way migration and not a second accepted spelling.\n */\n// webpieces-disable no-function-outside-class -- module-scope for the same dependency-free reason as shimCommand above\nexport function isManagedCommand(command: string): boolean {\n return command.includes(SHIM_MARKER) || command.includes(LEGACY_GUARANTEE_ROOT_MARKER);\n}\n\n// webpieces-disable no-function-outside-class -- module-scope for the same dependency-free reason as shimCommand above\nfunction preToolUse(settings: ClaudeSettings): readonly HookEntry[] {\n return settings.hooks?.PreToolUse ?? [];\n}\n\n/** Every webpieces-managed entry of one settings file, flattened to matcher + command pairs. */\n// webpieces-disable no-function-outside-class -- module-scope for the same dependency-free reason as shimCommand above\nexport function managedEntries(settings: ClaudeSettings): readonly HookRegistrationEntry[] {\n const found: HookRegistrationEntry[] = [];\n for (const entry of preToolUse(settings)) {\n for (const hook of entry.hooks) {\n if (isManagedCommand(hook.command)) found.push(new HookRegistrationEntry(entry.matcher, hook.command));\n }\n }\n return found;\n}\n\n/**\n * Which guard bins this settings file registers, in installer order. A file registering NEITHER is not\n * a project (relative) install and is therefore never judged — a global/absolute install names the bin\n * path directly and carries no shim marker at all.\n */\n// webpieces-disable no-function-outside-class -- module-scope for the same dependency-free reason as shimCommand above\nexport function registeredBins(settings: ClaudeSettings): readonly string[] {\n const commands = managedEntries(settings).map((e: HookRegistrationEntry): string => e.command);\n return [GUARDS_BIN, RULES_BIN].filter((bin: string): boolean => commands.some((c: string): boolean => c.includes(bin)));\n}\n\n/**\n * The exact set of entries THIS RELEASE expects in a settings file that registers `bins`.\n *\n * ONE entry per registered bin, and nothing else. There used to be a third, `guarantee-root.sh`, added\n * whenever the GUARDS bin was present; it is retired, and a settings file still carrying it is STALE —\n * `repairRegistration()` removes it via isManagedCommand()'s legacy marker.\n */\n// webpieces-disable no-function-outside-class -- module-scope for the same dependency-free reason as HarnessRegistration's siblings\nexport function expectedEntries(harness: HarnessRegistration, bins: readonly string[]): readonly HookRegistrationEntry[] {\n return bins.map((bin: string): HookRegistrationEntry => harness.entryFor(bin));\n}\n\n/**\n * True when a settings file registers webpieces hooks in a shape this release does not expect: a\n * RELATIVE shim command, a leftover guarantee-root entry, a stray duplicate, or a wrong matcher.\n *\n * Compared as a SET, not a sequence: Claude Code runs all matching hooks in parallel, so array order\n * carries no meaning and reordering must not read as drift.\n */\n// webpieces-disable no-function-outside-class -- module-scope for the same dependency-free reason as shimCommand above\nexport function registrationStale(harness: HarnessRegistration, settings: ClaudeSettings): boolean {\n const bins = registeredBins(settings);\n if (bins.length === 0) return false;\n const have = managedEntries(settings);\n const want = expectedEntries(harness, bins);\n if (have.length !== want.length) return true;\n return want.some((w: HookRegistrationEntry): boolean => !have.some((h: HookRegistrationEntry): boolean => h.sameAs(w)));\n}\n\n/** Drop every webpieces-managed PreToolUse command; returns true if anything was removed. */\n// webpieces-disable no-function-outside-class -- module-scope for the same dependency-free reason as shimCommand above\nexport function dropManagedEntries(settings: ClaudeSettings): boolean {\n const entries = settings.hooks?.PreToolUse;\n if (!entries) return false;\n let changed = false;\n const kept: HookEntry[] = [];\n for (const entry of entries) {\n const hooks = entry.hooks.filter((h: HookCommand): boolean => !isManagedCommand(h.command));\n if (hooks.length !== entry.hooks.length) changed = true;\n if (hooks.length > 0) kept.push({ matcher: entry.matcher, hooks });\n }\n if (changed) settings.hooks!.PreToolUse = kept;\n return changed;\n}\n\n/** Append one PreToolUse entry. */\n// webpieces-disable no-function-outside-class -- module-scope for the same dependency-free reason as shimCommand above\nexport function addHookEntry(settings: ClaudeSettings, entry: HookRegistrationEntry): void {\n if (!settings.hooks) settings.hooks = {};\n if (!Array.isArray(settings.hooks.PreToolUse)) settings.hooks.PreToolUse = [];\n settings.hooks.PreToolUse.push({ matcher: entry.matcher, hooks: [{ type: 'command', command: entry.command }] });\n}\n\n/** The settings `env` block, or null when the file carries none (or carries junk in its place). */\n// webpieces-disable no-function-outside-class -- module-scope for the same dependency-free reason as shimCommand above\nfunction settingsEnv(settings: ClaudeSettings): Record<string, string> | null {\n // webpieces-disable no-any-unknown -- settings.json is opaque consumer JSON; `env` is only typed after this check\n const env: unknown = settings.env;\n if (typeof env !== 'object' || env === null || Array.isArray(env)) return null;\n return env as Record<string, string>;\n}\n\n/**\n * True when a settings file that registers webpieces hooks is missing the managed `env` entry, or\n * carries any value other than the one required one.\n *\n * Gated on `registeredBins()` for the SAME reason `registrationStale()` is: a settings file that\n * registers no webpieces hooks is not a project install and is never judged — a global install names\n * the bin path directly and carries no shim marker at all, so there is nothing here to keep in step.\n *\n * NOTE what this entry is for NOW. It was originally justified by keeping the then-RELATIVE hook path\n * resolvable; that job is retired, because both hooks are absolute and resolve from any cwd. It is kept\n * for VERDICT STABILITY — a guard's answer must depend on the command, not on where an earlier `cd` left\n * the shell — and because settings `env` is inherited, every subagent shares that cwd and therefore that\n * verdict. See managed-env.ts, which states this at length.\n */\n// webpieces-disable no-function-outside-class -- module-scope for the same dependency-free reason as shimCommand above\nexport function envStale(settings: ClaudeSettings): boolean {\n if (registeredBins(settings).length === 0) return false;\n const env = settingsEnv(settings);\n return env === null || env[BASH_CWD_ENV_KEY] !== BASH_CWD_ENV_VALUE;\n}\n\n/**\n * Set the managed `env` entry IN PLACE, whatever was there before. Returns true when it changed.\n *\n * A user-set `\"0\"` is BROUGHT TO `\"1\"`, not honoured: this is a managed surface, and \"webpieces sets it\n * unless you disagreed\" would be a second, invisible spelling of the decision. Turning it off means\n * uninstalling the hooks, exactly as it does for every other managed thing.\n *\n * UNGATED on purpose — the installer calls it for the file it is writing hooks into, and the gate lives\n * at the caller (repairRegistration / applyHook), so a file with no webpieces hooks is never touched.\n */\n// webpieces-disable no-function-outside-class -- module-scope for the same dependency-free reason as shimCommand above\nexport function applyManagedEnv(settings: ClaudeSettings): boolean {\n const existing = settingsEnv(settings);\n if (existing !== null && existing[BASH_CWD_ENV_KEY] === BASH_CWD_ENV_VALUE) return false;\n const env = existing ?? {};\n env[BASH_CWD_ENV_KEY] = BASH_CWD_ENV_VALUE;\n settings.env = env;\n return true;\n}\n\n/**\n * Bring one settings object to the two-hook ABSOLUTE form AND the managed `env` entry IN PLACE. Returns\n * true when it changed anything.\n *\n * REMOVE-then-ADD for the hooks, never add-beside: two spellings of one registration is exactly the\n * compatibility shim the backwards-compat reviewer rejects, and leaving a RELATIVE entry beside the\n * absolute one would run two shims per call — the\n * straddle this whole change exists to delete.\n *\n * THE TWO REPAIRS ARE EVALUATED INDEPENDENTLY, and that is load-bearing rather than tidy: this used to\n * early-return on `!registrationStale(settings)`, so a repo whose hooks are already current but whose\n * `env` entry is missing — the state EVERY existing consumer is in the moment this release lands — would\n * have been reported as drifted by fault S and then left unrepaired by its own prescribed cure. A cure\n * that skips half the surface is the failure mode `upgrade-shim.ts`'s header exists to prevent.\n */\n// webpieces-disable no-function-outside-class -- module-scope for the same dependency-free reason as shimCommand above\nexport function repairRegistration(harness: HarnessRegistration, settings: ClaudeSettings): boolean {\n const bins = registeredBins(settings);\n if (bins.length === 0) return false;\n let changed = false;\n if (registrationStale(harness, settings)) {\n dropManagedEntries(settings);\n for (const entry of expectedEntries(harness, bins)) addHookEntry(settings, entry);\n changed = true;\n }\n // Only where the harness HAS that surface. Codex's hooks.json has no `env` block to manage, and\n // inventing one there would write a key Codex does not read — a managed surface nothing consumes is\n // a surface that can drift with no consequence and no way to notice.\n if (harness.managesEnv && applyManagedEnv(settings)) changed = true;\n return changed;\n}\n\n// webpieces-disable no-function-outside-class -- module-scope for the same dependency-free reason as shimCommand above\nexport function readSettings(settingsPath: string): ClaudeSettings {\n if (!fs.existsSync(settingsPath)) return {};\n const raw = fs.readFileSync(settingsPath, 'utf8');\n if (raw.trim() === '') return {};\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return JSON.parse(raw) as ClaudeSettings;\n } catch (err: unknown) {\n const error = toError(err);\n throw new Error(`${settingsPath} has invalid JSON — fix it, then retry: ${error.message}`, { cause: error });\n }\n}\n\n// webpieces-disable no-function-outside-class -- module-scope for the same dependency-free reason as shimCommand above\nexport function writeSettings(settingsPath: string, settings: ClaudeSettings): void {\n fs.mkdirSync(path.dirname(settingsPath), { recursive: true });\n fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 4) + '\\n');\n}\n\n/**\n * True when any settings file ONE HARNESS owns under `root` carries a stale registration.\n *\n * Per harness, not per repo, so the drift report can NAME which one moved — `.claude/settings.json` and\n * `.codex/hooks.json` have different cures, and a fault that says only \"the registration is stale\" sends\n * the reader to the wrong file half the time.\n *\n * A file that does not exist, or exists and registers no webpieces hooks, is never judged (see\n * `registrationStale`). That is what keeps a repo which has never armed Codex from suddenly faulting on\n * a `.codex/hooks.json` it does not have.\n */\n// webpieces-disable no-function-outside-class -- module-scope for the same dependency-free reason as HarnessRegistration's siblings\nexport function registrationStaleAt(harness: HarnessRegistration, root: string | null): boolean {\n if (root === null) return false;\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return harness.settingsPaths(root).some((p: string): boolean => registrationStale(harness, readSettings(p)));\n } catch (err: unknown) {\n const error = toError(err);\n void error; // best-effort: unreadable/invalid settings counts as \"not stale\" so it never wedges a call\n return false;\n }\n}\n\n/**\n * True when either Claude project settings file under `root` is missing the managed `env` entry.\n *\n * CLAUDE-ONLY by construction, and stated as such rather than looped over the harnesses: `env` is a\n * Claude Code settings surface, Codex has no equivalent, and Codex's cwd is measured not to drift, which\n * is the whole thing the entry is for. See HarnessRegistration.managesEnv.\n */\n// webpieces-disable no-function-outside-class -- module-scope for the same dependency-free reason as HarnessRegistration's siblings\nexport function envStaleAt(root: string | null): boolean {\n if (root === null) return false;\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return CLAUDE_REGISTRATION.settingsPaths(root).some((p: string): boolean => envStale(readSettings(p)));\n } catch (err: unknown) {\n const error = toError(err);\n void error; // best-effort: unreadable/invalid settings counts as \"not stale\" so it never wedges a call\n return false;\n }\n}\n\n/**\n * WHAT was rewritten in ONE settings file. Data-only → a class, per CLAUDE.md.\n *\n * Two independent flags rather than one path, because the cure has to be able to SAY which repair it\n * made: \"rewrote the hook registration\" printed for a file whose registration was already current and\n * whose `env` entry was the only thing missing is a cure lying about its own work.\n */\nexport class SettingsRepair {\n constructor(\n readonly settingsPath: string,\n readonly registration: boolean,\n readonly env: boolean,\n ) {}\n}\n\n/**\n * Rewrite every stale settings file under `root`, for EVERY harness; returns what changed, per file.\n *\n * Existing files only — this never CREATES a registration. Arming a harness is the installer's decision\n * (`wp-install-ai-hooks`); this is the cure for one that has already been armed and has drifted, so a\n * repo that has never armed Codex is left exactly as it was.\n */\n// webpieces-disable no-function-outside-class -- module-scope for the same dependency-free reason as HarnessRegistration's siblings\nexport function repairRegistrationAt(root: string): readonly SettingsRepair[] {\n const repairs: SettingsRepair[] = [];\n for (const harness of HARNESS_REGISTRATIONS) {\n for (const settingsPath of harness.settingsPaths(root)) {\n if (!fs.existsSync(settingsPath)) continue;\n const settings = readSettings(settingsPath);\n const neededRegistration = registrationStale(harness, settings);\n const neededEnv = harness.managesEnv && envStale(settings);\n if (!repairRegistration(harness, settings)) continue;\n writeSettings(settingsPath, settings);\n repairs.push(new SettingsRepair(settingsPath, neededRegistration, neededEnv));\n }\n }\n return repairs;\n}\n\n/**\n * The names the drift check reports, so a deny can say WHICH surface moved.\n *\n * There were four, then three, and there are now FOUR again — but the fourth is not the one that was\n * deleted. `GUARANTEE_ROOT_SURFACE` went with L-1 itself: an absolutely-registered shim resolves from\n * any cwd, so there is no launch guarantee left to police and no second .sh file to keep byte-locked. A\n * settings file still carrying the retired H1 entry is not its own surface — it is ordinary REGISTRATION\n * drift, which `registrationStaleAt()` reports and `repairRegistration()` fixes by removing it.\n *\n * The new fourth is `.codex/hooks.json`, and it is here because of what happened while it was NOT a\n * managed surface: something else wrote it, with a matcher that matched no Codex file tool and a shim\n * path anchored on a variable Codex does not set, and no check in this package could see it. A file the\n * guards depend on and nothing validates is the exact shape of that incident.\n *\n * Each harness's registration surface is its own name, from HarnessRegistration.registrationSurface, so\n * the deny sends the reader to the file that actually moved.\n */\nexport const SHIM_SURFACE = SHIM_MARKER;\nexport const REGISTRATION_SURFACE = CLAUDE_REGISTRATION.registrationSurface;\nexport const CODEX_REGISTRATION_SURFACE = CODEX_REGISTRATION.registrationSurface;\nexport const ENV_SURFACE = `.claude/settings.json env.${BASH_CWD_ENV_KEY}`;\n\n/**\n * WHICH of the managed surfaces disagree with this release — the input to fault S.\n *\n * All of them are checked against the SAME root, resolved from the RUNNING MODULE (governingShimRoot),\n * never from cwd and never from `$CLAUDE_PROJECT_DIR`: the files we compare and the renderers we\n * compare them TO must come from one install, or the check straddles two trees and can never converge.\n */\n// webpieces-disable no-function-outside-class -- module-scope for the same dependency-free reason as HarnessRegistration's siblings\nexport function managedSurfaceDrift(root: string | null): readonly string[] {\n const drifted: string[] = [];\n if (committedShimStale(root)) drifted.push(SHIM_SURFACE);\n for (const harness of HARNESS_REGISTRATIONS) {\n if (registrationStaleAt(harness, root)) drifted.push(harness.registrationSurface);\n }\n if (envStaleAt(root)) drifted.push(ENV_SURFACE);\n return drifted;\n}\n"]}