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.
Files changed (63) hide show
  1. package/README.md +16 -263
  2. package/dist/brainclaw-vscode.vsix +0 -0
  3. package/dist/cli/register-capture.js +209 -0
  4. package/dist/cli/register-code-map.js +19 -0
  5. package/dist/cli/register-coordination.js +472 -0
  6. package/dist/cli/register-federation.js +258 -0
  7. package/dist/cli/register-lifecycle.js +436 -0
  8. package/dist/cli/register-memory-context.js +502 -0
  9. package/dist/cli/register-planning.js +167 -0
  10. package/dist/cli/register-review.js +149 -0
  11. package/dist/cli/shared.js +5 -0
  12. package/dist/cli.js +212 -2015
  13. package/dist/commands/dispatch-watch.js +25 -2
  14. package/dist/commands/harvest.js +31 -6
  15. package/dist/commands/mcp-catalog.js +1438 -0
  16. package/dist/commands/mcp-contract.js +33 -0
  17. package/dist/commands/mcp-presentation.js +27 -0
  18. package/dist/commands/mcp-read-handlers.js +72 -36
  19. package/dist/commands/mcp-write-admin.js +328 -0
  20. package/dist/commands/mcp-write-claims.js +864 -0
  21. package/dist/commands/mcp-write-coordination.js +1825 -0
  22. package/dist/commands/mcp-write-entities.js +620 -0
  23. package/dist/commands/mcp-write-memory.js +451 -0
  24. package/dist/commands/mcp-write-sequences.js +116 -0
  25. package/dist/commands/mcp-write-support.js +367 -0
  26. package/dist/commands/mcp.js +261 -5570
  27. package/dist/commands/update-handoff.js +28 -42
  28. package/dist/core/agent-capability.js +31 -14
  29. package/dist/core/agent-files.js +1 -1
  30. package/dist/core/agent-registry.js +51 -3
  31. package/dist/core/claims.js +18 -0
  32. package/dist/core/coordination.js +5 -2
  33. package/dist/core/cross-project.js +35 -1
  34. package/dist/core/dispatcher.js +34 -20
  35. package/dist/core/entity-operations.js +335 -12
  36. package/dist/core/entity-registry.js +72 -9
  37. package/dist/core/execution.js +28 -4
  38. package/dist/core/facade-schema.js +30 -4
  39. package/dist/core/federation-cloud.js +142 -11
  40. package/dist/core/federation-outbox.js +292 -0
  41. package/dist/core/federation-signing.js +115 -0
  42. package/dist/core/handoff-review.js +35 -0
  43. package/dist/core/io.js +6 -0
  44. package/dist/core/protocol-tool-policy.js +113 -0
  45. package/dist/core/review-loop-close.js +115 -0
  46. package/dist/core/schema.js +25 -2
  47. package/dist/core/security-detectors.js +35 -6
  48. package/dist/core/security.js +32 -12
  49. package/dist/core/worktree.js +98 -9
  50. package/dist/facts.js +13 -11
  51. package/dist/facts.json +12 -10
  52. package/docs/PROTOCOL.md +7 -3
  53. package/docs/concepts/coordinator-runbook.md +3 -0
  54. package/docs/concepts/dispatch-lifecycle.md +4 -4
  55. package/docs/concepts/loop-engine.md +3 -1
  56. package/docs/concepts/troubleshooting.md +1 -1
  57. package/docs/integrations/codex.md +3 -3
  58. package/docs/integrations/overview.md +1 -1
  59. package/docs/mcp-schema-changelog.md +153 -2
  60. package/docs/playbooks/orchestration.md +1 -1
  61. package/docs/product/entity-model-audit.md +3 -2
  62. package/docs/security.md +22 -1
  63. 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