@hunter-harness/workflow-harness 0.2.53 → 0.2.55

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 (186) 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 +1 -1
  3. package/harness/bundles/general/claude-code/harness-codebase-map/SKILL.md +1 -1
  4. package/harness/bundles/general/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  5. package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +1 -1
  6. package/harness/bundles/general/claude-code/harness-plan/SKILL.md +4 -3
  7. package/harness/bundles/general/claude-code/harness-plan/checklist.md +2 -1
  8. package/harness/bundles/general/claude-code/harness-review/SKILL.md +6 -4
  9. package/harness/bundles/general/claude-code/harness-run/SKILL.md +4 -4
  10. package/harness/bundles/general/claude-code/harness-run/reference.md +2 -2
  11. package/harness/bundles/general/claude-code/harness-submit/SKILL.md +1 -1
  12. package/harness/bundles/general/claude-code/harness-sync/SKILL.md +1 -1
  13. package/harness/bundles/general/claude-code/harness-test/SKILL.md +5 -3
  14. package/harness/bundles/general/claude-code/harness-test/reference.md +1 -1
  15. package/harness/bundles/general/claude-code/scripts/harness_archive.py +46 -7
  16. package/harness/bundles/general/claude-code/scripts/harness_context.py +123 -22
  17. package/harness/bundles/general/claude-code/scripts/harness_events.py +40 -1
  18. package/harness/bundles/general/claude-code/scripts/harness_events_sync.py +236 -10
  19. package/harness/bundles/general/claude-code/scripts/harness_gate.py +165 -1
  20. package/harness/bundles/general/claude-code/scripts/harness_ledger.py +2 -0
  21. package/harness/bundles/general/claude-code/scripts/harness_plan_finalize.py +2 -0
  22. package/harness/bundles/general/claude-code/scripts/harness_test_guard.py +37 -44
  23. package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
  24. package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +1 -1
  25. package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
  26. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  27. package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  28. package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +4 -3
  29. package/harness/bundles/general/codebuddy/harness-plan/checklist.md +2 -1
  30. package/harness/bundles/general/codebuddy/harness-review/SKILL.md +6 -4
  31. package/harness/bundles/general/codebuddy/harness-run/SKILL.md +4 -4
  32. package/harness/bundles/general/codebuddy/harness-run/reference.md +2 -2
  33. package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +1 -1
  34. package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +1 -1
  35. package/harness/bundles/general/codebuddy/harness-test/SKILL.md +5 -3
  36. package/harness/bundles/general/codebuddy/harness-test/reference.md +1 -1
  37. package/harness/bundles/general/codebuddy/scripts/harness_archive.py +46 -7
  38. package/harness/bundles/general/codebuddy/scripts/harness_context.py +123 -22
  39. package/harness/bundles/general/codebuddy/scripts/harness_events.py +40 -1
  40. package/harness/bundles/general/codebuddy/scripts/harness_events_sync.py +236 -10
  41. package/harness/bundles/general/codebuddy/scripts/harness_gate.py +165 -1
  42. package/harness/bundles/general/codebuddy/scripts/harness_ledger.py +2 -0
  43. package/harness/bundles/general/codebuddy/scripts/harness_plan_finalize.py +2 -0
  44. package/harness/bundles/general/codebuddy/scripts/harness_test_guard.py +37 -44
  45. package/harness/bundles/general/codex/.harness-build.json +1 -1
  46. package/harness/bundles/general/codex/harness-archive/SKILL.md +1 -1
  47. package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
  48. package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +1 -1
  49. package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
  50. package/harness/bundles/general/codex/harness-plan/SKILL.md +4 -3
  51. package/harness/bundles/general/codex/harness-plan/checklist.md +2 -1
  52. package/harness/bundles/general/codex/harness-review/SKILL.md +3 -3
  53. package/harness/bundles/general/codex/harness-run/SKILL.md +4 -4
  54. package/harness/bundles/general/codex/harness-run/reference.md +2 -2
  55. package/harness/bundles/general/codex/harness-submit/SKILL.md +1 -1
  56. package/harness/bundles/general/codex/harness-sync/SKILL.md +1 -1
  57. package/harness/bundles/general/codex/harness-test/SKILL.md +5 -3
  58. package/harness/bundles/general/codex/harness-test/reference.md +1 -1
  59. package/harness/bundles/general/codex/scripts/harness_archive.py +46 -7
  60. package/harness/bundles/general/codex/scripts/harness_context.py +123 -22
  61. package/harness/bundles/general/codex/scripts/harness_events.py +40 -1
  62. package/harness/bundles/general/codex/scripts/harness_events_sync.py +236 -10
  63. package/harness/bundles/general/codex/scripts/harness_gate.py +165 -1
  64. package/harness/bundles/general/codex/scripts/harness_ledger.py +2 -0
  65. package/harness/bundles/general/codex/scripts/harness_plan_finalize.py +2 -0
  66. package/harness/bundles/general/codex/scripts/harness_test_guard.py +37 -44
  67. package/harness/bundles/general/cursor/.harness-build.json +1 -1
  68. package/harness/bundles/general/cursor/harness-archive/SKILL.md +1 -1
  69. package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
  70. package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  71. package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
  72. package/harness/bundles/general/cursor/harness-plan/SKILL.md +4 -3
  73. package/harness/bundles/general/cursor/harness-plan/checklist.md +2 -1
  74. package/harness/bundles/general/cursor/harness-review/SKILL.md +3 -3
  75. package/harness/bundles/general/cursor/harness-run/SKILL.md +4 -4
  76. package/harness/bundles/general/cursor/harness-run/reference.md +2 -2
  77. package/harness/bundles/general/cursor/harness-submit/SKILL.md +1 -1
  78. package/harness/bundles/general/cursor/harness-sync/SKILL.md +1 -1
  79. package/harness/bundles/general/cursor/harness-test/SKILL.md +5 -3
  80. package/harness/bundles/general/cursor/harness-test/reference.md +1 -1
  81. package/harness/bundles/general/cursor/scripts/harness_archive.py +46 -7
  82. package/harness/bundles/general/cursor/scripts/harness_context.py +123 -22
  83. package/harness/bundles/general/cursor/scripts/harness_events.py +40 -1
  84. package/harness/bundles/general/cursor/scripts/harness_events_sync.py +236 -10
  85. package/harness/bundles/general/cursor/scripts/harness_gate.py +165 -1
  86. package/harness/bundles/general/cursor/scripts/harness_ledger.py +2 -0
  87. package/harness/bundles/general/cursor/scripts/harness_plan_finalize.py +2 -0
  88. package/harness/bundles/general/cursor/scripts/harness_test_guard.py +37 -44
  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 +1 -1
  92. package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
  93. package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  94. package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
  95. package/harness/bundles/java/claude-code/harness-package/SKILL.md +1 -1
  96. package/harness/bundles/java/claude-code/harness-plan/SKILL.md +4 -3
  97. package/harness/bundles/java/claude-code/harness-plan/checklist.md +2 -1
  98. package/harness/bundles/java/claude-code/harness-review/SKILL.md +6 -4
  99. package/harness/bundles/java/claude-code/harness-run/SKILL.md +4 -4
  100. package/harness/bundles/java/claude-code/harness-submit/SKILL.md +1 -1
  101. package/harness/bundles/java/claude-code/harness-sync/SKILL.md +1 -1
  102. package/harness/bundles/java/claude-code/harness-test/SKILL.md +5 -3
  103. package/harness/bundles/java/claude-code/scripts/harness_archive.py +46 -7
  104. package/harness/bundles/java/claude-code/scripts/harness_context.py +123 -22
  105. package/harness/bundles/java/claude-code/scripts/harness_events.py +40 -1
  106. package/harness/bundles/java/claude-code/scripts/harness_events_sync.py +236 -10
  107. package/harness/bundles/java/claude-code/scripts/harness_gate.py +165 -1
  108. package/harness/bundles/java/claude-code/scripts/harness_ledger.py +2 -0
  109. package/harness/bundles/java/claude-code/scripts/harness_plan_finalize.py +2 -0
  110. package/harness/bundles/java/claude-code/scripts/harness_test_guard.py +37 -44
  111. package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
  112. package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +1 -1
  113. package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +1 -1
  114. package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
  115. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  116. package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  117. package/harness/bundles/java/codebuddy/harness-package/SKILL.md +1 -1
  118. package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +4 -3
  119. package/harness/bundles/java/codebuddy/harness-plan/checklist.md +2 -1
  120. package/harness/bundles/java/codebuddy/harness-review/SKILL.md +6 -4
  121. package/harness/bundles/java/codebuddy/harness-run/SKILL.md +4 -4
  122. package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +1 -1
  123. package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +1 -1
  124. package/harness/bundles/java/codebuddy/harness-test/SKILL.md +5 -3
  125. package/harness/bundles/java/codebuddy/scripts/harness_archive.py +46 -7
  126. package/harness/bundles/java/codebuddy/scripts/harness_context.py +123 -22
  127. package/harness/bundles/java/codebuddy/scripts/harness_events.py +40 -1
  128. package/harness/bundles/java/codebuddy/scripts/harness_events_sync.py +236 -10
  129. package/harness/bundles/java/codebuddy/scripts/harness_gate.py +165 -1
  130. package/harness/bundles/java/codebuddy/scripts/harness_ledger.py +2 -0
  131. package/harness/bundles/java/codebuddy/scripts/harness_plan_finalize.py +2 -0
  132. package/harness/bundles/java/codebuddy/scripts/harness_test_guard.py +37 -44
  133. package/harness/bundles/java/codex/.harness-build.json +1 -1
  134. package/harness/bundles/java/codex/harness-apidoc/SKILL.md +1 -1
  135. package/harness/bundles/java/codex/harness-archive/SKILL.md +1 -1
  136. package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
  137. package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +1 -1
  138. package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
  139. package/harness/bundles/java/codex/harness-package/SKILL.md +1 -1
  140. package/harness/bundles/java/codex/harness-plan/SKILL.md +4 -3
  141. package/harness/bundles/java/codex/harness-plan/checklist.md +2 -1
  142. package/harness/bundles/java/codex/harness-review/SKILL.md +3 -3
  143. package/harness/bundles/java/codex/harness-run/SKILL.md +4 -4
  144. package/harness/bundles/java/codex/harness-submit/SKILL.md +1 -1
  145. package/harness/bundles/java/codex/harness-sync/SKILL.md +1 -1
  146. package/harness/bundles/java/codex/harness-test/SKILL.md +5 -3
  147. package/harness/bundles/java/codex/scripts/harness_archive.py +46 -7
  148. package/harness/bundles/java/codex/scripts/harness_context.py +123 -22
  149. package/harness/bundles/java/codex/scripts/harness_events.py +40 -1
  150. package/harness/bundles/java/codex/scripts/harness_events_sync.py +236 -10
  151. package/harness/bundles/java/codex/scripts/harness_gate.py +165 -1
  152. package/harness/bundles/java/codex/scripts/harness_ledger.py +2 -0
  153. package/harness/bundles/java/codex/scripts/harness_plan_finalize.py +2 -0
  154. package/harness/bundles/java/codex/scripts/harness_test_guard.py +37 -44
  155. package/harness/bundles/java/cursor/.harness-build.json +1 -1
  156. package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +1 -1
  157. package/harness/bundles/java/cursor/harness-archive/SKILL.md +1 -1
  158. package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
  159. package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  160. package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
  161. package/harness/bundles/java/cursor/harness-package/SKILL.md +1 -1
  162. package/harness/bundles/java/cursor/harness-plan/SKILL.md +4 -3
  163. package/harness/bundles/java/cursor/harness-plan/checklist.md +2 -1
  164. package/harness/bundles/java/cursor/harness-review/SKILL.md +3 -3
  165. package/harness/bundles/java/cursor/harness-run/SKILL.md +4 -4
  166. package/harness/bundles/java/cursor/harness-submit/SKILL.md +1 -1
  167. package/harness/bundles/java/cursor/harness-sync/SKILL.md +1 -1
  168. package/harness/bundles/java/cursor/harness-test/SKILL.md +5 -3
  169. package/harness/bundles/java/cursor/scripts/harness_archive.py +46 -7
  170. package/harness/bundles/java/cursor/scripts/harness_context.py +123 -22
  171. package/harness/bundles/java/cursor/scripts/harness_events.py +40 -1
  172. package/harness/bundles/java/cursor/scripts/harness_events_sync.py +236 -10
  173. package/harness/bundles/java/cursor/scripts/harness_gate.py +165 -1
  174. package/harness/bundles/java/cursor/scripts/harness_ledger.py +2 -0
  175. package/harness/bundles/java/cursor/scripts/harness_plan_finalize.py +2 -0
  176. package/harness/bundles/java/cursor/scripts/harness_test_guard.py +37 -44
  177. package/harness/manifests/general/claude-code.json +24 -24
  178. package/harness/manifests/general/codebuddy.json +24 -24
  179. package/harness/manifests/general/codex.json +24 -24
  180. package/harness/manifests/general/cursor.json +24 -24
  181. package/harness/manifests/java/claude-code.json +24 -24
  182. package/harness/manifests/java/codebuddy.json +24 -24
  183. package/harness/manifests/java/codex.json +24 -24
  184. package/harness/manifests/java/cursor.json +24 -24
  185. package/hunter-workflow-family.json +5 -4
  186. package/package.json +1 -1
@@ -23,6 +23,8 @@ PHASE_GRAPH = {
23
23
  "submit": (),
24
24
  }
25
25
 
26
+ DISPLAY_TITLE_MAX_LENGTH = 80
27
+
26
28
 
27
29
  def _now() -> dt.datetime:
28
30
  return dt.datetime.now(dt.timezone.utc)
@@ -35,6 +37,41 @@ def _read_json(path: Path) -> dict[str, Any]:
35
37
  return data
36
38
 
37
39
 
40
+ def _normalize_display_title(value: Any) -> str | None:
41
+ if value is None:
42
+ return None
43
+ if not isinstance(value, str):
44
+ raise ValueError("display title must be text")
45
+ title = value.strip()
46
+ if not title:
47
+ raise ValueError("display title must not be empty")
48
+ if len(title) > DISPLAY_TITLE_MAX_LENGTH:
49
+ raise ValueError(
50
+ f"display title exceeds {DISPLAY_TITLE_MAX_LENGTH} characters"
51
+ )
52
+ if any(ord(character) < 32 or ord(character) == 127 for character in title):
53
+ raise ValueError("display title contains control characters")
54
+ return title
55
+
56
+
57
+ def _ensure_display_title(contract_root: Path, value: Any) -> str | None:
58
+ path = contract_root / "meta" / "change-title.json"
59
+ if path.is_file():
60
+ payload = _read_json(path)
61
+ return _normalize_display_title(payload.get("displayTitle"))
62
+ title = _normalize_display_title(value)
63
+ if title is None:
64
+ return None
65
+ _write_json_atomic(
66
+ path,
67
+ {
68
+ "schemaVersion": 1,
69
+ "displayTitle": title,
70
+ },
71
+ )
72
+ return title
73
+
74
+
38
75
  def _write_json_atomic(path: Path, payload: dict[str, Any]) -> None:
39
76
  path.parent.mkdir(parents=True, exist_ok=True)
40
77
  fd, raw = tempfile.mkstemp(prefix=f".{path.name}.", dir=str(path.parent))
@@ -243,6 +280,7 @@ def prepare_context(
243
280
  phase: str,
244
281
  executor: str,
245
282
  change: str | None = None,
283
+ display_title: str | None = None,
246
284
  ttl_seconds: int = 3600,
247
285
  ) -> dict[str, Any]:
248
286
  project = Path(project).resolve()
@@ -265,6 +303,16 @@ def prepare_context(
265
303
  contract_root, contract, state_root = _contract(project, change)
266
304
  except (OSError, ValueError, json.JSONDecodeError) as exc:
267
305
  return {"ok": False, "code": str(exc).split(":", 1)[0], "error": str(exc)}
306
+ try:
307
+ resolved_display_title = _ensure_display_title(
308
+ contract_root, display_title
309
+ )
310
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
311
+ return {
312
+ "ok": False,
313
+ "code": "DISPLAY_TITLE_INVALID",
314
+ "error": str(exc),
315
+ }
268
316
  lifecycle = contract.get("lifecycle")
269
317
  if (
270
318
  isinstance(lifecycle, dict)
@@ -343,6 +391,7 @@ def prepare_context(
343
391
  "executionRoot": str(execution_root),
344
392
  "preparedAt": now.isoformat(),
345
393
  "receiptHash": latest.get("receiptHash") if latest else None,
394
+ "displayTitle": resolved_display_title,
346
395
  },
347
396
  )
348
397
  next_phases = (
@@ -363,6 +412,7 @@ def prepare_context(
363
412
  "latestTransition": latest,
364
413
  "lease": lease,
365
414
  "recovery": recovery,
415
+ "displayTitle": resolved_display_title,
366
416
  }
367
417
 
368
418
 
@@ -424,7 +474,7 @@ def close_transition(
424
474
  *,
425
475
  from_phase: str,
426
476
  to_phase: str,
427
- executor: str,
477
+ executor: str | None,
428
478
  artifacts: list[str] | None = None,
429
479
  status: str = "OK",
430
480
  ) -> dict[str, Any]:
@@ -441,13 +491,81 @@ def close_transition(
441
491
  except (OSError, ValueError, json.JSONDecodeError) as exc:
442
492
  return {"ok": False, "code": "CHANGE_NOT_FOUND", "error": str(exc)}
443
493
  paths = _paths(state_root)
494
+
495
+ artifact_entries: list[dict[str, Any]] = []
496
+ for raw in artifacts or []:
497
+ requested = Path(raw)
498
+ candidates = (
499
+ [requested]
500
+ if requested.is_absolute()
501
+ else [project / requested, contract_root / requested]
502
+ )
503
+ valid: list[Path] = []
504
+ for candidate in candidates:
505
+ try:
506
+ resolved_candidate = candidate.resolve()
507
+ except OSError:
508
+ continue
509
+ if resolved_candidate in valid:
510
+ continue
511
+ if resolved_candidate.is_relative_to(project) and resolved_candidate.is_file():
512
+ valid.append(resolved_candidate)
513
+ if len(valid) > 1:
514
+ return {
515
+ "ok": False,
516
+ "code": "TRANSITION_ARTIFACT_AMBIGUOUS",
517
+ "path": raw,
518
+ "candidates": [item.relative_to(project).as_posix() for item in valid],
519
+ "message": "relative artifact exists under both project and change roots",
520
+ }
521
+ if not valid:
522
+ return {
523
+ "ok": False,
524
+ "code": "TRANSITION_ARTIFACT_INVALID",
525
+ "path": raw,
526
+ "acceptedBases": [str(project), str(contract_root)],
527
+ "examples": [
528
+ str(contract_root / "meta" / "plan-finalization.json"),
529
+ f".harness/changes/{change}/meta/plan-finalization.json",
530
+ "meta/plan-finalization.json",
531
+ ],
532
+ }
533
+ resolved_artifact = valid[0]
534
+ artifact_entries.append(
535
+ {
536
+ "path": resolved_artifact.relative_to(project).as_posix(),
537
+ "sha256": _sha256(resolved_artifact),
538
+ }
539
+ )
540
+
541
+ transitions = _read_ndjson(paths["transitions"])
444
542
  if not paths["lease"].is_file():
543
+ latest = transitions[-1] if transitions else None
544
+ if (
545
+ isinstance(latest, dict)
546
+ and latest.get("fromPhase") == from_phase
547
+ and latest.get("toPhase") == to_phase
548
+ and latest.get("status") == status
549
+ and latest.get("artifacts") == artifact_entries
550
+ and (executor is None or latest.get("executor") == executor)
551
+ ):
552
+ return {
553
+ "ok": True,
554
+ "code": "TRANSITION_ALREADY_CLOSED",
555
+ "idempotent": True,
556
+ "receipt": latest,
557
+ "path": str(paths["transitions"]),
558
+ "invalidation": None,
559
+ }
445
560
  return {"ok": False, "code": "CONTEXT_LEASE_REQUIRED"}
446
561
  try:
447
562
  lease = _read_json(paths["lease"])
448
563
  except (OSError, ValueError, json.JSONDecodeError):
449
564
  return {"ok": False, "code": "CONTEXT_LEASE_INVALID"}
450
- if lease.get("owner") != executor or lease.get("phase") != from_phase:
565
+ effective_executor = executor or str(lease.get("owner") or "")
566
+ if not effective_executor:
567
+ return {"ok": False, "code": "CONTEXT_EXECUTOR_REQUIRED"}
568
+ if lease.get("owner") != effective_executor or lease.get("phase") != from_phase:
451
569
  return {
452
570
  "ok": False,
453
571
  "code": "CONTEXT_LEASE_MISMATCH",
@@ -458,25 +576,6 @@ def close_transition(
458
576
  if expiry is None or _now() >= expiry:
459
577
  return {"ok": False, "code": "CONTEXT_LEASE_EXPIRED"}
460
578
 
461
- artifact_entries: list[dict[str, Any]] = []
462
- for raw in artifacts or []:
463
- path = Path(raw)
464
- if not path.is_absolute():
465
- path = contract_root / path
466
- resolved = path.resolve()
467
- if not resolved.is_relative_to(project) or not resolved.is_file():
468
- return {
469
- "ok": False,
470
- "code": "TRANSITION_ARTIFACT_INVALID",
471
- "path": str(path),
472
- }
473
- artifact_entries.append(
474
- {
475
- "path": resolved.relative_to(project).as_posix(),
476
- "sha256": _sha256(resolved),
477
- }
478
- )
479
- transitions = _read_ndjson(paths["transitions"])
480
579
  previous_hash = transitions[-1].get("receiptHash") if transitions else None
481
580
  attempt = (
482
581
  1
@@ -493,7 +592,7 @@ def close_transition(
493
592
  "fromPhase": from_phase,
494
593
  "toPhase": to_phase,
495
594
  "status": status,
496
- "executor": executor,
595
+ "executor": effective_executor,
497
596
  "productCommit": _head(project),
498
597
  "artifacts": artifact_entries,
499
598
  "attempt": attempt,
@@ -657,6 +756,7 @@ def build_parser() -> argparse.ArgumentParser:
657
756
  prepare.add_argument("--change")
658
757
  prepare.add_argument("--phase", required=True)
659
758
  prepare.add_argument("--executor", required=True)
759
+ prepare.add_argument("--title")
660
760
  prepare.add_argument("--ttl-seconds", type=int, default=3600)
661
761
  close = sub.add_parser("close")
662
762
  close.add_argument("--json", action="store_true")
@@ -688,6 +788,7 @@ def main(argv: list[str] | None = None) -> int:
688
788
  phase=args.phase,
689
789
  executor=args.executor,
690
790
  change=args.change,
791
+ display_title=args.title,
691
792
  ttl_seconds=args.ttl_seconds,
692
793
  )
693
794
  elif args.command == "close":
@@ -41,7 +41,7 @@ def _project_root_for_change(change_dir: Path) -> Path | None:
41
41
  return None
42
42
 
43
43
 
44
- def _nudge_remote_sync(change_dir: Path) -> None:
44
+ def nudge_remote_sync(change_dir: Path) -> None:
45
45
  """Best-effort wake-up after the event append has been flushed to disk."""
46
46
  project_root = _project_root_for_change(change_dir)
47
47
  if project_root is None:
@@ -55,6 +55,11 @@ def _nudge_remote_sync(change_dir: Path) -> None:
55
55
  return
56
56
 
57
57
 
58
+ def _nudge_remote_sync(change_dir: Path) -> None:
59
+ """Compatibility wrapper for older callers and focused tests."""
60
+ nudge_remote_sync(change_dir)
61
+
62
+
58
63
  if hasattr(sys.stdout, "reconfigure"):
59
64
  sys.stdout.reconfigure(encoding="utf-8")
60
65
  if hasattr(sys.stderr, "reconfigure"):
@@ -121,6 +126,12 @@ OPTIONAL_FIELDS = (
121
126
  "renamed_from",
122
127
  "renamed_to",
123
128
  "change_uuid",
129
+ "execution_mode",
130
+ "decision_reason_code",
131
+ "fallback_reason_code",
132
+ "trigger",
133
+ "from_phase",
134
+ "result_status",
124
135
  )
125
136
 
126
137
  _PROVENANCE_FIELDS = frozenset(
@@ -139,6 +150,12 @@ _PROVENANCE_FIELDS = frozenset(
139
150
  "orchestration_active_ms",
140
151
  "wall_clock_ms",
141
152
  "user_wait_ms",
153
+ "execution_mode",
154
+ "decision_reason_code",
155
+ "fallback_reason_code",
156
+ "trigger",
157
+ "from_phase",
158
+ "result_status",
142
159
  }
143
160
  )
144
161
  _EVENT_ALLOWED_FIELDS = {
@@ -1698,6 +1715,22 @@ def append_with_auto_seal(
1698
1715
  else []
1699
1716
  )
1700
1717
  )
1718
+ if event_type in {"phase.start", "phase.end", "phase.auto_sealed"} and phase:
1719
+ phase_attempts = split_phase_attempts(
1720
+ [item for item in loaded if item.get("phase") == phase]
1721
+ )
1722
+ latest_attempt = max(
1723
+ (
1724
+ int(item.get("attempt"))
1725
+ for item in phase_attempts
1726
+ if isinstance(item.get("attempt"), int)
1727
+ ),
1728
+ default=0,
1729
+ )
1730
+ if not isinstance(event.get("attempt"), int) or int(event["attempt"]) <= 0:
1731
+ event["attempt"] = (
1732
+ latest_attempt + 1 if event_type == "phase.start" else latest_attempt or 1
1733
+ )
1701
1734
  if event_type == "phase.start" and phase:
1702
1735
  auto_sealed = seal_open_phase_attempts(loaded, phase=phase)
1703
1736
  for seal_event in auto_sealed:
@@ -1967,6 +2000,12 @@ def build_parser() -> argparse.ArgumentParser:
1967
2000
  p_append.add_argument("--orchestration-active-ms", type=int, default=None)
1968
2001
  p_append.add_argument("--wall-clock-ms", type=int, default=None)
1969
2002
  p_append.add_argument("--user-wait-ms", type=int, default=None)
2003
+ p_append.add_argument("--execution-mode", default=None)
2004
+ p_append.add_argument("--decision-reason-code", default=None)
2005
+ p_append.add_argument("--fallback-reason-code", default=None)
2006
+ p_append.add_argument("--trigger", default=None)
2007
+ p_append.add_argument("--from-phase", default=None)
2008
+ p_append.add_argument("--result-status", default=None)
1970
2009
  p_append.add_argument(
1971
2010
  "--legacy-lenient",
1972
2011
  action="store_true",
@@ -40,6 +40,7 @@ WHITELIST = {
40
40
  "phase",
41
41
  "status",
42
42
  "note",
43
+ "message",
43
44
  "attempt",
44
45
  "name",
45
46
  "code",
@@ -49,11 +50,34 @@ WHITELIST = {
49
50
  "exit_code",
50
51
  "duration_ms",
51
52
  "schemaVersion",
53
+ "summary",
54
+ "executor_tool",
55
+ "executor_agent",
56
+ "executor_model",
57
+ "execution_mode",
58
+ "decision_reason_code",
59
+ "fallback_reason_code",
60
+ "trigger",
61
+ "from_phase",
62
+ "result_status",
52
63
  }
53
64
 
54
65
  MAX_BATCH_SIZE = 100
55
66
  ACCEPTED_STATUSES = {"accepted", "duplicate_accepted"}
56
67
  QUARANTINE_STATUSES = {"id_conflict", "rejected", "rejected_schema"}
68
+ DISPLAY_TITLE_MAX_LENGTH = 200
69
+ EVENT_SUMMARY_MAX_LENGTH = 500
70
+
71
+ PHASE_LABELS = {
72
+ "plan": "计划",
73
+ "run": "编码",
74
+ "test": "测试",
75
+ "review": "评审",
76
+ "package": "打包",
77
+ "apidoc": "接口文档",
78
+ "submit": "提交",
79
+ "archive": "归档",
80
+ }
57
81
 
58
82
 
59
83
  def _yaml_scalar(text: str, key: str) -> str | None:
@@ -87,8 +111,162 @@ def load_endpoint(project: Path) -> dict[str, str] | None:
87
111
  }
88
112
 
89
113
 
114
+ def _summary_text(value: Any) -> str:
115
+ if not isinstance(value, str):
116
+ return ""
117
+ text = re.sub(r"\s+", " ", value).strip()
118
+ text = "".join(character for character in text if ord(character) >= 32 and ord(character) != 127)
119
+ return text[:EVENT_SUMMARY_MAX_LENGTH].rstrip()
120
+
121
+
122
+ _MACHINE_DETAIL_RE = re.compile(
123
+ r"(?:\s*[;;,,]?\s*)"
124
+ r"(?:artifactsHash|artifactHash|receiptHash|content_sha256|contentSha256)"
125
+ r"\s*[:=]\s*(?:sha256:)?[0-9a-f]{32,64}",
126
+ re.IGNORECASE,
127
+ )
128
+
129
+
130
+ def _readable_summary_text(value: Any) -> str:
131
+ text = _summary_text(value)
132
+ if not text:
133
+ return ""
134
+ text = _MACHINE_DETAIL_RE.sub("", text)
135
+ text = re.sub(
136
+ r"\bfinalize\s+ok\b",
137
+ "规划产物已校验并发布",
138
+ text,
139
+ flags=re.IGNORECASE,
140
+ )
141
+ text = re.sub(r"\s*([;;,,])\s*([;;,,])", r"\1", text)
142
+ return _summary_text(text.strip(" ;;,,"))
143
+
144
+
145
+ def build_event_summary(event: dict[str, Any]) -> str:
146
+ explicit = _readable_summary_text(event.get("summary"))
147
+ if explicit:
148
+ return explicit
149
+
150
+ event_type = _summary_text(event.get("type"))
151
+ phase = _summary_text(event.get("phase"))
152
+ phase_label = PHASE_LABELS.get(phase, phase or "当前")
153
+ note = _readable_summary_text(event.get("note"))
154
+ message = _readable_summary_text(event.get("message"))
155
+ decision = _readable_summary_text(event.get("decision"))
156
+ reason = _readable_summary_text(event.get("reason"))
157
+ name = _readable_summary_text(event.get("name"))
158
+ code = _readable_summary_text(event.get("code"))
159
+ status = _summary_text(event.get("status"))
160
+
161
+ if event_type == "decision" and decision:
162
+ return _summary_text(f"{decision};原因:{reason}" if reason else decision)
163
+ if event_type == "issue":
164
+ detail = message or note or code
165
+ if detail and reason and reason != detail:
166
+ return _summary_text(f"{detail};原因:{reason}")
167
+ return detail or "发现一项需要处理的问题。"
168
+ if event_type == "issue.resolve":
169
+ detail = message or note or code
170
+ return _summary_text(f"问题已解决:{detail}" if detail else "已解决此前记录的问题。")
171
+ if event_type == "phase.start":
172
+ return _summary_text(
173
+ f"开始执行{phase_label}阶段:{note}" if note else f"开始执行{phase_label}阶段。"
174
+ )
175
+ if event_type in {"phase.end", "phase.auto_sealed"}:
176
+ detail = note or status
177
+ return _summary_text(
178
+ f"{phase_label}阶段已结束:{detail}" if detail else f"{phase_label}阶段已结束。"
179
+ )
180
+ if event_type == "command":
181
+ detail = name or note
182
+ return _summary_text(f"执行步骤:{detail}" if detail else "执行了一项工作步骤。")
183
+ if event_type == "verification":
184
+ detail = name or note or status
185
+ return _summary_text(f"完成验证:{detail}" if detail else "完成了一项结果验证。")
186
+ if event_type == "artifact":
187
+ detail = name or note
188
+ return _summary_text(f"生成或更新产物:{detail}" if detail else "生成或更新了一项交付内容。")
189
+ if event_type == "correction":
190
+ detail = note or reason or message
191
+ return _summary_text(f"调整执行方式:{detail}" if detail else "根据当前结果调整了执行方式。")
192
+ if event_type == "change.rename":
193
+ return note or message or "更新了变更名称。"
194
+ if event_type in {"recovery", "phase.recovery", "attempt.recovery"}:
195
+ return note or reason or message or "恢复了中断的运行状态。"
196
+ if event_type == "heartbeat":
197
+ return "客户端保持在线,并同步了最新运行状态。"
198
+
199
+ return note or message or name or reason or code or "记录了一项运行事件。"
200
+
201
+
90
202
  def sanitize(event: dict[str, Any]) -> dict[str, Any]:
91
- return {key: event[key] for key in WHITELIST if key in event}
203
+ payload = {key: event[key] for key in WHITELIST if key in event}
204
+ payload["summary"] = build_event_summary(event)
205
+ return payload
206
+
207
+
208
+ def _valid_display_title(value: Any) -> str | None:
209
+ if not isinstance(value, str):
210
+ return None
211
+ title = value.strip()
212
+ if (
213
+ not title
214
+ or len(title) > DISPLAY_TITLE_MAX_LENGTH
215
+ or any(ord(character) < 32 or ord(character) == 127 for character in title)
216
+ ):
217
+ return None
218
+ return title
219
+
220
+
221
+ def _safe_file_within(change_dir: Path, path: Path) -> Path | None:
222
+ try:
223
+ root = change_dir.resolve()
224
+ resolved = path.resolve()
225
+ except OSError:
226
+ return None
227
+ if path.is_symlink() or not resolved.is_relative_to(root) or not resolved.is_file():
228
+ return None
229
+ return resolved
230
+
231
+
232
+ def load_display_title(change_dir: Path, change_key: str) -> str:
233
+ metadata_path = _safe_file_within(
234
+ change_dir, change_dir / "meta" / "change-title.json"
235
+ )
236
+ if metadata_path is not None:
237
+ try:
238
+ payload = json.loads(metadata_path.read_text(encoding="utf-8-sig"))
239
+ except (OSError, json.JSONDecodeError):
240
+ payload = None
241
+ if isinstance(payload, dict):
242
+ title = _valid_display_title(payload.get("displayTitle"))
243
+ if title is not None:
244
+ return title
245
+
246
+ # Legacy migration: older Plan runs already wrote a human title into the
247
+ # approved design H1 but had no structured title metadata.
248
+ design_path = _safe_file_within(
249
+ change_dir, change_dir / "spec" / f"{change_key}-design.md"
250
+ )
251
+ if design_path is not None:
252
+ try:
253
+ with design_path.open("r", encoding="utf-8-sig") as stream:
254
+ for _ in range(40):
255
+ line = stream.readline()
256
+ if not line:
257
+ break
258
+ if not line.startswith("# "):
259
+ continue
260
+ candidate = re.sub(
261
+ r"\s+(?:设计文档|设计方案|设计)$", "", line[2:].strip()
262
+ )
263
+ title = _valid_display_title(candidate)
264
+ if title is not None and title != change_key:
265
+ return title
266
+ break
267
+ except OSError:
268
+ pass
269
+ return change_key
92
270
 
93
271
 
94
272
  def cursor_path(change_dir: Path) -> Path:
@@ -252,6 +430,7 @@ def sync_change(
252
430
  resolved_key = change_key or change_dir.name
253
431
  rid = run_id or run_id_for(change_dir)
254
432
  project_id = endpoint["project_id"]
433
+ display_title = load_display_title(change_dir, resolved_key)
255
434
 
256
435
  # Always heartbeat so the console can separate connection vs run status.
257
436
  try:
@@ -264,7 +443,7 @@ def sync_change(
264
443
  "run_id": rid,
265
444
  "change_key": resolved_key,
266
445
  "client_time": client_time,
267
- "title": resolved_key,
446
+ "title": display_title,
268
447
  },
269
448
  )
270
449
  except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError) as exc:
@@ -337,7 +516,7 @@ def sync_change(
337
516
  "protocol_version": "hunter-progress-sync/v1",
338
517
  "run_id": rid,
339
518
  "change_key": resolved_key,
340
- "title": resolved_key,
519
+ "title": display_title,
341
520
  "events": batch,
342
521
  },
343
522
  )
@@ -607,6 +786,39 @@ def _discover_changes(project_root: Path) -> list[Path]:
607
786
  return sorted(path for path in root.glob("*") if path.is_dir()) if root.is_dir() else []
608
787
 
609
788
 
789
+ def _change_has_open_phase(change_dir: Path) -> bool:
790
+ """Project the append-only stream into open/closed phase state."""
791
+ try:
792
+ path = Path(harness_paths.resolve_state_dir_for_contract(change_dir)) / "events.ndjson"
793
+ except (OSError, ValueError):
794
+ return False
795
+ if not path.is_file():
796
+ return False
797
+ open_phases: dict[str, bool] = {}
798
+ try:
799
+ for line in path.read_text(encoding="utf-8-sig").splitlines():
800
+ if not line.strip():
801
+ continue
802
+ event = json.loads(line)
803
+ if not isinstance(event, dict):
804
+ continue
805
+ phase = event.get("phase")
806
+ event_type = event.get("type")
807
+ if not isinstance(phase, str) or not phase:
808
+ continue
809
+ if event_type == "phase.start":
810
+ open_phases[phase] = True
811
+ elif event_type in {"phase.end", "phase.auto_sealed", "workflow.end", "run.end"}:
812
+ open_phases[phase] = False
813
+ except (OSError, json.JSONDecodeError):
814
+ return False
815
+ return any(open_phases.values())
816
+
817
+
818
+ def _project_has_open_phase(project_root: Path) -> bool:
819
+ return any(_change_has_open_phase(path) for path in _discover_changes(project_root))
820
+
821
+
610
822
  def _run_agent_session(project_root: Path) -> dict[str, Any]:
611
823
  attempts = 0
612
824
  uploaded = 0
@@ -619,14 +831,28 @@ def _run_agent_session(project_root: Path) -> dict[str, Any]:
619
831
  if all(item.get("ok") for item in results):
620
832
  time.sleep(0.6)
621
833
  if generation == _read_nudge_generation(project_root):
622
- return {
623
- "ok": True,
624
- "uploaded": uploaded,
625
- "quarantined": quarantined,
626
- "attempts": attempts + 1,
627
- }
834
+ if not _project_has_open_phase(project_root):
835
+ return {
836
+ "ok": True,
837
+ "uploaded": uploaded,
838
+ "quarantined": quarantined,
839
+ "attempts": attempts + 1,
840
+ }
841
+ # Keep a finite workflow lease alive while a phase is open.
842
+ # A phase.end nudge is observed on the next pass and releases
843
+ # the worker naturally; no terminal window is created.
844
+ time.sleep(10.0)
845
+ heartbeat_results = [
846
+ sync_change(project_root, path, heartbeat_only=True)
847
+ for path in _discover_changes(project_root)
848
+ ]
849
+ if all(item.get("ok") for item in heartbeat_results):
850
+ attempts = 0
851
+ continue
852
+ results = heartbeat_results
628
853
  attempts = 0
629
- continue
854
+ if generation != _read_nudge_generation(project_root):
855
+ continue
630
856
  time.sleep(min(16.0, float(2 ** attempts)))
631
857
  attempts += 1
632
858
  return {