@hunter-harness/workflow-harness 0.2.27 → 0.2.28

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 (194) 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-archive/templates/render-summary.mjs +25 -6
  4. package/harness/bundles/general/claude-code/harness-archive/templates/summary-data-template.json +44 -0
  5. package/harness/bundles/general/claude-code/harness-codebase-map/SKILL.md +1 -1
  6. package/harness/bundles/general/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  7. package/harness/bundles/general/claude-code/harness-knowledge-ingest/scripts/harness_knowledge.py +493 -74
  8. package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +1 -1
  9. package/harness/bundles/general/claude-code/harness-plan/SKILL.md +1 -1
  10. package/harness/bundles/general/claude-code/harness-review/SKILL.md +1 -1
  11. package/harness/bundles/general/claude-code/harness-run/SKILL.md +1 -1
  12. package/harness/bundles/general/claude-code/harness-submit/SKILL.md +1 -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 +1 -1
  15. package/harness/bundles/general/claude-code/scripts/harness_archive.py +2061 -187
  16. package/harness/bundles/general/claude-code/scripts/harness_events.py +43 -9
  17. package/harness/bundles/general/claude-code/scripts/harness_gate.py +491 -7
  18. package/harness/bundles/general/claude-code/scripts/harness_integration.py +232 -0
  19. package/harness/bundles/general/claude-code/scripts/harness_phase.py +758 -1
  20. package/harness/bundles/general/claude-code/scripts/harness_plan_finalize.py +74 -0
  21. package/harness/bundles/general/claude-code/scripts/harness_retry.py +175 -0
  22. package/harness/bundles/general/claude-code/scripts/harness_test_guard.py +702 -22
  23. package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
  24. package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +1 -1
  25. package/harness/bundles/general/codebuddy/harness-archive/templates/render-summary.mjs +25 -6
  26. package/harness/bundles/general/codebuddy/harness-archive/templates/summary-data-template.json +44 -0
  27. package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
  28. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  29. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge.py +493 -74
  30. package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  31. package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +1 -1
  32. package/harness/bundles/general/codebuddy/harness-review/SKILL.md +1 -1
  33. package/harness/bundles/general/codebuddy/harness-run/SKILL.md +1 -1
  34. package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +1 -1
  35. package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +1 -1
  36. package/harness/bundles/general/codebuddy/harness-test/SKILL.md +1 -1
  37. package/harness/bundles/general/codebuddy/scripts/harness_archive.py +2061 -187
  38. package/harness/bundles/general/codebuddy/scripts/harness_events.py +43 -9
  39. package/harness/bundles/general/codebuddy/scripts/harness_gate.py +491 -7
  40. package/harness/bundles/general/codebuddy/scripts/harness_integration.py +232 -0
  41. package/harness/bundles/general/codebuddy/scripts/harness_phase.py +758 -1
  42. package/harness/bundles/general/codebuddy/scripts/harness_plan_finalize.py +74 -0
  43. package/harness/bundles/general/codebuddy/scripts/harness_retry.py +175 -0
  44. package/harness/bundles/general/codebuddy/scripts/harness_test_guard.py +702 -22
  45. package/harness/bundles/general/codex/.harness-build.json +1 -1
  46. package/harness/bundles/general/codex/harness-archive/SKILL.md +1 -1
  47. package/harness/bundles/general/codex/harness-archive/templates/render-summary.mjs +25 -6
  48. package/harness/bundles/general/codex/harness-archive/templates/summary-data-template.json +44 -0
  49. package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
  50. package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +1 -1
  51. package/harness/bundles/general/codex/harness-knowledge-ingest/scripts/harness_knowledge.py +493 -74
  52. package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
  53. package/harness/bundles/general/codex/harness-plan/SKILL.md +1 -1
  54. package/harness/bundles/general/codex/harness-review/SKILL.md +1 -1
  55. package/harness/bundles/general/codex/harness-run/SKILL.md +1 -1
  56. package/harness/bundles/general/codex/harness-submit/SKILL.md +1 -1
  57. package/harness/bundles/general/codex/harness-sync/SKILL.md +1 -1
  58. package/harness/bundles/general/codex/harness-test/SKILL.md +1 -1
  59. package/harness/bundles/general/codex/scripts/harness_archive.py +2061 -187
  60. package/harness/bundles/general/codex/scripts/harness_events.py +43 -9
  61. package/harness/bundles/general/codex/scripts/harness_gate.py +491 -7
  62. package/harness/bundles/general/codex/scripts/harness_integration.py +232 -0
  63. package/harness/bundles/general/codex/scripts/harness_phase.py +758 -1
  64. package/harness/bundles/general/codex/scripts/harness_plan_finalize.py +74 -0
  65. package/harness/bundles/general/codex/scripts/harness_retry.py +175 -0
  66. package/harness/bundles/general/codex/scripts/harness_test_guard.py +702 -22
  67. package/harness/bundles/general/cursor/.harness-build.json +1 -1
  68. package/harness/bundles/general/cursor/harness-archive/SKILL.md +1 -1
  69. package/harness/bundles/general/cursor/harness-archive/templates/render-summary.mjs +25 -6
  70. package/harness/bundles/general/cursor/harness-archive/templates/summary-data-template.json +44 -0
  71. package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
  72. package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  73. package/harness/bundles/general/cursor/harness-knowledge-ingest/scripts/harness_knowledge.py +493 -74
  74. package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
  75. package/harness/bundles/general/cursor/harness-plan/SKILL.md +1 -1
  76. package/harness/bundles/general/cursor/harness-review/SKILL.md +1 -1
  77. package/harness/bundles/general/cursor/harness-run/SKILL.md +1 -1
  78. package/harness/bundles/general/cursor/harness-submit/SKILL.md +1 -1
  79. package/harness/bundles/general/cursor/harness-sync/SKILL.md +1 -1
  80. package/harness/bundles/general/cursor/harness-test/SKILL.md +1 -1
  81. package/harness/bundles/general/cursor/scripts/harness_archive.py +2061 -187
  82. package/harness/bundles/general/cursor/scripts/harness_events.py +43 -9
  83. package/harness/bundles/general/cursor/scripts/harness_gate.py +491 -7
  84. package/harness/bundles/general/cursor/scripts/harness_integration.py +232 -0
  85. package/harness/bundles/general/cursor/scripts/harness_phase.py +758 -1
  86. package/harness/bundles/general/cursor/scripts/harness_plan_finalize.py +74 -0
  87. package/harness/bundles/general/cursor/scripts/harness_retry.py +175 -0
  88. package/harness/bundles/general/cursor/scripts/harness_test_guard.py +702 -22
  89. package/harness/bundles/java/claude-code/.harness-build.json +1 -1
  90. package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +1 -1
  91. package/harness/bundles/java/claude-code/harness-archive/SKILL.md +1 -1
  92. package/harness/bundles/java/claude-code/harness-archive/templates/render-summary.mjs +25 -6
  93. package/harness/bundles/java/claude-code/harness-archive/templates/summary-data-template.json +44 -0
  94. package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
  95. package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  96. package/harness/bundles/java/claude-code/harness-knowledge-ingest/scripts/harness_knowledge.py +493 -74
  97. package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
  98. package/harness/bundles/java/claude-code/harness-package/SKILL.md +1 -1
  99. package/harness/bundles/java/claude-code/harness-plan/SKILL.md +1 -1
  100. package/harness/bundles/java/claude-code/harness-review/SKILL.md +1 -1
  101. package/harness/bundles/java/claude-code/harness-run/SKILL.md +1 -1
  102. package/harness/bundles/java/claude-code/harness-submit/SKILL.md +1 -1
  103. package/harness/bundles/java/claude-code/harness-sync/SKILL.md +1 -1
  104. package/harness/bundles/java/claude-code/harness-test/SKILL.md +1 -1
  105. package/harness/bundles/java/claude-code/scripts/harness_archive.py +2061 -187
  106. package/harness/bundles/java/claude-code/scripts/harness_events.py +43 -9
  107. package/harness/bundles/java/claude-code/scripts/harness_gate.py +491 -7
  108. package/harness/bundles/java/claude-code/scripts/harness_integration.py +232 -0
  109. package/harness/bundles/java/claude-code/scripts/harness_phase.py +758 -1
  110. package/harness/bundles/java/claude-code/scripts/harness_plan_finalize.py +74 -0
  111. package/harness/bundles/java/claude-code/scripts/harness_retry.py +175 -0
  112. package/harness/bundles/java/claude-code/scripts/harness_test_guard.py +702 -22
  113. package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
  114. package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +1 -1
  115. package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +1 -1
  116. package/harness/bundles/java/codebuddy/harness-archive/templates/render-summary.mjs +25 -6
  117. package/harness/bundles/java/codebuddy/harness-archive/templates/summary-data-template.json +44 -0
  118. package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
  119. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  120. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge.py +493 -74
  121. package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  122. package/harness/bundles/java/codebuddy/harness-package/SKILL.md +1 -1
  123. package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +1 -1
  124. package/harness/bundles/java/codebuddy/harness-review/SKILL.md +1 -1
  125. package/harness/bundles/java/codebuddy/harness-run/SKILL.md +1 -1
  126. package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +1 -1
  127. package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +1 -1
  128. package/harness/bundles/java/codebuddy/harness-test/SKILL.md +1 -1
  129. package/harness/bundles/java/codebuddy/scripts/harness_archive.py +2061 -187
  130. package/harness/bundles/java/codebuddy/scripts/harness_events.py +43 -9
  131. package/harness/bundles/java/codebuddy/scripts/harness_gate.py +491 -7
  132. package/harness/bundles/java/codebuddy/scripts/harness_integration.py +232 -0
  133. package/harness/bundles/java/codebuddy/scripts/harness_phase.py +758 -1
  134. package/harness/bundles/java/codebuddy/scripts/harness_plan_finalize.py +74 -0
  135. package/harness/bundles/java/codebuddy/scripts/harness_retry.py +175 -0
  136. package/harness/bundles/java/codebuddy/scripts/harness_test_guard.py +702 -22
  137. package/harness/bundles/java/codex/.harness-build.json +1 -1
  138. package/harness/bundles/java/codex/harness-apidoc/SKILL.md +1 -1
  139. package/harness/bundles/java/codex/harness-archive/SKILL.md +1 -1
  140. package/harness/bundles/java/codex/harness-archive/templates/render-summary.mjs +25 -6
  141. package/harness/bundles/java/codex/harness-archive/templates/summary-data-template.json +44 -0
  142. package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
  143. package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +1 -1
  144. package/harness/bundles/java/codex/harness-knowledge-ingest/scripts/harness_knowledge.py +493 -74
  145. package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
  146. package/harness/bundles/java/codex/harness-package/SKILL.md +1 -1
  147. package/harness/bundles/java/codex/harness-plan/SKILL.md +1 -1
  148. package/harness/bundles/java/codex/harness-review/SKILL.md +1 -1
  149. package/harness/bundles/java/codex/harness-run/SKILL.md +1 -1
  150. package/harness/bundles/java/codex/harness-submit/SKILL.md +1 -1
  151. package/harness/bundles/java/codex/harness-sync/SKILL.md +1 -1
  152. package/harness/bundles/java/codex/harness-test/SKILL.md +1 -1
  153. package/harness/bundles/java/codex/scripts/harness_archive.py +2061 -187
  154. package/harness/bundles/java/codex/scripts/harness_events.py +43 -9
  155. package/harness/bundles/java/codex/scripts/harness_gate.py +491 -7
  156. package/harness/bundles/java/codex/scripts/harness_integration.py +232 -0
  157. package/harness/bundles/java/codex/scripts/harness_phase.py +758 -1
  158. package/harness/bundles/java/codex/scripts/harness_plan_finalize.py +74 -0
  159. package/harness/bundles/java/codex/scripts/harness_retry.py +175 -0
  160. package/harness/bundles/java/codex/scripts/harness_test_guard.py +702 -22
  161. package/harness/bundles/java/cursor/.harness-build.json +1 -1
  162. package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +1 -1
  163. package/harness/bundles/java/cursor/harness-archive/SKILL.md +1 -1
  164. package/harness/bundles/java/cursor/harness-archive/templates/render-summary.mjs +25 -6
  165. package/harness/bundles/java/cursor/harness-archive/templates/summary-data-template.json +44 -0
  166. package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
  167. package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  168. package/harness/bundles/java/cursor/harness-knowledge-ingest/scripts/harness_knowledge.py +493 -74
  169. package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
  170. package/harness/bundles/java/cursor/harness-package/SKILL.md +1 -1
  171. package/harness/bundles/java/cursor/harness-plan/SKILL.md +1 -1
  172. package/harness/bundles/java/cursor/harness-review/SKILL.md +1 -1
  173. package/harness/bundles/java/cursor/harness-run/SKILL.md +1 -1
  174. package/harness/bundles/java/cursor/harness-submit/SKILL.md +1 -1
  175. package/harness/bundles/java/cursor/harness-sync/SKILL.md +1 -1
  176. package/harness/bundles/java/cursor/harness-test/SKILL.md +1 -1
  177. package/harness/bundles/java/cursor/scripts/harness_archive.py +2061 -187
  178. package/harness/bundles/java/cursor/scripts/harness_events.py +43 -9
  179. package/harness/bundles/java/cursor/scripts/harness_gate.py +491 -7
  180. package/harness/bundles/java/cursor/scripts/harness_integration.py +232 -0
  181. package/harness/bundles/java/cursor/scripts/harness_phase.py +758 -1
  182. package/harness/bundles/java/cursor/scripts/harness_plan_finalize.py +74 -0
  183. package/harness/bundles/java/cursor/scripts/harness_retry.py +175 -0
  184. package/harness/bundles/java/cursor/scripts/harness_test_guard.py +702 -22
  185. package/harness/manifests/general/claude-code.json +26 -22
  186. package/harness/manifests/general/codebuddy.json +26 -22
  187. package/harness/manifests/general/codex.json +26 -22
  188. package/harness/manifests/general/cursor.json +26 -22
  189. package/harness/manifests/java/claude-code.json +28 -24
  190. package/harness/manifests/java/codebuddy.json +28 -24
  191. package/harness/manifests/java/codex.json +28 -24
  192. package/harness/manifests/java/cursor.json +28 -24
  193. package/hunter-workflow-family.json +2 -2
  194. package/package.json +1 -1
@@ -56,6 +56,11 @@ REASONS = ("tdd-created", "stale-test-repair", "test-updated")
56
56
  class LockUnavailable(RuntimeError):
57
57
  """A compatible lock file is already held by another process."""
58
58
 
59
+
60
+ class ProfileConfigInvalid(ValueError):
61
+ """The selected project-owned test-tracking profile is malformed."""
62
+
63
+
59
64
  if hasattr(sys.stdout, "reconfigure"):
60
65
  sys.stdout.reconfigure(encoding="utf-8")
61
66
  if hasattr(sys.stderr, "reconfigure"):
@@ -218,30 +223,69 @@ def _manifest_target_inside(change_root: Path, manifest: Path) -> bool:
218
223
  )
219
224
 
220
225
 
221
- def _profile_config(project: Path) -> tuple[list[str], list[str]] | None:
222
- profile_path = project / PROFILE_REL
223
- if not profile_path.is_file():
224
- return None
226
+ def _profile_config(
227
+ project: Path,
228
+ *,
229
+ strict: bool = False,
230
+ ) -> tuple[list[str], list[str]] | None:
231
+ def invalid(message: str) -> tuple[list[str], list[str]]:
232
+ if strict:
233
+ raise ProfileConfigInvalid(message)
234
+ return [], []
235
+
236
+ local_profile = project / PROFILE_REL
237
+ if os.path.lexists(local_profile):
238
+ profile_path = local_profile
239
+ if not profile_path.is_file():
240
+ return invalid(f"{PROFILE_REL.as_posix()} is not a regular file")
241
+ else:
242
+ state_project = _state_project_root(project)
243
+ common_root = harness_paths.common_root(project)
244
+ common_profile = state_project / PROFILE_REL
245
+ if (
246
+ state_project == project
247
+ or state_project != common_root
248
+ or not os.path.lexists(common_profile)
249
+ ):
250
+ return None
251
+ if not common_profile.is_file():
252
+ return invalid(
253
+ f"common {PROFILE_REL.as_posix()} is not a regular file"
254
+ )
255
+ profile_path = common_profile
225
256
  try:
226
257
  profile = _read_json(profile_path)
227
- except (OSError, json.JSONDecodeError):
228
- return [], []
229
- tracking = profile.get("testTracking") if isinstance(profile, dict) else None
258
+ except (OSError, json.JSONDecodeError) as exc:
259
+ return invalid(f"cannot read {profile_path}: {exc}")
260
+ if not isinstance(profile, dict):
261
+ return invalid(f"{profile_path} must contain a JSON object")
262
+ tracking = profile.get("testTracking")
263
+ if not isinstance(tracking, dict):
264
+ return invalid(f"{profile_path} testTracking must be an object")
230
265
  paths = tracking.get("paths") if isinstance(tracking, dict) else None
231
- if not isinstance(paths, list):
232
- return [], []
233
- excluded = profile.get("excludedRoots") if isinstance(profile, dict) else None
266
+ if (
267
+ not isinstance(paths, list)
268
+ or not paths
269
+ or any(not isinstance(item, str) or not item.strip() for item in paths)
270
+ ):
271
+ return invalid(
272
+ f"{profile_path} testTracking.paths must be a non-empty string list"
273
+ )
274
+ excluded = profile.get("excludedRoots")
275
+ if excluded is not None and (
276
+ not isinstance(excluded, list)
277
+ or any(not isinstance(item, str) or not item.strip() for item in excluded)
278
+ ):
279
+ return invalid(f"{profile_path} excludedRoots must be a string list")
234
280
  configured_roots = (
235
- [item.replace("\\", "/").strip("/") for item in excluded if isinstance(item, str) and item]
281
+ [item.replace("\\", "/").strip("/") for item in excluded]
236
282
  if isinstance(excluded, list)
237
283
  else []
238
284
  )
239
285
  excluded_roots = list(
240
286
  dict.fromkeys([*configured_roots, *MANDATORY_EXCLUDED_ROOTS])
241
287
  )
242
- patterns = [
243
- item.replace("\\", "/") for item in paths if isinstance(item, str) and item
244
- ]
288
+ patterns = [item.replace("\\", "/") for item in paths]
245
289
  return patterns, excluded_roots
246
290
 
247
291
 
@@ -297,6 +341,29 @@ def _validate_file(project: Path, raw: str) -> tuple[Path | None, str | None, st
297
341
  return resolved, rel, None
298
342
 
299
343
 
344
+ def _validate_snapshot_file(
345
+ project: Path,
346
+ raw: str,
347
+ ) -> tuple[Path | None, str | None, str | None]:
348
+ """Validate a path captured under the begin-time profile.
349
+
350
+ The active profile may legitimately change before close. Snapshot paths
351
+ remain constrained to the repository but are not reclassified against the
352
+ newer allowlist.
353
+ """
354
+ candidate = Path(raw)
355
+ if not candidate.is_absolute():
356
+ candidate = project / candidate
357
+ resolved = candidate.resolve()
358
+ if not _inside(resolved, project):
359
+ return None, None, "PATH_OUTSIDE_PROJECT"
360
+ if not resolved.exists():
361
+ return None, None, "FILE_NOT_FOUND"
362
+ if not resolved.is_file():
363
+ return None, None, "NOT_REGULAR_FILE"
364
+ return resolved, resolved.relative_to(project).as_posix(), None
365
+
366
+
300
367
  def _contract_is_v2(change_root: Path) -> bool:
301
368
  try:
302
369
  contract = harness_paths.load_change_contract(change_root)
@@ -744,6 +811,217 @@ def rehome(
744
811
  )
745
812
 
746
813
 
814
+ def rebind_snapshot(
815
+ from_project: Path | str,
816
+ to_project: Path | str,
817
+ change_dir: Path | str,
818
+ expected_base: str,
819
+ ) -> dict[str, Any]:
820
+ """Rebind an unchanged pre-change snapshot to a descendant worktree.
821
+
822
+ This is the pre-merge counterpart to ``rehome``. It is intentionally
823
+ narrow: the source root must still be exactly at ``expected_base``, every
824
+ snapshotted file must still match its captured byte hash there, and the
825
+ target HEAD must descend from that base. A target-owned manifest, when
826
+ present, is validated but never rewritten.
827
+ """
828
+
829
+ action = "rebind-snapshot"
830
+ from_root = Path(from_project).resolve()
831
+ to_root = Path(to_project).resolve()
832
+ if not from_root.is_dir() or not to_root.is_dir():
833
+ return _result(False, action, "PROJECT_ROOT_MISSING", [])
834
+ if harness_paths.repository_identity(from_root) != harness_paths.repository_identity(to_root):
835
+ return _result(False, action, "REPOSITORY_MISMATCH", [])
836
+
837
+ from_head_result = _git(from_root, "rev-parse", "--verify", "HEAD")
838
+ from_head = from_head_result.stdout.strip() if from_head_result.returncode == 0 else ""
839
+ if not expected_base or from_head != expected_base:
840
+ return _result(
841
+ False,
842
+ action,
843
+ "EXPECTED_BASE_MISMATCH",
844
+ [],
845
+ expectedBase=expected_base,
846
+ actualBase=from_head,
847
+ )
848
+
849
+ to_head_result = _git(to_root, "rev-parse", "--verify", "HEAD")
850
+ to_head = to_head_result.stdout.strip() if to_head_result.returncode == 0 else ""
851
+ descendant = _git(
852
+ to_root,
853
+ "merge-base",
854
+ "--is-ancestor",
855
+ expected_base,
856
+ to_head,
857
+ )
858
+ if not to_head or descendant.returncode != 0:
859
+ return _result(
860
+ False,
861
+ action,
862
+ "TARGET_NOT_DESCENDANT",
863
+ [],
864
+ expectedBase=expected_base,
865
+ targetHead=to_head,
866
+ )
867
+
868
+ change_root = _change_dir(from_root, change_dir)
869
+ if change_root is None:
870
+ return _result(False, action, "CHANGE_DIR_OUTSIDE_PROJECT", [])
871
+ snapshot_path = _state_root(change_root) / SNAPSHOT_REL
872
+ if not _manifest_target_inside(change_root, snapshot_path):
873
+ return _result(False, action, "SNAPSHOT_PATH_OUTSIDE_PROJECT", [])
874
+ manifest_path = _manifest_path(change_root)
875
+ if manifest_path is None:
876
+ return _result(False, action, "MANIFEST_PATH_OUTSIDE_PROJECT", [])
877
+
878
+ lock_path = snapshot_path.with_name(snapshot_path.name + ".lock")
879
+ try:
880
+ with _exclusive_lock(lock_path, wait_seconds=5.0):
881
+ try:
882
+ snapshot = _read_json(snapshot_path)
883
+ except FileNotFoundError:
884
+ return _result(False, action, "SNAPSHOT_MISSING", [])
885
+ except (OSError, json.JSONDecodeError) as exc:
886
+ return _result(False, action, "SNAPSHOT_INVALID", [], error=str(exc))
887
+
888
+ entries = snapshot.get("files") if isinstance(snapshot, dict) else None
889
+ snapshot_root = snapshot.get("projectRoot") if isinstance(snapshot, dict) else None
890
+ if (
891
+ not isinstance(snapshot, dict)
892
+ or snapshot.get("schemaVersion") != SCHEMA_VERSION
893
+ or snapshot.get("mode") != MODE
894
+ or snapshot_root not in {str(from_root), str(to_root)}
895
+ or not isinstance(entries, list)
896
+ ):
897
+ return _result(False, action, "SNAPSHOT_INVALID", [])
898
+
899
+ validated_paths: list[str] = []
900
+ for item in entries:
901
+ if (
902
+ not isinstance(item, dict)
903
+ or not isinstance(item.get("path"), str)
904
+ or not isinstance(item.get("sha256"), str)
905
+ ):
906
+ return _result(False, action, "SNAPSHOT_INVALID", [])
907
+ rel = item["path"]
908
+ source, normalized, error = _validate_file(from_root, rel)
909
+ if error or normalized != rel or source is None:
910
+ return _result(False, action, error or "SNAPSHOT_INVALID", [rel])
911
+ if item["sha256"] != _sha256(source):
912
+ return _result(False, action, "SOURCE_SNAPSHOT_DRIFT", [rel])
913
+ item["logicalHash"] = logical_file_hash(from_root, rel)
914
+ validated_paths.append(rel)
915
+
916
+ manifest = None
917
+ if manifest_path.is_file():
918
+ try:
919
+ manifest = _read_json(manifest_path)
920
+ except (OSError, json.JSONDecodeError) as exc:
921
+ return _result(False, action, "MANIFEST_INVALID", [], error=str(exc))
922
+ validator = (
923
+ _validate_existing_manifest_v2
924
+ if isinstance(manifest, dict) and manifest.get("schemaVersion") == 2
925
+ else _validate_existing_manifest
926
+ )
927
+ error, _ = validator(to_root, manifest, require_files=False)
928
+ if error:
929
+ return _result(False, action, error, [])
930
+
931
+ before_hash = _sha256(snapshot_path)
932
+ rebound_at = time.strftime("%Y-%m-%dT%H:%M:%S")
933
+ rebind_id = "rebind-" + hashlib.sha256(
934
+ f"{from_root}\0{to_root}\0{expected_base}\0{to_head}".encode("utf-8")
935
+ ).hexdigest()[:20]
936
+ updated = json.loads(json.dumps(snapshot))
937
+ updated["files"] = entries
938
+ rebinds = updated.get("rebinds")
939
+ if rebinds is None:
940
+ rebinds = []
941
+ if not isinstance(rebinds, list):
942
+ return _result(False, action, "SNAPSHOT_INVALID", [])
943
+ record = {
944
+ "id": rebind_id,
945
+ "fromRoot": str(from_root),
946
+ "toRoot": str(to_root),
947
+ "expectedBase": expected_base,
948
+ "targetHead": to_head,
949
+ "at": rebound_at,
950
+ "snapshotHashBefore": before_hash,
951
+ }
952
+ if not any(
953
+ isinstance(item, dict) and item.get("id") == rebind_id
954
+ for item in rebinds
955
+ ):
956
+ rebinds.append(record)
957
+ updated["projectRoot"] = str(to_root)
958
+ updated["rebinds"] = rebinds
959
+ _write_json(snapshot_path, updated)
960
+ after_hash = _sha256(snapshot_path)
961
+
962
+ removed_manifest_paths: list[str] = []
963
+ if isinstance(manifest, dict):
964
+ manifest_entries = manifest.get("files")
965
+ if not isinstance(manifest_entries, list):
966
+ return _result(False, action, "MANIFEST_INVALID", [])
967
+ baseline = {
968
+ item["path"]: item["logicalHash"]
969
+ for item in entries
970
+ if isinstance(item, dict)
971
+ and isinstance(item.get("path"), str)
972
+ and isinstance(item.get("logicalHash"), str)
973
+ }
974
+ kept_entries: list[dict[str, Any]] = []
975
+ for item in manifest_entries:
976
+ rel = item.get("path") if isinstance(item, dict) else None
977
+ if (
978
+ isinstance(rel, str)
979
+ and rel in baseline
980
+ and item.get("reason") in REASONS
981
+ and logical_file_hash(to_root, rel) == baseline[rel]
982
+ ):
983
+ removed_manifest_paths.append(rel)
984
+ continue
985
+ kept_entries.append(item)
986
+ if removed_manifest_paths:
987
+ cleaned = json.loads(json.dumps(manifest))
988
+ cleaned["files"] = kept_entries
989
+ cleanups = cleaned.get("reconciliations")
990
+ if cleanups is None:
991
+ cleanups = []
992
+ if not isinstance(cleanups, list):
993
+ return _result(False, action, "MANIFEST_INVALID", [])
994
+ cleanups.append(
995
+ {
996
+ "rebindId": rebind_id,
997
+ "at": rebound_at,
998
+ "reason": "logical-baseline-match",
999
+ "removedPaths": sorted(removed_manifest_paths),
1000
+ }
1001
+ )
1002
+ cleaned["reconciliations"] = cleanups
1003
+ _write_json(manifest_path, cleaned)
1004
+ except LockUnavailable:
1005
+ return _result(False, action, "SNAPSHOT_LOCKED", [])
1006
+
1007
+ return _result(
1008
+ True,
1009
+ action,
1010
+ "SNAPSHOT_REBOUND",
1011
+ sorted(validated_paths),
1012
+ fromRoot=str(from_root),
1013
+ toRoot=str(to_root),
1014
+ expectedBase=expected_base,
1015
+ targetHead=to_head,
1016
+ rebindId=rebind_id,
1017
+ snapshotHashBefore=before_hash,
1018
+ snapshotHashAfter=after_hash,
1019
+ manifestEntriesRemoved=len(removed_manifest_paths),
1020
+ removedManifestPaths=sorted(removed_manifest_paths),
1021
+ snapshotPath=str(snapshot_path),
1022
+ )
1023
+
1024
+
747
1025
  def _stage_locked(
748
1026
  project_root: Path, change_root: Path, manifest_path: Path, index_path: Path
749
1027
  ) -> dict[str, Any]:
@@ -870,10 +1148,14 @@ def _walk_matching_files(
870
1148
  return matches
871
1149
 
872
1150
 
873
- def _enumerate_allowed_test_files(project_root: Path) -> dict[str, str]:
1151
+ def _enumerate_allowed_test_files(
1152
+ project_root: Path,
1153
+ *,
1154
+ strict_profile: bool = False,
1155
+ ) -> dict[str, str]:
874
1156
  """Map repo-relative test path -> sha256 for all allowed existing files."""
875
1157
  found: dict[str, str] = {}
876
- config = _profile_config(project_root)
1158
+ config = _profile_config(project_root, strict=strict_profile)
877
1159
  if config is None:
878
1160
  patterns = ["test/**", "tests/**", "src/test/**"]
879
1161
  excluded_roots = [
@@ -888,13 +1170,285 @@ def _enumerate_allowed_test_files(project_root: Path) -> dict[str, str]:
888
1170
  if not _inside(resolved, base):
889
1171
  continue
890
1172
  rel = resolved.relative_to(base).as_posix()
891
- if rel in seen or not _allowed_test_path(project_root, rel):
1173
+ if (
1174
+ rel in seen
1175
+ or _path_is_excluded(rel, excluded_roots)
1176
+ or not any(_matches_pattern(rel, pattern) for pattern in patterns)
1177
+ ):
892
1178
  continue
893
1179
  seen.add(rel)
894
1180
  found[rel] = _sha256(resolved)
895
1181
  return found
896
1182
 
897
1183
 
1184
+ def _current_head(project_root: Path) -> str | None:
1185
+ result = _git(project_root, "rev-parse", "--verify", "HEAD")
1186
+ value = result.stdout.strip() if result.returncode == 0 else ""
1187
+ return value if re.fullmatch(r"[0-9a-fA-F]{40,64}", value) else None
1188
+
1189
+
1190
+ def _snapshot_baseline_commit(
1191
+ project_root: Path,
1192
+ change_root: Path,
1193
+ snapshot: dict[str, Any],
1194
+ ) -> str | None:
1195
+ candidates: list[Any] = [snapshot.get("headCommit")]
1196
+ for state_snapshot_path in dict.fromkeys(
1197
+ [
1198
+ change_root / "meta" / "state-snapshot.json",
1199
+ _state_root(change_root) / "meta" / "state-snapshot.json",
1200
+ ]
1201
+ ):
1202
+ if not state_snapshot_path.is_file():
1203
+ continue
1204
+ try:
1205
+ state_snapshot = _read_json(state_snapshot_path)
1206
+ except (OSError, json.JSONDecodeError):
1207
+ continue
1208
+ if isinstance(state_snapshot, dict):
1209
+ git_state = state_snapshot.get("git")
1210
+ if isinstance(git_state, dict):
1211
+ candidates.extend([git_state.get("base"), git_state.get("head")])
1212
+ for candidate in candidates:
1213
+ if not isinstance(candidate, str) or re.fullmatch(
1214
+ r"[0-9a-fA-F]{40,64}", candidate
1215
+ ) is None:
1216
+ continue
1217
+ exists = _git(project_root, "cat-file", "-e", f"{candidate}^{{commit}}")
1218
+ if exists.returncode == 0:
1219
+ return candidate
1220
+ return None
1221
+
1222
+
1223
+ def _classify_against_baseline(
1224
+ project_root: Path,
1225
+ rel: str,
1226
+ baseline_commit: str | None,
1227
+ ) -> tuple[str | None, str | None]:
1228
+ """Return touched reason (or None when unchanged) and an optional error."""
1229
+ if baseline_commit is None:
1230
+ return None, "BASELINE_COMMIT_MISSING"
1231
+ listed = _git(
1232
+ project_root,
1233
+ "ls-tree",
1234
+ "-r",
1235
+ "--name-only",
1236
+ "-z",
1237
+ baseline_commit,
1238
+ "--",
1239
+ rel,
1240
+ )
1241
+ if listed.returncode != 0:
1242
+ return None, "BASELINE_LOOKUP_FAILED"
1243
+ tracked_at_baseline = rel in {
1244
+ item for item in listed.stdout.split("\0") if item
1245
+ }
1246
+ if not tracked_at_baseline:
1247
+ return "tdd-created", None
1248
+ changed = _git(
1249
+ project_root,
1250
+ "diff",
1251
+ "--quiet",
1252
+ baseline_commit,
1253
+ "--",
1254
+ rel,
1255
+ )
1256
+ if changed.returncode == 0:
1257
+ return None, None
1258
+ if changed.returncode == 1:
1259
+ return "test-updated", None
1260
+ return None, "BASELINE_DIFF_FAILED"
1261
+
1262
+
1263
+ def _profile_fingerprint(
1264
+ project_root: Path,
1265
+ *,
1266
+ strict_profile: bool = False,
1267
+ ) -> str:
1268
+ config = _profile_config(project_root, strict=strict_profile)
1269
+ payload = json.dumps(
1270
+ {"mode": "fallback"} if config is None else {"mode": "profile", "value": config},
1271
+ ensure_ascii=False,
1272
+ sort_keys=True,
1273
+ separators=(",", ":"),
1274
+ ).encode("utf-8")
1275
+ return "sha256:" + hashlib.sha256(payload).hexdigest()
1276
+
1277
+
1278
+ def _reconcile_close_manifest(
1279
+ project_root: Path,
1280
+ change_root: Path,
1281
+ recorded: set[str],
1282
+ baseline_commit: str | None,
1283
+ ) -> dict[str, Any]:
1284
+ manifest_path = _manifest_path(change_root)
1285
+ if manifest_path is None:
1286
+ return {"ok": False, "code": "MANIFEST_PATH_OUTSIDE_PROJECT"}
1287
+ if not manifest_path.is_file():
1288
+ return {
1289
+ "ok": True,
1290
+ "code": "NO_MANIFEST",
1291
+ "removedCurrentTouches": 0,
1292
+ "deletedEntries": 0,
1293
+ "preservedForeignEntries": 0,
1294
+ }
1295
+
1296
+ lock_path = manifest_path.with_name(manifest_path.name + ".lock")
1297
+ try:
1298
+ with _exclusive_lock(lock_path, wait_seconds=5.0):
1299
+ try:
1300
+ manifest = _read_json(manifest_path)
1301
+ except (OSError, json.JSONDecodeError) as exc:
1302
+ return {"ok": False, "code": "MANIFEST_INVALID", "error": str(exc)}
1303
+ is_v2 = isinstance(manifest, dict) and manifest.get("schemaVersion") == 2
1304
+ validator = (
1305
+ _validate_existing_manifest_v2
1306
+ if is_v2
1307
+ else _validate_existing_manifest
1308
+ )
1309
+ head_before = _current_head(project_root)
1310
+ if head_before is None:
1311
+ return {"ok": False, "code": "HEAD_MISSING"}
1312
+ allow_hash_drift: set[str] = set()
1313
+ raw_entries = (
1314
+ manifest.get("files")
1315
+ if isinstance(manifest, dict)
1316
+ else None
1317
+ )
1318
+ if isinstance(raw_entries, list):
1319
+ for raw_item in raw_entries:
1320
+ if not isinstance(raw_item, dict):
1321
+ continue
1322
+ rel = raw_item.get("path")
1323
+ if (
1324
+ not isinstance(rel, str)
1325
+ or not rel
1326
+ or rel in recorded
1327
+ or raw_item.get("reason") == "stale-test-repair"
1328
+ ):
1329
+ continue
1330
+ touched_reason, classify_error = _classify_against_baseline(
1331
+ project_root,
1332
+ rel,
1333
+ baseline_commit,
1334
+ )
1335
+ if classify_error:
1336
+ return {
1337
+ "ok": False,
1338
+ "code": classify_error,
1339
+ "files": [rel],
1340
+ }
1341
+ if touched_reason is None:
1342
+ allow_hash_drift.add(rel)
1343
+ error, validated = validator(
1344
+ project_root,
1345
+ manifest,
1346
+ allow_hash_drift=allow_hash_drift,
1347
+ require_files=False,
1348
+ )
1349
+ if error:
1350
+ return {"ok": False, "code": error}
1351
+
1352
+ before_hash = _sha256(manifest_path)
1353
+ change_id = change_root.name
1354
+ reconciled: list[dict[str, Any]] = []
1355
+ removed_paths: list[str] = []
1356
+ removed_current_touches = 0
1357
+ deleted_entries = 0
1358
+ preserved_foreign_entries = 0
1359
+
1360
+ for rel in sorted(validated):
1361
+ item = dict(validated[rel])
1362
+ if rel in recorded or item.get("reason") == "stale-test-repair":
1363
+ reconciled.append(item)
1364
+ continue
1365
+ touched_reason, classify_error = _classify_against_baseline(
1366
+ project_root,
1367
+ rel,
1368
+ baseline_commit,
1369
+ )
1370
+ if classify_error:
1371
+ return {"ok": False, "code": classify_error, "files": [rel]}
1372
+ if touched_reason is not None:
1373
+ reconciled.append(item)
1374
+ continue
1375
+
1376
+ if not is_v2:
1377
+ removed_paths.append(rel)
1378
+ deleted_entries += 1
1379
+ continue
1380
+
1381
+ touched_by = list(item.get("touchedBy") or [])
1382
+ if change_id not in touched_by:
1383
+ reconciled.append(item)
1384
+ preserved_foreign_entries += 1
1385
+ continue
1386
+ remaining = [owner for owner in touched_by if owner != change_id]
1387
+ removed_current_touches += 1
1388
+ if item.get("introducedBy") == change_id:
1389
+ if remaining:
1390
+ return {
1391
+ "ok": False,
1392
+ "code": "FOREIGN_PROVENANCE_CONFLICT",
1393
+ "files": [rel],
1394
+ }
1395
+ removed_paths.append(rel)
1396
+ deleted_entries += 1
1397
+ continue
1398
+ item["touchedBy"] = remaining
1399
+ item["commitScope"] = "foreign-change"
1400
+ current_hash = logical_file_hash(project_root, rel)
1401
+ item["logicalHash"] = current_hash
1402
+ item["binaryHash"] = (
1403
+ None if current_hash.startswith("gitblob:") else current_hash
1404
+ )
1405
+ reconciled.append(item)
1406
+ preserved_foreign_entries += 1
1407
+
1408
+ for rel in removed_paths:
1409
+ touched_reason, classify_error = _classify_against_baseline(
1410
+ project_root,
1411
+ rel,
1412
+ baseline_commit,
1413
+ )
1414
+ if classify_error or touched_reason is not None:
1415
+ return {
1416
+ "ok": False,
1417
+ "code": classify_error or "FILE_CHANGED_DURING_RECONCILE",
1418
+ "files": [rel],
1419
+ }
1420
+ if _current_head(project_root) != head_before:
1421
+ return {"ok": False, "code": "HEAD_MOVED"}
1422
+
1423
+ updated = json.loads(json.dumps(manifest))
1424
+ updated["files"] = reconciled
1425
+ if reconciled:
1426
+ recheck_error, _ = validator(
1427
+ project_root,
1428
+ updated,
1429
+ require_files=False,
1430
+ )
1431
+ if recheck_error:
1432
+ return {"ok": False, "code": recheck_error}
1433
+ _write_json(manifest_path, updated)
1434
+ after_hash: str | None = _sha256(manifest_path)
1435
+ else:
1436
+ manifest_path.unlink()
1437
+ after_hash = None
1438
+ except LockUnavailable:
1439
+ return {"ok": False, "code": "MANIFEST_LOCKED"}
1440
+
1441
+ return {
1442
+ "ok": True,
1443
+ "code": "RECONCILED",
1444
+ "manifestHashBefore": before_hash,
1445
+ "manifestHashAfter": after_hash,
1446
+ "removedCurrentTouches": removed_current_touches,
1447
+ "deletedEntries": deleted_entries,
1448
+ "preservedForeignEntries": preserved_foreign_entries,
1449
+ }
1450
+
1451
+
898
1452
  def begin(project: Path | str, change_dir: Path | str) -> dict[str, Any]:
899
1453
  action = "begin"
900
1454
  project_root = Path(project).resolve()
@@ -907,6 +1461,19 @@ def begin(project: Path | str, change_dir: Path | str) -> dict[str, Any]:
907
1461
  snapshot_path = _state_root(change_root) / SNAPSHOT_REL
908
1462
  if not _manifest_target_inside(change_root, snapshot_path):
909
1463
  return _result(False, action, "SNAPSHOT_PATH_OUTSIDE_PROJECT", [])
1464
+ try:
1465
+ current_profile_fingerprint = _profile_fingerprint(
1466
+ project_root,
1467
+ strict_profile=True,
1468
+ )
1469
+ except ProfileConfigInvalid as exc:
1470
+ return _result(
1471
+ False,
1472
+ action,
1473
+ "PROFILE_INVALID",
1474
+ [],
1475
+ error=str(exc),
1476
+ )
910
1477
 
911
1478
  if snapshot_path.is_file():
912
1479
  try:
@@ -924,6 +1491,11 @@ def begin(project: Path | str, change_dir: Path | str) -> dict[str, Any]:
924
1491
  for item in entries)
925
1492
  ):
926
1493
  return _result(False, action, "SNAPSHOT_INVALID", [])
1494
+ if (
1495
+ isinstance(snapshot.get("profileFingerprint"), str)
1496
+ and snapshot["profileFingerprint"] != current_profile_fingerprint
1497
+ ):
1498
+ return _result(False, action, "PROFILE_CHANGED", [])
927
1499
  return _result(
928
1500
  True,
929
1501
  action,
@@ -933,11 +1505,26 @@ def begin(project: Path | str, change_dir: Path | str) -> dict[str, Any]:
933
1505
  fileCount=len(entries),
934
1506
  )
935
1507
 
936
- files = _enumerate_allowed_test_files(project_root)
1508
+ try:
1509
+ files = _enumerate_allowed_test_files(
1510
+ project_root,
1511
+ strict_profile=True,
1512
+ )
1513
+ except ProfileConfigInvalid as exc:
1514
+ return _result(
1515
+ False,
1516
+ action,
1517
+ "PROFILE_INVALID",
1518
+ [],
1519
+ error=str(exc),
1520
+ )
937
1521
  snapshot = {
938
1522
  "schemaVersion": SCHEMA_VERSION,
939
1523
  "mode": MODE,
940
1524
  "projectRoot": str(project_root),
1525
+ "repositoryId": harness_paths.repository_identity(project_root),
1526
+ "headCommit": _current_head(project_root),
1527
+ "profileFingerprint": current_profile_fingerprint,
941
1528
  "capturedAt": time.strftime("%Y-%m-%dT%H:%M:%S"),
942
1529
  "files": [
943
1530
  {"path": rel, "sha256": digest, "ignored": _is_ignored(project_root, rel)}
@@ -987,6 +1574,36 @@ def close(project: Path | str, change_dir: Path | str) -> dict[str, Any]:
987
1574
  expectedRoot=snapshot_root,
988
1575
  actualRoot=str(project_root),
989
1576
  )
1577
+ snapshot_repository = snapshot.get("repositoryId")
1578
+ if (
1579
+ snapshot_repository is not None
1580
+ and snapshot_repository != harness_paths.repository_identity(project_root)
1581
+ ):
1582
+ return _result(False, action, "REPOSITORY_MISMATCH", [])
1583
+ try:
1584
+ current_profile_fingerprint = _profile_fingerprint(
1585
+ project_root,
1586
+ strict_profile=True,
1587
+ )
1588
+ except ProfileConfigInvalid as exc:
1589
+ return _result(
1590
+ False,
1591
+ action,
1592
+ "PROFILE_INVALID",
1593
+ [],
1594
+ error=str(exc),
1595
+ )
1596
+ if (
1597
+ isinstance(snapshot.get("profileFingerprint"), str)
1598
+ and snapshot["profileFingerprint"] != current_profile_fingerprint
1599
+ ):
1600
+ return _result(False, action, "PROFILE_CHANGED", [])
1601
+ baseline_commit = _snapshot_baseline_commit(
1602
+ project_root,
1603
+ change_root,
1604
+ snapshot,
1605
+ )
1606
+ profile_changed = False
990
1607
 
991
1608
  before_entries = snapshot.get("files")
992
1609
  if not isinstance(before_entries, list):
@@ -999,14 +1616,26 @@ def close(project: Path | str, change_dir: Path | str) -> dict[str, Any]:
999
1616
  digest = item.get("sha256")
1000
1617
  if not isinstance(rel, str) or not rel or not isinstance(digest, str):
1001
1618
  return _result(False, action, "SNAPSHOT_INVALID", [])
1002
- path, normalized, error = _validate_file(project_root, rel)
1619
+ path, normalized, error = _validate_snapshot_file(project_root, rel)
1003
1620
  if error:
1004
1621
  return _result(False, action, error, [rel])
1005
1622
  if normalized != rel or path is None:
1006
1623
  return _result(False, action, "PATH_ESCAPE", [rel])
1007
1624
  before[rel] = item
1008
1625
 
1009
- current = _enumerate_allowed_test_files(project_root)
1626
+ try:
1627
+ current = _enumerate_allowed_test_files(
1628
+ project_root,
1629
+ strict_profile=True,
1630
+ )
1631
+ except ProfileConfigInvalid as exc:
1632
+ return _result(
1633
+ False,
1634
+ action,
1635
+ "PROFILE_INVALID",
1636
+ [],
1637
+ error=str(exc),
1638
+ )
1010
1639
  for rel in current:
1011
1640
  path, normalized, error = _validate_file(project_root, rel)
1012
1641
  if error:
@@ -1017,9 +1646,23 @@ def close(project: Path | str, change_dir: Path | str) -> dict[str, Any]:
1017
1646
  touched: list[tuple[str, str]] = []
1018
1647
  for rel, digest in current.items():
1019
1648
  if rel not in before:
1020
- touched.append((rel, "tdd-created"))
1649
+ reason, classify_error = _classify_against_baseline(
1650
+ project_root,
1651
+ rel,
1652
+ baseline_commit,
1653
+ )
1654
+ if classify_error:
1655
+ return _result(False, action, classify_error, [rel])
1656
+ if reason is not None:
1657
+ touched.append((rel, reason))
1021
1658
  continue
1022
- if before[rel]["sha256"] != digest:
1659
+ before_digest = before[rel].get("logicalHash") or before[rel]["sha256"]
1660
+ current_digest = (
1661
+ logical_file_hash(project_root, rel)
1662
+ if before[rel].get("logicalHash")
1663
+ else digest
1664
+ )
1665
+ if before_digest != current_digest:
1023
1666
  touched.append((rel, "test-updated"))
1024
1667
 
1025
1668
  for reason in ("tdd-created", "test-updated"):
@@ -1039,6 +1682,19 @@ def close(project: Path | str, change_dir: Path | str) -> dict[str, Any]:
1039
1682
  return result
1040
1683
 
1041
1684
  recorded = [rel for rel, _ in touched]
1685
+ reconciliation = _reconcile_close_manifest(
1686
+ project_root,
1687
+ change_root,
1688
+ set(recorded),
1689
+ baseline_commit,
1690
+ )
1691
+ if not reconciliation.get("ok"):
1692
+ return _result(
1693
+ False,
1694
+ action,
1695
+ str(reconciliation.get("code") or "MANIFEST_RECONCILE_FAILED"),
1696
+ list(reconciliation.get("files") or []),
1697
+ )
1042
1698
 
1043
1699
  # Cross-check (retro §5.10): if the manifest has active entries for this
1044
1700
  # change but close computed recordedCount=0, the snapshot/manifest/diff
@@ -1052,10 +1708,17 @@ def close(project: Path | str, change_dir: Path | str) -> dict[str, Any]:
1052
1708
  if isinstance(manifest, dict):
1053
1709
  manifest_files = manifest.get("files")
1054
1710
  if isinstance(manifest_files, list):
1711
+ is_v2 = manifest.get("schemaVersion") == 2
1712
+ change_id = change_root.name
1055
1713
  active_entries = [
1056
1714
  f for f in manifest_files
1057
1715
  if isinstance(f, dict)
1058
1716
  and f.get("reason") in ("tdd-created", "test-updated", "stale-test-repair")
1717
+ and (
1718
+ not is_v2
1719
+ or f.get("introducedBy") == change_id
1720
+ or change_id in (f.get("touchedBy") or [])
1721
+ )
1059
1722
  ]
1060
1723
  if active_entries and not recorded:
1061
1724
  return _result(
@@ -1075,6 +1738,13 @@ def close(project: Path | str, change_dir: Path | str) -> dict[str, Any]:
1075
1738
  recorded,
1076
1739
  recordedCount=len(recorded),
1077
1740
  unchangedPreexisting=len(before) - sum(1 for rel, _ in touched if rel in before),
1741
+ baselineCommit=baseline_commit,
1742
+ profileChanged=profile_changed,
1743
+ manifestReconciliation={
1744
+ key: value
1745
+ for key, value in reconciliation.items()
1746
+ if key != "ok"
1747
+ },
1078
1748
  )
1079
1749
 
1080
1750
 
@@ -1146,6 +1816,12 @@ def main(argv: list[str] | None = None) -> int:
1146
1816
  rehome_parser.add_argument("--change-dir", required=True)
1147
1817
  rehome_parser.add_argument("--expected-head", required=True)
1148
1818
  rehome_parser.add_argument("--json", action="store_true")
1819
+ rebind_parser = sub.add_parser("rebind-snapshot")
1820
+ rebind_parser.add_argument("--from", dest="from_project", required=True)
1821
+ rebind_parser.add_argument("--to", dest="to_project", required=True)
1822
+ rebind_parser.add_argument("--change-dir", required=True)
1823
+ rebind_parser.add_argument("--expected-base", required=True)
1824
+ rebind_parser.add_argument("--json", action="store_true")
1149
1825
  mark_parser = sub.add_parser("mark")
1150
1826
  mark_parser.add_argument("--project", required=True)
1151
1827
  mark_parser.add_argument("--change-dir", required=True)
@@ -1166,6 +1842,10 @@ def main(argv: list[str] | None = None) -> int:
1166
1842
  result = rehome(
1167
1843
  args.from_project, args.to_project, args.change_dir, args.expected_head
1168
1844
  )
1845
+ elif args.action == "rebind-snapshot":
1846
+ result = rebind_snapshot(
1847
+ args.from_project, args.to_project, args.change_dir, args.expected_base
1848
+ )
1169
1849
  else:
1170
1850
  result = stage(args.project, args.change_dir)
1171
1851
  print(json.dumps(result, ensure_ascii=False, indent=2 if args.json else None))