devlyn-cli 3.1.3 → 3.2.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.
@@ -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
  }
@@ -1147,7 +1147,7 @@ def finding_targets_block(block: dict, findings: list[dict]) -> bool:
1147
1147
 
1148
1148
 
1149
1149
  def _read_triggering_findings(devlyn: pathlib.Path, origin_phase: str) -> tuple[list[dict], str]:
1150
- 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"
1151
1151
  path = devlyn / name
1152
1152
  try:
1153
1153
  raw = path.read_bytes()
@@ -1579,6 +1579,34 @@ def is_plan_dispatch_receipt(entry: dict) -> bool:
1579
1579
  return all(field in entry for field in PLAN_RECEIPT_FIELDS)
1580
1580
 
1581
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
+
1582
1610
  def append_phase_history(entry: dict, phase: str) -> None:
1583
1611
  if entry.get("started_at") is None:
1584
1612
  return
@@ -1589,6 +1617,12 @@ def append_phase_history(entry: dict, phase: str) -> None:
1589
1617
  fields = PLAN_RECEIPT_FIELDS + (
1590
1618
  ("invocation_receipt",) if "invocation_receipt" in entry else ()
1591
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)
1592
1626
  elif phase in WORKER_SESSION_ARTIFACT_PHASES and "invocation_receipt" in entry:
1593
1627
  fields = (
1594
1628
  "started_at", "verdict", "completed_at", "duration_ms",
@@ -1654,7 +1688,13 @@ def do_spawn(state: dict, phase: str, round_: int, triggered_by: str | None,
1654
1688
  prompt_sha256: str | None = None,
1655
1689
  untracked_before: list[str] | None = None,
1656
1690
  devlyn: pathlib.Path | None = None) -> None:
1657
- 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:
1658
1698
  resolution = role_config_module()["snapshot"](state)
1659
1699
  selected = resolution["roles"]["primary_judge" if phase == "verify" else "worker"]
1660
1700
  if engine is not None and engine != selected["engine"]:
@@ -1669,7 +1709,17 @@ def do_spawn(state: dict, phase: str, round_: int, triggered_by: str | None,
1669
1709
  phases_value = state.get("phases")
1670
1710
  entry = phases_value.get(phase) if isinstance(phases_value, dict) else None
1671
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")
1672
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")
1673
1723
  history = entry.get("history", []) if isinstance(entry, dict) else []
1674
1724
  if not isinstance(history, list):
1675
1725
  raise SystemExit("error: phases.plan.history must be an array")
@@ -1682,11 +1732,11 @@ def do_spawn(state: dict, phase: str, round_: int, triggered_by: str | None,
1682
1732
  raise SystemExit(
1683
1733
  f"BLOCKED:plan-round-nonmonotonic: expected={dispatch_count} supplied={round_}"
1684
1734
  )
1685
- if not isinstance(engine, str) or not engine:
1735
+ if not owner and (not isinstance(engine, str) or not engine):
1686
1736
  raise SystemExit("error: phases.plan spawn requires --engine")
1687
- if not isinstance(model, str) or not model:
1737
+ if not owner and (not isinstance(model, str) or not model):
1688
1738
  raise SystemExit("error: phases.plan spawn requires --model")
1689
- 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)):
1690
1740
  raise SystemExit("error: phases.plan spawn requires --prompt-sha256")
1691
1741
  if dispatch_count > 0 and triggered_by is None:
1692
1742
  raise SystemExit("error: phases.plan re-spawn requires --triggered-by")
@@ -1724,6 +1774,7 @@ def do_spawn(state: dict, phase: str, round_: int, triggered_by: str | None,
1724
1774
  )
1725
1775
  if (
1726
1776
  state.get("version") == "3.0"
1777
+ and not owner
1727
1778
  and requested_engine == "codex"
1728
1779
  and phase in WORKER_SESSION_ARTIFACT_PHASES
1729
1780
  and prompt_sha256 is None
@@ -1737,6 +1788,7 @@ def do_spawn(state: dict, phase: str, round_: int, triggered_by: str | None,
1737
1788
  )
1738
1789
  if (
1739
1790
  state.get("version") == "3.0"
1791
+ and not owner
1740
1792
  and requested_engine == "codex"
1741
1793
  and phase in WORKER_SESSION_ARTIFACT_PHASES
1742
1794
  and not requested_model
@@ -1786,6 +1838,11 @@ def do_spawn(state: dict, phase: str, round_: int, triggered_by: str | None,
1786
1838
  entry["pre_sha"] = pre_sha
1787
1839
  if prompt_sha256 is not None:
1788
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)
1789
1846
  if phase == "surface_close":
1790
1847
  entry["input_patch_sha256"] = input_patch_sha256
1791
1848
  entry["untracked_before"] = untracked_before
@@ -1833,6 +1890,33 @@ def do_complete(state: dict, phase: str, verdict: str | None,
1833
1890
  raise SystemExit(
1834
1891
  f"error: phases.{phase} already completed — respawn before completing again"
1835
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
+ indexed = subprocess.run(
1907
+ ["git", "diff", "--cached", "--quiet", entry["pre_sha"], "--"], cwd=work,
1908
+ capture_output=True, check=False,
1909
+ )
1910
+ head = subprocess.run(["git", "rev-parse", "HEAD"], cwd=work,
1911
+ capture_output=True, text=True, check=False)
1912
+ if checked.returncode != 0 or indexed.returncode != 0 or head.returncode != 0 or head.stdout.strip() != post_sha:
1913
+ raise SystemExit("BLOCKED:owner-cleanup-source-changed: return code cleanup to IMPLEMENT")
1914
+ checker = runpy.run_path(pathlib.Path(__file__).with_name("spec-verify-check.py"))
1915
+ baseline, baseline_error = checker["load_untracked_baseline"](devlyn)
1916
+ current, current_error = checker["current_untracked_files"](work)
1917
+ if baseline_error or current_error or current - baseline:
1918
+ detail = baseline_error or current_error or repr(sorted(current - baseline))
1919
+ raise SystemExit(f"BLOCKED:owner-cleanup-untracked: {detail}")
1836
1920
  if phase != "plan":
1837
1921
  validate_plan_output(state, devlyn, phase)
1838
1922
  if phase == "final_report":
@@ -1891,7 +1975,7 @@ def do_complete(state: dict, phase: str, verdict: str | None,
1891
1975
  entry.setdefault("model_requested", None)
1892
1976
  entry.pop("model", None)
1893
1977
 
1894
- artifact_phase = WORKER_SESSION_ARTIFACT_PHASES.get(phase)
1978
+ artifact_phase = None if owner else WORKER_SESSION_ARTIFACT_PHASES.get(phase)
1895
1979
  retained_session = None
1896
1980
  if devlyn is not None and artifact_phase is not None:
1897
1981
  candidate = devlyn / f"{artifact_phase}.worker-session.{entry.get('round')}.jsonl"
@@ -2020,6 +2104,21 @@ def do_transition(
2020
2104
  """
2021
2105
  if next_phase not in LEGAL_TRANSITIONS.get(phase, set()):
2022
2106
  raise SystemExit(f"error: illegal phase transition: {phase} -> {next_phase}")
2107
+ if phase == "implement" and state.get("phases", {}).get(phase, {}).get("triggered_by") == "cleanup":
2108
+ bypasses = state.get("bypasses", [])
2109
+ required = "build_gate" if "build-gate" not in bypasses else (
2110
+ "cleanup" if "cleanup" not in bypasses else "verify"
2111
+ )
2112
+ if next_phase not in {required, "final_report"}:
2113
+ raise SystemExit(f"BLOCKED:owner-cleanup-repair-route: next phase must be {required}")
2114
+ if next_phase != "final_report" and next_round != state["phases"][phase]["round"]:
2115
+ raise SystemExit("BLOCKED:owner-cleanup-repair-route: preserve the IMPLEMENT repair round")
2116
+ if phase == "cleanup":
2117
+ owner = orchestrator_phase(state.get("phases", {}).get(phase), phase)
2118
+ if owner and next_phase == "verify" and verdict not in {"PASS", "PASS_WITH_ISSUES"}:
2119
+ raise SystemExit("BLOCKED:owner-cleanup-failed: repair before VERIFY")
2120
+ if next_phase == "implement" and (not owner or verdict != "FAIL" or next_triggered_by != "cleanup"):
2121
+ raise SystemExit("BLOCKED:owner-cleanup-failed: repair requires owner FAIL and cleanup trigger")
2023
2122
  candidate = copy.deepcopy(state)
2024
2123
  attestation_error = do_complete(
2025
2124
  candidate, phase, verdict, post_sha, findings_file, log_file,
@@ -2649,7 +2748,7 @@ def self_test() -> int:
2649
2748
  build_carrier_1 = write_build_gate_results()
2650
2749
  transitioned = build_gate_state_cli(
2651
2750
  "transition", "--verdict", "PASS", "--next-phase", "cleanup",
2652
- "--next-round", "0",
2751
+ "--next-round", "0", "--next-pre-sha", "test-pre-sha",
2653
2752
  )
2654
2753
  assert transitioned.returncode == 0, transitioned.stderr
2655
2754
  build_bound_state = read_state(evidence_state_path)
@@ -2746,6 +2845,148 @@ def self_test() -> int:
2746
2845
  assert blocked.returncode == 0, blocked.stderr
2747
2846
  print("PASS iter-0112 BUILD_GATE sealed outcome owns the verdict floor")
2748
2847
 
2848
+ def test_orchestrator_build() -> None:
2849
+ work = (devlyn / "orchestrator-build").resolve()
2850
+ active = work / ".devlyn"
2851
+ active.mkdir(parents=True)
2852
+ state_file = active / "pipeline.state.json"
2853
+ fixture = {"version": "3.0", "run_id": "rs-orchestrator-build",
2854
+ "engine": "codex", "phases": {"build_gate": None}}
2855
+ (active / "spec-verify.results.json").write_text(
2856
+ json.dumps({"commands": [], "process_evidence": None}), encoding="utf-8",
2857
+ )
2858
+ env = {key: value for key, value in os.environ.items()
2859
+ if not key.startswith("DEVLYN_INVOCATION_")}
2860
+ env["PYTHONDONTWRITEBYTECODE"] = "1"
2861
+
2862
+ def cli(phase, *args):
2863
+ return subprocess.run(
2864
+ [sys.executable, str(pathlib.Path(__file__).resolve()),
2865
+ "--devlyn-dir", ".devlyn", "--phase", phase, *args],
2866
+ cwd=work, env=env, capture_output=True, text=True, check=False,
2867
+ )
2868
+
2869
+ def rejected(phase, *args):
2870
+ before = state_file.read_bytes()
2871
+ result = cli(phase, *args)
2872
+ assert result.returncode != 0, result.stdout
2873
+ assert "error:" in result.stderr or "BLOCKED:" in result.stderr, result.stderr
2874
+ assert state_file.read_bytes() == before
2875
+
2876
+ def command_identity(entry):
2877
+ assert entry["execution_kind"] == "orchestrator_commands"
2878
+ assert all(entry[key] is None for key in ("engine", "model_requested", "model_effective"))
2879
+ assert not {"prompt_sha256", "invocation_receipt", "role_argv", "role_evidence"} & entry.keys()
2880
+
2881
+ write_state(state_file, fixture)
2882
+ for args in (("--engine", "claude"), ("--model", "worker-model"),
2883
+ ("--prompt-sha256", "0" * 64)):
2884
+ rejected("build_gate", "spawn", "--round", "0", *args)
2885
+ for name in ("build_gate.prompt.0", "build_gate.worker-session.0.jsonl",
2886
+ "build_gate.invocation.0.json", "build_gate.argv.0.json"):
2887
+ path = active / name
2888
+ path.write_text("forged", encoding="utf-8")
2889
+ rejected("build_gate", "spawn", "--round", "0")
2890
+ path.unlink()
2891
+ result = cli("build_gate", "spawn", "--round", "0")
2892
+ assert result.returncode == 0, result.stderr
2893
+ opened = read_state(state_file)
2894
+ command_identity(opened["phases"]["build_gate"])
2895
+ for args in (("--engine", "codex"), ("--model", "worker-model"),
2896
+ ("--engine-session-log", str(active / "external.jsonl"))):
2897
+ rejected("build_gate", "complete", "--verdict", "PASS", *args)
2898
+ for field, value in (("engine", "codex"), ("model_effective", "worker-model"),
2899
+ ("invocation_receipt", {}), ("prompt_sha256", None)):
2900
+ bad = copy.deepcopy(opened)
2901
+ bad["phases"]["build_gate"][field] = value
2902
+ write_state(state_file, bad)
2903
+ rejected("build_gate", "complete", "--verdict", "PASS")
2904
+ for kind in (None, "worker", {}, False):
2905
+ bad = copy.deepcopy(opened)
2906
+ bad["phases"]["build_gate"]["execution_kind"] = kind
2907
+ write_state(state_file, bad)
2908
+ rejected("build_gate", "complete", "--verdict", "PASS")
2909
+ bad["phases"]["build_gate"]["completed_at"] = now_iso()
2910
+ write_state(state_file, bad)
2911
+ rejected("build_gate", "spawn", "--round", "1")
2912
+ write_state(state_file, opened)
2913
+ proof = active / "build_gate.worker-session.0.jsonl"
2914
+ proof.symlink_to(active / "missing")
2915
+ rejected("build_gate", "complete", "--verdict", "PASS")
2916
+ proof.unlink()
2917
+ result = cli("build_gate", "complete", "--verdict", "PASS")
2918
+ assert result.returncode == 0, result.stderr
2919
+ completed = read_state(state_file)["phases"]["build_gate"]
2920
+ command_identity(completed)
2921
+ assert completed["verdict"] == "PASS" and completed["completed_at"]
2922
+ result = cli("build_gate", "spawn", "--round", "1")
2923
+ assert result.returncode == 0, result.stderr
2924
+ reentered = read_state(state_file)["phases"]["build_gate"]
2925
+ command_identity(reentered)
2926
+ assert reentered["history"][0]["execution_kind"] == "orchestrator_commands"
2927
+ assert reentered["history"][0]["round"] == 0
2928
+ result = cli("build_gate", "transition", "--verdict", "PASS",
2929
+ "--next-phase", "cleanup", "--next-round", "0", "--next-engine", "claude")
2930
+ assert result.returncode == 0, result.stderr
2931
+
2932
+ # Incoming atomic handoff must not inherit the top-level Codex identity.
2933
+ incoming = copy.deepcopy(fixture)
2934
+ incoming["phases"]["implement"] = {
2935
+ "started_at": now_iso(), "completed_at": None, "round": 0, "engine": "claude",
2936
+ }
2937
+ write_state(state_file, incoming)
2938
+ args = ("transition", "--verdict", "PASS", "--next-phase", "build_gate", "--next-round", "0")
2939
+ rejected("implement", *args, "--next-engine", "codex")
2940
+ result = cli("implement", *args)
2941
+ assert result.returncode == 0, result.stderr
2942
+ command_identity(read_state(state_file)["phases"]["build_gate"])
2943
+
2944
+ # Seed a historical span, then use the unchanged real receipt API.
2945
+ prompt = active / "build_gate.prompt.0"
2946
+ session = active / "build_gate.worker-session.0.jsonl"
2947
+ receipt_path = active / "build_gate.invocation.0.json"
2948
+ prompt.write_text("historical build\n", encoding="utf-8")
2949
+ session.write_text('{"type":"thread.started"}\n', encoding="utf-8")
2950
+ model = "gpt-5.6-sol"
2951
+ legacy = copy.deepcopy(fixture)
2952
+ legacy["phases"]["build_gate"] = {
2953
+ "started_at": now_iso(), "completed_at": None, "round": 0,
2954
+ "engine": "codex", "model_requested": model,
2955
+ "prompt_sha256": hashlib.sha256(prompt.read_bytes()).hexdigest(),
2956
+ }
2957
+ receipts = invocation_receipt_module()
2958
+ receipts.start_receipt(
2959
+ work, receipt_path, fixture["run_id"], "build_gate", 0, str(prompt), str(session),
2960
+ ["--json", "-C", str(work), "-s", "workspace-write", "-m", model,
2961
+ "-c", "sandbox_workspace_write.network_access=true", "historical build"],
2962
+ )
2963
+ receipts.finish_receipt(work, receipt_path, 0)
2964
+ write_state(state_file, legacy)
2965
+ result = cli("build_gate", "complete", "--verdict", "PASS", "--engine-session-log", str(session))
2966
+ assert result.returncode == 0, result.stderr
2967
+ legacy_done = read_state(state_file)
2968
+ old = legacy_done["phases"]["build_gate"]
2969
+ assert "execution_kind" not in old and old["model_effective"] is None
2970
+ assert old["invocation_receipt"]["sandbox_network_access"] is True
2971
+ result = cli("build_gate", "spawn", "--round", "1")
2972
+ assert result.returncode == 0, result.stderr
2973
+ fresh = read_state(state_file)["phases"]["build_gate"]
2974
+ command_identity(fresh)
2975
+ history = fresh["history"][0]
2976
+ assert "execution_kind" not in history and history["round"] == 0
2977
+ for field in ("engine", "model_requested", "model_effective", "prompt_sha256", "invocation_receipt"):
2978
+ assert history[field] == old[field]
2979
+ write_state(state_file, legacy)
2980
+ session.write_text("altered", encoding="utf-8")
2981
+ rejected("build_gate", "transition", "--verdict", "PASS", "--engine-session-log", str(session),
2982
+ "--next-phase", "cleanup", "--next-round", "0", "--next-engine", "claude")
2983
+ result = cli("build_gate", "complete", "--verdict", "PASS", "--engine-session-log", str(session))
2984
+ assert result.returncode == 1 and "invocation-receipt-invalid" in result.stderr
2985
+ assert read_state(state_file)["phases"]["build_gate"]["verdict"] == "BLOCKED"
2986
+ print("PASS iter-0125 command BUILD CLI identity, transitions/history and historical receipt boundaries")
2987
+
2988
+ test_orchestrator_build()
2989
+
2749
2990
  def test_interrupted_build_gate() -> None:
2750
2991
  # Iter-0119 R1-R5: interrupted observations bind only to BLOCKED,
2751
2992
  # without changing their bytes or bypassing archive/receipt guards.
@@ -2767,7 +3008,7 @@ def self_test() -> int:
2767
3008
  "phases": {"build_gate": None, "final_report": None},
2768
3009
  "process_evidence": None,
2769
3010
  }
2770
- do_spawn(fixture, "build_gate", 0, None, None, "claude", None)
3011
+ do_spawn(fixture, "build_gate", 0, None, None, None, None)
2771
3012
  write_state(state_file, fixture)
2772
3013
  open_bytes = state_file.read_bytes()
2773
3014
  relative = runner.manifest_relative_path(fixture, "build_gate")
@@ -2980,6 +3221,8 @@ def self_test() -> int:
2980
3221
  receipt = active / "build_gate.invocation.0.json"
2981
3222
  model = "gpt-5.6-sol"
2982
3223
  candidate = copy.deepcopy(fixture)
3224
+ # Seed the historical absent-kind route; a new spawn is commands-only.
3225
+ candidate["phases"]["build_gate"].pop("execution_kind")
2983
3226
  candidate["phases"]["build_gate"].update({
2984
3227
  "engine": "codex", "model_requested": model,
2985
3228
  "prompt_sha256": hashlib.sha256(prompt.read_bytes()).hexdigest(),
@@ -3007,8 +3250,8 @@ def self_test() -> int:
3007
3250
  assert receipt.read_bytes() == receipt_bytes
3008
3251
  print("PASS iter-0119 nonzero receipt persists BLOCKED before exit1; transition stays atomic")
3009
3252
 
3010
- candidate = copy.deepcopy(bound)
3011
- do_spawn(candidate, "build_gate", 1, None, None, "claude", None)
3253
+ candidate = read_state(state_file)
3254
+ do_spawn(candidate, "build_gate", 1, None, None, None, None)
3012
3255
  write_state(state_file, candidate)
3013
3256
  next_relative = runner.manifest_relative_path(candidate, "build_gate")
3014
3257
  runner.capture_process(
@@ -3026,6 +3269,50 @@ def self_test() -> int:
3026
3269
  assert {path: path.read_bytes() for path in observed} == observed
3027
3270
  assert set(observed).issubset(archive.dynamic_evidence_artifacts(active, appended))
3028
3271
 
3272
+ # A post-spec denial refresh must preserve earlier failed checks.
3273
+ refresh_state = copy.deepcopy(fixture)
3274
+ refresh_state["phases"]["build_gate"] = None
3275
+ do_spawn(refresh_state, "build_gate", 2, None, None, None, None, devlyn=active)
3276
+ write_state(state_file, refresh_state)
3277
+ refresh_relative = runner.manifest_relative_path(refresh_state, "build_gate")
3278
+ refresh_manifest = work / refresh_relative
3279
+ for obligation in obligations[:2]:
3280
+ runner.capture_process(work, refresh_manifest, fixture["run_id"], "build_gate", 2, obligation)
3281
+ prior_raw = {path: path.read_bytes() for path in refresh_manifest.parent.iterdir()
3282
+ if path != refresh_manifest}
3283
+ sealed = runner.validate_manifest(
3284
+ work, refresh_relative, fixture["run_id"], "build_gate", 2, require_expectations=False,
3285
+ )
3286
+ summary.write_text(json.dumps({
3287
+ "commands": runner.bound_carrier_summary_commands(work, sealed),
3288
+ "process_evidence": sealed, "findings_count": 1,
3289
+ }), encoding="utf-8")
3290
+ denied = runner.normalize_obligation({
3291
+ "id": "browser-capability", "phase": "build_gate", "cmd": "browser-check",
3292
+ })
3293
+ runner.record_capability_denial(
3294
+ work, refresh_manifest, fixture["run_id"], "build_gate", 2, denied,
3295
+ "loopback", b"authoritative route denial fixture",
3296
+ )
3297
+ rejected("complete", "--verdict", "BLOCKED")
3298
+ refreshed = runner.validate_manifest(
3299
+ work, refresh_relative, fixture["run_id"], "build_gate", 2, require_expectations=False,
3300
+ )
3301
+ refreshed_commands = runner.bound_carrier_summary_commands(work, refreshed)
3302
+ runner.validate_summary_commands(work, refreshed_commands, refreshed)
3303
+ results = loads_strict_json(summary.read_text(encoding="utf-8"))
3304
+ results.update(commands=refreshed_commands, process_evidence=refreshed)
3305
+ summary.write_text(json.dumps(results), encoding="utf-8")
3306
+ for verdict in ("PASS", "FAIL"):
3307
+ rejected("complete", "--verdict", verdict)
3308
+ completed = cli("complete", "--verdict", "BLOCKED")
3309
+ assert completed.returncode == 0, completed.stderr
3310
+ assert results["findings_count"] == 1 and len(refreshed_commands) == 3
3311
+ assert refreshed_commands[1]["pass"] is False
3312
+ assert {path: path.read_bytes() for path in prior_raw} == prior_raw
3313
+ assert read_state(state_file)["process_evidence"] == [refreshed]
3314
+ print("PASS iter-0125 post-spec denial refresh retains failed expectations/raw without replay")
3315
+
3029
3316
  test_interrupted_build_gate()
3030
3317
 
3031
3318
  def test_preflight_build_gate() -> None:
@@ -3056,7 +3343,7 @@ def self_test() -> int:
3056
3343
  "source": {"type": "generated", "criteria_path": ".devlyn/criteria.generated.md"},
3057
3344
  "phases": {"build_gate": None}, "process_evidence": copy.deepcopy(history) or None,
3058
3345
  }
3059
- do_spawn(fixture, "build_gate", round_, None, None, "claude", None)
3346
+ do_spawn(fixture, "build_gate", round_, None, None, None, None)
3060
3347
  write_state(state_file, fixture)
3061
3348
  original_state = state_file.read_bytes()
3062
3349
  results_file.unlink(missing_ok=True)
@@ -3340,7 +3627,7 @@ def self_test() -> int:
3340
3627
  # resets the live record.
3341
3628
  write_state(state_path, {"phases": {}})
3342
3629
  state = read_state(state_path)
3343
- do_spawn(state, "build_gate", 0, None, None, "claude", None)
3630
+ do_spawn(state, "build_gate", 0, None, None, None, None)
3344
3631
  write_state(state_path, state)
3345
3632
  time.sleep(0.05)
3346
3633
  state = read_state(state_path)
@@ -3508,7 +3795,7 @@ def self_test() -> int:
3508
3795
  # exact diff window for later mechanical checks.
3509
3796
  write_state(state_path, {"phases": {}})
3510
3797
  state = read_state(state_path)
3511
- do_spawn(state, "cleanup", 0, None, "pre-sha", None, None)
3798
+ do_spawn(state, "cleanup", 0, None, "pre-sha", "claude", None)
3512
3799
  write_state(state_path, state)
3513
3800
  state = read_state(state_path)
3514
3801
  do_complete(state, "cleanup", "PASS", "post-sha", None, None, None, None)
@@ -4261,7 +4548,11 @@ def self_test() -> int:
4261
4548
  )
4262
4549
  write_state(state_path, {"phases": {}})
4263
4550
  state = read_state(state_path)
4264
- do_spawn(state, "build_gate", 0, None, None, "claude", "claude-" "opus-5")
4551
+ # Historical Claude BUILD evidence keeps the original mismatch guard.
4552
+ state["phases"]["build_gate"] = {
4553
+ "started_at": now_iso(), "completed_at": None, "round": 0,
4554
+ "engine": "claude", "model_requested": "claude-" "opus-5",
4555
+ }
4265
4556
  mismatch = do_complete(
4266
4557
  state, "build_gate", "PASS", None, None, None, None, None, str(claude_log)
4267
4558
  )
@@ -5274,15 +5565,17 @@ def main() -> int:
5274
5565
  spawn_phase = args.phase if args.event == "spawn" else args.next_phase
5275
5566
  spawn_round = args.round if args.event == "spawn" else args.next_round
5276
5567
  implement = (state.get("phases") or {}).get("implement")
5568
+ origin = implement.get("triggered_by") if isinstance(implement, dict) else None
5277
5569
  fix_reentry = (
5278
5570
  spawn_phase in VALID_TRIGGERS and spawn_round >= 1
5279
5571
  and isinstance(implement, dict)
5280
5572
  and implement.get("round") == spawn_round
5281
- and implement.get("triggered_by") == spawn_phase
5573
+ and (origin == spawn_phase or origin == "cleanup")
5574
+ and (origin != "cleanup" or args.event == "spawn" or args.phase == "implement")
5282
5575
  )
5283
5576
  if fix_reentry:
5284
5577
  enforce_closure_durability_reentry(
5285
- pathlib.Path.cwd(), devlyn, state, spawn_phase, spawn_round,
5578
+ pathlib.Path.cwd(), devlyn, state, origin, spawn_round,
5286
5579
  require_existing=True,
5287
5580
  )
5288
5581
  if args.event == "spawn":
@@ -36,7 +36,7 @@ def require(condition, message):
36
36
  def command(argv, *, cwd=None, ok=(0,)):
37
37
  result = subprocess.run(argv, cwd=cwd, capture_output=True, text=True, encoding="utf-8")
38
38
  require(result.returncode in ok, f"{shlex.join(argv[:4])}: {result.stderr.strip() or result.stdout.strip()}")
39
- return result.stdout.strip()
39
+ return result.stdout.removesuffix("\n")
40
40
 
41
41
 
42
42
  def git(work, *args):
@@ -190,7 +190,7 @@ def allocate(args):
190
190
  receipt["remote_url"] = remote_url(receipt)
191
191
  require("\n" not in receipt["remote_url"]["push"] and receipt["remote_url"]["push"] == receipt["remote_url"]["fetch"], "split/multiple remote URLs are unsupported")
192
192
  require(not ref_sha(receipt, "refs/heads/"+args.branch), "existing branch cannot be adopted")
193
- require(git(work, "symbolic-ref", "--short", "HEAD") == args.base, "allocate from the retained base checkout")
193
+ require(git(work, "symbolic-ref", "HEAD") == "refs/heads/" + args.base, "allocate from the retained base checkout")
194
194
  require(not git(work, "status", "--porcelain", "--untracked-files=all"), "allocation requires clean tracked/untracked contents")
195
195
  receipt["baseline"] = git(work, "rev-parse", "HEAD")
196
196
  target = Path(args.worktree).absolute() if args.worktree else work
@@ -862,8 +862,8 @@ class CompletionTests(unittest.TestCase):
862
862
  r = self.run_cmd([sys.executable, str(Path(__file__).resolve()), *map(str, args)], success=success, cwd=cwd)
863
863
  return json.loads(r.stdout), r
864
864
 
865
- def allocate(self, linked=False):
866
- args = ["allocate", "--repo", self.work, "--task", "fixture", "--branch", "task/fixture", "--repository", "test/project", "--base", "main"]
865
+ def allocate(self, linked=False, base="main"):
866
+ args = ["allocate", "--repo", self.work, "--task", "fixture", "--branch", "task/fixture", "--repository", "test/project", "--base", base]
867
867
  if linked:
868
868
  args += ["--worktree", self.root / "linked"]
869
869
  result, _ = self.cli(*args)
@@ -871,6 +871,39 @@ class CompletionTests(unittest.TestCase):
871
871
  self.task = Path(result["worktree"])
872
872
  return result
873
873
 
874
+ def test_allocate_base_with_same_named_tag(self):
875
+ self.g("tag", "main")
876
+ result = self.allocate(linked=True)
877
+ self.assertEqual(result["status"], "ALLOCATED")
878
+ self.assertEqual(json.loads(self.receipt.read_text(encoding="utf-8"))["base"], "main")
879
+
880
+ def test_allocate_base_preserves_unicode_whitespace(self):
881
+ base = "main\u00a0"
882
+ self.g("switch", "-c", base)
883
+ result = self.allocate(base=base)
884
+ self.assertEqual(result["status"], "ALLOCATED")
885
+ self.assertEqual(json.loads(self.receipt.read_text(encoding="utf-8"))["base"], base)
886
+
887
+ def assert_base_identity_rejected(self, actual, base):
888
+ result, process = self.cli("allocate", "--repo", self.work, "--task", "fixture",
889
+ "--branch", "task/fixture", "--repository", "test/project", "--base", base, success=False)
890
+ self.assertNotEqual(process.returncode, 0)
891
+ self.assertIn("retained base checkout", result["reason"])
892
+ head = self.run_cmd(["git", "-C", str(self.work), "symbolic-ref", "HEAD"]).stdout
893
+ self.assertEqual(head, "refs/heads/" + actual + "\n")
894
+ self.assertFalse((self.work / ".git/devlyn-completion").exists())
895
+ self.assertNotEqual(self.run_cmd(["git", "-C", str(self.work), "show-ref", "--verify",
896
+ "refs/heads/task/fixture"], success=False).returncode, 0)
897
+
898
+ def test_allocate_rejects_trimmed_base_alias(self):
899
+ self.g("switch", "-c", "main\u00a0")
900
+ self.assert_base_identity_rejected("main\u00a0", "main")
901
+
902
+ def test_allocate_rejects_short_base_alias(self):
903
+ self.g("branch", "heads/main")
904
+ self.g("tag", "main")
905
+ self.assert_base_identity_rejected("main", "heads/main")
906
+
874
907
  @unittest.skipUnless(sys.platform == "darwin" or sys.platform.startswith("linux"), "writer observation requires POSIX")
875
908
  def test_disposable_scratch_cleans_for_local_only_and_preserves_source(self):
876
909
  result = self.allocate()
@@ -149,7 +149,7 @@ def classify_state_bytes(
149
149
  return malformed("run state must be a JSON object"), None
150
150
 
151
151
  run_id = state.get("run_id")
152
- if not isinstance(run_id, str) or not SAFE_RUN_ID_RE.fullmatch(run_id):
152
+ if not isinstance(run_id, str) or run_id in (".", "..") or not SAFE_RUN_ID_RE.fullmatch(run_id):
153
153
  return malformed("run state has invalid run_id"), state
154
154
  phases = state.get("phases")
155
155
  if not isinstance(phases, dict):
@@ -197,7 +197,7 @@ def classify_state_bytes(
197
197
  verdict = verify.get("verdict")
198
198
  if verdict is None:
199
199
  return incomplete("verify", "verify completed without verdict", run_id), state
200
- if verdict not in VALID_VERIFY_VERDICTS:
200
+ if not isinstance(verdict, str) or verdict not in VALID_VERIFY_VERDICTS:
201
201
  return malformed("verify has invalid verdict", run_id), state
202
202
  else:
203
203
  verdict = None