brainclaw 1.14.0 → 1.16.0
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/README.md +16 -263
- package/dist/brainclaw-vscode.vsix +0 -0
- package/dist/cli/register-capture.js +209 -0
- package/dist/cli/register-code-map.js +19 -0
- package/dist/cli/register-coordination.js +472 -0
- package/dist/cli/register-federation.js +258 -0
- package/dist/cli/register-lifecycle.js +436 -0
- package/dist/cli/register-memory-context.js +502 -0
- package/dist/cli/register-planning.js +167 -0
- package/dist/cli/register-review.js +149 -0
- package/dist/cli/shared.js +5 -0
- package/dist/cli.js +212 -2015
- package/dist/commands/dispatch-watch.js +25 -2
- package/dist/commands/harvest.js +31 -6
- package/dist/commands/mcp-catalog.js +1438 -0
- package/dist/commands/mcp-contract.js +33 -0
- package/dist/commands/mcp-presentation.js +27 -0
- package/dist/commands/mcp-read-handlers.js +72 -36
- package/dist/commands/mcp-write-admin.js +328 -0
- package/dist/commands/mcp-write-claims.js +864 -0
- package/dist/commands/mcp-write-coordination.js +1825 -0
- package/dist/commands/mcp-write-entities.js +620 -0
- package/dist/commands/mcp-write-memory.js +451 -0
- package/dist/commands/mcp-write-sequences.js +116 -0
- package/dist/commands/mcp-write-support.js +367 -0
- package/dist/commands/mcp.js +261 -5570
- package/dist/commands/update-handoff.js +28 -42
- package/dist/core/agent-capability.js +31 -14
- package/dist/core/agent-files.js +1 -1
- package/dist/core/agent-registry.js +51 -3
- package/dist/core/claims.js +18 -0
- package/dist/core/coordination.js +5 -2
- package/dist/core/cross-project.js +35 -1
- package/dist/core/dispatcher.js +34 -20
- package/dist/core/entity-operations.js +335 -12
- package/dist/core/entity-registry.js +72 -9
- package/dist/core/execution.js +28 -4
- package/dist/core/facade-schema.js +30 -4
- package/dist/core/federation-cloud.js +142 -11
- package/dist/core/federation-outbox.js +292 -0
- package/dist/core/federation-signing.js +115 -0
- package/dist/core/handoff-review.js +35 -0
- package/dist/core/io.js +6 -0
- package/dist/core/protocol-tool-policy.js +113 -0
- package/dist/core/review-loop-close.js +115 -0
- package/dist/core/schema.js +25 -2
- package/dist/core/security-detectors.js +35 -6
- package/dist/core/security.js +32 -12
- package/dist/core/worktree.js +98 -9
- package/dist/facts.js +13 -11
- package/dist/facts.json +12 -10
- package/docs/PROTOCOL.md +7 -3
- package/docs/concepts/coordinator-runbook.md +3 -0
- package/docs/concepts/dispatch-lifecycle.md +4 -4
- package/docs/concepts/loop-engine.md +3 -1
- package/docs/concepts/troubleshooting.md +1 -1
- package/docs/integrations/codex.md +3 -3
- package/docs/integrations/overview.md +1 -1
- package/docs/mcp-schema-changelog.md +153 -2
- package/docs/playbooks/orchestration.md +1 -1
- package/docs/product/entity-model-audit.md +3 -2
- package/docs/security.md +22 -1
- package/package.json +3 -1
|
@@ -0,0 +1,1825 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP coordination / dispatch / loop WRITE tool handlers.
|
|
3
|
+
*
|
|
4
|
+
* Extracted mechanically from mcp.ts (pln#622 PR3a). Each exported handler is
|
|
5
|
+
* the verbatim body of the corresponding `executeMcpToolCall` branch; the
|
|
6
|
+
* enclosing executor's locals (effective cwd, connection session id, resolved
|
|
7
|
+
* model) are carried via {@link McpWriteToolContext} from mcp-write-support.
|
|
8
|
+
*
|
|
9
|
+
* Import rule (pln#622 PR1 guard): this module must never import ./mcp.js —
|
|
10
|
+
* it imports mcp-contract / mcp-catalog boundary modules and core directly.
|
|
11
|
+
*
|
|
12
|
+
* @module
|
|
13
|
+
*/
|
|
14
|
+
import crypto from 'node:crypto';
|
|
15
|
+
import { buildClaimEnvPrefix } from '../core/execution-profile.js';
|
|
16
|
+
import { resolveProjectCwd } from '../core/cross-project.js';
|
|
17
|
+
import { attachAssignmentMessageToClaim, createCoordinatorClaim, linkClaimToAssignment, listClaims, saveClaim, } from '../core/claims.js';
|
|
18
|
+
import { ensureAgentRegisteredForDispatch, findAgentIdentityById, findAgentIdentityByName, } from '../core/agent-registry.js';
|
|
19
|
+
import { appendAuditEntry } from '../core/audit.js';
|
|
20
|
+
import { nowISO } from '../core/ids.js';
|
|
21
|
+
import { validateMcpField } from '../core/input-validation.js';
|
|
22
|
+
import { generateCandidateIdWithLabel, saveCandidate } from '../core/candidates.js';
|
|
23
|
+
import { ackMessage, getThread, hasActiveAssignment, sendMessage } from '../core/messaging.js';
|
|
24
|
+
import { dispatch, dispatchReview, generateDispatchBrief } from '../core/dispatcher.js';
|
|
25
|
+
import { CoordinateRequestSchema } from '../core/facade-schema.js';
|
|
26
|
+
import { buildInvokeCommand, getCapabilityProfile, getSpawnableAgents, resolveModel, validateAgentForDispatch, } from '../core/agent-capability.js';
|
|
27
|
+
import { attemptExecution } from '../core/execution.js';
|
|
28
|
+
import { createAgentRun, transitionAgentRun } from '../core/agentruns.js';
|
|
29
|
+
import { createAssignment, generateAssignmentId, patchAssignmentMessageId, transitionAssignment, } from '../core/assignments.js';
|
|
30
|
+
import { createToolErrorResponse, toolResponse, } from './mcp-contract.js';
|
|
31
|
+
import { handleMcpReadToolCall } from './mcp-read-handlers.js';
|
|
32
|
+
import { ensureTrust } from './mcp-write-support.js';
|
|
33
|
+
export async function handleBclawDispatch(args, ctx) {
|
|
34
|
+
const { cwd, connectionSessionId } = ctx;
|
|
35
|
+
if ((args.intent === 'analysis' || args.intent === 'review')) {
|
|
36
|
+
// Phase 3 slice 3d — intent dispatch. Routes analysis/review to the
|
|
37
|
+
// equivalent legacy tool handler, preserving the execute path below.
|
|
38
|
+
// See docs/concepts/mcp-governance.md.
|
|
39
|
+
const dispatchIntent = args.intent;
|
|
40
|
+
if (dispatchIntent === 'analysis') {
|
|
41
|
+
try {
|
|
42
|
+
return { response: toolResponse(handleMcpReadToolCall('bclaw_dispatch_analysis', args, { cwd })) };
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
return { response: createToolErrorResponse('operation_error', err.message) };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
// dispatchIntent === 'review' — delegate via dispatchReview directly.
|
|
49
|
+
const resolvedReview = ensureTrust(args, { nameField: 'agent', idField: 'agentId' }, 'trusted', cwd, connectionSessionId);
|
|
50
|
+
if (resolvedReview.error) {
|
|
51
|
+
return { response: createToolErrorResponse(resolvedReview.error.kind, resolvedReview.error.message, resolvedReview.error.details) };
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
const result = dispatchReview({
|
|
55
|
+
handoffId: args.handoffId,
|
|
56
|
+
reviewer: args.reviewer,
|
|
57
|
+
dryRun: args.dryRun,
|
|
58
|
+
openLoop: args.openLoop,
|
|
59
|
+
reviewMode: args.reviewMode,
|
|
60
|
+
dispatcherAgent: resolvedReview.identity.agent_name,
|
|
61
|
+
dispatcherAgentId: resolvedReview.identity.agent_id,
|
|
62
|
+
sessionId: connectionSessionId,
|
|
63
|
+
}, cwd);
|
|
64
|
+
const text = args.dryRun
|
|
65
|
+
? `🔍 Review dispatch dry run: ${result.reviews_sent.length} target(s).`
|
|
66
|
+
: `✔ Review dispatch complete: ${result.reviews_sent.length} target(s).`;
|
|
67
|
+
return {
|
|
68
|
+
response: toolResponse({
|
|
69
|
+
content: [{ type: 'text', text }],
|
|
70
|
+
...result,
|
|
71
|
+
}),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
return { response: createToolErrorResponse('operation_error', err.message) };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (args.intent !== undefined && args.intent !== 'execute') {
|
|
79
|
+
return { response: createToolErrorResponse('validation_error', `bclaw_dispatch: unknown intent '${args.intent}'. Expected analysis | execute | review.`) };
|
|
80
|
+
}
|
|
81
|
+
const resolved = ensureTrust(args, { nameField: 'agent', idField: 'agentId' }, 'trusted', cwd, connectionSessionId);
|
|
82
|
+
if (resolved.error) {
|
|
83
|
+
return { response: createToolErrorResponse(resolved.error.kind, resolved.error.message, resolved.error.details) };
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
const result = await dispatch({
|
|
87
|
+
agents: args.agents,
|
|
88
|
+
lanes: args.lanes,
|
|
89
|
+
maxAssignments: args.maxAssignments,
|
|
90
|
+
dryRun: args.dryRun,
|
|
91
|
+
dispatcherAgent: resolved.identity.agent_name,
|
|
92
|
+
dispatcherAgentId: resolved.identity.agent_id,
|
|
93
|
+
sessionId: connectionSessionId,
|
|
94
|
+
autoExecute: args.autoExecute,
|
|
95
|
+
model: args.model,
|
|
96
|
+
}, cwd);
|
|
97
|
+
if (!result) {
|
|
98
|
+
return { response: createToolErrorResponse('operation_error', 'No active sequence found. Create a sequence first.') };
|
|
99
|
+
}
|
|
100
|
+
const { analysis, result: dispatchResult } = result;
|
|
101
|
+
const lines = [];
|
|
102
|
+
if (args.dryRun) {
|
|
103
|
+
lines.push('Dispatch dry run (no messages sent):');
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
lines.push('Dispatch cycle complete:');
|
|
107
|
+
}
|
|
108
|
+
lines.push(` Sequence: ${analysis.sequence.name}`);
|
|
109
|
+
lines.push(` Ready: ${analysis.ready.length} | Active: ${analysis.active.length} | Blocked: ${analysis.blocked.length} | Done: ${analysis.done.length}`);
|
|
110
|
+
// can_681a6c52 — truthful per-delivery status. The old output printed
|
|
111
|
+
// '[inbox]' for every entry and ALWAYS dumped a 'Run these commands'
|
|
112
|
+
// block, even when the auto-spawn had already SUCCEEDED — an obedient
|
|
113
|
+
// coordinator would then double-spawn the worker.
|
|
114
|
+
const spawnedPlanIds = new Set(dispatchResult.messages_sent
|
|
115
|
+
.filter((m) => m.execution_status === 'delivered_and_started')
|
|
116
|
+
.map((m) => m.plan_id));
|
|
117
|
+
if (dispatchResult.messages_sent.length > 0) {
|
|
118
|
+
lines.push('');
|
|
119
|
+
lines.push(args.dryRun ? ' Would assign:' : ' Assigned:');
|
|
120
|
+
for (const msg of dispatchResult.messages_sent) {
|
|
121
|
+
const lane = msg.lane ? ` (lane: ${msg.lane})` : '';
|
|
122
|
+
const exec = msg.execution_status ? ` [${msg.execution_status}]` : ' [inbox]';
|
|
123
|
+
const pid = msg.pid ? ` pid=${msg.pid}` : '';
|
|
124
|
+
const run = msg.run_id ? ` run=${msg.run_id}` : '';
|
|
125
|
+
lines.push(` ${msg.agent}: ${msg.plan_id}${lane}${exec}${pid}${run}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const spawned = dispatchResult.messages_sent.filter((m) => m.execution_status === 'delivered_and_started');
|
|
129
|
+
if (spawned.length > 0) {
|
|
130
|
+
lines.push('');
|
|
131
|
+
lines.push('Auto-spawn succeeded — do NOT run the launch commands for these (double-spawn risk). Verify instead:');
|
|
132
|
+
for (const msg of spawned) {
|
|
133
|
+
const target = msg.assignment_id ?? msg.run_id ?? msg.claim_id;
|
|
134
|
+
lines.push(` bclaw_dispatch_status(target_id: "${target}") # ${msg.agent} on ${msg.plan_id}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
// Only surface manual launch commands for deliveries that were NOT
|
|
138
|
+
// auto-spawned (manual fallback, spawn refusal, inbox-only).
|
|
139
|
+
const manualCommands = dispatchResult.commands.filter((cmd) => !cmd.plan_id || !spawnedPlanIds.has(cmd.plan_id));
|
|
140
|
+
if (manualCommands.length > 0) {
|
|
141
|
+
lines.push('');
|
|
142
|
+
lines.push('Run these commands to launch the agents that were NOT auto-spawned:');
|
|
143
|
+
lines.push('');
|
|
144
|
+
for (const cmd of manualCommands) {
|
|
145
|
+
const lane = cmd.lane ? ` [lane: ${cmd.lane}]` : '';
|
|
146
|
+
lines.push(`# ${cmd.agent}${lane}`);
|
|
147
|
+
lines.push(cmd.command);
|
|
148
|
+
lines.push('');
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (dispatchResult.skipped.length > 0) {
|
|
152
|
+
lines.push('');
|
|
153
|
+
lines.push(' Skipped:');
|
|
154
|
+
for (const skip of dispatchResult.skipped) {
|
|
155
|
+
lines.push(` - ${skip.plan_id}: ${skip.reason}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
// can_45316d5c — worktree warnings / spawn refusals were hidden behind
|
|
159
|
+
// dispatch_status; surface them in the cycle output itself.
|
|
160
|
+
if (dispatchResult.warnings.length > 0) {
|
|
161
|
+
lines.push('');
|
|
162
|
+
lines.push(' Warnings:');
|
|
163
|
+
for (const warning of dispatchResult.warnings) {
|
|
164
|
+
lines.push(` ⚠ ${warning}`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
appendAuditEntry({
|
|
168
|
+
actor: resolved.identity.agent_name,
|
|
169
|
+
actor_id: resolved.identity.agent_id,
|
|
170
|
+
action: 'create',
|
|
171
|
+
item_type: 'dispatch',
|
|
172
|
+
scope: `${dispatchResult.messages_sent.length} assignments`,
|
|
173
|
+
}, cwd);
|
|
174
|
+
return {
|
|
175
|
+
response: toolResponse({
|
|
176
|
+
content: [{ type: 'text', text: lines.join('\n') }],
|
|
177
|
+
...dispatchResult,
|
|
178
|
+
sequence_id: analysis.sequence.id,
|
|
179
|
+
dry_run: !!args.dryRun,
|
|
180
|
+
}),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
catch (err) {
|
|
184
|
+
return { response: createToolErrorResponse('operation_error', err instanceof Error ? err.message : String(err)) };
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
export function handleBclawSendMessage(args, ctx) {
|
|
188
|
+
const { cwd, connectionSessionId } = ctx;
|
|
189
|
+
const resolved = ensureTrust(args, { nameField: 'agent', idField: 'agentId' }, 'contributor', cwd, connectionSessionId);
|
|
190
|
+
if (resolved.error) {
|
|
191
|
+
return { response: createToolErrorResponse(resolved.error.kind, resolved.error.message, resolved.error.details) };
|
|
192
|
+
}
|
|
193
|
+
const to = String(args.to ?? '').trim();
|
|
194
|
+
if (!to) {
|
|
195
|
+
return { response: createToolErrorResponse('validation_error', 'Missing required argument: to') };
|
|
196
|
+
}
|
|
197
|
+
const msgType = String(args.type ?? '').trim();
|
|
198
|
+
if (!['assign', 'review', 'rfc', 'info', 'reply'].includes(msgType)) {
|
|
199
|
+
return { response: createToolErrorResponse('validation_error', 'type must be one of: assign, review, rfc, info, reply') };
|
|
200
|
+
}
|
|
201
|
+
const msgText = String(args.text ?? '').trim();
|
|
202
|
+
if (!msgText) {
|
|
203
|
+
return { response: createToolErrorResponse('validation_error', 'Missing required argument: text') };
|
|
204
|
+
}
|
|
205
|
+
const textCheck = validateMcpField(msgText, 'text');
|
|
206
|
+
if (!textCheck.ok) {
|
|
207
|
+
return { response: createToolErrorResponse('validation_error', textCheck.message) };
|
|
208
|
+
}
|
|
209
|
+
// Auto-generate thread_id for new rfc/review threads
|
|
210
|
+
let threadId = args.thread_id;
|
|
211
|
+
if (!threadId && (msgType === 'rfc' || msgType === 'review')) {
|
|
212
|
+
threadId = `thread_${crypto.randomBytes(4).toString('hex')}`;
|
|
213
|
+
}
|
|
214
|
+
try {
|
|
215
|
+
const result = sendMessage({
|
|
216
|
+
from: resolved.identity.agent_name,
|
|
217
|
+
to,
|
|
218
|
+
type: msgType,
|
|
219
|
+
text: msgText,
|
|
220
|
+
ref: args.ref,
|
|
221
|
+
payload: args.payload,
|
|
222
|
+
scope: args.scope,
|
|
223
|
+
requires_ack: args.requires_ack,
|
|
224
|
+
thread_id: threadId,
|
|
225
|
+
tags: args.tags ?? [],
|
|
226
|
+
author_id: resolved.identity.agent_id,
|
|
227
|
+
session_id: connectionSessionId,
|
|
228
|
+
}, cwd);
|
|
229
|
+
appendAuditEntry({ actor: resolved.identity.agent_name, actor_id: resolved.identity.agent_id, action: 'create', item_id: result.id, item_type: 'message', scope: to }, cwd);
|
|
230
|
+
const threadInfo = threadId ? ` thread:${threadId}` : '';
|
|
231
|
+
return {
|
|
232
|
+
response: toolResponse({
|
|
233
|
+
content: [{ type: 'text', text: `✔ Message sent: [${result.shortLabel}] ${msgType} → ${to}${threadInfo}` }],
|
|
234
|
+
message_id: result.id,
|
|
235
|
+
thread_id: threadId,
|
|
236
|
+
}),
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
catch (err) {
|
|
240
|
+
return { response: createToolErrorResponse('operation_error', err instanceof Error ? err.message : String(err)) };
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
export function handleBclawAckMessage(args, ctx) {
|
|
244
|
+
const { cwd, connectionSessionId } = ctx;
|
|
245
|
+
const resolved = ensureTrust(args, { nameField: 'agent', idField: 'agentId' }, 'contributor', cwd, connectionSessionId);
|
|
246
|
+
if (resolved.error) {
|
|
247
|
+
return { response: createToolErrorResponse(resolved.error.kind, resolved.error.message, resolved.error.details) };
|
|
248
|
+
}
|
|
249
|
+
const msgId = String(args.id ?? '').trim();
|
|
250
|
+
if (!msgId) {
|
|
251
|
+
return { response: createToolErrorResponse('validation_error', 'Missing required argument: id') };
|
|
252
|
+
}
|
|
253
|
+
try {
|
|
254
|
+
// pln#562 step 4 — a dispatched instance (BRAINCLAW_CLAIM_ID) may only
|
|
255
|
+
// ack messages bound to its own claim.
|
|
256
|
+
const result = ackMessage(msgId, resolved.identity.agent_name, cwd, {
|
|
257
|
+
claimId: process.env.BRAINCLAW_CLAIM_ID?.trim() || undefined,
|
|
258
|
+
});
|
|
259
|
+
appendAuditEntry({ actor: resolved.identity.agent_name, actor_id: resolved.identity.agent_id, action: 'update', item_id: result.id, item_type: 'message' }, cwd);
|
|
260
|
+
return {
|
|
261
|
+
response: toolResponse({
|
|
262
|
+
content: [{ type: 'text', text: `✔ Message acknowledged: [${result.id}]` }],
|
|
263
|
+
message_id: result.id,
|
|
264
|
+
status: result.status,
|
|
265
|
+
}),
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
catch (err) {
|
|
269
|
+
return { response: createToolErrorResponse('operation_error', err instanceof Error ? err.message : String(err)) };
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
export async function handleBclawCoordinate(args, ctx) {
|
|
273
|
+
const { cwd, connectionSessionId, currentModel } = ctx;
|
|
274
|
+
const startMs = Date.now();
|
|
275
|
+
const parseResult = CoordinateRequestSchema.safeParse(args);
|
|
276
|
+
if (!parseResult.success) {
|
|
277
|
+
return { response: createToolErrorResponse('validation_error', parseResult.error.message) };
|
|
278
|
+
}
|
|
279
|
+
const req = parseResult.data;
|
|
280
|
+
// pln#511 step 2 — preset selector validation. Presets are kind-
|
|
281
|
+
// specific in v1: only intent='ideate' carries them. Unknown names
|
|
282
|
+
// are rejected up-front against the registry so the handler never
|
|
283
|
+
// silently falls back to the kind-default. The bootstrap preset
|
|
284
|
+
// also enforces a dispatch constraint (can_753a083a): the champion
|
|
285
|
+
// must be a human-connected agent, never a sandboxed worker —
|
|
286
|
+
// checked below once the senderAgent is resolved.
|
|
287
|
+
if (req.preset !== undefined) {
|
|
288
|
+
if (req.intent !== 'ideate') {
|
|
289
|
+
return {
|
|
290
|
+
response: createToolErrorResponse('preset_kind_mismatch', `preset='${req.preset}' is only valid for intent='ideate' in v1; got intent='${req.intent}'. Loop presets are kind-specific — open the loop without a preset, or call with intent='ideate'.`),
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
const { PRESETS: PRESETS_REGISTRY } = await import('../core/loops/presets/index.js');
|
|
294
|
+
if (!(req.preset in PRESETS_REGISTRY)) {
|
|
295
|
+
const validNames = Object.keys(PRESETS_REGISTRY).join(', ') || '(none)';
|
|
296
|
+
return {
|
|
297
|
+
response: createToolErrorResponse('unknown_preset', `Unknown preset '${req.preset}'. Valid preset names: ${validNames}.`),
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
// can_30c295b4 / trp#371 Tier 2 — the scope-aware dirty-working-tree
|
|
302
|
+
// guard runs LOWER DOWN, after dispatchCwd / isCrossProject are
|
|
303
|
+
// resolved (so it probes the dispatch TARGET, not the source, and only
|
|
304
|
+
// for the intents that actually spawn a worktree worker). See the
|
|
305
|
+
// assessDirtyDispatchGuard call after the cross-project block.
|
|
306
|
+
const warnings = [];
|
|
307
|
+
const artifacts = [];
|
|
308
|
+
const side_effects = [];
|
|
309
|
+
// can_5e62334e — codex sandboxed dispatches cannot commit in worktrees
|
|
310
|
+
// because `.git` is a file pointer to the parent repo's
|
|
311
|
+
// .git/worktrees/<wt>/ directory, which lives OUTSIDE the worktree's
|
|
312
|
+
// writable root that `--sandbox workspace-write` permits. Any
|
|
313
|
+
// codex worker that runs `git commit` will fail with `index.lock:
|
|
314
|
+
// Permission denied`. Warn callers up-front so briefs don't request
|
|
315
|
+
// per-bug commits; the coordinator must harvest the worktree
|
|
316
|
+
// diff via `git diff` and commit from a non-sandboxed cwd.
|
|
317
|
+
if (Array.isArray(req.targetAgents) && req.targetAgents.includes('codex')) {
|
|
318
|
+
warnings.push('codex --sandbox workspace-write cannot commit to git in worktrees (.git is outside writable root). Briefs MUST NOT request per-bug commits; codex will produce uncommitted edits, then the coordinator must harvest via `git diff HEAD` from the worktree path and commit from the main repo. See can_5e62334e for context.');
|
|
319
|
+
}
|
|
320
|
+
const senderAgent = typeof args.agent === 'string' && args.agent.trim()
|
|
321
|
+
? args.agent.trim()
|
|
322
|
+
: 'bclaw_coordinate';
|
|
323
|
+
const senderAgentId = typeof args.agentId === 'string' && args.agentId.trim()
|
|
324
|
+
? args.agentId.trim()
|
|
325
|
+
: undefined;
|
|
326
|
+
// pln#511 step 2 — bootstrap preset dispatch constraint (can_753a083a).
|
|
327
|
+
// The bootstrap loop's champion must be a human-connected agent: it
|
|
328
|
+
// asks the operator clarifying questions and writes PROJECT.md. A
|
|
329
|
+
// sandboxed worker (codex / github-copilot) cannot reach the human,
|
|
330
|
+
// so the loop would stall in `clarify`. Enforce by requiring
|
|
331
|
+
// targetAgents to be empty (= single-agent / self-champion mode)
|
|
332
|
+
// or to contain only the caller. Other presets are unrestricted.
|
|
333
|
+
if (req.preset === 'bootstrap') {
|
|
334
|
+
const targets = req.targetAgents ?? [];
|
|
335
|
+
const onlyCaller = targets.length === 0
|
|
336
|
+
|| (targets.length === 1 && targets[0] === senderAgent);
|
|
337
|
+
if (!onlyCaller) {
|
|
338
|
+
return {
|
|
339
|
+
response: createToolErrorResponse('bootstrap_preset_not_dispatchable', `preset='bootstrap' cannot dispatch to other agents (can_753a083a): the champion must be a human-connected agent. Got targetAgents=${JSON.stringify(targets)}; pass an empty array or [${JSON.stringify(senderAgent)}].`),
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
const commandHints = [];
|
|
344
|
+
const preparedInvokes = [];
|
|
345
|
+
// pln#359 phase 1b — cross-project routing. When `project` is set, all
|
|
346
|
+
// dispatch writes (claim, assignment, inbox message, audit) land in the
|
|
347
|
+
// target project (`dispatchCwd`). The target agent picks the brief up
|
|
348
|
+
// async via its own bclaw_work — auto-spawn from the source process
|
|
349
|
+
// is disabled because the spawn cwd / worktree semantics are tied to
|
|
350
|
+
// the target's git repo. The dispatch flow below uses `dispatchCwd`
|
|
351
|
+
// for state-mutating helpers; the outer `cwd` (source) stays in scope
|
|
352
|
+
// for the few cases that genuinely need source attribution.
|
|
353
|
+
const dispatchCwd = resolveProjectCwd(req.project, cwd);
|
|
354
|
+
const isCrossProject = dispatchCwd !== cwd;
|
|
355
|
+
if (isCrossProject && req.autoExecute !== false) {
|
|
356
|
+
warnings.push(`cross-project dispatch (project='${req.project}') — auto-spawn disabled; the target agent picks up the brief async via its own bclaw_work.`);
|
|
357
|
+
}
|
|
358
|
+
const effectiveAutoExecute = isCrossProject ? false : req.autoExecute;
|
|
359
|
+
// can_30c295b4 / trp#371 Tier 2 — scope-aware dirty-working-tree guard.
|
|
360
|
+
// Intents that spawn a worktree worker from HEAD can review/edit stale code,
|
|
361
|
+
// so they are guarded; consult/summarize (no worktree) are not. pln#626
|
|
362
|
+
// Phase 2 — multi-agent ideate now ALSO builds a worktree (from HEAD) per
|
|
363
|
+
// critic, so it is subject to the same stale-code concern; but ideation ABOUT
|
|
364
|
+
// in-progress work is legitimate, so ideate only WARNS (never blocks) that
|
|
365
|
+
// critics see HEAD, not the working tree. Cross-project dispatch is inbox-only
|
|
366
|
+
// (no local worktree here) so it is skipped. The guard compares dirty files
|
|
367
|
+
// against the dispatch scope and only blocks when overlap can't be ruled out;
|
|
368
|
+
// allow_dirty=true downgrades a block to a warning; an explicit ref makes
|
|
369
|
+
// working-tree dirt intentionally out of scope.
|
|
370
|
+
const WORKTREE_SPAWNING_INTENTS = new Set(['assign', 'review', 'reroute']);
|
|
371
|
+
const ideateWillSpawn = req.intent === 'ideate'
|
|
372
|
+
&& Array.isArray(req.targetAgents) && req.targetAgents.length > 0
|
|
373
|
+
&& req.preset !== 'bootstrap';
|
|
374
|
+
if (!isCrossProject && (WORKTREE_SPAWNING_INTENTS.has(req.intent) || ideateWillSpawn)) {
|
|
375
|
+
// Probe with the SAME scope the dispatch will actually claim, so the
|
|
376
|
+
// resolution mirrors reality (codex r1): assign falls back to the task
|
|
377
|
+
// text (mcp ~assignScope), reroute to the targeted active claim's scope.
|
|
378
|
+
let guardScope = req.scope;
|
|
379
|
+
if (req.intent === 'assign') {
|
|
380
|
+
guardScope = req.scope ?? req.task;
|
|
381
|
+
}
|
|
382
|
+
else if (req.intent === 'reroute' && !req.scope) {
|
|
383
|
+
guardScope = listClaims(dispatchCwd).find((c) => c.status === 'active')?.scope;
|
|
384
|
+
}
|
|
385
|
+
const { assessDirtyDispatchGuard } = await import('../core/dirty-scope.js');
|
|
386
|
+
const assessment = assessDirtyDispatchGuard({
|
|
387
|
+
cwd: dispatchCwd,
|
|
388
|
+
scope: guardScope,
|
|
389
|
+
allowDirty: req.allow_dirty,
|
|
390
|
+
checkoutRef: req.ref,
|
|
391
|
+
});
|
|
392
|
+
// ideate never blocks (ideating on dirty work is valid) — its block downgrades to a warn.
|
|
393
|
+
if (assessment.decision === 'block' && !ideateWillSpawn) {
|
|
394
|
+
return {
|
|
395
|
+
response: createToolErrorResponse('dirty_working_tree', `${assessment.reason} (cwd: ${dispatchCwd})`),
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
if (assessment.decision === 'warn' || (assessment.decision === 'block' && ideateWillSpawn)) {
|
|
399
|
+
warnings.push(`dirty_working_tree: ${assessment.reason}`
|
|
400
|
+
+ (ideateWillSpawn ? ' — ideate critics spawn from HEAD and will not see your uncommitted changes' : ''));
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
/** Run E2E execution phase on prepared delivery entries. Returns overall execution status. */
|
|
404
|
+
const runCoordinateExecution = async (prepared, opts) => {
|
|
405
|
+
let overall = 'inbox_only';
|
|
406
|
+
for (const { entry, invoke, worktreePath } of prepared) {
|
|
407
|
+
const execResult = await attemptExecution(invoke, {
|
|
408
|
+
agent: entry.agent,
|
|
409
|
+
autoExecute: opts.autoExecute,
|
|
410
|
+
worktreePath,
|
|
411
|
+
claimId: entry.claim_id,
|
|
412
|
+
assignmentId: entry.assignment_id,
|
|
413
|
+
dispatcherAgent: opts.senderAgent,
|
|
414
|
+
dispatcherAgentId: opts.senderAgentId,
|
|
415
|
+
cwd: opts.cwd,
|
|
416
|
+
requireWorktree: true, // pln#531: never spawn a worker in the integration repo
|
|
417
|
+
});
|
|
418
|
+
entry.execution_status = execResult.execution_status;
|
|
419
|
+
// pln#626 Phase 1 — carry the machine-readable reason (+ failure_kind) to
|
|
420
|
+
// the delivery entry so the primary spawn path (assign/review/reroute) is
|
|
421
|
+
// as honest as consult/ideate: a command_ready_manual entry now says WHY.
|
|
422
|
+
if (execResult.execution_reason)
|
|
423
|
+
entry.execution_reason = execResult.execution_reason;
|
|
424
|
+
if (execResult.failure_kind)
|
|
425
|
+
entry.failure_kind = execResult.failure_kind;
|
|
426
|
+
if (execResult.pid)
|
|
427
|
+
entry.pid = execResult.pid;
|
|
428
|
+
if (execResult.execution_status === 'delivered_and_started') {
|
|
429
|
+
entry.channel = 'spawned_cli';
|
|
430
|
+
overall = 'delivered_and_started';
|
|
431
|
+
}
|
|
432
|
+
else if (execResult.execution_status === 'command_ready_manual' && overall !== 'delivered_and_started') {
|
|
433
|
+
overall = 'command_ready_manual';
|
|
434
|
+
}
|
|
435
|
+
// Attribute the reason to its agent — a 3-target NO_SPAWN dispatch used to
|
|
436
|
+
// emit three identical context-free warnings (pln#626 Phase 1 R5).
|
|
437
|
+
if (execResult.error)
|
|
438
|
+
opts.warnings.push(`${entry.agent}: ${execResult.error}`);
|
|
439
|
+
if (entry.assignment_id && entry.claim_id) {
|
|
440
|
+
if (execResult.failure_kind === 'spawn_no_handshake') {
|
|
441
|
+
try {
|
|
442
|
+
const run = createAgentRun({
|
|
443
|
+
assignment_id: entry.assignment_id,
|
|
444
|
+
claim_id: entry.claim_id,
|
|
445
|
+
message_id: entry.message_id,
|
|
446
|
+
agent: entry.agent,
|
|
447
|
+
transport: 'cli_spawn',
|
|
448
|
+
status: 'launching',
|
|
449
|
+
scope: worktreePath ?? entry.scope ?? entry.ref ?? entry.assignment_id,
|
|
450
|
+
description: `Coordinate execution attempt for ${entry.scope ?? entry.ref ?? entry.assignment_id}`,
|
|
451
|
+
worktree_path: worktreePath,
|
|
452
|
+
command: execResult.command,
|
|
453
|
+
shell: execResult.shell,
|
|
454
|
+
pid: execResult.pid,
|
|
455
|
+
status_reason: 'CLI spawn launched by coordinator',
|
|
456
|
+
tags: ['coordinate-run', `message:${entry.message_type}`],
|
|
457
|
+
}, opts.cwd);
|
|
458
|
+
transitionAgentRun(run.id, 'failed', {
|
|
459
|
+
actor: opts.senderAgent,
|
|
460
|
+
actor_id: opts.senderAgentId,
|
|
461
|
+
pid: execResult.pid,
|
|
462
|
+
status_reason: execResult.error,
|
|
463
|
+
error_message: execResult.error,
|
|
464
|
+
}, opts.cwd);
|
|
465
|
+
}
|
|
466
|
+
catch (runErr) {
|
|
467
|
+
opts.warnings.push(`AgentRun creation failed for ${entry.assignment_id}: ${runErr instanceof Error ? runErr.message : String(runErr)}`);
|
|
468
|
+
}
|
|
469
|
+
try {
|
|
470
|
+
transitionAssignment(entry.assignment_id, 'failed', {
|
|
471
|
+
actor: opts.senderAgent,
|
|
472
|
+
actor_id: opts.senderAgentId,
|
|
473
|
+
error_message: execResult.error,
|
|
474
|
+
status_reason: execResult.error,
|
|
475
|
+
syncAgentRun: false,
|
|
476
|
+
}, opts.cwd);
|
|
477
|
+
}
|
|
478
|
+
catch (assignmentErr) {
|
|
479
|
+
opts.warnings.push(`Assignment failure transition failed for ${entry.assignment_id}: ${assignmentErr instanceof Error ? assignmentErr.message : String(assignmentErr)}`);
|
|
480
|
+
}
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
try {
|
|
484
|
+
const run = createAgentRun({
|
|
485
|
+
assignment_id: entry.assignment_id,
|
|
486
|
+
claim_id: entry.claim_id,
|
|
487
|
+
message_id: entry.message_id,
|
|
488
|
+
agent: entry.agent,
|
|
489
|
+
transport: execResult.execution_status === 'delivered_and_started'
|
|
490
|
+
? 'cli_spawn'
|
|
491
|
+
: execResult.execution_status === 'command_ready_manual'
|
|
492
|
+
? 'manual_command'
|
|
493
|
+
: 'inbox_only',
|
|
494
|
+
scope: worktreePath ?? entry.scope ?? entry.ref ?? entry.assignment_id,
|
|
495
|
+
description: `Coordinate execution attempt for ${entry.scope ?? entry.ref ?? entry.assignment_id}`,
|
|
496
|
+
worktree_path: worktreePath,
|
|
497
|
+
command: execResult.command,
|
|
498
|
+
shell: execResult.shell,
|
|
499
|
+
pid: execResult.pid,
|
|
500
|
+
status_reason: execResult.error,
|
|
501
|
+
tags: ['coordinate-run', `message:${entry.message_type}`],
|
|
502
|
+
}, opts.cwd);
|
|
503
|
+
if (execResult.execution_status === 'delivered_and_started') {
|
|
504
|
+
transitionAgentRun(run.id, 'launching', {
|
|
505
|
+
actor: opts.senderAgent,
|
|
506
|
+
actor_id: opts.senderAgentId,
|
|
507
|
+
pid: execResult.pid,
|
|
508
|
+
status_reason: 'CLI spawn launched by coordinator',
|
|
509
|
+
}, opts.cwd);
|
|
510
|
+
transitionAgentRun(run.id, 'running', {
|
|
511
|
+
actor: opts.senderAgent,
|
|
512
|
+
actor_id: opts.senderAgentId,
|
|
513
|
+
pid: execResult.pid,
|
|
514
|
+
status_reason: 'CLI process started',
|
|
515
|
+
}, opts.cwd);
|
|
516
|
+
}
|
|
517
|
+
else if (execResult.execution_status === 'command_ready_manual') {
|
|
518
|
+
transitionAgentRun(run.id, 'waiting_input', {
|
|
519
|
+
actor: opts.senderAgent,
|
|
520
|
+
actor_id: opts.senderAgentId,
|
|
521
|
+
status_reason: execResult.error ?? 'Awaiting manual command execution',
|
|
522
|
+
}, opts.cwd);
|
|
523
|
+
}
|
|
524
|
+
else {
|
|
525
|
+
transitionAgentRun(run.id, 'waiting_input', {
|
|
526
|
+
actor: opts.senderAgent,
|
|
527
|
+
actor_id: opts.senderAgentId,
|
|
528
|
+
status_reason: 'Awaiting inbox pickup by assigned agent',
|
|
529
|
+
}, opts.cwd);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
catch (runErr) {
|
|
533
|
+
opts.warnings.push(`AgentRun creation failed for ${entry.assignment_id}: ${runErr instanceof Error ? runErr.message : String(runErr)}`);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
return overall;
|
|
538
|
+
};
|
|
539
|
+
// pln#628 Focus 4B — appended to a REVIEW dispatch brief only. Tells the
|
|
540
|
+
// reviewer to emit a machine-readable verdict in LANE-RESULT.json so the
|
|
541
|
+
// coordinator's harvest can close the review loop (reviewer_green) without a
|
|
542
|
+
// human driving complete_turn/advance. Findings prose still goes wherever the
|
|
543
|
+
// task asks (e.g. a REVIEW-*.md); this is the structured signal the loop reads.
|
|
544
|
+
const reviewVerdictBriefSuffix = '\n\n## Review verdict (required — drives autonomous loop convergence)\n'
|
|
545
|
+
+ 'In your LANE-RESULT.json set "status":"completed" AND add "review_verdict": '
|
|
546
|
+
+ '"approve" (change is good to merge) or "request_changes" (needs fixes), plus '
|
|
547
|
+
+ '"review_summary":"<one-line rationale>". The coordinator reads review_verdict '
|
|
548
|
+
+ 'to close the review loop on approve, or continue it on request_changes.';
|
|
549
|
+
/** Build a coordinate brief: delegates to shared generateDispatchBrief(). */
|
|
550
|
+
const buildCoordinateBrief = (agentName, task, options) => {
|
|
551
|
+
return generateDispatchBrief({
|
|
552
|
+
task,
|
|
553
|
+
agent: agentName,
|
|
554
|
+
claimId: options?.claimId,
|
|
555
|
+
scope: options?.scope,
|
|
556
|
+
worktreePath: options?.worktreePath,
|
|
557
|
+
assignmentId: options?.assignmentId,
|
|
558
|
+
});
|
|
559
|
+
};
|
|
560
|
+
const toMessageSummary = (deliveryPlan) => deliveryPlan.map((entry) => ({
|
|
561
|
+
agent: entry.agent,
|
|
562
|
+
message_id: entry.message_id,
|
|
563
|
+
channel: entry.channel,
|
|
564
|
+
ref: entry.ref,
|
|
565
|
+
...(entry.thread_id ? { thread_id: entry.thread_id } : {}),
|
|
566
|
+
}));
|
|
567
|
+
const queueCoordinateMessage = (input) => {
|
|
568
|
+
const msgResult = sendMessage({
|
|
569
|
+
from: senderAgent,
|
|
570
|
+
to: input.agent,
|
|
571
|
+
type: input.messageType,
|
|
572
|
+
text: input.text,
|
|
573
|
+
ref: input.ref,
|
|
574
|
+
payload: input.payload,
|
|
575
|
+
scope: input.scope,
|
|
576
|
+
requires_ack: input.requiresAck,
|
|
577
|
+
thread_id: input.threadId,
|
|
578
|
+
claim_id: input.claimId,
|
|
579
|
+
assignment_id: input.assignmentId,
|
|
580
|
+
tags: input.tags ?? [],
|
|
581
|
+
author_id: senderAgentId,
|
|
582
|
+
session_id: connectionSessionId,
|
|
583
|
+
}, dispatchCwd);
|
|
584
|
+
artifacts.push({ type: 'message', id: msgResult.id });
|
|
585
|
+
side_effects.push({ action: 'create', entity: 'message', id: msgResult.id });
|
|
586
|
+
const invoke = buildInvokeCommand(input.agent, input.text, {
|
|
587
|
+
mode: input.commandMode ?? 'worker',
|
|
588
|
+
// pln#520/#606 — decouple model from agent identity. req.model is the
|
|
589
|
+
// override link; when unset, resolveModel intentionally falls back to
|
|
590
|
+
// the profile's default_model (the documented last link in the chain),
|
|
591
|
+
// mirroring the dispatcher's resolveModel usage (dispatcher.ts) so
|
|
592
|
+
// coordinate and dispatch spawn with the same model. No profile ships
|
|
593
|
+
// a default_model today, so omitting model stays a no-op in practice
|
|
594
|
+
// (gpt-5.6-luna review). Flows to both the manual commandHint and the
|
|
595
|
+
// auto-spawn path (runCoordinateExecution reuses this invoke).
|
|
596
|
+
model: resolveModel(input.agent, { override: req.model }),
|
|
597
|
+
});
|
|
598
|
+
// Build env prefix for claim routing — centralised in
|
|
599
|
+
// execution-profile.ts:buildClaimEnvPrefix as of pln#496 step
|
|
600
|
+
// stp_a9afe59d (handles all five shells, not just Windows/POSIX).
|
|
601
|
+
const claimEnvPrefix = buildClaimEnvPrefix(input.claimId);
|
|
602
|
+
const resolvedShell = process.platform === 'win32' ? 'cmd' : (invoke?.shell ? 'bash' : 'sh');
|
|
603
|
+
const commandHint = invoke
|
|
604
|
+
? {
|
|
605
|
+
agent: input.agent,
|
|
606
|
+
command: `${claimEnvPrefix}${invoke.bashCommand}`,
|
|
607
|
+
shell: resolvedShell,
|
|
608
|
+
}
|
|
609
|
+
: undefined;
|
|
610
|
+
if (commandHint)
|
|
611
|
+
commandHints.push(commandHint);
|
|
612
|
+
return {
|
|
613
|
+
entry: {
|
|
614
|
+
agent: input.agent,
|
|
615
|
+
message_id: msgResult.id,
|
|
616
|
+
channel: 'inbox',
|
|
617
|
+
message_type: input.messageType,
|
|
618
|
+
requires_ack: input.requiresAck ?? false,
|
|
619
|
+
ref: input.ref,
|
|
620
|
+
scope: input.scope,
|
|
621
|
+
thread_id: input.threadId,
|
|
622
|
+
claim_id: input.claimId,
|
|
623
|
+
assignment_id: input.assignmentId,
|
|
624
|
+
released_claim_id: input.releasedClaimId,
|
|
625
|
+
command: commandHint?.command,
|
|
626
|
+
shell: commandHint?.shell,
|
|
627
|
+
},
|
|
628
|
+
invoke,
|
|
629
|
+
};
|
|
630
|
+
};
|
|
631
|
+
// Resolve target agents: explicit list or all spawnable
|
|
632
|
+
const resolvedAgents = (req.targetAgents && req.targetAgents.length > 0)
|
|
633
|
+
? req.targetAgents
|
|
634
|
+
: getSpawnableAgents().map((a) => a.name);
|
|
635
|
+
let result = { selected_targets: resolvedAgents };
|
|
636
|
+
let facadeStatus = 'ok';
|
|
637
|
+
if (req.intent === 'assign') {
|
|
638
|
+
const delivery_plan = [];
|
|
639
|
+
for (const agentName of resolvedAgents) {
|
|
640
|
+
// trp#51: dispatch-time validation rejects unknown profiles,
|
|
641
|
+
// non-spawnable agents, and missing invoke_binary. Caller gets a
|
|
642
|
+
// specific error code instead of a silent skip, so automated
|
|
643
|
+
// retry loops can react (e.g. fall back to a different agent).
|
|
644
|
+
const check = validateAgentForDispatch(agentName, { requireSpawnable: true });
|
|
645
|
+
if (!check.valid) {
|
|
646
|
+
warnings.push(JSON.stringify({
|
|
647
|
+
warning: 'agent_validation_failed',
|
|
648
|
+
agent: agentName,
|
|
649
|
+
code: check.code,
|
|
650
|
+
reason: check.reason,
|
|
651
|
+
}));
|
|
652
|
+
continue;
|
|
653
|
+
}
|
|
654
|
+
// Ensure target agent is registered before creating claims/messages
|
|
655
|
+
ensureAgentRegisteredForDispatch(agentName, dispatchCwd);
|
|
656
|
+
const assignScope = req.scope ?? req.task;
|
|
657
|
+
// Guard: warn if there is already a non-archived assign message for this agent+scope
|
|
658
|
+
if (hasActiveAssignment(agentName, assignScope, dispatchCwd)) {
|
|
659
|
+
warnings.push(JSON.stringify({
|
|
660
|
+
warning: 'plan_already_assigned',
|
|
661
|
+
plan_id: assignScope,
|
|
662
|
+
existing_agent: agentName,
|
|
663
|
+
}));
|
|
664
|
+
}
|
|
665
|
+
// Guard: warn if there is already an active claim on the same scope
|
|
666
|
+
const conflictingClaims = listClaims(dispatchCwd).filter((c) => c.status === 'active' && c.scope === assignScope);
|
|
667
|
+
if (conflictingClaims.length > 0) {
|
|
668
|
+
const existing = conflictingClaims[0];
|
|
669
|
+
warnings.push(JSON.stringify({
|
|
670
|
+
warning: 'scope_already_claimed',
|
|
671
|
+
scope: assignScope,
|
|
672
|
+
existing_agent: existing.agent,
|
|
673
|
+
existing_claim_id: existing.id,
|
|
674
|
+
}));
|
|
675
|
+
}
|
|
676
|
+
const claimResult = createCoordinatorClaim({
|
|
677
|
+
agent: agentName,
|
|
678
|
+
scope: assignScope,
|
|
679
|
+
description: req.task,
|
|
680
|
+
dispatcherAgent: senderAgent,
|
|
681
|
+
sessionId: connectionSessionId,
|
|
682
|
+
cwd: dispatchCwd,
|
|
683
|
+
// createCoordinatorClaim guarantees the worktree reflects this ref
|
|
684
|
+
// (resets a stale branch / re-points a reused worktree) — see the
|
|
685
|
+
// worktreeBaseRef invariant there (pln#520 Tier 2).
|
|
686
|
+
worktreeBaseRef: req.ref,
|
|
687
|
+
});
|
|
688
|
+
const claimId = claimResult.claimId;
|
|
689
|
+
if (claimResult.worktreeWarning) {
|
|
690
|
+
warnings.push(claimResult.worktreeWarning);
|
|
691
|
+
}
|
|
692
|
+
artifacts.push({ type: 'claim', id: claimId });
|
|
693
|
+
side_effects.push({
|
|
694
|
+
action: claimResult.reusedExisting ? 'reuse' : 'create',
|
|
695
|
+
entity: 'claim',
|
|
696
|
+
id: claimId,
|
|
697
|
+
});
|
|
698
|
+
let assignmentId;
|
|
699
|
+
try {
|
|
700
|
+
const preId = generateAssignmentId(dispatchCwd);
|
|
701
|
+
const assignment = createAssignment({
|
|
702
|
+
id: preId.id,
|
|
703
|
+
short_label: preId.short_label,
|
|
704
|
+
claim_id: claimId,
|
|
705
|
+
agent: agentName,
|
|
706
|
+
dispatcher_agent: senderAgent,
|
|
707
|
+
dispatcher_session_id: connectionSessionId,
|
|
708
|
+
scope: assignScope,
|
|
709
|
+
description: req.task,
|
|
710
|
+
tags: ['coordinate', 'assign'],
|
|
711
|
+
}, dispatchCwd);
|
|
712
|
+
assignmentId = assignment.id;
|
|
713
|
+
artifacts.push({ type: 'assignment', id: assignment.id });
|
|
714
|
+
}
|
|
715
|
+
catch (err) {
|
|
716
|
+
warnings.push(`Assignment creation failed for ${agentName}: ${err instanceof Error ? err.message : String(err)}`);
|
|
717
|
+
}
|
|
718
|
+
const assignBrief = buildCoordinateBrief(agentName, req.task, {
|
|
719
|
+
claimId,
|
|
720
|
+
scope: assignScope,
|
|
721
|
+
worktreePath: claimResult.worktreePath,
|
|
722
|
+
assignmentId,
|
|
723
|
+
});
|
|
724
|
+
const queued = queueCoordinateMessage({
|
|
725
|
+
agent: agentName,
|
|
726
|
+
text: assignBrief,
|
|
727
|
+
messageType: 'assign',
|
|
728
|
+
ref: assignScope,
|
|
729
|
+
scope: assignScope,
|
|
730
|
+
requiresAck: true,
|
|
731
|
+
claimId,
|
|
732
|
+
assignmentId,
|
|
733
|
+
tags: ['coordinate', 'assign'],
|
|
734
|
+
payload: {
|
|
735
|
+
intent: req.intent,
|
|
736
|
+
scope: assignScope,
|
|
737
|
+
claim_id: claimId,
|
|
738
|
+
...(assignmentId ? { assignment_id: assignmentId } : {}),
|
|
739
|
+
worktree_path: claimResult.worktreePath,
|
|
740
|
+
constraints: req.constraints,
|
|
741
|
+
},
|
|
742
|
+
commandMode: 'worker',
|
|
743
|
+
});
|
|
744
|
+
if (assignmentId) {
|
|
745
|
+
try {
|
|
746
|
+
attachAssignmentMessageToClaim(claimId, queued.entry.message_id, dispatchCwd);
|
|
747
|
+
linkClaimToAssignment(claimId, assignmentId, dispatchCwd);
|
|
748
|
+
transitionAssignment(assignmentId, 'offered', { actor: senderAgent }, dispatchCwd);
|
|
749
|
+
patchAssignmentMessageId(assignmentId, queued.entry.message_id, dispatchCwd);
|
|
750
|
+
queued.entry.assignment_id = assignmentId;
|
|
751
|
+
}
|
|
752
|
+
catch (err) {
|
|
753
|
+
warnings.push(`Assignment linkage failed for ${agentName}: ${err instanceof Error ? err.message : String(err)}`);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
delivery_plan.push(queued.entry);
|
|
757
|
+
preparedInvokes.push({ entry: queued.entry, invoke: queued.invoke, worktreePath: claimResult.worktreePath });
|
|
758
|
+
}
|
|
759
|
+
// E2E execution phase: attempt to spawn assigned agents
|
|
760
|
+
const overallExecStatus = await runCoordinateExecution(preparedInvokes, {
|
|
761
|
+
autoExecute: effectiveAutoExecute !== false,
|
|
762
|
+
senderAgent, senderAgentId, cwd: dispatchCwd, warnings,
|
|
763
|
+
});
|
|
764
|
+
result = {
|
|
765
|
+
selected_targets: resolvedAgents,
|
|
766
|
+
delivery_plan,
|
|
767
|
+
messages_sent: toMessageSummary(delivery_plan),
|
|
768
|
+
commands: commandHints,
|
|
769
|
+
execution_status: overallExecStatus,
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
else if (req.intent === 'consult') {
|
|
773
|
+
// pln#626 Phase 1 — consult is inbox-only by design: it delivers an RFC to
|
|
774
|
+
// the target inbox(es) and never spawns. autoExecute is a no-op here, so
|
|
775
|
+
// say so explicitly rather than silently ignoring a caller who set it.
|
|
776
|
+
if (req.autoExecute === true) {
|
|
777
|
+
warnings.push("autoExecute has no effect on intent='consult': consult delivers the RFC to the target inbox(es) only and never spawns an agent — targets pick it up via their own bclaw_work. For real spawning use bclaw_dispatch(intent='execute') on a sequence, or intent='assign'/'review' (pln#626).");
|
|
778
|
+
}
|
|
779
|
+
const consultThreadId = req.threadId ?? `thread_${crypto.randomBytes(4).toString('hex')}`;
|
|
780
|
+
const contacted = [];
|
|
781
|
+
const delivery_plan = [];
|
|
782
|
+
for (const agentName of resolvedAgents) {
|
|
783
|
+
const profile = getCapabilityProfile(agentName);
|
|
784
|
+
if (!profile) {
|
|
785
|
+
warnings.push(`Unknown agent profile: ${agentName}`);
|
|
786
|
+
continue;
|
|
787
|
+
}
|
|
788
|
+
delivery_plan.push(queueCoordinateMessage({
|
|
789
|
+
agent: agentName,
|
|
790
|
+
text: req.task,
|
|
791
|
+
messageType: 'rfc',
|
|
792
|
+
scope: req.scope,
|
|
793
|
+
threadId: consultThreadId,
|
|
794
|
+
tags: ['coordinate', 'consult'],
|
|
795
|
+
payload: {
|
|
796
|
+
intent: req.intent,
|
|
797
|
+
scope: req.scope,
|
|
798
|
+
constraints: req.constraints,
|
|
799
|
+
},
|
|
800
|
+
commandMode: 'consult',
|
|
801
|
+
}).entry);
|
|
802
|
+
contacted.push(agentName);
|
|
803
|
+
}
|
|
804
|
+
result = {
|
|
805
|
+
selected_targets: resolvedAgents,
|
|
806
|
+
contacted,
|
|
807
|
+
thread_id: consultThreadId,
|
|
808
|
+
delivery_plan,
|
|
809
|
+
messages_sent: toMessageSummary(delivery_plan),
|
|
810
|
+
commands: commandHints,
|
|
811
|
+
// pln#626 Phase 1 — consult is inbox-only, so it reports that honestly
|
|
812
|
+
// instead of omitting execution_status (which read as "maybe it spawned").
|
|
813
|
+
execution_status: 'inbox_only',
|
|
814
|
+
execution_reason: 'intent_inbox_only',
|
|
815
|
+
};
|
|
816
|
+
}
|
|
817
|
+
else if (req.intent === 'review') {
|
|
818
|
+
// Cap the implicit fan-out so that omitting `targetAgents` on an
|
|
819
|
+
// open_loop review doesn't mint a loop with a reviewer slot per
|
|
820
|
+
// spawnable agent. The cap only applies to the implicit case —
|
|
821
|
+
// callers who explicitly list targetAgents get the full list.
|
|
822
|
+
const REVIEW_OPEN_LOOP_FANOUT_CAP = 3;
|
|
823
|
+
const implicitFanout = !(req.targetAgents && req.targetAgents.length > 0);
|
|
824
|
+
let loopReviewerAgents = resolvedAgents;
|
|
825
|
+
const preReviewWarnings = [];
|
|
826
|
+
if (req.open_loop === true && implicitFanout && resolvedAgents.length > REVIEW_OPEN_LOOP_FANOUT_CAP) {
|
|
827
|
+
loopReviewerAgents = resolvedAgents.slice(0, REVIEW_OPEN_LOOP_FANOUT_CAP);
|
|
828
|
+
preReviewWarnings.push(`open_loop: implicit reviewer fan-out capped at ${REVIEW_OPEN_LOOP_FANOUT_CAP} of ${resolvedAgents.length} spawnable agents; pass targetAgents to override`);
|
|
829
|
+
}
|
|
830
|
+
// pln#533 — pre-flight the reviewer agents with a trivial validation
|
|
831
|
+
// spawn BEFORE opening the loop, so an environment death (config
|
|
832
|
+
// rejected, auth fail, model mismatch) surfaces instantly with a clear
|
|
833
|
+
// reason instead of a generic "did not acknowledge" loop timeout. Drop
|
|
834
|
+
// the agents that fail and surface their reasons; if none survive the
|
|
835
|
+
// existing length===0 guard skips loop creation. Skipped when open_loop
|
|
836
|
+
// is off, preflight=false, or BRAINCLAW_NO_SPAWN is set (handled inside
|
|
837
|
+
// preflightAgents). Cross-project dispatch never auto-spawns, so skip.
|
|
838
|
+
if (req.open_loop === true && req.preflight !== false && !req.project && loopReviewerAgents.length > 0) {
|
|
839
|
+
try {
|
|
840
|
+
const { preflightAgents } = await import('../core/spawn-check.js');
|
|
841
|
+
const pf = await preflightAgents(loopReviewerAgents, { cwd: dispatchCwd });
|
|
842
|
+
if (pf.blocked.length > 0) {
|
|
843
|
+
const healthy = loopReviewerAgents.filter((a) => !pf.blocked.some((b) => b.agent === a));
|
|
844
|
+
for (const b of pf.blocked) {
|
|
845
|
+
preReviewWarnings.push(`pre-flight: dropped reviewer '${b.agent}' — ${b.reason}.${b.recommended_next_action ? ` ${b.recommended_next_action}` : ''}`);
|
|
846
|
+
}
|
|
847
|
+
loopReviewerAgents = healthy;
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
catch (pfErr) {
|
|
851
|
+
// Pre-flight is best-effort: a failure here must not block the review.
|
|
852
|
+
preReviewWarnings.push(`pre-flight: skipped (check threw: ${pfErr instanceof Error ? pfErr.message : String(pfErr)})`);
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
// Lazy-import the loops module once before defining performReview so
|
|
856
|
+
// the synchronous withLoopLock work callback can use it without
|
|
857
|
+
// re-importing inside the callback.
|
|
858
|
+
const loopsModuleRef = await import('../core/loops/index.js');
|
|
859
|
+
const performReview = () => {
|
|
860
|
+
const out = {
|
|
861
|
+
candidateId: '',
|
|
862
|
+
artifacts: [],
|
|
863
|
+
sideEffects: [],
|
|
864
|
+
warnings: [...preReviewWarnings],
|
|
865
|
+
partial: false,
|
|
866
|
+
preparedReviews: [],
|
|
867
|
+
};
|
|
868
|
+
const candId = generateCandidateIdWithLabel(dispatchCwd);
|
|
869
|
+
saveCandidate({
|
|
870
|
+
id: candId.id,
|
|
871
|
+
short_label: candId.short_label,
|
|
872
|
+
type: 'handoff',
|
|
873
|
+
text: req.task,
|
|
874
|
+
created_at: nowISO(),
|
|
875
|
+
author: senderAgent,
|
|
876
|
+
author_id: senderAgentId,
|
|
877
|
+
tags: ['review'],
|
|
878
|
+
status: 'pending',
|
|
879
|
+
star_count: 0,
|
|
880
|
+
starred_by: [],
|
|
881
|
+
usage_count: 0,
|
|
882
|
+
usage_events: [],
|
|
883
|
+
...(req.scope ? { related_paths: [req.scope] } : {}),
|
|
884
|
+
}, dispatchCwd);
|
|
885
|
+
out.candidateId = candId.id;
|
|
886
|
+
out.artifacts.push({ type: 'candidate', id: candId.id });
|
|
887
|
+
out.sideEffects.push({ action: 'create', entity: 'candidate', id: candId.id });
|
|
888
|
+
if (req.open_loop === true && loopReviewerAgents.length === 0) {
|
|
889
|
+
out.warnings.push('open_loop: no reviewer targets resolved; skipped loop creation — candidate preserved');
|
|
890
|
+
return out;
|
|
891
|
+
}
|
|
892
|
+
if (req.open_loop !== true) {
|
|
893
|
+
return out;
|
|
894
|
+
}
|
|
895
|
+
try {
|
|
896
|
+
const { openLoop, add_artifact, advance, turn } = loopsModuleRef;
|
|
897
|
+
const senderIdentity = ((senderAgentId ? findAgentIdentityById(senderAgentId, dispatchCwd) : undefined)
|
|
898
|
+
?? findAgentIdentityByName(senderAgent, dispatchCwd)
|
|
899
|
+
?? ensureAgentRegisteredForDispatch(senderAgent, dispatchCwd));
|
|
900
|
+
const authorAgentId = senderIdentity?.agent_id ?? senderAgentId;
|
|
901
|
+
const creatorActor = authorAgentId ?? senderAgent;
|
|
902
|
+
const slots = [
|
|
903
|
+
{
|
|
904
|
+
role: 'author',
|
|
905
|
+
agent: senderAgent,
|
|
906
|
+
...(authorAgentId ? { agent_id: authorAgentId } : {}),
|
|
907
|
+
},
|
|
908
|
+
...loopReviewerAgents.map((agent) => {
|
|
909
|
+
const reviewerIdentity = findAgentIdentityByName(agent, dispatchCwd) ?? ensureAgentRegisteredForDispatch(agent, dispatchCwd);
|
|
910
|
+
return {
|
|
911
|
+
role: 'reviewer',
|
|
912
|
+
agent,
|
|
913
|
+
...(reviewerIdentity?.agent_id ? { agent_id: reviewerIdentity.agent_id } : {}),
|
|
914
|
+
};
|
|
915
|
+
}),
|
|
916
|
+
];
|
|
917
|
+
const loop = openLoop({
|
|
918
|
+
kind: 'review',
|
|
919
|
+
title: req.task.slice(0, 120),
|
|
920
|
+
created_by: creatorActor,
|
|
921
|
+
slots,
|
|
922
|
+
mode: req.review_mode ?? 'asymmetric',
|
|
923
|
+
}, dispatchCwd);
|
|
924
|
+
out.loopId = loop.id;
|
|
925
|
+
out.artifacts.push({ type: 'loop', id: loop.id });
|
|
926
|
+
out.sideEffects.push({ action: 'create', entity: 'loop', id: loop.id });
|
|
927
|
+
add_artifact({
|
|
928
|
+
id: loop.id,
|
|
929
|
+
actor: creatorActor,
|
|
930
|
+
artifact: {
|
|
931
|
+
phase: 'change_summary',
|
|
932
|
+
type: 'change_summary',
|
|
933
|
+
ref: { kind: 'candidate', id: candId.id },
|
|
934
|
+
},
|
|
935
|
+
}, dispatchCwd);
|
|
936
|
+
const advanced = advance({ id: loop.id, actor: creatorActor }, dispatchCwd);
|
|
937
|
+
const reviewerSlots = advanced.loop.slots.filter((s) => s.role === 'reviewer');
|
|
938
|
+
for (const slot of reviewerSlots) {
|
|
939
|
+
// pln#458 stp_daffa477: turn() is pure state mutation — it does
|
|
940
|
+
// NOT spawn the reviewer. Without the linkage below, the loop
|
|
941
|
+
// stays "assigned" forever and no work ever runs (symptom
|
|
942
|
+
// observed on lop_0a0cb84a7bf8dd92). Build the same claim +
|
|
943
|
+
// assignment + queued message chain as intent=assign so that
|
|
944
|
+
// the downstream runCoordinateExecution actually spawns.
|
|
945
|
+
// pln#628 Focus 4B (Codex review of #87, BLOCKING 2): turn() is now
|
|
946
|
+
// called AFTER the claim + assignment exist so the reviewer slot is
|
|
947
|
+
// BOUND to its assignment_id/claim_id. Previously turn() ran first with
|
|
948
|
+
// no ids, so a harvest could only match reviewer slots by agent name —
|
|
949
|
+
// which completes the WRONG slot in symmetric (multi-reviewer) mode.
|
|
950
|
+
try {
|
|
951
|
+
const reviewScope = `review-loop:${loop.id}`;
|
|
952
|
+
const reviewDescription = `Review loop turn for ${loop.id} slot ${slot.slot_id} phase findings. `
|
|
953
|
+
+ `Mode: ${advanced.loop.protocol?.review_mode ?? 'asymmetric'}. ${req.task}`;
|
|
954
|
+
const claimResult = createCoordinatorClaim({
|
|
955
|
+
agent: slot.agent ?? '',
|
|
956
|
+
scope: reviewScope,
|
|
957
|
+
description: reviewDescription,
|
|
958
|
+
dispatcherAgent: senderAgent,
|
|
959
|
+
sessionId: connectionSessionId,
|
|
960
|
+
cwd: dispatchCwd,
|
|
961
|
+
worktreeBaseRef: req.ref,
|
|
962
|
+
});
|
|
963
|
+
if (claimResult.worktreeWarning)
|
|
964
|
+
out.warnings.push(claimResult.worktreeWarning);
|
|
965
|
+
out.artifacts.push({ type: 'claim', id: claimResult.claimId });
|
|
966
|
+
out.sideEffects.push({
|
|
967
|
+
action: claimResult.reusedExisting ? 'reuse' : 'create',
|
|
968
|
+
entity: 'claim',
|
|
969
|
+
id: claimResult.claimId,
|
|
970
|
+
});
|
|
971
|
+
let reviewAssignmentId;
|
|
972
|
+
try {
|
|
973
|
+
const preId = generateAssignmentId(dispatchCwd);
|
|
974
|
+
const assignment = createAssignment({
|
|
975
|
+
id: preId.id,
|
|
976
|
+
short_label: preId.short_label,
|
|
977
|
+
claim_id: claimResult.claimId,
|
|
978
|
+
agent: slot.agent ?? '',
|
|
979
|
+
dispatcher_agent: senderAgent,
|
|
980
|
+
dispatcher_session_id: connectionSessionId,
|
|
981
|
+
scope: reviewScope,
|
|
982
|
+
description: reviewDescription,
|
|
983
|
+
tags: ['coordinate', 'review', 'loop'],
|
|
984
|
+
}, dispatchCwd);
|
|
985
|
+
reviewAssignmentId = assignment.id;
|
|
986
|
+
out.artifacts.push({ type: 'assignment', id: assignment.id });
|
|
987
|
+
}
|
|
988
|
+
catch (asgErr) {
|
|
989
|
+
out.warnings.push(`Review assignment creation failed for slot ${slot.slot_id}: ${asgErr instanceof Error ? asgErr.message : String(asgErr)}`);
|
|
990
|
+
}
|
|
991
|
+
// pln#628 Focus 4B (BLOCKING 2) — assign the slot NOW that the
|
|
992
|
+
// claim/assignment exist, binding their ids onto the slot so the
|
|
993
|
+
// harvest close resolves this exact reviewer by assignment_id. Runs
|
|
994
|
+
// even if assignment creation failed (undefined id → the harvest
|
|
995
|
+
// falls back to the legacy agent match for this one slot).
|
|
996
|
+
turn({
|
|
997
|
+
id: loop.id,
|
|
998
|
+
slot_id: slot.slot_id,
|
|
999
|
+
actor: creatorActor,
|
|
1000
|
+
input: req.task,
|
|
1001
|
+
assignment_id: reviewAssignmentId,
|
|
1002
|
+
claim_id: claimResult.claimId,
|
|
1003
|
+
}, dispatchCwd);
|
|
1004
|
+
const reviewBrief = buildCoordinateBrief(slot.agent ?? '', reviewDescription + reviewVerdictBriefSuffix, {
|
|
1005
|
+
claimId: claimResult.claimId,
|
|
1006
|
+
scope: reviewScope,
|
|
1007
|
+
worktreePath: claimResult.worktreePath,
|
|
1008
|
+
assignmentId: reviewAssignmentId,
|
|
1009
|
+
});
|
|
1010
|
+
const queued = queueCoordinateMessage({
|
|
1011
|
+
agent: slot.agent ?? '',
|
|
1012
|
+
text: reviewBrief,
|
|
1013
|
+
messageType: 'review',
|
|
1014
|
+
ref: loop.id,
|
|
1015
|
+
scope: reviewScope,
|
|
1016
|
+
requiresAck: true,
|
|
1017
|
+
claimId: claimResult.claimId,
|
|
1018
|
+
assignmentId: reviewAssignmentId,
|
|
1019
|
+
tags: ['coordinate', 'review', 'loop'],
|
|
1020
|
+
payload: {
|
|
1021
|
+
intent: 'review',
|
|
1022
|
+
loop_id: loop.id,
|
|
1023
|
+
slot_id: slot.slot_id,
|
|
1024
|
+
phase: 'findings',
|
|
1025
|
+
scope: reviewScope,
|
|
1026
|
+
claim_id: claimResult.claimId,
|
|
1027
|
+
...(reviewAssignmentId ? { assignment_id: reviewAssignmentId } : {}),
|
|
1028
|
+
worktree_path: claimResult.worktreePath,
|
|
1029
|
+
},
|
|
1030
|
+
commandMode: 'worker',
|
|
1031
|
+
});
|
|
1032
|
+
if (reviewAssignmentId) {
|
|
1033
|
+
try {
|
|
1034
|
+
attachAssignmentMessageToClaim(claimResult.claimId, queued.entry.message_id, dispatchCwd);
|
|
1035
|
+
linkClaimToAssignment(claimResult.claimId, reviewAssignmentId, dispatchCwd);
|
|
1036
|
+
transitionAssignment(reviewAssignmentId, 'offered', { actor: senderAgent }, dispatchCwd);
|
|
1037
|
+
patchAssignmentMessageId(reviewAssignmentId, queued.entry.message_id, dispatchCwd);
|
|
1038
|
+
queued.entry.assignment_id = reviewAssignmentId;
|
|
1039
|
+
}
|
|
1040
|
+
catch (linkErr) {
|
|
1041
|
+
out.warnings.push(`Review assignment linkage failed for ${reviewAssignmentId}: ${linkErr instanceof Error ? linkErr.message : String(linkErr)}`);
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
out.preparedReviews.push({
|
|
1045
|
+
entry: queued.entry,
|
|
1046
|
+
invoke: queued.invoke,
|
|
1047
|
+
worktreePath: claimResult.worktreePath,
|
|
1048
|
+
});
|
|
1049
|
+
}
|
|
1050
|
+
catch (dispatchErr) {
|
|
1051
|
+
out.partial = true;
|
|
1052
|
+
out.warnings.push(`open_loop: reviewer dispatch linkage failed for slot ${slot.slot_id} (${dispatchErr instanceof Error ? dispatchErr.message : String(dispatchErr)}); loop remains open without spawn`);
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
catch (loopErr) {
|
|
1057
|
+
out.partial = true;
|
|
1058
|
+
const loopErrMsg = loopErr instanceof Error ? loopErr.message : String(loopErr);
|
|
1059
|
+
out.warnings.push(`open_loop: failed to open review loop (${loopErrMsg}); candidate ${out.candidateId} still created`);
|
|
1060
|
+
}
|
|
1061
|
+
return out;
|
|
1062
|
+
};
|
|
1063
|
+
const useIdempotency = Boolean(req.client_request_id && senderAgentId && req.open_loop === true);
|
|
1064
|
+
let output;
|
|
1065
|
+
try {
|
|
1066
|
+
if (useIdempotency) {
|
|
1067
|
+
const { client_request_id: _crid, ...hashablePayload } = req;
|
|
1068
|
+
output = loopsModuleRef.withLoopLock({
|
|
1069
|
+
cwd: dispatchCwd,
|
|
1070
|
+
intent: 'coordinate_review',
|
|
1071
|
+
agentId: senderAgentId,
|
|
1072
|
+
scope: { kind: 'open_idempotency', clientRequestId: req.client_request_id },
|
|
1073
|
+
clientRequestId: req.client_request_id,
|
|
1074
|
+
requestPayload: hashablePayload,
|
|
1075
|
+
work: () => performReview(),
|
|
1076
|
+
});
|
|
1077
|
+
}
|
|
1078
|
+
else {
|
|
1079
|
+
output = performReview();
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
catch (err) {
|
|
1083
|
+
if (err instanceof loopsModuleRef.IdempotencyKeyReusedError) {
|
|
1084
|
+
return {
|
|
1085
|
+
response: createToolErrorResponse('idempotency_key_reused_with_different_body', err.message, { stored_hash: err.storedHash, submitted_hash: err.submittedHash }),
|
|
1086
|
+
};
|
|
1087
|
+
}
|
|
1088
|
+
throw err;
|
|
1089
|
+
}
|
|
1090
|
+
artifacts.push(...output.artifacts);
|
|
1091
|
+
side_effects.push(...output.sideEffects);
|
|
1092
|
+
warnings.push(...output.warnings);
|
|
1093
|
+
if (output.partial)
|
|
1094
|
+
facadeStatus = 'partial';
|
|
1095
|
+
// pln#458 stp_daffa477: spawn reviewers OUTSIDE the idempotency lock
|
|
1096
|
+
// so the async spawn work doesn't widen the critical section. Skipped
|
|
1097
|
+
// when there's nothing to spawn (no open_loop, or no reviewer slots).
|
|
1098
|
+
let reviewExecStatus;
|
|
1099
|
+
if (output.preparedReviews.length > 0) {
|
|
1100
|
+
reviewExecStatus = await runCoordinateExecution(output.preparedReviews, {
|
|
1101
|
+
autoExecute: effectiveAutoExecute !== false,
|
|
1102
|
+
senderAgent, senderAgentId, cwd: dispatchCwd, warnings,
|
|
1103
|
+
});
|
|
1104
|
+
}
|
|
1105
|
+
result = {
|
|
1106
|
+
candidate_id: output.candidateId,
|
|
1107
|
+
selected_targets: resolvedAgents,
|
|
1108
|
+
// pln#626 Phase 1 (review rework) — expose the reviewer delivery entries
|
|
1109
|
+
// so review is as honest as assign/reroute: each entry's execution_reason
|
|
1110
|
+
// (set by runCoordinateExecution) feeds the top-level derivation, so a
|
|
1111
|
+
// manual (autoExecute=false) open_loop review no longer hides WHY it
|
|
1112
|
+
// didn't spawn. Empty when there was nothing to dispatch (plain review).
|
|
1113
|
+
delivery_plan: output.preparedReviews.map((p) => p.entry),
|
|
1114
|
+
...(output.loopId ? { loop_id: output.loopId } : {}),
|
|
1115
|
+
...(reviewExecStatus ? { execution_status: reviewExecStatus } : {}),
|
|
1116
|
+
};
|
|
1117
|
+
}
|
|
1118
|
+
else if (req.intent === 'reroute') {
|
|
1119
|
+
const activeClaims = listClaims(dispatchCwd).filter((c) => c.status === 'active' && (req.scope ? c.scope === req.scope : true));
|
|
1120
|
+
if (activeClaims.length === 0) {
|
|
1121
|
+
return { response: createToolErrorResponse('not_found', `No active claim found for scope: ${req.scope ?? '(any)'}`) };
|
|
1122
|
+
}
|
|
1123
|
+
const oldClaim = activeClaims[0];
|
|
1124
|
+
saveClaim({ ...oldClaim, status: 'released', released_at: nowISO() }, dispatchCwd);
|
|
1125
|
+
appendAuditEntry({ actor: oldClaim.agent, action: 'release_claim', item_id: oldClaim.id, item_type: 'claim', scope: oldClaim.scope }, dispatchCwd);
|
|
1126
|
+
side_effects.push({ action: 'release', entity: 'claim', id: oldClaim.id });
|
|
1127
|
+
// trp#61: supersede assignments attached to the old claim so they
|
|
1128
|
+
// don't linger in `created`/`offered`/etc. Prior behaviour only
|
|
1129
|
+
// released the claim, leaving the assignment FSM stuck and confusing
|
|
1130
|
+
// dispatch analysis / review.
|
|
1131
|
+
const { listAssignments: listAsgn } = await import('../core/assignments.js');
|
|
1132
|
+
const predecessors = listAsgn(dispatchCwd, { claim_id: oldClaim.id })
|
|
1133
|
+
.filter((a) => a.status !== 'completed' && a.status !== 'cancelled' && a.status !== 'expired' && a.status !== 'rerouted');
|
|
1134
|
+
for (const predecessor of predecessors) {
|
|
1135
|
+
try {
|
|
1136
|
+
transitionAssignment(predecessor.id, 'rerouted', {
|
|
1137
|
+
actor: senderAgent,
|
|
1138
|
+
status_reason: `reroute: claim ${oldClaim.id} reassigned`,
|
|
1139
|
+
}, dispatchCwd);
|
|
1140
|
+
side_effects.push({ action: 'update', entity: 'assignment', id: predecessor.id });
|
|
1141
|
+
}
|
|
1142
|
+
catch (err) {
|
|
1143
|
+
warnings.push(`Failed to close predecessor assignment ${predecessor.id}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
const newAgentName = resolvedAgents.find((a) => a !== oldClaim.agent) ?? resolvedAgents[0];
|
|
1147
|
+
let newClaimId;
|
|
1148
|
+
if (newAgentName) {
|
|
1149
|
+
// trp#51: validate target agent before creating a new claim.
|
|
1150
|
+
const check = validateAgentForDispatch(newAgentName, { requireSpawnable: true });
|
|
1151
|
+
if (!check.valid) {
|
|
1152
|
+
warnings.push(JSON.stringify({
|
|
1153
|
+
warning: 'agent_validation_failed',
|
|
1154
|
+
agent: newAgentName,
|
|
1155
|
+
code: check.code,
|
|
1156
|
+
reason: check.reason,
|
|
1157
|
+
}));
|
|
1158
|
+
}
|
|
1159
|
+
const profile = check.profile;
|
|
1160
|
+
if (check.valid && profile) {
|
|
1161
|
+
ensureAgentRegisteredForDispatch(newAgentName, dispatchCwd);
|
|
1162
|
+
const rerouteClaimResult = createCoordinatorClaim({
|
|
1163
|
+
agent: newAgentName,
|
|
1164
|
+
scope: oldClaim.scope,
|
|
1165
|
+
description: req.task,
|
|
1166
|
+
dispatcherAgent: senderAgent,
|
|
1167
|
+
sessionId: connectionSessionId,
|
|
1168
|
+
cwd: dispatchCwd,
|
|
1169
|
+
worktreeBaseRef: req.ref,
|
|
1170
|
+
});
|
|
1171
|
+
newClaimId = rerouteClaimResult.claimId;
|
|
1172
|
+
if (rerouteClaimResult.worktreeWarning) {
|
|
1173
|
+
warnings.push(rerouteClaimResult.worktreeWarning);
|
|
1174
|
+
}
|
|
1175
|
+
artifacts.push({ type: 'claim', id: newClaimId });
|
|
1176
|
+
side_effects.push({
|
|
1177
|
+
action: rerouteClaimResult.reusedExisting ? 'reuse' : 'create',
|
|
1178
|
+
entity: 'claim',
|
|
1179
|
+
id: newClaimId,
|
|
1180
|
+
});
|
|
1181
|
+
let rerouteAssignmentId;
|
|
1182
|
+
try {
|
|
1183
|
+
const preId = generateAssignmentId(dispatchCwd);
|
|
1184
|
+
const assignment = createAssignment({
|
|
1185
|
+
id: preId.id,
|
|
1186
|
+
short_label: preId.short_label,
|
|
1187
|
+
claim_id: newClaimId,
|
|
1188
|
+
agent: newAgentName,
|
|
1189
|
+
dispatcher_agent: senderAgent,
|
|
1190
|
+
dispatcher_session_id: connectionSessionId,
|
|
1191
|
+
scope: oldClaim.scope,
|
|
1192
|
+
description: req.task,
|
|
1193
|
+
tags: ['coordinate', 'assign', 'reroute'],
|
|
1194
|
+
}, dispatchCwd);
|
|
1195
|
+
rerouteAssignmentId = assignment.id;
|
|
1196
|
+
artifacts.push({ type: 'assignment', id: assignment.id });
|
|
1197
|
+
}
|
|
1198
|
+
catch (err) {
|
|
1199
|
+
warnings.push(`Assignment creation failed for ${newAgentName}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1200
|
+
}
|
|
1201
|
+
const rerouteBrief = buildCoordinateBrief(newAgentName, req.task, {
|
|
1202
|
+
claimId: newClaimId,
|
|
1203
|
+
scope: oldClaim.scope,
|
|
1204
|
+
worktreePath: rerouteClaimResult.worktreePath,
|
|
1205
|
+
assignmentId: rerouteAssignmentId,
|
|
1206
|
+
});
|
|
1207
|
+
const delivery_plan = [];
|
|
1208
|
+
const reroutePrepared = [];
|
|
1209
|
+
const queued = queueCoordinateMessage({
|
|
1210
|
+
agent: newAgentName,
|
|
1211
|
+
text: rerouteBrief,
|
|
1212
|
+
messageType: 'assign',
|
|
1213
|
+
ref: oldClaim.scope,
|
|
1214
|
+
scope: oldClaim.scope,
|
|
1215
|
+
requiresAck: true,
|
|
1216
|
+
claimId: newClaimId,
|
|
1217
|
+
assignmentId: rerouteAssignmentId,
|
|
1218
|
+
releasedClaimId: oldClaim.id,
|
|
1219
|
+
tags: ['coordinate', 'assign', 'reroute'],
|
|
1220
|
+
payload: {
|
|
1221
|
+
intent: req.intent,
|
|
1222
|
+
scope: oldClaim.scope,
|
|
1223
|
+
claim_id: newClaimId,
|
|
1224
|
+
...(rerouteAssignmentId ? { assignment_id: rerouteAssignmentId } : {}),
|
|
1225
|
+
worktree_path: rerouteClaimResult.worktreePath,
|
|
1226
|
+
released_claim_id: oldClaim.id,
|
|
1227
|
+
previous_agent: oldClaim.agent,
|
|
1228
|
+
constraints: req.constraints,
|
|
1229
|
+
},
|
|
1230
|
+
commandMode: 'worker',
|
|
1231
|
+
});
|
|
1232
|
+
if (rerouteAssignmentId) {
|
|
1233
|
+
try {
|
|
1234
|
+
attachAssignmentMessageToClaim(newClaimId, queued.entry.message_id, dispatchCwd);
|
|
1235
|
+
linkClaimToAssignment(newClaimId, rerouteAssignmentId, dispatchCwd);
|
|
1236
|
+
transitionAssignment(rerouteAssignmentId, 'offered', { actor: senderAgent }, dispatchCwd);
|
|
1237
|
+
patchAssignmentMessageId(rerouteAssignmentId, queued.entry.message_id, dispatchCwd);
|
|
1238
|
+
queued.entry.assignment_id = rerouteAssignmentId;
|
|
1239
|
+
}
|
|
1240
|
+
catch (err) {
|
|
1241
|
+
warnings.push(`Assignment linkage failed for ${newAgentName}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
delivery_plan.push(queued.entry);
|
|
1245
|
+
reroutePrepared.push({ entry: queued.entry, invoke: queued.invoke, worktreePath: rerouteClaimResult.worktreePath });
|
|
1246
|
+
const rerouteExecStatus = await runCoordinateExecution(reroutePrepared, {
|
|
1247
|
+
autoExecute: effectiveAutoExecute !== false,
|
|
1248
|
+
senderAgent, senderAgentId, cwd: dispatchCwd, warnings,
|
|
1249
|
+
});
|
|
1250
|
+
result = {
|
|
1251
|
+
released_claim: oldClaim.id,
|
|
1252
|
+
old_agent: oldClaim.agent,
|
|
1253
|
+
new_agent: newAgentName,
|
|
1254
|
+
new_claim_id: newClaimId,
|
|
1255
|
+
selected_targets: resolvedAgents,
|
|
1256
|
+
delivery_plan,
|
|
1257
|
+
messages_sent: toMessageSummary(delivery_plan),
|
|
1258
|
+
commands: commandHints,
|
|
1259
|
+
execution_status: rerouteExecStatus,
|
|
1260
|
+
};
|
|
1261
|
+
}
|
|
1262
|
+
else {
|
|
1263
|
+
warnings.push(`Unknown agent profile: ${newAgentName}`);
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
if (!('released_claim' in result)) {
|
|
1267
|
+
result = {
|
|
1268
|
+
released_claim: oldClaim.id,
|
|
1269
|
+
old_agent: oldClaim.agent,
|
|
1270
|
+
new_agent: newAgentName,
|
|
1271
|
+
new_claim_id: newClaimId,
|
|
1272
|
+
selected_targets: resolvedAgents,
|
|
1273
|
+
delivery_plan: [],
|
|
1274
|
+
messages_sent: [],
|
|
1275
|
+
commands: commandHints,
|
|
1276
|
+
};
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
else if (req.intent === 'summarize') {
|
|
1280
|
+
const threadId = req.threadId ?? req.scope;
|
|
1281
|
+
if (!threadId) {
|
|
1282
|
+
return { response: createToolErrorResponse('validation_error', 'summarize intent requires threadId or scope') };
|
|
1283
|
+
}
|
|
1284
|
+
const messages = getThread(threadId, dispatchCwd, { truncateText: 500 });
|
|
1285
|
+
const summary = messages.length === 0
|
|
1286
|
+
? 'No messages found in thread.'
|
|
1287
|
+
: messages.map((m, i) => `[${i + 1}] ${m.from} → ${m.to}: ${m.text}`).join('\n');
|
|
1288
|
+
result = { thread_id: threadId, message_count: messages.length, summary };
|
|
1289
|
+
}
|
|
1290
|
+
else if (req.intent === 'ideate')
|
|
1291
|
+
ideate: {
|
|
1292
|
+
// pln#626 Phase 2 — multi-agent ideate now SPAWNS its critics as
|
|
1293
|
+
// worktree-isolated workers (Option B), so autoExecute IS honored on that
|
|
1294
|
+
// path (no longer a no-op). Single-agent / bootstrap ideate still opens the
|
|
1295
|
+
// loop for manual driving — covered by the "champion drives manually" and
|
|
1296
|
+
// "joined existing" warnings below, so no blanket no-op warning here.
|
|
1297
|
+
// pln#492 phase 2.c (open + proposal) + 2.d.2 (multi-agent dispatch).
|
|
1298
|
+
// Single-agent mode (no targetAgents): open the loop with the task
|
|
1299
|
+
// as a proposal seed and stop there — the champion drives manually
|
|
1300
|
+
// via bclaw_loop intent='turn' / 'advance'. Multi-agent mode
|
|
1301
|
+
// (targetAgents passed): also advance to critique and dispatch a
|
|
1302
|
+
// turn to each critic slot with a context-filtered, BM25-ranked,
|
|
1303
|
+
// size-capped brief assembled by buildIdeationBrief.
|
|
1304
|
+
//
|
|
1305
|
+
// pln#511 step 2 — when `req.preset` is set, the loop opens with
|
|
1306
|
+
// the preset's phases / stop_condition / protocol instead of the
|
|
1307
|
+
// kind-default ideation chain. Bootstrap preset enforces single-
|
|
1308
|
+
// agent / self-champion mode (validated above), so the multi-
|
|
1309
|
+
// agent dispatch branch never runs for it.
|
|
1310
|
+
//
|
|
1311
|
+
// pln#513 step 2 — labelled block (ideate:) so the bootstrap
|
|
1312
|
+
// join-or-lock path can break out early after assigning result.
|
|
1313
|
+
const loopsModuleRef = await import('../core/loops/index.js');
|
|
1314
|
+
const { openLoop, add_artifact, advance, turn, getLoop, buildIdeationBrief } = loopsModuleRef;
|
|
1315
|
+
const presetSelected = req.preset
|
|
1316
|
+
? (await import('../core/loops/presets/index.js')).PRESETS[req.preset]
|
|
1317
|
+
: undefined;
|
|
1318
|
+
// pln#513 step 2 — bootstrap join-or-lock. The bootstrap preset is
|
|
1319
|
+
// project-singleton: two concurrent callers must converge on the same
|
|
1320
|
+
// loop rather than open duplicates. Strategy:
|
|
1321
|
+
// 1. Find existing bootstrap loop in {open, paused} → join.
|
|
1322
|
+
// 2. Else check for an active coordination claim (someone is
|
|
1323
|
+
// mid-open). Re-find once; surface bootstrap_coordination_in_progress
|
|
1324
|
+
// if still nothing.
|
|
1325
|
+
// 3. Else acquire the lock, fall through to the normal open path,
|
|
1326
|
+
// release the lock on the way out (success or fail).
|
|
1327
|
+
// The lock is opportunistic, not blocking — a fast retry-in-place
|
|
1328
|
+
// not a wait-on-mutex. Keeps the verb short and predictable.
|
|
1329
|
+
// pln#518 step 1 — bootstrap join-or-lock now delegates to the shared
|
|
1330
|
+
// acquireBootstrapLoop helper (src/core/loops/bootstrap-acquire.ts).
|
|
1331
|
+
// Both the CLI and this MCP handler converge on the same singleton
|
|
1332
|
+
// acquire path, eliminating the race where two concurrent callers both
|
|
1333
|
+
// passed the local scan and called openLoop directly.
|
|
1334
|
+
const senderIdentity = ((senderAgentId ? findAgentIdentityById(senderAgentId, dispatchCwd) : undefined)
|
|
1335
|
+
?? findAgentIdentityByName(senderAgent, dispatchCwd)
|
|
1336
|
+
?? ensureAgentRegisteredForDispatch(senderAgent, dispatchCwd));
|
|
1337
|
+
const authorAgentId = senderIdentity?.agent_id ?? senderAgentId;
|
|
1338
|
+
const creatorActor = authorAgentId ?? senderAgent;
|
|
1339
|
+
let bootstrapOpenedLoop;
|
|
1340
|
+
if (req.preset === 'bootstrap') {
|
|
1341
|
+
const { acquireBootstrapLoop, BootstrapCoordinationInProgressError: BcipError } = await import('../core/loops/bootstrap-acquire.js');
|
|
1342
|
+
let acqResult;
|
|
1343
|
+
try {
|
|
1344
|
+
acqResult = acquireBootstrapLoop({
|
|
1345
|
+
actor: senderAgent,
|
|
1346
|
+
agent_id: authorAgentId,
|
|
1347
|
+
created_by: creatorActor,
|
|
1348
|
+
title: req.task.slice(0, 120),
|
|
1349
|
+
goal: req.scope,
|
|
1350
|
+
model: currentModel,
|
|
1351
|
+
}, dispatchCwd);
|
|
1352
|
+
}
|
|
1353
|
+
catch (err) {
|
|
1354
|
+
if (err instanceof BcipError) {
|
|
1355
|
+
return {
|
|
1356
|
+
response: createToolErrorResponse('bootstrap_coordination_in_progress', err.message),
|
|
1357
|
+
};
|
|
1358
|
+
}
|
|
1359
|
+
throw err;
|
|
1360
|
+
}
|
|
1361
|
+
warnings.push(...acqResult.warnings);
|
|
1362
|
+
if (acqResult.action === 'joined') {
|
|
1363
|
+
const jLoop = acqResult.loop;
|
|
1364
|
+
artifacts.push({ type: 'loop', id: jLoop.id });
|
|
1365
|
+
result = {
|
|
1366
|
+
loop_id: jLoop.id,
|
|
1367
|
+
joined_existing: true,
|
|
1368
|
+
current_phase: jLoop.current_phase,
|
|
1369
|
+
status: jLoop.status,
|
|
1370
|
+
mode: 'single_agent',
|
|
1371
|
+
preset: req.preset,
|
|
1372
|
+
// pln#626 Phase 1 — report inbox_only even on the join early-return,
|
|
1373
|
+
// so this path is not the one silent hole in the contract (R1).
|
|
1374
|
+
execution_status: 'inbox_only',
|
|
1375
|
+
execution_reason: 'intent_inbox_only',
|
|
1376
|
+
};
|
|
1377
|
+
// Skip the rest of the ideate flow — we joined an existing loop.
|
|
1378
|
+
break ideate;
|
|
1379
|
+
}
|
|
1380
|
+
// action === 'opened': helper already called openLoop + released lock.
|
|
1381
|
+
bootstrapOpenedLoop = acqResult.loop;
|
|
1382
|
+
}
|
|
1383
|
+
// pln#511 step 2 — bootstrap preset always runs in single-agent
|
|
1384
|
+
// mode: the champion drives the whole loop. Even when the caller
|
|
1385
|
+
// passes targetAgents=[caller] (validated as the only legal non-
|
|
1386
|
+
// empty form), we don't add critic slots and we don't take the
|
|
1387
|
+
// multi-agent dispatch branch. Treating that idiom as "single
|
|
1388
|
+
// agent / self-champion" matches what the constraint check
|
|
1389
|
+
// already enforced upstream.
|
|
1390
|
+
const explicitTargets = Boolean(req.targetAgents
|
|
1391
|
+
&& req.targetAgents.length > 0
|
|
1392
|
+
&& req.preset !== 'bootstrap');
|
|
1393
|
+
const slots = [
|
|
1394
|
+
{
|
|
1395
|
+
role: 'champion',
|
|
1396
|
+
agent: senderAgent,
|
|
1397
|
+
...(authorAgentId ? { agent_id: authorAgentId } : {}),
|
|
1398
|
+
},
|
|
1399
|
+
];
|
|
1400
|
+
if (explicitTargets) {
|
|
1401
|
+
for (const agent of req.targetAgents) {
|
|
1402
|
+
const criticIdentity = findAgentIdentityByName(agent, dispatchCwd) ?? ensureAgentRegisteredForDispatch(agent, dispatchCwd);
|
|
1403
|
+
slots.push({
|
|
1404
|
+
role: 'critic',
|
|
1405
|
+
agent,
|
|
1406
|
+
...(criticIdentity?.agent_id ? { agent_id: criticIdentity.agent_id } : {}),
|
|
1407
|
+
});
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
let loopId;
|
|
1411
|
+
let proposalArtifactId;
|
|
1412
|
+
if (bootstrapOpenedLoop) {
|
|
1413
|
+
// Bootstrap case: helper already opened the loop and released its lock.
|
|
1414
|
+
loopId = bootstrapOpenedLoop.id;
|
|
1415
|
+
artifacts.push({ type: 'loop', id: bootstrapOpenedLoop.id });
|
|
1416
|
+
side_effects.push({ action: 'create', entity: 'loop', id: bootstrapOpenedLoop.id });
|
|
1417
|
+
}
|
|
1418
|
+
else {
|
|
1419
|
+
try {
|
|
1420
|
+
const loop = openLoop({
|
|
1421
|
+
kind: 'ideation',
|
|
1422
|
+
title: req.task.slice(0, 120),
|
|
1423
|
+
goal: req.scope,
|
|
1424
|
+
created_by: creatorActor,
|
|
1425
|
+
slots,
|
|
1426
|
+
...(presetSelected
|
|
1427
|
+
? {
|
|
1428
|
+
phases: presetSelected.phases,
|
|
1429
|
+
stop_condition: presetSelected.stop_condition,
|
|
1430
|
+
protocol: presetSelected.protocol,
|
|
1431
|
+
}
|
|
1432
|
+
: {}),
|
|
1433
|
+
}, dispatchCwd);
|
|
1434
|
+
loopId = loop.id;
|
|
1435
|
+
artifacts.push({ type: 'loop', id: loop.id });
|
|
1436
|
+
side_effects.push({ action: 'create', entity: 'loop', id: loop.id });
|
|
1437
|
+
// pln#511 step 2 — skip the proposal-seed artifact when a preset
|
|
1438
|
+
// is in use. The kind-default ideation chain opens at phase
|
|
1439
|
+
// 'proposal' and the seed artifact lives there; presets define
|
|
1440
|
+
// their own initial phase + seeding semantics (bootstrap starts
|
|
1441
|
+
// at 'survey' and produces a signals_report). Forcing a
|
|
1442
|
+
// 'proposal'-phased artifact here would dangle on a phase the
|
|
1443
|
+
// loop doesn't contain. The task text is already captured on
|
|
1444
|
+
// the thread (title + goal).
|
|
1445
|
+
if (!presetSelected) {
|
|
1446
|
+
const proposalBody = req.task.slice(0, 4000);
|
|
1447
|
+
const updated = add_artifact({
|
|
1448
|
+
id: loop.id,
|
|
1449
|
+
actor: creatorActor,
|
|
1450
|
+
artifact: {
|
|
1451
|
+
phase: 'proposal',
|
|
1452
|
+
type: 'proposal',
|
|
1453
|
+
body: proposalBody,
|
|
1454
|
+
produced_by: creatorActor,
|
|
1455
|
+
},
|
|
1456
|
+
}, dispatchCwd);
|
|
1457
|
+
const lastArtifact = updated.artifacts[updated.artifacts.length - 1];
|
|
1458
|
+
proposalArtifactId = lastArtifact?.artifact_id;
|
|
1459
|
+
if (proposalArtifactId) {
|
|
1460
|
+
artifacts.push({ type: 'artifact', id: proposalArtifactId });
|
|
1461
|
+
side_effects.push({ action: 'create', entity: 'artifact', id: proposalArtifactId });
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
catch (err) {
|
|
1466
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1467
|
+
return {
|
|
1468
|
+
response: createToolErrorResponse('ideate_failed', `ideate intent: failed to open ideation loop — ${msg}`),
|
|
1469
|
+
};
|
|
1470
|
+
}
|
|
1471
|
+
} // end else (non-bootstrap open path)
|
|
1472
|
+
// pln#492 phase 2.d.2 — multi-agent dispatch. Skipped in single-
|
|
1473
|
+
// agent mode (the champion drives manually).
|
|
1474
|
+
//
|
|
1475
|
+
// pln#511 step 2 — initial phase comes from the actual loop's
|
|
1476
|
+
// first phase, not a hardcoded 'proposal'. Presets like bootstrap
|
|
1477
|
+
// open at 'survey'; the kind-default ideation chain still opens
|
|
1478
|
+
// at 'proposal', so this is backward compatible.
|
|
1479
|
+
let dispatchedCritics = 0;
|
|
1480
|
+
let dispatchedPhase = presetSelected ? presetSelected.phases[0].name : 'proposal';
|
|
1481
|
+
// pln#626 Phase 2 (Option B) — prepared invokes for the critic spawn, run
|
|
1482
|
+
// through runCoordinateExecution after the loop (mirrors intent=assign).
|
|
1483
|
+
const preparedCritics = [];
|
|
1484
|
+
let ideateExecStatus;
|
|
1485
|
+
if (explicitTargets) {
|
|
1486
|
+
try {
|
|
1487
|
+
// Build a search-backed BriefMemoryProvider. Maps user-facing
|
|
1488
|
+
// memory categories the brief asks for onto src/core/search.ts
|
|
1489
|
+
// sections (BM25 there). Loop-internal categories
|
|
1490
|
+
// (critique_history / revision_history / synthesis_artifact)
|
|
1491
|
+
// are pulled by the assembler from the thread directly.
|
|
1492
|
+
const searchModule = await import('../core/search.js');
|
|
1493
|
+
const sectionByCategory = {
|
|
1494
|
+
traps: 'traps',
|
|
1495
|
+
decisions: 'decisions',
|
|
1496
|
+
constraints: 'constraints',
|
|
1497
|
+
handoffs: 'handoffs',
|
|
1498
|
+
plans: 'plans',
|
|
1499
|
+
candidates: 'candidates',
|
|
1500
|
+
};
|
|
1501
|
+
const provider = {
|
|
1502
|
+
fetch(category, query, topK) {
|
|
1503
|
+
const section = sectionByCategory[category];
|
|
1504
|
+
if (!section)
|
|
1505
|
+
return [];
|
|
1506
|
+
const results = searchModule.search({
|
|
1507
|
+
query,
|
|
1508
|
+
section,
|
|
1509
|
+
maxResults: topK,
|
|
1510
|
+
cwd: dispatchCwd,
|
|
1511
|
+
includePending: section === 'candidates',
|
|
1512
|
+
});
|
|
1513
|
+
return results.map((r) => ({
|
|
1514
|
+
id: r.id,
|
|
1515
|
+
category,
|
|
1516
|
+
text: r.text,
|
|
1517
|
+
score: r.score,
|
|
1518
|
+
}));
|
|
1519
|
+
},
|
|
1520
|
+
};
|
|
1521
|
+
// Advance proposal → critique. proposal has no advance_gate so
|
|
1522
|
+
// this is unconditional. After advance, the loop sits at the
|
|
1523
|
+
// critique phase ready for critic turns.
|
|
1524
|
+
advance({ id: loopId, actor: creatorActor }, dispatchCwd);
|
|
1525
|
+
const advancedLoop = getLoop(loopId, dispatchCwd);
|
|
1526
|
+
if (!advancedLoop) {
|
|
1527
|
+
throw new Error('ideate dispatch: loop disappeared after advance');
|
|
1528
|
+
}
|
|
1529
|
+
dispatchedPhase = advancedLoop.current_phase;
|
|
1530
|
+
const criticSlots = advancedLoop.slots.filter((s) => s.role === 'critic');
|
|
1531
|
+
for (const slot of criticSlots) {
|
|
1532
|
+
if (!slot.agent)
|
|
1533
|
+
continue;
|
|
1534
|
+
// pln#626 Phase 2 — validate the target BEFORE any claim/worktree churn
|
|
1535
|
+
// (mirrors intent=assign). A typo'd or non-spawnable critic is skipped
|
|
1536
|
+
// with a clear warning instead of leaving a claim+worktree+assignment
|
|
1537
|
+
// behind that only fails later at spawn time.
|
|
1538
|
+
const critCheck = validateAgentForDispatch(slot.agent, { requireSpawnable: true });
|
|
1539
|
+
if (!critCheck.valid) {
|
|
1540
|
+
warnings.push(JSON.stringify({
|
|
1541
|
+
warning: 'agent_validation_failed',
|
|
1542
|
+
agent: slot.agent,
|
|
1543
|
+
code: critCheck.code,
|
|
1544
|
+
reason: critCheck.reason,
|
|
1545
|
+
}));
|
|
1546
|
+
continue;
|
|
1547
|
+
}
|
|
1548
|
+
const briefResult = buildIdeationBrief({
|
|
1549
|
+
thread: advancedLoop,
|
|
1550
|
+
slotRole: slot.role,
|
|
1551
|
+
memoryProvider: provider,
|
|
1552
|
+
});
|
|
1553
|
+
turn({
|
|
1554
|
+
id: loopId,
|
|
1555
|
+
slot_id: slot.slot_id,
|
|
1556
|
+
actor: creatorActor,
|
|
1557
|
+
input: briefResult.text,
|
|
1558
|
+
}, dispatchCwd);
|
|
1559
|
+
// pln#626 Phase 2 (Option B) — spawn the critic as a worktree-isolated
|
|
1560
|
+
// worker, mirroring the intent=assign / review chain. Each critic gets
|
|
1561
|
+
// its OWN claim + worktree (scope is unique per slot) so parallel
|
|
1562
|
+
// critics never share a checkout. The ideation brief is wrapped in the
|
|
1563
|
+
// coordinate envelope (assignment header + ack instructions) so a
|
|
1564
|
+
// spawned critic — even one without brainclaw MCP wired — can ack and
|
|
1565
|
+
// reply. The brief instructs critique-only; the worktree makes any
|
|
1566
|
+
// stray edit harmless (it lands in the throwaway checkout, not master).
|
|
1567
|
+
const criticScope = `ideate-loop:${loopId}:${slot.slot_id}`;
|
|
1568
|
+
const criticDescription = `Ideation critic turn for loop ${loopId} slot ${slot.slot_id} (phase ${advancedLoop.current_phase}). `
|
|
1569
|
+
+ `Critique the proposal and reply with your critique — do not edit code. ${req.task}`;
|
|
1570
|
+
try {
|
|
1571
|
+
const claimResult = createCoordinatorClaim({
|
|
1572
|
+
agent: slot.agent,
|
|
1573
|
+
scope: criticScope,
|
|
1574
|
+
description: criticDescription,
|
|
1575
|
+
dispatcherAgent: senderAgent,
|
|
1576
|
+
sessionId: connectionSessionId,
|
|
1577
|
+
cwd: dispatchCwd,
|
|
1578
|
+
worktreeBaseRef: req.ref,
|
|
1579
|
+
});
|
|
1580
|
+
if (claimResult.worktreeWarning)
|
|
1581
|
+
warnings.push(claimResult.worktreeWarning);
|
|
1582
|
+
artifacts.push({ type: 'claim', id: claimResult.claimId });
|
|
1583
|
+
side_effects.push({
|
|
1584
|
+
action: claimResult.reusedExisting ? 'reuse' : 'create',
|
|
1585
|
+
entity: 'claim',
|
|
1586
|
+
id: claimResult.claimId,
|
|
1587
|
+
});
|
|
1588
|
+
let criticAssignmentId;
|
|
1589
|
+
try {
|
|
1590
|
+
const preId = generateAssignmentId(dispatchCwd);
|
|
1591
|
+
const assignment = createAssignment({
|
|
1592
|
+
id: preId.id,
|
|
1593
|
+
short_label: preId.short_label,
|
|
1594
|
+
claim_id: claimResult.claimId,
|
|
1595
|
+
agent: slot.agent,
|
|
1596
|
+
dispatcher_agent: senderAgent,
|
|
1597
|
+
dispatcher_session_id: connectionSessionId,
|
|
1598
|
+
scope: criticScope,
|
|
1599
|
+
description: criticDescription,
|
|
1600
|
+
tags: ['coordinate', 'ideate', 'loop'],
|
|
1601
|
+
}, dispatchCwd);
|
|
1602
|
+
criticAssignmentId = assignment.id;
|
|
1603
|
+
artifacts.push({ type: 'assignment', id: assignment.id });
|
|
1604
|
+
}
|
|
1605
|
+
catch (asgErr) {
|
|
1606
|
+
warnings.push(`ideate assignment creation failed for slot ${slot.slot_id}: ${asgErr instanceof Error ? asgErr.message : String(asgErr)}`);
|
|
1607
|
+
}
|
|
1608
|
+
// pln#626 Phase 2 — the critique-only contract must reach the
|
|
1609
|
+
// DELIVERED brief, not just the claim record: buildCoordinateBrief
|
|
1610
|
+
// wraps this in a worker envelope, so prepend the constraint + the
|
|
1611
|
+
// reply path (MCP complete_turn, or LANE-RESULT.json for a sandboxed
|
|
1612
|
+
// critic without brainclaw MCP) ahead of the ideation brief body.
|
|
1613
|
+
const criticTaskText = `CRITIQUE-ONLY TASK — do NOT edit code or commit. Read the proposal below and reply with your critique: `
|
|
1614
|
+
+ `call bclaw_loop(intent='complete_turn') if you have brainclaw MCP, otherwise write your critique to LANE-RESULT.json in your worktree root (the coordinator harvests it).\n\n`
|
|
1615
|
+
+ briefResult.text;
|
|
1616
|
+
const criticBrief = buildCoordinateBrief(slot.agent, criticTaskText, {
|
|
1617
|
+
claimId: claimResult.claimId,
|
|
1618
|
+
scope: criticScope,
|
|
1619
|
+
worktreePath: claimResult.worktreePath,
|
|
1620
|
+
assignmentId: criticAssignmentId,
|
|
1621
|
+
});
|
|
1622
|
+
const queued = queueCoordinateMessage({
|
|
1623
|
+
agent: slot.agent,
|
|
1624
|
+
text: criticBrief,
|
|
1625
|
+
messageType: 'rfc',
|
|
1626
|
+
ref: loopId,
|
|
1627
|
+
scope: criticScope,
|
|
1628
|
+
requiresAck: true,
|
|
1629
|
+
claimId: claimResult.claimId,
|
|
1630
|
+
assignmentId: criticAssignmentId,
|
|
1631
|
+
tags: ['coordinate', 'ideate', 'loop'],
|
|
1632
|
+
payload: {
|
|
1633
|
+
intent: 'ideate',
|
|
1634
|
+
loop_id: loopId,
|
|
1635
|
+
slot_id: slot.slot_id,
|
|
1636
|
+
phase: advancedLoop.current_phase,
|
|
1637
|
+
iteration: advancedLoop.iteration_count,
|
|
1638
|
+
proposal_artifact_id: proposalArtifactId,
|
|
1639
|
+
...(criticAssignmentId ? { assignment_id: criticAssignmentId } : {}),
|
|
1640
|
+
worktree_path: claimResult.worktreePath,
|
|
1641
|
+
},
|
|
1642
|
+
commandMode: 'worker',
|
|
1643
|
+
});
|
|
1644
|
+
if (criticAssignmentId) {
|
|
1645
|
+
try {
|
|
1646
|
+
attachAssignmentMessageToClaim(claimResult.claimId, queued.entry.message_id, dispatchCwd);
|
|
1647
|
+
linkClaimToAssignment(claimResult.claimId, criticAssignmentId, dispatchCwd);
|
|
1648
|
+
transitionAssignment(criticAssignmentId, 'offered', { actor: senderAgent }, dispatchCwd);
|
|
1649
|
+
patchAssignmentMessageId(criticAssignmentId, queued.entry.message_id, dispatchCwd);
|
|
1650
|
+
queued.entry.assignment_id = criticAssignmentId;
|
|
1651
|
+
}
|
|
1652
|
+
catch (linkErr) {
|
|
1653
|
+
warnings.push(`ideate assignment linkage failed for ${criticAssignmentId}: ${linkErr instanceof Error ? linkErr.message : String(linkErr)}`);
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
preparedCritics.push({
|
|
1657
|
+
entry: queued.entry,
|
|
1658
|
+
invoke: queued.invoke,
|
|
1659
|
+
worktreePath: claimResult.worktreePath,
|
|
1660
|
+
});
|
|
1661
|
+
dispatchedCritics += 1;
|
|
1662
|
+
}
|
|
1663
|
+
catch (criticErr) {
|
|
1664
|
+
facadeStatus = 'partial';
|
|
1665
|
+
warnings.push(`ideate critic dispatch failed for slot ${slot.slot_id} (${slot.agent}): ${criticErr instanceof Error ? criticErr.message : String(criticErr)}; loop ${loopId} stays open`);
|
|
1666
|
+
}
|
|
1667
|
+
if (briefResult.truncated) {
|
|
1668
|
+
warnings.push(`Brief for critic slot ${slot.slot_id} (${slot.agent}) truncated: ${briefResult.droppedItems} memory items dropped to fit cap`);
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
// pln#626 Phase 2 (Option B) — spawn the prepared critics now that all
|
|
1672
|
+
// loop state is mutated (mirrors intent=assign's post-loop execution).
|
|
1673
|
+
// autoExecute=false yields command_ready_manual per critic; a target
|
|
1674
|
+
// that can't spawn yields not_spawnable — both surfaced honestly.
|
|
1675
|
+
if (preparedCritics.length > 0) {
|
|
1676
|
+
ideateExecStatus = await runCoordinateExecution(preparedCritics, {
|
|
1677
|
+
autoExecute: effectiveAutoExecute !== false,
|
|
1678
|
+
senderAgent,
|
|
1679
|
+
senderAgentId,
|
|
1680
|
+
cwd: dispatchCwd,
|
|
1681
|
+
warnings,
|
|
1682
|
+
});
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
catch (err) {
|
|
1686
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1687
|
+
warnings.push(`ideate dispatch failed: ${msg}; loop ${loopId} stays at proposal phase`);
|
|
1688
|
+
facadeStatus = 'partial';
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
if (!explicitTargets) {
|
|
1692
|
+
warnings.push(presetSelected
|
|
1693
|
+
? `ideate single-agent mode (preset='${req.preset}'): loop opened at phase '${dispatchedPhase}'; champion drives manually via bclaw_loop intent='turn' / 'advance'.`
|
|
1694
|
+
: "ideate single-agent mode: loop opened with proposal seed; champion drives manually via bclaw_loop intent='turn' / 'advance'. Pass targetAgents to enable multi-agent auto-dispatch.");
|
|
1695
|
+
}
|
|
1696
|
+
// pln#626 Phase 2 — multi-agent critics are now SPAWNED (worktree workers),
|
|
1697
|
+
// so the result reports the real execution_status from runCoordinateExecution
|
|
1698
|
+
// and exposes the delivery entries (each carrying execution_reason) so the
|
|
1699
|
+
// top-level reason derivation works — exactly like intent=assign. Single-
|
|
1700
|
+
// agent ideate spawns nothing, so it stays honestly inbox_only.
|
|
1701
|
+
result = {
|
|
1702
|
+
loop_id: loopId,
|
|
1703
|
+
proposal_artifact_id: proposalArtifactId,
|
|
1704
|
+
selected_targets: explicitTargets ? req.targetAgents : [],
|
|
1705
|
+
mode: explicitTargets ? 'multi_agent' : 'single_agent',
|
|
1706
|
+
dispatched_critics: dispatchedCritics,
|
|
1707
|
+
current_phase: dispatchedPhase,
|
|
1708
|
+
delivery_plan: preparedCritics.map((p) => p.entry),
|
|
1709
|
+
...(ideateExecStatus
|
|
1710
|
+
? { execution_status: ideateExecStatus }
|
|
1711
|
+
: explicitTargets
|
|
1712
|
+
// Multi-agent was intended but nothing prepared (advance() threw, or
|
|
1713
|
+
// every per-critic prep failed) — that's a failure, not by-design
|
|
1714
|
+
// inbox delivery. facadeStatus is already 'partial'; say so honestly.
|
|
1715
|
+
? { execution_status: 'inbox_only', execution_reason: 'dispatch_failed' }
|
|
1716
|
+
: { execution_status: 'inbox_only', execution_reason: 'intent_inbox_only' }),
|
|
1717
|
+
...(presetSelected ? { preset: req.preset } : {}),
|
|
1718
|
+
};
|
|
1719
|
+
// pln#518 step 1 — bootstrap lock is now managed inside acquireBootstrapLoop;
|
|
1720
|
+
// no release needed here.
|
|
1721
|
+
}
|
|
1722
|
+
// Extract execution_status from result if present (assign/reroute set it)
|
|
1723
|
+
const resultExecStatus = (result && typeof result === 'object' && 'execution_status' in result)
|
|
1724
|
+
? result.execution_status
|
|
1725
|
+
: undefined;
|
|
1726
|
+
// pln#626 Phase 1 — surface the REASON WHY at the top level, not just per
|
|
1727
|
+
// delivery entry. The reason accompanies execution_status only when it is
|
|
1728
|
+
// NOT delivered_and_started (mixed dispatches where ≥1 target spawned report
|
|
1729
|
+
// delivered_and_started overall, and their per-entry failures stay visible in
|
|
1730
|
+
// delivery_plan + warnings — the top-level reason must not contradict the
|
|
1731
|
+
// status). Prefer an explicit result.execution_reason (consult / ideate /
|
|
1732
|
+
// bootstrap-join set it); otherwise derive it from the first non-started
|
|
1733
|
+
// delivery entry (e.g. auto_execute_disabled on a manual assign/review,
|
|
1734
|
+
// not_spawnable on an IDE-only target).
|
|
1735
|
+
const resultExecReason = (() => {
|
|
1736
|
+
if (resultExecStatus === 'delivered_and_started')
|
|
1737
|
+
return undefined;
|
|
1738
|
+
if (!result || typeof result !== 'object')
|
|
1739
|
+
return undefined;
|
|
1740
|
+
const r = result;
|
|
1741
|
+
if (typeof r.execution_reason === 'string')
|
|
1742
|
+
return r.execution_reason;
|
|
1743
|
+
const plan = r.delivery_plan;
|
|
1744
|
+
if (Array.isArray(plan)) {
|
|
1745
|
+
for (const e of plan) {
|
|
1746
|
+
if (e && e.execution_status !== 'delivered_and_started' && typeof e.execution_reason === 'string') {
|
|
1747
|
+
return e.execution_reason;
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
return undefined;
|
|
1752
|
+
})();
|
|
1753
|
+
// pln#503 phase 3.3: when execution_status === 'delivered_and_started',
|
|
1754
|
+
// attach a self-documenting `verify_with` hint pointing at the assignment
|
|
1755
|
+
// record. Callers should not take delivered_and_started at face value —
|
|
1756
|
+
// it only attests the brief-ack sentinel was touched, not that the worker
|
|
1757
|
+
// is doing useful work. The hint tells them exactly which canonical-
|
|
1758
|
+
// grammar call to make next to verify spawn liveness.
|
|
1759
|
+
let verifyWith;
|
|
1760
|
+
if (resultExecStatus === 'delivered_and_started') {
|
|
1761
|
+
const firstAssignment = artifacts.find((a) => a.type === 'assignment');
|
|
1762
|
+
if (firstAssignment) {
|
|
1763
|
+
verifyWith = {
|
|
1764
|
+
action: 'bclaw_find',
|
|
1765
|
+
entity: 'agent_run',
|
|
1766
|
+
filter: { assignment_id: firstAssignment.id },
|
|
1767
|
+
expected_when_alive: 'agent_run with status="running" AND OS pid alive AND last_event_at within the last few minutes',
|
|
1768
|
+
see_also: 'docs/concepts/dispatch-lifecycle.md',
|
|
1769
|
+
};
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1772
|
+
const facadeResponse = {
|
|
1773
|
+
status: facadeStatus,
|
|
1774
|
+
intent: req.intent,
|
|
1775
|
+
result,
|
|
1776
|
+
artifacts,
|
|
1777
|
+
side_effects,
|
|
1778
|
+
warnings,
|
|
1779
|
+
duration_ms: Date.now() - startMs,
|
|
1780
|
+
...(resultExecStatus ? { execution_status: resultExecStatus } : {}),
|
|
1781
|
+
...(resultExecReason ? { execution_reason: resultExecReason } : {}),
|
|
1782
|
+
...(verifyWith ? { verify_with: verifyWith } : {}),
|
|
1783
|
+
};
|
|
1784
|
+
const summaryParts = [`✔ bclaw_coordinate [${req.intent}] targets=${resolvedAgents.length}`];
|
|
1785
|
+
if (resultExecStatus)
|
|
1786
|
+
summaryParts.push(`execution: ${resultExecStatus}${resultExecReason ? ` (${resultExecReason})` : ''}`);
|
|
1787
|
+
if (warnings.length > 0)
|
|
1788
|
+
summaryParts.push(warnings.map((w) => `⚠ ${w}`).join('\n'));
|
|
1789
|
+
return {
|
|
1790
|
+
response: toolResponse({
|
|
1791
|
+
content: [{ type: 'text', text: summaryParts.join('\n') }],
|
|
1792
|
+
structuredContent: facadeResponse,
|
|
1793
|
+
}),
|
|
1794
|
+
};
|
|
1795
|
+
}
|
|
1796
|
+
export async function handleBclawLoop(args, ctx) {
|
|
1797
|
+
const { cwd, connectionSessionId } = ctx;
|
|
1798
|
+
// pln#542: intent='open' is no longer exposed standalone over MCP — it
|
|
1799
|
+
// creates a loop without dispatching the first turn (the documented
|
|
1800
|
+
// anti-pattern, now removed instead of documented). Internal callers
|
|
1801
|
+
// (bclaw_coordinate, CLI bootstrap) use core openLoop directly.
|
|
1802
|
+
if (args?.intent === 'open') {
|
|
1803
|
+
return {
|
|
1804
|
+
response: createToolErrorResponse('intent_not_exposed', "bclaw_loop(intent='open') is not exposed standalone: it creates a loop structure without dispatching any turn, so the work never starts. Use bclaw_coordinate(intent='review', open_loop=true, targetAgents=[…]) or bclaw_coordinate(intent='ideate') — they open the loop AND dispatch the first turn."),
|
|
1805
|
+
};
|
|
1806
|
+
}
|
|
1807
|
+
// pln#562 step 4 — dispatching a turn hands work to another agent; gate
|
|
1808
|
+
// it at the same trust bar as the other dispatch surfaces.
|
|
1809
|
+
if (args?.intent === 'turn' && args?.dispatch === true) {
|
|
1810
|
+
const resolved = ensureTrust(args, { nameField: 'agent', idField: 'agentId' }, 'trusted', cwd, connectionSessionId);
|
|
1811
|
+
if (resolved.error) {
|
|
1812
|
+
return { response: createToolErrorResponse(resolved.error.kind, resolved.error.message, resolved.error.details) };
|
|
1813
|
+
}
|
|
1814
|
+
}
|
|
1815
|
+
const { handleBclawLoop: runLoopIntent } = await import('./loops-handlers.js');
|
|
1816
|
+
const targetCwd = resolveProjectCwd(args?.project, cwd);
|
|
1817
|
+
const result = runLoopIntent({ args: args, cwd: targetCwd });
|
|
1818
|
+
return {
|
|
1819
|
+
response: toolResponse({
|
|
1820
|
+
content: [{ type: 'text', text: result.summary }],
|
|
1821
|
+
structuredContent: result.response,
|
|
1822
|
+
}),
|
|
1823
|
+
};
|
|
1824
|
+
}
|
|
1825
|
+
//# sourceMappingURL=mcp-write-coordination.js.map
|