devlyn-cli 2.10.1 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/AGENTS.md +8 -9
  2. package/CLAUDE.md +17 -17
  3. package/README.md +33 -9
  4. package/benchmark/auto-resolve/results/20260507-f10-f11-tier1-full-pipeline/headroom-gate.json +25 -0
  5. package/benchmark/auto-resolve/results/20260507-f10-f11-tier1-full-pipeline/headroom-gate.md +10 -0
  6. package/benchmark/auto-resolve/results/20260508-f22-exact-error-headroom/headroom-gate.json +18 -0
  7. package/benchmark/auto-resolve/results/20260508-f22-exact-error-headroom/headroom-gate.md +9 -0
  8. package/benchmark/auto-resolve/results/20260508-f26-headroom/headroom-gate.json +18 -0
  9. package/benchmark/auto-resolve/results/20260508-f26-headroom/headroom-gate.md +9 -0
  10. package/benchmark/auto-resolve/results/20260510-f16-f23-f25-combined-proof/F16-cli-quote-tax-rules/l2_risk_probes/result.json +28 -0
  11. package/benchmark/auto-resolve/results/20260510-f16-f23-f25-combined-proof/F23-cli-fulfillment-wave/l2_risk_probes/result.json +28 -0
  12. package/benchmark/auto-resolve/results/20260510-f16-f23-f25-combined-proof/F25-cli-cart-promotion-rules/l2_risk_probes/result.json +30 -0
  13. package/benchmark/auto-resolve/results/20260511-f12-webhook-headroom/headroom-gate.json +28 -0
  14. package/benchmark/auto-resolve/results/20260511-f12-webhook-headroom/headroom-gate.md +15 -0
  15. package/benchmark/auto-resolve/results/20260511-f15-concurrency-headroom/headroom-gate.json +28 -0
  16. package/benchmark/auto-resolve/results/20260511-f15-concurrency-headroom/headroom-gate.md +15 -0
  17. package/benchmark/auto-resolve/results/20260511-f21-current-riskprobes-v1/F21-cli-scheduler-priority/l2_risk_probes/result.json +32 -0
  18. package/benchmark/auto-resolve/results/20260511-f21-current-riskprobes-v1/full-pipeline-pair-gate.json +44 -0
  19. package/benchmark/auto-resolve/results/20260511-f21-current-riskprobes-v1/full-pipeline-pair-gate.md +16 -0
  20. package/benchmark/auto-resolve/results/20260511-f21-current-riskprobes-v1/headroom-gate.json +18 -0
  21. package/benchmark/auto-resolve/results/20260511-f21-current-riskprobes-v1/headroom-gate.md +9 -0
  22. package/benchmark/auto-resolve/results/20260511-f3-http-error-headroom/headroom-gate.json +28 -0
  23. package/benchmark/auto-resolve/results/20260511-f3-http-error-headroom/headroom-gate.md +15 -0
  24. package/benchmark/auto-resolve/results/20260512-f2-medium-headroom/headroom-gate.json +28 -0
  25. package/benchmark/auto-resolve/results/20260512-f2-medium-headroom/headroom-gate.md +15 -0
  26. package/benchmark/auto-resolve/results/20260512-f31-seat-rebalance-headroom/headroom-gate.json +28 -0
  27. package/benchmark/auto-resolve/results/20260512-f31-seat-rebalance-headroom/headroom-gate.md +15 -0
  28. package/benchmark/auto-resolve/results/20260512-f32-subscription-renewal-headroom/headroom-gate.json +28 -0
  29. package/benchmark/auto-resolve/results/20260512-f32-subscription-renewal-headroom/headroom-gate.md +15 -0
  30. package/benchmark/auto-resolve/results/20260512-f4-web-headroom/headroom-gate.json +28 -0
  31. package/benchmark/auto-resolve/results/20260512-f4-web-headroom/headroom-gate.md +15 -0
  32. package/benchmark/auto-resolve/results/20260512-f5-fixloop-headroom/headroom-gate.json +28 -0
  33. package/benchmark/auto-resolve/results/20260512-f5-fixloop-headroom/headroom-gate.md +15 -0
  34. package/benchmark/auto-resolve/results/20260512-f6-checksum-headroom/headroom-gate.json +28 -0
  35. package/benchmark/auto-resolve/results/20260512-f6-checksum-headroom/headroom-gate.md +15 -0
  36. package/benchmark/auto-resolve/results/20260512-f7-scope-headroom/headroom-gate.json +28 -0
  37. package/benchmark/auto-resolve/results/20260512-f7-scope-headroom/headroom-gate.md +15 -0
  38. package/benchmark/auto-resolve/results/20260512-f9-e2e-headroom/headroom-gate.json +28 -0
  39. package/benchmark/auto-resolve/results/20260512-f9-e2e-headroom/headroom-gate.md +15 -0
  40. package/bin/devlyn.js +9 -2
  41. package/config/skills/_shared/.ruff.toml +2 -0
  42. package/config/skills/_shared/adapters/claude.md +9 -1
  43. package/config/skills/_shared/adapters/codex.md +5 -0
  44. package/config/skills/_shared/adapters/grok.md +4 -2
  45. package/config/skills/_shared/archive_run.py +738 -56
  46. package/config/skills/_shared/codex-config.md +19 -4
  47. package/config/skills/_shared/codex-monitored.sh +81 -12
  48. package/config/skills/_shared/collect-codex-findings.py +311 -128
  49. package/config/skills/_shared/engine-preflight.md +8 -7
  50. package/config/skills/_shared/expected.schema.json +61 -0
  51. package/config/skills/_shared/finish-gate.py +12 -11
  52. package/config/skills/_shared/grok-anchor-guard.py +18 -1
  53. package/config/skills/_shared/invocation-receipt.py +1041 -0
  54. package/config/skills/_shared/judge-output-parser.py +200 -0
  55. package/config/skills/_shared/judge-role-evidence.py +273 -0
  56. package/config/skills/_shared/process-evidence.py +909 -0
  57. package/config/skills/_shared/resolve-bootstrap.py +776 -84
  58. package/config/skills/_shared/resolve-stop-hook.py +33 -1
  59. package/config/skills/_shared/role-config.py +552 -0
  60. package/config/skills/_shared/run-bounded.py +1 -1
  61. package/config/skills/_shared/spec-verify-check.py +749 -181
  62. package/config/skills/_shared/state-phase-write.py +1838 -58
  63. package/config/skills/_shared/terminal-claim-check.py +15 -0
  64. package/config/skills/_shared/verify-merge-findings.py +958 -51
  65. package/config/skills/devlyn:engines/SKILL.md +8 -16
  66. package/config/skills/devlyn:ideate/SKILL.md +2 -2
  67. package/config/skills/devlyn:ideate/references/spec-template.md +6 -5
  68. package/config/skills/devlyn:queue/SKILL.md +7 -4
  69. package/config/skills/devlyn:resolve/SKILL.md +93 -35
  70. package/config/skills/devlyn:resolve/references/free-form-mode.md +1 -0
  71. package/config/skills/devlyn:resolve/references/outer-loop.md +15 -0
  72. package/config/skills/devlyn:resolve/references/phases/build-gate.md +31 -2
  73. package/config/skills/devlyn:resolve/references/phases/implement.md +3 -2
  74. package/config/skills/devlyn:resolve/references/phases/verify.md +120 -84
  75. package/config/skills/devlyn:resolve/references/state-schema.md +22 -8
  76. package/package.json +5 -1
  77. package/scripts/lint-skills.sh +314 -285
@@ -2,12 +2,14 @@
2
2
  """Deterministic PHASE-0 bootstrap for /devlyn:resolve."""
3
3
  from __future__ import annotations
4
4
 
5
+ import contextlib
5
6
  import datetime
6
7
  import hashlib
