devlyn-cli 4.3.21 → 4.3.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.
Files changed (51) hide show
  1. package/AGENTS.md +1 -1
  2. package/CLAUDE.md +1 -1
  3. package/README.md +1 -1
  4. package/bin/devlyn.js +26 -1
  5. package/bin/harness-activation.js +11 -13
  6. package/bin/instruction-templates.json +26 -0
  7. package/bin/instructions.js +1 -1
  8. package/bin/skill-history.json +15 -1
  9. package/config/skills/_shared/bring-in.py +810 -0
  10. package/config/skills/_shared/harness-store.py +18 -6
  11. package/config/skills/_shared/runtime-principles.md +2 -2
  12. package/config/skills/_shared/storage-checkout.py +117 -0
  13. package/config/skills/_shared/storage-client.py +1180 -0
  14. package/config/skills/_shared/task-complete.py +900 -88
  15. package/config/skills/_shared/task-completion.md +78 -6
  16. package/config/skills/_shared/warm-build-dirs.py +53 -3
  17. package/config/skills/devlyn-ideate/SKILL.md +2 -2
  18. package/config/skills/devlyn-ideate/references/loop.md +4 -4
  19. package/config/skills/devlyn-ideate/scripts/queue.py +70 -19
  20. package/config/storage-client-contract.json +7 -0
  21. package/harness/AGENTS.md +2 -2
  22. package/harness/CLAUDE.md +2 -2
  23. package/native/claude/.claude-plugin/plugin.json +1 -1
  24. package/native/claude/skills/_shared/bring-in.py +810 -0
  25. package/native/claude/skills/_shared/harness-store.py +18 -6
  26. package/native/claude/skills/_shared/runtime-principles.md +2 -2
  27. package/native/claude/skills/_shared/storage-checkout.py +117 -0
  28. package/native/claude/skills/_shared/storage-client.py +1180 -0
  29. package/native/claude/skills/_shared/task-complete.py +900 -88
  30. package/native/claude/skills/_shared/task-completion.md +78 -6
  31. package/native/claude/skills/_shared/warm-build-dirs.py +53 -3
  32. package/native/claude/skills/devlyn-ideate/SKILL.md +2 -2
  33. package/native/claude/skills/devlyn-ideate/references/loop.md +4 -4
  34. package/native/claude/skills/devlyn-ideate/scripts/queue.py +70 -19
  35. package/native/codex/.agents/plugins/marketplace.json +1 -1
  36. package/native/codex/plugins/devlyn/.codex-plugin/plugin.json +1 -1
  37. package/native/codex/plugins/devlyn/skills/_shared/bring-in.py +810 -0
  38. package/native/codex/plugins/devlyn/skills/_shared/harness-store.py +18 -6
  39. package/native/codex/plugins/devlyn/skills/_shared/runtime-principles.md +2 -2
  40. package/native/codex/plugins/devlyn/skills/_shared/storage-checkout.py +117 -0
  41. package/native/codex/plugins/devlyn/skills/_shared/storage-client.py +1180 -0
  42. package/native/codex/plugins/devlyn/skills/_shared/task-complete.py +900 -88
  43. package/native/codex/plugins/devlyn/skills/_shared/task-completion.md +78 -6
  44. package/native/codex/plugins/devlyn/skills/_shared/warm-build-dirs.py +53 -3
  45. package/native/codex/plugins/devlyn/skills/devlyn-ideate/SKILL.md +2 -2
  46. package/native/codex/plugins/devlyn/skills/devlyn-ideate/references/loop.md +4 -4
  47. package/native/codex/plugins/devlyn/skills/devlyn-ideate/scripts/queue.py +70 -19
  48. package/optional-skills/better-auth-setup/SKILL.md +9 -3
  49. package/optional-skills/better-auth-setup/references/proxy-setup.md +10 -3
  50. package/package.json +1 -1
  51. package/release.json +93 -43
@@ -80,22 +80,88 @@ After remote delivery, fetch first. If the branch can fast-forward to the
80
80
  delivered result, recheck the copied edits' current ownership and contents,
81
81
  then remove them: `git restore --source=HEAD --staged --worktree -- <paths>`
82
82
  for tracked paths holding only your edits, and delete copied untracked files.
83
- Then use `git merge --ff-only <task branch>` for local delivery, or
84
- `git merge --ff-only <remote>/<base>` on the base branch after remote delivery;
83
+ Then run `python3 "$DEVLYN_SHARED_DIR/bring-in.py" --repo . --branch <checked-out branch> --target <task branch> --ff-only`
84
+ for local delivery, or the same command with `--target <remote>/<base>` on the base branch after remote delivery;
85
85
  a drain's `Bring into` command is its own. Once a local delivery is reconciled,
86
86
  remove its worktree and task branch (`git worktree remove <worktree>`,
87
87
  `git branch -d <task branch>`). Do not switch branches, create a reconciliation
88
- merge commit or discard WIP to force this step. If reconciliation is unsafe,
88
+ merge commit, or discard WIP or ignored files to force this step. If reconciliation is unsafe,
89
89
  retain and report the edits, delivered result and remaining bring-in action.
90
90
 
91
91
  Save the returned receipt path under the common Gitdir. `reconciled` reports earlier accepted,
92
92
  PR-delivered tasks whose merged resources were cleaned or retained; it is
93
93
  informational, so never resume or release a receipt you do not own. Existing
94
94
  branches/trees cannot be adopted, even when their names look generated. Nor
95
- can a receipt that never reached `allocation: owned`: remove its worktree and
96
- branch if present, delete its receipt directory, then allocate again.
95
+ can an interrupted standalone receipt that never reached `allocation: owned`:
96
+ remove its worktree and branch if present, delete its receipt directory, then allocate again.
97
97
  Unreceipted tasks remain owner-managed.
98
98
 
99
+ New allocation and warming modes come from enrollment state alone:
100
+
101
+ | `~/.devlyn/storage-client.json` | recovery marker | unenroll journal | Mode |
102
+ |---|---|---|---|
103
+ | absent | absent | not read | standalone (never enrolled) |
104
+ | valid | any | not read | managed; a fenced authority refuses |
105
+ | invalid or unreadable | any | not read | refuse; repair registration |
106
+ | absent | present | verified `retired: true` | standalone (unenrollment complete) |
107
+ | absent | present | absent, unreadable or incomplete | refuse; Open Devlyn to repair its storage registration |
108
+
109
+ The journal `storage-client-unenroll.json` is read no-follow only on the last two
110
+ rows. Schema 1, canonical UUIDv7 operation, permanent `storage-client-fence.json`,
111
+ matching operation/root/lock identities and Rust's archive ordering/path
112
+ validation are required. If native root/lock identities or the home path changed,
113
+ completed retirement permits standalone only when `storage`, `control`,
114
+ `harness/state.json` and `storage-client.json` are all absent under the current
115
+ lifecycle root (checked without following links). Live inventory requires repair.
116
+ No repository-wide receipt scan selects mode. Other keys can allocate after
117
+ completed unenrollment; a leftover managed key retains its own recovery command.
118
+
119
+ Existing operations keep their managed identity. Receipts pin the control
120
+ identity; current enrollment verifies the helper before allocation. Recovery uses
121
+ the current verified `storage-client-recovery/pins.json`, including after uninstall
122
+ or unenrollment, and requires its authority binding to match the receipt. Missing
123
+ or invalid pins retain resources and require repair; saved artifacts are never a
124
+ fallback. Enrolled checkout preserves
125
+ `managed_pending`/`managed_owned` and Rust's `storage-allocation.json` subreceipt.
126
+ After an interrupted reply, use the returned `recover-storage` resume. Never
127
+ delete managed receipts or replace an unfinished operation ID.
128
+
129
+ Owner `recover-storage --receipt <receipt.json> --operation-id <saved ID>
130
+ --for-removal` fences allocation and reconciles closure and the exact cache set.
131
+ Never-published allocations are abandoned. Accepted LOCAL_ONLY, FAILED, and PRs
132
+ observed CLOSED without merge share custody/recovery-ref verification and fenced
133
+ removal, retaining task branches and a durable terminal delivery. Release writers
134
+ with `complete --writers-stopped` first. A queue-produced FAILED checkout is
135
+ advanced by `read-tree -m -u <source_sha> <publish_sha>` only when HEAD is at the
136
+ attached publish commit and the index and worktree are exactly at source; other
137
+ differences refuse as dirty.
138
+
139
+ Removal-finished receipts with final COMPLETE, LOCAL_ONLY, FAILED or CLOSED
140
+ delivery move under `devlyn-completion/retired/`, regardless of scratch status.
141
+ Retirement moves scratch and its saved cleanup record with the audit and deletes
142
+ nothing. `complete --writers-stopped` saves the writer release there and retries
143
+ pending scratch, including an interrupted cleanup with no saved result; the next
144
+ allocation retries completed deliveries with released writers. `clean-scratch`
145
+ remains available there and never downgrades CLEAN.
146
+ Saved managed `complete`, `recover-storage` and `clean-scratch` resumes carry an
147
+ operation ID validated under the receipt lock. Without an ID, a locator selects
148
+ its active receipt. An ID selects only that operation, including its verified
149
+ retired receipt; a mismatched ID refuses. Retired paths also remain usable.
150
+ A new allocation may reuse a retired key without disturbing its audit.
151
+
152
+ Retained submodule repositories in the admin directory block removal even when
153
+ the checkout is absent; preserve/publish their source and explicitly resolve
154
+ them. Manual checkout removal is advised only after portable source checks and
155
+ record preservation succeed; changed or new source requires retention. If native
156
+ Windows or unobservable writers then require retention, stop all
157
+ writers, remove the task checkout by hand, keep its branch and receipt, then run
158
+ the returned owner-removal resume. The absent-tree path completes the fenced
159
+ registration removal. Scratch cleanup requires positive allocation closure,
160
+ without a cache-removal fence; actual checkout removal owns that fence, including
161
+ empty cache sets. Missing helpers or uncertain closure retain resources with a
162
+ repair/resume action. Compatible recovery artifacts and Rust's LaunchGate remain
163
+ required while managed obligations exist.
164
+
99
165
  Copy attributable task edits into the allocated worktree as a patch, never a
100
166
  stash. Establish attribution from the pre-edit contents and starting Git state,
101
167
  including staged, unstaged and untracked work. A path-scoped
@@ -113,6 +179,10 @@ require the affected checks again.
113
179
  Keep the original edits through pending or failed delivery; only reconciliation
114
180
  removes them. Preserve all other work and report anything left behind.
115
181
 
182
+ Managed allocations currently start cold; `build_dirs` retains each returned cache
183
+ and publication lease ID. Managed warming is deferred. Standalone allocations keep
184
+ the warming behavior below.
185
+
116
186
  Build in the task worktree's own default build directory, never in one
117
187
  another checkout builds in, even one build at a time: tools such as Cargo take
118
188
  one checkout's outputs as fresh for the other. When the shared repository-local
@@ -142,6 +212,7 @@ release build caches when parking or after a failed run, use:
142
212
  ```sh
143
213
  python3 "$DEVLYN_SHARED_DIR/task-complete.py" clean-scratch \
144
214
  --receipt '<returned receipt.json>' --writers-stopped
215
+ # For managed receipts, use the saved resume with --operation-id <saved ID>.
145
216
  ```
146
217
 
147
218
  This empties only the prospectively owned scratch, preserves its directory
@@ -246,7 +317,8 @@ the next allocation or resume after the restart.
246
317
  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.
247
318
 
248
319
  The receipt directory holds byte-verified `custody/`, `manifest.json` and a guarded
249
- `refs/devlyn/completed/<id>` recovery ref. Check evidence is copied there
320
+ `refs/devlyn/completed/<id>` recovery ref (new managed allocations use
321
+ `<id>.<operation>` so branch reuse preserves earlier recovery refs). Check evidence is copied there
250
322
  before publication; removal rechecks it, the recovery ref and the merge or accepted commit on base.
251
323
  Ignored build output and other ignored files are disposable under native
252
324
  non-force worktree removal. The tree's `.devlyn/` files are byte/mode-verified
@@ -526,8 +526,15 @@ def warm_cargo(work, anchor, staging_root, deadline):
526
526
  with contextlib.ExitStack() as stack:
527
527
  staging = None
528
528
  try:
529
- if shutil.which("cargo") is None:
530
- raise Skip("cargo is not on PATH")
529
+ # Decide managed cold start before Cargo, destination setup, donors
530
+ # or any seeding work. Standalone keeps its original path.
531
+ import runpy
532
+ storage = runpy.run_path(str(Path(__file__).with_name('storage-client.py')))
533
+ decision = storage['operation_enrollment']()
534
+ if decision['mode'] == 'managed':
535
+ return entry('cargo', 'cold', reason='managed warming deferred; cold start')
536
+ if shutil.which('cargo') is None:
537
+ raise Skip('cargo is not on PATH')
531
538
  target, relative, parent = destination(work, deadline, stack)
532
539
  candidates = donors(work, anchor, relative, deadline)
533
540
  if not candidates:
@@ -678,6 +685,11 @@ class WarmTests(unittest.TestCase):
678
685
  temporary = tempfile.TemporaryDirectory(prefix="warm-build-dirs-")
679
686
  self.addCleanup(temporary.cleanup)
680
687
  self.root = Path(temporary.name).resolve()
688
+ test_home = self.root / "home"
689
+ test_home.mkdir()
690
+ home_env = mock.patch.dict(os.environ, {"HOME": str(test_home), "USERPROFILE": str(test_home)})
691
+ home_env.start()
692
+ self.addCleanup(home_env.stop)
681
693
  (self.root / "sitecustomize.py").write_text(
682
694
  "import importlib.machinery, importlib.util, pathlib, sys\n"
683
695
  "spec = importlib.machinery.PathFinder.find_spec('sitecustomize', [p for p in sys.path if pathlib.Path(p or '.').resolve() != pathlib.Path(__file__).resolve().parent])\n"
@@ -730,7 +742,7 @@ class WarmTests(unittest.TestCase):
730
742
  def copy(source, target, deadline):
731
743
  target.parent.mkdir(parents=True, exist_ok=True)
732
744
  subprocess.run(["cp", "-a", "--", str(source), str(target)], check=True)
733
- patch = mock.patch(f"{__name__}.clone", copy)
745
+ patch = mock.patch.dict(warm_cargo.__globals__, clone=copy)
734
746
  patch.start()
735
747
  self.addCleanup(patch.stop)
736
748
 
@@ -832,6 +844,44 @@ class WarmTests(unittest.TestCase):
832
844
  self.assertEqual([], list(self.staging.iterdir()))
833
845
  self.assertEqual("", self.git(self.work, "status", "--porcelain", "--untracked-files=all"))
834
846
 
847
+ @unittest.skipIf(os.name == 'nt', 'Windows keeps its existing skip')
848
+ def test_managed_skip_precedes_donor_traversal_and_reports_cold_start(self):
849
+ from unittest import mock
850
+ import runpy
851
+ real = runpy.run_path
852
+ def modules(path, *args, **kwargs):
853
+ if Path(path).name == 'storage-client.py':
854
+ return {'operation_enrollment': lambda: {'mode': 'managed'}}
855
+ return real(path, *args, **kwargs)
856
+ with mock.patch.object(runpy, 'run_path', modules), \
857
+ mock.patch.dict(warm_cargo.__globals__, donors=mock.Mock(side_effect=AssertionError('donor traversal')),
858
+ destination=mock.Mock(side_effect=AssertionError('destination setup')),
859
+ clone=mock.Mock(side_effect=AssertionError('seed')), publish=mock.Mock(side_effect=AssertionError('publish'))):
860
+ result = self.warm()[0]
861
+ self.assertEqual(result['status'], 'cold')
862
+ self.assertIn('cold start', result['reason'])
863
+ self.assertFalse((self.work / 'target').exists())
864
+ self.assert_nothing_left()
865
+
866
+ @unittest.skipIf(os.name == 'nt', 'warming is POSIX-only')
867
+ def test_standalone_warm_never_registers_destination(self):
868
+ from unittest import mock
869
+ import runpy
870
+ real = runpy.run_path
871
+ def modules(path, *args, **kwargs):
872
+ module = real(path, *args, **kwargs)
873
+ if Path(path).name == 'storage-client.py':
874
+ module['Client'] = mock.Mock(side_effect=AssertionError('standalone authority call'))
875
+ module['operation_id'] = mock.Mock(side_effect=AssertionError('standalone operation'))
876
+ return module
877
+ common = Path(self.git(self.work, 'rev-parse', '--path-format=absolute', '--git-common-dir').strip())
878
+ before = sorted(p.name for p in common.iterdir())
879
+ with mock.patch.object(runpy, 'run_path', modules):
880
+ result = self.warm()[0]
881
+ self.assertEqual(result['status'], 'seeded', result)
882
+ self.assertEqual(sorted(p.name for p in common.iterdir()), before)
883
+ self.assertFalse((Path(os.environ['HOME']) / '.devlyn').exists())
884
+
835
885
  @unittest.skipIf(os.name == "nt", "warming is POSIX-only")
836
886
  def test_local_git_true_seeds_without_cargo_opt_in(self):
837
887
  self.write_fixture(workspace_metadata=None)
@@ -116,14 +116,14 @@ Follow [loop.md](references/loop.md). The executor is the configured route, the
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:
117
117
 
118
118
  ```text
119
- Execute the devlyn loop task packet {packet}: read its workerPrompt when present; read methodology.defaults.path when named and apply those verified defaults, with explicit user and native owner/project instructions taking precedence; work only in its owned worktree under the installed instructions; meet its obligations, then write its submission and any review records as "Executor exchange" and "Review records" in <DEVLYN_SKILL_DIR>/references/loop.md specify. Use the literal DEVLYN_SKILL_DIR and DEVLYN_SHARED_DIR bindings in workerPrompt when present; otherwise set DEVLYN_SKILL_DIR=<DEVLYN_SKILL_DIR> and DEVLYN_SHARED_DIR=<DEVLYN_SHARED_DIR> from these literal values. Never use inherited environment values. Never push, open a pull request or merge: the drain delivers.
119
+ Execute the devlyn loop task packet {packet}: read its workerPrompt when present; read methodology.defaults.path when named and apply those verified defaults, with explicit user and native owner/project instructions taking precedence, except a Devlyn managed block (devlyn:instructions markers), which those defaults replace; work only in its owned worktree under the installed instructions; meet its obligations, then write its submission and any review records as "Executor exchange" and "Review records" in <DEVLYN_SKILL_DIR>/references/loop.md specify. Use the literal DEVLYN_SKILL_DIR and DEVLYN_SHARED_DIR bindings in workerPrompt when present; otherwise set DEVLYN_SKILL_DIR=<DEVLYN_SKILL_DIR> and DEVLYN_SHARED_DIR=<DEVLYN_SHARED_DIR> from these literal values. Never use inherited environment values. Never push, open a pull request or merge: the drain delivers.
120
120
  ```
121
121
 
122
122
  ```sh
123
123
  python3 "$DEVLYN_SKILL_DIR/scripts/queue.py" drain --repo . [--local-only] -- <executor argv>
124
124
  ```
125
125
 
126
- A drain can run for hours. Do not end the turn until it exits: run it in the foreground with the longest allowed timeout, or in the background and await its completion notice, because a headless host kills background tasks at its final response.
126
+ A drain can run for hours. Do not end the turn until it exits: run it in the foreground with the longest allowed timeout, or in the background and await its completion notice, because reporting the drain's result requires observing its exit.
127
127
 
128
128
  - `WAITING` on legacy rows: plan and materialize each under the autonomous policy, then drain again; a row whose planning stops on material ambiguity stays pending and its question is reported.
129
129
  - Other `WAITING`: report each task’s reason and recovery command. Task-local failures leave dependents and barrier-held siblings waiting while independent work continues; the next drain retries eligible work once. An interrupted executor is observed once per drain and cannot be adopted or respawned until it stops. `BLOCKED` means authoritative queue reconstruction or preflight failed, including a held drain lock. Never edit receipts, refs or queue rows to get past either result.
@@ -37,7 +37,7 @@ The host supplies the executor and maps its configured engine to the argv; the l
37
37
 
38
38
  | Interrupted | Resume |
39
39
  |---|---|
40
- | During allocation | A receipt without `allocation: owned` is never adopted: its task waits, naming the recovery (remove its worktree and branch if present, delete the receipt directory, drain again), while independent work continues. |
40
+ | During allocation | Replay `managed_pending` with `recover-storage --receipt`; `managed_owned` is owned. Managed removal uses `recover-storage --receipt --for-removal` and preserves receipts. An interrupted standalone allocation is never adopted: its task waits, naming the recovery (remove its worktree and branch if present, delete the receipt directory, drain again), while independent work continues. |
41
41
  | Inputs commit | A branch head equal to the expected inputs tree is adopted; anything else leaves the task waiting for inspection, preserving the unexpected commits. |
42
42
  | During execution | Each attempt's start is recorded before its spawn and its exit after it. A start without an exit gets one writer observation per drain. A live writer leaves the task waiting while independent work continues; a later drain adopts the submission or runs the executor again only after cessation is established; 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. |
@@ -49,7 +49,7 @@ The host supplies the executor and maps its configured engine to the argv; the l
49
49
 
50
50
  Expected task-local operational failures leave the selected task waiting with `<phase> blocked: <detail>` and recovery guidance. It is attempted at most once per drain; dependents, local siblings behind an active task, and siblings behind an undelivered package carrier wait too. Independent work continues and the drain ends `WAITING`. Inputs, acceptance, custody and terminal-attachment failures resume only their missing effects; bound results and accepted work never replay. The controller boundary contains `LoopError` and `OSError`; other programming errors propagate there. The inherited task-complete wrapper and delivery-repository lookup also contain helper `TypeError`, `KeyError` and `ValueError`, converting them to task waits.
51
51
 
52
- 11. **Report** to `<common Gitdir>/devlyn-loops/<loop-id>/drain-report.md` after every drain: per task the product result and reason, receipt, evidence custody and recovery ref, allocation base, accepted (or unaccepted) source, terminal commit, delivery status, PR URL and resume command, assumptions and unresolved questions, the retained worktree and branch, and workspace and scratch cleanup. Include the package's decisions and assumptions once, excluding the manifest and separately from executor assumptions; read the captured package when an add record exists, otherwise use the checkout, and report an unreadable package explicitly. Whole-loop acceptance is reported separately over the manifest's complete task set: every task needs receipt-backed acceptance, including the integration task's assembled-product check on the final frontier; a manifest task missing from the queue is reported as not yet run. `Bring into` names the command that brings the accepted frontier into the branch the loop was added on. For a local loop it is `git merge --ff <final frontier branch>` on that branch, which fast-forwards when it can and merges otherwise, so the commands of several local loops apply in either order. For `auto`/`pr`, once the frontier is delivered, it is `git merge --ff origin/<base>` on `<base>`: the anchor holds no loop commit or copy, so it fast-forwards after merge, squash and rebase delivery alike unless that branch has commits of its own, and merges then.
52
+ 11. **Report** to `<common Gitdir>/devlyn-loops/<loop-id>/drain-report.md` after every drain: per task the product result and reason, receipt, evidence custody and recovery ref, allocation base, accepted (or unaccepted) source, terminal commit, delivery status, PR URL and resume command, assumptions and unresolved questions, the retained worktree and branch, and workspace and scratch cleanup. Include the package's decisions and assumptions once, excluding the manifest and separately from executor assumptions; read the captured package when an add record exists, otherwise use the checkout, and report an unreadable package explicitly. Whole-loop acceptance is reported separately over the manifest's complete task set: every task needs receipt-backed acceptance, including the integration task's assembled-product check on the final frontier; a manifest task missing from the queue is reported as not yet run. `Bring into` names the shared `bring-in.py` helper, targeting the local frontier branch or the delivered `<remote>/<base>`. It fast-forwards when it can; otherwise it prepares the merge without touching the checkout and lands it by a protected fast-forward, so several local loops still apply in either order. A file collision, conflict, or unsupported merge setting stops before the branch moves and names the next step.
53
53
 
54
54
  Package cleanup, parent pruning and report failures appear as diagnostics under `cleanup`, with retained paths and repair guidance; they do not change product acceptance or completed delivery. The next drain retries them.
55
55
 
@@ -59,7 +59,7 @@ Package cleanup, parent pruning and report failures appear as diagnostics under
59
59
 
60
60
  `queue.py inspect --repo <dir> --json` reads without cleanup, locks or writes. It returns the status summary plus `binding` (null for legacy) and `compatibility`: `compatible`, `legacy`, `reasons`, `recoveryArgv`, `taskPacket` ranges and `queueBinding` ranges. Incompatible or unreadable state is reported, never migrated. Apps should use this reader instead of `status`, which still reconciles cleanup. For a mismatch, `recoveryArgv` names the bound queue’s inspect invocation; use the same bound queue entry point for authorized add/drain operations.
61
61
 
62
- Codex projection writers place `.devlyn-harness-projection.json` at the copied plugin root, beside `skills` and `.codex-plugin`: `{schemaVersion: 1, release: <ReleaseRef>, source: "native/codex/plugins/devlyn"}`. The reader verifies the named release and every projected file’s bytes/mode against that subtree (excluding only the marker). Symlinked skill invocations resolve through their real path. Standalone installs and existing unfinished unbound queues retain legacy behavior; new legacy packets use v2 with a null release, and their `defaults` name the invoking installation's own `_shared/runtime-principles.md` (`source: "skill"`). No engine applicability is inferred from instruction filenames: bound queues use verified release defaults with native instructions taking precedence. Worker and runner paths point to the bound release, independent of a projection cache’s lifetime.
62
+ Codex projection writers place `.devlyn-harness-projection.json` at the copied plugin root, beside `skills` and `.codex-plugin`: `{schemaVersion: 1, release: <ReleaseRef>, source: "native/codex/plugins/devlyn"}`. The reader verifies the named release and every projected file’s bytes/mode against that subtree (excluding only the marker). Symlinked skill invocations resolve through their real path. Standalone installs and existing unfinished unbound queues retain legacy behavior; new legacy packets use v2 with a null release, and their `defaults` name the invoking installation's own `_shared/runtime-principles.md` (`source: "skill"`). No engine applicability is inferred from instruction filenames: bound queues use verified release defaults with native instructions taking precedence, except a Devlyn managed block, which the defaults replace. Worker and runner paths point to the bound release, independent of a projection cache’s lifetime.
63
63
 
64
64
  ## Executor exchange
65
65
 
@@ -75,7 +75,7 @@ The packet (`<receipt dir>/packet.json`) carries everything a fresh executor nee
75
75
  | `shared_constraints` | The meta-prompt's `Constraints and exclusions`. |
76
76
  | `dependencies` | `{task, receipt, source_sha}` of each accepted prerequisite. |
77
77
  | `schema_version`, `harness` | New packets use version 2 and the bound release reference, or null for legacy unbound queues. Existing v1 packets execute unchanged. |
78
- | `methodology` | `projectInstructions`: `{path, sha256}` of instruction bytes at the inputs commit. `defaults`: `{source: "release", path, sha256}` from the verified binding; for an unbound queue `{source: "skill", path, sha256}` of the installation's own `_shared/runtime-principles.md`. A task whose executor would get no principles at all (no defaults and no committed CLAUDE.md/AGENTS.md) waits with that reason instead of running. Read the named defaults; explicit user and native owner/project instructions take precedence. No native engine applicability is inferred from filenames. |
78
+ | `methodology` | `projectInstructions`: `{path, sha256}` of instruction bytes at the inputs commit. `defaults`: `{source: "release", path, sha256}` from the verified binding; for an unbound queue `{source: "skill", path, sha256}` of the installation's own `_shared/runtime-principles.md`. A task whose executor would get no principles at all (no defaults and no committed CLAUDE.md/AGENTS.md) waits with that reason instead of running. Read the named defaults; explicit user and native owner/project instructions take precedence, except a Devlyn managed block (devlyn:instructions markers), which the defaults replace. No native engine applicability is inferred from filenames. |
79
79
  | `workerPrompt` | `{path, sha256}` of the task’s worker instructions, or null for legacy unbound queues. Read it before working. |
80
80
  | `delivery` | `local-only`, `auto` or `pr`. |
81
81
  | `evidence_dir`, `runner` | Where loop evidence lives (`<worktree>/.devlyn/loop`, ignored by Git) and the runner argv. |
@@ -47,7 +47,7 @@ FRONTMATTER_KEYS = {"id", "loop_id", "title", "kind", "review_requirements", "co
47
47
  KINDS = {"feature", "spike", "prototype"}
48
48
  UNSUPPORTED = {"process_evidence": "phase process obligations", "required_risk_probe_requirements": "risk-probe obligations",
49
49
  "tier_a_waivers": "benchmark scope waivers", "spec_output_files": "benchmark scope surfaces"}
50
- SETTLED = {"LOCAL_ONLY", "COMPLETE", "FAILED"}
50
+ SETTLED = {"LOCAL_ONLY", "COMPLETE", "FAILED", "CLOSED"}
51
51
  OBLIGATIONS = [
52
52
  "Work only in this worktree on its owned branch under the installed methodology; never edit the loop package or docs/specs/queue.md.",
53
53
  "Commit any source changes on the owned branch; when none are needed, use inputs_sha without creating an empty commit. Leave no uncommitted or untracked source and remove residue the change created.",
@@ -170,10 +170,10 @@ def lock(common, name, *, blocking):
170
170
  yield
171
171
 
172
172
 
173
- def harness_call(name, *args):
173
+ def harness_call(name, *args, **kwargs):
174
174
  helper = shared("harness-store")
175
175
  try:
176
- return helper[name](*args)
176
+ return helper[name](*args, **kwargs)
177
177
  except helper["HarnessError"] as exc:
178
178
  raise LoopError(str(exc)) from exc
179
179
 
@@ -542,7 +542,23 @@ def branch_of(identity):
542
542
 
543
543
 
544
544
  def receipt_path(common, identity):
545
- return common / "devlyn-completion" / hashlib.sha256(branch_of(identity).encode()).hexdigest()[:24] / "receipt.json"
545
+ key = hashlib.sha256(branch_of(identity).encode()).hexdigest()[:24]
546
+ root = common / 'devlyn-completion'
547
+ active = root / key / 'receipt.json'
548
+ if active.exists():
549
+ return active
550
+ retired = sorted((root / 'retired').glob(key + '.*/receipt.json'))
551
+ if len(retired) > 1:
552
+ delivered = []
553
+ for path in retired:
554
+ receipt = read_json(path)
555
+ require(isinstance(receipt, dict), f'{path}: malformed retired receipt')
556
+ if receipt.get('delivery') == 'COMPLETE':
557
+ delivered.append(path)
558
+ require(len(delivered) <= 1, f'conflicting delivered receipts for {identity}: {delivered}')
559
+ if delivered:
560
+ return delivered[0]
561
+ return retired[-1] if retired else active
546
562
 
547
563
 
548
564
  def added_path(common, loop):
@@ -671,7 +687,9 @@ def task_complete(action, *, exclude_receipts=(), warnings=None, **values):
671
687
  return helper[action](argparse.Namespace(**values), **options)
672
688
  except (helper["CompletionError"], OSError, KeyError, TypeError, ValueError) as exc:
673
689
  # Inherited helper containment includes these three programming-error types; the controller boundary is narrower.
674
- raise LoopError(f"task-complete {action}: {exc}") from exc
690
+ details = getattr(exc, "storage_error", {})
691
+ context = f"; receipt={details.get('receipt')}; resume={details.get('resume')}" if details else ""
692
+ raise LoopError(f"task-complete {action}: {exc}{context}") from exc
675
693
 
676
694
 
677
695
  def derive_state(anchor, common, row, claims, rev):
@@ -690,7 +708,7 @@ def derive_state(anchor, common, row, claims, rev):
690
708
  require(receipt.get("task") == identity and receipt.get("branch") == branch_of(identity) and path == receipt_path(common, identity)
691
709
  and path == path.resolve(),
692
710
  f"receipt {path} claims {identity} from branch {receipt.get('branch')!r}; conflicting ownership, inspect it")
693
- require(receipt.get("allocation") in ("owned", "allocating"),
711
+ require(receipt.get("allocation") in ("owned", "allocating", "managed_pending", "managed_owned"),
694
712
  f"receipt {path}: missing or malformed allocation checkpoint")
695
713
  if receipt["allocation"] == "allocating":
696
714
  # Never adopted, and no executor ran: the task waits until the user removes what the allocation left.
@@ -700,9 +718,18 @@ def derive_state(anchor, common, row, claims, rev):
700
718
  return state
701
719
  require(all(isinstance(receipt.get(key), str) and receipt[key] and "\0" not in receipt[key] for key in
702
720
  ("id", "baseline", "worktree", "common_gitdir", "recovery_ref")), f"receipt {path}: malformed allocation fields")
703
- require(receipt["common_gitdir"] == str(common) and receipt["id"] == path.parent.name,
721
+ archived = path.parent.parent == common / 'devlyn-completion/retired'
722
+ directory_id = receipt['id'] + '.' + receipt.get('storage', {}).get('request', {}).get('operation_id', '') if archived else receipt['id']
723
+ require(not archived or receipt.get('storage', {}).get('removal_finished') and (
724
+ receipt.get('status') == 'ABANDONED' or receipt.get('delivery') in {'COMPLETE', 'LOCAL_ONLY', 'FAILED', 'CLOSED'}),
725
+ f'{path}: nonterminal retired receipt')
726
+ require(receipt["common_gitdir"] == str(common) and directory_id == path.parent.name,
704
727
  f"receipt {path}: conflicting common Gitdir/id binding")
705
- require(receipt["recovery_ref"] == "refs/devlyn/completed/" + receipt["id"], f"receipt {path}: conflicting recovery ref ownership")
728
+ recovery = "refs/devlyn/completed/" + receipt["id"]
729
+ allowed = {recovery}
730
+ if receipt['allocation'] in ('managed_pending', 'managed_owned'):
731
+ allowed.add(recovery + '.' + receipt.get('storage', {}).get('request', {}).get('operation_id', ''))
732
+ require(receipt["recovery_ref"] in allowed, f"receipt {path}: conflicting recovery ref ownership")
706
733
  for key in ("delivery", "product", "pr_url", "base", "remote"):
707
734
  require(key not in receipt or receipt[key] is None or isinstance(receipt[key], str), f"receipt {path}: malformed {key}")
708
735
  for key in ("acceptance", "queue", "merge", "workspace_cleanup", "scratch_cleanup"):
@@ -753,7 +780,8 @@ def derive_state(anchor, common, row, claims, rev):
753
780
  require(bound["worktree"] == receipt["worktree"] and bound["task"] == identity and bound["branch"] == receipt["branch"]
754
781
  and bound["allocation_base"] == receipt["baseline"],
755
782
  f"{packet}: conflicting packet ownership")
756
- for key, expected in (("receipt", path), ("scratch", path.parent / "scratch"), ("submission", path.parent / "submission.json"),
783
+ original = common / 'devlyn-completion' / receipt['id'] / 'receipt.json' if archived else path
784
+ for key, expected in (("receipt", original), ("scratch", original.parent / "scratch"), ("submission", original.parent / "submission.json"),
757
785
  ("evidence_dir", Path(receipt["worktree"]) / ".devlyn/loop")):
758
786
  require(bound.get(key) == str(expected), f"{packet}: malformed or conflicting {key} path")
759
787
  require(isinstance(bound["inputs_sha"], str) and SHA_RE.fullmatch(bound["inputs_sha"]), f"{packet}: malformed inputs commit")
@@ -788,7 +816,8 @@ def view(anchor, common, local_only=False, *, pure=False):
788
816
  claims, unreadable, damaged = {}, [], {}
789
817
  canonical = {receipt_path(common, row["identity"]): row["identity"] for row in rows if row["identity"]}
790
818
  branches = {branch_of(identity): identity for identity in canonical.values()}
791
- for path in sorted((common / "devlyn-completion").glob("*/receipt.json")):
819
+ for path in sorted([*(common / "devlyn-completion").glob("*/receipt.json"),
820
+ *(path for path in canonical if path.parent.parent.name == "retired")]):
792
821
  try:
793
822
  receipt = read_json(path)
794
823
  require(isinstance(receipt, dict), f"{path}: malformed receipt identity")
@@ -1186,7 +1215,7 @@ def ensure_packet(v, row, path, receipt):
1186
1215
  packet["runner"][1] = str(bound_skills / "devlyn-ideate/scripts/acceptance.py")
1187
1216
  prompt = path.parent / "worker-prompt.md"
1188
1217
  body = (f"Execute task packet {packet_path}. Work only in {worktree} on its owned branch.\n"
1189
- f"Read {binding['defaults']['path']} and apply it as defaults; explicit user and native owner/project instructions win.\n"
1218
+ f"Read {binding['defaults']['path']} and apply it as defaults; explicit user and native owner/project instructions win, except a Devlyn managed block (devlyn:instructions markers), which these defaults replace.\n"
1190
1219
  f"Set DEVLYN_SKILL_DIR={bound_skills / 'devlyn-ideate'} and DEVLYN_SHARED_DIR={bound_skills / '_shared'}.\n"
1191
1220
  f"Follow Executor exchange and Review records in {bound_skills / 'devlyn-ideate/references/loop.md'}.\n"
1192
1221
  f"Meet the packet obligations, run its runner and write {packet['submission']}. Never push, open a pull request or merge.\n")
@@ -1322,8 +1351,22 @@ def advance(v, row, opts):
1322
1351
  if not path.exists() and (refused := allocate(v, row, opts)):
1323
1352
  return refused
1324
1353
  receipt = read_json(path)
1354
+ if receipt.get('status') == 'ABANDONED':
1355
+ if refused := allocate(v, row, opts):
1356
+ return refused
1357
+ path = receipt_path(v['common'], identity)
1358
+ receipt = read_json(path)
1359
+ accepted = receipt.get('acceptance') or receipt.get('pushed') or receipt.get('pr_number')
1360
+ if receipt.get('allocation') in ('managed_pending', 'managed_owned') and not accepted:
1361
+ require('removal_request' not in receipt.get('storage', {}),
1362
+ 'allocation fenced; run ' + shared('task-complete')['receipt_resume'](
1363
+ 'recover-storage', path, receipt, flags=['--for-removal']))
1364
+ if receipt.get('allocation') == 'managed_pending' and not accepted:
1365
+ task_complete('recover_storage', receipt=str(path), for_removal=False,
1366
+ operation_id=receipt.get('storage', {}).get('request', {}).get('operation_id'))
1367
+ receipt = read_json(path)
1325
1368
  if receipt.get("acceptance") and (path.parent / "packet.json").exists():
1326
- harness_call("verify_packet", read_json(path.parent / "packet.json"), path.parent / "packet.json", SKILLS)
1369
+ harness_call("verify_packet", read_json(path.parent / "packet.json"), path.parent / "packet.json", SKILLS, accepted_receipt=receipt)
1327
1370
  if not receipt.get("acceptance"):
1328
1371
  state["phase"] = "inputs"
1329
1372
  failure = v["states"][identity].get("inputs_changed")
@@ -1350,6 +1393,7 @@ def advance(v, row, opts):
1350
1393
  packet = read_json(path.parent / "packet.json")
1351
1394
  # The plan's mode seeds the first delivery call; a later per-task --mode persists in the receipt.
1352
1395
  result = task_complete("complete", warnings=opts.warnings, receipt=str(path), acceptance=None,
1396
+ operation_id=receipt.get("storage", {}).get("request", {}).get("operation_id"),
1353
1397
  mode=None if local or receipt.get("mode_override") else packet["delivery"], local_only=local, writers_stopped=True)
1354
1398
  progress(identity, f"delivery {result['status']}")
1355
1399
  if result.get("merge_refused"):
@@ -1379,6 +1423,8 @@ def summary(v, row):
1379
1423
  binding = harness_call("read_binding", v["common"])
1380
1424
  skills = Path(binding["release"]["root"]) / "config/skills" if binding else SKILLS
1381
1425
  argv = [sys.executable, str(skills / "_shared/task-complete.py"), "complete", "--receipt", str(state["path"])]
1426
+ if operation := receipt.get("storage", {}).get("request", {}).get("operation_id"):
1427
+ argv += ["--operation-id", operation]
1382
1428
  if receipt.get("writers_released"):
1383
1429
  argv.append("--writers-stopped")
1384
1430
  if not receipt.get("local_only") and not receipt.get("mode_override"):
@@ -1438,13 +1484,18 @@ def bring_in(v, loop):
1438
1484
  return None
1439
1485
  receipt = tip["receipt"]
1440
1486
  if receipt.get("local_only"):
1441
- # It fast-forwards when it can and merges otherwise, so the commands of several local loops apply in any order.
1442
- return f"{v['records'][loop]['branch']}: git merge --ff {receipt['branch']}" if loop in v["records"] else None
1443
- if not delivered(receipt):
1444
- return None
1445
- # The anchor holds no loop commit, so after merge, squash or rebase delivery this fast-forwards unless the branch has commits
1446
- # of its own, and merges then; like the local form, the commands of several loops apply in any order.
1447
- return f"{receipt['base']}: git merge --ff {receipt['remote']}/{receipt['base']}"
1487
+ if loop not in v["records"]:
1488
+ return None
1489
+ branch, target = v["records"][loop]["branch"], receipt["branch"]
1490
+ else:
1491
+ if not delivered(receipt):
1492
+ return None
1493
+ branch, target = receipt["base"], f"{receipt['remote']}/{receipt['base']}"
1494
+ # Prepare any merge without touching the anchor, then land by protected fast-forward.
1495
+ # Several loops still apply in either order, including after merge, squash or rebase delivery.
1496
+ command = shlex.join(["python3", str(SKILLS / "_shared" / "bring-in.py"), "--repo", str(v["anchor"]),
1497
+ "--branch", branch, "--target", target])
1498
+ return f"{branch}: {command}"
1448
1499
 
1449
1500
 
1450
1501
  def write_reports(v, status, reason, warnings):
@@ -1,5 +1,5 @@
1
1
  {
2
- "name": "devlyn-harness-v4-3-21",
2
+ "name": "devlyn-harness-v4-3-24",
3
3
  "plugins": [
4
4
  {
5
5
  "name": "devlyn",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devlyn",
3
- "version": "4.3.21",
3
+ "version": "4.3.24",
4
4
  "description": "Devlyn harness skills",
5
5
  "skills": "./skills/"
6
6
  }