claude-dev-env 1.95.0 → 2.0.1

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 (241) hide show
  1. package/_shared/advisor/CLAUDE.md +2 -2
  2. package/_shared/advisor/advisor-protocol.md +20 -20
  3. package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +15 -12
  4. package/_shared/advisor/scripts/model_tier_run_validator.py +11 -10
  5. package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +25 -19
  6. package/_shared/advisor/scripts/tests/test_tier_model_ids.py +17 -17
  7. package/_shared/advisor/scripts/tier_model_ids.py +18 -18
  8. package/_shared/pr-loop/CLAUDE.md +1 -0
  9. package/_shared/pr-loop/scripts/CLAUDE.md +2 -1
  10. package/_shared/pr-loop/scripts/README.md +1 -0
  11. package/_shared/pr-loop/scripts/code_rules_gate.py +253 -1980
  12. package/_shared/pr-loop/scripts/code_rules_gate_parts/CLAUDE.md +32 -0
  13. package/_shared/pr-loop/scripts/code_rules_gate_parts/__init__.py +7 -0
  14. package/_shared/pr-loop/scripts/code_rules_gate_parts/added_line_maps.py +268 -0
  15. package/_shared/pr-loop/scripts/code_rules_gate_parts/enforcer_loading.py +172 -0
  16. package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_arguments.py +70 -0
  17. package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_running.py +326 -0
  18. package/_shared/pr-loop/scripts/code_rules_gate_parts/git_blob_readers.py +85 -0
  19. package/_shared/pr-loop/scripts/code_rules_gate_parts/git_file_sets.py +331 -0
  20. package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_running.py +369 -0
  21. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/conftest.py +14 -0
  22. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_added_line_maps.py +118 -0
  23. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_enforcer_loading.py +17 -0
  24. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_gate_arguments.py +29 -0
  25. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_gate_running.py +99 -0
  26. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_git_blob_readers.py +69 -0
  27. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_git_file_sets.py +137 -0
  28. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_staged_test_running.py +116 -0
  29. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_violation_scoping.py +75 -0
  30. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_wrapper_plumb_check.py +49 -0
  31. package/_shared/pr-loop/scripts/code_rules_gate_parts/violation_scoping.py +328 -0
  32. package/_shared/pr-loop/scripts/code_rules_gate_parts/wrapper_plumb_check.py +206 -0
  33. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +24 -17
  34. package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviews_disabled_constants.py +1 -0
  35. package/_shared/pr-loop/scripts/reviews_disabled.py +19 -2
  36. package/_shared/pr-loop/scripts/test_code_rules_gate.py +278 -0
  37. package/_shared/pr-loop/scripts/tests/test_code_rules_gate_constants.py +6 -39
  38. package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +43 -0
  39. package/_shared/pr-loop/worker-spawn.md +186 -0
  40. package/agents/code-verifier.md +1 -1
  41. package/bin/ever-shipped-skills.mjs +3 -0
  42. package/bin/expand_home_directory_tokens.mjs +1 -1
  43. package/bin/install.mjs +5 -2
  44. package/hooks/advisory/refactor_guard.py +3 -4
  45. package/hooks/blocking/CLAUDE.md +7 -1
  46. package/hooks/blocking/block_main_commit.py +2 -2
  47. package/hooks/blocking/claude_md_orphan_file_blocker.py +75 -699
  48. package/hooks/blocking/claude_md_orphan_file_blocker_parts/CLAUDE.md +28 -0
  49. package/hooks/blocking/claude_md_orphan_file_blocker_parts/__init__.py +1 -0
  50. package/hooks/blocking/claude_md_orphan_file_blocker_parts/config/__init__.py +1 -0
  51. package/hooks/blocking/claude_md_orphan_file_blocker_parts/config/orphan_blocker_constants.py +18 -0
  52. package/hooks/blocking/claude_md_orphan_file_blocker_parts/decision.py +81 -0
  53. package/hooks/blocking/claude_md_orphan_file_blocker_parts/references.py +307 -0
  54. package/hooks/blocking/claude_md_orphan_file_blocker_parts/scan_plan.py +124 -0
  55. package/hooks/blocking/claude_md_orphan_file_blocker_parts/subtree_scan.py +179 -0
  56. package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/conftest.py +10 -0
  57. package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_decision.py +34 -0
  58. package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_references.py +42 -0
  59. package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_scan_plan.py +27 -0
  60. package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_subtree_scan.py +30 -0
  61. package/hooks/blocking/code_rules_boolean_mustcheck.py +1 -1
  62. package/hooks/blocking/code_rules_mock_completeness.py +1 -1
  63. package/hooks/blocking/code_rules_optional_params.py +2 -2
  64. package/hooks/blocking/code_rules_shared.py +1 -1
  65. package/hooks/blocking/code_rules_test_assertions.py +1 -1
  66. package/hooks/blocking/code_rules_typeddict_stub.py +1 -1
  67. package/hooks/blocking/gh_pr_author_enforcer.py +1 -1
  68. package/hooks/blocking/inventory_intent_records/CLAUDE.md +26 -0
  69. package/hooks/blocking/inventory_intent_records/__init__.py +1 -0
  70. package/hooks/blocking/inventory_intent_records/config/__init__.py +1 -0
  71. package/hooks/blocking/inventory_intent_records/config/intent_records_constants.py +20 -0
  72. package/hooks/blocking/inventory_intent_records/records.py +271 -0
  73. package/hooks/blocking/inventory_intent_records/tests/conftest.py +10 -0
  74. package/hooks/blocking/inventory_intent_records/tests/test_records.py +80 -0
  75. package/hooks/blocking/package_inventory_stale_blocker.py +54 -384
  76. package/hooks/blocking/package_inventory_stale_blocker_parts/CLAUDE.md +26 -0
  77. package/hooks/blocking/package_inventory_stale_blocker_parts/__init__.py +1 -0
  78. package/hooks/blocking/package_inventory_stale_blocker_parts/config/__init__.py +1 -0
  79. package/hooks/blocking/package_inventory_stale_blocker_parts/config/inventory_blocker_constants.py +16 -0
  80. package/hooks/blocking/package_inventory_stale_blocker_parts/decision.py +84 -0
  81. package/hooks/blocking/package_inventory_stale_blocker_parts/inventory_detection.py +307 -0
  82. package/hooks/blocking/package_inventory_stale_blocker_parts/tests/conftest.py +10 -0
  83. package/hooks/blocking/package_inventory_stale_blocker_parts/tests/test_decision.py +38 -0
  84. package/hooks/blocking/package_inventory_stale_blocker_parts/tests/test_inventory_detection.py +61 -0
  85. package/hooks/blocking/pii_payload_scan.py +138 -42
  86. package/hooks/blocking/pii_prevention_blocker.py +185 -291
  87. package/hooks/blocking/pii_prevention_blocker_parts/CLAUDE.md +24 -0
  88. package/hooks/blocking/pii_prevention_blocker_parts/__init__.py +1 -0
  89. package/hooks/blocking/pii_prevention_blocker_parts/config/__init__.py +1 -0
  90. package/hooks/blocking/pii_prevention_blocker_parts/config/repository_resolution_constants.py +28 -0
  91. package/hooks/blocking/pii_prevention_blocker_parts/repository_exemption.py +214 -0
  92. package/hooks/blocking/pii_prevention_blocker_parts/repository_resolution.py +208 -0
  93. package/hooks/blocking/pr_description_command_parser.py +8 -4
  94. package/hooks/blocking/precommit_code_rules_gate.py +3 -3
  95. package/hooks/blocking/tdd_enforcer.py +97 -608
  96. package/hooks/blocking/tdd_enforcer_parts/CLAUDE.md +30 -0
  97. package/hooks/blocking/tdd_enforcer_parts/__init__.py +1 -0
  98. package/hooks/blocking/tdd_enforcer_parts/candidate_paths.py +142 -0
  99. package/hooks/blocking/tdd_enforcer_parts/config/__init__.py +1 -0
  100. package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +32 -0
  101. package/hooks/blocking/tdd_enforcer_parts/content_analysis.py +268 -0
  102. package/hooks/blocking/tdd_enforcer_parts/decisions.py +92 -0
  103. package/hooks/blocking/tdd_enforcer_parts/freshness.py +80 -0
  104. package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +63 -0
  105. package/hooks/blocking/tdd_enforcer_parts/path_classification.py +119 -0
  106. package/hooks/blocking/tdd_enforcer_parts/tests/conftest.py +10 -0
  107. package/hooks/blocking/tdd_enforcer_parts/tests/test_candidate_paths.py +31 -0
  108. package/hooks/blocking/tdd_enforcer_parts/tests/test_content_analysis.py +30 -0
  109. package/hooks/blocking/tdd_enforcer_parts/tests/test_decisions.py +34 -0
  110. package/hooks/blocking/tdd_enforcer_parts/tests/test_freshness.py +28 -0
  111. package/hooks/blocking/tdd_enforcer_parts/tests/test_git_tracking.py +48 -0
  112. package/hooks/blocking/tdd_enforcer_parts/tests/test_path_classification.py +36 -0
  113. package/hooks/blocking/test_inventory_deadlock_resolution.py +154 -0
  114. package/hooks/blocking/test_pii_payload_scan.py +168 -0
  115. package/hooks/blocking/test_tdd_enforcer_restore.py +108 -0
  116. package/hooks/blocking/test_verifier_verdict_minter.py +55 -158
  117. package/hooks/blocking/tests/conftest.py +10 -0
  118. package/hooks/blocking/tests/test_pii_prevention_blocker.py +260 -0
  119. package/hooks/blocking/tests/test_repository_exemption.py +105 -0
  120. package/hooks/blocking/tests/test_repository_resolution.py +108 -0
  121. package/hooks/diagnostic/hook_log_extractor.py +12 -10
  122. package/hooks/git-hooks/post_commit.py +3 -4
  123. package/hooks/hooks_constants/CLAUDE.md +2 -2
  124. package/hooks/hooks_constants/banned_identifiers_constants.py +0 -1
  125. package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -1
  126. package/hooks/hooks_constants/local_identity.py +59 -8
  127. package/hooks/hooks_constants/pii_prevention_constants.py +0 -6
  128. package/hooks/hooks_constants/test_local_identity.py +105 -3
  129. package/hooks/pyproject.toml +13 -36
  130. package/hooks/session/plugin_data_dir_cleanup.py +0 -1
  131. package/hooks/validation/mypy_validator.py +2 -2
  132. package/hooks/validators/health_check.py +1 -0
  133. package/hooks/validators/mypy_integration.py +2 -0
  134. package/hooks/validators/ruff_integration.py +3 -0
  135. package/hooks/workflow/auto_formatter.py +5 -4
  136. package/package.json +1 -1
  137. package/scripts/CLAUDE.md +4 -0
  138. package/scripts/dev_env_scripts_constants/CLAUDE.md +6 -4
  139. package/scripts/dev_env_scripts_constants/code_review_constants.py +71 -0
  140. package/scripts/dev_env_scripts_constants/grok_worker_constants.py +435 -0
  141. package/scripts/dev_env_scripts_constants/timing.py +7 -1
  142. package/scripts/grok_headless_runner.py +294 -0
  143. package/scripts/grok_worker_preflight.py +410 -0
  144. package/scripts/invoke_code_review.py +463 -0
  145. package/scripts/resolve_worker_spawn.py +619 -0
  146. package/scripts/spawn_grok_batch.py +672 -0
  147. package/scripts/test_grok_headless_runner.py +626 -0
  148. package/scripts/test_grok_worker_preflight.py +1054 -0
  149. package/scripts/test_invoke_code_review.py +672 -0
  150. package/scripts/test_resolve_worker_spawn.py +1014 -0
  151. package/scripts/test_spawn_grok_batch.py +1017 -0
  152. package/skills/CLAUDE.md +5 -3
  153. package/skills/_shared/pr-loop/scripts/build_audit_prompt.py +72 -13
  154. package/skills/_shared/pr-loop/scripts/build_fix_prompt.py +121 -14
  155. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/path_resolver_constants.py +78 -0
  156. package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +121 -0
  157. package/skills/_shared/pr-loop/scripts/test_build_fix_prompt.py +196 -6
  158. package/skills/autoconverge/CLAUDE.md +3 -3
  159. package/skills/autoconverge/SKILL.md +9 -3
  160. package/skills/autoconverge/reference/CLAUDE.md +2 -2
  161. package/skills/autoconverge/reference/convergence.md +33 -11
  162. package/skills/autoconverge/reference/stop-conditions.md +16 -5
  163. package/skills/autoconverge/workflow/CLAUDE.md +2 -1
  164. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +7 -2
  165. package/skills/autoconverge/workflow/converge.codex-gate.test.mjs +300 -0
  166. package/skills/autoconverge/workflow/converge.contract.test.mjs +5 -5
  167. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +29 -29
  168. package/skills/autoconverge/workflow/converge.fix-progress.test.mjs +1 -1
  169. package/skills/autoconverge/workflow/converge.mjs +200 -16
  170. package/skills/bugteam/CLAUDE.md +2 -2
  171. package/skills/bugteam/CONSTRAINTS.md +3 -2
  172. package/skills/bugteam/PROMPTS.md +7 -6
  173. package/skills/bugteam/SKILL.md +18 -13
  174. package/skills/bugteam/reference/audit-and-teammates.md +215 -35
  175. package/skills/bugteam/reference/design-rationale.md +1 -1
  176. package/skills/bugteam/reference/obstacles/CLAUDE.md +1 -1
  177. package/skills/bugteam/reference/team-setup.md +8 -2
  178. package/skills/codex-review/CLAUDE.md +46 -0
  179. package/skills/codex-review/SKILL.md +181 -0
  180. package/skills/codex-review/reference/CLAUDE.md +15 -0
  181. package/skills/codex-review/reference/cli-contract.md +253 -0
  182. package/skills/codex-review/reference/loop-integration.md +118 -0
  183. package/skills/codex-review/scripts/codex_down_classifier.py +98 -0
  184. package/skills/codex-review/scripts/codex_review_scripts_constants/CLAUDE.md +18 -0
  185. package/skills/codex-review/scripts/codex_review_scripts_constants/__init__.py +1 -0
  186. package/skills/codex-review/scripts/codex_review_scripts_constants/classifier_constants.py +35 -0
  187. package/skills/codex-review/scripts/codex_review_scripts_constants/codex_usage_probe_constants.py +86 -0
  188. package/skills/codex-review/scripts/codex_review_scripts_constants/findings_constants.py +18 -0
  189. package/skills/codex-review/scripts/codex_review_scripts_constants/run_constants.py +45 -0
  190. package/skills/codex-review/scripts/codex_usage_probe.py +573 -0
  191. package/skills/codex-review/scripts/fixtures/auth_failure_synthetic.txt +1 -0
  192. package/skills/codex-review/scripts/fixtures/config_load_failure_v0.125.0.txt +1 -0
  193. package/skills/codex-review/scripts/fixtures/freeform_findings_v0.144.3.txt +6 -0
  194. package/skills/codex-review/scripts/fixtures/model_rejection_v0.125.0.jsonl +5 -0
  195. package/skills/codex-review/scripts/fixtures/structured_findings.txt +13 -0
  196. package/skills/codex-review/scripts/fixtures/success_stream_v0.144.3.jsonl +6 -0
  197. package/skills/codex-review/scripts/fixtures/unknown_failure_synthetic.txt +1 -0
  198. package/skills/codex-review/scripts/fixtures/usage_limit_synthetic.txt +1 -0
  199. package/skills/codex-review/scripts/parse_codex_findings.py +207 -0
  200. package/skills/codex-review/scripts/run_codex_review.py +415 -0
  201. package/skills/codex-review/scripts/test_codex_down_classifier.py +143 -0
  202. package/skills/codex-review/scripts/test_codex_usage_probe.py +678 -0
  203. package/skills/codex-review/scripts/test_parse_codex_findings.py +130 -0
  204. package/skills/codex-review/scripts/test_run_codex_review.py +812 -0
  205. package/skills/codex-review/test_skill_scaffold.py +192 -0
  206. package/skills/grok-spawn/CLAUDE.md +28 -0
  207. package/skills/grok-spawn/SKILL.md +226 -0
  208. package/skills/grok-spawn/reference/flag-profiles.md +132 -0
  209. package/skills/grok-spawn/reference/worker-briefs.md +152 -0
  210. package/skills/grokify/SKILL.md +9 -1
  211. package/skills/grokify/capability-claims.test.mjs +28 -0
  212. package/skills/grokify/evals/README.md +72 -0
  213. package/skills/grokify/evals/parse-payload.test.mjs +171 -0
  214. package/skills/grokify/evals/run-capability-evals.mjs +545 -0
  215. package/skills/orchestrator/SKILL.md +42 -29
  216. package/skills/orchestrator-refresh/SKILL.md +17 -9
  217. package/skills/pr-converge/SKILL.md +34 -13
  218. package/skills/pr-converge/reference/convergence-gates.md +42 -15
  219. package/skills/pr-converge/reference/fix-protocol.md +1 -1
  220. package/skills/pr-converge/reference/ground-rules.md +1 -1
  221. package/skills/pr-converge/reference/per-tick.md +130 -42
  222. package/skills/pr-converge/reference/state-schema.md +10 -0
  223. package/skills/pr-converge/scripts/CLAUDE.md +2 -0
  224. package/skills/pr-converge/scripts/_pr_converge_path_setup.py +5 -1
  225. package/skills/pr-converge/scripts/check_convergence.py +605 -29
  226. package/skills/pr-converge/scripts/check_convergence_availability.py +232 -0
  227. package/skills/pr-converge/scripts/check_convergence_gates.py +279 -235
  228. package/skills/pr-converge/scripts/check_convergence_thread_gates.py +1 -1
  229. package/skills/pr-converge/scripts/pr_converge_scripts_constants/convergence_gate_constants.py +36 -2
  230. package/skills/pr-converge/scripts/test__pr_converge_path_setup.py +4 -0
  231. package/skills/pr-converge/scripts/test_check_convergence.py +71 -3
  232. package/skills/pr-converge/scripts/test_check_convergence_availability.py +326 -0
  233. package/skills/pr-converge/scripts/test_check_convergence_codex.py +507 -0
  234. package/skills/pr-converge/scripts/test_check_convergence_contract.py +89 -17
  235. package/skills/pr-converge/scripts/test_check_convergence_fixture.py +179 -0
  236. package/skills/pr-converge/scripts/test_check_convergence_gates.py +84 -68
  237. package/skills/pr-converge/scripts/test_check_convergence_thread_gates.py +24 -0
  238. package/skills/pr-converge/test_step5_host_branch.py +106 -0
  239. package/skills/pr-loop-cloud-transport/SKILL.md +2 -0
  240. package/skills/reviewer-gates/SKILL.md +7 -5
  241. package/skills/team-advisor/SKILL.md +7 -7
