brainclaw 1.16.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 (85) hide show
  1. package/README.md +22 -8
  2. package/dist/brainclaw-vscode.vsix +0 -0
  3. package/dist/cli/register-review.js +2 -2
  4. package/dist/commands/code-map.js +4 -1
  5. package/dist/commands/codev.js +61 -30
  6. package/dist/commands/doctor.js +14 -1
  7. package/dist/commands/harvest.js +241 -25
  8. package/dist/commands/inbox.js +10 -4
  9. package/dist/commands/loop.js +2 -2
  10. package/dist/commands/loops-handlers.js +82 -1
  11. package/dist/commands/mcp-catalog.js +12 -4
  12. package/dist/commands/mcp-read-handlers.js +90 -7
  13. package/dist/commands/mcp-schemas.generated.js +3 -0
  14. package/dist/commands/mcp-write-coordination.js +159 -40
  15. package/dist/commands/mcp.js +11 -2
  16. package/dist/core/agent-capability.js +7 -2
  17. package/dist/core/agent-files.js +53 -2
  18. package/dist/core/agent-integrations.js +1 -0
  19. package/dist/core/agentrun-reconciler.js +171 -7
  20. package/dist/core/agentruns.js +6 -1
  21. package/dist/core/code-map/aggregate.js +473 -0
  22. package/dist/core/code-map/backend.js +36 -10
  23. package/dist/core/code-map/freshness.js +36 -1
  24. package/dist/core/code-map/lang/c/imports.scm +12 -0
  25. package/dist/core/code-map/lang/c/index.js +150 -0
  26. package/dist/core/code-map/lang/c/tags.scm +68 -0
  27. package/dist/core/code-map/lang/cpp/imports.scm +14 -0
  28. package/dist/core/code-map/lang/cpp/index.js +149 -0
  29. package/dist/core/code-map/lang/cpp/tags.scm +87 -0
  30. package/dist/core/code-map/lang/csharp/imports.scm +20 -0
  31. package/dist/core/code-map/lang/csharp/index.js +224 -0
  32. package/dist/core/code-map/lang/csharp/tags.scm +63 -0
  33. package/dist/core/code-map/lang/go/imports.scm +13 -0
  34. package/dist/core/code-map/lang/go/index.js +139 -0
  35. package/dist/core/code-map/lang/go/tags.scm +36 -0
  36. package/dist/core/code-map/lang/providers.js +12 -1
  37. package/dist/core/code-map/lang/ruby/imports.scm +24 -0
  38. package/dist/core/code-map/lang/ruby/index.js +198 -0
  39. package/dist/core/code-map/lang/ruby/tags.scm +49 -0
  40. package/dist/core/code-map/lang/rust/imports.scm +44 -0
  41. package/dist/core/code-map/lang/rust/index.js +136 -0
  42. package/dist/core/code-map/lang/rust/tags.scm +47 -0
  43. package/dist/core/code-map/query.js +229 -80
  44. package/dist/core/code-map/types.js +18 -0
  45. package/dist/core/code-map/work-section.js +8 -7
  46. package/dist/core/codev-responses.js +16 -0
  47. package/dist/core/dispatcher.js +209 -29
  48. package/dist/core/execution-adapters.js +29 -3
  49. package/dist/core/ideation-loop-close.js +124 -0
  50. package/dist/core/loops/artifact-resolver.js +197 -0
  51. package/dist/core/loops/attempt-reservation.js +576 -0
  52. package/dist/core/loops/commit-intent.js +494 -0
  53. package/dist/core/loops/facade-schema.js +48 -0
  54. package/dist/core/loops/impl-bind.js +144 -0
  55. package/dist/core/loops/index.js +1 -1
  56. package/dist/core/loops/iteration-engine.js +29 -0
  57. package/dist/core/loops/lock.js +14 -0
  58. package/dist/core/loops/project-resolution.js +157 -0
  59. package/dist/core/loops/reconcile-turn.js +369 -0
  60. package/dist/core/loops/result-reducers.js +88 -0
  61. package/dist/core/loops/store.js +46 -7
  62. package/dist/core/loops/types.js +139 -11
  63. package/dist/core/loops/verbs.js +9 -3
  64. package/dist/core/loops/verify-command.js +209 -0
  65. package/dist/core/messaging.js +58 -5
  66. package/dist/core/review-loop-close.js +106 -34
  67. package/dist/core/review-loop-turn-dispatch.js +445 -0
  68. package/dist/core/runtime-signals.js +68 -0
  69. package/dist/core/schema.js +34 -0
  70. package/dist/core/worktree.js +240 -22
  71. package/dist/facts.js +10 -10
  72. package/dist/facts.json +9 -9
  73. package/dist/wasm/tree-sitter-c.wasm +0 -0
  74. package/dist/wasm/tree-sitter-c_sharp.wasm +0 -0
  75. package/dist/wasm/tree-sitter-cpp.wasm +0 -0
  76. package/dist/wasm/tree-sitter-go.wasm +0 -0
  77. package/dist/wasm/tree-sitter-ruby.wasm +0 -0
  78. package/dist/wasm/tree-sitter-rust.wasm +0 -0
  79. package/docs/cli.md +1 -1
  80. package/docs/code-map.md +22 -6
  81. package/docs/concepts/loop-engine.md +28 -2
  82. package/docs/concepts/observer-protocol.md +22 -0
  83. package/docs/integrations/codex.md +19 -3
  84. package/docs/mcp-schema-changelog.md +43 -1
  85. package/package.json +1 -1
