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
@@ -8,7 +8,6 @@ from __future__ import annotations
8
8
 
9
9
  import argparse
10
10
  import contextlib
11
- import fcntl
12
11
  import functools
13
12
  import hashlib
14
13
  import json
@@ -35,7 +34,7 @@ def require(condition, message):
35
34
 
36
35
 
37
36
  def command(argv, *, cwd=None, ok=(0,)):
38
- result = subprocess.run(argv, cwd=cwd, capture_output=True, text=True)
37
+ result = subprocess.run(argv, cwd=cwd, capture_output=True, text=True, encoding="utf-8")
39
38
  require(result.returncode in ok, f"{shlex.join(argv[:4])}: {result.stderr.strip() or result.stdout.strip()}")
40
39
  return result.stdout.strip()
41
40
 
@@ -51,23 +50,24 @@ def shared(name):
51
50
 
52
51
  def read_json(path):
53
52
  require(stat.S_ISREG(path.lstat().st_mode), f"JSON must be a nonsymlink regular file: {path}")
54
- return shared("archive_run")["loads_strict_json"](path.read_text())
53
+ return shared("archive_run")["loads_strict_json"](path.read_text(encoding="utf-8"))
55
54
 
56
55
 
57
56
  def atomic_json(path, value):
58
57
  fd, temporary = tempfile.mkstemp(dir=path.parent, prefix=".receipt-")
59
58
  try:
60
- with os.fdopen(fd, "w") as stream:
59
+ with os.fdopen(fd, "w", encoding="utf-8") as stream:
61
60
  json.dump(value, stream, sort_keys=True, indent=2)
62
61
  stream.write("\n")
63
62
  stream.flush()
64
63
  os.fsync(stream.fileno())
65
64
  os.replace(temporary, path)
66
- directory = os.open(path.parent, os.O_RDONLY)
67
- try:
68
- os.fsync(directory)
69
- finally:
70
- os.close(directory)
65
+ if os.name != "nt":
66
+ directory = os.open(path.parent, os.O_RDONLY)
67
+ try:
68
+ os.fsync(directory)
69
+ finally:
70
+ os.close(directory)
71
71
  finally:
72
72
  Path(temporary).unlink(missing_ok=True)
73
73
 
@@ -167,7 +167,7 @@ def repository_from_url(url):
167
167
 
168
168
 
169
169
  def policy(receipt, override):
170
- config = subprocess.run(["git", "--git-dir", receipt["common_gitdir"], "config", "--local", "--get-all", "devlyn.completionMode"], capture_output=True, text=True)
170
+ config = subprocess.run(["git", "--git-dir", receipt["common_gitdir"], "config", "--local", "--get-all", "devlyn.completionMode"], capture_output=True, text=True, encoding="utf-8")
171
171
  require(config.returncode in {0, 1}, "cannot read local completion policy: " + config.stderr.strip())
172
172
  require(config.returncode == 1 or config.stdout in {"auto\n", "pr\n"}, "invalid local devlyn.completionMode; use auto|pr")
173
173
  value = config.stdout.strip() if config.returncode == 0 else None
@@ -204,6 +204,9 @@ def allocate(args):
204
204
  path = directory / "receipt.json"
205
205
  receipt["id"] = key
206
206
  receipt["recovery_ref"] = "refs/devlyn/completed/" + key
207
+ scratch = directory / "scratch"
208
+ scratch.mkdir()
209
+ receipt["scratch_identity"] = workspace_identity(scratch, directory)
207
210
  # Persist prospective intent BEFORE native creation. An interrupted allocation
208
211
  # stays visibly incomplete; a later call may not adopt whatever now exists.
209
212
  atomic_json(path, receipt)
@@ -215,7 +218,7 @@ def allocate(args):
215
218
  receipt["worktree_identity"] = workspace_identity(target, Path(receipt["worktree_gitdir"]))
216
219
  receipt["allocation"] = "owned"
217
220
  atomic_json(path, receipt)
218
- return {"status": "ALLOCATED", "receipt": str(path), "worktree": str(target)}
221
+ return {"status": "ALLOCATED", "receipt": str(path), "worktree": str(target), "scratch": str(scratch)}
219
222
 
220
223
 
221
224
  def pipeline_acceptance(work, acceptance, files, directory):
@@ -269,7 +272,7 @@ def pipeline_acceptance(work, acceptance, files, directory):
269
272
  require(evidence_module["bound_carrier_outcome"](reconstructed, carrier)["verdict"] == "PASS", "required process evidence failed")
270
273
  source = state.get("source") or {}
271
274
  if source.get("spec_path"):
272
- expected = Path(source["spec_path"]).with_suffix(".expected.json")
275
+ expected = Path(source["spec_path"]).with_name("spec.expected.json")
273
276
  original = safe_path(work, str(expected))
274
277
  if original.exists():
275
278
  dest = safe_path(reconstructed, str(expected))
@@ -342,7 +345,8 @@ def bind_acceptance(receipt, path, supplied):
342
345
  require(committed.returncode == 0 and committed.stdout == source_file.read_bytes(), "source contract changed since accepted commit")
343
346
  bound_digest = source.get(key.replace("_path", "_sha256"))
344
347
  require(file_record(source_file)["sha256"] == bound_digest, "source contract differs from run binding")
345
- expected = str(Path(source[key]).with_suffix(".expected.json"))
348
+ source_path = Path(source[key])
349
+ expected = str(source_path.with_name("spec.expected.json") if key == "spec_path" else source_path.with_suffix(".expected.json"))
346
350
  if safe_path(work, expected).exists():
347
351
  paths.append(expected)
348
352
  else:
@@ -412,7 +416,7 @@ def stopped_writers(work, *, linked):
412
416
  # The owner assertion covers its actual children; an OS observation catches
413
417
  # other currently open files/cwds, not future writers or a universal lease.
414
418
  if sys.platform == "darwin":
415
- result = subprocess.run(["lsof", "-Fpn", "+D", str(work)], capture_output=True, text=True)
419
+ result = subprocess.run(["lsof", "-Fpn", "+D", str(work)], capture_output=True, text=True, encoding="utf-8")
416
420
  require(result.returncode in {0, 1} and not result.stderr.strip(), "writer observation unavailable; retain tree and inspect writers")
417
421
  pid = None
418
422
  for line in result.stdout.splitlines():
@@ -437,6 +441,75 @@ def stopped_writers(work, *, linked):
437
441
  raise CompletionError("writer observation unsupported on this platform; retain workspace")
438
442
 
439
443
 
