@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
@@ -1250,7 +1250,7 @@ def resolve_product_archive_identity(
1250
1250
  product_commit: str | None = None,
1251
1251
  archive_commit: str | None = None,
1252
1252
  ) -> dict[str, Any]:
1253
- """Resolve the three-way archive identity fields for summary/meta."""
1253
+ """Resolve checkpoint product/feature merge release identity facts."""
1254
1254
  change_dir = change_dir.resolve()
1255
1255
  project_root = (project or find_project_root(change_dir)).resolve()
1256
1256
  ledger = load_ledger(change_dir) or {}
@@ -1266,14 +1266,27 @@ def resolve_product_archive_identity(
1266
1266
  or ledger.get("finalCommit")
1267
1267
  or ""
1268
1268
  )
1269
- feature_merge = str(
1270
- ledger.get("featureMergeHash")
1271
- or ledger.get("mergeFinalHash")
1269
+ checkpoint = str(
1270
+ ledger.get("checkpointCommit")
1271
+ or (ledger.get("checkpoint") or {}).get("commit")
1272
+ if isinstance(ledger.get("checkpoint"), dict)
1273
+ else ledger.get("checkpointCommit") or ""
1274
+ ).strip()
1275
+ feature_tip = str(
1276
+ ledger.get("featureTip")
1277
+ or ledger.get("featureTipHash")
1272
1278
  or product
1273
1279
  ).strip()
1280
+ merge_commit = str(
1281
+ ledger.get("mergeCommit")
1282
+ or ledger.get("featureMergeHash")
1283
+ or ledger.get("mergeFinalHash")
1284
+ or ""
1285
+ ).strip()
1274
1286
  code, current_head, _ = git_run(project_root, "rev-parse", "HEAD")
1275
1287
  release_tip = str(
1276
- ledger.get("releaseTipHash")
1288
+ ledger.get("releaseTip")
1289
+ or ledger.get("releaseTipHash")
1277
1290
  or (current_head if code == 0 else "")
1278
1291
  or ledger.get("archiveCommit")
1279
1292
  or product
@@ -1318,8 +1331,13 @@ def resolve_product_archive_identity(
1318
1331
  # Bare temp fixtures are not real project roots — avoid scanning up-tree.
1319
1332
  tree = hashlib.sha256(b"fixture-no-product-tree").hexdigest()
1320
1333
  identity = {
1334
+ "checkpointCommit": checkpoint,
1321
1335
  "productCommit": str(product),
1322
- "featureMergeHash": feature_merge,
1336
+ "featureTip": feature_tip,
1337
+ "mergeCommit": merge_commit,
1338
+ "releaseTip": release_tip,
1339
+ # Legacy aliases retain their prior public contract.
1340
+ "featureMergeHash": merge_commit or feature_tip,
1323
1341
  "releaseTipHash": release_tip,
1324
1342
  "productTreeHash": str(tree),
1325
1343
  "archiveCommit": str(archive),
@@ -2312,6 +2330,90 @@ def check_status(
2312
2330
  }
2313
2331
 
2314
2332
 
2333
+ def archive_auto_gate(
2334
+ change_dir: Path,
2335
+ *,
2336
+ allow_missing_review: bool = False,
2337
+ archive_intent: str = "release-candidate",
2338
+ ) -> dict[str, Any]:
2339
+ """Read-only proof that a post-submit/merge archive may run unattended."""
2340
+ status = check_status(
2341
+ change_dir,
2342
+ allow_missing_review=allow_missing_review,
2343
+ archive_intent=archive_intent,
2344
+ )
2345
+ if not status.get("archivable"):
2346
+ return {
2347
+ "ok": False,
2348
+ "action": "auto-gate",
2349
+ "autoArchiveAllowed": False,
2350
+ "reasonCode": "ARCHIVE_PRECONDITIONS_UNSATISFIED",
2351
+ "status": status,
2352
+ "nextAction": "Resolve archive status blockers, then run `harness_archive.py auto-gate` again.",
2353
+ }
2354
+
2355
+ snapshot_path = change_dir / "meta" / "state-snapshot.json"
2356
+ try:
2357
+ snapshot = read_json(snapshot_path)
2358
+ except (OSError, json.JSONDecodeError, TypeError):
2359
+ snapshot = None
2360
+ if not isinstance(snapshot, dict):
2361
+ return {
2362
+ "ok": False,
2363
+ "action": "auto-gate",
2364
+ "autoArchiveAllowed": False,
2365
+ "reasonCode": "ARCHIVE_BOUNDARY_SNAPSHOT_MISSING",
2366
+ "status": status,
2367
+ "nextAction": "Capture the post-submit/merge archive-boundary state snapshot before auto-archiving.",
2368
+ }
2369
+
2370
+ git_state = snapshot.get("git")
2371
+ snapshot_base = (
2372
+ (git_state.get("base") or git_state.get("baseCommit"))
2373
+ if isinstance(git_state, dict)
2374
+ else snapshot.get("base") or snapshot.get("baseCommit")
2375
+ )
2376
+ if not isinstance(snapshot_base, str) or not snapshot_base.strip():
2377
+ return {
2378
+ "ok": False,
2379
+ "action": "auto-gate",
2380
+ "autoArchiveAllowed": False,
2381
+ "reasonCode": "ARCHIVE_BOUNDARY_SNAPSHOT_INVALID",
2382
+ "status": status,
2383
+ "nextAction": "Capture a state snapshot containing the archive-boundary base commit after Submit or Merge.",
2384
+ }
2385
+
2386
+ events_path = change_dir / "events.ndjson"
2387
+ events = he.load_events(events_path) if events_path.is_file() else []
2388
+ completed = any(
2389
+ event.get("phase") in {"submit", "merge"}
2390
+ and event.get("type") == "phase.end"
2391
+ and str(event.get("status") or "").upper() in {"OK", "WARN"}
2392
+ for event in events
2393
+ if isinstance(event, dict)
2394
+ )
2395
+ if not completed:
2396
+ return {
2397
+ "ok": False,
2398
+ "action": "auto-gate",
2399
+ "autoArchiveAllowed": False,
2400
+ "reasonCode": "SUBMIT_OR_MERGE_NOT_COMPLETED",
2401
+ "status": status,
2402
+ "nextAction": "Complete Submit or Merge and record its terminal phase event before auto-archiving.",
2403
+ }
2404
+
2405
+ return {
2406
+ "ok": True,
2407
+ "action": "auto-gate",
2408
+ "autoArchiveAllowed": True,
2409
+ "reasonCode": "ARCHIVE_AUTO_GATE_SATISFIED",
2410
+ "status": status,
2411
+ "snapshotPath": str(snapshot_path),
2412
+ "snapshotBase": snapshot_base,
2413
+ "nextAction": "Run `harness_archive.py finalize` with the same change directory and intent; no AskQuestion confirmation is required.",
2414
+ }
2415
+
2416
+
2315
2417
  # ---------------------------------------------------------------------------
2316
2418
  # collect
2317
2419
  # ---------------------------------------------------------------------------
@@ -2337,6 +2439,11 @@ def _ledger_unit_tests(ledger: dict[str, Any] | None) -> dict[str, Any]:
2337
2439
  "skipped": 0,
2338
2440
  "passRate": NOT_AVAILABLE,
2339
2441
  "source": "not-run",
2442
+ "latestAuthoritativeAttempt": None,
2443
+ "perAttemptCounts": [],
2444
+ "uniqueTestIdentities": [],
2445
+ "uniqueTestCount": 0,
2446
+ "rerunCount": 0,
2340
2447
  }
2341
2448
  if not ledger:
2342
2449
  return empty
@@ -2460,6 +2567,87 @@ def _ledger_unit_tests(ledger: dict[str, Any] | None) -> dict[str, Any]:
2460
2567
  }
2461
2568
  if status in {"NOT_RUN", "USER_SKIPPED", "SKIPPED"}:
2462
2569
  result["status"] = status if status != "SKIPPED" else "USER_SKIPPED"
2570
+ attempt_records: list[dict[str, Any]] = []
2571
+ for key in ("history", "attempts"):
2572
+ records = unit.get(key)
2573
+ if isinstance(records, list):
2574
+ attempt_records.extend(item for item in records if isinstance(item, dict))
2575
+ if not attempt_records:
2576
+ attempt_records = [unit]
2577
+ seen_attempts: set[str] = set()
2578
+ per_attempt: list[dict[str, Any]] = []
2579
+ unique_identities: set[str] = set()
2580
+ for index, attempt in enumerate(attempt_records, start=1):
2581
+ attempt_key = json.dumps(
2582
+ {
2583
+ "attempt": attempt.get("attempt"),
2584
+ "runId": attempt.get("runId") or attempt.get("run_id"),
2585
+ "finishedAt": attempt.get("finishedAt") or attempt.get("completedAt"),
2586
+ "metrics": attempt.get("metrics"),
2587
+ },
2588
+ ensure_ascii=False,
2589
+ sort_keys=True,
2590
+ default=str,
2591
+ )
2592
+ if attempt_key in seen_attempts:
2593
+ continue
2594
+ seen_attempts.add(attempt_key)
2595
+ attempt_metrics = (
2596
+ attempt.get("metrics") if isinstance(attempt.get("metrics"), dict) else attempt
2597
+ )
2598
+ attempt_total = int(
2599
+ attempt_metrics.get(
2600
+ "total",
2601
+ attempt_metrics.get("run", attempt_metrics.get("testsRun", 0)),
2602
+ )
2603
+ or 0
2604
+ )
2605
+ attempt_failed = int(
2606
+ attempt_metrics.get("failed", attempt_metrics.get("failures", 0)) or 0
2607
+ )
2608
+ attempt_errors = int(attempt_metrics.get("errors", 0) or 0)
2609
+ attempt_skipped = int(attempt_metrics.get("skipped", 0) or 0)
2610
+ attempt_passed = int(
2611
+ attempt_metrics.get(
2612
+ "passed",
2613
+ max(
2614
+ attempt_total - attempt_failed - attempt_errors - attempt_skipped,
2615
+ 0,
2616
+ ),
2617
+ )
2618
+ or 0
2619
+ )
2620
+ raw_identities = (
2621
+ attempt.get("testIdentities")
2622
+ or attempt_metrics.get("testIdentities")
2623
+ or attempt.get("testIds")
2624
+ or attempt_metrics.get("testIds")
2625
+ or []
2626
+ )
2627
+ identities = (
2628
+ sorted({str(item) for item in raw_identities if str(item)})
2629
+ if isinstance(raw_identities, list)
2630
+ else []
2631
+ )
2632
+ unique_identities.update(identities)
2633
+ per_attempt.append(
2634
+ {
2635
+ "attempt": attempt.get("attempt", index),
2636
+ "status": str(attempt.get("status") or status or "UNKNOWN").upper(),
2637
+ "run": attempt_total,
2638
+ "passed": attempt_passed,
2639
+ "failures": attempt_failed,
2640
+ "errors": attempt_errors,
2641
+ "skipped": attempt_skipped,
2642
+ "deselected": int(attempt_metrics.get("deselected", 0) or 0),
2643
+ "testIdentities": identities,
2644
+ }
2645
+ )
2646
+ result["perAttemptCounts"] = per_attempt
2647
+ result["latestAuthoritativeAttempt"] = per_attempt[-1] if per_attempt else None
2648
+ result["uniqueTestIdentities"] = sorted(unique_identities)
2649
+ result["uniqueTestCount"] = len(unique_identities)
2650
+ result["rerunCount"] = max(len(per_attempt) - 1, 0)
2463
2651
  return result
2464
2652
 
2465
2653
 
@@ -2631,19 +2819,61 @@ def _risks_from_test_results(change_dir: Path) -> tuple[list[dict[str, Any]], li
2631
2819
  return risks, actions
2632
2820
 
2633
2821
 
2634
- def _ledger_db_compat(ledger: dict[str, Any] | None) -> str:
2822
+ def _ledger_db_compat(ledger: dict[str, Any] | None) -> dict[str, Any]:
2823
+ """Project only typed DB compatibility evidence; command text is not proof."""
2824
+ empty = {"status": "NOT_RUN", "source": "not-run"}
2635
2825
  if not ledger:
2636
- return "NOT_RUN"
2826
+ return empty
2637
2827
  validations = ledger.get("validations") or {}
2638
2828
  db = validations.get("dbCompatibility") or validations.get("db") or {}
2639
- if isinstance(db, dict):
2640
- return str(db.get("status") or "NOT_RUN").upper()
2641
- if isinstance(db, str) and db.strip():
2642
- return db.strip().upper()
2643
- top = ledger.get("dbCompatibility")
2644
- if isinstance(top, str) and top.strip():
2645
- return top.strip().upper()
2646
- return "NOT_RUN"
2829
+ if not isinstance(db, dict):
2830
+ return empty
2831
+ raw_status = str(db.get("status") or "").upper()
2832
+ if raw_status in {"NOT_RUN", "USER_SKIPPED", "SKIPPED"}:
2833
+ return {"status": "NOT_RUN", "source": "typed-ledger"}
2834
+ if raw_status == "UNKNOWN":
2835
+ return {"status": "UNKNOWN", "source": "typed-ledger"}
2836
+ metrics = db.get("metrics")
2837
+ receipt = db.get("receipt")
2838
+ if not isinstance(metrics, dict) and isinstance(receipt, dict):
2839
+ candidate = receipt.get("metrics", receipt)
2840
+ metrics = candidate if isinstance(candidate, dict) else None
2841
+ if not isinstance(metrics, dict):
2842
+ return {"status": "EVIDENCE_MISSING", "source": "typed-ledger"}
2843
+ applicability = str(metrics.get("applicability") or "").upper()
2844
+ if applicability == "NOT_APPLICABLE":
2845
+ reason = str(metrics.get("reason") or "").strip()
2846
+ return {
2847
+ "status": "NOT_APPLICABLE" if reason else "EVIDENCE_MISSING",
2848
+ "reason": reason,
2849
+ "source": "typed-receipt" if isinstance(receipt, dict) else "typed-ledger",
2850
+ }
2851
+ if applicability != "APPLICABLE":
2852
+ return {"status": "UNKNOWN", "source": "typed-ledger"}
2853
+ typed_status = str(metrics.get("status") or "").upper()
2854
+ total = metrics.get("total")
2855
+ passed = metrics.get("passed")
2856
+ failed = metrics.get("failed")
2857
+ evidence_hash = metrics.get("evidenceHash")
2858
+ valid_counts = (
2859
+ all(isinstance(value, int) and not isinstance(value, bool) and value >= 0
2860
+ for value in (total, passed, failed))
2861
+ and passed + failed == total
2862
+ )
2863
+ valid_hash = isinstance(evidence_hash, str) and bool(
2864
+ re.fullmatch(r"sha256:[0-9a-f]{64}", evidence_hash)
2865
+ )
2866
+ if typed_status not in {"OK", "FAIL"} or not valid_counts or not valid_hash:
2867
+ return {"status": "EVIDENCE_MISSING", "source": "typed-ledger"}
2868
+ return {
2869
+ "status": typed_status,
2870
+ "applicability": applicability,
2871
+ "total": total,
2872
+ "passed": passed,
2873
+ "failed": failed,
2874
+ "evidenceHash": evidence_hash,
2875
+ "source": "typed-receipt" if isinstance(receipt, dict) else "typed-ledger",
2876
+ }
2647
2877
 
2648
2878
 
2649
2879
  def _typed_test_metrics(entry: dict[str, Any], *, total_key: str) -> dict[str, Any]:
@@ -2691,8 +2921,10 @@ def build_verification_projection(
2691
2921
  projection: dict[str, Any] = {
2692
2922
  "unitTests": _ledger_unit_tests(effective_ledger),
2693
2923
  "apiTests": _ledger_api_tests(effective_ledger, change_dir=change_dir),
2694
- "dbCompatibility": _ledger_db_compat(effective_ledger),
2695
2924
  }
2925
+ db_compatibility = _ledger_db_compat(effective_ledger)
2926
+ projection["dbCompatibility"] = db_compatibility["status"]
2927
+ projection["dbCompatibilityEvidence"] = db_compatibility
2696
2928
  api_contract = validations.get("apiContract")
2697
2929
  if isinstance(api_contract, dict):
2698
2930
  projection["apiContract"] = _typed_test_metrics(
@@ -2946,6 +3178,9 @@ def _durations_from_event_phases(
2946
3178
  timing = canonical.get(name)
2947
3179
  if timing:
2948
3180
  stage["activeExecutionMs"] = timing.get("activeExecutionMs")
3181
+ # HH-WF-20260730-001: recovered/superseded time (write-path
3182
+ # auto-seal) stays disjoint from activeExecutionMs.
3183
+ stage["recoveredMs"] = timing.get("recoveredMs")
2949
3184
  stage["wallClockSpanMs"] = timing.get("wallClockSpanMs")
2950
3185
  stage["lateEventCount"] = timing.get("lateEventCount")
2951
3186
  stage["unclosedAttemptCount"] = timing.get("unclosedAttemptCount")
@@ -3364,18 +3599,58 @@ def _review_summary(
3364
3599
  return base
3365
3600
 
3366
3601
 
3602
+ def _base_from_state_snapshot(change_dir: Path) -> str:
3603
+ """Read archive-boundary base from meta/state-snapshot.json when present."""
3604
+ for relative in (
3605
+ Path("meta") / "state-snapshot.json",
3606
+ Path("state-snapshot.json"),
3607
+ ):
3608
+ path = change_dir / relative
3609
+ if not path.is_file():
3610
+ continue
3611
+ try:
3612
+ payload = read_json(path)
3613
+ except (OSError, json.JSONDecodeError, TypeError):
3614
+ continue
3615
+ if not isinstance(payload, dict):
3616
+ continue
3617
+ git_info = payload.get("git")
3618
+ if isinstance(git_info, dict):
3619
+ base = str(git_info.get("base") or git_info.get("baseCommit") or "").strip()
3620
+ if base and base != NOT_AVAILABLE:
3621
+ return base
3622
+ base = str(payload.get("baseCommit") or payload.get("base") or "").strip()
3623
+ if base and base != NOT_AVAILABLE:
3624
+ return base
3625
+ return ""
3626
+
3627
+
3367
3628
  def _resolve_base_commit(
3368
3629
  ledger: dict[str, Any] | None,
3369
3630
  change_dir: Path,
3370
3631
  project: Path,
3371
3632
  final: str | None,
3372
3633
  ) -> str:
3373
- """Resolve baseCommit: ledger → latest phase-context → merge first parent → merge-base."""
3634
+ """Resolve baseCommit in archive-boundary order.
3635
+
3636
+ Priority (HH-ARCHIVE-20260730-001):
3637
+ ledger base
3638
+ → archive-boundary state snapshot base
3639
+ → phase context
3640
+ → merge first parent
3641
+ → merge-base
3642
+
3643
+ Latest merge phase context must not override the full change boundary.
3644
+ """
3374
3645
  if ledger:
3375
3646
  base = str(ledger.get("baseCommit") or "").strip()
3376
3647
  if base and base != NOT_AVAILABLE:
3377
3648
  return base
3378
3649
 
3650
+ snapshot_base = _base_from_state_snapshot(change_dir)
3651
+ if snapshot_base:
3652
+ return snapshot_base
3653
+
3379
3654
  ctx_dir = change_dir / "runtime" / "phase-context"
3380
3655
  if ctx_dir.is_dir():
3381
3656
  candidates = sorted(
@@ -3685,6 +3960,7 @@ def collect_summary_data(
3685
3960
  "unitTests": unit,
3686
3961
  "apiTests": api,
3687
3962
  "dbCompatibility": db,
3963
+ "dbCompatibilityEvidence": projection["dbCompatibilityEvidence"],
3688
3964
  "coverageDisplay": coverage,
3689
3965
  }
3690
3966
  for typed_key in ("apiContract", "browserE2E", "performance"):
@@ -3698,6 +3974,7 @@ def collect_summary_data(
3698
3974
  ver.setdefault("unitTests", {})
3699
3975
  ver.setdefault("apiTests", {})
3700
3976
  ver.setdefault("dbCompatibility", ver.get("dbCompatibility", NOT_AVAILABLE))
3977
+ ver.setdefault("dbCompatibilityEvidence", {})
3701
3978
  ver.setdefault("coverageDisplay", ver.get("coverageDisplay", NOT_AVAILABLE))
3702
3979
 
3703
3980
  # stageStatus / finalStatus
@@ -3785,9 +4062,13 @@ def collect_summary_data(
3785
4062
  # IA-1/4 identity (product vs archive)
3786
4063
  if not for_replay:
3787
4064
  identity = resolve_product_archive_identity(change_dir, project=project)
4065
+ data["checkpointCommit"] = identity.get("checkpointCommit") or ""
3788
4066
  data["productCommit"] = identity.get("productCommit") or data.get("finalCommit")
3789
- data["featureMergeHash"] = identity.get("featureMergeHash") or data.get("finalCommit")
3790
- data["releaseTipHash"] = identity.get("releaseTipHash") or data.get("archiveCommit")
4067
+ data["featureTip"] = identity.get("featureTip") or data["productCommit"]
4068
+ data["mergeCommit"] = identity.get("mergeCommit") or ""
4069
+ data["releaseTip"] = identity.get("releaseTip") or data.get("archiveCommit")
4070
+ data["featureMergeHash"] = data["mergeCommit"] or data.get("finalCommit")
4071
+ data["releaseTipHash"] = data["releaseTip"] or data.get("archiveCommit")
3791
4072
  data["productTreeHash"] = identity.get("productTreeHash")
3792
4073
  data["archiveCommit"] = identity.get("archiveCommit") or data.get("finalCommit")
3793
4074
  data["environmentHash"] = identity.get("environmentHash") or NOT_AVAILABLE
@@ -3856,8 +4137,12 @@ def collect_summary_data(
3856
4137
  diff_for_facts = data.get("diffStat") if isinstance(data.get("diffStat"), dict) else {}
3857
4138
  data["gitFacts"] = {
3858
4139
  "baseCommit": data.get("baseCommit") or "",
4140
+ "checkpointCommit": data.get("checkpointCommit") or "",
3859
4141
  "finalCommit": data.get("finalCommit") or "",
3860
4142
  "productCommit": data.get("productCommit") or "",
4143
+ "featureTip": data.get("featureTip") or "",
4144
+ "mergeCommit": data.get("mergeCommit") or "",
4145
+ "releaseTip": data.get("releaseTip") or "",
3861
4146
  "featureMergeHash": data.get("featureMergeHash") or "",
3862
4147
  "releaseTipHash": data.get("releaseTipHash") or "",
3863
4148
  "productTreeHash": data.get("productTreeHash") or "",
@@ -5240,6 +5525,92 @@ def validate_report_adequacy(summary: dict[str, Any]) -> dict[str, Any]:
5240
5525
  "message": f"final commit {final} differs from base {base} but filesChanged=0",
5241
5526
  })
5242
5527
 
5528
+ # HH-ARCHIVE-20260730-001: refuse internally-consistent but truncated boundaries.
5529
+ identity_doc_early = (
5530
+ summary.get("changeIdentity")
5531
+ if isinstance(summary.get("changeIdentity"), dict)
5532
+ else {}
5533
+ )
5534
+ product_commit = str(
5535
+ summary.get("productCommit")
5536
+ or identity_doc_early.get("productCommit")
5537
+ or ""
5538
+ ).strip()
5539
+ feature_tip = str(
5540
+ summary.get("featureTip")
5541
+ or summary.get("featureTipHash")
5542
+ or product_commit
5543
+ or ""
5544
+ ).strip()
5545
+ if not feature_tip:
5546
+ feature_tip = str(identity_doc_early.get("productCommit") or "").strip()
5547
+
5548
+ if (
5549
+ base
5550
+ and base != NOT_AVAILABLE
5551
+ and feature_tip
5552
+ and feature_tip != NOT_AVAILABLE
5553
+ and base == feature_tip
5554
+ ):
5555
+ issues.append({
5556
+ "code": "ARCHIVE_BASE_EQUALS_FEATURE_TIP",
5557
+ "severity": "error",
5558
+ "message": (
5559
+ f"baseCommit equals feature tip ({base}); "
5560
+ "archive boundary collapsed to an empty/merge-only delta"
5561
+ ),
5562
+ })
5563
+
5564
+ ownership = summary.get("ownershipDiff") or summary.get("ownership")
5565
+ ownership = ownership if isinstance(ownership, dict) else {}
5566
+ ownership_files = ownership.get("files") or ownership.get("changedFiles") or []
5567
+ ownership_count = 0
5568
+ if isinstance(ownership_files, list):
5569
+ ownership_count = len(ownership_files)
5570
+ elif isinstance(ownership.get("fileCount"), int):
5571
+ ownership_count = int(ownership["fileCount"])
5572
+ changed_files = summary.get("changedFiles")
5573
+ changed_count = len(changed_files) if isinstance(changed_files, list) else files_changed
5574
+
5575
+ if ownership_count > 0 and changed_count == 0:
5576
+ issues.append({
5577
+ "code": "ARCHIVE_DIFF_SHRUNK_VS_OWNERSHIP",
5578
+ "severity": "error",
5579
+ "message": (
5580
+ f"ownership diff has {ownership_count} file(s) but report "
5581
+ "diff is empty; base/diff pairing is internally consistent "
5582
+ "but does not represent the full change"
5583
+ ),
5584
+ })
5585
+ elif ownership_count > 0 and changed_count > 0 and changed_count * 3 < ownership_count:
5586
+ issues.append({
5587
+ "code": "ARCHIVE_DIFF_SHRUNK_VS_OWNERSHIP",
5588
+ "severity": "error",
5589
+ "message": (
5590
+ f"report diff covers {changed_count} file(s) but ownership "
5591
+ f"lists {ownership_count}; suspected merge-delta truncation"
5592
+ ),
5593
+ })
5594
+
5595
+ merge_parents = summary.get("mergeParents") or summary.get("mergeParentHashes")
5596
+ if isinstance(merge_parents, list) and len(merge_parents) >= 2:
5597
+ first_parent = str(merge_parents[0] or "").strip()
5598
+ if (
5599
+ base
5600
+ and first_parent
5601
+ and base == first_parent
5602
+ and ownership_count > max(changed_count, files_changed)
5603
+ and max(changed_count, files_changed) <= 1
5604
+ ):
5605
+ issues.append({
5606
+ "code": "ARCHIVE_NOFF_MERGE_DELTA_ONLY",
5607
+ "severity": "error",
5608
+ "message": (
5609
+ "no-ff merge archive appears to cover only the merge "
5610
+ "commit delta rather than archive-base..release-tip"
5611
+ ),
5612
+ })
5613
+
5243
5614
  # Typed metrics missing despite test report artifacts present.
5244
5615
  verification = summary.get("verification") or {}
5245
5616
  if isinstance(verification, dict):
@@ -5258,6 +5629,33 @@ def validate_report_adequacy(summary: dict[str, Any]) -> dict[str, Any]:
5258
5629
  "severity": "error",
5259
5630
  "message": "test report artifacts present but unitTests/apiTests typed metrics are empty",
5260
5631
  })
5632
+ db_status = str(verification.get("dbCompatibility") or "NOT_RUN").upper()
5633
+ db_evidence = verification.get("dbCompatibilityEvidence")
5634
+ if "dbCompatibility" in verification and db_status == "EVIDENCE_MISSING":
5635
+ issues.append({
5636
+ "code": "DB_COMPATIBILITY_EVIDENCE_MISSING",
5637
+ "severity": "error",
5638
+ "message": (
5639
+ "DB compatibility was recorded but lacks a valid typed "
5640
+ "ledger/receipt evidence payload"
5641
+ ),
5642
+ })
5643
+ elif "dbCompatibility" in verification and db_status == "NOT_RUN":
5644
+ issues.append({
5645
+ "code": "DB_COMPATIBILITY_NOT_RUN",
5646
+ "severity": "warning",
5647
+ "message": "DB compatibility validation was not run",
5648
+ })
5649
+ elif (
5650
+ "dbCompatibility" in verification
5651
+ and db_status in {"OK", "FAIL", "NOT_APPLICABLE"}
5652
+ and not isinstance(db_evidence, dict)
5653
+ ):
5654
+ issues.append({
5655
+ "code": "DB_COMPATIBILITY_EVIDENCE_MISSING",
5656
+ "severity": "error",
5657
+ "message": "DB compatibility status has no typed evidence projection",
5658
+ })
5261
5659
 
5262
5660
  # stageStatus contradicts event reducer.
5263
5661
  stage = summary.get("stageStatus") or {}
@@ -7410,6 +7808,16 @@ def cmd_status_cli(args: argparse.Namespace) -> int:
7410
7808
  return 0 if result.get("ok") else 1
7411
7809
 
7412
7810
 
7811
+ def cmd_auto_gate_cli(args: argparse.Namespace) -> int:
7812
+ result = archive_auto_gate(
7813
+ resolve_path(args.change_dir),
7814
+ allow_missing_review=bool(getattr(args, "allow_missing_review", False)),
7815
+ archive_intent=str(getattr(args, "intent", "release-candidate")),
7816
+ )
7817
+ emit_json(result)
7818
+ return 0 if result.get("ok") else 1
7819
+
7820
+
7413
7821
  def cmd_certify_local_cli(args: argparse.Namespace) -> int:
7414
7822
  change_dir = resolve_path(args.change_dir)
7415
7823
  project = (
@@ -7649,6 +8057,24 @@ def build_parser() -> argparse.ArgumentParser:
7649
8057
  )
7650
8058
  p_status.set_defaults(func=cmd_status_cli)
7651
8059
 
8060
+ p_auto_gate = sub.add_parser(
8061
+ "auto-gate",
8062
+ help="read-only proof that a post-submit/merge archive may run without confirmation",
8063
+ )
8064
+ p_auto_gate.add_argument("--change-dir", required=True)
8065
+ p_auto_gate.add_argument("--json", action="store_true", default=True)
8066
+ p_auto_gate.add_argument(
8067
+ "--allow-missing-review",
8068
+ action="store_true",
8069
+ help="downgrade full-tier missing review from blocker to warning",
8070
+ )
8071
+ p_auto_gate.add_argument(
8072
+ "--intent",
8073
+ choices=("release-candidate", "record-only"),
8074
+ default="release-candidate",
8075
+ )
8076
+ p_auto_gate.set_defaults(func=cmd_auto_gate_cli)
8077
+
7652
8078
  p_cert = sub.add_parser(
7653
8079
  "certify-local",
7654
8080
  help="create candidate receipt from existing full ledger evidence without rerunning tests",