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