claude-dev-env 1.72.0 → 1.73.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 (50) hide show
  1. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +2 -2
  2. package/bin/install.mjs +73 -5
  3. package/bin/install.test.mjs +360 -4
  4. package/hooks/blocking/CLAUDE.md +3 -1
  5. package/hooks/blocking/claude_md_orphan_file_blocker.py +5 -6
  6. package/hooks/blocking/code_rules_dead_config_field.py +69 -56
  7. package/hooks/blocking/code_rules_docstrings.py +616 -0
  8. package/hooks/blocking/code_rules_enforcer.py +22 -0
  9. package/hooks/blocking/code_rules_shared.py +19 -0
  10. package/hooks/blocking/code_verifier_spawn_preflight_gate.py +420 -0
  11. package/hooks/blocking/md_to_html_blocker.py +7 -8
  12. package/hooks/blocking/open_questions_in_plans_blocker.py +5 -6
  13. package/hooks/blocking/plain_language_blocker.py +51 -16
  14. package/hooks/blocking/pr_converge_bugteam_enforcer.py +5 -5
  15. package/hooks/blocking/pre_tool_use_dispatcher.py +545 -0
  16. package/hooks/blocking/pytest_testpaths_orphan_blocker.py +358 -0
  17. package/hooks/blocking/state_description_blocker.py +75 -36
  18. package/hooks/blocking/test_code_rules_enforcer_dead_config_field.py +81 -0
  19. package/hooks/blocking/test_code_rules_enforcer_docstring_inline_literal_claim.py +93 -0
  20. package/hooks/blocking/test_code_rules_enforcer_docstring_step_dispatch.py +262 -0
  21. package/hooks/blocking/test_code_rules_enforcer_docstring_undefined_constant.py +253 -0
  22. package/hooks/blocking/test_code_rules_enforcer_module_docstring_roster.py +279 -0
  23. package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +456 -0
  24. package/hooks/blocking/test_pre_tool_use_dispatcher.py +816 -0
  25. package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +341 -0
  26. package/hooks/blocking/test_pytest_testpaths_orphan_blocker.py +247 -0
  27. package/hooks/blocking/test_shared_stdin_adoption.py +166 -0
  28. package/hooks/blocking/verdict_directory_write_blocker.py +12 -7
  29. package/hooks/hooks.json +9 -79
  30. package/hooks/hooks_constants/CLAUDE.md +3 -1
  31. package/hooks/hooks_constants/blocking_check_limits.py +61 -0
  32. package/hooks/hooks_constants/code_rules_enforcer_constants.py +6 -0
  33. package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +45 -0
  34. package/hooks/hooks_constants/dead_config_field_constants.py +5 -5
  35. package/hooks/hooks_constants/mypy_validator_cache_constants.py +36 -0
  36. package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +69 -0
  37. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +135 -0
  38. package/hooks/hooks_constants/pytest_testpaths_orphan_blocker_constants.py +79 -0
  39. package/hooks/validation/mypy_validator.py +215 -17
  40. package/hooks/validation/post_tool_use_dispatcher.py +344 -0
  41. package/hooks/validation/test_mypy_validator.py +184 -1
  42. package/hooks/validation/test_post_tool_use_dispatcher.py +610 -0
  43. package/hooks/workflow/test_auto_formatter.py +10 -9
  44. package/package.json +1 -1
  45. package/rules/docstring-prose-matches-implementation.md +2 -1
  46. package/skills/autoconverge/SKILL.md +93 -0
  47. package/skills/autoconverge/workflow/converge.mjs +27 -2
  48. package/skills/autoconverge/workflow/converge.path-aware.test.mjs +47 -0
  49. package/skills/autoconverge/workflow/converge_multi.mjs +161 -0
  50. package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +100 -0
@@ -23,9 +23,9 @@ Decomposition is by the **kind of docstring claim** that needs to be cross-check
23
23
  | O1 | Module-level responsibility verbs | A module docstring uses verbs (`detects`, `validates`, `enforces`, `recovers`, `parses`, `routes`) — every claimed responsibility is implemented by an exported symbol in the same module. Symbols absent from the module body should not appear as this module's responsibilities. |
24
24
  | O2 | Fixture docstring vs sibling-test behavior | An autouse / module-scope fixture docstring asserts an invariant (`readability is disabled`, `network is mocked`, `tmp_path is empty`). No sibling test in the same module explicitly opts out of the invariant. |
25
25
  | O3 | Predicate-name and -docstring vs body breadth | A boolean helper's name and docstring promise a narrow predicate. Walk the body's branches: every branch's `return True` path is consistent with the promised name. Bodies that accept inputs broader than the name (`_dir_value_resolves_to_shared_temp` also accepting HOME/TMP env-derived paths) are O3 findings. |
26
- | O4 | Step-ordering narrative | A docstring describes processing as `A then B then C`. Walk the body and confirm the call order matches. Mismatched order is an O4 finding regardless of whether the final output is the same. |
26
+ | O4 | Step-ordering narrative | A docstring describes processing as `A then B then C`. Walk the body and confirm the call order matches. Mismatched order is an O4 finding regardless of whether the final output is the same. A docstring step enumeration that names the body's linear steps but omits a corrective workflow step the body guards inside an `if`/`elif` branch (`if not await cancel_and_reinitiate_update(...): return`) is also an O4 finding: the reader trusts the step list to be complete and misses the conditional path. The branch-guarded-dispatch shape of this drift — a docstring that names two or more linear-step callees while the body guards a two-or-more-token dispatch callee inside a branch whose name the prose never spells out — is gated deterministically at Write/Edit time by `check_docstring_step_enumeration_dispatch_coverage` (`packages/claude-dev-env/hooks/blocking/code_rules_docstrings.py`), so the audit lane focuses on the step-ordering shapes the gate cannot match (re-ordered steps, plain unguarded steps the prose omits). |
27
27
  | O5 | Named-sentinel / filename references | A docstring names a sentinel marker, environment variable, filename, or magic string. Confirm the named token actually exists in the module body or in the repo's naming convention. |
