claude-dev-env 1.57.2 → 1.58.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/bin/install.mjs +217 -27
  2. package/bin/install.test.mjs +344 -1
  3. package/hooks/blocking/intent_only_ending_blocker.py +155 -0
  4. package/hooks/blocking/session_handoff_blocker.py +190 -0
  5. package/hooks/blocking/test_intent_only_ending_blocker.py +175 -0
  6. package/hooks/blocking/test_session_handoff_blocker.py +312 -0
  7. package/hooks/hooks.json +10 -0
  8. package/hooks/hooks_constants/messages.py +4 -0
  9. package/hooks/hooks_constants/session_handoff_blocker_constants.py +10 -0
  10. package/hooks/workflow/auto_formatter.py +26 -1
  11. package/hooks/workflow/test_auto_formatter.py +134 -0
  12. package/package.json +1 -1
  13. package/rules/conservative-action.md +1 -0
  14. package/rules/long-horizon-autonomy.md +43 -0
  15. package/skills/autoconverge/SKILL.md +55 -4
  16. package/skills/autoconverge/reference/closing-report.md +44 -0
  17. package/skills/autoconverge/workflow/autoconverge_report_constants/__init__.py +0 -0
  18. package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +105 -0
  19. package/skills/autoconverge/workflow/converge.contract.test.mjs +30 -1
  20. package/skills/autoconverge/workflow/converge.mjs +0 -2
  21. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a11d903476b803493.jsonl +2 -0
  22. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a26213978adeef6fb.jsonl +2 -0
  23. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a3def0d15ed9d9110.jsonl +2 -0
  24. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a41f41b1b708ee3b7.jsonl +2 -0
  25. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a758b880abecc3ff7.jsonl +2 -0
  26. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a8897b89656b1bd16.jsonl +2 -0
  27. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-abd463d744a1437bc.jsonl +2 -0
  28. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-ad19d027ae8ee1816.jsonl +2 -0
  29. package/skills/autoconverge/workflow/fixtures/wf_run/workflows/wf_881252e6-700.json +259 -0
  30. package/skills/autoconverge/workflow/render_report.py +903 -0
  31. package/skills/autoconverge/workflow/test_render_report.py +484 -0
package/bin/install.mjs CHANGED
@@ -165,12 +165,31 @@ const INSTALL_GROUPS = {
165
165
  ...discoverDependencyGroups(),
166
166
  };
167
167
 
