@hunter-harness/workflow-harness 0.2.69 → 0.2.71

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/README.md +1 -1
  3. package/harness/bundles/general/claude-code/harness-archive/SKILL.md +1 -1
  4. package/harness/bundles/general/claude-code/harness-codebase-map/SKILL.md +1 -1
  5. package/harness/bundles/general/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  6. package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +1 -1
  7. package/harness/bundles/general/claude-code/harness-plan/SKILL.md +3 -1
  8. package/harness/bundles/general/claude-code/harness-plan/reference.md +31 -1
  9. package/harness/bundles/general/claude-code/harness-pull/SKILL.md +1 -1
  10. package/harness/bundles/general/claude-code/harness-push/SKILL.md +1 -1
  11. package/harness/bundles/general/claude-code/harness-review/SKILL.md +1 -1
  12. package/harness/bundles/general/claude-code/harness-run/SKILL.md +3 -1
  13. package/harness/bundles/general/claude-code/harness-run/reference.md +20 -0
  14. package/harness/bundles/general/claude-code/harness-submit/SKILL.md +1 -1
  15. package/harness/bundles/general/claude-code/harness-sync/SKILL.md +1 -1
  16. package/harness/bundles/general/claude-code/harness-test/SKILL.md +3 -1
  17. package/harness/bundles/general/claude-code/harness-test/pitfalls.md +34 -3
  18. package/harness/bundles/general/claude-code/harness-test/reference.md +9 -0
  19. package/harness/bundles/general/claude-code/scripts/harness_context.py +27 -7
  20. package/harness/bundles/general/claude-code/scripts/harness_events.py +12 -3
  21. package/harness/bundles/general/claude-code/scripts/harness_gate.py +127 -11
  22. package/harness/bundles/general/claude-code/scripts/harness_ledger.py +244 -5
  23. package/harness/bundles/general/claude-code/scripts/harness_plan_finalize.py +254 -19
  24. package/harness/bundles/general/claude-code/scripts/harness_service.py +69 -15
  25. package/harness/bundles/general/claude-code/scripts/harness_test_guard.py +48 -1
  26. package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
  27. package/harness/bundles/general/codebuddy/README.md +1 -1
  28. package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +1 -1
  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-query/SKILL.md +1 -1
  32. package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +3 -1
  33. package/harness/bundles/general/codebuddy/harness-plan/reference.md +31 -1
  34. package/harness/bundles/general/codebuddy/harness-pull/SKILL.md +1 -1
  35. package/harness/bundles/general/codebuddy/harness-push/SKILL.md +1 -1
  36. package/harness/bundles/general/codebuddy/harness-review/SKILL.md +1 -1
  37. package/harness/bundles/general/codebuddy/harness-run/SKILL.md +3 -1
  38. package/harness/bundles/general/codebuddy/harness-run/reference.md +20 -0
  39. package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +1 -1
  40. package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +1 -1
  41. package/harness/bundles/general/codebuddy/harness-test/SKILL.md +3 -1
  42. package/harness/bundles/general/codebuddy/harness-test/pitfalls.md +34 -3
  43. package/harness/bundles/general/codebuddy/harness-test/reference.md +9 -0
  44. package/harness/bundles/general/codebuddy/scripts/harness_context.py +27 -7
  45. package/harness/bundles/general/codebuddy/scripts/harness_events.py +12 -3
  46. package/harness/bundles/general/codebuddy/scripts/harness_gate.py +127 -11
  47. package/harness/bundles/general/codebuddy/scripts/harness_ledger.py +244 -5
  48. package/harness/bundles/general/codebuddy/scripts/harness_plan_finalize.py +254 -19
  49. package/harness/bundles/general/codebuddy/scripts/harness_service.py +69 -15
  50. package/harness/bundles/general/codebuddy/scripts/harness_test_guard.py +48 -1
  51. package/harness/bundles/general/codex/.harness-build.json +1 -1
  52. package/harness/bundles/general/codex/README.md +1 -1
  53. package/harness/bundles/general/codex/harness-archive/SKILL.md +1 -1
  54. package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
  55. package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +1 -1
  56. package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
  57. package/harness/bundles/general/codex/harness-plan/SKILL.md +3 -1
  58. package/harness/bundles/general/codex/harness-plan/reference.md +31 -1
  59. package/harness/bundles/general/codex/harness-pull/SKILL.md +1 -1
  60. package/harness/bundles/general/codex/harness-push/SKILL.md +1 -1
  61. package/harness/bundles/general/codex/harness-review/SKILL.md +1 -1
  62. package/harness/bundles/general/codex/harness-run/SKILL.md +3 -1
  63. package/harness/bundles/general/codex/harness-run/reference.md +20 -0
  64. package/harness/bundles/general/codex/harness-submit/SKILL.md +1 -1
  65. package/harness/bundles/general/codex/harness-sync/SKILL.md +1 -1
  66. package/harness/bundles/general/codex/harness-test/SKILL.md +3 -1
  67. package/harness/bundles/general/codex/harness-test/pitfalls.md +34 -3
  68. package/harness/bundles/general/codex/harness-test/reference.md +9 -0
  69. package/harness/bundles/general/codex/scripts/harness_context.py +27 -7
  70. package/harness/bundles/general/codex/scripts/harness_events.py +12 -3
  71. package/harness/bundles/general/codex/scripts/harness_gate.py +127 -11
  72. package/harness/bundles/general/codex/scripts/harness_ledger.py +244 -5
  73. package/harness/bundles/general/codex/scripts/harness_plan_finalize.py +254 -19
  74. package/harness/bundles/general/codex/scripts/harness_service.py +69 -15
  75. package/harness/bundles/general/codex/scripts/harness_test_guard.py +48 -1
  76. package/harness/bundles/general/cursor/.harness-build.json +1 -1
  77. package/harness/bundles/general/cursor/README.md +1 -1
  78. package/harness/bundles/general/cursor/harness-archive/SKILL.md +1 -1
  79. package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
  80. package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  81. package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
  82. package/harness/bundles/general/cursor/harness-plan/SKILL.md +3 -1
  83. package/harness/bundles/general/cursor/harness-plan/reference.md +31 -1
  84. package/harness/bundles/general/cursor/harness-pull/SKILL.md +1 -1
  85. package/harness/bundles/general/cursor/harness-push/SKILL.md +1 -1
  86. package/harness/bundles/general/cursor/harness-review/SKILL.md +1 -1
  87. package/harness/bundles/general/cursor/harness-run/SKILL.md +3 -1
  88. package/harness/bundles/general/cursor/harness-run/reference.md +20 -0
  89. package/harness/bundles/general/cursor/harness-submit/SKILL.md +1 -1
  90. package/harness/bundles/general/cursor/harness-sync/SKILL.md +1 -1
  91. package/harness/bundles/general/cursor/harness-test/SKILL.md +3 -1
  92. package/harness/bundles/general/cursor/harness-test/pitfalls.md +34 -3
  93. package/harness/bundles/general/cursor/harness-test/reference.md +9 -0
  94. package/harness/bundles/general/cursor/scripts/harness_context.py +27 -7
  95. package/harness/bundles/general/cursor/scripts/harness_events.py +12 -3
  96. package/harness/bundles/general/cursor/scripts/harness_gate.py +127 -11
  97. package/harness/bundles/general/cursor/scripts/harness_ledger.py +244 -5
  98. package/harness/bundles/general/cursor/scripts/harness_plan_finalize.py +254 -19
  99. package/harness/bundles/general/cursor/scripts/harness_service.py +69 -15
  100. package/harness/bundles/general/cursor/scripts/harness_test_guard.py +48 -1
  101. package/harness/bundles/java/claude-code/.harness-build.json +1 -1
  102. package/harness/bundles/java/claude-code/README.md +1 -1
  103. package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +1 -1
  104. package/harness/bundles/java/claude-code/harness-archive/SKILL.md +1 -1
  105. package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
  106. package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  107. package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
  108. package/harness/bundles/java/claude-code/harness-package/SKILL.md +1 -1
  109. package/harness/bundles/java/claude-code/harness-plan/SKILL.md +3 -1
  110. package/harness/bundles/java/claude-code/harness-plan/reference.md +31 -1
  111. package/harness/bundles/java/claude-code/harness-pull/SKILL.md +1 -1
  112. package/harness/bundles/java/claude-code/harness-push/SKILL.md +1 -1
  113. package/harness/bundles/java/claude-code/harness-review/SKILL.md +1 -1
  114. package/harness/bundles/java/claude-code/harness-run/SKILL.md +3 -1
  115. package/harness/bundles/java/claude-code/harness-submit/SKILL.md +1 -1
  116. package/harness/bundles/java/claude-code/harness-sync/SKILL.md +1 -1
  117. package/harness/bundles/java/claude-code/harness-test/SKILL.md +3 -1
  118. package/harness/bundles/java/claude-code/harness-test/pitfalls.md +34 -3
  119. package/harness/bundles/java/claude-code/scripts/harness_context.py +27 -7
  120. package/harness/bundles/java/claude-code/scripts/harness_events.py +12 -3
  121. package/harness/bundles/java/claude-code/scripts/harness_gate.py +127 -11
  122. package/harness/bundles/java/claude-code/scripts/harness_ledger.py +244 -5
  123. package/harness/bundles/java/claude-code/scripts/harness_plan_finalize.py +254 -19
  124. package/harness/bundles/java/claude-code/scripts/harness_service.py +69 -15
  125. package/harness/bundles/java/claude-code/scripts/harness_test_guard.py +48 -1
  126. package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
  127. package/harness/bundles/java/codebuddy/README.md +1 -1
  128. package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +1 -1
  129. package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +1 -1
  130. package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
  131. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  132. package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  133. package/harness/bundles/java/codebuddy/harness-package/SKILL.md +1 -1
  134. package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +3 -1
  135. package/harness/bundles/java/codebuddy/harness-plan/reference.md +31 -1
  136. package/harness/bundles/java/codebuddy/harness-pull/SKILL.md +1 -1
  137. package/harness/bundles/java/codebuddy/harness-push/SKILL.md +1 -1
  138. package/harness/bundles/java/codebuddy/harness-review/SKILL.md +1 -1
  139. package/harness/bundles/java/codebuddy/harness-run/SKILL.md +3 -1
  140. package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +1 -1
  141. package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +1 -1
  142. package/harness/bundles/java/codebuddy/harness-test/SKILL.md +3 -1
  143. package/harness/bundles/java/codebuddy/harness-test/pitfalls.md +34 -3
  144. package/harness/bundles/java/codebuddy/scripts/harness_context.py +27 -7
  145. package/harness/bundles/java/codebuddy/scripts/harness_events.py +12 -3
  146. package/harness/bundles/java/codebuddy/scripts/harness_gate.py +127 -11
  147. package/harness/bundles/java/codebuddy/scripts/harness_ledger.py +244 -5
  148. package/harness/bundles/java/codebuddy/scripts/harness_plan_finalize.py +254 -19
  149. package/harness/bundles/java/codebuddy/scripts/harness_service.py +69 -15
  150. package/harness/bundles/java/codebuddy/scripts/harness_test_guard.py +48 -1
  151. package/harness/bundles/java/codex/.harness-build.json +1 -1
  152. package/harness/bundles/java/codex/README.md +1 -1
  153. package/harness/bundles/java/codex/harness-apidoc/SKILL.md +1 -1
  154. package/harness/bundles/java/codex/harness-archive/SKILL.md +1 -1
  155. package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
  156. package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +1 -1
  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 +3 -1
  160. package/harness/bundles/java/codex/harness-plan/reference.md +31 -1
  161. package/harness/bundles/java/codex/harness-pull/SKILL.md +1 -1
  162. package/harness/bundles/java/codex/harness-push/SKILL.md +1 -1
  163. package/harness/bundles/java/codex/harness-review/SKILL.md +1 -1
  164. package/harness/bundles/java/codex/harness-run/SKILL.md +3 -1
  165. package/harness/bundles/java/codex/harness-submit/SKILL.md +1 -1
  166. package/harness/bundles/java/codex/harness-sync/SKILL.md +1 -1
  167. package/harness/bundles/java/codex/harness-test/SKILL.md +3 -1
  168. package/harness/bundles/java/codex/harness-test/pitfalls.md +34 -3
  169. package/harness/bundles/java/codex/scripts/harness_context.py +27 -7
  170. package/harness/bundles/java/codex/scripts/harness_events.py +12 -3
  171. package/harness/bundles/java/codex/scripts/harness_gate.py +127 -11
  172. package/harness/bundles/java/codex/scripts/harness_ledger.py +244 -5
  173. package/harness/bundles/java/codex/scripts/harness_plan_finalize.py +254 -19
  174. package/harness/bundles/java/codex/scripts/harness_service.py +69 -15
  175. package/harness/bundles/java/codex/scripts/harness_test_guard.py +48 -1
  176. package/harness/bundles/java/cursor/.harness-build.json +1 -1
  177. package/harness/bundles/java/cursor/README.md +1 -1
  178. package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +1 -1
  179. package/harness/bundles/java/cursor/harness-archive/SKILL.md +1 -1
  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-query/SKILL.md +1 -1
  183. package/harness/bundles/java/cursor/harness-package/SKILL.md +1 -1
  184. package/harness/bundles/java/cursor/harness-plan/SKILL.md +3 -1
  185. package/harness/bundles/java/cursor/harness-plan/reference.md +31 -1
  186. package/harness/bundles/java/cursor/harness-pull/SKILL.md +1 -1
  187. package/harness/bundles/java/cursor/harness-push/SKILL.md +1 -1
  188. package/harness/bundles/java/cursor/harness-review/SKILL.md +1 -1
  189. package/harness/bundles/java/cursor/harness-run/SKILL.md +3 -1
  190. package/harness/bundles/java/cursor/harness-submit/SKILL.md +1 -1
  191. package/harness/bundles/java/cursor/harness-sync/SKILL.md +1 -1
  192. package/harness/bundles/java/cursor/harness-test/SKILL.md +3 -1
  193. package/harness/bundles/java/cursor/harness-test/pitfalls.md +34 -3
  194. package/harness/bundles/java/cursor/scripts/harness_context.py +27 -7
  195. package/harness/bundles/java/cursor/scripts/harness_events.py +12 -3
  196. package/harness/bundles/java/cursor/scripts/harness_gate.py +127 -11
  197. package/harness/bundles/java/cursor/scripts/harness_ledger.py +244 -5
  198. package/harness/bundles/java/cursor/scripts/harness_plan_finalize.py +254 -19
  199. package/harness/bundles/java/cursor/scripts/harness_service.py +69 -15
  200. package/harness/bundles/java/cursor/scripts/harness_test_guard.py +48 -1
  201. package/harness/manifests/general/claude-code.json +27 -27
  202. package/harness/manifests/general/codebuddy.json +27 -27
  203. package/harness/manifests/general/codex.json +27 -27
  204. package/harness/manifests/general/cursor.json +27 -27
  205. package/harness/manifests/java/claude-code.json +27 -27
  206. package/harness/manifests/java/codebuddy.json +27 -27
  207. package/harness/manifests/java/codex.json +27 -27
  208. package/harness/manifests/java/cursor.json +27 -27
  209. package/hunter-workflow-family.json +5 -5
  210. package/package.json +1 -1
@@ -728,7 +728,9 @@ def _validate_plan_start(
728
728
  if not matching:
729
729
  return _result_error(
730
730
  "PHASE_START_MISSING",
731
- "no matching plan phase.start event found for finalizer runId/attempt",
731
+ "no matching plan phase.start event found for finalizer runId/attempt; "
732
+ "append one with the same run id and attempt before finalizing, or use "
733
+ "the `republish` subcommand which allocates and appends both for you",
732
734
  )
733
735
  if len(matching) > 1:
734
736
  return _result_error(
@@ -1299,6 +1301,72 @@ def _append_terminal(change_dir: Path, run_id: str, attempt: int) -> tuple[int,
1299
1301
  return code, stderr.getvalue().strip()
1300
1302
 
1301
1303
 
1304
+ def _rollback_publish(
1305
+ created: list[Path], replaced: list[tuple[Path, bytes]]
1306
+ ) -> None:
1307
+ """Undo a failed publish: delete new files, restore overwritten ones."""
1308
+ for target in reversed(created):
1309
+ target.unlink(missing_ok=True)
1310
+ for target, original in reversed(replaced):
1311
+ try:
1312
+ target.write_bytes(original)
1313
+ except OSError:
1314
+ # Best effort: the caller already reports the publish failure.
1315
+ pass
1316
+
1317
+
1318
+ def _plan_attempts(change_dir: Path) -> list[int]:
1319
+ """Every attempt number already used by a plan lifecycle event."""
1320
+ try:
1321
+ events = harness_events.load_events(harness_events.events_path(change_dir))
1322
+ except (OSError, ValueError):
1323
+ return []
1324
+ attempts: list[int] = []
1325
+ for event in events:
1326
+ if event.get("phase") != "plan":
1327
+ continue
1328
+ value = event.get("attempt")
1329
+ if isinstance(value, int) and not isinstance(value, bool):
1330
+ attempts.append(value)
1331
+ return attempts
1332
+
1333
+
1334
+ def _run_id_in_use(change_dir: Path, run_id: str) -> bool:
1335
+ try:
1336
+ events = harness_events.load_events(harness_events.events_path(change_dir))
1337
+ except (OSError, ValueError):
1338
+ return False
1339
+ return any(event.get("run_id") == run_id for event in events)
1340
+
1341
+
1342
+ def _append_plan_start(
1343
+ change_dir: Path, run_id: str, attempt: int, reason: str
1344
+ ) -> tuple[int, str]:
1345
+ stdout = io.StringIO()
1346
+ stderr = io.StringIO()
1347
+ with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
1348
+ code = harness_events.main(
1349
+ [
1350
+ "append",
1351
+ "--change-dir",
1352
+ str(change_dir),
1353
+ "--phase",
1354
+ "plan",
1355
+ "--type",
1356
+ "phase.start",
1357
+ # phase.start accepts only `note` beyond provenance fields.
1358
+ "--note",
1359
+ reason,
1360
+ "--run-id",
1361
+ run_id,
1362
+ "--attempt",
1363
+ str(attempt),
1364
+ "--json",
1365
+ ]
1366
+ )
1367
+ return code, stderr.getvalue().strip()
1368
+
1369
+
1302
1370
  def finalize_plan(
1303
1371
  change_dir: Path,
1304
1372
  staging: Path,
@@ -1306,6 +1374,8 @@ def finalize_plan(
1306
1374
  change_name: str,
1307
1375
  run_id: str,
1308
1376
  attempt: int,
1377
+ allow_amend: bool = False,
1378
+ amend_reason: str | None = None,
1309
1379
  ) -> dict[str, Any]:
1310
1380
  change_dir = change_dir.resolve()
1311
1381
  staging = staging.resolve()
@@ -1331,6 +1401,7 @@ def finalize_plan(
1331
1401
  receipt_path = change_dir / "meta" / "plan-finalization.json"
1332
1402
  lock_path = change_dir / "meta" / "plan-finalize.lock"
1333
1403
  receipt: dict[str, Any] | None = None
1404
+ supersedes: dict[str, Any] | None = None
1334
1405
  if receipt_path.is_file():
1335
1406
  try:
1336
1407
  loaded = json.loads(receipt_path.read_text(encoding="utf-8-sig"))
@@ -1338,11 +1409,22 @@ def finalize_plan(
1338
1409
  except (OSError, json.JSONDecodeError) as exc:
1339
1410
  return _result_error("PLAN_FINALIZATION_RECEIPT_INVALID", str(exc))
1340
1411
  if receipt and receipt.get("artifactsHash") != validation["artifactsHash"]:
1341
- return _result_error(
1342
- "PLAN_FINALIZATION_HASH_CONFLICT",
1343
- "finalizer was already invoked with a different artifact set",
1344
- )
1345
- if (
1412
+ if not allow_amend:
1413
+ return _result_error(
1414
+ "PLAN_FINALIZATION_HASH_CONFLICT",
1415
+ "finalizer was already invoked with a different artifact set; "
1416
+ "to publish an approved amendment run: harness_plan_finalize.py "
1417
+ "republish --change-dir <dir> --staging-dir <dir> --change "
1418
+ f"{change_name} --run-id <fresh-run-id> --reason \"<why>\"",
1419
+ )
1420
+ # Authorized amendment: keep the superseded identity in the new
1421
+ # receipt so the published history stays auditable.
1422
+ supersedes = {
1423
+ "artifactsHash": receipt.get("artifactsHash"),
1424
+ "runId": receipt.get("runId"),
1425
+ "attempt": receipt.get("attempt"),
1426
+ }
1427
+ elif (
1346
1428
  receipt
1347
1429
  and receipt.get("status") == "finalized"
1348
1430
  and _terminal_exists(change_dir, run_id, attempt)
@@ -1365,21 +1447,33 @@ def finalize_plan(
1365
1447
  return _result_error("PLAN_FINALIZATION_LOCKED", f"lock exists: {lock_path}")
1366
1448
 
1367
1449
  created: list[Path] = []
1450
+ # Amendment overwrites replace already-published bytes. Keep the originals
1451
+ # so a failed publish restores them instead of deleting the published plan.
1452
+ replaced: list[tuple[Path, bytes]] = []
1368
1453
  terminal_committed = False
1369
1454
  try:
1370
1455
  for rel_text in validation["files"]:
1371
1456
  source = staging / rel_text
1372
1457
  target = change_dir / rel_text
1373
- if target.exists() and target.read_bytes() != source.read_bytes():
1458
+ if (
1459
+ not allow_amend
1460
+ and target.exists()
1461
+ and target.read_bytes() != source.read_bytes()
1462
+ ):
1374
1463
  return _result_error(
1375
- "PLAN_TARGET_CONFLICT", f"refusing to overwrite {rel_text}"
1464
+ "PLAN_TARGET_CONFLICT",
1465
+ f"refusing to overwrite {rel_text}; publish an approved "
1466
+ "amendment with the `republish` subcommand instead",
1376
1467
  )
1377
1468
 
1378
1469
  for rel_text in validation["files"]:
1379
1470
  source = staging / rel_text
1380
1471
  target = change_dir / rel_text
1381
- if target.exists():
1382
- continue
1472
+ existed = target.exists()
1473
+ if existed:
1474
+ original = target.read_bytes()
1475
+ if not allow_amend or original == source.read_bytes():
1476
+ continue
1383
1477
  target.parent.mkdir(parents=True, exist_ok=True)
1384
1478
  fd, raw_tmp = tempfile.mkstemp(
1385
1479
  prefix=f".{target.name}.", suffix=".tmp", dir=str(target.parent)
@@ -1389,7 +1483,10 @@ def finalize_plan(
1389
1483
  try:
1390
1484
  shutil.copyfile(source, tmp)
1391
1485
  os.replace(tmp, target)
1392
- created.append(target)
1486
+ if existed:
1487
+ replaced.append((target, original))
1488
+ else:
1489
+ created.append(target)
1393
1490
  finally:
1394
1491
  tmp.unlink(missing_ok=True)
1395
1492
 
@@ -1403,8 +1500,11 @@ def finalize_plan(
1403
1500
  "tasks": tasks,
1404
1501
  "foundationGate": "approved",
1405
1502
  }
1503
+ if checkpoints_path.is_file():
1504
+ replaced.append((checkpoints_path, checkpoints_path.read_bytes()))
1505
+ else:
1506
+ created.append(checkpoints_path)
1406
1507
  _atomic_write_json(checkpoints_path, checkpoints_payload)
1407
- created.append(checkpoints_path)
1408
1508
 
1409
1509
  # C9: write scenario-manifest.json with parsed scenarios.
1410
1510
  scenarios = validation.get("scenarios") or []
@@ -1421,8 +1521,11 @@ def finalize_plan(
1421
1521
  for scenario in scenarios
1422
1522
  ],
1423
1523
  }
1524
+ if manifest_path.is_file():
1525
+ replaced.append((manifest_path, manifest_path.read_bytes()))
1526
+ else:
1527
+ created.append(manifest_path)
1424
1528
  _atomic_write_json(manifest_path, manifest_payload)
1425
- created.append(manifest_path)
1426
1529
 
1427
1530
  pending_receipt = {
1428
1531
  "schemaVersion": SCHEMA_VERSION,
@@ -1433,12 +1536,14 @@ def finalize_plan(
1433
1536
  "runId": run_id,
1434
1537
  "attempt": attempt,
1435
1538
  }
1539
+ if supersedes is not None:
1540
+ pending_receipt["supersedes"] = supersedes
1541
+ pending_receipt["amendReason"] = amend_reason or ""
1436
1542
  _atomic_write_json(receipt_path, pending_receipt)
1437
1543
  terminal_code, terminal_error = _append_terminal(change_dir, run_id, attempt)
1438
1544
  terminal_committed = _terminal_exists(change_dir, run_id, attempt)
1439
1545
  if terminal_code != 0 and not terminal_committed:
1440
- for target in reversed(created):
1441
- target.unlink(missing_ok=True)
1546
+ _rollback_publish(created, replaced)
1442
1547
  receipt_path.unlink(missing_ok=True)
1443
1548
  return _result_error(
1444
1549
  "PLAN_TERMINAL_APPEND_FAILED",
@@ -1446,9 +1551,9 @@ def finalize_plan(
1446
1551
  )
1447
1552
  pending_receipt["status"] = "finalized"
1448
1553
  _atomic_write_json(receipt_path, pending_receipt)
1449
- return {
1554
+ result: dict[str, Any] = {
1450
1555
  "ok": True,
1451
- "action": "finalize",
1556
+ "action": "republish" if supersedes is not None else "finalize",
1452
1557
  "idempotent": False,
1453
1558
  "artifactsHash": validation["artifactsHash"],
1454
1559
  "files": validation["files"],
@@ -1456,23 +1561,145 @@ def finalize_plan(
1456
1561
  "artifactRef": "meta/plan-finalization.json",
1457
1562
  "executionLogPath": str(harness_events.execution_log_path(change_dir)),
1458
1563
  }
1564
+ if supersedes is not None:
1565
+ result["supersedes"] = supersedes
1566
+ result["scenarioCount"] = len(scenarios)
1567
+ result["taskCount"] = len(tasks)
1568
+ return result
1459
1569
  except OSError as exc:
1460
1570
  if terminal_committed or _terminal_exists(change_dir, run_id, attempt):
1461
1571
  return _result_error(
1462
1572
  "PLAN_FINALIZATION_RECOVERY_REQUIRED",
1463
1573
  f"terminal committed; retry finalization to complete receipt: {exc}",
1464
1574
  )
1465
- for target in reversed(created):
1466
- target.unlink(missing_ok=True)
1575
+ _rollback_publish(created, replaced)
1467
1576
  receipt_path.unlink(missing_ok=True)
1468
1577
  return _result_error("PLAN_FINALIZATION_IO_ERROR", str(exc))
1469
1578
  finally:
1470
1579
  lock_path.unlink(missing_ok=True)
1471
1580
 
1472
1581
 
1582
+ def republish_plan(
1583
+ change_dir: Path,
1584
+ staging: Path,
1585
+ *,
1586
+ change_name: str,
1587
+ run_id: str,
1588
+ reason: str,
1589
+ ) -> dict[str, Any]:
1590
+ """Publish an approved amendment to an already-finalized plan.
1591
+
1592
+ Amending a published plan (adding a scenario, correcting a task) used to
1593
+ require reverse-engineering a five-step lifecycle dance across three
1594
+ modules — new run id, incremented attempt, stale receipt removal, dangling
1595
+ attempt sealing — and every wrong turn produced a different error code
1596
+ (PLAN_FINALIZATION_HASH_CONFLICT / PHASE_START_MISSING /
1597
+ EVENT_ATTEMPT_CONFLICT / PHASE_ALREADY_CLOSED). Agents that gave up
1598
+ hand-edited meta/scenario-manifest.json, creating exactly the drift the
1599
+ hash guard exists to prevent.
1600
+
1601
+ This performs the whole sequence atomically and keeps the superseded
1602
+ identity in the receipt, so amendment stays auditable rather than silent.
1603
+ """
1604
+ change_dir = change_dir.resolve()
1605
+ staging = staging.resolve()
1606
+
1607
+ if not str(reason).strip():
1608
+ return _result_error(
1609
+ "PLAN_AMEND_REASON_REQUIRED",
1610
+ "--reason is required: an amendment to a published plan must record why",
1611
+ )
1612
+
1613
+ receipt_path = change_dir / "meta" / "plan-finalization.json"
1614
+ if not receipt_path.is_file():
1615
+ return _result_error(
1616
+ "PLAN_NOT_FINALIZED",
1617
+ "no plan-finalization.json to amend; use `finalize` for the first "
1618
+ "publication",
1619
+ )
1620
+
1621
+ if _run_id_in_use(change_dir, run_id):
1622
+ return _result_error(
1623
+ "PLAN_AMEND_RUN_ID_IN_USE",
1624
+ f"run id {run_id} already appears in this change's events; "
1625
+ "an amendment needs a fresh run id",
1626
+ )
1627
+
1628
+ # Nothing to amend is a no-op, not a new attempt: probe the staged hash
1629
+ # before writing any lifecycle event.
1630
+ reclassify = _reclassify_gate_policy(staging, change_name)
1631
+ if not reclassify.get("ok"):
1632
+ return _result_error(
1633
+ "CAPABILITY_GATE_DRIFT", reclassify.get("error", "reclassify failed")
1634
+ )
1635
+ probe = validate_staging(staging, change_name)
1636
+ if not probe["ok"]:
1637
+ return probe
1638
+ try:
1639
+ published = json.loads(receipt_path.read_text(encoding="utf-8-sig"))
1640
+ except (OSError, json.JSONDecodeError) as exc:
1641
+ return _result_error("PLAN_FINALIZATION_RECEIPT_INVALID", str(exc))
1642
+ if (
1643
+ isinstance(published, dict)
1644
+ and published.get("status") == "finalized"
1645
+ and published.get("artifactsHash") == probe["artifactsHash"]
1646
+ ):
1647
+ return {
1648
+ "ok": True,
1649
+ "action": "republish",
1650
+ "idempotent": True,
1651
+ "artifactsHash": probe["artifactsHash"],
1652
+ "files": probe["files"],
1653
+ "receiptPath": str(receipt_path),
1654
+ "artifactRef": "meta/plan-finalization.json",
1655
+ "note": "published plan already matches staging; nothing to amend",
1656
+ }
1657
+
1658
+ attempt = max(_plan_attempts(change_dir), default=0) + 1
1659
+ start_code, start_error = _append_plan_start(
1660
+ change_dir, run_id, attempt, f"plan amendment: {reason}"
1661
+ )
1662
+ if start_code != 0:
1663
+ return _result_error(
1664
+ "PLAN_AMEND_START_FAILED",
1665
+ start_error or "failed to append plan phase.start for the amendment",
1666
+ )
1667
+
1668
+ result = finalize_plan(
1669
+ change_dir,
1670
+ staging,
1671
+ change_name=change_name,
1672
+ run_id=run_id,
1673
+ attempt=attempt,
1674
+ allow_amend=True,
1675
+ amend_reason=reason,
1676
+ )
1677
+ if isinstance(result, dict):
1678
+ result.setdefault("runId", run_id)
1679
+ result.setdefault("attempt", attempt)
1680
+ return result
1681
+
1682
+
1473
1683
  def build_parser() -> argparse.ArgumentParser:
1474
1684
  parser = argparse.ArgumentParser(prog="harness_plan_finalize.py")
1475
1685
  sub = parser.add_subparsers(dest="command", required=True)
1686
+ republish = sub.add_parser(
1687
+ "republish",
1688
+ help=(
1689
+ "publish an approved amendment to an already-finalized plan "
1690
+ "(allocates a fresh attempt and records what it supersedes)"
1691
+ ),
1692
+ )
1693
+ republish.add_argument("--change-dir", required=True)
1694
+ republish.add_argument("--staging-dir", required=True)
1695
+ republish.add_argument("--change", required=True)
1696
+ republish.add_argument(
1697
+ "--run-id", required=True, help="a fresh run id, unused by this change"
1698
+ )
1699
+ republish.add_argument(
1700
+ "--reason", required=True, help="why the published plan is being amended"
1701
+ )
1702
+ republish.add_argument("--json", action="store_true")
1476
1703
  finalize = sub.add_parser("finalize")
1477
1704
  finalize.add_argument("--change-dir", required=True)
1478
1705
  finalize.add_argument("--staging-dir", required=True)
@@ -1490,6 +1717,14 @@ def main(argv: list[str] | None = None) -> int:
1490
1717
  args = build_parser().parse_args(argv)
1491
1718
  if args.command == "verify":
1492
1719
  result = verify_plan(Path(args.change_dir))
1720
+ elif args.command == "republish":
1721
+ result = republish_plan(
1722
+ Path(args.change_dir),
1723
+ Path(args.staging_dir),
1724
+ change_name=args.change,
1725
+ run_id=args.run_id,
1726
+ reason=args.reason,
1727
+ )
1493
1728
  else:
1494
1729
  result = finalize_plan(
1495
1730
  Path(args.change_dir),
@@ -1462,6 +1462,10 @@ def _job_id_path(change_dir: Path) -> Path:
1462
1462
  return _runtime_dir(change_dir) / "_harness_service.job.id"
1463
1463
 
1464
1464
 
1465
+ def _breakaway_note_path(change_dir: Path) -> Path:
1466
+ return _runtime_dir(change_dir) / "_harness_service.breakaway.json"
1467
+
1468
+
1465
1469
  def _child_pid_path(change_dir: Path) -> Path:
1466
1470
  return _runtime_dir(change_dir) / "_harness_service.child.pid"
1467
1471
 
@@ -1574,20 +1578,56 @@ def _start_detached_service_windows(
1574
1578
  str(pid_path),
1575
1579
  job_id,
1576
1580
  ]
1577
- win_flags = (
1578
- _DETACHED_PROCESS
1579
- | _CREATE_NEW_PROCESS_GROUP
1580
- | _CREATE_NO_WINDOW
1581
- | _CREATE_BREAKAWAY_FROM_JOB
1582
- )
1583
- launcher_proc = subprocess.Popen(
1584
- launcher_args,
1585
- cwd=str(cwd),
1586
- stdin=subprocess.DEVNULL,
1587
- stdout=subprocess.DEVNULL,
1588
- stderr=subprocess.DEVNULL,
1589
- creationflags=win_flags,
1590
- close_fds=False,
1581
+ base_flags = _DETACHED_PROCESS | _CREATE_NEW_PROCESS_GROUP | _CREATE_NO_WINDOW
1582
+ # CREATE_BREAKAWAY_FROM_JOB fails with ERROR_ACCESS_DENIED (WinError 5)
1583
+ # whenever the *caller* already runs inside a Job Object that was created
1584
+ # without JOB_OBJECT_LIMIT_BREAKAWAY_OK — which is exactly how agent CLIs
1585
+ # (Claude Code / CodeBuddy / Codex) contain their child processes. Breakaway
1586
+ # is an optimisation (the service outlives the agent session), not a
1587
+ # requirement, so fall back to spawning inside the caller's job rather than
1588
+ # failing the whole start.
1589
+ launcher_proc = None
1590
+ breakaway = True
1591
+ try:
1592
+ launcher_proc = subprocess.Popen(
1593
+ launcher_args,
1594
+ cwd=str(cwd),
1595
+ stdin=subprocess.DEVNULL,
1596
+ stdout=subprocess.DEVNULL,
1597
+ stderr=subprocess.DEVNULL,
1598
+ creationflags=base_flags | _CREATE_BREAKAWAY_FROM_JOB,
1599
+ close_fds=False,
1600
+ )
1601
+ except OSError as exc:
1602
+ if getattr(exc, "winerror", None) != 5:
1603
+ raise
1604
+ breakaway = False
1605
+ launcher_proc = subprocess.Popen(
1606
+ launcher_args,
1607
+ cwd=str(cwd),
1608
+ stdin=subprocess.DEVNULL,
1609
+ stdout=subprocess.DEVNULL,
1610
+ stderr=subprocess.DEVNULL,
1611
+ creationflags=base_flags,
1612
+ close_fds=False,
1613
+ )
1614
+ _breakaway_note_path(change_dir).write_text(
1615
+ json.dumps(
1616
+ {
1617
+ "breakawayFromJob": breakaway,
1618
+ "note": (
1619
+ None
1620
+ if breakaway
1621
+ else "spawned inside the caller's Job Object "
1622
+ "(CREATE_BREAKAWAY_FROM_JOB denied); the service is "
1623
+ "terminated when the agent session ends"
1624
+ ),
1625
+ },
1626
+ ensure_ascii=False,
1627
+ )
1628
+ + "\n",
1629
+ encoding="utf-8",
1630
+ newline="\n",
1591
1631
  )
1592
1632
  _launcher_pid_path(change_dir).write_text(str(launcher_proc.pid), encoding="utf-8")
1593
1633
  _wait_for_child_pid(pid_path)
@@ -2400,7 +2440,21 @@ def _start_and_record(
2400
2440
  }
2401
2441
  )
2402
2442
  write_json(mutation_state_path(change_dir), mutation_state)
2403
- return emit_error(f"failed to start service: {exc}", as_json=as_json)
2443
+ if getattr(exc, "winerror", None) == 5:
2444
+ return emit_error(
2445
+ "failed to start service: Windows denied process creation "
2446
+ f"({exc}). The agent shell blocks detached spawns; start the "
2447
+ "service from a terminal the agent does not own (or use the "
2448
+ "documented nohup fallback) and re-run `ensure` so the harness "
2449
+ "adopts the running instance.",
2450
+ as_json=as_json,
2451
+ reasonCode="SERVICE_SPAWN_DENIED",
2452
+ )
2453
+ return emit_error(
2454
+ f"failed to start service: {exc}",
2455
+ as_json=as_json,
2456
+ reasonCode="SERVICE_START_FAILED",
2457
+ )
2404
2458
 
2405
2459
  # Give the OS a moment to register the process before identity/create-time reads
2406
2460
  time.sleep(0.15)
@@ -178,6 +178,32 @@ def _state_project_root(project: Path) -> Path:
178
178
  return resolved.parent if resolved.name == ".git" else project
179
179
 
180
180
 
181
+ def _invalid_project_root(
182
+ action: str, project: Path | str, resolved: Path
183
+ ) -> dict[str, Any] | None:
184
+ """Reject a --project that is not an existing directory.
185
+
186
+ Passing the *project name* (``--project udp``) instead of its path used to
187
+ resolve to ``<cwd>/udp`` and then surface as SNAPSHOT_MISSING — an error
188
+ that points at the wrong thing entirely. Fail here with the resolved path
189
+ so the real mistake is visible.
190
+ """
191
+ if resolved.is_dir():
192
+ return None
193
+ return _result(
194
+ False,
195
+ action,
196
+ "PROJECT_ROOT_INVALID",
197
+ [],
198
+ project=str(project),
199
+ resolvedProject=str(resolved),
200
+ hint=(
201
+ "--project takes a filesystem path to the project root "
202
+ "(use '.' when running from it), not the project name"
203
+ ),
204
+ )
205
+
206
+
181
207
  def _change_dir(project: Path, change_dir: Path | str) -> Path | None:
182
208
  state_project = _state_project_root(project)
183
209
  candidate = Path(change_dir)
@@ -556,6 +582,9 @@ def record(
556
582
  ) -> dict[str, Any]:
557
583
  action = "record"
558
584
  project_root = Path(project).resolve()
585
+ invalid_root = _invalid_project_root(action, project, project_root)
586
+ if invalid_root is not None:
587
+ return invalid_root
559
588
  if not files:
560
589
  return _result(False, action, "EMPTY_FILES", [])
561
590
  if reason not in REASONS:
@@ -1452,6 +1481,9 @@ def _reconcile_close_manifest(
1452
1481
  def begin(project: Path | str, change_dir: Path | str) -> dict[str, Any]:
1453
1482
  action = "begin"
1454
1483
  project_root = Path(project).resolve()
1484
+ invalid_root = _invalid_project_root(action, project, project_root)
1485
+ if invalid_root is not None:
1486
+ return invalid_root
1455
1487
  change_root = _change_dir(project_root, change_dir)
1456
1488
  if change_root is None:
1457
1489
  return _result(False, action, "CHANGE_DIR_OUTSIDE_PROJECT", [])
@@ -1545,12 +1577,24 @@ def begin(project: Path | str, change_dir: Path | str) -> dict[str, Any]:
1545
1577
  def close(project: Path | str, change_dir: Path | str) -> dict[str, Any]:
1546
1578
  action = "close"
1547
1579
  project_root = Path(project).resolve()
1580
+ invalid_root = _invalid_project_root(action, project, project_root)
1581
+ if invalid_root is not None:
1582
+ return invalid_root
1548
1583
  change_root = _change_dir(project_root, change_dir)
1549
1584
  if change_root is None:
1550
1585
  return _result(False, action, "CHANGE_DIR_OUTSIDE_PROJECT", [])
1551
1586
  snapshot_path = _state_root(change_root) / SNAPSHOT_REL
1552
1587
  if not snapshot_path.is_file():
1553
- return _result(False, action, "SNAPSHOT_MISSING", [])
1588
+ # Name the exact path: this snapshot is evidence/test-guard-snapshot.json,
1589
+ # not meta/state-snapshot.json, and the two are easy to confuse.
1590
+ return _result(
1591
+ False,
1592
+ action,
1593
+ "SNAPSHOT_MISSING",
1594
+ [],
1595
+ expectedSnapshot=str(snapshot_path),
1596
+ hint="run `harness_test_guard.py begin` for this change first",
1597
+ )
1554
1598
  try:
1555
1599
  snapshot = _read_json(snapshot_path)
1556
1600
  except (OSError, json.JSONDecodeError) as exc:
@@ -1752,6 +1796,9 @@ def mark(
1752
1796
  def stage(project: Path | str, change_dir: Path | str) -> dict[str, Any]:
1753
1797
  action = "stage"
1754
1798
  project_root = Path(project).resolve()
1799
+ invalid_root = _invalid_project_root(action, project, project_root)
1800
+ if invalid_root is not None:
1801
+ return invalid_root
1755
1802
  change_root = _change_dir(project_root, change_dir)
1756
1803
  if change_root is None:
1757
1804
  return _result(False, action, "CHANGE_DIR_OUTSIDE_PROJECT", [])