devlyn-cli 3.1.0 → 3.1.2
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 +6 -1
- 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 +20 -3
- package/config/skills/_shared/resolve-bootstrap.py +131 -63
- 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 +393 -167
- package/config/skills/_shared/state-phase-write.py +26 -9
- package/config/skills/_shared/task-complete.py +337 -97
- package/config/skills/_shared/verify-merge-findings.py +9 -8
- package/config/skills/devlyn:ideate/references/spec-template.md +1 -1
- package/config/skills/devlyn:resolve/SKILL.md +10 -7
- package/config/skills/devlyn:resolve/references/free-form-mode.md +1 -1
- package/config/skills/devlyn:resolve/references/phases/build-gate.md +1 -1
- package/config/skills/devlyn:resolve/references/phases/verify.md +14 -10
- package/config/skills/devlyn:resolve/references/state-schema.md +1 -1
- package/config/skills/devlyn:resolve/references/task-completion.md +28 -1
- package/package.json +1 -1
- package/scripts/lint-skills.sh +6 -5
|
@@ -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)
|
|
@@ -282,16 +282,13 @@ def init_spec_source(
|
|
|
282
282
|
block("BLOCKED:invalid-flags", f"spec unreadable: {raw_path}: {exc}")
|
|
283
283
|
helper = shared_dir / "spec-verify-check.py"
|
|
284
284
|
expected = path.with_name("spec.expected.json")
|
|
285
|
-
if expected.is_file():
|
|
286
|
-
run_checked([sys.executable, str(helper), "--check-expected", str(expected)], cwd)
|
|
287
|
-
else:
|
|
288
|
-
run_checked([sys.executable, str(helper), "--check", str(path)], cwd)
|
|
289
285
|
module = load_spec_helper(shared_dir)
|
|
290
286
|
if expected.is_file():
|
|
291
287
|
found, _staged, error, _expected_path, _data = module.stage_from_expected(path, staging_dir)
|
|
292
288
|
if not found or error:
|
|
293
289
|
block("BLOCKED:invalid-flags", error or f"expected contract not found: {expected}")
|
|
294
290
|
else:
|
|
291
|
+
run_checked([sys.executable, str(helper), "--check", str(path)], cwd)
|
|
295
292
|
_staged, error = module.stage_from_source(path, staging_dir)
|
|
296
293
|
if error:
|
|
297
294
|
block("BLOCKED:invalid-flags", error)
|
|
@@ -320,6 +317,7 @@ def base_branch(cwd: pathlib.Path) -> str | None:
|
|
|
320
317
|
cwd=cwd,
|
|
321
318
|
capture_output=True,
|
|
322
319
|
text=True,
|
|
320
|
+
encoding="utf-8",
|
|
323
321
|
)
|
|
324
322
|
if proc.returncode == 1 and not proc.stdout.strip() and not proc.stderr.strip():
|
|
325
323
|
return None
|
|
@@ -363,24 +361,18 @@ def require_clean_tracked_baseline(cwd: pathlib.Path) -> None:
|
|
|
363
361
|
@contextlib.contextmanager
|
|
364
362
|
def admission_lock(cwd: pathlib.Path):
|
|
365
363
|
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:
|
|
364
|
+
with contextlib.ExitStack() as stack:
|
|
373
365
|
try:
|
|
374
|
-
|
|
366
|
+
stack.enter_context(runpy.run_path(pathlib.Path(__file__).with_name("platform-support.py"))["file_lock"](lock_path))
|
|
375
367
|
except BlockingIOError:
|
|
376
368
|
block(
|
|
377
369
|
"BLOCKED:bootstrap-contended",
|
|
378
370
|
f"Bootstrap admission is occupied at {cwd}. Retry from this root after admission finishes.",
|
|
379
371
|
)
|
|
380
|
-
except (OSError, AttributeError) as exc:
|
|
372
|
+
except (ImportError, OSError, AttributeError) as exc:
|
|
381
373
|
block("BLOCKED:bootstrap-lock-unavailable", f"{cwd}: {lock_path}: {exc}")
|
|
382
374
|
for path in (cwd / ".devlyn", cwd / ".devlyn" / "runs"):
|
|
383
|
-
if path.is_symlink() or (path.exists() and not path.is_dir()):
|
|
375
|
+
if path.is_symlink() or path.resolve() != path or (path.exists() and not path.is_dir()):
|
|
384
376
|
block("BLOCKED:devlyn-path-redirect", f"{cwd}: require an unredirected directory at {path}")
|
|
385
377
|
yield
|
|
386
378
|
|
|
@@ -502,6 +494,9 @@ def pathname_self_test() -> None:
|
|
|
502
494
|
env = dict(os.environ, GIT_CONFIG_GLOBAL=os.devnull, GIT_CONFIG_NOSYSTEM="1")
|
|
503
495
|
with tempfile.TemporaryDirectory() as tmp:
|
|
504
496
|
for name in ("plain", "trailing ", "trailing\t", "trailing\n", "trailing\r\n"):
|
|
497
|
+
if os.name == "nt" and name != "plain":
|
|
498
|
+
print(f"SKIP POSIX-only pathname {name!r}: native Windows rejects/normalizes this name")
|
|
499
|
+
continue
|
|
505
500
|
repo = pathlib.Path(tmp).resolve() / name
|
|
506
501
|
repo.mkdir()
|
|
507
502
|
|
|
@@ -515,13 +510,13 @@ def pathname_self_test() -> None:
|
|
|
515
510
|
for option, path in (("--show-toplevel", repo), ("--absolute-git-dir", repo / ".git")):
|
|
516
511
|
raw = git("rev-parse", option)
|
|
517
512
|
print(f"pathname {name!r} {option}: {raw!r}", flush=True)
|
|
518
|
-
assert raw == os.fsencode(path) + b"\n", raw
|
|
513
|
+
assert raw == os.fsencode(path.as_posix() if os.name == "nt" else path) + b"\n", raw
|
|
519
514
|
command = [sys.executable, str(script), "pathname", "probe"]
|
|
520
515
|
proc = subprocess.run(command, cwd=repo, env=env, capture_output=True, timeout=15)
|
|
521
516
|
result = strict_json(proc.stdout)
|
|
522
517
|
assert proc.returncode == 0 and result["ok"] and not proc.stderr, (name, proc, result)
|
|
523
518
|
for option, path in (("--show-toplevel", repo), ("--absolute-git-dir", repo / ".git")):
|
|
524
|
-
assert git_text(repo, "rev-parse", option) == str(path)
|
|
519
|
+
assert git_text(repo, "rev-parse", option) == (path.as_posix() if os.name == "nt" else str(path))
|
|
525
520
|
lock = repo / ".git" / "devlyn-bootstrap.lock"
|
|
526
521
|
inode = lock.stat().st_ino
|
|
527
522
|
devlyn = repo / ".devlyn"
|
|
@@ -565,23 +560,34 @@ def admission_self_test() -> None:
|
|
|
565
560
|
git(repo, "init", "-q")
|
|
566
561
|
git(repo, "config", "user.email", "test@example.com")
|
|
567
562
|
git(repo, "config", "user.name", "Test")
|
|
568
|
-
(repo / "app.py").write_text("print('base')\n")
|
|
563
|
+
(repo / "app.py").write_text("print('base')\n", encoding="utf-8")
|
|
569
564
|
git(repo, "add", "app.py")
|
|
570
565
|
git(repo, "commit", "-qm", "base")
|
|
571
566
|
inputs(repo)
|
|
572
567
|
|
|
573
568
|
def snapshot(path):
|
|
574
|
-
|
|
569
|
+
try:
|
|
570
|
+
pending = [(path, path.lstat())]
|
|
571
|
+
except FileNotFoundError:
|
|
575
572
|
return {}
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
573
|
+
result = {}
|
|
574
|
+
while pending:
|
|
575
|
+
p, metadata = pending.pop()
|
|
576
|
+
key = str(p.relative_to(path))
|
|
577
|
+
if stat.S_ISLNK(metadata.st_mode) or (
|
|
578
|
+
os.name == "nt" and metadata.st_reparse_tag == stat.IO_REPARSE_TAG_MOUNT_POINT
|
|
579
|
+
):
|
|
580
|
+
result[key] = str(p.readlink())
|
|
581
|
+
elif stat.S_ISDIR(metadata.st_mode):
|
|
582
|
+
result[key] = None
|
|
583
|
+
pending.extend((child, child.lstat()) for child in p.iterdir())
|
|
584
|
+
else:
|
|
585
|
+
result[key] = p.read_bytes()
|
|
586
|
+
return result
|
|
581
587
|
|
|
582
588
|
def cli(repo, argv, extra_env=None):
|
|
583
589
|
proc = subprocess.run([sys.executable, str(script), *argv], cwd=repo,
|
|
584
|
-
env=extra_env or env, capture_output=True, text=True, timeout=15)
|
|
590
|
+
env=extra_env or env, capture_output=True, text=True, timeout=15, encoding="utf-8")
|
|
585
591
|
assert proc.stderr == "", (proc.returncode, proc.stdout, proc.stderr)
|
|
586
592
|
result = strict_json(proc.stdout)
|
|
587
593
|
assert proc.returncode == (0 if result["ok"] else 1), result
|
|
@@ -628,7 +634,13 @@ if action.startswith('temp:'):
|
|
|
628
634
|
return replace(path, target)
|
|
629
635
|
pathlib.Path.replace = held_replace
|
|
630
636
|
signals.with_suffix('.ready').touch()
|
|
631
|
-
if
|
|
637
|
+
if barrier.startswith('file:'):
|
|
638
|
+
deadline = time.monotonic() + 20
|
|
639
|
+
while not pathlib.Path(barrier[5:]).exists():
|
|
640
|
+
if time.monotonic() > deadline:
|
|
641
|
+
raise TimeoutError('native admission barrier was not released')
|
|
642
|
+
time.sleep(0.01)
|
|
643
|
+
elif int(barrier) >= 0:
|
|
632
644
|
os.read(int(barrier), 1)
|
|
633
645
|
os.close(int(barrier))
|
|
634
646
|
try:
|
|
@@ -646,7 +658,7 @@ print(json.dumps(result))
|
|
|
646
658
|
first = cli(repo, modes[0])
|
|
647
659
|
devlyn = repo / ".devlyn"
|
|
648
660
|
state_path = devlyn / "pipeline.state.json"
|
|
649
|
-
initial = strict_json(state_path.read_text())
|
|
661
|
+
initial = strict_json(state_path.read_text(encoding="utf-8"))
|
|
650
662
|
(devlyn / "engines.json").write_bytes(b'{"executor":"codex"}\n')
|
|
651
663
|
(devlyn / "config.json").write_bytes(b'{"setting":true}\n')
|
|
652
664
|
(devlyn / "unrelated.data").write_bytes(b"keep\n")
|
|
@@ -671,7 +683,7 @@ print(json.dumps(result))
|
|
|
671
683
|
refusal(repo, modes[0], "BLOCKED:prior-run-unfinished")
|
|
672
684
|
for verdict in ("PASS", "PASS_WITH_ISSUES", "NEEDS_WORK", "BLOCKED"):
|
|
673
685
|
for timestamp in ("2024-02-29T23:59:59Z", "2024-02-29T23:59:59.123Z"):
|
|
674
|
-
state = strict_json(state_path.read_text())
|
|
686
|
+
state = strict_json(state_path.read_text(encoding="utf-8"))
|
|
675
687
|
state["phases"]["final_report"] = {"verdict": verdict, "completed_at": timestamp}
|
|
676
688
|
state_path.write_bytes(json_bytes(state))
|
|
677
689
|
raw = state_path.read_bytes()
|
|
@@ -706,7 +718,13 @@ print(json.dumps(result))
|
|
|
706
718
|
identity = root / "identity"
|
|
707
719
|
init(identity)
|
|
708
720
|
alias = root / "alias"
|
|
709
|
-
|
|
721
|
+
def directory_link(link, target):
|
|
722
|
+
if os.name == "nt":
|
|
723
|
+
subprocess.run(["cmd.exe", "/d", "/c", "mklink", "/J", str(link), str(target)],
|
|
724
|
+
check=True, capture_output=True)
|
|
725
|
+
else:
|
|
726
|
+
link.symlink_to(target, target_is_directory=True)
|
|
727
|
+
directory_link(alias, identity)
|
|
710
728
|
sub = identity / "sub"
|
|
711
729
|
sub.mkdir()
|
|
712
730
|
detail = refusal(sub, ["--goal-file", "relative.txt"], "BLOCKED:worktree-root-required")["detail"]
|
|
@@ -731,20 +749,26 @@ print(json.dumps(result))
|
|
|
731
749
|
(target / "keep").write_bytes(b"untouched")
|
|
732
750
|
link = identity / relative
|
|
733
751
|
link.parent.mkdir(exist_ok=True)
|
|
734
|
-
link
|
|
752
|
+
directory_link(link, target)
|
|
735
753
|
before = snapshot(target)
|
|
754
|
+
assert before == ({} if dangling else {".": None, "keep": b"untouched"})
|
|
755
|
+
expected = {".": str(link.readlink())} if relative == ".devlyn" else {
|
|
756
|
+
".": None, "runs": str(link.readlink()),
|
|
757
|
+
}
|
|
758
|
+
assert snapshot(identity / ".devlyn") == expected
|
|
736
759
|
result = refusal(identity, modes[0], "BLOCKED:devlyn-path-redirect")
|
|
737
|
-
assert str(link) in result["detail"] and link.is_symlink()
|
|
760
|
+
assert str(link) in result["detail"] and link.resolve() == target and (os.name == "nt" or link.is_symlink())
|
|
738
761
|
assert snapshot(target) == before
|
|
739
|
-
link.unlink()
|
|
762
|
+
link.rmdir() if os.name == "nt" else link.unlink()
|
|
740
763
|
print("PASS admission identity: physical-root guidance, Git redirects including empty, live/dangling directory links")
|
|
741
764
|
|
|
742
765
|
def launch(repo, signals, action, argv, barrier=-1):
|
|
743
766
|
return subprocess.Popen(
|
|
744
767
|
[sys.executable, "-c", child, str(script), str(repo), str(signals),
|
|
745
768
|
action, str(barrier), json.dumps(argv)],
|
|
746
|
-
cwd=repo, env=env, pass_fds
|
|
769
|
+
cwd=repo, env=env, **({} if os.name == "nt" else {"pass_fds": (() if barrier < 0 else (barrier,))}),
|
|
747
770
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
|
|
771
|
+
encoding="utf-8",
|
|
748
772
|
)
|
|
749
773
|
|
|
750
774
|
def receipt(proc):
|
|
@@ -762,14 +786,22 @@ print(json.dumps(result))
|
|
|
762
786
|
other = ["goal", "B"] if i == 0 else [s.replace("spec.md", "spec-b.md").replace(
|
|
763
787
|
"external.patch", "external-b.patch") for s in argv]
|
|
764
788
|
signals = [root / f"race-{i}-{label}" for label in ("A", "B")]
|
|
765
|
-
|
|
789
|
+
if os.name == "nt":
|
|
790
|
+
barrier_file = root / f"race-{i}.barrier"
|
|
791
|
+
rfd = "file:" + str(barrier_file)
|
|
792
|
+
else:
|
|
793
|
+
rfd, wfd = os.pipe()
|
|
766
794
|
procs = []
|
|
767
795
|
try:
|
|
768
796
|
procs = [launch(race, signal, "hold", args, rfd)
|
|
769
797
|
for signal, args in zip(signals, (argv, other))]
|
|
770
|
-
os.
|
|
798
|
+
if os.name != "nt":
|
|
799
|
+
os.close(rfd)
|
|
771
800
|
wait_for(lambda: all(s.with_suffix(".ready").exists() for s in signals))
|
|
772
|
-
os.
|
|
801
|
+
if os.name == "nt":
|
|
802
|
+
barrier_file.touch()
|
|
803
|
+
else:
|
|
804
|
+
os.close(wfd)
|
|
773
805
|
wait_for(lambda: any(s.with_suffix(".inside").exists() for s in signals)
|
|
774
806
|
and any(p.poll() is not None for p in procs))
|
|
775
807
|
holders = [j for j, s in enumerate(signals) if s.with_suffix(".inside").exists()]
|
|
@@ -840,9 +872,10 @@ print(json.dumps(result))
|
|
|
840
872
|
assert not (failure / ".devlyn").exists()
|
|
841
873
|
failure_lock = failure / ".git/devlyn-bootstrap.lock"
|
|
842
874
|
failure_inode = failure_lock.stat().st_ino
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
875
|
+
module = __import__("msvcrt" if os.name == "nt" else "fcntl")
|
|
876
|
+
operation = "locking" if os.name == "nt" else "flock"
|
|
877
|
+
for replacement in (patch.dict(sys.modules, {module.__name__: None}),
|
|
878
|
+
patch.object(module, operation, side_effect=OSError("unsupported locking"))):
|
|
846
879
|
with replacement:
|
|
847
880
|
try:
|
|
848
881
|
bootstrap(modes[0], failure, shared)
|
|
@@ -874,7 +907,7 @@ print(json.dumps(result))
|
|
|
874
907
|
inode = (dead / ".git/devlyn-bootstrap.lock").stat().st_ino
|
|
875
908
|
proc.kill()
|
|
876
909
|
stdout, stderr = proc.communicate(timeout=15)
|
|
877
|
-
assert proc.returncode == -9 and stdout == "" and stderr == "", (stdout, stderr)
|
|
910
|
+
assert proc.returncode == (1 if os.name == "nt" else -9) and stdout == "" and stderr == "", (proc.returncode, stdout, stderr)
|
|
878
911
|
finally:
|
|
879
912
|
if proc.poll() is None:
|
|
880
913
|
proc.kill()
|
|
@@ -904,7 +937,7 @@ def self_test() -> int:
|
|
|
904
937
|
subprocess.run(["git", "init", "-q"], cwd=path, check=True)
|
|
905
938
|
subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=path, check=True)
|
|
906
939
|
subprocess.run(["git", "config", "user.name", "Test"], cwd=path, check=True)
|
|
907
|
-
(path / "app.py").write_text("print('base')\n")
|
|
940
|
+
(path / "app.py").write_text("print('base')\n", encoding="utf-8")
|
|
908
941
|
subprocess.run(["git", "add", "app.py"], cwd=path, check=True)
|
|
909
942
|
subprocess.run(["git", "commit", "-qm", "base"], cwd=path, check=True)
|
|
910
943
|
|
|
@@ -917,7 +950,7 @@ def self_test() -> int:
|
|
|
917
950
|
|
|
918
951
|
def complete_prior(path: pathlib.Path) -> None:
|
|
919
952
|
state_path = path / ".devlyn" / "pipeline.state.json"
|
|
920
|
-
state = strict_json(state_path.read_text())
|
|
953
|
+
state = strict_json(state_path.read_text(encoding="utf-8"))
|
|
921
954
|
state["phases"]["final_report"] = {
|
|
922
955
|
"verdict": "PASS", "completed_at": "2026-09-06T00:00:00Z",
|
|
923
956
|
}
|
|
@@ -940,8 +973,8 @@ def self_test() -> int:
|
|
|
940
973
|
role_raw = b'{"roles":{"worker":{"engine":"codex","model":"gpt-6-astra"}}}'
|
|
941
974
|
role_file.write_bytes(role_raw)
|
|
942
975
|
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("{}")
|
|
976
|
+
staged_role = strict_json((role_work / ".devlyn/pipeline.state.json").read_text(encoding="utf-8"))
|
|
977
|
+
role_file.write_text("{}", encoding="utf-8")
|
|
945
978
|
assert staged_role["role_config_input"]["sha256"] == sha256(role_raw)
|
|
946
979
|
assert staged_role["role_config_input"]["value"] == strict_json(role_raw.decode())
|
|
947
980
|
assert staged_role["role_no_pair"] is True
|
|
@@ -952,7 +985,7 @@ def self_test() -> int:
|
|
|
952
985
|
prior_session = os.environ.pop(session_key, None)
|
|
953
986
|
result = bootstrap(["fix", "app.py", "failing", "test"], work, script_shared)
|
|
954
987
|
state_path = work / ".devlyn" / "pipeline.state.json"
|
|
955
|
-
state = strict_json(state_path.read_text())
|
|
988
|
+
state = strict_json(state_path.read_text(encoding="utf-8"))
|
|
956
989
|
expected = {
|
|
957
990
|
"version": "3.0",
|
|
958
991
|
"run_id": state["run_id"],
|
|
@@ -994,7 +1027,10 @@ def self_test() -> int:
|
|
|
994
1027
|
"phases": {name: None for name in PHASE_NAMES},
|
|
995
1028
|
"verify": {"coverage_failed": False, "pair_trigger": None},
|
|
996
1029
|
}
|
|
997
|
-
|
|
1030
|
+
schema_dirs = [script_shared.parent / name for name in ("devlyn:resolve", "devlyn\uF03Aresolve")
|
|
1031
|
+
if (script_shared.parent / name).is_dir()]
|
|
1032
|
+
assert len(schema_dirs) == 1, schema_dirs
|
|
1033
|
+
schema = (schema_dirs[0] / "references" / "state-schema.md").read_text(encoding="utf-8")
|
|
998
1034
|
assert '"version": "3.0"' in schema and all(f'"{name}"' in schema for name in PHASE_NAMES)
|
|
999
1035
|
assert state_path.read_bytes() == json_bytes(expected)
|
|
1000
1036
|
assert result["state_sha256"] == sha256(json_bytes(expected))
|
|
@@ -1007,7 +1043,7 @@ def self_test() -> int:
|
|
|
1007
1043
|
init_repo(detached)
|
|
1008
1044
|
subprocess.run(["git", "checkout", "--detach", "-q"], cwd=detached, check=True)
|
|
1009
1045
|
detached_result = bootstrap(["fix", "app.py"], detached, script_shared)
|
|
1010
|
-
detached_state = strict_json((detached / ".devlyn" / "pipeline.state.json").read_text())
|
|
1046
|
+
detached_state = strict_json((detached / ".devlyn" / "pipeline.state.json").read_text(encoding="utf-8"))
|
|
1011
1047
|
assert detached_result["ok"] is True
|
|
1012
1048
|
assert detached_state["base_ref"]["branch"] is None
|
|
1013
1049
|
assert detached_state["base_ref"]["sha"] == git_text(detached, "rev-parse", "HEAD")
|
|
@@ -1016,7 +1052,7 @@ def self_test() -> int:
|
|
|
1016
1052
|
complete_prior(work)
|
|
1017
1053
|
os.environ[session_key] = "session-self-test"
|
|
1018
1054
|
bootstrap(["--engine", "raw-engine", "--pair-verify", "fix", "app.py"], work, script_shared)
|
|
1019
|
-
stamped = strict_json(state_path.read_text())
|
|
1055
|
+
stamped = strict_json(state_path.read_text(encoding="utf-8"))
|
|
1020
1056
|
assert stamped["session_id"] == "session-self-test"
|
|
1021
1057
|
assert stamped["engine"] == "raw-engine" and stamped["engine_source"] == "flag"
|
|
1022
1058
|
assert stamped["pair_verify"] is True
|
|
@@ -1063,6 +1099,7 @@ def self_test() -> int:
|
|
|
1063
1099
|
cli_blocked = subprocess.run(
|
|
1064
1100
|
[sys.executable, str(pathlib.Path(__file__).resolve()), "--pair-verify", "--no-pair"],
|
|
1065
1101
|
cwd=blocked_work, capture_output=True, text=True,
|
|
1102
|
+
encoding="utf-8",
|
|
1066
1103
|
)
|
|
1067
1104
|
assert cli_blocked.returncode == 1 and cli_blocked.stderr == ""
|
|
1068
1105
|
assert strict_json(cli_blocked.stdout)["blocked"] == "BLOCKED:invalid-flags"
|
|
@@ -1070,7 +1107,7 @@ def self_test() -> int:
|
|
|
1070
1107
|
print("PASS bootstrap self-test failure receipt: machine-only JSON, zero partial state")
|
|
1071
1108
|
|
|
1072
1109
|
goal_file = blocked_work / "goal.txt"
|
|
1073
|
-
goal_file.write_text(" \n")
|
|
1110
|
+
goal_file.write_text(" \n", encoding="utf-8")
|
|
1074
1111
|
try:
|
|
1075
1112
|
bootstrap(["--goal-file", "goal.txt"], blocked_work, script_shared)
|
|
1076
1113
|
except BootstrapBlocked as exc:
|
|
@@ -1135,7 +1172,8 @@ def self_test() -> int:
|
|
|
1135
1172
|
spec_path = spec_dir / "spec.md"
|
|
1136
1173
|
spec_path.write_text(
|
|
1137
1174
|
"---\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"
|
|
1175
|
+
"## Verification\n\n```json\n{\"verification_commands\":[{\"cmd\":\"printf ok\",\"stdout_contains\":[\"ok\"]}]}\n```\n",
|
|
1176
|
+
encoding="utf-8",
|
|
1139
1177
|
)
|
|
1140
1178
|
spec_raw = spec_path.read_bytes()
|
|
1141
1179
|
external_patch = work / ".devlyn" / "external-diff.patch"
|
|
@@ -1143,7 +1181,7 @@ def self_test() -> int:
|
|
|
1143
1181
|
complete_prior(work)
|
|
1144
1182
|
spec_result = bootstrap(["--spec", str(spec_path.relative_to(work))], work, script_shared)
|
|
1145
1183
|
assert not external_patch.exists()
|
|
1146
|
-
staged = strict_json((work / ".devlyn" / "spec-verify.json").read_text())
|
|
1184
|
+
staged = strict_json((work / ".devlyn" / "spec-verify.json").read_text(encoding="utf-8"))
|
|
1147
1185
|
assert staged["verification_commands"][0]["cmd"] == "printf ok"
|
|
1148
1186
|
assert spec_result["source"]["spec_sha256"] == sha256(spec_raw)
|
|
1149
1187
|
external_patch.write_bytes(b"stale free-form patch\n")
|
|
@@ -1152,14 +1190,14 @@ def self_test() -> int:
|
|
|
1152
1190
|
assert not external_patch.exists()
|
|
1153
1191
|
(spec_dir / "spec.expected.json").write_text(json.dumps({
|
|
1154
1192
|
"verification_commands": [{"cmd": "printf expected", "stdout_contains": ["expected"]}],
|
|
1155
|
-
}) + "\n")
|
|
1193
|
+
}) + "\n", encoding="utf-8")
|
|
1156
1194
|
complete_prior(work)
|
|
1157
1195
|
bootstrap(["--spec", str(spec_path.relative_to(work))], work, script_shared)
|
|
1158
|
-
staged = strict_json((work / ".devlyn" / "spec-verify.json").read_text())
|
|
1196
|
+
staged = strict_json((work / ".devlyn" / "spec-verify.json").read_text(encoding="utf-8"))
|
|
1159
1197
|
assert staged["verification_commands"][0]["cmd"] == "printf expected"
|
|
1160
1198
|
patch_raw = b"diff --git a/app.py b/app.py\nexact external bytes\x00\n"
|
|
1161
1199
|
(work / "external.patch").write_bytes(patch_raw)
|
|
1162
|
-
(work / "app.py").write_text("print('dirty verify-only input')\n")
|
|
1200
|
+
(work / "app.py").write_text("print('dirty verify-only input')\n", encoding="utf-8")
|
|
1163
1201
|
complete_prior(work)
|
|
1164
1202
|
verify_result = bootstrap([
|
|
1165
1203
|
"--verify-only", "external.patch", "--spec", str(spec_path.relative_to(work)),
|
|
@@ -1169,11 +1207,39 @@ def self_test() -> int:
|
|
|
1169
1207
|
subprocess.run(["git", "restore", "app.py"], cwd=work, check=True)
|
|
1170
1208
|
print("PASS bootstrap self-test patch lifecycle: full-mode removal + dirty verify-only exact capture")
|
|
1171
1209
|
|
|
1210
|
+
for valid_named in (True, False):
|
|
1211
|
+
named_work = root / f"named-spec-{valid_named}"
|
|
1212
|
+
init_repo(named_work)
|
|
1213
|
+
named_spec = named_work / "X.md"
|
|
1214
|
+
named_spec.write_text(
|
|
1215
|
+
"---\ncomplexity: " + ("medium" if valid_named else "hihg") + "\n---\n# Named spec\n",
|
|
1216
|
+
encoding="utf-8",
|
|
1217
|
+
)
|
|
1218
|
+
(named_work / "spec.md").write_text(
|
|
1219
|
+
"---\ncomplexity: " + ("hihg" if valid_named else "medium") + "\n---\n# Unrelated spec\n",
|
|
1220
|
+
encoding="utf-8",
|
|
1221
|
+
)
|
|
1222
|
+
(named_work / "spec.expected.json").write_text(json.dumps({
|
|
1223
|
+
"verification_commands": [{"cmd": "printf named-contract"}],
|
|
1224
|
+
}), encoding="utf-8")
|
|
1225
|
+
try:
|
|
1226
|
+
result = bootstrap(["--spec", "X.md"], named_work, script_shared)
|
|
1227
|
+
except BootstrapBlocked as exc:
|
|
1228
|
+
assert not valid_named and exc.reason == "BLOCKED:invalid-flags", exc
|
|
1229
|
+
assert "frontmatter complexity" in exc.detail, exc.detail
|
|
1230
|
+
assert not (named_work / ".devlyn").exists()
|
|
1231
|
+
else:
|
|
1232
|
+
assert valid_named, "invalid named spec was accepted via unrelated spec.md"
|
|
1233
|
+
assert result["source"]["spec_path"] == "X.md"
|
|
1234
|
+
assert result["source"]["spec_sha256"] == sha256(named_spec.read_bytes())
|
|
1235
|
+
staged = strict_json((named_work / ".devlyn/spec-verify.json").read_text(encoding="utf-8"))
|
|
1236
|
+
assert staged["verification_commands"] == [{"cmd": "printf named-contract"}]
|
|
1237
|
+
|
|
1172
1238
|
dirty_work = root / "dirty-repo"
|
|
1173
1239
|
init_repo(dirty_work)
|
|
1174
1240
|
bootstrap(["clean", "baseline"], dirty_work, script_shared)
|
|
1175
1241
|
before_dirty = snapshot(dirty_work / ".devlyn")
|
|
1176
|
-
(dirty_work / "app.py").write_text("print('unstaged')\n")
|
|
1242
|
+
(dirty_work / "app.py").write_text("print('unstaged')\n", encoding="utf-8")
|
|
1177
1243
|
for label in ("unstaged", "staged"):
|
|
1178
1244
|
if label == "staged":
|
|
1179
1245
|
subprocess.run(["git", "add", "app.py"], cwd=dirty_work, check=True)
|
|
@@ -1192,16 +1258,16 @@ def self_test() -> int:
|
|
|
1192
1258
|
init_repo(devlyn_work)
|
|
1193
1259
|
(devlyn_work / ".devlyn").mkdir()
|
|
1194
1260
|
owner_file = devlyn_work / ".devlyn" / "owner.txt"
|
|
1195
|
-
owner_file.write_text("base\n")
|
|
1261
|
+
owner_file.write_text("base\n", encoding="utf-8")
|
|
1196
1262
|
subprocess.run(["git", "add", ".devlyn/owner.txt"], cwd=devlyn_work, check=True)
|
|
1197
1263
|
subprocess.run(["git", "commit", "-qm", "track devlyn owner"], cwd=devlyn_work, check=True)
|
|
1198
|
-
owner_file.write_text("dirty allowed\n")
|
|
1264
|
+
owner_file.write_text("dirty allowed\n", encoding="utf-8")
|
|
1199
1265
|
assert bootstrap(["devlyn", "allowed"], devlyn_work, script_shared)["ok"] is True
|
|
1200
|
-
assert owner_file.read_text() == "dirty allowed\n"
|
|
1266
|
+
assert owner_file.read_text(encoding="utf-8") == "dirty allowed\n"
|
|
1201
1267
|
|
|
1202
1268
|
untracked_work = root / "untracked-repo"
|
|
1203
1269
|
init_repo(untracked_work)
|
|
1204
|
-
(untracked_work / "untracked.txt").write_text("allowed\n")
|
|
1270
|
+
(untracked_work / "untracked.txt").write_text("allowed\n", encoding="utf-8")
|
|
1205
1271
|
assert bootstrap(["untracked", "allowed"], untracked_work, script_shared)["ok"] is True
|
|
1206
1272
|
print("PASS bootstrap self-test honest baseline: dirty owner blocked; .devlyn/untracked allowed")
|
|
1207
1273
|
|
|
@@ -1248,11 +1314,11 @@ def self_test() -> int:
|
|
|
1248
1314
|
assert (prior_archive / name).is_file(), name
|
|
1249
1315
|
if name not in {"pipeline.state.json", "goal.raw.txt"}:
|
|
1250
1316
|
assert not (prior_devlyn / name).exists(), name
|
|
1251
|
-
assert strict_json((prior_devlyn / "pipeline.state.json").read_text())["run_id"] == replacement["run_id"]
|
|
1317
|
+
assert strict_json((prior_devlyn / "pipeline.state.json").read_text(encoding="utf-8"))["run_id"] == replacement["run_id"]
|
|
1252
1318
|
assert (prior_archive / "goal.raw.txt").read_bytes() == b"prior run"
|
|
1253
1319
|
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"
|
|
1320
|
+
assert (prior_devlyn / "engines.json").read_text(encoding="utf-8") == '{"executor":"codex"}\n'
|
|
1321
|
+
assert (prior_devlyn / "unrelated.data").read_text(encoding="utf-8") == "preserve\n"
|
|
1256
1322
|
|
|
1257
1323
|
for label, state_raw in (("missing", None), ("malformed", b"{\n")):
|
|
1258
1324
|
unauthenticated = root / f"unauthenticated-{label}"
|
|
@@ -1276,7 +1342,8 @@ def self_test() -> int:
|
|
|
1276
1342
|
init_repo(malformed_work)
|
|
1277
1343
|
bad_spec = malformed_work / "spec.md"
|
|
1278
1344
|
bad_spec.write_text(
|
|
1279
|
-
"# Spec\n\n<!-- devlyn:verification -->\n## Verification\n\n```json\n{broken\n```\n"
|
|
1345
|
+
"# Spec\n\n<!-- devlyn:verification -->\n## Verification\n\n```json\n{broken\n```\n",
|
|
1346
|
+
encoding="utf-8",
|
|
1280
1347
|
)
|
|
1281
1348
|
try:
|
|
1282
1349
|
bootstrap(["--spec", "spec.md"], malformed_work, script_shared)
|
|
@@ -1319,4 +1386,5 @@ def main() -> int:
|
|
|
1319
1386
|
|
|
1320
1387
|
|
|
1321
1388
|
if __name__ == "__main__":
|
|
1389
|
+
runpy.run_path(str(pathlib.Path(__file__).with_name("platform-support.py")))["configure_utf8"]()
|
|
1322
1390
|
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)))
|