devlyn-cli 2.10.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/AGENTS.md +4 -9
  2. package/CLAUDE.md +11 -15
  3. package/README.md +31 -7
  4. package/benchmark/auto-resolve/results/20260507-f10-f11-tier1-full-pipeline/headroom-gate.json +25 -0
  5. package/benchmark/auto-resolve/results/20260507-f10-f11-tier1-full-pipeline/headroom-gate.md +10 -0
  6. package/benchmark/auto-resolve/results/20260508-f22-exact-error-headroom/headroom-gate.json +18 -0
  7. package/benchmark/auto-resolve/results/20260508-f22-exact-error-headroom/headroom-gate.md +9 -0
  8. package/benchmark/auto-resolve/results/20260508-f26-headroom/headroom-gate.json +18 -0
  9. package/benchmark/auto-resolve/results/20260508-f26-headroom/headroom-gate.md +9 -0
  10. package/benchmark/auto-resolve/results/20260510-f16-f23-f25-combined-proof/F16-cli-quote-tax-rules/l2_risk_probes/result.json +28 -0
  11. package/benchmark/auto-resolve/results/20260510-f16-f23-f25-combined-proof/F23-cli-fulfillment-wave/l2_risk_probes/result.json +28 -0
  12. package/benchmark/auto-resolve/results/20260510-f16-f23-f25-combined-proof/F25-cli-cart-promotion-rules/l2_risk_probes/result.json +30 -0
  13. package/benchmark/auto-resolve/results/20260511-f12-webhook-headroom/headroom-gate.json +28 -0
  14. package/benchmark/auto-resolve/results/20260511-f12-webhook-headroom/headroom-gate.md +15 -0
  15. package/benchmark/auto-resolve/results/20260511-f15-concurrency-headroom/headroom-gate.json +28 -0
  16. package/benchmark/auto-resolve/results/20260511-f15-concurrency-headroom/headroom-gate.md +15 -0
  17. package/benchmark/auto-resolve/results/20260511-f21-current-riskprobes-v1/F21-cli-scheduler-priority/l2_risk_probes/result.json +32 -0
  18. package/benchmark/auto-resolve/results/20260511-f21-current-riskprobes-v1/full-pipeline-pair-gate.json +44 -0
  19. package/benchmark/auto-resolve/results/20260511-f21-current-riskprobes-v1/full-pipeline-pair-gate.md +16 -0
  20. package/benchmark/auto-resolve/results/20260511-f21-current-riskprobes-v1/headroom-gate.json +18 -0
  21. package/benchmark/auto-resolve/results/20260511-f21-current-riskprobes-v1/headroom-gate.md +9 -0
  22. package/benchmark/auto-resolve/results/20260511-f3-http-error-headroom/headroom-gate.json +28 -0
  23. package/benchmark/auto-resolve/results/20260511-f3-http-error-headroom/headroom-gate.md +15 -0
  24. package/benchmark/auto-resolve/results/20260512-f2-medium-headroom/headroom-gate.json +28 -0
  25. package/benchmark/auto-resolve/results/20260512-f2-medium-headroom/headroom-gate.md +15 -0
  26. package/benchmark/auto-resolve/results/20260512-f31-seat-rebalance-headroom/headroom-gate.json +28 -0
  27. package/benchmark/auto-resolve/results/20260512-f31-seat-rebalance-headroom/headroom-gate.md +15 -0
  28. package/benchmark/auto-resolve/results/20260512-f32-subscription-renewal-headroom/headroom-gate.json +28 -0
  29. package/benchmark/auto-resolve/results/20260512-f32-subscription-renewal-headroom/headroom-gate.md +15 -0
  30. package/benchmark/auto-resolve/results/20260512-f4-web-headroom/headroom-gate.json +28 -0
  31. package/benchmark/auto-resolve/results/20260512-f4-web-headroom/headroom-gate.md +15 -0
  32. package/benchmark/auto-resolve/results/20260512-f5-fixloop-headroom/headroom-gate.json +28 -0
  33. package/benchmark/auto-resolve/results/20260512-f5-fixloop-headroom/headroom-gate.md +15 -0
  34. package/benchmark/auto-resolve/results/20260512-f6-checksum-headroom/headroom-gate.json +28 -0
  35. package/benchmark/auto-resolve/results/20260512-f6-checksum-headroom/headroom-gate.md +15 -0
  36. package/benchmark/auto-resolve/results/20260512-f7-scope-headroom/headroom-gate.json +28 -0
  37. package/benchmark/auto-resolve/results/20260512-f7-scope-headroom/headroom-gate.md +15 -0
  38. package/benchmark/auto-resolve/results/20260512-f9-e2e-headroom/headroom-gate.json +28 -0
  39. package/benchmark/auto-resolve/results/20260512-f9-e2e-headroom/headroom-gate.md +15 -0
  40. package/benchmark/auto-resolve/scripts/run-fixture.sh +11 -1
  41. package/benchmark/auto-resolve/scripts/test-benchmark-arg-parsing.sh +83 -1
  42. package/bin/devlyn.js +59 -9
  43. package/config/skills/_shared/.ruff.toml +2 -0
  44. package/config/skills/_shared/adapters/claude.md +9 -1
  45. package/config/skills/_shared/adapters/codex.md +5 -0
  46. package/config/skills/_shared/adapters/grok.md +37 -13
  47. package/config/skills/_shared/archive_run.py +738 -56
  48. package/config/skills/_shared/codex-config.md +19 -4
  49. package/config/skills/_shared/codex-monitored.sh +81 -12
  50. package/config/skills/_shared/collect-codex-findings.py +419 -60
  51. package/config/skills/_shared/engine-preflight.md +8 -7
  52. package/config/skills/_shared/expected.schema.json +68 -0
  53. package/config/skills/_shared/finish-gate.py +12 -11
  54. package/config/skills/_shared/grok-anchor-guard.py +78 -0
  55. package/config/skills/_shared/invocation-receipt.py +1041 -0
  56. package/config/skills/_shared/judge-output-parser.py +200 -0
  57. package/config/skills/_shared/judge-role-evidence.py +273 -0
  58. package/config/skills/_shared/phase-prompt-render.py +181 -0
  59. package/config/skills/_shared/process-evidence.py +909 -0
  60. package/config/skills/_shared/resolve-bootstrap.py +776 -84
  61. package/config/skills/_shared/resolve-stop-hook.py +33 -1
  62. package/config/skills/_shared/role-config.py +552 -0
  63. package/config/skills/_shared/run-bounded.py +1 -1
  64. package/config/skills/_shared/spec-verify-check.py +1037 -192
  65. package/config/skills/_shared/state-phase-write.py +2551 -83
  66. package/config/skills/_shared/terminal-claim-check.py +15 -0
  67. package/config/skills/_shared/verify-merge-findings.py +1127 -50
  68. package/config/skills/devlyn:engines/SKILL.md +8 -16
  69. package/config/skills/devlyn:ideate/SKILL.md +1 -1
  70. package/config/skills/devlyn:ideate/references/spec-template.md +1 -0
  71. package/config/skills/devlyn:queue/SKILL.md +7 -4
  72. package/config/skills/devlyn:resolve/SKILL.md +94 -35
  73. package/config/skills/devlyn:resolve/references/outer-loop.md +15 -0
  74. package/config/skills/devlyn:resolve/references/phases/build-gate.md +33 -4
  75. package/config/skills/devlyn:resolve/references/phases/implement.md +4 -3
  76. package/config/skills/devlyn:resolve/references/phases/plan.md +1 -5
  77. package/config/skills/devlyn:resolve/references/phases/probe-derive.md +4 -2
  78. package/config/skills/devlyn:resolve/references/phases/verify.md +120 -80
  79. package/config/skills/devlyn:resolve/references/state-schema.md +23 -9
  80. package/package.json +5 -1
  81. package/scripts/lint-fixtures.sh +4 -1
  82. package/scripts/lint-skills.sh +447 -290
@@ -27,10 +27,12 @@ import copy
27
27
  import datetime
28
28
  import difflib
29
29
  import hashlib
30
+ import importlib.util
30
31
  import json
31
32
  import os
32
33
  import pathlib
33
34
  import re
35
+ import runpy
34
36
  import shutil
35
37
  import subprocess
36
38
  import sys
@@ -38,6 +40,7 @@ import tempfile
38
40
 
39
41
  VALID_VERDICTS = {"PASS", "PASS_WITH_ISSUES", "FAIL", "NEEDS_WORK", "BLOCKED"}
40
42
  VALID_TRIGGERS = {"build_gate", "verify"}
43
+ SPAWN_TRIGGERS = VALID_TRIGGERS | {"plan"}
41
44
  PHASE_NAMES = {"plan", "probe_derive", "implement", "surface_close", "build_gate", "cleanup", "verify", "final_report"}
42
45
  LEGAL_TRANSITIONS = {
43
46
  "plan": {"probe_derive", "implement", "final_report"},
@@ -50,11 +53,12 @@ LEGAL_TRANSITIONS = {
50
53
  "final_report": set(),
51
54
  }
52
55
  WORKER_SESSION_ARTIFACT_PHASES = {
56
+ "plan": "plan",
53
57
  "implement": "implement",
54
58
  "surface_close": "surface-close",
59
+ "build_gate": "build_gate",
55
60
  "cleanup": "cleanup",
56
61
  }
57
- MODEL_HEADER_RE = re.compile(r"(?m)^[ \t]*model:[ \t]*(\S+)[ \t]*$")
58
62
  SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
59
63
  SURFACE_ROW_RE = re.compile(
60
64
  r"^(?P<obligation>UVR-STALE|PATH-TEST): (?:"
@@ -68,14 +72,37 @@ VALIDATION_EXECUTION_RE = re.compile(
68
72
  )
69
73
  SURFACE_SKIP_REASON = "auto_surface_close_claude_unavailable"
70
74
  SURFACE_RECOVERY_REASON = "surface_close_rolled_back_adjudication_malformed"
75
+ PLAN_MAX_DISPATCHES = 2
76
+ PLAN_SPAWN_RECEIPT_FIELDS = (
77
+ "round", "started_at", "triggered_by", "engine", "model_requested", "prompt_sha256",
78
+ )
79
+ PLAN_COMPLETION_RECEIPT_FIELDS = (
80
+ "completed_at", "duration_ms", "verdict", "model_effective", "output_sha256",
81
+ )
82
+ PLAN_RECEIPT_FIELDS = PLAN_SPAWN_RECEIPT_FIELDS + PLAN_COMPLETION_RECEIPT_FIELDS
83
+ _PROCESS_EVIDENCE_MODULE = None
84
+ _INVOCATION_RECEIPT_MODULE = None
71
85
 
72
86
 
73
87
  def reject_json_constant(token: str) -> None:
74
88
  raise ValueError(f"invalid JSON numeric constant: {token}")
75
89
 
76
90
 
91
+ def reject_duplicate_keys(pairs: list[tuple[str, object]]) -> dict:
92
+ result = {}
93
+ for key, value in pairs:
94
+ if key in result:
95
+ raise ValueError(f"duplicate JSON key: {key}")
96
+ result[key] = value
97
+ return result
98
+
99
+
77
100
  def loads_strict_json(text: str):
78
- return json.loads(text, parse_constant=reject_json_constant)
101
+ return json.loads(
102
+ text,
103
+ parse_constant=reject_json_constant,
104
+ object_pairs_hook=reject_duplicate_keys,
105
+ )
79
106
 
80
107
 
81
108
  def now_ms() -> datetime.datetime:
@@ -120,6 +147,236 @@ def write_state(state_path: pathlib.Path, state: dict) -> None:
120
147
  raise
121
148
 
122
149
 
150
+ def validate_plan_output(state: dict, devlyn: pathlib.Path | None, phase: str) -> None:
151
+ plan = (state.get("phases") or {}).get("plan")
152
+ if not isinstance(plan, dict) or plan.get("completed_at") is None:
153
+ return
154
+ expected = plan.get("output_sha256")
155
+ if expected is None and state.get("version") != "3.0":
156
+ return
157
+ if (
158
+ expected is None and "output_sha256" in plan and plan.get("verdict") == "BLOCKED"
159
+ and devlyn is not None and not os.path.lexists(devlyn / "plan.md")
160
+ ):
161
+ if phase != "final_report":
162
+ raise SystemExit("BLOCKED:plan-output-missing: only final_report is allowed")
163
+ return
164
+ if not isinstance(expected, str) or SHA256_RE.fullmatch(expected) is None:
165
+ raise SystemExit("BLOCKED:plan-integrity-invalid: phases.plan.output_sha256 is missing")
166
+ if devlyn is None:
167
+ raise SystemExit("BLOCKED:plan-integrity-invalid: .devlyn is required to rehash PLAN output")
168
+ plan_path = devlyn / "plan.md"
169
+ try:
170
+ actual = hashlib.sha256(plan_path.read_bytes()).hexdigest()
171
+ except OSError as exc:
172
+ raise SystemExit(f"BLOCKED:plan-integrity-invalid: cannot read {plan_path}: {exc}") from exc
173
+ if actual != expected:
174
+ raise SystemExit(
175
+ "BLOCKED:plan-integrity-mismatch: "
176
+ f"expected={expected} actual={actual} path={plan_path}"
177
+ )
178
+
179
+
180
+ def bind_plan_output(state: dict, devlyn: pathlib.Path | None) -> None:
181
+ if devlyn is None:
182
+ raise SystemExit("BLOCKED:plan-integrity-invalid: .devlyn is required to bind PLAN output")
183
+ plan_path = devlyn / "plan.md"
184
+ plan = state["phases"]["plan"]
185
+ if (
186
+ plan.get("verdict") == "BLOCKED" and plan.get("output_sha256") is None
187
+ and not os.path.lexists(plan_path)
188
+ ):
189
+ digest = None
190
+ else:
191
+ try:
192
+ digest = hashlib.sha256(plan_path.read_bytes()).hexdigest()
193
+ except OSError as exc:
194
+ raise SystemExit(f"BLOCKED:plan-integrity-invalid: cannot read {plan_path}: {exc}") from exc
195
+ plan["output_sha256"] = digest
196
+
197
+
198
+ def final_report_digest(state: dict, devlyn: pathlib.Path | None, log_file: str | None) -> str:
199
+ try:
200
+ if devlyn is None or not log_file:
201
+ raise ValueError("--log-file must name .devlyn/final-report.md")
202
+ path = devlyn / "final-report.md"
203
+ supplied = pathlib.Path(log_file)
204
+ if supplied.parent.resolve() / supplied.name != devlyn.resolve() / path.name:
205
+ raise ValueError("--log-file must name the canonical final-report.md")
206
+ if path.is_symlink() or not path.is_file():
207
+ raise ValueError("final-report.md must be a nonsymlink regular file")
208
+ raw = path.read_bytes()
209
+ lines = raw.decode("utf-8").splitlines()
210
+ run_id = state.get("run_id")
211
+ prefix = "<!-- devlyn:final-report run_id="
212
+ if (
213
+ not isinstance(run_id, str) or not run_id or not lines
214
+ or lines[0] != f"{prefix}{run_id} -->"
215
+ or sum(line.startswith(prefix) for line in lines) != 1
216
+ ):
217
+ raise ValueError("first line must uniquely identify the current state.run_id")
218
+ if not any(line.strip() for line in lines[1:]):
219
+ raise ValueError("final-report.md body must not be empty")
220
+ return hashlib.sha256(raw).hexdigest()
221
+ except (OSError, UnicodeError, ValueError) as exc:
222
+ raise SystemExit(f"BLOCKED:final-report-invalid: {exc}") from exc
223
+
224
+
225
+ def process_evidence_module():
226
+ global _PROCESS_EVIDENCE_MODULE
227
+ if _PROCESS_EVIDENCE_MODULE is None:
228
+ module_path = pathlib.Path(__file__).with_name("process-evidence.py")
229
+ spec = importlib.util.spec_from_file_location("devlyn_process_evidence", module_path)
230
+ if spec is None or spec.loader is None:
231
+ raise SystemExit(f"BLOCKED:process-evidence-invalid: cannot load {module_path}")
232
+ module = importlib.util.module_from_spec(spec)
233
+ previous_bytecode_setting = sys.dont_write_bytecode
234
+ sys.dont_write_bytecode = True
235
+ try:
236
+ spec.loader.exec_module(module)
237
+ finally:
238
+ sys.dont_write_bytecode = previous_bytecode_setting
239
+ _PROCESS_EVIDENCE_MODULE = module
240
+ return _PROCESS_EVIDENCE_MODULE
241
+
242
+
243
+ def invocation_receipt_module():
244
+ global _INVOCATION_RECEIPT_MODULE
245
+ if _INVOCATION_RECEIPT_MODULE is None:
246
+ module_path = pathlib.Path(__file__).with_name("invocation-receipt.py")
247
+ spec = importlib.util.spec_from_file_location("devlyn_invocation_receipt", module_path)
248
+ if spec is None or spec.loader is None:
249
+ raise SystemExit(f"BLOCKED:invocation-receipt-invalid: cannot load {module_path}")
250
+ module = importlib.util.module_from_spec(spec)
251
+ previous_bytecode_setting = sys.dont_write_bytecode
252
+ sys.dont_write_bytecode = True
253
+ try:
254
+ spec.loader.exec_module(module)
255
+ finally:
256
+ sys.dont_write_bytecode = previous_bytecode_setting
257
+ _INVOCATION_RECEIPT_MODULE = module
258
+ return _INVOCATION_RECEIPT_MODULE
259
+
260
+
261
+ def bind_process_evidence(
262
+ state: dict, phase: str, verdict: str | None,
263
+ devlyn: pathlib.Path | None, work: pathlib.Path | None,
264
+ ) -> None:
265
+ if phase == "implement" and verdict not in {"PASS", "PASS_WITH_ISSUES"}:
266
+ return
267
+ if phase not in {"implement", "build_gate"}:
268
+ return
269
+ source = state.get("source")
270
+ if work is None or devlyn is None:
271
+ if isinstance(source, dict) and source.get("type") == "spec":
272
+ raise SystemExit(
273
+ "BLOCKED:process-evidence-invalid: worktree is required for spec evidence validation"
274
+ )
275
+ state.setdefault("process_evidence", None)
276
+ return
277
+ runner = process_evidence_module()
278
+ try:
279
+ results_path = devlyn / "spec-verify.results.json"
280
+ if phase == "implement":
281
+ obligations = runner.declared_obligations(work, state, phase)
282
+ if not obligations:
283
+ state.setdefault("process_evidence", None)
284
+ return
285
+ round_ = runner.phase_round(state, phase)
286
+ manifest_path = runner.manifest_relative_path(state, phase)
287
+ carrier = runner.validate_manifest(
288
+ work, manifest_path, state.get("run_id"), phase, round_, obligations,
289
+ )
290
+ elif verdict == "BLOCKED" and not os.path.lexists(results_path):
291
+ round_ = runner.phase_round(state, phase)
292
+ carrier = runner.validate_manifest(
293
+ work, runner.manifest_relative_path(state, phase),
294
+ state.get("run_id"), phase, round_, require_expectations=False,
295
+ )
296
+ else:
297
+ if not results_path.is_file():
298
+ raise runner.EvidenceError(
299
+ "spec-verify.results.json is missing for BUILD_GATE completion"
300
+ )
301
+ results = loads_strict_json(results_path.read_text(encoding="utf-8"))
302
+ if not isinstance(results, dict):
303
+ raise runner.EvidenceError(
304
+ "spec-verify.results.json must contain a JSON object"
305
+ )
306
+ commands = results.get("commands")
307
+ if not isinstance(commands, list):
308
+ raise runner.EvidenceError(
309
+ "spec-verify.results.json commands must be an array"
310
+ )
311
+ carrier = results.get("process_evidence")
312
+ if carrier is None:
313
+ if commands:
314
+ raise runner.EvidenceError(
315
+ "BUILD_GATE commands exist without a process-evidence carrier"
316
+ )
317
+ manifest_path = work / runner.manifest_relative_path(state, phase)
318
+ if manifest_path.exists():
319
+ raise runner.EvidenceError(
320
+ "BUILD_GATE manifest exists without a process-evidence carrier"
321
+ )
322
+ if runner.mechanical_evidence_required(work, state):
323
+ raise runner.EvidenceError(
324
+ "required BUILD_GATE evidence has no process-evidence carrier"
325
+ )
326
+ state.setdefault("process_evidence", None)
327
+ return
328
+ round_ = runner.phase_round(state, phase)
329
+ manifest = carrier.get("manifest") if isinstance(carrier, dict) else None
330
+ if (
331
+ not isinstance(carrier, dict)
332
+ or carrier.get("phase") != phase
333
+ or carrier.get("round") != round_
334
+ or not isinstance(manifest, dict)
335
+ or manifest.get("path") != runner.manifest_relative_path(state, phase)
336
+ ):
337
+ raise runner.EvidenceError(
338
+ "BUILD_GATE process-evidence carrier does not match the active run/round"
339
+ )
340
+ outcome = runner.validate_summary_commands(work, commands, carrier)
341
+ if outcome["verdict"] == "BLOCKED" and verdict != "BLOCKED":
342
+ denial = outcome["capability_denials"][0]
343
+ raise runner.EvidenceError(
344
+ "BUILD_GATE capability denial requires BLOCKED verdict: "
345
+ f"{denial['id']}:{denial['operation']}"
346
+ )
347
+ if (
348
+ outcome["verdict"] == "NEEDS_WORK"
349
+ and verdict in {"PASS", "PASS_WITH_ISSUES"}
350
+ ):
351
+ raise runner.EvidenceError(
352
+ "BUILD_GATE process evidence mismatch cannot complete as "
353
+ f"{verdict}: {','.join(outcome['failed_ids'])}"
354
+ )
355
+ except (runner.EvidenceError, OSError, UnicodeError, ValueError) as exc:
356
+ raise SystemExit(f"BLOCKED:process-evidence-invalid: {exc}") from exc
357
+ existing = state.get("process_evidence")
358
+ if existing is None:
359
+ existing = []
360
+ if not isinstance(existing, list):
361
+ raise SystemExit("BLOCKED:process-evidence-invalid: state.process_evidence must be null or an array")
362
+ try:
363
+ for prior in existing:
364
+ runner.validate_bound_carrier(work, prior)
365
+ except runner.EvidenceError as exc:
366
+ raise SystemExit(f"BLOCKED:process-evidence-invalid: {exc}") from exc
367
+ if any(
368
+ isinstance(item, dict)
369
+ and item.get("phase") == carrier["phase"]
370
+ and item.get("round") == carrier["round"]
371
+ for item in existing
372
+ ):
373
+ raise SystemExit(
374
+ "BLOCKED:process-evidence-invalid: duplicate state carrier for "
375
+ f"{phase} round {carrier['round']}"
376
+ )
377
+ state["process_evidence"] = [*existing, carrier]
378
+
379
+
123
380
  def parse_string_list(raw: str, label: str) -> list[str]:
124
381
  try:
125
382
  value = loads_strict_json(raw)
@@ -226,6 +483,24 @@ def ensure_surface_clean_baseline(work: pathlib.Path, devlyn: pathlib.Path, stat
226
483
  raise SystemExit(f"BLOCKED:surface-close-preexisting-delta: {detail}")
227
484
 
228
485
 
486
+ def validate_surface_brace_glob(entry: str) -> str | None:
487
+ if "{" not in entry and "}" not in entry:
488
+ return None
489
+ brace = re.search(r"\{([^{}]*)\}", entry)
490
+ alternatives = brace.group(1).split(",") if brace else []
491
+ if (
492
+ entry.count("{") != 1
493
+ or entry.count("}") != 1
494
+ or len(alternatives) < 2
495
+ or any(not value or any(char in value for char in "{},/*") for value in alternatives)
496
+ ):
497
+ return (
498
+ f"unsupported brace glob {entry!r}; supported form is {{alt1,alt2,...}} "
499
+ "with at least two non-empty plain alternatives"
500
+ )
501
+ return None
502
+
503
+
229
504
  def validate_authorized_surface(raw: str) -> list[str]:
230
505
  surface = parse_string_list(raw, "--authorized-surface-json")
231
506
  if not surface:
@@ -238,24 +513,103 @@ def validate_authorized_surface(raw: str) -> list[str]:
238
513
  or pathlib.PurePosixPath(path).is_absolute() or ".." in parts
239
514
  ):
240
515
  raise SystemExit(f"error: invalid authorized_surface entry: {entry!r}")
516
+ brace_error = validate_surface_brace_glob(entry)
517
+ if brace_error is not None:
518
+ raise SystemExit(f"error: {brace_error}")
241
519
  return surface
242
520
 
243
521
 
244
522
  def path_matches_surface(path: str, surface: list[str]) -> bool:
245
523
  for entry in surface:
246
- if entry.endswith("/**"):
247
- prefix = entry[:-3].rstrip("/")
248
- if path == prefix or path.startswith(f"{prefix}/"):
524
+ brace_error = validate_surface_brace_glob(entry)
525
+ if brace_error is not None:
526
+ raise ValueError(brace_error)
527
+ if "{" in entry:
528
+ brace = re.search(r"\{([^{}]*)\}", entry)
529
+ assert brace is not None
530
+ alternatives = brace.group(1).split(",")
531
+ entries = tuple(
532
+ entry[:brace.start()] + value + entry[brace.end():]
533
+ for value in alternatives
534
+ )
535
+ else:
536
+ entries = (entry,)
537
+ for expanded in entries:
538
+ if expanded.endswith("/**"):
539
+ prefix = expanded[:-3].rstrip("/")
540
+ if path == prefix or path.startswith(f"{prefix}/"):
541
+ return True
542
+ elif path == expanded:
249
543
  return True
250
- elif path == entry:
251
- return True
252
544
  return False
253
545
 
254
546
 
547
+ def safe_path_matches_surface(path: str, surface: list[str]) -> bool:
548
+ parsed = pathlib.PurePosixPath(path)
549
+ return not (parsed.is_absolute() or ".." in parsed.parts) and path_matches_surface(path, surface)
550
+
551
+
552
+ def worktree_file_exists(work: pathlib.Path, path: str) -> bool:
553
+ parsed = pathlib.PurePosixPath(path)
554
+ if parsed.is_absolute() or ".." in parsed.parts:
555
+ return False
556
+ try:
557
+ resolved = (work / path).resolve()
558
+ resolved.relative_to(work.resolve())
559
+ return resolved.is_file()
560
+ except (OSError, RuntimeError, ValueError):
561
+ return False
562
+
563
+
564
+ def worktree_path_exists(work: pathlib.Path, path: str) -> bool:
565
+ parsed = pathlib.PurePosixPath(path)
566
+ if parsed.is_absolute() or ".." in parsed.parts:
567
+ return False
568
+ try:
569
+ resolved = (work / path).resolve()
570
+ resolved.relative_to(work.resolve())
571
+ return resolved.exists()
572
+ except (OSError, RuntimeError, ValueError):
573
+ return False
574
+
575
+
576
+ def resolve_na_surface_citation(
577
+ work: pathlib.Path, raw: str, parsed_path: str, parsed_line: int | None,
578
+ surface: list[str],
579
+ ) -> tuple[str, int | None]:
580
+ def proven(path: str) -> bool:
581
+ if path in surface and not path.endswith("/**"):
582
+ return True
583
+ return worktree_file_exists(work, path) and any(
584
+ entry.endswith("/**") and safe_path_matches_surface(path, [entry])
585
+ for entry in surface
586
+ )
587
+
588
+ if proven(raw):
589
+ return raw, None
590
+ if worktree_path_exists(work, raw) and not safe_path_matches_surface(raw, surface):
591
+ raise SystemExit(f"BLOCKED:surface-close-adjudication-out-of-surface: {raw}")
592
+ if (
593
+ worktree_path_exists(work, parsed_path)
594
+ and not safe_path_matches_surface(parsed_path, surface)
595
+ ):
596
+ raise SystemExit(f"BLOCKED:surface-close-adjudication-out-of-surface: {raw}")
597
+ for split in range(len(raw) - 1, -1, -1):
598
+ if raw[split] != ":" or not proven(raw[:split]):
599
+ continue
600
+ suffix = raw[split:]
601
+ if not re.fullmatch(r":[1-9][0-9]*", suffix):
602
+ raise SystemExit(
603
+ f"BLOCKED:surface-close-adjudication-malformed: citation {raw!r}"
604
+ )
605
+ return raw[:split], int(suffix[1:])
606
+ return parsed_path, parsed_line
607
+
608
+
255
609
  def surface_offenders(work: pathlib.Path, devlyn: pathlib.Path, state: dict,
256
610
  surface: list[str]) -> list[str]:
257
611
  tracked, untracked = surface_delta_paths(work, devlyn, state)
258
- return sorted(path for path in set(tracked + untracked) if not path_matches_surface(path, surface))
612
+ return sorted(path for path in set(tracked + untracked) if not safe_path_matches_surface(path, surface))
259
613
 
260
614
 
261
615
  def path_exists_at_commit(work: pathlib.Path, sha: str, path: str) -> bool:
@@ -342,14 +696,20 @@ def validate_surface_adjudication(
342
696
  raw_line = match.group("fired_line") or match.group("na_line")
343
697
  line_number = int(raw_line) if raw_line is not None else None
344
698
  citation = path if line_number is None else f"{path}:{line_number}"
345
- if not path_matches_surface(path, surface):
699
+ if status == "N/A":
700
+ path, line_number = resolve_na_surface_citation(
701
+ work, citation, path, line_number, surface,
702
+ )
703
+ citation = path if line_number is None else f"{path}:{line_number}"
704
+ if not safe_path_matches_surface(path, surface):
346
705
  raise SystemExit(
347
706
  f"BLOCKED:surface-close-adjudication-out-of-surface: {citation}"
348
707
  )
349
- cited = work / path
350
708
  try:
709
+ cited = (work / path).resolve()
710
+ cited.relative_to(work.resolve())
351
711
  cited_lines = cited.read_text(encoding="utf-8").splitlines()
352
- except (OSError, UnicodeError) as exc:
712
+ except (OSError, RuntimeError, UnicodeError, ValueError) as exc:
353
713
  raise SystemExit(
354
714
  f"BLOCKED:surface-close-adjudication-citation-missing: {citation}: {exc}"
355
715
  ) from exc
@@ -464,7 +824,7 @@ def validate_surface_write_audit(
464
824
  "BLOCKED:surface-close-write-audit-violation: "
465
825
  f"line {line_number}: {raw_target!r}"
466
826
  ) from exc
467
- if not path_matches_surface(relative, surface):
827
+ if not safe_path_matches_surface(relative, surface):
468
828
  raise SystemExit(
469
829
  "BLOCKED:surface-close-write-audit-violation: "
470
830
  f"line {line_number}: {relative!r}"
@@ -488,16 +848,66 @@ def require_surface_adjudication_malformed(
488
848
  )
489
849
 
490
850
 
851
+ CLAUDE_USAGE_COUNTERS = (
852
+ ("input_tokens", "inputTokens"),
853
+ ("output_tokens", "outputTokens"),
854
+ ("cache_read_input_tokens", "cacheReadInputTokens"),
855
+ ("cache_creation_input_tokens", "cacheCreationInputTokens"),
856
+ )
857
+
858
+
859
+ def select_claude_primary_model(evidence: dict) -> str:
860
+ model_usage = evidence.get("modelUsage")
861
+ models = (
862
+ [model for model in model_usage if isinstance(model, str) and model]
863
+ if isinstance(model_usage, dict)
864
+ else []
865
+ )
866
+ if len(models) == 1:
867
+ return models[0]
868
+ if not models:
869
+ raise ValueError("Claude JSON wrapper has no effective-model evidence")
870
+
871
+ usage = evidence.get("usage")
872
+ if not isinstance(usage, dict):
873
+ raise ValueError("multi-entry Claude JSON wrapper has malformed top-level usage")
874
+
875
+ def counters(container: dict, fields: tuple[str, ...]) -> tuple[int, ...]:
876
+ values = tuple(container.get(field) for field in fields)
877
+ if any(
878
+ not isinstance(value, int)
879
+ or isinstance(value, bool)
880
+ or value < 0
881
+ for value in values
882
+ ):
883
+ raise ValueError("multi-entry Claude JSON wrapper has malformed usage counters")
884
+ return values
885
+
886
+ primary_usage = counters(
887
+ usage, tuple(field[0] for field in CLAUDE_USAGE_COUNTERS)
888
+ )
889
+ matches = []
890
+ for model in models:
891
+ entry = model_usage[model]
892
+ if not isinstance(entry, dict):
893
+ raise ValueError("multi-entry Claude JSON wrapper has malformed modelUsage entry")
894
+ if counters(
895
+ entry, tuple(field[1] for field in CLAUDE_USAGE_COUNTERS)
896
+ ) == primary_usage:
897
+ matches.append(model)
898
+ if len(matches) == 1:
899
+ return matches[0]
900
+ raise ValueError(
901
+ "multi-entry Claude JSON wrapper has no unique primary usage match"
902
+ )
903
+
904
+
491
905
  def parse_effective_model(session_log: pathlib.Path) -> str:
492
906
  try:
493
907
  text = session_log.read_text(encoding="utf-8")
494
908
  except (OSError, UnicodeError) as exc:
495
909
  raise ValueError(f"cannot read engine session log {session_log}: {exc}") from exc
496
910
 
497
- header = MODEL_HEADER_RE.search(text)
498
- if header:
499
- return header.group(1)
500
-
501
911
  models: set[str] = set()
502
912
  if session_log.suffix == ".jsonl":
503
913
  for line_number, line in enumerate(text.splitlines(), start=1):
@@ -520,9 +930,8 @@ def parse_effective_model(session_log: pathlib.Path) -> str:
520
930
  evidence = loads_strict_json(text)
521
931
  except ValueError:
522
932
  evidence = None
523
- model_usage = evidence.get("modelUsage") if isinstance(evidence, dict) else None
524
- if isinstance(model_usage, dict):
525
- models.update(model for model in model_usage if isinstance(model, str) and model)
933
+ if isinstance(evidence, dict) and isinstance(evidence.get("modelUsage"), dict):
934
+ return select_claude_primary_model(evidence)
526
935
 
527
936
  if len(models) == 1:
528
937
  return next(iter(models))
@@ -1126,42 +1535,135 @@ def clear_verify_round_artifacts(devlyn: pathlib.Path) -> None:
1126
1535
  # verify-merge-findings.py (iter-0060 R0 finding).
1127
1536
  # verify*.jsonl (not just *.findings.jsonl): judge-specific files like
1128
1537
  # verify.findings.judge-codex.jsonl end in .judge-<engine>.jsonl.
1129
- # *-judge.* covers every engine's stdout/stderr capture (codex-judge.*,
1130
- # claude-judge.* — adapters/claude.md ## Invocation).
1131
- for pattern in ("verify*.jsonl", "*-judge.*"):
1538
+ # The judge suffixes cover harness-owned stdout/stderr captures and the
1539
+ # collector-written pair-judge.summary.json, without matching prompts.
1540
+ for pattern in ("verify*.jsonl", "*-judge.stdout", "*-judge.stderr", "*-judge.summary.json"):
1132
1541
  for path in devlyn.glob(pattern):
1133
1542
  path.unlink()
1134
1543
  (devlyn / "verify-merge.summary.json").unlink(missing_ok=True)
1135
1544
 
1136
1545
 
1137
- def append_phase_history(entry: dict) -> None:
1546
+ def is_plan_dispatch_receipt(entry: dict) -> bool:
1547
+ return all(field in entry for field in PLAN_RECEIPT_FIELDS)
1548
+
1549
+
1550
+ def append_phase_history(entry: dict, phase: str) -> None:
1138
1551
  if entry.get("started_at") is None:
1139
1552
  return
1140
1553
  history = entry.get("history")
1141
1554
  if not isinstance(history, list):
1142
1555
  history = []
1143
- history.append({
1144
- "started_at": entry.get("started_at"),
1145
- "verdict": entry.get("verdict"),
1146
- "completed_at": entry.get("completed_at"),
1147
- "duration_ms": entry.get("duration_ms"),
1148
- })
1556
+ if phase == "plan" and is_plan_dispatch_receipt(entry):
1557
+ fields = PLAN_RECEIPT_FIELDS + (
1558
+ ("invocation_receipt",) if "invocation_receipt" in entry else ()
1559
+ )
1560
+ elif phase in WORKER_SESSION_ARTIFACT_PHASES and "invocation_receipt" in entry:
1561
+ fields = (
1562
+ "started_at", "verdict", "completed_at", "duration_ms",
1563
+ "invocation_receipt",
1564
+ ) + (("role_argv",) if "role_argv" in entry else ())
1565
+ elif phase == "verify":
1566
+ fields = ("started_at", "verdict", "completed_at", "duration_ms", "round", "engine", "role_evidence")
1567
+ else:
1568
+ fields = ("started_at", "verdict", "completed_at", "duration_ms")
1569
+ history.append({field: entry.get(field) for field in fields})
1149
1570
  entry["history"] = history
1150
1571
 
1151
1572
 
1573
+ def role_config_module():
1574
+ return runpy.run_path(pathlib.Path(__file__).with_name("role-config.py"))
1575
+
1576
+
1577
+ def bind_worker_role_argv(state, phase, entry, devlyn, receipt):
1578
+ if phase not in {"implement", "cleanup"}:
1579
+ return None
1580
+ helper = role_config_module()
1581
+ resolution = helper["snapshot"](state)
1582
+ effort = resolution["roles"]["worker"]["effort_requested"] if resolution else None
1583
+ if effort is None:
1584
+ return None
1585
+ path = devlyn / f"{phase}.argv.{entry['round']}.json"
1586
+ if path.is_symlink() or not path.is_file():
1587
+ raise ValueError("explicit worker effort requires the retained native argv array")
1588
+ raw = path.read_bytes()
1589
+ argv = helper["loads"](raw)
1590
+ if not isinstance(argv, list) or any(not isinstance(arg, str) for arg in argv):
1591
+ raise ValueError("worker argv must be an array of strings")
1592
+ if hashlib.sha256(json.dumps(argv, separators=(",", ":")).encode()).hexdigest() != receipt["argv_sha256"]:
1593
+ raise ValueError("worker argv differs from the actual invocation receipt")
1594
+ values = [argv[i + 1] for i, arg in enumerate(argv[:-1]) if arg in {"-c", "--config"}]
1595
+ efforts = [value.split("=", 1)[1].strip('"') for value in values if value.startswith("model_reasoning_effort=")]
1596
+ if efforts != [effort]:
1597
+ raise ValueError("worker effort differs from the frozen explicit selection")
1598
+ return {"path": ".devlyn/" + path.name, "sha256": hashlib.sha256(raw).hexdigest(),
1599
+ "bytes": len(raw), "effort_requested": effort, "identity_basis": "invocation-argv"}
1600
+
1601
+
1602
+ def freeze_roles(state: dict, work: pathlib.Path, default_engine: str) -> dict:
1603
+ helper = role_config_module()
1604
+ existing = helper["snapshot"](state)
1605
+ if existing is not None:
1606
+ return existing
1607
+ if any(isinstance(entry, dict) and entry.get("started_at") for entry in state.get("phases", {}).values()):
1608
+ raise ValueError("BLOCKED:invalid-engine-config: roles must be frozen before phase dispatch")
1609
+ resolved = helper["resolve"](
1610
+ work, default_engine,
1611
+ flag_engine=state.get("engine") if state.get("engine_source") == "flag" else None,
1612
+ run_input=state.get("role_config_input"), no_pair=state.get("role_no_pair", False),
1613
+ )
1614
+ state["role_resolution"] = resolved
1615
+ state["engine"], state["engine_source"] = resolved["legacy_engine"], resolved["legacy_source"]
1616
+ return resolved
1617
+
1618
+
1152
1619
  def do_spawn(state: dict, phase: str, round_: int, triggered_by: str | None,
1153
1620
  pre_sha: str | None, engine: str | None, model: str | None, *,
1154
1621
  input_patch_sha256: str | None = None,
1155
1622
  prompt_sha256: str | None = None,
1156
- untracked_before: list[str] | None = None) -> None:
1623
+ untracked_before: list[str] | None = None,
1624
+ devlyn: pathlib.Path | None = None) -> None:
1625
+ if phase in {"implement", "cleanup", "verify"} and "role_resolution" in state:
1626
+ resolution = role_config_module()["snapshot"](state)
1627
+ selected = resolution["roles"]["primary_judge" if phase == "verify" else "worker"]
1628
+ if engine is not None and engine != selected["engine"]:
1629
+ raise SystemExit("BLOCKED:role-selection-mismatch: phase engine differs from frozen selection")
1630
+ engine = selected["engine"]
1631
+ wanted_model = selected["model_requested"]
1632
+ if wanted_model is not None and model is not None and wanted_model != model:
1633
+ raise SystemExit("BLOCKED:role-selection-mismatch: phase model differs from frozen selection")
1634
+ model = wanted_model or model
1157
1635
  # Merge, don't replace: a phase-gated large run's `exec` progress (or any
1158
1636
  # other field this script doesn't own) survives a fix-loop respawn.
1637
+ phases_value = state.get("phases")
1638
+ entry = phases_value.get(phase) if isinstance(phases_value, dict) else None
1639
+ validate_plan_output(state, devlyn, phase)
1640
+ if phase == "plan":
1641
+ history = entry.get("history", []) if isinstance(entry, dict) else []
1642
+ if not isinstance(history, list):
1643
+ raise SystemExit("error: phases.plan.history must be an array")
1644
+ dispatch_count = len(history) + int(
1645
+ isinstance(entry, dict) and entry.get("started_at") is not None
1646
+ )
1647
+ if dispatch_count >= PLAN_MAX_DISPATCHES:
1648
+ raise SystemExit("BLOCKED:plan-respawn-exhausted")
1649
+ if round_ != dispatch_count:
1650
+ raise SystemExit(
1651
+ f"BLOCKED:plan-round-nonmonotonic: expected={dispatch_count} supplied={round_}"
1652
+ )
1653
+ if not isinstance(engine, str) or not engine:
1654
+ raise SystemExit("error: phases.plan spawn requires --engine")
1655
+ if not isinstance(model, str) or not model:
1656
+ raise SystemExit("error: phases.plan spawn requires --model")
1657
+ if prompt_sha256 is None or not SHA256_RE.fullmatch(prompt_sha256):
1658
+ raise SystemExit("error: phases.plan spawn requires --prompt-sha256")
1659
+ if dispatch_count > 0 and triggered_by is None:
1660
+ raise SystemExit("error: phases.plan re-spawn requires --triggered-by")
1159
1661
  if phase == "surface_close" and engine != "claude":
1160
1662
  raise SystemExit("error: phases.surface_close spawn requires --engine claude")
1161
- if phase == "surface_close" and not model:
1162
- raise SystemExit("error: phases.surface_close spawn requires --model")
1163
- phases = state.setdefault("phases", {})
1164
- entry = phases.get(phase)
1663
+ if phase == "surface_close" and model is not None and (
1664
+ not isinstance(model, str) or not model.strip() or model != model.strip()
1665
+ ):
1666
+ raise SystemExit("error: phases.surface_close explicit --model must be a nonempty exact string")
1165
1667
  if phase == "surface_close" and isinstance(entry, dict) and (
1166
1668
  entry.get("started_at") is not None or entry.get("skipped_reason") is not None
1167
1669
  ):
@@ -1175,8 +1677,42 @@ def do_spawn(state: dict, phase: str, round_: int, triggered_by: str | None,
1175
1677
  raise SystemExit("error: phases.surface_close spawn requires --prompt-sha256")
1176
1678
  if untracked_before is None:
1177
1679
  raise SystemExit("error: phases.surface_close spawn requires --untracked-before-json")
1178
- elif input_patch_sha256 is not None or prompt_sha256 is not None or untracked_before is not None:
1179
- raise SystemExit("error: SURFACE_CLOSE metadata is invalid for this phase")
1680
+ elif phase == "plan":
1681
+ if input_patch_sha256 is not None or untracked_before is not None:
1682
+ raise SystemExit("error: SURFACE_CLOSE metadata is invalid for this phase")
1683
+ else:
1684
+ if input_patch_sha256 is not None or untracked_before is not None:
1685
+ raise SystemExit("error: SURFACE_CLOSE metadata is invalid for this phase")
1686
+ if prompt_sha256 is not None and SHA256_RE.fullmatch(prompt_sha256) is None:
1687
+ raise SystemExit("error: --prompt-sha256 must be a lowercase SHA-256 digest")
1688
+ requested_engine = (
1689
+ engine if isinstance(engine, str) and engine else
1690
+ entry.get("engine") if isinstance(entry, dict) else
1691
+ state.get("engine")
1692
+ )
1693
+ if (
1694
+ state.get("version") == "3.0"
1695
+ and requested_engine == "codex"
1696
+ and phase in WORKER_SESSION_ARTIFACT_PHASES
1697
+ and prompt_sha256 is None
1698
+ ):
1699
+ raise SystemExit(
1700
+ f"error: schema-v3 Codex phases.{phase} spawn requires --prompt-sha256"
1701
+ )
1702
+ requested_model = (
1703
+ model if isinstance(model, str) and model else
1704
+ entry.get("model_requested") if isinstance(entry, dict) else None
1705
+ )
1706
+ if (
1707
+ state.get("version") == "3.0"
1708
+ and requested_engine == "codex"
1709
+ and phase in WORKER_SESSION_ARTIFACT_PHASES
1710
+ and not requested_model
1711
+ ):
1712
+ raise SystemExit(
1713
+ f"error: schema-v3 Codex phases.{phase} spawn requires --model"
1714
+ )
1715
+ phases = state.setdefault("phases", {})
1180
1716
  if not isinstance(entry, dict):
1181
1717
  entry = {}
1182
1718
  phases[phase] = entry
@@ -1184,7 +1720,10 @@ def do_spawn(state: dict, phase: str, round_: int, triggered_by: str | None,
1184
1720
  raise SystemExit(
1185
1721
  f"error: phases.{phase} has an open span — complete it before respawn"
1186
1722
  )
1187
- append_phase_history(entry)
1723
+ append_phase_history(entry, phase)
1724
+ if phase in WORKER_SESSION_ARTIFACT_PHASES:
1725
+ entry.pop("invocation_receipt", None)
1726
+ entry.pop("role_argv", None)
1188
1727
  entry["started_at"] = now_iso()
1189
1728
  entry["completed_at"] = None
1190
1729
  entry["duration_ms"] = None
@@ -1195,8 +1734,16 @@ def do_spawn(state: dict, phase: str, round_: int, triggered_by: str | None,
1195
1734
  entry["sub_verdicts"] = None
1196
1735
  if phase == "verify":
1197
1736
  entry["judge_durations_ms"] = None
1737
+ entry.pop("role_evidence", None)
1198
1738
  if engine is not None:
1199
1739
  entry["engine"] = engine
1740
+ elif (
1741
+ state.get("version") == "3.0"
1742
+ and "engine" not in entry
1743
+ and isinstance(state.get("engine"), str)
1744
+ and state["engine"]
1745
+ ):
1746
+ entry["engine"] = state["engine"]
1200
1747
  if model is not None:
1201
1748
  entry["model_requested"] = model
1202
1749
  else:
@@ -1205,9 +1752,10 @@ def do_spawn(state: dict, phase: str, round_: int, triggered_by: str | None,
1205
1752
  entry["model_effective"] = None
1206
1753
  if pre_sha is not None:
1207
1754
  entry["pre_sha"] = pre_sha
1755
+ if prompt_sha256 is not None:
1756
+ entry["prompt_sha256"] = prompt_sha256
1208
1757
  if phase == "surface_close":
1209
1758
  entry["input_patch_sha256"] = input_patch_sha256
1210
- entry["prompt_sha256"] = prompt_sha256
1211
1759
  entry["untracked_before"] = untracked_before
1212
1760
 
1213
1761
 
@@ -1238,21 +1786,49 @@ def do_complete(state: dict, phase: str, verdict: str | None,
1238
1786
  post_sha: str | None, findings_file: str | None, log_file: str | None,
1239
1787
  engine: str | None, model: str | None,
1240
1788
  engine_session_log: str | None = None,
1241
- devlyn: pathlib.Path | None = None) -> str | None:
1789
+ devlyn: pathlib.Path | None = None,
1790
+ work: pathlib.Path | None = None) -> str | None:
1242
1791
  phases = state.setdefault("phases", {})
1243
1792
  entry = phases.get(phase)
1244
1793
  if not isinstance(entry, dict) or not entry.get("started_at"):
1245
1794
  raise SystemExit(f"error: phases.{phase} was never spawned (no started_at) — cannot complete")
1795
+ if phase == "verify" and verdict is not None:
1796
+ raise SystemExit(
1797
+ "error: phases.verify.verdict is owned by verify-merge-findings.py "
1798
+ "--write-state; do not pass --verdict to complete for this phase"
1799
+ )
1800
+ if entry.get("completed_at") is not None:
1801
+ raise SystemExit(
1802
+ f"error: phases.{phase} already completed — respawn before completing again"
1803
+ )
1804
+ if phase != "plan":
1805
+ validate_plan_output(state, devlyn, phase)
1806
+ if phase == "final_report":
1807
+ report_digest = final_report_digest(state, devlyn, log_file)
1808
+ log_file = ".devlyn/final-report.md"
1809
+ if phase == "plan" and entry.get("prompt_sha256") is not None:
1810
+ missing = [field for field in PLAN_SPAWN_RECEIPT_FIELDS if field not in entry]
1811
+ if missing:
1812
+ raise SystemExit(
1813
+ "error: phases.plan spawn receipt is incomplete: " + ",".join(missing)
1814
+ )
1815
+ if engine is not None and engine != entry["engine"]:
1816
+ raise SystemExit("error: phases.plan completion cannot replace spawn engine")
1817
+ if model is not None and model != entry["model_requested"]:
1818
+ raise SystemExit("error: phases.plan completion cannot replace requested model")
1819
+ if state.get("version") == "3.0" and phase != "plan":
1820
+ if engine is not None and engine != entry.get("engine"):
1821
+ raise SystemExit(f"error: phases.{phase} completion cannot replace spawn engine")
1822
+ if model is not None and model != entry.get("model_requested"):
1823
+ raise SystemExit(
1824
+ f"error: phases.{phase} completion cannot replace requested model"
1825
+ )
1826
+ bind_process_evidence(state, phase, verdict, devlyn, work)
1246
1827
  started = parse_iso(entry["started_at"])
1247
1828
  now = now_ms()
1248
1829
  entry["completed_at"] = now_iso(now)
1249
1830
  entry["duration_ms"] = max(0, round((now - started).total_seconds() * 1000))
1250
1831
  if phase == "verify":
1251
- if verdict is not None:
1252
- raise SystemExit(
1253
- "error: phases.verify.verdict is owned by verify-merge-findings.py "
1254
- "--write-state; do not pass --verdict to complete for this phase"
1255
- )
1256
1832
  if entry.get("verdict") is None:
1257
1833
  raise SystemExit(
1258
1834
  "error: phases.verify.verdict is still null — run "
@@ -1262,17 +1838,24 @@ def do_complete(state: dict, phase: str, verdict: str | None,
1262
1838
  entry["verdict"] = verdict
1263
1839
  else:
1264
1840
  raise SystemExit(f"error: --verdict is required to complete phases.{phase}")
1841
+ if phase == "plan":
1842
+ if state.get("version") == "3.0":
1843
+ bind_plan_output(state, devlyn)
1844
+ else:
1845
+ entry.setdefault("output_sha256", None)
1846
+ if phase == "final_report":
1847
+ entry["output_sha256"] = report_digest
1265
1848
  if findings_file is not None or log_file is not None:
1266
1849
  artifacts = entry.setdefault("artifacts", {"findings_file": None, "log_file": None})
1267
1850
  if findings_file is not None:
1268
1851
  artifacts["findings_file"] = findings_file
1269
1852
  if log_file is not None:
1270
1853
  artifacts["log_file"] = log_file
1271
- if engine is not None:
1854
+ if engine is not None and phase != "plan":
1272
1855
  entry["engine"] = engine
1273
- if model is not None:
1856
+ if model is not None and phase != "plan":
1274
1857
  entry["model_requested"] = model
1275
- else:
1858
+ elif phase != "plan":
1276
1859
  entry.setdefault("model_requested", None)
1277
1860
  entry.pop("model", None)
1278
1861
 
@@ -1284,13 +1867,74 @@ def do_complete(state: dict, phase: str, verdict: str | None,
1284
1867
  retained_session = candidate
1285
1868
 
1286
1869
  attestation_error = None
1287
- if engine_session_log is None:
1870
+ if phase == "surface_close":
1871
+ canonical_result = (devlyn or pathlib.Path(".devlyn")) / "surface-close.output.json"
1872
+ if engine_session_log is None or pathlib.Path(engine_session_log).resolve() != canonical_result.resolve():
1873
+ attestation_error = (
1874
+ "BLOCKED:model-attestation-failed: SURFACE_CLOSE requires canonical native JSON "
1875
+ f"via --engine-session-log {canonical_result}"
1876
+ )
1877
+ else:
1878
+ # Select the Claude JSON parser even if the caller used a .jsonl symlink alias.
1879
+ engine_session_log = str(canonical_result)
1880
+ schema_v3_worker = (
1881
+ state.get("version") == "3.0"
1882
+ and artifact_phase is not None
1883
+ and entry.get("engine") == "codex"
1884
+ )
1885
+ if schema_v3_worker and retained_session is None:
1886
+ expected_session = (
1887
+ devlyn / f"{artifact_phase}.worker-session.{entry.get('round')}.jsonl"
1888
+ if devlyn is not None else
1889
+ pathlib.Path(".devlyn") / f"{artifact_phase}.worker-session.{entry.get('round')}.jsonl"
1890
+ )
1891
+ attestation_error = (
1892
+ "BLOCKED:model-attestation-failed: canonical retained worker session is missing: "
1893
+ f"{expected_session}"
1894
+ )
1895
+ if (
1896
+ attestation_error is None
1897
+ and schema_v3_worker
1898
+ and engine_session_log is not None
1899
+ and pathlib.Path(engine_session_log).resolve() != retained_session.resolve()
1900
+ ):
1901
+ attestation_error = (
1902
+ "BLOCKED:model-attestation-failed: --engine-session-log must be the canonical "
1903
+ f"phase/round session {retained_session}"
1904
+ )
1905
+ if attestation_error is not None:
1906
+ entry["model_effective"] = None
1907
+ elif engine_session_log is None:
1288
1908
  entry["model_effective"] = None
1289
1909
  if retained_session is not None:
1290
1910
  attestation_error = (
1291
1911
  "BLOCKED:model-attestation-failed: --engine-session-log is required because "
1292
1912
  f"retained worker session exists: {retained_session}"
1293
1913
  )
1914
+ elif schema_v3_worker:
1915
+ receipt_path = devlyn / f"{artifact_phase}.invocation.{entry.get('round')}.json"
1916
+ try:
1917
+ receipt = invocation_receipt_module().validate_receipt(
1918
+ work.resolve(), receipt_path,
1919
+ run_id=state.get("run_id"),
1920
+ phase=phase,
1921
+ round_=entry.get("round"),
1922
+ model=entry.get("model_requested"),
1923
+ prompt_sha256=entry.get("prompt_sha256"),
1924
+ session_path=retained_session,
1925
+ )
1926
+ if receipt["exit_code"] != 0:
1927
+ raise invocation_receipt_module().ReceiptError(
1928
+ f"Codex invocation exited {receipt['exit_code']}"
1929
+ )
1930
+ role_argv = bind_worker_role_argv(state, phase, entry, devlyn, receipt)
1931
+ if role_argv is not None:
1932
+ entry["role_argv"] = role_argv
1933
+ entry["model_effective"] = None # The receipt binds argv, not an observed model.
1934
+ entry["invocation_receipt"] = receipt
1935
+ except (OSError, UnicodeError, ValueError) as exc:
1936
+ entry["model_effective"] = None
1937
+ attestation_error = f"BLOCKED:invocation-receipt-invalid: {exc}"
1294
1938
  else:
1295
1939
  try:
1296
1940
  entry["model_effective"] = parse_effective_model(pathlib.Path(engine_session_log))
@@ -1333,6 +1977,7 @@ def do_transition(
1333
1977
  next_prompt_sha256: str | None = None,
1334
1978
  next_untracked_before: list[str] | None = None,
1335
1979
  between=None,
1980
+ work: pathlib.Path | None = None,
1336
1981
  ) -> dict:
1337
1982
  """Validate complete + caller-selected spawn against a copy of state.
1338
1983
 
@@ -1346,7 +1991,7 @@ def do_transition(
1346
1991
  candidate = copy.deepcopy(state)
1347
1992
  attestation_error = do_complete(
1348
1993
  candidate, phase, verdict, post_sha, findings_file, log_file,
1349
- engine, model, engine_session_log, devlyn,
1994
+ engine, model, engine_session_log, devlyn, work,
1350
1995
  )
1351
1996
  if attestation_error is not None:
1352
1997
  raise SystemExit(attestation_error)
@@ -1358,6 +2003,7 @@ def do_transition(
1358
2003
  input_patch_sha256=next_input_patch_sha256,
1359
2004
  prompt_sha256=next_prompt_sha256,
1360
2005
  untracked_before=next_untracked_before,
2006
+ devlyn=devlyn,
1361
2007
  )
1362
2008
  return candidate
1363
2009
 
@@ -1377,14 +2023,387 @@ def do_surface_adjudication_recovery(state: dict, devlyn: pathlib.Path) -> str |
1377
2023
  return None
1378
2024
 
1379
2025
 
2026
+ def final_report_self_test() -> None:
2027
+ with tempfile.TemporaryDirectory() as tmp:
2028
+ work = pathlib.Path(tmp)
2029
+ devlyn = work / ".devlyn"
2030
+ devlyn.mkdir()
2031
+ state_path = devlyn / "pipeline.state.json"
2032
+ report = devlyn / "final-report.md"
2033
+ script = pathlib.Path(__file__).resolve()
2034
+ archive = script.with_name("archive_run.py")
2035
+ run_id = "rs-final-report-0126"
2036
+ write_state(state_path, {"version": "3.0", "run_id": run_id, "engine": "claude", "phases": {}})
2037
+ command = [sys.executable, str(script), "--devlyn-dir", ".devlyn", "--phase", "final_report"]
2038
+ spawned = subprocess.run(command + ["spawn", "--round", "0"], cwd=work, capture_output=True, text=True)
2039
+ assert spawned.returncode == 0, spawned.stderr
2040
+ before = state_path.read_bytes()
2041
+ marker = f"<!-- devlyn:final-report run_id={run_id} -->\n"
2042
+ valid = (marker + "# Report\nPASS_WITH_ISSUES — retained LOW finding.\n").encode("utf-8")
2043
+ outside = work / "other.md"
2044
+ outside.write_bytes(valid)
2045
+ cases = (
2046
+ "missing-argument", "missing-file", "empty", "blank-body", "stale",
2047
+ "missing-marker", "duplicate-marker", "invalid-utf8", "directory", "symlink", "wrong-path",
2048
+ )
2049
+ for case in cases:
2050
+ if report.is_dir() and not report.is_symlink():
2051
+ report.rmdir()
2052
+ else:
2053
+ report.unlink(missing_ok=True)
2054
+ args = ["--log-file", ".devlyn/final-report.md"]
2055
+ if case == "missing-argument":
2056
+ args = []
2057
+ elif case == "empty":
2058
+ report.write_bytes(b"")
2059
+ elif case == "blank-body":
2060
+ report.write_text(marker + " \t\n", encoding="utf-8")
2061
+ elif case == "stale":
2062
+ report.write_bytes(valid.replace(run_id.encode(), b"another-run"))
2063
+ elif case == "missing-marker":
2064
+ report.write_bytes(b"# Report\nBLOCKED\n")
2065
+ elif case == "duplicate-marker":
2066
+ report.write_bytes(valid + marker.encode())
2067
+ elif case == "invalid-utf8":
2068
+ report.write_bytes(marker.encode() + b"\xff")
2069
+ elif case == "directory":
2070
+ report.mkdir()
2071
+ elif case == "symlink":
2072
+ report.symlink_to(outside)
2073
+ elif case == "wrong-path":
2074
+ report.write_bytes(valid)
2075
+ args = ["--log-file", str(outside)]
2076
+ result = subprocess.run(command + ["complete", "--verdict", "PASS_WITH_ISSUES", *args],
2077
+ cwd=work, capture_output=True, text=True)
2078
+ assert result.returncode == 1 and "BLOCKED:final-report-invalid" in result.stderr, (case, result)
2079
+ assert state_path.read_bytes() == before, case
2080
+ report.write_bytes(valid)
2081
+ result = subprocess.run(command + ["complete", "--verdict", "PASS_WITH_ISSUES", "--log-file", ".devlyn/final-report.md"],
2082
+ cwd=work, capture_output=True, text=True)
2083
+ assert result.returncode == 0, result.stderr
2084
+ completed_bytes = state_path.read_bytes()
2085
+ completed = read_state(state_path)
2086
+ final = completed["phases"]["final_report"]
2087
+ assert final["completed_at"] is not None and final["verdict"] == "PASS_WITH_ISSUES"
2088
+ assert final["artifacts"]["log_file"] == ".devlyn/final-report.md"
2089
+ assert final["output_sha256"] == hashlib.sha256(valid).hexdigest()
2090
+ archive_command = [sys.executable, str(archive), "--devlyn-dir", ".devlyn"]
2091
+ for case in ("missing", "altered", "null-digest", "malformed-digest", "wrong-path", "symlink"):
2092
+ candidate = copy.deepcopy(completed)
2093
+ report.unlink(missing_ok=True)
2094
+ report.write_bytes(valid)
2095
+ if case == "missing":
2096
+ report.unlink()
2097
+ elif case == "altered":
2098
+ report.write_bytes(valid + b"changed after completion\n")
2099
+ elif case in {"null-digest", "malformed-digest"}:
2100
+ candidate["phases"]["final_report"]["output_sha256"] = None if case == "null-digest" else "invalid"
2101
+ elif case == "wrong-path":
2102
+ candidate["phases"]["final_report"]["artifacts"]["log_file"] = str(outside)
2103
+ else:
2104
+ report.unlink()
2105
+ report.symlink_to(outside)
2106
+ write_state(state_path, candidate)
2107
+ before_archive = state_path.read_bytes()
2108
+ result = subprocess.run(archive_command, cwd=work, capture_output=True, text=True)
2109
+ assert result.returncode == 1 and "error: archive blocked:" in result.stderr, (case, result)
2110
+ assert state_path.read_bytes() == before_archive, case
2111
+ assert not (devlyn / "runs").exists(), case
2112
+ if case != "missing":
2113
+ assert report.is_symlink() if case == "symlink" else report.read_bytes() == (
2114
+ valid + b"changed after completion\n" if case == "altered" else valid
2115
+ ), case
2116
+ report.unlink()
2117
+ report.write_bytes(valid)
2118
+ state_path.write_bytes(completed_bytes)
2119
+ result = subprocess.run(archive_command, cwd=work, capture_output=True, text=True)
2120
+ assert result.returncode == 0, result.stderr
2121
+ target = devlyn / "runs" / run_id
2122
+ assert (target / "final-report.md").read_bytes() == valid
2123
+ assert (target / "pipeline.state.json").read_bytes() == completed_bytes
2124
+ assert not state_path.exists() and not report.exists()
2125
+ assert outside.read_bytes() == valid
2126
+ print("PASS iter-0126 final report: 11 invalid CLI completions preserve state; exact binding/archive; 6 archive refusals preserve artifacts")
2127
+
2128
+
1380
2129
  def self_test() -> int:
1381
2130
  import time
1382
2131
 
2132
+ final_report_self_test()
2133
+ with tempfile.TemporaryDirectory() as tmp:
2134
+ work = pathlib.Path(tmp); devlyn = work / ".devlyn"; devlyn.mkdir()
2135
+ helper = role_config_module()
2136
+ config = {"roles": {"worker": {"engine": "codex", "model": "gpt-6-astra", "effort": "high"},
2137
+ "primary_judge": {"engine": "claude"}}}
2138
+ (devlyn / "engines.json").write_bytes(helper["encoded"](config))
2139
+ state = {"version": "3.0", "engine": "codex", "engine_source": "default", "phases": {}}
2140
+ frozen = freeze_roles(state, work, "codex")
2141
+ (devlyn / "engines.json").write_text('{"executor":"claude"}')
2142
+ assert freeze_roles(state, work, "codex") == frozen
2143
+ do_spawn(state, "verify", 0, None, None, None, None, devlyn=devlyn)
2144
+ assert state["engine"] == "codex" and state["phases"]["verify"]["engine"] == "claude"
2145
+ for phase in ("implement", "cleanup"):
2146
+ before = copy.deepcopy(state)
2147
+ try:
2148
+ do_spawn(state, phase, 0, None, None, "claude", None, devlyn=devlyn)
2149
+ except SystemExit:
2150
+ pass
2151
+ else:
2152
+ raise AssertionError("wrong worker engine accepted")
2153
+ assert state == before
2154
+ argv = ["--json", "-m", "gpt-6-astra", "-c", "model_reasoning_effort=high", "task"]
2155
+ receipt = {"argv_sha256": hashlib.sha256(json.dumps(argv, separators=(",", ":")).encode()).hexdigest()}
2156
+ path = devlyn / "implement.argv.0.json"
2157
+ path.write_text(json.dumps(argv))
2158
+ binding = bind_worker_role_argv(state, "implement", {"round": 0}, devlyn, receipt)
2159
+ assert binding["effort_requested"] == "high"
2160
+ path.write_text(json.dumps([*argv, "extra"]))
2161
+ try:
2162
+ bind_worker_role_argv(state, "implement", {"round": 0}, devlyn, receipt)
2163
+ except ValueError:
2164
+ pass
2165
+ else:
2166
+ raise AssertionError("modified worker argv accepted")
2167
+ assert bind_worker_role_argv(state, "build_gate", {}, devlyn, receipt) is None
2168
+ print("PASS explicit roles: frozen primary/worker routing and actual worker argv binding")
2169
+
2170
+ try:
2171
+ loads_strict_json('{"process_evidence":null,"process_evidence":[]}')
2172
+ except ValueError as exc:
2173
+ assert "duplicate JSON key" in str(exc)
2174
+ else:
2175
+ raise AssertionError("duplicate pipeline state key was accepted")
2176
+
1383
2177
  with tempfile.TemporaryDirectory() as tmp:
1384
2178
  devlyn = pathlib.Path(tmp)
1385
2179
  state_path = devlyn / "pipeline.state.json"
1386
2180
  write_state(state_path, {"phases": {}})
1387
2181
 
2182
+ # Iter-0089 P-0089-1/2/6: PLAN authorization is a CLI-level,
2183
+ # state-derived ledger contract. Rejections must leave the actual
2184
+ # state file byte-identical, not merely preserve an in-memory copy.
2185
+ script = str(pathlib.Path(__file__).resolve())
2186
+ digest0 = hashlib.sha256(b"plan prompt round 0").hexdigest()
2187
+ digest1 = hashlib.sha256(b"plan prompt round 1").hexdigest()
2188
+
2189
+ def plan_cli(*event_args: str) -> subprocess.CompletedProcess[str]:
2190
+ return subprocess.run(
2191
+ [
2192
+ sys.executable, script, "--devlyn-dir", str(devlyn),
2193
+ "--phase", "plan", *event_args,
2194
+ ],
2195
+ capture_output=True, text=True, check=False,
2196
+ )
2197
+
2198
+ def assert_plan_rejected_unchanged(
2199
+ expected_stderr: str, *event_args: str,
2200
+ ) -> None:
2201
+ before = state_path.read_bytes()
2202
+ before_hash = hashlib.sha256(before).hexdigest()
2203
+ result = plan_cli(*event_args)
2204
+ assert result.returncode != 0, result.stdout
2205
+ assert result.stderr.strip() == expected_stderr, result.stderr
2206
+ after = state_path.read_bytes()
2207
+ assert after == before
2208
+ assert hashlib.sha256(after).hexdigest() == before_hash
2209
+
2210
+ required_spawn = (
2211
+ "spawn", "--round", "0", "--engine", "claude",
2212
+ "--model", "plan-test-model", "--prompt-sha256", digest0,
2213
+ )
2214
+ assert_plan_rejected_unchanged(
2215
+ "error: phases.plan spawn requires --engine",
2216
+ "spawn", "--round", "0", "--model", "plan-test-model",
2217
+ "--prompt-sha256", digest0,
2218
+ )
2219
+ assert_plan_rejected_unchanged(
2220
+ "error: phases.plan spawn requires --model",
2221
+ "spawn", "--round", "0", "--engine", "claude",
2222
+ "--prompt-sha256", digest0,
2223
+ )
2224
+ assert_plan_rejected_unchanged(
2225
+ "error: phases.plan spawn requires --prompt-sha256",
2226
+ "spawn", "--round", "0", "--engine", "claude",
2227
+ "--model", "plan-test-model",
2228
+ )
2229
+ assert_plan_rejected_unchanged(
2230
+ "error: phases.plan spawn requires --prompt-sha256",
2231
+ "spawn", "--round", "0", "--engine", "claude",
2232
+ "--model", "plan-test-model", "--prompt-sha256", "ABC",
2233
+ )
2234
+ assert_plan_rejected_unchanged(
2235
+ "BLOCKED:plan-round-nonmonotonic: expected=0 supplied=1",
2236
+ "spawn", "--round", "1", "--engine", "claude",
2237
+ "--model", "plan-test-model", "--prompt-sha256", digest0,
2238
+ )
2239
+
2240
+ result = plan_cli(*required_spawn)
2241
+ assert result.returncode == 0, result.stderr
2242
+ plan0 = read_state(state_path)["phases"]["plan"]
2243
+ assert {field: plan0[field] for field in PLAN_SPAWN_RECEIPT_FIELDS} == {
2244
+ "round": 0,
2245
+ "started_at": plan0["started_at"],
2246
+ "triggered_by": None,
2247
+ "engine": "claude",
2248
+ "model_requested": "plan-test-model",
2249
+ "prompt_sha256": digest0,
2250
+ }
2251
+ result = plan_cli("complete", "--verdict", "NEEDS_WORK")
2252
+ assert result.returncode == 0, result.stderr
2253
+ completed0 = read_state(state_path)["phases"]["plan"]
2254
+ assert all(field in completed0 for field in PLAN_RECEIPT_FIELDS)
2255
+ assert completed0["model_effective"] is None
2256
+ assert completed0["completed_at"] is not None
2257
+ assert completed0["duration_ms"] >= 0
2258
+ assert_plan_rejected_unchanged(
2259
+ "BLOCKED:plan-round-nonmonotonic: expected=1 supplied=0",
2260
+ "spawn", "--round", "0", "--triggered-by", "plan",
2261
+ "--engine", "claude", "--model", "plan-test-model",
2262
+ "--prompt-sha256", digest1,
2263
+ )
2264
+ assert_plan_rejected_unchanged(
2265
+ "BLOCKED:plan-round-nonmonotonic: expected=1 supplied=2",
2266
+ "spawn", "--round", "2", "--triggered-by", "plan",
2267
+ "--engine", "claude", "--model", "plan-test-model",
2268
+ "--prompt-sha256", digest1,
2269
+ )
2270
+ assert_plan_rejected_unchanged(
2271
+ "error: phases.plan re-spawn requires --triggered-by",
2272
+ "spawn", "--round", "1", "--engine", "claude",
2273
+ "--model", "plan-test-model", "--prompt-sha256", digest1,
2274
+ )
2275
+ result = plan_cli(
2276
+ "spawn", "--round", "1", "--triggered-by", "plan",
2277
+ "--engine", "claude", "--model", "plan-test-model",
2278
+ "--prompt-sha256", digest1,
2279
+ )
2280
+ assert result.returncode == 0, result.stderr
2281
+ plan1 = read_state(state_path)["phases"]["plan"]
2282
+ assert len(plan1["history"]) == 1
2283
+ assert set(plan1["history"][0]) == set(PLAN_RECEIPT_FIELDS)
2284
+ assert plan1["history"][0]["prompt_sha256"] == digest0
2285
+ assert plan1["prompt_sha256"] == digest1
2286
+ guard_error = (
2287
+ "error: phases.plan complete with PASS or PASS_WITH_ISSUES requires "
2288
+ "--phase plan transition --verdict <verdict> --next-phase <phase>"
2289
+ )
2290
+ assert_plan_rejected_unchanged(
2291
+ guard_error, "complete", "--verdict", "PASS",
2292
+ )
2293
+ assert_plan_rejected_unchanged(
2294
+ guard_error, "complete", "--verdict", "PASS_WITH_ISSUES",
2295
+ )
2296
+ result = plan_cli(
2297
+ "transition", "--verdict", "PASS", "--next-phase", "implement",
2298
+ "--next-round", "0", "--next-engine", "claude",
2299
+ )
2300
+ assert result.returncode == 0, result.stderr
2301
+ for supplied_round in (0, 1, 2):
2302
+ assert_plan_rejected_unchanged(
2303
+ "BLOCKED:plan-respawn-exhausted",
2304
+ "spawn", "--round", str(supplied_round),
2305
+ "--triggered-by", "plan", "--engine", "claude",
2306
+ "--model", "plan-test-model", "--prompt-sha256", digest1,
2307
+ )
2308
+ old_receipt = {
2309
+ "started_at": "2026-01-01T00:00:00.000Z",
2310
+ "verdict": "PASS",
2311
+ "completed_at": "2026-01-01T00:00:01.000Z",
2312
+ "duration_ms": 1000,
2313
+ }
2314
+ assert not is_plan_dispatch_receipt(old_receipt)
2315
+ assert is_plan_dispatch_receipt(read_state(state_path)["phases"]["plan"])
2316
+
2317
+ # Iter-0112: schema-v3 PLAN output is an immutable authority receipt.
2318
+ plan_output = devlyn / "plan.md"
2319
+ plan_output.write_text("# Authorized plan\n- config/skills/x.py\n", encoding="utf-8")
2320
+ write_state(state_path, {"version": "3.0", "phases": {}})
2321
+ result = plan_cli(*required_spawn)
2322
+ assert result.returncode == 0, result.stderr
2323
+ result = plan_cli(
2324
+ "transition", "--verdict", "PASS", "--next-phase", "implement",
2325
+ "--next-round", "0", "--next-engine", "claude",
2326
+ )
2327
+ assert result.returncode == 0, result.stderr
2328
+ sealed_plan_state = read_state(state_path)
2329
+ assert sealed_plan_state["phases"]["plan"]["output_sha256"] == hashlib.sha256(
2330
+ plan_output.read_bytes()
2331
+ ).hexdigest()
2332
+ plan_output.write_text(
2333
+ "# Authorized plan\n- config/skills/x.py\n- unapproved.py\n",
2334
+ encoding="utf-8",
2335
+ )
2336
+ before = state_path.read_bytes()
2337
+ blocked_plan_mutation = subprocess.run(
2338
+ [
2339
+ sys.executable, script, "--devlyn-dir", str(devlyn),
2340
+ "--phase", "build_gate", "spawn", "--round", "0",
2341
+ ],
2342
+ capture_output=True, text=True, check=False,
2343
+ )
2344
+ assert blocked_plan_mutation.returncode != 0
2345
+ assert "BLOCKED:plan-integrity-mismatch" in blocked_plan_mutation.stderr
2346
+ assert state_path.read_bytes() == before
2347
+ assert_plan_rejected_unchanged(
2348
+ "error: phases.plan already completed — respawn before completing again",
2349
+ "complete", "--verdict", "NEEDS_WORK",
2350
+ )
2351
+ print("PASS iter-0112 PLAN output digest blocks mid-flight widening")
2352
+
2353
+ write_state(state_path, {
2354
+ "phases": {
2355
+ "plan": {
2356
+ "started_at": "2026-01-01T00:00:00.000Z",
2357
+ "completed_at": None,
2358
+ "duration_ms": None,
2359
+ "round": 0,
2360
+ "triggered_by": None,
2361
+ "verdict": None,
2362
+ },
2363
+ "implement": None,
2364
+ }
2365
+ })
2366
+ result = plan_cli("complete", "--verdict", "BLOCKED")
2367
+ assert result.returncode == 0, result.stderr
2368
+ assert read_state(state_path)["phases"]["plan"]["verdict"] == "BLOCKED"
2369
+ write_state(state_path, {
2370
+ "phases": {
2371
+ "plan": {
2372
+ "started_at": "2026-01-01T00:00:00.000Z",
2373
+ "completed_at": None,
2374
+ "duration_ms": None,
2375
+ "round": 0,
2376
+ "triggered_by": None,
2377
+ "verdict": None,
2378
+ },
2379
+ "implement": None,
2380
+ }
2381
+ })
2382
+ result = plan_cli(
2383
+ "complete", "--verdict", "PASS", "--engine-session-log",
2384
+ str(devlyn / "missing-session.jsonl"),
2385
+ )
2386
+ assert result.returncode == 1, result.stderr
2387
+ assert "BLOCKED:model-attestation-failed" in result.stderr
2388
+ assert guard_error not in result.stderr
2389
+ attestation_blocked = read_state(state_path)["phases"]["plan"]
2390
+ assert attestation_blocked["verdict"] == "BLOCKED"
2391
+ assert attestation_blocked["completed_at"] is not None
2392
+ assert attestation_blocked["model_effective"] is None
2393
+ legacy_state = {"phases": {"plan": old_receipt | {"round": 0}}}
2394
+ write_state(state_path, legacy_state)
2395
+ result = plan_cli(
2396
+ "spawn", "--round", "1", "--triggered-by", "plan",
2397
+ "--engine", "claude", "--model", "plan-test-model",
2398
+ "--prompt-sha256", digest1,
2399
+ )
2400
+ assert result.returncode == 0, result.stderr
2401
+ archived_legacy = read_state(state_path)["phases"]["plan"]["history"][0]
2402
+ assert archived_legacy == old_receipt
2403
+ print("PASS iter-0089 PLAN ledger: P-0089-1/2/6")
2404
+
2405
+ write_state(state_path, {"phases": {}})
2406
+
1388
2407
  # Round 0: spawn -> complete.
1389
2408
  state = read_state(state_path)
1390
2409
  do_spawn(state, "implement", 0, None, None, "claude", None)
@@ -1430,15 +2449,552 @@ def self_test() -> int:
1430
2449
  write_state(state_path, state)
1431
2450
  final = read_state(state_path)["phases"]["implement"]
1432
2451
  assert final["verdict"] == "PASS"
2452
+ assert read_state(state_path)["process_evidence"] is None
1433
2453
  assert parse_iso(final["started_at"]) == parse_iso(respawned["started_at"])
1434
2454
  assert parse_iso(final["completed_at"]) >= parse_iso(final["started_at"])
1435
2455
  assert final["artifacts"]["findings_file"] == ".devlyn/x.jsonl"
1436
2456
 
1437
- # complete() before spawn() must fail loudly, not silently invent data.
1438
- write_state(state_path, {"phases": {}})
1439
- state = read_state(state_path)
1440
- try:
1441
- do_complete(state, "build_gate", "PASS", None, None, None, None, None)
2457
+ # Iter-0111 R2: successful IMPLEMENT completion and transition both
2458
+ # validate declared evidence before any lifecycle mutation, then bind
2459
+ # the exact manifest and stream digests into state. Legacy runs with no
2460
+ # declaration remain legal through the null carrier asserted above.
2461
+ evidence_work = devlyn / "process-evidence-state"
2462
+ evidence_devlyn = evidence_work / ".devlyn"
2463
+ evidence_spec_dir = evidence_work / "docs" / "evidence"
2464
+ evidence_devlyn.mkdir(parents=True)
2465
+ evidence_spec_dir.mkdir(parents=True)
2466
+ (evidence_spec_dir / "spec.md").write_text("# Evidence fixture\n", encoding="utf-8")
2467
+ (evidence_spec_dir / "spec.expected.json").write_text(json.dumps({
2468
+ "verification_commands": [{"cmd": "printf build-gate"}],
2469
+ "process_evidence": [{
2470
+ "id": "red-first",
2471
+ "phase": "implement",
2472
+ "cmd": "printf red-before-fix >&2; exit 7",
2473
+ "exit_code": 7,
2474
+ "stdout_contains": ["red-before-fix"],
2475
+ }],
2476
+ }) + "\n", encoding="utf-8")
2477
+ evidence_state = {
2478
+ "run_id": "rs-state-evidence",
2479
+ "source": {"type": "spec", "spec_path": "docs/evidence/spec.md"},
2480
+ "phases": {"implement": None, "build_gate": None},
2481
+ }
2482
+ do_spawn(evidence_state, "implement", 0, None, None, "codex", None)
2483
+ evidence_state_path = evidence_devlyn / "pipeline.state.json"
2484
+ write_state(evidence_state_path, evidence_state)
2485
+ evidence_script = str(pathlib.Path(__file__).resolve())
2486
+
2487
+ def evidence_state_cli(event: str, *event_args: str) -> subprocess.CompletedProcess[str]:
2488
+ return subprocess.run(
2489
+ [
2490
+ sys.executable, evidence_script, "--devlyn-dir", ".devlyn",
2491
+ "--phase", "implement", event, *event_args,
2492
+ ],
2493
+ cwd=evidence_work, capture_output=True, text=True, check=False,
2494
+ )
2495
+
2496
+ for event_args in (
2497
+ ("complete", "--verdict", "PASS"),
2498
+ (
2499
+ "transition", "--verdict", "PASS", "--next-phase", "build_gate",
2500
+ "--next-round", "0",
2501
+ ),
2502
+ ):
2503
+ before = evidence_state_path.read_bytes()
2504
+ result = evidence_state_cli(event_args[0], *event_args[1:])
2505
+ assert result.returncode != 0
2506
+ assert "BLOCKED:process-evidence-invalid" in result.stderr
2507
+ assert "missing" in result.stderr
2508
+ assert evidence_state_path.read_bytes() == before
2509
+
2510
+ runner_script = str(pathlib.Path(__file__).with_name("process-evidence.py").resolve())
2511
+ captured = subprocess.run(
2512
+ [
2513
+ sys.executable, runner_script, "--devlyn-dir", ".devlyn", "run",
2514
+ "--phase", "implement", "--id", "red-first",
2515
+ ],
2516
+ cwd=evidence_work, capture_output=True, text=True, check=False,
2517
+ )
2518
+ assert captured.returncode == 0, captured.stderr
2519
+ manifest_rel = loads_strict_json(captured.stdout)["manifest_path"]
2520
+ manifest = evidence_work / manifest_rel
2521
+ stderr_path = manifest.parent / "red-first.stderr"
2522
+ original_stderr = stderr_path.read_bytes()
2523
+ stderr_path.write_bytes(b"altered")
2524
+ before = evidence_state_path.read_bytes()
2525
+ altered = evidence_state_cli("complete", "--verdict", "PASS")
2526
+ assert altered.returncode != 0
2527
+ assert "digest or byte count mismatch" in altered.stderr
2528
+ assert evidence_state_path.read_bytes() == before
2529
+ stderr_path.write_bytes(original_stderr)
2530
+ transitioned = evidence_state_cli(
2531
+ "transition", "--verdict", "PASS", "--next-phase", "build_gate",
2532
+ "--next-round", "0",
2533
+ )
2534
+ assert transitioned.returncode == 0, transitioned.stderr
2535
+ sealed_state = read_state(evidence_state_path)
2536
+ carrier = sealed_state["process_evidence"][0]
2537
+ assert carrier["phase"] == "implement" and carrier["round"] == 0
2538
+ assert carrier["manifest"]["path"] == manifest_rel
2539
+ assert carrier["manifest"]["sha256"] == hashlib.sha256(manifest.read_bytes()).hexdigest()
2540
+ assert sealed_state["phases"]["implement"]["verdict"] == "PASS"
2541
+ assert sealed_state["phases"]["build_gate"]["started_at"] is not None
2542
+ print("PASS iter-0111 process evidence: completion/transition gate and state digest binding")
2543
+
2544
+ # Gate-discovered iter-0111 regression: BUILD_GATE MECHANICAL emitted
2545
+ # sealed process evidence, but completion left it outside the state
2546
+ # binding and archive_run.py correctly rejected the orphaned files.
2547
+ def build_gate_state_cli(
2548
+ event: str, *event_args: str,
2549
+ ) -> subprocess.CompletedProcess[str]:
2550
+ return subprocess.run(
2551
+ [
2552
+ sys.executable, evidence_script, "--devlyn-dir", ".devlyn",
2553
+ "--phase", "build_gate", event, *event_args,
2554
+ ],
2555
+ cwd=evidence_work, capture_output=True, text=True, check=False,
2556
+ )
2557
+
2558
+ results_path = evidence_devlyn / "spec-verify.results.json"
2559
+ results_path.write_text(json.dumps({
2560
+ "commands": [{"pass": True}], "process_evidence": None,
2561
+ }) + "\n", encoding="utf-8")
2562
+ before = evidence_state_path.read_bytes()
2563
+ missing = build_gate_state_cli("complete", "--verdict", "PASS")
2564
+ assert missing.returncode != 0
2565
+ assert "commands exist without a process-evidence carrier" in missing.stderr
2566
+ assert evidence_state_path.read_bytes() == before
2567
+ results_path.write_text(json.dumps({
2568
+ "commands": [], "process_evidence": None,
2569
+ }) + "\n", encoding="utf-8")
2570
+ removed = build_gate_state_cli("complete", "--verdict", "PASS")
2571
+ assert removed.returncode != 0
2572
+ assert "required BUILD_GATE evidence has no process-evidence carrier" in removed.stderr
2573
+ assert evidence_state_path.read_bytes() == before
2574
+
2575
+ runner = process_evidence_module()
2576
+
2577
+ def write_build_gate_results() -> dict:
2578
+ current = read_state(evidence_state_path)
2579
+ round_ = runner.phase_round(current, "build_gate")
2580
+ obligation = runner.normalize_obligation({
2581
+ "id": "verification-command-0001",
2582
+ "phase": "build_gate",
2583
+ "argv": [sys.executable, "-c", "print('sealed build gate')"],
2584
+ })
2585
+ relative = runner.manifest_relative_path(current, "build_gate")
2586
+ runner.capture_process(
2587
+ evidence_work, evidence_work / relative, current["run_id"],
2588
+ "build_gate", round_, obligation,
2589
+ )
2590
+ build_carrier = runner.validate_manifest(
2591
+ evidence_work, relative, current["run_id"], "build_gate", round_,
2592
+ [obligation], require_expectations=False,
2593
+ )
2594
+ results_path.write_text(json.dumps({
2595
+ "commands": runner.bound_carrier_summary_commands(
2596
+ evidence_work, build_carrier,
2597
+ ),
2598
+ "process_evidence": build_carrier,
2599
+ }) + "\n", encoding="utf-8")
2600
+ return build_carrier
2601
+
2602
+ build_carrier_0 = write_build_gate_results()
2603
+ completed = build_gate_state_cli("complete", "--verdict", "PASS")
2604
+ assert completed.returncode == 0, completed.stderr
2605
+ completed_state = read_state(evidence_state_path)
2606
+ assert completed_state["process_evidence"] == [carrier, build_carrier_0]
2607
+
2608
+ spawned = build_gate_state_cli(
2609
+ "spawn", "--round", "1", "--triggered-by", "build_gate",
2610
+ )
2611
+ assert spawned.returncode == 0, spawned.stderr
2612
+ build_carrier_1 = write_build_gate_results()
2613
+ transitioned = build_gate_state_cli(
2614
+ "transition", "--verdict", "PASS", "--next-phase", "cleanup",
2615
+ "--next-round", "0",
2616
+ )
2617
+ assert transitioned.returncode == 0, transitioned.stderr
2618
+ build_bound_state = read_state(evidence_state_path)
2619
+ assert build_bound_state["process_evidence"] == [
2620
+ carrier, build_carrier_0, build_carrier_1,
2621
+ ]
2622
+ assert build_bound_state["phases"]["cleanup"]["started_at"] is not None
2623
+ print("PASS iter-0111 BUILD_GATE evidence: completion/transition state binding")
2624
+
2625
+ # Iter-0112 audit counterexample: a valid carrier previously authenticated
2626
+ # bytes only, so a failed entry or capability denial could still be completed
2627
+ # with caller-supplied PASS.
2628
+ build_bound_state["phases"]["build_gate"] = None
2629
+ do_spawn(build_bound_state, "build_gate", 2, "build_gate", None, None, None)
2630
+ write_state(evidence_state_path, build_bound_state)
2631
+ mismatch = runner.normalize_obligation({
2632
+ "id": "verification-command-0001",
2633
+ "phase": "build_gate",
2634
+ "argv": [sys.executable, "-c", "raise SystemExit(7)"],
2635
+ })
2636
+ mismatch_rel = runner.manifest_relative_path(build_bound_state, "build_gate")
2637
+ runner.capture_process(
2638
+ evidence_work, evidence_work / mismatch_rel, build_bound_state["run_id"],
2639
+ "build_gate", 2, mismatch,
2640
+ )
2641
+ mismatch_carrier = runner.validate_manifest(
2642
+ evidence_work, mismatch_rel, build_bound_state["run_id"], "build_gate", 2,
2643
+ [mismatch], require_expectations=False,
2644
+ )
2645
+ mismatch_commands = runner.bound_carrier_summary_commands(
2646
+ evidence_work, mismatch_carrier,
2647
+ )
2648
+ mismatch_commands[0]["pass"] = True
2649
+ results_path.write_text(json.dumps({
2650
+ "commands": mismatch_commands, "process_evidence": mismatch_carrier,
2651
+ }) + "\n", encoding="utf-8")
2652
+ before = evidence_state_path.read_bytes()
2653
+ laundered = build_gate_state_cli("complete", "--verdict", "PASS")
2654
+ assert laundered.returncode != 0
2655
+ assert "command 0 disagrees with sealed process evidence" in laundered.stderr
2656
+ assert evidence_state_path.read_bytes() == before
2657
+ results_path.write_text(json.dumps({
2658
+ "commands": [], "process_evidence": None,
2659
+ }) + "\n", encoding="utf-8")
2660
+ removed_after_capture = build_gate_state_cli("complete", "--verdict", "PASS")
2661
+ assert removed_after_capture.returncode != 0
2662
+ assert "manifest exists without a process-evidence carrier" in removed_after_capture.stderr
2663
+ assert evidence_state_path.read_bytes() == before
2664
+ results_path.write_text(json.dumps({
2665
+ "commands": runner.bound_carrier_summary_commands(
2666
+ evidence_work, mismatch_carrier,
2667
+ ),
2668
+ "process_evidence": mismatch_carrier,
2669
+ }) + "\n", encoding="utf-8")
2670
+ failed = build_gate_state_cli("complete", "--verdict", "FAIL")
2671
+ assert failed.returncode == 0, failed.stderr
2672
+
2673
+ failed_state = read_state(evidence_state_path)
2674
+ do_spawn(failed_state, "build_gate", 3, "build_gate", None, None, None)
2675
+ write_state(evidence_state_path, failed_state)
2676
+ denied = runner.normalize_obligation({
2677
+ "id": "verification-command-0001",
2678
+ "phase": "build_gate",
2679
+ "cmd": "python3 -m pytest",
2680
+ })
2681
+ denied_rel = runner.manifest_relative_path(failed_state, "build_gate")
2682
+ runner.record_capability_denial(
2683
+ evidence_work, evidence_work / denied_rel, failed_state["run_id"],
2684
+ "build_gate", 3, denied, "subprocess", b"parent denied",
2685
+ )
2686
+ denied_carrier = runner.validate_manifest(
2687
+ evidence_work, denied_rel, failed_state["run_id"], "build_gate", 3,
2688
+ [denied], require_expectations=False,
2689
+ )
2690
+ denied_commands = runner.bound_carrier_summary_commands(
2691
+ evidence_work, denied_carrier,
2692
+ )
2693
+ denied_commands[0]["pass"] = True
2694
+ results_path.write_text(json.dumps({
2695
+ "commands": denied_commands, "process_evidence": denied_carrier,
2696
+ }) + "\n", encoding="utf-8")
2697
+ before = evidence_state_path.read_bytes()
2698
+ denial_laundered = build_gate_state_cli("complete", "--verdict", "PASS")
2699
+ assert denial_laundered.returncode != 0
2700
+ assert "command 0 disagrees with sealed process evidence" in denial_laundered.stderr
2701
+ assert evidence_state_path.read_bytes() == before
2702
+ results_path.write_text(json.dumps({
2703
+ "commands": runner.bound_carrier_summary_commands(
2704
+ evidence_work, denied_carrier,
2705
+ ),
2706
+ "process_evidence": denied_carrier,
2707
+ }) + "\n", encoding="utf-8")
2708
+ blocked = build_gate_state_cli("complete", "--verdict", "BLOCKED")
2709
+ assert blocked.returncode == 0, blocked.stderr
2710
+ print("PASS iter-0112 BUILD_GATE sealed outcome owns the verdict floor")
2711
+
2712
+ def test_interrupted_build_gate() -> None:
2713
+ # Iter-0119 R1-R5: interrupted observations bind only to BLOCKED,
2714
+ # without changing their bytes or bypassing archive/receipt guards.
2715
+ work = (devlyn / "interrupted-build-gate").resolve()
2716
+ active = work / ".devlyn"
2717
+ active.mkdir(parents=True)
2718
+ state_file = active / "pipeline.state.json"
2719
+ summary = active / "spec-verify.results.json"
2720
+ runner = process_evidence_module()
2721
+ archive_spec = importlib.util.spec_from_file_location(
2722
+ "interrupted_archive", pathlib.Path(__file__).with_name("archive_run.py"),
2723
+ )
2724
+ assert archive_spec is not None and archive_spec.loader is not None
2725
+ archive = importlib.util.module_from_spec(archive_spec)
2726
+ archive_spec.loader.exec_module(archive)
2727
+ fixture = {
2728
+ "version": "3.0", "run_id": "rs-interrupted-build-gate",
2729
+ "source": {"type": "spec", "spec_path": "spec.md"},
2730
+ "phases": {"build_gate": None, "final_report": None},
2731
+ "process_evidence": None,
2732
+ }
2733
+ do_spawn(fixture, "build_gate", 0, None, None, "claude", None)
2734
+ write_state(state_file, fixture)
2735
+ open_bytes = state_file.read_bytes()
2736
+ relative = runner.manifest_relative_path(fixture, "build_gate")
2737
+ manifest = work / relative
2738
+ obligations = [
2739
+ runner.normalize_obligation({
2740
+ "id": f"verification-command-{index:04d}", "phase": "build_gate",
2741
+ "argv": [sys.executable, "-c", (
2742
+ "import os; os.write(1, b'observed\\x00\\xff\\n'); "
2743
+ f"os.write(2, b'diagnostic\\r\\n'); raise SystemExit({exit_code})"
2744
+ )],
2745
+ })
2746
+ for index, exit_code in enumerate((0, 7, 0), 1)
2747
+ ]
2748
+ (work / "spec.md").write_text("# Interrupted build fixture\n", encoding="utf-8")
2749
+ (work / "spec.expected.json").write_text(json.dumps({
2750
+ "process_evidence": obligations,
2751
+ }), encoding="utf-8")
2752
+ assert runner.declared_obligations(work, fixture, "build_gate") == obligations
2753
+ for obligation in obligations[:2]:
2754
+ runner.capture_process(
2755
+ work, manifest, fixture["run_id"], "build_gate", 0, obligation,
2756
+ )
2757
+ manifest_bytes = manifest.read_bytes()
2758
+ document = loads_strict_json(manifest_bytes.decode("utf-8"))
2759
+ assert [entry["expectation_met"] for entry in document["entries"]] == [True, False]
2760
+ assert [entry["outcome"] for entry in document["entries"]] == [
2761
+ {"kind": "exit", "exit_code": code, "signal": None} for code in (0, 7)
2762
+ ]
2763
+ observed = {path: path.read_bytes() for path in manifest.parent.iterdir()}
2764
+ assert set(observed) == {manifest} | {
2765
+ manifest.parent / f"verification-command-{index:04d}.{stream}"
2766
+ for index in (1, 2) for stream in ("stdout", "stderr")
2767
+ }
2768
+ carrier = runner.validate_manifest(
2769
+ work, relative, fixture["run_id"], "build_gate", 0,
2770
+ require_expectations=False,
2771
+ )
2772
+ cli_env = {key: value for key, value in os.environ.items()
2773
+ if not key.startswith("DEVLYN_INVOCATION_")}
2774
+ cli_env["PYTHONDONTWRITEBYTECODE"] = "1"
2775
+
2776
+ def cli(*args: str) -> subprocess.CompletedProcess[str]:
2777
+ return subprocess.run(
2778
+ [sys.executable, str(pathlib.Path(__file__).resolve()),
2779
+ "--devlyn-dir", ".devlyn", "--phase", "build_gate", *args],
2780
+ cwd=work, env=cli_env, capture_output=True, text=True, check=False,
2781
+ )
2782
+
2783
+ def rejected(*args: str, error: str = "BLOCKED:process-evidence-invalid") -> None:
2784
+ before = state_file.read_bytes()
2785
+ result = cli(*args)
2786
+ assert result.returncode != 0, result.stdout
2787
+ assert error in result.stderr, result.stderr
2788
+ assert state_file.read_bytes() == before
2789
+
2790
+ def archived(state: dict) -> None:
2791
+ paths = archive.dynamic_evidence_artifacts(active, state)
2792
+ assert set(paths) == set(observed)
2793
+ assert {path: path.read_bytes() for path in paths} == observed
2794
+ assert {path: path.read_bytes() for path in manifest.parent.iterdir()} == observed
2795
+
2796
+ def terminal() -> dict:
2797
+ state = read_state(state_file)
2798
+ entry = state["phases"]["build_gate"]
2799
+ assert entry["verdict"] == "BLOCKED"
2800
+ assert entry["completed_at"] is not None and entry["duration_ms"] >= 0
2801
+ assert entry["model_effective"] is None
2802
+ assert entry.get("invocation_receipt") is None
2803
+ assert state["phases"]["final_report"] is None
2804
+ assert state["process_evidence"] == [carrier]
2805
+ assert not summary.exists() and not summary.is_symlink()
2806
+ archived(state)
2807
+ return state
2808
+
2809
+ completed = cli("complete", "--verdict", "BLOCKED")
2810
+ assert completed.returncode == 0, completed.stderr
2811
+ bound = terminal()
2812
+ print("PASS iter-0119 absent-summary BLOCKED persists exact five-file evidence")
2813
+
2814
+ for control in ("unbound", "tampered", "manifest", "binding", "malformed", "duplicate"):
2815
+ candidate = copy.deepcopy(bound)
2816
+ extra = manifest.parent / "unexpected.stdout"
2817
+ stream = manifest.parent / "verification-command-0001.stdout"
2818
+ if control == "unbound":
2819
+ extra.write_bytes(b"unbound")
2820
+ elif control == "tampered":
2821
+ stream.write_bytes(b"tampered")
2822
+ elif control == "manifest":
2823
+ manifest.write_text("{", encoding="utf-8")
2824
+ elif control == "malformed":
2825
+ candidate["process_evidence"] = [{}]
2826
+ elif control == "duplicate":
2827
+ candidate["process_evidence"].append(copy.deepcopy(carrier))
2828
+ else:
2829
+ candidate["process_evidence"][0]["manifest"]["sha256"] = "0" * 64
2830
+ try:
2831
+ archive.dynamic_evidence_artifacts(active, candidate)
2832
+ except archive.ArchiveError as exc:
2833
+ if control == "duplicate":
2834
+ assert "duplicate state-bound process-evidence" in str(exc), str(exc)
2835
+ else:
2836
+ assert ("unbound process-evidence" if control == "unbound" else
2837
+ "invalid bound process evidence") in str(exc), str(exc)
2838
+ else:
2839
+ raise AssertionError(f"archive accepted {control}")
2840
+ extra.unlink(missing_ok=True)
2841
+ stream.write_bytes(observed[stream])
2842
+ manifest.write_bytes(manifest_bytes)
2843
+ archived(bound)
2844
+
2845
+ state_file.write_bytes(open_bytes)
2846
+ for verdict in ("PASS", "PASS_WITH_ISSUES", "NEEDS_WORK", "FAIL"):
2847
+ rejected("complete", "--verdict", verdict, error="spec-verify.results.json is missing")
2848
+ rejected("complete", error="spec-verify.results.json is missing")
2849
+ manifest.unlink()
2850
+ rejected("complete", "--verdict", "BLOCKED")
2851
+ manifest.write_text("{", encoding="utf-8")
2852
+ rejected("complete", "--verdict", "BLOCKED")
2853
+ for control in ("run_id", "phase", "round", "expectation", "expectation_met",
2854
+ "duplicate", "path", "bytes", "sha256"):
2855
+ altered = copy.deepcopy(document)
2856
+ entry = altered["entries"][1]
2857
+ if control in {"run_id", "phase", "round"}:
2858
+ altered[control] = {"run_id": "rs-wrong", "phase": "implement", "round": 1}[control]
2859
+ elif control == "expectation":
2860
+ entry["expectation"]["exit_code"] = "zero"
2861
+ elif control == "expectation_met":
2862
+ entry[control] = True
2863
+ elif control == "duplicate":
2864
+ altered["entries"].append(copy.deepcopy(entry))
2865
+ else:
2866
+ entry["stdout"][control] = {
2867
+ "path": "../escaped.stdout", "bytes": entry["stdout"]["bytes"] + 1,
2868
+ "sha256": "0" * 64,
2869
+ }[control]
2870
+ manifest.write_text(json.dumps(altered), encoding="utf-8")
2871
+ rejected("complete", "--verdict", "BLOCKED")
2872
+ manifest.write_bytes(manifest_bytes)
2873
+ stream = manifest.parent / "verification-command-0001.stdout"
2874
+ stream.write_bytes(b"tampered")
2875
+ rejected("complete", "--verdict", "BLOCKED")
2876
+ stream.write_bytes(observed[stream])
2877
+ outside = devlyn / "escaped-evidence"
2878
+ for path in (manifest, manifest.parent / "verification-command-0001.stdout"):
2879
+ outside.write_bytes(observed[path])
2880
+ path.unlink()
2881
+ path.symlink_to(outside)
2882
+ rejected("complete", "--verdict", "BLOCKED")
2883
+ path.unlink()
2884
+ path.write_bytes(observed[path])
2885
+ bad_prior = copy.deepcopy(carrier)
2886
+ bad_prior["manifest"]["sha256"] = "0" * 64
2887
+ for invalid, error in (
2888
+ ({}, "state.process_evidence must be null or an array"),
2889
+ ([{}], "state process-evidence carrier has an invalid shape"),
2890
+ ([bad_prior], "bound process evidence manifest digest mismatch"),
2891
+ ([carrier], "duplicate state carrier"),
2892
+ ):
2893
+ candidate = copy.deepcopy(fixture)
2894
+ candidate["process_evidence"] = invalid
2895
+ write_state(state_file, candidate)
2896
+ rejected("complete", "--verdict", "BLOCKED", error=error)
2897
+ state_file.write_bytes(open_bytes)
2898
+
2899
+ commands = runner.bound_carrier_summary_commands(work, carrier)
2900
+ wrong_commands = copy.deepcopy(commands)
2901
+ wrong_commands[1]["pass"] = True
2902
+ wrong_carrier = copy.deepcopy(carrier)
2903
+ wrong_carrier["round"] = 1
2904
+ for content in (
2905
+ "{", "[]", json.dumps({"commands": {}}),
2906
+ json.dumps({"commands": commands, "process_evidence": None}),
2907
+ json.dumps({"commands": commands, "process_evidence": wrong_carrier}),
2908
+ json.dumps({"commands": wrong_commands, "process_evidence": carrier}),
2909
+ ):
2910
+ summary.write_text(content, encoding="utf-8")
2911
+ rejected("complete", "--verdict", "BLOCKED")
2912
+ summary.unlink()
2913
+ summary.mkdir()
2914
+ rejected("complete", "--verdict", "BLOCKED")
2915
+ summary.rmdir()
2916
+ summary.symlink_to(active / "absent-summary")
2917
+ rejected("complete", "--verdict", "BLOCKED")
2918
+ summary.unlink()
2919
+ # A valid summary symlink retains the existing outcome floor.
2920
+ target = devlyn / "valid-summary.json"
2921
+ target.write_text(json.dumps({
2922
+ "commands": commands, "process_evidence": carrier,
2923
+ }), encoding="utf-8")
2924
+ summary.symlink_to(target)
2925
+ for verdict in ("PASS", "PASS_WITH_ISSUES"):
2926
+ rejected("complete", "--verdict", verdict, error="mismatch cannot complete")
2927
+ completed = cli("complete", "--verdict", "FAIL")
2928
+ assert completed.returncode == 0, completed.stderr
2929
+ summarized = read_state(state_file)
2930
+ assert summarized["phases"]["build_gate"]["verdict"] == "FAIL"
2931
+ assert summarized["process_evidence"] == [carrier]
2932
+ archived(summarized)
2933
+ summary.unlink()
2934
+ print("PASS iter-0119 ineligible verdicts, invalid summaries and integrity controls preserve state")
2935
+
2936
+ # Valid nonzero receipt: transition stays atomic; standalone
2937
+ # completion persists BLOCKED before reporting the receipt error.
2938
+ prompt = active / "build_gate.prompt.0"
2939
+ prompt.write_text("inspect interrupted build\n", encoding="utf-8")
2940
+ session = active / "build_gate.worker-session.0.jsonl"
2941
+ session.write_text('{"type":"thread.started"}\n', encoding="utf-8")
2942
+ receipt = active / "build_gate.invocation.0.json"
2943
+ model = "gpt-5.6-sol"
2944
+ candidate = copy.deepcopy(fixture)
2945
+ candidate["phases"]["build_gate"].update({
2946
+ "engine": "codex", "model_requested": model,
2947
+ "prompt_sha256": hashlib.sha256(prompt.read_bytes()).hexdigest(),
2948
+ })
2949
+ write_state(state_file, candidate)
2950
+ receipts = invocation_receipt_module()
2951
+ receipts.start_receipt(
2952
+ work, receipt, fixture["run_id"], "build_gate", 0, str(prompt), str(session),
2953
+ ["--json", "-C", str(work), "-s", "workspace-write", "-m", model,
2954
+ "-c", "sandbox_workspace_write.network_access=true", "inspect interrupted build"],
2955
+ )
2956
+ receipts.finish_receipt(work, receipt, 7)
2957
+ receipt_bytes = receipt.read_bytes()
2958
+ error = "BLOCKED:invocation-receipt-invalid: Codex invocation exited 7"
2959
+ rejected(
2960
+ "transition", "--verdict", "BLOCKED", "--engine-session-log", str(session),
2961
+ "--next-phase", "final_report", "--next-round", "0", error=error,
2962
+ )
2963
+ completed = cli(
2964
+ "complete", "--verdict", "BLOCKED", "--engine-session-log", str(session),
2965
+ )
2966
+ assert completed.returncode == 1, completed.stdout
2967
+ assert completed.stderr.strip() == error, completed.stderr
2968
+ terminal()
2969
+ assert receipt.read_bytes() == receipt_bytes
2970
+ print("PASS iter-0119 nonzero receipt persists BLOCKED before exit1; transition stays atomic")
2971
+
2972
+ candidate = copy.deepcopy(bound)
2973
+ do_spawn(candidate, "build_gate", 1, None, None, "claude", None)
2974
+ write_state(state_file, candidate)
2975
+ next_relative = runner.manifest_relative_path(candidate, "build_gate")
2976
+ runner.capture_process(
2977
+ work, work / next_relative, fixture["run_id"], "build_gate", 1, obligations[0],
2978
+ )
2979
+ next_carrier = runner.validate_manifest(
2980
+ work, next_relative, fixture["run_id"], "build_gate", 1,
2981
+ require_expectations=False,
2982
+ )
2983
+ completed = cli("complete", "--verdict", "BLOCKED")
2984
+ assert completed.returncode == 0, completed.stderr
2985
+ appended = read_state(state_file)
2986
+ assert appended["process_evidence"] == [carrier, next_carrier]
2987
+ assert appended["phases"]["build_gate"]["verdict"] == "BLOCKED"
2988
+ assert {path: path.read_bytes() for path in observed} == observed
2989
+ assert set(observed).issubset(archive.dynamic_evidence_artifacts(active, appended))
2990
+
2991
+ test_interrupted_build_gate()
2992
+
2993
+ # complete() before spawn() must fail loudly, not silently invent data.
2994
+ write_state(state_path, {"phases": {}})
2995
+ state = read_state(state_path)
2996
+ try:
2997
+ do_complete(state, "build_gate", "PASS", None, None, None, None, None)
1442
2998
  except SystemExit as e:
1443
2999
  assert "never spawned" in str(e)
1444
3000
  else:
@@ -1505,7 +3061,9 @@ def self_test() -> int:
1505
3061
  attestation_state = copy.deepcopy(transition_state)
1506
3062
  attestation_state["phases"]["plan"]["model_requested"] = "wanted-model"
1507
3063
  attestation_log = devlyn / "transition-attestation.log"
1508
- attestation_log.write_text("model: other-model\n", encoding="utf-8")
3064
+ attestation_log.write_text(json.dumps({
3065
+ "modelUsage": {"other-model": {"inputTokens": 1}},
3066
+ }) + "\n", encoding="utf-8")
1509
3067
  write_state(state_path, attestation_state)
1510
3068
  attestation_before = state_path.read_bytes()
1511
3069
  try:
@@ -1657,7 +3215,10 @@ def self_test() -> int:
1657
3215
  # flow does.
1658
3216
  write_state(state_path, {"phases": {}})
1659
3217
  state = read_state(state_path)
1660
- do_spawn(state, "plan", 0, None, None, None, None)
3218
+ do_spawn(
3219
+ state, "plan", 0, None, None, "claude", "plan-test-model",
3220
+ prompt_sha256=digest0,
3221
+ )
1661
3222
  write_state(state_path, state)
1662
3223
  state = read_state(state_path)
1663
3224
  try:
@@ -1693,8 +3254,11 @@ def self_test() -> int:
1693
3254
  "verify-merge.summary.json",
1694
3255
  "codex-judge.stdout",
1695
3256
  "claude-judge.stdout",
3257
+ "claude-judge.stderr",
3258
+ "pair-judge.summary.json",
1696
3259
  ):
1697
3260
  (devlyn / name).write_text("stale\n", encoding="utf-8")
3261
+ (devlyn / "codex-primary-judge.prompt.md").write_text("current\n", encoding="utf-8")
1698
3262
  (devlyn / "spec-verify.json").write_text("{}", encoding="utf-8")
1699
3263
  clear_verify_round_artifacts(devlyn)
1700
3264
  for name in (
@@ -1705,8 +3269,11 @@ def self_test() -> int:
1705
3269
  "verify-merge.summary.json",
1706
3270
  "codex-judge.stdout",
1707
3271
  "claude-judge.stdout",
3272
+ "claude-judge.stderr",
3273
+ "pair-judge.summary.json",
1708
3274
  ):
1709
3275
  assert not (devlyn / name).exists(), f"{name} must be cleared on VERIFY spawn"
3276
+ assert (devlyn / "codex-primary-judge.prompt.md").exists(), "judge prompts must survive VERIFY spawn"
1710
3277
  assert (devlyn / "spec-verify.json").exists(), "non-VERIFY-round files must survive"
1711
3278
 
1712
3279
  # Fix-loop respawn of phase-gated IMPLEMENT must preserve `exec`
@@ -1782,14 +3349,24 @@ def self_test() -> int:
1782
3349
  subprocess.run(["git", "config", "user.name", "self-test"], cwd=work, check=True)
1783
3350
  (work / "allowed.txt").write_text("base\n", encoding="utf-8")
1784
3351
  (work / "blocked.txt").write_text("base\n", encoding="utf-8")
3352
+ (work / "blocked:literal.txt:1").write_text("base\n", encoding="utf-8")
3353
+ (work / "blocked:raw.txt").write_text("base\n", encoding="utf-8")
3354
+ (work / "branchbase").write_text("line\n" * 10, encoding="utf-8")
3355
+ (work / "branchbase:7").write_text("base\n", encoding="utf-8")
3356
+ (work / "branchbase:dir").mkdir()
3357
+ (work / "branchbase:dir" / "marker").write_text("base\n", encoding="utf-8")
3358
+ (work / "exact:1").write_text("base\n", encoding="utf-8")
1785
3359
  (work / "schedule").mkdir()
1786
3360
  (work / "schedule" / "__init__.py").write_text("line\n" * 700, encoding="utf-8")
1787
3361
  (work / "test_schedule.py").write_text("line\n", encoding="utf-8")
1788
3362
  (work / "tests").mkdir()
1789
3363
  (work / "tests" / "cli.test.js").write_text("line\n" * 170, encoding="utf-8")
3364
+ (work / "tests" / "literal:1-3").write_text("line\n", encoding="utf-8")
1790
3365
  subprocess.run(
1791
- ["git", "add", "--", "allowed.txt", "blocked.txt", "schedule/__init__.py",
1792
- "test_schedule.py", "tests/cli.test.js"],
3366
+ ["git", "add", "--", "allowed.txt", "blocked.txt", "blocked:literal.txt:1",
3367
+ "blocked:raw.txt", "branchbase", "branchbase:7", "branchbase:dir/marker",
3368
+ "exact:1", "schedule/__init__.py", "test_schedule.py", "tests/cli.test.js",
3369
+ "tests/literal:1-3"],
1793
3370
  cwd=work, check=True,
1794
3371
  )
1795
3372
  subprocess.run(["git", "commit", "-qm", "base"], cwd=work, check=True)
@@ -1825,18 +3402,26 @@ def self_test() -> int:
1825
3402
  else:
1826
3403
  raise AssertionError("SURFACE_CLOSE accepted a non-Claude engine")
1827
3404
  assert rejected_state == {"sentinel": True}
1828
- rejected_state = {"sentinel": True}
1829
- try:
1830
- do_spawn(
1831
- rejected_state, "surface_close", 0, None, pre_sha, "claude", None,
1832
- input_patch_sha256=file_sha256(patch), prompt_sha256=file_sha256(prompt),
1833
- untracked_before=["kept.txt"],
1834
- )
1835
- except SystemExit as exc:
1836
- assert "phases.surface_close spawn requires --model" in str(exc)
1837
- else:
1838
- raise AssertionError("SURFACE_CLOSE accepted a missing requested model")
1839
- assert rejected_state == {"sentinel": True}
3405
+ for blank_model in ("", " ", " sonnet"):
3406
+ rejected_state = {"sentinel": True}
3407
+ try:
3408
+ do_spawn(
3409
+ rejected_state, "surface_close", 0, None, pre_sha, "claude", blank_model,
3410
+ input_patch_sha256=file_sha256(patch), prompt_sha256=file_sha256(prompt),
3411
+ untracked_before=["kept.txt"],
3412
+ )
3413
+ except SystemExit as exc:
3414
+ assert "explicit --model must be a nonempty exact string" in str(exc)
3415
+ else:
3416
+ raise AssertionError("SURFACE_CLOSE accepted an invalid requested model")
3417
+ assert rejected_state == {"sentinel": True}
3418
+ inherited_surface = {**copy.deepcopy(surface_state), "version": "3.0"}
3419
+ do_spawn(
3420
+ inherited_surface, "surface_close", 0, None, pre_sha, "claude", None,
3421
+ input_patch_sha256=file_sha256(patch), prompt_sha256=file_sha256(prompt),
3422
+ untracked_before=["kept.txt"],
3423
+ )
3424
+ assert inherited_surface["phases"]["surface_close"]["model_requested"] is None
1840
3425
  do_spawn(
1841
3426
  surface_state, "surface_close", 0, None, pre_sha, "claude", "sonnet",
1842
3427
  input_patch_sha256=file_sha256(patch), prompt_sha256=file_sha256(prompt),
@@ -1845,9 +3430,89 @@ def self_test() -> int:
1845
3430
  validate_surface_inputs(work, work_devlyn, surface_state)
1846
3431
  validate_surface_prompt(work_devlyn, surface_state)
1847
3432
  ensure_surface_clean_baseline(work, work_devlyn, surface_state)
3433
+ (work_devlyn / "pipeline.state.json").write_text(
3434
+ json.dumps(surface_state), encoding="utf-8",
3435
+ )
3436
+ for entry in ("src/{a,b", "src/{a,}/**", "src/{a,{b,c}}", "src/{a,**}"):
3437
+ surface_check = subprocess.run(
3438
+ [
3439
+ sys.executable, str(pathlib.Path(__file__).resolve()),
3440
+ "--devlyn-dir", ".devlyn", "--phase", "surface_close", "surface-check",
3441
+ "--authorized-surface-json", json.dumps([entry]),
3442
+ ],
3443
+ cwd=work, capture_output=True, text=True,
3444
+ )
3445
+ if (
3446
+ surface_check.returncode == 0
3447
+ or "error: unsupported brace glob" not in surface_check.stderr
3448
+ or "supported form" not in surface_check.stderr
3449
+ or "Traceback" in surface_check.stderr
3450
+ ):
3451
+ raise AssertionError(f"surface-check did not fail closed for {entry}: {surface_check.stderr}")
1848
3452
  surface = validate_authorized_surface(
1849
- '["allowed.txt", "schedule/**", "test_schedule.py", "tests/**"]'
3453
+ '["allowed.txt", "branchbase", "exact-link", "exact:1", "schedule/**", '
3454
+ '"test_schedule.py", "tests/**", "x.ts"]'
1850
3455
  )
3456
+ assert path_matches_surface("src/a/example.py", ["src/{a,b}/**"])
3457
+ assert path_matches_surface("src/b/example.py", ["src/{a,b}/**"])
3458
+ assert not path_matches_surface("src/c/example.py", ["src/{a,b}/**"])
3459
+ for entry in ("src/{a,b", "src/{a,}/**", "src/{a,{b,c}}", "src/{a,**}"):
3460
+ try:
3461
+ validate_authorized_surface(json.dumps([entry]))
3462
+ except SystemExit as exc:
3463
+ assert str(exc).startswith("error: unsupported brace glob") and "supported form" in str(exc)
3464
+ else:
3465
+ raise AssertionError(f"malformed brace glob passed validation: {entry}")
3466
+ try:
3467
+ path_matches_surface("src/a/example.py", [entry])
3468
+ except ValueError as exc:
3469
+ assert entry in str(exc) and "supported form" in str(exc)
3470
+ else:
3471
+ raise AssertionError(f"malformed brace glob accepted: {entry}")
3472
+ outside = devlyn / "outside.txt"
3473
+ outside.write_text("outside\n", encoding="utf-8")
3474
+ (work / "outside-link").symlink_to(outside)
3475
+ assert worktree_file_exists(work, "allowed.txt")
3476
+ assert not worktree_file_exists(work, str(outside))
3477
+ assert not worktree_file_exists(work, "../outside.txt")
3478
+ assert not worktree_file_exists(work, "outside-link")
3479
+ assert worktree_path_exists(work, "branchbase:dir")
3480
+ assert not worktree_path_exists(work, str(outside))
3481
+ assert not worktree_path_exists(work, "../outside.txt")
3482
+ assert not worktree_path_exists(work, "outside-link")
3483
+ (work / "outside-link").unlink()
3484
+ for link, target in (
3485
+ ("exact-link", "allowed.txt"),
3486
+ ("tests/inside-link", "cli.test.js"),
3487
+ ):
3488
+ (work / link).symlink_to(target)
3489
+ output = work_devlyn / "surface-close.stdout"
3490
+ output.write_text(
3491
+ f"UVR-STALE: N/A {link} — contained symlink\n"
3492
+ "PATH-TEST: FIRED allowed.txt:1\nPASS\n",
3493
+ encoding="utf-8",
3494
+ )
3495
+ validate_surface_adjudication(
3496
+ work, work_devlyn, surface_state, surface,
3497
+ )
3498
+ (work / link).unlink()
3499
+ for link in ("exact-link", "tests/outside-link"):
3500
+ (work / link).symlink_to(outside)
3501
+ output = work_devlyn / "surface-close.stdout"
3502
+ output.write_text(
3503
+ f"UVR-STALE: N/A {link} — escaping symlink\n"
3504
+ "PATH-TEST: FIRED allowed.txt:1\nPASS\n",
3505
+ encoding="utf-8",
3506
+ )
3507
+ try:
3508
+ validate_surface_adjudication(
3509
+ work, work_devlyn, surface_state, surface,
3510
+ )
3511
+ except SystemExit as exc:
3512
+ assert "citation-missing" in str(exc), (link, exc)
3513
+ else:
3514
+ raise AssertionError(f"SURFACE_CLOSE followed escaping symlink: {link}")
3515
+ (work / link).unlink()
1851
3516
  entry = surface_entry(surface_state)
1852
3517
  assert entry["prompt_sha256"] == file_sha256(prompt)
1853
3518
  assert entry["model_requested"] == "sonnet"
@@ -1873,6 +3538,18 @@ def self_test() -> int:
1873
3538
  encoding="utf-8",
1874
3539
  )
1875
3540
  validate_surface_adjudication(work, work_devlyn, surface_state, surface)
3541
+ output.write_text(
3542
+ "UVR-STALE: N/A exact:1 — exact colon path is unchanged\n"
3543
+ "PATH-TEST: N/A exact:1:1 — exact colon path line is covered\nPASS\n",
3544
+ encoding="utf-8",
3545
+ )
3546
+ validate_surface_adjudication(work, work_devlyn, surface_state, surface)
3547
+ output.write_text(
3548
+ "UVR-STALE: N/A tests/literal:1-3 — literal range-shaped path is unchanged\n"
3549
+ "PATH-TEST: FIRED allowed.txt:1\nPASS\n",
3550
+ encoding="utf-8",
3551
+ )
3552
+ validate_surface_adjudication(work, work_devlyn, surface_state, surface)
1876
3553
 
1877
3554
  output.write_text(
1878
3555
  "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"
@@ -1924,6 +3601,81 @@ def self_test() -> int:
1924
3601
  "PATH-TEST: FIRED allowed.txt:1\nPASS\n",
1925
3602
  "citation-missing",
1926
3603
  ),
3604
+ (
3605
+ "UVR-STALE: N/A allowed.txt:49-73,223-235,293-295 — production range citation\n"
3606
+ "PATH-TEST: FIRED allowed.txt:1\nPASS\n",
3607
+ "adjudication-malformed",
3608
+ ),
3609
+ (
3610
+ "UVR-STALE: N/A allowed.txt:1, allowed.txt:1 — production list citation\n"
3611
+ "PATH-TEST: FIRED allowed.txt:1\nPASS\n",
3612
+ "adjudication-malformed",
3613
+ ),
3614
+ (
3615
+ "UVR-STALE: N/A tests/cli.test.js:1-3 — glob range citation\n"
3616
+ "PATH-TEST: FIRED allowed.txt:1\nPASS\n",
3617
+ "adjudication-malformed",
3618
+ ),
3619
+ (
3620
+ "UVR-STALE: N/A x.ts:12:34 — doubled line citation\n"
3621
+ "PATH-TEST: FIRED allowed.txt:1\nPASS\n",
3622
+ "adjudication-malformed",
3623
+ ),
3624
+ (
3625
+ "UVR-STALE: N/A allowed.txt:0 — zero line citation\n"
3626
+ "PATH-TEST: FIRED allowed.txt:1\nPASS\n",
3627
+ "adjudication-malformed",
3628
+ ),
3629
+ (
3630
+ "UVR-STALE: N/A allowed.txt:012 — leading-zero line citation\n"
3631
+ "PATH-TEST: FIRED allowed.txt:1\nPASS\n",
3632
+ "adjudication-malformed",
3633
+ ),
3634
+ (
3635
+ "UVR-STALE: N/A allowed.txt: — empty line citation\n"
3636
+ "PATH-TEST: FIRED allowed.txt:1\nPASS\n",
3637
+ "adjudication-malformed",
3638
+ ),
3639
+ (
3640
+ "UVR-STALE: N/A blocked.txt:1 — blocked parsed path\n"
3641
+ "PATH-TEST: FIRED allowed.txt:1\nPASS\n",
3642
+ "out-of-surface",
3643
+ ),
3644
+ (
3645
+ "UVR-STALE: N/A blocked.txt:1-3 — blocked range citation\n"
3646
+ "PATH-TEST: FIRED allowed.txt:1\nPASS\n",
3647
+ "out-of-surface",
3648
+ ),
3649
+ (
3650
+ "UVR-STALE: N/A blocked:literal.txt:1 — blocked raw colon path\n"
3651
+ "PATH-TEST: FIRED allowed.txt:1\nPASS\n",
3652
+ "out-of-surface",
3653
+ ),
3654
+ (
3655
+ "UVR-STALE: N/A blocked:raw.txt:1 — blocked parsed colon path\n"
3656
+ "PATH-TEST: FIRED allowed.txt:1\nPASS\n",
3657
+ "out-of-surface",
3658
+ ),
3659
+ (
3660
+ "UVR-STALE: N/A branchbase:7 — existing unauthorized raw path\n"
3661
+ "PATH-TEST: FIRED allowed.txt:1\nPASS\n",
3662
+ "out-of-surface",
3663
+ ),
3664
+ (
3665
+ "UVR-STALE: N/A branchbase:dir:1 — existing unauthorized parsed directory\n"
3666
+ "PATH-TEST: FIRED allowed.txt:1\nPASS\n",
3667
+ "out-of-surface",
3668
+ ),
3669
+ (
3670
+ "UVR-STALE: N/A tests/../blocked.txt:1 — wildcard traversal\n"
3671
+ "PATH-TEST: FIRED allowed.txt:1\nPASS\n",
3672
+ "out-of-surface",
3673
+ ),
3674
+ (
3675
+ "UVR-STALE: N/A tests/ghost.txt:1 — nonexistent glob descendant\n"
3676
+ "PATH-TEST: FIRED allowed.txt:1\nPASS\n",
3677
+ "citation-missing",
3678
+ ),
1927
3679
  )
1928
3680
  for raw_output, marker in rejected_outputs:
1929
3681
  output.write_text(raw_output, encoding="utf-8")
@@ -1934,6 +3686,14 @@ def self_test() -> int:
1934
3686
  else:
1935
3687
  raise AssertionError(f"SURFACE_CLOSE accepted invalid adjudication: {marker}")
1936
3688
 
3689
+ output.write_text(
3690
+ "UVR-STALE: N/A allowed.txt:49-73,223-235,293-295 — production range citation\n"
3691
+ "PATH-TEST: FIRED allowed.txt:1\nPASS\n",
3692
+ encoding="utf-8",
3693
+ )
3694
+ require_surface_adjudication_malformed(
3695
+ work, work_devlyn, surface_state, surface,
3696
+ )
1937
3697
  output.write_text(
1938
3698
  "UVR-STALE: N/A allowed.txt:1 — no stale interface text\n"
1939
3699
  "PATH-TEST: N/A allowed.txt:1 — requested path already covered\nPASS\n",
@@ -2006,6 +3766,78 @@ def self_test() -> int:
2006
3766
  assert completed_surface["model_effective"] == "sonnet"
2007
3767
  assert completed_surface["verdict"] == "PASS"
2008
3768
 
3769
+ inherited_completion = copy.deepcopy(inherited_surface)
3770
+ assert do_complete(
3771
+ inherited_completion, "surface_close", "PASS", pre_sha, None, None,
3772
+ None, None, str(wrapper_log), devlyn=work_devlyn,
3773
+ ) is None
3774
+ assert inherited_completion["phases"]["surface_close"]["model_requested"] is None
3775
+ assert inherited_completion["phases"]["surface_close"]["model_effective"] == "sonnet"
3776
+ replacement = copy.deepcopy(inherited_surface)
3777
+ try:
3778
+ do_complete(replacement, "surface_close", "PASS", None, None, None,
3779
+ None, "sonnet", str(wrapper_log), devlyn=work_devlyn)
3780
+ except SystemExit as exc:
3781
+ assert "cannot replace requested model" in str(exc)
3782
+ else:
3783
+ raise AssertionError("completion replaced inherited model selection")
3784
+ assert replacement == inherited_surface
3785
+
3786
+ native_json = wrapper_log.read_bytes()
3787
+ transcript_bytes = transcript.read_bytes()
3788
+ foreign_log = work_devlyn / "foreign.output.json"
3789
+ foreign_log.write_bytes(native_json)
3790
+ for retained in (False, True):
3791
+ if retained:
3792
+ transcript.write_bytes(transcript_bytes)
3793
+ else:
3794
+ transcript.unlink()
3795
+ for log in (None, str(foreign_log)):
3796
+ rejected = copy.deepcopy(inherited_surface)
3797
+ error = do_complete(rejected, "surface_close", "PASS", None, None, None,
3798
+ None, None, log, devlyn=work_devlyn)
3799
+ assert error and "canonical native JSON" in error
3800
+ assert rejected["phases"]["surface_close"]["verdict"] == "BLOCKED"
3801
+ for invalid_json in (
3802
+ b"not JSON", b'{"result":"PASS"}',
3803
+ b'{"modelUsage":{"model-a":{},"model-b":{}}}',
3804
+ b'{"type":"turn_context","payload":{"model":"sonnet"}}\n',
3805
+ ):
3806
+ wrapper_log.write_bytes(invalid_json)
3807
+ rejected = copy.deepcopy(inherited_surface)
3808
+ assert do_complete(rejected, "surface_close", "PASS", None, None, None,
3809
+ None, None, str(wrapper_log), devlyn=work_devlyn)
3810
+ assert rejected["phases"]["surface_close"]["verdict"] == "BLOCKED"
3811
+ alias_log = work_devlyn / "aliased-native.jsonl"
3812
+ alias_log.symlink_to(wrapper_log)
3813
+ rejected = copy.deepcopy(inherited_surface)
3814
+ assert do_complete(rejected, "surface_close", "PASS", None, None, None,
3815
+ None, None, str(alias_log), devlyn=work_devlyn)
3816
+ wrapper_log.unlink()
3817
+ rejected = copy.deepcopy(inherited_surface)
3818
+ assert do_complete(rejected, "surface_close", "PASS", None, None, None,
3819
+ None, None, str(wrapper_log), devlyn=work_devlyn)
3820
+ recovery_failure = copy.deepcopy(inherited_surface)
3821
+ assert do_surface_adjudication_recovery(recovery_failure, work_devlyn)
3822
+ assert recovery_failure["phases"]["surface_close"]["verdict"] == "BLOCKED"
3823
+ assert not recovery_failure["phases"]["surface_close"].get("continued_after_block")
3824
+ transition_state = copy.deepcopy(inherited_surface)
3825
+ try:
3826
+ do_transition(transition_state, "surface_close", "build_gate", "PASS", pre_sha,
3827
+ None, None, None, None, None, work_devlyn, 0, None, None, "claude", None)
3828
+ except SystemExit as exc:
3829
+ assert "canonical native JSON" in str(exc)
3830
+ else:
3831
+ raise AssertionError("SURFACE_CLOSE opened BUILD_GATE without native evidence")
3832
+ assert transition_state == inherited_surface
3833
+ wrapper_log.write_text('{"modelUsage":{"different-model":{"inputTokens":1}}}\n')
3834
+ mismatch = copy.deepcopy(surface_state)
3835
+ assert "model-attestation-mismatch" in do_complete(
3836
+ mismatch, "surface_close", "PASS", None, None, None, None, None,
3837
+ str(wrapper_log), devlyn=work_devlyn,
3838
+ )
3839
+ wrapper_log.write_bytes(native_json)
3840
+
2009
3841
  recovery_state = loads_strict_json(json.dumps(surface_state))
2010
3842
  recovery_started_at = recovery_state["phases"]["surface_close"]["started_at"]
2011
3843
  output.write_text("UVR-STALE: FIRED allowed.txt:1\nPASS\n", encoding="utf-8")
@@ -2059,20 +3891,178 @@ def self_test() -> int:
2059
3891
  assert skipped["verdict"] is None
2060
3892
  assert skipped["skipped_reason"] == SURFACE_SKIP_REASON
2061
3893
 
2062
- # Effective model evidence: engine header line and rollout JSONL.
3894
+ # Effective model evidence: handwritten headers are not provenance;
3895
+ # canonical rollout JSONL and Claude wrapper output remain accepted.
2063
3896
  header_log = devlyn / "codex-build.log"
2064
3897
  header_log.write_text("session\nmodel: gpt-5.6-sol\n", encoding="utf-8")
2065
- assert parse_effective_model(header_log) == "gpt-5.6-sol"
3898
+ try:
3899
+ parse_effective_model(header_log)
3900
+ except ValueError as exc:
3901
+ assert "no effective-model evidence" in str(exc)
3902
+ else:
3903
+ raise AssertionError("plaintext model header was accepted as provenance")
2066
3904
  rollout_log = devlyn / "rollout.jsonl"
2067
3905
  rollout_log.write_text(json.dumps({
2068
3906
  "type": "turn_context", "payload": {"model": "gpt-5.6-terra"},
2069
3907
  }) + "\n", encoding="utf-8")
2070
3908
  assert parse_effective_model(rollout_log) == "gpt-5.6-terra"
2071
3909
  claude_log = devlyn / "claude-result.json"
2072
- claude_log.write_text(json.dumps({
2073
- "modelUsage": {"claude-alpha-1": {"inputTokens": 1}},
2074
- }) + "\n", encoding="utf-8")
2075
- assert parse_effective_model(claude_log) == "claude-alpha-1"
3910
+
3911
+ def claude_wrapper(primary_usage, entries):
3912
+ wrapper = {"modelUsage": {}}
3913
+ if primary_usage is not None:
3914
+ wrapper["usage"] = dict(zip((
3915
+ "input_tokens", "output_tokens", "cache_read_input_tokens",
3916
+ "cache_creation_input_tokens",
3917
+ ), primary_usage))
3918
+ for model, entry_usage, metadata in entries:
3919
+ entry = dict(zip((
3920
+ "inputTokens", "outputTokens", "cacheReadInputTokens",
3921
+ "cacheCreationInputTokens",
3922
+ ), entry_usage))
3923
+ entry.update(metadata)
3924
+ wrapper["modelUsage"][model] = entry
3925
+ return wrapper
3926
+
3927
+ oversized_counter = 10 ** 400
3928
+ claude_selector_rows = (
3929
+ (
3930
+ "singleton",
3931
+ {"modelUsage": {"claude-alpha-1": {"inputTokens": 1}}},
3932
+ "claude-alpha-1",
3933
+ ),
3934
+ (
3935
+ "opus-primary",
3936
+ claude_wrapper(
3937
+ (2854, 9927, 1095180, 73889),
3938
+ (
3939
+ ("claude-" "haiku-4-5-20251001", (4106, 14, 0, 0), {
3940
+ "costUSD": 0.004176,
3941
+ }),
3942
+ ("claude-" "opus-5[1m]", (2854, 9927, 1095180, 73889), {
3943
+ "costUSD": 1.5489249999999999,
3944
+ "canonicalModel": "claude-" "opus-5",
3945
+ }),
3946
+ ),
3947
+ ),
3948
+ "claude-" "opus-5[1m]",
3949
+ ),
3950
+ (
3951
+ "fable-primary",
3952
+ claude_wrapper(
3953
+ (8068, 26740, 403950, 76656),
3954
+ (
3955
+ ("claude-" "haiku-4-5-20251001", (2246, 22, 0, 0), {
3956
+ "costUSD": 0.002356,
3957
+ }),
3958
+ ("claude-" "fable-5", (8068, 26740, 403950, 76656), {
3959
+ "costUSD": 3.35475,
3960
+ "canonicalModel": "claude-" "fable-5",
3961
+ }),
3962
+ ),
3963
+ ),
3964
+ "claude-" "fable-5",
3965
+ ),
3966
+ (
3967
+ "rank-confound",
3968
+ claude_wrapper(
3969
+ (3, 5, 7, 11),
3970
+ (
3971
+ ("larger-auxiliary", (300000, 500000, 700000, 1100000), {
3972
+ "costUSD": 999.0,
3973
+ }),
3974
+ ("expected-primary", (3, 5, 7, 11), {"costUSD": 0.01}),
3975
+ ),
3976
+ ),
3977
+ "expected-primary",
3978
+ ),
3979
+ (
3980
+ "oversized-integer",
3981
+ claude_wrapper(
3982
+ (oversized_counter, 5, 7, 11),
3983
+ (
3984
+ ("oversized-primary", (oversized_counter, 5, 7, 11), {}),
3985
+ ("auxiliary", (3, 5, 7, 11), {}),
3986
+ ),
3987
+ ),
3988
+ "oversized-primary",
3989
+ ),
3990
+ (
3991
+ "zero-match",
3992
+ claude_wrapper(
3993
+ (1, 2, 3, 4),
3994
+ (("model-a", (10, 2, 3, 4), {}), ("model-b", (1, 20, 3, 4), {})),
3995
+ ),
3996
+ ValueError,
3997
+ ),
3998
+ (
3999
+ "duplicate-match",
4000
+ claude_wrapper(
4001
+ (1, 2, 3, 4),
4002
+ (("model-a", (1, 2, 3, 4), {}), ("model-b", (1, 2, 3, 4), {})),
4003
+ ),
4004
+ ValueError,
4005
+ ),
4006
+ (
4007
+ "missing-top-level-usage",
4008
+ claude_wrapper(
4009
+ None,
4010
+ (("model-a", (1, 2, 3, 4), {}), ("model-b", (5, 6, 7, 8), {})),
4011
+ ),
4012
+ ValueError,
4013
+ ),
4014
+ (
4015
+ "malformed-top-level-float",
4016
+ claude_wrapper(
4017
+ (1.0, 2, 3, 4),
4018
+ (("model-a", (1, 2, 3, 4), {}), ("model-b", (5, 6, 7, 8), {})),
4019
+ ),
4020
+ ValueError,
4021
+ ),
4022
+ (
4023
+ "malformed-top-level-negative",
4024
+ claude_wrapper(
4025
+ (-1, 2, 3, 4),
4026
+ (("model-a", (-1, 2, 3, 4), {}), ("model-b", (5, 6, 7, 8), {})),
4027
+ ),
4028
+ ValueError,
4029
+ ),
4030
+ (
4031
+ "malformed-entry-counter",
4032
+ claude_wrapper(
4033
+ (1, 2, 3, 4),
4034
+ (("model-a", (1, 2, 3, 4), {}), ("model-b", (True, 20, 30, 40), {})),
4035
+ ),
4036
+ ValueError,
4037
+ ),
4038
+ )
4039
+ for row_name, wrapper, expected in claude_selector_rows:
4040
+ claude_log.write_text(json.dumps(wrapper) + "\n", encoding="utf-8")
4041
+ if expected is ValueError:
4042
+ try:
4043
+ parse_effective_model(claude_log)
4044
+ except ValueError:
4045
+ pass
4046
+ else:
4047
+ raise AssertionError(f"Claude selector accepted {row_name}")
4048
+ else:
4049
+ assert parse_effective_model(claude_log) == expected, row_name
4050
+
4051
+ claude_log.write_text(
4052
+ json.dumps(claude_selector_rows[-1][1]) + "\n", encoding="utf-8"
4053
+ )
4054
+ write_state(state_path, {"phases": {}})
4055
+ state = read_state(state_path)
4056
+ do_spawn(
4057
+ state, "plan", 0, None, None, "claude", "claude-default",
4058
+ prompt_sha256=digest0,
4059
+ )
4060
+ malformed = do_complete(
4061
+ state, "plan", "PASS", None, None, None, None, None, str(claude_log)
4062
+ )
4063
+ assert malformed and "model-attestation-failed" in malformed
4064
+ assert state["phases"]["plan"]["model_effective"] is None
4065
+ assert state["phases"]["plan"]["verdict"] == "BLOCKED"
2076
4066
 
2077
4067
  # Requested/effective drift is a persisted, fail-closed attestation.
2078
4068
  write_state(state_path, {"phases": {}})
@@ -2087,6 +4077,21 @@ def self_test() -> int:
2087
4077
  assert mismatched["model_effective"] == "gpt-5.6-terra"
2088
4078
  assert mismatched["verdict"] == "BLOCKED"
2089
4079
 
4080
+ claude_log.write_text(
4081
+ json.dumps(claude_selector_rows[1][1]) + "\n", encoding="utf-8"
4082
+ )
4083
+ write_state(state_path, {"phases": {}})
4084
+ state = read_state(state_path)
4085
+ do_spawn(state, "build_gate", 0, None, None, "claude", "claude-" "opus-5")
4086
+ mismatch = do_complete(
4087
+ state, "build_gate", "PASS", None, None, None, None, None, str(claude_log)
4088
+ )
4089
+ mismatched = state["phases"]["build_gate"]
4090
+ assert mismatch and "model-attestation-mismatch" in mismatch
4091
+ assert mismatched["model_requested"] == "claude-" "opus-5"
4092
+ assert mismatched["model_effective"] == "claude-" "opus-5[1m]"
4093
+ assert mismatched["verdict"] == "BLOCKED"
4094
+
2090
4095
  # A retained mutation-worker session makes the completion flag
2091
4096
  # mandatory; without a retained file, null remains legal.
2092
4097
  retained_log = devlyn / "implement.worker-session.0.jsonl"
@@ -2115,6 +4120,437 @@ def self_test() -> int:
2115
4120
  assert state["phases"]["implement"]["model_effective"] == "gpt-5.6-terra"
2116
4121
  assert state["phases"]["implement"]["verdict"] == "PASS"
2117
4122
 
4123
+ receipt_work = devlyn / "receipt-work"
4124
+ receipt_devlyn = receipt_work / ".devlyn"
4125
+ receipt_devlyn.mkdir(parents=True)
4126
+ receipt_prompt = receipt_devlyn / "implement.prompt.0"
4127
+ receipt_prompt.write_text("implement exactly\n", encoding="utf-8")
4128
+ receipt_session = receipt_devlyn / "implement.worker-session.0.jsonl"
4129
+ receipt_session.write_text('{"type":"thread.started"}\n', encoding="utf-8")
4130
+ receipt_path = receipt_devlyn / "implement.invocation.0.json"
4131
+ receipt_model = "gpt-5.6-sol"
4132
+ receipt_prompt_sha = hashlib.sha256(receipt_prompt.read_bytes()).hexdigest()
4133
+ inherited_engine_state = {
4134
+ "version": "3.0",
4135
+ "run_id": "rs-inherited-engine",
4136
+ "engine": "codex",
4137
+ "phases": {"implement": None},
4138
+ }
4139
+ try:
4140
+ do_spawn(
4141
+ inherited_engine_state, "implement", 0, None, None, None, None,
4142
+ prompt_sha256=receipt_prompt_sha, devlyn=receipt_devlyn,
4143
+ )
4144
+ except SystemExit as exc:
4145
+ assert "spawn requires --model" in str(exc)
4146
+ else:
4147
+ raise AssertionError("schema-v3 inherited Codex engine accepted no model")
4148
+ do_spawn(
4149
+ inherited_engine_state, "implement", 0, None, None, None, receipt_model,
4150
+ prompt_sha256=receipt_prompt_sha, devlyn=receipt_devlyn,
4151
+ )
4152
+ assert inherited_engine_state["phases"]["implement"]["engine"] == "codex"
4153
+ receipt_state = {
4154
+ "version": "3.0",
4155
+ "run_id": "rs-invocation-state",
4156
+ "engine": "codex",
4157
+ "phases": {"implement": None},
4158
+ }
4159
+ do_spawn(
4160
+ receipt_state, "implement", 0, None, None, "codex", receipt_model,
4161
+ prompt_sha256=receipt_prompt_sha, devlyn=receipt_devlyn,
4162
+ )
4163
+ receipt_runner = invocation_receipt_module()
4164
+ receipt_runner.start_receipt(
4165
+ receipt_work, receipt_path, receipt_state["run_id"], "implement", 0,
4166
+ str(receipt_prompt), str(receipt_session),
4167
+ ["--json", "-C", str(receipt_work), "-s", "workspace-write", "-m", receipt_model,
4168
+ "-c", "sandbox_workspace_write.network_access=false", "implement exactly"],
4169
+ )
4170
+ receipt_runner.finish_receipt(receipt_work, receipt_path, 0)
4171
+ rerouted_state = copy.deepcopy(receipt_state)
4172
+ assert do_complete(
4173
+ receipt_state, "implement", "PASS", None, None, None, None, None,
4174
+ str(receipt_session), devlyn=receipt_devlyn, work=receipt_work,
4175
+ ) is None
4176
+ receipt_entry = receipt_state["phases"]["implement"]
4177
+ assert receipt_entry["model_effective"] is None
4178
+ assert receipt_entry["model_requested"] == receipt_model
4179
+ assert receipt_entry["invocation_receipt"]["path"] == (
4180
+ ".devlyn/implement.invocation.0.json"
4181
+ )
4182
+
4183
+ # A native reroute remains a failed phase even after a successful terminal event.
4184
+ receipt_session.write_text(
4185
+ '{"type":"item.completed","item":{"type":"error",'
4186
+ '"message":"model rerouted: gpt-5.6-sol -> other (Policy)"}}\n'
4187
+ '{"type":"turn.completed"}\n', encoding="utf-8",
4188
+ )
4189
+ receipt_path.unlink()
4190
+ receipt_runner.start_receipt(
4191
+ receipt_work, receipt_path, rerouted_state["run_id"], "implement", 0,
4192
+ str(receipt_prompt), str(receipt_session),
4193
+ ["--json", "-C", str(receipt_work), "-s", "workspace-write", "-m", receipt_model,
4194
+ "-c", "sandbox_workspace_write.network_access=false", "implement exactly"],
4195
+ )
4196
+ receipt_runner.finish_receipt(receipt_work, receipt_path, 0)
4197
+ reroute_error = do_complete(
4198
+ rerouted_state, "implement", "PASS", None, None, None, None, None,
4199
+ str(receipt_session), devlyn=receipt_devlyn, work=receipt_work,
4200
+ )
4201
+ assert reroute_error and "model reroute" in reroute_error
4202
+ assert rerouted_state["phases"]["implement"]["verdict"] == "BLOCKED"
4203
+ do_spawn(rerouted_state, "implement", 1, None, None, "codex", receipt_model,
4204
+ prompt_sha256=receipt_prompt_sha, devlyn=receipt_devlyn)
4205
+ assert rerouted_state["phases"]["implement"]["history"][-1]["verdict"] == "BLOCKED"
4206
+
4207
+ plan_prompt = receipt_devlyn / "plan.prompt.0"
4208
+ plan_prompt.write_text("plan exactly\n", encoding="utf-8")
4209
+ plan_session = receipt_devlyn / "plan.worker-session.0.jsonl"
4210
+ plan_session.write_text('{"type":"thread.started"}\n', encoding="utf-8")
4211
+ plan_path = receipt_devlyn / "plan.invocation.0.json"
4212
+ (receipt_devlyn / "plan.md").write_text("## Files to touch\n", encoding="utf-8")
4213
+ plan_model = "gpt-5.6-sol"
4214
+ plan_prompt_sha = hashlib.sha256(plan_prompt.read_bytes()).hexdigest()
4215
+ plan_state = {
4216
+ "version": "3.0",
4217
+ "run_id": "rs-plan-invocation",
4218
+ "engine": "codex",
4219
+ "phases": {"plan": None},
4220
+ }
4221
+ do_spawn(
4222
+ plan_state, "plan", 0, None, None, "codex", plan_model,
4223
+ prompt_sha256=plan_prompt_sha, devlyn=receipt_devlyn,
4224
+ )
4225
+ receipt_runner.start_receipt(
4226
+ receipt_work, plan_path, plan_state["run_id"], "plan", 0,
4227
+ str(plan_prompt), str(plan_session),
4228
+ ["--json", "-C", str(receipt_work), "-s", "workspace-write",
4229
+ "-m", plan_model, "-c",
4230
+ "sandbox_workspace_write.network_access=false", "plan exactly"],
4231
+ )
4232
+ receipt_runner.finish_receipt(receipt_work, plan_path, 0)
4233
+ assert do_complete(
4234
+ plan_state, "plan", "PASS", None, None, None, None, None,
4235
+ str(plan_session), devlyn=receipt_devlyn, work=receipt_work,
4236
+ ) is None
4237
+ plan_entry = plan_state["phases"]["plan"]
4238
+ assert plan_entry["model_effective"] is None
4239
+ assert plan_entry["model_requested"] == plan_model
4240
+ assert plan_entry["invocation_receipt"]["path"] == ".devlyn/plan.invocation.0.json"
4241
+ assert plan_entry["output_sha256"] == hashlib.sha256(
4242
+ (receipt_devlyn / "plan.md").read_bytes()
4243
+ ).hexdigest()
4244
+ plan_prompt_1 = receipt_devlyn / "plan.prompt.1"
4245
+ plan_prompt_1.write_text("replan exactly\n", encoding="utf-8")
4246
+ plan_session_1 = receipt_devlyn / "plan.worker-session.1.jsonl"
4247
+ plan_session_1.write_text('{"type":"thread.started"}\n', encoding="utf-8")
4248
+ plan_path_1 = receipt_devlyn / "plan.invocation.1.json"
4249
+ plan_prompt_sha_1 = hashlib.sha256(plan_prompt_1.read_bytes()).hexdigest()
4250
+ do_spawn(
4251
+ plan_state, "plan", 1, "plan", None, "codex", plan_model,
4252
+ prompt_sha256=plan_prompt_sha_1, devlyn=receipt_devlyn,
4253
+ )
4254
+ plan_history = plan_state["phases"]["plan"]["history"]
4255
+ assert plan_history[0]["invocation_receipt"]["path"] == (
4256
+ ".devlyn/plan.invocation.0.json"
4257
+ )
4258
+ assert "invocation_receipt" not in plan_state["phases"]["plan"]
4259
+ receipt_runner.start_receipt(
4260
+ receipt_work, plan_path_1, plan_state["run_id"], "plan", 1,
4261
+ str(plan_prompt_1), str(plan_session_1),
4262
+ ["--json", "-C", str(receipt_work), "-s", "workspace-write",
4263
+ "-m", plan_model, "-c",
4264
+ "sandbox_workspace_write.network_access=false", "replan exactly"],
4265
+ )
4266
+ receipt_runner.finish_receipt(receipt_work, plan_path_1, 0)
4267
+ assert do_complete(
4268
+ plan_state, "plan", "PASS", None, None, None, None, None,
4269
+ str(plan_session_1), devlyn=receipt_devlyn, work=receipt_work,
4270
+ ) is None
4271
+ assert plan_state["phases"]["plan"]["invocation_receipt"]["path"] == (
4272
+ ".devlyn/plan.invocation.1.json"
4273
+ )
4274
+
4275
+ confused_state = {
4276
+ "version": "3.0",
4277
+ "run_id": "rs-invocation-confused",
4278
+ "engine": "codex",
4279
+ "phases": {"implement": None},
4280
+ }
4281
+ do_spawn(
4282
+ confused_state, "implement", 0, None, None, "codex", receipt_model,
4283
+ prompt_sha256=receipt_prompt_sha, devlyn=receipt_devlyn,
4284
+ )
4285
+ confused_before = copy.deepcopy(confused_state)
4286
+ try:
4287
+ do_complete(
4288
+ confused_state, "implement", "PASS", None, None, None,
4289
+ "claude", receipt_model, str(receipt_session),
4290
+ devlyn=receipt_devlyn, work=receipt_work,
4291
+ )
4292
+ except SystemExit as exc:
4293
+ assert "cannot replace spawn engine" in str(exc)
4294
+ else:
4295
+ raise AssertionError("completion replaced a Codex spawn with Claude attestation")
4296
+ assert confused_state == confused_before
4297
+ try:
4298
+ do_complete(
4299
+ confused_state, "implement", "PASS", None, None, None,
4300
+ None, "other-model", str(receipt_session),
4301
+ devlyn=receipt_devlyn, work=receipt_work,
4302
+ )
4303
+ except SystemExit as exc:
4304
+ assert "cannot replace requested model" in str(exc)
4305
+ else:
4306
+ raise AssertionError("completion replaced the requested Codex model")
4307
+ assert confused_state == confused_before
4308
+
4309
+ wrong_path_state = {
4310
+ "version": "3.0",
4311
+ "run_id": "rs-invocation-wrong-path",
4312
+ "engine": "codex",
4313
+ "phases": {"implement": None},
4314
+ }
4315
+ do_spawn(
4316
+ wrong_path_state, "implement", 0, None, None, "codex", receipt_model,
4317
+ prompt_sha256=receipt_prompt_sha, devlyn=receipt_devlyn,
4318
+ )
4319
+ arbitrary_log = receipt_devlyn / "handwritten.log"
4320
+ arbitrary_log.write_text(f"model: {receipt_model}\n", encoding="utf-8")
4321
+ wrong_path_error = do_complete(
4322
+ wrong_path_state, "implement", "PASS", None, None, None, None, None,
4323
+ str(arbitrary_log), devlyn=receipt_devlyn, work=receipt_work,
4324
+ )
4325
+ assert wrong_path_error and "canonical phase/round session" in wrong_path_error
4326
+ assert wrong_path_state["phases"]["implement"]["verdict"] == "BLOCKED"
4327
+ print("PASS iter-0112 canonical Codex invocation receipt and session ownership")
4328
+
4329
+ # Iter-0121: rs-20260906T173436Z-5a086a70590e exited 1 with an
4330
+ # empty canonical session and no plan. Exercise the real CLI writes.
4331
+ receipt_state_path = receipt_devlyn / "pipeline.state.json"
4332
+ missing_plan_output = receipt_devlyn / "plan.md"
4333
+ missing_plan_output.unlink()
4334
+ plan_session.write_bytes(b"")
4335
+ plan_path.unlink()
4336
+ receipt_runner.start_receipt(
4337
+ receipt_work, plan_path, plan_state["run_id"], "plan", 0,
4338
+ str(plan_prompt), str(plan_session),
4339
+ ["--json", "-C", str(receipt_work), "-s", "workspace-write",
4340
+ "-m", plan_model, "-c",
4341
+ "sandbox_workspace_write.network_access=false", "plan exactly"],
4342
+ )
4343
+ receipt_runner.finish_receipt(receipt_work, plan_path, 1)
4344
+ failed_receipt_bytes = plan_path.read_bytes()
4345
+
4346
+ def receipt_cli(phase: str, *event_args: str) -> subprocess.CompletedProcess[str]:
4347
+ return subprocess.run(
4348
+ [sys.executable, script, "--devlyn-dir", str(receipt_devlyn),
4349
+ "--phase", phase, *event_args],
4350
+ cwd=receipt_work, capture_output=True, text=True, check=False,
4351
+ )
4352
+
4353
+ write_state(receipt_state_path, {
4354
+ "version": "3.0", "run_id": plan_state["run_id"],
4355
+ "engine": "codex", "phases": {},
4356
+ })
4357
+ result = receipt_cli(
4358
+ "plan", "spawn", "--round", "0", "--engine", "codex",
4359
+ "--model", plan_model, "--prompt-sha256", plan_prompt_sha,
4360
+ )
4361
+ assert result.returncode == 0, result.stderr
4362
+ open_plan_bytes = receipt_state_path.read_bytes()
4363
+ blocked_args = (
4364
+ "--verdict", "BLOCKED", "--engine-session-log", str(plan_session),
4365
+ )
4366
+ result = receipt_cli("plan", "complete", *blocked_args)
4367
+ assert result.returncode == 1, result.stderr
4368
+ assert "BLOCKED:invocation-receipt-invalid: Codex invocation exited 1" in result.stderr, result.stderr
4369
+ blocked_plan = read_state(receipt_state_path)["phases"]["plan"]
4370
+ assert blocked_plan["verdict"] == "BLOCKED"
4371
+ assert blocked_plan["output_sha256"] is None
4372
+ assert blocked_plan["model_effective"] is None
4373
+ assert blocked_plan["completed_at"] is not None
4374
+ assert blocked_plan["duration_ms"] == round((
4375
+ parse_iso(blocked_plan["completed_at"]) - parse_iso(blocked_plan["started_at"])
4376
+ ).total_seconds() * 1000)
4377
+ assert blocked_plan["duration_ms"] >= 0
4378
+ assert not os.path.lexists(missing_plan_output)
4379
+ assert plan_path.read_bytes() == failed_receipt_bytes
4380
+ print("PASS iter-0121 failed PLAN worker persists BLOCKED timing/null output and receipt error")
4381
+
4382
+ for phase, event_args in [
4383
+ (phase, (
4384
+ "spawn", "--round", "1" if phase == "plan" else "0",
4385
+ "--triggered-by", "plan", "--engine", "codex",
4386
+ "--model", plan_model, "--prompt-sha256", plan_prompt_sha,
4387
+ )) for phase in sorted(PHASE_NAMES - {"final_report"})
4388
+ ] + [
4389
+ ("surface_close", ("surface-skip",)),
4390
+ ("surface_close", ("surface-check", "--authorized-surface-json", "[]")),
4391
+ ("surface_close", ("surface-adjudication-recover", "--authorized-surface-json", "[]")),
4392
+ ("surface_close", ("surface-rollback",)),
4393
+ ("implement", ("durability-enforce", "--round", "1", "--origin-phase", "verify")),
4394
+ ]:
4395
+ before = receipt_state_path.read_bytes()
4396
+ result = receipt_cli(phase, *event_args)
4397
+ assert result.returncode == 1, (phase, event_args, result.stderr)
4398
+ assert "BLOCKED:plan-output-missing" in result.stderr, (phase, event_args, result.stderr)
4399
+ assert receipt_state_path.read_bytes() == before
4400
+ result = receipt_cli("final_report", "spawn", "--round", "0")
4401
+ assert result.returncode == 0, result.stderr
4402
+ blocked_report = receipt_devlyn / "final-report.md"
4403
+ blocked_report.write_text(
4404
+ f"<!-- devlyn:final-report run_id={read_state(receipt_state_path)['run_id']} -->\n"
4405
+ "# BLOCKED\nPLAN invocation failed; no PLAN output was produced.\n", encoding="utf-8",
4406
+ )
4407
+ result = receipt_cli("final_report", "complete", "--verdict", "BLOCKED", "--log-file", str(blocked_report))
4408
+ assert result.returncode == 0, result.stderr
4409
+ terminal_state = read_state(receipt_state_path)
4410
+ assert terminal_state["phases"]["plan"] == blocked_plan
4411
+ assert terminal_state["phases"]["final_report"]["completed_at"] is not None
4412
+ assert terminal_state["phases"]["final_report"]["verdict"] == "BLOCKED"
4413
+ assert terminal_state["phases"]["final_report"]["output_sha256"] == hashlib.sha256(blocked_report.read_bytes()).hexdigest()
4414
+ with tempfile.TemporaryDirectory() as archive_tmp:
4415
+ archive_work = pathlib.Path(archive_tmp)
4416
+ archive_devlyn = archive_work / ".devlyn"
4417
+ archive_devlyn.mkdir()
4418
+ retained = (receipt_state_path, blocked_report, plan_path, plan_prompt, plan_session)
4419
+ for source in retained:
4420
+ shutil.copy2(source, archive_devlyn / source.name)
4421
+ archived = subprocess.run(
4422
+ [sys.executable, str(pathlib.Path(script).with_name("archive_run.py"))],
4423
+ cwd=archive_work, capture_output=True, text=True,
4424
+ )
4425
+ assert archived.returncode == 0, archived.stderr
4426
+ destination = archive_devlyn / "runs" / terminal_state["run_id"]
4427
+ assert not (archive_devlyn / "pipeline.state.json").exists()
4428
+ assert not (destination / "plan.md").exists()
4429
+ assert all((destination / source.name).read_bytes() == source.read_bytes() for source in retained)
4430
+ print("PASS iter-0121 missing-output BLOCKED permits final closure and refuses all work spawns/special events")
4431
+
4432
+ # Non-BLOCKED requests cannot obtain the exception via failed attestation.
4433
+ for verdict in sorted(VALID_VERDICTS - {"BLOCKED"}):
4434
+ receipt_state_path.write_bytes(open_plan_bytes)
4435
+ result = receipt_cli(
4436
+ "plan", "complete", "--verdict", verdict,
4437
+ "--engine-session-log", str(plan_session),
4438
+ )
4439
+ assert result.returncode == 1, (verdict, result.stderr)
4440
+ assert "BLOCKED:plan-integrity-invalid" in result.stderr
4441
+ assert receipt_state_path.read_bytes() == open_plan_bytes
4442
+ for next_phase in ("implement", "probe_derive", "final_report"):
4443
+ result = receipt_cli(
4444
+ "plan", "transition", *blocked_args, "--next-phase", next_phase,
4445
+ "--next-round", "0", "--next-engine", "claude",
4446
+ )
4447
+ assert result.returncode == 1, result.stderr
4448
+ assert "Codex invocation exited 1" in result.stderr
4449
+ assert receipt_state_path.read_bytes() == open_plan_bytes
4450
+ plan_path.write_text("{", encoding="utf-8")
4451
+ result = receipt_cli("plan", "complete", *blocked_args)
4452
+ assert result.returncode == 1, result.stderr
4453
+ assert "BLOCKED:invocation-receipt-invalid" in result.stderr
4454
+ malformed_plan = read_state(receipt_state_path)["phases"]["plan"]
4455
+ assert malformed_plan["verdict"] == "BLOCKED"
4456
+ assert malformed_plan["model_effective"] is None
4457
+ assert malformed_plan["output_sha256"] is None
4458
+ assert malformed_plan["completed_at"] is not None
4459
+
4460
+ # Even attested BLOCKED completion cannot transition into more work.
4461
+ plan_path.unlink()
4462
+ receipt_runner.start_receipt(
4463
+ receipt_work, plan_path, plan_state["run_id"], "plan", 0,
4464
+ str(plan_prompt), str(plan_session),
4465
+ ["--json", "-C", str(receipt_work), "-s", "workspace-write",
4466
+ "-m", plan_model, "-c",
4467
+ "sandbox_workspace_write.network_access=false", "plan exactly"],
4468
+ )
4469
+ receipt_runner.finish_receipt(receipt_work, plan_path, 0)
4470
+ receipt_state_path.write_bytes(open_plan_bytes)
4471
+ for next_phase in ("implement", "probe_derive"):
4472
+ result = receipt_cli(
4473
+ "plan", "transition", *blocked_args, "--next-phase", next_phase,
4474
+ "--next-round", "0", "--next-engine", "claude",
4475
+ )
4476
+ assert result.returncode == 1, result.stderr
4477
+ assert "BLOCKED:plan-output-missing" in result.stderr
4478
+ assert receipt_state_path.read_bytes() == open_plan_bytes
4479
+ result = receipt_cli(
4480
+ "plan", "transition", *blocked_args, "--next-phase", "final_report",
4481
+ "--next-round", "0",
4482
+ )
4483
+ assert result.returncode == 0, result.stderr
4484
+ absent_transition_bytes = receipt_state_path.read_bytes()
4485
+ assert read_state(receipt_state_path)["phases"]["plan"]["model_effective"] is None
4486
+ print("PASS iter-0121 non-BLOCKED output requirement, malformed receipt and atomic transitions")
4487
+
4488
+ for path_kind in ("file", "directory", "dangling-symlink", "unreadable"):
4489
+ if path_kind == "directory":
4490
+ missing_plan_output.mkdir()
4491
+ elif path_kind == "dangling-symlink":
4492
+ missing_plan_output.symlink_to(receipt_devlyn / "missing-target")
4493
+ else:
4494
+ missing_plan_output.write_bytes(b"## Files to touch\n")
4495
+ if path_kind == "unreadable":
4496
+ missing_plan_output.chmod(0)
4497
+ try:
4498
+ receipt_state_path.write_bytes(absent_transition_bytes)
4499
+ result = receipt_cli("final_report", "complete", "--verdict", "BLOCKED")
4500
+ assert result.returncode == 1, (path_kind, result.stderr)
4501
+ assert "BLOCKED:plan-integrity-invalid" in result.stderr
4502
+ assert receipt_state_path.read_bytes() == absent_transition_bytes
4503
+ if path_kind in {"directory", "dangling-symlink"} or (
4504
+ path_kind == "unreadable" and not os.access(missing_plan_output, os.R_OK)
4505
+ ):
4506
+ receipt_state_path.write_bytes(open_plan_bytes)
4507
+ result = receipt_cli("plan", "complete", *blocked_args)
4508
+ assert result.returncode == 1, (path_kind, result.stderr)
4509
+ assert "BLOCKED:plan-integrity-invalid" in result.stderr
4510
+ assert receipt_state_path.read_bytes() == open_plan_bytes
4511
+ finally:
4512
+ if path_kind == "directory":
4513
+ missing_plan_output.rmdir()
4514
+ else:
4515
+ if path_kind == "unreadable":
4516
+ missing_plan_output.chmod(0o600)
4517
+ missing_plan_output.unlink()
4518
+ print("PASS iter-0121 lexical absence rejects existing/nonregular/unreadable output")
4519
+
4520
+ # BLOCKED with bytes still binds them, including across legal correction.
4521
+ missing_plan_output.write_bytes(b"## Files to touch\n")
4522
+ receipt_state_path.write_bytes(open_plan_bytes)
4523
+ result = receipt_cli("plan", "complete", *blocked_args)
4524
+ assert result.returncode == 0, result.stderr
4525
+ bound_bytes = receipt_state_path.read_bytes()
4526
+ assert read_state(receipt_state_path)["phases"]["plan"]["output_sha256"] == hashlib.sha256(
4527
+ missing_plan_output.read_bytes()
4528
+ ).hexdigest()
4529
+ result = receipt_cli(
4530
+ "plan", "spawn", "--round", "1", "--triggered-by", "plan",
4531
+ "--engine", "codex", "--model", plan_model,
4532
+ "--prompt-sha256", plan_prompt_sha_1,
4533
+ )
4534
+ assert result.returncode == 0, result.stderr
4535
+ correction_bytes = receipt_state_path.read_bytes()
4536
+ missing_plan_output.unlink()
4537
+ result = receipt_cli(
4538
+ "plan", "complete", "--verdict", "BLOCKED",
4539
+ "--engine-session-log", str(plan_session_1),
4540
+ )
4541
+ assert result.returncode == 1, result.stderr
4542
+ assert "BLOCKED:plan-integrity-invalid" in result.stderr
4543
+ assert receipt_state_path.read_bytes() == correction_bytes
4544
+ for changed in (False, True):
4545
+ if changed:
4546
+ missing_plan_output.write_bytes(b"altered plan\n")
4547
+ receipt_state_path.write_bytes(bound_bytes)
4548
+ result = receipt_cli("final_report", "spawn", "--round", "0")
4549
+ assert result.returncode == 1, result.stderr
4550
+ assert "BLOCKED:plan-integrity-" in result.stderr
4551
+ assert receipt_state_path.read_bytes() == bound_bytes
4552
+ print("PASS iter-0121 BLOCKED output binding rejects deletion/tampering and lost correction output")
4553
+
2118
4554
  retained_log.unlink()
2119
4555
  write_state(state_path, {"phases": {}})
2120
4556
  state = read_state(state_path)
@@ -2128,14 +4564,20 @@ def self_test() -> int:
2128
4564
  # Supplied evidence must parse and never silently record null.
2129
4565
  write_state(state_path, {"phases": {}})
2130
4566
  state = read_state(state_path)
2131
- do_spawn(state, "plan", 0, None, None, "claude", "claude-default")
4567
+ do_spawn(
4568
+ state, "plan", 0, None, None, "claude", "claude-default",
4569
+ prompt_sha256=digest0,
4570
+ )
2132
4571
  assert do_complete(state, "plan", "PASS", None, None, None, None, None) is None
2133
4572
  assert state["phases"]["plan"]["model_effective"] is None
2134
4573
  invalid_log = devlyn / "invalid-session.log"
2135
4574
  invalid_log.write_text("no model evidence\n", encoding="utf-8")
2136
4575
  write_state(state_path, {"phases": {}})
2137
4576
  state = read_state(state_path)
2138
- do_spawn(state, "plan", 0, None, None, "claude", "claude-default")
4577
+ do_spawn(
4578
+ state, "plan", 0, None, None, "claude", "claude-default",
4579
+ prompt_sha256=digest0,
4580
+ )
2139
4581
  invalid = do_complete(
2140
4582
  state, "plan", "PASS", None, None, None, None, None, str(invalid_log)
2141
4583
  )
@@ -2227,7 +4669,6 @@ def self_test() -> int:
2227
4669
  ["git", "show", "-s", "--format=%s", "HEAD"], cwd=exact_repo,
2228
4670
  check=True, capture_output=True, text=True,
2229
4671
  ).stdout.strip() == "chore(pipeline): closure-restore round 1"
2230
- import importlib.util
2231
4672
  gate_spec = importlib.util.spec_from_file_location(
2232
4673
  "f7_carrier_gate", pathlib.Path(__file__).resolve().parents[3]
2233
4674
  / "benchmark/ceiling/scripts/f7-carrier-gate.py",
@@ -2502,11 +4943,13 @@ def main() -> int:
2502
4943
  ap.add_argument("--devlyn-dir", default=".devlyn")
2503
4944
  ap.add_argument("--phase", choices=sorted(PHASE_NAMES))
2504
4945
  ap.add_argument("--self-test", action="store_true")
4946
+ ap.add_argument("--freeze-roles", action="store_true")
4947
+ ap.add_argument("--default-engine", default="claude")
2505
4948
  sub = ap.add_subparsers(dest="event")
2506
4949
 
2507
4950
  spawn_p = sub.add_parser("spawn")
2508
4951
  spawn_p.add_argument("--round", type=int, required=True)
2509
- spawn_p.add_argument("--triggered-by", choices=sorted(VALID_TRIGGERS), default=None)
4952
+ spawn_p.add_argument("--triggered-by", choices=sorted(SPAWN_TRIGGERS), default=None)
2510
4953
  spawn_p.add_argument("--pre-sha", default=None)
2511
4954
  spawn_p.add_argument("--input-patch-sha256", default=None)
2512
4955
  spawn_p.add_argument("--prompt-sha256", default=None)
@@ -2533,7 +4976,7 @@ def main() -> int:
2533
4976
  transition_p.add_argument("--engine-session-log", default=None)
2534
4977
  transition_p.add_argument("--next-phase", choices=sorted(PHASE_NAMES), required=True)
2535
4978
  transition_p.add_argument("--next-round", type=int, required=True)
2536
- transition_p.add_argument("--next-triggered-by", choices=sorted(VALID_TRIGGERS), default=None)
4979
+ transition_p.add_argument("--next-triggered-by", choices=sorted(SPAWN_TRIGGERS), default=None)
2537
4980
  transition_p.add_argument("--next-pre-sha", default=None)
2538
4981
  transition_p.add_argument("--next-input-patch-sha256", default=None)
2539
4982
  transition_p.add_argument("--next-prompt-sha256", default=None)
@@ -2555,12 +4998,24 @@ def main() -> int:
2555
4998
  if args.self_test:
2556
4999
  return self_test()
2557
5000
 
5001
+ if args.freeze_roles:
5002
+ devlyn = pathlib.Path(args.devlyn_dir)
5003
+ state_path = devlyn / "pipeline.state.json"
5004
+ try:
5005
+ state = read_state(state_path)
5006
+ result = freeze_roles(state, devlyn.resolve().parent, args.default_engine)
5007
+ write_state(state_path, state)
5008
+ print(json.dumps(result, sort_keys=True))
5009
+ return 0
5010
+ except (ValueError, OSError) as exc:
5011
+ print(str(exc), file=sys.stderr)
5012
+ return 1
5013
+
2558
5014
  surface_events = {
2559
5015
  "surface-check", "surface-adjudication-recover", "surface-rollback", "surface-skip",
2560
5016
  }
2561
5017
  if not args.phase or args.event not in {"spawn", "complete", "transition", "durability-enforce", *surface_events}:
2562
5018
  ap.error("--phase and a phase event are required unless --self-test")
2563
-
2564
5019
  devlyn = pathlib.Path(args.devlyn_dir)
2565
5020
  if not devlyn.is_dir():
2566
5021
  sys.stderr.write(f"error: {devlyn} is not a directory\n")
@@ -2571,6 +5026,7 @@ def main() -> int:
2571
5026
  if args.event == "durability-enforce":
2572
5027
  if args.phase != "implement":
2573
5028
  ap.error("durability-enforce is valid only for --phase implement")
5029
+ validate_plan_output(state, devlyn, args.phase)
2574
5030
  _persist_durability_event(
2575
5031
  pathlib.Path.cwd(), devlyn, state, state_path, args.origin_phase, args.round,
2576
5032
  )
@@ -2580,6 +5036,7 @@ def main() -> int:
2580
5036
  if args.event in surface_events:
2581
5037
  if args.phase != "surface_close":
2582
5038
  ap.error(f"{args.event} is valid only for --phase surface_close")
5039
+ validate_plan_output(state, devlyn, args.phase)
2583
5040
  if args.event == "surface-skip":
2584
5041
  do_surface_skip(state)
2585
5042
  write_state(state_path, state)
@@ -2652,6 +5109,7 @@ def main() -> int:
2652
5109
  input_patch_sha256=args.input_patch_sha256,
2653
5110
  prompt_sha256=args.prompt_sha256,
2654
5111
  untracked_before=untracked_before,
5112
+ devlyn=devlyn,
2655
5113
  )
2656
5114
  else:
2657
5115
  next_untracked_before = (
@@ -2669,6 +5127,7 @@ def main() -> int:
2669
5127
  next_input_patch_sha256=args.next_input_patch_sha256,
2670
5128
  next_prompt_sha256=args.next_prompt_sha256,
2671
5129
  next_untracked_before=next_untracked_before,
5130
+ work=pathlib.Path.cwd(),
2672
5131
  )
2673
5132
  if spawn_phase == "surface_close":
2674
5133
  validate_surface_inputs(pathlib.Path.cwd(), devlyn, state)
@@ -2700,7 +5159,16 @@ def main() -> int:
2700
5159
  attestation_error = do_complete(
2701
5160
  state, args.phase, args.verdict, args.post_sha, args.findings_file,
2702
5161
  args.log_file, args.engine, args.model, args.engine_session_log, devlyn,
5162
+ pathlib.Path.cwd(),
2703
5163
  )
5164
+ if (
5165
+ args.phase == "plan"
5166
+ and state["phases"]["plan"]["verdict"] in {"PASS", "PASS_WITH_ISSUES"}
5167
+ ):
5168
+ raise SystemExit(
5169
+ "error: phases.plan complete with PASS or PASS_WITH_ISSUES requires "
5170
+ "--phase plan transition --verdict <verdict> --next-phase <phase>"
5171
+ )
2704
5172
 
2705
5173
  if args.event not in {"spawn", "transition"}:
2706
5174
  write_state(state_path, state)