devlyn-cli 4.2.5 → 4.2.6

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,7 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
3
  const crypto = require('crypto');
4
+ const { Lexer } = require('marked');
4
5
  const legacyTemplates = require('./instruction-templates.json');
5
6
 
6
7
  const BEGIN = '<!-- devlyn:instructions:begin';
@@ -119,16 +120,29 @@ function instructionParagraphs(text) {
119
120
  return paragraphs;
120
121
  }
121
122
 
122
- // Claude Code expands an `@AGENTS.md` import line, except inside a fenced code block.
123
+ // Recognize direct adjacent imports in Markdown text, including inline imports.
124
+ // Leaf tokens keep code, escapes and ordinary HTML from removing our only block.
123
125
  function importsAgentsMd(text) {
124
- let fence = null;
125
- for (const line of text.replace(/^\uFEFF/, '').split(/\r?\n/)) {
126
- const marker = /^ {0,3}(`{3,}|~{3,})/.exec(line)?.[1];
127
- if (marker && !fence) fence = marker;
128
- else if (marker && marker[0] === fence[0] && marker.length >= fence.length && /^ {0,3}(?:`+|~+)\s*$/.test(line)) fence = null;
129
- else if (!fence && line.trimEnd() === '@AGENTS.md') return true;
130
- }
131
- return false;
126
+ // Match the complete native path token before dropping its optional fragment.
127
+ const matchesImport = (value) => [...value.matchAll(/(?:^|\s)@((?:[^\s\\]|\\ )+)/g)].some((match) => {
128
+ const file = match[1].split('#', 1)[0];
129
+ return file === 'AGENTS.md' || file === './AGENTS.md';
130
+ });
131
+ const containsImport = (tokens) => tokens.some((token) => {
132
+ if (['code', 'codespan'].includes(token.type)) return false;
133
+ if (token.type === 'html') {
134
+ const raw = token.raw || '';
135
+ // Native Claude scans text left after complete comments in a comment-shaped HTML token.
136
+ return raw.trimStart().startsWith('<!--') && raw.includes('-->')
137
+ && matchesImport(raw.replace(/<!--[\s\S]*?-->/g, ''));
138
+ }
139
+ if (token.tokens) return containsImport(token.tokens);
140
+ if (token.items) return containsImport(token.items);
141
+ return token.type === 'text' && matchesImport(token.text);
142
+ });
143
+ // Claude strips this bounded frontmatter prefix before expanding imports.
144
+ const body = text.replace(/^\uFEFF/, '').replace(/^---\s*\n([\s\S]*?)---\s*\n?/, '');
145
+ return containsImport(new Lexer({ gfm: false }).lex(body));
132
146
  }
133
147
 
134
148
  function customInstructions(text, name, template, managed = false) {
@@ -1,11 +1,12 @@
1
1
  {
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",
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, 4.2.5 and 4.2.6 (_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
6
  "8c80926ab03b2477e52264495b35c2116f65fc51b02e8c56e441b5705872392a",
7
7
  "3c81c575cc43d70488865b440204cb399091405d9b7f81dbf047a32d3d5ac045",
8
- "6546d6b833701f2641a004802cfc17ec62442e5d0003f396cb0231b676ba9a4a"
8
+ "6546d6b833701f2641a004802cfc17ec62442e5d0003f396cb0231b676ba9a4a",
9
+ "640500e3ada3d81645c293ddc956e9757f0c4ba0a06e3f914c9bdad4968cbc66"
9
10
  ],
10
11
  "devlyn-design-ui": [
11
12
  "91080266232fb62b6f1d45932184f065589e12eb66f02fb6442527c6dfcd81b6"
@@ -18,7 +19,8 @@
18
19
  "8ff5148cc65b4608c35b29afd6fef20d42d8da93a094144d98cfc63e74089b6e",
19
20
  "5824f07e455fe1c3942bf595a12e935258d4c0a851ca1bf741db915603d1c4e0",
20
21
  "7f451e48e391643b34f6249244bc47d93d6303875e7b092771e3af0718343bc1",
21
- "d6433bf0f96dd9dd485c3623885255738654674b3e23cfcc1a2cfd24f3cda96e"
22
+ "d6433bf0f96dd9dd485c3623885255738654674b3e23cfcc1a2cfd24f3cda96e",
23
+ "7fb3ceaf835230e1a75e510b046f4fe26e79815760354bec1949fa55515c6316"
22
24
  ],
23
25
  "devlyn-queue": [
24
26
  "4c7d988dcd154c7bf658d2d24cd7868955df9d854ed87eb784742d6d4bb229e0"
@@ -185,7 +185,8 @@ def policy(receipt, override):
185
185
  return override or value or "auto"
186
186
 
187
187
 
188
- def allocate(args):
188
+ def allocate(args, *, exclude_receipts=()):
189
+ """Reconcile older tasks, except receipts the queue has identified as invalid."""
189
190
  work = Path(git(Path(args.repo).resolve(), "rev-parse", "--show-toplevel")).resolve()
190
191
  common = Path(git(work, "rev-parse", "--path-format=absolute", "--git-common-dir")).resolve()
191
192
  require(args.task.strip(), "task identity is required")
@@ -228,7 +229,7 @@ def allocate(args):
228
229
  receipt["allocation"] = "owned"
229
230
  atomic_json(path, receipt)
230
231
  return {"status": "ALLOCATED", "receipt": str(path), "worktree": str(target), "scratch": str(scratch),
231
- "reconciled": [] if local else reconcile(common, path, work)}
232
+ "reconciled": [] if local else reconcile(common, path, work, exclude_receipts=exclude_receipts)}
232
233
 
233
234
 
234
235
  def exact_commit(receipt, value, flag):
@@ -243,7 +244,7 @@ def local_baseline(receipt, args):
243
244
  if args.from_receipt:
244
245
  with locked_receipt(Path(args.from_receipt).absolute(), blocking=False) as predecessor:
245
246
  require(predecessor["common_gitdir"] == receipt["common_gitdir"], "predecessor receipt belongs to another repository")
246
- require(predecessor.get("acceptance") and predecessor.get("product") != "FAILED", "predecessor has no accepted result")
247
+ require(predecessor.get("acceptance") and predecessor["acceptance"].get("verdict") != "FAILED", "predecessor has no accepted result")
247
248
  require(ref_sha(predecessor, predecessor["recovery_ref"]) == predecessor["publish_sha"], "predecessor recovery ref changed")
248
249
  gref(predecessor, "merge-base", "--is-ancestor", predecessor["source_sha"], predecessor["publish_sha"])
249
250
  receipt["allocated_from"] = {"receipt": predecessor["id"], "source_sha": predecessor["source_sha"]}
@@ -341,7 +342,7 @@ def accept(args):
341
342
  with locked_receipt(path) as receipt:
342
343
  refuse_retired_pipeline(receipt, path, args.acceptance, receipt.get("local_only"), [])
343
344
  bind_acceptance(receipt, path, args.acceptance)
344
- return {"status": "FAILED" if receipt.get("product") == "FAILED" else "ACCEPTED", "receipt": str(path),
345
+ return {"status": "FAILED" if receipt["acceptance"].get("verdict") == "FAILED" else "ACCEPTED", "receipt": str(path),
345
346
  "source_sha": receipt["source_sha"], "recovery_ref": receipt["recovery_ref"]}
346
347
 
347
348
 
@@ -357,7 +358,7 @@ def attach(args):
357
358
  queue_commit(receipt, work, queue, receipt["source_sha"])
358
359
  # A failed result is never published, so its checkout stays as left; a publishable one must be checked out.
359
360
  require(ref_sha(receipt, "refs/heads/"+receipt["branch"]) == args.commit
360
- and (receipt.get("product") == "FAILED" or git(work, "rev-parse", "HEAD") == args.commit),
361
+ and (receipt["acceptance"].get("verdict") == "FAILED" or git(work, "rev-parse", "HEAD") == args.commit),
361
362
  "task ref and HEAD must be the terminal commit")
362
363
  recovery = ref_sha(receipt, receipt["recovery_ref"])
363
364
  require(recovery in {receipt["source_sha"], args.commit}, "recovery ref changed")
@@ -439,6 +440,17 @@ def removable(receipt, work):
439
440
  stopped_writers(work)
440
441
 
441
442
 
443
+ def linux_process_status(process):
444
+ # One opened status file is bound to that process, not a reused PID.
445
+ # A zombie leader can still have living sibling threads.
446
+ try:
447
+ return dict(line.split(":", 1) for line in (process / "status").read_text(encoding="utf-8").splitlines() if ":" in line)
448
+ except (FileNotFoundError, ProcessLookupError):
449
+ raise
450
+ except (OSError, UnicodeError) as exc:
451
+ raise WritersUnobservable("unknown process status; retain tree until writer cessation can be established") from exc
452
+
453
+
442
454
  def stopped_writers(work):
443
455
  outside(work)
444
456
  # The owner assertion covers its actual children; an OS observation catches
@@ -463,15 +475,22 @@ def stopped_writers(work):
463
475
  try:
464
476
  target = Path(os.readlink(link))
465
477
  except FileNotFoundError:
466
- if link == process / "cwd":
467
- break
478
+ if link == process / "cwd" and linux_process_status(process).get("Threads", "").strip() != "1":
479
+ raise WritersUnobservable("unknown process thread access; retain tree until writer cessation can be established")
468
480
  continue
469
481
  if target.is_absolute() and target.is_relative_to(work):
470
482
  raise WriterActive(f"active process {process.name} uses task files; stop/yield it before resume")
471
- except FileNotFoundError:
483
+ except (FileNotFoundError, ProcessLookupError):
472
484
  continue # Process exited during observation.
473
485
  except PermissionError as exc:
474
- raise WritersUnobservable("unknown process access; retain tree until writer cessation can be established") from exc
486
+ reason = "unknown process access; retain tree until writer cessation can be established"
487
+ try:
488
+ status = linux_process_status(process)
489
+ except (FileNotFoundError, ProcessLookupError) as error:
490
+ raise WritersUnobservable(reason) from error
491
+ if status.get("State", "").strip() == "Z (zombie)" and status.get("Threads", "").strip() == "1":
492
+ continue
493
+ raise WritersUnobservable(reason) from exc
475
494
  else:
476
495
  raise WritersUnobservable("writer observation unsupported on this platform; retain workspace")
477
496
 
@@ -646,10 +665,10 @@ def completion_result(receipt, path, status):
646
665
  "scratch_cleanup": scratch, "workspace_cleanup": receipt.get("workspace_cleanup")}
647
666
 
648
667
 
649
- def reconcile(common, allocated, anchor):
668
+ def reconcile(common, allocated, anchor, *, exclude_receipts=()):
650
669
  results = []
651
670
  for path in sorted((common / "devlyn-completion").glob("*/receipt.json")):
652
- if path == allocated:
671
+ if path == allocated or path in exclude_receipts:
653
672
  continue
654
673
  try:
655
674
  receipt = read_json(path)
@@ -693,7 +712,7 @@ def complete(args):
693
712
  atomic_json(path, receipt)
694
713
  def result(status):
695
714
  return completion_result(receipt, path, status)
696
- if receipt.get("product") == "FAILED":
715
+ if (receipt.get("acceptance") or {}).get("verdict") == "FAILED":
697
716
  return result("FAILED")
698
717
  if args.local_only or receipt.get("local_only"):
699
718
  require(not receipt.get("pushed"), f"{receipt['task']} already has a pushed PR {receipt.get('pr_url') or receipt['branch']}; "
@@ -1134,6 +1153,119 @@ class CompletionTests(unittest.TestCase):
1134
1153
  with self.assertRaisesRegex(WriterActive, "active process " + process.name):
1135
1154
  stopped_writers(self.work)
1136
1155
 
1156
+ def linux_unobservable_process(self, status, *, missing_cwd=False, active_fd=False):
1157
+ from unittest.mock import patch
1158
+ process = Path("/proc") / str(os.getpid() + 1)
1159
+ original_entries, original_text, original_link = Path.iterdir, Path.read_text, os.readlink
1160
+ def entries(path):
1161
+ if path == Path("/proc"):
1162
+ return iter([process])
1163
+ if path == process / "fd":
1164
+ if missing_cwd:
1165
+ return iter([process / "fd/3"] if active_fd else [])
1166
+ raise PermissionError(path)
1167
+ return original_entries(path)
1168
+ def text(path, *args, **kwargs):
1169
+ if path == process / "status":
1170
+ if isinstance(status, Exception):
1171
+ raise status
1172
+ return status
1173
+ return original_text(path, *args, **kwargs)
1174
+ def link(path, *args, **kwargs):
1175
+ if path == process / "cwd":
1176
+ raise FileNotFoundError(path)
1177
+ if path == process / "fd/3":
1178
+ return str(self.work / "product")
1179
+ return original_link(path, *args, **kwargs)
1180
+ with patch.object(sys, "platform", "linux"), patch.object(Path, "iterdir", entries), patch.object(Path, "read_text", text), patch.object(os, "readlink", link):
1181
+ stopped_writers(self.work)
1182
+
1183
+ def test_linux_writer_scan_accepts_confirmed_single_thread_zombie(self):
1184
+ self.linux_unobservable_process("Name:\tgit\nState:\tZ (zombie)\nThreads:\t1\n")
1185
+
1186
+ def test_linux_writer_scan_retains_denied_live_or_threaded_process(self):
1187
+ # The leader may be Z after pthread_exit while sibling threads write.
1188
+ for state, threads in (("Z (zombie)", "2"), ("R (running)", "1"), ("S (sleeping)", "1"), ("T (stopped)", "1")):
1189
+ with self.subTest(state=state, threads=threads):
1190
+ with self.assertRaises(WritersUnobservable):
1191
+ self.linux_unobservable_process(f"State:\t{state}\nThreads:\t{threads}\n")
1192
+
1193
+ def test_linux_writer_scan_retains_unknown_zombie_status(self):
1194
+ for status in ("", "State:\tZ (zombie)\n", "Threads:\t1\n", "State:\tZ (zombie)\nThreads:\tunknown\n",
1195
+ PermissionError("status denied"), FileNotFoundError("exited before terminal evidence")):
1196
+ with self.subTest(status=str(status)):
1197
+ with self.assertRaises(WritersUnobservable):
1198
+ self.linux_unobservable_process(status)
1199
+
1200
+ def test_linux_missing_cwd_checks_threads_and_remaining_fds(self):
1201
+ self.linux_unobservable_process("State:\tZ (zombie)\nThreads:\t1\n", missing_cwd=True)
1202
+ self.linux_unobservable_process(FileNotFoundError("process exited"), missing_cwd=True)
1203
+ self.linux_unobservable_process("State:\tS (sleeping)\nThreads:\t1\n", missing_cwd=True)
1204
+ with self.assertRaises(WriterActive):
1205
+ self.linux_unobservable_process("State:\tS (sleeping)\nThreads:\t1\n", missing_cwd=True, active_fd=True)
1206
+ for status in ("State:\tZ (zombie)\nThreads:\t2\n", "State:\tS (sleeping)\nThreads:\t2\n", ""):
1207
+ with self.subTest(status=status):
1208
+ with self.assertRaises(WritersUnobservable):
1209
+ self.linux_unobservable_process(status, missing_cwd=True)
1210
+
1211
+ @unittest.skipUnless(sys.platform.startswith("linux"), "requires Linux proc and unreaped child")
1212
+ def test_linux_real_zombie_allows_only_attested_scratch_cleanup(self):
1213
+ scratch = Path(self.allocate()["scratch"])
1214
+ (scratch / "artifact").write_text("rebuildable")
1215
+ child = os.fork()
1216
+ if child == 0:
1217
+ os._exit(0)
1218
+ try:
1219
+ os.waitid(os.P_PID, child, os.WEXITED | os.WNOWAIT)
1220
+ state = (Path("/proc") / str(child) / "status").read_text()
1221
+ self.assertRegex(state, r"State:\s+Z \(zombie\)")
1222
+ self.assertRegex(state, r"Threads:\s+1\n")
1223
+ result, _ = self.cli("clean-scratch", "--receipt", self.receipt, success=False)
1224
+ self.assertIn("--writers-stopped", result["reason"])
1225
+ self.assertTrue((scratch / "artifact").exists())
1226
+ result, _ = self.cli("clean-scratch", "--receipt", self.receipt, "--writers-stopped")
1227
+ self.assertEqual(result["status"], "SCRATCH_CLEAN")
1228
+ self.assertFalse(any(scratch.iterdir()))
1229
+ finally:
1230
+ os.waitpid(child, 0)
1231
+
1232
+ @unittest.skipUnless(sys.platform.startswith("linux"), "requires Linux pthread leader state")
1233
+ def test_linux_real_zombie_leader_retains_live_sibling_writer(self):
1234
+ import time
1235
+ scratch = Path(self.allocate()["scratch"])
1236
+ heartbeat = scratch / "heartbeat"
1237
+ code = """import ctypes, pathlib, sys, threading, time
1238
+ path = pathlib.Path(sys.argv[1])
1239
+ def worker():
1240
+ with path.open('w') as stream:
1241
+ while True:
1242
+ stream.write('x'); stream.flush(); time.sleep(.01)
1243
+ threading.Thread(target=worker).start()
1244
+ ctypes.CDLL(None).pthread_exit(None)
1245
+ """
1246
+ child = subprocess.Popen([sys.executable, "-c", code, str(heartbeat)], cwd=self.root)
1247
+ try:
1248
+ deadline = time.monotonic() + 5
1249
+ while time.monotonic() < deadline:
1250
+ status = (Path("/proc") / str(child.pid) / "status").read_text()
1251
+ if re.search(r"State:\s+Z \(zombie\)", status) and heartbeat.exists():
1252
+ break
1253
+ time.sleep(.01)
1254
+ else:
1255
+ self.fail("threaded zombie fixture did not become ready")
1256
+ self.assertRegex(status, r"Threads:\s+2\n")
1257
+ before = heartbeat.stat().st_size
1258
+ result, _ = self.cli("clean-scratch", "--receipt", self.receipt, "--writers-stopped", success=False)
1259
+ self.assertIn("unknown process", result["reason"])
1260
+ self.assertTrue(heartbeat.exists())
1261
+ deadline = time.monotonic() + 5
1262
+ while heartbeat.stat().st_size <= before and time.monotonic() < deadline:
1263
+ time.sleep(.01)
1264
+ self.assertGreater(heartbeat.stat().st_size, before)
1265
+ finally:
1266
+ child.kill()
1267
+ child.wait(timeout=5)
1268
+
1137
1269
  @unittest.skipUnless(sys.platform == "darwin" or sys.platform.startswith("linux"), "writer observation requires POSIX")
1138
1270
  def test_scratch_rejects_redirect_and_git_data_but_does_not_follow_child_links(self):
1139
1271
  scratch = Path(self.allocate()["scratch"])
@@ -15,6 +15,11 @@ just-edit instructions skip delivery. Existing session-wide restrictions remain
15
15
  in force until changed. Read-only work, unchanged work and incidental generated
16
16
  files do not trigger delivery. An ideate drain delivers every task.
17
17
 
18
+ When delivery is implicit and no GitHub origin is configured, use the existing
19
+ local-only route and report `LOCAL_ONLY`, the commit and why no PR/merge occurred.
20
+ An explicit PR/merge request remains blocked. Authentication, network and
21
+ configuration failures are not grounds for this fallback.
22
+
18
23
  ## Concurrent writers
19
24
 
20
25
  When other sessions or agents are known to write this checkout (the user says
@@ -58,11 +63,6 @@ isolation without publication, use `--local-base` with the exact current HEAD
58
63
  and omit `--repository`/`--remote`. Nothing is fetched or pushed; complete with
59
64
  `--local-only` only when committing is allowed.
60
65
 
61
- When delivery is implicit and no GitHub origin is configured, use the existing
62
- local-only route and report `LOCAL_ONLY`, the commit and why no PR/merge occurred.
63
- An explicit PR/merge request remains blocked. Authentication, network and
64
- configuration failures are not grounds for this fallback.
65
-
66
66
  After local completion or confirmed PR merge, reconcile the original checkout
67
67
  only when its branch and changes remain understood and can be preserved.
68
68
  After remote delivery, fetch first. If the branch can fast-forward to the
@@ -122,7 +122,7 @@ python3 "$DEVLYN_SKILL_DIR/scripts/queue.py" drain --repo . [--local-only] -- <e
122
122
  A drain can run for hours. Do not end the turn until it exits: run it in the foreground with the longest allowed timeout, or in the background and await its completion notice, because a headless host kills background tasks at its final response.
123
123
 
124
124
  - `WAITING` on legacy rows: plan and materialize each under the autonomous policy, then drain again; a row whose planning stops on material ambiguity stays pending and its question is reported.
125
- - `WAITING` on anything else, or `BLOCKED`: report the reason and resume commands; never edit receipts, refs or queue rows to get past them.
125
+ - Other `WAITING`: report each task’s reason and recovery command. Task-local failures leave dependents and barrier-held siblings waiting while independent work continues; the next drain retries eligible work once. An interrupted executor is observed once per drain and cannot be adopted or respawned until it stops. `BLOCKED` means authoritative queue reconstruction or preflight failed, including a held drain lock. Never edit receipts, refs or queue rows to get past either result.
126
126
  - An interrupted drain is resumed by running `drain` again; accepted work is never replayed, and a failed task is never rerun: plan its work again as a new loop.
127
127
 
128
- Report each task's product result, delivery status, PR URL, resume command, assumptions and unresolved questions, plus whole-loop acceptance, the package's `## Decisions and assumptions`, the drain report paths and its exact `Bring into` command; leave running it to the user. Report any `cleanup` entry too: a package copy left in the checkout stops that command until it is removed.
128
+ Report each task's product result, delivery status, PR URL, resume command, assumptions and unresolved questions, plus whole-loop acceptance, the package's `## Decisions and assumptions`, the drain report paths and its exact `Bring into` command; leave running it to the user. Report every `cleanup` diagnostic, including unavailable reports and retained parents; completed products keep their verdict. A package copy left in the checkout can prevent the `Bring into` command until it is removed.
@@ -12,7 +12,7 @@ python3 "$DEVLYN_SKILL_DIR/scripts/queue.py" drain --repo . [--local-only] [--wo
12
12
  Both print one JSON object; exit 1 is `BLOCKED` with a `reason`.
13
13
 
14
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
- - `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.
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` (unreadable or ambiguous authoritative queue, invalid invocation, preflight refusal or held drain lock). 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>`.
18
18
 
@@ -22,13 +22,13 @@ The host supplies the executor and maps its configured engine to the argv; the l
22
22
 
23
23
  ## Steps
24
24
 
25
- 1. **Reconcile.** First finish `add`'s cleanup of package copies ([package-format.md](package-format.md) Commands). Then read the queue view: the legacy rows, each replaced row as its loop's rows, then each loop's rows, from its capture or, without a record, from the checkout's tracked queue file ([package-format.md](package-format.md) Queue rows); every `<common Gitdir>/devlyn-completion/*/receipt.json` and their recovery refs. A row's identity, its receipt's branch `devlyn/<loop-id>/<task-id>`, the recovery ref (equal to the receipt's `publish_sha`, or to its validated terminal commit while that attachment is unfinished), the terminal commit's mark and, while active, the committed contract digests must agree. A receipt's terminal result supplements a `[ ]` row. Two receipts for one task or a terminal row contradicting its receipt stop selection with the conflict named; an unfinished allocation makes its task wait (step 10). A captured package never changes; for a loop without a record, an active task whose contract or expected acceptance changed in the checkout, also when that text is now missing or invalid, becomes `[F] inputs-changed` without executing again, its workspace retained, other work continuing while the revision is planned as a new task, and a pending task whose package text fails validation waits with that error, as do its dependents. Unsettled receipts (active, terminal commit unattached, or delivery not final) resume before any new allocation.
25
+ 1. **Reconcile.** First finish `add`'s cleanup of package copies ([package-format.md](package-format.md) Commands). Then read the queue view: the legacy rows, each replaced row as its loop's rows, then each loop's rows, from its capture or, without a record, from the checkout's tracked queue file ([package-format.md](package-format.md) Queue rows); every `<common Gitdir>/devlyn-completion/*/receipt.json` and their recovery refs. A row's identity, its receipt's branch `devlyn/<loop-id>/<task-id>`, the recovery ref (equal to the receipt's `publish_sha`, or to its validated terminal commit while that attachment is unfinished), the terminal commit's mark and, while active, the committed contract digests must agree. A receipt's terminal result supplements a `[ ]` row. Receipt, recovery-ref and packet conflicts invalidate the implicated tasks with an inspection/recovery reason; a missing queued prerequisite invalidates its dependent. Invalid evidence proves neither acceptance nor a frontier. A possible writer or package carrier retains its loop’s safety barrier while independent loops continue. An occupied receipt directory without a receipt waits for verified interrupted-allocation recovery; nothing is fabricated, rewritten or deleted. An unfinished allocation makes its task wait (step 10). Unreadable authoritative queue bytes, duplicate identities, missing/malformed queue files and invalid add records block the whole drain. A captured package never changes; for a loop without a record, an active task whose contract or expected acceptance changed in the checkout, also when that text is now missing or invalid, becomes `[F] inputs-changed` without executing again, its workspace retained, other work continuing while the revision is planned as a new task, and a pending task whose package text fails validation waits with that error, as do its dependents. Unsettled receipts (active, terminal commit unattached, or delivery not final) resume before any new allocation.
26
26
  2. **Select** the earliest pending row whose prerequisites are accepted with a receipt in every mode (a hand-written `[x]` never counts) and, for `auto`/`pr`, delivered (merged). A failed or blocked prerequisite makes its dependent `[F] blocked-prerequisite:<id>` without invoking the executor; that derived mark is proven by the prerequisite's receipt and travels into later checkouts and the report. A prerequisite awaiting delivery leaves its dependent pending while independent work continues; so does an `auto`/`pr` carrier in flight (step 3), or a local loop's active task (a local loop runs one task at a time), for every other task of its loop. Legacy rows wait for `add --materialize`.
27
- 3. **Allocate** a new owned worktree on the absent branch `devlyn/<loop-id>/<task-id>` with `task-complete.py allocate`. First, the commit the task starts from must ignore `.devlyn/` (a committed `.gitignore` entry there, or `<common Gitdir>/info/exclude`), or nothing is allocated and the drain blocks naming the fix. It must also hold this checkout's `CLAUDE.md` and `AGENTS.md` (each identical, or both absent), so the task runs under the installed instructions, and, where it holds the loop's queue file, every captured package file unchanged, so the task binds its captured contract; otherwise the task waits, naming the fix, while independent work continues.
27
+ 3. **Allocate** a new owned worktree on the absent branch `devlyn/<loop-id>/<task-id>` with `task-complete.py allocate`. First, the commit the task starts from must ignore `.devlyn/` (a committed `.gitignore` entry there, or `<common Gitdir>/info/exclude`), or nothing is allocated and the task waits naming the fix while independent work continues. It must also hold this checkout's `CLAUDE.md` and `AGENTS.md` (each identical, or both absent), so the task runs under the installed instructions, and, where it holds the loop's queue file, every captured package file unchanged, so the task binds its captured contract; otherwise the task waits, naming the fix, while independent work continues.
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
- - `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.
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 blocks the drain.
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.
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.
@@ -38,17 +38,21 @@ The host supplies the executor and maps its configured engine to the argv; the l
38
38
  | Interrupted | Resume |
39
39
  |---|---|
40
40
  | During allocation | A receipt without `allocation: owned` is never adopted: its task waits, naming the recovery (remove its worktree and branch if present, delete the receipt directory, drain again), while independent work continues. |
41
- | Inputs commit | A branch head equal to the expected inputs tree is adopted; anything else blocks. |
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. |
41
+ | Inputs commit | A branch head equal to the expected inputs tree is adopted; anything else leaves the task waiting for inspection, preserving the unexpected commits. |
42
+ | During execution | Each attempt's start is recorded before its spawn and its exit after it. A start without an exit gets one writer observation per drain. A live writer leaves the task waiting while independent work continues; a later drain adopts the submission or runs the executor again only after cessation is established; 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
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. |
45
45
  | Accepted, before terminal commit | Only the missing transition is created. |
46
46
  | Terminal committed, before attachment | The commit is validated and attached, also when attachment already moved the recovery ref; nothing reruns. |
47
47
  | Terminal committed, before delivery | Only the missing delivery effects run. |
48
- | After delivery, before cleanup | Owned cleanup resumes; the product verdict is unchanged. |
48
+ | After delivery, before cleanup | Owned cleanup resumes; the product verdict is unchanged. After confirmed worktree removal, drain prunes empty parents strictly below its matching worktree root, preserving that root, nonempty directories and symlinks. Later drains revisit completed receipts to finish interrupted parent pruning without execution. |
49
+
50
+ Expected task-local operational failures leave the selected task waiting with `<phase> blocked: <detail>` and recovery guidance. It is attempted at most once per drain; dependents, local siblings behind an active task, and siblings behind an undelivered package carrier wait too. Independent work continues and the drain ends `WAITING`. Inputs, acceptance, custody and terminal-attachment failures resume only their missing effects; bound results and accepted work never replay. The controller boundary contains `LoopError` and `OSError`; other programming errors propagate there. The inherited task-complete wrapper and delivery-repository lookup also contain helper `TypeError`, `KeyError` and `ValueError`, converting them to task waits.
49
51
 
50
52
  11. **Report** to `<common Gitdir>/devlyn-loops/<loop-id>/drain-report.md` after every drain: per task the product result and reason, receipt, evidence custody and recovery ref, allocation base, accepted (or unaccepted) source, terminal commit, delivery status, PR URL and resume command, assumptions and unresolved questions, the retained worktree and branch, and workspace and scratch cleanup. Include the package's decisions and assumptions once, excluding the manifest and separately from executor assumptions; read the captured package when an add record exists, otherwise use the checkout, and report an unreadable package explicitly. Whole-loop acceptance is reported separately over the manifest's complete task set: every task needs receipt-backed acceptance, including the integration task's assembled-product check on the final frontier; a manifest task missing from the queue is reported as not yet run. `Bring into` names the command that brings the accepted frontier into the branch the loop was added on. For a local loop it is `git merge --ff <final frontier branch>` on that branch, which fast-forwards when it can and merges otherwise, so the commands of several local loops apply in either order. For `auto`/`pr`, once the frontier is delivered, it is `git merge --ff origin/<base>` on `<base>`: the anchor holds no loop commit or copy, so it fast-forwards after merge, squash and rebase delivery alike unless that branch has commits of its own, and merges then.
51
53
 
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
+
52
56
  ## Executor exchange
53
57
 
54
58
  The packet (`<receipt dir>/packet.json`) carries everything a fresh executor needs: