@hunter-harness/workflow-harness 0.2.38 → 0.2.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (194) hide show
  1. package/harness/bundles/general/claude-code/.harness-build.json +1 -1
  2. package/harness/bundles/general/claude-code/harness-archive/SKILL.md +10 -7
  3. package/harness/bundles/general/claude-code/harness-archive/templates/render-summary.mjs +24 -4
  4. package/harness/bundles/general/claude-code/harness-codebase-map/SKILL.md +1 -1
  5. package/harness/bundles/general/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  6. package/harness/bundles/general/claude-code/harness-knowledge-ingest/scripts/harness_knowledge.py +283 -28
  7. package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +1 -1
  8. package/harness/bundles/general/claude-code/harness-plan/SKILL.md +1 -1
  9. package/harness/bundles/general/claude-code/harness-review/SKILL.md +2 -2
  10. package/harness/bundles/general/claude-code/harness-review/reference.md +20 -5
  11. package/harness/bundles/general/claude-code/harness-run/SKILL.md +1 -1
  12. package/harness/bundles/general/claude-code/harness-submit/SKILL.md +1 -1
  13. package/harness/bundles/general/claude-code/harness-sync/SKILL.md +1 -1
  14. package/harness/bundles/general/claude-code/harness-test/SKILL.md +1 -1
  15. package/harness/bundles/general/claude-code/scripts/harness_archive.py +446 -20
  16. package/harness/bundles/general/claude-code/scripts/harness_events.py +273 -21
  17. package/harness/bundles/general/claude-code/scripts/harness_gate.py +56 -32
  18. package/harness/bundles/general/claude-code/scripts/harness_integration.py +197 -3
  19. package/harness/bundles/general/claude-code/scripts/harness_ledger.py +126 -2
  20. package/harness/bundles/general/claude-code/scripts/harness_report_model.py +28 -6
  21. package/harness/bundles/general/claude-code/scripts/harness_review.py +118 -17
  22. package/harness/bundles/general/claude-code/scripts/harness_service.py +219 -0
  23. package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
  24. package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +10 -7
  25. package/harness/bundles/general/codebuddy/harness-archive/templates/render-summary.mjs +24 -4
  26. package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
  27. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  28. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge.py +283 -28
  29. package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  30. package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +1 -1
  31. package/harness/bundles/general/codebuddy/harness-review/SKILL.md +2 -2
  32. package/harness/bundles/general/codebuddy/harness-review/reference.md +20 -5
  33. package/harness/bundles/general/codebuddy/harness-run/SKILL.md +1 -1
  34. package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +1 -1
  35. package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +1 -1
  36. package/harness/bundles/general/codebuddy/harness-test/SKILL.md +1 -1
  37. package/harness/bundles/general/codebuddy/scripts/harness_archive.py +446 -20
  38. package/harness/bundles/general/codebuddy/scripts/harness_events.py +273 -21
  39. package/harness/bundles/general/codebuddy/scripts/harness_gate.py +56 -32
  40. package/harness/bundles/general/codebuddy/scripts/harness_integration.py +197 -3
  41. package/harness/bundles/general/codebuddy/scripts/harness_ledger.py +126 -2
  42. package/harness/bundles/general/codebuddy/scripts/harness_report_model.py +28 -6
  43. package/harness/bundles/general/codebuddy/scripts/harness_review.py +118 -17
  44. package/harness/bundles/general/codebuddy/scripts/harness_service.py +219 -0
  45. package/harness/bundles/general/codex/.harness-build.json +1 -1
  46. package/harness/bundles/general/codex/harness-archive/SKILL.md +10 -7
  47. package/harness/bundles/general/codex/harness-archive/templates/render-summary.mjs +24 -4
  48. package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
  49. package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +1 -1
  50. package/harness/bundles/general/codex/harness-knowledge-ingest/scripts/harness_knowledge.py +283 -28
  51. package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
  52. package/harness/bundles/general/codex/harness-plan/SKILL.md +1 -1
  53. package/harness/bundles/general/codex/harness-review/SKILL.md +2 -2
  54. package/harness/bundles/general/codex/harness-review/reference.md +20 -5
  55. package/harness/bundles/general/codex/harness-run/SKILL.md +1 -1
  56. package/harness/bundles/general/codex/harness-submit/SKILL.md +1 -1
  57. package/harness/bundles/general/codex/harness-sync/SKILL.md +1 -1
  58. package/harness/bundles/general/codex/harness-test/SKILL.md +1 -1
  59. package/harness/bundles/general/codex/scripts/harness_archive.py +446 -20
  60. package/harness/bundles/general/codex/scripts/harness_events.py +273 -21
  61. package/harness/bundles/general/codex/scripts/harness_gate.py +56 -32
  62. package/harness/bundles/general/codex/scripts/harness_integration.py +197 -3
  63. package/harness/bundles/general/codex/scripts/harness_ledger.py +126 -2
  64. package/harness/bundles/general/codex/scripts/harness_report_model.py +28 -6
  65. package/harness/bundles/general/codex/scripts/harness_review.py +118 -17
  66. package/harness/bundles/general/codex/scripts/harness_service.py +219 -0
  67. package/harness/bundles/general/cursor/.harness-build.json +1 -1
  68. package/harness/bundles/general/cursor/harness-archive/SKILL.md +10 -7
  69. package/harness/bundles/general/cursor/harness-archive/templates/render-summary.mjs +24 -4
  70. package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
  71. package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  72. package/harness/bundles/general/cursor/harness-knowledge-ingest/scripts/harness_knowledge.py +283 -28
  73. package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
  74. package/harness/bundles/general/cursor/harness-plan/SKILL.md +1 -1
  75. package/harness/bundles/general/cursor/harness-review/SKILL.md +2 -2
  76. package/harness/bundles/general/cursor/harness-review/reference.md +20 -5
  77. package/harness/bundles/general/cursor/harness-run/SKILL.md +1 -1
  78. package/harness/bundles/general/cursor/harness-submit/SKILL.md +1 -1
  79. package/harness/bundles/general/cursor/harness-sync/SKILL.md +1 -1
  80. package/harness/bundles/general/cursor/harness-test/SKILL.md +1 -1
  81. package/harness/bundles/general/cursor/scripts/harness_archive.py +446 -20
  82. package/harness/bundles/general/cursor/scripts/harness_events.py +273 -21
  83. package/harness/bundles/general/cursor/scripts/harness_gate.py +56 -32
  84. package/harness/bundles/general/cursor/scripts/harness_integration.py +197 -3
  85. package/harness/bundles/general/cursor/scripts/harness_ledger.py +126 -2
  86. package/harness/bundles/general/cursor/scripts/harness_report_model.py +28 -6
  87. package/harness/bundles/general/cursor/scripts/harness_review.py +118 -17
  88. package/harness/bundles/general/cursor/scripts/harness_service.py +219 -0
  89. package/harness/bundles/java/claude-code/.harness-build.json +1 -1
  90. package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +1 -1
  91. package/harness/bundles/java/claude-code/harness-archive/SKILL.md +10 -7
  92. package/harness/bundles/java/claude-code/harness-archive/templates/render-summary.mjs +24 -4
  93. package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
  94. package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  95. package/harness/bundles/java/claude-code/harness-knowledge-ingest/scripts/harness_knowledge.py +283 -28
  96. package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
  97. package/harness/bundles/java/claude-code/harness-package/SKILL.md +1 -1
  98. package/harness/bundles/java/claude-code/harness-plan/SKILL.md +1 -1
  99. package/harness/bundles/java/claude-code/harness-review/SKILL.md +2 -2
  100. package/harness/bundles/java/claude-code/harness-review/reference.md +20 -5
  101. package/harness/bundles/java/claude-code/harness-run/SKILL.md +1 -1
  102. package/harness/bundles/java/claude-code/harness-submit/SKILL.md +1 -1
  103. package/harness/bundles/java/claude-code/harness-sync/SKILL.md +1 -1
  104. package/harness/bundles/java/claude-code/harness-test/SKILL.md +1 -1
  105. package/harness/bundles/java/claude-code/scripts/harness_archive.py +446 -20
  106. package/harness/bundles/java/claude-code/scripts/harness_events.py +273 -21
  107. package/harness/bundles/java/claude-code/scripts/harness_gate.py +56 -32
  108. package/harness/bundles/java/claude-code/scripts/harness_integration.py +197 -3
  109. package/harness/bundles/java/claude-code/scripts/harness_ledger.py +126 -2
  110. package/harness/bundles/java/claude-code/scripts/harness_report_model.py +28 -6
  111. package/harness/bundles/java/claude-code/scripts/harness_review.py +118 -17
  112. package/harness/bundles/java/claude-code/scripts/harness_service.py +219 -0
  113. package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
  114. package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +1 -1
  115. package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +10 -7
  116. package/harness/bundles/java/codebuddy/harness-archive/templates/render-summary.mjs +24 -4
  117. package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
  118. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  119. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge.py +283 -28
  120. package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  121. package/harness/bundles/java/codebuddy/harness-package/SKILL.md +1 -1
  122. package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +1 -1
  123. package/harness/bundles/java/codebuddy/harness-review/SKILL.md +2 -2
  124. package/harness/bundles/java/codebuddy/harness-review/reference.md +20 -5
  125. package/harness/bundles/java/codebuddy/harness-run/SKILL.md +1 -1
  126. package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +1 -1
  127. package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +1 -1
  128. package/harness/bundles/java/codebuddy/harness-test/SKILL.md +1 -1
  129. package/harness/bundles/java/codebuddy/scripts/harness_archive.py +446 -20
  130. package/harness/bundles/java/codebuddy/scripts/harness_events.py +273 -21
  131. package/harness/bundles/java/codebuddy/scripts/harness_gate.py +56 -32
  132. package/harness/bundles/java/codebuddy/scripts/harness_integration.py +197 -3
  133. package/harness/bundles/java/codebuddy/scripts/harness_ledger.py +126 -2
  134. package/harness/bundles/java/codebuddy/scripts/harness_report_model.py +28 -6
  135. package/harness/bundles/java/codebuddy/scripts/harness_review.py +118 -17
  136. package/harness/bundles/java/codebuddy/scripts/harness_service.py +219 -0
  137. package/harness/bundles/java/codex/.harness-build.json +1 -1
  138. package/harness/bundles/java/codex/harness-apidoc/SKILL.md +1 -1
  139. package/harness/bundles/java/codex/harness-archive/SKILL.md +10 -7
  140. package/harness/bundles/java/codex/harness-archive/templates/render-summary.mjs +24 -4
  141. package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
  142. package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +1 -1
  143. package/harness/bundles/java/codex/harness-knowledge-ingest/scripts/harness_knowledge.py +283 -28
  144. package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
  145. package/harness/bundles/java/codex/harness-package/SKILL.md +1 -1
  146. package/harness/bundles/java/codex/harness-plan/SKILL.md +1 -1
  147. package/harness/bundles/java/codex/harness-review/SKILL.md +2 -2
  148. package/harness/bundles/java/codex/harness-review/reference.md +20 -5
  149. package/harness/bundles/java/codex/harness-run/SKILL.md +1 -1
  150. package/harness/bundles/java/codex/harness-submit/SKILL.md +1 -1
  151. package/harness/bundles/java/codex/harness-sync/SKILL.md +1 -1
  152. package/harness/bundles/java/codex/harness-test/SKILL.md +1 -1
  153. package/harness/bundles/java/codex/scripts/harness_archive.py +446 -20
  154. package/harness/bundles/java/codex/scripts/harness_events.py +273 -21
  155. package/harness/bundles/java/codex/scripts/harness_gate.py +56 -32
  156. package/harness/bundles/java/codex/scripts/harness_integration.py +197 -3
  157. package/harness/bundles/java/codex/scripts/harness_ledger.py +126 -2
  158. package/harness/bundles/java/codex/scripts/harness_report_model.py +28 -6
  159. package/harness/bundles/java/codex/scripts/harness_review.py +118 -17
  160. package/harness/bundles/java/codex/scripts/harness_service.py +219 -0
  161. package/harness/bundles/java/cursor/.harness-build.json +1 -1
  162. package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +1 -1
  163. package/harness/bundles/java/cursor/harness-archive/SKILL.md +10 -7
  164. package/harness/bundles/java/cursor/harness-archive/templates/render-summary.mjs +24 -4
  165. package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
  166. package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  167. package/harness/bundles/java/cursor/harness-knowledge-ingest/scripts/harness_knowledge.py +283 -28
  168. package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
  169. package/harness/bundles/java/cursor/harness-package/SKILL.md +1 -1
  170. package/harness/bundles/java/cursor/harness-plan/SKILL.md +1 -1
  171. package/harness/bundles/java/cursor/harness-review/SKILL.md +2 -2
  172. package/harness/bundles/java/cursor/harness-review/reference.md +20 -5
  173. package/harness/bundles/java/cursor/harness-run/SKILL.md +1 -1
  174. package/harness/bundles/java/cursor/harness-submit/SKILL.md +1 -1
  175. package/harness/bundles/java/cursor/harness-sync/SKILL.md +1 -1
  176. package/harness/bundles/java/cursor/harness-test/SKILL.md +1 -1
  177. package/harness/bundles/java/cursor/scripts/harness_archive.py +446 -20
  178. package/harness/bundles/java/cursor/scripts/harness_events.py +273 -21
  179. package/harness/bundles/java/cursor/scripts/harness_gate.py +56 -32
  180. package/harness/bundles/java/cursor/scripts/harness_integration.py +197 -3
  181. package/harness/bundles/java/cursor/scripts/harness_ledger.py +126 -2
  182. package/harness/bundles/java/cursor/scripts/harness_report_model.py +28 -6
  183. package/harness/bundles/java/cursor/scripts/harness_review.py +118 -17
  184. package/harness/bundles/java/cursor/scripts/harness_service.py +219 -0
  185. package/harness/manifests/general/claude-code.json +24 -24
  186. package/harness/manifests/general/codebuddy.json +24 -24
  187. package/harness/manifests/general/codex.json +24 -24
  188. package/harness/manifests/general/cursor.json +24 -24
  189. package/harness/manifests/java/claude-code.json +26 -26
  190. package/harness/manifests/java/codebuddy.json +26 -26
  191. package/harness/manifests/java/codex.json +26 -26
  192. package/harness/manifests/java/cursor.json +26 -26
  193. package/hunter-workflow-family.json +4 -4
  194. package/package.json +1 -1
@@ -43,6 +43,7 @@ EVENT_TYPES = frozenset(
43
43
  {
44
44
  "phase.start",
45
45
  "phase.end",
46
+ "phase.auto_sealed",
46
47
  "command",
47
48
  "verification",
48
49
  "artifact",
@@ -124,6 +125,7 @@ _EVENT_ALLOWED_FIELDS = {
124
125
  {"status", "duration_ms", "note", "reason", "issue_id"}
125
126
  )
126
127
  | _PROVENANCE_FIELDS,
128
+ "phase.auto_sealed": frozenset({"status", "reason", "note"}) | _PROVENANCE_FIELDS,
127
129
  "command": frozenset({"command", "exit_code", "duration_ms", "note"})
128
130
  | _PROVENANCE_FIELDS,
129
131
  "verification": frozenset(
@@ -164,11 +166,35 @@ _EVENT_REQUIRED_FIELDS = {
164
166
  "new_value_json",
165
167
  "reason",
166
168
  ),
169
+ "phase.auto_sealed": ("reason",),
167
170
  }
168
171
  _CORRECTION_PROTECTED_FIELDS = frozenset(
169
172
  {"schema_version", "id", "timestamp", "phase", "type"}
170
173
  )
171
174
 
175
+ # HH-WF-20260730-001: reasons a write-path auto-seal may attribute to an
176
+ # attempt that was still open when it was closed on its behalf.
177
+ _AUTO_SEAL_REASONS = frozenset(
178
+ {"executor_lost", "user_wait", "external_wait", "superseded", "unknown"}
179
+ )
180
+
181
+ # Best-effort mapping from wait-style metadata events (seen inside an open
182
+ # attempt's own event stream) to the auto-seal reason they imply.
183
+ _WAIT_EVENT_REASON_MAP = {
184
+ "user.wait": "user_wait",
185
+ "environment.wait": "external_wait",
186
+ "env.wait": "external_wait",
187
+ "external.wait": "external_wait",
188
+ "ci.wait": "external_wait",
189
+ }
190
+ _RECOVERY_EVENT_TYPES = frozenset({"recovery", "phase.recovery", "attempt.recovery"})
191
+
192
+ # Terminal event types that close a phase attempt (RET/HH-WF-20260730-001).
193
+ # ``phase.end`` is an explicit human/automation-reported terminal; the
194
+ # write path also inserts ``phase.auto_sealed`` when a new ``phase.start``
195
+ # (or another terminal path) finds a still-open prior attempt.
196
+ TERMINAL_PHASE_EVENT_TYPES = frozenset({"phase.end", "phase.auto_sealed"})
197
+
172
198
 
173
199
  def now_iso() -> str:
174
200
  return dt.datetime.now().astimezone().isoformat(timespec="milliseconds")
@@ -587,12 +613,23 @@ def append_event(
587
613
  if archived is not None:
588
614
  return {"ok": False, "code": "ARCHIVED_CHANGE_IMMUTABLE", "message": str(archived)}
589
615
  events_path_obj = events_path(change_dir)
590
- lock_path = events_path_obj.with_name(events_path_obj.name + ".lock")
591
- existing = []
592
- event = build_event(args, existing)
593
- with event_file_lock(lock_path):
594
- atomic_append_line(events_path_obj, json.dumps(event, ensure_ascii=False))
595
- return {"ok": True, "event": event, "events_path": str(events_path_obj), "rendered": False}
616
+ event = build_event(args, [])
617
+ result = append_with_auto_seal(events_path_obj, event)
618
+ if result.get("phaseAlreadyClosed"):
619
+ return {
620
+ "ok": False,
621
+ "code": "PHASE_ALREADY_CLOSED",
622
+ "message": "PHASE_ALREADY_CLOSED: refusing a second phase.end for the same attempt",
623
+ "event": event,
624
+ "autoSealed": result.get("autoSealed") or [],
625
+ }
626
+ return {
627
+ "ok": True,
628
+ "event": event,
629
+ "events_path": str(events_path_obj),
630
+ "rendered": False,
631
+ "autoSealed": result.get("autoSealed") or [],
632
+ }
596
633
 
597
634
 
598
635
  def batch_append_events(
@@ -791,6 +828,14 @@ def validate_append_event(args: argparse.Namespace) -> tuple[str, str] | None:
791
828
  "EVENT_TIMING_FIELD_INVALID",
792
829
  f"EVENT_TIMING_FIELD_INVALID: --{field.replace('_', '-')} must be a nonnegative integer",
793
830
  )
831
+ if event_type == "phase.auto_sealed":
832
+ reason_value = str(getattr(args, "reason", "") or "").strip()
833
+ if reason_value and reason_value not in _AUTO_SEAL_REASONS:
834
+ return (
835
+ "EVENT_REASON_INVALID",
836
+ "EVENT_REASON_INVALID: phase.auto_sealed --reason must be one of "
837
+ f"{sorted(_AUTO_SEAL_REASONS)}",
838
+ )
794
839
  if event_type == "correction":
795
840
  target_field = str(getattr(args, "target_field", "") or "").strip()
796
841
  if target_field in _CORRECTION_PROTECTED_FIELDS:
@@ -879,7 +924,11 @@ def group_events_by_phase(events: list[dict[str, Any]]) -> list[tuple[str, list[
879
924
 
880
925
 
881
926
  def split_phase_attempts(phase_events: list[dict[str, Any]]) -> list[dict[str, Any]]:
882
- """Split repeated starts into attempts and flag legacy events written after end."""
927
+ """Split repeated starts into attempts and flag legacy events written after end.
928
+
929
+ ``phase.end`` and ``phase.auto_sealed`` are both terminal: either one closes
930
+ the current attempt (HH-WF-20260730-001).
931
+ """
883
932
  attempts: list[dict[str, Any]] = []
884
933
  current: dict[str, Any] | None = None
885
934
  next_attempt = 1
@@ -906,7 +955,7 @@ def split_phase_attempts(phase_events: list[dict[str, Any]]) -> list[dict[str, A
906
955
  if attempts:
907
956
  prior_type = attempts[-1]["events"][-1].get("type")
908
957
  attempts[-1]["events"].append(event)
909
- if prior_type == "phase.end":
958
+ if prior_type in TERMINAL_PHASE_EVENT_TYPES:
910
959
  attempts[-1]["warnings"].append("event recorded after phase.end")
911
960
  continue
912
961
  if event.get("type") in pre_start_metadata_types:
@@ -924,7 +973,7 @@ def split_phase_attempts(phase_events: list[dict[str, Any]]) -> list[dict[str, A
924
973
  }
925
974
  else:
926
975
  current["events"].append(event)
927
- if event.get("type") == "phase.end":
976
+ if event.get("type") in TERMINAL_PHASE_EVENT_TYPES:
928
977
  attempts.append(current)
929
978
  current = None
930
979
  if current is not None:
@@ -948,7 +997,7 @@ def phase_duration_ms(phase_events: list[dict[str, Any]]) -> int | None:
948
997
  etype = event.get("type")
949
998
  if etype == "phase.start" and start_ts is None:
950
999
  start_ts = event.get("timestamp")
951
- elif etype == "phase.end":
1000
+ elif etype in TERMINAL_PHASE_EVENT_TYPES:
952
1001
  end_ts = event.get("timestamp")
953
1002
  if start_ts and end_ts:
954
1003
  # Closed phases end at the matching phase.end. Late events appended
@@ -1031,8 +1080,12 @@ def attempt_invocations(
1031
1080
  ) -> list[dict[str, Any]]:
1032
1081
  """Per-attempt invocation view: attempt, status, durationMs (RET-22 / IA-2).
1033
1082
 
1034
- Closed attempts keep phase.end status. Unclosed attempts are sealed as
1035
- INCOMPLETE at the next attempt start, an explicit recovery event, or cutoff.
1083
+ Closed attempts keep phase.end status. Attempts closed by a write-path
1084
+ ``phase.auto_sealed`` event (HH-WF-20260730-001) keep that event's status
1085
+ (typically RECOVERED) but are never activeEligible: recovered/superseded
1086
+ time must not count as active execution. Unclosed attempts (no terminal
1087
+ event at all yet) are sealed as INCOMPLETE at the next attempt start, an
1088
+ explicit recovery event, or cutoff.
1036
1089
  """
1037
1090
  attempts = split_phase_attempts(phase_events)
1038
1091
  invocations: list[dict[str, Any]] = []
@@ -1043,6 +1096,7 @@ def attempt_invocations(
1043
1096
  status = None
1044
1097
  terminal_status = None
1045
1098
  closed_by_end = False
1099
+ closed_by_auto_seal = False
1046
1100
  for event in events_in:
1047
1101
  if event.get("type") == "phase.start" and start_ts is None:
1048
1102
  start_ts = event.get("timestamp")
@@ -1052,9 +1106,15 @@ def attempt_invocations(
1052
1106
  status = event.get("status")
1053
1107
  terminal_status = str(event.get("status") or "").upper() or None
1054
1108
  closed_by_end = True
1109
+ elif event.get("type") == "phase.auto_sealed":
1110
+ end_ts = event.get("timestamp")
1111
+ seal_status = event.get("status") or "RECOVERED"
1112
+ status = seal_status
1113
+ terminal_status = str(seal_status or "").upper() or "RECOVERED"
1114
+ closed_by_auto_seal = True
1055
1115
  missing_start = start_ts is None
1056
1116
  sealed_incomplete = False
1057
- if not closed_by_end:
1117
+ if not closed_by_end and not closed_by_auto_seal:
1058
1118
  next_start = None
1059
1119
  if index + 1 < len(attempts):
1060
1120
  for event in attempts[index + 1].get("events") or []:
@@ -1078,7 +1138,9 @@ def attempt_invocations(
1078
1138
  duration = None
1079
1139
  if start_ts and end_ts:
1080
1140
  duration = duration_ms_between(start_ts, end_ts)
1081
- # Active execution counts phase.end-closed attempts only (INCOMPLETE seals excluded).
1141
+ # Active execution counts phase.end-closed attempts only. Both
1142
+ # cutoff/INCOMPLETE seals and auto-sealed (recovered/superseded)
1143
+ # attempts are excluded (HH-WF-20260730-001).
1082
1144
  active_eligible = closed_by_end and not sealed_incomplete
1083
1145
  invocations.append(
1084
1146
  {
@@ -1090,6 +1152,7 @@ def attempt_invocations(
1090
1152
  "durationMs": duration,
1091
1153
  "activeEligible": active_eligible,
1092
1154
  "sealedIncomplete": sealed_incomplete,
1155
+ "closedByAutoSeal": closed_by_auto_seal,
1093
1156
  "warnings": list(attempt.get("warnings") or []),
1094
1157
  }
1095
1158
  )
@@ -1124,6 +1187,8 @@ def canonical_phase_timing(
1124
1187
  """Single reducer for every duration view (RET-20 / IA-2).
1125
1188
 
1126
1189
  activeExecutionMs: sum of closed attempt start→end only (INCOMPLETE excluded).
1190
+ recoveredMs: sum of durations for attempts terminated via write-path
1191
+ auto-seal (HH-WF-20260730-001) — always disjoint from activeExecutionMs.
1127
1192
  wallClockSpanMs: first → last/cutoff timestamp; unclosed attempts retain span.
1128
1193
  """
1129
1194
  invocations = attempt_invocations(phase_events, cutoff_ts=cutoff_ts)
@@ -1138,6 +1203,12 @@ def canonical_phase_timing(
1138
1203
  if invocation.get("activeEligible") and invocation.get("durationMs") is not None
1139
1204
  ]
1140
1205
  active = sum(closed_durations) if closed_durations else (0 if invocations else None)
1206
+ recovered_durations = [
1207
+ invocation["durationMs"]
1208
+ for invocation in invocations
1209
+ if invocation.get("closedByAutoSeal") and invocation.get("durationMs") is not None
1210
+ ]
1211
+ recovered = sum(recovered_durations) if recovered_durations else (0 if invocations else None)
1141
1212
  late = late_event_stats(phase_events)
1142
1213
  if len(stamps) >= 2:
1143
1214
  wall = duration_ms_between(stamps[0], stamps[-1])
@@ -1151,6 +1222,7 @@ def canonical_phase_timing(
1151
1222
  )
1152
1223
  return {
1153
1224
  "activeExecutionMs": active,
1225
+ "recoveredMs": recovered,
1154
1226
  "wallClockSpanMs": wall,
1155
1227
  "lateEventCount": late["lateEventCount"],
1156
1228
  "lateEventSpanMs": late["lateEventSpanMs"],
@@ -1231,6 +1303,14 @@ def render_event_line(event: dict[str, Any]) -> list[str]:
1231
1303
  note = str(event.get("note") or "").strip()
1232
1304
  suffix = f" — {note}" if note else ""
1233
1305
  return [f"- phase.end @ {event.get('timestamp', '')}{suffix}"]
1306
+ if etype == "phase.auto_sealed":
1307
+ reason = str(event.get("reason") or "").strip() or "unknown"
1308
+ status = str(event.get("status") or "RECOVERED").strip()
1309
+ note = str(event.get("note") or "").strip()
1310
+ suffix = f" — {note}" if note else ""
1311
+ return [
1312
+ f"- phase.auto_sealed ({status}/{reason}) @ {event.get('timestamp', '')}{suffix}"
1313
+ ]
1234
1314
  if etype == "verification":
1235
1315
  note = str(event.get("note") or "").strip()
1236
1316
  name = str(event.get("name") or "").strip() or (
@@ -1422,14 +1502,19 @@ def build_summary(change_dir: Path, events: list[dict[str, Any]]) -> dict[str, A
1422
1502
  def phase_end_already_recorded(
1423
1503
  events: list[dict[str, Any]], candidate: dict[str, Any]
1424
1504
  ) -> bool:
1425
- """Return whether the candidate attempt already has a terminal event."""
1505
+ """Return whether the candidate attempt already has a terminal event.
1506
+
1507
+ ``phase.auto_sealed`` counts as a terminal here too (HH-WF-20260730-001):
1508
+ a stale ``phase.end`` for an attempt that was already auto-sealed (e.g.
1509
+ superseded by a later ``phase.start``) must not close it a second time.
1510
+ """
1426
1511
  phase_events = [
1427
1512
  event for event in events if event.get("phase") == candidate.get("phase")
1428
1513
  ]
1429
1514
  candidate_attempt = candidate.get("attempt")
1430
1515
  if isinstance(candidate_attempt, int):
1431
1516
  return any(
1432
- event.get("type") == "phase.end"
1517
+ event.get("type") in TERMINAL_PHASE_EVENT_TYPES
1433
1518
  and event.get("attempt") == candidate_attempt
1434
1519
  for event in phase_events
1435
1520
  )
@@ -1437,7 +1522,151 @@ def phase_end_already_recorded(
1437
1522
  if not attempts:
1438
1523
  return False
1439
1524
  latest_events = attempts[-1].get("events") or []
1440
- return any(event.get("type") == "phase.end" for event in latest_events)
1525
+ return any(event.get("type") in TERMINAL_PHASE_EVENT_TYPES for event in latest_events)
1526
+
1527
+
1528
+ def infer_auto_seal_reason(attempt_events: list[dict[str, Any]]) -> str:
1529
+ """Best-effort ``phase.auto_sealed`` reason from an open attempt's own events.
1530
+
1531
+ HH-WF-20260730-001: prefer an explicit wait signal recorded while the
1532
+ attempt was open, then an explicit recovery marker, else fall back to
1533
+ ``superseded`` (the default meaning: a new phase.start arrived).
1534
+ """
1535
+ for event in attempt_events:
1536
+ mapped = _WAIT_EVENT_REASON_MAP.get(str(event.get("type") or "").lower())
1537
+ if mapped:
1538
+ return mapped
1539
+ for event in attempt_events:
1540
+ if str(event.get("type") or "").lower() in _RECOVERY_EVENT_TYPES:
1541
+ return "executor_lost"
1542
+ return "superseded"
1543
+
1544
+
1545
+ def open_attempts_for_phase(
1546
+ existing_events: list[dict[str, Any]], phase: str
1547
+ ) -> list[dict[str, Any]]:
1548
+ """Return ``split_phase_attempts`` records for ``phase`` that started but
1549
+ have no terminal (``phase.end``/``phase.auto_sealed``) event yet.
1550
+ """
1551
+ phase_events = [event for event in existing_events if event.get("phase") == phase]
1552
+ if not phase_events:
1553
+ return []
1554
+ open_attempts: list[dict[str, Any]] = []
1555
+ for attempt in split_phase_attempts(phase_events):
1556
+ events_in = attempt.get("events") or []
1557
+ if not events_in:
1558
+ continue
1559
+ if not any(event.get("type") == "phase.start" for event in events_in):
1560
+ # No actual start recorded (e.g. pure metadata bucket) — nothing
1561
+ # to auto-seal; sealing it would manufacture a phantom attempt.
1562
+ continue
1563
+ if any(
1564
+ event.get("type") in TERMINAL_PHASE_EVENT_TYPES for event in events_in
1565
+ ):
1566
+ # Prefer "any terminal" over "last event is terminal": late events
1567
+ # may be appended onto a closed attempt after phase.end/auto_sealed.
1568
+ continue
1569
+ open_attempts.append(attempt)
1570
+ return open_attempts
1571
+
1572
+
1573
+ def seal_open_phase_attempts(
1574
+ existing_events: list[dict[str, Any]],
1575
+ *,
1576
+ phase: str,
1577
+ seal_reason: str | None = None,
1578
+ seal_ts: str | None = None,
1579
+ ) -> list[dict[str, Any]]:
1580
+ """Build ``phase.auto_sealed`` events for still-open attempts of ``phase``.
1581
+
1582
+ HH-WF-20260730-001: write-path auto-seal. Returns the seal event dicts —
1583
+ callers append them (then the triggering event, e.g. the new
1584
+ ``phase.start``) under the same lock so the whole sequence is atomic from
1585
+ other processes' point of view.
1586
+
1587
+ When ``seal_reason`` is omitted, each open attempt gets a best-effort
1588
+ inferred reason (wait / recovery / superseded).
1589
+ """
1590
+ open_attempts = open_attempts_for_phase(existing_events, phase)
1591
+ if not open_attempts:
1592
+ return []
1593
+ ts = seal_ts or now_iso()
1594
+ seal_events: list[dict[str, Any]] = []
1595
+ for attempt in open_attempts:
1596
+ events_in = attempt.get("events") or []
1597
+ provenance_source = events_in[0] if events_in else {}
1598
+ if seal_reason is None:
1599
+ reason = infer_auto_seal_reason(events_in)
1600
+ elif seal_reason in _AUTO_SEAL_REASONS:
1601
+ reason = seal_reason
1602
+ else:
1603
+ reason = "unknown"
1604
+ seal_event: dict[str, Any] = {
1605
+ "schema_version": SCHEMA_VERSION,
1606
+ "id": new_event_id(),
1607
+ "timestamp": ts,
1608
+ "phase": phase,
1609
+ "type": "phase.auto_sealed",
1610
+ "status": "RECOVERED",
1611
+ "reason": reason,
1612
+ "note": "",
1613
+ "attempt": attempt.get("attempt"),
1614
+ }
1615
+ for field in ("run_id", "executor_tool", "executor_agent", "executor_model"):
1616
+ value = provenance_source.get(field)
1617
+ if value is not None:
1618
+ seal_event[field] = value
1619
+ seal_events.append(seal_event)
1620
+ return seal_events
1621
+
1622
+
1623
+ def append_with_auto_seal(
1624
+ events_file: Path,
1625
+ event: dict[str, Any],
1626
+ *,
1627
+ existing_events: list[dict[str, Any]] | None = None,
1628
+ ) -> dict[str, Any]:
1629
+ """Append ``event`` under lock; auto-seal open attempts when type is phase.start.
1630
+
1631
+ Returns ``{ok, event, autoSealed, phaseAlreadyClosed, events_path}``.
1632
+ Caller must NOT hold the events lock — this function acquires it.
1633
+ """
1634
+ lock_path = events_file.with_name(events_file.name + ".lock")
1635
+ event_type = str(event.get("type") or "")
1636
+ phase = str(event.get("phase") or "")
1637
+ auto_sealed: list[dict[str, Any]] = []
1638
+ phase_already_closed = False
1639
+ with event_file_lock(lock_path):
1640
+ loaded = (
1641
+ existing_events
1642
+ if existing_events is not None
1643
+ else (
1644
+ load_events(events_file)
1645
+ if event_type in {"phase.start", "phase.end", "correction"}
1646
+ else []
1647
+ )
1648
+ )
1649
+ if event_type == "phase.start" and phase:
1650
+ auto_sealed = seal_open_phase_attempts(loaded, phase=phase)
1651
+ for seal_event in auto_sealed:
1652
+ atomic_append_line(
1653
+ events_file,
1654
+ json.dumps(seal_event, ensure_ascii=False, separators=(",", ":")),
1655
+ )
1656
+ if event_type == "phase.end":
1657
+ phase_already_closed = phase_end_already_recorded(loaded, event)
1658
+ if not phase_already_closed:
1659
+ atomic_append_line(
1660
+ events_file,
1661
+ json.dumps(event, ensure_ascii=False, separators=(",", ":")),
1662
+ )
1663
+ return {
1664
+ "ok": not phase_already_closed,
1665
+ "event": event,
1666
+ "autoSealed": auto_sealed,
1667
+ "phaseAlreadyClosed": phase_already_closed,
1668
+ "events_path": str(events_file),
1669
+ }
1441
1670
 
1442
1671
 
1443
1672
  def cmd_append(args: argparse.Namespace) -> int:
@@ -1487,11 +1716,12 @@ def cmd_append(args: argparse.Namespace) -> int:
1487
1716
  line = json.dumps(event, ensure_ascii=False, separators=(",", ":"))
1488
1717
  phase_closed = False
1489
1718
  projection_error: str | None = None
1719
+ auto_sealed_events: list[dict[str, Any]] = []
1490
1720
  try:
1491
1721
  with event_file_lock(lock_path):
1492
1722
  existing_events = (
1493
1723
  load_events(path)
1494
- if args.type in {"phase.end", "correction"}
1724
+ if args.type in {"phase.end", "correction", "phase.start"}
1495
1725
  else []
1496
1726
  )
1497
1727
  if args.type == "phase.end":
@@ -1501,7 +1731,26 @@ def cmd_append(args: argparse.Namespace) -> int:
1501
1731
  apply_event_corrections([*existing_events, event])
1502
1732
  except ValueError as exc:
1503
1733
  projection_error = str(exc)
1734
+ elif args.type == "phase.start":
1735
+ # HH-WF-20260730-001: a new phase.start must not be appended
1736
+ # while a prior attempt for the same phase is still open —
1737
+ # auto-seal it first so timing never has two open attempts.
1738
+ open_attempts = open_attempts_for_phase(existing_events, args.phase)
1739
+ if open_attempts:
1740
+ inferred_reason = infer_auto_seal_reason(
1741
+ open_attempts[-1].get("events") or []
1742
+ )
1743
+ auto_sealed_events = seal_open_phase_attempts(
1744
+ existing_events,
1745
+ phase=args.phase,
1746
+ seal_reason=inferred_reason,
1747
+ )
1504
1748
  if not phase_closed and projection_error is None:
1749
+ for seal_event in auto_sealed_events:
1750
+ atomic_append_line(
1751
+ path,
1752
+ json.dumps(seal_event, ensure_ascii=False, separators=(",", ":")),
1753
+ )
1505
1754
  atomic_append_line(path, line)
1506
1755
  except (OSError, TimeoutError, ValueError) as exc:
1507
1756
  return emit_error(f"append failed: {exc}", as_json=as_json)
@@ -1519,12 +1768,13 @@ def cmd_append(args: argparse.Namespace) -> int:
1519
1768
  error_code=error_code,
1520
1769
  )
1521
1770
 
1522
- # §6.1: phase.end append -> 追加成功后执行一次 render(从完整 events 重建 log)。
1523
- # 普通 command/issue 等 append 不渲染(O(1));显式 `render` 子命令随时重建。
1771
+ # §6.1: phase.end append(或写路径产生了 auto-seal)-> 追加成功后执行一次
1772
+ # render(从完整 events 重建 log)。普通 command/issue 等 append 不渲染
1773
+ # (O(1));显式 `render` 子命令随时重建。
1524
1774
  rendered = False
1525
1775
  log_path = None
1526
1776
  log_lines = None
1527
- if args.type == "phase.end":
1777
+ if args.type == "phase.end" or auto_sealed_events:
1528
1778
  try:
1529
1779
  events = load_events(path)
1530
1780
  content = render_execution_log(events)
@@ -1541,6 +1791,8 @@ def cmd_append(args: argparse.Namespace) -> int:
1541
1791
  "events_path": str(path),
1542
1792
  "rendered": rendered,
1543
1793
  }
1794
+ if args.type == "phase.start":
1795
+ payload["autoSealed"] = auto_sealed_events
1544
1796
  if log_path is not None:
1545
1797
  payload["execution_log_path"] = str(log_path)
1546
1798
  payload["execution_log_lines"] = log_lines
@@ -1501,44 +1501,67 @@ def append_phase_event(
1501
1501
  executor_model: str | None = None,
1502
1502
  code: str | None = None,
1503
1503
  ) -> dict[str, Any]:
1504
+ """Skill-facing phase lifecycle append (begin/close use this directly).
1505
+
1506
+ HH-WF-20260730-001: shares harness_events' write-path auto-seal helpers
1507
+ so a ``phase.start`` here gets the same guarantee as the CLI's
1508
+ ``append_events.py append`` — a still-open prior attempt for the same
1509
+ phase is auto-sealed (``phase.auto_sealed``) before the new start is
1510
+ appended, atomically under the same lock.
1511
+ """
1504
1512
  events_file = he.events_path(change_dir)
1505
- existing = he.load_events(events_file)
1506
- args = argparse.Namespace(
1507
- phase=phase,
1508
- type=type_,
1509
- status=status,
1510
- note=note,
1511
- command=None,
1512
- exit_code=None,
1513
- duration_ms=None,
1514
- name=None,
1515
- path=None,
1516
- kind=None,
1517
- code=code,
1518
- severity=None,
1519
- message=None,
1520
- decision=None,
1521
- reason=None,
1522
- run_id=run_id,
1523
- attempt=None,
1524
- executor_tool=executor_tool,
1525
- executor_agent=executor_agent,
1526
- executor_model=executor_model,
1527
- handoff_from_tool=None,
1528
- handoff_reason=None,
1529
- )
1530
- event = he.build_event(args, existing)
1531
- if identity:
1532
- for key, value in identity.items():
1533
- if key not in event and value is not None:
1534
- event[key] = value
1535
- line = json.dumps(event, ensure_ascii=False, separators=(",", ":"))
1536
1513
  lock_path = events_file.with_name(events_file.name + ".lock")
1514
+ auto_sealed: list[dict[str, Any]] = []
1537
1515
  with he.event_file_lock(lock_path):
1516
+ existing = he.load_events(events_file)
1517
+ args = argparse.Namespace(
1518
+ phase=phase,
1519
+ type=type_,
1520
+ status=status,
1521
+ note=note,
1522
+ command=None,
1523
+ exit_code=None,
1524
+ duration_ms=None,
1525
+ name=None,
1526
+ path=None,
1527
+ kind=None,
1528
+ code=code,
1529
+ severity=None,
1530
+ message=None,
1531
+ decision=None,
1532
+ reason=None,
1533
+ run_id=run_id,
1534
+ attempt=None,
1535
+ executor_tool=executor_tool,
1536
+ executor_agent=executor_agent,
1537
+ executor_model=executor_model,
1538
+ handoff_from_tool=None,
1539
+ handoff_reason=None,
1540
+ )
1541
+ event = he.build_event(args, existing)
1542
+ if identity:
1543
+ for key, value in identity.items():
1544
+ if key not in event and value is not None:
1545
+ event[key] = value
1546
+ if type_ == "phase.start":
1547
+ open_attempts = he.open_attempts_for_phase(existing, phase)
1548
+ if open_attempts:
1549
+ inferred_reason = he.infer_auto_seal_reason(
1550
+ open_attempts[-1].get("events") or []
1551
+ )
1552
+ auto_sealed = he.seal_open_phase_attempts(
1553
+ existing, phase=phase, seal_reason=inferred_reason
1554
+ )
1555
+ for seal_event in auto_sealed:
1556
+ he.atomic_append_line(
1557
+ events_file,
1558
+ json.dumps(seal_event, ensure_ascii=False, separators=(",", ":")),
1559
+ )
1560
+ line = json.dumps(event, ensure_ascii=False, separators=(",", ":"))
1538
1561
  he.atomic_append_line(events_file, line)
1539
1562
  rendered = False
1540
1563
  log_path = None
1541
- if type_ == "phase.end":
1564
+ if type_ == "phase.end" or auto_sealed:
1542
1565
  events = he.load_events(events_file)
1543
1566
  content = he.render_execution_log(events)
1544
1567
  log_path = he.write_execution_log(change_dir, content)
@@ -1549,6 +1572,7 @@ def append_phase_event(
1549
1572
  "path": str(events_file),
1550
1573
  "rendered": rendered,
1551
1574
  "executionLogPath": str(log_path) if log_path else None,
1575
+ "autoSealed": auto_sealed,
1552
1576
  }
1553
1577
 
1554
1578