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
@@ -0,0 +1,45 @@
1
+ """Tests for the prose-style enforcement opt-in flag (default off)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.util
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+
10
+ _CONSTANTS_PATH = Path(__file__).resolve().parent / "prose_style_enforcement_constants.py"
11
+
12
+
13
+ def _load_constants_module(monkeypatch: pytest.MonkeyPatch, env_value: str | None):
14
+ module_name = "prose_style_enforcement_constants_under_test"
15
+ if env_value is None:
16
+ monkeypatch.delenv("CLAUDE_PROSE_STYLE_ENFORCEMENT", raising=False)
17
+ else:
18
+ monkeypatch.setenv("CLAUDE_PROSE_STYLE_ENFORCEMENT", env_value)
19
+ module_spec = importlib.util.spec_from_file_location(module_name, _CONSTANTS_PATH)
20
+ assert module_spec is not None and module_spec.loader is not None
21
+ module = importlib.util.module_from_spec(module_spec)
22
+ module_spec.loader.exec_module(module)
23
+ return module
24
+
25
+
26
+ def test_absent_env_is_default_off(monkeypatch: pytest.MonkeyPatch) -> None:
27
+ module = _load_constants_module(monkeypatch, None)
28
+ assert module.PROSE_STYLE_ENFORCEMENT_ENABLED is False
29
+ assert module.prose_style_enforcement_enabled_in_environment() is False
30
+
31
+
32
+ @pytest.mark.parametrize("enabled_value", ["1", "true", "yes", "on", " True "])
33
+ def test_truthy_env_enables(
34
+ monkeypatch: pytest.MonkeyPatch, enabled_value: str
35
+ ) -> None:
36
+ module = _load_constants_module(monkeypatch, enabled_value)
37
+ assert module.PROSE_STYLE_ENFORCEMENT_ENABLED is True
38
+
39
+
40
+ @pytest.mark.parametrize("disabled_value", ["0", "false", "no", "off", ""])
41
+ def test_falsy_env_stays_off(
42
+ monkeypatch: pytest.MonkeyPatch, disabled_value: str
43
+ ) -> None:
44
+ module = _load_constants_module(monkeypatch, disabled_value)
45
+ assert module.PROSE_STYLE_ENFORCEMENT_ENABLED is False
@@ -1,15 +1,13 @@
1
1
  #!/usr/bin/env python3
2
2
  """
3
- Stop hook that blocks a final reply breaking the short action-first reply shape.
4
-
5
- The `eli11-replies` rule asks every chat reply to lead with the action, keep
6
- findings to a few bullets, and stay short. This hook reads the final assistant
7
- message, strips code fences, inline code, blockquotes, table rows, and link
8
- targets, then judges what a reader actually sees: the word count against a hard
9
- cap, whether numbered steps lead a reply that tells the user to act, the number
10
- of bullet lines, and how many lines pack more than one idea's worth of words.
11
- A reply opening with "Long form:" opts out, so a user-requested full report
12
- still goes through.
3
+ Stop hook that blocks egregious reply-shape failures (action-first, bullets).
4
+
5
+ The `eli11-replies` and `opus5-communication-contract` rules ask every chat
6
+ reply to lead with the action when the user must act, keep findings to a few
7
+ bullets, and stay scannable. This hook reads the final assistant message,
8
+ strips code fences, inline code, blockquotes, table rows, and link targets,
9
+ then judges shape only: multi-line instructions need numbered steps first,
10
+ bullet count stays bounded, and list lines stay one-idea short.
13
11
  """
14
12
 
15
13
  import json
@@ -30,13 +28,10 @@ from hooks_constants.eli11_reply_enforcer_constants import ( # noqa: E402
30
28
  IMPERATIVE_OBJECT_TOKEN_PATTERN,
31
29
  LINK_TARGET_PATTERN,
32
30
  LIST_MARKER_PREFIX_PATTERN,
33
- LONG_FORM_ESCAPE_PREFIX,
34
31
  MARKDOWN_LEAD_MARKER_PATTERN,
35
32
  MAXIMUM_BULLET_LINE_COUNT,
36
- MAXIMUM_OVERPACKED_LINE_COUNT,
37
- MAXIMUM_REPLY_WORD_COUNT,
38
- MAXIMUM_WORDS_PER_LINE,
39
- MINIMUM_ENFORCED_WORD_COUNT,
33
+ MAXIMUM_OVERPACKED_LIST_LINE_COUNT,
34
+ MAXIMUM_WORDS_PER_LIST_LINE,
40
35
  NUMBERED_STEP_PATTERN,
41
36
  TABLE_ROW_PATTERN,
42
37
  TARGET_BULLET_LINE_COUNT,
@@ -69,18 +64,6 @@ def extract_reply_prose(assistant_message: str) -> str:
69
64
  return LINK_TARGET_PATTERN.sub("", prose_text)
70
65
 
71
66
 
72
- def count_reply_words(prose_text: str) -> int:
73
- """Return the number of reader-visible words in the stripped prose.
74
-
75
- Args:
76
- prose_text: Prose already cleaned of code, quotes, tables, and links.
77
-
78
- Returns:
79
- The count of whitespace-separated tokens carrying a letter or digit.
80
- """
81
- return len(COUNTABLE_WORD_PATTERN.findall(prose_text))
82
-
83
-
84
67
  def collect_non_empty_lines(prose_text: str) -> list[str]:
85
68
  """Return every line of the stripped prose that carries visible text.
