@hunter-harness/workflow-harness 0.2.18 → 0.2.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. package/harness/bundles/general/claude-code/.harness-build.json +1 -1
  2. package/harness/bundles/general/claude-code/harness-archive/SKILL.md +7 -1
  3. package/harness/bundles/general/claude-code/harness-archive/checklist.md +6 -0
  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-query/SKILL.md +1 -1
  7. package/harness/bundles/general/claude-code/harness-plan/SKILL.md +1 -1
  8. package/harness/bundles/general/claude-code/harness-review/SKILL.md +1 -1
  9. package/harness/bundles/general/claude-code/harness-run/SKILL.md +1 -1
  10. package/harness/bundles/general/claude-code/harness-submit/SKILL.md +11 -2
  11. package/harness/bundles/general/claude-code/harness-submit/checklist.md +20 -2
  12. package/harness/bundles/general/claude-code/harness-sync/SKILL.md +1 -1
  13. package/harness/bundles/general/claude-code/harness-test/SKILL.md +1 -1
  14. package/harness/bundles/general/claude-code/protocols/report-pipeline-protocol.md +2 -0
  15. package/harness/bundles/general/claude-code/scripts/harness_archive.py +199 -30
  16. package/harness/bundles/general/claude-code/scripts/harness_events.py +5 -18
  17. package/harness/bundles/general/claude-code/scripts/harness_integration.py +153 -0
  18. package/harness/bundles/general/claude-code/scripts/harness_ledger.py +37 -11
  19. package/harness/bundles/general/claude-code/scripts/harness_paths.py +170 -1
  20. package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
  21. package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +7 -1
  22. package/harness/bundles/general/codebuddy/harness-archive/checklist.md +6 -0
  23. package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
  24. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  25. package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  26. package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +1 -1
  27. package/harness/bundles/general/codebuddy/harness-review/SKILL.md +1 -1
  28. package/harness/bundles/general/codebuddy/harness-run/SKILL.md +1 -1
  29. package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +11 -2
  30. package/harness/bundles/general/codebuddy/harness-submit/checklist.md +20 -2
  31. package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +1 -1
  32. package/harness/bundles/general/codebuddy/harness-test/SKILL.md +1 -1
  33. package/harness/bundles/general/codebuddy/protocols/report-pipeline-protocol.md +2 -0
  34. package/harness/bundles/general/codebuddy/scripts/harness_archive.py +199 -30
  35. package/harness/bundles/general/codebuddy/scripts/harness_events.py +5 -18
  36. package/harness/bundles/general/codebuddy/scripts/harness_integration.py +153 -0
  37. package/harness/bundles/general/codebuddy/scripts/harness_ledger.py +37 -11
  38. package/harness/bundles/general/codebuddy/scripts/harness_paths.py +170 -1
  39. package/harness/bundles/general/codex/.harness-build.json +1 -1
  40. package/harness/bundles/general/codex/harness-archive/SKILL.md +7 -1
  41. package/harness/bundles/general/codex/harness-archive/checklist.md +6 -0
  42. package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
  43. package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +1 -1
  44. package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
  45. package/harness/bundles/general/codex/harness-plan/SKILL.md +1 -1
  46. package/harness/bundles/general/codex/harness-review/SKILL.md +1 -1
  47. package/harness/bundles/general/codex/harness-run/SKILL.md +1 -1
  48. package/harness/bundles/general/codex/harness-submit/SKILL.md +11 -2
  49. package/harness/bundles/general/codex/harness-submit/checklist.md +20 -2
  50. package/harness/bundles/general/codex/harness-sync/SKILL.md +1 -1
  51. package/harness/bundles/general/codex/harness-test/SKILL.md +1 -1
  52. package/harness/bundles/general/codex/protocols/report-pipeline-protocol.md +2 -0
  53. package/harness/bundles/general/codex/scripts/harness_archive.py +199 -30
  54. package/harness/bundles/general/codex/scripts/harness_events.py +5 -18
  55. package/harness/bundles/general/codex/scripts/harness_integration.py +153 -0
  56. package/harness/bundles/general/codex/scripts/harness_ledger.py +37 -11
  57. package/harness/bundles/general/codex/scripts/harness_paths.py +170 -1
  58. package/harness/bundles/general/cursor/.harness-build.json +1 -1
  59. package/harness/bundles/general/cursor/harness-archive/SKILL.md +7 -1
  60. package/harness/bundles/general/cursor/harness-archive/checklist.md +6 -0
  61. package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
  62. package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  63. package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
  64. package/harness/bundles/general/cursor/harness-plan/SKILL.md +1 -1
  65. package/harness/bundles/general/cursor/harness-review/SKILL.md +1 -1
  66. package/harness/bundles/general/cursor/harness-run/SKILL.md +1 -1
  67. package/harness/bundles/general/cursor/harness-submit/SKILL.md +11 -2
  68. package/harness/bundles/general/cursor/harness-submit/checklist.md +20 -2
  69. package/harness/bundles/general/cursor/harness-sync/SKILL.md +1 -1
  70. package/harness/bundles/general/cursor/harness-test/SKILL.md +1 -1
  71. package/harness/bundles/general/cursor/protocols/report-pipeline-protocol.md +2 -0
  72. package/harness/bundles/general/cursor/scripts/harness_archive.py +199 -30
  73. package/harness/bundles/general/cursor/scripts/harness_events.py +5 -18
  74. package/harness/bundles/general/cursor/scripts/harness_integration.py +153 -0
  75. package/harness/bundles/general/cursor/scripts/harness_ledger.py +37 -11
  76. package/harness/bundles/general/cursor/scripts/harness_paths.py +170 -1
  77. package/harness/bundles/java/claude-code/.harness-build.json +1 -1
  78. package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +1 -1
  79. package/harness/bundles/java/claude-code/harness-archive/SKILL.md +7 -1
  80. package/harness/bundles/java/claude-code/harness-archive/checklist.md +6 -0
  81. package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
  82. package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  83. package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
  84. package/harness/bundles/java/claude-code/harness-package/SKILL.md +1 -1
  85. package/harness/bundles/java/claude-code/harness-plan/SKILL.md +1 -1
  86. package/harness/bundles/java/claude-code/harness-review/SKILL.md +1 -1
  87. package/harness/bundles/java/claude-code/harness-run/SKILL.md +1 -1
  88. package/harness/bundles/java/claude-code/harness-submit/SKILL.md +11 -2
  89. package/harness/bundles/java/claude-code/harness-submit/checklist.md +20 -2
  90. package/harness/bundles/java/claude-code/harness-sync/SKILL.md +1 -1
  91. package/harness/bundles/java/claude-code/harness-test/SKILL.md +1 -1
  92. package/harness/bundles/java/claude-code/protocols/report-pipeline-protocol.md +2 -0
  93. package/harness/bundles/java/claude-code/scripts/harness_archive.py +199 -30
  94. package/harness/bundles/java/claude-code/scripts/harness_events.py +5 -18
  95. package/harness/bundles/java/claude-code/scripts/harness_integration.py +153 -0
  96. package/harness/bundles/java/claude-code/scripts/harness_ledger.py +37 -11
  97. package/harness/bundles/java/claude-code/scripts/harness_paths.py +170 -1
  98. package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
  99. package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +1 -1
  100. package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +7 -1
  101. package/harness/bundles/java/codebuddy/harness-archive/checklist.md +6 -0
  102. package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
  103. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  104. package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  105. package/harness/bundles/java/codebuddy/harness-package/SKILL.md +1 -1
  106. package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +1 -1
  107. package/harness/bundles/java/codebuddy/harness-review/SKILL.md +1 -1
  108. package/harness/bundles/java/codebuddy/harness-run/SKILL.md +1 -1
  109. package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +11 -2
  110. package/harness/bundles/java/codebuddy/harness-submit/checklist.md +20 -2
  111. package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +1 -1
  112. package/harness/bundles/java/codebuddy/harness-test/SKILL.md +1 -1
  113. package/harness/bundles/java/codebuddy/protocols/report-pipeline-protocol.md +2 -0
  114. package/harness/bundles/java/codebuddy/scripts/harness_archive.py +199 -30
  115. package/harness/bundles/java/codebuddy/scripts/harness_events.py +5 -18
  116. package/harness/bundles/java/codebuddy/scripts/harness_integration.py +153 -0
  117. package/harness/bundles/java/codebuddy/scripts/harness_ledger.py +37 -11
  118. package/harness/bundles/java/codebuddy/scripts/harness_paths.py +170 -1
  119. package/harness/bundles/java/codex/.harness-build.json +1 -1
  120. package/harness/bundles/java/codex/harness-apidoc/SKILL.md +1 -1
  121. package/harness/bundles/java/codex/harness-archive/SKILL.md +7 -1
  122. package/harness/bundles/java/codex/harness-archive/checklist.md +6 -0
  123. package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
  124. package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +1 -1
  125. package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
  126. package/harness/bundles/java/codex/harness-package/SKILL.md +1 -1
  127. package/harness/bundles/java/codex/harness-plan/SKILL.md +1 -1
  128. package/harness/bundles/java/codex/harness-review/SKILL.md +1 -1
  129. package/harness/bundles/java/codex/harness-run/SKILL.md +1 -1
  130. package/harness/bundles/java/codex/harness-submit/SKILL.md +11 -2
  131. package/harness/bundles/java/codex/harness-submit/checklist.md +20 -2
  132. package/harness/bundles/java/codex/harness-sync/SKILL.md +1 -1
  133. package/harness/bundles/java/codex/harness-test/SKILL.md +1 -1
  134. package/harness/bundles/java/codex/protocols/report-pipeline-protocol.md +2 -0
  135. package/harness/bundles/java/codex/scripts/harness_archive.py +199 -30
  136. package/harness/bundles/java/codex/scripts/harness_events.py +5 -18
  137. package/harness/bundles/java/codex/scripts/harness_integration.py +153 -0
  138. package/harness/bundles/java/codex/scripts/harness_ledger.py +37 -11
  139. package/harness/bundles/java/codex/scripts/harness_paths.py +170 -1
  140. package/harness/bundles/java/cursor/.harness-build.json +1 -1
  141. package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +1 -1
  142. package/harness/bundles/java/cursor/harness-archive/SKILL.md +7 -1
  143. package/harness/bundles/java/cursor/harness-archive/checklist.md +6 -0
  144. package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
  145. package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  146. package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
  147. package/harness/bundles/java/cursor/harness-package/SKILL.md +1 -1
  148. package/harness/bundles/java/cursor/harness-plan/SKILL.md +1 -1
  149. package/harness/bundles/java/cursor/harness-review/SKILL.md +1 -1
  150. package/harness/bundles/java/cursor/harness-run/SKILL.md +1 -1
  151. package/harness/bundles/java/cursor/harness-submit/SKILL.md +11 -2
  152. package/harness/bundles/java/cursor/harness-submit/checklist.md +20 -2
  153. package/harness/bundles/java/cursor/harness-sync/SKILL.md +1 -1
  154. package/harness/bundles/java/cursor/harness-test/SKILL.md +1 -1
  155. package/harness/bundles/java/cursor/protocols/report-pipeline-protocol.md +2 -0
  156. package/harness/bundles/java/cursor/scripts/harness_archive.py +199 -30
  157. package/harness/bundles/java/cursor/scripts/harness_events.py +5 -18
  158. package/harness/bundles/java/cursor/scripts/harness_integration.py +153 -0
  159. package/harness/bundles/java/cursor/scripts/harness_ledger.py +37 -11
  160. package/harness/bundles/java/cursor/scripts/harness_paths.py +170 -1
  161. package/harness/manifests/general/claude-code.json +19 -19
  162. package/harness/manifests/general/codebuddy.json +19 -19
  163. package/harness/manifests/general/codex.json +19 -19
  164. package/harness/manifests/general/cursor.json +19 -19
  165. package/harness/manifests/java/claude-code.json +21 -21
  166. package/harness/manifests/java/codebuddy.json +21 -21
  167. package/harness/manifests/java/codex.json +21 -21
  168. package/harness/manifests/java/cursor.json +21 -21
  169. package/hunter-workflow-family.json +1 -1
  170. package/package.json +1 -1
@@ -520,6 +520,41 @@ def check_status(
520
520
  }
521
521
  )
522
522
 
523
+ # --- H-4 formal-layer minimum set ---
524
+ plans_dir = change_dir / "plans"
525
+ plan_files = (
526
+ sorted(plans_dir.glob("*-plan.md")) if plans_dir.is_dir() else []
527
+ )
528
+ checks["plan_files"] = [str(p.relative_to(change_dir)) for p in plan_files]
529
+ if not plan_files:
530
+ blockers.append(
531
+ {
532
+ "code": "missing-plan",
533
+ "message": "plans/*-plan.md is required before archive",
534
+ }
535
+ )
536
+
537
+ events_path = change_dir / "events.ndjson"
538
+ events_present = events_path.is_file() and events_path.stat().st_size > 0
539
+ checks["events_ndjson"] = events_present
540
+ if not events_present:
541
+ blockers.append(
542
+ {
543
+ "code": "missing-events",
544
+ "message": "events.ndjson is required and must be non-empty",
545
+ }
546
+ )
547
+
548
+ ledger = load_ledger(change_dir)
549
+ checks["verification_ledger"] = ledger is not None
550
+ if ledger is None:
551
+ blockers.append(
552
+ {
553
+ "code": "missing-verification-ledger",
554
+ "message": "evidence/verification-ledger.json is required before archive",
555
+ }
556
+ )
557
+
523
558
  # --- commit pushed ---
524
559
  code, out, err = git_run(project, "rev-parse", "--is-inside-work-tree")
525
560
  if code != 0:
@@ -559,7 +594,8 @@ def check_status(
559
594
 
560
595
  expected_hash: str | None = None
561
596
  hash_source: str | None = None
562
- ledger = load_ledger(change_dir)
597
+ if ledger is None:
598
+ ledger = load_ledger(change_dir)
563
599
  if worktree_requested(change_dir) and ledger:
564
600
  merge_hash = ledger.get("mergeFinalHash") or (
565
601
  ledger.get("merge") or {}
@@ -625,13 +661,25 @@ def check_status(
625
661
  # --- test / review reports ---
626
662
  test_reports = find_test_reports(change_dir)
627
663
  review_reports = find_review_reports(change_dir)
628
- events_path = change_dir / "events.ndjson"
629
664
  events = he.load_events(events_path) if events_path.is_file() else []
630
665
  review_ran = review_evidence_present(change_dir, events)
631
666
 
632
667
  checks["test_reports"] = [str(p.relative_to(change_dir)) for p in test_reports]
633
668
  checks["review_reports"] = [str(p.relative_to(change_dir)) for p in review_reports]
634
669
 
670
+ # H-4 min blockers are plan/events/ledger only. Missing test/review evidence
671
+ # remains a warning so archive-before-review stays possible (advisory).
672
+ if not test_reports and not review_reports and not review_ran:
673
+ warnings.append(
674
+ {
675
+ "code": "missing-test-or-review-report",
676
+ "message": (
677
+ "no test report or review evidence yet; prefer completing "
678
+ "test/review before archive (not a hard blocker)"
679
+ ),
680
+ }
681
+ )
682
+
635
683
  if test_reports:
636
684
  checks["test_report_status"] = "present"
637
685
  else:
@@ -842,15 +890,18 @@ def _ledger_unit_tests(ledger: dict[str, Any] | None) -> dict[str, Any]:
842
890
  elif str(unit.get("reused") or "").lower() in {"true", "1"} or "REUSED" in status:
843
891
  source = "committed"
844
892
 
845
- if pass_rate is None and run > 0:
893
+ if pass_rate is None:
846
894
  passed = (
847
895
  passed_count
848
896
  if passed_count is not None
849
- else max(run - failures - errors - skipped, 0)
897
+ else max(int(run or 0) - int(failures or 0) - int(errors or 0) - int(skipped or 0), 0)
850
898
  )
851
- pass_rate = f"{passed / run:.0%}"
852
- elif pass_rate is None:
853
- pass_rate = NOT_AVAILABLE
899
+ # H-13: passRate denominator excludes skipped.
900
+ denom = int(passed) + int(failures or 0) + int(errors or 0)
901
+ if denom > 0:
902
+ pass_rate = f"{passed / denom:.0%}"
903
+ else:
904
+ pass_rate = NOT_AVAILABLE
854
905
 
855
906
  result = {
856
907
  "run": int(run or 0),
@@ -1332,7 +1383,19 @@ def _stage_status_from_sources(
1332
1383
  elif raw in {"SKIP", "SKIPPED"}:
1333
1384
  raw = "USER_SKIPPED"
1334
1385
  status[phase] = raw
1335
- # Issues in events can downgrade
1386
+ # Issues in events can downgrade. H-14: informational / hygiene / agent-preflight
1387
+ # notes must not downgrade an already-OK phase.end.
1388
+ _informational_markers = (
1389
+ "[archive-hygiene]",
1390
+ "informational",
1391
+ "custom_agents_unsupported",
1392
+ "definition_not_found_host_capable",
1393
+ "知识查询",
1394
+ "knowledge query",
1395
+ "harness-explorer",
1396
+ "harness-evaluator",
1397
+ "委派",
1398
+ )
1336
1399
  for e in he.current_issues(events):
1337
1400
  sev = str(e.get("severity") or "").lower()
1338
1401
  issue_text = " ".join(str(e.get(key) or "") for key in ("message", "note", "code")).lower()
@@ -1341,6 +1404,10 @@ def _stage_status_from_sources(
1341
1404
  sev = "error"
1342
1405
  elif any(token in issue_text for token in ("warn", "skip", "风险", "警告")):
1343
1406
  sev = "warn"
1407
+ if sev in {"warn", "warning"} and any(m in issue_text for m in _informational_markers):
1408
+ continue
1409
+ if sev in {"", "info", "note", "informational"}:
1410
+ continue
1344
1411
  phase = str(e.get("phase") or "").lower()
1345
1412
  if phase in status and sev in {"error", "fail", "failed", "critical"}:
1346
1413
  status[phase] = "FAIL"
@@ -1651,6 +1718,51 @@ def _review_summary(
1651
1718
  return base
1652
1719
 
1653
1720
 
1721
+ def _resolve_base_commit(
1722
+ ledger: dict[str, Any] | None,
1723
+ change_dir: Path,
1724
+ project: Path,
1725
+ final: str | None,
1726
+ ) -> str:
1727
+ """Resolve baseCommit: ledger → latest phase-context → merge first parent → merge-base."""
1728
+ if ledger:
1729
+ base = str(ledger.get("baseCommit") or "").strip()
1730
+ if base and base != NOT_AVAILABLE:
1731
+ return base
1732
+
1733
+ ctx_dir = change_dir / "runtime" / "phase-context"
1734
+ if ctx_dir.is_dir():
1735
+ candidates = sorted(
1736
+ (p for p in ctx_dir.glob("*.json") if p.is_file()),
1737
+ key=lambda p: p.stat().st_mtime,
1738
+ reverse=True,
1739
+ )
1740
+ for path in candidates:
1741
+ try:
1742
+ payload = read_json(path)
1743
+ except (OSError, json.JSONDecodeError, TypeError):
1744
+ continue
1745
+ if not isinstance(payload, dict):
1746
+ continue
1747
+ base = str(payload.get("baseCommit") or "").strip()
1748
+ if base and base != NOT_AVAILABLE:
1749
+ return base
1750
+
1751
+ final_commit = str(final or "").strip()
1752
+ if final_commit and final_commit != NOT_AVAILABLE:
1753
+ code, parents_line, _ = git_run(
1754
+ project, "rev-list", "--parents", "-n", "1", final_commit
1755
+ )
1756
+ if code == 0 and parents_line:
1757
+ parts = parents_line.split()
1758
+ if len(parts) >= 2:
1759
+ return parts[1]
1760
+ code, merge_base, _ = git_run(project, "merge-base", final_commit, "HEAD")
1761
+ if code == 0 and merge_base and merge_base != final_commit:
1762
+ return merge_base
1763
+ return ""
1764
+
1765
+
1654
1766
  def _final_commit_from_sources(
1655
1767
  ledger: dict[str, Any] | None,
1656
1768
  events: list[dict[str, Any]],
@@ -1864,12 +1976,21 @@ def collect_summary_data(
1864
1976
  data["finalCommit"] = final_commit or (NOT_AVAILABLE if for_replay else "")
1865
1977
 
1866
1978
  if not data.get("baseCommit") or str(data.get("baseCommit")).startswith("<"):
1867
- if ledger and ledger.get("baseCommit"):
1868
- data["baseCommit"] = ledger["baseCommit"]
1869
- elif existing and existing.get("baseCommit"):
1979
+ if for_replay and existing and existing.get("baseCommit"):
1870
1980
  data["baseCommit"] = existing["baseCommit"]
1871
1981
  else:
1872
- data["baseCommit"] = NOT_AVAILABLE if for_replay else ""
1982
+ resolved_base = _resolve_base_commit(
1983
+ ledger,
1984
+ change_dir,
1985
+ project,
1986
+ str(data.get("finalCommit") or "") or None,
1987
+ )
1988
+ if resolved_base:
1989
+ data["baseCommit"] = resolved_base
1990
+ elif existing and existing.get("baseCommit"):
1991
+ data["baseCommit"] = existing["baseCommit"]
1992
+ else:
1993
+ data["baseCommit"] = NOT_AVAILABLE if for_replay else ""
1873
1994
 
1874
1995
  if not data.get("finalCommitBranch") or str(data.get("finalCommitBranch")).startswith("<"):
1875
1996
  code, branch, _ = git_run(project, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}")
@@ -2002,6 +2123,16 @@ def collect_summary_data(
2002
2123
  }
2003
2124
  data["changedFiles"] = []
2004
2125
 
2126
+ # H-11: surface identity + diff facts for adequacy (top-level + gitFacts).
2127
+ diff_for_facts = data.get("diffStat") if isinstance(data.get("diffStat"), dict) else {}
2128
+ data["gitFacts"] = {
2129
+ "baseCommit": data.get("baseCommit") or "",
2130
+ "finalCommit": data.get("finalCommit") or "",
2131
+ "filesChanged": int(diff_for_facts.get("filesChanged") or 0),
2132
+ "insertions": int(diff_for_facts.get("insertions") or 0),
2133
+ "deletions": int(diff_for_facts.get("deletions") or 0),
2134
+ }
2135
+
2005
2136
  data.setdefault(
2006
2137
  "ownershipDiff",
2007
2138
  {
@@ -2837,18 +2968,52 @@ def validate_report_adequacy(summary: dict[str, Any]) -> dict[str, Any]:
2837
2968
  """
2838
2969
  issues: list[dict[str, str]] = []
2839
2970
 
2971
+ # H-12: prefer top-level baseCommit + diffStat; gitFacts is a mirror.
2972
+ base = str(summary.get("baseCommit") or "").strip()
2973
+ final = str(summary.get("finalCommit") or "").strip()
2974
+ merge_final = str(summary.get("mergeFinalHash") or "").strip()
2975
+ diff_stat = summary.get("diffStat") if isinstance(summary.get("diffStat"), dict) else {}
2976
+ files_changed = int(diff_stat.get("filesChanged") or 0)
2977
+
2840
2978
  git_facts = summary.get("gitFacts") or {}
2841
2979
  if isinstance(git_facts, dict):
2842
- base = str(git_facts.get("baseCommit") or "").strip()
2843
- final = str(git_facts.get("finalCommit") or "").strip()
2844
- files_changed = int(git_facts.get("filesChanged") or 0)
2845
- # base != final but diff=0 factually incomplete.
2846
- if base and final and base != final and files_changed == 0:
2847
- issues.append({
2848
- "code": "DIFF_ZERO_WITH_NONEMPTY_COMMIT",
2849
- "severity": "error",
2850
- "message": f"final commit {final} differs from base {base} but filesChanged=0",
2851
- })
2980
+ if not base or base == NOT_AVAILABLE:
2981
+ base = str(git_facts.get("baseCommit") or "").strip() or base
2982
+ if not final or final == NOT_AVAILABLE:
2983
+ final = str(git_facts.get("finalCommit") or "").strip() or final
2984
+ if "filesChanged" in git_facts and files_changed == 0:
2985
+ try:
2986
+ files_changed = int(git_facts.get("filesChanged") or 0)
2987
+ except (TypeError, ValueError):
2988
+ files_changed = 0
2989
+
2990
+ base_missing = not base or base == NOT_AVAILABLE
2991
+ final_present = bool(
2992
+ (final and final != NOT_AVAILABLE) or (merge_final and merge_final != NOT_AVAILABLE)
2993
+ )
2994
+ if final_present and base_missing:
2995
+ issues.append({
2996
+ "code": "IDENTITY_BASE_MISSING",
2997
+ "severity": "error",
2998
+ "message": (
2999
+ "final/merge identity present but baseCommit is empty; "
3000
+ "refuse CONDITIONAL_OK+ without a resolved base"
3001
+ ),
3002
+ })
3003
+
3004
+ if (
3005
+ base
3006
+ and final
3007
+ and base != NOT_AVAILABLE
3008
+ and final != NOT_AVAILABLE
3009
+ and base != final
3010
+ and files_changed == 0
3011
+ ):
3012
+ issues.append({
3013
+ "code": "DIFF_ZERO_WITH_NONEMPTY_COMMIT",
3014
+ "severity": "error",
3015
+ "message": f"final commit {final} differs from base {base} but filesChanged=0",
3016
+ })
2852
3017
 
2853
3018
  # Typed metrics missing despite test report artifacts present.
2854
3019
  verification = summary.get("verification") or {}
@@ -2886,11 +3051,11 @@ def validate_report_adequacy(summary: dict[str, Any]) -> dict[str, Any]:
2886
3051
 
2887
3052
 
2888
3053
  def artifact_preflight(change_dir: Path) -> dict[str, Any]:
2889
- """Classify artifact events before destructive finalize (retro §5.31).
3054
+ """Classify artifact events before destructive finalize (retro §5.31 / H-8).
2890
3055
 
2891
- Returns per-artifact classification: informational (no path, OK),
2892
- canonicalizable (same-change repo-relative path, auto-normalize), or
2893
- blocking (cross-change/absolute/escaping path, fail closed).
3056
+ Returns per-artifact classification: blocking (missing / escaping /
3057
+ cross-change path), canonicalizable (same-change repo-relative path),
3058
+ or file-backed (change-relative path). Pathless legacy rows fail closed.
2894
3059
  """
2895
3060
  change_dir = change_dir.resolve()
2896
3061
  events_p = change_dir / "events.ndjson"
@@ -2910,12 +3075,16 @@ def artifact_preflight(change_dir: Path) -> dict[str, Any]:
2910
3075
  kind = str(event.get("kind") or "").strip()
2911
3076
  event_id = str(event.get("id") or "")
2912
3077
  if not path:
2913
- items.append({
3078
+ item = {
2914
3079
  "eventId": event_id,
2915
- "category": "informational",
2916
- "kind": kind or "informational",
3080
+ "category": "blocking",
3081
+ "path": "",
3082
+ "kind": kind or "",
3083
+ "reason": "artifact path missing",
2917
3084
  "note": str(event.get("note") or "")[:80],
2918
- })
3085
+ }
3086
+ items.append(item)
3087
+ blocking.append(item)
2919
3088
  continue
2920
3089
  # Check for escaping/cross-change paths.
2921
3090
  parts = path.replace("\\", "/").split("/")
@@ -679,29 +679,16 @@ def validate_append_event(args: argparse.Namespace) -> tuple[str, str] | None:
679
679
  "CORRECTION_VALUE_INVALID_JSON",
680
680
  f"CORRECTION_VALUE_INVALID_JSON: {exc}",
681
681
  )
682
- # Retro §5.31: artifact events must distinguish file-backed from
683
- # informational. file-backed requires a change-relative path; informational
684
- # (preview/summary) must not masquerade as a file artifact. Legacy kind
685
- # values (ledger, report, etc.) are treated as file-backed aliases.
682
+ # Retro 2026-07-21 H-8: artifact events always require a non-empty path.
683
+ # Knowledge/exploration notes must use issue/decision, not pathless artifacts.
686
684
  if event_type == "artifact":
687
- kind = str(getattr(args, "kind", "") or "").strip()
688
685
  path = str(getattr(args, "path", "") or "").strip()
689
- is_informational = kind == "informational"
690
- if kind == "file-backed" and not path:
691
- return (
692
- "ARTIFACT_PATH_REQUIRED",
693
- "ARTIFACT_PATH_REQUIRED: file-backed artifact requires --path",
694
- )
695
- # If kind is explicitly informational, path is optional. If kind is
696
- # absent and path is absent, reject (can't infer file-backed without path).
697
- if not kind and not path:
686
+ if not path:
698
687
  return (
699
688
  "ARTIFACT_PATH_REQUIRED",
700
- "ARTIFACT_PATH_REQUIRED: artifact without --kind or --path is ambiguous; "
701
- "use --kind informational for previews or --path for file artifacts",
689
+ "ARTIFACT_PATH_REQUIRED: artifact requires --path "
690
+ "(use issue/decision for informational notes)",
702
691
  )
703
- # If no kind is provided, infer from path presence: a path implies
704
- # file-backed, absence implies informational (legacy compatibility).
705
692
  return None
706
693
 
707
694
 
@@ -109,6 +109,10 @@ class CleanupResidualError(IntegrationError):
109
109
  )
110
110
 
111
111
 
112
+ class AbandonRefusedError(IntegrationError):
113
+ code = "ABANDON_REFUSED"
114
+
115
+
112
116
  class JournalConflictError(IntegrationError):
113
117
  code = "JOURNAL_CONFLICT"
114
118
 
@@ -740,6 +744,8 @@ class IntegrationTransaction:
740
744
  str(intg_root),
741
745
  self.temp_branch,
742
746
  )
747
+ # H-5: LF-stable hashing in the integration worktree.
748
+ self.runner.run(intg_root, "config", "core.autocrlf", "false")
743
749
 
744
750
  return self._run_step("prepare", action)
745
751
 
@@ -1195,6 +1201,148 @@ class IntegrationTransaction:
1195
1201
  harness_change.integration_lock_release(self.project_root, run_id=self.run_id)
1196
1202
  return journal
1197
1203
 
1204
+ # ------------------------------------------------------------ abandon
1205
+
1206
+ def _remote_contains_merge(self, journal: dict[str, Any]) -> bool:
1207
+ merge_commit = str(journal.get("mergeCommit") or "")
1208
+ if not merge_commit:
1209
+ return False
1210
+ remote_line = self.runner.text(
1211
+ self.project_root, "ls-remote", "origin", self.target_branch
1212
+ )
1213
+ observed_remote = (
1214
+ remote_line.split()[0] if remote_line and remote_line.split() else ""
1215
+ )
1216
+ return bool(observed_remote and observed_remote == merge_commit)
1217
+
1218
+ def _force_remove_integration_root(
1219
+ self, journal: dict[str, Any], intg_root: Path
1220
+ ) -> dict[str, Any]:
1221
+ """Best-effort remove of the integration worktree for abandon only."""
1222
+ allowed = Path(str(journal.get("allowedCleanupRoot") or "")).resolve()
1223
+ resolved = harness_paths.assert_path_within(intg_root, self.temp_root)
1224
+ if not allowed or resolved != allowed:
1225
+ raise CleanupRefusedError(
1226
+ f"refusing to abandon non-transaction path: {resolved}"
1227
+ )
1228
+ self.runner.run(
1229
+ self.project_root,
1230
+ "worktree",
1231
+ "remove",
1232
+ "--force",
1233
+ str(resolved),
1234
+ check=False,
1235
+ )
1236
+ residual: list[str] = []
1237
+ if resolved.exists():
1238
+ for name in HEAVY_WORKTREE_ROOTS:
1239
+ heavy = resolved / name
1240
+ if heavy.exists():
1241
+ harness_paths.assert_path_within(heavy, resolved)
1242
+ try:
1243
+ if heavy.is_dir() and not heavy.is_symlink():
1244
+ shutil.rmtree(heavy)
1245
+ else:
1246
+ heavy.unlink()
1247
+ except OSError:
1248
+ residual.append(name)
1249
+ if resolved.exists():
1250
+ try:
1251
+ shutil.rmtree(resolved)
1252
+ except OSError:
1253
+ residual.append(str(resolved))
1254
+ listed = self.runner.text(self.project_root, "worktree", "list", "--porcelain")
1255
+ still_registered = str(resolved) in {
1256
+ str(Path(line.removeprefix("worktree ")).resolve())
1257
+ for line in (listed or "").splitlines()
1258
+ if line.startswith("worktree ")
1259
+ }
1260
+ ok = not still_registered and not residual and not resolved.exists()
1261
+ return {
1262
+ "ok": ok,
1263
+ "code": "CLEANUP_COMPLETE" if ok else "CLEANUP_RESIDUAL",
1264
+ "target": str(resolved),
1265
+ "worktreeRegistered": still_registered,
1266
+ "diskPathPresent": resolved.exists(),
1267
+ "residualPaths": residual,
1268
+ }
1269
+
1270
+ def abandon(self) -> dict[str, Any]:
1271
+ """Official recovery for a failed integration transaction (H-6).
1272
+
1273
+ Allowed only when push is not DONE and the remote does not already
1274
+ contain ``mergeCommit``. Removes the integration worktree, temp branch,
1275
+ protection refs, and integration lock. Never deletes the feature
1276
+ worktree or feature branch.
1277
+ """
1278
+ try:
1279
+ journal = self._load()
1280
+ except FileNotFoundError as exc:
1281
+ raise AbandonRefusedError(
1282
+ f"no journal for transaction {self.transaction_id}"
1283
+ ) from exc
1284
+
1285
+ push_status = str(self._step(journal, "push").get("status") or "")
1286
+ push_intent = journal.get("pushIntent") or {}
1287
+ push_confirmed = (
1288
+ push_status == "DONE"
1289
+ or (
1290
+ isinstance(push_intent, dict)
1291
+ and push_intent.get("status") == "REMOTE_CONFIRMED"
1292
+ )
1293
+ or bool(journal.get("pushedHead"))
1294
+ or bool(journal.get("mergeFinalHash"))
1295
+ )
1296
+ remote_has_merge = self._remote_contains_merge(journal)
1297
+ if push_confirmed or remote_has_merge:
1298
+ raise AbandonRefusedError(
1299
+ "refuse abandon: push succeeded or remote already contains "
1300
+ "the merge commit; use cleanup instead"
1301
+ )
1302
+
1303
+ intg_root = Path(str(journal.get("integrationRoot") or ""))
1304
+ cleanup_result: dict[str, Any] = {
1305
+ "ok": True,
1306
+ "code": "ALREADY_ABSENT",
1307
+ "target": str(intg_root) if intg_root else "",
1308
+ "worktreeRegistered": False,
1309
+ "diskPathPresent": False,
1310
+ "residualPaths": [],
1311
+ }
1312
+ prepare_done = self._step(journal, "prepare").get("status") == "DONE"
1313
+ if prepare_done and str(journal.get("integrationRoot") or "").strip():
1314
+ try:
1315
+ cleanup_result = self.cleanup_target(intg_root)
1316
+ except CleanupRefusedError:
1317
+ cleanup_result = self._force_remove_integration_root(journal, intg_root)
1318
+
1319
+ self.runner.run(
1320
+ self.project_root, "branch", "-D", self.temp_branch, check=False
1321
+ )
1322
+ refs = journal.get("protectionRefs") or {}
1323
+ if isinstance(refs, dict):
1324
+ for ref in (refs.get("base"), refs.get("head")):
1325
+ if ref:
1326
+ self.runner.run(
1327
+ self.project_root, "update-ref", "-d", str(ref), check=False
1328
+ )
1329
+
1330
+ harness_change.integration_lock_release(self.project_root, run_id=self.run_id)
1331
+
1332
+ journal = self._load()
1333
+ journal["abandonedAt"] = now_iso()
1334
+ journal["abandonResult"] = cleanup_result
1335
+ self._save(journal)
1336
+
1337
+ return {
1338
+ "ok": True,
1339
+ "code": "ABANDON_COMPLETE",
1340
+ "transactionId": self.transaction_id,
1341
+ "cleanup": cleanup_result,
1342
+ "featureBranchRetained": True,
1343
+ "featureWorktreeRetained": True,
1344
+ }
1345
+
1198
1346
  # ------------------------------------------------------------ recover
1199
1347
 
1200
1348
  def recover(
@@ -1282,6 +1430,10 @@ def cmd_cleanup(args: argparse.Namespace) -> int:
1282
1430
  return _emit(_txn_from_args(args).cleanup())
1283
1431
 
1284
1432
 
1433
+ def cmd_abandon(args: argparse.Namespace) -> int:
1434
+ return _emit(_txn_from_args(args).abandon())
1435
+
1436
+
1285
1437
  def cmd_recover(args: argparse.Namespace) -> int:
1286
1438
  return _emit(_txn_from_args(args).recover())
1287
1439
 
@@ -1333,6 +1485,7 @@ def build_parser() -> argparse.ArgumentParser:
1333
1485
  add("merge", cmd_merge)
1334
1486
  add("push", cmd_push)
1335
1487
  add("cleanup", cmd_cleanup)
1488
+ add("abandon", cmd_abandon)
1336
1489
  add("recover", cmd_recover)
1337
1490
  add("status", cmd_status)
1338
1491
 
@@ -28,6 +28,7 @@ if str(SCRIPTS_DIR) not in sys.path:
28
28
  sys.path.insert(0, str(SCRIPTS_DIR))
29
29
 
30
30
  import harness_paths # noqa: E402
31
+ import harness_profile # noqa: E402
31
32
 
32
33
 
33
34
  if hasattr(sys.stdout, "reconfigure"):
@@ -131,8 +132,19 @@ def emit_compact_or_verbose(
131
132
  emit_json(out, as_json=as_json)
132
133
 
133
134
 
134
- def emit_error(message: str, *, as_json: bool, code: int = 1) -> int:
135
- payload = {"ok": False, "error": message}
135
+ def emit_error(
136
+ message: str,
137
+ *,
138
+ as_json: bool,
139
+ code: int = 1,
140
+ error_code: str | None = None,
141
+ extra: dict[str, Any] | None = None,
142
+ ) -> int:
143
+ payload: dict[str, Any] = {"ok": False, "error": message}
144
+ if error_code:
145
+ payload["code"] = error_code
146
+ if extra:
147
+ payload.update(extra)
136
148
  if as_json:
137
149
  sys.stderr.write(json.dumps(payload, ensure_ascii=False) + "\n")
138
150
  else:
@@ -523,18 +535,30 @@ def expand_profile_input_files(
523
535
  ) -> tuple[list[str], str | None]:
524
536
  """Expand verificationInputs[profile_input] globs from build-profile.json.
525
537
 
526
- Globs are relative to project; only files inside project are kept
527
- (deduped, path-sorted). Returns (files, error); error is None on success.
538
+ Profile is loaded via ``harness_profile.load_profile`` (C7: common_root then
539
+ execution overlay) so linked worktrees without a local build-profile still
540
+ reuse the main checkout profile. Globs remain relative to the execution
541
+ ``project`` root (not common_root) so inputsHash tracks the tree under test.
542
+
543
+ Returns (files, error); error is None on success.
528
544
  profile 缺失 / key 缺失 / glob 无匹配 / 结果为空 → 返回 ([], "<reason>"),
529
545
  调用方据此返回 insufficient-evidence,执行全量测试但不允许缓存复用。
530
546
  """
531
- profile_path = project / ".harness" / "config" / "build-profile.json"
532
- if not profile_path.is_file():
547
+ profile = harness_profile.load_profile(Path(project))
548
+ if profile is None:
549
+ # load_profile swallows JSON errors; restore actionable unreadable diag
550
+ # when a profile file exists but cannot be parsed (review YELLOW-1).
551
+ project_root = Path(project).resolve()
552
+ common = harness_paths.common_root(project_root)
553
+ for root in (common, project_root):
554
+ candidate = root / ".harness" / "config" / "build-profile.json"
555
+ if not candidate.is_file():
556
+ continue
557
+ try:
558
+ json.loads(candidate.read_text(encoding="utf-8-sig"))
559
+ except (OSError, json.JSONDecodeError) as exc:
560
+ return [], f"build-profile.json unreadable: {exc}"
533
561
  return [], "build-profile.json missing; run harness_preflight.py detect"
534
- try:
535
- profile = json.loads(profile_path.read_text(encoding="utf-8-sig"))
536
- except (OSError, json.JSONDecodeError) as exc:
537
- return [], f"build-profile.json unreadable: {exc}"
538
562
  if not isinstance(profile, dict):
539
563
  return [], "build-profile.json is not an object"
540
564
  inputs = profile.get("verificationInputs")
@@ -544,7 +568,7 @@ def expand_profile_input_files(
544
568
  if not isinstance(patterns, list) or not patterns:
545
569
  return [], f"verificationInputs.{profile_input} is empty or invalid"
546
570
 
547
- base = project.resolve()
571
+ base = Path(project).resolve()
548
572
  seen: set[str] = set()
549
573
  for pat in patterns:
550
574
  if not isinstance(pat, str) or not pat.strip():
@@ -1520,6 +1544,8 @@ def cmd_record(args: argparse.Namespace) -> int:
1520
1544
  return emit_error(
1521
1545
  "ledger identity incomplete: " + ", ".join(missing),
1522
1546
  as_json=as_json,
1547
+ error_code="LEDGER_IDENTITY_INVALID",
1548
+ extra={"missing": missing},
1523
1549
  )
1524
1550
  write_ledger(out_path, ledger)
1525
1551
  except (OSError, ValueError, FileNotFoundError, json.JSONDecodeError) as exc: