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 +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/intent-gate/matcher.js +27 -0
- package/src/lsp/admin.js +24 -13
- package/src/lsp/index.js +5 -2
- package/src/lsp/registry.js +83 -17
- package/src/notify/commands.js +15 -2
- package/src/notify/index.js +48 -10
- package/src/notify/web-channel.js +26 -3
- package/src/process/index.js +22 -2
- package/src/settings/sections.js +27 -4
- package/src/shared/audit.js +7 -0
- package/src/shared/child-scope.js +3 -1
|
@@ -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. */
|
|
@@ -20,6 +20,7 @@ export function stripQuotedRegions(text) {
|
|
|
20
20
|
* @property {string} id
|
|
21
21
|
* @property {RegExp[]} patterns - compiled matchers
|
|
22
22
|
* @property {boolean} oncePerSession
|
|
23
|
+
* @property {string} [summary] - one-line catalog description for the semantic classifier
|
|
23
24
|
* @property {{ kind: 'skill-pointer', skill: string }
|
|
24
25
|
* | { kind: 'message', text: string }
|
|
25
26
|
* | { kind: 'effort', reasoningEffort: string }} injection
|
|
@@ -76,6 +77,7 @@ export function compileIntentTable(raw) {
|
|
|
76
77
|
id: entry.id,
|
|
77
78
|
patterns,
|
|
78
79
|
oncePerSession: entry.oncePerSession !== false,
|
|
80
|
+
...(typeof entry.summary === 'string' && entry.summary.length > 0 ? { summary: entry.summary } : {}),
|
|
79
81
|
injection,
|
|
80
82
|
}
|
|
81
83
|
})
|
|
@@ -91,6 +93,16 @@ export function matchIntents(stripped, intents) {
|
|
|
91
93
|
return intents.filter((intent) => intent.patterns.some((pattern) => pattern.test(stripped)))
|
|
92
94
|
}
|
|
93
95
|
|
|
96
|
+
/** Verbatim directive injected on every writing intent hit. */
|
|
97
|
+
export const WRITING_DIRECTIVE = `<intent-gate id="writing">
|
|
98
|
+
Write the deliverable as the finished product, not as a record of producing it.
|
|
99
|
+
- Keep the agent's thinking, implementation rationale, design reasoning, debugging narrative, and next-step plans out of the deliverable.
|
|
100
|
+
- Do not write explanatory UI copy — "I will", "we can", "this page shows", "here you'll find", "used to illustrate", "implemented" — unless it is genuine product help text or empty-state copy.
|
|
101
|
+
- Put implementation approach, verification method, limitations, and trade-offs only in the chat reply, code comments, the PR/MR description, docs, or plan files.
|
|
102
|
+
- Exception: include design notes, methodology, implementation walkthrough, or a work log in the deliverable only when the user explicitly asks for them.
|
|
103
|
+
- Unless there is a real need, a change leaves no trace: no comments, notes, markers, or any "why this changed" evidence. Code should look like it was always that way. The best change looks like no change was made — present the result, not the change itself.
|
|
104
|
+
</intent-gate>`
|
|
105
|
+
|
|
94
106
|
/** The default intent table (English templates; runtime content follows the session language). */
|
|
95
107
|
export const DEFAULT_INTENTS = [
|
|
96
108
|
{
|
|
@@ -113,6 +125,21 @@ export const DEFAULT_INTENTS = [
|
|
|
113
125
|
matchers: ['\\bdebug\\b', '\\bdebugging\\b', '调试', '排查'],
|
|
114
126
|
injection: { kind: 'skill-pointer', skill: 'debugging' },
|
|
115
127
|
},
|
|
128
|
+
{
|
|
129
|
+
id: 'writing',
|
|
130
|
+
matchers: [
|
|
131
|
+
'\\bwrite (?:a |an |the |some )?(?:[a-z][\\w-]* )?(?:doc|docs|document|documents|documentation|readme|guide|manual|report|spec)\\b',
|
|
132
|
+
'\\b(?:draft|compose|author|produce|prepare|rewrite|revise|update|polish) (?:a |an |the |some )?(?:[a-z][\\w-]* )?(?:doc|docs|document|documentation|readme|guide|manual|report)\\b',
|
|
133
|
+
'\\bwrite[- ]?up\\b',
|
|
134
|
+
'\\bdocument (?:the|this|these|a|an)\\b',
|
|
135
|
+
'(?:写|撰写|编写|起草|整理成|写成|润色|补全|更新)[^\\n]{0,8}(?:文档|说明|报告|指南|手册|教程|文案)',
|
|
136
|
+
'(?:写|撰写|编写|起草|润色|补全|更新)[^\\n]{0,8}readme',
|
|
137
|
+
'文档(?:撰写|编写|写作|润色|补全|更新)',
|
|
138
|
+
],
|
|
139
|
+
oncePerSession: false,
|
|
140
|
+
summary: 'author a documentation-style deliverable (docs, README, guide, report) as the finished result',
|
|
141
|
+
injection: { kind: 'message', text: WRITING_DIRECTIVE },
|
|
142
|
+
},
|
|
116
143
|
{
|
|
117
144
|
id: 'think',
|
|
118
145
|
matchers: ['\\bthink hard\\b', '\\bultrathink\\b', '深思熟虑'],
|