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.
- package/bin/instructions.js +23 -9
- package/bin/skill-history.json +5 -3
- package/config/skills/_shared/task-complete.py +144 -12
- package/config/skills/_shared/task-completion.md +5 -5
- package/config/skills/devlyn-ideate/SKILL.md +2 -2
- package/config/skills/devlyn-ideate/references/loop.md +12 -8
- package/config/skills/devlyn-ideate/scripts/queue.py +300 -100
- package/node_modules/marked/LICENSE.md +44 -0
- package/node_modules/marked/README.md +106 -0
- package/node_modules/marked/bin/main.js +282 -0
- package/node_modules/marked/bin/marked.js +15 -0
- package/node_modules/marked/lib/marked.cjs +2211 -0
- package/node_modules/marked/lib/marked.cjs.map +7 -0
- package/node_modules/marked/lib/marked.d.cts +728 -0
- package/node_modules/marked/lib/marked.d.ts +728 -0
- package/node_modules/marked/lib/marked.esm.js +2189 -0
- package/node_modules/marked/lib/marked.esm.js.map +7 -0
- package/node_modules/marked/lib/marked.umd.js +2213 -0
- package/node_modules/marked/lib/marked.umd.js.map +7 -0
- package/node_modules/marked/man/marked.1 +111 -0
- package/node_modules/marked/man/marked.1.md +92 -0
- package/node_modules/marked/marked.min.js +69 -0
- package/node_modules/marked/package.json +111 -0
- package/package.json +5 -3
package/bin/instructions.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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) {
|
package/bin/skill-history.json
CHANGED
|
@@ -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.
|
|
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("
|
|
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("
|
|
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("
|
|
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
|
-
|
|
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
|
-
|
|
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("
|
|
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
|
|
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
|
|
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` (
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
42
|
-
| During execution | Each attempt's start is recorded before its spawn and its exit after it. A start without an exit
|
|
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:
|