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