brainclaw 1.8.0 → 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 (140) hide show
  1. package/README.md +12 -11
  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 +285 -22
  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 +588 -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 +79 -5
  55. package/dist/core/dispatcher.js +64 -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/facade-schema.js +38 -0
  67. package/dist/core/gc-semantic.js +130 -5
  68. package/dist/core/handoff-snapshot.js +68 -0
  69. package/dist/core/ids.js +19 -8
  70. package/dist/core/instruction-templates.js +34 -115
  71. package/dist/core/io.js +39 -3
  72. package/dist/core/json-store.js +10 -1
  73. package/dist/core/lock.js +153 -28
  74. package/dist/core/loops/bootstrap-acquire.js +25 -1
  75. package/dist/core/loops/facade-schema.js +2 -0
  76. package/dist/core/loops/hooks/survey-signals-baseline.js +36 -0
  77. package/dist/core/loops/index.js +1 -0
  78. package/dist/core/loops/presets/bootstrap.js +7 -0
  79. package/dist/core/loops/store.js +17 -0
  80. package/dist/core/loops/verbs.js +24 -1
  81. package/dist/core/markdown.js +8 -76
  82. package/dist/core/mcp-command-resolution.js +245 -0
  83. package/dist/core/memory-compactor.js +5 -3
  84. package/dist/core/memory-lifecycle.js +282 -0
  85. package/dist/core/merge-risk.js +150 -0
  86. package/dist/core/messaging.js +8 -1
  87. package/dist/core/migration.js +11 -1
  88. package/dist/core/observer-mode.js +26 -0
  89. package/dist/core/operations/memory-mutation.js +90 -65
  90. package/dist/core/operations/plan.js +27 -1
  91. package/dist/core/protocol-skills.js +210 -0
  92. package/dist/core/reflection-safety.js +6 -7
  93. package/dist/core/reputation.js +84 -2
  94. package/dist/core/runtime-signals.js +71 -9
  95. package/dist/core/runtime.js +84 -1
  96. package/dist/core/schema.js +114 -0
  97. package/dist/core/security-detectors.js +125 -0
  98. package/dist/core/security-extract.js +189 -0
  99. package/dist/core/security-guard.js +107 -29
  100. package/dist/core/security-packages.js +121 -0
  101. package/dist/core/security-scoring.js +76 -9
  102. package/dist/core/security.js +34 -2
  103. package/dist/core/sequence.js +11 -2
  104. package/dist/core/setup-flow.js +141 -13
  105. package/dist/core/staleness.js +72 -1
  106. package/dist/core/state.js +250 -54
  107. package/dist/core/store-resolution.js +19 -5
  108. package/dist/core/worktree.js +72 -8
  109. package/dist/facts.js +8 -8
  110. package/dist/facts.json +7 -7
  111. package/docs/PROTOCOL.md +223 -0
  112. package/docs/cli.md +11 -10
  113. package/docs/concepts/coordinator-runbook.md +129 -0
  114. package/docs/concepts/event-log-store-critique-A.md +333 -0
  115. package/docs/concepts/event-log-store-critique-B.md +353 -0
  116. package/docs/concepts/event-log-store-phase0-measurements.md +58 -0
  117. package/docs/concepts/event-log-store-proposal-A.md +365 -0
  118. package/docs/concepts/event-log-store-proposal-B.md +404 -0
  119. package/docs/concepts/event-log-store.md +928 -0
  120. package/docs/concepts/identity-model-proposal.md +371 -0
  121. package/docs/concepts/memory.md +5 -4
  122. package/docs/concepts/observer-protocol.md +361 -0
  123. package/docs/concepts/parallel-merge-protocol.md +71 -0
  124. package/docs/concepts/plans-and-claims.md +43 -0
  125. package/docs/concepts/skills.md +78 -0
  126. package/docs/concepts/workspace-bootstrapping.md +61 -0
  127. package/docs/integrations/agents.md +4 -4
  128. package/docs/integrations/cline.md +10 -11
  129. package/docs/integrations/codex.md +2 -2
  130. package/docs/integrations/continue.md +5 -5
  131. package/docs/integrations/copilot.md +14 -12
  132. package/docs/integrations/openclaw.md +7 -6
  133. package/docs/integrations/overview.md +7 -7
  134. package/docs/integrations/roo.md +3 -3
  135. package/docs/integrations/windsurf.md +6 -6
  136. package/docs/mcp-schema-changelog.md +29 -2
  137. package/docs/quickstart.md +48 -47
  138. package/docs/security.md +174 -15
  139. package/docs/storage.md +4 -2
  140. 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
  },
@@ -998,8 +1008,12 @@ const MCP_WRITE_TOOLS = [
998
1008
  properties: {
999
1009
  intent: {
1000
1010
  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.).',
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.',
1003
1017
  },
1004
1018
  loop_id: { type: 'string', description: 'Target loop id (lop_…). Required for every intent except open and list.' },
1005
1019
  kind: { type: 'string', enum: ['review', 'ideation', 'implementation', 'research', 'debug'], description: 'Loop kind for open / list filter.' },
@@ -1122,6 +1136,7 @@ const MCP_WRITE_TOOLS = [
1122
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.' },
1123
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.' },
1124
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.' },
1125
1140
  },
1126
1141
  required: ['entity'],
1127
1142
  },
@@ -1136,6 +1151,7 @@ const MCP_WRITE_TOOLS = [
1136
1151
  entity: { type: 'string', description: 'Entity name.' },
1137
1152
  id: { type: 'string', description: 'Entity id (e.g. dec_ab12cd) or short_label (e.g. dec#42).' },
1138
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).' },
1139
1155
  },
1140
1156
  required: ['entity', 'id'],
1141
1157
  },
@@ -1226,6 +1242,39 @@ export const MCP_TOOL_NAMES = ALL_TOOLS.map((tool) => tool.name);
1226
1242
  export const MCP_HEADLESS_AUTO_TOOL_NAMES = ALL_TOOLS
1227
1243
  .filter((tool) => tool.annotations?.headlessApproval === 'auto')
1228
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);
1229
1278
  /**
1230
1279
  * Tools removed from the MCP surface at the v1.0 cut (Phase 3 slice 3i).
1231
1280
  * Handlers remain in place defensively, but these names are hidden from
@@ -1333,6 +1382,21 @@ export const DEFAULT_PUBLISHED_TOOLS = PUBLISHED_TOOLS
1333
1382
  return a.index - b.index;
1334
1383
  })
1335
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
+ }
1336
1400
  class McpProtocolError extends Error {
1337
1401
  code;
1338
1402
  id;
@@ -1525,10 +1589,108 @@ function getCancelledRequestId(params) {
1525
1589
  }
1526
1590
  return undefined;
1527
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
+ }
1528
1650
  function resolveMutationIdentity(args, fields, cwd, sessionId) {
1529
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.
1530
1692
  // 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;
1693
+ let agentName = explicitName;
1532
1694
  if (!agentName && sessionId) {
1533
1695
  const session = loadSessionById(sessionId, cwd);
1534
1696
  if (session?.agent) {
@@ -1538,7 +1700,7 @@ function resolveMutationIdentity(args, fields, cwd, sessionId) {
1538
1700
  return {
1539
1701
  identity: requireRegisteredAgentIdentity({
1540
1702
  agentName,
1541
- agentId: typeof args[fields.idField] === 'string' ? String(args[fields.idField]) : undefined,
1703
+ agentId: explicitId,
1542
1704
  cwd,
1543
1705
  allowCurrent: true,
1544
1706
  allowEnv: true,
@@ -1592,12 +1754,15 @@ function ensureTrust(args, fields, level, cwd, sessionId) {
1592
1754
  }
1593
1755
  /**
1594
1756
  * 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).
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).
1599
1761
  *
1600
- * 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.
1601
1766
  */
1602
1767
  function resolveCanonicalAuthor(args, cwd, connectionSessionId) {
1603
1768
  const resolved = resolveMutationIdentity(args, { nameField: 'agent', idField: 'agentId' }, cwd, connectionSessionId);
@@ -1607,8 +1772,9 @@ function resolveCanonicalAuthor(args, cwd, connectionSessionId) {
1607
1772
  agent_id: resolved.identity.agent_id,
1608
1773
  };
1609
1774
  }
1610
- const explicit = typeof args.agent === 'string' ? args.agent : undefined;
1611
- 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.');
1612
1778
  }
1613
1779
  function explicitSessionIdFromEnv() {
1614
1780
  return process.env.BRAINCLAW_SESSION_ID?.trim()
@@ -1650,11 +1816,17 @@ export function parseMcpLine(line) {
1650
1816
  isNotification: !('id' in parsed),
1651
1817
  };
1652
1818
  }
1653
- export function createInitializeResult(protocolVersion) {
1819
+ export function createInitializeResult(protocolVersion, options) {
1820
+ const uninitialized = options?.uninitialized === true;
1654
1821
  return {
1655
1822
  protocolVersion,
1656
1823
  serverInfo: { name: 'brainclaw', version: SCHEMA_VERSION },
1657
- 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
+ : {}),
1658
1830
  };
1659
1831
  }
1660
1832
  export class McpTaskRunner {
@@ -1777,6 +1949,8 @@ export class McpServerConnection {
1777
1949
  state = 'pre_init';
1778
1950
  protocolVersion;
1779
1951
  connectionSessionId;
1952
+ /** True while the project memory at cwd is absent — serves the minimal setup catalog. */
1953
+ uninitializedMode;
1780
1954
  /** Version of brainclaw code loaded in this process at boot time. */
1781
1955
  bootVersion;
1782
1956
  /** Throttle disk version checks — at most once per 60s. */
@@ -1787,11 +1961,15 @@ export class McpServerConnection {
1787
1961
  constructor(options) {
1788
1962
  this.cwd = options.cwd;
1789
1963
  this.send = options.send;
1964
+ this.uninitializedMode = options.uninitialized ?? false;
1790
1965
  this.bootVersion = getInstalledBrainclawVersion();
1791
1966
  this.taskRunner = new McpTaskRunner({
1792
1967
  executeTool: options.executeTool ?? createWorkerToolExecutor(),
1793
1968
  onResult: (requestId, outcome) => {
1794
- 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
+ }
1795
1973
  // Inject version mismatch advisory if stale
1796
1974
  const advisory = this.checkVersionMismatch();
1797
1975
  if (advisory && outcome.response.content.length > 0) {
@@ -1800,17 +1978,43 @@ export class McpServerConnection {
1800
1978
  ...outcome.response.content,
1801
1979
  ];
1802
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
+ }
1803
1988
  // Track usage: append response size to usage.jsonl
1804
1989
  if (outcome.toolName) {
1805
1990
  this.trackUsage(outcome.toolName, outcome.response);
1806
1991
  }
1807
1992
  this.sendResult(requestId, outcome.response);
1993
+ if (catalogUnlocked) {
1994
+ this.send({ jsonrpc: '2.0', method: 'notifications/tools/list_changed' });
1995
+ }
1808
1996
  },
1809
1997
  onInternalError: (requestId, error) => {
1810
1998
  this.sendError(requestId, -32603, error instanceof Error ? error.message : 'Internal error');
1811
1999
  },
1812
2000
  });
1813
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
+ }
1814
2018
  /**
1815
2019
  * Compare the version loaded in memory with the version on disk.
1816
2020
  * Returns an advisory string if they differ, undefined otherwise.
@@ -1895,7 +2099,11 @@ export class McpServerConnection {
1895
2099
  const protocolVersion = resolveRequestedProtocolVersion(params, id ?? null);
1896
2100
  this.protocolVersion = protocolVersion;
1897
2101
  this.state = 'awaiting_initialized';
1898
- 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
+ }));
1899
2107
  return;
1900
2108
  }
1901
2109
  if (method === 'notifications/initialized' || method === 'initialized') {
@@ -1918,6 +2126,15 @@ export class McpServerConnection {
1918
2126
  }
1919
2127
  if (method === 'tools/list') {
1920
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
+ }
1921
2138
  const params = message.params === undefined ? {} : requireObjectParams(message.params, id ?? null);
1922
2139
  const catalog = typeof params.catalog === 'string' ? params.catalog : undefined;
1923
2140
  const include = typeof params.include === 'string' ? params.include : undefined;
@@ -1947,6 +2164,19 @@ export class McpServerConnection {
1947
2164
  return;
1948
2165
  }
1949
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
+ }
1950
2180
  this.taskRunner.enqueue(id ?? null, {
1951
2181
  name,
1952
2182
  args,
@@ -2082,12 +2312,15 @@ export class StdioTransport {
2082
2312
  }
2083
2313
  drainNewline() {
2084
2314
  while (true) {
2085
- const str = this.buffer.toString('utf-8');
2086
- 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);
2087
2319
  if (newlineIndex === -1)
2088
2320
  return;
2089
- const line = str.slice(0, newlineIndex).replace(/\r$/, '');
2090
- 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$/, '');
2091
2324
  if (line.trim()) {
2092
2325
  this.onMessage(line);
2093
2326
  }
@@ -2096,9 +2329,12 @@ export class StdioTransport {
2096
2329
  }
2097
2330
  export function runMcp() {
2098
2331
  const cwd = resolveEffectiveCwd();
2099
- if (!memoryExists(cwd)) {
2100
- console.error('Project memory not initialized. Run `brainclaw init` first.');
2101
- 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));
2102
2338
  }
2103
2339
  const missingWorkerPath = resolveMcpWorkerEntryPath();
2104
2340
  if (!fs.existsSync(missingWorkerPath)) {
@@ -2120,6 +2356,7 @@ export function runMcp() {
2120
2356
  cwd,
2121
2357
  send: adaptiveSend,
2122
2358
  executeTool: createWorkerToolExecutor(),
2359
+ uninitialized,
2123
2360
  });
2124
2361
  transport.onMessage = (line) => connection.handleLine(line);
2125
2362
  transport.start();
@@ -2135,6 +2372,9 @@ function createWorkerToolExecutor() {
2135
2372
  try {
2136
2373
  worker = new Worker(new URL('./mcp-worker.js', import.meta.url), {
2137
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,
2138
2378
  });
2139
2379
  }
2140
2380
  catch (error) {
@@ -2145,6 +2385,8 @@ function createWorkerToolExecutor() {
2145
2385
  reject(error);
2146
2386
  return;
2147
2387
  }
2388
+ // Redirect any worker stdout to the server's stderr (safe for diagnostics)
2389
+ worker.stdout?.pipe(process.stderr);
2148
2390
  let settled = false;
2149
2391
  const cleanup = () => {
2150
2392
  worker.removeAllListeners('message');
@@ -2295,7 +2537,10 @@ function getCrossProjectArg(args, ...keys) {
2295
2537
  return undefined;
2296
2538
  }
2297
2539
  function blockCrossProjectExecution(entity, args) {
2298
- 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');
2299
2544
  if (!targetProject) {
2300
2545
  return undefined;
2301
2546
  }
@@ -2385,20 +2630,24 @@ async function _executeMcpToolCallInner(payload) {
2385
2630
  if (detected) {
2386
2631
  summary.push(`✔ Agent detected: ${detected.name}`);
2387
2632
  }
2388
- summary.push('✔ Reload your agent session to activate brainclaw MCP tools.');
2389
- // 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.
2390
2636
  const probe = probeForQuickSetup(cwd);
2391
2637
  const bootstrapAvailable = probe.hasContent;
2638
+ const emptyMemoryRec = resolveEmptyMemoryRecommendation(cwd);
2392
2639
  const preview = buildOnboardingPreview(cwd);
2393
2640
  return {
2394
2641
  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 }],
2642
+ content: [{ type: 'text', text: summary.join('\n') + '\n\n' + preview }],
2396
2643
  structuredContent: {
2397
2644
  setup_complete: true,
2398
2645
  project_type: projectType,
2399
2646
  topology,
2400
2647
  detected_agent: detected?.name ?? null,
2401
2648
  bootstrap_available: bootstrapAvailable,
2649
+ bootstrap_route: emptyMemoryRec.route,
2650
+ next_action: emptyMemoryRec.mcp_next_action,
2402
2651
  preview,
2403
2652
  summary,
2404
2653
  },
@@ -2579,7 +2828,7 @@ async function _executeMcpToolCallInner(payload) {
2579
2828
  });
2580
2829
  const signal = writeCrossProjectSignal(resolveCrossProjectWritableTarget(crossProjectTarget, 'runtime_note', cwd), 'runtime_note', {
2581
2830
  schema_version: 2,
2582
- id: generateId('rtn'),
2831
+ id: generateId('runtime_note'),
2583
2832
  agent: opIdentity.agent,
2584
2833
  agent_id: opIdentity.agent_id,
2585
2834
  project_id: opIdentity.project_id ?? '',
@@ -2649,7 +2898,20 @@ async function _executeMcpToolCallInner(payload) {
2649
2898
  }
2650
2899
  }
2651
2900
  const identity = resolved.identity;
2652
- 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
+ }
2653
2915
  if (classification.target === 'note') {
2654
2916
  const result = createRuntimeNote(formatQuickCaptureNoteText(text, context), {
2655
2917
  agent: identity.agent_name,
@@ -2671,6 +2933,9 @@ async function _executeMcpToolCallInner(payload) {
2671
2933
  note_id: result.noteId,
2672
2934
  session_id: result.sessionId,
2673
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
+ ],
2674
2939
  },
2675
2940
  }),
2676
2941
  nextConnectionSessionId: explicitSessionIdFromEnv() ? undefined : result.sessionId,
@@ -2688,6 +2953,13 @@ async function _executeMcpToolCallInner(payload) {
2688
2953
  const statusText = capture.writeThrough
2689
2954
  ? `✔ Quick capture promoted as ${classification.target} [${capture.promotedItemId}]`
2690
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
+ ];
2691
2963
  return {
2692
2964
  response: toolResponse({
2693
2965
  content: [{ type: 'text', text: statusText }],
@@ -2699,7 +2971,8 @@ async function _executeMcpToolCallInner(payload) {
2699
2971
  candidate_id: capture.candidateId,
2700
2972
  promoted_item_id: capture.promotedItemId,
2701
2973
  write_through: capture.writeThrough,
2702
- promotion_blocked_reason: capture.promotionBlockedReason,
2974
+ // Advisory only (cnd_abe61d68): contradictions are metadata on
2975
+ // the candidate, never a promotion blocker.
2703
2976
  contradiction_summary: capture.contradictionSummary,
2704
2977
  contradictions_detected: capture.contradictionsDetected?.map((item) => ({
2705
2978
  severity: item.severity,
@@ -2707,9 +2980,9 @@ async function _executeMcpToolCallInner(payload) {
2707
2980
  conflicts_with: item.conflicts_with,
2708
2981
  })),
2709
2982
  context,
2983
+ next_actions: captureNextActions,
2710
2984
  },
2711
2985
  }),
2712
- nextConnectionSessionId: undefined,
2713
2986
  };
2714
2987
  }
2715
2988
  if (name === 'bclaw_create_plan') {
@@ -3038,10 +3311,29 @@ async function _executeMcpToolCallInner(payload) {
3038
3311
  catch {
3039
3312
  return { response: createToolErrorResponse('not_found', `Claim not found: ${claimId}`) };
3040
3313
  }
3041
- const cascadeResult = releaseClaimWithCascade(claimId, {
3042
- planStatus: args.planStatus,
3043
- cwd,
3044
- });
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
+ }
3045
3337
  const { planTransitioned, planWarning, planId: cascadePlanId, newPlanStatus: cascadeNewStatus } = cascadeResult;
3046
3338
  const summaryText = [
3047
3339
  `✔ Released claim [${claimId}]`,
@@ -3256,7 +3548,7 @@ async function _executeMcpToolCallInner(payload) {
3256
3548
  ...(result.handoff ? { handoff: result.handoff } : {}),
3257
3549
  ...(result.reflection_prompt ? { reflection_prompt: result.reflection_prompt } : {}),
3258
3550
  }),
3259
- nextConnectionSessionId: undefined,
3551
+ nextConnectionSessionId: null,
3260
3552
  };
3261
3553
  }
3262
3554
  if (name === 'bclaw_compact') {
@@ -3390,20 +3682,41 @@ async function _executeMcpToolCallInner(payload) {
3390
3682
  }
3391
3683
  lines.push(` Sequence: ${analysis.sequence.name}`);
3392
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));
3393
3692
  if (dispatchResult.messages_sent.length > 0) {
3394
3693
  lines.push('');
3395
3694
  lines.push(args.dryRun ? ' Would assign:' : ' Assigned:');
3396
3695
  for (const msg of dispatchResult.messages_sent) {
3397
3696
  const lane = msg.lane ? ` (lane: ${msg.lane})` : '';
3398
- 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}`);
3399
3710
  }
3400
3711
  }
3401
- // Surface bash commands prominently this is what the coordinator should run
3402
- 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) {
3403
3716
  lines.push('');
3404
- 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:');
3405
3718
  lines.push('');
3406
- for (const cmd of dispatchResult.commands) {
3719
+ for (const cmd of manualCommands) {
3407
3720
  const lane = cmd.lane ? ` [lane: ${cmd.lane}]` : '';
3408
3721
  lines.push(`# ${cmd.agent}${lane}`);
3409
3722
  lines.push(cmd.command);
@@ -3417,6 +3730,15 @@ async function _executeMcpToolCallInner(payload) {
3417
3730
  lines.push(` - ${skip.plan_id}: ${skip.reason}`);
3418
3731
  }
3419
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
+ }
3420
3742
  appendAuditEntry({
3421
3743
  actor: resolved.identity.agent_name,
3422
3744
  actor_id: resolved.identity.agent_id,
@@ -3499,7 +3821,9 @@ async function _executeMcpToolCallInner(payload) {
3499
3821
  if (status === 'accepted' && assignment.message_id) {
3500
3822
  try {
3501
3823
  const { ackMessage } = await import('../core/messaging.js');
3502
- 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 });
3503
3827
  }
3504
3828
  catch { /* best-effort: don't fail the update if ack fails */ }
3505
3829
  }
