@webpieces/ai-hook-rules 0.4.622 → 0.4.624

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 (60) hide show
  1. package/README.md +49 -19
  2. package/package.json +2 -2
  3. package/src/adapters/hook-core.js +13 -14
  4. package/src/adapters/hook-core.js.map +1 -1
  5. package/src/bin/hook-registration.d.ts +160 -50
  6. package/src/bin/hook-registration.js +227 -96
  7. package/src/bin/hook-registration.js.map +1 -1
  8. package/src/bin/managed-env.d.ts +46 -0
  9. package/src/bin/managed-env.js +50 -0
  10. package/src/bin/managed-env.js.map +1 -0
  11. package/src/bin/setup.d.ts +1 -2
  12. package/src/bin/setup.js +38 -46
  13. package/src/bin/setup.js.map +1 -1
  14. package/src/bin/shim-audit-log.js +12 -1
  15. package/src/bin/shim-audit-log.js.map +1 -1
  16. package/src/bin/shim-deny-reason.d.ts +6 -0
  17. package/src/bin/shim-deny-reason.js +82 -0
  18. package/src/bin/shim-deny-reason.js.map +1 -0
  19. package/src/bin/shim.d.ts +0 -1
  20. package/src/bin/shim.js +3 -57
  21. package/src/bin/shim.js.map +1 -1
  22. package/src/bin/upgrade-shim.js +140 -30
  23. package/src/bin/upgrade-shim.js.map +1 -1
  24. package/src/core/decision-log.d.ts +3 -3
  25. package/src/core/decision-log.js +8 -8
  26. package/src/core/decision-log.js.map +1 -1
  27. package/src/core/effective-tree.d.ts +5 -2
  28. package/src/core/effective-tree.js +1 -1
  29. package/src/core/effective-tree.js.map +1 -1
  30. package/src/core/l0-matrix.js +15 -13
  31. package/src/core/l0-matrix.js.map +1 -1
  32. package/src/core/l1-doc.js +29 -68
  33. package/src/core/l1-doc.js.map +1 -1
  34. package/src/core/l1-rows.d.ts +17 -9
  35. package/src/core/l1-rows.js +18 -13
  36. package/src/core/l1-rows.js.map +1 -1
  37. package/src/core/log-stream.d.ts +6 -4
  38. package/src/core/log-stream.js +6 -4
  39. package/src/core/log-stream.js.map +1 -1
  40. package/src/core/log-streams.d.ts +13 -3
  41. package/src/core/log-streams.js +15 -5
  42. package/src/core/log-streams.js.map +1 -1
  43. package/src/core/runner.d.ts +1 -2
  44. package/src/core/runner.js +29 -24
  45. package/src/core/runner.js.map +1 -1
  46. package/src/core/version-sync.d.ts +67 -0
  47. package/src/core/version-sync.js +148 -0
  48. package/src/core/version-sync.js.map +1 -0
  49. package/src/core/webpieces-versions.d.ts +83 -0
  50. package/src/core/webpieces-versions.js +169 -0
  51. package/src/core/webpieces-versions.js.map +1 -0
  52. package/templates/ai-hook.sh +15 -4
  53. package/templates/claude-settings-hook.json +6 -3
  54. package/src/bin/guarantee-root.d.ts +0 -95
  55. package/src/bin/guarantee-root.js +0 -297
  56. package/src/bin/guarantee-root.js.map +0 -1
  57. package/src/core/coordinator-worktree.d.ts +0 -61
  58. package/src/core/coordinator-worktree.js +0 -94
  59. package/src/core/coordinator-worktree.js.map +0 -1
  60. package/templates/guarantee-root.sh +0 -113
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.REGISTRATION_SURFACE = exports.GUARANTEE_ROOT_SURFACE = exports.SHIM_SURFACE = exports.GUARANTEE_ROOT_ENTRY = exports.GUARANTEE_ROOT_COMMAND = exports.GUARANTEE_ROOT_MATCHER = exports.GUARDS_MATCHER = exports.RULES_MATCHER = exports.GUARDS_BIN = exports.RULES_BIN = exports.HookRegistrationEntry = void 0;
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
4
  exports.shimCommand = shimCommand;
5
5
  exports.guardHookEntry = guardHookEntry;
6
6
  exports.isManagedCommand = isManagedCommand;
@@ -10,11 +10,14 @@ exports.expectedEntries = expectedEntries;
10
10
  exports.registrationStale = registrationStale;
11
11
  exports.dropManagedEntries = dropManagedEntries;
12
12
  exports.addHookEntry = addHookEntry;
13
+ exports.envStale = envStale;
14
+ exports.applyManagedEnv = applyManagedEnv;
13
15
  exports.repairRegistration = repairRegistration;
14
16
  exports.projectSettingsPaths = projectSettingsPaths;
15
17
  exports.readSettings = readSettings;
16
18
  exports.writeSettings = writeSettings;
17
19
  exports.registrationStaleAt = registrationStaleAt;
20
+ exports.envStaleAt = envStaleAt;
18
21
  exports.repairRegistrationAt = repairRegistrationAt;
19
22
  exports.managedSurfaceDrift = managedSurfaceDrift;
