weir-harness 1.2.0 → 1.2.2

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 (99) hide show
  1. package/cordis.patch.yml +0 -2
  2. package/lib/client.blackboard-model.js +172 -56
  3. package/lib/client.capability-manager.js +0 -1
  4. package/lib/client.capability-model.js +378 -31
  5. package/lib/client.chain-model.js +80 -0
  6. package/lib/client.disabled-categories-editor.js +22 -0
  7. package/lib/client.edit-lock-maintenance.js +239 -246
  8. package/lib/client.edit-lock-panel.js +110 -0
  9. package/lib/client.hash-edit-model.js +48 -0
  10. package/lib/client.js +267 -1549
  11. package/lib/client.lsp-model.js +92 -0
  12. package/lib/client.lsp-panel.js +11 -16
  13. package/lib/client.lsp-toggle.js +18 -0
  14. package/lib/client.notify-permissions.js +112 -20
  15. package/lib/client.notify-web.js +14 -11
  16. package/lib/client.robash-model.js +26 -0
  17. package/lib/client.settings-page.js +287 -17
  18. package/lib/client.worktree-model.js +225 -0
  19. package/package.json +5 -2
  20. package/src/blackboard/durable.js +12 -0
  21. package/src/blackboard/index.js +2 -2
  22. package/src/blackboard/kernel.js +23 -6
  23. package/src/blackboard/notify.js +10 -3
  24. package/src/blackboard/remote.js +31 -13
  25. package/src/blackboard/storage.js +13 -5
  26. package/src/capabilities/apply-engine.js +189 -101
  27. package/src/capabilities/defaults-transaction.js +136 -1
  28. package/src/capabilities/lifecycle-snapshot.js +83 -204
  29. package/src/capabilities/mcp-admission.js +1 -23
  30. package/src/capabilities/mcp-drain.js +30 -19
  31. package/src/capabilities/mcp-facade.js +23 -3
  32. package/src/capabilities/preset-library.js +247 -0
  33. package/src/capabilities/preset-package.js +212 -1
  34. package/src/capabilities/same-name-policy.js +3 -41
  35. package/src/capabilities/selection-notify.js +324 -21
  36. package/src/capabilities/skill-office-adapter.js +7 -1
  37. package/src/capabilities/skill-selection-plugin.js +315 -729
  38. package/src/capabilities/store/checked-contract.d.ts +5 -0
  39. package/src/capabilities/store/fs-adapter.js +52 -0
  40. package/src/capabilities/store/lock.js +86 -0
  41. package/src/capabilities/store/paths.js +5 -0
  42. package/src/capabilities/store/store.js +105 -5
  43. package/src/creative-guide/guide.js +1 -1
  44. package/src/delegate/child-composition.js +258 -0
  45. package/src/delegate/group-coordinator.js +25 -27
  46. package/src/delegate/index.js +21 -2
  47. package/src/delegate/rehydrate.js +24 -4
  48. package/src/delegate/robash-guard-core.js +2 -2
  49. package/src/delegate/spawn-adapter.js +214 -100
  50. package/src/delegate/status-tool.js +9 -6
  51. package/src/delegate/supervision-mount.js +35 -6
  52. package/src/delegate/tool.js +29 -4
  53. package/src/{shared → delegate}/whitelist-defaults.js +2 -2
  54. package/src/edit-lock/admin-ledger.js +8 -11
  55. package/src/edit-lock/admin-recovery.js +2 -1
  56. package/src/edit-lock/admission.js +12 -2
  57. package/src/edit-lock/binding-guidance.js +28 -10
  58. package/src/edit-lock/binding-machine.js +312 -0
  59. package/src/edit-lock/derived-facts.js +67 -0
  60. package/src/edit-lock/index.js +44 -244
  61. package/src/edit-lock/inspect.js +113 -1
  62. package/src/edit-lock/lifecycle.js +8 -5
  63. package/src/edit-lock/limits.js +61 -0
  64. package/src/edit-lock/maintenance.js +5 -2
  65. package/src/edit-lock/manager.js +6 -2
  66. package/src/edit-lock/operation-history.js +1 -1
  67. package/src/edit-lock/peer-client.js +9 -2
  68. package/src/edit-lock/peer-transport.js +7 -1
  69. package/src/edit-lock/remote.js +11 -4
  70. package/src/edit-lock/state.js +2 -4
  71. package/src/edit-lock/view.js +10 -11
  72. package/src/lsp/admin.js +2 -24
  73. package/src/lsp/index.js +1 -1
  74. package/src/lsp/manager.js +1 -1
  75. package/src/notify/permissions-admin.js +1 -1
  76. package/src/{lsp/child-process.js → process/index.js} +23 -6
  77. package/src/settings/env-admin.js +1 -1
  78. package/src/settings/index.js +1 -1
  79. package/src/settings/sections.js +0 -54
  80. package/src/shared/audit.js +12 -0
  81. package/src/shared/child-scope.js +3 -1
  82. package/src/shared/http-json.js +28 -0
  83. package/src/{delegate/audit-readers.js → shared/terminal-evidence.js} +34 -4
  84. package/src/worktree/errors.js +3 -0
  85. package/src/worktree/index.js +1 -2
  86. package/src/worktree/lane-core.js +641 -0
  87. package/src/worktree/lane-views.js +147 -0
  88. package/src/worktree/lane-workflows.js +853 -0
  89. package/src/worktree/lanes.js +24 -1513
  90. package/src/worktree/runner.js +5 -4
  91. package/src/capabilities/content-pin.js +0 -88
  92. package/src/capabilities/content-refresh.js +0 -331
  93. package/src/capabilities/distribution-manifest.js +0 -78
  94. package/src/capabilities/isolated-exec.js +0 -106
  95. package/src/capabilities/publisher.js +0 -129
  96. package/src/capabilities/skill-admission.js +0 -181
  97. package/src/capabilities/update-lifecycle.js +0 -106
  98. package/src/edit-lock/remote-service.js +0 -28
  99. package/src/worktree/contract-guidance.test.js +0 -100
@@ -1,15 +1,22 @@
1
1
  // Spawn adapter: the single site where a delegation becomes a started, guarded
2
- // child (design D1-D5). Request assembly, the maxDepth: 1 topology guarantee,
3
- // guard-attach order, and fail-closed teardown each live exactly once here;
4
- // the lanes differ only through the strategy knobs of the lane object
5
- // (start shape, toolFilter/persona transforms, guard handle path, teardown,
6
- // register ordering). Orchestration — escalation, job wrapping, group
7
- // two-phase/rollback/seal — stays in tool.js; this module only spawns.
2
+ // child (design D1-D5). Activation assembly, the maxDepth: 1 topology
3
+ // guarantee, guard-attach order, and fail-closed teardown each live exactly
4
+ // once here; the lanes differ only through the strategy knobs of the lane
5
+ // object (delivery policy, toolFilter/persona transforms, guard handle path,
6
+ // teardown, register ordering). Orchestration — escalation, job wrapping,
7
+ // group two-phase/rollback/seal — stays in tool.js; this module only spawns.
8
8
  //
9
- // Invariant: spawnGuardedChild returns only a started child whose read-only
10
- // guard is attached (or whose disabled-snapshot skip applies); a guard failure
11
- // always tears the child down and throws. Callers can never receive a
12
- // read-only child running unguarded.
9
+ // DSH 0.2.1 single start entry: subagents.startActivation(spec) with
10
+ // { provider, label, childId?, request, signal, delivery }. The request no
11
+ // longer carries label/signal/persona/toolFilter: the caller reserves the
12
+ // childId, and the persona + tool filter ride the creation-window composition
13
+ // shim (child-composition.js) registered synchronously before the start.
14
+ //
15
+ // Invariant: spawnGuardedChild returns only a started child whose composition
16
+ // was applied and whose read-only guard is attached (or whose disabled-snapshot
17
+ // skip applies); a failure at either step always tears the child down and
18
+ // throws. Callers can never receive an uncomposed or unguarded child.
19
+ import { randomUUID } from 'node:crypto'
13
20
  import { SUPERVISION_CONTRACT } from './group-coordinator.js'
14
21
  import { attachReadOnlyBashGuard } from './robash-guard.js'
15
22
  import { attachLaneGuard } from '../worktree/guard.js'
@@ -28,45 +35,88 @@ import { CONTINUABLE_CONTRACT, WORKER_CONTRACT, childToolFilter } from '../share
28
35
  /**
29
36
  * Lane strategy: every genuine per-lane difference as an injected knob.
30
37
  * @typedef {object} SpawnLane
31
- * @property {(deps: object, request: object) => Promise<object>} start - provider start call (lane-native shape)
38
+ * @property {string} laneName - 'one-shot' | 'supervised' | 'continuable' (spawn fact + diagnostics)
39
+ * @property {'parent' | 'caller'} delivery - runtime settlement policy: 'parent' notifies the model, 'caller' only returns the result
32
40
  * @property {(toolFilter: object | undefined) => object | undefined} toolFilterFor - toolFilter transform
33
41
  * @property {(target: object) => string} personaFor - persona decoration
34
42
  * @property {(started: object, target: object) => void} [beforeGuardAttach] - runs before any guard code (supervised: registerMember)
35
43
  * @property {(started: object, deps: object) => object} guardHandleFor - live agent handle for the guard
36
- * @property {(error: unknown, started: object) => never} onGuardFailure - fail-closed teardown (lane-native mechanism)
44
+ * @property {(error: unknown, started: object) => never | Promise<never>} onGuardFailure - fail-closed teardown (lane-native mechanism)
45
+ * @property {boolean} [cancelOnAbort] - tear the published child down when the spawn signal aborts (one-shot lanes)
37
46
  */
38
47
 
39
48
  /**
40
- * Spawn one child and attach its read-only guard before returning.
49
+ * Spawn one child, apply its composition, and attach its read-only guard before
50
+ * returning.
41
51
  * @param {SpawnAssignment} assignment
42
52
  * @param {SpawnLane} lane
43
- * @param {object} deps - DelegateDeps subset { subagents, robash, agents, restrictableNames? }
44
- * @returns {Promise<object>} the lane-native started handle (one-shot →
45
- * SubagentRun { id, localAgent, result, dispose }; supervised → { childId, messageId })
53
+ * @param {object} deps - DelegateDeps subset { subagents, robash, agents, restrictableNames?, composition?, reserveChildId? }
54
+ * @returns {Promise<{ id: string, childId: string, agent?: object, result: Promise<object>, dispose: () => Promise<void> | void, messageId?: string }>}
55
+ * the normalized handle: `id` aliases the durable child id, `agent` is the
56
+ * live child agent captured at spawn (0.2.1 resolves `result` only after
57
+ * teardown, so the handle must be captured now).
46
58
  */
47
59
  export async function spawnGuardedChild(assignment, lane, deps) {
48
60
  const { target } = assignment
49
- // Single request-assembly point (D3): the four near-verbatim literals of the
50
- // pre-refactor lanes converge here; lane hooks absorb the per-lane
61
+ // Single activation-assembly point (D3): the four near-verbatim literals of
62
+ // the pre-refactor lanes converge here; lane hooks absorb the per-lane
51
63
  // transforms; maxDepth: 1 (children never delegate further) has this one site.
52
64
  // The child tool catalog restriction applies AFTER the lane transform:
53
65
  // allow-list filters (curated read-only targets) pass through unchanged;
54
66
  // every other filter gets CHILD_DENY_TOOLS merged in (intersected with the
55
67
  // composition's restrictable names — tools.restrict() rejects unknown deny
56
68
  // names), so no spawned child can see orchestrator-only tools.
69
+ const label = assignment.label ?? target.label
57
70
  const toolFilter = childToolFilter(lane.toolFilterFor(target.toolFilter), deps.restrictableNames?.())
58
- const request = {
59
- label: assignment.label ?? target.label,
60
- prompt: assignment.prompt,
61
- parent: assignment.parent,
62
- signal: assignment.signal,
63
- ...(target.agentOptions ? { agentOptions: target.agentOptions } : {}),
71
+ const persona = lane.personaFor(target)
72
+ // Every spawn reserves its child id (0.2.1 lets the caller do this): the
73
+ // composition registration is keyed by it, the continuable/supervised lanes
74
+ // get their messageId back through it, and handle normalization is exact.
75
+ const childId = deps.reserveChildId?.() ?? randomUUID()
76
+ // Register the pending composition BEFORE the start call: the child's
77
+ // creation window (agent/created) runs inside startActivation, so this entry
78
+ // is always present when the listener fires (no race).
79
+ const registration = deps.composition?.beginSpawn({
80
+ session: assignment.parent?.session ?? null,
81
+ childId,
82
+ lane: lane.laneName,
83
+ label,
84
+ persona,
64
85
  ...(toolFilter ? { toolFilter } : {}),
65
- maxDepth: 1,
66
- persona: lane.personaFor(target),
86
+ })
87
+ let raw
88
+ try {
89
+ raw = await deps.subagents.startActivation({
90
+ provider: 'spawn',
91
+ label,
92
+ childId,
93
+ request: {
94
+ prompt: assignment.prompt,
95
+ parent: assignment.parent,
96
+ ...(target.agentOptions ? { agentOptions: target.agentOptions } : {}),
97
+ maxDepth: 1,
98
+ },
99
+ signal: assignment.signal,
100
+ delivery: lane.delivery,
101
+ })
102
+ } catch (error) {
103
+ registration?.forget()
104
+ throw error
105
+ }
106
+ const started = normalizeStarted(raw, childId, deps)
107
+ // Fail-closed composition gate: a child whose persona/deny list did not land
108
+ // inside its creation window is torn down instead of running uncomposed or
109
+ // with the orchestrator-tool deny list missing.
110
+ const outcome = registration?.outcome()
111
+ if (outcome && !outcome.ok) {
112
+ registration.forget()
113
+ try {
114
+ await started.dispose?.()
115
+ } catch {
116
+ // best-effort teardown of the uncomposed child
117
+ }
118
+ throw outcome.error
67
119
  }
68
- const started = await lane.start(deps, request)
69
-
70
120
  // Unified guard core (D5), in contract order: the lane's pre-guard hook runs
71
121
  // BEFORE any guard code (the supervised register-before-guard ordering, once
72
122
  // a comment-level contract, is now structural); the handle is resolved
@@ -75,79 +125,149 @@ export async function spawnGuardedChild(assignment, lane, deps) {
75
125
  // snapshot skips BOTH lanes silently (resolveTarget already withheld the
76
126
  // shell tool in that snapshot, so there is no shell surface left to guard).
77
127
  lane.beforeGuardAttach?.(started, target)
78
- if (!target.readOnly && !assignment.laneGuard) return started
79
- const handle = lane.guardHandleFor(started, deps)
80
- if (target.readOnly) {
81
- // deps.robash is a live resolver (the settings overlay can change between
82
- // delegations), so resolve it once here: the enabled check and the lists
83
- // handed to the guard must describe the same committed snapshot.
84
- const robash = deps.robash?.()
85
- // robash.lists carries both list sets ({ bash, pwsh }); the guard
86
- // dispatches on execution.name.
87
- if (robash?.enabled) {
128
+ if (target.readOnly || assignment.laneGuard) {
129
+ const handle = lane.guardHandleFor(started, deps)
130
+ if (target.readOnly) {
131
+ // deps.robash is a live resolver (the settings overlay can change between
132
+ // delegations), so resolve it once here: the enabled check and the lists
133
+ // handed to the guard must describe the same committed snapshot.
134
+ const robash = deps.robash?.()
135
+ // robash.lists carries both list sets ({ bash, pwsh }); the guard
136
+ // dispatches on execution.name.
137
+ if (robash?.enabled) {
138
+ try {
139
+ attachReadOnlyBashGuard(handle, robash.lists)
140
+ } catch (error) {
141
+ await lane.onGuardFailure(error, started)
142
+ }
143
+ }
144
+ }
145
+ // A lane-bound child additionally gets the lane guard (workdir, write
146
+ // paths/scope, branch-moving git) — same fail-closed teardown: a bound
147
+ // child never runs without it.
148
+ if (assignment.laneGuard) {
88
149
  try {
89
- attachReadOnlyBashGuard(handle, robash.lists)
150
+ attachLaneGuard(handle, assignment.laneGuard)
90
151
  } catch (error) {
91
- lane.onGuardFailure(error, started)
152
+ await lane.onGuardFailure(error, started)
92
153
  }
93
154
  }
94
155
  }
95
- // A lane-bound child additionally gets the lane guard (workdir, write
96
- // paths/scope, branch-moving git) — same fail-closed teardown: a bound
97
- // child never runs without it.
98
- if (assignment.laneGuard) {
156
+ // 0.2.1's activation signal is PRE-PUBLICATION cancellation only ("callers
157
+ // own later cancellation through dispose"); 0.2.0's in-process driver used
158
+ // to cancel a published child when its request signal aborted. The one-shot
159
+ // lanes keep that contract explicitly: aborting the tool call tears the
160
+ // child down (and its turn ends aborted, kind 'parent'), so a stopped
161
+ // parent never leaves a child running. Continuable/supervised lanes are
162
+ // deliberately NOT wired: their children outlive the call that started them.
163
+ // Wired last so a signal already aborted at spawn still leaves a fully
164
+ // guarded, fully composed child to tear down (never an unwired one).
165
+ if (lane.cancelOnAbort) wireAbortCancellation(assignment.signal, started)
166
+ return started
167
+ }
168
+
169
+ /**
170
+ * Cancel a published child when its spawn signal aborts (the 0.2.0 in-process
171
+ * driver contract, now caller-owned). `dispose()` is the 0.2.1 teardown that
172
+ * synchronously cancels the child agent (turn end: aborted, kind 'parent'), so
173
+ * a stopped parent never leaves a running child behind.
174
+ * @param {AbortSignal | undefined} signal
175
+ * @param {{ dispose?: () => Promise<void> | void }} started
176
+ */
177
+ function wireAbortCancellation(signal, started) {
178
+ if (!signal || typeof started?.dispose !== 'function') return
179
+ let fired = false
180
+ const cancel = () => {
181
+ if (fired) return
182
+ fired = true
99
183
  try {
100
- attachLaneGuard(handle, assignment.laneGuard)
101
- } catch (error) {
102
- lane.onGuardFailure(error, started)
184
+ void Promise.resolve(started.dispose()).catch(() => {})
185
+ } catch {
186
+ // best-effort teardown of the cancelled child
103
187
  }
104
188
  }
105
- return started
189
+ if (signal.aborted) {
190
+ cancel()
191
+ return
192
+ }
193
+ signal.addEventListener('abort', cancel, { once: true })
106
194
  }
107
195
 
108
196
  /**
109
- * One-shot lane: subagents.start('spawn', request) with the caller's
110
- * toolFilter untouched (the child deny-list merge happens once in
111
- * spawnGuardedChild) and the persona extended by WORKER_CONTRACT. Serves the
112
- * foreground, background (inside the job wrapper), and escalation respawn
113
- * sites — identical spawn semantics, only the assignment fields
114
- * (parent/signal/label) differ.
197
+ * Normalize a SubagentActivation into the handle the lanes and tool.js use:
198
+ * `{ id, childId, agent, result, dispose }`. The live child agent must be
199
+ * captured here — 0.2.1 resolves `result` only after the activation is
200
+ * disposed, so no post-settlement lookup can reach it.
201
+ * @param {object} raw - SubagentActivation ({ childId, messageId?, result, dispose() })
202
+ * @param {string} reservedChildId - the caller-reserved child id
203
+ * @param {object} deps
204
+ */
205
+ function normalizeStarted(raw, reservedChildId, deps) {
206
+ const childId = typeof raw?.childId === 'string' ? raw.childId : reservedChildId
207
+ let agent
208
+ try {
209
+ agent = deps.agents?.get?.(childId)
210
+ } catch {
211
+ agent = undefined
212
+ }
213
+ return {
214
+ id: childId,
215
+ childId,
216
+ agent: agent ?? raw?.agent,
217
+ result: raw?.result,
218
+ dispose: () => (typeof raw?.dispose === 'function' ? raw.dispose() : Promise.resolve()),
219
+ ...(raw?.messageId !== undefined ? { messageId: raw.messageId } : {}),
220
+ }
221
+ }
222
+
223
+ /**
224
+ * One-shot lane: startActivation with delivery 'caller' — the caller awaits
225
+ * the result itself and no runtime parent notice is sent (the jobs wrapper /
226
+ * the tool result is the report channel). The persona carries
227
+ * WORKER_CONTRACT. Serves the foreground, background (inside the job wrapper),
228
+ * and escalation respawn sites — identical spawn semantics, only the
229
+ * assignment fields (parent/signal/label) differ.
115
230
  * @returns {SpawnLane}
116
231
  */
117
232
  export function oneShotLane() {
118
233
  return {
119
- start: (deps, request) => deps.subagents.start('spawn', request),
234
+ laneName: 'one-shot',
235
+ delivery: 'caller',
236
+ // Aborting the tool call tears the child down (0.2.0 driver contract, see
237
+ // wireAbortCancellation): only the one-shot lanes own the call window.
238
+ cancelOnAbort: true,
120
239
  toolFilterFor: (toolFilter) => toolFilter,
121
240
  personaFor: (target) => target.persona + WORKER_CONTRACT,
122
- // S7 SubagentRun shape: the live agent handle rides the started run.
123
- guardHandleFor: (started) => started.localAgent,
124
- onGuardFailure(error, started) {
241
+ // The composition shim captures the live agent at spawn; no post-hoc lookup.
242
+ guardHandleFor: (started) => started.agent,
243
+ async onGuardFailure(error, started) {
125
244
  // A read-only child must never run unguarded: tear it down and fail loud.
126
- started.dispose()
245
+ try {
246
+ await started.dispose?.()
247
+ } catch {
248
+ // best-effort teardown of the unguarded child
249
+ }
127
250
  throw new Error(`delegate: failed to attach the read-only bash guard — ${String(error?.message ?? error)}`)
128
251
  },
129
252
  }
130
253
  }
131
254
 
132
255
  /**
133
- * Supervised lane: startContinuable with the worker + status contracts
134
- * appended to the persona (SUPERVISION_CONTRACT stays last) and send_message
135
- * denied, plus register-before-guard ordering.
136
- * Teardown deliberately differs from the one-shot lane: continuable handles
137
- * have no dispose, so a guard failure rethrows as-is and the lane-level
138
- * rollback in tool.js terminates the member through the coordinator (which
139
- * keeps the registry bookkeeping in sync).
256
+ * Supervised lane: startActivation with delivery 'parent' (the built-in
257
+ * settlement notice is the supervising parent's report channel and the
258
+ * ordering-gate input), the worker + status contracts appended to the persona
259
+ * (SUPERVISION_CONTRACT stays last) and send_message denied, plus
260
+ * register-before-guard ordering.
261
+ * Teardown: a guard failure annotates the exact child id and rethrows, so the
262
+ * lane-level rollback in tool.js terminates the member through the coordinator
263
+ * (which keeps the registry bookkeeping in sync).
140
264
  * @param {{ coordinator: object, groupName: string, members: Array<object> }} group
141
265
  * @returns {SpawnLane}
142
266
  */
143
267
  export function supervisedLane({ coordinator, groupName, members }) {
144
268
  return {
145
- // startContinuable takes label/signal beside the request, not inside it
146
- // (provider API shape).
147
- start: (deps, request) => {
148
- const { label, signal, ...rest } = request
149
- return deps.subagents.startContinuable({ provider: 'spawn', label, request: rest, signal })
150
- },
269
+ laneName: 'supervised',
270
+ delivery: 'parent',
151
271
  toolFilterFor: supervisedToolFilter,
152
272
  personaFor: (target) => target.persona + WORKER_CONTRACT + SUPERVISION_CONTRACT,
153
273
  beforeGuardAttach(started, target) {
@@ -156,11 +276,10 @@ export function supervisedLane({ coordinator, groupName, members }) {
156
276
  const member = coordinator.registerMember({ id: started.childId, name: target.label, group: groupName })
157
277
  members.push({ id: started.childId, name: target.label, member })
158
278
  },
159
- // startContinuable returns { childId, messageId } (no localAgent), so the
160
- // read-only shell guard attaches through the live agent handle — same
161
- // guard, same fail-closed semantics as the one-shot lane.
162
- guardHandleFor(started, deps) {
163
- const childAgent = deps.agents?.get(started.childId)
279
+ // The live agent captured at spawn is the guard handle (same guard, same
280
+ // fail-closed semantics as the one-shot lane).
281
+ guardHandleFor(started) {
282
+ const childAgent = started.agent
164
283
  if (!childAgent) throw new Error(`delegate: read-only supervised member spawned but no live agent handle is available for "${started.childId}"`)
165
284
  return childAgent
166
285
  },
@@ -172,38 +291,33 @@ export function supervisedLane({ coordinator, groupName, members }) {
172
291
 
173
292
  /**
174
293
  * Continuable lane (design D2/D3/D7): an opt-in `mode: 'continuable'` child
175
- * that starts via startContinuable and returns immediately; its results
176
- * arrive through the runtime's built-in settlement notices. The persona
177
- * carries WORKER_CONTRACT + CONTINUABLE_CONTRACT — NEVER SUPERVISION_CONTRACT:
178
- * no binary terminal-status contract, no coordinator registration, no
179
- * supervision bookkeeping (an untracked entry in supervised_status is the
180
- * normal state, not an anomaly). The child→parent channel stays closed the
181
- * same way as every lane: send_message is already in CHILD_DENY_TOOLS, which
182
- * the single assembly point merges, so toolFilterFor is the identity.
294
+ * that starts through startActivation (delivery 'parent') and returns
295
+ * immediately; its results arrive through the runtime's built-in settlement
296
+ * notices. The persona carries WORKER_CONTRACT + CONTINUABLE_CONTRACT — NEVER
297
+ * SUPERVISION_CONTRACT: no binary terminal-status contract, no coordinator
298
+ * registration, no supervision bookkeeping (an untracked entry in
299
+ * supervised_status is the normal state, not an anomaly). The child→parent
300
+ * channel stays closed the same way as every lane: send_message is already in
301
+ * CHILD_DENY_TOOLS, which the single assembly point merges, so toolFilterFor is
302
+ * the identity.
183
303
  *
184
- * Teardown matches the supervised lane: continuable handles have no dispose,
185
- * so a guard failure rethrows the original error with the child id attached;
186
- * the catch side in tool.js then best-effort interrupts the residual child
187
- * before propagating (the error names the child id, so the parent can
304
+ * Teardown: a guard failure rethrows the original error with the child id
305
+ * attached; the catch side in tool.js then best-effort interrupts the residual
306
+ * child before propagating (the error names the child id, so the parent can
188
307
  * dispose of it explicitly).
189
308
  * @returns {SpawnLane}
190
309
  */
191
310
  export function continuableLane() {
192
311
  return {
193
- // startContinuable takes label/signal beside the request, not inside it
194
- // (provider API shape) — same start shape as the supervised lane.
195
- start: (deps, request) => {
196
- const { label, signal, ...rest } = request
197
- return deps.subagents.startContinuable({ provider: 'spawn', label, request: rest, signal })
198
- },
312
+ laneName: 'continuable',
313
+ delivery: 'parent',
199
314
  toolFilterFor: (toolFilter) => toolFilter,
200
315
  personaFor: (target) => target.persona + WORKER_CONTRACT + CONTINUABLE_CONTRACT,
201
316
  // No beforeGuardAttach: nothing registers anywhere on this lane.
202
- // startContinuable returns { childId, messageId } (no localAgent), so the
203
- // read-only shell guard attaches through the live agent handle — same
204
- // guard, same fail-closed semantics as the other lanes.
205
- guardHandleFor(started, deps) {
206
- const childAgent = deps.agents?.get(started.childId)
317
+ // The live agent captured at spawn is the guard handle (same guard, same
318
+ // fail-closed semantics as the other lanes).
319
+ guardHandleFor(started) {
320
+ const childAgent = started.agent
207
321
  if (!childAgent) {
208
322
  const error = new Error(`delegate: read-only continuable child spawned but no live agent handle is available for "${started.childId}"`)
209
323
  error.childId = started.childId
@@ -19,7 +19,7 @@ export function digestReport(report, max = 200) {
19
19
  export function createStatusTool(deps) {
20
20
  return {
21
21
  name: 'supervised_status',
22
- description: `Render the supervised-child registry of this session: per child the id, name, group, status (running/blocked/completed/terminated), retries, and a truncated report digest; per group the member count and sealed/settled state; plus continuable children in the DSH subagent catalog that this coordinator does not track (flagged untracked). Main agent only. Member report bodies live in the built-in settlement notices and the registry digest; respond to a blocked member with resume_agent or terminate_agent.`,
22
+ description: `Render the supervised-child registry of this session: per child the id, name, group, status (running/blocked/completed/terminated), retries, and a truncated report digest; per group the member count and sealed/settled state; plus delegated children in the DSH subagent catalog that this coordinator does not track and no spawn fact claims (flagged untracked; the catalog mode is reported verbatim and may be 'continuable' or 'external'). Main agent only. Member report bodies live in the built-in settlement notices and the registry digest; respond to a blocked member with resume_agent or terminate_agent.`,
23
23
  parameters: {
24
24
  type: 'object',
25
25
  properties: {},
@@ -66,8 +66,9 @@ export function collectStatus(coordinator) {
66
66
  }
67
67
 
68
68
  /**
69
- * Cross-check the DSH catalog: continuable children not tracked by the
70
- * coordinator are supervision orphans and must be surfaced, never hidden.
69
+ * Cross-check the DSH catalog: delegated children not tracked by the
70
+ * coordinator (and not claimed by a spawn fact) are supervision orphans and
71
+ * must be surfaced, never hidden.
71
72
  * @returns {Promise<object[] | null>} untracked rows, or null when the catalog is unavailable
72
73
  */
73
74
  export async function catalogUntracked(coordinator, parentId, listChildren) {
@@ -111,11 +112,13 @@ export function renderStatus(value) {
111
112
  if (value.untracked === null) {
112
113
  lines.push('Untracked-catalog cross-check unavailable (DSH catalog read failed).')
113
114
  } else if (value.untracked.length === 0) {
114
- lines.push('No untracked continuable children in the DSH catalog.')
115
+ lines.push('No untracked delegated children in the DSH catalog.')
115
116
  } else {
116
- lines.push('Untracked continuable children in the DSH catalog (not supervised by this registry):')
117
+ lines.push('Untracked delegated children in the DSH catalog (not supervised by this registry and not spawned by this session):')
117
118
  for (const entry of value.untracked) {
118
- lines.push(`- ${entry.id} — label "${entry.label}" — mode ${entry.mode}`)
119
+ // The catalog mode is reported verbatim: DSH 0.2.1 reports every local
120
+ // child as 'continuable' and may also report 'external'.
121
+ lines.push(`- ${entry.id} — label "${entry.label}" — mode ${entry.mode ?? 'unknown'}`)
119
122
  }
120
123
  }
121
124
  return lines.join('\n')
@@ -4,9 +4,9 @@
4
4
  // into live coordinators. This module is a ctx adapter — per the jsconfig
5
5
  // purity curation it stays out of include (same precedent as index.js and
6
6
  // group-coordinator.js).
7
- import { createGroupCoordinator, isTerminalStatus } from './group-coordinator.js'
7
+ import { createGroupCoordinator } from './group-coordinator.js'
8
8
  import { rehydrateSupervision, applyChildLogRecovery } from './rehydrate.js'
9
- import { auditFilePathOf, readAuditTail, readChildFinalText } from './audit-readers.js'
9
+ import { auditFilePathOf, isTerminalStatus, readAuditTail, readChildFinalText } from '../shared/terminal-evidence.js'
10
10
  import { AUDIT_TYPES } from '../shared/audit.js'
11
11
  import { userTextMessage } from '../shared/user-message.js'
12
12
  import { contentText } from '../shared/content-text.js'
@@ -20,12 +20,34 @@ import { withSupervisedEditResume, settleDelegateEditLifecycle } from '../shared
20
20
  * @param {any} deps.settings - weirSettings service handle (absent = no commit push)
21
21
  * @param {() => object} deps.supervisionNow - overlay getter for supervision tuning
22
22
  * @param {(childId: string, parent: object) => void} [deps.onChildSettled] - a member reached a terminal fact (worktree lane settlement)
23
- * @returns {{ coordinatorFor: (parent: object) => Promise<object>, dispose: () => void }}
23
+ * @returns {{ coordinatorFor: (parent: object) => Promise<object>, noteSpawnFact: (fact: { session?: object | null, childId: string, lane?: string }) => void, dispose: () => void }}
24
24
  */
25
25
  export function mountSupervision({ ctx, audit, settings, supervisionNow, onChildSettled }) {
26
26
  // Supervised group coordinators, one per parent session.
27
27
  /** @type {Map<string, { coordinator: object, parent: object }>} */
28
28
  const coordinators = new Map()
29
+ // Durable delegate/spawn facts per parent session (childId → { lane }): the
30
+ // catalog cross-check consults them because DSH 0.2.1 reports every local
31
+ // child as mode 'continuable' (see untrackedCatalogEntries). Live spawns
32
+ // register through noteSpawnFact; a restart rebuild fills the same map from
33
+ // the rehydrated state.
34
+ /** @type {Map<string, Map<string, { childId: string, lane?: string }>>} */
35
+ const spawnFacts = new Map()
36
+ function spawnFactsFor(parentId) {
37
+ let facts = spawnFacts.get(parentId)
38
+ if (!facts) {
39
+ facts = new Map()
40
+ spawnFacts.set(parentId, facts)
41
+ }
42
+ return facts
43
+ }
44
+ /** Record one child-composition spawn fact (live spawn or rehydrated). */
45
+ function noteSpawnFact(fact) {
46
+ const parentId = fact?.session?.id
47
+ if (typeof parentId !== 'string' || parentId.length === 0) return
48
+ if (typeof fact.childId !== 'string' || fact.childId.length === 0) return
49
+ spawnFactsFor(parentId).set(fact.childId, { childId: fact.childId, lane: typeof fact.lane === 'string' ? fact.lane : undefined })
50
+ }
29
51
  // STATUS/turn-end is a bookkeeping fact, never proof the driver stopped.
30
52
  // This gate exists even when Edit Lock is disabled in the composition.
31
53
  const quiescence = new Map()
@@ -75,8 +97,9 @@ export function mountSupervision({ ctx, audit, settings, supervisionNow, onChild
75
97
  audit(parent.session, `${AUDIT_TYPES.supervision}/${fact.kind}`, fact)
76
98
  // A settled or terminated member frees its worktree lane (the host
77
99
  // checks the lane) — TERMINAL outcomes only (resumable-lane-workers
78
- // D1): a blocked member stands by for resume_agent, so its binding
79
- // must survive; the blocked fact still rides the audit channel.
100
+ // D1), with terminality defined by the shared isTerminalStatus
101
+ // predicate: a blocked member's binding must survive a possible
102
+ // resume; the blocked fact still rides the audit channel.
80
103
  // Deferred: never run lane work inside the fact.
81
104
  const settlesLane = fact.kind === 'terminate' || (fact.kind === 'settle' && isTerminalStatus(fact.status))
82
105
  if (settlesLane && !handoffs.has(fact.childId)) {
@@ -120,12 +143,18 @@ export function mountSupervision({ ctx, audit, settings, supervisionNow, onChild
120
143
  }
121
144
  deliver(1)
122
145
  },
146
+ // Lane decisions for the catalog cross-check come from the durable
147
+ // delegate/spawn facts (live-registered + rehydrated below).
148
+ spawnFactOf: (childId) => spawnFacts.get(parent.id)?.get(childId),
123
149
  },
124
150
  supervisionNow(),
125
151
  )
126
152
  // Restart rebuild: replay durable facts, cross-check the DSH catalog.
127
153
  const state = await rehydrateForParent(parent)
128
154
  coordinator.hydrate(state)
155
+ // Spawn facts ride the same tail: they decide which catalog children are
156
+ // orphans (one-shot/continuable children are never resurrected).
157
+ for (const fact of state.spawnFacts ?? []) noteSpawnFact({ session: parent.session, childId: fact.childId, lane: fact.lane })
129
158
  entry = { coordinator, parent }
130
159
  coordinators.set(parent.id, entry)
131
160
  }
@@ -208,5 +237,5 @@ export function mountSupervision({ ctx, audit, settings, supervisionNow, onChild
208
237
  offSettings?.()
209
238
  }
210
239
 
211
- return { coordinatorFor, dispose }
240
+ return { coordinatorFor, noteSpawnFact, dispose }
212
241
  }
@@ -3,6 +3,7 @@
3
3
  // @deepseek-ai packages do not resolve from a linked bundle).
4
4
  import { parseEscalation } from './escalate.js'
5
5
  import { continuableLane, oneShotLane, spawnGuardedChild, supervisedLane } from './spawn-adapter.js'
6
+ import { WORKTREE_CODES } from '../worktree/errors.js'
6
7
  import { contentText } from '../shared/content-text.js'
7
8
  import { settleDelegateEditLifecycle } from '../shared/delegate-edit-lifecycle.js'
8
9
 
@@ -47,6 +48,8 @@ Children cannot delegate further. Curated agents are read-only and never write f
47
48
  * @property {object | undefined} agents - ctx.agents (live agent lookup by child id)
48
49
  * @property {() => any} [lanes] - live getter for the weirWorktreeLanes service (absent = no lane support)
49
50
  * @property {() => Set<string> | undefined} [restrictableNames] - live resolver for the composition's restrictable tool names (ctx.tools.view); intersects CHILD_DENY_TOOLS at spawn because tools.restrict() rejects unknown deny names
51
+ * @property {{ beginSpawn: (composition: object) => { outcome: () => { ok: boolean, error?: Error }, forget: () => void } }} [composition] - the creation-window composition shim (child-composition.js): the reserved childId's persona/toolFilter are registered before startActivation and applied by the agent/created listener
52
+ * @property {() => string} [reserveChildId] - child-id minter override (tests); defaults to crypto.randomUUID
50
53
  */
51
54
 
52
55
  /**
@@ -320,7 +323,7 @@ async function settledResult(started, parent) {
320
323
  try { return await started.result } finally {
321
324
  // subagent/end is emit, not an awaited barrier. Explicitly join the
322
325
  // incarnation's standard edit disposition before result/escalation/handoff.
323
- await settleDelegateEditLifecycle(parent, started.id, started.localAgent)
326
+ await settleDelegateEditLifecycle(parent, started.id, started.agent)
324
327
  }
325
328
  }
326
329
  /** Await one child, honoring the one-level ESCALATE contract. */
@@ -473,16 +476,38 @@ async function laneBindingFor(target, args, deps, exec) {
473
476
  }
474
477
  }
475
478
 
476
- /** Spawn under a reserved binding: commit the child id, or roll back on failure. */
479
+ /**
480
+ * Spawn under a reserved binding: commit the child id, or roll back on
481
+ * failure. A commit refusing LANE_RESERVATION_LOST means the reservation was
482
+ * reclaimed while the child spawned (lane-binding-authoritative-commit): the
483
+ * child was never authorised, so it is disposed BEFORE the error surfaces
484
+ * (one-shot dispose precedent: spawn-adapter.js onGuardFailure). The rollback
485
+ * afterwards is a nonce-conditional no-op by then (D5). A spawn failure has
486
+ * no child to tear down; supervised-group commits roll back in the group
487
+ * catch (terminate per member), never here.
488
+ */
477
489
  async function spawnBound(binding, spawn, idOf) {
490
+ let started
491
+ try {
492
+ started = await spawn()
493
+ } catch (error) {
494
+ await binding?.rollback().catch(() => {})
495
+ throw error
496
+ }
478
497
  try {
479
- const started = await spawn()
480
498
  await binding?.commit(idOf(started))
481
- return started
482
499
  } catch (error) {
500
+ if (/** @type {any} */ (error)?.code === WORKTREE_CODES.LANE_RESERVATION_LOST) {
501
+ try {
502
+ await started.dispose?.()
503
+ } catch {
504
+ // best-effort teardown of the unauthorised child
505
+ }
506
+ }
483
507
  await binding?.rollback().catch(() => {})
484
508
  throw error
485
509
  }
510
+ return started
486
511
  }
487
512
 
488
513
  /** Lane guard spec with the path resolver bound to the parent's cwd. */
@@ -23,7 +23,7 @@
23
23
  // so tests can drive it without a runtime.
24
24
 
25
25
  import { readFileSync } from 'node:fs'
26
- import { DEFAULT_TABLES } from '../delegate/robash-guard-core.js'
26
+ import { DEFAULT_TABLES } from './robash-guard-core.js'
27
27
  /** @typedef {{ readFile?: (path: string | URL) => string, logger?: { warn?: (message: string) => void } }} ReaderOptions */
28
28
 
29
29
  /** The five table names, in the order they are published. */
@@ -51,7 +51,7 @@ export const FALLBACK_TABLES = Object.freeze({
51
51
  /**
52
52
  * The shipped defaults file, resolved against THIS module's location so it
53
53
  * travels with the bundle rather than with whatever working directory the host
54
- * happens to have. `src/shared/` → bundle root.
54
+ * happens to have. `src/delegate/` → bundle root.
55
55
  */
56
56
  export const DEFAULT_WHITELIST_PATH = new URL('../../whitelist-defaults.json', import.meta.url)
57
57