@@ -0,0 +1,28 @@
1
+ # claude_md_orphan_file_blocker_parts
2
+
3
+ The concern modules `claude_md_orphan_file_blocker.py` wires together to block a
4
+ per-directory `CLAUDE.md` that names a file absent from its subtree. The entry
5
+ hook imports them and re-exports their surface for the test suite.
6
+
7
+ ## Modules
8
+
9
+ | File | Purpose |
10
+ |---|---|
11
+ | `references.py` | Extracts the bare filenames a table cell names and the scripts a fenced run command invokes, honoring the `../` relative-path exemptions |
12
+ | `subtree_scan.py` | Resolves the scan root and reports which referenced filenames are absent from it, with a bounded walk plus a direct probe fallback |
13
+ | `scan_plan.py` | Builds the Write/Edit/MultiEdit scan plan and collects the orphan filenames the change introduces, excluding pre-existing orphans |
14
+ | `decision.py` | Builds the deny payload listing the missing files and closing with the retry hint, and emits the decision JSON |
15
+ | `__init__.py` | Package marker |
16
+
17
+ ## Subdirectories
18
+
19
+ | Entry | Description |
20
+ |---|---|
21
+ | `config/` | The region-join newline, the retry hint the deny reason closes with, and the missing-name join separator (`orphan_blocker_constants.py`) |
22
+ | `tests/` | pytest suite with one test module per module above |
23
+
24
+ ## Running tests
25
+
26
+ ```bash
27
+ python -m pytest packages/claude-dev-env/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/
28
+ ```
@@ -0,0 +1 @@
1
+ """Concern modules the ``claude_md_orphan_file_blocker`` entry hook wires together."""
@@ -0,0 +1 @@
1
+ """Configuration home for the CLAUDE.md orphan-file blocker parts constants."""
@@ -0,0 +1,18 @@
1
+ """Constants for the CLAUDE.md orphan-file blocker parts.
2
+
3
+ Holds the newline that joins a region's lines before scanning it for a
4
+ relative-path source, the separator that joins the missing filenames in the deny
5
+ reason, and the retry hint the deny reason closes with once the denied inventory
6
+ row has been recorded as a pending intent.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ NEWLINE_JOIN_SEPARATOR: str = "\n"
12
+
13
+ MISSING_NAME_JOIN_SEPARATOR: str = ", "
14
+
15
+ ROW_FIRST_RETRY_HINT: str = (
16
+ " This inventory row has been recorded: create the file it names now, and the "
17
+ "row will be allowed when you retry it."
18
+ )
@@ -0,0 +1,81 @@
1
+ """Build, record, and emit the CLAUDE.md orphan-file blocker's deny decision.
2
+
3
+ The deny reason lists each missing file and closes with the retry hint telling
4
+ the author to create the file now, since this row has been recorded.
5
+ """
6
+
7
+ import json
8
+ import sys
9
+ from typing import TextIO
10
+
11
+ from claude_md_orphan_file_blocker_parts.config.orphan_blocker_constants import (
12
+ MISSING_NAME_JOIN_SEPARATOR,
13
+ ROW_FIRST_RETRY_HINT,
14
+ )
15
+
16
+ from hooks_constants.claude_md_orphan_file_blocker_constants import (
17
+ ORPHAN_FILE_ADDITIONAL_CONTEXT,
18
+ ORPHAN_FILE_MESSAGE_TEMPLATE,
19
+ ORPHAN_FILE_SYSTEM_MESSAGE,
20
+ )
21
+ from hooks_constants.hook_block_logger import log_hook_block
22
+
23
+
24
+ def deny_orphan_files(
25
+ tool_name: str, file_path: str, directory: str, all_missing_filenames: list[str]
26
+ ) -> None:
27
+ """Build, record, and emit the deny decision listing the missing files.
28
+
29
+ Args:
30
+ tool_name: The intercepted tool name, for the block log record.
31
+ file_path: The destination path of the CLAUDE.md.
32
+ directory: The resolved CLAUDE.md directory.
33
+ all_missing_filenames: The referenced filenames absent from the subtree.
34
+ """
35
+ block_payload = build_block_payload(all_missing_filenames, directory)
36
+ log_hook_block(
37
+ calling_hook_name="claude_md_orphan_file_blocker.py",
38
+ hook_event="PreToolUse",
39
+ block_reason=block_payload["hookSpecificOutput"]["permissionDecisionReason"],
40
+ tool_name=tool_name,
41
+ offending_input_preview=file_path,
42
+ )
43
+ emit_hook_result(block_payload, sys.stdout)
44
+
45
+
46
+ def build_block_payload(all_missing_filenames: list[str], directory: str) -> dict:
47
+ """Build the PreToolUse deny payload listing each missing filename.
48
+
49
+ Args:
50
+ all_missing_filenames: The referenced filenames absent from the subtree.
51
+ directory: The directory that holds the target CLAUDE.md.
52
+
53
+ Returns:
54
+ The hook-result dictionary the harness reads to deny the write, whose
55
+ reason closes with the retry hint.
56
+ """
57
+ formatted_missing = MISSING_NAME_JOIN_SEPARATOR.join(
58
+ f"`{each_name}`" for each_name in all_missing_filenames
59
+ )
60
+ reason = ORPHAN_FILE_MESSAGE_TEMPLATE.format(directory=directory, missing=formatted_missing)
61
+ return {
62
+ "hookSpecificOutput": {
63
+ "hookEventName": "PreToolUse",
64
+ "permissionDecision": "deny",
65
+ "permissionDecisionReason": reason + ROW_FIRST_RETRY_HINT,
66
+ "additionalContext": ORPHAN_FILE_ADDITIONAL_CONTEXT,
67
+ },
68
+ "systemMessage": ORPHAN_FILE_SYSTEM_MESSAGE,
69
+ "suppressOutput": True,
70
+ }
71
+
72
+
73
+ def emit_hook_result(all_hook_data: dict, output_stream: TextIO) -> None:
74
+ """Write the hook result JSON to the given output stream.
75
+
76
+ Args:
77
+ all_hook_data: The hook-result dictionary to serialize.
78
+ output_stream: The stream the harness reads the decision from.
79
+ """
80
+ output_stream.write(json.dumps(all_hook_data) + "\n")
81
+ output_stream.flush()
@@ -0,0 +1,307 @@
1
+ """Extract the file references a per-directory CLAUDE.md makes.
2
+
3
+ Two kinds of reference are collected: a bare filename a first-column table cell
4
+ names in backticks, and a script an interpreter invocation inside a fenced run
5
+ command names (``python script.py``). A table block or run-command fence that
6
+ declares an explicit ``../`` relative-path source is left alone.
7
+ """
8
+
9
+ import os
10
+
11
+ from claude_md_orphan_file_blocker_parts.config.orphan_blocker_constants import (
12
+ NEWLINE_JOIN_SEPARATOR,
13
+ )
14
+
15
+ from hooks_constants.claude_md_orphan_file_blocker_constants import (
16
+ ALL_REFERENCED_FILE_EXTENSIONS,
17
+ ALL_RUN_COMMAND_SCRIPT_EXTENSIONS,
18
+ CLAUDE_MD_FILENAME,
19
+ CODE_FENCE_PATTERN,
20
+ FIRST_COLUMN_BACKTICK_PATTERN,
21
+ REGION_BOUNDARY_PATTERN,
22
+ RELATIVE_PATH_SOURCE_PATTERN,
23
+ RUN_COMMAND_SCRIPT_PATTERN,
24
+ SEPARATOR_CELL_PATTERN,
25
+ TABLE_ROW_PATTERN,
26
+ )
27
+
28
+
29
+ def is_claude_md_file(file_path: str) -> bool:
30
+ """Return whether *file_path* names a per-directory ``CLAUDE.md`` file.
31
+
32
+ Args:
33
+ file_path: The destination path of the write or edit.
34
+
35
+ Returns:
36
+ True when the path's basename is exactly ``CLAUDE.md``.
37
+ """
38
+ return os.path.basename(file_path) == CLAUDE_MD_FILENAME
39
+
40
+
41
+ def _first_table_cell(table_line: str) -> str:
42
+ """Return the trimmed text of the first column cell in a markdown table row.
43
+
44
+ Args:
45
+ table_line: A single line that begins with a pipe character.
46
+
47
+ Returns:
48
+ The text between the leading pipe and the next pipe, stripped.
49
+ """
50
+ after_leading_pipe = table_line.strip().lstrip("|")
51
+ first_cell, _, _ = after_leading_pipe.partition("|")
52
+ return first_cell.strip()
53
+
54
+
55
+ def _referenced_filename_in_cell(cell_text: str) -> str | None:
56
+ """Return the bare filename a table cell references, when it has one.
57
+
58
+ A cell references a bare filename only when its first backticked token has no
59
+ path separator, is not a slash-command, and carries a known file extension.
60
+
61
+ Args:
62
+ cell_text: The trimmed text of a first-column table cell.
63
+
64
+ Returns:
65
+ The bare filename to verify in the subtree, or None when the cell names
66
+ no bare file.
67
+ """
68
+ backtick_match = FIRST_COLUMN_BACKTICK_PATTERN.search(cell_text)
69
+ if backtick_match is None:
70
+ return None
71
+ inner_text = backtick_match.group(1).strip()
72
+ if not inner_text or inner_text.startswith("/"):
73
+ return None
74
+ if "/" in inner_text or "\\" in inner_text:
75
+ return None
76
+ _, extension = os.path.splitext(inner_text)
77
+ if extension.lower() not in ALL_REFERENCED_FILE_EXTENSIONS:
78
+ return None
79
+ return inner_text
80
+
81
+
82
+ def _filename_in_table_row(table_line: str) -> str | None:
83
+ """Return the bare filename a markdown table row references, when it has one.
84
+
85
+ Args:
86
+ table_line: A single line that begins with a pipe character.
87
+
88
+ Returns:
89
+ The bare filename in the row's first column, or None when the row is a
90
+ header-separator row or names no bare file.
91
+ """
92
+ first_cell = _first_table_cell(table_line)
93
+ if not first_cell or SEPARATOR_CELL_PATTERN.match(first_cell):
94
+ return None
95
+ return _referenced_filename_in_cell(first_cell)
96
+
97
+
98
+ def _declares_relative_path_source(text: str) -> bool:
99
+ """Return whether *text* declares an explicit relative-path file source.
100
+
101
+ Args:
102
+ text: A table block together with the prose that introduces it.
103
+
104
+ Returns:
105
+ True when *text* contains a ``../`` relative-path token.
106
+ """
107
+ return RELATIVE_PATH_SOURCE_PATTERN.search(text) is not None
108
+
109
+
110
+ def _block_filenames(all_region_lines: list[str], all_block_lines: list[str]) -> list[str]:
111
+ """Return the bare filenames a table block contributes, honoring its exemption.
112
+
113
+ Args:
114
+ all_region_lines: The prose lines accumulated before this block.
115
+ all_block_lines: The consecutive table rows that form the block.
116
+
117
+ Returns:
118
+ Each bare filename the block's first-column cells name, or an empty list
119
+ when the block (with its introducing region) declares a ``../`` source.
120
+ """
121
+ if not all_block_lines:
122
+ return []
123
+ block_region = NEWLINE_JOIN_SEPARATOR.join(all_region_lines + all_block_lines)
124
+ if _declares_relative_path_source(block_region):
125
+ return []
126
+ block_filenames: list[str] = []
127
+ for each_line in all_block_lines:
128
+ each_filename = _filename_in_table_row(each_line)
129
+ if each_filename is not None:
130
+ block_filenames.append(each_filename)
131
+ return block_filenames
132
+
133
+
134
+ def find_referenced_filenames(content: str) -> list[str]:
135
+ """Return each bare filename a CLAUDE.md table references, in order.
136
+
137
+ Args:
138
+ content: The CLAUDE.md content being written.
139
+
140
+ Returns:
141
+ Each referenced filename from a non-exempt table block, in order.
142
+ """
143
+ referenced_filenames: list[str] = []
144
+ pending_region: list[str] = []
145
+ current_block: list[str] = []
146
+ is_inside_code_fence = False
147
+ for each_line in content.splitlines():
148
+ if CODE_FENCE_PATTERN.match(each_line) is not None:
149
+ is_inside_code_fence = not is_inside_code_fence
150
+ continue
151
+ if is_inside_code_fence:
152
+ continue
153
+ if TABLE_ROW_PATTERN.match(each_line) is not None:
154
+ current_block.append(each_line)
155
+ continue
156
+ if current_block:
157
+ referenced_filenames.extend(_block_filenames(pending_region, current_block))
158
+ current_block, pending_region = [], []
159
+ if REGION_BOUNDARY_PATTERN.match(each_line) is not None:
160
+ pending_region = []
161
+ pending_region.append(each_line)
162
+ referenced_filenames.extend(_block_filenames(pending_region, current_block))
163
+ return referenced_filenames
164
+
165
+
166
+ def _script_basename_from_token(script_token: str) -> str | None:
167
+ """Return the bare basename one captured script token names, when it has one.
168
+
169
+ A path-qualified script keeps only its final segment. A script named by an
170
+ explicit ``../`` relative-path source is exempt and yields None, as does a
171
+ token whose basename carries no recognized script extension.
172
+
173
+ Args:
174
+ script_token: One script path captured from an interpreter invocation.
175
+
176
+ Returns:
177
+ The bare script basename, or None when the token is relative-path-sourced
178
+ or carries no recognized extension.
179
+ """
180
+ if _declares_relative_path_source(script_token):
181
+ return None
182
+ basename = os.path.basename(script_token.replace("\\", "/").rstrip("/"))
183
+ if not basename:
184
+ return None
185
+ _, extension = os.path.splitext(basename)
186
+ if extension.lower() not in ALL_RUN_COMMAND_SCRIPT_EXTENSIONS:
187
+ return None
188
+ return basename
189
+
190
+
191
+ def _next_quote_state(open_quote_character: str, each_character: str) -> str:
192
+ """Return the open-quote state after reading one character.
193
+
194
+ Args:
195
+ open_quote_character: The currently open quote character, or empty.
196
+ each_character: The character just read.
197
+
198
+ Returns:
199
+ The empty string when a matching close quote is read, the character when
200
+ it opens a quote, or the unchanged state otherwise.
201
+ """
202
+ if open_quote_character:
203
+ return "" if each_character == open_quote_character else open_quote_character
204
+ if each_character in ("'", '"'):
205
+ return each_character
206
+ return open_quote_character
207
+
208
+
209
+ def _command_text_before_comment(fenced_line: str) -> str:
210
+ """Return the runnable portion of a fenced line, with any shell comment removed.
211
+
212
+ A shell comment starts at a ``#`` that begins a word outside any quoted span
213
+ and runs to end of line; the text from that ``#`` is dropped.
214
+
215
+ Args:
216
+ fenced_line: A single line drawn from inside a fenced code block.
217
+
218
+ Returns:
219
+ The line truncated at its first unquoted word-leading ``#``, or the whole
220
+ line when it carries no such comment marker.
221
+ """
222
+ open_quote_character = ""
223
+ previous_character = ""
224
+ for each_index, each_character in enumerate(fenced_line):
225
+ starts_word = each_index == 0 or previous_character.isspace()
226
+ if not open_quote_character and each_character == "#" and starts_word:
227
+ return fenced_line[:each_index]
228
+ open_quote_character = _next_quote_state(open_quote_character, each_character)
229
+ previous_character = each_character
230
+ return fenced_line
231
+
232
+
233
+ def _run_command_filenames_in_line(fenced_line: str) -> list[str]:
234
+ """Return each script basename the interpreter invocations on this line name.
235
+
236
+ A shell comment's text contributes nothing; a chained line contributes each
237
+ invocation's basename in order.
238
+
239
+ Args:
240
+ fenced_line: A single line drawn from inside a fenced code block.
241
+
242
+ Returns:
243
+ Each bare script basename the line's invocations name, in order.
244
+ """
245
+ runnable_text = _command_text_before_comment(fenced_line)
246
+ line_filenames: list[str] = []
247
+ for each_match in RUN_COMMAND_SCRIPT_PATTERN.finditer(runnable_text):
248
+ each_basename = _script_basename_from_token(each_match.group(1).strip())
249
+ if each_basename is not None:
250
+ line_filenames.append(each_basename)
251
+ return line_filenames
252
+
253
+
254
+ class _RunCommandScan:
255
+ """Walks CLAUDE.md lines and collects the script basenames fenced run commands name.
256
+
257
+ Attributes:
258
+ filenames: The script basenames collected so far, in order.
259
+ """
260
+
261
+ def __init__(self) -> None:
262
+ self.filenames: list[str] = []
263
+ self._pending_region: list[str] = []
264
+ self._is_inside_fence = False
265
+ self._is_region_relative_path_sourced = False
266
+
267
+ def _toggle_fence(self) -> None:
268
+ """Open or close the current fence, capturing its region's ``../`` source flag."""
269
+ if not self._is_inside_fence:
270
+ self._is_region_relative_path_sourced = _declares_relative_path_source(
271
+ NEWLINE_JOIN_SEPARATOR.join(self._pending_region)
272
+ )
273
+ else:
274
+ self._pending_region = []
275
+ self._is_inside_fence = not self._is_inside_fence
276
+
277
+ def consume(self, each_line: str) -> None:
278
+ """Fold one CLAUDE.md line into the scan state.
279
+
280
+ Args:
281
+ each_line: The next line of the CLAUDE.md content.
282
+ """
283
+ if CODE_FENCE_PATTERN.match(each_line) is not None:
284
+ self._toggle_fence()
285
+ return
286
+ if self._is_inside_fence:
287
+ if not self._is_region_relative_path_sourced:
288
+ self.filenames.extend(_run_command_filenames_in_line(each_line))
289
+ return
290
+ if REGION_BOUNDARY_PATTERN.match(each_line) is not None:
291
+ self._pending_region = []
292
+ self._pending_region.append(each_line)
293
+
294
+
295
+ def find_run_command_filenames(content: str) -> list[str]:
296
+ """Return each script basename a fenced run command invokes, in order.
297
+
298
+ Args:
299
+ content: The CLAUDE.md content being written.
300
+
301
+ Returns:
302
+ Each script basename a fenced run command names, in the order it appears.
303
+ """
304
+ scan = _RunCommandScan()
305
+ for each_line in content.splitlines():
306
+ scan.consume(each_line)
307
+ return scan.filenames
@@ -0,0 +1,124 @@
1
+ """Build the orphan scan plan for a Write, Edit, or MultiEdit of a CLAUDE.md.
2
+
3
+ For a Write the plan scans the full new content with no baseline. For an Edit or
4
+ MultiEdit it scans the reconstructed post-edit file and records the orphans the
5
+ file already held, so a pre-existing orphan on an untouched line is excluded and
6
+ only an orphan the edit introduces is reported.
7
+ """
8
+
9
+ from pathlib import Path
10
+
11
+ from claude_md_orphan_file_blocker_parts.subtree_scan import find_missing_filenames
12
+
13
+ from hooks_constants.claude_md_orphan_file_blocker_constants import (
14
+ MAX_ORPHAN_FILE_ISSUES,
15
+ )
16
+ from hooks_constants.multi_edit_reconstruction import (
17
+ apply_edits,
18
+ edits_for_tool,
19
+ )
20
+
21
+
22
+ def _read_existing_file_content(file_path: str) -> str | None:
23
+ """Return the current on-disk content of *file_path*, or None when unreadable.
24
+
25
+ Args:
26
+ file_path: The path of the file the edit targets.
27
+
28
+ Returns:
29
+ The file's text, or None when the file is missing or cannot be decoded.
30
+ """
31
+ try:
32
+ return Path(file_path).read_text(encoding="utf-8")
33
+ except (OSError, UnicodeDecodeError):
34
+ return None
35
+
36
+
37
+ def _edit_fragments(all_edits: list[dict]) -> list[str]:
38
+ """Return each MultiEdit ``new_string`` fragment present as a non-empty string.
39
+
40
+ Args:
41
+ all_edits: The MultiEdit ``edits`` list.
42
+
43
+ Returns:
44
+ Every ``new_string`` value that is a non-empty string, in list order.
45
+ """
46
+ all_fragments: list[str] = []
47
+ for each_edit in all_edits:
48
+ if not isinstance(each_edit, dict):
49
+ continue
50
+ new_string = each_edit.get("new_string", "")
51
+ if isinstance(new_string, str) and new_string:
52
+ all_fragments.append(new_string)
53
+ return all_fragments
54
+
55
+
56
+ class _OrphanScanPlan:
57
+ """The contents to scan for orphans and the pre-existing orphans to exclude.
58
+
59
+ Attributes:
60
+ candidate_contents: Each content string whose table rows are scanned.
61
+ baseline_missing_filenames: The orphan filenames the file already held
62
+ before this edit; reporting excludes them.
63
+ """
64
+
65
+ def __init__(
66
+ self, all_candidate_contents: list[str], all_baseline_missing_filenames: set[str]
67
+ ) -> None:
68
+ self.candidate_contents = all_candidate_contents
69
+ self.baseline_missing_filenames = all_baseline_missing_filenames
70
+
71
+
72
+ def build_orphan_scan_plan(
73
+ tool_name: str, tool_input: dict, file_path: str, claude_md_directory: Path
74
+ ) -> _OrphanScanPlan:
75
+ """Return the contents to scan and the pre-existing orphans to exclude.
76
+
77
+ Args:
78
+ tool_name: The intercepted tool — ``Write``, ``Edit``, or ``MultiEdit``.
79
+ tool_input: The tool's input payload.
80
+ file_path: The destination path of the write or edit.
81
+ claude_md_directory: The directory that holds the target CLAUDE.md.
82
+
83
+ Returns:
84
+ The scan plan pairing candidate contents with the baseline orphan set.
85
+ """
86
+ if tool_name == "Write":
87
+ content = tool_input.get("content", "")
88
+ candidate_contents = [content] if isinstance(content, str) and content else []
89
+ return _OrphanScanPlan(candidate_contents, set())
90
+ all_edits = edits_for_tool(tool_name, tool_input)
91
+ existing_content = _read_existing_file_content(file_path)
92
+ if existing_content is None:
93
+ return _OrphanScanPlan(_edit_fragments(all_edits), set())
94
+ baseline_missing = set(find_missing_filenames(existing_content, claude_md_directory))
95
+ return _OrphanScanPlan([apply_edits(existing_content, all_edits)], baseline_missing)
96
+
97
+
98
+ def collect_missing_filenames(scan_plan: _OrphanScanPlan, claude_md_directory: Path) -> list[str]:
99
+ """Return every orphan filename the scan plan introduces, excluding baselines.
100
+
101
+ Args:
102
+ scan_plan: The candidate contents to scan paired with the baseline orphan
103
+ set to exclude.
104
+ claude_md_directory: The directory that holds the target CLAUDE.md.
105
+
106
+ Returns:
107
+ Each introduced orphan filename in first-seen order with duplicates
108
+ removed, capped at the issue budget.
109
+ """
110
+ all_candidate_missing: list[str] = []
111
+ for each_content in scan_plan.candidate_contents:
112
+ all_candidate_missing.extend(find_missing_filenames(each_content, claude_md_directory))
113
+ missing_filenames: list[str] = []
114
+ already_reported: set[str] = set()
115
+ for each_filename in all_candidate_missing:
116
+ if each_filename in scan_plan.baseline_missing_filenames:
117
+ continue
118
+ if each_filename in already_reported:
119
+ continue
120
+ already_reported.add(each_filename)
121
+ missing_filenames.append(each_filename)
122
+ if len(missing_filenames) >= MAX_ORPHAN_FILE_ISSUES:
123
+ break
124
+ return missing_filenames