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
@@ -15,16 +15,16 @@ if _HOOKS_ROOT not in sys.path:
15
15
  import eli11_reply_enforcer
16
16
  from hooks_constants.eli11_reply_enforcer_constants import (
17
17
  MAXIMUM_BULLET_LINE_COUNT,
18
- MAXIMUM_OVERPACKED_LINE_COUNT,
19
- MAXIMUM_REPLY_WORD_COUNT,
20
- MAXIMUM_WORDS_PER_LINE,
21
- MINIMUM_ENFORCED_WORD_COUNT,
18
+ MAXIMUM_OVERPACKED_LIST_LINE_COUNT,
19
+ MAXIMUM_WORDS_PER_LIST_LINE,
22
20
  TARGET_BULLET_LINE_COUNT,
23
21
  )
24
22
  from hooks_constants.text_stripping import strip_code_and_quotes
25
23
 
26
- SAFE_LINE_WORD_COUNT = MAXIMUM_WORDS_PER_LINE // 2
27
- OVERPACKED_LINE_WORD_COUNT = MAXIMUM_WORDS_PER_LINE + 5
24
+ SAFE_LINE_WORD_COUNT = MAXIMUM_WORDS_PER_LIST_LINE // 2
25
+ OVERPACKED_LIST_LINE_WORD_COUNT = MAXIMUM_WORDS_PER_LIST_LINE + 5
26
+ LONG_REPORT_WORD_COUNT = 240
27
+ SHORT_REPLY_WORD_COUNT = 30
28
28
 
29
29
 
30
30
  def build_filler_prose(word_count: int) -> str:
@@ -45,17 +45,13 @@ def build_prose_block(line_count: int, words_per_line: int) -> str:
45
45
 
46
46
 
47
47
  def build_reply_of_exactly(total_word_count: int) -> str:
48
- """Return a filler reply holding exactly the requested words, no line overpacked.
49
-
50
- ::
51
-
52
- in: 22 -> three lines of 10, 10, and 2 filler words
48
+ """Return a filler reply holding exactly the requested words, no list line overpacked.
53
49
 
54
50
  Args:
55
51
  total_word_count: How many countable words the whole reply carries.
56
52
 
57
53
  Returns:
58
- A newline-joined reply whose every line stays under the per-line cap.
54
+ A newline-joined reply whose every line stays under the list-line word cap.
59
55
  """
60
56
  all_lines = []
61
57
  remaining_word_count = total_word_count
@@ -66,63 +62,34 @@ def build_reply_of_exactly(total_word_count: int) -> str:
66
62
  return "\n".join(all_lines)
67
63
 
68
64
 
69
- SHORT_REPLY_WORD_COUNT = MINIMUM_ENFORCED_WORD_COUNT // 2
70
- OVERLONG_REPLY_WORD_COUNT = MAXIMUM_REPLY_WORD_COUNT * 2
71
- JUST_OVER_WORD_CAP_WORD_COUNT = MAXIMUM_REPLY_WORD_COUNT + 1
72
- UNDER_FLOOR_WORD_COUNT = MINIMUM_ENFORCED_WORD_COUNT - 1
73
-
74
65
  SHORT_REPLY = build_reply_of_exactly(SHORT_REPLY_WORD_COUNT)
75
- OVERLONG_REPLY = build_reply_of_exactly(OVERLONG_REPLY_WORD_COUNT)
76
- UNDER_FLOOR_REPLY = build_reply_of_exactly(UNDER_FLOOR_WORD_COUNT)
77
- JUST_OVER_WORD_CAP_REPLY = build_reply_of_exactly(JUST_OVER_WORD_CAP_WORD_COUNT)
78
- THREE_OVERPACKED_LINE_REPLY = build_prose_block(
79
- MAXIMUM_OVERPACKED_LINE_COUNT + 1, OVERPACKED_LINE_WORD_COUNT
66
+ LONG_REPORT_REPLY = build_reply_of_exactly(LONG_REPORT_WORD_COUNT)
67
+ REQUESTED_FULL_REPORT = (
68
+ "Outcome: the audit is complete.\n\n"
69
+ + build_reply_of_exactly(LONG_REPORT_WORD_COUNT)
80
70
  )
81
- TWO_OVERPACKED_LINES = build_prose_block(
82
- MAXIMUM_OVERPACKED_LINE_COUNT, OVERPACKED_LINE_WORD_COUNT
71
+ THREE_OVERPACKED_LIST_LINE_REPLY = build_bullet_block(
72
+ MAXIMUM_OVERPACKED_LIST_LINE_COUNT + 1, OVERPACKED_LIST_LINE_WORD_COUNT
83
73
  )
84
- TWO_OVERPACKED_LINE_REPLY = (
85
- f"{TWO_OVERPACKED_LINES}\n{build_filler_prose(SAFE_LINE_WORD_COUNT)}"
74
+ TWO_OVERPACKED_LIST_LINE_REPLY = build_bullet_block(
75
+ MAXIMUM_OVERPACKED_LIST_LINE_COUNT, OVERPACKED_LIST_LINE_WORD_COUNT
86
76
  )
87
- SEVEN_BULLET_JUST_OVER_FLOOR_REPLY = build_bullet_block(
77
+ SEVEN_BULLET_REPLY = build_bullet_block(
88
78
  MAXIMUM_BULLET_LINE_COUNT + 1, SAFE_LINE_WORD_COUNT
89
79
  )
90
- LONG_FORM_OVERLONG_REPLY = f"Long form: the audit report follows.\n\n{OVERLONG_REPLY}"
91
- BOLD_LONG_FORM_OVERLONG_REPLY = f"**Long form:** the audit report follows.\n\n{OVERLONG_REPLY}"
92
- QUOTED_LONG_FORM_OVERLONG_REPLY = f"> Long form: the audit report follows.\n\n{OVERLONG_REPLY}"
93
- HEADING_LONG_FORM_OVERLONG_REPLY = f"# Long form: the audit report follows.\n\n{OVERLONG_REPLY}"
94
-
80
+ SIX_BULLET_REPLY = build_bullet_block(
81
+ MAXIMUM_BULLET_LINE_COUNT, SAFE_LINE_WORD_COUNT
82
+ )
95
83
  INSTRUCTION_LINE = "Run the migration script."
96
- INSTRUCTION_LINE_WORD_COUNT = len(INSTRUCTION_LINE.split())
97
-
98
84
  ACTION_WITHOUT_STEPS_FIRST_REPLY = (
99
- f"{build_reply_of_exactly(MAXIMUM_REPLY_WORD_COUNT // 2)}\n\n"
100
- f"{INSTRUCTION_LINE}\n\nMerge the branch."
85
+ f"{build_reply_of_exactly(40)}\n\n{INSTRUCTION_LINE}\n\nMerge the branch."
101
86
  )
102
87
  ACTION_WITH_STEPS_FIRST_REPLY = (
103
88
  "1. **Run** the migration script.\n"
104
89
  "2. **Merge** the branch.\n\n"
105
- f"{build_reply_of_exactly(MAXIMUM_REPLY_WORD_COUNT // 2)}"
106
- )
107
- AT_FLOOR_ACTION_REPLY = (
108
- f"{build_reply_of_exactly(MINIMUM_ENFORCED_WORD_COUNT - INSTRUCTION_LINE_WORD_COUNT)}"
109
- f"\n\n{INSTRUCTION_LINE}"
110
- )
111
- SEVEN_BULLET_REPLY = build_bullet_block(
112
- MAXIMUM_BULLET_LINE_COUNT + 1, SAFE_LINE_WORD_COUNT + 3
113
- )
114
- SIX_BULLET_REPLY = build_bullet_block(
115
- MAXIMUM_BULLET_LINE_COUNT, SAFE_LINE_WORD_COUNT + 5
116
- )
117
- FENCED_CODE_REPLY = f"{SHORT_REPLY}\n\n```python\n{OVERLONG_REPLY}\n```\n"
118
- BLOCKQUOTE_REPLY = f"{SHORT_REPLY}\n\n> {build_filler_prose(OVERLONG_REPLY_WORD_COUNT)}\n"
119
- TABLE_REPLY = "{}\n\n{}\n".format(
120
- SHORT_REPLY,
121
- "\n".join(
122
- f"| {build_filler_prose(MAXIMUM_WORDS_PER_LINE)} | cell |" for _ in range(15)
123
- ),
90
+ f"{build_reply_of_exactly(40)}"
124
91
  )
125
-
92
+ SINGLE_LINE_INSTRUCTION = INSTRUCTION_LINE
126
93
  ALL_NARRATIVE_OPENER_LINES = (
127
94
  "Open questions remain about the stripper edge cases.",
128
95
  "Run time stays under one second on the package suite.",
@@ -130,10 +97,10 @@ ALL_NARRATIVE_OPENER_LINES = (
130
97
  )
131
98
  NARRATIVE_OPENER_REPLY = "{}\n\n{}".format(
132
99
  "\n".join(ALL_NARRATIVE_OPENER_LINES),
133
- build_reply_of_exactly(MINIMUM_ENFORCED_WORD_COUNT),
100
+ build_reply_of_exactly(40),
134
101
  )
135
102
  INSTALL_WITHOUT_STEPS_FIRST_REPLY = (
136
- f"{build_reply_of_exactly(MINIMUM_ENFORCED_WORD_COUNT)}\n\n"
103
+ f"{build_reply_of_exactly(40)}\n\n"
137
104
  "Install the package from the registry."
138
105
  )
139
106
  DO_THINGS_WITH_STEPS_FIRST_REPLY = (
@@ -141,12 +108,24 @@ DO_THINGS_WITH_STEPS_FIRST_REPLY = (
141
108
  "1. Install the package.\n"
142
109
  "2. Restart the daemon.\n"
143
110
  "3. Save the file.\n\n"
144
- f"{build_reply_of_exactly(MINIMUM_ENFORCED_WORD_COUNT)}"
111
+ f"{build_reply_of_exactly(40)}"
145
112
  )
146
113
  DO_THINGS_WITHOUT_STEPS_FIRST_REPLY = (
147
- f"{build_reply_of_exactly(MINIMUM_ENFORCED_WORD_COUNT)}\n\n"
114
+ f"{build_reply_of_exactly(40)}\n\n"
148
115
  "Do 3 things: install, restart, save."
149
116
  )
117
+ FENCED_CODE_REPLY = f"{SHORT_REPLY}\n\n```python\n{LONG_REPORT_REPLY}\n```\n"
118
+ BLOCKQUOTE_REPLY = f"{SHORT_REPLY}\n\n> {build_filler_prose(LONG_REPORT_WORD_COUNT)}\n"
119
+ TABLE_REPLY = "{}\n\n{}\n".format(
120
+ SHORT_REPLY,
121
+ "\n".join(
122
+ f"| {build_filler_prose(MAXIMUM_WORDS_PER_LIST_LINE)} | cell |"
123
+ for _ in range(15)
124
+ ),
125
+ )
126
+ LONG_PROSE_PARAGRAPHS_REPLY = "\n\n".join(
127
+ build_filler_prose(MAXIMUM_WORDS_PER_LIST_LINE + 10) for _ in range(4)
128
+ )
150
129
 
151
130
 
152
131
  def run_hook_with_payload(payload: dict) -> subprocess.CompletedProcess:
@@ -171,8 +150,8 @@ def test_blocker_uses_shared_strip_code_and_quotes() -> None:
171
150
  assert eli11_reply_enforcer.strip_code_and_quotes is strip_code_and_quotes
172
151
 
173
152
 
174
- def test_short_reply_passes_through() -> None:
175
- """A reply under the enforced word floor is never judged."""
153
+ def test_short_correct_reply_passes_through() -> None:
154
+ """A concise outcome reply passes without padding."""
176
155
  completed_process = run_hook_with_message(SHORT_REPLY)
177
156
  assert completed_process.returncode == 0
178
157
  assert completed_process.stdout == ""
@@ -188,84 +167,56 @@ def test_empty_message_passes_through() -> None:
188
167
  def test_stop_hook_active_flag_passes_through() -> None:
189
168
  """A re-entrant Stop invocation never blocks again."""
190
169
  completed_process = run_hook_with_payload(
191
- {"last_assistant_message": OVERLONG_REPLY, "stop_hook_active": True}
170
+ {
171
+ "last_assistant_message": ACTION_WITHOUT_STEPS_FIRST_REPLY,
172
+ "stop_hook_active": True,
173
+ }
192
174
  )
193
175
  assert completed_process.returncode == 0
194
176
  assert completed_process.stdout == ""
195
177
 
196
178
 
197
- def test_reply_over_word_cap_emits_block() -> None:
198
- """A reply past the word cap blocks with the count and the cap named."""
199
- completed_process = run_hook_with_message(OVERLONG_REPLY)
200
- assert completed_process.returncode == 0
201
- parsed_response = json.loads(completed_process.stdout)
202
- assert parsed_response["decision"] == "block"
203
- assert str(OVERLONG_REPLY_WORD_COUNT) in parsed_response["reason"]
204
- assert str(MAXIMUM_REPLY_WORD_COUNT) in parsed_response["reason"]
205
-
206
-
207
- def test_reply_just_over_word_cap_emits_block() -> None:
208
- """A reply one word past the cap blocks on length alone."""
209
- completed_process = run_hook_with_message(JUST_OVER_WORD_CAP_REPLY)
210
- assert completed_process.returncode == 0
211
- parsed_response = json.loads(completed_process.stdout)
212
- assert parsed_response["decision"] == "block"
213
- assert (
214
- f"{JUST_OVER_WORD_CAP_WORD_COUNT} words, over the "
215
- f"{MAXIMUM_REPLY_WORD_COUNT}-word cap"
216
- ) in parsed_response["reason"]
217
-
218
-
219
- def test_reply_under_word_floor_passes_through() -> None:
220
- """A reply one word under the floor is never judged."""
221
- completed_process = run_hook_with_message(UNDER_FLOOR_REPLY)
179
+ def test_long_requested_report_passes_without_magic_prefix() -> None:
180
+ """A thorough report passes with no Long form: prefix and no word ceiling."""
181
+ completed_process = run_hook_with_message(REQUESTED_FULL_REPORT)
222
182
  assert completed_process.returncode == 0
223
183
  assert completed_process.stdout == ""
224
184
 
225
185
 
226
- def test_reply_at_the_word_floor_is_judged_for_action_first() -> None:
227
- """A reply of exactly the floor word count earns its action-first violation."""
228
- completed_process = run_hook_with_message(AT_FLOOR_ACTION_REPLY)
186
+ def test_long_prose_paragraphs_are_not_overpacked() -> None:
187
+ """Plain paragraphs over the list-line word cap do not block."""
188
+ completed_process = run_hook_with_message(LONG_PROSE_PARAGRAPHS_REPLY)
229
189
  assert completed_process.returncode == 0
230
- parsed_response = json.loads(completed_process.stdout)
231
- assert parsed_response["decision"] == "block"
232
- assert "put the steps first" in parsed_response["reason"]
190
+ assert completed_process.stdout == ""
233
191
 
234
192
 
235
- def test_reply_just_over_word_floor_is_judged() -> None:
236
- """A bullet-heavy reply just over the floor earns its bullet violation."""
237
- completed_process = run_hook_with_message(SEVEN_BULLET_JUST_OVER_FLOOR_REPLY)
193
+ def test_single_line_instruction_passes_through() -> None:
194
+ """A one-line imperative does not require a numbered list."""
195
+ completed_process = run_hook_with_message(SINGLE_LINE_INSTRUCTION)
238
196
  assert completed_process.returncode == 0
239
- parsed_response = json.loads(completed_process.stdout)
240
- assert parsed_response["decision"] == "block"
241
- assert (
242
- f"cut findings to {TARGET_BULLET_LINE_COUNT} bullets"
243
- in parsed_response["reason"]
244
- )
197
+ assert completed_process.stdout == ""
245
198
 
246
199
 
247
- def test_three_overpacked_lines_emit_block() -> None:
248
- """One line past the overpacked-line cap blocks on one idea per line."""
249
- completed_process = run_hook_with_message(THREE_OVERPACKED_LINE_REPLY)
200
+ def test_three_overpacked_list_lines_emit_block() -> None:
201
+ """List lines past the overpacked-list cap block on one idea per bullet."""
202
+ completed_process = run_hook_with_message(THREE_OVERPACKED_LIST_LINE_REPLY)
250
203
  assert completed_process.returncode == 0
251
204
  parsed_response = json.loads(completed_process.stdout)
252
205
  assert parsed_response["decision"] == "block"
253
- assert "lines carry too many words - one idea per line" in (
254
- parsed_response["reason"]
255
- )
206
+ assert "list lines carry too many words" in parsed_response["reason"]
256
207
 
257
208
 
258
- def test_two_overpacked_lines_pass_through() -> None:
259
- """Over-packed lines at the cap pass."""
260
- completed_process = run_hook_with_message(TWO_OVERPACKED_LINE_REPLY)
209
+ def test_two_overpacked_list_lines_pass_through() -> None:
210
+ """Over-packed list lines at the cap pass."""
211
+ completed_process = run_hook_with_message(TWO_OVERPACKED_LIST_LINE_REPLY)
261
212
  assert completed_process.returncode == 0
262
213
  assert completed_process.stdout == ""
263
214
 
264
215
 
265
216
  def test_bullet_marker_is_not_counted_as_a_line_word() -> None:
266
- """A bullet at the per-line cap stays under it once its marker comes off."""
217
+ """A bullet at the per-list-line cap stays under it once its marker comes off."""
267
218
  capped_bullet_lines = build_bullet_block(
268
- MAXIMUM_BULLET_LINE_COUNT - 2, MAXIMUM_WORDS_PER_LINE
219
+ MAXIMUM_BULLET_LINE_COUNT - 2, MAXIMUM_WORDS_PER_LIST_LINE
269
220
  )
270
221
  all_violations = eli11_reply_enforcer.find_reply_shape_violations(
271
222
  capped_bullet_lines
@@ -273,41 +224,14 @@ def test_bullet_marker_is_not_counted_as_a_line_word() -> None:
273
224
  assert all_violations == []
274
225
 
275
226
 
276
- def test_long_form_prefix_exempts_an_overlong_reply() -> None:
277
- """The Long form escape hatch clears every reply-shape check."""
278
- completed_process = run_hook_with_message(LONG_FORM_OVERLONG_REPLY)
279
- assert completed_process.returncode == 0
280
- assert completed_process.stdout == ""
281
-
282
-
283
- def test_bold_long_form_prefix_exempts_an_overlong_reply() -> None:
284
- """A bold-wrapped Long form prefix still opts the reply out."""
285
- completed_process = run_hook_with_message(BOLD_LONG_FORM_OVERLONG_REPLY)
286
- assert completed_process.returncode == 0
287
- assert completed_process.stdout == ""
288
-
289
-
290
- def test_quoted_long_form_prefix_exempts_an_overlong_reply() -> None:
291
- """A blockquoted Long form prefix still opts the reply out."""
292
- completed_process = run_hook_with_message(QUOTED_LONG_FORM_OVERLONG_REPLY)
293
- assert completed_process.returncode == 0
294
- assert completed_process.stdout == ""
295
-
296
-
297
- def test_heading_long_form_prefix_exempts_an_overlong_reply() -> None:
298
- """A heading-wrapped Long form prefix still opts the reply out."""
299
- completed_process = run_hook_with_message(HEADING_LONG_FORM_OVERLONG_REPLY)
300
- assert completed_process.returncode == 0
301
- assert completed_process.stdout == ""
302
-
303
-
304
227
  def test_instruction_lines_without_leading_steps_emit_block() -> None:
305
228
  """Imperative instructions buried under prose block with a steps-first message."""
306
229
  completed_process = run_hook_with_message(ACTION_WITHOUT_STEPS_FIRST_REPLY)
307
230
  assert completed_process.returncode == 0
308
231
  parsed_response = json.loads(completed_process.stdout)
309
232
  assert parsed_response["decision"] == "block"
310
- assert "put the steps first" in parsed_response["reason"]
233
+ assert "numbered steps" in parsed_response["reason"]
234
+ assert "Rewrite the reply" in parsed_response["reason"]
311
235
 
312
236
 
313
237
  def test_numbered_steps_in_lead_lines_pass_through() -> None:
@@ -350,11 +274,11 @@ def test_names_imperative_object_accepts_determiners_counts_and_paths() -> None:
350
274
  def test_strip_markdown_lead_markers_removes_every_wrapper() -> None:
351
275
  """Blockquote, heading, and bold wrappers come off the front of a line."""
352
276
  assert eli11_reply_enforcer.strip_markdown_lead_markers(
353
- "> **Long form:** the report follows"
354
- ) == "Long form:** the report follows"
277
+ "> **Bold lead:** the report follows"
278
+ ) == "Bold lead:** the report follows"
355
279
  assert eli11_reply_enforcer.strip_markdown_lead_markers(
356
- "# Long form: the report follows"
357
- ) == "Long form: the report follows"
280
+ "# Heading lead: the report follows"
281
+ ) == "Heading lead: the report follows"
358
282
 
359
283
 
360
284
  def test_install_instruction_without_leading_steps_emits_block() -> None:
@@ -363,7 +287,7 @@ def test_install_instruction_without_leading_steps_emits_block() -> None:
363
287
  assert completed_process.returncode == 0
364
288
  parsed_response = json.loads(completed_process.stdout)
365
289
  assert parsed_response["decision"] == "block"
366
- assert "put the steps first" in parsed_response["reason"]
290
+ assert "numbered steps" in parsed_response["reason"]
367
291
 
368
292
 
369
293
  def test_do_three_things_without_leading_steps_emits_block() -> None:
@@ -372,7 +296,7 @@ def test_do_three_things_without_leading_steps_emits_block() -> None:
372
296
  assert completed_process.returncode == 0
373
297
  parsed_response = json.loads(completed_process.stdout)
374
298
  assert parsed_response["decision"] == "block"
375
- assert "put the steps first" in parsed_response["reason"]
299
+ assert "numbered steps" in parsed_response["reason"]
376
300
 
377
301
 
378
302
  def test_do_three_things_with_numbered_steps_passes_through() -> None:
@@ -398,13 +322,13 @@ def test_numbered_step_opener_is_a_numbered_step() -> None:
398
322
 
399
323
 
400
324
  def test_more_than_six_bullets_emits_block() -> None:
401
- """One bullet past the cap blocks with the cut-to-target-bullets message."""
325
+ """One bullet past the cap blocks with the put-findings message."""
402
326
  completed_process = run_hook_with_message(SEVEN_BULLET_REPLY)
403
327
  assert completed_process.returncode == 0
404
328
  parsed_response = json.loads(completed_process.stdout)
405
329
  assert parsed_response["decision"] == "block"
406
330
  assert (
407
- f"cut findings to {TARGET_BULLET_LINE_COUNT} bullets"
331
+ f"put findings in at most {TARGET_BULLET_LINE_COUNT} bullets"
408
332
  in parsed_response["reason"]
409
333
  )
410
334
 
@@ -416,29 +340,29 @@ def test_six_bullets_pass_through() -> None:
416
340
  assert completed_process.stdout == ""
417
341
 
418
342
 
419
- def test_fenced_code_words_are_not_counted() -> None:
420
- """Words inside a fenced code block never push a reply past the cap."""
343
+ def test_fenced_code_does_not_force_shape_block() -> None:
344
+ """Words inside a fenced code block do not create shape violations alone."""
421
345
  completed_process = run_hook_with_message(FENCED_CODE_REPLY)
422
346
  assert completed_process.returncode == 0
423
347
  assert completed_process.stdout == ""
424
348
 
425
349
 
426
- def test_blockquote_words_are_not_counted() -> None:
427
- """Quoted lines are the user's words and never push a reply past the cap."""
350
+ def test_blockquote_does_not_force_shape_block() -> None:
351
+ """Quoted lines are exempt from shape counting."""
428
352
  completed_process = run_hook_with_message(BLOCKQUOTE_REPLY)
429
353
  assert completed_process.returncode == 0
430
354
  assert completed_process.stdout == ""
431
355
 
432
356
 
433
357
  def test_table_rows_are_not_counted() -> None:
434
- """Table rows carry reference data and never push a reply past the cap."""
358
+ """Table rows carry reference data and do not create shape violations alone."""
435
359
  completed_process = run_hook_with_message(TABLE_REPLY)
436
360
  assert completed_process.returncode == 0
437
361
  assert completed_process.stdout == ""
438
362
 
439
363
 
440
364
  def test_urls_are_removed_from_counted_prose() -> None:
441
- """A link target is stripped before the words are counted."""
365
+ """A link target is stripped before prose is judged."""
442
366
  prose_text = eli11_reply_enforcer.extract_reply_prose(
443
367
  "The draft is at https://github.com/owner/repo/pull/704 now"
444
368
  )
@@ -446,12 +370,21 @@ def test_urls_are_removed_from_counted_prose() -> None:
446
370
  assert "draft" in prose_text
447
371
 
448
372
 
449
- def test_block_response_json_shape() -> None:
450
- """The block payload carries the Stop-hook keys and names the escape hatch."""
451
- completed_process = run_hook_with_message(OVERLONG_REPLY)
373
+ def test_block_reason_uses_positive_rewrite_language() -> None:
374
+ """Corrective output tells the model what to write, not only what failed."""
375
+ reason = eli11_reply_enforcer.build_block_reason(
376
+ [eli11_reply_enforcer.describe_action_first_violation()]
377
+ )
378
+ assert "Rewrite the reply" in reason
379
+ assert "120-word" not in reason
380
+ assert "Long form:" not in reason
381
+ assert "minimum" not in reason.lower()
382
+
383
+
384
+ def test_hook_never_forces_padding_on_short_replies() -> None:
385
+ """A correct short reply is not blocked for being under a word floor."""
386
+ tiny_reply = "Done."
387
+ completed_process = run_hook_with_message(tiny_reply)
452
388
  assert completed_process.returncode == 0
