devlyn-cli 3.1.2 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/AGENTS.md +6 -6
  2. package/CLAUDE.md +7 -7
  3. package/README.md +34 -13
  4. package/bin/devlyn.js +44 -65
  5. package/bin/instruction-templates.json +82 -0
  6. package/bin/instructions.js +187 -0
  7. package/config/skills/_shared/adapters/claude.md +1 -5
  8. package/config/skills/_shared/adapters/codex.md +1 -5
  9. package/config/skills/_shared/adapters/omp.md +0 -4
  10. package/config/skills/_shared/archive_run.py +5 -1
  11. package/config/skills/_shared/codex-config.md +3 -14
  12. package/config/skills/_shared/engine-preflight.md +3 -3
  13. package/config/skills/_shared/expected.schema.json +2 -2
  14. package/config/skills/_shared/platform-support.py +4 -1
  15. package/config/skills/_shared/resolve-bootstrap.py +22 -2
  16. package/config/skills/_shared/role-config.py +14 -2
  17. package/config/skills/_shared/spec-verify-check.py +427 -101
  18. package/config/skills/_shared/state-phase-write.py +497 -49
  19. package/config/skills/_shared/task-complete.py +46 -5
  20. package/config/skills/_shared/terminal-claim-check.py +2 -2
  21. package/config/skills/devlyn:ideate/SKILL.md +1 -1
  22. package/config/skills/devlyn:ideate/references/from-spec-mode.md +1 -1
  23. package/config/skills/devlyn:ideate/references/spec-template.md +2 -2
  24. package/config/skills/devlyn:resolve/SKILL.md +29 -51
  25. package/config/skills/devlyn:resolve/references/free-form-mode.md +2 -2
  26. package/config/skills/devlyn:resolve/references/phases/build-gate.md +8 -7
  27. package/config/skills/devlyn:resolve/references/phases/cleanup.md +5 -11
  28. package/config/skills/devlyn:resolve/references/phases/implement.md +1 -0
  29. package/config/skills/devlyn:resolve/references/phases/plan.md +3 -3
  30. package/config/skills/devlyn:resolve/references/state-schema.md +10 -9
  31. package/package.json +2 -1
  32. package/scripts/lint-skills.sh +14 -4
  33. package/scripts/test-owner-phases.py +193 -0
@@ -39,7 +39,7 @@ import sys
39
39
  import tempfile
40
40
 
41
41
  VALID_VERDICTS = {"PASS", "PASS_WITH_ISSUES", "FAIL", "NEEDS_WORK", "BLOCKED"}
42
- VALID_TRIGGERS = {"build_gate", "verify"}
42
+ VALID_TRIGGERS = {"build_gate", "cleanup", "verify"}
43
43
  SPAWN_TRIGGERS = VALID_TRIGGERS | {"plan"}
44
44
  PHASE_NAMES = {"plan", "probe_derive", "implement", "surface_close", "build_gate", "cleanup", "verify", "final_report"}
45
45
  LEGAL_TRANSITIONS = {
@@ -48,7 +48,7 @@ LEGAL_TRANSITIONS = {
48
48
  "implement": {"implement", "surface_close", "build_gate", "cleanup", "verify", "final_report"},
49
49
  "surface_close": {"build_gate", "cleanup", "verify", "final_report"},
50
50
  "build_gate": {"implement", "cleanup", "verify", "final_report"},
51
- "cleanup": {"verify", "final_report"},
51
+ "cleanup": {"implement", "verify", "final_report"},
52
52
  "verify": {"implement", "final_report"},
53
53
  "final_report": set(),
54
54
  }
@@ -308,6 +308,36 @@ def bind_process_evidence(
308
308
  raise runner.EvidenceError(
309
309
  "spec-verify.results.json commands must be an array"
310
310
  )
311
+ preflight_failure = "preflight_failure" in results
312
+ if preflight_failure:
313
+ failure = results["preflight_failure"]
314
+ if (
315
+ not isinstance(failure, dict)
316
+ or set(failure) != {"run_id", "phase", "round", "findings"}
317
+ or failure["run_id"] != state.get("run_id")
318
+ or failure["phase"] != phase
319
+ or type(failure["round"]) is not int
320
+ or failure["round"] != runner.phase_round(state, phase)
321
+ ):
322
+ raise runner.EvidenceError("BUILD_GATE preflight failure identity is invalid")
323
+ record = failure["findings"]
324
+ if not isinstance(record, dict) or set(record) != {"path", "sha256"}:
325
+ raise runner.EvidenceError("BUILD_GATE preflight failure findings binding is invalid")
326
+ path = runner._checked_file(work, record["path"], "preflight failure findings")
327
+ raw = path.read_bytes()
328
+ if hashlib.sha256(raw).hexdigest() != record["sha256"]:
329
+ raise runner.EvidenceError("BUILD_GATE preflight failure findings digest mismatch")
330
+ findings = [loads_strict_json(line) for line in raw.decode("utf-8").splitlines() if line.strip()]
331
+ if len(findings) != 1 or not isinstance(findings[0], dict) or (
332
+ findings[0].get("phase") != phase
333
+ or findings[0].get("severity") != "CRITICAL"
334
+ or findings[0].get("rule_id") not in {
335
+ "correctness.spec-verify-malformed", "correctness.risk-probe-integrity",
336
+ }
337
+ ):
338
+ raise runner.EvidenceError("BUILD_GATE preflight failure requires its CRITICAL finding")
339
+ if verdict not in {"FAIL", "BLOCKED"}:
340
+ raise runner.EvidenceError("BUILD_GATE preflight failure requires FAIL or BLOCKED verdict")
311
341
  carrier = results.get("process_evidence")
312
342
  if carrier is None:
313
343
  if commands:
@@ -315,43 +345,42 @@ def bind_process_evidence(
315
345
  "BUILD_GATE commands exist without a process-evidence carrier"
316
346
  )
317
347
  manifest_path = work / runner.manifest_relative_path(state, phase)
318
- if manifest_path.exists():
348
+ if os.path.lexists(manifest_path):
319
349
  raise runner.EvidenceError(
320
350
  "BUILD_GATE manifest exists without a process-evidence carrier"
321
351
  )
322
- if runner.mechanical_evidence_required(work, state):
352
+ if not preflight_failure and runner.mechanical_evidence_required(work, state):
323
353
  raise runner.EvidenceError(
324
354
  "required BUILD_GATE evidence has no process-evidence carrier"
325
355
  )
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
- )
356
+ else:
357
+ round_ = runner.phase_round(state, phase)
358
+ manifest = carrier.get("manifest") if isinstance(carrier, dict) else None
359
+ if (
360
+ not isinstance(carrier, dict)
361
+ or carrier.get("phase") != phase
362
+ or carrier.get("round") != round_
363
+ or not isinstance(manifest, dict)
364
+ or manifest.get("path") != runner.manifest_relative_path(state, phase)
365
+ ):
366
+ raise runner.EvidenceError(
367
+ "BUILD_GATE process-evidence carrier does not match the active run/round"
368
+ )
369
+ outcome = runner.validate_summary_commands(work, commands, carrier)
370
+ if outcome["verdict"] == "BLOCKED" and verdict != "BLOCKED":
371
+ denial = outcome["capability_denials"][0]
372
+ raise runner.EvidenceError(
373
+ "BUILD_GATE capability denial requires BLOCKED verdict: "
374
+ f"{denial['id']}:{denial['operation']}"
375
+ )
376
+ if (
377
+ outcome["verdict"] == "NEEDS_WORK"
378
+ and verdict in {"PASS", "PASS_WITH_ISSUES"}
379
+ ):
380
+ raise runner.EvidenceError(
381
+ "BUILD_GATE process evidence mismatch cannot complete as "
382
+ f"{verdict}: {','.join(outcome['failed_ids'])}"
383
+ )
355
384
  except (runner.EvidenceError, OSError, UnicodeError, ValueError) as exc:
356
385
  raise SystemExit(f"BLOCKED:process-evidence-invalid: {exc}") from exc
357
386
  existing = state.get("process_evidence")
@@ -362,8 +391,11 @@ def bind_process_evidence(
362
391
  try:
363
392
  for prior in existing:
364
393
  runner.validate_bound_carrier(work, prior)
365
- except runner.EvidenceError as exc:
394
+ except (runner.EvidenceError, OSError, UnicodeError, ValueError) as exc:
366
395
  raise SystemExit(f"BLOCKED:process-evidence-invalid: {exc}") from exc
396
+ if carrier is None:
397
+ state.setdefault("process_evidence", None)
398
+ return
367
399
  if any(
368
400
  isinstance(item, dict)
369
401
  and item.get("phase") == carrier["phase"]
@@ -1115,7 +1147,7 @@ def finding_targets_block(block: dict, findings: list[dict]) -> bool:
1115
1147
 
1116
1148
 
1117
1149
  def _read_triggering_findings(devlyn: pathlib.Path, origin_phase: str) -> tuple[list[dict], str]:
1118
- name = "build_gate.findings.jsonl" if origin_phase == "build_gate" else "verify-merged.findings.jsonl"
1150
+ name = "verify-merged.findings.jsonl" if origin_phase == "verify" else f"{origin_phase}.findings.jsonl"
1119
1151
  path = devlyn / name
1120
1152
  try:
1121
1153
  raw = path.read_bytes()
@@ -1547,6 +1579,34 @@ def is_plan_dispatch_receipt(entry: dict) -> bool:
1547
1579
  return all(field in entry for field in PLAN_RECEIPT_FIELDS)
1548
1580
 
1549
1581
 
1582
+ OWNER_EXECUTION = {"plan": "orchestrator_context", "build_gate": "orchestrator_commands",
1583
+ "cleanup": "orchestrator_commands"}
1584
+
1585
+
1586
+ def orchestrator_phase(entry: dict | None, phase: str) -> bool:
1587
+ if not isinstance(entry, dict) or "execution_kind" not in entry:
1588
+ return False
1589
+ if entry["execution_kind"] != OWNER_EXECUTION[phase]:
1590
+ raise SystemExit(f"error: phases.{phase}.execution_kind is invalid")
1591
+ return True
1592
+
1593
+
1594
+ def validate_owner_identity(devlyn, phase, round_, engine=None, model=None,
1595
+ prompt_sha256=None, engine_session_log=None, entry=None) -> None:
1596
+ if any(value is not None for value in (engine, model, prompt_sha256, engine_session_log)):
1597
+ raise SystemExit(f"error: orchestrator {phase} cannot claim worker identity or session")
1598
+ if entry is not None and (
1599
+ any(entry.get(field) is not None for field in ("engine", "model_requested", "model_effective"))
1600
+ or any(field in entry for field in ("model", "prompt_sha256", "invocation_receipt", "role_argv", "role_evidence"))
1601
+ ):
1602
+ raise SystemExit(f"error: orchestrator {phase} contains worker identity")
1603
+ if devlyn is not None:
1604
+ for name in (f"{phase}.prompt.{round_}", f"{phase}.worker-session.{round_}.jsonl",
1605
+ f"{phase}.invocation.{round_}.json", f"{phase}.argv.{round_}.json"):
1606
+ if os.path.lexists(devlyn / name):
1607
+ raise SystemExit(f"error: orchestrator {phase} has current-round worker evidence: {name}")
1608
+
1609
+
1550
1610
  def append_phase_history(entry: dict, phase: str) -> None:
1551
1611
  if entry.get("started_at") is None:
1552
1612
  return
@@ -1557,6 +1617,12 @@ def append_phase_history(entry: dict, phase: str) -> None:
1557
1617
  fields = PLAN_RECEIPT_FIELDS + (
1558
1618
  ("invocation_receipt",) if "invocation_receipt" in entry else ()
1559
1619
  )
1620
+ elif phase == "build_gate" or (phase in OWNER_EXECUTION and "execution_kind" in entry):
1621
+ fields = tuple(field for field in (
1622
+ "started_at", "verdict", "completed_at", "duration_ms", "round", "triggered_by",
1623
+ "execution_kind", "engine", "model", "model_requested", "model_effective", "prompt_sha256",
1624
+ "invocation_receipt", "role_argv", "artifacts", "output_sha256", "pre_sha", "post_sha",
1625
+ ) if field in entry)
1560
1626
  elif phase in WORKER_SESSION_ARTIFACT_PHASES and "invocation_receipt" in entry:
1561
1627
  fields = (
1562
1628
  "started_at", "verdict", "completed_at", "duration_ms",
@@ -1622,7 +1688,13 @@ def do_spawn(state: dict, phase: str, round_: int, triggered_by: str | None,
1622
1688
  prompt_sha256: str | None = None,
1623
1689
  untracked_before: list[str] | None = None,
1624
1690
  devlyn: pathlib.Path | None = None) -> None:
1625
- if phase in {"implement", "cleanup", "verify"} and "role_resolution" in state:
1691
+ # Omitted worker metadata selects owner PLAN/CLEANUP. Explicit metadata keeps
1692
+ # the historical worker API; an owner span never accepts those claims.
1693
+ owner = phase == "build_gate" or (
1694
+ phase in {"plan", "cleanup"}
1695
+ and all(value is None for value in (engine, model, prompt_sha256))
1696
+ )
1697
+ if not owner and phase in {"implement", "cleanup", "verify"} and "role_resolution" in state:
1626
1698
  resolution = role_config_module()["snapshot"](state)
1627
1699
  selected = resolution["roles"]["primary_judge" if phase == "verify" else "worker"]
1628
1700
  if engine is not None and engine != selected["engine"]:
@@ -1637,7 +1709,17 @@ def do_spawn(state: dict, phase: str, round_: int, triggered_by: str | None,
1637
1709
  phases_value = state.get("phases")
1638
1710
  entry = phases_value.get(phase) if isinstance(phases_value, dict) else None
1639
1711
  validate_plan_output(state, devlyn, phase)
1712
+ if phase in OWNER_EXECUTION and orchestrator_phase(entry, phase):
1713
+ validate_owner_identity(devlyn, phase, entry.get("round"), entry=entry)
1714
+ if not owner:
1715
+ raise SystemExit("error: owner phase cannot respawn as a worker")
1716
+ if owner:
1717
+ validate_owner_identity(devlyn, phase, round_, engine, model, prompt_sha256)
1718
+ if phase == "cleanup" and pre_sha is None:
1719
+ raise SystemExit("error: owner cleanup requires --pre-sha")
1640
1720
  if phase == "plan":
1721
+ if owner and (state.get("phases", {}).get("implement") or {}).get("started_at"):
1722
+ raise SystemExit("BLOCKED:plan-already-in-use: scope cannot change after implementation starts")
1641
1723
  history = entry.get("history", []) if isinstance(entry, dict) else []
1642
1724
  if not isinstance(history, list):
1643
1725
  raise SystemExit("error: phases.plan.history must be an array")
@@ -1650,11 +1732,11 @@ def do_spawn(state: dict, phase: str, round_: int, triggered_by: str | None,
1650
1732
  raise SystemExit(
1651
1733
  f"BLOCKED:plan-round-nonmonotonic: expected={dispatch_count} supplied={round_}"
1652
1734
  )
1653
- if not isinstance(engine, str) or not engine:
1735
+ if not owner and (not isinstance(engine, str) or not engine):
1654
1736
  raise SystemExit("error: phases.plan spawn requires --engine")
1655
- if not isinstance(model, str) or not model:
1737
+ if not owner and (not isinstance(model, str) or not model):
1656
1738
  raise SystemExit("error: phases.plan spawn requires --model")
1657
- if prompt_sha256 is None or not SHA256_RE.fullmatch(prompt_sha256):
1739
+ if not owner and (prompt_sha256 is None or not SHA256_RE.fullmatch(prompt_sha256)):
1658
1740
  raise SystemExit("error: phases.plan spawn requires --prompt-sha256")
1659
1741
  if dispatch_count > 0 and triggered_by is None:
1660
1742
  raise SystemExit("error: phases.plan re-spawn requires --triggered-by")
@@ -1692,6 +1774,7 @@ def do_spawn(state: dict, phase: str, round_: int, triggered_by: str | None,
1692
1774
  )
1693
1775
  if (
1694
1776
  state.get("version") == "3.0"
1777
+ and not owner
1695
1778
  and requested_engine == "codex"
1696
1779
  and phase in WORKER_SESSION_ARTIFACT_PHASES
1697
1780
  and prompt_sha256 is None
@@ -1705,6 +1788,7 @@ def do_spawn(state: dict, phase: str, round_: int, triggered_by: str | None,
1705
1788
  )
1706
1789
  if (
1707
1790
  state.get("version") == "3.0"
1791
+ and not owner
1708
1792
  and requested_engine == "codex"
1709
1793
  and phase in WORKER_SESSION_ARTIFACT_PHASES
1710
1794
  and not requested_model
@@ -1754,6 +1838,11 @@ def do_spawn(state: dict, phase: str, round_: int, triggered_by: str | None,
1754
1838
  entry["pre_sha"] = pre_sha
1755
1839
  if prompt_sha256 is not None:
1756
1840
  entry["prompt_sha256"] = prompt_sha256
1841
+ if owner:
1842
+ entry["execution_kind"] = OWNER_EXECUTION[phase]
1843
+ entry["engine"] = entry["model_requested"] = entry["model_effective"] = None
1844
+ entry.pop("prompt_sha256", None)
1845
+ entry.pop("role_evidence", None)
1757
1846
  if phase == "surface_close":
1758
1847
  entry["input_patch_sha256"] = input_patch_sha256
1759
1848
  entry["untracked_before"] = untracked_before
@@ -1801,6 +1890,29 @@ def do_complete(state: dict, phase: str, verdict: str | None,
1801
1890
  raise SystemExit(
1802
1891
  f"error: phases.{phase} already completed — respawn before completing again"
1803
1892
  )
1893
+ owner = phase in OWNER_EXECUTION and orchestrator_phase(entry, phase)
1894
+ if owner:
1895
+ validate_owner_identity(
1896
+ devlyn, phase, entry.get("round"), engine, model,
1897
+ engine_session_log=engine_session_log, entry=entry,
1898
+ )
1899
+ if phase == "cleanup" and verdict in {"PASS", "PASS_WITH_ISSUES"}:
1900
+ if work is None or not entry.get("pre_sha") or post_sha != entry["pre_sha"]:
1901
+ raise SystemExit("BLOCKED:owner-cleanup-source-changed: preserve the IMPLEMENT checkpoint")
1902
+ checked = subprocess.run(
1903
+ ["git", "diff", "--quiet", entry["pre_sha"], "--"], cwd=work,
1904
+ capture_output=True, check=False,
1905
+ )
1906
+ head = subprocess.run(["git", "rev-parse", "HEAD"], cwd=work,
1907
+ capture_output=True, text=True, check=False)
1908
+ if checked.returncode != 0 or head.returncode != 0 or head.stdout.strip() != post_sha:
1909
+ raise SystemExit("BLOCKED:owner-cleanup-source-changed: return code cleanup to IMPLEMENT")
1910
+ checker = runpy.run_path(pathlib.Path(__file__).with_name("spec-verify-check.py"))
1911
+ baseline, baseline_error = checker["load_untracked_baseline"](devlyn)
1912
+ current, current_error = checker["current_untracked_files"](work)
1913
+ if baseline_error or current_error or current - baseline:
1914
+ detail = baseline_error or current_error or repr(sorted(current - baseline))
1915
+ raise SystemExit(f"BLOCKED:owner-cleanup-untracked: {detail}")
1804
1916
  if phase != "plan":
1805
1917
  validate_plan_output(state, devlyn, phase)
1806
1918
  if phase == "final_report":
@@ -1859,7 +1971,7 @@ def do_complete(state: dict, phase: str, verdict: str | None,
1859
1971
  entry.setdefault("model_requested", None)
1860
1972
  entry.pop("model", None)
1861
1973
 
1862
- artifact_phase = WORKER_SESSION_ARTIFACT_PHASES.get(phase)
1974
+ artifact_phase = None if owner else WORKER_SESSION_ARTIFACT_PHASES.get(phase)
1863
1975
  retained_session = None
1864
1976
  if devlyn is not None and artifact_phase is not None:
1865
1977
  candidate = devlyn / f"{artifact_phase}.worker-session.{entry.get('round')}.jsonl"
@@ -1988,6 +2100,11 @@ def do_transition(
1988
2100
  """
1989
2101
  if next_phase not in LEGAL_TRANSITIONS.get(phase, set()):
1990
2102
  raise SystemExit(f"error: illegal phase transition: {phase} -> {next_phase}")
2103
+ if phase == "cleanup" and orchestrator_phase(state.get("phases", {}).get(phase), phase):
2104
+ if next_phase == "verify" and verdict not in {"PASS", "PASS_WITH_ISSUES"}:
2105
+ raise SystemExit("BLOCKED:owner-cleanup-failed: repair before VERIFY")
2106
+ if next_phase == "implement" and (verdict != "FAIL" or next_triggered_by != "cleanup"):
2107
+ raise SystemExit("BLOCKED:owner-cleanup-failed: repair requires FAIL and cleanup trigger")
1991
2108
  candidate = copy.deepcopy(state)
1992
2109
  attestation_error = do_complete(
1993
2110
  candidate, phase, verdict, post_sha, findings_file, log_file,
@@ -2617,7 +2734,7 @@ def self_test() -> int:
2617
2734
  build_carrier_1 = write_build_gate_results()
2618
2735
  transitioned = build_gate_state_cli(
2619
2736
  "transition", "--verdict", "PASS", "--next-phase", "cleanup",
2620
- "--next-round", "0",
2737
+ "--next-round", "0", "--next-pre-sha", "test-pre-sha",
2621
2738
  )
2622
2739
  assert transitioned.returncode == 0, transitioned.stderr
2623
2740
  build_bound_state = read_state(evidence_state_path)
@@ -2714,6 +2831,148 @@ def self_test() -> int:
2714
2831
  assert blocked.returncode == 0, blocked.stderr
2715
2832
  print("PASS iter-0112 BUILD_GATE sealed outcome owns the verdict floor")
2716
2833
 
2834
+ def test_orchestrator_build() -> None:
2835
+ work = (devlyn / "orchestrator-build").resolve()
2836
+ active = work / ".devlyn"
2837
+ active.mkdir(parents=True)
2838
+ state_file = active / "pipeline.state.json"
2839
+ fixture = {"version": "3.0", "run_id": "rs-orchestrator-build",
2840
+ "engine": "codex", "phases": {"build_gate": None}}
2841
+ (active / "spec-verify.results.json").write_text(
2842
+ json.dumps({"commands": [], "process_evidence": None}), encoding="utf-8",
2843
+ )
2844
+ env = {key: value for key, value in os.environ.items()
2845
+ if not key.startswith("DEVLYN_INVOCATION_")}
2846
+ env["PYTHONDONTWRITEBYTECODE"] = "1"
2847
+
2848
+ def cli(phase, *args):
2849
+ return subprocess.run(
2850
+ [sys.executable, str(pathlib.Path(__file__).resolve()),
2851
+ "--devlyn-dir", ".devlyn", "--phase", phase, *args],
2852
+ cwd=work, env=env, capture_output=True, text=True, check=False,
2853
+ )
2854
+
2855
+ def rejected(phase, *args):
2856
+ before = state_file.read_bytes()
2857
+ result = cli(phase, *args)
2858
+ assert result.returncode != 0, result.stdout
2859
+ assert "error:" in result.stderr or "BLOCKED:" in result.stderr, result.stderr
2860
+ assert state_file.read_bytes() == before
2861
+
2862
+ def command_identity(entry):
2863
+ assert entry["execution_kind"] == "orchestrator_commands"
2864
+ assert all(entry[key] is None for key in ("engine", "model_requested", "model_effective"))
2865
+ assert not {"prompt_sha256", "invocation_receipt", "role_argv", "role_evidence"} & entry.keys()
2866
+
2867
+ write_state(state_file, fixture)
2868
+ for args in (("--engine", "claude"), ("--model", "worker-model"),
2869
+ ("--prompt-sha256", "0" * 64)):
2870
+ rejected("build_gate", "spawn", "--round", "0", *args)
2871
+ for name in ("build_gate.prompt.0", "build_gate.worker-session.0.jsonl",
2872
+ "build_gate.invocation.0.json", "build_gate.argv.0.json"):
2873
+ path = active / name
2874
+ path.write_text("forged", encoding="utf-8")
2875
+ rejected("build_gate", "spawn", "--round", "0")
2876
+ path.unlink()
2877
+ result = cli("build_gate", "spawn", "--round", "0")
2878
+ assert result.returncode == 0, result.stderr
2879
+ opened = read_state(state_file)
2880
+ command_identity(opened["phases"]["build_gate"])
2881
+ for args in (("--engine", "codex"), ("--model", "worker-model"),
2882
+ ("--engine-session-log", str(active / "external.jsonl"))):
2883
+ rejected("build_gate", "complete", "--verdict", "PASS", *args)
2884
+ for field, value in (("engine", "codex"), ("model_effective", "worker-model"),
2885
+ ("invocation_receipt", {}), ("prompt_sha256", None)):
2886
+ bad = copy.deepcopy(opened)
2887
+ bad["phases"]["build_gate"][field] = value
2888
+ write_state(state_file, bad)
2889
+ rejected("build_gate", "complete", "--verdict", "PASS")
2890
+ for kind in (None, "worker", {}, False):
2891
+ bad = copy.deepcopy(opened)
2892
+ bad["phases"]["build_gate"]["execution_kind"] = kind
2893
+ write_state(state_file, bad)
2894
+ rejected("build_gate", "complete", "--verdict", "PASS")
2895
+ bad["phases"]["build_gate"]["completed_at"] = now_iso()
2896
+ write_state(state_file, bad)
2897
+ rejected("build_gate", "spawn", "--round", "1")
2898
+ write_state(state_file, opened)
2899
+ proof = active / "build_gate.worker-session.0.jsonl"
2900
+ proof.symlink_to(active / "missing")
2901
+ rejected("build_gate", "complete", "--verdict", "PASS")
2902
+ proof.unlink()
2903
+ result = cli("build_gate", "complete", "--verdict", "PASS")
2904
+ assert result.returncode == 0, result.stderr
2905
+ completed = read_state(state_file)["phases"]["build_gate"]
2906
+ command_identity(completed)
2907
+ assert completed["verdict"] == "PASS" and completed["completed_at"]
2908
+ result = cli("build_gate", "spawn", "--round", "1")
2909
+ assert result.returncode == 0, result.stderr
2910
+ reentered = read_state(state_file)["phases"]["build_gate"]
2911
+ command_identity(reentered)
2912
+ assert reentered["history"][0]["execution_kind"] == "orchestrator_commands"
2913
+ assert reentered["history"][0]["round"] == 0
2914
+ result = cli("build_gate", "transition", "--verdict", "PASS",
2915
+ "--next-phase", "cleanup", "--next-round", "0", "--next-engine", "claude")
2916
+ assert result.returncode == 0, result.stderr
2917
+
2918
+ # Incoming atomic handoff must not inherit the top-level Codex identity.
2919
+ incoming = copy.deepcopy(fixture)
2920
+ incoming["phases"]["implement"] = {
2921
+ "started_at": now_iso(), "completed_at": None, "round": 0, "engine": "claude",
2922
+ }
2923
+ write_state(state_file, incoming)
2924
+ args = ("transition", "--verdict", "PASS", "--next-phase", "build_gate", "--next-round", "0")
2925
+ rejected("implement", *args, "--next-engine", "codex")
2926
+ result = cli("implement", *args)
2927
+ assert result.returncode == 0, result.stderr
2928
+ command_identity(read_state(state_file)["phases"]["build_gate"])
2929
+
2930
+ # Seed a historical span, then use the unchanged real receipt API.
2931
+ prompt = active / "build_gate.prompt.0"
2932
+ session = active / "build_gate.worker-session.0.jsonl"
2933
+ receipt_path = active / "build_gate.invocation.0.json"
2934
+ prompt.write_text("historical build\n", encoding="utf-8")
2935
+ session.write_text('{"type":"thread.started"}\n', encoding="utf-8")
2936
+ model = "gpt-5.6-sol"
2937
+ legacy = copy.deepcopy(fixture)
2938
+ legacy["phases"]["build_gate"] = {
2939
+ "started_at": now_iso(), "completed_at": None, "round": 0,
2940
+ "engine": "codex", "model_requested": model,
2941
+ "prompt_sha256": hashlib.sha256(prompt.read_bytes()).hexdigest(),
2942
+ }
2943
+ receipts = invocation_receipt_module()
2944
+ receipts.start_receipt(
2945
+ work, receipt_path, fixture["run_id"], "build_gate", 0, str(prompt), str(session),
2946
+ ["--json", "-C", str(work), "-s", "workspace-write", "-m", model,
2947
+ "-c", "sandbox_workspace_write.network_access=true", "historical build"],
2948
+ )
2949
+ receipts.finish_receipt(work, receipt_path, 0)
2950
+ write_state(state_file, legacy)
2951
+ result = cli("build_gate", "complete", "--verdict", "PASS", "--engine-session-log", str(session))
2952
+ assert result.returncode == 0, result.stderr
2953
+ legacy_done = read_state(state_file)
2954
+ old = legacy_done["phases"]["build_gate"]
2955
+ assert "execution_kind" not in old and old["model_effective"] is None
2956
+ assert old["invocation_receipt"]["sandbox_network_access"] is True
2957
+ result = cli("build_gate", "spawn", "--round", "1")
2958
+ assert result.returncode == 0, result.stderr
2959
+ fresh = read_state(state_file)["phases"]["build_gate"]
2960
+ command_identity(fresh)
2961
+ history = fresh["history"][0]
2962
+ assert "execution_kind" not in history and history["round"] == 0
2963
+ for field in ("engine", "model_requested", "model_effective", "prompt_sha256", "invocation_receipt"):
2964
+ assert history[field] == old[field]
2965
+ write_state(state_file, legacy)
2966
+ session.write_text("altered", encoding="utf-8")
2967
+ rejected("build_gate", "transition", "--verdict", "PASS", "--engine-session-log", str(session),
2968
+ "--next-phase", "cleanup", "--next-round", "0", "--next-engine", "claude")
2969
+ result = cli("build_gate", "complete", "--verdict", "PASS", "--engine-session-log", str(session))
2970
+ assert result.returncode == 1 and "invocation-receipt-invalid" in result.stderr
2971
+ assert read_state(state_file)["phases"]["build_gate"]["verdict"] == "BLOCKED"
2972
+ print("PASS iter-0125 command BUILD CLI identity, transitions/history and historical receipt boundaries")
2973
+
2974
+ test_orchestrator_build()
2975
+
2717
2976
  def test_interrupted_build_gate() -> None:
2718
2977
  # Iter-0119 R1-R5: interrupted observations bind only to BLOCKED,
2719
2978
  # without changing their bytes or bypassing archive/receipt guards.
@@ -2735,7 +2994,7 @@ def self_test() -> int:
2735
2994
  "phases": {"build_gate": None, "final_report": None},
2736
2995
  "process_evidence": None,
2737
2996
  }
2738
- do_spawn(fixture, "build_gate", 0, None, None, "claude", None)
2997
+ do_spawn(fixture, "build_gate", 0, None, None, None, None)
2739
2998
  write_state(state_file, fixture)
2740
2999
  open_bytes = state_file.read_bytes()
2741
3000
  relative = runner.manifest_relative_path(fixture, "build_gate")
@@ -2948,6 +3207,8 @@ def self_test() -> int:
2948
3207
  receipt = active / "build_gate.invocation.0.json"
2949
3208
  model = "gpt-5.6-sol"
2950
3209
  candidate = copy.deepcopy(fixture)
3210
+ # Seed the historical absent-kind route; a new spawn is commands-only.
3211
+ candidate["phases"]["build_gate"].pop("execution_kind")
2951
3212
  candidate["phases"]["build_gate"].update({
2952
3213
  "engine": "codex", "model_requested": model,
2953
3214
  "prompt_sha256": hashlib.sha256(prompt.read_bytes()).hexdigest(),
@@ -2975,8 +3236,8 @@ def self_test() -> int:
2975
3236
  assert receipt.read_bytes() == receipt_bytes
2976
3237
  print("PASS iter-0119 nonzero receipt persists BLOCKED before exit1; transition stays atomic")
2977
3238
 
2978
- candidate = copy.deepcopy(bound)
2979
- do_spawn(candidate, "build_gate", 1, None, None, "claude", None)
3239
+ candidate = read_state(state_file)
3240
+ do_spawn(candidate, "build_gate", 1, None, None, None, None)
2980
3241
  write_state(state_file, candidate)
2981
3242
  next_relative = runner.manifest_relative_path(candidate, "build_gate")
2982
3243
  runner.capture_process(
@@ -2994,8 +3255,190 @@ def self_test() -> int:
2994
3255
  assert {path: path.read_bytes() for path in observed} == observed
2995
3256
  assert set(observed).issubset(archive.dynamic_evidence_artifacts(active, appended))
2996
3257
 
3258
+ # A post-spec denial refresh must preserve earlier failed checks.
3259
+ refresh_state = copy.deepcopy(fixture)
3260
+ refresh_state["phases"]["build_gate"] = None
3261
+ do_spawn(refresh_state, "build_gate", 2, None, None, None, None, devlyn=active)
3262
+ write_state(state_file, refresh_state)
3263
+ refresh_relative = runner.manifest_relative_path(refresh_state, "build_gate")
3264
+ refresh_manifest = work / refresh_relative
3265
+ for obligation in obligations[:2]:
3266
+ runner.capture_process(work, refresh_manifest, fixture["run_id"], "build_gate", 2, obligation)
3267
+ prior_raw = {path: path.read_bytes() for path in refresh_manifest.parent.iterdir()
3268
+ if path != refresh_manifest}
3269
+ sealed = runner.validate_manifest(
3270
+ work, refresh_relative, fixture["run_id"], "build_gate", 2, require_expectations=False,
3271
+ )
3272
+ summary.write_text(json.dumps({
3273
+ "commands": runner.bound_carrier_summary_commands(work, sealed),
3274
+ "process_evidence": sealed, "findings_count": 1,
3275
+ }), encoding="utf-8")
3276
+ denied = runner.normalize_obligation({
3277
+ "id": "browser-capability", "phase": "build_gate", "cmd": "browser-check",
3278
+ })
3279
+ runner.record_capability_denial(
3280
+ work, refresh_manifest, fixture["run_id"], "build_gate", 2, denied,
3281
+ "loopback", b"authoritative route denial fixture",
3282
+ )
3283
+ rejected("complete", "--verdict", "BLOCKED")
3284
+ refreshed = runner.validate_manifest(
3285
+ work, refresh_relative, fixture["run_id"], "build_gate", 2, require_expectations=False,
3286
+ )
3287
+ refreshed_commands = runner.bound_carrier_summary_commands(work, refreshed)
3288
+ runner.validate_summary_commands(work, refreshed_commands, refreshed)
3289
+ results = loads_strict_json(summary.read_text(encoding="utf-8"))
3290
+ results.update(commands=refreshed_commands, process_evidence=refreshed)
3291
+ summary.write_text(json.dumps(results), encoding="utf-8")
3292
+ for verdict in ("PASS", "FAIL"):
3293
+ rejected("complete", "--verdict", verdict)
3294
+ completed = cli("complete", "--verdict", "BLOCKED")
3295
+ assert completed.returncode == 0, completed.stderr
3296
+ assert results["findings_count"] == 1 and len(refreshed_commands) == 3
3297
+ assert refreshed_commands[1]["pass"] is False
3298
+ assert {path: path.read_bytes() for path in prior_raw} == prior_raw
3299
+ assert read_state(state_file)["process_evidence"] == [refreshed]
3300
+ print("PASS iter-0125 post-spec denial refresh retains failed expectations/raw without replay")
3301
+
2997
3302
  test_interrupted_build_gate()
2998
3303
 
3304
+ def test_preflight_build_gate() -> None:
3305
+ work = (devlyn / "preflight-build-gate").resolve()
3306
+ active = work / ".devlyn"
3307
+ active.mkdir(parents=True)
3308
+ state_file = active / "pipeline.state.json"
3309
+ results_file = active / "spec-verify.results.json"
3310
+ checker = pathlib.Path(__file__).with_name("spec-verify-check.py")
3311
+ env = {key: value for key, value in os.environ.items() if key not in {
3312
+ "BENCH_WORKDIR", "SPEC_VERIFY_PHASE", "SPEC_VERIFY_FINDINGS_FILE",
3313
+ "SPEC_VERIFY_FINDING_PREFIX",
3314
+ } and not key.startswith("DEVLYN_INVOCATION_")}
3315
+ runner = process_evidence_module()
3316
+
3317
+ def complete(verdict: str) -> subprocess.CompletedProcess[str]:
3318
+ return subprocess.run(
3319
+ [sys.executable, str(pathlib.Path(__file__).resolve()),
3320
+ "--devlyn-dir", ".devlyn", "--phase", "build_gate", "complete",
3321
+ "--verdict", verdict, "--findings-file", ".devlyn/spec-verify-findings.jsonl"],
3322
+ cwd=work, env=env, capture_output=True, text=True, encoding="utf-8",
3323
+ )
3324
+
3325
+ history = []
3326
+ for round_, scenario in enumerate(("fresh", "language", "stale", "denied")):
3327
+ fixture = {
3328
+ "version": "3.0", "run_id": "rs-preflight-build-gate",
3329
+ "source": {"type": "generated", "criteria_path": ".devlyn/criteria.generated.md"},
3330
+ "phases": {"build_gate": None}, "process_evidence": copy.deepcopy(history) or None,
3331
+ }
3332
+ do_spawn(fixture, "build_gate", round_, None, None, None, None)
3333
+ write_state(state_file, fixture)
3334
+ original_state = state_file.read_bytes()
3335
+ results_file.unlink(missing_ok=True)
3336
+ relative = runner.manifest_relative_path(fixture, "build_gate")
3337
+ manifest = work / relative
3338
+ obligation = {"id": "type-check", "phase": "build_gate",
3339
+ "argv": [sys.executable, "-c", "raise SystemExit(7)"]}
3340
+ if scenario == "language":
3341
+ runner.capture_process(work, manifest, fixture["run_id"], "build_gate", round_, obligation)
3342
+ elif scenario == "denied":
3343
+ runner.record_capability_denial(
3344
+ work, manifest, fixture["run_id"], "build_gate", round_, obligation,
3345
+ "subprocess", b"route denied subprocess",
3346
+ )
3347
+ elif scenario == "stale":
3348
+ results_file.write_bytes(prior_results)
3349
+ observed = {path: path.read_bytes() for path in manifest.parent.iterdir()} if manifest.exists() else {}
3350
+ rejected = subprocess.run(
3351
+ [sys.executable, str(checker), "--include-risk-probes"],
3352
+ cwd=work, env=env, capture_output=True, text=True, encoding="utf-8",
3353
+ )
3354
+ assert rejected.returncode == 1 and "source.criteria_path" in rejected.stderr, rejected.stderr
3355
+ results = loads_strict_json(results_file.read_text(encoding="utf-8"))
3356
+ assert results["preflight_failure"]["round"] == round_
3357
+ prior_results = results_file.read_bytes()
3358
+ finding = active / "spec-verify-findings.jsonl"
3359
+ finding_bytes = finding.read_bytes()
3360
+ for verdict in ("PASS", "PASS_WITH_ISSUES", "NEEDS_WORK"):
3361
+ denied = complete(verdict)
3362
+ assert denied.returncode != 0 and "preflight failure requires" in denied.stderr, denied.stderr
3363
+ assert state_file.read_bytes() == original_state
3364
+ if scenario == "denied":
3365
+ denied = complete("FAIL")
3366
+ assert denied.returncode != 0 and "capability denial requires BLOCKED" in denied.stderr
3367
+ assert state_file.read_bytes() == original_state
3368
+ completed = complete("BLOCKED" if scenario == "denied" else "FAIL")
3369
+ assert completed.returncode == 0, completed.stderr
3370
+ bound = read_state(state_file)
3371
+ assert bound["phases"]["build_gate"]["verdict"] == ("BLOCKED" if scenario == "denied" else "FAIL")
3372
+ assert finding.read_bytes() == finding_bytes and results_file.read_bytes() == prior_results
3373
+ assert {path: path.read_bytes() for path in observed} == observed
3374
+ if observed:
3375
+ assert bound["process_evidence"] == [*history, results["process_evidence"]]
3376
+ else:
3377
+ assert bound["process_evidence"] == (history or None) and results["commands"] == []
3378
+ history = bound["process_evidence"] or []
3379
+ for mutation in ("run", "phase", "round", "bool-round", "digest", "escape", "finding", "null"):
3380
+ state_file.write_bytes(original_state)
3381
+ altered = copy.deepcopy(results)
3382
+ failure = altered["preflight_failure"]
3383
+ if mutation in {"run", "phase", "round", "bool-round"}:
3384
+ field, value = {"run": ("run_id", "rs-other"), "phase": ("phase", "verify"),
3385
+ "round": ("round", round_ + 1), "bool-round": ("round", True)}[mutation]
3386
+ failure[field] = value
3387
+ elif mutation == "null":
3388
+ altered["preflight_failure"] = None
3389
+ elif mutation == "finding":
3390
+ finding.write_bytes(b"{}\n")
3391
+ failure["findings"]["sha256"] = hashlib.sha256(finding.read_bytes()).hexdigest()
3392
+ else:
3393
+ failure["findings"]["path" if mutation == "escape" else "sha256"] = (
3394
+ "../outside.jsonl" if mutation == "escape" else "0" * 64
3395
+ )
3396
+ results_file.write_text(json.dumps(altered), encoding="utf-8")
3397
+ result = complete("FAIL")
3398
+ assert result.returncode != 0 and "BLOCKED:process-evidence-invalid" in result.stderr, (mutation, result.stderr)
3399
+ assert state_file.read_bytes() == original_state
3400
+ finding.write_bytes(finding_bytes)
3401
+ results_file.write_bytes(prior_results)
3402
+ if scenario in {"fresh", "stale"}:
3403
+ state_file.write_bytes(original_state)
3404
+ malformed_prior = read_state(state_file)
3405
+ malformed_prior["process_evidence"] = {}
3406
+ write_state(state_file, malformed_prior)
3407
+ before = state_file.read_bytes()
3408
+ invalid = complete("FAIL")
3409
+ assert invalid.returncode != 0 and "null or an array" in invalid.stderr, invalid.stderr
3410
+ assert state_file.read_bytes() == before
3411
+ state_file.write_bytes(original_state)
3412
+ if scenario == "stale":
3413
+ prior_stream = work / history[0]["streams"][0]["stdout"]["path"]
3414
+ saved = prior_stream.read_bytes()
3415
+ prior_stream.write_bytes(b"altered prior observation")
3416
+ invalid = complete("FAIL")
3417
+ assert invalid.returncode != 0 and "mismatch" in invalid.stderr, invalid.stderr
3418
+ assert state_file.read_bytes() == original_state
3419
+ prior_stream.write_bytes(saved)
3420
+ from unittest.mock import patch
3421
+ read_bytes = pathlib.Path.read_bytes
3422
+
3423
+ def unreadable_prior(path):
3424
+ if path == prior_stream:
3425
+ raise PermissionError("prior stream unreadable")
3426
+ return read_bytes(path)
3427
+
3428
+ fixture = read_state(state_file)
3429
+ unchanged = copy.deepcopy(fixture)
3430
+ with patch.object(pathlib.Path, "read_bytes", unreadable_prior):
3431
+ try:
3432
+ bind_process_evidence(fixture, "build_gate", "FAIL", active, work)
3433
+ except SystemExit as exc:
3434
+ assert str(exc) == "BLOCKED:process-evidence-invalid: prior stream unreadable", exc
3435
+ else:
3436
+ raise AssertionError("unreadable prior evidence completed")
3437
+ assert fixture == unchanged and state_file.read_bytes() == original_state
3438
+ print("PASS preflight rejection: actual checker-to-completion, success floors, identity/digest controls")
3439
+
3440
+ test_preflight_build_gate()
3441
+
2999
3442
  # complete() before spawn() must fail loudly, not silently invent data.
3000
3443
  write_state(state_path, {"phases": {}})
3001
3444
  state = read_state(state_path)
@@ -3170,7 +3613,7 @@ def self_test() -> int:
3170
3613
  # resets the live record.
3171
3614
  write_state(state_path, {"phases": {}})
3172
3615
  state = read_state(state_path)
3173
- do_spawn(state, "build_gate", 0, None, None, "claude", None)
3616
+ do_spawn(state, "build_gate", 0, None, None, None, None)
3174
3617
  write_state(state_path, state)
3175
3618
  time.sleep(0.05)
3176
3619
  state = read_state(state_path)
@@ -3338,7 +3781,7 @@ def self_test() -> int:
3338
3781
  # exact diff window for later mechanical checks.
3339
3782
  write_state(state_path, {"phases": {}})
3340
3783
  state = read_state(state_path)
3341
- do_spawn(state, "cleanup", 0, None, "pre-sha", None, None)
3784
+ do_spawn(state, "cleanup", 0, None, "pre-sha", "claude", None)
3342
3785
  write_state(state_path, state)
3343
3786
  state = read_state(state_path)
3344
3787
  do_complete(state, "cleanup", "PASS", "post-sha", None, None, None, None)
@@ -4091,7 +4534,11 @@ def self_test() -> int:
4091
4534
  )
4092
4535
  write_state(state_path, {"phases": {}})
4093
4536
  state = read_state(state_path)
4094
- do_spawn(state, "build_gate", 0, None, None, "claude", "claude-" "opus-5")
4537
+ # Historical Claude BUILD evidence keeps the original mismatch guard.
4538
+ state["phases"]["build_gate"] = {
4539
+ "started_at": now_iso(), "completed_at": None, "round": 0,
4540
+ "engine": "claude", "model_requested": "claude-" "opus-5",
4541
+ }
4095
4542
  mismatch = do_complete(
4096
4543
  state, "build_gate", "PASS", None, None, None, None, None, str(claude_log)
4097
4544
  )
@@ -5104,15 +5551,16 @@ def main() -> int:
5104
5551
  spawn_phase = args.phase if args.event == "spawn" else args.next_phase
5105
5552
  spawn_round = args.round if args.event == "spawn" else args.next_round
5106
5553
  implement = (state.get("phases") or {}).get("implement")
5554
+ origin = implement.get("triggered_by") if isinstance(implement, dict) else None
5107
5555
  fix_reentry = (
5108
5556
  spawn_phase in VALID_TRIGGERS and spawn_round >= 1
5109
5557
  and isinstance(implement, dict)
5110
5558
  and implement.get("round") == spawn_round
5111
- and implement.get("triggered_by") == spawn_phase
5559
+ and (origin == spawn_phase or (origin == "cleanup" and spawn_phase == "build_gate"))
5112
5560
  )
5113
5561
  if fix_reentry:
5114
5562
  enforce_closure_durability_reentry(
5115
- pathlib.Path.cwd(), devlyn, state, spawn_phase, spawn_round,
5563
+ pathlib.Path.cwd(), devlyn, state, origin, spawn_round,
5116
5564
  require_existing=True,
5117
5565
  )
5118
5566
  if args.event == "spawn":