devlyn-cli 4.3.16 → 4.3.20

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.
Files changed (37) hide show
  1. package/README.md +19 -0
  2. package/bin/skill-history.json +12 -0
  3. package/config/skills/_shared/adapters/claude.md +15 -0
  4. package/config/skills/_shared/adapters/codex.md +10 -0
  5. package/config/skills/_shared/invocation-receipt.py +179 -9
  6. package/config/skills/_shared/platform-support.py +102 -1
  7. package/config/skills/_shared/task-complete.py +240 -25
  8. package/config/skills/_shared/task-completion.md +7 -4
  9. package/config/skills/_shared/unattended-tools.py +206 -0
  10. package/config/skills/devlyn-ideate/SKILL.md +1 -1
  11. package/config/skills/devlyn-ideate/scripts/acceptance.py +307 -1
  12. package/config/skills/devlyn-ideate/scripts/queue.py +31 -14
  13. package/native/claude/.claude-plugin/plugin.json +1 -1
  14. package/native/claude/skills/_shared/adapters/claude.md +15 -0
  15. package/native/claude/skills/_shared/adapters/codex.md +10 -0
  16. package/native/claude/skills/_shared/invocation-receipt.py +179 -9
  17. package/native/claude/skills/_shared/platform-support.py +102 -1
  18. package/native/claude/skills/_shared/task-complete.py +240 -25
  19. package/native/claude/skills/_shared/task-completion.md +7 -4
  20. package/native/claude/skills/_shared/unattended-tools.py +206 -0
  21. package/native/claude/skills/devlyn-ideate/SKILL.md +1 -1
  22. package/native/claude/skills/devlyn-ideate/scripts/acceptance.py +307 -1
  23. package/native/claude/skills/devlyn-ideate/scripts/queue.py +31 -14
  24. package/native/codex/.agents/plugins/marketplace.json +1 -1
  25. package/native/codex/plugins/devlyn/.codex-plugin/plugin.json +1 -1
  26. package/native/codex/plugins/devlyn/skills/_shared/adapters/claude.md +15 -0
  27. package/native/codex/plugins/devlyn/skills/_shared/adapters/codex.md +10 -0
  28. package/native/codex/plugins/devlyn/skills/_shared/invocation-receipt.py +179 -9
  29. package/native/codex/plugins/devlyn/skills/_shared/platform-support.py +102 -1
  30. package/native/codex/plugins/devlyn/skills/_shared/task-complete.py +240 -25
  31. package/native/codex/plugins/devlyn/skills/_shared/task-completion.md +7 -4
  32. package/native/codex/plugins/devlyn/skills/_shared/unattended-tools.py +206 -0
  33. package/native/codex/plugins/devlyn/skills/devlyn-ideate/SKILL.md +1 -1
  34. package/native/codex/plugins/devlyn/skills/devlyn-ideate/scripts/acceptance.py +307 -1
  35. package/native/codex/plugins/devlyn/skills/devlyn-ideate/scripts/queue.py +31 -14
  36. package/package.json +1 -1
  37. package/release.json +50 -35
@@ -777,7 +777,8 @@ def clean_scratch_command(args):
777
777
 
778
778
  def cleanup(receipt, path, pr, *, warnings=None):
779
779
  warnings = [] if warnings is None else warnings
780
- require(pr["state"] == "MERGED" and pr.get("mergedAt") and pr.get("mergeCommit", {}).get("oid"), "actual matching merge evidence is required")
780
+ if pr is not None:
781
+ require(pr["state"] == "MERGED" and pr.get("mergedAt") and pr.get("mergeCommit", {}).get("oid"), "actual matching merge evidence is required")
781
782
  sha = receipt["publish_sha"]
782
783
  branch_ref = "refs/heads/" + receipt["branch"]
783
784
  # The recovery ref keeps the accepted commit reachable, so squash and
@@ -785,7 +786,7 @@ def cleanup(receipt, path, pr, *, warnings=None):
785
786
  require(ref_sha(receipt, receipt["recovery_ref"]) == sha, "recovery reachability changed; retain resources")
786
787
  verify_files(path.parent / "custody", receipt["files"])
787
788
  require(read_json(path.parent / "manifest.json") == receipt["files"], "custody manifest changed")
788
- gref(receipt, "merge-base", "--is-ancestor", pr["mergeCommit"]["oid"], remote_base(receipt))
789
+ gref(receipt, "merge-base", "--is-ancestor", pr["mergeCommit"]["oid"] if pr else sha, remote_base(receipt))
789
790
  gref(receipt, "merge-base", "--is-ancestor", receipt["source_sha"], sha)
790
791
  work = Path(receipt["worktree"])
791
792
  local = ref_sha(receipt, branch_ref)
@@ -887,26 +888,29 @@ def reconcile(common, allocated, anchor, *, exclude_receipts=(), warnings=None):
887
888
  continue
888
889
  try:
889
890
  receipt = read_json(path)
890
- if receipt.get("allocation") != "owned" or not receipt.get("acceptance") or not receipt.get("pr_number") or receipt.get("local_only"):
891
+ if receipt.get("allocation") != "owned" or not receipt.get("acceptance") or receipt.get("local_only") or not (receipt.get("pr_number") or receipt.get("delivery") == "COMPLETE"):
891
892
  continue
892
893
  if receipt.get("status") == "COMPLETE" and (not receipt.get("writers_released") or "scratch_identity" not in receipt or receipt.get("scratch_cleanup", {}).get("status") == "CLEAN"):
893
894
  continue
894
895
  with locked_receipt(path, blocking=False) as receipt:
896
+ if not receipt.get("acceptance") or receipt.get("local_only") or not (receipt.get("pr_number") or receipt.get("delivery") == "COMPLETE"):
897
+ continue
895
898
  if receipt.get("status") == "COMPLETE":
896
899
  if receipt.get("writers_released") and "scratch_identity" in receipt and receipt.get("scratch_cleanup", {}).get("status") != "CLEAN":
897
900
  results.append(completion_result(receipt, path, "COMPLETE"))
898
901
  continue
899
- if not receipt.get("acceptance") or not receipt.get("pr_number") or receipt.get("local_only"):
900
- continue
901
- pr = json.loads(gh(receipt, "pr", "view", str(receipt["pr_number"]), "--json", PR_FIELDS))
902
- if pr["state"] != "MERGED":
903
- continue
904
- validate_pr(receipt, pr)
902
+ pr = None
903
+ if receipt.get("pr_number"):
904
+ pr = json.loads(gh(receipt, "pr", "view", str(receipt["pr_number"]), "--json", PR_FIELDS))
905
+ if pr["state"] != "MERGED":
906
+ continue
907
+ validate_pr(receipt, pr)
905
908
  require(not receipt["linked"] or Path(receipt["worktree"]) != anchor, "cannot clean the current allocation's anchor checkout: " + str(anchor))
906
909
  require(remote_url(receipt) == receipt["remote_url"], "remote URL changed since allocation")
907
910
  repo_policy(receipt)
908
- receipt["merge"] = pr
909
- atomic_json(path, receipt)
911
+ if pr:
912
+ receipt["merge"] = pr
913
+ atomic_json(path, receipt)
910
914
  cleanup(receipt, path, pr, warnings=warnings)
911
915
  results.append(completion_result(receipt, path, "COMPLETE", warnings=warnings))
912
916
  except (Exception, SystemExit) as error:
@@ -931,6 +935,9 @@ def complete(args, *, warnings=None):
931
935
  if (receipt.get("acceptance") or {}).get("verdict") == "FAILED":
932
936
  return result("FAILED")
933
937
  if args.local_only or receipt.get("local_only"):
938
+ require(receipt.get("delivery") != "COMPLETE" and receipt.get("status") != "COMPLETE" and "workspace_cleanup" not in receipt,
939
+ f"{receipt['task']} delivery is already COMPLETE; "
940
+ "--local-only cannot rewrite its delivery, resume it without --local-only")
934
941
  require(not receipt.get("pushed"), f"{receipt['task']} already has a pushed PR {receipt.get('pr_url') or receipt['branch']}; "
935
942
  "--local-only cannot rewrite its delivery, resume it without --local-only")
936
943
  receipt["local_only"] = True
@@ -969,8 +976,17 @@ def complete(args, *, warnings=None):
969
976
  pr = prs[0] if prs else None
970
977
  if pr:
971
978
  validate_pr(receipt, pr)
972
- actual = remote_head(receipt, receipt["branch"])
973
- if not pr or pr["state"] != "MERGED":
979
+ on_base = pr is None and receipt.get("delivery") == "COMPLETE"
980
+ if pr is None and not on_base:
981
+ ancestry = subprocess.run(["git", "--git-dir", receipt["common_gitdir"], "merge-base", "--is-ancestor",
982
+ receipt["publish_sha"], remote_base(receipt)], capture_output=True, text=True, encoding="utf-8")
983
+ require(ancestry.returncode in {0, 1}, "cannot check accepted commit on base: " + ancestry.stderr.strip())
984
+ on_base = ancestry.returncode == 0
985
+ if on_base:
986
+ require(remote_head(receipt, receipt["branch"]) in {None, receipt["publish_sha"]},
987
+ "remote task head changed; refusing to overwrite or merge")
988
+ if not on_base and (not pr or pr["state"] != "MERGED"):
989
+ actual = remote_head(receipt, receipt["branch"])
974
990
  inspect_workspace(receipt)
975
991
  verify_files(Path(receipt["worktree"]), receipt["files"])
976
992
  require(actual in {None, receipt["publish_sha"]}, "remote task head changed; refusing to overwrite or merge")
@@ -985,9 +1001,10 @@ def complete(args, *, warnings=None):
985
1001
  require(len(prs) == 1, "created PR could not be reconciled; resume from receipt")
986
1002
  pr = prs[0]
987
1003
  validate_pr(receipt, pr)
988
- receipt.update(pr_number=pr["number"], pr_url=pr["url"])
989
- atomic_json(path, receipt)
990
- if mode == "pr" and pr["state"] == "OPEN":
1004
+ if pr:
1005
+ receipt.update(pr_number=pr["number"], pr_url=pr["url"])
1006
+ atomic_json(path, receipt)
1007
+ if pr and mode == "pr" and pr["state"] == "OPEN":
991
1008
  if pr.get("autoMergeRequest"):
992
1009
  gh(receipt, "pr", "merge", str(pr["number"]), "--disable-auto")
993
1010
  pr = json.loads(gh(receipt, "pr", "view", str(pr["number"]), "--json", PR_FIELDS))
@@ -995,7 +1012,7 @@ def complete(args, *, warnings=None):
995
1012
  require(not pr.get("autoMergeRequest"), "auto-merge request remains on owned PR; retain and inspect")
996
1013
  if pr["state"] == "OPEN":
997
1014
  return result("PR")
998
- if pr["state"] != "MERGED":
1015
+ if pr and pr["state"] != "MERGED":
999
1016
  inspect_workspace(receipt)
1000
1017
  verify_files(Path(receipt["worktree"]), receipt["files"])
1001
1018
  require(remote_head(receipt, receipt["branch"]) == receipt["publish_sha"], "remote head race before merge")
