devlyn-cli 3.0.1 → 3.1.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/AGENTS.md +1 -1
- package/CLAUDE.md +3 -3
- package/README.md +20 -3
- package/bin/devlyn.js +69 -23
- package/config/skills/_shared/adapters/claude.md +3 -2
- package/config/skills/_shared/adapters/codex.md +1 -1
- package/config/skills/_shared/archive_run.py +5 -3
- package/config/skills/_shared/codex-config.md +13 -8
- package/config/skills/_shared/codex-monitored.sh +12 -19
- package/config/skills/_shared/collect-codex-findings.py +1 -0
- package/config/skills/_shared/engine-preflight.md +2 -0
- package/config/skills/_shared/finish-gate.py +4 -1
- package/config/skills/_shared/invocation-receipt.py +194 -5
- package/config/skills/_shared/judge-role-evidence.py +33 -6
- package/config/skills/_shared/phase-prompt-render.py +2 -0
- package/config/skills/_shared/platform-support.py +370 -0
- package/config/skills/_shared/process-evidence.py +4 -2
- package/config/skills/_shared/resolve-bootstrap.py +102 -59
- package/config/skills/_shared/resolve-stop-hook.py +3 -1
- package/config/skills/_shared/role-config.py +7 -5
- package/config/skills/_shared/run-bounded.py +35 -21
- package/config/skills/_shared/spec-verify-check.py +274 -157
- package/config/skills/_shared/state-phase-write.py +26 -9
- package/config/skills/_shared/task-complete.py +1173 -0
- package/config/skills/_shared/verify-merge-findings.py +9 -8
- package/config/skills/devlyn:queue/SKILL.md +3 -3
- package/config/skills/devlyn:resolve/SKILL.md +14 -7
- package/config/skills/devlyn:resolve/references/outer-loop.md +10 -2
- package/config/skills/devlyn:resolve/references/phases/verify.md +5 -3
- package/config/skills/devlyn:resolve/references/task-completion.md +117 -0
- package/package.json +1 -1
- package/scripts/lint-skills.sh +10 -6
|
@@ -252,7 +252,7 @@ def safe_goal_file(cwd: pathlib.Path, raw_path: str) -> bytes:
|
|
|
252
252
|
|
|
253
253
|
|
|
254
254
|
def run_checked(command: list[str], cwd: pathlib.Path) -> None:
|
|
255
|
-
proc = subprocess.run(command, cwd=cwd, capture_output=True, text=True)
|
|
255
|
+
proc = subprocess.run(command, cwd=cwd, capture_output=True, text=True, encoding="utf-8")
|
|
256
256
|
if proc.returncode != 0:
|
|
257
257
|
detail = (proc.stderr or proc.stdout).strip() or "command failed"
|
|
258
258
|
block("BLOCKED:invalid-flags", detail)
|
|
@@ -320,6 +320,7 @@ def base_branch(cwd: pathlib.Path) -> str | None:
|
|
|
320
320
|
cwd=cwd,
|
|
321
321
|
capture_output=True,
|
|
322
322
|
text=True,
|
|
323
|
+
encoding="utf-8",
|
|
323
324
|
)
|
|
324
325
|
if proc.returncode == 1 and not proc.stdout.strip() and not proc.stderr.strip():
|
|
325
326
|
return None
|
|
@@ -363,24 +364,18 @@ def require_clean_tracked_baseline(cwd: pathlib.Path) -> None:
|
|
|
363
364
|
@contextlib.contextmanager
|
|
364
365
|
def admission_lock(cwd: pathlib.Path):
|
|
365
366
|
lock_path = pathlib.Path(git_text(cwd, "rev-parse", "--absolute-git-dir")) / "devlyn-bootstrap.lock"
|
|
366
|
-
|
|
367
|
-
import fcntl
|
|
368
|
-
handle = lock_path.open("a+b")
|
|
369
|
-
except (ImportError, OSError) as exc:
|
|
370
|
-
block("BLOCKED:bootstrap-lock-unavailable", f"{cwd}: {lock_path}: {exc}")
|
|
371
|
-
# Closing releases flock on every exit; keep the inode for the next caller.
|
|
372
|
-
with handle:
|
|
367
|
+
with contextlib.ExitStack() as stack:
|
|
373
368
|
try:
|
|
374
|
-
|
|
369
|
+
stack.enter_context(runpy.run_path(pathlib.Path(__file__).with_name("platform-support.py"))["file_lock"](lock_path))
|
|
375
370
|
except BlockingIOError:
|
|
376
371
|
block(
|
|
377
372
|
"BLOCKED:bootstrap-contended",
|
|
378
373
|
f"Bootstrap admission is occupied at {cwd}. Retry from this root after admission finishes.",
|
|
379
374
|
)
|
|
380
|
-
except (OSError, AttributeError) as exc:
|
|
375
|
+
except (ImportError, OSError, AttributeError) as exc:
|
|
381
376
|
block("BLOCKED:bootstrap-lock-unavailable", f"{cwd}: {lock_path}: {exc}")
|
|
382
377
|
for path in (cwd / ".devlyn", cwd / ".devlyn" / "runs"):
|
|
383
|
-
if path.is_symlink() or (path.exists() and not path.is_dir()):
|
|
378
|
+
if path.is_symlink() or path.resolve() != path or (path.exists() and not path.is_dir()):
|
|
384
379
|
block("BLOCKED:devlyn-path-redirect", f"{cwd}: require an unredirected directory at {path}")
|
|
385
380
|
yield
|
|
386
381
|
|
|
@@ -502,6 +497,9 @@ def pathname_self_test() -> None:
|
|
|
502
497
|
env = dict(os.environ, GIT_CONFIG_GLOBAL=os.devnull, GIT_CONFIG_NOSYSTEM="1")
|
|
503
498
|
with tempfile.TemporaryDirectory() as tmp:
|
|
504
499
|
for name in ("plain", "trailing ", "trailing\t", "trailing\n", "trailing\r\n"):
|
|
500
|
+
if os.name == "nt" and name != "plain":
|
|
501
|
+
print(f"SKIP POSIX-only pathname {name!r}: native Windows rejects/normalizes this name")
|
|
502
|
+
continue
|
|
505
503
|
repo = pathlib.Path(tmp).resolve() / name
|
|
506
504
|
repo.mkdir()
|
|
507
505
|
|
|
@@ -515,13 +513,13 @@ def pathname_self_test() -> None:
|
|
|
515
513
|
for option, path in (("--show-toplevel", repo), ("--absolute-git-dir", repo / ".git")):
|
|
516
514
|
raw = git("rev-parse", option)
|
|
517
515
|
print(f"pathname {name!r} {option}: {raw!r}", flush=True)
|
|
518
|
-
assert raw == os.fsencode(path) + b"\n", raw
|
|
516
|
+
assert raw == os.fsencode(path.as_posix() if os.name == "nt" else path) + b"\n", raw
|
|
519
517
|
command = [sys.executable, str(script), "pathname", "probe"]
|
|
520
518
|
proc = subprocess.run(command, cwd=repo, env=env, capture_output=True, timeout=15)
|
|
521
519
|
result = strict_json(proc.stdout)
|
|
522
520
|
assert proc.returncode == 0 and result["ok"] and not proc.stderr, (name, proc, result)
|
|
523
521
|
for option, path in (("--show-toplevel", repo), ("--absolute-git-dir", repo / ".git")):
|
|
524
|
-
assert git_text(repo, "rev-parse", option) == str(path)
|
|
522
|
+
assert git_text(repo, "rev-parse", option) == (path.as_posix() if os.name == "nt" else str(path))
|
|
525
523
|
lock = repo / ".git" / "devlyn-bootstrap.lock"
|
|
526
524
|
inode = lock.stat().st_ino
|
|
527
525
|
devlyn = repo / ".devlyn"
|
|
@@ -565,23 +563,34 @@ def admission_self_test() -> None:
|
|
|
565
563
|
git(repo, "init", "-q")
|
|
566
564
|
git(repo, "config", "user.email", "test@example.com")
|
|
567
565
|
git(repo, "config", "user.name", "Test")
|
|
568
|
-
(repo / "app.py").write_text("print('base')\n")
|
|
566
|
+
(repo / "app.py").write_text("print('base')\n", encoding="utf-8")
|
|
569
567
|
git(repo, "add", "app.py")
|
|
570
568
|
git(repo, "commit", "-qm", "base")
|
|
571
569
|
inputs(repo)
|
|
572
570
|
|
|
573
571
|
def snapshot(path):
|
|
574
|
-
|
|
572
|
+
try:
|
|
573
|
+
pending = [(path, path.lstat())]
|
|
574
|
+
except FileNotFoundError:
|
|
575
575
|
return {}
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
576
|
+
result = {}
|
|
577
|
+
while pending:
|
|
578
|
+
p, metadata = pending.pop()
|
|
579
|
+
key = str(p.relative_to(path))
|
|
580
|
+
if stat.S_ISLNK(metadata.st_mode) or (
|
|
581
|
+
os.name == "nt" and metadata.st_reparse_tag == stat.IO_REPARSE_TAG_MOUNT_POINT
|
|
582
|
+
):
|
|
583
|
+
result[key] = str(p.readlink())
|
|
584
|
+
elif stat.S_ISDIR(metadata.st_mode):
|
|
585
|
+
result[key] = None
|
|
586
|
+
pending.extend((child, child.lstat()) for child in p.iterdir())
|
|
587
|
+
else:
|
|
588
|
+
result[key] = p.read_bytes()
|
|
589
|
+
return result
|
|
581
590
|
|
|
582
591
|
def cli(repo, argv, extra_env=None):
|
|
583
592
|
proc = subprocess.run([sys.executable, str(script), *argv], cwd=repo,
|
|
584
|
-
env=extra_env or env, capture_output=True, text=True, timeout=15)
|
|
593
|
+
env=extra_env or env, capture_output=True, text=True, timeout=15, encoding="utf-8")
|
|
585
594
|
assert proc.stderr == "", (proc.returncode, proc.stdout, proc.stderr)
|
|
586
595
|
result = strict_json(proc.stdout)
|
|
587
596
|
assert proc.returncode == (0 if result["ok"] else 1), result
|
|
@@ -628,7 +637,13 @@ if action.startswith('temp:'):
|
|
|
628
637
|
return replace(path, target)
|
|
629
638
|
pathlib.Path.replace = held_replace
|
|
630
639
|
signals.with_suffix('.ready').touch()
|
|
631
|
-
if
|
|
640
|
+
if barrier.startswith('file:'):
|
|
641
|
+
deadline = time.monotonic() + 20
|
|
642
|
+
while not pathlib.Path(barrier[5:]).exists():
|
|
643
|
+
if time.monotonic() > deadline:
|
|
644
|
+
raise TimeoutError('native admission barrier was not released')
|
|
645
|
+
time.sleep(0.01)
|
|
646
|
+
elif int(barrier) >= 0:
|
|
632
647
|
os.read(int(barrier), 1)
|
|
633
648
|
os.close(int(barrier))
|
|
634
649
|
try:
|
|
@@ -646,7 +661,7 @@ print(json.dumps(result))
|
|
|
646
661
|
first = cli(repo, modes[0])
|
|
647
662
|
devlyn = repo / ".devlyn"
|
|
648
663
|
state_path = devlyn / "pipeline.state.json"
|
|
649
|
-
initial = strict_json(state_path.read_text())
|
|
664
|
+
initial = strict_json(state_path.read_text(encoding="utf-8"))
|
|
650
665
|
(devlyn / "engines.json").write_bytes(b'{"executor":"codex"}\n')
|
|
651
666
|
(devlyn / "config.json").write_bytes(b'{"setting":true}\n')
|
|
652
667
|
(devlyn / "unrelated.data").write_bytes(b"keep\n")
|
|
@@ -671,7 +686,7 @@ print(json.dumps(result))
|
|
|
671
686
|
refusal(repo, modes[0], "BLOCKED:prior-run-unfinished")
|
|
672
687
|
for verdict in ("PASS", "PASS_WITH_ISSUES", "NEEDS_WORK", "BLOCKED"):
|
|
673
688
|
for timestamp in ("2024-02-29T23:59:59Z", "2024-02-29T23:59:59.123Z"):
|
|
674
|
-
state = strict_json(state_path.read_text())
|
|
689
|
+
state = strict_json(state_path.read_text(encoding="utf-8"))
|
|
675
690
|
state["phases"]["final_report"] = {"verdict": verdict, "completed_at": timestamp}
|
|
676
691
|
state_path.write_bytes(json_bytes(state))
|
|
677
692
|
raw = state_path.read_bytes()
|
|
@@ -706,7 +721,13 @@ print(json.dumps(result))
|
|
|
706
721
|
identity = root / "identity"
|
|
707
722
|
init(identity)
|
|
708
723
|
alias = root / "alias"
|
|
709
|
-
|
|
724
|
+
def directory_link(link, target):
|
|
725
|
+
if os.name == "nt":
|
|
726
|
+
subprocess.run(["cmd.exe", "/d", "/c", "mklink", "/J", str(link), str(target)],
|
|
727
|
+
check=True, capture_output=True)
|
|
728
|
+
else:
|
|
729
|
+
link.symlink_to(target, target_is_directory=True)
|
|
730
|
+
directory_link(alias, identity)
|
|
710
731
|
sub = identity / "sub"
|
|
711
732
|
sub.mkdir()
|
|
712
733
|
detail = refusal(sub, ["--goal-file", "relative.txt"], "BLOCKED:worktree-root-required")["detail"]
|
|
@@ -731,20 +752,26 @@ print(json.dumps(result))
|
|
|
731
752
|
(target / "keep").write_bytes(b"untouched")
|
|
732
753
|
link = identity / relative
|
|
733
754
|
link.parent.mkdir(exist_ok=True)
|
|
734
|
-
link
|
|
755
|
+
directory_link(link, target)
|
|
735
756
|
before = snapshot(target)
|
|
757
|
+
assert before == ({} if dangling else {".": None, "keep": b"untouched"})
|
|
758
|
+
expected = {".": str(link.readlink())} if relative == ".devlyn" else {
|
|
759
|
+
".": None, "runs": str(link.readlink()),
|
|
760
|
+
}
|
|
761
|
+
assert snapshot(identity / ".devlyn") == expected
|
|
736
762
|
result = refusal(identity, modes[0], "BLOCKED:devlyn-path-redirect")
|
|
737
|
-
assert str(link) in result["detail"] and link.is_symlink()
|
|
763
|
+
assert str(link) in result["detail"] and link.resolve() == target and (os.name == "nt" or link.is_symlink())
|
|
738
764
|
assert snapshot(target) == before
|
|
739
|
-
link.unlink()
|
|
765
|
+
link.rmdir() if os.name == "nt" else link.unlink()
|
|
740
766
|
print("PASS admission identity: physical-root guidance, Git redirects including empty, live/dangling directory links")
|
|
741
767
|
|
|
742
768
|
def launch(repo, signals, action, argv, barrier=-1):
|
|
743
769
|
return subprocess.Popen(
|
|
744
770
|
[sys.executable, "-c", child, str(script), str(repo), str(signals),
|
|
745
771
|
action, str(barrier), json.dumps(argv)],
|
|
746
|
-
cwd=repo, env=env, pass_fds
|
|
772
|
+
cwd=repo, env=env, **({} if os.name == "nt" else {"pass_fds": (() if barrier < 0 else (barrier,))}),
|
|
747
773
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
|
|
774
|
+
encoding="utf-8",
|
|
748
775
|
)
|
|
749
776
|
|
|
750
777
|
def receipt(proc):
|
|
@@ -762,14 +789,22 @@ print(json.dumps(result))
|
|
|
762
789
|
other = ["goal", "B"] if i == 0 else [s.replace("spec.md", "spec-b.md").replace(
|
|
763
790
|
"external.patch", "external-b.patch") for s in argv]
|
|
764
791
|
signals = [root / f"race-{i}-{label}" for label in ("A", "B")]
|
|
765
|
-
|
|
792
|
+
if os.name == "nt":
|
|
793
|
+
barrier_file = root / f"race-{i}.barrier"
|
|
794
|
+
rfd = "file:" + str(barrier_file)
|
|
795
|
+
else:
|
|
796
|
+
rfd, wfd = os.pipe()
|
|
766
797
|
procs = []
|
|
767
798
|
try:
|
|
768
799
|
procs = [launch(race, signal, "hold", args, rfd)
|
|
769
800
|
for signal, args in zip(signals, (argv, other))]
|
|
770
|
-
os.
|
|
801
|
+
if os.name != "nt":
|
|
802
|
+
os.close(rfd)
|
|
771
803
|
wait_for(lambda: all(s.with_suffix(".ready").exists() for s in signals))
|
|
772
|
-
os.
|
|
804
|
+
if os.name == "nt":
|
|
805
|
+
barrier_file.touch()
|
|
806
|
+
else:
|
|
807
|
+
os.close(wfd)
|
|
773
808
|
wait_for(lambda: any(s.with_suffix(".inside").exists() for s in signals)
|
|
774
809
|
and any(p.poll() is not None for p in procs))
|
|
775
810
|
holders = [j for j, s in enumerate(signals) if s.with_suffix(".inside").exists()]
|
|
@@ -840,9 +875,10 @@ print(json.dumps(result))
|
|
|
840
875
|
assert not (failure / ".devlyn").exists()
|
|
841
876
|
failure_lock = failure / ".git/devlyn-bootstrap.lock"
|
|
842
877
|
failure_inode = failure_lock.stat().st_ino
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
878
|
+
module = __import__("msvcrt" if os.name == "nt" else "fcntl")
|
|
879
|
+
operation = "locking" if os.name == "nt" else "flock"
|
|
880
|
+
for replacement in (patch.dict(sys.modules, {module.__name__: None}),
|
|
881
|
+
patch.object(module, operation, side_effect=OSError("unsupported locking"))):
|
|
846
882
|
with replacement:
|
|
847
883
|
try:
|
|
848
884
|
bootstrap(modes[0], failure, shared)
|
|
@@ -874,7 +910,7 @@ print(json.dumps(result))
|
|
|
874
910
|
inode = (dead / ".git/devlyn-bootstrap.lock").stat().st_ino
|
|
875
911
|
proc.kill()
|
|
876
912
|
stdout, stderr = proc.communicate(timeout=15)
|
|
877
|
-
assert proc.returncode == -9 and stdout == "" and stderr == "", (stdout, stderr)
|
|
913
|
+
assert proc.returncode == (1 if os.name == "nt" else -9) and stdout == "" and stderr == "", (proc.returncode, stdout, stderr)
|
|
878
914
|
finally:
|
|
879
915
|
if proc.poll() is None:
|
|
880
916
|
proc.kill()
|
|
@@ -904,7 +940,7 @@ def self_test() -> int:
|
|
|
904
940
|
subprocess.run(["git", "init", "-q"], cwd=path, check=True)
|
|
905
941
|
subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=path, check=True)
|
|
906
942
|
subprocess.run(["git", "config", "user.name", "Test"], cwd=path, check=True)
|
|
907
|
-
(path / "app.py").write_text("print('base')\n")
|
|
943
|
+
(path / "app.py").write_text("print('base')\n", encoding="utf-8")
|
|
908
944
|
subprocess.run(["git", "add", "app.py"], cwd=path, check=True)
|
|
909
945
|
subprocess.run(["git", "commit", "-qm", "base"], cwd=path, check=True)
|
|
910
946
|
|
|
@@ -917,7 +953,7 @@ def self_test() -> int:
|
|
|
917
953
|
|
|
918
954
|
def complete_prior(path: pathlib.Path) -> None:
|
|
919
955
|
state_path = path / ".devlyn" / "pipeline.state.json"
|
|
920
|
-
state = strict_json(state_path.read_text())
|
|
956
|
+
state = strict_json(state_path.read_text(encoding="utf-8"))
|
|
921
957
|
state["phases"]["final_report"] = {
|
|
922
958
|
"verdict": "PASS", "completed_at": "2026-09-06T00:00:00Z",
|
|
923
959
|
}
|
|
@@ -940,8 +976,8 @@ def self_test() -> int:
|
|
|
940
976
|
role_raw = b'{"roles":{"worker":{"engine":"codex","model":"gpt-6-astra"}}}'
|
|
941
977
|
role_file.write_bytes(role_raw)
|
|
942
978
|
bootstrap(["--role-config", str(role_file), "--no-pair", "fix app.py"], role_work, script_shared)
|
|
943
|
-
staged_role = strict_json((role_work / ".devlyn/pipeline.state.json").read_text())
|
|
944
|
-
role_file.write_text("{}")
|
|
979
|
+
staged_role = strict_json((role_work / ".devlyn/pipeline.state.json").read_text(encoding="utf-8"))
|
|
980
|
+
role_file.write_text("{}", encoding="utf-8")
|
|
945
981
|
assert staged_role["role_config_input"]["sha256"] == sha256(role_raw)
|
|
946
982
|
assert staged_role["role_config_input"]["value"] == strict_json(role_raw.decode())
|
|
947
983
|
assert staged_role["role_no_pair"] is True
|
|
@@ -952,7 +988,7 @@ def self_test() -> int:
|
|
|
952
988
|
prior_session = os.environ.pop(session_key, None)
|
|
953
989
|
result = bootstrap(["fix", "app.py", "failing", "test"], work, script_shared)
|
|
954
990
|
state_path = work / ".devlyn" / "pipeline.state.json"
|
|
955
|
-
state = strict_json(state_path.read_text())
|
|
991
|
+
state = strict_json(state_path.read_text(encoding="utf-8"))
|
|
956
992
|
expected = {
|
|
957
993
|
"version": "3.0",
|
|
958
994
|
"run_id": state["run_id"],
|
|
@@ -994,7 +1030,10 @@ def self_test() -> int:
|
|
|
994
1030
|
"phases": {name: None for name in PHASE_NAMES},
|
|
995
1031
|
"verify": {"coverage_failed": False, "pair_trigger": None},
|
|
996
1032
|
}
|
|
997
|
-
|
|
1033
|
+
schema_dirs = [script_shared.parent / name for name in ("devlyn:resolve", "devlyn\uF03Aresolve")
|
|
1034
|
+
if (script_shared.parent / name).is_dir()]
|
|
1035
|
+
assert len(schema_dirs) == 1, schema_dirs
|
|
1036
|
+
schema = (schema_dirs[0] / "references" / "state-schema.md").read_text(encoding="utf-8")
|
|
998
1037
|
assert '"version": "3.0"' in schema and all(f'"{name}"' in schema for name in PHASE_NAMES)
|
|
999
1038
|
assert state_path.read_bytes() == json_bytes(expected)
|
|
1000
1039
|
assert result["state_sha256"] == sha256(json_bytes(expected))
|
|
@@ -1007,7 +1046,7 @@ def self_test() -> int:
|
|
|
1007
1046
|
init_repo(detached)
|
|
1008
1047
|
subprocess.run(["git", "checkout", "--detach", "-q"], cwd=detached, check=True)
|
|
1009
1048
|
detached_result = bootstrap(["fix", "app.py"], detached, script_shared)
|
|
1010
|
-
detached_state = strict_json((detached / ".devlyn" / "pipeline.state.json").read_text())
|
|
1049
|
+
detached_state = strict_json((detached / ".devlyn" / "pipeline.state.json").read_text(encoding="utf-8"))
|
|
1011
1050
|
assert detached_result["ok"] is True
|
|
1012
1051
|
assert detached_state["base_ref"]["branch"] is None
|
|
1013
1052
|
assert detached_state["base_ref"]["sha"] == git_text(detached, "rev-parse", "HEAD")
|
|
@@ -1016,7 +1055,7 @@ def self_test() -> int:
|
|
|
1016
1055
|
complete_prior(work)
|
|
1017
1056
|
os.environ[session_key] = "session-self-test"
|
|
1018
1057
|
bootstrap(["--engine", "raw-engine", "--pair-verify", "fix", "app.py"], work, script_shared)
|
|
1019
|
-
stamped = strict_json(state_path.read_text())
|
|
1058
|
+
stamped = strict_json(state_path.read_text(encoding="utf-8"))
|
|
1020
1059
|
assert stamped["session_id"] == "session-self-test"
|
|
1021
1060
|
assert stamped["engine"] == "raw-engine" and stamped["engine_source"] == "flag"
|
|
1022
1061
|
assert stamped["pair_verify"] is True
|
|
@@ -1063,6 +1102,7 @@ def self_test() -> int:
|
|
|
1063
1102
|
cli_blocked = subprocess.run(
|
|
1064
1103
|
[sys.executable, str(pathlib.Path(__file__).resolve()), "--pair-verify", "--no-pair"],
|
|
1065
1104
|
cwd=blocked_work, capture_output=True, text=True,
|
|
1105
|
+
encoding="utf-8",
|
|
1066
1106
|
)
|
|
1067
1107
|
assert cli_blocked.returncode == 1 and cli_blocked.stderr == ""
|
|
1068
1108
|
assert strict_json(cli_blocked.stdout)["blocked"] == "BLOCKED:invalid-flags"
|
|
@@ -1070,7 +1110,7 @@ def self_test() -> int:
|
|
|
1070
1110
|
print("PASS bootstrap self-test failure receipt: machine-only JSON, zero partial state")
|
|
1071
1111
|
|
|
1072
1112
|
goal_file = blocked_work / "goal.txt"
|
|
1073
|
-
goal_file.write_text(" \n")
|
|
1113
|
+
goal_file.write_text(" \n", encoding="utf-8")
|
|
1074
1114
|
try:
|
|
1075
1115
|
bootstrap(["--goal-file", "goal.txt"], blocked_work, script_shared)
|
|
1076
1116
|
except BootstrapBlocked as exc:
|
|
@@ -1135,7 +1175,8 @@ def self_test() -> int:
|
|
|
1135
1175
|
spec_path = spec_dir / "spec.md"
|
|
1136
1176
|
spec_path.write_text(
|
|
1137
1177
|
"---\ncomplexity: medium\n---\n# Spec\n\n<!-- devlyn:verification -->\n"
|
|
1138
|
-
"## Verification\n\n```json\n{\"verification_commands\":[{\"cmd\":\"printf ok\",\"stdout_contains\":[\"ok\"]}]}\n```\n"
|
|
1178
|
+
"## Verification\n\n```json\n{\"verification_commands\":[{\"cmd\":\"printf ok\",\"stdout_contains\":[\"ok\"]}]}\n```\n",
|
|
1179
|
+
encoding="utf-8",
|
|
1139
1180
|
)
|
|
1140
1181
|
spec_raw = spec_path.read_bytes()
|
|
1141
1182
|
external_patch = work / ".devlyn" / "external-diff.patch"
|
|
@@ -1143,7 +1184,7 @@ def self_test() -> int:
|
|
|
1143
1184
|
complete_prior(work)
|
|
1144
1185
|
spec_result = bootstrap(["--spec", str(spec_path.relative_to(work))], work, script_shared)
|
|
1145
1186
|
assert not external_patch.exists()
|
|
1146
|
-
staged = strict_json((work / ".devlyn" / "spec-verify.json").read_text())
|
|
1187
|
+
staged = strict_json((work / ".devlyn" / "spec-verify.json").read_text(encoding="utf-8"))
|
|
1147
1188
|
assert staged["verification_commands"][0]["cmd"] == "printf ok"
|
|
1148
1189
|
assert spec_result["source"]["spec_sha256"] == sha256(spec_raw)
|
|
1149
1190
|
external_patch.write_bytes(b"stale free-form patch\n")
|
|
@@ -1152,14 +1193,14 @@ def self_test() -> int:
|
|
|
1152
1193
|
assert not external_patch.exists()
|
|
1153
1194
|
(spec_dir / "spec.expected.json").write_text(json.dumps({
|
|
1154
1195
|
"verification_commands": [{"cmd": "printf expected", "stdout_contains": ["expected"]}],
|
|
1155
|
-
}) + "\n")
|
|
1196
|
+
}) + "\n", encoding="utf-8")
|
|
1156
1197
|
complete_prior(work)
|
|
1157
1198
|
bootstrap(["--spec", str(spec_path.relative_to(work))], work, script_shared)
|
|
1158
|
-
staged = strict_json((work / ".devlyn" / "spec-verify.json").read_text())
|
|
1199
|
+
staged = strict_json((work / ".devlyn" / "spec-verify.json").read_text(encoding="utf-8"))
|
|
1159
1200
|
assert staged["verification_commands"][0]["cmd"] == "printf expected"
|
|
1160
1201
|
patch_raw = b"diff --git a/app.py b/app.py\nexact external bytes\x00\n"
|
|
1161
1202
|
(work / "external.patch").write_bytes(patch_raw)
|
|
1162
|
-
(work / "app.py").write_text("print('dirty verify-only input')\n")
|
|
1203
|
+
(work / "app.py").write_text("print('dirty verify-only input')\n", encoding="utf-8")
|
|
1163
1204
|
complete_prior(work)
|
|
1164
1205
|
verify_result = bootstrap([
|
|
1165
1206
|
"--verify-only", "external.patch", "--spec", str(spec_path.relative_to(work)),
|
|
@@ -1173,7 +1214,7 @@ def self_test() -> int:
|
|
|
1173
1214
|
init_repo(dirty_work)
|
|
1174
1215
|
bootstrap(["clean", "baseline"], dirty_work, script_shared)
|
|
1175
1216
|
before_dirty = snapshot(dirty_work / ".devlyn")
|
|
1176
|
-
(dirty_work / "app.py").write_text("print('unstaged')\n")
|
|
1217
|
+
(dirty_work / "app.py").write_text("print('unstaged')\n", encoding="utf-8")
|
|
1177
1218
|
for label in ("unstaged", "staged"):
|
|
1178
1219
|
if label == "staged":
|
|
1179
1220
|
subprocess.run(["git", "add", "app.py"], cwd=dirty_work, check=True)
|
|
@@ -1192,16 +1233,16 @@ def self_test() -> int:
|
|
|
1192
1233
|
init_repo(devlyn_work)
|
|
1193
1234
|
(devlyn_work / ".devlyn").mkdir()
|
|
1194
1235
|
owner_file = devlyn_work / ".devlyn" / "owner.txt"
|
|
1195
|
-
owner_file.write_text("base\n")
|
|
1236
|
+
owner_file.write_text("base\n", encoding="utf-8")
|
|
1196
1237
|
subprocess.run(["git", "add", ".devlyn/owner.txt"], cwd=devlyn_work, check=True)
|
|
1197
1238
|
subprocess.run(["git", "commit", "-qm", "track devlyn owner"], cwd=devlyn_work, check=True)
|
|
1198
|
-
owner_file.write_text("dirty allowed\n")
|
|
1239
|
+
owner_file.write_text("dirty allowed\n", encoding="utf-8")
|
|
1199
1240
|
assert bootstrap(["devlyn", "allowed"], devlyn_work, script_shared)["ok"] is True
|
|
1200
|
-
assert owner_file.read_text() == "dirty allowed\n"
|
|
1241
|
+
assert owner_file.read_text(encoding="utf-8") == "dirty allowed\n"
|
|
1201
1242
|
|
|
1202
1243
|
untracked_work = root / "untracked-repo"
|
|
1203
1244
|
init_repo(untracked_work)
|
|
1204
|
-
(untracked_work / "untracked.txt").write_text("allowed\n")
|
|
1245
|
+
(untracked_work / "untracked.txt").write_text("allowed\n", encoding="utf-8")
|
|
1205
1246
|
assert bootstrap(["untracked", "allowed"], untracked_work, script_shared)["ok"] is True
|
|
1206
1247
|
print("PASS bootstrap self-test honest baseline: dirty owner blocked; .devlyn/untracked allowed")
|
|
1207
1248
|
|
|
@@ -1248,11 +1289,11 @@ def self_test() -> int:
|
|
|
1248
1289
|
assert (prior_archive / name).is_file(), name
|
|
1249
1290
|
if name not in {"pipeline.state.json", "goal.raw.txt"}:
|
|
1250
1291
|
assert not (prior_devlyn / name).exists(), name
|
|
1251
|
-
assert strict_json((prior_devlyn / "pipeline.state.json").read_text())["run_id"] == replacement["run_id"]
|
|
1292
|
+
assert strict_json((prior_devlyn / "pipeline.state.json").read_text(encoding="utf-8"))["run_id"] == replacement["run_id"]
|
|
1252
1293
|
assert (prior_archive / "goal.raw.txt").read_bytes() == b"prior run"
|
|
1253
1294
|
assert (prior_devlyn / "goal.raw.txt").read_bytes() == b"replacement run"
|
|
1254
|
-
assert (prior_devlyn / "engines.json").read_text() == '{"executor":"codex"}\n'
|
|
1255
|
-
assert (prior_devlyn / "unrelated.data").read_text() == "preserve\n"
|
|
1295
|
+
assert (prior_devlyn / "engines.json").read_text(encoding="utf-8") == '{"executor":"codex"}\n'
|
|
1296
|
+
assert (prior_devlyn / "unrelated.data").read_text(encoding="utf-8") == "preserve\n"
|
|
1256
1297
|
|
|
1257
1298
|
for label, state_raw in (("missing", None), ("malformed", b"{\n")):
|
|
1258
1299
|
unauthenticated = root / f"unauthenticated-{label}"
|
|
@@ -1276,7 +1317,8 @@ def self_test() -> int:
|
|
|
1276
1317
|
init_repo(malformed_work)
|
|
1277
1318
|
bad_spec = malformed_work / "spec.md"
|
|
1278
1319
|
bad_spec.write_text(
|
|
1279
|
-
"# Spec\n\n<!-- devlyn:verification -->\n## Verification\n\n```json\n{broken\n```\n"
|
|
1320
|
+
"# Spec\n\n<!-- devlyn:verification -->\n## Verification\n\n```json\n{broken\n```\n",
|
|
1321
|
+
encoding="utf-8",
|
|
1280
1322
|
)
|
|
1281
1323
|
try:
|
|
1282
1324
|
bootstrap(["--spec", "spec.md"], malformed_work, script_shared)
|
|
@@ -1319,4 +1361,5 @@ def main() -> int:
|
|
|
1319
1361
|
|
|
1320
1362
|
|
|
1321
1363
|
if __name__ == "__main__":
|
|
1364
|
+
runpy.run_path(str(pathlib.Path(__file__).with_name("platform-support.py")))["configure_utf8"]()
|
|
1322
1365
|
raise SystemExit(main())
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
from __future__ import annotations
|
|
5
5
|
|
|
6
|
+
import runpy
|
|
6
7
|
import fnmatch
|
|
7
8
|
import hashlib
|
|
8
9
|
import importlib.util
|
|
@@ -281,7 +282,7 @@ def self_test() -> int:
|
|
|
281
282
|
assert hashlib.sha256(block_state.read_bytes()).hexdigest() == before_sha
|
|
282
283
|
assert len(receipts) == 1
|
|
283
284
|
assert any(fnmatch.fnmatch(receipts[0].name, pattern) for pattern in archive_module.PER_RUN_PATTERNS)
|
|
284
|
-
assert json.loads(receipts[0].read_text())["stop_hook_active"] is True
|
|
285
|
+
assert json.loads(receipts[0].read_text(encoding="utf-8"))["stop_hook_active"] is True
|
|
285
286
|
|
|
286
287
|
absent = scratch / "control-a-absent"
|
|
287
288
|
absent.mkdir()
|
|
@@ -354,4 +355,5 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
354
355
|
|
|
355
356
|
|
|
356
357
|
if __name__ == "__main__":
|
|
358
|
+
runpy.run_path(str(pathlib.Path(__file__).with_name("platform-support.py")))["configure_utf8"]()
|
|
357
359
|
raise SystemExit(main())
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
"""Resolve explicit devlyn roles; no model dispatch or lifecycle changes."""
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import runpy
|
|
5
6
|
import argparse
|
|
6
7
|
import hashlib
|
|
7
8
|
import json
|
|
@@ -58,7 +59,7 @@ def adapter(engine, judge=False, shared=SHARED):
|
|
|
58
59
|
path = shared / "adapters" / f"{engine}.md"
|
|
59
60
|
if not path.is_file():
|
|
60
61
|
fail(f"no adapter for {engine}")
|
|
61
|
-
text = path.read_text()
|
|
62
|
+
text = path.read_text(encoding="utf-8")
|
|
62
63
|
field = "pair_judge" if judge else "executor"
|
|
63
64
|
if re.search(rf"^{field}: no\s*$", text, re.M):
|
|
64
65
|
fail(f"{engine} is ineligible for {'judge' if judge else 'worker'}")
|
|
@@ -238,7 +239,7 @@ def native_version(engine):
|
|
|
238
239
|
if binary is None:
|
|
239
240
|
fail(f"install/authenticate {engine} and retry", f"{engine}-unavailable")
|
|
240
241
|
try:
|
|
241
|
-
proc = subprocess.run([binary, "--version"], capture_output=True, text=True, timeout=20)
|
|
242
|
+
proc = subprocess.run(runpy.run_path(Path(__file__).with_name("platform-support.py"))["native_argv"]([binary, "--version"]), capture_output=True, text=True, timeout=20, encoding="utf-8")
|
|
242
243
|
except (OSError, subprocess.SubprocessError) as exc:
|
|
243
244
|
fail(f"{engine} --version failed: {exc}; install/authenticate the native CLI and retry", f"{engine}-unavailable")
|
|
244
245
|
if proc.returncode != 0:
|
|
@@ -402,7 +403,7 @@ def self_test():
|
|
|
402
403
|
for role, arguments in (("pair_judge", ["--value", '{"engine":"claude"}']),
|
|
403
404
|
("worker", ["--value", "{"]), ("worker", [])):
|
|
404
405
|
proc = subprocess.run([sys.executable, str(Path(__file__).resolve()), "--workdir", str(work),
|
|
405
|
-
"--set-role", role, *arguments], capture_output=True, text=True)
|
|
406
|
+
"--set-role", role, *arguments], capture_output=True, text=True, encoding="utf-8")
|
|
406
407
|
assert proc.returncode == 1 and "BLOCKED:invalid-engine-config" in proc.stderr, proc.stderr
|
|
407
408
|
assert "Traceback" not in proc.stderr and path.read_bytes() == before
|
|
408
409
|
path.write_bytes(encoded({"executor": "claude", "pair_judge_priority": ["codex"]}))
|
|
@@ -420,7 +421,7 @@ def self_test():
|
|
|
420
421
|
assert status["roles"]["primary_judge"]["availability"] == "CLI-unavailable"
|
|
421
422
|
before = path.read_bytes()
|
|
422
423
|
proc = subprocess.run([sys.executable, str(Path(__file__).resolve()), "--workdir", str(work)],
|
|
423
|
-
capture_output=True, text=True, env={**os.environ, "PATH": str(work / "no-binaries")})
|
|
424
|
+
capture_output=True, text=True, env={**os.environ, "PATH": str(work / "no-binaries")}, encoding="utf-8")
|
|
424
425
|
assert proc.returncode == 0 and loads(proc.stdout)["roles"]["pair_judge"]["engine"] is None, proc.stderr
|
|
425
426
|
assert path.read_bytes() == before
|
|
426
427
|
for engine in (None, ""):
|
|
@@ -470,7 +471,7 @@ def self_test():
|
|
|
470
471
|
raise AssertionError("unknown judge effort support was assumed")
|
|
471
472
|
ineligible = work / "adapters"
|
|
472
473
|
ineligible.mkdir()
|
|
473
|
-
(ineligible / "claude.md").write_text("pair_judge: no\n")
|
|
474
|
+
(ineligible / "claude.md").write_text("pair_judge: no\n", encoding="utf-8")
|
|
474
475
|
try:
|
|
475
476
|
options(future, "primary_judge", version=future_version, shared=work)
|
|
476
477
|
except ValueError as exc:
|
|
@@ -549,4 +550,5 @@ def main():
|
|
|
549
550
|
|
|
550
551
|
|
|
551
552
|
if __name__ == "__main__":
|
|
553
|
+
runpy.run_path(str(Path(__file__).with_name("platform-support.py")))["configure_utf8"]()
|
|
552
554
|
raise SystemExit(main())
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import runpy
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
PLATFORM = runpy.run_path(Path(__file__).with_name("platform-support.py"))
|
|
5
10
|
|
|
6
11
|
|
|
7
12
|
def fail(message: str) -> int:
|
|
@@ -11,34 +16,43 @@ def fail(message: str) -> int:
|
|
|
11
16
|
|
|
12
17
|
def main(argv: list[str]) -> int:
|
|
13
18
|
if len(argv) < 4:
|
|
14
|
-
return fail("usage: run-bounded.py <seconds> -- <cmd> [args...]")
|
|
19
|
+
return fail("usage: run-bounded.py <seconds> [--stdin-file <path> [--record-transport]] -- <cmd> [args...]")
|
|
15
20
|
try:
|
|
16
21
|
seconds = int(argv[1])
|
|
17
22
|
except ValueError:
|
|
18
23
|
return fail("seconds must be a positive integer")
|
|
19
24
|
if seconds <= 0:
|
|
20
25
|
return fail("seconds must be a positive integer")
|
|
21
|
-
|
|
26
|
+
index, prompt, record_transport = 2, None, False
|
|
27
|
+
if argv[index] == "--stdin-file":
|
|
28
|
+
if len(argv) < 6:
|
|
29
|
+
return fail("--stdin-file requires a path and command")
|
|
30
|
+
prompt, index = argv[index + 1], index + 2
|
|
31
|
+
if argv[index] == "--record-transport":
|
|
32
|
+
record_transport, index = True, index + 1
|
|
33
|
+
if argv[index] != "--" or not argv[index + 1:]:
|
|
22
34
|
return fail("expected -- before command")
|
|
23
|
-
|
|
24
|
-
child = subprocess.Popen(argv[3:], stdin=subprocess.DEVNULL, start_new_session=True)
|
|
35
|
+
stream = None
|
|
25
36
|
try:
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
37
|
+
command = argv[index + 1:]
|
|
38
|
+
actual = PLATFORM["native_argv"](command)
|
|
39
|
+
if record_transport:
|
|
40
|
+
transport = runpy.run_path(Path(__file__).with_name("invocation-receipt.py"))
|
|
41
|
+
stream, carrier, record = transport["prepare_transport"](prompt, command, actual, seconds)
|
|
42
|
+
transport["write_transport"](carrier, record)
|
|
43
|
+
elif prompt is not None:
|
|
44
|
+
stream = PLATFORM["open_stdin"](prompt)
|
|
45
|
+
code = PLATFORM["run_process"](actual, stream if stream is not None else subprocess.DEVNULL, seconds)
|
|
46
|
+
if record_transport:
|
|
47
|
+
transport["finish_transport"](carrier, code)
|
|
48
|
+
return code
|
|
49
|
+
except (OSError, ValueError) as exc:
|
|
50
|
+
return fail(str(exc))
|
|
51
|
+
finally:
|
|
52
|
+
if stream is not None:
|
|
53
|
+
stream.close()
|
|
41
54
|
|
|
42
55
|
|
|
43
56
|
if __name__ == "__main__":
|
|
44
|
-
|
|
57
|
+
PLATFORM["configure_utf8"]()
|
|
58
|
+
raise SystemExit(PLATFORM["system_exit_code"](main(sys.argv)))
|