brainclaw 1.17.0 → 1.18.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 (80) hide show
  1. package/README.md +5 -5
  2. package/dist/brainclaw-vscode.vsix +0 -0
  3. package/dist/commands/code-map.js +4 -1
  4. package/dist/commands/codev.js +61 -30
  5. package/dist/commands/doctor.js +14 -1
  6. package/dist/commands/harvest.js +196 -42
  7. package/dist/commands/inbox.js +10 -4
  8. package/dist/commands/loop.js +2 -2
  9. package/dist/commands/loops-handlers.js +82 -1
  10. package/dist/commands/mcp-catalog.js +12 -4
  11. package/dist/commands/mcp-read-handlers.js +90 -7
  12. package/dist/commands/mcp-schemas.generated.js +3 -0
  13. package/dist/commands/mcp-write-coordination.js +159 -40
  14. package/dist/commands/mcp.js +11 -2
  15. package/dist/core/agentrun-reconciler.js +171 -7
  16. package/dist/core/agentruns.js +6 -1
  17. package/dist/core/code-map/aggregate.js +473 -0
  18. package/dist/core/code-map/backend.js +36 -10
  19. package/dist/core/code-map/freshness.js +36 -1
  20. package/dist/core/code-map/lang/c/imports.scm +12 -0
  21. package/dist/core/code-map/lang/c/index.js +150 -0
  22. package/dist/core/code-map/lang/c/tags.scm +68 -0
  23. package/dist/core/code-map/lang/cpp/imports.scm +14 -0
  24. package/dist/core/code-map/lang/cpp/index.js +149 -0
  25. package/dist/core/code-map/lang/cpp/tags.scm +87 -0
  26. package/dist/core/code-map/lang/csharp/imports.scm +20 -0
  27. package/dist/core/code-map/lang/csharp/index.js +224 -0
  28. package/dist/core/code-map/lang/csharp/tags.scm +63 -0
  29. package/dist/core/code-map/lang/go/imports.scm +13 -0
  30. package/dist/core/code-map/lang/go/index.js +139 -0
  31. package/dist/core/code-map/lang/go/tags.scm +36 -0
  32. package/dist/core/code-map/lang/providers.js +12 -1
  33. package/dist/core/code-map/lang/ruby/imports.scm +24 -0
  34. package/dist/core/code-map/lang/ruby/index.js +198 -0
  35. package/dist/core/code-map/lang/ruby/tags.scm +49 -0
  36. package/dist/core/code-map/lang/rust/imports.scm +44 -0
  37. package/dist/core/code-map/lang/rust/index.js +136 -0
  38. package/dist/core/code-map/lang/rust/tags.scm +47 -0
  39. package/dist/core/code-map/query.js +229 -80
  40. package/dist/core/code-map/types.js +18 -0
  41. package/dist/core/code-map/work-section.js +8 -7
  42. package/dist/core/codev-responses.js +16 -0
  43. package/dist/core/dispatcher.js +176 -22
  44. package/dist/core/execution-adapters.js +29 -3
  45. package/dist/core/ideation-loop-close.js +124 -0
  46. package/dist/core/loops/artifact-resolver.js +197 -0
  47. package/dist/core/loops/attempt-reservation.js +576 -0
  48. package/dist/core/loops/commit-intent.js +494 -0
  49. package/dist/core/loops/facade-schema.js +48 -0
  50. package/dist/core/loops/impl-bind.js +144 -0
  51. package/dist/core/loops/index.js +1 -1
  52. package/dist/core/loops/iteration-engine.js +29 -0
  53. package/dist/core/loops/lock.js +14 -0
  54. package/dist/core/loops/project-resolution.js +157 -0
  55. package/dist/core/loops/reconcile-turn.js +369 -0
  56. package/dist/core/loops/result-reducers.js +88 -0
  57. package/dist/core/loops/store.js +46 -7
  58. package/dist/core/loops/types.js +139 -11
  59. package/dist/core/loops/verbs.js +9 -3
  60. package/dist/core/loops/verify-command.js +209 -0
  61. package/dist/core/messaging.js +58 -5
  62. package/dist/core/review-loop-close.js +5 -2
  63. package/dist/core/review-loop-turn-dispatch.js +290 -28
  64. package/dist/core/runtime-signals.js +68 -0
  65. package/dist/core/schema.js +24 -0
  66. package/dist/core/worktree.js +24 -0
  67. package/dist/facts.js +9 -9
  68. package/dist/facts.json +8 -8
  69. package/dist/wasm/tree-sitter-c.wasm +0 -0
  70. package/dist/wasm/tree-sitter-c_sharp.wasm +0 -0
  71. package/dist/wasm/tree-sitter-cpp.wasm +0 -0
  72. package/dist/wasm/tree-sitter-go.wasm +0 -0
  73. package/dist/wasm/tree-sitter-ruby.wasm +0 -0
  74. package/dist/wasm/tree-sitter-rust.wasm +0 -0
  75. package/docs/cli.md +1 -1
  76. package/docs/code-map.md +22 -6
  77. package/docs/concepts/loop-engine.md +24 -0
  78. package/docs/concepts/observer-protocol.md +22 -0
  79. package/docs/mcp-schema-changelog.md +43 -1
  80. package/package.json +1 -1
@@ -1,6 +1,9 @@
1
1
  import { ZodError } from 'zod';
2
2
  import { listAgentRuns } from '../core/agentruns.js';
3
3
  import { reconcileAgentRun } from '../core/agentrun-reconciler.js';
4
+ import { findReservationByRunId } from '../core/loops/attempt-reservation.js';
5
+ import { runVerify } from '../core/loops/verify-command.js';
6
+ import { runImplBind } from '../core/loops/impl-bind.js';
4
7
  import { add_artifact, advance, AwaitingFileApplyApprovalError, closeLoop, complete_turn, computeNextExpected, getLoop, IdempotencyKeyReusedError, IdempotencyOwnerMismatchError, listLoopEvents, listLoops, LockLostError, LockTimeoutError, openLoop, pause, provideInput, requestInput, resume, sweepPauseTimeouts, turn, VersionConflictError, withLoopLock, } from '../core/loops/index.js';
5
8
  import { BclawLoopRequestSchema, BCLAW_LOOP_INTENTS, } from '../core/loops/facade-schema.js';
6
9
  // NextExpectedHint type now lives in src/core/loops/next-expected.ts
@@ -179,7 +182,7 @@ function trySweepLoopTimeouts(loop_id, cwd) {
179
182
  }
180
183
  catch { /* best-effort: never block facade on sweep errors */ }
181
184
  }
182
- export function handleBclawLoop(options) {
185
+ export async function handleBclawLoop(options) {
183
186
  const startMs = Date.now();
184
187
  const defaultActor = options.defaultActor ?? 'bclaw_loop';
185
188
  const inferredIntent = inferIntent(options.args);
@@ -218,6 +221,7 @@ export function handleBclawLoop(options) {
218
221
  linked: req.linked,
219
222
  stop_condition: req.stop_condition,
220
223
  mode: req.mode,
224
+ verify: req.verify,
221
225
  created_by: agentId,
222
226
  }, options.cwd);
223
227
  const newEvents = findNewLoopEvents(loop.id, undefined, options.cwd);
@@ -257,7 +261,20 @@ export function handleBclawLoop(options) {
257
261
  continue;
258
262
  if (slotStatus === 'done' || slotStatus === 'failed' || slotStatus === 'cancelled')
259
263
  continue;
264
+ // pln#630 PR2b-c (§13 R6): GET is strictly observational for
265
+ // TURN-OWNED slots. A slot carrying current_turn_id reconciles only
266
+ // via the dedicated mutating reconcile path (never on a read), so a
267
+ // stale/racing read can't phantom-complete its run. Legacy slots
268
+ // (no current_turn_id) keep the intentional lazy reconcile
269
+ // (trp_fdf3e590) that converges silent-completion on access.
270
+ if (slot.current_turn_id)
271
+ continue;
260
272
  for (const run of listAgentRuns(options.cwd, { assignment_id: assignmentId })) {
273
+ // Belt-and-braces (review PR2b-c #D): skip by ACTUAL ownership too,
274
+ // not just the slot pointer — if a run is turn-owned but its slot
275
+ // wasn't stamped (write-ordering), GET must still not mutate it.
276
+ if (findReservationByRunId(run.id, options.cwd))
277
+ continue;
261
278
  reconcileAgentRun(run.id, options.cwd);
262
279
  }
263
280
  }
@@ -426,6 +443,70 @@ export function handleBclawLoop(options) {
426
443
  return successResponse('close', { loop, next_expected: null }, [loopArtifactEntry(loop.id), ...loopEventArtifacts(newEvents)], [sideEffectUpdate('loop', loop.id), ...loopEventSideEffects(newEvents)], [], Date.now() - startMs, summarizeLoop(loop));
427
444
  });
428
445
  }
446
+ case 'verify': {
447
+ // pln#632 — run the loop's opener-configured verify command + record a
448
+ // deterministic verify_report. runVerify manages its OWN two lock scopes (the
449
+ // spawn runs OUT of the lock), so it is NOT wrapped in withLockedLoopMutation.
450
+ const existing = getLoop(req.loop_id, options.cwd);
451
+ if (!existing) {
452
+ return errorResponse('verify', 'not_found', `unknown loop_id ${req.loop_id}`, Date.now() - startMs);
453
+ }
454
+ const beforeEvents = snapshotLoopEvents(req.loop_id, options.cwd);
455
+ const result = runVerify({ loop_id: req.loop_id, actor }, options.cwd);
456
+ const newEvents = findNewLoopEvents(result.thread.id, beforeEvents, options.cwd);
457
+ const summary = result.unconfigured
458
+ ? `verify: loop has no protocol.verify — falling back to an agent-narrated verify_report`
459
+ : result.deduped
460
+ ? `verify: a verify_report already exists for iteration ${result.thread.iteration_count} (idempotent)`
461
+ : `${result.report?.passed ? '✔ verify green' : result.report?.timed_out ? '✖ verify RED (timeout)' : '✖ verify red'}: ${result.report?.command ?? ''}`;
462
+ return successResponse('verify', {
463
+ loop: result.thread,
464
+ verify_report: result.report ?? null,
465
+ deduped: result.deduped,
466
+ unconfigured: result.unconfigured ?? false,
467
+ next_expected: computeNextExpected(result.thread),
468
+ }, [loopArtifactEntry(result.thread.id), ...loopEventArtifacts(newEvents)], [sideEffectUpdate('loop', result.thread.id), ...loopEventSideEffects(newEvents)], [], Date.now() - startMs, summary);
469
+ }
470
+ case 'bind': {
471
+ // pln#632 impl-loop bind — dispatch the loop's linked sequence + advance
472
+ // bind→execute. runImplBind awaits the async spawn (the advance takes its own
473
+ // lock via the verb), so it is NOT wrapped in withLockedLoopMutation — it mirrors
474
+ // coordinate(open_loop)'s async-handler-spawns pattern, not a synchronous verb.
475
+ const existing = getLoop(req.loop_id, options.cwd);
476
+ if (!existing) {
477
+ return errorResponse('bind', 'not_found', `unknown loop_id ${req.loop_id}`, Date.now() - startMs);
478
+ }
479
+ if (existing.kind !== 'implementation') {
480
+ return errorResponse('bind', 'validation_error', `bind is only valid for implementation loops (loop ${req.loop_id} is kind='${existing.kind}'); review/ideation loops dispatch via bclaw_coordinate`, Date.now() - startMs);
481
+ }
482
+ const beforeEvents = snapshotLoopEvents(req.loop_id, options.cwd);
483
+ const bind = await runImplBind({
484
+ loop_id: req.loop_id,
485
+ dispatcherAgent: actor,
486
+ dispatcherAgentId: agentId,
487
+ sessionId: options.sessionId,
488
+ dryRun: req.dry_run,
489
+ lanes: req.lanes,
490
+ autoExecute: req.auto_execute,
491
+ model: req.model,
492
+ maxAssignments: req.max_assignments,
493
+ }, options.cwd);
494
+ const loop = getLoop(req.loop_id, options.cwd);
495
+ const newEvents = findNewLoopEvents(loop.id, beforeEvents, options.cwd);
496
+ const sideEffects = bind.action === 'bound'
497
+ ? [sideEffectUpdate('loop', loop.id), ...loopEventSideEffects(newEvents)]
498
+ : [...loopEventSideEffects(newEvents)];
499
+ return successResponse('bind', {
500
+ loop,
501
+ sequence_id: bind.sequence_id,
502
+ action: bind.action,
503
+ advanced_to: bind.advanced_to ?? null,
504
+ auto_closed: bind.auto_closed ?? false,
505
+ dispatched: bind.messages_sent,
506
+ dispatch: bind.dispatch,
507
+ next_expected: computeNextExpected(loop),
508
+ }, [loopArtifactEntry(loop.id), ...loopEventArtifacts(newEvents)], sideEffects, bind.dispatch?.warnings ?? [], Date.now() - startMs, bind.reason);
509
+ }
429
510
  }
