@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
package/src/bin/setup.js CHANGED
@@ -2,11 +2,11 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.GUARDS_HOOK = exports.RULES_HOOK = exports.InstallTarget = void 0;
4
4
  exports.installTargets = installTargets;
5
- exports.migrate = migrate;
6
5
  exports.hasHook = hasHook;
7
6
  exports.applyHook = applyHook;
8
7
  exports.resolveTargetChoice = resolveTargetChoice;
9
8
  exports.parseTargetArg = parseTargetArg;
9
+ exports.applyChoice = applyChoice;
10
10
  exports.main = main;
11
11
  const tslib_1 = require("tslib");
12
12
  const fs = tslib_1.__importStar(require("fs"));
@@ -14,17 +14,13 @@ const path = tslib_1.__importStar(require("path"));
14
14
  const os_1 = require("os");
15
15
  const readline_1 = require("readline");
16
16
  const rules_config_1 = require("@webpieces/rules-config");
17
- const to_error_1 = require("../core/to-error");
18
17
  const shim_1 = require("./shim");
19
18
  const hook_registration_1 = require("./hook-registration");
20
19
  const managed_env_1 = require("./managed-env");
21
- const CONFIG_FILENAME = 'webpieces.config.json';
22
- // The seeded buildCommand comes from @webpieces/rules-config, NOT from a copy here. This file used to
23
- // hold its own with `--base=origin/main`, a DIFFERENT base from the one the gate documents — so a
24
- // freshly set-up repo was seeded with a command that rebuilds projects touched by other people's
25
- // merged PRs, and whole-repo-build-guard then quoted that command back in its refusals.
26
- const DEFAULT_UPSERT_PR = 'pnpm wp-start-upsert-pr';
27
- const DEFAULT_MERGE_COMPLETE = 'pnpm wp-finish-upsert-pr';
20
+ const codex_trust_1 = require("./codex-trust");
21
+ // The config half of the installer seeding and migrating webpieces.config.json. Split for size along
22
+ // the seam that was already there: this module is hook WIRING, that one is CONFIG SHAPE.
23
+ const setup_config_1 = require("./setup-config");
28
24
  // ---------------------------------------------------------------------------
29
25
  // The two independently-installable GUARD hooks. Each can land in a different settings file (see
30
26
  // InstallTarget) so a team can ship the guards while a developer keeps the code-style rules local
@@ -35,23 +31,32 @@ const DEFAULT_MERGE_COMPLETE = 'pnpm wp-finish-upsert-pr';
35
31
  class HookSpec {
36
32
  key;
37
33
  label;
38
- matcher;
39
34
  bin;
40
- constructor(key, label, matcher, bin) {
35
+ constructor(key, label, bin) {
41
36
  this.key = key;
42
37
  this.label = label;
43
- this.matcher = matcher;
44
38
  this.bin = bin;
45
39
  }
40
+ /**
41
+ * WHICH TOOL NAMES this hook must see, in the harness the target belongs to.
42
+ *
43
+ * It is a lookup on the target rather than a field on the spec because the answer is not a property
44
+ * of the hook: the rules hook matches `Write|Edit|MultiEdit` under Claude Code and `apply_patch`
45
+ * under Codex, and a single stored matcher is exactly how `.codex/hooks.json` came to be registered
46
+ * against tool names Codex never emits.
47
+ */
48
+ matcherFor(target) {
49
+ return target.harness.matcherFor(this.bin);
50
+ }
46
51
  // Absolute targets (global) need the exact path to this repo's bin — no ~/.webpieces bridge.
47
- // Project targets get the ABSOLUTE shim command, `$CLAUDE_PROJECT_DIR/.claude/webpieces/ai-hook.sh`
48
- // (see hook-registration.ts for why): it resolves from ANY cwd, so a hook can never fail to launch —
49
- // which per the hooks reference would be exit 127, a SILENT UNGUARDED ALLOW rather than a block.
52
+ // Project targets get the ABSOLUTE shim command, anchored on the harness's own project-root variable
53
+ // (see HarnessRegistration.shimCommand): it resolves from ANY cwd, so a hook can never fail to
54
+ // launch — which per the hooks reference would be exit 127, a SILENT UNGUARDED ALLOW, not a block.
50
55
  commandFor(target, projectRoot) {
51
56
  if (target.absolute) {
52
57
  return `node ${path.join(projectRoot, 'node_modules', '.bin', this.bin)}`;
53
58
  }
54
- return (0, hook_registration_1.shimCommand)(this.bin);
59
+ return target.harness.shimCommand(this.bin);
55
60
  }
56
61
  }
57
62
  // Idempotent: re-running the installer overwrites the managed shim in place.
@@ -105,361 +110,58 @@ class InstallTarget {
105
110
  label;
106
111
  settingsPath;
107
112
  absolute;
108
- constructor(choice, label, settingsPath, absolute) {
113
+ harness;
114
+ // eslint-disable-next-line @typescript-eslint/max-params
115
+ constructor(choice, label, settingsPath, absolute,
116
+ /**
117
+ * WHICH HARNESS this file arms — it decides the matcher and the shim anchor written into it.
118
+ *
119
+ * REQUIRED, with no default, and the absence of one is the point. A default of
120
+ * CLAUDE_REGISTRATION would make "omit the harness" mean "Claude Code" — a widening that is an
121
+ * ABSENCE rather than a token, so a target built for Codex without it would silently carry
122
+ * `Write|Edit|MultiEdit` and `$CLAUDE_PROJECT_DIR`, which is EXACTLY the silently-unguarded
123
+ * state this class exists to end, and ungreppable besides.
124
+ */
125
+ harness) {
109
126
  this.choice = choice;
110
127
  this.label = label;
111
128
  this.settingsPath = settingsPath;
112
129
  this.absolute = absolute;
130
+ this.harness = harness;
113
131
  }
114
132
  }
115
133
  exports.InstallTarget = InstallTarget;
116
- // The matchers and bin names come from ./hook-registration, which is also what the drift check and
117
- // wp-upgrade-shim compare against — one spelling of the registration, or the installer and the
118
- // validator can disagree about what "installed" means.
119
- exports.RULES_HOOK = new HookSpec('rules', 'Rules hook (code-style validation)', hook_registration_1.RULES_MATCHER, hook_registration_1.RULES_BIN);
120
- exports.GUARDS_HOOK = new HookSpec('guards', 'Guards hook (git/PR/branch protection)', hook_registration_1.GUARDS_MATCHER, hook_registration_1.GUARDS_BIN);
121
- // `homeDir` is injectable so tests can point the global target at a temp dir instead of the real
122
- // ~/.claude/settings.json (a unit test must never write the user's actual global settings).
123
- function installTargets(projectRoot, homeDir = (0, os_1.homedir)()) {
124
- return [
125
- new InstallTarget('1', 'project (.claude/settings.json — committed, for the team)', path.join(projectRoot, '.claude', 'settings.json'), false),
126
- new InstallTarget('2', 'project for you (.claude/settings.local.json — personal)', path.join(projectRoot, '.claude', 'settings.local.json'), false),
127
- new InstallTarget('3', 'global (~/.claude/settings.json — exact path, this repo only)', path.join(homeDir, '.claude', 'settings.json'), true),
128
- ];
129
- }
130
- // webpieces-disable no-function-outside-class -- sibling of the other seed* helpers; this module is config-shape builders by design
131
- function seedRule(ruleName) {
132
- // Both escape hatches are seeded (and REQUIRED) so every rule block shows them: 0 = active,
133
- // null = no branch scoping. A human/AI edits these to time-box or branch-scope a rule off.
134
- //
135
- // The ENTIRE entry comes from rules-config's seedEntryForRule() — the same module that owns the
136
- // schema the loader validates against, so the installer can never emit an entry the loader
137
- // rejects. It supplies: the recommended mode (the SAME recommendation the validator prints in its
138
- // copy-paste snippet, so seed and advice cannot disagree), both hatches, and a default for every
139
- // other schema-REQUIRED field. Seeding used to be a flat 'OFF' plus the two hatches, which was
140
- // wrong twice over: adopters got nothing enforced, AND the entry was missing required fields
141
- // (e.g. branch-creation-guard.autoReapMergedBranches), so the config failed to load on first run.
142
- return (0, rules_config_1.seedEntryForRule)(ruleName);
143
- }
144
- // The guard-hint command strings live under `guardHints`. The flat `upsertPr`/`mergeComplete` keys this
145
- // used to seed are RETIRED and now fail validation — seeding them meant every freshly installed repo was
146
- // born on a shape the validator rejects.
147
- function seedCommands() {
148
- return {
149
- 'pr-gate': { mode: 'OFF', buildCommand: rules_config_1.DEFAULT_BUILD_COMMAND, gates: [] },
150
- guardHints: { prCreationOrPush: DEFAULT_UPSERT_PR, mergeInProgress: DEFAULT_MERGE_COMPLETE },
151
- };
152
- }
153
- // Required excludePaths block: ONE glob list suppressing hook enforcement per file path. Seeded empty
154
- // (enforce everywhere) — a client adds paths (e.g. "repositories/**") to exempt vendored trees.
155
- //
156
- // Deliberately NOT seeded with webpieces' own `.webpieces/` state dir. That exemption lives in CODE
157
- // (`isWebpiecesStateDir`, consulted by `filterByExcludedPaths` ahead of this list and regardless of it),
158
- // and a glob here would be a second, weaker spelling of it — weaker because `.webpieces/**` compiles to
159
- // an anchored regex that misses the bare directory the predicate matches, and because a config entry
160
- // invites a consumer to delete it and believe the exemption went with it.
161
- // webpieces-disable no-function-outside-class -- sibling of the other seed* helpers; this module is config-shape builders by design
162
- function seedExcludePaths() {
163
- return [];
164
- }
165
- // Bring an existing `excludePaths` forward to the single-list shape. Already a list → untouched.
166
- // Legacy `{ rules, guards }` → unioned (order preserved, duplicates dropped) and recorded as a change
167
- // so `wp-install-ai-hooks` is the migration path rather than a hand-edit. Anything else → seeded [].
168
- // webpieces-disable no-any-unknown -- `raw` is opaque consumer JSON until narrowed here
169
- // webpieces-disable no-function-outside-class -- sibling of the other seed*/migrate* helpers; this module is config-shape builders by design
170
- function migrateExcludePaths(raw, changes) {
171
- if (Array.isArray(raw))
172
- return raw.filter(p => typeof p === 'string');
173
- if (typeof raw === 'object' && raw !== null) {
174
- // webpieces-disable no-any-unknown -- narrowing the opaque legacy block from consumer JSON
175
- const legacy = raw;
176
- const rules = Array.isArray(legacy['rules']) ? legacy['rules'] : [];
177
- const guards = Array.isArray(legacy['guards']) ? legacy['guards'] : [];
178
- const merged = [...new Set([...rules, ...guards].filter(p => typeof p === 'string'))];
179
- changes.push(`migrated excludePaths {rules,guards} -> one list (${merged.length} path(s))`);
180
- return merged;
181
- }
182
- changes.push('added excludePaths ([])');
183
- return seedExcludePaths();
184
- }
185
- /** One retired flat command string and the guardHints field it becomes. Data-only (per CLAUDE.md). */
186
- class GuardHintMove {
187
- retiredKey;
188
- hintKey;
189
- fallback;
190
- constructor(retiredKey, hintKey, fallback) {
191
- this.retiredKey = retiredKey;
192
- this.hintKey = hintKey;
193
- this.fallback = fallback;
194
- }
195
- }
134
+ // The bin names come from ./hook-registration, which is also what the drift check and wp-upgrade-shim
135
+ // compare against — one spelling of the registration, or the installer and the validator can disagree
136
+ // about what "installed" means. The MATCHER is not here: it belongs to the harness, not the hook (see
137
+ // HookSpec.matcherFor).
138
+ exports.RULES_HOOK = new HookSpec('rules', 'Rules hook (code-style validation)', hook_registration_1.RULES_BIN);
139
+ exports.GUARDS_HOOK = new HookSpec('guards', 'Guards hook (git/PR/branch protection)', hook_registration_1.GUARDS_BIN);
196
140
  /**
197
- * Bring `commands` forward to the `guardHints` shape, moving the RETIRED flat `upsertPr`/`mergeComplete`
198
- * strings and DELETING them. Deleting is the point: the validator now rejects them, so leaving them behind
199
- * would keep the config failing after a "successful" sync.
141
+ * Every file the installer can write, keyed by the CHOICE a human makes.
200
142
  *
201
- * The consumer's own value wins over the default a repo that renamed its gated command keeps that name.
202
- */
203
- // webpieces-disable no-function-outside-class -- sibling of the other seed*/migrate* helpers; this module is config-shape builders by design
204
- function migrateGuardHints(commands, changes) {
205
- const hints = (typeof commands['guardHints'] === 'object' && commands['guardHints'] !== null)
206
- ? commands['guardHints'] : {};
207
- const moves = [
208
- new GuardHintMove('upsertPr', 'prCreationOrPush', DEFAULT_UPSERT_PR),
209
- new GuardHintMove('mergeComplete', 'mergeInProgress', DEFAULT_MERGE_COMPLETE),
210
- ];
211
- for (const move of moves) {
212
- const retiredKey = move.retiredKey;
213
- const hintKey = move.hintKey;
214
- const fallback = move.fallback;
215
- const carried = commands[retiredKey];
216
- if (carried !== undefined) {
217
- delete commands[retiredKey];
218
- if (hints[hintKey] === undefined)
219
- hints[hintKey] = carried;
220
- changes.push(`moved retired commands.${retiredKey} -> commands.guardHints.${hintKey}`);
221
- }
222
- if (hints[hintKey] === undefined) {
223
- hints[hintKey] = fallback;
224
- changes.push(`added commands.guardHints.${hintKey}`);
225
- }
226
- }
227
- commands['guardHints'] = hints;
228
- }
229
- /**
230
- * Apply the RETIRED rule/guard retirements in place. These used to be rewritten silently at load time, so
231
- * a consumer's file kept the dead name forever; the loader now rejects it, which makes this the one
232
- * command that can fix the file. Skips a rename when the new name is already configured, so an explicit
233
- * entry is never clobbered by a stale one.
143
+ * TWO TARGETS SHARE CHOICE `1`, and that is the design rather than an oversight: "the project, committed,
144
+ * for the team" is ONE intention, and a repo that is worked on by both harnesses needs both files armed
145
+ * to mean it. Splitting it into two questions would let a human answer them differently and end up with a
146
+ * repo where Codex is silently unguarded — which is the state this whole change exists to end. Every
147
+ * caller selects by choice id (`targets.filter(t => t.choice === answer)`), never by index, so adding a
148
+ * harness adds a row and changes no numbering.
234
149
  *
235
- * NOT EVERY RETIREMENT IS A RENAME, and treating them all as one produced garbage. `whole-repo-build-guard`
236
- * moved OUT of webpieces.config.json entirely its `movedTo` is the PROSE destination
237
- * `~/.webpieces/config.json → experimental.whole-repo-build-guard`, not a sibling key — so the rename
238
- * branch below would have created a hookGuards entry literally named that whole sentence, which no
239
- * validator knows and which the next run reports as another unknown rule. `prunable` is the discriminator:
240
- * when the entry says deleting is the whole fix, DELETE it, exactly as `ConfigPruner` does.
241
- */
242
- // webpieces-disable no-function-outside-class -- sibling of the other seed*/migrate* helpers; this module is config-shape builders by design
243
- function migrateRetiredRuleNames(section, changes) {
244
- for (const entry of rules_config_1.RETIRED_CONFIG_KEYS) {
245
- if (entry.scope !== rules_config_1.RETIRED_SCOPE_RULE)
246
- continue;
247
- if (!(entry.key in section))
248
- continue;
249
- if (entry.prunable) {
250
- delete section[entry.key];
251
- changes.push(`deleted retired "${entry.key}" (it moved to ${entry.movedTo})`);
252
- continue;
253
- }
254
- mergeIntoDestination(section, entry.key, entry.movedTo, changes);
255
- }
256
- fillRequiredFields(section, changes);
257
- }
258
- /**
259
- * Fold one retired key's entry into its destination, whether the destination exists yet or not.
260
- *
261
- * THIS IS N→1, NOT 1:1, and the difference is the whole reason this helper exists. Four retired keys
262
- * now point at ONE destination (`branch-state-guard`, `pr-lifecycle-guard`). The previous code renamed
263
- * the first key it met and then, finding the destination already present, DELETED each of the other
264
- * three outright — so which guard's settings survived depended on RETIRED_CONFIG_KEYS declaration
265
- * order rather than on the consumer's file, and the survivor carried only that one guard's fields, so
266
- * it was missing required fields of the merged schema. `wp-install-ai-hooks` is the command advertised
267
- * as the migration path; half-migrating every consumer into an invalid config is not an option.
150
+ * `2` (personal) and `3` (global) stay Claude-only because neither has a Codex counterpart: Codex reads
151
+ * one repo-local `hooks.json` and has no personal or home-scoped hook file.
268
152
  *
269
- * UNION, first writer wins per field. Earlier-declared keys are the more specific ones (only
270
- * feature-branch-guard carries `branchNamingConvention`), and a field already present on the
271
- * destination — because the consumer wrote it, or an earlier key contributed it — is never overwritten.
272
- * Fields the merged schema does not know are dropped by the same pass, since carrying a deleted field
273
- * across (`upsertPrCommand`) would produce a config the validator immediately rejects.
153
+ * `homeDir` is injectable so tests can point the global target at a temp dir instead of the real
154
+ * ~/.claude/settings.json (a unit test must never write the user's actual global settings).
274
155
  */
275
- // webpieces-disable no-function-outside-class -- sibling of the other seed*/migrate* helpers; this module is config-shape builders by design
276
- function mergeIntoDestination(section, key, destination, changes) {
277
- const source = asSection(section[key]);
278
- delete section[key];
279
- const fields = (0, rules_config_1.schemaFieldNames)(destination);
280
- const target = asSection(section[destination]);
281
- const existed = destination in section;
282
- const carried = [];
283
- const dropped = [];
284
- for (const field of Object.keys(source)) {
285
- if (fields !== null && !fields.includes(field)) {
286
- dropped.push(field);
287
- continue;
288
- }
289
- if (field in target)
290
- continue;
291
- target[field] = source[field];
292
- carried.push(field);
293
- }
294
- section[destination] = target;
295
- const verb = existed ? 'merged' : 'renamed';
296
- const droppedNote = dropped.length > 0 ? `; dropped deleted field(s) ${dropped.join(', ')}` : '';
297
- changes.push(`${verb} retired "${key}" -> "${destination}" (carried ${carried.join(', ') || 'nothing new'}${droppedNote})`);
298
- }
299
- /**
300
- * Fill any schema-REQUIRED field a migrated entry ended up without.
301
- *
302
- * A union of four partial entries is not guaranteed to satisfy the destination's schema — the merged
303
- * `branch-state-guard` needs `mode` and both escape hatches, and a consumer whose four old entries
304
- * predate one of them would land short. Seeding the gap from the SAME source the installer and the
305
- * validator use (seedEntryForRule) is what makes the install command a complete instruction
306
- * rather than a first step. Only ever ADDS; a value the consumer stated is never touched.
307
- */
308
- // webpieces-disable no-function-outside-class -- sibling of the other seed*/migrate* helpers; this module is config-shape builders by design
309
- function fillRequiredFields(section, changes) {
310
- for (const name of Object.keys(section)) {
311
- if ((0, rules_config_1.schemaFieldNames)(name) === null)
312
- continue;
313
- // A rule ENTRY is a flat bag of scalars, so it is read as Json here rather than through
314
- // asSection (whose values are whole entries). Same object either way; only the view differs.
315
- const entry = asSection(section[name]);
316
- const seed = (0, rules_config_1.seedEntryForRule)(name);
317
- const added = [];
318
- for (const field of Object.keys(seed)) {
319
- if (field in entry)
320
- continue;
321
- entry[field] = seed[field];
322
- added.push(field);
323
- }
324
- if (added.length === 0)
325
- continue;
326
- section[name] = entry;
327
- changes.push(`filled required field(s) on "${name}": ${added.join(', ')}`);
328
- }
329
- }
330
- // Deep-copy the framework's default match-rules (the no-fetch guard) into plain JSON for the config
331
- // file. Round-tripping through JSON turns the MatchRuleConfig instances into plain objects.
332
- function seedMatchRules() {
333
- return JSON.parse(JSON.stringify(rules_config_1.DEFAULT_MATCH_RULES));
334
- }
335
- function buildSeedConfig() {
336
- const rules = {};
337
- const hookGuards = {};
338
- for (const name of (0, rules_config_1.allRuleNames)()) {
339
- if ((0, rules_config_1.sectionForRule)(name) === 'hookGuards')
340
- hookGuards[name] = seedRule(name);
341
- else
342
- rules[name] = seedRule(name);
343
- }
344
- return {
345
- rules, hookGuards, commands: seedCommands(), excludePaths: seedExcludePaths(),
346
- // Seed the required match-rules array with the framework's default no-fetch guard. A fresh
347
- // project gets contract-first enforcement out of the box; clients edit it and add more entries.
348
- 'match-rules': seedMatchRules(),
349
- rulesDir: [],
350
- };
351
- }
352
- function writeConfig(configPath, config) {
353
- fs.writeFileSync(configPath, JSON.stringify(config, null, 4) + '\n');
354
- }
355
- function readConfig(configPath) {
356
- const raw = fs.readFileSync(configPath, 'utf8');
357
- // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
358
- try {
359
- return JSON.parse(raw);
360
- }
361
- catch (err) {
362
- const error = (0, to_error_1.toError)(err);
363
- throw new Error(`${CONFIG_FILENAME} has invalid JSON — fix it, then retry: ${error.message}`, { cause: error });
364
- }
365
- }
366
- function asSection(value) {
367
- return (typeof value === 'object' && value !== null && !Array.isArray(value)) ? value : {};
368
- }
369
- // Migrate an existing config to the rules / hookGuards / commands layout and add any missing rules.
370
- // Returns a human-readable list of what changed (empty = already up to date).
371
- function migrate(existing) {
372
- const changes = [];
373
- const rules = asSection(existing['rules']);
374
- const hookGuards = asSection(existing['hookGuards']);
375
- const commands = (typeof existing['commands'] === 'object' && existing['commands'] !== null)
376
- ? existing['commands'] : {};
377
- // Move a deprecated top-level pr-gate block under commands.
378
- if (existing['pr-gate'] !== undefined && commands['pr-gate'] === undefined) {
379
- commands['pr-gate'] = existing['pr-gate'];
380
- changes.push('moved top-level "pr-gate" → commands["pr-gate"]');
381
- }
382
- // Apply retired RENAMES first, so a renamed guard is placed and presence-checked under its new name
383
- // rather than being treated as unknown and re-added alongside its own stale entry.
384
- migrateRetiredRuleNames(rules, changes);
385
- migrateRetiredRuleNames(hookGuards, changes);
386
- // Move guards mistakenly left in rules into hookGuards.
387
- for (const name of Object.keys(rules)) {
388
- if ((0, rules_config_1.isHookGuard)(name)) {
389
- hookGuards[name] = rules[name];
390
- delete rules[name];
391
- changes.push(`moved "${name}" from rules → hookGuards`);
392
- }
393
- }
394
- // Move code rules mistakenly placed in hookGuards back into rules.
395
- for (const name of Object.keys(hookGuards)) {
396
- if (!(0, rules_config_1.isHookGuard)(name) && (0, rules_config_1.allRuleNames)().includes(name)) {
397
- rules[name] = hookGuards[name];
398
- delete hookGuards[name];
399
- changes.push(`moved "${name}" from hookGuards → rules`);
400
- }
401
- }
402
- // Add any missing built-in into its correct section, ENFORCING at its recommended mode (not OFF).
403
- for (const name of (0, rules_config_1.allRuleNames)()) {
404
- const target = (0, rules_config_1.sectionForRule)(name) === 'hookGuards' ? hookGuards : rules;
405
- if (!(name in target)) {
406
- const entry = seedRule(name);
407
- target[name] = entry;
408
- changes.push(`added "${name}" (${String(entry['mode'])}) to ${(0, rules_config_1.sectionForRule)(name)}`);
409
- }
410
- }
411
- // Fill command defaults.
412
- if (commands['pr-gate'] === undefined) {
413
- commands['pr-gate'] = { mode: 'OFF', buildCommand: rules_config_1.DEFAULT_BUILD_COMMAND, gates: [] };
414
- changes.push('added commands["pr-gate"] (OFF)');
415
- }
416
- migrateGuardHints(commands, changes);
417
- // Seed the now-required excludePaths list (empty = enforce everywhere) if the config predates it,
418
- // and MIGRATE the legacy `{ rules: [], guards: [] }` object to the single list by unioning them.
419
- // The union is behaviour-preserving for every config we have seen (both lists set identically), and
420
- // widening is the safe direction anyway: a path either side excluded stays excluded.
421
- const excludePaths = migrateExcludePaths(existing['excludePaths'], changes);
422
- // Seed the now-required match-rules array (with the default no-fetch guard) if the config predates
423
- // it. A client that has already customized it keeps their array untouched.
424
- let matchRules;
425
- if (Array.isArray(existing['match-rules'])) {
426
- matchRules = existing['match-rules'];
427
- }
428
- else {
429
- matchRules = seedMatchRules();
430
- changes.push('added "match-rules" (seeded with the no-fetch guard)');
431
- }
432
- const rulesDir = Array.isArray(existing['rulesDir']) ? existing['rulesDir'] : [];
433
- const config = { rules, hookGuards, commands, excludePaths, 'match-rules': matchRules, rulesDir };
434
- if (typeof existing['extends'] === 'string')
435
- config.extends = existing['extends'];
436
- return { config, changes };
437
- }
438
- // Seed the config when it is missing, migrate it when it is not. ONE behaviour, always — there is no
439
- // "migrate but stop here" mode any more. The flag that used to select it was never NECESSARY (the validator prints
440
- // the exact edit for every error at once, and editing webpieces.config.json is always allowed through the
441
- // guard — the documented primary cure), it REFUSED to act when the config was missing (useless in the one
442
- // case automation would have helped), and it gave deny messages a second competing path when they are
443
- // supposed to end in exactly one action. Readers also mistook it for the shim-repair command, which it
444
- // never was — `wp-upgrade-shim` is that.
445
- // webpieces-disable no-function-outside-class -- setup.ts is deliberately DI-free (it must run on a half-written node_modules; see install-entry.ts), so every function here is module-scope
446
- function seedOrSyncConfig(projectRoot) {
447
- const configPath = path.join(projectRoot, CONFIG_FILENAME);
448
- if (!fs.existsSync(configPath)) {
449
- writeConfig(configPath, buildSeedConfig());
450
- console.log(` [ai-hooks] Created ${CONFIG_FILENAME} (rules / hookGuards / commands); each rule seeded at its recommended mode — gradual where supported, so only code you change is enforced.`);
451
- console.log(' Enable the ones you want by changing "mode".');
452
- return;
453
- }
454
- const result = migrate(readConfig(configPath));
455
- if (result.changes.length === 0) {
456
- console.log(` [ai-hooks] ${CONFIG_FILENAME} already uses the rules / hookGuards / commands layout — no changes.`);
457
- return;
458
- }
459
- writeConfig(configPath, result.config);
460
- console.log(` [ai-hooks] Migrated ${CONFIG_FILENAME}:`);
461
- for (const change of result.changes)
462
- console.log(` - ${change}`);
156
+ function installTargets(projectRoot, homeDir = (0, os_1.homedir)()) {
157
+ return [
158
+ new InstallTarget('1', 'project (.claude/settings.json — committed, for the team)', path.join(projectRoot, '.claude', 'settings.json'), false, hook_registration_1.CLAUDE_REGISTRATION),
159
+ new InstallTarget('2', 'project for you (.claude/settings.local.json — personal)', path.join(projectRoot, '.claude', 'settings.local.json'), false, hook_registration_1.CLAUDE_REGISTRATION),
160
+ new InstallTarget('3', 'global (~/.claude/settings.json — exact path, this repo only)', path.join(homeDir, '.claude', 'settings.json'), true, hook_registration_1.CLAUDE_REGISTRATION),
161
+ // Given choice `1` so one answer arms the whole project — see the docblock. Its position in the
162
+ // array carries no meaning: every caller selects by choice id.
163
+ new InstallTarget('1', 'project, for Codex too (.codex/hooks.json — committed, for the team)', path.join(projectRoot, ...hook_registration_1.CODEX_REGISTRATION.settingsFiles[0].split('/')), false, hook_registration_1.CODEX_REGISTRATION),
164
+ ];
463
165
  }
464
166
  // ---------------------------------------------------------------------------
465
167
  // Claude Code settings.json hook wiring.
@@ -493,19 +195,26 @@ function removeHookByMarker(settings, marker) {
493
195
  // Apply the chosen install for one hook: remove it from every target file, then add it back to the
494
196
  // chosen one (or nowhere, for uninstall). Writes only the files that changed.
495
197
  function applyHook(hook, chosen, targets, projectRoot) {
496
- for (const target of targets) {
198
+ // SCOPED TO THE CHOSEN HARNESS, and this is what makes one call per target composable. Installing
199
+ // the Claude hook must not strip the Codex one, so "remove it from everywhere else" means everywhere
200
+ // else THIS HARNESS could live. Uninstall (chosen === null) is the one case that means every
201
+ // harness, because "not installed" has to be true everywhere or the hook is still armed somewhere.
202
+ const scope = chosen === null ? targets : targets.filter((t) => t.harness === chosen.harness);
203
+ for (const target of scope) {
497
204
  const settings = (0, hook_registration_1.readSettings)(target.settingsPath);
498
205
  const removed = removeHookByMarker(settings, hook.bin);
499
206
  const isChosen = chosen !== null && chosen.settingsPath === target.settingsPath;
500
207
  if (isChosen) {
501
- (0, hook_registration_1.addHookEntry)(settings, new hook_registration_1.HookRegistrationEntry(hook.matcher, hook.commandFor(target, projectRoot)));
208
+ (0, hook_registration_1.addHookEntry)(settings, new hook_registration_1.HookRegistrationEntry(hook.matcherFor(target), hook.commandFor(target, projectRoot)));
502
209
  // The managed `env` entry goes into the SAME file the hooks go into, on every path that
503
210
  // writes hooks — interactive or `--target=`. It pins the Bash cwd to the project root, so a
504
211
  // guard's answer depends on the command rather than on where an earlier `cd` left the shell,
505
212
  // and settings `env` is inherited, so every subagent gets the identical cwd and therefore the
506
213
  // identical guard verdict. (It no longer has a RESOLUTION job — the hooks are absolute.) See
507
214
  // hook-registration.ts for the full argument; `wp-upgrade-shim` self-heals it afterwards.
508
- if ((0, hook_registration_1.applyManagedEnv)(settings)) {
215
+ // …in the harness that HAS that surface. Codex has no settings `env`, and needs none: its
216
+ // cwd is MEASURED not to drift, which is the whole thing this entry buys under Claude Code.
217
+ if (target.harness.managesEnv && (0, hook_registration_1.applyManagedEnv)(settings)) {
509
218
  console.log(` ✅ env.${managed_env_1.BASH_CWD_ENV_KEY}=${managed_env_1.BASH_CWD_ENV_VALUE} → ${target.label} (pins the Bash cwd to the project root, for this session and every subagent)`);
510
219
  }
511
220
  (0, hook_registration_1.writeSettings)(target.settingsPath, settings);
@@ -531,6 +240,30 @@ function applyHook(hook, chosen, targets, projectRoot) {
531
240
  if (chosen === null)
532
241
  console.log(` ⛔ ${hook.label} not installed (removed from all locations).`);
533
242
  }
243
+ /**
244
+ * Say whether Codex will actually RUN what we just registered — the one thing the installer cannot do
245
+ * anything about and therefore must not leave silent.
246
+ *
247
+ * Codex trusts a hook entry TOFU and re-prompts whenever its bytes change, and the prompt's third option
248
+ * is "Continue without trusting (hooks won't run)". So a perfectly successful install can be followed by
249
+ * a fully unguarded session, and the only honest thing to print is what is true plus the one action a
250
+ * HUMAN has to take. Nothing here writes `~/.codex/config.toml` — see codex-trust.ts for why forging a
251
+ * `trusted_hash` is not on the table.
252
+ *
253
+ * Silent when this repo did not arm Codex, so a Claude-only install gains no noise.
254
+ */
255
+ // webpieces-disable no-function-outside-class -- setup.ts is deliberately DI-free (it must run on a half-written node_modules; see install-entry.ts), so every function here is module-scope
256
+ function reportCodexTrust(projectRoot, targets, choice) {
257
+ const armed = targets.some((t) => t.choice === choice && t.harness === hook_registration_1.CODEX_REGISTRATION);
258
+ if (!armed)
259
+ return;
260
+ const lines = new codex_trust_1.CodexTrustProbe().read(projectRoot).lines();
261
+ if (lines.length === 0)
262
+ return;
263
+ console.log('');
264
+ for (const line of lines)
265
+ console.log(line);
266
+ }
534
267
  function currentLocation(hook, targets) {
535
268
  const here = targets.filter((t) => hasHook((0, hook_registration_1.readSettings)(t.settingsPath), hook.bin));
536
269
  return here.length === 0 ? 'none' : here.map((t) => t.label.split(' (')[0]).join(', ');
@@ -560,16 +293,35 @@ function parseTargetArg(args) {
560
293
  const flag = args.find((a) => a.startsWith('--target='));
561
294
  return flag ? flag.slice('--target='.length) : null;
562
295
  }
296
+ /**
297
+ * Apply ONE choice for one hook: every target that answer selects, or uninstall when it selects none.
298
+ *
299
+ * The loop is what lets one choice arm several harnesses (choice `1` writes both the Claude settings
300
+ * file and the Codex hooks file — see installTargets). `applyHook` scopes its removals to the chosen
301
+ * target's harness, which is what keeps these calls from undoing one another.
302
+ */
303
+ // webpieces-disable no-function-outside-class -- setup.ts is deliberately DI-free (it must run on a half-written node_modules; see install-entry.ts), so every function here is module-scope
304
+ function applyChoice(hook, choice, targets, projectRoot) {
305
+ const chosen = targets.filter((t) => t.choice === choice);
306
+ if (chosen.length === 0) {
307
+ applyHook(hook, null, targets, projectRoot);
308
+ return;
309
+ }
310
+ for (const target of chosen)
311
+ applyHook(hook, target, targets, projectRoot);
312
+ }
563
313
  async function wireHook(hook, targets, projectRoot) {
564
314
  console.log('');
565
- console.log(`${hook.label} [matcher: ${hook.matcher}]`);
315
+ console.log(`${hook.label}`);
566
316
  console.log(` currently installed in: ${currentLocation(hook, targets)}`);
317
+ // The matcher is printed PER TARGET now, because it differs per harness — Codex's file tool is
318
+ // `apply_patch`, Claude's are Write|Edit|MultiEdit — and one matcher printed above the list would be
319
+ // wrong for whichever harness it did not describe.
567
320
  for (const target of targets)
568
- console.log(` ${target.choice}) ${target.label}`);
321
+ console.log(` ${target.choice}) ${target.label} [matcher: ${hook.matcherFor(target)}]`);
569
322
  console.log(' 4) none / uninstall');
570
323
  const answer = await prompt(' Where should it live? [1/2/3/4, default 4]: ');
571
- const chosen = targets.find((t) => t.choice === answer) ?? null;
572
- applyHook(hook, chosen, targets, projectRoot);
324
+ applyChoice(hook, answer, targets, projectRoot);
573
325
  }
574
326
  /**
575
327
  * Scaffold the SERVER-SIDE PR gate: the CI workflow plus the doc explaining how to turn it on.
@@ -601,7 +353,7 @@ async function main() {
601
353
  // Anchor the install at the repo root (git toplevel — webpieces.config.json may not exist yet on
602
354
  // a first install), never a subdir cwd, so `.webpieces`/hooks/config all land at the root.
603
355
  const projectRoot = new rules_config_1.RepoRootFinder().resolveRepoRoot(process.cwd());
604
- seedOrSyncConfig(projectRoot);
356
+ (0, setup_config_1.seedOrSyncConfig)(projectRoot);
605
357
  // Always refreshed: it explains why a retired key is rejected rather than accepted, and what to do
606
358
  // about it — which is exactly what an agent needs on the run where a migration just moved keys out
607
359
  // from under its config.
@@ -619,16 +371,19 @@ async function main() {
619
371
  process.exitCode = 1;
620
372
  return;
621
373
  }
622
- const chosen = targets.find((t) => t.choice === choice) ?? null;
623
- applyHook(exports.RULES_HOOK, chosen, targets, projectRoot);
624
- applyHook(exports.GUARDS_HOOK, chosen, targets, projectRoot);
374
+ applyChoice(exports.RULES_HOOK, choice, targets, projectRoot);
375
+ applyChoice(exports.GUARDS_HOOK, choice, targets, projectRoot);
625
376
  console.log(`\nDone. Both hooks set to: ${targetName}.`);
377
+ reportCodexTrust(projectRoot, targets, choice);
626
378
  return;
627
379
  }
628
380
  console.log('');
629
381
  console.log('Two webpieces hooks can be installed independently — choose a location for each:');
630
382
  await wireHook(exports.RULES_HOOK, targets, projectRoot);
631
383
  await wireHook(exports.GUARDS_HOOK, targets, projectRoot);
384
+ // Whichever choices were made above, report Codex trust for the project choice — an interactive run
385
+ // that armed Codex needs the same warning the --target path prints.
386
+ reportCodexTrust(projectRoot, targets, '1');
632
387
  console.log('');
633
388
  console.log('Done. Re-run `pnpm wp-install-ai-hooks` anytime to move or uninstall a hook.');
634
389
  console.log('(Non-interactive: pnpm wp-install-ai-hooks --target=project|project-personal|global|none)');