devlyn-cli 4.2.15 → 4.2.17

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.
@@ -58,6 +58,7 @@
58
58
  "5995fcdb203a6b98c3e7cae4fe6cc7a3fb57f06a5d32069576e58350d50eab6f",
59
59
  "59f0ecc2c1fd9620e4cac06be370e0bf9965a35c20616047445dbc42da6a1677",
60
60
  "5be2e643f96b6ce503ae8d631c67729f0c24b6c5d2b431c087161b520ccbe417",
61
+ "60cdd1a8b220e629bcba3a24652323d16e3e6a15ba5b07c69ef2785a9c2eca70",
61
62
  "61ec4c0435a2f0b307df8e88d81d9862c3abc1bf758648f2c18039581596659b",
62
63
  "628a40e873d156e9179fd5a6bb3da9f910b6dde0886055804a506cdf8a631aaa",
63
64
  "628ee7de57717804cb2b4f4c9357751578276f241eb3ab53fdee49ae92d57513",
@@ -337,6 +338,7 @@
337
338
  "aba66b674683935e4c28c93333896dc116ffe67e7a35fe30b3599a8be507e94e",
338
339
  "b2e4437fa981317362d03ee032e1cfd8b686316e966c6ba12ecd619a009c80a6",
339
340
  "c1b8e3ebf38e2be8972aa003e9614a782cb4f195c69d20b95a475ebf9aaaab9e",
341
+ "c8e375aab574148542f392b093cbbf3f2e8dc623826b45e35df0a1a2c3862226",
340
342
  "ce282a55328c2d071cacc377670fbebea4da4b9a7fee81b25293fb26763f17c9",
341
343
  "cf61a917a804503ef1e26fbb1cf498181e843a0758ec001f84edb7bcac837448",
342
344
  "d6433bf0f96dd9dd485c3623885255738654674b3e23cfcc1a2cfd24f3cda96e",
@@ -521,6 +521,18 @@ def raise_walk_error(error):
521
521
  raise error
522
522
 
523
523
 
524
+ def records_manifest(root):
525
+ require(root == root.resolve() and root.is_dir(), f"records root must be a nonsymlink directory; repair and resume: {root}")
526
+ devlyn = safe_path(root, ".devlyn")
527
+ if not devlyn.exists():
528
+ return {}
529
+ require(devlyn.is_dir(), f".devlyn records must be a directory; repair and resume: {devlyn}")
530
+ paths = [str((Path(directory) / name).relative_to(root))
531
+ for directory, dirs, files in os.walk(devlyn, onerror=raise_walk_error)
532
+ for name in files + [name for name in dirs if (Path(directory) / name).is_symlink()]]
533
+ return snapshot_files(root, paths) if paths else {}
534
+
535
+
524
536
  def clean_scratch(receipt, path, writers_stopped):
525
537
  """Empty the owned build directory; keep its identity across resume/reuse."""
526
538
  scratch = path.parent / "scratch"
@@ -583,23 +595,20 @@ def cleanup(receipt, path, pr):
583
595
  require(receipt.get("writers_released"), "wait actual children, stop/yield known writers, then resume with --writers-stopped")
584
596
  require(work != Path(receipt["anchor"]) and work != Path(receipt["common_gitdir"]).parent, "cannot remove retained/main checkout")
585
597
  removable(receipt, work)
