devlyn-cli 4.2.16 → 4.2.18
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/README.md +2 -0
- package/bin/skill-history.json +3 -0
- package/config/skills/_shared/platform-support.py +13 -0
- package/config/skills/_shared/task-completion.md +4 -2
- package/config/skills/devlyn-ideate/SKILL.md +2 -2
- package/config/skills/devlyn-ideate/references/loop.md +2 -2
- package/config/skills/devlyn-ideate/scripts/queue.py +55 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -231,6 +231,8 @@ Only `__pycache__` directories containing exclusively regular `*.pyc` files coun
|
|
|
231
231
|
|
|
232
232
|
On native Windows, use native Node/npm and Python plus Git for Windows Bash for the shipped shell wrapper. Run `npx devlyn-cli -y` in the project (add `--claude` for Claude Code). Skill folders follow the Agent Skills naming standard (for example `devlyn-ideate`), so a project that commits them checks out on Windows. Harness text is UTF-8 without requiring `PYTHONUTF8`.
|
|
233
233
|
|
|
234
|
+
New default drain roots disable Windows content indexing for new children; names stay indexed and Microsoft Defender scanning is unaffected. For Defender performance, use a Dev Drive via `--worktree-root` or an administrator's exclusion for the root; see [loop options](config/skills/devlyn-ideate/references/loop.md).
|
|
235
|
+
|
|
234
236
|
Windows completion preserves the workspace, owned refs and recovery receipt when writer cessation cannot be proved, even after merge; resume guidance and delivery status remain separate from product verification. The portability workflow checks out the repository on native Windows and tests the POSIX-packed npm artifact there. A POSIX pass alone does not establish Windows support: acceptance requires the passing Windows job for the exact source SHA/artifact hashes.
|
|
235
237
|
|
|
236
238
|
## Contributing
|
package/bin/skill-history.json
CHANGED
|
@@ -111,6 +111,7 @@
|
|
|
111
111
|
"a9c5242608e8b6f660f3ae9daf3fdefb210b6e4603bddee3d26ba59a55be4085",
|
|
112
112
|
"aa0d6fad740ead835720a528f197b29e6cd0b209d3b9db1b312b9895f991b037",
|
|
113
113
|
"ac3d475156af89e2e887118e92b972dbfefde6f0a07bbbdb68ba88fce5f7904d",
|
|
114
|
+
"ac404e4e846557b4c7b32050dee5f7c83648311b7d1cecdbdcae9eeda1b8eaf7",
|
|
114
115
|
"ac5485ee9218c7038c6d329a3877925995c5afcb2dbd16b1da4495ed769d2083",
|
|
115
116
|
"add41e1257ed525d6f225d9eb976a3978f126e604ca7c0f5aa1b223af78f1902",
|
|
116
117
|
"afb478a981c4fd91f66fbe10d04481cd4562825fd6785ba0aadc6ce3ee3da173",
|
|
@@ -322,6 +323,7 @@
|
|
|
322
323
|
"5954e371659ae1010757f0eec4b80dd65a899f9fcf76d4d84d9a75476a963218",
|
|
323
324
|
"65770e3f20c3a3afd2822e9acdcfc80b827f84a7737010b79332da70d431b05d",
|
|
324
325
|
"65c781764633b912e5947362eba0837c8027461a904975401d79ad4fe8714084",
|
|
326
|
+
"694c8920e50d92cba46dbb27eb44dd88035a89ed625d3cbf9f2bce9366d44c96",
|
|
325
327
|
"6c3e2e4b84b0b4f81aed3759704da7c14f3cbabf3beeb0d1654feda3a15a8675",
|
|
326
328
|
"6d9df58c117165e9d297aa6be0df8d076bac1ed49dc29ae93906af1969798ab1",
|
|
327
329
|
"6e7228610628fc4bca31dc0b0aaf5eb4445e1f123f2c54bd13921c5cd01129fe",
|
|
@@ -338,6 +340,7 @@
|
|
|
338
340
|
"aba66b674683935e4c28c93333896dc116ffe67e7a35fe30b3599a8be507e94e",
|
|
339
341
|
"b2e4437fa981317362d03ee032e1cfd8b686316e966c6ba12ecd619a009c80a6",
|
|
340
342
|
"c1b8e3ebf38e2be8972aa003e9614a782cb4f195c69d20b95a475ebf9aaaab9e",
|
|
343
|
+
"c8e375aab574148542f392b093cbbf3f2e8dc623826b45e35df0a1a2c3862226",
|
|
341
344
|
"ce282a55328c2d071cacc377670fbebea4da4b9a7fee81b25293fb26763f17c9",
|
|
342
345
|
"cf61a917a804503ef1e26fbb1cf498181e843a0758ec001f84edb7bcac837448",
|
|
343
346
|
"d6433bf0f96dd9dd485c3623885255738654674b3e23cfcc1a2cfd24f3cda96e",
|
|
@@ -115,6 +115,15 @@ def system_exit_code(code):
|
|
|
115
115
|
return code - 0x100000000 if os.name == "nt" and code >= 0x80000000 else code
|
|
116
116
|
|
|
117
117
|
|
|
118
|
+
def mark_not_content_indexed(path):
|
|
119
|
+
"""Disable Windows content indexing for a directory and children created afterwards."""
|
|
120
|
+
if os.name != "nt":
|
|
121
|
+
return
|
|
122
|
+
attrs = _kernel.GetFileAttributesW(str(path))
|
|
123
|
+
_checked(attrs != 0xFFFFFFFF, _kernel.GetFileAttributesW, (str(path),)) # INVALID_FILE_ATTRIBUTES.
|
|
124
|
+
_kernel.SetFileAttributesW(str(path), attrs | stat.FILE_ATTRIBUTE_NOT_CONTENT_INDEXED)
|
|
125
|
+
|
|
126
|
+
|
|
118
127
|
if os.name == "nt":
|
|
119
128
|
import _winapi
|
|
120
129
|
import ctypes
|
|
@@ -164,6 +173,7 @@ if os.name == "nt":
|
|
|
164
173
|
("DuplicateHandle", [wintypes.HANDLE, wintypes.HANDLE, wintypes.HANDLE, ctypes.POINTER(wintypes.HANDLE), wintypes.DWORD, wintypes.BOOL, wintypes.DWORD], wintypes.BOOL),
|
|
165
174
|
("CloseHandle", [wintypes.HANDLE], wintypes.BOOL),
|
|
166
175
|
("PeekNamedPipe", [wintypes.HANDLE, ctypes.c_void_p, wintypes.DWORD, ctypes.c_void_p, ctypes.POINTER(wintypes.DWORD), ctypes.c_void_p], wintypes.BOOL),
|
|
176
|
+
("SetFileAttributesW", [wintypes.LPCWSTR, wintypes.DWORD], wintypes.BOOL),
|
|
167
177
|
):
|
|
168
178
|
_function = getattr(_kernel, _name)
|
|
169
179
|
_function.argtypes, _function.restype, _function.errcheck = _args, _result, _checked
|
|
@@ -171,6 +181,9 @@ if os.name == "nt":
|
|
|
171
181
|
_kernel.WaitForSingleObject.argtypes = [wintypes.HANDLE, wintypes.DWORD]
|
|
172
182
|
_kernel.WaitForSingleObject.restype = wintypes.DWORD # WAIT_OBJECT_0 is successful zero, not BOOL.
|
|
173
183
|
|
|
184
|
+
_kernel.GetFileAttributesW.argtypes = [wintypes.LPCWSTR]
|
|
185
|
+
_kernel.GetFileAttributesW.restype = wintypes.DWORD # Failure is INVALID_FILE_ATTRIBUTES, not zero.
|
|
186
|
+
|
|
174
187
|
def _read_control(fd, count):
|
|
175
188
|
deadline = time.monotonic() + 10
|
|
176
189
|
data = b""
|
|
@@ -52,10 +52,12 @@ not write to the shared checkout. Queue drains retain their allocation order.
|
|
|
52
52
|
```sh
|
|
53
53
|
python3 "$DEVLYN_SHARED_DIR/task-complete.py" allocate --repo . \
|
|
54
54
|
--task '<task identity>' --branch '<absent task branch>' \
|
|
55
|
-
--worktree '<
|
|
55
|
+
--worktree '<repo parent>/<repo name>.devlyn.noindex/<task directory>' \
|
|
56
|
+
--repository '<owner/repo>' --remote origin --base main
|
|
56
57
|
```
|
|
57
58
|
|
|
58
|
-
Every delivery allocation owns a linked worktree; `--worktree` is required
|
|
59
|
+
Every delivery allocation owns a linked worktree; `--worktree` is required and
|
|
60
|
+
must name an absent path.
|
|
59
61
|
A publication allocation normally starts at the exact fetched remote base,
|
|
60
62
|
independent of the anchor's branch or dirty state, and leaves the anchor's
|
|
61
63
|
HEAD, index and files untouched. For a commit request, local-only work or
|
|
@@ -96,7 +96,7 @@ An absolute path to a package's `meta.md` is added as it is; anything else is pl
|
|
|
96
96
|
|
|
97
97
|
## status
|
|
98
98
|
|
|
99
|
-
Run `python3 "$DEVLYN_SKILL_DIR/scripts/queue.py" status --repo .` and report its counts, `next`, `blockers`, pending deliveries with their resume commands and any `cleanup` entry.
|
|
99
|
+
Run `python3 "$DEVLYN_SKILL_DIR/scripts/queue.py" status --repo .` and report its counts, `next`, `blockers`, `warnings`, pending deliveries with their resume commands and any `cleanup` entry.
|
|
100
100
|
|
|
101
101
|
## drain
|
|
102
102
|
|
|
@@ -125,4 +125,4 @@ A drain can run for hours. Do not end the turn until it exits: run it in the for
|
|
|
125
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 every `cleanup` diagnostic, including unavailable reports and retained parents
|
|
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, and all `warnings`; completed products keep their verdict. A package copy left in the checkout can prevent the `Bring into` command until it is removed.
|
|
@@ -11,10 +11,10 @@ python3 "$DEVLYN_SKILL_DIR/scripts/queue.py" drain --repo . [--local-only] [--wo
|
|
|
11
11
|
|
|
12
12
|
Both print one JSON object; exit 1 is `BLOCKED` with a `reason`.
|
|
13
13
|
|
|
14
|
-
- `status` 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 the printed completion command; add `--writers-stopped` only after stopping the task's writers), and under `cleanup` the package copies `add` kept or could not remove.
|
|
14
|
+
- `status` takes `queue.lock` in the repository's Git directory and finishes `add`'s cleanup (step 1); it reports the running `skill` root and version, any `warnings`, 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 the printed completion command; add `--writers-stopped` only after stopping the task's writers), and under `cleanup` the package copies `add` kept or could not remove.
|
|
15
15
|
- `drain` ends `DRAINED` (no runnable task remains; open deliveries show per task), `WAITING` (a task waits with its reason, or legacy rows need planning) or `BLOCKED` (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
|
-
- Worktrees default to `<repo parent>/<repo name>.devlyn/<loop-id>/<task-id
|
|
17
|
+
- Worktrees default to `<repo parent>/<repo name>.devlyn.noindex/<loop-id>/<task-id>`; tasks already allocated under an earlier default resume where their receipts record them. On Windows, a default root newly created by the drain gets the not-content-indexed attribute, inherited by new children (content only; names stay indexed). Microsoft Defender scanning is unaffected; use a Dev Drive via `--worktree-root` or an administrator's Defender exclusion for the root.
|
|
18
18
|
|
|
19
19
|
The host supplies the executor and maps its configured engine to the argv; the loop has no model preference. Each `{packet}` in the argv is replaced by the task packet path, and each `{worktree_git_dir}` by the task worktree's absolute Git directory. The executor runs with the task worktree as its working directory and the null device as stdin; its stdout and stderr are appended to `executor.stdout` and `executor.stderr` in the packet's `evidence_dir`, never a pipe.
|
|
20
20
|
|
|
@@ -112,6 +112,34 @@ def read_json(path):
|
|
|
112
112
|
raise LoopError(f"{path}: {exc}") from exc
|
|
113
113
|
|
|
114
114
|
|
|
115
|
+
def skill_diagnostics(anchor):
|
|
116
|
+
running = SKILLS / ".devlyn-install.json"
|
|
117
|
+
markers = dict.fromkeys([running, *(anchor / host / "skills/.devlyn-install.json" for host in (".claude", ".agents"))])
|
|
118
|
+
versions, errors = {}, {}
|
|
119
|
+
for marker in markers:
|
|
120
|
+
try:
|
|
121
|
+
data = json.loads(marker.read_text(encoding="utf-8"))
|
|
122
|
+
if not isinstance(data, dict) or not isinstance(data.get("version"), (str, type(None))):
|
|
123
|
+
raise ValueError("install marker must be an object with a string or null version")
|
|
124
|
+
versions[marker] = data.get("version")
|
|
125
|
+
except FileNotFoundError:
|
|
126
|
+
continue # Global-only and source checkouts need no repository install marker.
|
|
127
|
+
except (OSError, ValueError, RecursionError) as exc:
|
|
128
|
+
errors[marker] = str(exc)
|
|
129
|
+
skill = {"root": str(SKILLS), "version": versions.get(running)}
|
|
130
|
+
warnings = []
|
|
131
|
+
for marker in markers:
|
|
132
|
+
if marker in errors:
|
|
133
|
+
detail = f"unreadable: {errors[marker]}"
|
|
134
|
+
elif running not in errors and marker in versions and versions[marker] != skill["version"]:
|
|
135
|
+
detail = f"version {json.dumps(versions[marker])}"
|
|
136
|
+
else:
|
|
137
|
+
continue
|
|
138
|
+
warnings.append(f"Running skill {skill['root']} (version {json.dumps(skill['version'])}); "
|
|
139
|
+
f"install marker {marker} ({detail}); run npx devlyn-cli@latest update.")
|
|
140
|
+
return {"skill": skill, "warnings": warnings}
|
|
141
|
+
|
|
142
|
+
|
|
115
143
|
def write_json(path, value):
|
|
116
144
|
acceptance()["write_json"](path, value)
|
|
117
145
|
|
|
@@ -1034,6 +1062,17 @@ def allocate(v, row, opts):
|
|
|
1034
1062
|
if values["start"] and (drift := instruction_drift(anchor, start)):
|
|
1035
1063
|
return (f"the installed instructions ({drift}) in this checkout differ from those in its start commit {start}, so it would run "
|
|
1036
1064
|
f"without them; commit them and push them to origin/{manifest['base_ref']}")
|
|
1065
|
+
if os.name == "nt" and opts.default_worktree_root:
|
|
1066
|
+
try:
|
|
1067
|
+
opts.worktree_root.mkdir(exist_ok=False)
|
|
1068
|
+
except FileExistsError:
|
|
1069
|
+
pass # An existing root belongs to its owner; never change its attributes.
|
|
1070
|
+
else:
|
|
1071
|
+
try:
|
|
1072
|
+
shared("platform-support")["mark_not_content_indexed"](opts.worktree_root)
|
|
1073
|
+
except OSError as exc:
|
|
1074
|
+
opts.root_diagnostics.append(
|
|
1075
|
+
f"content indexing unchanged: {opts.worktree_root}: could not set FILE_ATTRIBUTE_NOT_CONTENT_INDEXED: {exc}; continuing")
|
|
1037
1076
|
result = task_complete("allocate", exclude_receipts=v["invalid_receipts"], **values)
|
|
1038
1077
|
progress(identity, f"allocated {result['worktree']}")
|
|
1039
1078
|
return None
|
|
@@ -1314,7 +1353,7 @@ def bring_in(v, loop):
|
|
|
1314
1353
|
return f"{receipt['base']}: git merge --ff {receipt['remote']}/{receipt['base']}"
|
|
1315
1354
|
|
|
1316
1355
|
|
|
1317
|
-
def write_reports(v, status, reason):
|
|
1356
|
+
def write_reports(v, status, reason, warnings):
|
|
1318
1357
|
paths = []
|
|
1319
1358
|
for loop in dict.fromkeys(row["loop"] for row in v["rows"] if row["identity"]):
|
|
1320
1359
|
path = v["common"] / "devlyn-loops" / loop / "drain-report.md"
|
|
@@ -1324,6 +1363,8 @@ def write_reports(v, status, reason):
|
|
|
1324
1363
|
f"- Queue: {f'{CAPTURES}{loop}:{loop_queue(loop)}' if loop in v['records'] else v['anchor'] / loop_queue(loop)}",
|
|
1325
1364
|
f"- Drain: {status}" + (f" — {reason}" if reason else ""), f"- Whole-loop acceptance: {loop_acceptance(v, loop, items)}",
|
|
1326
1365
|
*([f"- Bring into {line}"] if (line := bring_in(v, loop)) else []), ""]
|
|
1366
|
+
if warnings:
|
|
1367
|
+
lines += ["## Warnings", "", *(f"- {warning}" for warning in warnings), ""]
|
|
1327
1368
|
try:
|
|
1328
1369
|
meta, _ = load_manifest(v["anchor"], loop, capture(v, loop))
|
|
1329
1370
|
decisions = sections(
|
|
@@ -1393,8 +1434,12 @@ def prune_parents(v, root):
|
|
|
1393
1434
|
|
|
1394
1435
|
def drain(args):
|
|
1395
1436
|
anchor, common = repository(args.repo)
|
|
1437
|
+
args.repo = anchor # Preserve the repository root for failures reported by main.
|
|
1438
|
+
diagnostics = skill_diagnostics(anchor)
|
|
1396
1439
|
require(any("{packet}" in part for part in args.executor), "executor argv must contain {packet}, replaced by the task packet path")
|
|
1397
|
-
args.
|
|
1440
|
+
args.default_worktree_root = args.worktree_root is None
|
|
1441
|
+
args.root_diagnostics = []
|
|
1442
|
+
args.worktree_root = Path(args.worktree_root).resolve() if args.worktree_root else anchor.parent / f"{anchor.name}.devlyn.noindex"
|
|
1398
1443
|
with lock(common, "drain.lock", blocking=False):
|
|
1399
1444
|
attempted, last, status, reason, refused = set(), None, "DRAINED", None, {}
|
|
1400
1445
|
try:
|
|
@@ -1422,20 +1467,23 @@ def drain(args):
|
|
|
1422
1467
|
with contextlib.suppress(LoopError, OSError):
|
|
1423
1468
|
last = view(anchor, common, args.local_only)
|
|
1424
1469
|
if last is None:
|
|
1425
|
-
return {"status": status, "reason": reason}
|
|
1470
|
+
return {"status": status, "reason": reason, **diagnostics}
|
|
1471
|
+
last["cleanup"].extend(args.root_diagnostics)
|
|
1426
1472
|
annotate(last, refused)
|
|
1427
1473
|
prune_parents(last, args.worktree_root)
|
|
1428
|
-
return {"status": status, "reason": reason,
|
|
1474
|
+
return {"status": status, "reason": reason, **diagnostics,
|
|
1475
|
+
"reports": write_reports(last, status, reason, diagnostics["warnings"]), "counts": counts(last),
|
|
1429
1476
|
"tasks": [summary(last, row) for row in last["rows"] if row["identity"]], "cleanup": last["cleanup"]}
|
|
1430
1477
|
|
|
1431
1478
|
|
|
1432
1479
|
def status(args):
|
|
1433
1480
|
anchor, common = repository(args.repo)
|
|
1481
|
+
args.repo = anchor # Preserve the repository root for failures reported by main.
|
|
1434
1482
|
v = view(anchor, common, args.local_only)
|
|
1435
1483
|
row = next_task(v, set())
|
|
1436
1484
|
annotate(v, {})
|
|
1437
1485
|
tasks = [summary(v, r) for r in v["rows"] if r["identity"]]
|
|
1438
|
-
return {"status": "OK", "counts": counts(v), "next": row["identity"] if row else None,
|
|
1486
|
+
return {"status": "OK", **skill_diagnostics(anchor), "counts": counts(v), "next": row["identity"] if row else None,
|
|
1439
1487
|
"blockers": [f"{t['identity']}: {t['reason']}" for t in tasks if t.get("reason") and t["result"] in {"pending", "active", "blocked"}]
|
|
1440
1488
|
+ [f"legacy row {r['index'] + 1} needs planning: {r['text']}" for r in v["rows"] if not r["identity"] and r["mark"] == " "]
|
|
1441
1489
|
+ [f"unreadable receipt {item}" for item in v["unreadable"]],
|
|
@@ -1997,6 +2045,8 @@ def main():
|
|
|
1997
2045
|
result = {"check": check, "add": add, "status": status, "drain": drain}[args.action](args)
|
|
1998
2046
|
except (LoopError, OSError) as exc:
|
|
1999
2047
|
result = {"status": "BLOCKED", "reason": str(exc)}
|
|
2048
|
+
if args.action in {"status", "drain"}:
|
|
2049
|
+
result.update(skill_diagnostics(Path(args.repo)))
|
|
2000
2050
|
print(json.dumps(result, ensure_ascii=False, sort_keys=True))
|
|
2001
2051
|
return 1 if result["status"] == "BLOCKED" else 0
|
|
2002
2052
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "devlyn-cli",
|
|
3
|
-
"version": "4.2.
|
|
3
|
+
"version": "4.2.18",
|
|
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": {
|