20
23
  const tslib_1 = require("tslib");
@@ -22,42 +25,74 @@ const fs = tslib_1.__importStar(require("fs"));
22
25
  const path = tslib_1.__importStar(require("path"));
23
26
  const to_error_1 = require("../core/to-error");
24
27
  const shim_1 = require("./shim");
25
- const guarantee_root_1 = require("./guarantee-root");
28
+ const managed_env_1 = require("./managed-env");
26
29
  /**
27
- * THE INSTALLED HOOK SURFACE — three hooks, and the ONE place their spelling is defined.
30
+ * THE INSTALLED HOOK SURFACE — two hooks, both ABSOLUTE, and the ONE place their spelling is defined.
28
31
  *
29
- * ─── Why three, and why two of them are RELATIVE ───────────────────────────────────────────────────
30
- * `.claude/settings.json` used to register two hooks, BOTH absolute via `$CLAUDE_PROJECT_DIR`. That
31
- * variable NEVER moves — proven from four separate worktrees' own logs, every line reading
32
- * `root=<worktree> projectDir=<primary>` — so every tree was governed by the PRIMARY's shim, the
33
- * PRIMARY's binary and the PRIMARY's pin, forever. A worktree could never be judged by the release its
34
- * own branch pins, and measuring one tree while running another's binary is the non-convergent
35
- * "two-tree straddle" recorded in shim.ts (an agent gave up after four cures).
32
+ * ─── ONE GOVERNOR: the MAIN tree judges every tree ─────────────────────────────────────────────────
36
33
  *
37
- * The hooks reference says "the hook runs in the `cwd` value from the JSON input", so a RELATIVE
38
- * command resolves against the tool call's own tree. Hence:
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
39
36
  *
40
- * H1 absolute sh "$CLAUDE_PROJECT_DIR/.claude/webpieces/guarantee-root.sh" matcher Bash
41
- * H2 relative sh ".claude/webpieces/ai-hook.sh" wp-ai-guards-hook matcher Write|…|Read
42
- * H3 relative sh ".claude/webpieces/ai-hook.sh" wp-ai-rules-hook matcher Write|Edit|MultiEdit
37
+ * This REPLACES a three-hook form in which these two were RELATIVE (`sh ".claude/webpieces/ai-hook.sh"`)
38
+ * and a third hook, L-1 `guarantee-root.sh`, existed solely to guarantee that relative path resolved.
43
39
  *
44
- * H1 stays absolute because it is the one hook that must ALWAYS resolve: a relative hook that cannot
45
- * resolve exits 127, and per the same reference any non-2 non-zero exit is a NON-BLOCKING error — i.e.
46
- * a SILENT UNGUARDED ALLOW. H1 refuses any `cd` that would park the shell where H2/H3 cannot launch,
47
- * which is what makes the relative pair admissible at all. See guarantee-root.ts.
40
+ * ─── Why the relative experiment was reversed ──────────────────────────────────────────────────────
41
+ * Relative registration was adopted so each git tree would be governed by "its own release, binary and
42
+ * pin". MEASURED 2026-08-10: it never delivered that. A linked worktree has NO `node_modules`, so
43
+ * ai-hook.sh's upward walk executes the PRIMARY's binary `readlink -f` resolved a worktree agent's bin
44
+ * to `<primary>/node_modules/@webpieces/ai-hook-rules`. A worktree ran its own SCRIPT and its own
45
+ * CONFIG; it never ran its own release. Governance was already the primary's, in every tree, the whole
46
+ * time.
48
47
  *
49
- * H1 matches `Bash` alone because only Bash can move the shell the same reason stated at the top of
50
- * guarantee-root.ts, and the reason the rendered guarantee-root.sh exits 0 immediately for every other
51
- * tool. Registering it wider would spawn a process per Write/Read to do nothing.
48
+ * The price of that fiction was an entire guard layer. A relative path only resolves at a tree root, and
49
+ * a hook that cannot resolve exits 127 — per the hooks reference a NON-BLOCKING error, i.e. a SILENT
50
+ * UNGUARDED ALLOW. So L-1 had to deny every `cd` into a project subdirectory. That denial produced the
51
+ * force-to-root bug class, the reaped-worktree `cd` prescription, and a measured hard deadlock: L-1 told
52
+ * a worktree-isolated agent to `cd` to the primary clone, which the harness refuses for an isolated
53
+ * agent, leaving it unable to stay or to follow the cure.
54
+ *
55
+ * An absolute path resolves from ANY cwd. So the launch guarantee is structural, L-1 has no job left and
56
+ * is deleted, and `cd` into a subdirectory is simply allowed. What used to be true only by policing the
57
+ * shell is now true by construction.
58
+ *
59
+ * ─── What replaces the property that was lost ──────────────────────────────────────────────────────
60
+ * Nothing is lost that was ever delivered — but the case relative registration WANTED to handle (a tree
61
+ * that genuinely needs a different @webpieces) is now DETECTED instead of silently mis-governed:
62
+ * `VersionSyncGuard` (L1 row 8) blocks when a worktree's pin disagrees with the main tree's, and
63
+ * prescribes either aligning the pins (same git hash → same tracked pin → one install in the main tree)
64
+ * or using a separate CLONE, which — unlike a worktree — really does get its own node_modules and its
65
+ * own governance.
52
66
  *
53
67
  * ─── Why the registration is a DRIFT SURFACE, not just an install step ─────────────────────────────
54
- * Nothing used to validate `.claude/settings.json` at all, so a settings file left on the old
55
- * two-absolute-hook form silently reverted a repo to per-PRIMARY governance and disabled H1 the one
56
- * component whose whole job is failing closed. The installed surface is therefore THREE things
57
- * (ai-hook.sh, guarantee-root.sh, the registration), all three are compared against this release, and
58
- * `wp-upgrade-shim` regenerates all three. A cure that fixes one of three is worse than no cure,
59
- * because it reports success.
68
+ * Nothing used to validate `.claude/settings.json` at all, so a settings file left on a superseded form
69
+ * silently changed who governs. The installed surface is THREE things (ai-hook.sh, the registration, and
70
+ * the managed `env` entry see managed-env.ts), all three are compared against this release, and
71
+ * `wp-upgrade-shim` regenerates all three. A cure that fixes two of three is worse than no cure, because
72
+ * it reports success.
73
+ */
74
+ /**
75
+ * The RETIRED L-1 hook's committed path. Named here, in the one module that must still recognise it, and
76
+ * matched ONLY by isManagedCommand() so repairRegistration() can delete the stale entry. There is no
77
+ * guarantee-root.ts any more — this literal is all that remains of it, deliberately.
78
+ *
79
+ * It is a one-way RECOGNISER, not a shim: nothing emits it, `expectedEntries()` never returns it, and a
80
+ * settings file carrying it is reported STALE. Its only job is to make the retired entry findable so it
81
+ * can be REMOVED.
60
82
  */
