devlyn-cli 4.2.3 → 4.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/devlyn.js CHANGED
@@ -880,6 +880,7 @@ function install(targets, global, optionalNames = []) {
880
880
  else installClaudeCore();
881
881
  }
882
882
  log(`\n✅ devlyn ${PKG.version} installed`, 'green');
883
+ if (global) log('The principles block installs per project: run npx devlyn-cli in the project without --global.');
883
884
  noticeGlobalDrift(roots);
884
885
  return roots;
885
886
  }
@@ -1,9 +1,11 @@
1
1
  {
2
- "source": "devlyn-cli@4.1.0 (npm tarball sha1 398ff07d3d689b56cc22e18ac50260bc6e4e9e85); devlyn-cli@4.2.0 (npm tarball sha1 c93c2fb1cf5630b18d2eba0085182c14fddbe2d5); whole-tree fingerprints from bin/skill-ownership.js; deprecated commands: commands/ and optional-commands/*/ files of devlyn-cli 0.0.1-0.5.8 (every published version that shipped them), LF-normalized SHA-256",
2
+ "source": "devlyn-cli@4.1.0 (npm tarball sha1 398ff07d3d689b56cc22e18ac50260bc6e4e9e85); devlyn-cli@4.2.0 (npm tarball sha1 c93c2fb1cf5630b18d2eba0085182c14fddbe2d5); devlyn-cli@4.2.4 and 4.2.5 (_shared and devlyn-ideate, fingerprinted from an install of each packed tarball); whole-tree fingerprints from bin/skill-ownership.js; deprecated commands: commands/ and optional-commands/*/ files of devlyn-cli 0.0.1-0.5.8 (every published version that shipped them), LF-normalized SHA-256",
3
3
  "skills": {
4
4
  "_shared": [
5
5
  "eb3327d5e6940bd5e0459fa28c9ab323d9c58ccfdeb9426c19cd027cc4e5c4ed",
6
- "8c80926ab03b2477e52264495b35c2116f65fc51b02e8c56e441b5705872392a"
6
+ "8c80926ab03b2477e52264495b35c2116f65fc51b02e8c56e441b5705872392a",
7
+ "3c81c575cc43d70488865b440204cb399091405d9b7f81dbf047a32d3d5ac045",
8
+ "6546d6b833701f2641a004802cfc17ec62442e5d0003f396cb0231b676ba9a4a"
7
9
  ],
8
10
  "devlyn-design-ui": [
9
11
  "91080266232fb62b6f1d45932184f065589e12eb66f02fb6442527c6dfcd81b6"
@@ -14,7 +16,9 @@
14
16
  ],
15
17
  "devlyn-ideate": [
16
18
  "8ff5148cc65b4608c35b29afd6fef20d42d8da93a094144d98cfc63e74089b6e",
17
- "5824f07e455fe1c3942bf595a12e935258d4c0a851ca1bf741db915603d1c4e0"
19
+ "5824f07e455fe1c3942bf595a12e935258d4c0a851ca1bf741db915603d1c4e0",
20
+ "7f451e48e391643b34f6249244bc47d93d6303875e7b092771e3af0718343bc1",
21
+ "d6433bf0f96dd9dd485c3623885255738654674b3e23cfcc1a2cfd24f3cda96e"
18
22
  ],
19
23
  "devlyn-queue": [
20
24
  "4c7d988dcd154c7bf658d2d24cd7868955df9d854ed87eb784742d6d4bb229e0"
@@ -102,7 +102,8 @@ def resolve(work, default_engine, *, available=None, shared=SHARED):
102
102
  """The project's executor pin, else the invoking CLI; status only, never a dispatch decision."""
103
103
  config, source = read_config(Path(work) / ".devlyn/engines.json", optional=True, shared=shared)
104
104
  engine = config.get("executor", default_engine)
105
- adapter(engine, shared=shared)
105
+ # read_config validates pins; the invoking host needs no adapter to report its own default.
106
+ name(engine, "engine")
106
107
  available = available or (lambda candidate: shutil.which(candidate) is not None)
107
108
  return {"executor": {"engine": engine, "source": "engines.json" if "executor" in config else "default",
108
109
  "availability": "CLI-present/auth-unchecked" if available(engine) else "CLI-unavailable"},
@@ -113,6 +114,7 @@ def select(work, default_engine, *, available=None, shared=SHARED):
113
114
  """The executor for dispatch. A pin is a promise: an unavailable pinned engine is never substituted."""
114
115
  result = resolve(work, default_engine, available=available, shared=shared)
115
116
  executor = result["executor"]
117
+ adapter(executor["engine"], shared=shared)
116
118
  if executor["source"] == "engines.json" and executor["availability"] == "CLI-unavailable":
117
119
  fail(f"pinned executor {executor['engine']} is unavailable; install and authenticate its CLI, "
118
120
  f"verify `{executor['engine']} --version`, and retry, or pin an available engine with `devlyn-engines executor <name>`",
@@ -22,6 +22,7 @@ import subprocess
22
22
  import sys
23
23
  import tempfile
24
24
  import unittest
25
+ import uuid
25
26
 
26
27
 
27
28
  class CompletionError(Exception):
@@ -122,21 +123,22 @@ def verify_files(root, files):
122
123
 
123
124
 
124
125
  def custody(work, destination, files):
125
- destination.mkdir(exist_ok=True)
126
- for relative, expected in files.items():
127
- source = safe_path(work, relative)
128
- target = safe_path(destination, relative)
129
- target.parent.mkdir(parents=True, exist_ok=True)
130
- if not target.exists():
131
- # Interrupted partial copies are diagnosed, never silently overwritten.
126
+ # Publish only a complete copy. A hard crash's private staging directory is never reused.
127
+ with tempfile.TemporaryDirectory(prefix=".custody-", dir=destination.parent) as temporary:
128
+ root = Path(temporary)
129
+ for relative, expected in files.items():
130
+ source = safe_path(work, relative)
131
+ target = safe_path(root, relative)
132
+ target.parent.mkdir(parents=True, exist_ok=True)
132
133
  with source.open("rb") as incoming, target.open("xb") as outgoing:
133
134
  shutil.copyfileobj(incoming, outgoing)
134
135
  outgoing.flush()
135
136
  os.fsync(outgoing.fileno())
136
137
  target.chmod(expected["mode"])
137
- require(file_record(target) == expected, f"custody copy mismatch; retain original: {relative}")
138
- verify_files(work, files)
139
- verify_files(destination, files)
138
+ require(file_record(target) == expected, f"custody copy mismatch; retain original: {relative}")
139
+ verify_files(work, files)
140
+ verify_files(root, files)
141
+ root.rename(destination)
140
142
  atomic_json(destination.parent / "manifest.json", files)
141
143
 
142
144
 
@@ -309,7 +311,12 @@ def bind_acceptance(receipt, path, supplied):
309
311
  else:
310
312
  raise CompletionError("acceptance kind must be direct|loop")
311
313
  files = snapshot_files(work, paths)
312
- custody(work, path.parent / "custody", files)
314
+ destination = safe_path(path.parent, "custody")
315
+ if destination.exists():
316
+ # An unbound copy is never authoritative; retain it like an orphan staging directory.
317
+ require(destination.is_dir(), "custody is not a directory; retain and inspect")
318
+ destination.rename(path.parent / (".custody-" + uuid.uuid4().hex))
319
+ custody(work, destination, files)
313
320
  recovery = ref_sha(receipt, receipt["recovery_ref"])
314
321
  require(recovery in {None, sha}, "recovery ref changed")
315
322
  if recovery is None:
@@ -368,9 +375,14 @@ def gh(receipt, *args):
368
375
  return command(["gh", *args, "--repo", "github.com/"+receipt["repository"]])
369
376
 
370
377
 
378
+ def repository_info(repository):
379
+ info = json.loads(command(["gh", "repo", "view", "github.com/"+repository, "--json", "nameWithOwner,url,defaultBranchRef,mergeCommitAllowed"]))
380
+ require(info["nameWithOwner"].lower() == repository.lower() and info["url"].lower() == "https://github.com/"+repository.lower(), "GitHub repository identity changed")
381
+ return info
382
+
383
+
371
384
  def repo_policy(receipt):
372
- info = json.loads(command(["gh", "repo", "view", "github.com/"+receipt["repository"], "--json", "nameWithOwner,url,defaultBranchRef,mergeCommitAllowed"]))
373
- require(info["nameWithOwner"].lower() == receipt["repository"].lower() and info["url"].lower() == "https://github.com/"+receipt["repository"].lower(), "GitHub repository identity changed")
385
+ info = repository_info(receipt["repository"])
374
386
  require(info["defaultBranchRef"]["name"] == receipt["base"] and receipt["branch"] != info["defaultBranchRef"]["name"], "base/default branch changed; retain resources")
375
387
  return info
376
388
 
@@ -792,7 +804,7 @@ def main():
792
804
  attachment = actions.add_parser("attach")
793
805
  attachment.add_argument("--receipt", required=True)
794
806
  attachment.add_argument("--commit", required=True)
795
- attachment.add_argument("--file", default="docs/specs/queue.md")
807
+ attachment.add_argument("--file", required=True)
796
808
  completion = actions.add_parser("complete")
797
809
  completion.add_argument("--receipt", required=True)
798
810
  completion.add_argument("--acceptance")
@@ -1793,21 +1805,37 @@ class CompletionTests(unittest.TestCase):
1793
1805
 
1794
1806
  def test_custody_failure_and_evidence_tamper(self):
1795
1807
  self.allocate(); self.accept()
1808
+ self.cli("accept", "--receipt", self.receipt, "--acceptance", self.acceptance)
1796
1809
  bad = self.receipt.parent / "custody/.devlyn/checks.txt"
1797
- bad.parent.mkdir(parents=True)
1798
- bad.write_text("partial interrupted copy", encoding="utf-8")
1810
+ bad.write_text("corrupted bound copy", encoding="utf-8")
1799
1811
  _, r = self.complete("--writers-stopped", success=False)
1800
1812
  self.assertNotEqual(r.returncode, 0)
1801
1813
  self.assertTrue(self.task.exists())
1802
1814
  self.assertEqual((self.task / ".devlyn/checks.txt").read_text(encoding="utf-8"), "actual fixture check: accepted bytes\n")
1803
1815
  self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture"), "")
1804
- bad.unlink() # Owner repairs only the identified corrupt fixture copy.
1816
+ shutil.copy2(self.task / ".devlyn/checks.txt", bad) # Repair only the identified corrupt fixture copy.
1805
1817
  self.complete("--mode", "pr")
1806
1818
  (self.task / ".devlyn/checks.txt").write_text("altered after acceptance", encoding="utf-8")
1807
1819
  _, r = self.complete("--mode", "auto", success=False)
1808
1820
  self.assertNotEqual(r.returncode, 0)
1809
1821
  self.assertEqual(json.loads(self.data.read_text(encoding="utf-8")).get("merges",0), 0)
1810
1822
 
1823
+ def test_unbound_partial_custody_is_retained_through_cleanup(self):
1824
+ self.allocate(); self.accept()
1825
+ partial = self.receipt.parent / "custody/.devlyn/checks.txt"
1826
+ partial.parent.mkdir(parents=True)
1827
+ partial.write_text("partial interrupted copy", encoding="utf-8")
1828
+ original = file_record(partial)
1829
+ result, _ = self.complete("--writers-stopped", "--mode", "auto")
1830
+ self.assertEqual(result["status"], "COMPLETE")
1831
+ self.assertEqual(result["scratch_cleanup"]["status"], "CLEAN")
1832
+ self.assertFalse(self.task.exists())
1833
+ retained = list(self.receipt.parent.glob(".custody-*"))
1834
+ self.assertEqual(len(retained), 1)
1835
+ self.assertEqual(file_record(retained[0] / ".devlyn/checks.txt"), original)
1836
+ self.assertEqual(self.complete(acceptance=False)[0]["status"], "COMPLETE")
1837
+ self.assertEqual(file_record(retained[0] / ".devlyn/checks.txt"), original)
1838
+
1811
1839
  def test_actual_foreign_writer_and_registration(self):
1812
1840
  # H4. Prediction: a process still using the merged task's tree settles the delivery COMPLETE with the workspace
1813
1841
  # retained (CLEANUP_PENDING) and the process named, as unobservable writers do. Before: complete ended BLOCKED
@@ -2118,6 +2146,12 @@ class CompletionTests(unittest.TestCase):
2118
2146
  result, _ = self.cli("accept", "--receipt", self.receipt, "--acceptance", acceptance, success=False)
2119
2147
  self.assertEqual(result["status"], "FAILED" if verdict == "FAILED" else "BLOCKED", result)
2120
2148
 
2149
+ def test_attach_requires_an_explicit_queue_file(self):
2150
+ result = self.run_cmd([sys.executable, str(Path(__file__).resolve()), 'attach',
2151
+ '--receipt', str(self.root / 'absent.json'), '--commit', 'HEAD'], success=False)
2152
+ self.assertEqual(result.returncode, 2, result.stdout + result.stderr)
2153
+ self.assertIn('the following arguments are required: --file', result.stderr)
2154
+
2121
2155
  def test_attach_requires_the_terminal_checkout_only_for_a_publishable_result(self):
2122
2156
  for verdict in ("ACCEPTED", "FAILED"):
2123
2157
  with self.subTest(verdict=verdict):
@@ -100,6 +100,8 @@ Run `python3 "$DEVLYN_SKILL_DIR/scripts/queue.py" status --repo .` and report it
100
100
 
101
101
  ## drain
102
102
 
103
+ `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.
104
+
103
105
  Follow [loop.md](references/loop.md). The executor is the configured route, the `.devlyn/engines.json` `executor` pin or else this CLI, checked per `_shared/engine-preflight.md`. Pass its argv after `--`. `<git dir>` is the output of `git rev-parse --path-format=absolute --git-common-dir`, where the executor commits and writes its submission; the drain fills `{worktree_git_dir}` with the task worktree's own Git directory, which Codex's sandbox keeps read-only unless that exact directory is listed:
104
106
 
105
107
  | Executor | argv |
@@ -11,7 +11,7 @@ python3 "$DEVLYN_SKILL_DIR/scripts/queue.py" drain --repo . [--local-only] [--wo
11
11
 
12
12
  Both print one JSON object; exit 1 is `BLOCKED` with a `reason`.
13
13
 
14
- - `status` writes nothing beyond finishing `add`'s cleanup (step 1): reconciled counts (`pending`, `active`, `accepted`, `failed`, `blocked`, `legacy_pending`), the `next` task, `blockers`, deliveries still pending with their resume (drain again until the terminal commit is attached, then `task-complete.py complete --receipt <receipt>`), and under `cleanup` the package copies `add` kept or could not remove.
14
+ - `status` takes `queue.lock` in the repository's Git directory and finishes `add`'s cleanup (step 1); it reports reconciled counts (`pending`, `active`, `accepted`, `failed`, `blocked`, `legacy_pending`), the `next` task, `blockers`, deliveries still pending with their resume (drain again until the terminal commit is attached, then `task-complete.py complete --receipt <receipt>`), and under `cleanup` the package copies `add` kept or could not remove.
15
15
  - `drain` ends `DRAINED` (no runnable task remains; open deliveries show per task), `WAITING` (a task waits with its reason, or legacy rows need planning) or `BLOCKED` (conflict, invalid input or a recovery blocker). Progress lines `devlyn-loop: <task>: <event>` go to stderr.
16
16
  - `--local-only` (alias `--no-push`) keeps the loop local. A manifest `delivery: local-only` and any earlier local receipt of the loop keep that restriction for later drains. A local loop needs no remote; nothing is fetched or pushed. An `auto`/`pr` loop none of whose tasks has a pushed PR runs as a local loop from its capture and the branch it was added on. Once one has, that PR is never rewritten to local and the loop is never run locally: its tasks wait, naming the PR, while other work continues.
17
17
  - Worktrees default to `<repo parent>/<repo name>.devlyn/<loop-id>/<task-id>`.
@@ -41,6 +41,7 @@ The host supplies the executor and maps its configured engine to the argv; the l
41
41
  | Inputs commit | A branch head equal to the expected inputs tree is adopted; anything else blocks. |
42
42
  | During execution | Each attempt's start is recorded before its spawn and its exit after it. A start without an exit waits until task-complete observes no process using the worktree, then adopts the submission that executor wrote or runs the executor again; where writers cannot be observed (native Windows) the task becomes `[F] interrupted-unobservable` with its workspace retained, its dependents become prerequisite-blocked and independent work continues. Exactly-once execution is not promised. |
43
43
  | During checks | Acceptance reruns; incomplete evidence stays unreferenced. |
44
+ | During custody binding | Acceptance reruns while the receipt is unbound. Any unbound custody copy, complete or partial, is set aside in a unique hidden sibling before fresh custody is published; it and orphan staging copies stay retained and unreferenced. A bound receipt resumes its original result with strict evidence validation. |
44
45
  | Accepted, before terminal commit | Only the missing transition is created. |
45
46
  | Terminal committed, before attachment | The commit is validated and attached, also when attachment already moved the recovery ref; nothing reruns. |
46
47
  | Terminal committed, before delivery | Only the missing delivery effects run. |
@@ -843,9 +843,9 @@ def evidence_ignored(anchor, common, start):
843
843
 
844
844
 
845
845
  def allocate(v, row, opts):
846
- """Allocate the task's owned worktree, or return why it waits: origin names no GitHub repository, or its refreshed
847
- remote base holds another version of the loop's package or lacks the checkout's instructions (waiting() checks a local
848
- start before selection)."""
846
+ """Allocate the task's owned worktree, or return why it waits: origin names no GitHub repository, its base is not the
847
+ default, or its refreshed remote base holds another version of the loop's package or lacks the checkout's instructions
848
+ (waiting() checks a local start before selection)."""
849
849
  identity, loop, task = row["identity"], row["loop"], row["task"]
850
850
  package = v["packages"][loop]
851
851
  manifest = package["manifest"]
@@ -868,6 +868,13 @@ def allocate(v, row, opts):
868
868
  except helper["CompletionError"]:
869
869
  return (f"{manifest['delivery']} delivery needs an origin remote of the form https://github.com/<owner>/<repo>, "
870
870
  "git@github.com:<owner>/<repo> or ssh://git@github.com/<owner>/<repo>; set one, or drain with --local-only")
871
+ try:
872
+ default = helper["repository_info"](values["repository"])["defaultBranchRef"]["name"]
873
+ except (helper["CompletionError"], OSError, KeyError, TypeError, ValueError) as exc:
874
+ return f"cannot check delivery repository: {exc}"
875
+ if manifest["base_ref"] != default:
876
+ return (f"{manifest['delivery']} delivery base {manifest['base_ref']} is not the repository's default branch {default}; "
877
+ f"plan the loop on {default}, or drain with --local-only")
871
878
  try:
872
879
  start = helper["remote_base"]({"common_gitdir": str(common), "remote": "origin", "base": manifest["base_ref"]})
873
880
  except helper["CompletionError"] as exc:
@@ -1069,6 +1076,8 @@ def advance(v, row, opts):
1069
1076
  except LoopError as exc:
1070
1077
  return f"delivery blocked: {exc}"
1071
1078
  progress(identity, f"delivery {result['status']}")
1079
+ if result.get("merge_refused"):
1080
+ return f"merge refused: {result['merge_refused']}; a person merges the PR"
1072
1081
  return None
1073
1082
 
1074
1083
 
@@ -1093,9 +1102,11 @@ def summary(v, row):
1093
1102
  questions=[r.removeprefix("needs-review: ") for r in acceptance_record.get("reasons", []) if r.startswith("needs-review: ")],
1094
1103
  worktree=receipt["worktree"] if receipt.get("worktree") and Path(receipt["worktree"]).exists() else None,
1095
1104
  cleanup=f"{c['status']} — {c['reason']}; resume: {c['resume']}" if (c := receipt.get("workspace_cleanup")) else None,
1096
- branch=receipt.get("branch"), recovery_ref=receipt.get("recovery_ref"),
1105
+ branch=receipt.get("branch") if receipt.get("branch") and ref_value(v["anchor"], "refs/heads/" + receipt["branch"]) else None,
1106
+ recovery_ref=receipt.get("recovery_ref"),
1097
1107
  custody=str(Path(state["path"]).parent / "custody") if receipt.get("files") else None,
1098
- scratch=(receipt.get("scratch_cleanup") or {}).get("status", "NOT_CLEANED"))
1108
+ scratch=(f"{c['status']} — {c['reason']}; resume: {c['resume']}" if (c := receipt.get("scratch_cleanup") or {}).get("reason")
1109
+ else c.get("status", "NOT_CLEANED")))
1099
1110
  return item
1100
1111
 
1101
1112
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devlyn-cli",
3
- "version": "4.2.3",
3
+ "version": "4.2.5",
4
4
  "description": "Loop & harness engineering for AI coding agents (Claude Code, Codex, and any AGENTS.md-driven agent) — core engineering principles for CLAUDE.md/AGENTS.md and a loop designer that splits one intent into tasks agents drain hands-free",
5
5
  "homepage": "https://github.com/fysoul17/devlyn-cli#readme",
6
6
  "bin": {