@hunter-harness/workflow-harness 0.2.19 → 0.2.21

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 (166) hide show
  1. package/harness/bundles/general/claude-code/.harness-build.json +1 -1
  2. package/harness/bundles/general/claude-code/CONTEXT.md +20 -0
  3. package/harness/bundles/general/claude-code/harness-archive/SKILL.md +1 -1
  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-run/checklist.md +6 -0
  11. package/harness/bundles/general/claude-code/harness-submit/SKILL.md +1 -1
  12. package/harness/bundles/general/claude-code/harness-submit/checklist.md +8 -0
  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/protocols/model-routing-protocol.md +22 -0
  16. package/harness/bundles/general/claude-code/scripts/harness_events.py +159 -1
  17. package/harness/bundles/general/claude-code/scripts/harness_integration.py +34 -0
  18. package/harness/bundles/general/claude-code/scripts/harness_ledger.py +149 -9
  19. package/harness/bundles/general/claude-code/scripts/harness_migration_head.py +220 -0
  20. package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
  21. package/harness/bundles/general/codebuddy/CONTEXT.md +20 -0
  22. package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +1 -1
  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-run/checklist.md +6 -0
  30. package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +1 -1
  31. package/harness/bundles/general/codebuddy/harness-submit/checklist.md +8 -0
  32. package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +1 -1
  33. package/harness/bundles/general/codebuddy/harness-test/SKILL.md +1 -1
  34. package/harness/bundles/general/codebuddy/protocols/model-routing-protocol.md +22 -0
  35. package/harness/bundles/general/codebuddy/scripts/harness_events.py +159 -1
  36. package/harness/bundles/general/codebuddy/scripts/harness_integration.py +34 -0
  37. package/harness/bundles/general/codebuddy/scripts/harness_ledger.py +149 -9
  38. package/harness/bundles/general/codebuddy/scripts/harness_migration_head.py +220 -0
  39. package/harness/bundles/general/codex/.harness-build.json +1 -1
  40. package/harness/bundles/general/codex/CONTEXT.md +20 -0
  41. package/harness/bundles/general/codex/harness-archive/SKILL.md +1 -1
  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-run/checklist.md +6 -0
  49. package/harness/bundles/general/codex/harness-submit/SKILL.md +1 -1
  50. package/harness/bundles/general/codex/harness-submit/checklist.md +8 -0
  51. package/harness/bundles/general/codex/harness-sync/SKILL.md +1 -1
  52. package/harness/bundles/general/codex/harness-test/SKILL.md +1 -1
  53. package/harness/bundles/general/codex/protocols/model-routing-protocol.md +22 -0
  54. package/harness/bundles/general/codex/scripts/harness_events.py +159 -1
  55. package/harness/bundles/general/codex/scripts/harness_integration.py +34 -0
  56. package/harness/bundles/general/codex/scripts/harness_ledger.py +149 -9
  57. package/harness/bundles/general/codex/scripts/harness_migration_head.py +220 -0
  58. package/harness/bundles/general/cursor/.harness-build.json +1 -1
  59. package/harness/bundles/general/cursor/CONTEXT.md +20 -0
  60. package/harness/bundles/general/cursor/harness-archive/SKILL.md +1 -1
  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-run/checklist.md +6 -0
  68. package/harness/bundles/general/cursor/harness-submit/SKILL.md +1 -1
  69. package/harness/bundles/general/cursor/harness-submit/checklist.md +8 -0
  70. package/harness/bundles/general/cursor/harness-sync/SKILL.md +1 -1
  71. package/harness/bundles/general/cursor/harness-test/SKILL.md +1 -1
  72. package/harness/bundles/general/cursor/protocols/model-routing-protocol.md +22 -0
  73. package/harness/bundles/general/cursor/scripts/harness_events.py +159 -1
  74. package/harness/bundles/general/cursor/scripts/harness_integration.py +34 -0
  75. package/harness/bundles/general/cursor/scripts/harness_ledger.py +149 -9
  76. package/harness/bundles/general/cursor/scripts/harness_migration_head.py +220 -0
  77. package/harness/bundles/java/claude-code/.harness-build.json +1 -1
  78. package/harness/bundles/java/claude-code/CONTEXT.md +20 -0
  79. package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +1 -1
  80. package/harness/bundles/java/claude-code/harness-archive/SKILL.md +1 -1
  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 +1 -1
  89. package/harness/bundles/java/claude-code/harness-submit/checklist.md +8 -0
  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/model-routing-protocol.md +22 -0
  93. package/harness/bundles/java/claude-code/scripts/harness_events.py +159 -1
  94. package/harness/bundles/java/claude-code/scripts/harness_integration.py +34 -0
  95. package/harness/bundles/java/claude-code/scripts/harness_ledger.py +149 -9
  96. package/harness/bundles/java/claude-code/scripts/harness_migration_head.py +220 -0
  97. package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
  98. package/harness/bundles/java/codebuddy/CONTEXT.md +20 -0
  99. package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +1 -1
  100. package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +1 -1
  101. package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
  102. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  103. package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  104. package/harness/bundles/java/codebuddy/harness-package/SKILL.md +1 -1
  105. package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +1 -1
  106. package/harness/bundles/java/codebuddy/harness-review/SKILL.md +1 -1
  107. package/harness/bundles/java/codebuddy/harness-run/SKILL.md +1 -1
  108. package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +1 -1
  109. package/harness/bundles/java/codebuddy/harness-submit/checklist.md +8 -0
  110. package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +1 -1
  111. package/harness/bundles/java/codebuddy/harness-test/SKILL.md +1 -1
  112. package/harness/bundles/java/codebuddy/protocols/model-routing-protocol.md +22 -0
  113. package/harness/bundles/java/codebuddy/scripts/harness_events.py +159 -1
  114. package/harness/bundles/java/codebuddy/scripts/harness_integration.py +34 -0
  115. package/harness/bundles/java/codebuddy/scripts/harness_ledger.py +149 -9
  116. package/harness/bundles/java/codebuddy/scripts/harness_migration_head.py +220 -0
  117. package/harness/bundles/java/codex/.harness-build.json +1 -1
  118. package/harness/bundles/java/codex/CONTEXT.md +20 -0
  119. package/harness/bundles/java/codex/harness-apidoc/SKILL.md +1 -1
  120. package/harness/bundles/java/codex/harness-archive/SKILL.md +1 -1
  121. package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
  122. package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +1 -1
  123. package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
  124. package/harness/bundles/java/codex/harness-package/SKILL.md +1 -1
  125. package/harness/bundles/java/codex/harness-plan/SKILL.md +1 -1
  126. package/harness/bundles/java/codex/harness-review/SKILL.md +1 -1
  127. package/harness/bundles/java/codex/harness-run/SKILL.md +1 -1
  128. package/harness/bundles/java/codex/harness-submit/SKILL.md +1 -1
  129. package/harness/bundles/java/codex/harness-submit/checklist.md +8 -0
  130. package/harness/bundles/java/codex/harness-sync/SKILL.md +1 -1
  131. package/harness/bundles/java/codex/harness-test/SKILL.md +1 -1
  132. package/harness/bundles/java/codex/protocols/model-routing-protocol.md +22 -0
  133. package/harness/bundles/java/codex/scripts/harness_events.py +159 -1
  134. package/harness/bundles/java/codex/scripts/harness_integration.py +34 -0
  135. package/harness/bundles/java/codex/scripts/harness_ledger.py +149 -9
  136. package/harness/bundles/java/codex/scripts/harness_migration_head.py +220 -0
  137. package/harness/bundles/java/cursor/.harness-build.json +1 -1
  138. package/harness/bundles/java/cursor/CONTEXT.md +20 -0
  139. package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +1 -1
  140. package/harness/bundles/java/cursor/harness-archive/SKILL.md +1 -1
  141. package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
  142. package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  143. package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
  144. package/harness/bundles/java/cursor/harness-package/SKILL.md +1 -1
  145. package/harness/bundles/java/cursor/harness-plan/SKILL.md +1 -1
  146. package/harness/bundles/java/cursor/harness-review/SKILL.md +1 -1
  147. package/harness/bundles/java/cursor/harness-run/SKILL.md +1 -1
  148. package/harness/bundles/java/cursor/harness-submit/SKILL.md +1 -1
  149. package/harness/bundles/java/cursor/harness-submit/checklist.md +8 -0
  150. package/harness/bundles/java/cursor/harness-sync/SKILL.md +1 -1
  151. package/harness/bundles/java/cursor/harness-test/SKILL.md +1 -1
  152. package/harness/bundles/java/cursor/protocols/model-routing-protocol.md +22 -0
  153. package/harness/bundles/java/cursor/scripts/harness_events.py +159 -1
  154. package/harness/bundles/java/cursor/scripts/harness_integration.py +34 -0
  155. package/harness/bundles/java/cursor/scripts/harness_ledger.py +149 -9
  156. package/harness/bundles/java/cursor/scripts/harness_migration_head.py +220 -0
  157. package/harness/manifests/general/claude-code.json +25 -17
  158. package/harness/manifests/general/codebuddy.json +25 -17
  159. package/harness/manifests/general/codex.json +25 -17
  160. package/harness/manifests/general/cursor.json +25 -17
  161. package/harness/manifests/java/claude-code.json +26 -18
  162. package/harness/manifests/java/codebuddy.json +26 -18
  163. package/harness/manifests/java/codex.json +26 -18
  164. package/harness/manifests/java/cursor.json +26 -18
  165. package/hunter-workflow-family.json +1 -1
  166. package/package.json +1 -1
@@ -24,7 +24,7 @@ import tempfile
24
24
  import time
25
25
  import uuid
26
26
  from pathlib import Path
27
- from typing import Any
27
+ from typing import Any, Sequence
28
28
 
29
29
  SCRIPTS_DIR = Path(__file__).resolve().parent
30
30
  if str(SCRIPTS_DIR) not in sys.path:
@@ -595,6 +595,132 @@ def append_event(
595
595
  return {"ok": True, "event": event, "events_path": str(events_path_obj), "rendered": False}
596
596
 
597
597
 
598
+ def batch_append_events(
599
+ change_dir: Path,
600
+ events: Sequence[dict[str, Any]],
601
+ ) -> dict[str, Any]:
602
+ """Validate then append many events under one lock (Wave-2 H-15).
603
+
604
+ Any invalid item aborts the whole batch before mutation. ``phase.end`` items
605
+ are rejected here — use single ``append`` so render semantics stay explicit.
606
+ """
607
+ if not isinstance(events, (list, tuple)) or not events:
608
+ return {
609
+ "ok": False,
610
+ "code": "BATCH_EMPTY",
611
+ "message": "batch-append requires a non-empty events array",
612
+ }
613
+ archived = archived_change_dir(change_dir)
614
+ if archived is not None:
615
+ return {
616
+ "ok": False,
617
+ "code": "ARCHIVED_CHANGE_IMMUTABLE",
618
+ "message": str(archived),
619
+ }
620
+
621
+ built: list[dict[str, Any]] = []
622
+ for index, raw in enumerate(events):
623
+ if not isinstance(raw, dict):
624
+ return {
625
+ "ok": False,
626
+ "code": "BATCH_ITEM_INVALID",
627
+ "message": f"events[{index}] must be an object",
628
+ "index": index,
629
+ }
630
+ type_ = str(raw.get("type") or "").strip()
631
+ phase = str(raw.get("phase") or "").strip()
632
+ if not type_ or type_ not in EVENT_TYPES:
633
+ return {
634
+ "ok": False,
635
+ "code": "EVENT_TYPE_INVALID",
636
+ "message": f"events[{index}]: unsupported type: {type_}",
637
+ "index": index,
638
+ }
639
+ if not phase:
640
+ return {
641
+ "ok": False,
642
+ "code": "EVENT_REQUIRED_FIELD",
643
+ "message": f"events[{index}]: phase required",
644
+ "index": index,
645
+ }
646
+ if type_ == "phase.end":
647
+ return {
648
+ "ok": False,
649
+ "code": "BATCH_PHASE_END_FORBIDDEN",
650
+ "message": f"events[{index}]: phase.end must use single append",
651
+ "index": index,
652
+ }
653
+ args = argparse.Namespace(
654
+ change_dir=str(change_dir),
655
+ phase=phase,
656
+ type=type_,
657
+ note=raw.get("note"),
658
+ kind=raw.get("kind"),
659
+ path=raw.get("path"),
660
+ run_id=raw.get("run_id"),
661
+ executor_tool=raw.get("executor_tool"),
662
+ command=raw.get("command"),
663
+ exit_code=raw.get("exit_code"),
664
+ duration_ms=raw.get("duration_ms"),
665
+ status=raw.get("status"),
666
+ name=raw.get("name"),
667
+ code=raw.get("code"),
668
+ severity=raw.get("severity"),
669
+ message=raw.get("message"),
670
+ decision=raw.get("decision"),
671
+ reason=raw.get("reason"),
672
+ issue_id=raw.get("issue_id"),
673
+ scope=raw.get("scope"),
674
+ target_event_id=raw.get("target_event_id"),
675
+ target_field=raw.get("target_field"),
676
+ old_value_hash=raw.get("old_value_hash"),
677
+ new_value_json=raw.get("new_value_json"),
678
+ renamed_from=raw.get("renamed_from"),
679
+ renamed_to=raw.get("renamed_to"),
680
+ change_uuid=raw.get("change_uuid"),
681
+ attempt=raw.get("attempt"),
682
+ executor_agent=raw.get("executor_agent"),
683
+ executor_model=raw.get("executor_model"),
684
+ handoff_from_tool=raw.get("handoff_from_tool"),
685
+ handoff_reason=raw.get("handoff_reason"),
686
+ trace_id=raw.get("trace_id"),
687
+ span_id=raw.get("span_id"),
688
+ parent_span_id=raw.get("parent_span_id"),
689
+ runner_ms=raw.get("runner_ms"),
690
+ orchestration_active_ms=raw.get("orchestration_active_ms"),
691
+ wall_clock_ms=raw.get("wall_clock_ms"),
692
+ user_wait_ms=raw.get("user_wait_ms"),
693
+ legacy_lenient=False,
694
+ json=True,
695
+ )
696
+ validation = validate_append_event(args)
697
+ if validation:
698
+ error_code, message = validation
699
+ return {
700
+ "ok": False,
701
+ "code": error_code,
702
+ "message": f"events[{index}]: {message}",
703
+ "index": index,
704
+ }
705
+ built.append(build_event(args, []))
706
+
707
+ events_path_obj = events_path(change_dir)
708
+ lock_path = events_path_obj.with_name(events_path_obj.name + ".lock")
709
+ with event_file_lock(lock_path):
710
+ for event in built:
711
+ atomic_append_line(
712
+ events_path_obj, json.dumps(event, ensure_ascii=False, separators=(",", ":"))
713
+ )
714
+ return {
715
+ "ok": True,
716
+ "action": "batch-append",
717
+ "count": len(built),
718
+ "events": built,
719
+ "events_path": str(events_path_obj),
720
+ "rendered": False,
721
+ }
722
+
723
+
598
724
  def build_event(args: argparse.Namespace, existing: list[dict[str, Any]]) -> dict[str, Any]:
599
725
  event: dict[str, Any] = {
600
726
  "schema_version": SCHEMA_VERSION,
@@ -1295,6 +1421,25 @@ def cmd_append(args: argparse.Namespace) -> int:
1295
1421
  return 0
1296
1422
 
1297
1423
 
1424
+ def cmd_batch_append(args: argparse.Namespace) -> int:
1425
+ as_json = bool(args.json)
1426
+ change_dir = resolve_change_dir(args.change_dir)
1427
+ batch_path = Path(str(args.file)).expanduser()
1428
+ try:
1429
+ raw = json.loads(batch_path.read_text(encoding="utf-8-sig"))
1430
+ except (OSError, json.JSONDecodeError) as exc:
1431
+ return emit_error(f"batch-append failed to read --file: {exc}", as_json=as_json)
1432
+ result = batch_append_events(change_dir, raw if isinstance(raw, list) else [])
1433
+ if not result.get("ok"):
1434
+ return emit_error(
1435
+ str(result.get("message") or "batch-append failed"),
1436
+ as_json=as_json,
1437
+ error_code=str(result.get("code") or "BATCH_APPEND_FAILED"),
1438
+ )
1439
+ emit_json(result, as_json=as_json)
1440
+ return 0
1441
+
1442
+
1298
1443
  def cmd_render(args: argparse.Namespace) -> int:
1299
1444
  as_json = bool(args.json)
1300
1445
  change_dir = resolve_change_dir(args.change_dir)
@@ -1395,6 +1540,19 @@ def build_parser() -> argparse.ArgumentParser:
1395
1540
  )
1396
1541
  p_append.set_defaults(func=cmd_append)
1397
1542
 
1543
+ p_batch = sub.add_parser(
1544
+ "batch-append",
1545
+ parents=[common],
1546
+ help="validate and append many events under one lock (H-15)",
1547
+ )
1548
+ p_batch.add_argument("--change-dir", required=True)
1549
+ p_batch.add_argument(
1550
+ "--file",
1551
+ required=True,
1552
+ help="JSON file containing a non-empty array of event objects",
1553
+ )
1554
+ p_batch.set_defaults(func=cmd_batch_append)
1555
+
1398
1556
  p_render = sub.add_parser(
1399
1557
  "render",
1400
1558
  parents=[common],
@@ -862,8 +862,42 @@ class IntegrationTransaction:
862
862
 
863
863
  return self._run_step("merge", action)
864
864
 
865
+ def _required_on_merge_commands(self) -> list[list[str]]:
866
+ """Load build-profile mergeVerification.requiredOnMerge (Wave-2 H-9)."""
867
+ import harness_profile
868
+
869
+ profile = harness_profile.load_profile(self.project_root)
870
+ if not isinstance(profile, dict):
871
+ return []
872
+ merge = profile.get("mergeVerification")
873
+ if not isinstance(merge, dict):
874
+ return []
875
+ required = merge.get("requiredOnMerge")
876
+ if not isinstance(required, list):
877
+ return []
878
+ commands: list[list[str]] = []
879
+ for index, item in enumerate(required):
880
+ if isinstance(item, str) and item.strip():
881
+ commands.append(shlex.split(item.strip()))
882
+ continue
883
+ if not isinstance(item, dict):
884
+ raise ValueError(f"requiredOnMerge[{index}] must be string or object")
885
+ raw = item.get("command")
886
+ if isinstance(raw, list) and raw and all(isinstance(x, str) and x for x in raw):
887
+ commands.append([str(x) for x in raw])
888
+ elif isinstance(raw, str) and raw.strip():
889
+ commands.append(shlex.split(raw.strip()))
890
+ else:
891
+ raise ValueError(f"requiredOnMerge[{index}].command missing")
892
+ return commands
893
+
865
894
  def verify(self, commands: Sequence[Sequence[str]] | None = None) -> dict[str, Any]:
866
895
  commands = list(commands or [])
896
+ # Wave-2 H-9: append profile mergeVerification.requiredOnMerge commands.
897
+ try:
898
+ commands.extend(self._required_on_merge_commands())
899
+ except Exception as exc: # noqa: BLE001 — surface as verify plan error
900
+ raise VerifyPlanMissingError(f"mergeVerification load failed: {exc}") from exc
867
901
 
868
902
  def action(journal: dict[str, Any]) -> None:
869
903
  if not commands or any(
@@ -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"):
@@ -534,18 +535,30 @@ def expand_profile_input_files(
534
535
  ) -> tuple[list[str], str | None]:
535
536
  """Expand verificationInputs[profile_input] globs from build-profile.json.
536
537
 
537
- Globs are relative to project; only files inside project are kept
538
- (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.
539
544
  profile 缺失 / key 缺失 / glob 无匹配 / 结果为空 → 返回 ([], "<reason>"),
540
545
  调用方据此返回 insufficient-evidence,执行全量测试但不允许缓存复用。
541
546
  """
542
- profile_path = project / ".harness" / "config" / "build-profile.json"
543
- 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}"
544
561
  return [], "build-profile.json missing; run harness_preflight.py detect"
545
- try:
546
- profile = json.loads(profile_path.read_text(encoding="utf-8-sig"))
547
- except (OSError, json.JSONDecodeError) as exc:
548
- return [], f"build-profile.json unreadable: {exc}"
549
562
  if not isinstance(profile, dict):
550
563
  return [], "build-profile.json is not an object"
551
564
  inputs = profile.get("verificationInputs")
@@ -555,7 +568,7 @@ def expand_profile_input_files(
555
568
  if not isinstance(patterns, list) or not patterns:
556
569
  return [], f"verificationInputs.{profile_input} is empty or invalid"
557
570
 
558
- base = project.resolve()
571
+ base = Path(project).resolve()
559
572
  seen: set[str] = set()
560
573
  for pat in patterns:
561
574
  if not isinstance(pat, str) or not pat.strip():
@@ -1042,6 +1055,7 @@ def decide_can_reuse(
1042
1055
  requested_toolchain_hash: str | None = None,
1043
1056
  requested_profile_hash: str | None = None,
1044
1057
  requested_environment_hash: str | None = None,
1058
+ requested_db_schema_hash: str | None = None,
1045
1059
  ) -> dict[str, Any]:
1046
1060
  ledger, ledger_path = load_ledger(change_dir)
1047
1061
  if ledger is None:
@@ -1170,6 +1184,7 @@ def decide_can_reuse(
1170
1184
  ("toolchainHash", requested_toolchain_hash, "TOOLCHAIN_CHANGED"),
1171
1185
  ("profileHash", requested_profile_hash, "PROFILE_CHANGED"),
1172
1186
  ("environmentHash", requested_environment_hash, "ENVIRONMENT_CHANGED"),
1187
+ ("dbSchemaHash", requested_db_schema_hash, "DB_SCHEMA_CHANGED"),
1173
1188
  ):
1174
1189
  stored = entry.get(field)
1175
1190
  if (
@@ -1236,6 +1251,7 @@ def decide_can_reuse(
1236
1251
  "reuse": True,
1237
1252
  "reason": "reuse",
1238
1253
  "code": "REUSED",
1254
+ "invalidationCode": None,
1239
1255
  "verification": verification,
1240
1256
  "ledger_path": str(ledger_path) if ledger_path else None,
1241
1257
  "inputsHash": stored_hash,
@@ -1245,6 +1261,90 @@ def decide_can_reuse(
1245
1261
  }
1246
1262
 
1247
1263
 
1264
+ _INVALIDATION_ALIASES = {
1265
+ "INPUTS_HASH_CHANGED": "INPUT_CHANGED",
1266
+ "ENVIRONMENT_CHANGED": "ENV_CHANGED",
1267
+ "MISSING_FIELDS": "MISSING_IDENTITY_FIELD",
1268
+ "MISSING_V2_FIELDS": "MISSING_IDENTITY_FIELD",
1269
+ "LEDGER_MISSING": "MISSING_IDENTITY_FIELD",
1270
+ "VALIDATIONS_MISSING": "MISSING_IDENTITY_FIELD",
1271
+ "VALIDATION_MISSING": "MISSING_IDENTITY_FIELD",
1272
+ }
1273
+
1274
+
1275
+ def _attach_invalidation_code(payload: dict[str, Any]) -> dict[str, Any]:
1276
+ """Wave-2 H-9: expose stable invalidationCode alongside legacy code."""
1277
+ if payload.get("reuse") is True:
1278
+ payload.setdefault("invalidationCode", None)
1279
+ return payload
1280
+ code = str(payload.get("code") or "").strip()
1281
+ if code:
1282
+ payload["invalidationCode"] = _INVALIDATION_ALIASES.get(code, code)
1283
+ return payload
1284
+
1285
+
1286
+ def verification_graph_path(change_dir: Path) -> Path:
1287
+ return Path(change_dir) / "evidence" / "verification-graph.json"
1288
+
1289
+
1290
+ def upsert_verification_graph_node(
1291
+ change_dir: Path,
1292
+ *,
1293
+ verification: str,
1294
+ status: str,
1295
+ inputs_hash: str,
1296
+ command: str | None = None,
1297
+ toolchain_hash: str | None = None,
1298
+ environment_hash: str | None = None,
1299
+ db_schema_hash: str | None = None,
1300
+ diff_hash: str | None = None,
1301
+ ) -> dict[str, Any]:
1302
+ """Persist a verification graph node keyed by canonical identity (H-9)."""
1303
+ path = verification_graph_path(change_dir)
1304
+ path.parent.mkdir(parents=True, exist_ok=True)
1305
+ graph: dict[str, Any]
1306
+ if path.is_file():
1307
+ try:
1308
+ loaded = json.loads(path.read_text(encoding="utf-8-sig"))
1309
+ graph = loaded if isinstance(loaded, dict) else {"schemaVersion": 1, "nodes": []}
1310
+ except (OSError, json.JSONDecodeError):
1311
+ graph = {"schemaVersion": 1, "nodes": []}
1312
+ else:
1313
+ graph = {"schemaVersion": 1, "nodes": []}
1314
+ nodes = graph.get("nodes")
1315
+ if not isinstance(nodes, list):
1316
+ nodes = []
1317
+ identity = {
1318
+ "verification": verification,
1319
+ "inputsHash": inputs_hash,
1320
+ "command": command,
1321
+ "toolchainHash": toolchain_hash,
1322
+ "environmentHash": environment_hash,
1323
+ "dbSchemaHash": db_schema_hash,
1324
+ "diffHash": diff_hash,
1325
+ }
1326
+ node = {
1327
+ "identity": identity,
1328
+ "status": status,
1329
+ "recordedAt": dt.datetime.now().astimezone().isoformat(timespec="milliseconds"),
1330
+ }
1331
+ replaced = False
1332
+ for index, existing in enumerate(nodes):
1333
+ if not isinstance(existing, dict):
1334
+ continue
1335
+ existing_id = existing.get("identity")
1336
+ if isinstance(existing_id, dict) and existing_id == identity:
1337
+ nodes[index] = node
1338
+ replaced = True
1339
+ break
1340
+ if not replaced:
1341
+ nodes.append(node)
1342
+ graph["schemaVersion"] = 1
1343
+ graph["nodes"] = nodes
1344
+ write_ledger(path, graph) # atomic json write helper already used for ledger
1345
+ return {"ok": True, "path": str(path), "nodeCount": len(nodes), "replaced": replaced}
1346
+
1347
+
1248
1348
  def cmd_hash(args: argparse.Namespace) -> int:
1249
1349
  as_json = bool(args.json)
1250
1350
  try:
@@ -1317,10 +1417,12 @@ def cmd_can_reuse(args: argparse.Namespace) -> int:
1317
1417
  requested_toolchain_hash=getattr(args, "toolchain_hash", None),
1318
1418
  requested_profile_hash=getattr(args, "profile_hash", None),
1319
1419
  requested_environment_hash=getattr(args, "environment_hash", None),
1420
+ requested_db_schema_hash=getattr(args, "db_schema_hash", None),
1320
1421
  )
1321
1422
  except (OSError, ValueError, json.JSONDecodeError) as exc:
1322
1423
  return emit_error(f"can-reuse failed: {exc}", as_json=as_json)
1323
1424
 
1425
+ payload = _attach_invalidation_code(payload)
1324
1426
  emit_compact_or_verbose(
1325
1427
  payload, as_json=as_json, verbose=verbose,
1326
1428
  compact_fn=_compact_can_reuse_payload,
@@ -1457,6 +1559,7 @@ def cmd_record(args: argparse.Namespace) -> int:
1457
1559
  ("toolchainHash", "toolchain_hash"),
1458
1560
  ("profileHash", "profile_hash"),
1459
1561
  ("environmentHash", "environment_hash"),
1562
+ ("dbSchemaHash", "db_schema_hash"),
1460
1563
  ):
1461
1564
  val = getattr(args, attr, None)
1462
1565
  if _nonempty_str(val):
@@ -1535,6 +1638,37 @@ def cmd_record(args: argparse.Namespace) -> int:
1535
1638
  extra={"missing": missing},
1536
1639
  )
1537
1640
  write_ledger(out_path, ledger)
1641
+ try:
1642
+ upsert_verification_graph_node(
1643
+ change_dir,
1644
+ verification=verification,
1645
+ status=str(status),
1646
+ inputs_hash=str(inputs_hash),
1647
+ command=str(entry.get("command") or "") or None,
1648
+ toolchain_hash=(
1649
+ str(entry.get("toolchainHash")).strip()
1650
+ if _nonempty_str(entry.get("toolchainHash"))
1651
+ else None
1652
+ ),
1653
+ environment_hash=(
1654
+ str(entry.get("environmentHash")).strip()
1655
+ if _nonempty_str(entry.get("environmentHash"))
1656
+ else None
1657
+ ),
1658
+ db_schema_hash=(
1659
+ str(entry.get("dbSchemaHash")).strip()
1660
+ if _nonempty_str(entry.get("dbSchemaHash"))
1661
+ else None
1662
+ ),
1663
+ diff_hash=(
1664
+ str(ledger.get("diffHash")).strip()
1665
+ if _nonempty_str(ledger.get("diffHash"))
1666
+ else None
1667
+ ),
1668
+ )
1669
+ except (OSError, ValueError, TypeError):
1670
+ # Graph is advisory persistence; ledger write already succeeded.
1671
+ pass
1538
1672
  except (OSError, ValueError, FileNotFoundError, json.JSONDecodeError) as exc:
1539
1673
  return emit_error(f"record failed: {exc}", as_json=as_json)
1540
1674
 
@@ -1654,6 +1788,11 @@ def build_parser() -> argparse.ArgumentParser:
1654
1788
  default=None,
1655
1789
  help="optional environment hash to compare against ledger entry (UT-017)",
1656
1790
  )
1791
+ p_reuse.add_argument(
1792
+ "--db-schema-hash",
1793
+ default=None,
1794
+ help="optional DB schema hash to compare against ledger entry (H-9)",
1795
+ )
1657
1796
  p_reuse.add_argument(
1658
1797
  "--verbose",
1659
1798
  action="store_true",
@@ -1695,6 +1834,7 @@ def build_parser() -> argparse.ArgumentParser:
1695
1834
  p_record.add_argument("--toolchain-hash", default=None)
1696
1835
  p_record.add_argument("--profile-hash", default=None)
1697
1836
  p_record.add_argument("--environment-hash", default=None)
1837
+ p_record.add_argument("--db-schema-hash", default=None)
1698
1838
  p_record.add_argument("--deploy-artifact", default=None, help="package: built artifact path")
1699
1839
  p_record.add_argument("--artifact-hash", default=None, help="package: artifact sha256")
1700
1840
  p_record.add_argument(