claude-dev-env 2.9.0 → 2.11.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 (318) hide show
  1. package/CLAUDE.md +2 -2
  2. package/_shared/advisor/CLAUDE.md +3 -2
  3. package/_shared/advisor/advisor-protocol.md +74 -108
  4. package/_shared/advisor/reference/advisor-block.md +37 -0
  5. package/_shared/advisor/reference/cli-chain.md +45 -0
  6. package/_shared/advisor/reference/consult-format.md +41 -0
  7. package/_shared/advisor/reference/lifecycle.md +21 -0
  8. package/_shared/advisor/reference/sol-rung.md +31 -0
  9. package/_shared/advisor/reference/spawn-walk-log.md +31 -0
  10. package/_shared/advisor/reference/third-party-bind.md +30 -0
  11. package/_shared/advisor/reference/warm-up.md +33 -0
  12. package/_shared/advisor/scripts/codex_sol_advisor.py +449 -0
  13. package/_shared/advisor/scripts/config/advisor_scripts_constants/advisor_route_constants.py +21 -0
  14. package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +19 -17
  15. package/_shared/advisor/scripts/config/advisor_scripts_constants/sol_advisor_constants.py +28 -0
  16. package/_shared/advisor/scripts/model_tier_run_validator.py +32 -9
  17. package/_shared/advisor/scripts/tests/test_codex_sol_advisor.py +474 -0
  18. package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +79 -0
  19. package/_shared/advisor/scripts/tests/test_tier_model_ids.py +39 -17
  20. package/_shared/advisor/scripts/tier_model_ids.py +24 -0
  21. package/_shared/pr-loop/CLAUDE.md +1 -1
  22. package/_shared/pr-loop/audit-contract.md +17 -6
  23. package/_shared/pr-loop/audit-reply-template.md +4 -4
  24. package/_shared/pr-loop/code-rules-gate.md +3 -5
  25. package/_shared/pr-loop/fix-protocol.md +2 -3
  26. package/_shared/pr-loop/gh-payloads.md +1 -1
  27. package/_shared/pr-loop/scripts/CLAUDE.md +1 -1
  28. package/_shared/pr-loop/scripts/README.md +1 -1
  29. package/_shared/pr-loop/scripts/code_rules_gate.py +2 -0
  30. package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_running.py +16 -1
  31. package/_shared/pr-loop/scripts/code_rules_gate_parts/git_blob_readers.py +11 -5
  32. package/_shared/pr-loop/scripts/preflight.py +9 -4
  33. package/_shared/pr-loop/scripts/reviews_disabled.py +50 -22
  34. package/_shared/pr-loop/scripts/tests/conftest.py +20 -0
  35. package/_shared/pr-loop/scripts/tests/test_claude_permissions_common.py +6 -6
  36. package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +50 -6
  37. package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +1 -1
  38. package/_shared/pr-loop/state-schema.md +5 -14
  39. package/agents/CLAUDE.md +2 -2
  40. package/agents/clean-coder.md +58 -548
  41. package/agents/code-quality-agent.md +10 -2
  42. package/agents/code-verifier.md +1 -1
  43. package/agents/test_agent_frontmatter.py +32 -40
  44. package/audit-rubrics/CLAUDE.md +2 -1
  45. package/audit-rubrics/audit-categories.json +704 -0
  46. package/audit-rubrics/prompts/category-i-concurrency.md +1 -1
  47. package/bin/CLAUDE.md +16 -5
  48. package/bin/ever-shipped-skills.mjs +2 -0
  49. package/bin/install-plan.mjs +402 -0
  50. package/bin/install-transaction.mjs +455 -0
  51. package/bin/install.mjs +593 -147
  52. package/bin/install.plan.test.mjs +194 -0
  53. package/bin/install.profile-root.test.mjs +154 -0
  54. package/bin/install.profiles.test.mjs +253 -0
  55. package/bin/install.settings-defaults.test.mjs +200 -0
  56. package/bin/install.transaction.test.mjs +400 -0
  57. package/bin/install.uninstall-transaction.test.mjs +418 -0
  58. package/bin/merge_managed_permissions.mjs +130 -0
  59. package/bin/resolve-install-root.mjs +181 -0
  60. package/bin/select-install-targets.mjs +401 -0
  61. package/commands/CLAUDE.md +0 -2
  62. package/docs/references/CLAUDE.md +3 -1
  63. package/docs/references/advisor-tool.md +25 -7
  64. package/docs/references/prose-style-enforcement.md +25 -0
  65. package/docs/references/team-advisor-skill.md +3 -3
  66. package/docs/references/weak-executor-advisor.md +91 -0
  67. package/hooks/blocking/CLAUDE.md +6 -6
  68. package/hooks/blocking/_path_setup.py +9 -5
  69. package/hooks/blocking/code_rules_docstrings.py +124 -30
  70. package/hooks/blocking/code_rules_enforcer.py +161 -16
  71. package/hooks/blocking/code_rules_shared.py +40 -23
  72. package/hooks/blocking/config/CLAUDE.md +3 -5
  73. package/hooks/blocking/config/prose_style_enforcement_constants.py +38 -0
  74. package/hooks/blocking/config/test_prose_style_enforcement_constants.py +45 -0
  75. package/hooks/blocking/eli11_reply_enforcer.py +70 -113
  76. package/hooks/blocking/hedging_language_blocker.py +103 -20
  77. package/hooks/blocking/hook_prose_detector_consistency.py +6 -0
  78. package/hooks/blocking/intent_only_ending_blocker.py +6 -0
  79. package/hooks/blocking/plain_language_blocker.py +139 -20
  80. package/hooks/blocking/pre_tool_use_dispatcher.py +102 -20
  81. package/hooks/blocking/state_description_blocker.py +7 -1
  82. package/hooks/blocking/tdd_enforcer.py +8 -0
  83. package/hooks/blocking/test__path_setup.py +28 -0
  84. package/hooks/blocking/test_code_rules_enforcer_agent_home_tooling.py +99 -0
  85. package/hooks/blocking/test_code_rules_enforcer_docstring_args_span_scope.py +232 -10
  86. package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +1 -1
  87. package/hooks/blocking/test_code_rules_enforcer_join_separator_magic.py +41 -0
  88. package/hooks/blocking/test_code_rules_enforcer_string_magic.py +98 -0
  89. package/hooks/blocking/test_eli11_reply_enforcer.py +98 -165
  90. package/hooks/blocking/test_fable_spawn_gate.py +18 -11
  91. package/hooks/blocking/test_hedging_language_blocker.py +120 -1
  92. package/hooks/blocking/test_hook_prose_detector_consistency.py +28 -8
  93. package/hooks/blocking/test_intent_only_ending_blocker.py +27 -2
  94. package/hooks/blocking/test_package_inventory_stale_blocker.py +11 -4
  95. package/hooks/blocking/test_plain_language_blocker.py +129 -19
  96. package/hooks/blocking/test_plain_language_blocker_allowlist.py +70 -26
  97. package/hooks/blocking/test_pre_tool_use_dispatcher.py +99 -26
  98. package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +87 -50
  99. package/hooks/blocking/test_state_description_blocker.py +45 -2
  100. package/hooks/blocking/test_stop_dispatcher.py +11 -7
  101. package/hooks/blocking/test_volatile_path_in_post_blocker.py +12 -12
  102. package/hooks/blocking/volatile_path_in_post_blocker.py +2 -2
  103. package/hooks/hooks.json +15 -0
  104. package/hooks/hooks_constants/CLAUDE.md +14 -3
  105. package/hooks/hooks_constants/ask_user_question_shape.py +281 -0
  106. package/hooks/hooks_constants/code_rules_enforcer_constants.py +2 -1
  107. package/hooks/hooks_constants/eli11_reply_enforcer_constants.py +5 -12
  108. package/hooks/hooks_constants/hedging_uncertainty_constants.py +42 -0
  109. package/hooks/hooks_constants/issue_tracker_session_starter_constants.py +23 -0
  110. package/hooks/hooks_constants/orchestrator_auto_starter_constants.py +23 -0
  111. package/hooks/hooks_constants/piped_pytest_blocker_constants.py +4 -1
  112. package/hooks/hooks_constants/plain_language_blocker_constants.py +4 -1
  113. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +6 -0
  114. package/hooks/hooks_constants/project_paths_reader.py +31 -4
  115. package/hooks/hooks_constants/prose_matcher_precision_constants.py +40 -0
  116. package/hooks/hooks_constants/pytest_invocation.py +354 -0
  117. package/hooks/hooks_constants/session_start_injector.py +163 -0
  118. package/hooks/hooks_constants/session_start_injector_constants.py +46 -0
  119. package/hooks/hooks_constants/shell_command_pipeline.py +397 -0
  120. package/hooks/hooks_constants/shell_command_segments.py +5 -0
  121. package/hooks/hooks_constants/test_ask_user_question_shape.py +167 -0
  122. package/hooks/hooks_constants/test_project_paths_reader.py +29 -0
  123. package/hooks/hooks_constants/test_prose_metrics_parity.py +8 -0
  124. package/hooks/hooks_constants/test_pytest_invocation.py +130 -0
  125. package/hooks/hooks_constants/test_session_start_injector.py +168 -0
  126. package/hooks/hooks_constants/test_shell_command_pipeline.py +135 -0
  127. package/hooks/hooks_constants/volatile_path_in_post_blocker_constants.py +1 -1
  128. package/hooks/hooks_constants/working_style_prompt_constants.py +30 -0
  129. package/hooks/observability/CLAUDE.md +2 -0
  130. package/hooks/observability/prose_matcher_advisory.py +237 -0
  131. package/hooks/observability/test_prose_matcher_advisory.py +143 -0
  132. package/hooks/session/CLAUDE.md +9 -1
  133. package/hooks/session/_path_setup.py +13 -0
  134. package/hooks/session/issue_tracker_session_starter.py +135 -0
  135. package/hooks/session/orchestrator_auto_starter.py +100 -0
  136. package/hooks/session/test__path_setup.py +28 -0
  137. package/hooks/session/test_issue_tracker_session_starter.py +104 -0
  138. package/hooks/session/test_orchestrator_auto_starter.py +99 -0
  139. package/hooks/session/test_working_style_prompt.py +47 -0
  140. package/hooks/session/untracked_repo_detector.py +1 -24
  141. package/hooks/session/working_style_prompt.py +36 -0
  142. package/hooks/validators/_path_setup.py +19 -0
  143. package/hooks/validators/run_all_validators.py +8 -13
  144. package/installable-surfaces.manifest.json +21 -0
  145. package/output-styles/CLAUDE.md +1 -3
  146. package/package.json +4 -2
  147. package/rules/CLAUDE.md +1 -0
  148. package/rules/durable-post-artifacts.md +2 -2
  149. package/rules/eli11-replies.md +6 -1
  150. package/rules/hedging-claims.md +4 -2
  151. package/rules/long-horizon-autonomy.md +3 -1
  152. package/rules/opus5-communication-contract.md +45 -0
  153. package/rules/plain-language.md +2 -2
  154. package/rules/research-mode.md +1 -1
  155. package/scripts/CLAUDE.md +11 -0
  156. package/scripts/Sync-RepoMain.ps1 +215 -0
  157. package/scripts/active_capability_references.py +218 -0
  158. package/scripts/ci/windows-installer-lifecycle.ps1 +78 -0
  159. package/scripts/claude_chain_runner.py +394 -6
  160. package/scripts/claude_chain_usage.py +1 -1
  161. package/scripts/codex_compat_materializer.py +105 -85
  162. package/scripts/dev_env_scripts_constants/CLAUDE.md +2 -0
  163. package/scripts/dev_env_scripts_constants/active_capability_constants.py +46 -0
  164. package/scripts/dev_env_scripts_constants/claude_chain_constants.py +74 -0
  165. package/scripts/dev_env_scripts_constants/verify_installable_package_constants.py +116 -0
  166. package/scripts/profile-isolation-launchers/config/mcp-bundles.json +25 -0
  167. package/scripts/profile-isolation-launchers/config/profile-isolation-constants.mjs +60 -0
  168. package/scripts/profile-isolation-launchers/config/profiles.manifest.json +54 -0
  169. package/scripts/profile-isolation-launchers/config/shared-allowlist.json +64 -0
  170. package/scripts/profile-isolation-launchers/launcher-runtime.mjs +180 -0
  171. package/scripts/profile-isolation-launchers/lib/profile-manifest.mjs +288 -0
  172. package/scripts/profile-isolation-launchers/mcp-bundles.mjs +275 -0
  173. package/scripts/profile-isolation-launchers/profile-isolation-contract.test.mjs +221 -0
  174. package/scripts/profile-isolation-launchers/tests/launcher-runtime.test.mjs +108 -0
  175. package/scripts/profile-isolation-launchers/tests/mcp-bundles.test.mjs +147 -0
  176. package/scripts/profile-isolation-launchers/tests/shortcut-contract.test.ps1 +102 -0
  177. package/scripts/profile-isolation-launchers/tests/version-compatibility.test.mjs +210 -0
  178. package/scripts/profile-isolation-launchers/version-compatibility.mjs +299 -0
  179. package/scripts/profile-isolation-launchers/windows/shortcut-inventory.ps1 +127 -0
  180. package/scripts/profile-isolation-launchers/windows/shortcut-manifest.json +51 -0
  181. package/scripts/profile-isolation-launchers/windows/shortcut-reconcile.ps1 +77 -0
  182. package/scripts/spawn_grok_batch.py +3 -0
  183. package/scripts/test_active_capability_references.py +108 -0
  184. package/scripts/test_claude_chain_runner.py +414 -82
  185. package/scripts/test_claude_chain_usage.py +12 -12
  186. package/scripts/test_resolve_worker_spawn.py +2 -2
  187. package/scripts/test_verify_installable_package.py +208 -0
  188. package/scripts/tests/test_codex_compat_materializer.py +33 -0
  189. package/scripts/verify_installable_package.py +612 -0
  190. package/settings.json +10 -0
  191. package/skills/CLAUDE.md +2 -0
  192. package/skills/_shared/advisor/CLAUDE.md +1 -1
  193. package/skills/_shared/advisor/scripts/README.md +2 -0
  194. package/skills/_shared/pr-loop/scripts/CLAUDE.md +1 -0
  195. package/skills/_shared/pr-loop/scripts/audit_category_schema.py +355 -0
  196. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/CLAUDE.md +1 -0
  197. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/audit_category_schema_constants.py +32 -0
  198. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/path_resolver_constants.py +7 -19
  199. package/skills/_shared/pr-loop/scripts/test_audit_category_schema.py +94 -0
  200. package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +21 -0
  201. package/skills/autoconverge/reference/convergence.md +2 -1
  202. package/skills/autoconverge/reference/stop-conditions.md +5 -3
  203. package/skills/beat-sheet/SKILL.md +54 -0
  204. package/skills/beat-sheet/reference/visual-beats.md +29 -0
  205. package/skills/bugteam/CONSTRAINTS.md +4 -4
  206. package/skills/bugteam/EXAMPLES.md +1 -1
  207. package/skills/bugteam/reference/README.md +1 -1
  208. package/skills/e-code-review/SKILL.md +26 -5
  209. package/skills/e-code-review/reference/effort-evaluation.md +35 -0
  210. package/skills/e-code-review/reference/medium.md +15 -4
  211. package/skills/e-code-review/scripts/config/e_code_review_effort_constants/__init__.py +41 -0
  212. package/skills/e-code-review/scripts/config/e_code_review_effort_constants/effort_constants.py +40 -0
  213. package/skills/e-code-review/scripts/e_code_review_scripts_constants/finding_pipeline_constants.py +49 -0
  214. package/skills/e-code-review/scripts/effort_defaults_evidence.json +186 -0
  215. package/skills/e-code-review/scripts/effort_evaluation.py +362 -0
  216. package/skills/e-code-review/scripts/finding_pipeline.py +140 -0
  217. package/skills/e-code-review/scripts/fixtures/demanding.json +26 -0
  218. package/skills/e-code-review/scripts/fixtures/easy.json +14 -0
  219. package/skills/e-code-review/scripts/fixtures/medium.json +20 -0
  220. package/skills/e-code-review/scripts/grok_code_review.py +16 -7
  221. package/skills/e-code-review/scripts/test_effort_evaluation.py +180 -0
  222. package/skills/e-code-review/scripts/test_finding_pipeline.py +197 -0
  223. package/skills/e-code-review/scripts/test_grok_code_review.py +77 -0
  224. package/skills/grokify/SKILL.md +1 -1
  225. package/skills/grokify/templates/handoff-template.md +2 -2
  226. package/skills/orchestrator/SKILL.md +5 -4
  227. package/skills/plan-to-pr/scripts/create_packet.py +4 -4
  228. package/skills/plan-to-pr/scripts/load_skill_constants.py +41 -0
  229. package/skills/plan-to-pr/scripts/validate_packet.py +4 -4
  230. package/skills/plan-to-pr/scripts/validate_protocol.py +4 -1
  231. package/skills/plan-to-pr/scripts/validate_run.py +4 -1
  232. package/skills/pr-converge/scripts/check_convergence.py +21 -19
  233. package/skills/pr-converge/scripts/check_convergence_availability.py +50 -7
  234. package/skills/pr-converge/scripts/conftest.py +35 -0
  235. package/skills/pr-converge/scripts/test_check_convergence_availability.py +65 -0
  236. package/skills/pr-converge/scripts/test_check_convergence_codex.py +11 -1
  237. package/skills/pr-converge/scripts/test_check_convergence_contract.py +9 -2
  238. package/skills/pr-loop-cloud-transport/SKILL.md +1 -1
  239. package/skills/rebase/SKILL.md +15 -3
  240. package/skills/reviewer-gates/SKILL.md +2 -2
  241. package/skills/show/SKILL.md +51 -0
  242. package/skills/show/references/accessibility.md +7 -0
  243. package/skills/show/references/art.md +3 -0
  244. package/skills/show/references/charts.md +3 -0
  245. package/skills/show/references/core-design.md +14 -0
  246. package/skills/show/references/erds.md +3 -0
  247. package/skills/show/references/flowcharts.md +3 -0
  248. package/skills/show/references/host-and-html.md +3 -0
  249. package/skills/show/references/illustrative-diagrams.md +10 -0
  250. package/skills/show/references/interaction.md +3 -0
  251. package/skills/show/references/mockups.md +3 -0
  252. package/skills/show/references/quality-gates.md +7 -0
  253. package/skills/show/references/structural-diagrams.md +3 -0
  254. package/skills/show/references/subject-inventory.md +21 -0
  255. package/skills/show/references/svg-contract.md +22 -0
  256. package/skills/show/routing.yaml +30 -0
  257. package/skills/show/samples/pr1262-v2.svg +222 -0
  258. package/skills/show/scripts/README.md +6 -0
  259. package/skills/show/scripts/validate-artifact.py +91 -0
  260. package/skills/show/scripts/validate-package.py +18 -0
  261. package/skills/show/templates/html-widget.html +4 -0
  262. package/skills/show/templates/svg-base.svg +19 -0
  263. package/skills/show/tests/fixtures/css-var.svg +6 -0
  264. package/skills/show/tests/fixtures/dead-ref.svg +7 -0
  265. package/skills/show/tests/fixtures/filled-glyph.svg +8 -0
  266. package/skills/show/tests/fixtures/inherited-fill.svg +18 -0
  267. package/skills/show/tests/fixtures/invalid.svg +1 -0
  268. package/skills/show/tests/fixtures/large-canvas.svg +21 -0
  269. package/skills/show/tests/fixtures/unfilled-connector.svg +15 -0
  270. package/skills/show/tests/fixtures/valid.html +1 -0
  271. package/skills/show/tests/test_validate-artifact.py +74 -0
  272. package/skills/show/tests/test_validators.py +59 -0
  273. package/skills/show/workflows/create-visual.md +13 -0
  274. package/skills/show/workflows/review-visual.md +20 -0
  275. package/skills/split-pr/SKILL.md +85 -0
  276. package/skills/split-pr/reference/path-layers.md +16 -0
  277. package/skills/split-pr/reference/proposal-format.md +15 -0
  278. package/skills/split-pr/reference/split-further-loop.md +10 -0
  279. package/skills/split-pr/reference/splitting-principles.md +26 -0
  280. package/skills/split-pr/scripts/analyze_pr.py +279 -0
  281. package/skills/split-pr/scripts/categorize_files.py +106 -0
  282. package/skills/split-pr/scripts/config/__init__.py +1 -0
  283. package/skills/split-pr/scripts/config/dependency_constants.py +14 -0
  284. package/skills/split-pr/scripts/config/git_operations_constants.py +36 -0
  285. package/skills/split-pr/scripts/config/packing_constants.py +61 -0
  286. package/skills/split-pr/scripts/config/plan_constants.py +49 -0
  287. package/skills/split-pr/scripts/config/split_pr_constants.py +110 -0
  288. package/skills/split-pr/scripts/execute_split_slices.py +82 -0
  289. package/skills/split-pr/scripts/pack_files_into_slices.py +212 -0
  290. package/skills/split-pr/scripts/split_pr_dependency_graph.py +70 -0
  291. package/skills/split-pr/scripts/split_pr_git_operations.py +184 -0
  292. package/skills/split-pr/scripts/split_pr_layer_order.py +58 -0
  293. package/skills/split-pr/scripts/split_pr_paginate.py +119 -0
  294. package/skills/split-pr/scripts/split_pr_process_runner.py +52 -0
  295. package/skills/split-pr/scripts/split_pr_script_types.py +126 -0
  296. package/skills/split-pr/scripts/split_pr_title.py +41 -0
  297. package/skills/split-pr/scripts/test_analyze_pr.py +228 -0
  298. package/skills/split-pr/scripts/test_categorize_files.py +55 -0
  299. package/skills/split-pr/scripts/test_categorize_files_packing.py +59 -0
  300. package/skills/split-pr/scripts/test_execute_split_slices.py +99 -0
  301. package/skills/split-pr/scripts/test_split_pr_dependency_graph.py +47 -0
  302. package/skills/split-pr/scripts/test_split_pr_git_operations.py +125 -0
  303. package/skills/split-pr/scripts/test_split_pr_layer_order.py +36 -0
  304. package/skills/split-pr/scripts/test_split_pr_paginate.py +65 -0
  305. package/skills/split-pr/scripts/test_split_pr_script_types.py +73 -0
  306. package/skills/split-pr/scripts/test_split_pr_title.py +28 -0
  307. package/skills/split-pr/scripts/test_verify_dependency_graph.py +46 -0
  308. package/skills/split-pr/scripts/test_verify_plan.py +56 -0
  309. package/skills/split-pr/scripts/test_verify_plan_contract.py +50 -0
  310. package/skills/split-pr/scripts/test_verify_plan_path_normalization.py +45 -0
  311. package/skills/split-pr/scripts/verify_dependency_graph.py +111 -0
  312. package/skills/split-pr/scripts/verify_plan.py +139 -0
  313. package/skills/team-advisor/SKILL.md +7 -4
  314. package/skills/team-advisor/reference/advisor-docs-review.md +207 -0
  315. package/system-prompts/software-engineer.xml +11 -2
  316. package/commands/initialize.md +0 -90
  317. package/commands/stubcheck.md +0 -88
  318. package/output-styles/caveman-agent.md +0 -37
