@hunter-harness/workflow-harness 0.2.27 → 0.2.28

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 +1 -1
  3. package/harness/bundles/general/claude-code/harness-archive/templates/render-summary.mjs +25 -6
  4. package/harness/bundles/general/claude-code/harness-archive/templates/summary-data-template.json +44 -0
  5. package/harness/bundles/general/claude-code/harness-codebase-map/SKILL.md +1 -1
  6. package/harness/bundles/general/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  7. package/harness/bundles/general/claude-code/harness-knowledge-ingest/scripts/harness_knowledge.py +493 -74
  8. package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +1 -1
  9. package/harness/bundles/general/claude-code/harness-plan/SKILL.md +1 -1
  10. package/harness/bundles/general/claude-code/harness-review/SKILL.md +1 -1
  11. package/harness/bundles/general/claude-code/harness-run/SKILL.md +1 -1
  12. package/harness/bundles/general/claude-code/harness-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 +2061 -187
  16. package/harness/bundles/general/claude-code/scripts/harness_events.py +43 -9
  17. package/harness/bundles/general/claude-code/scripts/harness_gate.py +491 -7
  18. package/harness/bundles/general/claude-code/scripts/harness_integration.py +232 -0
  19. package/harness/bundles/general/claude-code/scripts/harness_phase.py +758 -1
  20. package/harness/bundles/general/claude-code/scripts/harness_plan_finalize.py +74 -0
  21. package/harness/bundles/general/claude-code/scripts/harness_retry.py +175 -0
  22. package/harness/bundles/general/claude-code/scripts/harness_test_guard.py +702 -22
  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-archive/templates/render-summary.mjs +25 -6
  26. package/harness/bundles/general/codebuddy/harness-archive/templates/summary-data-template.json +44 -0
  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-ingest/scripts/harness_knowledge.py +493 -74
  30. package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  31. package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +1 -1
  32. package/harness/bundles/general/codebuddy/harness-review/SKILL.md +1 -1
  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 +2061 -187
  38. package/harness/bundles/general/codebuddy/scripts/harness_events.py +43 -9
  39. package/harness/bundles/general/codebuddy/scripts/harness_gate.py +491 -7
  40. package/harness/bundles/general/codebuddy/scripts/harness_integration.py +232 -0
  41. package/harness/bundles/general/codebuddy/scripts/harness_phase.py +758 -1
  42. package/harness/bundles/general/codebuddy/scripts/harness_plan_finalize.py +74 -0
  43. package/harness/bundles/general/codebuddy/scripts/harness_retry.py +175 -0
  44. package/harness/bundles/general/codebuddy/scripts/harness_test_guard.py +702 -22
  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-archive/templates/render-summary.mjs +25 -6
  48. package/harness/bundles/general/codex/harness-archive/templates/summary-data-template.json +44 -0
  49. package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
  50. package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +1 -1
  51. package/harness/bundles/general/codex/harness-knowledge-ingest/scripts/harness_knowledge.py +493 -74
  52. package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
  53. package/harness/bundles/general/codex/harness-plan/SKILL.md +1 -1
  54. package/harness/bundles/general/codex/harness-review/SKILL.md +1 -1
  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 +2061 -187
  60. package/harness/bundles/general/codex/scripts/harness_events.py +43 -9
  61. package/harness/bundles/general/codex/scripts/harness_gate.py +491 -7
  62. package/harness/bundles/general/codex/scripts/harness_integration.py +232 -0
  63. package/harness/bundles/general/codex/scripts/harness_phase.py +758 -1
  64. package/harness/bundles/general/codex/scripts/harness_plan_finalize.py +74 -0
  65. package/harness/bundles/general/codex/scripts/harness_retry.py +175 -0
  66. package/harness/bundles/general/codex/scripts/harness_test_guard.py +702 -22
  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-archive/templates/render-summary.mjs +25 -6
  70. package/harness/bundles/general/cursor/harness-archive/templates/summary-data-template.json +44 -0
  71. package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
  72. package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  73. package/harness/bundles/general/cursor/harness-knowledge-ingest/scripts/harness_knowledge.py +493 -74
  74. package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
  75. package/harness/bundles/general/cursor/harness-plan/SKILL.md +1 -1
  76. package/harness/bundles/general/cursor/harness-review/SKILL.md +1 -1
  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 +2061 -187
  82. package/harness/bundles/general/cursor/scripts/harness_events.py +43 -9
  83. package/harness/bundles/general/cursor/scripts/harness_gate.py +491 -7
  84. package/harness/bundles/general/cursor/scripts/harness_integration.py +232 -0
  85. package/harness/bundles/general/cursor/scripts/harness_phase.py +758 -1
  86. package/harness/bundles/general/cursor/scripts/harness_plan_finalize.py +74 -0
  87. package/harness/bundles/general/cursor/scripts/harness_retry.py +175 -0
  88. package/harness/bundles/general/cursor/scripts/harness_test_guard.py +702 -22
  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-archive/templates/render-summary.mjs +25 -6
  93. package/harness/bundles/java/claude-code/harness-archive/templates/summary-data-template.json +44 -0
  94. package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
  95. package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  96. package/harness/bundles/java/claude-code/harness-knowledge-ingest/scripts/harness_knowledge.py +493 -74
  97. package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
  98. package/harness/bundles/java/claude-code/harness-package/SKILL.md +1 -1
  99. package/harness/bundles/java/claude-code/harness-plan/SKILL.md +1 -1
  100. package/harness/bundles/java/claude-code/harness-review/SKILL.md +1 -1
  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 +2061 -187
  106. package/harness/bundles/java/claude-code/scripts/harness_events.py +43 -9
  107. package/harness/bundles/java/claude-code/scripts/harness_gate.py +491 -7
  108. package/harness/bundles/java/claude-code/scripts/harness_integration.py +232 -0
  109. package/harness/bundles/java/claude-code/scripts/harness_phase.py +758 -1
  110. package/harness/bundles/java/claude-code/scripts/harness_plan_finalize.py +74 -0
  111. package/harness/bundles/java/claude-code/scripts/harness_retry.py +175 -0
  112. package/harness/bundles/java/claude-code/scripts/harness_test_guard.py +702 -22
  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 +1 -1
  116. package/harness/bundles/java/codebuddy/harness-archive/templates/render-summary.mjs +25 -6
  117. package/harness/bundles/java/codebuddy/harness-archive/templates/summary-data-template.json +44 -0
  118. package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
  119. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  120. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge.py +493 -74
  121. package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  122. package/harness/bundles/java/codebuddy/harness-package/SKILL.md +1 -1
  123. package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +1 -1
  124. package/harness/bundles/java/codebuddy/harness-review/SKILL.md +1 -1
  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 +2061 -187
  130. package/harness/bundles/java/codebuddy/scripts/harness_events.py +43 -9
  131. package/harness/bundles/java/codebuddy/scripts/harness_gate.py +491 -7
  132. package/harness/bundles/java/codebuddy/scripts/harness_integration.py +232 -0
  133. package/harness/bundles/java/codebuddy/scripts/harness_phase.py +758 -1
  134. package/harness/bundles/java/codebuddy/scripts/harness_plan_finalize.py +74 -0
  135. package/harness/bundles/java/codebuddy/scripts/harness_retry.py +175 -0
  136. package/harness/bundles/java/codebuddy/scripts/harness_test_guard.py +702 -22
  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 +1 -1
  140. package/harness/bundles/java/codex/harness-archive/templates/render-summary.mjs +25 -6
  141. package/harness/bundles/java/codex/harness-archive/templates/summary-data-template.json +44 -0
  142. package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
  143. package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +1 -1
  144. package/harness/bundles/java/codex/harness-knowledge-ingest/scripts/harness_knowledge.py +493 -74
  145. package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
  146. package/harness/bundles/java/codex/harness-package/SKILL.md +1 -1
  147. package/harness/bundles/java/codex/harness-plan/SKILL.md +1 -1
  148. package/harness/bundles/java/codex/harness-review/SKILL.md +1 -1
  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 +2061 -187
  154. package/harness/bundles/java/codex/scripts/harness_events.py +43 -9
  155. package/harness/bundles/java/codex/scripts/harness_gate.py +491 -7
  156. package/harness/bundles/java/codex/scripts/harness_integration.py +232 -0
  157. package/harness/bundles/java/codex/scripts/harness_phase.py +758 -1
  158. package/harness/bundles/java/codex/scripts/harness_plan_finalize.py +74 -0
  159. package/harness/bundles/java/codex/scripts/harness_retry.py +175 -0
  160. package/harness/bundles/java/codex/scripts/harness_test_guard.py +702 -22
  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 +1 -1
  164. package/harness/bundles/java/cursor/harness-archive/templates/render-summary.mjs +25 -6
  165. package/harness/bundles/java/cursor/harness-archive/templates/summary-data-template.json +44 -0
  166. package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
  167. package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  168. package/harness/bundles/java/cursor/harness-knowledge-ingest/scripts/harness_knowledge.py +493 -74
  169. package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
  170. package/harness/bundles/java/cursor/harness-package/SKILL.md +1 -1
  171. package/harness/bundles/java/cursor/harness-plan/SKILL.md +1 -1
  172. package/harness/bundles/java/cursor/harness-review/SKILL.md +1 -1
  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 +2061 -187
  178. package/harness/bundles/java/cursor/scripts/harness_events.py +43 -9
  179. package/harness/bundles/java/cursor/scripts/harness_gate.py +491 -7
  180. package/harness/bundles/java/cursor/scripts/harness_integration.py +232 -0
  181. package/harness/bundles/java/cursor/scripts/harness_phase.py +758 -1
  182. package/harness/bundles/java/cursor/scripts/harness_plan_finalize.py +74 -0
  183. package/harness/bundles/java/cursor/scripts/harness_retry.py +175 -0
  184. package/harness/bundles/java/cursor/scripts/harness_test_guard.py +702 -22
  185. package/harness/manifests/general/claude-code.json +26 -22
  186. package/harness/manifests/general/codebuddy.json +26 -22
  187. package/harness/manifests/general/codex.json +26 -22
  188. package/harness/manifests/general/cursor.json +26 -22
  189. package/harness/manifests/java/claude-code.json +28 -24
  190. package/harness/manifests/java/codebuddy.json +28 -24
  191. package/harness/manifests/java/codex.json +28 -24
  192. package/harness/manifests/java/cursor.json +28 -24
  193. package/hunter-workflow-family.json +2 -2
  194. package/package.json +1 -1
