claude-dev-env 2.8.0 → 2.10.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 (323) hide show
  1. package/CLAUDE.md +8 -2
  2. package/_shared/pr-loop/CLAUDE.md +1 -1
  3. package/_shared/pr-loop/audit-contract.md +17 -6
  4. package/_shared/pr-loop/audit-reply-template.md +4 -4
  5. package/_shared/pr-loop/code-rules-gate.md +3 -5
  6. package/_shared/pr-loop/fix-protocol.md +2 -3
  7. package/_shared/pr-loop/gh-payloads.md +1 -1
  8. package/_shared/pr-loop/scripts/CLAUDE.md +1 -1
  9. package/_shared/pr-loop/scripts/README.md +1 -1
  10. package/_shared/pr-loop/scripts/code_rules_gate.py +2 -0
  11. package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_running.py +16 -1
  12. package/_shared/pr-loop/scripts/code_rules_gate_parts/git_blob_readers.py +11 -5
  13. package/_shared/pr-loop/scripts/preflight.py +9 -4
  14. package/_shared/pr-loop/scripts/reviews_disabled.py +50 -22
  15. package/_shared/pr-loop/scripts/tests/conftest.py +20 -0
  16. package/_shared/pr-loop/scripts/tests/test_claude_permissions_common.py +6 -6
  17. package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +50 -6
  18. package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +1 -1
  19. package/_shared/pr-loop/state-schema.md +5 -14
  20. package/agents/CLAUDE.md +2 -2
  21. package/agents/clean-coder.md +62 -562
  22. package/agents/code-quality-agent.md +10 -2
  23. package/agents/code-verifier.md +1 -1
  24. package/agents/test_agent_frontmatter.py +58 -40
  25. package/audit-rubrics/CLAUDE.md +2 -1
  26. package/audit-rubrics/audit-categories.json +704 -0
  27. package/audit-rubrics/prompts/category-i-concurrency.md +1 -1
  28. package/bin/CLAUDE.md +16 -5
  29. package/bin/ever-shipped-skills.mjs +2 -0
  30. package/bin/install-plan.mjs +402 -0
  31. package/bin/install-transaction.mjs +455 -0
  32. package/bin/install.mjs +593 -147
  33. package/bin/install.plan.test.mjs +194 -0
  34. package/bin/install.profile-root.test.mjs +154 -0
  35. package/bin/install.profiles.test.mjs +253 -0
  36. package/bin/install.settings-defaults.test.mjs +200 -0
  37. package/bin/install.transaction.test.mjs +400 -0
  38. package/bin/install.uninstall-transaction.test.mjs +418 -0
  39. package/bin/merge_managed_permissions.mjs +130 -0
  40. package/bin/resolve-install-root.mjs +181 -0
  41. package/bin/select-install-targets.mjs +401 -0
  42. package/commands/CLAUDE.md +0 -2
  43. package/docs/CODE_RULES.md +4 -2
  44. package/docs/references/CLAUDE.md +3 -2
  45. package/docs/references/advisor-tool.md +44 -6
  46. package/docs/references/prose-style-enforcement.md +25 -0
  47. package/docs/references/team-advisor-skill.md +14 -8
  48. package/hooks/blocking/CLAUDE.md +6 -6
  49. package/hooks/blocking/_path_setup.py +9 -5
  50. package/hooks/blocking/code_rules_docstrings.py +124 -30
  51. package/hooks/blocking/code_rules_enforcer.py +161 -16
  52. package/hooks/blocking/code_rules_shared.py +40 -23
  53. package/hooks/blocking/config/CLAUDE.md +3 -5
  54. package/hooks/blocking/config/prose_style_enforcement_constants.py +38 -0
  55. package/hooks/blocking/config/test_prose_style_enforcement_constants.py +45 -0
  56. package/hooks/blocking/eli11_reply_enforcer.py +70 -113
  57. package/hooks/blocking/hedging_language_blocker.py +103 -20
  58. package/hooks/blocking/hook_prose_detector_consistency.py +6 -0
  59. package/hooks/blocking/intent_only_ending_blocker.py +6 -0
  60. package/hooks/blocking/plain_language_blocker.py +139 -20
  61. package/hooks/blocking/pre_tool_use_dispatcher.py +102 -20
  62. package/hooks/blocking/state_description_blocker.py +7 -1
  63. package/hooks/blocking/tdd_enforcer.py +8 -0
  64. package/hooks/blocking/test__path_setup.py +28 -0
  65. package/hooks/blocking/test_code_rules_enforcer_agent_home_tooling.py +99 -0
  66. package/hooks/blocking/test_code_rules_enforcer_docstring_args_span_scope.py +232 -10
  67. package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +1 -1
  68. package/hooks/blocking/test_code_rules_enforcer_join_separator_magic.py +41 -0
  69. package/hooks/blocking/test_code_rules_enforcer_string_magic.py +98 -0
  70. package/hooks/blocking/test_eli11_reply_enforcer.py +98 -165
  71. package/hooks/blocking/test_hedging_language_blocker.py +120 -1
  72. package/hooks/blocking/test_hook_prose_detector_consistency.py +28 -8
  73. package/hooks/blocking/test_intent_only_ending_blocker.py +27 -2
  74. package/hooks/blocking/test_package_inventory_stale_blocker.py +11 -4
  75. package/hooks/blocking/test_plain_language_blocker.py +129 -19
  76. package/hooks/blocking/test_plain_language_blocker_allowlist.py +70 -26
  77. package/hooks/blocking/test_pre_tool_use_dispatcher.py +99 -26
  78. package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +87 -50
  79. package/hooks/blocking/test_state_description_blocker.py +45 -2
  80. package/hooks/blocking/test_stop_dispatcher.py +11 -7
  81. package/hooks/blocking/test_volatile_path_in_post_blocker.py +12 -12
  82. package/hooks/blocking/volatile_path_in_post_blocker.py +2 -2
  83. package/hooks/hooks.json +15 -0
  84. package/hooks/hooks_constants/CLAUDE.md +14 -3
  85. package/hooks/hooks_constants/ask_user_question_shape.py +281 -0
  86. package/hooks/hooks_constants/code_rules_enforcer_constants.py +2 -1
  87. package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -0
  88. package/hooks/hooks_constants/eli11_reply_enforcer_constants.py +5 -12
  89. package/hooks/hooks_constants/hedging_uncertainty_constants.py +42 -0
  90. package/hooks/hooks_constants/issue_tracker_session_starter_constants.py +23 -0
  91. package/hooks/hooks_constants/orchestrator_auto_starter_constants.py +23 -0
  92. package/hooks/hooks_constants/piped_pytest_blocker_constants.py +4 -1
  93. package/hooks/hooks_constants/plain_language_blocker_constants.py +4 -1
  94. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +6 -0
  95. package/hooks/hooks_constants/project_paths_reader.py +31 -4
  96. package/hooks/hooks_constants/prose_matcher_precision_constants.py +40 -0
  97. package/hooks/hooks_constants/pytest_invocation.py +354 -0
  98. package/hooks/hooks_constants/session_start_injector.py +163 -0
  99. package/hooks/hooks_constants/session_start_injector_constants.py +46 -0
  100. package/hooks/hooks_constants/shell_command_pipeline.py +397 -0
  101. package/hooks/hooks_constants/shell_command_segments.py +5 -0
  102. package/hooks/hooks_constants/test_ask_user_question_shape.py +167 -0
  103. package/hooks/hooks_constants/test_project_paths_reader.py +29 -0
  104. package/hooks/hooks_constants/test_prose_metrics_parity.py +8 -0
  105. package/hooks/hooks_constants/test_pytest_invocation.py +130 -0
  106. package/hooks/hooks_constants/test_session_start_injector.py +168 -0
  107. package/hooks/hooks_constants/test_shell_command_pipeline.py +135 -0
  108. package/hooks/hooks_constants/volatile_path_in_post_blocker_constants.py +1 -1
  109. package/hooks/hooks_constants/working_style_prompt_constants.py +30 -0
  110. package/hooks/observability/CLAUDE.md +2 -0
  111. package/hooks/observability/prose_matcher_advisory.py +237 -0
  112. package/hooks/observability/test_prose_matcher_advisory.py +143 -0
  113. package/hooks/session/CLAUDE.md +9 -1
  114. package/hooks/session/_path_setup.py +13 -0
  115. package/hooks/session/issue_tracker_session_starter.py +135 -0
  116. package/hooks/session/orchestrator_auto_starter.py +100 -0
  117. package/hooks/session/test__path_setup.py +28 -0
  118. package/hooks/session/test_issue_tracker_session_starter.py +104 -0
  119. package/hooks/session/test_orchestrator_auto_starter.py +99 -0
  120. package/hooks/session/test_working_style_prompt.py +47 -0
  121. package/hooks/session/untracked_repo_detector.py +1 -24
  122. package/hooks/session/working_style_prompt.py +36 -0
  123. package/hooks/validators/_path_setup.py +19 -0
  124. package/hooks/validators/run_all_validators.py +8 -13
  125. package/installable-surfaces.manifest.json +21 -0
  126. package/output-styles/CLAUDE.md +15 -0
  127. package/package.json +5 -2
  128. package/rules/CLAUDE.md +1 -0
  129. package/rules/code-standards.md +33 -7
  130. package/rules/durable-post-artifacts.md +2 -2
  131. package/rules/eli11-replies.md +7 -2
  132. package/rules/hedging-claims.md +4 -2
  133. package/rules/long-horizon-autonomy.md +3 -1
  134. package/rules/opus5-communication-contract.md +45 -0
  135. package/rules/plain-language.md +2 -2
  136. package/rules/research-mode.md +1 -1
  137. package/scripts/CLAUDE.md +13 -2
  138. package/scripts/Sync-RepoMain.ps1 +215 -0
  139. package/scripts/active_capability_references.py +218 -0
  140. package/scripts/ci/windows-installer-lifecycle.ps1 +78 -0
  141. package/scripts/claude_chain_runner.py +394 -6
  142. package/scripts/claude_chain_usage.py +1 -1
  143. package/scripts/codex_compat_materializer.py +105 -85
  144. package/scripts/dev_env_scripts_constants/CLAUDE.md +3 -1
  145. package/scripts/dev_env_scripts_constants/active_capability_constants.py +46 -0
  146. package/scripts/dev_env_scripts_constants/claude_chain_constants.py +74 -0
  147. package/scripts/dev_env_scripts_constants/grok_run_ledger_constants.py +50 -0
  148. package/scripts/dev_env_scripts_constants/grok_worker_constants.py +104 -0
  149. package/scripts/dev_env_scripts_constants/verify_installable_package_constants.py +116 -0
  150. package/scripts/grok_patch_artifacts.py +123 -0
  151. package/scripts/grok_run_ledger.py +318 -0
  152. package/scripts/profile-isolation-launchers/config/mcp-bundles.json +25 -0
  153. package/scripts/profile-isolation-launchers/config/profile-isolation-constants.mjs +60 -0
  154. package/scripts/profile-isolation-launchers/config/profiles.manifest.json +54 -0
  155. package/scripts/profile-isolation-launchers/config/shared-allowlist.json +64 -0
  156. package/scripts/profile-isolation-launchers/launcher-runtime.mjs +180 -0
  157. package/scripts/profile-isolation-launchers/lib/profile-manifest.mjs +288 -0
  158. package/scripts/profile-isolation-launchers/mcp-bundles.mjs +275 -0
  159. package/scripts/profile-isolation-launchers/profile-isolation-contract.test.mjs +221 -0
  160. package/scripts/profile-isolation-launchers/tests/launcher-runtime.test.mjs +108 -0
  161. package/scripts/profile-isolation-launchers/tests/mcp-bundles.test.mjs +147 -0
  162. package/scripts/profile-isolation-launchers/tests/shortcut-contract.test.ps1 +102 -0
  163. package/scripts/profile-isolation-launchers/tests/version-compatibility.test.mjs +210 -0
  164. package/scripts/profile-isolation-launchers/version-compatibility.mjs +299 -0
  165. package/scripts/profile-isolation-launchers/windows/shortcut-inventory.ps1 +127 -0
  166. package/scripts/profile-isolation-launchers/windows/shortcut-manifest.json +51 -0
  167. package/scripts/profile-isolation-launchers/windows/shortcut-reconcile.ps1 +77 -0
  168. package/scripts/spawn_grok_batch.py +556 -9
  169. package/scripts/test_active_capability_references.py +108 -0
  170. package/scripts/test_claude_chain_runner.py +414 -82
  171. package/scripts/test_claude_chain_usage.py +12 -12
  172. package/scripts/test_grok_patch_artifacts.py +82 -0
  173. package/scripts/test_grok_run_ledger.py +116 -0
  174. package/scripts/test_resolve_worker_spawn.py +2 -2
  175. package/scripts/test_spawn_grok_batch.py +295 -0
  176. package/scripts/test_verify_installable_package.py +208 -0
  177. package/scripts/tests/test_codex_compat_materializer.py +33 -0
  178. package/scripts/verify_installable_package.py +612 -0
  179. package/settings.json +10 -0
  180. package/skills/CLAUDE.md +6 -2
  181. package/skills/_shared/CLAUDE.md +37 -4
  182. package/skills/_shared/advisor/CLAUDE.md +9 -0
  183. package/skills/_shared/advisor/advisor-protocol.md +5 -0
  184. package/skills/_shared/advisor/scripts/README.md +9 -0
  185. package/skills/_shared/end-of-run-gotcha-recommendations.md +156 -0
  186. package/skills/_shared/pr-loop/CLAUDE.md +18 -1
  187. package/skills/_shared/pr-loop/audit-contract.md +5 -0
  188. package/skills/_shared/pr-loop/audit-reply-template.md +5 -0
  189. package/skills/_shared/pr-loop/code-rules-gate.md +5 -0
  190. package/skills/_shared/pr-loop/fix-protocol.md +5 -0
  191. package/skills/_shared/pr-loop/gh-payloads.md +5 -0
  192. package/skills/_shared/pr-loop/post-audit-thread-contract.md +5 -0
  193. package/skills/_shared/pr-loop/precatch-rubric.md +5 -0
  194. package/skills/_shared/pr-loop/scripts/CLAUDE.md +9 -1
  195. package/skills/_shared/pr-loop/scripts/RUNTIME_SCRIPTS.md +29 -0
  196. package/skills/_shared/pr-loop/scripts/audit_category_schema.py +355 -0
  197. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/CLAUDE.md +1 -0
  198. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/audit_category_schema_constants.py +32 -0
  199. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/path_resolver_constants.py +7 -19
  200. package/skills/_shared/pr-loop/scripts/test_audit_category_schema.py +94 -0
  201. package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +21 -0
  202. package/skills/_shared/pr-loop/state-schema.md +5 -0
  203. package/skills/_shared/pr-loop/worker-spawn.md +5 -0
  204. package/skills/autoconverge/reference/convergence.md +2 -1
  205. package/skills/autoconverge/reference/stop-conditions.md +5 -3
  206. package/skills/beat-sheet/SKILL.md +54 -0
  207. package/skills/beat-sheet/reference/visual-beats.md +29 -0
  208. package/skills/bugteam/CONSTRAINTS.md +4 -4
  209. package/skills/bugteam/EXAMPLES.md +1 -1
  210. package/skills/bugteam/reference/README.md +1 -1
  211. package/skills/e-code-review/SKILL.md +29 -3
  212. package/skills/e-code-review/reference/effort-evaluation.md +35 -0
  213. package/skills/e-code-review/reference/medium.md +15 -4
  214. package/skills/e-code-review/reference/runner-selection.md +40 -0
  215. package/skills/e-code-review/scripts/config/e_code_review_effort_constants/__init__.py +41 -0
  216. package/skills/e-code-review/scripts/config/e_code_review_effort_constants/effort_constants.py +40 -0
  217. package/skills/e-code-review/scripts/e_code_review_scripts_constants/__init__.py +1 -0
  218. package/skills/e-code-review/scripts/e_code_review_scripts_constants/finding_pipeline_constants.py +49 -0
  219. package/skills/e-code-review/scripts/e_code_review_scripts_constants/grok_code_review_constants.py +55 -0
  220. package/skills/e-code-review/scripts/effort_defaults_evidence.json +186 -0
  221. package/skills/e-code-review/scripts/effort_evaluation.py +362 -0
  222. package/skills/e-code-review/scripts/finding_pipeline.py +140 -0
  223. package/skills/e-code-review/scripts/fixtures/demanding.json +26 -0
  224. package/skills/e-code-review/scripts/fixtures/easy.json +14 -0
  225. package/skills/e-code-review/scripts/fixtures/medium.json +20 -0
  226. package/skills/e-code-review/scripts/grok_code_review.py +230 -0
  227. package/skills/e-code-review/scripts/test_effort_evaluation.py +180 -0
  228. package/skills/e-code-review/scripts/test_finding_pipeline.py +197 -0
  229. package/skills/e-code-review/scripts/test_grok_code_review.py +289 -0
  230. package/skills/grok-spawn/SKILL.md +5 -0
  231. package/skills/orchestrator/SKILL.md +5 -0
  232. package/skills/plan-to-pr/scripts/create_packet.py +4 -4
  233. package/skills/plan-to-pr/scripts/load_skill_constants.py +41 -0
  234. package/skills/plan-to-pr/scripts/validate_packet.py +4 -4
  235. package/skills/plan-to-pr/scripts/validate_protocol.py +4 -1
  236. package/skills/plan-to-pr/scripts/validate_run.py +4 -1
  237. package/skills/pr-converge/scripts/check_convergence.py +21 -19
  238. package/skills/pr-converge/scripts/check_convergence_availability.py +50 -7
  239. package/skills/pr-converge/scripts/conftest.py +35 -0
  240. package/skills/pr-converge/scripts/test_check_convergence_availability.py +65 -0
  241. package/skills/pr-converge/scripts/test_check_convergence_codex.py +11 -1
  242. package/skills/pr-converge/scripts/test_check_convergence_contract.py +9 -2
  243. package/skills/pr-loop-cloud-transport/SKILL.md +1 -1
  244. package/skills/rebase/SKILL.md +15 -3
  245. package/skills/reviewer-gates/SKILL.md +2 -2
  246. package/skills/show/SKILL.md +51 -0
  247. package/skills/show/references/accessibility.md +7 -0
  248. package/skills/show/references/art.md +3 -0
  249. package/skills/show/references/charts.md +3 -0
  250. package/skills/show/references/core-design.md +14 -0
  251. package/skills/show/references/erds.md +3 -0
  252. package/skills/show/references/flowcharts.md +3 -0
  253. package/skills/show/references/host-and-html.md +3 -0
  254. package/skills/show/references/illustrative-diagrams.md +10 -0
  255. package/skills/show/references/interaction.md +3 -0
  256. package/skills/show/references/mockups.md +3 -0
  257. package/skills/show/references/quality-gates.md +7 -0
  258. package/skills/show/references/structural-diagrams.md +3 -0
  259. package/skills/show/references/subject-inventory.md +21 -0
  260. package/skills/show/references/svg-contract.md +22 -0
  261. package/skills/show/routing.yaml +30 -0
  262. package/skills/show/samples/pr1262-v2.svg +222 -0
  263. package/skills/show/scripts/README.md +6 -0
  264. package/skills/show/scripts/validate-artifact.py +91 -0
  265. package/skills/show/scripts/validate-package.py +18 -0
  266. package/skills/show/templates/html-widget.html +4 -0
  267. package/skills/show/templates/svg-base.svg +19 -0
  268. package/skills/show/tests/fixtures/css-var.svg +6 -0
  269. package/skills/show/tests/fixtures/dead-ref.svg +7 -0
  270. package/skills/show/tests/fixtures/filled-glyph.svg +8 -0
  271. package/skills/show/tests/fixtures/inherited-fill.svg +18 -0
  272. package/skills/show/tests/fixtures/invalid.svg +1 -0
  273. package/skills/show/tests/fixtures/large-canvas.svg +21 -0
  274. package/skills/show/tests/fixtures/unfilled-connector.svg +15 -0
  275. package/skills/show/tests/fixtures/valid.html +1 -0
  276. package/skills/show/tests/test_validate-artifact.py +74 -0
  277. package/skills/show/tests/test_validators.py +59 -0
  278. package/skills/show/workflows/create-visual.md +13 -0
  279. package/skills/show/workflows/review-visual.md +20 -0
  280. package/skills/split-pr/SKILL.md +85 -0
  281. package/skills/split-pr/reference/path-layers.md +16 -0
  282. package/skills/split-pr/reference/proposal-format.md +15 -0
  283. package/skills/split-pr/reference/split-further-loop.md +10 -0
  284. package/skills/split-pr/reference/splitting-principles.md +26 -0
  285. package/skills/split-pr/scripts/analyze_pr.py +279 -0
  286. package/skills/split-pr/scripts/categorize_files.py +106 -0
  287. package/skills/split-pr/scripts/config/__init__.py +1 -0
  288. package/skills/split-pr/scripts/config/dependency_constants.py +14 -0
  289. package/skills/split-pr/scripts/config/git_operations_constants.py +36 -0
  290. package/skills/split-pr/scripts/config/packing_constants.py +61 -0
  291. package/skills/split-pr/scripts/config/plan_constants.py +49 -0
  292. package/skills/split-pr/scripts/config/split_pr_constants.py +110 -0
  293. package/skills/split-pr/scripts/execute_split_slices.py +82 -0
  294. package/skills/split-pr/scripts/pack_files_into_slices.py +212 -0
  295. package/skills/split-pr/scripts/split_pr_dependency_graph.py +70 -0
  296. package/skills/split-pr/scripts/split_pr_git_operations.py +184 -0
  297. package/skills/split-pr/scripts/split_pr_layer_order.py +58 -0
  298. package/skills/split-pr/scripts/split_pr_paginate.py +119 -0
  299. package/skills/split-pr/scripts/split_pr_process_runner.py +52 -0
  300. package/skills/split-pr/scripts/split_pr_script_types.py +126 -0
  301. package/skills/split-pr/scripts/split_pr_title.py +41 -0
  302. package/skills/split-pr/scripts/test_analyze_pr.py +228 -0
  303. package/skills/split-pr/scripts/test_categorize_files.py +55 -0
  304. package/skills/split-pr/scripts/test_categorize_files_packing.py +59 -0
  305. package/skills/split-pr/scripts/test_execute_split_slices.py +99 -0
  306. package/skills/split-pr/scripts/test_split_pr_dependency_graph.py +47 -0
  307. package/skills/split-pr/scripts/test_split_pr_git_operations.py +125 -0
  308. package/skills/split-pr/scripts/test_split_pr_layer_order.py +36 -0
  309. package/skills/split-pr/scripts/test_split_pr_paginate.py +65 -0
  310. package/skills/split-pr/scripts/test_split_pr_script_types.py +73 -0
  311. package/skills/split-pr/scripts/test_split_pr_title.py +28 -0
  312. package/skills/split-pr/scripts/test_verify_dependency_graph.py +46 -0
  313. package/skills/split-pr/scripts/test_verify_plan.py +56 -0
  314. package/skills/split-pr/scripts/test_verify_plan_contract.py +50 -0
  315. package/skills/split-pr/scripts/test_verify_plan_path_normalization.py +45 -0
  316. package/skills/split-pr/scripts/verify_dependency_graph.py +111 -0
  317. package/skills/split-pr/scripts/verify_plan.py +139 -0
  318. package/skills/task-build/reference/tool-routing.md +3 -0
  319. package/skills/team-advisor/SKILL.md +23 -44
  320. package/system-prompts/software-engineer.xml +17 -5
  321. package/commands/initialize.md +0 -90
  322. package/commands/stubcheck.md +0 -88
  323. package/skills/test_markdown_link_integrity.py +0 -107
@@ -14,6 +14,7 @@ fail-open malformed input.
14
14
  from __future__ import annotations
15
15
 
16
16
  import json
17
+ import os
17
18
  import subprocess
18
19
  import sys
19
20
  from pathlib import Path
@@ -39,13 +40,27 @@ from hooks_constants.pre_tool_use_dispatcher_constants import ( # noqa: E402, I
39
40
  HostedHookEntry,
40
41
  )
41
42
  from pre_tool_use_dispatcher import ( # noqa: E402, I001
43
+ DispatcherDecision,
42
44
  HostedHookResult,
45
+ _emit_deny_decision,
43
46
  aggregate_hosted_hook_results,
44
47
  run_hosted_hook,
48
+ unique_first_seen_strings,
45
49
  )
46
50
 
47
51
  _DISPATCHER_SCRIPT = str(_BLOCKING_DIR / "pre_tool_use_dispatcher.py")
48
52
 
53
+ _PROSE_STYLE_ENV_VAR = "CLAUDE_PROSE_STYLE_ENFORCEMENT"
54
+ _PROSE_STYLE_ENV_VALUE = "1"
55
+
56
+
57
+ def _subprocess_environment() -> dict[str, str]:
58
+ """Return process env with opinionated prose gates enabled for golden tests."""
59
+ environment_by_key = os.environ.copy()
60
+ environment_by_key[_PROSE_STYLE_ENV_VAR] = _PROSE_STYLE_ENV_VALUE
61
+ return environment_by_key
62
+
63
+
49
64
  _TEMP_FILE_PATH = str(_HOOKS_ROOT.parent.parent.parent / "tmp" / "dispatcher_test_dummy.txt")
