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.
- package/README.md +21 -15
- package/bin/devlyn.js +36 -15
- package/bin/instruction-templates.json +36 -6
- package/bin/instructions.js +69 -19
- package/config/skills/_shared/archive_run.py +5 -1
- package/config/skills/_shared/codex-config.md +3 -14
- package/config/skills/_shared/engine-preflight.md +3 -3
- package/config/skills/_shared/role-config.py +14 -2
- package/config/skills/_shared/spec-verify-check.py +3 -4
- package/config/skills/_shared/state-phase-write.py +296 -18
- package/config/skills/_shared/task-complete.py +37 -4
- package/config/skills/_shared/terminal-claim-check.py +2 -2
- package/config/skills/devlyn:resolve/SKILL.md +25 -47
- package/config/skills/devlyn:resolve/references/phases/build-gate.md +5 -5
- package/config/skills/devlyn:resolve/references/phases/cleanup.md +5 -11
- package/config/skills/devlyn:resolve/references/phases/implement.md +1 -0
- package/config/skills/devlyn:resolve/references/phases/plan.md +3 -3
- package/config/skills/devlyn:resolve/references/state-schema.md +9 -8
- package/package.json +2 -1
- package/scripts/lint-skills.sh +12 -2
- 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
|
}
|
|
@@ -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 = "
|
|
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
|
-
|
|
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,
|
|
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 =
|
|
3011
|
-
do_spawn(candidate, "build_gate", 1, None, 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,
|
|
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,
|
|
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",
|
|
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
|
-
|
|
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
|
|
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,
|
|
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.
|
|
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", "
|
|
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",
|
|
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
|