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
@@ -17,691 +17,81 @@ rows or that fence's commands are left alone — the exemption is scoped to the
17
17
  region, not the whole file.
18
18
  """
19
19
 
20
- import json
21
- import os
22
20
  import sys
23
21
  from pathlib import Path
24
- from typing import TextIO
25
22
 
26
- _hooks_dir = str(Path(__file__).resolve().parent.parent)
27
- if _hooks_dir not in sys.path:
28
- sys.path.insert(0, _hooks_dir)
29
-
30
- from hooks_constants.claude_md_orphan_file_blocker_constants import ( # noqa: E402
31
- ALL_NOISE_DIRECTORY_NAMES,
32
- ALL_REFERENCED_FILE_EXTENSIONS,
33
- ALL_RUN_COMMAND_SCRIPT_EXTENSIONS,
34
- CLAUDE_MD_FILENAME,
35
- CODE_FENCE_PATTERN,
36
- FIRST_COLUMN_BACKTICK_PATTERN,
37
- MAX_ORPHAN_FILE_ISSUES,
38
- MAX_SUBTREE_FILES_SCANNED,
39
- ORPHAN_FILE_ADDITIONAL_CONTEXT,
40
- ORPHAN_FILE_MESSAGE_TEMPLATE,
41
- ORPHAN_FILE_SYSTEM_MESSAGE,
42
- REGION_BOUNDARY_PATTERN,
43
- RELATIVE_PATH_SOURCE_PATTERN,
44
- RUN_COMMAND_SCRIPT_PATTERN,
45
- SEPARATOR_CELL_PATTERN,
46
- TABLE_ROW_PATTERN,
47
- )
48
- from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
49
- from hooks_constants.multi_edit_reconstruction import ( # noqa: E402
50
- apply_edits,
51
- edits_for_tool,
52
- )
53
- from hooks_constants.pre_tool_use_stdin import ( # noqa: E402
54
- read_hook_input_dictionary_from_stdin,
55
- )
56
-
57
-
58
- def is_claude_md_file(file_path: str) -> bool:
59
- """Return whether *file_path* names a per-directory ``CLAUDE.md`` file.
60
-
61
- Args:
62
- file_path: The destination path of the write or edit.
63
-
64
- Returns:
65
- True when the path's basename is exactly ``CLAUDE.md``.
66
- """
67
- return os.path.basename(file_path) == CLAUDE_MD_FILENAME
68
-
69
-
70
- def _first_table_cell(table_line: str) -> str:
71
- """Return the trimmed text of the first column cell in a markdown table row.
72
-
73
- Args:
74
- table_line: A single line that begins with a pipe character.
75
-
76
- Returns:
77
- The text between the leading pipe and the next pipe, stripped of
78
- surrounding whitespace; an empty string when no cell is present.
79
- """
80
- after_leading_pipe = table_line.strip().lstrip("|")
81
- first_cell, _, _ = after_leading_pipe.partition("|")
82
- return first_cell.strip()
83
-
84
-
85
- def _referenced_filename_in_cell(cell_text: str) -> str | None:
86
- """Return the bare filename a table cell references, when it has one.
87
-
88
- A cell references a bare filename only when its first backticked token has no
89
- path separator, is not a slash-command, and carries a known file extension.
90
- Subdirectory cells (trailing slash) and paths fall outside this scope and
91
- yield None.
92
-
93
- Args:
94
- cell_text: The trimmed text of a first-column table cell.
95
-
96
- Returns:
97
- The bare filename to verify in the subtree, or None when the cell names
98
- no bare file.
99
- """
100
- backtick_match = FIRST_COLUMN_BACKTICK_PATTERN.search(cell_text)
101
- if backtick_match is None:
102
- return None
103
- inner_text = backtick_match.group(1).strip()
104
- if not inner_text:
105
- return None
106
- if inner_text.startswith("/"):
107
- return None
108
- if "/" in inner_text or "\\" in inner_text:
109
- return None
110
- _, extension = os.path.splitext(inner_text)
111
- if extension.lower() not in ALL_REFERENCED_FILE_EXTENSIONS:
112
- return None
113
- return inner_text
114
-
115
-
116
- def _filename_in_table_row(table_line: str) -> str | None:
117
- """Return the bare filename a markdown table row references, when it has one.
118
-
119
- Args:
120
- table_line: A single line that begins with a pipe character.
121
-
122
- Returns:
123
- The bare filename in the row's first column, or None when the row is a
124
- header-separator row or names no bare file.
125
- """
126
- first_cell = _first_table_cell(table_line)
127
- if not first_cell or SEPARATOR_CELL_PATTERN.match(first_cell):
128
- return None
129
- return _referenced_filename_in_cell(first_cell)
130
-
131
-
132
- def _declares_relative_path_source(text: str) -> bool:
133
- """Return whether *text* declares an explicit relative-path file source.
134
-
135
- A ``../`` token signals that a table documents files in a sibling tree,
136
- referenced by path rather than living in the CLAUDE.md's own subtree. The
137
- block that carries such a token is out of scope, since its files legitimately
138
- sit outside the subtree.
139
-
140
- Args:
141
- text: A table block together with the prose that introduces it.
142
-
143
- Returns:
144
- True when *text* contains a ``../`` relative-path token.
145
- """
146
- return RELATIVE_PATH_SOURCE_PATTERN.search(text) is not None
147
-
148
-
149
- def find_referenced_filenames(content: str) -> list[str]:
150
- """Return each bare filename a CLAUDE.md table references, in order.
151
-
152
- Walks the content line by line, grouping it into table blocks. A table block
153
- is a maximal run of consecutive markdown table rows; the prose region since
154
- the most recent heading introduces it, so prose under one section never
155
- introduces a table under a later section. A line inside a fenced code block
156
- (between a ``` or ~~~
157
- fence pair) is example or sample text, not a live table, so it contributes
158
- nothing and never ends a block. A block whose introducing region or own rows
159
- declare an explicit relative-path source (a ``../`` token) documents files in
160
- a sibling tree, so its rows are skipped — the exemption is scoped to that
161
- region and block, not the whole file. Every remaining block contributes the
162
- bare filename each first-column cell names.
163
-
164
- Args:
165
- content: The CLAUDE.md content being written.
166
-
167
- Returns:
168
- Each referenced filename from a non-exempt table block, in the order it
169
- appears; duplicates preserved.
170
- """
171
- referenced_filenames: list[str] = []
172
- pending_region: list[str] = []
173
- current_block: list[str] = []
174
- is_inside_code_fence = False
175
- for each_line in content.splitlines():
176
- if CODE_FENCE_PATTERN.match(each_line) is not None:
177
- is_inside_code_fence = not is_inside_code_fence
178
- continue
179
- if is_inside_code_fence:
180
- continue
181
- if TABLE_ROW_PATTERN.match(each_line) is not None:
182
- current_block.append(each_line)
183
- continue
184
- if current_block:
185
- referenced_filenames.extend(_block_filenames(pending_region, current_block))
186
- current_block = []
187
- pending_region = []
188
- if REGION_BOUNDARY_PATTERN.match(each_line) is not None:
189
- pending_region = []
190
- pending_region.append(each_line)
191
- referenced_filenames.extend(_block_filenames(pending_region, current_block))
192
- return referenced_filenames
193
-
194
-
195
- def _block_filenames(all_region_lines: list[str], all_block_lines: list[str]) -> list[str]:
196
- """Return the bare filenames a table block contributes, honoring its exemption.
197
-
198
- Args:
199
- all_region_lines: The prose lines accumulated before this block.
200
- all_block_lines: The consecutive table rows that form the block.
201
-
202
- Returns:
203
- Each bare filename the block's first-column cells name, or an empty list
204
- when the block (with its introducing region) declares a ``../`` source.
205
- """
206
- if not all_block_lines:
207
- return []
208
- block_region = "\n".join(all_region_lines + all_block_lines)
209
- if _declares_relative_path_source(block_region):
210
- return []
211
- block_filenames: list[str] = []
212
- for each_line in all_block_lines:
213
- each_filename = _filename_in_table_row(each_line)
214
- if each_filename is not None:
215
- block_filenames.append(each_filename)
216
- return block_filenames
217
-
218
-
219
- def _script_basename_from_token(script_token: str) -> str | None:
220
- """Return the bare basename one captured script token names, when it has one.
221
-
222
- A path-qualified script keeps only its final segment, so ``tools/build_bundle.mjs``
223
- yields ``build_bundle.mjs`` — the basename the directory file would match. A
224
- script named by an explicit relative-path source (a ``../`` token in the token
225
- itself, as in ``../shared/preflight.py``) lives outside the subtree by design, so
226
- it is exempt and yields None. This token-scoped ``../`` check is one of two
227
- relative-path exemptions: ``find_run_command_filenames`` also skips a whole fence
228
- whose introducing prose region declares a ``../`` source, which is the
229
- region-scoped counterpart to the table-cell exemption. A token whose basename
230
- carries no recognized script extension yields None.
231
-
232
- Args:
233
- script_token: One script path captured from an interpreter invocation.
234
-
235
- Returns:
236
- The bare script basename, or None when the token is relative-path-sourced
237
- or carries no recognized extension.
238
- """
239
- if _declares_relative_path_source(script_token):
240
- return None
241
- basename = os.path.basename(script_token.replace("\\", "/").rstrip("/"))
242
- if not basename:
243
- return None
244
- _, extension = os.path.splitext(basename)
245
- if extension.lower() not in ALL_RUN_COMMAND_SCRIPT_EXTENSIONS:
246
- return None
247
- return basename
248
-
249
-
250
- def _command_text_before_comment(fenced_line: str) -> str:
251
- """Return the runnable portion of a fenced line, with any shell comment removed.
252
-
253
- A shell comment starts at a ``#`` that begins a word — at the line's start or
254
- after whitespace — outside any quoted span, and runs to end of line. The text
255
- after it documents a removed or alternative command rather than a runnable
256
- contract, so it is dropped: a full-line comment yields an empty string, and an
257
- inline trailing comment (``python real.py # was: python old.py``) yields only
258
- the part before the ``#``. A ``#`` inside single or double quotes, or one
259
- glued to a preceding non-space character, stays in the runnable text.
260
-
261
- Args:
262
- fenced_line: A single line drawn from inside a fenced code block.
263
-
264
- Returns:
265
- The line truncated at its first unquoted word-leading ``#``, or the whole
266
- line when it carries no such comment marker.
267
- """
268
- open_quote_character = ""
269
- previous_character = ""
270
- for each_index, each_character in enumerate(fenced_line):
271
- if open_quote_character:
272
- if each_character == open_quote_character:
273
- open_quote_character = ""
274
- elif each_character in ("'", '"'):
275
- open_quote_character = each_character
276
- elif each_character == "#" and (each_index == 0 or previous_character.isspace()):
277
- return fenced_line[:each_index]
278
- previous_character = each_character
279
- return fenced_line
280
-
281
-
282
- def _run_command_filenames_in_line(fenced_line: str) -> list[str]:
283
- """Return each script basename the interpreter invocations on this line name.
284
-
285
- A fenced run-command line such as ``python tools/verify.py --flag`` invokes a
286
- script file; this returns that script's bare basename. A line that chains
287
- several invocations with a shell separator (``python deploy.py && node build.mjs``,
288
- ``python first.py; python second.py``) contributes each invocation's basename in
289
- order. A shell comment — a ``#`` that begins a word outside any quoted span,
290
- whether it opens the line or trails a command (``python real.py # was python
291
- old.py``) — documents a removed or alternative command rather than a runnable
292
- contract, so the text from that ``#`` to end of line contributes nothing. A line
293
- with no interpreter invocation contributes nothing.
294
-
295
- Args:
296
- fenced_line: A single line drawn from inside a fenced code block.
297
-
298
- Returns:
299
- Each bare script basename the line's invocations name, in order; empty when
300
- the line is a comment or names no runnable script.
301
- """
302
- runnable_text = _command_text_before_comment(fenced_line)
303
- line_filenames: list[str] = []
304
- for each_match in RUN_COMMAND_SCRIPT_PATTERN.finditer(runnable_text):
305
- each_basename = _script_basename_from_token(each_match.group(1).strip())
306
- if each_basename is not None:
307
- line_filenames.append(each_basename)
308
- return line_filenames
309
-
310
-
311
- def find_run_command_filenames(content: str) -> list[str]:
312
- """Return each script basename a fenced run command invokes, in order.
313
-
314
- Walks the content line by line, inspecting only lines inside a fenced code
315
- block (between a ``` or ~~~ fence pair). A fenced run-command line that invokes
316
- an interpreter on a script (``python script.py``, ``node bundle.mjs``,
317
- ``pwsh build.ps1``) contributes that script's bare basename, and a line that
318
- chains several invocations with a shell separator contributes each one. A line
319
- outside any fence is prose, not a live command, and contributes nothing — an
320
- inline ``python x.py`` in a sentence is documentation, not a runnable contract.
321
- A fence whose introducing region declares an explicit relative-path source (a
322
- ``../`` token in the prose accumulated since the prior fence or heading)
323
- documents commands that run scripts in a sibling tree, so that fence's run
324
- commands are skipped — mirroring the region-scoped table-cell ``../``
325
- exemption. The region resets when a fence closes, so the exemption is scoped to
326
- that fence alone: a second fence under the same heading, introduced by prose
327
- that names no ``../`` source, is still inspected.
328
-
329
- Args:
330
- content: The CLAUDE.md content being written.
331
-
332
- Returns:
333
- Each script basename a fenced run command names, in the order it appears;
334
- duplicates preserved.
335
- """
336
- run_command_filenames: list[str] = []
337
- pending_region: list[str] = []
338
- is_inside_code_fence = False
339
- is_region_relative_path_sourced = False
340
- for each_line in content.splitlines():
341
- if CODE_FENCE_PATTERN.match(each_line) is not None:
342
- if not is_inside_code_fence:
343
- is_region_relative_path_sourced = _declares_relative_path_source(
344
- "\n".join(pending_region)
345
- )
346
- else:
347
- pending_region = []
348
- is_inside_code_fence = not is_inside_code_fence
349
- continue
350
- if is_inside_code_fence:
351
- if not is_region_relative_path_sourced:
352
- run_command_filenames.extend(_run_command_filenames_in_line(each_line))
353
- continue
354
- if REGION_BOUNDARY_PATTERN.match(each_line) is not None:
355
- pending_region = []
356
- pending_region.append(each_line)
357
- return run_command_filenames
358
-
359
-
360
- def _resolve_scan_root(claude_md_directory: Path) -> Path:
361
- """Return the directory whose subtree bounds the filename existence search.
362
-
363
- The search root is the CLAUDE.md directory's parent when that parent exists,
364
- so a table that documents files in a sibling directory or one level up still
365
- resolves them. When the directory has no distinct parent, the CLAUDE.md
366
- directory itself is the root.
367
-
368
- Args:
369
- claude_md_directory: The directory that holds the target CLAUDE.md.
370
-
371
- Returns:
372
- The directory to walk when collecting candidate filenames.
373
- """
374
- parent_directory = claude_md_directory.parent
375
- if parent_directory == claude_md_directory:
376
- return claude_md_directory
377
- return parent_directory
378
-
379
-
380
- class _SubtreeScan:
381
- """The basenames a bounded subtree walk collected and whether it ran complete.
382
-
383
- Attributes:
384
- all_basenames: Each file basename the walk reached.
385
- was_scan_complete: True when the walk visited the whole subtree within the
386
- budget, so ``all_basenames`` is authoritative; False when the budget
387
- truncated the walk, so a basename's absence from the set is not proof
388
- of its absence on disk.
389
- """
390
-
391
- def __init__(self, all_basenames: set[str], was_scan_complete: bool) -> None:
392
- self.all_basenames = all_basenames
393
- self.was_scan_complete = was_scan_complete
394
-
395
-
396
- def _is_under_noise_directory(scan_root: Path, candidate_path: Path) -> bool:
397
- """Return whether *candidate_path* lies inside a pruned noise directory.
398
-
399
- A noise directory (``.git``, ``__pycache__``, ``node_modules``, and the test
400
- and lint caches) holds volatile generated files that no CLAUDE.md table
401
- documents, so the walk skips them. This keeps generated files out of the
402
- basename set and keeps them from consuming the scan budget.
403
-
404
- Args:
405
- scan_root: The directory the walk descends from.
406
- candidate_path: A path the walk yielded under the scan root.
407
-
408
- Returns:
409
- True when any path segment below *scan_root* names a noise directory.
410
- """
411
- try:
412
- relative_segments = candidate_path.relative_to(scan_root).parts
413
- except ValueError:
414
- relative_segments = candidate_path.parts
415
- return any(each_segment in ALL_NOISE_DIRECTORY_NAMES for each_segment in relative_segments)
416
-
417
-
418
- def _scan_subtree_basenames(scan_root: Path) -> _SubtreeScan:
419
- """Return the bounded basename scan of *scan_root*, skipping unreadable entries.
420
-
421
- Walks the subtree collecting each file's basename, stopping once the scan
422
- budget is reached. A path inside a noise directory is pruned, and a per-entry
423
- stat error skips that entry. The result records whether the walk completed
424
- within the budget, so the caller knows whether the set is authoritative.
425
-
426
- Args:
427
- scan_root: The directory whose subtree bounds the existence search.
428
-
429
- Returns:
430
- The collected basenames paired with the scan-completeness flag.
431
- """
432
- all_basenames: set[str] = set()
433
- scanned_count = 0
434
- for each_path in scan_root.rglob("*"):
435
- if _is_under_noise_directory(scan_root, each_path):
436
- continue
437
- try:
438
- if not each_path.is_file():
439
- continue
440
- except OSError:
441
- continue
442
- all_basenames.add(each_path.name)
443
- scanned_count += 1
444
- if scanned_count >= MAX_SUBTREE_FILES_SCANNED:
445
- return _SubtreeScan(all_basenames, was_scan_complete=False)
446
- return _SubtreeScan(all_basenames, was_scan_complete=True)
447
-
448
-
449
- def _filename_exists_under(scan_root: Path, filename: str) -> bool:
450
- """Return whether a file with basename *filename* exists anywhere under root.
451
-
452
- A direct probe that resolves one filename deterministically even when the
453
- bounded subtree walk was truncated. A match inside a noise directory is pruned
454
- so the probe agrees with the bounded walk, and an unreadable entry mid-walk is
455
- skipped.
456
-
457
- Args:
458
- scan_root: The directory whose subtree bounds the existence search.
459
- filename: The bare basename to look for.
460
-
461
- Returns:
462
- True when at least one matching file is reachable under the scan root.
463
- """
464
- for each_match in scan_root.rglob(filename):
465
- if _is_under_noise_directory(scan_root, each_match):
466
- continue
467
- try:
468
- if each_match.is_file():
469
- return True
470
- except OSError:
471
- continue
472
- return False
473
-
474
-
475
- def _present_referenced_filenames(
476
- all_referenced_filenames: list[str], scan_root: Path
477
- ) -> set[str]:
478
- """Return the referenced filenames that exist under the scan root.
479
-
480
- A complete bounded walk yields an authoritative basename set, so membership in
481
- it decides presence. When the budget truncated the walk, a name absent from
482
- the partial set is probed directly with ``rglob`` so a truncated slice never
483
- produces a false-missing verdict.
484
-
485
- Args:
486
- all_referenced_filenames: The bare filenames a CLAUDE.md table names.
487
- scan_root: The directory whose subtree bounds the existence search.
488
-
489
- Returns:
490
- The subset of *all_referenced_filenames* that resolve to an existing file.
491
- """
492
- subtree_scan = _scan_subtree_basenames(scan_root)
493
- present_filenames: set[str] = set()
494
- for each_filename in all_referenced_filenames:
495
- if each_filename in subtree_scan.all_basenames:
496
- present_filenames.add(each_filename)
497
- continue
498
- if subtree_scan.was_scan_complete:
499
- continue
500
- if _filename_exists_under(scan_root, each_filename):
501
- present_filenames.add(each_filename)
502
- return present_filenames
503
-
504
-
505
- def find_missing_filenames(content: str, claude_md_directory: Path) -> list[str]:
506
- """Return the referenced filenames absent from the CLAUDE.md's scan root.
507
-
508
- A referenced filename comes from two sources: a bare filename a table cell
509
- names, and a script a fenced run command invokes (``python script.py``). It is
510
- missing when it exists nowhere under the scan root — the CLAUDE.md directory's
511
- parent (or the directory itself when it has no distinct parent), which covers
512
- the directory, its subdirectories, and its siblings. A table block that
513
- declares an explicit relative-path source (a ``../`` token in the block or the
514
- prose that introduces it) yields no findings for that block's rows, since those
515
- files legitimately live elsewhere; an unrelated block in the same file is still
516
- checked. When the content references no bare filename, no findings result and
517
- the subtree walk is skipped. A filesystem error that halts the whole subtree
518
- walk yields no findings (fail open), so an unreadable tree never blocks a write.
519
-
520
- Args:
521
- content: The CLAUDE.md content being written.
522
- claude_md_directory: The directory that holds the target CLAUDE.md.
523
-
524
- Returns:
525
- Each referenced filename with no matching file under the scan root, in
526
- first-seen order with duplicates removed, capped at the issue budget.
527
- """
528
- referenced_filenames = find_referenced_filenames(content) + find_run_command_filenames(
529
- content
23
+ try:
24
+ _hooks_root_directory = str(Path(__file__).resolve().parent.parent)
25
+ _blocking_directory = str(Path(__file__).resolve().parent)
26
+ for each_bootstrap_directory in (_hooks_root_directory, _blocking_directory):
27
+ if each_bootstrap_directory not in sys.path:
28
+ sys.path.insert(0, each_bootstrap_directory)
29
+ from claude_md_orphan_file_blocker_parts import (
30
+ decision,
31
+ references,
32
+ scan_plan,
33
+ subtree_scan,
530
34
  )
531
- if not referenced_filenames:
532
- return []
533
- scan_root = _resolve_scan_root(claude_md_directory)
534
- try:
535
- present_filenames = _present_referenced_filenames(referenced_filenames, scan_root)
536
- except OSError:
537
- return []
538
- missing_filenames: list[str] = []
539
- already_reported: set[str] = set()
540
- for each_filename in referenced_filenames:
541
- if each_filename in already_reported:
542
- continue
543
- if each_filename in present_filenames:
544
- continue
545
- already_reported.add(each_filename)
546
- missing_filenames.append(each_filename)
547
- if len(missing_filenames) >= MAX_ORPHAN_FILE_ISSUES:
548
- break
549
- return missing_filenames
550
-
551
-
552
- def _read_existing_file_content(file_path: str) -> str | None:
553
- """Return the current on-disk content of *file_path*, or None when unreadable.
554
-
555
- Args:
556
- file_path: The path of the file the edit targets.
557
-
558
- Returns:
559
- The file's text, or None when the file is missing or cannot be decoded.
560
- """
561
- try:
562
- return Path(file_path).read_text(encoding="utf-8")
563
- except (OSError, UnicodeDecodeError):
564
- return None
565
-
566
-
567
- def _edit_fragments(all_edits: list[dict]) -> list[str]:
568
- """Return each MultiEdit ``new_string`` fragment present as a non-empty string.
569
-
570
- Args:
571
- all_edits: The MultiEdit ``edits`` list.
572
-
573
- Returns:
574
- Every ``new_string`` value that is a non-empty string, in list order.
575
- """
576
- all_fragments: list[str] = []
577
- for each_edit in all_edits:
578
- if not isinstance(each_edit, dict):
579
- continue
580
- new_string = each_edit.get("new_string", "")
581
- if isinstance(new_string, str) and new_string:
582
- all_fragments.append(new_string)
583
- return all_fragments
584
-
585
-
586
- class _OrphanScanPlan:
587
- """The contents to scan for orphans and the pre-existing orphans to exclude.
588
-
589
- Attributes:
590
- candidate_contents: Each content string whose table rows are scanned.
591
- baseline_missing_filenames: The orphan filenames the file already held
592
- before this edit; reporting excludes them so an unrelated edit over a
593
- pre-existing orphan on an untouched line is not blocked. Empty for a
594
- Write (the whole file is replaced) and for an edit whose existing file
595
- cannot be read.
596
- """
597
-
598
- def __init__(
599
- self, all_candidate_contents: list[str], all_baseline_missing_filenames: set[str]
600
- ) -> None:
601
- self.candidate_contents = all_candidate_contents
602
- self.baseline_missing_filenames = all_baseline_missing_filenames
603
-
604
-
605
- def _build_orphan_scan_plan(
606
- tool_name: str, tool_input: dict, file_path: str, claude_md_directory: Path
607
- ) -> _OrphanScanPlan:
608
- """Return the contents to scan and the pre-existing orphans to exclude.
609
-
610
- For Write the candidate is the full new content with no baseline, so every
611
- orphan it names is introduced by the write. For Edit and MultiEdit the
612
- candidate is the existing file with the replacements applied, so a ``../``
613
- source line outside the edited rows still exempts that table block; the
614
- baseline is the orphan set the existing file already held, so a pre-existing
615
- orphan on an untouched line is excluded and only an orphan the edit introduces
616
- is reported. When the existing file cannot be read, the raw ``new_string``
617
- fragment(s) are scanned with no baseline, so an orphan the edit itself adds is
618
- still caught.
619
-
620
- Args:
621
- tool_name: The intercepted tool — ``Write``, ``Edit``, or ``MultiEdit``.
622
- tool_input: The tool's input payload.
623
- file_path: The destination path of the write or edit.
624
- claude_md_directory: The directory that holds the target CLAUDE.md.
625
-
626
- Returns:
627
- The scan plan pairing candidate contents with the baseline orphan set.
628
- """
629
- if tool_name == "Write":
630
- content = tool_input.get("content", "")
631
- candidate_contents = [content] if isinstance(content, str) and content else []
632
- return _OrphanScanPlan(candidate_contents, set())
633
- all_edits = edits_for_tool(tool_name, tool_input)
634
- existing_content = _read_existing_file_content(file_path)
635
- if existing_content is None:
636
- return _OrphanScanPlan(_edit_fragments(all_edits), set())
637
- baseline_missing = set(find_missing_filenames(existing_content, claude_md_directory))
638
- return _OrphanScanPlan([apply_edits(existing_content, all_edits)], baseline_missing)
639
-
640
-
641
- def _collect_missing_filenames(scan_plan: _OrphanScanPlan, claude_md_directory: Path) -> list[str]:
642
- """Return every orphan filename the scan plan introduces, excluding baselines.
643
-
644
- An orphan the file already held before the edit (a member of the plan's
645
- baseline set) is excluded, so an unrelated edit over a pre-existing orphan on
646
- an untouched line reports nothing.
647
-
648
- Args:
649
- scan_plan: The candidate contents to scan paired with the baseline orphan
650
- set to exclude.
651
- claude_md_directory: The directory that holds the target CLAUDE.md.
652
-
653
- Returns:
654
- Each introduced orphan filename in first-seen order with duplicates
655
- removed, capped at the issue budget.
656
- """
657
- missing_filenames: list[str] = []
658
- already_reported: set[str] = set()
659
- for each_content in scan_plan.candidate_contents:
660
- for each_filename in find_missing_filenames(each_content, claude_md_directory):
661
- if each_filename in scan_plan.baseline_missing_filenames:
662
- continue
663
- if each_filename in already_reported:
664
- continue
665
- already_reported.add(each_filename)
666
- missing_filenames.append(each_filename)
667
- if len(missing_filenames) >= MAX_ORPHAN_FILE_ISSUES:
668
- return missing_filenames
669
- return missing_filenames
670
-
671
-
672
- def _build_block_payload(all_missing_filenames: list[str], directory: str) -> dict:
673
- """Build the PreToolUse deny payload listing each missing filename.
674
-
675
- Args:
676
- all_missing_filenames: The referenced filenames absent from the subtree.
677
- directory: The directory that holds the target CLAUDE.md.
678
-
679
- Returns:
680
- The hook-result dictionary the harness reads to deny the write.
681
- """
682
- formatted_missing = ", ".join(f"`{each_name}`" for each_name in all_missing_filenames)
683
- reason = ORPHAN_FILE_MESSAGE_TEMPLATE.format(directory=directory, missing=formatted_missing)
684
- return {
685
- "hookSpecificOutput": {
686
- "hookEventName": "PreToolUse",
687
- "permissionDecision": "deny",
688
- "permissionDecisionReason": reason,
689
- "additionalContext": ORPHAN_FILE_ADDITIONAL_CONTEXT,
690
- },
691
- "systemMessage": ORPHAN_FILE_SYSTEM_MESSAGE,
692
- "suppressOutput": True,
693
- }
694
-
695
-
696
- def _emit_hook_result(all_hook_data: dict, output_stream: TextIO) -> None:
697
- """Write the hook result JSON to the given output stream.
35
+ from claude_md_orphan_file_blocker_parts.subtree_scan import (
36
+ MAX_SUBTREE_FILES_SCANNED, # noqa: F401
37
+ )
38
+ from inventory_intent_records import records
698
39
 
699
- Args:
700
- all_hook_data: The hook-result dictionary to serialize.
701
- output_stream: The stream the harness reads the decision from.
702
- """
703
- output_stream.write(json.dumps(all_hook_data) + "\n")
704
- output_stream.flush()
40
+ from hooks_constants.pre_tool_use_stdin import (
41
+ read_hook_input_dictionary_from_stdin,
42
+ )
43
+ except ImportError as import_error:
44
+ raise ImportError(
45
+ "claude_md_orphan_file_blocker: cannot import its parts submodules; "
46
+ "ensure the hooks directory is importable."
47
+ ) from import_error
48
+
49
+
50
+ is_claude_md_file = references.is_claude_md_file
51
+ find_referenced_filenames = references.find_referenced_filenames
52
+ find_run_command_filenames = references.find_run_command_filenames
53
+ find_missing_filenames = subtree_scan.find_missing_filenames
54
+ build_orphan_scan_plan = scan_plan.build_orphan_scan_plan
55
+ collect_missing_filenames = scan_plan.collect_missing_filenames
56
+ deny_orphan_files = decision.deny_orphan_files
57
+
58
+
59
+ def _partition_by_file_intent(
60
+ session_id: str, directory: str, all_missing_filenames: list[str]
61
+ ) -> tuple[list[str], list[str]]:
62
+ """Split missing filenames into those with a pending file intent and those without."""
63
+ matched_filenames = [
64
+ each_filename
65
+ for each_filename in all_missing_filenames
66
+ if records.has_fresh_file_intent(session_id, directory, each_filename)
67
+ ]
68
+ unmatched_filenames = [
69
+ each_filename
70
+ for each_filename in all_missing_filenames
71
+ if each_filename not in matched_filenames
72
+ ]
73
+ return matched_filenames, unmatched_filenames
74
+
75
+
76
+ def _emit_orphan_decision(
77
+ input_data: dict,
78
+ tool_name: str,
79
+ file_path: str,
80
+ directory: str,
81
+ all_missing_filenames: list[str],
82
+ ) -> None:
83
+ """Consume covered rows and deny any genuine orphan the change introduces."""
84
+ session_id = str(input_data.get("session_id") or "")
85
+ matched_filenames, unmatched_filenames = _partition_by_file_intent(
86
+ session_id, directory, all_missing_filenames
87
+ )
88
+ if unmatched_filenames:
89
+ for each_filename in unmatched_filenames:
90
+ records.record_row_intent(session_id, directory, each_filename)
91
+ deny_orphan_files(tool_name, file_path, directory, unmatched_filenames)
92
+ return
93
+ for each_filename in matched_filenames:
94
+ records.consume_file_intent(session_id, directory, each_filename)
705
95
 
706
96
 
707
97
  def main() -> None:
@@ -709,43 +99,29 @@ def main() -> None:
709
99
  input_data = read_hook_input_dictionary_from_stdin()
710
100
  if input_data is None:
711
101
  sys.exit(0)
712
-
713
102
  tool_name = input_data.get("tool_name", "")
714
103
  if not isinstance(tool_name, str):
715
104
  sys.exit(0)
716
-
717
105
  tool_input = input_data.get("tool_input", {})
718
106
  if not isinstance(tool_input, dict):
719
107
  sys.exit(0)
720
-
721
108
  if tool_name not in ("Write", "Edit", "MultiEdit"):
722
109
  sys.exit(0)
723
-
724
110
  file_path = tool_input.get("file_path", "")
725
111
  if not isinstance(file_path, str) or not is_claude_md_file(file_path):
726
112
  sys.exit(0)
727
-
728
113
  claude_md_directory = Path(file_path).resolve().parent
729
114
  if not claude_md_directory.is_dir():
730
115
  sys.exit(0)
731
-
732
- scan_plan = _build_orphan_scan_plan(tool_name, tool_input, file_path, claude_md_directory)
733
- if not scan_plan.candidate_contents:
116
+ scan_plan_result = build_orphan_scan_plan(tool_name, tool_input, file_path, claude_md_directory)
117
+ if not scan_plan_result.candidate_contents:
734
118
  sys.exit(0)
735
-
736
- missing_filenames = _collect_missing_filenames(scan_plan, claude_md_directory)
119
+ missing_filenames = collect_missing_filenames(scan_plan_result, claude_md_directory)
737
120
  if not missing_filenames:
738
121
  sys.exit(0)
739
-
740
- block_payload = _build_block_payload(missing_filenames, str(claude_md_directory))
741
- log_hook_block(
742
- calling_hook_name="claude_md_orphan_file_blocker.py",
743
- hook_event="PreToolUse",
744
- block_reason=block_payload["hookSpecificOutput"]["permissionDecisionReason"],
745
- tool_name=tool_name,
746
- offending_input_preview=file_path,
122
+ _emit_orphan_decision(
123
+ input_data, tool_name, file_path, str(claude_md_directory), missing_filenames
747
124
  )
748
- _emit_hook_result(block_payload, sys.stdout)
749
125
  sys.exit(0)
750
126
 
751
127