586
- devlyn = safe_path(work, ".devlyn")
587
- if devlyn.exists():
588
- require(devlyn.is_dir(), ".devlyn records must be a directory; retain tree")
589
- paths = [str((Path(directory) / name).relative_to(work))
590
- for directory, dirs, files in os.walk(devlyn, onerror=raise_walk_error)
591
- for name in files + [name for name in dirs if (Path(directory) / name).is_symlink()]]
592
- if paths:
593
- records = snapshot_files(work, paths)
594
- destination = path.parent / "records"
595
- if not destination.exists():
596
- with tempfile.TemporaryDirectory(prefix=".records-", dir=path.parent) as temporary:
597
- shutil.copytree(devlyn, Path(temporary) / ".devlyn", symlinks=True)
598
- verify_files(Path(temporary), records)
599
- Path(temporary).rename(destination)
600
- verify_files(destination, records)
601
- verify_files(work, records)
598
+ records = records_manifest(work)
599
+ if records:
600
+ destination = path.parent / "records"
601
+ if os.path.lexists(destination) and records_manifest(destination) != records:
602
+ digest = hashlib.sha256(json.dumps(records, sort_keys=True, separators=(",", ":")).encode()).hexdigest()[:12]
603
+ destination = path.parent / ("records." + digest)
604
+ if not os.path.lexists(destination):
605
+ with tempfile.TemporaryDirectory(prefix=".records-", dir=path.parent) as temporary:
606
+ shutil.copytree(safe_path(work, ".devlyn"), Path(temporary) / ".devlyn", symlinks=True)
607
+ require(records_manifest(Path(temporary)) == records, ".devlyn changed while it was copied; stop its writer and resume")
608
+ Path(temporary).rename(destination)
609
+ require(records_manifest(destination) == records, f"records copy mismatch; repair and resume: {destination}")
602
610
  removable(receipt, work)
611
+ require(records_manifest(work) == records, ".devlyn changed after it was copied; stop its writer and resume")
603
612
  gref(receipt, "worktree", "remove", str(work))
604
613
  else:
605
614
  require(str(work) not in registrations(receipt), "missing worktree is still registered; retain")
@@ -1637,10 +1646,16 @@ ctypes.CDLL(None).pthread_exit(None)
1637
1646
  self.assertEqual(result["status"], "PR")
1638
1647
  self.assertTrue(json.loads(receipt.read_text())["writers_released"])
1639
1648
  self.merge_pr()
1649
+ original = records_manifest(task)
1650
+ shutil.copytree(task / ".devlyn", receipt.parent / "records/.devlyn")
1651
+ (task / ".devlyn/storage-cleanup.json").write_bytes(b"{}\n")
1652
+ final = records_manifest(task)
1640
1653
  server = json.loads(self.data.read_text())
1641
1654
  result = self.allocate("released")
1642
1655
  self.assertEqual([(row["receipt"], row["status"]) for row in result["reconciled"]], [(str(receipt), "COMPLETE")])
1643
1656
  self.assertFalse(task.exists())
1657
+ self.assertEqual(records_manifest(receipt.parent / "records"), original)
1658
+ self.assertEqual(records_manifest(next(receipt.parent.glob("records.*"))), final)
1644
1659
  self.assertEqual(self.g("branch", "--list", "task/fixture"), "")
1645
1660
  self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture"), "")
1646
1661
  current = json.loads(self.data.read_text())
@@ -1840,16 +1855,123 @@ ctypes.CDLL(None).pthread_exit(None)
1840
1855
  saved = self.receipt.parent / "records/.devlyn"
1841
1856
  shutil.copytree(self.task / ".devlyn", saved)
1842
1857
  (saved / "checks.txt").write_bytes(b"tampered copy")
1843
- result, _ = self.complete(success=False)
1844
- self.assertIn("bytes/mode changed", result["reason"])
1858
+ final = records_manifest(self.task)
1859
+ result, _ = self.complete()
1860
+ self.assertEqual(result["status"], "COMPLETE")
1861
+ self.assertFalse(self.task.exists())
1845
1862
  self.assertEqual((saved / "checks.txt").read_bytes(), b"tampered copy")
