@hunter-harness/workflow-harness 0.2.37 → 0.2.39

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 +10 -7
  3. package/harness/bundles/general/claude-code/harness-archive/templates/render-summary.mjs +24 -4
  4. package/harness/bundles/general/claude-code/harness-codebase-map/SKILL.md +1 -1
  5. package/harness/bundles/general/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  6. package/harness/bundles/general/claude-code/harness-knowledge-ingest/scripts/harness_knowledge.py +283 -28
  7. package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +1 -1
  8. package/harness/bundles/general/claude-code/harness-plan/SKILL.md +1 -1
  9. package/harness/bundles/general/claude-code/harness-review/SKILL.md +2 -2
  10. package/harness/bundles/general/claude-code/harness-review/reference.md +20 -5
  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 +446 -20
  16. package/harness/bundles/general/claude-code/scripts/harness_events.py +273 -21
  17. package/harness/bundles/general/claude-code/scripts/harness_gate.py +56 -32
  18. package/harness/bundles/general/claude-code/scripts/harness_integration.py +197 -3
  19. package/harness/bundles/general/claude-code/scripts/harness_ledger.py +126 -2
  20. package/harness/bundles/general/claude-code/scripts/harness_report_model.py +28 -6
  21. package/harness/bundles/general/claude-code/scripts/harness_review.py +118 -17
  22. package/harness/bundles/general/claude-code/scripts/harness_service.py +219 -0
  23. package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
  24. package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +10 -7
  25. package/harness/bundles/general/codebuddy/harness-archive/templates/render-summary.mjs +24 -4
  26. package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
  27. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  28. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge.py +283 -28
  29. package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  30. package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +1 -1
  31. package/harness/bundles/general/codebuddy/harness-review/SKILL.md +2 -2
  32. package/harness/bundles/general/codebuddy/harness-review/reference.md +20 -5
  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 +446 -20
  38. package/harness/bundles/general/codebuddy/scripts/harness_events.py +273 -21
  39. package/harness/bundles/general/codebuddy/scripts/harness_gate.py +56 -32
  40. package/harness/bundles/general/codebuddy/scripts/harness_integration.py +197 -3
  41. package/harness/bundles/general/codebuddy/scripts/harness_ledger.py +126 -2
  42. package/harness/bundles/general/codebuddy/scripts/harness_report_model.py +28 -6
  43. package/harness/bundles/general/codebuddy/scripts/harness_review.py +118 -17
  44. package/harness/bundles/general/codebuddy/scripts/harness_service.py +219 -0
  45. package/harness/bundles/general/codex/.harness-build.json +1 -1
  46. package/harness/bundles/general/codex/harness-archive/SKILL.md +10 -7
  47. package/harness/bundles/general/codex/harness-archive/templates/render-summary.mjs +24 -4
  48. package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
  49. package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +1 -1
  50. package/harness/bundles/general/codex/harness-knowledge-ingest/scripts/harness_knowledge.py +283 -28
  51. package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
  52. package/harness/bundles/general/codex/harness-plan/SKILL.md +1 -1
  53. package/harness/bundles/general/codex/harness-review/SKILL.md +2 -2
  54. package/harness/bundles/general/codex/harness-review/reference.md +20 -5
  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 +446 -20
  60. package/harness/bundles/general/codex/scripts/harness_events.py +273 -21
  61. package/harness/bundles/general/codex/scripts/harness_gate.py +56 -32
  62. package/harness/bundles/general/codex/scripts/harness_integration.py +197 -3
  63. package/harness/bundles/general/codex/scripts/harness_ledger.py +126 -2
  64. package/harness/bundles/general/codex/scripts/harness_report_model.py +28 -6
  65. package/harness/bundles/general/codex/scripts/harness_review.py +118 -17
  66. package/harness/bundles/general/codex/scripts/harness_service.py +219 -0
  67. package/harness/bundles/general/cursor/.harness-build.json +1 -1
  68. package/harness/bundles/general/cursor/harness-archive/SKILL.md +10 -7
  69. package/harness/bundles/general/cursor/harness-archive/templates/render-summary.mjs +24 -4
  70. package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
  71. package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  72. package/harness/bundles/general/cursor/harness-knowledge-ingest/scripts/harness_knowledge.py +283 -28
  73. package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
  74. package/harness/bundles/general/cursor/harness-plan/SKILL.md +1 -1
  75. package/harness/bundles/general/cursor/harness-review/SKILL.md +2 -2
  76. package/harness/bundles/general/cursor/harness-review/reference.md +20 -5
  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 +446 -20
  82. package/harness/bundles/general/cursor/scripts/harness_events.py +273 -21
  83. package/harness/bundles/general/cursor/scripts/harness_gate.py +56 -32
  84. package/harness/bundles/general/cursor/scripts/harness_integration.py +197 -3
  85. package/harness/bundles/general/cursor/scripts/harness_ledger.py +126 -2
  86. package/harness/bundles/general/cursor/scripts/harness_report_model.py +28 -6
  87. package/harness/bundles/general/cursor/scripts/harness_review.py +118 -17
  88. package/harness/bundles/general/cursor/scripts/harness_service.py +219 -0
  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 +10 -7
  92. package/harness/bundles/java/claude-code/harness-archive/templates/render-summary.mjs +24 -4
  93. package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
  94. package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  95. package/harness/bundles/java/claude-code/harness-knowledge-ingest/scripts/harness_knowledge.py +283 -28
  96. package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
  97. package/harness/bundles/java/claude-code/harness-package/SKILL.md +1 -1
  98. package/harness/bundles/java/claude-code/harness-plan/SKILL.md +1 -1
  99. package/harness/bundles/java/claude-code/harness-review/SKILL.md +2 -2
  100. package/harness/bundles/java/claude-code/harness-review/reference.md +20 -5
  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 +446 -20
  106. package/harness/bundles/java/claude-code/scripts/harness_events.py +273 -21
  107. package/harness/bundles/java/claude-code/scripts/harness_gate.py +56 -32
  108. package/harness/bundles/java/claude-code/scripts/harness_integration.py +197 -3
  109. package/harness/bundles/java/claude-code/scripts/harness_ledger.py +126 -2
  110. package/harness/bundles/java/claude-code/scripts/harness_report_model.py +28 -6
  111. package/harness/bundles/java/claude-code/scripts/harness_review.py +118 -17
  112. package/harness/bundles/java/claude-code/scripts/harness_service.py +219 -0
  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 +10 -7
  116. package/harness/bundles/java/codebuddy/harness-archive/templates/render-summary.mjs +24 -4
  117. package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
  118. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  119. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge.py +283 -28
  120. package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  121. package/harness/bundles/java/codebuddy/harness-package/SKILL.md +1 -1
  122. package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +1 -1
  123. package/harness/bundles/java/codebuddy/harness-review/SKILL.md +2 -2
  124. package/harness/bundles/java/codebuddy/harness-review/reference.md +20 -5
  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 +446 -20
  130. package/harness/bundles/java/codebuddy/scripts/harness_events.py +273 -21
  131. package/harness/bundles/java/codebuddy/scripts/harness_gate.py +56 -32
  132. package/harness/bundles/java/codebuddy/scripts/harness_integration.py +197 -3
  133. package/harness/bundles/java/codebuddy/scripts/harness_ledger.py +126 -2
  134. package/harness/bundles/java/codebuddy/scripts/harness_report_model.py +28 -6
  135. package/harness/bundles/java/codebuddy/scripts/harness_review.py +118 -17
  136. package/harness/bundles/java/codebuddy/scripts/harness_service.py +219 -0
  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 +10 -7
  140. package/harness/bundles/java/codex/harness-archive/templates/render-summary.mjs +24 -4
  141. package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
  142. package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +1 -1
  143. package/harness/bundles/java/codex/harness-knowledge-ingest/scripts/harness_knowledge.py +283 -28
  144. package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
  145. package/harness/bundles/java/codex/harness-package/SKILL.md +1 -1
  146. package/harness/bundles/java/codex/harness-plan/SKILL.md +1 -1
  147. package/harness/bundles/java/codex/harness-review/SKILL.md +2 -2
  148. package/harness/bundles/java/codex/harness-review/reference.md +20 -5
  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 +446 -20
  154. package/harness/bundles/java/codex/scripts/harness_events.py +273 -21
  155. package/harness/bundles/java/codex/scripts/harness_gate.py +56 -32
  156. package/harness/bundles/java/codex/scripts/harness_integration.py +197 -3
  157. package/harness/bundles/java/codex/scripts/harness_ledger.py +126 -2
  158. package/harness/bundles/java/codex/scripts/harness_report_model.py +28 -6
  159. package/harness/bundles/java/codex/scripts/harness_review.py +118 -17
  160. package/harness/bundles/java/codex/scripts/harness_service.py +219 -0
  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 +10 -7
  164. package/harness/bundles/java/cursor/harness-archive/templates/render-summary.mjs +24 -4
  165. package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
  166. package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  167. package/harness/bundles/java/cursor/harness-knowledge-ingest/scripts/harness_knowledge.py +283 -28
  168. package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
  169. package/harness/bundles/java/cursor/harness-package/SKILL.md +1 -1
  170. package/harness/bundles/java/cursor/harness-plan/SKILL.md +1 -1
  171. package/harness/bundles/java/cursor/harness-review/SKILL.md +2 -2
  172. package/harness/bundles/java/cursor/harness-review/reference.md +20 -5
  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 +446 -20
  178. package/harness/bundles/java/cursor/scripts/harness_events.py +273 -21
  179. package/harness/bundles/java/cursor/scripts/harness_gate.py +56 -32
  180. package/harness/bundles/java/cursor/scripts/harness_integration.py +197 -3
  181. package/harness/bundles/java/cursor/scripts/harness_ledger.py +126 -2
  182. package/harness/bundles/java/cursor/scripts/harness_report_model.py +28 -6
  183. package/harness/bundles/java/cursor/scripts/harness_review.py +118 -17
  184. package/harness/bundles/java/cursor/scripts/harness_service.py +219 -0
  185. package/harness/manifests/general/claude-code.json +24 -24
  186. package/harness/manifests/general/codebuddy.json +24 -24
  187. package/harness/manifests/general/codex.json +24 -24
  188. package/harness/manifests/general/cursor.json +24 -24
  189. package/harness/manifests/java/claude-code.json +26 -26
  190. package/harness/manifests/java/codebuddy.json +26 -26
  191. package/harness/manifests/java/codex.json +26 -26
  192. package/harness/manifests/java/cursor.json +26 -26
  193. package/hunter-workflow-family.json +4 -4
  194. package/package.json +1 -1
