brainclaw 1.15.0 → 1.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -4
- package/dist/brainclaw-vscode.vsix +0 -0
- package/dist/cli/register-capture.js +209 -0
- package/dist/cli/register-code-map.js +19 -0
- package/dist/cli/register-coordination.js +472 -0
- package/dist/cli/register-federation.js +258 -0
- package/dist/cli/register-lifecycle.js +436 -0
- package/dist/cli/register-memory-context.js +502 -0
- package/dist/cli/register-planning.js +167 -0
- package/dist/cli/register-review.js +149 -0
- package/dist/cli/shared.js +5 -0
- package/dist/cli.js +212 -2183
- package/dist/commands/dispatch-watch.js +25 -2
- package/dist/commands/harvest.js +107 -20
- package/dist/commands/mcp-catalog.js +1438 -0
- package/dist/commands/mcp-contract.js +33 -0
- package/dist/commands/mcp-presentation.js +27 -0
- package/dist/commands/mcp-read-handlers.js +72 -36
- package/dist/commands/mcp-write-admin.js +328 -0
- package/dist/commands/mcp-write-claims.js +864 -0
- package/dist/commands/mcp-write-coordination.js +1825 -0
- package/dist/commands/mcp-write-entities.js +620 -0
- package/dist/commands/mcp-write-memory.js +451 -0
- package/dist/commands/mcp-write-sequences.js +116 -0
- package/dist/commands/mcp-write-support.js +367 -0
- package/dist/commands/mcp.js +261 -5584
- package/dist/commands/update-handoff.js +28 -42
- package/dist/core/agent-capability.js +38 -16
- package/dist/core/agent-files.js +54 -3
- package/dist/core/agent-integrations.js +1 -0
- package/dist/core/coordination.js +5 -2
- package/dist/core/cross-project.js +35 -1
- package/dist/core/dispatcher.js +67 -27
- package/dist/core/entity-operations.js +335 -12
- package/dist/core/entity-registry.js +72 -9
- package/dist/core/execution.js +28 -4
- package/dist/core/facade-schema.js +18 -4
- package/dist/core/handoff-review.js +35 -0
- package/dist/core/protocol-tool-policy.js +113 -0
- package/dist/core/review-loop-close.js +184 -0
- package/dist/core/review-loop-turn-dispatch.js +183 -0
- package/dist/core/schema.js +24 -2
- package/dist/core/security-detectors.js +35 -6
- package/dist/core/security.js +32 -12
- package/dist/core/worktree.js +274 -12
- package/dist/facts.js +13 -11
- package/dist/facts.json +12 -10
- package/docs/PROTOCOL.md +7 -3
- package/docs/concepts/coordinator-runbook.md +3 -0
- package/docs/concepts/dispatch-lifecycle.md +4 -4
- package/docs/concepts/loop-engine.md +6 -2
- package/docs/concepts/troubleshooting.md +1 -1
- package/docs/integrations/codex.md +22 -6
- package/docs/integrations/overview.md +1 -1
- package/docs/mcp-schema-changelog.md +137 -2
- package/docs/playbooks/orchestration.md +1 -1
- package/docs/product/entity-model-audit.md +3 -2
- package/docs/security.md +22 -1
- package/package.json +3 -1
package/dist/core/execution.js
CHANGED
|
@@ -143,17 +143,37 @@ export async function attemptExecution(invoke, options) {
|
|
|
143
143
|
const adapter = options.adapter ?? defaultExecutionAdapter;
|
|
144
144
|
// No invoke command available (IDE-only agents, etc.)
|
|
145
145
|
if (!invoke) {
|
|
146
|
-
return { execution_status: 'inbox_only' };
|
|
146
|
+
return { execution_status: 'inbox_only', execution_reason: 'no_invoke_command' };
|
|
147
147
|
}
|
|
148
148
|
const spawnCheck = adapter.canSpawn(options.agent);
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
|
|
149
|
+
// pln#626 Phase 1 — split the old `(!autoExecute || !spawnCheck.canSpawn)`
|
|
150
|
+
// branch, which collapsed two very different outcomes into one silent
|
|
151
|
+
// command_ready_manual (no reason, no failure_kind — it even discarded
|
|
152
|
+
// spawnCheck.reason). Callers could not tell "you didn't ask me to spawn"
|
|
153
|
+
// apart from "I tried and can't".
|
|
154
|
+
// (1) autoExecute explicitly disabled: a deliberate manual handoff, NOT a
|
|
155
|
+
// failure. Prepend BRAINCLAW_CLAIM_ID so manual copy-paste still routes.
|
|
156
|
+
if (!options.autoExecute) {
|
|
152
157
|
const manual = adapter.prepareManualCommand(invoke, options);
|
|
153
158
|
return {
|
|
154
159
|
execution_status: 'command_ready_manual',
|
|
155
160
|
command: manual.command,
|
|
156
161
|
shell: manual.shell,
|
|
162
|
+
execution_reason: 'auto_execute_disabled',
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
// (2) autoExecute requested but the agent cannot be spawned here: this IS a
|
|
166
|
+
// failure of the caller's intent. Surface spawnCheck.reason (previously
|
|
167
|
+
// dropped) instead of returning a bare manual command.
|
|
168
|
+
if (!spawnCheck.canSpawn) {
|
|
169
|
+
const manual = adapter.prepareManualCommand(invoke, options);
|
|
170
|
+
return {
|
|
171
|
+
execution_status: 'command_ready_manual',
|
|
172
|
+
command: manual.command,
|
|
173
|
+
shell: manual.shell,
|
|
174
|
+
error: spawnCheck.reason,
|
|
175
|
+
failure_kind: 'not_spawnable',
|
|
176
|
+
execution_reason: 'not_spawnable',
|
|
157
177
|
};
|
|
158
178
|
}
|
|
159
179
|
// pln#531 — isolation invariant: a spawned worker MUST run in its own
|
|
@@ -179,6 +199,7 @@ export async function attemptExecution(invoke, options) {
|
|
|
179
199
|
shell: manual.shell,
|
|
180
200
|
error: 'Refusing to spawn without an isolated worktree: with no worktree the worker would run in the integration repo and edit the main tree. Fix worktree creation (see claim worktreeWarning) or run the command manually inside a worktree.',
|
|
181
201
|
failure_kind: 'spawn_no_worktree',
|
|
202
|
+
execution_reason: 'spawn_no_worktree',
|
|
182
203
|
};
|
|
183
204
|
}
|
|
184
205
|
// Capacity guard: skip if agent is at max concurrent tasks
|
|
@@ -201,6 +222,7 @@ export async function attemptExecution(invoke, options) {
|
|
|
201
222
|
shell: manual.shell,
|
|
202
223
|
error: `Spawn skipped: ${instanceCheck.reason}. Use the command manually.`,
|
|
203
224
|
failure_kind: 'spawn_capacity',
|
|
225
|
+
execution_reason: 'spawn_capacity',
|
|
204
226
|
};
|
|
205
227
|
}
|
|
206
228
|
}
|
|
@@ -239,6 +261,7 @@ export async function attemptExecution(invoke, options) {
|
|
|
239
261
|
shell: manual.shell,
|
|
240
262
|
error: `Spawn launched (pid ${result.pid}) but assignment ${options.assignmentId} did not acknowledge within ${handshakeTimeoutMs}ms`,
|
|
241
263
|
failure_kind: 'spawn_no_handshake',
|
|
264
|
+
execution_reason: 'spawn_no_handshake',
|
|
242
265
|
pid: result.pid,
|
|
243
266
|
};
|
|
244
267
|
}
|
|
@@ -280,6 +303,7 @@ export async function attemptExecution(invoke, options) {
|
|
|
280
303
|
shell: manual.shell,
|
|
281
304
|
error: `Spawn failed (${errorMsg}), falling back to manual execution`,
|
|
282
305
|
failure_kind: 'spawn_failed',
|
|
306
|
+
execution_reason: 'spawn_failed',
|
|
283
307
|
};
|
|
284
308
|
}
|
|
285
309
|
}
|
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
export const ExecutionStatusSchema = z.enum(['delivered_and_started', 'command_ready_manual', 'inbox_only']);
|
|
3
3
|
export const WorkIntentSchema = z.enum(['execute', 'consult', 'resume', 'review']);
|
|
4
|
-
// pln#
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
4
|
+
// pln#626 — coordinate intents split into three honest contracts:
|
|
5
|
+
// • SPAWNING (assign / review / reroute, + multi-agent ideate): create a claim
|
|
6
|
+
// + worktree and, when autoExecute is on, spawn a worker on the brief.
|
|
7
|
+
// 'ideate' with targetAgents advances to critique and spawns one
|
|
8
|
+
// worktree-isolated critic worker per target (Phase 2, Option B); single-
|
|
9
|
+
// agent ideate just opens the loop for the champion to drive manually via
|
|
10
|
+
// bclaw_loop intent='turn'/'advance'.
|
|
11
|
+
// • INBOX-ONLY (consult): delivers the brief to the target inbox; autoExecute
|
|
12
|
+
// is a no-op here (never silently ignored).
|
|
13
|
+
// • READ-ONLY (summarize): reads a thread and returns a summary — no claim,
|
|
14
|
+
// no dispatch, no execution_status; autoExecute is irrelevant.
|
|
8
15
|
export const CoordinateIntentSchema = z.enum(['assign', 'consult', 'review', 'reroute', 'summarize', 'ideate']);
|
|
9
16
|
export const WorkRequestSchema = z.object({
|
|
10
17
|
intent: WorkIntentSchema,
|
|
@@ -172,6 +179,13 @@ export const FacadeResponseSchema = z.object({
|
|
|
172
179
|
session_id: z.string().optional(),
|
|
173
180
|
warnings: z.array(z.string()),
|
|
174
181
|
execution_status: ExecutionStatusSchema.optional(),
|
|
182
|
+
/**
|
|
183
|
+
* pln#626 Phase 1 — machine-readable reason accompanying execution_status
|
|
184
|
+
* when it is not `delivered_and_started`: auto_execute_disabled (manual
|
|
185
|
+
* handoff), not_spawnable, spawn_no_worktree/capacity/no_handshake/failed,
|
|
186
|
+
* or intent_inbox_only (consult/ideate). Absent when everything spawned.
|
|
187
|
+
*/
|
|
188
|
+
execution_reason: z.string().optional(),
|
|
175
189
|
/** pln#503 phase 3.3: present when execution_status === 'delivered_and_started'. */
|
|
176
190
|
verify_with: VerifyWithSchema.optional(),
|
|
177
191
|
/**
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { nowISO } from './ids.js';
|
|
2
|
+
/**
|
|
3
|
+
* The review sub-fields whose presence in a patch marks the review as
|
|
4
|
+
* "completed" and (re)stamps `reviewed_at`. Single source of truth for the
|
|
5
|
+
* completion rule — shared by the canonical grammar (`updateEntity(handoff)`)
|
|
6
|
+
* and the dispatcher/CLI path (`applyHandoffUpdates`) so the two write paths
|
|
7
|
+
* can never drift (pln#625 Phase 3, Codex review of #84).
|
|
8
|
+
*/
|
|
9
|
+
export const REVIEW_COMPLETION_FIELDS = [
|
|
10
|
+
'verdict',
|
|
11
|
+
'reviewed_by',
|
|
12
|
+
'summary',
|
|
13
|
+
'blocking_issues',
|
|
14
|
+
'suggestions',
|
|
15
|
+
];
|
|
16
|
+
/**
|
|
17
|
+
* Merge a partial review patch onto an existing review (shallow — PATCH
|
|
18
|
+
* semantics: provided fields overwrite, others survive) and stamp
|
|
19
|
+
* `reviewed_at` when the patch introduces a completion field.
|
|
20
|
+
*
|
|
21
|
+
* A caller that explicitly provides `reviewed_at` (e.g. a federation import
|
|
22
|
+
* replaying a prior review) keeps its value; otherwise a completing patch
|
|
23
|
+
* stamps `nowISO()`. The flat-option callers (applyHandoffUpdates) never carry
|
|
24
|
+
* `reviewed_at`, so for them this is an unconditional restamp-on-completion —
|
|
25
|
+
* identical to the pre-extraction behaviour.
|
|
26
|
+
*/
|
|
27
|
+
export function mergeHandoffReview(existing, patch) {
|
|
28
|
+
const merged = { ...(existing ?? {}), ...patch };
|
|
29
|
+
const completed = REVIEW_COMPLETION_FIELDS.some((field) => patch[field] !== undefined);
|
|
30
|
+
if (completed && patch.reviewed_at === undefined) {
|
|
31
|
+
merged.reviewed_at = nowISO();
|
|
32
|
+
}
|
|
33
|
+
return merged;
|
|
34
|
+
}
|
|
35
|
+
//# sourceMappingURL=handoff-review.js.map
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static protocol tool-policy name lists (core-owned, pln#622 PR1).
|
|
3
|
+
*
|
|
4
|
+
* Ces listes statiques REMPLACENT les dérivations depuis les annotations
|
|
5
|
+
* d'ALL_TOOLS ; la cohérence avec le catalogue est garantie par
|
|
6
|
+
* tests/unit/protocol-tool-policy.test.ts.
|
|
7
|
+
*
|
|
8
|
+
* (These STATIC lists REPLACE the derivations from ALL_TOOLS annotations for
|
|
9
|
+
* core consumers: core/ must not import the commands/ MCP layer, so the tool
|
|
10
|
+
* names are materialised here instead of being derived from the catalog at
|
|
11
|
+
* import time. The catalog — src/commands/mcp-catalog.ts — KEEPS deriving its
|
|
12
|
+
* own copies from tool annotations; bidirectional set equality between these
|
|
13
|
+
* static lists and the catalog derivations is enforced by
|
|
14
|
+
* tests/unit/protocol-tool-policy.test.ts.)
|
|
15
|
+
*
|
|
16
|
+
* Zero imports by design — this module must stay a pure leaf.
|
|
17
|
+
*
|
|
18
|
+
* @module
|
|
19
|
+
*/
|
|
20
|
+
/**
|
|
21
|
+
* Tools safe for headless auto-approval (annotation `headlessApproval: 'auto'`
|
|
22
|
+
* in the catalog). Consumed by agent-files writers (Cline autoApprove, Roo
|
|
23
|
+
* alwaysAllow, Codex approval_mode). Order mirrors catalog declaration order
|
|
24
|
+
* so generated agent config files are byte-identical to the derived era.
|
|
25
|
+
*/
|
|
26
|
+
export const MCP_HEADLESS_AUTO_TOOL_NAMES = [
|
|
27
|
+
'bclaw_context',
|
|
28
|
+
'bclaw_search',
|
|
29
|
+
'bclaw_estimation_report',
|
|
30
|
+
'bclaw_list_sequences',
|
|
31
|
+
'bclaw_assignment_events',
|
|
32
|
+
'bclaw_list_agents',
|
|
33
|
+
'bclaw_list_instructions',
|
|
34
|
+
'bclaw_get_capabilities',
|
|
35
|
+
'bclaw_list_tools',
|
|
36
|
+
'bclaw_search_tools',
|
|
37
|
+
'bclaw_doctor',
|
|
38
|
+
'bclaw_history',
|
|
39
|
+
'bclaw_audit',
|
|
40
|
+
'bclaw_get_discovery',
|
|
41
|
+
'bclaw_conflict_check',
|
|
42
|
+
'bclaw_who',
|
|
43
|
+
'bclaw_check_policy',
|
|
44
|
+
'bclaw_check_security',
|
|
45
|
+
'bclaw_read_inbox',
|
|
46
|
+
'bclaw_get_thread',
|
|
47
|
+
'bclaw_dispatch_status',
|
|
48
|
+
'bclaw_code_status',
|
|
49
|
+
'bclaw_code_find',
|
|
50
|
+
'bclaw_code_brief',
|
|
51
|
+
'bclaw_send_message',
|
|
52
|
+
'bclaw_ack_message',
|
|
53
|
+
'bclaw_write_note',
|
|
54
|
+
'bclaw_quick_capture',
|
|
55
|
+
'bclaw_claim',
|
|
56
|
+
'bclaw_release_claim',
|
|
57
|
+
'bclaw_session_start',
|
|
58
|
+
'bclaw_session_end',
|
|
59
|
+
'bclaw_add_step',
|
|
60
|
+
'bclaw_complete_step',
|
|
61
|
+
'bclaw_update_step',
|
|
62
|
+
'bclaw_update_handoff',
|
|
63
|
+
'bclaw_work',
|
|
64
|
+
'bclaw_coordinate',
|
|
65
|
+
'bclaw_loop',
|
|
66
|
+
'bclaw_assignment_update',
|
|
67
|
+
'bclaw_assignment_action',
|
|
68
|
+
'bclaw_harvest_candidates',
|
|
69
|
+
'bclaw_find',
|
|
70
|
+
'bclaw_get',
|
|
71
|
+
];
|
|
72
|
+
/**
|
|
73
|
+
* Narrow "canonical grammar" tool set — the read-side facade entries
|
|
74
|
+
* (session + context) plus the five memory verbs. Consumed by writers
|
|
75
|
+
* (e.g. Hermes' tools.include) that want a minimal advertised surface.
|
|
76
|
+
*/
|
|
77
|
+
export const MCP_CANONICAL_GRAMMAR_TOOL_NAMES = [
|
|
78
|
+
'bclaw_context',
|
|
79
|
+
'bclaw_work',
|
|
80
|
+
'bclaw_find',
|
|
81
|
+
'bclaw_get',
|
|
82
|
+
'bclaw_create',
|
|
83
|
+
'bclaw_update',
|
|
84
|
+
'bclaw_transition',
|
|
85
|
+
];
|
|
86
|
+
/**
|
|
87
|
+
* Tools removed from the MCP surface at the v1.0 cut (Phase 3 slice 3i).
|
|
88
|
+
* Hidden from every `tools/list` response; direct `tools/call` still works
|
|
89
|
+
* as a migration escape hatch.
|
|
90
|
+
*/
|
|
91
|
+
export const REMOVED_IN_V1_TOOLS = new Set([
|
|
92
|
+
'bclaw_list_plans',
|
|
93
|
+
'bclaw_list_candidates',
|
|
94
|
+
'bclaw_list_claims',
|
|
95
|
+
'bclaw_list_actions',
|
|
96
|
+
'bclaw_list_assignments',
|
|
97
|
+
'bclaw_list_runs',
|
|
98
|
+
'bclaw_list_agents', // pln#625 — retired for bclaw_find(entity='agent')
|
|
99
|
+
'bclaw_read_handoff',
|
|
100
|
+
'bclaw_create_plan',
|
|
101
|
+
'bclaw_update_plan',
|
|
102
|
+
'bclaw_create_candidate',
|
|
103
|
+
'bclaw_accept',
|
|
104
|
+
'bclaw_reject',
|
|
105
|
+
'bclaw_get_execution_context',
|
|
106
|
+
'bclaw_get_agent_board',
|
|
107
|
+
'bclaw_get_agent_board_summary',
|
|
108
|
+
'bclaw_dispatch_analysis',
|
|
109
|
+
'bclaw_dispatch_review',
|
|
110
|
+
'bclaw_update_handoff',
|
|
111
|
+
'bclaw_get_context',
|
|
112
|
+
]);
|
|
113
|
+
//# sourceMappingURL=protocol-tool-policy.js.map
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { getLoop } from './loops/store.js';
|
|
2
|
+
import { complete_turn, advance } from './loops/verbs.js';
|
|
3
|
+
import { withLoopLock } from './loops/lock.js';
|
|
4
|
+
/** review-loop:lop_xxx → the loop id (mirrors assignment-reconciler.ts). */
|
|
5
|
+
const REVIEW_LOOP_SCOPE_RE = /^review-loop:(lop_[0-9a-z]+)/;
|
|
6
|
+
const LOOP_TERMINAL = new Set(['completed', 'cancelled', 'blocked']);
|
|
7
|
+
/** Build the fix+re-review brief for a request_changes cycle turn (symmetric). */
|
|
8
|
+
function buildFixCycleTask(summary, iteration) {
|
|
9
|
+
return (`The reviewer requested changes (fix cycle round ${iteration}). `
|
|
10
|
+
+ 'Apply the requested changes DIRECTLY in this worktree (it is the same '
|
|
11
|
+
+ 'checkout, kept across turns so your commits accumulate), then RE-REVIEW '
|
|
12
|
+
+ 'the result. Set review_verdict="approve" once the change is correct and '
|
|
13
|
+
+ 'complete, or "request_changes" to take another pass.'
|
|
14
|
+
+ (summary ? `\n\nRequested changes: ${summary}` : ''));
|
|
15
|
+
}
|
|
16
|
+
/** Mirrors verbs.ts:isVerdictAccepted — reviewer_green fires only on a `verdict`
|
|
17
|
+
* artifact whose body starts with "accepted". */
|
|
18
|
+
function isAcceptedVerdict(artifact) {
|
|
19
|
+
if (artifact.type !== 'verdict')
|
|
20
|
+
return false;
|
|
21
|
+
return /^accepted(?:\b|[:\s])/.test((artifact.body ?? '').trim().toLowerCase());
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Resolve the reviewer slot to complete. STRICT binding first: the active slot
|
|
25
|
+
* whose assignment_id matches this lane's assignment (the #87 coordinate fix
|
|
26
|
+
* stamps it), so symmetric loops target the right reviewer. If OTHER slots are
|
|
27
|
+
* bound but none matches ours, refuse (never complete someone else's slot).
|
|
28
|
+
* Falls back to a single active reviewer / agent match only for legacy unbound
|
|
29
|
+
* slots. Returns undefined when no active reviewer slot is ours — the caller
|
|
30
|
+
* then checks the resume path.
|
|
31
|
+
*/
|
|
32
|
+
function resolveReviewerSlot(loop, assignment) {
|
|
33
|
+
const active = loop.slots.filter((s) => s.role === 'reviewer' && s.status !== 'done' && s.status !== 'cancelled' && s.status !== 'failed');
|
|
34
|
+
if (active.length === 0)
|
|
35
|
+
return undefined;
|
|
36
|
+
if (assignment.id) {
|
|
37
|
+
const bound = active.find((s) => s.assignment_id === assignment.id);
|
|
38
|
+
if (bound)
|
|
39
|
+
return bound;
|
|
40
|
+
// Some active slots are bound to OTHER assignments — do not guess/steal.
|
|
41
|
+
if (active.some((s) => s.assignment_id !== undefined))
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
// Legacy unbound slots: single reviewer, else disambiguate by agent.
|
|
45
|
+
if (active.length === 1)
|
|
46
|
+
return active[0];
|
|
47
|
+
const byAgent = assignment.agent ? active.find((s) => s.agent === assignment.agent) : undefined;
|
|
48
|
+
return byAgent ?? active[0];
|
|
49
|
+
}
|
|
50
|
+
export function closeReviewLoopFromLaneResult(assignment, lane, actor, cwd, options) {
|
|
51
|
+
const scopeMatch = assignment.scope?.match(REVIEW_LOOP_SCOPE_RE);
|
|
52
|
+
if (!scopeMatch)
|
|
53
|
+
return undefined;
|
|
54
|
+
if (!lane.review_verdict)
|
|
55
|
+
return undefined;
|
|
56
|
+
const cycleOnRequestChanges = options?.cycleOnRequestChanges ?? true;
|
|
57
|
+
const loopId = scopeMatch[1];
|
|
58
|
+
const verdict = lane.review_verdict;
|
|
59
|
+
const noop = (reason, loop_status) => ({
|
|
60
|
+
loop_id: loopId, verdict, action: 'noop', reason, loop_status,
|
|
61
|
+
});
|
|
62
|
+
try {
|
|
63
|
+
// Lock the loop so the compound complete_turn + advance can't interleave
|
|
64
|
+
// with a concurrent harvest (BLOCKING 3). All state is re-read inside.
|
|
65
|
+
return withLoopLock({
|
|
66
|
+
cwd,
|
|
67
|
+
intent: 'review-harvest-close',
|
|
68
|
+
agentId: actor,
|
|
69
|
+
scope: { kind: 'loop', loopId },
|
|
70
|
+
work: () => {
|
|
71
|
+
const loop = getLoop(loopId, cwd);
|
|
72
|
+
if (!loop)
|
|
73
|
+
return noop('loop not found');
|
|
74
|
+
if (LOOP_TERMINAL.has(loop.status))
|
|
75
|
+
return noop(`loop already ${loop.status}`, loop.status);
|
|
76
|
+
const slot = resolveReviewerSlot(loop, assignment);
|
|
77
|
+
const acceptedVerdictExists = loop.artifacts.some(isAcceptedVerdict);
|
|
78
|
+
const summary = (lane.review_summary ?? '').trim();
|
|
79
|
+
// ── approve → close on reviewer_green ───────────────────────────────
|
|
80
|
+
if (verdict === 'approve') {
|
|
81
|
+
if (slot) {
|
|
82
|
+
// isVerdictAccepted fires reviewer_green ONLY on an "accepted…" body.
|
|
83
|
+
complete_turn({
|
|
84
|
+
id: loopId, slot_id: slot.slot_id, actor,
|
|
85
|
+
artifact: { phase: loop.current_phase, type: 'verdict', body: `accepted${summary ? `: ${summary}` : ''}` },
|
|
86
|
+
}, cwd);
|
|
87
|
+
}
|
|
88
|
+
else if (!acceptedVerdictExists) {
|
|
89
|
+
// No slot to complete and no accepted verdict recorded → a prior pass
|
|
90
|
+
// already processed this (idempotent no-op).
|
|
91
|
+
return noop('already processed (no active reviewer slot; no accepted verdict to resume)', loop.status);
|
|
92
|
+
}
|
|
93
|
+
// Advance: closes on reviewer_green. Convergent — safe whether we just
|
|
94
|
+
// recorded the verdict or are resuming an interrupted approve.
|
|
95
|
+
const advanced = advance({ id: loopId, actor }, cwd);
|
|
96
|
+
return {
|
|
97
|
+
loop_id: loopId,
|
|
98
|
+
verdict,
|
|
99
|
+
action: advanced.auto_closed ? 'closed' : 'advanced',
|
|
100
|
+
reason: advanced.auto_closed
|
|
101
|
+
? `reviewer_green → loop ${advanced.loop.status}`
|
|
102
|
+
: `accepted verdict recorded → advanced to "${advanced.loop.current_phase}"`,
|
|
103
|
+
loop_status: advanced.loop.status,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
// ── request_changes → autonomous fix cycle (PR2) ────────────────────
|
|
107
|
+
if (!slot) {
|
|
108
|
+
// The cycle already advanced + re-dispatched on the first pass (the
|
|
109
|
+
// re-dispatched slot is now bound to a NEWER assignment, so
|
|
110
|
+
// resolveReviewerSlot returned undefined here → idempotent no-op).
|
|
111
|
+
return noop('already processed (no active reviewer slot to cycle)', loop.status);
|
|
112
|
+
}
|
|
113
|
+
if (!cycleOnRequestChanges) {
|
|
114
|
+
// Report-only path: never advance a cycle it can't follow through on
|
|
115
|
+
// (no re-dispatch, no claim retention). Defer to `harvest --integrate`.
|
|
116
|
+
return noop('request_changes deferred to --integrate (report path does not cycle)', loop.status);
|
|
117
|
+
}
|
|
118
|
+
// Codex review P1 — the autonomous fix cycle is SYMMETRIC-only in v1: it
|
|
119
|
+
// asks the SAME reviewer slot to modify AND re-review in the reused
|
|
120
|
+
// worktree, which is only sound when both roles are the same coding agent
|
|
121
|
+
// (mode='symmetric'). Review loops DEFAULT to asymmetric, where the
|
|
122
|
+
// reviewer must NOT self-fix. For asymmetric, fall back to the PR1
|
|
123
|
+
// behavior: record the verdict, advance linearly to `author_response`,
|
|
124
|
+
// and DO NOT keep the claim / emit a next_turn — the author-fix dispatch
|
|
125
|
+
// is a planned follow-up, so a human drives it. (No re-dispatch means no
|
|
126
|
+
// worktree reuse, so the claim is released by harvest as usual.)
|
|
127
|
+
const symmetric = loop.protocol?.review_mode === 'symmetric';
|
|
128
|
+
complete_turn({
|
|
129
|
+
id: loopId, slot_id: slot.slot_id, actor,
|
|
130
|
+
artifact: { phase: loop.current_phase, type: 'verdict', body: `changes-requested${summary ? `: ${summary}` : ''}` },
|
|
131
|
+
}, cwd);
|
|
132
|
+
if (!symmetric) {
|
|
133
|
+
const advancedAsym = advance({ id: loopId, actor }, cwd);
|
|
134
|
+
return {
|
|
135
|
+
loop_id: loopId,
|
|
136
|
+
verdict,
|
|
137
|
+
action: advancedAsym.auto_closed ? 'closed' : 'advanced',
|
|
138
|
+
reason: advancedAsym.auto_closed
|
|
139
|
+
? `request_changes → loop ${advancedAsym.loop.status}`
|
|
140
|
+
: `request_changes (asymmetric) → advanced to "${advancedAsym.loop.current_phase}"; author-fix dispatch is a follow-up (drive manually)`,
|
|
141
|
+
loop_status: advancedAsym.loop.status,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
// Symmetric: bump the round counter by advancing to the SAME phase
|
|
145
|
+
// (advance treats to_phase <= current as a backward iteration →
|
|
146
|
+
// iteration_count += 1). The post-advance stop_condition (max_iterations
|
|
147
|
+
// n=3) auto-closes the loop as `blocked` once the cap is hit; otherwise
|
|
148
|
+
// the loop stays open and we hand harvest a `next_turn` to re-dispatch
|
|
149
|
+
// into the SAME (kept) worktree so fixes accumulate on one branch.
|
|
150
|
+
const advanced = advance({ id: loopId, to_phase: loop.current_phase, actor }, cwd);
|
|
151
|
+
if (advanced.auto_closed) {
|
|
152
|
+
return {
|
|
153
|
+
loop_id: loopId,
|
|
154
|
+
verdict,
|
|
155
|
+
action: 'closed',
|
|
156
|
+
reason: `request_changes hit iteration cap → loop ${advanced.loop.status} (needs human)`,
|
|
157
|
+
loop_status: advanced.loop.status,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
loop_id: loopId,
|
|
162
|
+
verdict,
|
|
163
|
+
action: 'advanced',
|
|
164
|
+
reason: `request_changes (round ${advanced.loop.iteration_count}) → re-dispatch same reviewer into kept worktree`,
|
|
165
|
+
loop_status: advanced.loop.status,
|
|
166
|
+
keep_claim: true,
|
|
167
|
+
next_turn: {
|
|
168
|
+
slot_id: slot.slot_id,
|
|
169
|
+
role: slot.role,
|
|
170
|
+
agent: slot.agent ?? '',
|
|
171
|
+
agent_id: slot.agent_id,
|
|
172
|
+
phase: advanced.loop.current_phase,
|
|
173
|
+
iteration: advanced.loop.iteration_count,
|
|
174
|
+
task: buildFixCycleTask(summary, advanced.loop.iteration_count),
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
catch (err) {
|
|
181
|
+
return noop(`loop close error (harvest not blocked): ${err instanceof Error ? err.message : String(err)}`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
//# sourceMappingURL=review-loop-close.js.map
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pln#628 Focus 4B PR2 — reusable "dispatch one review-loop turn".
|
|
3
|
+
*
|
|
4
|
+
* PR1 wired the harvest→loop direction (a reviewer verdict advances/closes the
|
|
5
|
+
* loop). PR2 makes the request_changes→fix→re-review cycle autonomous, which
|
|
6
|
+
* means the harvest close path must be able to SPAWN the next turn's worker
|
|
7
|
+
* (the author to apply fixes, then the reviewer to re-review). The full spawn
|
|
8
|
+
* chain — coordinator claim + assignment + slot binding + brief + queued inbox
|
|
9
|
+
* message + CLI spawn — used to live only as closures inside the
|
|
10
|
+
* bclaw_coordinate review handler (mcp-write-coordination.ts). This module
|
|
11
|
+
* lifts that chain into a standalone, closure-free function the harvest path
|
|
12
|
+
* can call.
|
|
13
|
+
*
|
|
14
|
+
* Layering (mirrors review-loop-close.ts's cycle-avoidance note): this is a
|
|
15
|
+
* core module that imports the heavy dispatch primitives (execution, dispatcher,
|
|
16
|
+
* claims, messaging, assignments). review-loop-close.ts stays PURE (loops +
|
|
17
|
+
* schema only) and merely RETURNS a `NextTurn` descriptor; harvest.ts is the
|
|
18
|
+
* command-level orchestrator that owns both and calls this to spawn. Nothing
|
|
19
|
+
* here imports harvest or review-loop-close, so no import cycle is introduced.
|
|
20
|
+
*/
|
|
21
|
+
import { createCoordinatorClaim, attachAssignmentMessageToClaim, linkClaimToAssignment } from './claims.js';
|
|
22
|
+
import { createAssignment, transitionAssignment, generateAssignmentId, patchAssignmentMessageId } from './assignments.js';
|
|
23
|
+
import { turn } from './loops/verbs.js';
|
|
24
|
+
import { generateDispatchBrief } from './dispatcher.js';
|
|
25
|
+
import { sendMessage } from './messaging.js';
|
|
26
|
+
import { buildInvokeCommand, resolveModel } from './agent-capability.js';
|
|
27
|
+
import { attemptExecution } from './execution.js';
|
|
28
|
+
/**
|
|
29
|
+
* The structured signal a reviewer must emit in LANE-RESULT.json so harvest can
|
|
30
|
+
* map its lane back onto the loop. Shared by the initial dispatch and every
|
|
31
|
+
* re-review turn (identical wording keeps the reviewer contract stable).
|
|
32
|
+
*/
|
|
33
|
+
export const REVIEW_VERDICT_BRIEF_SUFFIX = '\n\n## Review verdict (required — drives autonomous loop convergence)\n'
|
|
34
|
+
+ 'In your LANE-RESULT.json set "status":"completed" AND add "review_verdict": '
|
|
35
|
+
+ '"approve" (change is good to merge) or "request_changes" (needs fixes), plus '
|
|
36
|
+
+ '"review_summary":"<one-line rationale>". The coordinator reads review_verdict '
|
|
37
|
+
+ 'to close the review loop on approve, or continue the fix cycle on request_changes.';
|
|
38
|
+
/**
|
|
39
|
+
* Dispatch a single review-loop turn: create the coordinator claim + assignment,
|
|
40
|
+
* bind the slot to them (so harvest resolves the exact slot by assignment_id),
|
|
41
|
+
* build + queue the brief, and spawn the worker CLI.
|
|
42
|
+
*
|
|
43
|
+
* Best-effort and non-throwing: any failure is returned in `.error` so the
|
|
44
|
+
* caller (harvest) can record it as a warning without aborting the harvest —
|
|
45
|
+
* the loop simply stays open awaiting a manual turn. Mirrors the resilience of
|
|
46
|
+
* the initial reviewer dispatch (which pushes a warning and leaves the loop open
|
|
47
|
+
* on failure) rather than the fail-fast style of a user-facing command.
|
|
48
|
+
*/
|
|
49
|
+
export async function dispatchReviewLoopTurn(input) {
|
|
50
|
+
const { loopId, slot, phase } = input;
|
|
51
|
+
// createCoordinatorClaim / sendMessage require a concrete cwd; the harvest
|
|
52
|
+
// caller always supplies one — default defensively for direct callers/tests.
|
|
53
|
+
const cwd = input.cwd ?? process.cwd();
|
|
54
|
+
const agent = slot.agent ?? '';
|
|
55
|
+
const scope = `review-loop:${loopId}`;
|
|
56
|
+
const isReviewer = slot.role === 'reviewer';
|
|
57
|
+
const result = {
|
|
58
|
+
loop_id: loopId,
|
|
59
|
+
slot_id: slot.slot_id,
|
|
60
|
+
role: slot.role,
|
|
61
|
+
agent,
|
|
62
|
+
phase,
|
|
63
|
+
};
|
|
64
|
+
try {
|
|
65
|
+
const description = `Review loop turn for ${loopId} slot ${slot.slot_id} phase ${phase}. ${input.task}`;
|
|
66
|
+
const claimResult = createCoordinatorClaim({
|
|
67
|
+
agent,
|
|
68
|
+
scope,
|
|
69
|
+
description,
|
|
70
|
+
dispatcherAgent: input.dispatcherAgent,
|
|
71
|
+
sessionId: input.sessionId,
|
|
72
|
+
cwd,
|
|
73
|
+
worktreeBaseRef: input.worktreeBaseRef,
|
|
74
|
+
});
|
|
75
|
+
result.claim_id = claimResult.claimId;
|
|
76
|
+
result.worktree_path = claimResult.worktreePath;
|
|
77
|
+
let assignmentId;
|
|
78
|
+
try {
|
|
79
|
+
const preId = generateAssignmentId(cwd);
|
|
80
|
+
const assignment = createAssignment({
|
|
81
|
+
id: preId.id,
|
|
82
|
+
short_label: preId.short_label,
|
|
83
|
+
claim_id: claimResult.claimId,
|
|
84
|
+
agent,
|
|
85
|
+
dispatcher_agent: input.dispatcherAgent,
|
|
86
|
+
dispatcher_session_id: input.sessionId,
|
|
87
|
+
scope,
|
|
88
|
+
description,
|
|
89
|
+
tags: ['coordinate', 'review', 'loop', isReviewer ? 're-review' : 'author-fix'],
|
|
90
|
+
}, cwd);
|
|
91
|
+
assignmentId = assignment.id;
|
|
92
|
+
result.assignment_id = assignment.id;
|
|
93
|
+
}
|
|
94
|
+
catch (asgErr) {
|
|
95
|
+
result.error = `assignment creation failed: ${asgErr instanceof Error ? asgErr.message : String(asgErr)}`;
|
|
96
|
+
}
|
|
97
|
+
// Bind the slot to the new claim/assignment (PR1 BLOCKING 2 invariant): a
|
|
98
|
+
// later harvest must resolve THIS slot by assignment_id, not by agent name
|
|
99
|
+
// (which is ambiguous under symmetric multi-reviewer loops). Runs even if
|
|
100
|
+
// assignment creation failed (undefined id → legacy agent-match fallback).
|
|
101
|
+
turn({
|
|
102
|
+
id: loopId,
|
|
103
|
+
slot_id: slot.slot_id,
|
|
104
|
+
actor: input.dispatcherAgentId ?? input.dispatcherAgent,
|
|
105
|
+
input: input.task,
|
|
106
|
+
assignment_id: assignmentId,
|
|
107
|
+
claim_id: claimResult.claimId,
|
|
108
|
+
}, cwd);
|
|
109
|
+
// Reviewer turns must carry the verdict contract; author-fix turns must not
|
|
110
|
+
// (an author lane has no verdict — it's mapped by scope+slot instead).
|
|
111
|
+
const briefTask = isReviewer ? input.task + REVIEW_VERDICT_BRIEF_SUFFIX : input.task;
|
|
112
|
+
const brief = generateDispatchBrief({
|
|
113
|
+
task: briefTask,
|
|
114
|
+
agent,
|
|
115
|
+
claimId: claimResult.claimId,
|
|
116
|
+
scope,
|
|
117
|
+
worktreePath: claimResult.worktreePath,
|
|
118
|
+
assignmentId,
|
|
119
|
+
});
|
|
120
|
+
const msg = sendMessage({
|
|
121
|
+
from: input.dispatcherAgent,
|
|
122
|
+
to: agent,
|
|
123
|
+
type: 'review',
|
|
124
|
+
text: brief,
|
|
125
|
+
ref: loopId,
|
|
126
|
+
scope,
|
|
127
|
+
requires_ack: true,
|
|
128
|
+
claim_id: claimResult.claimId,
|
|
129
|
+
assignment_id: assignmentId,
|
|
130
|
+
tags: ['coordinate', 'review', 'loop', isReviewer ? 're-review' : 'author-fix'],
|
|
131
|
+
author_id: input.dispatcherAgentId,
|
|
132
|
+
session_id: input.sessionId,
|
|
133
|
+
payload: {
|
|
134
|
+
intent: 'review',
|
|
135
|
+
loop_id: loopId,
|
|
136
|
+
slot_id: slot.slot_id,
|
|
137
|
+
phase,
|
|
138
|
+
scope,
|
|
139
|
+
claim_id: claimResult.claimId,
|
|
140
|
+
...(assignmentId ? { assignment_id: assignmentId } : {}),
|
|
141
|
+
worktree_path: claimResult.worktreePath,
|
|
142
|
+
},
|
|
143
|
+
}, cwd);
|
|
144
|
+
result.message_id = msg.id;
|
|
145
|
+
if (assignmentId) {
|
|
146
|
+
try {
|
|
147
|
+
attachAssignmentMessageToClaim(claimResult.claimId, msg.id, cwd);
|
|
148
|
+
linkClaimToAssignment(claimResult.claimId, assignmentId, cwd);
|
|
149
|
+
transitionAssignment(assignmentId, 'offered', { actor: input.dispatcherAgent }, cwd);
|
|
150
|
+
patchAssignmentMessageId(assignmentId, msg.id, cwd);
|
|
151
|
+
}
|
|
152
|
+
catch (linkErr) {
|
|
153
|
+
result.error = `assignment linkage failed: ${linkErr instanceof Error ? linkErr.message : String(linkErr)}`;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const invoke = buildInvokeCommand(agent, brief, {
|
|
157
|
+
mode: 'worker',
|
|
158
|
+
model: resolveModel(agent, { override: input.model }),
|
|
159
|
+
});
|
|
160
|
+
const execResult = await attemptExecution(invoke, {
|
|
161
|
+
agent,
|
|
162
|
+
autoExecute: true,
|
|
163
|
+
worktreePath: claimResult.worktreePath,
|
|
164
|
+
claimId: claimResult.claimId,
|
|
165
|
+
assignmentId,
|
|
166
|
+
dispatcherAgent: input.dispatcherAgent,
|
|
167
|
+
dispatcherAgentId: input.dispatcherAgentId,
|
|
168
|
+
cwd,
|
|
169
|
+
requireWorktree: true, // never spawn a worker in the integration repo (pln#531)
|
|
170
|
+
});
|
|
171
|
+
result.execution_status = execResult.execution_status;
|
|
172
|
+
result.command = execResult.command;
|
|
173
|
+
result.shell = execResult.shell;
|
|
174
|
+
if (execResult.error && !result.error)
|
|
175
|
+
result.error = execResult.error;
|
|
176
|
+
return result;
|
|
177
|
+
}
|
|
178
|
+
catch (err) {
|
|
179
|
+
result.error = `review-loop turn dispatch failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
180
|
+
return result;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
//# sourceMappingURL=review-loop-turn-dispatch.js.map
|