devlyn-cli 4.2.3 → 4.2.5
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 +1 -0
- package/bin/skill-history.json +7 -3
- package/config/skills/_shared/role-config.py +3 -1
- package/config/skills/_shared/task-complete.py +51 -17
- package/config/skills/devlyn-ideate/SKILL.md +2 -0
- package/config/skills/devlyn-ideate/references/loop.md +2 -1
- package/config/skills/devlyn-ideate/scripts/queue.py +16 -5
- package/package.json +1 -1
package/bin/devlyn.js
CHANGED
|
@@ -880,6 +880,7 @@ function install(targets, global, optionalNames = []) {
|
|
|
880
880
|
else installClaudeCore();
|
|
881
881
|
}
|
|
882
882
|
log(`\n✅ devlyn ${PKG.version} installed`, 'green');
|
|
883
|
+
if (global) log('The principles block installs per project: run npx devlyn-cli in the project without --global.');
|
|
883
884
|
noticeGlobalDrift(roots);
|
|
884
885
|
return roots;
|
|
885
886
|
}
|
package/bin/skill-history.json
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
{
|
|
2
|
-
"source": "devlyn-cli@4.1.0 (npm tarball sha1 398ff07d3d689b56cc22e18ac50260bc6e4e9e85); devlyn-cli@4.2.0 (npm tarball sha1 c93c2fb1cf5630b18d2eba0085182c14fddbe2d5); whole-tree fingerprints from bin/skill-ownership.js; deprecated commands: commands/ and optional-commands/*/ files of devlyn-cli 0.0.1-0.5.8 (every published version that shipped them), LF-normalized SHA-256",
|
|
2
|
+
"source": "devlyn-cli@4.1.0 (npm tarball sha1 398ff07d3d689b56cc22e18ac50260bc6e4e9e85); devlyn-cli@4.2.0 (npm tarball sha1 c93c2fb1cf5630b18d2eba0085182c14fddbe2d5); devlyn-cli@4.2.4 and 4.2.5 (_shared and devlyn-ideate, fingerprinted from an install of each packed tarball); whole-tree fingerprints from bin/skill-ownership.js; deprecated commands: commands/ and optional-commands/*/ files of devlyn-cli 0.0.1-0.5.8 (every published version that shipped them), LF-normalized SHA-256",
|
|
3
3
|
"skills": {
|
|
4
4
|
"_shared": [
|
|
5
5
|
"eb3327d5e6940bd5e0459fa28c9ab323d9c58ccfdeb9426c19cd027cc4e5c4ed",
|
|
6
|
-
"8c80926ab03b2477e52264495b35c2116f65fc51b02e8c56e441b5705872392a"
|
|
6
|
+
"8c80926ab03b2477e52264495b35c2116f65fc51b02e8c56e441b5705872392a",
|
|
7
|
+
"3c81c575cc43d70488865b440204cb399091405d9b7f81dbf047a32d3d5ac045",
|
|
8
|
+
"6546d6b833701f2641a004802cfc17ec62442e5d0003f396cb0231b676ba9a4a"
|
|
7
9
|
],
|
|
8
10
|
"devlyn-design-ui": [
|
|
9
11
|
"91080266232fb62b6f1d45932184f065589e12eb66f02fb6442527c6dfcd81b6"
|
|
@@ -14,7 +16,9 @@
|
|
|
14
16
|
],
|
|
15
17
|
"devlyn-ideate": [
|
|
16
18
|
"8ff5148cc65b4608c35b29afd6fef20d42d8da93a094144d98cfc63e74089b6e",
|
|
17
|
-
"5824f07e455fe1c3942bf595a12e935258d4c0a851ca1bf741db915603d1c4e0"
|
|
19
|
+
"5824f07e455fe1c3942bf595a12e935258d4c0a851ca1bf741db915603d1c4e0",
|
|
20
|
+
"7f451e48e391643b34f6249244bc47d93d6303875e7b092771e3af0718343bc1",
|
|
21
|
+
"d6433bf0f96dd9dd485c3623885255738654674b3e23cfcc1a2cfd24f3cda96e"
|
|
18
22
|
],
|
|
19
23
|
"devlyn-queue": [
|
|
20
24
|
"4c7d988dcd154c7bf658d2d24cd7868955df9d854ed87eb784742d6d4bb229e0"
|
|
@@ -102,7 +102,8 @@ def resolve(work, default_engine, *, available=None, shared=SHARED):
|
|
|
102
102
|
"""The project's executor pin, else the invoking CLI; status only, never a dispatch decision."""
|
|
103
103
|
config, source = read_config(Path(work) / ".devlyn/engines.json", optional=True, shared=shared)
|
|
104
104
|
engine = config.get("executor", default_engine)
|
|
105
|
-
adapter
|
|
105
|
+
# read_config validates pins; the invoking host needs no adapter to report its own default.
|
|
106
|
+
name(engine, "engine")
|
|
106
107
|
available = available or (lambda candidate: shutil.which(candidate) is not None)
|
|
107
108
|
return {"executor": {"engine": engine, "source": "engines.json" if "executor" in config else "default",
|
|
108
109
|
"availability": "CLI-present/auth-unchecked" if available(engine) else "CLI-unavailable"},
|
|
@@ -113,6 +114,7 @@ def select(work, default_engine, *, available=None, shared=SHARED):
|
|
|
113
114
|
"""The executor for dispatch. A pin is a promise: an unavailable pinned engine is never substituted."""
|
|
114
115
|
result = resolve(work, default_engine, available=available, shared=shared)
|
|
115
116
|
executor = result["executor"]
|
|
117
|
+
adapter(executor["engine"], shared=shared)
|
|
116
118
|
if executor["source"] == "engines.json" and executor["availability"] == "CLI-unavailable":
|
|
117
119
|
fail(f"pinned executor {executor['engine']} is unavailable; install and authenticate its CLI, "
|
|
118
120
|
f"verify `{executor['engine']} --version`, and retry, or pin an available engine with `devlyn-engines executor <name>`",
|
|
@@ -22,6 +22,7 @@ import subprocess
|
|
|
22
22
|
import sys
|
|
23
23
|
import tempfile
|
|
24
24
|
import unittest
|
|
25
|
+
import uuid
|
|
25
26
|
|
|
26
27
|
|
|
27
28
|
class CompletionError(Exception):
|
|
@@ -122,21 +123,22 @@ def verify_files(root, files):
|
|
|
122
123
|
|
|
123
124
|
|
|
124
125
|
def custody(work, destination, files):
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
126
|
+
# Publish only a complete copy. A hard crash's private staging directory is never reused.
|
|
127
|
+
with tempfile.TemporaryDirectory(prefix=".custody-", dir=destination.parent) as temporary:
|
|
128
|
+
root = Path(temporary)
|
|
129
|
+
for relative, expected in files.items():
|
|
130
|
+
source = safe_path(work, relative)
|
|
131
|
+
target = safe_path(root, relative)
|
|
132
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
132
133
|
with source.open("rb") as incoming, target.open("xb") as outgoing:
|
|
133
134
|
shutil.copyfileobj(incoming, outgoing)
|
|
134
135
|
outgoing.flush()
|
|
135
136
|
os.fsync(outgoing.fileno())
|
|
136
137
|
target.chmod(expected["mode"])
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
138
|
+
require(file_record(target) == expected, f"custody copy mismatch; retain original: {relative}")
|
|
139
|
+
verify_files(work, files)
|
|
140
|
+
verify_files(root, files)
|
|
141
|
+
root.rename(destination)
|
|
140
142
|
atomic_json(destination.parent / "manifest.json", files)
|
|
141
143
|
|
|
142
144
|
|
|
@@ -309,7 +311,12 @@ def bind_acceptance(receipt, path, supplied):
|
|
|
309
311
|
else:
|
|
310
312
|
raise CompletionError("acceptance kind must be direct|loop")
|
|
311
313
|
files = snapshot_files(work, paths)
|
|
312
|
-
|
|
314
|
+
destination = safe_path(path.parent, "custody")
|
|
315
|
+
if destination.exists():
|
|
316
|
+
# An unbound copy is never authoritative; retain it like an orphan staging directory.
|
|
317
|
+
require(destination.is_dir(), "custody is not a directory; retain and inspect")
|
|
318
|
+
destination.rename(path.parent / (".custody-" + uuid.uuid4().hex))
|
|
319
|
+
custody(work, destination, files)
|
|
313
320
|
recovery = ref_sha(receipt, receipt["recovery_ref"])
|
|
314
321
|
require(recovery in {None, sha}, "recovery ref changed")
|
|
315
322
|
if recovery is None:
|
|
@@ -368,9 +375,14 @@ def gh(receipt, *args):
|
|
|
368
375
|
return command(["gh", *args, "--repo", "github.com/"+receipt["repository"]])
|
|
369
376
|
|
|
370
377
|
|
|
378
|
+
def repository_info(repository):
|
|
379
|
+
info = json.loads(command(["gh", "repo", "view", "github.com/"+repository, "--json", "nameWithOwner,url,defaultBranchRef,mergeCommitAllowed"]))
|
|
380
|
+
require(info["nameWithOwner"].lower() == repository.lower() and info["url"].lower() == "https://github.com/"+repository.lower(), "GitHub repository identity changed")
|
|
381
|
+
return info
|
|
382
|
+
|
|
383
|
+
|
|
371
384
|
def repo_policy(receipt):
|
|
372
|
-
info =
|
|
373
|
-
require(info["nameWithOwner"].lower() == receipt["repository"].lower() and info["url"].lower() == "https://github.com/"+receipt["repository"].lower(), "GitHub repository identity changed")
|
|
385
|
+
info = repository_info(receipt["repository"])
|
|
374
386
|
require(info["defaultBranchRef"]["name"] == receipt["base"] and receipt["branch"] != info["defaultBranchRef"]["name"], "base/default branch changed; retain resources")
|
|
375
387
|
return info
|
|
376
388
|
|
|
@@ -792,7 +804,7 @@ def main():
|
|
|
792
804
|
attachment = actions.add_parser("attach")
|
|
793
805
|
attachment.add_argument("--receipt", required=True)
|
|
794
806
|
attachment.add_argument("--commit", required=True)
|
|
795
|
-
attachment.add_argument("--file",
|
|
807
|
+
attachment.add_argument("--file", required=True)
|
|
796
808
|
completion = actions.add_parser("complete")
|
|
797
809
|
completion.add_argument("--receipt", required=True)
|
|
798
810
|
completion.add_argument("--acceptance")
|
|
@@ -1793,21 +1805,37 @@ class CompletionTests(unittest.TestCase):
|
|
|
1793
1805
|
|
|
1794
1806
|
def test_custody_failure_and_evidence_tamper(self):
|
|
1795
1807
|
self.allocate(); self.accept()
|
|
1808
|
+
self.cli("accept", "--receipt", self.receipt, "--acceptance", self.acceptance)
|
|
1796
1809
|
bad = self.receipt.parent / "custody/.devlyn/checks.txt"
|
|
1797
|
-
bad.
|
|
1798
|
-
bad.write_text("partial interrupted copy", encoding="utf-8")
|
|
1810
|
+
bad.write_text("corrupted bound copy", encoding="utf-8")
|
|
1799
1811
|
_, r = self.complete("--writers-stopped", success=False)
|
|
1800
1812
|
self.assertNotEqual(r.returncode, 0)
|
|
1801
1813
|
self.assertTrue(self.task.exists())
|
|
1802
1814
|
self.assertEqual((self.task / ".devlyn/checks.txt").read_text(encoding="utf-8"), "actual fixture check: accepted bytes\n")
|
|
1803
1815
|
self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture"), "")
|
|
1804
|
-
|
|
1816
|
+
shutil.copy2(self.task / ".devlyn/checks.txt", bad) # Repair only the identified corrupt fixture copy.
|
|
1805
1817
|
self.complete("--mode", "pr")
|
|
1806
1818
|
(self.task / ".devlyn/checks.txt").write_text("altered after acceptance", encoding="utf-8")
|
|
1807
1819
|
_, r = self.complete("--mode", "auto", success=False)
|
|
1808
1820
|
self.assertNotEqual(r.returncode, 0)
|
|
1809
1821
|
self.assertEqual(json.loads(self.data.read_text(encoding="utf-8")).get("merges",0), 0)
|
|
1810
1822
|
|
|
1823
|
+
def test_unbound_partial_custody_is_retained_through_cleanup(self):
|
|
1824
|
+
self.allocate(); self.accept()
|
|
1825
|
+
partial = self.receipt.parent / "custody/.devlyn/checks.txt"
|
|
1826
|
+
partial.parent.mkdir(parents=True)
|
|
1827
|
+
partial.write_text("partial interrupted copy", encoding="utf-8")
|
|
1828
|
+
original = file_record(partial)
|
|
1829
|
+
result, _ = self.complete("--writers-stopped", "--mode", "auto")
|
|
1830
|
+
self.assertEqual(result["status"], "COMPLETE")
|
|
1831
|
+
self.assertEqual(result["scratch_cleanup"]["status"], "CLEAN")
|
|
1832
|
+
self.assertFalse(self.task.exists())
|
|
1833
|
+
retained = list(self.receipt.parent.glob(".custody-*"))
|
|
1834
|
+
self.assertEqual(len(retained), 1)
|
|
1835
|
+
self.assertEqual(file_record(retained[0] / ".devlyn/checks.txt"), original)
|
|
1836
|
+
self.assertEqual(self.complete(acceptance=False)[0]["status"], "COMPLETE")
|
|
1837
|
+
self.assertEqual(file_record(retained[0] / ".devlyn/checks.txt"), original)
|
|
1838
|
+
|
|
1811
1839
|
def test_actual_foreign_writer_and_registration(self):
|
|
1812
1840
|
# H4. Prediction: a process still using the merged task's tree settles the delivery COMPLETE with the workspace
|
|
1813
1841
|
# retained (CLEANUP_PENDING) and the process named, as unobservable writers do. Before: complete ended BLOCKED
|
|
@@ -2118,6 +2146,12 @@ class CompletionTests(unittest.TestCase):
|
|
|
2118
2146
|
result, _ = self.cli("accept", "--receipt", self.receipt, "--acceptance", acceptance, success=False)
|
|
2119
2147
|
self.assertEqual(result["status"], "FAILED" if verdict == "FAILED" else "BLOCKED", result)
|
|
2120
2148
|
|
|
2149
|
+
def test_attach_requires_an_explicit_queue_file(self):
|
|
2150
|
+
result = self.run_cmd([sys.executable, str(Path(__file__).resolve()), 'attach',
|
|
2151
|
+
'--receipt', str(self.root / 'absent.json'), '--commit', 'HEAD'], success=False)
|
|
2152
|
+
self.assertEqual(result.returncode, 2, result.stdout + result.stderr)
|
|
2153
|
+
self.assertIn('the following arguments are required: --file', result.stderr)
|
|
2154
|
+
|
|
2121
2155
|
def test_attach_requires_the_terminal_checkout_only_for_a_publishable_result(self):
|
|
2122
2156
|
for verdict in ("ACCEPTED", "FAILED"):
|
|
2123
2157
|
with self.subTest(verdict=verdict):
|
|
@@ -100,6 +100,8 @@ Run `python3 "$DEVLYN_SKILL_DIR/scripts/queue.py" status --repo .` and report it
|
|
|
100
100
|
|
|
101
101
|
## drain
|
|
102
102
|
|
|
103
|
+
`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.
|
|
104
|
+
|
|
103
105
|
Follow [loop.md](references/loop.md). The executor is the configured route, the `.devlyn/engines.json` `executor` pin or else this CLI, checked per `_shared/engine-preflight.md`. Pass its argv after `--`. `<git dir>` is the output of `git rev-parse --path-format=absolute --git-common-dir`, where the executor commits and writes its submission; the drain fills `{worktree_git_dir}` with the task worktree's own Git directory, which Codex's sandbox keeps read-only unless that exact directory is listed:
|
|
104
106
|
|
|
105
107
|
| Executor | argv |
|
|
@@ -11,7 +11,7 @@ python3 "$DEVLYN_SKILL_DIR/scripts/queue.py" drain --repo . [--local-only] [--wo
|
|
|
11
11
|
|
|
12
12
|
Both print one JSON object; exit 1 is `BLOCKED` with a `reason`.
|
|
13
13
|
|
|
14
|
-
- `status`
|
|
14
|
+
- `status` takes `queue.lock` in the repository's Git directory and finishes `add`'s cleanup (step 1); it reports reconciled counts (`pending`, `active`, `accepted`, `failed`, `blocked`, `legacy_pending`), the `next` task, `blockers`, deliveries still pending with their resume (drain again until the terminal commit is attached, then `task-complete.py complete --receipt <receipt>`), and under `cleanup` the package copies `add` kept or could not remove.
|
|
15
15
|
- `drain` ends `DRAINED` (no runnable task remains; open deliveries show per task), `WAITING` (a task waits with its reason, or legacy rows need planning) or `BLOCKED` (conflict, invalid input or a recovery blocker). Progress lines `devlyn-loop: <task>: <event>` go to stderr.
|
|
16
16
|
- `--local-only` (alias `--no-push`) keeps the loop local. A manifest `delivery: local-only` and any earlier local receipt of the loop keep that restriction for later drains. A local loop needs no remote; nothing is fetched or pushed. An `auto`/`pr` loop none of whose tasks has a pushed PR runs as a local loop from its capture and the branch it was added on. Once one has, that PR is never rewritten to local and the loop is never run locally: its tasks wait, naming the PR, while other work continues.
|
|
17
17
|
- Worktrees default to `<repo parent>/<repo name>.devlyn/<loop-id>/<task-id>`.
|
|
@@ -41,6 +41,7 @@ The host supplies the executor and maps its configured engine to the argv; the l
|
|
|
41
41
|
| Inputs commit | A branch head equal to the expected inputs tree is adopted; anything else blocks. |
|
|
42
42
|
| During execution | Each attempt's start is recorded before its spawn and its exit after it. A start without an exit waits until task-complete observes no process using the worktree, then adopts the submission that executor wrote or runs the executor again; where writers cannot be observed (native Windows) the task becomes `[F] interrupted-unobservable` with its workspace retained, its dependents become prerequisite-blocked and independent work continues. Exactly-once execution is not promised. |
|
|
43
43
|
| During checks | Acceptance reruns; incomplete evidence stays unreferenced. |
|
|
44
|
+
| During custody binding | Acceptance reruns while the receipt is unbound. Any unbound custody copy, complete or partial, is set aside in a unique hidden sibling before fresh custody is published; it and orphan staging copies stay retained and unreferenced. A bound receipt resumes its original result with strict evidence validation. |
|
|
44
45
|
| Accepted, before terminal commit | Only the missing transition is created. |
|
|
45
46
|
| Terminal committed, before attachment | The commit is validated and attached, also when attachment already moved the recovery ref; nothing reruns. |
|
|
46
47
|
| Terminal committed, before delivery | Only the missing delivery effects run. |
|
|
@@ -843,9 +843,9 @@ def evidence_ignored(anchor, common, start):
|
|
|
843
843
|
|
|
844
844
|
|
|
845
845
|
def allocate(v, row, opts):
|
|
846
|
-
"""Allocate the task's owned worktree, or return why it waits: origin names no GitHub repository,
|
|
847
|
-
remote base holds another version of the loop's package or lacks the checkout's instructions
|
|
848
|
-
start before selection)."""
|
|
846
|
+
"""Allocate the task's owned worktree, or return why it waits: origin names no GitHub repository, its base is not the
|
|
847
|
+
default, or its refreshed remote base holds another version of the loop's package or lacks the checkout's instructions
|
|
848
|
+
(waiting() checks a local start before selection)."""
|
|
849
849
|
identity, loop, task = row["identity"], row["loop"], row["task"]
|
|
850
850
|
package = v["packages"][loop]
|
|
851
851
|
manifest = package["manifest"]
|
|
@@ -868,6 +868,13 @@ def allocate(v, row, opts):
|
|
|
868
868
|
except helper["CompletionError"]:
|
|
869
869
|
return (f"{manifest['delivery']} delivery needs an origin remote of the form https://github.com/<owner>/<repo>, "
|
|
870
870
|
"git@github.com:<owner>/<repo> or ssh://git@github.com/<owner>/<repo>; set one, or drain with --local-only")
|
|
871
|
+
try:
|
|
872
|
+
default = helper["repository_info"](values["repository"])["defaultBranchRef"]["name"]
|
|
873
|
+
except (helper["CompletionError"], OSError, KeyError, TypeError, ValueError) as exc:
|
|
874
|
+
return f"cannot check delivery repository: {exc}"
|
|
875
|
+
if manifest["base_ref"] != default:
|
|
876
|
+
return (f"{manifest['delivery']} delivery base {manifest['base_ref']} is not the repository's default branch {default}; "
|
|
877
|
+
f"plan the loop on {default}, or drain with --local-only")
|
|
871
878
|
try:
|
|
872
879
|
start = helper["remote_base"]({"common_gitdir": str(common), "remote": "origin", "base": manifest["base_ref"]})
|
|
873
880
|
except helper["CompletionError"] as exc:
|
|
@@ -1069,6 +1076,8 @@ def advance(v, row, opts):
|
|
|
1069
1076
|
except LoopError as exc:
|
|
1070
1077
|
return f"delivery blocked: {exc}"
|
|
1071
1078
|
progress(identity, f"delivery {result['status']}")
|
|
1079
|
+
if result.get("merge_refused"):
|
|
1080
|
+
return f"merge refused: {result['merge_refused']}; a person merges the PR"
|
|
1072
1081
|
return None
|
|
1073
1082
|
|
|
1074
1083
|
|
|
@@ -1093,9 +1102,11 @@ def summary(v, row):
|
|
|
1093
1102
|
questions=[r.removeprefix("needs-review: ") for r in acceptance_record.get("reasons", []) if r.startswith("needs-review: ")],
|
|
1094
1103
|
worktree=receipt["worktree"] if receipt.get("worktree") and Path(receipt["worktree"]).exists() else None,
|
|
1095
1104
|
cleanup=f"{c['status']} — {c['reason']}; resume: {c['resume']}" if (c := receipt.get("workspace_cleanup")) else None,
|
|
1096
|
-
branch=receipt.get("branch")
|
|
1105
|
+
branch=receipt.get("branch") if receipt.get("branch") and ref_value(v["anchor"], "refs/heads/" + receipt["branch"]) else None,
|
|
1106
|
+
recovery_ref=receipt.get("recovery_ref"),
|
|
1097
1107
|
custody=str(Path(state["path"]).parent / "custody") if receipt.get("files") else None,
|
|
1098
|
-
scratch=(receipt.get("scratch_cleanup") or {}).get("
|
|
1108
|
+
scratch=(f"{c['status']} — {c['reason']}; resume: {c['resume']}" if (c := receipt.get("scratch_cleanup") or {}).get("reason")
|
|
1109
|
+
else c.get("status", "NOT_CLEANED")))
|
|
1099
1110
|
return item
|
|
1100
1111
|
|
|
1101
1112
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "devlyn-cli",
|
|
3
|
-
"version": "4.2.
|
|
3
|
+
"version": "4.2.5",
|
|
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": {
|