devlyn-cli 2.9.2 → 2.10.0

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