28
- | O6 | Free-form `Args:`-adjacent claims | A docstring's `Returns:` / `Raises:` / `Note:` / `Example:` sections make claims (`returns shared-temp only`, `raises ValueError on missing key`). Verify each claim against the body. When a docstring enumerates the inputs a body counts (a "field counts as read when ..." list, a list of conditions treated as a match, a list of cases the body skips), list every union member and every suppressor the body applies (`read_names = a | b | c`, each early-return guard) and confirm each appears in the prose enumeration. A union member or suppressor the body applies but the prose omits is an O6 finding. The single-condition shared-fallback shape of this drift — a summary that scopes a fallback call to one condition while the body routes to that same call from two or more early-return guards — is gated deterministically at Write/Edit time by `check_docstring_fallback_branch_coverage`, so the audit lane focuses on the O6 shapes the gate cannot match. A `Returns:` that names the mechanism, tool, or output format the function produces (`instructing a StructuredOutput summary`, `returns a YAML document`, `emits a JSON object`) matches the artifact the body actually builds: a prompt body that asks the agent to "Return strictly a JSON object" while the docstring claims it "instruct[s] a StructuredOutput" summary is an O6 finding, because the named tool appears nowhere in the emitted text. See `../../rules/docstring-prose-matches-implementation.md`. |
28
+ | O6 | Free-form `Args:`-adjacent claims | A docstring's `Returns:` / `Raises:` / `Note:` / `Example:` sections make claims (`returns shared-temp only`, `raises ValueError on missing key`). Verify each claim against the body. When a docstring enumerates the inputs a body counts (a "field counts as read when ..." list, a list of conditions treated as a match, a list of cases the body skips), list every union member and every suppressor the body applies (`read_names = a | b | c`, each early-return guard) and confirm each appears in the prose enumeration. A union member or suppressor the body applies but the prose omits is an O6 finding. When a docstring sentence excludes a named category of input from what the function flags (`X are not dispatch steps`, `Y is not a match`), confirm the axis the prose excludes on is the axis the body's branch condition actually keys on. A body that flags a call when it sits inside an `If.test` guard, paired with prose that excludes by the call's receiver shape (`method-on-local calls inside a branch are not dispatch steps`), is an O6 finding: a guarded method-on-local call is flagged even though the prose lists it as excluded — the exclusion is keyed to the wrong axis. The single-condition shared-fallback shape of this drift — a summary that scopes a fallback call to one condition while the body routes to that same call from two or more early-return guards — is gated deterministically at Write/Edit time by `check_docstring_fallback_branch_coverage`, so the audit lane focuses on the O6 shapes the gate cannot match. A `Returns:` that names the mechanism, tool, or output format the function produces (`instructing a StructuredOutput summary`, `returns a YAML document`, `emits a JSON object`) matches the artifact the body actually builds: a prompt body that asks the agent to "Return strictly a JSON object" while the docstring claims it "instruct[s] a StructuredOutput" summary is an O6 finding, because the named tool appears nowhere in the emitted text. See `../../rules/docstring-prose-matches-implementation.md`. |
29
29
  | O7 | Module-doc-vs-split-module after refactor | When a refactor moves a responsibility to a sibling module, the originating module's docstring and the receiving module's docstring both describe the home of that responsibility. A module docstring should describe only the responsibilities it owns. |
30
30
  | O8 | Companion-doc ordering/content vs producer | When a PR changes a producer function's ordering or union, read that skill's companion `SKILL.md` and sibling `.md` docs for any sentence naming the same produced artifact (a file path, a JSON key, a named list). A doc sentence that claims the artifact is `sorted` / `alphabetical` / `in sorted order`, or holds `just the at-risk names` / `only the current set`, while the producer merges stored names with new names and appends — preserving file order, not re-sorting the union — is an O8 finding on both counts (wrong order claim, hidden merged-in entries). The finding stands even when the PR diff never touched the `.md` file, because the behavior change orphaned the doc claim. See `../../rules/docstring-prose-matches-implementation.md`. |
31
31
 
package/bin/install.mjs CHANGED
@@ -293,18 +293,59 @@ function backupClaudeHubBeforeOverwrite(destPath, incomingPath) {
293
293
  return backupPath;
294
294
  }
295
295
 
