brainclaw 0.28.0 → 1.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (198) hide show
  1. package/README.md +193 -170
  2. package/dist/brainclaw-vscode.vsix +0 -0
  3. package/dist/cli.js +683 -23
  4. package/dist/commands/accept.js +3 -0
  5. package/dist/commands/add-step.js +11 -26
  6. package/dist/commands/agent-board.js +70 -3
  7. package/dist/commands/audit.js +19 -0
  8. package/dist/commands/check-policy.js +54 -0
  9. package/dist/commands/check-security-mcp.js +145 -0
  10. package/dist/commands/check-security.js +106 -0
  11. package/dist/commands/claim-resource.js +1 -0
  12. package/dist/commands/codev.js +672 -0
  13. package/dist/commands/compact.js +74 -0
  14. package/dist/commands/complete-step.js +16 -26
  15. package/dist/commands/constraint.js +8 -20
  16. package/dist/commands/decision.js +9 -20
  17. package/dist/commands/delete-plan.js +10 -12
  18. package/dist/commands/delete-step.js +16 -0
  19. package/dist/commands/dispatch.js +163 -0
  20. package/dist/commands/doctor.js +1122 -49
  21. package/dist/commands/enable-agent.js +1 -0
  22. package/dist/commands/export.js +280 -22
  23. package/dist/commands/handoff.js +33 -0
  24. package/dist/commands/harvest.js +189 -0
  25. package/dist/commands/hooks.js +82 -25
  26. package/dist/commands/inbox.js +169 -0
  27. package/dist/commands/init.js +38 -31
  28. package/dist/commands/install-hooks.js +71 -44
  29. package/dist/commands/link.js +89 -0
  30. package/dist/commands/list-claims.js +48 -3
  31. package/dist/commands/list-plans.js +129 -25
  32. package/dist/commands/loops-handlers.js +409 -0
  33. package/dist/commands/mcp-read-handlers.js +1628 -0
  34. package/dist/commands/mcp-schemas.generated.js +74 -0
  35. package/dist/commands/mcp.js +4244 -1475
  36. package/dist/commands/plan-resource.js +64 -0
  37. package/dist/commands/plan.js +12 -26
  38. package/dist/commands/prune.js +37 -2
  39. package/dist/commands/reflect.js +20 -7
  40. package/dist/commands/release-claim.js +11 -6
  41. package/dist/commands/release-notes.js +170 -0
  42. package/dist/commands/repair.js +210 -0
  43. package/dist/commands/run-profile.js +57 -0
  44. package/dist/commands/sequence.js +113 -0
  45. package/dist/commands/session-end.js +423 -14
  46. package/dist/commands/session-start.js +214 -41
  47. package/dist/commands/setup-security.js +103 -0
  48. package/dist/commands/setup.js +42 -4
  49. package/dist/commands/stale.js +109 -0
  50. package/dist/commands/switch.js +131 -10
  51. package/dist/commands/trap.js +14 -31
  52. package/dist/commands/update-handoff.js +63 -4
  53. package/dist/commands/update-plan.js +21 -28
  54. package/dist/commands/update-step.js +37 -0
  55. package/dist/commands/upgrade.js +313 -6
  56. package/dist/commands/usage.js +102 -0
  57. package/dist/commands/version.js +20 -0
  58. package/dist/commands/who.js +124 -0
  59. package/dist/commands/worktree.js +105 -0
  60. package/dist/core/actions.js +315 -0
  61. package/dist/core/agent-capability.js +610 -17
  62. package/dist/core/agent-context.js +7 -1
  63. package/dist/core/agent-files.js +1169 -85
  64. package/dist/core/agent-integrations.js +160 -5
  65. package/dist/core/agent-inventory.js +2 -0
  66. package/dist/core/agent-profiles.js +93 -0
  67. package/dist/core/agent-registry.js +162 -30
  68. package/dist/core/agentrun-reconciler.js +345 -0
  69. package/dist/core/agentruns.js +424 -0
  70. package/dist/core/ai-agent-detection.js +31 -10
  71. package/dist/core/archival.js +77 -0
  72. package/dist/core/assignment-sweeper.js +82 -0
  73. package/dist/core/assignments.js +367 -0
  74. package/dist/core/audit.js +30 -0
  75. package/dist/core/bootstrap.js +61 -10
  76. package/dist/core/brainclaw-version.js +94 -2
  77. package/dist/core/candidates.js +93 -2
  78. package/dist/core/claims.js +419 -0
  79. package/dist/core/codev-metrics.js +77 -0
  80. package/dist/core/codev-personas.js +31 -0
  81. package/dist/core/codev-plan-gen.js +35 -0
  82. package/dist/core/codev-prompts.js +74 -0
  83. package/dist/core/codev-responses.js +62 -0
  84. package/dist/core/codev-rounds.js +218 -0
  85. package/dist/core/config.js +4 -0
  86. package/dist/core/context.js +454 -34
  87. package/dist/core/coordination.js +201 -6
  88. package/dist/core/cross-project.js +230 -16
  89. package/dist/core/default-profiles/doctor.yaml +11 -0
  90. package/dist/core/default-profiles/janitor.yaml +11 -0
  91. package/dist/core/default-profiles/onboarder.yaml +11 -0
  92. package/dist/core/default-profiles/reviewer.yaml +13 -0
  93. package/dist/core/dispatcher.js +1189 -0
  94. package/dist/core/duplicates.js +2 -2
  95. package/dist/core/entity-operations.js +450 -0
  96. package/dist/core/entity-registry.js +344 -0
  97. package/dist/core/event-log.js +1 -0
  98. package/dist/core/events.js +106 -2
  99. package/dist/core/execution-adapters.js +154 -0
  100. package/dist/core/execution-context.js +63 -0
  101. package/dist/core/execution-profile.js +270 -0
  102. package/dist/core/execution.js +255 -0
  103. package/dist/core/facade-schema.js +81 -0
  104. package/dist/core/federation-cloud.js +99 -0
  105. package/dist/core/federation-message.js +52 -0
  106. package/dist/core/federation-transport.js +65 -0
  107. package/dist/core/gc-semantic.js +482 -0
  108. package/dist/core/governance.js +247 -0
  109. package/dist/core/guards.js +19 -0
  110. package/dist/core/ideation.js +72 -0
  111. package/dist/core/identity.js +252 -28
  112. package/dist/core/ids.js +6 -0
  113. package/dist/core/input-validation.js +2 -2
  114. package/dist/core/instruction-templates.js +344 -136
  115. package/dist/core/io.js +90 -11
  116. package/dist/core/lock.js +6 -2
  117. package/dist/core/loops/brief-assembly.js +213 -0
  118. package/dist/core/loops/facade-schema.js +148 -0
  119. package/dist/core/loops/index.js +7 -0
  120. package/dist/core/loops/iteration-engine.js +139 -0
  121. package/dist/core/loops/lock.js +385 -0
  122. package/dist/core/loops/store.js +201 -0
  123. package/dist/core/loops/types.js +403 -0
  124. package/dist/core/loops/verbs.js +534 -0
  125. package/dist/core/markdown.js +15 -3
  126. package/dist/core/memory-compactor.js +432 -0
  127. package/dist/core/memory-git.js +152 -8
  128. package/dist/core/messaging.js +278 -0
  129. package/dist/core/migration.js +32 -1
  130. package/dist/core/mutation-pipeline.js +4 -2
  131. package/dist/core/operations/memory-mutation.js +129 -0
  132. package/dist/core/operations/memory-write.js +78 -0
  133. package/dist/core/operations/plan.js +190 -0
  134. package/dist/core/policy.js +169 -0
  135. package/dist/core/repo-analysis.js +67 -0
  136. package/dist/core/reputation.js +9 -3
  137. package/dist/core/schema.js +546 -21
  138. package/dist/core/search.js +21 -2
  139. package/dist/core/security-cache.js +71 -0
  140. package/dist/core/security-guard.js +152 -0
  141. package/dist/core/security-scoring.js +86 -0
  142. package/dist/core/sequence.js +130 -0
  143. package/dist/core/socket-client.js +113 -0
  144. package/dist/core/staleness.js +246 -0
  145. package/dist/core/state.js +98 -22
  146. package/dist/core/store-resolution.js +54 -12
  147. package/dist/core/toml-writer.js +76 -0
  148. package/dist/core/upgrades/backup.js +232 -0
  149. package/dist/core/upgrades/health-check.js +169 -0
  150. package/dist/core/upgrades/patches/candidate-archive.js +145 -0
  151. package/dist/core/upgrades/patches/handoff-review-strip.js +128 -0
  152. package/dist/core/upgrades/patches/provenance-rollout.js +136 -0
  153. package/dist/core/upgrades/schema-version.js +97 -0
  154. package/dist/core/worktree.js +606 -0
  155. package/dist/facts.js +114 -0
  156. package/dist/facts.json +111 -0
  157. package/docs/architecture/project-refs.md +5 -1
  158. package/docs/cli.md +690 -43
  159. package/docs/concepts/ideation-loop.md +317 -0
  160. package/docs/concepts/loop-engine.md +456 -0
  161. package/docs/concepts/mcp-governance.md +268 -0
  162. package/docs/concepts/memory-staleness.md +122 -0
  163. package/docs/concepts/multi-agent-workflows.md +166 -0
  164. package/docs/concepts/plans-and-claims.md +31 -6
  165. package/docs/concepts/project-md-convention.md +35 -0
  166. package/docs/concepts/troubleshooting.md +220 -0
  167. package/docs/concepts/upgrade-cli.md +202 -0
  168. package/docs/concepts/upgrade-dogfood-procedure.md +114 -0
  169. package/docs/context-format-changelog.md +2 -2
  170. package/docs/context-format.md +2 -2
  171. package/docs/index.md +68 -0
  172. package/docs/integrations/agents.md +15 -16
  173. package/docs/integrations/cline.md +88 -0
  174. package/docs/integrations/codex.md +75 -23
  175. package/docs/integrations/continue.md +60 -0
  176. package/docs/integrations/copilot.md +67 -9
  177. package/docs/integrations/kilocode.md +72 -0
  178. package/docs/integrations/mcp.md +304 -21
  179. package/docs/integrations/mistral-vibe.md +122 -0
  180. package/docs/integrations/opencode.md +84 -0
  181. package/docs/integrations/overview.md +23 -8
  182. package/docs/integrations/roo.md +74 -0
  183. package/docs/integrations/windsurf.md +83 -0
  184. package/docs/mcp-schema-changelog.md +191 -1
  185. package/docs/playbooks/integration/index.md +121 -0
  186. package/docs/playbooks/productivity/index.md +102 -0
  187. package/docs/playbooks/team/index.md +122 -0
  188. package/docs/product/agent-first-model.md +184 -0
  189. package/docs/product/entity-model-audit.md +462 -0
  190. package/docs/quickstart-existing-project.md +135 -0
  191. package/docs/quickstart.md +124 -37
  192. package/docs/release-maintenance.md +79 -0
  193. package/docs/review.md +2 -0
  194. package/docs/server-operations.md +118 -0
  195. package/package.json +20 -12
  196. package/dist/commands/claude-desktop-extension.js +0 -18
  197. package/dist/commands/diff.js +0 -99
  198. package/dist/core/claude-desktop-extension.js +0 -224
@@ -0,0 +1,1189 @@
1
+ /**
2
+ * Local dispatcher — claim-routed multi-instance coordination.
3
+ *
4
+ * ## Architecture (dec_39d59cab, Codex-reviewed)
5
+ *
6
+ * - **Agent type** = capability profile (what codex CAN do)
7
+ * - **Claim** = routing key (exists before spawn, locks a scope)
8
+ * - **Session** = observability metadata (adopted post-spawn)
9
+ *
10
+ * ## Dispatch pipeline
11
+ *
12
+ * 1. `analyzeSequence()` — categorize lanes, compute `agent_capacity` per agent
13
+ * 2. `scoreAgents()` — 4-factor weighted scoring with capacity-aware utilization
14
+ * 3. Claim-based capacity guard — agents stay in pool until claims >= max_concurrent_tasks
15
+ * 4. `createCoordinatorClaim()` — scope lock is global (any active claim blocks)
16
+ * 5. `sendMessage()` — inbox message with top-level `claim_id` for routing
17
+ * 6. `attachAssignmentMessageToClaim()` — links claim → message for tracing
18
+ * 7. `attemptExecution()` — spawn with `BRAINCLAW_CLAIM_ID` in env
19
+ * 8. Instance calls `session_start` → adopts claim → filters inbox by `claim_id`
20
+ *
21
+ * ## Multi-instance support
22
+ *
23
+ * An agent type can run N parallel instances (max_concurrent_tasks in profile).
24
+ * Each instance gets its own worktree, claim, and inbox messages. The dispatcher
25
+ * scores by utilization (claims / max_tasks) and naturally load-balances across
26
+ * agents and instances within a single dispatch cycle.
27
+ *
28
+ * ## Limits
29
+ *
30
+ * - Instruction files, hooks, MCP config remain per agent type (not per instance)
31
+ * - Live companion refresh is global (last writer wins, deterministic)
32
+ * - Copilot CLI is inbox/review-only (canBeSpawnedCli=false)
33
+ *
34
+ * @module
35
+ */
36
+ import { buildClaimEnvPrefix } from './execution-profile.js';
37
+ import { getActiveSequence } from './sequence.js';
38
+ import { loadState, persistState } from './state.js';
39
+ import { listClaims, createCoordinatorClaim, attachAssignmentMessageToClaim, linkClaimToAssignment, assessClaimLiveness } from './claims.js';
40
+ import { listAgentIdentities, ensureAgentRegisteredForDispatch } from './agent-registry.js';
41
+ import { sendMessage, hasActiveAssignment } from './messaging.js';
42
+ import { memoryDir } from './io.js';
43
+ import { loadVersionedJsonFile } from './migration.js';
44
+ import fs from 'node:fs';
45
+ import path from 'node:path';
46
+ import { buildInvokeCommand, resolveBriefMode, getCapabilityProfile } from './agent-capability.js';
47
+ import { attemptExecution } from './execution.js';
48
+ import { createAssignment, transitionAssignment, generateAssignmentId, patchAssignmentMessageId } from './assignments.js';
49
+ import { createAgentRun, transitionAgentRun } from './agentruns.js';
50
+ import * as loopsModule from './loops/index.js';
51
+ import { sweepAssignments } from './assignment-sweeper.js';
52
+ import { InboxMessageSchema } from './schema.js';
53
+ import { generateId, nowISO } from './ids.js';
54
+ import { applyHandoffUpdates } from '../commands/update-handoff.js';
55
+ const MAX_INLINE_BRIEF_LENGTH = 4000;
56
+ /**
57
+ * Build a cross-platform env prefix for BRAINCLAW_CLAIM_ID. Delegates to
58
+ * the centralised buildClaimEnvPrefix in src/core/execution-profile.ts
59
+ * (pln#496 step stp_a9afe59d) which speaks all five shells. The prior
60
+ * Windows/POSIX-only branch lives there now as a hard-detected default.
61
+ */
62
+ function buildEnvPrefix(claimId) {
63
+ return buildClaimEnvPrefix(claimId);
64
+ }
65
+ // ── Lane Analysis ───────────────────────────────────────────
66
+ /**
67
+ * Analyze the active sequence and categorize each item as ready, active, blocked, or done.
68
+ */
69
+ export function analyzeSequence(cwd) {
70
+ const sequence = getActiveSequence(cwd);
71
+ if (!sequence)
72
+ return null;
73
+ const state = loadState(cwd);
74
+ const claims = listClaims(cwd).filter(c => c.status === 'active');
75
+ const agents = listAgentIdentities(cwd);
76
+ // Index plans by ID for fast lookup
77
+ const planIndex = new Map();
78
+ for (const p of state.plan_items) {
79
+ planIndex.set(p.id, p);
80
+ if (p.short_label)
81
+ planIndex.set(p.short_label, p);
82
+ }
83
+ // Collect plan IDs that are done or dropped (terminal states)
84
+ const terminalPlanIds = new Set();
85
+ for (const p of state.plan_items) {
86
+ if (p.status === 'done' || p.status === 'dropped') {
87
+ terminalPlanIds.add(p.id);
88
+ }
89
+ }
90
+ // Collect plan IDs with active claims
91
+ const claimedPlanIds = new Map();
92
+ for (const c of claims) {
93
+ if (c.plan_id)
94
+ claimedPlanIds.set(c.plan_id, c);
95
+ }
96
+ // Count ALL active claims per agent in the project (not just sequence-scoped).
97
+ // An agent working on a claim outside the current sequence still has reduced capacity.
98
+ const agentClaimCounts = new Map();
99
+ for (const c of claims) {
100
+ agentClaimCounts.set(c.agent, (agentClaimCounts.get(c.agent) ?? 0) + 1);
101
+ }
102
+ const ready = [];
103
+ const active = [];
104
+ const blocked = [];
105
+ const done = [];
106
+ for (const item of sequence.items) {
107
+ const plan = planIndex.get(item.planId);
108
+ // Plan is done
109
+ if (plan && (plan.status === 'done' || plan.status === 'dropped')) {
110
+ done.push(item);
111
+ continue;
112
+ }
113
+ // Plan has active claim — someone is working on it
114
+ const activeClaim = claimedPlanIds.get(item.planId);
115
+ if (activeClaim && plan) {
116
+ active.push({
117
+ item,
118
+ plan,
119
+ lane: item.lane,
120
+ claim: activeClaim,
121
+ agent: activeClaim.agent,
122
+ liveness: assessClaimLiveness(activeClaim, { cwd }).status,
123
+ });
124
+ continue;
125
+ }
126
+ // Check hard dependencies
127
+ const unmetHard = item.hard_after.filter(dep => !terminalPlanIds.has(dep));
128
+ if (unmetHard.length > 0) {
129
+ blocked.push({
130
+ item,
131
+ plan,
132
+ lane: item.lane,
133
+ reason: `Waiting on hard dependencies: ${unmetHard.join(', ')}`,
134
+ blocked_by: unmetHard,
135
+ });
136
+ continue;
137
+ }
138
+ // Check soft dependencies (advisory — don't block, just note)
139
+ const unmetSoft = item.soft_after.filter(dep => !terminalPlanIds.has(dep));
140
+ const softNote = unmetSoft.length > 0
141
+ ? ` (soft deps not yet done: ${unmetSoft.join(', ')})`
142
+ : '';
143
+ if (!plan) {
144
+ blocked.push({
145
+ item,
146
+ plan: undefined,
147
+ lane: item.lane,
148
+ reason: `Plan ${item.planId} not found`,
149
+ blocked_by: [],
150
+ });
151
+ continue;
152
+ }
153
+ ready.push({
154
+ item,
155
+ plan,
156
+ lane: item.lane,
157
+ reason: `All hard dependencies met${softNote}`,
158
+ });
159
+ }
160
+ // Build capacity summary per agent (multi-instance aware)
161
+ const allAgentNames = agents
162
+ .filter(a => a.kind !== 'human')
163
+ .map(a => a.agent_name);
164
+ const agent_capacity = allAgentNames.map(agent => {
165
+ const active_claims = agentClaimCounts.get(agent) ?? 0;
166
+ const profile = getCapabilityProfile(agent);
167
+ const max_tasks = profile?.max_concurrent_tasks ?? 1;
168
+ return { agent, active_claims, max_tasks, slots_remaining: Math.max(0, max_tasks - active_claims) };
169
+ });
170
+ // Available agents: those with remaining capacity (slots_remaining > 0)
171
+ const available_agents = agent_capacity
172
+ .filter(a => a.slots_remaining > 0)
173
+ .map(a => a.agent);
174
+ return { sequence, ready, active, blocked, done, available_agents, agent_capacity };
175
+ }
176
+ // ── Brief Generation ────────────────────────────────────────
177
+ /**
178
+ * Protocol + Available tools section, shared between generateBrief (plan-based)
179
+ * and generateDispatchBrief (task-based / coordinate).
180
+ *
181
+ * Only emitted for 'full' briefMode — agents in 'compact' or 'task_card' mode
182
+ * either lack MCP access entirely (nanoclaw / nemoclaw / zeroclaw) or are
183
+ * IDE-only (Cursor / Windsurf / Roo) where the human pastes the task. Both
184
+ * cases ignore the protocol-side instructions, so emitting them is noise.
185
+ *
186
+ * pln#496 Phase 1.b note: codex and mistral-vibe USED TO get 'compact'
187
+ * because they are task-based, but they also have hasMcp=true, so the
188
+ * Protocol section IS useful to them — `resolveBriefMode` was updated to
189
+ * return 'full' for that combination.
190
+ */
191
+ export function buildProtocolSection(options) {
192
+ const parts = [];
193
+ parts.push('## Protocol');
194
+ if (options?.claimId) {
195
+ parts.push(`Your scope has been pre-claimed by the coordinator (claim: ${options.claimId}).`);
196
+ }
197
+ if (options?.assignmentId) {
198
+ parts.push(`Assignment: ${options.assignmentId}`);
199
+ }
200
+ if (options?.worktreePath) {
201
+ parts.push(`Worktree: ${options.worktreePath}`);
202
+ }
203
+ parts.push('');
204
+ // Assignment lifecycle protocol (Agent SDK)
205
+ if (options?.assignmentId) {
206
+ parts.push(`1. Call bclaw_assignment_update(assignment_id: "${options.assignmentId}", status: "accepted")`);
207
+ if (options.worktreePath) {
208
+ parts.push(`2. cd into the worktree: ${options.worktreePath}`);
209
+ }
210
+ parts.push(`${options.worktreePath ? '3' : '2'}. Call bclaw_assignment_update(assignment_id: "${options.assignmentId}", status: "started")`);
211
+ parts.push(`${options.worktreePath ? '4' : '3'}. Work on the assigned scope`);
212
+ parts.push(`${options.worktreePath ? '5' : '4'}. Periodically call bclaw_assignment_update(status: "progress", message: "...") as heartbeat`);
213
+ parts.push(`${options.worktreePath ? '6' : '5'}. When done: bclaw_assignment_update(status: "completed", artifacts: [...])`);
214
+ const claimRef = options?.claimId ? `id: "${options.claimId}"` : 'id: "<claim_id>"';
215
+ parts.push(`${options.worktreePath ? '7' : '6'}. Release the claim: bclaw_release_claim(${claimRef}, planStatus: "done") — required for hard_after gating to unblock downstream tasks`);
216
+ parts.push(`${options.worktreePath ? '8' : '7'}. If blocked: bclaw_assignment_update(status: "blocked", blocker: "...")`);
217
+ parts.push(`${options.worktreePath ? '9' : '8'}. If failed: bclaw_assignment_update(status: "failed", error_message: "...")`);
218
+ }
219
+ else if (options?.claimId) {
220
+ parts.push('1. Call bclaw_session_start to register your session');
221
+ if (options.worktreePath) {
222
+ parts.push(`2. cd into the worktree: ${options.worktreePath}`);
223
+ }
224
+ parts.push(`${options.worktreePath ? '3' : '2'}. Work on the assigned scope (claim already active)`);
225
+ parts.push(`${options.worktreePath ? '4' : '3'}. Release the claim: bclaw_release_claim(id: "${options.claimId}", planStatus: "done") — required for hard_after gating to unblock downstream tasks`);
226
+ parts.push(`${options.worktreePath ? '5' : '4'}. Call bclaw_session_end with a narrative when done`);
227
+ }
228
+ else {
229
+ parts.push('1. Call bclaw_session_start to register your session');
230
+ parts.push('2. Call bclaw_claim to claim the scope before editing');
231
+ parts.push('3. Work in the worktree created by the claim');
232
+ parts.push('4. Release the claim when done: bclaw_release_claim(id: "clm_xxx", planStatus: "done") — required for hard_after sequence gating to unlock the next step');
233
+ parts.push('5. Call bclaw_session_end with a narrative when done');
234
+ }
235
+ parts.push('');
236
+ parts.push('## Available tools');
237
+ if (options?.assignmentId) {
238
+ parts.push('- bclaw_assignment_update (report lifecycle: accepted/started/progress/completed/failed/blocked)');
239
+ }
240
+ parts.push('- bclaw_session_start, bclaw_session_end (session lifecycle)');
241
+ if (!options?.claimId) {
242
+ parts.push('- bclaw_claim, bclaw_release_claim (scope ownership)');
243
+ }
244
+ parts.push('- bclaw_context(kind: "memory") — or bclaw_work(intent: "consult") for the facade shape (project memory)');
245
+ parts.push('- bclaw_find/get/create/update/transition — canonical CRUD on any brainclaw entity');
246
+ parts.push('- bclaw_write_note, bclaw_quick_capture (capture decisions/traps)');
247
+ parts.push('');
248
+ return parts.join('\n');
249
+ }
250
+ /**
251
+ * Generate a dispatch brief for an agent about to work on a plan.
252
+ * The brief content adapts to the agent's capabilities via briefMode:
253
+ * - 'full': complete brief with Protocol + Available tools (MCP-capable agents)
254
+ * - 'compact': task + steps + constraints only (sandboxed agents like Codex)
255
+ * - 'task_card': ultra-short human-readable card (IDE-only agents)
256
+ */
257
+ export function generateBrief(plan, item, cwd, briefMode, options) {
258
+ const mode = briefMode ?? 'full';
259
+ // ── task_card: ultra-short for IDE agents ──────────────────
260
+ // Includes claim_id and worktree_path so inbox-only agents (e.g. Copilot)
261
+ // can see the pre-created artifacts even without the full protocol section.
262
+ if (mode === 'task_card') {
263
+ const parts = [];
264
+ parts.push(`Task: ${plan.text}`);
265
+ parts.push(`Plan: ${plan.id}${plan.short_label ? ` (${plan.short_label})` : ''}`);
266
+ parts.push(`Priority: ${plan.priority}`);
267
+ if (item.lane)
268
+ parts.push(`Lane: ${item.lane}`);
269
+ if (item.scope_hint)
270
+ parts.push(`Scope: ${item.scope_hint}`);
271
+ if (options?.claimId)
272
+ parts.push(`Claim: ${options.claimId} (pre-claimed by coordinator)`);
273
+ if (options?.worktreePath)
274
+ parts.push(`Worktree: ${options.worktreePath}`);
275
+ if (plan.steps?.length) {
276
+ parts.push('');
277
+ for (const step of plan.steps) {
278
+ const check = step.status === 'done' ? '[x]' : '[ ]';
279
+ parts.push(`${check} ${step.text}`);
280
+ }
281
+ }
282
+ return parts.join('\n');
283
+ }
284
+ const state = loadState(cwd);
285
+ // Find relevant handoffs (previous work on this plan or related plans)
286
+ const planHandoffs = state.open_handoffs
287
+ .filter(h => h.plan_id === plan.id)
288
+ .sort((a, b) => b.created_at.localeCompare(a.created_at));
289
+ // Find handoffs from hard_after plans (prior lane context)
290
+ const depHandoffs = state.open_handoffs
291
+ .filter(h => h.plan_id && item.hard_after.includes(h.plan_id))
292
+ .sort((a, b) => b.created_at.localeCompare(a.created_at));
293
+ const parts = [];
294
+ // Header
295
+ parts.push(`# Assignment: ${plan.text}`);
296
+ parts.push('');
297
+ parts.push(`Plan: ${plan.id}${plan.short_label ? ` (${plan.short_label})` : ''}`);
298
+ parts.push(`Priority: ${plan.priority}`);
299
+ if (plan.assignee)
300
+ parts.push(`Assignee: ${plan.assignee}`);
301
+ if (item.lane)
302
+ parts.push(`Lane: ${item.lane}`);
303
+ if (plan.tags?.length)
304
+ parts.push(`Tags: ${plan.tags.join(', ')}`);
305
+ if (plan.estimated_effort)
306
+ parts.push(`Estimated effort: ${plan.estimated_effort} minutes`);
307
+ parts.push('');
308
+ // Steps if any
309
+ if (plan.steps?.length) {
310
+ parts.push('## Steps');
311
+ for (const step of plan.steps) {
312
+ const check = step.status === 'done' ? '[x]' : '[ ]';
313
+ parts.push(`- ${check} ${step.text}`);
314
+ }
315
+ parts.push('');
316
+ }
317
+ // Rationale from sequence
318
+ if (item.rationale) {
319
+ parts.push(`## Rationale`);
320
+ parts.push(item.rationale);
321
+ parts.push('');
322
+ }
323
+ // Scope hint
324
+ if (item.scope_hint) {
325
+ parts.push(`## Scope hint`);
326
+ parts.push(item.scope_hint);
327
+ parts.push('');
328
+ }
329
+ // Prior handoffs on this plan (compact: shorter excerpts)
330
+ const handoffSliceLen = mode === 'compact' ? 200 : 500;
331
+ if (planHandoffs.length > 0) {
332
+ parts.push('## Prior work on this plan');
333
+ for (const h of planHandoffs.slice(0, mode === 'compact' ? 1 : 3)) {
334
+ parts.push(`### Handoff from ${h.from} (${h.status})`);
335
+ if (h.narrative)
336
+ parts.push(h.narrative.slice(0, handoffSliceLen));
337
+ else
338
+ parts.push(h.text.slice(0, handoffSliceLen));
339
+ parts.push('');
340
+ }
341
+ }
342
+ // Context from dependency handoffs
343
+ if (depHandoffs.length > 0) {
344
+ parts.push('## Context from completed dependencies');
345
+ for (const h of depHandoffs.slice(0, mode === 'compact' ? 1 : 3)) {
346
+ parts.push(`### ${h.from} on ${h.plan_id}`);
347
+ if (h.narrative)
348
+ parts.push(h.narrative.slice(0, handoffSliceLen));
349
+ else
350
+ parts.push(h.text.slice(0, handoffSliceLen));
351
+ parts.push('');
352
+ }
353
+ }
354
+ // Protocol and Available tools — only for 'full' mode.
355
+ // Compact mode is now reserved for task-based agents WITHOUT MCP access
356
+ // (nanoclaw / nemoclaw / zeroclaw). Codex and Mistral Vibe — both
357
+ // task-based with MCP — receive the full Protocol section since
358
+ // pln#496 Phase 1.b, so they actually call
359
+ // bclaw_assignment_update(status: 'completed') at end and the loop
360
+ // converges. See agent-capability.ts:resolveBriefMode for the rule.
361
+ if (mode === 'full') {
362
+ parts.push(buildProtocolSection(options));
363
+ }
364
+ // Codex-specific constraints: focus and speed guidance for sandboxed runs.
365
+ // Gated on agent identity (not brief mode) so future non-codex compact consumers
366
+ // don't inherit sandbox-specific wording. (Codex review cnd#561)
367
+ if (options?.agent === 'codex') {
368
+ parts.push('## Constraints');
369
+ parts.push('- Focus on specified files only — do not explore the broader codebase');
370
+ parts.push('- Produce output quickly; if blocked, capture as trap candidate and move on');
371
+ parts.push('- Sandbox blocks MCP writes: use filesystem writes for candidates, coordinator harvests');
372
+ parts.push('');
373
+ }
374
+ return parts.join('\n');
375
+ }
376
+ export function generateDispatchBrief(options) {
377
+ const briefMode = resolveBriefMode(options.agent);
378
+ const parts = [];
379
+ parts.push(`# Assignment: ${options.task}`);
380
+ parts.push('');
381
+ if (options.scope)
382
+ parts.push(`Scope: ${options.scope}`);
383
+ if (options.claimId)
384
+ parts.push(`Claim: ${options.claimId} (pre-claimed by coordinator)`);
385
+ if (options.worktreePath)
386
+ parts.push(`Worktree: ${options.worktreePath}`);
387
+ parts.push('');
388
+ if (briefMode === 'full') {
389
+ parts.push(buildProtocolSection({
390
+ claimId: options.claimId,
391
+ worktreePath: options.worktreePath,
392
+ assignmentId: options.assignmentId,
393
+ }));
394
+ }
395
+ // Codex-specific constraints: focus and speed guidance for sandboxed runs
396
+ if (options.agent === 'codex') {
397
+ parts.push('## Constraints');
398
+ parts.push('- Focus on specified files only — do not explore the broader codebase');
399
+ parts.push('- Produce output quickly; if blocked, capture as trap candidate and move on');
400
+ parts.push('- Sandbox blocks MCP writes: use filesystem writes for candidates, coordinator harvests');
401
+ parts.push('');
402
+ }
403
+ return parts.join('\n');
404
+ }
405
+ export function scoreAgents(agentPool, plan, activeClaims, cycleAssignments) {
406
+ const W_PREFERENCE = 40;
407
+ const W_CAPABILITY = 30;
408
+ const W_AVAILABILITY = 20;
409
+ const W_LOAD_BALANCE = 10;
410
+ // Count active claims per agent for load balancing
411
+ const claimCounts = new Map();
412
+ for (const claim of activeClaims) {
413
+ claimCounts.set(claim.agent, (claimCounts.get(claim.agent) ?? 0) + 1);
414
+ }
415
+ const maxClaims = Math.max(1, ...claimCounts.values());
416
+ return agentPool.map(agent => {
417
+ // Factor 1: Preference — is this the plan's assignee?
418
+ const preference = (plan.assignee === agent) ? 1.0 : 0.0;
419
+ // Factor 2: Capability — can this agent execute tasks?
420
+ const profile = getCapabilityProfile(agent);
421
+ const canExecute = profile?.role_capabilities.includes('execute') ?? false;
422
+ const canSpawn = profile?.runtime.canBeSpawnedCli ?? false;
423
+ const capability = canExecute ? (canSpawn ? 1.0 : 0.5) : 0.1;
424
+ // Factor 3: Availability — graduated by utilization (claims / max_concurrent_tasks)
425
+ // Include in-cycle assignments so load-balance works within a single dispatch call
426
+ const agentClaims = (claimCounts.get(agent) ?? 0) + (cycleAssignments?.get(agent) ?? 0);
427
+ const maxTasks = profile?.max_concurrent_tasks ?? 1;
428
+ const utilization = Math.min(1.0, agentClaims / maxTasks);
429
+ const availability = 1.0 - (utilization * 0.5); // range [0.5, 1.0]
430
+ // Factor 4: Load balance — normalized by agent's capacity, not raw claim count
431
+ const load_balance = 1.0 - utilization;
432
+ const score = preference * W_PREFERENCE +
433
+ capability * W_CAPABILITY +
434
+ availability * W_AVAILABILITY +
435
+ load_balance * W_LOAD_BALANCE;
436
+ return { agent, score, factors: { preference, capability, availability, load_balance } };
437
+ }).sort((a, b) => b.score - a.score);
438
+ }
439
+ // Re-export checkActiveInstance for consumers who import from dispatcher
440
+ export { checkActiveInstance } from './execution.js';
441
+ export function selectWorktreeBaseForReadyLane(item, analysis) {
442
+ const hardAfter = item.hard_after ?? [];
443
+ if (hardAfter.length === 0)
444
+ return {};
445
+ const donePlanIds = new Set(analysis.done.map((entry) => entry.planId));
446
+ const allHardDepsDone = hardAfter.every((planId) => donePlanIds.has(planId));
447
+ if (!allHardDepsDone)
448
+ return {};
449
+ return {
450
+ baseRef: 'HEAD',
451
+ resetExistingBranch: true,
452
+ reason: `hard_after dependencies already integrated: ${hardAfter.join(', ')}`,
453
+ };
454
+ }
455
+ /**
456
+ * Run a dispatch cycle: analyze the sequence, generate briefs, send assignments.
457
+ */
458
+ export async function dispatch(options, cwd) {
459
+ // Run assignment sweeper before dispatch to detect stuck/expired work
460
+ try {
461
+ sweepAssignments(cwd, { actor: options.dispatcherAgent });
462
+ }
463
+ catch { /* best-effort */ }
464
+ const analysis = analyzeSequence(cwd);
465
+ if (!analysis)
466
+ return null;
467
+ const result = { delivery_plan: [], messages_sent: [], commands: [], skipped: [], warnings: [] };
468
+ // Filter ready lanes
469
+ let readyToAssign = analysis.ready;
470
+ if (options.lanes?.length) {
471
+ readyToAssign = readyToAssign.filter(r => r.lane && options.lanes.includes(r.lane));
472
+ }
473
+ // Match ready items to available agents
474
+ // Normalize: options.agents may arrive as a single string from some MCP clients
475
+ const rawAgents = options.agents;
476
+ const normalizedAgents = rawAgents
477
+ ? (Array.isArray(rawAgents) ? rawAgents : [rawAgents])
478
+ : undefined;
479
+ const agentPool = normalizedAgents?.length
480
+ ? [...normalizedAgents]
481
+ : [...analysis.available_agents];
482
+ // Collect all active claims for scoring
483
+ const allActiveClaims = listClaims(cwd).filter(c => c.status === 'active');
484
+ const max = options.maxAssignments ?? readyToAssign.length;
485
+ let assigned = 0;
486
+ // Track assignments per agent in this dispatch cycle (for multi-slot capacity)
487
+ const cycleAssignments = new Map();
488
+ // Track invoke commands + worktree paths for E2E execution phase
489
+ const preparedEntries = [];
490
+ for (const readyItem of readyToAssign) {
491
+ if (assigned >= max)
492
+ break;
493
+ // Pick agent using 4-factor scoring — iterate through ranked agents
494
+ // to find the first one that passes all guards (idempotency + active instance).
495
+ const scored = scoreAgents(agentPool, readyItem.plan, allActiveClaims, cycleAssignments);
496
+ let targetAgent;
497
+ for (const candidate of scored) {
498
+ // Idempotency: skip if there's already a non-archived assign for this plan+agent
499
+ // BUT allow re-dispatch if the linked claim has been released (stale assignment)
500
+ if (!options.dryRun && hasActiveAssignment(candidate.agent, readyItem.plan.id, cwd)) {
501
+ const hasClaim = allActiveClaims.some(c => c.agent === candidate.agent && c.plan_id === readyItem.plan.id);
502
+ if (hasClaim)
503
+ continue; // truly active — skip
504
+ // Claim released but message not archived: stale assignment, allow re-dispatch
505
+ }
506
+ // Claim-based capacity guard: check claims (existing + this cycle) against max_concurrent_tasks.
507
+ // This is the authoritative capacity check — covers both options.agents and analysis.available_agents paths.
508
+ const existingClaims = allActiveClaims.filter(c => c.agent === candidate.agent).length;
509
+ const inCycleCount = cycleAssignments.get(candidate.agent) ?? 0;
510
+ const maxTasks = getCapabilityProfile(candidate.agent)?.max_concurrent_tasks ?? 1;
511
+ if (existingClaims + inCycleCount >= maxTasks) {
512
+ result.warnings.push(`${candidate.agent}: at capacity (${existingClaims + inCycleCount}/${maxTasks} claims)`);
513
+ continue; // try next agent
514
+ }
515
+ targetAgent = candidate.agent;
516
+ break;
517
+ }
518
+ if (!targetAgent) {
519
+ result.skipped.push({
520
+ plan_id: readyItem.plan.id,
521
+ reason: scored.length === 0
522
+ ? 'No available agent'
523
+ : `All ${scored.length} candidate(s) rejected by guards (active session or existing assignment)`,
524
+ });
525
+ continue;
526
+ }
527
+ // Ensure target agent is registered before creating claims/messages
528
+ ensureAgentRegisteredForDispatch(targetAgent, cwd);
529
+ // Coordinator-owned claim: create before sending the brief (with worktree isolation)
530
+ const claimScope = readyItem.item.scope_hint ?? readyItem.plan.id;
531
+ let claimId = '(dry-run)';
532
+ let worktreePath;
533
+ if (!options.dryRun) {
534
+ const worktreeBase = selectWorktreeBaseForReadyLane(readyItem.item, analysis);
535
+ const claimResult = createCoordinatorClaim({
536
+ agent: targetAgent,
537
+ scope: claimScope,
538
+ description: readyItem.plan.text,
539
+ planId: readyItem.plan.id,
540
+ dispatcherAgent: options.dispatcherAgent,
541
+ sessionId: options.sessionId,
542
+ cwd,
543
+ worktreeBaseRef: worktreeBase.baseRef,
544
+ resetExistingWorktreeBranch: worktreeBase.resetExistingBranch,
545
+ });
546
+ // Scope conflict: a different agent holds this scope — skip this plan
547
+ if (claimResult.scopeConflict) {
548
+ result.skipped.push({
549
+ plan_id: readyItem.plan.id,
550
+ reason: `Scope '${claimScope}' is locked by ${claimResult.conflictAgent} (claim ${claimResult.claimId})`,
551
+ });
552
+ continue;
553
+ }
554
+ claimId = claimResult.claimId;
555
+ worktreePath = claimResult.worktreePath;
556
+ if (claimResult.worktreeWarning) {
557
+ result.warnings.push(`${targetAgent}/${claimScope}: ${claimResult.worktreeWarning}`);
558
+ }
559
+ }
560
+ // --- Dry-run path: skip assignment creation and message sending ---
561
+ if (options.dryRun) {
562
+ const briefMode = resolveBriefMode(targetAgent);
563
+ const brief = generateBrief(readyItem.plan, readyItem.item, cwd, briefMode, { claimId, worktreePath });
564
+ const invokeCmd = buildInvokeCommand(targetAgent, brief);
565
+ if (invokeCmd) {
566
+ const cmdPrefix = buildEnvPrefix(claimId);
567
+ result.commands.push({ agent: targetAgent, lane: readyItem.lane, command: `${cmdPrefix}${invokeCmd.bashCommand}`, shell: process.platform === 'win32' ? 'cmd' : (invokeCmd.shell ? 'bash' : 'sh') });
568
+ }
569
+ const deliveryEntry = { agent: targetAgent, plan_id: readyItem.plan.id, message_id: '(dry-run)', lane: readyItem.lane, channel: 'inbox', claim_id: claimId };
570
+ result.delivery_plan.push(deliveryEntry);
571
+ result.messages_sent.push(deliveryEntry);
572
+ assigned++;
573
+ cycleAssignments.set(targetAgent, (cycleAssignments.get(targetAgent) ?? 0) + 1);
574
+ const dryExisting = allActiveClaims.filter(c => c.agent === targetAgent).length;
575
+ const dryCycle = cycleAssignments.get(targetAgent) ?? 0;
576
+ const dryMax = getCapabilityProfile(targetAgent)?.max_concurrent_tasks ?? 1;
577
+ if (dryExisting + dryCycle >= dryMax) {
578
+ const idx = agentPool.indexOf(targetAgent);
579
+ if (idx >= 0)
580
+ agentPool.splice(idx, 1);
581
+ }
582
+ continue;
583
+ }
584
+ // --- Live path: create assignment FIRST, then brief, then message ---
585
+ // Step 1: Create Assignment entity (Agent SDK runtime protocol)
586
+ let assignmentId;
587
+ try {
588
+ const preId = generateAssignmentId(cwd);
589
+ const assignment = createAssignment({
590
+ id: preId.id,
591
+ short_label: preId.short_label,
592
+ claim_id: claimId,
593
+ plan_id: readyItem.plan.id,
594
+ sequence_id: analysis.sequence.id,
595
+ agent: targetAgent,
596
+ dispatcher_agent: options.dispatcherAgent,
597
+ dispatcher_session_id: options.sessionId,
598
+ scope: readyItem.item.scope_hint ?? readyItem.plan.id,
599
+ description: readyItem.plan.text,
600
+ lane: readyItem.lane,
601
+ worktree_path: worktreePath,
602
+ tags: ['dispatch', ...(readyItem.lane ? [`lane:${readyItem.lane}`] : [])],
603
+ }, cwd);
604
+ assignmentId = assignment.id;
605
+ }
606
+ catch (err) {
607
+ result.warnings.push(`Assignment creation failed for ${readyItem.plan.id}: ${err instanceof Error ? err.message : String(err)}`);
608
+ // Continue without assignment — brief will use legacy protocol
609
+ }
610
+ // Step 2: Generate brief (includes assignment_id only if creation succeeded)
611
+ const briefMode = resolveBriefMode(targetAgent);
612
+ const brief = generateBrief(readyItem.plan, readyItem.item, cwd, briefMode, {
613
+ claimId,
614
+ worktreePath,
615
+ assignmentId, // undefined if creation failed → legacy protocol in brief
616
+ agent: targetAgent,
617
+ });
618
+ // Step 3: Build invoke command
619
+ const invokeCmd = buildInvokeCommand(targetAgent, brief);
620
+ if (invokeCmd) {
621
+ const cmdPrefix = buildEnvPrefix(claimId);
622
+ result.commands.push({
623
+ agent: targetAgent,
624
+ lane: readyItem.lane,
625
+ command: `${cmdPrefix}${invokeCmd.bashCommand}`,
626
+ shell: process.platform === 'win32' ? 'cmd' : (invokeCmd.shell ? 'bash' : 'sh'),
627
+ });
628
+ }
629
+ // Step 4: Send assignment message with assignment_id in payload
630
+ let msgResult;
631
+ try {
632
+ msgResult = sendMessage({
633
+ from: options.dispatcherAgent,
634
+ to: targetAgent,
635
+ type: 'assign',
636
+ text: brief,
637
+ ref: readyItem.plan.id,
638
+ payload: {
639
+ plan_id: readyItem.plan.id,
640
+ plan_short_label: readyItem.plan.short_label,
641
+ sequence_id: analysis.sequence.id,
642
+ lane: readyItem.lane,
643
+ rank: readyItem.item.rank,
644
+ priority: readyItem.plan.priority,
645
+ claim_id: claimId,
646
+ worktree_path: worktreePath,
647
+ ...(assignmentId ? { assignment_id: assignmentId } : {}),
648
+ },
649
+ scope: readyItem.item.scope_hint,
650
+ requires_ack: true,
651
+ claim_id: claimId,
652
+ assignment_id: assignmentId,
653
+ tags: ['dispatch', ...(readyItem.lane ? [`lane:${readyItem.lane}`] : [])],
654
+ author_id: options.dispatcherAgentId,
655
+ session_id: options.sessionId,
656
+ }, cwd);
657
+ }
658
+ catch (msgErr) {
659
+ // If message send fails, transition assignment to failed to avoid zombie
660
+ if (assignmentId) {
661
+ try {
662
+ transitionAssignment(assignmentId, 'offered', { actor: options.dispatcherAgent }, cwd);
663
+ }
664
+ catch { /* ignore */ }
665
+ try {
666
+ transitionAssignment(assignmentId, 'expired', { actor: options.dispatcherAgent, status_reason: `Message delivery failed: ${msgErr instanceof Error ? msgErr.message : String(msgErr)}` }, cwd);
667
+ }
668
+ catch { /* ignore */ }
669
+ }
670
+ result.warnings.push(`Message send failed for ${readyItem.plan.id}: ${msgErr instanceof Error ? msgErr.message : String(msgErr)}`);
671
+ continue;
672
+ }
673
+ // Step 5: Link claim → message and claim → assignment
674
+ if (claimId !== '(dry-run)') {
675
+ try {
676
+ attachAssignmentMessageToClaim(claimId, msgResult.id, cwd);
677
+ }
678
+ catch { /* best-effort */ }
679
+ if (assignmentId) {
680
+ try {
681
+ linkClaimToAssignment(claimId, assignmentId, cwd);
682
+ }
683
+ catch { /* best-effort */ }
684
+ }
685
+ }
686
+ // Step 6: Transition assignment to offered + attach message_id
687
+ if (assignmentId) {
688
+ try {
689
+ transitionAssignment(assignmentId, 'offered', { actor: options.dispatcherAgent }, cwd);
690
+ // Attach message_id to the assignment (wasn't available at creation time)
691
+ patchAssignmentMessageId(assignmentId, msgResult.id, cwd);
692
+ }
693
+ catch { /* best-effort */ }
694
+ }
695
+ const deliveryEntry = {
696
+ agent: targetAgent,
697
+ plan_id: readyItem.plan.id,
698
+ message_id: msgResult.id,
699
+ lane: readyItem.lane,
700
+ channel: 'inbox',
701
+ claim_id: claimId,
702
+ assignment_id: assignmentId,
703
+ };
704
+ result.delivery_plan.push(deliveryEntry);
705
+ result.messages_sent.push(deliveryEntry);
706
+ preparedEntries.push({ deliveryEntry, invokeCmd, worktreePath });
707
+ assigned++;
708
+ // Track assignments this cycle for multi-slot capacity
709
+ cycleAssignments.set(targetAgent, (cycleAssignments.get(targetAgent) ?? 0) + 1);
710
+ // Remove agent from pool only when at capacity (existing claims + this cycle's assignments)
711
+ const existingClaims = allActiveClaims.filter(c => c.agent === targetAgent).length;
712
+ const cycleCount = cycleAssignments.get(targetAgent) ?? 0;
713
+ const maxTasks = getCapabilityProfile(targetAgent)?.max_concurrent_tasks ?? 1;
714
+ if (existingClaims + cycleCount >= maxTasks) {
715
+ const idx = agentPool.indexOf(targetAgent);
716
+ if (idx >= 0)
717
+ agentPool.splice(idx, 1);
718
+ }
719
+ }
720
+ // E2E execution phase: attempt to spawn assigned agents (skip in dry run)
721
+ if (!options.dryRun) {
722
+ const autoExecute = options.autoExecute !== false; // default true
723
+ for (const prepared of preparedEntries) {
724
+ const entry = prepared.deliveryEntry;
725
+ const execResult = await attemptExecution(prepared.invokeCmd, {
726
+ agent: entry.agent,
727
+ autoExecute,
728
+ worktreePath: prepared.worktreePath,
729
+ claimId: entry.claim_id,
730
+ assignmentId: entry.assignment_id,
731
+ dispatcherAgent: options.dispatcherAgent,
732
+ dispatcherAgentId: options.dispatcherAgentId,
733
+ cwd,
734
+ handshakeTimeoutMs: options.handshakeTimeoutMs,
735
+ });
736
+ entry.execution_status = execResult.execution_status;
737
+ if (execResult.pid)
738
+ entry.pid = execResult.pid;
739
+ if (execResult.execution_status === 'delivered_and_started') {
740
+ entry.channel = 'spawned_cli';
741
+ }
742
+ if (execResult.error)
743
+ result.warnings.push(execResult.error);
744
+ if (entry.assignment_id && entry.claim_id) {
745
+ if (execResult.failure_kind === 'spawn_no_handshake') {
746
+ try {
747
+ const run = createAgentRun({
748
+ assignment_id: entry.assignment_id,
749
+ claim_id: entry.claim_id,
750
+ message_id: entry.message_id,
751
+ plan_id: entry.plan_id,
752
+ sequence_id: analysis.sequence.id,
753
+ agent: entry.agent,
754
+ transport: 'cli_spawn',
755
+ status: 'launching',
756
+ scope: prepared.worktreePath ?? entry.plan_id,
757
+ description: `Execution attempt for ${entry.plan_id}`,
758
+ worktree_path: prepared.worktreePath,
759
+ command: execResult.command,
760
+ shell: execResult.shell,
761
+ pid: execResult.pid,
762
+ status_reason: 'CLI spawn launched by dispatcher',
763
+ tags: ['dispatch-run', ...(entry.lane ? [`lane:${entry.lane}`] : [])],
764
+ }, cwd);
765
+ transitionAgentRun(run.id, 'failed', {
766
+ actor: options.dispatcherAgent,
767
+ actor_id: options.dispatcherAgentId,
768
+ pid: execResult.pid,
769
+ status_reason: execResult.error,
770
+ error_message: execResult.error,
771
+ }, cwd);
772
+ }
773
+ catch (runErr) {
774
+ result.warnings.push(`AgentRun creation failed for ${entry.assignment_id}: ${runErr instanceof Error ? runErr.message : String(runErr)}`);
775
+ }
776
+ try {
777
+ transitionAssignment(entry.assignment_id, 'failed', {
778
+ actor: options.dispatcherAgent,
779
+ actor_id: options.dispatcherAgentId,
780
+ error_message: execResult.error,
781
+ status_reason: execResult.error,
782
+ syncAgentRun: false,
783
+ }, cwd);
784
+ }
785
+ catch (assignmentErr) {
786
+ result.warnings.push(`Assignment failure transition failed for ${entry.assignment_id}: ${assignmentErr instanceof Error ? assignmentErr.message : String(assignmentErr)}`);
787
+ }
788
+ continue;
789
+ }
790
+ try {
791
+ const run = createAgentRun({
792
+ assignment_id: entry.assignment_id,
793
+ claim_id: entry.claim_id,
794
+ message_id: entry.message_id,
795
+ plan_id: entry.plan_id,
796
+ sequence_id: analysis.sequence.id,
797
+ agent: entry.agent,
798
+ transport: execResult.execution_status === 'delivered_and_started'
799
+ ? 'cli_spawn'
800
+ : execResult.execution_status === 'command_ready_manual'
801
+ ? 'manual_command'
802
+ : 'inbox_only',
803
+ scope: prepared.worktreePath ?? entry.plan_id,
804
+ description: `Execution attempt for ${entry.plan_id}`,
805
+ worktree_path: prepared.worktreePath,
806
+ command: execResult.command,
807
+ shell: execResult.shell,
808
+ pid: execResult.pid,
809
+ status_reason: execResult.error,
810
+ tags: ['dispatch-run', ...(entry.lane ? [`lane:${entry.lane}`] : [])],
811
+ }, cwd);
812
+ if (execResult.execution_status === 'delivered_and_started') {
813
+ transitionAgentRun(run.id, 'launching', {
814
+ actor: options.dispatcherAgent,
815
+ actor_id: options.dispatcherAgentId,
816
+ pid: execResult.pid,
817
+ status_reason: 'CLI spawn launched by dispatcher',
818
+ }, cwd);
819
+ transitionAgentRun(run.id, 'running', {
820
+ actor: options.dispatcherAgent,
821
+ actor_id: options.dispatcherAgentId,
822
+ pid: execResult.pid,
823
+ status_reason: 'CLI process started',
824
+ }, cwd);
825
+ }
826
+ else if (execResult.execution_status === 'command_ready_manual') {
827
+ transitionAgentRun(run.id, 'waiting_input', {
828
+ actor: options.dispatcherAgent,
829
+ actor_id: options.dispatcherAgentId,
830
+ status_reason: execResult.error ?? 'Awaiting manual command execution',
831
+ }, cwd);
832
+ }
833
+ else {
834
+ transitionAgentRun(run.id, 'waiting_input', {
835
+ actor: options.dispatcherAgent,
836
+ actor_id: options.dispatcherAgentId,
837
+ status_reason: 'Awaiting inbox pickup by assigned agent',
838
+ }, cwd);
839
+ }
840
+ }
841
+ catch (runErr) {
842
+ result.warnings.push(`AgentRun creation failed for ${entry.assignment_id}: ${runErr instanceof Error ? runErr.message : String(runErr)}`);
843
+ }
844
+ }
845
+ }
846
+ }
847
+ return { analysis, result };
848
+ }
849
+ /**
850
+ * Find handoffs that are ready for review:
851
+ * - Status is 'accepted' or 'open' (not closed)
852
+ * - Linked to a plan that is done
853
+ * - No existing non-archived review message for this handoff
854
+ */
855
+ export function findReviewableHandoffs(cwd) {
856
+ const state = loadState(cwd);
857
+ const result = [];
858
+ for (const handoff of state.open_handoffs) {
859
+ if (handoff.status === 'closed')
860
+ continue;
861
+ // Must have a linked plan
862
+ if (!handoff.plan_id)
863
+ continue;
864
+ const plan = state.plan_items.find(p => p.id === handoff.plan_id);
865
+ if (!plan)
866
+ continue;
867
+ if (plan.status !== 'done')
868
+ continue;
869
+ // Check no existing review message for this handoff
870
+ if (hasActiveReviewMessage(handoff.id, cwd))
871
+ continue;
872
+ result.push({ handoff, plan });
873
+ }
874
+ return result;
875
+ }
876
+ /**
877
+ * Check if there's already a non-archived review message for a handoff.
878
+ */
879
+ function hasActiveReviewMessage(handoffId, cwd) {
880
+ const baseDir = path.join(memoryDir(cwd), 'coordination', 'inbox');
881
+ if (!fs.existsSync(baseDir))
882
+ return false;
883
+ const agents = fs.readdirSync(baseDir).filter(f => {
884
+ try {
885
+ return fs.statSync(path.join(baseDir, f)).isDirectory();
886
+ }
887
+ catch {
888
+ return false;
889
+ }
890
+ });
891
+ for (const agent of agents) {
892
+ const agentDir = path.join(baseDir, agent);
893
+ if (!fs.existsSync(agentDir))
894
+ continue;
895
+ const files = fs.readdirSync(agentDir).filter(f => f.endsWith('.json'));
896
+ for (const file of files) {
897
+ try {
898
+ const result = loadVersionedJsonFile('message', path.join(agentDir, file));
899
+ const msg = InboxMessageSchema.parse(result.document);
900
+ if (msg.type === 'review' && msg.ref === handoffId && msg.status !== 'archived') {
901
+ return true;
902
+ }
903
+ }
904
+ catch { /* skip invalid */ }
905
+ }
906
+ }
907
+ return false;
908
+ }
909
+ /**
910
+ * Generate a structured review brief from a handoff.
911
+ */
912
+ export function generateReviewBrief(handoff, plan) {
913
+ const parts = [];
914
+ parts.push('# Code Review Request');
915
+ parts.push('');
916
+ parts.push(`Handoff: ${handoff.id}${handoff.short_label ? ` (${handoff.short_label})` : ''}`);
917
+ parts.push(`Author: ${handoff.from}`);
918
+ if (plan) {
919
+ parts.push(`Plan: ${plan.id}${plan.short_label ? ` (${plan.short_label})` : ''}`);
920
+ parts.push(`Plan description: ${plan.text}`);
921
+ }
922
+ parts.push('');
923
+ // Narrative (the human-readable summary of what was done)
924
+ if (handoff.narrative) {
925
+ parts.push('## What was done');
926
+ parts.push(handoff.narrative);
927
+ parts.push('');
928
+ }
929
+ // Commits
930
+ if (handoff.text) {
931
+ parts.push('## Commits and changes');
932
+ parts.push(handoff.text.slice(0, 2000));
933
+ parts.push('');
934
+ }
935
+ // Diff snapshot
936
+ if (handoff.snapshot?.diff) {
937
+ parts.push('## Diff');
938
+ parts.push('```');
939
+ parts.push(handoff.snapshot.diff.slice(0, 5000));
940
+ parts.push('```');
941
+ parts.push('');
942
+ }
943
+ // Contract
944
+ if (handoff.contract) {
945
+ if (handoff.contract.pre_conditions?.length) {
946
+ parts.push('## Pre-conditions');
947
+ for (const c of handoff.contract.pre_conditions) {
948
+ parts.push(`- ${c}`);
949
+ }
950
+ parts.push('');
951
+ }
952
+ if (handoff.contract.files_touched?.length) {
953
+ parts.push('## Files touched');
954
+ for (const f of handoff.contract.files_touched) {
955
+ parts.push(`- ${f}`);
956
+ }
957
+ parts.push('');
958
+ }
959
+ if (handoff.contract.post_conditions?.length) {
960
+ parts.push('## Post-conditions to verify');
961
+ for (const c of handoff.contract.post_conditions) {
962
+ parts.push(`- ${c}`);
963
+ }
964
+ parts.push('');
965
+ }
966
+ if (handoff.contract.tests_to_verify?.length) {
967
+ parts.push('## Tests to verify');
968
+ for (const t of handoff.contract.tests_to_verify) {
969
+ parts.push(`- ${t}`);
970
+ }
971
+ parts.push('');
972
+ }
973
+ if (handoff.contract.linked_plans?.length) {
974
+ parts.push('## Linked plans');
975
+ for (const lp of handoff.contract.linked_plans) {
976
+ parts.push(`- ${lp}`);
977
+ }
978
+ parts.push('');
979
+ }
980
+ }
981
+ // Plan steps (for checking completeness)
982
+ if (plan?.steps?.length) {
983
+ parts.push('## Plan steps');
984
+ for (const step of plan.steps) {
985
+ const check = step.status === 'done' ? '[x]' : '[ ]';
986
+ parts.push(`- ${check} ${step.text}`);
987
+ }
988
+ parts.push('');
989
+ }
990
+ // Review criteria
991
+ parts.push('## Review criteria');
992
+ parts.push('Evaluate this work on the following criteria. Be direct and critical.');
993
+ parts.push('');
994
+ parts.push('1. **Scope**: Does the work match the plan description? Are there out-of-scope changes?');
995
+ parts.push('2. **Bugs/Regressions**: Any potential bugs, regressions, or logic errors in the changes?');
996
+ parts.push('3. **Completeness**: Are all plan steps addressed? Any missing pieces?');
997
+ parts.push('4. **Tests**: Are the changes adequately tested? Do the tests actually verify the behavior?');
998
+ parts.push('5. **Handoff quality**: Is the narrative clear enough for another agent to continue the work?');
999
+ parts.push('');
1000
+ parts.push('## Output format');
1001
+ parts.push('Respond with:');
1002
+ parts.push('- **Verdict**: APPROVE or REQUEST_CHANGES');
1003
+ parts.push('- **Blocking issues**: (list, or "none")');
1004
+ parts.push('- **Non-blocking suggestions**: (list, or "none")');
1005
+ parts.push('- **Summary**: 2-3 sentence overall assessment');
1006
+ parts.push('');
1007
+ return parts.join('\n');
1008
+ }
1009
+ /**
1010
+ * Dispatch code reviews for completed handoffs.
1011
+ */
1012
+ export function dispatchReview(options, cwd) {
1013
+ const result = { reviews_sent: [], skipped: [] };
1014
+ const state = loadState(cwd);
1015
+ // Find reviewable handoffs
1016
+ let reviewable;
1017
+ if (options.handoffId) {
1018
+ const handoff = state.open_handoffs.find(h => h.id === options.handoffId || h.short_label === options.handoffId);
1019
+ if (!handoff) {
1020
+ result.skipped.push({ handoff_id: options.handoffId, reason: 'Handoff not found' });
1021
+ return result;
1022
+ }
1023
+ if (handoff.status === 'closed') {
1024
+ result.skipped.push({ handoff_id: handoff.id, reason: 'Handoff is closed' });
1025
+ return result;
1026
+ }
1027
+ if (!handoff.plan_id) {
1028
+ result.skipped.push({ handoff_id: handoff.id, reason: 'Handoff has no linked plan' });
1029
+ return result;
1030
+ }
1031
+ const plan = state.plan_items.find(p => p.id === handoff.plan_id);
1032
+ if (!plan) {
1033
+ result.skipped.push({ handoff_id: handoff.id, reason: 'Linked plan not found' });
1034
+ return result;
1035
+ }
1036
+ if (plan.status !== 'done') {
1037
+ result.skipped.push({ handoff_id: handoff.id, reason: 'Linked plan is not done' });
1038
+ return result;
1039
+ }
1040
+ if (hasActiveReviewMessage(handoff.id, cwd)) {
1041
+ result.skipped.push({ handoff_id: handoff.id, reason: 'Active review already exists' });
1042
+ return result;
1043
+ }
1044
+ reviewable = [{ handoff, plan }];
1045
+ }
1046
+ else {
1047
+ reviewable = state.open_handoffs
1048
+ .filter((handoff) => {
1049
+ if (handoff.status === 'closed')
1050
+ return false;
1051
+ if (!handoff.plan_id)
1052
+ return false;
1053
+ const plan = state.plan_items.find((entry) => entry.id === handoff.plan_id);
1054
+ if (!plan || plan.status !== 'done')
1055
+ return false;
1056
+ if (hasActiveReviewMessage(handoff.id, cwd))
1057
+ return false;
1058
+ return true;
1059
+ })
1060
+ .map((handoff) => ({
1061
+ handoff,
1062
+ plan: state.plan_items.find((entry) => entry.id === handoff.plan_id),
1063
+ }));
1064
+ }
1065
+ if (reviewable.length === 0)
1066
+ return result;
1067
+ // Find reviewer agent
1068
+ const agents = listAgentIdentities(cwd);
1069
+ const availableReviewers = agents
1070
+ .filter(a => a.kind !== 'human')
1071
+ .map(a => a.agent_name);
1072
+ for (const { handoff, plan } of reviewable) {
1073
+ // Pick reviewer: prefer explicit, then any available that isn't the author
1074
+ let reviewer = options.reviewer;
1075
+ if (!reviewer) {
1076
+ reviewer = availableReviewers.find(a => a !== handoff.from);
1077
+ }
1078
+ if (!reviewer) {
1079
+ result.skipped.push({ handoff_id: handoff.id, reason: 'No available reviewer (all agents are the author)' });
1080
+ continue;
1081
+ }
1082
+ const brief = generateReviewBrief(handoff, plan);
1083
+ if (options.dryRun) {
1084
+ result.reviews_sent.push({
1085
+ handoff_id: handoff.id,
1086
+ plan_id: plan?.id,
1087
+ reviewer,
1088
+ message_id: '(dry-run)',
1089
+ thread_id: handoff.review?.thread_id,
1090
+ channel: 'inbox',
1091
+ });
1092
+ continue;
1093
+ }
1094
+ const reviewThreadId = handoff.review?.thread_id ?? generateId('thread');
1095
+ // Send review message
1096
+ const msgResult = sendMessage({
1097
+ from: options.dispatcherAgent,
1098
+ to: reviewer,
1099
+ type: 'review',
1100
+ text: brief,
1101
+ ref: handoff.id,
1102
+ thread_id: reviewThreadId,
1103
+ payload: {
1104
+ handoff_id: handoff.id,
1105
+ plan_id: plan?.id,
1106
+ author: handoff.from,
1107
+ },
1108
+ requires_ack: true,
1109
+ tags: ['review', 'auto-review'],
1110
+ author_id: options.dispatcherAgentId,
1111
+ session_id: options.sessionId,
1112
+ }, cwd);
1113
+ applyHandoffUpdates(handoff, {
1114
+ requester: options.dispatcherAgent,
1115
+ reviewer,
1116
+ requested_at: nowISO(),
1117
+ review_thread_id: reviewThreadId,
1118
+ review_message_id: msgResult.id,
1119
+ });
1120
+ persistState(state, cwd);
1121
+ // Open a review Loop on top of the handoff unless the caller opts out.
1122
+ // Best-effort: a loop failure must not break the legacy review dispatch
1123
+ // (inbox message already sent, handoff updates already persisted).
1124
+ let loopId;
1125
+ if (options.openLoop !== false) {
1126
+ try {
1127
+ const authorIdentity = listAgentIdentities(cwd).find((a) => a.agent_name === handoff.from);
1128
+ const reviewerIdentity = listAgentIdentities(cwd).find((a) => a.agent_name === reviewer)
1129
+ ?? ensureAgentRegisteredForDispatch(reviewer, cwd);
1130
+ const creatorActor = options.dispatcherAgentId ?? options.dispatcherAgent;
1131
+ const loop = loopsModule.openLoop({
1132
+ kind: 'review',
1133
+ title: `Review of ${handoff.short_label ?? handoff.id}`,
1134
+ created_by: creatorActor,
1135
+ mode: options.reviewMode ?? 'asymmetric',
1136
+ linked: plan?.id ? { plan_ids: [plan.id] } : undefined,
1137
+ slots: [
1138
+ {
1139
+ role: 'author',
1140
+ agent: handoff.from,
1141
+ ...(authorIdentity?.agent_id ? { agent_id: authorIdentity.agent_id } : {}),
1142
+ },
1143
+ {
1144
+ role: 'reviewer',
1145
+ agent: reviewer,
1146
+ ...(reviewerIdentity?.agent_id ? { agent_id: reviewerIdentity.agent_id } : {}),
1147
+ },
1148
+ ],
1149
+ }, cwd);
1150
+ loopId = loop.id;
1151
+ loopsModule.add_artifact({
1152
+ id: loop.id,
1153
+ actor: creatorActor,
1154
+ artifact: {
1155
+ phase: 'change_summary',
1156
+ type: 'change_summary',
1157
+ ref: { kind: 'handoff', id: handoff.id },
1158
+ },
1159
+ }, cwd);
1160
+ const advanced = loopsModule.advance({ id: loop.id, actor: creatorActor }, cwd);
1161
+ const reviewerSlot = advanced.loop.slots.find((s) => s.role === 'reviewer');
1162
+ if (reviewerSlot) {
1163
+ loopsModule.turn({
1164
+ id: loop.id,
1165
+ slot_id: reviewerSlot.slot_id,
1166
+ actor: creatorActor,
1167
+ assignment_id: msgResult.id,
1168
+ }, cwd);
1169
+ }
1170
+ }
1171
+ catch {
1172
+ // Loop failure doesn't break legacy review dispatch. The handoff +
1173
+ // inbox message stand on their own as the v0 review artifact.
1174
+ loopId = undefined;
1175
+ }
1176
+ }
1177
+ result.reviews_sent.push({
1178
+ handoff_id: handoff.id,
1179
+ plan_id: plan?.id,
1180
+ reviewer,
1181
+ message_id: msgResult.id,
1182
+ thread_id: reviewThreadId,
1183
+ channel: 'inbox',
1184
+ ...(loopId ? { loop_id: loopId } : {}),
1185
+ });
1186
+ }
1187
+ return result;
1188
+ }
1189
+ //# sourceMappingURL=dispatcher.js.map