@@ -160,6 +160,118 @@ def _git_is_ancestor(cwd: Path, ancestor: str, descendant: str) -> bool:
160
160
  return proc.returncode == 0
161
161
 
162
162
 
163
+ def evaluate_projection_gate(
164
+ project: Path,
165
+ phase: str,
166
+ ) -> dict[str, Any]:
167
+ """Validate three-stage projection identity and apply phase fallback policy."""
168
+ project = project.resolve()
169
+ path = project / ".harness" / "config" / "projection-receipt.json"
170
+ release_phase = phase in {"submit", "archive", "release", "deploy"}
171
+ if not path.is_file():
172
+ return {
173
+ "ok": True,
174
+ "code": "PROJECTION_RECEIPT_NOT_CONFIGURED",
175
+ "status": "legacy",
176
+ "mode": "legacy",
177
+ "receiptPath": str(path),
178
+ "releasePhase": release_phase,
179
+ }
180
+ try:
181
+ receipt = _read_json(path)
182
+ except (OSError, json.JSONDecodeError) as exc:
183
+ receipt = {}
184
+ parse_error = str(exc)
185
+ else:
186
+ parse_error = None
187
+ source = receipt.get("source")
188
+ transform = receipt.get("transform")
189
+ target = receipt.get("target")
190
+ source = source if isinstance(source, dict) else {}
191
+ transform = transform if isinstance(transform, dict) else {}
192
+ target = target if isinstance(target, dict) else {}
193
+ digest_pattern = re.compile(r"sha256:[0-9a-fA-F]{64}")
194
+ issues = []
195
+ if receipt.get("schemaVersion") != 1:
196
+ issues.append("schemaVersion")
197
+ for label, value in (
198
+ ("source.hash", source.get("hash")),
199
+ ("transform.hash", transform.get("hash")),
200
+ ("target.hash", target.get("hash")),
201
+ ):
202
+ if not isinstance(value, str) or not digest_pattern.fullmatch(value):
203
+ issues.append(label)
204
+ if not str(transform.get("version") or "").strip():
205
+ issues.append("transform.version")
206
+ if not isinstance(receipt.get("projectOwnedExclusions"), list):
207
+ issues.append("projectOwnedExclusions")
208
+ if not str(receipt.get("generatedAt") or "").strip():
209
+ issues.append("generatedAt")
210
+ status = str(receipt.get("status") or "").strip().lower()
211
+ if status not in {"verified", "degraded"}:
212
+ issues.append("status")
213
+ status = "degraded"
214
+ if parse_error:
215
+ issues.append("parse")
216
+ receipt_hash = "sha256:" + hashlib.sha256(
217
+ json.dumps(
218
+ receipt,
219
+ ensure_ascii=False,
220
+ sort_keys=True,
221
+ separators=(",", ":"),
222
+ ).encode("utf-8")
223
+ ).hexdigest()
224
+ degraded = status == "degraded" or bool(issues)
225
+ if degraded and release_phase:
226
+ return {
227
+ "ok": False,
228
+ "code": "PROJECTION_DEGRADED_BLOCKED",
229
+ "message": (
230
+ "adapter/config projection is degraded; refresh the projection "
231
+ "receipt before submit/archive/release"
232
+ ),
233
+ "status": "degraded",
234
+ "mode": "blocked",
235
+ "issues": issues,
236
+ "receipt": receipt,
237
+ "receiptHash": receipt_hash,
238
+ "receiptPath": str(path),
239
+ "releasePhase": True,
240
+ "remediation": (
241
+ "regenerate .harness/config/projection-receipt.json from the "
242
+ "project truth source"
243
+ ),
244
+ }
245
+ return {
246
+ "ok": True,
247
+ "code": (
248
+ "PROJECTION_VERIFIED"
249
+ if not degraded
250
+ else "PROJECTION_DEGRADED_FALLBACK"
251
+ ),
252
+ "message": (
253
+ "projection receipt verified"
254
+ if not degraded
255
+ else "degraded projection allowed only in explicit fallback mode"
256
+ ),
257
+ "status": "verified" if not degraded else "degraded",
258
+ "mode": "normal" if not degraded else "fallback",
259
+ "issues": issues,
260
+ "receipt": receipt,
261
+ "receiptHash": receipt_hash,
262
+ "receiptPath": str(path),
263
+ "releasePhase": release_phase,
264
+ "remediation": (
265
+ None
266
+ if not degraded
267
+ else (
268
+ "regenerate .harness/config/projection-receipt.json from the "
269
+ "project truth source before any release phase"
270
+ )
271
+ ),
272
+ }
273
+
274
+
163
275
  def resolve_execution_root(main_project: Path, raw: str | None) -> Path:
164
276
  candidate = Path(raw).expanduser().resolve() if raw else main_project.resolve()
165
277
  if not candidate.is_dir():
@@ -173,6 +285,46 @@ def resolve_execution_root(main_project: Path, raw: str | None) -> Path:
173
285
  return root
174
286
 
175
287
 
288
+ def resolve_begin_execution_hint(
289
+ main_project: Path,
290
+ *,
291
+ requested: str | None,
292
+ capsule: dict[str, Any] | None,
293
+ cwd: Path | None = None,
294
+ ) -> dict[str, str]:
295
+ """Choose an execution root explicitly, from the capsule, or from Git cwd."""
296
+ if requested:
297
+ return {
298
+ "executionRoot": str(Path(requested).expanduser().resolve()),
299
+ "source": "explicit",
300
+ }
301
+ if isinstance(capsule, dict) and str(capsule.get("executionRoot") or "").strip():
302
+ return {
303
+ "executionRoot": str(capsule["executionRoot"]),
304
+ "source": "phase-capsule",
305
+ }
306
+ current = (cwd or Path.cwd()).resolve()
307
+ try:
308
+ top = _git_text(current, "rev-parse", "--show-toplevel")
309
+ detected = Path(top).resolve() if top else None
310
+ if (
311
+ detected is not None
312
+ and detected.is_dir()
313
+ and hp.repository_identity(detected)
314
+ == hp.repository_identity(main_project)
315
+ ):
316
+ return {
317
+ "executionRoot": str(detected),
318
+ "source": "cwd-git-root",
319
+ }
320
+ except (OSError, RuntimeError, ValueError):
321
+ pass
322
+ return {
323
+ "executionRoot": str(main_project.resolve()),
324
+ "source": "main-project-default",
325
+ }
326
+
327
+
176
328
  def _phase_capsule_path(change_dir: Path, phase: str, run_id: str) -> Path:
177
329
  key = hashlib.sha256(f"{phase}\0{run_id}".encode("utf-8")).hexdigest()[:20]
178
330
  return (
@@ -206,6 +358,37 @@ def write_phase_capsule(
206
358
  return path
207
359
 
208
360
 
361
+ def persist_close_failure(
362
+ change_dir: Path,
363
+ phase: str,
364
+ run_id: str,
365
+ capsule: dict[str, Any] | None,
366
+ *,
367
+ status: str,
368
+ error: dict[str, Any],
369
+ ) -> None:
370
+ """Journal a retryable close failure before returning to the caller."""
371
+ if capsule is None:
372
+ return
373
+ transaction = capsule.get("closeTransaction")
374
+ transaction = dict(transaction) if isinstance(transaction, dict) else {}
375
+ transaction.update(
376
+ {
377
+ "status": status,
378
+ "retryable": True,
379
+ "guardClosed": bool(transaction.get("guardClosed")),
380
+ "phaseEndRecorded": bool(transaction.get("phaseEndRecorded")),
381
+ "leaseReleased": False,
382
+ "lastError": error,
383
+ "updatedAt": he.now_iso(),
384
+ }
385
+ )
386
+ capsule["closeTransaction"] = transaction
387
+ capsule.pop("closeStatus", None)
388
+ capsule.pop("closedAt", None)
389
+ write_phase_capsule(change_dir, phase, run_id, capsule)
390
+
391
+
209
392
  def validate_phase_capsule(
210
393
  capsule: dict[str, Any],
211
394
  *,
@@ -770,6 +953,145 @@ def _ordered_unique(items: list[str]) -> list[str]:
770
953
  return list(dict.fromkeys(items))
771
954
 
772
955
 
956
+ FINAL_SEQUENCE_CANONICAL_ORDER = (
957
+ "complete-review",
958
+ "code-freeze",
959
+ "unit-test-full",
960
+ "delta-review",
961
+ "submit-reuse",
962
+ "remote-candidate-ci",
963
+ "archive",
964
+ )
965
+
966
+
967
+ def _sequence_slug(value: str) -> str:
968
+ expanded = re.sub(r"(?<=[a-z0-9])(?=[A-Z])", "-", value.strip())
969
+ return re.sub(r"[^a-z0-9]+", "-", expanded.lower()).strip("-")
970
+
971
+
972
+ def compile_final_sequence_dag(
973
+ base_dag: dict[str, Any],
974
+ sequence: list[Any],
975
+ ) -> dict[str, Any]:
976
+ """Compile a declared release sequence into an executable, versioned DAG."""
977
+ raw_nodes = base_dag.get("nodes")
978
+ raw_edges = base_dag.get("edges")
979
+ if not isinstance(raw_nodes, list) or not isinstance(raw_edges, list):
980
+ raise ValueError("base requiredGateDag must contain nodes and edges arrays")
981
+ if not sequence:
982
+ raise ValueError("finalSequence must not be empty")
983
+
984
+ entries: list[dict[str, Any]] = []
985
+ seen: set[str] = set()
986
+ for index, raw in enumerate(sequence):
987
+ if isinstance(raw, str):
988
+ name = raw
989
+ metadata: dict[str, Any] = {}
990
+ elif isinstance(raw, dict):
991
+ name = str(raw.get("id") or raw.get("name") or raw.get("step") or "")
992
+ metadata = {
993
+ key: value
994
+ for key, value in raw.items()
995
+ if key not in {"id", "name", "step", "dependsOn", "kind"}
996
+ }
997
+ else:
998
+ raise ValueError(f"finalSequence[{index}] must be a string or object")
999
+ slug = _sequence_slug(name)
1000
+ if not slug:
1001
+ raise ValueError(f"finalSequence[{index}] requires a non-empty name")
1002
+ if slug in seen:
1003
+ raise ValueError(f"duplicate finalSequence step: {slug}")
1004
+ seen.add(slug)
1005
+ entries.append({"slug": slug, "metadata": metadata})
1006
+
1007
+ positions = {entry["slug"]: index for index, entry in enumerate(entries)}
1008
+ present_order = [
1009
+ name for name in FINAL_SEQUENCE_CANONICAL_ORDER if name in positions
1010
+ ]
1011
+ for left, right in zip(present_order, present_order[1:]):
1012
+ if positions[left] > positions[right]:
1013
+ raise ValueError(
1014
+ f"finalSequence must place {left} before {right}; "
1015
+ "code-freeze must precede unit-test-full"
1016
+ )
1017
+ if "archive" in positions and positions["archive"] != len(entries) - 1:
1018
+ raise ValueError("finalSequence archive step must be last")
1019
+
1020
+ nodes = [dict(node) for node in raw_nodes if isinstance(node, dict)]
1021
+ edges = [dict(edge) for edge in raw_edges if isinstance(edge, dict)]
1022
+ base_ids = {
1023
+ str(node.get("id") or "")
1024
+ for node in nodes
1025
+ if str(node.get("id") or "")
1026
+ }
1027
+ depended_on = {
1028
+ str(dependency)
1029
+ for node in nodes
1030
+ for dependency in (node.get("dependsOn") or [])
1031
+ if isinstance(dependency, str)
1032
+ }
1033
+ base_terminals = sorted(base_ids - depended_on)
1034
+
1035
+ sequence_ids: list[str] = []
1036
+ predecessor_ids = base_terminals
1037
+ freeze_id = "sequence:code-freeze"
1038
+ for index, entry in enumerate(entries):
1039
+ node_id = f"sequence:{entry['slug']}"
1040
+ sequence_ids.append(node_id)
1041
+ node = {
1042
+ "id": node_id,
1043
+ "kind": "sequence",
1044
+ "phase": "archive",
1045
+ "sequenceIndex": index,
1046
+ "dependsOn": list(predecessor_ids),
1047
+ "subjectBound": True,
1048
+ "invalidatedByProductChange": (
1049
+ freeze_id in sequence_ids or node_id == freeze_id
1050
+ ),
1051
+ **entry["metadata"],
1052
+ }
1053
+ nodes.append(node)
1054
+ edges.extend(
1055
+ {"from": dependency, "to": node_id}
1056
+ for dependency in predecessor_ids
1057
+ )
1058
+ predecessor_ids = [node_id]
1059
+
1060
+ return {
1061
+ "schemaVersion": 2,
1062
+ "nodes": nodes,
1063
+ "edges": edges,
1064
+ "finalSequence": {
1065
+ "schemaVersion": 1,
1066
+ "nodeIds": sequence_ids,
1067
+ "freezeNodeId": (
1068
+ freeze_id if freeze_id in sequence_ids else None
1069
+ ),
1070
+ "terminalNodeId": sequence_ids[-1],
1071
+ },
1072
+ }
1073
+
1074
+
1075
+ def _load_configured_final_sequence(
1076
+ project: Path,
1077
+ change_dir: Path,
1078
+ ) -> list[Any] | None:
1079
+ candidates = (
1080
+ change_dir / "meta" / "final-sequence.json",
1081
+ project / ".harness" / "config" / "final-sequence.json",
1082
+ )
1083
+ for path in candidates:
1084
+ if not path.is_file():
1085
+ continue
1086
+ raw = _read_json(path)
1087
+ if isinstance(raw, list):
1088
+ return raw
1089
+ if isinstance(raw, dict) and isinstance(raw.get("finalSequence"), list):
1090
+ return raw["finalSequence"]
1091
+ raise ValueError(f"final sequence configuration is invalid: {path}")
1092
+ return None
1093
+
1094
+
773
1095
  def _apply_required_gate_contract(
774
1096
  payload: dict[str, Any],
775
1097
  workflow: dict[str, Any],
@@ -949,7 +1271,15 @@ def classify_risk(
949
1271
  }
950
1272
  if stage == "post-run":
951
1273
  _write_json(change_dir / "meta" / "risk-classification.json", payload)
952
- return _apply_required_gate_contract(payload, workflow, capabilities)
1274
+ result = _apply_required_gate_contract(payload, workflow, capabilities)
1275
+ configured_sequence = _load_configured_final_sequence(main_project, change_dir)
1276
+ if configured_sequence is not None:
1277
+ result["requiredGateDag"] = compile_final_sequence_dag(
1278
+ result["requiredGateDag"],
1279
+ configured_sequence,
1280
+ )
1281
+ result["finalSequence"] = result["requiredGateDag"]["finalSequence"]
1282
+ return result
953
1283
 
954
1284
 
955
1285
  def _stage_decisions_for_tier(
@@ -993,14 +1323,35 @@ def apply_tier_override(
993
1323
  payload["conditionalStages"] = list(tier_policy["conditionalStages"])
994
1324
  payload["stageDecisions"] = _stage_decisions_for_tier(workflow, tier, signals)
995
1325
  payload["tierOverride"] = {"tier": tier, "by": override_by, "at": now}
996
- return _apply_required_gate_contract(
1326
+ result = _apply_required_gate_contract(
997
1327
  payload, workflow, list(payload.get("capabilities") or [])
998
1328
  )
1329
+ final_sequence = payload.get("finalSequence")
1330
+ node_ids = (
1331
+ final_sequence.get("nodeIds")
1332
+ if isinstance(final_sequence, dict)
1333
+ else None
1334
+ )
1335
+ if isinstance(node_ids, list) and node_ids:
1336
+ sequence = [
1337
+ str(node_id).removeprefix("sequence:")
1338
+ for node_id in node_ids
1339
+ if str(node_id).startswith("sequence:")
1340
+ ]
1341
+ if sequence:
1342
+ result["requiredGateDag"] = compile_final_sequence_dag(
1343
+ result["requiredGateDag"],
1344
+ sequence,
1345
+ )
1346
+ result["finalSequence"] = result["requiredGateDag"][
1347
+ "finalSequence"
1348
+ ]
1349
+ return result
999
1350
 
1000
1351
 
1001
1352
  def gate_policy_document(payload: dict[str, Any]) -> dict[str, Any]:
1002
1353
  """Cross-change contract: meta/gate-policy.json (schemaVersion=1)."""
1003
- return {
1354
+ document = {
1004
1355
  "schemaVersion": 1,
1005
1356
  "tier": payload["tier"],
1006
1357
  "source": payload["source"],
@@ -1016,6 +1367,12 @@ def gate_policy_document(payload: dict[str, Any]) -> dict[str, Any]:
1016
1367
  "classifiedAt": payload.get("classifiedAt") or hc.now_iso(),
1017
1368
  "tierOverride": payload.get("tierOverride"),
1018
1369
  }
1370
+ if isinstance(payload.get("finalSequence"), dict):
1371
+ document["finalSequence"] = dict(payload["finalSequence"])
1372
+ for optional in ("candidateVerification", "releasePolicy"):
1373
+ if isinstance(payload.get(optional), dict):
1374
+ document[optional] = dict(payload[optional])
1375
+ return document
1019
1376
 
1020
1377
 
1021
1378
  def classify_defaults(
@@ -1352,6 +1709,18 @@ def cmd_begin(args: argparse.Namespace) -> int:
1352
1709
  except (OSError, json.JSONDecodeError, ValueError) as exc:
1353
1710
  return emit_error("BUNDLE_IDENTITY_INVALID", str(exc), as_json=as_json)
1354
1711
  executor_tool = executor_tool or str(identity.get("adapter") or "") or None
1712
+ projection = evaluate_projection_gate(project, args.phase)
1713
+ if not projection.get("ok"):
1714
+ return emit_error(
1715
+ str(projection.get("code") or "PROJECTION_BLOCKED"),
1716
+ str(projection.get("message") or "projection gate blocked phase"),
1717
+ as_json=as_json,
1718
+ extra={
1719
+ key: value
1720
+ for key, value in projection.items()
1721
+ if key not in {"ok", "code", "message"}
1722
+ },
1723
+ )
1355
1724
  explicit_run_id = args.run_id or os.environ.get("HUNTER_HARNESS_RUN_ID")
1356
1725
  current_lease = hc.inspect_lease(project, resolved["changeId"])
1357
1726
  run_id = explicit_run_id or (
@@ -1363,11 +1732,16 @@ def cmd_begin(args: argparse.Namespace) -> int:
1363
1732
  capsule = load_phase_capsule(change_dir, args.phase, run_id)
1364
1733
  except ValueError as exc:
1365
1734
  return emit_error("PHASE_CAPSULE_INVALID", str(exc), as_json=as_json)
1366
- execution_hint = args.project
1367
- if not execution_hint and capsule is not None:
1368
- execution_hint = str(capsule.get("executionRoot") or "")
1735
+ execution_hint = resolve_begin_execution_hint(
1736
+ project,
1737
+ requested=args.project,
1738
+ capsule=capsule,
1739
+ )
1369
1740
  try:
1370
- execution_root = resolve_execution_root(project, execution_hint)
1741
+ execution_root = resolve_execution_root(
1742
+ project,
1743
+ execution_hint["executionRoot"],
1744
+ )
1371
1745
  except ValueError as exc:
1372
1746
  return emit_error("EXECUTION_ROOT_INVALID", str(exc), as_json=as_json)
1373
1747
  if capsule is not None:
@@ -1436,6 +1810,7 @@ def cmd_begin(args: argparse.Namespace) -> int:
1436
1810
  "baseCommit": base_commit or current_head,
1437
1811
  "currentHead": current_head,
1438
1812
  "createdAt": he.now_iso(),
1813
+ "projectionReceipt": projection,
1439
1814
  }
1440
1815
  write_phase_capsule(change_dir, args.phase, run_id, capsule)
1441
1816
  event_result = {"ok": True, "skipped": True, "reason": "already-recorded"} \
@@ -1472,6 +1847,8 @@ def cmd_begin(args: argparse.Namespace) -> int:
1472
1847
  "event": event_result,
1473
1848
  "testGuard": guard_result,
1474
1849
  "policySchemaVersion": policy.get("schemaVersion"),
1850
+ "projection": projection,
1851
+ "executionRootSource": execution_hint["source"],
1475
1852
  }
1476
1853
  emit(payload, as_json=as_json)
1477
1854
  return 0
@@ -1529,6 +1906,18 @@ def cmd_close(args: argparse.Namespace) -> int:
1529
1906
  if args.project:
1530
1907
  requested_root = resolve_execution_root(project, args.project)
1531
1908
  if requested_root != execution_root:
1909
+ persist_close_failure(
1910
+ change_dir,
1911
+ args.phase,
1912
+ run_id,
1913
+ capsule,
1914
+ status="ROOT_VALIDATION_FAILED",
1915
+ error={
1916
+ "code": "EXECUTION_ROOT_MISMATCH",
1917
+ "storedExecutionRoot": str(execution_root),
1918
+ "requestedExecutionRoot": str(requested_root),
1919
+ },
1920
+ )
1532
1921
  return emit_error(
1533
1922
  "EXECUTION_ROOT_MISMATCH",
1534
1923
  "close must use the execution root captured at begin",
@@ -1536,9 +1925,24 @@ def cmd_close(args: argparse.Namespace) -> int:
1536
1925
  extra={
1537
1926
  "storedExecutionRoot": str(execution_root),
1538
1927
  "requestedExecutionRoot": str(requested_root),
1928
+ "recoveryAction": (
1929
+ "retry close from the stored execution root or "
1930
+ "pass --project with storedExecutionRoot"
1931
+ ),
1539
1932
  },
1540
1933
  )
1541
1934
  except ValueError as exc:
1935
+ persist_close_failure(
1936
+ change_dir,
1937
+ args.phase,
1938
+ run_id,
1939
+ capsule,
1940
+ status="ROOT_VALIDATION_FAILED",
1941
+ error={
1942
+ "code": "EXECUTION_ROOT_INVALID",
1943
+ "message": str(exc),
1944
+ },
1945
+ )
1542
1946
  return emit_error("EXECUTION_ROOT_INVALID", str(exc), as_json=as_json)
1543
1947
  else:
1544
1948
  try:
@@ -1558,13 +1962,85 @@ def cmd_close(args: argparse.Namespace) -> int:
1558
1962
  allow_head_advance=args.phase in {"run", "submit", "merge"},
1559
1963
  )
1560
1964
  except ValueError as exc:
1965
+ persist_close_failure(
1966
+ change_dir,
1967
+ args.phase,
1968
+ run_id,
1969
+ capsule,
1970
+ status="CAPSULE_VALIDATION_FAILED",
1971
+ error={
1972
+ "code": "PHASE_CAPSULE_MISMATCH",
1973
+ "message": str(exc),
1974
+ },
1975
+ )
1561
1976
  return emit_error("PHASE_CAPSULE_MISMATCH", str(exc), as_json=as_json)
1562
1977
 
1978
+ projection = evaluate_projection_gate(project, args.phase)
1979
+ if not projection.get("ok"):
1980
+ persist_close_failure(
1981
+ change_dir,
1982
+ args.phase,
1983
+ run_id,
1984
+ capsule,
1985
+ status="PROJECTION_VALIDATION_FAILED",
1986
+ error=projection,
1987
+ )
1988
+ return emit_error(
1989
+ str(projection.get("code") or "PROJECTION_BLOCKED"),
1990
+ str(projection.get("message") or "projection gate blocked close"),
1991
+ as_json=as_json,
1992
+ extra={
1993
+ key: value
1994
+ for key, value in projection.items()
1995
+ if key not in {"ok", "code", "message"}
1996
+ },
1997
+ )
1998
+ captured_projection = (
1999
+ capsule.get("projectionReceipt")
2000
+ if isinstance(capsule, dict)
2001
+ else None
2002
+ )
2003
+ if (
2004
+ isinstance(captured_projection, dict)
2005
+ and captured_projection.get("receiptHash")
2006
+ and captured_projection.get("receiptHash")
2007
+ != projection.get("receiptHash")
2008
+ and args.phase in {"submit", "archive", "release", "deploy"}
2009
+ ):
2010
+ mismatch = {
2011
+ "code": "PROJECTION_CHANGED_DURING_PHASE",
2012
+ "message": "projection receipt changed after phase begin",
2013
+ "capturedReceiptHash": captured_projection.get("receiptHash"),
2014
+ "currentReceiptHash": projection.get("receiptHash"),
2015
+ }
2016
+ persist_close_failure(
2017
+ change_dir,
2018
+ args.phase,
2019
+ run_id,
2020
+ capsule,
2021
+ status="PROJECTION_VALIDATION_FAILED",
2022
+ error=mismatch,
2023
+ )
2024
+ return emit_error(
2025
+ mismatch["code"],
2026
+ mismatch["message"],
2027
+ as_json=as_json,
2028
+ extra=mismatch,
2029
+ )
2030
+
1563
2031
  ledger_result = validate_ledger_for_phase_close(
1564
2032
  change_dir, args.phase, policy, execution_root=execution_root,
1565
2033
  phase_status=args.status,
1566
2034
  )
1567
2035
  if not ledger_result.get("ok") and args.phase in {"run", "test", "package"}:
2036
+ persist_close_failure(
2037
+ change_dir,
2038
+ args.phase,
2039
+ run_id,
2040
+ capsule,
2041
+ status="LEDGER_VALIDATION_FAILED",
2042
+ error=ledger_result,
2043
+ )
1568
2044
  return emit_error(
1569
2045
  str(ledger_result.get("code", "LEDGER_INVALID")),
1570
2046
  str(ledger_result.get("message", "ledger validation failed")),
@@ -1576,6 +2052,14 @@ def cmd_close(args: argparse.Namespace) -> int:
1576
2052
  if args.phase in {"run", "test"}:
1577
2053
  coverage = _validate_scenario_coverage(change_dir)
1578
2054
  if not coverage.get("ok"):
2055
+ persist_close_failure(
2056
+ change_dir,
2057
+ args.phase,
2058
+ run_id,
2059
+ capsule,
2060
+ status="SCENARIO_VALIDATION_FAILED",
2061
+ error=coverage,
2062
+ )
1579
2063
  return emit_error(
1580
2064
  str(coverage.get("code", "SCENARIO_COVERAGE_FAILED")),
1581
2065
  str(coverage.get("message", "scenario coverage validation failed")),