296
+ /**
297
+ * Hook script paths that were folded into the PreToolUse dispatcher in Stage 1.
298
+ * These entries no longer appear in hooks.json but must still be recognized as
299
+ * managed so a reinstall from an older settings shape prunes them and they do
300
+ * not double-run alongside the dispatcher.
301
+ */
302
+ export const FOLDED_HOOK_RELATIVE_PATHS = new Set([
303
+ 'blocking/write_existing_file_blocker.py',
304
+ 'blocking/sensitive_file_protector.py',
305
+ 'validation/hook_format_validator.py',
306
+ 'blocking/code_rules_enforcer.py',
307
+ 'blocking/tdd_enforcer.py',
308
+ 'blocking/windows_rmtree_blocker.py',
309
+ 'blocking/state_description_blocker.py',
310
+ 'blocking/subprocess_budget_completeness.py',
311
+ 'blocking/hook_prose_detector_consistency.py',
312
+ 'blocking/verified_commit_message_accuracy_blocker.py',
313
+ 'blocking/workflow_substitution_slot_blocker.py',
314
+ 'blocking/claude_md_orphan_file_blocker.py',
315
+ 'blocking/pytest_testpaths_orphan_blocker.py',
316
+ 'blocking/open_questions_in_plans_blocker.py',
317
+ 'blocking/plain_language_blocker.py',
318
+ ]);
319
+
320
+ /**
321
+ * Hook script paths that were folded into the PostToolUse dispatcher. These
322
+ * after-write hooks no longer appear in hooks.json but must still be recognized
323
+ * as managed so a reinstall from an older settings shape prunes them and they do
324
+ * not double-run alongside the PostToolUse dispatcher.
325
+ */
326
+ export const POST_FOLDED_HOOK_RELATIVE_PATHS = new Set([
327
+ 'validation/mypy_validator.py',
328
+ 'workflow/auto_formatter.py',
329
+ 'workflow/doc_gist_auto_publish.py',
330
+ ]);
331
+
296
332
  /**
297
333
  * Builds the set of hook script paths this installer manages, each relative to
298
334
  * the hooks directory (e.g. 'blocking/code_rules_enforcer.py'), parsed from the
299
335
  * `${CLAUDE_PLUGIN_ROOT}/hooks/<path>` references in hooks.json. Inline
300
336
  * `python3 -c` commands reference the hooks directory without a script tail and
301
- * contribute nothing.
337
+ * contribute nothing. Also includes every path from FOLDED_HOOK_RELATIVE_PATHS
338
+ * and POST_FOLDED_HOOK_RELATIVE_PATHS so a reinstall from an older settings shape
339
+ * prunes both the PreToolUse and the PostToolUse folded entries.
302
340
  *
303
341
  * @param {{hooks: object}} hooksConfig Parsed hooks.json.
304
342
  * @returns {Set<string>} Forward-slash relative script paths under hooks/.
305
343
  */
