devlyn-cli 4.2.26 → 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.
@@ -0,0 +1,201 @@
1
+ """Release identity and queue resource checks. Readers never write or create locks.
2
+
3
+ The release verifier is CLI-1's bin/release.js, not a second manifest verifier.
4
+ A Codex projection has .devlyn-harness-projection.json at its plugin root:
5
+ {schemaVersion: 1, release: ReleaseRef, source: "native/codex/plugins/devlyn"}.
6
+ Its complete regular-file tree (except that marker) must match that release subtree.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import json
12
+ import os
13
+ from pathlib import Path
14
+ import re
15
+ import subprocess
16
+ import sys
17
+
18
+ PROJECTION = ".devlyn-harness-projection.json"
19
+ CODEX_SOURCE = "native/codex/plugins/devlyn"
20
+
21
+
22
+ class HarnessError(Exception):
23
+ pass
24
+
25
+
26
+ def require(condition, message):
27
+ if not condition:
28
+ raise HarnessError(message)
29
+
30
+
31
+ def read_json(path):
32
+ try:
33
+ return json.loads(Path(path).read_bytes())
34
+ except (OSError, ValueError) as exc:
35
+ raise HarnessError(f"{path}: {exc}") from exc
36
+
37
+
38
+ def file_identity(path):
39
+ path = Path(path)
40
+ info = path.lstat()
41
+ require(path.is_file() and not path.is_symlink() and info.st_nlink == 1, f"not a regular resource: {path}")
42
+ return {"path": str(path), "sha256": hashlib.sha256(path.read_bytes()).hexdigest()}
43
+
44
+
45
+ def release_ref(value):
46
+ require(isinstance(value, dict) and set(value) == {"package", "version", "digest", "root"}
47
+ and value["package"] == "devlyn-cli"
48
+ and all(isinstance(value[k], str) and value[k] and "\0" not in value[k] for k in ("version", "digest", "root"))
49
+ and re.fullmatch(r"[a-f0-9]{64}", value["digest"]) and Path(value["root"]).is_absolute(), "malformed release reference")
50
+ return value
51
+
52
+
53
+ def verify_release(root):
54
+ root = Path(root)
55
+ script = "const r=require(process.argv[1]);r.runJson(()=>r.verifyRelease(process.argv[2]));"
56
+ result = subprocess.run(["node", "-e", script, str(root / "bin/release.js"), str(root)], capture_output=True, text=True, encoding="utf-8")
57
+ require(result.returncode == 0, f"release verification failed: {root}: {result.stderr.strip()}")
58
+ try:
59
+ release = release_ref(json.loads(result.stdout)["data"]["release"])
60
+ except (KeyError, ValueError, TypeError) as exc:
61
+ raise HarnessError(f"invalid release verifier result: {root}: {exc}") from exc
62
+ manifest = read_json(root / "release.json")
63
+ defaults = file_identity(root / manifest["principles"]["defaults"])
64
+ return {"schemaVersion": 1, "release": release, "defaults": defaults}
65
+
66
+
67
+ def walk_error(error):
68
+ raise error # os.walk otherwise silently skips unreadable projection directories.
69
+
70
+
71
+ def resolve(skills):
72
+ """Resolve only this invoking skill's real ancestry; never search another install."""
73
+ skill = (Path(skills) / "devlyn-ideate").resolve()
74
+ for parent in (skill, *skill.parents):
75
+ marker = parent / PROJECTION
76
+ if marker.exists() or marker.is_symlink():
77
+ file_identity(marker)
78
+ projection = read_json(marker)
79
+ require(isinstance(projection, dict) and set(projection) == {"schemaVersion", "release", "source"}
80
+ and type(projection["schemaVersion"]) is int and projection["schemaVersion"] == 1
81
+ and projection["source"] == CODEX_SOURCE, f"malformed projection: {marker}")
82
+ ref = release_ref(projection["release"])
83
+ binding = verify_release(ref["root"])
84
+ require(binding["release"] == ref, f"projection release changed: {marker}")
85
+ source = Path(ref["root"]) / CODEX_SOURCE
86
+ require(skill == parent / "skills/devlyn-ideate", f"unexpected projection skill: {skill}")
87
+ expected = {p.relative_to(source) for p in source.rglob("*") if p.is_file()}
88
+ actual = set()
89
+ for directory, dirs, files in os.walk(parent, onerror=walk_error):
90
+ for name in dirs:
91
+ require(not (Path(directory) / name).is_symlink(), f"projection directory link: {directory}/{name}")
92
+ for name in files:
93
+ file = Path(directory) / name
94
+ if file == marker:
95
+ continue
96
+ rel = file.relative_to(parent)
97
+ actual.add(rel)
98
+ require(rel in expected and file_identity(file)["sha256"] == file_identity(source / rel)["sha256"]
99
+ and (os.name == "nt" or bool(file.stat().st_mode & 0o111) == bool((source / rel).stat().st_mode & 0o111)),
100
+ f"projection resource changed: {file}")
101
+ require(actual == expected, f"projection file list changed: {parent}")
102
+ return binding
103
+ manifest = parent / "release.json"
104
+ if (manifest.exists() or manifest.is_symlink()) and skill in [parent / p / "devlyn-ideate" for p in
105
+ ("config/skills", "native/claude/skills", CODEX_SOURCE + "/skills")]:
106
+ return verify_release(parent)
107
+ return None
108
+
109
+
110
+ def read_binding(common):
111
+ path = Path(common) / "devlyn-loops/harness.json"
112
+ if not path.exists() and not path.is_symlink():
113
+ return None
114
+ file_identity(path)
115
+ binding = read_json(path)
116
+ require(isinstance(binding, dict) and set(binding) == {"schemaVersion", "release", "defaults"}
117
+ and type(binding["schemaVersion"]) is int and binding["schemaVersion"] == 1, f"incompatible queue binding: {path}")
118
+ release_ref(binding["release"])
119
+ resource(binding["defaults"])
120
+ return binding
121
+
122
+
123
+ def resource(value):
124
+ require(isinstance(value, dict) and set(value) == {"path", "sha256"}
125
+ and isinstance(value["path"], str) and "\0" not in value["path"] and Path(value["path"]).is_absolute()
126
+ and isinstance(value["sha256"], str) and re.fullmatch(r"[a-f0-9]{64}", value["sha256"]), "malformed resource binding")
127
+
128
+
129
+ def recovery_argv(binding, argv):
130
+ return [sys.executable, str(Path(binding["release"]["root"]) / "config/skills/devlyn-ideate/scripts/queue.py"), *argv]
131
+
132
+
133
+ def check_binding(binding, invoking):
134
+ if binding is None:
135
+ return
136
+ require(invoking is not None and binding["release"] == invoking["release"], "queue-release-mismatch")
137
+ require(binding == invoking, "queue defaults changed; restore the bound release")
138
+
139
+
140
+ def packet_schema(packet):
141
+ require(isinstance(packet, dict), "task packet must be an object")
142
+ version = packet.get("schema_version", 1)
143
+ require(type(version) is int and version in (1, 2), f"incompatible task packet version: {version!r}")
144
+ if version == 1:
145
+ return
146
+ require("harness" in packet and isinstance(packet.get("methodology"), dict), "malformed packet v2 harness/methodology")
147
+ methodology = packet["methodology"]
148
+ require(set(methodology) == {"defaults", "projectInstructions"} and isinstance(methodology["projectInstructions"], list),
149
+ "malformed packet v2 methodology")
150
+ for entry in methodology["projectInstructions"]:
151
+ resource(entry)
152
+ if packet["harness"] is None:
153
+ defaults = methodology["defaults"]
154
+ require(packet.get("workerPrompt") is None and (defaults is None or (isinstance(defaults, dict) and defaults.get("source") == "skill")),
155
+ "legacy packet has release resources")
156
+ if defaults is not None:
157
+ resource({k: v for k, v in defaults.items() if k != "source"})
158
+ return
159
+ release_ref(packet["harness"])
160
+ defaults = methodology["defaults"]
161
+ require(isinstance(defaults, dict) and defaults.get("source") == "release", "packet has no verified release defaults source")
162
+ resource({k: v for k, v in defaults.items() if k != "source"})
163
+ resource(packet.get("workerPrompt"))
164
+
165
+
166
+ def verify_packet(packet, path, skills):
167
+ packet_schema(packet)
168
+ if packet.get("schema_version", 1) == 1:
169
+ return # Preserve v1 execution without upgrading its resources.
170
+ receipt = packet.get("receipt")
171
+ require(isinstance(receipt, str) and "\0" not in receipt and Path(receipt).is_absolute()
172
+ and Path(receipt).name == "receipt.json" and Path(receipt).parent.parent.name == "devlyn-completion"
173
+ and Path(receipt) == Path(path).with_name("receipt.json"),
174
+ "malformed packet receipt path")
175
+ binding = read_binding(Path(receipt).parent.parent.parent)
176
+ if packet["harness"] is None:
177
+ require(binding is None, "bound task packet has no harness/defaults source")
178
+ # An unbound installation's own defaults change with its normal updates: never gate acceptance or delivery on
179
+ # their bytes (queue.py checks only their presence, before a spawn).
180
+ return
181
+ require(binding is not None, "packet queue binding is missing; restore harness.json")
182
+ invoking = resolve(skills)
183
+ ref = packet["harness"]
184
+ require(invoking is not None and invoking["release"] == ref,
185
+ f"packet-release-mismatch; use {Path(ref['root']) / 'config/skills/devlyn-ideate/scripts/queue.py'}")
186
+ check_binding(binding, invoking)
187
+ defaults = {"source": "release", **binding["defaults"]}
188
+ require(packet["methodology"]["defaults"] == defaults, "packet defaults differ from queue binding")
189
+ expected_runner = [str(Path(ref["root"]) / "config/skills/devlyn-ideate/scripts/acceptance.py"),
190
+ "run", "--packet", str(path)]
191
+ require(isinstance(packet.get("runner"), list) and packet["runner"][1:] == expected_runner, "packet runner differs from bound release")
192
+ prompt = packet["workerPrompt"]
193
+ require(prompt["path"] == str(Path(path).parent / "worker-prompt.md") and file_identity(prompt["path"]) == prompt,
194
+ "worker prompt changed or missing; restore the bound resource")
195
+ for entry in packet["methodology"]["projectInstructions"]:
196
+ name = Path(entry["path"]).name
197
+ require(name in ("CLAUDE.md", "AGENTS.md") and entry["path"] == str(Path(packet["worktree"]) / name),
198
+ "project instruction path escapes packet worktree")
199
+ committed = subprocess.run(["git", "-C", packet["worktree"], "cat-file", "blob", f"{packet['inputs_sha']}:{name}"], capture_output=True)
200
+ require(committed.returncode == 0 and hashlib.sha256(committed.stdout).hexdigest() == entry["sha256"],
201
+ f"project instruction differs from inputs commit: {name}")
@@ -94,10 +94,14 @@ A removed flag stops with its instruction and selects no other behavior.
94
94
 
95
95
  An absolute path to a package's `meta.md` is added as it is; anything else is planned first. Add with `python3 "$DEVLYN_SKILL_DIR/scripts/queue.py" add '<absolute meta.md>'`, the only writer of a loop's queue file `docs/specs/<loop-id>/queue.md`; never edit one by hand. In every delivery mode add makes no commit on the checked-out branch and leaves the branch, the index and tracked files as they are: it holds the package and its rows at `refs/devlyn/captures/<loop-id>`, records the add and removes the package copies equal to that capture. Report the capture ref and its `git show` command, any `cleanup` entry, and that a revision needs a new loop id. A retry of the same add reports it as added, also once the copies are gone. Tasks start from committed state, a local loop from the branch it was added on and `auto`/`pr` from the remote base, so commit, and for `auto`/`pr` push, whatever the plan depends on. A pending legacy raw-intent row of `docs/specs/queue.md` is replaced in place by a package whose `## Intent` reproduces the row verbatim, added with `--materialize <line>`.
96
96
 
97
+ Release-backed queues retain their verified release in `<git-common-dir>/devlyn-loops/harness.json` until all tasks, recovery and delivery settle. A different release refuses with the exact recovery argv; use it. Existing unfinished unbound queues and standalone installations remain legacy.
98
+
97
99
  ## status
98
100
 
99
101
  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
102
 
103
+ For read-only consumers, `python3 "$DEVLYN_SKILL_DIR/scripts/queue.py" inspect --repo . --json` returns the status summary plus `binding` and `compatibility`, without cleanup, locks or writes.
104
+
101
105
  ## drain
102
106
 
103
107
  `status`, `add` and `drain` write the repository's Git directory, and auto/pr delivery needs network access, so a sandboxed host runs them with escalation or full access.
@@ -112,7 +116,7 @@ Follow [loop.md](references/loop.md). The executor is the configured route, the
112
116
  Another engine needs an argv that starts one fresh non-interactive session able, without prompts, to edit, run commands and commit in its task worktree and to write the packet's submission file. `<prompt>` carries the absolute binding values, while `{packet}` stays literal for the drain to fill:
113
117
 
114
118
  ```text
115
- Execute the devlyn loop task packet {packet}: work only in its owned worktree under the installed instructions; meet its obligations, then write its submission and any review records as "Executor exchange" and "Review records" in <DEVLYN_SKILL_DIR>/references/loop.md specify. Set DEVLYN_SKILL_DIR=<DEVLYN_SKILL_DIR> and DEVLYN_SHARED_DIR=<DEVLYN_SHARED_DIR> from these literal values, never from your inherited environment. Never push, open a pull request or merge: the drain delivers.
119
+ Execute the devlyn loop task packet {packet}: read its workerPrompt when present; read methodology.defaults.path when named and apply those verified defaults, with explicit user and native owner/project instructions taking precedence; work only in its owned worktree under the installed instructions; meet its obligations, then write its submission and any review records as "Executor exchange" and "Review records" in <DEVLYN_SKILL_DIR>/references/loop.md specify. Use the literal DEVLYN_SKILL_DIR and DEVLYN_SHARED_DIR bindings in workerPrompt when present; otherwise set DEVLYN_SKILL_DIR=<DEVLYN_SKILL_DIR> and DEVLYN_SHARED_DIR=<DEVLYN_SHARED_DIR> from these literal values. Never use inherited environment values. Never push, open a pull request or merge: the drain delivers.
116
120
  ```
117
121
 
118
122
  ```sh
@@ -28,7 +28,7 @@ The host supplies the executor and maps its configured engine to the argv; the l
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
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 keeps its allocation, packet and durable start/not-started log, waits for executable, permissions or workspace repair, and retries on the next drain.
31
+ 5. **Exchange one packet.** Verify the bound release, defaults, packet and worker prompt before execution or recovery; changed or missing resources refuse dispatch. 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.
@@ -53,6 +53,14 @@ Expected task-local operational failures leave the selected task waiting with `<
53
53
 
54
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
55
 
56
+ ## Release binding and read-only consumers
57
+
58
+ `<git-common-dir>/devlyn-loops/harness.json` is authoritative across linked worktrees: `{schemaVersion: 1, release: {package, version, digest, root}, defaults: {path, sha256}}`. Paths are absolute and hashes cover exact bytes. The queue lock covers binding before the first capture. An interrupted first add, unfinished tasks, recovery conflicts and pending delivery retain it; settlement removes it. A different release refuses with `recovery argv` as a JSON argv array, preserving the caller’s arguments. No caller substitutes another implementation.
59
+
60
+ `queue.py inspect --repo <dir> --json` reads without cleanup, locks or writes. It returns the status summary plus `binding` (null for legacy) and `compatibility`: `compatible`, `legacy`, `reasons`, `recoveryArgv`, `taskPacket` ranges and `queueBinding` ranges. Incompatible or unreadable state is reported, never migrated. Apps should use this reader instead of `status`, which still reconciles cleanup. For a mismatch, `recoveryArgv` names the bound queue’s inspect invocation; use the same bound queue entry point for authorized add/drain operations.
61
+
62
+ Codex projection writers place `.devlyn-harness-projection.json` at the copied plugin root, beside `skills` and `.codex-plugin`: `{schemaVersion: 1, release: <ReleaseRef>, source: "native/codex/plugins/devlyn"}`. The reader verifies the named release and every projected file’s bytes/mode against that subtree (excluding only the marker). Symlinked skill invocations resolve through their real path. Standalone installs and existing unfinished unbound queues retain legacy behavior; new legacy packets use v2 with a null release, and their `defaults` name the invoking installation's own `_shared/runtime-principles.md` (`source: "skill"`). No engine applicability is inferred from instruction filenames: bound queues use verified release defaults with native instructions taking precedence. Worker and runner paths point to the bound release, independent of a projection cache’s lifetime.
63
+
56
64
  ## Executor exchange
57
65
 
58
66
  The packet (`<receipt dir>/packet.json`) carries everything a fresh executor needs:
@@ -66,7 +74,9 @@ The packet (`<receipt dir>/packet.json`) carries everything a fresh executor nee
66
74
  | `requirements`, `review_requirements` | Requirement IDs and those needing review evidence. |
67
75
  | `shared_constraints` | The meta-prompt's `Constraints and exclusions`. |
68
76
  | `dependencies` | `{task, receipt, source_sha}` of each accepted prerequisite. |
69
- | `methodology` | `{path, sha256}` of the installed instruction files at the inputs commit. |
77
+ | `schema_version`, `harness` | New packets use version 2 and the bound release reference, or null for legacy unbound queues. Existing v1 packets execute unchanged. |
78
+ | `methodology` | `projectInstructions`: `{path, sha256}` of instruction bytes at the inputs commit. `defaults`: `{source: "release", path, sha256}` from the verified binding; for an unbound queue `{source: "skill", path, sha256}` of the installation's own `_shared/runtime-principles.md`. A task whose executor would get no principles at all (no defaults and no committed CLAUDE.md/AGENTS.md) waits with that reason instead of running. Read the named defaults; explicit user and native owner/project instructions take precedence. No native engine applicability is inferred from filenames. |
79
+ | `workerPrompt` | `{path, sha256}` of the task’s worker instructions, or null for legacy unbound queues. Read it before working. |
70
80
  | `delivery` | `local-only`, `auto` or `pr`. |
71
81
  | `evidence_dir`, `runner` | Where loop evidence lives (`<worktree>/.devlyn/loop`, ignored by Git) and the runner argv. |
72
82
  | `submission`, `obligations` | Where to write the submission, and the completion obligations. |
@@ -0,0 +1,12 @@
1
+ # Planning and capture only
2
+
3
+ This reference grants plan and add operations only. Never drain, implement, run an executor or deliver code from this planning session.
4
+
5
+ Resolve this reference’s actual containing skill directory (the parent of `references`), resolving symlinks; bind it as `DEVLYN_SKILL_DIR`. Resolve its sibling `_shared` as `DEVLYN_SHARED_DIR`. Verify the loaded files; missing or conflicting source identity is `BLOCKED:skill-source-unresolved`. Never substitute another installation or inherited environment values.
6
+
7
+ 1. Inspect project facts and follow [elicitation.md](elicitation.md). Ask only when the unresolved answer changes authorized behavior, scope, data semantics, acceptance or delivery. Existing authorization remains effective. Under autonomous planning, use conventional, reversible defaults for ordinary details and record each assumption; material ambiguity stops affected work as needs-review with a concrete question. Independent authorized planning may continue.
8
+ 2. Write `docs/specs/<loop-id>/meta.md` and self-contained task contracts per [package-format.md](package-format.md). Preserve an input document unchanged and carry every substantive requirement into the package. Use this execution policy in the package: for ordinary details within authorized scope, follow established project conventions, otherwise the narrowest literal or conventional reading consistent with the request; choices must be low-consequence and reversible, recorded with their reasons. Material ambiguity means competing intended outcomes, unresolved persistent data/state semantics or public surface beyond the request; stop affected work as needs-review with a concrete question. Independent authorized work may continue. Never omit requested work or weaken acceptance. Set delivery from explicit restrictions or the project completion policy; without one GitHub origin, record local-only as an assumption, or ask when the user explicitly requested a PR/merge.
9
+ 3. Run `python3 "<DEVLYN_SKILL_DIR>/scripts/queue.py" check '<absolute meta.md>'` and repair until `VALID`. Plan stops here: report the path, tasks, dependencies and assumptions.
10
+ 4. When add is authorized, run `python3 "<DEVLYN_SKILL_DIR>/scripts/queue.py" add '<absolute meta.md>'`. It alone writes queue state, captures without committing on the checked-out branch and removes matching untracked package copies. Report the capture ref, its `git show` command and cleanup diagnostics. Revisions need a new loop id. A legacy pending row is materialized with `--materialize <line>` and its verbatim intent in the package.
11
+
12
+ Report capture and stop. A separate authorized execution session owns drain.
@@ -89,6 +89,11 @@ def load_packet(path):
89
89
  packet = read_json(path)
90
90
  if not isinstance(packet, dict) or PACKET_KEYS - set(packet):
91
91
  raise AcceptanceError(f"{path}: task packet lacks {sorted(PACKET_KEYS - set(packet if isinstance(packet, dict) else {}))}")
92
+ helper = shared("harness-store")
93
+ try:
94
+ helper["verify_packet"](packet, path, SKILLS)
95
+ except (helper["HarnessError"], OSError) as exc:
96
+ raise AcceptanceError(str(exc)) from exc
92
97
  return packet
93
98
 
94
99
 
@@ -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,13 +1,14 @@
1
1
  {
2
2
  "name": "devlyn-cli",
3
- "version": "4.2.26",
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
9
  "scripts": {
10
- "prepack": "node -e \"require('./bin/release').buildRelease()\""
10
+ "prepack": "node -e \"require('./bin/release').buildRelease()\"",
11
+ "postpack": "node -e \"require('./bin/release').cleanGenerated()\""
11
12
  },
12
13
  "files": [
13
14
  "bin",