86
69
 
@@ -249,29 +232,13 @@ def count_words_in_line(prose_line: str) -> int:
249
232
  return len(COUNTABLE_WORD_PATTERN.findall(unmarked_line))
250
233
 
251
234
 
252
- def count_overpacked_lines(all_prose_lines: list[str]) -> int:
253
- """Return how many lines carry more words than one idea needs.
254
-
255
- Args:
256
- all_prose_lines: The non-empty lines of reply prose.
257
-
258
- Returns:
259
- The count of lines over the per-line word cap.
260
- """
261
- return sum(
262
- 1
263
- for each_line in all_prose_lines
264
- if count_words_in_line(each_line) > MAXIMUM_WORDS_PER_LINE
265
- )
266
-
267
-
268
235
  def strip_markdown_lead_markers(prose_line: str) -> str:
269
236
  """Return a line with its opening blockquote, heading, and bold markers off.
270
237
 
271
238
  ::
272
239
 
273
- in: "> **Long form:** the report follows"
274
- out: "Long form:** the report follows"
240
+ in: "> **Bold lead:** the report follows"
241
+ out: "Bold lead:** the report follows"
275
242
 
276
243
  Args:
277
244
  prose_line: One line of raw reply text.
@@ -282,34 +249,37 @@ def strip_markdown_lead_markers(prose_line: str) -> str:
282
249
  return MARKDOWN_LEAD_MARKER_PATTERN.sub("", prose_line.strip(), count=1)
283
250
 
284
251
 
285
- def opens_with_long_form_escape(assistant_message: str) -> bool:
286
- """Return True when the reply opts out through the Long form prefix.
252
+ def is_list_item_line(prose_line: str) -> bool:
253
+ """Return True when a line is a bullet or numbered list item.
254
+
255
+ Args:
256
+ prose_line: One non-empty line of reply prose.
257
+
258
+ Returns:
259
+ True when the line opens with a bullet or numbered-step marker.
260
+ """
261
+ if BULLET_LINE_PATTERN.match(prose_line):
262
+ return True
263
+ return NUMBERED_STEP_PATTERN.match(prose_line) is not None
287
264
 
288
- ::
289
265
 
