brainclaw 0.29.2 → 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.
- package/README.md +193 -170
- package/dist/brainclaw-vscode.vsix +0 -0
- package/dist/cli.js +673 -24
- package/dist/commands/accept.js +3 -0
- package/dist/commands/add-step.js +11 -26
- package/dist/commands/agent-board.js +70 -3
- package/dist/commands/audit.js +19 -0
- package/dist/commands/check-policy.js +54 -0
- package/dist/commands/check-security-mcp.js +145 -0
- package/dist/commands/check-security.js +106 -0
- package/dist/commands/claim-resource.js +1 -0
- package/dist/commands/codev.js +672 -0
- package/dist/commands/compact.js +74 -0
- package/dist/commands/complete-step.js +16 -26
- package/dist/commands/constraint.js +8 -20
- package/dist/commands/decision.js +9 -20
- package/dist/commands/delete-plan.js +10 -12
- package/dist/commands/delete-step.js +16 -0
- package/dist/commands/dispatch.js +163 -0
- package/dist/commands/doctor.js +1122 -49
- package/dist/commands/enable-agent.js +1 -0
- package/dist/commands/export.js +280 -22
- package/dist/commands/handoff.js +33 -0
- package/dist/commands/harvest.js +189 -0
- package/dist/commands/hooks.js +82 -25
- package/dist/commands/inbox.js +169 -0
- package/dist/commands/init.js +38 -31
- package/dist/commands/install-hooks.js +71 -44
- package/dist/commands/link.js +89 -0
- package/dist/commands/list-claims.js +48 -3
- package/dist/commands/list-plans.js +129 -25
- package/dist/commands/loops-handlers.js +409 -0
- package/dist/commands/mcp-read-handlers.js +1628 -0
- package/dist/commands/mcp-schemas.generated.js +74 -0
- package/dist/commands/mcp.js +4221 -1501
- package/dist/commands/plan-resource.js +64 -0
- package/dist/commands/plan.js +12 -26
- package/dist/commands/prune.js +37 -2
- package/dist/commands/reflect.js +20 -7
- package/dist/commands/release-claim.js +11 -6
- package/dist/commands/release-notes.js +170 -0
- package/dist/commands/repair.js +210 -0
- package/dist/commands/run-profile.js +57 -0
- package/dist/commands/sequence.js +113 -0
- package/dist/commands/session-end.js +423 -14
- package/dist/commands/session-start.js +214 -41
- package/dist/commands/setup-security.js +103 -0
- package/dist/commands/setup.js +42 -4
- package/dist/commands/stale.js +109 -0
- package/dist/commands/switch.js +100 -2
- package/dist/commands/trap.js +14 -31
- package/dist/commands/update-handoff.js +63 -4
- package/dist/commands/update-plan.js +21 -28
- package/dist/commands/update-step.js +37 -0
- package/dist/commands/upgrade.js +313 -6
- package/dist/commands/usage.js +102 -0
- package/dist/commands/version.js +20 -0
- package/dist/commands/who.js +33 -5
- package/dist/commands/worktree.js +105 -0
- package/dist/core/actions.js +315 -0
- package/dist/core/agent-capability.js +610 -17
- package/dist/core/agent-context.js +7 -1
- package/dist/core/agent-files.js +1169 -85
- package/dist/core/agent-integrations.js +160 -5
- package/dist/core/agent-inventory.js +2 -0
- package/dist/core/agent-profiles.js +93 -0
- package/dist/core/agent-registry.js +162 -30
- package/dist/core/agentrun-reconciler.js +345 -0
- package/dist/core/agentruns.js +424 -0
- package/dist/core/ai-agent-detection.js +31 -10
- package/dist/core/archival.js +77 -0
- package/dist/core/assignment-sweeper.js +82 -0
- package/dist/core/assignments.js +367 -0
- package/dist/core/audit.js +30 -0
- package/dist/core/brainclaw-version.js +94 -2
- package/dist/core/candidates.js +93 -2
- package/dist/core/claims.js +419 -0
- package/dist/core/codev-metrics.js +77 -0
- package/dist/core/codev-personas.js +31 -0
- package/dist/core/codev-plan-gen.js +35 -0
- package/dist/core/codev-prompts.js +74 -0
- package/dist/core/codev-responses.js +62 -0
- package/dist/core/codev-rounds.js +218 -0
- package/dist/core/config.js +4 -0
- package/dist/core/context.js +381 -34
- package/dist/core/coordination.js +201 -6
- package/dist/core/cross-project.js +230 -16
- package/dist/core/default-profiles/doctor.yaml +11 -0
- package/dist/core/default-profiles/janitor.yaml +11 -0
- package/dist/core/default-profiles/onboarder.yaml +11 -0
- package/dist/core/default-profiles/reviewer.yaml +13 -0
- package/dist/core/dispatcher.js +1189 -0
- package/dist/core/duplicates.js +2 -2
- package/dist/core/entity-operations.js +450 -0
- package/dist/core/entity-registry.js +344 -0
- package/dist/core/events.js +106 -2
- package/dist/core/execution-adapters.js +154 -0
- package/dist/core/execution-context.js +63 -0
- package/dist/core/execution-profile.js +270 -0
- package/dist/core/execution.js +255 -0
- package/dist/core/facade-schema.js +81 -0
- package/dist/core/federation-cloud.js +99 -0
- package/dist/core/federation-message.js +52 -0
- package/dist/core/federation-transport.js +65 -0
- package/dist/core/gc-semantic.js +482 -0
- package/dist/core/governance.js +247 -0
- package/dist/core/guards.js +19 -0
- package/dist/core/ideation.js +72 -0
- package/dist/core/identity.js +110 -25
- package/dist/core/ids.js +6 -0
- package/dist/core/input-validation.js +2 -2
- package/dist/core/instruction-templates.js +344 -136
- package/dist/core/io.js +90 -11
- package/dist/core/lock.js +6 -2
- package/dist/core/loops/brief-assembly.js +213 -0
- package/dist/core/loops/facade-schema.js +148 -0
- package/dist/core/loops/index.js +7 -0
- package/dist/core/loops/iteration-engine.js +139 -0
- package/dist/core/loops/lock.js +385 -0
- package/dist/core/loops/store.js +201 -0
- package/dist/core/loops/types.js +403 -0
- package/dist/core/loops/verbs.js +534 -0
- package/dist/core/markdown.js +15 -3
- package/dist/core/memory-compactor.js +432 -0
- package/dist/core/memory-git.js +152 -8
- package/dist/core/messaging.js +278 -0
- package/dist/core/migration.js +32 -1
- package/dist/core/mutation-pipeline.js +4 -2
- package/dist/core/operations/memory-mutation.js +129 -0
- package/dist/core/operations/memory-write.js +78 -0
- package/dist/core/operations/plan.js +190 -0
- package/dist/core/policy.js +169 -0
- package/dist/core/reputation.js +9 -3
- package/dist/core/schema.js +491 -6
- package/dist/core/search.js +21 -2
- package/dist/core/security-cache.js +71 -0
- package/dist/core/security-guard.js +152 -0
- package/dist/core/security-scoring.js +86 -0
- package/dist/core/sequence.js +130 -0
- package/dist/core/socket-client.js +113 -0
- package/dist/core/staleness.js +246 -0
- package/dist/core/state.js +98 -22
- package/dist/core/store-resolution.js +43 -11
- package/dist/core/toml-writer.js +76 -0
- package/dist/core/upgrades/backup.js +232 -0
- package/dist/core/upgrades/health-check.js +169 -0
- package/dist/core/upgrades/patches/candidate-archive.js +145 -0
- package/dist/core/upgrades/patches/handoff-review-strip.js +128 -0
- package/dist/core/upgrades/patches/provenance-rollout.js +136 -0
- package/dist/core/upgrades/schema-version.js +97 -0
- package/dist/core/worktree.js +606 -0
- package/dist/facts.js +114 -0
- package/dist/facts.json +111 -0
- package/docs/architecture/project-refs.md +5 -1
- package/docs/cli.md +690 -43
- package/docs/concepts/ideation-loop.md +317 -0
- package/docs/concepts/loop-engine.md +456 -0
- package/docs/concepts/mcp-governance.md +268 -0
- package/docs/concepts/memory-staleness.md +122 -0
- package/docs/concepts/multi-agent-workflows.md +166 -0
- package/docs/concepts/plans-and-claims.md +31 -6
- package/docs/concepts/project-md-convention.md +35 -0
- package/docs/concepts/troubleshooting.md +220 -0
- package/docs/concepts/upgrade-cli.md +202 -0
- package/docs/concepts/upgrade-dogfood-procedure.md +114 -0
- package/docs/context-format-changelog.md +2 -2
- package/docs/context-format.md +2 -2
- package/docs/index.md +68 -0
- package/docs/integrations/agents.md +15 -16
- package/docs/integrations/cline.md +88 -0
- package/docs/integrations/codex.md +75 -23
- package/docs/integrations/continue.md +60 -0
- package/docs/integrations/copilot.md +67 -9
- package/docs/integrations/kilocode.md +72 -0
- package/docs/integrations/mcp.md +304 -21
- package/docs/integrations/mistral-vibe.md +122 -0
- package/docs/integrations/opencode.md +84 -0
- package/docs/integrations/overview.md +23 -8
- package/docs/integrations/roo.md +74 -0
- package/docs/integrations/windsurf.md +83 -0
- package/docs/mcp-schema-changelog.md +191 -1
- package/docs/playbooks/integration/index.md +121 -0
- package/docs/playbooks/productivity/index.md +102 -0
- package/docs/playbooks/team/index.md +122 -0
- package/docs/product/agent-first-model.md +184 -0
- package/docs/product/entity-model-audit.md +462 -0
- package/docs/quickstart-existing-project.md +135 -0
- package/docs/quickstart.md +124 -37
- package/docs/release-maintenance.md +79 -0
- package/docs/review.md +2 -0
- package/docs/server-operations.md +118 -0
- package/package.json +20 -12
- package/dist/commands/claude-desktop-extension.js +0 -18
- package/dist/commands/diff.js +0 -99
- 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
|