@dalzoubi/dev-agents-sync 2.2.1 → 2.2.3
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/README.md +15 -2
- package/package.json +1 -1
- package/src/commands/update.mjs +3 -1
- package/src/hooks.mjs +5 -1
- package/src/settings.mjs +69 -9
- package/tests/e2e/steering-hooks-flow.test.mjs +5 -5
- package/tests/hook-retirement.test.mjs +25 -11
- package/tests/hooks-cli-install.test.mjs +21 -21
- package/tests/hooks-command-form.test.mjs +435 -0
- package/tests/hooks-wave2-registration.test.mjs +46 -28
- package/tests/rules-injector-registration.test.mjs +17 -8
- package/tests/update-uptodate-wiring.test.mjs +9 -7
package/README.md
CHANGED
|
@@ -116,7 +116,7 @@ The CLI seeds a blank steering scaffold under `.claude/steering/` — `decisions
|
|
|
116
116
|
| Script | Event | Matcher | Mode | What it does |
|
|
117
117
|
|---|---|---|---|---|
|
|
118
118
|
| `steering-injector.sh` | `SessionStart` | `.*` | warning | Surfaces steering context at the start of a session. |
|
|
119
|
-
| `managed-file-guard.sh` | `PostToolUse` |
|
|
119
|
+
| `managed-file-guard.sh` | `PostToolUse` | `Edit\|Write` | warning | Guards managed files against unexpected edits. |
|
|
120
120
|
| `uncommitted-check.sh` | `Stop` | `.*` | warning | Flags uncommitted work when a session ends. |
|
|
121
121
|
| `force-push-guard.sh` | `PreToolUse` | `Bash` | **blocking** | Denies `git push` carrying `--force` / `-f` / `--force-with-lease` at `main` or `master` (by explicit refspec, or by the checked-out branch when the push names no ref). Force-pushing a feature branch, and a plain push to `main`, both pass. |
|
|
122
122
|
| `spec-approval-gate.sh` | `PreToolUse` | `Edit\|Write` | **blocking** | Denies an edit that sets a spec to `Status: Approved` while an unresolved clarification marker — a bare bracketed `NEEDS CLARIFICATION` token — remains in prose. Occurrences inside code spans or fenced blocks are documentation and never match. |
|
|
@@ -162,7 +162,20 @@ By default, `init` and `update` also register each installed hook in `.claude/se
|
|
|
162
162
|
- **Additive** — every existing setting and every consumer-authored hook is preserved untouched.
|
|
163
163
|
- **Idempotent** — running `update` twice produces no duplicate entries.
|
|
164
164
|
- **Tagged** — each injected entry is marked as CLI-managed so it can be identified and removed later without disturbing consumer-authored hooks.
|
|
165
|
-
- **Self-cleaning** — when the toolkit retires a hook, `update` also prunes the CLI-managed registration for it, so no consumer is left with an entry pointing at a script that no longer ships. Only tagged entries are pruned, and only the retired command within them: a consumer-authored entry for the same command is left untouched, and a command you added yourself inside a tagged entry survives alongside. The prune is reported on stderr (`info: dev-agents-sync: pruned N retired hook registration(s)`, counting registrations removed) and is computed — and surfaced in the `--dry-run` result — without writing anything. `rules-injector.sh` is the first retired hook — Claude Code now loads `.claude/rules/*.md` natively, so the hook is redundant.
|
|
165
|
+
- **Self-cleaning** — when the toolkit retires a hook, `update` also prunes the CLI-managed registration for it, so no consumer is left with an entry pointing at a script that no longer ships. Only tagged entries are pruned, and only the retired command within them: a consumer-authored entry for the same command is left untouched, and a command you added yourself inside a tagged entry survives alongside. The prune is reported on stderr (`info: dev-agents-sync: pruned N retired hook registration(s)`, counting registrations removed) and is computed — and surfaced in the `--dry-run` result — without writing anything. `rules-injector.sh` is the first retired hook — Claude Code now loads `.claude/rules/*.md` natively, so the hook is redundant. What retires is a *command string*, so this also covers a hook whose script still ships under a changed command form (see below).
|
|
166
|
+
|
|
167
|
+
Each registration invokes its script through an explicit interpreter:
|
|
168
|
+
|
|
169
|
+
```json
|
|
170
|
+
{
|
|
171
|
+
"matcher": "Edit|Write",
|
|
172
|
+
"hooks": [{ "type": "command", "command": "bash .claude/hooks/managed-file-guard.sh" }]
|
|
173
|
+
}
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Naming `bash` is what keeps this working on Windows. A bare `.claude/hooks/<name>.sh` command has no executable interpreter there, so the shell falls through to the `.sh` file association — which Git for Windows registers as `bash.exe --login -i "%1"`. That is an *interactive* login shell, and Windows gives it a visible console window, so a window popped up for every matched tool call. `bash` is the interpreter Claude Code already shells out to on Windows, and the hook scripts are POSIX-sh, so the same command form is correct on macOS and Linux.
|
|
177
|
+
|
|
178
|
+
> **One-time re-registration.** Hooks shipped with the bare-path command form up to and including CLI **v2.2.1**. The first `update` on a newer CLI de-registers those stale entries and adds the `bash …` form, so you will see a `pruned N retired hook registration(s)` line once, alongside the usual wiring line. Nothing is duplicated and the run converges: a second `update` leaves `settings.json` byte-identical. Untagged entries you wrote yourself are never touched — if you hand-registered a hook with a bare path, it stays as-is and is yours to update.
|
|
166
179
|
|
|
167
180
|
> **Carve-out.** `settings.json` is a consumer-owned configuration file, **not** a marker-managed file. The CLI never replaces it wholesale; it only merges in its own tagged hook registrations and otherwise leaves the file alone. If `settings.json` contains malformed JSON, `update` fails loud with the stable code `ESETTINGS_PARSE` and leaves the file byte-identical — it will never half-write or clobber an unparseable file.
|
|
168
181
|
|
package/package.json
CHANGED
package/src/commands/update.mjs
CHANGED
|
@@ -152,8 +152,10 @@ export async function runUpdate(consumerCwd, opts = {}) {
|
|
|
152
152
|
// idempotent, so running twice == once. --dry-run still writes nothing.
|
|
153
153
|
let prunedRetired = [];
|
|
154
154
|
if (claudeTargetActive && !noHooksWiring) {
|
|
155
|
+
// Existence is checked against `h.script` (the path on disk), not
|
|
156
|
+
// `h.command` (the `bash <path>` shell string this hook is registered as).
|
|
155
157
|
const hookSpec = DEFAULT_HOOK_SCRIPTS.filter((h) =>
|
|
156
|
-
existsSync(path.join(consumerCwd, h.
|
|
158
|
+
existsSync(path.join(consumerCwd, h.script)),
|
|
157
159
|
);
|
|
158
160
|
// Unconditional on hookSpec.length: the prune needs no spec, so a consumer
|
|
159
161
|
// with no hook scripts on disk still gets retired registrations removed.
|
package/src/hooks.mjs
CHANGED
|
@@ -47,10 +47,14 @@ export function collectHookPaths(relKeys) {
|
|
|
47
47
|
* Returns the settings.json hook registration spec restricted to the hook
|
|
48
48
|
* scripts that were actually installed (present in `relKeys`). Each registration
|
|
49
49
|
* is taken from DEFAULT_HOOK_SCRIPTS so the event/matcher stay authoritative.
|
|
50
|
+
*
|
|
51
|
+
* Matched on `h.script` — the path — because that is what `collectHookPaths`
|
|
52
|
+
* yields. `h.command` is a shell command string (`bash .claude/hooks/foo.sh`)
|
|
53
|
+
* and never equals a FileMap-derived path.
|
|
50
54
|
*/
|
|
51
55
|
export function hookScriptsForInstalled(relKeys) {
|
|
52
56
|
const installed = new Set(collectHookPaths(relKeys));
|
|
53
|
-
return DEFAULT_HOOK_SCRIPTS.filter((h) => installed.has(h.
|
|
57
|
+
return DEFAULT_HOOK_SCRIPTS.filter((h) => installed.has(h.script));
|
|
54
58
|
}
|
|
55
59
|
|
|
56
60
|
/**
|
package/src/settings.mjs
CHANGED
|
@@ -391,10 +391,42 @@ export function removeHookSettings(consumerCwd, opts = {}) {
|
|
|
391
391
|
return { removed: true, dryRun: false, settingsPath: absPath };
|
|
392
392
|
}
|
|
393
393
|
|
|
394
|
+
/**
|
|
395
|
+
* The settings.json command string that runs a hook script at `script`.
|
|
396
|
+
*
|
|
397
|
+
* The interpreter prefix is load-bearing on Windows and is why `script` and
|
|
398
|
+
* `command` are separate fields at all. A bare `.claude/hooks/foo.sh` command
|
|
399
|
+
* has no executable interpreter on Windows, so the shell falls through to the
|
|
400
|
+
* `.sh` FILE ASSOCIATION, which Git for Windows registers as
|
|
401
|
+
* `bash.exe --login -i "%1"` — an INTERACTIVE login shell, which Windows gives
|
|
402
|
+
* a visible console window. The result was a terminal window popping up for
|
|
403
|
+
* every matched tool call. Naming `bash` explicitly means the association is
|
|
404
|
+
* never consulted, on any platform.
|
|
405
|
+
*
|
|
406
|
+
* `bash` rather than `sh`: it is the interpreter Claude Code itself shells out
|
|
407
|
+
* to on Windows (Git Bash), so it is on PATH exactly where the association
|
|
408
|
+
* fallback would otherwise fire. The hook scripts are POSIX-sh and run fine
|
|
409
|
+
* under it.
|
|
410
|
+
*/
|
|
411
|
+
export function hookCommandFor(script) {
|
|
412
|
+
return `bash ${script}`;
|
|
413
|
+
}
|
|
414
|
+
|
|
394
415
|
/**
|
|
395
416
|
* The default hook registration spec — maps each installed hook script to its
|
|
396
417
|
* Claude Code event. Kept here so init/update share one source of truth.
|
|
397
418
|
*
|
|
419
|
+
* Each entry carries both halves of that mapping, deliberately separated:
|
|
420
|
+
* - `script` — the consumer-relative PATH on disk (`.claude/hooks/foo.sh`).
|
|
421
|
+
* This is the filesystem identity: what `init`/`update` install, what the
|
|
422
|
+
* lockfile tracks, and what an existence check joins against a repo root.
|
|
423
|
+
* - `command` — the settings.json COMMAND STRING that invokes it, derived
|
|
424
|
+
* from `script` by `hookCommandFor`. This is the settings identity: what
|
|
425
|
+
* the merge dedupes on and what retirement matches.
|
|
426
|
+
* They were one field until the command grew an interpreter prefix; collapsing
|
|
427
|
+
* them again would either resurrect the Windows console-window bug or make the
|
|
428
|
+
* CLI join a shell command onto a path.
|
|
429
|
+
*
|
|
398
430
|
* The matcher is the tool-name filter Claude Code applies before running the
|
|
399
431
|
* hook. Session-scoped events (`SessionStart`, `Stop`) take `.*`; tool-scoped
|
|
400
432
|
* events name the tools they inspect, so a hook that only ever reads a Bash
|
|
@@ -405,18 +437,21 @@ export function removeHookSettings(consumerCwd, opts = {}) {
|
|
|
405
437
|
* independent registrations keyed by command.
|
|
406
438
|
*/
|
|
407
439
|
export const DEFAULT_HOOK_SCRIPTS = [
|
|
408
|
-
{ event: 'SessionStart', matcher: '.*',
|
|
409
|
-
|
|
410
|
-
|
|
440
|
+
{ event: 'SessionStart', matcher: '.*', script: '.claude/hooks/steering-injector.sh' },
|
|
441
|
+
// Edit|Write, not `.*`: this hook reads a written file path out of the hook
|
|
442
|
+
// payload and exits 0 silently when there is none, so on `.*` it was spawned
|
|
443
|
+
// by every tool call — every Read, every Bash — to do nothing.
|
|
444
|
+
{ event: 'PostToolUse', matcher: 'Edit|Write', script: '.claude/hooks/managed-file-guard.sh' },
|
|
445
|
+
{ event: 'Stop', matcher: '.*', script: '.claude/hooks/uncommitted-check.sh' },
|
|
411
446
|
// Added by docs/specs/260805-hooks-wave-2/ — the toolkit's first blocking
|
|
412
447
|
// hooks plus two warning hooks. Blockers deny only on a positive violation
|
|
413
448
|
// match, fail open otherwise, and honor DEV_AGENTS_HOOKS_BYPASS.
|
|
414
|
-
{ event: 'PreToolUse', matcher: 'Bash',
|
|
415
|
-
{ event: 'PreToolUse', matcher: 'Edit|Write',
|
|
416
|
-
{ event: 'PreToolUse', matcher: 'Write',
|
|
417
|
-
{ event: 'PostToolUse', matcher: 'Edit|Write',
|
|
418
|
-
{ event: 'PreToolUse', matcher: 'Bash',
|
|
419
|
-
];
|
|
449
|
+
{ event: 'PreToolUse', matcher: 'Bash', script: '.claude/hooks/force-push-guard.sh' },
|
|
450
|
+
{ event: 'PreToolUse', matcher: 'Edit|Write', script: '.claude/hooks/spec-approval-gate.sh' },
|
|
451
|
+
{ event: 'PreToolUse', matcher: 'Write', script: '.claude/hooks/handoff-state-guard.sh' },
|
|
452
|
+
{ event: 'PostToolUse', matcher: 'Edit|Write', script: '.claude/hooks/spec-drift-warn.sh' },
|
|
453
|
+
{ event: 'PreToolUse', matcher: 'Bash', script: '.claude/hooks/pre-pr-preflight.sh' },
|
|
454
|
+
].map((h) => ({ ...h, command: hookCommandFor(h.script) }));
|
|
420
455
|
|
|
421
456
|
/**
|
|
422
457
|
* Hook registrations the toolkit once shipped and has since retired.
|
|
@@ -434,10 +469,35 @@ export const DEFAULT_HOOK_SCRIPTS = [
|
|
|
434
469
|
* an older CLI reading newer content simply does not prune rather than erroring.
|
|
435
470
|
* Reversing a retirement is done by moving the entry back to
|
|
436
471
|
* DEFAULT_HOOK_SCRIPTS, not by silently dropping it from here.
|
|
472
|
+
*
|
|
473
|
+
* What retires is a COMMAND STRING, not necessarily a script. A hook whose
|
|
474
|
+
* script still ships but whose command form changed retires the old form here
|
|
475
|
+
* and registers the new one in DEFAULT_HOOK_SCRIPTS; merge-then-prune runs the
|
|
476
|
+
* additive pass first, so the consumer ends a single `update` with exactly one
|
|
477
|
+
* registration — the new one. Without the retirement the two forms would both
|
|
478
|
+
* be live and the hook would run twice per matched call.
|
|
479
|
+
*
|
|
480
|
+
* `event`/`matcher` are recorded for the reader; the prune matches on `command`
|
|
481
|
+
* alone and scans every event bucket, so a stale registration is removed
|
|
482
|
+
* wherever a past CLI (or a hand-edit) put it.
|
|
437
483
|
*/
|
|
438
484
|
export const RETIRED_HOOK_SCRIPTS = [
|
|
439
485
|
// Retired by docs/specs/260724-opus5-native-overlap/ (S1): Claude Code loads
|
|
440
486
|
// `.claude/rules/*.md` natively as project instructions, which is strictly
|
|
441
487
|
// broader than this hook's fixed injection list.
|
|
442
488
|
{ event: 'SessionStart', matcher: '.*', command: '.claude/hooks/rules-injector.sh' },
|
|
489
|
+
// The bare-path command form every hook shipped with before `hookCommandFor`
|
|
490
|
+
// added the `bash` prefix. Every one of these scripts still ships; only the
|
|
491
|
+
// command form is retired, so each is registered above in its `bash …` form
|
|
492
|
+
// and the stale bare-path registration is pruned on the next `update`. This
|
|
493
|
+
// is what makes the Windows fix reach existing consumers: leaving the old
|
|
494
|
+
// registration in place would keep spawning the console window it fixes.
|
|
495
|
+
{ event: 'SessionStart', matcher: '.*', command: '.claude/hooks/steering-injector.sh' },
|
|
496
|
+
{ event: 'PostToolUse', matcher: '.*', command: '.claude/hooks/managed-file-guard.sh' },
|
|
497
|
+
{ event: 'Stop', matcher: '.*', command: '.claude/hooks/uncommitted-check.sh' },
|
|
498
|
+
{ event: 'PreToolUse', matcher: 'Bash', command: '.claude/hooks/force-push-guard.sh' },
|
|
499
|
+
{ event: 'PreToolUse', matcher: 'Edit|Write', command: '.claude/hooks/spec-approval-gate.sh' },
|
|
500
|
+
{ event: 'PreToolUse', matcher: 'Write', command: '.claude/hooks/handoff-state-guard.sh' },
|
|
501
|
+
{ event: 'PostToolUse', matcher: 'Edit|Write', command: '.claude/hooks/spec-drift-warn.sh' },
|
|
502
|
+
{ event: 'PreToolUse', matcher: 'Bash', command: '.claude/hooks/pre-pr-preflight.sh' },
|
|
443
503
|
];
|
|
@@ -89,7 +89,7 @@ const STEERING_FILES = ['decisions.md', 'glossary.md', 'architecture.md'];
|
|
|
89
89
|
* the 260805 wave added five hooks that a pinned list of three would not have
|
|
90
90
|
* covered at all. Deriving keeps the flow honest for every future wave.
|
|
91
91
|
*/
|
|
92
|
-
const HOOK_FILES = DEFAULT_HOOK_SCRIPTS.map((h) => path.basename(h.
|
|
92
|
+
const HOOK_FILES = DEFAULT_HOOK_SCRIPTS.map((h) => path.basename(h.script));
|
|
93
93
|
|
|
94
94
|
/**
|
|
95
95
|
* Events paired with the hook script that handles each one. Used to assert
|
|
@@ -98,7 +98,7 @@ const HOOK_FILES = DEFAULT_HOOK_SCRIPTS.map((h) => path.basename(h.command));
|
|
|
98
98
|
*/
|
|
99
99
|
const HOOK_EVENT_PAIRS = DEFAULT_HOOK_SCRIPTS.map((h) => ({
|
|
100
100
|
event: h.event,
|
|
101
|
-
script: path.basename(h.
|
|
101
|
+
script: path.basename(h.script),
|
|
102
102
|
}));
|
|
103
103
|
|
|
104
104
|
const AVAILABLE_TAGS = ['v1.0.0', 'v1.1.0', 'v1.2.0', 'v2.0.0'];
|
|
@@ -139,10 +139,10 @@ function buildFixtureFileMap(targets = ['claude']) {
|
|
|
139
139
|
// registration. They carry the shell-comment marker (POSIX-sh, not HTML).
|
|
140
140
|
// Bodies are stand-ins: this flow proves install + wiring + drift, never a
|
|
141
141
|
// hook's own behavior (that lives in the content repo's tests/hooks-*.mjs).
|
|
142
|
-
for (const { event,
|
|
143
|
-
const key =
|
|
142
|
+
for (const { event, script } of DEFAULT_HOOK_SCRIPTS) {
|
|
143
|
+
const key = script.replace(/^\.claude\//, 'claude/');
|
|
144
144
|
map[key] =
|
|
145
|
-
`#!/bin/sh\n# managed-by: dev-agents-sync v2.0.16\n# ${event}: fixture stand-in for ${path.basename(
|
|
145
|
+
`#!/bin/sh\n# managed-by: dev-agents-sync v2.0.16\n# ${event}: fixture stand-in for ${path.basename(script)}.\nexit 0\n`;
|
|
146
146
|
}
|
|
147
147
|
|
|
148
148
|
return map;
|
|
@@ -94,6 +94,20 @@ async function importSettings() {
|
|
|
94
94
|
|
|
95
95
|
const RETIRED_COMMAND = '.claude/hooks/rules-injector.sh';
|
|
96
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Command strings for the three hooks that SURVIVE the rules-injector prune.
|
|
99
|
+
*
|
|
100
|
+
* The `bash ` prefix is the current registration form (see `hookCommandFor`).
|
|
101
|
+
* It matters here because the bare-path form these same scripts once shipped
|
|
102
|
+
* with is itself on RETIRED_HOOK_SCRIPTS: seeding a survivor in the old form
|
|
103
|
+
* would make the prune legitimately remove it, and these tests would then be
|
|
104
|
+
* asserting the wrong thing about a mechanism that works. A survivor must be
|
|
105
|
+
* spelled the way the CLI registers it today.
|
|
106
|
+
*/
|
|
107
|
+
const SURVIVOR_STEERING = 'bash .claude/hooks/steering-injector.sh';
|
|
108
|
+
const SURVIVOR_GUARD = 'bash .claude/hooks/managed-file-guard.sh';
|
|
109
|
+
const SURVIVOR_UNCOMMITTED = 'bash .claude/hooks/uncommitted-check.sh';
|
|
110
|
+
|
|
97
111
|
/**
|
|
98
112
|
* The intended POST-S1 caller-supplied hookScripts list — i.e. what a fixed
|
|
99
113
|
* DEFAULT_HOOK_SCRIPTS should look like once rules-injector.sh is removed
|
|
@@ -103,9 +117,9 @@ const RETIRED_COMMAND = '.claude/hooks/rules-injector.sh';
|
|
|
103
117
|
* edit to DEFAULT_HOOK_SCRIPTS itself.
|
|
104
118
|
*/
|
|
105
119
|
const FUTURE_HOOK_SCRIPTS = [
|
|
106
|
-
{ event: 'SessionStart', matcher: '.*', command:
|
|
107
|
-
{ event: 'PostToolUse', matcher: '
|
|
108
|
-
{ event: 'Stop', matcher: '.*', command:
|
|
120
|
+
{ event: 'SessionStart', matcher: '.*', command: SURVIVOR_STEERING },
|
|
121
|
+
{ event: 'PostToolUse', matcher: 'Edit|Write', command: SURVIVOR_GUARD },
|
|
122
|
+
{ event: 'Stop', matcher: '.*', command: SURVIVOR_UNCOMMITTED },
|
|
109
123
|
];
|
|
110
124
|
|
|
111
125
|
function makeTmpDir() {
|
|
@@ -313,9 +327,9 @@ describe('S1 P3 — no retired entry present means no change', () => {
|
|
|
313
327
|
// add, and the prune pass has nothing to remove.
|
|
314
328
|
writeSettings(consumerDir, {
|
|
315
329
|
hooks: {
|
|
316
|
-
SessionStart: [taggedEntry('.*',
|
|
317
|
-
PostToolUse: [taggedEntry('
|
|
318
|
-
Stop: [taggedEntry('.*',
|
|
330
|
+
SessionStart: [taggedEntry('.*', SURVIVOR_STEERING)],
|
|
331
|
+
PostToolUse: [taggedEntry('Edit|Write', SURVIVOR_GUARD)],
|
|
332
|
+
Stop: [taggedEntry('.*', SURVIVOR_UNCOMMITTED)],
|
|
319
333
|
},
|
|
320
334
|
});
|
|
321
335
|
const before = readSettingsRaw(consumerDir);
|
|
@@ -508,7 +522,7 @@ describe('S1 H1 — prune is hook-granular, not entry-granular (a consumer comma
|
|
|
508
522
|
const steeringRegistered = sessionStart.some(
|
|
509
523
|
(e) =>
|
|
510
524
|
Array.isArray(e.hooks) &&
|
|
511
|
-
e.hooks.some((h) => h.command ===
|
|
525
|
+
e.hooks.some((h) => h.command === SURVIVOR_STEERING),
|
|
512
526
|
);
|
|
513
527
|
assert.ok(
|
|
514
528
|
steeringRegistered,
|
|
@@ -525,7 +539,7 @@ describe('S1 H1 — prune is hook-granular, not entry-granular (a consumer comma
|
|
|
525
539
|
// surviving managed hook vanishes with nothing left to re-register it —
|
|
526
540
|
// an un-registration deliver-validate flagged as a real regression risk,
|
|
527
541
|
// not just a data-loss risk.
|
|
528
|
-
const SURVIVING_COMMAND =
|
|
542
|
+
const SURVIVING_COMMAND = SURVIVOR_STEERING;
|
|
529
543
|
const mixedEntry = {
|
|
530
544
|
[HOOK_TAG]: true,
|
|
531
545
|
matcher: '.*',
|
|
@@ -644,9 +658,9 @@ describe('S1 P6 — the other three managed hooks survive the prune unchanged',
|
|
|
644
658
|
});
|
|
645
659
|
|
|
646
660
|
it('P6: steering-injector.sh, managed-file-guard.sh, and uncommitted-check.sh entries are byte-identical after the prune', async () => {
|
|
647
|
-
const steering = taggedEntry('.*',
|
|
648
|
-
const guard = taggedEntry('
|
|
649
|
-
const uncommitted = taggedEntry('.*',
|
|
661
|
+
const steering = taggedEntry('.*', SURVIVOR_STEERING);
|
|
662
|
+
const guard = taggedEntry('Edit|Write', SURVIVOR_GUARD);
|
|
663
|
+
const uncommitted = taggedEntry('.*', SURVIVOR_UNCOMMITTED);
|
|
650
664
|
const retired = taggedEntry('.*', RETIRED_COMMAND);
|
|
651
665
|
|
|
652
666
|
writeSettings(consumerDir, {
|
|
@@ -39,9 +39,9 @@
|
|
|
39
39
|
* - hookScripts: { event: string, matcher: string, command: string }[]
|
|
40
40
|
* Each element describes one hook to wire in settings.json.
|
|
41
41
|
* Example: [
|
|
42
|
-
* { event: 'SessionStart', matcher: '.*', command: '.claude/hooks/steering-injector.sh' },
|
|
43
|
-
* { event: 'PostToolUse', matcher: '.*', command: '.claude/hooks/managed-file-guard.sh' },
|
|
44
|
-
* { event: 'Stop', matcher: '.*', command: '.claude/hooks/uncommitted-check.sh' },
|
|
42
|
+
* { event: 'SessionStart', matcher: '.*', command: 'bash .claude/hooks/steering-injector.sh' },
|
|
43
|
+
* { event: 'PostToolUse', matcher: '.*', command: 'bash .claude/hooks/managed-file-guard.sh' },
|
|
44
|
+
* { event: 'Stop', matcher: '.*', command: 'bash .claude/hooks/uncommitted-check.sh' },
|
|
45
45
|
* ]
|
|
46
46
|
* - opts.dryRun: boolean — when true, computes plan but writes nothing
|
|
47
47
|
* Returns: { merged: boolean, dryRun: boolean } (or equivalent plan object in dry-run)
|
|
@@ -665,9 +665,9 @@ describe('S4 H6 — settings merge is additive and idempotent', () => {
|
|
|
665
665
|
await mergeHookSettings(
|
|
666
666
|
consumerDir,
|
|
667
667
|
[
|
|
668
|
-
{ event: 'SessionStart', matcher: '.*', command: '.claude/hooks/steering-injector.sh' },
|
|
669
|
-
{ event: 'PostToolUse', matcher: '.*', command: '.claude/hooks/managed-file-guard.sh' },
|
|
670
|
-
{ event: 'Stop', matcher: '.*', command: '.claude/hooks/uncommitted-check.sh' },
|
|
668
|
+
{ event: 'SessionStart', matcher: '.*', command: 'bash .claude/hooks/steering-injector.sh' },
|
|
669
|
+
{ event: 'PostToolUse', matcher: '.*', command: 'bash .claude/hooks/managed-file-guard.sh' },
|
|
670
|
+
{ event: 'Stop', matcher: '.*', command: 'bash .claude/hooks/uncommitted-check.sh' },
|
|
671
671
|
],
|
|
672
672
|
);
|
|
673
673
|
|
|
@@ -726,9 +726,9 @@ describe('S4 H6 — settings merge is additive and idempotent', () => {
|
|
|
726
726
|
await mergeHookSettings(
|
|
727
727
|
consumerDir,
|
|
728
728
|
[
|
|
729
|
-
{ event: 'SessionStart', matcher: '.*', command: '.claude/hooks/steering-injector.sh' },
|
|
730
|
-
{ event: 'PostToolUse', matcher: '.*', command: '.claude/hooks/managed-file-guard.sh' },
|
|
731
|
-
{ event: 'Stop', matcher: '.*', command: '.claude/hooks/uncommitted-check.sh' },
|
|
729
|
+
{ event: 'SessionStart', matcher: '.*', command: 'bash .claude/hooks/steering-injector.sh' },
|
|
730
|
+
{ event: 'PostToolUse', matcher: '.*', command: 'bash .claude/hooks/managed-file-guard.sh' },
|
|
731
|
+
{ event: 'Stop', matcher: '.*', command: 'bash .claude/hooks/uncommitted-check.sh' },
|
|
732
732
|
],
|
|
733
733
|
);
|
|
734
734
|
|
|
@@ -762,9 +762,9 @@ describe('S4 H6 — settings merge is additive and idempotent', () => {
|
|
|
762
762
|
writeFileSync(settingsPath, JSON.stringify({}, null, 2), 'utf8');
|
|
763
763
|
|
|
764
764
|
const hookSpec = [
|
|
765
|
-
{ event: 'SessionStart', matcher: '.*', command: '.claude/hooks/steering-injector.sh' },
|
|
766
|
-
{ event: 'PostToolUse', matcher: '.*', command: '.claude/hooks/managed-file-guard.sh' },
|
|
767
|
-
{ event: 'Stop', matcher: '.*', command: '.claude/hooks/uncommitted-check.sh' },
|
|
765
|
+
{ event: 'SessionStart', matcher: '.*', command: 'bash .claude/hooks/steering-injector.sh' },
|
|
766
|
+
{ event: 'PostToolUse', matcher: '.*', command: 'bash .claude/hooks/managed-file-guard.sh' },
|
|
767
|
+
{ event: 'Stop', matcher: '.*', command: 'bash .claude/hooks/uncommitted-check.sh' },
|
|
768
768
|
];
|
|
769
769
|
|
|
770
770
|
await mergeHookSettings(consumerDir, hookSpec);
|
|
@@ -804,7 +804,7 @@ describe('S4 H6 — settings merge is additive and idempotent', () => {
|
|
|
804
804
|
await mergeHookSettings(
|
|
805
805
|
consumerDir,
|
|
806
806
|
[
|
|
807
|
-
{ event: 'SessionStart', matcher: '.*', command: '.claude/hooks/steering-injector.sh' },
|
|
807
|
+
{ event: 'SessionStart', matcher: '.*', command: 'bash .claude/hooks/steering-injector.sh' },
|
|
808
808
|
],
|
|
809
809
|
);
|
|
810
810
|
|
|
@@ -837,7 +837,7 @@ describe('S4 H7 — injected hook entries are tagged (identifiable and removable
|
|
|
837
837
|
writeFileSync(settingsPath, JSON.stringify({}, null, 2), 'utf8');
|
|
838
838
|
|
|
839
839
|
await mergeHookSettings(consumerDir, [
|
|
840
|
-
{ event: 'SessionStart', matcher: '.*', command: '.claude/hooks/steering-injector.sh' },
|
|
840
|
+
{ event: 'SessionStart', matcher: '.*', command: 'bash .claude/hooks/steering-injector.sh' },
|
|
841
841
|
]);
|
|
842
842
|
|
|
843
843
|
const raw = readFileSync(settingsPath, 'utf8');
|
|
@@ -875,7 +875,7 @@ describe('S4 H7 — injected hook entries are tagged (identifiable and removable
|
|
|
875
875
|
);
|
|
876
876
|
|
|
877
877
|
await mergeHookSettings(consumerDir, [
|
|
878
|
-
{ event: 'SessionStart', matcher: '.*', command: '.claude/hooks/steering-injector.sh' },
|
|
878
|
+
{ event: 'SessionStart', matcher: '.*', command: 'bash .claude/hooks/steering-injector.sh' },
|
|
879
879
|
]);
|
|
880
880
|
|
|
881
881
|
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
@@ -1174,7 +1174,7 @@ describe('S4 H10 — malformed settings.json triggers ESETTINGS_PARSE (no write)
|
|
|
1174
1174
|
let thrownError = null;
|
|
1175
1175
|
try {
|
|
1176
1176
|
await mergeHookSettings(consumerDir, [
|
|
1177
|
-
{ event: 'SessionStart', matcher: '.*', command: '.claude/hooks/steering-injector.sh' },
|
|
1177
|
+
{ event: 'SessionStart', matcher: '.*', command: 'bash .claude/hooks/steering-injector.sh' },
|
|
1178
1178
|
]);
|
|
1179
1179
|
} catch (err) {
|
|
1180
1180
|
thrownError = err;
|
|
@@ -1197,7 +1197,7 @@ describe('S4 H10 — malformed settings.json triggers ESETTINGS_PARSE (no write)
|
|
|
1197
1197
|
|
|
1198
1198
|
try {
|
|
1199
1199
|
await mergeHookSettings(consumerDir, [
|
|
1200
|
-
{ event: 'SessionStart', matcher: '.*', command: '.claude/hooks/steering-injector.sh' },
|
|
1200
|
+
{ event: 'SessionStart', matcher: '.*', command: 'bash .claude/hooks/steering-injector.sh' },
|
|
1201
1201
|
]);
|
|
1202
1202
|
} catch {
|
|
1203
1203
|
// expected
|
|
@@ -1377,7 +1377,7 @@ describe('S4 L5 — wrong-shape hooks field fails loud (ESETTINGS_PARSE, no writ
|
|
|
1377
1377
|
});
|
|
1378
1378
|
|
|
1379
1379
|
const HOOK_SPEC = [
|
|
1380
|
-
{ event: 'SessionStart', matcher: '.*', command: '.claude/hooks/steering-injector.sh' },
|
|
1380
|
+
{ event: 'SessionStart', matcher: '.*', command: 'bash .claude/hooks/steering-injector.sh' },
|
|
1381
1381
|
];
|
|
1382
1382
|
|
|
1383
1383
|
// Each case: a syntactically valid JSON object whose `hooks` sub-tree is the
|
|
@@ -1495,9 +1495,9 @@ describe('S4 L4 — consumer hook on a managed event survives merge then remove'
|
|
|
1495
1495
|
|
|
1496
1496
|
// Merge the managed registrations (steering-injector is also SessionStart).
|
|
1497
1497
|
await mergeHookSettings(consumerDir, [
|
|
1498
|
-
{ event: 'SessionStart', matcher: '.*', command: '.claude/hooks/steering-injector.sh' },
|
|
1499
|
-
{ event: 'PostToolUse', matcher: '.*', command: '.claude/hooks/managed-file-guard.sh' },
|
|
1500
|
-
{ event: 'Stop', matcher: '.*', command: '.claude/hooks/uncommitted-check.sh' },
|
|
1498
|
+
{ event: 'SessionStart', matcher: '.*', command: 'bash .claude/hooks/steering-injector.sh' },
|
|
1499
|
+
{ event: 'PostToolUse', matcher: '.*', command: 'bash .claude/hooks/managed-file-guard.sh' },
|
|
1500
|
+
{ event: 'Stop', matcher: '.*', command: 'bash .claude/hooks/uncommitted-check.sh' },
|
|
1501
1501
|
]);
|
|
1502
1502
|
|
|
1503
1503
|
// After merge: SessionStart has BOTH the consumer entry and the managed one.
|
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tests/hooks-command-form.test.mjs
|
|
3
|
+
*
|
|
4
|
+
* The settings.json COMMAND FORM of a hook registration, and the migration off
|
|
5
|
+
* the form the toolkit shipped before it.
|
|
6
|
+
*
|
|
7
|
+
* Why this file exists: every hook was registered as a bare relative path
|
|
8
|
+
* (`.claude/hooks/managed-file-guard.sh`). On Linux/macOS the shebang makes
|
|
9
|
+
* that executable, so nothing in the suite noticed that Windows has no shebang
|
|
10
|
+
* handling — there, the bare `.sh` resolves through the `.sh` FILE ASSOCIATION,
|
|
11
|
+
* which Git for Windows registers as `bash.exe --login -i "%1"`. That `-i` is
|
|
12
|
+
* an interactive shell, so Windows gives it a visible console window: one
|
|
13
|
+
* window per matched tool call, for the life of the session. Naming the
|
|
14
|
+
* interpreter in the command keeps the association out of it.
|
|
15
|
+
*
|
|
16
|
+
* The regression is invisible on the platforms CI runs on, which is exactly why
|
|
17
|
+
* the contract is asserted on the table rather than on observed behavior.
|
|
18
|
+
*
|
|
19
|
+
* Behavior anchors:
|
|
20
|
+
*
|
|
21
|
+
* C1. No registered command is a bare `.sh` path; each names an interpreter
|
|
22
|
+
* and points at its `script`.
|
|
23
|
+
* C2. `script` and `command` stay consistent: one row per shipped script, and
|
|
24
|
+
* the command is derived from the path rather than typed twice.
|
|
25
|
+
* C3. The pre-fix bare-path form of every shipped script is retired, so a
|
|
26
|
+
* consumer's stale registration is de-registered rather than left live
|
|
27
|
+
* alongside the new one.
|
|
28
|
+
* C4. End to end: a consumer whose settings.json carries the old registrations
|
|
29
|
+
* ends a single `update` with exactly one registration per hook, in the new
|
|
30
|
+
* form. This is the anchor that would fail if C3 were dropped — the hook
|
|
31
|
+
* would be registered twice and the console window would survive the fix.
|
|
32
|
+
* C5. `managed-file-guard` is registered on `Edit|Write`, not `.*`. It reads a
|
|
33
|
+
* written file path out of the payload and exits 0 when there is none, so
|
|
34
|
+
* on `.*` every unrelated tool call spawned it to do nothing.
|
|
35
|
+
*
|
|
36
|
+
* Test runner: `node --test packages/dev-agents-sync/tests/hooks-command-form.test.mjs`
|
|
37
|
+
* i18n/accessibility: N/A — CLI-only, no UI surface and no translated strings.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import { describe, it, beforeEach, afterEach } from 'node:test';
|
|
41
|
+
import assert from 'node:assert/strict';
|
|
42
|
+
import {
|
|
43
|
+
mkdtempSync,
|
|
44
|
+
mkdirSync,
|
|
45
|
+
writeFileSync,
|
|
46
|
+
readFileSync,
|
|
47
|
+
existsSync,
|
|
48
|
+
rmSync,
|
|
49
|
+
readdirSync,
|
|
50
|
+
} from 'node:fs';
|
|
51
|
+
import { tmpdir } from 'node:os';
|
|
52
|
+
import path from 'node:path';
|
|
53
|
+
import { fileURLToPath } from 'node:url';
|
|
54
|
+
|
|
55
|
+
import { runUpdate } from '../src/commands/update.mjs';
|
|
56
|
+
import { DEFAULT_HOOK_SCRIPTS, RETIRED_HOOK_SCRIPTS, HOOK_TAG } from '../src/settings.mjs';
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// Paths / fixtures
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
63
|
+
const FIXTURE_DIR = path.join(__dirname, 'fixtures', 'release-v1.0.0');
|
|
64
|
+
const AVAILABLE_TAGS = ['v1.0.0', 'v1.1.0', 'v1.2.0', 'v2.0.0'];
|
|
65
|
+
const LOCKFILE_NAME = '.dev-agents-sync.json';
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The expected command form, spelled out rather than imported from
|
|
69
|
+
* `hookCommandFor`. Importing the producer would assert it equals itself; the
|
|
70
|
+
* literal prefix IS the contract, since it is what keeps the Windows `.sh`
|
|
71
|
+
* association — and its interactive console window — out of the path.
|
|
72
|
+
*/
|
|
73
|
+
const INTERPRETER = 'bash';
|
|
74
|
+
|
|
75
|
+
function collectFiles(baseDir, currentDir, map, prefix) {
|
|
76
|
+
for (const entry of readdirSync(currentDir, { withFileTypes: true })) {
|
|
77
|
+
const full = path.join(currentDir, entry.name);
|
|
78
|
+
if (entry.isDirectory()) {
|
|
79
|
+
collectFiles(baseDir, full, map, prefix);
|
|
80
|
+
} else {
|
|
81
|
+
const rel = path.relative(baseDir, full).replace(/\\/g, '/');
|
|
82
|
+
map[`${prefix}/${rel}`] = readFileSync(full, 'utf8');
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** A fetched FileMap carrying one hook script per registration. */
|
|
88
|
+
function buildFixtureFileMap() {
|
|
89
|
+
const map = {};
|
|
90
|
+
const targetDir = path.join(FIXTURE_DIR, 'claude');
|
|
91
|
+
if (existsSync(targetDir)) collectFiles(targetDir, targetDir, map, 'claude');
|
|
92
|
+
for (const { event, script } of DEFAULT_HOOK_SCRIPTS) {
|
|
93
|
+
map[script.replace(/^\.claude\//, 'claude/')] =
|
|
94
|
+
`#!/bin/sh\n# managed-by: dev-agents-sync v2.0.16\n# ${event} hook fixture: ${path.basename(script)}.\nexit 0\n`;
|
|
95
|
+
}
|
|
96
|
+
return map;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function makeFixtureFetcher() {
|
|
100
|
+
return async () => buildFixtureFileMap();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function makeTmpDir() {
|
|
104
|
+
return mkdtempSync(path.join(tmpdir(), 'das-hook-command-form-'));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function settingsPathIn(consumerDir) {
|
|
108
|
+
return path.join(consumerDir, '.claude', 'settings.json');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Seeds a consumer synced by a PRE-FIX CLI: a lockfile plus a settings.json
|
|
113
|
+
* whose managed registrations all carry the bare-path command form, on the
|
|
114
|
+
* events and matchers that CLI used (`managed-file-guard` on `.*`).
|
|
115
|
+
*/
|
|
116
|
+
function setupPreFixRepo(consumerDir) {
|
|
117
|
+
const raw =
|
|
118
|
+
JSON.stringify(
|
|
119
|
+
{
|
|
120
|
+
$schema:
|
|
121
|
+
'https://raw.githubusercontent.com/dalzoubi/dev-agents/v1/schema/lockfile.schema.json',
|
|
122
|
+
source: 'github:dalzoubi/dev-agents',
|
|
123
|
+
range: '^1',
|
|
124
|
+
resolvedVersion: '1.0.0',
|
|
125
|
+
targets: ['claude'],
|
|
126
|
+
lastUpdated: '2026-08-05T00:00:00Z',
|
|
127
|
+
hooks: DEFAULT_HOOK_SCRIPTS.map((h) => h.script),
|
|
128
|
+
},
|
|
129
|
+
null,
|
|
130
|
+
2,
|
|
131
|
+
) + '\n';
|
|
132
|
+
writeFileSync(path.join(consumerDir, LOCKFILE_NAME), raw, 'utf8');
|
|
133
|
+
|
|
134
|
+
const agentsDir = path.join(consumerDir, '.claude', 'agents');
|
|
135
|
+
mkdirSync(agentsDir, { recursive: true });
|
|
136
|
+
writeFileSync(
|
|
137
|
+
path.join(agentsDir, 'define.md'),
|
|
138
|
+
readFileSync(path.join(FIXTURE_DIR, 'claude', 'agents', 'define.md'), 'utf8'),
|
|
139
|
+
'utf8',
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
// The pre-fix wiring: bare-path commands, one tagged entry per hook, grouped
|
|
143
|
+
// into the event buckets the old table used.
|
|
144
|
+
const hooks = {};
|
|
145
|
+
for (const { event, matcher, script } of DEFAULT_HOOK_SCRIPTS) {
|
|
146
|
+
const oldMatcher = script.endsWith('managed-file-guard.sh') ? '.*' : matcher;
|
|
147
|
+
hooks[event] ??= [];
|
|
148
|
+
hooks[event].push({
|
|
149
|
+
[HOOK_TAG]: true,
|
|
150
|
+
matcher: oldMatcher,
|
|
151
|
+
hooks: [{ type: 'command', command: script }],
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
writeFileSync(
|
|
155
|
+
settingsPathIn(consumerDir),
|
|
156
|
+
JSON.stringify({ hooks }, null, 2) + '\n',
|
|
157
|
+
'utf8',
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Every CLI-managed registration in settings.json, flattened. */
|
|
162
|
+
function managedRegistrations(consumerDir) {
|
|
163
|
+
const settingsPath = settingsPathIn(consumerDir);
|
|
164
|
+
if (!existsSync(settingsPath)) return [];
|
|
165
|
+
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
166
|
+
const out = [];
|
|
167
|
+
for (const [event, entries] of Object.entries(settings.hooks ?? {})) {
|
|
168
|
+
if (!Array.isArray(entries)) continue;
|
|
169
|
+
for (const entry of entries) {
|
|
170
|
+
if (entry?.[HOOK_TAG] !== true) continue;
|
|
171
|
+
for (const h of entry.hooks ?? []) {
|
|
172
|
+
if (typeof h.command === 'string') {
|
|
173
|
+
out.push({ event, matcher: entry.matcher, command: h.command });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return out;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
// C1 — no registered command is a bare .sh path
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
describe('C1 — every registered command names an interpreter', () => {
|
|
186
|
+
for (const { script, command } of DEFAULT_HOOK_SCRIPTS) {
|
|
187
|
+
it(`C1: ${path.basename(script)} is registered as "${INTERPRETER} <path>", not a bare .sh path`, () => {
|
|
188
|
+
assert.equal(
|
|
189
|
+
command,
|
|
190
|
+
`${INTERPRETER} ${script}`,
|
|
191
|
+
`A bare "${script}" command has no interpreter on Windows, so the shell falls ` +
|
|
192
|
+
`through to the .sh file association (Git for Windows: bash --login -i "%1") and ` +
|
|
193
|
+
`each matched tool call opens a console window. Register it as ` +
|
|
194
|
+
`"${INTERPRETER} ${script}".`,
|
|
195
|
+
);
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
it('C1: no command in the table ends in .sh (the shape that triggers the association)', () => {
|
|
200
|
+
const bare = DEFAULT_HOOK_SCRIPTS.map((h) => h.command)
|
|
201
|
+
.filter((c) => /\.sh$/.test(c) && !c.includes(' '))
|
|
202
|
+
.sort();
|
|
203
|
+
assert.deepEqual(
|
|
204
|
+
bare,
|
|
205
|
+
[],
|
|
206
|
+
`these commands are bare script paths and will resolve through the Windows .sh ` +
|
|
207
|
+
`file association: ${bare.join(', ')}`,
|
|
208
|
+
);
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
// C2 — script and command stay consistent
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
describe('C2 — script and command are consistent across the table', () => {
|
|
217
|
+
it('C2a: every row carries both a consumer-relative script path and a command', () => {
|
|
218
|
+
for (const row of DEFAULT_HOOK_SCRIPTS) {
|
|
219
|
+
assert.match(
|
|
220
|
+
row.script,
|
|
221
|
+
/^\.claude\/hooks\/[a-z0-9-]+\.sh$/,
|
|
222
|
+
`every row needs a consumer-relative script path; got: ${JSON.stringify(row)}`,
|
|
223
|
+
);
|
|
224
|
+
assert.ok(
|
|
225
|
+
typeof row.command === 'string' && row.command.length > 0,
|
|
226
|
+
`every row needs a command string; got: ${JSON.stringify(row)}`,
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('C2b: every command ends with the exact script path of its own row', () => {
|
|
232
|
+
// Guards the pairing itself: a copy/paste that leaves one row's command
|
|
233
|
+
// pointing at another row's script would register a hook that runs the
|
|
234
|
+
// wrong guard, on the wrong event, with nothing else noticing.
|
|
235
|
+
for (const { script, command } of DEFAULT_HOOK_SCRIPTS) {
|
|
236
|
+
assert.ok(
|
|
237
|
+
command.endsWith(` ${script}`),
|
|
238
|
+
`${command} must invoke its own row's script (${script})`,
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('C2c: each shipped script has exactly one row', () => {
|
|
244
|
+
const scripts = DEFAULT_HOOK_SCRIPTS.map((h) => h.script);
|
|
245
|
+
const duplicated = scripts.filter((s, i) => scripts.indexOf(s) !== i).sort();
|
|
246
|
+
assert.deepEqual(
|
|
247
|
+
duplicated,
|
|
248
|
+
[],
|
|
249
|
+
`a script registered twice runs twice per matched call: ${duplicated.join(', ')}`,
|
|
250
|
+
);
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
// C3 — the pre-fix command form is retired
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
describe('C3 — the bare-path form of every shipped hook is retired', () => {
|
|
259
|
+
it('C3a: RETIRED_HOOK_SCRIPTS names the bare-path command of every shipped script', () => {
|
|
260
|
+
// Without this, an existing consumer keeps their old registration AND gains
|
|
261
|
+
// the new one: the hook runs twice and the console window the fix removes
|
|
262
|
+
// comes straight back from the stale entry.
|
|
263
|
+
const retired = new Set(RETIRED_HOOK_SCRIPTS.map((h) => h.command));
|
|
264
|
+
const missing = DEFAULT_HOOK_SCRIPTS.map((h) => h.script)
|
|
265
|
+
.filter((s) => !retired.has(s))
|
|
266
|
+
.sort();
|
|
267
|
+
assert.deepEqual(
|
|
268
|
+
missing,
|
|
269
|
+
[],
|
|
270
|
+
`the pre-fix bare-path registration for these scripts is not retired, so it stays ` +
|
|
271
|
+
`live alongside the new one: ${missing.join(', ')}`,
|
|
272
|
+
);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('C3b: no retired command is re-registered in the shipped table', () => {
|
|
276
|
+
// merge-then-prune would otherwise add and immediately remove it on every run.
|
|
277
|
+
const retired = new Set(RETIRED_HOOK_SCRIPTS.map((h) => h.command));
|
|
278
|
+
const reRegistered = DEFAULT_HOOK_SCRIPTS.map((h) => h.command)
|
|
279
|
+
.filter((c) => retired.has(c))
|
|
280
|
+
.sort();
|
|
281
|
+
assert.deepEqual(reRegistered, [], `re-registered retired commands: ${reRegistered.join(', ')}`);
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
// C4 — an existing consumer migrates in one update
|
|
287
|
+
// ---------------------------------------------------------------------------
|
|
288
|
+
|
|
289
|
+
describe('C4 — a pre-fix consumer migrates to the new command form in one update', () => {
|
|
290
|
+
let consumerDir;
|
|
291
|
+
|
|
292
|
+
beforeEach(() => {
|
|
293
|
+
consumerDir = makeTmpDir();
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
afterEach(() => {
|
|
297
|
+
rmSync(consumerDir, { recursive: true, force: true });
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it('C4a: after one update, every hook is registered exactly once, in the new form', async () => {
|
|
301
|
+
setupPreFixRepo(consumerDir);
|
|
302
|
+
|
|
303
|
+
await runUpdate(consumerDir, {
|
|
304
|
+
fetcher: makeFixtureFetcher(),
|
|
305
|
+
availableTags: AVAILABLE_TAGS,
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
const commands = managedRegistrations(consumerDir)
|
|
309
|
+
.map((w) => w.command)
|
|
310
|
+
.sort();
|
|
311
|
+
assert.deepEqual(
|
|
312
|
+
commands,
|
|
313
|
+
DEFAULT_HOOK_SCRIPTS.map((h) => h.command).sort(),
|
|
314
|
+
'one update must leave exactly the current registrations — no stale bare-path ' +
|
|
315
|
+
'entry surviving alongside its replacement',
|
|
316
|
+
);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it('C4b: no bare-path registration survives the update', async () => {
|
|
320
|
+
setupPreFixRepo(consumerDir);
|
|
321
|
+
|
|
322
|
+
await runUpdate(consumerDir, {
|
|
323
|
+
fetcher: makeFixtureFetcher(),
|
|
324
|
+
availableTags: AVAILABLE_TAGS,
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
const stale = managedRegistrations(consumerDir)
|
|
328
|
+
.map((w) => w.command)
|
|
329
|
+
.filter((c) => DEFAULT_HOOK_SCRIPTS.some((h) => h.script === c))
|
|
330
|
+
.sort();
|
|
331
|
+
assert.deepEqual(
|
|
332
|
+
stale,
|
|
333
|
+
[],
|
|
334
|
+
`these pre-fix registrations survived and will keep opening a console window on ` +
|
|
335
|
+
`Windows: ${stale.join(', ')}`,
|
|
336
|
+
);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it('C4c: the migration is reported on stderr, not silent', async () => {
|
|
340
|
+
// De-registering entries in a consumer-owned file is a real edit (§6).
|
|
341
|
+
setupPreFixRepo(consumerDir);
|
|
342
|
+
|
|
343
|
+
const origWrite = process.stderr.write.bind(process.stderr);
|
|
344
|
+
let stderr = '';
|
|
345
|
+
process.stderr.write = (chunk) => {
|
|
346
|
+
stderr += typeof chunk === 'string' ? chunk : chunk.toString();
|
|
347
|
+
return true;
|
|
348
|
+
};
|
|
349
|
+
try {
|
|
350
|
+
await runUpdate(consumerDir, {
|
|
351
|
+
fetcher: makeFixtureFetcher(),
|
|
352
|
+
availableTags: AVAILABLE_TAGS,
|
|
353
|
+
});
|
|
354
|
+
} finally {
|
|
355
|
+
process.stderr.write = origWrite;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
assert.match(
|
|
359
|
+
stderr,
|
|
360
|
+
/info: dev-agents-sync: pruned \d+ retired hook registration\(s\)/,
|
|
361
|
+
`the de-registration must be reported like every other edit to consumer-owned ` +
|
|
362
|
+
`state; got stderr:\n${stderr || '(empty)'}`,
|
|
363
|
+
);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it('C4d: a second update is a byte-identical no-op', async () => {
|
|
367
|
+
setupPreFixRepo(consumerDir);
|
|
368
|
+
const opts = { fetcher: makeFixtureFetcher(), availableTags: AVAILABLE_TAGS };
|
|
369
|
+
|
|
370
|
+
await runUpdate(consumerDir, opts);
|
|
371
|
+
const first = readFileSync(settingsPathIn(consumerDir), 'utf8');
|
|
372
|
+
|
|
373
|
+
await runUpdate(consumerDir, opts);
|
|
374
|
+
const second = readFileSync(settingsPathIn(consumerDir), 'utf8');
|
|
375
|
+
|
|
376
|
+
assert.equal(
|
|
377
|
+
second,
|
|
378
|
+
first,
|
|
379
|
+
'the migration must converge in one run — a second update must add nothing and ' +
|
|
380
|
+
'prune nothing (§9)',
|
|
381
|
+
);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it('C4e: a consumer-authored registration for the old command form is left alone', async () => {
|
|
385
|
+
// Retirement is scoped to CLI-managed (tagged) entries. An untagged entry
|
|
386
|
+
// naming the same script is consumer-owned: they may have written it
|
|
387
|
+
// themselves before the CLI wired hooks at all, and it is not ours to
|
|
388
|
+
// remove — even though we consider that command form retired.
|
|
389
|
+
setupPreFixRepo(consumerDir);
|
|
390
|
+
const settingsPath = settingsPathIn(consumerDir);
|
|
391
|
+
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
392
|
+
settings.hooks.SessionStart.push({
|
|
393
|
+
matcher: '.*',
|
|
394
|
+
hooks: [{ type: 'command', command: '.claude/hooks/steering-injector.sh' }],
|
|
395
|
+
});
|
|
396
|
+
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
|
|
397
|
+
|
|
398
|
+
await runUpdate(consumerDir, {
|
|
399
|
+
fetcher: makeFixtureFetcher(),
|
|
400
|
+
availableTags: AVAILABLE_TAGS,
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
const after = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
404
|
+
const survives = (after.hooks?.SessionStart ?? []).some(
|
|
405
|
+
(e) =>
|
|
406
|
+
e[HOOK_TAG] !== true &&
|
|
407
|
+
(e.hooks ?? []).some((h) => h.command === '.claude/hooks/steering-injector.sh'),
|
|
408
|
+
);
|
|
409
|
+
assert.ok(
|
|
410
|
+
survives,
|
|
411
|
+
`a consumer-authored entry must survive the prune untouched; got ` +
|
|
412
|
+
`hooks.SessionStart: ${JSON.stringify(after.hooks?.SessionStart)}`,
|
|
413
|
+
);
|
|
414
|
+
});
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
// ---------------------------------------------------------------------------
|
|
418
|
+
// C5 — managed-file-guard is scoped to the tools it inspects
|
|
419
|
+
// ---------------------------------------------------------------------------
|
|
420
|
+
|
|
421
|
+
describe('C5 — managed-file-guard is registered on Edit|Write, not .*', () => {
|
|
422
|
+
it('C5: the matcher names the tools whose payload the hook reads', () => {
|
|
423
|
+
const entry = DEFAULT_HOOK_SCRIPTS.find(
|
|
424
|
+
(h) => h.script === '.claude/hooks/managed-file-guard.sh',
|
|
425
|
+
);
|
|
426
|
+
assert.ok(entry, 'managed-file-guard.sh must be registered');
|
|
427
|
+
assert.equal(
|
|
428
|
+
entry.matcher,
|
|
429
|
+
'Edit|Write',
|
|
430
|
+
`managed-file-guard reads a written file path out of the hook payload and exits 0 ` +
|
|
431
|
+
`silently when there is none, so on "${entry.matcher}" it is spawned by every ` +
|
|
432
|
+
`unrelated tool call — every Read, every Bash — to do nothing`,
|
|
433
|
+
);
|
|
434
|
+
});
|
|
435
|
+
});
|
|
@@ -76,13 +76,26 @@ const LOCKFILE_NAME = '.dev-agents-sync.json';
|
|
|
76
76
|
* holds ONLY these five.
|
|
77
77
|
*/
|
|
78
78
|
const WAVE2_REGISTRATIONS = [
|
|
79
|
-
{
|
|
80
|
-
{
|
|
81
|
-
{
|
|
82
|
-
{
|
|
83
|
-
{
|
|
79
|
+
{ script: '.claude/hooks/force-push-guard.sh', event: 'PreToolUse', matcher: 'Bash' },
|
|
80
|
+
{ script: '.claude/hooks/spec-approval-gate.sh', event: 'PreToolUse', matcher: 'Edit|Write' },
|
|
81
|
+
{ script: '.claude/hooks/handoff-state-guard.sh', event: 'PreToolUse', matcher: 'Write' },
|
|
82
|
+
{ script: '.claude/hooks/spec-drift-warn.sh', event: 'PostToolUse', matcher: 'Edit|Write' },
|
|
83
|
+
{ script: '.claude/hooks/pre-pr-preflight.sh', event: 'PreToolUse', matcher: 'Bash' },
|
|
84
84
|
];
|
|
85
85
|
|
|
86
|
+
/**
|
|
87
|
+
* The command string a registration for `script` must carry in settings.json.
|
|
88
|
+
*
|
|
89
|
+
* Spelled out here rather than imported from `hookCommandFor`: the interpreter
|
|
90
|
+
* prefix IS the contract (a bare `.sh` command resolves through the Windows
|
|
91
|
+
* `.sh` file association to an interactive `bash --login -i`, which opens a
|
|
92
|
+
* console window per matched tool call), so importing the producer would assert
|
|
93
|
+
* it equals itself.
|
|
94
|
+
*/
|
|
95
|
+
function expectedCommand(script) {
|
|
96
|
+
return `bash ${script}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
86
99
|
function collectFiles(baseDir, currentDir, map, prefix) {
|
|
87
100
|
for (const entry of readdirSync(currentDir, { withFileTypes: true })) {
|
|
88
101
|
const full = path.join(currentDir, entry.name);
|
|
@@ -104,9 +117,9 @@ function buildFixtureFileMap() {
|
|
|
104
117
|
const map = {};
|
|
105
118
|
const targetDir = path.join(FIXTURE_DIR, 'claude');
|
|
106
119
|
if (existsSync(targetDir)) collectFiles(targetDir, targetDir, map, 'claude');
|
|
107
|
-
for (const { event,
|
|
108
|
-
map[
|
|
109
|
-
`#!/bin/sh\n# managed-by: dev-agents-sync v2.0.16\n# ${event} hook fixture: ${path.basename(
|
|
120
|
+
for (const { event, script } of DEFAULT_HOOK_SCRIPTS) {
|
|
121
|
+
map[script.replace(/^\.claude\//, 'claude/')] =
|
|
122
|
+
`#!/bin/sh\n# managed-by: dev-agents-sync v2.0.16\n# ${event} hook fixture: ${path.basename(script)}.\nexit 0\n`;
|
|
110
123
|
}
|
|
111
124
|
return map;
|
|
112
125
|
}
|
|
@@ -179,18 +192,18 @@ function managedRegistrations(consumerDir) {
|
|
|
179
192
|
// ---------------------------------------------------------------------------
|
|
180
193
|
|
|
181
194
|
describe('W1 — the five wave-2 hooks are registered on the specified event and matcher', () => {
|
|
182
|
-
for (const {
|
|
183
|
-
it(`W1: ${path.basename(
|
|
184
|
-
const entries = DEFAULT_HOOK_SCRIPTS.filter((h) => h.
|
|
195
|
+
for (const { script, event, matcher } of WAVE2_REGISTRATIONS) {
|
|
196
|
+
it(`W1: ${path.basename(script)} is registered on ${event} with matcher "${matcher}"`, () => {
|
|
197
|
+
const entries = DEFAULT_HOOK_SCRIPTS.filter((h) => h.script === script);
|
|
185
198
|
assert.equal(
|
|
186
199
|
entries.length,
|
|
187
200
|
1,
|
|
188
|
-
`${
|
|
201
|
+
`${script} must have exactly one DEFAULT_HOOK_SCRIPTS entry; got ${entries.length}`,
|
|
189
202
|
);
|
|
190
203
|
assert.deepEqual(
|
|
191
204
|
{ event: entries[0].event, matcher: entries[0].matcher },
|
|
192
205
|
{ event, matcher },
|
|
193
|
-
`${
|
|
206
|
+
`${script} must be wired per docs/specs/260805-hooks-wave-2/plan.md. A wrong event ` +
|
|
194
207
|
`means the hook never sees the call it guards; a wrong matcher means it is spawned ` +
|
|
195
208
|
`on unrelated tool calls (or skipped on the ones that matter).`,
|
|
196
209
|
);
|
|
@@ -205,13 +218,13 @@ describe('W1 — the five wave-2 hooks are registered on the specified event and
|
|
|
205
218
|
'.claude/hooks/spec-approval-gate.sh',
|
|
206
219
|
'.claude/hooks/handoff-state-guard.sh',
|
|
207
220
|
];
|
|
208
|
-
for (const
|
|
209
|
-
const entry = DEFAULT_HOOK_SCRIPTS.find((h) => h.
|
|
210
|
-
assert.ok(entry, `${
|
|
221
|
+
for (const script of blockers) {
|
|
222
|
+
const entry = DEFAULT_HOOK_SCRIPTS.find((h) => h.script === script);
|
|
223
|
+
assert.ok(entry, `${script} must be registered`);
|
|
211
224
|
assert.equal(
|
|
212
225
|
entry.event,
|
|
213
226
|
'PreToolUse',
|
|
214
|
-
`${
|
|
227
|
+
`${script} blocks by exiting 2, which only denies on PreToolUse; on ${entry.event} it ` +
|
|
215
228
|
`would be a hook that reports failure and lets the call through`,
|
|
216
229
|
);
|
|
217
230
|
}
|
|
@@ -240,8 +253,8 @@ describe('W2 — init and update install and wire every registered hook', () =>
|
|
|
240
253
|
availableTags: AVAILABLE_TAGS,
|
|
241
254
|
});
|
|
242
255
|
|
|
243
|
-
const missing = DEFAULT_HOOK_SCRIPTS.map((h) => h.
|
|
244
|
-
(
|
|
256
|
+
const missing = DEFAULT_HOOK_SCRIPTS.map((h) => h.script).filter(
|
|
257
|
+
(s) => !existsSync(path.join(consumerDir, s)),
|
|
245
258
|
);
|
|
246
259
|
assert.deepEqual(missing, [], `init must install every registered hook script; missing: ${missing}`);
|
|
247
260
|
});
|
|
@@ -254,7 +267,8 @@ describe('W2 — init and update install and wire every registered hook', () =>
|
|
|
254
267
|
});
|
|
255
268
|
|
|
256
269
|
const wired = managedRegistrations(consumerDir);
|
|
257
|
-
for (const { event, matcher,
|
|
270
|
+
for (const { event, matcher, script } of DEFAULT_HOOK_SCRIPTS) {
|
|
271
|
+
const command = expectedCommand(script);
|
|
258
272
|
assert.ok(
|
|
259
273
|
wired.some((w) => w.command === command && w.event === event && w.matcher === matcher),
|
|
260
274
|
`settings.json must wire ${command} on ${event} with matcher "${matcher}"; ` +
|
|
@@ -271,7 +285,8 @@ describe('W2 — init and update install and wire every registered hook', () =>
|
|
|
271
285
|
});
|
|
272
286
|
|
|
273
287
|
const wired = managedRegistrations(consumerDir);
|
|
274
|
-
for (const { event, matcher,
|
|
288
|
+
for (const { event, matcher, script } of DEFAULT_HOOK_SCRIPTS) {
|
|
289
|
+
const command = expectedCommand(script);
|
|
275
290
|
assert.ok(
|
|
276
291
|
wired.some((w) => w.command === command && w.event === event && w.matcher === matcher),
|
|
277
292
|
`update must wire ${command} on ${event} with matcher "${matcher}"; ` +
|
|
@@ -296,7 +311,10 @@ describe('W2 — init and update install and wire every registered hook', () =>
|
|
|
296
311
|
.sort();
|
|
297
312
|
assert.deepEqual(
|
|
298
313
|
commands,
|
|
299
|
-
[
|
|
314
|
+
[
|
|
315
|
+
expectedCommand('.claude/hooks/force-push-guard.sh'),
|
|
316
|
+
expectedCommand('.claude/hooks/pre-pr-preflight.sh'),
|
|
317
|
+
],
|
|
300
318
|
'both PreToolUse/Bash hooks must be wired independently',
|
|
301
319
|
);
|
|
302
320
|
});
|
|
@@ -309,8 +327,8 @@ describe('W2 — init and update install and wire every registered hook', () =>
|
|
|
309
327
|
});
|
|
310
328
|
|
|
311
329
|
const raw = readFileSync(path.join(consumerDir, LOCKFILE_NAME), 'utf8');
|
|
312
|
-
for (const {
|
|
313
|
-
assert.ok(raw.includes(path.basename(
|
|
330
|
+
for (const { script } of WAVE2_REGISTRATIONS) {
|
|
331
|
+
assert.ok(raw.includes(path.basename(script)), `lockfile must track ${script}`);
|
|
314
332
|
}
|
|
315
333
|
});
|
|
316
334
|
});
|
|
@@ -366,12 +384,12 @@ describe('W3 — a second update is a no-op (twice == once)', () => {
|
|
|
366
384
|
|
|
367
385
|
await runUpdate(consumerDir, opts);
|
|
368
386
|
const first = DEFAULT_HOOK_SCRIPTS.map((h) =>
|
|
369
|
-
readFileSync(path.join(consumerDir, h.
|
|
387
|
+
readFileSync(path.join(consumerDir, h.script), 'utf8'),
|
|
370
388
|
);
|
|
371
389
|
|
|
372
390
|
await runUpdate(consumerDir, opts);
|
|
373
391
|
const second = DEFAULT_HOOK_SCRIPTS.map((h) =>
|
|
374
|
-
readFileSync(path.join(consumerDir, h.
|
|
392
|
+
readFileSync(path.join(consumerDir, h.script), 'utf8'),
|
|
375
393
|
);
|
|
376
394
|
|
|
377
395
|
assert.deepEqual(second, first, 'hook scripts must be byte-identical after a second update');
|
|
@@ -414,8 +432,8 @@ describe('W4 — a pre-wave lockfile upgrades additively', () => {
|
|
|
414
432
|
assert.ok(raw.includes(name), `upgrade must not drop the pre-wave hook ${name}`);
|
|
415
433
|
}
|
|
416
434
|
// ...and the wave's hooks are added.
|
|
417
|
-
for (const {
|
|
418
|
-
assert.ok(raw.includes(path.basename(
|
|
435
|
+
for (const { script } of WAVE2_REGISTRATIONS) {
|
|
436
|
+
assert.ok(raw.includes(path.basename(script)), `upgrade must record ${script}`);
|
|
419
437
|
}
|
|
420
438
|
});
|
|
421
439
|
|
|
@@ -239,14 +239,20 @@ describe('S1 R1 — DEFAULT_HOOK_SCRIPTS no longer includes rules-injector.sh (r
|
|
|
239
239
|
});
|
|
240
240
|
|
|
241
241
|
it('R1c: the three pre-existing managed hooks are still registered (retirement is subtractive only, not destructive)', () => {
|
|
242
|
-
|
|
242
|
+
// Asserted on `script` — the SCRIPT each row registers — because that is
|
|
243
|
+
// what "still registered" means here: retiring one hook must not drop the
|
|
244
|
+
// other three from the shipped table. The `command` string is a separate,
|
|
245
|
+
// independently-versioned concern (it grew a `bash ` prefix to stop the
|
|
246
|
+
// Windows `.sh` file association from opening a console window), and its
|
|
247
|
+
// old form is itself on the retired list.
|
|
248
|
+
const scripts = DEFAULT_HOOK_SCRIPTS.map((h) => h.script);
|
|
243
249
|
for (const expected of [
|
|
244
250
|
'.claude/hooks/steering-injector.sh',
|
|
245
251
|
'.claude/hooks/managed-file-guard.sh',
|
|
246
252
|
'.claude/hooks/uncommitted-check.sh',
|
|
247
253
|
]) {
|
|
248
254
|
assert.ok(
|
|
249
|
-
|
|
255
|
+
scripts.includes(expected),
|
|
250
256
|
`retiring rules-injector.sh must not drop the existing ${expected} registration`,
|
|
251
257
|
);
|
|
252
258
|
}
|
|
@@ -503,8 +509,11 @@ describe('S1 H2 — update reports the retired-hook-registration prune (not sile
|
|
|
503
509
|
|
|
504
510
|
it('H2c: no prune line is emitted when there is nothing to prune (guards against a spurious message every run)', async () => {
|
|
505
511
|
setupInitializedRepo(consumerDir, { resolvedVersion: '1.0.0' });
|
|
506
|
-
// settings.json is already fully wired to the three surviving hooks
|
|
507
|
-
//
|
|
512
|
+
// settings.json is already fully wired to the three surviving hooks — in
|
|
513
|
+
// the command form the CLI registers TODAY (`bash <path>`; the bare-path
|
|
514
|
+
// form is itself retired, so seeding that would give the prune real work
|
|
515
|
+
// and this test would stop testing the no-op case) — with no retired
|
|
516
|
+
// registration anywhere. Nothing to prune.
|
|
508
517
|
const settingsDir = path.join(consumerDir, '.claude');
|
|
509
518
|
mkdirSync(settingsDir, { recursive: true });
|
|
510
519
|
writeFileSync(
|
|
@@ -516,21 +525,21 @@ describe('S1 H2 — update reports the retired-hook-registration prune (not sile
|
|
|
516
525
|
{
|
|
517
526
|
[HOOK_TAG]: true,
|
|
518
527
|
matcher: '.*',
|
|
519
|
-
hooks: [{ type: 'command', command: '.claude/hooks/steering-injector.sh' }],
|
|
528
|
+
hooks: [{ type: 'command', command: 'bash .claude/hooks/steering-injector.sh' }],
|
|
520
529
|
},
|
|
521
530
|
],
|
|
522
531
|
PostToolUse: [
|
|
523
532
|
{
|
|
524
533
|
[HOOK_TAG]: true,
|
|
525
|
-
matcher: '
|
|
526
|
-
hooks: [{ type: 'command', command: '.claude/hooks/managed-file-guard.sh' }],
|
|
534
|
+
matcher: 'Edit|Write',
|
|
535
|
+
hooks: [{ type: 'command', command: 'bash .claude/hooks/managed-file-guard.sh' }],
|
|
527
536
|
},
|
|
528
537
|
],
|
|
529
538
|
Stop: [
|
|
530
539
|
{
|
|
531
540
|
[HOOK_TAG]: true,
|
|
532
541
|
matcher: '.*',
|
|
533
|
-
hooks: [{ type: 'command', command: '.claude/hooks/uncommitted-check.sh' }],
|
|
542
|
+
hooks: [{ type: 'command', command: 'bash .claude/hooks/uncommitted-check.sh' }],
|
|
534
543
|
},
|
|
535
544
|
],
|
|
536
545
|
},
|
|
@@ -65,8 +65,10 @@ function noFetchFetcher() {
|
|
|
65
65
|
function fetcherWithHooks(registrations) {
|
|
66
66
|
return async () => {
|
|
67
67
|
const map = {};
|
|
68
|
-
|
|
69
|
-
|
|
68
|
+
// Keyed off `script` (the path), not `command` (`bash <path>`): FileMap keys
|
|
69
|
+
// are paths, and a shell command string has no recognized prefix.
|
|
70
|
+
for (const { script } of registrations) {
|
|
71
|
+
map[script.replace(/^\.claude\//, 'claude/')] = hookBody(path.basename(script));
|
|
70
72
|
}
|
|
71
73
|
return map;
|
|
72
74
|
};
|
|
@@ -98,13 +100,13 @@ function setupPinnedRepo(consumerDir, { resolvedVersion = LATEST_IN_RANGE } = {}
|
|
|
98
100
|
* follows the table the moment a hook is added or retired.
|
|
99
101
|
*/
|
|
100
102
|
function seedHookScripts(consumerDir, registrations) {
|
|
101
|
-
const
|
|
102
|
-
for (const
|
|
103
|
-
const abs = path.join(consumerDir,
|
|
103
|
+
const scripts = new Set(registrations.map((h) => h.script));
|
|
104
|
+
for (const script of scripts) {
|
|
105
|
+
const abs = path.join(consumerDir, script);
|
|
104
106
|
mkdirSync(path.dirname(abs), { recursive: true });
|
|
105
|
-
writeFileSync(abs, hookBody(path.basename(
|
|
107
|
+
writeFileSync(abs, hookBody(path.basename(script)), 'utf8');
|
|
106
108
|
}
|
|
107
|
-
return DEFAULT_HOOK_SCRIPTS.filter((h) =>
|
|
109
|
+
return DEFAULT_HOOK_SCRIPTS.filter((h) => scripts.has(h.script)).length;
|
|
108
110
|
}
|
|
109
111
|
|
|
110
112
|
/** Captures everything written to stderr while `fn` runs. */
|