devlyn-cli 4.2.25 → 4.3.0

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.
@@ -1,6 +1,8 @@
1
- # Runtime principles
1
+ The following Devlyn instructions are defaults for this session. Applicable explicit user instructions and native owner/project instructions take precedence over these defaults. This delegation applies only to the Devlyn defaults below; it does not change other engine instructions or permissions.
2
2
 
3
- The North Star and core principles of the installed CLAUDE.md/AGENTS.md block, for a context without that block. These sections stay byte-identical to the templates (lint Check 12).
3
+ # Project Instructions
4
+
5
+ devlyn-cli installs the `devlyn-ideate` and `devlyn-engines` skills alongside the principles below, which are non-negotiable on every change — yours and any sub-agent's.
4
6
 
5
7
  ## North Star
6
8
 
@@ -23,3 +25,11 @@ Three discipline rules govern HOW the principles are applied:
23
25
  - **Root cause via flexible why-chain.** Keep asking "why?" until you find the violated invariant. **If the answer surfaces in 2 questions, stop.** If it takes 5 or 7, keep going. Strict counts are wrong; until-found is right.
24
26
  - **First-principles thinking.** Challenge the requirement before optimizing the answer. Surface unstated assumptions, ambiguities, tradeoffs, and simpler alternatives BEFORE implementing — do not silently pick one interpretation when multiple exist, do not hide confusion, push back when a simpler path is genuinely better. Most "we have to do X" assumptions are habit, not necessity. Reduce the problem to its irreducible truths and rebuild from there.
25
27
  - **Perfection is achieved not when there is nothing more to add, but when there is nothing left to take away.** — Saint-Exupéry. This is the operating definition of "done." A change is finished when no further line, branch, flag, or doc paragraph can be removed without breaking a learned failure mode. Not before.
28
+
29
+ ## Quick Start
30
+
31
+ - `devlyn-ideate` — loop designer and intent queue: `plan <intent or document>` writes a validated loop package, `add` queues its tasks, `status` (or no arguments) reports the queue, `drain` executes it serially.
32
+ - Delivery — direct work edits the current checkout; deliver completed, verified requests with changes by project policy (default PR and merge), following `_shared/task-completion.md` in `.claude/skills/` or `.agents/skills/` (project, else `~`). With known concurrent writers, isolate before editing; on unexplained changes, pause writes until safe or isolated. An ideate drain delivers every task. Explicit limits win; commit alone stays local.
33
+ - Executor — `devlyn-engines` shows it and pins it in `.devlyn/engines.json`. A pinned executor does the implementation work, direct or drained: when it is not you, delegate to it, and when it is unavailable stop with `BLOCKED:<engine>-unavailable`. Without a pin, you are the executor.
34
+
35
+ Each skill's `SKILL.md` is the source of truth for its flags and workflow.
@@ -16,12 +16,16 @@ Linux reflink; never a copy), removes what must not carry over, and moves the re
16
16
  them: on reuse Cargo rewrites a script's own OUT_DIR in its directives, not paths elsewhere
17
17
  (aws-lc-rs's `cargo:include` into aws-lc-sys) nor inside the text it wrote (tauri's permission
18
18
  lists);
19
- - every unit of a package whose sources or generated Rust plainly compile OUT_DIR in (rather
20
- than through include!/include_str!/include_bytes!): Cargo never recompiles that path
21
- (fingerprint/dep_info.rs). This is a best-effort reading of the source, not a proof;
22
- - incremental caches.
23
-
24
- Cargo rebuilds what it then finds missing, and everything that depends on it. A path that
19
+ - every unit of a package whose crates read OUT_DIR while compiling, by rustc's dep-info record
20
+ of each env!/option_env! read in any spelling, and whose sources or generated Rust compile it
21
+ in other than through include!/include_str!/include_bytes!: Cargo never recompiles that path
22
+ (fingerprint/dep_info.rs). Telling an include from a compiled-in path is a best-effort
23
+ reading of the source, not a proof;
24
+ - incremental caches, final artifacts beside the unit directories, and outputs no fingerprint
25
+ claims.
26
+
27
+ Removal goes by unit hash across the layouts, so no Cargo command touches the clone. Cargo
28
+ rebuilds what it then finds missing, and everything that depends on it. A path that
25
29
  survives inside compiled output (a proc-macro's install name, debug maps, a native library or
26
30
  a crate that compiled a path in where the reading above does not see it) still names the
27
31
  donor's files: when the donor rebuilds or removes them, the recipient can change behavior
@@ -63,12 +67,15 @@ SNIFF_BYTES = 8192 # a NUL among the first bytes marks a binary file
63
67
  # a compiled-in path, and only the plain include!(concat!(env!("OUT_DIR"), ...)) forms, which
64
68
  # embed contents, offset one each; an env!/option_env! whose argument is not a literal counts too.
65
69
  _SPACE = r"(?:\s|//[^\n]*|/\*[\s\S]*?\*/)*"
66
- # Every quote may start a literal (a lookahead, so candidates overlap); its escapes are decoded.
67
- STRING_CANDIDATE = re.compile(r'(?="((?:[^"\\]|\\[\s\S])*?)")')
70
+ # A literal that can decode to OUT_DIR holds only its letters, \x and \u{} escapes and string
71
+ # continuations; every quote may start one (a lookahead, so candidates overlap).
72
+ STRING_CANDIDATE = re.compile(r'"(?=((?:[OUTDIR_]|\\x[0-9a-fA-F]{2}|\\u\{[0-9a-fA-F_]+\}|\\\r?\n\s*)+)")')
68
73
  ESCAPE = re.compile(r'\\(?:x([0-9a-fA-F]{2})|u\{([0-9a-fA-F_]+)\}|\n\s*|(.))')
69
- NONLITERAL_ENV = re.compile(rf'(?<![A-Za-z0-9_])(?:option_)?env{_SPACE}!{_SPACE}[(\[{{]{_SPACE}(?!r?#*")')
74
+ NONLITERAL_ENV = re.compile(rf'env{_SPACE}!{_SPACE}[(\[{{]{_SPACE}(?!r?#*")')
75
+ # Both start with a literal word, which re scans for quickly; the word's left boundary is checked
76
+ # on each match (a lookbehind first would make re try every position).
70
77
  INCLUDED_OUT_DIR = re.compile(
71
- r'(?<![A-Za-z0-9_])include(?:_str|_bytes)?\s*!\s*\(\s*concat\s*!\s*\(\s*env\s*!\s*\(\s*"OUT_DIR"\s*\)')
78
+ r'include(?:_str|_bytes)?\s*!\s*\(\s*concat\s*!\s*\(\s*env\s*!\s*\(\s*"OUT_DIR"\s*\)')
72
79
  REDIRECTS = ("CARGO_TARGET_DIR", "CARGO_BUILD_TARGET_DIR", "CARGO_BUILD_BUILD_DIR")
73
80
  RUSTC_ENV = re.compile(r"^cargo::?rustc-env=[^=]+=")
74
81
  # Runs in a child so the time limit can stop a long clone; argv: source, target.
@@ -284,18 +291,20 @@ def publish(staging_parent, staging_name, target_parent, target_name):
284
291
 
285
292
  def check_clone(staging, deadline):
286
293
  """No Git data, no special file, and no link that leads out of the clone."""
287
- for directory, dirs, files in os.walk(staging):
294
+ pending = [staging]
295
+ while pending:
288
296
  deadline.left()
289
- for name in dirs + files:
290
- path = Path(directory, name)
291
- mode = path.lstat().st_mode
292
- if name.casefold() == ".git":
293
- raise Unsafe(f"{path} is Git data")
294
- if stat.S_ISLNK(mode):
295
- if not Path(os.path.realpath(path)).is_relative_to(staging):
296
- raise Unsafe(f"{path} links out of the clone")
297
- elif not (stat.S_ISREG(mode) or stat.S_ISDIR(mode)):
298
- raise Unsafe(f"{path} is not a regular file or directory")
297
+ with os.scandir(pending.pop()) as entries:
298
+ for item in entries:
299
+ if item.name.casefold() == ".git":
300
+ raise Unsafe(f"{item.path} is Git data")
301
+ if item.is_symlink():
302
+ if not Path(os.path.realpath(item.path)).is_relative_to(staging):
303
+ raise Unsafe(f"{item.path} links out of the clone")
304
+ elif item.is_dir(follow_symlinks=False):
305
+ pending.append(item.path)
306
+ elif not item.is_file(follow_symlinks=False):
307
+ raise Unsafe(f"{item.path} is not a regular file or directory")
299
308
 
300
309
 
301
310
  def mentions(data, roots):
@@ -311,7 +320,7 @@ def is_text(path):
311
320
  return b"\0" not in stream.read(SNIFF_BYTES)
312
321
 
313
322
 
314
- def foreign_runs(staging, layout, roots, deadline):
323
+ def foreign_runs(staging, layout, roots, readers, deadline):
315
324
  """Hashes of build-script runs whose kept results name a donor root Cargo does not rewrite."""
316
325
  found = set()
317
326
  for run_dir in (staging / layout / "build").iterdir():
@@ -338,7 +347,10 @@ def foreign_runs(staging, layout, roots, deadline):
338
347
  deadline.left()
339
348
  if is_text(path):
340
349
  data = path.read_bytes()
341
- foreign = mentions(data, needles) or (name.endswith(".rs") and compiles_path(data.decode(errors="replace")))
350
+ # Generated Rust is compiled only through an include, which reads OUT_DIR.
351
+ foreign = mentions(data, needles) or (
352
+ name.endswith(".rs") and UNIT.match(run_dir.name)["name"] in readers
353
+ and compiles_path(data.decode(errors="replace")))
342
354
  if foreign:
343
355
  break
344
356
  if foreign:
@@ -357,10 +369,20 @@ def decoded(literal):
357
369
  return ESCAPE.sub(one, literal)
358
370
 
359
371
 
372
+ def at_word(text, start, prefix=""):
373
+ """Whether text[start:] begins a word (or one led by `prefix`, as option_ leads env)."""
374
+ if prefix and text.endswith(prefix, 0, start):
375
+ start -= len(prefix)
376
+ return start == 0 or not (text[start - 1].isalnum() or text[start - 1] == "_")
377
+
378
+
360
379
  def compiles_path(text):
361
380
  """Whether Rust source may compile its OUT_DIR in other than through an include macro."""
362
- literals = sum(decoded(match) == "OUT_DIR" for match in STRING_CANDIDATE.findall(text))
363
- return literals > len(INCLUDED_OUT_DIR.findall(text)) or NONLITERAL_ENV.search(text) is not None
381
+ # Without OUT_DIR spelled out or a backslash, no literal can decode to it.
382
+ literals = (sum(decoded(match) == "OUT_DIR" for match in STRING_CANDIDATE.findall(text))
383
+ if "OUT_DIR" in text or "\\" in text else 0)
384
+ included = sum(at_word(text, m.start()) for m in INCLUDED_OUT_DIR.finditer(text)) if "OUT_DIR" in text else 0
385
+ return literals > included or any(at_word(text, m.start(), "option_") for m in NONLITERAL_ENV.finditer(text))
364
386
 
365
387
 
366
388
  def compiles_out_dir(package, deadline):
@@ -415,36 +437,61 @@ def remove_unclassified(staging, layout, deadline):
415
437
  remove(item, deadline)
416
438
 
417
439
 
440
+ def crates_reading_out_dir(staging, found, crates, deadline):
441
+ """The crates among these that may have read OUT_DIR while compiling, by rustc's dep-info.
442
+
443
+ rustc writes `# env-dep:OUT_DIR=...` into a unit's deps/<crate>-<hash>.d for every
444
+ env!/option_env! read of it, whatever the spelling or macro that made it. A crate counts as
445
+ not reading it only when every kept output of it has that file and none says so. Any
446
+ hashed output counts, with or without the `lib` prefix: rlibs, rmeta, dylibs, Windows DLLs
447
+ and static libraries, test executables.
448
+ """
449
+ reading, recorded, outputs = set(), {}, {}
450
+ for layout in found:
451
+ for item in (staging / layout / "deps").iterdir():
452
+ deadline.left()
453
+ if not (match := UNIT.match(item.name)):
454
+ continue
455
+ if item.name.endswith(".d"):
456
+ if match["name"] in crates:
457
+ recorded.setdefault(match["name"], set()).add(match["hash"])
458
+ if b"\n# env-dep:OUT_DIR=" in b"\n" + item.read_bytes():
459
+ reading.add(match["name"])
460
+ continue
461
+ stem = match["name"]
462
+ for crate in {stem, stem.removeprefix("lib")} & crates:
463
+ outputs.setdefault(crate, set()).add(match["hash"])
464
+ return reading | {crate for crate, hashes in outputs.items() if hashes - recorded.get(crate, set())}
465
+
466
+
418
467
  def sanitize(work, staging, found, roots, deadline):
419
468
  for layout in found:
420
469
  remove_unclassified(staging, layout, deadline)
421
- env = {**without_redirects(), "CARGO_TARGET_DIR": str(staging)}
422
- resolved = metadata(work, deadline, env=env, deps=True)
423
- if not (Path(resolved["target_directory"]).resolve() == Path(resolved["build_directory"]).resolve() == staging.resolve()):
424
- raise Unsafe("Cargo does not resolve the staging directory as both target and build directory")
470
+ resolved = metadata(work, deadline, env=without_redirects(), deps=True)
425
471
  names = {package["name"] for package in resolved["packages"]}
426
- path_packages = sorted({package["name"] for package in resolved["packages"] if package["source"] is None})
427
- out_dir_packages = sorted({package["name"] for package in resolved["packages"] if package["source"] is not None
428
- and any("custom-build" in t["kind"] for t in package["targets"])
429
- and compiles_out_dir(package, deadline)})
472
+ path_packages = {package["name"] for package in resolved["packages"] if package["source"] is None}
473
+ # Every version of a package counts on its own; a name is removed if any version must be.
474
+ scripted = [package for package in resolved["packages"]
475
+ if package["source"] is not None and any("custom-build" in t["kind"] for t in package["targets"])]
476
+ crates = [{t["name"].replace("-", "_") for t in package["targets"] if "custom-build" not in t["kind"]}
477
+ for package in scripted]
478
+ reading = crates_reading_out_dir(staging, found, set().union(*crates), deadline)
479
+ reader_packages = [package for package, own in zip(scripted, crates) if own & reading]
480
+ readers = {package["name"] for package in reader_packages}
481
+ out_dir_packages = {package["name"] for package in reader_packages if compiles_out_dir(package, deadline)}
430
482
  removed = set()
431
483
  for layout in found:
432
- orphans = {m["hash"] for item in (staging / layout / ".fingerprint").iterdir()
433
- if (m := UNIT.match(item.name)) and m["name"] not in names}
434
- foreign = foreign_runs(staging, layout, roots, deadline)
435
- removed |= {m["name"] for item in (staging / layout / ".fingerprint").iterdir()
436
- if (m := UNIT.match(item.name)) and m["hash"] in orphans | foreign}
437
- remove_units(staging, layout, orphans | foreign, deadline)
438
- clean = path_packages + out_dir_packages
439
- if clean:
440
- base = ["cargo", "clean", "--frozen", "--target-dir", str(staging), "--profile", "dev"]
441
- for name in clean:
442
- base += ["-p", name]
443
- run(base, cwd=work, deadline=deadline, env=env) # Without --target only the host layout's outputs go.
444
- triples = [arg for layout in found[1:] for arg in ("--target", layout.parts[0])]
445
- if triples:
446
- run([*base, *triples], cwd=work, deadline=deadline, env=env)
447
- return sorted(removed | set(clean))
484
+ units = {m["hash"]: m["name"] for item in (staging / layout / ".fingerprint").iterdir()
485
+ if (m := UNIT.match(item.name))}
486
+ drop = {digest for digest, name in units.items()
487
+ if name not in names or name in path_packages or name in out_dir_packages}
488
+ drop |= foreign_runs(staging, layout, roots, readers, deadline)
489
+ removed |= {units[digest] for digest in drop if digest in units}
490
+ # Outputs without a fingerprint belong to no unit Cargo would reuse.
491
+ drop |= {m["hash"] for sub in ("build", "deps", "examples") if (staging / layout / sub).is_dir()
492
+ for item in (staging / layout / sub).iterdir() if (m := UNIT.match(item.name)) and m["hash"] not in units}
493
+ remove_units(staging, layout, drop, deadline)
494
+ return sorted(removed)
448
495
 
449
496
 
450
497
  def warm_cargo(work, anchor, staging_root, deadline):
@@ -575,34 +622,24 @@ def self_test():
575
622
  return 0 if result.wasSuccessful() else 1
576
623
 
577
624
 
578
- # Fixtures use real Git and a fake `cargo` on PATH that answers `metadata` from a JSON file
579
- # and performs `clean -p` on the layout as Cargo names it; a filesystem without cloning
580
- # copies instead, in tests only, so every rule still runs there.
625
+ # Fixtures use real Git and a fake `cargo` on PATH that answers `metadata` from a JSON file;
626
+ # a filesystem without cloning copies instead, in tests only, so every rule still runs there.
581
627
  FAKE_CARGO = r"""#!/usr/bin/env python3
582
- import json, os, pathlib, shutil, sys, time
628
+ import json, os, pathlib, sys, time
583
629
  argv = sys.argv[1:]
584
630
  fixture = json.loads(pathlib.Path(os.environ["FAKE_CARGO"]).read_text())
585
631
  with open(os.environ["FAKE_CARGO_LOG"], "a") as log:
586
632
  log.write(json.dumps(argv) + "\n")
587
633
  time.sleep(fixture.get("sleep", 0))
588
634
  target = pathlib.Path(os.environ.get("CARGO_TARGET_DIR") or os.getcwd() + "/" + fixture.get("target", "target"))
589
- if argv[0] == "metadata":
590
- found = {"target_directory": str(target), "packages": fixture["packages"] if "--no-deps" not in argv else [],
591
- "metadata": fixture.get("workspace_metadata", {"devlyn": {"warm-build-dirs": True}})}
592
- if not fixture.get("old_cargo"):
593
- found["build_directory"] = fixture.get("build_directory", str(target))
594
- print(json.dumps(found))
595
- elif argv[0] == "clean":
596
- directory = pathlib.Path(argv[argv.index("--target-dir") + 1])
597
- names = [argv[i + 1] for i, a in enumerate(argv) if a == "-p"]
598
- triples = [argv[i + 1] for i, a in enumerate(argv) if a == "--target"]
599
- for layout in [directory / t / "debug" for t in triples] or [directory / "debug"]:
600
- for name in names:
601
- for sub in (".fingerprint", "build", "deps"):
602
- for item in (layout / sub).glob("*"):
603
- stem = item.name.split(".")[0].removeprefix("lib")
604
- if stem.rsplit("-", 1)[0].replace("_", "-") == name:
605
- shutil.rmtree(item) if item.is_dir() else item.unlink()
635
+ assert argv[0] == "metadata", argv # warming never runs another cargo command
636
+ found = {"target_directory": str(target), "packages": fixture["packages"] if "--no-deps" not in argv else [],
637
+ "metadata": fixture.get("workspace_metadata", {"devlyn": {"warm-build-dirs": True}})}
638
+ if not fixture.get("old_cargo"):
639
+ found["build_directory"] = fixture.get("build_directory", str(target))
640
+ print(json.dumps(found))
641
+ # The full resolve runs while warming, after the clone: the moment to race the destination.
642
+ if "--no-deps" not in argv:
606
643
  if fixture.get("swap") and not os.path.islink("build"):
607
644
  os.rename("build", "build-moved")
608
645
  os.symlink(fixture["swap"], "build")
@@ -669,20 +706,29 @@ class WarmTests(unittest.TestCase):
669
706
 
670
707
  def write_fixture(self, **extra):
671
708
  packages = [
672
- {"name": "app", "source": None, "manifest_path": str(self.work / "Cargo.toml"), "targets": [{"kind": ["lib"]}]},
673
- {"name": "serde", "source": "registry", "manifest_path": str(self.root / "registry/serde/Cargo.toml"), "targets": [{"kind": ["lib"]}]},
674
- {"name": "ring", "source": "registry", "manifest_path": str(self.root / "registry/ring/Cargo.toml"), "targets": [{"kind": ["lib"]}, {"kind": ["custom-build"]}]},
675
- {"name": "tauri", "source": "registry", "manifest_path": str(self.root / "registry/tauri/Cargo.toml"), "targets": [{"kind": ["lib"]}, {"kind": ["custom-build"]}]},
676
- {"name": "aws-lc-rs", "source": "registry", "manifest_path": str(self.root / "registry/aws-lc-rs/Cargo.toml"), "targets": [{"kind": ["lib"]}, {"kind": ["custom-build"]}]},
677
- {"name": "baked", "source": "registry", "manifest_path": str(self.root / "registry/baked/Cargo.toml"), "targets": [{"kind": ["lib"]}, {"kind": ["custom-build"]}]},
678
- {"name": "exporter", "source": "registry", "manifest_path": str(self.root / "registry/exporter/Cargo.toml"), "targets": [{"kind": ["lib"]}, {"kind": ["custom-build"]}]},
679
- {"name": "generator", "source": "registry", "manifest_path": str(self.root / "registry/generator/Cargo.toml"), "targets": [{"kind": ["lib"]}, {"kind": ["custom-build"]}]},
709
+ {"name": "app", "source": None, "manifest_path": str(self.work / "Cargo.toml"), "targets": [{"kind": ["lib"], "name": "LIB"}]},
710
+ {"name": "serde", "source": "registry", "manifest_path": str(self.root / "registry/serde/Cargo.toml"), "targets": [{"kind": ["lib"], "name": "LIB"}]},
711
+ {"name": "ring", "source": "registry", "manifest_path": str(self.root / "registry/ring/Cargo.toml"), "targets": [{"kind": ["lib"], "name": "LIB"}, {"kind": ["custom-build"], "name": "build-script-build"}]},
712
+ {"name": "tauri", "source": "registry", "manifest_path": str(self.root / "registry/tauri/Cargo.toml"), "targets": [{"kind": ["lib"], "name": "LIB"}, {"kind": ["custom-build"], "name": "build-script-build"}]},
713
+ {"name": "aws-lc-rs", "source": "registry", "manifest_path": str(self.root / "registry/aws-lc-rs/Cargo.toml"), "targets": [{"kind": ["lib"], "name": "LIB"}, {"kind": ["custom-build"], "name": "build-script-build"}]},
714
+ {"name": "baked", "source": "registry", "manifest_path": str(self.root / "registry/baked/Cargo.toml"), "targets": [{"kind": ["lib"], "name": "LIB"}, {"kind": ["custom-build"], "name": "build-script-build"}]},
715
+ {"name": "exporter", "source": "registry", "manifest_path": str(self.root / "registry/exporter/Cargo.toml"), "targets": [{"kind": ["lib"], "name": "LIB"}, {"kind": ["custom-build"], "name": "build-script-build"}]},
716
+ {"name": "generator", "source": "registry", "manifest_path": str(self.root / "registry/generator/Cargo.toml"), "targets": [{"kind": ["lib"], "name": "LIB"}, {"kind": ["custom-build"], "name": "build-script-build"}]},
717
+ {"name": "windowsdll", "source": "registry", "manifest_path": str(self.root / "registry/windowsdll/Cargo.toml"), "targets": [{"kind": ["dylib"], "name": "LIB"}, {"kind": ["custom-build"], "name": "build-script-build"}]},
718
+ # rustc left no dep-info for its library, so nothing proves it never read OUT_DIR.
719
+ {"name": "undocumented", "source": "registry", "manifest_path": str(self.root / "registry/undocumented/Cargo.toml"), "targets": [{"kind": ["lib"], "name": "LIB"}, {"kind": ["custom-build"], "name": "build-script-build"}]},
720
+ # Two versions of one package: only 1.0 compiles OUT_DIR in.
721
+ {"name": "dual", "source": "registry", "manifest_path": str(self.root / "registry/dual-1/Cargo.toml"), "targets": [{"kind": ["lib"], "name": "LIB"}, {"kind": ["custom-build"], "name": "build-script-build"}]},
722
+ {"name": "dual", "source": "registry", "manifest_path": str(self.root / "registry/dual-2/Cargo.toml"), "targets": [{"kind": ["lib"], "name": "LIB"}, {"kind": ["custom-build"], "name": "build-script-build"}]},
680
723
  ]
724
+ for package in packages:
725
+ package["targets"] = [{**t, "name": package["name"] if t["name"] == "LIB" else t["name"]} for t in package["targets"]]
681
726
  for package in packages[1:]:
682
727
  source = Path(package["manifest_path"]).parent / "src"
683
728
  source.mkdir(parents=True, exist_ok=True)
684
- (source / "lib.rs").write_text('include!(concat!(\n env!("OUT_DIR"), "/gen.rs"));\n' if package["name"] != "baked"
685
- else 'pub const DIR: &str = env!(/* why */ "OUT_DIR");\n')
729
+ compiled = Path(package["manifest_path"]).parent.name in {"baked", "undocumented", "dual-1", "windowsdll"}
730
+ (source / "lib.rs").write_text('pub const DIR: &str = env!(/* why */ "OUT_DIR");\n' if compiled
731
+ else 'include!(concat!(\n env!("OUT_DIR"), "/gen.rs"));\n')
686
732
  self.fixture.write_text(json.dumps({"packages": packages, **extra}))
687
733
 
688
734
  def unit(self, layout, sub, name, digest, files=None):
@@ -706,6 +752,24 @@ class WarmTests(unittest.TestCase):
706
752
  self.unit(layout, ".fingerprint", name, digest)
707
753
  (deps / f"lib{name}-{digest}.rlib").write_bytes(b"rlib")
708
754
  (self.donor / layout / ".cargo-lock").write_bytes(b"")
755
+ # rustc's dep-info: which crates read OUT_DIR while compiling.
756
+ for crate, digest, reads in (("serde", "1" * 16, False), ("baked", "8" * 16, True), ("generator", "b" * 16, True),
757
+ ("ring", "c" * 16, False), ("dual", "1a" * 8, True), ("dual", "2b" * 8, True)):
758
+ record = f"# env-dep:OUT_DIR={self.donor}/{layout}/build/{crate}-x/out\n" if reads else ""
759
+ (deps / f"{crate}-{digest}.d").write_text(f"/src/{crate}/lib.rs:\n\n# env-dep:CARGO_PKG_NAME={crate}\n{record}")
760
+ self.unit(layout, ".fingerprint", crate, digest)
761
+ (deps / "libstray-dddddddddddddddd.rlib").write_bytes(b"rlib")
762
+ self.unit(layout, ".fingerprint", "undocumented", "e" * 16)
763
+ (deps / f"libundocumented-{'e' * 16}.rlib").write_bytes(b"rlib")
764
+ # A Windows-style DLL (no `lib` prefix) without dep-info, of a crate that compiles OUT_DIR in.
765
+ self.unit(layout, ".fingerprint", "windowsdll", "9a" * 8)
766
+ (deps / f"windowsdll-{'9a' * 8}.dll").write_bytes(b"dll")
767
+ for digest in ("1a" * 8, "2b" * 8):
768
+ (deps / f"libdual-{digest}.rlib").write_bytes(b"rlib")
769
+ # A run that points into the donor and has no fingerprint left.
770
+ self.unit(layout, "build", "orphanrun", "f" * 16, {
771
+ "root-output": f"{self.donor}/{layout}/build/orphanrun-{'f' * 16}/out".encode(),
772
+ "output": f"cargo:include={self.donor}/{layout}/build/aws-lc-sys-{'6' * 16}/out\n".encode()})
709
773
  (self.donor / layout / "incremental" / "app-x").mkdir(parents=True)
710
774
  # Final artifacts Cargo puts beside the unit directories, here of the unused `gone`.
711
775
  (self.donor / layout / "old-tool").write_bytes(b"bin")
@@ -743,11 +807,18 @@ class WarmTests(unittest.TestCase):
743
807
  target = self.work / "target"
744
808
  for layout in ("debug", "x86_64-pc-windows-msvc/debug"):
745
809
  kept = {p.name for p in (target / layout / ".fingerprint").iterdir()}
746
- self.assertEqual({f"serde-{'1' * 16}", f"ring-{'4' * 16}"}, kept, layout)
810
+ # generator keeps its library unit: Cargo rebuilds it after rerunning the removed run.
811
+ self.assertEqual({f"serde-{'1' * 16}", f"ring-{'4' * 16}", f"ring-{'c' * 16}", f"generator-{'b' * 16}"}, kept, layout)
812
+ self.assertFalse((target / layout / "deps" / "libstray-dddddddddddddddd.rlib").exists())
747
813
  self.assertEqual({".fingerprint", "build", "deps", "examples", ".cargo-lock"}, {p.name for p in (target / layout).iterdir()})
748
814
  self.assertEqual([], list((target / layout / "examples").iterdir()))
749
815
  self.assertTrue((target / layout / "build" / f"ring-{'4' * 16}" / "out" / "libring.a").exists())
750
- self.assertEqual(["app", "aws-lc-rs", "baked", "exporter", "generator", "gone", "tauri"], result["removed"])
816
+ self.assertEqual(["app", "aws-lc-rs", "baked", "dual", "exporter", "generator", "gone", "tauri", "undocumented",
817
+ "windowsdll"],
818
+ result["removed"])
819
+ for layout in ("debug", "x86_64-pc-windows-msvc/debug"):
820
+ self.assertFalse((target / layout / "build" / f"orphanrun-{'f' * 16}").exists())
821
+ self.assertFalse(any((target / layout / "deps").glob("libdual-*")))
751
822
  self.assertEqual(str(self.donor), result["donor"])
752
823
  self.assertEqual(before, self.snapshot(self.donor))
753
824
  self.assert_nothing_left()
@@ -876,7 +947,7 @@ class WarmTests(unittest.TestCase):
876
947
  'option_env!("OUT_DIR")', 'myinclude!(concat!(env!("OUT_DIR"), "/a.rs"))', 'env![ "OUT_DIR" ]',
877
948
  'env/**/!("OUT_DIR")', 'env!(/* why */ "OUT_DIR")', 'env!("OUT\\x5fDIR")', 'env!("\\u{4f}UT_DIR")',
878
949
  'use std::env as e; e!("OUT_DIR")', 'env!(concat!("OUT", "_DIR"))', 'env!(// note\n"OUT_DIR")',
879
- 'env!("\\u{4_f}UT_DIR")', 'env!("OUT_\\\n DIR")', 'let a = \'"\'; env!("OUT_DIR")',
950
+ 'env!("\\u{4_f}UT_DIR")', 'env!("\\u{0_0_0_0_4_f}UT_DIR")', 'env!("OUT_\\\n DIR")', 'let a = \'"\'; env!("OUT_DIR")',
880
951
  'include!(concat!(env!(/* c */ "OUT_DIR"), "/a.rs"))']
881
952
  for text in compiled:
882
953
  self.assertTrue(compiles_path(text), text)
@@ -94,10 +94,14 @@ A removed flag stops with its instruction and selects no other behavior.
94
94
 
95
95
  An absolute path to a package's `meta.md` is added as it is; anything else is planned first. Add with `python3 "$DEVLYN_SKILL_DIR/scripts/queue.py" add '<absolute meta.md>'`, the only writer of a loop's queue file `docs/specs/<loop-id>/queue.md`; never edit one by hand. In every delivery mode add makes no commit on the checked-out branch and leaves the branch, the index and tracked files as they are: it holds the package and its rows at `refs/devlyn/captures/<loop-id>`, records the add and removes the package copies equal to that capture. Report the capture ref and its `git show` command, any `cleanup` entry, and that a revision needs a new loop id. A retry of the same add reports it as added, also once the copies are gone. Tasks start from committed state, a local loop from the branch it was added on and `auto`/`pr` from the remote base, so commit, and for `auto`/`pr` push, whatever the plan depends on. A pending legacy raw-intent row of `docs/specs/queue.md` is replaced in place by a package whose `## Intent` reproduces the row verbatim, added with `--materialize <line>`.
96
96
 
97
+ Release-backed queues retain their verified release in `<git-common-dir>/devlyn-loops/harness.json` until all tasks, recovery and delivery settle. A different release refuses with the exact recovery argv; use it. Existing unfinished unbound queues and standalone installations remain legacy.
98
+
97
99
  ## status
98
100
 
99
101
  Run `python3 "$DEVLYN_SKILL_DIR/scripts/queue.py" status --repo .` and report its counts, `next`, `blockers`, `warnings`, pending deliveries with their resume commands and any `cleanup` entry.
100
102
 
103
+ For read-only consumers, `python3 "$DEVLYN_SKILL_DIR/scripts/queue.py" inspect --repo . --json` returns the status summary plus `binding` and `compatibility`, without cleanup, locks or writes.
104
+
101
105
  ## drain
102
106
 
103
107
  `status`, `add` and `drain` write the repository's Git directory, and auto/pr delivery needs network access, so a sandboxed host runs them with escalation or full access.
@@ -112,7 +116,7 @@ Follow [loop.md](references/loop.md). The executor is the configured route, the
112
116
  Another engine needs an argv that starts one fresh non-interactive session able, without prompts, to edit, run commands and commit in its task worktree and to write the packet's submission file. `<prompt>` carries the absolute binding values, while `{packet}` stays literal for the drain to fill:
113
117
 
114
118
  ```text
115
- Execute the devlyn loop task packet {packet}: work only in its owned worktree under the installed instructions; meet its obligations, then write its submission and any review records as "Executor exchange" and "Review records" in <DEVLYN_SKILL_DIR>/references/loop.md specify. Set DEVLYN_SKILL_DIR=<DEVLYN_SKILL_DIR> and DEVLYN_SHARED_DIR=<DEVLYN_SHARED_DIR> from these literal values, never from your inherited environment. Never push, open a pull request or merge: the drain delivers.
119
+ Execute the devlyn loop task packet {packet}: read its workerPrompt when present; read methodology.defaults.path when named and apply those verified defaults, with explicit user and native owner/project instructions taking precedence; work only in its owned worktree under the installed instructions; meet its obligations, then write its submission and any review records as "Executor exchange" and "Review records" in <DEVLYN_SKILL_DIR>/references/loop.md specify. Use the literal DEVLYN_SKILL_DIR and DEVLYN_SHARED_DIR bindings in workerPrompt when present; otherwise set DEVLYN_SKILL_DIR=<DEVLYN_SKILL_DIR> and DEVLYN_SHARED_DIR=<DEVLYN_SHARED_DIR> from these literal values. Never use inherited environment values. Never push, open a pull request or merge: the drain delivers.
116
120
  ```
117
121
 
118
122
  ```sh
@@ -28,7 +28,7 @@ The host supplies the executor and maps its configured engine to the argv; the l
28
28
  - Local: the first task uses `--local-base <HEAD of the branch the loop was added on>`, the add record's branch, never whichever branch is checked out; that HEAD must descend from the manifest `base_sha`, or the task waits naming it. Instructions committed there before this first allocation are therefore used. Its receipt keeps that start, and a later task with no accepted predecessor reuses it, also after the first task failed; a fixed start is never recalculated. Later tasks use `--from-receipt <latest accepted receipt of the loop>`, which starts from that receipt's exact `source_sha`, never its terminal commit, current HEAD or workspace. Every prerequisite's accepted source must be an ancestor of that frontier; divergent dependencies need a planned integration task, because the driver never merges. A failure leaves the frontier unchanged.
29
29
  - `auto`/`pr`: `--start` the refreshed remote base, never an anchor-local commit, so no unpushed anchor commit reaches a task PR; push whatever the plan depends on. A task allocated while that base lacks `docs/specs/<loop-id>/` is the loop's carrier; a carrier that fails is never published, and the next task allocated becomes the carrier. The start must contain each prerequisite's merge commit, checked before allocation and again before every execution or resume. A squash merge needs no original-source ancestry. A refresh failure waits for restored remote access or the base ref. A missing merge before allocation waits for that merge to be restored on the remote base; an allocated baseline is immutable and needs a new loop or verified recovery of a never-executed allocation.
30
30
  4. **Commit scoped inputs** on the owned branch before any executor write, only what the allocation base lacks. A base without `docs/specs/<loop-id>/` gets the package directory from the capture with the loop's queue file, its settled rows keeping their marks (receipt-proven `[x]` and `[F]`, prerequisite-blocked `[F]`): a local loop's first task, or an `auto`/`pr` carrier, whose PR lands the package before tasks diverge. Otherwise a local task commits the base's queue file with the loop's receipt-proven terminal rows (typically a predecessor's `[x]`), every other byte unchanged, and an `auto`/`pr` task commits nothing, so its PR changes only its own row and its product. A base that already carries them gets no inputs commit; unrelated product commits stay out.
31
- 5. **Exchange one packet.** Write `<receipt dir>/packet.json`, then run the executor. An executor that exits without writing its submission leaves its task active, waiting with the exit code and `executor.stderr`, and the next drain runs it again; one that cannot start keeps its allocation, packet and durable start/not-started log, waits for executable, permissions or workspace repair, and retries on the next drain.
31
+ 5. **Exchange one packet.** Verify the bound release, defaults, packet and worker prompt before execution or recovery; changed or missing resources refuse dispatch. Write `<receipt dir>/packet.json`, then run the executor. An executor that exits without writing its submission leaves its task active, waiting with the exit code and `executor.stderr`, and the next drain runs it again; one that cannot start keeps its allocation, packet and durable start/not-started log, waits for executable, permissions or workspace repair, and retries on the next drain.
32
32
  6. **Derive acceptance** with `acceptance.py accept`. It binds the committed contract to the packet digests; requires the candidate to be the owned branch head with HEAD checked out on that branch, to descend from the inputs, to leave the package (its queue file included) and `docs/specs/queue.md` untouched and to have a clean worktree; executes each declared command on that source, recording raw stdout/stderr and exit, timeout or spawn outcomes (a runner result is reused only when it is wholly clean — no reasons, so no failed check and no source change during its checks — for the same source and contract digests, with unchanged streams); evaluates the file and diff guards against the inputs; confirms the source is unchanged; checks review records; and writes `.devlyn/loop/acceptance.json`. Missing checks, missing review coverage and open binding findings cannot produce `[x]`, and acceptance weakening is not repair. This establishes command outcomes and evidence completeness, not a reviewer's semantic judgment; deliberate evidence forgery is outside the trust model.
33
33
  7. **Bind before terminal metadata.** `task-complete.py accept --receipt <receipt> --acceptance <worktree>/.devlyn/loop/acceptance.json` takes evidence custody and points the recovery ref at the source. Failed results are bound the same way; they never become a frontier or a publishable product.
34
34
  8. **Commit the terminal transition** under the queue lock: one commit whose sole parent is the bound source and whose only change is this task's row in `docs/specs/<loop-id>/queue.md` (`[x]`, or `[F] — <first reason> (receipt <id>)`), validated as the only legal transition. Then `task-complete.py attach --receipt <receipt> --commit <terminal> --file docs/specs/<loop-id>/queue.md` records it separately from the source and moves the recovery ref to it.
@@ -53,6 +53,14 @@ Expected task-local operational failures leave the selected task waiting with `<
53
53
 
54
54
  Package cleanup, parent pruning and report failures appear as diagnostics under `cleanup`, with retained paths and repair guidance; they do not change product acceptance or completed delivery. The next drain retries them.
55
55
 
56
+ ## Release binding and read-only consumers
57
+
58
+ `<git-common-dir>/devlyn-loops/harness.json` is authoritative across linked worktrees: `{schemaVersion: 1, release: {package, version, digest, root}, defaults: {path, sha256}}`. Paths are absolute and hashes cover exact bytes. The queue lock covers binding before the first capture. An interrupted first add, unfinished tasks, recovery conflicts and pending delivery retain it; settlement removes it. A different release refuses with `recovery argv` as a JSON argv array, preserving the caller’s arguments. No caller substitutes another implementation.
59
+
60
+ `queue.py inspect --repo <dir> --json` reads without cleanup, locks or writes. It returns the status summary plus `binding` (null for legacy) and `compatibility`: `compatible`, `legacy`, `reasons`, `recoveryArgv`, `taskPacket` ranges and `queueBinding` ranges. Incompatible or unreadable state is reported, never migrated. Apps should use this reader instead of `status`, which still reconciles cleanup. For a mismatch, `recoveryArgv` names the bound queue’s inspect invocation; use the same bound queue entry point for authorized add/drain operations.
61
+
62
+ Codex projection writers place `.devlyn-harness-projection.json` at the copied plugin root, beside `skills` and `.codex-plugin`: `{schemaVersion: 1, release: <ReleaseRef>, source: "native/codex/plugins/devlyn"}`. The reader verifies the named release and every projected file’s bytes/mode against that subtree (excluding only the marker). Symlinked skill invocations resolve through their real path. Standalone installs and existing unfinished unbound queues retain legacy behavior; new legacy packets use v2 with a null release, and their `defaults` name the invoking installation's own `_shared/runtime-principles.md` (`source: "skill"`). No engine applicability is inferred from instruction filenames: bound queues use verified release defaults with native instructions taking precedence. Worker and runner paths point to the bound release, independent of a projection cache’s lifetime.
63
+
56
64
  ## Executor exchange
57
65
 
58
66
  The packet (`<receipt dir>/packet.json`) carries everything a fresh executor needs:
@@ -66,7 +74,9 @@ The packet (`<receipt dir>/packet.json`) carries everything a fresh executor nee
66
74
  | `requirements`, `review_requirements` | Requirement IDs and those needing review evidence. |
67
75
  | `shared_constraints` | The meta-prompt's `Constraints and exclusions`. |
68
76
  | `dependencies` | `{task, receipt, source_sha}` of each accepted prerequisite. |
69
- | `methodology` | `{path, sha256}` of the installed instruction files at the inputs commit. |
77
+ | `schema_version`, `harness` | New packets use version 2 and the bound release reference, or null for legacy unbound queues. Existing v1 packets execute unchanged. |
78
+ | `methodology` | `projectInstructions`: `{path, sha256}` of instruction bytes at the inputs commit. `defaults`: `{source: "release", path, sha256}` from the verified binding; for an unbound queue `{source: "skill", path, sha256}` of the installation's own `_shared/runtime-principles.md`. A task whose executor would get no principles at all (no defaults and no committed CLAUDE.md/AGENTS.md) waits with that reason instead of running. Read the named defaults; explicit user and native owner/project instructions take precedence. No native engine applicability is inferred from filenames. |
79
+ | `workerPrompt` | `{path, sha256}` of the task’s worker instructions, or null for legacy unbound queues. Read it before working. |
70
80
  | `delivery` | `local-only`, `auto` or `pr`. |
71
81
  | `evidence_dir`, `runner` | Where loop evidence lives (`<worktree>/.devlyn/loop`, ignored by Git) and the runner argv. |
72
82
  | `submission`, `obligations` | Where to write the submission, and the completion obligations. |
@@ -0,0 +1,12 @@
1
+ # Planning and capture only
2
+
3
+ This reference grants plan and add operations only. Never drain, implement, run an executor or deliver code from this planning session.
4
+
5
+ Resolve this reference’s actual containing skill directory (the parent of `references`), resolving symlinks; bind it as `DEVLYN_SKILL_DIR`. Resolve its sibling `_shared` as `DEVLYN_SHARED_DIR`. Verify the loaded files; missing or conflicting source identity is `BLOCKED:skill-source-unresolved`. Never substitute another installation or inherited environment values.
6
+
7
+ 1. Inspect project facts and follow [elicitation.md](elicitation.md). Ask only when the unresolved answer changes authorized behavior, scope, data semantics, acceptance or delivery. Existing authorization remains effective. Under autonomous planning, use conventional, reversible defaults for ordinary details and record each assumption; material ambiguity stops affected work as needs-review with a concrete question. Independent authorized planning may continue.
8
+ 2. Write `docs/specs/<loop-id>/meta.md` and self-contained task contracts per [package-format.md](package-format.md). Preserve an input document unchanged and carry every substantive requirement into the package. Use this execution policy in the package: for ordinary details within authorized scope, follow established project conventions, otherwise the narrowest literal or conventional reading consistent with the request; choices must be low-consequence and reversible, recorded with their reasons. Material ambiguity means competing intended outcomes, unresolved persistent data/state semantics or public surface beyond the request; stop affected work as needs-review with a concrete question. Independent authorized work may continue. Never omit requested work or weaken acceptance. Set delivery from explicit restrictions or the project completion policy; without one GitHub origin, record local-only as an assumption, or ask when the user explicitly requested a PR/merge.
9
+ 3. Run `python3 "<DEVLYN_SKILL_DIR>/scripts/queue.py" check '<absolute meta.md>'` and repair until `VALID`. Plan stops here: report the path, tasks, dependencies and assumptions.
10
+ 4. When add is authorized, run `python3 "<DEVLYN_SKILL_DIR>/scripts/queue.py" add '<absolute meta.md>'`. It alone writes queue state, captures without committing on the checked-out branch and removes matching untracked package copies. Report the capture ref, its `git show` command and cleanup diagnostics. Revisions need a new loop id. A legacy pending row is materialized with `--materialize <line>` and its verbatim intent in the package.
11
+
12
+ Report capture and stop. A separate authorized execution session owns drain.
@@ -89,6 +89,11 @@ def load_packet(path):
89
89
  packet = read_json(path)
90
90
  if not isinstance(packet, dict) or PACKET_KEYS - set(packet):
91
91
  raise AcceptanceError(f"{path}: task packet lacks {sorted(PACKET_KEYS - set(packet if isinstance(packet, dict) else {}))}")
92
+ helper = shared("harness-store")
93
+ try:
94
+ helper["verify_packet"](packet, path, SKILLS)
95
+ except (helper["HarnessError"], OSError) as exc:
96
+ raise AcceptanceError(str(exc)) from exc
92
97
  return packet
93
98
 
94
99