@@ -1035,12 +1052,13 @@ def complete(args, *, warnings=None):
1035
1052
  if pr["state"] != "MERGED":
1036
1053
  # Merge only when the repository allows it; a refused request leaves the PR for a person.
1037
1054
  return dict(result("PR"), merge_refused=refused) if refused and not pr.get("autoMergeRequest") else result("PENDING")
1038
- receipt["merge"] = pr
1039
- atomic_json(path, receipt)
1055
+ if pr:
1056
+ receipt["merge"] = pr
1057
+ atomic_json(path, receipt)
1040
1058
  try:
1041
1059
  cleanup(receipt, path, pr, warnings=warnings)
1042
1060
  except (WritersUnobservable, WriterActive) as error:
1043
- # Conservative retention: the merge settles delivery; workspace, task refs and custody stay, reported.
1061
+ # Conservative retention: base containment settles delivery; workspace, task refs and custody stay, reported.
1044
1062
  receipt["workspace_cleanup"] = {"status": "RETAINED", "reason": str(error), "resume": shlex.join([
1045
1063
  sys.executable, str(Path(__file__).resolve()), "complete", "--receipt", str(path)])}
1046
1064
  atomic_json(path, receipt)
@@ -2169,17 +2187,18 @@ ctypes.CDLL(None).pthread_exit(None)
2169
2187
  with self.assertRaisesRegex(CompletionError, "cannot parse mount table"):
2170
2188
  scratch_mounts()
2171
2189
 
2172
- def accept(self):
2173
- (self.task / "product").write_text("accepted\n", encoding="utf-8")
2174
- self.g("add", "product", work=self.task)
2175
- self.g("commit", "-m", "scoped task", work=self.task)
2190
+ def accept(self, *, unchanged=False):
2191
+ if not unchanged:
2192
+ (self.task / "product").write_text("accepted\n", encoding="utf-8")
2193
+ self.g("add", "product", work=self.task)
2194
+ self.g("commit", "-m", "scoped task", work=self.task)
2176
2195
  self.sha = self.g("rev-parse", "HEAD", work=self.task)
2177
2196
  evidence = self.task / ".devlyn"
2178
2197
  evidence.mkdir(exist_ok=True)
2179
2198
  (evidence / "checks.txt").write_text("actual fixture check: accepted bytes\n", encoding="utf-8")
2180
2199
  a = {"kind": "direct", "task": "fixture", "source_sha": self.sha,
2181
2200
  "checks": [{"command": "fixture byte assertion", "evidence": ".devlyn/checks.txt"}]}
2182
- self.assertEqual((self.task / "product").read_text(encoding="utf-8"), "accepted\n")
2201
+ self.assertEqual((self.task / "product").read_text(encoding="utf-8"), "baseline\n" if unchanged else "accepted\n")
2183
2202
  self.acceptance = evidence / "acceptance.json"
2184
2203
  self.acceptance.write_text(json.dumps(a), encoding="utf-8")
2185
2204
 
@@ -2189,6 +2208,202 @@ ctypes.CDLL(None).pthread_exit(None)
2189
2208
  command_args += ["--acceptance", self.acceptance]
2190
2209
  return self.cli(*command_args, *args, success=success, cwd=cwd)
2191
2210
 
2211
+ def test_unchanged_at_base_cleans_without_publication(self):
2212
+ # Prediction: equality with base settles without push/PR, preserving custody and removing the tree, ref and scratch output.
2213
+ self.allocate(); self.accept(unchanged=True)
2214
+ records = records_manifest(self.task)
2215
+ scratch = self.receipt.parent / "scratch"
2216
+ (scratch / "build").write_bytes(b"temporary output")
2217
+ result, _ = self.complete("--writers-stopped")
2218
+ self.assertEqual((result["status"], result["pr"]), ("COMPLETE", None))
2219
+ self.assertFalse(self.task.exists())
2220
+ self.assertEqual(self.g("branch", "--list", "task/fixture"), "")
2221
+ self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture"), "")
2222
+ self.assertEqual((result["scratch_cleanup"]["status"], list(scratch.iterdir())), ("CLEAN", []))
2223
+ self.assertEqual(records_manifest(self.receipt.parent / "records"), records)
2224
+ receipt = read_json(self.receipt)
2225
+ verify_files(self.receipt.parent / "custody", receipt["files"])
2226
+ self.assertEqual(self.g("rev-parse", receipt["recovery_ref"]), self.sha)
2227
+ d = read_json(self.data)
2228
+ self.assertEqual((d["pushs"], d.get("creates", 0), d.get("deletes", 0)), (0, 0, 0))
2229
+
2230
+ def test_unchanged_ancestor_cleans_in_pr_mode(self):
2231
+ # Prediction: only the freshly fetched base contains the accepted ancestor; PR mode cleans without publication.
2232
+ self.allocate(); self.accept()
2233
+ stale = self.g("rev-parse", "refs/remotes/origin/main")
2234
+ advanced = self.g("commit-tree", self.sha + "^{tree}", "-p", self.sha, "-m", "advance remote base")
2235
+ self.g("push", "origin", advanced + ":refs/heads/main")
2236
+ self.configure(pushs=0)
2237
+ self.g("update-ref", "refs/remotes/origin/main", stale)
2238
+ for ref in ("main", "refs/remotes/origin/main"):
2239
+ self.assertEqual(self.run_cmd(["git", "-C", str(self.work), "merge-base", "--is-ancestor",
2240
+ self.sha, ref], success=False).returncode, 1)
2241
+ result, _ = self.complete("--mode", "pr", "--writers-stopped")
2242
+ self.assertEqual((result["status"], result["pr"]), ("COMPLETE", None))
2243
+ self.assertEqual(self.g("rev-parse", "refs/remotes/origin/main"), advanced)
2244
+ self.assertEqual(self.g("rev-parse", "main"), stale)
2245
+ self.assertFalse(self.task.exists())
2246
+ self.assertEqual(self.g("branch", "--list", "task/fixture"), "")
2247
+ d = read_json(self.data)
2248
+ self.assertEqual((d["pushs"], d.get("creates", 0)), (0, 0))
2249
+
2250
+ def test_unchanged_old_pushed_receipt_resumes_with_exact_delete(self):
2251
+ # Prediction: a pushed receipt without a PR resumes with only the exact-lease deletion and settles COMPLETE.
2252
+ from unittest.mock import Mock, patch
2253
+ self.allocate(); self.accept(unchanged=True)
2254
+ self.cli("accept", "--receipt", self.receipt, "--acceptance", self.acceptance)
2255
+ self.g("push", "origin", self.sha + ":refs/heads/task/fixture")
2256
+ receipt = read_json(self.receipt)
2257
+ receipt["pushed"] = True
2258
+ atomic_json(self.receipt, receipt)
2259
+ args = argparse.Namespace(receipt=str(self.receipt), acceptance=None, mode=None, local_only=False, writers_stopped=True)
2260
+ run = Mock(wraps=gref)
2261
+ with patch.dict(os.environ, self.env), patch.dict(complete.__globals__, gref=run):
2262
+ result = complete(args)
2263
+ pushes = [call.args[1:] for call in run.call_args_list if call.args[1] == "push"]
2264
+ self.assertEqual(pushes, [("push", "--force-with-lease=refs/heads/task/fixture:" + self.sha, "origin", ":refs/heads/task/fixture")])
2265
+ self.assertEqual((result["status"], result["pr"]), ("COMPLETE", None))
2266
+ self.assertFalse(self.task.exists())
2267
+ self.assertEqual(self.g("branch", "--list", "task/fixture"), "")
2268
+ self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture"), "")
2269
+ d = read_json(self.data)
2270
+ self.assertEqual((d["pushs"], d.get("creates", 0), d["deletes"]), (1, 0, 1))
2271
+
2272
+ def test_unchanged_writer_retention_resumes_to_complete(self):
2273
+ # Prediction: either writer refusal settles delivery but retains the tree/ref; resume cleans without push/PR.
2274
+ from unittest.mock import Mock, patch
2275
+ self.allocate(); self.accept(unchanged=True)
2276
+ args = argparse.Namespace(receipt=str(self.receipt), acceptance=str(self.acceptance), mode=None, local_only=False, writers_stopped=True)
2277
+ for error in (WriterActive, WritersUnobservable):
2278
+ with self.subTest(error=error.__name__), patch.dict(os.environ, self.env), \
2279
+ patch.dict(complete.__globals__, stopped_writers=Mock(side_effect=error("fixture writer retention"))):
2280
+ result = complete(args)
2281
+ self.assertEqual((result["status"], result["delivery_status"], result["pr"]), ("CLEANUP_PENDING", "COMPLETE", None))
2282
+ self.assertEqual(result["workspace_cleanup"]["status"], "RETAINED")
2283
+ self.assertIn(str(self.receipt), result["workspace_cleanup"]["resume"])
2284
+ self.assertTrue(self.task.exists())
2285
+ self.assertNotEqual(self.g("branch", "--list", "task/fixture"), "")
2286
+ result, _ = self.complete(acceptance=False)
2287
+ self.assertEqual((result["status"], result["pr"], result["workspace_cleanup"]), ("COMPLETE", None, None))
2288
+ self.assertFalse(self.task.exists())
2289
+ self.assertEqual(self.g("branch", "--list", "task/fixture"), "")
2290
+ d = read_json(self.data)
2291
+ self.assertEqual((d["pushs"], d.get("creates", 0)), (0, 0))
2292
+
2293
+ def test_unchanged_writer_retention_reconciles_on_allocate(self):
2294
+ # Prediction: the next allocation retries on-base delivery once writer observation succeeds.
2295
+ from unittest.mock import Mock, patch
2296
+ self.allocate(); self.accept(unchanged=True)
2297
+ args = argparse.Namespace(receipt=str(self.receipt), acceptance=str(self.acceptance), mode=None, local_only=False, writers_stopped=True)
2298
+ with patch.dict(os.environ, self.env), \
2299
+ patch.dict(complete.__globals__, stopped_writers=Mock(side_effect=WriterActive("fixture writer retention"))):
2300
+ result = complete(args)
2301
+ self.assertEqual((result["status"], result["delivery_status"], result["pr"]), ("CLEANUP_PENDING", "COMPLETE", None))
2302
+ self.assertEqual(result["workspace_cleanup"]["status"], "RETAINED")
2303
+ receipt, task = self.receipt, self.task
2304
+ result = self.allocate("next")
2305
+ self.assertEqual([(row["receipt"], row["status"]) for row in result["reconciled"]], [(str(receipt), "COMPLETE")])
2306
+ self.assertFalse(task.exists())
2307
+ self.assertEqual(self.g("branch", "--list", "task/fixture"), "")
2308
+
2309
+ def test_unchanged_settled_resume_blocks_after_base_rewind(self):
2310
+ # Prediction: settled delivery never republishes after base rewind; cleanup blocks with the tree and refs kept.
2311
+ from unittest.mock import Mock, patch
2312
+ self.allocate(); self.accept()
2313
+ base = self.g("rev-parse", "main")
2314
+ self.g("push", "origin", self.sha + ":refs/heads/main")
2315
+ args = argparse.Namespace(receipt=str(self.receipt), acceptance=str(self.acceptance), mode=None, local_only=False, writers_stopped=True)
2316
+ with patch.dict(os.environ, self.env), \
2317
+ patch.dict(complete.__globals__, stopped_writers=Mock(side_effect=WriterActive("fixture writer retention"))):
2318
+ result = complete(args)
2319
+ self.assertEqual((result["status"], result["delivery_status"], result["pr"]), ("CLEANUP_PENDING", "COMPLETE", None))
2320
+ self.assertIsNone(read_json(self.receipt).get("status"))
2321
+ self.g("push", "origin", "+" + base + ":refs/heads/main")
2322
+ self.configure(pushs=0)
2323
+ for mode in ("auto", "pr"):
2324
+ with self.subTest(mode=mode):
2325
+ result, _ = self.complete("--mode", mode, acceptance=False, success=False)
2326
+ self.assertEqual(result["status"], "BLOCKED")
2327
+ self.assertIn("merge-base:", result["reason"])
2328
+ self.assertTrue(self.task.exists())
2329
+ receipt = read_json(self.receipt)
2330
+ self.assertEqual(receipt["delivery"], "COMPLETE")
2331
+ self.assertEqual(self.g("rev-parse", "task/fixture"), self.sha)
2332
+ self.assertEqual(self.g("rev-parse", receipt["recovery_ref"]), self.sha)
2333
+ self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture"), "")
2334
+ d = read_json(self.data)
2335
+ self.assertEqual((d["pushs"], d.get("creates", 0), d.get("merges", 0)), (0, 0, 0))
2336
+
2337
+ def test_unchanged_interrupted_cleanup_refuses_local_only(self):
2338
+ # Prediction: both cleanup checkpoints prevent local-only relabeling before delivery is persisted.
2339
+ from unittest.mock import Mock, patch
2340
+ self.allocate(); self.accept(unchanged=True)
2341
+ args = argparse.Namespace(receipt=str(self.receipt), acceptance=str(self.acceptance), mode=None, local_only=False, writers_stopped=True)
2342
+ for retained in (True, False):
2343
+ with self.subTest(retained=retained), patch.dict(os.environ, self.env), \
2344
+ patch.dict(complete.__globals__,
2345
+ stopped_writers=Mock(side_effect=WriterActive("fixture writer retention") if retained else None),
2346
+ completion_result=Mock(side_effect=RuntimeError("fixture interruption"))):
2347
+ with self.assertRaisesRegex(RuntimeError, "fixture interruption"):
2348
+ complete(args)
2349
+ receipt = read_json(self.receipt)
2350
+ self.assertIsNone(receipt.get("delivery"))
2351
+ self.assertFalse(receipt.get("pushed"))
2352
+ self.assertEqual("workspace_cleanup" in receipt, retained)
2353
+ self.assertEqual(receipt.get("status"), None if retained else "COMPLETE")
2354
+ result, _ = self.complete("--local-only", acceptance=False, success=False)
2355
+ self.assertEqual(result["status"], "BLOCKED")
2356
+ self.assertIn("already COMPLETE", result["reason"])
2357
+ self.assertFalse(read_json(self.receipt).get("local_only"))
2358
+ self.assertEqual(self.complete(acceptance=False)[0]["status"], "COMPLETE")
2359
+
2360
+ def test_merged_foreign_remote_head_records_merge_and_retains_ref(self):
2361
+ # Prediction: merged PRs record merge evidence and reach cleanup's remote-ref retention guard.
2362
+ self.allocate(); self.accept()
2363
+ self.complete("--mode", "pr")
2364
+ self.run_cmd(["gh", "pr", "merge", "1", "--merge", "--match-head-commit", self.sha,
2365
+ "--repo", "github.com/test/project"])
2366
+ self.g("commit", "--allow-empty", "-m", "foreign commit")
2367
+ foreign = self.g("rev-parse", "HEAD")
2368
+ self.g("push", "origin", "+" + foreign + ":refs/heads/task/fixture")
2369
+ result, _ = self.complete("--writers-stopped", success=False)
2370
+ self.assertEqual(result["status"], "BLOCKED")
2371
+ self.assertIn("remote branch changed after merge; retain ref", result["reason"])
2372
+ self.assertEqual(read_json(self.receipt)["merge"], read_json(self.data)["prs"][0])
2373
+ self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture").split()[0], foreign)
2374
+
2375
+ def test_unchanged_foreign_remote_head_retains_worktree(self):
2376
+ # Prediction: a foreign remote task head refuses completion before worktree removal.
2377
+ self.allocate(); self.accept(unchanged=True)
2378
+ self.g("commit", "--allow-empty", "-m", "foreign commit")
2379
+ self.g("push", "origin", "HEAD:refs/heads/task/fixture")
2380
+ result, _ = self.complete("--writers-stopped", success=False)
2381
+ self.assertEqual(result["status"], "BLOCKED")
2382
+ self.assertIn("remote task head changed", result["reason"])
2383
+ self.assertTrue(self.task.exists())
2384
+
2385
+ def test_unchanged_scratch_retention_reconciles_on_allocate(self):
2386
+ # Prediction: status COMPLETE without a PR still retries retained scratch on the next allocation.
2387
+ scratch = Path(self.allocate()["scratch"])
2388
+ self.accept(unchanged=True)
2389
+ (scratch / ".git").mkdir()
2390
+ result, _ = self.complete("--writers-stopped")
2391
+ self.assertEqual((result["status"], result["scratch_cleanup"]["status"]), ("CLEANUP_PENDING", "RETAINED"))
2392
+ receipt = self.receipt
2393
+ self.assertEqual(read_json(receipt)["status"], "COMPLETE")
2394
+ (scratch / ".git").rmdir()
2395
+ result = self.allocate("next")
2396
+ self.assertEqual([(row["receipt"], row["status"]) for row in result["reconciled"]], [(str(receipt), "COMPLETE")])
2397
+ self.assertFalse(any(scratch.iterdir()))
2398
+
2399
+ def test_unchanged_complete_refuses_local_only(self):
2400
+ # Prediction: a delivered on-base receipt cannot be rewritten as local-only.
2401
+ self.allocate(); self.accept(unchanged=True)
2402
+ self.assertEqual(self.complete("--writers-stopped")[0]["status"], "COMPLETE")
2403
+ result, _ = self.complete("--local-only", acceptance=False, success=False)
2404
+ self.assertIn("already COMPLETE", result["reason"])
2405
+ self.assertEqual(read_json(self.receipt)["delivery"], "COMPLETE")
2406
+
2192
2407
  def test_reject_repository_rewrite_before_allocation(self):
2193
2408
  intended = "https://github.com/test/project.git"
2194
2409
  destination = "https://github.com/test/other.git"
@@ -200,7 +200,10 @@ python3 "$DEVLYN_SHARED_DIR/task-complete.py" complete \
200
200
  Project policy is `git config --local devlyn.completionMode auto|pr`; absent means
201
201
  `auto`. Invalid local values fail before external effects, even with an override.
202
202
  Use `complete --mode auto|pr` for one task, or `--local-only`/`--no-push` to honor
203
- the user's delivery restriction. These per-task choices persist in the receipt; `--local-only` is refused once the task ref is pushed.
203
+ the user's delivery restriction. These per-task choices persist in the receipt; `--local-only` is refused once the task ref is pushed or delivery is `COMPLETE`.
204
+
205
+ With no matching PR and the accepted commit already on the fetched base, either
206
+ publication mode pushes no task commit and opens no PR. Delivery is `COMPLETE` with no `pr`; cleanup and retention work as for a merged delivery.
204
207
 
205
208
  `pr` pushes the exact accepted task ref and creates/reuses its exact repository,
206
209
  head and base PR. While OPEN, it cancels owned auto-merge, reports `PR` with its URL
@@ -230,7 +233,7 @@ must never be reported as accepted.
230
233
 
