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.
- package/bin/devlyn.js +510 -0
- package/bin/engines.js +20 -15
- package/bin/harness-activation.js +1 -2
- package/bin/release.js +16 -3
- package/bin/skill-history.json +2 -0
- package/config/skills/_shared/harness-store.py +201 -0
- package/config/skills/devlyn-ideate/SKILL.md +5 -1
- package/config/skills/devlyn-ideate/references/loop.md +12 -2
- package/config/skills/devlyn-ideate/references/plan-add.md +12 -0
- package/config/skills/devlyn-ideate/scripts/acceptance.py +5 -0
- package/config/skills/devlyn-ideate/scripts/queue.py +156 -15
- package/package.json +3 -2
|
@@ -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.
|
|
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
|
-
| `
|
|
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
|
-
|
|
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":
|
|
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
|
-
|
|
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
|
|
1465
|
-
anchor
|
|
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.
|
|
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",
|