@hunter-harness/workflow-harness 0.2.4 → 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 (170) hide show
  1. package/harness/bundles/general/claude-code/.harness-build.json +1 -1
  2. package/harness/bundles/general/claude-code/harness-archive/SKILL.md +1 -1
  3. package/harness/bundles/general/claude-code/harness-codebase-map/SKILL.md +1 -1
  4. package/harness/bundles/general/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  5. package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +1 -1
  6. package/harness/bundles/general/claude-code/harness-plan/SKILL.md +1 -1
  7. package/harness/bundles/general/claude-code/harness-review/SKILL.md +1 -1
  8. package/harness/bundles/general/claude-code/harness-run/SKILL.md +15 -3
  9. package/harness/bundles/general/claude-code/harness-run/checklist.md +7 -3
  10. package/harness/bundles/general/claude-code/harness-run/reference.md +3 -9
  11. package/harness/bundles/general/claude-code/harness-submit/SKILL.md +5 -3
  12. package/harness/bundles/general/claude-code/harness-submit/checklist.md +10 -1
  13. package/harness/bundles/general/claude-code/harness-sync/SKILL.md +1 -1
  14. package/harness/bundles/general/claude-code/harness-test/SKILL.md +14 -2
  15. package/harness/bundles/general/claude-code/harness-test/checklist.md +4 -0
  16. package/harness/bundles/general/claude-code/harness-test/reference.md +4 -4
  17. package/harness/bundles/general/claude-code/protocols/archive-report-protocol.md +2 -4
  18. package/harness/bundles/general/claude-code/protocols/ledger-protocol.md +7 -15
  19. package/harness/bundles/general/claude-code/scripts/harness_ledger.py +111 -5
  20. package/harness/bundles/general/claude-code/scripts/harness_profile.py +29 -1
  21. package/harness/bundles/general/claude-code/scripts/harness_test_guard.py +502 -0
  22. package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
  23. package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +1 -1
  24. package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
  25. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  26. package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  27. package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +1 -1
  28. package/harness/bundles/general/codebuddy/harness-review/SKILL.md +1 -1
  29. package/harness/bundles/general/codebuddy/harness-run/SKILL.md +15 -3
  30. package/harness/bundles/general/codebuddy/harness-run/checklist.md +7 -3
  31. package/harness/bundles/general/codebuddy/harness-run/reference.md +3 -9
  32. package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +5 -3
  33. package/harness/bundles/general/codebuddy/harness-submit/checklist.md +10 -1
  34. package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +1 -1
  35. package/harness/bundles/general/codebuddy/harness-test/SKILL.md +14 -2
  36. package/harness/bundles/general/codebuddy/harness-test/checklist.md +4 -0
  37. package/harness/bundles/general/codebuddy/harness-test/reference.md +4 -4
  38. package/harness/bundles/general/codebuddy/protocols/archive-report-protocol.md +2 -4
  39. package/harness/bundles/general/codebuddy/protocols/ledger-protocol.md +7 -15
  40. package/harness/bundles/general/codebuddy/scripts/harness_ledger.py +111 -5
  41. package/harness/bundles/general/codebuddy/scripts/harness_profile.py +29 -1
  42. package/harness/bundles/general/codebuddy/scripts/harness_test_guard.py +502 -0
  43. package/harness/bundles/general/codex/.harness-build.json +1 -1
  44. package/harness/bundles/general/codex/harness-archive/SKILL.md +1 -1
  45. package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
  46. package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +1 -1
  47. package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
  48. package/harness/bundles/general/codex/harness-plan/SKILL.md +1 -1
  49. package/harness/bundles/general/codex/harness-review/SKILL.md +1 -1
  50. package/harness/bundles/general/codex/harness-run/SKILL.md +15 -3
  51. package/harness/bundles/general/codex/harness-run/checklist.md +7 -3
  52. package/harness/bundles/general/codex/harness-run/reference.md +3 -9
  53. package/harness/bundles/general/codex/harness-submit/SKILL.md +5 -3
  54. package/harness/bundles/general/codex/harness-submit/checklist.md +10 -1
  55. package/harness/bundles/general/codex/harness-sync/SKILL.md +1 -1
  56. package/harness/bundles/general/codex/harness-test/SKILL.md +14 -2
  57. package/harness/bundles/general/codex/harness-test/checklist.md +4 -0
  58. package/harness/bundles/general/codex/harness-test/reference.md +4 -4
  59. package/harness/bundles/general/codex/protocols/archive-report-protocol.md +2 -4
  60. package/harness/bundles/general/codex/protocols/ledger-protocol.md +7 -15
  61. package/harness/bundles/general/codex/scripts/harness_ledger.py +111 -5
  62. package/harness/bundles/general/codex/scripts/harness_profile.py +29 -1
  63. package/harness/bundles/general/codex/scripts/harness_test_guard.py +502 -0
  64. package/harness/bundles/general/cursor/.harness-build.json +1 -1
  65. package/harness/bundles/general/cursor/harness-archive/SKILL.md +1 -1
  66. package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
  67. package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  68. package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
  69. package/harness/bundles/general/cursor/harness-plan/SKILL.md +1 -1
  70. package/harness/bundles/general/cursor/harness-review/SKILL.md +1 -1
  71. package/harness/bundles/general/cursor/harness-run/SKILL.md +15 -3
  72. package/harness/bundles/general/cursor/harness-run/checklist.md +7 -3
  73. package/harness/bundles/general/cursor/harness-run/reference.md +3 -9
  74. package/harness/bundles/general/cursor/harness-submit/SKILL.md +5 -3
  75. package/harness/bundles/general/cursor/harness-submit/checklist.md +10 -1
  76. package/harness/bundles/general/cursor/harness-sync/SKILL.md +1 -1
  77. package/harness/bundles/general/cursor/harness-test/SKILL.md +14 -2
  78. package/harness/bundles/general/cursor/harness-test/checklist.md +4 -0
  79. package/harness/bundles/general/cursor/harness-test/reference.md +4 -4
  80. package/harness/bundles/general/cursor/protocols/archive-report-protocol.md +2 -4
  81. package/harness/bundles/general/cursor/protocols/ledger-protocol.md +7 -15
  82. package/harness/bundles/general/cursor/scripts/harness_ledger.py +111 -5
  83. package/harness/bundles/general/cursor/scripts/harness_profile.py +29 -1
  84. package/harness/bundles/general/cursor/scripts/harness_test_guard.py +502 -0
  85. package/harness/bundles/java/claude-code/.harness-build.json +1 -1
  86. package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +1 -1
  87. package/harness/bundles/java/claude-code/harness-archive/SKILL.md +1 -1
  88. package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
  89. package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  90. package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
  91. package/harness/bundles/java/claude-code/harness-package/SKILL.md +1 -1
  92. package/harness/bundles/java/claude-code/harness-plan/SKILL.md +1 -1
  93. package/harness/bundles/java/claude-code/harness-review/SKILL.md +1 -1
  94. package/harness/bundles/java/claude-code/harness-run/SKILL.md +18 -4
  95. package/harness/bundles/java/claude-code/harness-submit/SKILL.md +5 -3
  96. package/harness/bundles/java/claude-code/harness-submit/checklist.md +10 -1
  97. package/harness/bundles/java/claude-code/harness-sync/SKILL.md +1 -1
  98. package/harness/bundles/java/claude-code/harness-test/SKILL.md +16 -2
  99. package/harness/bundles/java/claude-code/protocols/archive-report-protocol.md +2 -4
  100. package/harness/bundles/java/claude-code/protocols/ledger-protocol.md +7 -15
  101. package/harness/bundles/java/claude-code/scripts/harness_ledger.py +111 -5
  102. package/harness/bundles/java/claude-code/scripts/harness_profile.py +29 -1
  103. package/harness/bundles/java/claude-code/scripts/harness_test_guard.py +502 -0
  104. package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
  105. package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +1 -1
  106. package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +1 -1
  107. package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
  108. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  109. package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  110. package/harness/bundles/java/codebuddy/harness-package/SKILL.md +1 -1
  111. package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +1 -1
  112. package/harness/bundles/java/codebuddy/harness-review/SKILL.md +1 -1
  113. package/harness/bundles/java/codebuddy/harness-run/SKILL.md +18 -4
  114. package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +5 -3
  115. package/harness/bundles/java/codebuddy/harness-submit/checklist.md +10 -1
  116. package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +1 -1
  117. package/harness/bundles/java/codebuddy/harness-test/SKILL.md +16 -2
  118. package/harness/bundles/java/codebuddy/protocols/archive-report-protocol.md +2 -4
  119. package/harness/bundles/java/codebuddy/protocols/ledger-protocol.md +7 -15
  120. package/harness/bundles/java/codebuddy/scripts/harness_ledger.py +111 -5
  121. package/harness/bundles/java/codebuddy/scripts/harness_profile.py +29 -1
  122. package/harness/bundles/java/codebuddy/scripts/harness_test_guard.py +502 -0
  123. package/harness/bundles/java/codex/.harness-build.json +1 -1
  124. package/harness/bundles/java/codex/harness-apidoc/SKILL.md +1 -1
  125. package/harness/bundles/java/codex/harness-archive/SKILL.md +1 -1
  126. package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
  127. package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +1 -1
  128. package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
  129. package/harness/bundles/java/codex/harness-package/SKILL.md +1 -1
  130. package/harness/bundles/java/codex/harness-plan/SKILL.md +1 -1
  131. package/harness/bundles/java/codex/harness-review/SKILL.md +1 -1
  132. package/harness/bundles/java/codex/harness-run/SKILL.md +18 -4
  133. package/harness/bundles/java/codex/harness-submit/SKILL.md +5 -3
  134. package/harness/bundles/java/codex/harness-submit/checklist.md +10 -1
  135. package/harness/bundles/java/codex/harness-sync/SKILL.md +1 -1
  136. package/harness/bundles/java/codex/harness-test/SKILL.md +16 -2
  137. package/harness/bundles/java/codex/protocols/archive-report-protocol.md +2 -4
  138. package/harness/bundles/java/codex/protocols/ledger-protocol.md +7 -15
  139. package/harness/bundles/java/codex/scripts/harness_ledger.py +111 -5
  140. package/harness/bundles/java/codex/scripts/harness_profile.py +29 -1
  141. package/harness/bundles/java/codex/scripts/harness_test_guard.py +502 -0
  142. package/harness/bundles/java/cursor/.harness-build.json +1 -1
  143. package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +1 -1
  144. package/harness/bundles/java/cursor/harness-archive/SKILL.md +1 -1
  145. package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
  146. package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  147. package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
  148. package/harness/bundles/java/cursor/harness-package/SKILL.md +1 -1
  149. package/harness/bundles/java/cursor/harness-plan/SKILL.md +1 -1
  150. package/harness/bundles/java/cursor/harness-review/SKILL.md +1 -1
  151. package/harness/bundles/java/cursor/harness-run/SKILL.md +18 -4
  152. package/harness/bundles/java/cursor/harness-submit/SKILL.md +5 -3
  153. package/harness/bundles/java/cursor/harness-submit/checklist.md +10 -1
  154. package/harness/bundles/java/cursor/harness-sync/SKILL.md +1 -1
  155. package/harness/bundles/java/cursor/harness-test/SKILL.md +16 -2
  156. package/harness/bundles/java/cursor/protocols/archive-report-protocol.md +2 -4
  157. package/harness/bundles/java/cursor/protocols/ledger-protocol.md +7 -15
  158. package/harness/bundles/java/cursor/scripts/harness_ledger.py +111 -5
  159. package/harness/bundles/java/cursor/scripts/harness_profile.py +29 -1
  160. package/harness/bundles/java/cursor/scripts/harness_test_guard.py +502 -0
  161. package/harness/manifests/general/claude-code.json +25 -21
  162. package/harness/manifests/general/codebuddy.json +25 -21
  163. package/harness/manifests/general/codex.json +25 -21
  164. package/harness/manifests/general/cursor.json +25 -21
  165. package/harness/manifests/java/claude-code.json +23 -19
  166. package/harness/manifests/java/codebuddy.json +23 -19
  167. package/harness/manifests/java/codex.json +23 -19
  168. package/harness/manifests/java/cursor.json +23 -19
  169. package/hunter-workflow-family.json +2 -2
  170. 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())
@@ -2,5 +2,5 @@
2
2
  "schemaVersion": 1,
3
3
  "overlay": "java",
4
4
  "agent": "claude-code",
5
- "coreHash": "959b22fdc1e7af33"
5
+ "coreHash": "a90894841bbf814e"
6
6
  }
@@ -20,7 +20,7 @@ disallowed-tools:
20
20
  - Bash(awk *)
21
21
  - Bash(curl *)
22
22
  ---
23
- <!-- generated by harness_deploy.py; core=959b22fdc1e7af33; overlay=java; agent=claude-code; do not edit -->
23
+ <!-- generated by harness_deploy.py; core=a90894841bbf814e; overlay=java; agent=claude-code; do not edit -->
24
24
  # harness-apidoc — 前端接口文档(Java overlay)
25
25
 
26
26
  ## Purpose
@@ -20,7 +20,7 @@ disallowed-tools:
20
20
  - Bash(awk *)
21
21
  - Bash(curl *)
22
22
  ---
23
- <!-- generated by harness_deploy.py; core=959b22fdc1e7af33; overlay=java; agent=claude-code; do not edit -->
23
+ <!-- generated by harness_deploy.py; core=a90894841bbf814e; overlay=java; agent=claude-code; do not edit -->
24
24
  # harness-archive — 变更归档
25
25
 
26
26
  ## Purpose
@@ -20,7 +20,7 @@ disallowed-tools:
20
20
  - Bash(awk *)
21
21
  - Bash(curl *)
22
22
  ---
23
- <!-- generated by harness_deploy.py; core=959b22fdc1e7af33; overlay=java; agent=claude-code; do not edit -->
23
+ <!-- generated by harness_deploy.py; core=a90894841bbf814e; overlay=java; agent=claude-code; do not edit -->
24
24
  # harness-codebase-map — 代码库地图
25
25
 
26
26
  ## Purpose
@@ -20,7 +20,7 @@ disallowed-tools:
20
20
  - Bash(awk *)
21
21
  - Bash(curl *)
22
22
  ---
23
- <!-- generated by harness_deploy.py; core=959b22fdc1e7af33; overlay=java; agent=claude-code; do not edit -->
23
+ <!-- generated by harness_deploy.py; core=a90894841bbf814e; overlay=java; agent=claude-code; do not edit -->
24
24
  # harness-knowledge-ingest
25
25
 
26
26
  从 `.harness/archive/**/reports/final/summary-data.json` 抽取项目知识,生成并维护本地 `.harness/knowledge/` 索引。
@@ -20,7 +20,7 @@ disallowed-tools:
20
20
  - Bash(awk *)
21
21
  - Bash(curl *)
22
22
  ---
23
- <!-- generated by harness_deploy.py; core=959b22fdc1e7af33; overlay=java; agent=claude-code; do not edit -->
23
+ <!-- generated by harness_deploy.py; core=a90894841bbf814e; overlay=java; agent=claude-code; do not edit -->
24
24
  # harness-knowledge-query
25
25
 
26
26
  根据用户的新需求或排查问题,从项目本地 `.harness/knowledge` 中检索历史需求、决策、实现、风险和测试证据,并生成 AI 可读的 context pack。
@@ -20,7 +20,7 @@ disallowed-tools:
20
20
  - Bash(awk *)
21
21
  - Bash(curl *)
22
22
  ---
23
- <!-- generated by harness_deploy.py; core=959b22fdc1e7af33; overlay=java; agent=claude-code; do not edit -->
23
+ <!-- generated by harness_deploy.py; core=a90894841bbf814e; overlay=java; agent=claude-code; do not edit -->
24
24
  # harness-package — 增量模块打包(Java overlay)
25
25
 
26
26
  ## Purpose
@@ -20,7 +20,7 @@ disallowed-tools:
20
20
  - Bash(awk *)
21
21
  - Bash(curl *)
22
22
  ---
23
- <!-- generated by harness_deploy.py; core=959b22fdc1e7af33; overlay=java; agent=claude-code; do not edit -->
23
+ <!-- generated by harness_deploy.py; core=a90894841bbf814e; overlay=java; agent=claude-code; do not edit -->
24
24
  # harness-plan — 需求规划
25
25
 
26
26
  ## Purpose
@@ -21,7 +21,7 @@ disallowed-tools:
21
21
  - Bash(curl *)
22
22
  - Bash(codegraph *)
23
23
  ---
24
- <!-- generated by harness_deploy.py; core=959b22fdc1e7af33; overlay=java; agent=claude-code; do not edit -->
24
+ <!-- generated by harness_deploy.py; core=a90894841bbf814e; overlay=java; agent=claude-code; do not edit -->
25
25
  # harness-review — 代码审查
26
26
 
27
27
  ## Purpose
@@ -21,7 +21,7 @@ disallowed-tools:
21
21
  - Bash(curl *)
22
22
  - Bash(codegraph *)
23
23
  ---
24
- <!-- generated by harness_deploy.py; core=959b22fdc1e7af33; overlay=java; agent=claude-code; do not edit -->
24
+ <!-- generated by harness_deploy.py; core=a90894841bbf814e; overlay=java; agent=claude-code; do not edit -->
25
25
  # harness-run — 需求编码
26
26
 
27
27
  ## Purpose
@@ -75,7 +75,7 @@ disallowed-tools:
75
75
  0. 加载上下文(change-name、spec/plan/detail/scenarios/ledger/run-task-status/worktree;`--fixback` 读 fixback)→ append `phase.start`
76
76
  0.5. **测试基础设施探测**(先写 `CHECKING`,四项证据齐备后再结论)→ `reference.md` Step 0.5
77
77
  1. **变更簇 TDD** — `protocols.md` `run-tdd-protocol`;批量 RED/GREEN;按需 `change-cluster-review-protocol`(高风险 + reviewer 预检可用)
78
- 2. 构建验证 + 写 ledger(diffHash 三部分合并,`reference.md` Step 2c)
78
+ 2. 构建验证 + 写 ledger(`harness_ledger.py diff-hash --change-dir` 纳入 ignored tests,`reference.md` Step 2c)
79
79
  3. **场景覆盖检查**(场景表映射,禁止用用例数冒充场景数)
80
80
  4. **关门检查**(10 项)+ 计划状态持久化
81
81
 
@@ -105,12 +105,24 @@ disallowed-tools:
105
105
  | **变更簇 TDD** | 一簇一次 RED/GREEN;低价值项豁免;新分支必须 RED |
106
106
  | **RED/GREEN** | RED 须有效;静态验证 ≠ 测试通过;greenfield 大重写豁免见 reference |
107
107
  | **Mapper/DB** | 纯 Mock 不得宣称 DB 验证通过;迁移脚本**永不自动执行** |
108
- | **探测/ledger** | 基础设施先探测;每次构建/测试写 ledger;三部分 diffHash |
108
+ | **探测/ledger** | 基础设施先探测;每次构建/测试写 ledger;用 canonical `diff-hash --change-dir` |
109
109
  | **预存变更** | 保留 → baseline 隔离;存在则最终 ≥ 🟡WARN |
110
110
  | **关门/状态** | 10 项关门检查;持久化 run-task-status;P0 静态-only 不得建议 submit |
111
111
  | **Worktree** | `requested=true` 时代码只写 worktree |
112
112
  | **PowerShell** | 所有 git/构建经 `powershell.exe -NoProfile -Command` |
113
113
 
114
+ ### 陈旧测试安全修复与精确跟踪
115
+
116
+ 测试编译或 RED/GREEN 失败时,先区分当前实现缺陷、测试基础设施故障与陈旧测试。只有同时满足以下条件才允许自动修复陈旧测试:当前生产代码、已批准计划或可验证的历史变更能唯一确定新契约;修改范围仅限测试文件;修复后会立即重跑该测试及本变更目标测试。符合时以 `stale-test-repair` 记录:
117
+
118
+ ```text
119
+ python <skills-root>/scripts/harness_test_guard.py record --project . --change-dir ".harness/changes/<change-name>" --files "<精确测试文件路径,逗号分隔>" --reason stale-test-repair --json
120
+ ```
121
+
122
+ 新建或正常更新测试分别使用 `tdd-created` / `test-updated`。若预期行为存在业务歧义,停止测试修复并记录 `BLOCKED_PREEXISTING`,不得猜测新断言。
123
+
124
+ **禁止临时排除测试**:不得将测试改名为 `.bak`、移出测试目录、删除、添加 `@Disabled`/`@Ignore`、修改 Surefire/Gradle exclude 或跳过测试来制造绿色结果;也不得仅为满足陈旧测试而修改生产代码。`.gitignore` 中的测试只能通过 manifest 的精确路径闭环处理,禁止全局放宽 ignore。
125
+
114
126
  ### Java TDD / 数据访问补充
115
127
 
116
128
  | 项 | 规则 |
@@ -118,7 +130,9 @@ disallowed-tools:
118
130
  | **低价值豁免** | ErrorCode 常量、VO/DTO 字段、注释、import 清理、格式化、SQL 脚本、配置模板、文档 — 不单独建测试类;禁止为单个错误码单独跑 Maven |
119
131
  | **Mapper / LambdaQueryWrapper / SQL** | 纯 Mock 返回值**不得**宣称 DB 验证通过 → 🟡 静态验证,交 harness-test 真实 DB |
120
132
  | **行为性新分支** | 正则/条件/分支逻辑变更新增分支须 RED→GREEN;不属于低价值豁免 |
121
- | **ledger** | compile 必写;执行了 test 写 unitTest,否则 `NOT_RUN_BY_RUN`;diffHash 三部分合并(见 reference) |
133
+ | **ledger** | compile 必写;执行了 test 写 unitTest,否则 `NOT_RUN_BY_RUN`;diffHash `harness_ledger.py diff-hash --change-dir`(见 reference) |
134
+ | **陈旧 Java 测试** | `cannot find symbol`/mapper 方法改名/DTO 字段迁移只有在当前接口和批准计划唯一确定替代契约时才允许只改测试;修复后定向 `mvn test -Dtest=<class>`,再跑目标测试并记录 `stale-test-repair` |
135
+ | **禁止规避** | 禁止把 `*Test.java` 改成 `.bak`、删除、`@Disabled`/`@Ignore`、Surefire exclude 或以 `-DskipTests` 充当 GREEN |
122
136
  ## Output Format
123
137
 
124
138
  变更文件表 + 构建/测试证据 + 场景覆盖摘要 + 最终状态(✅OK / 🟡WARN / ❌FAIL)。→ `reference.md`
@@ -20,7 +20,7 @@ disallowed-tools:
20
20
  - Bash(awk *)
21
21
  - Bash(curl *)
22
22
  ---
23
- <!-- generated by harness_deploy.py; core=959b22fdc1e7af33; overlay=java; agent=claude-code; do not edit -->
23
+ <!-- generated by harness_deploy.py; core=a90894841bbf814e; overlay=java; agent=claude-code; do not edit -->
24
24
  # harness-submit — 最终提交(含 worktree 合并)
25
25
 
26
26
  ## Purpose
@@ -70,10 +70,10 @@ disallowed-tools:
70
70
 
71
71
  ### 提交流程(步骤 0–7)
72
72
 
73
- 0. **启动准备** — 确定变更名;读 ledger + 计算 diffHash + post-test 7 类分类;append `phase.start`
73
+ 0. **启动准备** — 确定变更名;读 ledger,以 `harness_ledger.py diff-hash --repo . --base <baseCommit> --change-dir ".harness/changes/<change-name>" --json` 计算 diffHash + post-test 7 类分类;append `phase.start`
74
74
  1. **合并最新代码** — 主目录:`stash` → `pull --rebase` → `stash pop`;**worktree:N/A**(远端同步在合并段主分支完成)
75
75
  2. **最终验证** — ledger 复用优先;**提交前最终门禁只调 `can-reuse`**(删除与 coverage 冲突的二次全量门禁):`harness_ledger.py can-reuse --verification unitTestFull --scope module --project . --profile-input unitTestFull --command <resolved commands.unitTestFull.command>`。`--command` **按 profile key resolve**:读 `build-profile.json` 的 `commands.unitTestFull.command`(v2),或 `harness_profile.py resolve --project . --key unitTestFull --json` 取 resolved command,**不复制示例模块名**(文档示例只展示 key)。`--profile-input unitTestFull` 从 `verificationInputs.unitTestFull`(v2 由 `commands.unitTestFull.inputs` 派生)展开依赖闭包,**禁止用仅含 staged 文件的 `--files` 快捷方式**冒充全量闭包。`reuse=true` → 不再执行二次全量测试;仅 `reuse=false` 时执行**同一 resolved verification**(profile `unitTestFull` 命令),成功后用同一文件集、command、`scope=module` 写回 ledger `unitTestFull` 项。增量 `unitTest` 永远不能冒充 `unitTestFull` 门禁。
76
- 3. **.gitignore + 暂存** ⚠️ — 检查 `.harness/` 在 `.gitignore`;**禁止 `git add -A`**
76
+ 3. **.gitignore + 精确暂存** ⚠️ — 检查 `.harness/` 在 `.gitignore`;**禁止 `git add -A`**。若存在 `evidence/test-tracking.json`,先执行 `python <skills-root>/scripts/harness_test_guard.py stage --project . --change-dir ".harness/changes/<change-name>" --json`;失败即硬停止。无 manifest 时不使用 `-f`。manifest 之外的文件按精确业务路径正常暂存,**禁止全局 force-add**。
77
77
  4. **提交方式** — 主目录:AskUserQuestion 三选项(commit+push / 仅本地 / 取消);**worktree:固定仅本地 commit**
78
78
  5. **commit-message.txt** ⚠️ — 展示 staged、diff stat、完整中文 message;用户确认
79
79
  6. **commit / push** — `git commit -F`;主目录按选项 push(push 前 fetch 检查远端);**worktree:只 commit,记录 local hash**
@@ -122,6 +122,8 @@ commit+push / 仅本地 commit / 取消;**不调用 Superpowers 呈现 PR/丢
122
122
 
123
123
  见 `checklist.md` 与 `../protocols/ledger-protocol.md`、`../protocols/submit-protocol.md`。
124
124
 
125
+ test-tracking manifest 是 ignored test 的唯一强制暂存授权:只允许 `harness_test_guard.py stage` 暂存 manifest 中已校验路径,禁止 `git add -f .`、目录级 `git add -f` 或全局修改 `.gitignore`。worktree commit 前须确认 manifest 路径全部进入 cached diff;合并回主分支后、删除 worktree 前须确认这些路径已由目标 commit 跟踪,否则停止清理。
126
+
125
127
  ### 四、worktree 合并硬规则
126
128
 
127
129
  - **push 只在主分支**;worktree 分支不 push