claude-dev-env 2.9.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 (289) hide show
  1. package/CLAUDE.md +1 -1
  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 +58 -548
  22. package/agents/code-quality-agent.md +10 -2
  23. package/agents/code-verifier.md +1 -1
  24. package/agents/test_agent_frontmatter.py +32 -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/references/CLAUDE.md +1 -0
  44. package/docs/references/advisor-tool.md +1 -1
  45. package/docs/references/prose-style-enforcement.md +25 -0
  46. package/hooks/blocking/CLAUDE.md +6 -6
  47. package/hooks/blocking/_path_setup.py +9 -5
  48. package/hooks/blocking/code_rules_docstrings.py +124 -30
  49. package/hooks/blocking/code_rules_enforcer.py +161 -16
  50. package/hooks/blocking/code_rules_shared.py +40 -23
  51. package/hooks/blocking/config/CLAUDE.md +3 -5
  52. package/hooks/blocking/config/prose_style_enforcement_constants.py +38 -0
  53. package/hooks/blocking/config/test_prose_style_enforcement_constants.py +45 -0
  54. package/hooks/blocking/eli11_reply_enforcer.py +70 -113
  55. package/hooks/blocking/hedging_language_blocker.py +103 -20
  56. package/hooks/blocking/hook_prose_detector_consistency.py +6 -0
  57. package/hooks/blocking/intent_only_ending_blocker.py +6 -0
  58. package/hooks/blocking/plain_language_blocker.py +139 -20
  59. package/hooks/blocking/pre_tool_use_dispatcher.py +102 -20
  60. package/hooks/blocking/state_description_blocker.py +7 -1
  61. package/hooks/blocking/tdd_enforcer.py +8 -0
  62. package/hooks/blocking/test__path_setup.py +28 -0
  63. package/hooks/blocking/test_code_rules_enforcer_agent_home_tooling.py +99 -0
  64. package/hooks/blocking/test_code_rules_enforcer_docstring_args_span_scope.py +232 -10
  65. package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +1 -1
  66. package/hooks/blocking/test_code_rules_enforcer_join_separator_magic.py +41 -0
  67. package/hooks/blocking/test_code_rules_enforcer_string_magic.py +98 -0
  68. package/hooks/blocking/test_eli11_reply_enforcer.py +98 -165
  69. package/hooks/blocking/test_hedging_language_blocker.py +120 -1
  70. package/hooks/blocking/test_hook_prose_detector_consistency.py +28 -8
  71. package/hooks/blocking/test_intent_only_ending_blocker.py +27 -2
  72. package/hooks/blocking/test_package_inventory_stale_blocker.py +11 -4
  73. package/hooks/blocking/test_plain_language_blocker.py +129 -19
  74. package/hooks/blocking/test_plain_language_blocker_allowlist.py +70 -26
  75. package/hooks/blocking/test_pre_tool_use_dispatcher.py +99 -26
  76. package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +87 -50
  77. package/hooks/blocking/test_state_description_blocker.py +45 -2
  78. package/hooks/blocking/test_stop_dispatcher.py +11 -7
  79. package/hooks/blocking/test_volatile_path_in_post_blocker.py +12 -12
  80. package/hooks/blocking/volatile_path_in_post_blocker.py +2 -2
  81. package/hooks/hooks.json +15 -0
  82. package/hooks/hooks_constants/CLAUDE.md +14 -3
  83. package/hooks/hooks_constants/ask_user_question_shape.py +281 -0
  84. package/hooks/hooks_constants/code_rules_enforcer_constants.py +2 -1
  85. package/hooks/hooks_constants/eli11_reply_enforcer_constants.py +5 -12
  86. package/hooks/hooks_constants/hedging_uncertainty_constants.py +42 -0
  87. package/hooks/hooks_constants/issue_tracker_session_starter_constants.py +23 -0
  88. package/hooks/hooks_constants/orchestrator_auto_starter_constants.py +23 -0
  89. package/hooks/hooks_constants/piped_pytest_blocker_constants.py +4 -1
  90. package/hooks/hooks_constants/plain_language_blocker_constants.py +4 -1
  91. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +6 -0
  92. package/hooks/hooks_constants/project_paths_reader.py +31 -4
  93. package/hooks/hooks_constants/prose_matcher_precision_constants.py +40 -0
  94. package/hooks/hooks_constants/pytest_invocation.py +354 -0
  95. package/hooks/hooks_constants/session_start_injector.py +163 -0
  96. package/hooks/hooks_constants/session_start_injector_constants.py +46 -0
  97. package/hooks/hooks_constants/shell_command_pipeline.py +397 -0
  98. package/hooks/hooks_constants/shell_command_segments.py +5 -0
  99. package/hooks/hooks_constants/test_ask_user_question_shape.py +167 -0
  100. package/hooks/hooks_constants/test_project_paths_reader.py +29 -0
  101. package/hooks/hooks_constants/test_prose_metrics_parity.py +8 -0
  102. package/hooks/hooks_constants/test_pytest_invocation.py +130 -0
  103. package/hooks/hooks_constants/test_session_start_injector.py +168 -0
  104. package/hooks/hooks_constants/test_shell_command_pipeline.py +135 -0
  105. package/hooks/hooks_constants/volatile_path_in_post_blocker_constants.py +1 -1
  106. package/hooks/hooks_constants/working_style_prompt_constants.py +30 -0
  107. package/hooks/observability/CLAUDE.md +2 -0
  108. package/hooks/observability/prose_matcher_advisory.py +237 -0
  109. package/hooks/observability/test_prose_matcher_advisory.py +143 -0
  110. package/hooks/session/CLAUDE.md +9 -1
  111. package/hooks/session/_path_setup.py +13 -0
  112. package/hooks/session/issue_tracker_session_starter.py +135 -0
  113. package/hooks/session/orchestrator_auto_starter.py +100 -0
  114. package/hooks/session/test__path_setup.py +28 -0
  115. package/hooks/session/test_issue_tracker_session_starter.py +104 -0
  116. package/hooks/session/test_orchestrator_auto_starter.py +99 -0
  117. package/hooks/session/test_working_style_prompt.py +47 -0
  118. package/hooks/session/untracked_repo_detector.py +1 -24
  119. package/hooks/session/working_style_prompt.py +36 -0
  120. package/hooks/validators/_path_setup.py +19 -0
  121. package/hooks/validators/run_all_validators.py +8 -13
  122. package/installable-surfaces.manifest.json +21 -0
  123. package/output-styles/CLAUDE.md +1 -3
  124. package/package.json +4 -2
  125. package/rules/CLAUDE.md +1 -0
  126. package/rules/durable-post-artifacts.md +2 -2
  127. package/rules/eli11-replies.md +6 -1
  128. package/rules/hedging-claims.md +4 -2
  129. package/rules/long-horizon-autonomy.md +3 -1
  130. package/rules/opus5-communication-contract.md +45 -0
  131. package/rules/plain-language.md +2 -2
  132. package/rules/research-mode.md +1 -1
  133. package/scripts/CLAUDE.md +11 -0
  134. package/scripts/Sync-RepoMain.ps1 +215 -0
  135. package/scripts/active_capability_references.py +218 -0
  136. package/scripts/ci/windows-installer-lifecycle.ps1 +78 -0
  137. package/scripts/claude_chain_runner.py +394 -6
  138. package/scripts/claude_chain_usage.py +1 -1
  139. package/scripts/codex_compat_materializer.py +105 -85
  140. package/scripts/dev_env_scripts_constants/CLAUDE.md +2 -0
  141. package/scripts/dev_env_scripts_constants/active_capability_constants.py +46 -0
  142. package/scripts/dev_env_scripts_constants/claude_chain_constants.py +74 -0
  143. package/scripts/dev_env_scripts_constants/verify_installable_package_constants.py +116 -0
  144. package/scripts/profile-isolation-launchers/config/mcp-bundles.json +25 -0
  145. package/scripts/profile-isolation-launchers/config/profile-isolation-constants.mjs +60 -0
  146. package/scripts/profile-isolation-launchers/config/profiles.manifest.json +54 -0
  147. package/scripts/profile-isolation-launchers/config/shared-allowlist.json +64 -0
  148. package/scripts/profile-isolation-launchers/launcher-runtime.mjs +180 -0
  149. package/scripts/profile-isolation-launchers/lib/profile-manifest.mjs +288 -0
  150. package/scripts/profile-isolation-launchers/mcp-bundles.mjs +275 -0
  151. package/scripts/profile-isolation-launchers/profile-isolation-contract.test.mjs +221 -0
  152. package/scripts/profile-isolation-launchers/tests/launcher-runtime.test.mjs +108 -0
  153. package/scripts/profile-isolation-launchers/tests/mcp-bundles.test.mjs +147 -0
  154. package/scripts/profile-isolation-launchers/tests/shortcut-contract.test.ps1 +102 -0
  155. package/scripts/profile-isolation-launchers/tests/version-compatibility.test.mjs +210 -0
  156. package/scripts/profile-isolation-launchers/version-compatibility.mjs +299 -0
  157. package/scripts/profile-isolation-launchers/windows/shortcut-inventory.ps1 +127 -0
  158. package/scripts/profile-isolation-launchers/windows/shortcut-manifest.json +51 -0
  159. package/scripts/profile-isolation-launchers/windows/shortcut-reconcile.ps1 +77 -0
  160. package/scripts/spawn_grok_batch.py +3 -0
  161. package/scripts/test_active_capability_references.py +108 -0
  162. package/scripts/test_claude_chain_runner.py +414 -82
  163. package/scripts/test_claude_chain_usage.py +12 -12
  164. package/scripts/test_resolve_worker_spawn.py +2 -2
  165. package/scripts/test_verify_installable_package.py +208 -0
  166. package/scripts/tests/test_codex_compat_materializer.py +33 -0
  167. package/scripts/verify_installable_package.py +612 -0
  168. package/settings.json +10 -0
  169. package/skills/CLAUDE.md +2 -0
  170. package/skills/_shared/pr-loop/scripts/CLAUDE.md +1 -0
  171. package/skills/_shared/pr-loop/scripts/audit_category_schema.py +355 -0
  172. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/CLAUDE.md +1 -0
  173. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/audit_category_schema_constants.py +32 -0
  174. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/path_resolver_constants.py +7 -19
  175. package/skills/_shared/pr-loop/scripts/test_audit_category_schema.py +94 -0
  176. package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +21 -0
  177. package/skills/autoconverge/reference/convergence.md +2 -1
  178. package/skills/autoconverge/reference/stop-conditions.md +5 -3
  179. package/skills/beat-sheet/SKILL.md +54 -0
  180. package/skills/beat-sheet/reference/visual-beats.md +29 -0
  181. package/skills/bugteam/CONSTRAINTS.md +4 -4
  182. package/skills/bugteam/EXAMPLES.md +1 -1
  183. package/skills/bugteam/reference/README.md +1 -1
  184. package/skills/e-code-review/SKILL.md +26 -5
  185. package/skills/e-code-review/reference/effort-evaluation.md +35 -0
  186. package/skills/e-code-review/reference/medium.md +15 -4
  187. package/skills/e-code-review/scripts/config/e_code_review_effort_constants/__init__.py +41 -0
  188. package/skills/e-code-review/scripts/config/e_code_review_effort_constants/effort_constants.py +40 -0
  189. package/skills/e-code-review/scripts/e_code_review_scripts_constants/finding_pipeline_constants.py +49 -0
  190. package/skills/e-code-review/scripts/effort_defaults_evidence.json +186 -0
  191. package/skills/e-code-review/scripts/effort_evaluation.py +362 -0
  192. package/skills/e-code-review/scripts/finding_pipeline.py +140 -0
  193. package/skills/e-code-review/scripts/fixtures/demanding.json +26 -0
  194. package/skills/e-code-review/scripts/fixtures/easy.json +14 -0
  195. package/skills/e-code-review/scripts/fixtures/medium.json +20 -0
  196. package/skills/e-code-review/scripts/grok_code_review.py +16 -7
  197. package/skills/e-code-review/scripts/test_effort_evaluation.py +180 -0
  198. package/skills/e-code-review/scripts/test_finding_pipeline.py +197 -0
  199. package/skills/e-code-review/scripts/test_grok_code_review.py +77 -0
  200. package/skills/plan-to-pr/scripts/create_packet.py +4 -4
  201. package/skills/plan-to-pr/scripts/load_skill_constants.py +41 -0
  202. package/skills/plan-to-pr/scripts/validate_packet.py +4 -4
  203. package/skills/plan-to-pr/scripts/validate_protocol.py +4 -1
  204. package/skills/plan-to-pr/scripts/validate_run.py +4 -1
  205. package/skills/pr-converge/scripts/check_convergence.py +21 -19
  206. package/skills/pr-converge/scripts/check_convergence_availability.py +50 -7
  207. package/skills/pr-converge/scripts/conftest.py +35 -0
  208. package/skills/pr-converge/scripts/test_check_convergence_availability.py +65 -0
  209. package/skills/pr-converge/scripts/test_check_convergence_codex.py +11 -1
  210. package/skills/pr-converge/scripts/test_check_convergence_contract.py +9 -2
  211. package/skills/pr-loop-cloud-transport/SKILL.md +1 -1
  212. package/skills/rebase/SKILL.md +15 -3
  213. package/skills/reviewer-gates/SKILL.md +2 -2
  214. package/skills/show/SKILL.md +51 -0
  215. package/skills/show/references/accessibility.md +7 -0
  216. package/skills/show/references/art.md +3 -0
  217. package/skills/show/references/charts.md +3 -0
  218. package/skills/show/references/core-design.md +14 -0
  219. package/skills/show/references/erds.md +3 -0
  220. package/skills/show/references/flowcharts.md +3 -0
  221. package/skills/show/references/host-and-html.md +3 -0
  222. package/skills/show/references/illustrative-diagrams.md +10 -0
  223. package/skills/show/references/interaction.md +3 -0
  224. package/skills/show/references/mockups.md +3 -0
  225. package/skills/show/references/quality-gates.md +7 -0
  226. package/skills/show/references/structural-diagrams.md +3 -0
  227. package/skills/show/references/subject-inventory.md +21 -0
  228. package/skills/show/references/svg-contract.md +22 -0
  229. package/skills/show/routing.yaml +30 -0
  230. package/skills/show/samples/pr1262-v2.svg +222 -0
  231. package/skills/show/scripts/README.md +6 -0
  232. package/skills/show/scripts/validate-artifact.py +91 -0
  233. package/skills/show/scripts/validate-package.py +18 -0
  234. package/skills/show/templates/html-widget.html +4 -0
  235. package/skills/show/templates/svg-base.svg +19 -0
  236. package/skills/show/tests/fixtures/css-var.svg +6 -0
  237. package/skills/show/tests/fixtures/dead-ref.svg +7 -0
  238. package/skills/show/tests/fixtures/filled-glyph.svg +8 -0
  239. package/skills/show/tests/fixtures/inherited-fill.svg +18 -0
  240. package/skills/show/tests/fixtures/invalid.svg +1 -0
  241. package/skills/show/tests/fixtures/large-canvas.svg +21 -0
  242. package/skills/show/tests/fixtures/unfilled-connector.svg +15 -0
  243. package/skills/show/tests/fixtures/valid.html +1 -0
  244. package/skills/show/tests/test_validate-artifact.py +74 -0
  245. package/skills/show/tests/test_validators.py +59 -0
  246. package/skills/show/workflows/create-visual.md +13 -0
  247. package/skills/show/workflows/review-visual.md +20 -0
  248. package/skills/split-pr/SKILL.md +85 -0
  249. package/skills/split-pr/reference/path-layers.md +16 -0
  250. package/skills/split-pr/reference/proposal-format.md +15 -0
  251. package/skills/split-pr/reference/split-further-loop.md +10 -0
  252. package/skills/split-pr/reference/splitting-principles.md +26 -0
  253. package/skills/split-pr/scripts/analyze_pr.py +279 -0
  254. package/skills/split-pr/scripts/categorize_files.py +106 -0
  255. package/skills/split-pr/scripts/config/__init__.py +1 -0
  256. package/skills/split-pr/scripts/config/dependency_constants.py +14 -0
  257. package/skills/split-pr/scripts/config/git_operations_constants.py +36 -0
  258. package/skills/split-pr/scripts/config/packing_constants.py +61 -0
  259. package/skills/split-pr/scripts/config/plan_constants.py +49 -0
  260. package/skills/split-pr/scripts/config/split_pr_constants.py +110 -0
  261. package/skills/split-pr/scripts/execute_split_slices.py +82 -0
  262. package/skills/split-pr/scripts/pack_files_into_slices.py +212 -0
  263. package/skills/split-pr/scripts/split_pr_dependency_graph.py +70 -0
  264. package/skills/split-pr/scripts/split_pr_git_operations.py +184 -0
  265. package/skills/split-pr/scripts/split_pr_layer_order.py +58 -0
  266. package/skills/split-pr/scripts/split_pr_paginate.py +119 -0
  267. package/skills/split-pr/scripts/split_pr_process_runner.py +52 -0
  268. package/skills/split-pr/scripts/split_pr_script_types.py +126 -0
  269. package/skills/split-pr/scripts/split_pr_title.py +41 -0
  270. package/skills/split-pr/scripts/test_analyze_pr.py +228 -0
  271. package/skills/split-pr/scripts/test_categorize_files.py +55 -0
  272. package/skills/split-pr/scripts/test_categorize_files_packing.py +59 -0
  273. package/skills/split-pr/scripts/test_execute_split_slices.py +99 -0
  274. package/skills/split-pr/scripts/test_split_pr_dependency_graph.py +47 -0
  275. package/skills/split-pr/scripts/test_split_pr_git_operations.py +125 -0
  276. package/skills/split-pr/scripts/test_split_pr_layer_order.py +36 -0
  277. package/skills/split-pr/scripts/test_split_pr_paginate.py +65 -0
  278. package/skills/split-pr/scripts/test_split_pr_script_types.py +73 -0
  279. package/skills/split-pr/scripts/test_split_pr_title.py +28 -0
  280. package/skills/split-pr/scripts/test_verify_dependency_graph.py +46 -0
  281. package/skills/split-pr/scripts/test_verify_plan.py +56 -0
  282. package/skills/split-pr/scripts/test_verify_plan_contract.py +50 -0
  283. package/skills/split-pr/scripts/test_verify_plan_path_normalization.py +45 -0
  284. package/skills/split-pr/scripts/verify_dependency_graph.py +111 -0
  285. package/skills/split-pr/scripts/verify_plan.py +139 -0
  286. package/system-prompts/software-engineer.xml +11 -2
  287. package/commands/initialize.md +0 -90
  288. package/commands/stubcheck.md +0 -88
  289. package/output-styles/caveman-agent.md +0 -37
