claude-dev-env 1.94.0 → 2.0.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 (280) hide show
  1. package/_shared/advisor/CLAUDE.md +2 -2
  2. package/_shared/advisor/advisor-protocol.md +41 -33
  3. package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +17 -13
  4. package/_shared/advisor/scripts/model_tier_run_validator.py +27 -18
  5. package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +95 -25
  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/CLAUDE.md +10 -1
  42. package/bin/ever-shipped-skills.mjs +73 -0
  43. package/bin/expand_home_directory_tokens.mjs +1 -1
  44. package/bin/install.mjs +140 -7
  45. package/bin/install.prune.test.mjs +457 -0
  46. package/docs/CODE_RULES.md +1 -1
  47. package/hooks/advisory/refactor_guard.py +3 -4
  48. package/hooks/blocking/CLAUDE.md +7 -1
  49. package/hooks/blocking/block_main_commit.py +2 -2
  50. package/hooks/blocking/claude_md_orphan_file_blocker.py +75 -699
  51. package/hooks/blocking/claude_md_orphan_file_blocker_parts/CLAUDE.md +28 -0
  52. package/hooks/blocking/claude_md_orphan_file_blocker_parts/__init__.py +1 -0
  53. package/hooks/blocking/claude_md_orphan_file_blocker_parts/config/__init__.py +1 -0
  54. package/hooks/blocking/claude_md_orphan_file_blocker_parts/config/orphan_blocker_constants.py +18 -0
  55. package/hooks/blocking/claude_md_orphan_file_blocker_parts/decision.py +81 -0
  56. package/hooks/blocking/claude_md_orphan_file_blocker_parts/references.py +307 -0
  57. package/hooks/blocking/claude_md_orphan_file_blocker_parts/scan_plan.py +124 -0
  58. package/hooks/blocking/claude_md_orphan_file_blocker_parts/subtree_scan.py +179 -0
  59. package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/conftest.py +10 -0
  60. package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_decision.py +34 -0
  61. package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_references.py +42 -0
  62. package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_scan_plan.py +27 -0
  63. package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_subtree_scan.py +30 -0
  64. package/hooks/blocking/code_rules_boolean_mustcheck.py +1 -1
  65. package/hooks/blocking/code_rules_enforcer.py +4 -0
  66. package/hooks/blocking/code_rules_mock_completeness.py +1 -1
  67. package/hooks/blocking/code_rules_optional_params.py +2 -2
  68. package/hooks/blocking/code_rules_shared.py +83 -1
  69. package/hooks/blocking/code_rules_test_assertions.py +1 -1
  70. package/hooks/blocking/code_rules_test_layout.py +9 -3
  71. package/hooks/blocking/code_rules_typeddict_stub.py +1 -1
  72. package/hooks/blocking/gh_pr_author_enforcer.py +1 -1
  73. package/hooks/blocking/inventory_intent_records/CLAUDE.md +26 -0
  74. package/hooks/blocking/inventory_intent_records/__init__.py +1 -0
  75. package/hooks/blocking/inventory_intent_records/config/__init__.py +1 -0
  76. package/hooks/blocking/inventory_intent_records/config/intent_records_constants.py +20 -0
  77. package/hooks/blocking/inventory_intent_records/records.py +271 -0
  78. package/hooks/blocking/inventory_intent_records/tests/conftest.py +10 -0
  79. package/hooks/blocking/inventory_intent_records/tests/test_records.py +80 -0
  80. package/hooks/blocking/package_inventory_stale_blocker.py +54 -384
  81. package/hooks/blocking/package_inventory_stale_blocker_parts/CLAUDE.md +26 -0
  82. package/hooks/blocking/package_inventory_stale_blocker_parts/__init__.py +1 -0
  83. package/hooks/blocking/package_inventory_stale_blocker_parts/config/__init__.py +1 -0
  84. package/hooks/blocking/package_inventory_stale_blocker_parts/config/inventory_blocker_constants.py +16 -0
  85. package/hooks/blocking/package_inventory_stale_blocker_parts/decision.py +84 -0
  86. package/hooks/blocking/package_inventory_stale_blocker_parts/inventory_detection.py +307 -0
  87. package/hooks/blocking/package_inventory_stale_blocker_parts/tests/conftest.py +10 -0
  88. package/hooks/blocking/package_inventory_stale_blocker_parts/tests/test_decision.py +38 -0
  89. package/hooks/blocking/package_inventory_stale_blocker_parts/tests/test_inventory_detection.py +61 -0
  90. package/hooks/blocking/pii_payload_scan.py +138 -42
  91. package/hooks/blocking/pii_prevention_blocker.py +185 -291
  92. package/hooks/blocking/pii_prevention_blocker_parts/CLAUDE.md +24 -0
  93. package/hooks/blocking/pii_prevention_blocker_parts/__init__.py +1 -0
  94. package/hooks/blocking/pii_prevention_blocker_parts/config/__init__.py +1 -0
  95. package/hooks/blocking/pii_prevention_blocker_parts/config/repository_resolution_constants.py +28 -0
  96. package/hooks/blocking/pii_prevention_blocker_parts/repository_exemption.py +214 -0
  97. package/hooks/blocking/pii_prevention_blocker_parts/repository_resolution.py +208 -0
  98. package/hooks/blocking/plain_language_blocker.py +138 -4
  99. package/hooks/blocking/pr_description_command_parser.py +8 -4
  100. package/hooks/blocking/precommit_code_rules_gate.py +3 -3
  101. package/hooks/blocking/sensitive_file_protector.py +114 -48
  102. package/hooks/blocking/tdd_enforcer.py +97 -601
  103. package/hooks/blocking/tdd_enforcer_parts/CLAUDE.md +30 -0
  104. package/hooks/blocking/tdd_enforcer_parts/__init__.py +1 -0
  105. package/hooks/blocking/tdd_enforcer_parts/candidate_paths.py +142 -0
  106. package/hooks/blocking/tdd_enforcer_parts/config/__init__.py +1 -0
  107. package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +32 -0
  108. package/hooks/blocking/tdd_enforcer_parts/content_analysis.py +268 -0
  109. package/hooks/blocking/tdd_enforcer_parts/decisions.py +92 -0
  110. package/hooks/blocking/tdd_enforcer_parts/freshness.py +80 -0
  111. package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +63 -0
  112. package/hooks/blocking/tdd_enforcer_parts/path_classification.py +119 -0
  113. package/hooks/blocking/tdd_enforcer_parts/tests/conftest.py +10 -0
  114. package/hooks/blocking/tdd_enforcer_parts/tests/test_candidate_paths.py +31 -0
  115. package/hooks/blocking/tdd_enforcer_parts/tests/test_content_analysis.py +30 -0
  116. package/hooks/blocking/tdd_enforcer_parts/tests/test_decisions.py +34 -0
  117. package/hooks/blocking/tdd_enforcer_parts/tests/test_freshness.py +28 -0
  118. package/hooks/blocking/tdd_enforcer_parts/tests/test_git_tracking.py +48 -0
  119. package/hooks/blocking/tdd_enforcer_parts/tests/test_path_classification.py +36 -0
  120. package/hooks/blocking/test_code_rules_enforcer_scratchpad.py +105 -0
  121. package/hooks/blocking/test_code_rules_shared.py +181 -0
  122. package/hooks/blocking/test_inventory_deadlock_resolution.py +154 -0
  123. package/hooks/blocking/test_pii_payload_scan.py +168 -0
  124. package/hooks/blocking/test_plain_language_blocker_allowlist.py +184 -0
  125. package/hooks/blocking/test_sensitive_file_protector.py +185 -0
  126. package/hooks/blocking/test_tdd_enforcer_restore.py +108 -0
  127. package/hooks/blocking/test_tdd_enforcer_scratchpad.py +105 -0
  128. package/hooks/blocking/tests/conftest.py +10 -0
  129. package/hooks/blocking/tests/test_pii_prevention_blocker.py +260 -0
  130. package/hooks/blocking/tests/test_repository_exemption.py +105 -0
  131. package/hooks/blocking/tests/test_repository_resolution.py +108 -0
  132. package/hooks/diagnostic/hook_log_extractor.py +12 -10
  133. package/hooks/git-hooks/post_commit.py +3 -4
  134. package/hooks/hooks_constants/CLAUDE.md +4 -2
  135. package/hooks/hooks_constants/banned_identifiers_constants.py +0 -1
  136. package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -1
  137. package/hooks/hooks_constants/harness_scratchpad_constants.py +17 -0
  138. package/hooks/hooks_constants/local_identity.py +59 -8
  139. package/hooks/hooks_constants/pii_prevention_constants.py +0 -6
  140. package/hooks/hooks_constants/plain_language_blocker_constants.py +5 -0
  141. package/hooks/hooks_constants/sensitive_file_protector_constants.py +42 -0
  142. package/hooks/hooks_constants/test_local_identity.py +105 -3
  143. package/hooks/pyproject.toml +52 -4
  144. package/hooks/session/plugin_data_dir_cleanup.py +0 -1
  145. package/hooks/validation/mypy_validator.py +2 -2
  146. package/hooks/validators/CLAUDE.md +1 -1
  147. package/hooks/validators/README.md +2 -0
  148. package/hooks/validators/health_check.py +1 -0
  149. package/hooks/validators/mypy_integration.py +2 -0
  150. package/hooks/validators/python_style_checks.py +114 -136
  151. package/hooks/validators/python_style_helpers.py +95 -0
  152. package/hooks/validators/ruff_integration.py +3 -0
  153. package/hooks/validators/test_python_style_checks.py +0 -164
  154. package/hooks/validators/test_python_style_checks_decorator_gap.py +119 -0
  155. package/hooks/validators/test_python_style_fixes.py +251 -0
  156. package/hooks/validators/test_python_style_helpers.py +125 -0
  157. package/hooks/workflow/auto_formatter.py +5 -4
  158. package/package.json +1 -1
  159. package/rules/CLAUDE.md +1 -0
  160. package/rules/anti-corollary-tests.md +69 -0
  161. package/rules/bdd.md +1 -3
  162. package/rules/code-reviews.md +1 -1
  163. package/rules/gh-paginate.md +1 -1
  164. package/rules/plain-language.md +2 -0
  165. package/scripts/CLAUDE.md +4 -0
  166. package/scripts/dev_env_scripts_constants/CLAUDE.md +6 -4
  167. package/scripts/dev_env_scripts_constants/code_review_constants.py +71 -0
  168. package/scripts/dev_env_scripts_constants/grok_worker_constants.py +435 -0
  169. package/scripts/dev_env_scripts_constants/timing.py +7 -1
  170. package/scripts/grok_headless_runner.py +294 -0
  171. package/scripts/grok_worker_preflight.py +410 -0
  172. package/scripts/invoke_code_review.py +463 -0
  173. package/scripts/resolve_worker_spawn.py +619 -0
  174. package/scripts/spawn_grok_batch.py +672 -0
  175. package/scripts/test_grok_headless_runner.py +626 -0
  176. package/scripts/test_grok_worker_preflight.py +1054 -0
  177. package/scripts/test_invoke_code_review.py +672 -0
  178. package/scripts/test_resolve_worker_spawn.py +1014 -0
  179. package/scripts/test_spawn_grok_batch.py +1017 -0
  180. package/skills/CLAUDE.md +6 -3
  181. package/skills/_shared/pr-loop/scripts/build_audit_prompt.py +72 -13
  182. package/skills/_shared/pr-loop/scripts/build_fix_prompt.py +121 -14
  183. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/path_resolver_constants.py +78 -0
  184. package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +121 -0
  185. package/skills/_shared/pr-loop/scripts/test_build_fix_prompt.py +196 -6
  186. package/skills/autoconverge/CLAUDE.md +3 -3
  187. package/skills/autoconverge/SKILL.md +9 -3
  188. package/skills/autoconverge/reference/CLAUDE.md +2 -2
  189. package/skills/autoconverge/reference/convergence.md +33 -11
  190. package/skills/autoconverge/reference/stop-conditions.md +16 -5
  191. package/skills/autoconverge/workflow/CLAUDE.md +2 -1
  192. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +7 -2
  193. package/skills/autoconverge/workflow/converge.codex-gate.test.mjs +300 -0
  194. package/skills/autoconverge/workflow/converge.contract.test.mjs +5 -5
  195. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +29 -29
  196. package/skills/autoconverge/workflow/converge.fix-progress.test.mjs +1 -1
  197. package/skills/autoconverge/workflow/converge.mjs +202 -18
  198. package/skills/bugteam/CLAUDE.md +2 -2
  199. package/skills/bugteam/CONSTRAINTS.md +3 -2
  200. package/skills/bugteam/PROMPTS.md +7 -6
  201. package/skills/bugteam/SKILL.md +18 -13
  202. package/skills/bugteam/reference/README.md +2 -3
  203. package/skills/bugteam/reference/audit-and-teammates.md +215 -35
  204. package/skills/bugteam/reference/design-rationale.md +1 -1
  205. package/skills/bugteam/reference/obstacles/CLAUDE.md +1 -1
  206. package/skills/bugteam/reference/team-setup.md +8 -2
  207. package/skills/closeout/SKILL.md +153 -0
  208. package/skills/closeout/reference/handoff-prompt-template.md +72 -0
  209. package/skills/closeout/reference/issue-body-templates.md +108 -0
  210. package/skills/closeout/reference/pii-redaction-checklist.md +36 -0
  211. package/skills/codex-review/CLAUDE.md +46 -0
  212. package/skills/codex-review/SKILL.md +181 -0
  213. package/skills/codex-review/reference/CLAUDE.md +15 -0
  214. package/skills/codex-review/reference/cli-contract.md +253 -0
  215. package/skills/codex-review/reference/loop-integration.md +118 -0
  216. package/skills/codex-review/scripts/codex_down_classifier.py +98 -0
  217. package/skills/codex-review/scripts/codex_review_scripts_constants/CLAUDE.md +18 -0
  218. package/skills/codex-review/scripts/codex_review_scripts_constants/__init__.py +1 -0
  219. package/skills/codex-review/scripts/codex_review_scripts_constants/classifier_constants.py +35 -0
  220. package/skills/codex-review/scripts/codex_review_scripts_constants/codex_usage_probe_constants.py +86 -0
  221. package/skills/codex-review/scripts/codex_review_scripts_constants/findings_constants.py +18 -0
  222. package/skills/codex-review/scripts/codex_review_scripts_constants/run_constants.py +45 -0
  223. package/skills/codex-review/scripts/codex_usage_probe.py +573 -0
  224. package/skills/codex-review/scripts/fixtures/auth_failure_synthetic.txt +1 -0
  225. package/skills/codex-review/scripts/fixtures/config_load_failure_v0.125.0.txt +1 -0
  226. package/skills/codex-review/scripts/fixtures/freeform_findings_v0.144.3.txt +6 -0
  227. package/skills/codex-review/scripts/fixtures/model_rejection_v0.125.0.jsonl +5 -0
  228. package/skills/codex-review/scripts/fixtures/structured_findings.txt +13 -0
  229. package/skills/codex-review/scripts/fixtures/success_stream_v0.144.3.jsonl +6 -0
  230. package/skills/codex-review/scripts/fixtures/unknown_failure_synthetic.txt +1 -0
  231. package/skills/codex-review/scripts/fixtures/usage_limit_synthetic.txt +1 -0
  232. package/skills/codex-review/scripts/parse_codex_findings.py +207 -0
  233. package/skills/codex-review/scripts/run_codex_review.py +415 -0
  234. package/skills/codex-review/scripts/test_codex_down_classifier.py +143 -0
  235. package/skills/codex-review/scripts/test_codex_usage_probe.py +678 -0
  236. package/skills/codex-review/scripts/test_parse_codex_findings.py +130 -0
  237. package/skills/codex-review/scripts/test_run_codex_review.py +812 -0
  238. package/skills/codex-review/test_skill_scaffold.py +192 -0
  239. package/skills/grok-spawn/CLAUDE.md +28 -0
  240. package/skills/grok-spawn/SKILL.md +226 -0
  241. package/skills/grok-spawn/reference/flag-profiles.md +132 -0
  242. package/skills/grok-spawn/reference/worker-briefs.md +152 -0
  243. package/skills/grokify/SKILL.md +9 -1
  244. package/skills/grokify/capability-claims.test.mjs +28 -0
  245. package/skills/grokify/evals/README.md +72 -0
  246. package/skills/grokify/evals/parse-payload.test.mjs +171 -0
  247. package/skills/grokify/evals/run-capability-evals.mjs +545 -0
  248. package/skills/orchestrator/SKILL.md +32 -22
  249. package/skills/orchestrator-refresh/SKILL.md +12 -8
  250. package/skills/pr-converge/CLAUDE.md +1 -1
  251. package/skills/pr-converge/SKILL.md +34 -13
  252. package/skills/pr-converge/reference/convergence-gates.md +42 -15
  253. package/skills/pr-converge/reference/fix-protocol.md +1 -1
  254. package/skills/pr-converge/reference/ground-rules.md +1 -1
  255. package/skills/pr-converge/reference/per-tick.md +130 -42
  256. package/skills/pr-converge/reference/state-schema.md +10 -0
  257. package/skills/pr-converge/scripts/CLAUDE.md +2 -0
  258. package/skills/pr-converge/scripts/_pr_converge_path_setup.py +5 -1
  259. package/skills/pr-converge/scripts/check_convergence.py +605 -29
  260. package/skills/pr-converge/scripts/check_convergence_availability.py +232 -0
  261. package/skills/pr-converge/scripts/check_convergence_gates.py +279 -235
  262. package/skills/pr-converge/scripts/check_convergence_thread_gates.py +1 -1
  263. package/skills/pr-converge/scripts/pr_converge_scripts_constants/convergence_gate_constants.py +36 -2
  264. package/skills/pr-converge/scripts/test__pr_converge_path_setup.py +4 -0
  265. package/skills/pr-converge/scripts/test_check_convergence.py +71 -3
  266. package/skills/pr-converge/scripts/test_check_convergence_availability.py +326 -0
  267. package/skills/pr-converge/scripts/test_check_convergence_codex.py +507 -0
  268. package/skills/pr-converge/scripts/test_check_convergence_contract.py +89 -17
  269. package/skills/pr-converge/scripts/test_check_convergence_fixture.py +179 -0
  270. package/skills/pr-converge/scripts/test_check_convergence_gates.py +84 -68
  271. package/skills/pr-converge/scripts/test_check_convergence_thread_gates.py +24 -0
  272. package/skills/pr-converge/test_step5_host_branch.py +106 -0
  273. package/skills/pr-fix-protocol/SKILL.md +65 -0
  274. package/skills/pr-loop-cloud-transport/SKILL.md +2 -0
  275. package/skills/reviewer-gates/SKILL.md +7 -5
  276. package/skills/skill-builder/references/skill-modularity.md +1 -1
  277. package/skills/team-advisor/SKILL.md +16 -12
  278. package/system-prompts/software-engineer.xml +7 -6
  279. package/hooks/validators/test_verify_paths.py +0 -32
  280. package/hooks/validators/verify_paths.py +0 -57
@@ -15,381 +15,75 @@ existing file, exempt files (``__init__.py``, ``conftest.py``, ``setup.py``,
15
15
  per-file inventory (such as ``config/`` or ``tests/``) are out of scope.
16
16
  """
17
17
 
18
- import json
19
18
  import os
20
19
  import sys
21
20
  from pathlib import Path
22
- from typing import TextIO
23
21
 
24
- _hooks_dir = str(Path(__file__).resolve().parent.parent)
25
- if _hooks_dir not in sys.path:
26
- sys.path.insert(0, _hooks_dir)
27
-
28
- from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
29
- from hooks_constants.package_inventory_stale_blocker_constants import ( # noqa: E402
30
- ALL_EXEMPT_BASENAMES,
31
- ALL_EXEMPT_DIRECTORY_NAMES,
32
- ALL_INVENTORY_DOCUMENT_NAMES,
33
- ALL_PRODUCTION_CODE_EXTENSIONS,
34
- ALL_TEST_FILE_MARKERS,
35
- BACKTICK_TOKEN_PATTERN,
36
- CODE_FENCE_PATTERN,
37
- GLOB_METACHARACTER_PATTERN,
38
- MAX_INVENTORY_FILE_BYTES,
39
- MINIMUM_INVENTORY_ENTRY_COUNT,
40
- NON_FILENAME_TOKEN_PATTERN,
41
- PYTHON_FILE_EXTENSION,
42
- SCRIPTS_SUBDIRECTORY_NAME,
43
- SKILL_INVENTORY_DOCUMENT_NAME,
44
- STALE_INVENTORY_ADDITIONAL_CONTEXT,
45
- STALE_INVENTORY_MESSAGE_TEMPLATE,
46
- STALE_INVENTORY_SYSTEM_MESSAGE,
47
- )
48
- from hooks_constants.pre_tool_use_stdin import ( # noqa: E402
49
- read_hook_input_dictionary_from_stdin,
50
- )
51
-
52
-
53
- def _basename_token(backtick_inner_text: str) -> str | None:
54
- """Return the bare filename a backticked token names, when it names one.
55
-
56
- A token names a bare filename when it is a single filename or path token
57
- carrying a known file extension. A token that holds a path keeps only its
58
- final segment, so an inventory cell naming ``pipeline/seam_continuity.py``
59
- yields ``seam_continuity.py`` — the basename the directory file would match.
60
- A slash-command token (leading ``/``), a glob/pattern token carrying a
61
- metacharacter (``*``, ``?``, brace or bracket range, so ``*.py`` and
62
- ``test_*.py`` name no literal file), a multi-word command-example span
63
- carrying whitespace or shell punctuation (``:``, ``$``, ``<``, ``>``, so
64
- ``parent:node_modules package.json`` and ``python <file>.py`` name no
65
- literal file), and a token with no file extension yield None.
66
-
67
- Args:
68
- backtick_inner_text: The text between a backtick pair, stripped.
69
-
70
- Returns:
71
- The bare basename the token references, or None when it names no file.
72
- """
73
- inner_text = backtick_inner_text.strip()
74
- if not inner_text or inner_text.startswith("/"):
75
- return None
76
- if GLOB_METACHARACTER_PATTERN.search(inner_text) is not None:
77
- return None
78
- if NON_FILENAME_TOKEN_PATTERN.search(inner_text) is not None:
79
- return None
80
- basename = os.path.basename(inner_text.replace("\\", "/").rstrip("/"))
81
- if not basename:
82
- return None
83
- _, extension = os.path.splitext(basename)
84
- if not extension:
85
- return None
86
- return basename
87
-
88
-
89
- def _lines_outside_code_fences(inventory_content: str) -> list[str]:
90
- """Return the inventory lines that sit outside any fenced code block.
91
-
92
- A line inside a ``` or ~~~ fence pair is example or sample text, not a live
93
- listing, so it is dropped — mirroring the fence handling in
94
- ``claude_md_orphan_file_blocker``.
95
-
96
- Args:
97
- inventory_content: The text of a README.md or CLAUDE.md inventory.
98
-
99
- Returns:
100
- The lines that lie outside every code fence, in document order.
101
- """
102
- live_lines: list[str] = []
103
- is_inside_code_fence = False
104
- for each_line in inventory_content.splitlines():
105
- if CODE_FENCE_PATTERN.match(each_line) is not None:
106
- is_inside_code_fence = not is_inside_code_fence
107
- continue
108
- if is_inside_code_fence:
109
- continue
110
- live_lines.append(each_line)
111
- return live_lines
112
-
113
-
114
- def inventory_named_basenames(inventory_content: str) -> set[str]:
115
- """Return every bare filename a package inventory document names in backticks.
116
-
117
- Lines inside a fenced code block are skipped as example text. Each backticked
118
- token on a remaining line is examined; one that names a literal file (a single
119
- filename or path token that carries an extension, no glob metacharacter, and
120
- no whitespace or shell punctuation) contributes its basename, and a token
121
- holding a path contributes its final segment. A multi-word command-example
122
- span contributes nothing. This covers both a README.md table cell and a
123
- CLAUDE.md bullet, since both name files in backticks.
124
-
125
- Args:
126
- inventory_content: The text of a README.md or CLAUDE.md inventory.
127
-
128
- Returns:
129
- The set of bare basenames the inventory names.
130
- """
131
- named_basenames: set[str] = set()
132
- for each_line in _lines_outside_code_fences(inventory_content):
133
- for each_match in BACKTICK_TOKEN_PATTERN.finditer(each_line):
134
- each_basename = _basename_token(each_match.group(1))
135
- if each_basename is not None:
136
- named_basenames.add(each_basename)
137
- return named_basenames
138
-
139
-
140
- def _read_inventory_content(inventory_path: Path) -> str | None:
141
- """Return the text of an inventory document, or None when it is unreadable.
142
-
143
- A document larger than the byte budget is skipped (None), so an oversized
144
- file never stalls the hook.
145
-
146
- Args:
147
- inventory_path: The path of the README.md or CLAUDE.md to read.
148
-
149
- Returns:
150
- The document text, or None when it is missing, oversized, or undecodable.
151
- """
152
- try:
153
- if inventory_path.stat().st_size > MAX_INVENTORY_FILE_BYTES:
154
- return None
155
- return inventory_path.read_text(encoding="utf-8")
156
- except (OSError, UnicodeDecodeError):
157
- return None
158
-
159
-
160
- class _InventorySurvey:
161
- """The inventory documents found beside a file and the files they name.
162
-
163
- Attributes:
164
- present_inventory_names: The inventory document basenames present in the
165
- directory (``README.md``, ``CLAUDE.md``, and/or ``SKILL.md``).
166
- named_basenames: Every bare filename the present inventories name.
167
- """
168
-
169
- def __init__(
170
- self, all_present_inventory_names: list[str], all_named_basenames: set[str]
171
- ) -> None:
172
- self.present_inventory_names = all_present_inventory_names
173
- self.named_basenames = all_named_basenames
174
-
175
-
176
- def survey_directory_inventories(package_directory: Path) -> _InventorySurvey:
177
- """Return the inventory documents beside a file and the basenames they name.
178
-
179
- Reads each present ``README.md`` and ``CLAUDE.md`` in *package_directory* and
180
- unions the basenames they name in backticks.
181
-
182
- Args:
183
- package_directory: The directory that holds the file being written.
184
-
185
- Returns:
186
- The survey pairing the present inventory document names with the union of
187
- the basenames they name.
188
- """
189
- present_inventory_names: list[str] = []
190
- named_basenames: set[str] = set()
191
- for each_inventory_name in sorted(ALL_INVENTORY_DOCUMENT_NAMES):
192
- inventory_path = package_directory / each_inventory_name
193
- inventory_content = _read_inventory_content(inventory_path)
194
- if inventory_content is None:
195
- continue
196
- present_inventory_names.append(each_inventory_name)
197
- named_basenames |= inventory_named_basenames(inventory_content)
198
- return _InventorySurvey(present_inventory_names, named_basenames)
199
-
200
-
201
- def _is_test_file(basename: str) -> bool:
202
- """Return whether *basename* names a test file the inventory need not list.
203
-
204
- Args:
205
- basename: The bare filename of the file being written.
206
-
207
- Returns:
208
- True when the name matches a test-file shape (``test_*.py``,
209
- ``*_test.py``, ``*.spec.*``, or ``*.test.*``).
210
- """
211
- if basename.startswith("test_") and basename.endswith(PYTHON_FILE_EXTENSION):
212
- return True
213
- if basename.endswith("_test" + PYTHON_FILE_EXTENSION):
214
- return True
215
- return any(each_marker in basename for each_marker in ALL_TEST_FILE_MARKERS)
216
-
217
-
218
- def _is_under_exempt_directory(package_directory: Path) -> bool:
219
- """Return whether the file's directory is itself an exempt directory.
220
-
221
- A file directly inside a directory that carries no per-file inventory (such
222
- as ``config/`` or ``tests/``) has no individual entry, so its directory
223
- exempts it.
224
-
225
- Args:
226
- package_directory: The directory that holds the file being written.
227
-
228
- Returns:
229
- True when the directory's own name is an exempt directory name.
230
- """
231
- return package_directory.name in ALL_EXEMPT_DIRECTORY_NAMES
232
-
233
-
234
- def is_inventoried_production_file(file_path: str) -> bool:
235
- """Return whether *file_path* is a production file an inventory should name.
236
-
237
- A production file is a non-test, non-exempt code file (``.py``, ``.mjs``,
238
- ``.js``, ``.ts``, ``.ps1``, ``.sh``) outside a directory that carries no
239
- per-file inventory (such as ``config/`` or ``tests/``). Exempt basenames
240
- (``__init__.py``, ``conftest.py``, ``setup.py``, ``_path_setup.py``) and
241
- test files are out of scope.
242
-
243
- Args:
244
- file_path: The destination path of the write.
245
-
246
- Returns:
247
- True when the file is one an inventory entry should name.
248
- """
249
- basename = os.path.basename(file_path)
250
- _, extension = os.path.splitext(basename)
251
- if extension.lower() not in ALL_PRODUCTION_CODE_EXTENSIONS:
252
- return False
253
- if basename in ALL_EXEMPT_BASENAMES:
254
- return False
255
- if _is_test_file(basename):
256
- return False
257
- return not _is_under_exempt_directory(Path(file_path).resolve().parent)
258
-
259
-
260
- def _sibling_named_basenames(
261
- package_directory: Path, all_named_basenames: set[str]
262
- ) -> set[str]:
263
- """Return the named basenames that exist as files in *package_directory*.
264
-
265
- A maintained inventory lists the directory's own files, so a named basename
266
- counts toward the inventory only when a file with that basename sits directly
267
- in the directory. A name the inventory mentions in passing — a file living in
268
- another directory (``install.mjs``), a sibling doc — is dropped, so prose that
269
- references non-sibling files never reads as a maintained inventory.
270
-
271
- Args:
272
- package_directory: The directory that holds the file being written.
273
- all_named_basenames: Every bare basename the inventory documents name.
274
-
275
- Returns:
276
- The subset of *all_named_basenames* present as a file in the directory.
277
- """
278
- sibling_basenames: set[str] = set()
279
- for each_basename in all_named_basenames:
280
- if (package_directory / each_basename).is_file():
281
- sibling_basenames.add(each_basename)
282
- return sibling_basenames
283
-
284
-
285
- def _parent_skill_inventory(package_directory: Path) -> _InventorySurvey | None:
286
- """Return the parent skill ``SKILL.md`` survey for a ``scripts/`` directory.
287
-
288
- A skill package keeps its ``SKILL.md`` at the skill root and maps the
289
- ``scripts/`` subdirectory in a Layout table whose rows name files by their
290
- ``scripts/<name>`` path. A production file landing in that ``scripts/``
291
- directory is governed by the parent ``SKILL.md``, which sits one level up
292
- rather than beside the file. This reads that parent ``SKILL.md`` and reports
293
- the basenames it names. Any directory not named ``scripts/`` and any missing
294
- or unreadable parent ``SKILL.md`` yield None.
295
-
296
- Args:
297
- package_directory: The directory that holds the file being written.
298
-
299
- Returns:
300
- The parent ``SKILL.md`` survey, or None when there is none to read.
301
- """
302
- if package_directory.name != SCRIPTS_SUBDIRECTORY_NAME:
303
- return None
304
- skill_inventory_path = package_directory.parent / SKILL_INVENTORY_DOCUMENT_NAME
305
- inventory_content = _read_inventory_content(skill_inventory_path)
306
- if inventory_content is None:
307
- return None
308
- return _InventorySurvey(
309
- [SKILL_INVENTORY_DOCUMENT_NAME], inventory_named_basenames(inventory_content)
22
+ try:
23
+ _hooks_root_directory = str(Path(__file__).resolve().parent.parent)
24
+ _blocking_directory = str(Path(__file__).resolve().parent)
25
+ for each_bootstrap_directory in (_hooks_root_directory, _blocking_directory):
26
+ if each_bootstrap_directory not in sys.path:
27
+ sys.path.insert(0, each_bootstrap_directory)
28
+ from inventory_intent_records import records
29
+ from package_inventory_stale_blocker_parts import decision, inventory_detection
30
+
31
+ from hooks_constants.pre_tool_use_stdin import (
32
+ read_hook_input_dictionary_from_stdin,
310
33
  )
34
+ except ImportError as import_error:
35
+ raise ImportError(
36
+ "package_inventory_stale_blocker: cannot import its parts submodules; "
37
+ "ensure the hooks directory is importable."
38
+ ) from import_error
311
39
 
312
40
 
313
- def find_stale_inventory(file_path: str) -> _InventorySurvey | None:
314
- """Return the maintained inventory survey a new file is absent from, or None.
41
+ inventory_named_basenames = inventory_detection.inventory_named_basenames
42
+ is_inventoried_production_file = inventory_detection.is_inventoried_production_file
43
+ find_stale_inventory = inventory_detection.find_stale_inventory
44
+ survey_directory_inventories = inventory_detection.survey_directory_inventories
45
+ deny_stale_inventory = decision.deny_stale_inventory
315
46
 
316
- The file's own directory inventories are surveyed and, when the file sits in
317
- a ``scripts/`` subdirectory, the parent skill ``SKILL.md`` Layout table is
318
- surveyed too; the named basenames union across both. They are then filtered
319
- to those that exist as files in the file's directory — the inventory's own
320
- sibling files. The survey reports a stale inventory only when every condition
321
- holds: at least one inventory document is present, those documents together
322
- name at least the minimum entry count of on-disk sibling files (marking them
323
- a maintained inventory rather than incidental prose that mentions files living
324
- elsewhere), and no inventory already names this file's basename. When any
325
- condition fails the file is in step with its inventory (or there is no
326
- inventory to be out of step with), so None results.
327
47
 
328
- Args:
329
- file_path: The destination path of the write.
330
-
331
- Returns:
332
- The inventory survey when the file is a stale omission, or None.
333
- """
334
- package_directory = Path(file_path).resolve().parent
335
- if not package_directory.is_dir():
336
- return None
337
- survey = survey_directory_inventories(package_directory)
338
- present_inventory_names = list(survey.present_inventory_names)
339
- named_basenames = set(survey.named_basenames)
340
- parent_skill_survey = _parent_skill_inventory(package_directory)
341
- if parent_skill_survey is not None:
342
- present_inventory_names += parent_skill_survey.present_inventory_names
343
- named_basenames |= parent_skill_survey.named_basenames
344
- if not present_inventory_names:
345
- return None
346
- sibling_basenames = _sibling_named_basenames(package_directory, named_basenames)
347
- if len(sibling_basenames) < MINIMUM_INVENTORY_ENTRY_COUNT:
348
- return None
349
- if os.path.basename(file_path) in named_basenames:
350
- return None
351
- return _InventorySurvey(present_inventory_names, sibling_basenames)
352
-
353
-
354
- def _build_block_payload(file_path: str, survey: _InventorySurvey) -> dict:
355
- """Build the PreToolUse deny payload for a stale-inventory omission.
48
+ def _resolve_payload(input_data: dict) -> tuple[str, dict, str]:
49
+ """Return the tool name, tool input, and file path from a PreToolUse payload.
356
50
 
357
51
  Args:
358
- file_path: The destination path of the write.
359
- survey: The maintained-inventory survey the file is absent from.
52
+ input_data: The parsed PreToolUse payload.
360
53
 
361
54
  Returns:
362
- The hook-result dictionary the harness reads to deny the write.
55
+ The tool name, the tool input dict, and the file path — each emptied when
56
+ its value is absent or the wrong type.
363
57
  """
364
- package_directory = str(Path(file_path).resolve().parent)
365
- formatted_inventories = ", ".join(survey.present_inventory_names)
366
- reason = STALE_INVENTORY_MESSAGE_TEMPLATE.format(
367
- filename=os.path.basename(file_path),
368
- directory=package_directory,
369
- inventories=formatted_inventories,
370
- entry_count=len(survey.named_basenames),
371
- )
372
- return {
373
- "hookSpecificOutput": {
374
- "hookEventName": "PreToolUse",
375
- "permissionDecision": "deny",
376
- "permissionDecisionReason": reason,
377
- "additionalContext": STALE_INVENTORY_ADDITIONAL_CONTEXT,
378
- },
379
- "systemMessage": STALE_INVENTORY_SYSTEM_MESSAGE,
380
- "suppressOutput": True,
381
- }
58
+ raw_tool_name = input_data.get("tool_name", "")
59
+ tool_name = raw_tool_name if isinstance(raw_tool_name, str) else ""
60
+ tool_input = input_data.get("tool_input", {})
61
+ if not isinstance(tool_input, dict):
62
+ return tool_name, {}, ""
63
+ file_path = tool_input.get("file_path", "")
64
+ if not isinstance(file_path, str):
65
+ return tool_name, tool_input, ""
66
+ return tool_name, tool_input, file_path
382
67
 
383
68
 
384
- def _emit_hook_result(all_hook_data: dict, output_stream: TextIO) -> None:
385
- """Write the hook result JSON to the given output stream.
69
+ def _emit_stale_decision(
70
+ input_data: dict, file_path: str, survey: inventory_detection._InventorySurvey
71
+ ) -> None:
72
+ """Allow the write when a pending row intent matches, else record and deny.
386
73
 
387
74
  Args:
388
- all_hook_data: The hook-result dictionary to serialize.
389
- output_stream: The stream the harness reads the decision from.
75
+ input_data: The parsed PreToolUse payload.
76
+ file_path: The destination path of the new file.
77
+ survey: The maintained-inventory survey the file is absent from.
390
78
  """
391
- output_stream.write(json.dumps(all_hook_data) + "\n")
392
- output_stream.flush()
79
+ session_id = str(input_data.get("session_id") or "")
80
+ directory = str(Path(file_path).resolve().parent)
81
+ filename = os.path.basename(file_path)
82
+ if records.has_fresh_row_intent(session_id, directory, filename):
83
+ records.consume_row_intent(session_id, directory, filename)
84
+ return
85
+ records.record_file_intent(session_id, directory, filename)
86
+ deny_stale_inventory(file_path, survey)
393
87
 
394
88
 
395
89
  def main() -> None:
@@ -397,39 +91,15 @@ def main() -> None:
397
91
  input_data = read_hook_input_dictionary_from_stdin()
398
92
  if input_data is None:
399
93
  sys.exit(0)
400
-
401
- raw_tool_name = input_data.get("tool_name", "")
402
- tool_name = raw_tool_name if isinstance(raw_tool_name, str) else ""
403
- if tool_name != "Write":
404
- sys.exit(0)
405
-
406
- tool_input = input_data.get("tool_input", {})
407
- if not isinstance(tool_input, dict):
408
- sys.exit(0)
409
-
410
- file_path = tool_input.get("file_path", "")
411
- if not isinstance(file_path, str) or not file_path:
412
- sys.exit(0)
413
-
414
- if os.path.exists(file_path):
94
+ tool_name, _tool_input, file_path = _resolve_payload(input_data)
95
+ if tool_name != "Write" or not file_path:
415
96
  sys.exit(0)
416
-
417
- if not is_inventoried_production_file(file_path):
97
+ if os.path.exists(file_path) or not is_inventoried_production_file(file_path):
418
98
  sys.exit(0)
419
-
420
99
  survey = find_stale_inventory(file_path)
421
100
  if survey is None:
422
101
  sys.exit(0)
423
-
424
- block_payload = _build_block_payload(file_path, survey)
425
- log_hook_block(
426
- calling_hook_name="package_inventory_stale_blocker.py",
427
- hook_event="PreToolUse",
428
- block_reason=block_payload["hookSpecificOutput"]["permissionDecisionReason"],
429
- tool_name=tool_name,
430
- offending_input_preview=file_path,
431
- )
432
- _emit_hook_result(block_payload, sys.stdout)
102
+ _emit_stale_decision(input_data, file_path, survey)
433
103
  sys.exit(0)
434
104
 
435
105
 
@@ -0,0 +1,26 @@
1
+ # package_inventory_stale_blocker_parts
2
+
3
+ The concern modules `package_inventory_stale_blocker.py` wires together to block a
4
+ new production file its package inventory omits. The entry hook imports them and
5
+ re-exports their surface for the test suite.
6
+
7
+ ## Modules
8
+
9
+ | File | Purpose |
10
+ |---|---|
11
+ | `inventory_detection.py` | Surveys a directory's `README.md`/`CLAUDE.md`/`SKILL.md`, collects the filenames they name, and reports whether a maintained inventory omits the file being written |
12
+ | `decision.py` | Builds the deny payload naming the omitted file and closing with the retry hint, and emits the decision JSON |
13
+ | `__init__.py` | Package marker |
14
+
15
+ ## Subdirectories
16
+
17
+ | Entry | Description |
18
+ |---|---|
19
+ | `config/` | The retry hint the deny reason closes with and the inventory-name join separator (`inventory_blocker_constants.py`) |
20
+ | `tests/` | pytest suite with one test module per module above |
21
+
22
+ ## Running tests
23
+
24
+ ```bash
25
+ python -m pytest packages/claude-dev-env/hooks/blocking/package_inventory_stale_blocker_parts/tests/
26
+ ```
@@ -0,0 +1 @@
1
+ """Concern modules the ``package_inventory_stale_blocker`` entry hook wires together."""
@@ -0,0 +1 @@
1
+ """Configuration home for the package-inventory blocker parts constants."""
@@ -0,0 +1,16 @@
1
+ """Constants for the package-inventory blocker parts.
2
+
3
+ Holds the separator that joins the present inventory document names in the deny
4
+ reason, and the retry hint the deny reason closes with once the denied file write
5
+ has been recorded as a pending intent.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ INVENTORY_NAME_JOIN_SEPARATOR: str = ", "
11
+
12
+ FILE_FIRST_RETRY_HINT: str = (
13
+ " This file write has been recorded: add the inventory entry naming this file "
14
+ "now (a README.md/CLAUDE.md/SKILL.md row or bullet), and the file write will "
15
+ "be allowed when you retry it."
16
+ )
@@ -0,0 +1,84 @@
1
+ """Build, record, and emit the package-inventory blocker's deny decision.
2
+
3
+ The deny reason names the omitted file and closes with the retry hint that tells
4
+ the author to add the inventory row now, since this write has been recorded.
5
+ """
6
+
7
+ import json
8
+ import os
9
+ import sys
10
+ from pathlib import Path
11
+ from typing import TextIO
12
+
13
+ from package_inventory_stale_blocker_parts.config.inventory_blocker_constants import (
14
+ FILE_FIRST_RETRY_HINT,
15
+ INVENTORY_NAME_JOIN_SEPARATOR,
16
+ )
17
+ from package_inventory_stale_blocker_parts.inventory_detection import _InventorySurvey
18
+
19
+ from hooks_constants.hook_block_logger import log_hook_block
20
+ from hooks_constants.package_inventory_stale_blocker_constants import (
21
+ STALE_INVENTORY_ADDITIONAL_CONTEXT,
22
+ STALE_INVENTORY_MESSAGE_TEMPLATE,
23
+ STALE_INVENTORY_SYSTEM_MESSAGE,
24
+ )
25
+
26
+
27
+ def deny_stale_inventory(file_path: str, survey: _InventorySurvey) -> None:
28
+ """Build, record, and emit the deny decision for a stale-inventory omission.
29
+
30
+ Args:
31
+ file_path: The destination path of the denied new file.
32
+ survey: The maintained-inventory survey the file is absent from.
33
+ """
34
+ block_payload = build_block_payload(file_path, survey)
35
+ log_hook_block(
36
+ calling_hook_name="package_inventory_stale_blocker.py",
37
+ hook_event="PreToolUse",
38
+ block_reason=block_payload["hookSpecificOutput"]["permissionDecisionReason"],
39
+ tool_name="Write",
40
+ offending_input_preview=file_path,
41
+ )
42
+ emit_hook_result(block_payload, sys.stdout)
43
+
44
+
45
+ def build_block_payload(file_path: str, survey: _InventorySurvey) -> dict:
46
+ """Build the PreToolUse deny payload for a stale-inventory omission.
47
+
48
+ Args:
49
+ file_path: The destination path of the write.
50
+ survey: The maintained-inventory survey the file is absent from.
51
+
52
+ Returns:
53
+ The hook-result dictionary the harness reads to deny the write, whose
54
+ reason closes with the retry hint.
55
+ """
56
+ package_directory = str(Path(file_path).resolve().parent)
57
+ formatted_inventories = INVENTORY_NAME_JOIN_SEPARATOR.join(survey.present_inventory_names)
58
+ reason = STALE_INVENTORY_MESSAGE_TEMPLATE.format(
59
+ filename=os.path.basename(file_path),
60
+ directory=package_directory,
61
+ inventories=formatted_inventories,
62
+ entry_count=len(survey.named_basenames),
63
+ )
64
+ return {
65
+ "hookSpecificOutput": {
66
+ "hookEventName": "PreToolUse",
67
+ "permissionDecision": "deny",
68
+ "permissionDecisionReason": reason + FILE_FIRST_RETRY_HINT,
69
+ "additionalContext": STALE_INVENTORY_ADDITIONAL_CONTEXT,
70
+ },
71
+ "systemMessage": STALE_INVENTORY_SYSTEM_MESSAGE,
72
+ "suppressOutput": True,
73
+ }
74
+
75
+
76
+ def emit_hook_result(all_hook_data: dict, output_stream: TextIO) -> None:
77
+ """Write the hook result JSON to the given output stream.
78
+
79
+ Args:
80
+ all_hook_data: The hook-result dictionary to serialize.
81
+ output_stream: The stream the harness reads the decision from.
82
+ """
83
+ output_stream.write(json.dumps(all_hook_data) + "\n")
84
+ output_stream.flush()