devlyn-cli 2.9.3 → 2.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +3 -3
- package/CLAUDE.md +5 -5
- package/README.md +5 -5
- package/benchmark/auto-resolve/fixtures/SCHEMA.md +1 -1
- package/benchmark/auto-resolve/scripts/pair_evidence_contract.py +1 -0
- package/benchmark/auto-resolve/scripts/run-fixture.sh +11 -1
- package/benchmark/auto-resolve/scripts/test-benchmark-arg-parsing.sh +83 -1
- package/bin/devlyn.js +108 -15
- package/config/skills/_shared/adapters/README.md +2 -2
- package/config/skills/_shared/adapters/grok.md +99 -0
- package/config/skills/_shared/archive_run.py +35 -1
- package/config/skills/_shared/collect-codex-findings.py +231 -50
- package/config/skills/_shared/engine-doctor.sh +6 -3
- package/config/skills/_shared/engine-preflight.md +12 -5
- package/config/skills/_shared/expected.schema.json +7 -0
- package/config/skills/_shared/grok-anchor-guard.py +61 -0
- package/config/skills/_shared/phase-prompt-render.py +181 -0
- package/config/skills/_shared/resolve-bootstrap.py +630 -0
- package/config/skills/_shared/resolve-stop-hook.py +325 -0
- package/config/skills/_shared/spec-verify-check.py +308 -31
- package/config/skills/_shared/state-phase-write.py +2966 -27
- package/config/skills/_shared/terminal-claim-check.py +537 -0
- package/config/skills/_shared/verify-merge-findings.py +533 -16
- package/config/skills/devlyn:engines/SKILL.md +2 -2
- package/config/skills/devlyn:ideate/SKILL.md +1 -1
- package/config/skills/devlyn:queue/SKILL.md +12 -0
- package/config/skills/devlyn:resolve/SKILL.md +60 -78
- package/config/skills/devlyn:resolve/references/phases/build-gate.md +2 -2
- package/config/skills/devlyn:resolve/references/phases/implement.md +1 -1
- package/config/skills/devlyn:resolve/references/phases/plan.md +1 -5
- package/config/skills/devlyn:resolve/references/phases/probe-derive.md +4 -2
- package/config/skills/devlyn:resolve/references/phases/surface-close.md +34 -0
- package/config/skills/devlyn:resolve/references/phases/verify.md +70 -97
- package/config/skills/devlyn:resolve/references/state-schema.md +26 -19
- 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-fixtures.sh +4 -1
- package/scripts/lint-skills.sh +269 -77
|
@@ -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,60 @@ 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
|
-
|
|
41
|
+
SPAWN_TRIGGERS = VALID_TRIGGERS | {"plan"}
|
|
42
|
+
PHASE_NAMES = {"plan", "probe_derive", "implement", "surface_close", "build_gate", "cleanup", "verify", "final_report"}
|
|
43
|
+
LEGAL_TRANSITIONS = {
|
|
44
|
+
"plan": {"probe_derive", "implement", "final_report"},
|
|
45
|
+
"probe_derive": {"implement", "final_report"},
|
|
46
|
+
"implement": {"implement", "surface_close", "build_gate", "cleanup", "verify", "final_report"},
|
|
47
|
+
"surface_close": {"build_gate", "cleanup", "verify", "final_report"},
|
|
48
|
+
"build_gate": {"implement", "cleanup", "verify", "final_report"},
|
|
49
|
+
"cleanup": {"verify", "final_report"},
|
|
50
|
+
"verify": {"implement", "final_report"},
|
|
51
|
+
"final_report": set(),
|
|
52
|
+
}
|
|
53
|
+
WORKER_SESSION_ARTIFACT_PHASES = {
|
|
54
|
+
"implement": "implement",
|
|
55
|
+
"surface_close": "surface-close",
|
|
56
|
+
"cleanup": "cleanup",
|
|
57
|
+
}
|
|
58
|
+
MODEL_HEADER_RE = re.compile(r"(?m)^[ \t]*model:[ \t]*(\S+)[ \t]*$")
|
|
59
|
+
SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
|
|
60
|
+
SURFACE_ROW_RE = re.compile(
|
|
61
|
+
r"^(?P<obligation>UVR-STALE|PATH-TEST): (?:"
|
|
62
|
+
r"(?P<fired>FIRED) (?P<fired_path>.+):(?P<fired_line>[1-9][0-9]*)"
|
|
63
|
+
r"(?: — (?P<fired_evidence>\S.*))?|"
|
|
64
|
+
r"(?P<na>N/A) (?P<na_path>.+?)(?::(?P<na_line>[1-9][0-9]*))?"
|
|
65
|
+
r"(?: — (?P<na_evidence>\S.*))?)$"
|
|
66
|
+
)
|
|
67
|
+
VALIDATION_EXECUTION_RE = re.compile(
|
|
68
|
+
r"npm\s+test|node\s+--test|node\s+-e|node\s+bin/|node\s+tests/|git\s+stash"
|
|
69
|
+
)
|
|
70
|
+
SURFACE_SKIP_REASON = "auto_surface_close_claude_unavailable"
|
|
71
|
+
SURFACE_RECOVERY_REASON = "surface_close_rolled_back_adjudication_malformed"
|
|
72
|
+
PLAN_MAX_DISPATCHES = 2
|
|
73
|
+
PLAN_SPAWN_RECEIPT_FIELDS = (
|
|
74
|
+
"round", "started_at", "triggered_by", "engine", "model_requested", "prompt_sha256",
|
|
75
|
+
)
|
|
76
|
+
PLAN_COMPLETION_RECEIPT_FIELDS = (
|
|
77
|
+
"completed_at", "duration_ms", "verdict", "model_effective",
|
|
78
|
+
)
|
|
79
|
+
PLAN_RECEIPT_FIELDS = PLAN_SPAWN_RECEIPT_FIELDS + PLAN_COMPLETION_RECEIPT_FIELDS
|
|
30
80
|
|
|
31
81
|
|
|
32
82
|
def reject_json_constant(token: str) -> None:
|
|
@@ -79,6 +129,1124 @@ def write_state(state_path: pathlib.Path, state: dict) -> None:
|
|
|
79
129
|
raise
|
|
80
130
|
|
|
81
131
|
|
|
132
|
+
def parse_string_list(raw: str, label: str) -> list[str]:
|
|
133
|
+
try:
|
|
134
|
+
value = loads_strict_json(raw)
|
|
135
|
+
except ValueError as exc:
|
|
136
|
+
raise SystemExit(f"error: {label} is not valid JSON: {exc}") from exc
|
|
137
|
+
if not isinstance(value, list) or any(not isinstance(item, str) for item in value):
|
|
138
|
+
raise SystemExit(f"error: {label} must be a JSON array of strings")
|
|
139
|
+
return value
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def run_git_paths(work: pathlib.Path, *args: str) -> list[str]:
|
|
143
|
+
proc = subprocess.run(
|
|
144
|
+
["git", *args], cwd=work, capture_output=True, check=False,
|
|
145
|
+
)
|
|
146
|
+
if proc.returncode != 0:
|
|
147
|
+
detail = os.fsdecode(proc.stderr or proc.stdout).strip() or "git command failed"
|
|
148
|
+
raise SystemExit(f"error: {detail}")
|
|
149
|
+
return [os.fsdecode(item) for item in proc.stdout.split(b"\0") if item]
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def surface_entry(state: dict) -> dict:
|
|
153
|
+
entry = (state.get("phases") or {}).get("surface_close")
|
|
154
|
+
if not isinstance(entry, dict) or not entry.get("started_at"):
|
|
155
|
+
raise SystemExit("error: phases.surface_close was never spawned")
|
|
156
|
+
pre_sha = entry.get("pre_sha")
|
|
157
|
+
patch_digest = entry.get("input_patch_sha256")
|
|
158
|
+
prompt_digest = entry.get("prompt_sha256")
|
|
159
|
+
baseline = entry.get("untracked_before")
|
|
160
|
+
if not isinstance(pre_sha, str) or not pre_sha:
|
|
161
|
+
raise SystemExit("error: phases.surface_close.pre_sha is missing")
|
|
162
|
+
if not isinstance(patch_digest, str) or not SHA256_RE.fullmatch(patch_digest):
|
|
163
|
+
raise SystemExit("error: phases.surface_close.input_patch_sha256 must be 64 lowercase hex characters")
|
|
164
|
+
if not isinstance(prompt_digest, str) or not SHA256_RE.fullmatch(prompt_digest):
|
|
165
|
+
raise SystemExit("error: phases.surface_close.prompt_sha256 must be 64 lowercase hex characters")
|
|
166
|
+
if not isinstance(baseline, list) or any(not isinstance(item, str) for item in baseline):
|
|
167
|
+
raise SystemExit("error: phases.surface_close.untracked_before must be a string array")
|
|
168
|
+
return entry
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def devlyn_prefix(work: pathlib.Path, devlyn: pathlib.Path) -> str:
|
|
172
|
+
try:
|
|
173
|
+
return devlyn.resolve().relative_to(work.resolve()).as_posix().strip("/")
|
|
174
|
+
except ValueError as exc:
|
|
175
|
+
raise SystemExit("error: --devlyn-dir must be inside --workdir") from exc
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def file_sha256(path: pathlib.Path) -> str:
|
|
179
|
+
digest = hashlib.sha256()
|
|
180
|
+
try:
|
|
181
|
+
with path.open("rb") as handle:
|
|
182
|
+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
183
|
+
digest.update(chunk)
|
|
184
|
+
except OSError as exc:
|
|
185
|
+
raise SystemExit(f"BLOCKED:surface-close-input-mismatch: {path}: {exc}") from exc
|
|
186
|
+
return digest.hexdigest()
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def validate_surface_inputs(work: pathlib.Path, devlyn: pathlib.Path, state: dict) -> None:
|
|
190
|
+
entry = surface_entry(state)
|
|
191
|
+
source = state.get("source")
|
|
192
|
+
if not isinstance(source, dict):
|
|
193
|
+
raise SystemExit("BLOCKED:surface-close-input-mismatch: source is missing")
|
|
194
|
+
goal_path = source.get("goal_path")
|
|
195
|
+
goal_digest = source.get("goal_sha256")
|
|
196
|
+
if not isinstance(goal_path, str) or not goal_path or not isinstance(goal_digest, str):
|
|
197
|
+
raise SystemExit("BLOCKED:surface-close-input-mismatch: Goal metadata is missing")
|
|
198
|
+
goal = work / goal_path
|
|
199
|
+
try:
|
|
200
|
+
goal.resolve().relative_to(work.resolve())
|
|
201
|
+
except (OSError, ValueError) as exc:
|
|
202
|
+
raise SystemExit("BLOCKED:surface-close-input-mismatch: Goal path escapes worktree") from exc
|
|
203
|
+
patch = devlyn / "surface-close.input.patch"
|
|
204
|
+
if file_sha256(goal) != goal_digest or file_sha256(patch) != entry["input_patch_sha256"]:
|
|
205
|
+
raise SystemExit("BLOCKED:surface-close-input-mismatch: artifact digest changed")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def validate_surface_prompt(devlyn: pathlib.Path, state: dict) -> None:
|
|
209
|
+
prompt = devlyn / "surface-close.prompt"
|
|
210
|
+
if file_sha256(prompt) != surface_entry(state)["prompt_sha256"]:
|
|
211
|
+
raise SystemExit("BLOCKED:surface-close-prompt-mismatch")
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def surface_delta_paths(work: pathlib.Path, devlyn: pathlib.Path, state: dict) -> tuple[list[str], list[str]]:
|
|
215
|
+
entry = surface_entry(state)
|
|
216
|
+
pre_sha = entry["pre_sha"]
|
|
217
|
+
prefix = devlyn_prefix(work, devlyn)
|
|
218
|
+
tracked = set(run_git_paths(work, "diff", "--name-only", "-z", pre_sha, "--"))
|
|
219
|
+
untracked_now = set(run_git_paths(work, "ls-files", "--others", "--exclude-standard", "-z"))
|
|
220
|
+
new_untracked = untracked_now - set(entry["untracked_before"])
|
|
221
|
+
|
|
222
|
+
def external(path: str) -> bool:
|
|
223
|
+
return bool(path) and path != prefix and not path.startswith(f"{prefix}/")
|
|
224
|
+
|
|
225
|
+
return (
|
|
226
|
+
sorted(path for path in tracked if external(path)),
|
|
227
|
+
sorted(path for path in new_untracked if external(path)),
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def ensure_surface_clean_baseline(work: pathlib.Path, devlyn: pathlib.Path, state: dict) -> None:
|
|
232
|
+
tracked, new_untracked = surface_delta_paths(work, devlyn, state)
|
|
233
|
+
if tracked or new_untracked:
|
|
234
|
+
detail = json.dumps(sorted(set(tracked + new_untracked)))
|
|
235
|
+
raise SystemExit(f"BLOCKED:surface-close-preexisting-delta: {detail}")
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def validate_authorized_surface(raw: str) -> list[str]:
|
|
239
|
+
surface = parse_string_list(raw, "--authorized-surface-json")
|
|
240
|
+
if not surface:
|
|
241
|
+
raise SystemExit("error: --authorized-surface-json must not be empty")
|
|
242
|
+
for entry in surface:
|
|
243
|
+
path = entry[:-3] if entry.endswith("/**") else entry
|
|
244
|
+
parts = pathlib.PurePosixPath(path).parts
|
|
245
|
+
if (
|
|
246
|
+
not path or path == "." or path.startswith("./")
|
|
247
|
+
or pathlib.PurePosixPath(path).is_absolute() or ".." in parts
|
|
248
|
+
):
|
|
249
|
+
raise SystemExit(f"error: invalid authorized_surface entry: {entry!r}")
|
|
250
|
+
return surface
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def path_matches_surface(path: str, surface: list[str]) -> bool:
|
|
254
|
+
parsed = pathlib.PurePosixPath(path)
|
|
255
|
+
if parsed.is_absolute() or ".." in parsed.parts:
|
|
256
|
+
return False
|
|
257
|
+
for entry in surface:
|
|
258
|
+
if entry.endswith("/**"):
|
|
259
|
+
prefix = entry[:-3].rstrip("/")
|
|
260
|
+
if path == prefix or path.startswith(f"{prefix}/"):
|
|
261
|
+
return True
|
|
262
|
+
elif path == entry:
|
|
263
|
+
return True
|
|
264
|
+
return False
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def worktree_file_exists(work: pathlib.Path, path: str) -> bool:
|
|
268
|
+
parsed = pathlib.PurePosixPath(path)
|
|
269
|
+
if parsed.is_absolute() or ".." in parsed.parts:
|
|
270
|
+
return False
|
|
271
|
+
try:
|
|
272
|
+
resolved = (work / path).resolve()
|
|
273
|
+
resolved.relative_to(work.resolve())
|
|
274
|
+
return resolved.is_file()
|
|
275
|
+
except (OSError, RuntimeError, ValueError):
|
|
276
|
+
return False
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def worktree_path_exists(work: pathlib.Path, path: str) -> bool:
|
|
280
|
+
parsed = pathlib.PurePosixPath(path)
|
|
281
|
+
if parsed.is_absolute() or ".." in parsed.parts:
|
|
282
|
+
return False
|
|
283
|
+
try:
|
|
284
|
+
resolved = (work / path).resolve()
|
|
285
|
+
resolved.relative_to(work.resolve())
|
|
286
|
+
return resolved.exists()
|
|
287
|
+
except (OSError, RuntimeError, ValueError):
|
|
288
|
+
return False
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def resolve_na_surface_citation(
|
|
292
|
+
work: pathlib.Path, raw: str, parsed_path: str, parsed_line: int | None,
|
|
293
|
+
surface: list[str],
|
|
294
|
+
) -> tuple[str, int | None]:
|
|
295
|
+
def proven(path: str) -> bool:
|
|
296
|
+
if path in surface and not path.endswith("/**"):
|
|
297
|
+
return True
|
|
298
|
+
return worktree_file_exists(work, path) and any(
|
|
299
|
+
entry.endswith("/**") and path_matches_surface(path, [entry])
|
|
300
|
+
for entry in surface
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
if proven(raw):
|
|
304
|
+
return raw, None
|
|
305
|
+
if worktree_path_exists(work, raw) and not path_matches_surface(raw, surface):
|
|
306
|
+
raise SystemExit(f"BLOCKED:surface-close-adjudication-out-of-surface: {raw}")
|
|
307
|
+
if (
|
|
308
|
+
worktree_path_exists(work, parsed_path)
|
|
309
|
+
and not path_matches_surface(parsed_path, surface)
|
|
310
|
+
):
|
|
311
|
+
raise SystemExit(f"BLOCKED:surface-close-adjudication-out-of-surface: {raw}")
|
|
312
|
+
for split in range(len(raw) - 1, -1, -1):
|
|
313
|
+
if raw[split] != ":" or not proven(raw[:split]):
|
|
314
|
+
continue
|
|
315
|
+
suffix = raw[split:]
|
|
316
|
+
if not re.fullmatch(r":[1-9][0-9]*", suffix):
|
|
317
|
+
raise SystemExit(
|
|
318
|
+
f"BLOCKED:surface-close-adjudication-malformed: citation {raw!r}"
|
|
319
|
+
)
|
|
320
|
+
return raw[:split], int(suffix[1:])
|
|
321
|
+
return parsed_path, parsed_line
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def surface_offenders(work: pathlib.Path, devlyn: pathlib.Path, state: dict,
|
|
325
|
+
surface: list[str]) -> list[str]:
|
|
326
|
+
tracked, untracked = surface_delta_paths(work, devlyn, state)
|
|
327
|
+
return sorted(path for path in set(tracked + untracked) if not path_matches_surface(path, surface))
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def path_exists_at_commit(work: pathlib.Path, sha: str, path: str) -> bool:
|
|
331
|
+
proc = subprocess.run(
|
|
332
|
+
["git", "ls-tree", "--name-only", "-z", sha, "--", path],
|
|
333
|
+
cwd=work, capture_output=True, check=False,
|
|
334
|
+
)
|
|
335
|
+
if proc.returncode != 0:
|
|
336
|
+
detail = os.fsdecode(proc.stderr or proc.stdout).strip() or "git ls-tree failed"
|
|
337
|
+
raise SystemExit(f"error: {detail}")
|
|
338
|
+
return bool(proc.stdout)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def remove_worktree_path(work: pathlib.Path, path: str) -> None:
|
|
342
|
+
parsed = pathlib.PurePosixPath(path)
|
|
343
|
+
if parsed.is_absolute() or ".." in parsed.parts:
|
|
344
|
+
raise SystemExit(f"error: rollback path escapes worktree: {path!r}")
|
|
345
|
+
target = work / path
|
|
346
|
+
if target.is_symlink() or target.is_file():
|
|
347
|
+
target.unlink()
|
|
348
|
+
elif target.is_dir():
|
|
349
|
+
shutil.rmtree(target)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def rollback_surface_delta(work: pathlib.Path, devlyn: pathlib.Path, state: dict) -> list[str]:
|
|
353
|
+
entry = surface_entry(state)
|
|
354
|
+
pre_sha = entry["pre_sha"]
|
|
355
|
+
tracked, untracked = surface_delta_paths(work, devlyn, state)
|
|
356
|
+
restore = [path for path in tracked if path_exists_at_commit(work, pre_sha, path)]
|
|
357
|
+
remove = sorted(set(untracked + [
|
|
358
|
+
path for path in tracked if not path_exists_at_commit(work, pre_sha, path)
|
|
359
|
+
]))
|
|
360
|
+
if restore:
|
|
361
|
+
proc = subprocess.run(
|
|
362
|
+
["git", "restore", f"--source={pre_sha}", "--staged", "--worktree", "--", *restore],
|
|
363
|
+
cwd=work, capture_output=True, check=False,
|
|
364
|
+
)
|
|
365
|
+
if proc.returncode != 0:
|
|
366
|
+
detail = os.fsdecode(proc.stderr or proc.stdout).strip() or "git restore failed"
|
|
367
|
+
raise SystemExit(f"error: {detail}")
|
|
368
|
+
if remove:
|
|
369
|
+
proc = subprocess.run(
|
|
370
|
+
["git", "rm", "-f", "--cached", "--ignore-unmatch", "--", *remove],
|
|
371
|
+
cwd=work, capture_output=True, check=False,
|
|
372
|
+
)
|
|
373
|
+
if proc.returncode != 0:
|
|
374
|
+
detail = os.fsdecode(proc.stderr or proc.stdout).strip() or "git rm --cached failed"
|
|
375
|
+
raise SystemExit(f"error: {detail}")
|
|
376
|
+
for path in remove:
|
|
377
|
+
remove_worktree_path(work, path)
|
|
378
|
+
return sorted(set(restore + remove))
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def validate_surface_adjudication(
|
|
382
|
+
work: pathlib.Path, devlyn: pathlib.Path, state: dict, surface: list[str],
|
|
383
|
+
) -> dict[str, str]:
|
|
384
|
+
output = devlyn / "surface-close.stdout"
|
|
385
|
+
try:
|
|
386
|
+
lines = output.read_text(encoding="utf-8").splitlines()
|
|
387
|
+
except (OSError, UnicodeError) as exc:
|
|
388
|
+
raise SystemExit(f"BLOCKED:surface-close-adjudication-malformed: {output}: {exc}") from exc
|
|
389
|
+
|
|
390
|
+
rows: dict[str, tuple[str, str, int | None, str | None, int]] = {}
|
|
391
|
+
for index, line in enumerate(lines):
|
|
392
|
+
if "UVR-STALE:" not in line and "PATH-TEST:" not in line:
|
|
393
|
+
continue
|
|
394
|
+
match = SURFACE_ROW_RE.fullmatch(line)
|
|
395
|
+
if match is None:
|
|
396
|
+
raise SystemExit(
|
|
397
|
+
f"BLOCKED:surface-close-adjudication-malformed: line {index + 1}: {line!r}"
|
|
398
|
+
)
|
|
399
|
+
obligation = match.group("obligation")
|
|
400
|
+
if obligation in rows:
|
|
401
|
+
raise SystemExit(
|
|
402
|
+
f"BLOCKED:surface-close-adjudication-malformed: duplicate {obligation} row"
|
|
403
|
+
)
|
|
404
|
+
status = "FIRED" if match.group("fired") else "N/A"
|
|
405
|
+
evidence = match.group("fired_evidence") or match.group("na_evidence")
|
|
406
|
+
if status == "N/A" and evidence is None:
|
|
407
|
+
raise SystemExit(
|
|
408
|
+
f"BLOCKED:surface-close-adjudication-malformed: {obligation} N/A requires evidence"
|
|
409
|
+
)
|
|
410
|
+
path = match.group("fired_path") or match.group("na_path")
|
|
411
|
+
raw_line = match.group("fired_line") or match.group("na_line")
|
|
412
|
+
line_number = int(raw_line) if raw_line is not None else None
|
|
413
|
+
citation = path if line_number is None else f"{path}:{line_number}"
|
|
414
|
+
if status == "N/A":
|
|
415
|
+
path, line_number = resolve_na_surface_citation(
|
|
416
|
+
work, citation, path, line_number, surface,
|
|
417
|
+
)
|
|
418
|
+
citation = path if line_number is None else f"{path}:{line_number}"
|
|
419
|
+
if not path_matches_surface(path, surface):
|
|
420
|
+
raise SystemExit(
|
|
421
|
+
f"BLOCKED:surface-close-adjudication-out-of-surface: {citation}"
|
|
422
|
+
)
|
|
423
|
+
try:
|
|
424
|
+
cited = (work / path).resolve()
|
|
425
|
+
cited.relative_to(work.resolve())
|
|
426
|
+
cited_lines = cited.read_text(encoding="utf-8").splitlines()
|
|
427
|
+
except (OSError, RuntimeError, UnicodeError, ValueError) as exc:
|
|
428
|
+
raise SystemExit(
|
|
429
|
+
f"BLOCKED:surface-close-adjudication-citation-missing: {citation}: {exc}"
|
|
430
|
+
) from exc
|
|
431
|
+
if line_number is not None and line_number > len(cited_lines):
|
|
432
|
+
raise SystemExit(
|
|
433
|
+
f"BLOCKED:surface-close-adjudication-citation-missing: {path}:{line_number}"
|
|
434
|
+
)
|
|
435
|
+
rows[obligation] = (status, path, line_number, evidence, index)
|
|
436
|
+
|
|
437
|
+
missing = [name for name in ("UVR-STALE", "PATH-TEST") if name not in rows]
|
|
438
|
+
if missing:
|
|
439
|
+
raise SystemExit(
|
|
440
|
+
"BLOCKED:surface-close-adjudication-malformed: missing " + ", ".join(missing)
|
|
441
|
+
)
|
|
442
|
+
pass_lines = [index for index, line in enumerate(lines) if line == "PASS"]
|
|
443
|
+
if len(pass_lines) != 1 or pass_lines[0] <= max(row[4] for row in rows.values()):
|
|
444
|
+
raise SystemExit(
|
|
445
|
+
"BLOCKED:surface-close-adjudication-malformed: exactly one PASS must follow both rows"
|
|
446
|
+
)
|
|
447
|
+
statuses = {name: row[0] for name, row in rows.items()}
|
|
448
|
+
if all(status == "N/A" for status in statuses.values()):
|
|
449
|
+
tracked, untracked = surface_delta_paths(work, devlyn, state)
|
|
450
|
+
if tracked or untracked:
|
|
451
|
+
raise SystemExit("BLOCKED:surface-close-empty-pass-has-delta")
|
|
452
|
+
return statuses
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def surface_transcript_commands(transcript: pathlib.Path) -> list[str]:
|
|
456
|
+
try:
|
|
457
|
+
lines = transcript.read_text(encoding="utf-8").splitlines()
|
|
458
|
+
except (OSError, UnicodeError) as exc:
|
|
459
|
+
raise SystemExit(f"BLOCKED:surface-close-worker-session-invalid: {transcript}: {exc}") from exc
|
|
460
|
+
commands: list[str] = []
|
|
461
|
+
for line_number, line in enumerate(lines, start=1):
|
|
462
|
+
if not line.strip():
|
|
463
|
+
continue
|
|
464
|
+
try:
|
|
465
|
+
event = loads_strict_json(line)
|
|
466
|
+
except ValueError as exc:
|
|
467
|
+
raise SystemExit(
|
|
468
|
+
f"BLOCKED:surface-close-worker-session-invalid: line {line_number}: {exc}"
|
|
469
|
+
) from exc
|
|
470
|
+
if not isinstance(event, dict):
|
|
471
|
+
continue
|
|
472
|
+
message = event.get("message")
|
|
473
|
+
content = message.get("content") if isinstance(message, dict) else None
|
|
474
|
+
if isinstance(content, list):
|
|
475
|
+
for item in content:
|
|
476
|
+
if not isinstance(item, dict) or item.get("type") != "tool_use":
|
|
477
|
+
continue
|
|
478
|
+
if item.get("name") != "Bash":
|
|
479
|
+
continue
|
|
480
|
+
tool_input = item.get("input")
|
|
481
|
+
command = tool_input.get("command") if isinstance(tool_input, dict) else None
|
|
482
|
+
if isinstance(command, str):
|
|
483
|
+
commands.append(command)
|
|
484
|
+
return commands
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def validate_surface_execution(devlyn: pathlib.Path, state: dict) -> None:
|
|
488
|
+
entry = surface_entry(state)
|
|
489
|
+
transcript = devlyn / f"surface-close.worker-session.{entry.get('round')}.jsonl"
|
|
490
|
+
commands = surface_transcript_commands(transcript)
|
|
491
|
+
hits = [command for command in commands if VALIDATION_EXECUTION_RE.search(command)]
|
|
492
|
+
if hits:
|
|
493
|
+
raise SystemExit(
|
|
494
|
+
"BLOCKED:surface-close-validation-execution: " + json.dumps(hits)
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def validate_surface_write_audit(
|
|
499
|
+
work: pathlib.Path, devlyn: pathlib.Path, state: dict, surface: list[str],
|
|
500
|
+
) -> list[str]:
|
|
501
|
+
entry = surface_entry(state)
|
|
502
|
+
transcript = devlyn / f"surface-close.worker-session.{entry.get('round')}.jsonl"
|
|
503
|
+
try:
|
|
504
|
+
lines = transcript.read_text(encoding="utf-8").splitlines()
|
|
505
|
+
except (OSError, UnicodeError) as exc:
|
|
506
|
+
raise SystemExit(f"BLOCKED:surface-close-worker-session-invalid: {transcript}: {exc}") from exc
|
|
507
|
+
targets: list[str] = []
|
|
508
|
+
for line_number, line in enumerate(lines, start=1):
|
|
509
|
+
if not line.strip():
|
|
510
|
+
continue
|
|
511
|
+
try:
|
|
512
|
+
event = loads_strict_json(line)
|
|
513
|
+
except ValueError as exc:
|
|
514
|
+
raise SystemExit(
|
|
515
|
+
f"BLOCKED:surface-close-worker-session-invalid: line {line_number}: {exc}"
|
|
516
|
+
) from exc
|
|
517
|
+
message = event.get("message") if isinstance(event, dict) else None
|
|
518
|
+
content = message.get("content") if isinstance(message, dict) else None
|
|
519
|
+
if not isinstance(content, list):
|
|
520
|
+
continue
|
|
521
|
+
for item in content:
|
|
522
|
+
if not isinstance(item, dict) or item.get("type") != "tool_use":
|
|
523
|
+
continue
|
|
524
|
+
if item.get("name") not in {"Edit", "Write"}:
|
|
525
|
+
continue
|
|
526
|
+
tool_input = item.get("input")
|
|
527
|
+
raw_target = tool_input.get("file_path") if isinstance(tool_input, dict) else None
|
|
528
|
+
if not isinstance(raw_target, str) or not raw_target:
|
|
529
|
+
raise SystemExit(
|
|
530
|
+
"BLOCKED:surface-close-write-audit-violation: "
|
|
531
|
+
f"line {line_number}: Edit/Write target missing"
|
|
532
|
+
)
|
|
533
|
+
target = pathlib.Path(raw_target)
|
|
534
|
+
try:
|
|
535
|
+
resolved = (target if target.is_absolute() else work / target).resolve()
|
|
536
|
+
relative = resolved.relative_to(work.resolve()).as_posix()
|
|
537
|
+
except (OSError, ValueError) as exc:
|
|
538
|
+
raise SystemExit(
|
|
539
|
+
"BLOCKED:surface-close-write-audit-violation: "
|
|
540
|
+
f"line {line_number}: {raw_target!r}"
|
|
541
|
+
) from exc
|
|
542
|
+
if not path_matches_surface(relative, surface):
|
|
543
|
+
raise SystemExit(
|
|
544
|
+
"BLOCKED:surface-close-write-audit-violation: "
|
|
545
|
+
f"line {line_number}: {relative!r}"
|
|
546
|
+
)
|
|
547
|
+
targets.append(relative)
|
|
548
|
+
return targets
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def require_surface_adjudication_malformed(
|
|
552
|
+
work: pathlib.Path, devlyn: pathlib.Path, state: dict, surface: list[str],
|
|
553
|
+
) -> None:
|
|
554
|
+
try:
|
|
555
|
+
validate_surface_adjudication(work, devlyn, state, surface)
|
|
556
|
+
except SystemExit as exc:
|
|
557
|
+
if str(exc).startswith("BLOCKED:surface-close-adjudication-malformed:"):
|
|
558
|
+
return
|
|
559
|
+
raise
|
|
560
|
+
raise SystemExit(
|
|
561
|
+
"error: surface-adjudication-recover requires "
|
|
562
|
+
"BLOCKED:surface-close-adjudication-malformed"
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
CLAUDE_USAGE_COUNTERS = (
|
|
567
|
+
("input_tokens", "inputTokens"),
|
|
568
|
+
("output_tokens", "outputTokens"),
|
|
569
|
+
("cache_read_input_tokens", "cacheReadInputTokens"),
|
|
570
|
+
("cache_creation_input_tokens", "cacheCreationInputTokens"),
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def select_claude_primary_model(evidence: dict) -> str:
|
|
575
|
+
model_usage = evidence.get("modelUsage")
|
|
576
|
+
models = (
|
|
577
|
+
[model for model in model_usage if isinstance(model, str) and model]
|
|
578
|
+
if isinstance(model_usage, dict)
|
|
579
|
+
else []
|
|
580
|
+
)
|
|
581
|
+
if len(models) == 1:
|
|
582
|
+
return models[0]
|
|
583
|
+
if not models:
|
|
584
|
+
raise ValueError("Claude JSON wrapper has no effective-model evidence")
|
|
585
|
+
|
|
586
|
+
usage = evidence.get("usage")
|
|
587
|
+
if not isinstance(usage, dict):
|
|
588
|
+
raise ValueError("multi-entry Claude JSON wrapper has malformed top-level usage")
|
|
589
|
+
|
|
590
|
+
def counters(container: dict, fields: tuple[str, ...]) -> tuple[int, ...]:
|
|
591
|
+
values = tuple(container.get(field) for field in fields)
|
|
592
|
+
if any(
|
|
593
|
+
not isinstance(value, int)
|
|
594
|
+
or isinstance(value, bool)
|
|
595
|
+
or value < 0
|
|
596
|
+
for value in values
|
|
597
|
+
):
|
|
598
|
+
raise ValueError("multi-entry Claude JSON wrapper has malformed usage counters")
|
|
599
|
+
return values
|
|
600
|
+
|
|
601
|
+
primary_usage = counters(
|
|
602
|
+
usage, tuple(field[0] for field in CLAUDE_USAGE_COUNTERS)
|
|
603
|
+
)
|
|
604
|
+
matches = []
|
|
605
|
+
for model in models:
|
|
606
|
+
entry = model_usage[model]
|
|
607
|
+
if not isinstance(entry, dict):
|
|
608
|
+
raise ValueError("multi-entry Claude JSON wrapper has malformed modelUsage entry")
|
|
609
|
+
if counters(
|
|
610
|
+
entry, tuple(field[1] for field in CLAUDE_USAGE_COUNTERS)
|
|
611
|
+
) == primary_usage:
|
|
612
|
+
matches.append(model)
|
|
613
|
+
if len(matches) == 1:
|
|
614
|
+
return matches[0]
|
|
615
|
+
raise ValueError(
|
|
616
|
+
"multi-entry Claude JSON wrapper has no unique primary usage match"
|
|
617
|
+
)
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def parse_effective_model(session_log: pathlib.Path) -> str:
|
|
621
|
+
try:
|
|
622
|
+
text = session_log.read_text(encoding="utf-8")
|
|
623
|
+
except (OSError, UnicodeError) as exc:
|
|
624
|
+
raise ValueError(f"cannot read engine session log {session_log}: {exc}") from exc
|
|
625
|
+
|
|
626
|
+
header = MODEL_HEADER_RE.search(text)
|
|
627
|
+
if header:
|
|
628
|
+
return header.group(1)
|
|
629
|
+
|
|
630
|
+
models: set[str] = set()
|
|
631
|
+
if session_log.suffix == ".jsonl":
|
|
632
|
+
for line_number, line in enumerate(text.splitlines(), start=1):
|
|
633
|
+
if not line.strip():
|
|
634
|
+
continue
|
|
635
|
+
try:
|
|
636
|
+
event = loads_strict_json(line)
|
|
637
|
+
except ValueError as exc:
|
|
638
|
+
raise ValueError(
|
|
639
|
+
f"engine session log {session_log} has malformed JSONL at line {line_number}: {exc}"
|
|
640
|
+
) from exc
|
|
641
|
+
if not isinstance(event, dict) or event.get("type") != "turn_context":
|
|
642
|
+
continue
|
|
643
|
+
payload = event.get("payload")
|
|
644
|
+
model = payload.get("model") if isinstance(payload, dict) else None
|
|
645
|
+
if isinstance(model, str) and model:
|
|
646
|
+
models.add(model)
|
|
647
|
+
else:
|
|
648
|
+
try:
|
|
649
|
+
evidence = loads_strict_json(text)
|
|
650
|
+
except ValueError:
|
|
651
|
+
evidence = None
|
|
652
|
+
if isinstance(evidence, dict) and isinstance(evidence.get("modelUsage"), dict):
|
|
653
|
+
return select_claude_primary_model(evidence)
|
|
654
|
+
|
|
655
|
+
if len(models) == 1:
|
|
656
|
+
return next(iter(models))
|
|
657
|
+
if not models:
|
|
658
|
+
raise ValueError(f"engine session log {session_log} has no effective-model evidence")
|
|
659
|
+
raise ValueError(
|
|
660
|
+
f"engine session log {session_log} has conflicting effective models: {sorted(models)}"
|
|
661
|
+
)
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def _git_output(work: pathlib.Path, *args: str, input_bytes: bytes | None = None) -> bytes:
|
|
665
|
+
proc = subprocess.run(
|
|
666
|
+
["git", *args], cwd=work, input=input_bytes, capture_output=True, check=False,
|
|
667
|
+
)
|
|
668
|
+
if proc.returncode != 0:
|
|
669
|
+
detail = os.fsdecode(proc.stderr or proc.stdout).strip() or "git command failed"
|
|
670
|
+
raise SystemExit(f"BLOCKED:closure-durability-mechanical: {detail}")
|
|
671
|
+
return proc.stdout
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def _commit_blob(work: pathlib.Path, sha: str, path: str) -> bytes | None:
|
|
675
|
+
if not path_exists_at_commit(work, sha, path):
|
|
676
|
+
return None
|
|
677
|
+
return _git_output(work, "show", f"{sha}:{path}")
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
def _line_matches(haystack: list[bytes], needle: list[bytes]) -> list[int]:
|
|
681
|
+
if not needle or len(needle) > len(haystack):
|
|
682
|
+
return []
|
|
683
|
+
width = len(needle)
|
|
684
|
+
return [index for index in range(len(haystack) - width + 1)
|
|
685
|
+
if haystack[index:index + width] == needle]
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def _nearest(matches: list[int], expected: int) -> int | None:
|
|
689
|
+
return min(matches, key=lambda value: (abs(value - expected), value)) if matches else None
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def _block_image_index(lines: list[bytes], image: list[bytes], block: dict) -> int | None:
|
|
693
|
+
matches = []
|
|
694
|
+
before = block["_before_lines"]
|
|
695
|
+
after = block["_after_lines"]
|
|
696
|
+
for index in _line_matches(lines, image):
|
|
697
|
+
if before and (index < len(before) or lines[index - len(before):index] != before):
|
|
698
|
+
continue
|
|
699
|
+
end = index + len(image)
|
|
700
|
+
if after and lines[end:end + len(after)] != after:
|
|
701
|
+
continue
|
|
702
|
+
matches.append(index)
|
|
703
|
+
return _nearest(matches, block["_expected_index"])
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def _block_anchor_index(lines: list[bytes], block: dict) -> int | None:
|
|
707
|
+
before = block["_before_lines"]
|
|
708
|
+
after = block["_after_lines"]
|
|
709
|
+
if not before and not after:
|
|
710
|
+
return 0 if not lines else None
|
|
711
|
+
candidates = []
|
|
712
|
+
for index in range(len(lines) + 1):
|
|
713
|
+
if before and (index < len(before) or lines[index - len(before):index] != before):
|
|
714
|
+
continue
|
|
715
|
+
if after and lines[index:index + len(after)] != after:
|
|
716
|
+
continue
|
|
717
|
+
candidates.append(index)
|
|
718
|
+
return _nearest(candidates, block["_expected_index"])
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def _map_pre_fix_span(block: dict, pre_fix_lines: list[bytes]) -> None:
|
|
722
|
+
post_lines = block["_post_lines"]
|
|
723
|
+
if post_lines:
|
|
724
|
+
index = _block_image_index(pre_fix_lines, post_lines, block)
|
|
725
|
+
else:
|
|
726
|
+
index = _block_anchor_index(pre_fix_lines, block)
|
|
727
|
+
if index is None:
|
|
728
|
+
block["_pre_fix_anchor"] = False
|
|
729
|
+
return
|
|
730
|
+
block["_pre_fix_anchor"] = True
|
|
731
|
+
block["_expected_index"] = index
|
|
732
|
+
width = max(1, len(post_lines))
|
|
733
|
+
block["pre_fix_span"] = [index + 1, index + width]
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def surface_change_blocks(work: pathlib.Path, state: dict,
|
|
737
|
+
pre_fix_sha: str | None = None) -> list[dict]:
|
|
738
|
+
surface = ((state.get("phases") or {}).get("surface_close") or {})
|
|
739
|
+
pre_sha = surface.get("pre_sha")
|
|
740
|
+
post_sha = surface.get("post_sha")
|
|
741
|
+
if not isinstance(pre_sha, str) or not pre_sha or not isinstance(post_sha, str) or not post_sha:
|
|
742
|
+
return []
|
|
743
|
+
paths = run_git_paths(work, "diff", "--name-only", "--no-renames", "-z", pre_sha, post_sha, "--")
|
|
744
|
+
blocks = []
|
|
745
|
+
for path in paths:
|
|
746
|
+
parsed = pathlib.PurePosixPath(path)
|
|
747
|
+
if parsed.is_absolute() or ".." in parsed.parts:
|
|
748
|
+
raise SystemExit(f"BLOCKED:closure-durability-mechanical: unsafe surface path {path!r}")
|
|
749
|
+
old_blob = _commit_blob(work, pre_sha, path)
|
|
750
|
+
post_blob = _commit_blob(work, post_sha, path)
|
|
751
|
+
old_lines = [] if old_blob is None else old_blob.splitlines(keepends=True)
|
|
752
|
+
post_lines = [] if post_blob is None else post_blob.splitlines(keepends=True)
|
|
753
|
+
matcher = difflib.SequenceMatcher(None, old_lines, post_lines, autojunk=False)
|
|
754
|
+
opcodes = matcher.get_opcodes()
|
|
755
|
+
for ordinal, (tag, old_start, old_end, new_start, new_end) in enumerate(opcodes):
|
|
756
|
+
if tag == "equal":
|
|
757
|
+
continue
|
|
758
|
+
old_image = old_lines[old_start:old_end]
|
|
759
|
+
post_image = post_lines[new_start:new_end]
|
|
760
|
+
before_lines = []
|
|
761
|
+
after_lines = []
|
|
762
|
+
if ordinal > 0 and opcodes[ordinal - 1][0] == "equal":
|
|
763
|
+
_tag, _i1, _i2, prior_start, prior_end = opcodes[ordinal - 1]
|
|
764
|
+
before_lines = post_lines[max(prior_start, prior_end - 2):prior_end]
|
|
765
|
+
if ordinal + 1 < len(opcodes) and opcodes[ordinal + 1][0] == "equal":
|
|
766
|
+
_tag, _i1, _i2, next_start, next_end = opcodes[ordinal + 1]
|
|
767
|
+
after_lines = post_lines[next_start:min(next_end, next_start + 2)]
|
|
768
|
+
identity = hashlib.sha256(
|
|
769
|
+
path.encode("utf-8", "surrogateescape") + b"\0"
|
|
770
|
+
+ str(old_start + 1).encode() + b":" + str(old_end - old_start).encode() + b"\0"
|
|
771
|
+
+ str(new_start + 1).encode() + b":" + str(new_end - new_start).encode() + b"\0"
|
|
772
|
+
+ b"".join(old_image) + b"\0" + b"".join(post_image)
|
|
773
|
+
).hexdigest()[:16]
|
|
774
|
+
width = max(1, len(post_image))
|
|
775
|
+
block = {
|
|
776
|
+
"id": f"{path}:{ordinal}:{identity}",
|
|
777
|
+
"path": path,
|
|
778
|
+
"pre_fix_span": [new_start + 1, new_start + width],
|
|
779
|
+
"_pre_lines": old_image,
|
|
780
|
+
"_post_lines": post_image,
|
|
781
|
+
"_before_lines": before_lines,
|
|
782
|
+
"_after_lines": after_lines,
|
|
783
|
+
"_expected_index": new_start,
|
|
784
|
+
"_pre_fix_anchor": True,
|
|
785
|
+
"_post_exists": post_blob is not None,
|
|
786
|
+
}
|
|
787
|
+
if pre_fix_sha is not None:
|
|
788
|
+
pre_fix_blob = _commit_blob(work, pre_fix_sha, path)
|
|
789
|
+
_map_pre_fix_span(block, [] if pre_fix_blob is None else pre_fix_blob.splitlines(keepends=True))
|
|
790
|
+
blocks.append(block)
|
|
791
|
+
return blocks
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
def classify_surface_block(block: dict, current: bytes | None) -> str:
|
|
795
|
+
lines = [] if current is None else current.splitlines(keepends=True)
|
|
796
|
+
post_lines = block["_post_lines"]
|
|
797
|
+
pre_lines = block["_pre_lines"]
|
|
798
|
+
if post_lines:
|
|
799
|
+
post_index = _block_image_index(lines, post_lines, block)
|
|
800
|
+
if post_index is not None:
|
|
801
|
+
block["_restore_index"] = post_index
|
|
802
|
+
return "SURVIVED"
|
|
803
|
+
if pre_lines:
|
|
804
|
+
pre_index = _block_image_index(lines, pre_lines, block)
|
|
805
|
+
if pre_index is not None:
|
|
806
|
+
block["_restore_index"] = pre_index
|
|
807
|
+
return "REVERTED"
|
|
808
|
+
else:
|
|
809
|
+
anchor = _block_anchor_index(lines, block)
|
|
810
|
+
if anchor is not None:
|
|
811
|
+
block["_restore_index"] = anchor
|
|
812
|
+
return "REVERTED"
|
|
813
|
+
return "EVOLVED"
|
|
814
|
+
pre_index = _block_image_index(lines, pre_lines, block)
|
|
815
|
+
if pre_index is not None:
|
|
816
|
+
block["_restore_index"] = pre_index
|
|
817
|
+
return "REVERTED"
|
|
818
|
+
anchor = _block_anchor_index(lines, block)
|
|
819
|
+
if anchor is not None:
|
|
820
|
+
block["_restore_index"] = anchor
|
|
821
|
+
return "SURVIVED"
|
|
822
|
+
return "EVOLVED"
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
def finding_targets_block(block: dict, findings: list[dict]) -> bool:
|
|
826
|
+
start, end = block["pre_fix_span"]
|
|
827
|
+
for finding in findings:
|
|
828
|
+
path = finding.get("path", finding.get("file"))
|
|
829
|
+
line = finding.get("line")
|
|
830
|
+
if path == block["path"] and isinstance(line, int) and not isinstance(line, bool):
|
|
831
|
+
if start <= line <= end:
|
|
832
|
+
return True
|
|
833
|
+
return False
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
def _read_triggering_findings(devlyn: pathlib.Path, origin_phase: str) -> tuple[list[dict], str]:
|
|
837
|
+
name = "build_gate.findings.jsonl" if origin_phase == "build_gate" else "verify-merged.findings.jsonl"
|
|
838
|
+
path = devlyn / name
|
|
839
|
+
try:
|
|
840
|
+
raw = path.read_bytes()
|
|
841
|
+
except OSError as exc:
|
|
842
|
+
raise SystemExit(f"BLOCKED:closure-durability-receipt: {path}: {exc}") from exc
|
|
843
|
+
findings = []
|
|
844
|
+
for line_number, line in enumerate(raw.splitlines(), start=1):
|
|
845
|
+
if not line.strip():
|
|
846
|
+
continue
|
|
847
|
+
try:
|
|
848
|
+
finding = loads_strict_json(line.decode("utf-8"))
|
|
849
|
+
except (UnicodeError, ValueError) as exc:
|
|
850
|
+
raise SystemExit(
|
|
851
|
+
f"BLOCKED:closure-durability-receipt: {path}:{line_number}: {exc}"
|
|
852
|
+
) from exc
|
|
853
|
+
if not isinstance(finding, dict):
|
|
854
|
+
raise SystemExit(
|
|
855
|
+
f"BLOCKED:closure-durability-receipt: {path}:{line_number} is not an object"
|
|
856
|
+
)
|
|
857
|
+
findings.append(finding)
|
|
858
|
+
return findings, hashlib.sha256(raw).hexdigest()
|
|
859
|
+
|
|
860
|
+
|
|
861
|
+
def _worktree_file(work: pathlib.Path, path: str) -> bytes | None:
|
|
862
|
+
target = work / path
|
|
863
|
+
if target.is_file() or target.is_symlink():
|
|
864
|
+
return target.read_bytes()
|
|
865
|
+
return None
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
def _restored_files(work: pathlib.Path, blocks: list[dict]) -> dict[str, bytes | None]:
|
|
869
|
+
desired = {}
|
|
870
|
+
for path in sorted({block["path"] for block in blocks}):
|
|
871
|
+
current = _worktree_file(work, path)
|
|
872
|
+
lines = [] if current is None else current.splitlines(keepends=True)
|
|
873
|
+
path_blocks = sorted(
|
|
874
|
+
(block for block in blocks if block["path"] == path),
|
|
875
|
+
key=lambda block: block["_restore_index"], reverse=True,
|
|
876
|
+
)
|
|
877
|
+
for block in path_blocks:
|
|
878
|
+
index = block["_restore_index"]
|
|
879
|
+
pre_lines = block["_pre_lines"]
|
|
880
|
+
post_lines = block["_post_lines"]
|
|
881
|
+
if lines[index:index + len(pre_lines)] != pre_lines:
|
|
882
|
+
raise SystemExit(
|
|
883
|
+
f"BLOCKED:closure-durability-apply: restore anchor changed for {block['id']}"
|
|
884
|
+
)
|
|
885
|
+
lines[index:index + len(pre_lines)] = post_lines
|
|
886
|
+
desired[path] = b"".join(lines) if lines or any(
|
|
887
|
+
block["_post_exists"] for block in path_blocks
|
|
888
|
+
) else None
|
|
889
|
+
return desired
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
def _restore_patch(work: pathlib.Path, desired: dict[str, bytes | None]) -> bytes:
|
|
893
|
+
parts = []
|
|
894
|
+
with tempfile.TemporaryDirectory(prefix="closure-durability-") as tmp:
|
|
895
|
+
root = pathlib.Path(tmp)
|
|
896
|
+
for path, wanted in desired.items():
|
|
897
|
+
old = root / "old" / path
|
|
898
|
+
new = root / "new" / path
|
|
899
|
+
current = _worktree_file(work, path)
|
|
900
|
+
if current is not None:
|
|
901
|
+
old.parent.mkdir(parents=True, exist_ok=True)
|
|
902
|
+
old.write_bytes(current)
|
|
903
|
+
if wanted is not None:
|
|
904
|
+
new.parent.mkdir(parents=True, exist_ok=True)
|
|
905
|
+
new.write_bytes(wanted)
|
|
906
|
+
old_arg = str(old.relative_to(root)) if current is not None else "/dev/null"
|
|
907
|
+
new_arg = str(new.relative_to(root)) if wanted is not None else "/dev/null"
|
|
908
|
+
proc = subprocess.run(
|
|
909
|
+
["git", "diff", "--no-index", "--binary", "--src-prefix=a/", "--dst-prefix=b/",
|
|
910
|
+
"--", old_arg, new_arg],
|
|
911
|
+
cwd=root, capture_output=True, check=False,
|
|
912
|
+
)
|
|
913
|
+
if proc.returncode not in (0, 1):
|
|
914
|
+
detail = os.fsdecode(proc.stderr or proc.stdout).strip() or "git diff --no-index failed"
|
|
915
|
+
raise SystemExit(f"BLOCKED:closure-durability-apply: {detail}")
|
|
916
|
+
patch = proc.stdout
|
|
917
|
+
encoded = path.encode("utf-8", "surrogateescape")
|
|
918
|
+
rewritten = []
|
|
919
|
+
header = True
|
|
920
|
+
for line in patch.splitlines(keepends=True):
|
|
921
|
+
if line.startswith(b"@@ ") or line.startswith(b"GIT binary patch"):
|
|
922
|
+
header = False
|
|
923
|
+
if header and line.startswith((b"diff --git ", b"--- ", b"+++ ", b"Binary files ")):
|
|
924
|
+
for prefix in (b"a/old/", b"a/new/"):
|
|
925
|
+
line = line.replace(prefix + encoded, b"a/" + encoded)
|
|
926
|
+
for prefix in (b"b/old/", b"b/new/"):
|
|
927
|
+
line = line.replace(prefix + encoded, b"b/" + encoded)
|
|
928
|
+
rewritten.append(line)
|
|
929
|
+
parts.append(b"".join(rewritten))
|
|
930
|
+
return b"".join(parts)
|
|
931
|
+
|
|
932
|
+
|
|
933
|
+
def _tracked_status(work: pathlib.Path) -> bytes:
|
|
934
|
+
return _git_output(work, "status", "--porcelain=v1", "-z", "--untracked-files=no")
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
def _apply_restore_patch(work: pathlib.Path, patch: bytes, paths: list[str]) -> None:
|
|
938
|
+
before = _tracked_status(work)
|
|
939
|
+
if before:
|
|
940
|
+
raise SystemExit("BLOCKED:closure-durability-apply: tracked worktree/index is not clean")
|
|
941
|
+
check = subprocess.run(
|
|
942
|
+
["git", "apply", "--check", "--index", "-"], cwd=work,
|
|
943
|
+
input=patch, capture_output=True, check=False,
|
|
944
|
+
)
|
|
945
|
+
if check.returncode != 0:
|
|
946
|
+
if _tracked_status(work) != before:
|
|
947
|
+
raise SystemExit("BLOCKED:closure-durability-apply: preflight mutated tracked state")
|
|
948
|
+
detail = os.fsdecode(check.stderr or check.stdout).strip() or "git apply --check failed"
|
|
949
|
+
raise SystemExit(f"BLOCKED:closure-durability-apply: {detail}")
|
|
950
|
+
apply = subprocess.run(
|
|
951
|
+
["git", "apply", "--index", "-"], cwd=work,
|
|
952
|
+
input=patch, capture_output=True, check=False,
|
|
953
|
+
)
|
|
954
|
+
if apply.returncode != 0:
|
|
955
|
+
subprocess.run(
|
|
956
|
+
["git", "restore", "--source=HEAD", "--staged", "--worktree", "--", *paths],
|
|
957
|
+
cwd=work, capture_output=True, check=False,
|
|
958
|
+
)
|
|
959
|
+
if _tracked_status(work) != before:
|
|
960
|
+
raise SystemExit("BLOCKED:closure-durability-apply: failed apply left partial mutation")
|
|
961
|
+
detail = os.fsdecode(apply.stderr or apply.stdout).strip() or "git apply failed"
|
|
962
|
+
raise SystemExit(f"BLOCKED:closure-durability-apply: {detail}")
|
|
963
|
+
|
|
964
|
+
|
|
965
|
+
def _write_json_atomic(path: pathlib.Path, value: dict) -> bytes:
|
|
966
|
+
raw = (json.dumps(value, indent=2, sort_keys=True) + "\n").encode("utf-8")
|
|
967
|
+
fd, tmp_name = tempfile.mkstemp(dir=str(path.parent), prefix=path.name + ".tmp.")
|
|
968
|
+
try:
|
|
969
|
+
with open(fd, "wb") as handle:
|
|
970
|
+
handle.write(raw)
|
|
971
|
+
pathlib.Path(tmp_name).replace(path)
|
|
972
|
+
except BaseException:
|
|
973
|
+
pathlib.Path(tmp_name).unlink(missing_ok=True)
|
|
974
|
+
raise
|
|
975
|
+
return raw
|
|
976
|
+
|
|
977
|
+
|
|
978
|
+
def _surface_durability_ledger(state: dict) -> list[dict]:
|
|
979
|
+
phases = state.setdefault("phases", {})
|
|
980
|
+
surface = phases.get("surface_close")
|
|
981
|
+
if surface is None:
|
|
982
|
+
surface = {}
|
|
983
|
+
phases["surface_close"] = surface
|
|
984
|
+
if not isinstance(surface, dict):
|
|
985
|
+
raise SystemExit("BLOCKED:closure-durability-receipt: phases.surface_close is malformed")
|
|
986
|
+
ledger = surface.setdefault("durability", [])
|
|
987
|
+
if not isinstance(ledger, list) or any(not isinstance(item, dict) for item in ledger):
|
|
988
|
+
raise SystemExit("BLOCKED:closure-durability-receipt: durability ledger is malformed")
|
|
989
|
+
return ledger
|
|
990
|
+
|
|
991
|
+
|
|
992
|
+
def _receipt_blocks(blocks: list[dict]) -> list[dict]:
|
|
993
|
+
return [{
|
|
994
|
+
"id": block["id"],
|
|
995
|
+
"path": block["path"],
|
|
996
|
+
"pre_fix_span": block["pre_fix_span"],
|
|
997
|
+
"classification": block["classification"],
|
|
998
|
+
"finding_targeted": block["finding_targeted"],
|
|
999
|
+
"action": block["action"],
|
|
1000
|
+
} for block in blocks]
|
|
1001
|
+
|
|
1002
|
+
|
|
1003
|
+
def _rollback_durability_creation(
|
|
1004
|
+
work: pathlib.Path, receipt_path: pathlib.Path, state: dict, receipt: dict,
|
|
1005
|
+
) -> None:
|
|
1006
|
+
restore_sha = receipt.get("restore_commit_sha")
|
|
1007
|
+
fix_sha = receipt["fix_commit_sha"]
|
|
1008
|
+
restored_paths = sorted({
|
|
1009
|
+
block["path"] for block in receipt.get("blocks", [])
|
|
1010
|
+
if block.get("action") == "restored"
|
|
1011
|
+
})
|
|
1012
|
+
if restore_sha is not None:
|
|
1013
|
+
head = _git_output(work, "rev-parse", "HEAD").decode().strip()
|
|
1014
|
+
if head != restore_sha:
|
|
1015
|
+
raise SystemExit("BLOCKED:closure-durability-rollback: restore commit is no longer HEAD")
|
|
1016
|
+
restore = subprocess.run(
|
|
1017
|
+
["git", "restore", f"--source={fix_sha}", "--staged", "--worktree", "--", *restored_paths],
|
|
1018
|
+
cwd=work, capture_output=True, check=False,
|
|
1019
|
+
)
|
|
1020
|
+
if restore.returncode != 0:
|
|
1021
|
+
detail = os.fsdecode(restore.stderr or restore.stdout).strip() or "git restore failed"
|
|
1022
|
+
raise SystemExit(f"BLOCKED:closure-durability-rollback: {detail}")
|
|
1023
|
+
update = subprocess.run(
|
|
1024
|
+
["git", "update-ref", "HEAD", fix_sha, restore_sha],
|
|
1025
|
+
cwd=work, capture_output=True, check=False,
|
|
1026
|
+
)
|
|
1027
|
+
if update.returncode != 0:
|
|
1028
|
+
subprocess.run(
|
|
1029
|
+
["git", "restore", f"--source={restore_sha}", "--staged", "--worktree", "--",
|
|
1030
|
+
*restored_paths], cwd=work, capture_output=True, check=False,
|
|
1031
|
+
)
|
|
1032
|
+
detail = os.fsdecode(update.stderr or update.stdout).strip() or "git update-ref failed"
|
|
1033
|
+
raise SystemExit(f"BLOCKED:closure-durability-rollback: {detail}")
|
|
1034
|
+
receipt_path.unlink(missing_ok=True)
|
|
1035
|
+
ledger = _surface_durability_ledger(state)
|
|
1036
|
+
ledger[:] = [
|
|
1037
|
+
item for item in ledger
|
|
1038
|
+
if not (item.get("round") == receipt["round"]
|
|
1039
|
+
and item.get("origin_phase") == receipt["origin_phase"])
|
|
1040
|
+
]
|
|
1041
|
+
head = _git_output(work, "rev-parse", "HEAD").decode().strip()
|
|
1042
|
+
if head != fix_sha or _tracked_status(work):
|
|
1043
|
+
raise SystemExit("BLOCKED:closure-durability-rollback: rollback left partial mutation")
|
|
1044
|
+
|
|
1045
|
+
|
|
1046
|
+
def _validate_durability_receipt(
|
|
1047
|
+
work: pathlib.Path, devlyn: pathlib.Path, state: dict, origin_phase: str, round_: int,
|
|
1048
|
+
receipt_path: pathlib.Path, findings_digest: str, ledger: list[dict],
|
|
1049
|
+
) -> dict | None:
|
|
1050
|
+
matches = [item for item in ledger if item.get("round") == round_]
|
|
1051
|
+
if not receipt_path.exists() and not matches:
|
|
1052
|
+
return None
|
|
1053
|
+
if not receipt_path.is_file() or len(matches) != 1:
|
|
1054
|
+
raise SystemExit(
|
|
1055
|
+
f"BLOCKED:closure-durability-receipt: missing or duplicate round {round_} receipt/ledger"
|
|
1056
|
+
)
|
|
1057
|
+
try:
|
|
1058
|
+
raw = receipt_path.read_bytes()
|
|
1059
|
+
receipt = loads_strict_json(raw.decode("utf-8"))
|
|
1060
|
+
except (OSError, UnicodeError, ValueError) as exc:
|
|
1061
|
+
raise SystemExit(f"BLOCKED:closure-durability-receipt: {receipt_path}: {exc}") from exc
|
|
1062
|
+
required = {
|
|
1063
|
+
"schema_version", "round", "origin_phase", "triggering_findings_sha256",
|
|
1064
|
+
"surface_close_commit_sha", "pre_fix_sha", "fix_commit_sha", "post_restore_sha",
|
|
1065
|
+
"restore_commit_sha", "blocks",
|
|
1066
|
+
}
|
|
1067
|
+
if not isinstance(receipt, dict) or set(receipt) != required:
|
|
1068
|
+
raise SystemExit("BLOCKED:closure-durability-receipt: receipt fields are stale or malformed")
|
|
1069
|
+
surface = ((state.get("phases") or {}).get("surface_close") or {})
|
|
1070
|
+
expected_surface_sha = surface.get("post_sha") if isinstance(surface, dict) else None
|
|
1071
|
+
if (
|
|
1072
|
+
receipt["schema_version"] != 1 or receipt["round"] != round_
|
|
1073
|
+
or receipt["origin_phase"] != origin_phase
|
|
1074
|
+
or receipt["triggering_findings_sha256"] != findings_digest
|
|
1075
|
+
or receipt["surface_close_commit_sha"] != expected_surface_sha
|
|
1076
|
+
):
|
|
1077
|
+
raise SystemExit("BLOCKED:closure-durability-receipt: receipt metadata is stale")
|
|
1078
|
+
blocks = receipt.get("blocks")
|
|
1079
|
+
if not isinstance(blocks, list):
|
|
1080
|
+
raise SystemExit("BLOCKED:closure-durability-receipt: blocks must be an array")
|
|
1081
|
+
block_fields = {"id", "path", "pre_fix_span", "classification", "finding_targeted", "action"}
|
|
1082
|
+
for block in blocks:
|
|
1083
|
+
span = block.get("pre_fix_span") if isinstance(block, dict) else None
|
|
1084
|
+
if (
|
|
1085
|
+
not isinstance(block, dict) or set(block) != block_fields
|
|
1086
|
+
or not isinstance(block.get("id"), str) or not isinstance(block.get("path"), str)
|
|
1087
|
+
or not isinstance(span, list) or len(span) != 2
|
|
1088
|
+
or any(not isinstance(value, int) or isinstance(value, bool) or value < 1 for value in span)
|
|
1089
|
+
or span[0] > span[1]
|
|
1090
|
+
or block.get("classification") not in {"SURVIVED", "REVERTED", "EVOLVED"}
|
|
1091
|
+
or not isinstance(block.get("finding_targeted"), bool)
|
|
1092
|
+
or block.get("action") not in {"restored", "finding-targeted", "none"}
|
|
1093
|
+
):
|
|
1094
|
+
raise SystemExit("BLOCKED:closure-durability-receipt: block entry is malformed")
|
|
1095
|
+
sha_fields = ("pre_fix_sha", "fix_commit_sha", "post_restore_sha")
|
|
1096
|
+
if any(not isinstance(receipt.get(field), str) or not re.fullmatch(r"[0-9a-f]{40,64}", receipt[field])
|
|
1097
|
+
for field in sha_fields):
|
|
1098
|
+
raise SystemExit("BLOCKED:closure-durability-receipt: commit sha is malformed")
|
|
1099
|
+
restore_sha = receipt.get("restore_commit_sha")
|
|
1100
|
+
if restore_sha is not None and (
|
|
1101
|
+
not isinstance(restore_sha, str) or not re.fullmatch(r"[0-9a-f]{40,64}", restore_sha)
|
|
1102
|
+
):
|
|
1103
|
+
raise SystemExit("BLOCKED:closure-durability-receipt: restore_commit_sha is malformed")
|
|
1104
|
+
head = _git_output(work, "rev-parse", "HEAD").decode().strip()
|
|
1105
|
+
if head != receipt["post_restore_sha"] or (restore_sha is not None and restore_sha != head):
|
|
1106
|
+
raise SystemExit("BLOCKED:closure-durability-receipt: receipt does not match current HEAD")
|
|
1107
|
+
if restore_sha is None and receipt["fix_commit_sha"] != receipt["post_restore_sha"]:
|
|
1108
|
+
raise SystemExit("BLOCKED:closure-durability-receipt: no-op receipt changed HEAD")
|
|
1109
|
+
if restore_sha is not None:
|
|
1110
|
+
parent = _git_output(work, "rev-parse", f"{restore_sha}^").decode().strip()
|
|
1111
|
+
subject = _git_output(work, "show", "-s", "--format=%s", restore_sha).decode().strip()
|
|
1112
|
+
if parent != receipt["fix_commit_sha"] or subject != f"chore(pipeline): closure-restore round {round_}":
|
|
1113
|
+
raise SystemExit("BLOCKED:closure-durability-receipt: restore commit evidence is stale")
|
|
1114
|
+
fix_parent = _git_output(work, "rev-parse", f"{receipt['fix_commit_sha']}^").decode().strip()
|
|
1115
|
+
fix_subject = _git_output(
|
|
1116
|
+
work, "show", "-s", "--format=%s", receipt["fix_commit_sha"]
|
|
1117
|
+
).decode().strip()
|
|
1118
|
+
if fix_parent != receipt["pre_fix_sha"] or fix_subject != f"chore(pipeline): implement fix round {round_}":
|
|
1119
|
+
raise SystemExit("BLOCKED:closure-durability-receipt: fix checkpoint evidence is stale")
|
|
1120
|
+
receipt_rel = f"{devlyn_prefix(work, devlyn)}/{receipt_path.name}"
|
|
1121
|
+
expected_ledger = {
|
|
1122
|
+
"round": round_,
|
|
1123
|
+
"origin_phase": origin_phase,
|
|
1124
|
+
"receipt_path": receipt_rel,
|
|
1125
|
+
"receipt_sha256": hashlib.sha256(raw).hexdigest(),
|
|
1126
|
+
"restore_commit_sha": restore_sha,
|
|
1127
|
+
}
|
|
1128
|
+
if matches[0] != expected_ledger:
|
|
1129
|
+
raise SystemExit("BLOCKED:closure-durability-receipt: ledger entry is stale")
|
|
1130
|
+
return receipt
|
|
1131
|
+
|
|
1132
|
+
|
|
1133
|
+
def enforce_closure_durability_reentry(
|
|
1134
|
+
work: pathlib.Path, devlyn: pathlib.Path, state: dict, origin_phase: str, round_: int,
|
|
1135
|
+
*, require_existing: bool = False,
|
|
1136
|
+
) -> dict | None:
|
|
1137
|
+
if round_ < 1:
|
|
1138
|
+
return None
|
|
1139
|
+
if origin_phase not in VALID_TRIGGERS:
|
|
1140
|
+
raise SystemExit(f"BLOCKED:closure-durability-receipt: invalid origin phase {origin_phase!r}")
|
|
1141
|
+
findings, findings_digest = _read_triggering_findings(devlyn, origin_phase)
|
|
1142
|
+
ledger = _surface_durability_ledger(state)
|
|
1143
|
+
receipt_path = devlyn / f"closure-durability.round-{round_}.json"
|
|
1144
|
+
existing = _validate_durability_receipt(
|
|
1145
|
+
work, devlyn, state, origin_phase, round_, receipt_path, findings_digest, ledger,
|
|
1146
|
+
)
|
|
1147
|
+
if existing is not None:
|
|
1148
|
+
return existing
|
|
1149
|
+
if require_existing:
|
|
1150
|
+
raise SystemExit(
|
|
1151
|
+
f"BLOCKED:closure-durability-receipt: round {round_} checkpoint receipt is missing"
|
|
1152
|
+
)
|
|
1153
|
+
|
|
1154
|
+
if any(item.get("round") == round_ or item.get("origin_phase") == origin_phase
|
|
1155
|
+
and item.get("receipt_path") == f"{devlyn_prefix(work, devlyn)}/{receipt_path.name}"
|
|
1156
|
+
for item in ledger):
|
|
1157
|
+
raise SystemExit("BLOCKED:closure-durability-receipt: append-only ledger collision")
|
|
1158
|
+
if _tracked_status(work):
|
|
1159
|
+
raise SystemExit("BLOCKED:closure-durability-apply: tracked worktree/index is not clean")
|
|
1160
|
+
fix_commit_sha = _git_output(work, "rev-parse", "HEAD").decode().strip()
|
|
1161
|
+
fix_subject = _git_output(work, "show", "-s", "--format=%s", fix_commit_sha).decode().strip()
|
|
1162
|
+
if fix_subject != f"chore(pipeline): implement fix round {round_}":
|
|
1163
|
+
raise SystemExit(
|
|
1164
|
+
f"BLOCKED:closure-durability-receipt: expected fix checkpoint round {round_}, got {fix_subject!r}"
|
|
1165
|
+
)
|
|
1166
|
+
pre_fix_sha = _git_output(work, "rev-parse", f"{fix_commit_sha}^").decode().strip()
|
|
1167
|
+
surface = ((state.get("phases") or {}).get("surface_close") or {})
|
|
1168
|
+
surface_commit_sha = surface.get("post_sha") if isinstance(surface, dict) else None
|
|
1169
|
+
blocks = surface_change_blocks(work, state, pre_fix_sha)
|
|
1170
|
+
restore_blocks = []
|
|
1171
|
+
for block in blocks:
|
|
1172
|
+
classification = classify_surface_block(block, _worktree_file(work, block["path"]))
|
|
1173
|
+
targeted = finding_targets_block(block, findings)
|
|
1174
|
+
block["classification"] = classification
|
|
1175
|
+
block["finding_targeted"] = targeted
|
|
1176
|
+
if classification == "REVERTED" and not targeted:
|
|
1177
|
+
block["action"] = "restored"
|
|
1178
|
+
restore_blocks.append(block)
|
|
1179
|
+
elif targeted:
|
|
1180
|
+
block["action"] = "finding-targeted"
|
|
1181
|
+
else:
|
|
1182
|
+
block["action"] = "none"
|
|
1183
|
+
|
|
1184
|
+
restore_commit_sha = None
|
|
1185
|
+
if restore_blocks:
|
|
1186
|
+
desired = _restored_files(work, restore_blocks)
|
|
1187
|
+
patch = _restore_patch(work, desired)
|
|
1188
|
+
if not patch:
|
|
1189
|
+
raise SystemExit("BLOCKED:closure-durability-apply: restored blocks produced an empty patch")
|
|
1190
|
+
paths = sorted(desired)
|
|
1191
|
+
_apply_restore_patch(work, patch, paths)
|
|
1192
|
+
commit = subprocess.run(
|
|
1193
|
+
["git", "commit", "-m", f"chore(pipeline): closure-restore round {round_}"],
|
|
1194
|
+
cwd=work, capture_output=True, check=False,
|
|
1195
|
+
)
|
|
1196
|
+
if commit.returncode != 0:
|
|
1197
|
+
subprocess.run(
|
|
1198
|
+
["git", "restore", "--source=HEAD", "--staged", "--worktree", "--", *paths],
|
|
1199
|
+
cwd=work, capture_output=True, check=False,
|
|
1200
|
+
)
|
|
1201
|
+
detail = os.fsdecode(commit.stderr or commit.stdout).strip() or "git commit failed"
|
|
1202
|
+
raise SystemExit(f"BLOCKED:closure-durability-apply: {detail}")
|
|
1203
|
+
restore_commit_sha = _git_output(work, "rev-parse", "HEAD").decode().strip()
|
|
1204
|
+
post_restore_sha = _git_output(work, "rev-parse", "HEAD").decode().strip()
|
|
1205
|
+
receipt = {
|
|
1206
|
+
"schema_version": 1,
|
|
1207
|
+
"round": round_,
|
|
1208
|
+
"origin_phase": origin_phase,
|
|
1209
|
+
"triggering_findings_sha256": findings_digest,
|
|
1210
|
+
"surface_close_commit_sha": surface_commit_sha,
|
|
1211
|
+
"pre_fix_sha": pre_fix_sha,
|
|
1212
|
+
"fix_commit_sha": fix_commit_sha,
|
|
1213
|
+
"post_restore_sha": post_restore_sha,
|
|
1214
|
+
"restore_commit_sha": restore_commit_sha,
|
|
1215
|
+
"blocks": _receipt_blocks(blocks),
|
|
1216
|
+
}
|
|
1217
|
+
try:
|
|
1218
|
+
raw = _write_json_atomic(receipt_path, receipt)
|
|
1219
|
+
ledger.append({
|
|
1220
|
+
"round": round_,
|
|
1221
|
+
"origin_phase": origin_phase,
|
|
1222
|
+
"receipt_path": f"{devlyn_prefix(work, devlyn)}/{receipt_path.name}",
|
|
1223
|
+
"receipt_sha256": hashlib.sha256(raw).hexdigest(),
|
|
1224
|
+
"restore_commit_sha": restore_commit_sha,
|
|
1225
|
+
})
|
|
1226
|
+
except BaseException as exc:
|
|
1227
|
+
_rollback_durability_creation(work, receipt_path, state, receipt)
|
|
1228
|
+
raise SystemExit(f"BLOCKED:closure-durability-receipt: {exc}") from exc
|
|
1229
|
+
return receipt
|
|
1230
|
+
|
|
1231
|
+
|
|
1232
|
+
def _persist_durability_event(
|
|
1233
|
+
work: pathlib.Path, devlyn: pathlib.Path, state: dict, state_path: pathlib.Path,
|
|
1234
|
+
origin_phase: str, round_: int, writer=write_state,
|
|
1235
|
+
) -> dict:
|
|
1236
|
+
receipt_path = devlyn / f"closure-durability.round-{round_}.json"
|
|
1237
|
+
existed = receipt_path.exists()
|
|
1238
|
+
receipt = enforce_closure_durability_reentry(
|
|
1239
|
+
work, devlyn, state, origin_phase, round_,
|
|
1240
|
+
)
|
|
1241
|
+
try:
|
|
1242
|
+
writer(state_path, state)
|
|
1243
|
+
except BaseException:
|
|
1244
|
+
if not existed:
|
|
1245
|
+
_rollback_durability_creation(work, receipt_path, state, receipt)
|
|
1246
|
+
raise
|
|
1247
|
+
return receipt
|
|
1248
|
+
|
|
1249
|
+
|
|
82
1250
|
def clear_verify_round_artifacts(devlyn: pathlib.Path) -> None:
|
|
83
1251
|
# The per-round reset contract covers files, not just JSON fields: a
|
|
84
1252
|
# VERIFY fix-loop respawn reuses the same .devlyn, so a prior round's
|
|
@@ -94,31 +1262,85 @@ def clear_verify_round_artifacts(devlyn: pathlib.Path) -> None:
|
|
|
94
1262
|
(devlyn / "verify-merge.summary.json").unlink(missing_ok=True)
|
|
95
1263
|
|
|
96
1264
|
|
|
97
|
-
def
|
|
1265
|
+
def is_plan_dispatch_receipt(entry: dict) -> bool:
|
|
1266
|
+
return all(field in entry for field in PLAN_RECEIPT_FIELDS)
|
|
1267
|
+
|
|
1268
|
+
|
|
1269
|
+
def append_phase_history(entry: dict, phase: str) -> None:
|
|
98
1270
|
if entry.get("started_at") is None:
|
|
99
1271
|
return
|
|
100
1272
|
history = entry.get("history")
|
|
101
1273
|
if not isinstance(history, list):
|
|
102
1274
|
history = []
|
|
103
|
-
|
|
104
|
-
"
|
|
105
|
-
"
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
})
|
|
1275
|
+
fields = (
|
|
1276
|
+
PLAN_RECEIPT_FIELDS if phase == "plan" and is_plan_dispatch_receipt(entry)
|
|
1277
|
+
else ("started_at", "verdict", "completed_at", "duration_ms")
|
|
1278
|
+
)
|
|
1279
|
+
history.append({field: entry.get(field) for field in fields})
|
|
109
1280
|
entry["history"] = history
|
|
110
1281
|
|
|
111
1282
|
|
|
112
1283
|
def do_spawn(state: dict, phase: str, round_: int, triggered_by: str | None,
|
|
113
|
-
pre_sha: str | None, engine: str | None, model: str | None
|
|
1284
|
+
pre_sha: str | None, engine: str | None, model: str | None, *,
|
|
1285
|
+
input_patch_sha256: str | None = None,
|
|
1286
|
+
prompt_sha256: str | None = None,
|
|
1287
|
+
untracked_before: list[str] | None = None) -> None:
|
|
114
1288
|
# Merge, don't replace: a phase-gated large run's `exec` progress (or any
|
|
115
1289
|
# other field this script doesn't own) survives a fix-loop respawn.
|
|
1290
|
+
phases_value = state.get("phases")
|
|
1291
|
+
entry = phases_value.get(phase) if isinstance(phases_value, dict) else None
|
|
1292
|
+
if phase == "plan":
|
|
1293
|
+
history = entry.get("history", []) if isinstance(entry, dict) else []
|
|
1294
|
+
if not isinstance(history, list):
|
|
1295
|
+
raise SystemExit("error: phases.plan.history must be an array")
|
|
1296
|
+
dispatch_count = len(history) + int(
|
|
1297
|
+
isinstance(entry, dict) and entry.get("started_at") is not None
|
|
1298
|
+
)
|
|
1299
|
+
if dispatch_count >= PLAN_MAX_DISPATCHES:
|
|
1300
|
+
raise SystemExit("BLOCKED:plan-respawn-exhausted")
|
|
1301
|
+
if round_ != dispatch_count:
|
|
1302
|
+
raise SystemExit(
|
|
1303
|
+
f"BLOCKED:plan-round-nonmonotonic: expected={dispatch_count} supplied={round_}"
|
|
1304
|
+
)
|
|
1305
|
+
if not isinstance(engine, str) or not engine:
|
|
1306
|
+
raise SystemExit("error: phases.plan spawn requires --engine")
|
|
1307
|
+
if not isinstance(model, str) or not model:
|
|
1308
|
+
raise SystemExit("error: phases.plan spawn requires --model")
|
|
1309
|
+
if prompt_sha256 is None or not SHA256_RE.fullmatch(prompt_sha256):
|
|
1310
|
+
raise SystemExit("error: phases.plan spawn requires --prompt-sha256")
|
|
1311
|
+
if dispatch_count > 0 and triggered_by is None:
|
|
1312
|
+
raise SystemExit("error: phases.plan re-spawn requires --triggered-by")
|
|
1313
|
+
if phase == "surface_close" and engine != "claude":
|
|
1314
|
+
raise SystemExit("error: phases.surface_close spawn requires --engine claude")
|
|
1315
|
+
if phase == "surface_close" and not model:
|
|
1316
|
+
raise SystemExit("error: phases.surface_close spawn requires --model")
|
|
1317
|
+
if phase == "surface_close" and isinstance(entry, dict) and (
|
|
1318
|
+
entry.get("started_at") is not None or entry.get("skipped_reason") is not None
|
|
1319
|
+
):
|
|
1320
|
+
raise SystemExit("error: phases.surface_close is one-shot and cannot be re-entered")
|
|
1321
|
+
if phase == "surface_close":
|
|
1322
|
+
if pre_sha is None:
|
|
1323
|
+
raise SystemExit("error: phases.surface_close spawn requires --pre-sha")
|
|
1324
|
+
if input_patch_sha256 is None or not SHA256_RE.fullmatch(input_patch_sha256):
|
|
1325
|
+
raise SystemExit("error: phases.surface_close spawn requires --input-patch-sha256")
|
|
1326
|
+
if prompt_sha256 is None or not SHA256_RE.fullmatch(prompt_sha256):
|
|
1327
|
+
raise SystemExit("error: phases.surface_close spawn requires --prompt-sha256")
|
|
1328
|
+
if untracked_before is None:
|
|
1329
|
+
raise SystemExit("error: phases.surface_close spawn requires --untracked-before-json")
|
|
1330
|
+
elif phase == "plan":
|
|
1331
|
+
if input_patch_sha256 is not None or untracked_before is not None:
|
|
1332
|
+
raise SystemExit("error: SURFACE_CLOSE metadata is invalid for this phase")
|
|
1333
|
+
elif input_patch_sha256 is not None or prompt_sha256 is not None or untracked_before is not None:
|
|
1334
|
+
raise SystemExit("error: SURFACE_CLOSE metadata is invalid for this phase")
|
|
116
1335
|
phases = state.setdefault("phases", {})
|
|
117
|
-
entry = phases.get(phase)
|
|
118
1336
|
if not isinstance(entry, dict):
|
|
119
1337
|
entry = {}
|
|
120
1338
|
phases[phase] = entry
|
|
121
|
-
|
|
1339
|
+
if entry.get("started_at") is not None and entry.get("completed_at") is None:
|
|
1340
|
+
raise SystemExit(
|
|
1341
|
+
f"error: phases.{phase} has an open span — complete it before respawn"
|
|
1342
|
+
)
|
|
1343
|
+
append_phase_history(entry, phase)
|
|
122
1344
|
entry["started_at"] = now_iso()
|
|
123
1345
|
entry["completed_at"] = None
|
|
124
1346
|
entry["duration_ms"] = None
|
|
@@ -127,21 +1349,67 @@ def do_spawn(state: dict, phase: str, round_: int, triggered_by: str | None,
|
|
|
127
1349
|
entry["verdict"] = None
|
|
128
1350
|
entry["artifacts"] = {"findings_file": None, "log_file": None}
|
|
129
1351
|
entry["sub_verdicts"] = None
|
|
1352
|
+
if phase == "verify":
|
|
1353
|
+
entry["judge_durations_ms"] = None
|
|
130
1354
|
if engine is not None:
|
|
131
1355
|
entry["engine"] = engine
|
|
132
1356
|
if model is not None:
|
|
133
|
-
entry["
|
|
1357
|
+
entry["model_requested"] = model
|
|
1358
|
+
else:
|
|
1359
|
+
entry.setdefault("model_requested", None)
|
|
1360
|
+
entry.pop("model", None)
|
|
1361
|
+
entry["model_effective"] = None
|
|
134
1362
|
if pre_sha is not None:
|
|
135
1363
|
entry["pre_sha"] = pre_sha
|
|
1364
|
+
if phase in {"plan", "surface_close"}:
|
|
1365
|
+
entry["prompt_sha256"] = prompt_sha256
|
|
1366
|
+
if phase == "surface_close":
|
|
1367
|
+
entry["input_patch_sha256"] = input_patch_sha256
|
|
1368
|
+
entry["untracked_before"] = untracked_before
|
|
1369
|
+
|
|
1370
|
+
|
|
1371
|
+
def do_surface_skip(state: dict) -> None:
|
|
1372
|
+
phases = state.setdefault("phases", {})
|
|
1373
|
+
existing = phases.get("surface_close")
|
|
1374
|
+
if isinstance(existing, dict) and (
|
|
1375
|
+
existing.get("started_at") is not None or existing.get("skipped_reason") is not None
|
|
1376
|
+
):
|
|
1377
|
+
raise SystemExit("error: phases.surface_close is one-shot and cannot be re-entered")
|
|
1378
|
+
phases["surface_close"] = {
|
|
1379
|
+
"started_at": None,
|
|
1380
|
+
"completed_at": now_iso(),
|
|
1381
|
+
"duration_ms": 0,
|
|
1382
|
+
"round": 0,
|
|
1383
|
+
"triggered_by": None,
|
|
1384
|
+
"verdict": None,
|
|
1385
|
+
"engine": "claude",
|
|
1386
|
+
"model_requested": None,
|
|
1387
|
+
"model_effective": None,
|
|
1388
|
+
"artifacts": {"findings_file": None, "log_file": None},
|
|
1389
|
+
"sub_verdicts": None,
|
|
1390
|
+
"skipped_reason": SURFACE_SKIP_REASON,
|
|
1391
|
+
}
|
|
136
1392
|
|
|
137
1393
|
|
|
138
1394
|
def do_complete(state: dict, phase: str, verdict: str | None,
|
|
139
1395
|
post_sha: str | None, findings_file: str | None, log_file: str | None,
|
|
140
|
-
engine: str | None, model: str | None
|
|
1396
|
+
engine: str | None, model: str | None,
|
|
1397
|
+
engine_session_log: str | None = None,
|
|
1398
|
+
devlyn: pathlib.Path | None = None) -> str | None:
|
|
141
1399
|
phases = state.setdefault("phases", {})
|
|
142
1400
|
entry = phases.get(phase)
|
|
143
1401
|
if not isinstance(entry, dict) or not entry.get("started_at"):
|
|
144
1402
|
raise SystemExit(f"error: phases.{phase} was never spawned (no started_at) — cannot complete")
|
|
1403
|
+
if phase == "plan" and entry.get("prompt_sha256") is not None:
|
|
1404
|
+
missing = [field for field in PLAN_SPAWN_RECEIPT_FIELDS if field not in entry]
|
|
1405
|
+
if missing:
|
|
1406
|
+
raise SystemExit(
|
|
1407
|
+
"error: phases.plan spawn receipt is incomplete: " + ",".join(missing)
|
|
1408
|
+
)
|
|
1409
|
+
if engine is not None and engine != entry["engine"]:
|
|
1410
|
+
raise SystemExit("error: phases.plan completion cannot replace spawn engine")
|
|
1411
|
+
if model is not None and model != entry["model_requested"]:
|
|
1412
|
+
raise SystemExit("error: phases.plan completion cannot replace requested model")
|
|
145
1413
|
started = parse_iso(entry["started_at"])
|
|
146
1414
|
now = now_ms()
|
|
147
1415
|
entry["completed_at"] = now_iso(now)
|
|
@@ -167,12 +1435,113 @@ def do_complete(state: dict, phase: str, verdict: str | None,
|
|
|
167
1435
|
artifacts["findings_file"] = findings_file
|
|
168
1436
|
if log_file is not None:
|
|
169
1437
|
artifacts["log_file"] = log_file
|
|
170
|
-
if engine is not None:
|
|
1438
|
+
if engine is not None and phase != "plan":
|
|
171
1439
|
entry["engine"] = engine
|
|
172
|
-
if model is not None:
|
|
173
|
-
entry["
|
|
1440
|
+
if model is not None and phase != "plan":
|
|
1441
|
+
entry["model_requested"] = model
|
|
1442
|
+
elif phase != "plan":
|
|
1443
|
+
entry.setdefault("model_requested", None)
|
|
1444
|
+
entry.pop("model", None)
|
|
1445
|
+
|
|
1446
|
+
artifact_phase = WORKER_SESSION_ARTIFACT_PHASES.get(phase)
|
|
1447
|
+
retained_session = None
|
|
1448
|
+
if devlyn is not None and artifact_phase is not None:
|
|
1449
|
+
candidate = devlyn / f"{artifact_phase}.worker-session.{entry.get('round')}.jsonl"
|
|
1450
|
+
if candidate.is_file():
|
|
1451
|
+
retained_session = candidate
|
|
1452
|
+
|
|
1453
|
+
attestation_error = None
|
|
1454
|
+
if engine_session_log is None:
|
|
1455
|
+
entry["model_effective"] = None
|
|
1456
|
+
if retained_session is not None:
|
|
1457
|
+
attestation_error = (
|
|
1458
|
+
"BLOCKED:model-attestation-failed: --engine-session-log is required because "
|
|
1459
|
+
f"retained worker session exists: {retained_session}"
|
|
1460
|
+
)
|
|
1461
|
+
else:
|
|
1462
|
+
try:
|
|
1463
|
+
entry["model_effective"] = parse_effective_model(pathlib.Path(engine_session_log))
|
|
1464
|
+
except ValueError as exc:
|
|
1465
|
+
entry["model_effective"] = None
|
|
1466
|
+
attestation_error = f"BLOCKED:model-attestation-failed: {exc}"
|
|
1467
|
+
requested = entry.get("model_requested")
|
|
1468
|
+
effective = entry.get("model_effective")
|
|
1469
|
+
if attestation_error is None and requested is not None and requested != effective:
|
|
1470
|
+
attestation_error = (
|
|
1471
|
+
"BLOCKED:model-attestation-mismatch: "
|
|
1472
|
+
f"requested={requested} effective={effective}"
|
|
1473
|
+
)
|
|
1474
|
+
if attestation_error is not None:
|
|
1475
|
+
entry["verdict"] = "BLOCKED"
|
|
174
1476
|
if post_sha is not None:
|
|
175
1477
|
entry["post_sha"] = post_sha
|
|
1478
|
+
return attestation_error
|
|
1479
|
+
|
|
1480
|
+
|
|
1481
|
+
def do_transition(
|
|
1482
|
+
state: dict,
|
|
1483
|
+
phase: str,
|
|
1484
|
+
next_phase: str,
|
|
1485
|
+
verdict: str | None,
|
|
1486
|
+
post_sha: str | None,
|
|
1487
|
+
findings_file: str | None,
|
|
1488
|
+
log_file: str | None,
|
|
1489
|
+
engine: str | None,
|
|
1490
|
+
model: str | None,
|
|
1491
|
+
engine_session_log: str | None,
|
|
1492
|
+
devlyn: pathlib.Path,
|
|
1493
|
+
next_round: int,
|
|
1494
|
+
next_triggered_by: str | None,
|
|
1495
|
+
next_pre_sha: str | None,
|
|
1496
|
+
next_engine: str | None,
|
|
1497
|
+
next_model: str | None,
|
|
1498
|
+
*,
|
|
1499
|
+
next_input_patch_sha256: str | None = None,
|
|
1500
|
+
next_prompt_sha256: str | None = None,
|
|
1501
|
+
next_untracked_before: list[str] | None = None,
|
|
1502
|
+
between=None,
|
|
1503
|
+
) -> dict:
|
|
1504
|
+
"""Validate complete + caller-selected spawn against a copy of state.
|
|
1505
|
+
|
|
1506
|
+
The caller owns the next phase and every judgment-bearing argument. This
|
|
1507
|
+
primitive only validates the requested edge and applies both lifecycle
|
|
1508
|
+
mutations to a detached candidate, which the CLI commits with one atomic
|
|
1509
|
+
state-file replacement.
|
|
1510
|
+
"""
|
|
1511
|
+
if next_phase not in LEGAL_TRANSITIONS.get(phase, set()):
|
|
1512
|
+
raise SystemExit(f"error: illegal phase transition: {phase} -> {next_phase}")
|
|
1513
|
+
candidate = copy.deepcopy(state)
|
|
1514
|
+
attestation_error = do_complete(
|
|
1515
|
+
candidate, phase, verdict, post_sha, findings_file, log_file,
|
|
1516
|
+
engine, model, engine_session_log, devlyn,
|
|
1517
|
+
)
|
|
1518
|
+
if attestation_error is not None:
|
|
1519
|
+
raise SystemExit(attestation_error)
|
|
1520
|
+
if between is not None:
|
|
1521
|
+
between()
|
|
1522
|
+
do_spawn(
|
|
1523
|
+
candidate, next_phase, next_round, next_triggered_by,
|
|
1524
|
+
next_pre_sha, next_engine, next_model,
|
|
1525
|
+
input_patch_sha256=next_input_patch_sha256,
|
|
1526
|
+
prompt_sha256=next_prompt_sha256,
|
|
1527
|
+
untracked_before=next_untracked_before,
|
|
1528
|
+
)
|
|
1529
|
+
return candidate
|
|
1530
|
+
|
|
1531
|
+
|
|
1532
|
+
def do_surface_adjudication_recovery(state: dict, devlyn: pathlib.Path) -> str | None:
|
|
1533
|
+
entry = surface_entry(state)
|
|
1534
|
+
attestation_error = do_complete(
|
|
1535
|
+
state, "surface_close", "BLOCKED", entry["pre_sha"], None,
|
|
1536
|
+
".devlyn/surface-close.stdout", None, None,
|
|
1537
|
+
str(devlyn / "surface-close.output.json"), devlyn,
|
|
1538
|
+
)
|
|
1539
|
+
if attestation_error is not None:
|
|
1540
|
+
return attestation_error
|
|
1541
|
+
entry["verdict"] = None
|
|
1542
|
+
entry["skipped_reason"] = SURFACE_RECOVERY_REASON
|
|
1543
|
+
entry["continued_after_block"] = True
|
|
1544
|
+
return None
|
|
176
1545
|
|
|
177
1546
|
|
|
178
1547
|
def self_test() -> int:
|
|
@@ -183,6 +1552,194 @@ def self_test() -> int:
|
|
|
183
1552
|
state_path = devlyn / "pipeline.state.json"
|
|
184
1553
|
write_state(state_path, {"phases": {}})
|
|
185
1554
|
|
|
1555
|
+
# Iter-0089 P-0089-1/2/6: PLAN authorization is a CLI-level,
|
|
1556
|
+
# state-derived ledger contract. Rejections must leave the actual
|
|
1557
|
+
# state file byte-identical, not merely preserve an in-memory copy.
|
|
1558
|
+
script = str(pathlib.Path(__file__).resolve())
|
|
1559
|
+
digest0 = hashlib.sha256(b"plan prompt round 0").hexdigest()
|
|
1560
|
+
digest1 = hashlib.sha256(b"plan prompt round 1").hexdigest()
|
|
1561
|
+
|
|
1562
|
+
def plan_cli(*event_args: str) -> subprocess.CompletedProcess[str]:
|
|
1563
|
+
return subprocess.run(
|
|
1564
|
+
[
|
|
1565
|
+
sys.executable, script, "--devlyn-dir", str(devlyn),
|
|
1566
|
+
"--phase", "plan", *event_args,
|
|
1567
|
+
],
|
|
1568
|
+
capture_output=True, text=True, check=False,
|
|
1569
|
+
)
|
|
1570
|
+
|
|
1571
|
+
def assert_plan_rejected_unchanged(
|
|
1572
|
+
expected_stderr: str, *event_args: str,
|
|
1573
|
+
) -> None:
|
|
1574
|
+
before = state_path.read_bytes()
|
|
1575
|
+
before_hash = hashlib.sha256(before).hexdigest()
|
|
1576
|
+
result = plan_cli(*event_args)
|
|
1577
|
+
assert result.returncode != 0, result.stdout
|
|
1578
|
+
assert result.stderr.strip() == expected_stderr, result.stderr
|
|
1579
|
+
after = state_path.read_bytes()
|
|
1580
|
+
assert after == before
|
|
1581
|
+
assert hashlib.sha256(after).hexdigest() == before_hash
|
|
1582
|
+
|
|
1583
|
+
required_spawn = (
|
|
1584
|
+
"spawn", "--round", "0", "--engine", "claude",
|
|
1585
|
+
"--model", "plan-test-model", "--prompt-sha256", digest0,
|
|
1586
|
+
)
|
|
1587
|
+
assert_plan_rejected_unchanged(
|
|
1588
|
+
"error: phases.plan spawn requires --engine",
|
|
1589
|
+
"spawn", "--round", "0", "--model", "plan-test-model",
|
|
1590
|
+
"--prompt-sha256", digest0,
|
|
1591
|
+
)
|
|
1592
|
+
assert_plan_rejected_unchanged(
|
|
1593
|
+
"error: phases.plan spawn requires --model",
|
|
1594
|
+
"spawn", "--round", "0", "--engine", "claude",
|
|
1595
|
+
"--prompt-sha256", digest0,
|
|
1596
|
+
)
|
|
1597
|
+
assert_plan_rejected_unchanged(
|
|
1598
|
+
"error: phases.plan spawn requires --prompt-sha256",
|
|
1599
|
+
"spawn", "--round", "0", "--engine", "claude",
|
|
1600
|
+
"--model", "plan-test-model",
|
|
1601
|
+
)
|
|
1602
|
+
assert_plan_rejected_unchanged(
|
|
1603
|
+
"error: phases.plan spawn requires --prompt-sha256",
|
|
1604
|
+
"spawn", "--round", "0", "--engine", "claude",
|
|
1605
|
+
"--model", "plan-test-model", "--prompt-sha256", "ABC",
|
|
1606
|
+
)
|
|
1607
|
+
assert_plan_rejected_unchanged(
|
|
1608
|
+
"BLOCKED:plan-round-nonmonotonic: expected=0 supplied=1",
|
|
1609
|
+
"spawn", "--round", "1", "--engine", "claude",
|
|
1610
|
+
"--model", "plan-test-model", "--prompt-sha256", digest0,
|
|
1611
|
+
)
|
|
1612
|
+
|
|
1613
|
+
result = plan_cli(*required_spawn)
|
|
1614
|
+
assert result.returncode == 0, result.stderr
|
|
1615
|
+
plan0 = read_state(state_path)["phases"]["plan"]
|
|
1616
|
+
assert {field: plan0[field] for field in PLAN_SPAWN_RECEIPT_FIELDS} == {
|
|
1617
|
+
"round": 0,
|
|
1618
|
+
"started_at": plan0["started_at"],
|
|
1619
|
+
"triggered_by": None,
|
|
1620
|
+
"engine": "claude",
|
|
1621
|
+
"model_requested": "plan-test-model",
|
|
1622
|
+
"prompt_sha256": digest0,
|
|
1623
|
+
}
|
|
1624
|
+
result = plan_cli("complete", "--verdict", "NEEDS_WORK")
|
|
1625
|
+
assert result.returncode == 0, result.stderr
|
|
1626
|
+
completed0 = read_state(state_path)["phases"]["plan"]
|
|
1627
|
+
assert all(field in completed0 for field in PLAN_RECEIPT_FIELDS)
|
|
1628
|
+
assert completed0["model_effective"] is None
|
|
1629
|
+
assert completed0["completed_at"] is not None
|
|
1630
|
+
assert completed0["duration_ms"] >= 0
|
|
1631
|
+
assert_plan_rejected_unchanged(
|
|
1632
|
+
"BLOCKED:plan-round-nonmonotonic: expected=1 supplied=0",
|
|
1633
|
+
"spawn", "--round", "0", "--triggered-by", "plan",
|
|
1634
|
+
"--engine", "claude", "--model", "plan-test-model",
|
|
1635
|
+
"--prompt-sha256", digest1,
|
|
1636
|
+
)
|
|
1637
|
+
assert_plan_rejected_unchanged(
|
|
1638
|
+
"BLOCKED:plan-round-nonmonotonic: expected=1 supplied=2",
|
|
1639
|
+
"spawn", "--round", "2", "--triggered-by", "plan",
|
|
1640
|
+
"--engine", "claude", "--model", "plan-test-model",
|
|
1641
|
+
"--prompt-sha256", digest1,
|
|
1642
|
+
)
|
|
1643
|
+
assert_plan_rejected_unchanged(
|
|
1644
|
+
"error: phases.plan re-spawn requires --triggered-by",
|
|
1645
|
+
"spawn", "--round", "1", "--engine", "claude",
|
|
1646
|
+
"--model", "plan-test-model", "--prompt-sha256", digest1,
|
|
1647
|
+
)
|
|
1648
|
+
result = plan_cli(
|
|
1649
|
+
"spawn", "--round", "1", "--triggered-by", "plan",
|
|
1650
|
+
"--engine", "claude", "--model", "plan-test-model",
|
|
1651
|
+
"--prompt-sha256", digest1,
|
|
1652
|
+
)
|
|
1653
|
+
assert result.returncode == 0, result.stderr
|
|
1654
|
+
plan1 = read_state(state_path)["phases"]["plan"]
|
|
1655
|
+
assert len(plan1["history"]) == 1
|
|
1656
|
+
assert set(plan1["history"][0]) == set(PLAN_RECEIPT_FIELDS)
|
|
1657
|
+
assert plan1["history"][0]["prompt_sha256"] == digest0
|
|
1658
|
+
assert plan1["prompt_sha256"] == digest1
|
|
1659
|
+
guard_error = (
|
|
1660
|
+
"error: phases.plan complete with PASS or PASS_WITH_ISSUES requires "
|
|
1661
|
+
"--phase plan transition --verdict <verdict> --next-phase <phase>"
|
|
1662
|
+
)
|
|
1663
|
+
assert_plan_rejected_unchanged(
|
|
1664
|
+
guard_error, "complete", "--verdict", "PASS",
|
|
1665
|
+
)
|
|
1666
|
+
assert_plan_rejected_unchanged(
|
|
1667
|
+
guard_error, "complete", "--verdict", "PASS_WITH_ISSUES",
|
|
1668
|
+
)
|
|
1669
|
+
result = plan_cli(
|
|
1670
|
+
"transition", "--verdict", "PASS", "--next-phase", "implement",
|
|
1671
|
+
"--next-round", "0", "--next-engine", "claude",
|
|
1672
|
+
)
|
|
1673
|
+
assert result.returncode == 0, result.stderr
|
|
1674
|
+
for supplied_round in (0, 1, 2):
|
|
1675
|
+
assert_plan_rejected_unchanged(
|
|
1676
|
+
"BLOCKED:plan-respawn-exhausted",
|
|
1677
|
+
"spawn", "--round", str(supplied_round),
|
|
1678
|
+
"--triggered-by", "plan", "--engine", "claude",
|
|
1679
|
+
"--model", "plan-test-model", "--prompt-sha256", digest1,
|
|
1680
|
+
)
|
|
1681
|
+
old_receipt = {
|
|
1682
|
+
"started_at": "2026-01-01T00:00:00.000Z",
|
|
1683
|
+
"verdict": "PASS",
|
|
1684
|
+
"completed_at": "2026-01-01T00:00:01.000Z",
|
|
1685
|
+
"duration_ms": 1000,
|
|
1686
|
+
}
|
|
1687
|
+
assert not is_plan_dispatch_receipt(old_receipt)
|
|
1688
|
+
assert is_plan_dispatch_receipt(read_state(state_path)["phases"]["plan"])
|
|
1689
|
+
write_state(state_path, {
|
|
1690
|
+
"phases": {
|
|
1691
|
+
"plan": {
|
|
1692
|
+
"started_at": "2026-01-01T00:00:00.000Z",
|
|
1693
|
+
"completed_at": None,
|
|
1694
|
+
"duration_ms": None,
|
|
1695
|
+
"round": 0,
|
|
1696
|
+
"triggered_by": None,
|
|
1697
|
+
"verdict": None,
|
|
1698
|
+
},
|
|
1699
|
+
"implement": None,
|
|
1700
|
+
}
|
|
1701
|
+
})
|
|
1702
|
+
result = plan_cli("complete", "--verdict", "BLOCKED")
|
|
1703
|
+
assert result.returncode == 0, result.stderr
|
|
1704
|
+
assert read_state(state_path)["phases"]["plan"]["verdict"] == "BLOCKED"
|
|
1705
|
+
write_state(state_path, {
|
|
1706
|
+
"phases": {
|
|
1707
|
+
"plan": {
|
|
1708
|
+
"started_at": "2026-01-01T00:00:00.000Z",
|
|
1709
|
+
"completed_at": None,
|
|
1710
|
+
"duration_ms": None,
|
|
1711
|
+
"round": 0,
|
|
1712
|
+
"triggered_by": None,
|
|
1713
|
+
"verdict": None,
|
|
1714
|
+
},
|
|
1715
|
+
"implement": None,
|
|
1716
|
+
}
|
|
1717
|
+
})
|
|
1718
|
+
result = plan_cli(
|
|
1719
|
+
"complete", "--verdict", "PASS", "--engine-session-log",
|
|
1720
|
+
str(devlyn / "missing-session.jsonl"),
|
|
1721
|
+
)
|
|
1722
|
+
assert result.returncode == 1, result.stderr
|
|
1723
|
+
assert "BLOCKED:model-attestation-failed" in result.stderr
|
|
1724
|
+
assert guard_error not in result.stderr
|
|
1725
|
+
attestation_blocked = read_state(state_path)["phases"]["plan"]
|
|
1726
|
+
assert attestation_blocked["verdict"] == "BLOCKED"
|
|
1727
|
+
assert attestation_blocked["completed_at"] is not None
|
|
1728
|
+
assert attestation_blocked["model_effective"] is None
|
|
1729
|
+
legacy_state = {"phases": {"plan": old_receipt | {"round": 0}}}
|
|
1730
|
+
write_state(state_path, legacy_state)
|
|
1731
|
+
result = plan_cli(
|
|
1732
|
+
"spawn", "--round", "1", "--triggered-by", "plan",
|
|
1733
|
+
"--engine", "claude", "--model", "plan-test-model",
|
|
1734
|
+
"--prompt-sha256", digest1,
|
|
1735
|
+
)
|
|
1736
|
+
assert result.returncode == 0, result.stderr
|
|
1737
|
+
archived_legacy = read_state(state_path)["phases"]["plan"]["history"][0]
|
|
1738
|
+
assert archived_legacy == old_receipt
|
|
1739
|
+
print("PASS iter-0089 PLAN ledger: P-0089-1/2/6")
|
|
1740
|
+
|
|
1741
|
+
write_state(state_path, {"phases": {}})
|
|
1742
|
+
|
|
186
1743
|
# Round 0: spawn -> complete.
|
|
187
1744
|
state = read_state(state_path)
|
|
188
1745
|
do_spawn(state, "implement", 0, None, None, "claude", None)
|
|
@@ -242,6 +1799,163 @@ def self_test() -> int:
|
|
|
242
1799
|
else:
|
|
243
1800
|
raise AssertionError("complete() without a prior spawn() must raise")
|
|
244
1801
|
|
|
1802
|
+
# F11 rs-20260721T065728Z receipt: VERIFY merge wrote BLOCKED, but the
|
|
1803
|
+
# caller skipped complete() before re-entry. Respawn must fail without
|
|
1804
|
+
# archiving the open span as history.
|
|
1805
|
+
f11_open = {
|
|
1806
|
+
"phases": {
|
|
1807
|
+
"verify": {
|
|
1808
|
+
"started_at": "2026-07-21T07:08:11.684Z",
|
|
1809
|
+
"completed_at": None,
|
|
1810
|
+
"duration_ms": None,
|
|
1811
|
+
"verdict": "BLOCKED",
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
}
|
|
1815
|
+
before_f11 = json.dumps(f11_open, sort_keys=True)
|
|
1816
|
+
try:
|
|
1817
|
+
do_spawn(f11_open, "verify", 1, "verify", None, "codex", None)
|
|
1818
|
+
except SystemExit as e:
|
|
1819
|
+
assert "open span" in str(e) and "complete it before respawn" in str(e)
|
|
1820
|
+
else:
|
|
1821
|
+
raise AssertionError("respawn over an open F11 span must fail")
|
|
1822
|
+
assert json.dumps(f11_open, sort_keys=True) == before_f11
|
|
1823
|
+
assert "history" not in f11_open["phases"]["verify"]
|
|
1824
|
+
|
|
1825
|
+
# Transition is one state transaction: a forced failure between its
|
|
1826
|
+
# validated halves leaves the authoritative state byte-identical.
|
|
1827
|
+
transition_state = {
|
|
1828
|
+
"phases": {
|
|
1829
|
+
"plan": {
|
|
1830
|
+
"started_at": "2026-01-01T00:00:00.000Z",
|
|
1831
|
+
"completed_at": None,
|
|
1832
|
+
"duration_ms": None,
|
|
1833
|
+
"round": 0,
|
|
1834
|
+
"triggered_by": None,
|
|
1835
|
+
"verdict": None,
|
|
1836
|
+
},
|
|
1837
|
+
"implement": None,
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
write_state(state_path, transition_state)
|
|
1841
|
+
transition_before = state_path.read_bytes()
|
|
1842
|
+
|
|
1843
|
+
def fail_between_halves() -> None:
|
|
1844
|
+
raise RuntimeError("forced transition failure")
|
|
1845
|
+
|
|
1846
|
+
try:
|
|
1847
|
+
do_transition(
|
|
1848
|
+
transition_state, "plan", "implement", "PASS", None,
|
|
1849
|
+
None, None, None, None, None, devlyn, 0, None, None,
|
|
1850
|
+
"claude", None, between=fail_between_halves,
|
|
1851
|
+
)
|
|
1852
|
+
except RuntimeError as exc:
|
|
1853
|
+
assert str(exc) == "forced transition failure"
|
|
1854
|
+
else:
|
|
1855
|
+
raise AssertionError("forced transition failure did not fire")
|
|
1856
|
+
assert state_path.read_bytes() == transition_before
|
|
1857
|
+
assert transition_state["phases"]["plan"]["completed_at"] is None
|
|
1858
|
+
print("PASS self-test transition atomicity: forced midpoint failure left state unchanged")
|
|
1859
|
+
|
|
1860
|
+
attestation_state = copy.deepcopy(transition_state)
|
|
1861
|
+
attestation_state["phases"]["plan"]["model_requested"] = "wanted-model"
|
|
1862
|
+
attestation_log = devlyn / "transition-attestation.log"
|
|
1863
|
+
attestation_log.write_text("model: other-model\n", encoding="utf-8")
|
|
1864
|
+
write_state(state_path, attestation_state)
|
|
1865
|
+
attestation_before = state_path.read_bytes()
|
|
1866
|
+
try:
|
|
1867
|
+
do_transition(
|
|
1868
|
+
attestation_state, "plan", "implement", "PASS", None,
|
|
1869
|
+
None, None, None, None, str(attestation_log), devlyn,
|
|
1870
|
+
0, None, None, "claude", None,
|
|
1871
|
+
)
|
|
1872
|
+
except SystemExit as exc:
|
|
1873
|
+
assert "BLOCKED:model-attestation-mismatch" in str(exc)
|
|
1874
|
+
else:
|
|
1875
|
+
raise AssertionError("transition accepted mismatched model attestation")
|
|
1876
|
+
assert state_path.read_bytes() == attestation_before
|
|
1877
|
+
print("PASS self-test transition attestation: mismatch left state unchanged")
|
|
1878
|
+
|
|
1879
|
+
try:
|
|
1880
|
+
do_transition(
|
|
1881
|
+
transition_state, "plan", "cleanup", "PASS", None,
|
|
1882
|
+
None, None, None, None, None, devlyn, 0, None, None,
|
|
1883
|
+
"claude", None,
|
|
1884
|
+
)
|
|
1885
|
+
except SystemExit as exc:
|
|
1886
|
+
assert "illegal phase transition: plan -> cleanup" in str(exc)
|
|
1887
|
+
else:
|
|
1888
|
+
raise AssertionError("transition accepted an illegal phase edge")
|
|
1889
|
+
assert state_path.read_bytes() == attestation_before
|
|
1890
|
+
print("PASS self-test transition legal-edge guard: illegal edge left state unchanged")
|
|
1891
|
+
|
|
1892
|
+
transitioned = do_transition(
|
|
1893
|
+
transition_state, "plan", "implement", "PASS", None,
|
|
1894
|
+
None, None, None, None, None, devlyn, 0, None, None,
|
|
1895
|
+
"claude", None,
|
|
1896
|
+
)
|
|
1897
|
+
write_state(state_path, transitioned)
|
|
1898
|
+
assert transitioned["phases"]["plan"]["verdict"] == "PASS"
|
|
1899
|
+
assert transitioned["phases"]["plan"]["completed_at"] is not None
|
|
1900
|
+
assert transitioned["phases"]["implement"]["started_at"] is not None
|
|
1901
|
+
assert transitioned["phases"]["implement"]["verdict"] is None
|
|
1902
|
+
print("PASS self-test transition happy path: complete + spawn committed together")
|
|
1903
|
+
|
|
1904
|
+
cli_state = {
|
|
1905
|
+
"phases": {
|
|
1906
|
+
"plan": {
|
|
1907
|
+
"started_at": "2026-01-01T00:00:00.000Z",
|
|
1908
|
+
"completed_at": None,
|
|
1909
|
+
"duration_ms": None,
|
|
1910
|
+
"round": 0,
|
|
1911
|
+
"triggered_by": None,
|
|
1912
|
+
"verdict": None,
|
|
1913
|
+
},
|
|
1914
|
+
"implement": None,
|
|
1915
|
+
}
|
|
1916
|
+
}
|
|
1917
|
+
write_state(state_path, cli_state)
|
|
1918
|
+
cli_transition = subprocess.run(
|
|
1919
|
+
[
|
|
1920
|
+
sys.executable, str(pathlib.Path(__file__).resolve()),
|
|
1921
|
+
"--devlyn-dir", str(devlyn), "--phase", "plan", "transition",
|
|
1922
|
+
"--verdict", "PASS", "--next-phase", "implement",
|
|
1923
|
+
"--next-round", "0", "--next-engine", "claude",
|
|
1924
|
+
],
|
|
1925
|
+
capture_output=True, text=True,
|
|
1926
|
+
)
|
|
1927
|
+
assert cli_transition.returncode == 0, cli_transition.stderr
|
|
1928
|
+
cli_receipt = loads_strict_json(cli_transition.stdout)
|
|
1929
|
+
assert cli_receipt["completed_phase"] == "plan"
|
|
1930
|
+
assert cli_receipt["completed_verdict"] == "PASS"
|
|
1931
|
+
assert cli_receipt["next_phase"] == "implement"
|
|
1932
|
+
assert cli_receipt["state_sha256"] == hashlib.sha256(state_path.read_bytes()).hexdigest()
|
|
1933
|
+
print("PASS self-test transition CLI: machine-only JSON receipt")
|
|
1934
|
+
|
|
1935
|
+
open_next = copy.deepcopy(transition_state)
|
|
1936
|
+
open_next["phases"]["implement"] = {
|
|
1937
|
+
"started_at": "2026-01-01T00:00:01.000Z",
|
|
1938
|
+
"completed_at": None,
|
|
1939
|
+
"duration_ms": None,
|
|
1940
|
+
"round": 0,
|
|
1941
|
+
"triggered_by": None,
|
|
1942
|
+
"verdict": None,
|
|
1943
|
+
}
|
|
1944
|
+
write_state(state_path, open_next)
|
|
1945
|
+
open_next_before = state_path.read_bytes()
|
|
1946
|
+
try:
|
|
1947
|
+
do_transition(
|
|
1948
|
+
open_next, "plan", "implement", "PASS", None,
|
|
1949
|
+
None, None, None, None, None, devlyn, 1, None, None,
|
|
1950
|
+
"claude", None,
|
|
1951
|
+
)
|
|
1952
|
+
except SystemExit as exc:
|
|
1953
|
+
assert "open span" in str(exc) and "complete it before respawn" in str(exc)
|
|
1954
|
+
else:
|
|
1955
|
+
raise AssertionError("transition opened a phase that already had an open span")
|
|
1956
|
+
assert state_path.read_bytes() == open_next_before
|
|
1957
|
+
print("PASS self-test transition open-span guard: rejected without mutation")
|
|
1958
|
+
|
|
245
1959
|
# A completed FAIL round must be retained before a fix-loop respawn
|
|
246
1960
|
# resets the live record.
|
|
247
1961
|
write_state(state_path, {"phases": {}})
|
|
@@ -267,8 +1981,10 @@ def self_test() -> int:
|
|
|
267
1981
|
write_state(state_path, {"phases": {}})
|
|
268
1982
|
state = read_state(state_path)
|
|
269
1983
|
do_spawn(state, "verify", 0, None, None, "claude", None)
|
|
1984
|
+
assert state["phases"]["verify"]["judge_durations_ms"] is None
|
|
270
1985
|
state["phases"]["verify"]["verdict"] = "PASS"
|
|
271
1986
|
state["phases"]["verify"]["sub_verdicts"] = {"mechanical": "PASS", "judge": "PASS"}
|
|
1987
|
+
state["phases"]["verify"]["judge_durations_ms"] = {"judge": 23, "pair_judge": None}
|
|
272
1988
|
write_state(state_path, state)
|
|
273
1989
|
state = read_state(state_path)
|
|
274
1990
|
do_complete(state, "verify", None, None, None, None, None, None)
|
|
@@ -276,6 +1992,7 @@ def self_test() -> int:
|
|
|
276
1992
|
verify_entry = read_state(state_path)["phases"]["verify"]
|
|
277
1993
|
assert verify_entry["verdict"] == "PASS", "complete() must preserve pre-set verdict when omitted"
|
|
278
1994
|
assert verify_entry["sub_verdicts"] == {"mechanical": "PASS", "judge": "PASS"}
|
|
1995
|
+
assert verify_entry["judge_durations_ms"] == {"judge": 23, "pair_judge": None}
|
|
279
1996
|
assert verify_entry["completed_at"] is not None
|
|
280
1997
|
|
|
281
1998
|
# An explicit --verdict for VERIFY must be rejected — its verdict is
|
|
@@ -287,13 +2004,18 @@ def self_test() -> int:
|
|
|
287
2004
|
assert "owned by verify-merge-findings.py" in str(e)
|
|
288
2005
|
else:
|
|
289
2006
|
raise AssertionError("complete() must reject an explicit --verdict for VERIFY")
|
|
2007
|
+
do_spawn(state, "verify", 1, "verify", None, None, None)
|
|
2008
|
+
assert state["phases"]["verify"]["judge_durations_ms"] is None
|
|
290
2009
|
|
|
291
2010
|
# Non-VERIFY phases require --verdict explicitly; complete() must not
|
|
292
2011
|
# silently accept an unset verdict the way VERIFY's omit-to-preserve
|
|
293
2012
|
# flow does.
|
|
294
2013
|
write_state(state_path, {"phases": {}})
|
|
295
2014
|
state = read_state(state_path)
|
|
296
|
-
do_spawn(
|
|
2015
|
+
do_spawn(
|
|
2016
|
+
state, "plan", 0, None, None, "claude", "plan-test-model",
|
|
2017
|
+
prompt_sha256=digest0,
|
|
2018
|
+
)
|
|
297
2019
|
write_state(state_path, state)
|
|
298
2020
|
state = read_state(state_path)
|
|
299
2021
|
try:
|
|
@@ -409,6 +2131,1050 @@ def self_test() -> int:
|
|
|
409
2131
|
assert cleanup_entry["pre_sha"] == "pre-sha"
|
|
410
2132
|
assert cleanup_entry["post_sha"] == "post-sha"
|
|
411
2133
|
|
|
2134
|
+
# SURFACE_CLOSE keeps its one-shot envelope, adjudication grammar,
|
|
2135
|
+
# scope boundary, rollback, and execution prohibition mechanical.
|
|
2136
|
+
work = devlyn / "surface-repo"
|
|
2137
|
+
work.mkdir()
|
|
2138
|
+
subprocess.run(["git", "init", "-q"], cwd=work, check=True)
|
|
2139
|
+
subprocess.run(["git", "config", "user.email", "self-test@example.invalid"], cwd=work, check=True)
|
|
2140
|
+
subprocess.run(["git", "config", "user.name", "self-test"], cwd=work, check=True)
|
|
2141
|
+
(work / "allowed.txt").write_text("base\n", encoding="utf-8")
|
|
2142
|
+
(work / "blocked.txt").write_text("base\n", encoding="utf-8")
|
|
2143
|
+
(work / "blocked:literal.txt:1").write_text("base\n", encoding="utf-8")
|
|
2144
|
+
(work / "blocked:raw.txt").write_text("base\n", encoding="utf-8")
|
|
2145
|
+
(work / "branchbase").write_text("line\n" * 10, encoding="utf-8")
|
|
2146
|
+
(work / "branchbase:7").write_text("base\n", encoding="utf-8")
|
|
2147
|
+
(work / "branchbase:dir").mkdir()
|
|
2148
|
+
(work / "branchbase:dir" / "marker").write_text("base\n", encoding="utf-8")
|
|
2149
|
+
(work / "exact:1").write_text("base\n", encoding="utf-8")
|
|
2150
|
+
(work / "schedule").mkdir()
|
|
2151
|
+
(work / "schedule" / "__init__.py").write_text("line\n" * 700, encoding="utf-8")
|
|
2152
|
+
(work / "test_schedule.py").write_text("line\n", encoding="utf-8")
|
|
2153
|
+
(work / "tests").mkdir()
|
|
2154
|
+
(work / "tests" / "cli.test.js").write_text("line\n" * 170, encoding="utf-8")
|
|
2155
|
+
(work / "tests" / "literal:1-3").write_text("line\n", encoding="utf-8")
|
|
2156
|
+
subprocess.run(
|
|
2157
|
+
["git", "add", "--", "allowed.txt", "blocked.txt", "blocked:literal.txt:1",
|
|
2158
|
+
"blocked:raw.txt", "branchbase", "branchbase:7", "branchbase:dir/marker",
|
|
2159
|
+
"exact:1", "schedule/__init__.py", "test_schedule.py", "tests/cli.test.js",
|
|
2160
|
+
"tests/literal:1-3"],
|
|
2161
|
+
cwd=work, check=True,
|
|
2162
|
+
)
|
|
2163
|
+
subprocess.run(["git", "commit", "-qm", "base"], cwd=work, check=True)
|
|
2164
|
+
pre_sha = subprocess.run(
|
|
2165
|
+
["git", "rev-parse", "HEAD"], cwd=work, check=True, capture_output=True, text=True,
|
|
2166
|
+
).stdout.strip()
|
|
2167
|
+
work_devlyn = work / ".devlyn"
|
|
2168
|
+
work_devlyn.mkdir()
|
|
2169
|
+
(work / "kept.txt").write_text("keep\n", encoding="utf-8")
|
|
2170
|
+
goal = work_devlyn / "goal.raw.txt"
|
|
2171
|
+
patch = work_devlyn / "surface-close.input.patch"
|
|
2172
|
+
prompt = work_devlyn / "surface-close.prompt"
|
|
2173
|
+
goal.write_text("goal\n", encoding="utf-8")
|
|
2174
|
+
patch.write_text("patch\n", encoding="utf-8")
|
|
2175
|
+
prompt.write_text("adapter\nbody\ninputs\n", encoding="utf-8")
|
|
2176
|
+
surface_state = {
|
|
2177
|
+
"source": {
|
|
2178
|
+
"goal_path": ".devlyn/goal.raw.txt",
|
|
2179
|
+
"goal_sha256": file_sha256(goal),
|
|
2180
|
+
},
|
|
2181
|
+
"phases": {},
|
|
2182
|
+
}
|
|
2183
|
+
for rejected_engine in (None, "codex"):
|
|
2184
|
+
rejected_state = {"sentinel": True}
|
|
2185
|
+
try:
|
|
2186
|
+
do_spawn(
|
|
2187
|
+
rejected_state, "surface_close", 0, None, pre_sha, rejected_engine, "sonnet",
|
|
2188
|
+
input_patch_sha256=file_sha256(patch), prompt_sha256=file_sha256(prompt),
|
|
2189
|
+
untracked_before=["kept.txt"],
|
|
2190
|
+
)
|
|
2191
|
+
except SystemExit as exc:
|
|
2192
|
+
assert "requires --engine claude" in str(exc)
|
|
2193
|
+
else:
|
|
2194
|
+
raise AssertionError("SURFACE_CLOSE accepted a non-Claude engine")
|
|
2195
|
+
assert rejected_state == {"sentinel": True}
|
|
2196
|
+
rejected_state = {"sentinel": True}
|
|
2197
|
+
try:
|
|
2198
|
+
do_spawn(
|
|
2199
|
+
rejected_state, "surface_close", 0, None, pre_sha, "claude", None,
|
|
2200
|
+
input_patch_sha256=file_sha256(patch), prompt_sha256=file_sha256(prompt),
|
|
2201
|
+
untracked_before=["kept.txt"],
|
|
2202
|
+
)
|
|
2203
|
+
except SystemExit as exc:
|
|
2204
|
+
assert "phases.surface_close spawn requires --model" in str(exc)
|
|
2205
|
+
else:
|
|
2206
|
+
raise AssertionError("SURFACE_CLOSE accepted a missing requested model")
|
|
2207
|
+
assert rejected_state == {"sentinel": True}
|
|
2208
|
+
do_spawn(
|
|
2209
|
+
surface_state, "surface_close", 0, None, pre_sha, "claude", "sonnet",
|
|
2210
|
+
input_patch_sha256=file_sha256(patch), prompt_sha256=file_sha256(prompt),
|
|
2211
|
+
untracked_before=["kept.txt"],
|
|
2212
|
+
)
|
|
2213
|
+
validate_surface_inputs(work, work_devlyn, surface_state)
|
|
2214
|
+
validate_surface_prompt(work_devlyn, surface_state)
|
|
2215
|
+
ensure_surface_clean_baseline(work, work_devlyn, surface_state)
|
|
2216
|
+
surface = validate_authorized_surface(
|
|
2217
|
+
'["allowed.txt", "branchbase", "exact-link", "exact:1", "schedule/**", '
|
|
2218
|
+
'"test_schedule.py", "tests/**", "x.ts"]'
|
|
2219
|
+
)
|
|
2220
|
+
outside = devlyn / "outside.txt"
|
|
2221
|
+
outside.write_text("outside\n", encoding="utf-8")
|
|
2222
|
+
(work / "outside-link").symlink_to(outside)
|
|
2223
|
+
assert worktree_file_exists(work, "allowed.txt")
|
|
2224
|
+
assert not worktree_file_exists(work, str(outside))
|
|
2225
|
+
assert not worktree_file_exists(work, "../outside.txt")
|
|
2226
|
+
assert not worktree_file_exists(work, "outside-link")
|
|
2227
|
+
assert worktree_path_exists(work, "branchbase:dir")
|
|
2228
|
+
assert not worktree_path_exists(work, str(outside))
|
|
2229
|
+
assert not worktree_path_exists(work, "../outside.txt")
|
|
2230
|
+
assert not worktree_path_exists(work, "outside-link")
|
|
2231
|
+
(work / "outside-link").unlink()
|
|
2232
|
+
for link, target in (
|
|
2233
|
+
("exact-link", "allowed.txt"),
|
|
2234
|
+
("tests/inside-link", "cli.test.js"),
|
|
2235
|
+
):
|
|
2236
|
+
(work / link).symlink_to(target)
|
|
2237
|
+
output = work_devlyn / "surface-close.stdout"
|
|
2238
|
+
output.write_text(
|
|
2239
|
+
f"UVR-STALE: N/A {link} — contained symlink\n"
|
|
2240
|
+
"PATH-TEST: FIRED allowed.txt:1\nPASS\n",
|
|
2241
|
+
encoding="utf-8",
|
|
2242
|
+
)
|
|
2243
|
+
validate_surface_adjudication(
|
|
2244
|
+
work, work_devlyn, surface_state, surface,
|
|
2245
|
+
)
|
|
2246
|
+
(work / link).unlink()
|
|
2247
|
+
for link in ("exact-link", "tests/outside-link"):
|
|
2248
|
+
(work / link).symlink_to(outside)
|
|
2249
|
+
output = work_devlyn / "surface-close.stdout"
|
|
2250
|
+
output.write_text(
|
|
2251
|
+
f"UVR-STALE: N/A {link} — escaping symlink\n"
|
|
2252
|
+
"PATH-TEST: FIRED allowed.txt:1\nPASS\n",
|
|
2253
|
+
encoding="utf-8",
|
|
2254
|
+
)
|
|
2255
|
+
try:
|
|
2256
|
+
validate_surface_adjudication(
|
|
2257
|
+
work, work_devlyn, surface_state, surface,
|
|
2258
|
+
)
|
|
2259
|
+
except SystemExit as exc:
|
|
2260
|
+
assert "citation-missing" in str(exc), (link, exc)
|
|
2261
|
+
else:
|
|
2262
|
+
raise AssertionError(f"SURFACE_CLOSE followed escaping symlink: {link}")
|
|
2263
|
+
(work / link).unlink()
|
|
2264
|
+
entry = surface_entry(surface_state)
|
|
2265
|
+
assert entry["prompt_sha256"] == file_sha256(prompt)
|
|
2266
|
+
assert entry["model_requested"] == "sonnet"
|
|
2267
|
+
try:
|
|
2268
|
+
do_spawn(surface_state, "surface_close", 1, None, pre_sha, "claude", "sonnet")
|
|
2269
|
+
except SystemExit as exc:
|
|
2270
|
+
assert "one-shot" in str(exc)
|
|
2271
|
+
else:
|
|
2272
|
+
raise AssertionError("SURFACE_CLOSE re-entry must fail")
|
|
2273
|
+
|
|
2274
|
+
output = work_devlyn / "surface-close.stdout"
|
|
2275
|
+
output.write_text(
|
|
2276
|
+
"UVR-STALE: FIRED allowed.txt:1\n"
|
|
2277
|
+
"PATH-TEST: FIRED allowed.txt:1\nPASS\n",
|
|
2278
|
+
encoding="utf-8",
|
|
2279
|
+
)
|
|
2280
|
+
assert validate_surface_adjudication(work, work_devlyn, surface_state, surface) == {
|
|
2281
|
+
"UVR-STALE": "FIRED", "PATH-TEST": "FIRED",
|
|
2282
|
+
}
|
|
2283
|
+
output.write_text(
|
|
2284
|
+
"UVR-STALE: FIRED allowed.txt:1 — updated visible text\n"
|
|
2285
|
+
"PATH-TEST: N/A allowed.txt:1 — goal names no uncovered path\nPASS\n",
|
|
2286
|
+
encoding="utf-8",
|
|
2287
|
+
)
|
|
2288
|
+
validate_surface_adjudication(work, work_devlyn, surface_state, surface)
|
|
2289
|
+
output.write_text(
|
|
2290
|
+
"UVR-STALE: N/A exact:1 — exact colon path is unchanged\n"
|
|
2291
|
+
"PATH-TEST: N/A exact:1:1 — exact colon path line is covered\nPASS\n",
|
|
2292
|
+
encoding="utf-8",
|
|
2293
|
+
)
|
|
2294
|
+
validate_surface_adjudication(work, work_devlyn, surface_state, surface)
|
|
2295
|
+
output.write_text(
|
|
2296
|
+
"UVR-STALE: N/A tests/literal:1-3 — literal range-shaped path is unchanged\n"
|
|
2297
|
+
"PATH-TEST: FIRED allowed.txt:1\nPASS\n",
|
|
2298
|
+
encoding="utf-8",
|
|
2299
|
+
)
|
|
2300
|
+
validate_surface_adjudication(work, work_devlyn, surface_state, surface)
|
|
2301
|
+
|
|
2302
|
+
output.write_text(
|
|
2303
|
+
"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"
|
|
2304
|
+
"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"
|
|
2305
|
+
"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"
|
|
2306
|
+
"PASS\n",
|
|
2307
|
+
encoding="utf-8",
|
|
2308
|
+
)
|
|
2309
|
+
validate_surface_adjudication(work, work_devlyn, surface_state, surface)
|
|
2310
|
+
output.write_text(
|
|
2311
|
+
"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"
|
|
2312
|
+
"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"
|
|
2313
|
+
"PASS\n",
|
|
2314
|
+
encoding="utf-8",
|
|
2315
|
+
)
|
|
2316
|
+
validate_surface_adjudication(work, work_devlyn, surface_state, surface)
|
|
2317
|
+
|
|
2318
|
+
rejected_outputs = (
|
|
2319
|
+
("PASS\n", "missing UVR-STALE, PATH-TEST"),
|
|
2320
|
+
(
|
|
2321
|
+
"UVR-STALE: N/A allowed.txt:1\n"
|
|
2322
|
+
"PATH-TEST: N/A allowed.txt:1 — evidence\nPASS\n",
|
|
2323
|
+
"requires evidence",
|
|
2324
|
+
),
|
|
2325
|
+
("UVR-STALE: FIRED allowed.txt:1\nPASS\n", "missing PATH-TEST"),
|
|
2326
|
+
(
|
|
2327
|
+
"UVR-STALE: FIRED allowed.txt:1\n"
|
|
2328
|
+
"UVR-STALE: FIRED allowed.txt:1\n"
|
|
2329
|
+
"PATH-TEST: FIRED allowed.txt:1\nPASS\n",
|
|
2330
|
+
"duplicate UVR-STALE",
|
|
2331
|
+
),
|
|
2332
|
+
(
|
|
2333
|
+
"PASS\nUVR-STALE: FIRED allowed.txt:1\n"
|
|
2334
|
+
"PATH-TEST: FIRED allowed.txt:1\n",
|
|
2335
|
+
"exactly one PASS must follow both rows",
|
|
2336
|
+
),
|
|
2337
|
+
(
|
|
2338
|
+
"UVR-STALE: N/A allowed.txt — \n"
|
|
2339
|
+
"PATH-TEST: FIRED allowed.txt:1\nPASS\n",
|
|
2340
|
+
"requires evidence",
|
|
2341
|
+
),
|
|
2342
|
+
(
|
|
2343
|
+
"UVR-STALE: FIRED blocked.txt:1\n"
|
|
2344
|
+
"PATH-TEST: FIRED allowed.txt:1\nPASS\n",
|
|
2345
|
+
"out-of-surface",
|
|
2346
|
+
),
|
|
2347
|
+
(
|
|
2348
|
+
"UVR-STALE: FIRED allowed.txt:2\n"
|
|
2349
|
+
"PATH-TEST: FIRED allowed.txt:1\nPASS\n",
|
|
2350
|
+
"citation-missing",
|
|
2351
|
+
),
|
|
2352
|
+
(
|
|
2353
|
+
"UVR-STALE: N/A allowed.txt:49-73,223-235,293-295 — production range citation\n"
|
|
2354
|
+
"PATH-TEST: FIRED allowed.txt:1\nPASS\n",
|
|
2355
|
+
"adjudication-malformed",
|
|
2356
|
+
),
|
|
2357
|
+
(
|
|
2358
|
+
"UVR-STALE: N/A allowed.txt:1, allowed.txt:1 — production list citation\n"
|
|
2359
|
+
"PATH-TEST: FIRED allowed.txt:1\nPASS\n",
|
|
2360
|
+
"adjudication-malformed",
|
|
2361
|
+
),
|
|
2362
|
+
(
|
|
2363
|
+
"UVR-STALE: N/A tests/cli.test.js:1-3 — glob range citation\n"
|
|
2364
|
+
"PATH-TEST: FIRED allowed.txt:1\nPASS\n",
|
|
2365
|
+
"adjudication-malformed",
|
|
2366
|
+
),
|
|
2367
|
+
(
|
|
2368
|
+
"UVR-STALE: N/A x.ts:12:34 — doubled line citation\n"
|
|
2369
|
+
"PATH-TEST: FIRED allowed.txt:1\nPASS\n",
|
|
2370
|
+
"adjudication-malformed",
|
|
2371
|
+
),
|
|
2372
|
+
(
|
|
2373
|
+
"UVR-STALE: N/A allowed.txt:0 — zero line citation\n"
|
|
2374
|
+
"PATH-TEST: FIRED allowed.txt:1\nPASS\n",
|
|
2375
|
+
"adjudication-malformed",
|
|
2376
|
+
),
|
|
2377
|
+
(
|
|
2378
|
+
"UVR-STALE: N/A allowed.txt:012 — leading-zero line citation\n"
|
|
2379
|
+
"PATH-TEST: FIRED allowed.txt:1\nPASS\n",
|
|
2380
|
+
"adjudication-malformed",
|
|
2381
|
+
),
|
|
2382
|
+
(
|
|
2383
|
+
"UVR-STALE: N/A allowed.txt: — empty line citation\n"
|
|
2384
|
+
"PATH-TEST: FIRED allowed.txt:1\nPASS\n",
|
|
2385
|
+
"adjudication-malformed",
|
|
2386
|
+
),
|
|
2387
|
+
(
|
|
2388
|
+
"UVR-STALE: N/A blocked.txt:1 — blocked parsed path\n"
|
|
2389
|
+
"PATH-TEST: FIRED allowed.txt:1\nPASS\n",
|
|
2390
|
+
"out-of-surface",
|
|
2391
|
+
),
|
|
2392
|
+
(
|
|
2393
|
+
"UVR-STALE: N/A blocked.txt:1-3 — blocked range citation\n"
|
|
2394
|
+
"PATH-TEST: FIRED allowed.txt:1\nPASS\n",
|
|
2395
|
+
"out-of-surface",
|
|
2396
|
+
),
|
|
2397
|
+
(
|
|
2398
|
+
"UVR-STALE: N/A blocked:literal.txt:1 — blocked raw colon path\n"
|
|
2399
|
+
"PATH-TEST: FIRED allowed.txt:1\nPASS\n",
|
|
2400
|
+
"out-of-surface",
|
|
2401
|
+
),
|
|
2402
|
+
(
|
|
2403
|
+
"UVR-STALE: N/A blocked:raw.txt:1 — blocked parsed colon path\n"
|
|
2404
|
+
"PATH-TEST: FIRED allowed.txt:1\nPASS\n",
|
|
2405
|
+
"out-of-surface",
|
|
2406
|
+
),
|
|
2407
|
+
(
|
|
2408
|
+
"UVR-STALE: N/A branchbase:7 — existing unauthorized raw path\n"
|
|
2409
|
+
"PATH-TEST: FIRED allowed.txt:1\nPASS\n",
|
|
2410
|
+
"out-of-surface",
|
|
2411
|
+
),
|
|
2412
|
+
(
|
|
2413
|
+
"UVR-STALE: N/A branchbase:dir:1 — existing unauthorized parsed directory\n"
|
|
2414
|
+
"PATH-TEST: FIRED allowed.txt:1\nPASS\n",
|
|
2415
|
+
"out-of-surface",
|
|
2416
|
+
),
|
|
2417
|
+
(
|
|
2418
|
+
"UVR-STALE: N/A tests/../blocked.txt:1 — wildcard traversal\n"
|
|
2419
|
+
"PATH-TEST: FIRED allowed.txt:1\nPASS\n",
|
|
2420
|
+
"out-of-surface",
|
|
2421
|
+
),
|
|
2422
|
+
(
|
|
2423
|
+
"UVR-STALE: N/A tests/ghost.txt:1 — nonexistent glob descendant\n"
|
|
2424
|
+
"PATH-TEST: FIRED allowed.txt:1\nPASS\n",
|
|
2425
|
+
"citation-missing",
|
|
2426
|
+
),
|
|
2427
|
+
)
|
|
2428
|
+
for raw_output, marker in rejected_outputs:
|
|
2429
|
+
output.write_text(raw_output, encoding="utf-8")
|
|
2430
|
+
try:
|
|
2431
|
+
validate_surface_adjudication(work, work_devlyn, surface_state, surface)
|
|
2432
|
+
except SystemExit as exc:
|
|
2433
|
+
assert marker in str(exc), (marker, exc)
|
|
2434
|
+
else:
|
|
2435
|
+
raise AssertionError(f"SURFACE_CLOSE accepted invalid adjudication: {marker}")
|
|
2436
|
+
|
|
2437
|
+
output.write_text(
|
|
2438
|
+
"UVR-STALE: N/A allowed.txt:49-73,223-235,293-295 — production range citation\n"
|
|
2439
|
+
"PATH-TEST: FIRED allowed.txt:1\nPASS\n",
|
|
2440
|
+
encoding="utf-8",
|
|
2441
|
+
)
|
|
2442
|
+
require_surface_adjudication_malformed(
|
|
2443
|
+
work, work_devlyn, surface_state, surface,
|
|
2444
|
+
)
|
|
2445
|
+
output.write_text(
|
|
2446
|
+
"UVR-STALE: N/A allowed.txt:1 — no stale interface text\n"
|
|
2447
|
+
"PATH-TEST: N/A allowed.txt:1 — requested path already covered\nPASS\n",
|
|
2448
|
+
encoding="utf-8",
|
|
2449
|
+
)
|
|
2450
|
+
validate_surface_adjudication(work, work_devlyn, surface_state, surface)
|
|
2451
|
+
|
|
2452
|
+
transcript = work_devlyn / "surface-close.worker-session.0.jsonl"
|
|
2453
|
+
transcript.write_text(json.dumps({
|
|
2454
|
+
"message": {"content": [{
|
|
2455
|
+
"type": "tool_use", "name": "Bash",
|
|
2456
|
+
"input": {"command": "git diff -- allowed.txt"},
|
|
2457
|
+
}]},
|
|
2458
|
+
}) + "\n", encoding="utf-8")
|
|
2459
|
+
validate_surface_execution(work_devlyn, surface_state)
|
|
2460
|
+
for validation_command in ("npm test", "node bin/cli.js version"):
|
|
2461
|
+
transcript.write_text(json.dumps({
|
|
2462
|
+
"message": {"content": [{
|
|
2463
|
+
"type": "tool_use", "name": "Bash",
|
|
2464
|
+
"input": {"command": validation_command},
|
|
2465
|
+
}]},
|
|
2466
|
+
}) + "\n", encoding="utf-8")
|
|
2467
|
+
try:
|
|
2468
|
+
validate_surface_execution(work_devlyn, surface_state)
|
|
2469
|
+
except SystemExit as exc:
|
|
2470
|
+
assert "validation-execution" in str(exc)
|
|
2471
|
+
else:
|
|
2472
|
+
raise AssertionError(
|
|
2473
|
+
f"SURFACE_CLOSE execution audit accepted {validation_command}"
|
|
2474
|
+
)
|
|
2475
|
+
|
|
2476
|
+
transcript.write_text(
|
|
2477
|
+
json.dumps({"message": {"content": [
|
|
2478
|
+
{"type": "tool_use", "name": "Edit", "input": {"file_path": str(work / "allowed.txt")}},
|
|
2479
|
+
{"type": "tool_use", "name": "Write", "input": {"file_path": "tests/new.txt"}},
|
|
2480
|
+
]}}) + "\n",
|
|
2481
|
+
encoding="utf-8",
|
|
2482
|
+
)
|
|
2483
|
+
assert validate_surface_write_audit(
|
|
2484
|
+
work, work_devlyn, surface_state, surface,
|
|
2485
|
+
) == ["allowed.txt", "tests/new.txt"]
|
|
2486
|
+
for invalid_target in (str(work / "blocked.txt"), str(work_devlyn / "hidden.txt"), "../escape.txt"):
|
|
2487
|
+
transcript.write_text(
|
|
2488
|
+
json.dumps({"message": {"content": [{
|
|
2489
|
+
"type": "tool_use", "name": "Edit",
|
|
2490
|
+
"input": {"file_path": invalid_target},
|
|
2491
|
+
}]}}) + "\n",
|
|
2492
|
+
encoding="utf-8",
|
|
2493
|
+
)
|
|
2494
|
+
try:
|
|
2495
|
+
validate_surface_write_audit(work, work_devlyn, surface_state, surface)
|
|
2496
|
+
except SystemExit as exc:
|
|
2497
|
+
assert "write-audit-violation" in str(exc)
|
|
2498
|
+
else:
|
|
2499
|
+
raise AssertionError(f"SURFACE_CLOSE write audit accepted {invalid_target}")
|
|
2500
|
+
|
|
2501
|
+
wrapper_log = work_devlyn / "surface-close.output.json"
|
|
2502
|
+
wrapper_log.write_text(json.dumps({
|
|
2503
|
+
"result": output.read_text(encoding="utf-8"),
|
|
2504
|
+
"modelUsage": {"sonnet": {"inputTokens": 1}},
|
|
2505
|
+
}) + "\n", encoding="utf-8")
|
|
2506
|
+
completion_state = loads_strict_json(json.dumps(surface_state))
|
|
2507
|
+
assert do_complete(
|
|
2508
|
+
completion_state, "surface_close", "PASS", pre_sha, None,
|
|
2509
|
+
".devlyn/surface-close.stdout", None, None, str(wrapper_log),
|
|
2510
|
+
devlyn=work_devlyn,
|
|
2511
|
+
) is None
|
|
2512
|
+
completed_surface = completion_state["phases"]["surface_close"]
|
|
2513
|
+
assert completed_surface["model_requested"] == "sonnet"
|
|
2514
|
+
assert completed_surface["model_effective"] == "sonnet"
|
|
2515
|
+
assert completed_surface["verdict"] == "PASS"
|
|
2516
|
+
|
|
2517
|
+
recovery_state = loads_strict_json(json.dumps(surface_state))
|
|
2518
|
+
recovery_started_at = recovery_state["phases"]["surface_close"]["started_at"]
|
|
2519
|
+
output.write_text("UVR-STALE: FIRED allowed.txt:1\nPASS\n", encoding="utf-8")
|
|
2520
|
+
(work / "allowed.txt").write_text("surface edit\n", encoding="utf-8")
|
|
2521
|
+
transcript.write_text(
|
|
2522
|
+
json.dumps({"message": {"content": [{
|
|
2523
|
+
"type": "tool_use", "name": "Edit",
|
|
2524
|
+
"input": {"file_path": str(work / "allowed.txt")},
|
|
2525
|
+
}]}}) + "\n",
|
|
2526
|
+
encoding="utf-8",
|
|
2527
|
+
)
|
|
2528
|
+
require_surface_adjudication_malformed(
|
|
2529
|
+
work, work_devlyn, recovery_state, surface,
|
|
2530
|
+
)
|
|
2531
|
+
assert rollback_surface_delta(work, work_devlyn, recovery_state) == ["allowed.txt"]
|
|
2532
|
+
assert validate_surface_write_audit(
|
|
2533
|
+
work, work_devlyn, recovery_state, surface,
|
|
2534
|
+
) == ["allowed.txt"]
|
|
2535
|
+
assert do_surface_adjudication_recovery(recovery_state, work_devlyn) is None
|
|
2536
|
+
recovered = recovery_state["phases"]["surface_close"]
|
|
2537
|
+
assert recovered["started_at"] == recovery_started_at
|
|
2538
|
+
assert isinstance(recovered["duration_ms"], int)
|
|
2539
|
+
assert recovered["verdict"] is None
|
|
2540
|
+
assert recovered["skipped_reason"] == SURFACE_RECOVERY_REASON
|
|
2541
|
+
assert recovered["continued_after_block"] is True
|
|
2542
|
+
|
|
2543
|
+
(work / "allowed.txt").write_text("pre-existing\n", encoding="utf-8")
|
|
2544
|
+
try:
|
|
2545
|
+
ensure_surface_clean_baseline(work, work_devlyn, surface_state)
|
|
2546
|
+
except SystemExit as exc:
|
|
2547
|
+
assert "surface-close-preexisting-delta" in str(exc)
|
|
2548
|
+
else:
|
|
2549
|
+
raise AssertionError("SURFACE_CLOSE accepted a pre-existing tracked delta")
|
|
2550
|
+
subprocess.run(["git", "restore", "--", "allowed.txt"], cwd=work, check=True)
|
|
2551
|
+
(work / "allowed.txt").write_text("changed\n", encoding="utf-8")
|
|
2552
|
+
(work / "blocked.txt").write_text("changed\n", encoding="utf-8")
|
|
2553
|
+
(work / "tests" / "new.txt").write_text("new\n", encoding="utf-8")
|
|
2554
|
+
(work / "escape.txt").write_text("new\n", encoding="utf-8")
|
|
2555
|
+
assert surface_offenders(work, work_devlyn, surface_state, surface) == ["blocked.txt", "escape.txt"]
|
|
2556
|
+
restored = rollback_surface_delta(work, work_devlyn, surface_state)
|
|
2557
|
+
assert restored == ["allowed.txt", "blocked.txt", "escape.txt", "tests/new.txt"]
|
|
2558
|
+
assert (work / "allowed.txt").read_text(encoding="utf-8") == "base\n"
|
|
2559
|
+
assert (work / "blocked.txt").read_text(encoding="utf-8") == "base\n"
|
|
2560
|
+
assert (work / "kept.txt").read_text(encoding="utf-8") == "keep\n"
|
|
2561
|
+
assert not (work / "tests" / "new.txt").exists()
|
|
2562
|
+
assert not (work / "escape.txt").exists()
|
|
2563
|
+
|
|
2564
|
+
skipped_state = {"phases": {}}
|
|
2565
|
+
do_surface_skip(skipped_state)
|
|
2566
|
+
skipped = skipped_state["phases"]["surface_close"]
|
|
2567
|
+
assert skipped["verdict"] is None
|
|
2568
|
+
assert skipped["skipped_reason"] == SURFACE_SKIP_REASON
|
|
2569
|
+
|
|
2570
|
+
# Effective model evidence: engine header line and rollout JSONL.
|
|
2571
|
+
header_log = devlyn / "codex-build.log"
|
|
2572
|
+
header_log.write_text("session\nmodel: gpt-5.6-sol\n", encoding="utf-8")
|
|
2573
|
+
assert parse_effective_model(header_log) == "gpt-5.6-sol"
|
|
2574
|
+
rollout_log = devlyn / "rollout.jsonl"
|
|
2575
|
+
rollout_log.write_text(json.dumps({
|
|
2576
|
+
"type": "turn_context", "payload": {"model": "gpt-5.6-terra"},
|
|
2577
|
+
}) + "\n", encoding="utf-8")
|
|
2578
|
+
assert parse_effective_model(rollout_log) == "gpt-5.6-terra"
|
|
2579
|
+
claude_log = devlyn / "claude-result.json"
|
|
2580
|
+
|
|
2581
|
+
def claude_wrapper(primary_usage, entries):
|
|
2582
|
+
wrapper = {"modelUsage": {}}
|
|
2583
|
+
if primary_usage is not None:
|
|
2584
|
+
wrapper["usage"] = dict(zip((
|
|
2585
|
+
"input_tokens", "output_tokens", "cache_read_input_tokens",
|
|
2586
|
+
"cache_creation_input_tokens",
|
|
2587
|
+
), primary_usage))
|
|
2588
|
+
for model, entry_usage, metadata in entries:
|
|
2589
|
+
entry = dict(zip((
|
|
2590
|
+
"inputTokens", "outputTokens", "cacheReadInputTokens",
|
|
2591
|
+
"cacheCreationInputTokens",
|
|
2592
|
+
), entry_usage))
|
|
2593
|
+
entry.update(metadata)
|
|
2594
|
+
wrapper["modelUsage"][model] = entry
|
|
2595
|
+
return wrapper
|
|
2596
|
+
|
|
2597
|
+
oversized_counter = 10 ** 400
|
|
2598
|
+
claude_selector_rows = (
|
|
2599
|
+
(
|
|
2600
|
+
"singleton",
|
|
2601
|
+
{"modelUsage": {"claude-alpha-1": {"inputTokens": 1}}},
|
|
2602
|
+
"claude-alpha-1",
|
|
2603
|
+
),
|
|
2604
|
+
(
|
|
2605
|
+
"opus-primary",
|
|
2606
|
+
claude_wrapper(
|
|
2607
|
+
(2854, 9927, 1095180, 73889),
|
|
2608
|
+
(
|
|
2609
|
+
("claude-" "haiku-4-5-20251001", (4106, 14, 0, 0), {
|
|
2610
|
+
"costUSD": 0.004176,
|
|
2611
|
+
}),
|
|
2612
|
+
("claude-" "opus-5[1m]", (2854, 9927, 1095180, 73889), {
|
|
2613
|
+
"costUSD": 1.5489249999999999,
|
|
2614
|
+
"canonicalModel": "claude-" "opus-5",
|
|
2615
|
+
}),
|
|
2616
|
+
),
|
|
2617
|
+
),
|
|
2618
|
+
"claude-" "opus-5[1m]",
|
|
2619
|
+
),
|
|
2620
|
+
(
|
|
2621
|
+
"fable-primary",
|
|
2622
|
+
claude_wrapper(
|
|
2623
|
+
(8068, 26740, 403950, 76656),
|
|
2624
|
+
(
|
|
2625
|
+
("claude-" "haiku-4-5-20251001", (2246, 22, 0, 0), {
|
|
2626
|
+
"costUSD": 0.002356,
|
|
2627
|
+
}),
|
|
2628
|
+
("claude-" "fable-5", (8068, 26740, 403950, 76656), {
|
|
2629
|
+
"costUSD": 3.35475,
|
|
2630
|
+
"canonicalModel": "claude-" "fable-5",
|
|
2631
|
+
}),
|
|
2632
|
+
),
|
|
2633
|
+
),
|
|
2634
|
+
"claude-" "fable-5",
|
|
2635
|
+
),
|
|
2636
|
+
(
|
|
2637
|
+
"rank-confound",
|
|
2638
|
+
claude_wrapper(
|
|
2639
|
+
(3, 5, 7, 11),
|
|
2640
|
+
(
|
|
2641
|
+
("larger-auxiliary", (300000, 500000, 700000, 1100000), {
|
|
2642
|
+
"costUSD": 999.0,
|
|
2643
|
+
}),
|
|
2644
|
+
("expected-primary", (3, 5, 7, 11), {"costUSD": 0.01}),
|
|
2645
|
+
),
|
|
2646
|
+
),
|
|
2647
|
+
"expected-primary",
|
|
2648
|
+
),
|
|
2649
|
+
(
|
|
2650
|
+
"oversized-integer",
|
|
2651
|
+
claude_wrapper(
|
|
2652
|
+
(oversized_counter, 5, 7, 11),
|
|
2653
|
+
(
|
|
2654
|
+
("oversized-primary", (oversized_counter, 5, 7, 11), {}),
|
|
2655
|
+
("auxiliary", (3, 5, 7, 11), {}),
|
|
2656
|
+
),
|
|
2657
|
+
),
|
|
2658
|
+
"oversized-primary",
|
|
2659
|
+
),
|
|
2660
|
+
(
|
|
2661
|
+
"zero-match",
|
|
2662
|
+
claude_wrapper(
|
|
2663
|
+
(1, 2, 3, 4),
|
|
2664
|
+
(("model-a", (10, 2, 3, 4), {}), ("model-b", (1, 20, 3, 4), {})),
|
|
2665
|
+
),
|
|
2666
|
+
ValueError,
|
|
2667
|
+
),
|
|
2668
|
+
(
|
|
2669
|
+
"duplicate-match",
|
|
2670
|
+
claude_wrapper(
|
|
2671
|
+
(1, 2, 3, 4),
|
|
2672
|
+
(("model-a", (1, 2, 3, 4), {}), ("model-b", (1, 2, 3, 4), {})),
|
|
2673
|
+
),
|
|
2674
|
+
ValueError,
|
|
2675
|
+
),
|
|
2676
|
+
(
|
|
2677
|
+
"missing-top-level-usage",
|
|
2678
|
+
claude_wrapper(
|
|
2679
|
+
None,
|
|
2680
|
+
(("model-a", (1, 2, 3, 4), {}), ("model-b", (5, 6, 7, 8), {})),
|
|
2681
|
+
),
|
|
2682
|
+
ValueError,
|
|
2683
|
+
),
|
|
2684
|
+
(
|
|
2685
|
+
"malformed-top-level-float",
|
|
2686
|
+
claude_wrapper(
|
|
2687
|
+
(1.0, 2, 3, 4),
|
|
2688
|
+
(("model-a", (1, 2, 3, 4), {}), ("model-b", (5, 6, 7, 8), {})),
|
|
2689
|
+
),
|
|
2690
|
+
ValueError,
|
|
2691
|
+
),
|
|
2692
|
+
(
|
|
2693
|
+
"malformed-top-level-negative",
|
|
2694
|
+
claude_wrapper(
|
|
2695
|
+
(-1, 2, 3, 4),
|
|
2696
|
+
(("model-a", (-1, 2, 3, 4), {}), ("model-b", (5, 6, 7, 8), {})),
|
|
2697
|
+
),
|
|
2698
|
+
ValueError,
|
|
2699
|
+
),
|
|
2700
|
+
(
|
|
2701
|
+
"malformed-entry-counter",
|
|
2702
|
+
claude_wrapper(
|
|
2703
|
+
(1, 2, 3, 4),
|
|
2704
|
+
(("model-a", (1, 2, 3, 4), {}), ("model-b", (True, 20, 30, 40), {})),
|
|
2705
|
+
),
|
|
2706
|
+
ValueError,
|
|
2707
|
+
),
|
|
2708
|
+
)
|
|
2709
|
+
for row_name, wrapper, expected in claude_selector_rows:
|
|
2710
|
+
claude_log.write_text(json.dumps(wrapper) + "\n", encoding="utf-8")
|
|
2711
|
+
if expected is ValueError:
|
|
2712
|
+
try:
|
|
2713
|
+
parse_effective_model(claude_log)
|
|
2714
|
+
except ValueError:
|
|
2715
|
+
pass
|
|
2716
|
+
else:
|
|
2717
|
+
raise AssertionError(f"Claude selector accepted {row_name}")
|
|
2718
|
+
else:
|
|
2719
|
+
assert parse_effective_model(claude_log) == expected, row_name
|
|
2720
|
+
|
|
2721
|
+
claude_log.write_text(
|
|
2722
|
+
json.dumps(claude_selector_rows[-1][1]) + "\n", encoding="utf-8"
|
|
2723
|
+
)
|
|
2724
|
+
write_state(state_path, {"phases": {}})
|
|
2725
|
+
state = read_state(state_path)
|
|
2726
|
+
do_spawn(
|
|
2727
|
+
state, "plan", 0, None, None, "claude", "claude-default",
|
|
2728
|
+
prompt_sha256=digest0,
|
|
2729
|
+
)
|
|
2730
|
+
malformed = do_complete(
|
|
2731
|
+
state, "plan", "PASS", None, None, None, None, None, str(claude_log)
|
|
2732
|
+
)
|
|
2733
|
+
assert malformed and "model-attestation-failed" in malformed
|
|
2734
|
+
assert state["phases"]["plan"]["model_effective"] is None
|
|
2735
|
+
assert state["phases"]["plan"]["verdict"] == "BLOCKED"
|
|
2736
|
+
|
|
2737
|
+
# Requested/effective drift is a persisted, fail-closed attestation.
|
|
2738
|
+
write_state(state_path, {"phases": {}})
|
|
2739
|
+
state = read_state(state_path)
|
|
2740
|
+
do_spawn(state, "implement", 0, None, None, "codex", "gpt-5.5")
|
|
2741
|
+
mismatch = do_complete(
|
|
2742
|
+
state, "implement", "PASS", None, None, None, None, None, str(rollout_log)
|
|
2743
|
+
)
|
|
2744
|
+
mismatched = state["phases"]["implement"]
|
|
2745
|
+
assert mismatch and "model-attestation-mismatch" in mismatch
|
|
2746
|
+
assert mismatched["model_requested"] == "gpt-5.5"
|
|
2747
|
+
assert mismatched["model_effective"] == "gpt-5.6-terra"
|
|
2748
|
+
assert mismatched["verdict"] == "BLOCKED"
|
|
2749
|
+
|
|
2750
|
+
claude_log.write_text(
|
|
2751
|
+
json.dumps(claude_selector_rows[1][1]) + "\n", encoding="utf-8"
|
|
2752
|
+
)
|
|
2753
|
+
write_state(state_path, {"phases": {}})
|
|
2754
|
+
state = read_state(state_path)
|
|
2755
|
+
do_spawn(state, "build_gate", 0, None, None, "claude", "claude-" "opus-5")
|
|
2756
|
+
mismatch = do_complete(
|
|
2757
|
+
state, "build_gate", "PASS", None, None, None, None, None, str(claude_log)
|
|
2758
|
+
)
|
|
2759
|
+
mismatched = state["phases"]["build_gate"]
|
|
2760
|
+
assert mismatch and "model-attestation-mismatch" in mismatch
|
|
2761
|
+
assert mismatched["model_requested"] == "claude-" "opus-5"
|
|
2762
|
+
assert mismatched["model_effective"] == "claude-" "opus-5[1m]"
|
|
2763
|
+
assert mismatched["verdict"] == "BLOCKED"
|
|
2764
|
+
|
|
2765
|
+
# A retained mutation-worker session makes the completion flag
|
|
2766
|
+
# mandatory; without a retained file, null remains legal.
|
|
2767
|
+
retained_log = devlyn / "implement.worker-session.0.jsonl"
|
|
2768
|
+
retained_log.write_text(json.dumps({
|
|
2769
|
+
"type": "turn_context", "payload": {"model": "gpt-5.6-terra"},
|
|
2770
|
+
}) + "\n", encoding="utf-8")
|
|
2771
|
+
write_state(state_path, {"phases": {}})
|
|
2772
|
+
state = read_state(state_path)
|
|
2773
|
+
do_spawn(state, "implement", 0, None, None, "codex", "gpt-5.6-terra")
|
|
2774
|
+
omitted = do_complete(
|
|
2775
|
+
state, "implement", "PASS", None, None, None, None, None,
|
|
2776
|
+
devlyn=devlyn,
|
|
2777
|
+
)
|
|
2778
|
+
assert omitted and "model-attestation-failed" in omitted
|
|
2779
|
+
assert str(retained_log) in omitted
|
|
2780
|
+
assert state["phases"]["implement"]["model_effective"] is None
|
|
2781
|
+
assert state["phases"]["implement"]["verdict"] == "BLOCKED"
|
|
2782
|
+
|
|
2783
|
+
write_state(state_path, {"phases": {}})
|
|
2784
|
+
state = read_state(state_path)
|
|
2785
|
+
do_spawn(state, "implement", 0, None, None, "codex", "gpt-5.6-terra")
|
|
2786
|
+
assert do_complete(
|
|
2787
|
+
state, "implement", "PASS", None, None, None, None, None,
|
|
2788
|
+
str(retained_log), devlyn=devlyn,
|
|
2789
|
+
) is None
|
|
2790
|
+
assert state["phases"]["implement"]["model_effective"] == "gpt-5.6-terra"
|
|
2791
|
+
assert state["phases"]["implement"]["verdict"] == "PASS"
|
|
2792
|
+
|
|
2793
|
+
retained_log.unlink()
|
|
2794
|
+
write_state(state_path, {"phases": {}})
|
|
2795
|
+
state = read_state(state_path)
|
|
2796
|
+
do_spawn(state, "cleanup", 0, None, None, "claude", "claude-default")
|
|
2797
|
+
assert do_complete(
|
|
2798
|
+
state, "cleanup", "PASS", None, None, None, None, None,
|
|
2799
|
+
devlyn=devlyn,
|
|
2800
|
+
) is None
|
|
2801
|
+
assert state["phases"]["cleanup"]["model_effective"] is None
|
|
2802
|
+
|
|
2803
|
+
# Supplied evidence must parse and never silently record null.
|
|
2804
|
+
write_state(state_path, {"phases": {}})
|
|
2805
|
+
state = read_state(state_path)
|
|
2806
|
+
do_spawn(
|
|
2807
|
+
state, "plan", 0, None, None, "claude", "claude-default",
|
|
2808
|
+
prompt_sha256=digest0,
|
|
2809
|
+
)
|
|
2810
|
+
assert do_complete(state, "plan", "PASS", None, None, None, None, None) is None
|
|
2811
|
+
assert state["phases"]["plan"]["model_effective"] is None
|
|
2812
|
+
invalid_log = devlyn / "invalid-session.log"
|
|
2813
|
+
invalid_log.write_text("no model evidence\n", encoding="utf-8")
|
|
2814
|
+
write_state(state_path, {"phases": {}})
|
|
2815
|
+
state = read_state(state_path)
|
|
2816
|
+
do_spawn(
|
|
2817
|
+
state, "plan", 0, None, None, "claude", "claude-default",
|
|
2818
|
+
prompt_sha256=digest0,
|
|
2819
|
+
)
|
|
2820
|
+
invalid = do_complete(
|
|
2821
|
+
state, "plan", "PASS", None, None, None, None, None, str(invalid_log)
|
|
2822
|
+
)
|
|
2823
|
+
assert invalid and "model-attestation-failed" in invalid
|
|
2824
|
+
assert state["phases"]["plan"]["model_effective"] is None
|
|
2825
|
+
assert state["phases"]["plan"]["verdict"] == "BLOCKED"
|
|
2826
|
+
|
|
2827
|
+
def write_fixture_tree(repo: pathlib.Path, files: dict[str, str]) -> None:
|
|
2828
|
+
existing = {
|
|
2829
|
+
path.relative_to(repo).as_posix() for path in repo.rglob("*")
|
|
2830
|
+
if path.is_file() and ".git" not in path.parts and ".devlyn" not in path.parts
|
|
2831
|
+
}
|
|
2832
|
+
for path in existing - set(files):
|
|
2833
|
+
(repo / path).unlink()
|
|
2834
|
+
for path, content in files.items():
|
|
2835
|
+
target = repo / path
|
|
2836
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
2837
|
+
target.write_text(content, encoding="utf-8")
|
|
2838
|
+
|
|
2839
|
+
def commit_fixture(repo: pathlib.Path, files: dict[str, str], message: str) -> str:
|
|
2840
|
+
write_fixture_tree(repo, files)
|
|
2841
|
+
paths = sorted(set(files) | {
|
|
2842
|
+
path.relative_to(repo).as_posix() for path in repo.rglob("*")
|
|
2843
|
+
if path.is_file() and ".git" not in path.parts and ".devlyn" not in path.parts
|
|
2844
|
+
})
|
|
2845
|
+
subprocess.run(["git", "add", "--all", "--", *paths], cwd=repo, check=True)
|
|
2846
|
+
subprocess.run(["git", "commit", "-qm", message], cwd=repo, check=True)
|
|
2847
|
+
return subprocess.run(
|
|
2848
|
+
["git", "rev-parse", "HEAD"], cwd=repo, check=True,
|
|
2849
|
+
capture_output=True, text=True,
|
|
2850
|
+
).stdout.strip()
|
|
2851
|
+
|
|
2852
|
+
def durability_fixture(
|
|
2853
|
+
name: str, base_files: dict[str, str], surface_files: dict[str, str],
|
|
2854
|
+
fix_files: dict[str, str], origin: str, findings: list[dict], round_: int = 1,
|
|
2855
|
+
with_surface_post: bool = True,
|
|
2856
|
+
) -> tuple[pathlib.Path, pathlib.Path, dict, str, str]:
|
|
2857
|
+
repo = devlyn / name
|
|
2858
|
+
repo.mkdir()
|
|
2859
|
+
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
|
|
2860
|
+
subprocess.run(["git", "config", "user.email", "self-test@example.invalid"], cwd=repo, check=True)
|
|
2861
|
+
subprocess.run(["git", "config", "user.name", "self-test"], cwd=repo, check=True)
|
|
2862
|
+
base_sha = commit_fixture(repo, base_files, "base")
|
|
2863
|
+
surface_sha = commit_fixture(repo, surface_files, "surface close")
|
|
2864
|
+
fix_sha = commit_fixture(repo, fix_files, f"chore(pipeline): implement fix round {round_}")
|
|
2865
|
+
fixture_devlyn = repo / ".devlyn"
|
|
2866
|
+
fixture_devlyn.mkdir()
|
|
2867
|
+
findings_name = (
|
|
2868
|
+
"build_gate.findings.jsonl" if origin == "build_gate"
|
|
2869
|
+
else "verify-merged.findings.jsonl"
|
|
2870
|
+
)
|
|
2871
|
+
(fixture_devlyn / findings_name).write_text(
|
|
2872
|
+
"".join(json.dumps(finding) + "\n" for finding in findings), encoding="utf-8",
|
|
2873
|
+
)
|
|
2874
|
+
surface_entry = {"pre_sha": base_sha, "durability": []}
|
|
2875
|
+
if with_surface_post:
|
|
2876
|
+
surface_entry["post_sha"] = surface_sha
|
|
2877
|
+
fixture_state = {"phases": {"surface_close": surface_entry}}
|
|
2878
|
+
return repo, fixture_devlyn, fixture_state, fix_sha, surface_sha
|
|
2879
|
+
|
|
2880
|
+
# M-CP 1/8/9: exact -19c topology. The VERIFY finding is line 60,
|
|
2881
|
+
# outside the pre-fix USAGE block; the unrelated real fix survives,
|
|
2882
|
+
# the USAGE revert is restored, and the frozen gate's check-7 function
|
|
2883
|
+
# observes the separate closure-restore commit's final tree.
|
|
2884
|
+
base_lines = [f"line {index}\n" for index in range(1, 66)]
|
|
2885
|
+
base_lines[6] = "const USAGE = `\n"
|
|
2886
|
+
base_lines[7] = "usage: cli <command>\n"
|
|
2887
|
+
base_lines[9] = " version Show version\n"
|
|
2888
|
+
base_lines[10] = "`;\n"
|
|
2889
|
+
base_lines[59] = " return args.indexOf('--format')\n"
|
|
2890
|
+
surface_lines = list(base_lines)
|
|
2891
|
+
surface_lines[9] = " version --format <fmt> Show version\n"
|
|
2892
|
+
fix_lines = list(base_lines)
|
|
2893
|
+
fix_lines[59] = " return args.lastIndexOf('--format')\n"
|
|
2894
|
+
exact_repo, exact_devlyn, exact_state, exact_fix, _ = durability_fixture(
|
|
2895
|
+
"durability-exact", {"bin/cli.js": "".join(base_lines)},
|
|
2896
|
+
{"bin/cli.js": "".join(surface_lines)}, {"bin/cli.js": "".join(fix_lines)},
|
|
2897
|
+
"verify", [{"id": "-19c", "file": "bin/cli.js", "line": 60}],
|
|
2898
|
+
)
|
|
2899
|
+
exact_receipt = enforce_closure_durability_reentry(
|
|
2900
|
+
exact_repo, exact_devlyn, exact_state, "verify", 1,
|
|
2901
|
+
)
|
|
2902
|
+
exact_text = (exact_repo / "bin/cli.js").read_text(encoding="utf-8")
|
|
2903
|
+
assert "version --format <fmt>" in exact_text
|
|
2904
|
+
assert "lastIndexOf('--format')" in exact_text
|
|
2905
|
+
assert exact_receipt and exact_receipt["restore_commit_sha"]
|
|
2906
|
+
assert exact_receipt["fix_commit_sha"] == exact_fix
|
|
2907
|
+
assert subprocess.run(
|
|
2908
|
+
["git", "show", "-s", "--format=%s", "HEAD"], cwd=exact_repo,
|
|
2909
|
+
check=True, capture_output=True, text=True,
|
|
2910
|
+
).stdout.strip() == "chore(pipeline): closure-restore round 1"
|
|
2911
|
+
import importlib.util
|
|
2912
|
+
gate_spec = importlib.util.spec_from_file_location(
|
|
2913
|
+
"f7_carrier_gate", pathlib.Path(__file__).resolve().parents[3]
|
|
2914
|
+
/ "benchmark/ceiling/scripts/f7-carrier-gate.py",
|
|
2915
|
+
)
|
|
2916
|
+
gate = importlib.util.module_from_spec(gate_spec)
|
|
2917
|
+
gate_spec.loader.exec_module(gate)
|
|
2918
|
+
assert gate.check7(exact_text)[0]
|
|
2919
|
+
print("PASS M-CP self-test 1/8/9: -19c restore + file-line targeting + frozen check 7")
|
|
2920
|
+
|
|
2921
|
+
# M-CP 2: BUILD_GATE uses the same durability route.
|
|
2922
|
+
build_repo, build_devlyn, build_state, _, _ = durability_fixture(
|
|
2923
|
+
"durability-build", {"a.txt": "old\n"}, {"a.txt": "surface\n"},
|
|
2924
|
+
{"a.txt": "old\n"}, "build_gate",
|
|
2925
|
+
[{"id": "BG", "file": "a.txt", "line": 8}],
|
|
2926
|
+
)
|
|
2927
|
+
build_state["phases"]["implement"] = {"round": 1, "triggered_by": "build_gate"}
|
|
2928
|
+
write_state(build_devlyn / "pipeline.state.json", build_state)
|
|
2929
|
+
subprocess.run(
|
|
2930
|
+
[sys.executable, str(pathlib.Path(__file__).resolve()), "--devlyn-dir", ".devlyn",
|
|
2931
|
+
"--phase", "implement", "durability-enforce", "--round", "1",
|
|
2932
|
+
"--origin-phase", "build_gate"],
|
|
2933
|
+
cwd=build_repo, check=True, capture_output=True,
|
|
2934
|
+
)
|
|
2935
|
+
subprocess.run(
|
|
2936
|
+
[sys.executable, str(pathlib.Path(__file__).resolve()), "--devlyn-dir", ".devlyn",
|
|
2937
|
+
"--phase", "build_gate", "spawn", "--round", "1"],
|
|
2938
|
+
cwd=build_repo, check=True, capture_output=True,
|
|
2939
|
+
)
|
|
2940
|
+
# The next phase's first spawn shares rounds.global=1 but is not a
|
|
2941
|
+
# VERIFY re-entry; it must not reinterpret the BUILD_GATE receipt.
|
|
2942
|
+
subprocess.run(
|
|
2943
|
+
[sys.executable, str(pathlib.Path(__file__).resolve()), "--devlyn-dir", ".devlyn",
|
|
2944
|
+
"--phase", "verify", "spawn", "--round", "1"],
|
|
2945
|
+
cwd=build_repo, check=True, capture_output=True,
|
|
2946
|
+
)
|
|
2947
|
+
build_receipt = loads_strict_json(
|
|
2948
|
+
(build_devlyn / "closure-durability.round-1.json").read_text(encoding="utf-8")
|
|
2949
|
+
)
|
|
2950
|
+
assert (build_repo / "a.txt").read_text(encoding="utf-8") == "surface\n"
|
|
2951
|
+
assert build_receipt and build_receipt["origin_phase"] == "build_gate"
|
|
2952
|
+
print("PASS M-CP self-test 2: build_gate and verify routes")
|
|
2953
|
+
|
|
2954
|
+
# M-CP 3: block-granular multi-file partial revert mixture.
|
|
2955
|
+
mix_base = {
|
|
2956
|
+
"mix.txt": "A\nkeep-1\nB\nkeep-2\nC\n", "other.txt": "old\n",
|
|
2957
|
+
"context.txt": "before\nold\nafter\n",
|
|
2958
|
+
}
|
|
2959
|
+
mix_surface = {
|
|
2960
|
+
"mix.txt": "A-sc\nkeep-1\nB-sc\nkeep-2\nC-sc\n", "other.txt": "new\n",
|
|
2961
|
+
"context.txt": "before\nnew\nafter\n",
|
|
2962
|
+
}
|
|
2963
|
+
mix_fix = {
|
|
2964
|
+
"mix.txt": "A-sc\nkeep-1\nB-evolved\nkeep-2\nC\n", "other.txt": "old\n",
|
|
2965
|
+
"context.txt": "changed-context\nold\nafter\n",
|
|
2966
|
+
}
|
|
2967
|
+
mix_repo, mix_devlyn, mix_state, _, _ = durability_fixture(
|
|
2968
|
+
"durability-mix", mix_base, mix_surface, mix_fix, "verify", [],
|
|
2969
|
+
)
|
|
2970
|
+
mix_receipt = enforce_closure_durability_reentry(
|
|
2971
|
+
mix_repo, mix_devlyn, mix_state, "verify", 1,
|
|
2972
|
+
)
|
|
2973
|
+
mix_classes = [block["classification"] for block in mix_receipt["blocks"]]
|
|
2974
|
+
assert {"SURVIVED", "EVOLVED", "REVERTED"} <= set(mix_classes), mix_receipt["blocks"]
|
|
2975
|
+
assert (mix_repo / "mix.txt").read_text(encoding="utf-8") == (
|
|
2976
|
+
"A-sc\nkeep-1\nB-evolved\nkeep-2\nC-sc\n"
|
|
2977
|
+
)
|
|
2978
|
+
assert (mix_repo / "other.txt").read_text(encoding="utf-8") == "new\n"
|
|
2979
|
+
assert (mix_repo / "context.txt").read_text(encoding="utf-8") == (
|
|
2980
|
+
"changed-context\nold\nafter\n"
|
|
2981
|
+
)
|
|
2982
|
+
print("PASS M-CP self-test 3: multi-file SURVIVED/EVOLVED/REVERTED partial restore")
|
|
2983
|
+
|
|
2984
|
+
# M-CP 4: a deleted additive SC block is REVERTED and restored.
|
|
2985
|
+
add_repo, add_devlyn, add_state, _, _ = durability_fixture(
|
|
2986
|
+
"durability-add", {"docs/x.txt": "head\ntail\n"},
|
|
2987
|
+
{"docs/x.txt": "head\na/old/docs/x.txt\ntail\n"},
|
|
2988
|
+
{"docs/x.txt": "head\ntail\n"}, "verify", [],
|
|
2989
|
+
)
|
|
2990
|
+
add_receipt = enforce_closure_durability_reentry(add_repo, add_devlyn, add_state, "verify", 1)
|
|
2991
|
+
assert (add_repo / "docs/x.txt").read_text(encoding="utf-8") == (
|
|
2992
|
+
"head\na/old/docs/x.txt\ntail\n"
|
|
2993
|
+
)
|
|
2994
|
+
assert any(block["classification"] == "REVERTED" for block in add_receipt["blocks"])
|
|
2995
|
+
print("PASS M-CP self-test 4: deleted additive block restored")
|
|
2996
|
+
|
|
2997
|
+
# M-CP 5: an exact line-targeted deletion is preserved, not restored.
|
|
2998
|
+
target_repo, target_devlyn, target_state, target_fix, _ = durability_fixture(
|
|
2999
|
+
"durability-target", {"target.txt": "head\ntail\n"},
|
|
3000
|
+
{"target.txt": "head\nconsolidate\ntail\n"}, {"target.txt": "head\ntail\n"},
|
|
3001
|
+
"verify", [{"id": "E1", "path": "target.txt", "line": 2}],
|
|
3002
|
+
)
|
|
3003
|
+
target_receipt = enforce_closure_durability_reentry(
|
|
3004
|
+
target_repo, target_devlyn, target_state, "verify", 1,
|
|
3005
|
+
)
|
|
3006
|
+
assert (target_repo / "target.txt").read_text(encoding="utf-8") == "head\ntail\n"
|
|
3007
|
+
assert target_receipt["restore_commit_sha"] is None
|
|
3008
|
+
assert target_receipt["post_restore_sha"] == target_fix
|
|
3009
|
+
assert target_receipt["blocks"][0]["finding_targeted"] is True
|
|
3010
|
+
print("PASS M-CP self-test 5: finding-targeted deletion not restored")
|
|
3011
|
+
|
|
3012
|
+
# M-CP 6: once ledgered, a missing or stale receipt blocks re-entry.
|
|
3013
|
+
target_path = target_devlyn / "closure-durability.round-1.json"
|
|
3014
|
+
target_raw = target_path.read_bytes()
|
|
3015
|
+
target_state["phases"]["implement"] = {"round": 1, "triggered_by": "verify"}
|
|
3016
|
+
write_state(target_devlyn / "pipeline.state.json", target_state)
|
|
3017
|
+
stale_verify_artifact = target_devlyn / "verify.findings.jsonl"
|
|
3018
|
+
stale_verify_artifact.write_text("stale\n", encoding="utf-8")
|
|
3019
|
+
target_path.unlink()
|
|
3020
|
+
for mode in ("missing", "stale"):
|
|
3021
|
+
if mode == "stale":
|
|
3022
|
+
target_path.write_bytes(target_raw.replace(b'"schema_version": 1', b'"schema_version": 2'))
|
|
3023
|
+
try:
|
|
3024
|
+
enforce_closure_durability_reentry(
|
|
3025
|
+
target_repo, target_devlyn, target_state, "verify", 1,
|
|
3026
|
+
)
|
|
3027
|
+
except SystemExit as exc:
|
|
3028
|
+
assert "closure-durability-receipt" in str(exc)
|
|
3029
|
+
else:
|
|
3030
|
+
raise AssertionError(f"{mode} durability receipt was accepted")
|
|
3031
|
+
if mode == "missing":
|
|
3032
|
+
reentry = subprocess.run(
|
|
3033
|
+
[sys.executable, str(pathlib.Path(__file__).resolve()),
|
|
3034
|
+
"--devlyn-dir", ".devlyn", "--phase", "verify", "spawn",
|
|
3035
|
+
"--round", "1"],
|
|
3036
|
+
cwd=target_repo, capture_output=True, text=True,
|
|
3037
|
+
)
|
|
3038
|
+
assert reentry.returncode != 0
|
|
3039
|
+
assert "closure-durability-receipt" in reentry.stderr
|
|
3040
|
+
assert stale_verify_artifact.exists(), "re-entry guard must run before VERIFY clearing"
|
|
3041
|
+
target_path.write_bytes(target_raw)
|
|
3042
|
+
|
|
3043
|
+
# Skipping the explicit post-fix checkpoint cannot be repaired by the
|
|
3044
|
+
# spawn guard: re-entry is validation-only and both artifacts missing
|
|
3045
|
+
# must block without changing the fix tree.
|
|
3046
|
+
skipped_repo, skipped_devlyn, skipped_state, skipped_fix, _ = durability_fixture(
|
|
3047
|
+
"durability-skipped-checkpoint", {"skip.txt": "base\n"},
|
|
3048
|
+
{"skip.txt": "surface\n"}, {"skip.txt": "base\n"}, "build_gate", [],
|
|
3049
|
+
)
|
|
3050
|
+
skipped_state["phases"]["implement"] = {"round": 1, "triggered_by": "build_gate"}
|
|
3051
|
+
write_state(skipped_devlyn / "pipeline.state.json", skipped_state)
|
|
3052
|
+
skipped = subprocess.run(
|
|
3053
|
+
[sys.executable, str(pathlib.Path(__file__).resolve()),
|
|
3054
|
+
"--devlyn-dir", ".devlyn", "--phase", "build_gate", "spawn", "--round", "1"],
|
|
3055
|
+
cwd=skipped_repo, capture_output=True, text=True,
|
|
3056
|
+
)
|
|
3057
|
+
assert skipped.returncode != 0 and "checkpoint receipt is missing" in skipped.stderr
|
|
3058
|
+
assert not (skipped_devlyn / "closure-durability.round-1.json").exists()
|
|
3059
|
+
assert subprocess.run(
|
|
3060
|
+
["git", "rev-parse", "HEAD"], cwd=skipped_repo, check=True,
|
|
3061
|
+
capture_output=True, text=True,
|
|
3062
|
+
).stdout.strip() == skipped_fix
|
|
3063
|
+
|
|
3064
|
+
receipt_repo, receipt_devlyn, receipt_state, receipt_fix, _ = durability_fixture(
|
|
3065
|
+
"durability-receipt-write-fail", {"receipt.txt": "base\n"},
|
|
3066
|
+
{"receipt.txt": "surface\n"}, {"receipt.txt": "base\n"}, "verify", [],
|
|
3067
|
+
)
|
|
3068
|
+
original_json_writer = globals()["_write_json_atomic"]
|
|
3069
|
+
def fail_receipt_write(_path, _value):
|
|
3070
|
+
raise OSError("injected receipt write failure")
|
|
3071
|
+
globals()["_write_json_atomic"] = fail_receipt_write
|
|
3072
|
+
try:
|
|
3073
|
+
try:
|
|
3074
|
+
enforce_closure_durability_reentry(
|
|
3075
|
+
receipt_repo, receipt_devlyn, receipt_state, "verify", 1,
|
|
3076
|
+
)
|
|
3077
|
+
except SystemExit as exc:
|
|
3078
|
+
assert "injected receipt write failure" in str(exc)
|
|
3079
|
+
else:
|
|
3080
|
+
raise AssertionError("receipt write failure did not fail closed")
|
|
3081
|
+
finally:
|
|
3082
|
+
globals()["_write_json_atomic"] = original_json_writer
|
|
3083
|
+
assert _git_output(receipt_repo, "rev-parse", "HEAD").decode().strip() == receipt_fix
|
|
3084
|
+
assert (receipt_repo / "receipt.txt").read_text(encoding="utf-8") == "base\n"
|
|
3085
|
+
assert not (receipt_devlyn / "closure-durability.round-1.json").exists()
|
|
3086
|
+
assert receipt_state["phases"]["surface_close"]["durability"] == []
|
|
3087
|
+
|
|
3088
|
+
state_repo, state_devlyn, state_state, state_fix, _ = durability_fixture(
|
|
3089
|
+
"durability-state-write-fail", {"state.txt": "base\n"},
|
|
3090
|
+
{"state.txt": "surface\n"}, {"state.txt": "base\n"}, "verify", [],
|
|
3091
|
+
)
|
|
3092
|
+
def fail_state_write(_path, _state):
|
|
3093
|
+
raise OSError("injected state write failure")
|
|
3094
|
+
try:
|
|
3095
|
+
_persist_durability_event(
|
|
3096
|
+
state_repo, state_devlyn, state_state,
|
|
3097
|
+
state_devlyn / "pipeline.state.json", "verify", 1,
|
|
3098
|
+
writer=fail_state_write,
|
|
3099
|
+
)
|
|
3100
|
+
except OSError as exc:
|
|
3101
|
+
assert "injected state write failure" in str(exc)
|
|
3102
|
+
else:
|
|
3103
|
+
raise AssertionError("state write failure did not fail closed")
|
|
3104
|
+
assert _git_output(state_repo, "rev-parse", "HEAD").decode().strip() == state_fix
|
|
3105
|
+
assert (state_repo / "state.txt").read_text(encoding="utf-8") == "base\n"
|
|
3106
|
+
assert not (state_devlyn / "closure-durability.round-1.json").exists()
|
|
3107
|
+
assert state_state["phases"]["surface_close"]["durability"] == []
|
|
3108
|
+
print("PASS M-CP self-test 6: missing/stale receipt fails closed")
|
|
3109
|
+
|
|
3110
|
+
# M-CP 7: failed patch preflight leaves index and worktree byte-clean.
|
|
3111
|
+
before_status = _tracked_status(target_repo)
|
|
3112
|
+
before_bytes = (target_repo / "target.txt").read_bytes()
|
|
3113
|
+
try:
|
|
3114
|
+
_apply_restore_patch(target_repo, b"not a patch\n", ["target.txt"])
|
|
3115
|
+
except SystemExit as exc:
|
|
3116
|
+
assert "closure-durability-apply" in str(exc)
|
|
3117
|
+
else:
|
|
3118
|
+
raise AssertionError("invalid restore patch was accepted")
|
|
3119
|
+
assert _tracked_status(target_repo) == before_status
|
|
3120
|
+
assert (target_repo / "target.txt").read_bytes() == before_bytes
|
|
3121
|
+
|
|
3122
|
+
binary_repo = devlyn / "durability-binary"
|
|
3123
|
+
binary_repo.mkdir()
|
|
3124
|
+
subprocess.run(["git", "init", "-q"], cwd=binary_repo, check=True)
|
|
3125
|
+
subprocess.run(["git", "config", "user.email", "self-test@example.invalid"], cwd=binary_repo, check=True)
|
|
3126
|
+
subprocess.run(["git", "config", "user.name", "self-test"], cwd=binary_repo, check=True)
|
|
3127
|
+
binary_path = binary_repo / "binary.dat"
|
|
3128
|
+
binary_path.write_bytes(b"\x00base\xff\n")
|
|
3129
|
+
subprocess.run(["git", "add", "--", "binary.dat"], cwd=binary_repo, check=True)
|
|
3130
|
+
subprocess.run(["git", "commit", "-qm", "base"], cwd=binary_repo, check=True)
|
|
3131
|
+
wanted_binary = b"\x00a/old/binary.dat\xffb/new/binary.dat\n"
|
|
3132
|
+
binary_patch = _restore_patch(binary_repo, {"binary.dat": wanted_binary})
|
|
3133
|
+
_apply_restore_patch(binary_repo, binary_patch, ["binary.dat"])
|
|
3134
|
+
assert binary_path.read_bytes() == wanted_binary
|
|
3135
|
+
assert _git_output(binary_repo, "show", ":binary.dat") == wanted_binary
|
|
3136
|
+
|
|
3137
|
+
literal_repo = devlyn / "durability-header-literal"
|
|
3138
|
+
literal_repo.mkdir()
|
|
3139
|
+
subprocess.run(["git", "init", "-q"], cwd=literal_repo, check=True)
|
|
3140
|
+
subprocess.run(["git", "config", "user.email", "self-test@example.invalid"], cwd=literal_repo, check=True)
|
|
3141
|
+
subprocess.run(["git", "config", "user.name", "self-test"], cwd=literal_repo, check=True)
|
|
3142
|
+
literal_path = literal_repo / "literal.txt"
|
|
3143
|
+
literal_path.write_bytes(b"-- a/old/literal.txt\n")
|
|
3144
|
+
subprocess.run(["git", "add", "--", "literal.txt"], cwd=literal_repo, check=True)
|
|
3145
|
+
subprocess.run(["git", "commit", "-qm", "base"], cwd=literal_repo, check=True)
|
|
3146
|
+
wanted_literal = b"++ b/new/literal.txt\n"
|
|
3147
|
+
literal_patch = _restore_patch(literal_repo, {"literal.txt": wanted_literal})
|
|
3148
|
+
_apply_restore_patch(literal_repo, literal_patch, ["literal.txt"])
|
|
3149
|
+
assert literal_path.read_bytes() == wanted_literal
|
|
3150
|
+
assert _git_output(literal_repo, "show", ":literal.txt") == wanted_literal
|
|
3151
|
+
print("PASS M-CP self-test 7: apply failure leaves zero partial mutation")
|
|
3152
|
+
|
|
3153
|
+
# File-only findings are deliberately non-targeting.
|
|
3154
|
+
file_repo, file_devlyn, file_state, _, _ = durability_fixture(
|
|
3155
|
+
"durability-file-only", {"usage.txt": "usage old\n"},
|
|
3156
|
+
{"usage.txt": "usage --format\n"}, {"usage.txt": "usage old\n"},
|
|
3157
|
+
"verify", [{"id": "file-only", "file": "usage.txt"}],
|
|
3158
|
+
)
|
|
3159
|
+
file_receipt = enforce_closure_durability_reentry(
|
|
3160
|
+
file_repo, file_devlyn, file_state, "verify", 1,
|
|
3161
|
+
)
|
|
3162
|
+
assert (file_repo / "usage.txt").read_text(encoding="utf-8") == "usage --format\n"
|
|
3163
|
+
assert file_receipt["blocks"][0]["finding_targeted"] is False
|
|
3164
|
+
print("PASS M-CP self-test 8: file-only finding cannot mask restore")
|
|
3165
|
+
|
|
3166
|
+
# Skipped/no-post-SHA is receipt-visible and commit-free.
|
|
3167
|
+
noop_repo, noop_devlyn, noop_state, noop_fix, _ = durability_fixture(
|
|
3168
|
+
"durability-noop", {"noop.txt": "base\n"}, {"noop.txt": "surface\n"},
|
|
3169
|
+
{"noop.txt": "fix\n"}, "build_gate", [], with_surface_post=False,
|
|
3170
|
+
)
|
|
3171
|
+
noop_receipt = enforce_closure_durability_reentry(
|
|
3172
|
+
noop_repo, noop_devlyn, noop_state, "build_gate", 1,
|
|
3173
|
+
)
|
|
3174
|
+
assert noop_receipt["blocks"] == [] and noop_receipt["restore_commit_sha"] is None
|
|
3175
|
+
assert noop_receipt["post_restore_sha"] == noop_fix
|
|
3176
|
+
print("PASS M-CP self-test no-op: no post_sha writes receipt without commit")
|
|
3177
|
+
|
|
412
3178
|
return 0
|
|
413
3179
|
|
|
414
3180
|
|
|
@@ -421,8 +3187,11 @@ def main() -> int:
|
|
|
421
3187
|
|
|
422
3188
|
spawn_p = sub.add_parser("spawn")
|
|
423
3189
|
spawn_p.add_argument("--round", type=int, required=True)
|
|
424
|
-
spawn_p.add_argument("--triggered-by", choices=sorted(
|
|
3190
|
+
spawn_p.add_argument("--triggered-by", choices=sorted(SPAWN_TRIGGERS), default=None)
|
|
425
3191
|
spawn_p.add_argument("--pre-sha", default=None)
|
|
3192
|
+
spawn_p.add_argument("--input-patch-sha256", default=None)
|
|
3193
|
+
spawn_p.add_argument("--prompt-sha256", default=None)
|
|
3194
|
+
spawn_p.add_argument("--untracked-before-json", default=None)
|
|
426
3195
|
spawn_p.add_argument("--engine", default=None)
|
|
427
3196
|
spawn_p.add_argument("--model", default=None)
|
|
428
3197
|
|
|
@@ -433,14 +3202,45 @@ def main() -> int:
|
|
|
433
3202
|
complete_p.add_argument("--log-file", default=None)
|
|
434
3203
|
complete_p.add_argument("--engine", default=None)
|
|
435
3204
|
complete_p.add_argument("--model", default=None)
|
|
3205
|
+
complete_p.add_argument("--engine-session-log", default=None)
|
|
3206
|
+
|
|
3207
|
+
transition_p = sub.add_parser("transition")
|
|
3208
|
+
transition_p.add_argument("--verdict", choices=sorted(VALID_VERDICTS), default=None)
|
|
3209
|
+
transition_p.add_argument("--post-sha", default=None)
|
|
3210
|
+
transition_p.add_argument("--findings-file", default=None)
|
|
3211
|
+
transition_p.add_argument("--log-file", default=None)
|
|
3212
|
+
transition_p.add_argument("--engine", default=None)
|
|
3213
|
+
transition_p.add_argument("--model", default=None)
|
|
3214
|
+
transition_p.add_argument("--engine-session-log", default=None)
|
|
3215
|
+
transition_p.add_argument("--next-phase", choices=sorted(PHASE_NAMES), required=True)
|
|
3216
|
+
transition_p.add_argument("--next-round", type=int, required=True)
|
|
3217
|
+
transition_p.add_argument("--next-triggered-by", choices=sorted(SPAWN_TRIGGERS), default=None)
|
|
3218
|
+
transition_p.add_argument("--next-pre-sha", default=None)
|
|
3219
|
+
transition_p.add_argument("--next-input-patch-sha256", default=None)
|
|
3220
|
+
transition_p.add_argument("--next-prompt-sha256", default=None)
|
|
3221
|
+
transition_p.add_argument("--next-untracked-before-json", default=None)
|
|
3222
|
+
transition_p.add_argument("--next-engine", default=None)
|
|
3223
|
+
transition_p.add_argument("--next-model", default=None)
|
|
3224
|
+
|
|
3225
|
+
check_p = sub.add_parser("surface-check")
|
|
3226
|
+
check_p.add_argument("--authorized-surface-json", required=True)
|
|
3227
|
+
recover_p = sub.add_parser("surface-adjudication-recover")
|
|
3228
|
+
recover_p.add_argument("--authorized-surface-json", required=True)
|
|
3229
|
+
sub.add_parser("surface-rollback")
|
|
3230
|
+
sub.add_parser("surface-skip")
|
|
3231
|
+
durability_p = sub.add_parser("durability-enforce")
|
|
3232
|
+
durability_p.add_argument("--round", type=int, required=True)
|
|
3233
|
+
durability_p.add_argument("--origin-phase", choices=sorted(VALID_TRIGGERS), required=True)
|
|
436
3234
|
|
|
437
3235
|
args = ap.parse_args()
|
|
438
3236
|
if args.self_test:
|
|
439
3237
|
return self_test()
|
|
440
3238
|
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
3239
|
+
surface_events = {
|
|
3240
|
+
"surface-check", "surface-adjudication-recover", "surface-rollback", "surface-skip",
|
|
3241
|
+
}
|
|
3242
|
+
if not args.phase or args.event not in {"spawn", "complete", "transition", "durability-enforce", *surface_events}:
|
|
3243
|
+
ap.error("--phase and a phase event are required unless --self-test")
|
|
444
3244
|
devlyn = pathlib.Path(args.devlyn_dir)
|
|
445
3245
|
if not devlyn.is_dir():
|
|
446
3246
|
sys.stderr.write(f"error: {devlyn} is not a directory\n")
|
|
@@ -448,14 +3248,153 @@ def main() -> int:
|
|
|
448
3248
|
state_path = devlyn / "pipeline.state.json"
|
|
449
3249
|
state = read_state(state_path)
|
|
450
3250
|
|
|
451
|
-
if args.event == "
|
|
452
|
-
if args.phase
|
|
3251
|
+
if args.event == "durability-enforce":
|
|
3252
|
+
if args.phase != "implement":
|
|
3253
|
+
ap.error("durability-enforce is valid only for --phase implement")
|
|
3254
|
+
_persist_durability_event(
|
|
3255
|
+
pathlib.Path.cwd(), devlyn, state, state_path, args.origin_phase, args.round,
|
|
3256
|
+
)
|
|
3257
|
+
sys.stdout.write(f"ok: phases.surface_close.durability.round-{args.round}\n")
|
|
3258
|
+
return 0
|
|
3259
|
+
|
|
3260
|
+
if args.event in surface_events:
|
|
3261
|
+
if args.phase != "surface_close":
|
|
3262
|
+
ap.error(f"{args.event} is valid only for --phase surface_close")
|
|
3263
|
+
if args.event == "surface-skip":
|
|
3264
|
+
do_surface_skip(state)
|
|
3265
|
+
write_state(state_path, state)
|
|
3266
|
+
sys.stdout.write("ok: phases.surface_close.surface-skip\n")
|
|
3267
|
+
return 0
|
|
3268
|
+
work = pathlib.Path.cwd()
|
|
3269
|
+
if args.event == "surface-adjudication-recover":
|
|
3270
|
+
validate_surface_inputs(work, devlyn, state)
|
|
3271
|
+
validate_surface_prompt(devlyn, state)
|
|
3272
|
+
surface = validate_authorized_surface(args.authorized_surface_json)
|
|
3273
|
+
offenders = surface_offenders(work, devlyn, state, surface)
|
|
3274
|
+
if offenders:
|
|
3275
|
+
raise SystemExit(
|
|
3276
|
+
"BLOCKED:surface-close-out-of-surface: " + json.dumps(offenders)
|
|
3277
|
+
)
|
|
3278
|
+
validate_surface_execution(devlyn, state)
|
|
3279
|
+
require_surface_adjudication_malformed(work, devlyn, state, surface)
|
|
3280
|
+
rollback_surface_delta(work, devlyn, state)
|
|
3281
|
+
tracked, untracked = surface_delta_paths(work, devlyn, state)
|
|
3282
|
+
if tracked or untracked:
|
|
3283
|
+
raise SystemExit(
|
|
3284
|
+
"BLOCKED:surface-close-rollback-failed: "
|
|
3285
|
+
+ json.dumps(sorted(set(tracked + untracked)))
|
|
3286
|
+
)
|
|
3287
|
+
validate_surface_write_audit(work, devlyn, state, surface)
|
|
3288
|
+
attestation_error = do_surface_adjudication_recovery(state, devlyn)
|
|
3289
|
+
write_state(state_path, state)
|
|
3290
|
+
if attestation_error is not None:
|
|
3291
|
+
sys.stderr.write(attestation_error + "\n")
|
|
3292
|
+
return 1
|
|
3293
|
+
sys.stdout.write("ok: phases.surface_close.surface-adjudication-recover\n")
|
|
3294
|
+
return 0
|
|
3295
|
+
if args.event == "surface-check":
|
|
3296
|
+
validate_surface_inputs(work, devlyn, state)
|
|
3297
|
+
surface = validate_authorized_surface(args.authorized_surface_json)
|
|
3298
|
+
offenders = surface_offenders(work, devlyn, state, surface)
|
|
3299
|
+
if offenders:
|
|
3300
|
+
sys.stderr.write("BLOCKED:surface-close-out-of-scope: " + json.dumps(offenders) + "\n")
|
|
3301
|
+
return 2
|
|
3302
|
+
validate_surface_adjudication(work, devlyn, state, surface)
|
|
3303
|
+
validate_surface_execution(devlyn, state)
|
|
3304
|
+
sys.stdout.write("ok: phases.surface_close.surface-check\n")
|
|
3305
|
+
return 0
|
|
3306
|
+
restored = rollback_surface_delta(work, devlyn, state)
|
|
3307
|
+
sys.stdout.write("ok: phases.surface_close.surface-rollback " + json.dumps(restored) + "\n")
|
|
3308
|
+
return 0
|
|
3309
|
+
|
|
3310
|
+
if args.event in {"spawn", "transition"}:
|
|
3311
|
+
spawn_phase = args.phase if args.event == "spawn" else args.next_phase
|
|
3312
|
+
spawn_round = args.round if args.event == "spawn" else args.next_round
|
|
3313
|
+
implement = (state.get("phases") or {}).get("implement")
|
|
3314
|
+
fix_reentry = (
|
|
3315
|
+
spawn_phase in VALID_TRIGGERS and spawn_round >= 1
|
|
3316
|
+
and isinstance(implement, dict)
|
|
3317
|
+
and implement.get("round") == spawn_round
|
|
3318
|
+
and implement.get("triggered_by") == spawn_phase
|
|
3319
|
+
)
|
|
3320
|
+
if fix_reentry:
|
|
3321
|
+
enforce_closure_durability_reentry(
|
|
3322
|
+
pathlib.Path.cwd(), devlyn, state, spawn_phase, spawn_round,
|
|
3323
|
+
require_existing=True,
|
|
3324
|
+
)
|
|
3325
|
+
if args.event == "spawn":
|
|
3326
|
+
untracked_before = (
|
|
3327
|
+
None if args.untracked_before_json is None
|
|
3328
|
+
else parse_string_list(args.untracked_before_json, "--untracked-before-json")
|
|
3329
|
+
)
|
|
3330
|
+
do_spawn(
|
|
3331
|
+
state, args.phase, args.round, args.triggered_by, args.pre_sha, args.engine, args.model,
|
|
3332
|
+
input_patch_sha256=args.input_patch_sha256,
|
|
3333
|
+
prompt_sha256=args.prompt_sha256,
|
|
3334
|
+
untracked_before=untracked_before,
|
|
3335
|
+
)
|
|
3336
|
+
else:
|
|
3337
|
+
next_untracked_before = (
|
|
3338
|
+
None if args.next_untracked_before_json is None
|
|
3339
|
+
else parse_string_list(
|
|
3340
|
+
args.next_untracked_before_json, "--next-untracked-before-json"
|
|
3341
|
+
)
|
|
3342
|
+
)
|
|
3343
|
+
state = do_transition(
|
|
3344
|
+
state, args.phase, args.next_phase, args.verdict, args.post_sha,
|
|
3345
|
+
args.findings_file, args.log_file, args.engine, args.model,
|
|
3346
|
+
args.engine_session_log, devlyn, args.next_round,
|
|
3347
|
+
args.next_triggered_by, args.next_pre_sha, args.next_engine,
|
|
3348
|
+
args.next_model,
|
|
3349
|
+
next_input_patch_sha256=args.next_input_patch_sha256,
|
|
3350
|
+
next_prompt_sha256=args.next_prompt_sha256,
|
|
3351
|
+
next_untracked_before=next_untracked_before,
|
|
3352
|
+
)
|
|
3353
|
+
if spawn_phase == "surface_close":
|
|
3354
|
+
validate_surface_inputs(pathlib.Path.cwd(), devlyn, state)
|
|
3355
|
+
validate_surface_prompt(devlyn, state)
|
|
3356
|
+
ensure_surface_clean_baseline(pathlib.Path.cwd(), devlyn, state)
|
|
3357
|
+
write_state(state_path, state)
|
|
3358
|
+
if spawn_phase == "verify":
|
|
453
3359
|
clear_verify_round_artifacts(devlyn)
|
|
454
|
-
|
|
3360
|
+
if args.event == "transition":
|
|
3361
|
+
opened = state["phases"][args.next_phase]
|
|
3362
|
+
completed = (
|
|
3363
|
+
opened["history"][-1]
|
|
3364
|
+
if args.phase == args.next_phase
|
|
3365
|
+
else state["phases"][args.phase]
|
|
3366
|
+
)
|
|
3367
|
+
raw = state_path.read_bytes()
|
|
3368
|
+
sys.stdout.write(json.dumps({
|
|
3369
|
+
"completed_phase": args.phase,
|
|
3370
|
+
"completed_at": completed["completed_at"],
|
|
3371
|
+
"completed_verdict": completed["verdict"],
|
|
3372
|
+
"next_phase": args.next_phase,
|
|
3373
|
+
"next_started_at": opened["started_at"],
|
|
3374
|
+
"next_round": opened["round"],
|
|
3375
|
+
"state_path": str(state_path),
|
|
3376
|
+
"state_sha256": hashlib.sha256(raw).hexdigest(),
|
|
3377
|
+
}, sort_keys=True) + "\n")
|
|
3378
|
+
return 0
|
|
455
3379
|
else:
|
|
456
|
-
do_complete(
|
|
3380
|
+
attestation_error = do_complete(
|
|
3381
|
+
state, args.phase, args.verdict, args.post_sha, args.findings_file,
|
|
3382
|
+
args.log_file, args.engine, args.model, args.engine_session_log, devlyn,
|
|
3383
|
+
)
|
|
3384
|
+
if (
|
|
3385
|
+
args.phase == "plan"
|
|
3386
|
+
and state["phases"]["plan"]["verdict"] in {"PASS", "PASS_WITH_ISSUES"}
|
|
3387
|
+
):
|
|
3388
|
+
raise SystemExit(
|
|
3389
|
+
"error: phases.plan complete with PASS or PASS_WITH_ISSUES requires "
|
|
3390
|
+
"--phase plan transition --verdict <verdict> --next-phase <phase>"
|
|
3391
|
+
)
|
|
457
3392
|
|
|
458
|
-
|
|
3393
|
+
if args.event not in {"spawn", "transition"}:
|
|
3394
|
+
write_state(state_path, state)
|
|
3395
|
+
if args.event == "complete" and attestation_error is not None:
|
|
3396
|
+
sys.stderr.write(attestation_error + "\n")
|
|
3397
|
+
return 1
|
|
459
3398
|
sys.stdout.write(f"ok: phases.{args.phase}.{args.event}\n")
|
|
460
3399
|
return 0
|
|
461
3400
|
|