306
344
  export function managedHookScriptRelativePaths(hooksConfig) {
307
- const relativePaths = new Set();
345
+ const relativePaths = new Set([
346
+ ...FOLDED_HOOK_RELATIVE_PATHS,
347
+ ...POST_FOLDED_HOOK_RELATIVE_PATHS,
348
+ ]);
308
349
  const scriptReferencePattern = /\$\{CLAUDE_PLUGIN_ROOT\}\/hooks\/(\S+?\.py)/g;
309
350
  for (const matcherGroups of Object.values(hooksConfig.hooks)) {
310
351
  for (const sourceGroup of matcherGroups) {
@@ -426,12 +467,38 @@ export function commandIsInlineManagedValidatorRunner(normalizedCommand) {
426
467
  );
427
468
  }
428
469
 
470
+ /**
471
+ * Strips every managed hook (standalone script or inline validators runner) from
472
+ * all existing matcher groups of one event in a settings object, dropping any
473
+ * group left empty. Run before the per-group merge so a managed hook that an
474
+ * upgrade moves to a different matcher group is pruned from its old group rather
475
+ * than left to double-run. User-authored hooks outside the managed set stay.
476
+ *
477
+ * @param {object} settings The parsed settings.json object (mutated in place).
478
+ * @param {string} eventType The lifecycle event whose groups are pruned.
479
+ * @param {Set<string>} managedHookRelativePaths Managed script paths under hooks/.
480
+ * @returns {void}
481
+ */
482
+ function pruneManagedHooksFromEvent(settings, eventType, managedHookRelativePaths) {
483
+ const existingGroups = settings.hooks[eventType];
484
+ if (!existingGroups) return;
485
+ settings.hooks[eventType] = existingGroups
486
+ .map(group => ({
487
+ ...group,
488
+ hooks: group.hooks.filter(
489
+ hook => !commandReferencesManagedHook(hook.command, managedHookRelativePaths)
490
+ ),
491
+ }))
492
+ .filter(group => group.hooks.length > 0);
493
+ }
494
+
429
495
  /**
430
496
  * Merges the installer's managed hook groups into a settings object in memory,
431
497
  * pruning every prior managed hook (standalone script or inline validators
432
- * runner) from each matcher group before appending the freshly rewritten copies
433
- * so repeated merges stay idempotent. User-authored hooks in the same group are
434
- * preserved untouched.
498
+ * runner) from each event's existing matcher groups before appending the freshly
499
+ * rewritten copies so repeated merges stay idempotent and a managed hook moved to
500
+ * a new matcher group does not double-run. User-authored hooks are preserved
501
+ * untouched.
435
502
  *
436
503
  * @param {object} settings The parsed settings.json object (mutated in place).
437
504
  * @param {{hooks: object}} hooksConfig Parsed hooks.json.
@@ -445,6 +512,7 @@ export function mergeHooksIntoSettings(settings, hooksConfig, pluginRootDir, pyt
445
512
  let groupCount = 0;
446
513
  for (const [eventType, matcherGroups] of Object.entries(hooksConfig.hooks)) {
447
514
  if (!settings.hooks[eventType]) settings.hooks[eventType] = [];
515
+ pruneManagedHooksFromEvent(settings, eventType, managedHookRelativePaths);
448
516
  for (const sourceGroup of matcherGroups) {
449
517
  const rewrittenHooks = sourceGroup.hooks.map(hook => {
450
518
  let command = hook.command;
@@ -1,7 +1,7 @@
1
1
  import { test } from 'node:test';
2
2
  import { strict as assert } from 'node:assert';
3
3
  import { execFileSync } from 'node:child_process';
4
- import { mkdtempSync, rmSync, mkdirSync, writeFileSync, symlinkSync } from 'node:fs';
4
+ import { mkdtempSync, rmSync, mkdirSync, writeFileSync, symlinkSync, readFileSync } from 'node:fs';
5
5
  import { tmpdir } from 'node:os';
6
6
  import { join } from 'node:path';
7
7
  import { pathToFileURL } from 'node:url';
@@ -9,6 +9,8 @@ import { pathToFileURL } from 'node:url';
9
9
  import {
10
10
  collectPackageSourceConflicts,
11
11
  CONTENT_DIRECTORIES,
12
+ FOLDED_HOOK_RELATIVE_PATHS,
13
+ POST_FOLDED_HOOK_RELATIVE_PATHS,
12
14
  pythonCandidatesForPlatform,
13
15
  isWindowsStorePythonStub,
14
16
  interpreterCommandFromPath,
@@ -361,7 +363,9 @@ test('managedHookScriptRelativePaths collects every installed hook script path a
361
363
  const relativePaths = managedHookScriptRelativePaths(SAMPLE_HOOKS_CONFIG);
362
364
  assert.ok(relativePaths.has('notification/attention_needed_notify.py'));
363
365
  assert.ok(relativePaths.has('blocking/hedging_language_blocker.py'));
364
- assert.equal([...relativePaths].length, 2);
366
+ for (const foldedPath of FOLDED_HOOK_RELATIVE_PATHS) {
367
+ assert.ok(relativePaths.has(foldedPath), `folded hook ${foldedPath} must always be in the managed set`);
368
+ }
365
369
  });
366
370
 
367
371
 
@@ -566,7 +570,9 @@ test('managedHookScriptRelativePathsFromSourceRoots reads each root hooks.json s
566
570
 
567
571
  assert.ok(relativePaths.has('notification/attention_needed_notify.py'));
568
572
  assert.ok(relativePaths.has('blocking/hedging_language_blocker.py'));
569
- assert.equal([...relativePaths].length, 2);
573
+ for (const foldedPath of FOLDED_HOOK_RELATIVE_PATHS) {
574
+ assert.ok(relativePaths.has(foldedPath), `folded hook ${foldedPath} must always be in the managed set`);
575
+ }
570
576
  } finally {
571
577
  rmSync(sourceRoot, { recursive: true, force: true });
572
578
  }
@@ -588,7 +594,9 @@ test('managedHookScriptRelativePathsFromSourceRoots unions managed scripts acros
588
594
 
589
595
  assert.ok(relativePaths.has('blocking/code_rules_enforcer.py'));
590
596
  assert.ok(relativePaths.has('blocking/pwsh_enforcer.py'));
591
- assert.equal([...relativePaths].length, 2);
597
+ for (const foldedPath of FOLDED_HOOK_RELATIVE_PATHS) {
598
+ assert.ok(relativePaths.has(foldedPath), `folded hook ${foldedPath} must always be in the managed set`);
599
+ }
592
600
  } finally {
593
601
  rmSync(builtinRoot, { recursive: true, force: true });
594
602
  rmSync(dependencyRoot, { recursive: true, force: true });
@@ -647,3 +655,351 @@ test('purge set sourced from package hooks.json prunes standalone managed script
647
655
  rmSync(sourceRoot, { recursive: true, force: true });
648
656
  }
649
657
  });
658
+
659
+
660
+ const DISPATCHER_HOOKS_CONFIG = {
661
+ hooks: {
662
+ PreToolUse: [
663
+ {
664
+ matcher: 'Write|Edit',
665
+ hooks: [
666
+ {
667
+ type: 'command',
668
+ command: 'python3 -c "import sys; sys.path.insert(0, r\'${CLAUDE_PLUGIN_ROOT}/hooks\'); from validators.run_all_validators import main; sys.exit(main())"',
669
+ timeout: 15,
670
+ },
671
+ ],
672
+ },
673
+ {
674
+ matcher: 'Write|Edit|MultiEdit',
675
+ hooks: [
676
+ {
677
+ type: 'command',
678
+ command: 'python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/pre_tool_use_dispatcher.py',
679
+ timeout: 60,
680
+ },
681
+ ],
682
+ },
683
+ ],
684
+ },
685
+ };
686
+
687
+ const OLD_FOLDED_HOOKS_SETTINGS = {
688
+ hooks: {
689
+ PreToolUse: [
690
+ {
691
+ matcher: 'Write|Edit',
692
+ hooks: [
693
+ { type: 'command', command: 'py -3 C:/Users/x/.claude/hooks/blocking/write_existing_file_blocker.py', timeout: 10 },
694
+ { type: 'command', command: 'py -3 C:/Users/x/.claude/hooks/blocking/sensitive_file_protector.py', timeout: 10 },
695
+ { type: 'command', command: 'py -3 C:/Users/x/.claude/hooks/validation/hook_format_validator.py', timeout: 15 },
696
+ { type: 'command', command: 'py -3 C:/Users/x/.claude/hooks/blocking/code_rules_enforcer.py', timeout: 30 },
697
+ { type: 'command', command: 'py -3 -c "import sys; sys.path.insert(0, r\'C:/Users/x/.claude/hooks\'); from validators.run_all_validators import main; sys.exit(main())"', timeout: 15 },
698
+ { type: 'command', command: 'py -3 C:/Users/x/.claude/hooks/blocking/tdd_enforcer.py', timeout: 10 },
699
+ { type: 'command', command: 'py -3 C:/Users/x/.claude/hooks/blocking/windows_rmtree_blocker.py', timeout: 10 },
700
+ { type: 'command', command: 'py -3 C:/Users/x/.claude/hooks/blocking/state_description_blocker.py', timeout: 10 },
701
+ { type: 'command', command: 'py -3 C:/Users/x/.claude/hooks/blocking/subprocess_budget_completeness.py', timeout: 10 },
702
+ { type: 'command', command: 'py -3 C:/Users/x/.claude/hooks/blocking/hook_prose_detector_consistency.py', timeout: 10 },
703
+ { type: 'command', command: 'py -3 C:/Users/x/.claude/hooks/blocking/verified_commit_message_accuracy_blocker.py', timeout: 10 },
704
+ ],
705
+ },
706
+ {
707
+ matcher: 'Write|Edit|MultiEdit',
708
+ hooks: [
709
+ { type: 'command', command: 'py -3 C:/Users/x/.claude/hooks/blocking/workflow_substitution_slot_blocker.py', timeout: 10 },
710
+ { type: 'command', command: 'py -3 C:/Users/x/.claude/hooks/blocking/claude_md_orphan_file_blocker.py', timeout: 10 },
711
+ { type: 'command', command: 'py -3 C:/Users/x/.claude/hooks/blocking/pytest_testpaths_orphan_blocker.py', timeout: 10 },
712
+ { type: 'command', command: 'py -3 C:/Users/x/.claude/hooks/blocking/open_questions_in_plans_blocker.py', timeout: 10 },
713
+ { type: 'command', command: 'py -3 C:/Users/x/.claude/hooks/blocking/plain_language_blocker.py', timeout: 10 },
714
+ ],
715
+ },
716
+ ],
717
+ },
718
+ };
719
+
720
+
721
+ test('FOLDED_HOOK_RELATIVE_PATHS contains all 15 hooks removed from hooks.json', () => {
722
+ assert.equal(FOLDED_HOOK_RELATIVE_PATHS.size, 15);
723
+ assert.ok(FOLDED_HOOK_RELATIVE_PATHS.has('blocking/write_existing_file_blocker.py'));
724
+ assert.ok(FOLDED_HOOK_RELATIVE_PATHS.has('blocking/plain_language_blocker.py'));
725
+ assert.ok(FOLDED_HOOK_RELATIVE_PATHS.has('blocking/code_rules_enforcer.py'));
726
+ assert.ok(FOLDED_HOOK_RELATIVE_PATHS.has('blocking/pytest_testpaths_orphan_blocker.py'));
727
+ });
728
+
729
+
730
+ test('FOLDED_HOOK_RELATIVE_PATHS lists every hook the PreToolUse dispatcher hosts', () => {
731
+ const dispatcherHostedHooks = [
732
+ 'blocking/write_existing_file_blocker.py',
733
+ 'blocking/sensitive_file_protector.py',
734
+ 'validation/hook_format_validator.py',
735
+ 'blocking/code_rules_enforcer.py',
736
+ 'blocking/tdd_enforcer.py',
737
+ 'blocking/windows_rmtree_blocker.py',
738
+ 'blocking/state_description_blocker.py',
739
+ 'blocking/subprocess_budget_completeness.py',
740
+ 'blocking/hook_prose_detector_consistency.py',
741
+ 'blocking/verified_commit_message_accuracy_blocker.py',
742
+ 'blocking/workflow_substitution_slot_blocker.py',
743
+ 'blocking/claude_md_orphan_file_blocker.py',
744
+ 'blocking/pytest_testpaths_orphan_blocker.py',
745
+ 'blocking/open_questions_in_plans_blocker.py',
746
+ 'blocking/plain_language_blocker.py',
747
+ ];
748
+ for (const hostedPath of dispatcherHostedHooks) {
749
+ assert.ok(
750
+ FOLDED_HOOK_RELATIVE_PATHS.has(hostedPath),
751
+ `dispatcher-hosted hook ${hostedPath} must be in FOLDED_HOOK_RELATIVE_PATHS so a reinstall prunes its stale standalone entry and it does not double-run`
752
+ );
753
+ }
754
+ assert.equal(
755
+ FOLDED_HOOK_RELATIVE_PATHS.size,
756
+ dispatcherHostedHooks.length,
757
+ 'FOLDED_HOOK_RELATIVE_PATHS must hold exactly the dispatcher-hosted hooks, no more, no fewer'
758
+ );
759
+ });
760
+
761
+
762
+ test('managedHookScriptRelativePaths includes the dispatcher and all folded hooks so old entries are prunable', () => {
763
+ const relativePaths = managedHookScriptRelativePaths(DISPATCHER_HOOKS_CONFIG);
764
+ assert.ok(relativePaths.has('blocking/pre_tool_use_dispatcher.py'), 'dispatcher must be in managed set');
765
+ for (const foldedPath of FOLDED_HOOK_RELATIVE_PATHS) {
766
+ assert.ok(relativePaths.has(foldedPath), `folded hook ${foldedPath} must be in managed set`);
767
+ }
768
+ });
769
+
770
+
771
+ test('mergeHooksIntoSettings into old folded-hooks settings yields exactly one dispatcher entry and no folded entries', () => {
772
+ const settings = JSON.parse(JSON.stringify(OLD_FOLDED_HOOKS_SETTINGS));
773
+ mergeHooksIntoSettings(settings, DISPATCHER_HOOKS_CONFIG, 'C:/Users/x/.claude', 'py -3');
774
+
775
+ const allPreToolUseGroups = settings.hooks.PreToolUse || [];
776
+ const allHookCommands = allPreToolUseGroups.flatMap(group => group.hooks.map(hook => hook.command));
777
+
778
+ const allDispatcherCommands = allHookCommands.filter(cmd => cmd.includes('pre_tool_use_dispatcher.py'));
779
+ assert.equal(allDispatcherCommands.length, 1, 'exactly one dispatcher entry must be present');
780
+
781
+ for (const foldedPath of FOLDED_HOOK_RELATIVE_PATHS) {
782
+ const foldedBasename = foldedPath.split('/').pop();
783
+ const foldedCommands = allHookCommands.filter(
784
+ cmd => cmd.includes(foldedBasename) && !cmd.includes('pre_tool_use_dispatcher')
785
+ );
786
+ assert.equal(foldedCommands.length, 0, `folded hook ${foldedBasename} must not appear as a separate entry`);
787
+ }
788
+ });
789
+
790
+
791
+ test('mergeHooksIntoSettings into old folded-hooks settings preserves the inline validators runner', () => {
792
+ const settings = JSON.parse(JSON.stringify(OLD_FOLDED_HOOKS_SETTINGS));
793
+ mergeHooksIntoSettings(settings, DISPATCHER_HOOKS_CONFIG, 'C:/Users/x/.claude', 'py -3');
794
+
795
+ assert.equal(
796
+ countManagedRunAllValidatorsHooks(settings),
797
+ 1,
798
+ 'exactly one run_all_validators hook must remain in Write|Edit',
799
+ );
800
+ });
801
+
802
+
803
+ test('mergeHooksIntoSettings is idempotent when run twice against an already-updated settings shape', () => {
804
+ const settings = JSON.parse(JSON.stringify(OLD_FOLDED_HOOKS_SETTINGS));
805
+ mergeHooksIntoSettings(settings, DISPATCHER_HOOKS_CONFIG, 'C:/Users/x/.claude', 'py -3');
806
+ mergeHooksIntoSettings(settings, DISPATCHER_HOOKS_CONFIG, 'C:/Users/x/.claude', 'py -3');
807
+
808
+ const allPreToolUseGroups = settings.hooks.PreToolUse || [];
809
+ const allHookCommands = allPreToolUseGroups.flatMap(group => group.hooks.map(hook => hook.command));
810
+
811
+ const allDispatcherCommands = allHookCommands.filter(cmd => cmd.includes('pre_tool_use_dispatcher.py'));
812
+ assert.equal(allDispatcherCommands.length, 1, 'dispatcher must appear exactly once after two merges');
813
+ assert.equal(countManagedRunAllValidatorsHooks(settings), 1, 'run_all_validators must appear exactly once after two merges');
814
+ });
815
+
816
+
817
+ test('shipped hooks.json matches the dispatcher design: dispatchers registered, run_all_validators retained, no folded hook standalone', () => {
818
+ const shippedHooksConfig = JSON.parse(
819
+ readFileSync(new URL('../hooks/hooks.json', import.meta.url), 'utf8')
820
+ );
821
+
822
+ const allPreToolUseGroups = shippedHooksConfig.hooks.PreToolUse || [];
823
+ const allPreCommands = allPreToolUseGroups.flatMap(group => group.hooks.map(hook => hook.command));
824
+ const preDispatcherCommands = allPreCommands.filter(cmd => cmd.includes('pre_tool_use_dispatcher.py'));
825
+ assert.equal(preDispatcherCommands.length, 1, 'shipped hooks.json must register the PreToolUse dispatcher exactly once');
826
+
827
+ assert.equal(
828
+ countManagedRunAllValidatorsHooks(shippedHooksConfig),
829
+ 1,
830
+ 'shipped hooks.json must retain the inline run_all_validators runner in Write|Edit',
831
+ );
832
+
833
+ const allPostToolUseGroups = shippedHooksConfig.hooks.PostToolUse || [];
834
+ const postDispatcherCommands = allPostToolUseGroups
835
+ .flatMap(group => group.hooks.map(hook => hook.command))
836
+ .filter(cmd => cmd.includes('post_tool_use_dispatcher.py'));
837
+ assert.equal(postDispatcherCommands.length, 1, 'shipped hooks.json must register the PostToolUse dispatcher exactly once');
838
+
839
+ const writePathCommands = allPreToolUseGroups
840
+ .filter(group => /Write|Edit|MultiEdit/.test(group.matcher || ''))
841
+ .flatMap(group => group.hooks.map(hook => hook.command));
842
+ for (const foldedPath of FOLDED_HOOK_RELATIVE_PATHS) {
843
+ const foldedBasename = foldedPath.split('/').pop();
844
+ const standaloneFoldedCommands = writePathCommands.filter(
845
+ cmd => cmd.includes(foldedBasename) && !cmd.includes('pre_tool_use_dispatcher')
846
+ );
847
+ assert.equal(
848
+ standaloneFoldedCommands.length,
849
+ 0,
850
+ `folded hook ${foldedBasename} must not ship as a standalone write-path PreToolUse entry`,
851
+ );
852
+ }
853
+ });
854
+
855
+
856
+ const POST_DISPATCHER_HOOKS_CONFIG = {
857
+ hooks: {
858
+ PostToolUse: [
859
+ {
860
+ matcher: 'Write|Edit',
861
+ hooks: [
862
+ {
863
+ type: 'command',
864
+ command: 'python3 ${CLAUDE_PLUGIN_ROOT}/hooks/validation/post_tool_use_dispatcher.py',
865
+ timeout: 60,
866
+ },
867
+ ],
868
+ },
869
+ ],
870
+ },
871
+ };
872
+
873
+ const OLD_POST_FOLDED_HOOKS_SETTINGS = {
874
+ hooks: {
875
+ PostToolUse: [
876
+ {
877
+ matcher: 'Write|Edit',
878
+ hooks: [
879
+ { type: 'command', command: 'py -3 C:/Users/x/.claude/hooks/validation/mypy_validator.py', timeout: 30 },
880
+ { type: 'command', command: 'py -3 C:/Users/x/.claude/hooks/workflow/auto_formatter.py', timeout: 30 },
881
+ { type: 'command', command: 'py -3 C:/Users/x/.claude/hooks/workflow/doc_gist_auto_publish.py C:/Users/x/.claude', timeout: 60 },
882
+ ],
883
+ },
884
+ ],
885
+ },
886
+ };
887
+
888
+
889
+ test('POST_FOLDED_HOOK_RELATIVE_PATHS contains the three after-write hooks folded into the PostToolUse dispatcher', () => {
890
+ assert.equal(POST_FOLDED_HOOK_RELATIVE_PATHS.size, 3);
891
+ assert.ok(POST_FOLDED_HOOK_RELATIVE_PATHS.has('validation/mypy_validator.py'));
892
+ assert.ok(POST_FOLDED_HOOK_RELATIVE_PATHS.has('workflow/auto_formatter.py'));
893
+ assert.ok(POST_FOLDED_HOOK_RELATIVE_PATHS.has('workflow/doc_gist_auto_publish.py'));
894
+ });
895
+
896
+
897
+ test('managedHookScriptRelativePaths includes the PostToolUse dispatcher and all post-folded hooks so old entries are prunable', () => {
898
+ const relativePaths = managedHookScriptRelativePaths(POST_DISPATCHER_HOOKS_CONFIG);
899
+ assert.ok(relativePaths.has('validation/post_tool_use_dispatcher.py'), 'PostToolUse dispatcher must be in managed set');
900
+ for (const foldedPath of POST_FOLDED_HOOK_RELATIVE_PATHS) {
901
+ assert.ok(relativePaths.has(foldedPath), `post-folded hook ${foldedPath} must be in managed set`);
902
+ }
903
+ });
904
+
905
+
906
+ test('mergeHooksIntoSettings into the old three PostToolUse entries yields exactly one post_tool_use_dispatcher entry and none of the three', () => {
907
+ const settings = JSON.parse(JSON.stringify(OLD_POST_FOLDED_HOOKS_SETTINGS));
908
+ mergeHooksIntoSettings(settings, POST_DISPATCHER_HOOKS_CONFIG, 'C:/Users/x/.claude', 'py -3');
909
+
910
+ const writeEditGroup = settings.hooks.PostToolUse.find(group => group.matcher === 'Write|Edit');
911
+ const allCommands = writeEditGroup.hooks.map(hook => hook.command);
912
+
913
+ const dispatcherCommands = allCommands.filter(cmd => cmd.includes('post_tool_use_dispatcher.py'));
914
+ assert.equal(dispatcherCommands.length, 1, 'exactly one PostToolUse dispatcher entry must be present');
915
+
916
+ for (const foldedPath of POST_FOLDED_HOOK_RELATIVE_PATHS) {
917
+ const foldedBasename = foldedPath.split('/').pop();
918
+ const foldedCommands = allCommands.filter(cmd => cmd.includes(foldedBasename));
919
+ assert.equal(foldedCommands.length, 0, `post-folded hook ${foldedBasename} must not appear as a separate entry`);
920
+ }
921
+ });
922
+
923
+
924
+ test('mergeHooksIntoSettings installs the PostToolUse dispatcher cleanly into an empty settings object', () => {
925
+ const settings = {};
926
+ mergeHooksIntoSettings(settings, POST_DISPATCHER_HOOKS_CONFIG, 'C:/Users/x/.claude', 'py -3');
927
+
928
+ const writeEditGroup = settings.hooks.PostToolUse.find(group => group.matcher === 'Write|Edit');
929
+ assert.equal(writeEditGroup.hooks.length, 1);
930
+ assert.equal(
931
+ writeEditGroup.hooks[0].command,
932
+ 'py -3 C:/Users/x/.claude/hooks/validation/post_tool_use_dispatcher.py',
933
+ );
934
+ });
935
+
936
+
937
+ test('mergeHooksIntoSettings is idempotent for the PostToolUse dispatcher across two installs', () => {
938
+ const settings = JSON.parse(JSON.stringify(OLD_POST_FOLDED_HOOKS_SETTINGS));
939
+ mergeHooksIntoSettings(settings, POST_DISPATCHER_HOOKS_CONFIG, 'C:/Users/x/.claude', 'py -3');
940
+ mergeHooksIntoSettings(settings, POST_DISPATCHER_HOOKS_CONFIG, 'C:/Users/x/.claude', 'py -3');
941
+
942
+ const writeEditGroup = settings.hooks.PostToolUse.find(group => group.matcher === 'Write|Edit');
943
+ const dispatcherCommands = writeEditGroup.hooks.filter(hook => hook.command.includes('post_tool_use_dispatcher.py'));
944
+ assert.equal(dispatcherCommands.length, 1, 'PostToolUse dispatcher must appear exactly once after two merges');
945
+ assert.equal(writeEditGroup.hooks.length, 1);
946
+ });
947
+
948
+
949
+ const PRE_DISPATCHER_ONLY_HOOKS_CONFIG = {
950
+ hooks: {
951
+ PreToolUse: [
952
+ {
953
+ matcher: 'Write|Edit|MultiEdit',
954
+ hooks: [
955
+ {
956
+ type: 'command',
957
+ command: 'python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/pre_tool_use_dispatcher.py',
958
+ timeout: 60,
959
+ },
960
+ ],
961
+ },
962
+ ],
963
+ },
964
+ };
965
+
966
+ const SETTINGS_WITH_INLINE_RUNNER = {
967
+ hooks: {
968
+ PreToolUse: [
969
+ {
970
+ matcher: 'Write|Edit',
971
+ hooks: [
972
+ {
973
+ type: 'command',
974
+ command: "py -3 -c \"import sys; sys.path.insert(0, r'C:/Users/x/.claude/hooks'); from validators.run_all_validators import main; sys.exit(main())\"",
975
+ timeout: 15,
976
+ },
977
+ ],
978
+ },
979
+ {
980
+ matcher: 'Write|Edit|MultiEdit',
981
+ hooks: [
982
+ { type: 'command', command: 'py -3 C:/Users/x/.claude/hooks/blocking/pre_tool_use_dispatcher.py', timeout: 60 },
983
+ ],
984
+ },
985
+ ],
986
+ },
987
+ };
988
+
989
+
990
+ test('mergeHooksIntoSettings prunes the inline run_all_validators runner when the new shape no longer ships it', () => {
991
+ const settings = JSON.parse(JSON.stringify(SETTINGS_WITH_INLINE_RUNNER));
992
+ mergeHooksIntoSettings(settings, PRE_DISPATCHER_ONLY_HOOKS_CONFIG, 'C:/Users/x/.claude', 'py -3');
993
+
994
+ assert.equal(countManagedRunAllValidatorsHooks(settings), 0, 'the inline validators runner must be pruned');
995
+
996
+ const writeEditGroup = (settings.hooks.PreToolUse || []).find(group => group.matcher === 'Write|Edit');
997
+ if (writeEditGroup) {
998
+ const runnerSurvivors = writeEditGroup.hooks.filter(hook => hook.command.includes('run_all_validators'));
999
+ assert.equal(runnerSurvivors.length, 0);
1000
+ }
1001
+
1002
+ const dispatcherGroup = settings.hooks.PreToolUse.find(group => group.matcher === 'Write|Edit|MultiEdit');
1003
+ const dispatcherCommands = dispatcherGroup.hooks.filter(hook => hook.command.includes('pre_tool_use_dispatcher.py'));
1004
+ assert.equal(dispatcherCommands.length, 1, 'the PreToolUse dispatcher must remain exactly once');
1005
+ });
@@ -26,7 +26,7 @@ The check modules it calls are the `code_rules_<concern>.py` files below.
26
26
  | `code_rules_comments.py` | No new inline comments; no deletion of existing ones |
27
27
  | `code_rules_constants_config.py` | Constants must live in `config/`; file-global constant use-count |
28
28
  | `code_rules_dead_argparse_argument.py` | Argparse arguments with no references in the same file |
29
- | `code_rules_dead_config_field.py` | Dataclass config fields with no live references |
29
+ | `code_rules_dead_config_field.py` | `*Config` / `*Selectors` dataclass fields with no live references |
30
30
  | `code_rules_dead_dataclass_field.py` | Dataclass fields with no consuming references |
31
31
  | `code_rules_dead_module_constant.py` | `UPPER_SNAKE` constants in `*_constants.py` modules with no importers |
32
32
  | `code_rules_docstrings.py` | Google-style docstrings; `Args:` section matches signature; fallback-branch coverage |
@@ -59,6 +59,7 @@ The check modules it calls are the `code_rules_<concern>.py` files below.
59
59
  | `block_main_commit.py` | PreToolUse (Bash) | `git commit`/`git push` directly to `main` |
60
60
  | `bot_mention_comment_blocker.py` | PreToolUse (Write/Edit) | PR review comments that @-mention a bot |
61
61
  | `claude_md_orphan_file_blocker.py` | PreToolUse (Write/Edit/MultiEdit) | Per-directory `CLAUDE.md` table cells naming a bare filename absent from the directory subtree |
62
+ | `code_verifier_spawn_preflight_gate.py` | PreToolUse (Agent) | Spawning the `code-verifier` subagent when the branch has a merge conflict vs its base or a CODE_RULES violation on a working-tree-added line |
62
63
  | `convergence_gate_blocker.py` | PreToolUse (Bash) | Convergence workflow actions on a conflicting PR |
63
64
  | `destructive_command_blocker.py` | PreToolUse (Bash/PowerShell) | Shell commands with destructive literals (`rm -rf`, `git reset --hard`, etc.) |
64
65
  | `es_exe_path_rewriter.py` | PreToolUse | Rewrites paths referencing `.exe` under the Everything search path |
@@ -74,6 +75,7 @@ The check modules it calls are the `code_rules_<concern>.py` files below.
74
75
  | `pr_converge_bugteam_enforcer.py` | PreToolUse | Enforces that bugteam runs in parallel with bugbot in pr-converge loops |
75
76
  | `pr_description_enforcer.py` | PreToolUse (Bash) | `gh pr create`/`edit` without a PR-description-writer-authored body |
76
77
  | `precommit_code_rules_gate.py` | PreToolUse (Bash) | Staged changes that fail the CODE_RULES gate at commit time |
78
+ | `pytest_testpaths_orphan_blocker.py` | PreToolUse (Write/Edit/MultiEdit) | New `test_*.py` files created under a directory absent from a package's explicit pytest `testpaths` allowlist |
77
79
  | `question_to_user_enforcer.py` | Stop | User-directed questions not routed through `AskUserQuestion` |
78
80
  | `sensitive_file_protector.py` | PreToolUse (Write/Edit) | Writes to sensitive credential or config files |
79
81
  | `session_handoff_blocker.py` | Stop | Responses suggesting a new session mid-task |
@@ -39,6 +39,9 @@ from hooks_constants.claude_md_orphan_file_blocker_constants import ( # noqa: E
39
39
  SEPARATOR_CELL_PATTERN,
40
40
  TABLE_ROW_PATTERN,
41
41
  )
42
+ from hooks_constants.pre_tool_use_stdin import ( # noqa: E402
43
+ read_hook_input_dictionary_from_stdin,
44
+ )
42
45
 
43
46
 
44
47
  def is_claude_md_file(file_path: str) -> bool:
@@ -588,12 +591,8 @@ def _emit_hook_result(all_hook_data: dict, output_stream: TextIO) -> None:
588
591
 
589
592
  def main() -> None:
590
593
  """Read the PreToolUse payload from stdin and block an orphan-file CLAUDE.md."""
591
- try:
592
- input_data = json.load(sys.stdin)
593
- except json.JSONDecodeError:
594
- sys.exit(0)
595
-
596
- if not isinstance(input_data, dict):
594
+ input_data = read_hook_input_dictionary_from_stdin()
595
+ if input_data is None:
597
596
  sys.exit(0)
598
597
 
599
598
  tool_name = input_data.get("tool_name", "")