@hunter-harness/workflow-harness 0.2.26 → 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 (210) 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 +8 -4
  14. package/harness/bundles/general/claude-code/harness-sync/reference.md +28 -0
  15. package/harness/bundles/general/claude-code/harness-test/SKILL.md +1 -1
  16. package/harness/bundles/general/claude-code/scripts/harness_archive.py +2147 -189
  17. package/harness/bundles/general/claude-code/scripts/harness_change.py +8 -3
  18. package/harness/bundles/general/claude-code/scripts/harness_events.py +57 -9
  19. package/harness/bundles/general/claude-code/scripts/harness_gate.py +530 -8
  20. package/harness/bundles/general/claude-code/scripts/harness_integration.py +263 -6
  21. package/harness/bundles/general/claude-code/scripts/harness_phase.py +758 -1
  22. package/harness/bundles/general/claude-code/scripts/harness_plan_finalize.py +74 -0
  23. package/harness/bundles/general/claude-code/scripts/harness_retry.py +175 -0
  24. package/harness/bundles/general/claude-code/scripts/harness_test_guard.py +702 -22
  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-archive/templates/render-summary.mjs +25 -6
  28. package/harness/bundles/general/codebuddy/harness-archive/templates/summary-data-template.json +44 -0
  29. package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
  30. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  31. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge.py +493 -74
  32. package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  33. package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +1 -1
  34. package/harness/bundles/general/codebuddy/harness-review/SKILL.md +1 -1
  35. package/harness/bundles/general/codebuddy/harness-run/SKILL.md +1 -1
  36. package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +1 -1
  37. package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +8 -4
  38. package/harness/bundles/general/codebuddy/harness-sync/reference.md +28 -0
  39. package/harness/bundles/general/codebuddy/harness-test/SKILL.md +1 -1
  40. package/harness/bundles/general/codebuddy/scripts/harness_archive.py +2147 -189
  41. package/harness/bundles/general/codebuddy/scripts/harness_change.py +8 -3
  42. package/harness/bundles/general/codebuddy/scripts/harness_events.py +57 -9
  43. package/harness/bundles/general/codebuddy/scripts/harness_gate.py +530 -8
  44. package/harness/bundles/general/codebuddy/scripts/harness_integration.py +263 -6
  45. package/harness/bundles/general/codebuddy/scripts/harness_phase.py +758 -1
  46. package/harness/bundles/general/codebuddy/scripts/harness_plan_finalize.py +74 -0
  47. package/harness/bundles/general/codebuddy/scripts/harness_retry.py +175 -0
  48. package/harness/bundles/general/codebuddy/scripts/harness_test_guard.py +702 -22
  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-archive/templates/render-summary.mjs +25 -6
  52. package/harness/bundles/general/codex/harness-archive/templates/summary-data-template.json +44 -0
  53. package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
  54. package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +1 -1
  55. package/harness/bundles/general/codex/harness-knowledge-ingest/scripts/harness_knowledge.py +493 -74
  56. package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
  57. package/harness/bundles/general/codex/harness-plan/SKILL.md +1 -1
  58. package/harness/bundles/general/codex/harness-review/SKILL.md +1 -1
  59. package/harness/bundles/general/codex/harness-run/SKILL.md +1 -1
  60. package/harness/bundles/general/codex/harness-submit/SKILL.md +1 -1
  61. package/harness/bundles/general/codex/harness-sync/SKILL.md +8 -4
  62. package/harness/bundles/general/codex/harness-sync/reference.md +28 -0
  63. package/harness/bundles/general/codex/harness-test/SKILL.md +1 -1
  64. package/harness/bundles/general/codex/scripts/harness_archive.py +2147 -189
  65. package/harness/bundles/general/codex/scripts/harness_change.py +8 -3
  66. package/harness/bundles/general/codex/scripts/harness_events.py +57 -9
  67. package/harness/bundles/general/codex/scripts/harness_gate.py +530 -8
  68. package/harness/bundles/general/codex/scripts/harness_integration.py +263 -6
  69. package/harness/bundles/general/codex/scripts/harness_phase.py +758 -1
  70. package/harness/bundles/general/codex/scripts/harness_plan_finalize.py +74 -0
  71. package/harness/bundles/general/codex/scripts/harness_retry.py +175 -0
  72. package/harness/bundles/general/codex/scripts/harness_test_guard.py +702 -22
  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-archive/templates/render-summary.mjs +25 -6
  76. package/harness/bundles/general/cursor/harness-archive/templates/summary-data-template.json +44 -0
  77. package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
  78. package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  79. package/harness/bundles/general/cursor/harness-knowledge-ingest/scripts/harness_knowledge.py +493 -74
  80. package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
  81. package/harness/bundles/general/cursor/harness-plan/SKILL.md +1 -1
  82. package/harness/bundles/general/cursor/harness-review/SKILL.md +1 -1
  83. package/harness/bundles/general/cursor/harness-run/SKILL.md +1 -1
  84. package/harness/bundles/general/cursor/harness-submit/SKILL.md +1 -1
  85. package/harness/bundles/general/cursor/harness-sync/SKILL.md +8 -4
  86. package/harness/bundles/general/cursor/harness-sync/reference.md +28 -0
  87. package/harness/bundles/general/cursor/harness-test/SKILL.md +1 -1
  88. package/harness/bundles/general/cursor/scripts/harness_archive.py +2147 -189
  89. package/harness/bundles/general/cursor/scripts/harness_change.py +8 -3
  90. package/harness/bundles/general/cursor/scripts/harness_events.py +57 -9
  91. package/harness/bundles/general/cursor/scripts/harness_gate.py +530 -8
  92. package/harness/bundles/general/cursor/scripts/harness_integration.py +263 -6
  93. package/harness/bundles/general/cursor/scripts/harness_phase.py +758 -1
  94. package/harness/bundles/general/cursor/scripts/harness_plan_finalize.py +74 -0
  95. package/harness/bundles/general/cursor/scripts/harness_retry.py +175 -0
  96. package/harness/bundles/general/cursor/scripts/harness_test_guard.py +702 -22
  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-archive/templates/render-summary.mjs +25 -6
  101. package/harness/bundles/java/claude-code/harness-archive/templates/summary-data-template.json +44 -0
  102. package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
  103. package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  104. package/harness/bundles/java/claude-code/harness-knowledge-ingest/scripts/harness_knowledge.py +493 -74
  105. package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
  106. package/harness/bundles/java/claude-code/harness-package/SKILL.md +1 -1
  107. package/harness/bundles/java/claude-code/harness-plan/SKILL.md +1 -1
  108. package/harness/bundles/java/claude-code/harness-review/SKILL.md +1 -1
  109. package/harness/bundles/java/claude-code/harness-run/SKILL.md +1 -1
  110. package/harness/bundles/java/claude-code/harness-submit/SKILL.md +1 -1
  111. package/harness/bundles/java/claude-code/harness-sync/SKILL.md +8 -4
  112. package/harness/bundles/java/claude-code/harness-sync/reference.md +28 -0
  113. package/harness/bundles/java/claude-code/harness-test/SKILL.md +1 -1
  114. package/harness/bundles/java/claude-code/scripts/harness_archive.py +2147 -189
  115. package/harness/bundles/java/claude-code/scripts/harness_change.py +8 -3
  116. package/harness/bundles/java/claude-code/scripts/harness_events.py +57 -9
  117. package/harness/bundles/java/claude-code/scripts/harness_gate.py +530 -8
  118. package/harness/bundles/java/claude-code/scripts/harness_integration.py +263 -6
  119. package/harness/bundles/java/claude-code/scripts/harness_phase.py +758 -1
  120. package/harness/bundles/java/claude-code/scripts/harness_plan_finalize.py +74 -0
  121. package/harness/bundles/java/claude-code/scripts/harness_retry.py +175 -0
  122. package/harness/bundles/java/claude-code/scripts/harness_test_guard.py +702 -22
  123. package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
  124. package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +1 -1
  125. package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +1 -1
  126. package/harness/bundles/java/codebuddy/harness-archive/templates/render-summary.mjs +25 -6
  127. package/harness/bundles/java/codebuddy/harness-archive/templates/summary-data-template.json +44 -0
  128. package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
  129. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  130. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge.py +493 -74
  131. package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  132. package/harness/bundles/java/codebuddy/harness-package/SKILL.md +1 -1
  133. package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +1 -1
  134. package/harness/bundles/java/codebuddy/harness-review/SKILL.md +1 -1
  135. package/harness/bundles/java/codebuddy/harness-run/SKILL.md +1 -1
  136. package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +1 -1
  137. package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +8 -4
  138. package/harness/bundles/java/codebuddy/harness-sync/reference.md +28 -0
  139. package/harness/bundles/java/codebuddy/harness-test/SKILL.md +1 -1
  140. package/harness/bundles/java/codebuddy/scripts/harness_archive.py +2147 -189
  141. package/harness/bundles/java/codebuddy/scripts/harness_change.py +8 -3
  142. package/harness/bundles/java/codebuddy/scripts/harness_events.py +57 -9
  143. package/harness/bundles/java/codebuddy/scripts/harness_gate.py +530 -8
  144. package/harness/bundles/java/codebuddy/scripts/harness_integration.py +263 -6
  145. package/harness/bundles/java/codebuddy/scripts/harness_phase.py +758 -1
  146. package/harness/bundles/java/codebuddy/scripts/harness_plan_finalize.py +74 -0
  147. package/harness/bundles/java/codebuddy/scripts/harness_retry.py +175 -0
  148. package/harness/bundles/java/codebuddy/scripts/harness_test_guard.py +702 -22
  149. package/harness/bundles/java/codex/.harness-build.json +1 -1
  150. package/harness/bundles/java/codex/harness-apidoc/SKILL.md +1 -1
  151. package/harness/bundles/java/codex/harness-archive/SKILL.md +1 -1
  152. package/harness/bundles/java/codex/harness-archive/templates/render-summary.mjs +25 -6
  153. package/harness/bundles/java/codex/harness-archive/templates/summary-data-template.json +44 -0
  154. package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
  155. package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +1 -1
  156. package/harness/bundles/java/codex/harness-knowledge-ingest/scripts/harness_knowledge.py +493 -74
  157. package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
  158. package/harness/bundles/java/codex/harness-package/SKILL.md +1 -1
  159. package/harness/bundles/java/codex/harness-plan/SKILL.md +1 -1
  160. package/harness/bundles/java/codex/harness-review/SKILL.md +1 -1
  161. package/harness/bundles/java/codex/harness-run/SKILL.md +1 -1
  162. package/harness/bundles/java/codex/harness-submit/SKILL.md +1 -1
  163. package/harness/bundles/java/codex/harness-sync/SKILL.md +8 -4
  164. package/harness/bundles/java/codex/harness-sync/reference.md +28 -0
  165. package/harness/bundles/java/codex/harness-test/SKILL.md +1 -1
  166. package/harness/bundles/java/codex/scripts/harness_archive.py +2147 -189
  167. package/harness/bundles/java/codex/scripts/harness_change.py +8 -3
  168. package/harness/bundles/java/codex/scripts/harness_events.py +57 -9
  169. package/harness/bundles/java/codex/scripts/harness_gate.py +530 -8
  170. package/harness/bundles/java/codex/scripts/harness_integration.py +263 -6
  171. package/harness/bundles/java/codex/scripts/harness_phase.py +758 -1
  172. package/harness/bundles/java/codex/scripts/harness_plan_finalize.py +74 -0
  173. package/harness/bundles/java/codex/scripts/harness_retry.py +175 -0
  174. package/harness/bundles/java/codex/scripts/harness_test_guard.py +702 -22
  175. package/harness/bundles/java/cursor/.harness-build.json +1 -1
  176. package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +1 -1
  177. package/harness/bundles/java/cursor/harness-archive/SKILL.md +1 -1
  178. package/harness/bundles/java/cursor/harness-archive/templates/render-summary.mjs +25 -6
  179. package/harness/bundles/java/cursor/harness-archive/templates/summary-data-template.json +44 -0
  180. package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
  181. package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  182. package/harness/bundles/java/cursor/harness-knowledge-ingest/scripts/harness_knowledge.py +493 -74
  183. package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
  184. package/harness/bundles/java/cursor/harness-package/SKILL.md +1 -1
  185. package/harness/bundles/java/cursor/harness-plan/SKILL.md +1 -1
  186. package/harness/bundles/java/cursor/harness-review/SKILL.md +1 -1
  187. package/harness/bundles/java/cursor/harness-run/SKILL.md +1 -1
  188. package/harness/bundles/java/cursor/harness-submit/SKILL.md +1 -1
  189. package/harness/bundles/java/cursor/harness-sync/SKILL.md +8 -4
  190. package/harness/bundles/java/cursor/harness-sync/reference.md +28 -0
  191. package/harness/bundles/java/cursor/harness-test/SKILL.md +1 -1
  192. package/harness/bundles/java/cursor/scripts/harness_archive.py +2147 -189
  193. package/harness/bundles/java/cursor/scripts/harness_change.py +8 -3
  194. package/harness/bundles/java/cursor/scripts/harness_events.py +57 -9
  195. package/harness/bundles/java/cursor/scripts/harness_gate.py +530 -8
  196. package/harness/bundles/java/cursor/scripts/harness_integration.py +263 -6
  197. package/harness/bundles/java/cursor/scripts/harness_phase.py +758 -1
  198. package/harness/bundles/java/cursor/scripts/harness_plan_finalize.py +74 -0
  199. package/harness/bundles/java/cursor/scripts/harness_retry.py +175 -0
  200. package/harness/bundles/java/cursor/scripts/harness_test_guard.py +702 -22
  201. package/harness/manifests/general/claude-code.json +28 -24
  202. package/harness/manifests/general/codebuddy.json +28 -24
  203. package/harness/manifests/general/codex.json +28 -24
  204. package/harness/manifests/general/cursor.json +28 -24
  205. package/harness/manifests/java/claude-code.json +30 -26
  206. package/harness/manifests/java/codebuddy.json +30 -26
  207. package/harness/manifests/java/codex.json +30 -26
  208. package/harness/manifests/java/cursor.json +30 -26
  209. package/hunter-workflow-family.json +2 -2
  210. 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(
@@ -1140,6 +1497,7 @@ def append_phase_event(
1140
1497
  executor_tool: str | None = None,
1141
1498
  executor_agent: str | None = None,
1142
1499
  executor_model: str | None = None,
1500
+ code: str | None = None,
1143
1501
  ) -> dict[str, Any]:
1144
1502
  events_file = he.events_path(change_dir)
1145
1503
  existing = he.load_events(events_file)
@@ -1154,7 +1512,7 @@ def append_phase_event(
1154
1512
  name=None,
1155
1513
  path=None,
1156
1514
  kind=None,
1157
- code=None,
1515
+ code=code,
1158
1516
  severity=None,
1159
1517
  message=None,
1160
1518
  decision=None,
@@ -1192,6 +1550,34 @@ def append_phase_event(
1192
1550
  }
1193
1551
 
1194
1552
 
1553
+ def record_blocked_attempt(
1554
+ change_dir: Path,
1555
+ *,
1556
+ phase: str,
1557
+ code: str,
1558
+ message: str,
1559
+ run_id: str | None = None,
1560
+ ) -> dict[str, Any]:
1561
+ """Persist a rejected begin as a complete, zero-work attempt."""
1562
+ blocked_run_id = run_id or "blocked-" + uuid.uuid4().hex[:12]
1563
+ append_phase_event(
1564
+ change_dir,
1565
+ phase=phase,
1566
+ type_="phase.start",
1567
+ note=f"begin rejected: {code}",
1568
+ run_id=blocked_run_id,
1569
+ )
1570
+ return append_phase_event(
1571
+ change_dir,
1572
+ phase=phase,
1573
+ type_="phase.end",
1574
+ status="BLOCKED",
1575
+ note=message,
1576
+ run_id=blocked_run_id,
1577
+ code=code,
1578
+ )
1579
+
1580
+
1195
1581
  def _phase_event_exists(change_dir: Path, phase: str, type_: str, run_id: str) -> bool:
1196
1582
  return any(
1197
1583
  event.get("phase") == phase
@@ -1279,6 +1665,15 @@ def cmd_begin(args: argparse.Namespace) -> int:
1279
1665
  change_dir = Path(resolved["changeDir"])
1280
1666
  concurrency_block = hc.check_concurrency_block(project, resolved["changeId"])
1281
1667
  if concurrency_block is not None:
1668
+ record_blocked_attempt(
1669
+ change_dir,
1670
+ phase=args.phase,
1671
+ code=str(concurrency_block.get("code", "CONCURRENCY_BLOCKED")),
1672
+ message=str(
1673
+ concurrency_block.get("message", "concurrency mode blocked begin")
1674
+ ),
1675
+ run_id=args.run_id or os.environ.get("HUNTER_HARNESS_RUN_ID"),
1676
+ )
1282
1677
  return emit_error(
1283
1678
  str(concurrency_block.get("code", "CONCURRENCY_BLOCKED")),
1284
1679
  str(concurrency_block.get("message", "concurrency mode blocked begin")),
@@ -1314,6 +1709,18 @@ def cmd_begin(args: argparse.Namespace) -> int:
1314
1709
  except (OSError, json.JSONDecodeError, ValueError) as exc:
1315
1710
  return emit_error("BUNDLE_IDENTITY_INVALID", str(exc), as_json=as_json)
1316
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
+ )
1317
1724
  explicit_run_id = args.run_id or os.environ.get("HUNTER_HARNESS_RUN_ID")
1318
1725
  current_lease = hc.inspect_lease(project, resolved["changeId"])
1319
1726
  run_id = explicit_run_id or (
@@ -1325,11 +1732,16 @@ def cmd_begin(args: argparse.Namespace) -> int:
1325
1732
  capsule = load_phase_capsule(change_dir, args.phase, run_id)
1326
1733
  except ValueError as exc:
1327
1734
  return emit_error("PHASE_CAPSULE_INVALID", str(exc), as_json=as_json)
1328
- execution_hint = args.project
1329
- if not execution_hint and capsule is not None:
1330
- execution_hint = str(capsule.get("executionRoot") or "")
1735
+ execution_hint = resolve_begin_execution_hint(
1736
+ project,
1737
+ requested=args.project,
1738
+ capsule=capsule,
1739
+ )
1331
1740
  try:
1332
- execution_root = resolve_execution_root(project, execution_hint)
1741
+ execution_root = resolve_execution_root(
1742
+ project,
1743
+ execution_hint["executionRoot"],
1744
+ )
1333
1745
  except ValueError as exc:
1334
1746
  return emit_error("EXECUTION_ROOT_INVALID", str(exc), as_json=as_json)
1335
1747
  if capsule is not None:
@@ -1398,6 +1810,7 @@ def cmd_begin(args: argparse.Namespace) -> int:
1398
1810
  "baseCommit": base_commit or current_head,
1399
1811
  "currentHead": current_head,
1400
1812
  "createdAt": he.now_iso(),
1813
+ "projectionReceipt": projection,
1401
1814
  }
1402
1815
  write_phase_capsule(change_dir, args.phase, run_id, capsule)
1403
1816
  event_result = {"ok": True, "skipped": True, "reason": "already-recorded"} \
@@ -1434,6 +1847,8 @@ def cmd_begin(args: argparse.Namespace) -> int:
1434
1847
  "event": event_result,
1435
1848
  "testGuard": guard_result,
1436
1849
  "policySchemaVersion": policy.get("schemaVersion"),
1850
+ "projection": projection,
1851
+ "executionRootSource": execution_hint["source"],
1437
1852
  }
1438
1853
  emit(payload, as_json=as_json)
1439
1854
  return 0
@@ -1491,6 +1906,18 @@ def cmd_close(args: argparse.Namespace) -> int:
1491
1906
  if args.project:
1492
1907
  requested_root = resolve_execution_root(project, args.project)
1493
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
+ )
1494
1921
  return emit_error(
1495
1922
  "EXECUTION_ROOT_MISMATCH",
1496
1923
  "close must use the execution root captured at begin",
@@ -1498,9 +1925,24 @@ def cmd_close(args: argparse.Namespace) -> int:
1498
1925
  extra={
1499
1926
  "storedExecutionRoot": str(execution_root),
1500
1927
  "requestedExecutionRoot": str(requested_root),
1928
+ "recoveryAction": (
1929
+ "retry close from the stored execution root or "
1930
+ "pass --project with storedExecutionRoot"
1931
+ ),
1501
1932
  },
1502
1933
  )
1503
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
+ )
1504
1946
  return emit_error("EXECUTION_ROOT_INVALID", str(exc), as_json=as_json)
1505
1947
  else:
1506
1948
  try:
@@ -1520,13 +1962,85 @@ def cmd_close(args: argparse.Namespace) -> int:
1520
1962
  allow_head_advance=args.phase in {"run", "submit", "merge"},
1521
1963
  )
1522
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
+ )
1523
1976
  return emit_error("PHASE_CAPSULE_MISMATCH", str(exc), as_json=as_json)
1524
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
+
1525
2031
  ledger_result = validate_ledger_for_phase_close(
1526
2032
  change_dir, args.phase, policy, execution_root=execution_root,
1527
2033
  phase_status=args.status,
1528
2034
  )
1529
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
+ )
1530
2044
  return emit_error(
1531
2045
  str(ledger_result.get("code", "LEDGER_INVALID")),
1532
2046
  str(ledger_result.get("message", "ledger validation failed")),
@@ -1538,6 +2052,14 @@ def cmd_close(args: argparse.Namespace) -> int:
1538
2052
  if args.phase in {"run", "test"}:
1539
2053
  coverage = _validate_scenario_coverage(change_dir)
1540
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
+ )
1541
2063
  return emit_error(
1542
2064
  str(coverage.get("code", "SCENARIO_COVERAGE_FAILED")),
1543
2065
  str(coverage.get("message", "scenario coverage validation failed")),