claude-dev-env 1.95.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (241) hide show
  1. package/_shared/advisor/CLAUDE.md +2 -2
  2. package/_shared/advisor/advisor-protocol.md +20 -20
  3. package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +15 -12
  4. package/_shared/advisor/scripts/model_tier_run_validator.py +11 -10
  5. package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +25 -19
  6. package/_shared/advisor/scripts/tests/test_tier_model_ids.py +17 -17
  7. package/_shared/advisor/scripts/tier_model_ids.py +18 -18
  8. package/_shared/pr-loop/CLAUDE.md +1 -0
  9. package/_shared/pr-loop/scripts/CLAUDE.md +2 -1
  10. package/_shared/pr-loop/scripts/README.md +1 -0
  11. package/_shared/pr-loop/scripts/code_rules_gate.py +253 -1980
  12. package/_shared/pr-loop/scripts/code_rules_gate_parts/CLAUDE.md +32 -0
  13. package/_shared/pr-loop/scripts/code_rules_gate_parts/__init__.py +7 -0
  14. package/_shared/pr-loop/scripts/code_rules_gate_parts/added_line_maps.py +268 -0
  15. package/_shared/pr-loop/scripts/code_rules_gate_parts/enforcer_loading.py +172 -0
  16. package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_arguments.py +70 -0
  17. package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_running.py +326 -0
  18. package/_shared/pr-loop/scripts/code_rules_gate_parts/git_blob_readers.py +85 -0
  19. package/_shared/pr-loop/scripts/code_rules_gate_parts/git_file_sets.py +331 -0
  20. package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_running.py +369 -0
  21. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/conftest.py +14 -0
  22. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_added_line_maps.py +118 -0
  23. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_enforcer_loading.py +17 -0
  24. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_gate_arguments.py +29 -0
  25. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_gate_running.py +99 -0
  26. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_git_blob_readers.py +69 -0
  27. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_git_file_sets.py +137 -0
  28. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_staged_test_running.py +116 -0
  29. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_violation_scoping.py +75 -0
  30. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_wrapper_plumb_check.py +49 -0
  31. package/_shared/pr-loop/scripts/code_rules_gate_parts/violation_scoping.py +328 -0
  32. package/_shared/pr-loop/scripts/code_rules_gate_parts/wrapper_plumb_check.py +206 -0
  33. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +24 -17
  34. package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviews_disabled_constants.py +1 -0
  35. package/_shared/pr-loop/scripts/reviews_disabled.py +19 -2
  36. package/_shared/pr-loop/scripts/test_code_rules_gate.py +278 -0
  37. package/_shared/pr-loop/scripts/tests/test_code_rules_gate_constants.py +6 -39
  38. package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +43 -0
  39. package/_shared/pr-loop/worker-spawn.md +186 -0
  40. package/agents/code-verifier.md +1 -1
  41. package/bin/ever-shipped-skills.mjs +3 -0
  42. package/bin/expand_home_directory_tokens.mjs +1 -1
  43. package/bin/install.mjs +5 -2
  44. package/hooks/advisory/refactor_guard.py +3 -4
  45. package/hooks/blocking/CLAUDE.md +7 -1
  46. package/hooks/blocking/block_main_commit.py +2 -2
  47. package/hooks/blocking/claude_md_orphan_file_blocker.py +75 -699
  48. package/hooks/blocking/claude_md_orphan_file_blocker_parts/CLAUDE.md +28 -0
  49. package/hooks/blocking/claude_md_orphan_file_blocker_parts/__init__.py +1 -0
  50. package/hooks/blocking/claude_md_orphan_file_blocker_parts/config/__init__.py +1 -0
  51. package/hooks/blocking/claude_md_orphan_file_blocker_parts/config/orphan_blocker_constants.py +18 -0
  52. package/hooks/blocking/claude_md_orphan_file_blocker_parts/decision.py +81 -0
  53. package/hooks/blocking/claude_md_orphan_file_blocker_parts/references.py +307 -0
  54. package/hooks/blocking/claude_md_orphan_file_blocker_parts/scan_plan.py +124 -0
  55. package/hooks/blocking/claude_md_orphan_file_blocker_parts/subtree_scan.py +179 -0
  56. package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/conftest.py +10 -0
  57. package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_decision.py +34 -0
  58. package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_references.py +42 -0
  59. package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_scan_plan.py +27 -0
  60. package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_subtree_scan.py +30 -0
  61. package/hooks/blocking/code_rules_boolean_mustcheck.py +1 -1
  62. package/hooks/blocking/code_rules_mock_completeness.py +1 -1
  63. package/hooks/blocking/code_rules_optional_params.py +2 -2
  64. package/hooks/blocking/code_rules_shared.py +1 -1
  65. package/hooks/blocking/code_rules_test_assertions.py +1 -1
  66. package/hooks/blocking/code_rules_typeddict_stub.py +1 -1
  67. package/hooks/blocking/gh_pr_author_enforcer.py +1 -1
  68. package/hooks/blocking/inventory_intent_records/CLAUDE.md +26 -0
  69. package/hooks/blocking/inventory_intent_records/__init__.py +1 -0
  70. package/hooks/blocking/inventory_intent_records/config/__init__.py +1 -0
  71. package/hooks/blocking/inventory_intent_records/config/intent_records_constants.py +20 -0
  72. package/hooks/blocking/inventory_intent_records/records.py +271 -0
  73. package/hooks/blocking/inventory_intent_records/tests/conftest.py +10 -0
  74. package/hooks/blocking/inventory_intent_records/tests/test_records.py +80 -0
  75. package/hooks/blocking/package_inventory_stale_blocker.py +54 -384
  76. package/hooks/blocking/package_inventory_stale_blocker_parts/CLAUDE.md +26 -0
  77. package/hooks/blocking/package_inventory_stale_blocker_parts/__init__.py +1 -0
  78. package/hooks/blocking/package_inventory_stale_blocker_parts/config/__init__.py +1 -0
  79. package/hooks/blocking/package_inventory_stale_blocker_parts/config/inventory_blocker_constants.py +16 -0
  80. package/hooks/blocking/package_inventory_stale_blocker_parts/decision.py +84 -0
  81. package/hooks/blocking/package_inventory_stale_blocker_parts/inventory_detection.py +307 -0
  82. package/hooks/blocking/package_inventory_stale_blocker_parts/tests/conftest.py +10 -0
  83. package/hooks/blocking/package_inventory_stale_blocker_parts/tests/test_decision.py +38 -0
  84. package/hooks/blocking/package_inventory_stale_blocker_parts/tests/test_inventory_detection.py +61 -0
  85. package/hooks/blocking/pii_payload_scan.py +138 -42
  86. package/hooks/blocking/pii_prevention_blocker.py +185 -291
  87. package/hooks/blocking/pii_prevention_blocker_parts/CLAUDE.md +24 -0
  88. package/hooks/blocking/pii_prevention_blocker_parts/__init__.py +1 -0
  89. package/hooks/blocking/pii_prevention_blocker_parts/config/__init__.py +1 -0
  90. package/hooks/blocking/pii_prevention_blocker_parts/config/repository_resolution_constants.py +28 -0
  91. package/hooks/blocking/pii_prevention_blocker_parts/repository_exemption.py +214 -0
  92. package/hooks/blocking/pii_prevention_blocker_parts/repository_resolution.py +208 -0
  93. package/hooks/blocking/pr_description_command_parser.py +8 -4
  94. package/hooks/blocking/precommit_code_rules_gate.py +3 -3
  95. package/hooks/blocking/tdd_enforcer.py +97 -608
  96. package/hooks/blocking/tdd_enforcer_parts/CLAUDE.md +30 -0
  97. package/hooks/blocking/tdd_enforcer_parts/__init__.py +1 -0
  98. package/hooks/blocking/tdd_enforcer_parts/candidate_paths.py +142 -0
  99. package/hooks/blocking/tdd_enforcer_parts/config/__init__.py +1 -0
  100. package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +32 -0
  101. package/hooks/blocking/tdd_enforcer_parts/content_analysis.py +268 -0
  102. package/hooks/blocking/tdd_enforcer_parts/decisions.py +92 -0
  103. package/hooks/blocking/tdd_enforcer_parts/freshness.py +80 -0
  104. package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +63 -0
  105. package/hooks/blocking/tdd_enforcer_parts/path_classification.py +119 -0
  106. package/hooks/blocking/tdd_enforcer_parts/tests/conftest.py +10 -0
  107. package/hooks/blocking/tdd_enforcer_parts/tests/test_candidate_paths.py +31 -0
  108. package/hooks/blocking/tdd_enforcer_parts/tests/test_content_analysis.py +30 -0
  109. package/hooks/blocking/tdd_enforcer_parts/tests/test_decisions.py +34 -0
  110. package/hooks/blocking/tdd_enforcer_parts/tests/test_freshness.py +28 -0
  111. package/hooks/blocking/tdd_enforcer_parts/tests/test_git_tracking.py +48 -0
  112. package/hooks/blocking/tdd_enforcer_parts/tests/test_path_classification.py +36 -0
  113. package/hooks/blocking/test_inventory_deadlock_resolution.py +154 -0
  114. package/hooks/blocking/test_pii_payload_scan.py +168 -0
  115. package/hooks/blocking/test_tdd_enforcer_restore.py +108 -0
  116. package/hooks/blocking/test_verifier_verdict_minter.py +55 -158
  117. package/hooks/blocking/tests/conftest.py +10 -0
  118. package/hooks/blocking/tests/test_pii_prevention_blocker.py +260 -0
  119. package/hooks/blocking/tests/test_repository_exemption.py +105 -0
  120. package/hooks/blocking/tests/test_repository_resolution.py +108 -0
  121. package/hooks/diagnostic/hook_log_extractor.py +12 -10
  122. package/hooks/git-hooks/post_commit.py +3 -4
  123. package/hooks/hooks_constants/CLAUDE.md +2 -2
  124. package/hooks/hooks_constants/banned_identifiers_constants.py +0 -1
  125. package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -1
  126. package/hooks/hooks_constants/local_identity.py +59 -8
  127. package/hooks/hooks_constants/pii_prevention_constants.py +0 -6
  128. package/hooks/hooks_constants/test_local_identity.py +105 -3
  129. package/hooks/pyproject.toml +13 -36
  130. package/hooks/session/plugin_data_dir_cleanup.py +0 -1
  131. package/hooks/validation/mypy_validator.py +2 -2
  132. package/hooks/validators/health_check.py +1 -0
  133. package/hooks/validators/mypy_integration.py +2 -0
  134. package/hooks/validators/ruff_integration.py +3 -0
  135. package/hooks/workflow/auto_formatter.py +5 -4
  136. package/package.json +1 -1
  137. package/scripts/CLAUDE.md +4 -0
  138. package/scripts/dev_env_scripts_constants/CLAUDE.md +6 -4
  139. package/scripts/dev_env_scripts_constants/code_review_constants.py +71 -0
  140. package/scripts/dev_env_scripts_constants/grok_worker_constants.py +435 -0
  141. package/scripts/dev_env_scripts_constants/timing.py +7 -1
  142. package/scripts/grok_headless_runner.py +294 -0
  143. package/scripts/grok_worker_preflight.py +410 -0
  144. package/scripts/invoke_code_review.py +463 -0
  145. package/scripts/resolve_worker_spawn.py +619 -0
  146. package/scripts/spawn_grok_batch.py +672 -0
  147. package/scripts/test_grok_headless_runner.py +626 -0
  148. package/scripts/test_grok_worker_preflight.py +1054 -0
  149. package/scripts/test_invoke_code_review.py +672 -0
  150. package/scripts/test_resolve_worker_spawn.py +1014 -0
  151. package/scripts/test_spawn_grok_batch.py +1017 -0
  152. package/skills/CLAUDE.md +5 -3
  153. package/skills/_shared/pr-loop/scripts/build_audit_prompt.py +72 -13
  154. package/skills/_shared/pr-loop/scripts/build_fix_prompt.py +121 -14
  155. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/path_resolver_constants.py +78 -0
  156. package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +121 -0
  157. package/skills/_shared/pr-loop/scripts/test_build_fix_prompt.py +196 -6
  158. package/skills/autoconverge/CLAUDE.md +3 -3
  159. package/skills/autoconverge/SKILL.md +9 -3
  160. package/skills/autoconverge/reference/CLAUDE.md +2 -2
  161. package/skills/autoconverge/reference/convergence.md +33 -11
  162. package/skills/autoconverge/reference/stop-conditions.md +16 -5
  163. package/skills/autoconverge/workflow/CLAUDE.md +2 -1
  164. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +7 -2
  165. package/skills/autoconverge/workflow/converge.codex-gate.test.mjs +300 -0
  166. package/skills/autoconverge/workflow/converge.contract.test.mjs +5 -5
  167. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +29 -29
  168. package/skills/autoconverge/workflow/converge.fix-progress.test.mjs +1 -1
  169. package/skills/autoconverge/workflow/converge.mjs +200 -16
  170. package/skills/bugteam/CLAUDE.md +2 -2
  171. package/skills/bugteam/CONSTRAINTS.md +3 -2
  172. package/skills/bugteam/PROMPTS.md +7 -6
  173. package/skills/bugteam/SKILL.md +18 -13
  174. package/skills/bugteam/reference/audit-and-teammates.md +215 -35
  175. package/skills/bugteam/reference/design-rationale.md +1 -1
  176. package/skills/bugteam/reference/obstacles/CLAUDE.md +1 -1
  177. package/skills/bugteam/reference/team-setup.md +8 -2
  178. package/skills/codex-review/CLAUDE.md +46 -0
  179. package/skills/codex-review/SKILL.md +181 -0
  180. package/skills/codex-review/reference/CLAUDE.md +15 -0
  181. package/skills/codex-review/reference/cli-contract.md +253 -0
  182. package/skills/codex-review/reference/loop-integration.md +118 -0
  183. package/skills/codex-review/scripts/codex_down_classifier.py +98 -0
  184. package/skills/codex-review/scripts/codex_review_scripts_constants/CLAUDE.md +18 -0
  185. package/skills/codex-review/scripts/codex_review_scripts_constants/__init__.py +1 -0
  186. package/skills/codex-review/scripts/codex_review_scripts_constants/classifier_constants.py +35 -0
  187. package/skills/codex-review/scripts/codex_review_scripts_constants/codex_usage_probe_constants.py +86 -0
  188. package/skills/codex-review/scripts/codex_review_scripts_constants/findings_constants.py +18 -0
  189. package/skills/codex-review/scripts/codex_review_scripts_constants/run_constants.py +45 -0
  190. package/skills/codex-review/scripts/codex_usage_probe.py +573 -0
  191. package/skills/codex-review/scripts/fixtures/auth_failure_synthetic.txt +1 -0
  192. package/skills/codex-review/scripts/fixtures/config_load_failure_v0.125.0.txt +1 -0
  193. package/skills/codex-review/scripts/fixtures/freeform_findings_v0.144.3.txt +6 -0
  194. package/skills/codex-review/scripts/fixtures/model_rejection_v0.125.0.jsonl +5 -0
  195. package/skills/codex-review/scripts/fixtures/structured_findings.txt +13 -0
  196. package/skills/codex-review/scripts/fixtures/success_stream_v0.144.3.jsonl +6 -0
  197. package/skills/codex-review/scripts/fixtures/unknown_failure_synthetic.txt +1 -0
  198. package/skills/codex-review/scripts/fixtures/usage_limit_synthetic.txt +1 -0
  199. package/skills/codex-review/scripts/parse_codex_findings.py +207 -0
  200. package/skills/codex-review/scripts/run_codex_review.py +415 -0
  201. package/skills/codex-review/scripts/test_codex_down_classifier.py +143 -0
  202. package/skills/codex-review/scripts/test_codex_usage_probe.py +678 -0
  203. package/skills/codex-review/scripts/test_parse_codex_findings.py +130 -0
  204. package/skills/codex-review/scripts/test_run_codex_review.py +812 -0
  205. package/skills/codex-review/test_skill_scaffold.py +192 -0
  206. package/skills/grok-spawn/CLAUDE.md +28 -0
  207. package/skills/grok-spawn/SKILL.md +226 -0
  208. package/skills/grok-spawn/reference/flag-profiles.md +132 -0
  209. package/skills/grok-spawn/reference/worker-briefs.md +152 -0
  210. package/skills/grokify/SKILL.md +9 -1
  211. package/skills/grokify/capability-claims.test.mjs +28 -0
  212. package/skills/grokify/evals/README.md +72 -0
  213. package/skills/grokify/evals/parse-payload.test.mjs +171 -0
  214. package/skills/grokify/evals/run-capability-evals.mjs +545 -0
  215. package/skills/orchestrator/SKILL.md +42 -29
  216. package/skills/orchestrator-refresh/SKILL.md +17 -9
  217. package/skills/pr-converge/SKILL.md +34 -13
  218. package/skills/pr-converge/reference/convergence-gates.md +42 -15
  219. package/skills/pr-converge/reference/fix-protocol.md +1 -1
  220. package/skills/pr-converge/reference/ground-rules.md +1 -1
  221. package/skills/pr-converge/reference/per-tick.md +130 -42
  222. package/skills/pr-converge/reference/state-schema.md +10 -0
  223. package/skills/pr-converge/scripts/CLAUDE.md +2 -0
  224. package/skills/pr-converge/scripts/_pr_converge_path_setup.py +5 -1
  225. package/skills/pr-converge/scripts/check_convergence.py +605 -29
  226. package/skills/pr-converge/scripts/check_convergence_availability.py +232 -0
  227. package/skills/pr-converge/scripts/check_convergence_gates.py +279 -235
  228. package/skills/pr-converge/scripts/check_convergence_thread_gates.py +1 -1
  229. package/skills/pr-converge/scripts/pr_converge_scripts_constants/convergence_gate_constants.py +36 -2
  230. package/skills/pr-converge/scripts/test__pr_converge_path_setup.py +4 -0
  231. package/skills/pr-converge/scripts/test_check_convergence.py +71 -3
  232. package/skills/pr-converge/scripts/test_check_convergence_availability.py +326 -0
  233. package/skills/pr-converge/scripts/test_check_convergence_codex.py +507 -0
  234. package/skills/pr-converge/scripts/test_check_convergence_contract.py +89 -17
  235. package/skills/pr-converge/scripts/test_check_convergence_fixture.py +179 -0
  236. package/skills/pr-converge/scripts/test_check_convergence_gates.py +84 -68
  237. package/skills/pr-converge/scripts/test_check_convergence_thread_gates.py +24 -0
  238. package/skills/pr-converge/test_step5_host_branch.py +106 -0
  239. package/skills/pr-loop-cloud-transport/SKILL.md +2 -0
  240. package/skills/reviewer-gates/SKILL.md +7 -5
  241. package/skills/team-advisor/SKILL.md +7 -7
