brainclaw 1.8.0 → 1.9.1

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 (178) hide show
  1. package/README.md +592 -505
  2. package/dist/brainclaw-vscode.vsix +0 -0
  3. package/dist/cli.js +138 -13
  4. package/dist/commands/add-step.js +1 -1
  5. package/dist/commands/bootstrap.js +2 -26
  6. package/dist/commands/check-security-mcp.js +50 -33
  7. package/dist/commands/check-security.js +86 -43
  8. package/dist/commands/claim.js +22 -21
  9. package/dist/commands/confirm.js +26 -0
  10. package/dist/commands/context-diff.js +1 -1
  11. package/dist/commands/dispatch-watch.js +142 -0
  12. package/dist/commands/doctor.js +113 -2
  13. package/dist/commands/estimation-report.js +115 -16
  14. package/dist/commands/harvest.js +286 -23
  15. package/dist/commands/hooks.js +73 -73
  16. package/dist/commands/init.js +124 -22
  17. package/dist/commands/install-hooks.js +78 -78
  18. package/dist/commands/loops-handlers.js +4 -0
  19. package/dist/commands/mcp-read-handlers.js +253 -41
  20. package/dist/commands/mcp.js +664 -102
  21. package/dist/commands/memory.js +21 -17
  22. package/dist/commands/migrate.js +81 -17
  23. package/dist/commands/prune.js +78 -4
  24. package/dist/commands/reflect.js +26 -20
  25. package/dist/commands/register-agent.js +57 -1
  26. package/dist/commands/repair.js +20 -0
  27. package/dist/commands/session-end.js +15 -6
  28. package/dist/commands/session-start.js +18 -1
  29. package/dist/commands/setup-security.js +39 -18
  30. package/dist/commands/setup.js +26 -27
  31. package/dist/commands/stale.js +16 -2
  32. package/dist/commands/switch.js +26 -5
  33. package/dist/commands/uninstall.js +126 -34
  34. package/dist/commands/update-step.js +6 -0
  35. package/dist/commands/version.js +1 -1
  36. package/dist/commands/worktree.js +60 -0
  37. package/dist/core/actions.js +12 -3
  38. package/dist/core/agent-capability.js +30 -17
  39. package/dist/core/agent-files.js +963 -666
  40. package/dist/core/agent-integrations.js +0 -3
  41. package/dist/core/agent-inventory.js +67 -0
  42. package/dist/core/agent-registry.js +163 -29
  43. package/dist/core/agentrun-reconciler.js +33 -2
  44. package/dist/core/agentruns.js +7 -1
  45. package/dist/core/ai-agent-detection.js +31 -44
  46. package/dist/core/archival.js +15 -9
  47. package/dist/core/assignment-reconciler.js +56 -0
  48. package/dist/core/assignment-sweeper.js +127 -4
  49. package/dist/core/assignments.js +69 -11
  50. package/dist/core/bootstrap.js +233 -67
  51. package/dist/core/brainclaw-version.js +22 -0
  52. package/dist/core/candidates.js +21 -1
  53. package/dist/core/claims.js +313 -150
  54. package/dist/core/codev-prompts.js +38 -38
  55. package/dist/core/config.js +6 -1
  56. package/dist/core/context-diff.js +148 -20
  57. package/dist/core/context.js +129 -8
  58. package/dist/core/coordination.js +22 -3
  59. package/dist/core/default-profiles/doctor.yaml +11 -11
  60. package/dist/core/default-profiles/janitor.yaml +11 -11
  61. package/dist/core/default-profiles/onboarder.yaml +11 -11
  62. package/dist/core/default-profiles/reviewer.yaml +13 -13
  63. package/dist/core/dispatch-status.js +79 -5
  64. package/dist/core/dispatcher.js +65 -12
  65. package/dist/core/entity-operations.js +74 -27
  66. package/dist/core/entity-registry.js +31 -5
  67. package/dist/core/event-log.js +138 -21
  68. package/dist/core/events/checkpoint.js +258 -0
  69. package/dist/core/events/genesis.js +220 -0
  70. package/dist/core/events/journal.js +507 -0
  71. package/dist/core/events/materialize.js +126 -0
  72. package/dist/core/events/registry-post-image.js +110 -0
  73. package/dist/core/events/verify.js +109 -0
  74. package/dist/core/execution-adapters.js +23 -0
  75. package/dist/core/execution.js +1 -1
  76. package/dist/core/facade-schema.js +38 -0
  77. package/dist/core/gc-semantic.js +130 -5
  78. package/dist/core/handoff-snapshot.js +68 -0
  79. package/dist/core/ids.js +19 -8
  80. package/dist/core/instruction-templates.js +34 -115
  81. package/dist/core/io.js +39 -3
  82. package/dist/core/json-store.js +10 -1
  83. package/dist/core/lock.js +153 -28
  84. package/dist/core/loops/bootstrap-acquire.js +25 -1
  85. package/dist/core/loops/facade-schema.js +2 -0
  86. package/dist/core/loops/hooks/survey-signals-baseline.js +36 -0
  87. package/dist/core/loops/index.js +1 -0
  88. package/dist/core/loops/presets/bootstrap.js +7 -0
  89. package/dist/core/loops/store.js +17 -0
  90. package/dist/core/loops/verbs.js +24 -2
  91. package/dist/core/markdown.js +8 -76
  92. package/dist/core/mcp-command-resolution.js +245 -0
  93. package/dist/core/memory-compactor.js +5 -3
  94. package/dist/core/memory-lifecycle.js +282 -0
  95. package/dist/core/merge-risk.js +150 -0
  96. package/dist/core/messaging.js +10 -3
  97. package/dist/core/migration.js +11 -1
  98. package/dist/core/observer-mode.js +26 -0
  99. package/dist/core/operations/memory-mutation.js +90 -65
  100. package/dist/core/operations/plan.js +27 -1
  101. package/dist/core/protocol-skills.js +210 -0
  102. package/dist/core/reflection-safety.js +6 -7
  103. package/dist/core/reputation.js +84 -2
  104. package/dist/core/runtime-signals.js +72 -10
  105. package/dist/core/runtime.js +84 -1
  106. package/dist/core/schema.js +114 -0
  107. package/dist/core/search.js +19 -2
  108. package/dist/core/security-detectors.js +125 -0
  109. package/dist/core/security-extract.js +189 -0
  110. package/dist/core/security-guard.js +217 -139
  111. package/dist/core/security-packages.js +121 -0
  112. package/dist/core/security-scoring.js +76 -9
  113. package/dist/core/security.js +34 -2
  114. package/dist/core/sequence.js +11 -2
  115. package/dist/core/setup-flow.js +141 -13
  116. package/dist/core/spawn-check.js +16 -2
  117. package/dist/core/staleness.js +73 -2
  118. package/dist/core/state.js +250 -54
  119. package/dist/core/store-resolution.js +45 -12
  120. package/dist/core/worktree.js +90 -26
  121. package/dist/facts.js +8 -8
  122. package/dist/facts.json +7 -7
  123. package/docs/PROTOCOL.md +223 -0
  124. package/docs/adapters/openclaw.md +43 -43
  125. package/docs/architecture/project-refs.md +328 -328
  126. package/docs/cli.md +2097 -2096
  127. package/docs/concepts/coordination.md +52 -52
  128. package/docs/concepts/coordinator-runbook.md +129 -0
  129. package/docs/concepts/dispatch-lifecycle.md +245 -245
  130. package/docs/concepts/event-log-store.md +928 -0
  131. package/docs/concepts/ideation-loop.md +317 -317
  132. package/docs/concepts/loop-engine.md +520 -511
  133. package/docs/concepts/mcp-governance.md +268 -268
  134. package/docs/concepts/memory.md +89 -88
  135. package/docs/concepts/multi-agent-workflows.md +167 -167
  136. package/docs/concepts/observer-protocol.md +361 -0
  137. package/docs/concepts/parallel-merge-protocol.md +71 -0
  138. package/docs/concepts/plans-and-claims.md +217 -174
  139. package/docs/concepts/project-md-convention.md +35 -35
  140. package/docs/concepts/runtime-notes.md +38 -38
  141. package/docs/concepts/skills.md +78 -0
  142. package/docs/concepts/troubleshooting.md +254 -254
  143. package/docs/concepts/workspace-bootstrapping.md +142 -81
  144. package/docs/context-format-changelog.md +35 -35
  145. package/docs/context-format.md +48 -48
  146. package/docs/index.md +65 -65
  147. package/docs/integrations/agents.md +162 -162
  148. package/docs/integrations/claude-code.md +23 -23
  149. package/docs/integrations/cline.md +87 -88
  150. package/docs/integrations/codex.md +2 -2
  151. package/docs/integrations/continue.md +60 -60
  152. package/docs/integrations/copilot.md +82 -80
  153. package/docs/integrations/cursor.md +23 -23
  154. package/docs/integrations/kilocode.md +72 -72
  155. package/docs/integrations/mcp.md +377 -377
  156. package/docs/integrations/mistral-vibe.md +122 -122
  157. package/docs/integrations/openclaw.md +99 -98
  158. package/docs/integrations/opencode.md +84 -84
  159. package/docs/integrations/overview.md +122 -122
  160. package/docs/integrations/roo.md +74 -74
  161. package/docs/integrations/windsurf.md +83 -83
  162. package/docs/mcp-schema-changelog.md +360 -329
  163. package/docs/playbooks/integration/index.md +121 -121
  164. package/docs/playbooks/orchestration.md +37 -0
  165. package/docs/playbooks/productivity/index.md +99 -99
  166. package/docs/playbooks/team/index.md +117 -117
  167. package/docs/product/agent-first-model.md +184 -184
  168. package/docs/product/entity-model-audit.md +462 -462
  169. package/docs/product/positioning.md +86 -86
  170. package/docs/quickstart-existing-project.md +107 -107
  171. package/docs/quickstart.md +148 -147
  172. package/docs/release-maintenance.md +79 -79
  173. package/docs/reputation.md +52 -52
  174. package/docs/review.md +45 -45
  175. package/docs/security.md +212 -53
  176. package/docs/server-operations.md +118 -118
  177. package/docs/storage.md +110 -108
  178. package/package.json +86 -69
@@ -16,7 +16,8 @@ import { collectLoadValidationWarnings, findLoadValidationWarning, loadState, pe
16
16
  import { generateIdWithLabel } from '../core/ids.js';
17
17
  import { memoryExists } from '../core/io.js';
18
18
  import { generateCandidateIdWithLabel, loadCandidate, saveCandidate } from '../core/candidates.js';
19
- import { createEntity, getEntity, listEntities, boundListResult, removeEntity, transitionEntity, updateEntity, } from '../core/entity-operations.js';
19
+ import { createEntity, getEntity, listEntities, boundListResult, DEFAULT_FIND_CHAR_BUDGET, removeEntity, transitionEntity, updateEntity, } from '../core/entity-operations.js';
20
+ import { handoffDiffPreviewNote } from '../core/handoff-snapshot.js';
20
21
  import { ENTITY_REGISTRY } from '../core/entity-registry.js';
21
22
  import { generateClaimId, listClaims, loadClaim, saveClaim, createCoordinatorClaim, adoptClaimSession, attachAssignmentMessageToClaim, linkClaimToAssignment, releaseClaimWithCascade } from '../core/claims.js';
22
23
  import { createSequence, updateSequence, deleteSequence } from '../core/sequence.js';
@@ -29,7 +30,7 @@ import { rejectCandidate } from './reject.js';
29
30
  import { startSession } from './session-start.js';
30
31
  import { endSession } from './session-end.js';
31
32
  import { applyHandoffUpdates } from './update-handoff.js';
32
- import { AgentIdentityResolutionError, AgentTrustError, findAgentIdentityById, findAgentIdentityByName, requireMinimumTrustLevel, requireRegisteredAgentIdentity, resolveCurrentModel, ensureAgentRegisteredForDispatch, } from '../core/agent-registry.js';
33
+ import { AgentIdentityResolutionError, AgentTrustError, findAgentIdentityById, findAgentIdentityByName, hasMinimumTrustLevel, normalizeAgentName, requireMinimumTrustLevel, requireRegisteredAgentIdentity, resolveCurrentAgentIdentity, resolveCurrentModel, ensureAgentRegisteredForDispatch, } from '../core/agent-registry.js';
33
34
  import { appendAuditEntry } from '../core/audit.js';
34
35
  import { nowISO, generateId } from '../core/ids.js';
35
36
  import { buildOperationalIdentity, loadAllSessions, loadSessionById } from '../core/identity.js';
@@ -38,8 +39,8 @@ import { createCapability, createTool as createRegistryTool } from '../core/regi
38
39
  import { detectAiAgent } from '../core/ai-agent-detection.js';
39
40
  import { checkGitPresence, scanGitRepos, parseRoots, parseRepoSelection, parseAgentSelection, getDetectedSetupAgentNames, getInstalledAgentNames, runGlobalInstall, initReposAndConfigureAgents, readSetupState, ALL_KNOWN_AGENTS, } from './setup.js';
40
41
  import { buildAgentInventory } from '../core/agent-inventory.js';
41
- import { resolveEffectiveCwd, resolveProjectRef, resolveTargetStore } from '../core/store-resolution.js';
42
- import { probeForQuickSetup, buildQuickSetupProbeResponse, buildOnboardingPreview } from '../core/setup-flow.js';
42
+ import { resolveEffectiveCwd, resolveEffectiveCwdInfo, resolveProjectRef, resolveTargetStore } from '../core/store-resolution.js';
43
+ import { assessBootstrapNeed, probeForQuickSetup, buildQuickSetupProbeResponse, buildOnboardingPreview, resolveEmptyMemoryRecommendation } from '../core/setup-flow.js';
43
44
  import { ensureUserStore, resolveHomeDir } from '../core/setup-state.js';
44
45
  import { createPlan, addStep as addStepOp, completeStep as completeStepOp, updateStep as updateStepOp, deleteStep as deleteStepOp, deletePlan as deletePlanOp } from '../core/operations/plan.js';
45
46
  import { sendMessage, ackMessage, countActionable, getThread, hasActiveAssignment } from '../core/messaging.js';
@@ -95,8 +96,9 @@ export const MCP_READ_TOOLS = [
95
96
  refresh: { type: 'boolean', description: 'Force a fresh bootstrap scan.' },
96
97
  audience: { type: 'string', description: 'Optional interview audience filter: cli, ide_chat, or any.' },
97
98
  interview: { type: 'boolean', description: 'Render interview text instead of the summary text.' },
98
- apply: { type: 'boolean', description: 'Apply the current import proposal into canonical memory.' },
99
- uninstall: { type: 'boolean', description: 'Uninstall the last bootstrap-managed import.' },
99
+ apply: { type: 'boolean', description: 'Apply the current import proposal into canonical memory. Requires yes: true.' },
100
+ uninstall: { type: 'boolean', description: 'Uninstall the last bootstrap-managed import. Requires yes: true.' },
101
+ yes: { type: 'boolean', description: 'Explicit confirmation for apply/uninstall (mirrors the CLI --yes gate). Without it the call returns confirmation_required and makes no changes.' },
100
102
  interviewAnswers: {
101
103
  type: 'array',
102
104
  description: 'Optional structured interview answers. Each answer may include question_id, response_text, response_items, response_boolean, and explicit suggestions.',
@@ -119,15 +121,15 @@ export const MCP_READ_TOOLS = [
119
121
  // Unified dispatcher over the four legacy context reads.
120
122
  // Promoted to standard tier at the v1.0 cut.
121
123
  name: 'bclaw_context',
122
- description: 'Unified context read. Dispatches by kind: memory (project memory for a path), execution (local execution env), board (full agent board), board_summary (compact counts), delta (memory changes since a reference session).',
124
+ description: 'Unified context read. Dispatches by kind: memory (project memory for a path), execution (local execution env), board (full agent board), board_summary (compact counts), cross_project (linked_projects + incoming_signals only), delta (memory changes since a reference session).',
123
125
  annotations: { tier: 'facade', category: 'context', headlessApproval: 'auto' },
124
126
  inputSchema: {
125
127
  type: 'object',
126
128
  properties: {
127
129
  kind: {
128
130
  type: 'string',
129
- enum: ['memory', 'execution', 'board', 'board_summary', 'delta'],
130
- description: 'memory = project memory context; execution = local env/tooling; board = full agent board; board_summary = lightweight counts; delta = memory changes since `since`.',
131
+ enum: ['memory', 'execution', 'board', 'board_summary', 'cross_project', 'delta'],
132
+ description: 'memory = project memory context; execution = local env/tooling; board = full agent board; board_summary = lightweight counts; cross_project = linked_projects + incoming_signals only; delta = memory changes since `since`.',
131
133
  },
132
134
  since: {
133
135
  type: 'string',
@@ -149,6 +151,7 @@ export const MCP_READ_TOOLS = [
149
151
  compactTemplate: { type: 'boolean', description: 'Use compact template (memory kind, format=template).' },
150
152
  includeAgentTooling: { type: 'boolean', description: 'Include agent tooling signals (execution kind).' },
151
153
  project: { type: 'string', description: 'Optional: name of a linked project to read context from. Defaults to the current project. Accepts cross_project_links and workspace store-chain children.' },
154
+ budget_tokens: { type: 'number', description: 'Approximate token budget for the payload (~4 chars/token). memory kind: relevance-ranked item fill; board kind: arrays bounded by size.' },
152
155
  },
153
156
  required: ['kind'],
154
157
  },
@@ -164,8 +167,11 @@ export const MCP_READ_TOOLS = [
164
167
  type: { type: 'string', description: 'Filter by section: decisions, constraints, traps, handoffs, candidates, plans, sequences.' },
165
168
  section: { type: 'string', description: 'Filter by section (state, candidates, runtime).' },
166
169
  since: { type: 'string', description: 'Filter items created after this ISO date.' },
170
+ project: { type: 'string', description: 'Optional project name/path to search. Defaults to the active project.' },
171
+ includeLegacy: { type: 'boolean', description: 'Include records with provenance.kind="legacy" (default false). Response reports excluded_legacy when false.' },
167
172
  limit: { type: 'number', description: 'Maximum number of results to return (default 10).' },
168
173
  offset: { type: 'number', description: 'Number of results to skip (for pagination).' },
174
+ budget_tokens: { type: 'number', description: 'Optional token budget for the result page (~4 chars/token). The page is size-bounded; has_more/next_offset advertise the rest.' },
169
175
  },
170
176
  required: ['query'],
171
177
  },
@@ -560,12 +566,13 @@ const MCP_WRITE_TOOLS = [
560
566
  },
561
567
  {
562
568
  name: 'bclaw_quick_capture',
563
- description: 'Capture free-form text and classify it locally into a decision, trap, or fallback runtime note. Uses keyword heuristics only, never an LLM.',
569
+ description: 'Capture free-form text as a decision, trap, constraint, or runtime note. Declare `type` yourself (you know what you are capturing — caller assertion wins); keyword heuristics are only a fallback when type is absent. Contradictions with existing memory are attached as advisory metadata on the candidate, never block promotion.',
564
570
  annotations: { tier: 'standard', category: 'memory', headlessApproval: 'auto' },
565
571
  inputSchema: {
566
572
  type: 'object',
567
573
  properties: {
568
574
  text: { type: 'string', description: 'Free-form capture text.' },
575
+ type: { type: 'string', enum: ['decision', 'trap', 'constraint', 'note'], description: 'Caller-asserted classification. Strongly recommended — the calling agent knows the nature of the capture better than keyword heuristics (cnd_abe61d68: 18 false contradiction positives on a review summary).' },
569
576
  context: { type: 'string', description: 'Optional file/path/scope context to associate with the capture.' },
570
577
  agent: { type: 'string', description: 'Agent name.' },
571
578
  agentId: { type: 'string', description: 'Registered agent id.' },
@@ -643,7 +650,7 @@ const MCP_WRITE_TOOLS = [
643
650
  reflectHandoff: { type: 'boolean', description: 'Materialize an open handoff from git commits since session start.' },
644
651
  dispatchReview: { type: 'boolean', description: 'When used with reflectHandoff, auto-dispatch a code review if the reflected handoff is reviewable.' },
645
652
  reviewer: { type: 'string', description: 'Explicit reviewer for the reflected handoff review dispatch.' },
646
- reflect: { type: 'boolean', description: 'Include structured reflection questions. Answer via bclaw_write_note with tag [reflection].' },
653
+ reflect: { type: 'boolean', description: 'Emit the dogfooding reflection prompt (project + your surfaces/skills/tools). Default true pass false to suppress on a trivial session. Capture actionable findings via bclaw_quick_capture.' },
647
654
  },
648
655
  },
649
656
  },
@@ -701,6 +708,8 @@ const MCP_WRITE_TOOLS = [
701
708
  text: { type: 'string', description: 'Step description.' },
702
709
  title: { type: 'string', description: 'Alias for text.' },
703
710
  assignee: { type: 'string', description: 'Optional assignee.' },
711
+ estimated_effort: { type: 'number', description: 'Step-level estimate in minutes (pln#495). A duration string like "2h"/"30m" is also accepted and coerced.' },
712
+ actual_effort: { type: 'string', description: 'Step-level actual effort, free-form ("45m", "2h"), parsed when the estimation report runs.' },
704
713
  },
705
714
  },
706
715
  text: { type: 'string', description: 'Legacy top-level step description; prefer data.text.' },
@@ -740,6 +749,8 @@ const MCP_WRITE_TOOLS = [
740
749
  status: { type: 'string', description: 'New status: todo, in_progress, testing, done, blocked.' },
741
750
  text: { type: 'string', description: 'New step text.' },
742
751
  assignee: { type: 'string', description: 'New assignee (empty string to unassign).' },
752
+ estimated_effort: { type: 'number', description: 'Step-level estimate in minutes (pln#495); a duration string is also coerced.' },
753
+ actual_effort: { type: 'string', description: 'Step-level actual effort, free-form ("45m", "2h").' },
743
754
  agent: { type: 'string', description: 'Agent name.' },
744
755
  agentId: { type: 'string', description: 'Registered agent id.' },
745
756
  project: { type: 'string', description: 'Optional: name of a linked project to update the step in. Defaults to the current project.' },
@@ -952,6 +963,7 @@ const MCP_WRITE_TOOLS = [
952
963
  agent: { type: 'string', description: 'Agent name.' },
953
964
  agentId: { type: 'string', description: 'Registered agent id.' },
954
965
  compact: { type: 'boolean', description: 'Return a compact payload (default true). Set to false to include the full context result. Compact mode avoids exceeding MCP token limits on projects with large memory.', default: true },
966
+ budget_tokens: { type: 'number', description: 'Approximate token budget for the context payload. Relevance-ranked fill: highest-scoring items kept until the budget is reached (~4 chars/token).' },
955
967
  },
956
968
  required: ['intent'],
957
969
  },
@@ -998,8 +1010,12 @@ const MCP_WRITE_TOOLS = [
998
1010
  properties: {
999
1011
  intent: {
1000
1012
  type: 'string',
1001
- enum: ['open', 'get', 'list', 'turn', 'complete_turn', 'advance', 'add_artifact', 'pause', 'resume', 'close'],
1002
- description: 'Loop lifecycle intent. See docs/concepts/loop-engine.md for semantics. ANTI-PATTERN: do NOT call `intent="open"` directly to start a review or ideation loop — it creates the loop structure WITHOUT dispatching the first turn, so the reviewer/participant never receives the work. Use `bclaw_coordinate(intent="review", open_loop=true, targetAgents=[…])` (or `intent="ideate"`) instead — that opens the loop AND dispatches the first turn in one call. `bclaw_loop` is for driving turns inside a loop that was already opened via the coordinate facade (intents: turn, complete_turn, advance, close, etc.).',
1013
+ // 'open' is intentionally NOT exposed standalone (pln#542): it
1014
+ // created a loop structure without dispatching the first turn, so
1015
+ // nothing ever ran. Loops are opened via
1016
+ // bclaw_coordinate(intent='review', open_loop=true) or intent='ideate'.
1017
+ enum: ['get', 'list', 'turn', 'complete_turn', 'advance', 'add_artifact', 'pause', 'resume', 'close'],
1018
+ 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.',
1003
1019
  },
1004
1020
  loop_id: { type: 'string', description: 'Target loop id (lop_…). Required for every intent except open and list.' },
1005
1021
  kind: { type: 'string', enum: ['review', 'ideation', 'implementation', 'research', 'debug'], description: 'Loop kind for open / list filter.' },
@@ -1122,6 +1138,7 @@ const MCP_WRITE_TOOLS = [
1122
1138
  entity: { type: 'string', description: 'Entity name: plan | decision | constraint | trap | handoff | runtime_note | candidate | sequence | claim | action | assignment | agent_run | cross_project_link. Others not yet wired.' },
1123
1139
  filter: { type: 'object', description: 'Filter keys: status, tag (single tag), tags (array, any-match), author, plan_id, source, auto_generated, limit, offset, includeLegacy (bool, default false), minAutoReflectConfidence (0-1, default 0.6). entity=agent_run also accepts assignment_id, claim_id, message_id.' },
1124
1140
  project: { type: 'string', description: 'Optional: name (or path/basename) of a linked project to query. Defaults to the current project. Only cross_project_links (config.yaml) and workspace store-chain children are accepted — list with `brainclaw link list`.' },
1141
+ budget_tokens: { type: 'number', description: 'Optional token budget for the page payload (~4 chars/token). Tightens the default size cap; pagination metadata (has_more/next_offset) still applies.' },
1125
1142
  },
1126
1143
  required: ['entity'],
1127
1144
  },
@@ -1136,6 +1153,7 @@ const MCP_WRITE_TOOLS = [
1136
1153
  entity: { type: 'string', description: 'Entity name.' },
1137
1154
  id: { type: 'string', description: 'Entity id (e.g. dec_ab12cd) or short_label (e.g. dec#42).' },
1138
1155
  project: { type: 'string', description: 'Optional: name of a linked project to fetch from. Defaults to the current project. See `brainclaw link list` for accepted names.' },
1156
+ budget_tokens: { type: 'number', description: 'Optional token budget (~4 chars/token). Bounds unbounded fields (e.g. handoff snapshot diffs).' },
1139
1157
  },
1140
1158
  required: ['entity', 'id'],
1141
1159
  },
@@ -1226,6 +1244,39 @@ export const MCP_TOOL_NAMES = ALL_TOOLS.map((tool) => tool.name);
1226
1244
  export const MCP_HEADLESS_AUTO_TOOL_NAMES = ALL_TOOLS
1227
1245
  .filter((tool) => tool.annotations?.headlessApproval === 'auto')
1228
1246
  .map((tool) => tool.name);
1247
+ /**
1248
+ * Narrow "canonical grammar" tool set — the read-side facade entries
1249
+ * (session + context) plus the five memory verbs (find / get / create /
1250
+ * update / transition). Consumed by writers (e.g. Hermes' tools.include)
1251
+ * that want a minimal advertised surface rather than the full headless-auto
1252
+ * catalog. Coordination facades (dispatch, coordinate, loop) are excluded
1253
+ * because narrow-surface agents shouldn't be routing work.
1254
+ *
1255
+ * Derivation rule (no hand-curated array):
1256
+ * - tier=facade AND category in {session, context} AND headlessApproval=auto
1257
+ * - OR name in the canonical memory verbs
1258
+ *
1259
+ * Adding a new memory grammar verb is the only edit that requires touching
1260
+ * this file; everything else propagates from ALL_TOOLS annotations (pln#546 step 2).
1261
+ */
1262
+ const _CANONICAL_GRAMMAR_MEMORY_VERBS = new Set([
1263
+ 'bclaw_find',
1264
+ 'bclaw_get',
1265
+ 'bclaw_create',
1266
+ 'bclaw_update',
1267
+ 'bclaw_transition',
1268
+ ]);
1269
+ export const MCP_CANONICAL_GRAMMAR_TOOL_NAMES = ALL_TOOLS
1270
+ .filter((tool) => {
1271
+ const ann = tool.annotations ?? {};
1272
+ if (ann.tier === 'facade'
1273
+ && (ann.category === 'session' || ann.category === 'context')
1274
+ && ann.headlessApproval === 'auto') {
1275
+ return true;
1276
+ }
1277
+ return _CANONICAL_GRAMMAR_MEMORY_VERBS.has(tool.name);
1278
+ })
1279
+ .map((tool) => tool.name);
1229
1280
  /**
1230
1281
  * Tools removed from the MCP surface at the v1.0 cut (Phase 3 slice 3i).
1231
1282
  * Handlers remain in place defensively, but these names are hidden from
@@ -1333,6 +1384,21 @@ export const DEFAULT_PUBLISHED_TOOLS = PUBLISHED_TOOLS
1333
1384
  return a.index - b.index;
1334
1385
  })
1335
1386
  .map(({ tool }) => tool);
1387
+ /**
1388
+ * Minimal catalog served while the project memory at cwd is absent.
1389
+ * Instead of refusing to boot (the historical exit(1)), the server starts
1390
+ * in "setup mode" so an agent landing on a fresh repo can initialize it
1391
+ * via bclaw_setup without a CLI shell-out + session-reload discontinuity.
1392
+ */
1393
+ export const UNINITIALIZED_TOOL_NAMES = new Set([
1394
+ 'bclaw_setup',
1395
+ 'bclaw_init_project',
1396
+ 'bclaw_doctor',
1397
+ ]);
1398
+ export const UNINITIALIZED_PUBLISHED_TOOLS = PUBLISHED_TOOLS.filter((tool) => UNINITIALIZED_TOOL_NAMES.has(tool.name));
1399
+ export function buildUninitializedStateMessage(cwd) {
1400
+ return `Project memory not initialized at ${cwd}. The brainclaw MCP server is running in setup mode: only bclaw_setup, bclaw_init_project and bclaw_doctor are available. Call bclaw_setup to initialize this repo — the full tool catalog activates automatically afterwards.`;
1401
+ }
1336
1402
  class McpProtocolError extends Error {
1337
1403
  code;
1338
1404
  id;
@@ -1525,10 +1591,108 @@ function getCancelledRequestId(params) {
1525
1591
  }
1526
1592
  return undefined;
1527
1593
  }
1594
+ let principalCache;
1595
+ /** Test hook — the principal is otherwise pinned for the process lifetime. */
1596
+ export function __resetConnectionPrincipalForTests() {
1597
+ principalCache = undefined;
1598
+ }
1599
+ /**
1600
+ * Resolve the connection principal. The MCP server is one process per
1601
+ * connection, so a process-level pin IS the per-connection pin; the cache key
1602
+ * guards the identity-bearing env vars so in-process test harnesses that
1603
+ * switch agents between calls re-resolve instead of leaking the first pin.
1604
+ */
1605
+ function resolveConnectionPrincipal(cwd, sessionId) {
1606
+ const env = process.env;
1607
+ const key = [
1608
+ env.BRAINCLAW_CLAIM_ID ?? '', env.BRAINCLAW_AGENT_ID ?? '',
1609
+ env.BRAINCLAW_AGENT_NAME ?? '', env.BRAINCLAW_AGENT ?? '',
1610
+ cwd ?? '', sessionId ?? '',
1611
+ ].join('|');
1612
+ if (principalCache && principalCache.key === key)
1613
+ return principalCache.value;
1614
+ let value;
1615
+ // 1. Assignment binding: a dispatched worker carries BRAINCLAW_CLAIM_ID; the
1616
+ // claim names the identity the coordinator dispatched — authoritative.
1617
+ const claimId = env.BRAINCLAW_CLAIM_ID?.trim();
1618
+ if (claimId) {
1619
+ try {
1620
+ const claim = loadClaim(claimId, cwd);
1621
+ const identity = (claim.agent_id ? findAgentIdentityById(claim.agent_id, cwd) : undefined)
1622
+ ?? findAgentIdentityByName(claim.agent, cwd);
1623
+ if (identity) {
1624
+ value = {
1625
+ agent_name: identity.agent_name,
1626
+ agent_id: identity.agent_id,
1627
+ session_id: claim.session_id ?? sessionId,
1628
+ pid: process.pid,
1629
+ source: 'claim_binding',
1630
+ };
1631
+ }
1632
+ }
1633
+ catch { /* claim may not exist in this store — fall through */ }
1634
+ }
1635
+ // 2. Server-side detection (env-pinned or detected REGISTERED identity —
1636
+ // read-only since pln#562 step 2, never mints).
1637
+ if (!value) {
1638
+ const identity = resolveCurrentAgentIdentity(cwd);
1639
+ if (identity) {
1640
+ value = {
1641
+ agent_name: identity.agent_name,
1642
+ agent_id: identity.agent_id,
1643
+ session_id: sessionId,
1644
+ pid: process.pid,
1645
+ source: 'server_detection',
1646
+ };
1647
+ }
1648
+ }
1649
+ principalCache = { key, value };
1650
+ return value;
1651
+ }
1528
1652
  function resolveMutationIdentity(args, fields, cwd, sessionId) {
1529
1653
  try {
1654
+ const explicitName = typeof args[fields.nameField] === 'string' ? String(args[fields.nameField]) : undefined;
1655
+ const explicitId = typeof args[fields.idField] === 'string' ? String(args[fields.idField]) : undefined;
1656
+ // pln#562 step 3 — pinned connection principal. When the server resolved
1657
+ // an authenticated principal, caller args are verified against it:
1658
+ // matching/absent args → principal; mismatching args → curator-only
1659
+ // explicit override, otherwise the mismatch is REJECTED loudly. Silently
1660
+ // re-attributing a spoofed/mistaken identity to the principal would hide
1661
+ // caller bugs — fail-loud is the contract (mcp-protocol.test 'rejects
1662
+ // unregistered identities and mismatched id/name pairs').
1663
+ const principal = resolveConnectionPrincipal(cwd, sessionId);
1664
+ if (principal) {
1665
+ // Re-load per call (cheap) so trust changes propagate mid-connection;
1666
+ // the BINDING (who you are) stays pinned.
1667
+ const principalDoc = findAgentIdentityById(principal.agent_id, cwd);
1668
+ if (principalDoc) {
1669
+ const mismatch = (explicitName !== undefined && normalizeAgentName(explicitName) !== normalizeAgentName(principal.agent_name))
1670
+ || (explicitId !== undefined && explicitId !== principal.agent_id);
1671
+ if (!mismatch) {
1672
+ return { identity: principalDoc };
1673
+ }
1674
+ if ((principalDoc.trust_level ?? 'contributor') === 'curator') {
1675
+ return {
1676
+ identity: requireRegisteredAgentIdentity({
1677
+ agentName: explicitName,
1678
+ agentId: explicitId,
1679
+ cwd,
1680
+ allowCurrent: true,
1681
+ allowEnv: true,
1682
+ }),
1683
+ };
1684
+ }
1685
+ return {
1686
+ error: {
1687
+ kind: 'identity_error',
1688
+ message: `Caller-supplied identity (agent=${explicitName ?? '<none>'}, agentId=${explicitId ?? '<none>'}) does not match the pinned connection principal '${principal.agent_name}' (${principal.agent_id}). Omit the identity args, or have a curator perform the override.`,
1689
+ },
1690
+ };
1691
+ }
1692
+ }
1693
+ // No pinned principal (unregistered connection): legacy chain.
1530
1694
  // Session-pinned identity: if no explicit agent in args, use the session's pinned agent
1531
- let agentName = typeof args[fields.nameField] === 'string' ? String(args[fields.nameField]) : undefined;
1695
+ let agentName = explicitName;
1532
1696
  if (!agentName && sessionId) {
1533
1697
  const session = loadSessionById(sessionId, cwd);
1534
1698
  if (session?.agent) {
@@ -1538,7 +1702,7 @@ function resolveMutationIdentity(args, fields, cwd, sessionId) {
1538
1702
  return {
1539
1703
  identity: requireRegisteredAgentIdentity({
1540
1704
  agentName,
1541
- agentId: typeof args[fields.idField] === 'string' ? String(args[fields.idField]) : undefined,
1705
+ agentId: explicitId,
1542
1706
  cwd,
1543
1707
  allowCurrent: true,
1544
1708
  allowEnv: true,
@@ -1592,12 +1756,15 @@ function ensureTrust(args, fields, level, cwd, sessionId) {
1592
1756
  }
1593
1757
  /**
1594
1758
  * Resolve the agent identity for canonical-grammar mutation verbs
1595
- * (bclaw_create/update/remove/transition). Returns a best-effort identity so
1596
- * that handlers can auto-fill required fields (e.g. plan.author) instead of
1597
- * letting the create land on disk with a missing field which would then be
1598
- * silently GC'd by the state sync loop (see fix plan pln_5f44426c).
1759
+ * (bclaw_create/update/remove/transition), so handlers can auto-fill required
1760
+ * fields (e.g. plan.author) instead of letting the create land on disk with a
1761
+ * missing field which would then be silently GC'd by the state sync loop
1762
+ * (see fix plan pln_5f44426c).
1599
1763
  *
1600
- * Falls back to args.agent if resolution fails, and finally to 'unknown'.
1764
+ * pln#562 step 3 resolution failure is a HARD error. The old fallback to
1765
+ * author:'unknown' produced records that passed creation but were schema-
1766
+ * invalid on read and silently GC'd: a write that lies about succeeding.
1767
+ * Callers map the throw to a validation_error tool response.
1601
1768
  */
1602
1769
  function resolveCanonicalAuthor(args, cwd, connectionSessionId) {
1603
1770
  const resolved = resolveMutationIdentity(args, { nameField: 'agent', idField: 'agentId' }, cwd, connectionSessionId);
@@ -1607,8 +1774,9 @@ function resolveCanonicalAuthor(args, cwd, connectionSessionId) {
1607
1774
  agent_id: resolved.identity.agent_id,
1608
1775
  };
1609
1776
  }
1610
- const explicit = typeof args.agent === 'string' ? args.agent : undefined;
1611
- return { agent_name: explicit ?? 'unknown' };
1777
+ const detail = 'error' in resolved && resolved.error ? resolved.error.message : 'no registered agent identity resolved';
1778
+ throw new Error(`cannot resolve mutation author: ${detail} `
1779
+ + 'Start a session (bclaw_session_start) or pass a registered agent before writing.');
1612
1780
  }
1613
1781
  function explicitSessionIdFromEnv() {
1614
1782
  return process.env.BRAINCLAW_SESSION_ID?.trim()
@@ -1616,6 +1784,32 @@ function explicitSessionIdFromEnv() {
1616
1784
  || process.env.CLAUDE_SESSION_ID?.trim()
1617
1785
  || process.env.COPILOT_SESSION_ID?.trim();
1618
1786
  }
1787
+ function projectInfoForCwd(cwd) {
1788
+ try {
1789
+ const config = loadConfig(cwd);
1790
+ return { path: cwd, name: config.project_name };
1791
+ }
1792
+ catch {
1793
+ return { path: cwd };
1794
+ }
1795
+ }
1796
+ function scopeMetadataForTarget(args, targetCwd, effectiveScope) {
1797
+ const hasExplicitProject = typeof args.project === 'string' && args.project.trim().length > 0;
1798
+ return {
1799
+ resolved_project: projectInfoForCwd(targetCwd),
1800
+ active_source: hasExplicitProject ? 'explicit' : effectiveScope.active_source,
1801
+ };
1802
+ }
1803
+ function renderProvenanceFilterNote(result) {
1804
+ const parts = [];
1805
+ if ((result.excluded_legacy ?? 0) > 0) {
1806
+ parts.push(`${result.excluded_legacy} legacy item(s) excluded (pass filter.includeLegacy=true to include them)`);
1807
+ }
1808
+ if ((result.excluded_low_confidence_auto_reflect ?? 0) > 0) {
1809
+ parts.push(`${result.excluded_low_confidence_auto_reflect} low-confidence auto_reflect item(s) excluded (lower filter.minAutoReflectConfidence to include them)`);
1810
+ }
1811
+ return parts.length > 0 ? parts.join('; ') : undefined;
1812
+ }
1619
1813
  export function parseMcpLine(line) {
1620
1814
  let parsed;
1621
1815
  try {
@@ -1650,11 +1844,17 @@ export function parseMcpLine(line) {
1650
1844
  isNotification: !('id' in parsed),
1651
1845
  };
1652
1846
  }
1653
- export function createInitializeResult(protocolVersion) {
1847
+ export function createInitializeResult(protocolVersion, options) {
1848
+ const uninitialized = options?.uninitialized === true;
1654
1849
  return {
1655
1850
  protocolVersion,
1656
1851
  serverInfo: { name: 'brainclaw', version: SCHEMA_VERSION },
1657
- capabilities: { tools: { listChanged: false } },
1852
+ // listChanged is only advertised in setup mode, where the catalog flips
1853
+ // to the full set once the project memory is initialized.
1854
+ capabilities: { tools: { listChanged: uninitialized } },
1855
+ ...(uninitialized
1856
+ ? { instructions: buildUninitializedStateMessage(options?.cwd ?? process.cwd()) }
1857
+ : {}),
1658
1858
  };
1659
1859
  }
1660
1860
  export class McpTaskRunner {
@@ -1777,6 +1977,8 @@ export class McpServerConnection {
1777
1977
  state = 'pre_init';
1778
1978
  protocolVersion;
1779
1979
  connectionSessionId;
1980
+ /** True while the project memory at cwd is absent — serves the minimal setup catalog. */
1981
+ uninitializedMode;
1780
1982
  /** Version of brainclaw code loaded in this process at boot time. */
1781
1983
  bootVersion;
1782
1984
  /** Throttle disk version checks — at most once per 60s. */
@@ -1787,11 +1989,15 @@ export class McpServerConnection {
1787
1989
  constructor(options) {
1788
1990
  this.cwd = options.cwd;
1789
1991
  this.send = options.send;
1992
+ this.uninitializedMode = options.uninitialized ?? false;
1790
1993
  this.bootVersion = getInstalledBrainclawVersion();
1791
1994
  this.taskRunner = new McpTaskRunner({
1792
1995
  executeTool: options.executeTool ?? createWorkerToolExecutor(),
1793
1996
  onResult: (requestId, outcome) => {
1794
- this.connectionSessionId = outcome.nextConnectionSessionId;
1997
+ if (outcome.nextConnectionSessionId !== undefined) {
1998
+ // null = explicit clear (session ended); string = refresh; undefined = no-op
1999
+ this.connectionSessionId = outcome.nextConnectionSessionId ?? undefined;
2000
+ }
1795
2001
  // Inject version mismatch advisory if stale
1796
2002
  const advisory = this.checkVersionMismatch();
1797
2003
  if (advisory && outcome.response.content.length > 0) {
@@ -1800,17 +2006,43 @@ export class McpServerConnection {
1800
2006
  ...outcome.response.content,
1801
2007
  ];
1802
2008
  }
2009
+ const catalogUnlocked = this.reconcileUninitializedMode();
2010
+ if (catalogUnlocked && outcome.response.content.length > 0) {
2011
+ outcome.response.content = [
2012
+ ...outcome.response.content,
2013
+ { type: 'text', text: '✔ Project memory initialized — the full brainclaw tool catalog is now active. If your client does not refresh tools automatically, reload the MCP server session.' },
2014
+ ];
2015
+ }
1803
2016
  // Track usage: append response size to usage.jsonl
1804
2017
  if (outcome.toolName) {
1805
2018
  this.trackUsage(outcome.toolName, outcome.response);
1806
2019
  }
1807
2020
  this.sendResult(requestId, outcome.response);
2021
+ if (catalogUnlocked) {
2022
+ this.send({ jsonrpc: '2.0', method: 'notifications/tools/list_changed' });
2023
+ }
1808
2024
  },
1809
2025
  onInternalError: (requestId, error) => {
1810
2026
  this.sendError(requestId, -32603, error instanceof Error ? error.message : 'Internal error');
1811
2027
  },
1812
2028
  });
1813
2029
  }
2030
+ /**
2031
+ * Lazy reconcile: if the server booted in setup mode but the project
2032
+ * memory now exists (initialized via bclaw_setup, bclaw_init_project,
2033
+ * or an out-of-band CLI init), unlock the full catalog.
2034
+ * Returns true exactly once — on the transition.
2035
+ */
2036
+ reconcileUninitializedMode() {
2037
+ if (!this.uninitializedMode) {
2038
+ return false;
2039
+ }
2040
+ if (!memoryExists(this.cwd)) {
2041
+ return false;
2042
+ }
2043
+ this.uninitializedMode = false;
2044
+ return true;
2045
+ }
1814
2046
  /**
1815
2047
  * Compare the version loaded in memory with the version on disk.
1816
2048
  * Returns an advisory string if they differ, undefined otherwise.
@@ -1895,7 +2127,11 @@ export class McpServerConnection {
1895
2127
  const protocolVersion = resolveRequestedProtocolVersion(params, id ?? null);
1896
2128
  this.protocolVersion = protocolVersion;
1897
2129
  this.state = 'awaiting_initialized';
1898
- this.sendResult(id ?? null, createInitializeResult(protocolVersion));
2130
+ this.reconcileUninitializedMode();
2131
+ this.sendResult(id ?? null, createInitializeResult(protocolVersion, {
2132
+ uninitialized: this.uninitializedMode,
2133
+ cwd: this.cwd,
2134
+ }));
1899
2135
  return;
1900
2136
  }
1901
2137
  if (method === 'notifications/initialized' || method === 'initialized') {
@@ -1918,6 +2154,15 @@ export class McpServerConnection {
1918
2154
  }
1919
2155
  if (method === 'tools/list') {
1920
2156
  if (!isNotification) {
2157
+ this.reconcileUninitializedMode();
2158
+ if (this.uninitializedMode) {
2159
+ this.sendResult(id ?? null, {
2160
+ tools: UNINITIALIZED_PUBLISHED_TOOLS,
2161
+ uninitialized: true,
2162
+ state: buildUninitializedStateMessage(this.cwd),
2163
+ });
2164
+ return;
2165
+ }
1921
2166
  const params = message.params === undefined ? {} : requireObjectParams(message.params, id ?? null);
1922
2167
  const catalog = typeof params.catalog === 'string' ? params.catalog : undefined;
1923
2168
  const include = typeof params.include === 'string' ? params.include : undefined;
@@ -1947,6 +2192,19 @@ export class McpServerConnection {
1947
2192
  return;
1948
2193
  }
1949
2194
  const args = params.arguments === undefined ? {} : requireObjectParams(params.arguments, id ?? null);
2195
+ this.reconcileUninitializedMode();
2196
+ if (this.uninitializedMode && !UNINITIALIZED_TOOL_NAMES.has(name)) {
2197
+ this.sendResult(id ?? null, toolResponse({
2198
+ content: [{ type: 'text', text: buildUninitializedStateMessage(this.cwd) }],
2199
+ structuredContent: {
2200
+ error: 'uninitialized',
2201
+ cwd: this.cwd,
2202
+ available_tools: [...UNINITIALIZED_TOOL_NAMES],
2203
+ next_action: 'Call bclaw_setup to initialize this repo.',
2204
+ },
2205
+ }, true));
2206
+ return;
2207
+ }
1950
2208
  this.taskRunner.enqueue(id ?? null, {
1951
2209
  name,
1952
2210
  args,
@@ -2082,12 +2340,15 @@ export class StdioTransport {
2082
2340
  }
2083
2341
  drainNewline() {
2084
2342
  while (true) {
2085
- const str = this.buffer.toString('utf-8');
2086
- const newlineIndex = str.indexOf('\n');
2343
+ // Search for '\n' (0x0a) at the byte level so a multibyte UTF-8 sequence
2344
+ // that spans two chunks is never split mid-character. Avoids O(n²)
2345
+ // string→buffer reconversion on every iteration.
2346
+ const newlineIndex = this.buffer.indexOf(0x0a);
2087
2347
  if (newlineIndex === -1)
2088
2348
  return;
2089
- const line = str.slice(0, newlineIndex).replace(/\r$/, '');
2090
- this.buffer = Buffer.from(str.slice(newlineIndex + 1), 'utf-8');
2349
+ const lineBuffer = this.buffer.subarray(0, newlineIndex);
2350
+ this.buffer = this.buffer.subarray(newlineIndex + 1);
2351
+ const line = lineBuffer.toString('utf-8').replace(/\r$/, '');
2091
2352
  if (line.trim()) {
2092
2353
  this.onMessage(line);
2093
2354
  }
@@ -2096,9 +2357,12 @@ export class StdioTransport {
2096
2357
  }
2097
2358
  export function runMcp() {
2098
2359
  const cwd = resolveEffectiveCwd();
2099
- if (!memoryExists(cwd)) {
2100
- console.error('Project memory not initialized. Run `brainclaw init` first.');
2101
- process.exit(1);
2360
+ // No project memory yet: start in setup mode instead of refusing to boot,
2361
+ // so agents can initialize the repo via bclaw_setup without a CLI
2362
+ // shell-out + session reload.
2363
+ const uninitialized = !memoryExists(cwd);
2364
+ if (uninitialized) {
2365
+ console.error(buildUninitializedStateMessage(cwd));
2102
2366
  }
2103
2367
  const missingWorkerPath = resolveMcpWorkerEntryPath();
2104
2368
  if (!fs.existsSync(missingWorkerPath)) {
@@ -2120,6 +2384,7 @@ export function runMcp() {
2120
2384
  cwd,
2121
2385
  send: adaptiveSend,
2122
2386
  executeTool: createWorkerToolExecutor(),
2387
+ uninitialized,
2123
2388
  });
2124
2389
  transport.onMessage = (line) => connection.handleLine(line);
2125
2390
  transport.start();
@@ -2135,6 +2400,9 @@ function createWorkerToolExecutor() {
2135
2400
  try {
2136
2401
  worker = new Worker(new URL('./mcp-worker.js', import.meta.url), {
2137
2402
  workerData: payload,
2403
+ // Capture worker stdout so console.log in tool handlers cannot corrupt
2404
+ // the parent's JSON-RPC stream. Drain captured output to stderr.
2405
+ stdout: true,
2138
2406
  });
2139
2407
  }
2140
2408
  catch (error) {
@@ -2145,6 +2413,8 @@ function createWorkerToolExecutor() {
2145
2413
  reject(error);
2146
2414
  return;
2147
2415
  }
2416
+ // Redirect any worker stdout to the server's stderr (safe for diagnostics)
2417
+ worker.stdout?.pipe(process.stderr);
2148
2418
  let settled = false;
2149
2419
  const cleanup = () => {
2150
2420
  worker.removeAllListeners('message');
@@ -2295,7 +2565,10 @@ function getCrossProjectArg(args, ...keys) {
2295
2565
  return undefined;
2296
2566
  }
2297
2567
  function blockCrossProjectExecution(entity, args) {
2298
- const targetProject = getCrossProjectArg(args, 'targetProject', 'target_project', 'crossProject', 'cross_project');
2568
+ // Include 'project' so that canonical write verbs that accept a project routing
2569
+ // arg (e.g. bclaw_claim project=...) are subject to the same signaling-only
2570
+ // boundary as the explicit cross-project keys.
2571
+ const targetProject = getCrossProjectArg(args, 'targetProject', 'target_project', 'crossProject', 'cross_project', 'project');
2299
2572
  if (!targetProject) {
2300
2573
  return undefined;
2301
2574
  }
@@ -2312,6 +2585,11 @@ import { handleMcpReadToolCall } from './mcp-read-handlers.js';
2312
2585
  export { handleMcpReadToolCall };
2313
2586
  async function _executeMcpToolCallInner(payload) {
2314
2587
  const { name, args, cwd, connectionSessionId } = payload;
2588
+ const scopeInfo = payload.effectiveScope ?? {
2589
+ cwd,
2590
+ active_source: 'cwd',
2591
+ resolved_project: projectInfoForCwd(cwd),
2592
+ };
2315
2593
  try {
2316
2594
  if (isLegacyMcpToolFacadeDisabled(name)) {
2317
2595
  return { response: createLegacyMcpToolDisabledResponse() };
@@ -2323,7 +2601,7 @@ async function _executeMcpToolCallInner(payload) {
2323
2601
  }
2324
2602
  if (MCP_READ_TOOLS.some((tool) => tool.name === name) || LEGACY_READ_TOOL_HANDLERS.has(name)) {
2325
2603
  return {
2326
- response: appendLegacyMcpToolWarning(toolResponse(handleMcpReadToolCall(name, args, { cwd })), name),
2604
+ response: appendLegacyMcpToolWarning(toolResponse(handleMcpReadToolCall(name, args, { cwd, connectionSessionId, effectiveScope: scopeInfo })), name),
2327
2605
  };
2328
2606
  }
2329
2607
  // Resolve model once for all write operations
@@ -2385,20 +2663,24 @@ async function _executeMcpToolCallInner(payload) {
2385
2663
  if (detected) {
2386
2664
  summary.push(`✔ Agent detected: ${detected.name}`);
2387
2665
  }
2388
- summary.push('✔ Reload your agent session to activate brainclaw MCP tools.');
2389
- // Check if bootstrap is available and generate preview
2666
+ summary.push('✔ Full brainclaw MCP catalog activates automatically; reload your agent session only if new tools do not appear.');
2667
+ // Bootstrap route follows the shared empty-memory rule; the preview
2668
+ // already embeds the same recommendation text when memory is empty.
2390
2669
  const probe = probeForQuickSetup(cwd);
2391
2670
  const bootstrapAvailable = probe.hasContent;
2671
+ const emptyMemoryRec = resolveEmptyMemoryRecommendation(cwd);
2392
2672
  const preview = buildOnboardingPreview(cwd);
2393
2673
  return {
2394
2674
  response: toolResponse({
2395
- content: [{ type: 'text', text: summary.join('\n') + (bootstrapAvailable ? '\n\nThe repo has existing content. Run bclaw_bootstrap to extract initial project context.' : '') + '\n\n' + preview }],
2675
+ content: [{ type: 'text', text: summary.join('\n') + '\n\n' + preview }],
2396
2676
  structuredContent: {
2397
2677
  setup_complete: true,
2398
2678
  project_type: projectType,
2399
2679
  topology,
2400
2680
  detected_agent: detected?.name ?? null,
2401
2681
  bootstrap_available: bootstrapAvailable,
2682
+ bootstrap_route: emptyMemoryRec.route,
2683
+ next_action: emptyMemoryRec.mcp_next_action,
2402
2684
  preview,
2403
2685
  summary,
2404
2686
  },
@@ -2579,7 +2861,7 @@ async function _executeMcpToolCallInner(payload) {
2579
2861
  });
2580
2862
  const signal = writeCrossProjectSignal(resolveCrossProjectWritableTarget(crossProjectTarget, 'runtime_note', cwd), 'runtime_note', {
2581
2863
  schema_version: 2,
2582
- id: generateId('rtn'),
2864
+ id: generateId('runtime_note'),
2583
2865
  agent: opIdentity.agent,
2584
2866
  agent_id: opIdentity.agent_id,
2585
2867
  project_id: opIdentity.project_id ?? '',
@@ -2649,7 +2931,20 @@ async function _executeMcpToolCallInner(payload) {
2649
2931
  }
2650
2932
  }
2651
2933
  const identity = resolved.identity;
2652
- const classification = classifyQuickCapture(text);
2934
+ // Caller-asserted classification wins (pln#542): the calling agent
2935
+ // declares decision/trap/constraint/note; keyword heuristics are the
2936
+ // fallback when no type is given.
2937
+ const assertedType = typeof args.type === 'string' ? args.type.trim().toLowerCase() : undefined;
2938
+ let classification;
2939
+ if (assertedType === 'decision' || assertedType === 'trap' || assertedType === 'constraint' || assertedType === 'note') {
2940
+ classification = { target: assertedType, reason: 'caller_asserted', decisionScore: 0, trapScore: 0 };
2941
+ }
2942
+ else if (assertedType !== undefined) {
2943
+ return { response: createToolErrorResponse('validation_error', `type must be one of: decision, trap, constraint, note (got '${assertedType}')`) };
2944
+ }
2945
+ else {
2946
+ classification = classifyQuickCapture(text);
2947
+ }
2653
2948
  if (classification.target === 'note') {
2654
2949
  const result = createRuntimeNote(formatQuickCaptureNoteText(text, context), {
2655
2950
  agent: identity.agent_name,
@@ -2671,6 +2966,9 @@ async function _executeMcpToolCallInner(payload) {
2671
2966
  note_id: result.noteId,
2672
2967
  session_id: result.sessionId,
2673
2968
  context,
2969
+ next_actions: [
2970
+ { tool: 'bclaw_quick_capture', args: { text: '<same text>', type: 'decision' }, when: 'this was actually a durable decision/trap/constraint — re-capture with an asserted type' },
2971
+ ],
2674
2972
  },
2675
2973
  }),
2676
2974
  nextConnectionSessionId: explicitSessionIdFromEnv() ? undefined : result.sessionId,
@@ -2688,6 +2986,13 @@ async function _executeMcpToolCallInner(payload) {
2688
2986
  const statusText = capture.writeThrough
2689
2987
  ? `✔ Quick capture promoted as ${classification.target} [${capture.promotedItemId}]`
2690
2988
  : `✔ Quick capture saved as ${classification.target} candidate [${capture.candidateId}]`;
2989
+ const captureNextActions = capture.writeThrough
2990
+ ? [
2991
+ { tool: 'bclaw_get', args: { entity: classification.target, id: capture.promotedItemId }, when: 'to verify the promoted item' },
2992
+ ]
2993
+ : [
2994
+ { tool: 'bclaw_get', args: { entity: 'candidate', id: capture.candidateId }, when: 'to review the pending candidate (contradiction metadata is advisory)' },
2995
+ ];
2691
2996
  return {
2692
2997
  response: toolResponse({
2693
2998
  content: [{ type: 'text', text: statusText }],
@@ -2699,7 +3004,8 @@ async function _executeMcpToolCallInner(payload) {
2699
3004
  candidate_id: capture.candidateId,
2700
3005
  promoted_item_id: capture.promotedItemId,
2701
3006
  write_through: capture.writeThrough,
2702
- promotion_blocked_reason: capture.promotionBlockedReason,
3007
+ // Advisory only (cnd_abe61d68): contradictions are metadata on
3008
+ // the candidate, never a promotion blocker.
2703
3009
  contradiction_summary: capture.contradictionSummary,
2704
3010
  contradictions_detected: capture.contradictionsDetected?.map((item) => ({
2705
3011
  severity: item.severity,
@@ -2707,9 +3013,9 @@ async function _executeMcpToolCallInner(payload) {
2707
3013
  conflicts_with: item.conflicts_with,
2708
3014
  })),
2709
3015
  context,
3016
+ next_actions: captureNextActions,
2710
3017
  },
2711
3018
  }),
2712
- nextConnectionSessionId: undefined,
2713
3019
  };
2714
3020
  }
2715
3021
  if (name === 'bclaw_create_plan') {
@@ -3038,10 +3344,29 @@ async function _executeMcpToolCallInner(payload) {
3038
3344
  catch {
3039
3345
  return { response: createToolErrorResponse('not_found', `Claim not found: ${claimId}`) };
3040
3346
  }
3041
- const cascadeResult = releaseClaimWithCascade(claimId, {
3042
- planStatus: args.planStatus,
3043
- cwd,
3044
- });
3347
+ // pln#562 step 5 — release is ownership-checked like acquisition and
3348
+ // adoption. The resolved principal must own the claim; trusted+ callers
3349
+ // (coordinators) may release others' claims, with an audit entry.
3350
+ const releaseIdentity = resolveMutationIdentity(args, { nameField: 'agent', idField: 'agentId' }, cwd, connectionSessionId);
3351
+ const releaseAuth = 'identity' in releaseIdentity && releaseIdentity.identity
3352
+ ? {
3353
+ agent: releaseIdentity.identity.agent_name,
3354
+ agent_id: releaseIdentity.identity.agent_id,
3355
+ session_id: connectionSessionId,
3356
+ override: hasMinimumTrustLevel(releaseIdentity.identity.trust_level ?? 'contributor', 'trusted'),
3357
+ }
3358
+ : undefined;
3359
+ let cascadeResult;
3360
+ try {
3361
+ cascadeResult = releaseClaimWithCascade(claimId, {
3362
+ planStatus: args.planStatus,
3363
+ cwd,
3364
+ auth: releaseAuth,
3365
+ });
3366
+ }
3367
+ catch (err) {
3368
+ return { response: createToolErrorResponse('trust_error', err instanceof Error ? err.message : String(err)) };
3369
+ }
3045
3370
  const { planTransitioned, planWarning, planId: cascadePlanId, newPlanStatus: cascadeNewStatus } = cascadeResult;
3046
3371
  const summaryText = [
3047
3372
  `✔ Released claim [${claimId}]`,
@@ -3256,7 +3581,7 @@ async function _executeMcpToolCallInner(payload) {
3256
3581
  ...(result.handoff ? { handoff: result.handoff } : {}),
3257
3582
  ...(result.reflection_prompt ? { reflection_prompt: result.reflection_prompt } : {}),
3258
3583
  }),
3259
- nextConnectionSessionId: undefined,
3584
+ nextConnectionSessionId: null,
3260
3585
  };
3261
3586
  }
3262
3587
  if (name === 'bclaw_compact') {
@@ -3390,20 +3715,41 @@ async function _executeMcpToolCallInner(payload) {
3390
3715
  }
3391
3716
  lines.push(` Sequence: ${analysis.sequence.name}`);
3392
3717
  lines.push(` Ready: ${analysis.ready.length} | Active: ${analysis.active.length} | Blocked: ${analysis.blocked.length} | Done: ${analysis.done.length}`);
3718
+ // can_681a6c52 — truthful per-delivery status. The old output printed
3719
+ // '[inbox]' for every entry and ALWAYS dumped a 'Run these commands'
3720
+ // block, even when the auto-spawn had already SUCCEEDED — an obedient
3721
+ // coordinator would then double-spawn the worker.
3722
+ const spawnedPlanIds = new Set(dispatchResult.messages_sent
3723
+ .filter((m) => m.execution_status === 'delivered_and_started')
3724
+ .map((m) => m.plan_id));
3393
3725
  if (dispatchResult.messages_sent.length > 0) {
3394
3726
  lines.push('');
3395
3727
  lines.push(args.dryRun ? ' Would assign:' : ' Assigned:');
3396
3728
  for (const msg of dispatchResult.messages_sent) {
3397
3729
  const lane = msg.lane ? ` (lane: ${msg.lane})` : '';
3398
- lines.push(` ${msg.agent}: ${msg.plan_id}${lane} [inbox]`);
3730
+ const exec = msg.execution_status ? ` [${msg.execution_status}]` : ' [inbox]';
3731
+ const pid = msg.pid ? ` pid=${msg.pid}` : '';
3732
+ const run = msg.run_id ? ` run=${msg.run_id}` : '';
3733
+ lines.push(` ${msg.agent}: ${msg.plan_id}${lane}${exec}${pid}${run}`);
3734
+ }
3735
+ }
3736
+ const spawned = dispatchResult.messages_sent.filter((m) => m.execution_status === 'delivered_and_started');
3737
+ if (spawned.length > 0) {
3738
+ lines.push('');
3739
+ lines.push('Auto-spawn succeeded — do NOT run the launch commands for these (double-spawn risk). Verify instead:');
3740
+ for (const msg of spawned) {
3741
+ const target = msg.assignment_id ?? msg.run_id ?? msg.claim_id;
3742
+ lines.push(` bclaw_dispatch_status(target_id: "${target}") # ${msg.agent} on ${msg.plan_id}`);
3399
3743
  }
3400
3744
  }
3401
- // Surface bash commands prominently this is what the coordinator should run
3402
- if (dispatchResult.commands.length > 0) {
3745
+ // Only surface manual launch commands for deliveries that were NOT
3746
+ // auto-spawned (manual fallback, spawn refusal, inbox-only).
3747
+ const manualCommands = dispatchResult.commands.filter((cmd) => !cmd.plan_id || !spawnedPlanIds.has(cmd.plan_id));
3748
+ if (manualCommands.length > 0) {
3403
3749
  lines.push('');
3404
- lines.push('Run these commands to launch the assigned agents:');
3750
+ lines.push('Run these commands to launch the agents that were NOT auto-spawned:');
3405
3751
  lines.push('');
3406
- for (const cmd of dispatchResult.commands) {
3752
+ for (const cmd of manualCommands) {
3407
3753
  const lane = cmd.lane ? ` [lane: ${cmd.lane}]` : '';
3408
3754
  lines.push(`# ${cmd.agent}${lane}`);
3409
3755
  lines.push(cmd.command);
@@ -3417,6 +3763,15 @@ async function _executeMcpToolCallInner(payload) {
3417
3763
  lines.push(` - ${skip.plan_id}: ${skip.reason}`);
3418
3764
  }
3419
3765
  }
3766
+ // can_45316d5c — worktree warnings / spawn refusals were hidden behind
3767
+ // dispatch_status; surface them in the cycle output itself.
3768
+ if (dispatchResult.warnings.length > 0) {
3769
+ lines.push('');
3770
+ lines.push(' Warnings:');
3771
+ for (const warning of dispatchResult.warnings) {
3772
+ lines.push(` ⚠ ${warning}`);
3773
+ }
3774
+ }
3420
3775
  appendAuditEntry({
3421
3776
  actor: resolved.identity.agent_name,
3422
3777
  actor_id: resolved.identity.agent_id,
@@ -3499,7 +3854,9 @@ async function _executeMcpToolCallInner(payload) {
3499
3854
  if (status === 'accepted' && assignment.message_id) {
3500
3855
  try {
3501
3856
  const { ackMessage } = await import('../core/messaging.js');
3502
- ackMessage(assignment.message_id, callerAgent, cwd);
3857
+ // pln#562 step 4 — scope the ack to this assignment's claim so a
3858
+ // same-named sibling instance cannot consume the message.
3859
+ ackMessage(assignment.message_id, callerAgent, cwd, { claimId: assignment.claim_id });
3503
3860
  }
3504
3861
  catch { /* best-effort: don't fail the update if ack fails */ }
3505
3862
  }
@@ -3666,7 +4023,11 @@ async function _executeMcpToolCallInner(payload) {
3666
4023
  return { response: createToolErrorResponse('validation_error', 'Missing required argument: id') };
3667
4024
  }
3668
4025
  try {
3669
- const result = ackMessage(msgId, resolved.identity.agent_name, cwd);
4026
+ // pln#562 step 4 — a dispatched instance (BRAINCLAW_CLAIM_ID) may only
4027
+ // ack messages bound to its own claim.
4028
+ const result = ackMessage(msgId, resolved.identity.agent_name, cwd, {
4029
+ claimId: process.env.BRAINCLAW_CLAIM_ID?.trim() || undefined,
4030
+ });
3670
4031
  appendAuditEntry({ actor: resolved.identity.agent_name, actor_id: resolved.identity.agent_id, action: 'update', item_id: result.id, item_type: 'message' }, cwd);
3671
4032
  return {
3672
4033
  response: toolResponse({
@@ -3768,13 +4129,15 @@ async function _executeMcpToolCallInner(payload) {
3768
4129
  const stepTextRaw = stepData.text ?? stepData.title ?? args.text;
3769
4130
  const stepText = typeof stepTextRaw === 'string' ? stepTextRaw.trim() : '';
3770
4131
  const stepAssignee = (stepData.assignee ?? args.assignee);
4132
+ const stepEstimated = (stepData.estimated_effort ?? args.estimated_effort);
4133
+ const stepActual = (stepData.actual_effort ?? args.actual_effort);
3771
4134
  if (!stepPlanId)
3772
4135
  return { response: createToolErrorResponse('validation_error', 'Missing required argument: planId') };
3773
4136
  if (!stepText)
3774
4137
  return { response: createToolErrorResponse('validation_error', 'Missing required argument: data.text') };
3775
4138
  const stepTargetCwd = resolveProjectCwd(args.project, cwd);
3776
4139
  try {
3777
- const result = addStepOp({ planId: stepPlanId, text: stepText, assignee: stepAssignee }, stepTargetCwd);
4140
+ const result = addStepOp({ planId: stepPlanId, text: stepText, assignee: stepAssignee, estimatedEffort: stepEstimated, actualEffort: stepActual }, stepTargetCwd);
3778
4141
  return {
3779
4142
  response: toolResponse({
3780
4143
  content: [{ type: 'text', text: `✔ Step added: [${result.stepId}] ${stepText} (${result.doneSteps}/${result.totalSteps} done)` }],
@@ -3855,6 +4218,8 @@ async function _executeMcpToolCallInner(payload) {
3855
4218
  status: args.status,
3856
4219
  text: args.text,
3857
4220
  assignee: args.assignee,
4221
+ estimatedEffort: args.estimated_effort,
4222
+ actualEffort: args.actual_effort,
3858
4223
  }, usTargetCwd);
3859
4224
  const changes = [];
3860
4225
  if (args.status)
@@ -3863,6 +4228,10 @@ async function _executeMcpToolCallInner(payload) {
3863
4228
  changes.push('text updated');
3864
4229
  if (args.assignee !== undefined)
3865
4230
  changes.push(`assignee=${args.assignee || 'unassigned'}`);
4231
+ if (args.estimated_effort !== undefined)
4232
+ changes.push(`estimate=${args.estimated_effort}`);
4233
+ if (args.actual_effort !== undefined)
4234
+ changes.push(`actual=${args.actual_effort}`);
3866
4235
  return {
3867
4236
  response: toolResponse({
3868
4237
  content: [{ type: 'text', text: `✔ Step updated: [${result.stepId}] ${changes.join(', ')} (${result.doneSteps}/${result.totalSteps} done)` }],
@@ -4290,11 +4659,11 @@ async function _executeMcpToolCallInner(payload) {
4290
4659
  if (sessionResult.auto_registered) {
4291
4660
  warnings.push(`Agent '${sessionResult.agent}' was auto-registered (first use). Run \`brainclaw register-agent ${sessionResult.agent}\` to set capabilities and trust level.`);
4292
4661
  }
4293
- // Step 2: build context for requested scope. When intent='resume',
4294
- // auto-surface the memory delta since the previous session for the
4295
- // same agent matches session-start.ts:85 pattern. Phase 4
4296
- // Sprint 1 Lane A step 5 (pln#390). Without this the resume intent
4297
- // was functionally identical to consult, defeating its purpose.
4662
+ // Step 2: build context for requested scope. The "what's new" diff is
4663
+ // surfaced for ALL intents (pln#390 regression fix): intent='resume'
4664
+ // anchors it on the agent's previous session; every other intent gets
4665
+ // the per-agent event-log-cursor diff computed inside buildContext
4666
+ // (pln#542 converged novelty mechanism, covers status transitions).
4298
4667
  let contextResult;
4299
4668
  try {
4300
4669
  let sinceSession;
@@ -4308,6 +4677,8 @@ async function _executeMcpToolCallInner(payload) {
4308
4677
  agent: sessionResult.agent,
4309
4678
  cwd: targetCwd,
4310
4679
  sinceSession,
4680
+ // ~4 chars/token: relevance-ranked fill up to the caller's budget.
4681
+ maxChars: workReq.budget_tokens ? workReq.budget_tokens * 4 : undefined,
4311
4682
  });
4312
4683
  }
4313
4684
  catch { /* non-fatal — context failure should not block work */ }
@@ -4368,11 +4739,30 @@ async function _executeMcpToolCallInner(payload) {
4368
4739
  text: w.text.slice(0, 80),
4369
4740
  age_days: w.age_days,
4370
4741
  }));
4742
+ // pln#390 regression fix: the compact projection used to silently
4743
+ // drop context_diff, defeating the diff-first contract. Keep a
4744
+ // trimmed view (summary + counts + top-5 items, text capped).
4745
+ const trimmedDiff = contextResult.context_diff
4746
+ ? {
4747
+ since: contextResult.context_diff.since,
4748
+ since_session: contextResult.context_diff.since_session,
4749
+ source: contextResult.context_diff.source,
4750
+ summary: contextResult.context_diff.summary,
4751
+ counts: contextResult.context_diff.counts,
4752
+ changed_items: (contextResult.context_diff.changed_items ?? [])
4753
+ .slice(0, 5)
4754
+ .map((item) => ({ ...item, text: item.text.slice(0, 120) })),
4755
+ ...(contextResult.context_diff.unseen_event_count !== undefined
4756
+ ? { unseen_event_count: contextResult.context_diff.unseen_event_count }
4757
+ : {}),
4758
+ }
4759
+ : undefined;
4371
4760
  resultPayload = {
4372
4761
  context_schema: contextResult.context_schema,
4373
4762
  profile: contextResult.profile,
4374
4763
  memory_version: contextResult.memory_version,
4375
4764
  memory_density: contextResult.memory_density,
4765
+ context_diff: trimmedDiff ?? null,
4376
4766
  plan_summary: planItems,
4377
4767
  stale_warnings: staleTop3,
4378
4768
  workflow_hints: (contextResult.workflow_hints ?? []).slice(0, 3),
@@ -4382,34 +4772,88 @@ async function _executeMcpToolCallInner(payload) {
4382
4772
  _full_context_hint: 'Use bclaw_context(kind="memory") for the full payload.',
4383
4773
  };
4384
4774
  }
4385
- // pln#513 step 1 — bootstrap hint. When the project lacks a usable
4386
- // PROJECT.md (absent or 0 bytes), surface a hint so the agent knows
4387
- // the canonical entry-point. Cheap probe one fs.statSync, no
4388
- // gating flag. The literal next_action mirrors the documented
4389
- // canonical-grammar call so callers can suggest it verbatim.
4775
+ // pln#513 step 1 / pln#557 step 3 — bootstrap hint. The original probe
4776
+ // was a one-bit PROJECT.md stat(): false positive on a rich store
4777
+ // without PROJECT.md (recommended from-scratch bootstrap over 17k
4778
+ // events), eternal false negative on a fossil PROJECT.md. The composite
4779
+ // assessment (assessBootstrapNeed: presence × mtime-vs-activity ×
4780
+ // store density) adds a distinct 'refresh' verdict that maps to
4781
+ // bclaw_bootstrap(refresh: true) — coordinate with the pln#514 step 1
4782
+ // force-flag. 'bootstrap' keeps the shared empty-memory rule
4783
+ // (resolveEmptyMemoryRecommendation): repo with content → extract via
4784
+ // bclaw_bootstrap; greenfield → bootstrap loop. Both chainable.
4390
4785
  let bootstrapRecommended;
4786
+ let bootstrapVerdict;
4787
+ let bootstrapRefreshReason;
4391
4788
  let nextAction;
4789
+ let emptyMemoryRec;
4790
+ let storeDensity;
4392
4791
  try {
4393
- const fsMod = await import('node:fs');
4394
- const pathMod = await import('node:path');
4395
- const projectMdPath = pathMod.join(targetCwd, 'PROJECT.md');
4396
- let needsBootstrap = false;
4397
- try {
4398
- const stat = fsMod.statSync(projectMdPath);
4399
- if (!stat.isFile() || stat.size === 0)
4400
- needsBootstrap = true;
4401
- }
4402
- catch {
4403
- needsBootstrap = true; // ENOENT → absent
4792
+ const assessment = assessBootstrapNeed(targetCwd);
4793
+ bootstrapVerdict = assessment.verdict;
4794
+ bootstrapRecommended = assessment.verdict !== 'none';
4795
+ storeDensity = assessment.store_density;
4796
+ if (assessment.verdict === 'bootstrap') {
4797
+ emptyMemoryRec = resolveEmptyMemoryRecommendation(targetCwd);
4798
+ nextAction = emptyMemoryRec.mcp_next_action;
4404
4799
  }
4405
- bootstrapRecommended = needsBootstrap;
4406
- if (needsBootstrap) {
4407
- nextAction = "bclaw_coordinate(intent='ideate', preset='bootstrap')";
4800
+ else if (assessment.verdict === 'refresh') {
4801
+ bootstrapRefreshReason = assessment.reasons[0];
4802
+ nextAction = 'bclaw_bootstrap(refresh: true)';
4408
4803
  }
4409
4804
  }
4410
4805
  catch {
4411
4806
  // Best-effort: never block bclaw_work on the probe.
4412
4807
  }
4808
+ // Self-teaching affordances (pln#542): each response carries the
4809
+ // recommended follow-up calls with exact shapes.
4810
+ const nextActions = [];
4811
+ if (bootstrapVerdict === 'bootstrap' && emptyMemoryRec) {
4812
+ if (emptyMemoryRec.route === 'extract') {
4813
+ nextActions.push({ tool: 'bclaw_bootstrap', args: {}, when: `project vision is missing and the ${emptyMemoryRec.reason} — extract initial context, then chain ${emptyMemoryRec.chained_mcp_action} if the vision is still missing` });
4814
+ }
4815
+ else {
4816
+ nextActions.push({ tool: 'bclaw_coordinate', args: { intent: 'ideate', preset: 'bootstrap' }, when: `project vision is missing and the repo is greenfield — open a bootstrap loop before assuming context, then chain ${emptyMemoryRec.chained_mcp_action} once content exists` });
4817
+ }
4818
+ }
4819
+ else if (bootstrapVerdict === 'refresh') {
4820
+ nextActions.push({ tool: 'bclaw_bootstrap', args: { refresh: true }, when: bootstrapRefreshReason ?? 'PROJECT.md is missing or fossil relative to a mature store — refresh from existing memory, do not bootstrap from scratch' });
4821
+ }
4822
+ if (claimId) {
4823
+ nextActions.push({ tool: 'bclaw_release_claim', args: { id: claimId, planStatus: 'done' }, when: 'implementation complete and committed' });
4824
+ }
4825
+ else if (workReq.intent === 'consult' || workReq.intent === 'resume') {
4826
+ nextActions.push({ tool: 'bclaw_work', args: { intent: 'execute', scope: workReq.scope ?? '<scope>' }, when: 'ready to edit — claims the scope' });
4827
+ }
4828
+ // Solo-agent empty-store hint: the bootstrap recommendation covers
4829
+ // vision; agents arriving on a freshly-initialised store also need a
4830
+ // surface for *work* itself. Without this they reliably consult, see
4831
+ // nothing, and stop — bclaw_create(entity='plan') is the missing
4832
+ // affordance (2026-06-10 front-door audit). The store-density signal
4833
+ // bumps to 'low' as soon as session_start lands a single event, so
4834
+ // gate the hint directly on "no plans yet" — the actual condition
4835
+ // the agent is in.
4836
+ let noPlansYet = false;
4837
+ try {
4838
+ noPlansYet = loadState(targetCwd).plan_items.length === 0;
4839
+ }
4840
+ catch {
4841
+ // loadState may fail on a brand-new store with no project.md yet;
4842
+ // treat that as "no plans yet" — the next_action remains correct.
4843
+ noPlansYet = true;
4844
+ }
4845
+ if (noPlansYet && (storeDensity === 'empty' || storeDensity === 'low')) {
4846
+ nextActions.push({
4847
+ tool: 'bclaw_create',
4848
+ args: { entity: 'plan', input: { title: '<plan title>', steps: ['<first step>'] } },
4849
+ when: 'memory has no plans yet — once you know what you are doing, create a plan so progress is tracked',
4850
+ });
4851
+ }
4852
+ const diffTotal = contextResult?.context_diff?.counts.total ?? 0;
4853
+ if (useCompact && diffTotal > 0) {
4854
+ nextActions.push({ tool: 'bclaw_context', args: { kind: 'memory' }, when: 'to read the full changed items behind context_diff' });
4855
+ }
4856
+ nextActions.push({ tool: 'bclaw_quick_capture', args: { text: '<finding>', type: '<decision|trap|constraint|note>' }, when: 'capture decisions/traps as you work' });
4413
4857
  const facadeResponse = {
4414
4858
  status: 'ok',
4415
4859
  intent: workReq.intent,
@@ -4421,15 +4865,23 @@ async function _executeMcpToolCallInner(payload) {
4421
4865
  warnings,
4422
4866
  duration_ms: Date.now() - startMs,
4423
4867
  bootstrap_recommended: bootstrapRecommended,
4868
+ bootstrap_verdict: bootstrapVerdict,
4424
4869
  next_action: nextAction,
4870
+ next_actions: nextActions,
4425
4871
  };
4426
4872
  const summaryParts = [`✔ bclaw_work [${workReq.intent}] session=${sessionResult.session_id}`];
4427
4873
  if (claimId)
4428
4874
  summaryParts.push(`claim=${claimId} (${claimStatus})`);
4875
+ if (contextResult?.context_diff && diffTotal > 0) {
4876
+ summaryParts.push(`Δ since last look: ${contextResult.context_diff.summary}`);
4877
+ }
4429
4878
  if (useCompact)
4430
4879
  summaryParts.push('mode=compact (use bclaw_context for full payload)');
4431
- if (bootstrapRecommended) {
4432
- summaryParts.push(`💡 PROJECT.md missing — call ${nextAction} to open a bootstrap loop`);
4880
+ if (bootstrapVerdict === 'bootstrap' && emptyMemoryRec) {
4881
+ summaryParts.push(`💡 ${emptyMemoryRec.text}`);
4882
+ }
4883
+ else if (bootstrapVerdict === 'refresh') {
4884
+ summaryParts.push(`💡 ${bootstrapRefreshReason ?? 'PROJECT.md is missing or fossil relative to a mature store'} → bclaw_bootstrap(refresh: true)`);
4433
4885
  }
4434
4886
  if (warnings.length > 0)
4435
4887
  summaryParts.push(warnings.map((w) => `⚠ ${w}`).join('\n'));
@@ -5732,6 +6184,23 @@ async function _executeMcpToolCallInner(payload) {
5732
6184
  };
5733
6185
  }
5734
6186
  if (name === 'bclaw_loop') {
6187
+ // pln#542: intent='open' is no longer exposed standalone over MCP — it
6188
+ // creates a loop without dispatching the first turn (the documented
6189
+ // anti-pattern, now removed instead of documented). Internal callers
6190
+ // (bclaw_coordinate, CLI bootstrap) use core openLoop directly.
6191
+ if (args?.intent === 'open') {
6192
+ return {
6193
+ response: createToolErrorResponse('intent_not_exposed', "bclaw_loop(intent='open') is not exposed standalone: it creates a loop structure without dispatching any turn, so the work never starts. Use bclaw_coordinate(intent='review', open_loop=true, targetAgents=[…]) or bclaw_coordinate(intent='ideate') — they open the loop AND dispatch the first turn."),
6194
+ };
6195
+ }
6196
+ // pln#562 step 4 — dispatching a turn hands work to another agent; gate
6197
+ // it at the same trust bar as the other dispatch surfaces.
6198
+ if (args?.intent === 'turn' && args?.dispatch === true) {
6199
+ const resolved = ensureTrust(args, { nameField: 'agent', idField: 'agentId' }, 'trusted', cwd, connectionSessionId);
6200
+ if (resolved.error) {
6201
+ return { response: createToolErrorResponse(resolved.error.kind, resolved.error.message, resolved.error.details) };
6202
+ }
6203
+ }
5735
6204
  const { handleBclawLoop } = await import('./loops-handlers.js');
5736
6205
  const targetCwd = resolveProjectCwd(args?.project, cwd);
5737
6206
  const result = handleBclawLoop({ args: args, cwd: targetCwd });
@@ -5778,6 +6247,7 @@ async function _executeMcpToolCallInner(payload) {
5778
6247
  try {
5779
6248
  const entity = String(args.entity ?? '');
5780
6249
  const targetCwd = resolveProjectCwd(args.project, cwd);
6250
+ const targetScope = scopeMetadataForTarget(args, targetCwd, scopeInfo);
5781
6251
  // pln#460 follow-up — some MCP clients (notably Claude Code with a
5782
6252
  // tool schema that declares `filter: { type: 'object' }` without a
5783
6253
  // sub-property schema) stringify the filter object before shipping
@@ -5825,9 +6295,19 @@ async function _executeMcpToolCallInner(payload) {
5825
6295
  // this caps SIZE) so a verbose result set never overflows the MCP token
5826
6296
  // cap and silently pushes the agent to the CLI (trp#449). Advertises
5827
6297
  // has_more / next_offset / hint for explicit pagination.
6298
+ // pln#542: budget_tokens lets the caller shrink the size cap further
6299
+ // (~4 chars/token); it can only tighten, never exceed the default.
5828
6300
  const offset = Math.max(0, Number(filter.offset) || 0);
5829
- const bounded = boundListResult(result, offset);
6301
+ const budgetTokens = typeof args.budget_tokens === 'number' && args.budget_tokens > 0 ? args.budget_tokens : undefined;
6302
+ const charBudget = budgetTokens ? Math.min(budgetTokens * 4, DEFAULT_FIND_CHAR_BUDGET) : DEFAULT_FIND_CHAR_BUDGET;
6303
+ const bounded = boundListResult(result, offset, charBudget);
5830
6304
  const warnings = collectLoadValidationWarnings(entity, targetCwd);
6305
+ const nextActions = [
6306
+ { tool: 'bclaw_get', args: { entity, id: '<id from items>', ...(args.project ? { project: args.project } : {}), ...(args.budget_tokens ? { budget_tokens: args.budget_tokens } : {}) }, when: 'to read one item in full' },
6307
+ ];
6308
+ if (bounded.has_more) {
6309
+ nextActions.push({ tool: 'bclaw_find', args: { entity, filter: { ...filter, offset: bounded.next_offset }, ...(args.project ? { project: args.project } : {}), ...(args.budget_tokens ? { budget_tokens: args.budget_tokens } : {}) }, when: 'to fetch the next page' });
6310
+ }
5831
6311
  // structuredContent is the canonical MCP return channel that clients
5832
6312
  // (VS Code extension, Codex, etc.) read for machine-parseable data.
5833
6313
  // Prior to this fix we spread `...result` at top-level of the
@@ -5835,10 +6315,20 @@ async function _executeMcpToolCallInner(payload) {
5835
6315
  // `result.items` arrived as undefined on the client — the root cause
5836
6316
  // of the VS Code Backlog section rendering empty.
5837
6317
  const moreNote = bounded.has_more ? ` (returned ${bounded.returned}; ${result.total - bounded.returned} more — offset ${bounded.next_offset})` : '';
6318
+ const provenanceFilterNote = renderProvenanceFilterNote(result);
6319
+ const legacyNote = provenanceFilterNote
6320
+ ? `; ${provenanceFilterNote}`
6321
+ : '';
5838
6322
  return {
5839
6323
  response: toolResponse({
5840
- content: [{ type: 'text', text: `✔ ${result.total} ${entity} item(s)${moreNote}` }],
5841
- structuredContent: { ...bounded, warnings },
6324
+ content: [{ type: 'text', text: `✔ ${result.total} ${entity} item(s)${moreNote}${legacyNote}` }],
6325
+ structuredContent: {
6326
+ ...bounded,
6327
+ warnings,
6328
+ resolved_project: targetScope.resolved_project,
6329
+ active_source: targetScope.active_source,
6330
+ next_actions: nextActions,
6331
+ },
5842
6332
  }),
5843
6333
  };
5844
6334
  }
@@ -5867,10 +6357,35 @@ async function _executeMcpToolCallInner(payload) {
5867
6357
  };
5868
6358
  }
5869
6359
  const item = getEntity(entity, id, targetCwd);
6360
+ // trp#449 class (pln#542): handoff snapshots embed an unbounded git
6361
+ // diff — cap it (budget_tokens tightens, ~4 chars/token).
6362
+ let boundedItem = item;
6363
+ let diffTruncated = false;
6364
+ const getBudgetTokens = typeof args.budget_tokens === 'number' && args.budget_tokens > 0 ? args.budget_tokens : undefined;
6365
+ const getCharBudget = getBudgetTokens ? Math.min(getBudgetTokens * 4, DEFAULT_FIND_CHAR_BUDGET) : DEFAULT_FIND_CHAR_BUDGET;
6366
+ if (entity === 'handoff' && item && typeof item === 'object') {
6367
+ const snapshot = item.snapshot;
6368
+ if (snapshot?.diff && snapshot.diff.length > getCharBudget) {
6369
+ diffTruncated = true;
6370
+ boundedItem = {
6371
+ ...item,
6372
+ snapshot: { ...snapshot, diff: `${snapshot.diff.slice(0, getCharBudget)}\n… [diff truncated to ${getCharBudget} chars]` },
6373
+ };
6374
+ }
6375
+ else {
6376
+ const note = handoffDiffPreviewNote(snapshot);
6377
+ if (snapshot?.diff && note) {
6378
+ boundedItem = {
6379
+ ...item,
6380
+ snapshot: { ...snapshot, diff: `${snapshot.diff}\n${note}` },
6381
+ };
6382
+ }
6383
+ }
6384
+ }
5870
6385
  return {
5871
6386
  response: toolResponse({
5872
6387
  content: [{ type: 'text', text: `✔ fetched ${entity} ${id}` }],
5873
- structuredContent: { entity, item },
6388
+ structuredContent: { entity, item: boundedItem, ...(diffTruncated ? { diff_truncated: true } : {}) },
5874
6389
  }),
5875
6390
  };
5876
6391
  }
@@ -5881,29 +6396,52 @@ async function _executeMcpToolCallInner(payload) {
5881
6396
  if (name === 'bclaw_create') {
5882
6397
  try {
5883
6398
  const entity = String(args.entity ?? '');
6399
+ // Execution entities stay local: the signaling-only cross-project
6400
+ // boundary applies to the canonical verbs too, not just legacy tools.
6401
+ if (entity === 'claim' || entity === 'plan') {
6402
+ const crossProjectError = blockCrossProjectExecution(entity, args);
6403
+ if (crossProjectError)
6404
+ return { response: crossProjectError };
6405
+ }
5884
6406
  const rawData = (args.data ?? {});
5885
6407
  const targetCwd = resolveProjectCwd(args.project, cwd);
6408
+ const targetScope = scopeMetadataForTarget(args, targetCwd, scopeInfo);
5886
6409
  // Auto-fill identity fields. Without this, a caller who omits author/agent
5887
6410
  // creates a schema-invalid record that is silently dropped on read and
5888
- // GC'd from disk on the next mutation. Fallback chain:
5889
- // resolved MCP identity → args.agent → 'unknown'.
6411
+ // GC'd from disk on the next mutation.
5890
6412
  // Identity is resolved against the SOURCE cwd (the agent's own
5891
6413
  // project/registry), not the target — an agent doesn't need to be
5892
- // registered in the target project to write into it.
5893
- const { agent_name, agent_id } = resolveCanonicalAuthor(args, cwd, connectionSessionId);
6414
+ // registered in the target project to write into it. An explicitly
6415
+ // supplied data.author is honored as content-level attribution
6416
+ // (cross-project signaling writers may not be registered locally);
6417
+ // when author is MISSING, resolution is mandatory and failure is a
6418
+ // hard validation_error (pln#562 step 3) — never author:'unknown'.
5894
6419
  const data = { ...rawData };
5895
- if (data.author === undefined)
6420
+ let actor = typeof data.author === 'string' ? data.author : undefined;
6421
+ let actorId = typeof data.agent_id === 'string' ? data.agent_id : undefined;
6422
+ if (data.author === undefined) {
6423
+ const { agent_name, agent_id } = resolveCanonicalAuthor(args, cwd, connectionSessionId);
5896
6424
  data.author = agent_name;
5897
- if (data.agent === undefined)
5898
- data.agent = agent_name;
5899
- if (data.agent_id === undefined && agent_id)
5900
- data.agent_id = agent_id;
6425
+ if (data.agent === undefined)
6426
+ data.agent = agent_name;
6427
+ if (data.agent_id === undefined && agent_id)
6428
+ data.agent_id = agent_id;
6429
+ actor = agent_name;
6430
+ actorId = agent_id;
6431
+ }
6432
+ else if (data.agent === undefined) {
6433
+ data.agent = data.author;
6434
+ }
5901
6435
  const result = createEntity(entity, data, targetCwd);
5902
- appendAuditEntry({ actor: agent_name, ...(agent_id ? { actor_id: agent_id } : {}), action: 'create', item_id: result.id, item_type: entity }, targetCwd);
6436
+ appendAuditEntry({ actor: actor ?? 'unknown', ...(actorId ? { actor_id: actorId } : {}), action: 'create', item_id: result.id, item_type: entity }, targetCwd);
5903
6437
  return {
5904
6438
  response: toolResponse({
5905
6439
  content: [{ type: 'text', text: `✔ created ${entity} ${result.id}` }],
5906
- structuredContent: { ...result },
6440
+ structuredContent: {
6441
+ ...result,
6442
+ resolved_project: targetScope.resolved_project,
6443
+ active_source: targetScope.active_source,
6444
+ },
5907
6445
  }),
5908
6446
  };
5909
6447
  }
@@ -5917,13 +6455,18 @@ async function _executeMcpToolCallInner(payload) {
5917
6455
  const id = String(args.id ?? '');
5918
6456
  const patch = (args.patch ?? {});
5919
6457
  const targetCwd = resolveProjectCwd(args.project, cwd);
6458
+ const targetScope = scopeMetadataForTarget(args, targetCwd, scopeInfo);
5920
6459
  const { agent_name, agent_id } = resolveCanonicalAuthor(args, cwd, connectionSessionId);
5921
6460
  const result = updateEntity(entity, id, patch, targetCwd);
5922
6461
  appendAuditEntry({ actor: agent_name, ...(agent_id ? { actor_id: agent_id } : {}), action: 'update', item_id: id, item_type: entity }, targetCwd);
5923
6462
  return {
5924
6463
  response: toolResponse({
5925
6464
  content: [{ type: 'text', text: `✔ updated ${entity} ${id}` }],
5926
- structuredContent: { ...result },
6465
+ structuredContent: {
6466
+ ...result,
6467
+ resolved_project: targetScope.resolved_project,
6468
+ active_source: targetScope.active_source,
6469
+ },
5927
6470
  }),
5928
6471
  };
5929
6472
  }
@@ -5937,13 +6480,18 @@ async function _executeMcpToolCallInner(payload) {
5937
6480
  const id = String(args.id ?? '');
5938
6481
  const purge = args.purge === true;
5939
6482
  const targetCwd = resolveProjectCwd(args.project, cwd);
6483
+ const targetScope = scopeMetadataForTarget(args, targetCwd, scopeInfo);
5940
6484
  const { agent_name, agent_id } = resolveCanonicalAuthor(args, cwd, connectionSessionId);
5941
6485
  const result = removeEntity(entity, id, targetCwd, purge);
5942
6486
  appendAuditEntry({ actor: agent_name, ...(agent_id ? { actor_id: agent_id } : {}), action: 'delete', item_id: id, item_type: entity, reason: purge ? 'purged' : 'archived' }, targetCwd);
5943
6487
  return {
5944
6488
  response: toolResponse({
5945
6489
  content: [{ type: 'text', text: `✔ removed ${entity} ${id}` }],
5946
- structuredContent: { ...result },
6490
+ structuredContent: {
6491
+ ...result,
6492
+ resolved_project: targetScope.resolved_project,
6493
+ active_source: targetScope.active_source,
6494
+ },
5947
6495
  }),
5948
6496
  };
5949
6497
  }
@@ -5954,17 +6502,29 @@ async function _executeMcpToolCallInner(payload) {
5954
6502
  if (name === 'bclaw_transition') {
5955
6503
  try {
5956
6504
  const entity = String(args.entity ?? '');
6505
+ // Same signaling-only boundary as bclaw_create: no remote lifecycle
6506
+ // driving of execution entities through the canonical grammar.
6507
+ if (entity === 'claim' || entity === 'plan') {
6508
+ const crossProjectError = blockCrossProjectExecution(entity, args);
6509
+ if (crossProjectError)
6510
+ return { response: crossProjectError };
6511
+ }
5957
6512
  const id = String(args.id ?? '');
5958
6513
  const to = String(args.to ?? '');
5959
6514
  const reason = args.reason;
5960
6515
  const targetCwd = resolveProjectCwd(args.project, cwd);
6516
+ const targetScope = scopeMetadataForTarget(args, targetCwd, scopeInfo);
5961
6517
  const { agent_name, agent_id } = resolveCanonicalAuthor(args, cwd, connectionSessionId);
5962
6518
  const result = transitionEntity(entity, id, to, targetCwd, reason);
5963
6519
  appendAuditEntry({ actor: agent_name, ...(agent_id ? { actor_id: agent_id } : {}), action: 'update', item_id: id, item_type: entity, reason: `transition ${result.from} → ${to}${reason ? ` (${reason})` : ''}` }, targetCwd);
5964
6520
  return {
5965
6521
  response: toolResponse({
5966
6522
  content: [{ type: 'text', text: `✔ ${entity} ${id}: ${result.from} → ${to}` }],
5967
- structuredContent: { ...result },
6523
+ structuredContent: {
6524
+ ...result,
6525
+ resolved_project: targetScope.resolved_project,
6526
+ active_source: targetScope.active_source,
6527
+ },
5968
6528
  }),
5969
6529
  };
5970
6530
  }
@@ -6008,9 +6568,10 @@ async function _executeMcpToolCallInner(payload) {
6008
6568
  */
6009
6569
  export async function executeMcpToolCall(payload) {
6010
6570
  const baseCwd = payload.cwd;
6011
- const cwd = payload.name === 'bclaw_switch'
6012
- ? baseCwd
6013
- : resolveEffectiveCwd({ baseCwd });
6571
+ const effective = payload.name === 'bclaw_switch'
6572
+ ? { cwd: baseCwd, active_source: 'cwd', resolved_project: undefined }
6573
+ : resolveEffectiveCwdInfo({ baseCwd, sessionId: payload.connectionSessionId });
6574
+ const cwd = effective.cwd;
6014
6575
  const envClaimId = process.env.BRAINCLAW_CLAIM_ID?.trim() || undefined;
6015
6576
  // ── Auto-session ────────────────────────────────────────────────────────────
6016
6577
  let autoSessionId;
@@ -6068,6 +6629,7 @@ export async function executeMcpToolCall(payload) {
6068
6629
  ...payload,
6069
6630
  cwd,
6070
6631
  connectionSessionId: effectiveConnectionSessionId,
6632
+ effectiveScope: effective,
6071
6633
  });
6072
6634
  // Apply legacy deprecation warning uniformly (Phase 3 slice 3g). Read tools
6073
6635
  // already get it at line 2560; write tools historically did not. This