7
8
  import importlib.util
8
9
  import json
9
10
  import os
10
11
  import pathlib
12
+ import runpy
11
13
  import secrets
12
14
  import stat
13
15
  import subprocess
@@ -18,7 +20,7 @@ sys.dont_write_bytecode = True
18
20
 
19
21
 
20
22
  VALUE_FLAGS = {
21
- "--max-rounds", "--engine", "--spec", "--verify-only", "--goal-file", "--bypass",
23
+ "--max-rounds", "--engine", "--spec", "--verify-only", "--goal-file", "--bypass", "--role-config",
22
24
  }
23
25
  BOOL_FLAGS = {"--pair-verify", "--no-pair", "--risk-probes", "--no-risk-probes", "--perf"}
24
26
  SINGLE_VALUE_FLAGS = VALUE_FLAGS - {"--bypass"}
@@ -44,8 +46,21 @@ def reject_json_constant(token: str) -> None:
44
46
  raise ValueError(f"invalid JSON numeric constant: {token}")
45
47
 
46
48
 
49
+ def reject_duplicate_keys(pairs: list[tuple[str, object]]) -> dict:
50
+ result = {}
51
+ for key, value in pairs:
52
+ if key in result:
53
+ raise ValueError(f"duplicate JSON key: {key}")
54
+ result[key] = value
55
+ return result
56
+
57
+
47
58
  def strict_json(text: str):
48
- return json.loads(text, parse_constant=reject_json_constant)
59
+ return json.loads(
60
+ text,
61
+ parse_constant=reject_json_constant,
62
+ object_pairs_hook=reject_duplicate_keys,
63
+ )
49
64
 
50
65
 
51
66
  def json_bytes(value: object) -> bytes:
@@ -171,6 +186,8 @@ def parse_flags(argv: list[str]) -> dict:
171
186
  "spec": values.get("--spec"),
172
187
  "verify_ref": values.get("--verify-only"),
173
188
  "goal_file": values.get("--goal-file"),
189
+ "role_config": values.get("--role-config"),
190
+ "no_pair": "--no-pair" in switches,
174
191
  "inline_goal": " ".join(positional),
175
192
  "pair_verify": "--pair-verify" in switches,
176
193
  "bypasses": bypasses,
@@ -178,9 +195,32 @@ def parse_flags(argv: list[str]) -> dict:
178
195
 
179
196
 
180
197
  def validate_shared_dir(shared_dir: pathlib.Path) -> None:
181
- required = shared_dir / "spec-verify-check.py"
182
- if not required.is_file():
183
- block("BLOCKED:shared-dir-unresolved", str(required))
198
+ for name in ("spec-verify-check.py", "archive_run.py", "process-evidence.py"):
199
+ required = shared_dir / name
200
+ if not required.is_file():
201
+ block("BLOCKED:shared-dir-unresolved", str(required))
202
+
203
+
204
+ def archive_prior_run(devlyn: pathlib.Path, shared_dir: pathlib.Path) -> None:
205
+ archive = runpy.run_path(shared_dir / "archive_run.py")
206
+ if not archive["has_owned_artifacts"](devlyn):
207
+ return
208
+ try:
209
+ state = archive["read_state"](devlyn)
210
+ except (archive["ArchiveError"], OSError, UnicodeError, ValueError) as exc:
211
+ block("BLOCKED:prior-run-ownership-unverified", str(exc))
212
+ run_id = state["run_id"]
213
+ if not archive["is_completed"](state) or archive["has_bootstrap_residue"](devlyn):
214
+ block(
215
+ "BLOCKED:prior-run-unfinished",
216
+ f"Run {run_id} at {devlyn.parent} is unfinished or indeterminate. "
217
+ "Continue through its owning session or start in a distinct worktree.",
218
+ )
219
+ try:
220
+ archive["move_artifacts"](devlyn, devlyn / "runs" / run_id)
221
+ archive["prune"](devlyn / "runs", keep=10)
222
+ except (archive["ArchiveError"], OSError, UnicodeError, ValueError) as exc:
223
+ block("BLOCKED:prior-run-archive-failed", str(exc))
184
224
 
185
225
 
186
226
  def safe_goal_file(cwd: pathlib.Path, raw_path: str) -> bytes:
@@ -265,15 +305,30 @@ def init_spec_source(
265
305
  }, staged_path.read_bytes() if staged_path.is_file() else None)
266
306
 
267
307
 
268
- def git_text(cwd: pathlib.Path, *args: str, allow_empty: bool = False) -> str:
269
- proc = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True)
270
- text = proc.stdout.strip()
271
- if proc.returncode != 0 or (not allow_empty and not text):
272
- detail = (proc.stderr or proc.stdout).strip() or "git command failed"
308
+ def git_text(cwd: pathlib.Path, *args: str) -> str:
309
+ proc = subprocess.run(["git", *args], cwd=cwd, capture_output=True)
310
+ text = os.fsdecode(proc.stdout.removesuffix(b"\n"))
311
+ if proc.returncode != 0 or not text:
312
+ detail = os.fsdecode(proc.stderr or proc.stdout).strip() or "git command failed"
273
313
  block("BLOCKED:invalid-flags", detail)
274
314
  return text
275
315
 
276
316
 
317
+ def base_branch(cwd: pathlib.Path) -> str | None:
318
+ proc = subprocess.run(
319
+ ["git", "symbolic-ref", "--short", "-q", "HEAD"],
320
+ cwd=cwd,
321
+ capture_output=True,
322
+ text=True,
323
+ )
324
+ if proc.returncode == 1 and not proc.stdout.strip() and not proc.stderr.strip():
325
+ return None
326
+ if proc.returncode != 0 or not proc.stdout.strip():
327
+ detail = (proc.stderr or proc.stdout).strip() or "git symbolic-ref failed"
328
+ block("BLOCKED:invalid-flags", detail)
329
+ return proc.stdout.strip()
330
+
331
+
277
332
  def capture_external_diff(cwd: pathlib.Path, ref: str) -> bytes:
278
333
  supplied = pathlib.Path(ref)
279
334
  source = supplied if supplied.is_absolute() else cwd / supplied
@@ -287,6 +342,49 @@ def capture_external_diff(cwd: pathlib.Path, ref: str) -> bytes:
287
342
  return raw
288
343
 
289
344
 