1863
+ self.assertEqual(records_manifest(next(self.receipt.parent.glob("records.*"))), final)
1864
+
1865
+ def test_records_resume_preserves_added_and_changed_files(self):
1866
+ self.allocate(); self.accept()
1867
+ self.complete("--mode", "pr", "--writers-stopped")
1868
+ self.merge_pr()
1869
+ extra = self.task / ".devlyn/extra.log"
1870
+ extra.write_bytes(b"before\n")
1871
+ saved = self.receipt.parent / "records"
1872
+ shutil.copytree(self.task / ".devlyn", saved / ".devlyn")
1873
+ original = records_manifest(saved)
1874
+ extra.write_bytes(b"after\n")
1875
+ extra.chmod(0o640)
1876
+ (self.task / ".devlyn/storage-cleanup.json").write_bytes(b'{"status":"retained"}\n')
1877
+ final = records_manifest(self.task)
1878
+ digest = hashlib.sha256(json.dumps(final, sort_keys=True, separators=(",", ":")).encode()).hexdigest()[:12]
1879
+ result, _ = self.complete()
1880
+ self.assertEqual(result["status"], "COMPLETE")
1881
+ self.assertFalse(self.task.exists())
1882
+ self.assertEqual(records_manifest(saved), original)
1883
+ self.assertEqual(records_manifest(self.receipt.parent / ("records." + digest)), final)
1884
+
1885
+ def test_records_resume_preserves_deleted_file_only_in_earlier_copy(self):
1886
+ self.allocate(); self.accept()
1887
+ self.complete("--mode", "pr", "--writers-stopped")
1888
+ self.merge_pr()
1889
+ extra = self.task / ".devlyn/extra.log"
1890
+ extra.write_bytes(b"before\n")
1891
+ saved = self.receipt.parent / "records"
1892
+ shutil.copytree(self.task / ".devlyn", saved / ".devlyn")
1893
+ original = records_manifest(saved)
1894
+ extra.unlink()
1895
+ final = records_manifest(self.task)
1896
+ result, _ = self.complete()
1897
+ self.assertEqual(result["status"], "COMPLETE")
1898
+ self.assertFalse(self.task.exists())
1899
+ self.assertEqual(records_manifest(saved), original)
1900
+ generation = next(self.receipt.parent.glob("records.*"))
1901
+ self.assertEqual(records_manifest(generation), final)
1902
+ self.assertFalse((generation / ".devlyn/extra.log").exists())
1903
+
1904
+ def test_records_generation_reuse_and_mismatch(self):
1905
+ self.allocate(); self.accept()
1906
+ self.complete("--mode", "pr", "--writers-stopped")
1907
+ self.merge_pr()
1908
+ saved = self.receipt.parent / "records"
1909
+ shutil.copytree(self.task / ".devlyn", saved / ".devlyn")
1910
+ original = records_manifest(saved)
1911
+ (self.task / ".devlyn/storage-cleanup.json").write_bytes(b"{}\n")
1912
+ final = records_manifest(self.task)
1913
+ digest = hashlib.sha256(json.dumps(final, sort_keys=True, separators=(",", ":")).encode()).hexdigest()[:12]
1914
+ generation = self.receipt.parent / ("records." + digest)
1915
+ shutil.copytree(self.task / ".devlyn", generation / ".devlyn")
1916
+ extra = generation / ".devlyn/unexpected"
1917
+ extra.write_bytes(b"keep this copy\n")
1918
+ mismatch = records_manifest(generation)
1919
+ result, _ = self.complete(success=False)
1920
+ self.assertIn(generation.name, result["reason"])
1846
1921
  self.assertTrue(self.task.exists())
1847
- self.assertNotEqual(self.g("branch", "--list", "task/fixture"), "")
1848
- self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture").split()[0], self.sha)
1849
- shutil.copy2(self.task / ".devlyn/checks.txt", saved / "checks.txt")
1922
+ self.assertEqual(records_manifest(generation), mismatch)
1923
+ self.assertEqual(records_manifest(saved), original)
1924
+ extra.unlink() # Owner repairs the mismatched copy before resuming.
1925
+ identity = generation.stat()
1926
+ result, _ = self.complete()
1927
+ self.assertEqual(result["status"], "COMPLETE")
1928
+ self.assertFalse(self.task.exists())
1929
+ self.assertEqual(records_manifest(saved), original)
1930
+ self.assertEqual(records_manifest(generation), final)
1931
+ self.assertEqual(generation.stat(), identity)
1932
+
1933
+ def test_records_change_after_copy_retains_until_resume(self):
1934
+ from unittest.mock import patch
1935
+ self.allocate(); self.accept()
1936
+ self.complete("--mode", "pr", "--writers-stopped")
1937
+ self.merge_pr()
1938
+ original = records_manifest(self.task)
1939
+ saved = self.receipt.parent / "records"
1940
+ check = removable
1941
+ def changed(receipt, work):
1942
+ check(receipt, work)
1943
+ if saved.exists():
1944
+ (work / ".devlyn/storage-cleanup.json").write_bytes(b"{}\n")
1945
+ args = argparse.Namespace(receipt=str(self.receipt), acceptance=None, mode=None, local_only=False, writers_stopped=False)
1946
+ with patch.dict(os.environ, self.env), patch.dict(cleanup.__globals__, {"removable": changed}):
1947
+ with self.assertRaisesRegex(CompletionError, "changed after it was copied; stop its writer and resume"):
1948
+ complete(args)
1949
+ self.assertTrue(self.task.exists())
1950
+ self.assertEqual(records_manifest(saved), original)
1951
+ final = records_manifest(self.task)
1850
1952
  result, _ = self.complete()
