devlyn-cli 4.2.25 → 4.3.0

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.
@@ -170,6 +170,58 @@ def lock(common, name, *, blocking):
170
170
  yield
171
171
 
172
172
 
173
+ def harness_call(name, *args):
174
+ helper = shared("harness-store")
175
+ try:
176
+ return helper[name](*args)
177
+ except helper["HarnessError"] as exc:
178
+ raise LoopError(str(exc)) from exc
179
+
180
+
181
+ def enforce_binding(common, anchor=None):
182
+ binding = harness_call("read_binding", common)
183
+ try:
184
+ harness_call("check_binding", binding, harness_call("resolve", SKILLS))
185
+ except (LoopError, OSError) as exc:
186
+ # A queue that settled outside queue.py (its last delivery finished through task-complete.py) keeps a binding no
187
+ # work needs: another release drops it instead of refusing. Callers outside the queue lock pass `anchor`.
188
+ if binding and anchor is not None and release_settled_binding(anchor, common, interrupted_add=True):
189
+ return enforce_binding(common)
190
+ if binding:
191
+ argv = harness_call("recovery_argv", binding, sys.argv[1:])
192
+ raise LoopError(f"{exc}; recovery argv: {json.dumps(argv, ensure_ascii=False)}") from exc
193
+ raise
194
+ return binding
195
+
196
+
197
+ def unfinished(v):
198
+ if v["unreadable"] or counts(v)["legacy_pending"]:
199
+ return True
200
+ for state in v["states"].values():
201
+ receipt = state["receipt"]
202
+ # A blocked task waits on a failed prerequisite for good, as drain counts it: it holds no binding.
203
+ if state.get("invalid") or state["kind"] in {"pending", "active"} or receipt and (
204
+ not receipt.get("queue") or receipt.get("delivery") not in SETTLED):
205
+ return True
206
+ # An interrupted capture with no add record is still resumable work.
207
+ refs = git(v["anchor"], "for-each-ref", "--format=%(refname)", CAPTURES).splitlines()
208
+ return any(ref.removeprefix(CAPTURES) not in v["records"] for ref in refs)
209
+
210
+
211
+ def release_settled_binding(anchor, common, *, interrupted_add=False):
212
+ """Remove the binding once nothing needs it. The bound release keeps one from an interrupted first add (no record
213
+ yet) so its retry rebinds the same identity; another release (`interrupted_add`) drops that too, since a binding
214
+ published before its capture captured nothing. Returns whether it was removed."""
215
+ with lock(common, "queue.lock", blocking=True):
216
+ if not harness_call("read_binding", common):
217
+ return False
218
+ v = view(anchor, common, pure=True)
219
+ if unfinished(v) or not (v["records"] or interrupted_add):
220
+ return False
221
+ (common / "devlyn-loops/harness.json").unlink()
222
+ return True
223
+
224
+
173
225
  def sections(text, path):
174
226
  found, current, fence = {}, None, False
175
227
  for line in text.split("\n"):
@@ -686,6 +738,7 @@ def derive_state(anchor, common, row, claims, rev):
686
738
  if packet.exists():
687
739
  bound = read_json(packet)
688
740
  require(isinstance(bound, dict) and not acceptance()["PACKET_KEYS"] - bound.keys(), f"{packet}: malformed task packet")
741
+ harness_call("packet_schema", bound)
689
742
  require(bound["worktree"] == receipt["worktree"] and bound["task"] == identity and bound["branch"] == receipt["branch"]
690
743
  and bound["allocation_base"] == receipt["baseline"],
691
744
  f"{packet}: conflicting packet ownership")
@@ -713,11 +766,13 @@ def derive_state(anchor, common, row, claims, rev):
713
766
  return state
714
767
 
715
768
 
716
- def view(anchor, common, local_only=False):
769
+ def view(anchor, common, local_only=False, *, pure=False):
717
770
  """Reconcile the queue with every receipt and recovery ref before anything is selected."""
718
- with lock(common, "queue.lock", blocking=True):
771
+ with contextlib.nullcontext() if pure else lock(common, "queue.lock", blocking=True):
772
+ if not pure:
773
+ enforce_binding(common)
719
774
  recorded = records(common)
720
- cleanup = clean(common, recorded)
775
+ cleanup = [] if pure else clean(common, recorded)
721
776
  rows = queue_view(anchor, recorded)
722
777
  claims, unreadable, damaged = {}, [], {}
723
778
  canonical = {receipt_path(common, row["identity"]): row["identity"] for row in rows if row["identity"]}
@@ -737,7 +792,7 @@ def view(anchor, common, local_only=False):
737
792
  if identity := canonical.get(path):
738
793
  damaged[identity] = str(exc)
739
794
  v = {"anchor": anchor, "common": common, "rows": rows, "records": recorded, "packages": {}, "manifests": {}, "states": {},
740
- "unreadable": unreadable, "cleanup": cleanup, "local_only": local_only}
795
+ "unreadable": unreadable, "cleanup": cleanup, "local_only": local_only, "pure": pure}
741
796
  errors = {}
742
797
  for row in v["rows"]:
743
798
  if row["identity"]:
@@ -902,7 +957,7 @@ def local_start(v, loop):
902
957
  return None, f"{branch}, the branch {loop} was added on, {where}; bring {base} into {branch}, or plan the work as a new loop"
903
958
  if drift := package_drift(v, loop, start, branch):
904
959
  return start, drift
905
- if drift := instruction_drift(v["anchor"], start):
960
+ if drift := instruction_drift(v["anchor"], start, pure=v.get("pure", False)):
906
961
  return start, f"the installed instructions ({drift}) in this checkout differ from those in its start commit {start}, so it would run without them; {remedy}"
907
962
  return start, None
908
963
 
@@ -992,12 +1047,14 @@ def package_drift(v, loop, start, where):
992
1047
  "bytes") if files else None
993
1048
 
994
1049
 
995
- def instruction_drift(anchor, rev):
1050
+ def instruction_drift(anchor, rev, *, pure=False):
996
1051
  """The installed instruction files whose content at `rev` differs from the anchor checkout's (both absent is equal): a
997
1052
  task starting at `rev` would run without them. Git compares them as `git status` does: a link by its target, line endings
998
1053
  under the checkout's conversion rules, and an untracked or ignored file as one still to commit."""
999
1054
  names = ("CLAUDE.md", "AGENTS.md")
1000
- differ = git(anchor, "diff", "--name-only", "--no-renames", rev, "--", *names).split("\n")
1055
+ # Git's porcelain diff refreshes the index independently of GIT_OPTIONAL_LOCKS.
1056
+ options = ("-c", "diff.autoRefreshIndex=false") if pure else ()
1057
+ differ = git(anchor, *options, "diff", "--name-only", "--no-renames", rev, "--", *names).split("\n")
1001
1058
  differ += git(anchor, "ls-files", "--others", "--", *names).split("\n")
1002
1059
  return ", ".join(name for name in names if name in differ)
1003
1060
 
@@ -1088,7 +1145,7 @@ def ensure_packet(v, row, path, receipt):
1088
1145
  return {"path": str(worktree / rel), "sha256": hashlib.sha256(show(worktree, inputs, rel)).hexdigest()}
1089
1146
 
1090
1147
  packet = {
1091
- "schema_version": 1, "task": identity, "title": spec["title"], "kind": spec["kind"],
1148
+ "schema_version": 2, "task": identity, "title": spec["title"], "kind": spec["kind"],
1092
1149
  "worktree": str(worktree), "branch": branch, "receipt": str(path), "scratch": str(path.parent / "scratch"),
1093
1150
  "allocation_base": baseline, "inputs_sha": inputs,
1094
1151
  "contract": identity_of(f"docs/specs/{loop}/{task}/spec.md"),
@@ -1105,6 +1162,31 @@ def ensure_packet(v, row, path, receipt):
1105
1162
  "runner": [sys.executable, str(Path(__file__).with_name("acceptance.py")), "run", "--packet", str(packet_path)],
1106
1163
  "obligations": OBLIGATIONS,
1107
1164
  }
1165
+ instructions = packet["methodology"]
1166
+ binding = harness_call("read_binding", v["common"])
1167
+ if binding:
1168
+ defaults = {"source": "release", **binding["defaults"]}
1169
+ else:
1170
+ # An unbound queue's executor still gets the principles: the invoking skill's own copy.
1171
+ own = SKILLS / "_shared/runtime-principles.md"
1172
+ defaults = {"source": "skill", **harness_call("file_identity", own)} if own.is_file() else None
1173
+ if defaults is None and not instructions:
1174
+ raise Waiting(f"{identity}: no principles reach its executor — neither CLAUDE.md/AGENTS.md at the inputs commit nor "
1175
+ f"{SKILLS / '_shared/runtime-principles.md'}; reinstall devlyn-cli to restore that file, then drain again")
1176
+ packet.update(harness=binding["release"] if binding else None,
1177
+ methodology={"defaults": defaults, "projectInstructions": instructions}, workerPrompt=None)
1178
+ if binding:
1179
+ harness_call("check_binding", binding, harness_call("resolve", SKILLS))
1180
+ bound_skills = Path(binding["release"]["root"]) / "config/skills"
1181
+ packet["runner"][1] = str(bound_skills / "devlyn-ideate/scripts/acceptance.py")
1182
+ prompt = path.parent / "worker-prompt.md"
1183
+ body = (f"Execute task packet {packet_path}. Work only in {worktree} on its owned branch.\n"
1184
+ f"Read {binding['defaults']['path']} and apply it as defaults; explicit user and native owner/project instructions win.\n"
1185
+ f"Set DEVLYN_SKILL_DIR={bound_skills / 'devlyn-ideate'} and DEVLYN_SHARED_DIR={bound_skills / '_shared'}.\n"
1186
+ f"Follow Executor exchange and Review records in {bound_skills / 'devlyn-ideate/references/loop.md'}.\n"
1187
+ f"Meet the packet obligations, run its runner and write {packet['submission']}. Never push, open a pull request or merge.\n")
1188
+ acceptance()["atomic_write"](prompt, body.encode("utf-8"))
1189
+ packet["workerPrompt"] = harness_call("file_identity", prompt)
1108
1190
  write_json(packet_path, packet)
1109
1191
  return packet_path, packet
1110
1192
 
@@ -1125,6 +1207,7 @@ def ensure_submission(identity, packet_path, packet, executor):
1125
1207
  An attempt's start is recorded before the spawn and its exit after the wait, so a start without an exit may have
1126
1208
  left a live executor: drain observes once and waits if it is active. Only established cessation permits adoption
1127
1209
  or another execution. Where writers cannot be observed the task fails as interrupted-unobservable."""
1210
+ harness_call("verify_packet", packet, packet_path, SKILLS)
1128
1211
  submission = Path(packet["submission"])
1129
1212
  worktree = Path(packet["worktree"])
1130
1213
  log = submission.with_name("executions.log")
@@ -1144,6 +1227,10 @@ def ensure_submission(identity, packet_path, packet, executor):
1144
1227
  raise LoopError(f"cannot establish that an interrupted execution stopped: {exc}; repair writer observation, then drain again") from exc
1145
1228
  if submission.exists():
1146
1229
  return None
1230
+ defaults = packet["methodology"].get("defaults") if packet.get("schema_version") == 2 else None
1231
+ if packet.get("harness") is None and defaults and not Path(defaults["path"]).is_file():
1232
+ # An installation's own copy moves with its updates; only its absence matters, and only before a spawn.
1233
+ raise Waiting(f"{identity}: its principles file {defaults['path']} is gone; reinstall devlyn-cli to restore it, then drain again")
1147
1234
  argv = [part.replace("{packet}", str(packet_path)) for part in executor]
1148
1235
  if any("{worktree_git_dir}" in part for part in argv): # Optional: a Codex executor lists it as a writable root.
1149
1236
  git_dir = git(worktree, "rev-parse", "--path-format=absolute", "--git-dir")
@@ -1217,6 +1304,8 @@ def advance(v, row, opts):
1217
1304
  if not path.exists() and (refused := allocate(v, row, opts)):
1218
1305
  return refused
1219
1306
  receipt = read_json(path)
1307
+ if receipt.get("acceptance") and (path.parent / "packet.json").exists():
1308
+ harness_call("verify_packet", read_json(path.parent / "packet.json"), path.parent / "packet.json", SKILLS)
1220
1309
  if not receipt.get("acceptance"):
1221
1310
  state["phase"] = "inputs"
1222
1311
  failure = v["states"][identity].get("inputs_changed")
@@ -1269,7 +1358,9 @@ def summary(v, row):
1269
1358
  branch = receipt["branch"]
1270
1359
  if receipt.get("acceptance") and receipt.get("delivery") not in SETTLED:
1271
1360
  if receipt.get("queue"):
1272
- argv = [sys.executable, str(SKILLS / "_shared/task-complete.py"), "complete", "--receipt", str(state["path"])]
1361
+ binding = harness_call("read_binding", v["common"])
1362
+ skills = Path(binding["release"]["root"]) / "config/skills" if binding else SKILLS
1363
+ argv = [sys.executable, str(skills / "_shared/task-complete.py"), "complete", "--receipt", str(state["path"])]
1273
1364
  if receipt.get("writers_released"):
1274
1365
  argv.append("--writers-stopped")
1275
1366
  if not receipt.get("local_only") and not receipt.get("mode_override"):
@@ -1420,6 +1511,7 @@ def prune_parents(v, root):
1420
1511
  def drain(args):
1421
1512
  anchor, common = repository(args.repo)
1422
1513
  args.repo = anchor # Preserve the repository root for failures reported by main.
1514
+ enforce_binding(common, anchor)
1423
1515
  diagnostics = skill_diagnostics(anchor)
1424
1516
  require(any("{packet}" in part for part in args.executor), "executor argv must contain {packet}, replaced by the task packet path")
1425
1517
  args.default_worktree_root = args.worktree_root is None
@@ -1456,15 +1548,17 @@ def drain(args):
1456
1548
  last["cleanup"].extend(args.root_diagnostics)
1457
1549
  annotate(last, refused)
1458
1550
  prune_parents(last, args.worktree_root)
1551
+ try:
1552
+ release_settled_binding(anchor, common)
1553
+ except (LoopError, OSError) as exc:
1554
+ last["cleanup"].append(f"queue binding not released: {exc}; the next status or drain retries")
1459
1555
  return {"status": status, "reason": reason, **diagnostics,
1460
1556
  "reports": write_reports(last, status, reason, diagnostics["warnings"]), "counts": counts(last),
1461
1557
  "tasks": [summary(last, row) for row in last["rows"] if row["identity"]], "cleanup": last["cleanup"]}
1462
1558
 
1463
1559
 
1464
- def status(args):
1465
- anchor, common = repository(args.repo)
1466
- args.repo = anchor # Preserve the repository root for failures reported by main.
1467
- v = view(anchor, common, args.local_only)
1560
+ def status_summary(v):
1561
+ anchor = v["anchor"]
1468
1562
  row = next_task(v, set())
1469
1563
  annotate(v, {})
1470
1564
  tasks = [summary(v, r) for r in v["rows"] if r["identity"]]
@@ -1475,6 +1569,41 @@ def status(args):
1475
1569
  "delivery": [t for t in tasks if t.get("resume")], "cleanup": v["cleanup"]}
1476
1570
 
1477
1571
 
1572
+ def status(args):
1573
+ anchor, common = repository(args.repo)
1574
+ args.repo = anchor
1575
+ enforce_binding(common, anchor)
1576
+ result = status_summary(view(anchor, common, args.local_only))
1577
+ release_settled_binding(anchor, common)
1578
+ return result
1579
+
1580
+
1581
+ def inspect(args):
1582
+ anchor, common = repository(args.repo)
1583
+ try:
1584
+ result = status_summary(view(anchor, common, pure=True))
1585
+ except (LoopError, OSError) as exc:
1586
+ result = {"status": "BLOCKED", "reason": str(exc), **skill_diagnostics(anchor)}
1587
+ binding_path = common / "devlyn-loops/harness.json"
1588
+ binding = None
1589
+ compatibility = {"compatible": True, "legacy": not (binding_path.exists() or binding_path.is_symlink()), "reasons": [], "recoveryArgv": None,
1590
+ "taskPacket": {"inspect": [1, 2], "execute": [1, 2], "write": 2}, "queueBinding": {"read": [1, 1], "write": 1}}
1591
+ try:
1592
+ binding = read_json(binding_path) if not compatibility["legacy"] else None
1593
+ enforce_binding(common)
1594
+ for packet in (common / "devlyn-completion").glob("*/packet.json"):
1595
+ harness_call("packet_schema", read_json(packet))
1596
+ except (LoopError, OSError) as exc:
1597
+ compatibility.update(compatible=False, reasons=[str(exc)])
1598
+ try:
1599
+ valid = harness_call("read_binding", common)
1600
+ if valid:
1601
+ compatibility["recoveryArgv"] = harness_call("recovery_argv", valid, ["inspect", "--repo", str(anchor), "--json"])
1602
+ except (LoopError, OSError):
1603
+ pass # The diagnostic above describes the malformed binding; no safe argv can be inferred.
1604
+ return {**result, "binding": binding, "compatibility": compatibility}
1605
+
1606
+
1478
1607
  def legacy_target(anchor, number):
1479
1608
  """The pending legacy row on line `number` of the legacy queue, as add records it: its exact text and its occurrence
1480
1609
  among equal rows, line endings aside; None for any other line."""
@@ -1501,7 +1630,9 @@ def add(args):
1501
1630
  ref, package_dir = CAPTURES + loop, f"docs/specs/{loop}"
1502
1631
  require(meta.name == "meta.md" and meta.parent.parent == anchor / "docs/specs" and re.fullmatch(ID, loop),
1503
1632
  f"package must be <repo>/docs/specs/<loop-id>/meta.md: {meta}")
1633
+ enforce_binding(common, anchor)
1504
1634
  with lock(common, "queue.lock", blocking=True):
1635
+ enforce_binding(common)
1505
1636
  recorded = records(common)
1506
1637
  notes = clean(common, recorded)
1507
1638
  target = legacy_target(anchor, args.materialize) if args.materialize else None
@@ -1540,6 +1671,11 @@ def add(args):
1540
1671
  require(" ".join(target["line"][6:].split()) in intent, "meta.md '## Intent' must reproduce the legacy row's intent verbatim")
1541
1672
  paths = [f"{package_dir}/meta.md", *(f"{package_dir}/{task}/{name}" for task in package["tasks"] for name in ("spec.md", "spec.expected.json"))]
1542
1673
  files = {rel: package_bytes(anchor, None, rel) for rel in paths} | {loop_queue(loop): loop_rows(loop, package, target)}
1674
+ # The lock covers both first binding and first capture; an interrupted binding is authoritative on retry.
1675
+ if harness_call("read_binding", common) is None:
1676
+ invoking = harness_call("resolve", SKILLS)
1677
+ if invoking is not None and not unfinished(view(anchor, common, pure=True)):
1678
+ write_json(common / "devlyn-loops/harness.json", invoking)
1543
1679
  tree = build_tree(anchor, None, files)
1544
1680
  if captured:
1545
1681
  require(tree == git(anchor, "rev-parse", captured + "^{tree}"), interrupted)
@@ -2016,6 +2152,9 @@ def main():
2016
2152
  action = actions.add_parser(name)
2017
2153
  action.add_argument("--repo", default=".")
2018
2154
  action.add_argument("--local-only", "--no-push", action="store_true")
2155
+ inspector = actions.add_parser("inspect")
2156
+ inspector.add_argument("--repo", default=".")
2157
+ inspector.add_argument("--json", action="store_true")
2019
2158
  actions.choices["drain"].add_argument("--worktree-root")
2020
2159
  actions.choices["drain"].add_argument("executor", nargs="+", help="host executor argv after --; {packet} becomes the packet path")
2021
2160
  args = parser.parse_args()
@@ -2025,9 +2164,11 @@ def main():
2025
2164
  print(f"queue self-test: PASS ({result.testsRun} tests)")
2026
2165
  return 0 if result.wasSuccessful() else 1
2027
2166
  if args.action is None:
2028
- parser.error("check, add, status or drain is required")
2167
+ parser.error("check, add, status, inspect or drain is required")
2168
+ if args.action == "inspect":
2169
+ os.environ["GIT_OPTIONAL_LOCKS"] = "0" # git diff may otherwise refresh the index even with unchanged bytes.
2029
2170
  try:
2030
- result = {"check": check, "add": add, "status": status, "drain": drain}[args.action](args)
2171
+ result = {"check": check, "add": add, "status": status, "drain": drain, "inspect": inspect}[args.action](args)
2031
2172
  except (LoopError, OSError) as exc:
2032
2173
  result = {"status": "BLOCKED", "reason": str(exc)}
2033
2174
  if args.action in {"status", "drain"}:
package/package.json CHANGED
@@ -1,14 +1,21 @@
1
1
  {
2
2
  "name": "devlyn-cli",
3
- "version": "4.2.25",
3
+ "version": "4.3.0",
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": {
7
7
  "devlyn": "bin/devlyn.js"
8
8
  },
9
+ "scripts": {
10
+ "prepack": "node -e \"require('./bin/release').buildRelease()\"",
11
+ "postpack": "node -e \"require('./bin/release').cleanGenerated()\""
12
+ },
9
13
  "files": [
10
14
  "bin",
11
15
  "config",
16
+ "harness",
17
+ "native",
18
+ "release.json",
12
19
  "!config/skills/preflight-workspace",
13
20
  "!config/skills/preflight-workspace/**",
14
21
  "!config/skills/devlyn:ideate-workspace",