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
@@ -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:
@@ -20,6 +20,9 @@ if _hooks_dir not in sys.path:
20
20
  sys.path.insert(0, _hooks_dir)
21
21
 
22
22
  from blocking.code_rules_shared import is_ephemeral_path # noqa: E402
23
+ from blocking.config.prose_style_enforcement_constants import ( # noqa: E402
24
+ prose_style_enforcement_enabled_in_environment,
25
+ )
23
26
  from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
24
27
  from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin # noqa: E402
25
28
  from hooks_constants.state_description_blocker_constants import ( # noqa: E402
@@ -278,7 +281,8 @@ def evaluate(payload_by_key: dict[str, object]) -> str | None:
278
281
 
279
282
  Applies the same tool-name gate, file-extension gate, content selection, and
280
283
  pattern scan the standalone hook applies. Returns the deny-reason text when a
281
- historical phrase is found, or None to allow.
284
+ historical phrase is found, or None to allow. Opinionated prose enforcement
285
+ is default-off via ``prose_style_enforcement_enabled_in_environment()``.
282
286
 
283
287
  Args:
284
288
  payload_by_key: The PreToolUse payload with tool_name and tool_input.
@@ -287,6 +291,8 @@ def evaluate(payload_by_key: dict[str, object]) -> str | None:
287
291
  The permissionDecisionReason text when the write is denied, or None when
288
292
  the write is allowed.
289
293
  """
294
+ if not prose_style_enforcement_enabled_in_environment():
295
+ return None
290
296
  raw_tool_name = payload_by_key.get("tool_name", "")
291
297
  tool_name = raw_tool_name if isinstance(raw_tool_name, str) else ""
292
298
  if tool_name not in ("Write", "Edit"):
@@ -17,6 +17,7 @@ try:
17
17
  if each_bootstrap_directory not in sys.path:
18
18
  sys.path.insert(0, each_bootstrap_directory)
19
19
  from code_rules_shared import (
20
+ is_agent_home_tooling,
20
21
  is_ephemeral_script_path,
21
22
  is_under_session_scratchpad,
22
23
  )
@@ -40,6 +41,11 @@ def _is_session_scratchpad_write(file_path: str, input_data: dict) -> bool:
40
41
  return is_under_session_scratchpad(file_path, input_data)
41
42
 
42
43
 
44
+ def _is_agent_home_tooling_write(file_path: str) -> bool:
45
+ """A coding agent's own home-directory tooling is outside the TDD gate."""
46
+ return is_agent_home_tooling(file_path)
47
+
48
+
43
49
  production_extensions = path_classification.production_extensions
44
50
  skip_extensions = path_classification.skip_extensions
45
51
  _is_inside_dotclaude_segment = path_classification._is_inside_dotclaude_segment
@@ -69,6 +75,8 @@ def _resolve_payload(input_data: dict) -> tuple[str, dict, str]:
69
75
  def _is_silently_skipped(file_path: str, extension: str, path: Path) -> bool:
70
76
  if _is_inside_dotclaude_segment(file_path) or is_ephemeral_script_path(file_path):
71
77
  return True
78
+ if _is_agent_home_tooling_write(file_path):
79
+ return True
72
80
  if extension in skip_extensions():
73
81
  return True
74
82
  if extension not in production_extensions():
@@ -37,3 +37,31 @@ def test_importing_path_setup_puts_the_hooks_directory_on_sys_path() -> None:
37
37
  )
38
38
  assert completed.returncode == 0, completed.stderr
39
39
  assert completed.stdout.strip() == "ok"
40
+
41
+
42
+ def test_importing_path_setup_puts_the_blocking_directory_on_sys_path() -> None:
43
+ """A module reached through the package finds its own directory on sys.path.
44
+
45
+ ``code_rules_shared`` imports both ``hooks_constants`` (a hooks-directory
46
+ package) and its blocking-directory siblings, so the bootstrap must place
47
+ both roots. Only the hooks directory is on PYTHONPATH here, so the blocking
48
+ entry can only come from the bootstrap itself.
49
+ """
50
+ driver = (
51
+ "import sys\n"
52
+ "from blocking import _path_setup\n"
53
+ "from pathlib import Path\n"
54
+ "blocking_dir = str(Path(_path_setup.__file__).resolve().parent)\n"
55
+ "assert blocking_dir in sys.path, blocking_dir\n"
56
+ "print('ok')\n"
57
+ )
58
+ subprocess_environment = {**os.environ, "PYTHONPATH": str(_HOOKS_DIR)}
59
+ completed = subprocess.run(
60
+ [sys.executable, "-c", driver],
61
+ check=False,
62
+ capture_output=True,
63
+ text=True,
64
+ env=subprocess_environment,
65
+ )
66
+ assert completed.returncode == 0, completed.stderr
67
+ assert completed.stdout.strip() == "ok"
@@ -0,0 +1,99 @@
1
+ """Agent-home tooling exemption tests for the classifier and the enforcer.
2
+
3
+ A coding agent installs its own helper scripts under a dot-directory in the
4
+ user's home — ``~/.grok/runs/`` sits beside the already-exempt
5
+ ``~/.claude/hooks/``. That tree is vendored tooling rather than project code,
6
+ so the repository code rules do not govern it::
7
+
8
+ /home/example/.grok/runs/worktree-health/health.py -> exempt
9
+ C:/dev/my-project/src/grokking.py -> governed
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import subprocess
16
+ import sys
17
+ import tempfile
18
+ from pathlib import Path
19
+
20
+ from blocking import _path_setup # noqa: F401 (pins this checkout ahead of sibling worktrees)
21
+ from blocking.code_rules_shared import is_agent_home_tooling, is_ephemeral_path
22
+
23
+ _ENFORCER_SCRIPT = Path(__file__).resolve().parent / "code_rules_enforcer.py"
24
+
25
+ _VIOLATING_PRODUCTION_SOURCE = "def process_data(payload: str) -> None:\n print(payload)\n"
26
+
27
+
28
+ def _check_as(candidate_source: str, target_path: str) -> subprocess.CompletedProcess[str]:
29
+ """Run the enforcer's pre-check on a candidate judged as another path.
30
+
31
+ Args:
32
+ candidate_source: Python source written to the candidate file.
33
+ target_path: Destination path the candidate is judged as.
34
+
35
+ Returns:
36
+ The completed process carrying stdout, stderr, and the exit code.
37
+ """
38
+ with tempfile.TemporaryDirectory() as scratch:
39
+ candidate = Path(scratch) / "candidate.py"
40
+ candidate.write_text(candidate_source, encoding="utf-8")
41
+ return subprocess.run(
42
+ [sys.executable, str(_ENFORCER_SCRIPT), "--check", str(candidate), "--as", target_path],
43
+ input="",
44
+ capture_output=True,
45
+ text=True,
46
+ check=False,
47
+ )
48
+
49
+
50
+ def test_posix_agent_home_path_is_agent_tooling() -> None:
51
+ assert is_agent_home_tooling("/home/example/.grok/runs/worktree-health/health.py")
52
+
53
+
54
+ def test_windows_agent_home_path_is_agent_tooling() -> None:
55
+ assert is_agent_home_tooling(r"C:\Users\example\.grok\runs\worktree-health\health.py")
56
+
57
+
58
+ def test_any_depth_under_the_agent_home_is_agent_tooling() -> None:
59
+ assert is_agent_home_tooling("/home/example/.grok/hooks/force_worktree.py")
60
+
61
+
62
+ def test_ordinary_project_code_is_not_agent_tooling() -> None:
63
+ assert not is_agent_home_tooling("/dev/my-project/src/service.py")
64
+
65
+
66
+ def test_a_filename_spelled_like_the_agent_is_not_agent_tooling() -> None:
67
+ assert not is_agent_home_tooling("/dev/my-project/src/grokking.py")
68
+
69
+
70
+ def test_a_directory_without_the_leading_dot_is_not_agent_tooling() -> None:
71
+ assert not is_agent_home_tooling("/dev/my-project/grok/runner.py")
72
+
73
+
74
+ def test_an_empty_path_is_not_agent_tooling() -> None:
75
+ assert not is_agent_home_tooling("")
76
+
77
+
78
+ def test_agent_home_path_is_exempt_from_the_repository_gates() -> None:
79
+ assert is_ephemeral_path("/home/example/.grok/runs/worktree-health/health.py")
80
+
81
+
82
+ def test_project_code_is_not_exempt_from_the_repository_gates() -> None:
83
+ assert not is_ephemeral_path("/dev/my-project/src/service.py")
84
+
85
+
86
+ def test_enforcer_allows_violating_source_under_the_agent_home() -> None:
87
+ target = os.path.join("/home/example", ".grok", "runs", "worktree-health", "health.py")
88
+
89
+ completed = _check_as(_VIOLATING_PRODUCTION_SOURCE, target)
90
+
91
+ assert completed.returncode == 0, completed.stdout + completed.stderr
92
+
93
+
94
+ def test_enforcer_still_flags_the_same_source_in_a_project_path() -> None:
95
+ target = os.path.join("/dev/my-project", "src", "service.py")
96
+
97
+ completed = _check_as(_VIOLATING_PRODUCTION_SOURCE, target)
98
+
99
+ assert completed.returncode != 0