@dalzoubi/dev-agents-sync 2.2.0 → 2.2.2

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 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` | `.*` | warning | Guards managed files against unexpected edits. |
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dalzoubi/dev-agents-sync",
3
- "version": "2.2.0",
3
+ "version": "2.2.2",
4
4
  "type": "module",
5
5
  "description": "CLI that syncs managed dev-agent prompts into consumer repos (.claude/ and/or .cursor/).",
6
6
  "license": "UNLICENSED",
@@ -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.command)),
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.command));
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: '.*', command: '.claude/hooks/steering-injector.sh' },
409
- { event: 'PostToolUse', matcher: '.*', command: '.claude/hooks/managed-file-guard.sh' },
410
- { event: 'Stop', matcher: '.*', command: '.claude/hooks/uncommitted-check.sh' },
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', command: '.claude/hooks/force-push-guard.sh' },
415
- { event: 'PreToolUse', matcher: 'Edit|Write', command: '.claude/hooks/spec-approval-gate.sh' },
416
- { event: 'PreToolUse', matcher: 'Write', command: '.claude/hooks/handoff-state-guard.sh' },
417
- { event: 'PostToolUse', matcher: 'Edit|Write', command: '.claude/hooks/spec-drift-warn.sh' },
418
- { event: 'PreToolUse', matcher: 'Bash', command: '.claude/hooks/pre-pr-preflight.sh' },
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.command));
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.command),
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, command } of DEFAULT_HOOK_SCRIPTS) {
143
- const key = command.replace(/^\.claude\//, 'claude/');
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(command)}.\nexit 0\n`;
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: '.claude/hooks/steering-injector.sh' },
107
- { event: 'PostToolUse', matcher: '.*', command: '.claude/hooks/managed-file-guard.sh' },
108
- { event: 'Stop', matcher: '.*', command: '.claude/hooks/uncommitted-check.sh' },
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('.*', '.claude/hooks/steering-injector.sh')],
317
- PostToolUse: [taggedEntry('.*', '.claude/hooks/managed-file-guard.sh')],
318
- Stop: [taggedEntry('.*', '.claude/hooks/uncommitted-check.sh')],
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 === '.claude/hooks/steering-injector.sh'),
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 = '.claude/hooks/steering-injector.sh';
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('.*', '.claude/hooks/steering-injector.sh');
648
- const guard = taggedEntry('.*', '.claude/hooks/managed-file-guard.sh');
649
- const uncommitted = taggedEntry('.*', '.claude/hooks/uncommitted-check.sh');
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
- { command: '.claude/hooks/force-push-guard.sh', event: 'PreToolUse', matcher: 'Bash' },
80
- { command: '.claude/hooks/spec-approval-gate.sh', event: 'PreToolUse', matcher: 'Edit|Write' },
81
- { command: '.claude/hooks/handoff-state-guard.sh', event: 'PreToolUse', matcher: 'Write' },
82
- { command: '.claude/hooks/spec-drift-warn.sh', event: 'PostToolUse', matcher: 'Edit|Write' },
83
- { command: '.claude/hooks/pre-pr-preflight.sh', event: 'PreToolUse', matcher: 'Bash' },
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, command } of DEFAULT_HOOK_SCRIPTS) {
108
- map[command.replace(/^\.claude\//, 'claude/')] =
109
- `#!/bin/sh\n# managed-by: dev-agents-sync v2.0.16\n# ${event} hook fixture: ${path.basename(command)}.\nexit 0\n`;
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 { command, event, matcher } of WAVE2_REGISTRATIONS) {
183
- it(`W1: ${path.basename(command)} is registered on ${event} with matcher "${matcher}"`, () => {
184
- const entries = DEFAULT_HOOK_SCRIPTS.filter((h) => h.command === command);
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
- `${command} must have exactly one DEFAULT_HOOK_SCRIPTS entry; got ${entries.length}`,
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
- `${command} must be wired per docs/specs/260805-hooks-wave-2/plan.md. A wrong event ` +
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 command of blockers) {
209
- const entry = DEFAULT_HOOK_SCRIPTS.find((h) => h.command === command);
210
- assert.ok(entry, `${command} must be registered`);
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
- `${command} blocks by exiting 2, which only denies on PreToolUse; on ${entry.event} it ` +
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.command).filter(
244
- (c) => !existsSync(path.join(consumerDir, c)),
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, command } of DEFAULT_HOOK_SCRIPTS) {
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, command } of DEFAULT_HOOK_SCRIPTS) {
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
- ['.claude/hooks/force-push-guard.sh', '.claude/hooks/pre-pr-preflight.sh'],
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 { command } of WAVE2_REGISTRATIONS) {
313
- assert.ok(raw.includes(path.basename(command)), `lockfile must track ${command}`);
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.command), 'utf8'),
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.command), 'utf8'),
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 { command } of WAVE2_REGISTRATIONS) {
418
- assert.ok(raw.includes(path.basename(command)), `upgrade must record ${command}`);
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
- const commands = DEFAULT_HOOK_SCRIPTS.map((h) => h.command);
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
- commands.includes(expected),
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, with
507
- // no retired registration anywhere — nothing to prune.
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
- for (const { command } of registrations) {
69
- map[command.replace(/^\.claude\//, 'claude/')] = hookBody(path.basename(command));
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 commands = new Set(registrations.map((h) => h.command));
102
- for (const command of commands) {
103
- const abs = path.join(consumerDir, command);
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(command)), 'utf8');
107
+ writeFileSync(abs, hookBody(path.basename(script)), 'utf8');
106
108
  }
107
- return DEFAULT_HOOK_SCRIPTS.filter((h) => commands.has(h.command)).length;
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. */