weir-harness 1.2.1 → 1.2.3

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.
@@ -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')
@@ -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()
@@ -121,12 +143,18 @@ export function mountSupervision({ ctx, audit, settings, supervisionNow, onChild
121
143
  }
122
144
  deliver(1)
123
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),
124
149
  },
125
150
  supervisionNow(),
126
151
  )
127
152
  // Restart rebuild: replay durable facts, cross-check the DSH catalog.
128
153
  const state = await rehydrateForParent(parent)
129
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 })
130
158
  entry = { coordinator, parent }
131
159
  coordinators.set(parent.id, entry)
132
160
  }
@@ -209,5 +237,5 @@ export function mountSupervision({ ctx, audit, settings, supervisionNow, onChild
209
237
  offSettings?.()
210
238
  }
211
239
 
212
- return { coordinatorFor, dispose }
240
+ return { coordinatorFor, noteSpawnFact, dispose }
213
241
  }
@@ -48,6 +48,8 @@ Children cannot delegate further. Curated agents are read-only and never write f
48
48
  * @property {object | undefined} agents - ctx.agents (live agent lookup by child id)
49
49
  * @property {() => any} [lanes] - live getter for the weirWorktreeLanes service (absent = no lane support)
50
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
51
53
  */
52
54
 
53
55
  /**
@@ -321,7 +323,7 @@ async function settledResult(started, parent) {
321
323
  try { return await started.result } finally {
322
324
  // subagent/end is emit, not an awaited barrier. Explicitly join the
323
325
  // incarnation's standard edit disposition before result/escalation/handoff.
324
- await settleDelegateEditLifecycle(parent, started.id, started.localAgent)
326
+ await settleDelegateEditLifecycle(parent, started.id, started.agent)
325
327
  }
326
328
  }
327
329
  /** Await one child, honoring the one-level ESCALATE contract. */
@@ -20,6 +20,7 @@ export function stripQuotedRegions(text) {
20
20
  * @property {string} id
21
21
  * @property {RegExp[]} patterns - compiled matchers
22
22
  * @property {boolean} oncePerSession
23
+ * @property {string} [summary] - one-line catalog description for the semantic classifier
23
24
  * @property {{ kind: 'skill-pointer', skill: string }
24
25
  * | { kind: 'message', text: string }
25
26
  * | { kind: 'effort', reasoningEffort: string }} injection
@@ -76,6 +77,7 @@ export function compileIntentTable(raw) {
76
77
  id: entry.id,
77
78
  patterns,
78
79
  oncePerSession: entry.oncePerSession !== false,
80
+ ...(typeof entry.summary === 'string' && entry.summary.length > 0 ? { summary: entry.summary } : {}),
79
81
  injection,
80
82
  }
81
83
  })
@@ -91,6 +93,16 @@ export function matchIntents(stripped, intents) {
91
93
  return intents.filter((intent) => intent.patterns.some((pattern) => pattern.test(stripped)))
92
94
  }
93
95
 
96
+ /** Verbatim directive injected on every writing intent hit. */
97
+ export const WRITING_DIRECTIVE = `<intent-gate id="writing">
98
+ Write the deliverable as the finished product, not as a record of producing it.
99
+ - Keep the agent's thinking, implementation rationale, design reasoning, debugging narrative, and next-step plans out of the deliverable.
100
+ - Do not write explanatory UI copy — "I will", "we can", "this page shows", "here you'll find", "used to illustrate", "implemented" — unless it is genuine product help text or empty-state copy.
101
+ - Put implementation approach, verification method, limitations, and trade-offs only in the chat reply, code comments, the PR/MR description, docs, or plan files.
102
+ - Exception: include design notes, methodology, implementation walkthrough, or a work log in the deliverable only when the user explicitly asks for them.
103
+ - Unless there is a real need, a change leaves no trace: no comments, notes, markers, or any "why this changed" evidence. Code should look like it was always that way. The best change looks like no change was made — present the result, not the change itself.
104
+ </intent-gate>`
105
+
94
106
  /** The default intent table (English templates; runtime content follows the session language). */
95
107
  export const DEFAULT_INTENTS = [
96
108
  {
@@ -113,6 +125,21 @@ export const DEFAULT_INTENTS = [
113
125
  matchers: ['\\bdebug\\b', '\\bdebugging\\b', '调试', '排查'],
114
126
  injection: { kind: 'skill-pointer', skill: 'debugging' },
115
127
  },
128
+ {
129
+ id: 'writing',
130
+ matchers: [
131
+ '\\bwrite (?:a |an |the |some )?(?:[a-z][\\w-]* )?(?:doc|docs|document|documents|documentation|readme|guide|manual|report|spec)\\b',
132
+ '\\b(?:draft|compose|author|produce|prepare|rewrite|revise|update|polish) (?:a |an |the |some )?(?:[a-z][\\w-]* )?(?:doc|docs|document|documentation|readme|guide|manual|report)\\b',
133
+ '\\bwrite[- ]?up\\b',
134
+ '\\bdocument (?:the|this|these|a|an)\\b',
135
+ '(?:写|撰写|编写|起草|整理成|写成|润色|补全|更新)[^\\n]{0,8}(?:文档|说明|报告|指南|手册|教程|文案)',
136
+ '(?:写|撰写|编写|起草|润色|补全|更新)[^\\n]{0,8}readme',
137
+ '文档(?:撰写|编写|写作|润色|补全|更新)',
138
+ ],
139
+ oncePerSession: false,
140
+ summary: 'author a documentation-style deliverable (docs, README, guide, report) as the finished result',
141
+ injection: { kind: 'message', text: WRITING_DIRECTIVE },
142
+ },
116
143
  {
117
144
  id: 'think',
118
145
  matchers: ['\\bthink hard\\b', '\\bultrathink\\b', '深思熟虑'],