@@ -42,24 +42,40 @@ USER_DIRECTED_NEXT_STEPS_MESSAGE = (
42
42
  EMPTY_MESSAGE = ""
43
43
 
44
44
 
45
- def run_hook_with_message(assistant_message: str) -> subprocess.CompletedProcess:
45
+ def run_hook_with_message(
46
+ assistant_message: str, *, is_prose_style_enabled: bool = True
47
+ ) -> subprocess.CompletedProcess:
46
48
  hook_input_payload = json.dumps({"last_assistant_message": assistant_message})
49
+ environment_by_key = os.environ.copy()
50
+ if is_prose_style_enabled:
51
+ environment_by_key["CLAUDE_PROSE_STYLE_ENFORCEMENT"] = "1"
52
+ else:
53
+ environment_by_key.pop("CLAUDE_PROSE_STYLE_ENFORCEMENT", None)
47
54
  return subprocess.run(
48
55
  [sys.executable, HOOK_SCRIPT_PATH],
49
56
  input=hook_input_payload,
50
57
  capture_output=True,
51
58
  text=True,
52
59
  check=False,
60
+ env=environment_by_key,
53
61
  )
54
62
 
55
63
 
56
- def run_hook_with_payload(hook_input_payload: dict) -> subprocess.CompletedProcess:
64
+ def run_hook_with_payload(
65
+ hook_input_payload: dict, *, is_prose_style_enabled: bool = True
66
+ ) -> subprocess.CompletedProcess:
67
+ environment_by_key = os.environ.copy()
68
+ if is_prose_style_enabled:
69
+ environment_by_key["CLAUDE_PROSE_STYLE_ENFORCEMENT"] = "1"
70
+ else:
71
+ environment_by_key.pop("CLAUDE_PROSE_STYLE_ENFORCEMENT", None)
57
72
  return subprocess.run(
58
73
  [sys.executable, HOOK_SCRIPT_PATH],
59
74
  input=json.dumps(hook_input_payload),
60
75
  capture_output=True,
61
76
  text=True,
62
77
  check=False,
78
+ env=environment_by_key,
63
79
  )
64
80
 
65
81
 
@@ -76,6 +92,15 @@ def test_user_facing_notice_matches_config_messages_module():
76
92
  )
77
93
 
78
94
 
95
+ def test_intent_scan_is_default_off() -> None:
96
+ completed_process = run_hook_with_message(
97
+ INTENT_ENDING_MESSAGE, is_prose_style_enabled=False
98
+ )
99
+
100
+ assert completed_process.returncode == 0
101
+ assert completed_process.stdout == ""
102
+
103
+
79
104
  def test_intent_ending_message_emits_block_with_short_user_notice():
80
105
  completed_process = run_hook_with_message(INTENT_ENDING_MESSAGE)
81
106
 
@@ -367,9 +367,16 @@ def test_find_stale_inventory_returns_survey_for_omission(tmp_path: Path):
367
367
  assert survey.named_basenames == {"dialer_compose.py", "compose_dialer_cli.py"}
368
368
 
369
369
 
370
- def test_find_stale_inventory_skips_prose_only_directory():
371
- audit_rubrics_directory = (
372
- Path(__file__).resolve().parent.parent.parent / "audit-rubrics"
370
+ def test_find_stale_inventory_skips_directory_below_minimum_named_entries(
371
+ tmp_path: Path,
372
+ ) -> None:
373
+ """Skip a package inventory that names fewer than the minimum on-disk siblings."""
374
+ package_directory = tmp_path / "prose_package"
375
+ package_directory.mkdir()
376
+ (package_directory / "CLAUDE.md").write_text(
377
+ "| File | Role |\n|---|---|\n| `only_one.py` | sole entry |\n",
378
+ encoding="utf-8",
373
379
  )
374
- new_file_path = audit_rubrics_directory / "new_helper.py"
380
+ (package_directory / "only_one.py").write_text("FLAG = 1\n", encoding="utf-8")
381
+ new_file_path = package_directory / "new_helper.py"
375
382
  assert find_stale_inventory(str(new_file_path)) is None
@@ -42,18 +42,31 @@ strip_non_prose_regions = hook_module.strip_non_prose_regions
42
42
  build_block_reason = hook_module.build_block_reason
43
43
  find_question_block_violations = hook_module.find_question_block_violations
44
44
  build_lean_block_reason = hook_module.build_lean_block_reason
45
- evaluate = hook_module.evaluate
45
+
46
+
47
+ def evaluate(payload_by_key: dict[str, object]) -> str | None:
48
+ """Call evaluate with opinionated prose enforcement enabled."""
49
+ with patch.dict(os.environ, {"CLAUDE_PROSE_STYLE_ENFORCEMENT": "1"}):
50
+ return hook_module.evaluate(payload_by_key)
46
51
 
47
52
  from pre_tool_use_dispatcher import NativeHook, run_native_hook # noqa: E402
48
53
 
49
54
 
50
- def _run_hook_with_payload(payload: dict) -> subprocess.CompletedProcess[str]:
55
+ def _run_hook_with_payload(
56
+ payload: dict, *, is_prose_style_enabled: bool = True
57
+ ) -> subprocess.CompletedProcess[str]:
58
+ environment_by_key = os.environ.copy()
59
+ if is_prose_style_enabled:
60
+ environment_by_key["CLAUDE_PROSE_STYLE_ENFORCEMENT"] = "1"
61
+ else:
62
+ environment_by_key.pop("CLAUDE_PROSE_STYLE_ENFORCEMENT", None)
51
63
  return subprocess.run(
52
64
  [sys.executable, str(HOOK_SCRIPT_PATH)],
53
65
  input=json.dumps(payload),
54
66
  capture_output=True,
55
67
  text=True,
56
68
  check=False,
69
+ env=environment_by_key,
57
70
  )
58
71
 
59
72
 
@@ -64,10 +77,81 @@ def _decision_from(completed: subprocess.CompletedProcess[str]) -> str | None:
64
77
  return parsed.get("hookSpecificOutput", {}).get("permissionDecision")
65
78
 
66
79
 
80
+ def _system_message_from(completed: subprocess.CompletedProcess[str]) -> str | None:
81
+ if not completed.stdout:
82
+ return None
83
+ parsed = json.loads(completed.stdout)
84
+ system_message = parsed.get("systemMessage")
85
+ return system_message if isinstance(system_message, str) else None
86
+
87
+
67
88
  def test_canonical_hook_script_exists_at_expected_path() -> None:
68
89
  assert HOOK_SCRIPT_PATH.is_file()
69
90
 
70
91
 
92
+ def test_heavy_word_scan_is_default_off(monkeypatch: pytest.MonkeyPatch) -> None:
93
+ monkeypatch.delenv("CLAUDE_PROSE_STYLE_ENFORCEMENT", raising=False)
94
+ payload = {
95
+ "tool_name": "Write",
96
+ "tool_input": {
97
+ "file_path": "notes.md",
98
+ "content": "We initiate the worker pool at boot.",
99
+ },
100
+ }
101
+ assert hook_module.evaluate(payload) is None
102
+ completed = _run_hook_with_payload(payload, is_prose_style_enabled=False)
103
+ assert _decision_from(completed) is None
104
+
105
+
106
+ def test_default_off_emits_privacy_safe_advisory_candidates(
107
+ monkeypatch: pytest.MonkeyPatch,
108
+ ) -> None:
109
+ monkeypatch.delenv("CLAUDE_PROSE_STYLE_ENFORCEMENT", raising=False)
110
+ all_emitted: list[tuple[str, str, str]] = []
111
+
112
+ def _record_advisory(
113
+ matcher_id: str, surface: str, context_text: str, **_kwargs: object
114
+ ) -> dict[str, object]:
115
+ all_emitted.append((matcher_id, surface, context_text))
116
+ return {}
117
+
118
+ monkeypatch.setattr(hook_module, "emit_advisory_candidate", _record_advisory)
119
+ payload = {
120
+ "tool_name": "Write",
121
+ "tool_input": {
122
+ "file_path": "notes.md",
123
+ "content": "We initiate the worker pool at boot.",
124
+ },
125
+ }
126
+ assert hook_module.evaluate(payload) is None
127
+ assert all_emitted
128
+ matcher_id, surface, context_text = all_emitted[0]
129
+ assert matcher_id == "plain_language_heavy_word"
130
+ assert surface == "Write"
131
+ assert "initiate" in context_text
132
+
133
+
134
+ def test_heavy_word_scan_opt_in_allows_with_system_message_swaps() -> None:
135
+ payload = {
136
+ "tool_name": "Write",
137
+ "tool_input": {
138
+ "file_path": "notes.md",
139
+ "content": "We initiate the worker pool at boot.",
140
+ },
141
+ }
142
+ assert evaluate(payload) is None
143
+ with patch.dict(os.environ, {"CLAUDE_PROSE_STYLE_ENFORCEMENT": "1"}):
144
+ deny_reason, advisory_message = hook_module.evaluate_with_advisory(payload)
145
+ assert deny_reason is None
146
+ assert advisory_message is not None
147
+ assert 'use "start" instead of "initiate"' in advisory_message
148
+ completed = _run_hook_with_payload(payload, is_prose_style_enabled=True)
149
+ assert _decision_from(completed) == "allow"
150
+ system_message = _system_message_from(completed)
151
+ assert system_message is not None
152
+ assert 'use "start" instead of "initiate"' in system_message
153
+
154
+
71
155
  def test_bare_prose_banned_term_is_detected() -> None:
72
156
  matched = find_banned_terms("We initiate the worker pool at boot.")
73
157
  assert any(each_term == "initiate" for each_term, _replacement in matched)
@@ -127,7 +211,7 @@ def test_block_reason_names_term_and_replacement() -> None:
127
211
  assert "start" in reason
128
212
 
129
213
 
130
- def test_ask_user_question_with_banned_term_is_denied() -> None:
214
+ def test_ask_user_question_with_banned_term_is_advisory_allow() -> None:
131
215
  payload = {
132
216
  "tool_name": "AskUserQuestion",
133
217
  "tool_input": {
@@ -141,10 +225,13 @@ def test_ask_user_question_with_banned_term_is_denied() -> None:
141
225
  },
142
226
  }
143
227
  completed = _run_hook_with_payload(payload)
144
- assert _decision_from(completed) == "deny"
228
+ assert _decision_from(completed) == "allow"
229
+ system_message = _system_message_from(completed)
230
+ assert system_message is not None
231
+ assert "utilize" in system_message
145
232
 
146
233
 
147
- def test_ask_user_question_banned_term_in_option_label_is_denied() -> None:
234
+ def test_ask_user_question_banned_term_in_option_label_is_advisory_allow() -> None:
148
235
  payload = {
149
236
  "tool_name": "AskUserQuestion",
150
237
  "tool_input": {
@@ -158,7 +245,10 @@ def test_ask_user_question_banned_term_in_option_label_is_denied() -> None:
158
245
  },
159
246
  }
160
247
  completed = _run_hook_with_payload(payload)
161
- assert _decision_from(completed) == "deny"
248
+ assert _decision_from(completed) == "allow"
249
+ system_message = _system_message_from(completed)
250
+ assert system_message is not None
251
+ assert "utilize" in system_message
162
252
 
163
253
 
164
254
  def test_clean_ask_user_question_passes_through() -> None:
@@ -178,7 +268,7 @@ def test_clean_ask_user_question_passes_through() -> None:
178
268
  assert _decision_from(completed) is None
179
269
 
180
270
 
181
- def test_write_markdown_with_banned_term_is_denied(tmp_path: Path) -> None:
271
+ def test_write_markdown_with_banned_term_is_advisory_allow(tmp_path: Path) -> None:
182
272
  target = tmp_path / "notes.md"
183
273
  payload = {
184
274
  "tool_name": "Write",
@@ -188,7 +278,10 @@ def test_write_markdown_with_banned_term_is_denied(tmp_path: Path) -> None:
188
278
  },
189
279
  }
190
280
  completed = _run_hook_with_payload(payload)
191
- assert _decision_from(completed) == "deny"
281
+ assert _decision_from(completed) == "allow"
282
+ system_message = _system_message_from(completed)
283
+ assert system_message is not None
284
+ assert "utilize" in system_message
192
285
 
193
286
 
194
287
  def test_write_markdown_at_ephemeral_path_is_allowed(monkeypatch: pytest.MonkeyPatch) -> None:
@@ -230,7 +323,7 @@ def test_edit_markdown_clean_content_passes_through(tmp_path: Path) -> None:
230
323
  assert _decision_from(completed) is None
231
324
 
232
325
 
233
- def test_multiedit_markdown_with_banned_term_is_denied(tmp_path: Path) -> None:
326
+ def test_multiedit_markdown_with_banned_term_is_advisory_allow(tmp_path: Path) -> None:
234
327
  target = tmp_path / "notes.md"
235
328
  payload = {
236
329
  "tool_name": "MultiEdit",
@@ -243,7 +336,10 @@ def test_multiedit_markdown_with_banned_term_is_denied(tmp_path: Path) -> None:
243
336
  },
244
337
  }
245
338
  completed = _run_hook_with_payload(payload)
246
- assert _decision_from(completed) == "deny"
339
+ assert _decision_from(completed) == "allow"
340
+ system_message = _system_message_from(completed)
341
+ assert system_message is not None
342
+ assert "utilize" in system_message
247
343
 
248
344
 
249
345
  def test_other_tool_is_ignored() -> None:
@@ -270,19 +366,24 @@ def test_real_file_path_is_still_stripped() -> None:
270
366
 
271
367
 
272
368
  def test_native_dispatch_path_logs_the_block(tmp_path: Path) -> None:
273
- """A deny routed through the dispatcher's native path logs one record.
369
+ """A lean-block deny routed through the dispatcher's native path logs once.
274
370
 
275
371
  On the Write|Edit|MultiEdit surface this hook runs only through
276
372
  pre_tool_use_dispatcher's native path, which calls evaluate() and
277
- build_deny_payload() — never _emit_deny() or main(). The block must still
278
- land in the hook-blocks log, so the log call lives on build_deny_payload,
279
- the function the native path executes.
373
+ build_deny_payload() — never _emit_deny() or main(). Heavy words no longer
374
+ deny (OP-07D), so this probe uses AskUserQuestion lean-block chat detail.
375
+ The log call still lives on build_deny_payload.
280
376
  """
281
377
  deny_payload = {
282
- "tool_name": "Edit",
378
+ "tool_name": "AskUserQuestion",
283
379
  "tool_input": {
284
- "file_path": str(tmp_path / "notes.md"),
285
- "new_string": "This guide explains how to utilize the new cache layer.",
380
+ "questions": [
381
+ {
382
+ "question": "Which gate should run first?\n\nPlan the whole stack.",
383
+ "header": "Gate",
384
+ "options": [{"label": "Write", "description": "Runs on write."}],
385
+ }
386
+ ]
286
387
  },
287
388
  }
288
389
  native_hook = NativeHook(
@@ -291,7 +392,9 @@ def test_native_dispatch_path_logs_the_block(tmp_path: Path) -> None:
291
392
  )
292
393
 
293
394
  with patch.object(Path, "home", return_value=tmp_path):
294
- hosted_result = run_native_hook(native_hook, deny_payload, is_blocking=True)
395
+ hosted_result = run_native_hook(
396
+ native_hook, deny_payload, is_blocking=True
397
+ )
295
398
 
296
399
  assert hosted_result.captured_stdout
297
400
  log_path = tmp_path / ".claude" / "logs" / "hook-blocks.log"
@@ -527,7 +630,14 @@ def test_markdown_write_keeps_its_bullets_and_tables(tmp_path: Path) -> None:
527
630
  "tool_input": {"file_path": target_path, "content": bulleted_prose},
528
631
  }
529
632
 
530
- assert evaluate(heavy_payload) is not None
633
+ assert evaluate(heavy_payload) is None
634
+ with patch.dict(os.environ, {"CLAUDE_PROSE_STYLE_ENFORCEMENT": "1"}):
635
+ _deny_reason, advisory_message = hook_module.evaluate_with_advisory(
636
+ heavy_payload
637
+ )
638
+ assert _deny_reason is None
639
+ assert advisory_message is not None
640
+ assert "utilize" in advisory_message
531
641
  assert evaluate(clean_payload) is None
532
642
 
533
643
 
@@ -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: