@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.
- package/package.json +2 -2
- package/src/adapters/detect-ai.d.ts +5 -4
- package/src/adapters/detect-ai.js +5 -4
- package/src/adapters/detect-ai.js.map +1 -1
- package/src/adapters/hook-core.js +9 -3
- package/src/adapters/hook-core.js.map +1 -1
- package/src/bin/codex-guard-presence.d.ts +86 -0
- package/src/bin/codex-guard-presence.js +150 -0
- package/src/bin/codex-guard-presence.js.map +1 -0
- package/src/bin/codex-trust.d.ts +99 -0
- package/src/bin/codex-trust.js +199 -0
- package/src/bin/codex-trust.js.map +1 -0
- package/src/bin/hook-registration.d.ts +200 -43
- package/src/bin/hook-registration.js +218 -89
- package/src/bin/hook-registration.js.map +1 -1
- package/src/bin/l0-allowlist.d.ts +0 -30
- package/src/bin/l0-allowlist.js +9 -67
- package/src/bin/l0-allowlist.js.map +1 -1
- package/src/bin/l0-decide.d.ts +43 -0
- package/src/bin/l0-decide.js +90 -0
- package/src/bin/l0-decide.js.map +1 -0
- package/src/bin/l0-ignored-tools.d.ts +38 -0
- package/src/bin/l0-ignored-tools.js +44 -0
- package/src/bin/l0-ignored-tools.js.map +1 -0
- package/src/bin/setup-config.d.ts +19 -0
- package/src/bin/setup-config.js +370 -0
- package/src/bin/setup-config.js.map +1 -0
- package/src/bin/setup.d.ts +56 -21
- package/src/bin/setup.js +125 -370
- package/src/bin/setup.js.map +1 -1
- package/src/bin/shim-audit-log.js +10 -1
- package/src/bin/shim-audit-log.js.map +1 -1
- package/src/bin/shim-deny-reason.js +4 -4
- package/src/bin/shim-deny-reason.js.map +1 -1
- package/src/bin/shim.d.ts +2 -0
- package/src/bin/shim.js +21 -1
- package/src/bin/shim.js.map +1 -1
- package/src/bin/upgrade-shim.d.ts +1 -1
- package/src/bin/upgrade-shim.js +12 -8
- package/src/bin/upgrade-shim.js.map +1 -1
- package/src/core/agent-event.d.ts +16 -0
- package/src/core/agent-event.js +17 -1
- package/src/core/agent-event.js.map +1 -1
- package/src/core/ai-type-context.d.ts +36 -0
- package/src/core/ai-type-context.js +45 -0
- package/src/core/ai-type-context.js.map +1 -0
- package/src/core/decision-log.js +9 -0
- package/src/core/decision-log.js.map +1 -1
- package/src/core/l0-matrix.js +14 -6
- package/src/core/l0-matrix.js.map +1 -1
- package/src/core/l0-tooling-doc.d.ts +3 -2
- package/src/core/l0-tooling-doc.js +22 -10
- package/src/core/l0-tooling-doc.js.map +1 -1
- package/src/core/rejection-log.js +5 -1
- package/src/core/rejection-log.js.map +1 -1
- package/src/index.d.ts +1 -1
- package/src/index.js +4 -1
- package/src/index.js.map +1 -1
- 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
|
|
22
|
-
// The
|
|
23
|
-
//
|
|
24
|
-
|
|
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,
|
|
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,
|
|
48
|
-
// (see
|
|
49
|
-
// which per the hooks reference would be exit 127, a SILENT UNGUARDED ALLOW
|
|
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
|
|
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
|
-
|
|
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
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
|
|
120
|
-
exports.
|
|
121
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
*
|
|
236
|
-
*
|
|
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
|
-
*
|
|
270
|
-
*
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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}
|
|
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
|
-
|
|
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
|
-
|
|
623
|
-
|
|
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)');
|