devlyn-cli 2.9.3 → 2.10.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 +3 -3
- package/CLAUDE.md +4 -4
- package/README.md +4 -4
- package/benchmark/auto-resolve/fixtures/SCHEMA.md +1 -1
- package/benchmark/auto-resolve/scripts/pair_evidence_contract.py +1 -0
- package/bin/devlyn.js +55 -7
- package/config/skills/_shared/adapters/README.md +2 -2
- package/config/skills/_shared/adapters/grok.md +77 -0
- package/config/skills/_shared/archive_run.py +35 -1
- package/config/skills/_shared/collect-codex-findings.py +15 -10
- package/config/skills/_shared/engine-doctor.sh +6 -3
- package/config/skills/_shared/engine-preflight.md +12 -5
- package/config/skills/_shared/resolve-bootstrap.py +630 -0
- package/config/skills/_shared/resolve-stop-hook.py +325 -0
- package/config/skills/_shared/state-phase-write.py +2264 -13
- package/config/skills/_shared/terminal-claim-check.py +537 -0
- package/config/skills/_shared/verify-merge-findings.py +363 -16
- package/config/skills/devlyn:engines/SKILL.md +1 -1
- package/config/skills/devlyn:queue/SKILL.md +12 -0
- package/config/skills/devlyn:resolve/SKILL.md +54 -73
- package/config/skills/devlyn:resolve/references/phases/surface-close.md +34 -0
- package/config/skills/devlyn:resolve/references/phases/verify.md +65 -96
- package/config/skills/devlyn:resolve/references/state-schema.md +25 -18
- package/optional-skills/devlyn:reap/SKILL.md +1 -1
- package/optional-skills/devlyn:reap/scripts/reap.sh +25 -13
- package/optional-skills/devlyn:reap/scripts/scan.sh +15 -1
- package/package.json +1 -1
- package/scripts/lint-skills.sh +131 -67
|
@@ -4,8 +4,13 @@
|
|
|
4
4
|
Usage:
|
|
5
5
|
python3 state-phase-write.py --devlyn-dir .devlyn --phase implement spawn \
|
|
6
6
|
--round 1 --triggered-by verify [--pre-sha <sha>] [--engine claude] [--model <id>]
|
|
7
|
+
python3 state-phase-write.py --devlyn-dir .devlyn --phase implement durability-enforce \
|
|
8
|
+
--round 1 --origin-phase verify
|
|
7
9
|
python3 state-phase-write.py --devlyn-dir .devlyn --phase implement complete \
|
|
8
|
-
--verdict PASS [--post-sha <sha>] [--findings-file <path>] [--log-file <path>]
|
|
10
|
+
--verdict PASS [--post-sha <sha>] [--findings-file <path>] [--log-file <path>] \
|
|
11
|
+
[--engine claude] [--model <requested-id>] [--engine-session-log <path>]
|
|
12
|
+
python3 state-phase-write.py --devlyn-dir .devlyn --phase plan transition \
|
|
13
|
+
--verdict PASS --next-phase implement --next-round 0 --next-engine claude
|
|
9
14
|
|
|
10
15
|
references/state-schema.md#write-protocol is the contract this implements.
|
|
11
16
|
A prior hand-edited fix-loop respawn left `started_at` at its original round's
|
|
@@ -18,15 +23,51 @@ drift apart again.
|
|
|
18
23
|
from __future__ import annotations
|
|
19
24
|
|
|
20
25
|
import argparse
|
|
26
|
+
import copy
|
|
21
27
|
import datetime
|
|
28
|
+
import difflib
|
|
29
|
+
import hashlib
|
|
22
30
|
import json
|
|
31
|
+
import os
|
|
23
32
|
import pathlib
|
|
33
|
+
import re
|
|
34
|
+
import shutil
|
|
35
|
+
import subprocess
|
|
24
36
|
import sys
|
|
25
37
|
import tempfile
|
|
26
38
|
|
|
27
39
|
VALID_VERDICTS = {"PASS", "PASS_WITH_ISSUES", "FAIL", "NEEDS_WORK", "BLOCKED"}
|
|
28
40
|
VALID_TRIGGERS = {"build_gate", "verify"}
|
|
29
|
-
PHASE_NAMES = {"plan", "probe_derive", "implement", "build_gate", "cleanup", "verify", "final_report"}
|
|
41
|
+
PHASE_NAMES = {"plan", "probe_derive", "implement", "surface_close", "build_gate", "cleanup", "verify", "final_report"}
|
|
42
|
+
LEGAL_TRANSITIONS = {
|
|
43
|
+
"plan": {"probe_derive", "implement", "final_report"},
|
|
44
|
+
"probe_derive": {"implement", "final_report"},
|
|
45
|
+
"implement": {"implement", "surface_close", "build_gate", "cleanup", "verify", "final_report"},
|
|
46
|
+
"surface_close": {"build_gate", "cleanup", "verify", "final_report"},
|
|
47
|
+
"build_gate": {"implement", "cleanup", "verify", "final_report"},
|
|
48
|
+
"cleanup": {"verify", "final_report"},
|
|
49
|
+
"verify": {"implement", "final_report"},
|
|
50
|
+
"final_report": set(),
|
|
51
|
+
}
|
|
52
|
+
WORKER_SESSION_ARTIFACT_PHASES = {
|
|
53
|
+
"implement": "implement",
|
|
54
|
+
"surface_close": "surface-close",
|
|
55
|
+
"cleanup": "cleanup",
|
|
56
|
+
}
|
|
57
|
+
MODEL_HEADER_RE = re.compile(r"(?m)^[ \t]*model:[ \t]*(\S+)[ \t]*$")
|
|
58
|
+
SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
|
|
59
|
+
SURFACE_ROW_RE = re.compile(
|
|
60
|
+
r"^(?P<obligation>UVR-STALE|PATH-TEST): (?:"
|
|
61
|
+
r"(?P<fired>FIRED) (?P<fired_path>.+):(?P<fired_line>[1-9][0-9]*)"
|
|
62
|
+
r"(?: — (?P<fired_evidence>\S.*))?|"
|
|
63
|
+
r"(?P<na>N/A) (?P<na_path>.+?)(?::(?P<na_line>[1-9][0-9]*))?"
|
|
64
|
+
r"(?: — (?P<na_evidence>\S.*))?)$"
|
|
65
|
+
)
|
|
66
|
+
VALIDATION_EXECUTION_RE = re.compile(
|
|
67
|
+
r"npm\s+test|node\s+--test|node\s+-e|node\s+bin/|node\s+tests/|git\s+stash"
|
|
68
|
+
)
|
|
69
|
+
SURFACE_SKIP_REASON = "auto_surface_close_claude_unavailable"
|
|
70
|
+
SURFACE_RECOVERY_REASON = "surface_close_rolled_back_adjudication_malformed"
|
|
30
71
|
|
|
31
72
|
|
|
32
73
|
def reject_json_constant(token: str) -> None:
|
|
@@ -79,6 +120,1005 @@ def write_state(state_path: pathlib.Path, state: dict) -> None:
|
|
|
79
120
|
raise
|
|
80
121
|
|
|
81
122
|
|
|
123
|
+
def parse_string_list(raw: str, label: str) -> list[str]:
|
|
124
|
+
try:
|
|
125
|
+
value = loads_strict_json(raw)
|
|
126
|
+
except ValueError as exc:
|
|
127
|
+
raise SystemExit(f"error: {label} is not valid JSON: {exc}") from exc
|
|
128
|
+
if not isinstance(value, list) or any(not isinstance(item, str) for item in value):
|
|
129
|
+
raise SystemExit(f"error: {label} must be a JSON array of strings")
|
|
130
|
+
return value
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def run_git_paths(work: pathlib.Path, *args: str) -> list[str]:
|
|
134
|
+
proc = subprocess.run(
|
|
135
|
+
["git", *args], cwd=work, capture_output=True, check=False,
|
|
136
|
+
)
|
|
137
|
+
if proc.returncode != 0:
|
|
138
|
+
detail = os.fsdecode(proc.stderr or proc.stdout).strip() or "git command failed"
|
|
139
|
+
raise SystemExit(f"error: {detail}")
|
|
140
|
+
return [os.fsdecode(item) for item in proc.stdout.split(b"\0") if item]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def surface_entry(state: dict) -> dict:
|
|
144
|
+
entry = (state.get("phases") or {}).get("surface_close")
|
|
145
|
+
if not isinstance(entry, dict) or not entry.get("started_at"):
|
|
146
|
+
raise SystemExit("error: phases.surface_close was never spawned")
|
|
147
|
+
pre_sha = entry.get("pre_sha")
|
|
148
|
+
patch_digest = entry.get("input_patch_sha256")
|
|
149
|
+
prompt_digest = entry.get("prompt_sha256")
|
|
150
|
+
baseline = entry.get("untracked_before")
|
|
151
|
+
if not isinstance(pre_sha, str) or not pre_sha:
|
|
152
|
+
raise SystemExit("error: phases.surface_close.pre_sha is missing")
|
|
153
|
+
if not isinstance(patch_digest, str) or not SHA256_RE.fullmatch(patch_digest):
|
|
154
|
+
raise SystemExit("error: phases.surface_close.input_patch_sha256 must be 64 lowercase hex characters")
|
|
155
|
+
if not isinstance(prompt_digest, str) or not SHA256_RE.fullmatch(prompt_digest):
|
|
156
|
+
raise SystemExit("error: phases.surface_close.prompt_sha256 must be 64 lowercase hex characters")
|
|
157
|
+
if not isinstance(baseline, list) or any(not isinstance(item, str) for item in baseline):
|
|
158
|
+
raise SystemExit("error: phases.surface_close.untracked_before must be a string array")
|
|
159
|
+
return entry
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def devlyn_prefix(work: pathlib.Path, devlyn: pathlib.Path) -> str:
|
|
163
|
+
try:
|
|
164
|
+
return devlyn.resolve().relative_to(work.resolve()).as_posix().strip("/")
|
|
165
|
+
except ValueError as exc:
|
|
166
|
+
raise SystemExit("error: --devlyn-dir must be inside --workdir") from exc
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def file_sha256(path: pathlib.Path) -> str:
|
|
170
|
+
digest = hashlib.sha256()
|
|
171
|
+
try:
|
|
172
|
+
with path.open("rb") as handle:
|
|
173
|
+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
174
|
+
digest.update(chunk)
|
|
175
|
+
except OSError as exc:
|
|
176
|
+
raise SystemExit(f"BLOCKED:surface-close-input-mismatch: {path}: {exc}") from exc
|
|
177
|
+
return digest.hexdigest()
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def validate_surface_inputs(work: pathlib.Path, devlyn: pathlib.Path, state: dict) -> None:
|
|
181
|
+
entry = surface_entry(state)
|
|
182
|
+
source = state.get("source")
|
|
183
|
+
if not isinstance(source, dict):
|
|
184
|
+
raise SystemExit("BLOCKED:surface-close-input-mismatch: source is missing")
|
|
185
|
+
goal_path = source.get("goal_path")
|
|
186
|
+
goal_digest = source.get("goal_sha256")
|
|
187
|
+
if not isinstance(goal_path, str) or not goal_path or not isinstance(goal_digest, str):
|
|
188
|
+
raise SystemExit("BLOCKED:surface-close-input-mismatch: Goal metadata is missing")
|
|
189
|
+
goal = work / goal_path
|
|
190
|
+
try:
|
|
191
|
+
goal.resolve().relative_to(work.resolve())
|
|
192
|
+
except (OSError, ValueError) as exc:
|
|
193
|
+
raise SystemExit("BLOCKED:surface-close-input-mismatch: Goal path escapes worktree") from exc
|
|
194
|
+
patch = devlyn / "surface-close.input.patch"
|
|
195
|
+
if file_sha256(goal) != goal_digest or file_sha256(patch) != entry["input_patch_sha256"]:
|
|
196
|
+
raise SystemExit("BLOCKED:surface-close-input-mismatch: artifact digest changed")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def validate_surface_prompt(devlyn: pathlib.Path, state: dict) -> None:
|
|
200
|
+
prompt = devlyn / "surface-close.prompt"
|
|
201
|
+
if file_sha256(prompt) != surface_entry(state)["prompt_sha256"]:
|
|
202
|
+
raise SystemExit("BLOCKED:surface-close-prompt-mismatch")
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def surface_delta_paths(work: pathlib.Path, devlyn: pathlib.Path, state: dict) -> tuple[list[str], list[str]]:
|
|
206
|
+
entry = surface_entry(state)
|
|
207
|
+
pre_sha = entry["pre_sha"]
|
|
208
|
+
prefix = devlyn_prefix(work, devlyn)
|
|
209
|
+
tracked = set(run_git_paths(work, "diff", "--name-only", "-z", pre_sha, "--"))
|
|
210
|
+
untracked_now = set(run_git_paths(work, "ls-files", "--others", "--exclude-standard", "-z"))
|
|
211
|
+
new_untracked = untracked_now - set(entry["untracked_before"])
|
|
212
|
+
|
|
213
|
+
def external(path: str) -> bool:
|
|
214
|
+
return bool(path) and path != prefix and not path.startswith(f"{prefix}/")
|
|
215
|
+
|
|
216
|
+
return (
|
|
217
|
+
sorted(path for path in tracked if external(path)),
|
|
218
|
+
sorted(path for path in new_untracked if external(path)),
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def ensure_surface_clean_baseline(work: pathlib.Path, devlyn: pathlib.Path, state: dict) -> None:
|
|
223
|
+
tracked, new_untracked = surface_delta_paths(work, devlyn, state)
|
|
224
|
+
if tracked or new_untracked:
|
|
225
|
+
detail = json.dumps(sorted(set(tracked + new_untracked)))
|
|
226
|
+
raise SystemExit(f"BLOCKED:surface-close-preexisting-delta: {detail}")
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def validate_authorized_surface(raw: str) -> list[str]:
|
|
230
|
+
surface = parse_string_list(raw, "--authorized-surface-json")
|
|
231
|
+
if not surface:
|
|
232
|
+
raise SystemExit("error: --authorized-surface-json must not be empty")
|
|
233
|
+
for entry in surface:
|
|
234
|
+
path = entry[:-3] if entry.endswith("/**") else entry
|
|
235
|
+
parts = pathlib.PurePosixPath(path).parts
|
|
236
|
+
if (
|
|
237
|
+
not path or path == "." or path.startswith("./")
|
|
238
|
+
or pathlib.PurePosixPath(path).is_absolute() or ".." in parts
|
|
239
|
+
):
|
|
240
|
+
raise SystemExit(f"error: invalid authorized_surface entry: {entry!r}")
|
|
241
|
+
return surface
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def path_matches_surface(path: str, surface: list[str]) -> bool:
|
|
245
|
+
for entry in surface:
|
|
246
|
+
if entry.endswith("/**"):
|
|
247
|
+
prefix = entry[:-3].rstrip("/")
|
|
248
|
+
if path == prefix or path.startswith(f"{prefix}/"):
|
|
249
|
+
return True
|
|
250
|
+
elif path == entry:
|
|
251
|
+
return True
|
|
252
|
+
return False
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def surface_offenders(work: pathlib.Path, devlyn: pathlib.Path, state: dict,
|
|
256
|
+
surface: list[str]) -> list[str]:
|
|
257
|
+
tracked, untracked = surface_delta_paths(work, devlyn, state)
|
|
258
|
+
return sorted(path for path in set(tracked + untracked) if not path_matches_surface(path, surface))
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def path_exists_at_commit(work: pathlib.Path, sha: str, path: str) -> bool:
|
|
262
|
+
proc = subprocess.run(
|
|
263
|
+
["git", "ls-tree", "--name-only", "-z", sha, "--", path],
|
|
264
|
+
cwd=work, capture_output=True, check=False,
|
|
265
|
+
)
|
|
266
|
+
if proc.returncode != 0:
|
|
267
|
+
detail = os.fsdecode(proc.stderr or proc.stdout).strip() or "git ls-tree failed"
|
|
268
|
+
raise SystemExit(f"error: {detail}")
|
|
269
|
+
return bool(proc.stdout)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def remove_worktree_path(work: pathlib.Path, path: str) -> None:
|
|
273
|
+
parsed = pathlib.PurePosixPath(path)
|
|
274
|
+
if parsed.is_absolute() or ".." in parsed.parts:
|
|
275
|
+
raise SystemExit(f"error: rollback path escapes worktree: {path!r}")
|
|
276
|
+
target = work / path
|
|
277
|
+
if target.is_symlink() or target.is_file():
|
|
278
|
+
target.unlink()
|
|
279
|
+
elif target.is_dir():
|
|
280
|
+
shutil.rmtree(target)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def rollback_surface_delta(work: pathlib.Path, devlyn: pathlib.Path, state: dict) -> list[str]:
|
|
284
|
+
entry = surface_entry(state)
|
|
285
|
+
pre_sha = entry["pre_sha"]
|
|
286
|
+
tracked, untracked = surface_delta_paths(work, devlyn, state)
|
|
287
|
+
restore = [path for path in tracked if path_exists_at_commit(work, pre_sha, path)]
|
|
288
|
+
remove = sorted(set(untracked + [
|
|
289
|
+
path for path in tracked if not path_exists_at_commit(work, pre_sha, path)
|
|
290
|
+
]))
|
|
291
|
+
if restore:
|
|
292
|
+
proc = subprocess.run(
|
|
293
|
+
["git", "restore", f"--source={pre_sha}", "--staged", "--worktree", "--", *restore],
|
|
294
|
+
cwd=work, capture_output=True, check=False,
|
|
295
|
+
)
|
|
296
|
+
if proc.returncode != 0:
|
|
297
|
+
detail = os.fsdecode(proc.stderr or proc.stdout).strip() or "git restore failed"
|
|
298
|
+
raise SystemExit(f"error: {detail}")
|
|
299
|
+
if remove:
|
|
300
|
+
proc = subprocess.run(
|
|
301
|
+
["git", "rm", "-f", "--cached", "--ignore-unmatch", "--", *remove],
|
|
302
|
+
cwd=work, capture_output=True, check=False,
|
|
303
|
+
)
|
|
304
|
+
if proc.returncode != 0:
|
|
305
|
+
detail = os.fsdecode(proc.stderr or proc.stdout).strip() or "git rm --cached failed"
|
|
306
|
+
raise SystemExit(f"error: {detail}")
|
|
307
|
+
for path in remove:
|
|
308
|
+
remove_worktree_path(work, path)
|
|
309
|
+
return sorted(set(restore + remove))
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def validate_surface_adjudication(
|
|
313
|
+
work: pathlib.Path, devlyn: pathlib.Path, state: dict, surface: list[str],
|
|
314
|
+
) -> dict[str, str]:
|
|
315
|
+
output = devlyn / "surface-close.stdout"
|
|
316
|
+
try:
|
|
317
|
+
lines = output.read_text(encoding="utf-8").splitlines()
|
|
318
|
+
except (OSError, UnicodeError) as exc:
|
|
319
|
+
raise SystemExit(f"BLOCKED:surface-close-adjudication-malformed: {output}: {exc}") from exc
|
|
320
|
+
|
|
321
|
+
rows: dict[str, tuple[str, str, int | None, str | None, int]] = {}
|
|
322
|
+
for index, line in enumerate(lines):
|
|
323
|
+
if "UVR-STALE:" not in line and "PATH-TEST:" not in line:
|
|
324
|
+
continue
|
|
325
|
+
match = SURFACE_ROW_RE.fullmatch(line)
|
|
326
|
+
if match is None:
|
|
327
|
+
raise SystemExit(
|
|
328
|
+
f"BLOCKED:surface-close-adjudication-malformed: line {index + 1}: {line!r}"
|
|
329
|
+
)
|
|
330
|
+
obligation = match.group("obligation")
|
|
331
|
+
if obligation in rows:
|
|
332
|
+
raise SystemExit(
|
|
333
|
+
f"BLOCKED:surface-close-adjudication-malformed: duplicate {obligation} row"
|
|
334
|
+
)
|
|
335
|
+
status = "FIRED" if match.group("fired") else "N/A"
|
|
336
|
+
evidence = match.group("fired_evidence") or match.group("na_evidence")
|
|
337
|
+
if status == "N/A" and evidence is None:
|
|
338
|
+
raise SystemExit(
|
|
339
|
+
f"BLOCKED:surface-close-adjudication-malformed: {obligation} N/A requires evidence"
|
|
340
|
+
)
|
|
341
|
+
path = match.group("fired_path") or match.group("na_path")
|
|
342
|
+
raw_line = match.group("fired_line") or match.group("na_line")
|
|
343
|
+
line_number = int(raw_line) if raw_line is not None else None
|
|
344
|
+
citation = path if line_number is None else f"{path}:{line_number}"
|
|
345
|
+
if not path_matches_surface(path, surface):
|
|
346
|
+
raise SystemExit(
|
|
347
|
+
f"BLOCKED:surface-close-adjudication-out-of-surface: {citation}"
|
|
348
|
+
)
|
|
349
|
+
cited = work / path
|
|
350
|
+
try:
|
|
351
|
+
cited_lines = cited.read_text(encoding="utf-8").splitlines()
|
|
352
|
+
except (OSError, UnicodeError) as exc:
|
|
353
|
+
raise SystemExit(
|
|
354
|
+
f"BLOCKED:surface-close-adjudication-citation-missing: {citation}: {exc}"
|
|
355
|
+
) from exc
|
|
356
|
+
if line_number is not None and line_number > len(cited_lines):
|
|
357
|
+
raise SystemExit(
|
|
358
|
+
f"BLOCKED:surface-close-adjudication-citation-missing: {path}:{line_number}"
|
|
359
|
+
)
|
|
360
|
+
rows[obligation] = (status, path, line_number, evidence, index)
|
|
361
|
+
|
|
362
|
+
missing = [name for name in ("UVR-STALE", "PATH-TEST") if name not in rows]
|
|
363
|
+
if missing:
|
|
364
|
+
raise SystemExit(
|
|
365
|
+
"BLOCKED:surface-close-adjudication-malformed: missing " + ", ".join(missing)
|
|
366
|
+
)
|
|
367
|
+
pass_lines = [index for index, line in enumerate(lines) if line == "PASS"]
|
|
368
|
+
if len(pass_lines) != 1 or pass_lines[0] <= max(row[4] for row in rows.values()):
|
|
369
|
+
raise SystemExit(
|
|
370
|
+
"BLOCKED:surface-close-adjudication-malformed: exactly one PASS must follow both rows"
|
|
371
|
+
)
|
|
372
|
+
statuses = {name: row[0] for name, row in rows.items()}
|
|
373
|
+
if all(status == "N/A" for status in statuses.values()):
|
|
374
|
+
tracked, untracked = surface_delta_paths(work, devlyn, state)
|
|
375
|
+
if tracked or untracked:
|
|
376
|
+
raise SystemExit("BLOCKED:surface-close-empty-pass-has-delta")
|
|
377
|
+
return statuses
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def surface_transcript_commands(transcript: pathlib.Path) -> list[str]:
|
|
381
|
+
try:
|
|
382
|
+
lines = transcript.read_text(encoding="utf-8").splitlines()
|
|
383
|
+
except (OSError, UnicodeError) as exc:
|
|
384
|
+
raise SystemExit(f"BLOCKED:surface-close-worker-session-invalid: {transcript}: {exc}") from exc
|
|
385
|
+
commands: list[str] = []
|
|
386
|
+
for line_number, line in enumerate(lines, start=1):
|
|
387
|
+
if not line.strip():
|
|
388
|
+
continue
|
|
389
|
+
try:
|
|
390
|
+
event = loads_strict_json(line)
|
|
391
|
+
except ValueError as exc:
|
|
392
|
+
raise SystemExit(
|
|
393
|
+
f"BLOCKED:surface-close-worker-session-invalid: line {line_number}: {exc}"
|
|
394
|
+
) from exc
|
|
395
|
+
if not isinstance(event, dict):
|
|
396
|
+
continue
|
|
397
|
+
message = event.get("message")
|
|
398
|
+
content = message.get("content") if isinstance(message, dict) else None
|
|
399
|
+
if isinstance(content, list):
|
|
400
|
+
for item in content:
|
|
401
|
+
if not isinstance(item, dict) or item.get("type") != "tool_use":
|
|
402
|
+
continue
|
|
403
|
+
if item.get("name") != "Bash":
|
|
404
|
+
continue
|
|
405
|
+
tool_input = item.get("input")
|
|
406
|
+
command = tool_input.get("command") if isinstance(tool_input, dict) else None
|
|
407
|
+
if isinstance(command, str):
|
|
408
|
+
commands.append(command)
|
|
409
|
+
return commands
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def validate_surface_execution(devlyn: pathlib.Path, state: dict) -> None:
|
|
413
|
+
entry = surface_entry(state)
|
|
414
|
+
transcript = devlyn / f"surface-close.worker-session.{entry.get('round')}.jsonl"
|
|
415
|
+
commands = surface_transcript_commands(transcript)
|
|
416
|
+
hits = [command for command in commands if VALIDATION_EXECUTION_RE.search(command)]
|
|
417
|
+
if hits:
|
|
418
|
+
raise SystemExit(
|
|
419
|
+
"BLOCKED:surface-close-validation-execution: " + json.dumps(hits)
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def validate_surface_write_audit(
|
|
424
|
+
work: pathlib.Path, devlyn: pathlib.Path, state: dict, surface: list[str],
|
|
425
|
+
) -> list[str]:
|
|
426
|
+
entry = surface_entry(state)
|
|
427
|
+
transcript = devlyn / f"surface-close.worker-session.{entry.get('round')}.jsonl"
|
|
428
|
+
try:
|
|
429
|
+
lines = transcript.read_text(encoding="utf-8").splitlines()
|
|
430
|
+
except (OSError, UnicodeError) as exc:
|
|
431
|
+
raise SystemExit(f"BLOCKED:surface-close-worker-session-invalid: {transcript}: {exc}") from exc
|
|
432
|
+
targets: list[str] = []
|
|
433
|
+
for line_number, line in enumerate(lines, start=1):
|
|
434
|
+
if not line.strip():
|
|
435
|
+
continue
|
|
436
|
+
try:
|
|
437
|
+
event = loads_strict_json(line)
|
|
438
|
+
except ValueError as exc:
|
|
439
|
+
raise SystemExit(
|
|
440
|
+
f"BLOCKED:surface-close-worker-session-invalid: line {line_number}: {exc}"
|
|
441
|
+
) from exc
|
|
442
|
+
message = event.get("message") if isinstance(event, dict) else None
|
|
443
|
+
content = message.get("content") if isinstance(message, dict) else None
|
|
444
|
+
if not isinstance(content, list):
|
|
445
|
+
continue
|
|
446
|
+
for item in content:
|
|
447
|
+
if not isinstance(item, dict) or item.get("type") != "tool_use":
|
|
448
|
+
continue
|
|
449
|
+
if item.get("name") not in {"Edit", "Write"}:
|
|
450
|
+
continue
|
|
451
|
+
tool_input = item.get("input")
|
|
452
|
+
raw_target = tool_input.get("file_path") if isinstance(tool_input, dict) else None
|
|
453
|
+
if not isinstance(raw_target, str) or not raw_target:
|
|
454
|
+
raise SystemExit(
|
|
455
|
+
"BLOCKED:surface-close-write-audit-violation: "
|
|
456
|
+
f"line {line_number}: Edit/Write target missing"
|
|
457
|
+
)
|
|
458
|
+
target = pathlib.Path(raw_target)
|
|
459
|
+
try:
|
|
460
|
+
resolved = (target if target.is_absolute() else work / target).resolve()
|
|
461
|
+
relative = resolved.relative_to(work.resolve()).as_posix()
|
|
462
|
+
except (OSError, ValueError) as exc:
|
|
463
|
+
raise SystemExit(
|
|
464
|
+
"BLOCKED:surface-close-write-audit-violation: "
|
|
465
|
+
f"line {line_number}: {raw_target!r}"
|
|
466
|
+
) from exc
|
|
467
|
+
if not path_matches_surface(relative, surface):
|
|
468
|
+
raise SystemExit(
|
|
469
|
+
"BLOCKED:surface-close-write-audit-violation: "
|
|
470
|
+
f"line {line_number}: {relative!r}"
|
|
471
|
+
)
|
|
472
|
+
targets.append(relative)
|
|
473
|
+
return targets
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def require_surface_adjudication_malformed(
|
|
477
|
+
work: pathlib.Path, devlyn: pathlib.Path, state: dict, surface: list[str],
|
|
478
|
+
) -> None:
|
|
479
|
+
try:
|
|
480
|
+
validate_surface_adjudication(work, devlyn, state, surface)
|
|
481
|
+
except SystemExit as exc:
|
|
482
|
+
if str(exc).startswith("BLOCKED:surface-close-adjudication-malformed:"):
|
|
483
|
+
return
|
|
484
|
+
raise
|
|
485
|
+
raise SystemExit(
|
|
486
|
+
"error: surface-adjudication-recover requires "
|
|
487
|
+
"BLOCKED:surface-close-adjudication-malformed"
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def parse_effective_model(session_log: pathlib.Path) -> str:
|
|
492
|
+
try:
|
|
493
|
+
text = session_log.read_text(encoding="utf-8")
|
|
494
|
+
except (OSError, UnicodeError) as exc:
|
|
495
|
+
raise ValueError(f"cannot read engine session log {session_log}: {exc}") from exc
|
|
496
|
+
|
|
497
|
+
header = MODEL_HEADER_RE.search(text)
|
|
498
|
+
if header:
|
|
499
|
+
return header.group(1)
|
|
500
|
+
|
|
501
|
+
models: set[str] = set()
|
|
502
|
+
if session_log.suffix == ".jsonl":
|
|
503
|
+
for line_number, line in enumerate(text.splitlines(), start=1):
|
|
504
|
+
if not line.strip():
|
|
505
|
+
continue
|
|
506
|
+
try:
|
|
507
|
+
event = loads_strict_json(line)
|
|
508
|
+
except ValueError as exc:
|
|
509
|
+
raise ValueError(
|
|
510
|
+
f"engine session log {session_log} has malformed JSONL at line {line_number}: {exc}"
|
|
511
|
+
) from exc
|
|
512
|
+
if not isinstance(event, dict) or event.get("type") != "turn_context":
|
|
513
|
+
continue
|
|
514
|
+
payload = event.get("payload")
|
|
515
|
+
model = payload.get("model") if isinstance(payload, dict) else None
|
|
516
|
+
if isinstance(model, str) and model:
|
|
517
|
+
models.add(model)
|
|
518
|
+
else:
|
|
519
|
+
try:
|
|
520
|
+
evidence = loads_strict_json(text)
|
|
521
|
+
except ValueError:
|
|
522
|
+
evidence = None
|
|
523
|
+
model_usage = evidence.get("modelUsage") if isinstance(evidence, dict) else None
|
|
524
|
+
if isinstance(model_usage, dict):
|
|
525
|
+
models.update(model for model in model_usage if isinstance(model, str) and model)
|
|
526
|
+
|
|
527
|
+
if len(models) == 1:
|
|
528
|
+
return next(iter(models))
|
|
529
|
+
if not models:
|
|
530
|
+
raise ValueError(f"engine session log {session_log} has no effective-model evidence")
|
|
531
|
+
raise ValueError(
|
|
532
|
+
f"engine session log {session_log} has conflicting effective models: {sorted(models)}"
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _git_output(work: pathlib.Path, *args: str, input_bytes: bytes | None = None) -> bytes:
|
|
537
|
+
proc = subprocess.run(
|
|
538
|
+
["git", *args], cwd=work, input=input_bytes, capture_output=True, check=False,
|
|
539
|
+
)
|
|
540
|
+
if proc.returncode != 0:
|
|
541
|
+
detail = os.fsdecode(proc.stderr or proc.stdout).strip() or "git command failed"
|
|
542
|
+
raise SystemExit(f"BLOCKED:closure-durability-mechanical: {detail}")
|
|
543
|
+
return proc.stdout
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _commit_blob(work: pathlib.Path, sha: str, path: str) -> bytes | None:
|
|
547
|
+
if not path_exists_at_commit(work, sha, path):
|
|
548
|
+
return None
|
|
549
|
+
return _git_output(work, "show", f"{sha}:{path}")
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def _line_matches(haystack: list[bytes], needle: list[bytes]) -> list[int]:
|
|
553
|
+
if not needle or len(needle) > len(haystack):
|
|
554
|
+
return []
|
|
555
|
+
width = len(needle)
|
|
556
|
+
return [index for index in range(len(haystack) - width + 1)
|
|
557
|
+
if haystack[index:index + width] == needle]
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _nearest(matches: list[int], expected: int) -> int | None:
|
|
561
|
+
return min(matches, key=lambda value: (abs(value - expected), value)) if matches else None
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def _block_image_index(lines: list[bytes], image: list[bytes], block: dict) -> int | None:
|
|
565
|
+
matches = []
|
|
566
|
+
before = block["_before_lines"]
|
|
567
|
+
after = block["_after_lines"]
|
|
568
|
+
for index in _line_matches(lines, image):
|
|
569
|
+
if before and (index < len(before) or lines[index - len(before):index] != before):
|
|
570
|
+
continue
|
|
571
|
+
end = index + len(image)
|
|
572
|
+
if after and lines[end:end + len(after)] != after:
|
|
573
|
+
continue
|
|
574
|
+
matches.append(index)
|
|
575
|
+
return _nearest(matches, block["_expected_index"])
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def _block_anchor_index(lines: list[bytes], block: dict) -> int | None:
|
|
579
|
+
before = block["_before_lines"]
|
|
580
|
+
after = block["_after_lines"]
|
|
581
|
+
if not before and not after:
|
|
582
|
+
return 0 if not lines else None
|
|
583
|
+
candidates = []
|
|
584
|
+
for index in range(len(lines) + 1):
|
|
585
|
+
if before and (index < len(before) or lines[index - len(before):index] != before):
|
|
586
|
+
continue
|
|
587
|
+
if after and lines[index:index + len(after)] != after:
|
|
588
|
+
continue
|
|
589
|
+
candidates.append(index)
|
|
590
|
+
return _nearest(candidates, block["_expected_index"])
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def _map_pre_fix_span(block: dict, pre_fix_lines: list[bytes]) -> None:
|
|
594
|
+
post_lines = block["_post_lines"]
|
|
595
|
+
if post_lines:
|
|
596
|
+
index = _block_image_index(pre_fix_lines, post_lines, block)
|
|
597
|
+
else:
|
|
598
|
+
index = _block_anchor_index(pre_fix_lines, block)
|
|
599
|
+
if index is None:
|
|
600
|
+
block["_pre_fix_anchor"] = False
|
|
601
|
+
return
|
|
602
|
+
block["_pre_fix_anchor"] = True
|
|
603
|
+
block["_expected_index"] = index
|
|
604
|
+
width = max(1, len(post_lines))
|
|
605
|
+
block["pre_fix_span"] = [index + 1, index + width]
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def surface_change_blocks(work: pathlib.Path, state: dict,
|
|
609
|
+
pre_fix_sha: str | None = None) -> list[dict]:
|
|
610
|
+
surface = ((state.get("phases") or {}).get("surface_close") or {})
|
|
611
|
+
pre_sha = surface.get("pre_sha")
|
|
612
|
+
post_sha = surface.get("post_sha")
|
|
613
|
+
if not isinstance(pre_sha, str) or not pre_sha or not isinstance(post_sha, str) or not post_sha:
|
|
614
|
+
return []
|
|
615
|
+
paths = run_git_paths(work, "diff", "--name-only", "--no-renames", "-z", pre_sha, post_sha, "--")
|
|
616
|
+
blocks = []
|
|
617
|
+
for path in paths:
|
|
618
|
+
parsed = pathlib.PurePosixPath(path)
|
|
619
|
+
if parsed.is_absolute() or ".." in parsed.parts:
|
|
620
|
+
raise SystemExit(f"BLOCKED:closure-durability-mechanical: unsafe surface path {path!r}")
|
|
621
|
+
old_blob = _commit_blob(work, pre_sha, path)
|
|
622
|
+
post_blob = _commit_blob(work, post_sha, path)
|
|
623
|
+
old_lines = [] if old_blob is None else old_blob.splitlines(keepends=True)
|
|
624
|
+
post_lines = [] if post_blob is None else post_blob.splitlines(keepends=True)
|
|
625
|
+
matcher = difflib.SequenceMatcher(None, old_lines, post_lines, autojunk=False)
|
|
626
|
+
opcodes = matcher.get_opcodes()
|
|
627
|
+
for ordinal, (tag, old_start, old_end, new_start, new_end) in enumerate(opcodes):
|
|
628
|
+
if tag == "equal":
|
|
629
|
+
continue
|
|
630
|
+
old_image = old_lines[old_start:old_end]
|
|
631
|
+
post_image = post_lines[new_start:new_end]
|
|
632
|
+
before_lines = []
|
|
633
|
+
after_lines = []
|
|
634
|
+
if ordinal > 0 and opcodes[ordinal - 1][0] == "equal":
|
|
635
|
+
_tag, _i1, _i2, prior_start, prior_end = opcodes[ordinal - 1]
|
|
636
|
+
before_lines = post_lines[max(prior_start, prior_end - 2):prior_end]
|
|
637
|
+
if ordinal + 1 < len(opcodes) and opcodes[ordinal + 1][0] == "equal":
|
|
638
|
+
_tag, _i1, _i2, next_start, next_end = opcodes[ordinal + 1]
|
|
639
|
+
after_lines = post_lines[next_start:min(next_end, next_start + 2)]
|
|
640
|
+
identity = hashlib.sha256(
|
|
641
|
+
path.encode("utf-8", "surrogateescape") + b"\0"
|
|
642
|
+
+ str(old_start + 1).encode() + b":" + str(old_end - old_start).encode() + b"\0"
|
|
643
|
+
+ str(new_start + 1).encode() + b":" + str(new_end - new_start).encode() + b"\0"
|
|
644
|
+
+ b"".join(old_image) + b"\0" + b"".join(post_image)
|
|
645
|
+
).hexdigest()[:16]
|
|
646
|
+
width = max(1, len(post_image))
|
|
647
|
+
block = {
|
|
648
|
+
"id": f"{path}:{ordinal}:{identity}",
|
|
649
|
+
"path": path,
|
|
650
|
+
"pre_fix_span": [new_start + 1, new_start + width],
|
|
651
|
+
"_pre_lines": old_image,
|
|
652
|
+
"_post_lines": post_image,
|
|
653
|
+
"_before_lines": before_lines,
|
|
654
|
+
"_after_lines": after_lines,
|
|
655
|
+
"_expected_index": new_start,
|
|
656
|
+
"_pre_fix_anchor": True,
|
|
657
|
+
"_post_exists": post_blob is not None,
|
|
658
|
+
}
|
|
659
|
+
if pre_fix_sha is not None:
|
|
660
|
+
pre_fix_blob = _commit_blob(work, pre_fix_sha, path)
|
|
661
|
+
_map_pre_fix_span(block, [] if pre_fix_blob is None else pre_fix_blob.splitlines(keepends=True))
|
|
662
|
+
blocks.append(block)
|
|
663
|
+
return blocks
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def classify_surface_block(block: dict, current: bytes | None) -> str:
|
|
667
|
+
lines = [] if current is None else current.splitlines(keepends=True)
|
|
668
|
+
post_lines = block["_post_lines"]
|
|
669
|
+
pre_lines = block["_pre_lines"]
|
|
670
|
+
if post_lines:
|
|
671
|
+
post_index = _block_image_index(lines, post_lines, block)
|
|
672
|
+
if post_index is not None:
|
|
673
|
+
block["_restore_index"] = post_index
|
|
674
|
+
return "SURVIVED"
|
|
675
|
+
if pre_lines:
|
|
676
|
+
pre_index = _block_image_index(lines, pre_lines, block)
|
|
677
|
+
if pre_index is not None:
|
|
678
|
+
block["_restore_index"] = pre_index
|
|
679
|
+
return "REVERTED"
|
|
680
|
+
else:
|
|
681
|
+
anchor = _block_anchor_index(lines, block)
|
|
682
|
+
if anchor is not None:
|
|
683
|
+
block["_restore_index"] = anchor
|
|
684
|
+
return "REVERTED"
|
|
685
|
+
return "EVOLVED"
|
|
686
|
+
pre_index = _block_image_index(lines, pre_lines, block)
|
|
687
|
+
if pre_index is not None:
|
|
688
|
+
block["_restore_index"] = pre_index
|
|
689
|
+
return "REVERTED"
|
|
690
|
+
anchor = _block_anchor_index(lines, block)
|
|
691
|
+
if anchor is not None:
|
|
692
|
+
block["_restore_index"] = anchor
|
|
693
|
+
return "SURVIVED"
|
|
694
|
+
return "EVOLVED"
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def finding_targets_block(block: dict, findings: list[dict]) -> bool:
|
|
698
|
+
start, end = block["pre_fix_span"]
|
|
699
|
+
for finding in findings:
|
|
700
|
+
path = finding.get("path", finding.get("file"))
|
|
701
|
+
line = finding.get("line")
|
|
702
|
+
if path == block["path"] and isinstance(line, int) and not isinstance(line, bool):
|
|
703
|
+
if start <= line <= end:
|
|
704
|
+
return True
|
|
705
|
+
return False
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def _read_triggering_findings(devlyn: pathlib.Path, origin_phase: str) -> tuple[list[dict], str]:
|
|
709
|
+
name = "build_gate.findings.jsonl" if origin_phase == "build_gate" else "verify-merged.findings.jsonl"
|
|
710
|
+
path = devlyn / name
|
|
711
|
+
try:
|
|
712
|
+
raw = path.read_bytes()
|
|
713
|
+
except OSError as exc:
|
|
714
|
+
raise SystemExit(f"BLOCKED:closure-durability-receipt: {path}: {exc}") from exc
|
|
715
|
+
findings = []
|
|
716
|
+
for line_number, line in enumerate(raw.splitlines(), start=1):
|
|
717
|
+
if not line.strip():
|
|
718
|
+
continue
|
|
719
|
+
try:
|
|
720
|
+
finding = loads_strict_json(line.decode("utf-8"))
|
|
721
|
+
except (UnicodeError, ValueError) as exc:
|
|
722
|
+
raise SystemExit(
|
|
723
|
+
f"BLOCKED:closure-durability-receipt: {path}:{line_number}: {exc}"
|
|
724
|
+
) from exc
|
|
725
|
+
if not isinstance(finding, dict):
|
|
726
|
+
raise SystemExit(
|
|
727
|
+
f"BLOCKED:closure-durability-receipt: {path}:{line_number} is not an object"
|
|
728
|
+
)
|
|
729
|
+
findings.append(finding)
|
|
730
|
+
return findings, hashlib.sha256(raw).hexdigest()
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def _worktree_file(work: pathlib.Path, path: str) -> bytes | None:
|
|
734
|
+
target = work / path
|
|
735
|
+
if target.is_file() or target.is_symlink():
|
|
736
|
+
return target.read_bytes()
|
|
737
|
+
return None
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
def _restored_files(work: pathlib.Path, blocks: list[dict]) -> dict[str, bytes | None]:
|
|
741
|
+
desired = {}
|
|
742
|
+
for path in sorted({block["path"] for block in blocks}):
|
|
743
|
+
current = _worktree_file(work, path)
|
|
744
|
+
lines = [] if current is None else current.splitlines(keepends=True)
|
|
745
|
+
path_blocks = sorted(
|
|
746
|
+
(block for block in blocks if block["path"] == path),
|
|
747
|
+
key=lambda block: block["_restore_index"], reverse=True,
|
|
748
|
+
)
|
|
749
|
+
for block in path_blocks:
|
|
750
|
+
index = block["_restore_index"]
|
|
751
|
+
pre_lines = block["_pre_lines"]
|
|
752
|
+
post_lines = block["_post_lines"]
|
|
753
|
+
if lines[index:index + len(pre_lines)] != pre_lines:
|
|
754
|
+
raise SystemExit(
|
|
755
|
+
f"BLOCKED:closure-durability-apply: restore anchor changed for {block['id']}"
|
|
756
|
+
)
|
|
757
|
+
lines[index:index + len(pre_lines)] = post_lines
|
|
758
|
+
desired[path] = b"".join(lines) if lines or any(
|
|
759
|
+
block["_post_exists"] for block in path_blocks
|
|
760
|
+
) else None
|
|
761
|
+
return desired
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def _restore_patch(work: pathlib.Path, desired: dict[str, bytes | None]) -> bytes:
|
|
765
|
+
parts = []
|
|
766
|
+
with tempfile.TemporaryDirectory(prefix="closure-durability-") as tmp:
|
|
767
|
+
root = pathlib.Path(tmp)
|
|
768
|
+
for path, wanted in desired.items():
|
|
769
|
+
old = root / "old" / path
|
|
770
|
+
new = root / "new" / path
|
|
771
|
+
current = _worktree_file(work, path)
|
|
772
|
+
if current is not None:
|
|
773
|
+
old.parent.mkdir(parents=True, exist_ok=True)
|
|
774
|
+
old.write_bytes(current)
|
|
775
|
+
if wanted is not None:
|
|
776
|
+
new.parent.mkdir(parents=True, exist_ok=True)
|
|
777
|
+
new.write_bytes(wanted)
|
|
778
|
+
old_arg = str(old.relative_to(root)) if current is not None else "/dev/null"
|
|
779
|
+
new_arg = str(new.relative_to(root)) if wanted is not None else "/dev/null"
|
|
780
|
+
proc = subprocess.run(
|
|
781
|
+
["git", "diff", "--no-index", "--binary", "--src-prefix=a/", "--dst-prefix=b/",
|
|
782
|
+
"--", old_arg, new_arg],
|
|
783
|
+
cwd=root, capture_output=True, check=False,
|
|
784
|
+
)
|
|
785
|
+
if proc.returncode not in (0, 1):
|
|
786
|
+
detail = os.fsdecode(proc.stderr or proc.stdout).strip() or "git diff --no-index failed"
|
|
787
|
+
raise SystemExit(f"BLOCKED:closure-durability-apply: {detail}")
|
|
788
|
+
patch = proc.stdout
|
|
789
|
+
encoded = path.encode("utf-8", "surrogateescape")
|
|
790
|
+
rewritten = []
|
|
791
|
+
header = True
|
|
792
|
+
for line in patch.splitlines(keepends=True):
|
|
793
|
+
if line.startswith(b"@@ ") or line.startswith(b"GIT binary patch"):
|
|
794
|
+
header = False
|
|
795
|
+
if header and line.startswith((b"diff --git ", b"--- ", b"+++ ", b"Binary files ")):
|
|
796
|
+
for prefix in (b"a/old/", b"a/new/"):
|
|
797
|
+
line = line.replace(prefix + encoded, b"a/" + encoded)
|
|
798
|
+
for prefix in (b"b/old/", b"b/new/"):
|
|
799
|
+
line = line.replace(prefix + encoded, b"b/" + encoded)
|
|
800
|
+
rewritten.append(line)
|
|
801
|
+
parts.append(b"".join(rewritten))
|
|
802
|
+
return b"".join(parts)
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
def _tracked_status(work: pathlib.Path) -> bytes:
|
|
806
|
+
return _git_output(work, "status", "--porcelain=v1", "-z", "--untracked-files=no")
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
def _apply_restore_patch(work: pathlib.Path, patch: bytes, paths: list[str]) -> None:
|
|
810
|
+
before = _tracked_status(work)
|
|
811
|
+
if before:
|
|
812
|
+
raise SystemExit("BLOCKED:closure-durability-apply: tracked worktree/index is not clean")
|
|
813
|
+
check = subprocess.run(
|
|
814
|
+
["git", "apply", "--check", "--index", "-"], cwd=work,
|
|
815
|
+
input=patch, capture_output=True, check=False,
|
|
816
|
+
)
|
|
817
|
+
if check.returncode != 0:
|
|
818
|
+
if _tracked_status(work) != before:
|
|
819
|
+
raise SystemExit("BLOCKED:closure-durability-apply: preflight mutated tracked state")
|
|
820
|
+
detail = os.fsdecode(check.stderr or check.stdout).strip() or "git apply --check failed"
|
|
821
|
+
raise SystemExit(f"BLOCKED:closure-durability-apply: {detail}")
|
|
822
|
+
apply = subprocess.run(
|
|
823
|
+
["git", "apply", "--index", "-"], cwd=work,
|
|
824
|
+
input=patch, capture_output=True, check=False,
|
|
825
|
+
)
|
|
826
|
+
if apply.returncode != 0:
|
|
827
|
+
subprocess.run(
|
|
828
|
+
["git", "restore", "--source=HEAD", "--staged", "--worktree", "--", *paths],
|
|
829
|
+
cwd=work, capture_output=True, check=False,
|
|
830
|
+
)
|
|
831
|
+
if _tracked_status(work) != before:
|
|
832
|
+
raise SystemExit("BLOCKED:closure-durability-apply: failed apply left partial mutation")
|
|
833
|
+
detail = os.fsdecode(apply.stderr or apply.stdout).strip() or "git apply failed"
|
|
834
|
+
raise SystemExit(f"BLOCKED:closure-durability-apply: {detail}")
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
def _write_json_atomic(path: pathlib.Path, value: dict) -> bytes:
|
|
838
|
+
raw = (json.dumps(value, indent=2, sort_keys=True) + "\n").encode("utf-8")
|
|
839
|
+
fd, tmp_name = tempfile.mkstemp(dir=str(path.parent), prefix=path.name + ".tmp.")
|
|
840
|
+
try:
|
|
841
|
+
with open(fd, "wb") as handle:
|
|
842
|
+
handle.write(raw)
|
|
843
|
+
pathlib.Path(tmp_name).replace(path)
|
|
844
|
+
except BaseException:
|
|
845
|
+
pathlib.Path(tmp_name).unlink(missing_ok=True)
|
|
846
|
+
raise
|
|
847
|
+
return raw
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
def _surface_durability_ledger(state: dict) -> list[dict]:
|
|
851
|
+
phases = state.setdefault("phases", {})
|
|
852
|
+
surface = phases.get("surface_close")
|
|
853
|
+
if surface is None:
|
|
854
|
+
surface = {}
|
|
855
|
+
phases["surface_close"] = surface
|
|
856
|
+
if not isinstance(surface, dict):
|
|
857
|
+
raise SystemExit("BLOCKED:closure-durability-receipt: phases.surface_close is malformed")
|
|
858
|
+
ledger = surface.setdefault("durability", [])
|
|
859
|
+
if not isinstance(ledger, list) or any(not isinstance(item, dict) for item in ledger):
|
|
860
|
+
raise SystemExit("BLOCKED:closure-durability-receipt: durability ledger is malformed")
|
|
861
|
+
return ledger
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
def _receipt_blocks(blocks: list[dict]) -> list[dict]:
|
|
865
|
+
return [{
|
|
866
|
+
"id": block["id"],
|
|
867
|
+
"path": block["path"],
|
|
868
|
+
"pre_fix_span": block["pre_fix_span"],
|
|
869
|
+
"classification": block["classification"],
|
|
870
|
+
"finding_targeted": block["finding_targeted"],
|
|
871
|
+
"action": block["action"],
|
|
872
|
+
} for block in blocks]
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
def _rollback_durability_creation(
|
|
876
|
+
work: pathlib.Path, receipt_path: pathlib.Path, state: dict, receipt: dict,
|
|
877
|
+
) -> None:
|
|
878
|
+
restore_sha = receipt.get("restore_commit_sha")
|
|
879
|
+
fix_sha = receipt["fix_commit_sha"]
|
|
880
|
+
restored_paths = sorted({
|
|
881
|
+
block["path"] for block in receipt.get("blocks", [])
|
|
882
|
+
if block.get("action") == "restored"
|
|
883
|
+
})
|
|
884
|
+
if restore_sha is not None:
|
|
885
|
+
head = _git_output(work, "rev-parse", "HEAD").decode().strip()
|
|
886
|
+
if head != restore_sha:
|
|
887
|
+
raise SystemExit("BLOCKED:closure-durability-rollback: restore commit is no longer HEAD")
|
|
888
|
+
restore = subprocess.run(
|
|
889
|
+
["git", "restore", f"--source={fix_sha}", "--staged", "--worktree", "--", *restored_paths],
|
|
890
|
+
cwd=work, capture_output=True, check=False,
|
|
891
|
+
)
|
|
892
|
+
if restore.returncode != 0:
|
|
893
|
+
detail = os.fsdecode(restore.stderr or restore.stdout).strip() or "git restore failed"
|
|
894
|
+
raise SystemExit(f"BLOCKED:closure-durability-rollback: {detail}")
|
|
895
|
+
update = subprocess.run(
|
|
896
|
+
["git", "update-ref", "HEAD", fix_sha, restore_sha],
|
|
897
|
+
cwd=work, capture_output=True, check=False,
|
|
898
|
+
)
|
|
899
|
+
if update.returncode != 0:
|
|
900
|
+
subprocess.run(
|
|
901
|
+
["git", "restore", f"--source={restore_sha}", "--staged", "--worktree", "--",
|
|
902
|
+
*restored_paths], cwd=work, capture_output=True, check=False,
|
|
903
|
+
)
|
|
904
|
+
detail = os.fsdecode(update.stderr or update.stdout).strip() or "git update-ref failed"
|
|
905
|
+
raise SystemExit(f"BLOCKED:closure-durability-rollback: {detail}")
|
|
906
|
+
receipt_path.unlink(missing_ok=True)
|
|
907
|
+
ledger = _surface_durability_ledger(state)
|
|
908
|
+
ledger[:] = [
|
|
909
|
+
item for item in ledger
|
|
910
|
+
if not (item.get("round") == receipt["round"]
|
|
911
|
+
and item.get("origin_phase") == receipt["origin_phase"])
|
|
912
|
+
]
|
|
913
|
+
head = _git_output(work, "rev-parse", "HEAD").decode().strip()
|
|
914
|
+
if head != fix_sha or _tracked_status(work):
|
|
915
|
+
raise SystemExit("BLOCKED:closure-durability-rollback: rollback left partial mutation")
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
def _validate_durability_receipt(
|
|
919
|
+
work: pathlib.Path, devlyn: pathlib.Path, state: dict, origin_phase: str, round_: int,
|
|
920
|
+
receipt_path: pathlib.Path, findings_digest: str, ledger: list[dict],
|
|
921
|
+
) -> dict | None:
|
|
922
|
+
matches = [item for item in ledger if item.get("round") == round_]
|
|
923
|
+
if not receipt_path.exists() and not matches:
|
|
924
|
+
return None
|
|
925
|
+
if not receipt_path.is_file() or len(matches) != 1:
|
|
926
|
+
raise SystemExit(
|
|
927
|
+
f"BLOCKED:closure-durability-receipt: missing or duplicate round {round_} receipt/ledger"
|
|
928
|
+
)
|
|
929
|
+
try:
|
|
930
|
+
raw = receipt_path.read_bytes()
|
|
931
|
+
receipt = loads_strict_json(raw.decode("utf-8"))
|
|
932
|
+
except (OSError, UnicodeError, ValueError) as exc:
|
|
933
|
+
raise SystemExit(f"BLOCKED:closure-durability-receipt: {receipt_path}: {exc}") from exc
|
|
934
|
+
required = {
|
|
935
|
+
"schema_version", "round", "origin_phase", "triggering_findings_sha256",
|
|
936
|
+
"surface_close_commit_sha", "pre_fix_sha", "fix_commit_sha", "post_restore_sha",
|
|
937
|
+
"restore_commit_sha", "blocks",
|
|
938
|
+
}
|
|
939
|
+
if not isinstance(receipt, dict) or set(receipt) != required:
|
|
940
|
+
raise SystemExit("BLOCKED:closure-durability-receipt: receipt fields are stale or malformed")
|
|
941
|
+
surface = ((state.get("phases") or {}).get("surface_close") or {})
|
|
942
|
+
expected_surface_sha = surface.get("post_sha") if isinstance(surface, dict) else None
|
|
943
|
+
if (
|
|
944
|
+
receipt["schema_version"] != 1 or receipt["round"] != round_
|
|
945
|
+
or receipt["origin_phase"] != origin_phase
|
|
946
|
+
or receipt["triggering_findings_sha256"] != findings_digest
|
|
947
|
+
or receipt["surface_close_commit_sha"] != expected_surface_sha
|
|
948
|
+
):
|
|
949
|
+
raise SystemExit("BLOCKED:closure-durability-receipt: receipt metadata is stale")
|
|
950
|
+
blocks = receipt.get("blocks")
|
|
951
|
+
if not isinstance(blocks, list):
|
|
952
|
+
raise SystemExit("BLOCKED:closure-durability-receipt: blocks must be an array")
|
|
953
|
+
block_fields = {"id", "path", "pre_fix_span", "classification", "finding_targeted", "action"}
|
|
954
|
+
for block in blocks:
|
|
955
|
+
span = block.get("pre_fix_span") if isinstance(block, dict) else None
|
|
956
|
+
if (
|
|
957
|
+
not isinstance(block, dict) or set(block) != block_fields
|
|
958
|
+
or not isinstance(block.get("id"), str) or not isinstance(block.get("path"), str)
|
|
959
|
+
or not isinstance(span, list) or len(span) != 2
|
|
960
|
+
or any(not isinstance(value, int) or isinstance(value, bool) or value < 1 for value in span)
|
|
961
|
+
or span[0] > span[1]
|
|
962
|
+
or block.get("classification") not in {"SURVIVED", "REVERTED", "EVOLVED"}
|
|
963
|
+
or not isinstance(block.get("finding_targeted"), bool)
|
|
964
|
+
or block.get("action") not in {"restored", "finding-targeted", "none"}
|
|
965
|
+
):
|
|
966
|
+
raise SystemExit("BLOCKED:closure-durability-receipt: block entry is malformed")
|
|
967
|
+
sha_fields = ("pre_fix_sha", "fix_commit_sha", "post_restore_sha")
|
|
968
|
+
if any(not isinstance(receipt.get(field), str) or not re.fullmatch(r"[0-9a-f]{40,64}", receipt[field])
|
|
969
|
+
for field in sha_fields):
|
|
970
|
+
raise SystemExit("BLOCKED:closure-durability-receipt: commit sha is malformed")
|
|
971
|
+
restore_sha = receipt.get("restore_commit_sha")
|
|
972
|
+
if restore_sha is not None and (
|
|
973
|
+
not isinstance(restore_sha, str) or not re.fullmatch(r"[0-9a-f]{40,64}", restore_sha)
|
|
974
|
+
):
|
|
975
|
+
raise SystemExit("BLOCKED:closure-durability-receipt: restore_commit_sha is malformed")
|
|
976
|
+
head = _git_output(work, "rev-parse", "HEAD").decode().strip()
|
|
977
|
+
if head != receipt["post_restore_sha"] or (restore_sha is not None and restore_sha != head):
|
|
978
|
+
raise SystemExit("BLOCKED:closure-durability-receipt: receipt does not match current HEAD")
|
|
979
|
+
if restore_sha is None and receipt["fix_commit_sha"] != receipt["post_restore_sha"]:
|
|
980
|
+
raise SystemExit("BLOCKED:closure-durability-receipt: no-op receipt changed HEAD")
|
|
981
|
+
if restore_sha is not None:
|
|
982
|
+
parent = _git_output(work, "rev-parse", f"{restore_sha}^").decode().strip()
|
|
983
|
+
subject = _git_output(work, "show", "-s", "--format=%s", restore_sha).decode().strip()
|
|
984
|
+
if parent != receipt["fix_commit_sha"] or subject != f"chore(pipeline): closure-restore round {round_}":
|
|
985
|
+
raise SystemExit("BLOCKED:closure-durability-receipt: restore commit evidence is stale")
|
|
986
|
+
fix_parent = _git_output(work, "rev-parse", f"{receipt['fix_commit_sha']}^").decode().strip()
|
|
987
|
+
fix_subject = _git_output(
|
|
988
|
+
work, "show", "-s", "--format=%s", receipt["fix_commit_sha"]
|
|
989
|
+
).decode().strip()
|
|
990
|
+
if fix_parent != receipt["pre_fix_sha"] or fix_subject != f"chore(pipeline): implement fix round {round_}":
|
|
991
|
+
raise SystemExit("BLOCKED:closure-durability-receipt: fix checkpoint evidence is stale")
|
|
992
|
+
receipt_rel = f"{devlyn_prefix(work, devlyn)}/{receipt_path.name}"
|
|
993
|
+
expected_ledger = {
|
|
994
|
+
"round": round_,
|
|
995
|
+
"origin_phase": origin_phase,
|
|
996
|
+
"receipt_path": receipt_rel,
|
|
997
|
+
"receipt_sha256": hashlib.sha256(raw).hexdigest(),
|
|
998
|
+
"restore_commit_sha": restore_sha,
|
|
999
|
+
}
|
|
1000
|
+
if matches[0] != expected_ledger:
|
|
1001
|
+
raise SystemExit("BLOCKED:closure-durability-receipt: ledger entry is stale")
|
|
1002
|
+
return receipt
|
|
1003
|
+
|
|
1004
|
+
|
|
1005
|
+
def enforce_closure_durability_reentry(
|
|
1006
|
+
work: pathlib.Path, devlyn: pathlib.Path, state: dict, origin_phase: str, round_: int,
|
|
1007
|
+
*, require_existing: bool = False,
|
|
1008
|
+
) -> dict | None:
|
|
1009
|
+
if round_ < 1:
|
|
1010
|
+
return None
|
|
1011
|
+
if origin_phase not in VALID_TRIGGERS:
|
|
1012
|
+
raise SystemExit(f"BLOCKED:closure-durability-receipt: invalid origin phase {origin_phase!r}")
|
|
1013
|
+
findings, findings_digest = _read_triggering_findings(devlyn, origin_phase)
|
|
1014
|
+
ledger = _surface_durability_ledger(state)
|
|
1015
|
+
receipt_path = devlyn / f"closure-durability.round-{round_}.json"
|
|
1016
|
+
existing = _validate_durability_receipt(
|
|
1017
|
+
work, devlyn, state, origin_phase, round_, receipt_path, findings_digest, ledger,
|
|
1018
|
+
)
|
|
1019
|
+
if existing is not None:
|
|
1020
|
+
return existing
|
|
1021
|
+
if require_existing:
|
|
1022
|
+
raise SystemExit(
|
|
1023
|
+
f"BLOCKED:closure-durability-receipt: round {round_} checkpoint receipt is missing"
|
|
1024
|
+
)
|
|
1025
|
+
|
|
1026
|
+
if any(item.get("round") == round_ or item.get("origin_phase") == origin_phase
|
|
1027
|
+
and item.get("receipt_path") == f"{devlyn_prefix(work, devlyn)}/{receipt_path.name}"
|
|
1028
|
+
for item in ledger):
|
|
1029
|
+
raise SystemExit("BLOCKED:closure-durability-receipt: append-only ledger collision")
|
|
1030
|
+
if _tracked_status(work):
|
|
1031
|
+
raise SystemExit("BLOCKED:closure-durability-apply: tracked worktree/index is not clean")
|
|
1032
|
+
fix_commit_sha = _git_output(work, "rev-parse", "HEAD").decode().strip()
|
|
1033
|
+
fix_subject = _git_output(work, "show", "-s", "--format=%s", fix_commit_sha).decode().strip()
|
|
1034
|
+
if fix_subject != f"chore(pipeline): implement fix round {round_}":
|
|
1035
|
+
raise SystemExit(
|
|
1036
|
+
f"BLOCKED:closure-durability-receipt: expected fix checkpoint round {round_}, got {fix_subject!r}"
|
|
1037
|
+
)
|
|
1038
|
+
pre_fix_sha = _git_output(work, "rev-parse", f"{fix_commit_sha}^").decode().strip()
|
|
1039
|
+
surface = ((state.get("phases") or {}).get("surface_close") or {})
|
|
1040
|
+
surface_commit_sha = surface.get("post_sha") if isinstance(surface, dict) else None
|
|
1041
|
+
blocks = surface_change_blocks(work, state, pre_fix_sha)
|
|
1042
|
+
restore_blocks = []
|
|
1043
|
+
for block in blocks:
|
|
1044
|
+
classification = classify_surface_block(block, _worktree_file(work, block["path"]))
|
|
1045
|
+
targeted = finding_targets_block(block, findings)
|
|
1046
|
+
block["classification"] = classification
|
|
1047
|
+
block["finding_targeted"] = targeted
|
|
1048
|
+
if classification == "REVERTED" and not targeted:
|
|
1049
|
+
block["action"] = "restored"
|
|
1050
|
+
restore_blocks.append(block)
|
|
1051
|
+
elif targeted:
|
|
1052
|
+
block["action"] = "finding-targeted"
|
|
1053
|
+
else:
|
|
1054
|
+
block["action"] = "none"
|
|
1055
|
+
|
|
1056
|
+
restore_commit_sha = None
|
|
1057
|
+
if restore_blocks:
|
|
1058
|
+
desired = _restored_files(work, restore_blocks)
|
|
1059
|
+
patch = _restore_patch(work, desired)
|
|
1060
|
+
if not patch:
|
|
1061
|
+
raise SystemExit("BLOCKED:closure-durability-apply: restored blocks produced an empty patch")
|
|
1062
|
+
paths = sorted(desired)
|
|
1063
|
+
_apply_restore_patch(work, patch, paths)
|
|
1064
|
+
commit = subprocess.run(
|
|
1065
|
+
["git", "commit", "-m", f"chore(pipeline): closure-restore round {round_}"],
|
|
1066
|
+
cwd=work, capture_output=True, check=False,
|
|
1067
|
+
)
|
|
1068
|
+
if commit.returncode != 0:
|
|
1069
|
+
subprocess.run(
|
|
1070
|
+
["git", "restore", "--source=HEAD", "--staged", "--worktree", "--", *paths],
|
|
1071
|
+
cwd=work, capture_output=True, check=False,
|
|
1072
|
+
)
|
|
1073
|
+
detail = os.fsdecode(commit.stderr or commit.stdout).strip() or "git commit failed"
|
|
1074
|
+
raise SystemExit(f"BLOCKED:closure-durability-apply: {detail}")
|
|
1075
|
+
restore_commit_sha = _git_output(work, "rev-parse", "HEAD").decode().strip()
|
|
1076
|
+
post_restore_sha = _git_output(work, "rev-parse", "HEAD").decode().strip()
|
|
1077
|
+
receipt = {
|
|
1078
|
+
"schema_version": 1,
|
|
1079
|
+
"round": round_,
|
|
1080
|
+
"origin_phase": origin_phase,
|
|
1081
|
+
"triggering_findings_sha256": findings_digest,
|
|
1082
|
+
"surface_close_commit_sha": surface_commit_sha,
|
|
1083
|
+
"pre_fix_sha": pre_fix_sha,
|
|
1084
|
+
"fix_commit_sha": fix_commit_sha,
|
|
1085
|
+
"post_restore_sha": post_restore_sha,
|
|
1086
|
+
"restore_commit_sha": restore_commit_sha,
|
|
1087
|
+
"blocks": _receipt_blocks(blocks),
|
|
1088
|
+
}
|
|
1089
|
+
try:
|
|
1090
|
+
raw = _write_json_atomic(receipt_path, receipt)
|
|
1091
|
+
ledger.append({
|
|
1092
|
+
"round": round_,
|
|
1093
|
+
"origin_phase": origin_phase,
|
|
1094
|
+
"receipt_path": f"{devlyn_prefix(work, devlyn)}/{receipt_path.name}",
|
|
1095
|
+
"receipt_sha256": hashlib.sha256(raw).hexdigest(),
|
|
1096
|
+
"restore_commit_sha": restore_commit_sha,
|
|
1097
|
+
})
|
|
1098
|
+
except BaseException as exc:
|
|
1099
|
+
_rollback_durability_creation(work, receipt_path, state, receipt)
|
|
1100
|
+
raise SystemExit(f"BLOCKED:closure-durability-receipt: {exc}") from exc
|
|
1101
|
+
return receipt
|
|
1102
|
+
|
|
1103
|
+
|
|
1104
|
+
def _persist_durability_event(
|
|
1105
|
+
work: pathlib.Path, devlyn: pathlib.Path, state: dict, state_path: pathlib.Path,
|
|
1106
|
+
origin_phase: str, round_: int, writer=write_state,
|
|
1107
|
+
) -> dict:
|
|
1108
|
+
receipt_path = devlyn / f"closure-durability.round-{round_}.json"
|
|
1109
|
+
existed = receipt_path.exists()
|
|
1110
|
+
receipt = enforce_closure_durability_reentry(
|
|
1111
|
+
work, devlyn, state, origin_phase, round_,
|
|
1112
|
+
)
|
|
1113
|
+
try:
|
|
1114
|
+
writer(state_path, state)
|
|
1115
|
+
except BaseException:
|
|
1116
|
+
if not existed:
|
|
1117
|
+
_rollback_durability_creation(work, receipt_path, state, receipt)
|
|
1118
|
+
raise
|
|
1119
|
+
return receipt
|
|
1120
|
+
|
|
1121
|
+
|
|
82
1122
|
def clear_verify_round_artifacts(devlyn: pathlib.Path) -> None:
|
|
83
1123
|
# The per-round reset contract covers files, not just JSON fields: a
|
|
84
1124
|
# VERIFY fix-loop respawn reuses the same .devlyn, so a prior round's
|
|
@@ -110,14 +1150,40 @@ def append_phase_history(entry: dict) -> None:
|
|
|
110
1150
|
|
|
111
1151
|
|
|
112
1152
|
def do_spawn(state: dict, phase: str, round_: int, triggered_by: str | None,
|
|
113
|
-
pre_sha: str | None, engine: str | None, model: str | None
|
|
1153
|
+
pre_sha: str | None, engine: str | None, model: str | None, *,
|
|
1154
|
+
input_patch_sha256: str | None = None,
|
|
1155
|
+
prompt_sha256: str | None = None,
|
|
1156
|
+
untracked_before: list[str] | None = None) -> None:
|
|
114
1157
|
# Merge, don't replace: a phase-gated large run's `exec` progress (or any
|
|
115
1158
|
# other field this script doesn't own) survives a fix-loop respawn.
|
|
1159
|
+
if phase == "surface_close" and engine != "claude":
|
|
1160
|
+
raise SystemExit("error: phases.surface_close spawn requires --engine claude")
|
|
1161
|
+
if phase == "surface_close" and not model:
|
|
1162
|
+
raise SystemExit("error: phases.surface_close spawn requires --model")
|
|
116
1163
|
phases = state.setdefault("phases", {})
|
|
117
1164
|
entry = phases.get(phase)
|
|
1165
|
+
if phase == "surface_close" and isinstance(entry, dict) and (
|
|
1166
|
+
entry.get("started_at") is not None or entry.get("skipped_reason") is not None
|
|
1167
|
+
):
|
|
1168
|
+
raise SystemExit("error: phases.surface_close is one-shot and cannot be re-entered")
|
|
1169
|
+
if phase == "surface_close":
|
|
1170
|
+
if pre_sha is None:
|
|
1171
|
+
raise SystemExit("error: phases.surface_close spawn requires --pre-sha")
|
|
1172
|
+
if input_patch_sha256 is None or not SHA256_RE.fullmatch(input_patch_sha256):
|
|
1173
|
+
raise SystemExit("error: phases.surface_close spawn requires --input-patch-sha256")
|
|
1174
|
+
if prompt_sha256 is None or not SHA256_RE.fullmatch(prompt_sha256):
|
|
1175
|
+
raise SystemExit("error: phases.surface_close spawn requires --prompt-sha256")
|
|
1176
|
+
if untracked_before is None:
|
|
1177
|
+
raise SystemExit("error: phases.surface_close spawn requires --untracked-before-json")
|
|
1178
|
+
elif input_patch_sha256 is not None or prompt_sha256 is not None or untracked_before is not None:
|
|
1179
|
+
raise SystemExit("error: SURFACE_CLOSE metadata is invalid for this phase")
|
|
118
1180
|
if not isinstance(entry, dict):
|
|
119
1181
|
entry = {}
|
|
120
1182
|
phases[phase] = entry
|
|
1183
|
+
if entry.get("started_at") is not None and entry.get("completed_at") is None:
|
|
1184
|
+
raise SystemExit(
|
|
1185
|
+
f"error: phases.{phase} has an open span — complete it before respawn"
|
|
1186
|
+
)
|
|
121
1187
|
append_phase_history(entry)
|
|
122
1188
|
entry["started_at"] = now_iso()
|
|
123
1189
|
entry["completed_at"] = None
|
|
@@ -127,17 +1193,52 @@ def do_spawn(state: dict, phase: str, round_: int, triggered_by: str | None,
|
|
|
127
1193
|
entry["verdict"] = None
|
|
128
1194
|
entry["artifacts"] = {"findings_file": None, "log_file": None}
|
|
129
1195
|
entry["sub_verdicts"] = None
|
|
1196
|
+
if phase == "verify":
|
|
1197
|
+
entry["judge_durations_ms"] = None
|
|
130
1198
|
if engine is not None:
|
|
131
1199
|
entry["engine"] = engine
|
|
132
1200
|
if model is not None:
|
|
133
|
-
entry["
|
|
1201
|
+
entry["model_requested"] = model
|
|
1202
|
+
else:
|
|
1203
|
+
entry.setdefault("model_requested", None)
|
|
1204
|
+
entry.pop("model", None)
|
|
1205
|
+
entry["model_effective"] = None
|
|
134
1206
|
if pre_sha is not None:
|
|
135
1207
|
entry["pre_sha"] = pre_sha
|
|
1208
|
+
if phase == "surface_close":
|
|
1209
|
+
entry["input_patch_sha256"] = input_patch_sha256
|
|
1210
|
+
entry["prompt_sha256"] = prompt_sha256
|
|
1211
|
+
entry["untracked_before"] = untracked_before
|
|
1212
|
+
|
|
1213
|
+
|
|
1214
|
+
def do_surface_skip(state: dict) -> None:
|
|
1215
|
+
phases = state.setdefault("phases", {})
|
|
1216
|
+
existing = phases.get("surface_close")
|
|
1217
|
+
if isinstance(existing, dict) and (
|
|
1218
|
+
existing.get("started_at") is not None or existing.get("skipped_reason") is not None
|
|
1219
|
+
):
|
|
1220
|
+
raise SystemExit("error: phases.surface_close is one-shot and cannot be re-entered")
|
|
1221
|
+
phases["surface_close"] = {
|
|
1222
|
+
"started_at": None,
|
|
1223
|
+
"completed_at": now_iso(),
|
|
1224
|
+
"duration_ms": 0,
|
|
1225
|
+
"round": 0,
|
|
1226
|
+
"triggered_by": None,
|
|
1227
|
+
"verdict": None,
|
|
1228
|
+
"engine": "claude",
|
|
1229
|
+
"model_requested": None,
|
|
1230
|
+
"model_effective": None,
|
|
1231
|
+
"artifacts": {"findings_file": None, "log_file": None},
|
|
1232
|
+
"sub_verdicts": None,
|
|
1233
|
+
"skipped_reason": SURFACE_SKIP_REASON,
|
|
1234
|
+
}
|
|
136
1235
|
|
|
137
1236
|
|
|
138
1237
|
def do_complete(state: dict, phase: str, verdict: str | None,
|
|
139
1238
|
post_sha: str | None, findings_file: str | None, log_file: str | None,
|
|
140
|
-
engine: str | None, model: str | None
|
|
1239
|
+
engine: str | None, model: str | None,
|
|
1240
|
+
engine_session_log: str | None = None,
|
|
1241
|
+
devlyn: pathlib.Path | None = None) -> str | None:
|
|
141
1242
|
phases = state.setdefault("phases", {})
|
|
142
1243
|
entry = phases.get(phase)
|
|
143
1244
|
if not isinstance(entry, dict) or not entry.get("started_at"):
|
|
@@ -170,9 +1271,110 @@ def do_complete(state: dict, phase: str, verdict: str | None,
|
|
|
170
1271
|
if engine is not None:
|
|
171
1272
|
entry["engine"] = engine
|
|
172
1273
|
if model is not None:
|
|
173
|
-
entry["
|
|
1274
|
+
entry["model_requested"] = model
|
|
1275
|
+
else:
|
|
1276
|
+
entry.setdefault("model_requested", None)
|
|
1277
|
+
entry.pop("model", None)
|
|
1278
|
+
|
|
1279
|
+
artifact_phase = WORKER_SESSION_ARTIFACT_PHASES.get(phase)
|
|
1280
|
+
retained_session = None
|
|
1281
|
+
if devlyn is not None and artifact_phase is not None:
|
|
1282
|
+
candidate = devlyn / f"{artifact_phase}.worker-session.{entry.get('round')}.jsonl"
|
|
1283
|
+
if candidate.is_file():
|
|
1284
|
+
retained_session = candidate
|
|
1285
|
+
|
|
1286
|
+
attestation_error = None
|
|
1287
|
+
if engine_session_log is None:
|
|
1288
|
+
entry["model_effective"] = None
|
|
1289
|
+
if retained_session is not None:
|
|
1290
|
+
attestation_error = (
|
|
1291
|
+
"BLOCKED:model-attestation-failed: --engine-session-log is required because "
|
|
1292
|
+
f"retained worker session exists: {retained_session}"
|
|
1293
|
+
)
|
|
1294
|
+
else:
|
|
1295
|
+
try:
|
|
1296
|
+
entry["model_effective"] = parse_effective_model(pathlib.Path(engine_session_log))
|
|
1297
|
+
except ValueError as exc:
|
|
1298
|
+
entry["model_effective"] = None
|
|
1299
|
+
attestation_error = f"BLOCKED:model-attestation-failed: {exc}"
|
|
1300
|
+
requested = entry.get("model_requested")
|
|
1301
|
+
effective = entry.get("model_effective")
|
|
1302
|
+
if attestation_error is None and requested is not None and requested != effective:
|
|
1303
|
+
attestation_error = (
|
|
1304
|
+
"BLOCKED:model-attestation-mismatch: "
|
|
1305
|
+
f"requested={requested} effective={effective}"
|
|
1306
|
+
)
|
|
1307
|
+
if attestation_error is not None:
|
|
1308
|
+
entry["verdict"] = "BLOCKED"
|
|
174
1309
|
if post_sha is not None:
|
|
175
1310
|
entry["post_sha"] = post_sha
|
|
1311
|
+
return attestation_error
|
|
1312
|
+
|
|
1313
|
+
|
|
1314
|
+
def do_transition(
|
|
1315
|
+
state: dict,
|
|
1316
|
+
phase: str,
|
|
1317
|
+
next_phase: str,
|
|
1318
|
+
verdict: str | None,
|
|
1319
|
+
post_sha: str | None,
|
|
1320
|
+
findings_file: str | None,
|
|
1321
|
+
log_file: str | None,
|
|
1322
|
+
engine: str | None,
|
|
1323
|
+
model: str | None,
|
|
1324
|
+
engine_session_log: str | None,
|
|
1325
|
+
devlyn: pathlib.Path,
|
|
1326
|
+
next_round: int,
|
|
1327
|
+
next_triggered_by: str | None,
|
|
1328
|
+
next_pre_sha: str | None,
|
|
1329
|
+
next_engine: str | None,
|
|
1330
|
+
next_model: str | None,
|
|
1331
|
+
*,
|
|
1332
|
+
next_input_patch_sha256: str | None = None,
|
|
1333
|
+
next_prompt_sha256: str | None = None,
|
|
1334
|
+
next_untracked_before: list[str] | None = None,
|
|
1335
|
+
between=None,
|
|
1336
|
+
) -> dict:
|
|
1337
|
+
"""Validate complete + caller-selected spawn against a copy of state.
|
|
1338
|
+
|
|
1339
|
+
The caller owns the next phase and every judgment-bearing argument. This
|
|
1340
|
+
primitive only validates the requested edge and applies both lifecycle
|
|
1341
|
+
mutations to a detached candidate, which the CLI commits with one atomic
|
|
1342
|
+
state-file replacement.
|
|
1343
|
+
"""
|
|
1344
|
+
if next_phase not in LEGAL_TRANSITIONS.get(phase, set()):
|
|
1345
|
+
raise SystemExit(f"error: illegal phase transition: {phase} -> {next_phase}")
|
|
1346
|
+
candidate = copy.deepcopy(state)
|
|
1347
|
+
attestation_error = do_complete(
|
|
1348
|
+
candidate, phase, verdict, post_sha, findings_file, log_file,
|
|
1349
|
+
engine, model, engine_session_log, devlyn,
|
|
1350
|
+
)
|
|
1351
|
+
if attestation_error is not None:
|
|
1352
|
+
raise SystemExit(attestation_error)
|
|
1353
|
+
if between is not None:
|
|
1354
|
+
between()
|
|
1355
|
+
do_spawn(
|
|
1356
|
+
candidate, next_phase, next_round, next_triggered_by,
|
|
1357
|
+
next_pre_sha, next_engine, next_model,
|
|
1358
|
+
input_patch_sha256=next_input_patch_sha256,
|
|
1359
|
+
prompt_sha256=next_prompt_sha256,
|
|
1360
|
+
untracked_before=next_untracked_before,
|
|
1361
|
+
)
|
|
1362
|
+
return candidate
|
|
1363
|
+
|
|
1364
|
+
|
|
1365
|
+
def do_surface_adjudication_recovery(state: dict, devlyn: pathlib.Path) -> str | None:
|
|
1366
|
+
entry = surface_entry(state)
|
|
1367
|
+
attestation_error = do_complete(
|
|
1368
|
+
state, "surface_close", "BLOCKED", entry["pre_sha"], None,
|
|
1369
|
+
".devlyn/surface-close.stdout", None, None,
|
|
1370
|
+
str(devlyn / "surface-close.output.json"), devlyn,
|
|
1371
|
+
)
|
|
1372
|
+
if attestation_error is not None:
|
|
1373
|
+
return attestation_error
|
|
1374
|
+
entry["verdict"] = None
|
|
1375
|
+
entry["skipped_reason"] = SURFACE_RECOVERY_REASON
|
|
1376
|
+
entry["continued_after_block"] = True
|
|
1377
|
+
return None
|
|
176
1378
|
|
|
177
1379
|
|
|
178
1380
|
def self_test() -> int:
|
|
@@ -242,6 +1444,163 @@ def self_test() -> int:
|
|
|
242
1444
|
else:
|
|
243
1445
|
raise AssertionError("complete() without a prior spawn() must raise")
|
|
244
1446
|
|
|
1447
|
+
# F11 rs-20260721T065728Z receipt: VERIFY merge wrote BLOCKED, but the
|
|
1448
|
+
# caller skipped complete() before re-entry. Respawn must fail without
|
|
1449
|
+
# archiving the open span as history.
|
|
1450
|
+
f11_open = {
|
|
1451
|
+
"phases": {
|
|
1452
|
+
"verify": {
|
|
1453
|
+
"started_at": "2026-07-21T07:08:11.684Z",
|
|
1454
|
+
"completed_at": None,
|
|
1455
|
+
"duration_ms": None,
|
|
1456
|
+
"verdict": "BLOCKED",
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
before_f11 = json.dumps(f11_open, sort_keys=True)
|
|
1461
|
+
try:
|
|
1462
|
+
do_spawn(f11_open, "verify", 1, "verify", None, "codex", None)
|
|
1463
|
+
except SystemExit as e:
|
|
1464
|
+
assert "open span" in str(e) and "complete it before respawn" in str(e)
|
|
1465
|
+
else:
|
|
1466
|
+
raise AssertionError("respawn over an open F11 span must fail")
|
|
1467
|
+
assert json.dumps(f11_open, sort_keys=True) == before_f11
|
|
1468
|
+
assert "history" not in f11_open["phases"]["verify"]
|
|
1469
|
+
|
|
1470
|
+
# Transition is one state transaction: a forced failure between its
|
|
1471
|
+
# validated halves leaves the authoritative state byte-identical.
|
|
1472
|
+
transition_state = {
|
|
1473
|
+
"phases": {
|
|
1474
|
+
"plan": {
|
|
1475
|
+
"started_at": "2026-01-01T00:00:00.000Z",
|
|
1476
|
+
"completed_at": None,
|
|
1477
|
+
"duration_ms": None,
|
|
1478
|
+
"round": 0,
|
|
1479
|
+
"triggered_by": None,
|
|
1480
|
+
"verdict": None,
|
|
1481
|
+
},
|
|
1482
|
+
"implement": None,
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
write_state(state_path, transition_state)
|
|
1486
|
+
transition_before = state_path.read_bytes()
|
|
1487
|
+
|
|
1488
|
+
def fail_between_halves() -> None:
|
|
1489
|
+
raise RuntimeError("forced transition failure")
|
|
1490
|
+
|
|
1491
|
+
try:
|
|
1492
|
+
do_transition(
|
|
1493
|
+
transition_state, "plan", "implement", "PASS", None,
|
|
1494
|
+
None, None, None, None, None, devlyn, 0, None, None,
|
|
1495
|
+
"claude", None, between=fail_between_halves,
|
|
1496
|
+
)
|
|
1497
|
+
except RuntimeError as exc:
|
|
1498
|
+
assert str(exc) == "forced transition failure"
|
|
1499
|
+
else:
|
|
1500
|
+
raise AssertionError("forced transition failure did not fire")
|
|
1501
|
+
assert state_path.read_bytes() == transition_before
|
|
1502
|
+
assert transition_state["phases"]["plan"]["completed_at"] is None
|
|
1503
|
+
print("PASS self-test transition atomicity: forced midpoint failure left state unchanged")
|
|
1504
|
+
|
|
1505
|
+
attestation_state = copy.deepcopy(transition_state)
|
|
1506
|
+
attestation_state["phases"]["plan"]["model_requested"] = "wanted-model"
|
|
1507
|
+
attestation_log = devlyn / "transition-attestation.log"
|
|
1508
|
+
attestation_log.write_text("model: other-model\n", encoding="utf-8")
|
|
1509
|
+
write_state(state_path, attestation_state)
|
|
1510
|
+
attestation_before = state_path.read_bytes()
|
|
1511
|
+
try:
|
|
1512
|
+
do_transition(
|
|
1513
|
+
attestation_state, "plan", "implement", "PASS", None,
|
|
1514
|
+
None, None, None, None, str(attestation_log), devlyn,
|
|
1515
|
+
0, None, None, "claude", None,
|
|
1516
|
+
)
|
|
1517
|
+
except SystemExit as exc:
|
|
1518
|
+
assert "BLOCKED:model-attestation-mismatch" in str(exc)
|
|
1519
|
+
else:
|
|
1520
|
+
raise AssertionError("transition accepted mismatched model attestation")
|
|
1521
|
+
assert state_path.read_bytes() == attestation_before
|
|
1522
|
+
print("PASS self-test transition attestation: mismatch left state unchanged")
|
|
1523
|
+
|
|
1524
|
+
try:
|
|
1525
|
+
do_transition(
|
|
1526
|
+
transition_state, "plan", "cleanup", "PASS", None,
|
|
1527
|
+
None, None, None, None, None, devlyn, 0, None, None,
|
|
1528
|
+
"claude", None,
|
|
1529
|
+
)
|
|
1530
|
+
except SystemExit as exc:
|
|
1531
|
+
assert "illegal phase transition: plan -> cleanup" in str(exc)
|
|
1532
|
+
else:
|
|
1533
|
+
raise AssertionError("transition accepted an illegal phase edge")
|
|
1534
|
+
assert state_path.read_bytes() == attestation_before
|
|
1535
|
+
print("PASS self-test transition legal-edge guard: illegal edge left state unchanged")
|
|
1536
|
+
|
|
1537
|
+
transitioned = do_transition(
|
|
1538
|
+
transition_state, "plan", "implement", "PASS", None,
|
|
1539
|
+
None, None, None, None, None, devlyn, 0, None, None,
|
|
1540
|
+
"claude", None,
|
|
1541
|
+
)
|
|
1542
|
+
write_state(state_path, transitioned)
|
|
1543
|
+
assert transitioned["phases"]["plan"]["verdict"] == "PASS"
|
|
1544
|
+
assert transitioned["phases"]["plan"]["completed_at"] is not None
|
|
1545
|
+
assert transitioned["phases"]["implement"]["started_at"] is not None
|
|
1546
|
+
assert transitioned["phases"]["implement"]["verdict"] is None
|
|
1547
|
+
print("PASS self-test transition happy path: complete + spawn committed together")
|
|
1548
|
+
|
|
1549
|
+
cli_state = {
|
|
1550
|
+
"phases": {
|
|
1551
|
+
"plan": {
|
|
1552
|
+
"started_at": "2026-01-01T00:00:00.000Z",
|
|
1553
|
+
"completed_at": None,
|
|
1554
|
+
"duration_ms": None,
|
|
1555
|
+
"round": 0,
|
|
1556
|
+
"triggered_by": None,
|
|
1557
|
+
"verdict": None,
|
|
1558
|
+
},
|
|
1559
|
+
"implement": None,
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
write_state(state_path, cli_state)
|
|
1563
|
+
cli_transition = subprocess.run(
|
|
1564
|
+
[
|
|
1565
|
+
sys.executable, str(pathlib.Path(__file__).resolve()),
|
|
1566
|
+
"--devlyn-dir", str(devlyn), "--phase", "plan", "transition",
|
|
1567
|
+
"--verdict", "PASS", "--next-phase", "implement",
|
|
1568
|
+
"--next-round", "0", "--next-engine", "claude",
|
|
1569
|
+
],
|
|
1570
|
+
capture_output=True, text=True,
|
|
1571
|
+
)
|
|
1572
|
+
assert cli_transition.returncode == 0, cli_transition.stderr
|
|
1573
|
+
cli_receipt = loads_strict_json(cli_transition.stdout)
|
|
1574
|
+
assert cli_receipt["completed_phase"] == "plan"
|
|
1575
|
+
assert cli_receipt["completed_verdict"] == "PASS"
|
|
1576
|
+
assert cli_receipt["next_phase"] == "implement"
|
|
1577
|
+
assert cli_receipt["state_sha256"] == hashlib.sha256(state_path.read_bytes()).hexdigest()
|
|
1578
|
+
print("PASS self-test transition CLI: machine-only JSON receipt")
|
|
1579
|
+
|
|
1580
|
+
open_next = copy.deepcopy(transition_state)
|
|
1581
|
+
open_next["phases"]["implement"] = {
|
|
1582
|
+
"started_at": "2026-01-01T00:00:01.000Z",
|
|
1583
|
+
"completed_at": None,
|
|
1584
|
+
"duration_ms": None,
|
|
1585
|
+
"round": 0,
|
|
1586
|
+
"triggered_by": None,
|
|
1587
|
+
"verdict": None,
|
|
1588
|
+
}
|
|
1589
|
+
write_state(state_path, open_next)
|
|
1590
|
+
open_next_before = state_path.read_bytes()
|
|
1591
|
+
try:
|
|
1592
|
+
do_transition(
|
|
1593
|
+
open_next, "plan", "implement", "PASS", None,
|
|
1594
|
+
None, None, None, None, None, devlyn, 1, None, None,
|
|
1595
|
+
"claude", None,
|
|
1596
|
+
)
|
|
1597
|
+
except SystemExit as exc:
|
|
1598
|
+
assert "open span" in str(exc) and "complete it before respawn" in str(exc)
|
|
1599
|
+
else:
|
|
1600
|
+
raise AssertionError("transition opened a phase that already had an open span")
|
|
1601
|
+
assert state_path.read_bytes() == open_next_before
|
|
1602
|
+
print("PASS self-test transition open-span guard: rejected without mutation")
|
|
1603
|
+
|
|
245
1604
|
# A completed FAIL round must be retained before a fix-loop respawn
|
|
246
1605
|
# resets the live record.
|
|
247
1606
|
write_state(state_path, {"phases": {}})
|
|
@@ -267,8 +1626,10 @@ def self_test() -> int:
|
|
|
267
1626
|
write_state(state_path, {"phases": {}})
|
|
268
1627
|
state = read_state(state_path)
|
|
269
1628
|
do_spawn(state, "verify", 0, None, None, "claude", None)
|
|
1629
|
+
assert state["phases"]["verify"]["judge_durations_ms"] is None
|
|
270
1630
|
state["phases"]["verify"]["verdict"] = "PASS"
|
|
271
1631
|
state["phases"]["verify"]["sub_verdicts"] = {"mechanical": "PASS", "judge": "PASS"}
|
|
1632
|
+
state["phases"]["verify"]["judge_durations_ms"] = {"judge": 23, "pair_judge": None}
|
|
272
1633
|
write_state(state_path, state)
|
|
273
1634
|
state = read_state(state_path)
|
|
274
1635
|
do_complete(state, "verify", None, None, None, None, None, None)
|
|
@@ -276,6 +1637,7 @@ def self_test() -> int:
|
|
|
276
1637
|
verify_entry = read_state(state_path)["phases"]["verify"]
|
|
277
1638
|
assert verify_entry["verdict"] == "PASS", "complete() must preserve pre-set verdict when omitted"
|
|
278
1639
|
assert verify_entry["sub_verdicts"] == {"mechanical": "PASS", "judge": "PASS"}
|
|
1640
|
+
assert verify_entry["judge_durations_ms"] == {"judge": 23, "pair_judge": None}
|
|
279
1641
|
assert verify_entry["completed_at"] is not None
|
|
280
1642
|
|
|
281
1643
|
# An explicit --verdict for VERIFY must be rejected — its verdict is
|
|
@@ -287,6 +1649,8 @@ def self_test() -> int:
|
|
|
287
1649
|
assert "owned by verify-merge-findings.py" in str(e)
|
|
288
1650
|
else:
|
|
289
1651
|
raise AssertionError("complete() must reject an explicit --verdict for VERIFY")
|
|
1652
|
+
do_spawn(state, "verify", 1, "verify", None, None, None)
|
|
1653
|
+
assert state["phases"]["verify"]["judge_durations_ms"] is None
|
|
290
1654
|
|
|
291
1655
|
# Non-VERIFY phases require --verdict explicitly; complete() must not
|
|
292
1656
|
# silently accept an unset verdict the way VERIFY's omit-to-preserve
|
|
@@ -409,6 +1773,727 @@ def self_test() -> int:
|
|
|
409
1773
|
assert cleanup_entry["pre_sha"] == "pre-sha"
|
|
410
1774
|
assert cleanup_entry["post_sha"] == "post-sha"
|
|
411
1775
|
|
|
1776
|
+
# SURFACE_CLOSE keeps its one-shot envelope, adjudication grammar,
|
|
1777
|
+
# scope boundary, rollback, and execution prohibition mechanical.
|
|
1778
|
+
work = devlyn / "surface-repo"
|
|
1779
|
+
work.mkdir()
|
|
1780
|
+
subprocess.run(["git", "init", "-q"], cwd=work, check=True)
|
|
1781
|
+
subprocess.run(["git", "config", "user.email", "self-test@example.invalid"], cwd=work, check=True)
|
|
1782
|
+
subprocess.run(["git", "config", "user.name", "self-test"], cwd=work, check=True)
|
|
1783
|
+
(work / "allowed.txt").write_text("base\n", encoding="utf-8")
|
|
1784
|
+
(work / "blocked.txt").write_text("base\n", encoding="utf-8")
|
|
1785
|
+
(work / "schedule").mkdir()
|
|
1786
|
+
(work / "schedule" / "__init__.py").write_text("line\n" * 700, encoding="utf-8")
|
|
1787
|
+
(work / "test_schedule.py").write_text("line\n", encoding="utf-8")
|
|
1788
|
+
(work / "tests").mkdir()
|
|
1789
|
+
(work / "tests" / "cli.test.js").write_text("line\n" * 170, encoding="utf-8")
|
|
1790
|
+
subprocess.run(
|
|
1791
|
+
["git", "add", "--", "allowed.txt", "blocked.txt", "schedule/__init__.py",
|
|
1792
|
+
"test_schedule.py", "tests/cli.test.js"],
|
|
1793
|
+
cwd=work, check=True,
|
|
1794
|
+
)
|
|
1795
|
+
subprocess.run(["git", "commit", "-qm", "base"], cwd=work, check=True)
|
|
1796
|
+
pre_sha = subprocess.run(
|
|
1797
|
+
["git", "rev-parse", "HEAD"], cwd=work, check=True, capture_output=True, text=True,
|
|
1798
|
+
).stdout.strip()
|
|
1799
|
+
work_devlyn = work / ".devlyn"
|
|
1800
|
+
work_devlyn.mkdir()
|
|
1801
|
+
(work / "kept.txt").write_text("keep\n", encoding="utf-8")
|
|
1802
|
+
goal = work_devlyn / "goal.raw.txt"
|
|
1803
|
+
patch = work_devlyn / "surface-close.input.patch"
|
|
1804
|
+
prompt = work_devlyn / "surface-close.prompt"
|
|
1805
|
+
goal.write_text("goal\n", encoding="utf-8")
|
|
1806
|
+
patch.write_text("patch\n", encoding="utf-8")
|
|
1807
|
+
prompt.write_text("adapter\nbody\ninputs\n", encoding="utf-8")
|
|
1808
|
+
surface_state = {
|
|
1809
|
+
"source": {
|
|
1810
|
+
"goal_path": ".devlyn/goal.raw.txt",
|
|
1811
|
+
"goal_sha256": file_sha256(goal),
|
|
1812
|
+
},
|
|
1813
|
+
"phases": {},
|
|
1814
|
+
}
|
|
1815
|
+
for rejected_engine in (None, "codex"):
|
|
1816
|
+
rejected_state = {"sentinel": True}
|
|
1817
|
+
try:
|
|
1818
|
+
do_spawn(
|
|
1819
|
+
rejected_state, "surface_close", 0, None, pre_sha, rejected_engine, "sonnet",
|
|
1820
|
+
input_patch_sha256=file_sha256(patch), prompt_sha256=file_sha256(prompt),
|
|
1821
|
+
untracked_before=["kept.txt"],
|
|
1822
|
+
)
|
|
1823
|
+
except SystemExit as exc:
|
|
1824
|
+
assert "requires --engine claude" in str(exc)
|
|
1825
|
+
else:
|
|
1826
|
+
raise AssertionError("SURFACE_CLOSE accepted a non-Claude engine")
|
|
1827
|
+
assert rejected_state == {"sentinel": True}
|
|
1828
|
+
rejected_state = {"sentinel": True}
|
|
1829
|
+
try:
|
|
1830
|
+
do_spawn(
|
|
1831
|
+
rejected_state, "surface_close", 0, None, pre_sha, "claude", None,
|
|
1832
|
+
input_patch_sha256=file_sha256(patch), prompt_sha256=file_sha256(prompt),
|
|
1833
|
+
untracked_before=["kept.txt"],
|
|
1834
|
+
)
|
|
1835
|
+
except SystemExit as exc:
|
|
1836
|
+
assert "phases.surface_close spawn requires --model" in str(exc)
|
|
1837
|
+
else:
|
|
1838
|
+
raise AssertionError("SURFACE_CLOSE accepted a missing requested model")
|
|
1839
|
+
assert rejected_state == {"sentinel": True}
|
|
1840
|
+
do_spawn(
|
|
1841
|
+
surface_state, "surface_close", 0, None, pre_sha, "claude", "sonnet",
|
|
1842
|
+
input_patch_sha256=file_sha256(patch), prompt_sha256=file_sha256(prompt),
|
|
1843
|
+
untracked_before=["kept.txt"],
|
|
1844
|
+
)
|
|
1845
|
+
validate_surface_inputs(work, work_devlyn, surface_state)
|
|
1846
|
+
validate_surface_prompt(work_devlyn, surface_state)
|
|
1847
|
+
ensure_surface_clean_baseline(work, work_devlyn, surface_state)
|
|
1848
|
+
surface = validate_authorized_surface(
|
|
1849
|
+
'["allowed.txt", "schedule/**", "test_schedule.py", "tests/**"]'
|
|
1850
|
+
)
|
|
1851
|
+
entry = surface_entry(surface_state)
|
|
1852
|
+
assert entry["prompt_sha256"] == file_sha256(prompt)
|
|
1853
|
+
assert entry["model_requested"] == "sonnet"
|
|
1854
|
+
try:
|
|
1855
|
+
do_spawn(surface_state, "surface_close", 1, None, pre_sha, "claude", "sonnet")
|
|
1856
|
+
except SystemExit as exc:
|
|
1857
|
+
assert "one-shot" in str(exc)
|
|
1858
|
+
else:
|
|
1859
|
+
raise AssertionError("SURFACE_CLOSE re-entry must fail")
|
|
1860
|
+
|
|
1861
|
+
output = work_devlyn / "surface-close.stdout"
|
|
1862
|
+
output.write_text(
|
|
1863
|
+
"UVR-STALE: FIRED allowed.txt:1\n"
|
|
1864
|
+
"PATH-TEST: FIRED allowed.txt:1\nPASS\n",
|
|
1865
|
+
encoding="utf-8",
|
|
1866
|
+
)
|
|
1867
|
+
assert validate_surface_adjudication(work, work_devlyn, surface_state, surface) == {
|
|
1868
|
+
"UVR-STALE": "FIRED", "PATH-TEST": "FIRED",
|
|
1869
|
+
}
|
|
1870
|
+
output.write_text(
|
|
1871
|
+
"UVR-STALE: FIRED allowed.txt:1 — updated visible text\n"
|
|
1872
|
+
"PATH-TEST: N/A allowed.txt:1 — goal names no uncovered path\nPASS\n",
|
|
1873
|
+
encoding="utf-8",
|
|
1874
|
+
)
|
|
1875
|
+
validate_surface_adjudication(work, work_devlyn, surface_state, surface)
|
|
1876
|
+
|
|
1877
|
+
output.write_text(
|
|
1878
|
+
"PATH-TEST review confirms every success/failure path the goal specifies (chaining in both positions, exact-nth-run cancellation via both `run_pending()` and `run_all()`, first-limit-wins with `.until()` in both directions, `next_run`/`idle_seconds` reflecting removal, validation errors, repeated-call override, per-job independence) is already covered by the added tests — no gap found.\n\n"
|
|
1879
|
+
"UVR-STALE: FIRED schedule/__init__.py:690 — `Job.run()`'s docstring described CancelJob only via `.until()`'s deadline, omitting the new `max_runs` cancellation path added to the same method in this diff (line 715); updated the docstring minimally.\n"
|
|
1880
|
+
"PATH-TEST: N/A test_schedule.py — every success/failure path in the goal (chaining order, exact-nth-run cutoff via both `run_pending()`/`run_all()`, `.until()` first-limit-wins both directions, `next_run`/`idle_seconds` post-removal, validation errors, repeat-call override, per-job independence) already has a covering test in the patch.\n"
|
|
1881
|
+
"PASS\n",
|
|
1882
|
+
encoding="utf-8",
|
|
1883
|
+
)
|
|
1884
|
+
validate_surface_adjudication(work, work_devlyn, surface_state, surface)
|
|
1885
|
+
output.write_text(
|
|
1886
|
+
"UVR-STALE: N/A tests/cli.test.js — USAGE in bin/cli.js was already updated in this patch to document `fulfill-wave --input PATH`; no authorized file has stale interface text.\n"
|
|
1887
|
+
"PATH-TEST: FIRED tests/cli.test.js:166 — goal names \"file-read failures\" as a distinct exit-2 path, implemented via the shared catch in `runFulfillWave`, but untested before this addition.\n"
|
|
1888
|
+
"PASS\n",
|
|
1889
|
+
encoding="utf-8",
|
|
1890
|
+
)
|
|
1891
|
+
validate_surface_adjudication(work, work_devlyn, surface_state, surface)
|
|
1892
|
+
|
|
1893
|
+
rejected_outputs = (
|
|
1894
|
+
("PASS\n", "missing UVR-STALE, PATH-TEST"),
|
|
1895
|
+
(
|
|
1896
|
+
"UVR-STALE: N/A allowed.txt:1\n"
|
|
1897
|
+
"PATH-TEST: N/A allowed.txt:1 — evidence\nPASS\n",
|
|
1898
|
+
"requires evidence",
|
|
1899
|
+
),
|
|
1900
|
+
("UVR-STALE: FIRED allowed.txt:1\nPASS\n", "missing PATH-TEST"),
|
|
1901
|
+
(
|
|
1902
|
+
"UVR-STALE: FIRED allowed.txt:1\n"
|
|
1903
|
+
"UVR-STALE: FIRED allowed.txt:1\n"
|
|
1904
|
+
"PATH-TEST: FIRED allowed.txt:1\nPASS\n",
|
|
1905
|
+
"duplicate UVR-STALE",
|
|
1906
|
+
),
|
|
1907
|
+
(
|
|
1908
|
+
"PASS\nUVR-STALE: FIRED allowed.txt:1\n"
|
|
1909
|
+
"PATH-TEST: FIRED allowed.txt:1\n",
|
|
1910
|
+
"exactly one PASS must follow both rows",
|
|
1911
|
+
),
|
|
1912
|
+
(
|
|
1913
|
+
"UVR-STALE: N/A allowed.txt — \n"
|
|
1914
|
+
"PATH-TEST: FIRED allowed.txt:1\nPASS\n",
|
|
1915
|
+
"requires evidence",
|
|
1916
|
+
),
|
|
1917
|
+
(
|
|
1918
|
+
"UVR-STALE: FIRED blocked.txt:1\n"
|
|
1919
|
+
"PATH-TEST: FIRED allowed.txt:1\nPASS\n",
|
|
1920
|
+
"out-of-surface",
|
|
1921
|
+
),
|
|
1922
|
+
(
|
|
1923
|
+
"UVR-STALE: FIRED allowed.txt:2\n"
|
|
1924
|
+
"PATH-TEST: FIRED allowed.txt:1\nPASS\n",
|
|
1925
|
+
"citation-missing",
|
|
1926
|
+
),
|
|
1927
|
+
)
|
|
1928
|
+
for raw_output, marker in rejected_outputs:
|
|
1929
|
+
output.write_text(raw_output, encoding="utf-8")
|
|
1930
|
+
try:
|
|
1931
|
+
validate_surface_adjudication(work, work_devlyn, surface_state, surface)
|
|
1932
|
+
except SystemExit as exc:
|
|
1933
|
+
assert marker in str(exc), (marker, exc)
|
|
1934
|
+
else:
|
|
1935
|
+
raise AssertionError(f"SURFACE_CLOSE accepted invalid adjudication: {marker}")
|
|
1936
|
+
|
|
1937
|
+
output.write_text(
|
|
1938
|
+
"UVR-STALE: N/A allowed.txt:1 — no stale interface text\n"
|
|
1939
|
+
"PATH-TEST: N/A allowed.txt:1 — requested path already covered\nPASS\n",
|
|
1940
|
+
encoding="utf-8",
|
|
1941
|
+
)
|
|
1942
|
+
validate_surface_adjudication(work, work_devlyn, surface_state, surface)
|
|
1943
|
+
|
|
1944
|
+
transcript = work_devlyn / "surface-close.worker-session.0.jsonl"
|
|
1945
|
+
transcript.write_text(json.dumps({
|
|
1946
|
+
"message": {"content": [{
|
|
1947
|
+
"type": "tool_use", "name": "Bash",
|
|
1948
|
+
"input": {"command": "git diff -- allowed.txt"},
|
|
1949
|
+
}]},
|
|
1950
|
+
}) + "\n", encoding="utf-8")
|
|
1951
|
+
validate_surface_execution(work_devlyn, surface_state)
|
|
1952
|
+
for validation_command in ("npm test", "node bin/cli.js version"):
|
|
1953
|
+
transcript.write_text(json.dumps({
|
|
1954
|
+
"message": {"content": [{
|
|
1955
|
+
"type": "tool_use", "name": "Bash",
|
|
1956
|
+
"input": {"command": validation_command},
|
|
1957
|
+
}]},
|
|
1958
|
+
}) + "\n", encoding="utf-8")
|
|
1959
|
+
try:
|
|
1960
|
+
validate_surface_execution(work_devlyn, surface_state)
|
|
1961
|
+
except SystemExit as exc:
|
|
1962
|
+
assert "validation-execution" in str(exc)
|
|
1963
|
+
else:
|
|
1964
|
+
raise AssertionError(
|
|
1965
|
+
f"SURFACE_CLOSE execution audit accepted {validation_command}"
|
|
1966
|
+
)
|
|
1967
|
+
|
|
1968
|
+
transcript.write_text(
|
|
1969
|
+
json.dumps({"message": {"content": [
|
|
1970
|
+
{"type": "tool_use", "name": "Edit", "input": {"file_path": str(work / "allowed.txt")}},
|
|
1971
|
+
{"type": "tool_use", "name": "Write", "input": {"file_path": "tests/new.txt"}},
|
|
1972
|
+
]}}) + "\n",
|
|
1973
|
+
encoding="utf-8",
|
|
1974
|
+
)
|
|
1975
|
+
assert validate_surface_write_audit(
|
|
1976
|
+
work, work_devlyn, surface_state, surface,
|
|
1977
|
+
) == ["allowed.txt", "tests/new.txt"]
|
|
1978
|
+
for invalid_target in (str(work / "blocked.txt"), str(work_devlyn / "hidden.txt"), "../escape.txt"):
|
|
1979
|
+
transcript.write_text(
|
|
1980
|
+
json.dumps({"message": {"content": [{
|
|
1981
|
+
"type": "tool_use", "name": "Edit",
|
|
1982
|
+
"input": {"file_path": invalid_target},
|
|
1983
|
+
}]}}) + "\n",
|
|
1984
|
+
encoding="utf-8",
|
|
1985
|
+
)
|
|
1986
|
+
try:
|
|
1987
|
+
validate_surface_write_audit(work, work_devlyn, surface_state, surface)
|
|
1988
|
+
except SystemExit as exc:
|
|
1989
|
+
assert "write-audit-violation" in str(exc)
|
|
1990
|
+
else:
|
|
1991
|
+
raise AssertionError(f"SURFACE_CLOSE write audit accepted {invalid_target}")
|
|
1992
|
+
|
|
1993
|
+
wrapper_log = work_devlyn / "surface-close.output.json"
|
|
1994
|
+
wrapper_log.write_text(json.dumps({
|
|
1995
|
+
"result": output.read_text(encoding="utf-8"),
|
|
1996
|
+
"modelUsage": {"sonnet": {"inputTokens": 1}},
|
|
1997
|
+
}) + "\n", encoding="utf-8")
|
|
1998
|
+
completion_state = loads_strict_json(json.dumps(surface_state))
|
|
1999
|
+
assert do_complete(
|
|
2000
|
+
completion_state, "surface_close", "PASS", pre_sha, None,
|
|
2001
|
+
".devlyn/surface-close.stdout", None, None, str(wrapper_log),
|
|
2002
|
+
devlyn=work_devlyn,
|
|
2003
|
+
) is None
|
|
2004
|
+
completed_surface = completion_state["phases"]["surface_close"]
|
|
2005
|
+
assert completed_surface["model_requested"] == "sonnet"
|
|
2006
|
+
assert completed_surface["model_effective"] == "sonnet"
|
|
2007
|
+
assert completed_surface["verdict"] == "PASS"
|
|
2008
|
+
|
|
2009
|
+
recovery_state = loads_strict_json(json.dumps(surface_state))
|
|
2010
|
+
recovery_started_at = recovery_state["phases"]["surface_close"]["started_at"]
|
|
2011
|
+
output.write_text("UVR-STALE: FIRED allowed.txt:1\nPASS\n", encoding="utf-8")
|
|
2012
|
+
(work / "allowed.txt").write_text("surface edit\n", encoding="utf-8")
|
|
2013
|
+
transcript.write_text(
|
|
2014
|
+
json.dumps({"message": {"content": [{
|
|
2015
|
+
"type": "tool_use", "name": "Edit",
|
|
2016
|
+
"input": {"file_path": str(work / "allowed.txt")},
|
|
2017
|
+
}]}}) + "\n",
|
|
2018
|
+
encoding="utf-8",
|
|
2019
|
+
)
|
|
2020
|
+
require_surface_adjudication_malformed(
|
|
2021
|
+
work, work_devlyn, recovery_state, surface,
|
|
2022
|
+
)
|
|
2023
|
+
assert rollback_surface_delta(work, work_devlyn, recovery_state) == ["allowed.txt"]
|
|
2024
|
+
assert validate_surface_write_audit(
|
|
2025
|
+
work, work_devlyn, recovery_state, surface,
|
|
2026
|
+
) == ["allowed.txt"]
|
|
2027
|
+
assert do_surface_adjudication_recovery(recovery_state, work_devlyn) is None
|
|
2028
|
+
recovered = recovery_state["phases"]["surface_close"]
|
|
2029
|
+
assert recovered["started_at"] == recovery_started_at
|
|
2030
|
+
assert isinstance(recovered["duration_ms"], int)
|
|
2031
|
+
assert recovered["verdict"] is None
|
|
2032
|
+
assert recovered["skipped_reason"] == SURFACE_RECOVERY_REASON
|
|
2033
|
+
assert recovered["continued_after_block"] is True
|
|
2034
|
+
|
|
2035
|
+
(work / "allowed.txt").write_text("pre-existing\n", encoding="utf-8")
|
|
2036
|
+
try:
|
|
2037
|
+
ensure_surface_clean_baseline(work, work_devlyn, surface_state)
|
|
2038
|
+
except SystemExit as exc:
|
|
2039
|
+
assert "surface-close-preexisting-delta" in str(exc)
|
|
2040
|
+
else:
|
|
2041
|
+
raise AssertionError("SURFACE_CLOSE accepted a pre-existing tracked delta")
|
|
2042
|
+
subprocess.run(["git", "restore", "--", "allowed.txt"], cwd=work, check=True)
|
|
2043
|
+
(work / "allowed.txt").write_text("changed\n", encoding="utf-8")
|
|
2044
|
+
(work / "blocked.txt").write_text("changed\n", encoding="utf-8")
|
|
2045
|
+
(work / "tests" / "new.txt").write_text("new\n", encoding="utf-8")
|
|
2046
|
+
(work / "escape.txt").write_text("new\n", encoding="utf-8")
|
|
2047
|
+
assert surface_offenders(work, work_devlyn, surface_state, surface) == ["blocked.txt", "escape.txt"]
|
|
2048
|
+
restored = rollback_surface_delta(work, work_devlyn, surface_state)
|
|
2049
|
+
assert restored == ["allowed.txt", "blocked.txt", "escape.txt", "tests/new.txt"]
|
|
2050
|
+
assert (work / "allowed.txt").read_text(encoding="utf-8") == "base\n"
|
|
2051
|
+
assert (work / "blocked.txt").read_text(encoding="utf-8") == "base\n"
|
|
2052
|
+
assert (work / "kept.txt").read_text(encoding="utf-8") == "keep\n"
|
|
2053
|
+
assert not (work / "tests" / "new.txt").exists()
|
|
2054
|
+
assert not (work / "escape.txt").exists()
|
|
2055
|
+
|
|
2056
|
+
skipped_state = {"phases": {}}
|
|
2057
|
+
do_surface_skip(skipped_state)
|
|
2058
|
+
skipped = skipped_state["phases"]["surface_close"]
|
|
2059
|
+
assert skipped["verdict"] is None
|
|
2060
|
+
assert skipped["skipped_reason"] == SURFACE_SKIP_REASON
|
|
2061
|
+
|
|
2062
|
+
# Effective model evidence: engine header line and rollout JSONL.
|
|
2063
|
+
header_log = devlyn / "codex-build.log"
|
|
2064
|
+
header_log.write_text("session\nmodel: gpt-5.6-sol\n", encoding="utf-8")
|
|
2065
|
+
assert parse_effective_model(header_log) == "gpt-5.6-sol"
|
|
2066
|
+
rollout_log = devlyn / "rollout.jsonl"
|
|
2067
|
+
rollout_log.write_text(json.dumps({
|
|
2068
|
+
"type": "turn_context", "payload": {"model": "gpt-5.6-terra"},
|
|
2069
|
+
}) + "\n", encoding="utf-8")
|
|
2070
|
+
assert parse_effective_model(rollout_log) == "gpt-5.6-terra"
|
|
2071
|
+
claude_log = devlyn / "claude-result.json"
|
|
2072
|
+
claude_log.write_text(json.dumps({
|
|
2073
|
+
"modelUsage": {"claude-alpha-1": {"inputTokens": 1}},
|
|
2074
|
+
}) + "\n", encoding="utf-8")
|
|
2075
|
+
assert parse_effective_model(claude_log) == "claude-alpha-1"
|
|
2076
|
+
|
|
2077
|
+
# Requested/effective drift is a persisted, fail-closed attestation.
|
|
2078
|
+
write_state(state_path, {"phases": {}})
|
|
2079
|
+
state = read_state(state_path)
|
|
2080
|
+
do_spawn(state, "implement", 0, None, None, "codex", "gpt-5.5")
|
|
2081
|
+
mismatch = do_complete(
|
|
2082
|
+
state, "implement", "PASS", None, None, None, None, None, str(rollout_log)
|
|
2083
|
+
)
|
|
2084
|
+
mismatched = state["phases"]["implement"]
|
|
2085
|
+
assert mismatch and "model-attestation-mismatch" in mismatch
|
|
2086
|
+
assert mismatched["model_requested"] == "gpt-5.5"
|
|
2087
|
+
assert mismatched["model_effective"] == "gpt-5.6-terra"
|
|
2088
|
+
assert mismatched["verdict"] == "BLOCKED"
|
|
2089
|
+
|
|
2090
|
+
# A retained mutation-worker session makes the completion flag
|
|
2091
|
+
# mandatory; without a retained file, null remains legal.
|
|
2092
|
+
retained_log = devlyn / "implement.worker-session.0.jsonl"
|
|
2093
|
+
retained_log.write_text(json.dumps({
|
|
2094
|
+
"type": "turn_context", "payload": {"model": "gpt-5.6-terra"},
|
|
2095
|
+
}) + "\n", encoding="utf-8")
|
|
2096
|
+
write_state(state_path, {"phases": {}})
|
|
2097
|
+
state = read_state(state_path)
|
|
2098
|
+
do_spawn(state, "implement", 0, None, None, "codex", "gpt-5.6-terra")
|
|
2099
|
+
omitted = do_complete(
|
|
2100
|
+
state, "implement", "PASS", None, None, None, None, None,
|
|
2101
|
+
devlyn=devlyn,
|
|
2102
|
+
)
|
|
2103
|
+
assert omitted and "model-attestation-failed" in omitted
|
|
2104
|
+
assert str(retained_log) in omitted
|
|
2105
|
+
assert state["phases"]["implement"]["model_effective"] is None
|
|
2106
|
+
assert state["phases"]["implement"]["verdict"] == "BLOCKED"
|
|
2107
|
+
|
|
2108
|
+
write_state(state_path, {"phases": {}})
|
|
2109
|
+
state = read_state(state_path)
|
|
2110
|
+
do_spawn(state, "implement", 0, None, None, "codex", "gpt-5.6-terra")
|
|
2111
|
+
assert do_complete(
|
|
2112
|
+
state, "implement", "PASS", None, None, None, None, None,
|
|
2113
|
+
str(retained_log), devlyn=devlyn,
|
|
2114
|
+
) is None
|
|
2115
|
+
assert state["phases"]["implement"]["model_effective"] == "gpt-5.6-terra"
|
|
2116
|
+
assert state["phases"]["implement"]["verdict"] == "PASS"
|
|
2117
|
+
|
|
2118
|
+
retained_log.unlink()
|
|
2119
|
+
write_state(state_path, {"phases": {}})
|
|
2120
|
+
state = read_state(state_path)
|
|
2121
|
+
do_spawn(state, "cleanup", 0, None, None, "claude", "claude-default")
|
|
2122
|
+
assert do_complete(
|
|
2123
|
+
state, "cleanup", "PASS", None, None, None, None, None,
|
|
2124
|
+
devlyn=devlyn,
|
|
2125
|
+
) is None
|
|
2126
|
+
assert state["phases"]["cleanup"]["model_effective"] is None
|
|
2127
|
+
|
|
2128
|
+
# Supplied evidence must parse and never silently record null.
|
|
2129
|
+
write_state(state_path, {"phases": {}})
|
|
2130
|
+
state = read_state(state_path)
|
|
2131
|
+
do_spawn(state, "plan", 0, None, None, "claude", "claude-default")
|
|
2132
|
+
assert do_complete(state, "plan", "PASS", None, None, None, None, None) is None
|
|
2133
|
+
assert state["phases"]["plan"]["model_effective"] is None
|
|
2134
|
+
invalid_log = devlyn / "invalid-session.log"
|
|
2135
|
+
invalid_log.write_text("no model evidence\n", encoding="utf-8")
|
|
2136
|
+
write_state(state_path, {"phases": {}})
|
|
2137
|
+
state = read_state(state_path)
|
|
2138
|
+
do_spawn(state, "plan", 0, None, None, "claude", "claude-default")
|
|
2139
|
+
invalid = do_complete(
|
|
2140
|
+
state, "plan", "PASS", None, None, None, None, None, str(invalid_log)
|
|
2141
|
+
)
|
|
2142
|
+
assert invalid and "model-attestation-failed" in invalid
|
|
2143
|
+
assert state["phases"]["plan"]["model_effective"] is None
|
|
2144
|
+
assert state["phases"]["plan"]["verdict"] == "BLOCKED"
|
|
2145
|
+
|
|
2146
|
+
def write_fixture_tree(repo: pathlib.Path, files: dict[str, str]) -> None:
|
|
2147
|
+
existing = {
|
|
2148
|
+
path.relative_to(repo).as_posix() for path in repo.rglob("*")
|
|
2149
|
+
if path.is_file() and ".git" not in path.parts and ".devlyn" not in path.parts
|
|
2150
|
+
}
|
|
2151
|
+
for path in existing - set(files):
|
|
2152
|
+
(repo / path).unlink()
|
|
2153
|
+
for path, content in files.items():
|
|
2154
|
+
target = repo / path
|
|
2155
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
2156
|
+
target.write_text(content, encoding="utf-8")
|
|
2157
|
+
|
|
2158
|
+
def commit_fixture(repo: pathlib.Path, files: dict[str, str], message: str) -> str:
|
|
2159
|
+
write_fixture_tree(repo, files)
|
|
2160
|
+
paths = sorted(set(files) | {
|
|
2161
|
+
path.relative_to(repo).as_posix() for path in repo.rglob("*")
|
|
2162
|
+
if path.is_file() and ".git" not in path.parts and ".devlyn" not in path.parts
|
|
2163
|
+
})
|
|
2164
|
+
subprocess.run(["git", "add", "--all", "--", *paths], cwd=repo, check=True)
|
|
2165
|
+
subprocess.run(["git", "commit", "-qm", message], cwd=repo, check=True)
|
|
2166
|
+
return subprocess.run(
|
|
2167
|
+
["git", "rev-parse", "HEAD"], cwd=repo, check=True,
|
|
2168
|
+
capture_output=True, text=True,
|
|
2169
|
+
).stdout.strip()
|
|
2170
|
+
|
|
2171
|
+
def durability_fixture(
|
|
2172
|
+
name: str, base_files: dict[str, str], surface_files: dict[str, str],
|
|
2173
|
+
fix_files: dict[str, str], origin: str, findings: list[dict], round_: int = 1,
|
|
2174
|
+
with_surface_post: bool = True,
|
|
2175
|
+
) -> tuple[pathlib.Path, pathlib.Path, dict, str, str]:
|
|
2176
|
+
repo = devlyn / name
|
|
2177
|
+
repo.mkdir()
|
|
2178
|
+
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
|
|
2179
|
+
subprocess.run(["git", "config", "user.email", "self-test@example.invalid"], cwd=repo, check=True)
|
|
2180
|
+
subprocess.run(["git", "config", "user.name", "self-test"], cwd=repo, check=True)
|
|
2181
|
+
base_sha = commit_fixture(repo, base_files, "base")
|
|
2182
|
+
surface_sha = commit_fixture(repo, surface_files, "surface close")
|
|
2183
|
+
fix_sha = commit_fixture(repo, fix_files, f"chore(pipeline): implement fix round {round_}")
|
|
2184
|
+
fixture_devlyn = repo / ".devlyn"
|
|
2185
|
+
fixture_devlyn.mkdir()
|
|
2186
|
+
findings_name = (
|
|
2187
|
+
"build_gate.findings.jsonl" if origin == "build_gate"
|
|
2188
|
+
else "verify-merged.findings.jsonl"
|
|
2189
|
+
)
|
|
2190
|
+
(fixture_devlyn / findings_name).write_text(
|
|
2191
|
+
"".join(json.dumps(finding) + "\n" for finding in findings), encoding="utf-8",
|
|
2192
|
+
)
|
|
2193
|
+
surface_entry = {"pre_sha": base_sha, "durability": []}
|
|
2194
|
+
if with_surface_post:
|
|
2195
|
+
surface_entry["post_sha"] = surface_sha
|
|
2196
|
+
fixture_state = {"phases": {"surface_close": surface_entry}}
|
|
2197
|
+
return repo, fixture_devlyn, fixture_state, fix_sha, surface_sha
|
|
2198
|
+
|
|
2199
|
+
# M-CP 1/8/9: exact -19c topology. The VERIFY finding is line 60,
|
|
2200
|
+
# outside the pre-fix USAGE block; the unrelated real fix survives,
|
|
2201
|
+
# the USAGE revert is restored, and the frozen gate's check-7 function
|
|
2202
|
+
# observes the separate closure-restore commit's final tree.
|
|
2203
|
+
base_lines = [f"line {index}\n" for index in range(1, 66)]
|
|
2204
|
+
base_lines[6] = "const USAGE = `\n"
|
|
2205
|
+
base_lines[7] = "usage: cli <command>\n"
|
|
2206
|
+
base_lines[9] = " version Show version\n"
|
|
2207
|
+
base_lines[10] = "`;\n"
|
|
2208
|
+
base_lines[59] = " return args.indexOf('--format')\n"
|
|
2209
|
+
surface_lines = list(base_lines)
|
|
2210
|
+
surface_lines[9] = " version --format <fmt> Show version\n"
|
|
2211
|
+
fix_lines = list(base_lines)
|
|
2212
|
+
fix_lines[59] = " return args.lastIndexOf('--format')\n"
|
|
2213
|
+
exact_repo, exact_devlyn, exact_state, exact_fix, _ = durability_fixture(
|
|
2214
|
+
"durability-exact", {"bin/cli.js": "".join(base_lines)},
|
|
2215
|
+
{"bin/cli.js": "".join(surface_lines)}, {"bin/cli.js": "".join(fix_lines)},
|
|
2216
|
+
"verify", [{"id": "-19c", "file": "bin/cli.js", "line": 60}],
|
|
2217
|
+
)
|
|
2218
|
+
exact_receipt = enforce_closure_durability_reentry(
|
|
2219
|
+
exact_repo, exact_devlyn, exact_state, "verify", 1,
|
|
2220
|
+
)
|
|
2221
|
+
exact_text = (exact_repo / "bin/cli.js").read_text(encoding="utf-8")
|
|
2222
|
+
assert "version --format <fmt>" in exact_text
|
|
2223
|
+
assert "lastIndexOf('--format')" in exact_text
|
|
2224
|
+
assert exact_receipt and exact_receipt["restore_commit_sha"]
|
|
2225
|
+
assert exact_receipt["fix_commit_sha"] == exact_fix
|
|
2226
|
+
assert subprocess.run(
|
|
2227
|
+
["git", "show", "-s", "--format=%s", "HEAD"], cwd=exact_repo,
|
|
2228
|
+
check=True, capture_output=True, text=True,
|
|
2229
|
+
).stdout.strip() == "chore(pipeline): closure-restore round 1"
|
|
2230
|
+
import importlib.util
|
|
2231
|
+
gate_spec = importlib.util.spec_from_file_location(
|
|
2232
|
+
"f7_carrier_gate", pathlib.Path(__file__).resolve().parents[3]
|
|
2233
|
+
/ "benchmark/ceiling/scripts/f7-carrier-gate.py",
|
|
2234
|
+
)
|
|
2235
|
+
gate = importlib.util.module_from_spec(gate_spec)
|
|
2236
|
+
gate_spec.loader.exec_module(gate)
|
|
2237
|
+
assert gate.check7(exact_text)[0]
|
|
2238
|
+
print("PASS M-CP self-test 1/8/9: -19c restore + file-line targeting + frozen check 7")
|
|
2239
|
+
|
|
2240
|
+
# M-CP 2: BUILD_GATE uses the same durability route.
|
|
2241
|
+
build_repo, build_devlyn, build_state, _, _ = durability_fixture(
|
|
2242
|
+
"durability-build", {"a.txt": "old\n"}, {"a.txt": "surface\n"},
|
|
2243
|
+
{"a.txt": "old\n"}, "build_gate",
|
|
2244
|
+
[{"id": "BG", "file": "a.txt", "line": 8}],
|
|
2245
|
+
)
|
|
2246
|
+
build_state["phases"]["implement"] = {"round": 1, "triggered_by": "build_gate"}
|
|
2247
|
+
write_state(build_devlyn / "pipeline.state.json", build_state)
|
|
2248
|
+
subprocess.run(
|
|
2249
|
+
[sys.executable, str(pathlib.Path(__file__).resolve()), "--devlyn-dir", ".devlyn",
|
|
2250
|
+
"--phase", "implement", "durability-enforce", "--round", "1",
|
|
2251
|
+
"--origin-phase", "build_gate"],
|
|
2252
|
+
cwd=build_repo, check=True, capture_output=True,
|
|
2253
|
+
)
|
|
2254
|
+
subprocess.run(
|
|
2255
|
+
[sys.executable, str(pathlib.Path(__file__).resolve()), "--devlyn-dir", ".devlyn",
|
|
2256
|
+
"--phase", "build_gate", "spawn", "--round", "1"],
|
|
2257
|
+
cwd=build_repo, check=True, capture_output=True,
|
|
2258
|
+
)
|
|
2259
|
+
# The next phase's first spawn shares rounds.global=1 but is not a
|
|
2260
|
+
# VERIFY re-entry; it must not reinterpret the BUILD_GATE receipt.
|
|
2261
|
+
subprocess.run(
|
|
2262
|
+
[sys.executable, str(pathlib.Path(__file__).resolve()), "--devlyn-dir", ".devlyn",
|
|
2263
|
+
"--phase", "verify", "spawn", "--round", "1"],
|
|
2264
|
+
cwd=build_repo, check=True, capture_output=True,
|
|
2265
|
+
)
|
|
2266
|
+
build_receipt = loads_strict_json(
|
|
2267
|
+
(build_devlyn / "closure-durability.round-1.json").read_text(encoding="utf-8")
|
|
2268
|
+
)
|
|
2269
|
+
assert (build_repo / "a.txt").read_text(encoding="utf-8") == "surface\n"
|
|
2270
|
+
assert build_receipt and build_receipt["origin_phase"] == "build_gate"
|
|
2271
|
+
print("PASS M-CP self-test 2: build_gate and verify routes")
|
|
2272
|
+
|
|
2273
|
+
# M-CP 3: block-granular multi-file partial revert mixture.
|
|
2274
|
+
mix_base = {
|
|
2275
|
+
"mix.txt": "A\nkeep-1\nB\nkeep-2\nC\n", "other.txt": "old\n",
|
|
2276
|
+
"context.txt": "before\nold\nafter\n",
|
|
2277
|
+
}
|
|
2278
|
+
mix_surface = {
|
|
2279
|
+
"mix.txt": "A-sc\nkeep-1\nB-sc\nkeep-2\nC-sc\n", "other.txt": "new\n",
|
|
2280
|
+
"context.txt": "before\nnew\nafter\n",
|
|
2281
|
+
}
|
|
2282
|
+
mix_fix = {
|
|
2283
|
+
"mix.txt": "A-sc\nkeep-1\nB-evolved\nkeep-2\nC\n", "other.txt": "old\n",
|
|
2284
|
+
"context.txt": "changed-context\nold\nafter\n",
|
|
2285
|
+
}
|
|
2286
|
+
mix_repo, mix_devlyn, mix_state, _, _ = durability_fixture(
|
|
2287
|
+
"durability-mix", mix_base, mix_surface, mix_fix, "verify", [],
|
|
2288
|
+
)
|
|
2289
|
+
mix_receipt = enforce_closure_durability_reentry(
|
|
2290
|
+
mix_repo, mix_devlyn, mix_state, "verify", 1,
|
|
2291
|
+
)
|
|
2292
|
+
mix_classes = [block["classification"] for block in mix_receipt["blocks"]]
|
|
2293
|
+
assert {"SURVIVED", "EVOLVED", "REVERTED"} <= set(mix_classes), mix_receipt["blocks"]
|
|
2294
|
+
assert (mix_repo / "mix.txt").read_text(encoding="utf-8") == (
|
|
2295
|
+
"A-sc\nkeep-1\nB-evolved\nkeep-2\nC-sc\n"
|
|
2296
|
+
)
|
|
2297
|
+
assert (mix_repo / "other.txt").read_text(encoding="utf-8") == "new\n"
|
|
2298
|
+
assert (mix_repo / "context.txt").read_text(encoding="utf-8") == (
|
|
2299
|
+
"changed-context\nold\nafter\n"
|
|
2300
|
+
)
|
|
2301
|
+
print("PASS M-CP self-test 3: multi-file SURVIVED/EVOLVED/REVERTED partial restore")
|
|
2302
|
+
|
|
2303
|
+
# M-CP 4: a deleted additive SC block is REVERTED and restored.
|
|
2304
|
+
add_repo, add_devlyn, add_state, _, _ = durability_fixture(
|
|
2305
|
+
"durability-add", {"docs/x.txt": "head\ntail\n"},
|
|
2306
|
+
{"docs/x.txt": "head\na/old/docs/x.txt\ntail\n"},
|
|
2307
|
+
{"docs/x.txt": "head\ntail\n"}, "verify", [],
|
|
2308
|
+
)
|
|
2309
|
+
add_receipt = enforce_closure_durability_reentry(add_repo, add_devlyn, add_state, "verify", 1)
|
|
2310
|
+
assert (add_repo / "docs/x.txt").read_text(encoding="utf-8") == (
|
|
2311
|
+
"head\na/old/docs/x.txt\ntail\n"
|
|
2312
|
+
)
|
|
2313
|
+
assert any(block["classification"] == "REVERTED" for block in add_receipt["blocks"])
|
|
2314
|
+
print("PASS M-CP self-test 4: deleted additive block restored")
|
|
2315
|
+
|
|
2316
|
+
# M-CP 5: an exact line-targeted deletion is preserved, not restored.
|
|
2317
|
+
target_repo, target_devlyn, target_state, target_fix, _ = durability_fixture(
|
|
2318
|
+
"durability-target", {"target.txt": "head\ntail\n"},
|
|
2319
|
+
{"target.txt": "head\nconsolidate\ntail\n"}, {"target.txt": "head\ntail\n"},
|
|
2320
|
+
"verify", [{"id": "E1", "path": "target.txt", "line": 2}],
|
|
2321
|
+
)
|
|
2322
|
+
target_receipt = enforce_closure_durability_reentry(
|
|
2323
|
+
target_repo, target_devlyn, target_state, "verify", 1,
|
|
2324
|
+
)
|
|
2325
|
+
assert (target_repo / "target.txt").read_text(encoding="utf-8") == "head\ntail\n"
|
|
2326
|
+
assert target_receipt["restore_commit_sha"] is None
|
|
2327
|
+
assert target_receipt["post_restore_sha"] == target_fix
|
|
2328
|
+
assert target_receipt["blocks"][0]["finding_targeted"] is True
|
|
2329
|
+
print("PASS M-CP self-test 5: finding-targeted deletion not restored")
|
|
2330
|
+
|
|
2331
|
+
# M-CP 6: once ledgered, a missing or stale receipt blocks re-entry.
|
|
2332
|
+
target_path = target_devlyn / "closure-durability.round-1.json"
|
|
2333
|
+
target_raw = target_path.read_bytes()
|
|
2334
|
+
target_state["phases"]["implement"] = {"round": 1, "triggered_by": "verify"}
|
|
2335
|
+
write_state(target_devlyn / "pipeline.state.json", target_state)
|
|
2336
|
+
stale_verify_artifact = target_devlyn / "verify.findings.jsonl"
|
|
2337
|
+
stale_verify_artifact.write_text("stale\n", encoding="utf-8")
|
|
2338
|
+
target_path.unlink()
|
|
2339
|
+
for mode in ("missing", "stale"):
|
|
2340
|
+
if mode == "stale":
|
|
2341
|
+
target_path.write_bytes(target_raw.replace(b'"schema_version": 1', b'"schema_version": 2'))
|
|
2342
|
+
try:
|
|
2343
|
+
enforce_closure_durability_reentry(
|
|
2344
|
+
target_repo, target_devlyn, target_state, "verify", 1,
|
|
2345
|
+
)
|
|
2346
|
+
except SystemExit as exc:
|
|
2347
|
+
assert "closure-durability-receipt" in str(exc)
|
|
2348
|
+
else:
|
|
2349
|
+
raise AssertionError(f"{mode} durability receipt was accepted")
|
|
2350
|
+
if mode == "missing":
|
|
2351
|
+
reentry = subprocess.run(
|
|
2352
|
+
[sys.executable, str(pathlib.Path(__file__).resolve()),
|
|
2353
|
+
"--devlyn-dir", ".devlyn", "--phase", "verify", "spawn",
|
|
2354
|
+
"--round", "1"],
|
|
2355
|
+
cwd=target_repo, capture_output=True, text=True,
|
|
2356
|
+
)
|
|
2357
|
+
assert reentry.returncode != 0
|
|
2358
|
+
assert "closure-durability-receipt" in reentry.stderr
|
|
2359
|
+
assert stale_verify_artifact.exists(), "re-entry guard must run before VERIFY clearing"
|
|
2360
|
+
target_path.write_bytes(target_raw)
|
|
2361
|
+
|
|
2362
|
+
# Skipping the explicit post-fix checkpoint cannot be repaired by the
|
|
2363
|
+
# spawn guard: re-entry is validation-only and both artifacts missing
|
|
2364
|
+
# must block without changing the fix tree.
|
|
2365
|
+
skipped_repo, skipped_devlyn, skipped_state, skipped_fix, _ = durability_fixture(
|
|
2366
|
+
"durability-skipped-checkpoint", {"skip.txt": "base\n"},
|
|
2367
|
+
{"skip.txt": "surface\n"}, {"skip.txt": "base\n"}, "build_gate", [],
|
|
2368
|
+
)
|
|
2369
|
+
skipped_state["phases"]["implement"] = {"round": 1, "triggered_by": "build_gate"}
|
|
2370
|
+
write_state(skipped_devlyn / "pipeline.state.json", skipped_state)
|
|
2371
|
+
skipped = subprocess.run(
|
|
2372
|
+
[sys.executable, str(pathlib.Path(__file__).resolve()),
|
|
2373
|
+
"--devlyn-dir", ".devlyn", "--phase", "build_gate", "spawn", "--round", "1"],
|
|
2374
|
+
cwd=skipped_repo, capture_output=True, text=True,
|
|
2375
|
+
)
|
|
2376
|
+
assert skipped.returncode != 0 and "checkpoint receipt is missing" in skipped.stderr
|
|
2377
|
+
assert not (skipped_devlyn / "closure-durability.round-1.json").exists()
|
|
2378
|
+
assert subprocess.run(
|
|
2379
|
+
["git", "rev-parse", "HEAD"], cwd=skipped_repo, check=True,
|
|
2380
|
+
capture_output=True, text=True,
|
|
2381
|
+
).stdout.strip() == skipped_fix
|
|
2382
|
+
|
|
2383
|
+
receipt_repo, receipt_devlyn, receipt_state, receipt_fix, _ = durability_fixture(
|
|
2384
|
+
"durability-receipt-write-fail", {"receipt.txt": "base\n"},
|
|
2385
|
+
{"receipt.txt": "surface\n"}, {"receipt.txt": "base\n"}, "verify", [],
|
|
2386
|
+
)
|
|
2387
|
+
original_json_writer = globals()["_write_json_atomic"]
|
|
2388
|
+
def fail_receipt_write(_path, _value):
|
|
2389
|
+
raise OSError("injected receipt write failure")
|
|
2390
|
+
globals()["_write_json_atomic"] = fail_receipt_write
|
|
2391
|
+
try:
|
|
2392
|
+
try:
|
|
2393
|
+
enforce_closure_durability_reentry(
|
|
2394
|
+
receipt_repo, receipt_devlyn, receipt_state, "verify", 1,
|
|
2395
|
+
)
|
|
2396
|
+
except SystemExit as exc:
|
|
2397
|
+
assert "injected receipt write failure" in str(exc)
|
|
2398
|
+
else:
|
|
2399
|
+
raise AssertionError("receipt write failure did not fail closed")
|
|
2400
|
+
finally:
|
|
2401
|
+
globals()["_write_json_atomic"] = original_json_writer
|
|
2402
|
+
assert _git_output(receipt_repo, "rev-parse", "HEAD").decode().strip() == receipt_fix
|
|
2403
|
+
assert (receipt_repo / "receipt.txt").read_text(encoding="utf-8") == "base\n"
|
|
2404
|
+
assert not (receipt_devlyn / "closure-durability.round-1.json").exists()
|
|
2405
|
+
assert receipt_state["phases"]["surface_close"]["durability"] == []
|
|
2406
|
+
|
|
2407
|
+
state_repo, state_devlyn, state_state, state_fix, _ = durability_fixture(
|
|
2408
|
+
"durability-state-write-fail", {"state.txt": "base\n"},
|
|
2409
|
+
{"state.txt": "surface\n"}, {"state.txt": "base\n"}, "verify", [],
|
|
2410
|
+
)
|
|
2411
|
+
def fail_state_write(_path, _state):
|
|
2412
|
+
raise OSError("injected state write failure")
|
|
2413
|
+
try:
|
|
2414
|
+
_persist_durability_event(
|
|
2415
|
+
state_repo, state_devlyn, state_state,
|
|
2416
|
+
state_devlyn / "pipeline.state.json", "verify", 1,
|
|
2417
|
+
writer=fail_state_write,
|
|
2418
|
+
)
|
|
2419
|
+
except OSError as exc:
|
|
2420
|
+
assert "injected state write failure" in str(exc)
|
|
2421
|
+
else:
|
|
2422
|
+
raise AssertionError("state write failure did not fail closed")
|
|
2423
|
+
assert _git_output(state_repo, "rev-parse", "HEAD").decode().strip() == state_fix
|
|
2424
|
+
assert (state_repo / "state.txt").read_text(encoding="utf-8") == "base\n"
|
|
2425
|
+
assert not (state_devlyn / "closure-durability.round-1.json").exists()
|
|
2426
|
+
assert state_state["phases"]["surface_close"]["durability"] == []
|
|
2427
|
+
print("PASS M-CP self-test 6: missing/stale receipt fails closed")
|
|
2428
|
+
|
|
2429
|
+
# M-CP 7: failed patch preflight leaves index and worktree byte-clean.
|
|
2430
|
+
before_status = _tracked_status(target_repo)
|
|
2431
|
+
before_bytes = (target_repo / "target.txt").read_bytes()
|
|
2432
|
+
try:
|
|
2433
|
+
_apply_restore_patch(target_repo, b"not a patch\n", ["target.txt"])
|
|
2434
|
+
except SystemExit as exc:
|
|
2435
|
+
assert "closure-durability-apply" in str(exc)
|
|
2436
|
+
else:
|
|
2437
|
+
raise AssertionError("invalid restore patch was accepted")
|
|
2438
|
+
assert _tracked_status(target_repo) == before_status
|
|
2439
|
+
assert (target_repo / "target.txt").read_bytes() == before_bytes
|
|
2440
|
+
|
|
2441
|
+
binary_repo = devlyn / "durability-binary"
|
|
2442
|
+
binary_repo.mkdir()
|
|
2443
|
+
subprocess.run(["git", "init", "-q"], cwd=binary_repo, check=True)
|
|
2444
|
+
subprocess.run(["git", "config", "user.email", "self-test@example.invalid"], cwd=binary_repo, check=True)
|
|
2445
|
+
subprocess.run(["git", "config", "user.name", "self-test"], cwd=binary_repo, check=True)
|
|
2446
|
+
binary_path = binary_repo / "binary.dat"
|
|
2447
|
+
binary_path.write_bytes(b"\x00base\xff\n")
|
|
2448
|
+
subprocess.run(["git", "add", "--", "binary.dat"], cwd=binary_repo, check=True)
|
|
2449
|
+
subprocess.run(["git", "commit", "-qm", "base"], cwd=binary_repo, check=True)
|
|
2450
|
+
wanted_binary = b"\x00a/old/binary.dat\xffb/new/binary.dat\n"
|
|
2451
|
+
binary_patch = _restore_patch(binary_repo, {"binary.dat": wanted_binary})
|
|
2452
|
+
_apply_restore_patch(binary_repo, binary_patch, ["binary.dat"])
|
|
2453
|
+
assert binary_path.read_bytes() == wanted_binary
|
|
2454
|
+
assert _git_output(binary_repo, "show", ":binary.dat") == wanted_binary
|
|
2455
|
+
|
|
2456
|
+
literal_repo = devlyn / "durability-header-literal"
|
|
2457
|
+
literal_repo.mkdir()
|
|
2458
|
+
subprocess.run(["git", "init", "-q"], cwd=literal_repo, check=True)
|
|
2459
|
+
subprocess.run(["git", "config", "user.email", "self-test@example.invalid"], cwd=literal_repo, check=True)
|
|
2460
|
+
subprocess.run(["git", "config", "user.name", "self-test"], cwd=literal_repo, check=True)
|
|
2461
|
+
literal_path = literal_repo / "literal.txt"
|
|
2462
|
+
literal_path.write_bytes(b"-- a/old/literal.txt\n")
|
|
2463
|
+
subprocess.run(["git", "add", "--", "literal.txt"], cwd=literal_repo, check=True)
|
|
2464
|
+
subprocess.run(["git", "commit", "-qm", "base"], cwd=literal_repo, check=True)
|
|
2465
|
+
wanted_literal = b"++ b/new/literal.txt\n"
|
|
2466
|
+
literal_patch = _restore_patch(literal_repo, {"literal.txt": wanted_literal})
|
|
2467
|
+
_apply_restore_patch(literal_repo, literal_patch, ["literal.txt"])
|
|
2468
|
+
assert literal_path.read_bytes() == wanted_literal
|
|
2469
|
+
assert _git_output(literal_repo, "show", ":literal.txt") == wanted_literal
|
|
2470
|
+
print("PASS M-CP self-test 7: apply failure leaves zero partial mutation")
|
|
2471
|
+
|
|
2472
|
+
# File-only findings are deliberately non-targeting.
|
|
2473
|
+
file_repo, file_devlyn, file_state, _, _ = durability_fixture(
|
|
2474
|
+
"durability-file-only", {"usage.txt": "usage old\n"},
|
|
2475
|
+
{"usage.txt": "usage --format\n"}, {"usage.txt": "usage old\n"},
|
|
2476
|
+
"verify", [{"id": "file-only", "file": "usage.txt"}],
|
|
2477
|
+
)
|
|
2478
|
+
file_receipt = enforce_closure_durability_reentry(
|
|
2479
|
+
file_repo, file_devlyn, file_state, "verify", 1,
|
|
2480
|
+
)
|
|
2481
|
+
assert (file_repo / "usage.txt").read_text(encoding="utf-8") == "usage --format\n"
|
|
2482
|
+
assert file_receipt["blocks"][0]["finding_targeted"] is False
|
|
2483
|
+
print("PASS M-CP self-test 8: file-only finding cannot mask restore")
|
|
2484
|
+
|
|
2485
|
+
# Skipped/no-post-SHA is receipt-visible and commit-free.
|
|
2486
|
+
noop_repo, noop_devlyn, noop_state, noop_fix, _ = durability_fixture(
|
|
2487
|
+
"durability-noop", {"noop.txt": "base\n"}, {"noop.txt": "surface\n"},
|
|
2488
|
+
{"noop.txt": "fix\n"}, "build_gate", [], with_surface_post=False,
|
|
2489
|
+
)
|
|
2490
|
+
noop_receipt = enforce_closure_durability_reentry(
|
|
2491
|
+
noop_repo, noop_devlyn, noop_state, "build_gate", 1,
|
|
2492
|
+
)
|
|
2493
|
+
assert noop_receipt["blocks"] == [] and noop_receipt["restore_commit_sha"] is None
|
|
2494
|
+
assert noop_receipt["post_restore_sha"] == noop_fix
|
|
2495
|
+
print("PASS M-CP self-test no-op: no post_sha writes receipt without commit")
|
|
2496
|
+
|
|
412
2497
|
return 0
|
|
413
2498
|
|
|
414
2499
|
|
|
@@ -423,6 +2508,9 @@ def main() -> int:
|
|
|
423
2508
|
spawn_p.add_argument("--round", type=int, required=True)
|
|
424
2509
|
spawn_p.add_argument("--triggered-by", choices=sorted(VALID_TRIGGERS), default=None)
|
|
425
2510
|
spawn_p.add_argument("--pre-sha", default=None)
|
|
2511
|
+
spawn_p.add_argument("--input-patch-sha256", default=None)
|
|
2512
|
+
spawn_p.add_argument("--prompt-sha256", default=None)
|
|
2513
|
+
spawn_p.add_argument("--untracked-before-json", default=None)
|
|
426
2514
|
spawn_p.add_argument("--engine", default=None)
|
|
427
2515
|
spawn_p.add_argument("--model", default=None)
|
|
428
2516
|
|
|
@@ -433,13 +2521,45 @@ def main() -> int:
|
|
|
433
2521
|
complete_p.add_argument("--log-file", default=None)
|
|
434
2522
|
complete_p.add_argument("--engine", default=None)
|
|
435
2523
|
complete_p.add_argument("--model", default=None)
|
|
2524
|
+
complete_p.add_argument("--engine-session-log", default=None)
|
|
2525
|
+
|
|
2526
|
+
transition_p = sub.add_parser("transition")
|
|
2527
|
+
transition_p.add_argument("--verdict", choices=sorted(VALID_VERDICTS), default=None)
|
|
2528
|
+
transition_p.add_argument("--post-sha", default=None)
|
|
2529
|
+
transition_p.add_argument("--findings-file", default=None)
|
|
2530
|
+
transition_p.add_argument("--log-file", default=None)
|
|
2531
|
+
transition_p.add_argument("--engine", default=None)
|
|
2532
|
+
transition_p.add_argument("--model", default=None)
|
|
2533
|
+
transition_p.add_argument("--engine-session-log", default=None)
|
|
2534
|
+
transition_p.add_argument("--next-phase", choices=sorted(PHASE_NAMES), required=True)
|
|
2535
|
+
transition_p.add_argument("--next-round", type=int, required=True)
|
|
2536
|
+
transition_p.add_argument("--next-triggered-by", choices=sorted(VALID_TRIGGERS), default=None)
|
|
2537
|
+
transition_p.add_argument("--next-pre-sha", default=None)
|
|
2538
|
+
transition_p.add_argument("--next-input-patch-sha256", default=None)
|
|
2539
|
+
transition_p.add_argument("--next-prompt-sha256", default=None)
|
|
2540
|
+
transition_p.add_argument("--next-untracked-before-json", default=None)
|
|
2541
|
+
transition_p.add_argument("--next-engine", default=None)
|
|
2542
|
+
transition_p.add_argument("--next-model", default=None)
|
|
2543
|
+
|
|
2544
|
+
check_p = sub.add_parser("surface-check")
|
|
2545
|
+
check_p.add_argument("--authorized-surface-json", required=True)
|
|
2546
|
+
recover_p = sub.add_parser("surface-adjudication-recover")
|
|
2547
|
+
recover_p.add_argument("--authorized-surface-json", required=True)
|
|
2548
|
+
sub.add_parser("surface-rollback")
|
|
2549
|
+
sub.add_parser("surface-skip")
|
|
2550
|
+
durability_p = sub.add_parser("durability-enforce")
|
|
2551
|
+
durability_p.add_argument("--round", type=int, required=True)
|
|
2552
|
+
durability_p.add_argument("--origin-phase", choices=sorted(VALID_TRIGGERS), required=True)
|
|
436
2553
|
|
|
437
2554
|
args = ap.parse_args()
|
|
438
2555
|
if args.self_test:
|
|
439
2556
|
return self_test()
|
|
440
2557
|
|
|
441
|
-
|
|
442
|
-
|
|
2558
|
+
surface_events = {
|
|
2559
|
+
"surface-check", "surface-adjudication-recover", "surface-rollback", "surface-skip",
|
|
2560
|
+
}
|
|
2561
|
+
if not args.phase or args.event not in {"spawn", "complete", "transition", "durability-enforce", *surface_events}:
|
|
2562
|
+
ap.error("--phase and a phase event are required unless --self-test")
|
|
443
2563
|
|
|
444
2564
|
devlyn = pathlib.Path(args.devlyn_dir)
|
|
445
2565
|
if not devlyn.is_dir():
|
|
@@ -448,14 +2568,145 @@ def main() -> int:
|
|
|
448
2568
|
state_path = devlyn / "pipeline.state.json"
|
|
449
2569
|
state = read_state(state_path)
|
|
450
2570
|
|
|
451
|
-
if args.event == "
|
|
452
|
-
if args.phase
|
|
2571
|
+
if args.event == "durability-enforce":
|
|
2572
|
+
if args.phase != "implement":
|
|
2573
|
+
ap.error("durability-enforce is valid only for --phase implement")
|
|
2574
|
+
_persist_durability_event(
|
|
2575
|
+
pathlib.Path.cwd(), devlyn, state, state_path, args.origin_phase, args.round,
|
|
2576
|
+
)
|
|
2577
|
+
sys.stdout.write(f"ok: phases.surface_close.durability.round-{args.round}\n")
|
|
2578
|
+
return 0
|
|
2579
|
+
|
|
2580
|
+
if args.event in surface_events:
|
|
2581
|
+
if args.phase != "surface_close":
|
|
2582
|
+
ap.error(f"{args.event} is valid only for --phase surface_close")
|
|
2583
|
+
if args.event == "surface-skip":
|
|
2584
|
+
do_surface_skip(state)
|
|
2585
|
+
write_state(state_path, state)
|
|
2586
|
+
sys.stdout.write("ok: phases.surface_close.surface-skip\n")
|
|
2587
|
+
return 0
|
|
2588
|
+
work = pathlib.Path.cwd()
|
|
2589
|
+
if args.event == "surface-adjudication-recover":
|
|
2590
|
+
validate_surface_inputs(work, devlyn, state)
|
|
2591
|
+
validate_surface_prompt(devlyn, state)
|
|
2592
|
+
surface = validate_authorized_surface(args.authorized_surface_json)
|
|
2593
|
+
offenders = surface_offenders(work, devlyn, state, surface)
|
|
2594
|
+
if offenders:
|
|
2595
|
+
raise SystemExit(
|
|
2596
|
+
"BLOCKED:surface-close-out-of-surface: " + json.dumps(offenders)
|
|
2597
|
+
)
|
|
2598
|
+
validate_surface_execution(devlyn, state)
|
|
2599
|
+
require_surface_adjudication_malformed(work, devlyn, state, surface)
|
|
2600
|
+
rollback_surface_delta(work, devlyn, state)
|
|
2601
|
+
tracked, untracked = surface_delta_paths(work, devlyn, state)
|
|
2602
|
+
if tracked or untracked:
|
|
2603
|
+
raise SystemExit(
|
|
2604
|
+
"BLOCKED:surface-close-rollback-failed: "
|
|
2605
|
+
+ json.dumps(sorted(set(tracked + untracked)))
|
|
2606
|
+
)
|
|
2607
|
+
validate_surface_write_audit(work, devlyn, state, surface)
|
|
2608
|
+
attestation_error = do_surface_adjudication_recovery(state, devlyn)
|
|
2609
|
+
write_state(state_path, state)
|
|
2610
|
+
if attestation_error is not None:
|
|
2611
|
+
sys.stderr.write(attestation_error + "\n")
|
|
2612
|
+
return 1
|
|
2613
|
+
sys.stdout.write("ok: phases.surface_close.surface-adjudication-recover\n")
|
|
2614
|
+
return 0
|
|
2615
|
+
if args.event == "surface-check":
|
|
2616
|
+
validate_surface_inputs(work, devlyn, state)
|
|
2617
|
+
surface = validate_authorized_surface(args.authorized_surface_json)
|
|
2618
|
+
offenders = surface_offenders(work, devlyn, state, surface)
|
|
2619
|
+
if offenders:
|
|
2620
|
+
sys.stderr.write("BLOCKED:surface-close-out-of-scope: " + json.dumps(offenders) + "\n")
|
|
2621
|
+
return 2
|
|
2622
|
+
validate_surface_adjudication(work, devlyn, state, surface)
|
|
2623
|
+
validate_surface_execution(devlyn, state)
|
|
2624
|
+
sys.stdout.write("ok: phases.surface_close.surface-check\n")
|
|
2625
|
+
return 0
|
|
2626
|
+
restored = rollback_surface_delta(work, devlyn, state)
|
|
2627
|
+
sys.stdout.write("ok: phases.surface_close.surface-rollback " + json.dumps(restored) + "\n")
|
|
2628
|
+
return 0
|
|
2629
|
+
|
|
2630
|
+
if args.event in {"spawn", "transition"}:
|
|
2631
|
+
spawn_phase = args.phase if args.event == "spawn" else args.next_phase
|
|
2632
|
+
spawn_round = args.round if args.event == "spawn" else args.next_round
|
|
2633
|
+
implement = (state.get("phases") or {}).get("implement")
|
|
2634
|
+
fix_reentry = (
|
|
2635
|
+
spawn_phase in VALID_TRIGGERS and spawn_round >= 1
|
|
2636
|
+
and isinstance(implement, dict)
|
|
2637
|
+
and implement.get("round") == spawn_round
|
|
2638
|
+
and implement.get("triggered_by") == spawn_phase
|
|
2639
|
+
)
|
|
2640
|
+
if fix_reentry:
|
|
2641
|
+
enforce_closure_durability_reentry(
|
|
2642
|
+
pathlib.Path.cwd(), devlyn, state, spawn_phase, spawn_round,
|
|
2643
|
+
require_existing=True,
|
|
2644
|
+
)
|
|
2645
|
+
if args.event == "spawn":
|
|
2646
|
+
untracked_before = (
|
|
2647
|
+
None if args.untracked_before_json is None
|
|
2648
|
+
else parse_string_list(args.untracked_before_json, "--untracked-before-json")
|
|
2649
|
+
)
|
|
2650
|
+
do_spawn(
|
|
2651
|
+
state, args.phase, args.round, args.triggered_by, args.pre_sha, args.engine, args.model,
|
|
2652
|
+
input_patch_sha256=args.input_patch_sha256,
|
|
2653
|
+
prompt_sha256=args.prompt_sha256,
|
|
2654
|
+
untracked_before=untracked_before,
|
|
2655
|
+
)
|
|
2656
|
+
else:
|
|
2657
|
+
next_untracked_before = (
|
|
2658
|
+
None if args.next_untracked_before_json is None
|
|
2659
|
+
else parse_string_list(
|
|
2660
|
+
args.next_untracked_before_json, "--next-untracked-before-json"
|
|
2661
|
+
)
|
|
2662
|
+
)
|
|
2663
|
+
state = do_transition(
|
|
2664
|
+
state, args.phase, args.next_phase, args.verdict, args.post_sha,
|
|
2665
|
+
args.findings_file, args.log_file, args.engine, args.model,
|
|
2666
|
+
args.engine_session_log, devlyn, args.next_round,
|
|
2667
|
+
args.next_triggered_by, args.next_pre_sha, args.next_engine,
|
|
2668
|
+
args.next_model,
|
|
2669
|
+
next_input_patch_sha256=args.next_input_patch_sha256,
|
|
2670
|
+
next_prompt_sha256=args.next_prompt_sha256,
|
|
2671
|
+
next_untracked_before=next_untracked_before,
|
|
2672
|
+
)
|
|
2673
|
+
if spawn_phase == "surface_close":
|
|
2674
|
+
validate_surface_inputs(pathlib.Path.cwd(), devlyn, state)
|
|
2675
|
+
validate_surface_prompt(devlyn, state)
|
|
2676
|
+
ensure_surface_clean_baseline(pathlib.Path.cwd(), devlyn, state)
|
|
2677
|
+
write_state(state_path, state)
|
|
2678
|
+
if spawn_phase == "verify":
|
|
453
2679
|
clear_verify_round_artifacts(devlyn)
|
|
454
|
-
|
|
2680
|
+
if args.event == "transition":
|
|
2681
|
+
opened = state["phases"][args.next_phase]
|
|
2682
|
+
completed = (
|
|
2683
|
+
opened["history"][-1]
|
|
2684
|
+
if args.phase == args.next_phase
|
|
2685
|
+
else state["phases"][args.phase]
|
|
2686
|
+
)
|
|
2687
|
+
raw = state_path.read_bytes()
|
|
2688
|
+
sys.stdout.write(json.dumps({
|
|
2689
|
+
"completed_phase": args.phase,
|
|
2690
|
+
"completed_at": completed["completed_at"],
|
|
2691
|
+
"completed_verdict": completed["verdict"],
|
|
2692
|
+
"next_phase": args.next_phase,
|
|
2693
|
+
"next_started_at": opened["started_at"],
|
|
2694
|
+
"next_round": opened["round"],
|
|
2695
|
+
"state_path": str(state_path),
|
|
2696
|
+
"state_sha256": hashlib.sha256(raw).hexdigest(),
|
|
2697
|
+
}, sort_keys=True) + "\n")
|
|
2698
|
+
return 0
|
|
455
2699
|
else:
|
|
456
|
-
do_complete(
|
|
2700
|
+
attestation_error = do_complete(
|
|
2701
|
+
state, args.phase, args.verdict, args.post_sha, args.findings_file,
|
|
2702
|
+
args.log_file, args.engine, args.model, args.engine_session_log, devlyn,
|
|
2703
|
+
)
|
|
457
2704
|
|
|
458
|
-
|
|
2705
|
+
if args.event not in {"spawn", "transition"}:
|
|
2706
|
+
write_state(state_path, state)
|
|
2707
|
+
if args.event == "complete" and attestation_error is not None:
|
|
2708
|
+
sys.stderr.write(attestation_error + "\n")
|
|
2709
|
+
return 1
|
|
459
2710
|
sys.stdout.write(f"ok: phases.{args.phase}.{args.event}\n")
|
|
460
2711
|
return 0
|
|
461
2712
|
|