168
- function detectPython() {
169
- const candidates = [
168
+ /**
169
+ * Returns the ordered python interpreter candidates to probe for the given
170
+ * platform. On win32 the `py -3` launcher is probed first because it resolves
171
+ * through the Windows registry and is immune to the Microsoft Store
172
+ * `python.exe` App Execution Alias that otherwise gets baked into settings.json.
173
+ *
174
+ * @param {string} platform A value from `process.platform` (e.g. 'win32', 'linux').
175
+ * @returns {{command: string, versionFlag: string}[]} Candidates in probe order.
176
+ */
177
+ export function pythonCandidatesForPlatform(platform) {
178
+ const windowsOrder = [
179
+ { command: 'py -3', versionFlag: '--version' },
180
+ { command: 'python3', versionFlag: '--version' },
181
+ { command: 'python', versionFlag: '--version' },
182
+ ];
183
+ const defaultOrder = [
170
184
  { command: 'python3', versionFlag: '--version' },
171
185
  { command: 'python', versionFlag: '--version' },
172
186
  { command: 'py -3', versionFlag: '--version' },
173
187
  ];
188
+ return platform === 'win32' ? windowsOrder : defaultOrder;
189
+ }
190
+
191
+ function detectPython() {
192
+ const candidates = pythonCandidatesForPlatform(process.platform);
174
193
  for (const { command, versionFlag } of candidates) {
175
194
  try {
176
195
  const version = execSync(`${command} ${versionFlag}`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
@@ -235,22 +254,155 @@ function backupClaudeHubBeforeOverwrite(destPath, incomingPath) {
235
254
  return backupPath;
236
255
  }
237
256
 
238
- function mergeHooks(hooksSourceRoot, pythonCommand) {
239
- const hooksJsonPath = join(hooksSourceRoot, 'hooks', 'hooks.json');
240
- if (!existsSync(hooksJsonPath)) return 0;
241
- const hooksConfig = JSON.parse(readFileSync(hooksJsonPath, 'utf8'));
242
- const settingsPath = join(CLAUDE_HOME, 'settings.json');
243
- let settings = {};
244
- if (existsSync(settingsPath)) {
245
- const raw = readFileSync(settingsPath, 'utf8').trim();
246
- if (raw) {
247
- try { settings = JSON.parse(raw); }
248
- catch { console.error(' ERROR: settings.json is malformed JSON. Fix it and rerun.'); process.exit(1); }
257
+ /**
258
+ * Builds the set of hook script paths this installer manages, each relative to
259
+ * the hooks directory (e.g. 'blocking/code_rules_enforcer.py'), parsed from the
260
+ * `${CLAUDE_PLUGIN_ROOT}/hooks/<path>` references in hooks.json. Inline
261
+ * `python3 -c` commands reference the hooks directory without a script tail and
262
+ * contribute nothing.
263
+ *
264
+ * @param {{hooks: object}} hooksConfig Parsed hooks.json.
265
+ * @returns {Set<string>} Forward-slash relative script paths under hooks/.
266
+ */
267
+ export function managedHookScriptRelativePaths(hooksConfig) {
268
+ const relativePaths = new Set();
269
+ const scriptReferencePattern = /\$\{CLAUDE_PLUGIN_ROOT\}\/hooks\/(\S+?\.py)/g;
270
+ for (const matcherGroups of Object.values(hooksConfig.hooks)) {
271
+ for (const sourceGroup of matcherGroups) {
272
+ for (const hook of sourceGroup.hooks) {
273
+ for (const scriptMatch of hook.command.matchAll(scriptReferencePattern)) {
274
+ relativePaths.add(scriptMatch[1]);
275
+ }
276
+ }
277
+ }
278
+ }
279
+ return relativePaths;
280
+ }
281
+
282
+ /**
283
+ * Builds the union of managed hook script paths across the given package source
284
+ * roots by parsing each root's hooks/hooks.json. The installer copies hook
285
+ * scripts into ~/.claude/hooks/ but never copies hooks.json itself, so the
286
+ * uninstall and update-refresh purge must read the managed-hook set from the
287
+ * package source the same way the merge does, never from ~/.claude/hooks/.
288
+ * Roots without a hooks.json contribute nothing.
289
+ *
290
+ * @param {string[]} sourceRoots Package roots that hold a hooks/hooks.json.
291
+ * @returns {Set<string>} Forward-slash relative script paths under hooks/.
292
+ */
293
+ export function managedHookScriptRelativePathsFromSourceRoots(sourceRoots) {
294
+ const relativePaths = new Set();
295
+ for (const sourceRoot of sourceRoots) {
296
+ const hooksJsonPath = join(sourceRoot, 'hooks', 'hooks.json');
297
+ if (!existsSync(hooksJsonPath)) continue;
298
+ const hooksConfig = JSON.parse(readFileSync(hooksJsonPath, 'utf8'));
299
+ for (const relativePath of managedHookScriptRelativePaths(hooksConfig)) {
300
+ relativePaths.add(relativePath);
301
+ }
302
+ }
303
+ return relativePaths;
304
+ }
305
+
306
+ /**
307
+ * Resolves every package source root the installer can copy hooks from: this
308
+ * package plus each resolvable dependency package that ships hooks. The purge
309
+ * reads hooks.json from these roots so it prunes managed entries no matter which
310
+ * package contributed them.
311
+ *
312
+ * @returns {string[]} Distinct package roots, this package first.
313
+ */
314
+ function managedPackageSourceRoots() {
315
+ const dependencyRoots = Object.values(INSTALL_GROUPS)
316
+ .filter(group => group.packageRoot)
317
+ .map(group => group.packageRoot);
318
+ return [...new Set([PACKAGE_ROOT, ...dependencyRoots])];
319
+ }
320
+
321
+ /**
322
+ * Reports whether a settings.json hook command points at one of this installer's
323
+ * managed scripts, no matter how the home directory was written ($HOME, ~,
324
+ * ${HOME}, or an absolute path) or which path separator was used. Matching on
325
+ * the `/.claude/hooks/<relative>` tail lets a reinstall prune stale entries from
326
+ * earlier installs that used a different interpreter prefix, while leaving
327
+ * user-authored hooks outside the managed set untouched.
328
+ *
329
+ * @param {string} commandString The hook command from settings.json.
330
+ * @param {Set<string>} managedHookRelativePaths Managed script paths under hooks/.
331
+ * @returns {boolean} True when the command references a managed script.
332
+ */
333
+ export function commandReferencesManagedHook(commandString, managedHookRelativePaths) {
334
+ const normalizedCommand = commandString.replace(/\\/g, '/');
335
+ if (commandIsInlineManagedValidatorRunner(normalizedCommand)) {
336
+ return true;
337
+ }
338
+ for (const relativePath of managedHookRelativePaths) {
339
+ if (commandTailEndsAtManagedHook(normalizedCommand, relativePath)) {
340
+ return true;
249
341
  }
250
342
  }
343
+ return false;
344
+ }
345
+
346
+ /**
347
+ * Reports whether a command contains the `/.claude/hooks/<relative>` tail ending
348
+ * at a path boundary: end of string, or an argument separator (whitespace, quote,
349
+ * or semicolon). Anchoring the tail keeps a user hook whose path is the managed
350
+ * tail plus a suffix (`code_rules_enforcer.py.bak`, `a.py/extra/thing.py`) outside
351
+ * the managed set, so it is never pruned.
352
+ *
353
+ * @param {string} normalizedCommand Forward-slash-normalized hook command.
354
+ * @param {string} relativePath Managed script path under hooks/.
355
+ * @returns {boolean} True when the managed tail ends at a path boundary.
356
+ */
357
+ function commandTailEndsAtManagedHook(normalizedCommand, relativePath) {
358
+ const commandArgumentBoundary = /[\s'";]/;
359
+ const managedTail = `/.claude/hooks/${relativePath}`;
360
+ let searchStart = normalizedCommand.indexOf(managedTail);
361
+ while (searchStart !== -1) {
362
+ const characterAfterTail = normalizedCommand[searchStart + managedTail.length];
363
+ if (characterAfterTail === undefined || commandArgumentBoundary.test(characterAfterTail)) {
364
+ return true;
365
+ }
366
+ searchStart = normalizedCommand.indexOf(managedTail, searchStart + 1);
367
+ }
368
+ return false;
369
+ }
370
+
371
+ /**
372
+ * Reports whether a settings.json hook command is the inline validators-runner
373
+ * the installer writes in place of a standalone script. That hook inserts the
374
+ * managed hooks directory onto sys.path and imports run_all_validators, so it
375
+ * carries no `<script>.py` tail for managedHookScriptRelativePaths to record.
376
+ * Matching its shape lets a reinstall prune the prior copy before appending the
377
+ * freshly rewritten one, keeping the merge idempotent.
378
+ *
379
+ * @param {string} normalizedCommand Forward-slash-normalized hook command.
380
+ * @returns {boolean} True when the command is the inline validators runner.
381
+ */
382
+ export function commandIsInlineManagedValidatorRunner(normalizedCommand) {
383
+ const inlineValidatorRunnerMarker = /sys\.path\.insert\([^)]*\.claude\/hooks[^)]*\)[\s\S]*run_all_validators/;
384
+ return (
385
+ normalizedCommand.includes('/.claude/hooks') &&
386
+ inlineValidatorRunnerMarker.test(normalizedCommand)
387
+ );
388
+ }
389
+
390
+ /**
391
+ * Merges the installer's managed hook groups into a settings object in memory,
392
+ * pruning every prior managed hook (standalone script or inline validators
393
+ * runner) from each matcher group before appending the freshly rewritten copies
394
+ * so repeated merges stay idempotent. User-authored hooks in the same group are
395
+ * preserved untouched.
396
+ *
397
+ * @param {object} settings The parsed settings.json object (mutated in place).
398
+ * @param {{hooks: object}} hooksConfig Parsed hooks.json.
399
+ * @param {string} pluginRootDir Directory ${CLAUDE_PLUGIN_ROOT} resolves to.
400
+ * @param {string} pythonCommand Interpreter command that replaces python3.
401
+ * @returns {number} Count of matcher groups merged.
402
+ */
403
+ export function mergeHooksIntoSettings(settings, hooksConfig, pluginRootDir, pythonCommand) {
404
+ const managedHookRelativePaths = managedHookScriptRelativePaths(hooksConfig);
251
405
  if (!settings.hooks) settings.hooks = {};
252
- const installedHooksDir = join(CLAUDE_HOME, 'hooks');
253
- const pluginRootDir = CLAUDE_HOME;
254
406
  let groupCount = 0;
255
407
  for (const [eventType, matcherGroups] of Object.entries(hooksConfig.hooks)) {
256
408
  if (!settings.hooks[eventType]) settings.hooks[eventType] = [];
@@ -267,7 +419,7 @@ function mergeHooks(hooksSourceRoot, pythonCommand) {
267
419
  if (existingIndex >= 0) {
268
420
  const existing = settings.hooks[eventType][existingIndex];
269
421
  const userHooks = existing.hooks.filter(
270
- hook => !hook.command.includes(installedHooksDir.replace(/\\/g, '/'))
422
+ hook => !commandReferencesManagedHook(hook.command, managedHookRelativePaths)
271
423
  );
272
424
  settings.hooks[eventType][existingIndex] = {
273
425
  ...existing,
@@ -279,6 +431,51 @@ function mergeHooks(hooksSourceRoot, pythonCommand) {
279
431
  groupCount++;
280
432
  }
281
433
  }
434
+ return groupCount;
435
+ }
436
+
437
+ /**
438
+ * Removes every managed hook (standalone script or inline validators runner)
439
+ * from a settings object in memory, matching each command through
440
+ * commandReferencesManagedHook so entries written with any home-path style
441
+ * ($HOME, ~, ${HOME}, or absolute) and any path separator are pruned. Matcher
442
+ * groups left empty are dropped, and an empty hooks map is removed entirely.
443
+ * User-authored hooks outside the managed set are preserved untouched.
444
+ *
445
+ * @param {object} settings The parsed settings.json object (mutated in place).
446
+ * @param {Set<string>} managedHookRelativePaths Managed script paths under hooks/.
447
+ * @returns {void}
448
+ */
449
+ export function pruneManagedHooksFromSettings(settings, managedHookRelativePaths) {
450
+ if (!settings.hooks) return;
451
+ for (const [eventType, matcherGroups] of Object.entries(settings.hooks)) {
452
+ settings.hooks[eventType] = matcherGroups
453
+ .map(group => ({
454
+ ...group,
455
+ hooks: group.hooks.filter(
456
+ hook => !commandReferencesManagedHook(hook.command, managedHookRelativePaths)
457
+ ),
458
+ }))
459
+ .filter(group => group.hooks.length > 0);
460
+ if (settings.hooks[eventType].length === 0) delete settings.hooks[eventType];
461
+ }
462
+ if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
463
+ }
464
+
465
+ function mergeHooks(hooksSourceRoot, pythonCommand) {
466
+ const hooksJsonPath = join(hooksSourceRoot, 'hooks', 'hooks.json');
467
+ if (!existsSync(hooksJsonPath)) return 0;
468
+ const hooksConfig = JSON.parse(readFileSync(hooksJsonPath, 'utf8'));
469
+ const settingsPath = join(CLAUDE_HOME, 'settings.json');
470
+ let settings = {};
471
+ if (existsSync(settingsPath)) {
472
+ const raw = readFileSync(settingsPath, 'utf8').trim();
473
+ if (raw) {
474
+ try { settings = JSON.parse(raw); }
475
+ catch { console.error(' ERROR: settings.json is malformed JSON. Fix it and rerun.'); process.exit(1); }
476
+ }
477
+ }
478
+ const groupCount = mergeHooksIntoSettings(settings, hooksConfig, CLAUDE_HOME, pythonCommand);
282
479
  writeFileSync(settingsPath, JSON.stringify(settings, null, 4) + '\n');
283
480
  return groupCount;
284
481
  }
@@ -564,17 +761,10 @@ function purgeManagedInstallation({ requireManifest }) {
564
761
  if (existsSync(settingsPath)) {
565
762
  const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
566
763
  if (settings.hooks) {
567
- const installedHooksDir = join(CLAUDE_HOME, 'hooks').replace(/\\/g, '/');
568
- for (const [eventType, matcherGroups] of Object.entries(settings.hooks)) {
569
- settings.hooks[eventType] = matcherGroups
570
- .map(group => ({
571
- ...group,
572
- hooks: group.hooks.filter(hook => !hook.command.includes(installedHooksDir)),
573
- }))
574
- .filter(group => group.hooks.length > 0);
575
- if (settings.hooks[eventType].length === 0) delete settings.hooks[eventType];
576
- }
577
- if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
764
+ const managedHookRelativePaths = managedHookScriptRelativePathsFromSourceRoots(
765
+ managedPackageSourceRoots()
766
+ );
767
+ pruneManagedHooksFromSettings(settings, managedHookRelativePaths);
578
768
  writeFileSync(settingsPath, JSON.stringify(settings, null, 4) + '\n');
579
769
  console.log(' Hook entries removed from settings.json');
580
770
  }
@@ -5,7 +5,16 @@ import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'node:fs';
5
5
  import { tmpdir } from 'node:os';
6
6
  import { join } from 'node:path';
7
7
 
8
- import { collectPackageSourceConflicts, CONTENT_DIRECTORIES } from './install.mjs';
8
+ import {
9
+ collectPackageSourceConflicts,
10
+ CONTENT_DIRECTORIES,
11
+ pythonCandidatesForPlatform,
12
+ managedHookScriptRelativePaths,
13
+ managedHookScriptRelativePathsFromSourceRoots,
14
+ commandReferencesManagedHook,
15
+ mergeHooksIntoSettings,
16
+ pruneManagedHooksFromSettings,
17
+ } from './install.mjs';
9
18
 
10
19
 
11
20
  function createTemporaryGitRepository() {
@@ -172,3 +181,337 @@ test('collectPackageSourceConflicts surfaces both-added and deleted-by-them entr
172
181
  rmSync(repositoryRoot, { recursive: true, force: true });
173
182
  }
174
183
  });
184
+
185
+
186
+ test('pythonCandidatesForPlatform prefers py -3 ahead of python on win32 so the Microsoft Store stub is never probed first', () => {
187
+ const commands = pythonCandidatesForPlatform('win32').map(candidate => candidate.command);
188
+ assert.equal(commands[0], 'py -3');
189
+ assert.ok(commands.indexOf('py -3') < commands.indexOf('python'));
190
+ });
191
+
192
+
193
+ test('pythonCandidatesForPlatform keeps python3 first on non-Windows platforms', () => {
194
+ const commands = pythonCandidatesForPlatform('linux').map(candidate => candidate.command);
195
+ assert.equal(commands[0], 'python3');
196
+ });
197
+
198
+
199
+ test('pythonCandidatesForPlatform still offers python as a win32 fallback when py -3 and python3 are absent', () => {
200
+ const commands = pythonCandidatesForPlatform('win32').map(candidate => candidate.command);
201
+ assert.ok(commands.includes('python'));
202
+ });
203
+
204
+
205
+ const SAMPLE_HOOKS_CONFIG = {
206
+ hooks: {
207
+ Stop: [
208
+ {
209
+ matcher: '',
210
+ hooks: [
211
+ { command: 'python3 ${CLAUDE_PLUGIN_ROOT}/hooks/notification/attention_needed_notify.py', timeout: 15 },
212
+ { command: 'python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/hedging_language_blocker.py', timeout: 10 },
213
+ ],
214
+ },
215
+ ],
216
+ PreToolUse: [
217
+ {
218
+ matcher: 'Write',
219
+ hooks: [
220
+ { command: 'python3 -c "import sys; sys.path.insert(0, r\'${CLAUDE_PLUGIN_ROOT}/hooks\'); print(1)"', timeout: 5 },
221
+ ],
222
+ },
223
+ ],
224
+ },
225
+ };
226
+
227
+
228
+ test('managedHookScriptRelativePaths collects every installed hook script path and ignores inline -c commands', () => {
229
+ const relativePaths = managedHookScriptRelativePaths(SAMPLE_HOOKS_CONFIG);
230
+ assert.ok(relativePaths.has('notification/attention_needed_notify.py'));
231
+ assert.ok(relativePaths.has('blocking/hedging_language_blocker.py'));
232
+ assert.equal([...relativePaths].length, 2);
233
+ });
234
+
235
+
236
+ test('commandReferencesManagedHook matches managed scripts written with $HOME, ~, ${HOME}, and absolute path styles', () => {
237
+ const managedPaths = new Set(['notification/attention_needed_notify.py']);
238
+ assert.ok(commandReferencesManagedHook('python $HOME/.claude/hooks/notification/attention_needed_notify.py', managedPaths));
239
+ assert.ok(commandReferencesManagedHook('python ~/.claude/hooks/notification/attention_needed_notify.py', managedPaths));
240
+ assert.ok(commandReferencesManagedHook('python ${HOME}/.claude/hooks/notification/attention_needed_notify.py', managedPaths));
241
+ assert.ok(commandReferencesManagedHook('py -3 C:/Users/jonlo/.claude/hooks/notification/attention_needed_notify.py', managedPaths));
242
+ assert.ok(commandReferencesManagedHook('python /Users/jon/.claude/hooks/notification/attention_needed_notify.py', managedPaths));
243
+ });
244
+
245
+
246
+ test('commandReferencesManagedHook matches Windows backslash paths', () => {
247
+ const managedPaths = new Set(['blocking/hedging_language_blocker.py']);
248
+ assert.ok(commandReferencesManagedHook('py -3 C:\\Users\\jonlo\\.claude\\hooks\\blocking\\hedging_language_blocker.py', managedPaths));
249
+ });
250
+
251
+
252
+ test('commandReferencesManagedHook leaves user hooks outside the managed set untouched', () => {
253
+ const managedPaths = new Set(['notification/attention_needed_notify.py']);
254
+ assert.equal(commandReferencesManagedHook('python /home/me/custom-tools/my_own_hook.py', managedPaths), false);
255
+ assert.equal(commandReferencesManagedHook('py -3 ~/.claude/hooks/blocking/some_unmanaged_user_hook.py', managedPaths), false);
256
+ });
257
+
258
+
259
+ test('commandReferencesManagedHook leaves a user hook whose path is a managed tail plus a suffix untouched', () => {
260
+ const managedPaths = new Set(['blocking/code_rules_enforcer.py']);
261
+ assert.equal(commandReferencesManagedHook('python ~/.claude/hooks/blocking/code_rules_enforcer.py.bak', managedPaths), false);
262
+ assert.equal(commandReferencesManagedHook('python ~/.claude/hooks/blocking/code_rules_enforcer.py2', managedPaths), false);
263
+ });
264
+
265
+
266
+ test('commandReferencesManagedHook leaves a command whose managed tail is mid-path untouched', () => {
267
+ const managedPaths = new Set(['blocking/a.py']);
268
+ assert.equal(commandReferencesManagedHook('python /x/.claude/hooks/blocking/a.py/extra/thing.py', managedPaths), false);
269
+ });
270
+
271
+
272
+ test('commandReferencesManagedHook matches a managed script followed by a whitespace-separated argument', () => {
273
+ const managedPaths = new Set(['blocking/code_rules_enforcer.py']);
274
+ assert.ok(commandReferencesManagedHook('python ~/.claude/hooks/blocking/code_rules_enforcer.py PreToolUse', managedPaths));
275
+ });
276
+
277
+
278
+ test('commandReferencesManagedHook matches the rewritten inline validators-runner hook that carries no script tail', () => {
279
+ const managedPaths = new Set(['blocking/code_rules_enforcer.py']);
280
+ const rewrittenInlineCommand =
281
+ "py -3 -c \"import sys; sys.path.insert(0, r'C:/Users/jonlo/.claude/hooks'); from validators.run_all_validators import main; sys.exit(main())\"";
282
+ assert.ok(commandReferencesManagedHook(rewrittenInlineCommand, managedPaths));
283
+ });
284
+
285
+
286
+ test('commandReferencesManagedHook leaves an unmanaged inline -c command that imports a different module untouched', () => {
287
+ const managedPaths = new Set(['blocking/code_rules_enforcer.py']);
288
+ const userInlineCommand =
289
+ "python -c \"import sys; sys.path.insert(0, r'/home/me/tools'); from my_tools.runner import main; sys.exit(main())\"";
290
+ assert.equal(commandReferencesManagedHook(userInlineCommand, managedPaths), false);
291
+ });
292
+
293
+
294
+ function countManagedRunAllValidatorsHooks(settings) {
295
+ const writeEditGroups = (settings.hooks.PreToolUse || []).filter(
296
+ group => group.matcher === 'Write|Edit'
297
+ );
298
+ let runAllValidatorsCount = 0;
299
+ for (const group of writeEditGroups) {
300
+ for (const hook of group.hooks) {
301
+ if (hook.command.includes('run_all_validators')) {
302
+ runAllValidatorsCount++;
303
+ }
304
+ }
305
+ }
306
+ return runAllValidatorsCount;
307
+ }
308
+
309
+
310
+ test('mergeHooksIntoSettings is idempotent for the inline -c validators hook across two installs', () => {
311
+ const hooksConfig = {
312
+ hooks: {
313
+ 'PreToolUse': [
314
+ {
315
+ matcher: 'Write|Edit',
316
+ hooks: [
317
+ { command: 'python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/code_rules_enforcer.py', timeout: 30 },
318
+ {
319
+ command:
320
+ 'python3 -c "import sys; sys.path.insert(0, r\'${CLAUDE_PLUGIN_ROOT}/hooks\'); from validators.run_all_validators import main; sys.exit(main())"',
321
+ timeout: 15,
322
+ },
323
+ ],
324
+ },
325
+ ],
326
+ },
327
+ };
328
+ const settings = {};
329
+ const pluginRootDir = 'C:/Users/jonlo/.claude';
330
+
331
+ mergeHooksIntoSettings(settings, hooksConfig, pluginRootDir, 'py -3');
332
+ mergeHooksIntoSettings(settings, hooksConfig, pluginRootDir, 'py -3');
333
+
334
+ assert.equal(countManagedRunAllValidatorsHooks(settings), 1);
335
+ const writeEditGroup = settings.hooks.PreToolUse.find(group => group.matcher === 'Write|Edit');
336
+ assert.equal(writeEditGroup.hooks.length, 2);
337
+ });
338
+
339
+
340
+ test('mergeHooksIntoSettings preserves user hooks in a managed matcher group across re-merges', () => {
341
+ const hooksConfig = {
342
+ hooks: {
343
+ 'PreToolUse': [
344
+ {
345
+ matcher: 'Write|Edit',
346
+ hooks: [
347
+ {
348
+ command:
349
+ 'python3 -c "import sys; sys.path.insert(0, r\'${CLAUDE_PLUGIN_ROOT}/hooks\'); from validators.run_all_validators import main; sys.exit(main())"',
350
+ timeout: 15,
351
+ },
352
+ ],
353
+ },
354
+ ],
355
+ },
356
+ };
357
+ const userHookCommand = 'python /home/me/custom-tools/my_own_hook.py';
358
+ const settings = {
359
+ hooks: {
360
+ PreToolUse: [
361
+ { matcher: 'Write|Edit', hooks: [{ command: userHookCommand, timeout: 5 }] },
362
+ ],
363
+ },
364
+ };
365
+
366
+ mergeHooksIntoSettings(settings, hooksConfig, 'C:/Users/jonlo/.claude', 'py -3');
367
+ mergeHooksIntoSettings(settings, hooksConfig, 'C:/Users/jonlo/.claude', 'py -3');
368
+
369
+ const writeEditGroup = settings.hooks.PreToolUse.find(group => group.matcher === 'Write|Edit');
370
+ const userHookSurvivors = writeEditGroup.hooks.filter(hook => hook.command === userHookCommand);
371
+ assert.equal(userHookSurvivors.length, 1);
372
+ assert.equal(countManagedRunAllValidatorsHooks(settings), 1);
373
+ });
374
+
375
+ test('pruneManagedHooksFromSettings removes a managed hook command written with the ~ home-path style', () => {
376
+ const managedPaths = new Set(['blocking/code_rules_enforcer.py']);
377
+ const settings = {
378
+ hooks: {
379
+ PreToolUse: [
380
+ {
381
+ matcher: 'Write|Edit',
382
+ hooks: [
383
+ { command: 'python ~/.claude/hooks/blocking/code_rules_enforcer.py', timeout: 30 },
384
+ ],
385
+ },
386
+ ],
387
+ },
388
+ };
389
+
390
+ pruneManagedHooksFromSettings(settings, managedPaths);
391
+
392
+ assert.equal(settings.hooks, undefined);
393
+ });
394
+
395
+
396
+ test('pruneManagedHooksFromSettings removes managed hooks in every home-path and separator style while keeping user hooks', () => {
397
+ const managedPaths = new Set(['notification/attention_needed_notify.py']);
398
+ const userHookCommand = 'python /home/me/custom-tools/my_own_hook.py';
399
+ const settings = {
400
+ hooks: {
401
+ Stop: [
402
+ {
403
+ matcher: '',
404
+ hooks: [
405
+ { command: 'python $HOME/.claude/hooks/notification/attention_needed_notify.py', timeout: 15 },
406
+ { command: 'python ${HOME}/.claude/hooks/notification/attention_needed_notify.py', timeout: 15 },
407
+ { command: 'py -3 C:\\Users\\jonlo\\.claude\\hooks\\notification\\attention_needed_notify.py', timeout: 15 },
408
+ { command: userHookCommand, timeout: 5 },
409
+ ],
410
+ },
411
+ ],
412
+ },
413
+ };
414
+
415
+ pruneManagedHooksFromSettings(settings, managedPaths);
416
+
417
+ const stopGroup = settings.hooks.Stop.find(group => group.matcher === '');
418
+ assert.equal(stopGroup.hooks.length, 1);
419
+ assert.equal(stopGroup.hooks[0].command, userHookCommand);
420
+ });
421
+
422
+ function writeHooksJsonAtRoot(sourceRoot, hooksConfig) {
423
+ mkdirSync(join(sourceRoot, 'hooks'), { recursive: true });
424
+ writeFileSync(join(sourceRoot, 'hooks', 'hooks.json'), JSON.stringify(hooksConfig));
425
+ }
426
+
427
+
428
+ test('managedHookScriptRelativePathsFromSourceRoots reads each root hooks.json so purge matches every installed script', () => {
429
+ const sourceRoot = mkdtempSync(join(tmpdir(), 'cdev-purge-set-'));
430
+ try {
431
+ writeHooksJsonAtRoot(sourceRoot, SAMPLE_HOOKS_CONFIG);
432
+
433
+ const relativePaths = managedHookScriptRelativePathsFromSourceRoots([sourceRoot]);
434
+
435
+ assert.ok(relativePaths.has('notification/attention_needed_notify.py'));
436
+ assert.ok(relativePaths.has('blocking/hedging_language_blocker.py'));
437
+ assert.equal([...relativePaths].length, 2);
438
+ } finally {
439
+ rmSync(sourceRoot, { recursive: true, force: true });
440
+ }
441
+ });
442
+
443
+
444
+ test('managedHookScriptRelativePathsFromSourceRoots unions managed scripts across multiple package roots', () => {
445
+ const builtinRoot = mkdtempSync(join(tmpdir(), 'cdev-purge-builtin-'));
446
+ const dependencyRoot = mkdtempSync(join(tmpdir(), 'cdev-purge-dependency-'));
447
+ try {
448
+ writeHooksJsonAtRoot(builtinRoot, {
449
+ hooks: { Stop: [{ matcher: '', hooks: [{ command: 'python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/code_rules_enforcer.py' }] }] },
450
+ });
451
+ writeHooksJsonAtRoot(dependencyRoot, {
452
+ hooks: { PreToolUse: [{ matcher: 'Bash', hooks: [{ command: 'python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/pwsh_enforcer.py' }] }] },
453
+ });
454
+
455
+ const relativePaths = managedHookScriptRelativePathsFromSourceRoots([builtinRoot, dependencyRoot]);
456
+
457
+ assert.ok(relativePaths.has('blocking/code_rules_enforcer.py'));
458
+ assert.ok(relativePaths.has('blocking/pwsh_enforcer.py'));
459
+ assert.equal([...relativePaths].length, 2);
460
+ } finally {
461
+ rmSync(builtinRoot, { recursive: true, force: true });
462
+ rmSync(dependencyRoot, { recursive: true, force: true });
463
+ }
464
+ });
465
+
466
+
467
+ test('managedHookScriptRelativePathsFromSourceRoots skips roots whose hooks.json is absent', () => {
468
+ const rootWithoutHooks = mkdtempSync(join(tmpdir(), 'cdev-purge-empty-'));
469
+ try {
470
+ const relativePaths = managedHookScriptRelativePathsFromSourceRoots([rootWithoutHooks]);
471
+ assert.equal([...relativePaths].length, 0);
472
+ } finally {
473
+ rmSync(rootWithoutHooks, { recursive: true, force: true });
474
+ }
475
+ });
476
+
477
+
478
+ test('purge set sourced from package hooks.json prunes standalone managed script hooks and keeps user hooks', () => {
479
+ const sourceRoot = mkdtempSync(join(tmpdir(), 'cdev-purge-prune-'));
480
+ try {
481
+ writeHooksJsonAtRoot(sourceRoot, {
482
+ hooks: {
483
+ PreToolUse: [
484
+ {
485
+ matcher: 'Write|Edit',
486
+ hooks: [
487
+ { command: 'python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/code_rules_enforcer.py', timeout: 30 },
488
+ ],
489
+ },
490
+ ],
491
+ },
492
+ });
493
+ const userHookCommand = 'python /home/me/custom-tools/my_own_hook.py';
494
+ const settings = {
495
+ hooks: {
496
+ PreToolUse: [
497
+ {
498
+ matcher: 'Write|Edit',
499
+ hooks: [
500
+ { command: 'py -3 C:\\Users\\jonlo\\.claude\\hooks\\blocking\\code_rules_enforcer.py', timeout: 30 },
501
+ { command: userHookCommand, timeout: 5 },
502
+ ],
503
+ },
504
+ ],
505
+ },
506
+ };
507
+
508
+ const managedHookRelativePaths = managedHookScriptRelativePathsFromSourceRoots([sourceRoot]);
509
+ pruneManagedHooksFromSettings(settings, managedHookRelativePaths);
510
+
511
+ const writeEditGroup = settings.hooks.PreToolUse.find(group => group.matcher === 'Write|Edit');
512
+ assert.equal(writeEditGroup.hooks.length, 1);
513
+ assert.equal(writeEditGroup.hooks[0].command, userHookCommand);
514
+ } finally {
515
+ rmSync(sourceRoot, { recursive: true, force: true });
516
+ }
517
+ });