430
511
  }
431
512
  catch (err) {
@@ -360,19 +360,22 @@ export const MCP_READ_TOOLS = [
360
360
  },
361
361
  {
362
362
  name: 'bclaw_read_inbox',
363
- description: 'Read messages from an agent inbox. Returns pending messages by default. Use markAsRead to auto-mark pending messages as read. Supports filtering by status, type, and thread_id.',
363
+ description: 'Read messages from an agent inbox, newest-first. By default returns only ACTIONABLE messages (pending + read) and hides acknowledged/archived — pass includeAll=true, or a specific status, to widen. Message bodies are previewed (~500 chars) unless full=true; the page is size-bounded by budget_tokens so a read can never blow the token budget. Use markAsRead to auto-mark pending messages as read.',
364
364
  annotations: { tier: 'standard', category: 'coordination', headlessApproval: 'auto' },
365
365
  inputSchema: {
366
366
  type: 'object',
367
367
  properties: {
368
368
  agent: { type: 'string', description: 'Agent name whose inbox to read. Defaults to calling agent.' },
369
369
  agentId: { type: 'string', description: 'Registered agent id.' },
370
- status: { type: 'string', description: 'Filter by status: pending, read, acknowledged, archived.' },
370
+ status: { type: 'string', description: 'Filter by an exact status: pending, read, acknowledged, archived. Overrides the actionable default.' },
371
+ includeAll: { type: 'boolean', description: 'Return every status (including acknowledged + archived), disabling the actionable default. Default: false.' },
371
372
  type: { type: 'string', description: 'Filter by message type: assign, review, rfc, info, reply.' },
372
373
  thread_id: { type: 'string', description: 'Filter by thread ID to see a conversation.' },
374
+ full: { type: 'boolean', description: 'Return complete message bodies instead of ~500-char previews. Each previewed message reports text_length + truncated:true. Default: false.' },
373
375
  markAsRead: { type: 'boolean', description: 'Mark pending messages as read. Default: false.' },
374
376
  limit: { type: 'number', description: 'Maximum messages to return (default: 20).' },
375
377
  offset: { type: 'number', description: 'Skip N messages for pagination.' },
378
+ budget_tokens: { type: 'number', description: 'Cap the returned page size (~4 chars/token). Messages are trimmed until the payload fits; has_more/next_offset let you page the rest.' },
376
379
  },
377
380
  },
378
381
  },
@@ -1028,8 +1031,8 @@ const MCP_WRITE_TOOLS = [
1028
1031
  // created a loop structure without dispatching the first turn, so
1029
1032
  // nothing ever ran. Loops are opened via
1030
1033
  // bclaw_coordinate(intent='review', open_loop=true) or intent='ideate'.
1031
- enum: ['get', 'list', 'turn', 'complete_turn', 'advance', 'add_artifact', 'pause', 'resume', 'close'],
1032
- description: 'Loop lifecycle intent for driving turns inside a loop that was already opened via the coordinate facade. To START a loop, use `bclaw_coordinate(intent="review", open_loop=true, targetAgents=[…])` or `intent="ideate"` — that opens the loop AND dispatches the first turn. See docs/concepts/loop-engine.md.',
1034
+ enum: ['get', 'list', 'turn', 'complete_turn', 'advance', 'add_artifact', 'pause', 'resume', 'close', 'bind'],
1035
+ description: 'Loop lifecycle intent for driving turns inside a loop that was already opened via the coordinate facade. To START a loop, use `bclaw_coordinate(intent="review", open_loop=true, targetAgents=[…])` or `intent="ideate"` — that opens the loop AND dispatches the first turn. `bind` (implementation loops only) dispatches the loop\'s linked sequence and advances bind→execute — the engine action for the `bind` phase. See docs/concepts/loop-engine.md.',
1033
1036
  },
1034
1037
  loop_id: { type: 'string', description: 'Target loop id (lop_…). Required for every intent except open and list.' },
1035
1038
  kind: { type: 'string', enum: ['review', 'ideation', 'implementation', 'research', 'debug'], description: 'Loop kind for open / list filter.' },
@@ -1052,6 +1055,11 @@ const MCP_WRITE_TOOLS = [
1052
1055
  outcome: { type: 'string', enum: ['done', 'failed', 'cancelled'], description: 'complete_turn outcome (default done).' },
1053
1056
  failure_reason: { type: 'string', description: 'complete_turn: optional failure/cancel reason.' },
1054
1057
  artifact: { type: 'object', description: 'complete_turn / add_artifact payload: { phase, type, body?, produced_by?, ref? }.' },
1058
+ dry_run: { type: 'boolean', description: 'bind: analyze + report what would dispatch; no spawn, no advance.' },
1059
+ lanes: { type: 'array', items: { type: 'string' }, description: 'bind: restrict the dispatch to specific sequence lanes.' },
1060
+ auto_execute: { type: 'boolean', description: 'bind: deliver briefs without spawning (→ manual launch commands).' },
1061
+ model: { type: 'string', description: 'bind: model override for the dispatched agents.' },
1062
+ max_assignments: { type: 'number', description: 'bind: cap assignments made in this bind.' },
1055
1063
  to_phase: { type: 'string', description: 'advance: explicit target phase (otherwise the next phase).' },
1056
1064
  force: { type: 'boolean', description: 'advance: allow going backwards (increments iteration_count).' },
1057
1065
  reason: { type: 'string', description: 'advance / pause / close: optional reason string.' },
@@ -149,6 +149,7 @@ export function handleMcpReadToolCall(name, args = {}, context = {}) {
149
149
  let agentNameCache;
150
150
  return dispatchReadTool(name, args, {
151
151
  cwd,
152
+ sourceCwd: effective.cwd,
152
153
  activeSource,
153
154
  resolvedProject,
154
155
  projectRoutingApplied,
@@ -1803,6 +1804,8 @@ function dispatchReadTool(name, args, ctx) {
1803
1804
  if (name === 'bclaw_read_inbox') {
1804
1805
  const agentName = args.agent ?? ctx.getAgentName();
1805
1806
  const markAsRead = args.markAsRead === true; // default: false — reading doesn't imply processing
1807
+ const includeAll = args.includeAll === true; // pln#627 Phase A — widen past the actionable default
1808
+ const full = args.full === true; // pln#627 Phase A — return whole bodies, not previews
1806
1809
  const result = readInbox({
1807
1810
  agent: agentName,
1808
1811
  status: args.status,
@@ -1810,21 +1813,77 @@ function dispatchReadTool(name, args, ctx) {
1810
1813
  thread_id: args.thread_id,
1811
1814
  limit: args.limit,
1812
1815
  offset: args.offset,
1816
+ includeAll,
1813
1817
  markAsRead,
1814
1818
  }, cwd);
1815
- const lines = [`Inbox for ${agentName} ${result.total} message(s):`];
1816
- for (const msg of result.messages) {
1819
+ // pln#627 Phase A bound the payload so a single inbox read can never blow
1820
+ // the MCP token budget (root cause: persona/CoDev dumps persisted as inbox
1821
+ // messages, one at 960 KB). Two independent guards:
1822
+ // 1. per-message: preview each body to INBOX_PREVIEW_CHARS unless full=true;
1823
+ // the whole body stays available via full=true (bclaw_get(inbox_message)
1824
+ // is per-agent-scoped and cannot serve one message by id).
1825
+ // 2. whole-page: boundListResult trims messages until the JSON fits the
1826
+ // char budget, the same way bclaw_find / bclaw_search do (~4 chars/token).
1827
+ const INBOX_PREVIEW_CHARS = 500;
1828
+ const projected = result.messages.map((msg) => {
1829
+ const textLength = msg.text.length;
1830
+ const truncated = !full && textLength > INBOX_PREVIEW_CHARS;
1831
+ return {
1832
+ ...msg,
1833
+ text: truncated ? msg.text.slice(0, INBOX_PREVIEW_CHARS) : msg.text,
1834
+ text_length: textLength,
1835
+ truncated,
1836
+ };
1837
+ });
1838
+ const budgetTokens = typeof args.budget_tokens === 'number' && args.budget_tokens > 0 ? args.budget_tokens : undefined;
1839
+ const charBudget = budgetTokens ? Math.min(budgetTokens * 4, DEFAULT_FIND_CHAR_BUDGET) : DEFAULT_FIND_CHAR_BUDGET;
1840
+ const bounded = boundListResult({ entity: 'inbox_message', total: result.total, items: projected }, result.offset, charBudget);
1841
+ const scopeNote = includeAll || args.status ? '' : ' actionable (pending+read); pass includeAll=true for acknowledged/archived';
1842
+ const lines = [`Inbox for ${agentName} — ${result.total} message(s)${scopeNote}:`];
1843
+ for (const msg of bounded.items) {
1817
1844
  const ack = msg.requires_ack ? ' [ACK required]' : '';
1818
1845
  const thread = msg.thread_id ? ` thread:${msg.thread_id}` : '';
1819
1846
  lines.push(` [${msg.short_label ?? msg.id}] ${msg.type} from ${msg.from} (${msg.status})${ack}${thread}`);
1820
- lines.push(` ${msg.text.slice(0, 200)}${msg.text.length > 200 ? '...' : ''}`);
1821
- }
1822
- if (result.messages.length === 0) {
1847
+ const preview = msg.text.slice(0, 200);
1848
+ const more = msg.truncated || msg.text_length > 200;
1849
+ const moreNote = more
1850
+ ? `… (${msg.text_length} chars${msg.truncated ? '; pass full=true for the whole body' : ''})`
1851
+ : '';
1852
+ lines.push(` ${preview}${moreNote}`);
1853
+ }
1854
+ if (bounded.items.length === 0) {
1823
1855
  lines.push(' (no messages)');
1824
1856
  }
1857
+ if (bounded.hint)
1858
+ lines.push('', bounded.hint);
1859
+ const nextActions = bounded.has_more
1860
+ ? [{
1861
+ tool: 'bclaw_read_inbox',
1862
+ args: {
1863
+ ...(args.agent ? { agent: args.agent } : {}),
1864
+ offset: bounded.next_offset,
1865
+ ...(args.limit ? { limit: args.limit } : {}),
1866
+ ...(args.status ? { status: args.status } : {}),
1867
+ ...(includeAll ? { includeAll: true } : {}),
1868
+ },
1869
+ when: 'to fetch the next page',
1870
+ }]
1871
+ : [];
1825
1872
  return {
1826
1873
  content: [{ type: 'text', text: lines.join('\n') }],
1827
- structuredContent: { ...result, schema_version: SCHEMA_VERSION },
1874
+ structuredContent: {
1875
+ total: result.total,
1876
+ offset: result.offset,
1877
+ limit: result.limit,
1878
+ messages: bounded.items,
1879
+ returned: bounded.returned,
1880
+ has_more: bounded.has_more,
1881
+ ...(bounded.next_offset !== undefined ? { next_offset: bounded.next_offset } : {}),
1882
+ ...(bounded.omitted_for_size ? { omitted_for_size: bounded.omitted_for_size } : {}),
1883
+ ...(bounded.hint ? { hint: bounded.hint } : {}),
1884
+ ...(nextActions.length ? { next_actions: nextActions } : {}),
1885
+ schema_version: SCHEMA_VERSION,
1886
+ },
1828
1887
  };
1829
1888
  }
1830
1889
  if (name === 'bclaw_context') {
@@ -1915,9 +1974,33 @@ function dispatchReadTool(name, args, ctx) {
1915
1974
  ` stderr: ${status.runtime.log_files.stderr?.exists ? `${status.runtime.log_files.stderr.size_bytes}B` : 'absent'}`,
1916
1975
  ` git: commits_ahead=${status.runtime.commits_ahead ?? 'n/a'} dirty_tracked=${status.runtime.dirty_tracked ?? 'n/a'}`,
1917
1976
  ];
1977
+ // pln#521 P1 (B4) — routing echo. Operators debugging a dispatch need to see
1978
+ // which project this status was read from, and WHY that project won, without
1979
+ // reverse-engineering cwd + store state. The decision (project_name/
1980
+ // project_cwd) is a first-class field; the reasoning is the `_resolution_trace`
1981
+ // sibling, which by design ships here and nowhere else. Deliberately cheap:
1982
+ // no candidate/nested-store scan on a hot read path. source_cwd is the
1983
+ // PRE-routing cwd (ctx.sourceCwd) — using the routed cwd would make the two
1984
+ // ends identical exactly when a `project` arg hopped stores, i.e. the one
1985
+ // case the trace exists to show.
1986
+ const projectCwd = resolvedProject?.path ?? cwd;
1987
+ const projectName = resolvedProject?.name;
1988
+ const resolutionTrace = {
1989
+ source_cwd: ctx.sourceCwd,
1990
+ effective_cwd: projectCwd,
1991
+ active_source: activeSource,
1992
+ ...(projectRoutingApplied && typeof args.project === 'string' ? { project_arg: args.project } : {}),
1993
+ };
1994
+ lines.push('', `Project: ${projectName ?? '(unnamed)'} — ${projectCwd} (via ${activeSource})`);
1918
1995
  return {
1919
1996
  content: [{ type: 'text', text: lines.join('\n') }],
1920
- structuredContent: { ...status, schema_version: SCHEMA_VERSION },
1997
+ structuredContent: {
1998
+ ...status,
1999
+ project_cwd: projectCwd,
2000
+ ...(projectName ? { project_name: projectName } : {}),
2001
+ _resolution_trace: resolutionTrace,
2002
+ schema_version: SCHEMA_VERSION,
2003
+ },
1921
2004
  };
1922
2005
  }
1923
2006
  throw new Error(`Unknown read tool: ${name}`);
@@ -291,6 +291,9 @@ export const generatedSchemas = {
291
291
  "failed",
292
292
  "cancelled"
293
293
  ]
294
+ },
295
+ "current_turn_id": {
296
+ "type": "string"
294
297
  }
295
298
  },
296
299
  "required": [
@@ -20,12 +20,14 @@ import { appendAuditEntry } from '../core/audit.js';
20
20
  import { nowISO } from '../core/ids.js';
21
21
  import { validateMcpField } from '../core/input-validation.js';
22
22
  import { generateCandidateIdWithLabel, saveCandidate } from '../core/candidates.js';
23
+ import { validateLoopProjectResolution } from '../core/loops/project-resolution.js';
23
24
  import { ackMessage, getThread, hasActiveAssignment, sendMessage } from '../core/messaging.js';
24
25
  import { dispatch, dispatchReview, generateDispatchBrief } from '../core/dispatcher.js';
25
26
  import { CoordinateRequestSchema } from '../core/facade-schema.js';
26
27
  import { buildInvokeCommand, getCapabilityProfile, getSpawnableAgents, resolveModel, validateAgentForDispatch, } from '../core/agent-capability.js';
27
28
  import { attemptExecution } from '../core/execution.js';
28
29
  import { createAgentRun, transitionAgentRun } from '../core/agentruns.js';
30
+ import { prepareTurnOwnedReviewDispatch, turnOwnedReviewEnabled } from '../core/review-loop-turn-dispatch.js';
29
31
  import { createAssignment, generateAssignmentId, patchAssignmentMessageId, transitionAssignment, } from '../core/assignments.js';
30
32
  import { createToolErrorResponse, toolResponse, } from './mcp-contract.js';
31
33
  import { handleMcpReadToolCall } from './mcp-read-handlers.js';
@@ -228,11 +230,13 @@ export function handleBclawSendMessage(args, ctx) {
228
230
  }, cwd);
229
231
  appendAuditEntry({ actor: resolved.identity.agent_name, actor_id: resolved.identity.agent_id, action: 'create', item_id: result.id, item_type: 'message', scope: to }, cwd);
230
232
  const threadInfo = threadId ? ` thread:${threadId}` : '';
233
+ const warningLine = result.warning ? `\n⚠ ${result.warning}` : '';
231
234
  return {
232
235
  response: toolResponse({
233
- content: [{ type: 'text', text: `✔ Message sent: [${result.shortLabel}] ${msgType} → ${to}${threadInfo}` }],
236
+ content: [{ type: 'text', text: `✔ Message sent: [${result.shortLabel}] ${msgType} → ${to}${threadInfo}${warningLine}` }],
234
237
  message_id: result.id,
235
238
  thread_id: threadId,
239
+ ...(result.warning ? { warning: result.warning } : {}),
236
240
  }),
237
241
  };
238
242
  }
@@ -270,7 +274,7 @@ export function handleBclawAckMessage(args, ctx) {
270
274
  }
271
275
  }
272
276
  export async function handleBclawCoordinate(args, ctx) {
273
- const { cwd, connectionSessionId, currentModel } = ctx;
277
+ const { cwd, connectionSessionId, currentModel, effectiveScope } = ctx;
274
278
  const startMs = Date.now();
275
279
  const parseResult = CoordinateRequestSchema.safeParse(args);
276
280
  if (!parseResult.success) {
@@ -356,6 +360,35 @@ export async function handleBclawCoordinate(args, ctx) {
356
360
  warnings.push(`cross-project dispatch (project='${req.project}') — auto-spawn disabled; the target agent picks up the brief async via its own bclaw_work.`);
357
361
  }
358
362
  const effectiveAutoExecute = isCrossProject ? false : req.autoExecute;
363
+ // pln#521 P1 — project resolution gate. A review loop written into the wrong
364
+ // store is worse than one that never opened: candidate, claim, assignment and
365
+ // loop all persist where nobody is watching, and the reviewer spawns against
366
+ // the wrong repo (DGX misroute). So when this store can host several projects
367
+ // and NONE was selected, refuse here — before the pre-flight spawn and before
368
+ // the first write — instead of silently defaulting to cwd. Explicit `project`,
369
+ // a session switch, or an active-project pointer all count as a choice; ref /
370
+ // scope / path never do (B3 rejected, art_e29e88878209). Scoped to the failing
371
+ // path (review + open_loop): every other intent keeps its routing untouched.
372
+ let projectResolution;
373
+ if (req.intent === 'review' && req.open_loop === true) {
374
+ // Resolve from `cwd`, not `dispatchCwd`: an explicit project name is
375
+ // resolvable from the SOURCE store (its links / store chain), which is
376
+ // exactly what produced dispatchCwd above. Re-resolving from the target
377
+ // could fail for a link whose name differs from the target's project_name.
378
+ const resolution = validateLoopProjectResolution({
379
+ cwd,
380
+ projectArg: req.project,
381
+ activeSource: effectiveScope?.active_source,
382
+ });
383
+ if (!resolution.ok) {
384
+ return {
385
+ response: createToolErrorResponse(resolution.code, resolution.message, {
386
+ candidates: resolution.candidates,
387
+ }),
388
+ };
389
+ }
390
+ projectResolution = resolution;
391
+ }
359
392
  // can_30c295b4 / trp#371 Tier 2 — scope-aware dirty-working-tree guard.
360
393
  // Intents that spawn a worktree worker from HEAD can review/edit stale code,
361
394
  // so they are guarded; consult/summarize (no worktree) are not. pln#626
@@ -403,7 +436,7 @@ export async function handleBclawCoordinate(args, ctx) {
403
436
  /** Run E2E execution phase on prepared delivery entries. Returns overall execution status. */
404
437
  const runCoordinateExecution = async (prepared, opts) => {
405
438
  let overall = 'inbox_only';
406
- for (const { entry, invoke, worktreePath } of prepared) {
439
+ for (const { entry, invoke, worktreePath, turnEcho } of prepared) {
407
440
  const execResult = await attemptExecution(invoke, {
408
441
  agent: entry.agent,
409
442
  autoExecute: opts.autoExecute,
@@ -414,6 +447,8 @@ export async function handleBclawCoordinate(args, ctx) {
414
447
  dispatcherAgentId: opts.senderAgentId,
415
448
  cwd: opts.cwd,
416
449
  requireWorktree: true, // pln#531: never spawn a worker in the integration repo
450
+ turnEcho, // pln#630 — turn-owned reviewer (initial dispatch): the ack-wrapper writes the
451
+ // turn-keyed completion sentinel. undefined for every non-turn-owned entry.
417
452
  });
418
453
  entry.execution_status = execResult.execution_status;
419
454
  // pln#626 Phase 1 — carry the machine-readable reason (+ failure_kind) to
@@ -436,7 +471,23 @@ export async function handleBclawCoordinate(args, ctx) {
436
471
  // emit three identical context-free warnings (pln#626 Phase 1 R5).
437
472
  if (execResult.error)
438
473
  opts.warnings.push(`${entry.agent}: ${execResult.error}`);
439
- if (entry.assignment_id && entry.claim_id) {
474
+ if (turnEcho) {
475
+ // pln#630 risk #1 — a turn-owned reviewer's run was ALREADY created (`created`) by
476
+ // prepareTurnOwnedReviewDispatch. Do NOT mint a second run here (double-mint). Transition
477
+ // the deterministic run → running on a real spawn (mirrors dispatchReviewLoopTurn); leave
478
+ // it `created` otherwise so the no-sentinel legacy fallback (turnOwnedLaneEvidence) + the
479
+ // pre-run lease reconciler govern it. Non-turn-owned entries take the unchanged else-branch.
480
+ if (execResult.execution_status === 'delivered_and_started') {
481
+ try {
482
+ transitionAgentRun(turnEcho.run_id, 'running', {
483
+ actor: opts.senderAgent, actor_id: opts.senderAgentId, pid: execResult.pid,
484
+ status_reason: 'turn-owned reviewer spawned by coordinator',
485
+ }, opts.cwd);
486
+ }
487
+ catch { /* best-effort — the reconciler converges if this races */ }
488
+ }
489
+ }
490
+ else if (entry.assignment_id && entry.claim_id) {
440
491
  if (execResult.failure_kind === 'spawn_no_handshake') {
441
492
  try {
442
493
  const run = createAgentRun({
@@ -969,38 +1020,82 @@ export async function handleBclawCoordinate(args, ctx) {
969
1020
  id: claimResult.claimId,
970
1021
  });
971
1022
  let reviewAssignmentId;
972
- try {
973
- const preId = generateAssignmentId(dispatchCwd);
974
- const assignment = createAssignment({
975
- id: preId.id,
976
- short_label: preId.short_label,
977
- claim_id: claimResult.claimId,
1023
+ let reviewTurnEcho;
1024
+ // pln#630 turn-own the INITIAL reviewer dispatch (same default + kill-switch as the
1025
+ // fix cycle). Skipped for cross-project reviews (no local worktree/sentinel → they never
1026
+ // spawn here). WON: prepare minted the DETERMINISTIC assignment + run + turn()-bound the
1027
+ // slot, so we reuse prep.assignmentId for the brief/message/linkage below and carry the
1028
+ // turnEcho so the ack-wrapper writes the turn-keyed sentinel. DENIED: the exactly-once
1029
+ // fence says this dispatch is NOT the spawner — do NOT spawn, do NOT fall back to legacy
1030
+ // (that is the double-spawn hole), and do NOT release the (possibly shared) claim; leave
1031
+ // the slot for reconcile/self-heal. LEGACY: the unchanged inline mint runs.
1032
+ let usedTurnOwned = false;
1033
+ if (turnOwnedReviewEnabled() && !req.project) {
1034
+ const prep = prepareTurnOwnedReviewDispatch({
1035
+ loopId: loop.id,
1036
+ slotId: slot.slot_id,
978
1037
  agent: slot.agent ?? '',
979
- dispatcher_agent: senderAgent,
980
- dispatcher_session_id: connectionSessionId,
981
- scope: reviewScope,
1038
+ agentId: slot.agent_id,
1039
+ phase: 'findings',
1040
+ task: req.task,
982
1041
  description: reviewDescription,
983
- tags: ['coordinate', 'review', 'loop'],
984
- }, dispatchCwd);
985
- reviewAssignmentId = assignment.id;
986
- out.artifacts.push({ type: 'assignment', id: assignment.id });
1042
+ scope: reviewScope,
1043
+ claimId: claimResult.claimId,
1044
+ worktreePath: claimResult.worktreePath,
1045
+ dispatcherAgent: senderAgent,
1046
+ dispatcherAgentId: senderAgentId,
1047
+ sessionId: connectionSessionId,
1048
+ isReviewer: true,
1049
+ cwd: dispatchCwd,
1050
+ });
1051
+ if (prep.kind === 'won') {
1052
+ reviewAssignmentId = prep.assignmentId; // deterministic — harvest correlates on it
1053
+ reviewTurnEcho = { turn_id: prep.turnId, run_id: prep.runId, nonce: prep.nonce };
1054
+ out.artifacts.push({ type: 'assignment', id: prep.assignmentId });
1055
+ usedTurnOwned = true; // prepare already created the assignment + run + bound the slot
1056
+ }
1057
+ else if (prep.kind === 'denied') {
1058
+ out.partial = true;
1059
+ out.warnings.push(`open_loop: turn-owned reviewer dispatch denied for slot ${slot.slot_id} (${prep.reason}); not spawning — slot left for reconcile/self-heal`);
1060
+ continue; // MUST NOT spawn AND MUST NOT legacy-fallback beside a live reservation
1061
+ }
1062
+ // prep.kind === 'legacy' (pre-identity failure) → fall through to the inline mint.
987
1063
  }
988
- catch (asgErr) {
989
- out.warnings.push(`Review assignment creation failed for slot ${slot.slot_id}: ${asgErr instanceof Error ? asgErr.message : String(asgErr)}`);
1064
+ if (!usedTurnOwned) {
1065
+ try {
1066
+ const preId = generateAssignmentId(dispatchCwd);
1067
+ const assignment = createAssignment({
1068
+ id: preId.id,
1069
+ short_label: preId.short_label,
1070
+ claim_id: claimResult.claimId,
1071
+ agent: slot.agent ?? '',
1072
+ dispatcher_agent: senderAgent,
1073
+ dispatcher_session_id: connectionSessionId,
1074
+ scope: reviewScope,
1075
+ description: reviewDescription,
1076
+ tags: ['coordinate', 'review', 'loop'],
1077
+ }, dispatchCwd);
1078
+ reviewAssignmentId = assignment.id;
1079
+ out.artifacts.push({ type: 'assignment', id: assignment.id });
1080
+ }
1081
+ catch (asgErr) {
1082
+ out.warnings.push(`Review assignment creation failed for slot ${slot.slot_id}: ${asgErr instanceof Error ? asgErr.message : String(asgErr)}`);
1083
+ }
1084
+ // pln#628 Focus 4B (BLOCKING 2) — assign the slot NOW that the
1085
+ // claim/assignment exist, binding their ids onto the slot so the
1086
+ // harvest close resolves this exact reviewer by assignment_id. Runs
1087
+ // even if assignment creation failed (undefined id → the harvest
1088
+ // falls back to the legacy agent match for this one slot). (For the
1089
+ // turn-owned WON path, prepare already turn()-bound the slot.)
1090
+ turn({
1091
+ id: loop.id,
1092
+ slot_id: slot.slot_id,
1093
+ actor: creatorActor,
1094
+ input: req.task,
1095
+ assignment_id: reviewAssignmentId,
1096
+ claim_id: claimResult.claimId,
1097
+ }, dispatchCwd);
990
1098
  }
991
- // pln#628 Focus 4B (BLOCKING 2) — assign the slot NOW that the
992
- // claim/assignment exist, binding their ids onto the slot so the
993
- // harvest close resolves this exact reviewer by assignment_id. Runs
994
- // even if assignment creation failed (undefined id → the harvest
995
- // falls back to the legacy agent match for this one slot).
996
- turn({
997
- id: loop.id,
998
- slot_id: slot.slot_id,
999
- actor: creatorActor,
1000
- input: req.task,
1001
- assignment_id: reviewAssignmentId,
1002
- claim_id: claimResult.claimId,
1003
- }, dispatchCwd);
1004
1099
  const reviewBrief = buildCoordinateBrief(slot.agent ?? '', reviewDescription + reviewVerdictBriefSuffix, {
1005
1100
  claimId: claimResult.claimId,
1006
1101
  scope: reviewScope,
@@ -1045,6 +1140,7 @@ export async function handleBclawCoordinate(args, ctx) {
1045
1140
  entry: queued.entry,
1046
1141
  invoke: queued.invoke,
1047
1142
  worktreePath: claimResult.worktreePath,
1143
+ turnEcho: reviewTurnEcho,
1048
1144
  });
1049
1145
  }
1050
1146
  catch (dispatchErr) {
@@ -1105,6 +1201,16 @@ export async function handleBclawCoordinate(args, ctx) {
1105
1201
  result = {
1106
1202
  candidate_id: output.candidateId,
1107
1203
  selected_targets: resolvedAgents,
1204
+ // pln#521 P1 (B4) — echo the routing decision so an operator can see WHERE
1205
+ // the loop landed without reverse-engineering cwd and store state. Present
1206
+ // for open_loop reviews (the gated path); the reasoning behind the decision
1207
+ // ships as `_resolution_trace` on dispatch_status, not here.
1208
+ ...(projectResolution
1209
+ ? {
1210
+ project_cwd: projectResolution.project_cwd,
1211
+ ...(projectResolution.project_name ? { project_name: projectResolution.project_name } : {}),
1212
+ }
1213
+ : {}),
1108
1214
  // pln#626 Phase 1 (review rework) — expose the reviewer delivery entries
1109
1215
  // so review is as honest as assign/reroute: each entry's execution_reason
1110
1216
  // (set by runCoordinateExecution) feeds the top-level derivation, so a
@@ -1550,12 +1656,6 @@ export async function handleBclawCoordinate(args, ctx) {
1550
1656
  slotRole: slot.role,
1551
1657
  memoryProvider: provider,
1552
1658
  });
1553
- turn({
1554
- id: loopId,
1555
- slot_id: slot.slot_id,
1556
- actor: creatorActor,
1557
- input: briefResult.text,
1558
- }, dispatchCwd);
1559
1659
  // pln#626 Phase 2 (Option B) — spawn the critic as a worktree-isolated
1560
1660
  // worker, mirroring the intent=assign / review chain. Each critic gets
1561
1661
  // its OWN claim + worktree (scope is unique per slot) so parallel
@@ -1605,6 +1705,23 @@ export async function handleBclawCoordinate(args, ctx) {
1605
1705
  catch (asgErr) {
1606
1706
  warnings.push(`ideate assignment creation failed for slot ${slot.slot_id}: ${asgErr instanceof Error ? asgErr.message : String(asgErr)}`);
1607
1707
  }
1708
+ // pln#629 — bind the slot to its claim/assignment NOW that both
1709
+ // exist (mirrors the review path, pln#628 BLOCKING 2). The turn()
1710
+ // used to fire BEFORE the assignment was created, leaving
1711
+ // slot.assignment_id undefined: bclaw_loop get's reconcile then
1712
+ // skipped the critic slot (loops-handlers.ts `if (!assignmentId)
1713
+ // continue`) and dispatch_status(lop_) resolved no assignment, so
1714
+ // ideate loops could never be reconciled (trp_dfe0b941 /
1715
+ // trp_2187b340 / trp_1de94516). Runs even if assignment creation
1716
+ // failed (undefined id → legacy agent-match fallback, as review).
1717
+ turn({
1718
+ id: loopId,
1719
+ slot_id: slot.slot_id,
1720
+ actor: creatorActor,
1721
+ input: briefResult.text,
1722
+ assignment_id: criticAssignmentId,
1723
+ claim_id: claimResult.claimId,
1724
+ }, dispatchCwd);
1608
1725
  // pln#626 Phase 2 — the critique-only contract must reach the
1609
1726
  // DELIVERED brief, not just the claim record: buildCoordinateBrief
1610
1727
  // wraps this in a worker envelope, so prepend the constraint + the
@@ -1806,7 +1923,9 @@ export async function handleBclawLoop(args, ctx) {
1806
1923
  }
1807
1924
  // pln#562 step 4 — dispatching a turn hands work to another agent; gate
1808
1925
  // it at the same trust bar as the other dispatch surfaces.
1809
- if (args?.intent === 'turn' && args?.dispatch === true) {
1926
+ // pln#632 `bind` also SPAWNS real workers (it dispatches the loop's linked
1927
+ // sequence), so it is gated at the same 'trusted' bar as turn-dispatch / coordinate.
1928
+ if ((args?.intent === 'turn' && args?.dispatch === true) || args?.intent === 'bind') {
1810
1929
  const resolved = ensureTrust(args, { nameField: 'agent', idField: 'agentId' }, 'trusted', cwd, connectionSessionId);
1811
1930
  if (resolved.error) {
1812
1931
  return { response: createToolErrorResponse(resolved.error.kind, resolved.error.message, resolved.error.details) };
@@ -1814,7 +1933,7 @@ export async function handleBclawLoop(args, ctx) {
1814
1933
  }
1815
1934
  const { handleBclawLoop: runLoopIntent } = await import('./loops-handlers.js');
1816
1935
  const targetCwd = resolveProjectCwd(args?.project, cwd);
1817
- const result = runLoopIntent({ args: args, cwd: targetCwd });
1936
+ const result = await runLoopIntent({ args: args, cwd: targetCwd, sessionId: connectionSessionId });
1818
1937
  return {
1819
1938
  response: toolResponse({
1820
1939
  content: [{ type: 'text', text: result.summary }],