@hunter-harness/workflow-harness 0.2.3 → 0.2.5

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 (226) hide show
  1. package/harness/bundles/general/claude-code/.harness-build.json +1 -1
  2. package/harness/bundles/general/claude-code/README.md +12 -11
  3. package/harness/bundles/general/claude-code/harness-archive/SKILL.md +7 -9
  4. package/harness/bundles/general/claude-code/harness-archive/checklist.md +4 -4
  5. package/harness/bundles/general/claude-code/harness-archive/reference.md +3 -5
  6. package/harness/bundles/general/claude-code/harness-codebase-map/SKILL.md +2 -2
  7. package/harness/bundles/general/claude-code/harness-knowledge-ingest/SKILL.md +2 -2
  8. package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +10 -16
  9. package/harness/bundles/general/claude-code/harness-plan/SKILL.md +14 -10
  10. package/harness/bundles/general/claude-code/harness-plan/checklist.md +28 -13
  11. package/harness/bundles/general/claude-code/harness-plan/protocols.md +10 -3
  12. package/harness/bundles/general/claude-code/harness-plan/reference.md +9 -23
  13. package/harness/bundles/general/claude-code/harness-review/SKILL.md +2 -2
  14. package/harness/bundles/general/claude-code/harness-run/SKILL.md +16 -4
  15. package/harness/bundles/general/claude-code/harness-run/checklist.md +7 -3
  16. package/harness/bundles/general/claude-code/harness-run/protocols.md +3 -2
  17. package/harness/bundles/general/claude-code/harness-run/reference.md +3 -9
  18. package/harness/bundles/general/claude-code/harness-submit/SKILL.md +6 -4
  19. package/harness/bundles/general/claude-code/harness-submit/checklist.md +10 -1
  20. package/harness/bundles/general/claude-code/harness-sync/SKILL.md +2 -2
  21. package/harness/bundles/general/claude-code/harness-test/SKILL.md +15 -3
  22. package/harness/bundles/general/claude-code/harness-test/checklist.md +4 -0
  23. package/harness/bundles/general/claude-code/harness-test/reference.md +4 -4
  24. package/harness/bundles/general/claude-code/protocols/archive-report-protocol.md +2 -4
  25. package/harness/bundles/general/claude-code/protocols/ledger-protocol.md +7 -15
  26. package/harness/bundles/general/claude-code/scripts/harness_ledger.py +111 -5
  27. package/harness/bundles/general/claude-code/scripts/harness_profile.py +29 -1
  28. package/harness/bundles/general/claude-code/scripts/harness_test_guard.py +502 -0
  29. package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
  30. package/harness/bundles/general/codebuddy/README.md +12 -11
  31. package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +7 -9
  32. package/harness/bundles/general/codebuddy/harness-archive/checklist.md +4 -4
  33. package/harness/bundles/general/codebuddy/harness-archive/reference.md +3 -5
  34. package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +2 -2
  35. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +2 -2
  36. package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +10 -16
  37. package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +13 -9
  38. package/harness/bundles/general/codebuddy/harness-plan/checklist.md +28 -13
  39. package/harness/bundles/general/codebuddy/harness-plan/protocols.md +10 -3
  40. package/harness/bundles/general/codebuddy/harness-plan/reference.md +9 -23
  41. package/harness/bundles/general/codebuddy/harness-review/SKILL.md +2 -2
  42. package/harness/bundles/general/codebuddy/harness-run/SKILL.md +16 -4
  43. package/harness/bundles/general/codebuddy/harness-run/checklist.md +7 -3
  44. package/harness/bundles/general/codebuddy/harness-run/protocols.md +3 -2
  45. package/harness/bundles/general/codebuddy/harness-run/reference.md +3 -9
  46. package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +6 -4
  47. package/harness/bundles/general/codebuddy/harness-submit/checklist.md +10 -1
  48. package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +2 -2
  49. package/harness/bundles/general/codebuddy/harness-test/SKILL.md +15 -3
  50. package/harness/bundles/general/codebuddy/harness-test/checklist.md +4 -0
  51. package/harness/bundles/general/codebuddy/harness-test/reference.md +4 -4
  52. package/harness/bundles/general/codebuddy/protocols/archive-report-protocol.md +2 -4
  53. package/harness/bundles/general/codebuddy/protocols/ledger-protocol.md +7 -15
  54. package/harness/bundles/general/codebuddy/scripts/harness_ledger.py +111 -5
  55. package/harness/bundles/general/codebuddy/scripts/harness_profile.py +29 -1
  56. package/harness/bundles/general/codebuddy/scripts/harness_test_guard.py +502 -0
  57. package/harness/bundles/general/codex/.harness-build.json +1 -1
  58. package/harness/bundles/general/codex/README.md +12 -11
  59. package/harness/bundles/general/codex/harness-archive/SKILL.md +7 -9
  60. package/harness/bundles/general/codex/harness-archive/checklist.md +4 -4
  61. package/harness/bundles/general/codex/harness-archive/reference.md +3 -5
  62. package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +2 -2
  63. package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +2 -2
  64. package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +10 -16
  65. package/harness/bundles/general/codex/harness-plan/SKILL.md +13 -9
  66. package/harness/bundles/general/codex/harness-plan/checklist.md +28 -13
  67. package/harness/bundles/general/codex/harness-plan/protocols.md +10 -3
  68. package/harness/bundles/general/codex/harness-plan/reference.md +9 -23
  69. package/harness/bundles/general/codex/harness-review/SKILL.md +2 -2
  70. package/harness/bundles/general/codex/harness-run/SKILL.md +16 -4
  71. package/harness/bundles/general/codex/harness-run/checklist.md +7 -3
  72. package/harness/bundles/general/codex/harness-run/protocols.md +3 -2
  73. package/harness/bundles/general/codex/harness-run/reference.md +3 -9
  74. package/harness/bundles/general/codex/harness-submit/SKILL.md +6 -4
  75. package/harness/bundles/general/codex/harness-submit/checklist.md +10 -1
  76. package/harness/bundles/general/codex/harness-sync/SKILL.md +2 -2
  77. package/harness/bundles/general/codex/harness-test/SKILL.md +15 -3
  78. package/harness/bundles/general/codex/harness-test/checklist.md +4 -0
  79. package/harness/bundles/general/codex/harness-test/reference.md +4 -4
  80. package/harness/bundles/general/codex/protocols/archive-report-protocol.md +2 -4
  81. package/harness/bundles/general/codex/protocols/ledger-protocol.md +7 -15
  82. package/harness/bundles/general/codex/scripts/harness_ledger.py +111 -5
  83. package/harness/bundles/general/codex/scripts/harness_profile.py +29 -1
  84. package/harness/bundles/general/codex/scripts/harness_test_guard.py +502 -0
  85. package/harness/bundles/general/cursor/.harness-build.json +1 -1
  86. package/harness/bundles/general/cursor/README.md +12 -11
  87. package/harness/bundles/general/cursor/harness-archive/SKILL.md +7 -9
  88. package/harness/bundles/general/cursor/harness-archive/checklist.md +4 -4
  89. package/harness/bundles/general/cursor/harness-archive/reference.md +3 -5
  90. package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +2 -2
  91. package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +2 -2
  92. package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +10 -16
  93. package/harness/bundles/general/cursor/harness-plan/SKILL.md +13 -9
  94. package/harness/bundles/general/cursor/harness-plan/checklist.md +28 -13
  95. package/harness/bundles/general/cursor/harness-plan/protocols.md +10 -3
  96. package/harness/bundles/general/cursor/harness-plan/reference.md +9 -23
  97. package/harness/bundles/general/cursor/harness-review/SKILL.md +2 -2
  98. package/harness/bundles/general/cursor/harness-run/SKILL.md +16 -4
  99. package/harness/bundles/general/cursor/harness-run/checklist.md +7 -3
  100. package/harness/bundles/general/cursor/harness-run/protocols.md +3 -2
  101. package/harness/bundles/general/cursor/harness-run/reference.md +3 -9
  102. package/harness/bundles/general/cursor/harness-submit/SKILL.md +6 -4
  103. package/harness/bundles/general/cursor/harness-submit/checklist.md +10 -1
  104. package/harness/bundles/general/cursor/harness-sync/SKILL.md +2 -2
  105. package/harness/bundles/general/cursor/harness-test/SKILL.md +15 -3
  106. package/harness/bundles/general/cursor/harness-test/checklist.md +4 -0
  107. package/harness/bundles/general/cursor/harness-test/reference.md +4 -4
  108. package/harness/bundles/general/cursor/protocols/archive-report-protocol.md +2 -4
  109. package/harness/bundles/general/cursor/protocols/ledger-protocol.md +7 -15
  110. package/harness/bundles/general/cursor/scripts/harness_ledger.py +111 -5
  111. package/harness/bundles/general/cursor/scripts/harness_profile.py +29 -1
  112. package/harness/bundles/general/cursor/scripts/harness_test_guard.py +502 -0
  113. package/harness/bundles/java/claude-code/.harness-build.json +1 -1
  114. package/harness/bundles/java/claude-code/README.md +12 -11
  115. package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +2 -2
  116. package/harness/bundles/java/claude-code/harness-archive/SKILL.md +7 -9
  117. package/harness/bundles/java/claude-code/harness-archive/checklist.md +4 -4
  118. package/harness/bundles/java/claude-code/harness-archive/reference.md +3 -5
  119. package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +2 -2
  120. package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +2 -2
  121. package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +10 -16
  122. package/harness/bundles/java/claude-code/harness-package/SKILL.md +2 -2
  123. package/harness/bundles/java/claude-code/harness-plan/SKILL.md +14 -10
  124. package/harness/bundles/java/claude-code/harness-plan/checklist.md +28 -13
  125. package/harness/bundles/java/claude-code/harness-plan/protocols.md +10 -3
  126. package/harness/bundles/java/claude-code/harness-plan/reference.md +9 -23
  127. package/harness/bundles/java/claude-code/harness-review/SKILL.md +2 -2
  128. package/harness/bundles/java/claude-code/harness-run/SKILL.md +19 -5
  129. package/harness/bundles/java/claude-code/harness-run/protocols.md +3 -2
  130. package/harness/bundles/java/claude-code/harness-submit/SKILL.md +6 -4
  131. package/harness/bundles/java/claude-code/harness-submit/checklist.md +10 -1
  132. package/harness/bundles/java/claude-code/harness-sync/SKILL.md +2 -2
  133. package/harness/bundles/java/claude-code/harness-test/SKILL.md +17 -3
  134. package/harness/bundles/java/claude-code/protocols/archive-report-protocol.md +2 -4
  135. package/harness/bundles/java/claude-code/protocols/ledger-protocol.md +7 -15
  136. package/harness/bundles/java/claude-code/scripts/harness_ledger.py +111 -5
  137. package/harness/bundles/java/claude-code/scripts/harness_profile.py +29 -1
  138. package/harness/bundles/java/claude-code/scripts/harness_test_guard.py +502 -0
  139. package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
  140. package/harness/bundles/java/codebuddy/README.md +12 -11
  141. package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +2 -2
  142. package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +7 -9
  143. package/harness/bundles/java/codebuddy/harness-archive/checklist.md +4 -4
  144. package/harness/bundles/java/codebuddy/harness-archive/reference.md +3 -5
  145. package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +2 -2
  146. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +2 -2
  147. package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +10 -16
  148. package/harness/bundles/java/codebuddy/harness-package/SKILL.md +2 -2
  149. package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +13 -9
  150. package/harness/bundles/java/codebuddy/harness-plan/checklist.md +28 -13
  151. package/harness/bundles/java/codebuddy/harness-plan/protocols.md +10 -3
  152. package/harness/bundles/java/codebuddy/harness-plan/reference.md +9 -23
  153. package/harness/bundles/java/codebuddy/harness-review/SKILL.md +2 -2
  154. package/harness/bundles/java/codebuddy/harness-run/SKILL.md +19 -5
  155. package/harness/bundles/java/codebuddy/harness-run/protocols.md +3 -2
  156. package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +6 -4
  157. package/harness/bundles/java/codebuddy/harness-submit/checklist.md +10 -1
  158. package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +2 -2
  159. package/harness/bundles/java/codebuddy/harness-test/SKILL.md +17 -3
  160. package/harness/bundles/java/codebuddy/protocols/archive-report-protocol.md +2 -4
  161. package/harness/bundles/java/codebuddy/protocols/ledger-protocol.md +7 -15
  162. package/harness/bundles/java/codebuddy/scripts/harness_ledger.py +111 -5
  163. package/harness/bundles/java/codebuddy/scripts/harness_profile.py +29 -1
  164. package/harness/bundles/java/codebuddy/scripts/harness_test_guard.py +502 -0
  165. package/harness/bundles/java/codex/.harness-build.json +1 -1
  166. package/harness/bundles/java/codex/README.md +12 -11
  167. package/harness/bundles/java/codex/harness-apidoc/SKILL.md +2 -2
  168. package/harness/bundles/java/codex/harness-archive/SKILL.md +7 -9
  169. package/harness/bundles/java/codex/harness-archive/checklist.md +4 -4
  170. package/harness/bundles/java/codex/harness-archive/reference.md +3 -5
  171. package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +2 -2
  172. package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +2 -2
  173. package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +10 -16
  174. package/harness/bundles/java/codex/harness-package/SKILL.md +2 -2
  175. package/harness/bundles/java/codex/harness-plan/SKILL.md +13 -9
  176. package/harness/bundles/java/codex/harness-plan/checklist.md +28 -13
  177. package/harness/bundles/java/codex/harness-plan/protocols.md +10 -3
  178. package/harness/bundles/java/codex/harness-plan/reference.md +9 -23
  179. package/harness/bundles/java/codex/harness-review/SKILL.md +2 -2
  180. package/harness/bundles/java/codex/harness-run/SKILL.md +19 -5
  181. package/harness/bundles/java/codex/harness-run/protocols.md +3 -2
  182. package/harness/bundles/java/codex/harness-submit/SKILL.md +6 -4
  183. package/harness/bundles/java/codex/harness-submit/checklist.md +10 -1
  184. package/harness/bundles/java/codex/harness-sync/SKILL.md +2 -2
  185. package/harness/bundles/java/codex/harness-test/SKILL.md +17 -3
  186. package/harness/bundles/java/codex/protocols/archive-report-protocol.md +2 -4
  187. package/harness/bundles/java/codex/protocols/ledger-protocol.md +7 -15
  188. package/harness/bundles/java/codex/scripts/harness_ledger.py +111 -5
  189. package/harness/bundles/java/codex/scripts/harness_profile.py +29 -1
  190. package/harness/bundles/java/codex/scripts/harness_test_guard.py +502 -0
  191. package/harness/bundles/java/cursor/.harness-build.json +1 -1
  192. package/harness/bundles/java/cursor/README.md +12 -11
  193. package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +2 -2
  194. package/harness/bundles/java/cursor/harness-archive/SKILL.md +7 -9
  195. package/harness/bundles/java/cursor/harness-archive/checklist.md +4 -4
  196. package/harness/bundles/java/cursor/harness-archive/reference.md +3 -5
  197. package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +2 -2
  198. package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +2 -2
  199. package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +10 -16
  200. package/harness/bundles/java/cursor/harness-package/SKILL.md +2 -2
  201. package/harness/bundles/java/cursor/harness-plan/SKILL.md +13 -9
  202. package/harness/bundles/java/cursor/harness-plan/checklist.md +28 -13
  203. package/harness/bundles/java/cursor/harness-plan/protocols.md +10 -3
  204. package/harness/bundles/java/cursor/harness-plan/reference.md +9 -23
  205. package/harness/bundles/java/cursor/harness-review/SKILL.md +2 -2
  206. package/harness/bundles/java/cursor/harness-run/SKILL.md +19 -5
  207. package/harness/bundles/java/cursor/harness-run/protocols.md +3 -2
  208. package/harness/bundles/java/cursor/harness-submit/SKILL.md +6 -4
  209. package/harness/bundles/java/cursor/harness-submit/checklist.md +10 -1
  210. package/harness/bundles/java/cursor/harness-sync/SKILL.md +2 -2
  211. package/harness/bundles/java/cursor/harness-test/SKILL.md +17 -3
  212. package/harness/bundles/java/cursor/protocols/archive-report-protocol.md +2 -4
  213. package/harness/bundles/java/cursor/protocols/ledger-protocol.md +7 -15
  214. package/harness/bundles/java/cursor/scripts/harness_ledger.py +111 -5
  215. package/harness/bundles/java/cursor/scripts/harness_profile.py +29 -1
  216. package/harness/bundles/java/cursor/scripts/harness_test_guard.py +502 -0
  217. package/harness/manifests/general/claude-code.json +32 -28
  218. package/harness/manifests/general/codebuddy.json +32 -28
  219. package/harness/manifests/general/codex.json +32 -28
  220. package/harness/manifests/general/cursor.json +32 -28
  221. package/harness/manifests/java/claude-code.json +30 -26
  222. package/harness/manifests/java/codebuddy.json +30 -26
  223. package/harness/manifests/java/codex.json +30 -26
  224. package/harness/manifests/java/cursor.json +30 -26
  225. package/hunter-workflow-family.json +2 -2
  226. package/package.json +1 -1
@@ -0,0 +1,502 @@
1
+ #!/usr/bin/env python3
2
+ """Record and force-stage only explicitly touched test files.
3
+
4
+ Python 3.10+ stdlib only. Git is always invoked with an argv list.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import contextlib
11
+ import fnmatch
12
+ import hashlib
13
+ import json
14
+ import os
15
+ import re
16
+ import shutil
17
+ import subprocess
18
+ import sys
19
+ import tempfile
20
+ import time
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ SCHEMA_VERSION = 1
25
+ MODE = "force-track-touched"
26
+ MANIFEST_REL = Path("evidence") / "test-tracking.json"
27
+ PROFILE_REL = Path(".harness") / "config" / "build-profile.json"
28
+ REASONS = ("tdd-created", "stale-test-repair", "test-updated")
29
+
30
+
31
+ class LockUnavailable(RuntimeError):
32
+ """A compatible lock file is already held by another process."""
33
+
34
+ if hasattr(sys.stdout, "reconfigure"):
35
+ sys.stdout.reconfigure(encoding="utf-8")
36
+ if hasattr(sys.stderr, "reconfigure"):
37
+ sys.stderr.reconfigure(encoding="utf-8")
38
+
39
+
40
+ def _result(ok: bool, action: str, code: str, files: list[str], **extra: Any) -> dict[str, Any]:
41
+ return {"ok": ok, "action": action, "code": code, "files": files, **extra}
42
+
43
+
44
+ def _write_json(path: Path, data: Any) -> None:
45
+ path.parent.mkdir(parents=True, exist_ok=True)
46
+ text = json.dumps(data, ensure_ascii=False, indent=2) + "\n"
47
+ tmp = path.with_name(f".{path.name}.{os.getpid()}.tmp")
48
+ try:
49
+ tmp.write_text(text, encoding="utf-8", newline="\n")
50
+ os.replace(tmp, path)
51
+ except BaseException:
52
+ tmp.unlink(missing_ok=True)
53
+ raise
54
+
55
+
56
+ def _read_json(path: Path) -> Any:
57
+ return json.loads(path.read_text(encoding="utf-8-sig"))
58
+
59
+
60
+ def _sha256(path: Path) -> str:
61
+ digest = hashlib.sha256()
62
+ with path.open("rb") as handle:
63
+ for chunk in iter(lambda: handle.read(65536), b""):
64
+ digest.update(chunk)
65
+ return "sha256:" + digest.hexdigest()
66
+
67
+
68
+ def _git(
69
+ project: Path,
70
+ *args: str,
71
+ index_file: Path | None = None,
72
+ ) -> subprocess.CompletedProcess[str]:
73
+ env = os.environ.copy()
74
+ if index_file is not None:
75
+ env["GIT_INDEX_FILE"] = str(index_file)
76
+ return subprocess.run(
77
+ [
78
+ "git",
79
+ "--literal-pathspecs",
80
+ "-c",
81
+ "core.quotepath=false",
82
+ "-C",
83
+ str(project),
84
+ *args,
85
+ ],
86
+ capture_output=True,
87
+ text=True,
88
+ encoding="utf-8",
89
+ errors="replace",
90
+ timeout=30,
91
+ check=False,
92
+ env=env,
93
+ )
94
+
95
+
96
+ def _is_ignored(project: Path, rel: str) -> bool:
97
+ status = _git(
98
+ project,
99
+ "status",
100
+ "--ignored=matching",
101
+ "--untracked-files=all",
102
+ "--porcelain=v1",
103
+ "--",
104
+ rel,
105
+ )
106
+ return status.returncode == 0 and any(
107
+ line.startswith("!! ") for line in status.stdout.splitlines()
108
+ )
109
+
110
+
111
+ def _inside(path: Path, root: Path) -> bool:
112
+ try:
113
+ normalized_path = os.path.normcase(str(path))
114
+ normalized_root = os.path.normcase(str(root))
115
+ return os.path.commonpath((normalized_path, normalized_root)) == normalized_root
116
+ except ValueError:
117
+ return False
118
+
119
+
120
+ @contextlib.contextmanager
121
+ def _exclusive_lock(path: Path, *, wait_seconds: float) -> Any:
122
+ path.parent.mkdir(parents=True, exist_ok=True)
123
+ deadline = time.monotonic() + wait_seconds
124
+ descriptor: int | None = None
125
+ while descriptor is None:
126
+ try:
127
+ descriptor = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
128
+ except FileExistsError as exc:
129
+ if time.monotonic() >= deadline:
130
+ raise LockUnavailable(str(path)) from exc
131
+ time.sleep(0.01)
132
+ try:
133
+ os.write(descriptor, f"{os.getpid()}\n".encode("ascii"))
134
+ yield
135
+ finally:
136
+ os.close(descriptor)
137
+ path.unlink(missing_ok=True)
138
+
139
+
140
+ def _change_dir(project: Path, change_dir: Path | str) -> Path | None:
141
+ candidate = Path(change_dir)
142
+ if not candidate.is_absolute():
143
+ candidate = project / candidate
144
+ resolved = candidate.resolve()
145
+ return resolved if _inside(resolved, project) else None
146
+
147
+
148
+ def _manifest_path(project: Path, change_root: Path) -> Path | None:
149
+ evidence = change_root / MANIFEST_REL.parent
150
+ manifest = change_root / MANIFEST_REL
151
+ expected_evidence = evidence.absolute()
152
+ resolved_evidence = evidence.resolve()
153
+ if (
154
+ not _inside(change_root.resolve(), project)
155
+ or not _inside(resolved_evidence, project)
156
+ or os.path.normcase(str(resolved_evidence))
157
+ != os.path.normcase(str(expected_evidence))
158
+ ):
159
+ return None
160
+ return manifest
161
+
162
+
163
+ def _manifest_target_inside(project: Path, manifest: Path) -> bool:
164
+ resolved = manifest.resolve()
165
+ return _inside(resolved, project) and _inside(resolved, manifest.parent.absolute())
166
+
167
+
168
+ def _profile_config(project: Path) -> tuple[list[str], list[str]] | None:
169
+ profile_path = project / PROFILE_REL
170
+ if not profile_path.is_file():
171
+ return None
172
+ try:
173
+ profile = _read_json(profile_path)
174
+ except (OSError, json.JSONDecodeError):
175
+ return [], []
176
+ tracking = profile.get("testTracking") if isinstance(profile, dict) else None
177
+ paths = tracking.get("paths") if isinstance(tracking, dict) else None
178
+ if not isinstance(paths, list):
179
+ return [], []
180
+ excluded = profile.get("excludedRoots") if isinstance(profile, dict) else None
181
+ excluded_roots = (
182
+ [item.replace("\\", "/").strip("/") for item in excluded if isinstance(item, str) and item]
183
+ if isinstance(excluded, list)
184
+ else []
185
+ )
186
+ patterns = [
187
+ item.replace("\\", "/") for item in paths if isinstance(item, str) and item
188
+ ]
189
+ return patterns, excluded_roots
190
+
191
+
192
+ def _matches_pattern(rel: str, pattern: str) -> bool:
193
+ # fnmatch does not give **/ its usual zero-or-more-directory meaning, so
194
+ # also try the form with every **/ removed.
195
+ if fnmatch.fnmatchcase(rel, pattern):
196
+ return True
197
+ compact = pattern
198
+ while "**/" in compact:
199
+ compact = compact.replace("**/", "", 1)
200
+ if fnmatch.fnmatchcase(rel, compact):
201
+ return True
202
+ if pattern.endswith("/**"):
203
+ prefix = pattern[:-3].rstrip("/")
204
+ return rel == prefix or rel.startswith(prefix + "/")
205
+ return False
206
+
207
+
208
+ def _standard_test_path(rel: str) -> bool:
209
+ parts = rel.split("/")
210
+ if len(parts) >= 2 and parts[0] in ("test", "tests"):
211
+ return True
212
+ if any(parts[index:index + 2] == ["src", "test"] for index in range(len(parts) - 1)):
213
+ return True
214
+ return False
215
+
216
+
217
+ def _allowed_test_path(project: Path, rel: str) -> bool:
218
+ config = _profile_config(project)
219
+ if config is None:
220
+ return _standard_test_path(rel)
221
+ patterns, excluded_roots = config
222
+ rel_parts = tuple(os.path.normcase(part) for part in rel.split("/"))
223
+ for excluded in excluded_roots:
224
+ excluded_parts = tuple(os.path.normcase(part) for part in excluded.split("/"))
225
+ if rel_parts[: len(excluded_parts)] == excluded_parts:
226
+ return False
227
+ return any(_matches_pattern(rel, pattern) for pattern in patterns)
228
+
229
+
230
+ def _validate_file(project: Path, raw: str) -> tuple[Path | None, str | None, str | None]:
231
+ candidate = Path(raw)
232
+ if not candidate.is_absolute():
233
+ candidate = project / candidate
234
+ resolved = candidate.resolve()
235
+ if not _inside(resolved, project):
236
+ return None, None, "PATH_OUTSIDE_PROJECT"
237
+ if not resolved.exists():
238
+ return None, None, "FILE_NOT_FOUND"
239
+ if not resolved.is_file():
240
+ return None, None, "NOT_REGULAR_FILE"
241
+ rel = resolved.relative_to(project).as_posix()
242
+ if not _allowed_test_path(project, rel):
243
+ return None, rel, "TEST_PATH_NOT_ALLOWED"
244
+ return resolved, rel, None
245
+
246
+
247
+ def _entry_shape_valid(item: Any) -> bool:
248
+ return (
249
+ isinstance(item, dict)
250
+ and isinstance(item.get("path"), str)
251
+ and bool(item["path"])
252
+ and isinstance(item.get("sha256"), str)
253
+ and re.fullmatch(r"sha256:[0-9a-f]{64}", item["sha256"]) is not None
254
+ and item.get("reason") in REASONS
255
+ and type(item.get("ignored")) is bool
256
+ and type(item.get("trackedBefore")) is bool
257
+ )
258
+
259
+
260
+ def _validate_existing_manifest(
261
+ project: Path,
262
+ manifest: Any,
263
+ *,
264
+ allow_hash_drift: set[str] | None = None,
265
+ require_files: bool,
266
+ ) -> tuple[str | None, dict[str, dict[str, Any]]]:
267
+ if (
268
+ not isinstance(manifest, dict)
269
+ or manifest.get("schemaVersion") != SCHEMA_VERSION
270
+ or manifest.get("mode") != MODE
271
+ ):
272
+ return "MANIFEST_INVALID", {}
273
+ if manifest.get("projectRoot") != str(project):
274
+ return "MANIFEST_PROJECT_MISMATCH", {}
275
+ entries = manifest.get("files")
276
+ if not isinstance(entries, list):
277
+ return "MANIFEST_INVALID", {}
278
+ if require_files and not entries:
279
+ return "EMPTY_MANIFEST", {}
280
+
281
+ allowed_drift = allow_hash_drift or set()
282
+ validated: dict[str, dict[str, Any]] = {}
283
+ for item in entries:
284
+ if not _entry_shape_valid(item):
285
+ return "MANIFEST_INVALID", {}
286
+ rel = item["path"]
287
+ if rel in validated:
288
+ return "MANIFEST_INVALID", {}
289
+ path, normalized, error = _validate_file(project, rel)
290
+ if error or normalized != rel or path is None:
291
+ return error or "MANIFEST_INVALID", {}
292
+ if item["sha256"] != _sha256(path) and rel not in allowed_drift:
293
+ return "HASH_DRIFT" if require_files else "MANIFEST_INVALID", {}
294
+ ignored_now = _is_ignored(project, rel)
295
+ tracked_now = _git(
296
+ project, "ls-files", "--error-unmatch", "--", rel
297
+ ).returncode == 0
298
+ if item["ignored"] != ignored_now or item["trackedBefore"] != tracked_now:
299
+ return "MANIFEST_INVALID", {}
300
+ validated[rel] = dict(item)
301
+ return None, validated
302
+
303
+
304
+ def record(
305
+ project: Path | str,
306
+ change_dir: Path | str,
307
+ files: list[str],
308
+ reason: str,
309
+ ) -> dict[str, Any]:
310
+ action = "record"
311
+ project_root = Path(project).resolve()
312
+ if not files:
313
+ return _result(False, action, "EMPTY_FILES", [])
314
+ if reason not in REASONS:
315
+ return _result(False, action, "INVALID_REASON", [])
316
+ change_root = _change_dir(project_root, change_dir)
317
+ if change_root is None:
318
+ return _result(False, action, "CHANGE_DIR_OUTSIDE_PROJECT", [])
319
+
320
+ validated: list[tuple[Path, str]] = []
321
+ for raw in files:
322
+ path, rel, error = _validate_file(project_root, raw)
323
+ if error:
324
+ return _result(False, action, error, [rel or str(raw)])
325
+ assert path is not None and rel is not None
326
+ validated.append((path, rel))
327
+
328
+ manifest_path = _manifest_path(project_root, change_root)
329
+ if manifest_path is None:
330
+ return _result(False, action, "MANIFEST_PATH_OUTSIDE_PROJECT", [])
331
+ lock_path = manifest_path.with_name(manifest_path.name + ".lock")
332
+ try:
333
+ with _exclusive_lock(lock_path, wait_seconds=5.0):
334
+ if not _manifest_target_inside(project_root, manifest_path):
335
+ return _result(
336
+ False, action, "MANIFEST_PATH_OUTSIDE_PROJECT", []
337
+ )
338
+ existing_files: dict[str, dict[str, Any]] = {}
339
+ if manifest_path.is_file():
340
+ try:
341
+ existing = _read_json(manifest_path)
342
+ except (OSError, json.JSONDecodeError) as exc:
343
+ return _result(
344
+ False, action, "MANIFEST_INVALID", [], error=str(exc)
345
+ )
346
+ error, existing_files = _validate_existing_manifest(
347
+ project_root,
348
+ existing,
349
+ allow_hash_drift={rel for _, rel in validated},
350
+ require_files=False,
351
+ )
352
+ if error:
353
+ return _result(False, action, error, [])
354
+
355
+ for path, rel in validated:
356
+ ignored = _is_ignored(project_root, rel)
357
+ tracked = _git(
358
+ project_root, "ls-files", "--error-unmatch", "--", rel
359
+ ).returncode == 0
360
+ existing_files[rel] = {
361
+ "path": rel,
362
+ "sha256": _sha256(path),
363
+ "reason": reason,
364
+ "ignored": ignored,
365
+ "trackedBefore": tracked,
366
+ }
367
+
368
+ manifest = {
369
+ "schemaVersion": SCHEMA_VERSION,
370
+ "mode": MODE,
371
+ "projectRoot": str(project_root),
372
+ "files": [existing_files[key] for key in sorted(existing_files)],
373
+ }
374
+ _write_json(manifest_path, manifest)
375
+ except LockUnavailable:
376
+ return _result(False, action, "MANIFEST_LOCKED", [])
377
+ return _result(True, action, "RECORDED", [rel for _, rel in validated], manifestPath=str(manifest_path))
378
+
379
+
380
+ def _stage_locked(
381
+ project_root: Path, manifest_path: Path, index_path: Path
382
+ ) -> dict[str, Any]:
383
+ action = "stage"
384
+ if not _manifest_target_inside(project_root, manifest_path):
385
+ return _result(False, action, "MANIFEST_PATH_OUTSIDE_PROJECT", [])
386
+ try:
387
+ manifest = _read_json(manifest_path)
388
+ except FileNotFoundError:
389
+ return _result(False, action, "MANIFEST_MISSING", [])
390
+ except (OSError, json.JSONDecodeError) as exc:
391
+ return _result(False, action, "MANIFEST_INVALID", [], error=str(exc))
392
+ error, entries = _validate_existing_manifest(
393
+ project_root, manifest, require_files=True
394
+ )
395
+ if error:
396
+ return _result(False, action, error, [])
397
+ rels = list(entries)
398
+
399
+ before_cached_result = _git(project_root, "diff", "--cached", "--name-only")
400
+ if before_cached_result.returncode != 0:
401
+ return _result(False, action, "CACHED_DIFF_FAILED", rels)
402
+ before_cached = set(before_cached_result.stdout.splitlines())
403
+
404
+ handle, temp_name = tempfile.mkstemp(
405
+ prefix=f".{index_path.name}.test-guard.", dir=index_path.parent
406
+ )
407
+ os.close(handle)
408
+ temp_index = Path(temp_name)
409
+ temp_index.unlink(missing_ok=True)
410
+ try:
411
+ if index_path.is_file():
412
+ shutil.copy2(index_path, temp_index)
413
+ added = _git(project_root, "add", "-f", "--", *rels, index_file=temp_index)
414
+ if added.returncode != 0:
415
+ return _result(
416
+ False,
417
+ action,
418
+ "GIT_ADD_FAILED",
419
+ rels,
420
+ error=added.stderr.strip(),
421
+ )
422
+ cached = _git(
423
+ project_root,
424
+ "diff",
425
+ "--cached",
426
+ "--name-only",
427
+ index_file=temp_index,
428
+ )
429
+ if cached.returncode != 0:
430
+ return _result(False, action, "CACHED_DIFF_FAILED", rels)
431
+ after_cached = set(cached.stdout.splitlines())
432
+ missing = [rel for rel in rels if rel not in after_cached]
433
+ unexpected = sorted((after_cached - before_cached) - set(rels))
434
+ if missing or unexpected:
435
+ return _result(
436
+ False,
437
+ action,
438
+ "CACHED_DIFF_MISMATCH",
439
+ missing or unexpected,
440
+ )
441
+ os.replace(temp_index, index_path)
442
+ return _result(True, action, "STAGED", rels)
443
+ finally:
444
+ temp_index.unlink(missing_ok=True)
445
+
446
+
447
+ def stage(project: Path | str, change_dir: Path | str) -> dict[str, Any]:
448
+ action = "stage"
449
+ project_root = Path(project).resolve()
450
+ change_root = _change_dir(project_root, change_dir)
451
+ if change_root is None:
452
+ return _result(False, action, "CHANGE_DIR_OUTSIDE_PROJECT", [])
453
+ manifest_path = _manifest_path(project_root, change_root)
454
+ if manifest_path is None:
455
+ return _result(False, action, "MANIFEST_PATH_OUTSIDE_PROJECT", [])
456
+
457
+ index_result = _git(project_root, "rev-parse", "--git-path", "index")
458
+ if index_result.returncode != 0 or not index_result.stdout.strip():
459
+ return _result(False, action, "GIT_INDEX_NOT_FOUND", [])
460
+ index_path = Path(index_result.stdout.strip())
461
+ if not index_path.is_absolute():
462
+ index_path = (project_root / index_path).resolve()
463
+ index_path.parent.mkdir(parents=True, exist_ok=True)
464
+
465
+ index_lock = index_path.with_name(index_path.name + ".lock")
466
+ try:
467
+ with _exclusive_lock(index_lock, wait_seconds=0.0):
468
+ manifest_lock = manifest_path.with_name(manifest_path.name + ".lock")
469
+ try:
470
+ with _exclusive_lock(manifest_lock, wait_seconds=0.0):
471
+ return _stage_locked(project_root, manifest_path, index_path)
472
+ except LockUnavailable:
473
+ return _result(False, action, "MANIFEST_LOCKED", [])
474
+ except LockUnavailable:
475
+ return _result(False, action, "INDEX_LOCKED", [])
476
+
477
+
478
+ def main(argv: list[str] | None = None) -> int:
479
+ parser = argparse.ArgumentParser(description=__doc__)
480
+ sub = parser.add_subparsers(dest="action", required=True)
481
+ record_parser = sub.add_parser("record")
482
+ record_parser.add_argument("--project", required=True)
483
+ record_parser.add_argument("--change-dir", required=True)
484
+ record_parser.add_argument("--files", required=True)
485
+ record_parser.add_argument("--reason", required=True, choices=REASONS)
486
+ record_parser.add_argument("--json", action="store_true")
487
+ stage_parser = sub.add_parser("stage")
488
+ stage_parser.add_argument("--project", required=True)
489
+ stage_parser.add_argument("--change-dir", required=True)
490
+ stage_parser.add_argument("--json", action="store_true")
491
+ args = parser.parse_args(argv)
492
+ if args.action == "record":
493
+ files = [item.strip() for item in args.files.split(",") if item.strip()]
494
+ result = record(args.project, args.change_dir, files, args.reason)
495
+ else:
496
+ result = stage(args.project, args.change_dir)
497
+ print(json.dumps(result, ensure_ascii=False, indent=2 if args.json else None))
498
+ return 0 if result["ok"] else 1
499
+
500
+
501
+ if __name__ == "__main__":
502
+ raise SystemExit(main())