290
- ok: "**Long form:** the report follows" -> True
291
- flag: "The report follows" -> False
266
+ def count_overpacked_list_lines(all_prose_lines: list[str]) -> int:
267
+ """Return how many list lines carry more words than one idea needs.
292
268
 
293
- A bold, blockquote, or heading wrapper around the prefix still opts out.
269
+ Plain paragraphs are not counted so a requested full report can use long
270
+ prose lines without a word ceiling.
294
271
 
295
272
  Args:
296
- assistant_message: The raw final assistant message.
273
+ all_prose_lines: The non-empty lines of reply prose.
297
274
 
298
275
  Returns:
299
- True when the first line with content starts with the escape prefix.
276
+ The count of bullet/numbered lines over the per-list-line word cap.
300
277
  """
301
- for each_line in assistant_message.splitlines():
302
- if not each_line.strip():
303
- continue
304
- unwrapped_line = strip_markdown_lead_markers(each_line)
305
- return unwrapped_line.lower().startswith(LONG_FORM_ESCAPE_PREFIX)
306
- return False
307
-
308
-
309
- def describe_word_cap_violation(reply_word_count: int) -> str:
310
- """Return the violation text naming the reply length and the cap."""
311
- return (
312
- f"{reply_word_count} words, over the {MAXIMUM_REPLY_WORD_COUNT}-word cap"
278
+ return sum(
279
+ 1
280
+ for each_line in all_prose_lines
281
+ if is_list_item_line(each_line)
282
+ and count_words_in_line(each_line) > MAXIMUM_WORDS_PER_LIST_LINE
313
283
  )
314
284
 
315
285
 
@@ -317,70 +287,68 @@ def describe_bullet_cap_violation(bullet_line_count: int) -> str:
317
287
  """Return the violation text naming the bullet count and the target."""
318
288
  return (
319
289
  f"{bullet_line_count} bullets, over the {MAXIMUM_BULLET_LINE_COUNT}-bullet "
320
- f"cap - cut findings to {TARGET_BULLET_LINE_COUNT} bullets"
290
+ f"cap - put findings in at most {TARGET_BULLET_LINE_COUNT} bullets"
321
291
  )
322
292
 
323
293
 
324
- def describe_overpacked_line_violation(overpacked_line_count: int) -> str:
325
- """Return the violation text naming how many lines pack too much in."""
294
+ def describe_overpacked_list_line_violation(overpacked_line_count: int) -> str:
295
+ """Return the violation text for overpacked list lines."""
326
296
  return (
327
- f"{overpacked_line_count} lines carry too many words - one idea per line, "
328
- f"at most {MAXIMUM_WORDS_PER_LINE} words each"
297
+ f"{overpacked_line_count} list lines carry too many words - one idea per "
298
+ f"bullet or step, at most {MAXIMUM_WORDS_PER_LIST_LINE} words each"
329
299
  )
330
300
 
331
301
 
332
302
  def describe_action_first_violation() -> str:
333
303
  """Return the violation text for instructions buried under prose."""
334
304
  return (
335
- "the reply tells the user to act but leads with prose - put the steps "
336
- "first, as a numbered list"
305
+ "the reply tells the user to act but leads with prose - open with "
306
+ "numbered steps, one short line each"
337
307
  )
338
308
 
339
309
 
340
- def is_action_first_violation(
341
- reply_word_count: int, all_prose_lines: list[str]
342
- ) -> bool:
343
- """Return True when instructions sit below the lead of a long reply.
310
+ def is_action_first_violation(all_prose_lines: list[str]) -> bool:
311
+ """Return True when multi-line instructions lack leading numbered steps.
312
+
313
+ ::
314
+
315
+ ok: single-line "Run the migration." -> False
316
+ ok: numbered steps first, then prose -> False
317
+ flag: prose then "Install the package." -> True
344
318
 
345
319
  Args:
346
- reply_word_count: The reader-visible word count of the reply.
347
320
  all_prose_lines: The non-empty lines of reply prose.
348
321
 
349
322
  Returns:
350
- True when the reply is long, carries instruction lines, and shows no
351
- numbered step among its lead lines.
323
+ True when the reply has more than one line, carries instruction
324
+ lines, and shows no numbered step among its lead lines.
352
325
  """
353
- if reply_word_count < MINIMUM_ENFORCED_WORD_COUNT:
326
+ if len(all_prose_lines) <= 1:
354
327
  return False
355
328
  if not has_imperative_instruction_line(all_prose_lines):
356
329
  return False
357
330
  return not has_leading_numbered_step(all_prose_lines)
358
331
 
359
332
 
360
- def collect_shape_violations(
361
- reply_word_count: int, all_prose_lines: list[str]
362
- ) -> list[str]:
333
+ def collect_shape_violations(all_prose_lines: list[str]) -> list[str]:
363
334
  """Return the violation texts for every reply-shape rule the reply breaks.
364
335
 
365
336
  Args:
366
- reply_word_count: The reader-visible word count of the reply.
367
337
  all_prose_lines: The non-empty lines of reply prose.
368
338
 
369
339
  Returns:
370
340
  One violation text per broken rule, empty when the reply is in shape.
371
341
  """
372
342
  all_violations: list[str] = []
373
- if reply_word_count > MAXIMUM_REPLY_WORD_COUNT:
374
- all_violations.append(describe_word_cap_violation(reply_word_count))
375
- if is_action_first_violation(reply_word_count, all_prose_lines):
343
+ if is_action_first_violation(all_prose_lines):
376
344
  all_violations.append(describe_action_first_violation())
377
345
  bullet_line_count = count_bullet_lines(all_prose_lines)
378
346
  if bullet_line_count > MAXIMUM_BULLET_LINE_COUNT:
379
347
  all_violations.append(describe_bullet_cap_violation(bullet_line_count))
380
- overpacked_line_count = count_overpacked_lines(all_prose_lines)
381
- if overpacked_line_count > MAXIMUM_OVERPACKED_LINE_COUNT:
348
+ overpacked_list_line_count = count_overpacked_list_lines(all_prose_lines)
349
+ if overpacked_list_line_count > MAXIMUM_OVERPACKED_LIST_LINE_COUNT:
382
350
  all_violations.append(
383
- describe_overpacked_line_violation(overpacked_line_count)
351
+ describe_overpacked_list_line_violation(overpacked_list_line_count)
384
352
  )
385
353
  return all_violations
386
354
 
@@ -390,11 +358,9 @@ def find_reply_shape_violations(assistant_message: str) -> list[str]:
390
358
 
391
359
  ::
392
360
 
393
- ok: a 40-word outcome sentence -> []
394
- flag: a 260-word wall -> ["260 words, over the 120-word cap"]
395
-
396
- A reply opening with the escape prefix, and a reply under the enforced word
397
- floor, are both returned as in shape.
361
+ ok: a short outcome sentence -> []
362
+ ok: a long requested audit with few bullets -> []
363
+ flag: prose then buried "Install the package." -> [action-first]
398
364
 
399
365
  Args:
400
366
  assistant_message: The raw final assistant message.
@@ -402,19 +368,12 @@ def find_reply_shape_violations(assistant_message: str) -> list[str]:
402
368
  Returns:
403
369
  One violation text per broken rule, empty when the reply is in shape.
404
370
  """
405
- if opens_with_long_form_escape(assistant_message):
406
- return []
407
371
  prose_text = extract_reply_prose(assistant_message)
408
- reply_word_count = count_reply_words(prose_text)
409
- if reply_word_count < MINIMUM_ENFORCED_WORD_COUNT:
410
- return []
411
- return collect_shape_violations(
412
- reply_word_count, collect_non_empty_lines(prose_text)
413
- )
372
+ return collect_shape_violations(collect_non_empty_lines(prose_text))
414
373
 
415
374
 
416
375
  def build_block_reason(all_violations: list[str]) -> str:
417
- """Return the corrective message naming each violation and the escape hatch.
376
+ """Return the corrective message naming each violation and the rewrite path.
418
377
 
419
378
  Args:
420
379
  all_violations: The violation texts the reply earned.
@@ -424,16 +383,14 @@ def build_block_reason(all_violations: list[str]) -> str:
424
383
  """
425
384
  formatted_violation_list = VIOLATION_SEPARATOR.join(all_violations)
426
385
  return (
427
- f"ELI11 REPLY SHAPE: Your reply breaks the short action-first shape "
386
+ f"ELI11 REPLY SHAPE: Rewrite the reply into a short action-first shape "
428
387
  f"({formatted_violation_list}).\n\n"
429
- f"Rewrite it short. When the user must act, open with numbered "
430
- f"click-by-click steps, one short line each. When nothing is needed, open "
431
- f"with the outcome in one sentence. Keep findings to at most "
432
- f"{TARGET_BULLET_LINE_COUNT} short bullets and stay under "
433
- f"{MAXIMUM_REPLY_WORD_COUNT} words. Code fences, blockquotes, tables, and "
434
- f"links are already exempt from the count.\n\n"
435
- f"When the user asked for a full report, start the reply with "
436
- f'"Long form:" to opt out of this check.\n\n'
388
+ f"When the user must act, open with numbered click-by-click steps, one "
389
+ f"short line each. When nothing is needed, open with the outcome in one "
390
+ f"sentence. Keep findings to at most {TARGET_BULLET_LINE_COUNT} short "
391
+ f"bullets. A requested full report may run long; keep list lines short "
392
+ f"and lead with the outcome. Code fences, blockquotes, tables, and "
393
+ f"links are already exempt from shape counting.\n\n"
437
394
  f"You MUST re-output the complete, revised response with the correction "
438
395
  f"applied."
439
396
  )
@@ -1,9 +1,19 @@
1
1
  #!/usr/bin/env python3
2
- """
3
- Stop hook that blocks Claude responses containing hedging language.
2
+ """Stop hook for bare hedging words that mark unverified claims.
3
+
4
+ Words like "likely" or "probably" stand in for evidence. When prose-style
5
+ enforcement is on, a hedge word blocks the response unless the same sentence
6
+ carries an explicit uncertainty label (unverified, I don't know, no source for
7
+ this claim). A label in another sentence does not exempt a bare hedge.
8
+
9
+ ::
4
10
 
5
- Words like "likely", "probably", "presumably" signal unverified claims.
6
- When detected, Claude is forced to re-check and respond with verified facts.
11
+ pass: This claim is unverified; the deploy is probably blocked.
12
+ flag: This claim is unverified. The deploy is probably blocked.
13
+
14
+ When enforcement is off, surviving bare hedges emit privacy-safe advisory
15
+ candidates (OP-07B). See rules/hedging-claims.md and
16
+ docs/references/prose-style-enforcement.md.
7
17
  """
8
18
 
9
19
  import json
@@ -16,9 +26,25 @@ _hooks_dir = str(Path(__file__).resolve().parent.parent)
16
26
  if _hooks_dir not in sys.path:
17
27
  sys.path.insert(0, _hooks_dir)
18
28
 
29
+ from blocking.config.prose_style_enforcement_constants import ( # noqa: E402
30
+ prose_style_enforcement_enabled_in_environment,
31
+ )
32
+ from hooks_constants.hedging_uncertainty_constants import ( # noqa: E402
33
+ ALL_EXPLICIT_UNCERTAINTY_LABEL_PATTERNS,
34
+ BLOCK_REASON_PREFIX,
35
+ HEDGING_TERM_LIST_SEPARATOR,
36
+ POSITIVE_CORRECTIVE_GUIDANCE,
37
+ SENTENCE_BOUNDARY_PATTERN,
38
+ )
19
39
  from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
20
40
  from hooks_constants.messages import USER_FACING_NOTICE # noqa: E402
41
+ from hooks_constants.prose_matcher_precision_constants import ( # noqa: E402
42
+ ADVISORY_CONTEXT_SNIPPET_MAX_CHARS,
43
+ MATCHER_ID_HEDGING_WORD,
44
+ MAXIMUM_ADVISORY_EMITS_PER_CALL,
45
+ )
21
46
  from hooks_constants.text_stripping import strip_code_and_quotes # noqa: E402
47
+ from observability.prose_matcher_advisory import emit_advisory_candidate # noqa: E402
22
48
 
23
49
  PLUGIN_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
24
50
 
@@ -59,18 +85,65 @@ ALL_HEDGING_PATTERNS = [
59
85
  re.compile(pattern, re.IGNORECASE) for pattern in HEDGING_WORDS + HEDGING_PHRASES
60
86
  ]
61
87
 
62
- def find_hedging_words(text: str) -> list[str]:
63
- """Return all hedging words/phrases found in the text."""
64
- prose_text = strip_code_and_quotes(text)
65
- matched_terms = []
66
88
 
67
- for pattern in ALL_HEDGING_PATTERNS:
68
- all_matches = pattern.findall(prose_text)
69
- for each_match in all_matches:
89
+ def _sentence_carries_explicit_uncertainty(sentence: str) -> bool:
90
+ """Return True when the sentence labels the claim as unknown or unverified."""
91
+ for each_label_pattern in ALL_EXPLICIT_UNCERTAINTY_LABEL_PATTERNS:
92
+ if each_label_pattern.search(sentence):
93
+ return True
94
+ return False
95
+
96
+
97
+ def _hedging_terms_in_prose_slice(prose_slice: str) -> list[str]:
98
+ """Return unique hedge terms found in one prose slice (sentence or full text)."""
99
+ matched_terms: list[str] = []
100
+ for each_pattern in ALL_HEDGING_PATTERNS:
101
+ for each_match in each_pattern.findall(prose_slice):
70
102
  normalized_term = each_match.strip().lower()
71
103
  if normalized_term not in matched_terms:
72
104
  matched_terms.append(normalized_term)
105
+ return matched_terms
106
+
107
+
108
+ def find_hedging_words(text: str) -> list[str]:
109
+ """Return all hedging words/phrases in the stripped prose (any sentence)."""
110
+ prose_text = strip_code_and_quotes(text)
111
+ return _hedging_terms_in_prose_slice(prose_text)
112
+
113
+
114
+ def find_blocking_hedging_terms(text: str) -> list[str]:
115
+ """Return hedge terms that lack an explicit uncertainty label in-sentence.
116
+
117
+ ::
118
+
119
+ find_blocking_hedging_terms("Unverified: the port is probably down.")
120
+ -> []
121
+ find_blocking_hedging_terms("Unverified. The port is probably down.")
122
+ -> ["probably"]
73
123
 
124
+ Args:
125
+ text: Full assistant message, including code and quotes.
126
+
127
+ Returns:
128
+ Unique bare hedge terms that still warrant a block or advisory emit.
129
+ """
130
+ prose_text = strip_code_and_quotes(text)
131
+ if not prose_text.strip():
132
+ return []
133
+
134
+ all_sentences = SENTENCE_BOUNDARY_PATTERN.split(prose_text)
135
+ if len(all_sentences) <= 1:
136
+ all_sentences = [prose_text]
137
+
138
+ matched_terms: list[str] = []
139
+ for each_sentence in all_sentences:
140
+ if not each_sentence.strip():
141
+ continue
142
+ if _sentence_carries_explicit_uncertainty(each_sentence):
143
+ continue
144
+ for each_term in _hedging_terms_in_prose_slice(each_sentence):
145
+ if each_term not in matched_terms:
146
+ matched_terms.append(each_term)
74
147
  return matched_terms
75
148
 
76
149
 
@@ -88,12 +161,26 @@ def main() -> None:
88
161
  if not assistant_message:
89
162
  sys.exit(0)
90
163
 
91
- found_hedging_terms = find_hedging_words(assistant_message)
164
+ found_hedging_terms = find_blocking_hedging_terms(assistant_message)
92
165
 
93
166
  if not found_hedging_terms:
94
167
  sys.exit(0)
95
168
 
96
- formatted_term_list = ", ".join(f'"{term}"' for term in found_hedging_terms)
169
+ if not prose_style_enforcement_enabled_in_environment():
170
+ try:
171
+ for each_term in found_hedging_terms[:MAXIMUM_ADVISORY_EMITS_PER_CALL]:
172
+ emit_advisory_candidate(
173
+ MATCHER_ID_HEDGING_WORD,
174
+ "Stop",
175
+ f"{each_term}:{assistant_message[:ADVISORY_CONTEXT_SNIPPET_MAX_CHARS]}",
176
+ )
177
+ except (ImportError, OSError, TypeError, ValueError):
178
+ pass
179
+ sys.exit(0)
180
+
181
+ formatted_term_list = HEDGING_TERM_LIST_SEPARATOR.join(
182
+ f'"{term}"' for term in found_hedging_terms
183
+ )
97
184
 
98
185
  resolved_skill_path: str | None = None
99
186
  for each_skill_path in RESEARCH_MODE_SKILL_SEARCH_PATHS:
@@ -110,13 +197,9 @@ def main() -> None:
110
197
  )
