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.
Files changed (32) hide show
  1. package/README.md +6 -1
  2. package/bin/devlyn.js +69 -23
  3. package/config/skills/_shared/adapters/claude.md +3 -2
  4. package/config/skills/_shared/adapters/codex.md +1 -1
  5. package/config/skills/_shared/archive_run.py +5 -3
  6. package/config/skills/_shared/codex-config.md +13 -8
  7. package/config/skills/_shared/codex-monitored.sh +12 -19
  8. package/config/skills/_shared/collect-codex-findings.py +1 -0
  9. package/config/skills/_shared/engine-preflight.md +2 -0
  10. package/config/skills/_shared/finish-gate.py +4 -1
  11. package/config/skills/_shared/invocation-receipt.py +194 -5
  12. package/config/skills/_shared/judge-role-evidence.py +33 -6
  13. package/config/skills/_shared/phase-prompt-render.py +2 -0
  14. package/config/skills/_shared/platform-support.py +370 -0
  15. package/config/skills/_shared/process-evidence.py +20 -3
  16. package/config/skills/_shared/resolve-bootstrap.py +131 -63
  17. package/config/skills/_shared/resolve-stop-hook.py +3 -1
  18. package/config/skills/_shared/role-config.py +7 -5
  19. package/config/skills/_shared/run-bounded.py +35 -21
  20. package/config/skills/_shared/spec-verify-check.py +393 -167
  21. package/config/skills/_shared/state-phase-write.py +26 -9
  22. package/config/skills/_shared/task-complete.py +337 -97
  23. package/config/skills/_shared/verify-merge-findings.py +9 -8
  24. package/config/skills/devlyn:ideate/references/spec-template.md +1 -1
  25. package/config/skills/devlyn:resolve/SKILL.md +10 -7
  26. package/config/skills/devlyn:resolve/references/free-form-mode.md +1 -1
  27. package/config/skills/devlyn:resolve/references/phases/build-gate.md +1 -1
  28. package/config/skills/devlyn:resolve/references/phases/verify.md +14 -10
  29. package/config/skills/devlyn:resolve/references/state-schema.md +1 -1
  30. package/config/skills/devlyn:resolve/references/task-completion.md +28 -1
  31. package/package.json +1 -1
  32. 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
- try:
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
- fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
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
- if not path.exists() and not path.is_symlink():
569
+ try:
570
+ pending = [(path, path.lstat())]
571
+ except FileNotFoundError:
575
572
  return {}
576
- return {
577
- str(p.relative_to(path)): (str(p.readlink()) if p.is_symlink()
578
- else None if p.is_dir() else p.read_bytes())
579
- for p in (path, *path.rglob("*"))
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 int(barrier) >= 0:
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
- alias.symlink_to(identity, target_is_directory=True)
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.symlink_to(target, target_is_directory=True)
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=(() if barrier < 0 else (barrier,)),
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
- rfd, wfd = os.pipe()
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.close(rfd)
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.close(wfd)
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
- import fcntl
844
- for replacement in (patch.dict(sys.modules, {"fcntl": None}),
845
- patch.object(fcntl, "flock", side_effect=OSError("unsupported locking"))):
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
- schema = (script_shared.parent / "devlyn:resolve" / "references" / "state-schema.md").read_text()
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
- import os, signal, subprocess, sys
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
- if argv[2] != "--":
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
- return child.wait(timeout=seconds)
27
- except subprocess.TimeoutExpired:
28
- try:
29
- os.killpg(child.pid, signal.SIGTERM)
30
- except ProcessLookupError:
31
- return 124
32
- try:
33
- child.wait(timeout=5)
34
- except subprocess.TimeoutExpired:
35
- try:
36
- os.killpg(child.pid, signal.SIGKILL)
37
- except ProcessLookupError:
38
- pass
39
- child.wait()
40
- return 124
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
- raise SystemExit(main(sys.argv))
57
+ PLATFORM["configure_utf8"]()
58
+ raise SystemExit(PLATFORM["system_exit_code"](main(sys.argv)))