83
+ exports.LEGACY_GUARANTEE_ROOT_MARKER = '.claude/webpieces/guarantee-root.sh';
84
+ /**
85
+ * When this recogniser may be deleted, as a value rather than a comment nobody re-reads.
86
+ *
87
+ * A removal-only migration is still dead weight once no consumer can be carrying the old shape. The
88
+ * hazard of deleting it EARLY is severe and silent — repair would stop stripping the retired entry, and
89
+ * a hook registered against a deleted file exits 127, which the Claude Code hooks reference defines as a
90
+ * NON-BLOCKING error, i.e. every `cd` unjudged while the cure reports success. So it gets a stated date
91
+ * and a test that fails once the date passes, instead of an intention.
92
+ *
93
+ * `legacy-marker-expiry.spec.ts` reads this and fails after it, which is the reminder.
94
+ */
95
+ exports.LEGACY_MARKER_REMOVE_AFTER = '2026-12-01';
61
96
  /** One PreToolUse hook entry as webpieces registers it. Data-only → a class, per CLAUDE.md. */
62
97
  class HookRegistrationEntry {
63
98
  matcher;
@@ -78,32 +113,52 @@ exports.RULES_MATCHER = 'Write|Edit|MultiEdit';
78
113
  // no guard, but the guards hook owns the per-invocation audit log, so matching Read records every file
79
114
  // the AI opens (log-and-allow fast path in hook-core.ts; a Read is never blocked).
80
115
  exports.GUARDS_MATCHER = 'Write|Edit|MultiEdit|Bash|Read';
81
- // Only Bash can move the shell, and moving the shell is the only thing H1 judges.
82
- exports.GUARANTEE_ROOT_MATCHER = 'Bash';
83
116
  /**
84
- * The RELATIVE guard-hook command — `sh ".claude/webpieces/ai-hook.sh" <bin>`.
117
+ * The guard-hook command — `sh "$CLAUDE_PROJECT_DIR/.claude/webpieces/ai-hook.sh" <bin>`.
118
+ *
119
+ * ABSOLUTE. This replaced a RELATIVE spelling, and the reversal is the whole point of this release.
85
120
  *
86
- * Relative, NOT `$CLAUDE_PROJECT_DIR/…`: that is the whole point (see the header). Invoked via `sh
87
- * <file>` rather than executed directly so a missing executable bit on the checked-in shim (fresh
88
- * clone, a filesystem that drops the bit, git core.fileMode quirks) can never break the hook with a raw
89
- * `Permission denied` on every tool call. Quoted to survive spaces in the path.
121
+ * The relative form existed to give each git tree its own release, binary and pin. **It never delivered
122
+ * that.** Measured 2026-08-10: a linked worktree has no `node_modules`, so ai-hook.sh's upward walk
123
+ * executes the PRIMARY's binary `readlink -f` resolved a worktree agent's bin to
124
+ * `<primary>/node_modules/@webpieces/ai-hook-rules`. A worktree ran its own SCRIPT and its own CONFIG,
125
+ * never its own release. The property was fiction, and paying for it cost an entire guard layer: a
126
+ * relative path only resolves at a tree root, so L-1 (guarantee-root.sh) had to deny every `cd` into a
127
+ * project subdirectory, which produced the force-to-root bug class, the reaped-worktree `cd`
128
+ * prescription, and a measured hard deadlock where L-1 told a worktree-isolated agent to `cd` to the
129
+ * primary clone — which the harness refuses for an isolated agent.
130
+ *
131
+ * Absolute resolves from ANY cwd, so L-1 has no job left and is deleted. One governor: the MAIN tree
132
+ * judges every tree, which is what was already happening via the borrowed binary — the design now says
133
+ * so out loud, and `VersionSyncGuard` blocks the case where that is the wrong answer.
134
+ *
135
+ * Invoked via `sh <file>` rather than executed directly so a missing executable bit on the checked-in
136
+ * shim (fresh clone, a filesystem that drops the bit, git core.fileMode quirks) can never break the hook
137
+ * with a raw `Permission denied` on every tool call. Quoted to survive spaces in the path.
90
138
  */
91
- // 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 / guarantee-root.ts
139
+ // 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
92
140
  function shimCommand(bin) {
93
- return `sh "${shim_1.SHIM_MARKER}" ${bin}`;
141
+ return `sh "$CLAUDE_PROJECT_DIR/${shim_1.SHIM_MARKER}" ${bin}`;
94
142
  }
95
- /** H1's command. ABSOLUTE on purpose — it must resolve from ANY cwd or it cannot fail closed. */
96
- exports.GUARANTEE_ROOT_COMMAND = `sh "$CLAUDE_PROJECT_DIR/${guarantee_root_1.GUARANTEE_ROOT_MARKER}"`;
97
- exports.GUARANTEE_ROOT_ENTRY = new HookRegistrationEntry(exports.GUARANTEE_ROOT_MATCHER, exports.GUARANTEE_ROOT_COMMAND);
98
143
  /** The registration entry for one guard bin. */
99
144
  // webpieces-disable no-function-outside-class -- module-scope for the same dependency-free reason as shimCommand above
100
145
  function guardHookEntry(bin) {
101
146
  return new HookRegistrationEntry(bin === exports.RULES_BIN ? exports.RULES_MATCHER : exports.GUARDS_MATCHER, shimCommand(bin));
102
147
  }
103
- /** True when this PreToolUse command is one webpieces owns (either .sh file, in any spelling). */
148
+ /**
149
+ * True when this PreToolUse command is one webpieces owns — in ANY spelling it has ever shipped.
150
+ *
151
+ * THE SINGLE MOST IMPORTANT LINE IN THIS RELEASE. `LEGACY_GUARANTEE_ROOT_MARKER` is matched here and
152
+ * NOWHERE else: it is how `repairRegistration()` finds and REMOVES the retired H1 entry from a settings
153
+ * file written by an older release. Drop it and the repair silently leaves a live L-1 hook registered
154
+ * against a file this release deletes — exit 127, which the Claude Code hooks reference defines as a
155
+ * NON-BLOCKING error, so every `cd` goes unjudged while `wp-upgrade-shim` reports success and no drift
156
+ * check can name it. Removal-only, never emitted: it appears in no `expectedEntries()` result, so it is
157
+ * a one-way migration and not a second accepted spelling.
158
+ */
104
159
  // webpieces-disable no-function-outside-class -- module-scope for the same dependency-free reason as shimCommand above
105
160
  function isManagedCommand(command) {
106
- return command.includes(shim_1.SHIM_MARKER) || command.includes(guarantee_root_1.GUARANTEE_ROOT_MARKER);
161
+ return command.includes(shim_1.SHIM_MARKER) || command.includes(exports.LEGACY_GUARANTEE_ROOT_MARKER);
107
162
  }
108
163
  // webpieces-disable no-function-outside-class -- module-scope for the same dependency-free reason as shimCommand above
109
164
  function preToolUse(settings) {
@@ -134,22 +189,17 @@ function registeredBins(settings) {
134
189
  /**
135
190
  * The exact set of entries THIS RELEASE expects in a settings file that registers `bins`.
136
191
  *
137
- * H1 rides with the GUARDS hook, not the rules hook: H1 judges Bash, and `Bash` is in the guards
138
- * matcher. A file carrying only the rules hook (the supported split install, where a team ships the
139
- * guards and a developer keeps the code-style rules local) gets no H1 and needs none.
192
+ * ONE entry per registered bin, and nothing else. There used to be a third, `guarantee-root.sh`, added
193
+ * whenever the GUARDS bin was present; it is retired, and a settings file still carrying it is STALE —
194
+ * `repairRegistration()` removes it via isManagedCommand()'s legacy marker.
140
195
  */
141
196
  // webpieces-disable no-function-outside-class -- module-scope for the same dependency-free reason as shimCommand above
142
197
  function expectedEntries(bins) {
143
- const wanted = [];
144
- if (bins.includes(exports.GUARDS_BIN))
145
- wanted.push(exports.GUARANTEE_ROOT_ENTRY);
146
- for (const bin of bins)
147
- wanted.push(guardHookEntry(bin));
148
- return wanted;
198
+ return bins.map((bin) => guardHookEntry(bin));
149
199
  }
150
200
  /**
151
- * True when a settings file registers webpieces hooks in a shape this release does not expect — the
152
- * old two-absolute-hook form, a missing guarantee-root entry, a stray duplicate, a wrong matcher.
201
+ * True when a settings file registers webpieces hooks in a shape this release does not expect: a
202
+ * RELATIVE shim command, a leftover guarantee-root entry, a stray duplicate, or a wrong matcher.
153
203
  *
154
204
  * Compared as a SET, not a sequence: Claude Code runs all matching hooks in parallel, so array order
155
205
  * carries no meaning and reordering must not read as drift.
@@ -193,23 +243,86 @@ function addHookEntry(settings, entry) {
193
243
  settings.hooks.PreToolUse = [];
194
244
  settings.hooks.PreToolUse.push({ matcher: entry.matcher, hooks: [{ type: 'command', command: entry.command }] });
195
245
  }
246
+ /** The settings `env` block, or null when the file carries none (or carries junk in its place). */
247
+ // webpieces-disable no-function-outside-class -- module-scope for the same dependency-free reason as shimCommand above
248
+ function settingsEnv(settings) {
249
+ // webpieces-disable no-any-unknown -- settings.json is opaque consumer JSON; `env` is only typed after this check
250
+ const env = settings.env;
251
+ if (typeof env !== 'object' || env === null || Array.isArray(env))
252
+ return null;
253
+ return env;
254
+ }
196
255
  /**
197
- * Bring one settings object to the three-hook form IN PLACE. Returns true when it changed.
256
+ * True when a settings file that registers webpieces hooks is missing the managed `env` entry, or
257
+ * carries any value other than the one required one.
198
258
  *
199
- * REMOVE-then-ADD, never add-beside: two spellings of one registration is exactly the compatibility
200
- * shim the backwards-compat reviewer rejects, and leaving the `$CLAUDE_PROJECT_DIR/`-prefixed entry
201
- * beside the relative one would run the PRIMARY's binary alongside the tree's own the straddle this
202
- * whole change exists to delete.
259
+ * Gated on `registeredBins()` for the SAME reason `registrationStale()` is: a settings file that
260
+ * registers no webpieces hooks is not a project install and is never judged — a global install names
261
+ * the bin path directly and carries no shim marker at all, so there is nothing here to keep in step.
262
+ *
263
+ * NOTE what this entry is for NOW. It was originally justified by keeping the then-RELATIVE hook path
264
+ * resolvable; that job is retired, because both hooks are absolute and resolve from any cwd. It is kept
265
+ * for VERDICT STABILITY — a guard's answer must depend on the command, not on where an earlier `cd` left
266
+ * the shell — and because settings `env` is inherited, every subagent shares that cwd and therefore that
267
+ * verdict. See managed-env.ts, which states this at length.
268
+ */
269
+ // webpieces-disable no-function-outside-class -- module-scope for the same dependency-free reason as shimCommand above
270
+ function envStale(settings) {
271
+ if (registeredBins(settings).length === 0)
272
+ return false;
273
+ const env = settingsEnv(settings);
274
+ return env === null || env[managed_env_1.BASH_CWD_ENV_KEY] !== managed_env_1.BASH_CWD_ENV_VALUE;
275
+ }
276
+ /**
277
+ * Set the managed `env` entry IN PLACE, whatever was there before. Returns true when it changed.
278
+ *
279
+ * A user-set `"0"` is BROUGHT TO `"1"`, not honoured: this is a managed surface, and "webpieces sets it
280
+ * unless you disagreed" would be a second, invisible spelling of the decision. Turning it off means
281
+ * uninstalling the hooks, exactly as it does for every other managed thing.
282
+ *
283
+ * UNGATED on purpose — the installer calls it for the file it is writing hooks into, and the gate lives
284
+ * at the caller (repairRegistration / applyHook), so a file with no webpieces hooks is never touched.
285
+ */
286
+ // webpieces-disable no-function-outside-class -- module-scope for the same dependency-free reason as shimCommand above
287
+ function applyManagedEnv(settings) {
288
+ const existing = settingsEnv(settings);
289
+ if (existing !== null && existing[managed_env_1.BASH_CWD_ENV_KEY] === managed_env_1.BASH_CWD_ENV_VALUE)
290
+ return false;
291
+ const env = existing ?? {};
292
+ env[managed_env_1.BASH_CWD_ENV_KEY] = managed_env_1.BASH_CWD_ENV_VALUE;
293
+ settings.env = env;
294
+ return true;
295
+ }
296
+ /**
297
+ * Bring one settings object to the two-hook ABSOLUTE form AND the managed `env` entry IN PLACE. Returns
298
+ * true when it changed anything.
299
+ *
300
+ * REMOVE-then-ADD for the hooks, never add-beside: two spellings of one registration is exactly the
301
+ * compatibility shim the backwards-compat reviewer rejects, and leaving a RELATIVE entry beside the
302
+ * absolute one would run two shims per call — the
303
+ * straddle this whole change exists to delete.
304
+ *
305
+ * THE TWO REPAIRS ARE EVALUATED INDEPENDENTLY, and that is load-bearing rather than tidy: this used to
306
+ * early-return on `!registrationStale(settings)`, so a repo whose hooks are already current but whose
307
+ * `env` entry is missing — the state EVERY existing consumer is in the moment this release lands — would
308
+ * have been reported as drifted by fault S and then left unrepaired by its own prescribed cure. A cure
309
+ * that skips half the surface is the failure mode `upgrade-shim.ts`'s header exists to prevent.
203
310
  */
204
311
  // webpieces-disable no-function-outside-class -- module-scope for the same dependency-free reason as shimCommand above
205
312
  function repairRegistration(settings) {
206
313
  const bins = registeredBins(settings);
207
- if (bins.length === 0 || !registrationStale(settings))
314
+ if (bins.length === 0)
208
315
  return false;
209
- dropManagedEntries(settings);
210
- for (const entry of expectedEntries(bins))
211
- addHookEntry(settings, entry);
212
- return true;
316
+ let changed = false;
317
+ if (registrationStale(settings)) {
318
+ dropManagedEntries(settings);
319
+ for (const entry of expectedEntries(bins))
320
+ addHookEntry(settings, entry);
321
+ changed = true;
322
+ }
323
+ if (applyManagedEnv(settings))
324
+ changed = true;
325
+ return changed;
213
326
  }
214
327
  /** The two project settings files the installer can write. */
215
328
  // webpieces-disable no-function-outside-class -- module-scope for the same dependency-free reason as shimCommand above
@@ -255,25 +368,68 @@ function registrationStaleAt(root) {
255
368
  return false;
256
369
  }
257
370
  }
258
- /** Rewrite every stale project settings file under `root`; returns the paths actually changed. */
371
+ /** True when either project settings file under `root` is missing the managed `env` entry. */
372
+ // webpieces-disable no-function-outside-class -- module-scope for the same dependency-free reason as shimCommand above
373
+ function envStaleAt(root) {
374
+ if (root === null)
375
+ return false;
376
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
377
+ try {
378
+ return projectSettingsPaths(root).some((p) => envStale(readSettings(p)));
379
+ }
380
+ catch (err) {
381
+ const error = (0, to_error_1.toError)(err);
382
+ void error; // best-effort: unreadable/invalid settings counts as "not stale" so it never wedges a call
383
+ return false;
384
+ }
385
+ }
386
+ /**
387
+ * WHAT was rewritten in ONE settings file. Data-only → a class, per CLAUDE.md.
388
+ *
389
+ * Two independent flags rather than one path, because the cure has to be able to SAY which repair it
390
+ * made: "rewrote the hook registration" printed for a file whose registration was already current and
391
+ * whose `env` entry was the only thing missing is a cure lying about its own work.
392
+ */
393
+ class SettingsRepair {
394
+ settingsPath;
395
+ registration;
396
+ env;
397
+ constructor(settingsPath, registration, env) {
398
+ this.settingsPath = settingsPath;
399
+ this.registration = registration;
400
+ this.env = env;
401
+ }
402
+ }
403
+ exports.SettingsRepair = SettingsRepair;
404
+ /** Rewrite every stale project settings file under `root`; returns what changed, per file. */
259
405
  // webpieces-disable no-function-outside-class -- module-scope for the same dependency-free reason as shimCommand above
260
406
  function repairRegistrationAt(root) {
261
- const changed = [];
407
+ const repairs = [];
262
408
  for (const settingsPath of projectSettingsPaths(root)) {
263
409
  if (!fs.existsSync(settingsPath))
264
410
  continue;
265
411
  const settings = readSettings(settingsPath);
412
+ const neededRegistration = registrationStale(settings);
413
+ const neededEnv = envStale(settings);
266
414
  if (!repairRegistration(settings))
267
415
  continue;
268
416
  writeSettings(settingsPath, settings);
269
- changed.push(settingsPath);
417
+ repairs.push(new SettingsRepair(settingsPath, neededRegistration, neededEnv));
270
418
  }
271
- return changed;
419
+ return repairs;
272
420
  }
273
- /** The three names the drift check reports, so a deny can say WHICH of them moved. */
421
+ /**
422
+ * The THREE names the drift check reports, so a deny can say WHICH of them moved.
423
+ *
424
+ * There were four. `GUARANTEE_ROOT_SURFACE` is gone with L-1 itself: an absolutely-registered shim
425
+ * resolves from any cwd, so there is no launch guarantee left to police and no second .sh file to keep
426
+ * byte-locked. A settings file still carrying the retired H1 entry is not its own surface any more —
427
+ * it is ordinary REGISTRATION drift, which `registrationStaleAt()` already reports and
428
+ * `repairRegistration()` already fixes by removing it.
429
+ */
274
430
  exports.SHIM_SURFACE = shim_1.SHIM_MARKER;
275
- exports.GUARANTEE_ROOT_SURFACE = guarantee_root_1.GUARANTEE_ROOT_MARKER;
276
431
  exports.REGISTRATION_SURFACE = '.claude/settings.json hook registration';
432
+ exports.ENV_SURFACE = `.claude/settings.json env.${managed_env_1.BASH_CWD_ENV_KEY}`;
277
433
  /**
278
434
  * WHICH of the three managed surfaces disagree with this release — the input to fault S.
279
435
  *
@@ -286,35 +442,10 @@ function managedSurfaceDrift(root) {
286
442
  const drifted = [];
287
443
  if ((0, shim_1.committedShimStale)(root))
288
444
  drifted.push(exports.SHIM_SURFACE);
289
- if ((0, guarantee_root_1.committedGuaranteeRootStale)(root) || guaranteeRootRegisteredButMissing(root))
290
- drifted.push(exports.GUARANTEE_ROOT_SURFACE);
291
445
  if (registrationStaleAt(root))
292
446
  drifted.push(exports.REGISTRATION_SURFACE);
447
+ if (envStaleAt(root))
448
+ drifted.push(exports.ENV_SURFACE);
293
449
  return drifted;
294
450
  }
295
- /**
296
- * The L-1 hook is REGISTERED but its file is gone.
297
- *
298
- * `committedGuaranteeRootStale()` answers false for a missing file on purpose — a repo that has not
299
- * adopted L-1 yet is not "stale", it is simply still on the old registration, and that case is already
300
- * reported by `registrationStaleAt()`. But a settings file that REGISTERS the hook while the file is
301
- * absent is the worst case of all: the hook cannot launch, exit 127 is a NON-BLOCKING error, and per
302
- * the hooks reference the tool call proceeds — every `cd` unjudged, with nothing surfaced. The
303
- * registration is what distinguishes "not adopted" from "adopted and broken", so it is what is asked.
304
- */
305
- // webpieces-disable no-function-outside-class -- module-scope for the same dependency-free reason as shimCommand above
306
- function guaranteeRootRegisteredButMissing(root) {
307
- if (root === null)
308
- return false;
309
- // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
310
- try {
311
- const registered = projectSettingsPaths(root).some((p) => managedEntries(readSettings(p)).some((e) => e.command === exports.GUARANTEE_ROOT_COMMAND));
312
- return registered && !fs.existsSync((0, guarantee_root_1.guaranteeRootPath)(root));
313
- }
314
- catch (err) {
315
- const error = (0, to_error_1.toError)(err);
316
- void error; // best-effort: unreadable settings counts as "not drifted" so it never wedges a call
317
- return false;
318
- }
319
- }
320
451
  //# sourceMappingURL=hook-registration.js.map
@@ -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 { GUARANTEE_ROOT_MARKER, committedGuaranteeRootStale, guaranteeRootPath } from './guarantee-root';\n\n/**\n * THE INSTALLED HOOK SURFACE — three hooks, and the ONE place their spelling is defined.\n *\n * ─── Why three, and why two of them are RELATIVE ───────────────────────────────────────────────────\n * `.claude/settings.json` used to register two hooks, BOTH absolute via `$CLAUDE_PROJECT_DIR`. That\n * variable NEVER moves — proven from four separate worktrees' own logs, every line reading\n * `root=<worktree> projectDir=<primary>` — so every tree was governed by the PRIMARY's shim, the\n * PRIMARY's binary and the PRIMARY's pin, forever. A worktree could never be judged by the release its\n * own branch pins, and measuring one tree while running another's binary is the non-convergent\n * \"two-tree straddle\" recorded in shim.ts (an agent gave up after four cures).\n *\n * The hooks reference says \"the hook runs in the `cwd` value from the JSON input\", so a RELATIVE\n * command resolves against the tool call's own tree. Hence:\n *\n * H1 absolute sh \"$CLAUDE_PROJECT_DIR/.claude/webpieces/guarantee-root.sh\" matcher Bash\n * H2 relative sh \".claude/webpieces/ai-hook.sh\" wp-ai-guards-hook matcher Write|…|Read\n * H3 relative sh \".claude/webpieces/ai-hook.sh\" wp-ai-rules-hook matcher Write|Edit|MultiEdit\n *\n * H1 stays absolute because it is the one hook that must ALWAYS resolve: a relative hook that cannot\n * resolve exits 127, and per the same reference any non-2 non-zero exit is a NON-BLOCKING error — i.e.\n * a SILENT UNGUARDED ALLOW. H1 refuses any `cd` that would park the shell where H2/H3 cannot launch,\n * which is what makes the relative pair admissible at all. See guarantee-root.ts.\n *\n * H1 matches `Bash` alone because only Bash can move the shell — the same reason stated at the top of\n * guarantee-root.ts, and the reason the rendered guarantee-root.sh exits 0 immediately for every other\n * tool. Registering it wider would spawn a process per Write/Read to do nothing.\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 the old\n * two-absolute-hook form silently reverted a repo to per-PRIMARY governance and disabled H1 — the one\n * component whose whole job is failing closed. The installed surface is therefore THREE things\n * (ai-hook.sh, guarantee-root.sh, the registration), all three are compared against this release, and\n * `wp-upgrade-shim` regenerates all three. A cure that fixes one of three is worse than no cure,\n * because it reports success.\n */\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 // 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// Only Bash can move the shell, and moving the shell is the only thing H1 judges.\nexport const GUARANTEE_ROOT_MATCHER = 'Bash';\n\n/**\n * The RELATIVE guard-hook command — `sh \".claude/webpieces/ai-hook.sh\" <bin>`.\n *\n * Relative, NOT `$CLAUDE_PROJECT_DIR/…`: that is the whole point (see the header). Invoked via `sh\n * <file>` rather than executed directly so a missing executable bit on the checked-in shim (fresh\n * clone, a filesystem that drops the bit, git core.fileMode quirks) can never break the hook with a raw\n * `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 / guarantee-root.ts\nexport function shimCommand(bin: string): string {\n return `sh \"${SHIM_MARKER}\" ${bin}`;\n}\n\n/** H1's command. ABSOLUTE on purpose — it must resolve from ANY cwd or it cannot fail closed. */\nexport const GUARANTEE_ROOT_COMMAND = `sh \"$CLAUDE_PROJECT_DIR/${GUARANTEE_ROOT_MARKER}\"`;\n\nexport const GUARANTEE_ROOT_ENTRY = new HookRegistrationEntry(GUARANTEE_ROOT_MATCHER, GUARANTEE_ROOT_COMMAND);\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/** True when this PreToolUse command is one webpieces owns (either .sh file, in any spelling). */\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(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 * H1 rides with the GUARDS hook, not the rules hook: H1 judges Bash, and `Bash` is in the guards\n * matcher. A file carrying only the rules hook (the supported split install, where a team ships the\n * guards and a developer keeps the code-style rules local) gets no H1 and needs none.\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 const wanted: HookRegistrationEntry[] = [];\n if (bins.includes(GUARDS_BIN)) wanted.push(GUARANTEE_ROOT_ENTRY);\n for (const bin of bins) wanted.push(guardHookEntry(bin));\n return wanted;\n}\n\n/**\n * True when a settings file registers webpieces hooks in a shape this release does not expect — the\n * old two-absolute-hook form, a missing guarantee-root entry, a stray duplicate, 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/**\n * Bring one settings object to the three-hook form IN PLACE. Returns true when it changed.\n *\n * REMOVE-then-ADD, never add-beside: two spellings of one registration is exactly the compatibility\n * shim the backwards-compat reviewer rejects, and leaving the `$CLAUDE_PROJECT_DIR/`-prefixed entry\n * beside the relative one would run the PRIMARY's binary alongside the tree's own — the straddle this\n * whole change exists to delete.\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 || !registrationStale(settings)) return false;\n dropManagedEntries(settings);\n for (const entry of expectedEntries(bins)) addHookEntry(settings, entry);\n return true;\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/** Rewrite every stale project settings file under `root`; returns the paths actually changed. */\n// webpieces-disable no-function-outside-class -- module-scope for the same dependency-free reason as shimCommand above\nexport function repairRegistrationAt(root: string): readonly string[] {\n const changed: string[] = [];\n for (const settingsPath of projectSettingsPaths(root)) {\n if (!fs.existsSync(settingsPath)) continue;\n const settings = readSettings(settingsPath);\n if (!repairRegistration(settings)) continue;\n writeSettings(settingsPath, settings);\n changed.push(settingsPath);\n }\n return changed;\n}\n\n/** The three names the drift check reports, so a deny can say WHICH of them moved. */\nexport const SHIM_SURFACE = SHIM_MARKER;\nexport const GUARANTEE_ROOT_SURFACE = GUARANTEE_ROOT_MARKER;\nexport const REGISTRATION_SURFACE = '.claude/settings.json hook registration';\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 (committedGuaranteeRootStale(root) || guaranteeRootRegisteredButMissing(root)) drifted.push(GUARANTEE_ROOT_SURFACE);\n if (registrationStaleAt(root)) drifted.push(REGISTRATION_SURFACE);\n return drifted;\n}\n\n/**\n * The L-1 hook is REGISTERED but its file is gone.\n *\n * `committedGuaranteeRootStale()` answers false for a missing file on purpose — a repo that has not\n * adopted L-1 yet is not \"stale\", it is simply still on the old registration, and that case is already\n * reported by `registrationStaleAt()`. But a settings file that REGISTERS the hook while the file is\n * absent is the worst case of all: the hook cannot launch, exit 127 is a NON-BLOCKING error, and per\n * the hooks reference the tool call proceeds — every `cd` unjudged, with nothing surfaced. The\n * registration is what distinguishes \"not adopted\" from \"adopted and broken\", so it is what is asked.\n */\n// webpieces-disable no-function-outside-class -- module-scope for the same dependency-free reason as shimCommand above\nfunction guaranteeRootRegisteredButMissing(root: string | null): boolean {\n if (root === null) return false;\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const registered = projectSettingsPaths(root).some((p: string): boolean =>\n managedEntries(readSettings(p)).some((e: HookRegistrationEntry): boolean => e.command === GUARANTEE_ROOT_COMMAND));\n return registered && !fs.existsSync(guaranteeRootPath(root));\n } catch (err: unknown) {\n const error = toError(err);\n void error; // best-effort: unreadable settings counts as \"not drifted\" so it never wedges a call\n return false;\n }\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 { 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 → one install in the main tree)\n * or using a separate CLONE, which — unlike a worktree — really does get its own node_modules and its\n * own governance.\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"]}