@@ -2,12 +2,19 @@
2
2
 
3
3
  import importlib.util
4
4
  import json
5
+ import os
5
6
  from pathlib import Path
7
+
8
+ import pytest
6
9
  from types import ModuleType
10
+ from unittest.mock import patch
7
11
 
8
12
  BLOCKER_PATH = Path(__file__).parent / "plain_language_blocker.py"
9
13
  ALLOWLIST_RELATIVE_PATH = Path(".claude") / "plain-language-allow.json"
10
14
  ALWAYS_HEAVY_WORD = "utilize"
15
+ _PROSE_STYLE_ENFORCEMENT_ENVIRONMENT = {
16
+ "CLAUDE_PROSE_STYLE_ENFORCEMENT": "1",
17
+ }
11
18
 
12
19
 
13
20
  def _load_blocker() -> ModuleType:
@@ -22,6 +29,24 @@ def _load_blocker() -> ModuleType:
22
29
 
23
30
  _BLOCKER = _load_blocker()
24
31
 
32
+ @pytest.fixture(autouse=True)
33
+ def enable_prose_style_enforcement(monkeypatch: pytest.MonkeyPatch) -> None:
34
+ """Opinionated heavy-word scan is default-off; these tests arm it."""
35
+ monkeypatch.setenv("CLAUDE_PROSE_STYLE_ENFORCEMENT", "1")
36
+
37
+
38
+
39
+ def _evaluate_advisory(
40
+ payload_by_key: dict[str, object],
41
+ ) -> tuple[str | None, str | None]:
42
+ """Return (deny_reason, advisory_message) with prose enforcement on.
43
+
44
+ Heavy words never deny (OP-07D); allowlist coverage asserts on the advisory
45
+ message and on ``find_banned_terms`` so the scanner still sees domain terms.
46
+ """
47
+ with patch.dict(os.environ, _PROSE_STYLE_ENFORCEMENT_ENVIRONMENT):
48
+ return _BLOCKER.evaluate_with_advisory(payload_by_key)
49
+
25
50
 
