claude-dev-env 2.7.1 → 2.9.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 (127) hide show
  1. package/CLAUDE.md +7 -1
  2. package/_shared/CLAUDE.md +1 -0
  3. package/_shared/advisor/advisor-protocol.md +19 -9
  4. package/_shared/pr-loop/audit-contract.md +4 -4
  5. package/_shared/pr-loop/precatch-rubric.md +2 -2
  6. package/_shared/process-tree/CLAUDE.md +41 -0
  7. package/_shared/process-tree/scripts/config/process_tree_scripts_constants/__init__.py +1 -0
  8. package/_shared/process-tree/scripts/config/process_tree_scripts_constants/process_tree_kill_constants.py +27 -0
  9. package/_shared/process-tree/scripts/process_tree_kill.py +141 -0
  10. package/_shared/process-tree/scripts/pyproject.toml +16 -0
  11. package/_shared/process-tree/scripts/test_process_tree_kill.py +278 -0
  12. package/agents/clean-coder.md +9 -19
  13. package/agents/code-quality-agent.md +6 -5
  14. package/agents/deep-research.md +7 -24
  15. package/agents/docs-agent.md +1 -27
  16. package/agents/issue-tracker.md +1 -7
  17. package/agents/skill-writer-agent.md +1 -2
  18. package/agents/test_agent_frontmatter.py +335 -12
  19. package/docs/CODE_RULES.md +4 -2
  20. package/docs/references/CLAUDE.md +2 -2
  21. package/docs/references/advisor-tool.md +44 -6
  22. package/docs/references/team-advisor-skill.md +14 -8
  23. package/hooks/blocking/CLAUDE.md +2 -0
  24. package/hooks/blocking/fable_spawn_gate.py +187 -0
  25. package/hooks/blocking/piped_pytest_blocker.py +1223 -0
  26. package/hooks/blocking/plain_language_blocker.py +287 -15
  27. package/hooks/blocking/test_fable_spawn_gate.py +374 -0
  28. package/hooks/blocking/test_piped_pytest_blocker.py +587 -0
  29. package/hooks/blocking/test_plain_language_blocker.py +277 -2
  30. package/hooks/blocking/test_pre_tool_use_dispatcher.py +34 -2
  31. package/hooks/git-hooks/CLAUDE.md +2 -2
  32. package/hooks/git-hooks/git_hooks_constants/__init__.py +28 -0
  33. package/hooks/git-hooks/pre_push.py +343 -54
  34. package/hooks/git-hooks/test_pre_push.py +852 -6
  35. package/hooks/hooks.json +9 -19
  36. package/hooks/hooks_constants/CLAUDE.md +2 -0
  37. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +1 -0
  38. package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -0
  39. package/hooks/hooks_constants/fable_spawn_gate_constants.py +62 -0
  40. package/hooks/hooks_constants/piped_pytest_blocker_constants.py +360 -0
  41. package/hooks/hooks_constants/plain_language_blocker_constants.py +64 -1
  42. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +10 -0
  43. package/hooks/hooks_constants/shell_command_segments.py +1 -1
  44. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +1 -0
  45. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +27 -0
  46. package/hooks/hooks_constants/test_prose_metrics_parity.py +124 -0
  47. package/output-styles/CLAUDE.md +17 -0
  48. package/output-styles/caveman-agent.md +37 -0
  49. package/package.json +2 -1
  50. package/rules/CLAUDE.md +1 -0
  51. package/rules/ask-user-question-required.md +26 -0
  52. package/rules/claims-as-quotes.md +65 -0
  53. package/rules/code-standards.md +33 -7
  54. package/rules/eli11-replies.md +1 -1
  55. package/scripts/CLAUDE.md +3 -3
  56. package/scripts/_code_review_test_support.py +6 -0
  57. package/scripts/check.ps1 +18 -5
  58. package/scripts/claude_chain_runner.py +203 -31
  59. package/scripts/codec_forwarding_test_support.py +2 -0
  60. package/scripts/dev_env_scripts_constants/CLAUDE.md +4 -4
  61. package/scripts/dev_env_scripts_constants/claude_chain_constants.py +38 -0
  62. package/scripts/dev_env_scripts_constants/code_review_constants.py +403 -2
  63. package/scripts/dev_env_scripts_constants/grok_run_ledger_constants.py +50 -0
  64. package/scripts/dev_env_scripts_constants/grok_worker_constants.py +132 -24
  65. package/scripts/grok_headless_runner.py +16 -83
  66. package/scripts/grok_patch_artifacts.py +123 -0
  67. package/scripts/grok_run_ledger.py +318 -0
  68. package/scripts/spawn_grok_batch.py +591 -10
  69. package/scripts/test_claude_chain_runner.py +358 -0
  70. package/scripts/test_grok_headless_runner.py +18 -63
  71. package/scripts/test_grok_patch_artifacts.py +82 -0
  72. package/scripts/test_grok_run_ledger.py +116 -0
  73. package/scripts/test_invoke_code_review.py +298 -0
  74. package/scripts/test_resolve_worker_spawn.py +6 -0
  75. package/scripts/test_spawn_grok_batch.py +396 -0
  76. package/scripts/tests/CLAUDE.md +1 -0
  77. package/scripts/tests/test_grok_worker_constants.py +59 -0
  78. package/skills/CLAUDE.md +4 -2
  79. package/skills/_shared/CLAUDE.md +37 -4
  80. package/skills/_shared/advisor/CLAUDE.md +9 -0
  81. package/skills/_shared/advisor/advisor-protocol.md +5 -0
  82. package/skills/_shared/advisor/scripts/README.md +9 -0
  83. package/skills/_shared/end-of-run-gotcha-recommendations.md +156 -0
  84. package/skills/_shared/pr-loop/CLAUDE.md +18 -1
  85. package/skills/_shared/pr-loop/audit-contract.md +5 -0
  86. package/skills/_shared/pr-loop/audit-reply-template.md +5 -0
  87. package/skills/_shared/pr-loop/code-rules-gate.md +5 -0
  88. package/skills/_shared/pr-loop/fix-protocol.md +5 -0
  89. package/skills/_shared/pr-loop/gh-payloads.md +5 -0
  90. package/skills/_shared/pr-loop/post-audit-thread-contract.md +5 -0
  91. package/skills/_shared/pr-loop/precatch-rubric.md +5 -0
  92. package/skills/_shared/pr-loop/scripts/CLAUDE.md +8 -1
  93. package/skills/_shared/pr-loop/scripts/RUNTIME_SCRIPTS.md +29 -0
  94. package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +46 -0
  95. package/skills/_shared/pr-loop/state-schema.md +5 -0
  96. package/skills/_shared/pr-loop/worker-spawn.md +5 -0
  97. package/skills/autoconverge/workflow/converge.contract.test.mjs +105 -2
  98. package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +34 -1
  99. package/skills/autoconverge/workflow/converge.mjs +66 -33
  100. package/skills/codex-review/scripts/codex_review_scripts_constants/codex_usage_probe_constants.py +0 -4
  101. package/skills/codex-review/scripts/codex_usage_probe.py +20 -33
  102. package/skills/codex-review/scripts/run_codex_review.py +16 -64
  103. package/skills/codex-review/scripts/test_codex_usage_probe.py +46 -41
  104. package/skills/codex-review/scripts/test_run_codex_review.py +1 -33
  105. package/skills/e-code-review/SKILL.md +15 -9
  106. package/skills/e-code-review/reference/fix.md +29 -7
  107. package/skills/e-code-review/reference/loop.md +230 -14
  108. package/skills/e-code-review/reference/low.md +33 -15
  109. package/skills/e-code-review/reference/medium.md +55 -21
  110. package/skills/e-code-review/reference/runner-selection.md +40 -0
  111. package/skills/e-code-review/reference/xhigh.md +30 -12
  112. package/skills/e-code-review/scripts/e_code_review_scripts_constants/__init__.py +1 -0
  113. package/skills/e-code-review/scripts/e_code_review_scripts_constants/grok_code_review_constants.py +55 -0
  114. package/skills/e-code-review/scripts/grok_code_review.py +221 -0
  115. package/skills/e-code-review/scripts/test_grok_code_review.py +212 -0
  116. package/skills/fresh-branch/CLAUDE.md +5 -5
  117. package/skills/fresh-branch/SKILL.md +14 -6
  118. package/skills/fresh-branch/scripts/create_fresh_branch.py +122 -39
  119. package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +10 -3
  120. package/skills/fresh-branch/scripts/test_create_fresh_branch.py +251 -0
  121. package/skills/grok-spawn/SKILL.md +10 -0
  122. package/skills/orchestrator/SKILL.md +9 -1
  123. package/skills/orchestrator-refresh/SKILL.md +5 -1
  124. package/skills/task-build/reference/tool-routing.md +3 -0
  125. package/skills/team-advisor/SKILL.md +23 -41
  126. package/system-prompts/software-engineer.xml +6 -3
  127. package/skills/test_markdown_link_integrity.py +0 -107
package/hooks/hooks.json CHANGED
@@ -8,7 +8,7 @@
8
8
  {
9
9
  "type": "command",
10
10
  "command": "python3 -c \"import sys; sys.path.insert(0, r'${CLAUDE_PLUGIN_ROOT}/hooks'); from validators.run_all_validators import main; sys.exit(main())\" --pre-tool-use",
11
- "timeout": 15
11
+ "timeout": 30
12
12
  }
13
13
  ]
14
14
  },
@@ -22,21 +22,6 @@
22
22
  }
23
23
  ]
24
24
  },
25
- {
26
- "matcher": "Edit",
27
- "hooks": [
28
- {
29
- "type": "command",
30
- "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/advisory/refactor_guard.py",
31
- "timeout": 15
32
- },
33
- {
34
- "type": "command",
35
- "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/advisory/migration_safety_advisor.py",
36
- "timeout": 15
37
- }
38
- ]
39
- },
40
25
  {
41
26
  "matcher": "Bash|PowerShell",
42
27
  "hooks": [
@@ -99,6 +84,11 @@
99
84
  "type": "command",
100
85
  "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/code_verifier_spawn_preflight_gate.py",
101
86
  "timeout": 30
87
+ },
88
+ {
89
+ "type": "command",
90
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/fable_spawn_gate.py",
91
+ "timeout": 10
102
92
  }
103
93
  ]
104
94
  },
@@ -248,7 +238,7 @@
248
238
  {
249
239
  "type": "command",
250
240
  "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/observability/session_file_edit_tracker.py",
251
- "timeout": 10
241
+ "timeout": 30
252
242
  }
253
243
  ]
254
244
  },
@@ -258,7 +248,7 @@
258
248
  {
259
249
  "type": "command",
260
250
  "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/workflow/investigation_tracker_reset.py",
261
- "timeout": 10
251
+ "timeout": 30
262
252
  }
263
253
  ]
264
254
  },
@@ -268,7 +258,7 @@
268
258
  {
269
259
  "type": "command",
270
260
  "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/gh_pr_author_restore.py",
271
- "timeout": 20
261
+ "timeout": 60
272
262
  }
273
263
  ]
274
264
  }
@@ -32,6 +32,7 @@ Shared constant modules imported by hooks throughout the `hooks/` tree. Each fil
32
32
  | `duplicate_rmtree_helper_blocker_constants.py` | Sanctioned Windows-safe rmtree helper names, the definition pattern, and the exempt-path set for the duplicate-rmtree-helper blocker |
33
33
  | `dynamic_stderr_handler.py` | `DynamicStderrHandler` — a logging handler that resolves `sys.stderr` at emit time (for testability) |
34
34
  | `eli11_reply_enforcer_constants.py` | Reply-shape thresholds (word cap, enforced word floor, bullet cap, lead-line window, per-line word cap, over-packed line cap), the `Long form:` escape prefix, the bullet / numbered-step / table-row / link-target / word patterns, the imperative instruction verbs, and the user-facing notice for the ELI11 reply-shape Stop hook |
35
+ | `fable_spawn_gate_constants.py` | The spawn tool-name set, the fable model alias and the model-id segment split pattern, the `FABLE-SPAWN-AUTHORIZED` authorization token, the advisor-protocol document path, payload field names, the deny message, and the deny `additionalContext` recovery text for the fable-tier spawn gate |
35
36
  | `gh_pr_author_swap_constants.py` | Constants for the PR-author swap enforcement hooks |
36
37
  | `hardcoded_user_path_constants.py` | Patterns for detecting hardcoded home-directory paths |
37
38
  | `harness_scratchpad_constants.py` | Fixed path components (`claude` / `claude-` user-directory name and prefix, `scratchpad` leaf name), the PreToolUse session-id payload key, and the `CLAUDE_CODE_SESSION_ID` environment variable name that the code-rules and TDD gates use to recognize an existing harness session scratchpad directory |
@@ -53,6 +54,7 @@ Shared constant modules imported by hooks throughout the `hooks/` tree. Each fil
53
54
  | `paired_test_coverage_constants.py` | Test-directory name, stem-test filename affixes, test-file globs, exempt public-function names, scan budget, coverage threshold, and guidance text for the public-function paired-test coverage check |
54
55
  | `path_rewriter_constants.py` | Path rewriting patterns for the Everything-search path rewriter |
55
56
  | `pii_prevention_constants.py` | Patterns, allowlists, path exemptions, and deny-message text for `pii_prevention_blocker` |
57
+ | `piped_pytest_blocker_constants.py` | Bash tool name, pytest program basenames and the python-interpreter basename pattern, the pipe and segment-reset operator token sets, and the deny message for the piped-pytest blocker |
56
58
  | `plain_language_blocker_constants.py` | The list of heavy words and their everyday replacements |
57
59
  | `pr_converge_bugteam_enforcer_constants.py` | State keys and timing config for the bugteam-parallel enforcer |
58
60
  | `pr_converge_bugteam_enforcer_state.py` | State-file helpers for the bugteam enforcer |
@@ -57,6 +57,7 @@ ALL_BASH_HOSTED_HOOK_ENTRIES: tuple[BashHostedHookEntry, ...] = (
57
57
  BashHostedHookEntry("blocking/destructive_command_blocker.py", ALL_BASH_ONLY_TOOL_NAMES),
58
58
  BashHostedHookEntry("blocking/gh_body_arg_blocker.py", ALL_BASH_ONLY_TOOL_NAMES),
59
59
  BashHostedHookEntry("blocking/shell_substitution_blocker.py", ALL_BASH_ONLY_TOOL_NAMES),
60
+ BashHostedHookEntry("blocking/piped_pytest_blocker.py", ALL_BASH_ONLY_TOOL_NAMES),
60
61
  BashHostedHookEntry(
61
62
  "blocking/unscoped_search_blocker.py", ALL_BASH_AND_POWERSHELL_TOOL_NAMES
62
63
  ),
@@ -24,6 +24,7 @@ ALL_CONFIG_DIRECTORY_NAMES = frozenset(
24
24
  "usage_pause_constants",
25
25
  "prototype_scripts_constants",
26
26
  "codex_review_scripts_constants",
27
+ "e_code_review_scripts_constants",
27
28
  "dev_env_scripts_constants",
28
29
  "fresh_branch_scripts_constants",
29
30
  }
@@ -0,0 +1,62 @@
1
+ """Constants for the fable-tier subagent spawn gate.
2
+
3
+ The gate denies an ``Agent`` or ``Task`` spawn at the fable tier whose
4
+ ``prompt`` lacks ``FABLE_SPAWN_AUTHORIZATION_MARKER``. ``DENY_REASON`` points
5
+ at the protocol document rather than quoting the token, so pasting a denial
6
+ into a retry prompt authorizes nothing.
7
+ ``MODEL_SEGMENT_SPLIT_PATTERN`` finds that tier in a full model id too::
8
+
9
+ fable -> ['fable'] flag: fable tier
10
+ claude-fable-5 -> ['claude', 'fable', '5'] flag: fable tier
11
+ claude-sonnet-4 -> ['claude', 'sonnet', '4'] ok: another tier
12
+
13
+ Every literal the gate body reads lives here. ``AGENT_TOOL_NAME`` and
14
+ ``TASK_TOOL_NAME`` come from the sibling spawn-gate constants, so the spawn
15
+ gates share one source for both tool names.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import re
21
+
22
+ from hooks_constants.code_verifier_spawn_preflight_gate_constants import TASK_TOOL_NAME
23
+ from hooks_constants.pr_converge_bugteam_enforcer_constants import AGENT_TOOL_NAME
24
+
25
+ ALL_SPAWN_TOOL_NAMES: frozenset[str] = frozenset({AGENT_TOOL_NAME, TASK_TOOL_NAME})
26
+
27
+ FABLE_MODEL_ALIAS: str = "fable"
28
+ MODEL_SEGMENT_SPLIT_PATTERN: re.Pattern[str] = re.compile(r"[^a-z0-9]+")
29
+ FABLE_SPAWN_AUTHORIZATION_MARKER: str = "FABLE-SPAWN-AUTHORIZED"
30
+ ADVISOR_PROTOCOL_DOCUMENT_PATH: str = "_shared/advisor/advisor-protocol.md"
31
+
32
+ TOOL_NAME_FIELD_NAME: str = "tool_name"
33
+ TOOL_INPUT_FIELD_NAME: str = "tool_input"
34
+ MODEL_FIELD_NAME: str = "model"
35
+ PROMPT_FIELD_NAME: str = "prompt"
36
+
37
+ CALLING_HOOK_NAME: str = "fable_spawn_gate.py"
38
+ HOOK_EVENT_NAME: str = "PreToolUse"
39
+
40
+ DENY_REASON: str = (
41
+ "BLOCKED [fable-spawn-gate]: this subagent spawn names the fable tier in "
42
+ "its model field and carries no authorization marker. To authorize a "
43
+ f"fable bind, put the token named in {ADVISOR_PROTOCOL_DOCUMENT_PATH} as "
44
+ "plain text in the spawn prompt (substring match). Otherwise set model "
45
+ "to opus, sonnet, or haiku. The gate checks only model tier and marker "
46
+ "presence — it does not verify which session placed the token."
47
+ )
48
+
49
+ DENY_ADDITIONAL_CONTEXT: str = (
50
+ "[fable-spawn-gate] This Agent/Task spawn was denied: the model field "
51
+ "names the fable tier and the spawn prompt carries no authorization "
52
+ "marker. Preferred recovery: re-spawn at model opus, or an "
53
+ "opus-equivalent tier (sonnet/haiku when opus is unavailable) — those "
54
+ "tiers need no marker. Authorized fable bind only: put the token named "
55
+ f"in {ADVISOR_PROTOCOL_DOCUMENT_PATH} as plain text in the spawn prompt "
56
+ "(substring match), then retry. The gate checks only model tier and "
57
+ "marker presence. Do not paste this denial into a retry prompt — it "
58
+ "does not authorize the spawn."
59
+ )
60
+
61
+ DENY_PREVIEW_TEMPLATE: str = "model={model_text} marker_present=False"
62
+ MAXIMUM_PREVIEW_MODEL_LENGTH: int = 40
@@ -0,0 +1,360 @@
1
+ """Constants for the piped-pytest PreToolUse Bash blocker.
2
+
3
+ Holds the tool names, the pytest program basenames and the python-interpreter
4
+ basename pattern, the module-run flag and module name, the pipe and
5
+ segment-reset operator token sets, the line-continuation and physical-line
6
+ patterns, the heredoc-opener pattern that marks a script body, the
7
+ comment-start pattern that ends a line early, the parenthesis-group counters
8
+ that join a multi-line subshell, the group-closing reserved words a pipe reads
9
+ a group's status through, the body-introducing reserved words that stand ahead
10
+ of the command whose status a compound reports, the single characters the hot
11
+ path tests before it parses, the option-token pattern that separates a wrapper's
12
+ flags from its first operand, the wrapper commands whose run passes through to
13
+ the program behind them, the separator that rejoins a wrapper's argument
14
+ tokens, the empty commenter set that leaves ``#`` to the comment-start pattern,
15
+ the quote characters stripped before a basename read, and the deny message.
16
+ Segment helpers come from ``shell_command_segments.py``.
17
+
18
+ Two wrapper shapes reach the program behind them differently. ``sudo`` and
19
+ ``uvx`` take their own option flags and then the command, so the step-over drops
20
+ the flags. ``uv``, ``poetry``, ``pipenv``, ``pdm``, ``hatch``, ``rye``, and
21
+ ``coverage`` take the literal ``run`` subcommand first, so the step-over reads
22
+ that word and passes only when it is there — ``uv sync`` and ``coverage report``
23
+ keep the wrapper as its own program. ``uv`` spells the same pass-through as
24
+ ``uv tool run`` too, so ``TOOL_SUBCOMMAND_NAME`` names the word the step-over
25
+ reads before it looks for ``run``. A ``run`` subcommand takes its own flags too,
26
+ so the step-over drops those before it reads the program.
27
+
28
+ An option that takes a separate value swallows the token after it, so
29
+ ``sudo -u someone pytest`` runs pytest while ``uv run --with pytest mypy .``
30
+ runs mypy. ``ALL_VALUE_TAKING_WRAPPER_OPTION_FLAGS`` names those flags so the
31
+ operand scan reads past the value rather than mistaking it for the program, and
32
+ ``ALL_VALUE_TAKING_SHELL_OPTION_FLAGS`` does the same for the shells that run a
33
+ command string, so the ``-c`` behind ``bash -o pipefail`` stays visible. POSIX
34
+ lets short options cluster, and a cluster's value is the next token only when
35
+ the value-taking letter ends the cluster — ``sudo -nu ci`` takes ``ci`` while
36
+ ``sudo -nuci`` carries the value glued on. ``SHORT_OPTION_CLUSTER_PATTERN``
37
+ marks the tokens that walk letter by letter for that reading.
38
+ ``--`` ends an option list outright: every token after it is an operand however
39
+ it is spelled, so ``bash -- -c script`` runs a script named ``-c`` rather than a
40
+ command string.
41
+
42
+ A POSIX shell reads ``-c`` inside a cluster as well, so ``bash -euc 'pytest'``
43
+ runs the command string the same way ``bash -c 'pytest'`` does.
44
+ ``ALL_CLUSTERED_STRING_EXEC_OPTION_LETTERS`` names the letters that carry that
45
+ reading, and ``ALL_SHORT_OPTION_CLUSTERING_SHELL_BASENAMES`` limits it to the
46
+ shells that cluster: PowerShell spells its options as words, where the ``c`` in
47
+ ``-NonInteractive`` is a letter of a name rather than an option of its own. The
48
+ letter check reads the token in its own case, because ``-C`` is bash's noclobber
49
+ switch while ``-c`` takes the command string.
50
+
51
+ A shell ends a pipeable compound command with a reserved word as well as with
52
+ ``)`` or ``}``: ``fi``, ``done``, and ``esac``. ``ALL_GROUP_CLOSE_TOKENS`` names
53
+ all five, so a pipe after one steps back to the command whose exit status the
54
+ compound reports. The body of such a compound opens with a reserved word of its
55
+ own — ``then``, ``do``, or ``else`` — which stands ahead of the command rather
56
+ than being part of it, so ``ALL_COMPOUND_BODY_INTRODUCER_TOKENS`` names the
57
+ words a status read drops off the front. Both sets match a whole token only, so
58
+ ``ls done`` and ``echo fi`` name a path and a word rather than a keyword.
59
+
60
+ Such a compound reports the status of whichever branch ran, not of its last
61
+ branch alone, so ``if x; then pytest; else echo skip; fi`` exits with pytest's
62
+ code whenever ``x`` succeeds. ``ALL_BRANCH_CONTINUATION_TOKENS`` names ``else``
63
+ and ``elif``, the words that open the next branch and so mark the command
64
+ before them as the one its own branch ends on.
65
+
66
+ A redirection binds to the command it follows rather than being part of it, so
67
+ ``fi 2>&1`` and ``done < list`` still close their compound. Reading the closer
68
+ past one means dropping the redirection operator together with the file
69
+ descriptor written ahead of it, which ``FILE_DESCRIPTOR_TOKEN_PATTERN`` marks —
70
+ ``2>&1`` lexes as ``2``, ``>&``, ``1``.
71
+
72
+ An interpreter takes options of its own before its first operand, and a few of
73
+ them carry a separate value: ``ALL_VALUE_TAKING_INTERPRETER_OPTION_FLAGS`` names
74
+ those, so ``python -X dev -m pytest`` reads the ``-m`` behind the value while
75
+ ``python myscript.py -m pytest`` reads the script as the operand that ends the
76
+ options. The set covers PyPy's ``--jit`` as well as CPython's own, because the
77
+ interpreter pattern takes ``pypy3`` and an unnamed value-taking flag would read
78
+ its value as the operand that ends the options.
79
+
80
+ ``PIPE_CHARACTER``, ``COMMENT_START_CHARACTER``, and
81
+ ``HEREDOC_OPENER_OPERATOR`` are the substrings the hot path tests before it does
82
+ the matching parse. Every pipe operator spelling — ``|`` and ``|&`` — carries
83
+ ``PIPE_CHARACTER``, and no transform in the blocker inserts one, so a command
84
+ without it can hold no violation.
85
+
86
+ A heredoc delimiter is any shell word, so the opener pattern takes one written
87
+ bare, quoted, or backslash-escaped (``<<\\EOF`` quotes the delimiter the way
88
+ ``<<'EOF'`` does). ``<<<`` is a here-string rather than a heredoc, so the
89
+ pattern refuses to read one as an opener and leave the lines below it unread.
90
+ The opener also reports its own ``-``, because ``<<`` closes on a line spelling
91
+ the delimiter exactly while ``<<-`` closes on one carrying leading tabs.
92
+
93
+ The string-executing shell basenames and command flags start from the shared
94
+ ``unscoped_search_blocker_constants.py`` sets and add the Windows command shell
95
+ (``cmd /c "…"``) on top, locally. Widening the shared sets themselves would hand
96
+ ``cmd`` unwrapping to ``unscoped_search_blocker`` as a side effect of a
97
+ piped-pytest fix, so the addition stays in this module and that blocker keeps
98
+ its current behavior.
99
+
100
+ The operator sets partition ``ALL_SHELL_CONTROL_OPERATOR_TOKENS``. That
101
+ derivation covers the operator inventory only; quote-awareness stays local to
102
+ the blocker's own lexer.
103
+ """
104
+
105
+ from __future__ import annotations
106
+
107
+ import re
108
+
109
+ from hooks_constants.shell_command_segments import ALL_SHELL_CONTROL_OPERATOR_TOKENS
110
+ from hooks_constants.unscoped_search_blocker_constants import (
111
+ ALL_STRING_EXEC_COMMAND_FLAGS as _ALL_SHARED_STRING_EXEC_COMMAND_FLAGS,
112
+ )
113
+ from hooks_constants.unscoped_search_blocker_constants import (
114
+ ALL_STRING_EXECUTING_SHELL_BASENAMES as _ALL_SHARED_STRING_EXECUTING_SHELL_BASENAMES,
115
+ )
116
+
117
+ __all__ = [
118
+ "BASH_TOOL_NAME",
119
+ "ALL_SUPPORTED_TOOL_NAMES",
120
+ "ALL_PYTEST_PROGRAM_BASENAMES",
121
+ "PYTHON_INTERPRETER_BASENAME_PATTERN",
122
+ "MODULE_RUN_FLAG",
123
+ "PYTEST_MODULE_NAME",
124
+ "ALL_PIPE_OPERATOR_TOKENS",
125
+ "ALL_SEGMENT_RESET_OPERATOR_TOKENS",
126
+ "ALL_OPERATOR_TOKENS_LONGEST_FIRST",
127
+ "PUNCTUATION_ONLY_TOKEN_PATTERN",
128
+ "ALL_REDIRECTION_SUFFIX_CHARACTERS",
129
+ "ALL_STRING_EXECUTING_SHELL_BASENAMES",
130
+ "ALL_STRING_EXEC_COMMAND_FLAGS",
131
+ "ALL_CLUSTERED_STRING_EXEC_OPTION_LETTERS",
132
+ "ALL_SHORT_OPTION_CLUSTERING_SHELL_BASENAMES",
133
+ "COMMAND_OPTION_TOKEN_PATTERN",
134
+ "SHORT_OPTION_CLUSTER_PATTERN",
135
+ "SHORT_OPTION_PREFIX",
136
+ "END_OF_OPTIONS_TOKEN",
137
+ "ALL_VALUE_TAKING_WRAPPER_OPTION_FLAGS",
138
+ "ALL_VALUE_TAKING_SHELL_OPTION_FLAGS",
139
+ "ALL_VALUE_TAKING_INTERPRETER_OPTION_FLAGS",
140
+ "ALL_FLAG_TAKING_WRAPPER_COMMANDS",
141
+ "ALL_RUN_SUBCOMMAND_WRAPPER_COMMANDS",
142
+ "RUN_SUBCOMMAND_NAME",
143
+ "TOOL_SUBCOMMAND_NAME",
144
+ "WRAPPED_COMMAND_TOKEN_JOIN",
145
+ "DISABLED_LEXER_COMMENTERS",
146
+ "LINE_CONTINUATION_PATTERN",
147
+ "LINE_CONTINUATION_JOIN",
148
+ "COMMAND_LINE_SPLIT_PATTERN",
149
+ "QUOTED_REGION_PATTERN",
150
+ "QUOTED_REGION_REPLACEMENT",
151
+ "COMMENT_START_SCAN_PATTERN",
152
+ "COMMENT_START_GROUP",
153
+ "PIPE_CHARACTER",
154
+ "COMMENT_START_CHARACTER",
155
+ "HEREDOC_OPENER_OPERATOR",
156
+ "GROUP_OPEN_CHARACTER",
157
+ "GROUP_CLOSE_CHARACTER",
158
+ "ALL_GROUP_CLOSE_TOKENS",
159
+ "ALL_COMPOUND_BODY_INTRODUCER_TOKENS",
160
+ "ALL_BRANCH_CONTINUATION_TOKENS",
161
+ "FILE_DESCRIPTOR_TOKEN_PATTERN",
162
+ "PAREN_GROUP_LINE_JOIN",
163
+ "CLOSED_GROUP_DEPTH",
164
+ "HEREDOC_OPENER_PATTERN",
165
+ "HEREDOC_TAB_STRIP_GROUP",
166
+ "HEREDOC_QUOTE_GROUP",
167
+ "HEREDOC_TERMINATOR_GROUP",
168
+ "HEREDOC_TAB_STRIP_MARKER",
169
+ "HEREDOC_STRIPPED_INDENT_CHARACTERS",
170
+ "NO_FOLLOWING_OPERATOR",
171
+ "ALL_QUOTE_CHARACTERS",
172
+ "HOOK_EVENT_NAME",
173
+ "DENY_DECISION",
174
+ "CALLING_HOOK_NAME",
175
+ "CORRECTIVE_MESSAGE",
176
+ ]
177
+
178
+ BASH_TOOL_NAME = "Bash"
179
+ ALL_SUPPORTED_TOOL_NAMES: frozenset[str] = frozenset({BASH_TOOL_NAME})
180
+
181
+ ALL_PYTEST_PROGRAM_BASENAMES: frozenset[str] = frozenset(
182
+ {
183
+ "pytest",
184
+ "pytest.exe",
185
+ "pytest.bat",
186
+ "pytest.cmd",
187
+ "py.test",
188
+ "py.test.exe",
189
+ "py.test.bat",
190
+ "py.test.cmd",
191
+ }
192
+ )
193
+ PYTHON_INTERPRETER_BASENAME_PATTERN = re.compile(
194
+ r"^(?:(?:python|pypy)w?[0-9._]*t?|pyw?)(?:\.exe|\.bat|\.cmd)?$"
195
+ )
196
+ MODULE_RUN_FLAG = "-m"
197
+ PYTEST_MODULE_NAME = "pytest"
198
+
199
+ _ALL_PIPE_OPERATOR_SPELLINGS: frozenset[str] = frozenset({"|", "|&"})
200
+ _ALL_LOCAL_SEGMENT_RESET_EXTRAS: frozenset[str] = frozenset({";;", "(", "{"})
201
+
202
+ ALL_PIPE_OPERATOR_TOKENS: frozenset[str] = (
203
+ ALL_SHELL_CONTROL_OPERATOR_TOKENS & _ALL_PIPE_OPERATOR_SPELLINGS
204
+ )
205
+ ALL_SEGMENT_RESET_OPERATOR_TOKENS: frozenset[str] = (
206
+ ALL_SHELL_CONTROL_OPERATOR_TOKENS - ALL_PIPE_OPERATOR_TOKENS
207
+ ) | _ALL_LOCAL_SEGMENT_RESET_EXTRAS
208
+
209
+ ALL_OPERATOR_TOKENS_LONGEST_FIRST: tuple[str, ...] = tuple(
210
+ sorted(
211
+ ALL_PIPE_OPERATOR_TOKENS | ALL_SEGMENT_RESET_OPERATOR_TOKENS,
212
+ key=len,
213
+ reverse=True,
214
+ )
215
+ )
216
+ PUNCTUATION_ONLY_TOKEN_PATTERN = re.compile(r"[();<>|&]+")
217
+ ALL_REDIRECTION_SUFFIX_CHARACTERS: tuple[str, ...] = ("<", ">")
218
+
219
+ _ALL_WINDOWS_COMMAND_SHELL_BASENAMES: frozenset[str] = frozenset({"cmd", "cmd.exe"})
220
+ _ALL_WINDOWS_COMMAND_SHELL_FLAGS: frozenset[str] = frozenset({"/c", "/k"})
221
+ ALL_STRING_EXECUTING_SHELL_BASENAMES: frozenset[str] = (
222
+ _ALL_SHARED_STRING_EXECUTING_SHELL_BASENAMES | _ALL_WINDOWS_COMMAND_SHELL_BASENAMES
223
+ )
224
+ ALL_STRING_EXEC_COMMAND_FLAGS: frozenset[str] = (
225
+ _ALL_SHARED_STRING_EXEC_COMMAND_FLAGS | _ALL_WINDOWS_COMMAND_SHELL_FLAGS
226
+ )
227
+ ALL_CLUSTERED_STRING_EXEC_OPTION_LETTERS: frozenset[str] = frozenset({"c"})
228
+ ALL_SHORT_OPTION_CLUSTERING_SHELL_BASENAMES: frozenset[str] = frozenset(
229
+ {"bash", "bash.exe", "sh", "sh.exe"}
230
+ )
231
+ COMMAND_OPTION_TOKEN_PATTERN = re.compile(r"^(?:-.*|/[A-Za-z])$")
232
+ SHORT_OPTION_CLUSTER_PATTERN = re.compile(r"-[A-Za-z]{2,}")
233
+ SHORT_OPTION_PREFIX = "-"
234
+ END_OF_OPTIONS_TOKEN = "--"
235
+ ALL_VALUE_TAKING_WRAPPER_OPTION_FLAGS: frozenset[str] = frozenset(
236
+ {
237
+ "-C",
238
+ "-D",
239
+ "-R",
240
+ "-T",
241
+ "-U",
242
+ "-g",
243
+ "-h",
244
+ "-p",
245
+ "-r",
246
+ "-t",
247
+ "-u",
248
+ "--concurrency",
249
+ "--context",
250
+ "--data-file",
251
+ "--directory",
252
+ "--env-file",
253
+ "--extra",
254
+ "--from",
255
+ "--group",
256
+ "--include",
257
+ "--omit",
258
+ "--package",
259
+ "--project",
260
+ "--python",
261
+ "--rcfile",
262
+ "--source",
263
+ "--user",
264
+ "--with",
265
+ }
266
+ )
267
+ ALL_VALUE_TAKING_INTERPRETER_OPTION_FLAGS: frozenset[str] = frozenset(
268
+ {"-Q", "-W", "-X", "--check-hash-based-pycs", "--jit"}
269
+ )
270
+ ALL_VALUE_TAKING_SHELL_OPTION_FLAGS: frozenset[str] = frozenset(
271
+ {
272
+ "-o",
273
+ "--rcfile",
274
+ "--init-file",
275
+ "-configurationname",
276
+ "-custompipename",
277
+ "-executionpolicy",
278
+ "-inputformat",
279
+ "-outputformat",
280
+ "-settingsfile",
281
+ "-version",
282
+ "-windowstyle",
283
+ "-workingdirectory",
284
+ }
285
+ )
286
+
287
+ ALL_FLAG_TAKING_WRAPPER_COMMANDS: frozenset[str] = frozenset(
288
+ {"sudo", "sudo.exe", "uvx", "uvx.exe"}
289
+ )
290
+ ALL_RUN_SUBCOMMAND_WRAPPER_COMMANDS: frozenset[str] = frozenset(
291
+ {
292
+ "uv",
293
+ "uv.exe",
294
+ "poetry",
295
+ "poetry.exe",
296
+ "pipenv",
297
+ "pipenv.exe",
298
+ "pdm",
299
+ "pdm.exe",
300
+ "hatch",
301
+ "hatch.exe",
302
+ "rye",
303
+ "rye.exe",
304
+ "coverage",
305
+ "coverage.exe",
306
+ }
307
+ )
308
+ RUN_SUBCOMMAND_NAME = "run"
309
+ TOOL_SUBCOMMAND_NAME = "tool"
310
+ WRAPPED_COMMAND_TOKEN_JOIN = " "
311
+ DISABLED_LEXER_COMMENTERS = ""
312
+
313
+ LINE_CONTINUATION_PATTERN = re.compile(r"\\(?:\r\n|[\r\n])")
314
+ LINE_CONTINUATION_JOIN = ""
315
+ COMMAND_LINE_SPLIT_PATTERN = re.compile(r"[\n\r]+")
316
+ QUOTED_REGION_PATTERN = re.compile(r"'[^']*'|\"(?:\\.|[^\"\\])*\"|\\.")
317
+ QUOTED_REGION_REPLACEMENT = ""
318
+ PIPE_CHARACTER = "|"
319
+ COMMENT_START_CHARACTER = "#"
320
+ HEREDOC_OPENER_OPERATOR = "<<"
321
+ COMMENT_START_GROUP = "comment_start"
322
+ COMMENT_START_SCAN_PATTERN = re.compile(
323
+ rf"'[^']*'|\"(?:\\.|[^\"\\])*\"|\\.|(?P<{COMMENT_START_GROUP}>(?<![^\s])#)"
324
+ )
325
+ GROUP_OPEN_CHARACTER = "("
326
+ GROUP_CLOSE_CHARACTER = ")"
327
+ _BRACE_GROUP_CLOSE_TOKEN = "}"
328
+ _ALL_COMPOUND_CLOSE_KEYWORDS: frozenset[str] = frozenset({"fi", "done", "esac"})
329
+ ALL_GROUP_CLOSE_TOKENS: frozenset[str] = (
330
+ frozenset({GROUP_CLOSE_CHARACTER, _BRACE_GROUP_CLOSE_TOKEN}) | _ALL_COMPOUND_CLOSE_KEYWORDS
331
+ )
332
+ ALL_COMPOUND_BODY_INTRODUCER_TOKENS: frozenset[str] = frozenset({"then", "do", "else"})
333
+ ALL_BRANCH_CONTINUATION_TOKENS: frozenset[str] = frozenset({"else", "elif"})
334
+ FILE_DESCRIPTOR_TOKEN_PATTERN = re.compile(r"[0-9]+")
335
+ PAREN_GROUP_LINE_JOIN = " "
336
+ CLOSED_GROUP_DEPTH = 0
337
+ _HEREDOC_DELIMITER_CHARACTER_CLASS = r"[^\s'\"<>|&;()`$\\]"
338
+ HEREDOC_TAB_STRIP_GROUP = "heredoc_tab_strip"
339
+ HEREDOC_QUOTE_GROUP = "heredoc_quote"
340
+ HEREDOC_TERMINATOR_GROUP = "heredoc_terminator"
341
+ HEREDOC_OPENER_PATTERN = re.compile(
342
+ rf"(?<!<)<<(?!<)(?P<{HEREDOC_TAB_STRIP_GROUP}>-?)\s*(?P<{HEREDOC_QUOTE_GROUP}>['\"]?)\\?"
343
+ rf"(?P<{HEREDOC_TERMINATOR_GROUP}>{_HEREDOC_DELIMITER_CHARACTER_CLASS}+)"
344
+ rf"(?P={HEREDOC_QUOTE_GROUP})"
345
+ )
346
+ HEREDOC_TAB_STRIP_MARKER = "-"
347
+ HEREDOC_STRIPPED_INDENT_CHARACTERS = "\t"
348
+ NO_FOLLOWING_OPERATOR = ""
349
+ ALL_QUOTE_CHARACTERS = "\"'"
350
+
351
+ HOOK_EVENT_NAME = "PreToolUse"
352
+ DENY_DECISION = "deny"
353
+ CALLING_HOOK_NAME = "piped_pytest_blocker.py"
354
+
355
+ CORRECTIVE_MESSAGE = (
356
+ "Piped pytest run blocked. Run pytest alone; the pipe makes the tool report "
357
+ "the downstream command's exit code, so a failing suite reads as a pass. "
358
+ "When you need the output on disk, redirect it "
359
+ "(`python -m pytest tests > run.log 2>&1`) and read the file."
360
+ )
@@ -1,4 +1,25 @@
1
- """Configuration constants for the plain_language_blocker PreToolUse hook."""
1
+ """Configuration constants for the plain_language_blocker PreToolUse hook.
2
+
3
+ ::
4
+
5
+ ALL_CHAT_DETAIL_MARKERS
6
+ ok: "Which gate should run first?"
7
+ flag: "Which gate should run first?\\n- write\\n- commit"
8
+ INLINE_CODE_SPAN_PATTERN
9
+ ok: "Should we pass `--dry-run` here?" -> one word toward the caps
10
+ ALL_LINE_ENDING_REPLACEMENTS
11
+ flag: a blank line spelled "\\r\\n\\r\\n" reads as a paragraph break
12
+ MAXIMUM_OPTION_DESCRIPTION_WORD_COUNT
13
+ ok: "Runs on every write."
14
+ flag: a 30-word paragraph of tradeoffs
15
+
16
+ Holds the heavy words and their everyday replacements, the prose-region
17
+ strippers the word scan runs first, and the lean-question-block tunables: the
18
+ block-level chat-detail markers that belong in chat text, the line-ending
19
+ spellings normalized before those markers run, the inline-code span the length
20
+ counts collapse to one word, and the sentence and word caps a question and an
21
+ option description each answer to.
22
+ """
2
23
 
3
24
  from __future__ import annotations
4
25
 
@@ -298,3 +319,45 @@ USER_FACING_PLAIN_LANGUAGE_NOTICE: str = (
298
319
  "Plain-language check: replace each flagged formal word with the simpler "
299
320
  "word named in the reason."
300
321
  )
322
+
323
+ ALL_CHAT_DETAIL_MARKERS: tuple[tuple[re.Pattern[str], str], ...] = (
324
+ (re.compile(r"^[ \t]*```", re.MULTILINE), "a fenced code block"),
325
+ (re.compile(r"^[ \t]*#{1,6}[ \t]+\S", re.MULTILINE), "a heading"),
326
+ (re.compile(r"^[ \t]*\|.*\|[ \t]*$", re.MULTILINE), "a table row"),
327
+ (
328
+ re.compile(r"^[ \t]*(?:[-*+][ \t]+|\d{1,2}[.)][ \t]+)\S", re.MULTILINE),
329
+ "a bullet or numbered list marker",
330
+ ),
331
+ (re.compile(r"\n[ \t]*\n"), "more than one paragraph"),
332
+ )
333
+
334
+ INLINE_CODE_SPAN_PATTERN: re.Pattern[str] = re.compile(r"`[^`\n]+`")
335
+ INLINE_CODE_PLACEHOLDER: str = "code"
336
+
337
+ ALL_LINE_ENDING_REPLACEMENTS: tuple[tuple[str, str], ...] = (
338
+ ("\r\n", "\n"),
339
+ ("\r", "\n"),
340
+ )
341
+
342
+ SENTENCE_BOUNDARY_PATTERN: re.Pattern[str] = re.compile(r"[.!?]+(?=\s+[A-Z]|\s*$)")
343
+ COUNTABLE_WORD_PATTERN: re.Pattern[str] = re.compile(r"\S*[A-Za-z0-9]\S*")
344
+
345
+ MAXIMUM_QUESTION_SENTENCE_COUNT: int = 2
346
+ MAXIMUM_QUESTION_WORD_COUNT: int = 40
347
+ MAXIMUM_OPTION_DESCRIPTION_SENTENCE_COUNT: int = 1
348
+ MAXIMUM_OPTION_DESCRIPTION_WORD_COUNT: int = 15
349
+
350
+ QUESTION_SURFACE_NAME: str = "the question"
351
+ OPTION_DESCRIPTION_SURFACE_NAME: str = "an option description"
352
+
353
+ LEAN_QUESTION_BLOCK_PREFIX: str = "BLOCKED: [LEAN_QUESTION] Question block carries chat detail -- "
354
+ LEAN_QUESTION_VIOLATION_SEPARATOR: str = "; "
355
+ LEAN_QUESTION_BLOCK_GUIDANCE: str = (
356
+ "AskUserQuestion renders as one plain text block. Print the plan, the counts, "
357
+ "and the tradeoffs in chat text before the call, keep the question block to a "
358
+ "lean question and short options, and reach for an inline visualizer tool when "
359
+ "a choice needs formatting."
360
+ )
361
+ USER_FACING_LEAN_QUESTION_NOTICE: str = (
362
+ "Question-block check: move the detail into chat and ask a short question."
363
+ )
@@ -162,4 +162,14 @@ ALL_HOSTED_HOOK_ENTRIES: tuple[HostedHookEntry, ...] = (
162
162
  applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
163
163
  native_module_name=PLAIN_LANGUAGE_BLOCKER_MODULE_NAME,
164
164
  ),
165
+ HostedHookEntry(
166
+ script_relative_path="advisory/refactor_guard.py",
167
+ applicable_tool_names=frozenset({EDIT_TOOL_NAME}),
168
+ is_blocking=False,
169
+ ),
170
+ HostedHookEntry(
171
+ script_relative_path="advisory/migration_safety_advisor.py",
172
+ applicable_tool_names=frozenset({EDIT_TOOL_NAME}),
173
+ is_blocking=False,
174
+ ),
165
175
  )
@@ -22,7 +22,7 @@ __all__ = [
22
22
  ]
23
23
 
24
24
  ALL_LAUNCHER_WRAPPER_COMMANDS: frozenset[str] = frozenset(
25
- {"timeout", "nohup", "nice", "stdbuf", "setsid", "env"}
25
+ {"timeout", "nohup", "nice", "stdbuf", "setsid", "env", "time"}
26
26
  )
27
27
  ALL_SHELL_CONTROL_OPERATOR_TOKENS: frozenset[str] = frozenset(
28
28
  {"&&", "||", ";", "|", "&", "|&"}
@@ -26,6 +26,7 @@ _EXPECTED_BASH_ORDER = (
26
26
  "blocking/destructive_command_blocker.py",
27
27
  "blocking/gh_body_arg_blocker.py",
28
28
  "blocking/shell_substitution_blocker.py",
29
+ "blocking/piped_pytest_blocker.py",
29
30
  "blocking/unscoped_search_blocker.py",
30
31
  "blocking/nas_ssh_binary_enforcer.py",
31
32
  "blocking/volatile_path_in_post_blocker.py",
@@ -87,6 +87,33 @@ def test_blocking_hook_crash_deny_reason_surfaces_the_constant() -> None:
87
87
  assert BLOCKING_CRASH_DENY_REASON in decision.all_deny_reasons
88
88
 
89
89
 
90
+ def test_refactor_guard_is_hosted_as_an_edit_only_advisory_hook() -> None:
91
+ entry = _entry_for("advisory/refactor_guard.py")
92
+ assert entry is not None, (
93
+ "refactor_guard must be hosted by the dispatcher rather than spawning its own process"
94
+ )
95
+ assert entry.applicable_tool_names == frozenset({EDIT_TOOL_NAME})
96
+ assert entry.is_blocking is False
97
+
98
+
99
+ def test_migration_safety_advisor_is_hosted_as_an_edit_only_advisory_hook() -> None:
100
+ entry = _entry_for("advisory/migration_safety_advisor.py")
101
+ assert entry is not None, (
102
+ "migration_safety_advisor must be hosted by the dispatcher rather than "
103
+ "spawning its own process"
104
+ )
105
+ assert entry.applicable_tool_names == frozenset({EDIT_TOOL_NAME})
106
+ assert entry.is_blocking is False
107
+
108
+
109
+ def test_every_hosted_script_path_exists_under_the_hooks_root() -> None:
110
+ for each_entry in ALL_HOSTED_HOOK_ENTRIES:
111
+ hosted_script_path = _HOOKS_ROOT / each_entry.script_relative_path
112
+ assert hosted_script_path.is_file(), (
113
+ f"{each_entry.script_relative_path} is on the roster but missing from the hooks tree"
114
+ )
115
+
116
+
90
117
  def test_every_native_module_exposes_a_callable_evaluate() -> None:
91
118
  nativized_entries = [
92
119
  each_entry