@@ -0,0 +1,271 @@
1
+ """Shared pending-intent notes that break the inventory/orphan write deadlock.
2
+
3
+ ::
4
+
5
+ write foo/bar.py -> inventory deny, records FILE intent(foo, bar.py)
6
+ add row for bar.py -> orphan reads the FILE intent, allows the row
7
+ retry write foo/bar.py -> inventory reads the row on disk, allows the file
8
+
9
+ Each blocker writes a note when it denies and reads the sibling's note to allow
10
+ the matching second write. A note pairs a directory with a filename and its
11
+ record time; a note past the freshness window is ignored, and a consumed note is
12
+ removed so it opens the gate once. Both blockers share one JSON file per session
13
+ under the temp directory, and a missing or corrupt file reads as no notes.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import os
20
+ import tempfile
21
+ import time
22
+ from pathlib import Path
23
+
24
+ from inventory_intent_records.config.intent_records_constants import (
25
+ ALL_FILE_INTENTS_KEY,
26
+ ALL_ROW_INTENTS_KEY,
27
+ INTENT_DIRECTORY_FIELD,
28
+ INTENT_FILE_PREFIX,
29
+ INTENT_FILE_SUFFIX,
30
+ INTENT_FILENAME_FIELD,
31
+ INTENT_FRESHNESS_WINDOW_SECONDS,
32
+ INTENT_RECORDED_AT_FIELD,
33
+ )
34
+
35
+ from hooks_constants.session_edit_stage_gate_constants import (
36
+ SESSION_ID_UNSAFE_CHARACTERS_PATTERN,
37
+ STATE_FILE_ATOMIC_WRITE_SUFFIX,
38
+ STATE_FILE_DEFAULT_SESSION_ID,
39
+ STATE_FILE_JSON_INDENT_SPACES,
40
+ )
41
+
42
+
43
+ def _intent_file_path(session_id: str) -> Path:
44
+ """Return this session's shared intent-records file in the temp directory.
45
+
46
+ Args:
47
+ session_id: Raw session id; unsafe characters are stripped and an empty
48
+ result falls back to the default id.
49
+
50
+ Returns:
51
+ The absolute path of this session's intent-records file.
52
+ """
53
+ sanitized_session_id = SESSION_ID_UNSAFE_CHARACTERS_PATTERN.sub("", session_id)
54
+ effective_session_id = sanitized_session_id or STATE_FILE_DEFAULT_SESSION_ID
55
+ file_name = f"{INTENT_FILE_PREFIX}{effective_session_id}{INTENT_FILE_SUFFIX}"
56
+ return Path(tempfile.gettempdir()) / file_name
57
+
58
+
59
+ def _empty_records() -> dict[str, list[dict[str, object]]]:
60
+ """Return an empty two-list records structure."""
61
+ return {ALL_FILE_INTENTS_KEY: [], ALL_ROW_INTENTS_KEY: []}
62
+
63
+
64
+ def _intent_list(all_records: dict[str, object], intents_key: str) -> list[dict[str, object]]:
65
+ """Return the well-formed intent dictionaries stored under one list key.
66
+
67
+ Args:
68
+ all_records: A parsed records payload.
69
+ intents_key: The file-intents or row-intents list key.
70
+
71
+ Returns:
72
+ Each dictionary entry under the key; a malformed value yields an empty list.
73
+ """
74
+ stored_value = all_records.get(intents_key, [])
75
+ if not isinstance(stored_value, list):
76
+ return []
77
+ return [each_entry for each_entry in stored_value if isinstance(each_entry, dict)]
78
+
79
+
80
+ def _read_records(session_id: str) -> dict[str, list[dict[str, object]]]:
81
+ """Return this session's records, or an empty structure when unreadable.
82
+
83
+ Args:
84
+ session_id: Raw session id keying the shared records file.
85
+
86
+ Returns:
87
+ The two-list records structure; a missing, malformed, or wrong-shape
88
+ file reads as an empty structure so a storage fault falls back to no notes.
89
+ """
90
+ intent_file = _intent_file_path(session_id)
91
+ try:
92
+ raw_text = intent_file.read_text(encoding="utf-8")
93
+ except (FileNotFoundError, OSError, UnicodeDecodeError):
94
+ return _empty_records()
95
+ try:
96
+ parsed_payload = json.loads(raw_text)
97
+ except json.JSONDecodeError:
98
+ return _empty_records()
99
+ if not isinstance(parsed_payload, dict):
100
+ return _empty_records()
101
+ return {
102
+ ALL_FILE_INTENTS_KEY: _intent_list(parsed_payload, ALL_FILE_INTENTS_KEY),
103
+ ALL_ROW_INTENTS_KEY: _intent_list(parsed_payload, ALL_ROW_INTENTS_KEY),
104
+ }
105
+
106
+
107
+ def _write_records(session_id: str, all_records: dict[str, list[dict[str, object]]]) -> None:
108
+ """Write the records structure atomically, failing open on an OS error.
109
+
110
+ Args:
111
+ session_id: Raw session id keying the shared records file.
112
+ all_records: The full records structure to persist.
113
+ """
114
+ intent_file = _intent_file_path(session_id)
115
+ temporary_file = intent_file.with_name(intent_file.name + STATE_FILE_ATOMIC_WRITE_SUFFIX)
116
+ encoded_text = json.dumps(all_records, indent=STATE_FILE_JSON_INDENT_SPACES)
117
+ try:
118
+ temporary_file.write_text(encoded_text, encoding="utf-8")
119
+ os.replace(str(temporary_file), str(intent_file))
120
+ except OSError:
121
+ return
122
+
123
+
124
+ def _is_fresh(intent_record: dict, current_time: float) -> bool:
125
+ """Return whether one intent was recorded within the freshness window.
126
+
127
+ Args:
128
+ intent_record: One stored intent dictionary.
129
+ current_time: The current epoch time.
130
+
131
+ Returns:
132
+ True when the record time is present and within the window.
133
+ """
134
+ recorded_at = intent_record.get(INTENT_RECORDED_AT_FIELD)
135
+ if not isinstance(recorded_at, (int, float)):
136
+ return False
137
+ return current_time - float(recorded_at) <= INTENT_FRESHNESS_WINDOW_SECONDS
138
+
139
+
140
+ def _matches_target(intent_record: dict, directory: str, filename: str) -> bool:
141
+ """Return whether one intent names the given directory and filename.
142
+
143
+ Args:
144
+ intent_record: One stored intent dictionary.
145
+ directory: The resolved directory to match.
146
+ filename: The bare filename to match.
147
+
148
+ Returns:
149
+ True when both the directory and filename fields equal the target.
150
+ """
151
+ return (
152
+ intent_record.get(INTENT_DIRECTORY_FIELD) == directory
153
+ and intent_record.get(INTENT_FILENAME_FIELD) == filename
154
+ )
155
+
156
+
157
+ def _record_intent(session_id: str, intents_key: str, directory: str, filename: str) -> None:
158
+ """Add one pending intent, dropping stale notes and any prior same-target note."""
159
+ all_records = _read_records(session_id)
160
+ current_time = time.time()
161
+ kept_intents = [
162
+ each_intent
163
+ for each_intent in all_records[intents_key]
164
+ if _is_fresh(each_intent, current_time)
165
+ and not _matches_target(each_intent, directory, filename)
166
+ ]
167
+ kept_intents.append(
168
+ {
169
+ INTENT_DIRECTORY_FIELD: directory,
170
+ INTENT_FILENAME_FIELD: filename,
171
+ INTENT_RECORDED_AT_FIELD: current_time,
172
+ }
173
+ )
174
+ all_records[intents_key] = kept_intents
175
+ _write_records(session_id, all_records)
176
+
177
+
178
+ def _has_fresh_intent(session_id: str, intents_key: str, directory: str, filename: str) -> bool:
179
+ """Return whether a fresh pending intent names the directory and filename."""
180
+ all_records = _read_records(session_id)
181
+ current_time = time.time()
182
+ return any(
183
+ _is_fresh(each_intent, current_time) and _matches_target(each_intent, directory, filename)
184
+ for each_intent in all_records[intents_key]
185
+ )
186
+
187
+
188
+ def _consume_intent(session_id: str, intents_key: str, directory: str, filename: str) -> None:
189
+ """Remove every pending intent naming the directory and filename."""
190
+ all_records = _read_records(session_id)
191
+ current_time = time.time()
192
+ remaining_intents = [
193
+ each_intent
194
+ for each_intent in all_records[intents_key]
195
+ if _is_fresh(each_intent, current_time)
196
+ and not _matches_target(each_intent, directory, filename)
197
+ ]
198
+ all_records[intents_key] = remaining_intents
199
+ _write_records(session_id, all_records)
200
+
201
+
202
+ def record_file_intent(session_id: str, directory: str, filename: str) -> None:
203
+ """Record that a new file write was denied for want of its inventory row.
204
+
205
+ Args:
206
+ session_id: Raw session id keying the shared records file.
207
+ directory: The resolved directory the new file lands in.
208
+ filename: The bare filename of the denied new file.
209
+ """
210
+ _record_intent(session_id, ALL_FILE_INTENTS_KEY, directory, filename)
211
+
212
+
213
+ def record_row_intent(session_id: str, directory: str, filename: str) -> None:
214
+ """Record that an inventory row was denied for want of the file it names.
215
+
216
+ Args:
217
+ session_id: Raw session id keying the shared records file.
218
+ directory: The resolved directory of the inventory document.
219
+ filename: The bare filename the denied row names.
220
+ """
221
+ _record_intent(session_id, ALL_ROW_INTENTS_KEY, directory, filename)
222
+
223
+
224
+ def has_fresh_file_intent(session_id: str, directory: str, filename: str) -> bool:
225
+ """Return whether a fresh file intent names the directory and filename.
226
+
227
+ Args:
228
+ session_id: Raw session id keying the shared records file.
229
+ directory: The resolved directory to match.
230
+ filename: The bare filename to match.
231
+
232
+ Returns:
233
+ True when a matching fresh file intent exists.
234
+ """
235
+ return _has_fresh_intent(session_id, ALL_FILE_INTENTS_KEY, directory, filename)
236
+
237
+
238
+ def has_fresh_row_intent(session_id: str, directory: str, filename: str) -> bool:
239
+ """Return whether a fresh row intent names the directory and filename.
240
+
241
+ Args:
242
+ session_id: Raw session id keying the shared records file.
243
+ directory: The resolved directory to match.
244
+ filename: The bare filename to match.
245
+
246
+ Returns:
247
+ True when a matching fresh row intent exists.
248
+ """
249
+ return _has_fresh_intent(session_id, ALL_ROW_INTENTS_KEY, directory, filename)
250
+
251
+
252
+ def consume_file_intent(session_id: str, directory: str, filename: str) -> None:
253
+ """Remove the file intent naming the directory and filename.
254
+
255
+ Args:
256
+ session_id: Raw session id keying the shared records file.
257
+ directory: The resolved directory of the consumed file intent.
258
+ filename: The bare filename of the consumed file intent.
259
+ """
260
+ _consume_intent(session_id, ALL_FILE_INTENTS_KEY, directory, filename)
261
+
262
+
263
+ def consume_row_intent(session_id: str, directory: str, filename: str) -> None:
264
+ """Remove the row intent naming the directory and filename.
265
+
266
+ Args:
267
+ session_id: Raw session id keying the shared records file.
268
+ directory: The resolved directory of the consumed row intent.
269
+ filename: The bare filename of the consumed row intent.
270
+ """
271
+ _consume_intent(session_id, ALL_ROW_INTENTS_KEY, directory, filename)
@@ -0,0 +1,10 @@
1
+ """Put the blocking and hooks directories on sys.path for the intent-records tests."""
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ _blocking_directory = str(Path(__file__).resolve().parents[2])
7
+ _hooks_directory = str(Path(__file__).resolve().parents[3])
8
+ for each_bootstrap_directory in (_blocking_directory, _hooks_directory):
9
+ if each_bootstrap_directory not in sys.path:
10
+ sys.path.insert(0, each_bootstrap_directory)
@@ -0,0 +1,80 @@
1
+ """Tests for the shared inventory/orphan pending-intent records."""
2
+
3
+ import tempfile
4
+ import time
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+ from inventory_intent_records import records
9
+ from inventory_intent_records.config.intent_records_constants import (
10
+ INTENT_FRESHNESS_WINDOW_SECONDS,
11
+ )
12
+
13
+ SESSION_ID = "session-abc"
14
+ DIRECTORY = "/tmp/package_directory"
15
+ FILENAME = "new_module.py"
16
+ OTHER_FILENAME = "other_module.py"
17
+
18
+
19
+ @pytest.fixture(autouse=True)
20
+ def _isolated_temp_directory(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
21
+ """Point the records file at an isolated temp directory for each test."""
22
+ monkeypatch.setattr(tempfile, "gettempdir", lambda: str(tmp_path))
23
+
24
+
25
+ def test_recorded_file_intent_is_found_fresh() -> None:
26
+ records.record_file_intent(SESSION_ID, DIRECTORY, FILENAME)
27
+ assert records.has_fresh_file_intent(SESSION_ID, DIRECTORY, FILENAME) is True
28
+
29
+
30
+ def test_recorded_row_intent_is_found_fresh() -> None:
31
+ records.record_row_intent(SESSION_ID, DIRECTORY, FILENAME)
32
+ assert records.has_fresh_row_intent(SESSION_ID, DIRECTORY, FILENAME) is True
33
+
34
+
35
+ def test_file_intent_does_not_match_a_different_filename() -> None:
36
+ records.record_file_intent(SESSION_ID, DIRECTORY, FILENAME)
37
+ assert records.has_fresh_file_intent(SESSION_ID, DIRECTORY, OTHER_FILENAME) is False
38
+
39
+
40
+ def test_file_and_row_intents_do_not_cross_match() -> None:
41
+ records.record_file_intent(SESSION_ID, DIRECTORY, FILENAME)
42
+ assert records.has_fresh_row_intent(SESSION_ID, DIRECTORY, FILENAME) is False
43
+
44
+
45
+ def test_consume_file_intent_removes_it() -> None:
46
+ records.record_file_intent(SESSION_ID, DIRECTORY, FILENAME)
47
+ records.consume_file_intent(SESSION_ID, DIRECTORY, FILENAME)
48
+ assert records.has_fresh_file_intent(SESSION_ID, DIRECTORY, FILENAME) is False
49
+
50
+
51
+ def test_consume_row_intent_removes_it() -> None:
52
+ records.record_row_intent(SESSION_ID, DIRECTORY, FILENAME)
53
+ records.consume_row_intent(SESSION_ID, DIRECTORY, FILENAME)
54
+ assert records.has_fresh_row_intent(SESSION_ID, DIRECTORY, FILENAME) is False
55
+
56
+
57
+ def test_stale_intent_is_not_found(monkeypatch: pytest.MonkeyPatch) -> None:
58
+ expired_time = time.time() - INTENT_FRESHNESS_WINDOW_SECONDS - 1.0
59
+ monkeypatch.setattr(records.time, "time", lambda: expired_time)
60
+ records.record_file_intent(SESSION_ID, DIRECTORY, FILENAME)
61
+ monkeypatch.undo()
62
+ assert records.has_fresh_file_intent(SESSION_ID, DIRECTORY, FILENAME) is False
63
+
64
+
65
+ def test_missing_records_file_reads_as_no_intent() -> None:
66
+ assert records.has_fresh_file_intent("never-recorded", DIRECTORY, FILENAME) is False
67
+ assert records.has_fresh_row_intent("never-recorded", DIRECTORY, FILENAME) is False
68
+
69
+
70
+ def test_corrupt_records_file_reads_as_no_intent(tmp_path: Path) -> None:
71
+ corrupt_file = tmp_path / "claude-inventory-intent-session-abc.json"
72
+ corrupt_file.write_text("{not valid json", encoding="utf-8")
73
+ assert records.has_fresh_file_intent(SESSION_ID, DIRECTORY, FILENAME) is False
74
+
75
+
76
+ def test_non_utf8_records_file_reads_as_no_intent(tmp_path: Path) -> None:
77
+ byte_corrupt_file = tmp_path / "claude-inventory-intent-session-abc.json"
78
+ byte_corrupt_file.write_bytes(b"\xff\xfe not valid utf-8")
79
+ assert records.has_fresh_file_intent(SESSION_ID, DIRECTORY, FILENAME) is False
80
+ assert records.has_fresh_row_intent(SESSION_ID, DIRECTORY, FILENAME) is False