devlyn-cli 4.2.23 → 4.2.25

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.
@@ -23,15 +23,18 @@
23
23
  "21723943c52573124ce3c74e9090a8ae7d7c9ecdeb78fdfa52b89c45ff1b3374",
24
24
  "247904cad29c1c3ed810f848e66f982e8765e5c627ef3a18a876fd95eed9c29c",
25
25
  "24f73bd06d359296088e43ce2e7bf5178e3f69752b559639585563ec35fc33f5",
26
+ "2692865ee864aebedb2b385384b2a8220bc780bd92310bc1cf2f56927de1e0b8",
26
27
  "27d4da7863ed4652187f6a101da0e56b1d3d18e4565c720d5f8150ad0a193402",
27
28
  "2a4361bca0a9fadc4a8e5e4578c383171cd64c65296fe882990502250df44d53",
28
29
  "2bcac1ab5995e474c7dd46705413982876384d8b4da704126a66c10770a1a4d2",
29
30
  "2bcae17ca31adaf306d1e5d21ad0c211bbd6f57605f68168f656e078dbf524e2",
30
31
  "326a71c447fe4d98f3460eda3fc541920b7cc2205b8288743e8622d90215a49d",
31
32
  "34282740d421a65fc75dc95edf260aa0d0fc6b90472f7b4641cb80110df0a359",
33
+ "3429daf66b5130b3ff6e48f3015afbf7a8d7fd84d09aace462aadb3099aa3f09",
32
34
  "353b483942a62b79d1c5bf31e30060ef7ed9a67ae2d8b27450a53acb98e4e23c",
33
35
  "3554398c2be144938e4837d9b60c0976f685cfc2c0dd0451cda714fd053f3c7d",
34
36
  "35c7e6c069c847ff147f2bf4f3b67a296652c122aa91a3966457a00dd0597b78",
37
+ "364aa97ec4065d8b78188f40281dfa575120a8fb91089120363e532566ef0dcb",
35
38
  "37acccbdc76aee15311b5f47fd26c50b1652dbe7cbcbacfc37774841ab464028",
36
39
  "393ad728eb0906ae61110fde3820c4fce015d8f94643a2877372b74a6b6eeb02",
37
40
  "3b6193eabc83d815804533010ca7bff1f240286e7898380a5fc2adac71dcb513",
@@ -99,6 +102,7 @@
99
102
  "9162fc2fed6f4a611d299485be3a397080beb3304865ce59b95576ba7d00650e",
100
103
  "94a14a1b87df1d7297d12949a24e47a0d6c16a8fe30495ee759fcabb2e1aa398",
101
104
  "978e4210e49b42827c98cf6e40c868c87dc6e3533a2b779ca2fa05abfc16ad0f",
105
+ "97e7ad89c74c609b15dfbf6f9f7ac61df8b50e97e4bc318f72922e058ecffba4",
102
106
  "9815b6e6963e068872a6dbeae24068143bf92042bb035f67bafd1a224d65bcb0",
103
107
  "998343c148618b937556b58b83749c05d5aa3283280d751c118da6a542fb87f2",
104
108
  "9dd3ec225818efe0ad3726192db94ff129154e995617a9d3c150fc58ad0f9f2c",
@@ -338,9 +342,11 @@
338
342
  "9d62f726c5ac7ed8b3989c6ab3705b5c9e4b44bb70eeece0d43bd76142b9b75b",
339
343
  "a12b24d2a70917b5d4e698af5478b6cce4d09ddf021f63ab7129402867433a72",
340
344
  "a3b852c30898b572021b32721638f67d9f44cae98653da3362456a7b88208de7",
345
+ "a3c1de9718724e26c095af6f35baf3b7af1afc25ff33657ff3e8db066c692408",
341
346
  "a7d7d65fd5f672cb404e6069fdc42ad7077deaa80ed8500cfa8b382dab2e3e29",
342
347
  "aba66b674683935e4c28c93333896dc116ffe67e7a35fe30b3599a8be507e94e",
343
348
  "b2e4437fa981317362d03ee032e1cfd8b686316e966c6ba12ecd619a009c80a6",
349
+ "bdd0751eea04532c050334fda3bc5c61c6b429e485d97e0b8ebb4fae154da2c7",
344
350
  "c1b8e3ebf38e2be8972aa003e9614a782cb4f195c69d20b95a475ebf9aaaab9e",
345
351
  "c8e375aab574148542f392b093cbbf3f2e8dc623826b45e35df0a1a2c3862226",
346
352
  "ce282a55328c2d071cacc377670fbebea4da4b9a7fee81b25293fb26763f17c9",
@@ -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,11 +256,20 @@ 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"
231
266
  atomic_json(path, receipt)
267
+ # Owned first: an interrupted warm-up leaves a usable allocation whose build starts cold.
268
+ with locked_receipt(path) as owned:
269
+ owned["build_dirs"] = shared("warm-build-dirs")["warm"](target, work, scratch)
270
+ atomic_json(path, owned)
232
271
  return {"status": "ALLOCATED", "receipt": str(path), "worktree": str(target), "scratch": str(scratch),
272
+ "build_dirs": owned["build_dirs"],
233
273
  "reconciled": [] if local else reconcile(common, path, work, exclude_receipts=exclude_receipts)}
234
274
 
235
275
 
@@ -1106,6 +1146,201 @@ class CompletionTests(unittest.TestCase):
1106
1146
  self.g("status", "--porcelain", "--untracked-files=all", work=work), (work / "product").read_bytes(),
1107
1147
  (Path(self.g("rev-parse", "--absolute-git-dir", work=work)) / "index").read_bytes())
1108
1148
 
1149
+ def test_unignored_base_delivers_only_product_and_preserves_records(self):
1150
+ # Prediction: with the ignore rule only in the anchor's dirty .gitignore, remote, --start, --local-base
1151
+ # and --from-receipt allocations complete in their requested modes; only product is delivered, evidence
1152
+ # survives custody/cleanup, and the anchor's .gitignore, info/exclude and HEAD remain byte-identical.
1153
+ ignore = self.work / ".gitignore"
1154
+ ignore.write_text("ignored/\nnode_modules/\n.env\n", encoding="utf-8")
1155
+ self.g("commit", "-am", "base without .devlyn ignore")
1156
+ self.g("push", "origin", "main")
1157
+ base = self.g("rev-parse", "HEAD")
1158
+ ignore.write_text(ignore.read_text(encoding="utf-8") + ".devlyn/\n", encoding="utf-8")
1159
+ exclude = self.work / ".git/info/exclude"
1160
+ anchor = (ignore.read_bytes(), exclude.read_bytes(), self.anchor_state())
1161
+ predecessor = None
1162
+ for route, status in (("remote", "COMPLETE"), ("start", "PR"), ("local_base", "LOCAL_ONLY"),
1163
+ ("from_receipt", "LOCAL_ONLY")):
1164
+ with self.subTest(route=route):
1165
+ source = {} if route == "remote" else {route: predecessor if route == "from_receipt" else base}
1166
+ self.allocate_local(route, **source)
1167
+ baseline = json.loads(self.receipt.read_text(encoding="utf-8"))["baseline"]
1168
+ (self.task / "product").write_text(route + "\n", encoding="utf-8")
1169
+ self.g("add", "product", work=self.task)
1170
+ self.g("commit", "-m", "scoped product", work=self.task)
1171
+ self.sha = self.g("rev-parse", "HEAD", work=self.task)
1172
+ evidence = self.task / ".devlyn/loop/evidence/check.txt"
1173
+ evidence.parent.mkdir(parents=True, exist_ok=True)
1174
+ evidence.write_text("actual fixture check: " + route + "\n", encoding="utf-8")
1175
+ self.assertEqual((self.task / "product").read_text(encoding="utf-8"), route + "\n")
1176
+ self.acceptance = self.task / ".devlyn/acceptance.json"
1177
+ self.acceptance.write_text(json.dumps({"kind": "direct", "task": route, "source_sha": self.sha,
1178
+ "checks": [{"command": "fixture byte assertion", "evidence": ".devlyn/loop/evidence/check.txt"}]}), encoding="utf-8")
1179
+ records = snapshot_files(self.task, [".devlyn"])
1180
+ flags = ["--mode", "auto" if route == "remote" else "pr"] if route in {"remote", "start"} else ["--local-only"]
1181
+ result, _ = self.complete("--writers-stopped", *flags)
1182
+ self.assertEqual(result["status"], status)
1183
+ self.assertEqual(self.g("diff", "--name-only", baseline, self.sha), "product")
1184
+ self.assertEqual(self.g("ls-tree", "-r", "--name-only", self.sha, "--", ".devlyn"), "")
1185
+ verify_files(self.receipt.parent / "custody", {p: v for p, v in records.items() if Path(p) != Path(".devlyn/.gitignore")})
1186
+ if route == "remote":
1187
+ self.assertFalse(self.task.exists())
1188
+ verify_files(self.receipt.parent / "records", records)
1189
+ self.assertEqual(self.run_cmd(["git", "--git-dir", str(self.bare), "diff", "--name-only", base, "main"]).stdout.strip(), "product")
1190
+ elif route == "start":
1191
+ self.assertEqual(self.run_cmd(["git", "--git-dir", str(self.bare), "rev-parse", "refs/heads/task/start"]).stdout.strip(), self.sha)
1192
+ self.assertEqual((ignore.read_bytes(), exclude.read_bytes(), self.anchor_state()), anchor)
1193
+ predecessor = self.receipt
1194
+
1195
+ def test_ignored_base_needs_no_task_ignore_file(self):
1196
+ # Prediction: a base that already ignores both record paths creates no .devlyn directory or ignore file.
1197
+ self.allocate()
1198
+ self.assertFalse((self.task / ".devlyn").exists())
1199
+ self.assertEqual(self.g("status", "--porcelain", "--untracked-files=all", work=self.task), "")
1200
+
1201
+ @unittest.skipIf(os.name == "nt", "the dirty checkout uses a POSIX shell hook")
1202
+ def test_ignored_base_with_unrelated_checkout_dirt_is_owned(self):
1203
+ # Prediction: an already-ignoring base reaches owned despite unrelated checkout-hook dirt,
1204
+ # without creating .devlyn/.gitignore; round 0 refuses it because it checks the whole tree.
1205
+ hook = self.work / ".git/hooks/post-checkout"
1206
+ hook.write_text("#!/bin/sh\nprintf 'checkout dirt\\n' > unrelated\n", encoding="utf-8")
1207
+ hook.chmod(0o755)
1208
+ result = self.allocate()
1209
+ self.assertEqual(result["status"], "ALLOCATED")
1210
+ self.assertEqual(json.loads(self.receipt.read_text(encoding="utf-8"))["allocation"], "owned")
1211
+ self.assertFalse((self.task / ".devlyn/.gitignore").exists())
1212
+ self.assertEqual(self.g("status", "--porcelain", "--untracked-files=all", work=self.task), "?? unrelated")
1213
+
1214
+ def test_allocation_refuses_existing_devlyn_paths(self):
1215
+ # Prediction: a tracked .devlyn file, symlink, submodule or non-covering .gitignore is refused with a named
1216
+ # repair, remains untouched, and never becomes an owned allocation.
1217
+ base = self.g("rev-parse", "HEAD")
1218
+ for kind in ("file", "symlink", "ignore", "submodule"):
1219
+ with self.subTest(kind=kind):
1220
+ self.g("reset", "--hard", base)
1221
+ (self.work / ".gitignore").write_text("ignored/\n", encoding="utf-8")
1222
+ path = self.work / ".devlyn"
1223
+ if kind == "submodule":
1224
+ self.g("update-index", "--add", "--cacheinfo", f"160000,{base},.devlyn")
1225
+ path.mkdir()
1226
+ else:
1227
+ if kind == "file":
1228
+ path.write_text("owned file\n", encoding="utf-8")
1229
+ elif kind == "symlink":
1230
+ path.symlink_to("product")
1231
+ else:
1232
+ path.mkdir()
1233
+ (path / ".gitignore").write_text("unrelated\n", encoding="utf-8")
1234
+ self.g("add", ".devlyn")
1235
+ self.g("commit", "-am", "existing .devlyn " + kind)
1236
+ start = self.g("rev-parse", "HEAD")
1237
+ tree = self.root / kind
1238
+ result, run = self.cli("allocate", "--repo", self.work, "--task", kind, "--branch", "task/" + kind,
1239
+ "--worktree", tree, "--local-base", start, success=False)
1240
+ self.assertNotEqual(run.returncode, 0)
1241
+ self.assertEqual(result["status"], "BLOCKED")
1242
+ self.assertIn(str(tree / ".devlyn"), result["reason"])
1243
+ self.assertIn("starting commit", result["reason"])
1244
+ self.assertEqual(self.g("status", "--porcelain", "--untracked-files=all", work=tree), "")
1245
+ if kind == "ignore":
1246
+ self.assertEqual((tree / ".devlyn/.gitignore").read_text(encoding="utf-8"), "unrelated\n")
1247
+ receipt = self.work / ".git/devlyn-completion" / hashlib.sha256(("task/" + kind).encode()).hexdigest()[:24] / "receipt.json"
1248
+ self.assertEqual(json.loads(receipt.read_text(encoding="utf-8"))["allocation"], "allocating")
1249
+ self.assertIn(f"remove worktree {tree} and branch task/{kind}, delete receipt directory {receipt.parent}, then allocate again", result["reason"])
1250
+
1251
+ def test_sparse_allocation_refuses_hidden_tracked_devlyn_paths(self):
1252
+ # Prediction: sparse checkout hiding a tracked .devlyn file/symlink or .devlyn/.gitignore (including a case
1253
+ # alias with core.ignorecase=true) refuses allocation before creating either path; contents/index stay unchanged.
1254
+ base = self.g("rev-parse", "HEAD")
1255
+ ignorecase = self.run_cmd(["git", "-C", str(self.work), "config", "--local", "--get", "core.ignorecase"], success=False)
1256
+ self.assertIn(ignorecase.returncode, (0, 1))
1257
+ for kind in ("file", "symlink", "ignore", "ignore_alias"):
1258
+ with self.subTest(kind=kind):
1259
+ self.g("reset", "--hard", base)
1260
+ (self.work / ".gitignore").write_text("ignored/\n", encoding="utf-8")
1261
+ path = self.work / ".devlyn"
1262
+ if kind == "file":
1263
+ path.write_text("owned file\n", encoding="utf-8")
1264
+ elif kind == "symlink":
1265
+ path.symlink_to("product")
1266
+ else:
1267
+ path.mkdir()
1268
+ (path / (".GITIGNORE" if kind == "ignore_alias" else ".gitignore")).write_text("unrelated\n", encoding="utf-8")
1269
+ self.g("add", "-f", ".devlyn")
1270
+ self.g("commit", "-am", "tracked .devlyn " + kind)
1271
+ start = self.g("rev-parse", "HEAD")
1272
+ self.g("sparse-checkout", "init", "--no-cone")
1273
+ try:
1274
+ if kind == "ignore_alias":
1275
+ self.g("config", "core.ignorecase", "true")
1276
+ self.g("sparse-checkout", "set", "/product")
1277
+ self.assertFalse(os.path.lexists(path))
1278
+ tree = self.root / kind
1279
+ result, run = self.cli("allocate", "--repo", self.work, "--task", kind, "--branch", "task/" + kind,
1280
+ "--worktree", tree, "--local-base", start, success=False)
1281
+ self.assertNotEqual(run.returncode, 0)
1282
+ self.assertEqual(result["status"], "BLOCKED")
1283
+ self.assertIn(str(tree / ".devlyn"), result["reason"])
1284
+ receipt_dir = self.work / ".git/devlyn-completion" / hashlib.sha256(("task/" + kind).encode()).hexdigest()[:24]
1285
+ self.assertIn(f"remove worktree {tree} and branch task/{kind}, delete receipt directory {receipt_dir}, then allocate again", result["reason"])
1286
+ self.assertFalse(os.path.lexists(tree / ".devlyn"))
1287
+ self.assertEqual(self.g("status", "--porcelain", "--untracked-files=all", work=tree), "")
1288
+ self.assertEqual(self.g("write-tree", work=tree), self.g("rev-parse", start + "^{tree}"))
1289
+ finally:
1290
+ self.g("sparse-checkout", "disable")
1291
+ if kind == "ignore_alias":
1292
+ if ignorecase.returncode == 0:
1293
+ self.g("config", "core.ignorecase", ignorecase.stdout.strip())
1294
+ else:
1295
+ self.g("config", "--unset", "core.ignorecase")
1296
+
1297
+ def test_case_sensitive_allocation_allows_distinct_devlyn_paths(self):
1298
+ # Prediction: with core.ignorecase=false, tracked .DEVLYN and .devlyn/.GITIGNORE are distinct from the
1299
+ # record paths: both allocate as owned, preserve the tracked file, and gain an ignored .devlyn/.gitignore.
1300
+ # Round 1 refuses both because its index guards always ignore case.
1301
+ with tempfile.TemporaryDirectory(dir=self.root) as scratch:
1302
+ probe = Path(scratch)
1303
+ (probe / ".DEVLYN").write_text("probe\n", encoding="utf-8")
1304
+ if (probe / ".devlyn").exists():
1305
+ self.skipTest("distinct .DEVLYN/.devlyn paths require a case-sensitive filesystem")
1306
+ (probe / ".GITIGNORE").write_text("probe\n", encoding="utf-8")
1307
+ if (probe / ".gitignore").exists():
1308
+ self.skipTest("distinct .GITIGNORE/.gitignore paths require a case-sensitive filesystem")
1309
+ self.g("config", "core.ignorecase", "false")
1310
+ base = self.g("rev-parse", "HEAD")
1311
+ for kind, relative in (("file_alias", ".DEVLYN"), ("ignore_alias", ".devlyn/.GITIGNORE")):
1312
+ with self.subTest(kind=kind):
1313
+ self.g("reset", "--hard", base)
1314
+ (self.work / ".gitignore").write_text("ignored/\n", encoding="utf-8")
1315
+ path = self.work / relative
1316
+ path.parent.mkdir(exist_ok=True)
1317
+ path.write_text("tracked alias\n", encoding="utf-8")
1318
+ self.g("add", "-f", relative)
1319
+ self.g("commit", "-am", "distinct .devlyn " + kind)
1320
+ result = self.allocate_local(kind, local_base=self.g("rev-parse", "HEAD"))
1321
+ self.assertEqual(result["status"], "ALLOCATED")
1322
+ self.assertEqual(json.loads(self.receipt.read_text(encoding="utf-8"))["allocation"], "owned")
1323
+ self.assertEqual((self.task / relative).read_text(encoding="utf-8"), "tracked alias\n")
1324
+ self.assertEqual((self.task / ".devlyn/.gitignore").read_text(encoding="utf-8"), "*\n")
1325
+ for record in (".devlyn/acceptance.json", ".devlyn/loop/evidence"):
1326
+ self.g("check-ignore", "-q", "--", record, work=self.task)
1327
+ self.assertEqual(self.g("status", "--porcelain", "--untracked-files=all", work=self.task), "")
1328
+
1329
+ def test_allocation_with_literal_pathspecs_ignores_records(self):
1330
+ # Prediction: GIT_LITERAL_PATHSPECS=1 still allows an unignored base to allocate as owned and ignore records,
1331
+ # including with core.ignorecase=true; round 1 fails at check-ignore with exit 128.
1332
+ (self.work / ".gitignore").write_text("ignored/\n", encoding="utf-8")
1333
+ self.g("commit", "-am", "base without .devlyn ignore")
1334
+ self.g("config", "core.ignorecase", "true")
1335
+ self.env["GIT_LITERAL_PATHSPECS"] = "1"
1336
+ result = self.allocate_local("literal", local_base=self.g("rev-parse", "HEAD"))
1337
+ self.assertEqual(result["status"], "ALLOCATED")
1338
+ self.assertEqual(json.loads(self.receipt.read_text(encoding="utf-8"))["allocation"], "owned")
1339
+ self.assertEqual((self.task / ".devlyn/.gitignore").read_text(encoding="utf-8"), "*\n")
1340
+ for record in (".devlyn/acceptance.json", ".devlyn/loop/evidence"):
1341
+ self.g("--no-literal-pathspecs", "check-ignore", "-q", "--", record, work=self.task)
1342
+ self.assertEqual(self.g("status", "--porcelain", "--untracked-files=all", work=self.task), "")
1343
+
1109
1344
  def test_allocate_base_with_same_named_tag(self):
1110
1345
  self.g("tag", "main")
1111
1346
  result = self.allocate()
@@ -106,10 +106,21 @@ require the affected checks again.
106
106
  Keep the original edits through pending or failed delivery; only reconciliation
107
107
  removes them. Preserve all other work and report anything left behind.
108
108
 
109
- Allocation also returns a receipt-owned `scratch` directory. Put disposable
110
- build intermediates there (for example, set
111
- `CARGO_TARGET_DIR` to `<scratch>/target`). Keep source checkouts, Git data,
112
- lockfiles, reports and restart evidence outside scratch. Do not create unowned
109
+ Build in the task worktree's own default build directory, never in one
110
+ another checkout builds in, even one build at a time: tools such as Cargo take
111
+ one checkout's outputs as fresh for the other. When a Cargo workspace's
112
+ Cargo.toml sets `[workspace.metadata.devlyn] warm-build-dirs = true`,
113
+ allocation seeds that directory from a built checkout of the repository, so
114
+ the first build does not rebuild every dependency; `build_dirs` in its result
115
+ and receipt says what it did, and a skip means an ordinary cold build. The
116
+ opt-in is the workspace's: seeding keeps dependency outputs that may hold the
117
+ other checkout's paths, and those follow that checkout's later rebuilds. `warm-build-dirs.py <worktree>` does the same for a
118
+ worktree made without allocation. A warmed directory leaves with the worktree
119
+ after merge, so a pending, failed or local-only task keeps it.
120
+
121
+ Allocation also returns a receipt-owned `scratch` directory for other
122
+ disposable build files, such as writable tool caches. Keep source checkouts,
123
+ Git data, lockfiles, reports and restart evidence outside scratch. Do not create unowned
113
124
  temporary build trees or preserve them by copying them into evidence custody.
114
125
  Prefer `TemporaryDirectory`/`finally` for short-lived test fixtures.
115
126
 
@@ -139,7 +150,7 @@ inputs when needed; cleanup is not an instruction to restart parked work.
139
150
  In the allocated worktree, finish the candidate's decisive checks and diff
140
151
  review, then stage and commit only accepted task changes. The helper never
141
152
  stages product files. Write a
142
- root acceptance file in the checkout (usually ignored `.devlyn/acceptance.json`):
153
+ root acceptance file in the task worktree (`.devlyn/acceptance.json`):
143
154
 
144
155
  ```json
145
156
  {"kind":"direct","task":"<task identity>","source_sha":"<full commit>","checks":[{"command":"<actual check>","evidence":".devlyn/checks.log"}]}