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.
Files changed (59) hide show
  1. package/README.md +25 -4
  2. package/dist/brainclaw-vscode.vsix +0 -0
  3. package/dist/cli/register-capture.js +209 -0
  4. package/dist/cli/register-code-map.js +19 -0
  5. package/dist/cli/register-coordination.js +472 -0
  6. package/dist/cli/register-federation.js +258 -0
  7. package/dist/cli/register-lifecycle.js +436 -0
  8. package/dist/cli/register-memory-context.js +502 -0
  9. package/dist/cli/register-planning.js +167 -0
  10. package/dist/cli/register-review.js +149 -0
  11. package/dist/cli/shared.js +5 -0
  12. package/dist/cli.js +212 -2183
  13. package/dist/commands/dispatch-watch.js +25 -2
  14. package/dist/commands/harvest.js +107 -20
  15. package/dist/commands/mcp-catalog.js +1438 -0
  16. package/dist/commands/mcp-contract.js +33 -0
  17. package/dist/commands/mcp-presentation.js +27 -0
  18. package/dist/commands/mcp-read-handlers.js +72 -36
  19. package/dist/commands/mcp-write-admin.js +328 -0
  20. package/dist/commands/mcp-write-claims.js +864 -0
  21. package/dist/commands/mcp-write-coordination.js +1825 -0
  22. package/dist/commands/mcp-write-entities.js +620 -0
  23. package/dist/commands/mcp-write-memory.js +451 -0
  24. package/dist/commands/mcp-write-sequences.js +116 -0
  25. package/dist/commands/mcp-write-support.js +367 -0
  26. package/dist/commands/mcp.js +261 -5584
  27. package/dist/commands/update-handoff.js +28 -42
  28. package/dist/core/agent-capability.js +38 -16
  29. package/dist/core/agent-files.js +54 -3
  30. package/dist/core/agent-integrations.js +1 -0
  31. package/dist/core/coordination.js +5 -2
  32. package/dist/core/cross-project.js +35 -1
  33. package/dist/core/dispatcher.js +67 -27
  34. package/dist/core/entity-operations.js +335 -12
  35. package/dist/core/entity-registry.js +72 -9
  36. package/dist/core/execution.js +28 -4
  37. package/dist/core/facade-schema.js +18 -4
  38. package/dist/core/handoff-review.js +35 -0
  39. package/dist/core/protocol-tool-policy.js +113 -0
  40. package/dist/core/review-loop-close.js +184 -0
  41. package/dist/core/review-loop-turn-dispatch.js +183 -0
  42. package/dist/core/schema.js +24 -2
  43. package/dist/core/security-detectors.js +35 -6
  44. package/dist/core/security.js +32 -12
  45. package/dist/core/worktree.js +274 -12
  46. package/dist/facts.js +13 -11
  47. package/dist/facts.json +12 -10
  48. package/docs/PROTOCOL.md +7 -3
  49. package/docs/concepts/coordinator-runbook.md +3 -0
  50. package/docs/concepts/dispatch-lifecycle.md +4 -4
  51. package/docs/concepts/loop-engine.md +6 -2
  52. package/docs/concepts/troubleshooting.md +1 -1
  53. package/docs/integrations/codex.md +22 -6
  54. package/docs/integrations/overview.md +1 -1
  55. package/docs/mcp-schema-changelog.md +137 -2
  56. package/docs/playbooks/orchestration.md +1 -1
  57. package/docs/product/entity-model-audit.md +3 -2
  58. package/docs/security.md +22 -1
  59. package/package.json +3 -1
@@ -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
- // Opt-out or can't spawn: return command for manual execution
150
- // Prepend BRAINCLAW_CLAIM_ID so manual copy-paste still routes correctly
151
- if (!options.autoExecute || !spawnCheck.canSpawn) {
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#492 phase 2.c — 'ideate' opens an ideation loop with a proposal seed
5
- // artifact. The handler does NOT yet dispatch turns (driver wire-up is
6
- // phase 2.d). Callers receive loop_id and may drive the loop manually via
7
- // bclaw_loop intent='turn' / 'advance' until the dispatch path lands.
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