111
198
 
112
199
  block_reason = (
113
- f"ANTI-HALLUCINATION GUARDRAIL: Your response contains hedging language: "
114
- f"{formatted_term_list}. "
115
- f"These words signal unverified claims. You MUST rewrite your response "
116
- f"{skill_reference}\n\n"
117
- f"Do NOT simply remove the hedging word and keep the unverified claim. "
118
- f"Do more research to VERIFY it with a source, or prompt the user via AskUserQuestion with some potential options + context if you are unable to find anything online.\n\n"
119
- f"You MUST re-output the complete, revised response with the corrections applied."
200
+ f"{BLOCK_REASON_PREFIX}{formatted_term_list}. "
201
+ f"Rewrite {skill_reference}\n\n"
202
+ f"{POSITIVE_CORRECTIVE_GUIDANCE}"
120
203
  )
121
204
  block_response = {
122
205
  "decision": "block",
@@ -39,6 +39,9 @@ _hooks_dir = str(Path(__file__).resolve().parent.parent)
39
39
  if _hooks_dir not in sys.path:
40
40
  sys.path.insert(0, _hooks_dir)
41
41
 
42
+ from blocking.config.prose_style_enforcement_constants import ( # noqa: E402
43
+ prose_style_enforcement_enabled_in_environment,
44
+ )
42
45
  from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
43
46
  from hooks_constants.hook_prose_detector_consistency_constants import ( # noqa: E402
44
47
  CONSTANTS_MODULE_SUFFIX,
@@ -118,6 +121,9 @@ def main() -> None:
118
121
  except json.JSONDecodeError:
119
122
  sys.exit(0)
120
123
 
124
+ if not prose_style_enforcement_enabled_in_environment():
125
+ sys.exit(0)
126
+
121
127
  tool_name = hook_input.get("tool_name", "")
122
128
  if tool_name not in (WRITE_TOOL_NAME, EDIT_TOOL_NAME):
123
129
  sys.exit(0)
@@ -18,6 +18,9 @@ _hooks_dir = str(Path(__file__).resolve().parent.parent)
18
18
  if _hooks_dir not in sys.path:
19
19
  sys.path.insert(0, _hooks_dir)
20
20
 
21
+ from blocking.config.prose_style_enforcement_constants import ( # noqa: E402
22
+ prose_style_enforcement_enabled_in_environment,
23
+ )
21
24
  from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
22
25
  from hooks_constants.messages import USER_FACING_INTENT_ENDING_NOTICE # noqa: E402
23
26
  from hooks_constants.text_stripping import strip_code_and_quotes # noqa: E402
@@ -110,6 +113,9 @@ def main() -> None:
110
113
  if hook_input.get("stop_hook_active", False):
111
114
  sys.exit(0)
112
115
 
116
+ if not prose_style_enforcement_enabled_in_environment():
117
+ sys.exit(0)
118
+
113
119
  assistant_message = hook_input.get("last_assistant_message", "")
114
120
 
115
121
  if not assistant_message: