devlyn-cli 4.2.23 → 4.2.24
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/skill-history.json
CHANGED
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
"2bcae17ca31adaf306d1e5d21ad0c211bbd6f57605f68168f656e078dbf524e2",
|
|
30
30
|
"326a71c447fe4d98f3460eda3fc541920b7cc2205b8288743e8622d90215a49d",
|
|
31
31
|
"34282740d421a65fc75dc95edf260aa0d0fc6b90472f7b4641cb80110df0a359",
|
|
32
|
+
"3429daf66b5130b3ff6e48f3015afbf7a8d7fd84d09aace462aadb3099aa3f09",
|
|
32
33
|
"353b483942a62b79d1c5bf31e30060ef7ed9a67ae2d8b27450a53acb98e4e23c",
|
|
33
34
|
"3554398c2be144938e4837d9b60c0976f685cfc2c0dd0451cda714fd053f3c7d",
|
|
34
35
|
"35c7e6c069c847ff147f2bf4f3b67a296652c122aa91a3966457a00dd0597b78",
|
|
@@ -338,6 +339,7 @@
|
|
|
338
339
|
"9d62f726c5ac7ed8b3989c6ab3705b5c9e4b44bb70eeece0d43bd76142b9b75b",
|
|
339
340
|
"a12b24d2a70917b5d4e698af5478b6cce4d09ddf021f63ab7129402867433a72",
|
|
340
341
|
"a3b852c30898b572021b32721638f67d9f44cae98653da3362456a7b88208de7",
|
|
342
|
+
"a3c1de9718724e26c095af6f35baf3b7af1afc25ff33657ff3e8db066c692408",
|
|
341
343
|
"a7d7d65fd5f672cb404e6069fdc42ad7077deaa80ed8500cfa8b382dab2e3e29",
|
|
342
344
|
"aba66b674683935e4c28c93333896dc116ffe67e7a35fe30b3599a8be507e94e",
|
|
343
345
|
"b2e4437fa981317362d03ee032e1cfd8b686316e966c6ba12ecd619a009c80a6",
|
|
@@ -186,6 +186,37 @@ def policy(receipt, override):
|
|
|
186
186
|
return override or value or "auto"
|
|
187
187
|
|
|
188
188
|
|
|
189
|
+
def ignore_task_records(work):
|
|
190
|
+
"""Keep task records ignored without changing existing files or shared Git configuration."""
|
|
191
|
+
ignorecase = command(["git", "-C", str(work), "config", "--type=bool", "--get", "core.ignorecase"], ok=(0, 1)) == "true"
|
|
192
|
+
pathspec = ":(icase)" if ignorecase else ""
|
|
193
|
+
|
|
194
|
+
def ignored():
|
|
195
|
+
all_ignored = True
|
|
196
|
+
for probe in (".devlyn/acceptance.json", ".devlyn/loop/evidence"):
|
|
197
|
+
result = subprocess.run(["git", "-C", str(work), "--no-literal-pathspecs", "check-ignore", "-q", "--", probe], capture_output=True, text=True, encoding="utf-8")
|
|
198
|
+
require(result.returncode in (0, 1), f"git check-ignore {probe}: {result.stderr.strip()}")
|
|
199
|
+
if result.returncode == 1:
|
|
200
|
+
all_ignored = False
|
|
201
|
+
return all_ignored
|
|
202
|
+
|
|
203
|
+
directory = work / ".devlyn"
|
|
204
|
+
require(not directory.is_symlink() and (not directory.exists() or directory.is_dir())
|
|
205
|
+
and all((path.casefold() if ignorecase else path) != ".devlyn"
|
|
206
|
+
for path in git(work, "--no-literal-pathspecs", "ls-files", "--", pathspec + ".devlyn").splitlines()),
|
|
207
|
+
f"{directory} is not a real directory; move it out of .devlyn in the starting commit before allocating")
|
|
208
|
+
if ignored():
|
|
209
|
+
return
|
|
210
|
+
rule = directory / ".gitignore"
|
|
211
|
+
require(not os.path.lexists(rule) and not git(work, "--no-literal-pathspecs", "ls-files", "--", pathspec + ".devlyn/.gitignore"),
|
|
212
|
+
f"{rule} already exists or is tracked; make it ignore acceptance.json and loop/evidence in the starting commit before allocating")
|
|
213
|
+
directory.mkdir(exist_ok=True)
|
|
214
|
+
with rule.open("x", encoding="utf-8") as stream:
|
|
215
|
+
stream.write("*\n")
|
|
216
|
+
require(ignored() and not git(work, "--no-literal-pathspecs", "status", "--porcelain", "--untracked-files=all", "--", ".devlyn"),
|
|
217
|
+
f"{directory} records are not ignored or .devlyn is dirty; repair the starting commit's .devlyn paths and ignore rules before allocating")
|
|
218
|
+
|
|
219
|
+
|
|
189
220
|
def allocate(args, *, exclude_receipts=()):
|
|
190
221
|
"""Reconcile older tasks, except receipts the queue has identified as invalid."""
|
|
191
222
|
work = Path(git(Path(args.repo).resolve(), "rev-parse", "--show-toplevel")).resolve()
|
|
@@ -225,6 +256,10 @@ def allocate(args, *, exclude_receipts=()):
|
|
|
225
256
|
# stays visibly incomplete; a later call may not adopt whatever now exists.
|
|
226
257
|
atomic_json(path, receipt)
|
|
227
258
|
gref(receipt, "worktree", "add", "-b", args.branch, str(target), receipt["baseline"])
|
|
259
|
+
try:
|
|
260
|
+
ignore_task_records(target)
|
|
261
|
+
except CompletionError as exc:
|
|
262
|
+
raise CompletionError(f"{exc}; remove worktree {target} and branch {args.branch}, delete receipt directory {directory}, then allocate again") from exc
|
|
228
263
|
receipt["worktree_gitdir"] = git(target, "rev-parse", "--absolute-git-dir")
|
|
229
264
|
receipt["worktree_identity"] = workspace_identity(target, Path(receipt["worktree_gitdir"]))
|
|
230
265
|
receipt["allocation"] = "owned"
|
|
@@ -1106,6 +1141,201 @@ class CompletionTests(unittest.TestCase):
|
|
|
1106
1141
|
self.g("status", "--porcelain", "--untracked-files=all", work=work), (work / "product").read_bytes(),
|
|
1107
1142
|
(Path(self.g("rev-parse", "--absolute-git-dir", work=work)) / "index").read_bytes())
|
|
1108
1143
|
|
|
1144
|
+
def test_unignored_base_delivers_only_product_and_preserves_records(self):
|
|
1145
|
+
# Prediction: with the ignore rule only in the anchor's dirty .gitignore, remote, --start, --local-base
|
|
1146
|
+
# and --from-receipt allocations complete in their requested modes; only product is delivered, evidence
|
|
1147
|
+
# survives custody/cleanup, and the anchor's .gitignore, info/exclude and HEAD remain byte-identical.
|
|
1148
|
+
ignore = self.work / ".gitignore"
|
|
1149
|
+
ignore.write_text("ignored/\nnode_modules/\n.env\n", encoding="utf-8")
|
|
1150
|
+
self.g("commit", "-am", "base without .devlyn ignore")
|
|
1151
|
+
self.g("push", "origin", "main")
|
|
1152
|
+
base = self.g("rev-parse", "HEAD")
|
|
1153
|
+
ignore.write_text(ignore.read_text(encoding="utf-8") + ".devlyn/\n", encoding="utf-8")
|
|
1154
|
+
exclude = self.work / ".git/info/exclude"
|
|
1155
|
+
anchor = (ignore.read_bytes(), exclude.read_bytes(), self.anchor_state())
|
|
1156
|
+
predecessor = None
|
|
1157
|
+
for route, status in (("remote", "COMPLETE"), ("start", "PR"), ("local_base", "LOCAL_ONLY"),
|
|
1158
|
+
("from_receipt", "LOCAL_ONLY")):
|
|
1159
|
+
with self.subTest(route=route):
|
|
1160
|
+
source = {} if route == "remote" else {route: predecessor if route == "from_receipt" else base}
|
|
1161
|
+
self.allocate_local(route, **source)
|
|
1162
|
+
baseline = json.loads(self.receipt.read_text(encoding="utf-8"))["baseline"]
|
|
1163
|
+
(self.task / "product").write_text(route + "\n", encoding="utf-8")
|
|
1164
|
+
self.g("add", "product", work=self.task)
|
|
1165
|
+
self.g("commit", "-m", "scoped product", work=self.task)
|
|
1166
|
+
self.sha = self.g("rev-parse", "HEAD", work=self.task)
|
|
1167
|
+
evidence = self.task / ".devlyn/loop/evidence/check.txt"
|
|
1168
|
+
evidence.parent.mkdir(parents=True, exist_ok=True)
|
|
1169
|
+
evidence.write_text("actual fixture check: " + route + "\n", encoding="utf-8")
|
|
1170
|
+
self.assertEqual((self.task / "product").read_text(encoding="utf-8"), route + "\n")
|
|
1171
|
+
self.acceptance = self.task / ".devlyn/acceptance.json"
|
|
1172
|
+
self.acceptance.write_text(json.dumps({"kind": "direct", "task": route, "source_sha": self.sha,
|
|
1173
|
+
"checks": [{"command": "fixture byte assertion", "evidence": ".devlyn/loop/evidence/check.txt"}]}), encoding="utf-8")
|
|
1174
|
+
records = snapshot_files(self.task, [".devlyn"])
|
|
1175
|
+
flags = ["--mode", "auto" if route == "remote" else "pr"] if route in {"remote", "start"} else ["--local-only"]
|
|
1176
|
+
result, _ = self.complete("--writers-stopped", *flags)
|
|
1177
|
+
self.assertEqual(result["status"], status)
|
|
1178
|
+
self.assertEqual(self.g("diff", "--name-only", baseline, self.sha), "product")
|
|
1179
|
+
self.assertEqual(self.g("ls-tree", "-r", "--name-only", self.sha, "--", ".devlyn"), "")
|
|
1180
|
+
verify_files(self.receipt.parent / "custody", {p: v for p, v in records.items() if Path(p) != Path(".devlyn/.gitignore")})
|
|
1181
|
+
if route == "remote":
|
|
1182
|
+
self.assertFalse(self.task.exists())
|
|
1183
|
+
verify_files(self.receipt.parent / "records", records)
|
|
1184
|
+
self.assertEqual(self.run_cmd(["git", "--git-dir", str(self.bare), "diff", "--name-only", base, "main"]).stdout.strip(), "product")
|
|
1185
|
+
elif route == "start":
|
|
1186
|
+
self.assertEqual(self.run_cmd(["git", "--git-dir", str(self.bare), "rev-parse", "refs/heads/task/start"]).stdout.strip(), self.sha)
|
|
1187
|
+
self.assertEqual((ignore.read_bytes(), exclude.read_bytes(), self.anchor_state()), anchor)
|
|
1188
|
+
predecessor = self.receipt
|
|
1189
|
+
|
|
1190
|
+
def test_ignored_base_needs_no_task_ignore_file(self):
|
|
1191
|
+
# Prediction: a base that already ignores both record paths creates no .devlyn directory or ignore file.
|
|
1192
|
+
self.allocate()
|
|
1193
|
+
self.assertFalse((self.task / ".devlyn").exists())
|
|
1194
|
+
self.assertEqual(self.g("status", "--porcelain", "--untracked-files=all", work=self.task), "")
|
|
1195
|
+
|
|
1196
|
+
@unittest.skipIf(os.name == "nt", "the dirty checkout uses a POSIX shell hook")
|
|
1197
|
+
def test_ignored_base_with_unrelated_checkout_dirt_is_owned(self):
|
|
1198
|
+
# Prediction: an already-ignoring base reaches owned despite unrelated checkout-hook dirt,
|
|
1199
|
+
# without creating .devlyn/.gitignore; round 0 refuses it because it checks the whole tree.
|
|
1200
|
+
hook = self.work / ".git/hooks/post-checkout"
|
|
1201
|
+
hook.write_text("#!/bin/sh\nprintf 'checkout dirt\\n' > unrelated\n", encoding="utf-8")
|
|
1202
|
+
hook.chmod(0o755)
|
|
1203
|
+
result = self.allocate()
|
|
1204
|
+
self.assertEqual(result["status"], "ALLOCATED")
|
|
1205
|
+
self.assertEqual(json.loads(self.receipt.read_text(encoding="utf-8"))["allocation"], "owned")
|
|
1206
|
+
self.assertFalse((self.task / ".devlyn/.gitignore").exists())
|
|
1207
|
+
self.assertEqual(self.g("status", "--porcelain", "--untracked-files=all", work=self.task), "?? unrelated")
|
|
1208
|
+
|
|
1209
|
+
def test_allocation_refuses_existing_devlyn_paths(self):
|
|
1210
|
+
# Prediction: a tracked .devlyn file, symlink, submodule or non-covering .gitignore is refused with a named
|
|
1211
|
+
# repair, remains untouched, and never becomes an owned allocation.
|
|
1212
|
+
base = self.g("rev-parse", "HEAD")
|
|
1213
|
+
for kind in ("file", "symlink", "ignore", "submodule"):
|
|
1214
|
+
with self.subTest(kind=kind):
|
|
1215
|
+
self.g("reset", "--hard", base)
|
|
1216
|
+
(self.work / ".gitignore").write_text("ignored/\n", encoding="utf-8")
|
|
1217
|
+
path = self.work / ".devlyn"
|
|
1218
|
+
if kind == "submodule":
|
|
1219
|
+
self.g("update-index", "--add", "--cacheinfo", f"160000,{base},.devlyn")
|
|
1220
|
+
path.mkdir()
|
|
1221
|
+
else:
|
|
1222
|
+
if kind == "file":
|
|
1223
|
+
path.write_text("owned file\n", encoding="utf-8")
|
|
1224
|
+
elif kind == "symlink":
|
|
1225
|
+
path.symlink_to("product")
|
|
1226
|
+
else:
|
|
1227
|
+
path.mkdir()
|
|
1228
|
+
(path / ".gitignore").write_text("unrelated\n", encoding="utf-8")
|
|
1229
|
+
self.g("add", ".devlyn")
|
|
1230
|
+
self.g("commit", "-am", "existing .devlyn " + kind)
|
|
1231
|
+
start = self.g("rev-parse", "HEAD")
|
|
1232
|
+
tree = self.root / kind
|
|
1233
|
+
result, run = self.cli("allocate", "--repo", self.work, "--task", kind, "--branch", "task/" + kind,
|
|
1234
|
+
"--worktree", tree, "--local-base", start, success=False)
|
|
1235
|
+
self.assertNotEqual(run.returncode, 0)
|
|
1236
|
+
self.assertEqual(result["status"], "BLOCKED")
|
|
1237
|
+
self.assertIn(str(tree / ".devlyn"), result["reason"])
|
|
1238
|
+
self.assertIn("starting commit", result["reason"])
|
|
1239
|
+
self.assertEqual(self.g("status", "--porcelain", "--untracked-files=all", work=tree), "")
|
|
1240
|
+
if kind == "ignore":
|
|
1241
|
+
self.assertEqual((tree / ".devlyn/.gitignore").read_text(encoding="utf-8"), "unrelated\n")
|
|
1242
|
+
receipt = self.work / ".git/devlyn-completion" / hashlib.sha256(("task/" + kind).encode()).hexdigest()[:24] / "receipt.json"
|
|
1243
|
+
self.assertEqual(json.loads(receipt.read_text(encoding="utf-8"))["allocation"], "allocating")
|
|
1244
|
+
self.assertIn(f"remove worktree {tree} and branch task/{kind}, delete receipt directory {receipt.parent}, then allocate again", result["reason"])
|
|
1245
|
+
|
|
1246
|
+
def test_sparse_allocation_refuses_hidden_tracked_devlyn_paths(self):
|
|
1247
|
+
# Prediction: sparse checkout hiding a tracked .devlyn file/symlink or .devlyn/.gitignore (including a case
|
|
1248
|
+
# alias with core.ignorecase=true) refuses allocation before creating either path; contents/index stay unchanged.
|
|
1249
|
+
base = self.g("rev-parse", "HEAD")
|
|
1250
|
+
ignorecase = self.run_cmd(["git", "-C", str(self.work), "config", "--local", "--get", "core.ignorecase"], success=False)
|
|
1251
|
+
self.assertIn(ignorecase.returncode, (0, 1))
|
|
1252
|
+
for kind in ("file", "symlink", "ignore", "ignore_alias"):
|
|
1253
|
+
with self.subTest(kind=kind):
|
|
1254
|
+
self.g("reset", "--hard", base)
|
|
1255
|
+
(self.work / ".gitignore").write_text("ignored/\n", encoding="utf-8")
|
|
1256
|
+
path = self.work / ".devlyn"
|
|
1257
|
+
if kind == "file":
|
|
1258
|
+
path.write_text("owned file\n", encoding="utf-8")
|
|
1259
|
+
elif kind == "symlink":
|
|
1260
|
+
path.symlink_to("product")
|
|
1261
|
+
else:
|
|
1262
|
+
path.mkdir()
|
|
1263
|
+
(path / (".GITIGNORE" if kind == "ignore_alias" else ".gitignore")).write_text("unrelated\n", encoding="utf-8")
|
|
1264
|
+
self.g("add", "-f", ".devlyn")
|
|
1265
|
+
self.g("commit", "-am", "tracked .devlyn " + kind)
|
|
1266
|
+
start = self.g("rev-parse", "HEAD")
|
|
1267
|
+
self.g("sparse-checkout", "init", "--no-cone")
|
|
1268
|
+
try:
|
|
1269
|
+
if kind == "ignore_alias":
|
|
1270
|
+
self.g("config", "core.ignorecase", "true")
|
|
1271
|
+
self.g("sparse-checkout", "set", "/product")
|
|
1272
|
+
self.assertFalse(os.path.lexists(path))
|
|
1273
|
+
tree = self.root / kind
|
|
1274
|
+
result, run = self.cli("allocate", "--repo", self.work, "--task", kind, "--branch", "task/" + kind,
|
|
1275
|
+
"--worktree", tree, "--local-base", start, success=False)
|
|
1276
|
+
self.assertNotEqual(run.returncode, 0)
|
|
1277
|
+
self.assertEqual(result["status"], "BLOCKED")
|
|
1278
|
+
self.assertIn(str(tree / ".devlyn"), result["reason"])
|
|
1279
|
+
receipt_dir = self.work / ".git/devlyn-completion" / hashlib.sha256(("task/" + kind).encode()).hexdigest()[:24]
|
|
1280
|
+
self.assertIn(f"remove worktree {tree} and branch task/{kind}, delete receipt directory {receipt_dir}, then allocate again", result["reason"])
|
|
1281
|
+
self.assertFalse(os.path.lexists(tree / ".devlyn"))
|
|
1282
|
+
self.assertEqual(self.g("status", "--porcelain", "--untracked-files=all", work=tree), "")
|
|
1283
|
+
self.assertEqual(self.g("write-tree", work=tree), self.g("rev-parse", start + "^{tree}"))
|
|
1284
|
+
finally:
|
|
1285
|
+
self.g("sparse-checkout", "disable")
|
|
1286
|
+
if kind == "ignore_alias":
|
|
1287
|
+
if ignorecase.returncode == 0:
|
|
1288
|
+
self.g("config", "core.ignorecase", ignorecase.stdout.strip())
|
|
1289
|
+
else:
|
|
1290
|
+
self.g("config", "--unset", "core.ignorecase")
|
|
1291
|
+
|
|
1292
|
+
def test_case_sensitive_allocation_allows_distinct_devlyn_paths(self):
|
|
1293
|
+
# Prediction: with core.ignorecase=false, tracked .DEVLYN and .devlyn/.GITIGNORE are distinct from the
|
|
1294
|
+
# record paths: both allocate as owned, preserve the tracked file, and gain an ignored .devlyn/.gitignore.
|
|
1295
|
+
# Round 1 refuses both because its index guards always ignore case.
|
|
1296
|
+
with tempfile.TemporaryDirectory(dir=self.root) as scratch:
|
|
1297
|
+
probe = Path(scratch)
|
|
1298
|
+
(probe / ".DEVLYN").write_text("probe\n", encoding="utf-8")
|
|
1299
|
+
if (probe / ".devlyn").exists():
|
|
1300
|
+
self.skipTest("distinct .DEVLYN/.devlyn paths require a case-sensitive filesystem")
|
|
1301
|
+
(probe / ".GITIGNORE").write_text("probe\n", encoding="utf-8")
|
|
1302
|
+
if (probe / ".gitignore").exists():
|
|
1303
|
+
self.skipTest("distinct .GITIGNORE/.gitignore paths require a case-sensitive filesystem")
|
|
1304
|
+
self.g("config", "core.ignorecase", "false")
|
|
1305
|
+
base = self.g("rev-parse", "HEAD")
|
|
1306
|
+
for kind, relative in (("file_alias", ".DEVLYN"), ("ignore_alias", ".devlyn/.GITIGNORE")):
|
|
1307
|
+
with self.subTest(kind=kind):
|
|
1308
|
+
self.g("reset", "--hard", base)
|
|
1309
|
+
(self.work / ".gitignore").write_text("ignored/\n", encoding="utf-8")
|
|
1310
|
+
path = self.work / relative
|
|
1311
|
+
path.parent.mkdir(exist_ok=True)
|
|
1312
|
+
path.write_text("tracked alias\n", encoding="utf-8")
|
|
1313
|
+
self.g("add", "-f", relative)
|
|
1314
|
+
self.g("commit", "-am", "distinct .devlyn " + kind)
|
|
1315
|
+
result = self.allocate_local(kind, local_base=self.g("rev-parse", "HEAD"))
|
|
1316
|
+
self.assertEqual(result["status"], "ALLOCATED")
|
|
1317
|
+
self.assertEqual(json.loads(self.receipt.read_text(encoding="utf-8"))["allocation"], "owned")
|
|
1318
|
+
self.assertEqual((self.task / relative).read_text(encoding="utf-8"), "tracked alias\n")
|
|
1319
|
+
self.assertEqual((self.task / ".devlyn/.gitignore").read_text(encoding="utf-8"), "*\n")
|
|
1320
|
+
for record in (".devlyn/acceptance.json", ".devlyn/loop/evidence"):
|
|
1321
|
+
self.g("check-ignore", "-q", "--", record, work=self.task)
|
|
1322
|
+
self.assertEqual(self.g("status", "--porcelain", "--untracked-files=all", work=self.task), "")
|
|
1323
|
+
|
|
1324
|
+
def test_allocation_with_literal_pathspecs_ignores_records(self):
|
|
1325
|
+
# Prediction: GIT_LITERAL_PATHSPECS=1 still allows an unignored base to allocate as owned and ignore records,
|
|
1326
|
+
# including with core.ignorecase=true; round 1 fails at check-ignore with exit 128.
|
|
1327
|
+
(self.work / ".gitignore").write_text("ignored/\n", encoding="utf-8")
|
|
1328
|
+
self.g("commit", "-am", "base without .devlyn ignore")
|
|
1329
|
+
self.g("config", "core.ignorecase", "true")
|
|
1330
|
+
self.env["GIT_LITERAL_PATHSPECS"] = "1"
|
|
1331
|
+
result = self.allocate_local("literal", local_base=self.g("rev-parse", "HEAD"))
|
|
1332
|
+
self.assertEqual(result["status"], "ALLOCATED")
|
|
1333
|
+
self.assertEqual(json.loads(self.receipt.read_text(encoding="utf-8"))["allocation"], "owned")
|
|
1334
|
+
self.assertEqual((self.task / ".devlyn/.gitignore").read_text(encoding="utf-8"), "*\n")
|
|
1335
|
+
for record in (".devlyn/acceptance.json", ".devlyn/loop/evidence"):
|
|
1336
|
+
self.g("--no-literal-pathspecs", "check-ignore", "-q", "--", record, work=self.task)
|
|
1337
|
+
self.assertEqual(self.g("status", "--porcelain", "--untracked-files=all", work=self.task), "")
|
|
1338
|
+
|
|
1109
1339
|
def test_allocate_base_with_same_named_tag(self):
|
|
1110
1340
|
self.g("tag", "main")
|
|
1111
1341
|
result = self.allocate()
|
|
@@ -139,7 +139,7 @@ inputs when needed; cleanup is not an instruction to restart parked work.
|
|
|
139
139
|
In the allocated worktree, finish the candidate's decisive checks and diff
|
|
140
140
|
review, then stage and commit only accepted task changes. The helper never
|
|
141
141
|
stages product files. Write a
|
|
142
|
-
root acceptance file in the
|
|
142
|
+
root acceptance file in the task worktree (`.devlyn/acceptance.json`):
|
|
143
143
|
|
|
144
144
|
```json
|
|
145
145
|
{"kind":"direct","task":"<task identity>","source_sha":"<full commit>","checks":[{"command":"<actual check>","evidence":".devlyn/checks.log"}]}
|
|
@@ -24,7 +24,7 @@ The host supplies the executor and maps its configured engine to the argv; the l
|
|
|
24
24
|
|
|
25
25
|
1. **Reconcile.** First finish `add`'s cleanup of package copies ([package-format.md](package-format.md) Commands). Then read the queue view: the legacy rows, each replaced row as its loop's rows, then each loop's rows, from its capture or, without a record, from the checkout's tracked queue file ([package-format.md](package-format.md) Queue rows); every `<common Gitdir>/devlyn-completion/*/receipt.json` and their recovery refs. A row's identity, its receipt's branch `devlyn/<loop-id>/<task-id>`, the recovery ref (equal to the receipt's `publish_sha`, or to its validated terminal commit while that attachment is unfinished), the terminal commit's mark and, while active, the committed contract digests must agree. A receipt's terminal result supplements a `[ ]` row. Receipt, recovery-ref and packet conflicts invalidate the implicated tasks with an inspection/recovery reason; a missing queued prerequisite invalidates its dependent. Invalid evidence proves neither acceptance nor a frontier. A possible writer or package carrier retains its loop’s safety barrier while independent loops continue. An occupied receipt directory without a receipt waits for verified interrupted-allocation recovery; nothing is fabricated, rewritten or deleted. An unfinished allocation makes its task wait (step 10). Unreadable authoritative queue bytes, duplicate identities, missing/malformed queue files and invalid add records block the whole drain. A captured package never changes; for a loop without a record, an active task whose contract or expected acceptance changed in the checkout, also when that text is now missing or invalid, becomes `[F] inputs-changed` without executing again, its workspace retained, other work continuing while the revision is planned as a new task, and a pending task whose package text fails validation waits with that error, as do its dependents. Unsettled receipts (active, terminal commit unattached, or delivery not final) resume before any new allocation.
|
|
26
26
|
2. **Select** the earliest pending row whose prerequisites are accepted with a receipt in every mode (a hand-written `[x]` never counts) and, for `auto`/`pr`, delivered (merged). A failed or blocked prerequisite makes its dependent `[F] blocked-prerequisite:<id>` without invoking the executor; that derived mark is proven by the prerequisite's receipt and travels into later checkouts and the report. A prerequisite awaiting delivery leaves its dependent pending while independent work continues; so does an `auto`/`pr` carrier in flight (step 3), or a local loop's active task (a local loop runs one task at a time), for every other task of its loop. Legacy rows wait for `add --materialize`.
|
|
27
|
-
3. **Allocate** a new owned worktree on the absent branch `devlyn/<loop-id>/<task-id>` with `task-complete.py allocate`.
|
|
27
|
+
3. **Allocate** a new owned worktree on the absent branch `devlyn/<loop-id>/<task-id>` with `task-complete.py allocate`. The commit the task starts from must hold this checkout's `CLAUDE.md` and `AGENTS.md` (each identical, or both absent), so the task runs under the installed instructions, and, where it holds the loop's queue file, every captured package file unchanged, so the task binds its captured contract; otherwise the task waits, naming the fix, while independent work continues.
|
|
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.
|
|
@@ -1002,17 +1002,6 @@ def instruction_drift(anchor, rev):
|
|
|
1002
1002
|
return ", ".join(name for name in names if name in differ)
|
|
1003
1003
|
|
|
1004
1004
|
|
|
1005
|
-
def evidence_ignored(anchor, common, start):
|
|
1006
|
-
"""Whether a checkout of `start` ignores .devlyn/: its .gitignore files on that path, info/exclude, core.excludesFile."""
|
|
1007
|
-
with tempfile.TemporaryDirectory(prefix="devlyn-loop-ignore-") as temp:
|
|
1008
|
-
for rel in (".gitignore", ".devlyn/.gitignore", ".devlyn/loop/.gitignore"):
|
|
1009
|
-
if (rules := show(anchor, start, rel)) is not None:
|
|
1010
|
-
(Path(temp) / rel).parent.mkdir(parents=True, exist_ok=True)
|
|
1011
|
-
(Path(temp) / rel).write_bytes(rules)
|
|
1012
|
-
return git_run(temp, "--git-dir", str(common), "--work-tree", temp, "check-ignore", "-q", "--no-index",
|
|
1013
|
-
".devlyn/loop/evidence", ok=(0, 1)).returncode == 0
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
1005
|
def allocate(v, row, opts):
|
|
1017
1006
|
"""Allocate the task's owned worktree, or return why it waits: origin names no GitHub repository, its base is not the
|
|
1018
1007
|
default, or its refreshed remote base holds another version of the loop's package or lacks the checkout's instructions
|
|
@@ -1055,10 +1044,6 @@ def allocate(v, row, opts):
|
|
|
1055
1044
|
require_merged(v, row, start)
|
|
1056
1045
|
if drift := package_drift(v, loop, start, f"origin/{manifest['base_ref']}"):
|
|
1057
1046
|
return drift
|
|
1058
|
-
require(evidence_ignored(anchor, common, start),
|
|
1059
|
-
f"{identity}: .devlyn/ is not ignored in its start commit {start}, so loop evidence would dirty task source; commit a "
|
|
1060
|
-
f"`.devlyn/` entry to .gitignore in the base the task starts from (push it for remote delivery), or add `.devlyn/` to "
|
|
1061
|
-
f"{common / 'info' / 'exclude'}; then drain again")
|
|
1062
1047
|
if values["start"] and (drift := instruction_drift(anchor, start)):
|
|
1063
1048
|
return (f"the installed instructions ({drift}) in this checkout differ from those in its start commit {start}, so it would run "
|
|
1064
1049
|
f"without them; commit them and push them to origin/{manifest['base_ref']}")
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "devlyn-cli",
|
|
3
|
-
"version": "4.2.
|
|
3
|
+
"version": "4.2.24",
|
|
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": {
|