devlyn-cli 2.10.0 → 3.0.0

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 (82) hide show
  1. package/AGENTS.md +4 -9
  2. package/CLAUDE.md +11 -15
  3. package/README.md +31 -7
  4. package/benchmark/auto-resolve/results/20260507-f10-f11-tier1-full-pipeline/headroom-gate.json +25 -0
  5. package/benchmark/auto-resolve/results/20260507-f10-f11-tier1-full-pipeline/headroom-gate.md +10 -0
  6. package/benchmark/auto-resolve/results/20260508-f22-exact-error-headroom/headroom-gate.json +18 -0
  7. package/benchmark/auto-resolve/results/20260508-f22-exact-error-headroom/headroom-gate.md +9 -0
  8. package/benchmark/auto-resolve/results/20260508-f26-headroom/headroom-gate.json +18 -0
  9. package/benchmark/auto-resolve/results/20260508-f26-headroom/headroom-gate.md +9 -0
  10. package/benchmark/auto-resolve/results/20260510-f16-f23-f25-combined-proof/F16-cli-quote-tax-rules/l2_risk_probes/result.json +28 -0
  11. package/benchmark/auto-resolve/results/20260510-f16-f23-f25-combined-proof/F23-cli-fulfillment-wave/l2_risk_probes/result.json +28 -0
  12. package/benchmark/auto-resolve/results/20260510-f16-f23-f25-combined-proof/F25-cli-cart-promotion-rules/l2_risk_probes/result.json +30 -0
  13. package/benchmark/auto-resolve/results/20260511-f12-webhook-headroom/headroom-gate.json +28 -0
  14. package/benchmark/auto-resolve/results/20260511-f12-webhook-headroom/headroom-gate.md +15 -0
  15. package/benchmark/auto-resolve/results/20260511-f15-concurrency-headroom/headroom-gate.json +28 -0
  16. package/benchmark/auto-resolve/results/20260511-f15-concurrency-headroom/headroom-gate.md +15 -0
  17. package/benchmark/auto-resolve/results/20260511-f21-current-riskprobes-v1/F21-cli-scheduler-priority/l2_risk_probes/result.json +32 -0
  18. package/benchmark/auto-resolve/results/20260511-f21-current-riskprobes-v1/full-pipeline-pair-gate.json +44 -0
  19. package/benchmark/auto-resolve/results/20260511-f21-current-riskprobes-v1/full-pipeline-pair-gate.md +16 -0
  20. package/benchmark/auto-resolve/results/20260511-f21-current-riskprobes-v1/headroom-gate.json +18 -0
  21. package/benchmark/auto-resolve/results/20260511-f21-current-riskprobes-v1/headroom-gate.md +9 -0
  22. package/benchmark/auto-resolve/results/20260511-f3-http-error-headroom/headroom-gate.json +28 -0
  23. package/benchmark/auto-resolve/results/20260511-f3-http-error-headroom/headroom-gate.md +15 -0
  24. package/benchmark/auto-resolve/results/20260512-f2-medium-headroom/headroom-gate.json +28 -0
  25. package/benchmark/auto-resolve/results/20260512-f2-medium-headroom/headroom-gate.md +15 -0
  26. package/benchmark/auto-resolve/results/20260512-f31-seat-rebalance-headroom/headroom-gate.json +28 -0
  27. package/benchmark/auto-resolve/results/20260512-f31-seat-rebalance-headroom/headroom-gate.md +15 -0
  28. package/benchmark/auto-resolve/results/20260512-f32-subscription-renewal-headroom/headroom-gate.json +28 -0
  29. package/benchmark/auto-resolve/results/20260512-f32-subscription-renewal-headroom/headroom-gate.md +15 -0
  30. package/benchmark/auto-resolve/results/20260512-f4-web-headroom/headroom-gate.json +28 -0
  31. package/benchmark/auto-resolve/results/20260512-f4-web-headroom/headroom-gate.md +15 -0
  32. package/benchmark/auto-resolve/results/20260512-f5-fixloop-headroom/headroom-gate.json +28 -0
  33. package/benchmark/auto-resolve/results/20260512-f5-fixloop-headroom/headroom-gate.md +15 -0
  34. package/benchmark/auto-resolve/results/20260512-f6-checksum-headroom/headroom-gate.json +28 -0
  35. package/benchmark/auto-resolve/results/20260512-f6-checksum-headroom/headroom-gate.md +15 -0
  36. package/benchmark/auto-resolve/results/20260512-f7-scope-headroom/headroom-gate.json +28 -0
  37. package/benchmark/auto-resolve/results/20260512-f7-scope-headroom/headroom-gate.md +15 -0
  38. package/benchmark/auto-resolve/results/20260512-f9-e2e-headroom/headroom-gate.json +28 -0
  39. package/benchmark/auto-resolve/results/20260512-f9-e2e-headroom/headroom-gate.md +15 -0
  40. package/benchmark/auto-resolve/scripts/run-fixture.sh +11 -1
  41. package/benchmark/auto-resolve/scripts/test-benchmark-arg-parsing.sh +83 -1
  42. package/bin/devlyn.js +59 -9
  43. package/config/skills/_shared/.ruff.toml +2 -0
  44. package/config/skills/_shared/adapters/claude.md +9 -1
  45. package/config/skills/_shared/adapters/codex.md +5 -0
  46. package/config/skills/_shared/adapters/grok.md +37 -13
  47. package/config/skills/_shared/archive_run.py +738 -56
  48. package/config/skills/_shared/codex-config.md +19 -4
  49. package/config/skills/_shared/codex-monitored.sh +81 -12
  50. package/config/skills/_shared/collect-codex-findings.py +419 -60
  51. package/config/skills/_shared/engine-preflight.md +8 -7
  52. package/config/skills/_shared/expected.schema.json +68 -0
  53. package/config/skills/_shared/finish-gate.py +12 -11
  54. package/config/skills/_shared/grok-anchor-guard.py +78 -0
  55. package/config/skills/_shared/invocation-receipt.py +1041 -0
  56. package/config/skills/_shared/judge-output-parser.py +200 -0
  57. package/config/skills/_shared/judge-role-evidence.py +273 -0
  58. package/config/skills/_shared/phase-prompt-render.py +181 -0
  59. package/config/skills/_shared/process-evidence.py +909 -0
  60. package/config/skills/_shared/resolve-bootstrap.py +776 -84
  61. package/config/skills/_shared/resolve-stop-hook.py +33 -1
  62. package/config/skills/_shared/role-config.py +552 -0
  63. package/config/skills/_shared/run-bounded.py +1 -1
  64. package/config/skills/_shared/spec-verify-check.py +1037 -192
  65. package/config/skills/_shared/state-phase-write.py +2551 -83
  66. package/config/skills/_shared/terminal-claim-check.py +15 -0
  67. package/config/skills/_shared/verify-merge-findings.py +1127 -50
  68. package/config/skills/devlyn:engines/SKILL.md +8 -16
  69. package/config/skills/devlyn:ideate/SKILL.md +1 -1
  70. package/config/skills/devlyn:ideate/references/spec-template.md +1 -0
  71. package/config/skills/devlyn:queue/SKILL.md +7 -4
  72. package/config/skills/devlyn:resolve/SKILL.md +94 -35
  73. package/config/skills/devlyn:resolve/references/outer-loop.md +15 -0
  74. package/config/skills/devlyn:resolve/references/phases/build-gate.md +33 -4
  75. package/config/skills/devlyn:resolve/references/phases/implement.md +4 -3
  76. package/config/skills/devlyn:resolve/references/phases/plan.md +1 -5
  77. package/config/skills/devlyn:resolve/references/phases/probe-derive.md +4 -2
  78. package/config/skills/devlyn:resolve/references/phases/verify.md +120 -80
  79. package/config/skills/devlyn:resolve/references/state-schema.md +23 -9
  80. package/package.json +5 -1
  81. package/scripts/lint-fixtures.sh +4 -1
  82. package/scripts/lint-skills.sh +447 -290
@@ -6,7 +6,7 @@ Usage:
6
6
 
7
7
  Reads run_id from .devlyn/pipeline.state.json, moves per-run artifacts into
8
8
  .devlyn/runs/<run_id>/, then best-effort prunes to last 10 completed runs
9
- (in-flight runs — phases.final_report.verdict == null — are never deleted).
9
+ (unfinished or indeterminate runs are never deleted).
10
10
 
11
11
  The contract lives in pipeline-state.md. This script implements it so that
12
12
  archive behavior is identical across every invocation.
@@ -14,16 +14,26 @@ archive behavior is identical across every invocation.
14
14
  from __future__ import annotations
15
15
 
16
16
  import argparse
17
+ import datetime
18
+ import hashlib
17
19
  import json
18
20
  import pathlib
19
21
  import re
22
+ import runpy
20
23
  import shutil
24
+ import subprocess
21
25
  import sys
22
26
  import tempfile
23
27
 
24
28
 
25
29
  PER_RUN_PATTERNS = (
26
30
  "pipeline.state.json",
31
+ "untracked.baseline",
32
+ "external-diff.patch",
33
+ "plan.task-context",
34
+ "plan.prompt",
35
+ "probe-derive.stdout",
36
+ "probe-derive.stderr",
27
37
  "*.findings.jsonl",
28
38
  "*.log.md",
29
39
  "fix-batch.round-*.json",
@@ -37,12 +47,13 @@ PER_RUN_PATTERNS = (
37
47
  "surface-close.stdout",
38
48
  # Mutation workers retain the exact session JSONL identified by their
39
49
  # dispatch receipt. Round-scoped root files avoid engine-global scans.
40
- "implement.worker-session.*.jsonl",
41
- "surface-close.worker-session.*.jsonl",
42
- "cleanup.worker-session.*.jsonl",
50
+ "*.worker-session.*.jsonl",
51
+ "*.invocation.*.json",
52
+ "*.argv.*.json",
53
+ "*.prompt.*",
43
54
  "risk-probes.jsonl",
44
- # Probe scripts referenced by risk-probes.jsonl are archived by
45
- # move_probe_scripts() to preserve probes/<file> layout.
55
+ # Probe scripts referenced by risk-probes.jsonl preserve probes/<file>
56
+ # layout through the common preflight/move plan below.
46
57
  # iter-0019.8: spec-verify carrier artifacts get archived alongside
47
58
  # other per-run state. Killed mid-run cleanup is enforced separately
48
59
  # by spec-verify-check.py main() — when source markdown has no json
@@ -53,6 +64,8 @@ PER_RUN_PATTERNS = (
53
64
  "spec-verify.results.json",
54
65
  "spec-verify-findings.jsonl",
55
66
  "verify-merge.summary.json",
67
+ "verify.primary.timeout.json",
68
+ "verify.pair.timeout.json",
56
69
  "finish-gate.summary.json",
57
70
  # iter-0033a/2026-04-30 archive-fix iter: NEW /devlyn:resolve emits
58
71
  # plan.md (PLAN output) + final-report.md (PHASE 6 render) +
@@ -74,79 +87,412 @@ PER_RUN_PATTERNS = (
74
87
  # *-judge.*: every engine's pair-judge stdout/stderr capture
75
88
  # (codex-judge.*, claude-judge.* — adapters/claude.md ## Invocation).
76
89
  "*-judge.*",
90
+ # Phase-owned prompt, context, raw-stream, event, and retry carriers use
91
+ # a phase prefix. Keep the ownership surface here so bootstrap and final
92
+ # archive cannot drift onto separate cleanup lists.
77
93
  )
94
+ PER_RUN_PATTERNS += tuple(
95
+ f"{phase}.{suffix}"
96
+ for phase in (
97
+ "plan", "probe-derive", "implement", "surface-close", "build_gate",
98
+ "build-gate", "cleanup", "verify", "final-report", "finish-gate",
99
+ )
100
+ for suffix in (
101
+ "task-context", "prompt", "stdout", "stderr", "events.jsonl",
102
+ "event-stream.jsonl", "retry.*",
103
+ )
104
+ )
105
+ BOOTSTRAP_TEMP_PATTERNS = tuple(
106
+ name + ".tmp.*" for name in (
107
+ "goal.raw.txt", "spec-verify.json", "external-diff.patch", "pipeline.state.json",
108
+ )
109
+ )
110
+ PER_RUN_PATTERNS += BOOTSTRAP_TEMP_PATTERNS
78
111
 
79
112
  SAFE_RUN_ID_RE = re.compile(r"^[A-Za-z0-9_.-]+$")
80
113
 
81
114
 
115
+ class ArchiveError(Exception):
116
+ pass
117
+
118
+
119
+ def process_evidence_module():
120
+ return runpy.run_path(pathlib.Path(__file__).with_name("process-evidence.py"))
121
+
122
+
123
+ def invocation_receipt_module():
124
+ return runpy.run_path(pathlib.Path(__file__).with_name("invocation-receipt.py"))
125
+
126
+
82
127
  def reject_json_constant(token: str) -> None:
83
128
  raise ValueError(f"invalid JSON numeric constant: {token}")
84
129
 
85
130
 
131
+ def reject_duplicate_keys(pairs: list[tuple[str, object]]) -> dict:
132
+ result = {}
133
+ for key, value in pairs:
134
+ if key in result:
135
+ raise ValueError(f"duplicate JSON key: {key}")
136
+ result[key] = value
137
+ return result
138
+
139
+
86
140
  def loads_strict_json(text: str):
87
- return json.loads(text, parse_constant=reject_json_constant)
141
+ return json.loads(
142
+ text,
143
+ parse_constant=reject_json_constant,
144
+ object_pairs_hook=reject_duplicate_keys,
145
+ )
88
146
 
89
147
 
90
- def read_run_id(devlyn: pathlib.Path) -> str:
148
+ def read_state(devlyn: pathlib.Path) -> dict:
91
149
  state_path = devlyn / "pipeline.state.json"
92
150
  if not state_path.is_file():
93
- raise SystemExit(f"error: {state_path} not found")
151
+ raise ArchiveError(f"{state_path} not found")
94
152
  try:
95
153
  state = loads_strict_json(state_path.read_text(encoding="utf-8"))
96
- except ValueError as e:
97
- raise SystemExit(f"error: {state_path} is not valid JSON: {e}")
154
+ except (OSError, UnicodeError, ValueError) as exc:
155
+ raise ArchiveError(f"{state_path} is not valid JSON: {exc}") from exc
156
+ if not isinstance(state, dict):
157
+ raise ArchiveError(f"{state_path} must contain a JSON object")
98
158
  run_id = state.get("run_id")
99
159
  if not isinstance(run_id, str) or not run_id:
100
- raise SystemExit(f"error: {state_path} has no run_id")
160
+ raise ArchiveError(f"{state_path} has no run_id")
101
161
  if not SAFE_RUN_ID_RE.fullmatch(run_id):
102
- raise SystemExit(f"error: {state_path} run_id must match [A-Za-z0-9_.-]+")
103
- return run_id
162
+ raise ArchiveError(f"{state_path} run_id must match [A-Za-z0-9_.-]+")
163
+ return state
104
164
 
105
165
 
106
- def move_artifacts(devlyn: pathlib.Path, dest: pathlib.Path) -> int:
107
- dest.mkdir(parents=True, exist_ok=True)
108
- moved = 0
109
- for pat in PER_RUN_PATTERNS:
110
- for src in devlyn.glob(pat):
111
- if src.is_file():
112
- shutil.move(str(src), str(dest / src.name))
113
- moved += 1
114
- moved += move_probe_scripts(devlyn, dest)
115
- return moved
116
-
117
-
118
- def move_probe_scripts(devlyn: pathlib.Path, dest: pathlib.Path) -> int:
166
+ def read_run_id(devlyn: pathlib.Path) -> str:
167
+ try:
168
+ return read_state(devlyn)["run_id"]
169
+ except ArchiveError as exc:
170
+ raise SystemExit(f"error: {exc}") from exc
171
+
172
+
173
+ def is_completed(state: object) -> bool:
174
+ phases = state.get("phases") if isinstance(state, dict) else None
175
+ final = phases.get("final_report") if isinstance(phases, dict) else None
176
+ if not isinstance(final, dict) or final.get("verdict") not in (
177
+ "PASS", "PASS_WITH_ISSUES", "NEEDS_WORK", "BLOCKED",
178
+ ):
179
+ return False
180
+ timestamp = final.get("completed_at")
181
+ if not isinstance(timestamp, str) or not re.fullmatch(
182
+ r"[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]{3})?Z",
183
+ timestamp,
184
+ ):
185
+ return False
186
+ try:
187
+ datetime.datetime.strptime(
188
+ timestamp, "%Y-%m-%dT%H:%M:%S.%fZ" if "." in timestamp else "%Y-%m-%dT%H:%M:%SZ",
189
+ )
190
+ except ValueError:
191
+ return False
192
+ return True
193
+
194
+
195
+ def has_bootstrap_residue(devlyn: pathlib.Path) -> bool:
196
+ return any(path for pattern in BOOTSTRAP_TEMP_PATTERNS for path in devlyn.glob(pattern))
197
+
198
+
199
+ def static_artifacts(devlyn: pathlib.Path) -> list[pathlib.Path]:
200
+ found: dict[pathlib.Path, None] = {}
201
+ for pattern in PER_RUN_PATTERNS:
202
+ for path in devlyn.glob(pattern):
203
+ if path.is_file():
204
+ found[path] = None
119
205
  probes = devlyn / "probes"
120
- if not probes.is_dir():
121
- return 0
122
- moved = 0
123
- for src in sorted(probes.rglob("*")):
124
- if not src.is_file():
206
+ if probes.is_dir():
207
+ for path in probes.rglob("*"):
208
+ if path.is_file():
209
+ found[path] = None
210
+ return sorted(found)
211
+
212
+
213
+ def has_owned_artifacts(devlyn: pathlib.Path) -> bool:
214
+ if not devlyn.is_dir():
215
+ return False
216
+ if has_bootstrap_residue(devlyn) or static_artifacts(devlyn):
217
+ return True
218
+ evidence = devlyn / "process-evidence"
219
+ return evidence.is_dir() and any(path.is_file() for path in evidence.rglob("*"))
220
+
221
+
222
+ def dynamic_evidence_artifacts(devlyn: pathlib.Path, state: dict) -> list[pathlib.Path]:
223
+ carriers = state.get("process_evidence")
224
+ evidence_root = devlyn / "process-evidence"
225
+ if carriers is None:
226
+ if evidence_root.is_dir() and any(path.is_file() for path in evidence_root.rglob("*")):
227
+ raise ArchiveError("process-evidence files exist without state bindings")
228
+ return []
229
+ if not isinstance(carriers, list):
230
+ raise ArchiveError("state.process_evidence must be null or an array")
231
+
232
+ runner = process_evidence_module()
233
+ work = devlyn.parent.resolve()
234
+ run_id = state["run_id"]
235
+ expected_root = pathlib.PurePosixPath(".devlyn") / "process-evidence" / run_id
236
+ found: dict[pathlib.Path, None] = {}
237
+ for carrier in carriers:
238
+ try:
239
+ runner["validate_bound_carrier"](work, carrier)
240
+ except (runner["EvidenceError"], OSError, UnicodeError, ValueError) as exc:
241
+ raise ArchiveError(f"invalid bound process evidence: {exc}") from exc
242
+ manifest = carrier["manifest"]
243
+ paths = [manifest["path"]]
244
+ for stream in carrier["streams"]:
245
+ paths.extend((stream["stdout"]["path"], stream["stderr"]["path"]))
246
+ for relative_text in paths:
247
+ relative = pathlib.PurePosixPath(relative_text)
248
+ try:
249
+ relative.relative_to(expected_root)
250
+ except ValueError as exc:
251
+ raise ArchiveError(
252
+ f"process evidence is not owned by state.run_id {run_id}: {relative_text}"
253
+ ) from exc
254
+ source = work.joinpath(*relative.parts)
255
+ if source in found:
256
+ raise ArchiveError(f"duplicate state-bound process-evidence path: {relative_text}")
257
+ found[source] = None
258
+
259
+ actual = {
260
+ path.resolve()
261
+ for path in evidence_root.rglob("*")
262
+ if path.is_file()
263
+ } if evidence_root.is_dir() else set()
264
+ bound = {path.resolve() for path in found}
265
+ unbound = []
266
+ for path in actual - bound:
267
+ try:
268
+ unbound.append(path.relative_to(work).as_posix())
269
+ except ValueError as exc:
270
+ raise ArchiveError(f"process-evidence file escapes the worktree: {path}") from exc
271
+ unbound.sort()
272
+ if unbound:
273
+ raise ArchiveError("unbound process-evidence file(s): " + ",".join(unbound))
274
+ return sorted(found)
275
+
276
+
277
+ def dynamic_invocation_artifacts(devlyn: pathlib.Path, state: dict) -> list[pathlib.Path]:
278
+ phases = state.get("phases")
279
+ if not isinstance(phases, dict):
280
+ return []
281
+ work = devlyn.parent.resolve()
282
+ found: dict[pathlib.Path, None] = {}
283
+ for phase_name, phase in phases.items():
284
+ if not isinstance(phase, dict):
125
285
  continue
126
- target = dest / src.relative_to(devlyn)
127
- target.parent.mkdir(parents=True, exist_ok=True)
128
- shutil.move(str(src), str(target))
129
- moved += 1
130
- if probes.exists():
131
- shutil.rmtree(probes, ignore_errors=False)
132
- return moved
286
+ records = [phase]
287
+ history = phase.get("history")
288
+ if isinstance(history, list):
289
+ records.extend(record for record in history if isinstance(record, dict))
290
+ for record in records:
291
+ receipt = record.get("invocation_receipt")
292
+ if receipt is None:
293
+ continue
294
+ if not isinstance(receipt, dict) or set(receipt) != {
295
+ "path", "sha256", "sandbox", "sandbox_network_access",
296
+ "argv_sha256", "exit_code",
297
+ }:
298
+ raise ArchiveError(f"phases.{phase_name} invocation receipt binding is invalid")
299
+ relative_text = receipt["path"]
300
+ if not isinstance(relative_text, str):
301
+ raise ArchiveError(f"phases.{phase_name} invocation receipt path is invalid")
302
+ relative = pathlib.PurePosixPath(relative_text)
303
+ if (
304
+ relative.is_absolute()
305
+ or ".." in relative.parts
306
+ or relative.parts[:1] != (".devlyn",)
307
+ ):
308
+ raise ArchiveError(f"invocation receipt escapes .devlyn: {relative_text}")
309
+ source = work.joinpath(*relative.parts)
310
+ try:
311
+ resolved = source.resolve(strict=True)
312
+ resolved.relative_to(work)
313
+ if source.is_symlink() or not resolved.is_file():
314
+ raise ArchiveError(
315
+ f"bound invocation receipt is not a regular worktree file: {relative_text}"
316
+ )
317
+ raw = source.read_bytes()
318
+ except (OSError, ValueError) as exc:
319
+ raise ArchiveError(f"bound invocation receipt is missing: {relative_text}") from exc
320
+ if receipt["sha256"] != hashlib.sha256(raw).hexdigest():
321
+ raise ArchiveError(f"bound invocation receipt digest mismatch: {relative_text}")
322
+ try:
323
+ document, receipt_file, prompt_file, session_file = (
324
+ invocation_receipt_module()["validate_receipt_artifacts"](
325
+ work, source, run_id=state["run_id"], phase=phase_name,
326
+ )
327
+ )
328
+ except (OSError, UnicodeError, ValueError) as exc:
329
+ raise ArchiveError(f"invalid bound invocation receipt: {exc}") from exc
330
+ if (
331
+ not isinstance(receipt["sandbox"], str)
332
+ or type(receipt["sandbox_network_access"]) is not bool
333
+ or not isinstance(receipt["argv_sha256"], str)
334
+ or isinstance(receipt["exit_code"], bool)
335
+ or not isinstance(receipt["exit_code"], int)
336
+ or receipt["sandbox"] != document["sandbox"]
337
+ or receipt["sandbox_network_access"] is not document["sandbox_network_access"]
338
+ or receipt["argv_sha256"] != document["argv_sha256"]
339
+ or receipt["exit_code"] != document["exit_code"]
340
+ ):
341
+ raise ArchiveError(
342
+ f"bound invocation receipt fields mismatch sealed file: {relative_text}"
343
+ )
344
+ for artifact in (receipt_file, prompt_file, session_file):
345
+ if artifact in found:
346
+ raise ArchiveError(
347
+ f"duplicate state-bound invocation artifact: {artifact.relative_to(work)}"
348
+ )
349
+ found[artifact] = None
350
+ if "role_argv" in record:
351
+ binding = record["role_argv"]
352
+ expected = f".devlyn/{phase_name}.argv.{document['round']}.json"
353
+ if not isinstance(binding, dict) or binding.get("path") != expected:
354
+ raise ArchiveError("invalid state-bound worker argv path")
355
+ argv_file = work / expected
356
+ if argv_file.is_symlink() or not argv_file.is_file():
357
+ raise ArchiveError("missing regular state-bound worker argv")
358
+ argv_raw = argv_file.read_bytes()
359
+ try:
360
+ argv = json.loads(argv_raw)
361
+ except (ValueError, UnicodeError) as exc:
362
+ raise ArchiveError("malformed state-bound worker argv") from exc
363
+ if (not isinstance(argv, list) or any(not isinstance(arg, str) for arg in argv)
364
+ or len(argv_raw) != binding.get("bytes")
365
+ or hashlib.sha256(argv_raw).hexdigest() != binding.get("sha256")
366
+ or hashlib.sha256(json.dumps(argv, separators=(",", ":")).encode()).hexdigest() != receipt["argv_sha256"]):
367
+ raise ArchiveError("altered state-bound worker argv")
368
+ found[argv_file] = None
369
+ return sorted(found)
370
+
371
+
372
+ def dynamic_judge_role_artifacts(devlyn: pathlib.Path, state: dict) -> list[pathlib.Path]:
373
+ verify = state.get("phases", {}).get("verify", {})
374
+ if verify is None:
375
+ return []
376
+ if not isinstance(verify, dict) or not isinstance(verify.get("history", []), list):
377
+ raise ArchiveError("malformed VERIFY phase/history")
378
+ records = [verify] + (verify.get("history") or [])
379
+ found = set()
380
+ for record in records:
381
+ if not isinstance(record, dict):
382
+ continue
383
+ bindings = record.get("role_evidence") or {}
384
+ if not isinstance(bindings, dict):
385
+ raise ArchiveError("malformed state-bound judge role evidence")
386
+ for binding in bindings.values():
387
+ if not isinstance(binding, dict) or not isinstance(binding.get("artifacts"), list):
388
+ raise ArchiveError("malformed judge role artifact binding")
389
+ for artifact in [binding, *binding["artifacts"]]:
390
+ relative = pathlib.PurePosixPath(artifact.get("path", ""))
391
+ if len(relative.parts) != 2 or relative.parts[0] != ".devlyn":
392
+ raise ArchiveError("judge role artifact path escapes canonical .devlyn")
393
+ path = devlyn / relative.name
394
+ if path.is_symlink() or not path.is_file():
395
+ raise ArchiveError(f"missing regular judge role artifact: {relative}")
396
+ raw = path.read_bytes()
397
+ if hashlib.sha256(raw).hexdigest() != artifact.get("sha256") or len(raw) != artifact.get("bytes"):
398
+ raise ArchiveError(f"altered judge role artifact: {relative}")
399
+ found.add(path)
400
+ return sorted(found)
401
+
402
+
403
+ def archive_plan(devlyn: pathlib.Path, dest: pathlib.Path, state: dict) -> list[tuple[pathlib.Path, pathlib.Path]]:
404
+ devlyn = devlyn.resolve()
405
+ dest = dest.resolve(strict=False)
406
+ phases = state.get("phases")
407
+ final = phases.get("final_report") if isinstance(phases, dict) else None
408
+ if isinstance(final, dict) and "output_sha256" in final:
409
+ digest = final["output_sha256"]
410
+ artifacts = final.get("artifacts")
411
+ report = devlyn / "final-report.md"
412
+ if (
413
+ not isinstance(digest, str) or not re.fullmatch(r"[0-9a-f]{64}", digest)
414
+ or not isinstance(artifacts, dict)
415
+ or artifacts.get("log_file") != ".devlyn/final-report.md"
416
+ or report.is_symlink() or not report.is_file()
417
+ ):
418
+ raise ArchiveError("final-report binding or canonical file is invalid")
419
+ if hashlib.sha256(report.read_bytes()).hexdigest() != digest:
420
+ raise ArchiveError("final-report bytes differ from completed output_sha256")
421
+ moves: list[tuple[pathlib.Path, pathlib.Path]] = []
422
+ sources: set[pathlib.Path] = set()
423
+ targets: set[pathlib.Path] = set()
424
+
425
+ def add(source: pathlib.Path, target: pathlib.Path) -> None:
426
+ if source in sources:
427
+ return
428
+ if target in targets:
429
+ raise ArchiveError(f"archive destination is ambiguous: {target}")
430
+ if target.exists() or target.is_symlink():
431
+ raise ArchiveError(f"archive destination collision: {target}")
432
+ parent = target.parent
433
+ while parent != dest.parent:
434
+ if parent.exists() and (parent.is_symlink() or not parent.is_dir()):
435
+ raise ArchiveError(f"archive destination parent is unsafe: {parent}")
436
+ if parent == dest:
437
+ break
438
+ parent = parent.parent
439
+ sources.add(source)
440
+ targets.add(target)
441
+ moves.append((source, target))
442
+
443
+ for source in static_artifacts(devlyn):
444
+ relative = source.relative_to(devlyn)
445
+ target = dest / relative if relative.parts[0] == "probes" else dest / source.name
446
+ add(source, target)
447
+ for source in dynamic_evidence_artifacts(devlyn, state):
448
+ add(source, dest / source.relative_to(devlyn))
449
+ for source in dynamic_invocation_artifacts(devlyn, state):
450
+ add(source, dest / source.relative_to(devlyn))
451
+ for source in dynamic_judge_role_artifacts(devlyn, state):
452
+ add(source, dest / source.relative_to(devlyn))
453
+ return moves
454
+
455
+
456
+ def move_artifacts(devlyn: pathlib.Path, dest: pathlib.Path) -> int:
457
+ devlyn = devlyn.resolve()
458
+ dest = dest.resolve()
459
+ state = read_state(devlyn)
460
+ if dest.name != state["run_id"] or dest.parent != devlyn / "runs":
461
+ raise ArchiveError("archive destination does not match authenticated state.run_id")
462
+ moves = archive_plan(devlyn, dest, state)
463
+ completed: list[tuple[pathlib.Path, pathlib.Path]] = []
464
+ try:
465
+ for source, target in moves:
466
+ target.parent.mkdir(parents=True, exist_ok=True)
467
+ shutil.move(str(source), str(target))
468
+ completed.append((source, target))
469
+ except BaseException:
470
+ for source, target in reversed(completed):
471
+ source.parent.mkdir(parents=True, exist_ok=True)
472
+ shutil.move(str(target), str(source))
473
+ raise
474
+ for root in (devlyn / "probes", devlyn / "process-evidence" / state["run_id"]):
475
+ if root.is_dir():
476
+ for directory in sorted(
477
+ (path for path in root.rglob("*") if path.is_dir()),
478
+ key=lambda path: len(path.parts), reverse=True,
479
+ ):
480
+ directory.rmdir()
481
+ root.rmdir()
482
+ return len(completed)
133
483
 
134
484
 
135
485
  def prune(runs_dir: pathlib.Path, keep: int = 10) -> int:
136
486
  """Delete oldest completed runs beyond `keep`. In-flight runs never removed."""
137
487
  candidates = []
138
488
  for d in sorted(runs_dir.glob("*/"), key=lambda p: p.name):
139
- state_file = d / "pipeline.state.json"
140
- if not state_file.is_file():
141
- continue
142
489
  try:
143
- s = loads_strict_json(state_file.read_text(encoding="utf-8"))
144
- except ValueError:
490
+ s = read_state(d)
491
+ except ArchiveError:
145
492
  # Can't decide flight-state safely; skip (never prune)
146
493
  continue
147
- verdict = s.get("phases", {}).get("final_report", {}).get("verdict")
148
- if verdict is None:
149
- continue # in-flight
494
+ if not is_completed(s) or has_bootstrap_residue(d):
495
+ continue # in-flight or indeterminate
150
496
  candidates.append(d)
151
497
  over = len(candidates) - keep
152
498
  if over <= 0:
@@ -158,16 +504,170 @@ def prune(runs_dir: pathlib.Path, keep: int = 10) -> int:
158
504
  return pruned
159
505
 
160
506
 
507
+ def completion_self_test() -> None:
508
+ valid = [
509
+ {"verdict": verdict, "completed_at": timestamp}
510
+ for verdict in ("PASS", "PASS_WITH_ISSUES", "NEEDS_WORK", "BLOCKED")
511
+ for timestamp in ("2024-02-29T23:59:59Z", "2024-02-29T23:59:59.123Z")
512
+ ]
513
+ invalid = [None, [], "PASS", {}, {"verdict": "PASS"}]
514
+ invalid += [
515
+ {"verdict": verdict, "completed_at": "2026-09-06T00:00:00Z"}
516
+ for verdict in (None, [], {}, 1, "", "FAIL", "UNKNOWN", "pass")
517
+ ]
518
+ invalid += [
519
+ {"verdict": "PASS", "completed_at": timestamp}
520
+ for timestamp in (
521
+ None, [], 1, "", "2026-09-06T00:00:00", "2026-09-06T00:00:00+00:00",
522
+ "2026-02-29T00:00:00Z", "2026-13-01T00:00:00Z", "0000-01-01T00:00:00Z",
523
+ "2026-09-06T24:00:00Z", "2026-09-06T00:60:00Z", "2026-09-06T00:00:60Z",
524
+ "2026-09-06T00:00:00.1Z", "2026-09-06T00:00:00.123456Z",
525
+ "2026-9-06T00:00:00Z", "2026-09-06 00:00:00Z", "2026-09-06T00:00:00Z\n",
526
+ )
527
+ ]
528
+ invalid.append({"completed_at": "2026-09-06T00:00:00Z"})
529
+ assert all(is_completed({"phases": {"final_report": final}}) for final in valid)
530
+ assert not any(is_completed({"phases": {"final_report": final}}) for final in invalid)
531
+ assert not any(is_completed(state) for state in (None, [], {}, {"phases": []}))
532
+ with tempfile.TemporaryDirectory() as tmp:
533
+ runs = pathlib.Path(tmp)
534
+ preserved = []
535
+ for i, final in enumerate(invalid):
536
+ path = runs / f"indeterminate-{i:02d}" / "pipeline.state.json"
537
+ path.parent.mkdir()
538
+ path.write_text(json.dumps({"run_id": path.parent.name, "phases": {"final_report": final}}))
539
+ preserved.append(path)
540
+ for i, raw in enumerate((b"{", b"\xff", b"null", b'{"phases":{}}', b'{"run_id":[]}')):
541
+ path = runs / f"malformed-{i}" / "pipeline.state.json"
542
+ path.parent.mkdir()
543
+ path.write_bytes(raw)
544
+ preserved.append(path)
545
+ for i, pattern in enumerate(BOOTSTRAP_TEMP_PATTERNS):
546
+ path = runs / f"residue-{i}" / "pipeline.state.json"
547
+ path.parent.mkdir()
548
+ path.write_text(json.dumps({"run_id": path.parent.name, "phases": {"final_report": valid[0]}}))
549
+ residue = path.with_name(pattern.replace("*", "interrupted"))
550
+ residue.write_bytes(b"interrupted initialization")
551
+ preserved.extend((path, residue))
552
+ for i in range(12):
553
+ path = runs / f"complete-{i:02d}" / "pipeline.state.json"
554
+ path.parent.mkdir()
555
+ path.write_text(json.dumps({"run_id": path.parent.name, "phases": {"final_report": valid[i % 8]}}))
556
+ before = {p: p.read_bytes() for p in preserved}
557
+ assert prune(runs) == 2
558
+ assert {p: p.read_bytes() for p in preserved} == before
559
+ assert not (runs / "complete-00").exists() and not (runs / "complete-01").exists()
560
+ assert all((runs / f"complete-{i:02d}").is_dir() for i in range(2, 12))
561
+ assert prune(runs) == 0
562
+ print("PASS archive completion: 8 terminal forms; malformed/unfinished/residue retained beyond keep-10")
563
+
564
+
161
565
  def self_test() -> int:
566
+ assert dynamic_judge_role_artifacts(pathlib.Path(".devlyn"), {"phases": {"verify": None}}) == []
567
+ assert dynamic_judge_role_artifacts(pathlib.Path(".devlyn"), {"phases": {}}) == []
568
+ try:
569
+ dynamic_judge_role_artifacts(pathlib.Path(".devlyn"), {"phases": {"verify": []}})
570
+ except ArchiveError:
571
+ pass
572
+ else:
573
+ raise AssertionError("malformed non-null VERIFY phase accepted")
574
+ try:
575
+ loads_strict_json('{"run_id":"a","run_id":"b"}')
576
+ except ValueError as exc:
577
+ assert "duplicate JSON key" in str(exc)
578
+ else:
579
+ raise AssertionError("duplicate archive state key was accepted")
162
580
  with tempfile.TemporaryDirectory() as tmp:
581
+ work = pathlib.Path(tmp)
163
582
  devlyn = pathlib.Path(tmp) / ".devlyn"
164
583
  devlyn.mkdir()
584
+ invocation = invocation_receipt_module()
585
+ prior_prompt = devlyn / "build_gate.prompt.1"
586
+ prior_prompt.write_text("verify prior archive\n", encoding="utf-8")
587
+ prior_session = devlyn / "build_gate.worker-session.1.jsonl"
588
+ prior_session.write_text('{"type":"thread.started","round":1}\n', encoding="utf-8")
589
+ prior_receipt = devlyn / "build_gate.invocation.1.json"
590
+ invocation["start_receipt"](
591
+ work, prior_receipt, "run-1", "build_gate", 1,
592
+ str(prior_prompt), str(prior_session),
593
+ ["--json", "-C", str(work), "-s", "workspace-write", "-m", "gpt-test",
594
+ "-c", "sandbox_workspace_write.network_access=true",
595
+ "verify prior archive"],
596
+ )
597
+ invocation["finish_receipt"](work, prior_receipt, 0)
598
+ prior_binding = invocation["validate_receipt"](
599
+ work, prior_receipt, run_id="run-1", phase="build_gate", round_=1,
600
+ model="gpt-test", prompt_sha256=hashlib.sha256(prior_prompt.read_bytes()).hexdigest(),
601
+ session_path=prior_session,
602
+ )
603
+ build_prompt = devlyn / "build_gate.prompt.2"
604
+ build_prompt.write_text("verify archive\n", encoding="utf-8")
605
+ build_session = devlyn / "build_gate.worker-session.2.jsonl"
606
+ build_session.write_text('{"type":"thread.started"}\n', encoding="utf-8")
607
+ build_receipt = devlyn / "build_gate.invocation.2.json"
608
+ invocation["start_receipt"](
609
+ work, build_receipt, "run-1", "build_gate", 2,
610
+ str(build_prompt), str(build_session),
611
+ ["--json", "-C", str(work), "-s", "workspace-write", "-m", "gpt-test",
612
+ "-c", "sandbox_workspace_write.network_access=true", "verify archive"],
613
+ )
614
+ invocation["finish_receipt"](work, build_receipt, 0)
615
+ receipt_binding = invocation["validate_receipt"](
616
+ work, build_receipt, run_id="run-1", phase="build_gate", round_=2,
617
+ model="gpt-test", prompt_sha256=hashlib.sha256(build_prompt.read_bytes()).hexdigest(),
618
+ session_path=build_session,
619
+ )
620
+ plan_prompt = devlyn / "plan.prompt.0"
621
+ plan_prompt.write_text("plan archive\n", encoding="utf-8")
622
+ plan_session = devlyn / "plan.worker-session.0.jsonl"
623
+ plan_session.write_text('{"type":"thread.started"}\n', encoding="utf-8")
624
+ plan_receipt = devlyn / "plan.invocation.0.json"
625
+ invocation["start_receipt"](
626
+ work, plan_receipt, "run-1", "plan", 0,
627
+ str(plan_prompt), str(plan_session),
628
+ ["--json", "-C", str(work), "-s", "workspace-write",
629
+ "-m", "gpt-test", "-c",
630
+ "sandbox_workspace_write.network_access=false", "plan archive"],
631
+ )
632
+ invocation["finish_receipt"](work, plan_receipt, 0)
633
+ plan_binding = invocation["validate_receipt"](
634
+ work, plan_receipt, run_id="run-1", phase="plan", round_=0,
635
+ model="gpt-test", prompt_sha256=hashlib.sha256(plan_prompt.read_bytes()).hexdigest(),
636
+ session_path=plan_session,
637
+ )
638
+ state = {
639
+ "run_id": "run-1",
640
+ "phases": {
641
+ "plan": {
642
+ "round": 1,
643
+ "history": [{"invocation_receipt": plan_binding}],
644
+ },
645
+ "verify": {"round": 2},
646
+ "build_gate": {
647
+ "round": 2,
648
+ "history": [{"invocation_receipt": prior_binding}],
649
+ "invocation_receipt": receipt_binding,
650
+ },
651
+ "final_report": {"verdict": "PASS", "completed_at": "2026-09-06T00:00:00Z"},
652
+ },
653
+ "process_evidence": None,
654
+ }
655
+ runner = process_evidence_module()
656
+ obligation = runner["normalize_obligation"]({
657
+ "id": "mechanical-0",
658
+ "phase": "verify",
659
+ "argv": [sys.executable, "-c", "import sys;sys.stdout.buffer.write(b'raw\\x00out')"],
660
+ })
661
+ manifest_rel = runner["manifest_relative_path"](state, "verify")
662
+ runner["capture_process"](
663
+ work, work / manifest_rel, state["run_id"], "verify", 2, obligation,
664
+ )
665
+ state["process_evidence"] = [runner["validate_manifest"](
666
+ work, manifest_rel, state["run_id"], "verify", 2,
667
+ [obligation], require_expectations=False,
668
+ )]
165
669
  (devlyn / "pipeline.state.json").write_text(
166
- json.dumps({
167
- "run_id": "run-1",
168
- "phases": {"final_report": {"verdict": "PASS"}},
169
- }) + "\n",
170
- encoding="utf-8",
670
+ json.dumps(state) + "\n", encoding="utf-8",
171
671
  )
172
672
  for name in (
173
673
  "risk-probes.jsonl",
@@ -182,10 +682,24 @@ def self_test() -> int:
182
682
  "resolve-stop-hook.run-1.123.456.json",
183
683
  "verify.pair.findings.jsonl",
184
684
  "verify-merge.summary.json",
685
+ "untracked.baseline",
686
+ "external-diff.patch",
687
+ "plan.task-context",
688
+ "plan.prompt",
689
+ "probe-derive.stdout",
690
+ "probe-derive.stderr",
691
+ "verify.primary.timeout.json",
692
+ "verify.pair.timeout.json",
185
693
  "codex-judge.stdout",
186
694
  "codex-judge.summary.json",
187
695
  "claude-judge.stdout",
188
696
  "claude-judge.stderr",
697
+ "implement.task-context",
698
+ "implement.prompt",
699
+ "implement.stdout",
700
+ "implement.stderr",
701
+ "implement.events.jsonl",
702
+ "implement.retry.1.stdout",
189
703
  ):
190
704
  (devlyn / name).write_text("{}\n", encoding="utf-8")
191
705
  (devlyn / "probes").mkdir()
@@ -193,8 +707,42 @@ def self_test() -> int:
193
707
  global_rollout = devlyn / "engine-sessions" / "rollout-global.jsonl"
194
708
  global_rollout.parent.mkdir()
195
709
  global_rollout.write_text("{}\n", encoding="utf-8")
710
+ unrelated = devlyn / "unrelated.data"
711
+ unrelated.write_text("preserve\n", encoding="utf-8")
196
712
  run_id = read_run_id(devlyn)
197
713
  assert run_id == "run-1", run_id
714
+ original_session = build_session.read_bytes()
715
+ build_session.write_bytes(original_session + b"altered\n")
716
+ try:
717
+ archive_plan(devlyn, devlyn / "runs" / run_id, state)
718
+ except ArchiveError as exc:
719
+ assert "worker-session digest mismatch" in str(exc)
720
+ else:
721
+ raise AssertionError("archive accepted a mutated invocation worker session")
722
+ build_session.write_bytes(original_session)
723
+ receipt_binding["sandbox_network_access"] = False
724
+ try:
725
+ archive_plan(devlyn, devlyn / "runs" / run_id, state)
726
+ except ArchiveError as exc:
727
+ assert "fields mismatch sealed file" in str(exc)
728
+ else:
729
+ raise AssertionError("archive accepted a falsified invocation receipt binding")
730
+ receipt_binding["sandbox_network_access"] = True
731
+
732
+ escape = work / "archive-escape"
733
+ escape.mkdir()
734
+ unsafe_dest = devlyn / "runs" / run_id
735
+ unsafe_dest.mkdir(parents=True)
736
+ unsafe_parent = unsafe_dest / "process-evidence"
737
+ unsafe_parent.symlink_to(escape, target_is_directory=True)
738
+ try:
739
+ move_artifacts(devlyn, unsafe_dest)
740
+ except ArchiveError as exc:
741
+ assert "archive destination parent is unsafe" in str(exc)
742
+ else:
743
+ raise AssertionError("archive followed a symlinked destination parent")
744
+ unsafe_parent.unlink()
745
+ unsafe_dest.rmdir()
198
746
  moved = move_artifacts(devlyn, devlyn / "runs" / run_id)
199
747
  assert moved >= 12, moved
200
748
  for name in (
@@ -207,18 +755,138 @@ def self_test() -> int:
207
755
  "surface-close.stdout",
208
756
  "implement.worker-session.0.jsonl",
209
757
  "surface-close.worker-session.0.jsonl",
758
+ "build_gate.prompt.1",
759
+ "build_gate.worker-session.1.jsonl",
760
+ "build_gate.invocation.1.json",
761
+ "build_gate.prompt.2",
762
+ "build_gate.worker-session.2.jsonl",
210
763
  "cleanup.worker-session.1.jsonl",
764
+ "build_gate.invocation.2.json",
765
+ "plan.prompt.0",
766
+ "plan.worker-session.0.jsonl",
767
+ "plan.invocation.0.json",
211
768
  "probes/P1.py",
212
769
  "verify.pair.findings.jsonl",
213
770
  "verify-merge.summary.json",
771
+ "untracked.baseline",
772
+ "external-diff.patch",
773
+ "plan.task-context",
774
+ "plan.prompt",
775
+ "probe-derive.stdout",
776
+ "probe-derive.stderr",
777
+ "verify.primary.timeout.json",
778
+ "verify.pair.timeout.json",
214
779
  "codex-judge.stdout",
215
780
  "codex-judge.summary.json",
216
781
  "claude-judge.stdout",
217
782
  "claude-judge.stderr",
783
+ "implement.task-context",
784
+ "implement.prompt",
785
+ "implement.stdout",
786
+ "implement.stderr",
787
+ "implement.events.jsonl",
788
+ "implement.retry.1.stdout",
789
+ f"process-evidence/{run_id}/verify/round-2/manifest.json",
790
+ f"process-evidence/{run_id}/verify/round-2/mechanical-0.stdout",
791
+ f"process-evidence/{run_id}/verify/round-2/mechanical-0.stderr",
218
792
  ):
219
793
  assert (devlyn / "runs" / run_id / name).is_file(), name
794
+ assert not (devlyn / name).exists(), name
220
795
  assert global_rollout.is_file(), "engine-global session files must stay untouched"
221
796
  assert not (devlyn / "runs" / run_id / global_rollout.name).exists()
797
+ assert unrelated.read_text(encoding="utf-8") == "preserve\n"
798
+ assert not (devlyn / "runs" / run_id / unrelated.name).exists()
799
+ assert not (devlyn / "process-evidence" / run_id).exists()
800
+
801
+ for label in ("digest", "missing", "unsafe", "unbound", "collision"):
802
+ isolated_work = pathlib.Path(tmp) / f"{label}-work"
803
+ isolated_devlyn = isolated_work / ".devlyn"
804
+ isolated_devlyn.mkdir(parents=True)
805
+ isolated_state = {
806
+ "run_id": f"run-{label}",
807
+ "phases": {"verify": {"round": 0}},
808
+ "process_evidence": None,
809
+ }
810
+ isolated_manifest_rel = runner["manifest_relative_path"](isolated_state, "verify")
811
+ runner["capture_process"](
812
+ isolated_work,
813
+ isolated_work / isolated_manifest_rel,
814
+ isolated_state["run_id"],
815
+ "verify",
816
+ 0,
817
+ obligation,
818
+ )
819
+ isolated_state["process_evidence"] = [runner["validate_manifest"](
820
+ isolated_work,
821
+ isolated_manifest_rel,
822
+ isolated_state["run_id"],
823
+ "verify",
824
+ 0,
825
+ [obligation],
826
+ require_expectations=False,
827
+ )]
828
+ (isolated_devlyn / "pipeline.state.json").write_text(
829
+ json.dumps(isolated_state) + "\n", encoding="utf-8",
830
+ )
831
+ target = isolated_devlyn / "runs" / isolated_state["run_id"]
832
+ if label == "digest":
833
+ stream = isolated_work / isolated_state["process_evidence"][0]["streams"][0]["stdout"]["path"]
834
+ stream.write_bytes(stream.read_bytes() + b"altered")
835
+ elif label == "missing":
836
+ stream = isolated_work / isolated_state["process_evidence"][0]["streams"][0]["stderr"]["path"]
837
+ stream.unlink()
838
+ elif label == "unsafe":
839
+ isolated_state["process_evidence"][0]["manifest"]["path"] = "../escape/manifest.json"
840
+ (isolated_devlyn / "pipeline.state.json").write_text(
841
+ json.dumps(isolated_state) + "\n", encoding="utf-8",
842
+ )
843
+ elif label == "unbound":
844
+ extra = isolated_devlyn / "process-evidence" / isolated_state["run_id"] / "verify" / "round-0" / "extra.stdout"
845
+ extra.write_text("not state-bound\n", encoding="utf-8")
846
+ else:
847
+ collision = target / pathlib.PurePosixPath(isolated_manifest_rel).relative_to(".devlyn")
848
+ collision.parent.mkdir(parents=True)
849
+ collision.write_text("occupied\n", encoding="utf-8")
850
+ before = {
851
+ str(path.relative_to(isolated_devlyn)): path.read_bytes()
852
+ for path in isolated_devlyn.rglob("*") if path.is_file()
853
+ }
854
+ try:
855
+ move_artifacts(isolated_devlyn, target)
856
+ except ArchiveError:
857
+ pass
858
+ else:
859
+ raise AssertionError(f"{label} archive violation was accepted")
860
+ after = {
861
+ str(path.relative_to(isolated_devlyn)): path.read_bytes()
862
+ for path in isolated_devlyn.rglob("*") if path.is_file()
863
+ }
864
+ assert after == before, label
865
+
866
+ null_states = {
867
+ "run-0-final-report-null": {"phases": {"final_report": None}},
868
+ "run-0-phases-null": {"phases": None},
869
+ "run-0-root-null": None,
870
+ }
871
+ for name, state in null_states.items():
872
+ null_run = devlyn / "runs" / name
873
+ null_run.mkdir()
874
+ (null_run / "pipeline.state.json").write_text(
875
+ json.dumps(state) + "\n",
876
+ encoding="utf-8",
877
+ )
878
+ newest_run = devlyn / "runs" / "run-2"
879
+ newest_run.mkdir()
880
+ (newest_run / "pipeline.state.json").write_text(
881
+ json.dumps({"run_id": "run-2", "phases": {"final_report": {
882
+ "verdict": "PASS", "completed_at": "2026-09-06T00:00:00.123Z",
883
+ }}}) + "\n",
884
+ encoding="utf-8",
885
+ )
886
+ assert prune(devlyn / "runs", keep=1) == 1
887
+ assert all((devlyn / "runs" / name).is_dir() for name in null_states)
888
+ assert not (devlyn / "runs" / run_id).exists(), "oldest completed archive must be pruned"
889
+ assert newest_run.is_dir(), "newest completed archive must remain"
222
890
 
223
891
  bad = pathlib.Path(tmp) / "bad"
224
892
  bad.mkdir()
@@ -229,6 +897,14 @@ def self_test() -> int:
229
897
  assert "run_id must match" in str(exc)
230
898
  else:
231
899
  raise AssertionError("unsafe archive run_id was accepted")
900
+ blocked_cli = subprocess.run(
901
+ [sys.executable, str(pathlib.Path(__file__).resolve()), "--devlyn-dir", str(bad)],
902
+ capture_output=True,
903
+ text=True,
904
+ )
905
+ assert blocked_cli.returncode == 1
906
+ assert "error: archive blocked:" in blocked_cli.stderr
907
+ assert "Traceback" not in blocked_cli.stderr
232
908
 
233
909
  nan = pathlib.Path(tmp) / "nan"
234
910
  nan.mkdir()
@@ -239,6 +915,7 @@ def self_test() -> int:
239
915
  assert "invalid JSON numeric constant: NaN" in str(exc)
240
916
  else:
241
917
  raise AssertionError("NaN archive run_id was accepted")
918
+ completion_self_test()
242
919
  return 0
243
920
 
244
921
 
@@ -256,10 +933,15 @@ def main() -> int:
256
933
  sys.stderr.write(f"error: {devlyn} is not a directory\n")
257
934
  return 1
258
935
 
259
- run_id = read_run_id(devlyn)
260
- dest = devlyn / "runs" / run_id
261
- moved = move_artifacts(devlyn, dest)
262
- pruned = prune(devlyn / "runs", keep=args.keep)
936
+ try:
937
+ state = read_state(devlyn)
938
+ run_id = state["run_id"]
939
+ dest = devlyn / "runs" / run_id
940
+ moved = move_artifacts(devlyn, dest)
941
+ pruned = prune(devlyn / "runs", keep=args.keep)
942
+ except (ArchiveError, OSError, UnicodeError, ValueError) as exc:
943
+ sys.stderr.write(f"error: archive blocked: {exc}\n")
944
+ return 1
263
945
 
264
946
  sys.stdout.write(f"archived run_id={run_id} files={moved} pruned={pruned}\n")
265
947
  return 0