devlyn-cli 2.10.1 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/AGENTS.md +8 -9
  2. package/CLAUDE.md +17 -17
  3. package/README.md +33 -9
  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/bin/devlyn.js +9 -2
  41. package/config/skills/_shared/.ruff.toml +2 -0
  42. package/config/skills/_shared/adapters/claude.md +9 -1
  43. package/config/skills/_shared/adapters/codex.md +5 -0
  44. package/config/skills/_shared/adapters/grok.md +4 -2
  45. package/config/skills/_shared/archive_run.py +738 -56
  46. package/config/skills/_shared/codex-config.md +19 -4
  47. package/config/skills/_shared/codex-monitored.sh +81 -12
  48. package/config/skills/_shared/collect-codex-findings.py +311 -128
  49. package/config/skills/_shared/engine-preflight.md +8 -7
  50. package/config/skills/_shared/expected.schema.json +61 -0
  51. package/config/skills/_shared/finish-gate.py +12 -11
  52. package/config/skills/_shared/grok-anchor-guard.py +18 -1
  53. package/config/skills/_shared/invocation-receipt.py +1041 -0
  54. package/config/skills/_shared/judge-output-parser.py +200 -0
  55. package/config/skills/_shared/judge-role-evidence.py +273 -0
  56. package/config/skills/_shared/process-evidence.py +909 -0
  57. package/config/skills/_shared/resolve-bootstrap.py +776 -84
  58. package/config/skills/_shared/resolve-stop-hook.py +33 -1
  59. package/config/skills/_shared/role-config.py +552 -0
  60. package/config/skills/_shared/run-bounded.py +1 -1
  61. package/config/skills/_shared/spec-verify-check.py +749 -181
  62. package/config/skills/_shared/state-phase-write.py +1838 -58
  63. package/config/skills/_shared/terminal-claim-check.py +15 -0
  64. package/config/skills/_shared/verify-merge-findings.py +958 -51
  65. package/config/skills/devlyn:engines/SKILL.md +8 -16
  66. package/config/skills/devlyn:ideate/SKILL.md +2 -2
  67. package/config/skills/devlyn:ideate/references/spec-template.md +6 -5
  68. package/config/skills/devlyn:queue/SKILL.md +7 -4
  69. package/config/skills/devlyn:resolve/SKILL.md +93 -35
  70. package/config/skills/devlyn:resolve/references/free-form-mode.md +1 -0
  71. package/config/skills/devlyn:resolve/references/outer-loop.md +15 -0
  72. package/config/skills/devlyn:resolve/references/phases/build-gate.md +31 -2
  73. package/config/skills/devlyn:resolve/references/phases/implement.md +3 -2
  74. package/config/skills/devlyn:resolve/references/phases/verify.md +120 -84
  75. package/config/skills/devlyn:resolve/references/state-schema.md +22 -8
  76. package/package.json +5 -1
  77. package/scripts/lint-skills.sh +314 -285
@@ -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
@@ -51,11 +53,12 @@ LEGAL_TRANSITIONS = {
51
53
  "final_report": set(),
52
54
  }
53
55
  WORKER_SESSION_ARTIFACT_PHASES = {
56
+ "plan": "plan",
54
57
  "implement": "implement",
55
58
  "surface_close": "surface-close",
59
+ "build_gate": "build_gate",
56
60
  "cleanup": "cleanup",
57
61
  }
58
- MODEL_HEADER_RE = re.compile(r"(?m)^[ \t]*model:[ \t]*(\S+)[ \t]*$")
59
62
  SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
60
63
  SURFACE_ROW_RE = re.compile(
61
64
  r"^(?P<obligation>UVR-STALE|PATH-TEST): (?:"
@@ -74,17 +77,32 @@ PLAN_SPAWN_RECEIPT_FIELDS = (
74
77
  "round", "started_at", "triggered_by", "engine", "model_requested", "prompt_sha256",
75
78
  )
76
79
  PLAN_COMPLETION_RECEIPT_FIELDS = (
77
- "completed_at", "duration_ms", "verdict", "model_effective",
80
+ "completed_at", "duration_ms", "verdict", "model_effective", "output_sha256",
78
81
  )
79
82
  PLAN_RECEIPT_FIELDS = PLAN_SPAWN_RECEIPT_FIELDS + PLAN_COMPLETION_RECEIPT_FIELDS
83
+ _PROCESS_EVIDENCE_MODULE = None
84
+ _INVOCATION_RECEIPT_MODULE = None
80
85
 
81
86
 
82
87
  def reject_json_constant(token: str) -> None:
83
88
  raise ValueError(f"invalid JSON numeric constant: {token}")
84
89
 
85
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
+
86
100
  def loads_strict_json(text: str):
87
- 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
+ )
88
106
 
89
107
 
90
108
  def now_ms() -> datetime.datetime:
@@ -129,6 +147,236 @@ def write_state(state_path: pathlib.Path, state: dict) -> None:
129
147
  raise
130
148
 
131
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
+
132
380
  def parse_string_list(raw: str, label: str) -> list[str]:
133
381
  try:
134
382
  value = loads_strict_json(raw)
@@ -235,6 +483,24 @@ def ensure_surface_clean_baseline(work: pathlib.Path, devlyn: pathlib.Path, stat
235
483
  raise SystemExit(f"BLOCKED:surface-close-preexisting-delta: {detail}")
236
484
 
237
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
+
238
504
  def validate_authorized_surface(raw: str) -> list[str]:
239
505
  surface = parse_string_list(raw, "--authorized-surface-json")
240
506
  if not surface:
@@ -247,23 +513,42 @@ def validate_authorized_surface(raw: str) -> list[str]:
247
513
  or pathlib.PurePosixPath(path).is_absolute() or ".." in parts
248
514
  ):
249
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}")
250
519
  return surface
251
520
 
252
521
 
253
522
  def path_matches_surface(path: str, surface: list[str]) -> bool:
254
- parsed = pathlib.PurePosixPath(path)
255
- if parsed.is_absolute() or ".." in parsed.parts:
256
- return False
257
523
  for entry in surface:
258
- if entry.endswith("/**"):
259
- prefix = entry[:-3].rstrip("/")
260
- 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:
261
543
  return True
262
- elif path == entry:
263
- return True
264
544
  return False
265
545
 
266
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
+
267
552
  def worktree_file_exists(work: pathlib.Path, path: str) -> bool:
268
553
  parsed = pathlib.PurePosixPath(path)
269
554
  if parsed.is_absolute() or ".." in parsed.parts:
@@ -296,17 +581,17 @@ def resolve_na_surface_citation(
296
581
  if path in surface and not path.endswith("/**"):
297
582
  return True
298
583
  return worktree_file_exists(work, path) and any(
299
- entry.endswith("/**") and path_matches_surface(path, [entry])
584
+ entry.endswith("/**") and safe_path_matches_surface(path, [entry])
300
585
  for entry in surface
301
586
  )
302
587
 
303
588
  if proven(raw):
304
589
  return raw, None
305
- if worktree_path_exists(work, raw) and not path_matches_surface(raw, surface):
590
+ if worktree_path_exists(work, raw) and not safe_path_matches_surface(raw, surface):
306
591
  raise SystemExit(f"BLOCKED:surface-close-adjudication-out-of-surface: {raw}")
307
592
  if (
308
593
  worktree_path_exists(work, parsed_path)
309
- and not path_matches_surface(parsed_path, surface)
594
+ and not safe_path_matches_surface(parsed_path, surface)
310
595
  ):
311
596
  raise SystemExit(f"BLOCKED:surface-close-adjudication-out-of-surface: {raw}")
312
597
  for split in range(len(raw) - 1, -1, -1):
@@ -324,7 +609,7 @@ def resolve_na_surface_citation(
324
609
  def surface_offenders(work: pathlib.Path, devlyn: pathlib.Path, state: dict,
325
610
  surface: list[str]) -> list[str]:
326
611
  tracked, untracked = surface_delta_paths(work, devlyn, state)
327
- return sorted(path for path in set(tracked + untracked) if not path_matches_surface(path, surface))
612
+ return sorted(path for path in set(tracked + untracked) if not safe_path_matches_surface(path, surface))
328
613
 
329
614
 
330
615
  def path_exists_at_commit(work: pathlib.Path, sha: str, path: str) -> bool:
@@ -416,7 +701,7 @@ def validate_surface_adjudication(
416
701
  work, citation, path, line_number, surface,
417
702
  )
418
703
  citation = path if line_number is None else f"{path}:{line_number}"
419
- if not path_matches_surface(path, surface):
704
+ if not safe_path_matches_surface(path, surface):
420
705
  raise SystemExit(
421
706
  f"BLOCKED:surface-close-adjudication-out-of-surface: {citation}"
422
707
  )
@@ -539,7 +824,7 @@ def validate_surface_write_audit(
539
824
  "BLOCKED:surface-close-write-audit-violation: "
540
825
  f"line {line_number}: {raw_target!r}"
541
826
  ) from exc
542
- if not path_matches_surface(relative, surface):
827
+ if not safe_path_matches_surface(relative, surface):
543
828
  raise SystemExit(
544
829
  "BLOCKED:surface-close-write-audit-violation: "
545
830
  f"line {line_number}: {relative!r}"
@@ -623,10 +908,6 @@ def parse_effective_model(session_log: pathlib.Path) -> str:
623
908
  except (OSError, UnicodeError) as exc:
624
909
  raise ValueError(f"cannot read engine session log {session_log}: {exc}") from exc
625
910
 
626
- header = MODEL_HEADER_RE.search(text)
627
- if header:
628
- return header.group(1)
629
-
630
911
  models: set[str] = set()
631
912
  if session_log.suffix == ".jsonl":
632
913
  for line_number, line in enumerate(text.splitlines(), start=1):
@@ -1254,9 +1535,9 @@ def clear_verify_round_artifacts(devlyn: pathlib.Path) -> None:
1254
1535
  # verify-merge-findings.py (iter-0060 R0 finding).
1255
1536
  # verify*.jsonl (not just *.findings.jsonl): judge-specific files like
1256
1537
  # verify.findings.judge-codex.jsonl end in .judge-<engine>.jsonl.
1257
- # *-judge.* covers every engine's stdout/stderr capture (codex-judge.*,
1258
- # claude-judge.* — adapters/claude.md ## Invocation).
1259
- 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"):
1260
1541
  for path in devlyn.glob(pattern):
1261
1542
  path.unlink()
1262
1543
  (devlyn / "verify-merge.summary.json").unlink(missing_ok=True)
@@ -1272,23 +1553,90 @@ def append_phase_history(entry: dict, phase: str) -> None:
1272
1553
  history = entry.get("history")
1273
1554
  if not isinstance(history, list):
1274
1555
  history = []
1275
- fields = (
1276
- PLAN_RECEIPT_FIELDS if phase == "plan" and is_plan_dispatch_receipt(entry)
1277
- else ("started_at", "verdict", "completed_at", "duration_ms")
1278
- )
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")
1279
1569
  history.append({field: entry.get(field) for field in fields})
1280
1570
  entry["history"] = history
1281
1571
 
1282
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
+
1283
1619
  def do_spawn(state: dict, phase: str, round_: int, triggered_by: str | None,
1284
1620
  pre_sha: str | None, engine: str | None, model: str | None, *,
1285
1621
  input_patch_sha256: str | None = None,
1286
1622
  prompt_sha256: str | None = None,
1287
- 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
1288
1635
  # Merge, don't replace: a phase-gated large run's `exec` progress (or any
1289
1636
  # other field this script doesn't own) survives a fix-loop respawn.
1290
1637
  phases_value = state.get("phases")
1291
1638
  entry = phases_value.get(phase) if isinstance(phases_value, dict) else None
1639
+ validate_plan_output(state, devlyn, phase)
1292
1640
  if phase == "plan":
1293
1641
  history = entry.get("history", []) if isinstance(entry, dict) else []
1294
1642
  if not isinstance(history, list):
@@ -1312,8 +1660,10 @@ def do_spawn(state: dict, phase: str, round_: int, triggered_by: str | None,
1312
1660
  raise SystemExit("error: phases.plan re-spawn requires --triggered-by")
1313
1661
  if phase == "surface_close" and engine != "claude":
1314
1662
  raise SystemExit("error: phases.surface_close spawn requires --engine claude")
1315
- if phase == "surface_close" and not model:
1316
- raise SystemExit("error: phases.surface_close spawn requires --model")
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")
1317
1667
  if phase == "surface_close" and isinstance(entry, dict) and (
1318
1668
  entry.get("started_at") is not None or entry.get("skipped_reason") is not None
1319
1669
  ):
@@ -1330,8 +1680,38 @@ def do_spawn(state: dict, phase: str, round_: int, triggered_by: str | None,
1330
1680
  elif phase == "plan":
1331
1681
  if input_patch_sha256 is not None or untracked_before is not None:
1332
1682
  raise SystemExit("error: SURFACE_CLOSE metadata is invalid for this phase")
1333
- elif input_patch_sha256 is not None or prompt_sha256 is not None or untracked_before is not None:
1334
- raise SystemExit("error: SURFACE_CLOSE metadata is invalid for this phase")
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
+ )
1335
1715
  phases = state.setdefault("phases", {})
1336
1716
  if not isinstance(entry, dict):
1337
1717
  entry = {}
@@ -1341,6 +1721,9 @@ def do_spawn(state: dict, phase: str, round_: int, triggered_by: str | None,
1341
1721
  f"error: phases.{phase} has an open span — complete it before respawn"
1342
1722
  )
1343
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)
1344
1727
  entry["started_at"] = now_iso()
1345
1728
  entry["completed_at"] = None
1346
1729
  entry["duration_ms"] = None
@@ -1351,8 +1734,16 @@ def do_spawn(state: dict, phase: str, round_: int, triggered_by: str | None,
1351
1734
  entry["sub_verdicts"] = None
1352
1735
  if phase == "verify":
1353
1736
  entry["judge_durations_ms"] = None
1737
+ entry.pop("role_evidence", None)
1354
1738
  if engine is not None:
1355
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"]
1356
1747
  if model is not None:
1357
1748
  entry["model_requested"] = model
1358
1749
  else:
@@ -1361,7 +1752,7 @@ def do_spawn(state: dict, phase: str, round_: int, triggered_by: str | None,
1361
1752
  entry["model_effective"] = None
1362
1753
  if pre_sha is not None:
1363
1754
  entry["pre_sha"] = pre_sha
1364
- if phase in {"plan", "surface_close"}:
1755
+ if prompt_sha256 is not None:
1365
1756
  entry["prompt_sha256"] = prompt_sha256
1366
1757
  if phase == "surface_close":
1367
1758
  entry["input_patch_sha256"] = input_patch_sha256
@@ -1395,11 +1786,26 @@ def do_complete(state: dict, phase: str, verdict: str | None,
1395
1786
  post_sha: str | None, findings_file: str | None, log_file: str | None,
1396
1787
  engine: str | None, model: str | None,
1397
1788
  engine_session_log: str | None = None,
1398
- devlyn: pathlib.Path | None = None) -> str | None:
1789
+ devlyn: pathlib.Path | None = None,
1790
+ work: pathlib.Path | None = None) -> str | None:
1399
1791
  phases = state.setdefault("phases", {})
1400
1792
  entry = phases.get(phase)
1401
1793
  if not isinstance(entry, dict) or not entry.get("started_at"):
1402
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"
1403
1809
  if phase == "plan" and entry.get("prompt_sha256") is not None:
1404
1810
  missing = [field for field in PLAN_SPAWN_RECEIPT_FIELDS if field not in entry]
1405
1811
  if missing:
@@ -1410,16 +1816,19 @@ def do_complete(state: dict, phase: str, verdict: str | None,
1410
1816
  raise SystemExit("error: phases.plan completion cannot replace spawn engine")
1411
1817
  if model is not None and model != entry["model_requested"]:
1412
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)
1413
1827
  started = parse_iso(entry["started_at"])
1414
1828
  now = now_ms()
1415
1829
  entry["completed_at"] = now_iso(now)
1416
1830
  entry["duration_ms"] = max(0, round((now - started).total_seconds() * 1000))
1417
1831
  if phase == "verify":
1418
- if verdict is not None:
1419
- raise SystemExit(
1420
- "error: phases.verify.verdict is owned by verify-merge-findings.py "
1421
- "--write-state; do not pass --verdict to complete for this phase"
1422
- )
1423
1832
  if entry.get("verdict") is None:
1424
1833
  raise SystemExit(
1425
1834
  "error: phases.verify.verdict is still null — run "
@@ -1429,6 +1838,13 @@ def do_complete(state: dict, phase: str, verdict: str | None,
1429
1838
  entry["verdict"] = verdict
1430
1839
  else:
1431
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
1432
1848
  if findings_file is not None or log_file is not None:
1433
1849
  artifacts = entry.setdefault("artifacts", {"findings_file": None, "log_file": None})
1434
1850
  if findings_file is not None:
@@ -1451,13 +1867,74 @@ def do_complete(state: dict, phase: str, verdict: str | None,
1451
1867
  retained_session = candidate
1452
1868
 
1453
1869
  attestation_error = None
1454
- 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:
1455
1908
  entry["model_effective"] = None
1456
1909
  if retained_session is not None:
1457
1910
  attestation_error = (
1458
1911
  "BLOCKED:model-attestation-failed: --engine-session-log is required because "
1459
1912
  f"retained worker session exists: {retained_session}"
1460
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}"
1461
1938
  else:
1462
1939
  try:
1463
1940
  entry["model_effective"] = parse_effective_model(pathlib.Path(engine_session_log))
@@ -1500,6 +1977,7 @@ def do_transition(
1500
1977
  next_prompt_sha256: str | None = None,
1501
1978
  next_untracked_before: list[str] | None = None,
1502
1979
  between=None,
1980
+ work: pathlib.Path | None = None,
1503
1981
  ) -> dict:
1504
1982
  """Validate complete + caller-selected spawn against a copy of state.
1505
1983
 
@@ -1513,7 +1991,7 @@ def do_transition(
1513
1991
  candidate = copy.deepcopy(state)
1514
1992
  attestation_error = do_complete(
1515
1993
  candidate, phase, verdict, post_sha, findings_file, log_file,
1516
- engine, model, engine_session_log, devlyn,
1994
+ engine, model, engine_session_log, devlyn, work,
1517
1995
  )
1518
1996
  if attestation_error is not None:
1519
1997
  raise SystemExit(attestation_error)
@@ -1525,6 +2003,7 @@ def do_transition(
1525
2003
  input_patch_sha256=next_input_patch_sha256,
1526
2004
  prompt_sha256=next_prompt_sha256,
1527
2005
  untracked_before=next_untracked_before,
2006
+ devlyn=devlyn,
1528
2007
  )
1529
2008
  return candidate
1530
2009
 
@@ -1544,9 +2023,157 @@ def do_surface_adjudication_recovery(state: dict, devlyn: pathlib.Path) -> str |
1544
2023
  return None
1545
2024
 
1546
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
+
1547
2129
  def self_test() -> int:
1548
2130
  import time
1549
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
+
1550
2177
  with tempfile.TemporaryDirectory() as tmp:
1551
2178
  devlyn = pathlib.Path(tmp)
1552
2179
  state_path = devlyn / "pipeline.state.json"
@@ -1686,6 +2313,43 @@ def self_test() -> int:
1686
2313
  }
1687
2314
  assert not is_plan_dispatch_receipt(old_receipt)
1688
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
+
1689
2353
  write_state(state_path, {
1690
2354
  "phases": {
1691
2355
  "plan": {
@@ -1785,10 +2449,547 @@ def self_test() -> int:
1785
2449
  write_state(state_path, state)
1786
2450
  final = read_state(state_path)["phases"]["implement"]
1787
2451
  assert final["verdict"] == "PASS"
2452
+ assert read_state(state_path)["process_evidence"] is None
1788
2453
  assert parse_iso(final["started_at"]) == parse_iso(respawned["started_at"])
1789
2454
  assert parse_iso(final["completed_at"]) >= parse_iso(final["started_at"])
1790
2455
  assert final["artifacts"]["findings_file"] == ".devlyn/x.jsonl"
1791
2456
 
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
+
1792
2993
  # complete() before spawn() must fail loudly, not silently invent data.
1793
2994
  write_state(state_path, {"phases": {}})
1794
2995
  state = read_state(state_path)
@@ -1860,7 +3061,9 @@ def self_test() -> int:
1860
3061
  attestation_state = copy.deepcopy(transition_state)
1861
3062
  attestation_state["phases"]["plan"]["model_requested"] = "wanted-model"
1862
3063
  attestation_log = devlyn / "transition-attestation.log"
1863
- 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")
1864
3067
  write_state(state_path, attestation_state)
1865
3068
  attestation_before = state_path.read_bytes()
1866
3069
  try:
@@ -2051,8 +3254,11 @@ def self_test() -> int:
2051
3254
  "verify-merge.summary.json",
2052
3255
  "codex-judge.stdout",
2053
3256
  "claude-judge.stdout",
3257
+ "claude-judge.stderr",
3258
+ "pair-judge.summary.json",
2054
3259
  ):
2055
3260
  (devlyn / name).write_text("stale\n", encoding="utf-8")
3261
+ (devlyn / "codex-primary-judge.prompt.md").write_text("current\n", encoding="utf-8")
2056
3262
  (devlyn / "spec-verify.json").write_text("{}", encoding="utf-8")
2057
3263
  clear_verify_round_artifacts(devlyn)
2058
3264
  for name in (
@@ -2063,8 +3269,11 @@ def self_test() -> int:
2063
3269
  "verify-merge.summary.json",
2064
3270
  "codex-judge.stdout",
2065
3271
  "claude-judge.stdout",
3272
+ "claude-judge.stderr",
3273
+ "pair-judge.summary.json",
2066
3274
  ):
2067
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"
2068
3277
  assert (devlyn / "spec-verify.json").exists(), "non-VERIFY-round files must survive"
2069
3278
 
2070
3279
  # Fix-loop respawn of phase-gated IMPLEMENT must preserve `exec`
@@ -2193,18 +3402,26 @@ def self_test() -> int:
2193
3402
  else:
2194
3403
  raise AssertionError("SURFACE_CLOSE accepted a non-Claude engine")
2195
3404
  assert rejected_state == {"sentinel": True}
2196
- rejected_state = {"sentinel": True}
2197
- try:
2198
- do_spawn(
2199
- rejected_state, "surface_close", 0, None, pre_sha, "claude", None,
2200
- input_patch_sha256=file_sha256(patch), prompt_sha256=file_sha256(prompt),
2201
- untracked_before=["kept.txt"],
2202
- )
2203
- except SystemExit as exc:
2204
- assert "phases.surface_close spawn requires --model" in str(exc)
2205
- else:
2206
- raise AssertionError("SURFACE_CLOSE accepted a missing requested model")
2207
- assert rejected_state == {"sentinel": True}
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
2208
3425
  do_spawn(
2209
3426
  surface_state, "surface_close", 0, None, pre_sha, "claude", "sonnet",
2210
3427
  input_patch_sha256=file_sha256(patch), prompt_sha256=file_sha256(prompt),
@@ -2213,10 +3430,45 @@ def self_test() -> int:
2213
3430
  validate_surface_inputs(work, work_devlyn, surface_state)
2214
3431
  validate_surface_prompt(work_devlyn, surface_state)
2215
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}")
2216
3452
  surface = validate_authorized_surface(
2217
3453
  '["allowed.txt", "branchbase", "exact-link", "exact:1", "schedule/**", '
2218
3454
  '"test_schedule.py", "tests/**", "x.ts"]'
2219
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}")
2220
3472
  outside = devlyn / "outside.txt"
2221
3473
  outside.write_text("outside\n", encoding="utf-8")
2222
3474
  (work / "outside-link").symlink_to(outside)
@@ -2514,6 +3766,78 @@ def self_test() -> int:
2514
3766
  assert completed_surface["model_effective"] == "sonnet"
2515
3767
  assert completed_surface["verdict"] == "PASS"
2516
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
+
2517
3841
  recovery_state = loads_strict_json(json.dumps(surface_state))
2518
3842
  recovery_started_at = recovery_state["phases"]["surface_close"]["started_at"]
2519
3843
  output.write_text("UVR-STALE: FIRED allowed.txt:1\nPASS\n", encoding="utf-8")
@@ -2567,10 +3891,16 @@ def self_test() -> int:
2567
3891
  assert skipped["verdict"] is None
2568
3892
  assert skipped["skipped_reason"] == SURFACE_SKIP_REASON
2569
3893
 
2570
- # 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.
2571
3896
  header_log = devlyn / "codex-build.log"
2572
3897
  header_log.write_text("session\nmodel: gpt-5.6-sol\n", encoding="utf-8")
2573
- assert parse_effective_model(header_log) == "gpt-5.6-sol"
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")
2574
3904
  rollout_log = devlyn / "rollout.jsonl"
2575
3905
  rollout_log.write_text(json.dumps({
2576
3906
  "type": "turn_context", "payload": {"model": "gpt-5.6-terra"},
@@ -2790,6 +4120,437 @@ def self_test() -> int:
2790
4120
  assert state["phases"]["implement"]["model_effective"] == "gpt-5.6-terra"
2791
4121
  assert state["phases"]["implement"]["verdict"] == "PASS"
2792
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
+
2793
4554
  retained_log.unlink()
2794
4555
  write_state(state_path, {"phases": {}})
2795
4556
  state = read_state(state_path)
@@ -2908,7 +4669,6 @@ def self_test() -> int:
2908
4669
  ["git", "show", "-s", "--format=%s", "HEAD"], cwd=exact_repo,
2909
4670
  check=True, capture_output=True, text=True,
2910
4671
  ).stdout.strip() == "chore(pipeline): closure-restore round 1"
2911
- import importlib.util
2912
4672
  gate_spec = importlib.util.spec_from_file_location(
2913
4673
  "f7_carrier_gate", pathlib.Path(__file__).resolve().parents[3]
2914
4674
  / "benchmark/ceiling/scripts/f7-carrier-gate.py",
@@ -3183,6 +4943,8 @@ def main() -> int:
3183
4943
  ap.add_argument("--devlyn-dir", default=".devlyn")
3184
4944
  ap.add_argument("--phase", choices=sorted(PHASE_NAMES))
3185
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")
3186
4948
  sub = ap.add_subparsers(dest="event")
3187
4949
 
3188
4950
  spawn_p = sub.add_parser("spawn")
@@ -3236,6 +4998,19 @@ def main() -> int:
3236
4998
  if args.self_test:
3237
4999
  return self_test()
3238
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
+
3239
5014
  surface_events = {
3240
5015
  "surface-check", "surface-adjudication-recover", "surface-rollback", "surface-skip",
3241
5016
  }
@@ -3251,6 +5026,7 @@ def main() -> int:
3251
5026
  if args.event == "durability-enforce":
3252
5027
  if args.phase != "implement":
3253
5028
  ap.error("durability-enforce is valid only for --phase implement")
5029
+ validate_plan_output(state, devlyn, args.phase)
3254
5030
  _persist_durability_event(
3255
5031
  pathlib.Path.cwd(), devlyn, state, state_path, args.origin_phase, args.round,
3256
5032
  )
@@ -3260,6 +5036,7 @@ def main() -> int:
3260
5036
  if args.event in surface_events:
3261
5037
  if args.phase != "surface_close":
3262
5038
  ap.error(f"{args.event} is valid only for --phase surface_close")
5039
+ validate_plan_output(state, devlyn, args.phase)
3263
5040
  if args.event == "surface-skip":
3264
5041
  do_surface_skip(state)
3265
5042
  write_state(state_path, state)
@@ -3332,6 +5109,7 @@ def main() -> int:
3332
5109
  input_patch_sha256=args.input_patch_sha256,
3333
5110
  prompt_sha256=args.prompt_sha256,
3334
5111
  untracked_before=untracked_before,
5112
+ devlyn=devlyn,
3335
5113
  )
3336
5114
  else:
3337
5115
  next_untracked_before = (
@@ -3349,6 +5127,7 @@ def main() -> int:
3349
5127
  next_input_patch_sha256=args.next_input_patch_sha256,
3350
5128
  next_prompt_sha256=args.next_prompt_sha256,
3351
5129
  next_untracked_before=next_untracked_before,
5130
+ work=pathlib.Path.cwd(),
3352
5131
  )
3353
5132
  if spawn_phase == "surface_close":
3354
5133
  validate_surface_inputs(pathlib.Path.cwd(), devlyn, state)
@@ -3380,6 +5159,7 @@ def main() -> int:
3380
5159
  attestation_error = do_complete(
3381
5160
  state, args.phase, args.verdict, args.post_sha, args.findings_file,
3382
5161
  args.log_file, args.engine, args.model, args.engine_session_log, devlyn,
5162
+ pathlib.Path.cwd(),
3383
5163
  )
3384
5164
  if (
3385
5165
  args.phase == "plan"