claude-dev-env 2.8.0 → 2.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (323) hide show
  1. package/CLAUDE.md +8 -2
  2. package/_shared/pr-loop/CLAUDE.md +1 -1
  3. package/_shared/pr-loop/audit-contract.md +17 -6
  4. package/_shared/pr-loop/audit-reply-template.md +4 -4
  5. package/_shared/pr-loop/code-rules-gate.md +3 -5
  6. package/_shared/pr-loop/fix-protocol.md +2 -3
  7. package/_shared/pr-loop/gh-payloads.md +1 -1
  8. package/_shared/pr-loop/scripts/CLAUDE.md +1 -1
  9. package/_shared/pr-loop/scripts/README.md +1 -1
  10. package/_shared/pr-loop/scripts/code_rules_gate.py +2 -0
  11. package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_running.py +16 -1
  12. package/_shared/pr-loop/scripts/code_rules_gate_parts/git_blob_readers.py +11 -5
  13. package/_shared/pr-loop/scripts/preflight.py +9 -4
  14. package/_shared/pr-loop/scripts/reviews_disabled.py +50 -22
  15. package/_shared/pr-loop/scripts/tests/conftest.py +20 -0
  16. package/_shared/pr-loop/scripts/tests/test_claude_permissions_common.py +6 -6
  17. package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +50 -6
  18. package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +1 -1
  19. package/_shared/pr-loop/state-schema.md +5 -14
  20. package/agents/CLAUDE.md +2 -2
  21. package/agents/clean-coder.md +62 -562
  22. package/agents/code-quality-agent.md +10 -2
  23. package/agents/code-verifier.md +1 -1
  24. package/agents/test_agent_frontmatter.py +58 -40
  25. package/audit-rubrics/CLAUDE.md +2 -1
  26. package/audit-rubrics/audit-categories.json +704 -0
  27. package/audit-rubrics/prompts/category-i-concurrency.md +1 -1
  28. package/bin/CLAUDE.md +16 -5
  29. package/bin/ever-shipped-skills.mjs +2 -0
  30. package/bin/install-plan.mjs +402 -0
  31. package/bin/install-transaction.mjs +455 -0
  32. package/bin/install.mjs +593 -147
  33. package/bin/install.plan.test.mjs +194 -0
  34. package/bin/install.profile-root.test.mjs +154 -0
  35. package/bin/install.profiles.test.mjs +253 -0
  36. package/bin/install.settings-defaults.test.mjs +200 -0
  37. package/bin/install.transaction.test.mjs +400 -0
  38. package/bin/install.uninstall-transaction.test.mjs +418 -0
  39. package/bin/merge_managed_permissions.mjs +130 -0
  40. package/bin/resolve-install-root.mjs +181 -0
  41. package/bin/select-install-targets.mjs +401 -0
  42. package/commands/CLAUDE.md +0 -2
  43. package/docs/CODE_RULES.md +4 -2
  44. package/docs/references/CLAUDE.md +3 -2
  45. package/docs/references/advisor-tool.md +44 -6
  46. package/docs/references/prose-style-enforcement.md +25 -0
  47. package/docs/references/team-advisor-skill.md +14 -8
  48. package/hooks/blocking/CLAUDE.md +6 -6
  49. package/hooks/blocking/_path_setup.py +9 -5
  50. package/hooks/blocking/code_rules_docstrings.py +124 -30
  51. package/hooks/blocking/code_rules_enforcer.py +161 -16
  52. package/hooks/blocking/code_rules_shared.py +40 -23
  53. package/hooks/blocking/config/CLAUDE.md +3 -5
  54. package/hooks/blocking/config/prose_style_enforcement_constants.py +38 -0
  55. package/hooks/blocking/config/test_prose_style_enforcement_constants.py +45 -0
  56. package/hooks/blocking/eli11_reply_enforcer.py +70 -113
  57. package/hooks/blocking/hedging_language_blocker.py +103 -20
  58. package/hooks/blocking/hook_prose_detector_consistency.py +6 -0
  59. package/hooks/blocking/intent_only_ending_blocker.py +6 -0
  60. package/hooks/blocking/plain_language_blocker.py +139 -20
  61. package/hooks/blocking/pre_tool_use_dispatcher.py +102 -20
  62. package/hooks/blocking/state_description_blocker.py +7 -1
  63. package/hooks/blocking/tdd_enforcer.py +8 -0
  64. package/hooks/blocking/test__path_setup.py +28 -0
  65. package/hooks/blocking/test_code_rules_enforcer_agent_home_tooling.py +99 -0
  66. package/hooks/blocking/test_code_rules_enforcer_docstring_args_span_scope.py +232 -10
  67. package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +1 -1
  68. package/hooks/blocking/test_code_rules_enforcer_join_separator_magic.py +41 -0
  69. package/hooks/blocking/test_code_rules_enforcer_string_magic.py +98 -0
  70. package/hooks/blocking/test_eli11_reply_enforcer.py +98 -165
  71. package/hooks/blocking/test_hedging_language_blocker.py +120 -1
  72. package/hooks/blocking/test_hook_prose_detector_consistency.py +28 -8
  73. package/hooks/blocking/test_intent_only_ending_blocker.py +27 -2
  74. package/hooks/blocking/test_package_inventory_stale_blocker.py +11 -4
  75. package/hooks/blocking/test_plain_language_blocker.py +129 -19
  76. package/hooks/blocking/test_plain_language_blocker_allowlist.py +70 -26
  77. package/hooks/blocking/test_pre_tool_use_dispatcher.py +99 -26
  78. package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +87 -50
  79. package/hooks/blocking/test_state_description_blocker.py +45 -2
  80. package/hooks/blocking/test_stop_dispatcher.py +11 -7
  81. package/hooks/blocking/test_volatile_path_in_post_blocker.py +12 -12
  82. package/hooks/blocking/volatile_path_in_post_blocker.py +2 -2
  83. package/hooks/hooks.json +15 -0
  84. package/hooks/hooks_constants/CLAUDE.md +14 -3
  85. package/hooks/hooks_constants/ask_user_question_shape.py +281 -0
  86. package/hooks/hooks_constants/code_rules_enforcer_constants.py +2 -1
  87. package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -0
  88. package/hooks/hooks_constants/eli11_reply_enforcer_constants.py +5 -12
  89. package/hooks/hooks_constants/hedging_uncertainty_constants.py +42 -0
  90. package/hooks/hooks_constants/issue_tracker_session_starter_constants.py +23 -0
  91. package/hooks/hooks_constants/orchestrator_auto_starter_constants.py +23 -0
  92. package/hooks/hooks_constants/piped_pytest_blocker_constants.py +4 -1
  93. package/hooks/hooks_constants/plain_language_blocker_constants.py +4 -1
  94. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +6 -0
  95. package/hooks/hooks_constants/project_paths_reader.py +31 -4
  96. package/hooks/hooks_constants/prose_matcher_precision_constants.py +40 -0
  97. package/hooks/hooks_constants/pytest_invocation.py +354 -0
  98. package/hooks/hooks_constants/session_start_injector.py +163 -0
  99. package/hooks/hooks_constants/session_start_injector_constants.py +46 -0
  100. package/hooks/hooks_constants/shell_command_pipeline.py +397 -0
  101. package/hooks/hooks_constants/shell_command_segments.py +5 -0
  102. package/hooks/hooks_constants/test_ask_user_question_shape.py +167 -0
  103. package/hooks/hooks_constants/test_project_paths_reader.py +29 -0
  104. package/hooks/hooks_constants/test_prose_metrics_parity.py +8 -0
  105. package/hooks/hooks_constants/test_pytest_invocation.py +130 -0
  106. package/hooks/hooks_constants/test_session_start_injector.py +168 -0
  107. package/hooks/hooks_constants/test_shell_command_pipeline.py +135 -0
  108. package/hooks/hooks_constants/volatile_path_in_post_blocker_constants.py +1 -1
  109. package/hooks/hooks_constants/working_style_prompt_constants.py +30 -0
  110. package/hooks/observability/CLAUDE.md +2 -0
  111. package/hooks/observability/prose_matcher_advisory.py +237 -0
  112. package/hooks/observability/test_prose_matcher_advisory.py +143 -0
  113. package/hooks/session/CLAUDE.md +9 -1
  114. package/hooks/session/_path_setup.py +13 -0
  115. package/hooks/session/issue_tracker_session_starter.py +135 -0
  116. package/hooks/session/orchestrator_auto_starter.py +100 -0
  117. package/hooks/session/test__path_setup.py +28 -0
  118. package/hooks/session/test_issue_tracker_session_starter.py +104 -0
  119. package/hooks/session/test_orchestrator_auto_starter.py +99 -0
  120. package/hooks/session/test_working_style_prompt.py +47 -0
  121. package/hooks/session/untracked_repo_detector.py +1 -24
  122. package/hooks/session/working_style_prompt.py +36 -0
  123. package/hooks/validators/_path_setup.py +19 -0
  124. package/hooks/validators/run_all_validators.py +8 -13
  125. package/installable-surfaces.manifest.json +21 -0
  126. package/output-styles/CLAUDE.md +15 -0
  127. package/package.json +5 -2
  128. package/rules/CLAUDE.md +1 -0
  129. package/rules/code-standards.md +33 -7
  130. package/rules/durable-post-artifacts.md +2 -2
  131. package/rules/eli11-replies.md +7 -2
  132. package/rules/hedging-claims.md +4 -2
  133. package/rules/long-horizon-autonomy.md +3 -1
  134. package/rules/opus5-communication-contract.md +45 -0
  135. package/rules/plain-language.md +2 -2
  136. package/rules/research-mode.md +1 -1
  137. package/scripts/CLAUDE.md +13 -2
  138. package/scripts/Sync-RepoMain.ps1 +215 -0
  139. package/scripts/active_capability_references.py +218 -0
  140. package/scripts/ci/windows-installer-lifecycle.ps1 +78 -0
  141. package/scripts/claude_chain_runner.py +394 -6
  142. package/scripts/claude_chain_usage.py +1 -1
  143. package/scripts/codex_compat_materializer.py +105 -85
  144. package/scripts/dev_env_scripts_constants/CLAUDE.md +3 -1
  145. package/scripts/dev_env_scripts_constants/active_capability_constants.py +46 -0
  146. package/scripts/dev_env_scripts_constants/claude_chain_constants.py +74 -0
  147. package/scripts/dev_env_scripts_constants/grok_run_ledger_constants.py +50 -0
  148. package/scripts/dev_env_scripts_constants/grok_worker_constants.py +104 -0
  149. package/scripts/dev_env_scripts_constants/verify_installable_package_constants.py +116 -0
  150. package/scripts/grok_patch_artifacts.py +123 -0
  151. package/scripts/grok_run_ledger.py +318 -0
  152. package/scripts/profile-isolation-launchers/config/mcp-bundles.json +25 -0
  153. package/scripts/profile-isolation-launchers/config/profile-isolation-constants.mjs +60 -0
  154. package/scripts/profile-isolation-launchers/config/profiles.manifest.json +54 -0
  155. package/scripts/profile-isolation-launchers/config/shared-allowlist.json +64 -0
  156. package/scripts/profile-isolation-launchers/launcher-runtime.mjs +180 -0
  157. package/scripts/profile-isolation-launchers/lib/profile-manifest.mjs +288 -0
  158. package/scripts/profile-isolation-launchers/mcp-bundles.mjs +275 -0
  159. package/scripts/profile-isolation-launchers/profile-isolation-contract.test.mjs +221 -0
  160. package/scripts/profile-isolation-launchers/tests/launcher-runtime.test.mjs +108 -0
  161. package/scripts/profile-isolation-launchers/tests/mcp-bundles.test.mjs +147 -0
  162. package/scripts/profile-isolation-launchers/tests/shortcut-contract.test.ps1 +102 -0
  163. package/scripts/profile-isolation-launchers/tests/version-compatibility.test.mjs +210 -0
  164. package/scripts/profile-isolation-launchers/version-compatibility.mjs +299 -0
  165. package/scripts/profile-isolation-launchers/windows/shortcut-inventory.ps1 +127 -0
  166. package/scripts/profile-isolation-launchers/windows/shortcut-manifest.json +51 -0
  167. package/scripts/profile-isolation-launchers/windows/shortcut-reconcile.ps1 +77 -0
  168. package/scripts/spawn_grok_batch.py +556 -9
  169. package/scripts/test_active_capability_references.py +108 -0
  170. package/scripts/test_claude_chain_runner.py +414 -82
  171. package/scripts/test_claude_chain_usage.py +12 -12
  172. package/scripts/test_grok_patch_artifacts.py +82 -0
  173. package/scripts/test_grok_run_ledger.py +116 -0
  174. package/scripts/test_resolve_worker_spawn.py +2 -2
  175. package/scripts/test_spawn_grok_batch.py +295 -0
  176. package/scripts/test_verify_installable_package.py +208 -0
  177. package/scripts/tests/test_codex_compat_materializer.py +33 -0
  178. package/scripts/verify_installable_package.py +612 -0
  179. package/settings.json +10 -0
  180. package/skills/CLAUDE.md +6 -2
  181. package/skills/_shared/CLAUDE.md +37 -4
  182. package/skills/_shared/advisor/CLAUDE.md +9 -0
  183. package/skills/_shared/advisor/advisor-protocol.md +5 -0
  184. package/skills/_shared/advisor/scripts/README.md +9 -0
  185. package/skills/_shared/end-of-run-gotcha-recommendations.md +156 -0
  186. package/skills/_shared/pr-loop/CLAUDE.md +18 -1
  187. package/skills/_shared/pr-loop/audit-contract.md +5 -0
  188. package/skills/_shared/pr-loop/audit-reply-template.md +5 -0
  189. package/skills/_shared/pr-loop/code-rules-gate.md +5 -0
  190. package/skills/_shared/pr-loop/fix-protocol.md +5 -0
  191. package/skills/_shared/pr-loop/gh-payloads.md +5 -0
  192. package/skills/_shared/pr-loop/post-audit-thread-contract.md +5 -0
  193. package/skills/_shared/pr-loop/precatch-rubric.md +5 -0
  194. package/skills/_shared/pr-loop/scripts/CLAUDE.md +9 -1
  195. package/skills/_shared/pr-loop/scripts/RUNTIME_SCRIPTS.md +29 -0
  196. package/skills/_shared/pr-loop/scripts/audit_category_schema.py +355 -0
  197. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/CLAUDE.md +1 -0
  198. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/audit_category_schema_constants.py +32 -0
  199. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/path_resolver_constants.py +7 -19
  200. package/skills/_shared/pr-loop/scripts/test_audit_category_schema.py +94 -0
  201. package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +21 -0
  202. package/skills/_shared/pr-loop/state-schema.md +5 -0
  203. package/skills/_shared/pr-loop/worker-spawn.md +5 -0
  204. package/skills/autoconverge/reference/convergence.md +2 -1
  205. package/skills/autoconverge/reference/stop-conditions.md +5 -3
  206. package/skills/beat-sheet/SKILL.md +54 -0
  207. package/skills/beat-sheet/reference/visual-beats.md +29 -0
  208. package/skills/bugteam/CONSTRAINTS.md +4 -4
  209. package/skills/bugteam/EXAMPLES.md +1 -1
  210. package/skills/bugteam/reference/README.md +1 -1
  211. package/skills/e-code-review/SKILL.md +29 -3
  212. package/skills/e-code-review/reference/effort-evaluation.md +35 -0
  213. package/skills/e-code-review/reference/medium.md +15 -4
  214. package/skills/e-code-review/reference/runner-selection.md +40 -0
  215. package/skills/e-code-review/scripts/config/e_code_review_effort_constants/__init__.py +41 -0
  216. package/skills/e-code-review/scripts/config/e_code_review_effort_constants/effort_constants.py +40 -0
  217. package/skills/e-code-review/scripts/e_code_review_scripts_constants/__init__.py +1 -0
  218. package/skills/e-code-review/scripts/e_code_review_scripts_constants/finding_pipeline_constants.py +49 -0
  219. package/skills/e-code-review/scripts/e_code_review_scripts_constants/grok_code_review_constants.py +55 -0
  220. package/skills/e-code-review/scripts/effort_defaults_evidence.json +186 -0
  221. package/skills/e-code-review/scripts/effort_evaluation.py +362 -0
  222. package/skills/e-code-review/scripts/finding_pipeline.py +140 -0
  223. package/skills/e-code-review/scripts/fixtures/demanding.json +26 -0
  224. package/skills/e-code-review/scripts/fixtures/easy.json +14 -0
  225. package/skills/e-code-review/scripts/fixtures/medium.json +20 -0
  226. package/skills/e-code-review/scripts/grok_code_review.py +230 -0
  227. package/skills/e-code-review/scripts/test_effort_evaluation.py +180 -0
  228. package/skills/e-code-review/scripts/test_finding_pipeline.py +197 -0
  229. package/skills/e-code-review/scripts/test_grok_code_review.py +289 -0
  230. package/skills/grok-spawn/SKILL.md +5 -0
  231. package/skills/orchestrator/SKILL.md +5 -0
  232. package/skills/plan-to-pr/scripts/create_packet.py +4 -4
  233. package/skills/plan-to-pr/scripts/load_skill_constants.py +41 -0
  234. package/skills/plan-to-pr/scripts/validate_packet.py +4 -4
  235. package/skills/plan-to-pr/scripts/validate_protocol.py +4 -1
  236. package/skills/plan-to-pr/scripts/validate_run.py +4 -1
  237. package/skills/pr-converge/scripts/check_convergence.py +21 -19
  238. package/skills/pr-converge/scripts/check_convergence_availability.py +50 -7
  239. package/skills/pr-converge/scripts/conftest.py +35 -0
  240. package/skills/pr-converge/scripts/test_check_convergence_availability.py +65 -0
  241. package/skills/pr-converge/scripts/test_check_convergence_codex.py +11 -1
  242. package/skills/pr-converge/scripts/test_check_convergence_contract.py +9 -2
  243. package/skills/pr-loop-cloud-transport/SKILL.md +1 -1
  244. package/skills/rebase/SKILL.md +15 -3
  245. package/skills/reviewer-gates/SKILL.md +2 -2
  246. package/skills/show/SKILL.md +51 -0
  247. package/skills/show/references/accessibility.md +7 -0
  248. package/skills/show/references/art.md +3 -0
  249. package/skills/show/references/charts.md +3 -0
  250. package/skills/show/references/core-design.md +14 -0
  251. package/skills/show/references/erds.md +3 -0
  252. package/skills/show/references/flowcharts.md +3 -0
  253. package/skills/show/references/host-and-html.md +3 -0
  254. package/skills/show/references/illustrative-diagrams.md +10 -0
  255. package/skills/show/references/interaction.md +3 -0
  256. package/skills/show/references/mockups.md +3 -0
  257. package/skills/show/references/quality-gates.md +7 -0
  258. package/skills/show/references/structural-diagrams.md +3 -0
  259. package/skills/show/references/subject-inventory.md +21 -0
  260. package/skills/show/references/svg-contract.md +22 -0
  261. package/skills/show/routing.yaml +30 -0
  262. package/skills/show/samples/pr1262-v2.svg +222 -0
  263. package/skills/show/scripts/README.md +6 -0
  264. package/skills/show/scripts/validate-artifact.py +91 -0
  265. package/skills/show/scripts/validate-package.py +18 -0
  266. package/skills/show/templates/html-widget.html +4 -0
  267. package/skills/show/templates/svg-base.svg +19 -0
  268. package/skills/show/tests/fixtures/css-var.svg +6 -0
  269. package/skills/show/tests/fixtures/dead-ref.svg +7 -0
  270. package/skills/show/tests/fixtures/filled-glyph.svg +8 -0
  271. package/skills/show/tests/fixtures/inherited-fill.svg +18 -0
  272. package/skills/show/tests/fixtures/invalid.svg +1 -0
  273. package/skills/show/tests/fixtures/large-canvas.svg +21 -0
  274. package/skills/show/tests/fixtures/unfilled-connector.svg +15 -0
  275. package/skills/show/tests/fixtures/valid.html +1 -0
  276. package/skills/show/tests/test_validate-artifact.py +74 -0
  277. package/skills/show/tests/test_validators.py +59 -0
  278. package/skills/show/workflows/create-visual.md +13 -0
  279. package/skills/show/workflows/review-visual.md +20 -0
  280. package/skills/split-pr/SKILL.md +85 -0
  281. package/skills/split-pr/reference/path-layers.md +16 -0
  282. package/skills/split-pr/reference/proposal-format.md +15 -0
  283. package/skills/split-pr/reference/split-further-loop.md +10 -0
  284. package/skills/split-pr/reference/splitting-principles.md +26 -0
  285. package/skills/split-pr/scripts/analyze_pr.py +279 -0
  286. package/skills/split-pr/scripts/categorize_files.py +106 -0
  287. package/skills/split-pr/scripts/config/__init__.py +1 -0
  288. package/skills/split-pr/scripts/config/dependency_constants.py +14 -0
  289. package/skills/split-pr/scripts/config/git_operations_constants.py +36 -0
  290. package/skills/split-pr/scripts/config/packing_constants.py +61 -0
  291. package/skills/split-pr/scripts/config/plan_constants.py +49 -0
  292. package/skills/split-pr/scripts/config/split_pr_constants.py +110 -0
  293. package/skills/split-pr/scripts/execute_split_slices.py +82 -0
  294. package/skills/split-pr/scripts/pack_files_into_slices.py +212 -0
  295. package/skills/split-pr/scripts/split_pr_dependency_graph.py +70 -0
  296. package/skills/split-pr/scripts/split_pr_git_operations.py +184 -0
  297. package/skills/split-pr/scripts/split_pr_layer_order.py +58 -0
  298. package/skills/split-pr/scripts/split_pr_paginate.py +119 -0
  299. package/skills/split-pr/scripts/split_pr_process_runner.py +52 -0
  300. package/skills/split-pr/scripts/split_pr_script_types.py +126 -0
  301. package/skills/split-pr/scripts/split_pr_title.py +41 -0
  302. package/skills/split-pr/scripts/test_analyze_pr.py +228 -0
  303. package/skills/split-pr/scripts/test_categorize_files.py +55 -0
  304. package/skills/split-pr/scripts/test_categorize_files_packing.py +59 -0
  305. package/skills/split-pr/scripts/test_execute_split_slices.py +99 -0
  306. package/skills/split-pr/scripts/test_split_pr_dependency_graph.py +47 -0
  307. package/skills/split-pr/scripts/test_split_pr_git_operations.py +125 -0
  308. package/skills/split-pr/scripts/test_split_pr_layer_order.py +36 -0
  309. package/skills/split-pr/scripts/test_split_pr_paginate.py +65 -0
  310. package/skills/split-pr/scripts/test_split_pr_script_types.py +73 -0
  311. package/skills/split-pr/scripts/test_split_pr_title.py +28 -0
  312. package/skills/split-pr/scripts/test_verify_dependency_graph.py +46 -0
  313. package/skills/split-pr/scripts/test_verify_plan.py +56 -0
  314. package/skills/split-pr/scripts/test_verify_plan_contract.py +50 -0
  315. package/skills/split-pr/scripts/test_verify_plan_path_normalization.py +45 -0
  316. package/skills/split-pr/scripts/verify_dependency_graph.py +111 -0
  317. package/skills/split-pr/scripts/verify_plan.py +139 -0
  318. package/skills/task-build/reference/tool-routing.md +3 -0
  319. package/skills/team-advisor/SKILL.md +23 -44
  320. package/system-prompts/software-engineer.xml +17 -5
  321. package/commands/initialize.md +0 -90
  322. package/commands/stubcheck.md +0 -88
  323. package/skills/test_markdown_link_integrity.py +0 -107
@@ -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:
@@ -31,6 +31,9 @@ if _hooks_dir not in sys.path:
31
31
  sys.path.insert(0, _hooks_dir)
32
32
 
33
33
  from blocking.code_rules_shared import is_ephemeral_path # noqa: E402
34
+ from blocking.config.prose_style_enforcement_constants import ( # noqa: E402
35
+ prose_style_enforcement_enabled_in_environment,
36
+ )
34
37
  from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
35
38
  from hooks_constants.plain_language_blocker_constants import ( # noqa: E402
36
39
  ALL_CHAT_DETAIL_MARKERS,
@@ -44,6 +47,7 @@ from hooks_constants.plain_language_blocker_constants import ( # noqa: E402
44
47
  DOT_CLAUDE_DIRECTORY_NAME,
45
48
  FENCED_CODE_BLOCK_PATTERN,
46
49
  FILE_PATH_PATTERN,
50
+ HEAVY_WORD_SWAP_LIST_SEPARATOR,
47
51
  INLINE_CODE_PATTERN,
48
52
  INLINE_CODE_PLACEHOLDER,
49
53
  INLINE_CODE_SPAN_PATTERN,
@@ -65,6 +69,12 @@ from hooks_constants.plain_language_blocker_constants import ( # noqa: E402
65
69
  USER_FACING_LEAN_QUESTION_NOTICE,
66
70
  USER_FACING_PLAIN_LANGUAGE_NOTICE,
67
71
  )
72
+ from hooks_constants.prose_matcher_precision_constants import ( # noqa: E402
73
+ ADVISORY_CONTEXT_SNIPPET_MAX_CHARS,
74
+ MATCHER_ID_PLAIN_LANGUAGE_HEAVY_WORD,
75
+ MAXIMUM_ADVISORY_EMITS_PER_CALL,
76
+ )
77
+ from observability.prose_matcher_advisory import emit_advisory_candidate # noqa: E402
68
78
 
69
79
 
70
80
  def strip_non_prose_regions(text: str) -> str:
@@ -234,7 +244,12 @@ def _load_project_allowlist(
234
244
 
235
245
 
236
246
  def build_block_reason(all_matches: list[tuple[str, str]]) -> str:
237
- """Return a deny reason naming each flagged term and its plain replacement."""
247
+ """Return a legacy block-reason string naming each term and plain swap.
248
+
249
+ Heavy-word enforcement no longer hard-denies (OP-07D). Callers that still
250
+ need the named swap list for notices or tests use this helper. Lean-block
251
+ denials use ``build_lean_block_reason`` instead.
252
+ """
238
253
  swap_phrases = ", ".join(
239
254
  f'use "{each_replacement}" instead of "{each_term}"'
240
255
  for each_term, each_replacement in all_matches
@@ -246,6 +261,31 @@ def build_block_reason(all_matches: list[tuple[str, str]]) -> str:
246
261
  )
247
262
 
248
263
 
264
+ def build_advisory_system_message(all_matches: list[tuple[str, str]]) -> str:
265
+ """Return allow-path advisory text naming everyday swaps for heavy words.
266
+
267
+ ::
268
+
269
+ build_advisory_system_message([("initiate", "start")])
270
+ -> names use "start" instead of "initiate" (no permission deny)
271
+
272
+ Args:
273
+ all_matches: ``(heavy_term, everyday_swap)`` pairs from the word scan.
274
+
275
+ Returns:
276
+ A systemMessage string for PreToolUse allow with advisory guidance.
277
+ """
278
+ swap_phrases = HEAVY_WORD_SWAP_LIST_SEPARATOR.join(
279
+ f'use "{each_replacement}" instead of "{each_term}"'
280
+ for each_term, each_replacement in all_matches
281
+ )
282
+ return (
283
+ f"[PLAIN_LANGUAGE advisory] {swap_phrases}. "
284
+ "Reach for the everyday word the reader understands on the first pass. "
285
+ "This notice does not deny the write; lean-block structure denials still apply."
286
+ )
287
+
288
+
249
289
  def _normalize_line_endings(prose_text: str) -> str:
250
290
  """Return the prose with every line ending spelled as a bare line feed.
251
291
 
@@ -572,50 +612,125 @@ def _emit_deny(deny_reason: str, output_stream: TextIO) -> None:
572
612
  output_stream.flush()
573
613
 
574
614
 
615
+ def build_allow_advisory_payload(system_message: str) -> dict[str, object]:
616
+ """Build an allow payload that still surfaces everyday-word guidance.
617
+
618
+ Args:
619
+ system_message: Advisory text naming heavy terms and plain swaps.
620
+
621
+ Returns:
622
+ A PreToolUse payload without permissionDecision deny.
623
+ """
624
+ return {
625
+ "hookSpecificOutput": {
626
+ "hookEventName": "PreToolUse",
627
+ "permissionDecision": "allow",
628
+ },
629
+ "systemMessage": system_message,
630
+ "suppressOutput": False,
631
+ }
632
+
633
+
634
+ def _emit_allow_advisory(system_message: str, output_stream: TextIO) -> None:
635
+ output_stream.write(json.dumps(build_allow_advisory_payload(system_message)))
636
+ output_stream.flush()
637
+
638
+
639
+ def _emit_plain_language_advisory_candidates(
640
+ surface: str,
641
+ all_matches: list[tuple[str, str]],
642
+ prose_text: str,
643
+ ) -> None:
644
+ """Record privacy-safe advisory hits when enforcement is off (fail open)."""
645
+ try:
646
+ for each_match in all_matches[:MAXIMUM_ADVISORY_EMITS_PER_CALL]:
647
+ each_term = each_match[0]
648
+ emit_advisory_candidate(
649
+ MATCHER_ID_PLAIN_LANGUAGE_HEAVY_WORD,
650
+ surface,
651
+ f"{each_term}:{prose_text[:ADVISORY_CONTEXT_SNIPPET_MAX_CHARS]}",
652
+ )
653
+ except (ImportError, OSError, TypeError, ValueError):
654
+ return
655
+
656
+
575
657
  def evaluate(payload_by_key: dict[str, object]) -> str | None:
576
- """Decide whether a payload carries a question block or heavy words to block.
658
+ """Return a lean-block deny reason, or None when the payload is allowed.
577
659
 
578
- An AskUserQuestion payload meets the lean-block check first, so a question
579
- text or an option description carrying chat detail returns a LEAN_QUESTION
580
- deny reason. Every guarded payload then meets the word scan, which returns a
581
- PLAIN_LANGUAGE deny reason for the first heavy word it finds.
660
+ Heavy-word matches never produce a deny reason (OP-07D). When prose-style
661
+ enforcement is on, main still emits an allow advisory naming everyday swaps
662
+ for AskUserQuestion and ``.md`` writes. When enforcement is off, matches
663
+ only record privacy-safe advisory candidates (OP-07B).
582
664
 
583
665
  Args:
584
666
  payload_by_key: The PreToolUse payload with tool_name and tool_input.
585
667
 
586
668
  Returns:
587
- The permissionDecisionReason text when the payload is denied, or None
588
- when it is allowed.
669
+ The permissionDecisionReason text for a lean-block denial, or None when
670
+ the payload is allowed (with or without a heavy-word advisory).
671
+ """
672
+ deny_reason, _advisory_system_message = evaluate_with_advisory(payload_by_key)
673
+ return deny_reason
674
+
675
+
676
+ def evaluate_with_advisory(
677
+ payload_by_key: dict[str, object],
678
+ all_allowlisted_terms: frozenset[str] | None = None,
679
+ ) -> tuple[str | None, str | None]:
680
+ """Return ``(deny_reason, advisory_system_message)`` for one PreToolUse payload.
681
+
682
+ ::
683
+
684
+ lean chat detail in AskUserQuestion -> (lean deny reason, None)
685
+ heavy word, enforcement ON -> (None, advisory systemMessage)
686
+ heavy word, enforcement OFF -> (None, None) + privacy JSONL
687
+ clean payload -> (None, None)
688
+
689
+ Args:
690
+ payload_by_key: The PreToolUse payload with tool_name and tool_input.
691
+ all_allowlisted_terms: Optional project-vocabulary exemptions. When
692
+ omitted, the project allowlist file is loaded for the payload path.
693
+
694
+ Returns:
695
+ A pair of optional deny reason and optional allow-path advisory message.
589
696
  """
590
697
  raw_tool_name = payload_by_key.get("tool_name", "")
591
698
  raw_tool_input = payload_by_key.get("tool_input", {})
592
699
  if not isinstance(raw_tool_name, str) or not isinstance(raw_tool_input, dict):
593
- return None
700
+ return None, None
594
701
 
595
702
  if raw_tool_name in ALL_WRITE_EDIT_TOOL_NAMES:
596
703
  target_file_path = raw_tool_input.get("file_path", "")
597
704
  if isinstance(target_file_path, str) and is_ephemeral_path(
598
705
  target_file_path, payload_by_key
599
706
  ):
600
- return None
707
+ return None, None
601
708
 
602
709
  if raw_tool_name == ASK_USER_QUESTION_TOOL_NAME:
603
710
  all_block_violations = find_question_block_violations(raw_tool_input)
604
711
  if all_block_violations:
605
- return build_lean_block_reason(all_block_violations)
712
+ return build_lean_block_reason(all_block_violations), None
606
713
 
607
714
  prose_text = _collect_prose_for_tool(raw_tool_name, raw_tool_input)
608
715
  if not prose_text:
609
- return None
716
+ return None, None
610
717
 
611
- all_allowlisted_terms = _load_project_allowlist(
612
- raw_tool_name, raw_tool_input, payload_by_key
718
+ resolved_allowlisted_terms = (
719
+ all_allowlisted_terms
720
+ if all_allowlisted_terms is not None
721
+ else _load_project_allowlist(raw_tool_name, raw_tool_input, payload_by_key)
722
+ )
723
+ all_matches = find_banned_terms(
724
+ prose_text, all_allowlisted_terms=resolved_allowlisted_terms
613
725
  )
614
- all_matches = find_banned_terms(prose_text, all_allowlisted_terms)
615
726
  if not all_matches:
616
- return None
727
+ return None, None
617
728
 
618
- return build_block_reason(all_matches)
729
+ if not prose_style_enforcement_enabled_in_environment():
730
+ _emit_plain_language_advisory_candidates(raw_tool_name, all_matches, prose_text)
731
+ return None, None
732
+
733
+ return None, build_advisory_system_message(all_matches)
619
734
 
620
735
 
621
736
  def main() -> None:
@@ -627,11 +742,15 @@ def main() -> None:
627
742
  if not isinstance(input_data, dict):
628
743
  sys.exit(0)
629
744
 
630
- deny_reason = evaluate(input_data)
631
- if deny_reason is None:
745
+ deny_reason, advisory_system_message = evaluate_with_advisory(input_data)
746
+ if deny_reason is not None:
747
+ _emit_deny(deny_reason, sys.stdout)
748
+ sys.exit(0)
749
+
750
+ if advisory_system_message is not None:
751
+ _emit_allow_advisory(advisory_system_message, sys.stdout)
632
752
  sys.exit(0)
633
753
 
634
- _emit_deny(deny_reason, sys.stdout)
635
754
  sys.exit(0)
636
755
 
637
756
 
@@ -26,10 +26,16 @@ from pathlib import Path
26
26
 
27
27
  import _path_setup # noqa: F401
28
28
 
29
+ from plain_language_blocker import (
30
+ build_allow_advisory_payload as build_plain_language_allow_advisory_payload,
31
+ )
29
32
  from plain_language_blocker import (
30
33
  build_deny_payload as build_plain_language_deny_payload,
31
34
  )
32
35
  from plain_language_blocker import evaluate as evaluate_plain_language
36
+ from plain_language_blocker import (
37
+ evaluate_with_advisory as evaluate_plain_language_with_advisory,
38
+ )
33
39
  from state_description_blocker import (
34
40
  build_deny_payload as build_state_description_deny_payload,
35
41
  )
@@ -40,32 +46,46 @@ from hooks_constants.pre_tool_use_dispatcher_constants import (
40
46
  ALLOW_DECISION,
41
47
  BLOCKING_CRASH_DENY_REASON,
42
48
  BLOCKING_CRASH_EXIT_CODE,
49
+ CONTEXT_JOIN_SEPARATOR,
43
50
  DENY_DECISION,
44
51
  EXIT_CODE_TWO_DENY_REASON,
45
52
  HOOK_EVENT_NAME,
46
53
  PLAIN_LANGUAGE_BLOCKER_MODULE_NAME,
54
+ REASON_JOIN_SEPARATOR,
47
55
  STATE_DESCRIPTION_BLOCKER_MODULE_NAME,
56
+ SYSTEM_MESSAGE_JOIN_SEPARATOR,
48
57
  HostedHookEntry,
49
58
  )
50
59
  from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
51
60
 
52
61
  NativeEvaluator = Callable[[dict[str, object]], str | None]
53
62
  DenyPayloadBuilder = Callable[[str], dict[str, object]]
63
+ AdvisoryEvaluator = Callable[
64
+ [dict[str, object]], tuple[str | None, str | None]
65
+ ]
66
+ AllowAdvisoryPayloadBuilder = Callable[[str], dict[str, object]]
54
67
 
55
68
 
56
69
  @dataclass(frozen=True)
57
70
  class NativeHook:
58
- """A nativized hook's evaluator paired with its full deny-payload builder.
71
+ """A nativized hook's evaluator paired with its full payload builders.
59
72
 
60
73
  Attributes:
61
74
  evaluate: The hook's evaluate function returning a deny-reason or None.
62
75
  build_deny_payload: The hook's builder that turns a deny-reason into the
63
76
  full deny payload the standalone hook writes (carrying systemMessage,
64
77
  additionalContext, and suppressOutput).
78
+ evaluate_with_advisory: Optional evaluator that returns
79
+ ``(deny_reason, advisory_system_message)`` so allow-path notices
80
+ match the standalone hook (plain_language OP-07D).
81
+ build_allow_advisory_payload: Optional builder for an allow payload that
82
+ carries the advisory systemMessage without denying.
65
83
  """
66
84
 
67
85
  evaluate: NativeEvaluator
68
86
  build_deny_payload: DenyPayloadBuilder
87
+ evaluate_with_advisory: AdvisoryEvaluator | None = None
88
+ build_allow_advisory_payload: AllowAdvisoryPayloadBuilder | None = None
69
89
 
70
90
 
71
91
  _native_hook_by_module_name: dict[str, NativeHook] = {
@@ -76,6 +96,8 @@ _native_hook_by_module_name: dict[str, NativeHook] = {
76
96
  PLAIN_LANGUAGE_BLOCKER_MODULE_NAME: NativeHook(
77
97
  evaluate=evaluate_plain_language,
78
98
  build_deny_payload=build_plain_language_deny_payload,
99
+ evaluate_with_advisory=evaluate_plain_language_with_advisory,
100
+ build_allow_advisory_payload=build_plain_language_allow_advisory_payload,
79
101
  ),
80
102
  }
81
103
 
@@ -180,19 +202,28 @@ def run_native_hook(
180
202
  Calls the evaluator directly with the payload dict, builds the full deny JSON
181
203
  via the hook's own deny-payload builder so the captured stdout matches the
182
204
  standalone hook's deny shape (carrying systemMessage, additionalContext, and
183
- suppressOutput), and catches a non-SystemExit crash to log and classify it.
205
+ suppressOutput), emits an allow-with-systemMessage payload when the hook
206
+ returns an advisory without a deny, and catches a non-SystemExit crash to
207
+ log and classify it.
184
208
 
185
209
  Args:
186
- native_hook: The hook's evaluator paired with its deny-payload builder.
210
+ native_hook: The hook's evaluator paired with its payload builders.
187
211
  payload_by_key: The parsed payload dict to pass to the evaluator.
188
212
  is_blocking: Whether a crash from this hook surfaces a blocking signal.
189
213
 
190
214
  Returns:
191
- A HostedHookResult carrying the captured deny JSON (empty when allowed),
192
- the crash flag, and the blocking classification.
215
+ A HostedHookResult carrying the captured deny or allow-advisory JSON
216
+ (empty when the payload is clean), the crash flag, and the blocking
217
+ classification.
193
218
  """
194
219
  try:
195
- deny_reason = native_hook.evaluate(payload_by_key)
220
+ if native_hook.evaluate_with_advisory is not None:
221
+ deny_reason, advisory_system_message = native_hook.evaluate_with_advisory(
222
+ payload_by_key
223
+ )
224
+ else:
225
+ deny_reason = native_hook.evaluate(payload_by_key)
226
+ advisory_system_message = None
196
227
  except Exception as error:
197
228
  _log_hook_crash(native_hook.evaluate.__module__, error)
198
229
  return HostedHookResult(
@@ -202,9 +233,17 @@ def run_native_hook(
202
233
  is_blocking=is_blocking,
203
234
  )
204
235
 
205
- captured_stdout = (
206
- json.dumps(native_hook.build_deny_payload(deny_reason)) if deny_reason is not None else ""
207
- )
236
+ if deny_reason is not None:
237
+ captured_stdout = json.dumps(native_hook.build_deny_payload(deny_reason))
238
+ elif (
239
+ advisory_system_message is not None
240
+ and native_hook.build_allow_advisory_payload is not None
241
+ ):
242
+ captured_stdout = json.dumps(
243
+ native_hook.build_allow_advisory_payload(advisory_system_message)
244
+ )
245
+ else:
246
+ captured_stdout = ""
208
247
  return HostedHookResult(
209
248
  exit_code=0,
210
249
  captured_stdout=captured_stdout,
@@ -394,40 +433,79 @@ def aggregate_hosted_hook_results(
394
433
  )
395
434
 
396
435
 
436
+ def unique_first_seen_strings(all_values: list[str]) -> list[str]:
437
+ """Return values in first-seen order with exact-string duplicates dropped.
438
+
439
+ ::
440
+
441
+ unique_first_seen_strings(["a", "b", "a"]) -> ["a", "b"]
442
+
443
+ Comparison is exact full-string equality only (no case fold, no trim).
444
+ Each field of the deny payload (reasons, systemMessage, additionalContext)
445
+ is collapsed separately so a deny reason identical to a context string never
446
+ cross-collapses.
447
+
448
+ Args:
449
+ all_values: Strings collected from hosted hooks in run order.
450
+
451
+ Returns:
452
+ A new list with the first occurrence of each exact string kept.
453
+ """
454
+ seen_values: set[str] = set()
455
+ unique_values: list[str] = []
456
+ for each_value in all_values:
457
+ if each_value in seen_values:
458
+ continue
459
+ seen_values.add(each_value)
460
+ unique_values.append(each_value)
461
+ return unique_values
462
+
463
+
397
464
  def _emit_deny_decision(decision: DispatcherDecision) -> None:
398
465
  """Write one deny JSON object to stdout carrying all deny reasons and context.
399
466
 
400
- Carries every hook's systemMessage and additionalContext and the
401
- suppressOutput flag so the dispatched deny matches the standalone hooks'
402
- full deny shape.
467
+ Collapses exact-duplicate reasons, systemMessage lines, and additionalContext
468
+ lines at emit time (first-seen order) so the final payload is compact while
469
+ aggregate_hosted_hook_results still records every firing for logging.
403
470
 
404
471
  Args:
405
472
  decision: The aggregated dispatcher decision with deny reasons, context,
406
473
  and the suppressOutput flag.
407
474
  """
408
- combined_reason = " | ".join(decision.all_deny_reasons)
475
+ all_unique_deny_reasons = unique_first_seen_strings(decision.all_deny_reasons)
476
+ all_unique_system_messages = unique_first_seen_strings(decision.all_system_messages)
477
+ all_unique_additional_context = unique_first_seen_strings(
478
+ decision.all_additional_context
479
+ )
480
+ combined_reason = REASON_JOIN_SEPARATOR.join(all_unique_deny_reasons)
409
481
  hook_specific: dict[str, object] = {
410
482
  "hookEventName": HOOK_EVENT_NAME,
411
483
  "permissionDecision": DENY_DECISION,
412
484
  "permissionDecisionReason": combined_reason,
413
485
  }
414
- if decision.all_additional_context:
415
- hook_specific["additionalContext"] = "\n".join(decision.all_additional_context)
486
+ if all_unique_additional_context:
487
+ hook_specific["additionalContext"] = CONTEXT_JOIN_SEPARATOR.join(
488
+ all_unique_additional_context
489
+ )
416
490
  deny_payload: dict[str, object] = {"hookSpecificOutput": hook_specific}
417
- if decision.all_system_messages:
418
- deny_payload["systemMessage"] = "\n".join(decision.all_system_messages)
491
+ if all_unique_system_messages:
492
+ deny_payload["systemMessage"] = CONTEXT_JOIN_SEPARATOR.join(
493
+ all_unique_system_messages
494
+ )
419
495
  if decision.should_suppress_output:
420
496
  deny_payload["suppressOutput"] = True
421
497
  sys.stdout.write(json.dumps(deny_payload) + "\n")
422
498
  sys.stdout.flush()
423
499
 
424
500
 
425
- def _emit_allow_decision() -> None:
501
+ def _emit_allow_decision(decision: DispatcherDecision) -> None:
426
502
  """Write one explicit allow JSON object to stdout.
427
503
 
428
504
  Matches the shape a standalone hosted hook emits when it auto-approves the
429
505
  write, so a write a hosted hook allows explicitly is auto-approved under the
430
- dispatcher rather than falling back to the default permission flow.
506
+ dispatcher rather than falling back to the default permission flow. When a
507
+ hook also carried an advisory systemMessage (plain-language heavy words),
508
+ that notice rides on the allow payload without denying.
431
509
  """
432
510
  allow_payload: dict[str, object] = {
433
511
  "hookSpecificOutput": {
@@ -435,6 +513,10 @@ def _emit_allow_decision() -> None:
435
513
  "permissionDecision": ALLOW_DECISION,
436
514
  }
437
515
  }
516
+ if decision.all_system_messages:
517
+ allow_payload["systemMessage"] = SYSTEM_MESSAGE_JOIN_SEPARATOR.join(
518
+ decision.all_system_messages
519
+ )
438
520
  sys.stdout.write(json.dumps(allow_payload) + "\n")
439
521
  sys.stdout.flush()
440
522
 
@@ -522,7 +604,7 @@ def dispatch(
522
604
  _emit_deny_decision(aggregated_decision)
523
605
  return
524
606
  if aggregated_decision.should_allow:
525
- _emit_allow_decision()
607
+ _emit_allow_decision(aggregated_decision)
526
608
 
527
609
 
528
610
  def main() -> None: