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.
- package/AGENTS.md +10 -94
- package/CLAUDE.md +7 -160
- package/LICENSE +21 -0
- package/README.md +68 -111
- package/bin/devlyn.js +265 -259
- package/bin/instruction-templates.json +43 -2
- package/bin/instructions.js +29 -8
- package/bin/skill-history.json +140 -0
- package/bin/skill-ownership.js +67 -0
- package/config/skills/_shared/adapters/claude.md +8 -85
- package/config/skills/_shared/adapters/codex.md +14 -34
- package/config/skills/_shared/adapters/omp.md +1 -11
- package/config/skills/_shared/codex-monitored.sh +4 -54
- package/config/skills/_shared/engine-doctor.sh +18 -58
- package/config/skills/_shared/engine-preflight.md +15 -47
- package/config/skills/_shared/expected-contract.py +242 -0
- package/config/skills/_shared/expected.schema.json +10 -95
- package/config/skills/_shared/invocation-receipt.py +26 -757
- package/config/skills/_shared/platform-support.py +37 -28
- package/config/skills/_shared/role-config.py +100 -421
- package/config/skills/_shared/runtime-principles.md +17 -93
- package/config/skills/_shared/task-complete.py +490 -285
- package/config/skills/_shared/task-completion.md +235 -0
- package/config/skills/devlyn-engines/SKILL.md +9 -11
- package/config/skills/devlyn-ideate/SKILL.md +57 -107
- package/config/skills/devlyn-ideate/references/elicitation.md +18 -99
- package/config/skills/devlyn-ideate/references/loop.md +110 -0
- package/config/skills/devlyn-ideate/references/package-format.md +128 -0
- package/config/skills/devlyn-ideate/scripts/acceptance.py +795 -0
- package/config/skills/devlyn-ideate/scripts/queue.py +1780 -0
- package/node_modules/@inquirer/ansi/LICENSE +22 -0
- package/node_modules/@inquirer/ansi/README.md +121 -0
- package/node_modules/@inquirer/ansi/dist/index.d.ts +14 -0
- package/node_modules/@inquirer/ansi/dist/index.js +21 -0
- package/node_modules/@inquirer/ansi/package.json +79 -0
- package/node_modules/@inquirer/checkbox/LICENSE +22 -0
- package/node_modules/@inquirer/checkbox/README.md +265 -0
- package/node_modules/@inquirer/checkbox/dist/index.d.ts +67 -0
- package/node_modules/@inquirer/checkbox/dist/index.js +210 -0
- package/node_modules/@inquirer/checkbox/package.json +93 -0
- package/node_modules/@inquirer/core/LICENSE +22 -0
- package/node_modules/@inquirer/core/README.md +424 -0
- package/node_modules/@inquirer/core/dist/index.d.ts +13 -0
- package/node_modules/@inquirer/core/dist/index.js +12 -0
- package/node_modules/@inquirer/core/dist/lib/Separator.d.ts +10 -0
- package/node_modules/@inquirer/core/dist/lib/Separator.js +21 -0
- package/node_modules/@inquirer/core/dist/lib/__probe/p1.d.ts +9 -0
- package/node_modules/@inquirer/core/dist/lib/__probe/p1.js +4 -0
- package/node_modules/@inquirer/core/dist/lib/__probe/p2.d.ts +9 -0
- package/node_modules/@inquirer/core/dist/lib/__probe/p2.js +3 -0
- package/node_modules/@inquirer/core/dist/lib/__probe/p3.d.ts +9 -0
- package/node_modules/@inquirer/core/dist/lib/__probe/p3.js +5 -0
- package/node_modules/@inquirer/core/dist/lib/create-prompt.d.ts +11 -0
- package/node_modules/@inquirer/core/dist/lib/create-prompt.js +185 -0
- package/node_modules/@inquirer/core/dist/lib/errors.d.ts +20 -0
- package/node_modules/@inquirer/core/dist/lib/errors.js +21 -0
- package/node_modules/@inquirer/core/dist/lib/hook-engine.d.ts +17 -0
- package/node_modules/@inquirer/core/dist/lib/hook-engine.js +109 -0
- package/node_modules/@inquirer/core/dist/lib/key.d.ts +15 -0
- package/node_modules/@inquirer/core/dist/lib/key.js +34 -0
- package/node_modules/@inquirer/core/dist/lib/make-theme.d.ts +3 -0
- package/node_modules/@inquirer/core/dist/lib/make-theme.js +33 -0
- package/node_modules/@inquirer/core/dist/lib/pagination/use-pagination.d.ts +15 -0
- package/node_modules/@inquirer/core/dist/lib/pagination/use-pagination.js +121 -0
- package/node_modules/@inquirer/core/dist/lib/screen-manager.d.ts +14 -0
- package/node_modules/@inquirer/core/dist/lib/screen-manager.js +84 -0
- package/node_modules/@inquirer/core/dist/lib/theme.d.ts +166 -0
- package/node_modules/@inquirer/core/dist/lib/theme.js +29 -0
- package/node_modules/@inquirer/core/dist/lib/use-effect.d.ts +2 -0
- package/node_modules/@inquirer/core/dist/lib/use-effect.js +11 -0
- package/node_modules/@inquirer/core/dist/lib/use-keypress.d.ts +3 -0
- package/node_modules/@inquirer/core/dist/lib/use-keypress.js +20 -0
- package/node_modules/@inquirer/core/dist/lib/use-memo.d.ts +1 -0
- package/node_modules/@inquirer/core/dist/lib/use-memo.js +14 -0
- package/node_modules/@inquirer/core/dist/lib/use-prefix.d.ts +5 -0
- package/node_modules/@inquirer/core/dist/lib/use-prefix.js +35 -0
- package/node_modules/@inquirer/core/dist/lib/use-ref.d.ts +6 -0
- package/node_modules/@inquirer/core/dist/lib/use-ref.js +4 -0
- package/node_modules/@inquirer/core/dist/lib/use-state.d.ts +7 -0
- package/node_modules/@inquirer/core/dist/lib/use-state.js +28 -0
- package/node_modules/@inquirer/core/dist/lib/utils.d.ts +13 -0
- package/node_modules/@inquirer/core/dist/lib/utils.js +25 -0
- package/node_modules/@inquirer/core/package.json +98 -0
- package/node_modules/@inquirer/figures/LICENSE +22 -0
- package/node_modules/@inquirer/figures/dist/index.d.ts +275 -0
- package/node_modules/@inquirer/figures/dist/index.js +315 -0
- package/node_modules/@inquirer/figures/package.json +81 -0
- package/node_modules/@inquirer/select/LICENSE +22 -0
- package/node_modules/@inquirer/select/README.md +258 -0
- package/node_modules/@inquirer/select/dist/index.d.ts +42 -0
- package/node_modules/@inquirer/select/dist/index.js +191 -0
- package/node_modules/@inquirer/select/package.json +93 -0
- package/node_modules/@inquirer/type/LICENSE +22 -0
- package/node_modules/@inquirer/type/dist/index.d.ts +2 -0
- package/node_modules/@inquirer/type/dist/index.js +2 -0
- package/node_modules/@inquirer/type/dist/inquirer.d.ts +35 -0
- package/node_modules/@inquirer/type/dist/inquirer.js +1 -0
- package/node_modules/@inquirer/type/dist/utils.d.ts +45 -0
- package/node_modules/@inquirer/type/dist/utils.js +16 -0
- package/node_modules/@inquirer/type/package.json +88 -0
- package/node_modules/cli-width/LICENSE +13 -0
- package/node_modules/cli-width/README.md +71 -0
- package/node_modules/cli-width/index.d.ts +13 -0
- package/node_modules/cli-width/index.js +49 -0
- package/node_modules/cli-width/package.json +40 -0
- package/node_modules/fast-string-truncated-width/dist/index.d.ts +4 -0
- package/node_modules/fast-string-truncated-width/dist/index.js +111 -0
- package/node_modules/fast-string-truncated-width/dist/types.d.ts +19 -0
- package/node_modules/fast-string-truncated-width/dist/types.js +2 -0
- package/node_modules/fast-string-truncated-width/dist/utils.d.ts +4 -0
- package/node_modules/fast-string-truncated-width/dist/utils.js +20 -0
- package/node_modules/fast-string-truncated-width/license +21 -0
- package/node_modules/fast-string-truncated-width/package.json +35 -0
- package/node_modules/fast-string-truncated-width/readme.md +59 -0
- package/node_modules/fast-string-width/dist/index.d.ts +4 -0
- package/node_modules/fast-string-width/dist/index.js +14 -0
- package/node_modules/fast-string-width/license +21 -0
- package/node_modules/fast-string-width/package.json +34 -0
- package/node_modules/fast-string-width/readme.md +42 -0
- package/node_modules/fast-wrap-ansi/LICENSE +23 -0
- package/node_modules/fast-wrap-ansi/README.md +26 -0
- package/node_modules/fast-wrap-ansi/lib/main.d.ts +6 -0
- package/node_modules/fast-wrap-ansi/lib/main.js +218 -0
- package/node_modules/fast-wrap-ansi/package.json +51 -0
- package/node_modules/mute-stream/LICENSE +15 -0
- package/node_modules/mute-stream/README.md +68 -0
- package/node_modules/mute-stream/lib/index.js +142 -0
- package/node_modules/mute-stream/package.json +54 -0
- package/node_modules/signal-exit/LICENSE.txt +16 -0
- package/node_modules/signal-exit/README.md +74 -0
- package/node_modules/signal-exit/dist/cjs/browser.d.ts +12 -0
- package/node_modules/signal-exit/dist/cjs/browser.d.ts.map +1 -0
- package/node_modules/signal-exit/dist/cjs/browser.js +10 -0
- package/node_modules/signal-exit/dist/cjs/browser.js.map +1 -0
- package/node_modules/signal-exit/dist/cjs/index.d.ts +48 -0
- package/node_modules/signal-exit/dist/cjs/index.d.ts.map +1 -0
- package/node_modules/signal-exit/dist/cjs/index.js +279 -0
- package/node_modules/signal-exit/dist/cjs/index.js.map +1 -0
- package/node_modules/signal-exit/dist/cjs/package.json +3 -0
- package/node_modules/signal-exit/dist/cjs/signals.d.ts +29 -0
- package/node_modules/signal-exit/dist/cjs/signals.d.ts.map +1 -0
- package/node_modules/signal-exit/dist/cjs/signals.js +42 -0
- package/node_modules/signal-exit/dist/cjs/signals.js.map +1 -0
- package/node_modules/signal-exit/dist/mjs/browser.d.ts +12 -0
- package/node_modules/signal-exit/dist/mjs/browser.d.ts.map +1 -0
- package/node_modules/signal-exit/dist/mjs/browser.js +4 -0
- package/node_modules/signal-exit/dist/mjs/browser.js.map +1 -0
- package/node_modules/signal-exit/dist/mjs/index.d.ts +48 -0
- package/node_modules/signal-exit/dist/mjs/index.d.ts.map +1 -0
- package/node_modules/signal-exit/dist/mjs/index.js +275 -0
- package/node_modules/signal-exit/dist/mjs/index.js.map +1 -0
- package/node_modules/signal-exit/dist/mjs/package.json +3 -0
- package/node_modules/signal-exit/dist/mjs/signals.d.ts +29 -0
- package/node_modules/signal-exit/dist/mjs/signals.d.ts.map +1 -0
- package/node_modules/signal-exit/dist/mjs/signals.js +39 -0
- package/node_modules/signal-exit/dist/mjs/signals.js.map +1 -0
- package/node_modules/signal-exit/package.json +106 -0
- package/package.json +17 -4
- package/config/commit-conventions.md +0 -28
- package/config/skills/_shared/adapters/README.md +0 -74
- package/config/skills/_shared/adapters/grok.md +0 -100
- package/config/skills/_shared/archive_run.py +0 -969
- package/config/skills/_shared/codex-config.md +0 -81
- package/config/skills/_shared/collect-codex-findings.py +0 -505
- package/config/skills/_shared/finish-gate.py +0 -521
- package/config/skills/_shared/grok-anchor-guard.py +0 -78
- package/config/skills/_shared/judge-output-parser.py +0 -200
- package/config/skills/_shared/judge-role-evidence.py +0 -320
- package/config/skills/_shared/phase-prompt-render.py +0 -183
- package/config/skills/_shared/process-evidence.py +0 -926
- package/config/skills/_shared/resolve-bootstrap.py +0 -1410
- package/config/skills/_shared/resolve-stop-hook.py +0 -359
- package/config/skills/_shared/run-bounded.py +0 -58
- package/config/skills/_shared/spec-verify-check.py +0 -5083
- package/config/skills/_shared/state-phase-write.py +0 -5989
- package/config/skills/_shared/terminal-claim-check.py +0 -777
- package/config/skills/_shared/verify-merge-findings.py +0 -3528
- package/config/skills/devlyn-design-ui/SKILL.md +0 -650
- package/config/skills/devlyn-ideate/references/from-spec-mode.md +0 -61
- package/config/skills/devlyn-ideate/references/project-mode.md +0 -80
- package/config/skills/devlyn-ideate/references/spec-template.md +0 -121
- package/config/skills/devlyn-ideate/references/templates/decision.md +0 -48
- package/config/skills/devlyn-ideate/references/templates/roadmap.md +0 -50
- package/config/skills/devlyn-ideate/references/templates/vision.md +0 -44
- package/config/skills/devlyn-queue/SKILL.md +0 -66
- package/config/skills/devlyn-queue/scripts/append.py +0 -94
- package/config/skills/devlyn-resolve/SKILL.md +0 -379
- package/config/skills/devlyn-resolve/references/free-form-mode.md +0 -71
- package/config/skills/devlyn-resolve/references/outer-loop.md +0 -23
- package/config/skills/devlyn-resolve/references/phases/build-gate.md +0 -75
- package/config/skills/devlyn-resolve/references/phases/cleanup.md +0 -33
- package/config/skills/devlyn-resolve/references/phases/implement.md +0 -44
- package/config/skills/devlyn-resolve/references/phases/plan.md +0 -39
- package/config/skills/devlyn-resolve/references/phases/probe-derive.md +0 -261
- package/config/skills/devlyn-resolve/references/phases/surface-close.md +0 -34
- package/config/skills/devlyn-resolve/references/phases/verify.md +0 -310
- package/config/skills/devlyn-resolve/references/state-schema.md +0 -152
- package/config/skills/devlyn-resolve/references/task-completion.md +0 -160
- package/config/templates/prompt-templates.md +0 -71
- package/config/templates/template-feature.spec.md +0 -255
- 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())
|