@@ -36,6 +36,7 @@ if str(SCRIPTS_DIR) not in sys.path:
36
36
  import harness_change # noqa: E402
37
37
  import harness_ledger as hl # noqa: E402
38
38
  import harness_paths # noqa: E402
39
+ import harness_service # noqa: E402
39
40
 
40
41
  if hasattr(sys.stdout, "reconfigure"):
41
42
  sys.stdout.reconfigure(encoding="utf-8")
@@ -293,6 +294,68 @@ def _dedupe_verify_commands(
293
294
  return result
294
295
 
295
296
 
297
+ _HEAVY_VERIFICATIONS = frozenset({"unitTestFull", "apiTest", "browserTest", "package"})
298
+
299
+
300
+ def _merge_command_kind(item: Any) -> tuple[str, str | None]:
301
+ """Classify typed merge commands; legacy strings remain smoke."""
302
+ if not isinstance(item, dict):
303
+ return "smoke", None
304
+ verification = str(item.get("verification") or item.get("target") or "").strip() or None
305
+ declared = str(item.get("kind") or item.get("class") or "").strip().lower()
306
+ if declared in {"integrity", "smoke", "heavyweight"}:
307
+ return declared, verification
308
+ return ("heavyweight" if verification in _HEAVY_VERIFICATIONS else "smoke"), verification
309
+
310
+
311
+ def _reusable_merge_evidence(
312
+ ledger_path: Path | None,
313
+ *,
314
+ verification: str | None,
315
+ command: Sequence[str],
316
+ product_tree_hash: str | None,
317
+ reuse_context: dict[str, str],
318
+ ) -> dict[str, Any] | None:
319
+ """Return feature-tip evidence when merge content and execution key match."""
320
+ if ledger_path is None or verification is None or not product_tree_hash:
321
+ return None
322
+ try:
323
+ ledger = json.loads(ledger_path.read_text(encoding="utf-8-sig"))
324
+ except (OSError, json.JSONDecodeError):
325
+ return None
326
+ raw = ledger.get("verificationTargets")
327
+ entries = raw.values() if isinstance(raw, dict) else raw if isinstance(raw, list) else []
328
+ command_hash = hl.command_set_hash(" ".join(str(part) for part in command))
329
+ for entry in reversed(list(entries)):
330
+ if not isinstance(entry, dict) or entry.get("verification") != verification:
331
+ continue
332
+ if entry.get("status") != "OK":
333
+ continue
334
+ if str(entry.get("productTreeHash") or "").removeprefix("sha256:") != product_tree_hash.removeprefix("sha256:"):
335
+ continue
336
+ if str(entry.get("commandSetHash") or "") != command_hash:
337
+ continue
338
+ # Context identity is fail-closed: an evidence field may only be
339
+ # reused when the merge policy supplies the same current value.
340
+ if any(
341
+ str(entry.get(field) or "").strip()
342
+ and str(entry.get(field) or "").strip()
343
+ != str(reuse_context.get(field) or "").strip()
344
+ for field in ("environmentHash", "toolchainHash", "lockHash", "dbSchemaHash")
345
+ ):
346
+ continue
347
+ return {
348
+ "reusedFrom": {
349
+ "targetId": entry.get("id"),
350
+ "sourceCommit": entry.get("productCommit") or entry.get("currentHead"),
351
+ "sourceCandidate": entry.get("candidateId"),
352
+ },
353
+ "reuseReason": "product tree and canonical execution key matched",
354
+ "reuseKey": hl.canonical_reuse_key({**entry, "verification": verification}),
355
+ }
356
+ return None
357
+
358
+
296
359
  def _normalize_verify_command(
297
360
  command: Sequence[str],
298
361
  *,
@@ -1210,6 +1273,49 @@ class IntegrationTransaction:
1210
1273
  raise ValueError(f"requiredOnMerge[{index}].command missing")
1211
1274
  return commands
1212
1275
 
1276
+ def _required_on_merge_plan(self) -> list[dict[str, Any]]:
1277
+ """Classify profile commands without breaking the legacy command API."""
1278
+ import harness_profile
1279
+
1280
+ profile = harness_profile.load_profile(self.project_root)
1281
+ merge = profile.get("mergeVerification") if isinstance(profile, dict) else None
1282
+ required = merge.get("requiredOnMerge") if isinstance(merge, dict) else []
1283
+ if not isinstance(required, list):
1284
+ return []
1285
+ plan: list[dict[str, Any]] = []
1286
+ for index, item in enumerate(required):
1287
+ if isinstance(item, str) and item.strip():
1288
+ plan.append({"command": _split_verify_command(item.strip()), "kind": "smoke"})
1289
+ continue
1290
+ if not isinstance(item, dict):
1291
+ raise ValueError(f"requiredOnMerge[{index}] must be string or object")
1292
+ raw = item.get("command")
1293
+ if isinstance(raw, list) and raw and all(isinstance(value, str) and value for value in raw):
1294
+ command = [str(value) for value in raw]
1295
+ elif isinstance(raw, str) and raw.strip():
1296
+ command = _split_verify_command(raw.strip())
1297
+ else:
1298
+ raise ValueError(f"requiredOnMerge[{index}].command missing")
1299
+ kind, verification = _merge_command_kind(item)
1300
+ plan.append(
1301
+ {
1302
+ "command": command,
1303
+ "kind": kind,
1304
+ "verification": verification,
1305
+ "reuseContext": {
1306
+ field: str(item.get(field)).strip()
1307
+ for field in (
1308
+ "environmentHash",
1309
+ "toolchainHash",
1310
+ "lockHash",
1311
+ "dbSchemaHash",
1312
+ )
1313
+ if str(item.get(field) or "").strip()
1314
+ },
1315
+ }
1316
+ )
1317
+ return plan
1318
+
1213
1319
  def verify(
1214
1320
  self,
1215
1321
  commands: Sequence[Sequence[str]] | None = None,
@@ -1217,12 +1323,16 @@ class IntegrationTransaction:
1217
1323
  timeout_seconds: float = DEFAULT_VERIFY_TIMEOUT_SECONDS,
1218
1324
  ) -> dict[str, Any]:
1219
1325
  commands = list(commands or [])
1220
- # Wave-2 H-9: append profile mergeVerification.requiredOnMerge commands.
1221
1326
  try:
1222
- commands.extend(self._required_on_merge_commands())
1327
+ profile_plan = self._required_on_merge_plan()
1223
1328
  except Exception as exc: # noqa: BLE001 — surface as verify plan error
1224
1329
  raise VerifyPlanMissingError(f"mergeVerification load failed: {exc}") from exc
1225
- commands = _dedupe_verify_commands(commands)
1330
+ plan = [
1331
+ {"command": list(command), "kind": "smoke", "verification": None}
1332
+ for command in commands
1333
+ ]
1334
+ plan.extend(profile_plan)
1335
+ commands = _dedupe_verify_commands([item["command"] for item in plan])
1226
1336
  if timeout_seconds <= 0:
1227
1337
  raise VerifyPlanMissingError("verification timeout must be greater than zero")
1228
1338
 
@@ -1236,7 +1346,35 @@ class IntegrationTransaction:
1236
1346
  )
1237
1347
  intg_root = Path(journal["integrationRoot"]).resolve()
1238
1348
  results: list[dict[str, Any]] = []
1349
+ merge_tree = self.runner.text(intg_root, "rev-parse", "--verify", "HEAD^{tree}")
1350
+ merge_tree_hash = "sha256:" + merge_tree if merge_tree else None
1351
+ policies = {tuple(item["command"]): item for item in plan}
1239
1352
  for index, command in enumerate(commands):
1353
+ policy = policies.get(
1354
+ tuple(command), {"kind": "smoke", "verification": None}
1355
+ )
1356
+ reused = (
1357
+ _reusable_merge_evidence(
1358
+ self._ledger_path(),
1359
+ verification=policy.get("verification"),
1360
+ command=command,
1361
+ product_tree_hash=merge_tree_hash,
1362
+ reuse_context=policy.get("reuseContext") or {},
1363
+ )
1364
+ if policy.get("kind") == "heavyweight"
1365
+ else None
1366
+ )
1367
+ if reused is not None:
1368
+ results.append(
1369
+ {
1370
+ "command": list(command),
1371
+ "exitCode": 0,
1372
+ "reused": True,
1373
+ "kind": "heavyweight",
1374
+ **reused,
1375
+ }
1376
+ )
1377
+ continue
1240
1378
  executable_command = _normalize_verify_command(command)
1241
1379
  started = dt.datetime.now().astimezone()
1242
1380
  try:
@@ -1579,6 +1717,51 @@ class IntegrationTransaction:
1579
1717
  "residualPaths": residual_paths,
1580
1718
  }
1581
1719
 
1720
+ def _persist_cleanup_snapshot(
1721
+ self, journal: dict[str, Any], execution_root: Path
1722
+ ) -> dict[str, Any]:
1723
+ """Persist all evidence needed to close cleanup after the root is gone."""
1724
+ snapshot = {
1725
+ "schemaVersion": 1,
1726
+ "createdAt": now_iso(),
1727
+ "changeId": self.change_id,
1728
+ "attemptId": self.run_id,
1729
+ "worktreeRoot": str(execution_root.resolve()),
1730
+ "executionRoot": str(execution_root.resolve()),
1731
+ "mergeEvidence": {
1732
+ key: journal.get(key)
1733
+ for key in (
1734
+ "mergeCommit",
1735
+ "pushedHead",
1736
+ "mergeFinalHash",
1737
+ "ciExpectedHead",
1738
+ "remoteHead",
1739
+ )
1740
+ },
1741
+ "evidenceIdentity": journal.get("evidenceIdentity"),
1742
+ }
1743
+ journal["cleanupSnapshot"] = snapshot
1744
+ self._save(journal)
1745
+ return snapshot
1746
+
1747
+ def _stop_owned_services(
1748
+ self, journal: dict[str, Any], execution_root: Path
1749
+ ) -> dict[str, Any]:
1750
+ """Stop only sessions explicitly owned by this transaction worktree."""
1751
+ result = harness_service.stop_owned_sessions(
1752
+ self.project_root,
1753
+ change_id=self.change_id,
1754
+ execution_root=execution_root,
1755
+ worktree_root=execution_root,
1756
+ )
1757
+ journal["serviceStop"] = result
1758
+ self._save(journal)
1759
+ if not result["ok"]:
1760
+ raise CleanupRefusedError(
1761
+ "owned service could not be identity-verified and stopped"
1762
+ )
1763
+ return result
1764
+
1582
1765
  def cleanup(self) -> dict[str, Any]:
1583
1766
  try:
1584
1767
  existing = self._load()
@@ -1592,6 +1775,8 @@ class IntegrationTransaction:
1592
1775
  def action(journal: dict[str, Any]) -> None:
1593
1776
  self._assert_cleanup_consistency(journal)
1594
1777
  intg_root = Path(journal["integrationRoot"])
1778
+ self._persist_cleanup_snapshot(journal, intg_root)
1779
+ service_stop = self._stop_owned_services(journal, intg_root)
1595
1780
  cleanup_result: dict[str, Any] = {
1596
1781
  "ok": True,
1597
1782
  "code": "ALREADY_ABSENT",
@@ -1657,6 +1842,15 @@ class IntegrationTransaction:
1657
1842
  "primaryHead": local_target,
1658
1843
  "remoteHead": remote_target,
1659
1844
  "residualPaths": cleanup_result.get("residualPaths") or [],
1845
+ "serviceStop": service_stop,
1846
+ }
1847
+ journal["cleanupReceipt"] = {
1848
+ "schemaVersion": 1,
1849
+ "completedAt": now_iso(),
1850
+ "snapshot": journal.get("cleanupSnapshot"),
1851
+ "worktreeRemoval": cleanup_result,
1852
+ "serviceStop": service_stop,
1853
+ "worktreeGone": not intg_root.exists(),
1660
1854
  }
1661
1855
  self._save(journal)
1662
1856
 
@@ -139,6 +139,55 @@ def verification_target_identity(
139
139
  return "sha256:" + hashlib.sha256(payload).hexdigest()
140
140
 
141
141
 
142
+ def _hash_text(value: str) -> str:
143
+ return "sha256:" + hashlib.sha256(value.encode("utf-8")).hexdigest()
144
+
145
+
146
+ def command_set_hash(command: str) -> str:
147
+ """Stable identity for the exact command set represented by one target."""
148
+ return _hash_text(str(command).strip())
149
+
150
+
151
+ def product_tree_hash(project_root: Path | None) -> str | None:
152
+ """Git tree identity is content provenance; a commit SHA is not."""
153
+ if project_root is None:
154
+ return None
155
+ tree = _git_text(project_root, "rev-parse", "--verify", "HEAD^{tree}")
156
+ return "sha256:" + tree if _nonempty_str(tree) else None
157
+
158
+
159
+ def lock_hash(project_root: Path | None) -> str | None:
160
+ """Hash present dependency lockfiles, when the project exposes any."""
161
+ if project_root is None:
162
+ return None
163
+ names = (
164
+ "package-lock.json", "npm-shrinkwrap.json", "pnpm-lock.yaml",
165
+ "yarn.lock", "Cargo.lock", "go.sum", "poetry.lock",
166
+ )
167
+ files = [project_root / name for name in names if (project_root / name).is_file()]
168
+ if not files:
169
+ return None
170
+ digest = hashlib.sha256()
171
+ for path in files:
172
+ digest.update(path.name.encode("utf-8"))
173
+ digest.update(b"\0")
174
+ digest.update(hashlib.sha256(path.read_bytes()).digest())
175
+ return "sha256:" + digest.hexdigest()
176
+
177
+
178
+ def canonical_reuse_key(entry: dict[str, Any]) -> dict[str, str]:
179
+ """Commit-independent verification identity used to authorize reuse."""
180
+ fields = (
181
+ "verification", "productTreeHash", "commandSetHash", "environmentHash",
182
+ "toolchainHash", "lockHash", "dbSchemaHash", "targetIdentity",
183
+ )
184
+ return {
185
+ field: str(entry.get(field)).strip()
186
+ for field in fields
187
+ if _nonempty_str(entry.get(field))
188
+ }
189
+
190
+
142
191
  def now_iso() -> str:
143
192
  return dt.datetime.now().astimezone().isoformat(timespec="milliseconds")
144
193
 
@@ -1132,6 +1181,9 @@ def decide_can_reuse(
1132
1181
  requested_profile_hash: str | None = None,
1133
1182
  requested_environment_hash: str | None = None,
1134
1183
  requested_db_schema_hash: str | None = None,
1184
+ requested_product_tree_hash: str | None = None,
1185
+ requested_command_set_hash: str | None = None,
1186
+ requested_lock_hash: str | None = None,
1135
1187
  requested_target_identity: str | None = None,
1136
1188
  required_coverage: str | None = None,
1137
1189
  project_root: Path | str | None = None,
@@ -1162,7 +1214,40 @@ def decide_can_reuse(
1162
1214
  if isinstance(target, dict) and target.get("verification") == verification
1163
1215
  ]
1164
1216
  target_entries.sort(key=lambda item: str(item.get("finishedAt") or ""))
1165
- entry = target_entries[-1] if target_entries else None
1217
+ # A ledger can contain both a feature-tip and a later merge target. Select
1218
+ # the newest candidate matching the requested canonical key, rather than
1219
+ # letting an unrelated newest commit mask reusable evidence.
1220
+ requested_key_values = {
1221
+ "productTreeHash": requested_product_tree_hash,
1222
+ "commandSetHash": requested_command_set_hash,
1223
+ "toolchainHash": requested_toolchain_hash,
1224
+ "profileHash": requested_profile_hash,
1225
+ "environmentHash": requested_environment_hash,
1226
+ "lockHash": requested_lock_hash,
1227
+ "dbSchemaHash": requested_db_schema_hash,
1228
+ }
1229
+ matching_entries = [
1230
+ candidate
1231
+ for candidate in target_entries
1232
+ if all(
1233
+ not _nonempty_str(requested)
1234
+ or str(candidate.get(field) or "").strip().removeprefix("sha256:")
1235
+ == str(requested).strip().removeprefix("sha256:")
1236
+ for field, requested in requested_key_values.items()
1237
+ )
1238
+ and (
1239
+ not _nonempty_str(requested_command)
1240
+ or str(candidate.get("command") or "").strip()
1241
+ == str(requested_command).strip()
1242
+ )
1243
+ ]
1244
+ entry = (
1245
+ matching_entries[-1]
1246
+ if matching_entries
1247
+ else target_entries[-1]
1248
+ if target_entries
1249
+ else None
1250
+ )
1166
1251
  if not isinstance(validations, dict) and entry is None:
1167
1252
  return {
1168
1253
  "ok": True,
@@ -1314,12 +1399,17 @@ def decide_can_reuse(
1314
1399
  "requested_command": requested_command,
1315
1400
  }
1316
1401
 
1317
- # Toolchain / profile / environment hash drift -> structured rerun (UT-017).
1402
+ # The reusable identity is content and execution context, never commit SHA.
1403
+ # productCommit remains provenance only so a feature tip can serve its no-ff
1404
+ # merge when the merge has exactly the same product tree.
1318
1405
  # Only compared when both the stored entry and the request carry the field.
1319
1406
  for field, requested, code_name in (
1407
+ ("productTreeHash", requested_product_tree_hash, "PRODUCT_TREE_CHANGED"),
1408
+ ("commandSetHash", requested_command_set_hash, "COMMAND_SET_CHANGED"),
1320
1409
  ("toolchainHash", requested_toolchain_hash, "TOOLCHAIN_CHANGED"),
1321
1410
  ("profileHash", requested_profile_hash, "PROFILE_CHANGED"),
1322
1411
  ("environmentHash", requested_environment_hash, "ENVIRONMENT_CHANGED"),
1412
+ ("lockHash", requested_lock_hash, "LOCK_CHANGED"),
1323
1413
  ("dbSchemaHash", requested_db_schema_hash, "DB_SCHEMA_CHANGED"),
1324
1414
  ):
1325
1415
  stored = entry.get(field)
@@ -1400,6 +1490,13 @@ def decide_can_reuse(
1400
1490
  "inputsHash": stored_hash,
1401
1491
  "inputsFiles": stored_files,
1402
1492
  "evidence_summary": evidence_summary(entry),
1493
+ "reusedFrom": {
1494
+ "targetId": entry.get("id"),
1495
+ "sourceCommit": entry.get("productCommit") or entry.get("currentHead"),
1496
+ "sourceCandidate": entry.get("candidateId"),
1497
+ },
1498
+ "reuseReason": "canonical reuse key matched; commit SHA is provenance only",
1499
+ "reuseKey": canonical_reuse_key(entry),
1403
1500
  "marker": "REUSED",
1404
1501
  }
1405
1502
 
@@ -1568,6 +1665,20 @@ def cmd_can_reuse(args: argparse.Namespace) -> int:
1568
1665
  requested_profile_hash=getattr(args, "profile_hash", None),
1569
1666
  requested_environment_hash=getattr(args, "environment_hash", None),
1570
1667
  requested_db_schema_hash=getattr(args, "db_schema_hash", None),
1668
+ requested_product_tree_hash=(
1669
+ getattr(args, "product_tree_hash", None) or product_tree_hash(project_root)
1670
+ ),
1671
+ requested_command_set_hash=(
1672
+ getattr(args, "command_set_hash", None)
1673
+ or (
1674
+ command_set_hash(str(getattr(args, "command", "")))
1675
+ if _nonempty_str(getattr(args, "command", None))
1676
+ else None
1677
+ )
1678
+ ),
1679
+ requested_lock_hash=(
1680
+ getattr(args, "lock_hash", None) or lock_hash(project_root)
1681
+ ),
1571
1682
  requested_target_identity=verification_target_identity(
1572
1683
  verification, target
1573
1684
  ),
@@ -1727,6 +1838,13 @@ def cmd_record(args: argparse.Namespace) -> int:
1727
1838
  val = getattr(args, attr, None)
1728
1839
  if _nonempty_str(val):
1729
1840
  entry[field] = str(val).strip()
1841
+ entry["commandSetHash"] = command_set_hash(args.command)
1842
+ tree_hash = product_tree_hash(project_root)
1843
+ if tree_hash:
1844
+ entry["productTreeHash"] = tree_hash
1845
+ dependency_lock_hash = lock_hash(project_root)
1846
+ if dependency_lock_hash:
1847
+ entry["lockHash"] = dependency_lock_hash
1730
1848
  # package verification: record build artifact + test-reuse provenance.
1731
1849
  if verification == "package":
1732
1850
  if _nonempty_str(getattr(args, "deploy_artifact", None)):
@@ -1756,6 +1874,9 @@ def cmd_record(args: argparse.Namespace) -> int:
1756
1874
  "toolchainHash": entry.get("toolchainHash"),
1757
1875
  "profileHash": entry.get("profileHash"),
1758
1876
  "environmentHash": entry.get("environmentHash"),
1877
+ "productTreeHash": entry.get("productTreeHash"),
1878
+ "commandSetHash": entry.get("commandSetHash"),
1879
+ "lockHash": entry.get("lockHash"),
1759
1880
  "dbSchemaHash": entry.get("dbSchemaHash"),
1760
1881
  }
1761
1882
  target_id = (
@@ -1994,6 +2115,9 @@ def build_parser() -> argparse.ArgumentParser:
1994
2115
  default=None,
1995
2116
  help="optional DB schema hash to compare against ledger entry (H-9)",
1996
2117
  )
2118
+ p_reuse.add_argument("--product-tree-hash", default=None)
2119
+ p_reuse.add_argument("--command-set-hash", default=None)
2120
+ p_reuse.add_argument("--lock-hash", default=None)
1997
2121
  p_reuse.add_argument(
1998
2122
  "--verbose",
1999
2123
  action="store_true",
@@ -34,21 +34,30 @@ IDENTITY_FIELDS = (
34
34
  "repositoryId",
35
35
  "changeName",
36
36
  "baseCommit",
37
+ "checkpointCommit",
37
38
  "productCommit",
39
+ "featureTip",
40
+ "mergeCommit",
41
+ "releaseTip",
38
42
  "featureMergeHash",
39
43
  "releaseTipHash",
40
44
  "productTreeHash",
41
45
  "environmentHash",
42
46
  "candidateId",
43
47
  )
44
- CONTENT_HASH_FIELDS = frozenset({"productTreeHash", "environmentHash"})
48
+ CONTENT_HASH_FIELDS = frozenset(
49
+ {"productTreeHash", "commandSetHash", "environmentHash", "toolchainHash", "lockHash", "dbSchemaHash"}
50
+ )
45
51
  TARGET_IDENTITY_FIELDS = (
46
52
  "target",
47
53
  "repositoryId",
48
54
  "changeName",
49
- "productCommit",
50
55
  "productTreeHash",
56
+ "commandSetHash",
51
57
  "environmentHash",
58
+ "toolchainHash",
59
+ "lockHash",
60
+ "dbSchemaHash",
52
61
  "profile",
53
62
  "candidateId",
54
63
  )
@@ -92,18 +101,23 @@ def canonical_identity(summary: Mapping[str, Any]) -> dict[str, str]:
92
101
  "repositoryId": ("repositoryId",),
93
102
  "changeName": ("changeName",),
94
103
  "baseCommit": ("baseCommit",),
104
+ "checkpointCommit": ("checkpointCommit",),
95
105
  "productCommit": ("productCommit", "finalCommit"),
96
- "featureMergeHash": ("featureMergeHash", "productCommit", "finalCommit"),
97
- "releaseTipHash": ("releaseTipHash", "archiveCommit", "finalCommit"),
106
+ "featureTip": ("featureTip", "featureTipHash", "productCommit", "finalCommit"),
107
+ "mergeCommit": ("mergeCommit", "featureMergeHash"),
108
+ "releaseTip": ("releaseTip", "releaseTipHash", "archiveCommit", "finalCommit"),
109
+ "featureMergeHash": ("featureMergeHash", "mergeCommit", "featureTip", "productCommit", "finalCommit"),
110
+ "releaseTipHash": ("releaseTipHash", "releaseTip", "archiveCommit", "finalCommit"),
98
111
  "productTreeHash": ("productTreeHash",),
99
112
  "environmentHash": ("environmentHash",),
100
113
  "candidateId": ("candidateId",),
101
114
  }
102
115
  identity: dict[str, str] = {}
103
116
  for field in IDENTITY_FIELDS:
117
+ # Prefer changeIdentity, then nested aliases, then top-level mirrors.
104
118
  candidates = (nested.get(field),) + tuple(
105
- summary.get(key) for key in fallbacks[field]
106
- )
119
+ nested.get(key) for key in fallbacks[field]
120
+ ) + tuple(summary.get(key) for key in fallbacks[field])
107
121
  value = next(
108
122
  (
109
123
  str(candidate).strip()
@@ -126,7 +140,11 @@ def apply_identity_mirrors(
126
140
  canonical = dict(identity or canonical_identity(summary))
127
141
  result["changeIdentity"] = copy.deepcopy(canonical)
128
142
  for field in (
143
+ "checkpointCommit",
129
144
  "productCommit",
145
+ "featureTip",
146
+ "mergeCommit",
147
+ "releaseTip",
130
148
  "featureMergeHash",
131
149
  "releaseTipHash",
132
150
  "productTreeHash",
@@ -138,7 +156,11 @@ def apply_identity_mirrors(
138
156
  git_facts = copy.deepcopy(git_facts) if isinstance(git_facts, Mapping) else {}
139
157
  for field in (
140
158
  "baseCommit",
159
+ "checkpointCommit",
141
160
  "productCommit",
161
+ "featureTip",
162
+ "mergeCommit",
163
+ "releaseTip",
142
164
  "featureMergeHash",
143
165
  "releaseTipHash",
144
166
  "productTreeHash",