453
- parsed_response = json.loads(completed_process.stdout)
454
- assert parsed_response["decision"] == "block"
455
- assert parsed_response["suppressOutput"] is True
456
- assert parsed_response["systemMessage"]
457
- assert "Long form:" in parsed_response["reason"]
389
+ assert completed_process.stdout == ""
390
+ assert eli11_reply_enforcer.find_reply_shape_violations(tiny_reply) == []
@@ -1,12 +1,15 @@
1
1
  """Tests for hedging_language_blocker hook response shape."""
2
2
 
3
3
  import importlib.util
4
+ import io
4
5
  import json
5
6
  import os
6
7
  import subprocess
7
8
  import sys
8
9
  import tempfile
9
10
 
11
+ import pytest
12
+
10
13
  HOOK_SCRIPT_PATH = os.path.join(os.path.dirname(__file__), "hedging_language_blocker.py")
11
14
  _HOOKS_DIR = os.path.dirname(HOOK_SCRIPT_PATH)
12
15
  _HOOKS_ROOT = os.path.join(_HOOKS_DIR, "..")
@@ -30,14 +33,22 @@ CLEAN_MESSAGE = "This is verified by the source document."
30
33
  EMPTY_MESSAGE = ""
31
34
 
32
35
 
33
- def run_hook_with_message(assistant_message: str) -> subprocess.CompletedProcess:
36
+ def run_hook_with_message(
37
+ assistant_message: str, *, is_prose_style_enabled: bool = True
38
+ ) -> subprocess.CompletedProcess:
34
39
  hook_input_payload = json.dumps({"last_assistant_message": assistant_message})
40
+ environment_by_key = os.environ.copy()
41
+ if is_prose_style_enabled:
42
+ environment_by_key["CLAUDE_PROSE_STYLE_ENFORCEMENT"] = "1"
43
+ else:
44
+ environment_by_key.pop("CLAUDE_PROSE_STYLE_ENFORCEMENT", None)
35
45
  return subprocess.run(
36
46
  [sys.executable, HOOK_SCRIPT_PATH],
37
47
  input=hook_input_payload,
38
48
  capture_output=True,
39
49
  text=True,
40
50
  check=False,
51
+ env=environment_by_key,
41
52
  )
42
53
 
43
54
 
@@ -58,6 +69,8 @@ def run_hook_with_patched_search_paths(
58
69
  wrapper_file_path = wrapper_file.name
59
70
 
60
71
  hook_input_payload = json.dumps({"last_assistant_message": assistant_message})
72
+ environment_by_key = os.environ.copy()
73
+ environment_by_key["CLAUDE_PROSE_STYLE_ENFORCEMENT"] = "1"
61
74
  try:
62
75
  completed_process = subprocess.run(
63
76
  [sys.executable, wrapper_file_path],
@@ -65,6 +78,7 @@ def run_hook_with_patched_search_paths(
65
78
  capture_output=True,
66
79
  text=True,
67
80
  check=False,
81
+ env=environment_by_key,
68
82
  )
69
83
  finally:
70
84
  os.unlink(wrapper_file_path)
@@ -80,6 +94,49 @@ def test_user_facing_notice_matches_config_messages_module():
80
94
  assert module.USER_FACING_NOTICE == USER_FACING_NOTICE
81
95
 
82
96
 
97
+ def test_hedging_scan_is_default_off() -> None:
98
+ completed_process = run_hook_with_message(
99
+ HEDGING_MESSAGE, is_prose_style_enabled=False
100
+ )
101
+
102
+ assert completed_process.returncode == 0
103
+ assert completed_process.stdout == ""
104
+
105
+
106
+ def test_default_off_emits_privacy_safe_advisory_candidates(
107
+ monkeypatch: pytest.MonkeyPatch,
108
+ ) -> None:
109
+ all_emitted: list[tuple[str, str, str]] = []
110
+
111
+ def _record_advisory(
112
+ matcher_id: str, surface: str, context_text: str, **_kwargs: object
113
+ ) -> dict[str, object]:
114
+ all_emitted.append((matcher_id, surface, context_text))
115
+ return {}
116
+
117
+ monkeypatch.setattr(
118
+ hedging_language_blocker, "emit_advisory_candidate", _record_advisory
119
+ )
120
+ monkeypatch.setattr(
121
+ hedging_language_blocker,
122
+ "prose_style_enforcement_enabled_in_environment",
123
+ lambda: False,
124
+ )
125
+ monkeypatch.setattr(
126
+ hedging_language_blocker.sys,
127
+ "stdin",
128
+ io.StringIO(json.dumps({"last_assistant_message": HEDGING_MESSAGE})),
129
+ )
130
+ with pytest.raises(SystemExit) as exit_info:
131
+ hedging_language_blocker.main()
132
+ assert exit_info.value.code == 0
133
+ assert all_emitted
134
+ matcher_id, surface, context_text = all_emitted[0]
135
+ assert matcher_id == "hedging_word"
136
+ assert surface == "Stop"
137
+ assert "likely" in context_text
138
+
139
+
83
140
  def test_hedging_message_emits_block_with_short_user_notice():
84
141
  completed_process = run_hook_with_message(HEDGING_MESSAGE)
85
142
 
@@ -140,3 +197,65 @@ def test_empty_message_passes_through_with_no_output():
140
197
 
141
198
  assert completed_process.returncode == 0
142
199
  assert completed_process.stdout == ""
200
+
201
+
202
+ def test_explicit_unverified_label_in_same_sentence_passes() -> None:
203
+ completed_process = run_hook_with_message(
204
+ "This claim is unverified; the deploy is probably blocked."
205
+ )
206
+
207
+ assert completed_process.returncode == 0
208
+ assert completed_process.stdout == ""
209
+
210
+
211
+ def test_i_dont_know_label_in_same_sentence_passes() -> None:
212
+ completed_process = run_hook_with_message(
213
+ "I don't know whether the port is probably open."
214
+ )
215
+
216
+ assert completed_process.returncode == 0
217
+ assert completed_process.stdout == ""
218
+
219
+
220
+ def test_supported_probability_without_hedge_word_passes() -> None:
221
+ completed_process = run_hook_with_message(
222
+ "The suite reports 0.92 precision on the labeled fixture set in "
223
+ "test_prose_matcher_advisory.py."
224
+ )
225
+
226
+ assert completed_process.returncode == 0
227
+ assert completed_process.stdout == ""
228
+
229
+
230
+ def test_label_in_one_sentence_does_not_exempt_bare_hedge_in_another() -> None:
231
+ completed_process = run_hook_with_message(
232
+ "This claim is unverified. The deploy is probably blocked."
233
+ )
234
+
235
+ assert completed_process.returncode == 0
236
+ parsed_response = json.loads(completed_process.stdout)
237
+ assert parsed_response["decision"] == "block"
238
+ assert "probably" in parsed_response["reason"]
239
+ assert "explicit uncertainty label" in parsed_response["reason"]
240
+
241
+
242
+ def test_bare_probably_still_blocks_with_positive_corrective() -> None:
243
+ completed_process = run_hook_with_message("The deploy is probably blocked.")
244
+
245
+ assert completed_process.returncode == 0
246
+ parsed_response = json.loads(completed_process.stdout)
247
+ assert parsed_response["decision"] == "block"
248
+ assert "probably" in parsed_response["reason"]
249
+ assert "label that claim unverified" in parsed_response["reason"]
250
+ assert "AskUserQuestion" in parsed_response["reason"]
251
+
252
+
253
+ def test_find_blocking_hedging_terms_is_sentence_scoped() -> None:
254
+ bare = hedging_language_blocker.find_blocking_hedging_terms(
255
+ "This claim is unverified. The deploy is probably blocked."
256
+ )
257
+ labeled = hedging_language_blocker.find_blocking_hedging_terms(
258
+ "This claim is unverified; the deploy is probably blocked."
259
+ )
260
+ assert bare == ["probably"]
261
+ assert labeled == []
@@ -3,6 +3,7 @@
3
3
  import importlib.util
4
4
  import io
5
5
  import json
6
+ import os
6
7
  import pathlib
7
8
  import sys
8
9
  from unittest import mock
@@ -192,14 +193,33 @@ def test_written_content_reads_edit_new_string() -> None:
192
193
  assert written_content("Edit", edit_input) == "edited body"
193
194
 
194
195
 
195
- def _run_main_with_io(input_text: str) -> str:
196
- with mock.patch("sys.stdin", io.StringIO(input_text)):
197
- with mock.patch("sys.stdout", new_callable=io.StringIO) as mock_stdout:
198
- try:
199
- hook_module.main()
200
- except SystemExit:
201
- pass
202
- return mock_stdout.getvalue()
196
+ def _run_main_with_io(
197
+ input_text: str, *, is_prose_style_enabled: bool = True
198
+ ) -> str:
199
+ environment_by_key = os.environ.copy()
200
+ if is_prose_style_enabled:
201
+ environment_by_key["CLAUDE_PROSE_STYLE_ENFORCEMENT"] = "1"
202
+ else:
203
+ environment_by_key.pop("CLAUDE_PROSE_STYLE_ENFORCEMENT", None)
204
+ with mock.patch.dict(os.environ, environment_by_key, clear=True):
205
+ with mock.patch("sys.stdin", io.StringIO(input_text)):
206
+ with mock.patch("sys.stdout", new_callable=io.StringIO) as mock_stdout:
207
+ try:
208
+ hook_module.main()
209
+ except SystemExit:
210
+ pass
211
+ return mock_stdout.getvalue()
212
+
213
+
214
+ def test_main_is_default_off_for_overstated_hook_module_write() -> None:
215
+ hook_input = {
216
+ "tool_name": "Write",
217
+ "tool_input": {
218
+ "file_path": "/repo/hooks/hooks_constants/some_blocker_constants.py",
219
+ "content": _OVERSTATED_MESSAGE_MODULE,
220
+ },
221
+ }
222
+ assert _run_main_with_io(json.dumps(hook_input), is_prose_style_enabled=False) == ""
203
223
 
204
224
 
205
225
  def test_main_blocks_overstated_hook_module_write() -> None: