@hunter-harness/workflow-harness 0.2.20 → 0.2.22

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 (202) 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-archive/checklist.md +14 -0
  5. package/harness/bundles/general/claude-code/harness-archive/templates/render-summary.mjs +24 -3
  6. package/harness/bundles/general/claude-code/harness-codebase-map/SKILL.md +1 -1
  7. package/harness/bundles/general/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  8. package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +1 -1
  9. package/harness/bundles/general/claude-code/harness-plan/SKILL.md +1 -1
  10. package/harness/bundles/general/claude-code/harness-review/SKILL.md +1 -1
  11. package/harness/bundles/general/claude-code/harness-run/SKILL.md +1 -1
  12. package/harness/bundles/general/claude-code/harness-run/checklist.md +6 -0
  13. package/harness/bundles/general/claude-code/harness-submit/SKILL.md +1 -1
  14. package/harness/bundles/general/claude-code/harness-submit/checklist.md +12 -0
  15. package/harness/bundles/general/claude-code/harness-sync/SKILL.md +1 -1
  16. package/harness/bundles/general/claude-code/harness-test/SKILL.md +1 -1
  17. package/harness/bundles/general/claude-code/harness-test/checklist.md +6 -0
  18. package/harness/bundles/general/claude-code/protocols/model-routing-protocol.md +22 -0
  19. package/harness/bundles/general/claude-code/scripts/harness_archive.py +606 -28
  20. package/harness/bundles/general/claude-code/scripts/harness_environment.py +459 -0
  21. package/harness/bundles/general/claude-code/scripts/harness_events.py +252 -15
  22. package/harness/bundles/general/claude-code/scripts/harness_integration.py +34 -0
  23. package/harness/bundles/general/claude-code/scripts/harness_ledger.py +127 -0
  24. package/harness/bundles/general/claude-code/scripts/harness_migration_head.py +220 -0
  25. package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
  26. package/harness/bundles/general/codebuddy/CONTEXT.md +20 -0
  27. package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +1 -1
  28. package/harness/bundles/general/codebuddy/harness-archive/checklist.md +14 -0
  29. package/harness/bundles/general/codebuddy/harness-archive/templates/render-summary.mjs +24 -3
  30. package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
  31. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  32. package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  33. package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +1 -1
  34. package/harness/bundles/general/codebuddy/harness-review/SKILL.md +1 -1
  35. package/harness/bundles/general/codebuddy/harness-run/SKILL.md +1 -1
  36. package/harness/bundles/general/codebuddy/harness-run/checklist.md +6 -0
  37. package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +1 -1
  38. package/harness/bundles/general/codebuddy/harness-submit/checklist.md +12 -0
  39. package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +1 -1
  40. package/harness/bundles/general/codebuddy/harness-test/SKILL.md +1 -1
  41. package/harness/bundles/general/codebuddy/harness-test/checklist.md +6 -0
  42. package/harness/bundles/general/codebuddy/protocols/model-routing-protocol.md +22 -0
  43. package/harness/bundles/general/codebuddy/scripts/harness_archive.py +606 -28
  44. package/harness/bundles/general/codebuddy/scripts/harness_environment.py +459 -0
  45. package/harness/bundles/general/codebuddy/scripts/harness_events.py +252 -15
  46. package/harness/bundles/general/codebuddy/scripts/harness_integration.py +34 -0
  47. package/harness/bundles/general/codebuddy/scripts/harness_ledger.py +127 -0
  48. package/harness/bundles/general/codebuddy/scripts/harness_migration_head.py +220 -0
  49. package/harness/bundles/general/codex/.harness-build.json +1 -1
  50. package/harness/bundles/general/codex/CONTEXT.md +20 -0
  51. package/harness/bundles/general/codex/harness-archive/SKILL.md +1 -1
  52. package/harness/bundles/general/codex/harness-archive/checklist.md +14 -0
  53. package/harness/bundles/general/codex/harness-archive/templates/render-summary.mjs +24 -3
  54. package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
  55. package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +1 -1
  56. package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
  57. package/harness/bundles/general/codex/harness-plan/SKILL.md +1 -1
  58. package/harness/bundles/general/codex/harness-review/SKILL.md +1 -1
  59. package/harness/bundles/general/codex/harness-run/SKILL.md +1 -1
  60. package/harness/bundles/general/codex/harness-run/checklist.md +6 -0
  61. package/harness/bundles/general/codex/harness-submit/SKILL.md +1 -1
  62. package/harness/bundles/general/codex/harness-submit/checklist.md +12 -0
  63. package/harness/bundles/general/codex/harness-sync/SKILL.md +1 -1
  64. package/harness/bundles/general/codex/harness-test/SKILL.md +1 -1
  65. package/harness/bundles/general/codex/harness-test/checklist.md +6 -0
  66. package/harness/bundles/general/codex/protocols/model-routing-protocol.md +22 -0
  67. package/harness/bundles/general/codex/scripts/harness_archive.py +606 -28
  68. package/harness/bundles/general/codex/scripts/harness_environment.py +459 -0
  69. package/harness/bundles/general/codex/scripts/harness_events.py +252 -15
  70. package/harness/bundles/general/codex/scripts/harness_integration.py +34 -0
  71. package/harness/bundles/general/codex/scripts/harness_ledger.py +127 -0
  72. package/harness/bundles/general/codex/scripts/harness_migration_head.py +220 -0
  73. package/harness/bundles/general/cursor/.harness-build.json +1 -1
  74. package/harness/bundles/general/cursor/CONTEXT.md +20 -0
  75. package/harness/bundles/general/cursor/harness-archive/SKILL.md +1 -1
  76. package/harness/bundles/general/cursor/harness-archive/checklist.md +14 -0
  77. package/harness/bundles/general/cursor/harness-archive/templates/render-summary.mjs +24 -3
  78. package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
  79. package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  80. package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
  81. package/harness/bundles/general/cursor/harness-plan/SKILL.md +1 -1
  82. package/harness/bundles/general/cursor/harness-review/SKILL.md +1 -1
  83. package/harness/bundles/general/cursor/harness-run/SKILL.md +1 -1
  84. package/harness/bundles/general/cursor/harness-run/checklist.md +6 -0
  85. package/harness/bundles/general/cursor/harness-submit/SKILL.md +1 -1
  86. package/harness/bundles/general/cursor/harness-submit/checklist.md +12 -0
  87. package/harness/bundles/general/cursor/harness-sync/SKILL.md +1 -1
  88. package/harness/bundles/general/cursor/harness-test/SKILL.md +1 -1
  89. package/harness/bundles/general/cursor/harness-test/checklist.md +6 -0
  90. package/harness/bundles/general/cursor/protocols/model-routing-protocol.md +22 -0
  91. package/harness/bundles/general/cursor/scripts/harness_archive.py +606 -28
  92. package/harness/bundles/general/cursor/scripts/harness_environment.py +459 -0
  93. package/harness/bundles/general/cursor/scripts/harness_events.py +252 -15
  94. package/harness/bundles/general/cursor/scripts/harness_integration.py +34 -0
  95. package/harness/bundles/general/cursor/scripts/harness_ledger.py +127 -0
  96. package/harness/bundles/general/cursor/scripts/harness_migration_head.py +220 -0
  97. package/harness/bundles/java/claude-code/.harness-build.json +1 -1
  98. package/harness/bundles/java/claude-code/CONTEXT.md +20 -0
  99. package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +1 -1
  100. package/harness/bundles/java/claude-code/harness-archive/SKILL.md +1 -1
  101. package/harness/bundles/java/claude-code/harness-archive/checklist.md +14 -0
  102. package/harness/bundles/java/claude-code/harness-archive/templates/render-summary.mjs +24 -3
  103. package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
  104. package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  105. package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
  106. package/harness/bundles/java/claude-code/harness-package/SKILL.md +1 -1
  107. package/harness/bundles/java/claude-code/harness-plan/SKILL.md +1 -1
  108. package/harness/bundles/java/claude-code/harness-review/SKILL.md +1 -1
  109. package/harness/bundles/java/claude-code/harness-run/SKILL.md +1 -1
  110. package/harness/bundles/java/claude-code/harness-submit/SKILL.md +1 -1
  111. package/harness/bundles/java/claude-code/harness-submit/checklist.md +12 -0
  112. package/harness/bundles/java/claude-code/harness-sync/SKILL.md +1 -1
  113. package/harness/bundles/java/claude-code/harness-test/SKILL.md +1 -1
  114. package/harness/bundles/java/claude-code/protocols/model-routing-protocol.md +22 -0
  115. package/harness/bundles/java/claude-code/scripts/harness_archive.py +606 -28
  116. package/harness/bundles/java/claude-code/scripts/harness_environment.py +459 -0
  117. package/harness/bundles/java/claude-code/scripts/harness_events.py +252 -15
  118. package/harness/bundles/java/claude-code/scripts/harness_integration.py +34 -0
  119. package/harness/bundles/java/claude-code/scripts/harness_ledger.py +127 -0
  120. package/harness/bundles/java/claude-code/scripts/harness_migration_head.py +220 -0
  121. package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
  122. package/harness/bundles/java/codebuddy/CONTEXT.md +20 -0
  123. package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +1 -1
  124. package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +1 -1
  125. package/harness/bundles/java/codebuddy/harness-archive/checklist.md +14 -0
  126. package/harness/bundles/java/codebuddy/harness-archive/templates/render-summary.mjs +24 -3
  127. package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
  128. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  129. package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  130. package/harness/bundles/java/codebuddy/harness-package/SKILL.md +1 -1
  131. package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +1 -1
  132. package/harness/bundles/java/codebuddy/harness-review/SKILL.md +1 -1
  133. package/harness/bundles/java/codebuddy/harness-run/SKILL.md +1 -1
  134. package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +1 -1
  135. package/harness/bundles/java/codebuddy/harness-submit/checklist.md +12 -0
  136. package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +1 -1
  137. package/harness/bundles/java/codebuddy/harness-test/SKILL.md +1 -1
  138. package/harness/bundles/java/codebuddy/protocols/model-routing-protocol.md +22 -0
  139. package/harness/bundles/java/codebuddy/scripts/harness_archive.py +606 -28
  140. package/harness/bundles/java/codebuddy/scripts/harness_environment.py +459 -0
  141. package/harness/bundles/java/codebuddy/scripts/harness_events.py +252 -15
  142. package/harness/bundles/java/codebuddy/scripts/harness_integration.py +34 -0
  143. package/harness/bundles/java/codebuddy/scripts/harness_ledger.py +127 -0
  144. package/harness/bundles/java/codebuddy/scripts/harness_migration_head.py +220 -0
  145. package/harness/bundles/java/codex/.harness-build.json +1 -1
  146. package/harness/bundles/java/codex/CONTEXT.md +20 -0
  147. package/harness/bundles/java/codex/harness-apidoc/SKILL.md +1 -1
  148. package/harness/bundles/java/codex/harness-archive/SKILL.md +1 -1
  149. package/harness/bundles/java/codex/harness-archive/checklist.md +14 -0
  150. package/harness/bundles/java/codex/harness-archive/templates/render-summary.mjs +24 -3
  151. package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
  152. package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +1 -1
  153. package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
  154. package/harness/bundles/java/codex/harness-package/SKILL.md +1 -1
  155. package/harness/bundles/java/codex/harness-plan/SKILL.md +1 -1
  156. package/harness/bundles/java/codex/harness-review/SKILL.md +1 -1
  157. package/harness/bundles/java/codex/harness-run/SKILL.md +1 -1
  158. package/harness/bundles/java/codex/harness-submit/SKILL.md +1 -1
  159. package/harness/bundles/java/codex/harness-submit/checklist.md +12 -0
  160. package/harness/bundles/java/codex/harness-sync/SKILL.md +1 -1
  161. package/harness/bundles/java/codex/harness-test/SKILL.md +1 -1
  162. package/harness/bundles/java/codex/protocols/model-routing-protocol.md +22 -0
  163. package/harness/bundles/java/codex/scripts/harness_archive.py +606 -28
  164. package/harness/bundles/java/codex/scripts/harness_environment.py +459 -0
  165. package/harness/bundles/java/codex/scripts/harness_events.py +252 -15
  166. package/harness/bundles/java/codex/scripts/harness_integration.py +34 -0
  167. package/harness/bundles/java/codex/scripts/harness_ledger.py +127 -0
  168. package/harness/bundles/java/codex/scripts/harness_migration_head.py +220 -0
  169. package/harness/bundles/java/cursor/.harness-build.json +1 -1
  170. package/harness/bundles/java/cursor/CONTEXT.md +20 -0
  171. package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +1 -1
  172. package/harness/bundles/java/cursor/harness-archive/SKILL.md +1 -1
  173. package/harness/bundles/java/cursor/harness-archive/checklist.md +14 -0
  174. package/harness/bundles/java/cursor/harness-archive/templates/render-summary.mjs +24 -3
  175. package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
  176. package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  177. package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
  178. package/harness/bundles/java/cursor/harness-package/SKILL.md +1 -1
  179. package/harness/bundles/java/cursor/harness-plan/SKILL.md +1 -1
  180. package/harness/bundles/java/cursor/harness-review/SKILL.md +1 -1
  181. package/harness/bundles/java/cursor/harness-run/SKILL.md +1 -1
  182. package/harness/bundles/java/cursor/harness-submit/SKILL.md +1 -1
  183. package/harness/bundles/java/cursor/harness-submit/checklist.md +12 -0
  184. package/harness/bundles/java/cursor/harness-sync/SKILL.md +1 -1
  185. package/harness/bundles/java/cursor/harness-test/SKILL.md +1 -1
  186. package/harness/bundles/java/cursor/protocols/model-routing-protocol.md +22 -0
  187. package/harness/bundles/java/cursor/scripts/harness_archive.py +606 -28
  188. package/harness/bundles/java/cursor/scripts/harness_environment.py +459 -0
  189. package/harness/bundles/java/cursor/scripts/harness_events.py +252 -15
  190. package/harness/bundles/java/cursor/scripts/harness_integration.py +34 -0
  191. package/harness/bundles/java/cursor/scripts/harness_ledger.py +127 -0
  192. package/harness/bundles/java/cursor/scripts/harness_migration_head.py +220 -0
  193. package/harness/manifests/general/claude-code.json +33 -21
  194. package/harness/manifests/general/codebuddy.json +33 -21
  195. package/harness/manifests/general/codex.json +33 -21
  196. package/harness/manifests/general/cursor.json +33 -21
  197. package/harness/manifests/java/claude-code.json +33 -21
  198. package/harness/manifests/java/codebuddy.json +33 -21
  199. package/harness/manifests/java/codex.json +33 -21
  200. package/harness/manifests/java/cursor.json +33 -21
  201. package/hunter-workflow-family.json +1 -1
  202. 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,
@@ -842,28 +968,89 @@ def late_event_stats(phase_events: list[dict[str, Any]]) -> dict[str, int]:
842
968
  return {"lateEventCount": len(late_stamps), "lateEventSpanMs": span}
843
969
 
844
970
 
845
- def attempt_invocations(phase_events: list[dict[str, Any]]) -> list[dict[str, Any]]:
846
- """Per-attempt invocation view: attempt, status, durationMs (RET-22)."""
971
+ def _seal_timestamp_for_attempt(
972
+ events_in: list[dict[str, Any]],
973
+ *,
974
+ cutoff_ts: str | None,
975
+ next_attempt_start: Any = None,
976
+ ) -> tuple[Any, str | None]:
977
+ """Pick INCOMPLETE seal time: next start, recovery, or report cutoff."""
978
+ recovery_ts = None
979
+ for event in events_in:
980
+ if str(event.get("type") or "").lower() in {
981
+ "recovery",
982
+ "phase.recovery",
983
+ "attempt.recovery",
984
+ }:
985
+ recovery_ts = event.get("timestamp") or recovery_ts
986
+ if next_attempt_start:
987
+ return next_attempt_start, "INCOMPLETE"
988
+ if recovery_ts:
989
+ return recovery_ts, "INCOMPLETE"
990
+ if cutoff_ts:
991
+ return cutoff_ts, "INCOMPLETE"
992
+ # Fall back to last event timestamp so wall clock is not silently dropped.
993
+ for event in reversed(events_in):
994
+ if event.get("timestamp"):
995
+ return event.get("timestamp"), "INCOMPLETE"
996
+ return None, "INCOMPLETE"
997
+
998
+
999
+ def attempt_invocations(
1000
+ phase_events: list[dict[str, Any]],
1001
+ *,
1002
+ cutoff_ts: str | None = None,
1003
+ ) -> list[dict[str, Any]]:
1004
+ """Per-attempt invocation view: attempt, status, durationMs (RET-22 / IA-2).
1005
+
1006
+ Closed attempts keep phase.end status. Unclosed attempts are sealed as
1007
+ INCOMPLETE at the next attempt start, an explicit recovery event, or cutoff.
1008
+ """
1009
+ attempts = split_phase_attempts(phase_events)
847
1010
  invocations: list[dict[str, Any]] = []
848
- for attempt in split_phase_attempts(phase_events):
1011
+ for index, attempt in enumerate(attempts):
849
1012
  events_in = attempt.get("events") or []
850
1013
  start_ts = None
851
1014
  end_ts = None
852
1015
  status = None
1016
+ closed_by_end = False
853
1017
  for event in events_in:
854
1018
  if event.get("type") == "phase.start" and start_ts is None:
855
1019
  start_ts = event.get("timestamp")
856
1020
  elif event.get("type") == "phase.end":
857
1021
  end_ts = event.get("timestamp")
858
- status = event.get("status", status)
1022
+ if "status" in event:
1023
+ status = event.get("status")
1024
+ closed_by_end = True
1025
+ sealed_incomplete = False
1026
+ if not closed_by_end:
1027
+ next_start = None
1028
+ if index + 1 < len(attempts):
1029
+ for event in attempts[index + 1].get("events") or []:
1030
+ if event.get("type") == "phase.start" and event.get("timestamp"):
1031
+ next_start = event.get("timestamp")
1032
+ break
1033
+ seal_ts, seal_status = _seal_timestamp_for_attempt(
1034
+ events_in,
1035
+ cutoff_ts=cutoff_ts,
1036
+ next_attempt_start=next_start,
1037
+ )
1038
+ if seal_ts is not None:
1039
+ end_ts = seal_ts
1040
+ status = seal_status
1041
+ sealed_incomplete = True
859
1042
  duration = None
860
1043
  if start_ts and end_ts:
861
1044
  duration = duration_ms_between(start_ts, end_ts)
1045
+ # Active execution counts phase.end-closed attempts only (INCOMPLETE seals excluded).
1046
+ active_eligible = closed_by_end and not sealed_incomplete
862
1047
  invocations.append(
863
1048
  {
864
1049
  "attempt": attempt.get("attempt"),
865
1050
  "status": status,
866
1051
  "durationMs": duration,
1052
+ "activeEligible": active_eligible,
1053
+ "sealedIncomplete": sealed_incomplete,
867
1054
  }
868
1055
  )
869
1056
  return invocations
@@ -879,30 +1066,48 @@ def phase_final_state(phase_events: list[dict[str, Any]]) -> dict[str, Any]:
879
1066
  "attempt": latest.get("attempt"),
880
1067
  "status": latest.get("status"),
881
1068
  "durationMs": latest.get("durationMs"),
882
- "closed": latest.get("status") is not None,
1069
+ "closed": latest.get("status") is not None and latest.get("status") != "INCOMPLETE",
883
1070
  }
884
1071
 
885
1072
 
886
- def canonical_phase_timing(phase_events: list[dict[str, Any]]) -> dict[str, Any]:
887
- """Single reducer for every duration view (RET-20).
1073
+ def canonical_phase_timing(
1074
+ phase_events: list[dict[str, Any]],
1075
+ *,
1076
+ cutoff_ts: str | None = None,
1077
+ ) -> dict[str, Any]:
1078
+ """Single reducer for every duration view (RET-20 / IA-2).
888
1079
 
889
- activeExecutionMs: phase.start matching phase.end (closed contract).
890
- wallClockSpanMs: first → last timestamp in the bucket, late events included.
1080
+ activeExecutionMs: sum of closed attempt start→end only (INCOMPLETE excluded).
1081
+ wallClockSpanMs: first → last/cutoff timestamp; unclosed attempts retain span.
891
1082
  """
1083
+ invocations = attempt_invocations(phase_events, cutoff_ts=cutoff_ts)
892
1084
  stamps = [e.get("timestamp") for e in phase_events if e.get("timestamp")]
893
- invocation_durations = [
1085
+ # Cutoff seals INCOMPLETE attempts but must not inflate stage wall by itself.
1086
+ # Include cutoff in the wall span only when it sealed an otherwise-open attempt.
1087
+ if cutoff_ts and any(inv.get("sealedIncomplete") for inv in invocations):
1088
+ stamps = list(stamps) + [cutoff_ts]
1089
+ closed_durations = [
894
1090
  invocation["durationMs"]
895
- for invocation in attempt_invocations(phase_events)
896
- if invocation.get("durationMs") is not None
1091
+ for invocation in invocations
1092
+ if invocation.get("activeEligible") and invocation.get("durationMs") is not None
897
1093
  ]
898
- active = sum(invocation_durations) if invocation_durations else None
1094
+ active = sum(closed_durations) if closed_durations else (0 if invocations else None)
899
1095
  late = late_event_stats(phase_events)
900
- wall = duration_ms_between(stamps[0], stamps[-1]) if len(stamps) >= 2 else active
1096
+ if len(stamps) >= 2:
1097
+ wall = duration_ms_between(stamps[0], stamps[-1])
1098
+ else:
1099
+ wall = active
1100
+ unclosed = sum(
1101
+ 1
1102
+ for invocation in invocations
1103
+ if invocation.get("sealedIncomplete") or invocation.get("status") == "INCOMPLETE"
1104
+ )
901
1105
  return {
902
1106
  "activeExecutionMs": active,
903
1107
  "wallClockSpanMs": wall,
904
1108
  "lateEventCount": late["lateEventCount"],
905
1109
  "lateEventSpanMs": late["lateEventSpanMs"],
1110
+ "unclosedAttemptCount": unclosed,
906
1111
  }
907
1112
 
908
1113
 
@@ -1295,6 +1500,25 @@ def cmd_append(args: argparse.Namespace) -> int:
1295
1500
  return 0
1296
1501
 
1297
1502
 
1503
+ def cmd_batch_append(args: argparse.Namespace) -> int:
1504
+ as_json = bool(args.json)
1505
+ change_dir = resolve_change_dir(args.change_dir)
1506
+ batch_path = Path(str(args.file)).expanduser()
1507
+ try:
1508
+ raw = json.loads(batch_path.read_text(encoding="utf-8-sig"))
1509
+ except (OSError, json.JSONDecodeError) as exc:
1510
+ return emit_error(f"batch-append failed to read --file: {exc}", as_json=as_json)
1511
+ result = batch_append_events(change_dir, raw if isinstance(raw, list) else [])
1512
+ if not result.get("ok"):
1513
+ return emit_error(
1514
+ str(result.get("message") or "batch-append failed"),
1515
+ as_json=as_json,
1516
+ error_code=str(result.get("code") or "BATCH_APPEND_FAILED"),
1517
+ )
1518
+ emit_json(result, as_json=as_json)
1519
+ return 0
1520
+
1521
+
1298
1522
  def cmd_render(args: argparse.Namespace) -> int:
1299
1523
  as_json = bool(args.json)
1300
1524
  change_dir = resolve_change_dir(args.change_dir)
@@ -1395,6 +1619,19 @@ def build_parser() -> argparse.ArgumentParser:
1395
1619
  )
1396
1620
  p_append.set_defaults(func=cmd_append)
1397
1621
 
1622
+ p_batch = sub.add_parser(
1623
+ "batch-append",
1624
+ parents=[common],
1625
+ help="validate and append many events under one lock (H-15)",
1626
+ )
1627
+ p_batch.add_argument("--change-dir", required=True)
1628
+ p_batch.add_argument(
1629
+ "--file",
1630
+ required=True,
1631
+ help="JSON file containing a non-empty array of event objects",
1632
+ )
1633
+ p_batch.set_defaults(func=cmd_batch_append)
1634
+
1398
1635
  p_render = sub.add_parser(
1399
1636
  "render",
1400
1637
  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(
@@ -1055,6 +1055,7 @@ def decide_can_reuse(
1055
1055
  requested_toolchain_hash: str | None = None,
1056
1056
  requested_profile_hash: str | None = None,
1057
1057
  requested_environment_hash: str | None = None,
1058
+ requested_db_schema_hash: str | None = None,
1058
1059
  ) -> dict[str, Any]:
1059
1060
  ledger, ledger_path = load_ledger(change_dir)
1060
1061
  if ledger is None:
@@ -1183,6 +1184,7 @@ def decide_can_reuse(
1183
1184
  ("toolchainHash", requested_toolchain_hash, "TOOLCHAIN_CHANGED"),
1184
1185
  ("profileHash", requested_profile_hash, "PROFILE_CHANGED"),
1185
1186
  ("environmentHash", requested_environment_hash, "ENVIRONMENT_CHANGED"),
1187
+ ("dbSchemaHash", requested_db_schema_hash, "DB_SCHEMA_CHANGED"),
1186
1188
  ):
1187
1189
  stored = entry.get(field)
1188
1190
  if (
@@ -1249,6 +1251,7 @@ def decide_can_reuse(
1249
1251
  "reuse": True,
1250
1252
  "reason": "reuse",
1251
1253
  "code": "REUSED",
1254
+ "invalidationCode": None,
1252
1255
  "verification": verification,
1253
1256
  "ledger_path": str(ledger_path) if ledger_path else None,
1254
1257
  "inputsHash": stored_hash,
@@ -1258,6 +1261,90 @@ def decide_can_reuse(
1258
1261
  }
1259
1262
 
1260
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
+
1261
1348
  def cmd_hash(args: argparse.Namespace) -> int:
1262
1349
  as_json = bool(args.json)
1263
1350
  try:
@@ -1330,10 +1417,12 @@ def cmd_can_reuse(args: argparse.Namespace) -> int:
1330
1417
  requested_toolchain_hash=getattr(args, "toolchain_hash", None),
1331
1418
  requested_profile_hash=getattr(args, "profile_hash", None),
1332
1419
  requested_environment_hash=getattr(args, "environment_hash", None),
1420
+ requested_db_schema_hash=getattr(args, "db_schema_hash", None),
1333
1421
  )
1334
1422
  except (OSError, ValueError, json.JSONDecodeError) as exc:
1335
1423
  return emit_error(f"can-reuse failed: {exc}", as_json=as_json)
1336
1424
 
1425
+ payload = _attach_invalidation_code(payload)
1337
1426
  emit_compact_or_verbose(
1338
1427
  payload, as_json=as_json, verbose=verbose,
1339
1428
  compact_fn=_compact_can_reuse_payload,
@@ -1470,6 +1559,7 @@ def cmd_record(args: argparse.Namespace) -> int:
1470
1559
  ("toolchainHash", "toolchain_hash"),
1471
1560
  ("profileHash", "profile_hash"),
1472
1561
  ("environmentHash", "environment_hash"),
1562
+ ("dbSchemaHash", "db_schema_hash"),
1473
1563
  ):
1474
1564
  val = getattr(args, attr, None)
1475
1565
  if _nonempty_str(val):
@@ -1548,6 +1638,37 @@ def cmd_record(args: argparse.Namespace) -> int:
1548
1638
  extra={"missing": missing},
1549
1639
  )
1550
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
1551
1672
  except (OSError, ValueError, FileNotFoundError, json.JSONDecodeError) as exc:
1552
1673
  return emit_error(f"record failed: {exc}", as_json=as_json)
1553
1674
 
@@ -1667,6 +1788,11 @@ def build_parser() -> argparse.ArgumentParser:
1667
1788
  default=None,
1668
1789
  help="optional environment hash to compare against ledger entry (UT-017)",
1669
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
+ )
1670
1796
  p_reuse.add_argument(
1671
1797
  "--verbose",
1672
1798
  action="store_true",
@@ -1708,6 +1834,7 @@ def build_parser() -> argparse.ArgumentParser:
1708
1834
  p_record.add_argument("--toolchain-hash", default=None)
1709
1835
  p_record.add_argument("--profile-hash", default=None)
1710
1836
  p_record.add_argument("--environment-hash", default=None)
1837
+ p_record.add_argument("--db-schema-hash", default=None)
1711
1838
  p_record.add_argument("--deploy-artifact", default=None, help="package: built artifact path")
1712
1839
  p_record.add_argument("--artifact-hash", default=None, help="package: artifact sha256")
1713
1840
  p_record.add_argument(