claude-dev-env 2.4.0 → 2.7.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 (309) hide show
  1. package/CLAUDE.md +26 -59
  2. package/_shared/pr-loop/scripts/_claude_permissions_common.py +84 -0
  3. package/_shared/pr-loop/scripts/code_rules_gate.py +6 -3
  4. package/_shared/pr-loop/scripts/code_rules_gate_parts/CLAUDE.md +12 -2
  5. package/_shared/pr-loop/scripts/code_rules_gate_parts/baseline_import_isolation.py +309 -0
  6. package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_regression.py +540 -0
  7. package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_running.py +206 -70
  8. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/__init__.py +1 -0
  9. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/_repo_test_helpers.py +76 -0
  10. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_baseline_import_isolation.py +248 -0
  11. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_staged_test_regression.py +309 -0
  12. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_staged_test_running.py +91 -58
  13. package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +306 -306
  14. package/_shared/pr-loop/scripts/pr_loop_shared_constants/claude_permissions_constants.py +44 -0
  15. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +202 -0
  16. package/_shared/pr-loop/scripts/pr_loop_shared_constants/copilot_quota_constants.py +24 -24
  17. package/_shared/pr-loop/scripts/pr_loop_shared_constants/stale_worktree_rule_sweep_constants.py +107 -107
  18. package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +290 -48
  19. package/_shared/pr-loop/scripts/tests/test_claude_permissions_common.py +42 -2
  20. package/_shared/pr-loop/scripts/tests/test_claude_permissions_constants.py +36 -0
  21. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +100 -1
  22. package/_shared/pr-loop/scripts/tests/test_fix_hookspath.py +497 -497
  23. package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +311 -2
  24. package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep.py +301 -301
  25. package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep_constants.py +85 -85
  26. package/_shared/pr-loop/worker-spawn.md +1 -1
  27. package/agents/CLAUDE.md +3 -2
  28. package/agents/caveman.md +0 -1
  29. package/agents/clasp-deployment-orchestrator.md +0 -1
  30. package/agents/clean-coder.md +0 -1
  31. package/agents/code-advisor.md +0 -1
  32. package/agents/code-quality-agent.md +1 -2
  33. package/agents/code-verifier.md +36 -8
  34. package/agents/deep-research.md +0 -1
  35. package/agents/docs-agent.md +0 -1
  36. package/agents/git-commit-crafter.md +0 -1
  37. package/agents/issue-tracker.md +42 -0
  38. package/agents/plan-packet-validator.md +0 -1
  39. package/agents/pr-description-writer.md +0 -1
  40. package/agents/test_agent_frontmatter.py +67 -18
  41. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +143 -141
  42. package/bin/CLAUDE.md +68 -5
  43. package/bin/codex-compat.mjs +104 -0
  44. package/bin/codex-compat.test.mjs +51 -0
  45. package/bin/ever-shipped-skills.mjs +1 -0
  46. package/bin/install-constants.mjs +88 -0
  47. package/bin/install.mjs +1138 -114
  48. package/bin/install.prune.test.mjs +869 -19
  49. package/bin/install.test.mjs +906 -2
  50. package/codex-capability-map.json +13 -0
  51. package/commands/implement.md +1 -1
  52. package/commands/right-size.md +1 -1
  53. package/docs/CLAUDE.md +1 -0
  54. package/docs/CODE_RULES.md +2 -0
  55. package/docs/codex-compatibility.md +25 -0
  56. package/docs/host-pool-health-monitor.md +102 -0
  57. package/docs/nas-ssh-invocation.md +96 -12
  58. package/docs/references/CLAUDE.md +4 -2
  59. package/docs/references/advisor-tool.md +13 -0
  60. package/docs/references/code-review-enforcement.md +35 -0
  61. package/docs/references/team-advisor-skill.md +14 -0
  62. package/hooks/blocking/CLAUDE.md +4 -0
  63. package/hooks/blocking/code_review_pr_create_gate.py +7 -3
  64. package/hooks/blocking/code_review_push_gate.py +9 -4
  65. package/hooks/blocking/code_review_stamp_directory_write_blocker.py +8 -0
  66. package/hooks/blocking/config/__init__.py +5 -5
  67. package/hooks/blocking/config/code_review_enforcement_constants.py +40 -7
  68. package/hooks/blocking/config/test_code_review_enforcement_constants.py +58 -0
  69. package/hooks/blocking/config/verified_commit_constants.py +160 -159
  70. package/hooks/blocking/eli11_reply_enforcer.py +479 -0
  71. package/hooks/blocking/gh_body_arg_blocker.py +1 -1
  72. package/hooks/blocking/nas_ssh_binary_enforcer.py +8 -46
  73. package/hooks/blocking/orchestrator_refresh_reschedule_gate.py +256 -0
  74. package/hooks/blocking/pre_tool_use_dispatcher.py +24 -24
  75. package/hooks/blocking/shell_substitution_blocker.py +129 -0
  76. package/hooks/blocking/state_description_blocker.py +1 -1
  77. package/hooks/blocking/stop_dispatcher.py +1 -1
  78. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +2 -3
  79. package/hooks/blocking/test_code_review_pr_create_gate.py +14 -0
  80. package/hooks/blocking/test_code_review_push_gate.py +16 -0
  81. package/hooks/blocking/test_code_review_stamp_directory_write_blocker.py +19 -0
  82. package/hooks/blocking/test_eli11_reply_enforcer.py +457 -0
  83. package/hooks/blocking/test_orchestrator_refresh_reschedule_gate.py +231 -0
  84. package/hooks/blocking/test_pre_tool_use_dispatcher.py +10 -1
  85. package/hooks/blocking/test_shell_substitution_blocker.py +124 -0
  86. package/hooks/blocking/test_stop_dispatcher.py +23 -0
  87. package/hooks/blocking/test_unscoped_search_blocker.py +102 -0
  88. package/hooks/blocking/test_verdict_directory_write_blocker.py +804 -808
  89. package/hooks/blocking/test_verification_verdict_store.py +54 -0
  90. package/hooks/blocking/test_verified_commit_gate.py +581 -581
  91. package/hooks/blocking/test_verified_commit_message_accuracy_blocker.py +131 -131
  92. package/hooks/blocking/unscoped_search_blocker.py +391 -0
  93. package/hooks/blocking/verdict_directory_write_blocker.py +687 -687
  94. package/hooks/blocking/verification_verdict_store.py +1039 -1036
  95. package/hooks/blocking/verified_commit_message_accuracy_blocker.py +167 -167
  96. package/hooks/blocking/verifier_verdict_minter.py +280 -280
  97. package/hooks/git-hooks/CLAUDE.md +3 -0
  98. package/hooks/git-hooks/conftest.py +30 -0
  99. package/hooks/git-hooks/gate_utils.py +2 -2
  100. package/hooks/git-hooks/git_hooks_constants/__init__.py +41 -2
  101. package/hooks/git-hooks/pre_push.py +75 -4
  102. package/hooks/git-hooks/pre_push_base_reference.py +166 -0
  103. package/hooks/git-hooks/test_config.py +0 -15
  104. package/hooks/git-hooks/test_gate_utils.py +3 -15
  105. package/hooks/git-hooks/test_pre_commit.py +1 -15
  106. package/hooks/git-hooks/test_pre_push.py +257 -23
  107. package/hooks/git-hooks/test_pre_push_base_reference.py +339 -0
  108. package/hooks/hooks.json +10 -12
  109. package/hooks/hooks_constants/CLAUDE.md +7 -2
  110. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +4 -4
  111. package/hooks/hooks_constants/eli11_reply_enforcer_constants.py +101 -0
  112. package/hooks/hooks_constants/enter_worktree_prefetch_constants.py +18 -18
  113. package/hooks/hooks_constants/nas_ssh_binary_enforcer_constants.py +2 -8
  114. package/hooks/hooks_constants/orchestrator_refresh_reschedule_gate_constants.py +48 -0
  115. package/hooks/hooks_constants/ruff_integration_constants.py +16 -0
  116. package/hooks/hooks_constants/shell_command_segments.py +82 -0
  117. package/hooks/hooks_constants/shell_substitution_blocker_constants.py +67 -0
  118. package/hooks/hooks_constants/stop_dispatcher_constants.py +1 -0
  119. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +5 -6
  120. package/hooks/hooks_constants/test_stop_dispatcher_constants.py +1 -0
  121. package/hooks/hooks_constants/unscoped_search_blocker_constants.py +153 -0
  122. package/hooks/lifecycle/enter_worktree_origin_prefetch.py +163 -146
  123. package/hooks/lifecycle/test_enter_worktree_origin_prefetch.py +185 -178
  124. package/hooks/pyproject.toml +1 -0
  125. package/hooks/validators/CLAUDE.md +1 -0
  126. package/hooks/validators/config/__init__.py +0 -0
  127. package/hooks/validators/config/directory_exemption_constants.py +183 -0
  128. package/hooks/validators/config/test_directory_exemption_constants.py +21 -0
  129. package/hooks/validators/conftest.py +4 -0
  130. package/hooks/validators/ruff_integration.py +49 -5
  131. package/hooks/validators/run_all_validators.py +206 -9
  132. package/hooks/validators/test_directory_exemption_constants.py +185 -0
  133. package/hooks/validators/test_python_antipattern_checks.py +110 -5
  134. package/hooks/validators/test_ruff_integration.py +92 -1
  135. package/hooks/validators/test_run_all_validators.py +115 -68
  136. package/hooks/validators/test_run_all_validators_pretooluse.py +159 -1
  137. package/package.json +13 -3
  138. package/rules/CLAUDE.md +17 -22
  139. package/rules/agent-spawn-protocol.md +6 -6
  140. package/rules/anti-corollary-tests.md +1 -1
  141. package/rules/bdd.md +1 -1
  142. package/rules/cleanup-temp-files.md +10 -4
  143. package/rules/code-standards.md +7 -0
  144. package/rules/conservative-action.md +1 -5
  145. package/rules/context7.md +0 -4
  146. package/rules/destructive-commands.md +47 -0
  147. package/rules/doc-inventory-integrity.md +48 -0
  148. package/rules/doc-prose-cuts.md +58 -0
  149. package/rules/docstring-prose-matches-implementation.md +53 -44
  150. package/rules/durable-post-artifacts.md +0 -4
  151. package/rules/eli11-replies.md +31 -0
  152. package/rules/explore-thoroughly.md +4 -4
  153. package/rules/falsify-before-green.md +68 -0
  154. package/rules/file-global-constants.md +1 -1
  155. package/rules/filesystem-search.md +51 -0
  156. package/rules/gh-cli-conventions.md +27 -0
  157. package/rules/git-workflow.md +26 -0
  158. package/rules/hedging-claims.md +9 -0
  159. package/rules/long-horizon-autonomy.md +0 -4
  160. package/rules/measurement-denominators.md +48 -0
  161. package/rules/nas-ssh-invocation.md +23 -5
  162. package/rules/parallel-tools.md +2 -2
  163. package/rules/plain-illustrative-docstrings.md +3 -7
  164. package/rules/plain-language.md +2 -0
  165. package/rules/proof-of-work-pr-comments.md +0 -4
  166. package/rules/re-stage-before-commit.md +2 -0
  167. package/rules/research-mode.md +10 -0
  168. package/rules/shell-invocation.md +21 -0
  169. package/rules/testing.md +4 -0
  170. package/rules/verified-commit-gate-skip.md +3 -27
  171. package/rules/verify-before-asking.md +5 -0
  172. package/rules/windows-filesystem-safe.md +1 -1
  173. package/rules/workers-done-before-complete.md +4 -0
  174. package/scripts/CLAUDE.md +1 -0
  175. package/scripts/Capture-PoolHealth.ps1 +410 -0
  176. package/scripts/Migrate-ShellPolicy.ps1 +1 -1
  177. package/scripts/_code_review_test_support.py +404 -0
  178. package/scripts/claude_chain_runner.py +141 -1
  179. package/scripts/codex_capability_bridge.py +171 -0
  180. package/scripts/codex_compat_materializer.py +1087 -0
  181. package/scripts/codex_compat_watcher.py +502 -0
  182. package/scripts/conftest.py +16 -1
  183. package/scripts/dev_env_scripts_constants/CLAUDE.md +1 -1
  184. package/scripts/dev_env_scripts_constants/claude_chain_constants.py +9 -0
  185. package/scripts/dev_env_scripts_constants/code_review_constants.py +37 -0
  186. package/scripts/invoke_code_review.py +11 -4
  187. package/scripts/resolve_worker_spawn.py +626 -626
  188. package/scripts/spawn_grok_batch.py +672 -672
  189. package/scripts/sync_to_cursor/rules.py +0 -10
  190. package/scripts/test_claude_chain_runner.py +131 -0
  191. package/scripts/test_invoke_code_review.py +85 -908
  192. package/scripts/test_invoke_code_review_chain.py +70 -0
  193. package/scripts/test_invoke_code_review_cli.py +192 -0
  194. package/scripts/test_invoke_code_review_contract.py +256 -0
  195. package/scripts/test_invoke_code_review_git.py +123 -0
  196. package/scripts/test_invoke_code_review_mode.py +99 -0
  197. package/scripts/test_resolve_worker_spawn.py +1014 -1014
  198. package/scripts/tests/test_code_review_constants.py +80 -0
  199. package/scripts/tests/test_codex_capability_bridge.py +91 -0
  200. package/scripts/tests/test_codex_compat_materializer.py +632 -0
  201. package/scripts/tests/test_codex_compat_watcher.py +599 -0
  202. package/scripts/tests/test_sync_to_cursor.py +0 -1
  203. package/skills/CLAUDE.md +2 -0
  204. package/skills/auditing-claude-config/SKILL.md +114 -114
  205. package/skills/autoconverge/SKILL.md +427 -427
  206. package/skills/autoconverge/reference/convergence.md +24 -3
  207. package/skills/autoconverge/workflow/CLAUDE.md +1 -0
  208. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +3 -3
  209. package/skills/autoconverge/workflow/converge.contract.test.mjs +1263 -1263
  210. package/skills/autoconverge/workflow/converge.mjs +168 -1
  211. package/skills/autoconverge/workflow/converge.p2-advance.test.mjs +202 -0
  212. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a11d903476b803493.jsonl +2 -2
  213. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a26213978adeef6fb.jsonl +2 -2
  214. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a3def0d15ed9d9110.jsonl +2 -2
  215. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a41f41b1b708ee3b7.jsonl +2 -2
  216. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a758b880abecc3ff7.jsonl +2 -2
  217. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a8897b89656b1bd16.jsonl +2 -2
  218. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-abd463d744a1437bc.jsonl +2 -2
  219. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-ad19d027ae8ee1816.jsonl +2 -2
  220. package/skills/autoconverge/workflow/fixtures/wf_run/workflows/wf_881252e6-700.json +265 -265
  221. package/skills/bugteam/reference/copilot-gap-analysis.md +1 -1
  222. package/skills/closeout/SKILL.md +33 -50
  223. package/skills/codex-review/scripts/codex_review_scripts_constants/run_constants.py +8 -0
  224. package/skills/codex-review/scripts/run_codex_review.py +233 -1
  225. package/skills/codex-review/scripts/test_run_codex_review.py +189 -0
  226. package/skills/condensing-instructions/SKILL.md +72 -0
  227. package/skills/copilot-review/SKILL.md +119 -119
  228. package/skills/e-code-review/SKILL.md +52 -0
  229. package/skills/e-code-review/reference/fix.md +54 -0
  230. package/skills/e-code-review/reference/loop.md +43 -0
  231. package/skills/e-code-review/reference/low.md +57 -0
  232. package/skills/e-code-review/reference/medium.md +153 -0
  233. package/skills/e-code-review/reference/xhigh.md +182 -0
  234. package/skills/e-simplify/SKILL.md +97 -0
  235. package/skills/fresh-branch/CLAUDE.md +1 -1
  236. package/skills/fresh-branch/SKILL.md +5 -6
  237. package/skills/fresh-branch/scripts/create_fresh_branch.py +42 -24
  238. package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +1 -3
  239. package/skills/fresh-branch/scripts/test_create_fresh_branch.py +30 -126
  240. package/skills/issue-tracker/SKILL.md +92 -0
  241. package/skills/issue-tracker/reference/epic-and-sub-issue-model.md +55 -0
  242. package/skills/issue-tracker/reference/handoff-schema.md +64 -0
  243. package/skills/issue-tracker/reference/operation-matrix.md +41 -0
  244. package/skills/orchestrator/SKILL.md +177 -22
  245. package/skills/orchestrator/scripts/status_gate.py +625 -0
  246. package/skills/orchestrator/scripts/status_gate_constants/__init__.py +1 -0
  247. package/skills/orchestrator/scripts/status_gate_constants/config/__init__.py +1 -0
  248. package/skills/orchestrator/scripts/status_gate_constants/config/constants.py +47 -0
  249. package/skills/orchestrator/scripts/test_status_gate.py +439 -0
  250. package/skills/orchestrator-refresh/SKILL.md +129 -35
  251. package/skills/plan-to-pr/SKILL.md +155 -0
  252. package/skills/plan-to-pr/reference/final-validation-tasks.md +15 -0
  253. package/skills/plan-to-pr/reference/model-routing.md +36 -0
  254. package/skills/plan-to-pr/reference/packet-contract.md +43 -0
  255. package/skills/plan-to-pr/reference/packet-schema.json +57 -0
  256. package/skills/plan-to-pr/reference/process-inventory.md +22 -0
  257. package/skills/plan-to-pr/reference/review-loop.md +33 -0
  258. package/skills/plan-to-pr/reference/run-record.schema.json +27 -0
  259. package/skills/plan-to-pr/reference/self-audit-tasks.md +15 -0
  260. package/skills/plan-to-pr/reference/task-seeds.md +14 -0
  261. package/skills/plan-to-pr/reference/task-ticket.md +38 -0
  262. package/skills/plan-to-pr/scripts/config/__init__.py +1 -0
  263. package/skills/plan-to-pr/scripts/config/constants.py +193 -0
  264. package/skills/plan-to-pr/scripts/create_packet.py +173 -0
  265. package/skills/plan-to-pr/scripts/test_create_packet.py +102 -0
  266. package/skills/plan-to-pr/scripts/test_validate_packet.py +256 -0
  267. package/skills/plan-to-pr/scripts/test_validate_protocol.py +135 -0
  268. package/skills/plan-to-pr/scripts/test_validate_run.py +158 -0
  269. package/skills/plan-to-pr/scripts/validate_packet.py +655 -0
  270. package/skills/plan-to-pr/scripts/validate_protocol.py +622 -0
  271. package/skills/plan-to-pr/scripts/validate_run.py +173 -0
  272. package/skills/plan-to-pr/test_skill_contract.py +207 -0
  273. package/skills/plan-to-pr/test_task_ticket_contract.py +151 -0
  274. package/skills/pr-converge/SKILL.md +472 -469
  275. package/skills/pr-converge/reference/examples.md +3 -3
  276. package/skills/pr-converge/reference/fix-protocol.md +1 -1
  277. package/skills/pr-converge/reference/ground-rules.md +7 -4
  278. package/skills/pr-converge/reference/multi-pr-orchestration.md +4 -1
  279. package/skills/pr-converge/reference/per-tick.md +5 -5
  280. package/skills/pr-converge/reference/progress-checklist.md +1 -1
  281. package/skills/pr-converge/scripts/check_convergence_gates.py +279 -279
  282. package/skills/pr-converge/scripts/test_check_convergence_codex.py +507 -507
  283. package/skills/pr-converge/scripts/test_check_convergence_gates.py +84 -84
  284. package/skills/pr-converge/test_step5_host_branch.py +1 -1
  285. package/skills/pr-fix-protocol/SKILL.md +1 -1
  286. package/skills/privacy-hygiene/SKILL.md +68 -68
  287. package/skills/privacy-hygiene/reference/sweep-procedure.md +1 -1
  288. package/skills/prototype/workflows/promotion.md +1 -1
  289. package/skills/release-notes-html/SKILL.md +164 -0
  290. package/skills/session-log/SKILL.md +1 -1
  291. package/skills/task-build/CLAUDE.md +8 -7
  292. package/skills/task-build/SKILL.md +16 -8
  293. package/skills/task-build/reference/tool-routing.md +19 -0
  294. package/rules/claude-md-orphan-file.md +0 -28
  295. package/rules/cleanup-command-forms.md +0 -23
  296. package/rules/code-reviews.md +0 -11
  297. package/rules/env-var-table-code-drift.md +0 -10
  298. package/rules/gh-body-file.md +0 -5
  299. package/rules/gh-paginate.md +0 -3
  300. package/rules/hook-prose-matches-detector.md +0 -15
  301. package/rules/no-historical-clutter.md +0 -26
  302. package/rules/no-inline-destructive-literals.md +0 -9
  303. package/rules/no-justification-noise.md +0 -61
  304. package/rules/package-inventory-stale-entry.md +0 -25
  305. package/rules/right-sized-engineering.md +0 -28
  306. package/rules/self-contained-docs.md +0 -17
  307. package/rules/shell-invocation-policy.md +0 -5
  308. package/rules/tdd.md +0 -7
  309. package/skills/closeout/reference/issue-body-templates.md +0 -108