26
51
  def _init_repo(root: Path) -> Path:
27
52
  (root / ".git").mkdir(parents=True, exist_ok=True)
@@ -63,15 +88,17 @@ def test_allowlisted_word_passes_in_markdown_write(tmp_path: Path) -> None:
63
88
  project_with_allowlist = _init_repo(tmp_path / "domain")
64
89
  _write_allowlist(project_with_allowlist, ["submit"])
65
90
 
66
- control_deny_reason = _BLOCKER.evaluate(
91
+ control_deny, control_advisory = _evaluate_advisory(
67
92
  _markdown_write_payload(project_without_allowlist, prose)
68
93
  )
69
- allowlisted_deny_reason = _BLOCKER.evaluate(
94
+ allowlisted_deny, allowlisted_advisory = _evaluate_advisory(
70
95
  _markdown_write_payload(project_with_allowlist, prose)
71
96
  )
72
97
 
73
- assert control_deny_reason is not None and "submit" in control_deny_reason
74
- assert allowlisted_deny_reason is None
98
+ assert control_deny is None
99
+ assert control_advisory is not None and "submit" in control_advisory
100
+ assert allowlisted_deny is None
101
+ assert allowlisted_advisory is None
75
102
 
76
103
 
77
104
  def test_allowlisted_word_passes_in_ask_user_question(tmp_path: Path) -> None:
@@ -80,26 +107,29 @@ def test_allowlisted_word_passes_in_ask_user_question(tmp_path: Path) -> None:
80
107
  project_with_allowlist = _init_repo(tmp_path / "domain")
81
108
  _write_allowlist(project_with_allowlist, ["identify"])
82
109
 
83
- control_deny_reason = _BLOCKER.evaluate(
110
+ control_deny, control_advisory = _evaluate_advisory(
84
111
  _ask_user_question_payload(project_without_allowlist, prose)
85
112
  )
86
- allowlisted_deny_reason = _BLOCKER.evaluate(
113
+ allowlisted_deny, allowlisted_advisory = _evaluate_advisory(
87
114
  _ask_user_question_payload(project_with_allowlist, prose)
88
115
  )
89
116
 
90
- assert control_deny_reason is not None and "identify" in control_deny_reason
91
- assert allowlisted_deny_reason is None
117
+ assert control_deny is None
118
+ assert control_advisory is not None and "identify" in control_advisory
119
+ assert allowlisted_deny is None
120
+ assert allowlisted_advisory is None
92
121
 
93
122
 
94
- def test_non_allowlisted_heavy_word_still_blocked(tmp_path: Path) -> None:
123
+ def test_non_allowlisted_heavy_word_still_flagged(tmp_path: Path) -> None:
95
124
  _init_repo(tmp_path)
96
125
  _write_allowlist(tmp_path, ["submit"])
97
126
 
98
- deny_reason = _BLOCKER.evaluate(
127
+ deny_reason, advisory_message = _evaluate_advisory(
99
128
  _markdown_write_payload(tmp_path, f"Please {ALWAYS_HEAVY_WORD} the cache now.")
100
129
  )
101
130
 
102
- assert deny_reason is not None and ALWAYS_HEAVY_WORD in deny_reason
131
+ assert deny_reason is None
132
+ assert advisory_message is not None and ALWAYS_HEAVY_WORD in advisory_message
103
133
 
104
134
 
105
135
  def test_allowlist_match_is_case_insensitive(tmp_path: Path) -> None:
@@ -108,26 +138,29 @@ def test_allowlist_match_is_case_insensitive(tmp_path: Path) -> None:
108
138
  project_with_allowlist = _init_repo(tmp_path / "domain")
109
139
  _write_allowlist(project_with_allowlist, ["Submit"])
110
140
 
111
- control_deny_reason = _BLOCKER.evaluate(
141
+ control_deny, control_advisory = _evaluate_advisory(
112
142
  _markdown_write_payload(project_without_allowlist, prose)
113
143
  )
114
- allowlisted_deny_reason = _BLOCKER.evaluate(
144
+ allowlisted_deny, allowlisted_advisory = _evaluate_advisory(
115
145
  _markdown_write_payload(project_with_allowlist, prose)
116
146
  )
117
147
 
118
- assert control_deny_reason is not None and "submit" in control_deny_reason
119
- assert allowlisted_deny_reason is None
148
+ assert control_deny is None
149
+ assert control_advisory is not None and "submit" in control_advisory
150
+ assert allowlisted_deny is None
151
+ assert allowlisted_advisory is None
120
152
 
121
153
 
122
154
  def test_malformed_allowlist_json_is_ignored(tmp_path: Path) -> None:
123
155
  _init_repo(tmp_path)
124
156
  _write_raw_allowlist(tmp_path, "{ this is not valid json ")
125
157
 
126
- deny_reason = _BLOCKER.evaluate(
158
+ deny_reason, advisory_message = _evaluate_advisory(
127
159
  _markdown_write_payload(tmp_path, "Please submit the release notes.")
128
160
  )
129
161
 
130
- assert deny_reason is not None and "submit" in deny_reason
162
+ assert deny_reason is None
163
+ assert advisory_message is not None and "submit" in advisory_message
131
164
 
132
165
 
133
166
  def test_allowlist_in_a_different_project_root_is_not_applied(tmp_path: Path) -> None:
@@ -135,43 +168,54 @@ def test_allowlist_in_a_different_project_root_is_not_applied(tmp_path: Path) ->
135
168
  project_without_allowlist = _init_repo(tmp_path / "project_b")
136
169
  _write_allowlist(project_with_allowlist, ["submit"])
137
170
 
138
- deny_reason = _BLOCKER.evaluate(
171
+ deny_reason, advisory_message = _evaluate_advisory(
139
172
  _markdown_write_payload(project_without_allowlist, "Please submit the notes.")
140
173
  )
141
174
 
142
- assert deny_reason is not None and "submit" in deny_reason
175
+ assert deny_reason is None
176
+ assert advisory_message is not None and "submit" in advisory_message
143
177
 
144
178
 
145
179
  def test_allowlist_above_the_repo_root_is_not_applied(tmp_path: Path) -> None:
146
180
  _write_allowlist(tmp_path, ["submit"])
147
181
  repository_root = _init_repo(tmp_path / "repo")
148
182
 
149
- deny_reason = _BLOCKER.evaluate(
183
+ deny_reason, advisory_message = _evaluate_advisory(
150
184
  _markdown_write_payload(repository_root, "Please submit the notes.")
151
185
  )
152
186
 
153
- assert deny_reason is not None and "submit" in deny_reason
187
+ assert deny_reason is None
188
+ assert advisory_message is not None and "submit" in advisory_message
154
189
 
155
190
 
156
191
  def test_allowlist_at_the_repo_root_is_applied(tmp_path: Path) -> None:
157
192
  repository_root = _init_repo(tmp_path / "repo")
158
193
  _write_allowlist(repository_root, ["submit"])
159
194
 
160
- deny_reason = _BLOCKER.evaluate(
195
+ deny_reason, advisory_message = _evaluate_advisory(
161
196
  _markdown_write_payload(repository_root, "Please submit the notes.")
162
197
  )
163
198
 
164
199
  assert deny_reason is None
200
+ assert advisory_message is None
165
201
 
166
202
 
167
203
  def test_allowlist_without_a_repo_root_is_not_applied(tmp_path: Path) -> None:
204
+ """An allowlist only applies when the walk finds a ``.git`` repository root.
205
+
206
+ The upward walk can hit a personal home ``.git`` on some developer machines,
207
+ so this case stubs the finder to the no-repository outcome rather than
208
+ relying on the ambient filesystem.
209
+ """
168
210
  _write_allowlist(tmp_path, ["submit"])
169
211
 
170
- deny_reason = _BLOCKER.evaluate(
171
- _markdown_write_payload(tmp_path, "Please submit the notes.")
172
- )
212
+ with patch.object(_BLOCKER, "_find_project_allowlist_file", return_value=None):
213
+ deny_reason, advisory_message = _evaluate_advisory(
214
+ _markdown_write_payload(tmp_path, "Please submit the notes.")
215
+ )
173
216
 
174
- assert deny_reason is not None and "submit" in deny_reason
217
+ assert deny_reason is None
218
+ assert advisory_message is not None and "submit" in advisory_message
175
219
 
176
220
 
177
221
  def test_find_banned_terms_skips_allowlisted_terms() -> None:
@@ -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