@hunter-harness/workflow-harness 0.2.13 → 0.2.15

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 (206) 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 +1 -1
  7. package/harness/bundles/general/claude-code/harness-review/SKILL.md +1 -1
  8. package/harness/bundles/general/claude-code/harness-run/SKILL.md +1 -1
  9. package/harness/bundles/general/claude-code/harness-run/reference.md +3 -1
  10. package/harness/bundles/general/claude-code/harness-submit/SKILL.md +2 -2
  11. package/harness/bundles/general/claude-code/harness-submit/checklist.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 +1 -1
  14. package/harness/bundles/general/claude-code/scripts/harness_archive.py +204 -0
  15. package/harness/bundles/general/claude-code/scripts/harness_change.py +310 -16
  16. package/harness/bundles/general/claude-code/scripts/harness_deploy.py +177 -0
  17. package/harness/bundles/general/claude-code/scripts/harness_events.py +113 -1
  18. package/harness/bundles/general/claude-code/scripts/harness_gate.py +57 -4
  19. package/harness/bundles/general/claude-code/scripts/harness_integration.py +67 -7
  20. package/harness/bundles/general/claude-code/scripts/harness_ledger.py +15 -3
  21. package/harness/bundles/general/claude-code/scripts/harness_plan_finalize.py +85 -0
  22. package/harness/bundles/general/claude-code/scripts/harness_preflight.py +193 -77
  23. package/harness/bundles/general/claude-code/scripts/harness_state.py +12 -4
  24. package/harness/bundles/general/claude-code/scripts/harness_test_guard.py +86 -13
  25. package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
  26. package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +1 -1
  27. package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
  28. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  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 +1 -1
  32. package/harness/bundles/general/codebuddy/harness-run/SKILL.md +1 -1
  33. package/harness/bundles/general/codebuddy/harness-run/reference.md +3 -1
  34. package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +2 -2
  35. package/harness/bundles/general/codebuddy/harness-submit/checklist.md +1 -1
  36. package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +1 -1
  37. package/harness/bundles/general/codebuddy/harness-test/SKILL.md +1 -1
  38. package/harness/bundles/general/codebuddy/scripts/harness_archive.py +204 -0
  39. package/harness/bundles/general/codebuddy/scripts/harness_change.py +310 -16
  40. package/harness/bundles/general/codebuddy/scripts/harness_deploy.py +177 -0
  41. package/harness/bundles/general/codebuddy/scripts/harness_events.py +113 -1
  42. package/harness/bundles/general/codebuddy/scripts/harness_gate.py +57 -4
  43. package/harness/bundles/general/codebuddy/scripts/harness_integration.py +67 -7
  44. package/harness/bundles/general/codebuddy/scripts/harness_ledger.py +15 -3
  45. package/harness/bundles/general/codebuddy/scripts/harness_plan_finalize.py +85 -0
  46. package/harness/bundles/general/codebuddy/scripts/harness_preflight.py +193 -77
  47. package/harness/bundles/general/codebuddy/scripts/harness_state.py +12 -4
  48. package/harness/bundles/general/codebuddy/scripts/harness_test_guard.py +86 -13
  49. package/harness/bundles/general/codex/.harness-build.json +1 -1
  50. package/harness/bundles/general/codex/harness-archive/SKILL.md +1 -1
  51. package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
  52. package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +1 -1
  53. package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
  54. package/harness/bundles/general/codex/harness-plan/SKILL.md +1 -1
  55. package/harness/bundles/general/codex/harness-review/SKILL.md +1 -1
  56. package/harness/bundles/general/codex/harness-run/SKILL.md +1 -1
  57. package/harness/bundles/general/codex/harness-run/reference.md +3 -1
  58. package/harness/bundles/general/codex/harness-submit/SKILL.md +2 -2
  59. package/harness/bundles/general/codex/harness-submit/checklist.md +1 -1
  60. package/harness/bundles/general/codex/harness-sync/SKILL.md +1 -1
  61. package/harness/bundles/general/codex/harness-test/SKILL.md +1 -1
  62. package/harness/bundles/general/codex/scripts/harness_archive.py +204 -0
  63. package/harness/bundles/general/codex/scripts/harness_change.py +310 -16
  64. package/harness/bundles/general/codex/scripts/harness_deploy.py +177 -0
  65. package/harness/bundles/general/codex/scripts/harness_events.py +113 -1
  66. package/harness/bundles/general/codex/scripts/harness_gate.py +57 -4
  67. package/harness/bundles/general/codex/scripts/harness_integration.py +67 -7
  68. package/harness/bundles/general/codex/scripts/harness_ledger.py +15 -3
  69. package/harness/bundles/general/codex/scripts/harness_plan_finalize.py +85 -0
  70. package/harness/bundles/general/codex/scripts/harness_preflight.py +193 -77
  71. package/harness/bundles/general/codex/scripts/harness_state.py +12 -4
  72. package/harness/bundles/general/codex/scripts/harness_test_guard.py +86 -13
  73. package/harness/bundles/general/cursor/.harness-build.json +1 -1
  74. package/harness/bundles/general/cursor/harness-archive/SKILL.md +1 -1
  75. package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
  76. package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  77. package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
  78. package/harness/bundles/general/cursor/harness-plan/SKILL.md +1 -1
  79. package/harness/bundles/general/cursor/harness-review/SKILL.md +1 -1
  80. package/harness/bundles/general/cursor/harness-run/SKILL.md +1 -1
  81. package/harness/bundles/general/cursor/harness-run/reference.md +3 -1
  82. package/harness/bundles/general/cursor/harness-submit/SKILL.md +2 -2
  83. package/harness/bundles/general/cursor/harness-submit/checklist.md +1 -1
  84. package/harness/bundles/general/cursor/harness-sync/SKILL.md +1 -1
  85. package/harness/bundles/general/cursor/harness-test/SKILL.md +1 -1
  86. package/harness/bundles/general/cursor/scripts/harness_archive.py +204 -0
  87. package/harness/bundles/general/cursor/scripts/harness_change.py +310 -16
  88. package/harness/bundles/general/cursor/scripts/harness_deploy.py +177 -0
  89. package/harness/bundles/general/cursor/scripts/harness_events.py +113 -1
  90. package/harness/bundles/general/cursor/scripts/harness_gate.py +57 -4
  91. package/harness/bundles/general/cursor/scripts/harness_integration.py +67 -7
  92. package/harness/bundles/general/cursor/scripts/harness_ledger.py +15 -3
  93. package/harness/bundles/general/cursor/scripts/harness_plan_finalize.py +85 -0
  94. package/harness/bundles/general/cursor/scripts/harness_preflight.py +193 -77
  95. package/harness/bundles/general/cursor/scripts/harness_state.py +12 -4
  96. package/harness/bundles/general/cursor/scripts/harness_test_guard.py +86 -13
  97. package/harness/bundles/java/claude-code/.harness-build.json +1 -1
  98. package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +1 -1
  99. package/harness/bundles/java/claude-code/harness-archive/SKILL.md +1 -1
  100. package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
  101. package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  102. package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
  103. package/harness/bundles/java/claude-code/harness-package/SKILL.md +1 -1
  104. package/harness/bundles/java/claude-code/harness-plan/SKILL.md +1 -1
  105. package/harness/bundles/java/claude-code/harness-review/SKILL.md +1 -1
  106. package/harness/bundles/java/claude-code/harness-run/SKILL.md +1 -1
  107. package/harness/bundles/java/claude-code/harness-submit/SKILL.md +2 -2
  108. package/harness/bundles/java/claude-code/harness-submit/checklist.md +1 -1
  109. package/harness/bundles/java/claude-code/harness-sync/SKILL.md +1 -1
  110. package/harness/bundles/java/claude-code/harness-test/SKILL.md +1 -1
  111. package/harness/bundles/java/claude-code/scripts/harness_archive.py +204 -0
  112. package/harness/bundles/java/claude-code/scripts/harness_change.py +310 -16
  113. package/harness/bundles/java/claude-code/scripts/harness_deploy.py +177 -0
  114. package/harness/bundles/java/claude-code/scripts/harness_events.py +113 -1
  115. package/harness/bundles/java/claude-code/scripts/harness_gate.py +57 -4
  116. package/harness/bundles/java/claude-code/scripts/harness_integration.py +67 -7
  117. package/harness/bundles/java/claude-code/scripts/harness_ledger.py +15 -3
  118. package/harness/bundles/java/claude-code/scripts/harness_plan_finalize.py +85 -0
  119. package/harness/bundles/java/claude-code/scripts/harness_preflight.py +193 -77
  120. package/harness/bundles/java/claude-code/scripts/harness_state.py +12 -4
  121. package/harness/bundles/java/claude-code/scripts/harness_test_guard.py +86 -13
  122. package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
  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-codebase-map/SKILL.md +1 -1
  126. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  127. package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  128. package/harness/bundles/java/codebuddy/harness-package/SKILL.md +1 -1
  129. package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +1 -1
  130. package/harness/bundles/java/codebuddy/harness-review/SKILL.md +1 -1
  131. package/harness/bundles/java/codebuddy/harness-run/SKILL.md +1 -1
  132. package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +2 -2
  133. package/harness/bundles/java/codebuddy/harness-submit/checklist.md +1 -1
  134. package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +1 -1
  135. package/harness/bundles/java/codebuddy/harness-test/SKILL.md +1 -1
  136. package/harness/bundles/java/codebuddy/scripts/harness_archive.py +204 -0
  137. package/harness/bundles/java/codebuddy/scripts/harness_change.py +310 -16
  138. package/harness/bundles/java/codebuddy/scripts/harness_deploy.py +177 -0
  139. package/harness/bundles/java/codebuddy/scripts/harness_events.py +113 -1
  140. package/harness/bundles/java/codebuddy/scripts/harness_gate.py +57 -4
  141. package/harness/bundles/java/codebuddy/scripts/harness_integration.py +67 -7
  142. package/harness/bundles/java/codebuddy/scripts/harness_ledger.py +15 -3
  143. package/harness/bundles/java/codebuddy/scripts/harness_plan_finalize.py +85 -0
  144. package/harness/bundles/java/codebuddy/scripts/harness_preflight.py +193 -77
  145. package/harness/bundles/java/codebuddy/scripts/harness_state.py +12 -4
  146. package/harness/bundles/java/codebuddy/scripts/harness_test_guard.py +86 -13
  147. package/harness/bundles/java/codex/.harness-build.json +1 -1
  148. package/harness/bundles/java/codex/harness-apidoc/SKILL.md +1 -1
  149. package/harness/bundles/java/codex/harness-archive/SKILL.md +1 -1
  150. package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
  151. package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +1 -1
  152. package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
  153. package/harness/bundles/java/codex/harness-package/SKILL.md +1 -1
  154. package/harness/bundles/java/codex/harness-plan/SKILL.md +1 -1
  155. package/harness/bundles/java/codex/harness-review/SKILL.md +1 -1
  156. package/harness/bundles/java/codex/harness-run/SKILL.md +1 -1
  157. package/harness/bundles/java/codex/harness-submit/SKILL.md +2 -2
  158. package/harness/bundles/java/codex/harness-submit/checklist.md +1 -1
  159. package/harness/bundles/java/codex/harness-sync/SKILL.md +1 -1
  160. package/harness/bundles/java/codex/harness-test/SKILL.md +1 -1
  161. package/harness/bundles/java/codex/scripts/harness_archive.py +204 -0
  162. package/harness/bundles/java/codex/scripts/harness_change.py +310 -16
  163. package/harness/bundles/java/codex/scripts/harness_deploy.py +177 -0
  164. package/harness/bundles/java/codex/scripts/harness_events.py +113 -1
  165. package/harness/bundles/java/codex/scripts/harness_gate.py +57 -4
  166. package/harness/bundles/java/codex/scripts/harness_integration.py +67 -7
  167. package/harness/bundles/java/codex/scripts/harness_ledger.py +15 -3
  168. package/harness/bundles/java/codex/scripts/harness_plan_finalize.py +85 -0
  169. package/harness/bundles/java/codex/scripts/harness_preflight.py +193 -77
  170. package/harness/bundles/java/codex/scripts/harness_state.py +12 -4
  171. package/harness/bundles/java/codex/scripts/harness_test_guard.py +86 -13
  172. package/harness/bundles/java/cursor/.harness-build.json +1 -1
  173. package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +1 -1
  174. package/harness/bundles/java/cursor/harness-archive/SKILL.md +1 -1
  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 +2 -2
  183. package/harness/bundles/java/cursor/harness-submit/checklist.md +1 -1
  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/scripts/harness_archive.py +204 -0
  187. package/harness/bundles/java/cursor/scripts/harness_change.py +310 -16
  188. package/harness/bundles/java/cursor/scripts/harness_deploy.py +177 -0
  189. package/harness/bundles/java/cursor/scripts/harness_events.py +113 -1
  190. package/harness/bundles/java/cursor/scripts/harness_gate.py +57 -4
  191. package/harness/bundles/java/cursor/scripts/harness_integration.py +67 -7
  192. package/harness/bundles/java/cursor/scripts/harness_ledger.py +15 -3
  193. package/harness/bundles/java/cursor/scripts/harness_plan_finalize.py +85 -0
  194. package/harness/bundles/java/cursor/scripts/harness_preflight.py +193 -77
  195. package/harness/bundles/java/cursor/scripts/harness_state.py +12 -4
  196. package/harness/bundles/java/cursor/scripts/harness_test_guard.py +86 -13
  197. package/harness/manifests/general/claude-code.json +25 -25
  198. package/harness/manifests/general/codebuddy.json +25 -25
  199. package/harness/manifests/general/codex.json +25 -25
  200. package/harness/manifests/general/cursor.json +25 -25
  201. package/harness/manifests/java/claude-code.json +26 -26
  202. package/harness/manifests/java/codebuddy.json +26 -26
  203. package/harness/manifests/java/codex.json +26 -26
  204. package/harness/manifests/java/cursor.json +26 -26
  205. package/hunter-workflow-family.json +2 -2
  206. package/package.json +15 -15
@@ -683,6 +683,34 @@ def check_status(
683
683
  else:
684
684
  blockers.append(tier_issue)
685
685
 
686
+ # --- artifact preflight (retro §5.31) ---
687
+ # Classify artifact events before destructive finalize: informational (OK),
688
+ # canonicalizable (same-change repo-relative path, warning), or blocking
689
+ # (cross-change/absolute/escaping path, fail closed).
690
+ try:
691
+ preflight = artifact_preflight(change_dir)
692
+ except Exception as exc: # noqa: BLE001 — preflight must not crash status
693
+ preflight = {"ok": False, "items": [], "blocking": [], "error": str(exc)}
694
+ checks["artifact_preflight"] = preflight
695
+ for item in preflight.get("blocking") or []:
696
+ blockers.append({
697
+ "code": "artifact-path-blocking",
698
+ "message": (
699
+ f"artifact event {item.get('eventId', '')} path "
700
+ f"{item.get('path', '')}: {item.get('reason', 'blocking')}"
701
+ ),
702
+ })
703
+ for item in preflight.get("items") or []:
704
+ if item.get("category") == "canonicalizable":
705
+ warnings.append({
706
+ "code": "artifact-path-canonicalizable",
707
+ "message": (
708
+ f"artifact event {item.get('eventId', '')} path "
709
+ f"{item.get('path', '')} is repo-relative; "
710
+ f"canonical={item.get('canonicalPath', '')}"
711
+ ),
712
+ })
713
+
686
714
  archivable = len(blockers) == 0
687
715
  return {
688
716
  "ok": True,
@@ -2799,6 +2827,130 @@ def _html_escape(text: str) -> str:
2799
2827
  # ---------------------------------------------------------------------------
2800
2828
 
2801
2829
 
2830
+ def validate_report_adequacy(summary: dict[str, Any]) -> dict[str, Any]:
2831
+ """Validate that final summary is factually complete (retro §5.32).
2832
+
2833
+ The archive validator must not return all-green when the summary is
2834
+ factually incomplete. This gate checks independent sources (git facts,
2835
+ typed metrics, stage status) rather than comparing the summary against
2836
+ a lossy projection of itself.
2837
+ """
2838
+ issues: list[dict[str, str]] = []
2839
+
2840
+ git_facts = summary.get("gitFacts") or {}
2841
+ if isinstance(git_facts, dict):
2842
+ base = str(git_facts.get("baseCommit") or "").strip()
2843
+ final = str(git_facts.get("finalCommit") or "").strip()
2844
+ files_changed = int(git_facts.get("filesChanged") or 0)
2845
+ # base != final but diff=0 → factually incomplete.
2846
+ if base and final and base != final and files_changed == 0:
2847
+ issues.append({
2848
+ "code": "DIFF_ZERO_WITH_NONEMPTY_COMMIT",
2849
+ "severity": "error",
2850
+ "message": f"final commit {final} differs from base {base} but filesChanged=0",
2851
+ })
2852
+
2853
+ # Typed metrics missing despite test report artifacts present.
2854
+ verification = summary.get("verification") or {}
2855
+ if isinstance(verification, dict):
2856
+ unit = verification.get("unitTests") or {}
2857
+ api = verification.get("apiTests") or {}
2858
+ artifacts = summary.get("artifacts") or []
2859
+ has_test_report = any(
2860
+ isinstance(a, dict) and "test" in str(a.get("path") or "").lower()
2861
+ for a in artifacts
2862
+ ) if isinstance(artifacts, list) else False
2863
+ unit_pass = int(unit.get("passed") or 0) if isinstance(unit, dict) else 0
2864
+ api_status = str(api.get("status") or "") if isinstance(api, dict) else ""
2865
+ if has_test_report and unit_pass == 0 and api_status in {"", "not_available"}:
2866
+ issues.append({
2867
+ "code": "TYPED_METRICS_MISSING",
2868
+ "severity": "error",
2869
+ "message": "test report artifacts present but unitTests/apiTests typed metrics are empty",
2870
+ })
2871
+
2872
+ # stageStatus contradicts event reducer.
2873
+ stage = summary.get("stageStatus") or {}
2874
+ stage_from_events = summary.get("stageStatusFromEvents") or {}
2875
+ if isinstance(stage, dict) and isinstance(stage_from_events, dict):
2876
+ for phase, status in stage.items():
2877
+ event_status = stage_from_events.get(phase)
2878
+ if event_status is not None and str(status) != str(event_status):
2879
+ issues.append({
2880
+ "code": "STAGE_STATUS_CONTRADICTION",
2881
+ "severity": "error",
2882
+ "message": f"stageStatus.{phase}={status} but event reducer says {event_status}",
2883
+ })
2884
+
2885
+ return {"ok": not issues, "issues": issues}
2886
+
2887
+
2888
+ def artifact_preflight(change_dir: Path) -> dict[str, Any]:
2889
+ """Classify artifact events before destructive finalize (retro §5.31).
2890
+
2891
+ Returns per-artifact classification: informational (no path, OK),
2892
+ canonicalizable (same-change repo-relative path, auto-normalize), or
2893
+ blocking (cross-change/absolute/escaping path, fail closed).
2894
+ """
2895
+ change_dir = change_dir.resolve()
2896
+ events_p = change_dir / "events.ndjson"
2897
+ items: list[dict[str, Any]] = []
2898
+ blocking: list[dict[str, Any]] = []
2899
+ if not events_p.is_file():
2900
+ return {"ok": True, "items": [], "blocking": []}
2901
+ change_id = change_dir.name
2902
+ for line in events_p.read_text(encoding="utf-8").splitlines():
2903
+ try:
2904
+ event = json.loads(line)
2905
+ except json.JSONDecodeError:
2906
+ continue
2907
+ if not isinstance(event, dict) or event.get("type") != "artifact":
2908
+ continue
2909
+ path = str(event.get("path") or "").strip()
2910
+ kind = str(event.get("kind") or "").strip()
2911
+ event_id = str(event.get("id") or "")
2912
+ if not path:
2913
+ items.append({
2914
+ "eventId": event_id,
2915
+ "category": "informational",
2916
+ "kind": kind or "informational",
2917
+ "note": str(event.get("note") or "")[:80],
2918
+ })
2919
+ continue
2920
+ # Check for escaping/cross-change paths.
2921
+ parts = path.replace("\\", "/").split("/")
2922
+ if ".." in parts or path.startswith("/") or re.match(r"^[A-Za-z]:", path):
2923
+ item = {
2924
+ "eventId": event_id,
2925
+ "category": "blocking",
2926
+ "path": path,
2927
+ "reason": "absolute or escaping path",
2928
+ }
2929
+ items.append(item)
2930
+ blocking.append(item)
2931
+ continue
2932
+ # Same-change repo-relative path: canonicalizable.
2933
+ prefix = f".harness/changes/{change_id}/"
2934
+ if path.startswith(prefix):
2935
+ canonical = path[len(prefix):]
2936
+ items.append({
2937
+ "eventId": event_id,
2938
+ "category": "canonicalizable",
2939
+ "path": path,
2940
+ "canonicalPath": canonical,
2941
+ "correction": f"append correction --target-event-id {event_id} --target-field path --new-value-json \"{canonical}\"",
2942
+ })
2943
+ continue
2944
+ # Change-relative path: OK.
2945
+ items.append({
2946
+ "eventId": event_id,
2947
+ "category": "file-backed",
2948
+ "path": path,
2949
+ "exists": (change_dir / path).is_file(),
2950
+ })
2951
+ return {"ok": not blocking, "items": items, "blocking": blocking}
2952
+
2953
+
2802
2954
  def run_knowledge_poststeps(project_root: Path) -> dict[str, Any]:
2803
2955
  results: dict[str, Any] = {"ran": False, "steps": [], "warnings": []}
2804
2956
  if not KNOWLEDGE_SCRIPT.is_file():
@@ -3107,6 +3259,36 @@ def cmd_finalize(
3107
3259
  note="review missing on full tier (allowed by user)",
3108
3260
  )
3109
3261
 
3262
+ # --- 2c. artifact preflight (retro §5.31) ---
3263
+ # Classify artifact events before freeze: blocking paths must fail closed
3264
+ # before the staged operation commits a phase.end.
3265
+ try:
3266
+ preflight = artifact_preflight(work_dir)
3267
+ except Exception as exc: # noqa: BLE001 — preflight must not crash finalize
3268
+ preflight = {"ok": False, "items": [], "blocking": [], "error": str(exc)}
3269
+ payload["steps"]["artifact_preflight"] = {
3270
+ "ok": bool(preflight.get("ok")),
3271
+ "blockingCount": len(preflight.get("blocking") or []),
3272
+ "itemsCount": len(preflight.get("items") or []),
3273
+ }
3274
+ if preflight.get("blocking"):
3275
+ payload["error"] = (
3276
+ f"artifact preflight blocking: "
3277
+ f"{len(preflight['blocking'])} path(s) cannot be archived"
3278
+ )
3279
+ payload["issues"] = [
3280
+ {
3281
+ "code": "ARTIFACT_PATH_BLOCKING",
3282
+ "severity": "error",
3283
+ "message": f"{item.get('path', '')}: {item.get('reason', 'blocking')}",
3284
+ }
3285
+ for item in preflight["blocking"]
3286
+ ]
3287
+ _restore_finalize_failure()
3288
+ payload["warnings"] = warnings
3289
+ payload["ok"] = False
3290
+ return 1, payload
3291
+
3110
3292
  # --- 3. candidate phase.end + freeze cutoff (RET-19 freeze-first) ---
3111
3293
  # This terminal exists only inside the isolated operation staging tree. It
3112
3294
  # becomes authoritative atomically at publish; validation failure discards
@@ -3169,6 +3351,28 @@ def cmd_finalize(
3169
3351
  payload["ok"] = False
3170
3352
  return 1, payload
3171
3353
 
3354
+ # --- 5b. report adequacy (retro §5.32) ---
3355
+ # Validate that the summary is factually complete: no diff=0 with non-empty
3356
+ # commit, no missing typed metrics despite test reports, no stageStatus
3357
+ # contradictions. Fail closed rather than archiving an incomplete summary.
3358
+ adequacy_result = validate_report_adequacy(summary)
3359
+ payload["steps"]["report_adequacy"] = adequacy_result
3360
+ summary.setdefault("reportPipeline", {})["reportAdequacy"] = {
3361
+ "ok": bool(adequacy_result.get("ok")),
3362
+ "issues": adequacy_result.get("issues") or [],
3363
+ }
3364
+ try:
3365
+ write_json(summary_path, summary)
3366
+ except OSError as exc:
3367
+ warnings.append(f"could not write reportAdequacy: {exc}")
3368
+ if not adequacy_result.get("ok"):
3369
+ payload["issues"] = adequacy_result.get("issues") or []
3370
+ payload["error"] = "report adequacy failed; staged operation discarded"
3371
+ _restore_finalize_failure()
3372
+ payload["warnings"] = warnings
3373
+ payload["ok"] = False
3374
+ return 1, payload
3375
+
3172
3376
  # --- 6. render (Node, else Python fallback) ---
3173
3377
  render_result = render_final_summary(work_dir, summary_path)
3174
3378
  payload["steps"]["render"] = render_result
@@ -224,6 +224,60 @@ def _layout_fields(project_root: Path, change_id: str) -> dict[str, Any]:
224
224
  }
225
225
 
226
226
 
227
+ def read_concurrency_mode(project_root: Path) -> str:
228
+ """Return the configured concurrency mode (retro §5.2).
229
+
230
+ Defaults to ``single-active`` when no config declares a mode. Supported
231
+ values: ``single-active`` (default, blocks a second active Change),
232
+ ``isolated-multi-active`` (allows multiple active Changes but all
233
+ Change-scoped commands require ``--change``).
234
+ """
235
+ cfg = project_root / ".harness" / "config.json"
236
+ if cfg.is_file():
237
+ try:
238
+ data = json.loads(cfg.read_text(encoding="utf-8"))
239
+ if isinstance(data, dict):
240
+ mode = data.get("concurrencyMode")
241
+ if isinstance(mode, str) and mode in {
242
+ "single-active",
243
+ "isolated-multi-active",
244
+ }:
245
+ return mode
246
+ except (OSError, json.JSONDecodeError):
247
+ pass
248
+ return "single-active"
249
+
250
+
251
+ def check_concurrency_block(
252
+ project_root: Path, change_id: str
253
+ ) -> dict[str, Any] | None:
254
+ """Return a blocking payload when a second active Change is forbidden.
255
+
256
+ Returns ``None`` when the begin may proceed. In ``single-active`` mode a
257
+ second active Change (any other than ``change_id``) blocks begin; in
258
+ ``isolated-multi-active`` mode multiple active Changes are allowed.
259
+ """
260
+ mode = read_concurrency_mode(project_root)
261
+ if mode == "isolated-multi-active":
262
+ return None
263
+ active = list_active_changes(project_root)
264
+ others = [entry for entry in active if entry.get("changeId") != change_id]
265
+ if not others:
266
+ return None
267
+ return {
268
+ "ok": False,
269
+ "code": "SINGLE_ACTIVE_BLOCKED",
270
+ "message": (
271
+ "single-active concurrency mode: another active Change exists; "
272
+ "use portfolio/decompose to sequence multiple Changes, or switch "
273
+ "to isolated-multi-active after ensuring full Change-scoped isolation"
274
+ ),
275
+ "concurrencyMode": mode,
276
+ "activeChanges": active,
277
+ "blockingChanges": others,
278
+ }
279
+
280
+
227
281
  def resolve_change(
228
282
  project_root: Path,
229
283
  change_id: str | None,
@@ -536,7 +590,9 @@ def lease_port(
536
590
  }
537
591
  for port in range(start, end + 1):
538
592
  if port not in used:
593
+ lease_id = str(uuid.uuid4())
539
594
  entry = {
595
+ "leaseId": lease_id,
540
596
  "changeId": change_id,
541
597
  "runId": run_id,
542
598
  "pid": os.getpid(),
@@ -549,7 +605,7 @@ def lease_port(
549
605
  leases.append(entry)
550
606
  registry["leases"] = leases
551
607
  _write_json(registry_path, registry)
552
- return {"ok": True, "code": "PORT_LEASED", "port": port, "lease": entry}
608
+ return {"ok": True, "code": "PORT_LEASED", "port": port, "leaseId": lease_id, "lease": entry}
553
609
  return {
554
610
  "ok": False,
555
611
  "code": "PORT_RANGE_EXHAUSTED",
@@ -562,7 +618,16 @@ def release_port(
562
618
  *,
563
619
  change_id: str,
564
620
  run_id: str,
621
+ port: int | None = None,
622
+ lease_id: str | None = None,
565
623
  ) -> dict[str, Any]:
624
+ """Release port leases by subset (retro §5.16).
625
+
626
+ - `--lease-id`: release only the matching leaseId.
627
+ - `--port`: release only the matching port (must match changeId+runId).
628
+ - Neither: release all leases matching (changeId, runId) — does NOT require
629
+ all leases under changeId to have the same runId.
630
+ """
566
631
  registry_path = project_root / PORTS_REL / "registry.json"
567
632
  if not registry_path.is_file():
568
633
  return {"ok": True, "code": "PORT_LEASE_ABSENT", "changeId": change_id}
@@ -574,27 +639,84 @@ def release_port(
574
639
  leases = registry.get("leases") if isinstance(registry, dict) else None
575
640
  if not isinstance(leases, list):
576
641
  return {"ok": False, "code": "PORT_REGISTRY_INVALID", "message": "leases is not a list"}
577
- owned = [
578
- item for item in leases
579
- if isinstance(item, dict) and str(item.get("changeId")) == change_id
580
- ]
581
- if not owned:
642
+
643
+ # Select the subset to release based on lease_id, port, or (changeId, runId)
644
+ if lease_id is not None:
645
+ to_release = [
646
+ item for item in leases
647
+ if isinstance(item, dict) and str(item.get("leaseId")) == lease_id
648
+ ]
649
+ # Validate that the lease belongs to this change
650
+ for item in to_release:
651
+ if str(item.get("changeId")) != change_id:
652
+ return {
653
+ "ok": False,
654
+ "code": "PORT_LEASE_OWNER_MISMATCH",
655
+ "message": "leaseId does not belong to this change",
656
+ "holder": item,
657
+ }
658
+ elif port is not None:
659
+ to_release = [
660
+ item for item in leases
661
+ if isinstance(item, dict)
662
+ and item.get("port") == port
663
+ and str(item.get("changeId")) == change_id
664
+ ]
665
+ # Validate runId matches
666
+ for item in to_release:
667
+ if str(item.get("runId")) != run_id:
668
+ return {
669
+ "ok": False,
670
+ "code": "PORT_LEASE_OWNER_MISMATCH",
671
+ "message": f"port {port} owned by different runId",
672
+ "holder": item,
673
+ "conflictingOwners": [
674
+ {"runId": str(i.get("runId")), "port": i.get("port")}
675
+ for i in leases
676
+ if isinstance(i, dict)
677
+ and i.get("port") == port
678
+ and str(i.get("changeId")) == change_id
679
+ ],
680
+ }
681
+ else:
682
+ # Release all matching (changeId, runId) — subset release
683
+ to_release = [
684
+ item for item in leases
685
+ if isinstance(item, dict)
686
+ and str(item.get("changeId")) == change_id
687
+ and str(item.get("runId")) == run_id
688
+ ]
689
+
690
+ if not to_release:
691
+ # Check if there are other leases under this changeId (different runId)
692
+ other_owned = [
693
+ item for item in leases
694
+ if isinstance(item, dict)
695
+ and str(item.get("changeId")) == change_id
696
+ and str(item.get("runId")) != run_id
697
+ ]
698
+ if other_owned:
699
+ return {
700
+ "ok": False,
701
+ "code": "PORT_LEASE_OWNER_MISMATCH",
702
+ "message": "no matching leases for this runId; other runIds exist under this changeId",
703
+ "conflictingOwners": [
704
+ {"runId": str(i.get("runId")), "port": i.get("port"), "leaseId": i.get("leaseId")}
705
+ for i in other_owned
706
+ ],
707
+ }
582
708
  return {"ok": True, "code": "PORT_LEASE_ABSENT", "changeId": change_id}
583
- if any(str(item.get("runId")) != run_id for item in owned):
584
- return {
585
- "ok": False,
586
- "code": "PORT_LEASE_OWNER_MISMATCH",
587
- "message": "run id does not match port lease owner",
588
- "holder": owned[0],
589
- }
590
- released_ports = [item.get("port") for item in owned]
591
- registry["leases"] = [item for item in leases if item not in owned]
709
+
710
+ released_ports = [item.get("port") for item in to_release]
711
+ released_ids = [item.get("leaseId") for item in to_release]
712
+ registry["leases"] = [item for item in leases if item not in to_release]
592
713
  _write_json(registry_path, registry)
593
714
  return {
594
715
  "ok": True,
595
716
  "code": "PORT_LEASE_RELEASED",
596
717
  "changeId": change_id,
597
718
  "ports": released_ports,
719
+ "leaseIds": released_ids,
598
720
  }
599
721
 
600
722
 
@@ -794,7 +916,13 @@ def cmd_lease_port(args: argparse.Namespace) -> int:
794
916
 
795
917
  def cmd_release_port(args: argparse.Namespace) -> int:
796
918
  project = resolve_main_project_root()
797
- payload = release_port(project, change_id=args.change, run_id=args.run_id)
919
+ payload = release_port(
920
+ project,
921
+ change_id=args.change,
922
+ run_id=args.run_id,
923
+ port=getattr(args, "port", None),
924
+ lease_id=getattr(args, "lease_id", None),
925
+ )
798
926
  if payload.get("ok"):
799
927
  emit(payload, as_json=bool(args.json))
800
928
  return 0
@@ -827,6 +955,161 @@ def cmd_integration_lock(args: argparse.Namespace) -> int:
827
955
  )
828
956
 
829
957
 
958
+ def _ensure_change_identity(change_dir: Path) -> dict[str, str]:
959
+ """Ensure meta/change-identity.json exists with a stable UUID (retro §5.5).
960
+
961
+ Returns the identity dict with changeUuid and changeName.
962
+ """
963
+ identity_path = change_dir / "meta" / "change-identity.json"
964
+ if identity_path.is_file():
965
+ try:
966
+ data = json.loads(identity_path.read_text(encoding="utf-8-sig"))
967
+ if isinstance(data, dict) and data.get("changeUuid"):
968
+ return data
969
+ except (OSError, json.JSONDecodeError):
970
+ pass
971
+ # Generate new identity
972
+ identity = {
973
+ "schemaVersion": 1,
974
+ "changeUuid": str(uuid.uuid4()),
975
+ "changeName": change_dir.name,
976
+ "createdAt": now_iso(),
977
+ }
978
+ identity_path.parent.mkdir(parents=True, exist_ok=True)
979
+ identity_path.write_text(
980
+ json.dumps(identity, ensure_ascii=False, indent=2) + "\n",
981
+ encoding="utf-8",
982
+ newline="\n",
983
+ )
984
+ return identity
985
+
986
+
987
+ def cmd_rename(args: argparse.Namespace) -> int:
988
+ """Rename a change atomically: directory, pointers, worktree, identity (retro §5.5).
989
+
990
+ Appends a change.rename event; does not rewrite history.
991
+ """
992
+ project = resolve_main_project_root()
993
+ changes_root = project / ".harness" / "changes"
994
+ old_dir = changes_root / args.change
995
+ new_dir = changes_root / args.to
996
+
997
+ if not old_dir.is_dir():
998
+ return emit_error(
999
+ "CHANGE_NOT_FOUND",
1000
+ f"change directory not found: {old_dir}",
1001
+ as_json=bool(args.json),
1002
+ )
1003
+ if new_dir.exists():
1004
+ return emit_error(
1005
+ "CHANGE_ALREADY_EXISTS",
1006
+ f"target change directory already exists: {new_dir}",
1007
+ as_json=bool(args.json),
1008
+ )
1009
+
1010
+ # Ensure identity exists before rename
1011
+ identity = _ensure_change_identity(old_dir)
1012
+ old_uuid = identity.get("changeUuid", "")
1013
+
1014
+ # Rename directory
1015
+ import shutil
1016
+ shutil.move(str(old_dir), str(new_dir))
1017
+
1018
+ # Update knowledge-context.json.changeId if present
1019
+ kc_path = new_dir / "meta" / "knowledge-context.json"
1020
+ if kc_path.is_file():
1021
+ try:
1022
+ kc = json.loads(kc_path.read_text(encoding="utf-8-sig"))
1023
+ if isinstance(kc, dict):
1024
+ kc["changeId"] = args.to
1025
+ kc["changeUuid"] = old_uuid
1026
+ kc_path.write_text(
1027
+ json.dumps(kc, ensure_ascii=False, indent=2) + "\n",
1028
+ encoding="utf-8",
1029
+ newline="\n",
1030
+ )
1031
+ except (OSError, json.JSONDecodeError):
1032
+ pass
1033
+
1034
+ # Update worktree.json path/branch if present
1035
+ wt_path = new_dir / "meta" / "worktree.json"
1036
+ if wt_path.is_file():
1037
+ try:
1038
+ wt = json.loads(wt_path.read_text(encoding="utf-8-sig"))
1039
+ if isinstance(wt, dict):
1040
+ old_name = args.change
1041
+ new_name = args.to
1042
+ # Update path and branch if they contain old name
1043
+ if isinstance(wt.get("path"), str) and old_name in wt["path"]:
1044
+ wt["path"] = wt["path"].replace(old_name, new_name)
1045
+ if isinstance(wt.get("branch"), str) and old_name in wt["branch"]:
1046
+ wt["branch"] = wt["branch"].replace(old_name, new_name)
1047
+ wt_path.write_text(
1048
+ json.dumps(wt, ensure_ascii=False, indent=2) + "\n",
1049
+ encoding="utf-8",
1050
+ newline="\n",
1051
+ )
1052
+ except (OSError, json.JSONDecodeError):
1053
+ pass
1054
+
1055
+ # Update change-identity.json.changeName
1056
+ identity_path = new_dir / "meta" / "change-identity.json"
1057
+ if identity_path.is_file():
1058
+ try:
1059
+ ident = json.loads(identity_path.read_text(encoding="utf-8-sig"))
1060
+ if isinstance(ident, dict):
1061
+ ident["changeName"] = args.to
1062
+ ident["renamedFrom"] = args.change
1063
+ identity_path.write_text(
1064
+ json.dumps(ident, ensure_ascii=False, indent=2) + "\n",
1065
+ encoding="utf-8",
1066
+ newline="\n",
1067
+ )
1068
+ except (OSError, json.JSONDecodeError):
1069
+ pass
1070
+
1071
+ # Append change.rename event (does not rewrite history)
1072
+ try:
1073
+ import harness_events
1074
+ harness_events.append_event(
1075
+ new_dir,
1076
+ phase="plan",
1077
+ type_="change.rename",
1078
+ renamed_from=args.change,
1079
+ renamed_to=args.to,
1080
+ change_uuid=old_uuid,
1081
+ note=f"change renamed from {args.change} to {args.to}",
1082
+ )
1083
+ except Exception:
1084
+ pass # event append failure should not block rename
1085
+
1086
+ payload = {
1087
+ "ok": True,
1088
+ "code": "RENAMED",
1089
+ "changeUuid": old_uuid,
1090
+ "renamedFrom": args.change,
1091
+ "renamedTo": args.to,
1092
+ "changeDir": str(new_dir),
1093
+ }
1094
+ emit(payload, as_json=bool(args.json))
1095
+ return 0
1096
+
1097
+
1098
+ def cmd_ensure_identity(args: argparse.Namespace) -> int:
1099
+ """Ensure meta/change-identity.json exists with a stable UUID (retro §5.5)."""
1100
+ project = resolve_main_project_root()
1101
+ change_dir = project / ".harness" / "changes" / args.change
1102
+ if not change_dir.is_dir():
1103
+ return emit_error(
1104
+ "CHANGE_NOT_FOUND",
1105
+ f"change directory not found: {change_dir}",
1106
+ as_json=bool(args.json),
1107
+ )
1108
+ identity = _ensure_change_identity(change_dir)
1109
+ emit(identity, as_json=bool(args.json))
1110
+ return 0
1111
+
1112
+
830
1113
  def build_parser() -> argparse.ArgumentParser:
831
1114
  parser = argparse.ArgumentParser(prog="harness_change.py")
832
1115
  parser.add_argument("--json", action="store_true")
@@ -869,6 +1152,8 @@ def build_parser() -> argparse.ArgumentParser:
869
1152
  p_port_release = sub.add_parser("release-port", parents=[shared])
870
1153
  p_port_release.add_argument("--change", required=True)
871
1154
  p_port_release.add_argument("--run-id", required=True)
1155
+ p_port_release.add_argument("--port", type=int, default=None)
1156
+ p_port_release.add_argument("--lease-id", default=None)
872
1157
  p_port_release.set_defaults(func=cmd_release_port)
873
1158
 
874
1159
  p_lock = sub.add_parser("integration-lock", parents=[shared])
@@ -877,6 +1162,15 @@ def build_parser() -> argparse.ArgumentParser:
877
1162
  p_lock.add_argument("--ttl-seconds", type=int, default=3600)
878
1163
  p_lock.set_defaults(func=cmd_integration_lock)
879
1164
 
1165
+ p_rename = sub.add_parser("rename", parents=[shared])
1166
+ p_rename.add_argument("--change", required=True)
1167
+ p_rename.add_argument("--to", required=True)
1168
+ p_rename.set_defaults(func=cmd_rename)
1169
+
1170
+ p_identity = sub.add_parser("ensure-identity", parents=[shared])
1171
+ p_identity.add_argument("--change", required=True)
1172
+ p_identity.set_defaults(func=cmd_ensure_identity)
1173
+
880
1174
  return parser
881
1175
 
882
1176