@@ -0,0 +1,479 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Stop hook that blocks a final reply breaking the short action-first reply shape.
4
+
5
+ The `eli11-replies` rule asks every chat reply to lead with the action, keep
6
+ findings to a few bullets, and stay short. This hook reads the final assistant
7
+ message, strips code fences, inline code, blockquotes, table rows, and link
8
+ targets, then judges what a reader actually sees: the word count against a hard
9
+ cap, whether numbered steps lead a reply that tells the user to act, the number
10
+ of bullet lines, and how many lines pack more than one idea's worth of words.
11
+ A reply opening with "Long form:" opts out, so a user-requested full report
12
+ still goes through.
13
+ """
14
+
15
+ import json
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ _hooks_dir = str(Path(__file__).resolve().parent.parent)
20
+ if _hooks_dir not in sys.path:
21
+ sys.path.insert(0, _hooks_dir)
22
+
23
+ from hooks_constants.eli11_reply_enforcer_constants import ( # noqa: E402
24
+ ACTION_FIRST_LEAD_LINE_COUNT,
25
+ ALL_IMPERATIVE_INSTRUCTION_VERBS,
26
+ ALL_IMPERATIVE_OBJECT_LEAD_WORDS,
27
+ ALPHABETIC_WORD_PATTERN,
28
+ BULLET_LINE_PATTERN,
29
+ COUNTABLE_WORD_PATTERN,
30
+ IMPERATIVE_OBJECT_TOKEN_PATTERN,
31
+ LINK_TARGET_PATTERN,
32
+ LIST_MARKER_PREFIX_PATTERN,
33
+ LONG_FORM_ESCAPE_PREFIX,
34
+ MARKDOWN_LEAD_MARKER_PATTERN,
35
+ MAXIMUM_BULLET_LINE_COUNT,
36
+ MAXIMUM_OVERPACKED_LINE_COUNT,
37
+ MAXIMUM_REPLY_WORD_COUNT,
38
+ MAXIMUM_WORDS_PER_LINE,
39
+ MINIMUM_ENFORCED_WORD_COUNT,
40
+ NUMBERED_STEP_PATTERN,
41
+ TABLE_ROW_PATTERN,
42
+ TARGET_BULLET_LINE_COUNT,
43
+ USER_FACING_ELI11_NOTICE,
44
+ VIOLATION_SEPARATOR,
45
+ )
46
+ from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
47
+ from hooks_constants.text_stripping import strip_code_and_quotes # noqa: E402
48
+
49
+
50
+ def extract_reply_prose(assistant_message: str) -> str:
51
+ """Return the reader-visible prose of a reply.
52
+
53
+ ::
54
+
55
+ in: "See https://example.com/a now"
56
+ out: "See now"
57
+
58
+ Fenced code, inline code, and blockquotes come off through the shared
59
+ stripper; table rows and link targets come off here.
60
+
61
+ Args:
62
+ assistant_message: The raw final assistant message.
63
+
64
+ Returns:
65
+ The message with code, quotes, table rows, and link targets removed.
66
+ """
67
+ prose_text = strip_code_and_quotes(assistant_message)
68
+ prose_text = TABLE_ROW_PATTERN.sub("", prose_text)
69
+ return LINK_TARGET_PATTERN.sub("", prose_text)
70
+
71
+
72
+ def count_reply_words(prose_text: str) -> int:
73
+ """Return the number of reader-visible words in the stripped prose.
74
+
75
+ Args:
76
+ prose_text: Prose already cleaned of code, quotes, tables, and links.
77
+
78
+ Returns:
79
+ The count of whitespace-separated tokens carrying a letter or digit.
80
+ """
81
+ return len(COUNTABLE_WORD_PATTERN.findall(prose_text))
82
+
83
+
84
+ def collect_non_empty_lines(prose_text: str) -> list[str]:
85
+ """Return every line of the stripped prose that carries visible text.
86
+
87
+ Args:
88
+ prose_text: Prose already cleaned of code, quotes, tables, and links.
89
+
90
+ Returns:
91
+ The lines with content, in order, blank lines dropped.
92
+ """
93
+ return [
94
+ each_line for each_line in prose_text.splitlines() if each_line.strip()
95
+ ]
96
+
97
+
98
+ def leading_word_of(prose_line: str) -> str:
99
+ """Return the first word of a line once list and bold markers come off.
100
+
101
+ ::
102
+
103
+ in: "- **Click** Save"
104
+ out: "click"
105
+
106
+ Args:
107
+ prose_line: One non-empty line of reply prose.
108
+
109
+ Returns:
110
+ The lowercased first alphabetic word, or an empty string when the line
111
+ carries no letters.
112
+ """
113
+ unmarked_line = LIST_MARKER_PREFIX_PATTERN.sub("", prose_line, count=1)
114
+ all_leading_words = ALPHABETIC_WORD_PATTERN.findall(unmarked_line)
115
+ if not all_leading_words:
116
+ return ""
117
+ return all_leading_words[0].lower()
118
+
119
+
120
+ def object_word_after_leading_verb(prose_line: str) -> str:
121
+ """Return the word a line puts right after its opening verb.
122
+
123
+ ::
124
+
125
+ in: "Run the migration script."
126
+ out: "the"
127
+
128
+ Args:
129
+ prose_line: One non-empty line of reply prose.
130
+
131
+ Returns:
132
+ The lowercased token following the first alphabetic word, or an empty
133
+ string when the line carries no such token.
134
+ """
135
+ unmarked_line = LIST_MARKER_PREFIX_PATTERN.sub("", prose_line, count=1)
136
+ leading_word_match = ALPHABETIC_WORD_PATTERN.search(unmarked_line)
137
+ if leading_word_match is None:
138
+ return ""
139
+ all_trailing_tokens = COUNTABLE_WORD_PATTERN.findall(
140
+ unmarked_line[leading_word_match.end():]
141
+ )
142
+ if not all_trailing_tokens:
143
+ return ""
144
+ return all_trailing_tokens[0].lower()
145
+
146
+
147
+ def names_imperative_object(object_word: str) -> bool:
148
+ """Return True when a word reads as the thing an imperative acts on.
149
+
150
+ ::
151
+
152
+ ok: "the" (Run the script), "3" (Do 3 things), "scripts/run.py"
153
+ flag: "questions" (Open questions remain), "is" (Merge is complete)
154
+
155
+ An imperative names its object through a determiner, a count, a path, or a
156
+ filename; a narrative sentence puts a bare subject noun there instead.
157
+
158
+ Args:
159
+ object_word: The lowercased word following a line's opening verb.
160
+
161
+ Returns:
162
+ True when the word marks the opening verb as a real imperative.
163
+ """
164
+ if not object_word:
165
+ return False
166
+ if object_word in ALL_IMPERATIVE_OBJECT_LEAD_WORDS:
167
+ return True
168
+ return IMPERATIVE_OBJECT_TOKEN_PATTERN.search(object_word) is not None
169
+
170
+
171
+ def is_imperative_instruction_line(prose_line: str) -> bool:
172
+ """Return True when a line tells the user to act.
173
+
174
+ ::
175
+
176
+ ok: "Open questions remain about the stripper." -> False
177
+ flag: "Open the pull request." -> True
178
+
179
+ Args:
180
+ prose_line: One non-empty line of reply prose.
181
+
182
+ Returns:
183
+ True when the line opens with a tracked instruction verb naming an
184
+ object an imperative acts on.
185
+ """
186
+ if leading_word_of(prose_line) not in ALL_IMPERATIVE_INSTRUCTION_VERBS:
187
+ return False
188
+ return names_imperative_object(object_word_after_leading_verb(prose_line))
189
+
190
+
191
+ def has_imperative_instruction_line(all_prose_lines: list[str]) -> bool:
192
+ """Return True when any line opens with a verb telling the user to act.
193
+
194
+ Args:
195
+ all_prose_lines: The non-empty lines of reply prose.
196
+
197
+ Returns:
198
+ True when a line reads as an imperative instruction.
199
+ """
200
+ return any(
201
+ is_imperative_instruction_line(each_line) for each_line in all_prose_lines
202
+ )
203
+
204
+
205
+ def has_leading_numbered_step(all_prose_lines: list[str]) -> bool:
206
+ """Return True when a numbered step appears among the lead lines.
207
+
208
+ Args:
209
+ all_prose_lines: The non-empty lines of reply prose.
210
+
211
+ Returns:
212
+ True when one of the first lead lines opens a numbered list.
213
+ """
214
+ all_lead_lines = all_prose_lines[:ACTION_FIRST_LEAD_LINE_COUNT]
215
+ return any(
216
+ NUMBERED_STEP_PATTERN.match(each_line) for each_line in all_lead_lines
217
+ )
218
+
219
+
220
+ def count_bullet_lines(all_prose_lines: list[str]) -> int:
221
+ """Return how many lines of the reply are bullets.
222
+
223
+ Args:
224
+ all_prose_lines: The non-empty lines of reply prose.
225
+
226
+ Returns:
227
+ The count of lines opening with a bullet marker.
228
+ """
229
+ return sum(
230
+ 1 for each_line in all_prose_lines if BULLET_LINE_PATTERN.match(each_line)
231
+ )
232
+
233
+
234
+ def count_words_in_line(prose_line: str) -> int:
235
+ """Return the countable words a line carries once its list marker comes off.
236
+
237
+ ::
238
+
239
+ in: "- **Save** the draft"
240
+ out: 3
241
+
242
+ Args:
243
+ prose_line: One non-empty line of reply prose.
244
+
245
+ Returns:
246
+ The count of tokens carrying a letter or digit, marker text excluded.
247
+ """
248
+ unmarked_line = LIST_MARKER_PREFIX_PATTERN.sub("", prose_line, count=1)
249
+ return len(COUNTABLE_WORD_PATTERN.findall(unmarked_line))
250
+
251
+
252
+ def count_overpacked_lines(all_prose_lines: list[str]) -> int:
253
+ """Return how many lines carry more words than one idea needs.
254
+
255
+ Args:
256
+ all_prose_lines: The non-empty lines of reply prose.
257
+
258
+ Returns:
259
+ The count of lines over the per-line word cap.
260
+ """
261
+ return sum(
262
+ 1
263
+ for each_line in all_prose_lines
264
+ if count_words_in_line(each_line) > MAXIMUM_WORDS_PER_LINE
265
+ )
266
+
267
+
268
+ def strip_markdown_lead_markers(prose_line: str) -> str:
269
+ """Return a line with its opening blockquote, heading, and bold markers off.
270
+
271
+ ::
272
+
273
+ in: "> **Long form:** the report follows"
274
+ out: "Long form:** the report follows"
275
+
276
+ Args:
277
+ prose_line: One line of raw reply text.
278
+
279
+ Returns:
280
+ The line with every leading markdown marker and its padding removed.
281
+ """
282
+ return MARKDOWN_LEAD_MARKER_PATTERN.sub("", prose_line.strip(), count=1)
283
+
284
+
285
+ def opens_with_long_form_escape(assistant_message: str) -> bool:
286
+ """Return True when the reply opts out through the Long form prefix.
287
+
288
+ ::
289
+
290
+ ok: "**Long form:** the report follows" -> True
291
+ flag: "The report follows" -> False
292
+
293
+ A bold, blockquote, or heading wrapper around the prefix still opts out.
294
+
295
+ Args:
296
+ assistant_message: The raw final assistant message.
297
+
298
+ Returns:
299
+ True when the first line with content starts with the escape prefix.
300
+ """
301
+ for each_line in assistant_message.splitlines():
302
+ if not each_line.strip():
303
+ continue
304
+ unwrapped_line = strip_markdown_lead_markers(each_line)
305
+ return unwrapped_line.lower().startswith(LONG_FORM_ESCAPE_PREFIX)
306
+ return False
307
+
308
+
309
+ def describe_word_cap_violation(reply_word_count: int) -> str:
310
+ """Return the violation text naming the reply length and the cap."""
311
+ return (
312
+ f"{reply_word_count} words, over the {MAXIMUM_REPLY_WORD_COUNT}-word cap"
313
+ )
314
+
315
+
316
+ def describe_bullet_cap_violation(bullet_line_count: int) -> str:
317
+ """Return the violation text naming the bullet count and the target."""
318
+ return (
319
+ f"{bullet_line_count} bullets, over the {MAXIMUM_BULLET_LINE_COUNT}-bullet "
320
+ f"cap - cut findings to {TARGET_BULLET_LINE_COUNT} bullets"
321
+ )
322
+
323
+
324
+ def describe_overpacked_line_violation(overpacked_line_count: int) -> str:
325
+ """Return the violation text naming how many lines pack too much in."""
326
+ return (
327
+ f"{overpacked_line_count} lines carry too many words - one idea per line, "
328
+ f"at most {MAXIMUM_WORDS_PER_LINE} words each"
329
+ )
330
+
331
+
332
+ def describe_action_first_violation() -> str:
333
+ """Return the violation text for instructions buried under prose."""
334
+ return (
335
+ "the reply tells the user to act but leads with prose - put the steps "
336
+ "first, as a numbered list"
337
+ )
338
+
339
+
340
+ def is_action_first_violation(
341
+ reply_word_count: int, all_prose_lines: list[str]
342
+ ) -> bool:
343
+ """Return True when instructions sit below the lead of a long reply.
344
+
345
+ Args:
346
+ reply_word_count: The reader-visible word count of the reply.
347
+ all_prose_lines: The non-empty lines of reply prose.
348
+
349
+ Returns:
350
+ True when the reply is long, carries instruction lines, and shows no
351
+ numbered step among its lead lines.
352
+ """
353
+ if reply_word_count < MINIMUM_ENFORCED_WORD_COUNT:
354
+ return False
355
+ if not has_imperative_instruction_line(all_prose_lines):
356
+ return False
357
+ return not has_leading_numbered_step(all_prose_lines)
358
+
359
+
360
+ def collect_shape_violations(
361
+ reply_word_count: int, all_prose_lines: list[str]
362
+ ) -> list[str]:
363
+ """Return the violation texts for every reply-shape rule the reply breaks.
364
+
365
+ Args:
366
+ reply_word_count: The reader-visible word count of the reply.
367
+ all_prose_lines: The non-empty lines of reply prose.
368
+
369
+ Returns:
370
+ One violation text per broken rule, empty when the reply is in shape.
371
+ """
372
+ all_violations: list[str] = []
373
+ if reply_word_count > MAXIMUM_REPLY_WORD_COUNT:
374
+ all_violations.append(describe_word_cap_violation(reply_word_count))
375
+ if is_action_first_violation(reply_word_count, all_prose_lines):
376
+ all_violations.append(describe_action_first_violation())
377
+ bullet_line_count = count_bullet_lines(all_prose_lines)
378
+ if bullet_line_count > MAXIMUM_BULLET_LINE_COUNT:
379
+ all_violations.append(describe_bullet_cap_violation(bullet_line_count))
380
+ overpacked_line_count = count_overpacked_lines(all_prose_lines)
381
+ if overpacked_line_count > MAXIMUM_OVERPACKED_LINE_COUNT:
382
+ all_violations.append(
383
+ describe_overpacked_line_violation(overpacked_line_count)
384
+ )
385
+ return all_violations
386
+
387
+
388
+ def find_reply_shape_violations(assistant_message: str) -> list[str]:
389
+ """Return every reply-shape violation the final assistant message carries.
390
+
391
+ ::
392
+
393
+ ok: a 40-word outcome sentence -> []
394
+ flag: a 260-word wall -> ["260 words, over the 120-word cap"]
395
+
396
+ A reply opening with the escape prefix, and a reply under the enforced word
397
+ floor, are both returned as in shape.
398
+
399
+ Args:
400
+ assistant_message: The raw final assistant message.
401
+
402
+ Returns:
403
+ One violation text per broken rule, empty when the reply is in shape.
404
+ """
405
+ if opens_with_long_form_escape(assistant_message):
406
+ return []
407
+ prose_text = extract_reply_prose(assistant_message)
408
+ reply_word_count = count_reply_words(prose_text)
409
+ if reply_word_count < MINIMUM_ENFORCED_WORD_COUNT:
410
+ return []
411
+ return collect_shape_violations(
412
+ reply_word_count, collect_non_empty_lines(prose_text)
413
+ )
414
+
415
+
416
+ def build_block_reason(all_violations: list[str]) -> str:
417
+ """Return the corrective message naming each violation and the escape hatch.
418
+
419
+ Args:
420
+ all_violations: The violation texts the reply earned.
421
+
422
+ Returns:
423
+ The full block reason the model rewrites its reply against.
424
+ """
425
+ formatted_violation_list = VIOLATION_SEPARATOR.join(all_violations)
426
+ return (
427
+ f"ELI11 REPLY SHAPE: Your reply breaks the short action-first shape "
428
+ f"({formatted_violation_list}).\n\n"
429
+ f"Rewrite it short. When the user must act, open with numbered "
430
+ f"click-by-click steps, one short line each. When nothing is needed, open "
431
+ f"with the outcome in one sentence. Keep findings to at most "
432
+ f"{TARGET_BULLET_LINE_COUNT} short bullets and stay under "
433
+ f"{MAXIMUM_REPLY_WORD_COUNT} words. Code fences, blockquotes, tables, and "
434
+ f"links are already exempt from the count.\n\n"
435
+ f"When the user asked for a full report, start the reply with "
436
+ f'"Long form:" to opt out of this check.\n\n'
437
+ f"You MUST re-output the complete, revised response with the correction "
438
+ f"applied."
439
+ )
440
+
441
+
442
+ def main() -> None:
443
+ """Read the Stop payload and block a reply that breaks the ELI11 shape."""
444
+ try:
445
+ hook_input = json.load(sys.stdin)
446
+ except json.JSONDecodeError:
447
+ sys.exit(0)
448
+
449
+ if hook_input.get("stop_hook_active", False):
450
+ sys.exit(0)
451
+
452
+ assistant_message = hook_input.get("last_assistant_message", "")
453
+
454
+ if not assistant_message:
455
+ sys.exit(0)
456
+
457
+ all_violations = find_reply_shape_violations(assistant_message)
458
+
459
+ if not all_violations:
460
+ sys.exit(0)
461
+
462
+ block_reason = build_block_reason(all_violations)
463
+ block_response = {
464
+ "decision": "block",
465
+ "reason": block_reason,
466
+ "systemMessage": USER_FACING_ELI11_NOTICE,
467
+ "suppressOutput": True,
468
+ }
469
+ log_hook_block(
470
+ calling_hook_name="eli11_reply_enforcer.py",
471
+ hook_event="Stop",
472
+ block_reason=block_reason,
473
+ )
474
+ print(json.dumps(block_response))
475
+ sys.exit(0)
476
+
477
+
478
+ if __name__ == "__main__":
479
+ main()
@@ -68,7 +68,7 @@ _CORRECTIVE_MESSAGE = (
68
68
  " '@\n"
69
69
  " [IO.File]::WriteAllText($bodyPath, $body, [Text.UTF8Encoding]::new($false))\n"
70
70
  " gh ... --body-file $bodyPath\n\n"
71
- "See ~/.claude/rules/gh-body-file.md for full guidance."
71
+ "See ~/.claude/rules/gh-cli-conventions.md for full guidance."
72
72
  )
73
73
 
74
74
 
@@ -46,20 +46,16 @@ from hooks_constants.local_identity import ( # noqa: E402
46
46
  nas_host,
47
47
  )
48
48
  from hooks_constants.nas_ssh_binary_enforcer_constants import ( # noqa: E402
49
- ALL_LAUNCHER_WRAPPER_COMMANDS,
50
49
  ALL_OPENSSH_BINARY_PATH_SUFFIXES,
51
- ALL_SHELL_CONTROL_OPERATOR_TOKENS,
52
50
  ALL_SSH_FAMILY_COMMAND_BASENAMES,
53
51
  BASH_TOOL_NAME,
54
52
  BATCH_MODE_PATTERN,
55
- CONTROL_OPERATOR_SPLIT_PATTERN,
56
- LAUNCHER_DURATION_PATTERN,
57
- LEADING_ASSIGNMENT_PATTERN,
58
53
  )
59
-
60
-
61
- def _token_basename(token: str) -> str:
62
- return token.replace("\\", "/").rsplit("/", 1)[-1].lower()
54
+ from hooks_constants.shell_command_segments import ( # noqa: E402
55
+ effective_leading_program,
56
+ split_into_segments,
57
+ token_basename,
58
+ )
63
59
 
64
60
 
65
61
  def _is_openssh_binary_path(token: str) -> bool:
@@ -74,40 +70,6 @@ def _nas_host_token_pattern() -> re.Pattern[str]:
74
70
  return re.compile(rf"(?:^|@){re.escape(nas_host())}(?::|$)")
75
71
 
76
72
 
77
- def _split_into_segments(all_command_tokens: list[str]) -> list[list[str]]:
78
- all_exploded_tokens: list[str] = []
79
- for each_token in all_command_tokens:
80
- for each_fragment in CONTROL_OPERATOR_SPLIT_PATTERN.split(each_token):
81
- if each_fragment:
82
- all_exploded_tokens.append(each_fragment)
83
- all_segments: list[list[str]] = []
84
- current_segment: list[str] = []
85
- for each_token in all_exploded_tokens:
86
- if each_token in ALL_SHELL_CONTROL_OPERATOR_TOKENS:
87
- all_segments.append(current_segment)
88
- current_segment = []
89
- continue
90
- current_segment.append(each_token)
91
- all_segments.append(current_segment)
92
- return all_segments
93
-
94
-
95
- def _effective_leading_program(all_segment_tokens: list[str]) -> str | None:
96
- has_seen_launcher_wrapper = False
97
- for each_token in all_segment_tokens:
98
- if LEADING_ASSIGNMENT_PATTERN.match(each_token):
99
- continue
100
- if _token_basename(each_token) in ALL_LAUNCHER_WRAPPER_COMMANDS:
101
- has_seen_launcher_wrapper = True
102
- continue
103
- if has_seen_launcher_wrapper and (
104
- each_token.startswith("-") or LAUNCHER_DURATION_PATTERN.match(each_token)
105
- ):
106
- continue
107
- return each_token
108
- return None
109
-
110
-
111
73
  def _segment_references_nas(
112
74
  all_segment_tokens: list[str], nas_host_pattern: re.Pattern[str]
113
75
  ) -> bool:
@@ -145,11 +107,11 @@ def _find_nas_ssh_violation(command: str) -> str | None:
145
107
  except ValueError:
146
108
  return None
147
109
  nas_host_pattern = _nas_host_token_pattern()
148
- for each_segment in _split_into_segments(all_command_tokens):
149
- leading_program = _effective_leading_program(each_segment)
110
+ for each_segment in split_into_segments(all_command_tokens):
111
+ leading_program = effective_leading_program(each_segment)
150
112
  if leading_program is None:
151
113
  continue
152
- if _token_basename(leading_program) not in ALL_SSH_FAMILY_COMMAND_BASENAMES:
114
+ if token_basename(leading_program) not in ALL_SSH_FAMILY_COMMAND_BASENAMES:
153
115
  continue
154
116
  if not _segment_references_nas(each_segment, nas_host_pattern):
155
117
  continue