devlyn-cli 4.1.0 → 4.2.1

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 (201) hide show
  1. package/AGENTS.md +10 -94
  2. package/CLAUDE.md +7 -160
  3. package/LICENSE +21 -0
  4. package/README.md +68 -111
  5. package/bin/devlyn.js +265 -259
  6. package/bin/instruction-templates.json +43 -2
  7. package/bin/instructions.js +29 -8
  8. package/bin/skill-history.json +140 -0
  9. package/bin/skill-ownership.js +67 -0
  10. package/config/skills/_shared/adapters/claude.md +8 -85
  11. package/config/skills/_shared/adapters/codex.md +14 -34
  12. package/config/skills/_shared/adapters/omp.md +1 -11
  13. package/config/skills/_shared/codex-monitored.sh +4 -54
  14. package/config/skills/_shared/engine-doctor.sh +18 -58
  15. package/config/skills/_shared/engine-preflight.md +15 -47
  16. package/config/skills/_shared/expected-contract.py +242 -0
  17. package/config/skills/_shared/expected.schema.json +10 -95
  18. package/config/skills/_shared/invocation-receipt.py +26 -757
  19. package/config/skills/_shared/platform-support.py +37 -28
  20. package/config/skills/_shared/role-config.py +100 -421
  21. package/config/skills/_shared/runtime-principles.md +17 -93
  22. package/config/skills/_shared/task-complete.py +490 -285
  23. package/config/skills/_shared/task-completion.md +235 -0
  24. package/config/skills/devlyn-engines/SKILL.md +9 -11
  25. package/config/skills/devlyn-ideate/SKILL.md +57 -107
  26. package/config/skills/devlyn-ideate/references/elicitation.md +18 -99
  27. package/config/skills/devlyn-ideate/references/loop.md +110 -0
  28. package/config/skills/devlyn-ideate/references/package-format.md +128 -0
  29. package/config/skills/devlyn-ideate/scripts/acceptance.py +795 -0
  30. package/config/skills/devlyn-ideate/scripts/queue.py +1780 -0
  31. package/node_modules/@inquirer/ansi/LICENSE +22 -0
  32. package/node_modules/@inquirer/ansi/README.md +121 -0
  33. package/node_modules/@inquirer/ansi/dist/index.d.ts +14 -0
  34. package/node_modules/@inquirer/ansi/dist/index.js +21 -0
  35. package/node_modules/@inquirer/ansi/package.json +79 -0
  36. package/node_modules/@inquirer/checkbox/LICENSE +22 -0
  37. package/node_modules/@inquirer/checkbox/README.md +265 -0
  38. package/node_modules/@inquirer/checkbox/dist/index.d.ts +67 -0
  39. package/node_modules/@inquirer/checkbox/dist/index.js +210 -0
  40. package/node_modules/@inquirer/checkbox/package.json +93 -0
  41. package/node_modules/@inquirer/core/LICENSE +22 -0
  42. package/node_modules/@inquirer/core/README.md +424 -0
  43. package/node_modules/@inquirer/core/dist/index.d.ts +13 -0
  44. package/node_modules/@inquirer/core/dist/index.js +12 -0
  45. package/node_modules/@inquirer/core/dist/lib/Separator.d.ts +10 -0
  46. package/node_modules/@inquirer/core/dist/lib/Separator.js +21 -0
  47. package/node_modules/@inquirer/core/dist/lib/__probe/p1.d.ts +9 -0
  48. package/node_modules/@inquirer/core/dist/lib/__probe/p1.js +4 -0
  49. package/node_modules/@inquirer/core/dist/lib/__probe/p2.d.ts +9 -0
  50. package/node_modules/@inquirer/core/dist/lib/__probe/p2.js +3 -0
  51. package/node_modules/@inquirer/core/dist/lib/__probe/p3.d.ts +9 -0
  52. package/node_modules/@inquirer/core/dist/lib/__probe/p3.js +5 -0
  53. package/node_modules/@inquirer/core/dist/lib/create-prompt.d.ts +11 -0
  54. package/node_modules/@inquirer/core/dist/lib/create-prompt.js +185 -0
  55. package/node_modules/@inquirer/core/dist/lib/errors.d.ts +20 -0
  56. package/node_modules/@inquirer/core/dist/lib/errors.js +21 -0
  57. package/node_modules/@inquirer/core/dist/lib/hook-engine.d.ts +17 -0
  58. package/node_modules/@inquirer/core/dist/lib/hook-engine.js +109 -0
  59. package/node_modules/@inquirer/core/dist/lib/key.d.ts +15 -0
  60. package/node_modules/@inquirer/core/dist/lib/key.js +34 -0
  61. package/node_modules/@inquirer/core/dist/lib/make-theme.d.ts +3 -0
  62. package/node_modules/@inquirer/core/dist/lib/make-theme.js +33 -0
  63. package/node_modules/@inquirer/core/dist/lib/pagination/use-pagination.d.ts +15 -0
  64. package/node_modules/@inquirer/core/dist/lib/pagination/use-pagination.js +121 -0
  65. package/node_modules/@inquirer/core/dist/lib/screen-manager.d.ts +14 -0
  66. package/node_modules/@inquirer/core/dist/lib/screen-manager.js +84 -0
  67. package/node_modules/@inquirer/core/dist/lib/theme.d.ts +166 -0
  68. package/node_modules/@inquirer/core/dist/lib/theme.js +29 -0
  69. package/node_modules/@inquirer/core/dist/lib/use-effect.d.ts +2 -0
  70. package/node_modules/@inquirer/core/dist/lib/use-effect.js +11 -0
  71. package/node_modules/@inquirer/core/dist/lib/use-keypress.d.ts +3 -0
  72. package/node_modules/@inquirer/core/dist/lib/use-keypress.js +20 -0
  73. package/node_modules/@inquirer/core/dist/lib/use-memo.d.ts +1 -0
  74. package/node_modules/@inquirer/core/dist/lib/use-memo.js +14 -0
  75. package/node_modules/@inquirer/core/dist/lib/use-prefix.d.ts +5 -0
  76. package/node_modules/@inquirer/core/dist/lib/use-prefix.js +35 -0
  77. package/node_modules/@inquirer/core/dist/lib/use-ref.d.ts +6 -0
  78. package/node_modules/@inquirer/core/dist/lib/use-ref.js +4 -0
  79. package/node_modules/@inquirer/core/dist/lib/use-state.d.ts +7 -0
  80. package/node_modules/@inquirer/core/dist/lib/use-state.js +28 -0
  81. package/node_modules/@inquirer/core/dist/lib/utils.d.ts +13 -0
  82. package/node_modules/@inquirer/core/dist/lib/utils.js +25 -0
  83. package/node_modules/@inquirer/core/package.json +98 -0
  84. package/node_modules/@inquirer/figures/LICENSE +22 -0
  85. package/node_modules/@inquirer/figures/dist/index.d.ts +275 -0
  86. package/node_modules/@inquirer/figures/dist/index.js +315 -0
  87. package/node_modules/@inquirer/figures/package.json +81 -0
  88. package/node_modules/@inquirer/select/LICENSE +22 -0
  89. package/node_modules/@inquirer/select/README.md +258 -0
  90. package/node_modules/@inquirer/select/dist/index.d.ts +42 -0
  91. package/node_modules/@inquirer/select/dist/index.js +191 -0
  92. package/node_modules/@inquirer/select/package.json +93 -0
  93. package/node_modules/@inquirer/type/LICENSE +22 -0
  94. package/node_modules/@inquirer/type/dist/index.d.ts +2 -0
  95. package/node_modules/@inquirer/type/dist/index.js +2 -0
  96. package/node_modules/@inquirer/type/dist/inquirer.d.ts +35 -0
  97. package/node_modules/@inquirer/type/dist/inquirer.js +1 -0
  98. package/node_modules/@inquirer/type/dist/utils.d.ts +45 -0
  99. package/node_modules/@inquirer/type/dist/utils.js +16 -0
  100. package/node_modules/@inquirer/type/package.json +88 -0
  101. package/node_modules/cli-width/LICENSE +13 -0
  102. package/node_modules/cli-width/README.md +71 -0
  103. package/node_modules/cli-width/index.d.ts +13 -0
  104. package/node_modules/cli-width/index.js +49 -0
  105. package/node_modules/cli-width/package.json +40 -0
  106. package/node_modules/fast-string-truncated-width/dist/index.d.ts +4 -0
  107. package/node_modules/fast-string-truncated-width/dist/index.js +111 -0
  108. package/node_modules/fast-string-truncated-width/dist/types.d.ts +19 -0
  109. package/node_modules/fast-string-truncated-width/dist/types.js +2 -0
  110. package/node_modules/fast-string-truncated-width/dist/utils.d.ts +4 -0
  111. package/node_modules/fast-string-truncated-width/dist/utils.js +20 -0
  112. package/node_modules/fast-string-truncated-width/license +21 -0
  113. package/node_modules/fast-string-truncated-width/package.json +35 -0
  114. package/node_modules/fast-string-truncated-width/readme.md +59 -0
  115. package/node_modules/fast-string-width/dist/index.d.ts +4 -0
  116. package/node_modules/fast-string-width/dist/index.js +14 -0
  117. package/node_modules/fast-string-width/license +21 -0
  118. package/node_modules/fast-string-width/package.json +34 -0
  119. package/node_modules/fast-string-width/readme.md +42 -0
  120. package/node_modules/fast-wrap-ansi/LICENSE +23 -0
  121. package/node_modules/fast-wrap-ansi/README.md +26 -0
  122. package/node_modules/fast-wrap-ansi/lib/main.d.ts +6 -0
  123. package/node_modules/fast-wrap-ansi/lib/main.js +218 -0
  124. package/node_modules/fast-wrap-ansi/package.json +51 -0
  125. package/node_modules/mute-stream/LICENSE +15 -0
  126. package/node_modules/mute-stream/README.md +68 -0
  127. package/node_modules/mute-stream/lib/index.js +142 -0
  128. package/node_modules/mute-stream/package.json +54 -0
  129. package/node_modules/signal-exit/LICENSE.txt +16 -0
  130. package/node_modules/signal-exit/README.md +74 -0
  131. package/node_modules/signal-exit/dist/cjs/browser.d.ts +12 -0
  132. package/node_modules/signal-exit/dist/cjs/browser.d.ts.map +1 -0
  133. package/node_modules/signal-exit/dist/cjs/browser.js +10 -0
  134. package/node_modules/signal-exit/dist/cjs/browser.js.map +1 -0
  135. package/node_modules/signal-exit/dist/cjs/index.d.ts +48 -0
  136. package/node_modules/signal-exit/dist/cjs/index.d.ts.map +1 -0
  137. package/node_modules/signal-exit/dist/cjs/index.js +279 -0
  138. package/node_modules/signal-exit/dist/cjs/index.js.map +1 -0
  139. package/node_modules/signal-exit/dist/cjs/package.json +3 -0
  140. package/node_modules/signal-exit/dist/cjs/signals.d.ts +29 -0
  141. package/node_modules/signal-exit/dist/cjs/signals.d.ts.map +1 -0
  142. package/node_modules/signal-exit/dist/cjs/signals.js +42 -0
  143. package/node_modules/signal-exit/dist/cjs/signals.js.map +1 -0
  144. package/node_modules/signal-exit/dist/mjs/browser.d.ts +12 -0
  145. package/node_modules/signal-exit/dist/mjs/browser.d.ts.map +1 -0
  146. package/node_modules/signal-exit/dist/mjs/browser.js +4 -0
  147. package/node_modules/signal-exit/dist/mjs/browser.js.map +1 -0
  148. package/node_modules/signal-exit/dist/mjs/index.d.ts +48 -0
  149. package/node_modules/signal-exit/dist/mjs/index.d.ts.map +1 -0
  150. package/node_modules/signal-exit/dist/mjs/index.js +275 -0
  151. package/node_modules/signal-exit/dist/mjs/index.js.map +1 -0
  152. package/node_modules/signal-exit/dist/mjs/package.json +3 -0
  153. package/node_modules/signal-exit/dist/mjs/signals.d.ts +29 -0
  154. package/node_modules/signal-exit/dist/mjs/signals.d.ts.map +1 -0
  155. package/node_modules/signal-exit/dist/mjs/signals.js +39 -0
  156. package/node_modules/signal-exit/dist/mjs/signals.js.map +1 -0
  157. package/node_modules/signal-exit/package.json +106 -0
  158. package/package.json +17 -4
  159. package/config/commit-conventions.md +0 -28
  160. package/config/skills/_shared/adapters/README.md +0 -74
  161. package/config/skills/_shared/adapters/grok.md +0 -100
  162. package/config/skills/_shared/archive_run.py +0 -969
  163. package/config/skills/_shared/codex-config.md +0 -81
  164. package/config/skills/_shared/collect-codex-findings.py +0 -505
  165. package/config/skills/_shared/finish-gate.py +0 -521
  166. package/config/skills/_shared/grok-anchor-guard.py +0 -78
  167. package/config/skills/_shared/judge-output-parser.py +0 -200
  168. package/config/skills/_shared/judge-role-evidence.py +0 -320
  169. package/config/skills/_shared/phase-prompt-render.py +0 -183
  170. package/config/skills/_shared/process-evidence.py +0 -926
  171. package/config/skills/_shared/resolve-bootstrap.py +0 -1410
  172. package/config/skills/_shared/resolve-stop-hook.py +0 -359
  173. package/config/skills/_shared/run-bounded.py +0 -58
  174. package/config/skills/_shared/spec-verify-check.py +0 -5083
  175. package/config/skills/_shared/state-phase-write.py +0 -5989
  176. package/config/skills/_shared/terminal-claim-check.py +0 -777
  177. package/config/skills/_shared/verify-merge-findings.py +0 -3528
  178. package/config/skills/devlyn-design-ui/SKILL.md +0 -650
  179. package/config/skills/devlyn-ideate/references/from-spec-mode.md +0 -61
  180. package/config/skills/devlyn-ideate/references/project-mode.md +0 -80
  181. package/config/skills/devlyn-ideate/references/spec-template.md +0 -121
  182. package/config/skills/devlyn-ideate/references/templates/decision.md +0 -48
  183. package/config/skills/devlyn-ideate/references/templates/roadmap.md +0 -50
  184. package/config/skills/devlyn-ideate/references/templates/vision.md +0 -44
  185. package/config/skills/devlyn-queue/SKILL.md +0 -66
  186. package/config/skills/devlyn-queue/scripts/append.py +0 -94
  187. package/config/skills/devlyn-resolve/SKILL.md +0 -379
  188. package/config/skills/devlyn-resolve/references/free-form-mode.md +0 -71
  189. package/config/skills/devlyn-resolve/references/outer-loop.md +0 -23
  190. package/config/skills/devlyn-resolve/references/phases/build-gate.md +0 -75
  191. package/config/skills/devlyn-resolve/references/phases/cleanup.md +0 -33
  192. package/config/skills/devlyn-resolve/references/phases/implement.md +0 -44
  193. package/config/skills/devlyn-resolve/references/phases/plan.md +0 -39
  194. package/config/skills/devlyn-resolve/references/phases/probe-derive.md +0 -261
  195. package/config/skills/devlyn-resolve/references/phases/surface-close.md +0 -34
  196. package/config/skills/devlyn-resolve/references/phases/verify.md +0 -310
  197. package/config/skills/devlyn-resolve/references/state-schema.md +0 -152
  198. package/config/skills/devlyn-resolve/references/task-completion.md +0 -160
  199. package/config/templates/prompt-templates.md +0 -71
  200. package/config/templates/template-feature.spec.md +0 -255
  201. package/config/templates/template-product-spec.md +0 -680
