weir-harness 1.2.1 → 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.
package/cordis.patch.yml CHANGED
@@ -104,7 +104,6 @@
104
104
  You are the Orchestrator, the user's single point of collaboration, powered by the {{model}} model.
105
105
  You understand intent, decompose work, delegate to specialist sub-agents, choose parallel fan-out
106
106
  or sequenced waiting, verify evidence, and report results.
107
- suffix: Your working directory is {{cwd}}.
108
107
  - id: weir-core
109
108
  name: 'weir-harness/core'
110
109
  - id: agent-instructions
@@ -294,7 +293,6 @@
294
293
  You are the Orchestrator, the user's single point of collaboration, powered by the {{model}} model.
295
294
  You understand intent, decompose work, delegate to specialist sub-agents, choose parallel fan-out
296
295
  or sequenced waiting, verify evidence, and report results.
297
- suffix: Your working directory is {{cwd}}.
298
296
  - id: weir-core
299
297
  name: 'weir-harness/core'
300
298
  # Creative-only: how to use the fused tooling (inspection order,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "weir-harness",
3
- "version": "1.2.1",
3
+ "version": "1.2.2",
4
4
  "type": "module",
5
5
  "description": "Weir \u2014 orchestrator-centric DSH agent preset: intent gate, category delegation, todo driver, pull-based background jobs, auto-compaction, hashline edit.",
6
6
  "license": "MIT",
@@ -28,8 +28,11 @@
28
28
  "whitelist-defaults.json",
29
29
  "icon.svg"
30
30
  ],
31
+ "engines": {
32
+ "dsh": ">=0.2.1-rc.1 <0.3.0"
33
+ },
31
34
  "peerDependencies": {
32
- "@deepseek-ai/dsh": ">=0.2.0-rc.2 <0.3.0"
35
+ "@deepseek-ai/dsh": ">=0.2.1-rc.1 <0.3.0"
33
36
  },
34
37
  "exports": {
35
38
  "./package.json": "./package.json",
@@ -30,7 +30,13 @@ export function createOfficeAdapter(skills, machineId) {
30
30
  const observation = Array.isArray(output) ? { candidates: output, complete: true } : output
31
31
  const candidates = []
32
32
  for (const raw of observation.candidates) {
33
- if (!OFFICE_NAMES.includes(raw.name) || raw.provider !== 'dsh-office' || raw.source !== 'bundled' || typeof raw.locator !== 'string') {
33
+ // DSH 0.2.1's office provider lists MORE bundled siblings than the three
34
+ // Weir composes (`office`, `office-render`, `office-recalculate`,
35
+ // `office-export-pdf`). This adapter only owns OFFICE_NAMES: a foreign
36
+ // sibling is skipped, never an error — while a candidate that CLAIMS one
37
+ // of our names with an unexpected shape still fails closed below.
38
+ if (!OFFICE_NAMES.includes(raw.name)) continue
39
+ if (raw.provider !== 'dsh-office' || raw.source !== 'bundled' || typeof raw.locator !== 'string') {
34
40
  throw new Error('Unsupported Office provider candidate')
35
41
  }
36
42
  const path = await realpath(raw.locator)
@@ -20,7 +20,7 @@ Call \`cordis_inspect_list\` first to enumerate the Host and Client Inspect Prov
20
20
 
21
21
  ## Manage plugins deliberately
22
22
 
23
- \`plugin_manager\` lists, installs, enables, disables, and removes profile bundles and plugins. List first to obtain exact identifiers. Changes affect every session in the profile and apply immediately in a live profile. To re-apply a changed bundle, disable then enable it (\`set_bundle\`) — repeating \`install_bundle\` for an already-installed bundle is rejected as ambiguous. A version exemption risks crashes and data loss: warn the user and obtain explicit permission for the exact plugin and runtime versions before granting one.
23
+ \`plugin_manager\` lists, installs, enables, disables, and removes profile bundles and plugins. List first to obtain exact identifiers. Changes affect every session in the profile and apply immediately in a live profile. To re-apply a changed bundle, disable then enable it (\`set_bundle\`) — repeating \`install_bundle\` for a known, already-installed bundle is refused up front as \`already-installed\`. A version exemption risks crashes and data loss: warn the user and obtain explicit permission for the exact plugin and runtime versions before granting one.
24
24
 
25
25
  ## Load the development skills
26
26
 
@@ -0,0 +1,258 @@
1
+ // Child composition shim: the creation-window replacement for the
2
+ // request-level `persona`/`toolFilter` fields DSH 0.2.1 removed from
3
+ // `subagents.startActivation`. The delegate module reserves the childId before
4
+ // spawning, registers the composition here, and the `agent/created` listener
5
+ // applies it inside the child's creation window — before the subagent runtime
6
+ // delivers the initial prompt (startLocal → materialize → agents.create →
7
+ // setup → announce → submitMaterialized, verified against 0.2.1).
8
+ //
9
+ // Timing is race-free by construction and fail-closed: a fresh spawn waits for
10
+ // the recorded verdict and tears the child down when the composition did not
11
+ // land, so no child ever runs with the orchestrator-tool deny list missing.
12
+ //
13
+ // Cold resume: the applied sections/restrictions are runtime state of one
14
+ // agent incarnation, so a host restart loses them. Every spawn therefore also
15
+ // writes a `delegate/spawn` fact on the shared audit channel (the cold-safe
16
+ // channel — never a custom session event type, S13) and this listener replays
17
+ // the composition for a resumed child whose fact is still in the audit tail.
18
+ // Missing fact: persona/allow-lists degrade fail-open (same posture as the
19
+ // supervision rebuild), while the orchestrator-tool deny floor is still applied
20
+ // whenever the child is attributable to a weir-managed parent session.
21
+ import { AUDIT_TYPES, AUDIT_SUBTYPES } from '../shared/audit.js'
22
+ import { auditFilePathOf, readAuditTail } from '../shared/terminal-evidence.js'
23
+ import { CHILD_DENY_TOOLS, childToolFilter } from '../shared/child-scope.js'
24
+
25
+ /**
26
+ * The spawn-fact audit type AS PASSED TO the audit sink: UNPREFIXED, because
27
+ * createAudit prepends `weir/` itself (see src/shared/audit.js). Mirrors the
28
+ * AUDIT_TYPES/AUDIT_SUBTYPES registry; passing a pre-prefixed value here is
29
+ * what produced `weir/weir/delegate/spawn` records in the wild.
30
+ */
31
+ export const SPAWN_FACT_TYPE = `${AUDIT_TYPES.delegate}/${AUDIT_SUBTYPES.delegate[0]}`
32
+
33
+ /**
34
+ * The spawn-fact type AS STORED in the audit channel (JSONL record + cordis
35
+ * event name): the sink's single `weir/` prefix is part of the record, so every
36
+ * comparison against a stored `record.type` must use THIS constant.
37
+ */
38
+ export const SPAWN_FACT_RECORD_TYPE = `weir/${SPAWN_FACT_TYPE}`
39
+
40
+ /** Deployment persona prefix section (dsh-system-prompt / dsh-persona contract). */
41
+ const PERSONA_PREFIX_SECTION = 'deployment:persona-prefix'
42
+ const PERSONA_PREFIX_ORDER = 'DEPLOYMENT_PERSONA_PREFIX'
43
+
44
+ /**
45
+ * @typedef {object} SpawnComposition
46
+ * @property {string} [childId]
47
+ * @property {string} [lane] - one of 'one-shot' | 'continuable' | 'supervised'
48
+ * @property {string} [label]
49
+ * @property {string} persona - full persona text applied as the child's prefix section
50
+ * @property {{ allow?: string[], deny?: string[] }} [toolFilter] - requested child tool filter
51
+ */
52
+
53
+ /**
54
+ * Mount the composition listener on one plugin ctx.
55
+ * @param {object} deps
56
+ * @param {any} deps.ctx - plugin context (on / tools)
57
+ * @param {(session: object | null, type: string, data?: unknown) => void} deps.audit - createAudit sink
58
+ * @param {{ warn?: (message: string) => void }} [deps.logger]
59
+ * @param {() => Set<string> | undefined} [deps.restrictableNames] - fallback composition view
60
+ * @param {(fact: { session?: object | null, childId: string, lane?: string }) => void} [deps.onSpawn] - live spawn-fact sink (supervision catalog cross-check)
61
+ * @returns {{ beginSpawn: (composition: SpawnComposition & { session?: object | null }) => { outcome: () => { ok: boolean, error?: Error }, forget: () => void }, dispose: () => void }}
62
+ */
63
+ export function createChildComposition({ ctx, audit, logger, restrictableNames, onSpawn }) {
64
+ /** @type {Map<string, { session: object | null, childId: string, lane: string, label: string, persona: string, toolFilter?: object, applied: boolean, error?: Error }>} */
65
+ const pending = new Map()
66
+
67
+ /**
68
+ * Reserve one composition and write its cold-safe spawn fact. Called
69
+ * synchronously BEFORE startActivation: the child's creation window runs
70
+ * inside that call, so the entry is always present when the listener fires.
71
+ * @param {SpawnComposition & { session?: object | null }} composition
72
+ */
73
+ function beginSpawn(composition) {
74
+ const entry = {
75
+ session: composition.session ?? null,
76
+ childId: composition.childId,
77
+ lane: composition.lane ?? 'one-shot',
78
+ label: composition.label ?? '',
79
+ persona: composition.persona ?? '',
80
+ toolFilter: composition.toolFilter,
81
+ applied: false,
82
+ error: undefined,
83
+ }
84
+ pending.set(entry.childId, entry)
85
+ // Audit mirrors the fact to <cwd>/.weir/audit.jsonl (cold-safe replay) and
86
+ // emits it in-process. Never throws.
87
+ const fact = {
88
+ childId: entry.childId,
89
+ lane: entry.lane,
90
+ label: entry.label,
91
+ persona: entry.persona,
92
+ ...(entry.toolFilter ? { toolFilter: entry.toolFilter } : {}),
93
+ }
94
+ audit(entry.session, SPAWN_FACT_TYPE, fact)
95
+ // Live consumers (the supervision catalog cross-check) see the same fact
96
+ // without re-reading the file.
97
+ try {
98
+ onSpawn?.({ session: entry.session, childId: entry.childId, lane: entry.lane })
99
+ } catch {
100
+ // a live sink failure must never break the spawn
101
+ }
102
+ return {
103
+ outcome: () => (entry.applied
104
+ ? { ok: true }
105
+ : {
106
+ ok: false,
107
+ error: entry.error ?? new Error(`child "${entry.childId}": the composition was not applied inside the creation window`),
108
+ }),
109
+ forget: () => {
110
+ if (pending.get(entry.childId) === entry) pending.delete(entry.childId)
111
+ },
112
+ }
113
+ }
114
+
115
+ /** Apply a composition to one live child agent (throws on any failure). */
116
+ function apply(agent, composition) {
117
+ const systemPrompt = agent?.ctx?.systemPrompt
118
+ const tools = agent?.ctx?.tools
119
+ if (typeof composition.persona === 'string' && composition.persona.length > 0) {
120
+ if (typeof systemPrompt?.section !== 'function') {
121
+ throw new Error('the child agent exposes no systemPrompt service')
122
+ }
123
+ // Same section name the deployment/preset persona uses, so the child's
124
+ // own layer shadows it instead of duplicating a prompt paragraph.
125
+ systemPrompt.section({
126
+ name: PERSONA_PREFIX_SECTION,
127
+ order: systemPrompt.getSectionOrder(PERSONA_PREFIX_ORDER),
128
+ text: composition.persona,
129
+ })
130
+ }
131
+ const filter = composition.toolFilter
132
+ if (!filter) return
133
+ if (typeof tools?.restrict !== 'function') {
134
+ // An allow filter with no restriction channel would silently widen the
135
+ // child surface; the deny list is the safety floor. Fail closed.
136
+ throw new Error('the child agent exposes no tools service')
137
+ }
138
+ // tools.restrict() rejects unknown deny/allow names, so the deny list is
139
+ // merged against the CHILD scope's restrictable set (the composition's own
140
+ // view is only a fallback). childToolFilter keeps the existing merge
141
+ // semantics: allow-list filters pass through, every other filter gets
142
+ // CHILD_DENY_TOOLS intersected in, so the orchestrator-tool deny never
143
+ // degrades with the requested filter. The final deny list is then
144
+ // intersected with that same child set: a name the child scope never
145
+ // registered needs no deny (it is absent either way) and naming it would
146
+ // make restrict() throw.
147
+ const known = restrictableNamesOf(agent) ?? restrictableNames?.()
148
+ const merged = childToolFilter(filter, known)
149
+ const deny = Array.isArray(merged.deny) && known ? merged.deny.filter((name) => known.has(name)) : merged.deny
150
+ const effective = deny === merged.deny ? merged : { ...merged, deny }
151
+ const hasAllow = effective.allow !== undefined
152
+ const hasDeny = Array.isArray(effective.deny) && effective.deny.length > 0
153
+ if (!hasAllow && !hasDeny) return
154
+ tools.restrict(effective)
155
+ }
156
+
157
+ /** Resolve the child scope's restrictable tool names (undefined when unavailable). */
158
+ function restrictableNamesOf(agent) {
159
+ try {
160
+ const view = agent?.ctx?.tools?.view?.(agent)
161
+ if (view?.restrictableNames instanceof Set) return view.restrictableNames
162
+ } catch {
163
+ // view() failures fall back to the composition view
164
+ }
165
+ return undefined
166
+ }
167
+
168
+ /**
169
+ * Replay the composition of a cold-resumed child from the audit tail.
170
+ * @param {any} agent
171
+ * @param {object} header - the child session header
172
+ */
173
+ function replayFromAudit(agent, header) {
174
+ const file = auditFilePathOf(agent.session)
175
+ const records = readAuditTail(file)
176
+ if (records.length === 0) return
177
+ let fact
178
+ let parentManaged = false
179
+ for (const record of records) {
180
+ if (!record || typeof record !== 'object') continue
181
+ if (record.session !== header.parentSession) continue
182
+ if (typeof record.type !== 'string' || !record.type.startsWith('weir/')) continue
183
+ parentManaged = true
184
+ if (record.type === SPAWN_FACT_RECORD_TYPE && record.data?.childId === agent.id) fact = record.data
185
+ }
186
+ if (!parentManaged) return // not a weir-managed parent session: not our child
187
+ if (!fact) {
188
+ // Degraded: the spawn fact aged out of the tail (or the audit channel was
189
+ // unavailable). Persona and allow-lists stay unapplied, but the
190
+ // orchestrator-tool deny floor never does.
191
+ logger?.warn?.(`weir-delegate: child "${agent.id}" has no delegate/spawn fact in the audit tail — replaying the static orchestrator-tool deny floor only`)
192
+ applyDenyFloor(agent)
193
+ return
194
+ }
195
+ try {
196
+ apply(agent, fact)
197
+ } catch (error) {
198
+ logger?.warn?.(`weir-delegate: child "${agent.id}" composition replay failed (fail-open): ${String(error?.message ?? error)}`)
199
+ applyDenyFloor(agent)
200
+ }
201
+ }
202
+
203
+ /** Apply only the static orchestrator-tool deny floor (best-effort, warns). */
204
+ function applyDenyFloor(agent) {
205
+ try {
206
+ const known = restrictableNamesOf(agent) ?? restrictableNames?.()
207
+ const deny = known ? CHILD_DENY_TOOLS.filter((name) => known.has(name)) : [...CHILD_DENY_TOOLS]
208
+ if (deny.length === 0) return
209
+ agent.ctx.tools.restrict({ deny })
210
+ } catch (error) {
211
+ logger?.warn?.(`weir-delegate: child "${agent.id}" deny-floor application failed: ${String(error?.message ?? error)}`)
212
+ }
213
+ }
214
+
215
+ /**
216
+ * `agent/created` listener. A failure on the fresh-spawn path is thrown so
217
+ * the spawn call itself rejects (the runtime disposes the unpublished child
218
+ * after a rejected creation announcement); the recorded verdict lets the
219
+ * spawn's own fail-closed branch also catch a listener that never ran.
220
+ */
221
+ function onAgentCreated(payload) {
222
+ const agent = payload?.agent
223
+ const header = agent?.session?.header
224
+ if (!header) return
225
+ if ((header.delegationDepth ?? 0) < 1) return
226
+ const childId = agent.id
227
+ const entry = pending.get(childId)
228
+ if (entry) {
229
+ pending.delete(childId)
230
+ try {
231
+ apply(agent, entry)
232
+ entry.applied = true
233
+ } catch (error) {
234
+ entry.error = compositionError(childId, error)
235
+ throw entry.error
236
+ }
237
+ return
238
+ }
239
+ replayFromAudit(agent, header)
240
+ }
241
+
242
+ const off = ctx.on('agent/created', onAgentCreated)
243
+
244
+ return {
245
+ beginSpawn,
246
+ dispose: () => {
247
+ pending.clear()
248
+ off?.()
249
+ },
250
+ }
251
+ }
252
+
253
+ /** Wrap one composition failure with the delegate-facing cause. */
254
+ function compositionError(childId, error) {
255
+ const wrapped = new Error(`delegate: failed to apply the child composition for "${childId}" — ${String(error?.message ?? error)}`)
256
+ wrapped.cause = error
257
+ return wrapped
258
+ }
@@ -47,18 +47,31 @@ export function createGroupRecord(name, { sealed = false, settled = false, membe
47
47
  }
48
48
 
49
49
  /**
50
- * Single exit for the untracked-catalog predicate: continuable catalog entries
51
- * the registry does not track. mode is normalized (undefined → 'continuable');
52
- * the row shape is { id, label, mode }. `isTracked(id)` is the caller's lookup.
50
+ * Single exit for the untracked-catalog predicate: catalog children the
51
+ * supervision registry does not track AND no one-shot/continuable spawn fact
52
+ * claims. The row shape is { id, label, mode } (mode is passed through
53
+ * verbatim: DSH 0.2.1 reports every local child as 'continuable' and may also
54
+ * report 'external', so the catalog mode no longer discriminates lanes — the
55
+ * durable spawn facts do).
56
+ * `isTracked(id)` is the caller's registry lookup. `spawnFactOf(id)` returns
57
+ * the durable delegate/spawn fact for a weir-spawned child (lane included):
58
+ * one-shot and plain-continuable children must never be resurrected as
59
+ * supervision orphans, while a supervised child normally IS tracked and a
60
+ * supervised child missing from the fact chain stays visible as an orphan
61
+ * (never hidden). Only a fact-less orphan (not spawned by this weir build)
62
+ * keeps the promoted/recovered semantics.
63
+ * @param {object[]} entries - DSH catalog children
64
+ * @param {(id: string) => boolean} isTracked
65
+ * @param {(id: string) => { lane?: string } | undefined} [spawnFactOf]
53
66
  */
54
- export function untrackedCatalogEntries(entries, isTracked) {
67
+ export function untrackedCatalogEntries(entries, isTracked, spawnFactOf) {
55
68
  const untracked = []
56
69
  for (const entry of entries ?? []) {
57
70
  if (!entry || typeof entry !== 'object') continue
58
- const mode = entry.mode === undefined ? 'continuable' : entry.mode
59
- if (mode !== 'continuable') continue
60
71
  if (isTracked(entry.id)) continue
61
- untracked.push({ id: entry.id, label: entry.label ?? '', mode })
72
+ const lane = spawnFactOf?.(entry.id)?.lane
73
+ if (lane === 'one-shot' || lane === 'continuable') continue
74
+ untracked.push({ id: entry.id, label: entry.label ?? '', mode: entry.mode })
62
75
  }
63
76
  return untracked
64
77
  }
@@ -73,7 +86,8 @@ export function untrackedCatalogEntries(entries, isTracked) {
73
86
  * @param {(delayMs: number, fn: () => void) => void} deps.schedule - delayed retry scheduler
74
87
  * @param {(reason: string) => void} [deps.onAudit]
75
88
  * @param {(fact: object) => void} [deps.onFact] - structured supervision facts (durability)
76
- * @param {(text: string) => void} [deps.notifyParent] - deliver a parent-facing signal (timer-deferred followup)
89
+ * @param {(text: string) => void} [deps.notifyParent] - deliver a parent-facing signal (timer-deferred followup)
90
+ * @param {(childId: string) => { lane?: string } | undefined} [deps.spawnFactOf] - durable delegate/spawn fact for one child id (lane decides orphan resurrection; see untrackedCatalogEntries)
77
91
  */
78
92
  export function createGroupCoordinator(deps, config = {}) {
79
93
  const cfg = { ...DEFAULT_SUPERVISION, ...config }
@@ -174,9 +188,9 @@ export function createGroupCoordinator(deps, config = {}) {
174
188
  }
175
189
  }
176
190
 
177
- /** Catalog cross-check: continuable catalog entries this registry does not track. */
191
+ /** Catalog cross-check: catalog children this registry does not track (spawn-fact aware). */
178
192
  function untrackedAgainstCatalog(entries) {
179
- return untrackedCatalogEntries(entries, (id) => children.has(id))
193
+ return untrackedCatalogEntries(entries, (id) => children.has(id), deps.spawnFactOf)
180
194
  }
181
195
 
182
196
  function noteAssistantText(childId, text) {
@@ -18,6 +18,7 @@ import { createDelegateTool } from './tool.js'
18
18
  import { attachWorktreeModeGuard } from './worktree-mode.js'
19
19
  import { DOCTRINE_SECTION_ORDER } from '../core/doctrine.js'
20
20
  import { isDelegatedChild } from '../shared/child-scope.js'
21
+ import { createChildComposition } from './child-composition.js'
21
22
  import {
22
23
  DELEGATE_TARGETS_SECTION_NAME,
23
24
  DELEGATE_TARGETS_SECTION_ORDER_OFFSET,
@@ -97,6 +98,20 @@ function apply(ctx, config = {}) {
97
98
  onAdaptersUpdated: (fn) => ctx.on('llm/adapters-updated', fn),
98
99
  })
99
100
 
101
+ // Creation-window child composition (DSH 0.2.1 removed the request-level
102
+ // persona/toolFilter): the shim applies the reserved spawn's composition in
103
+ // the child's agent/created window and replays it from the audit channel on
104
+ // cold resume. One instance per plugin context; dispose unregisters it.
105
+ const composition = createChildComposition({
106
+ ctx,
107
+ audit,
108
+ logger: ctx.logger,
109
+ restrictableNames: () => ctx.tools.view?.(undefined)?.restrictableNames,
110
+ // Feed the supervision catalog cross-check the same durable fact without a
111
+ // file re-read (spawn-fact lane decides orphan resurrection).
112
+ onSpawn: mount.noteSpawnFact,
113
+ })
114
+
100
115
  ctx.tools.register(
101
116
  createDelegateTool({
102
117
  resolveTarget: targetResolver.resolveTarget,
@@ -114,6 +129,7 @@ function apply(ctx, config = {}) {
114
129
  // names, so the child deny list names only tools this composition
115
130
  // actually registers (tools.restrict() rejects unknown deny names).
116
131
  restrictableNames: () => ctx.tools.view?.(undefined)?.restrictableNames,
132
+ composition,
117
133
  }),
118
134
  )
119
135
 
@@ -165,6 +181,7 @@ function apply(ctx, config = {}) {
165
181
  return () => {
166
182
  offModeGuard()
167
183
  mount.dispose()
184
+ composition.dispose()
168
185
  }
169
186
  }
170
187
 
@@ -9,21 +9,37 @@ import { parseTerminalStatus, isTerminalStatus } from '../shared/terminal-eviden
9
9
  * Rebuild supervision state by replaying structured audit facts and
10
10
  * cross-checking the DSH subagent catalog.
11
11
  *
12
+ * Two fact channels ride the same audit file: the supervision facts
13
+ * (`weir/supervision/<kind>`) and the child-composition spawn facts
14
+ * (`weir/delegate/spawn`, which carry the lane). The spawn facts decide which
15
+ * catalog children are supervision orphans — DSH 0.2.1 reports every local
16
+ * child as mode 'continuable', so the catalog mode can no longer tell a
17
+ * one-shot child from a supervised one.
18
+ *
12
19
  * @param {object} input
13
20
  * @param {string} input.parentId - the parent session id (record filter key)
14
21
  * @param {object[]} input.records - parsed audit JSONL records (already tail-read; may contain other sessions/types)
15
22
  * @param {object[]} input.catalogChildren - DSH catalog entries ({ id, label?, mode? }); best-effort
16
- * @returns {{ children: object[], groups: object[], untracked: object[], confidence: 'full'|'partial', factsSeen: number }}
23
+ * @returns {{ children: object[], groups: object[], untracked: object[], spawnFacts: object[], confidence: 'full'|'partial', factsSeen: number }}
17
24
  */
18
25
  export function rehydrateSupervision({ parentId, records, catalogChildren }) {
19
26
  const children = new Map()
20
27
  const groups = new Map()
28
+ /** @type {Map<string, { childId: string, lane?: string }>} */
29
+ const spawnFacts = new Map()
21
30
  let factsSeen = 0
22
31
 
23
32
  for (const record of records ?? []) {
24
33
  if (!record || typeof record !== 'object') continue
25
34
  if (record.session !== undefined && record.session !== null && record.session !== parentId) continue
26
35
  const type = typeof record.type === 'string' ? record.type : ''
36
+ if (type === 'weir/delegate/spawn') {
37
+ const fact = record.data
38
+ if (fact && typeof fact === 'object' && typeof fact.childId === 'string' && fact.childId.length > 0) {
39
+ spawnFacts.set(fact.childId, { childId: fact.childId, lane: typeof fact.lane === 'string' ? fact.lane : undefined })
40
+ }
41
+ continue
42
+ }
27
43
  if (!type.startsWith('weir/supervision/')) continue
28
44
  const fact = record.data
29
45
  if (!fact || typeof fact !== 'object' || typeof fact.kind !== 'string') continue
@@ -37,13 +53,16 @@ export function rehydrateSupervision({ parentId, records, catalogChildren }) {
37
53
  group.settled = group.sealed === true && group.memberIds.length > 0 && terminal
38
54
  }
39
55
 
40
- // Catalog cross-check: continuable children the registry does not track.
41
- const untracked = untrackedCatalogEntries(catalogChildren, (id) => children.has(id))
56
+ // Catalog cross-check: only fact-less orphans remain untracked (the spawn
57
+ // facts identify the one-shot/continuable children this registry must never
58
+ // resurrect, and keep a supervised child whose fact chain is broken visible).
59
+ const untracked = untrackedCatalogEntries(catalogChildren, (id) => children.has(id), (id) => spawnFacts.get(id))
42
60
 
43
61
  return {
44
62
  children: [...children.values()],
45
63
  groups: [...groups.values()],
46
64
  untracked,
65
+ spawnFacts: [...spawnFacts.values()],
47
66
  confidence: factsSeen > 0 ? 'full' : 'partial',
48
67
  factsSeen,
49
68
  }
@@ -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. */
@@ -83,13 +83,22 @@ export function extraBinDirectories(env = process.env, platform = process.platfo
83
83
  * User-writable npm global prefix for panel installs: `npm --prefix <dir>
84
84
  * install -g …` lands servers here instead of a possibly root-owned global
85
85
  * prefix, and this directory is already part of the extended scan.
86
+ *
87
+ * `create` is a test seam: the win32 layout assertion must be runnable on any
88
+ * host WITHOUT materializing a literal `C:\Users\...` directory in the
89
+ * process CWD (the artifact that used to pollute the package directory after
90
+ * every unit run). Production callers keep the default (create the prefix).
91
+ * @param {Record<string, string | undefined>} [env]
92
+ * @param {string} [platform] - 'win32' | 'darwin' | 'linux' (process.platform values)
93
+ * @param {{ create?: boolean }} [options]
94
+ * @returns {string}
86
95
  */
87
- export function npmGlobalPrefix(env = process.env, platform = process.platform) {
96
+ export function npmGlobalPrefix(env = process.env, platform = process.platform, options = {}) {
88
97
  const home = env.HOME ?? homedir()
89
98
  const prefix = platform === 'win32'
90
99
  ? join(env.APPDATA ?? join(home, 'AppData', 'Roaming'), 'npm')
91
100
  : join(home, '.npm-global')
92
- mkdirSync(prefix, { recursive: true })
101
+ if (options.create !== false) mkdirSync(prefix, { recursive: true })
93
102
  return prefix
94
103
  }
95
104
 
@@ -44,6 +44,10 @@ export const AUDIT_TYPES = Object.freeze({
44
44
  // that had waiting subscribers; the emission lives in the blackboard plugin
45
45
  // and never touches a session log.
46
46
  blackboard: 'blackboard',
47
+ // Child-composition spawn facts (DSH 0.2.1 delegate shim): one record per
48
+ // spawn carrying the composition the child's creation window applies, so a
49
+ // host restart can replay it — the shim itself is runtime-only state.
50
+ delegate: 'delegate',
47
51
  })
48
52
  /**
49
53
  * Known dynamic sub-event kinds: `<type>/<kind>` events emitted beside a base
@@ -58,6 +62,9 @@ export const AUDIT_SUBTYPES = Object.freeze({
58
62
  // family, plus reconciliation reports.
59
63
  worktree: Object.freeze(['open', 'setup', 'bind', 'checked', 'check', 'invalidate', 'ask', 'decline', 'conflict', 'land', 'cleanup', 'abandon', 'reconcile', 'watch']),
60
64
  blackboard: Object.freeze(['released']),
65
+ // Child-composition spawn facts: one record per spawn, replayed on cold
66
+ // resume from this same channel (audit only — never a session log).
67
+ delegate: Object.freeze(['spawn']),
61
68
  })
62
69
 
63
70
  /**
@@ -59,7 +59,9 @@ export const CHILD_DENY_TOOLS = [
59
59
  * returned unchanged — allow semantics stay untouched. Deny-list or absent
60
60
  * filters get CHILD_DENY_TOOLS merged in, deduped. Same merge pattern as
61
61
  * supervisedToolFilter in src/delegate/spawn-adapter.js, applied AFTER the
62
- * lane transform at the single request-assembly point.
62
+ * lane transform at the single activation-assembly point, and re-applied by
63
+ * the DSH 0.2.1 creation-window shim (src/delegate/child-composition.js) with
64
+ * the CHILD scope's restrictable names before tools.restrict().
63
65
  *
64
66
  * DSH's tools.restrict() REJECTS deny names the composition never registered
65
67
  * (e.g. ask_user_question/present/edit_lock_* are absent from a headless