1851
1953
  self.assertEqual(result["status"], "COMPLETE")
1852
1954
  self.assertFalse(self.task.exists())
1955
+ self.assertEqual(records_manifest(saved), original)
1956
+ self.assertEqual(records_manifest(next(self.receipt.parent.glob("records.*"))), final)
1957
+
1958
+ def test_records_staged_extra_file_retains_without_publishing(self):
1959
+ from unittest.mock import patch
1960
+ self.allocate(); self.accept()
1961
+ self.complete("--mode", "pr", "--writers-stopped")
1962
+ self.merge_pr()
1963
+ copy = shutil.copytree
1964
+ def changed(source, destination, **kwargs):
1965
+ result = copy(source, destination, **kwargs)
1966
+ (destination / "unexpected").write_bytes(b"late write\n")
1967
+ return result
1968
+ args = argparse.Namespace(receipt=str(self.receipt), acceptance=None, mode=None, local_only=False, writers_stopped=False)
1969
+ with patch.dict(os.environ, self.env), patch.object(shutil, "copytree", changed):
1970
+ with self.assertRaisesRegex(CompletionError, "changed while it was copied; stop its writer and resume"):
1971
+ complete(args)
1972
+ self.assertTrue(self.task.exists())
1973
+ self.assertFalse((self.receipt.parent / "records").exists())
1974
+ self.assertEqual(list(self.receipt.parent.glob(".records-*")), [])
1853
1975
 
1854
1976
  def test_linked_custody_and_interrupted_removal(self):
1855
1977
  self.allocate(); self.accept()
@@ -218,8 +218,10 @@ The receipt directory holds byte-verified `custody/`, `manifest.json` and a guar
218
218
  before publication; removal rechecks it, the recovery ref and the merge commit on base.
219
219
  Ignored build output and other ignored files are disposable under native
220
220
  non-force worktree removal. The tree's `.devlyn/` files are byte/mode-verified
221
- and preserved as `<receipt dir>/records/.devlyn`; non-regular files or symlinks
222
- there retain the tree. Partial/corrupt custody or records retain it for repair.
221
+ and preserved as `<receipt dir>/records/.devlyn`; changes after an earlier copy
222
+ are preserved beside it as `records.<digest>/.devlyn`, never rewriting earlier copies.
223
+ Non-regular files or symlinks there, partial/corrupt custody, an unreadable
224
+ earlier copy or a mismatched `records.<digest>` copy retain the tree for repair.
223
225
 
224
226
  Legacy in-place receipts retire refs only once their task branch is no longer
225
227
  checked out; their checkout is never modified. Local ref deletion is guarded by
@@ -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; completed products keep their verdict. A package copy left in the checkout can prevent the `Bring into` 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, 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,7 +11,7 @@ python3 "$DEVLYN_SKILL_DIR/scripts/queue.py" drain --repo . [--local-only] [--wo
11
11
 
12
12
  Both print one JSON object; exit 1 is `BLOCKED` with a `reason`.
13
13
 
14
- - `status` 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
17
  - Worktrees default to `<repo parent>/<repo name>.devlyn/<loop-id>/<task-id>`.
@@ -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
 
@@ -1314,7 +1342,7 @@ def bring_in(v, loop):
1314
1342
  return f"{receipt['base']}: git merge --ff {receipt['remote']}/{receipt['base']}"
1315
1343
 
1316
1344
 
1317
- def write_reports(v, status, reason):
1345
+ def write_reports(v, status, reason, warnings):
1318
1346
  paths = []
1319
1347
  for loop in dict.fromkeys(row["loop"] for row in v["rows"] if row["identity"]):
1320
1348
  path = v["common"] / "devlyn-loops" / loop / "drain-report.md"
@@ -1324,6 +1352,8 @@ def write_reports(v, status, reason):
1324
1352
  f"- Queue: {f'{CAPTURES}{loop}:{loop_queue(loop)}' if loop in v['records'] else v['anchor'] / loop_queue(loop)}",
1325
1353
  f"- Drain: {status}" + (f" — {reason}" if reason else ""), f"- Whole-loop acceptance: {loop_acceptance(v, loop, items)}",
1326
1354
  *([f"- Bring into {line}"] if (line := bring_in(v, loop)) else []), ""]
