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.
- package/README.md +21 -16
- package/bin/devlyn.js +36 -15
- package/bin/instruction-templates.json +782 -7
- package/bin/instructions.js +162 -28
- 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 +311 -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 +238 -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,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,
|
|
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 =
|
|
3011
|
-
do_spawn(candidate, "build_gate", 1, None, 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,
|
|
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,
|
|
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",
|
|
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
|
-
|
|
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
|
|
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,
|
|
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.
|
|
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
|