444
+ def scratch_mounts():
445
+ if sys.platform.startswith("linux"):
446
+ rows = Path("/proc/self/mountinfo").read_text(encoding="utf-8").splitlines()
447
+ paths = []
448
+ for line in rows:
449
+ fields = line.split()
450
+ require(len(fields) >= 6, "cannot parse mount table; retain scratch")
451
+ paths.append(re.sub(r"\\([0-7]{3})", lambda m: chr(int(m[1], 8)), fields[4]))
452
+ elif sys.platform == "darwin":
453
+ paths = []
454
+ for line in command(["mount"]).splitlines():
455
+ description, separator, options = line.rpartition(" (")
456
+ candidates = [description[match.end():] for match in re.finditer(" on ", description)]
457
+ require(separator and options.endswith(")") and candidates, "cannot parse mount table; retain scratch")
458
+ # Darwin prints literal paths. Consider every possible separator so
459
+ # ' on ' in a device or directory name can only cause retention.
460
+ paths.extend(candidates)
461
+ else:
462
+ raise CompletionError("mount observation unsupported; retain scratch")
463
+ return [Path(value).resolve() for value in paths]
464
+
465
+
466
+ def raise_walk_error(error):
467
+ raise error
468
+
469
+
470
+ def clean_scratch(receipt, path, writers_stopped):
471
+ """Empty the owned build directory; keep its identity across resume/reuse."""
472
+ scratch = path.parent / "scratch"
473
+ if "scratch_identity" not in receipt:
474
+ return {"status": "NOT_OWNED"}
475
+ require(writers_stopped, "stop/yield scratch writers, then use --writers-stopped")
476
+ require(scratch == scratch.resolve() and scratch.is_dir(), "owned scratch missing or redirected; retain and inspect")
477
+ identity = receipt["scratch_identity"]
478
+ require(workspace_identity(scratch, path.parent) == identity, "scratch directory was replaced; retain")
479
+ require(shutil.rmtree.avoids_symlink_attacks, "safe scratch removal unsupported on this platform; retain")
480
+ require(not any(mount.is_relative_to(scratch) for mount in scratch_mounts()), "scratch contains a mounted filesystem; retain")
481
+ stopped_writers(scratch, linked=True)
482
+ size = 0
483
+ for directory, dirs, files in os.walk(scratch, followlinks=False, onerror=raise_walk_error):
484
+ require(not any(name.casefold() == ".git" for name in dirs + files), "scratch contains Git recovery data; move it to retained custody before cleanup")
485
+ for name in dirs + files:
486
+ item = Path(directory) / name
487
+ info = item.lstat()
488
+ require(info.st_dev == identity[0][0], "scratch contains a mounted filesystem; retain")
489
+ size += info.st_size if stat.S_ISREG(info.st_mode) else 0
490
+ stopped_writers(scratch, linked=True)
491
+ require(workspace_identity(scratch, path.parent) == identity, "scratch changed before removal; retain")
492
+ require(not any(mount.is_relative_to(scratch) for mount in scratch_mounts()), "scratch mount appeared before removal; retain")
493
+ for item in scratch.iterdir():
494
+ if item.is_dir() and not item.is_symlink():
495
+ shutil.rmtree(item)
496
+ else:
497
+ item.unlink()
498
+ require(workspace_identity(scratch, path.parent) == identity and not any(scratch.iterdir()), "scratch changed during cleanup; retain and inspect")
499
+ result = {"status": "CLEAN", "logical_bytes_removed": size}
500
+ receipt["scratch_cleanup"] = result
501
+ atomic_json(path, receipt)
502
+ return result
503
+
504
+
505
+ def clean_scratch_command(args):
506
+ path = Path(args.receipt).absolute()
507
+ with locked_receipt(path) as receipt:
508
+ result = clean_scratch(receipt, path, args.writers_stopped)
509
+ return {"status": "SCRATCH_CLEAN" if result["status"] == "CLEAN" else "SCRATCH_NOT_OWNED",
510
+ "receipt": str(path), "scratch_cleanup": result, "product_verdict_unchanged": True}
511
+
512
+
440
513
  def cleanup(receipt, path, pr, writers_stopped):
441
514
  require(pr["state"] == "MERGED" and pr.get("mergedAt") and pr.get("mergeCommit", {}).get("oid"), "actual matching merge evidence is required")
442
515
  require(writers_stopped, "wait actual children, stop/yield known writers, then resume with --writers-stopped")
@@ -508,8 +581,11 @@ def cleanup(receipt, path, pr, writers_stopped):
508
581
  def locked_receipt(path):
509
582
  require(path.name == "receipt.json" and path.parent.parent.name == "devlyn-completion" and path == path.resolve(), "receipt must be its original external nonsymlink path")
510
583
  require(not (path.parent / "lock").is_symlink(), "receipt lock must not be a symlink")
511
- with (path.parent / "lock").open("a+") as lock:
512
- fcntl.flock(lock, fcntl.LOCK_EX)
584
+ with contextlib.ExitStack() as stack:
585
+ try:
586
+ stack.enter_context(shared("platform-support")["file_lock"](path.parent / "lock", blocking=True))
587
+ except (ImportError, OSError, AttributeError) as exc:
588
+ raise CompletionError(f"receipt lock unavailable: {path}: {exc}") from exc
513
589
  receipt = read_json(path)
514
590
  require(path == Path(receipt["common_gitdir"]) / "devlyn-completion" / receipt["id"] / "receipt.json", "receipt/common Gitdir binding mismatch")
515
591
  require(receipt["id"] == hashlib.sha256(receipt["branch"].encode()).hexdigest()[:24] and receipt["branch"] not in {receipt["base"], "main", "master"}, "receipt branch ownership changed")
@@ -519,11 +595,27 @@ def locked_receipt(path):
519
595
 
520
596
  def complete(args):
521
597
  path = Path(args.receipt).absolute()
522
- resume = shlex.join([sys.executable, str(Path(__file__).resolve()), "complete", "--receipt", str(path)])
598
+ resume = shlex.join([sys.executable, str(Path(__file__).resolve()), "complete", "--receipt", str(path)] +
599
+ (["--writers-stopped"] if args.writers_stopped else []))
523
600
  with locked_receipt(path) as receipt:
524
601
  def result(status):
525
- return {"status": status, "receipt": str(path), "pr": receipt.get("pr_url"), "resume": resume,
526
- "acceptance": (receipt.get("acceptance") or {}).get("kind"), "product_verdict_unchanged": True}
602
+ scratch = {"status": "NOT_OWNED"}
603
+ if "scratch_identity" in receipt:
604
+ try:
605
+ scratch = clean_scratch(receipt, path, args.writers_stopped)
606
+ except (CompletionError, OSError, ValueError, KeyError, TypeError, IndexError) as error:
607
+ scratch = {"status": "RETAINED", "reason": str(error), "resume": shlex.join([
608
+ sys.executable, str(Path(__file__).resolve()), "clean-scratch", "--receipt", str(path), "--writers-stopped"])}
609
+ receipt["scratch_cleanup"] = scratch
610
+ try:
611
+ atomic_json(path, receipt)
612
+ except OSError as record_error:
613
+ scratch["record_error"] = str(record_error)
614
+ return {"status": "CLEANUP_PENDING" if status == "COMPLETE" and scratch["status"] == "RETAINED" else status,
615
+ "delivery_status": status, "receipt": str(path), "pr": receipt.get("pr_url"), "resume": resume,
616
+ "acceptance": (receipt.get("acceptance") or {}).get("kind"), "product_verdict_unchanged": True,
617
+ "scratch_path": str(path.parent / "scratch") if "scratch_identity" in receipt else None,
618
+ "scratch_cleanup": scratch}
527
619
  if args.local_only or receipt.get("local_only"):
528
620
  receipt["local_only"] = True
529
621
  atomic_json(path, receipt)
@@ -607,20 +699,24 @@ def main():
607
699
  completion.add_argument("--mode", choices=("auto", "pr"))
608
700
  completion.add_argument("--local-only", "--no-push", action="store_true")
609
701
  completion.add_argument("--writers-stopped", action="store_true")
702
+ scratch_cleanup = actions.add_parser("clean-scratch")
703
+ scratch_cleanup.add_argument("--receipt", required=True)
704
+ scratch_cleanup.add_argument("--writers-stopped", action="store_true")
610
705
  args = parser.parse_args()
611
706
  if args.self_test:
612
707
  return self_test()
613
708
  if args.action is None:
614
- parser.error("allocate or complete is required")
709
+ parser.error("allocate, complete or clean-scratch is required")
615
710
  try:
616
- result = allocate(args) if args.action == "allocate" else complete(args)
711
+ result = (allocate(args) if args.action == "allocate" else
712
+ clean_scratch_command(args) if args.action == "clean-scratch" else complete(args))
617
713
  print(json.dumps(result, sort_keys=True))
618
714
  return 0
619
- except (CompletionError, OSError, ValueError, KeyError, TypeError, SystemExit) as exc:
715
+ except (CompletionError, OSError, ValueError, KeyError, TypeError, IndexError, SystemExit) as exc:
620
716
  result = {"status": "BLOCKED", "reason": str(exc)}
621
- if args.action == "complete":
717
+ if args.action in {"complete", "clean-scratch"}:
622
718
  result["receipt"] = str(Path(args.receipt).absolute())
623
- result["resume"] = shlex.join([sys.executable, str(Path(__file__).resolve()), "complete", "--receipt", result["receipt"]])
719
+ result["resume"] = shlex.join([sys.executable, str(Path(__file__).resolve()), args.action, "--receipt", result["receipt"], *(["--writers-stopped"] if args.writers_stopped else [])])
624
720
  print(json.dumps(result, sort_keys=True))
625
721
  return 1
626
722
 
@@ -639,10 +735,10 @@ def self_test(names=None):
639
735
  FAKE_GH = r'''#!/usr/bin/env python3
640
736
  import json, os, pathlib, subprocess, sys
641
737
  p = pathlib.Path(os.environ['FIXTURE_GH'])
642
- d = json.loads(p.read_text()); a = sys.argv[1:]
643
- def save(): p.write_text(json.dumps(d))
738
+ d = json.loads(p.read_text(encoding="utf-8")); a = sys.argv[1:]
739
+ def save(): p.write_text(json.dumps(d), encoding="utf-8")
644
740
  def remote(ref):
645
- r = subprocess.run([os.environ['REAL_GIT'], '--git-dir', d['bare'], 'rev-parse', '--verify', ref], capture_output=True, text=True)
741
+ r = subprocess.run([os.environ['REAL_GIT'], '--git-dir', d['bare'], 'rev-parse', '--verify', ref], capture_output=True, text=True, encoding="utf-8")
646
742
  return r.stdout.strip() if r.returncode == 0 else None
647
743
  if a[:2] == ['repo', 'view']:
648
744
  assert a[2:] == ['github.com/test/project', '--json', 'nameWithOwner,url,defaultBranchRef,mergeCommitAllowed'], 'unsupported gh repo view arguments: '+str(a)
@@ -673,8 +769,8 @@ elif a[:2] == ['pr', 'merge']:
673
769
  else:
674
770
  g = [os.environ['REAL_GIT'],'--git-dir',d['bare']]
675
771
  base = remote('refs/heads/main')
676
- tree = subprocess.check_output(g+['rev-parse',sha+'^{tree}'],text=True).strip()
677
- merge = subprocess.check_output(g+['commit-tree',tree,'-p',base,'-p',sha,'-m','merge fixture'],text=True).strip()
772
+ tree = subprocess.check_output(g+['rev-parse',sha+'^{tree}'],text=True, encoding="utf-8").strip()
773
+ merge = subprocess.check_output(g+['commit-tree',tree,'-p',base,'-p',sha,'-m','merge fixture'],text=True, encoding="utf-8").strip()
678
774
  subprocess.check_call(g+['update-ref','refs/heads/main',merge,base])
679
775
  d['pr'].update(state='MERGED',mergedAt='now',mergeCommit={'oid':merge})
680
776
  save()
@@ -686,22 +782,22 @@ else:
686
782
  GIT_WRAPPER = r'''#!/usr/bin/env python3
687
783
  import json, os, pathlib, subprocess, sys
688
784
  a = sys.argv[1:]
689
- p = pathlib.Path(os.environ['FIXTURE_GH']); d = json.loads(p.read_text())
785
+ p = pathlib.Path(os.environ['FIXTURE_GH']); d = json.loads(p.read_text(encoding="utf-8"))
690
786
  if 'push' in a and any(x.startswith('--force-with-lease=') for x in a) and d.pop('remote_delete_race',False):
691
787
  subprocess.check_call([os.environ['REAL_GIT'],'--git-dir',d['bare'],'update-ref','refs/heads/'+d['pr']['headRefName'],d['race_sha']])
692
- p.write_text(json.dumps(d))
788
+ p.write_text(json.dumps(d), encoding="utf-8")
693
789
  for operation in ('push', 'fetch', 'ls-remote'):
694
790
  if operation in a and 'origin' in a:
695
791
  prefix = a[:a.index(operation)]
696
- url = subprocess.check_output([os.environ['REAL_GIT'], *prefix, 'remote', 'get-url', *(['--push'] if operation == 'push' else []), '--all', 'origin'], text=True).strip()
792
+ url = subprocess.check_output([os.environ['REAL_GIT'], *prefix, 'remote', 'get-url', *(['--push'] if operation == 'push' else []), '--all', 'origin'], text=True, encoding="utf-8").strip()
697
793
  if url in {'https://github.com/test/project.git', 'git@github.com:test/project.git', 'ssh://git@github.com/test/project.git'}:
698
794
  a[a.index('origin')] = d['bare']
699
795
  break
700
796
  r = subprocess.run([os.environ['REAL_GIT'],*a])
701
797
  event = 'delete' if 'push' in a and any(x.startswith(':refs/') for x in a) else 'push' if 'push' in a else 'remove' if 'worktree' in a and 'remove' in a else None
702
798
  if r.returncode == 0 and event:
703
- d = json.loads(p.read_text()); d[event+'s'] = d.get(event+'s',0)+1
704
- fail = d.pop('interrupt_'+event,False); p.write_text(json.dumps(d))
799
+ d = json.loads(p.read_text(encoding="utf-8")); d[event+'s'] = d.get(event+'s',0)+1
800
+ fail = d.pop('interrupt_'+event,False); p.write_text(json.dumps(d), encoding="utf-8")
705
801
  if fail: sys.exit(1)
706
802
  sys.exit(r.returncode)
