devlyn-cli 2.9.3 → 2.10.1

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