231
234
  ## Yield and retain recoverability
232
235
 
233
- Cleanup requires actual matching MERGED evidence; linked-tree removal also
236
+ Cleanup requires actual matching MERGED evidence or, with no PR, the accepted commit on the fetched base; linked-tree removal also
234
237
  requires the owner's recorded release. OS observation supplements it; neither the receipt
235
238
  nor a scan guarantees exclusion of future writers. Processes with cwd or open
236
239
  files inside the tree, inaccessible process state (on Linux, other users'
@@ -240,11 +243,11 @@ affected resources. Use `git worktree lock` to keep a tree.
240
243
  A VM that shared the tree (Docker Desktop after a bind mount) can keep files open
241
244
  until it restarts; the helper names it when identifiable and retries cleanup on
242
245
  the next allocation or resume after the restart.
243
- On native Windows, locking and file durability are supported, but the helper cannot prove writer cessation and reports `writer observation unsupported on this platform; retain workspace`. Even `--writers-stopped` cannot authorize deletion without that observation: retain the workspace, its task refs and external receipt/custody, report delivery separately, and preserve the receipt-based resume command. A merged delivery therefore reports `CLEANUP_PENDING` (delivery `COMPLETE`) with `workspace_cleanup` naming the reason and resume. Do not kill unknown processes or force worktree removal to make completion pass.
246
+ On native Windows, locking and file durability are supported, but the helper cannot prove writer cessation and reports `writer observation unsupported on this platform; retain workspace`. Even `--writers-stopped` cannot authorize deletion without that observation: retain the workspace, its task refs and external receipt/custody, report delivery separately, and preserve the receipt-based resume command. A merged or on-base delivery therefore reports `CLEANUP_PENDING` (delivery `COMPLETE`) with `workspace_cleanup` naming the reason and resume. Do not kill unknown processes or force worktree removal to make completion pass.
244
247
 
245
248
  The receipt directory holds byte-verified `custody/`, `manifest.json` and a guarded
246
249
  `refs/devlyn/completed/<id>` recovery ref. Check evidence is copied there
247
- before publication; removal rechecks it, the recovery ref and the merge commit on base.
250
+ before publication; removal rechecks it, the recovery ref and the merge or accepted commit on base.
248
251
  Ignored build output and other ignored files are disposable under native
249
252
  non-force worktree removal. The tree's `.devlyn/` files are byte/mode-verified
250
253
  and preserved as `<receipt dir>/records/.devlyn`; changes after an earlier copy
@@ -0,0 +1,206 @@
1
+ #!/usr/bin/env python3
2
+ """Launch-only tool restrictions for delegated Codex and drain Claude children."""
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import json
7
+ import os
8
+ from pathlib import Path
9
+ import re
10
+ import runpy
11
+ import shutil
12
+ import subprocess
13
+ import tempfile
14
+
15
+
16
+ PLATFORM = runpy.run_path(str(Path(__file__).with_name("platform-support.py")))
17
+ LOADS = runpy.run_path(str(Path(__file__).with_name("expected-contract.py")))["loads_strict_json"]
18
+ CODEX_FEATURES = ("apps", "plugins", "remote_plugin", "computer_use", "browser_use",
19
+ "browser_use_external", "in_app_browser")
20
+
21
+
22
+ class PolicyError(ValueError):
23
+ pass
24
+
25
+
26
+ def deny_names(engine):
27
+ path = Path.home() / ".devlyn/unattended-tools.json"
28
+ try:
29
+ raw = path.read_text(encoding="utf-8")
30
+ except FileNotFoundError:
31
+ return []
32
+ except (OSError, UnicodeError) as exc:
33
+ raise PolicyError(f"unattended tools: cannot read {path}: {exc}") from exc
34
+ try:
35
+ value = LOADS(raw)
36
+ if not isinstance(value, dict) or set(value) - {"codex", "claude"}:
37
+ raise ValueError("expected an object with codex/claude server-name arrays")
38
+ for names in value.values():
39
+ if not isinstance(names, list) or any(not isinstance(n, str) or not n.strip() for n in names):
40
+ raise ValueError("codex/claude must be arrays of nonempty server names")
41
+ return value.get(engine, [])
42
+ except ValueError as exc:
43
+ raise PolicyError(f"unattended tools: invalid {path}: {exc}") from exc
44
+
45
+
46
+ def probe(command, cwd, label, env=None):
47
+ try:
48
+ result = subprocess.run(command, cwd=cwd, env=env, stdin=subprocess.DEVNULL,
49
+ capture_output=True, text=True, encoding="utf-8", timeout=30)
50
+ except (OSError, UnicodeError, subprocess.TimeoutExpired) as exc:
51
+ raise PolicyError(f"unattended tools: {label} failed: {exc}") from exc
52
+ if result.returncode:
53
+ raise PolicyError(f"unattended tools: {label} exited {result.returncode}: {result.stderr.strip()}")
54
+ return result.stdout
55
+
56
+
57
+ def listed(command, cwd, label, key, env=None):
58
+ raw = probe(command, cwd, label, env)
59
+ try:
60
+ value = LOADS(raw)
61
+ if not isinstance(value, list) or any(
62
+ not isinstance(item, dict) or not isinstance(item.get(key), str) or not item[key].strip()
63
+ or not isinstance(item.get("enabled"), bool) for item in value
64
+ ):
65
+ raise ValueError(f"expected an array of objects with {key} and boolean enabled")
66
+ return value
67
+ except ValueError as exc:
68
+ raise PolicyError(f"unattended tools: malformed {label} output: {exc}") from exc
69
+
70
+
71
+ def codex_launch(binary, argv):
72
+ denied = {"node_repl", "computer-use", *deny_names("codex")}
73
+ resolved = shutil.which(binary)
74
+ if resolved is None:
75
+ raise PolicyError(f"unattended tools: Codex executable unavailable: {binary}")
76
+ native = PLATFORM["native_argv"]([str(Path(resolved).absolute())])
77
+ cwd, config, profile = Path.cwd(), [], []
78
+ # Only configuration inputs belong in discovery; prompt/model/output arguments do not.
79
+ values = {"-c", "--config", "-p", "--profile", "--enable", "--disable", "-C", "--cd",
80
+ "-m", "--model", "-s", "--sandbox", "-o", "--output-last-message", "--output-schema",
81
+ "--color", "--add-dir", "-i", "--image", "--local-provider", "--thread-source",
82
+ "--cyber-access-program"}
83
+ index = 0
84
+ while index < len(argv) and argv[index] != "--":
85
+ token = argv[index]
86
+ if token[:2] in {"-c", "-C", "-p"} and len(token) > 2:
87
+ key, equal, value = token[:2], "=", token[2:]
88
+ if value.startswith("="):
89
+ value = value[1:]
90
+ else:
91
+ key, equal, value = token.partition("=")
92
+ if key in values:
93
+ if not equal:
94
+ index += 1
95
+ if index == len(argv):
96
+ raise PolicyError(f"unattended tools: {key} requires a value")
97
+ value = argv[index]
98
+ if key in {"-C", "--cd"}:
99
+ cwd = Path(value).absolute()
100
+ elif key in {"-p", "--profile"}:
101
+ profile.extend([key, value])
102
+ elif key in {"-c", "--config", "--enable", "--disable"}:
103
+ config.extend([key, value])
104
+ elif token in {"--ignore-user-config", "--strict-config"}:
105
+ config.append(token)
106
+ index += 1
107
+ shutdown = [part for feature in CODEX_FEATURES for part in ("--disable", feature)]
108
+ try:
109
+ # Codex 0.164 accepts profiles on mcp, but not on features list.
110
+ raw = probe([*native, "features", "list", *shutdown, *config], cwd, "Codex feature preflight")
111
+ states = {}
112
+ for row in raw.splitlines():
113
+ fields = row.split()
114
+ if fields and fields[0] in CODEX_FEATURES:
115
+ if len(fields) < 3 or fields[-1] not in {"true", "false"} or fields[0] in states:
116
+ raise PolicyError(f"unattended tools: malformed Codex feature row: {row}")
117
+ states[fields[0]] = fields[-1]
118
+ unsafe = [name for name in CODEX_FEATURES if states.get(name) != "false"]
119
+ if unsafe:
120
+ raise PolicyError(f"unattended tools: Codex features not disabled: {', '.join(unsafe)}")
121
+ except PolicyError as exc:
122
+ version = probe([*native, "--version"], cwd, "Codex version").strip()
123
+ raise PolicyError(f"{exc} (Codex version: {version})") from exc
124
+ command = [*native, *profile, "mcp", "list", "--json"]
125
+ servers = listed([*command, *shutdown, *config], cwd, "Codex mcp list --json", "name")
126
+ targeted = {s["name"] for s in servers} & denied
127
+ overrides = []
128
+ for name in sorted(targeted):
129
+ if not re.fullmatch(r"[A-Za-z0-9_-]+", name):
130
+ raise PolicyError(f"unattended tools: cannot safely override Codex server: {name!r}")
131
+ overrides.extend(["-c", f"mcp_servers.{name}.enabled=false"])
132
+ # Match launch order: caller overrides come after the policy. Refuse a conflict.
133
+ verification = listed([*command, *shutdown, *overrides, *config], cwd,
134
+ "Codex mcp list --json verification", "name")
135
+ disabled = {s["name"] for s in verification if not s["enabled"]}
136
+ unsafe = (targeted - disabled) | {s["name"] for s in verification if s["enabled"] and s["name"] in denied}
137
+ if unsafe:
138
+ raise PolicyError(f"unattended tools: Codex MCP overrides did not disable: {', '.join(sorted(unsafe))}")
139
+ return [*shutdown, *overrides, *argv]
140
+
141
+
142
+ def claude_namespace(name):
143
+ # Claude Code's MCP tool-name normalization preserves ASCII letters, digits, '_' and '-'.
144
+ normalized = re.sub(r"[^a-zA-Z0-9_-]", "_", name)
145
+ if name.startswith("claude.ai "):
146
+ normalized = re.sub(r"_+", "_", normalized).strip("_")
147
+ return normalized
148
+
149
+
150
+ @contextlib.contextmanager
151
+ def claude_launch(argv, cwd):
152
+ denied = deny_names("claude")
153
+ native = PLATFORM["native_argv"]([argv[0]], cwd=cwd)
154
+ # Refuse configuration inputs we cannot verify. Do not consume or rewrite prompt values.
155
+ for index, token in enumerate(argv[1:], 1):
156
+ key, equal, value = token.partition("=")
157
+ if key in {"--settings", "--setting-sources", "--plugin-dir", "--plugin-url", "--chrome"}:
158
+ raise PolicyError(f"unattended tools: Claude {key} is not supported for drain executors")
159
+ if key != "--add-dir":
160
+ continue
161
+ directories = [value] if equal else []
162
+ for directory in argv[index + 1:]:
163
+ if directory.startswith("-"):
164
+ break
165
+ directories.append(directory)
166
+ if not directories:
167
+ raise PolicyError("unattended tools: Claude --add-dir requires a directory")
168
+ for directory in directories:
169
+ for filename in ("settings.json", "settings.local.json"):
170
+ path = Path(cwd) / Path(directory).expanduser() / ".claude" / filename
171
+ try:
172
+ raw = path.read_text(encoding="utf-8")
173
+ except FileNotFoundError:
174
+ continue
175
+ except (OSError, UnicodeError) as exc:
176
+ raise PolicyError(f"unattended tools: cannot read Claude --add-dir settings {path}: {exc}") from exc
177
+ try:
178
+ settings = LOADS(raw)
179
+ if not isinstance(settings, dict):
180
+ raise ValueError("expected a settings object")
181
+ if "enabledPlugins" in settings:
182
+ raise ValueError("enabledPlugins cannot be verified for --add-dir")
183
+ except ValueError as exc:
184
+ raise PolicyError(f"unattended tools: Claude --add-dir settings {path}: {exc}") from exc
185
+ env = {**os.environ, "ENABLE_CLAUDEAI_MCP_SERVERS": "false"}
186
+ plugins = listed([*native, "--no-chrome", "plugin", "list", "--json"], cwd, "Claude plugin list --json", "id", env)
187
+ targeted = {p["id"] for p in plugins if p["enabled"]}
188
+ # Claude 2.1.314 recognizes disableClaudeAiConnectors; also override settings.env.
189
+ settings = {"syncClaudeAiPlugins": False, "disableClaudeAiConnectors": True,
190
+ "env": {"ENABLE_CLAUDEAI_MCP_SERVERS": "false"},
191
+ "enabledPlugins": dict.fromkeys(sorted(targeted), False)}
192
+ with tempfile.TemporaryDirectory(prefix="devlyn-unattended-") as temporary:
193
+ path = Path(temporary) / "settings.json"
194
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
195
+ with os.fdopen(fd, "w", encoding="utf-8") as stream:
196
+ json.dump(settings, stream, ensure_ascii=False)
197
+ options = ["--settings", str(path)]
198
+ if denied:
199
+ options.append("--disallowedTools=" + ",".join("mcp__" + claude_namespace(n) for n in denied))
200
+ options.append("--no-chrome")
201
+ verification = listed([*native, *options, "plugin", "list", "--json"], cwd,
202
+ "Claude plugin list --json verification", "id", env)
203
+ enabled = sorted({p["id"] for p in verification if p["enabled"]})
204
+ if enabled:
205
+ raise PolicyError(f"unattended tools: Claude plugin overrides did not disable: {', '.join(enabled)}; check managed settings")
206
+ yield [argv[0], *options, *argv[1:]], env
@@ -110,7 +110,7 @@ Follow [loop.md](references/loop.md). The executor is the configured route, the
110
110
 
111
111
  | Executor | argv |
112
112
  |---|---|
113
- | Claude | `claude -p "<prompt>" --dangerously-skip-permissions --add-dir "<git dir>"` |
113
+ | Claude | `claude -p "<prompt>" --dangerously-skip-permissions --add-dir "<git dir>"` (drain prepends `--settings <temporary-policy.json> --no-chrome` and any `--disallowedTools=<list>` denies after `claude`) |
114
114
  | Codex | `bash "<DEVLYN_SHARED_DIR>/codex-monitored.sh" --skip-git-repo-check -s workspace-write -c 'sandbox_workspace_write.writable_roots=["<git dir>","{worktree_git_dir}"]' -c 'sandbox_workspace_write.network_access=true' "<prompt>"` |
115
115
 
116
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: