brainclaw 1.7.5 → 1.9.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 (143) hide show
  1. package/README.md +28 -11
  2. package/dist/brainclaw-vscode.vsix +0 -0
  3. package/dist/cli.js +139 -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 +502 -16
  15. package/dist/commands/init.js +123 -21
  16. package/dist/commands/loops-handlers.js +4 -0
  17. package/dist/commands/mcp-read-handlers.js +198 -29
  18. package/dist/commands/mcp.js +615 -92
  19. package/dist/commands/memory.js +21 -17
  20. package/dist/commands/migrate.js +81 -17
  21. package/dist/commands/prune.js +78 -4
  22. package/dist/commands/reflect.js +26 -20
  23. package/dist/commands/register-agent.js +57 -1
  24. package/dist/commands/repair.js +20 -0
  25. package/dist/commands/session-end.js +15 -6
  26. package/dist/commands/session-start.js +18 -1
  27. package/dist/commands/setup-security.js +39 -18
  28. package/dist/commands/setup.js +26 -27
  29. package/dist/commands/stale.js +16 -2
  30. package/dist/commands/uninstall.js +126 -34
  31. package/dist/commands/update-step.js +6 -0
  32. package/dist/commands/worktree.js +60 -0
  33. package/dist/core/actions.js +12 -3
  34. package/dist/core/agent-capability.js +11 -13
  35. package/dist/core/agent-files.js +844 -547
  36. package/dist/core/agent-integrations.js +0 -3
  37. package/dist/core/agent-inventory.js +67 -0
  38. package/dist/core/agent-registry.js +163 -29
  39. package/dist/core/agentrun-reconciler.js +33 -2
  40. package/dist/core/agentruns.js +7 -1
  41. package/dist/core/ai-agent-detection.js +31 -44
  42. package/dist/core/archival.js +15 -9
  43. package/dist/core/assignment-reconciler.js +56 -0
  44. package/dist/core/assignment-sweeper.js +127 -4
  45. package/dist/core/assignments.js +69 -11
  46. package/dist/core/bootstrap.js +233 -67
  47. package/dist/core/brainclaw-version.js +22 -0
  48. package/dist/core/candidates.js +21 -1
  49. package/dist/core/claims.js +313 -150
  50. package/dist/core/config.js +6 -1
  51. package/dist/core/context-diff.js +148 -20
  52. package/dist/core/context.js +129 -8
  53. package/dist/core/coordination.js +22 -3
  54. package/dist/core/dispatch-status.js +109 -5
  55. package/dist/core/dispatcher.js +65 -11
  56. package/dist/core/entity-operations.js +45 -24
  57. package/dist/core/entity-registry.js +31 -5
  58. package/dist/core/event-log.js +138 -21
  59. package/dist/core/events/checkpoint.js +258 -0
  60. package/dist/core/events/genesis.js +220 -0
  61. package/dist/core/events/journal.js +507 -0
  62. package/dist/core/events/materialize.js +126 -0
  63. package/dist/core/events/registry-post-image.js +110 -0
  64. package/dist/core/events/verify.js +109 -0
  65. package/dist/core/execution-adapters.js +23 -0
  66. package/dist/core/execution.js +25 -0
  67. package/dist/core/facade-schema.js +48 -0
  68. package/dist/core/gc-semantic.js +130 -5
  69. package/dist/core/handoff-snapshot.js +68 -0
  70. package/dist/core/ids.js +19 -8
  71. package/dist/core/instruction-templates.js +34 -115
  72. package/dist/core/io.js +39 -3
  73. package/dist/core/json-store.js +10 -1
  74. package/dist/core/lock.js +153 -28
  75. package/dist/core/loops/bootstrap-acquire.js +25 -1
  76. package/dist/core/loops/facade-schema.js +2 -0
  77. package/dist/core/loops/hooks/survey-signals-baseline.js +36 -0
  78. package/dist/core/loops/index.js +1 -0
  79. package/dist/core/loops/presets/bootstrap.js +7 -0
  80. package/dist/core/loops/store.js +17 -0
  81. package/dist/core/loops/verbs.js +24 -1
  82. package/dist/core/markdown.js +8 -76
  83. package/dist/core/mcp-command-resolution.js +245 -0
  84. package/dist/core/memory-compactor.js +5 -3
  85. package/dist/core/memory-lifecycle.js +282 -0
  86. package/dist/core/merge-risk.js +150 -0
  87. package/dist/core/messaging.js +8 -1
  88. package/dist/core/migration.js +11 -1
  89. package/dist/core/observer-mode.js +26 -0
  90. package/dist/core/operations/memory-mutation.js +90 -65
  91. package/dist/core/operations/plan.js +27 -1
  92. package/dist/core/protocol-skills.js +210 -0
  93. package/dist/core/reflection-safety.js +6 -7
  94. package/dist/core/reputation.js +84 -2
  95. package/dist/core/runtime-signals.js +71 -9
  96. package/dist/core/runtime.js +84 -1
  97. package/dist/core/schema.js +125 -0
  98. package/dist/core/security-detectors.js +125 -0
  99. package/dist/core/security-extract.js +189 -0
  100. package/dist/core/security-guard.js +107 -29
  101. package/dist/core/security-packages.js +121 -0
  102. package/dist/core/security-scoring.js +76 -9
  103. package/dist/core/security.js +34 -2
  104. package/dist/core/sequence.js +11 -2
  105. package/dist/core/setup-flow.js +141 -13
  106. package/dist/core/spawn-check.js +110 -4
  107. package/dist/core/staleness.js +109 -1
  108. package/dist/core/state.js +250 -54
  109. package/dist/core/store-resolution.js +19 -5
  110. package/dist/core/worktree.js +169 -7
  111. package/dist/facts.js +8 -8
  112. package/dist/facts.json +7 -7
  113. package/docs/PROTOCOL.md +223 -0
  114. package/docs/cli.md +11 -10
  115. package/docs/concepts/coordinator-runbook.md +129 -0
  116. package/docs/concepts/dispatch-lifecycle.md +17 -0
  117. package/docs/concepts/event-log-store-critique-A.md +333 -0
  118. package/docs/concepts/event-log-store-critique-B.md +353 -0
  119. package/docs/concepts/event-log-store-phase0-measurements.md +58 -0
  120. package/docs/concepts/event-log-store-proposal-A.md +365 -0
  121. package/docs/concepts/event-log-store-proposal-B.md +404 -0
  122. package/docs/concepts/event-log-store.md +928 -0
  123. package/docs/concepts/identity-model-proposal.md +371 -0
  124. package/docs/concepts/memory.md +5 -4
  125. package/docs/concepts/observer-protocol.md +361 -0
  126. package/docs/concepts/parallel-merge-protocol.md +71 -0
  127. package/docs/concepts/plans-and-claims.md +43 -0
  128. package/docs/concepts/skills.md +78 -0
  129. package/docs/concepts/workspace-bootstrapping.md +61 -0
  130. package/docs/integrations/agents.md +4 -4
  131. package/docs/integrations/cline.md +10 -11
  132. package/docs/integrations/codex.md +2 -2
  133. package/docs/integrations/continue.md +5 -5
  134. package/docs/integrations/copilot.md +14 -12
  135. package/docs/integrations/openclaw.md +7 -6
  136. package/docs/integrations/overview.md +7 -7
  137. package/docs/integrations/roo.md +3 -3
  138. package/docs/integrations/windsurf.md +6 -6
  139. package/docs/mcp-schema-changelog.md +51 -20
  140. package/docs/quickstart.md +48 -47
  141. package/docs/security.md +174 -15
  142. package/docs/storage.md +4 -2
  143. package/package.json +8 -6
@@ -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';
@@ -39,7 +40,7 @@ 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
42
  import { resolveEffectiveCwd, resolveProjectRef, resolveTargetStore } from '../core/store-resolution.js';
42
- import { probeForQuickSetup, buildQuickSetupProbeResponse, buildOnboardingPreview } from '../core/setup-flow.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
  },
@@ -166,6 +169,7 @@ export const MCP_READ_TOOLS = [
166
169
  since: { type: 'string', description: 'Filter items created after this ISO date.' },
167
170
  limit: { type: 'number', description: 'Maximum number of results to return (default 10).' },
168
171
  offset: { type: 'number', description: 'Number of results to skip (for pagination).' },
172
+ 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
173
  },
170
174
  required: ['query'],
171
175
  },
@@ -560,12 +564,13 @@ const MCP_WRITE_TOOLS = [
560
564
  },
561
565
  {
562
566
  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.',
567
+ 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
568
  annotations: { tier: 'standard', category: 'memory', headlessApproval: 'auto' },
565
569
  inputSchema: {
566
570
  type: 'object',
567
571
  properties: {
568
572
  text: { type: 'string', description: 'Free-form capture text.' },
573
+ 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
574
  context: { type: 'string', description: 'Optional file/path/scope context to associate with the capture.' },
570
575
  agent: { type: 'string', description: 'Agent name.' },
571
576
  agentId: { type: 'string', description: 'Registered agent id.' },
@@ -643,7 +648,7 @@ const MCP_WRITE_TOOLS = [
643
648
  reflectHandoff: { type: 'boolean', description: 'Materialize an open handoff from git commits since session start.' },
644
649
  dispatchReview: { type: 'boolean', description: 'When used with reflectHandoff, auto-dispatch a code review if the reflected handoff is reviewable.' },
645
650
  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].' },
651
+ 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
652
  },
648
653
  },
649
654
  },
@@ -701,6 +706,8 @@ const MCP_WRITE_TOOLS = [
701
706
  text: { type: 'string', description: 'Step description.' },
702
707
  title: { type: 'string', description: 'Alias for text.' },
703
708
  assignee: { type: 'string', description: 'Optional assignee.' },
709
+ estimated_effort: { type: 'number', description: 'Step-level estimate in minutes (pln#495). A duration string like "2h"/"30m" is also accepted and coerced.' },
710
+ actual_effort: { type: 'string', description: 'Step-level actual effort, free-form ("45m", "2h"), parsed when the estimation report runs.' },
704
711
  },
705
712
  },
706
713
  text: { type: 'string', description: 'Legacy top-level step description; prefer data.text.' },
@@ -740,6 +747,8 @@ const MCP_WRITE_TOOLS = [
740
747
  status: { type: 'string', description: 'New status: todo, in_progress, testing, done, blocked.' },
741
748
  text: { type: 'string', description: 'New step text.' },
742
749
  assignee: { type: 'string', description: 'New assignee (empty string to unassign).' },
750
+ estimated_effort: { type: 'number', description: 'Step-level estimate in minutes (pln#495); a duration string is also coerced.' },
751
+ actual_effort: { type: 'string', description: 'Step-level actual effort, free-form ("45m", "2h").' },
743
752
  agent: { type: 'string', description: 'Agent name.' },
744
753
  agentId: { type: 'string', description: 'Registered agent id.' },
745
754
  project: { type: 'string', description: 'Optional: name of a linked project to update the step in. Defaults to the current project.' },
@@ -952,6 +961,7 @@ const MCP_WRITE_TOOLS = [
952
961
  agent: { type: 'string', description: 'Agent name.' },
953
962
  agentId: { type: 'string', description: 'Registered agent id.' },
954
963
  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 },
964
+ 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
965
  },
956
966
  required: ['intent'],
957
967
  },
@@ -972,6 +982,7 @@ const MCP_WRITE_TOOLS = [
972
982
  autoExecute: { type: 'boolean', description: 'Attempt to spawn target agents after delivery (default: true). When false, returns command_ready_manual with bash commands for the supervisor to run.' },
973
983
  open_loop: { type: 'boolean', description: 'For intent=review only: also open a review Loop on top of the candidate (author + reviewer slots, advance to `findings`, dispatch turns). Default false — existing review callers are unaffected. See docs/concepts/loop-engine.md §Automation.' },
974
984
  review_mode: { type: 'string', enum: ['asymmetric', 'symmetric'], description: 'Optional review Loop mode when open_loop=true. `asymmetric` (default) keeps the classical author→reviewer handoff; `symmetric` lets each reviewer turn also apply fixes directly, halving round-trips for spec/doc reviews. Ignored when open_loop is false.' },
985
+ preflight: { type: 'boolean', description: 'pln#533: when open_loop=true, run a trivial validation spawn per reviewer agent BEFORE opening the loop so an environment death (config rejected, auth fail, model mismatch) surfaces instantly with a clear reason instead of a generic loop timeout. Reviewers that fail pre-flight are dropped (with a targeted warning); if all fail, loop creation is skipped. Default true; set false to skip (e.g. you already ran `brainclaw doctor --spawn-check`). Ignored when open_loop is false or BRAINCLAW_NO_SPAWN is set.' },
975
986
  agent: { type: 'string', description: 'Caller agent name.' },
976
987
  agentId: { type: 'string', description: 'Caller registered agent id.' },
977
988
  project: { type: 'string', description: 'Optional (pln#359 phase 1b): name of a linked project to dispatch into. When set, claim/assignment/message all land in the target project — the target agent picks the brief up async via its own bclaw_work. Auto-spawn is disabled in cross-project mode. Accepts cross_project_links and workspace store-chain children (see `brainclaw link list`).' },
@@ -997,8 +1008,12 @@ const MCP_WRITE_TOOLS = [
997
1008
  properties: {
998
1009
  intent: {
999
1010
  type: 'string',
1000
- enum: ['open', 'get', 'list', 'turn', 'complete_turn', 'advance', 'add_artifact', 'pause', 'resume', 'close'],
1001
- 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.).',
1011
+ // 'open' is intentionally NOT exposed standalone (pln#542): it
1012
+ // created a loop structure without dispatching the first turn, so
1013
+ // nothing ever ran. Loops are opened via
1014
+ // bclaw_coordinate(intent='review', open_loop=true) or intent='ideate'.
1015
+ enum: ['get', 'list', 'turn', 'complete_turn', 'advance', 'add_artifact', 'pause', 'resume', 'close'],
1016
+ 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.',
1002
1017
  },
1003
1018
  loop_id: { type: 'string', description: 'Target loop id (lop_…). Required for every intent except open and list.' },
1004
1019
  kind: { type: 'string', enum: ['review', 'ideation', 'implementation', 'research', 'debug'], description: 'Loop kind for open / list filter.' },
@@ -1121,6 +1136,7 @@ const MCP_WRITE_TOOLS = [
1121
1136
  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.' },
1122
1137
  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.' },
1123
1138
  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`.' },
1139
+ 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.' },
1124
1140
  },
1125
1141
  required: ['entity'],
1126
1142
  },
@@ -1135,6 +1151,7 @@ const MCP_WRITE_TOOLS = [
1135
1151
  entity: { type: 'string', description: 'Entity name.' },
1136
1152
  id: { type: 'string', description: 'Entity id (e.g. dec_ab12cd) or short_label (e.g. dec#42).' },
1137
1153
  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.' },
1154
+ budget_tokens: { type: 'number', description: 'Optional token budget (~4 chars/token). Bounds unbounded fields (e.g. handoff snapshot diffs).' },
1138
1155
  },
1139
1156
  required: ['entity', 'id'],
1140
1157
  },
@@ -1225,6 +1242,39 @@ export const MCP_TOOL_NAMES = ALL_TOOLS.map((tool) => tool.name);
1225
1242
  export const MCP_HEADLESS_AUTO_TOOL_NAMES = ALL_TOOLS
1226
1243
  .filter((tool) => tool.annotations?.headlessApproval === 'auto')
1227
1244
  .map((tool) => tool.name);
1245
+ /**
1246
+ * Narrow "canonical grammar" tool set — the read-side facade entries
1247
+ * (session + context) plus the five memory verbs (find / get / create /
1248
+ * update / transition). Consumed by writers (e.g. Hermes' tools.include)
1249
+ * that want a minimal advertised surface rather than the full headless-auto
1250
+ * catalog. Coordination facades (dispatch, coordinate, loop) are excluded
1251
+ * because narrow-surface agents shouldn't be routing work.
1252
+ *
1253
+ * Derivation rule (no hand-curated array):
1254
+ * - tier=facade AND category in {session, context} AND headlessApproval=auto
1255
+ * - OR name in the canonical memory verbs
1256
+ *
1257
+ * Adding a new memory grammar verb is the only edit that requires touching
1258
+ * this file; everything else propagates from ALL_TOOLS annotations (pln#546 step 2).
1259
+ */
1260
+ const _CANONICAL_GRAMMAR_MEMORY_VERBS = new Set([
1261
+ 'bclaw_find',
1262
+ 'bclaw_get',
1263
+ 'bclaw_create',
1264
+ 'bclaw_update',
1265
+ 'bclaw_transition',
1266
+ ]);
1267
+ export const MCP_CANONICAL_GRAMMAR_TOOL_NAMES = ALL_TOOLS
1268
+ .filter((tool) => {
1269
+ const ann = tool.annotations ?? {};
1270
+ if (ann.tier === 'facade'
1271
+ && (ann.category === 'session' || ann.category === 'context')
1272
+ && ann.headlessApproval === 'auto') {
1273
+ return true;
1274
+ }
1275
+ return _CANONICAL_GRAMMAR_MEMORY_VERBS.has(tool.name);
1276
+ })
1277
+ .map((tool) => tool.name);
1228
1278
  /**
1229
1279
  * Tools removed from the MCP surface at the v1.0 cut (Phase 3 slice 3i).
1230
1280
  * Handlers remain in place defensively, but these names are hidden from
@@ -1332,6 +1382,21 @@ export const DEFAULT_PUBLISHED_TOOLS = PUBLISHED_TOOLS
1332
1382
  return a.index - b.index;
1333
1383
  })
1334
1384
  .map(({ tool }) => tool);
1385
+ /**
1386
+ * Minimal catalog served while the project memory at cwd is absent.
1387
+ * Instead of refusing to boot (the historical exit(1)), the server starts
1388
+ * in "setup mode" so an agent landing on a fresh repo can initialize it
1389
+ * via bclaw_setup without a CLI shell-out + session-reload discontinuity.
1390
+ */
1391
+ export const UNINITIALIZED_TOOL_NAMES = new Set([
1392
+ 'bclaw_setup',
1393
+ 'bclaw_init_project',
1394
+ 'bclaw_doctor',
1395
+ ]);
1396
+ export const UNINITIALIZED_PUBLISHED_TOOLS = PUBLISHED_TOOLS.filter((tool) => UNINITIALIZED_TOOL_NAMES.has(tool.name));
1397
+ export function buildUninitializedStateMessage(cwd) {
1398
+ 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.`;
1399
+ }
1335
1400
  class McpProtocolError extends Error {
1336
1401
  code;
1337
1402
  id;
@@ -1524,10 +1589,108 @@ function getCancelledRequestId(params) {
1524
1589
  }
1525
1590
  return undefined;
1526
1591
  }
1592
+ let principalCache;
1593
+ /** Test hook — the principal is otherwise pinned for the process lifetime. */
1594
+ export function __resetConnectionPrincipalForTests() {
1595
+ principalCache = undefined;
1596
+ }
1597
+ /**
1598
+ * Resolve the connection principal. The MCP server is one process per
1599
+ * connection, so a process-level pin IS the per-connection pin; the cache key
1600
+ * guards the identity-bearing env vars so in-process test harnesses that
1601
+ * switch agents between calls re-resolve instead of leaking the first pin.
1602
+ */
1603
+ function resolveConnectionPrincipal(cwd, sessionId) {
1604
+ const env = process.env;
1605
+ const key = [
1606
+ env.BRAINCLAW_CLAIM_ID ?? '', env.BRAINCLAW_AGENT_ID ?? '',
1607
+ env.BRAINCLAW_AGENT_NAME ?? '', env.BRAINCLAW_AGENT ?? '',
1608
+ cwd ?? '', sessionId ?? '',
1609
+ ].join('|');
1610
+ if (principalCache && principalCache.key === key)
1611
+ return principalCache.value;
1612
+ let value;
1613
+ // 1. Assignment binding: a dispatched worker carries BRAINCLAW_CLAIM_ID; the
1614
+ // claim names the identity the coordinator dispatched — authoritative.
1615
+ const claimId = env.BRAINCLAW_CLAIM_ID?.trim();
1616
+ if (claimId) {
1617
+ try {
1618
+ const claim = loadClaim(claimId, cwd);
1619
+ const identity = (claim.agent_id ? findAgentIdentityById(claim.agent_id, cwd) : undefined)
1620
+ ?? findAgentIdentityByName(claim.agent, cwd);
1621
+ if (identity) {
1622
+ value = {
1623
+ agent_name: identity.agent_name,
1624
+ agent_id: identity.agent_id,
1625
+ session_id: claim.session_id ?? sessionId,
1626
+ pid: process.pid,
1627
+ source: 'claim_binding',
1628
+ };
1629
+ }
1630
+ }
1631
+ catch { /* claim may not exist in this store — fall through */ }
1632
+ }
1633
+ // 2. Server-side detection (env-pinned or detected REGISTERED identity —
1634
+ // read-only since pln#562 step 2, never mints).
1635
+ if (!value) {
1636
+ const identity = resolveCurrentAgentIdentity(cwd);
1637
+ if (identity) {
1638
+ value = {
1639
+ agent_name: identity.agent_name,
1640
+ agent_id: identity.agent_id,
1641
+ session_id: sessionId,
1642
+ pid: process.pid,
1643
+ source: 'server_detection',
1644
+ };
1645
+ }
1646
+ }
1647
+ principalCache = { key, value };
1648
+ return value;
1649
+ }
1527
1650
  function resolveMutationIdentity(args, fields, cwd, sessionId) {
1528
1651
  try {
1652
+ const explicitName = typeof args[fields.nameField] === 'string' ? String(args[fields.nameField]) : undefined;
1653
+ const explicitId = typeof args[fields.idField] === 'string' ? String(args[fields.idField]) : undefined;
1654
+ // pln#562 step 3 — pinned connection principal. When the server resolved
1655
+ // an authenticated principal, caller args are verified against it:
1656
+ // matching/absent args → principal; mismatching args → curator-only
1657
+ // explicit override, otherwise the mismatch is REJECTED loudly. Silently
1658
+ // re-attributing a spoofed/mistaken identity to the principal would hide
1659
+ // caller bugs — fail-loud is the contract (mcp-protocol.test 'rejects
1660
+ // unregistered identities and mismatched id/name pairs').
1661
+ const principal = resolveConnectionPrincipal(cwd, sessionId);
1662
+ if (principal) {
1663
+ // Re-load per call (cheap) so trust changes propagate mid-connection;
1664
+ // the BINDING (who you are) stays pinned.
1665
+ const principalDoc = findAgentIdentityById(principal.agent_id, cwd);
1666
+ if (principalDoc) {
1667
+ const mismatch = (explicitName !== undefined && normalizeAgentName(explicitName) !== normalizeAgentName(principal.agent_name))
1668
+ || (explicitId !== undefined && explicitId !== principal.agent_id);
1669
+ if (!mismatch) {
1670
+ return { identity: principalDoc };
1671
+ }
1672
+ if ((principalDoc.trust_level ?? 'contributor') === 'curator') {
1673
+ return {
1674
+ identity: requireRegisteredAgentIdentity({
1675
+ agentName: explicitName,
1676
+ agentId: explicitId,
1677
+ cwd,
1678
+ allowCurrent: true,
1679
+ allowEnv: true,
1680
+ }),
1681
+ };
1682
+ }
1683
+ return {
1684
+ error: {
1685
+ kind: 'identity_error',
1686
+ 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.`,
1687
+ },
1688
+ };
1689
+ }
1690
+ }
1691
+ // No pinned principal (unregistered connection): legacy chain.
1529
1692
  // Session-pinned identity: if no explicit agent in args, use the session's pinned agent
1530
- let agentName = typeof args[fields.nameField] === 'string' ? String(args[fields.nameField]) : undefined;
1693
+ let agentName = explicitName;
1531
1694
  if (!agentName && sessionId) {
1532
1695
  const session = loadSessionById(sessionId, cwd);
1533
1696
  if (session?.agent) {
@@ -1537,7 +1700,7 @@ function resolveMutationIdentity(args, fields, cwd, sessionId) {
1537
1700
  return {
1538
1701
  identity: requireRegisteredAgentIdentity({
1539
1702
  agentName,
1540
- agentId: typeof args[fields.idField] === 'string' ? String(args[fields.idField]) : undefined,
1703
+ agentId: explicitId,
1541
1704
  cwd,
1542
1705
  allowCurrent: true,
1543
1706
  allowEnv: true,
@@ -1591,12 +1754,15 @@ function ensureTrust(args, fields, level, cwd, sessionId) {
1591
1754
  }
1592
1755
  /**
1593
1756
  * Resolve the agent identity for canonical-grammar mutation verbs
1594
- * (bclaw_create/update/remove/transition). Returns a best-effort identity so
1595
- * that handlers can auto-fill required fields (e.g. plan.author) instead of
1596
- * letting the create land on disk with a missing field which would then be
1597
- * silently GC'd by the state sync loop (see fix plan pln_5f44426c).
1757
+ * (bclaw_create/update/remove/transition), so handlers can auto-fill required
1758
+ * fields (e.g. plan.author) instead of letting the create land on disk with a
1759
+ * missing field which would then be silently GC'd by the state sync loop
1760
+ * (see fix plan pln_5f44426c).
1598
1761
  *
1599
- * Falls back to args.agent if resolution fails, and finally to 'unknown'.
1762
+ * pln#562 step 3 resolution failure is a HARD error. The old fallback to
1763
+ * author:'unknown' produced records that passed creation but were schema-
1764
+ * invalid on read and silently GC'd: a write that lies about succeeding.
1765
+ * Callers map the throw to a validation_error tool response.
1600
1766
  */
1601
1767
  function resolveCanonicalAuthor(args, cwd, connectionSessionId) {
1602
1768
  const resolved = resolveMutationIdentity(args, { nameField: 'agent', idField: 'agentId' }, cwd, connectionSessionId);
@@ -1606,8 +1772,9 @@ function resolveCanonicalAuthor(args, cwd, connectionSessionId) {
1606
1772
  agent_id: resolved.identity.agent_id,
1607
1773
  };
1608
1774
  }
1609
- const explicit = typeof args.agent === 'string' ? args.agent : undefined;
1610
- return { agent_name: explicit ?? 'unknown' };
1775
+ const detail = 'error' in resolved && resolved.error ? resolved.error.message : 'no registered agent identity resolved';
1776
+ throw new Error(`cannot resolve mutation author: ${detail} `
1777
+ + 'Start a session (bclaw_session_start) or pass a registered agent before writing.');
1611
1778
  }
1612
1779
  function explicitSessionIdFromEnv() {
1613
1780
  return process.env.BRAINCLAW_SESSION_ID?.trim()
@@ -1649,11 +1816,17 @@ export function parseMcpLine(line) {
1649
1816
  isNotification: !('id' in parsed),
1650
1817
  };
1651
1818
  }
1652
- export function createInitializeResult(protocolVersion) {
1819
+ export function createInitializeResult(protocolVersion, options) {
1820
+ const uninitialized = options?.uninitialized === true;
1653
1821
  return {
1654
1822
  protocolVersion,
1655
1823
  serverInfo: { name: 'brainclaw', version: SCHEMA_VERSION },
1656
- capabilities: { tools: { listChanged: false } },
1824
+ // listChanged is only advertised in setup mode, where the catalog flips
1825
+ // to the full set once the project memory is initialized.
1826
+ capabilities: { tools: { listChanged: uninitialized } },
1827
+ ...(uninitialized
1828
+ ? { instructions: buildUninitializedStateMessage(options?.cwd ?? process.cwd()) }
1829
+ : {}),
1657
1830
  };
1658
1831
  }
1659
1832
  export class McpTaskRunner {
@@ -1776,6 +1949,8 @@ export class McpServerConnection {
1776
1949
  state = 'pre_init';
1777
1950
  protocolVersion;
1778
1951
  connectionSessionId;
1952
+ /** True while the project memory at cwd is absent — serves the minimal setup catalog. */
1953
+ uninitializedMode;
1779
1954
  /** Version of brainclaw code loaded in this process at boot time. */
1780
1955
  bootVersion;
1781
1956
  /** Throttle disk version checks — at most once per 60s. */
@@ -1786,11 +1961,15 @@ export class McpServerConnection {
1786
1961
  constructor(options) {
1787
1962
  this.cwd = options.cwd;
1788
1963
  this.send = options.send;
1964
+ this.uninitializedMode = options.uninitialized ?? false;
1789
1965
  this.bootVersion = getInstalledBrainclawVersion();
1790
1966
  this.taskRunner = new McpTaskRunner({
1791
1967
  executeTool: options.executeTool ?? createWorkerToolExecutor(),
1792
1968
  onResult: (requestId, outcome) => {
1793
- this.connectionSessionId = outcome.nextConnectionSessionId;
1969
+ if (outcome.nextConnectionSessionId !== undefined) {
1970
+ // null = explicit clear (session ended); string = refresh; undefined = no-op
1971
+ this.connectionSessionId = outcome.nextConnectionSessionId ?? undefined;
1972
+ }
1794
1973
  // Inject version mismatch advisory if stale
1795
1974
  const advisory = this.checkVersionMismatch();
1796
1975
  if (advisory && outcome.response.content.length > 0) {
@@ -1799,17 +1978,43 @@ export class McpServerConnection {
1799
1978
  ...outcome.response.content,
1800
1979
  ];
1801
1980
  }
1981
+ const catalogUnlocked = this.reconcileUninitializedMode();
1982
+ if (catalogUnlocked && outcome.response.content.length > 0) {
1983
+ outcome.response.content = [
1984
+ ...outcome.response.content,
1985
+ { 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.' },
1986
+ ];
1987
+ }
1802
1988
  // Track usage: append response size to usage.jsonl
1803
1989
  if (outcome.toolName) {
1804
1990
  this.trackUsage(outcome.toolName, outcome.response);
1805
1991
  }
1806
1992
  this.sendResult(requestId, outcome.response);
1993
+ if (catalogUnlocked) {
1994
+ this.send({ jsonrpc: '2.0', method: 'notifications/tools/list_changed' });
1995
+ }
1807
1996
  },
1808
1997
  onInternalError: (requestId, error) => {
1809
1998
  this.sendError(requestId, -32603, error instanceof Error ? error.message : 'Internal error');
1810
1999
  },
1811
2000
  });
1812
2001
  }
2002
+ /**
2003
+ * Lazy reconcile: if the server booted in setup mode but the project
2004
+ * memory now exists (initialized via bclaw_setup, bclaw_init_project,
2005
+ * or an out-of-band CLI init), unlock the full catalog.
2006
+ * Returns true exactly once — on the transition.
2007
+ */
2008
+ reconcileUninitializedMode() {
2009
+ if (!this.uninitializedMode) {
2010
+ return false;
2011
+ }
2012
+ if (!memoryExists(this.cwd)) {
2013
+ return false;
2014
+ }
2015
+ this.uninitializedMode = false;
2016
+ return true;
2017
+ }
1813
2018
  /**
1814
2019
  * Compare the version loaded in memory with the version on disk.
1815
2020
  * Returns an advisory string if they differ, undefined otherwise.
@@ -1894,7 +2099,11 @@ export class McpServerConnection {
1894
2099
  const protocolVersion = resolveRequestedProtocolVersion(params, id ?? null);
1895
2100
  this.protocolVersion = protocolVersion;
1896
2101
  this.state = 'awaiting_initialized';
1897
- this.sendResult(id ?? null, createInitializeResult(protocolVersion));
2102
+ this.reconcileUninitializedMode();
2103
+ this.sendResult(id ?? null, createInitializeResult(protocolVersion, {
2104
+ uninitialized: this.uninitializedMode,
2105
+ cwd: this.cwd,
2106
+ }));
1898
2107
  return;
1899
2108
  }
1900
2109
  if (method === 'notifications/initialized' || method === 'initialized') {
@@ -1917,6 +2126,15 @@ export class McpServerConnection {
1917
2126
  }
1918
2127
  if (method === 'tools/list') {
1919
2128
  if (!isNotification) {
2129
+ this.reconcileUninitializedMode();
2130
+ if (this.uninitializedMode) {
2131
+ this.sendResult(id ?? null, {
2132
+ tools: UNINITIALIZED_PUBLISHED_TOOLS,
2133
+ uninitialized: true,
2134
+ state: buildUninitializedStateMessage(this.cwd),
2135
+ });
2136
+ return;
2137
+ }
1920
2138
  const params = message.params === undefined ? {} : requireObjectParams(message.params, id ?? null);
1921
2139
  const catalog = typeof params.catalog === 'string' ? params.catalog : undefined;
1922
2140
  const include = typeof params.include === 'string' ? params.include : undefined;
@@ -1946,6 +2164,19 @@ export class McpServerConnection {
1946
2164
  return;
1947
2165
  }
1948
2166
  const args = params.arguments === undefined ? {} : requireObjectParams(params.arguments, id ?? null);
2167
+ this.reconcileUninitializedMode();
2168
+ if (this.uninitializedMode && !UNINITIALIZED_TOOL_NAMES.has(name)) {
2169
+ this.sendResult(id ?? null, toolResponse({
2170
+ content: [{ type: 'text', text: buildUninitializedStateMessage(this.cwd) }],
2171
+ structuredContent: {
2172
+ error: 'uninitialized',
2173
+ cwd: this.cwd,
2174
+ available_tools: [...UNINITIALIZED_TOOL_NAMES],
2175
+ next_action: 'Call bclaw_setup to initialize this repo.',
2176
+ },
2177
+ }, true));
2178
+ return;
2179
+ }
1949
2180
  this.taskRunner.enqueue(id ?? null, {
1950
2181
  name,
1951
2182
  args,
@@ -2081,12 +2312,15 @@ export class StdioTransport {
2081
2312
  }
2082
2313
  drainNewline() {
2083
2314
  while (true) {
2084
- const str = this.buffer.toString('utf-8');
2085
- const newlineIndex = str.indexOf('\n');
2315
+ // Search for '\n' (0x0a) at the byte level so a multibyte UTF-8 sequence
2316
+ // that spans two chunks is never split mid-character. Avoids O(n²)
2317
+ // string→buffer reconversion on every iteration.
2318
+ const newlineIndex = this.buffer.indexOf(0x0a);
2086
2319
  if (newlineIndex === -1)
2087
2320
  return;
2088
- const line = str.slice(0, newlineIndex).replace(/\r$/, '');
2089
- this.buffer = Buffer.from(str.slice(newlineIndex + 1), 'utf-8');
2321
+ const lineBuffer = this.buffer.subarray(0, newlineIndex);
2322
+ this.buffer = this.buffer.subarray(newlineIndex + 1);
2323
+ const line = lineBuffer.toString('utf-8').replace(/\r$/, '');
2090
2324
  if (line.trim()) {
2091
2325
  this.onMessage(line);
2092
2326
  }
@@ -2095,9 +2329,12 @@ export class StdioTransport {
2095
2329
  }
2096
2330
  export function runMcp() {
2097
2331
  const cwd = resolveEffectiveCwd();
2098
- if (!memoryExists(cwd)) {
2099
- console.error('Project memory not initialized. Run `brainclaw init` first.');
2100
- process.exit(1);
2332
+ // No project memory yet: start in setup mode instead of refusing to boot,
2333
+ // so agents can initialize the repo via bclaw_setup without a CLI
2334
+ // shell-out + session reload.
2335
+ const uninitialized = !memoryExists(cwd);
2336
+ if (uninitialized) {
2337
+ console.error(buildUninitializedStateMessage(cwd));
2101
2338
  }
2102
2339
  const missingWorkerPath = resolveMcpWorkerEntryPath();
2103
2340
  if (!fs.existsSync(missingWorkerPath)) {
@@ -2119,6 +2356,7 @@ export function runMcp() {
2119
2356
  cwd,
2120
2357
  send: adaptiveSend,
2121
2358
  executeTool: createWorkerToolExecutor(),
2359
+ uninitialized,
2122
2360
  });
2123
2361
  transport.onMessage = (line) => connection.handleLine(line);
2124
2362
  transport.start();
@@ -2134,6 +2372,9 @@ function createWorkerToolExecutor() {
2134
2372
  try {
2135
2373
  worker = new Worker(new URL('./mcp-worker.js', import.meta.url), {
2136
2374
  workerData: payload,
2375
+ // Capture worker stdout so console.log in tool handlers cannot corrupt
2376
+ // the parent's JSON-RPC stream. Drain captured output to stderr.
2377
+ stdout: true,
2137
2378
  });
2138
2379
  }
2139
2380
  catch (error) {
@@ -2144,6 +2385,8 @@ function createWorkerToolExecutor() {
2144
2385
  reject(error);
2145
2386
  return;
2146
2387
  }
2388
+ // Redirect any worker stdout to the server's stderr (safe for diagnostics)
2389
+ worker.stdout?.pipe(process.stderr);
2147
2390
  let settled = false;
2148
2391
  const cleanup = () => {
2149
2392
  worker.removeAllListeners('message');
@@ -2294,7 +2537,10 @@ function getCrossProjectArg(args, ...keys) {
2294
2537
  return undefined;
2295
2538
  }
2296
2539
  function blockCrossProjectExecution(entity, args) {
2297
- const targetProject = getCrossProjectArg(args, 'targetProject', 'target_project', 'crossProject', 'cross_project');
2540
+ // Include 'project' so that canonical write verbs that accept a project routing
2541
+ // arg (e.g. bclaw_claim project=...) are subject to the same signaling-only
2542
+ // boundary as the explicit cross-project keys.
2543
+ const targetProject = getCrossProjectArg(args, 'targetProject', 'target_project', 'crossProject', 'cross_project', 'project');
2298
2544
  if (!targetProject) {
2299
2545
  return undefined;
2300
2546
  }
@@ -2384,20 +2630,24 @@ async function _executeMcpToolCallInner(payload) {
2384
2630
  if (detected) {
2385
2631
  summary.push(`✔ Agent detected: ${detected.name}`);
2386
2632
  }
2387
- summary.push('✔ Reload your agent session to activate brainclaw MCP tools.');
2388
- // Check if bootstrap is available and generate preview
2633
+ summary.push('✔ Full brainclaw MCP catalog activates automatically; reload your agent session only if new tools do not appear.');
2634
+ // Bootstrap route follows the shared empty-memory rule; the preview
2635
+ // already embeds the same recommendation text when memory is empty.
2389
2636
  const probe = probeForQuickSetup(cwd);
2390
2637
  const bootstrapAvailable = probe.hasContent;
2638
+ const emptyMemoryRec = resolveEmptyMemoryRecommendation(cwd);
2391
2639
  const preview = buildOnboardingPreview(cwd);
2392
2640
  return {
2393
2641
  response: toolResponse({
2394
- 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 }],
2642
+ content: [{ type: 'text', text: summary.join('\n') + '\n\n' + preview }],
2395
2643
  structuredContent: {
2396
2644
  setup_complete: true,
2397
2645
  project_type: projectType,
2398
2646
  topology,
2399
2647
  detected_agent: detected?.name ?? null,
2400
2648
  bootstrap_available: bootstrapAvailable,
2649
+ bootstrap_route: emptyMemoryRec.route,
2650
+ next_action: emptyMemoryRec.mcp_next_action,
2401
2651
  preview,
2402
2652
  summary,
2403
2653
  },
@@ -2578,7 +2828,7 @@ async function _executeMcpToolCallInner(payload) {
2578
2828
  });
2579
2829
  const signal = writeCrossProjectSignal(resolveCrossProjectWritableTarget(crossProjectTarget, 'runtime_note', cwd), 'runtime_note', {
2580
2830
  schema_version: 2,
2581
- id: generateId('rtn'),
2831
+ id: generateId('runtime_note'),
2582
2832
  agent: opIdentity.agent,
2583
2833
  agent_id: opIdentity.agent_id,
2584
2834
  project_id: opIdentity.project_id ?? '',
@@ -2648,7 +2898,20 @@ async function _executeMcpToolCallInner(payload) {
2648
2898
  }
2649
2899
  }
2650
2900
  const identity = resolved.identity;
2651
- const classification = classifyQuickCapture(text);
2901
+ // Caller-asserted classification wins (pln#542): the calling agent
2902
+ // declares decision/trap/constraint/note; keyword heuristics are the
2903
+ // fallback when no type is given.
2904
+ const assertedType = typeof args.type === 'string' ? args.type.trim().toLowerCase() : undefined;
2905
+ let classification;
2906
+ if (assertedType === 'decision' || assertedType === 'trap' || assertedType === 'constraint' || assertedType === 'note') {
2907
+ classification = { target: assertedType, reason: 'caller_asserted', decisionScore: 0, trapScore: 0 };
2908
+ }
2909
+ else if (assertedType !== undefined) {
2910
+ return { response: createToolErrorResponse('validation_error', `type must be one of: decision, trap, constraint, note (got '${assertedType}')`) };
2911
+ }
2912
+ else {
2913
+ classification = classifyQuickCapture(text);
2914
+ }
2652
2915
  if (classification.target === 'note') {
2653
2916
  const result = createRuntimeNote(formatQuickCaptureNoteText(text, context), {
2654
2917
  agent: identity.agent_name,
@@ -2670,6 +2933,9 @@ async function _executeMcpToolCallInner(payload) {
2670
2933
  note_id: result.noteId,
2671
2934
  session_id: result.sessionId,
2672
2935
  context,
2936
+ next_actions: [
2937
+ { 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' },
2938
+ ],
2673
2939
  },
2674
2940
  }),
2675
2941
  nextConnectionSessionId: explicitSessionIdFromEnv() ? undefined : result.sessionId,
@@ -2687,6 +2953,13 @@ async function _executeMcpToolCallInner(payload) {
2687
2953
  const statusText = capture.writeThrough
2688
2954
  ? `✔ Quick capture promoted as ${classification.target} [${capture.promotedItemId}]`
2689
2955
  : `✔ Quick capture saved as ${classification.target} candidate [${capture.candidateId}]`;
2956
+ const captureNextActions = capture.writeThrough
2957
+ ? [
2958
+ { tool: 'bclaw_get', args: { entity: classification.target, id: capture.promotedItemId }, when: 'to verify the promoted item' },
2959
+ ]
2960
+ : [
2961
+ { tool: 'bclaw_get', args: { entity: 'candidate', id: capture.candidateId }, when: 'to review the pending candidate (contradiction metadata is advisory)' },
2962
+ ];
2690
2963
  return {
2691
2964
  response: toolResponse({
2692
2965
  content: [{ type: 'text', text: statusText }],
@@ -2698,7 +2971,8 @@ async function _executeMcpToolCallInner(payload) {
2698
2971
  candidate_id: capture.candidateId,
2699
2972
  promoted_item_id: capture.promotedItemId,
2700
2973
  write_through: capture.writeThrough,
2701
- promotion_blocked_reason: capture.promotionBlockedReason,
2974
+ // Advisory only (cnd_abe61d68): contradictions are metadata on
2975
+ // the candidate, never a promotion blocker.
2702
2976
  contradiction_summary: capture.contradictionSummary,
2703
2977
  contradictions_detected: capture.contradictionsDetected?.map((item) => ({
2704
2978
  severity: item.severity,
@@ -2706,9 +2980,9 @@ async function _executeMcpToolCallInner(payload) {
2706
2980
  conflicts_with: item.conflicts_with,
2707
2981
  })),
2708
2982
  context,
2983
+ next_actions: captureNextActions,
2709
2984
  },
2710
2985
  }),
2711
- nextConnectionSessionId: undefined,
2712
2986
  };
2713
2987
  }
2714
2988
  if (name === 'bclaw_create_plan') {
@@ -3037,10 +3311,29 @@ async function _executeMcpToolCallInner(payload) {
3037
3311
  catch {
3038
3312
  return { response: createToolErrorResponse('not_found', `Claim not found: ${claimId}`) };
3039
3313
  }
3040
- const cascadeResult = releaseClaimWithCascade(claimId, {
3041
- planStatus: args.planStatus,
3042
- cwd,
3043
- });
3314
+ // pln#562 step 5 — release is ownership-checked like acquisition and
3315
+ // adoption. The resolved principal must own the claim; trusted+ callers
3316
+ // (coordinators) may release others' claims, with an audit entry.
3317
+ const releaseIdentity = resolveMutationIdentity(args, { nameField: 'agent', idField: 'agentId' }, cwd, connectionSessionId);
3318
+ const releaseAuth = 'identity' in releaseIdentity && releaseIdentity.identity
3319
+ ? {
3320
+ agent: releaseIdentity.identity.agent_name,
3321
+ agent_id: releaseIdentity.identity.agent_id,
3322
+ session_id: connectionSessionId,
3323
+ override: hasMinimumTrustLevel(releaseIdentity.identity.trust_level ?? 'contributor', 'trusted'),
3324
+ }
3325
+ : undefined;
3326
+ let cascadeResult;
3327
+ try {
3328
+ cascadeResult = releaseClaimWithCascade(claimId, {
3329
+ planStatus: args.planStatus,
3330
+ cwd,
3331
+ auth: releaseAuth,
3332
+ });
3333
+ }
3334
+ catch (err) {
3335
+ return { response: createToolErrorResponse('trust_error', err instanceof Error ? err.message : String(err)) };
3336
+ }
3044
3337
  const { planTransitioned, planWarning, planId: cascadePlanId, newPlanStatus: cascadeNewStatus } = cascadeResult;
3045
3338
  const summaryText = [
3046
3339
  `✔ Released claim [${claimId}]`,
@@ -3255,7 +3548,7 @@ async function _executeMcpToolCallInner(payload) {
3255
3548
  ...(result.handoff ? { handoff: result.handoff } : {}),
3256
3549
  ...(result.reflection_prompt ? { reflection_prompt: result.reflection_prompt } : {}),
3257
3550
  }),
3258
- nextConnectionSessionId: undefined,
3551
+ nextConnectionSessionId: null,
3259
3552
  };
3260
3553
  }
3261
3554
  if (name === 'bclaw_compact') {
@@ -3389,20 +3682,41 @@ async function _executeMcpToolCallInner(payload) {
3389
3682
  }
3390
3683
  lines.push(` Sequence: ${analysis.sequence.name}`);
3391
3684
  lines.push(` Ready: ${analysis.ready.length} | Active: ${analysis.active.length} | Blocked: ${analysis.blocked.length} | Done: ${analysis.done.length}`);
3685
+ // can_681a6c52 — truthful per-delivery status. The old output printed
3686
+ // '[inbox]' for every entry and ALWAYS dumped a 'Run these commands'
3687
+ // block, even when the auto-spawn had already SUCCEEDED — an obedient
3688
+ // coordinator would then double-spawn the worker.
3689
+ const spawnedPlanIds = new Set(dispatchResult.messages_sent
3690
+ .filter((m) => m.execution_status === 'delivered_and_started')
3691
+ .map((m) => m.plan_id));
3392
3692
  if (dispatchResult.messages_sent.length > 0) {
3393
3693
  lines.push('');
3394
3694
  lines.push(args.dryRun ? ' Would assign:' : ' Assigned:');
3395
3695
  for (const msg of dispatchResult.messages_sent) {
3396
3696
  const lane = msg.lane ? ` (lane: ${msg.lane})` : '';
3397
- lines.push(` ${msg.agent}: ${msg.plan_id}${lane} [inbox]`);
3697
+ const exec = msg.execution_status ? ` [${msg.execution_status}]` : ' [inbox]';
3698
+ const pid = msg.pid ? ` pid=${msg.pid}` : '';
3699
+ const run = msg.run_id ? ` run=${msg.run_id}` : '';
3700
+ lines.push(` ${msg.agent}: ${msg.plan_id}${lane}${exec}${pid}${run}`);
3701
+ }
3702
+ }
3703
+ const spawned = dispatchResult.messages_sent.filter((m) => m.execution_status === 'delivered_and_started');
3704
+ if (spawned.length > 0) {
3705
+ lines.push('');
3706
+ lines.push('Auto-spawn succeeded — do NOT run the launch commands for these (double-spawn risk). Verify instead:');
3707
+ for (const msg of spawned) {
3708
+ const target = msg.assignment_id ?? msg.run_id ?? msg.claim_id;
3709
+ lines.push(` bclaw_dispatch_status(target_id: "${target}") # ${msg.agent} on ${msg.plan_id}`);
3398
3710
  }
3399
3711
  }
3400
- // Surface bash commands prominently this is what the coordinator should run
3401
- if (dispatchResult.commands.length > 0) {
3712
+ // Only surface manual launch commands for deliveries that were NOT
3713
+ // auto-spawned (manual fallback, spawn refusal, inbox-only).
3714
+ const manualCommands = dispatchResult.commands.filter((cmd) => !cmd.plan_id || !spawnedPlanIds.has(cmd.plan_id));
3715
+ if (manualCommands.length > 0) {
3402
3716
  lines.push('');
3403
- lines.push('Run these commands to launch the assigned agents:');
3717
+ lines.push('Run these commands to launch the agents that were NOT auto-spawned:');
3404
3718
  lines.push('');
3405
- for (const cmd of dispatchResult.commands) {
3719
+ for (const cmd of manualCommands) {
3406
3720
  const lane = cmd.lane ? ` [lane: ${cmd.lane}]` : '';
3407
3721
  lines.push(`# ${cmd.agent}${lane}`);
3408
3722
  lines.push(cmd.command);
@@ -3416,6 +3730,15 @@ async function _executeMcpToolCallInner(payload) {
3416
3730
  lines.push(` - ${skip.plan_id}: ${skip.reason}`);
3417
3731
  }
3418
3732
  }
3733
+ // can_45316d5c — worktree warnings / spawn refusals were hidden behind
3734
+ // dispatch_status; surface them in the cycle output itself.
3735
+ if (dispatchResult.warnings.length > 0) {
3736
+ lines.push('');
3737
+ lines.push(' Warnings:');
3738
+ for (const warning of dispatchResult.warnings) {
3739
+ lines.push(` ⚠ ${warning}`);
3740
+ }
3741
+ }
3419
3742
  appendAuditEntry({
3420
3743
  actor: resolved.identity.agent_name,
3421
3744
  actor_id: resolved.identity.agent_id,
@@ -3498,7 +3821,9 @@ async function _executeMcpToolCallInner(payload) {
3498
3821
  if (status === 'accepted' && assignment.message_id) {
3499
3822
  try {
3500
3823
  const { ackMessage } = await import('../core/messaging.js');
3501
- ackMessage(assignment.message_id, callerAgent, cwd);
3824
+ // pln#562 step 4 — scope the ack to this assignment's claim so a
3825
+ // same-named sibling instance cannot consume the message.
3826
+ ackMessage(assignment.message_id, callerAgent, cwd, { claimId: assignment.claim_id });
3502
3827
  }
3503
3828
  catch { /* best-effort: don't fail the update if ack fails */ }
3504
3829
  }
@@ -3665,7 +3990,11 @@ async function _executeMcpToolCallInner(payload) {
3665
3990
  return { response: createToolErrorResponse('validation_error', 'Missing required argument: id') };
3666
3991
  }
3667
3992
  try {
3668
- const result = ackMessage(msgId, resolved.identity.agent_name, cwd);
3993
+ // pln#562 step 4 — a dispatched instance (BRAINCLAW_CLAIM_ID) may only
3994
+ // ack messages bound to its own claim.
3995
+ const result = ackMessage(msgId, resolved.identity.agent_name, cwd, {
3996
+ claimId: process.env.BRAINCLAW_CLAIM_ID?.trim() || undefined,
3997
+ });
3669
3998
  appendAuditEntry({ actor: resolved.identity.agent_name, actor_id: resolved.identity.agent_id, action: 'update', item_id: result.id, item_type: 'message' }, cwd);
3670
3999
  return {
3671
4000
  response: toolResponse({
@@ -3767,13 +4096,15 @@ async function _executeMcpToolCallInner(payload) {
3767
4096
  const stepTextRaw = stepData.text ?? stepData.title ?? args.text;
3768
4097
  const stepText = typeof stepTextRaw === 'string' ? stepTextRaw.trim() : '';
3769
4098
  const stepAssignee = (stepData.assignee ?? args.assignee);
4099
+ const stepEstimated = (stepData.estimated_effort ?? args.estimated_effort);
4100
+ const stepActual = (stepData.actual_effort ?? args.actual_effort);
3770
4101
  if (!stepPlanId)
3771
4102
  return { response: createToolErrorResponse('validation_error', 'Missing required argument: planId') };
3772
4103
  if (!stepText)
3773
4104
  return { response: createToolErrorResponse('validation_error', 'Missing required argument: data.text') };
3774
4105
  const stepTargetCwd = resolveProjectCwd(args.project, cwd);
3775
4106
  try {
3776
- const result = addStepOp({ planId: stepPlanId, text: stepText, assignee: stepAssignee }, stepTargetCwd);
4107
+ const result = addStepOp({ planId: stepPlanId, text: stepText, assignee: stepAssignee, estimatedEffort: stepEstimated, actualEffort: stepActual }, stepTargetCwd);
3777
4108
  return {
3778
4109
  response: toolResponse({
3779
4110
  content: [{ type: 'text', text: `✔ Step added: [${result.stepId}] ${stepText} (${result.doneSteps}/${result.totalSteps} done)` }],
@@ -3854,6 +4185,8 @@ async function _executeMcpToolCallInner(payload) {
3854
4185
  status: args.status,
3855
4186
  text: args.text,
3856
4187
  assignee: args.assignee,
4188
+ estimatedEffort: args.estimated_effort,
4189
+ actualEffort: args.actual_effort,
3857
4190
  }, usTargetCwd);
3858
4191
  const changes = [];
3859
4192
  if (args.status)
@@ -3862,6 +4195,10 @@ async function _executeMcpToolCallInner(payload) {
3862
4195
  changes.push('text updated');
3863
4196
  if (args.assignee !== undefined)
3864
4197
  changes.push(`assignee=${args.assignee || 'unassigned'}`);
4198
+ if (args.estimated_effort !== undefined)
4199
+ changes.push(`estimate=${args.estimated_effort}`);
4200
+ if (args.actual_effort !== undefined)
4201
+ changes.push(`actual=${args.actual_effort}`);
3865
4202
  return {
3866
4203
  response: toolResponse({
3867
4204
  content: [{ type: 'text', text: `✔ Step updated: [${result.stepId}] ${changes.join(', ')} (${result.doneSteps}/${result.totalSteps} done)` }],
@@ -4289,11 +4626,11 @@ async function _executeMcpToolCallInner(payload) {
4289
4626
  if (sessionResult.auto_registered) {
4290
4627
  warnings.push(`Agent '${sessionResult.agent}' was auto-registered (first use). Run \`brainclaw register-agent ${sessionResult.agent}\` to set capabilities and trust level.`);
4291
4628
  }
4292
- // Step 2: build context for requested scope. When intent='resume',
4293
- // auto-surface the memory delta since the previous session for the
4294
- // same agent matches session-start.ts:85 pattern. Phase 4
4295
- // Sprint 1 Lane A step 5 (pln#390). Without this the resume intent
4296
- // was functionally identical to consult, defeating its purpose.
4629
+ // Step 2: build context for requested scope. The "what's new" diff is
4630
+ // surfaced for ALL intents (pln#390 regression fix): intent='resume'
4631
+ // anchors it on the agent's previous session; every other intent gets
4632
+ // the per-agent event-log-cursor diff computed inside buildContext
4633
+ // (pln#542 converged novelty mechanism, covers status transitions).
4297
4634
  let contextResult;
4298
4635
  try {
4299
4636
  let sinceSession;
@@ -4307,6 +4644,8 @@ async function _executeMcpToolCallInner(payload) {
4307
4644
  agent: sessionResult.agent,
4308
4645
  cwd: targetCwd,
4309
4646
  sinceSession,
4647
+ // ~4 chars/token: relevance-ranked fill up to the caller's budget.
4648
+ maxChars: workReq.budget_tokens ? workReq.budget_tokens * 4 : undefined,
4310
4649
  });
4311
4650
  }
4312
4651
  catch { /* non-fatal — context failure should not block work */ }
@@ -4367,11 +4706,30 @@ async function _executeMcpToolCallInner(payload) {
4367
4706
  text: w.text.slice(0, 80),
4368
4707
  age_days: w.age_days,
4369
4708
  }));
4709
+ // pln#390 regression fix: the compact projection used to silently
4710
+ // drop context_diff, defeating the diff-first contract. Keep a
4711
+ // trimmed view (summary + counts + top-5 items, text capped).
4712
+ const trimmedDiff = contextResult.context_diff
4713
+ ? {
4714
+ since: contextResult.context_diff.since,
4715
+ since_session: contextResult.context_diff.since_session,
4716
+ source: contextResult.context_diff.source,
4717
+ summary: contextResult.context_diff.summary,
4718
+ counts: contextResult.context_diff.counts,
4719
+ changed_items: (contextResult.context_diff.changed_items ?? [])
4720
+ .slice(0, 5)
4721
+ .map((item) => ({ ...item, text: item.text.slice(0, 120) })),
4722
+ ...(contextResult.context_diff.unseen_event_count !== undefined
4723
+ ? { unseen_event_count: contextResult.context_diff.unseen_event_count }
4724
+ : {}),
4725
+ }
4726
+ : undefined;
4370
4727
  resultPayload = {
4371
4728
  context_schema: contextResult.context_schema,
4372
4729
  profile: contextResult.profile,
4373
4730
  memory_version: contextResult.memory_version,
4374
4731
  memory_density: contextResult.memory_density,
4732
+ context_diff: trimmedDiff ?? null,
4375
4733
  plan_summary: planItems,
4376
4734
  stale_warnings: staleTop3,
4377
4735
  workflow_hints: (contextResult.workflow_hints ?? []).slice(0, 3),
@@ -4381,34 +4739,88 @@ async function _executeMcpToolCallInner(payload) {
4381
4739
  _full_context_hint: 'Use bclaw_context(kind="memory") for the full payload.',
4382
4740
  };
4383
4741
  }
4384
- // pln#513 step 1 — bootstrap hint. When the project lacks a usable
4385
- // PROJECT.md (absent or 0 bytes), surface a hint so the agent knows
4386
- // the canonical entry-point. Cheap probe one fs.statSync, no
4387
- // gating flag. The literal next_action mirrors the documented
4388
- // canonical-grammar call so callers can suggest it verbatim.
4742
+ // pln#513 step 1 / pln#557 step 3 — bootstrap hint. The original probe
4743
+ // was a one-bit PROJECT.md stat(): false positive on a rich store
4744
+ // without PROJECT.md (recommended from-scratch bootstrap over 17k
4745
+ // events), eternal false negative on a fossil PROJECT.md. The composite
4746
+ // assessment (assessBootstrapNeed: presence × mtime-vs-activity ×
4747
+ // store density) adds a distinct 'refresh' verdict that maps to
4748
+ // bclaw_bootstrap(refresh: true) — coordinate with the pln#514 step 1
4749
+ // force-flag. 'bootstrap' keeps the shared empty-memory rule
4750
+ // (resolveEmptyMemoryRecommendation): repo with content → extract via
4751
+ // bclaw_bootstrap; greenfield → bootstrap loop. Both chainable.
4389
4752
  let bootstrapRecommended;
4753
+ let bootstrapVerdict;
4754
+ let bootstrapRefreshReason;
4390
4755
  let nextAction;
4756
+ let emptyMemoryRec;
4757
+ let storeDensity;
4391
4758
  try {
4392
- const fsMod = await import('node:fs');
4393
- const pathMod = await import('node:path');
4394
- const projectMdPath = pathMod.join(targetCwd, 'PROJECT.md');
4395
- let needsBootstrap = false;
4396
- try {
4397
- const stat = fsMod.statSync(projectMdPath);
4398
- if (!stat.isFile() || stat.size === 0)
4399
- needsBootstrap = true;
4400
- }
4401
- catch {
4402
- needsBootstrap = true; // ENOENT → absent
4759
+ const assessment = assessBootstrapNeed(targetCwd);
4760
+ bootstrapVerdict = assessment.verdict;
4761
+ bootstrapRecommended = assessment.verdict !== 'none';
4762
+ storeDensity = assessment.store_density;
4763
+ if (assessment.verdict === 'bootstrap') {
4764
+ emptyMemoryRec = resolveEmptyMemoryRecommendation(targetCwd);
4765
+ nextAction = emptyMemoryRec.mcp_next_action;
4403
4766
  }
4404
- bootstrapRecommended = needsBootstrap;
4405
- if (needsBootstrap) {
4406
- nextAction = "bclaw_coordinate(intent='ideate', preset='bootstrap')";
4767
+ else if (assessment.verdict === 'refresh') {
4768
+ bootstrapRefreshReason = assessment.reasons[0];
4769
+ nextAction = 'bclaw_bootstrap(refresh: true)';
4407
4770
  }
4408
4771
  }
4409
4772
  catch {
4410
4773
  // Best-effort: never block bclaw_work on the probe.
4411
4774
  }
4775
+ // Self-teaching affordances (pln#542): each response carries the
4776
+ // recommended follow-up calls with exact shapes.
4777
+ const nextActions = [];
4778
+ if (bootstrapVerdict === 'bootstrap' && emptyMemoryRec) {
4779
+ if (emptyMemoryRec.route === 'extract') {
4780
+ 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` });
4781
+ }
4782
+ else {
4783
+ 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` });
4784
+ }
4785
+ }
4786
+ else if (bootstrapVerdict === 'refresh') {
4787
+ 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' });
4788
+ }
4789
+ if (claimId) {
4790
+ nextActions.push({ tool: 'bclaw_release_claim', args: { id: claimId, planStatus: 'done' }, when: 'implementation complete and committed' });
4791
+ }
4792
+ else if (workReq.intent === 'consult' || workReq.intent === 'resume') {
4793
+ nextActions.push({ tool: 'bclaw_work', args: { intent: 'execute', scope: workReq.scope ?? '<scope>' }, when: 'ready to edit — claims the scope' });
4794
+ }
4795
+ // Solo-agent empty-store hint: the bootstrap recommendation covers
4796
+ // vision; agents arriving on a freshly-initialised store also need a
4797
+ // surface for *work* itself. Without this they reliably consult, see
4798
+ // nothing, and stop — bclaw_create(entity='plan') is the missing
4799
+ // affordance (2026-06-10 front-door audit). The store-density signal
4800
+ // bumps to 'low' as soon as session_start lands a single event, so
4801
+ // gate the hint directly on "no plans yet" — the actual condition
4802
+ // the agent is in.
4803
+ let noPlansYet = false;
4804
+ try {
4805
+ noPlansYet = loadState(targetCwd).plan_items.length === 0;
4806
+ }
4807
+ catch {
4808
+ // loadState may fail on a brand-new store with no project.md yet;
4809
+ // treat that as "no plans yet" — the next_action remains correct.
4810
+ noPlansYet = true;
4811
+ }
4812
+ if (noPlansYet && (storeDensity === 'empty' || storeDensity === 'low')) {
4813
+ nextActions.push({
4814
+ tool: 'bclaw_create',
4815
+ args: { entity: 'plan', input: { title: '<plan title>', steps: ['<first step>'] } },
4816
+ when: 'memory has no plans yet — once you know what you are doing, create a plan so progress is tracked',
4817
+ });
4818
+ }
4819
+ const diffTotal = contextResult?.context_diff?.counts.total ?? 0;
4820
+ if (useCompact && diffTotal > 0) {
4821
+ nextActions.push({ tool: 'bclaw_context', args: { kind: 'memory' }, when: 'to read the full changed items behind context_diff' });
4822
+ }
4823
+ nextActions.push({ tool: 'bclaw_quick_capture', args: { text: '<finding>', type: '<decision|trap|constraint|note>' }, when: 'capture decisions/traps as you work' });
4412
4824
  const facadeResponse = {
4413
4825
  status: 'ok',
4414
4826
  intent: workReq.intent,
@@ -4420,15 +4832,23 @@ async function _executeMcpToolCallInner(payload) {
4420
4832
  warnings,
4421
4833
  duration_ms: Date.now() - startMs,
4422
4834
  bootstrap_recommended: bootstrapRecommended,
4835
+ bootstrap_verdict: bootstrapVerdict,
4423
4836
  next_action: nextAction,
4837
+ next_actions: nextActions,
4424
4838
  };
4425
4839
  const summaryParts = [`✔ bclaw_work [${workReq.intent}] session=${sessionResult.session_id}`];
4426
4840
  if (claimId)
4427
4841
  summaryParts.push(`claim=${claimId} (${claimStatus})`);
4842
+ if (contextResult?.context_diff && diffTotal > 0) {
4843
+ summaryParts.push(`Δ since last look: ${contextResult.context_diff.summary}`);
4844
+ }
4428
4845
  if (useCompact)
4429
4846
  summaryParts.push('mode=compact (use bclaw_context for full payload)');
4430
- if (bootstrapRecommended) {
4431
- summaryParts.push(`💡 PROJECT.md missing — call ${nextAction} to open a bootstrap loop`);
4847
+ if (bootstrapVerdict === 'bootstrap' && emptyMemoryRec) {
4848
+ summaryParts.push(`💡 ${emptyMemoryRec.text}`);
4849
+ }
4850
+ else if (bootstrapVerdict === 'refresh') {
4851
+ summaryParts.push(`💡 ${bootstrapRefreshReason ?? 'PROJECT.md is missing or fossil relative to a mature store'} → bclaw_bootstrap(refresh: true)`);
4432
4852
  }
4433
4853
  if (warnings.length > 0)
4434
4854
  summaryParts.push(warnings.map((w) => `⚠ ${w}`).join('\n'));
@@ -4576,6 +4996,7 @@ async function _executeMcpToolCallInner(payload) {
4576
4996
  dispatcherAgent: opts.senderAgent,
4577
4997
  dispatcherAgentId: opts.senderAgentId,
4578
4998
  cwd: opts.cwd,
4999
+ requireWorktree: true, // pln#531: never spawn a worker in the integration repo
4579
5000
  });
4580
5001
  entry.execution_status = execResult.execution_status;
4581
5002
  if (execResult.pid)
@@ -4952,6 +5373,31 @@ async function _executeMcpToolCallInner(payload) {
4952
5373
  loopReviewerAgents = resolvedAgents.slice(0, REVIEW_OPEN_LOOP_FANOUT_CAP);
4953
5374
  preReviewWarnings.push(`open_loop: implicit reviewer fan-out capped at ${REVIEW_OPEN_LOOP_FANOUT_CAP} of ${resolvedAgents.length} spawnable agents; pass targetAgents to override`);
4954
5375
  }
5376
+ // pln#533 — pre-flight the reviewer agents with a trivial validation
5377
+ // spawn BEFORE opening the loop, so an environment death (config
5378
+ // rejected, auth fail, model mismatch) surfaces instantly with a clear
5379
+ // reason instead of a generic "did not acknowledge" loop timeout. Drop
5380
+ // the agents that fail and surface their reasons; if none survive the
5381
+ // existing length===0 guard skips loop creation. Skipped when open_loop
5382
+ // is off, preflight=false, or BRAINCLAW_NO_SPAWN is set (handled inside
5383
+ // preflightAgents). Cross-project dispatch never auto-spawns, so skip.
5384
+ if (req.open_loop === true && req.preflight !== false && !req.project && loopReviewerAgents.length > 0) {
5385
+ try {
5386
+ const { preflightAgents } = await import('../core/spawn-check.js');
5387
+ const pf = await preflightAgents(loopReviewerAgents, { cwd: dispatchCwd });
5388
+ if (pf.blocked.length > 0) {
5389
+ const healthy = loopReviewerAgents.filter((a) => !pf.blocked.some((b) => b.agent === a));
5390
+ for (const b of pf.blocked) {
5391
+ preReviewWarnings.push(`pre-flight: dropped reviewer '${b.agent}' — ${b.reason}.${b.recommended_next_action ? ` ${b.recommended_next_action}` : ''}`);
5392
+ }
5393
+ loopReviewerAgents = healthy;
5394
+ }
5395
+ }
5396
+ catch (pfErr) {
5397
+ // Pre-flight is best-effort: a failure here must not block the review.
5398
+ preReviewWarnings.push(`pre-flight: skipped (check threw: ${pfErr instanceof Error ? pfErr.message : String(pfErr)})`);
5399
+ }
5400
+ }
4955
5401
  // Lazy-import the loops module once before defining performReview so
4956
5402
  // the synchronous withLoopLock work callback can use it without
4957
5403
  // re-importing inside the callback.
@@ -5705,6 +6151,23 @@ async function _executeMcpToolCallInner(payload) {
5705
6151
  };
5706
6152
  }
5707
6153
  if (name === 'bclaw_loop') {
6154
+ // pln#542: intent='open' is no longer exposed standalone over MCP — it
6155
+ // creates a loop without dispatching the first turn (the documented
6156
+ // anti-pattern, now removed instead of documented). Internal callers
6157
+ // (bclaw_coordinate, CLI bootstrap) use core openLoop directly.
6158
+ if (args?.intent === 'open') {
6159
+ return {
6160
+ 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."),
6161
+ };
6162
+ }
6163
+ // pln#562 step 4 — dispatching a turn hands work to another agent; gate
6164
+ // it at the same trust bar as the other dispatch surfaces.
6165
+ if (args?.intent === 'turn' && args?.dispatch === true) {
6166
+ const resolved = ensureTrust(args, { nameField: 'agent', idField: 'agentId' }, 'trusted', cwd, connectionSessionId);
6167
+ if (resolved.error) {
6168
+ return { response: createToolErrorResponse(resolved.error.kind, resolved.error.message, resolved.error.details) };
6169
+ }
6170
+ }
5708
6171
  const { handleBclawLoop } = await import('./loops-handlers.js');
5709
6172
  const targetCwd = resolveProjectCwd(args?.project, cwd);
5710
6173
  const result = handleBclawLoop({ args: args, cwd: targetCwd });
@@ -5798,9 +6261,19 @@ async function _executeMcpToolCallInner(payload) {
5798
6261
  // this caps SIZE) so a verbose result set never overflows the MCP token
5799
6262
  // cap and silently pushes the agent to the CLI (trp#449). Advertises
5800
6263
  // has_more / next_offset / hint for explicit pagination.
6264
+ // pln#542: budget_tokens lets the caller shrink the size cap further
6265
+ // (~4 chars/token); it can only tighten, never exceed the default.
5801
6266
  const offset = Math.max(0, Number(filter.offset) || 0);
5802
- const bounded = boundListResult(result, offset);
6267
+ const budgetTokens = typeof args.budget_tokens === 'number' && args.budget_tokens > 0 ? args.budget_tokens : undefined;
6268
+ const charBudget = budgetTokens ? Math.min(budgetTokens * 4, DEFAULT_FIND_CHAR_BUDGET) : DEFAULT_FIND_CHAR_BUDGET;
6269
+ const bounded = boundListResult(result, offset, charBudget);
5803
6270
  const warnings = collectLoadValidationWarnings(entity, targetCwd);
6271
+ const nextActions = [
6272
+ { tool: 'bclaw_get', args: { entity, id: '<id from items>' }, when: 'to read one item in full' },
6273
+ ];
6274
+ if (bounded.has_more) {
6275
+ nextActions.push({ tool: 'bclaw_find', args: { entity, filter: { ...filter, offset: bounded.next_offset } }, when: 'to fetch the next page' });
6276
+ }
5804
6277
  // structuredContent is the canonical MCP return channel that clients
5805
6278
  // (VS Code extension, Codex, etc.) read for machine-parseable data.
5806
6279
  // Prior to this fix we spread `...result` at top-level of the
@@ -5811,7 +6284,7 @@ async function _executeMcpToolCallInner(payload) {
5811
6284
  return {
5812
6285
  response: toolResponse({
5813
6286
  content: [{ type: 'text', text: `✔ ${result.total} ${entity} item(s)${moreNote}` }],
5814
- structuredContent: { ...bounded, warnings },
6287
+ structuredContent: { ...bounded, warnings, next_actions: nextActions },
5815
6288
  }),
5816
6289
  };
5817
6290
  }
@@ -5840,10 +6313,35 @@ async function _executeMcpToolCallInner(payload) {
5840
6313
  };
5841
6314
  }
5842
6315
  const item = getEntity(entity, id, targetCwd);
6316
+ // trp#449 class (pln#542): handoff snapshots embed an unbounded git
6317
+ // diff — cap it (budget_tokens tightens, ~4 chars/token).
6318
+ let boundedItem = item;
6319
+ let diffTruncated = false;
6320
+ const getBudgetTokens = typeof args.budget_tokens === 'number' && args.budget_tokens > 0 ? args.budget_tokens : undefined;
6321
+ const getCharBudget = getBudgetTokens ? Math.min(getBudgetTokens * 4, DEFAULT_FIND_CHAR_BUDGET) : DEFAULT_FIND_CHAR_BUDGET;
6322
+ if (entity === 'handoff' && item && typeof item === 'object') {
6323
+ const snapshot = item.snapshot;
6324
+ if (snapshot?.diff && snapshot.diff.length > getCharBudget) {
6325
+ diffTruncated = true;
6326
+ boundedItem = {
6327
+ ...item,
6328
+ snapshot: { ...snapshot, diff: `${snapshot.diff.slice(0, getCharBudget)}\n… [diff truncated to ${getCharBudget} chars]` },
6329
+ };
6330
+ }
6331
+ else {
6332
+ const note = handoffDiffPreviewNote(snapshot);
6333
+ if (snapshot?.diff && note) {
6334
+ boundedItem = {
6335
+ ...item,
6336
+ snapshot: { ...snapshot, diff: `${snapshot.diff}\n${note}` },
6337
+ };
6338
+ }
6339
+ }
6340
+ }
5843
6341
  return {
5844
6342
  response: toolResponse({
5845
6343
  content: [{ type: 'text', text: `✔ fetched ${entity} ${id}` }],
5846
- structuredContent: { entity, item },
6344
+ structuredContent: { entity, item: boundedItem, ...(diffTruncated ? { diff_truncated: true } : {}) },
5847
6345
  }),
5848
6346
  };
5849
6347
  }
@@ -5854,25 +6352,43 @@ async function _executeMcpToolCallInner(payload) {
5854
6352
  if (name === 'bclaw_create') {
5855
6353
  try {
5856
6354
  const entity = String(args.entity ?? '');
6355
+ // Execution entities stay local: the signaling-only cross-project
6356
+ // boundary applies to the canonical verbs too, not just legacy tools.
6357
+ if (entity === 'claim' || entity === 'plan') {
6358
+ const crossProjectError = blockCrossProjectExecution(entity, args);
6359
+ if (crossProjectError)
6360
+ return { response: crossProjectError };
6361
+ }
5857
6362
  const rawData = (args.data ?? {});
5858
6363
  const targetCwd = resolveProjectCwd(args.project, cwd);
5859
6364
  // Auto-fill identity fields. Without this, a caller who omits author/agent
5860
6365
  // creates a schema-invalid record that is silently dropped on read and
5861
- // GC'd from disk on the next mutation. Fallback chain:
5862
- // resolved MCP identity → args.agent → 'unknown'.
6366
+ // GC'd from disk on the next mutation.
5863
6367
  // Identity is resolved against the SOURCE cwd (the agent's own
5864
6368
  // project/registry), not the target — an agent doesn't need to be
5865
- // registered in the target project to write into it.
5866
- const { agent_name, agent_id } = resolveCanonicalAuthor(args, cwd, connectionSessionId);
6369
+ // registered in the target project to write into it. An explicitly
6370
+ // supplied data.author is honored as content-level attribution
6371
+ // (cross-project signaling writers may not be registered locally);
6372
+ // when author is MISSING, resolution is mandatory and failure is a
6373
+ // hard validation_error (pln#562 step 3) — never author:'unknown'.
5867
6374
  const data = { ...rawData };
5868
- if (data.author === undefined)
6375
+ let actor = typeof data.author === 'string' ? data.author : undefined;
6376
+ let actorId = typeof data.agent_id === 'string' ? data.agent_id : undefined;
6377
+ if (data.author === undefined) {
6378
+ const { agent_name, agent_id } = resolveCanonicalAuthor(args, cwd, connectionSessionId);
5869
6379
  data.author = agent_name;
5870
- if (data.agent === undefined)
5871
- data.agent = agent_name;
5872
- if (data.agent_id === undefined && agent_id)
5873
- data.agent_id = agent_id;
6380
+ if (data.agent === undefined)
6381
+ data.agent = agent_name;
6382
+ if (data.agent_id === undefined && agent_id)
6383
+ data.agent_id = agent_id;
6384
+ actor = agent_name;
6385
+ actorId = agent_id;
6386
+ }
6387
+ else if (data.agent === undefined) {
6388
+ data.agent = data.author;
6389
+ }
5874
6390
  const result = createEntity(entity, data, targetCwd);
5875
- appendAuditEntry({ actor: agent_name, ...(agent_id ? { actor_id: agent_id } : {}), action: 'create', item_id: result.id, item_type: entity }, targetCwd);
6391
+ appendAuditEntry({ actor: actor ?? 'unknown', ...(actorId ? { actor_id: actorId } : {}), action: 'create', item_id: result.id, item_type: entity }, targetCwd);
5876
6392
  return {
5877
6393
  response: toolResponse({
5878
6394
  content: [{ type: 'text', text: `✔ created ${entity} ${result.id}` }],
@@ -5927,6 +6443,13 @@ async function _executeMcpToolCallInner(payload) {
5927
6443
  if (name === 'bclaw_transition') {
5928
6444
  try {
5929
6445
  const entity = String(args.entity ?? '');
6446
+ // Same signaling-only boundary as bclaw_create: no remote lifecycle
6447
+ // driving of execution entities through the canonical grammar.
6448
+ if (entity === 'claim' || entity === 'plan') {
6449
+ const crossProjectError = blockCrossProjectExecution(entity, args);
6450
+ if (crossProjectError)
6451
+ return { response: crossProjectError };
6452
+ }
5930
6453
  const id = String(args.id ?? '');
5931
6454
  const to = String(args.to ?? '');
5932
6455
  const reason = args.reason;