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 +0 -2
- package/package.json +5 -2
- package/src/capabilities/skill-office-adapter.js +7 -1
- package/src/creative-guide/guide.js +1 -1
- package/src/delegate/child-composition.js +258 -0
- package/src/delegate/group-coordinator.js +24 -10
- package/src/delegate/index.js +17 -0
- package/src/delegate/rehydrate.js +22 -3
- package/src/delegate/spawn-adapter.js +214 -100
- package/src/delegate/status-tool.js +9 -6
- package/src/delegate/supervision-mount.js +30 -2
- package/src/delegate/tool.js +3 -1
- package/src/process/index.js +11 -2
- package/src/shared/audit.js +7 -0
- package/src/shared/child-scope.js +3 -1
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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:
|
|
51
|
-
*
|
|
52
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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) {
|
package/src/delegate/index.js
CHANGED
|
@@ -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:
|
|
41
|
-
|
|
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).
|
|
3
|
-
// guard-attach order, and fail-closed teardown each live exactly
|
|
4
|
-
// the lanes differ only through the strategy knobs of the lane
|
|
5
|
-
// (
|
|
6
|
-
// register ordering). Orchestration — escalation, job wrapping,
|
|
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
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
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 {
|
|
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
|
|
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>
|
|
45
|
-
*
|
|
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
|
|
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
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
66
|
-
|
|
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 (
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
96
|
-
//
|
|
97
|
-
// child
|
|
98
|
-
|
|
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
|
-
|
|
101
|
-
} catch
|
|
102
|
-
|
|
184
|
+
void Promise.resolve(started.dispose()).catch(() => {})
|
|
185
|
+
} catch {
|
|
186
|
+
// best-effort teardown of the cancelled child
|
|
103
187
|
}
|
|
104
188
|
}
|
|
105
|
-
|
|
189
|
+
if (signal.aborted) {
|
|
190
|
+
cancel()
|
|
191
|
+
return
|
|
192
|
+
}
|
|
193
|
+
signal.addEventListener('abort', cancel, { once: true })
|
|
106
194
|
}
|
|
107
195
|
|
|
108
196
|
/**
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
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
|
-
|
|
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
|
-
//
|
|
123
|
-
guardHandleFor: (started) => started.
|
|
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
|
-
|
|
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:
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
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
|
-
|
|
146
|
-
|
|
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
|
-
//
|
|
160
|
-
//
|
|
161
|
-
|
|
162
|
-
|
|
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
|
|
176
|
-
* arrive through the runtime's built-in settlement
|
|
177
|
-
* carries WORKER_CONTRACT + CONTINUABLE_CONTRACT — NEVER
|
|
178
|
-
* no binary terminal-status contract, no coordinator
|
|
179
|
-
* supervision bookkeeping (an untracked entry in
|
|
180
|
-
* normal state, not an anomaly). The child→parent
|
|
181
|
-
* same way as every lane: send_message is already in
|
|
182
|
-
* the single assembly point merges, so toolFilterFor is
|
|
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
|
|
185
|
-
*
|
|
186
|
-
* the
|
|
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
|
-
|
|
194
|
-
|
|
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
|
-
//
|
|
203
|
-
//
|
|
204
|
-
|
|
205
|
-
|
|
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
|
|
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:
|
|
70
|
-
* coordinator
|
|
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
|
|
115
|
+
lines.push('No untracked delegated children in the DSH catalog.')
|
|
115
116
|
} else {
|
|
116
|
-
lines.push('Untracked
|
|
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
|
-
|
|
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
|
}
|
package/src/delegate/tool.js
CHANGED
|
@@ -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.
|
|
326
|
+
await settleDelegateEditLifecycle(parent, started.id, started.agent)
|
|
325
327
|
}
|
|
326
328
|
}
|
|
327
329
|
/** Await one child, honoring the one-level ESCALATE contract. */
|
package/src/process/index.js
CHANGED
|
@@ -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
|
|
package/src/shared/audit.js
CHANGED
|
@@ -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
|
|
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
|