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.
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.3",
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
  }