345
+ def require_clean_tracked_baseline(cwd: pathlib.Path) -> None:
346
+ changed: list[bytes] = []
347
+ for args in (("diff",), ("diff", "--cached")):
348
+ proc = subprocess.run(
349
+ ["git", *args, "--no-renames", "--name-only", "-z"],
350
+ cwd=cwd,
351
+ capture_output=True,
352
+ )
353
+ if proc.returncode != 0:
354
+ block("BLOCKED:invalid-flags", os.fsdecode(proc.stderr or proc.stdout).strip())
355
+ changed.extend(path for path in proc.stdout.split(b"\0") if path)
356
+ if any(path != b".devlyn" and not path.startswith(b".devlyn/") for path in changed):
357
+ block(
358
+ "BLOCKED:worktree-dirty",
359
+ "Commit or stash tracked changes outside .devlyn before starting a full resolve.",
360
+ )
361
+
362
+
363
+ @contextlib.contextmanager
364
+ def admission_lock(cwd: pathlib.Path):
365
+ 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:
373
+ try:
374
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
375
+ except BlockingIOError:
376
+ block(
377
+ "BLOCKED:bootstrap-contended",
378
+ f"Bootstrap admission is occupied at {cwd}. Retry from this root after admission finishes.",
379
+ )
380
+ except (OSError, AttributeError) as exc:
381
+ block("BLOCKED:bootstrap-lock-unavailable", f"{cwd}: {lock_path}: {exc}")
382
+ for path in (cwd / ".devlyn", cwd / ".devlyn" / "runs"):
383
+ if path.is_symlink() or (path.exists() and not path.is_dir()):
384
+ block("BLOCKED:devlyn-path-redirect", f"{cwd}: require an unredirected directory at {path}")
385
+ yield
386
+
387
+
290
388
  def bootstrap(
291
389
  argv: list[str],
292
390
  cwd: pathlib.Path,
@@ -298,83 +396,508 @@ def bootstrap(
298
396
  cwd = cwd.resolve()
299
397
  validate_shared_dir(shared_dir)
300
398
  parsed = parse_flags(argv)
301
- devlyn = cwd / ".devlyn"
302
- outputs: dict[pathlib.Path, bytes | None] = {}
303
- if parsed["mode"] == "free-form":
304
- raw_goal = (
305
- safe_goal_file(cwd, parsed["goal_file"])
306
- if parsed["goal_file"] is not None
307
- else parsed["inline_goal"].encode("utf-8")
308
- )
309
- outputs[devlyn / "goal.raw.txt"] = raw_goal
310
- source = {
311
- "type": "generated",
312
- "spec_path": None,
313
- "spec_sha256": None,
314
- "goal_path": ".devlyn/goal.raw.txt",
315
- "goal_sha256": sha256(raw_goal),
316
- "criteria_path": ".devlyn/criteria.generated.md",
317
- "criteria_sha256": None,
399
+ for name in ("GIT_DIR", "GIT_WORK_TREE", "GIT_COMMON_DIR"):
400
+ if name in os.environ:
401
+ block("BLOCKED:git-env-redirect", f"{cwd}: inherited {name} redirects Git; unset it before retrying.")
402
+ root = pathlib.Path(git_text(cwd, "rev-parse", "--show-toplevel")).resolve()
403
+ if cwd != root:
404
+ block("BLOCKED:worktree-root-required", f"{cwd} is not the worktree root {root}. Retry from {root}.")
405
+ if parsed["mode"] != "verify-only":
406
+ require_clean_tracked_baseline(cwd)
407
+ with admission_lock(cwd):
408
+ devlyn = cwd / ".devlyn"
409
+ outputs: dict[pathlib.Path, bytes | None] = {devlyn / "external-diff.patch": None}
410
+ if parsed["mode"] == "free-form":
411
+ raw_goal = (
412
+ safe_goal_file(cwd, parsed["goal_file"])
413
+ if parsed["goal_file"] is not None
414
+ else parsed["inline_goal"].encode("utf-8")
415
+ )
416
+ outputs[devlyn / "goal.raw.txt"] = raw_goal
417
+ source = {
418
+ "type": "generated",
419
+ "spec_path": None,
420
+ "spec_sha256": None,
421
+ "goal_path": ".devlyn/goal.raw.txt",
422
+ "goal_sha256": sha256(raw_goal),
423
+ "criteria_path": ".devlyn/criteria.generated.md",
424
+ "criteria_sha256": None,
425
+ }
426
+ else:
427
+ with tempfile.TemporaryDirectory() as tmp:
428
+ source, staged_spec = init_spec_source(
429
+ cwd, pathlib.Path(tmp), shared_dir, parsed["spec"],
430
+ )
431
+ outputs[devlyn / "spec-verify.json"] = staged_spec
432
+ if parsed["mode"] == "verify-only":
433
+ outputs[devlyn / "external-diff.patch"] = capture_external_diff(cwd, parsed["verify_ref"])
434
+
435
+ role_input = None
436
+ if parsed["role_config"] is not None:
437
+ role_module = runpy.run_path(pathlib.Path(__file__).with_name("role-config.py"))
438
+ requested_path = pathlib.Path(parsed["role_config"])
439
+ if not requested_path.is_absolute():
440
+ requested_path = cwd / requested_path
441
+ try:
442
+ value, pin = role_module["read_config"](requested_path, run=True)
443
+ except ValueError as exc:
444
+ block("BLOCKED:invalid-engine-config", str(exc))
445
+ role_input = {**pin, "value": value}
446
+
447
+ engine = parsed["engine"] or default_engine
448
+ engine_source = "flag" if parsed["engine"] is not None else "default"
449
+ now = datetime.datetime.now(datetime.timezone.utc)
450
+ started_at = now.strftime("%Y-%m-%dT%H:%M:%S.") + f"{now.microsecond // 1000:03d}Z"
451
+ run_id = now.strftime("rs-%Y%m%dT%H%M%SZ-") + secrets.token_hex(6)
452
+ state = {
453
+ "version": "3.0",
454
+ "role_config_input": role_input,
455
+ "role_no_pair": parsed["no_pair"],
456
+ "run_id": run_id,
457
+ "started_at": started_at,
458
+ "session_id": os.environ.get("CLAUDE_CODE_SESSION_ID"),
459
+ "engine": engine,
460
+ "engine_source": engine_source,
461
+ "mode": parsed["mode"],
462
+ "pair_verify": parsed["pair_verify"],
463
+ "complexity": None,
464
+ "risk_profile": {
465
+ "high_risk": False,
466
+ "reasons": [],
467
+ "risk_probes_enabled": False,
468
+ "risk_probes_explicit": False,
469
+ "pair_default_enabled": True,
470
+ },
471
+ "risk_probes_digest": None,
472
+ "process_evidence": None,
473
+ "base_ref": {
474
+ "branch": base_branch(cwd),
475
+ "sha": git_text(cwd, "rev-parse", "HEAD"),
476
+ },
477
+ "rounds": {"max_rounds": parsed["max_rounds"], "global": 0},
478
+ "bypasses": parsed["bypasses"],
479
+ "implement_passed_sha": None,
480
+ "source": source,
481
+ "criteria": [],
482
+ "phases": {name: None for name in PHASE_NAMES},
483
+ "verify": {"coverage_failed": False, "pair_trigger": None},
318
484
  }
319
- else:
320
- with tempfile.TemporaryDirectory() as tmp:
321
- source, staged_spec = init_spec_source(
322
- cwd, pathlib.Path(tmp), shared_dir, parsed["spec"],
485
+ state_raw = json_bytes(state)
486
+ outputs[devlyn / "pipeline.state.json"] = state_raw
487
+ archive_prior_run(devlyn, shared_dir)
488
+ atomic_write_batch(outputs, writer)
489
+ return {
490
+ "ok": True,
491
+ "run_id": run_id,
492
+ "mode": parsed["mode"],
493
+ "source": source,
494
+ "state_path": ".devlyn/pipeline.state.json",
495
+ "state_sha256": sha256(state_raw),
496
+ }
497
+
498
+
499
+
500
+ def pathname_self_test() -> None:
501
+ script = pathlib.Path(__file__).resolve()
502
+ env = dict(os.environ, GIT_CONFIG_GLOBAL=os.devnull, GIT_CONFIG_NOSYSTEM="1")
503
+ with tempfile.TemporaryDirectory() as tmp:
504
+ for name in ("plain", "trailing ", "trailing\t", "trailing\n", "trailing\r\n"):
505
+ repo = pathlib.Path(tmp).resolve() / name
506
+ repo.mkdir()
507
+
508
+ def git(*args):
509
+ return subprocess.run(["git", *args], cwd=repo, env=env,
510
+ check=True, capture_output=True).stdout
511
+
512
+ git("init", "-q")
513
+ git("-c", "user.name=Test", "-c", "user.email=test@example.com",
514
+ "commit", "--allow-empty", "-qm", "base")
515
+ for option, path in (("--show-toplevel", repo), ("--absolute-git-dir", repo / ".git")):
516
+ raw = git("rev-parse", option)
517
+ print(f"pathname {name!r} {option}: {raw!r}", flush=True)
518
+ assert raw == os.fsencode(path) + b"\n", raw
519
+ command = [sys.executable, str(script), "pathname", "probe"]
520
+ proc = subprocess.run(command, cwd=repo, env=env, capture_output=True, timeout=15)
521
+ result = strict_json(proc.stdout)
522
+ assert proc.returncode == 0 and result["ok"] and not proc.stderr, (name, proc, result)
523
+ for option, path in (("--show-toplevel", repo), ("--absolute-git-dir", repo / ".git")):
524
+ assert git_text(repo, "rev-parse", option) == str(path)
525
+ lock = repo / ".git" / "devlyn-bootstrap.lock"
526
+ inode = lock.stat().st_ino
527
+ devlyn = repo / ".devlyn"
528
+ before = {p.relative_to(devlyn): p.read_bytes() for p in devlyn.rglob("*") if p.is_file()}
529
+ assert (devlyn / "goal.raw.txt").read_bytes() == b"pathname probe"
530
+ proc = subprocess.run(command, cwd=repo, env=env, capture_output=True, timeout=15)
531
+ refused = strict_json(proc.stdout)
532
+ assert proc.returncode == 1 and not proc.stderr, (name, proc)
533
+ assert refused["blocked"] == "BLOCKED:prior-run-unfinished", refused
534
+ assert str(repo) in refused["detail"] and result["run_id"] in refused["detail"]
535
+ assert {p.relative_to(devlyn): p.read_bytes() for p in devlyn.rglob("*") if p.is_file()} == before
536
+ assert not (devlyn / "runs").exists() and lock.stat().st_ino == inode
537
+ print(f"PASS admission pathname {name!r}: exact root/Gitdir, stable lock, unfinished refusal")
538
+
539
+
540
+ def admission_self_test() -> None:
541
+ import time
542
+ from unittest.mock import patch
543
+
544
+ shared = pathlib.Path(__file__).resolve().parent
545
+ script = shared / "resolve-bootstrap.py"
546
+ archive = runpy.run_path(shared / "archive_run.py")
547
+ env = dict(os.environ, GIT_CONFIG_GLOBAL=os.devnull, GIT_CONFIG_NOSYSTEM="1",
548
+ CLAUDE_CODE_SESSION_ID="same-admission-session")
549
+ spec_raw = (
550
+ '# Spec\n\n<!-- devlyn:verification -->\n## Verification\n\n```json\n'
551
+ '{"verification_commands":[{"cmd":"printf ok","stdout_contains":["ok"]}]}\n```\n'
552
+ ).encode()
553
+ modes = (["goal", "A"], ["--spec", "spec.md"],
554
+ ["--verify-only", "external.patch", "--spec", "spec.md"])
555
+
556
+ def git(repo, *args):
557
+ return subprocess.run(["git", *args], cwd=repo, env=env, check=True, capture_output=True).stdout
558
+
559
+ def inputs(repo):
560
+ (repo / "spec.md").write_bytes(spec_raw)
561
+ (repo / "external.patch").write_bytes(b"external patch A\x00\n")
562
+
563
+ def init(repo):
564
+ repo.mkdir()
565
+ git(repo, "init", "-q")
566
+ git(repo, "config", "user.email", "test@example.com")
567
+ git(repo, "config", "user.name", "Test")
568
+ (repo / "app.py").write_text("print('base')\n")
569
+ git(repo, "add", "app.py")
570
+ git(repo, "commit", "-qm", "base")
571
+ inputs(repo)
572
+
573
+ def snapshot(path):
574
+ if not path.exists() and not path.is_symlink():
575
+ 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
+ }
581
+
582
+ def cli(repo, argv, extra_env=None):
583
+ proc = subprocess.run([sys.executable, str(script), *argv], cwd=repo,
584
+ env=extra_env or env, capture_output=True, text=True, timeout=15)
585
+ assert proc.stderr == "", (proc.returncode, proc.stdout, proc.stderr)
586
+ result = strict_json(proc.stdout)
587
+ assert proc.returncode == (0 if result["ok"] else 1), result
588
+ return result
589
+
590
+ def refusal(repo, argv, reason, extra_env=None):
591
+ before = snapshot(repo / ".devlyn")
592
+ result = cli(repo, argv, extra_env)
593
+ assert result.get("blocked") == reason, result
594
+ assert snapshot(repo / ".devlyn") == before, result
595
+ return result
596
+
597
+ def wait_for(predicate):
598
+ deadline = time.monotonic() + 15
599
+ while not predicate():
600
+ assert time.monotonic() < deadline, "admission child synchronization timed out"
601
+ time.sleep(0.01)
602
+
603
+ # Test-only seam around the real bootstrap and atomic writer. Both children
604
+ # reach the pipe barrier before flock; only the winner reaches the writer.
605
+ child = r"""
606
+ import json, os, pathlib, runpy, sys, time
607
+ script, repo, signals, action, barrier, argv = sys.argv[1:]
608
+ m = runpy.run_path(script)
609
+ signals = pathlib.Path(signals)
610
+ def hold():
611
+ signals.with_suffix('.inside').touch()
612
+ deadline = time.monotonic() + 20
613
+ while not signals.with_suffix('.release').exists():
614
+ if time.monotonic() > deadline:
615
+ raise TimeoutError('held admission was not released')
616
+ time.sleep(0.01)
617
+ def writer(path, raw):
618
+ if action in ('hold', 'before'):
619
+ hold()
620
+ m['atomic_write'](path, raw)
621
+ if action == 'canonical':
622
+ hold()
623
+ if action.startswith('temp:'):
624
+ replace = pathlib.Path.replace
625
+ def held_replace(path, target):
626
+ if path.name.startswith(action[5:] + '.tmp.'):
627
+ hold()
628
+ return replace(path, target)
629
+ pathlib.Path.replace = held_replace
630
+ signals.with_suffix('.ready').touch()
631
+ if int(barrier) >= 0:
632
+ os.read(int(barrier), 1)
633
+ os.close(int(barrier))
634
+ try:
635
+ result = m['bootstrap'](json.loads(argv), pathlib.Path(repo), pathlib.Path(script).parent, writer=writer)
636
+ except m['BootstrapBlocked'] as exc:
637
+ print(json.dumps({'ok': False, 'blocked': exc.reason, 'detail': exc.detail}))
638
+ sys.exit(1)
639
+ print(json.dumps(result))
640
+ """
641
+
642
+ with tempfile.TemporaryDirectory() as tmp:
643
+ root = pathlib.Path(tmp).resolve()
644
+ repo = root / "state"
645
+ init(repo)
646
+ first = cli(repo, modes[0])
647
+ devlyn = repo / ".devlyn"
648
+ state_path = devlyn / "pipeline.state.json"
649
+ initial = strict_json(state_path.read_text())
650
+ (devlyn / "engines.json").write_bytes(b'{"executor":"codex"}\n')
651
+ (devlyn / "config.json").write_bytes(b'{"setting":true}\n')
652
+ (devlyn / "unrelated.data").write_bytes(b"keep\n")
653
+ old_archive = devlyn / "runs" / "unfinished-old" / "pipeline.state.json"
654
+ old_archive.parent.mkdir(parents=True)
655
+ old_archive.write_bytes(b'{"run_id":"unfinished-old","phases":{}}\n')
656
+ for argv in modes:
657
+ result = refusal(repo, argv, "BLOCKED:prior-run-unfinished")
658
+ assert first["run_id"] in result["detail"] and str(repo) in result["detail"]
659
+ invalid = [None, [], "PASS", {}, {"verdict": "PASS"},
660
+ {"completed_at": "2026-09-06T00:00:00Z"}]
661
+ invalid += [{"verdict": v, "completed_at": "2026-09-06T00:00:00Z"}
662
+ for v in (None, [], {}, "UNKNOWN", "FAIL")]
663
+ invalid += [{"verdict": "PASS", "completed_at": t} for t in (
664
+ None, [], "", "2026-09-06T00:00:00", "2026-02-29T00:00:00Z",
665
+ "2026-09-06T24:00:00Z", "2026-09-06T00:00:00.1Z",
666
+ "2026-09-06T00:00:00+00:00",
667
+ )]
668
+ for final in invalid:
669
+ state = dict(initial, phases={"final_report": final, "verify": {"verdict": "PASS"}})
670
+ state_path.write_bytes(json_bytes(state))
671
+ refusal(repo, modes[0], "BLOCKED:prior-run-unfinished")
672
+ for verdict in ("PASS", "PASS_WITH_ISSUES", "NEEDS_WORK", "BLOCKED"):
673
+ for timestamp in ("2024-02-29T23:59:59Z", "2024-02-29T23:59:59.123Z"):
674
+ state = strict_json(state_path.read_text())
675
+ state["phases"]["final_report"] = {"verdict": verdict, "completed_at": timestamp}
676
+ state_path.write_bytes(json_bytes(state))
677
+ raw = state_path.read_bytes()
678
+ goal = (devlyn / "goal.raw.txt").read_bytes()
679
+ assert cli(repo, modes[0])["ok"]
680
+ archived = devlyn / "runs" / state["run_id"]
681
+ assert (archived / "pipeline.state.json").read_bytes() == raw
682
+ assert (archived / "goal.raw.txt").read_bytes() == goal
683
+ assert (devlyn / "engines.json").read_bytes() == b'{"executor":"codex"}\n'
684
+ assert (devlyn / "config.json").read_bytes() == b'{"setting":true}\n'
685
+ assert (devlyn / "unrelated.data").read_bytes() == b"keep\n"
686
+ assert old_archive.read_bytes() == b'{"run_id":"unfinished-old","phases":{}}\n'
687
+ for raw in (None, b"{", b"null", b'{"run_id":[]}', b'{"run_id":"../escape"}'):
688
+ if raw is None:
689
+ state_path.unlink()
690
+ else:
691
+ state_path.write_bytes(raw)
692
+ refusal(repo, modes[0], "BLOCKED:prior-run-ownership-unverified")
693
+ # Residue also blocks when the canonical report otherwise looks completed.
694
+ for pattern in archive["BOOTSTRAP_TEMP_PATTERNS"]:
695
+ state = dict(initial, phases={"final_report": {
696
+ "verdict": "PASS", "completed_at": "2026-09-06T00:00:00Z",
697
+ }})
698
+ state_path.write_bytes(json_bytes(state))
699
+ residue = devlyn / pattern.replace("*", "interrupted")
700
+ residue.write_bytes(b"partial")
701
+ for argv in modes:
702
+ refusal(repo, argv, "BLOCKED:prior-run-unfinished")
703
+ residue.unlink()
704
+ print("PASS admission state: same-session/all-mode byte stability, 8 completed forms, ownership/residue refusal")
705
+
706
+ identity = root / "identity"
707
+ init(identity)
708
+ alias = root / "alias"
709
+ alias.symlink_to(identity, target_is_directory=True)
710
+ sub = identity / "sub"
711
+ sub.mkdir()
712
+ detail = refusal(sub, ["--goal-file", "relative.txt"], "BLOCKED:worktree-root-required")["detail"]
713
+ assert str(identity) in detail and "Retry" in detail
714
+ assert not (identity / ".devlyn").exists()
715
+ external = root / "external"
716
+ init(external)
717
+ external_before = snapshot(external)
718
+ for key in ("GIT_DIR", "GIT_WORK_TREE", "GIT_COMMON_DIR"):
719
+ for value in ("", str(external / ".git")):
720
+ for argv in modes:
721
+ result = refusal(identity, argv, "BLOCKED:git-env-redirect", dict(env, **{key: value}))
722
+ assert key in result["detail"] and str(identity) in result["detail"]
723
+ assert snapshot(external) == external_before
724
+ # Even a non-repository location must reject Git redirection before Git.
725
+ assert cli(root, modes[0], dict(env, GIT_DIR=""))["blocked"] == "BLOCKED:git-env-redirect"
726
+ for relative in (".devlyn", ".devlyn/runs"):
727
+ for dangling in (False, True):
728
+ target = root / ("missing-target" if dangling else "redirect-target")
729
+ if not dangling:
730
+ target.mkdir(exist_ok=True)
731
+ (target / "keep").write_bytes(b"untouched")
732
+ link = identity / relative
733
+ link.parent.mkdir(exist_ok=True)
734
+ link.symlink_to(target, target_is_directory=True)
735
+ before = snapshot(target)
736
+ result = refusal(identity, modes[0], "BLOCKED:devlyn-path-redirect")
737
+ assert str(link) in result["detail"] and link.is_symlink()
738
+ assert snapshot(target) == before
739
+ link.unlink()
740
+ print("PASS admission identity: physical-root guidance, Git redirects including empty, live/dangling directory links")
741
+
742
+ def launch(repo, signals, action, argv, barrier=-1):
743
+ return subprocess.Popen(
744
+ [sys.executable, "-c", child, str(script), str(repo), str(signals),
745
+ action, str(barrier), json.dumps(argv)],
746
+ cwd=repo, env=env, pass_fds=(() if barrier < 0 else (barrier,)),
747
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
323
748
  )
324
- outputs[devlyn / "spec-verify.json"] = staged_spec
325
- if parsed["mode"] == "verify-only":
326
- outputs[devlyn / "external-diff.patch"] = capture_external_diff(cwd, parsed["verify_ref"])
327
-
328
- engine = parsed["engine"] or default_engine
329
- engine_source = "flag" if parsed["engine"] is not None else "default"
330
- now = datetime.datetime.now(datetime.timezone.utc)
331
- started_at = now.strftime("%Y-%m-%dT%H:%M:%S.") + f"{now.microsecond // 1000:03d}Z"
332
- run_id = now.strftime("rs-%Y%m%dT%H%M%SZ-") + secrets.token_hex(6)
333
- state = {
334
- "version": "3.0",
335
- "run_id": run_id,
336
- "started_at": started_at,
337
- "session_id": os.environ.get("CLAUDE_CODE_SESSION_ID"),
338
- "engine": engine,
339
- "engine_source": engine_source,
340
- "mode": parsed["mode"],
341
- "pair_verify": parsed["pair_verify"],
342
- "complexity": None,
343
- "risk_profile": {
344
- "high_risk": False,
345
- "reasons": [],
346
- "risk_probes_enabled": False,
347
- "risk_probes_explicit": False,
348
- "pair_default_enabled": True,
349
- },
350
- "risk_probes_digest": None,
351
- "base_ref": {
352
- "branch": git_text(cwd, "symbolic-ref", "--short", "-q", "HEAD", allow_empty=True) or "HEAD",
353
- "sha": git_text(cwd, "rev-parse", "HEAD"),
354
- },
355
- "rounds": {"max_rounds": parsed["max_rounds"], "global": 0},
356
- "bypasses": parsed["bypasses"],
357
- "implement_passed_sha": None,
358
- "source": source,
359
- "criteria": [],
360
- "phases": {name: None for name in PHASE_NAMES},
361
- "verify": {"coverage_failed": False, "pair_trigger": None},
362
- }
363
- state_raw = json_bytes(state)
364
- outputs[devlyn / "pipeline.state.json"] = state_raw
365
- atomic_write_batch(outputs, writer)
366
- return {
367
- "ok": True,
368
- "run_id": run_id,
369
- "mode": parsed["mode"],
370
- "source": source,
371
- "state_path": ".devlyn/pipeline.state.json",
372
- "state_sha256": sha256(state_raw),
373
- }
749
+
750
+ def receipt(proc):
751
+ stdout, stderr = proc.communicate(timeout=15)
752
+ assert stderr == "", (proc.returncode, stdout, stderr)
753
+ result = strict_json(stdout)
754
+ assert proc.returncode == (0 if result["ok"] else 1), result
755
+ return result
756
+
757
+ for i, argv in enumerate(modes):
758
+ race = root / f"race-{i}"
759
+ init(race)
760
+ (race / "spec-b.md").write_bytes(spec_raw.replace(b"# Spec", b"# Other spec"))
761
+ (race / "external-b.patch").write_bytes(b"external patch B\n")
762
+ other = ["goal", "B"] if i == 0 else [s.replace("spec.md", "spec-b.md").replace(
763
+ "external.patch", "external-b.patch") for s in argv]
764
+ signals = [root / f"race-{i}-{label}" for label in ("A", "B")]
765
+ rfd, wfd = os.pipe()
766
+ procs = []
767
+ try:
768
+ procs = [launch(race, signal, "hold", args, rfd)
769
+ for signal, args in zip(signals, (argv, other))]
770
+ os.close(rfd)
771
+ wait_for(lambda: all(s.with_suffix(".ready").exists() for s in signals))
772
+ os.close(wfd)
773
+ wait_for(lambda: any(s.with_suffix(".inside").exists() for s in signals)
774
+ and any(p.poll() is not None for p in procs))
775
+ holders = [j for j, s in enumerate(signals) if s.with_suffix(".inside").exists()]
776
+ assert len(holders) == 1, holders
777
+ winner = holders[0]
778
+ loser = receipt(procs[1 - winner])
779
+ assert loser.get("blocked") == "BLOCKED:bootstrap-contended", loser
780
+ assert str(race) in loser["detail"] and "run_id" not in loser
781
+ assert snapshot(race / ".devlyn") == {}, "loser mutated owned/archive state"
782
+ signals[winner].with_suffix(".release").touch()
783
+ won = receipt(procs[winner])
784
+ assert won["ok"]
785
+ finally:
786
+ for proc in procs:
787
+ if proc.poll() is None:
788
+ proc.kill()
789
+ proc.communicate()
790
+ state_raw = (race / ".devlyn/pipeline.state.json").read_bytes()
791
+ state = strict_json(state_raw.decode())
792
+ assert state["run_id"] == won["run_id"] and sha256(state_raw) == won["state_sha256"]
793
+ expected_files = {"pipeline.state.json"}
794
+ if i == 0:
795
+ goal = (race / ".devlyn/goal.raw.txt").read_bytes()
796
+ assert goal == (b"goal A" if winner == 0 else b"goal B")
797
+ assert sha256(goal) == state["source"]["goal_sha256"]
798
+ expected_files.add("goal.raw.txt")
799
+ else:
800
+ source_path = "spec.md" if winner == 0 else "spec-b.md"
801
+ assert state["source"]["spec_path"] == source_path
802
+ assert state["source"]["spec_sha256"] == sha256((race / source_path).read_bytes())
803
+ expected_files.add("spec-verify.json")
804
+ if i == 2:
805
+ patch_path = "external.patch" if winner == 0 else "external-b.patch"
806
+ assert (race / ".devlyn/external-diff.patch").read_bytes() == (race / patch_path).read_bytes()
807
+ expected_files.add("external-diff.patch")
808
+ assert {p.name for p in (race / ".devlyn").iterdir()} == expected_files
809
+ for attempt in modes:
810
+ refusal(race, attempt, "BLOCKED:prior-run-unfinished")
811
+ print("PASS admission processes: pre-lock synchronized pair in all 3 modes, one winner, zero loser delta, unmixed outputs")
812
+
813
+ linked = root / "linked"
814
+ git(identity, "worktree", "add", "-qb", "independent", str(linked))
815
+ inputs(linked)
816
+ lock_path = pathlib.Path(git(identity, "rev-parse", "--absolute-git-dir").decode().strip()) / "devlyn-bootstrap.lock"
817
+ with admission_lock(identity):
818
+ inode = lock_path.stat().st_ino
819
+ assert cli(linked, modes[0])["ok"]
820
+ refusal(alias, modes[0], "BLOCKED:bootstrap-contended")
821
+ assert cli(alias, modes[0])["ok"]
822
+ assert lock_path.stat().st_ino == inode
823
+ assert pathlib.Path(git(linked, "rev-parse", "--absolute-git-dir").decode().strip()) != lock_path.parent
824
+ collision = subprocess.run(["git", "worktree", "add", str(root / "collision"), "independent"],
825
+ cwd=identity, env=env, capture_output=True)
826
+ assert collision.returncode != 0 and b"already" in collision.stderr
827
+ assert not (root / "collision").exists()
828
+ print("PASS admission worktrees: linked root initializes under other's lock; aliases exclude; branch collision retained")
829
+
830
+ failure = root / "failure"
831
+ init(failure)
832
+ def fail_writer(path, raw):
833
+ raise OSError("before-first-write")
834
+ try:
835
+ bootstrap(modes[0], failure, shared, writer=fail_writer)
836
+ except OSError as exc:
837
+ assert str(exc) == "before-first-write"
838
+ else:
839
+ raise AssertionError("writer exception was hidden")
840
+ assert not (failure / ".devlyn").exists()
841
+ failure_lock = failure / ".git/devlyn-bootstrap.lock"
842
+ 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"))):
846
+ with replacement:
847
+ try:
848
+ bootstrap(modes[0], failure, shared)
849
+ except BootstrapBlocked as exc:
850
+ assert exc.reason == "BLOCKED:bootstrap-lock-unavailable" and str(failure) in exc.detail
851
+ else:
852
+ raise AssertionError("unavailable locking was accepted")
853
+ assert not (failure / ".devlyn").exists()
854
+ assert failure_lock.stat().st_ino == failure_inode
855
+ denied = root / "lock-open-failure"
856
+ init(denied)
857
+ (denied / ".git/devlyn-bootstrap.lock").mkdir()
858
+ refusal(denied, modes[0], "BLOCKED:bootstrap-lock-unavailable")
859
+ assert cli(failure, modes[0])["ok"] and failure_lock.stat().st_ino == failure_inode
860
+ print("PASS admission failures: before-write exception/retry; explicit import/open/acquisition refusal; persistent inode")
861
+
862
+ deaths = [("before", modes[0], None), ("canonical", modes[0], "goal.raw.txt")]
863
+ deaths += [("temp:" + name, modes[mode], name + ".tmp.*") for name, mode in (
864
+ ("goal.raw.txt", 0), ("spec-verify.json", 1), ("external-diff.patch", 2), ("pipeline.state.json", 0),
865
+ )]
866
+ for i, (action, argv, family) in enumerate(deaths):
867
+ dead = root / f"death-{i}"
868
+ init(dead)
869
+ signal = root / f"death-{i}"
870
+ proc = launch(dead, signal, action, argv)
871
+ try:
872
+ wait_for(lambda: signal.with_suffix(".inside").exists() or proc.poll() is not None)
873
+ assert signal.with_suffix(".inside").exists(), proc.communicate()
874
+ inode = (dead / ".git/devlyn-bootstrap.lock").stat().st_ino
875
+ proc.kill()
876
+ stdout, stderr = proc.communicate(timeout=15)
877
+ assert proc.returncode == -9 and stdout == "" and stderr == "", (stdout, stderr)
878
+ finally:
879
+ if proc.poll() is None:
880
+ proc.kill()
881
+ proc.communicate()
882
+ if family is None:
883
+ assert not (dead / ".devlyn").exists()
884
+ assert cli(dead, modes[1])["ok"]
885
+ else:
886
+ assert len(list((dead / ".devlyn").glob(family))) == 1
887
+ for attempt in modes:
888
+ refusal(dead, attempt, "BLOCKED:prior-run-ownership-unverified")
889
+ assert (dead / ".git/devlyn-bootstrap.lock").stat().st_ino == inode
890
+ print("PASS admission death: SIGKILL before write retries; canonical and all 4 real atomic temp families preserved across modes")
374
891
 
375
892
 
376
893
  def self_test() -> int:
377
894
  script_shared = pathlib.Path(__file__).resolve().parent
895
+ try:
896
+ strict_json('{"run_id":"a","run_id":"b"}')
897
+ except ValueError as exc:
898
+ assert "duplicate JSON key" in str(exc)
899
+ else:
900
+ raise AssertionError("duplicate bootstrap state key was accepted")
378
901
 
379
902
  def init_repo(path: pathlib.Path) -> None:
380
903
  path.mkdir()
@@ -385,8 +908,43 @@ def self_test() -> int:
385
908
  subprocess.run(["git", "add", "app.py"], cwd=path, check=True)
386
909
  subprocess.run(["git", "commit", "-qm", "base"], cwd=path, check=True)
387
910
 
911
+ def snapshot(path: pathlib.Path) -> dict[str, bytes]:
912
+ return {
913
+ str(file.relative_to(path)): file.read_bytes()
914
+ for file in path.rglob("*")
915
+ if file.is_file()
916
+ }
917
+
918
+ def complete_prior(path: pathlib.Path) -> None:
919
+ state_path = path / ".devlyn" / "pipeline.state.json"
920
+ state = strict_json(state_path.read_text())
921
+ state["phases"]["final_report"] = {
922
+ "verdict": "PASS", "completed_at": "2026-09-06T00:00:00Z",
923
+ }
924
+ state_path.write_bytes(json_bytes(state))
925
+
388
926
  with tempfile.TemporaryDirectory() as tmp:
389
927
  root = pathlib.Path(tmp)
928
+ role_work = root / "role-repo"
929
+ init_repo(role_work)
930
+ role_file = root / "roles.json"
931
+ for raw in (b"{", b'{"executor":"codex"}', b'{"roles":null}'):
932
+ role_file.write_bytes(raw)
933
+ try:
934
+ bootstrap(["--role-config", str(role_file), "fix app.py"], role_work, script_shared)
935
+ except BootstrapBlocked as exc:
936
+ assert exc.reason == "BLOCKED:invalid-engine-config"
937
+ else:
938
+ raise AssertionError("invalid per-run roles admitted")
939
+ assert not (role_work / ".devlyn/pipeline.state.json").exists()
940
+ role_raw = b'{"roles":{"worker":{"engine":"codex","model":"gpt-6-astra"}}}'
941
+ role_file.write_bytes(role_raw)
942
+ 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("{}")
945
+ assert staged_role["role_config_input"]["sha256"] == sha256(role_raw)
946
+ assert staged_role["role_config_input"]["value"] == strict_json(role_raw.decode())
947
+ assert staged_role["role_no_pair"] is True
390
948
  work = root / "repo"
391
949
  init_repo(work)
392
950
  session_key = "CLAUDE_CODE_SESSION_ID"
@@ -403,6 +961,8 @@ def self_test() -> int:
403
961
  "engine": "claude",
404
962
  "engine_source": "default",
405
963
  "mode": "free-form",
964
+ "role_config_input": None,
965
+ "role_no_pair": False,
406
966
  "pair_verify": False,
407
967
  "complexity": None,
408
968
  "risk_profile": {
@@ -413,8 +973,9 @@ def self_test() -> int:
413
973
  "pair_default_enabled": True,
414
974
  },
415
975
  "risk_probes_digest": None,
976
+ "process_evidence": None,
416
977
  "base_ref": {
417
- "branch": git_text(work, "symbolic-ref", "--short", "-q", "HEAD"),
978
+ "branch": base_branch(work),
418
979
  "sha": git_text(work, "rev-parse", "HEAD"),
419
980
  },
420
981
  "rounds": {"max_rounds": 4, "global": 0},
@@ -442,6 +1003,17 @@ def self_test() -> int:
442
1003
  assert set(result) == {"ok", "run_id", "mode", "source", "state_path", "state_sha256"}
443
1004
  print("PASS bootstrap self-test state-init byte contract: schema v3.0 exact, slim result")
444
1005
 
1006
+ detached = root / "detached-repo"
1007
+ init_repo(detached)
1008
+ subprocess.run(["git", "checkout", "--detach", "-q"], cwd=detached, check=True)
1009
+ detached_result = bootstrap(["fix", "app.py"], detached, script_shared)
1010
+ detached_state = strict_json((detached / ".devlyn" / "pipeline.state.json").read_text())
1011
+ assert detached_result["ok"] is True
1012
+ assert detached_state["base_ref"]["branch"] is None
1013
+ assert detached_state["base_ref"]["sha"] == git_text(detached, "rev-parse", "HEAD")
1014
+ print("PASS bootstrap self-test detached HEAD: branch null with exact HEAD sha")
1015
+
1016
+ complete_prior(work)
445
1017
  os.environ[session_key] = "session-self-test"
446
1018
  bootstrap(["--engine", "raw-engine", "--pair-verify", "fix", "app.py"], work, script_shared)
447
1019
  stamped = strict_json(state_path.read_text())
@@ -456,6 +1028,8 @@ def self_test() -> int:
456
1028
  print("PASS bootstrap self-test session stamp: null-safe and env-present; engine flag passthrough")
457
1029
 
458
1030
  invalid_flag_cases = [
1031
+ ["--role-config"],
1032
+ ["--role-config", "a", "--role-config", "b", "fix app.py"],
459
1033
  ["--pair-verify", "--no-pair", "fix", "app.py"],
460
1034
  ["--goal-file", "goal.txt", "--spec", "spec.md"],
461
1035
  ["--goal-file", "goal.txt", "--verify-only", "patch", "--spec", "spec.md"],
@@ -520,6 +1094,7 @@ def self_test() -> int:
520
1094
 
521
1095
  goal_raw = "fix app.py\r\npreserve café bytes\n".encode()
522
1096
  (work / "goal.txt").write_bytes(goal_raw)
1097
+ complete_prior(work)
523
1098
  goal_result = bootstrap(["--goal-file", "goal.txt"], work, script_shared)
524
1099
  assert goal_result["source"]["goal_sha256"] == sha256(goal_raw)
525
1100
  assert (work / ".devlyn" / "goal.raw.txt").read_bytes() == goal_raw
@@ -563,24 +1138,139 @@ def self_test() -> int:
563
1138
  "## Verification\n\n```json\n{\"verification_commands\":[{\"cmd\":\"printf ok\",\"stdout_contains\":[\"ok\"]}]}\n```\n"
564
1139
  )
565
1140
  spec_raw = spec_path.read_bytes()
1141
+ external_patch = work / ".devlyn" / "external-diff.patch"
1142
+ external_patch.write_bytes(b"stale spec patch\n")
1143
+ complete_prior(work)
566
1144
  spec_result = bootstrap(["--spec", str(spec_path.relative_to(work))], work, script_shared)
1145
+ assert not external_patch.exists()
567
1146
  staged = strict_json((work / ".devlyn" / "spec-verify.json").read_text())
568
1147
  assert staged["verification_commands"][0]["cmd"] == "printf ok"
569
1148
  assert spec_result["source"]["spec_sha256"] == sha256(spec_raw)
1149
+ external_patch.write_bytes(b"stale free-form patch\n")
1150
+ complete_prior(work)
1151
+ bootstrap(["fresh", "goal"], work, script_shared)
1152
+ assert not external_patch.exists()
570
1153
  (spec_dir / "spec.expected.json").write_text(json.dumps({
571
1154
  "verification_commands": [{"cmd": "printf expected", "stdout_contains": ["expected"]}],
572
1155
  }) + "\n")
1156
+ complete_prior(work)
573
1157
  bootstrap(["--spec", str(spec_path.relative_to(work))], work, script_shared)
574
1158
  staged = strict_json((work / ".devlyn" / "spec-verify.json").read_text())
575
1159
  assert staged["verification_commands"][0]["cmd"] == "printf expected"
576
1160
  patch_raw = b"diff --git a/app.py b/app.py\nexact external bytes\x00\n"
577
1161
  (work / "external.patch").write_bytes(patch_raw)
1162
+ (work / "app.py").write_text("print('dirty verify-only input')\n")
1163
+ complete_prior(work)
578
1164
  verify_result = bootstrap([
579
1165
  "--verify-only", "external.patch", "--spec", str(spec_path.relative_to(work)),
580
1166
  ], work, script_shared)
581
1167
  assert verify_result["mode"] == "verify-only"
582
- assert (work / ".devlyn" / "external-diff.patch").read_bytes() == patch_raw
583
- print("PASS bootstrap self-test spec staging + verify-only capture: exact source/diff bytes")
1168
+ assert external_patch.read_bytes() == patch_raw
1169
+ subprocess.run(["git", "restore", "app.py"], cwd=work, check=True)
1170
+ print("PASS bootstrap self-test patch lifecycle: full-mode removal + dirty verify-only exact capture")
1171
+
1172
+ dirty_work = root / "dirty-repo"
1173
+ init_repo(dirty_work)
1174
+ bootstrap(["clean", "baseline"], dirty_work, script_shared)
1175
+ before_dirty = snapshot(dirty_work / ".devlyn")
1176
+ (dirty_work / "app.py").write_text("print('unstaged')\n")
1177
+ for label in ("unstaged", "staged"):
1178
+ if label == "staged":
1179
+ subprocess.run(["git", "add", "app.py"], cwd=dirty_work, check=True)
1180
+ try:
1181
+ bootstrap(["blocked", label], dirty_work, script_shared)
1182
+ except BootstrapBlocked as exc:
1183
+ assert exc.reason == "BLOCKED:worktree-dirty"
1184
+ assert "commit or stash" in exc.detail.lower()
1185
+ else:
1186
+ raise AssertionError(f"{label} tracked owner change accepted")
1187
+ assert snapshot(dirty_work / ".devlyn") == before_dirty
1188
+ subprocess.run(["git", "restore", "--staged", "app.py"], cwd=dirty_work, check=True)
1189
+ subprocess.run(["git", "restore", "app.py"], cwd=dirty_work, check=True)
1190
+
1191
+ devlyn_work = root / "devlyn-dirty-repo"
1192
+ init_repo(devlyn_work)
1193
+ (devlyn_work / ".devlyn").mkdir()
1194
+ owner_file = devlyn_work / ".devlyn" / "owner.txt"
1195
+ owner_file.write_text("base\n")
1196
+ subprocess.run(["git", "add", ".devlyn/owner.txt"], cwd=devlyn_work, check=True)
1197
+ subprocess.run(["git", "commit", "-qm", "track devlyn owner"], cwd=devlyn_work, check=True)
1198
+ owner_file.write_text("dirty allowed\n")
1199
+ assert bootstrap(["devlyn", "allowed"], devlyn_work, script_shared)["ok"] is True
1200
+ assert owner_file.read_text() == "dirty allowed\n"
1201
+
1202
+ untracked_work = root / "untracked-repo"
1203
+ init_repo(untracked_work)
1204
+ (untracked_work / "untracked.txt").write_text("allowed\n")
1205
+ assert bootstrap(["untracked", "allowed"], untracked_work, script_shared)["ok"] is True
1206
+ print("PASS bootstrap self-test honest baseline: dirty owner blocked; .devlyn/untracked allowed")
1207
+
1208
+ prior_work = root / "prior-run-repo"
1209
+ init_repo(prior_work)
1210
+ prior = bootstrap(["prior", "run"], prior_work, script_shared)
1211
+ prior_devlyn = prior_work / ".devlyn"
1212
+ for name in (
1213
+ "implement.task-context",
1214
+ "implement.prompt",
1215
+ "implement.stdout",
1216
+ "implement.stderr",
1217
+ "implement.events.jsonl",
1218
+ "implement.retry.1.stdout",
1219
+ "verify.primary.timeout.json",
1220
+ ):
1221
+ (prior_devlyn / name).write_text(f"prior {name}\n", encoding="utf-8")
1222
+ (prior_devlyn / "engines.json").write_text('{"executor":"codex"}\n', encoding="utf-8")
1223
+ (prior_devlyn / "unrelated.data").write_text("preserve\n", encoding="utf-8")
1224
+ before = snapshot(prior_devlyn)
1225
+ try:
1226
+ bootstrap(["replacement", "run"], prior_work, script_shared)
1227
+ except BootstrapBlocked as exc:
1228
+ assert exc.reason == "BLOCKED:prior-run-unfinished"
1229
+ assert prior["run_id"] in exc.detail and str(prior_work.resolve()) in exc.detail
1230
+ else:
1231
+ raise AssertionError("unfinished prior run was replaced")
1232
+ assert snapshot(prior_devlyn) == before
1233
+ complete_prior(prior_work)
1234
+ replacement = bootstrap(["replacement", "run"], prior_work, script_shared)
1235
+ prior_archive = prior_devlyn / "runs" / prior["run_id"]
1236
+ assert replacement["run_id"] != prior["run_id"]
1237
+ for name in (
1238
+ "pipeline.state.json",
1239
+ "goal.raw.txt",
1240
+ "implement.task-context",
1241
+ "implement.prompt",
1242
+ "implement.stdout",
1243
+ "implement.stderr",
1244
+ "implement.events.jsonl",
1245
+ "implement.retry.1.stdout",
1246
+ "verify.primary.timeout.json",
1247
+ ):
1248
+ assert (prior_archive / name).is_file(), name
1249
+ if name not in {"pipeline.state.json", "goal.raw.txt"}:
1250
+ assert not (prior_devlyn / name).exists(), name
1251
+ assert strict_json((prior_devlyn / "pipeline.state.json").read_text())["run_id"] == replacement["run_id"]
1252
+ assert (prior_archive / "goal.raw.txt").read_bytes() == b"prior run"
1253
+ 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"
1256
+
1257
+ for label, state_raw in (("missing", None), ("malformed", b"{\n")):
1258
+ unauthenticated = root / f"unauthenticated-{label}"
1259
+ init_repo(unauthenticated)
1260
+ unauthenticated_devlyn = unauthenticated / ".devlyn"
1261
+ unauthenticated_devlyn.mkdir()
1262
+ (unauthenticated_devlyn / "implement.stdout").write_bytes(b"owned bytes\n")
1263
+ if state_raw is not None:
1264
+ (unauthenticated_devlyn / "pipeline.state.json").write_bytes(state_raw)
1265
+ before = snapshot(unauthenticated_devlyn)
1266
+ try:
1267
+ bootstrap(["must", "block"], unauthenticated, script_shared)
1268
+ except BootstrapBlocked as exc:
1269
+ assert exc.reason == "BLOCKED:prior-run-ownership-unverified", exc.reason
1270
+ else:
1271
+ raise AssertionError(f"{label} prior-run ownership was accepted")
1272
+ assert snapshot(unauthenticated_devlyn) == before
1273
+ print("PASS bootstrap self-test prior-run ownership: unfinished refused, completed archived; unauthenticated bytes stable")
584
1274
 
585
1275
  malformed_work = root / "malformed-spec-repo"
586
1276
  init_repo(malformed_work)
@@ -597,6 +1287,8 @@ def self_test() -> int:
597
1287
  assert not (malformed_work / ".devlyn").exists()
598
1288
  print("PASS bootstrap self-test spec validation failure: zero partial filesystem state")
599
1289
 
1290
+ pathname_self_test()
1291
+ admission_self_test()
600
1292
  return 0
601
1293
 
602
1294