devlyn-cli 4.2.2 → 4.2.4

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.
@@ -183,6 +183,12 @@ function retainFile(file, bytes) {
183
183
  // `withBlock` false removes the devlyn defaults the file holds, keeping everything else. `write` false writes nothing
184
184
  // and returns whether the file needs an update, a merge included.
185
185
  function updateInstructions(name, initial = '', withBlock = true, write = true) {
186
+ const projectPackage = path.join(process.cwd(), 'package.json');
187
+ if (fs.existsSync(path.join(process.cwd(), 'bin', 'devlyn.js')) && fs.existsSync(projectPackage)
188
+ && JSON.parse(fs.readFileSync(projectPackage, 'utf8'))?.name === 'devlyn-cli') {
189
+ console.log(` → Skipping ${name}; shipped instruction template in devlyn-cli source checkout`);
190
+ return false;
191
+ }
186
192
  const templatePath = path.join(__dirname, '..', name);
187
193
  const template = fs.readFileSync(templatePath, 'utf8');
188
194
  if (template.includes(BEGIN) || template.includes(END)) {
@@ -1,9 +1,10 @@
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 (_shared and devlyn-ideate, fingerprinted from an install of its 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"
7
8
  ],
8
9
  "devlyn-design-ui": [
9
10
  "91080266232fb62b6f1d45932184f065589e12eb66f02fb6442527c6dfcd81b6"
@@ -14,7 +15,8 @@
14
15
  ],
15
16
  "devlyn-ideate": [
16
17
  "8ff5148cc65b4608c35b29afd6fef20d42d8da93a094144d98cfc63e74089b6e",
17
- "5824f07e455fe1c3942bf595a12e935258d4c0a851ca1bf741db915603d1c4e0"
18
+ "5824f07e455fe1c3942bf595a12e935258d4c0a851ca1bf741db915603d1c4e0",
19
+ "7f451e48e391643b34f6249244bc47d93d6303875e7b092771e3af0718343bc1"
18
20
  ],
19
21
  "devlyn-queue": [
20
22
  "4c7d988dcd154c7bf658d2d24cd7868955df9d854ed87eb784742d6d4bb229e0"
@@ -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:
@@ -1793,21 +1800,37 @@ class CompletionTests(unittest.TestCase):
1793
1800
 
1794
1801
  def test_custody_failure_and_evidence_tamper(self):
1795
1802
  self.allocate(); self.accept()
1803
+ self.cli("accept", "--receipt", self.receipt, "--acceptance", self.acceptance)
1796
1804
  bad = self.receipt.parent / "custody/.devlyn/checks.txt"
1797
- bad.parent.mkdir(parents=True)
1798
- bad.write_text("partial interrupted copy", encoding="utf-8")
1805
+ bad.write_text("corrupted bound copy", encoding="utf-8")
1799
1806
  _, r = self.complete("--writers-stopped", success=False)
1800
1807
  self.assertNotEqual(r.returncode, 0)
1801
1808
  self.assertTrue(self.task.exists())
1802
1809
  self.assertEqual((self.task / ".devlyn/checks.txt").read_text(encoding="utf-8"), "actual fixture check: accepted bytes\n")
1803
1810
  self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture"), "")
1804
- bad.unlink() # Owner repairs only the identified corrupt fixture copy.
1811
+ shutil.copy2(self.task / ".devlyn/checks.txt", bad) # Repair only the identified corrupt fixture copy.
1805
1812
  self.complete("--mode", "pr")
1806
1813
  (self.task / ".devlyn/checks.txt").write_text("altered after acceptance", encoding="utf-8")
1807
1814
  _, r = self.complete("--mode", "auto", success=False)
1808
1815
  self.assertNotEqual(r.returncode, 0)
1809
1816
  self.assertEqual(json.loads(self.data.read_text(encoding="utf-8")).get("merges",0), 0)
1810
1817
 
1818
+ def test_unbound_partial_custody_is_retained_through_cleanup(self):
1819
+ self.allocate(); self.accept()
1820
+ partial = self.receipt.parent / "custody/.devlyn/checks.txt"
1821
+ partial.parent.mkdir(parents=True)
1822
+ partial.write_text("partial interrupted copy", encoding="utf-8")
1823
+ original = file_record(partial)
1824
+ result, _ = self.complete("--writers-stopped", "--mode", "auto")
1825
+ self.assertEqual(result["status"], "COMPLETE")
1826
+ self.assertEqual(result["scratch_cleanup"]["status"], "CLEAN")
1827
+ self.assertFalse(self.task.exists())
1828
+ retained = list(self.receipt.parent.glob(".custody-*"))
1829
+ self.assertEqual(len(retained), 1)
1830
+ self.assertEqual(file_record(retained[0] / ".devlyn/checks.txt"), original)
1831
+ self.assertEqual(self.complete(acceptance=False)[0]["status"], "COMPLETE")
1832
+ self.assertEqual(file_record(retained[0] / ".devlyn/checks.txt"), original)
1833
+
1811
1834
  def test_actual_foreign_writer_and_registration(self):
1812
1835
  # H4. Prediction: a process still using the merged task's tree settles the delivery COMPLETE with the workspace
1813
1836
  # retained (CLEANUP_PENDING) and the process named, as unobservable writers do. Before: complete ended BLOCKED
@@ -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. |
@@ -1069,6 +1069,8 @@ def advance(v, row, opts):
1069
1069
  except LoopError as exc:
1070
1070
  return f"delivery blocked: {exc}"
1071
1071
  progress(identity, f"delivery {result['status']}")
1072
+ if result.get("merge_refused"):
1073
+ return f"merge refused: {result['merge_refused']}; a person merges the PR"
1072
1074
  return None
1073
1075
 
1074
1076
 
@@ -1093,9 +1095,11 @@ def summary(v, row):
1093
1095
  questions=[r.removeprefix("needs-review: ") for r in acceptance_record.get("reasons", []) if r.startswith("needs-review: ")],
1094
1096
  worktree=receipt["worktree"] if receipt.get("worktree") and Path(receipt["worktree"]).exists() else None,
1095
1097
  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"),
1098
+ branch=receipt.get("branch") if receipt.get("branch") and ref_value(v["anchor"], "refs/heads/" + receipt["branch"]) else None,
1099
+ recovery_ref=receipt.get("recovery_ref"),
1097
1100
  custody=str(Path(state["path"]).parent / "custody") if receipt.get("files") else None,
1098
- scratch=(receipt.get("scratch_cleanup") or {}).get("status", "NOT_CLEANED"))
1101
+ scratch=(f"{c['status']} — {c['reason']}; resume: {c['resume']}" if (c := receipt.get("scratch_cleanup") or {}).get("reason")
1102
+ else c.get("status", "NOT_CLEANED")))
1099
1103
  return item
1100
1104
 
1101
1105
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devlyn-cli",
3
- "version": "4.2.2",
3
+ "version": "4.2.4",
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": {