707
803
  '''
@@ -717,7 +813,7 @@ class CompletionTests(unittest.TestCase):
717
813
  self.bin = self.root / "bin"
718
814
  self.bin.mkdir()
719
815
  self.data = self.root / "gh.json"
720
- self.data.write_text(json.dumps({"bare": str(self.bare)}))
816
+ self.data.write_text(json.dumps({"bare": str(self.bare)}), encoding="utf-8")
721
817
  self.env = {**os.environ, "GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_GLOBAL": str(self.root / "gitconfig"),
722
818
  "GIT_AUTHOR_NAME": "Fixture", "GIT_AUTHOR_EMAIL": "fixture@example.invalid",
723
819
  "GIT_COMMITTER_NAME": "Fixture", "GIT_COMMITTER_EMAIL": "fixture@example.invalid",
@@ -728,13 +824,13 @@ class CompletionTests(unittest.TestCase):
728
824
  del self.env[key]
729
825
  for name, body in (("gh", FAKE_GH), ("git", GIT_WRAPPER)):
730
826
  path = self.bin / name
731
- path.write_text(body)
827
+ path.write_text(body, encoding="utf-8")
732
828
  path.chmod(0o755)
733
829
  self.env["PATH"] = str(self.bin) + os.pathsep + os.environ["PATH"]
734
830
  self.run_cmd(["git", "init", "--bare", "--initial-branch=main", str(self.bare)])
735
831
  self.run_cmd(["git", "init", "--initial-branch=main", str(self.work)])
736
- (self.work / ".gitignore").write_text(".devlyn/\nignored/\n")
737
- (self.work / "product").write_text("baseline\n")
832
+ (self.work / ".gitignore").write_text(".devlyn/\nignored/\n", encoding="utf-8")
833
+ (self.work / "product").write_text("baseline\n", encoding="utf-8")
738
834
  self.g("add", ".")
739
835
  self.g("commit", "-m", "baseline")
740
836
  self.g("remote", "add", "origin", "https://github.com/test/project.git")
@@ -742,7 +838,7 @@ class CompletionTests(unittest.TestCase):
742
838
  self.configure(pushs=0)
743
839
 
744
840
  def run_cmd(self, args, *, cwd=None, success=True):
745
- r = subprocess.run(args, cwd=cwd or self.root, env=self.env, capture_output=True, text=True)
841
+ r = subprocess.run(args, cwd=cwd or self.root, env=self.env, capture_output=True, text=True, encoding="utf-8")
746
842
  if success:
747
843
  self.assertEqual(r.returncode, 0, r.stderr + r.stdout)
748
844
  return r
@@ -751,8 +847,8 @@ class CompletionTests(unittest.TestCase):
751
847
  return self.run_cmd(["git", "-C", str(work or self.work), *args]).stdout.strip()
752
848
 
753
849
  def configure(self, **values):
754
- d = json.loads(self.data.read_text()); d.update(values)
755
- self.data.write_text(json.dumps(d))
850
+ d = json.loads(self.data.read_text(encoding="utf-8")); d.update(values)
851
+ self.data.write_text(json.dumps(d), encoding="utf-8")
756
852
 
757
853
  def cli(self, *args, success=True, cwd=None):
758
854
  r = self.run_cmd([sys.executable, str(Path(__file__).resolve()), *map(str, args)], success=success, cwd=cwd)
@@ -767,49 +863,176 @@ class CompletionTests(unittest.TestCase):
767
863
  self.task = Path(result["worktree"])
768
864
  return result
769
865
 
770
- def accept(self, pipeline=False, queue=False, spec_expected=None):
866
+ @unittest.skipUnless(sys.platform == "darwin" or sys.platform.startswith("linux"), "writer observation requires POSIX")
867
+ def test_disposable_scratch_cleans_for_local_only_and_preserves_source(self):
868
+ result = self.allocate()
869
+ scratch = Path(result["scratch"])
870
+ (scratch / "target").mkdir()
871
+ (scratch / "target" / "object").write_bytes(b"rebuildable")
872
+ (self.task / "uncommitted-work").write_text("preserve")
873
+ result, _ = self.cli("complete", "--receipt", self.receipt, "--local-only", "--writers-stopped")
874
+ self.assertEqual(result["status"], "LOCAL_ONLY")
875
+ self.assertFalse(any(scratch.iterdir()))
876
+ self.assertEqual((self.task / "uncommitted-work").read_text(), "preserve")
877
+ self.assertEqual(result["scratch_cleanup"]["logical_bytes_removed"], 11)
878
+ result, _ = self.cli("clean-scratch", "--receipt", self.receipt, "--writers-stopped")
879
+ self.assertEqual(result["status"], "SCRATCH_CLEAN")
880
+
881
+ @unittest.skipUnless(sys.platform == "darwin" or sys.platform.startswith("linux"), "writer observation requires POSIX")
882
+ def test_scratch_rejects_active_writer_and_requires_owner_assertion(self):
883
+ scratch = Path(self.allocate()["scratch"])
884
+ result, _ = self.cli("clean-scratch", "--receipt", self.receipt, success=False)
885
+ self.assertIn("--writers-stopped", result["reason"])
886
+ child = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"], cwd=scratch)
887
+ try:
888
+ result, _ = self.cli("clean-scratch", "--receipt", self.receipt, "--writers-stopped", success=False)
889
+ self.assertIn("active process", result["reason"])
890
+ self.assertTrue(scratch.is_dir())
891
+ finally:
892
+ child.terminate()
893
+ child.wait(timeout=5)
894
+ self.cli("clean-scratch", "--receipt", self.receipt, "--writers-stopped")
895
+
896
+ @unittest.skipUnless(sys.platform == "darwin" or sys.platform.startswith("linux"), "writer observation requires POSIX")
897
+ def test_scratch_rejects_redirect_and_git_data_but_does_not_follow_child_links(self):
898
+ scratch = Path(self.allocate()["scratch"])
899
+ outside = self.root / "outside"
900
+ outside.mkdir()
901
+ (outside / "keep").write_text("source")
902
+ retained = scratch.with_name("retained")
903
+ scratch.rename(retained)
904
+ scratch.symlink_to(outside, target_is_directory=True)
905
+ result, _ = self.cli("clean-scratch", "--receipt", self.receipt, "--writers-stopped", success=False)
906
+ self.assertIn("redirected", result["reason"])
907
+ scratch.unlink()
908
+ retained.rename(scratch)
909
+ (scratch / ".GiT").write_text("gitdir: elsewhere")
910
+ result, _ = self.cli("clean-scratch", "--receipt", self.receipt, "--writers-stopped", success=False)
911
+ self.assertIn("Git recovery data", result["reason"])
912
+ (scratch / ".GiT").unlink()
913
+ (scratch / "external-link").symlink_to(outside, target_is_directory=True)
914
+ self.cli("clean-scratch", "--receipt", self.receipt, "--writers-stopped")
915
+ self.assertEqual((outside / "keep").read_text(), "source")
916
+
917
+ @unittest.skipUnless(sys.platform == "darwin" or sys.platform.startswith("linux"), "writer observation requires POSIX")
918
+ def test_scratch_reuse_and_partial_cleanup_keep_original_identity(self):
919
+ scratch = Path(self.allocate()["scratch"])
920
+ (scratch / "first-build").write_bytes(b"first")
921
+ self.cli("clean-scratch", "--receipt", self.receipt, "--writers-stopped")
922
+ (scratch / "resumed-build").write_bytes(b"next")
923
+ result, _ = self.cli("clean-scratch", "--receipt", self.receipt, "--writers-stopped")
924
+ self.assertEqual(result["scratch_cleanup"]["logical_bytes_removed"], 4)
925
+ self.assertFalse(any(scratch.iterdir()))
926
+ scratch.rename(scratch.with_name("original"))
927
+ scratch.mkdir()
928
+ (scratch / "foreign").write_text("preserve")
929
+ result, _ = self.cli("clean-scratch", "--receipt", self.receipt, "--writers-stopped", success=False)
930
+ self.assertIn("replaced", result["reason"])
931
+ self.assertIn("--writers-stopped", result["resume"])
932
+ self.assertEqual((scratch / "foreign").read_text(), "preserve")
933
+
934
+ @unittest.skipUnless(sys.platform == "darwin" or sys.platform.startswith("linux"), "writer observation requires POSIX")
935
+ def test_scratch_refusal_does_not_block_local_only_delivery(self):
936
+ scratch = Path(self.allocate()["scratch"])
937
+ (scratch / ".git").mkdir()
938
+ result, _ = self.cli("complete", "--receipt", self.receipt, "--local-only", "--writers-stopped")
939
+ self.assertEqual(result["status"], "LOCAL_ONLY")
940
+ self.assertEqual(result["scratch_cleanup"]["status"], "RETAINED")
941
+ self.assertIn("Git recovery data", result["scratch_cleanup"]["reason"])
942
+ self.assertEqual(json.loads(self.receipt.read_text())["scratch_cleanup"]["status"], "RETAINED")
943
+ self.assertTrue((scratch / ".git").is_dir())
944
+
945
+ @unittest.skipUnless(sys.platform == "darwin" or sys.platform.startswith("linux"), "writer observation requires POSIX")
946
+ def test_scratch_refusal_reports_delivery_separately(self):
947
+ scratch = Path(self.allocate()["scratch"])
948
+ self.accept()
949
+ (scratch / ".git").mkdir()
950
+ result, _ = self.cli("complete", "--receipt", self.receipt, "--acceptance", self.acceptance, "--writers-stopped")
951
+ self.assertEqual(result["status"], "CLEANUP_PENDING")
952
+ self.assertEqual(result["delivery_status"], "COMPLETE")
953
+ self.assertEqual(result["scratch_cleanup"]["status"], "RETAINED")
954
+ self.assertIn("--writers-stopped", result["scratch_cleanup"]["resume"])
955
+ self.assertIn("--writers-stopped", result["resume"])
956
+ (scratch / ".git").rmdir()
957
+ result, _ = self.cli("complete", "--receipt", self.receipt, "--writers-stopped")
958
+ self.assertEqual(result["status"], "COMPLETE")
959
+
960
+ @unittest.skipUnless(sys.platform == "darwin" or sys.platform.startswith("linux"), "writer observation requires POSIX")
961
+ def test_scratch_mount_and_scan_failure_preserve_contents(self):
962
+ from unittest.mock import patch
963
+ scratch = Path(self.allocate()["scratch"])
964
+ (scratch / "keep").write_text("retained")
965
+ receipt = json.loads(self.receipt.read_text())
966
+ with patch.dict(clean_scratch.__globals__, {"scratch_mounts": lambda: [scratch / "same-device-bind"]}):
967
+ with self.assertRaisesRegex(CompletionError, "mounted filesystem"):
968
+ clean_scratch(receipt, self.receipt, True)
969
+ def failed_walk(*args, **kwargs):
970
+ kwargs["onerror"](PermissionError("unreadable subtree"))
971
+ with patch.object(os, "walk", failed_walk):
972
+ with self.assertRaisesRegex(PermissionError, "unreadable subtree"):
973
+ clean_scratch(receipt, self.receipt, True)
974
+ self.assertEqual((scratch / "keep").read_text(), "retained")
975
+
976
+ def test_scratch_mount_table_parsing_preserves_ambiguous_literal_paths(self):
977
+ from unittest.mock import patch
978
+ target = "/task/scratch/name on inside\\040literal"
979
+ with patch.object(sys, "platform", "darwin"), patch.dict(scratch_mounts.__globals__, {
980
+ "command": lambda args: "device on source on " + target + " (local, journaled)",
981
+ }):
982
+ self.assertIn(Path(target).resolve(), scratch_mounts())
983
+ alias = self.root / "scratch-alias"
984
+ alias.symlink_to(self.root, target_is_directory=True)
985
+ with patch.object(sys, "platform", "darwin"), patch.dict(scratch_mounts.__globals__, {
986
+ "command": lambda args: "device on " + str(alias / "mounted") + " (local)",
987
+ }):
988
+ self.assertEqual(scratch_mounts(), [(self.root / "mounted").resolve()])
989
+ with patch.object(sys, "platform", "linux"), patch.object(Path, "read_text", return_value="truncated row"):
990
+ with self.assertRaisesRegex(CompletionError, "cannot parse mount table"):
991
+ scratch_mounts()
992
+
993
+ def accept(self, pipeline=False, queue=False, spec_expected=None, spec_name="spec.md"):
771
994
  if spec_expected is not None:
772
- (self.task / "spec.md").write_text("# Fixture\nProduct contains accepted bytes.\n")
773
- (self.task / "spec.expected.json").write_text(json.dumps(spec_expected))
774
- self.g("add", "spec.md", "spec.expected.json", work=self.task)
775
- (self.task / "product").write_text("accepted\n")
995
+ (self.task / spec_name).write_text("# Fixture\nProduct contains accepted bytes.\n", encoding="utf-8")
996
+ (self.task / "spec.expected.json").write_text(json.dumps(spec_expected), encoding="utf-8")
997
+ self.g("add", spec_name, "spec.expected.json", work=self.task)
998
+ (self.task / "product").write_text("accepted\n", encoding="utf-8")
776
999
  self.g("add", "product", work=self.task)
777
1000
  self.g("commit", "-m", "scoped task", work=self.task)
778
1001
  self.sha = self.g("rev-parse", "HEAD", work=self.task)
779
1002
  evidence = self.task / ".devlyn"
780
1003
  evidence.mkdir(exist_ok=True)
781
- (evidence / "checks.txt").write_text("actual fixture check: accepted bytes\n")
1004
+ (evidence / "checks.txt").write_text("actual fixture check: accepted bytes\n", encoding="utf-8")
782
1005
  a = {"kind": "direct", "task": "fixture", "source_sha": self.sha,
783
1006
  "checks": [{"command": "fixture byte assertion", "evidence": ".devlyn/checks.txt"}]}
784
- self.assertEqual((self.task / "product").read_text(), "accepted\n")
1007
+ self.assertEqual((self.task / "product").read_text(encoding="utf-8"), "accepted\n")
785
1008
  if pipeline:
786
1009
  a = {"kind": "pipeline", "task": "fixture", "source_sha": self.sha, "run_id": "fixture-run"}
787
1010
  archive = evidence / "runs" / a["run_id"]
788
1011
  archive.mkdir(parents=True)
789
1012
  self.archive = archive
790
1013
  report = "<!-- devlyn:final-report run_id=fixture-run -->\nFixture completed.\n"
791
- (archive / "final-report.md").write_text(report)
1014
+ (archive / "final-report.md").write_text(report, encoding="utf-8")
792
1015
  phases = {n: {"started_at": "2026-09-10T00:00:00Z", "completed_at": "2026-09-10T00:00:01Z", "verdict": "PASS"}
793
1016
  for n in ("plan", "implement", "build_gate", "cleanup", "verify", "final_report")}
794
1017
  phases["cleanup"]["post_sha"] = self.sha
795
1018
  phases["final_report"].update(output_sha256=hashlib.sha256(report.encode()).hexdigest(), artifacts={"log_file": ".devlyn/final-report.md"})
796
1019
  criteria = "# Fixture acceptance\nProduct contains accepted bytes.\n"
797
- (archive / "criteria.generated.md").write_text(criteria)
1020
+ (archive / "criteria.generated.md").write_text(criteria, encoding="utf-8")
798
1021
  source = {"type": "generated", "criteria_path": ".devlyn/criteria.generated.md", "criteria_sha256": hashlib.sha256(criteria.encode()).hexdigest()}
799
1022
  self.state = {"run_id": a["run_id"], "mode": "free-form", "source": source, "phases": phases, "process_evidence": None}
800
1023
  if spec_expected is not None:
801
1024
  self.state["mode"] = "spec"
802
- self.state["source"] = {"type": "spec", "spec_path": "spec.md", "spec_sha256": hashlib.sha256((self.task / "spec.md").read_bytes()).hexdigest()}
803
- (archive / "pipeline.state.json").write_text(json.dumps(self.state))
804
- (archive / "finish-gate.summary.json").write_text(json.dumps({"mode": self.state["mode"], "exit": 0, "offenders": 0, "checked": 1}))
805
- (archive / "verify-merge.summary.json").write_text(json.dumps({"verdict": "PASS"}))
1025
+ self.state["source"] = {"type": "spec", "spec_path": spec_name, "spec_sha256": hashlib.sha256((self.task / spec_name).read_bytes()).hexdigest()}
1026
+ (archive / "pipeline.state.json").write_text(json.dumps(self.state), encoding="utf-8")
1027
+ (archive / "finish-gate.summary.json").write_text(json.dumps({"mode": self.state["mode"], "exit": 0, "offenders": 0, "checked": 1}), encoding="utf-8")
1028
+ (archive / "verify-merge.summary.json").write_text(json.dumps({"verdict": "PASS"}), encoding="utf-8")
806
1029
  if queue:
807
- (self.task / "queue.md").write_text("- [x] fixture\n")
1030
+ (self.task / "queue.md").write_text("- [x] fixture\n", encoding="utf-8")
808
1031
  self.g("add", "queue.md", work=self.task)
809
1032
  self.g("commit", "-m", "terminal queue", work=self.task)
810
1033
  a["queue"] = {"commit": self.g("rev-parse", "HEAD", work=self.task), "file": "queue.md"}
811
1034
  self.acceptance = evidence / "acceptance.json"
812
- self.acceptance.write_text(json.dumps(a))
1035
+ self.acceptance.write_text(json.dumps(a), encoding="utf-8")
813
1036
 
814
1037
  def complete(self, *args, success=True, cwd=None, acceptance=True):
815
1038
  command_args = ["complete", "--receipt", self.receipt]
@@ -843,11 +1066,11 @@ class CompletionTests(unittest.TestCase):
843
1066
  alias = "git@github.com:test/project.git"
844
1067
  self.g("config", "url." + alias + ".insteadOf", intended)
845
1068
  self.allocate(); self.accept()
846
- self.assertEqual(json.loads(self.receipt.read_text())["remote_url"], {"literal": intended, "fetch": alias, "push": alias})
1069
+ self.assertEqual(json.loads(self.receipt.read_text(encoding="utf-8"))["remote_url"], {"literal": intended, "fetch": alias, "push": alias})
847
1070
  result, _ = self.complete("--mode", "pr")
848
1071
  self.assertEqual(result["status"], "PR")
849
1072
  self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture").split()[0], self.sha)
850
- self.assertEqual(json.loads(self.data.read_text())["pushs"], 1)
1073
+ self.assertEqual(json.loads(self.data.read_text(encoding="utf-8"))["pushs"], 1)
851
1074
 
852
1075
  def test_remote_rewrite_after_allocation_is_rejected(self):
853
1076
  self.allocate(); self.accept()
@@ -868,9 +1091,9 @@ class CompletionTests(unittest.TestCase):
868
1091
  destination = "https://github.com/test/other.git"
869
1092
  self.g("config", "url." + destination + ".insteadOf", "https://github.com/test/project.git")
870
1093
  # Reproduce a receipt the old literal-only allocation check accepted.
871
- receipt = json.loads(self.receipt.read_text())
1094
+ receipt = json.loads(self.receipt.read_text(encoding="utf-8"))
872
1095
  receipt["remote_url"].update(fetch=destination, push=destination)
873
- self.receipt.write_text(json.dumps(receipt))
1096
+ self.receipt.write_text(json.dumps(receipt), encoding="utf-8")
874
1097
  local_refs = self.g("show-ref")
875
1098
  remote_refs = self.run_cmd(["git", "--git-dir", str(self.bare), "show-ref"]).stdout
876
1099
  server = self.data.read_bytes()
@@ -891,17 +1114,17 @@ class CompletionTests(unittest.TestCase):
891
1114
  for _ in range(2):
892
1115
  result, _ = self.complete("--mode", "auto")
893
1116
  self.assertEqual(result["status"], "PENDING")
894
- d = json.loads(self.data.read_text())
1117
+ d = json.loads(self.data.read_text(encoding="utf-8"))
895
1118
  self.assertEqual((d["creates"], d["merges"], d["pushs"]), (1, 1, 1))
896
1119
 
897
1120
  def test_in_place_retains_ignored_data(self):
898
1121
  self.allocate(); self.accept()
899
1122
  (self.task / "ignored").mkdir()
900
- (self.task / "ignored" / "user.txt").write_text("retain me")
1123
+ (self.task / "ignored" / "user.txt").write_text("retain me", encoding="utf-8")
901
1124
  result, _ = self.complete("--writers-stopped")
902
1125
  self.assertEqual(result["status"], "COMPLETE")
903
1126
  self.assertEqual(self.g("branch", "--show-current"), "main")
904
- self.assertEqual((self.task / "ignored/user.txt").read_text(), "retain me")
1127
+ self.assertEqual((self.task / "ignored/user.txt").read_text(encoding="utf-8"), "retain me")
905
1128
  self.assertEqual(self.g("branch", "--list", "task/fixture"), "")
906
1129
  self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture"), "")
907
1130
  again, _ = self.complete("--writers-stopped", acceptance=False)
@@ -915,7 +1138,7 @@ class CompletionTests(unittest.TestCase):
915
1138
  result, r = self.complete("--writers-stopped", success=False)
916
1139
  self.assertNotEqual(r.returncode, 0)
917
1140
  self.assertFalse(self.task.exists())
918
- saved = json.loads(self.receipt.read_text())
1141
+ saved = json.loads(self.receipt.read_text(encoding="utf-8"))
919
1142
  recovered = self.receipt.parent / "custody" / ".devlyn/runs/fixture-run/final-report.md"
920
1143
  self.assertEqual(hashlib.sha256(recovered.read_bytes()).hexdigest(), saved["files"][".devlyn/runs/fixture-run/final-report.md"]["sha256"])
921
1144
  result, _ = self.complete("--writers-stopped", acceptance=False)
@@ -933,30 +1156,30 @@ class CompletionTests(unittest.TestCase):
933
1156
  if case == "unfinished": state["phases"]["verify"]["completed_at"] = None
934
1157
  if case == "digest": state["phases"]["final_report"]["output_sha256"] = "0"*64
935
1158
  if case == "source": state["phases"]["cleanup"]["post_sha"] = self.g("rev-parse", "main")
936
- (self.archive / "pipeline.state.json").write_text(json.dumps(state))
1159
+ (self.archive / "pipeline.state.json").write_text(json.dumps(state), encoding="utf-8")
937
1160
  with self.subTest(case=case):
938
1161
  _, r = self.complete(success=False)
939
1162
  self.assertNotEqual(r.returncode, 0)
940
1163
  self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture"), "")
941
- (self.archive / "pipeline.state.json").write_text(json.dumps(original))
1164
+ (self.archive / "pipeline.state.json").write_text(json.dumps(original), encoding="utf-8")
942
1165
  for value in ("typo", "", " auto"):
943
1166
  self.g("config", "--local", "devlyn.completionMode", value)
944
1167
  _, r = self.complete("--mode", "pr", success=False)
945
1168
  self.assertNotEqual(r.returncode, 0)
946
1169
  result, _ = self.complete("--local-only")
947
1170
  self.assertEqual(result["status"], "LOCAL_ONLY")
948
- self.assertEqual(json.loads(self.data.read_text()).get("creates", 0), 0)
1171
+ self.assertEqual(json.loads(self.data.read_text(encoding="utf-8")).get("creates", 0), 0)
949
1172
 
950
1173
  def test_queue_only_and_unverified_descendant(self):
951
1174
  self.allocate(); self.accept(pipeline=True, queue=True)
952
1175
  result, _ = self.complete("--mode", "pr")
953
1176
  self.assertEqual(result["status"], "PR")
954
- (self.task / "product").write_text("unverified")
1177
+ (self.task / "product").write_text("unverified", encoding="utf-8")
955
1178
  self.g("add", "product", work=self.task)
956
1179
  self.g("commit", "-m", "unverified", work=self.task)
957
1180
  _, r = self.complete(success=False)
958
1181
  self.assertNotEqual(r.returncode, 0)
959
- self.assertEqual(json.loads(self.data.read_text()).get("merges", 0), 0)
1182
+ self.assertEqual(json.loads(self.data.read_text(encoding="utf-8")).get("merges", 0), 0)
960
1183
 
961
1184
  def test_interrupted_external_effects(self):
962
1185
  self.allocate(); self.accept()
@@ -966,7 +1189,7 @@ class CompletionTests(unittest.TestCase):
966
1189
  self.assertNotEqual(r.returncode, 0)
967
1190
  result, _ = self.complete("--writers-stopped")
968
1191
  self.assertEqual(result["status"], "COMPLETE")
969
- d = json.loads(self.data.read_text())
1192
+ d = json.loads(self.data.read_text(encoding="utf-8"))
970
1193
  self.assertEqual((d["pushs"], d["creates"], d["merges"]), (1, 1, 1))
971
1194
 
972
1195
  def test_retains_unknown_dirty_locked_and_active_tree(self):
@@ -974,7 +1197,7 @@ class CompletionTests(unittest.TestCase):
974
1197
  for case in ("ignored", "dirty", "untracked", "locked", "cwd", "no-yield"):
975
1198
  path = self.task / ("ignored/unknown" if case == "ignored" else "product" if case == "dirty" else "unknown")
976
1199
  if case in {"ignored", "dirty", "untracked"}:
977
- path.parent.mkdir(exist_ok=True); path.write_text("do not delete")
1200
+ path.parent.mkdir(exist_ok=True); path.write_text("do not delete", encoding="utf-8")
978
1201
  if case == "locked": self.g("worktree", "lock", str(self.task))
979
1202
  with self.subTest(case=case):
980
1203
  _, r = self.complete(*([] if case == "no-yield" else ["--writers-stopped"]), cwd=self.task if case == "cwd" else None, success=False)
@@ -982,7 +1205,7 @@ class CompletionTests(unittest.TestCase):
982
1205
  self.assertTrue(self.task.exists())
983
1206
  self.assertNotEqual(self.g("branch", "--list", "task/fixture"), "")
984
1207
  if case in {"ignored", "untracked"}: path.unlink()
985
- if case == "dirty": path.write_text("accepted\n")
1208
+ if case == "dirty": path.write_text("accepted\n", encoding="utf-8")
986
1209
  if case == "locked": self.g("worktree", "unlock", str(self.task))
987
1210
 
988
1211
  def test_no_adoption_and_remote_head_race(self):
@@ -993,34 +1216,34 @@ class CompletionTests(unittest.TestCase):
993
1216
  self.run_cmd(["git", "--git-dir", str(self.bare), "update-ref", "refs/heads/task/fixture", self.g("rev-parse", "main")])
994
1217
  _, r = self.complete(success=False)
995
1218
  self.assertNotEqual(r.returncode, 0)
996
- self.assertEqual(json.loads(self.data.read_text()).get("merges",0), 0)
1219
+ self.assertEqual(json.loads(self.data.read_text(encoding="utf-8")).get("merges",0), 0)
997
1220
 
998
1221
  def test_concurrent_completions(self):
999
1222
  self.allocate(); self.accept(); self.configure(pending=True)
1000
1223
  args = [sys.executable, str(Path(__file__).resolve()), "complete", "--receipt", str(self.receipt), "--acceptance", str(self.acceptance)]
1001
- procs = [subprocess.Popen(args, cwd=self.root, env=self.env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) for _ in range(2)]
1224
+ procs = [subprocess.Popen(args, cwd=self.root, env=self.env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8") for _ in range(2)]
1002
1225
  for proc in procs:
1003
1226
  out, err = proc.communicate(timeout=30)
1004
1227
  self.assertEqual(proc.returncode, 0, out+err)
1005
- d = json.loads(self.data.read_text())
1228
+ d = json.loads(self.data.read_text(encoding="utf-8"))
1006
1229
  self.assertEqual((d["pushs"], d["creates"], d["merges"]), (1,1,1))
1007
1230
 
1008
1231
  def test_custody_failure_and_evidence_tamper(self):
1009
1232
  self.allocate(linked=True); self.accept()
1010
1233
  bad = self.receipt.parent / "custody/.devlyn/checks.txt"
1011
1234
  bad.parent.mkdir(parents=True)
1012
- bad.write_text("partial interrupted copy")
1235
+ bad.write_text("partial interrupted copy", encoding="utf-8")
1013
1236
  _, r = self.complete("--writers-stopped", success=False)
1014
1237
  self.assertNotEqual(r.returncode, 0)
1015
1238
  self.assertTrue(self.task.exists())
1016
- self.assertEqual((self.task / ".devlyn/checks.txt").read_text(), "actual fixture check: accepted bytes\n")
1239
+ self.assertEqual((self.task / ".devlyn/checks.txt").read_text(encoding="utf-8"), "actual fixture check: accepted bytes\n")
1017
1240
  self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture"), "")
1018
1241
  bad.unlink() # Owner repairs only the identified corrupt fixture copy.
1019
1242
  self.complete("--mode", "pr")
1020
- (self.task / ".devlyn/checks.txt").write_text("altered after acceptance")
1243
+ (self.task / ".devlyn/checks.txt").write_text("altered after acceptance", encoding="utf-8")
1021
1244
  _, r = self.complete("--mode", "auto", success=False)
1022
1245
  self.assertNotEqual(r.returncode, 0)
1023
- self.assertEqual(json.loads(self.data.read_text()).get("merges",0), 0)
1246
+ self.assertEqual(json.loads(self.data.read_text(encoding="utf-8")).get("merges",0), 0)
1024
1247
 
1025
1248
  def test_actual_foreign_writer_and_registration(self):
1026
1249
  self.allocate(linked=True); self.accept()
@@ -1053,7 +1276,7 @@ class CompletionTests(unittest.TestCase):
1053
1276
  self.configure(merge_allowed=False)
1054
1277
  _, r = self.complete(success=False)
1055
1278
  self.assertNotEqual(r.returncode, 0)
1056
- self.assertEqual(json.loads(self.data.read_text()).get("pushs",0), 0)
1279
+ self.assertEqual(json.loads(self.data.read_text(encoding="utf-8")).get("pushs",0), 0)
1057
1280
  self.complete("--mode", "pr")
1058
1281
  result, _ = self.complete()
1059
1282
  self.assertEqual(result["status"], "PR")
@@ -1068,15 +1291,15 @@ class CompletionTests(unittest.TestCase):
1068
1291
 
1069
1292
  def test_queue_commit_cannot_hide_product_changes(self):
1070
1293
  self.allocate(); self.accept(pipeline=True, queue=True)
1071
- (self.task / "product").write_text("unverified extra\n")
1294
+ (self.task / "product").write_text("unverified extra\n", encoding="utf-8")
1072
1295
  self.g("add", "product", work=self.task)
1073
1296
  self.g("commit", "--amend", "--no-edit", work=self.task)
1074
- a = json.loads(self.acceptance.read_text())
1297
+ a = json.loads(self.acceptance.read_text(encoding="utf-8"))
1075
1298
  a["queue"]["commit"] = self.g("rev-parse", "HEAD", work=self.task)
1076
- self.acceptance.write_text(json.dumps(a))
1299
+ self.acceptance.write_text(json.dumps(a), encoding="utf-8")
1077
1300
  _, r = self.complete(success=False)
1078
1301
  self.assertNotEqual(r.returncode, 0)
1079
- self.assertEqual(json.loads(self.data.read_text()).get("pushs",0), 0)
1302
+ self.assertEqual(json.loads(self.data.read_text(encoding="utf-8")).get("pushs",0), 0)
1080
1303
 
1081
1304
  def test_pipeline_sealed_process_evidence(self):
1082
1305
  self.allocate(); self.accept(pipeline=True)
@@ -1088,15 +1311,15 @@ class CompletionTests(unittest.TestCase):
1088
1311
  results = {"commands": evidence_module["bound_carrier_summary_commands"](self.task, carrier), "process_evidence": carrier}
1089
1312
  self.state["phases"]["verify"]["round"] = 0
1090
1313
  self.state["process_evidence"] = [carrier]
1091
- (self.archive / "pipeline.state.json").write_text(json.dumps(self.state))
1092
- (self.archive / "spec-verify.results.json").write_text(json.dumps(results))
1314
+ (self.archive / "pipeline.state.json").write_text(json.dumps(self.state), encoding="utf-8")
1315
+ (self.archive / "spec-verify.results.json").write_text(json.dumps(results), encoding="utf-8")
1093
1316
  shutil.move(str(self.task / ".devlyn/process-evidence"), str(self.archive / "process-evidence"))
1094
1317
  stdout = self.archive / carrier["streams"][0]["stdout"]["path"].removeprefix(".devlyn/")
1095
1318
  before = stdout.read_bytes()
1096
- stdout.write_text("tampered")
1319
+ stdout.write_text("tampered", encoding="utf-8")
1097
1320
  _, r = self.complete(success=False)
1098
1321
  self.assertNotEqual(r.returncode, 0)
1099
- self.assertEqual(json.loads(self.data.read_text()).get("pushs",0), 0)
1322
+ self.assertEqual(json.loads(self.data.read_text(encoding="utf-8")).get("pushs",0), 0)
1100
1323
  stdout.write_bytes(before)
1101
1324
  result, _ = self.complete("--mode", "pr")
1102
1325
  self.assertEqual(result["status"], "PR")
@@ -1105,7 +1328,7 @@ class CompletionTests(unittest.TestCase):
1105
1328
  self.allocate(); self.accept()
1106
1329
  user_file = self.task / "ignored/user.txt"
1107
1330
  user_file.parent.mkdir()
1108
- user_file.write_text("retained user data")
1331
+ user_file.write_text("retained user data", encoding="utf-8")
1109
1332
  self.configure(interrupt_delete=True)
1110
1333
  _, r = self.complete("--writers-stopped", success=False)
1111
1334
  self.assertNotEqual(r.returncode, 0)
@@ -1113,13 +1336,13 @@ class CompletionTests(unittest.TestCase):
1113
1336
  other = self.root / "advance"
1114
1337
  self.run_cmd(["git", "clone", str(self.bare), str(other)])
1115
1338
  (other / "ignored").mkdir()
1116
- (other / "ignored/user.txt").write_text("new upstream tracked file")
1339
+ (other / "ignored/user.txt").write_text("new upstream tracked file", encoding="utf-8")
1117
1340
  self.g("add", "-f", "ignored/user.txt", work=other)
1118
1341
  self.g("commit", "-m", "upstream collision", work=other)
1119
1342
  self.g("push", "origin", "main", work=other)
1120
1343
  _, r = self.complete("--writers-stopped", acceptance=False, success=False)
1121
1344
  self.assertNotEqual(r.returncode, 0)
1122
- self.assertEqual(user_file.read_text(), "retained user data")
1345
+ self.assertEqual(user_file.read_text(encoding="utf-8"), "retained user data")
1123
1346
  self.assertNotEqual(self.g("branch", "--list", "task/fixture"), "")
1124
1347
 
1125
1348
  def test_replaced_tree_and_unsafe_custody(self):
@@ -1138,21 +1361,21 @@ class CompletionTests(unittest.TestCase):
1138
1361
  self.assertNotEqual(r.returncode, 0)
1139
1362
  self.assertIn("replaced", json.loads(r.stdout)["reason"])
1140
1363
  self.assertTrue(self.task.exists())
1141
- self.assertEqual(json.loads(self.data.read_text()).get("pushs",0), 0)
1364
+ self.assertEqual(json.loads(self.data.read_text(encoding="utf-8")).get("pushs",0), 0)
1142
1365
 
1143
1366
  def test_spec_source_and_report_binding(self):
1144
1367
  self.allocate(); self.accept(pipeline=True, spec_expected={"verification_commands": []})
1145
1368
  report = self.archive / "final-report.md"
1146
1369
  original = report.read_bytes()
1147
- report.write_text("<!-- devlyn:final-report run_id=another-run -->\nBody\n")
1370
+ report.write_text("<!-- devlyn:final-report run_id=another-run -->\nBody\n", encoding="utf-8")
1148
1371
  self.state["phases"]["final_report"]["output_sha256"] = hashlib.sha256(report.read_bytes()).hexdigest()
1149
- (self.archive / "pipeline.state.json").write_text(json.dumps(self.state))
1372
+ (self.archive / "pipeline.state.json").write_text(json.dumps(self.state), encoding="utf-8")
1150
1373
  _, r = self.complete(success=False)
1151
1374
  self.assertNotEqual(r.returncode, 0)
1152
- self.assertEqual(json.loads(self.data.read_text()).get("pushs",0), 0)
1375
+ self.assertEqual(json.loads(self.data.read_text(encoding="utf-8")).get("pushs",0), 0)
1153
1376
  report.write_bytes(original)
1154
1377
  self.state["phases"]["final_report"]["output_sha256"] = hashlib.sha256(original).hexdigest()
1155
- (self.archive / "pipeline.state.json").write_text(json.dumps(self.state))
1378
+ (self.archive / "pipeline.state.json").write_text(json.dumps(self.state), encoding="utf-8")
1156
1379
  result, _ = self.complete("--mode", "pr")
1157
1380
  self.assertEqual(result["status"], "PR")
1158
1381
 
@@ -1162,8 +1385,25 @@ class CompletionTests(unittest.TestCase):
1162
1385
  _, r = self.complete(success=False)
1163
1386
  self.assertNotEqual(r.returncode, 0)
1164
1387
  self.assertIn("implement process evidence", json.loads(r.stdout)["reason"])
1165
- self.assertEqual(json.loads(self.data.read_text()).get("pushs",0), 0)
1388
+ self.assertEqual(json.loads(self.data.read_text(encoding="utf-8")).get("pushs",0), 0)
1389
+
1390
+ def test_named_spec_custody(self):
1391
+ self.allocate(); self.accept(pipeline=True, spec_expected={"pure_design": True}, spec_name="X.md")
1392
+ result, _ = self.complete("--mode", "pr")
1393
+ self.assertEqual(result["status"], "PR")
1394
+ receipt = json.loads(self.receipt.read_text(encoding="utf-8"))
1395
+ self.assertIn("spec.expected.json", receipt["files"])
1396
+ self.assertEqual((self.receipt.parent / "custody/spec.expected.json").read_bytes(), (self.task / "spec.expected.json").read_bytes())
1397
+
1398
+ def test_named_spec_missing_required_process_evidence(self):
1399
+ expected = {"process_evidence": [{"id": "red-first", "phase": "implement", "argv": [sys.executable, "-c", "print('red')"], "exit_code": 0, "timeout_sec": 10}]}
1400
+ self.allocate(); self.accept(pipeline=True, spec_expected=expected, spec_name="X.md")
1401
+ _, r = self.complete(success=False)
1402
+ self.assertNotEqual(r.returncode, 0)
1403
+ self.assertIn("implement process evidence", json.loads(r.stdout)["reason"])
1404
+ self.assertEqual(json.loads(self.data.read_text(encoding="utf-8")).get("pushs",0), 0)
1166
1405
 
1167
1406
 
1168
1407
  if __name__ == "__main__":
1408
+ runpy.run_path(str(Path(__file__).with_name("platform-support.py")))["configure_utf8"]()
1169
1409
  sys.exit(main())