devlyn-cli 3.1.3 → 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.
@@ -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,29 @@ 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
+ 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}")
1836
1916
  if phase != "plan":
1837
1917
  validate_plan_output(state, devlyn, phase)
1838
1918
  if phase == "final_report":
@@ -1891,7 +1971,7 @@ def do_complete(state: dict, phase: str, verdict: str | None,
1891
1971
  entry.setdefault("model_requested", None)
1892
1972
  entry.pop("model", None)
1893
1973
 
1894
- artifact_phase = WORKER_SESSION_ARTIFACT_PHASES.get(phase)
1974
+ artifact_phase = None if owner else WORKER_SESSION_ARTIFACT_PHASES.get(phase)
1895
1975
  retained_session = None
1896
1976
  if devlyn is not None and artifact_phase is not None:
1897
1977
  candidate = devlyn / f"{artifact_phase}.worker-session.{entry.get('round')}.jsonl"
@@ -2020,6 +2100,11 @@ def do_transition(
2020
2100
  """
2021
2101
  if next_phase not in LEGAL_TRANSITIONS.get(phase, set()):
2022
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")
2023
2108
  candidate = copy.deepcopy(state)
2024
2109
  attestation_error = do_complete(
2025
2110
  candidate, phase, verdict, post_sha, findings_file, log_file,
@@ -2649,7 +2734,7 @@ def self_test() -> int:
2649
2734
  build_carrier_1 = write_build_gate_results()
2650
2735
  transitioned = build_gate_state_cli(
2651
2736
  "transition", "--verdict", "PASS", "--next-phase", "cleanup",
2652
- "--next-round", "0",
2737
+ "--next-round", "0", "--next-pre-sha", "test-pre-sha",
2653
2738
  )
2654
2739
  assert transitioned.returncode == 0, transitioned.stderr
2655
2740
  build_bound_state = read_state(evidence_state_path)
@@ -2746,6 +2831,148 @@ def self_test() -> int:
2746
2831
  assert blocked.returncode == 0, blocked.stderr
2747
2832
  print("PASS iter-0112 BUILD_GATE sealed outcome owns the verdict floor")
2748
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
+
2749
2976
  def test_interrupted_build_gate() -> None:
2750
2977
  # Iter-0119 R1-R5: interrupted observations bind only to BLOCKED,
2751
2978
  # without changing their bytes or bypassing archive/receipt guards.
@@ -2767,7 +2994,7 @@ def self_test() -> int:
2767
2994
  "phases": {"build_gate": None, "final_report": None},
2768
2995
  "process_evidence": None,
2769
2996
  }
2770
- do_spawn(fixture, "build_gate", 0, None, None, "claude", None)
2997
+ do_spawn(fixture, "build_gate", 0, None, None, None, None)
2771
2998
  write_state(state_file, fixture)
2772
2999
  open_bytes = state_file.read_bytes()
2773
3000
  relative = runner.manifest_relative_path(fixture, "build_gate")
@@ -2980,6 +3207,8 @@ def self_test() -> int:
2980
3207
  receipt = active / "build_gate.invocation.0.json"
2981
3208
  model = "gpt-5.6-sol"
2982
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")
2983
3212
  candidate["phases"]["build_gate"].update({
2984
3213
  "engine": "codex", "model_requested": model,
2985
3214
  "prompt_sha256": hashlib.sha256(prompt.read_bytes()).hexdigest(),
@@ -3007,8 +3236,8 @@ def self_test() -> int:
3007
3236
  assert receipt.read_bytes() == receipt_bytes
3008
3237
  print("PASS iter-0119 nonzero receipt persists BLOCKED before exit1; transition stays atomic")
3009
3238
 
3010
- candidate = copy.deepcopy(bound)
3011
- 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)
3012
3241
  write_state(state_file, candidate)
3013
3242
  next_relative = runner.manifest_relative_path(candidate, "build_gate")
3014
3243
  runner.capture_process(
@@ -3026,6 +3255,50 @@ def self_test() -> int:
3026
3255
  assert {path: path.read_bytes() for path in observed} == observed
3027
3256
  assert set(observed).issubset(archive.dynamic_evidence_artifacts(active, appended))
3028
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
+
3029
3302
  test_interrupted_build_gate()
3030
3303
 
3031
3304
  def test_preflight_build_gate() -> None:
@@ -3056,7 +3329,7 @@ def self_test() -> int:
3056
3329
  "source": {"type": "generated", "criteria_path": ".devlyn/criteria.generated.md"},
3057
3330
  "phases": {"build_gate": None}, "process_evidence": copy.deepcopy(history) or None,
3058
3331
  }
3059
- do_spawn(fixture, "build_gate", round_, None, None, "claude", None)
3332
+ do_spawn(fixture, "build_gate", round_, None, None, None, None)
3060
3333
  write_state(state_file, fixture)
3061
3334
  original_state = state_file.read_bytes()
3062
3335
  results_file.unlink(missing_ok=True)
@@ -3340,7 +3613,7 @@ def self_test() -> int:
3340
3613
  # resets the live record.
3341
3614
  write_state(state_path, {"phases": {}})
3342
3615
  state = read_state(state_path)
3343
- do_spawn(state, "build_gate", 0, None, None, "claude", None)
3616
+ do_spawn(state, "build_gate", 0, None, None, None, None)
3344
3617
  write_state(state_path, state)
3345
3618
  time.sleep(0.05)
3346
3619
  state = read_state(state_path)
@@ -3508,7 +3781,7 @@ def self_test() -> int:
3508
3781
  # exact diff window for later mechanical checks.
3509
3782
  write_state(state_path, {"phases": {}})
3510
3783
  state = read_state(state_path)
3511
- do_spawn(state, "cleanup", 0, None, "pre-sha", None, None)
3784
+ do_spawn(state, "cleanup", 0, None, "pre-sha", "claude", None)
3512
3785
  write_state(state_path, state)
3513
3786
  state = read_state(state_path)
3514
3787
  do_complete(state, "cleanup", "PASS", "post-sha", None, None, None, None)
@@ -4261,7 +4534,11 @@ def self_test() -> int:
4261
4534
  )
4262
4535
  write_state(state_path, {"phases": {}})
4263
4536
  state = read_state(state_path)
4264
- 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
+ }
4265
4542
  mismatch = do_complete(
4266
4543
  state, "build_gate", "PASS", None, None, None, None, None, str(claude_log)
4267
4544
  )
@@ -5274,15 +5551,16 @@ def main() -> int:
5274
5551
  spawn_phase = args.phase if args.event == "spawn" else args.next_phase
5275
5552
  spawn_round = args.round if args.event == "spawn" else args.next_round
5276
5553
  implement = (state.get("phases") or {}).get("implement")
5554
+ origin = implement.get("triggered_by") if isinstance(implement, dict) else None
5277
5555
  fix_reentry = (
5278
5556
  spawn_phase in VALID_TRIGGERS and spawn_round >= 1
5279
5557
  and isinstance(implement, dict)
5280
5558
  and implement.get("round") == spawn_round
5281
- and implement.get("triggered_by") == spawn_phase
5559
+ and (origin == spawn_phase or (origin == "cleanup" and spawn_phase == "build_gate"))
5282
5560
  )
5283
5561
  if fix_reentry:
5284
5562
  enforce_closure_durability_reentry(
5285
- pathlib.Path.cwd(), devlyn, state, spawn_phase, spawn_round,
5563
+ pathlib.Path.cwd(), devlyn, state, origin, spawn_round,
5286
5564
  require_existing=True,
5287
5565
  )
5288
5566
  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