@@ -21,12 +21,15 @@ export function runInboxList(options) {
21
21
  }
22
22
  const effectiveCwd = cwd ?? process.cwd();
23
23
  const agent = options.agent ?? resolveCurrentAgentName(effectiveCwd) ?? 'unknown';
24
- const status = options.all ? undefined : options.status ?? 'pending';
24
+ // --all widens past the core actionable default (pln#627 Phase A); without
25
+ // includeAll, a bare undefined status now hides acknowledged + archived.
26
+ const includeAll = options.all === true;
27
+ const status = includeAll ? undefined : options.status ?? 'pending';
25
28
  const msgType = options.type;
26
29
  const threadId = options.thread;
27
30
  let messages;
28
31
  if (options.localOnly) {
29
- const result = readInbox({ agent, status, type: msgType, thread_id: threadId, markAsRead: false }, effectiveCwd);
32
+ const result = readInbox({ agent, status, includeAll, type: msgType, thread_id: threadId, markAsRead: false }, effectiveCwd);
30
33
  messages = result.messages;
31
34
  }
32
35
  else {
@@ -36,7 +39,7 @@ export function runInboxList(options) {
36
39
  for (const store of chain) {
37
40
  try {
38
41
  // Fetch all messages from this store (no pagination limit)
39
- const storeResult = readInbox({ agent, status, type: msgType, thread_id: threadId, markAsRead: false, limit: 1_000_000 }, store.cwd);
42
+ const storeResult = readInbox({ agent, status, includeAll, type: msgType, thread_id: threadId, markAsRead: false, limit: 1_000_000 }, store.cwd);
40
43
  for (const msg of storeResult.messages) {
41
44
  if (!seenIds.has(msg.id)) {
42
45
  seenIds.add(msg.id);
@@ -48,7 +51,7 @@ export function runInboxList(options) {
48
51
  }
49
52
  // Fallback when no chain found
50
53
  if (messages.length === 0 && chain.length === 0) {
51
- const result = readInbox({ agent, status, type: msgType, thread_id: threadId, markAsRead: false }, effectiveCwd);
54
+ const result = readInbox({ agent, status, includeAll, type: msgType, thread_id: threadId, markAsRead: false }, effectiveCwd);
52
55
  messages = result.messages;
53
56
  }
54
57
  }
@@ -140,6 +143,9 @@ export function runInboxSend(to, text, options) {
140
143
  }
141
144
  else {
142
145
  console.log(`✔ Message sent: [${result.shortLabel}] ${result.type} → ${result.to}`);
146
+ if (result.warning) {
147
+ console.warn(`⚠ ${result.warning}`);
148
+ }
143
149
  }
144
150
  }
145
151
  catch (err) {
@@ -122,14 +122,14 @@ function buildRequest(subcommand, loopId, opts) {
122
122
  };
123
123
  }
124
124
  }
125
- export function runLoopCommand(subcommand, args, options = {}, cwd) {
125
+ export async function runLoopCommand(subcommand, args, options = {}, cwd) {
126
126
  if (!memoryExists(cwd)) {
127
127
  console.error('Error: .brainclaw/ not found. Run `brainclaw init` first.');
128
128
  process.exit(1);
129
129
  }
130
130
  const loopId = requireLoopId(args, options);
131
131
  const request = buildRequest(subcommand, loopId, options);
132
- const handled = handleBclawLoop({ args: request, cwd });
132
+ const handled = await handleBclawLoop({ args: request, cwd });
133
133
  if (handled.response.status !== 'ok') {
134
134
  const message = handled.response.error ?? handled.summary;
135
135
  fail(`bclaw_loop.${String(request.intent)} rejected the call: ${message}`, 2, options);
@@ -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": [