50
65
  _MARKDOWN_FILE_PATH = str(_HOOKS_ROOT.parent.parent.parent / "tmp" / "dispatcher_test_dummy.md")
51
66
 
@@ -70,6 +85,7 @@ def _run_hook_subprocess(
70
85
  capture_output=True,
71
86
  text=True,
72
87
  encoding="utf-8",
88
+ env=_subprocess_environment(),
73
89
  )
74
90
 
75
91
 
@@ -89,6 +105,7 @@ def _run_dispatcher(payload_text: str) -> subprocess.CompletedProcess[str]:
89
105
  capture_output=True,
90
106
  text=True,
91
107
  encoding="utf-8",
108
+ env=_subprocess_environment(),
92
109
  )
93
110
 
94
111
 
@@ -272,8 +289,8 @@ def test_clean_write_allows_on_multi_edit_tool() -> None:
272
289
  _assert_dispatcher_matches_individual_hooks(payload_text, MULTI_EDIT_TOOL_NAME)
273
290
 
274
291
 
275
- def test_plain_language_denial_on_write_of_markdown_file() -> None:
276
- """Dispatcher denies when plain_language_blocker denies a Write of heavy prose."""
292
+ def test_plain_language_heavy_prose_allows_on_write_of_markdown_file() -> None:
293
+ """Dispatcher matches individual hooks: heavy prose allows (OP-07D advisory)."""
277
294
  payload_text = _write_payload(
278
295
  _MARKDOWN_FILE_PATH,
279
296
  "# Guide\n\nPlease utilize this functionality to commence the process.\n",
@@ -281,8 +298,8 @@ def test_plain_language_denial_on_write_of_markdown_file() -> None:
281
298
  _assert_dispatcher_matches_individual_hooks(payload_text, WRITE_TOOL_NAME)
282
299
 
283
300
 
284
- def test_plain_language_denial_on_edit_of_markdown_file() -> None:
285
- """Dispatcher denies when plain_language_blocker denies an Edit with heavy prose."""
301
+ def test_plain_language_heavy_prose_allows_on_edit_of_markdown_file() -> None:
302
+ """Dispatcher matches individual hooks: heavy prose Edit allows (OP-07D)."""
286
303
  payload_text = _edit_payload(
287
304
  _MARKDOWN_FILE_PATH,
288
305
  "old line",
@@ -291,8 +308,8 @@ def test_plain_language_denial_on_edit_of_markdown_file() -> None:
291
308
  _assert_dispatcher_matches_individual_hooks(payload_text, EDIT_TOOL_NAME)
292
309
 
293
310
 
294
- def test_plain_language_denial_on_multi_edit_of_markdown_file() -> None:
295
- """Dispatcher denies when plain_language_blocker denies a MultiEdit with heavy prose."""
311
+ def test_plain_language_heavy_prose_allows_on_multi_edit_of_markdown_file() -> None:
312
+ """Dispatcher matches individual hooks: heavy prose MultiEdit allows (OP-07D)."""
296
313
  payload_text = _multi_edit_payload(
297
314
  _MARKDOWN_FILE_PATH,
298
315
  [{"old_string": "old", "new_string": "Please utilize this functionality to commence."}],
@@ -312,13 +329,11 @@ def test_dispatcher_docstring_points_at_roster_not_hardcoded_counts() -> None:
312
329
  def test_multi_edit_runs_only_group_b_hooks() -> None:
313
330
  """Dispatcher invokes only Group-B hooks on MultiEdit, not Group-A hooks.
314
331
 
315
- A plain_language_blocker denial on a MultiEdit proves Group B runs.
316
332
  The write_existing_file_blocker (Group A only) must not run on MultiEdit,
317
333
  so a MultiEdit to any path that would trip a Group-A hook must still allow.
318
- This test proves Group A does not run on MultiEdit by asserting:
319
- 1. Group-B (plain_language_blocker) fires on MultiEdit for a markdown file
320
- with heavy prose.
321
- 2. The set of applicable entries for MultiEdit contains no Group-A entries.
334
+ Heavy prose no longer denies (OP-07D); this test proves Group A is absent
335
+ from MultiEdit and that plain_language_blocker stays in the MultiEdit set
336
+ and still runs (allow-with-advisory on heavy prose).
322
337
  """
323
338
  all_multi_edit_entries = _applicable_entries_for_tool(MULTI_EDIT_TOOL_NAME)
324
339
  all_write_only_entries = [
@@ -334,15 +349,20 @@ def test_multi_edit_runs_only_group_b_hooks() -> None:
334
349
  f"Group-A hook {each_group_a_entry.script_relative_path!r} "
335
350
  "appears in the MultiEdit applicable set — it must not"
336
351
  )
352
+ assert "blocking/plain_language_blocker.py" in all_multi_edit_script_paths, (
353
+ "plain_language_blocker (Group B) must stay in the MultiEdit applicable set"
354
+ )
337
355
  heavy_prose_payload = _multi_edit_payload(
338
356
  _MARKDOWN_FILE_PATH,
339
357
  [{"old_string": "old line", "new_string": "Utilize this to commence the process."}],
340
358
  )
341
359
  dispatcher_result = _run_dispatcher(heavy_prose_payload)
342
360
  dispatcher_is_deny, _reason = _parse_hook_decision(dispatcher_result)
343
- assert dispatcher_is_deny, (
344
- "Dispatcher should deny a MultiEdit with heavy prose (plain_language_blocker "
345
- "is a Group-B hook and must run on MultiEdit)"
361
+ assert not dispatcher_is_deny, (
362
+ "Dispatcher should allow MultiEdit heavy prose (OP-07D advisory path)"
363
+ )
364
+ assert dispatcher_result.stdout.strip(), (
365
+ "plain_language_blocker should emit an allow advisory on MultiEdit heavy prose"
346
366
  )
347
367
 
348
368
 
@@ -431,16 +451,17 @@ def test_context_survives_alongside_deny_reason(
431
451
  def test_all_deny_reasons_present_when_multiple_hooks_deny() -> None:
432
452
  """When two or more hooks deny, all their reasons appear in the dispatcher output.
433
453
 
434
- Uses a Write to a .md file carrying both a historical phrase ("previously")
435
- and a heavy word ("utilize") so state_description_blocker (Group A) and
436
- plain_language_blocker (Group B) both deny deterministically with no
437
- real-filesystem dependency.
454
+ Uses a Write to an existing markdown path with a historical phrase
455
+ ("previously") so write_existing_file_blocker and state_description_blocker
456
+ both deny. Heavy words no longer hard-deny (OP-07D), so plain_language is
457
+ not a denier.
438
458
  """
459
+ existing_markdown_path = str(Path(__file__).resolve().parent / "CLAUDE.md")
439
460
  multi_deny_content = (
440
461
  "# Guide\n\n"
441
- "Previously the system utilized a different mechanism.\n"
462
+ "Previously the system used a different mechanism.\n"
442
463
  )
443
- payload_text = _write_payload(_MARKDOWN_FILE_PATH, multi_deny_content)
464
+ payload_text = _write_payload(existing_markdown_path, multi_deny_content)
444
465
  _assert_dispatcher_matches_individual_hooks(payload_text, WRITE_TOOL_NAME)
445
466
 
446
467
  dispatcher_result = _run_dispatcher(payload_text)
@@ -457,8 +478,8 @@ def test_all_deny_reasons_present_when_multiple_hooks_deny() -> None:
457
478
 
458
479
  assert len(all_expected_deny_reasons) >= 2, (
459
480
  f"Test payload must trip at least two hooks — got {len(all_expected_deny_reasons)}. "
460
- "Check that 'previously' triggers state_description_blocker and 'utilized' "
461
- "triggers plain_language_blocker on a .md Write."
481
+ "Check that an existing path triggers write_existing_file_blocker and "
482
+ "'previously' triggers state_description_blocker on a Write."
462
483
  )
463
484
  for each_reason in all_expected_deny_reasons:
464
485
  assert each_reason in dispatcher_reason, (
@@ -613,16 +634,68 @@ def test_aggregate_explicit_allow_is_overridden_by_a_deny() -> None:
613
634
  )
614
635
 
615
636
 
637
+ def test_unique_first_seen_strings_keeps_order_and_drops_exact_duplicates() -> None:
638
+ assert unique_first_seen_strings(["a", "b", "a", "c", "b"]) == ["a", "b", "c"]
639
+ assert unique_first_seen_strings(["Same", "same"]) == ["Same", "same"]
640
+
641
+
642
+ def test_emit_deny_collapses_identical_reasons_and_context(
643
+ capsys: pytest.CaptureFixture[str],
644
+ ) -> None:
645
+ """Final JSON keeps each exact string once; distinct reasons stay; suppress stays."""
646
+ shared_text = "BLOCKED: same corrective text"
647
+ decision = DispatcherDecision(
648
+ should_deny=True,
649
+ should_allow=False,
650
+ all_deny_reasons=[shared_text, "BLOCKED: other reason", shared_text],
651
+ all_system_messages=[shared_text, shared_text, "other notice"],
652
+ all_additional_context=["ctx-a", "ctx-a", "ctx-b"],
653
+ should_suppress_output=True,
654
+ )
655
+ _emit_deny_decision(decision)
656
+ captured = capsys.readouterr().out
657
+ parsed = json.loads(captured)
658
+ reason = parsed["hookSpecificOutput"]["permissionDecisionReason"]
659
+ assert reason == f"{shared_text} | BLOCKED: other reason"
660
+ assert reason.count(shared_text) == 1
661
+ system_message = parsed["systemMessage"]
662
+ assert system_message == f"{shared_text}\nother notice"
663
+ assert parsed["hookSpecificOutput"]["additionalContext"] == "ctx-a\nctx-b"
664
+ assert parsed["suppressOutput"] is True
665
+ assert parsed["hookSpecificOutput"]["permissionDecision"] == "deny"
666
+
667
+
668
+ def test_emit_deny_does_not_cross_collapse_reason_and_context(
669
+ capsys: pytest.CaptureFixture[str],
670
+ ) -> None:
671
+ """A deny reason equal to a systemMessage string stays in both fields."""
672
+ shared_text = "identical across fields"
673
+ decision = DispatcherDecision(
674
+ should_deny=True,
675
+ should_allow=False,
676
+ all_deny_reasons=[shared_text],
677
+ all_system_messages=[shared_text],
678
+ all_additional_context=[shared_text],
679
+ should_suppress_output=False,
680
+ )
681
+ _emit_deny_decision(decision)
682
+ parsed = json.loads(capsys.readouterr().out)
683
+ assert parsed["hookSpecificOutput"]["permissionDecisionReason"] == shared_text
684
+ assert parsed["systemMessage"] == shared_text
685
+ assert parsed["hookSpecificOutput"]["additionalContext"] == shared_text
686
+
687
+
616
688
  def test_later_hook_deny_survives_early_hook_exit() -> None:
617
689
  """Dispatcher denies even when an earlier hook exits cleanly before a later hook denies.
618
690
 
619
- plain_language_blocker (Group B, last in order) denies a markdown write with heavy
620
- prose. Earlier hooks exit 0 (allow). The dispatcher must catch each hook's
621
- SystemExit and continue, so the later denial reaches the aggregator.
691
+ state_description_blocker denies a markdown write with a historical phrase.
692
+ Earlier hooks exit 0 (allow). Heavy words no longer hard-deny (OP-07D). The
693
+ dispatcher must catch each hook's SystemExit and continue, so the later
694
+ denial reaches the aggregator.
622
695
  """
623
696
  payload_text = _write_payload(
624
697
  _MARKDOWN_FILE_PATH,
625
- "# Doc\n\nThis section attempts to facilitate the utilization of this functionality.\n",
698
+ "# Doc\n\nPreviously this section used a different mechanism.\n",
626
699
  )
627
700
  _assert_dispatcher_matches_individual_hooks(payload_text, WRITE_TOOL_NAME)
628
701
 
@@ -13,14 +13,22 @@ equivalence holds across both outcomes.
13
13
  from __future__ import annotations
14
14
 
15
15
  import json
16
+ import os
16
17
  import subprocess
17
18
  import sys
18
19
  from pathlib import Path
20
+ from unittest.mock import patch
21
+
22
+ import pytest
19
23
 
20
24
  _HOOKS_DIR = str(Path(__file__).resolve().parent.parent)
21
25
  if _HOOKS_DIR not in sys.path:
22
26
  sys.path.insert(0, _HOOKS_DIR)
23
27
 
28
+ _PROSE_STYLE_ENFORCEMENT_ENVIRONMENT = {
29
+ "CLAUDE_PROSE_STYLE_ENFORCEMENT": "1",
30
+ }
31
+
24
32
  from hooks_constants.pre_tool_use_dispatcher_constants import ( # noqa: E402, I001
25
33
  DENY_DECISION,
26
34
  EDIT_TOOL_NAME,
@@ -43,6 +51,19 @@ _STATE_DESCRIPTION_DENY_CONTENT = "# Guide\n\nPreviously the system used port 80
43
51
  _PLAIN_LANGUAGE_ALLOW_CONTENT = "# Guide\n\nStart the build to make the report.\n"
44
52
  _PLAIN_LANGUAGE_DENY_CONTENT = "# Guide\n\nUtilize this to commence the process.\n"
45
53
 
54
+ _PROSE_STYLE_ENV_VAR = "CLAUDE_PROSE_STYLE_ENFORCEMENT"
55
+ _PROSE_STYLE_ENV_VALUE = "1"
56
+
57
+ @pytest.fixture(autouse=True)
58
+ def enable_prose_style_enforcement(monkeypatch: pytest.MonkeyPatch) -> None:
59
+ """Opinionated prose gates are default-off; native-equivalence tests arm them."""
60
+ monkeypatch.setenv(_PROSE_STYLE_ENV_VAR, _PROSE_STYLE_ENV_VALUE)
61
+
62
+ def _subprocess_environment() -> dict[str, str]:
63
+ """Return process env with opinionated prose gates enabled."""
64
+ environment_by_key = os.environ.copy()
65
+ environment_by_key[_PROSE_STYLE_ENV_VAR] = _PROSE_STYLE_ENV_VALUE
66
+ return environment_by_key
46
67
 
47
68
  def _write_payload_dictionary(file_path: str, content: str) -> dict[str, object]:
48
69
  """Build a Write tool payload dict.
@@ -59,7 +80,6 @@ def _write_payload_dictionary(file_path: str, content: str) -> dict[str, object]
59
80
  "tool_input": {"file_path": file_path, "content": content},
60
81
  }
61
82
 
62
-
63
83
  def _edit_payload_dictionary(file_path: str, new_string: str) -> dict[str, object]:
64
84
  """Build an Edit tool payload dict.
65
85
 
@@ -79,7 +99,6 @@ def _edit_payload_dictionary(file_path: str, new_string: str) -> dict[str, objec
79
99
  },
80
100
  }
81
101
 
82
-
83
102
  def _multi_edit_payload_dictionary(file_path: str, new_string: str) -> dict[str, object]:
84
103
  """Build a MultiEdit tool payload dict.
85
104
 
@@ -99,6 +118,14 @@ def _multi_edit_payload_dictionary(file_path: str, new_string: str) -> dict[str,
99
118
  }
100
119
 
101
120
 
121
+ def _evaluate_with_prose_style_opt_in(
122
+ evaluate_callable: object, payload_dictionary: dict[str, object]
123
+ ) -> str | None:
124
+ """Call a native evaluate with opinionated prose enforcement enabled."""
125
+ with patch.dict(os.environ, _PROSE_STYLE_ENFORCEMENT_ENVIRONMENT):
126
+ return evaluate_callable(payload_dictionary) # type: ignore[operator]
127
+
128
+
102
129
  def _run_script_subprocess(script_path: str, payload_dictionary: dict[str, object]) -> str:
103
130
  """Run a hook script as a subprocess and return its stripped stdout.
104
131
 
@@ -116,10 +143,10 @@ def _run_script_subprocess(script_path: str, payload_dictionary: dict[str, objec
116
143
  capture_output=True,
117
144
  text=True,
118
145
  encoding="utf-8",
146
+ env=_subprocess_environment(),
119
147
  )
120
148
  return completed_process.stdout.strip()
121
149
 
122
-
123
150
  def _deny_reason_from_script_stdout(stdout_text: str) -> str | None:
124
151
  """Parse a script's stdout into its deny-reason text, or None for allow.
125
152
 
@@ -139,7 +166,6 @@ def _deny_reason_from_script_stdout(stdout_text: str) -> str | None:
139
166
  reason_text = hook_specific.get("permissionDecisionReason", "")
140
167
  return reason_text if isinstance(reason_text, str) else None
141
168
 
142
-
143
169
  def _deny_reason_from_dispatcher(payload_dictionary: dict[str, object]) -> str | None:
144
170
  """Run the dispatcher as a subprocess and return its deny-reason text.
145
171
 
@@ -157,10 +183,10 @@ def _deny_reason_from_dispatcher(payload_dictionary: dict[str, object]) -> str |
157
183
  capture_output=True,
158
184
  text=True,
159
185
  encoding="utf-8",
186
+ env=_subprocess_environment(),
160
187
  )
161
188
  return _deny_reason_from_script_stdout(completed_process.stdout.strip())
162
189
 
163
-
164
190
  def _deny_payload_from_dispatcher(payload_dictionary: dict[str, object]) -> dict[str, object]:
165
191
  """Run the dispatcher as a subprocess and return its parsed deny payload.
166
192
 
@@ -177,145 +203,157 @@ def _deny_payload_from_dispatcher(payload_dictionary: dict[str, object]) -> dict
177
203
  capture_output=True,
178
204
  text=True,
179
205
  encoding="utf-8",
206
+ env=_subprocess_environment(),
180
207
  )
181
208
  parsed_payload = json.loads(completed_process.stdout.strip())
182
209
  assert isinstance(parsed_payload, dict)
183
210
  return parsed_payload
184
211
 
185
-
186
212
  def test_state_description_native_allows_match_script() -> None:
187
213
  """state_description_blocker native allow matches the script's allow."""
188
214
  payload_dictionary = _write_payload_dictionary(_MARKDOWN_PATH, _STATE_DESCRIPTION_ALLOW_CONTENT)
189
- native_reason = state_description_blocker.evaluate(payload_dictionary)
215
+ native_reason = _evaluate_with_prose_style_opt_in(state_description_blocker.evaluate, payload_dictionary)
190
216
  script_stdout = _run_script_subprocess(_STATE_DESCRIPTION_SCRIPT, payload_dictionary)
191
217
  script_reason = _deny_reason_from_script_stdout(script_stdout)
192
218
  assert native_reason is None
193
219
  assert script_reason is None
194
220
 
195
-
196
221
  def test_state_description_native_deny_matches_script_reason() -> None:
197
222
  """state_description_blocker native deny reason matches the script's reason."""
198
223
  payload_dictionary = _write_payload_dictionary(_MARKDOWN_PATH, _STATE_DESCRIPTION_DENY_CONTENT)
199
- native_reason = state_description_blocker.evaluate(payload_dictionary)
224
+ native_reason = _evaluate_with_prose_style_opt_in(state_description_blocker.evaluate, payload_dictionary)
200
225
  script_stdout = _run_script_subprocess(_STATE_DESCRIPTION_SCRIPT, payload_dictionary)
201
226
  script_reason = _deny_reason_from_script_stdout(script_stdout)
202
227
  assert native_reason is not None
203
228
  assert native_reason == script_reason
204
229
 
205
-
206
230
  def test_state_description_native_edit_deny_matches_script_reason() -> None:
207
231
  """state_description_blocker native and script agree on an Edit denial."""
208
232
  payload_dictionary = _edit_payload_dictionary(
209
233
  _MARKDOWN_PATH, "Previously this used the old client.\n"
210
234
  )
211
- native_reason = state_description_blocker.evaluate(payload_dictionary)
235
+ native_reason = _evaluate_with_prose_style_opt_in(state_description_blocker.evaluate, payload_dictionary)
212
236
  script_stdout = _run_script_subprocess(_STATE_DESCRIPTION_SCRIPT, payload_dictionary)
213
237
  script_reason = _deny_reason_from_script_stdout(script_stdout)
214
238
  assert native_reason is not None
215
239
  assert native_reason == script_reason
216
240
 
217
-
218
241
  def test_state_description_native_non_target_tool_allows_match_script() -> None:
219
242
  """state_description_blocker native allows MultiEdit, matching the script."""
220
243
  payload_dictionary = _multi_edit_payload_dictionary(
221
244
  _MARKDOWN_PATH, _STATE_DESCRIPTION_DENY_CONTENT
222
245
  )
223
- native_reason = state_description_blocker.evaluate(payload_dictionary)
246
+ native_reason = _evaluate_with_prose_style_opt_in(state_description_blocker.evaluate, payload_dictionary)
224
247
  script_stdout = _run_script_subprocess(_STATE_DESCRIPTION_SCRIPT, payload_dictionary)
225
248
  script_reason = _deny_reason_from_script_stdout(script_stdout)
226
249
  assert native_reason is None
227
250
  assert script_reason is None
228
251
 
229
-
230
252
  def test_plain_language_native_allows_match_script() -> None:
231
253
  """plain_language_blocker native allow matches the script's allow."""
232
254
  payload_dictionary = _write_payload_dictionary(_MARKDOWN_PATH, _PLAIN_LANGUAGE_ALLOW_CONTENT)
233
- native_reason = plain_language_blocker.evaluate(payload_dictionary)
255
+ native_reason = _evaluate_with_prose_style_opt_in(plain_language_blocker.evaluate, payload_dictionary)
234
256
  script_stdout = _run_script_subprocess(_PLAIN_LANGUAGE_SCRIPT, payload_dictionary)
235
257
  script_reason = _deny_reason_from_script_stdout(script_stdout)
236
258
  assert native_reason is None
237
259
  assert script_reason is None
238
260
 
239
261
 
240
- def test_plain_language_native_deny_matches_script_reason() -> None:
241
- """plain_language_blocker native deny reason matches the script's reason."""
262
+ def test_plain_language_native_heavy_word_advisory_matches_script() -> None:
263
+ """plain_language_blocker native and script agree: no deny, allow advisory."""
242
264
  payload_dictionary = _write_payload_dictionary(_MARKDOWN_PATH, _PLAIN_LANGUAGE_DENY_CONTENT)
243
- native_reason = plain_language_blocker.evaluate(payload_dictionary)
265
+ with patch.dict(os.environ, _PROSE_STYLE_ENFORCEMENT_ENVIRONMENT):
266
+ native_deny, native_advisory = plain_language_blocker.evaluate_with_advisory(
267
+ payload_dictionary
268
+ )
244
269
  script_stdout = _run_script_subprocess(_PLAIN_LANGUAGE_SCRIPT, payload_dictionary)
245
270
  script_reason = _deny_reason_from_script_stdout(script_stdout)
246
- assert native_reason is not None
247
- assert native_reason == script_reason
271
+ assert native_deny is None
272
+ assert script_reason is None
273
+ assert native_advisory is not None
274
+ assert "utilize" in native_advisory.lower() or "commence" in native_advisory.lower()
275
+ script_payload = json.loads(script_stdout)
276
+ assert script_payload["hookSpecificOutput"]["permissionDecision"] == "allow"
277
+ assert native_advisory in script_payload["systemMessage"]
248
278
 
249
279
 
250
- def test_plain_language_native_multi_edit_deny_matches_script_reason() -> None:
251
- """plain_language_blocker native and script agree on a MultiEdit denial."""
280
+ def test_plain_language_native_multi_edit_heavy_word_advisory_matches_script() -> None:
281
+ """plain_language_blocker native and script agree on MultiEdit heavy-word allow."""
252
282
  payload_dictionary = _multi_edit_payload_dictionary(
253
283
  _MARKDOWN_PATH, "Utilize this to commence the process.\n"
254
284
  )
255
- native_reason = plain_language_blocker.evaluate(payload_dictionary)
285
+ with patch.dict(os.environ, _PROSE_STYLE_ENFORCEMENT_ENVIRONMENT):
286
+ native_deny, native_advisory = plain_language_blocker.evaluate_with_advisory(
287
+ payload_dictionary
288
+ )
256
289
  script_stdout = _run_script_subprocess(_PLAIN_LANGUAGE_SCRIPT, payload_dictionary)
257
290
  script_reason = _deny_reason_from_script_stdout(script_stdout)
258
- assert native_reason is not None
259
- assert native_reason == script_reason
260
-
291
+ assert native_deny is None
292
+ assert script_reason is None
293
+ assert native_advisory is not None
294
+ script_payload = json.loads(script_stdout)
295
+ assert script_payload["hookSpecificOutput"]["permissionDecision"] == "allow"
296
+ assert native_advisory in script_payload["systemMessage"]
261
297
 
262
298
  def test_plain_language_native_non_markdown_allows_match_script() -> None:
263
299
  """plain_language_blocker native allows a non-markdown Write, matching the script."""
264
300
  payload_dictionary = _write_payload_dictionary(_PYTHON_PATH, _PLAIN_LANGUAGE_DENY_CONTENT)
265
- native_reason = plain_language_blocker.evaluate(payload_dictionary)
301
+ native_reason = _evaluate_with_prose_style_opt_in(plain_language_blocker.evaluate, payload_dictionary)
266
302
  script_stdout = _run_script_subprocess(_PLAIN_LANGUAGE_SCRIPT, payload_dictionary)
267
303
  script_reason = _deny_reason_from_script_stdout(script_stdout)
268
304
  assert native_reason is None
269
305
  assert script_reason is None
270
306
 
271
-
272
307
  def test_dispatcher_native_path_denies_state_description() -> None:
273
308
  """The dispatcher's native path denies a state_description_blocker violation."""
274
309
  payload_dictionary = _write_payload_dictionary(_MARKDOWN_PATH, _STATE_DESCRIPTION_DENY_CONTENT)
275
- native_reason = state_description_blocker.evaluate(payload_dictionary)
310
+ native_reason = _evaluate_with_prose_style_opt_in(state_description_blocker.evaluate, payload_dictionary)
276
311
  dispatcher_reason = _deny_reason_from_dispatcher(payload_dictionary)
277
312
  assert native_reason is not None
278
313
  assert dispatcher_reason is not None
279
314
  assert native_reason in dispatcher_reason
280
315
 
281
316
 
282
- def test_dispatcher_native_path_denies_plain_language() -> None:
283
- """The dispatcher's native path denies a plain_language_blocker violation."""
317
+ def test_dispatcher_native_path_allows_plain_language_heavy_word() -> None:
318
+ """The dispatcher's native path allows heavy words and does not deny."""
284
319
  payload_dictionary = _multi_edit_payload_dictionary(
285
320
  _MARKDOWN_PATH, "Utilize this to commence the process.\n"
286
321
  )
287
- native_reason = plain_language_blocker.evaluate(payload_dictionary)
322
+ native_reason = _evaluate_with_prose_style_opt_in(
323
+ plain_language_blocker.evaluate, payload_dictionary
324
+ )
288
325
  dispatcher_reason = _deny_reason_from_dispatcher(payload_dictionary)
289
- assert native_reason is not None
290
- assert dispatcher_reason is not None
291
- assert native_reason in dispatcher_reason
326
+ assert native_reason is None
327
+ assert dispatcher_reason is None
292
328
 
293
329
 
294
- def test_dispatcher_native_plain_language_carries_system_message() -> None:
295
- """The dispatcher's plain-language deny carries the standalone systemMessage."""
330
+ def test_dispatcher_native_plain_language_carries_advisory_system_message() -> None:
331
+ """The dispatcher's plain-language allow carries the advisory systemMessage."""
296
332
  payload_dictionary = _write_payload_dictionary(_MARKDOWN_PATH, _PLAIN_LANGUAGE_DENY_CONTENT)
297
- deny_reason = plain_language_blocker.evaluate(payload_dictionary)
298
- assert deny_reason is not None
299
- standalone_payload = plain_language_blocker.build_deny_payload(deny_reason)
300
- expected_system_message = standalone_payload["systemMessage"]
301
- assert isinstance(expected_system_message, str)
333
+ with patch.dict(os.environ, _PROSE_STYLE_ENFORCEMENT_ENVIRONMENT):
334
+ deny_reason, advisory_message = plain_language_blocker.evaluate_with_advisory(
335
+ payload_dictionary
336
+ )
337
+ assert deny_reason is None
338
+ assert advisory_message is not None
302
339
  dispatcher_payload = _deny_payload_from_dispatcher(payload_dictionary)
340
+ assert dispatcher_payload["hookSpecificOutput"]["permissionDecision"] == "allow"
303
341
  dispatcher_system_message = dispatcher_payload.get("systemMessage")
304
342
  assert isinstance(dispatcher_system_message, str)
305
- assert expected_system_message in dispatcher_system_message
343
+ assert advisory_message in dispatcher_system_message
306
344
 
307
345
 
308
- def test_dispatcher_native_plain_language_carries_suppress_output() -> None:
309
- """The dispatcher's plain-language deny carries the standalone suppressOutput flag."""
346
+ def test_dispatcher_native_plain_language_allow_does_not_suppress_output() -> None:
347
+ """The dispatcher's plain-language advisory allow leaves suppressOutput off."""
310
348
  payload_dictionary = _write_payload_dictionary(_MARKDOWN_PATH, _PLAIN_LANGUAGE_DENY_CONTENT)
311
349
  dispatcher_payload = _deny_payload_from_dispatcher(payload_dictionary)
312
- assert dispatcher_payload.get("suppressOutput") is True
313
-
350
+ assert dispatcher_payload.get("suppressOutput") is not True
351
+ assert dispatcher_payload["hookSpecificOutput"]["permissionDecision"] == "allow"
314
352
 
315
353
  def test_dispatcher_native_state_description_carries_additional_context() -> None:
316
354
  """The dispatcher's state-description deny carries the standalone additionalContext."""
317
355
  payload_dictionary = _write_payload_dictionary(_MARKDOWN_PATH, _STATE_DESCRIPTION_DENY_CONTENT)
318
- deny_reason = state_description_blocker.evaluate(payload_dictionary)
356
+ deny_reason = _evaluate_with_prose_style_opt_in(state_description_blocker.evaluate, payload_dictionary)
319
357
  assert deny_reason is not None
320
358
  standalone_payload = state_description_blocker.build_deny_payload(deny_reason)
321
359
  standalone_hook_specific = standalone_payload["hookSpecificOutput"]
@@ -326,11 +364,10 @@ def test_dispatcher_native_state_description_carries_additional_context() -> Non
326
364
  assert isinstance(dispatcher_hook_specific, dict)
327
365
  assert dispatcher_hook_specific.get("additionalContext") == expected_additional_context
328
366
 
329
-
330
367
  def test_dispatcher_native_state_description_carries_system_message() -> None:
331
368
  """The dispatcher's state-description deny carries the standalone systemMessage."""
332
369
  payload_dictionary = _write_payload_dictionary(_MARKDOWN_PATH, _STATE_DESCRIPTION_DENY_CONTENT)
333
- deny_reason = state_description_blocker.evaluate(payload_dictionary)
370
+ deny_reason = _evaluate_with_prose_style_opt_in(state_description_blocker.evaluate, payload_dictionary)
334
371
  assert deny_reason is not None
335
372
  standalone_payload = state_description_blocker.build_deny_payload(deny_reason)
336
373
  expected_system_message = standalone_payload["systemMessage"]
@@ -19,8 +19,8 @@ if _HOOKS_ROOT not in sys.path:
19
19
  from pre_tool_use_dispatcher import NativeHook, run_native_hook # noqa: E402
20
20
  from state_description_blocker import ( # noqa: E402
21
21
  build_deny_payload,
22
- evaluate,
23
22
  )
23
+ from state_description_blocker import evaluate as _evaluate_without_opt_in # noqa: E402
24
24
 
25
25
  HOOK_SCRIPT_PATH = os.path.join(
26
26
  os.path.dirname(__file__), "state_description_blocker.py"
@@ -39,23 +39,66 @@ VIOLATION_MD_PREVIOUSLY = "# Config\n\nPreviously set via env var."
39
39
  VIOLATION_MD_NOW_USES = "# Auth\n\nNow uses OAuth2."
40
40
 
41
41
 
42
+ def evaluate(payload_by_key: dict[str, object]) -> str | None:
43
+ """Call evaluate with opinionated prose enforcement enabled."""
44
+ with patch.dict(os.environ, {"CLAUDE_PROSE_STYLE_ENFORCEMENT": "1"}):
45
+ return _evaluate_without_opt_in(payload_by_key)
46
+
47
+
42
48
  class _RunHook:
43
49
  """Helper to test the hook via subprocess."""
44
50
 
45
- def __call__(self, tool_name: str, tool_input: dict) -> subprocess.CompletedProcess:
51
+ def __call__(
52
+ self,
53
+ tool_name: str,
54
+ tool_input: dict,
55
+ *,
56
+ is_prose_style_enabled: bool = True,
57
+ ) -> subprocess.CompletedProcess:
46
58
  payload = json.dumps({"tool_name": tool_name, "tool_input": tool_input})
59
+ environment_by_key = os.environ.copy()
60
+ if is_prose_style_enabled:
61
+ environment_by_key["CLAUDE_PROSE_STYLE_ENFORCEMENT"] = "1"
62
+ else:
63
+ environment_by_key.pop("CLAUDE_PROSE_STYLE_ENFORCEMENT", None)
47
64
  return subprocess.run(
48
65
  [sys.executable, HOOK_SCRIPT_PATH],
49
66
  input=payload,
50
67
  capture_output=True,
51
68
  text=True,
52
69
  check=False,
70
+ env=environment_by_key,
53
71
  )
54
72
 
55
73
 
56
74
  _run_hook = _RunHook()
57
75
 
58
76
 
77
+ def test_historical_phrase_scan_is_default_off(
78
+ monkeypatch: pytest.MonkeyPatch,
79
+ ) -> None:
80
+ monkeypatch.delenv("CLAUDE_PROSE_STYLE_ENFORCEMENT", raising=False)
81
+ result = _run_hook(
82
+ "Write",
83
+ {
84
+ "file_path": "src/main.py",
85
+ "content": VIOLATION_INSTEAD_OF_COMMENT,
86
+ },
87
+ is_prose_style_enabled=False,
88
+ )
89
+ assert result.returncode == 0
90
+ assert result.stdout == ""
91
+ assert _evaluate_without_opt_in(
92
+ {
93
+ "tool_name": "Write",
94
+ "tool_input": {
95
+ "file_path": "src/main.py",
96
+ "content": VIOLATION_INSTEAD_OF_COMMENT,
97
+ },
98
+ }
99
+ ) is None
100
+
101
+
59
102
  def test_block_clean_python_comment_passes():
60
103
  result = _run_hook(
61
104
  "Write",