@@ -3666,7 +3990,11 @@ async function _executeMcpToolCallInner(payload) {
3666
3990
  return { response: createToolErrorResponse('validation_error', 'Missing required argument: id') };
3667
3991
  }
3668
3992
  try {
3669
- 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
+ });
3670
3998
  appendAuditEntry({ actor: resolved.identity.agent_name, actor_id: resolved.identity.agent_id, action: 'update', item_id: result.id, item_type: 'message' }, cwd);
3671
3999
  return {
3672
4000
  response: toolResponse({
@@ -3768,13 +4096,15 @@ async function _executeMcpToolCallInner(payload) {
3768
4096
  const stepTextRaw = stepData.text ?? stepData.title ?? args.text;
3769
4097
  const stepText = typeof stepTextRaw === 'string' ? stepTextRaw.trim() : '';
3770
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);
3771
4101
  if (!stepPlanId)
3772
4102
  return { response: createToolErrorResponse('validation_error', 'Missing required argument: planId') };
3773
4103
  if (!stepText)
3774
4104
  return { response: createToolErrorResponse('validation_error', 'Missing required argument: data.text') };
3775
4105
  const stepTargetCwd = resolveProjectCwd(args.project, cwd);
3776
4106
  try {
3777
- 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);
3778
4108
  return {
3779
4109
  response: toolResponse({
3780
4110
  content: [{ type: 'text', text: `✔ Step added: [${result.stepId}] ${stepText} (${result.doneSteps}/${result.totalSteps} done)` }],
@@ -3855,6 +4185,8 @@ async function _executeMcpToolCallInner(payload) {
3855
4185
  status: args.status,
3856
4186
  text: args.text,
3857
4187
  assignee: args.assignee,
4188
+ estimatedEffort: args.estimated_effort,
4189
+ actualEffort: args.actual_effort,
3858
4190
  }, usTargetCwd);
3859
4191
  const changes = [];
3860
4192
  if (args.status)
@@ -3863,6 +4195,10 @@ async function _executeMcpToolCallInner(payload) {
3863
4195
  changes.push('text updated');
3864
4196
  if (args.assignee !== undefined)
3865
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}`);
3866
4202
  return {
3867
4203
  response: toolResponse({
3868
4204
  content: [{ type: 'text', text: `✔ Step updated: [${result.stepId}] ${changes.join(', ')} (${result.doneSteps}/${result.totalSteps} done)` }],
@@ -4290,11 +4626,11 @@ async function _executeMcpToolCallInner(payload) {
4290
4626
  if (sessionResult.auto_registered) {
4291
4627
  warnings.push(`Agent '${sessionResult.agent}' was auto-registered (first use). Run \`brainclaw register-agent ${sessionResult.agent}\` to set capabilities and trust level.`);
4292
4628
  }
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.
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).
4298
4634
  let contextResult;
4299
4635
  try {
4300
4636
  let sinceSession;
@@ -4308,6 +4644,8 @@ async function _executeMcpToolCallInner(payload) {
4308
4644
  agent: sessionResult.agent,
4309
4645
  cwd: targetCwd,
4310
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,
4311
4649
  });
4312
4650
  }
4313
4651
  catch { /* non-fatal — context failure should not block work */ }
@@ -4368,11 +4706,30 @@ async function _executeMcpToolCallInner(payload) {
4368
4706
  text: w.text.slice(0, 80),
4369
4707
  age_days: w.age_days,
4370
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;
4371
4727
  resultPayload = {
4372
4728
  context_schema: contextResult.context_schema,
4373
4729
  profile: contextResult.profile,
4374
4730
  memory_version: contextResult.memory_version,
4375
4731
  memory_density: contextResult.memory_density,
4732
+ context_diff: trimmedDiff ?? null,
4376
4733
  plan_summary: planItems,
4377
4734
  stale_warnings: staleTop3,
4378
4735
  workflow_hints: (contextResult.workflow_hints ?? []).slice(0, 3),
@@ -4382,34 +4739,88 @@ async function _executeMcpToolCallInner(payload) {
4382
4739
  _full_context_hint: 'Use bclaw_context(kind="memory") for the full payload.',
4383
4740
  };
4384
4741
  }
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.
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.
4390
4752
  let bootstrapRecommended;
4753
+ let bootstrapVerdict;
4754
+ let bootstrapRefreshReason;
4391
4755
  let nextAction;
4756
+ let emptyMemoryRec;
4757
+ let storeDensity;
4392
4758
  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
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;
4404
4766
  }
4405
- bootstrapRecommended = needsBootstrap;
4406
- if (needsBootstrap) {
4407
- nextAction = "bclaw_coordinate(intent='ideate', preset='bootstrap')";
4767
+ else if (assessment.verdict === 'refresh') {
4768
+ bootstrapRefreshReason = assessment.reasons[0];
4769
+ nextAction = 'bclaw_bootstrap(refresh: true)';
4408
4770
  }
4409
4771
  }
4410
4772
  catch {
4411
4773
  // Best-effort: never block bclaw_work on the probe.
4412
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' });
4413
4824
  const facadeResponse = {
4414
4825
  status: 'ok',
4415
4826
  intent: workReq.intent,
@@ -4421,15 +4832,23 @@ async function _executeMcpToolCallInner(payload) {
4421
4832
  warnings,
4422
4833
  duration_ms: Date.now() - startMs,
4423
4834
  bootstrap_recommended: bootstrapRecommended,
4835
+ bootstrap_verdict: bootstrapVerdict,
4424
4836
  next_action: nextAction,
4837
+ next_actions: nextActions,
4425
4838
  };
4426
4839
  const summaryParts = [`✔ bclaw_work [${workReq.intent}] session=${sessionResult.session_id}`];
4427
4840
  if (claimId)
4428
4841
  summaryParts.push(`claim=${claimId} (${claimStatus})`);
4842
+ if (contextResult?.context_diff && diffTotal > 0) {
4843
+ summaryParts.push(`Δ since last look: ${contextResult.context_diff.summary}`);
4844
+ }
4429
4845
  if (useCompact)
4430
4846
  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`);
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)`);
4433
4852
  }
4434
4853
  if (warnings.length > 0)
4435
4854
  summaryParts.push(warnings.map((w) => `⚠ ${w}`).join('\n'));
@@ -5732,6 +6151,23 @@ async function _executeMcpToolCallInner(payload) {
5732
6151
  };
5733
6152
  }
5734
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
+ }
5735
6171
  const { handleBclawLoop } = await import('./loops-handlers.js');
5736
6172
  const targetCwd = resolveProjectCwd(args?.project, cwd);
5737
6173
  const result = handleBclawLoop({ args: args, cwd: targetCwd });
@@ -5825,9 +6261,19 @@ async function _executeMcpToolCallInner(payload) {
5825
6261
  // this caps SIZE) so a verbose result set never overflows the MCP token
5826
6262
  // cap and silently pushes the agent to the CLI (trp#449). Advertises
5827
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.
5828
6266
  const offset = Math.max(0, Number(filter.offset) || 0);
5829
- 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);
5830
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
+ }
5831
6277
  // structuredContent is the canonical MCP return channel that clients
5832
6278
  // (VS Code extension, Codex, etc.) read for machine-parseable data.
5833
6279
  // Prior to this fix we spread `...result` at top-level of the
@@ -5838,7 +6284,7 @@ async function _executeMcpToolCallInner(payload) {
5838
6284
  return {
5839
6285
  response: toolResponse({
5840
6286
  content: [{ type: 'text', text: `✔ ${result.total} ${entity} item(s)${moreNote}` }],
5841
- structuredContent: { ...bounded, warnings },
6287
+ structuredContent: { ...bounded, warnings, next_actions: nextActions },
5842
6288
  }),
5843
6289
  };
5844
6290
  }
@@ -5867,10 +6313,35 @@ async function _executeMcpToolCallInner(payload) {
5867
6313
  };
5868
6314
  }
5869
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
+ }
5870
6341
  return {
5871
6342
  response: toolResponse({
5872
6343
  content: [{ type: 'text', text: `✔ fetched ${entity} ${id}` }],
5873
- structuredContent: { entity, item },
6344
+ structuredContent: { entity, item: boundedItem, ...(diffTruncated ? { diff_truncated: true } : {}) },
5874
6345
  }),
5875
6346
  };
5876
6347
  }
@@ -5881,25 +6352,43 @@ async function _executeMcpToolCallInner(payload) {
5881
6352
  if (name === 'bclaw_create') {
5882
6353
  try {
5883
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
+ }
5884
6362
  const rawData = (args.data ?? {});
5885
6363
  const targetCwd = resolveProjectCwd(args.project, cwd);
5886
6364
  // Auto-fill identity fields. Without this, a caller who omits author/agent
5887
6365
  // 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'.
6366
+ // GC'd from disk on the next mutation.
5890
6367
  // Identity is resolved against the SOURCE cwd (the agent's own
5891
6368
  // 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);
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'.
5894
6374
  const data = { ...rawData };
5895
- 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);
5896
6379
  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;
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
+ }
5901
6390
  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);
6391
+ appendAuditEntry({ actor: actor ?? 'unknown', ...(actorId ? { actor_id: actorId } : {}), action: 'create', item_id: result.id, item_type: entity }, targetCwd);
5903
6392
  return {
5904
6393
  response: toolResponse({
5905
6394
  content: [{ type: 'text', text: `✔ created ${entity} ${result.id}` }],
@@ -5954,6 +6443,13 @@ async function _executeMcpToolCallInner(payload) {
5954
6443
  if (name === 'bclaw_transition') {
5955
6444
  try {
5956
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
+ }
5957
6453
  const id = String(args.id ?? '');
5958
6454
  const to = String(args.to ?? '');
5959
6455
  const reason = args.reason;