@@ -2,7 +2,7 @@
2
2
  """Outer-owner delivery and recoverable cleanup (task-completion spec, R1–7).
3
3
 
4
4
  No phase/state writer, staging, scheduler, or implicit adoption. See
5
- devlyn-resolve/references/task-completion.md for the owner acceptance contract.
5
+ _shared/task-completion.md for the owner acceptance contract.
6
6
  """
7
7
  from __future__ import annotations
8
8
 
@@ -28,6 +28,14 @@ class CompletionError(Exception):
28
28
  pass
29
29
 
30
30
 
31
+ class WritersUnobservable(CompletionError):
32
+ """Writer cessation cannot be observed here, so owned resources are retained, never deleted."""
33
+
34
+
35
+ class WriterActive(CompletionError):
36
+ """An observed process still uses the owned files: wait for it or stop it, then resume."""
37
+
38
+
31
39
  def require(condition, message):
32
40
  if not condition:
33
41
  raise CompletionError(message)
@@ -50,7 +58,7 @@ def shared(name):
50
58
 
51
59
  def read_json(path):
52
60
  require(stat.S_ISREG(path.lstat().st_mode), f"JSON must be a nonsymlink regular file: {path}")
53
- return shared("archive_run")["loads_strict_json"](path.read_text(encoding="utf-8"))
61
+ return shared("expected-contract")["loads_strict_json"](path.read_text(encoding="utf-8"))
54
62
 
55
63
 
56
64
  def atomic_json(path, value):
@@ -180,19 +188,24 @@ def allocate(args):
180
188
  common = Path(git(work, "rev-parse", "--path-format=absolute", "--git-common-dir")).resolve()
181
189
  require(args.task.strip(), "task identity is required")
182
190
  git(work, "check-ref-format", "refs/heads/" + args.branch)
183
- git(work, "check-ref-format", "refs/heads/" + args.base)
191
+ if args.base is not None:
192
+ git(work, "check-ref-format", "refs/heads/" + args.base)
184
193
  require(args.branch != args.base and args.branch not in {"main", "master"}, "cannot own a base/default branch")
185
194
  require(re.fullmatch(r"[A-Za-z0-9_.-]+", args.remote), "unsafe remote name")
186
195
  receipt = {"task": args.task, "repository": args.repository, "remote": args.remote,
187
196
  "base": args.base, "branch": args.branch, "common_gitdir": str(common),
188
197
  "anchor": str(work), "linked": True, "allocation": "allocating"}
189
- policy(receipt, None)
190
- receipt["remote_url"] = remote_url(receipt)
191
- require("\n" not in receipt["remote_url"]["push"] and receipt["remote_url"]["push"] == receipt["remote_url"]["fetch"], "split/multiple remote URLs are unsupported")
192
198
  require(not ref_sha(receipt, "refs/heads/"+args.branch), "existing branch cannot be adopted")
193
- receipt["baseline"] = remote_base(receipt)
194
- target = Path(args.worktree).absolute()
195
- require(target == target.resolve(), "worktree path must not traverse symlinks")
199
+ local = local_baseline(receipt, args)
200
+ if local:
201
+ receipt["local_only"] = True # Local work needs no remote: nothing is fetched or pushed.
202
+ else:
203
+ require(args.repository and args.base, "a remote allocation needs --repository <owner/repo> and --base <branch>; allocate local work with --local-base or --from-receipt")
204
+ policy(receipt, None)
205
+ receipt["remote_url"] = remote_url(receipt)
206
+ require("\n" not in receipt["remote_url"]["push"] and receipt["remote_url"]["push"] == receipt["remote_url"]["fetch"], "split/multiple remote URLs are unsupported")
207
+ receipt["baseline"] = local or exact_commit(receipt, args.start, "--start") or remote_base(receipt)
208
+ target = Path(args.worktree).resolve() # A relative, `..` or symlinked (macOS /tmp) path names its real location.
196
209
  require(not target.exists() and all(not target.is_relative_to(p) and not p.is_relative_to(target) for p in map(Path, registrations(receipt))), "linked worktree must be an absent path disjoint from every registered worktree")
197
210
  receipt["worktree"] = str(target)
198
211
  key = hashlib.sha256(args.branch.encode()).hexdigest()[:24]
@@ -213,78 +226,49 @@ def allocate(args):
213
226
  receipt["allocation"] = "owned"
214
227
  atomic_json(path, receipt)
215
228
  return {"status": "ALLOCATED", "receipt": str(path), "worktree": str(target), "scratch": str(scratch),
216
- "reconciled": reconcile(common, path, work)}
217
-
218
-
219
- def pipeline_acceptance(work, acceptance, files, directory):
220
- run_id = acceptance["run_id"]
221
- require(re.fullmatch(r"[A-Za-z0-9_.-]+", run_id) and run_id not in {".", ".."}, "invalid intended run id")
222
- archive = safe_path(work, ".devlyn/runs/" + run_id)
223
- state_file = archive / "pipeline.state.json"
224
- classifier = shared("terminal-claim-check")
225
- classification, state = classifier["classify_state_bytes"](work, state_file, state_file.read_bytes(), archived=True)
226
- require(classification.status == "CLEAN" and state["run_id"] == run_id, "intended archive is not terminal CLEAN")
227
- require(state.get("mode") in {"spec", "free-form"}, "only normal archived runs may publish")
228
- require((state.get("source") or {}).get("type") in {"spec", "generated"}, "normal archive must bind its source contract")
229
- phases = state["phases"]
230
- success = {"PASS", "PASS_WITH_ISSUES"}
231
- verify = phases.get("verify") or {}
232
- final = phases.get("final_report") or {}
233
- require(verify.get("verdict") in success and final.get("verdict") == verify["verdict"], "archive did not finish successfully")
234
- require(not any(str((phase or {}).get("verdict", "")).startswith("BLOCKED") for phase in phases.values()), "blocked phase takes terminal precedence")
235
- for name in ("plan", "implement", "cleanup", "verify", "final_report", "build_gate"):
236
- if name == "build_gate" and "build-gate" in (state.get("bypasses") or []):
237
- continue
238
- phase = phases.get(name) or {}
239
- require(phase.get("completed_at") and phase.get("verdict") in success, f"required {name} evidence is incomplete")
240
- require(phases["cleanup"].get("post_sha") == acceptance["source_sha"], "accepted source must equal cleanup.post_sha")
241
- report_digest = shared("state-phase-write")["final_report_digest"](state, archive, str(archive / "final-report.md"))
242
- require(final.get("output_sha256") == report_digest and final.get("artifacts", {}).get("log_file") == ".devlyn/final-report.md", "final report binding mismatch")
243
- finish = read_json(archive / "finish-gate.summary.json")
244
- require(finish.get("mode") == state["mode"] and finish.get("exit") == 0 and finish.get("offenders") == 0 and not finish.get("skipped") and not finish.get("malformed"), "finish gate was not clean")
245
- require(read_json(archive / "verify-merge.summary.json").get("verdict") == verify["verdict"], "VERIFY summary disagrees with terminal result")
246
- # Existing evidence validators expect the pre-archive .devlyn layout. Rebuild
247
- # it in a temporary directory; never move or mutate the original archive.
248
- with tempfile.TemporaryDirectory(prefix="validate-", dir=directory) as tmp:
249
- reconstructed = Path(tmp)
250
- devlyn = reconstructed / ".devlyn"
251
- devlyn.mkdir()
252
- prefix = f".devlyn/runs/{run_id}/"
253
- for rel in files:
254
- if rel.startswith(prefix):
255
- dest = safe_path(devlyn, rel[len(prefix):])
256
- dest.parent.mkdir(parents=True, exist_ok=True)
257
- shutil.copyfile(safe_path(work, rel), dest)
258
- archive_module = shared("archive_run")
259
- try:
260
- for validator in ("dynamic_evidence_artifacts", "dynamic_invocation_artifacts", "dynamic_judge_role_artifacts"):
261
- archive_module[validator](devlyn, state)
262
- except archive_module["ArchiveError"] as exc:
263
- raise CompletionError(str(exc)) from exc
264
- evidence_module = shared("process-evidence")
265
- for carrier in state.get("process_evidence") or []:
266
- if carrier["round"] == (phases.get(carrier["phase"]) or {}).get("round"):
267
- require(evidence_module["bound_carrier_outcome"](reconstructed, carrier)["verdict"] == "PASS", "required process evidence failed")
268
- source = state.get("source") or {}
269
- if source.get("spec_path"):
270
- expected = Path(source["spec_path"]).with_name("spec.expected.json")
271
- original = safe_path(work, str(expected))
272
- if original.exists():
273
- dest = safe_path(reconstructed, str(expected))
274
- dest.parent.mkdir(parents=True, exist_ok=True)
275
- shutil.copyfile(original, dest)
276
- for phase in ("implement", "build_gate", "verify"):
277
- obligations = evidence_module["declared_obligations"](reconstructed, state, phase)
278
- if obligations:
279
- carriers = [c for c in state.get("process_evidence") or [] if c.get("phase") == phase and c.get("round") == (phases.get(phase) or {}).get("round")]
280
- require(len(carriers) == 1, f"missing/ambiguous current {phase} process evidence")
281
- carrier = carriers[0]
282
- evidence_module["validate_manifest"](reconstructed, carrier["manifest"]["path"], run_id, phase, carrier["round"], obligations)
283
- if evidence_module["mechanical_evidence_required"](reconstructed, state):
284
- require((devlyn / "spec-verify.results.json").is_file(), "required MECHANICAL results are missing")
285
- carrier = shared("verify-merge-findings")["mechanical_evidence_carrier"](devlyn)
286
- if carrier:
287
- require(evidence_module["bound_carrier_outcome"](reconstructed, carrier)["verdict"] == "PASS", "MECHANICAL checks did not pass")
229
+ "reconciled": [] if local else reconcile(common, path, work)}
230
+
231
+
232
+ def exact_commit(receipt, value, flag):
233
+ require(value is None or re.fullmatch(r"[0-9a-f]{40,64}", value) and ref_sha(receipt, value+"^{commit}") == value,
234
+ f"{flag} must name an exact local commit")
235
+ return value
236
+
237
+
238
+ def local_baseline(receipt, args):
239
+ """A local loop starts from its recorded base commit or a receipt-bound accepted predecessor, never a fetch."""
240
+ require(sum(map(bool, (args.local_base, args.from_receipt, args.start))) <= 1, "use one of --local-base, --from-receipt or --start")
241
+ if args.from_receipt:
242
+ with locked_receipt(Path(args.from_receipt).absolute(), blocking=False) as predecessor:
243
+ require(predecessor["common_gitdir"] == receipt["common_gitdir"], "predecessor receipt belongs to another repository")
244
+ require(predecessor.get("acceptance") and predecessor.get("product") != "FAILED", "predecessor has no accepted result")
245
+ require(ref_sha(predecessor, predecessor["recovery_ref"]) == predecessor["publish_sha"], "predecessor recovery ref changed")
246
+ gref(predecessor, "merge-base", "--is-ancestor", predecessor["source_sha"], predecessor["publish_sha"])
247
+ receipt["allocated_from"] = {"receipt": predecessor["id"], "source_sha": predecessor["source_sha"]}
248
+ return predecessor["source_sha"]
249
+ return exact_commit(receipt, args.local_base, "--local-base")
250
+
251
+
252
+ def refuse_retired_pipeline(receipt, path, supplied, local, flags):
253
+ """Refuse an unbound acceptance of an archived resolve run before anything is written: binding its archive
254
+ needed the retired helpers. An unreadable acceptance is not classified here; binding reports it as before."""
255
+ if receipt.get("acceptance") or not supplied:
256
+ return
257
+ acceptance_path = Path(supplied).absolute()
258
+ try:
259
+ acceptance = read_json(acceptance_path)
260
+ except (CompletionError, OSError, ValueError):
261
+ return
262
+ if not isinstance(acceptance, dict) or acceptance.get("kind") != "pipeline":
263
+ return
264
+ command = shlex.join(["python3", "package/config/skills/_shared/task-complete.py", "complete", "--receipt", str(path),
265
+ "--acceptance", str(acceptance_path), *(["--local-only"] if local else []), *flags])
266
+ raise CompletionError(
267
+ "pipeline acceptance (an archived resolve run) was retired after devlyn-cli 4.1.0; nothing was changed. Finish the run "
268
+ f"with that release: run `npm pack devlyn-cli@4.1.0`, extract the tarball, then run `{command}` there. "
269
+ + ("That local-only completion ends as LOCAL_ONLY without binding the acceptance, as every 4.1.0 local-only completion did."
270
+ if local else "That completion binds the pipeline acceptance before publishing; delivery of a receipt it binds then "
271
+ "resumes with this helper."))
288
272
 
289
273
 
290
274
  def bind_acceptance(receipt, path, supplied):
@@ -295,7 +279,8 @@ def bind_acceptance(receipt, path, supplied):
295
279
  verify_files(path.parent / "custody", receipt["files"])
296
280
  return
297
281
  require(supplied, "first completion requires explicit root acceptance")
298
- acceptance_path = Path(supplied).absolute()
282
+ named = Path(supplied).absolute()
283
+ acceptance_path = named.parent.resolve() / named.name # Its directory's real path, as allocate names the worktree.
299
284
  require(acceptance_path.is_relative_to(work), "acceptance must be a regular file in its task checkout")
300
285
  safe_path(work, str(acceptance_path.relative_to(work)))
301
286
  acceptance = read_json(acceptance_path)
@@ -304,14 +289,10 @@ def bind_acceptance(receipt, path, supplied):
304
289
  require(isinstance(sha, str) and re.fullmatch(r"[0-9a-f]{40,64}", sha), "acceptance must name an exact commit")
305
290
  require(gref(receipt, "rev-parse", sha+"^{commit}") == sha, "source is not a commit")
306
291
  gref(receipt, "merge-base", "--is-ancestor", receipt["baseline"], sha)
307
- publish = sha
308
- queue = acceptance.get("queue")
309
- if queue:
310
- publish = queue["commit"]
311
- safe_path(work, queue["file"])
312
- require(gref(receipt, "rev-list", "--parents", "-n", "1", publish).split() == [publish, sha], "queue commit must be directly atop verified source with one parent")
313
- require(gref(receipt, "diff-tree", "--no-commit-id", "--name-only", "-r", "-z", publish).strip("\0") == queue["file"], "queue commit must change exactly the declared queue file")
314
- require(ref_sha(receipt, "refs/heads/"+receipt["branch"]) == publish and git(work, "rev-parse", "HEAD") == publish, "unverified source delta or changed task ref")
292
+ # A failed loop result is never published, so its checkout stays as the executor left it (as in attach).
293
+ failed = acceptance.get("kind") == "loop" and acceptance.get("verdict") == "FAILED"
294
+ require(ref_sha(receipt, "refs/heads/"+receipt["branch"]) == sha and (failed or git(work, "rev-parse", "HEAD") == sha),
295
+ "unverified source delta or changed task ref")
315
296
  paths = [str(acceptance_path.relative_to(work))]
316
297
  kind = acceptance.get("kind")
317
298
  if kind == "direct":
@@ -320,42 +301,66 @@ def bind_acceptance(receipt, path, supplied):
320
301
  for check in checks:
321
302
  require(isinstance(check.get("command"), str) and check["command"].strip(), "direct check must name its command")
322
303
  paths.append(check["evidence"])
323
- elif kind == "pipeline":
324
- paths.append(".devlyn/runs/" + acceptance["run_id"])
325
- state = read_json(safe_path(work, paths[-1]) / "pipeline.state.json")
326
- source = state.get("source") or {}
327
- for key in ("spec_path", "criteria_path"):
328
- if source.get(key):
329
- if key == "criteria_path" and source.get("type") == "generated":
330
- require(source[key] == ".devlyn/criteria.generated.md", "generated criteria path is not canonical")
331
- relative = f".devlyn/runs/{acceptance['run_id']}/criteria.generated.md"
332
- else:
333
- relative = source[key]
334
- paths.append(relative)
335
- source_file = safe_path(work, relative)
336
- if relative == source[key]:
337
- committed = subprocess.run(["git", "--git-dir", receipt["common_gitdir"], "show", sha+":"+relative], capture_output=True)
338
- require(committed.returncode == 0 and committed.stdout == source_file.read_bytes(), "source contract changed since accepted commit")
339
- bound_digest = source.get(key.replace("_path", "_sha256"))
340
- require(file_record(source_file)["sha256"] == bound_digest, "source contract differs from run binding")
341
- source_path = Path(source[key])
342
- expected = str(source_path.with_name("spec.expected.json") if key == "spec_path" else source_path.with_suffix(".expected.json"))
343
- if safe_path(work, expected).exists():
344
- paths.append(expected)
304
+ elif kind == "loop":
305
+ require(acceptance.get("verdict") in {"ACCEPTED", "FAILED"}, "loop result verdict must be ACCEPTED or FAILED")
306
+ evidence = acceptance.get("evidence")
307
+ require(isinstance(evidence, list) and all(isinstance(p, str) for p in evidence), "loop result must list its evidence files")
308
+ paths.extend(evidence)
345
309
  else:
346
- raise CompletionError("acceptance kind must be direct|pipeline")
310
+ raise CompletionError("acceptance kind must be direct|loop")
347
311
  files = snapshot_files(work, paths)
348
- if kind == "pipeline":
349
- pipeline_acceptance(work, acceptance, files, path.parent)
350
312
  custody(work, path.parent / "custody", files)
351
313
  recovery = ref_sha(receipt, receipt["recovery_ref"])
352
- require(recovery in {None, publish}, "recovery ref changed")
314
+ require(recovery in {None, sha}, "recovery ref changed")
353
315
  if recovery is None:
354
- gref(receipt, "update-ref", receipt["recovery_ref"], publish, "0"*len(publish))
355
- receipt.update(acceptance=acceptance, acceptance_digest=file_record(acceptance_path)["sha256"], source_sha=sha, publish_sha=publish, files=files)
316
+ gref(receipt, "update-ref", receipt["recovery_ref"], sha, "0"*len(sha))
317
+ receipt.update(acceptance=acceptance, acceptance_digest=file_record(acceptance_path)["sha256"], source_sha=sha, publish_sha=sha, files=files)
318
+ if acceptance.get("verdict") == "FAILED":
319
+ # Failed results keep custody and a recovery ref, but never become a frontier or a publishable product.
320
+ receipt["product"] = "FAILED"
356
321
  atomic_json(path, receipt)
357
322
 
358
323
 
324
+ def queue_commit(receipt, work, queue, source):
325
+ safe_path(work, queue["file"])
326
+ commit = queue["commit"]
327
+ require(gref(receipt, "rev-list", "--parents", "-n", "1", commit).split() == [commit, source], "queue commit must be directly atop verified source with one parent")
328
+ require(gref(receipt, "diff-tree", "--no-commit-id", "--name-only", "-r", "-z", commit).strip("\0") == queue["file"], "queue commit must change exactly the declared queue file")
329
+
330
+
331
+ def accept(args):
332
+ """Bind a result's source and evidence custody before terminal metadata or any delivery decision."""
333
+ path = Path(args.receipt).absolute()
334
+ with locked_receipt(path) as receipt:
335
+ refuse_retired_pipeline(receipt, path, args.acceptance, receipt.get("local_only"), [])
336
+ bind_acceptance(receipt, path, args.acceptance)
337
+ return {"status": "FAILED" if receipt.get("product") == "FAILED" else "ACCEPTED", "receipt": str(path),
338
+ "source_sha": receipt["source_sha"], "recovery_ref": receipt["recovery_ref"]}
339
+
340
+
341
+ def attach(args):
342
+ """Attach the queue-only terminal commit to an already bound result; the recovery ref moves with it."""
343
+ path = Path(args.receipt).absolute()
344
+ queue = {"commit": args.commit, "file": args.file}
345
+ with locked_receipt(path) as receipt:
346
+ require(receipt.get("acceptance"), "bind the result before attaching its terminal commit")
347
+ if receipt.get("queue") != queue:
348
+ require(not receipt.get("queue"), "a different terminal commit is already attached")
349
+ work = Path(receipt["worktree"])
350
+ queue_commit(receipt, work, queue, receipt["source_sha"])
351
+ # A failed result is never published, so its checkout stays as left; a publishable one must be checked out.
352
+ require(ref_sha(receipt, "refs/heads/"+receipt["branch"]) == args.commit
353
+ and (receipt.get("product") == "FAILED" or git(work, "rev-parse", "HEAD") == args.commit),
354
+ "task ref and HEAD must be the terminal commit")
355
+ recovery = ref_sha(receipt, receipt["recovery_ref"])
356
+ require(recovery in {receipt["source_sha"], args.commit}, "recovery ref changed")
357
+ if recovery != args.commit:
358
+ gref(receipt, "update-ref", receipt["recovery_ref"], args.commit, receipt["source_sha"])
359
+ receipt.update(queue=queue, publish_sha=args.commit)
360
+ atomic_json(path, receipt)
361
+ return {"status": "ATTACHED", "receipt": str(path), "source_sha": receipt["source_sha"], "terminal_sha": args.commit}
362
+
363
+
359
364
  PR_FIELDS = "number,url,headRefName,baseRefName,headRefOid,headRepository,headRepositoryOwner,isCrossRepository,state,mergedAt,mergeCommit,autoMergeRequest"
360
365
 
361
366
 
@@ -428,13 +433,14 @@ def stopped_writers(work):
428
433
  # other currently open files/cwds, not future writers or a universal lease.
429
434
  if sys.platform == "darwin":
430
435
  result = subprocess.run(["lsof", "-Fpn", "+D", str(work)], capture_output=True, text=True, encoding="utf-8")
431
- require(result.returncode in {0, 1} and not result.stderr.strip(), "writer observation unavailable; retain tree and inspect writers")
436
+ if result.returncode not in {0, 1} or result.stderr.strip():
437
+ raise WritersUnobservable("writer observation unavailable; retain tree and inspect writers")
432
438
  pid = None
433
439
  for line in result.stdout.splitlines():
434
440
  if line.startswith("p"):
435
441
  pid = int(line[1:])
436
442
  elif line.startswith("n") and pid != os.getpid():
437
- raise CompletionError(f"active process {pid} uses task files; stop/yield actual writers before resume")
443
+ raise WriterActive(f"active process {pid} uses task files; stop/yield actual writers before resume")
438
444
  elif sys.platform.startswith("linux"):
439
445
  for process in Path("/proc").iterdir():
440
446
  if not process.name.isdigit() or int(process.name) == os.getpid():
@@ -448,13 +454,14 @@ def stopped_writers(work):
448
454
  if link == process / "cwd":
449
455
  break
450
456
  continue
451
- require(not target.is_absolute() or not target.is_relative_to(work), f"active process {process.name} uses task files; stop/yield it before resume")
457
+ if target.is_absolute() and target.is_relative_to(work):
458
+ raise WriterActive(f"active process {process.name} uses task files; stop/yield it before resume")
452
459
  except FileNotFoundError:
453
460
  continue # Process exited during observation.
454
461
  except PermissionError as exc:
455
- raise CompletionError("unknown process access; retain tree until writer cessation can be established") from exc
462
+ raise WritersUnobservable("unknown process access; retain tree until writer cessation can be established") from exc
456
463
  else:
457
- raise CompletionError("writer observation unsupported on this platform; retain workspace")
464
+ raise WritersUnobservable("writer observation unsupported on this platform; retain workspace")
458
465
 
459
466
 
460
467
  def scratch_mounts():
@@ -579,6 +586,7 @@ def cleanup(receipt, path, pr):
579
586
  if ref_sha(receipt, branch_ref) is not None:
580
587
  require(all(row.get("branch") != branch_ref for row in registrations(receipt).values()), "task branch became checked out; retain ref")
581
588
  gref(receipt, "update-ref", "-d", branch_ref, sha)
589
+ receipt.pop("workspace_cleanup", None)
582
590
  receipt["status"] = "COMPLETE"
583
591
  atomic_json(path, receipt)
584
592
 
@@ -600,6 +608,9 @@ def locked_receipt(path, *, blocking=True):
600
608
 
601
609
 
602
610
  def completion_result(receipt, path, status):
611
+ if receipt.get("delivery") != status:
612
+ receipt["delivery"] = status # Phase-independent checkpoint: the latest delivery outcome.
613
+ atomic_json(path, receipt)
603
614
  released = receipt.get("writers_released", False)
604
615
  resume = shlex.join([sys.executable, str(Path(__file__).resolve()), "complete", "--receipt", str(path)] +
605
616
  (["--writers-stopped"] if released else []))
@@ -615,11 +626,12 @@ def completion_result(receipt, path, status):
615
626
  atomic_json(path, receipt)
616
627
  except OSError as record_error:
617
628
  scratch["record_error"] = str(record_error)
618
- return {"status": "CLEANUP_PENDING" if status == "COMPLETE" and scratch["status"] == "RETAINED" else status,
629
+ retained = scratch["status"] == "RETAINED" or "workspace_cleanup" in receipt
630
+ return {"status": "CLEANUP_PENDING" if status == "COMPLETE" and retained else status,
619
631
  "delivery_status": status, "receipt": str(path), "pr": receipt.get("pr_url"), "resume": resume,
620
632
  "acceptance": (receipt.get("acceptance") or {}).get("kind"), "product_verdict_unchanged": True,
621
633
  "scratch_path": str(path.parent / "scratch") if "scratch_identity" in receipt else None,
622
- "scratch_cleanup": scratch}
634
+ "scratch_cleanup": scratch, "workspace_cleanup": receipt.get("workspace_cleanup")}
623
635
 
624
636
 
625
637
  def reconcile(common, allocated, anchor):
@@ -659,6 +671,9 @@ def reconcile(common, allocated, anchor):
659
671
  def complete(args):
660
672
  path = Path(args.receipt).absolute()
661
673
  with locked_receipt(path) as receipt:
674
+ local = args.local_only or receipt.get("local_only")
675
+ refuse_retired_pipeline(receipt, path, args.acceptance, local, [*(["--mode", args.mode] if args.mode and not local else []),
676
+ *(["--writers-stopped"] if args.writers_stopped else [])])
662
677
  if args.writers_stopped:
663
678
  if receipt["linked"]:
664
679
  outside(Path(receipt["worktree"]))
@@ -666,9 +681,18 @@ def complete(args):
666
681
  atomic_json(path, receipt)
667
682
  def result(status):
668
683
  return completion_result(receipt, path, status)
684
+ if receipt.get("product") == "FAILED":
685
+ return result("FAILED")
669
686
  if args.local_only or receipt.get("local_only"):
687
+ require(not receipt.get("pushed"), f"{receipt['task']} already has a pushed PR {receipt.get('pr_url') or receipt['branch']}; "
688
+ "--local-only cannot rewrite its delivery, resume it without --local-only")
670
689
  receipt["local_only"] = True
671
690
  atomic_json(path, receipt)
691
+ if args.acceptance or receipt.get("acceptance"):
692
+ # Persist acceptance custody before the local-only return.
693
+ if not receipt.get("acceptance"):
694
+ inspect_workspace(dict(receipt, publish_sha=ref_sha(receipt, "refs/heads/"+receipt["branch"])))
695
+ bind_acceptance(receipt, path, args.acceptance)
672
696
  return result("LOCAL_ONLY")
673
697
  mode = policy(receipt, args.mode or receipt.get("mode_override"))
674
698
  if args.mode:
@@ -737,7 +761,13 @@ def complete(args):
737
761
  return dict(result("PR"), merge_refused=refused) if refused and not pr.get("autoMergeRequest") else result("PENDING")
738
762
  receipt["merge"] = pr
739
763
  atomic_json(path, receipt)
740
- cleanup(receipt, path, pr)
764
+ try:
765
+ cleanup(receipt, path, pr)
766
+ except (WritersUnobservable, WriterActive) as error:
767
+ # Conservative retention: the merge settles delivery; workspace, task refs and custody stay, reported.
768
+ receipt["workspace_cleanup"] = {"status": "RETAINED", "reason": str(error), "resume": shlex.join([
769
+ sys.executable, str(Path(__file__).resolve()), "complete", "--receipt", str(path)])}
770
+ atomic_json(path, receipt)
741
771
  return result("COMPLETE")
742
772
 
743
773
 
@@ -747,10 +777,22 @@ def main():
747
777
  actions = parser.add_subparsers(dest="action")
748
778
  allocation = actions.add_parser("allocate")
749
779
  allocation.add_argument("--repo", default=".")
750
- for name in ("task", "branch", "repository", "base"):
780
+ for name in ("task", "branch"):
751
781
  allocation.add_argument("--"+name, required=True)
782
+ allocation.add_argument("--base")
783
+ allocation.add_argument("--repository")
752
784
  allocation.add_argument("--remote", default="origin")
753
785
  allocation.add_argument("--worktree", required=True)
786
+ allocation.add_argument("--local-base")
787
+ allocation.add_argument("--from-receipt")
788
+ allocation.add_argument("--start")
789
+ binding = actions.add_parser("accept")
790
+ binding.add_argument("--receipt", required=True)
791
+ binding.add_argument("--acceptance", required=True)
792
+ attachment = actions.add_parser("attach")
793
+ attachment.add_argument("--receipt", required=True)
794
+ attachment.add_argument("--commit", required=True)
795
+ attachment.add_argument("--file", default="docs/specs/queue.md")
754
796
  completion = actions.add_parser("complete")
755
797
  completion.add_argument("--receipt", required=True)
756
798
  completion.add_argument("--acceptance")
@@ -764,16 +806,17 @@ def main():
764
806
  if args.self_test:
765
807
  return self_test()
766
808
  if args.action is None:
767
- parser.error("allocate, complete or clean-scratch is required")
809
+ parser.error("allocate, accept, attach, complete or clean-scratch is required")
768
810
  try:
769
- result = (allocate(args) if args.action == "allocate" else
770
- clean_scratch_command(args) if args.action == "clean-scratch" else complete(args))
811
+ result = {"allocate": allocate, "accept": accept, "attach": attach,
812
+ "clean-scratch": clean_scratch_command, "complete": complete}[args.action](args)
771
813
  print(json.dumps(result, sort_keys=True))
772
814
  return 0
773
815
  except (CompletionError, OSError, ValueError, KeyError, TypeError, IndexError, SystemExit) as exc:
774
816
  result = {"status": "BLOCKED", "reason": str(exc)}
775
- if args.action in {"complete", "clean-scratch"}:
817
+ if args.action != "allocate":
776
818
  result["receipt"] = str(Path(args.receipt).absolute())
819
+ if args.action in {"complete", "clean-scratch"}:
777
820
  result["resume"] = shlex.join([sys.executable, str(Path(__file__).resolve()), args.action, "--receipt", result["receipt"], *(["--writers-stopped"] if args.writers_stopped else [])])
778
821
  print(json.dumps(result, sort_keys=True))
779
822
  return 1
@@ -794,6 +837,7 @@ FAKE_GH = r'''#!/usr/bin/env python3
794
837
  import json, os, pathlib, subprocess, sys
795
838
  p = pathlib.Path(os.environ['FIXTURE_GH'])
796
839
  d = json.loads(p.read_text(encoding="utf-8")); a = sys.argv[1:]
840
+ prs = d.setdefault('prs', []) # PR number n is prs[n-1]; several may be open at once.
797
841
  def save(): p.write_text(json.dumps(d), encoding="utf-8")
798
842
  def remote(ref):
799
843
  r = subprocess.run([os.environ['REAL_GIT'], '--git-dir', d['bare'], 'rev-parse', '--verify', ref], capture_output=True, text=True, encoding="utf-8")
@@ -803,38 +847,44 @@ if a[:2] == ['repo', 'view']:
803
847
  print(json.dumps({'nameWithOwner':'test/project', 'url':'https://github.com/test/project', 'defaultBranchRef':{'name':d.get('default_branch','main')}, 'mergeCommitAllowed':d.get('merge_allowed',True)}))
804
848
  elif a[:2] == ['pr', 'list']:
805
849
  assert '--repo' in a and a[a.index('--repo')+1] == 'github.com/test/project'
806
- print(json.dumps([d['pr']] if d.get('pr') else []))
850
+ print(json.dumps([pr for pr in prs if pr['headRefName'] == a[a.index('--head')+1]]))
807
851
  elif a[:2] == ['pr', 'create']:
808
852
  d['creates'] = d.get('creates',0)+1
809
- assert not d.get('pr'), 'duplicate PR'
810
853
  head = a[a.index('--head')+1]; base = a[a.index('--base')+1]
811
- d['pr'] = {'number':1,'url':'https://github.com/test/project/pull/1','headRefName':head,'baseRefName':base,'headRefOid':remote('refs/heads/'+head),'headRepository':{'name':'project','owner':{'login':'test'}}, 'headRepositoryOwner':{'login':'test'},'isCrossRepository':False,'state':'OPEN','mergedAt':None,'mergeCommit':None,'autoMergeRequest':None}
854
+ assert not [pr for pr in prs if pr['headRefName'] == head], 'duplicate PR'
855
+ url = 'https://github.com/test/project/pull/%d' % (len(prs)+1)
856
+ prs.append({'number':len(prs)+1,'url':url,'headRefName':head,'baseRefName':base,'headRefOid':remote('refs/heads/'+head),'headRepository':{'name':'project','owner':{'login':'test'}}, 'headRepositoryOwner':{'login':'test'},'isCrossRepository':False,'state':'OPEN','mergedAt':None,'mergeCommit':None,'autoMergeRequest':None})
812
857
  save()
813
- print(d['pr']['url'])
858
+ print(url)
814
859
  if d.pop('interrupt_create',False): save(); sys.exit(1)
815
860
  elif a[:2] == ['pr', 'view']:
816
- print(json.dumps(d['pr']))
861
+ print(json.dumps(prs[int(a[2])-1]))
817
862
  elif a[:2] == ['pr', 'merge'] and '--disable-auto' in a:
818
863
  d['disables'] = d.get('disables',0)+1
819
- d['pr']['autoMergeRequest'] = None
864
+ prs[int(a[2])-1]['autoMergeRequest'] = None
820
865
  save()
821
866
  elif a[:2] == ['pr', 'merge']:
822
867
  assert '--admin' not in a and '--delete-branch' not in a
823
868
  assert '--auto' in a and '--merge' in a and '--match-head-commit' in a
869
+ pr = prs[int(a[2])-1]
824
870
  sha = a[a.index('--match-head-commit')+1]
825
- assert sha == remote('refs/heads/'+d['pr']['headRefName'])
871
+ assert sha == remote('refs/heads/'+pr['headRefName'])
826
872
  d['merges'] = d.get('merges',0)+1
827
873
  if not d.get('auto_allowed',True) and d.get('pending'):
828
874
  save(); print('repository disallows auto merge',file=sys.stderr); sys.exit(1)
829
875
  if d.get('pending'):
830
- d['pr']['autoMergeRequest'] = {'enabledAt':'now'}
876
+ pr['autoMergeRequest'] = {'enabledAt':'now'}
831
877
  else:
832
878
  g = [os.environ['REAL_GIT'],'--git-dir',d['bare']]
833
879
  base = remote('refs/heads/main')
834
- tree = subprocess.check_output(g+['rev-parse',sha+'^{tree}'],text=True, encoding="utf-8").strip()
835
- merge = subprocess.check_output(g+['commit-tree',tree,'-p',base,'-p',sha,'-m','merge fixture'],text=True, encoding="utf-8").strip()
880
+ # A three-way merge into the current base, as GitHub makes it; a conflicting PR does not merge.
881
+ merged = subprocess.run(g+['merge-tree','--write-tree',base,sha],capture_output=True,text=True,encoding="utf-8")
882
+ if merged.returncode:
883
+ save(); print('pull request is not mergeable: '+merged.stdout,file=sys.stderr); sys.exit(1)
884
+ parents = ['-p',base] if d.get('squash') else ['-p',base,'-p',sha]
885
+ merge = subprocess.check_output(g+['commit-tree',merged.stdout.split()[0],*parents,'-m','merge fixture'],text=True, encoding="utf-8").strip()
836
886
  subprocess.check_call(g+['update-ref','refs/heads/main',merge,base])
837
- d['pr'].update(state='MERGED',mergedAt='now',mergeCommit={'oid':merge})
887
+ pr.update(state='MERGED',mergedAt='now',mergeCommit={'oid':merge})
838
888
  save()
839
889
  if d.pop('interrupt_merge',False): save(); sys.exit(1)
840
890
  else:
@@ -854,7 +904,7 @@ import json, os, pathlib, subprocess, sys
854
904
  a = sys.argv[1:]
855
905
  p = pathlib.Path(os.environ['FIXTURE_GH']); d = json.loads(p.read_text(encoding="utf-8"))
856
906
  if 'push' in a and any(x.startswith('--force-with-lease=') for x in a) and d.pop('remote_delete_race',False):
857
- subprocess.check_call([os.environ['REAL_GIT'],'--git-dir',d['bare'],'update-ref','refs/heads/'+d['pr']['headRefName'],d['race_sha']])
907
+ subprocess.check_call([os.environ['REAL_GIT'],'--git-dir',d['bare'],'update-ref','refs/heads/'+d['prs'][0]['headRefName'],d['race_sha']])
858
908
  p.write_text(json.dumps(d), encoding="utf-8")
859
909
  for operation in ('push', 'fetch', 'ls-remote'):
860
910
  if operation in a and 'origin' in a:
@@ -1069,7 +1119,7 @@ class CompletionTests(unittest.TestCase):
1069
1119
  return str(self.work / "product")
1070
1120
  return readlink(path, *args, **kwargs)
1071
1121
  with patch.object(sys, "platform", "linux"), patch.object(Path, "iterdir", entries), patch.object(os, "readlink", target):
1072
- with self.assertRaisesRegex(CompletionError, "active process " + process.name):
1122
+ with self.assertRaisesRegex(WriterActive, "active process " + process.name):
1073
1123
  stopped_writers(self.work)
1074
1124
 
1075
1125
  @unittest.skipUnless(sys.platform == "darwin" or sys.platform.startswith("linux"), "writer observation requires POSIX")
@@ -1169,11 +1219,7 @@ class CompletionTests(unittest.TestCase):
1169
1219
  with self.assertRaisesRegex(CompletionError, "cannot parse mount table"):
1170
1220
  scratch_mounts()
1171
1221
 
1172
- def accept(self, pipeline=False, queue=False, spec_expected=None, spec_name="spec.md"):
1173
- if spec_expected is not None:
1174
- (self.task / spec_name).write_text("# Fixture\nProduct contains accepted bytes.\n", encoding="utf-8")
1175
- (self.task / "spec.expected.json").write_text(json.dumps(spec_expected), encoding="utf-8")
1176
- self.g("add", spec_name, "spec.expected.json", work=self.task)
1222
+ def accept(self):
1177
1223
  (self.task / "product").write_text("accepted\n", encoding="utf-8")
1178
1224
  self.g("add", "product", work=self.task)
1179
1225
  self.g("commit", "-m", "scoped task", work=self.task)
@@ -1184,32 +1230,6 @@ class CompletionTests(unittest.TestCase):
1184
1230
  a = {"kind": "direct", "task": "fixture", "source_sha": self.sha,
1185
1231
  "checks": [{"command": "fixture byte assertion", "evidence": ".devlyn/checks.txt"}]}
1186
1232
  self.assertEqual((self.task / "product").read_text(encoding="utf-8"), "accepted\n")
1187
- if pipeline:
1188
- a = {"kind": "pipeline", "task": "fixture", "source_sha": self.sha, "run_id": "fixture-run"}
1189
- archive = evidence / "runs" / a["run_id"]
1190
- archive.mkdir(parents=True)
1191
- self.archive = archive
1192
- report = "<!-- devlyn:final-report run_id=fixture-run -->\nFixture completed.\n"
1193
- (archive / "final-report.md").write_text(report, encoding="utf-8")
1194
- phases = {n: {"started_at": "2026-09-10T00:00:00Z", "completed_at": "2026-09-10T00:00:01Z", "verdict": "PASS"}
1195
- for n in ("plan", "implement", "build_gate", "cleanup", "verify", "final_report")}
1196
- phases["cleanup"]["post_sha"] = self.sha
1197
- phases["final_report"].update(output_sha256=hashlib.sha256(report.encode()).hexdigest(), artifacts={"log_file": ".devlyn/final-report.md"})
1198
- criteria = "# Fixture acceptance\nProduct contains accepted bytes.\n"
1199
- (archive / "criteria.generated.md").write_text(criteria, encoding="utf-8")
1200
- source = {"type": "generated", "criteria_path": ".devlyn/criteria.generated.md", "criteria_sha256": hashlib.sha256(criteria.encode()).hexdigest()}
1201
- self.state = {"run_id": a["run_id"], "mode": "free-form", "source": source, "phases": phases, "process_evidence": None}
1202
- if spec_expected is not None:
1203
- self.state["mode"] = "spec"
1204
- self.state["source"] = {"type": "spec", "spec_path": spec_name, "spec_sha256": hashlib.sha256((self.task / spec_name).read_bytes()).hexdigest()}
1205
- (archive / "pipeline.state.json").write_text(json.dumps(self.state), encoding="utf-8")
1206
- (archive / "finish-gate.summary.json").write_text(json.dumps({"mode": self.state["mode"], "exit": 0, "offenders": 0, "checked": 1}), encoding="utf-8")
1207
- (archive / "verify-merge.summary.json").write_text(json.dumps({"verdict": "PASS"}), encoding="utf-8")
1208
- if queue:
1209
- (self.task / "queue.md").write_text("- [x] fixture\n", encoding="utf-8")
1210
- self.g("add", "queue.md", work=self.task)
1211
- self.g("commit", "-m", "terminal queue", work=self.task)
1212
- a["queue"] = {"commit": self.g("rev-parse", "HEAD", work=self.task), "file": "queue.md"}
1213
1233
  self.acceptance = evidence / "acceptance.json"
1214
1234
  self.acceptance.write_text(json.dumps(a), encoding="utf-8")
1215
1235
 
@@ -1250,7 +1270,7 @@ class CompletionTests(unittest.TestCase):
1250
1270
  self.assertEqual(result["status"], "PR")
1251
1271
  self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture").split()[0], self.sha)
1252
1272
  d = json.loads(self.data.read_text(encoding="utf-8"))
1253
- self.assertEqual((d["pushs"], d.get("merges", 0), d["pr"]["autoMergeRequest"]), (1, 0, None))
1273
+ self.assertEqual((d["pushs"], d.get("merges", 0), d["prs"][0]["autoMergeRequest"]), (1, 0, None))
1254
1274
 
1255
1275
  def test_pr_mode_disables_only_owned_auto_merge(self):
1256
1276
  self.allocate(); self.accept()
@@ -1260,12 +1280,12 @@ class CompletionTests(unittest.TestCase):
1260
1280
  result, _ = self.complete("--mode", "pr")
1261
1281
  self.assertEqual(result["status"], "PR")
1262
1282
  d = json.loads(self.data.read_text(encoding="utf-8"))
1263
- self.assertEqual((d["disables"], d["pr"]["autoMergeRequest"]), (1, None))
1264
- self.configure(pr=dict(d["pr"], isCrossRepository=True, autoMergeRequest={"enabledAt": "now"}))
1283
+ self.assertEqual((d["disables"], d["prs"][0]["autoMergeRequest"]), (1, None))
1284
+ self.configure(prs=[dict(d["prs"][0], isCrossRepository=True, autoMergeRequest={"enabledAt": "now"})])
1265
1285
  _, r = self.complete("--mode", "pr", success=False)
1266
1286
  self.assertIn("PR head repository differs", json.loads(r.stdout)["reason"])
1267
1287
  d = json.loads(self.data.read_text(encoding="utf-8"))
1268
- self.assertEqual((d["disables"], d["pr"]["autoMergeRequest"]), (1, {"enabledAt": "now"}))
1288
+ self.assertEqual((d["disables"], d["prs"][0]["autoMergeRequest"]), (1, {"enabledAt": "now"}))
1269
1289
 
1270
1290
  def test_remote_rewrite_after_allocation_is_rejected(self):
1271
1291
  self.allocate(); self.accept()
@@ -1387,6 +1407,22 @@ class CompletionTests(unittest.TestCase):
1387
1407
  self.assertFalse(self.task.exists())
1388
1408
  self.assertEqual(self.g("branch", "--list", "task/fixture"), "")
1389
1409
 
1410
+ def test_unobservable_writers_settle_a_merged_delivery_and_retain_the_workspace(self):
1411
+ from unittest.mock import patch
1412
+ self.allocate(); self.accept()
1413
+ self.complete("--mode", "pr", "--writers-stopped")
1414
+ self.merge_pr()
1415
+ args = argparse.Namespace(receipt=str(self.receipt), acceptance=None, mode=None, local_only=False, writers_stopped=False)
1416
+ with patch.object(sys, "platform", "win32"), patch.dict(os.environ, self.env):
1417
+ result = complete(args)
1418
+ self.assertEqual((result["status"], result["delivery_status"]), ("CLEANUP_PENDING", "COMPLETE"))
1419
+ self.assertIn("writer observation unsupported", result["workspace_cleanup"]["reason"])
1420
+ receipt = json.loads(self.receipt.read_text(encoding="utf-8"))
1421
+ self.assertEqual((receipt["delivery"], receipt.get("status")), ("COMPLETE", None))
1422
+ self.assertTrue(self.task.exists())
1423
+ self.assertNotEqual(self.g("branch", "--list", "task/fixture"), "")
1424
+ self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture").split()[0], self.sha)
1425
+
1390
1426
  def test_release_is_refused_from_inside_the_task_tree(self):
1391
1427
  self.allocate(); self.accept()
1392
1428
  _, r = self.complete("--mode", "pr", "--writers-stopped", cwd=self.task, success=False)
@@ -1403,8 +1439,8 @@ class CompletionTests(unittest.TestCase):
1403
1439
  tree = self.run_cmd(bare + ["rev-parse", self.sha + "^{tree}"]).stdout.strip()
1404
1440
  squash = self.run_cmd(bare + ["commit-tree", tree, "-p", base, "-m", "squash fixture"]).stdout.strip()
1405
1441
  self.run_cmd(bare + ["update-ref", "refs/heads/main", squash, base])
1406
- pr = json.loads(self.data.read_text(encoding="utf-8"))["pr"]
1407
- self.configure(pr=dict(pr, state="MERGED", mergedAt="now", mergeCommit={"oid": squash}))
1442
+ pr = json.loads(self.data.read_text(encoding="utf-8"))["prs"][0]
1443
+ self.configure(prs=[dict(pr, state="MERGED", mergedAt="now", mergeCommit={"oid": squash})])
1408
1444
  result, _ = self.complete()
1409
1445
  self.assertEqual(result["status"], "COMPLETE")
1410
1446
  self.assertFalse(self.task.exists())
@@ -1451,7 +1487,7 @@ class CompletionTests(unittest.TestCase):
1451
1487
  self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture").split()[0], self.sha)
1452
1488
  # Reopen only the fixture PR to exercise the owner's pre-merge release.
1453
1489
  d = json.loads(self.data.read_text())
1454
- self.configure(pr=dict(d["pr"], state="OPEN", mergedAt=None, mergeCommit=None))
1490
+ self.configure(prs=[dict(d["prs"][0], state="OPEN", mergedAt=None, mergeCommit=None)])
1455
1491
  self.receipt, self.task = receipt, task
1456
1492
  result, _ = self.complete("--mode", "pr", "--writers-stopped")
1457
1493
  self.assertEqual(result["status"], "PR")
@@ -1493,13 +1529,13 @@ class CompletionTests(unittest.TestCase):
1493
1529
  saved.update(pr_number=1, writers_released=True)
1494
1530
  unaccepted.write_text(json.dumps(saved))
1495
1531
  unaccepted_before = unaccepted.read_bytes()
1496
- pr = json.loads(self.data.read_text())["pr"]
1497
- self.configure(pr=dict(pr, state="CLOSED"))
1532
+ pr = json.loads(self.data.read_text())["prs"][0]
1533
+ self.configure(prs=[dict(pr, state="CLOSED")])
1498
1534
  result = self.allocate("closed")
1499
1535
  self.assertEqual(result["reconciled"], [])
1500
1536
  self.assertEqual(receipt.read_bytes(), before)
1501
1537
  self.assertTrue(task.exists())
1502
- self.configure(pr=pr)
1538
+ self.configure(prs=[pr])
1503
1539
  self.merge_pr()
1504
1540
  self.g("worktree", "lock", str(task))
1505
1541
  result = self.allocate("locked")
@@ -1672,36 +1708,21 @@ class CompletionTests(unittest.TestCase):
1672
1708
  self.assertFalse(self.task.exists())
1673
1709
 
1674
1710
  def test_linked_custody_and_interrupted_removal(self):
1675
- self.allocate(); self.accept(pipeline=True)
1711
+ self.allocate(); self.accept()
1676
1712
  self.configure(interrupt_remove=True)
1677
1713
  result, r = self.complete("--writers-stopped", "--mode", "auto", success=False)
1678
1714
  self.assertNotEqual(r.returncode, 0)
1679
1715
  self.assertFalse(self.task.exists())
1680
1716
  saved = json.loads(self.receipt.read_text(encoding="utf-8"))
1681
1717
  self.assertNotIn("cleanup_started", saved)
1682
- recovered = self.receipt.parent / "custody" / ".devlyn/runs/fixture-run/final-report.md"
1683
- self.assertEqual(hashlib.sha256(recovered.read_bytes()).hexdigest(), saved["files"][".devlyn/runs/fixture-run/final-report.md"]["sha256"])
1718
+ recovered = self.receipt.parent / "custody" / ".devlyn/checks.txt"
1719
+ self.assertEqual(hashlib.sha256(recovered.read_bytes()).hexdigest(), saved["files"][".devlyn/checks.txt"]["sha256"])
1684
1720
  result, _ = self.complete("--writers-stopped", acceptance=False)
1685
1721
  self.assertEqual(result["status"], "COMPLETE")
1686
1722
  self.assertEqual(self.g("show", saved["recovery_ref"]+":product"), "accepted")
1687
1723
 
1688
- def test_reject_acceptance_and_policy_before_push(self):
1689
- self.allocate(); self.accept(pipeline=True)
1690
- original = json.loads(json.dumps(self.state))
1691
- for case in ("failed", "verify-only", "blocked", "unfinished", "digest", "source"):
1692
- state = json.loads(json.dumps(original))
1693
- if case == "failed": state["phases"]["verify"]["verdict"] = "NEEDS_WORK"
1694
- if case == "verify-only": state["mode"] = "verify-only"
1695
- if case == "blocked": state["phases"]["implement"]["verdict"] = "BLOCKED"
1696
- if case == "unfinished": state["phases"]["verify"]["completed_at"] = None
1697
- if case == "digest": state["phases"]["final_report"]["output_sha256"] = "0"*64
1698
- if case == "source": state["phases"]["cleanup"]["post_sha"] = self.g("rev-parse", "main")
1699
- (self.archive / "pipeline.state.json").write_text(json.dumps(state), encoding="utf-8")
1700
- with self.subTest(case=case):
1701
- _, r = self.complete(success=False)
1702
- self.assertNotEqual(r.returncode, 0)
1703
- self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture"), "")
1704
- (self.archive / "pipeline.state.json").write_text(json.dumps(original), encoding="utf-8")
1724
+ def test_reject_invalid_policy_before_push(self):
1725
+ self.allocate(); self.accept()
1705
1726
  for value in ("typo", "", " auto"):
1706
1727
  self.g("config", "--local", "devlyn.completionMode", value)
1707
1728
  _, r = self.complete("--mode", "pr", success=False)
@@ -1710,8 +1731,8 @@ class CompletionTests(unittest.TestCase):
1710
1731
  self.assertEqual(result["status"], "LOCAL_ONLY")
1711
1732
  self.assertEqual(json.loads(self.data.read_text(encoding="utf-8")).get("creates", 0), 0)
1712
1733
 
1713
- def test_queue_only_and_unverified_descendant(self):
1714
- self.allocate(); self.accept(pipeline=True, queue=True)
1734
+ def test_unverified_descendant_is_never_merged(self):
1735
+ self.allocate(); self.accept()
1715
1736
  result, _ = self.complete("--mode", "pr")
1716
1737
  self.assertEqual(result["status"], "PR")
1717
1738
  (self.task / "product").write_text("unverified", encoding="utf-8")
@@ -1788,13 +1809,16 @@ class CompletionTests(unittest.TestCase):
1788
1809
  self.assertEqual(json.loads(self.data.read_text(encoding="utf-8")).get("merges",0), 0)
1789
1810
 
1790
1811
  def test_actual_foreign_writer_and_registration(self):
1812
+ # H4. Prediction: a process still using the merged task's tree settles the delivery COMPLETE with the workspace
1813
+ # retained (CLEANUP_PENDING) and the process named, as unobservable writers do. Before: complete ended BLOCKED
1814
+ # "active process <pid> uses task files", so the merged delivery never settled and a drain stopped there.
1791
1815
  self.allocate(); self.accept()
1792
1816
  child = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"], cwd=self.task)
1793
1817
  try:
1794
- _, r = self.complete("--writers-stopped", "--mode", "auto", success=False)
1795
- self.assertNotEqual(r.returncode, 0)
1818
+ result, _ = self.complete("--writers-stopped", "--mode", "auto")
1819
+ self.assertEqual((result["status"], result["delivery_status"]), ("CLEANUP_PENDING", "COMPLETE"))
1820
+ self.assertIn("active process", result["workspace_cleanup"]["reason"])
1796
1821
  self.assertTrue(self.task.exists())
1797
- self.assertIn("active process", json.loads(r.stdout)["reason"])
1798
1822
  finally:
1799
1823
  child.terminate(); child.wait(timeout=5)
1800
1824
  # A foreign branch in the same registered tree never inherits ownership.
@@ -1829,45 +1853,52 @@ class CompletionTests(unittest.TestCase):
1829
1853
  self.assertEqual(result["status"], "PR")
1830
1854
  self.assertIn("disallows auto merge", result["merge_refused"])
1831
1855
  self.assertTrue(self.task.exists())
1832
- result, _ = self.complete("--no-push")
1833
- self.assertEqual(result["status"], "LOCAL_ONLY")
1834
- result, _ = self.complete(acceptance=False)
1835
- self.assertEqual(result["status"], "LOCAL_ONLY")
1836
-
1837
- def test_queue_commit_cannot_hide_product_changes(self):
1838
- self.allocate(); self.accept(pipeline=True, queue=True)
1839
- (self.task / "product").write_text("unverified extra\n", encoding="utf-8")
1840
- self.g("add", "product", work=self.task)
1841
- self.g("commit", "--amend", "--no-edit", work=self.task)
1842
- a = json.loads(self.acceptance.read_text(encoding="utf-8"))
1843
- a["queue"]["commit"] = self.g("rev-parse", "HEAD", work=self.task)
1844
- self.acceptance.write_text(json.dumps(a), encoding="utf-8")
1845
- _, r = self.complete(success=False)
1846
- self.assertNotEqual(r.returncode, 0)
1847
- self.assertEqual(json.loads(self.data.read_text(encoding="utf-8")).get("pushs",0), 0)
1848
-
1849
- def test_pipeline_sealed_process_evidence(self):
1850
- self.allocate(); self.accept(pipeline=True)
1851
- evidence_module = shared("process-evidence")
1852
- obligation = {"id": "actual-check", "phase": "verify", "argv": [sys.executable, "-c", "print('fixture passed')"], "exit_code": 0, "stdout_contains": ["fixture passed"], "timeout_sec": 10}
1853
- relative = ".devlyn/process-evidence/fixture-run/verify/round-0/manifest.json"
1854
- evidence_module["capture_process"](self.task, self.task / relative, "fixture-run", "verify", 0, obligation)
1855
- carrier = evidence_module["validate_manifest"](self.task, relative, "fixture-run", "verify", 0, [obligation])
1856
- results = {"commands": evidence_module["bound_carrier_summary_commands"](self.task, carrier), "process_evidence": carrier}
1857
- self.state["phases"]["verify"]["round"] = 0
1858
- self.state["process_evidence"] = [carrier]
1859
- (self.archive / "pipeline.state.json").write_text(json.dumps(self.state), encoding="utf-8")
1860
- (self.archive / "spec-verify.results.json").write_text(json.dumps(results), encoding="utf-8")
1861
- shutil.move(str(self.task / ".devlyn/process-evidence"), str(self.archive / "process-evidence"))
1862
- stdout = self.archive / carrier["streams"][0]["stdout"]["path"].removeprefix(".devlyn/")
1863
- before = stdout.read_bytes()
1864
- stdout.write_text("tampered", encoding="utf-8")
1865
- _, r = self.complete(success=False)
1866
- self.assertNotEqual(r.returncode, 0)
1867
- self.assertEqual(json.loads(self.data.read_text(encoding="utf-8")).get("pushs",0), 0)
1868
- stdout.write_bytes(before)
1869
- result, _ = self.complete("--mode", "pr")
1870
- self.assertEqual(result["status"], "PR")
1856
+ # Local-only cannot rewrite the delivery of a pushed PR; a local allocation keeps it (test_local_chain_*).
1857
+ result, _ = self.complete("--no-push", success=False)
1858
+ self.assertIn("fixture already has a pushed PR https://github.com/test/project/pull/1", result["reason"])
1859
+ self.assertNotIn("local_only", json.loads(self.receipt.read_text(encoding="utf-8")))
1860
+
1861
+ def test_pipeline_acceptance_is_refused_with_recovery_instruction(self):
1862
+ # Binding an archived resolve run needed the retired helpers. Whatever completion flags come with it,
1863
+ # it is refused before the receipt, a ref or the worktree changes, with the 4.1.0 command that finishes
1864
+ # it in the requested delivery mode; it never becomes direct acceptance.
1865
+ self.allocate(); self.accept()
1866
+ self.acceptance.write_text(json.dumps({"kind": "pipeline", "task": "fixture", "source_sha": self.sha,
1867
+ "run_id": "fixture-run"}), encoding="utf-8")
1868
+ def state():
1869
+ return (self.receipt.read_bytes(), self.g("show-ref"), self.g("rev-parse", "HEAD", work=self.task),
1870
+ self.g("status", "--porcelain", "--untracked-files=all", work=self.task),
1871
+ sorted(p.name for p in self.receipt.parent.iterdir() if p.name != "lock"))
1872
+ before = state()
1873
+ publish, local = "binds the pipeline acceptance before publishing", "ends as LOCAL_ONLY without binding"
1874
+ for flags, outcome in (((), publish), (("--mode", "pr"), publish), (("--mode", "auto", "--writers-stopped"), publish),
1875
+ (("--local-only",), local), (("--local-only", "--writers-stopped"), local)):
1876
+ with self.subTest(flags=flags):
1877
+ result, r = self.complete(*flags, success=False)
1878
+ self.assertEqual((r.returncode, result["status"]), (1, "BLOCKED"))
1879
+ self.assertEqual(state(), before)
1880
+ self.assertIn("`npm pack devlyn-cli@4.1.0`", result["reason"])
1881
+ self.assertIn(shlex.join(["complete", "--receipt", str(self.receipt), "--acceptance", str(self.acceptance), *flags]) + "`",
1882
+ result["reason"])
1883
+ self.assertIn(outcome, result["reason"])
1884
+ result, r = self.cli("accept", "--receipt", self.receipt, "--acceptance", self.acceptance, success=False)
1885
+ self.assertEqual((r.returncode, result["status"]), (1, "BLOCKED"))
1886
+ self.assertEqual(state(), before)
1887
+ self.assertIn(publish, result["reason"])
1888
+ self.assertNotIn(json.loads(before[0])["recovery_ref"], before[1])
1889
+ self.assertEqual(json.loads(self.data.read_text(encoding="utf-8")).get("pushs", 0), 0)
1890
+
1891
+ def test_bound_pipeline_receipt_resumes_delivery(self):
1892
+ # A receipt an earlier release bound to a resolve run keeps its custody; delivery resumes here.
1893
+ self.allocate(); self.accept()
1894
+ self.cli("accept", "--receipt", self.receipt, "--acceptance", self.acceptance)
1895
+ receipt = json.loads(self.receipt.read_text(encoding="utf-8"))
1896
+ receipt["acceptance"] = {"kind": "pipeline", "task": "fixture", "source_sha": self.sha, "run_id": "fixture-run"}
1897
+ self.receipt.write_text(json.dumps(receipt), encoding="utf-8")
1898
+ result, _ = self.complete("--writers-stopped", "--mode", "auto", acceptance=False)
1899
+ self.assertEqual((result["status"], result["acceptance"]), ("COMPLETE", "pipeline"))
1900
+ self.assertFalse(self.task.exists())
1901
+ self.assertEqual(self.g("show", receipt["recovery_ref"] + ":product"), "accepted")
1871
1902
 
1872
1903
  def test_legacy_in_place_resume_does_not_overwrite_ignored_collision(self):
1873
1904
  self.allocate_legacy(); self.accept()
@@ -1914,45 +1945,219 @@ class CompletionTests(unittest.TestCase):
1914
1945
  self.assertTrue(self.task.exists())
1915
1946
  self.assertEqual(json.loads(self.data.read_text(encoding="utf-8")).get("pushs",0), 0)
1916
1947
 
1917
- def test_spec_source_and_report_binding(self):
1918
- self.allocate(); self.accept(pipeline=True, spec_expected={"verification_commands": []})
1919
- report = self.archive / "final-report.md"
1920
- original = report.read_bytes()
1921
- report.write_text("<!-- devlyn:final-report run_id=another-run -->\nBody\n", encoding="utf-8")
1922
- self.state["phases"]["final_report"]["output_sha256"] = hashlib.sha256(report.read_bytes()).hexdigest()
1923
- (self.archive / "pipeline.state.json").write_text(json.dumps(self.state), encoding="utf-8")
1924
- _, r = self.complete(success=False)
1925
- self.assertNotEqual(r.returncode, 0)
1926
- self.assertEqual(json.loads(self.data.read_text(encoding="utf-8")).get("pushs",0), 0)
1927
- report.write_bytes(original)
1928
- self.state["phases"]["final_report"]["output_sha256"] = hashlib.sha256(original).hexdigest()
1929
- (self.archive / "pipeline.state.json").write_text(json.dumps(self.state), encoding="utf-8")
1930
- result, _ = self.complete("--mode", "pr")
1931
- self.assertEqual(result["status"], "PR")
1948
+ def allocate_local(self, name, **source):
1949
+ args = ["allocate", "--repo", self.work, "--task", name, "--branch", "task/"+name, "--repository", "test/project",
1950
+ "--base", "main", "--worktree", self.root / name]
1951
+ for key, value in source.items():
1952
+ args += ["--" + key.replace("_", "-"), value]
1953
+ result, _ = self.cli(*args)
1954
+ self.receipt, self.task = Path(result["receipt"]), Path(result["worktree"])
1955
+ return result
1932
1956
 
1933
- def test_missing_required_process_evidence(self):
1934
- expected = {"process_evidence": [{"id": "red-first", "phase": "implement", "argv": [sys.executable, "-c", "print('red')"], "exit_code": 0, "timeout_sec": 10}]}
1935
- self.allocate(); self.accept(pipeline=True, spec_expected=expected)
1936
- _, r = self.complete(success=False)
1937
- self.assertNotEqual(r.returncode, 0)
1938
- self.assertIn("implement process evidence", json.loads(r.stdout)["reason"])
1939
- self.assertEqual(json.loads(self.data.read_text(encoding="utf-8")).get("pushs",0), 0)
1957
+ def loop_result(self, verdict="ACCEPTED"):
1958
+ (self.task / "product").write_text(verdict.lower() + "\n", encoding="utf-8")
1959
+ self.g("add", "product", work=self.task)
1960
+ self.g("commit", "-m", "loop source", work=self.task)
1961
+ self.sha = self.g("rev-parse", "HEAD", work=self.task)
1962
+ stream = self.task / ".devlyn/loop/runs/1/cmd-0.stdout"
1963
+ stream.parent.mkdir(parents=True, exist_ok=True)
1964
+ stream.write_text("check output\n", encoding="utf-8")
1965
+ self.acceptance = self.task / ".devlyn/loop/acceptance.json"
1966
+ self.acceptance.write_text(json.dumps({"kind": "loop", "task": json.loads(self.receipt.read_text())["task"], "source_sha": self.sha,
1967
+ "verdict": verdict, "evidence": [".devlyn/loop/runs/1/cmd-0.stdout"]}), encoding="utf-8")
1968
+ return self.cli("accept", "--receipt", self.receipt, "--acceptance", self.acceptance)[0]
1969
+
1970
+ def terminal(self):
1971
+ (self.task / "queue.md").write_text("- [x] fixture\n", encoding="utf-8")
1972
+ self.g("add", "queue.md", work=self.task)
1973
+ self.g("commit", "-m", "terminal", work=self.task)
1974
+ commit = self.g("rev-parse", "HEAD", work=self.task)
1975
+ return commit, self.cli("attach", "--receipt", self.receipt, "--commit", commit, "--file", "queue.md")[0]
1976
+
1977
+ def test_local_chain_starts_from_accepted_source_not_terminal_commit(self):
1978
+ base = self.g("rev-parse", "main")
1979
+ other = self.root / "advance" # The remote advances; a local allocation never fetches it.
1980
+ self.run_cmd(["git", "clone", str(self.bare), str(other)])
1981
+ (other / "product").write_text("remote advance\n", encoding="utf-8")
1982
+ self.g("commit", "-am", "remote advance", work=other)
1983
+ self.g("push", "origin", "main", work=other)
1984
+ self.configure(pushs=0)
1985
+ result = self.allocate_local("first", local_base=base)
1986
+ receipt = json.loads(self.receipt.read_text())
1987
+ self.assertEqual((result["reconciled"], receipt["baseline"], receipt["local_only"]), ([], base, True))
1988
+ self.assertNotEqual(self.run_cmd(["git", "-C", str(self.work), "rev-parse", "--verify", "--quiet", "refs/remotes/origin/main"], success=False).returncode, 0)
1989
+ self.assertEqual(self.loop_result()["status"], "ACCEPTED")
1990
+ source = self.sha
1991
+ terminal, _ = self.terminal()
1992
+ receipt = json.loads(self.receipt.read_text())
1993
+ self.assertEqual((receipt["source_sha"], receipt["publish_sha"], self.g("rev-parse", receipt["recovery_ref"])), (source, terminal, terminal))
1994
+ first = self.receipt
1995
+ self.allocate_local("second", from_receipt=first)
1996
+ second = json.loads(self.receipt.read_text())
1997
+ self.assertEqual((second["baseline"], second["allocated_from"]["source_sha"], self.g("rev-parse", "HEAD", work=self.task)), (source, source, source))
1998
+ self.receipt = first
1999
+ result, _ = self.complete("--mode", "auto", acceptance=False)
2000
+ self.assertEqual(result["status"], "LOCAL_ONLY")
2001
+ self.assertEqual(json.loads(self.data.read_text()).get("pushs", 0), 0)
2002
+
2003
+ def test_local_allocation_refuses_unaccepted_failed_or_inexact_sources(self):
2004
+ self.allocate_local("pending", local_base=self.g("rev-parse", "main"))
2005
+ predecessor = self.receipt
2006
+ args = ["allocate", "--repo", self.work, "--task", "next", "--branch", "task/next", "--repository", "test/project",
2007
+ "--base", "main", "--worktree", self.root / "next"]
2008
+ for state in ("pending", "failed"):
2009
+ if state == "failed":
2010
+ self.assertEqual(self.loop_result("FAILED")["status"], "FAILED")
2011
+ result, _ = self.cli(*args, "--from-receipt", predecessor, success=False)
2012
+ self.assertIn("predecessor has no accepted result", result["reason"])
2013
+ result, _ = self.cli(*args, "--local-base", "main", success=False)
2014
+ self.assertIn("exact local commit", result["reason"])
2015
+ self.assertEqual(self.g("branch", "--list", "task/next"), "")
2016
+ self.assertFalse((self.root / "next").exists())
2017
+
2018
+ def test_local_allocation_needs_no_repository(self):
2019
+ """E4. Prediction: without --repository, a remote allocation is refused naming it, with nothing created, while a
2020
+ local allocation, here beside an origin that is not GitHub, is ALLOCATED and its local-only completion returns
2021
+ LOCAL_ONLY with nothing pushed. Before: argparse required --repository, so both failed as an argparse error."""
2022
+ args = ["allocate", "--repo", self.work, "--base", "main"]
2023
+ refused, _ = self.cli(*args, "--task", "next", "--branch", "task/next", "--worktree", self.root / "next", success=False)
2024
+ self.assertIn("--repository", refused["reason"])
2025
+ self.assertEqual((self.g("branch", "--list", "task/next"), (self.root / "next").exists()), ("", False))
2026
+ self.g("remote", "set-url", "origin", "https://gitlab.com/team/project.git")
2027
+ result, _ = self.cli(*args, "--task", "fixture", "--branch", "task/fixture", "--worktree", self.root / "linked",
2028
+ "--local-base", self.g("rev-parse", "HEAD"))
2029
+ self.assertEqual(result["status"], "ALLOCATED")
2030
+ self.receipt, self.task = Path(result["receipt"]), Path(result["worktree"])
2031
+ self.accept()
2032
+ self.assertEqual(self.complete("--local-only")[0]["status"], "LOCAL_ONLY")
2033
+ self.assertEqual(json.loads(self.data.read_text(encoding="utf-8")).get("pushs", 0), 0)
2034
+
2035
+ def test_local_allocation_needs_no_base(self):
2036
+ """P4-2. Prediction: a local allocation with --local-base and no --base, the form agents wrote in 3 of 3 timed
2037
+ runs, is ALLOCATED and completes LOCAL_ONLY, while a remote allocation without --base is refused naming it, with
2038
+ nothing created."""
2039
+ args = ["allocate", "--repo", self.work, "--task", "fixture", "--branch", "task/fixture", "--worktree", self.root / "linked"]
2040
+ refused, _ = self.cli(*args, "--repository", "test/project", success=False)
2041
+ self.assertIn("--base", refused["reason"])
2042
+ self.assertEqual((self.g("branch", "--list", "task/fixture"), (self.root / "linked").exists()), ("", False))
2043
+ result, _ = self.cli(*args, "--local-base", self.g("rev-parse", "HEAD"))
2044
+ self.assertEqual(result["status"], "ALLOCATED")
2045
+ self.receipt, self.task = Path(result["receipt"]), Path(result["worktree"])
2046
+ self.accept()
2047
+ self.assertEqual(self.complete("--local-only")[0]["status"], "LOCAL_ONLY")
1940
2048
 
1941
- def test_named_spec_custody(self):
1942
- self.allocate(); self.accept(pipeline=True, spec_expected={"pure_design": True}, spec_name="X.md")
1943
- result, _ = self.complete("--mode", "pr")
2049
+ def test_allocation_canonicalizes_symlinked_and_dotted_worktree_paths(self):
2050
+ """P4-2. Prediction: an absent worktree path reached through a symlinked directory (macOS /tmp) or written with
2051
+ `..` (`../work-x`, `$(pwd -P)/../work-x`) is ALLOCATED at its canonical path, and that path, now present, is
2052
+ refused through the link as not absent."""
2053
+ alias = self.root / "alias"
2054
+ alias.symlink_to(self.root, target_is_directory=True)
2055
+ args = ["allocate", "--repo", self.work, "--base", "main", "--local-base", self.g("rev-parse", "HEAD")]
2056
+ for name, given in (("linked", alias / "linked"), ("dotted", Path("..") / "dotted")):
2057
+ result, _ = self.cli(*args, "--task", name, "--branch", "task/" + name, "--worktree", given, cwd=self.work)
2058
+ self.assertEqual((result["status"], result["worktree"]), ("ALLOCATED", str(self.root / name)))
2059
+ self.assertEqual(self.g("rev-parse", "--show-toplevel", work=self.root / name), str(self.root / name))
2060
+ refused, _ = self.cli(*args, "--task", "again", "--branch", "task/again", "--worktree", alias / "dotted", success=False)
2061
+ self.assertIn("linked worktree must be an absent path", refused["reason"])
2062
+
2063
+ def test_completion_accepts_the_path_form_the_worktree_was_allocated_with(self):
2064
+ """Prediction: an acceptance named through the symlinked or `..` form the worktree was allocated with completes
2065
+ LOCAL_ONLY; before, allocate canonicalized the worktree but complete refused that form as outside it."""
2066
+ alias = self.root / "alias"
2067
+ alias.symlink_to(self.root, target_is_directory=True)
2068
+ for name, given in (("linked", alias / "linked"), ("dotted", Path("..") / "dotted")):
2069
+ result, _ = self.cli("allocate", "--repo", self.work, "--task", "fixture", "--branch", "task/" + name,
2070
+ "--worktree", given, "--local-base", self.g("rev-parse", "HEAD"), cwd=self.work)
2071
+ self.receipt, self.task = Path(result["receipt"]), Path(result["worktree"])
2072
+ self.accept()
2073
+ self.acceptance = given / ".devlyn" / "acceptance.json"
2074
+ self.assertEqual(self.complete("--local-only", cwd=self.work)[0]["status"], "LOCAL_ONLY")
2075
+
2076
+ def test_remote_allocation_starts_from_an_exact_commit(self):
2077
+ # An ideate auto/pr task starts from the exact refreshed remote base the drain checked, never a second fetch.
2078
+ (self.work / "product").write_text("added locally\n", encoding="utf-8")
2079
+ self.g("commit", "-qam", "devlyn loop: add fixture")
2080
+ start = self.g("rev-parse", "HEAD")
2081
+ args = ["allocate", "--repo", self.work, "--task", "first", "--branch", "task/first", "--repository", "test/project",
2082
+ "--base", "main", "--worktree", self.root / "first"]
2083
+ for extra, reason in ((["--start", "main"], "--start must name an exact local commit"),
2084
+ (["--start", start, "--local-base", start], "use one of --local-base, --from-receipt or --start")):
2085
+ self.assertIn(reason, self.cli(*args, *extra, success=False)[0]["reason"])
2086
+ result, _ = self.cli(*args, "--start", start)
2087
+ receipt = json.loads(Path(result["receipt"]).read_text(encoding="utf-8"))
2088
+ self.assertEqual((receipt["baseline"], receipt.get("local_only"), self.g("rev-parse", "HEAD", work=Path(result["worktree"]))),
2089
+ (start, None, start))
2090
+
2091
+ def test_failed_loop_result_keeps_custody_and_recovery_but_never_publishes(self):
2092
+ self.allocate()
2093
+ self.assertEqual(self.loop_result("FAILED")["status"], "FAILED")
2094
+ receipt = json.loads(self.receipt.read_text())
2095
+ self.assertEqual((receipt["product"], self.g("rev-parse", receipt["recovery_ref"])), ("FAILED", self.sha))
2096
+ self.assertTrue((self.receipt.parent / "custody/.devlyn/loop/runs/1/cmd-0.stdout").is_file())
2097
+ terminal, _ = self.terminal()
2098
+ self.assertEqual(self.g("rev-parse", receipt["recovery_ref"]), terminal)
2099
+ result, _ = self.complete("--mode", "auto", acceptance=False)
2100
+ self.assertEqual(result["status"], "FAILED")
2101
+ d = json.loads(self.data.read_text())
2102
+ self.assertEqual((d.get("pushs", 0), d.get("creates", 0)), (0, 0))
2103
+
2104
+ def test_bind_requires_the_source_checkout_only_for_a_publishable_result(self):
2105
+ for verdict in ("ACCEPTED", "FAILED"):
2106
+ with self.subTest(verdict=verdict):
2107
+ self.allocate(verdict.lower() + "-detached")
2108
+ (self.task / "product").write_text("source\n", encoding="utf-8")
2109
+ self.g("add", "product", work=self.task)
2110
+ self.g("commit", "-m", "loop source", work=self.task)
2111
+ sha = self.g("rev-parse", "HEAD", work=self.task)
2112
+ self.g("checkout", "-q", "--detach", "HEAD~1", work=self.task) # The executor left HEAD at another commit.
2113
+ acceptance = self.task / ".devlyn/loop/acceptance.json"
2114
+ acceptance.parent.mkdir(parents=True, exist_ok=True)
2115
+ (self.task / ".devlyn/loop/out.txt").write_text("check output\n", encoding="utf-8")
2116
+ acceptance.write_text(json.dumps({"kind": "loop", "task": json.loads(self.receipt.read_text())["task"], "source_sha": sha,
2117
+ "verdict": verdict, "evidence": [".devlyn/loop/out.txt"]}), encoding="utf-8")
2118
+ result, _ = self.cli("accept", "--receipt", self.receipt, "--acceptance", acceptance, success=False)
2119
+ self.assertEqual(result["status"], "FAILED" if verdict == "FAILED" else "BLOCKED", result)
2120
+
2121
+ def test_attach_requires_the_terminal_checkout_only_for_a_publishable_result(self):
2122
+ for verdict in ("ACCEPTED", "FAILED"):
2123
+ with self.subTest(verdict=verdict):
2124
+ self.allocate(verdict.lower())
2125
+ self.loop_result(verdict)
2126
+ (self.task / "queue.md").write_text("- [x] fixture\n", encoding="utf-8")
2127
+ self.g("add", "queue.md", work=self.task)
2128
+ self.g("commit", "-m", "terminal", work=self.task)
2129
+ terminal = self.g("rev-parse", "HEAD", work=self.task)
2130
+ self.g("checkout", "-q", "--detach", self.sha, work=self.task) # The task ref is terminal; HEAD stays at the source.
2131
+ result, _ = self.cli("attach", "--receipt", self.receipt, "--commit", terminal, "--file", "queue.md", success=False)
2132
+ self.assertEqual(result["status"], "ATTACHED" if verdict == "FAILED" else "BLOCKED", result)
2133
+
2134
+ def test_local_only_completion_binds_acceptance_custody(self):
2135
+ self.allocate(); self.accept()
2136
+ result, _ = self.complete("--local-only")
2137
+ self.assertEqual(result["status"], "LOCAL_ONLY")
2138
+ receipt = json.loads(self.receipt.read_text())
2139
+ self.assertEqual((receipt["source_sha"], self.g("rev-parse", receipt["recovery_ref"])), (self.sha, self.sha))
2140
+ verify_files(self.receipt.parent / "custody", receipt["files"])
2141
+
2142
+ def test_attach_binds_only_a_queue_only_commit_and_publishes_it_with_the_source(self):
2143
+ self.allocate(); self.loop_result()
2144
+ source = self.sha
2145
+ (self.task / "product").write_text("unaccepted\n", encoding="utf-8")
2146
+ (self.task / "queue.md").write_text("- [x] fixture\n", encoding="utf-8")
2147
+ self.g("add", "product", "queue.md", work=self.task)
2148
+ self.g("commit", "-m", "mixed", work=self.task)
2149
+ result, _ = self.cli("attach", "--receipt", self.receipt, "--commit", self.g("rev-parse", "HEAD", work=self.task),
2150
+ "--file", "queue.md", success=False)
2151
+ self.assertIn("exactly the declared queue file", result["reason"])
2152
+ self.g("reset", "--hard", source, work=self.task)
2153
+ terminal, attached = self.terminal()
2154
+ self.assertEqual(attached["status"], "ATTACHED")
2155
+ self.assertEqual(self.cli("attach", "--receipt", self.receipt, "--commit", terminal, "--file", "queue.md")[0]["status"], "ATTACHED")
2156
+ receipt = json.loads(self.receipt.read_text())
2157
+ self.assertEqual((receipt["source_sha"], receipt["publish_sha"]), (source, terminal))
2158
+ result, _ = self.complete("--mode", "pr", acceptance=False)
1944
2159
  self.assertEqual(result["status"], "PR")
1945
- receipt = json.loads(self.receipt.read_text(encoding="utf-8"))
1946
- self.assertIn("spec.expected.json", receipt["files"])
1947
- self.assertEqual((self.receipt.parent / "custody/spec.expected.json").read_bytes(), (self.task / "spec.expected.json").read_bytes())
1948
-
1949
- def test_named_spec_missing_required_process_evidence(self):
1950
- expected = {"process_evidence": [{"id": "red-first", "phase": "implement", "argv": [sys.executable, "-c", "print('red')"], "exit_code": 0, "timeout_sec": 10}]}
1951
- self.allocate(); self.accept(pipeline=True, spec_expected=expected, spec_name="X.md")
1952
- _, r = self.complete(success=False)
1953
- self.assertNotEqual(r.returncode, 0)
1954
- self.assertIn("implement process evidence", json.loads(r.stdout)["reason"])
1955
- self.assertEqual(json.loads(self.data.read_text(encoding="utf-8")).get("pushs",0), 0)
2160
+ self.assertEqual(json.loads(self.data.read_text())["prs"][0]["headRefOid"], terminal)
1956
2161
 
1957
2162
 
1958
2163
  if __name__ == "__main__":