1355
+ if warnings:
1356
+ lines += ["## Warnings", "", *(f"- {warning}" for warning in warnings), ""]
1327
1357
  try:
1328
1358
  meta, _ = load_manifest(v["anchor"], loop, capture(v, loop))
1329
1359
  decisions = sections(
@@ -1393,6 +1423,8 @@ def prune_parents(v, root):
1393
1423
 
1394
1424
  def drain(args):
1395
1425
  anchor, common = repository(args.repo)
1426
+ args.repo = anchor # Preserve the repository root for failures reported by main.
1427
+ diagnostics = skill_diagnostics(anchor)
1396
1428
  require(any("{packet}" in part for part in args.executor), "executor argv must contain {packet}, replaced by the task packet path")
1397
1429
  args.worktree_root = Path(args.worktree_root).resolve() if args.worktree_root else anchor.parent / f"{anchor.name}.devlyn"
1398
1430
  with lock(common, "drain.lock", blocking=False):
@@ -1422,20 +1454,22 @@ def drain(args):
1422
1454
  with contextlib.suppress(LoopError, OSError):
1423
1455
  last = view(anchor, common, args.local_only)
1424
1456
  if last is None:
1425
- return {"status": status, "reason": reason}
1457
+ return {"status": status, "reason": reason, **diagnostics}
1426
1458
  annotate(last, refused)
1427
1459
  prune_parents(last, args.worktree_root)
1428
- return {"status": status, "reason": reason, "reports": write_reports(last, status, reason), "counts": counts(last),
1460
+ return {"status": status, "reason": reason, **diagnostics,
1461
+ "reports": write_reports(last, status, reason, diagnostics["warnings"]), "counts": counts(last),
1429
1462
  "tasks": [summary(last, row) for row in last["rows"] if row["identity"]], "cleanup": last["cleanup"]}
1430
1463
 
1431
1464
 
1432
1465
  def status(args):
1433
1466
  anchor, common = repository(args.repo)
1467
+ args.repo = anchor # Preserve the repository root for failures reported by main.
1434
1468
  v = view(anchor, common, args.local_only)
1435
1469
  row = next_task(v, set())
1436
1470
  annotate(v, {})
1437
1471
  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,
1472
+ return {"status": "OK", **skill_diagnostics(anchor), "counts": counts(v), "next": row["identity"] if row else None,
1439
1473
  "blockers": [f"{t['identity']}: {t['reason']}" for t in tasks if t.get("reason") and t["result"] in {"pending", "active", "blocked"}]
1440
1474
  + [f"legacy row {r['index'] + 1} needs planning: {r['text']}" for r in v["rows"] if not r["identity"] and r["mark"] == " "]
1441
1475
  + [f"unreadable receipt {item}" for item in v["unreadable"]],
@@ -1997,6 +2031,8 @@ def main():
1997
2031
  result = {"check": check, "add": add, "status": status, "drain": drain}[args.action](args)
1998
2032
  except (LoopError, OSError) as exc:
1999
2033
  result = {"status": "BLOCKED", "reason": str(exc)}
2034
+ if args.action in {"status", "drain"}:
2035
+ result.update(skill_diagnostics(Path(args.repo)))
2000
2036
  print(json.dumps(result, ensure_ascii=False, sort_keys=True))
2001
2037
  return 1 if result["status"] == "BLOCKED" else 0
2002
2038
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devlyn-cli",
3
- "version": "4.2.15",
3
+ "version": "4.2.17",
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": {