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,8 +16,11 @@ import { BRAINCLAW_EXCLUSIVE_DIRECTORIES, describeAutoConfigWrite, ensureAgentFi
16
16
  import { detectAiAgent, detectWslEnvironment } from '../core/ai-agent-detection.js';
17
17
  import { buildAiSurfaceInventory, renderAiSurfaceUsageHints } from '../core/ai-surface-inventory.js';
18
18
  import { ensureUserStore, hasCompletedSetup } from '../core/setup-state.js';
19
+ import { resolveEmptyMemoryRecommendation } from '../core/setup-flow.js';
19
20
  import { writeDetectedAgentExport } from './export.js';
20
21
  import { writeDetectedAgentHooks } from './hooks.js';
22
+ import { checkGitPresence, runGlobalInstall } from './setup.js';
23
+ import { createBackup, BackupError } from '../core/upgrades/backup.js';
21
24
  import { ConfigSchema } from '../core/schema.js';
22
25
  export async function runInit(options = {}) {
23
26
  const cwd = options.cwd ?? process.cwd();
@@ -29,6 +32,14 @@ export async function runInit(options = {}) {
29
32
  if (!hasCompletedSetup()) {
30
33
  ensureUserStore();
31
34
  }
35
+ // Git-presence gate, aligned with `brainclaw setup`: agent-first onboarding
36
+ // assumes git for memory versioning + post-merge hooks. Allow override via
37
+ // BRAINCLAW_SKIP_REPO_ANALYSIS=1 for tests that exercise non-git fixtures.
38
+ if (process.env.BRAINCLAW_SKIP_REPO_ANALYSIS !== '1' && !checkGitPresence()) {
39
+ console.error('brainclaw init needs git to work.');
40
+ console.error('Install git from https://git-scm.com and try again.');
41
+ process.exit(1);
42
+ }
32
43
  if (containingMemoryStore) {
33
44
  console.error(`Error: cannot run \`brainclaw init\` from inside an existing project memory store (${containingMemoryStore}).`);
34
45
  console.error('Run `brainclaw init` from the project root directory instead.');
@@ -61,6 +72,29 @@ export async function runInit(options = {}) {
61
72
  const storageDir = resolveStorageDir(options.storageDir);
62
73
  const projectMemoryExists = memoryExists(cwd);
63
74
  const existingConfig = projectMemoryExists ? loadExistingConfig(cwd, storageDir) : undefined;
75
+ // --force backup gate: feedback_no_init_force (June 2026) entered the code.
76
+ // Before rebuilding identity fields on top of an existing store, take a
77
+ // sibling backup so curator personalisations (redaction patterns, claim
78
+ // TTL, governance, sensitive_paths) can always be recovered even if the
79
+ // merge below regresses or the agent ran `init --force` by mistake.
80
+ let forceBackupPath;
81
+ if (options.force && projectMemoryExists) {
82
+ try {
83
+ const handle = createBackup({
84
+ storePath: path.join(cwd, storageDir),
85
+ note: 'init --force pre-reconstruction snapshot',
86
+ storeSchemaVersion: existingConfig ? String(existingConfig.schema_version) : null,
87
+ });
88
+ forceBackupPath = handle.backupPath;
89
+ }
90
+ catch (err) {
91
+ const reason = err instanceof BackupError
92
+ ? `${err.code}: ${err.message}`
93
+ : err instanceof Error ? err.message : String(err);
94
+ console.error(`Error: --force backup failed (${reason}). Aborting to preserve the existing store. Re-run without --force, or move the store aside manually.`);
95
+ process.exit(1);
96
+ }
97
+ }
64
98
  const topology = resolveTopology(options.topology, existingConfig?.topology);
65
99
  const ignoreStrategy = resolveIgnoreStrategy(topology, existingConfig?.ignore_strategy);
66
100
  const skipAgentBootstrap = options.skipAgentBootstrap === true || process.env.BRAINCLAW_SKIP_AGENT_BOOTSTRAP === '1';
@@ -87,7 +121,9 @@ export async function runInit(options = {}) {
87
121
  ? registerAgentIdentity({
88
122
  agentName: detectedAi.name,
89
123
  kind: detectedAi.kind,
90
- trustLevel: detectedAi.trust_level,
124
+ // pln#562 step 2 — auto-registration never exceeds contributor;
125
+ // elevation is an explicit curator act (set-trust / register-agent).
126
+ trustLevel: 'contributor',
91
127
  cwd,
92
128
  preferredDirName: storageDir,
93
129
  })
@@ -123,7 +159,15 @@ export async function runInit(options = {}) {
123
159
  storageDir,
124
160
  topology,
125
161
  ignoreStrategy,
126
- existingConfig: options.force ? undefined : existingConfig,
162
+ // --force rebuilds identity (project_id, agent, topology, storage_dir)
163
+ // but merges through existingConfig so curator personalisations
164
+ // (redaction patterns, governance, claims TTL, sensitive_paths,
165
+ // cross_project_links, custom markdown caps) survive the reset.
166
+ // The original `force ? undefined` path wiped these silently —
167
+ // discovered when feedback_no_init_force was promoted from a memory
168
+ // habit to a tracked regression.
169
+ existingConfig,
170
+ defaultJournalMode: !projectMemoryExists,
127
171
  compact: options.compact === true,
128
172
  });
129
173
  if (detectedAi && isAgentIntegrationName(detectedAi.name)) {
@@ -141,6 +185,19 @@ export async function runInit(options = {}) {
141
185
  .filter((hook) => hook.relativePath !== detectedExport?.relativePath))
142
186
  : [];
143
187
  const detectedAutoConfig = detectedAi ? writeDetectedAgentAutoConfig(detectedAi.name, cwd) : [];
188
+ // Per-agent slice of machine prerequisites (the same writes setup performs
189
+ // globally, but scoped to the detected agent). This makes `init` the single
190
+ // entry point for the carte-blanche / fresh-repo case: an agent-first
191
+ // bootstrap no longer needs a separate `brainclaw setup` shell-out + session
192
+ // reload. Idempotent — each ensure* function returns "skipped" when the
193
+ // agent's user-scope config doesn't exist.
194
+ const skipMachinePrereqs = options.skipMachinePrereqs === true
195
+ || skipAgentBootstrap
196
+ || testMode
197
+ || process.env.BRAINCLAW_INIT_SKIP_MACHINE_PREREQS === '1';
198
+ const machinePrereqsWritten = detectedAi && !skipMachinePrereqs
199
+ ? safeRunMachinePrereqs(detectedAi.name)
200
+ : [];
144
201
  // Register in global project registry
145
202
  try {
146
203
  const entry = scanProject(cwd);
@@ -177,7 +234,10 @@ export async function runInit(options = {}) {
177
234
  if (projectMemoryExists) {
178
235
  console.log(`✔ Refreshed existing project memory in ${storageDir}/`);
179
236
  if (options.force) {
180
- console.log('✔ Existing memory preserved; rebuilt managed configuration and agent integration files from defaults');
237
+ if (forceBackupPath) {
238
+ console.log(`✔ Pre-reconstruction backup at ${forceBackupPath} (rollback: brainclaw upgrade --rollback)`);
239
+ }
240
+ console.log('✔ Existing memory preserved; rebuilt managed identity and refreshed agent integration files (customisations merged through)');
181
241
  }
182
242
  else {
183
243
  console.log('✔ Existing memory preserved; refreshed managed configuration and agent integration files');
@@ -192,6 +252,12 @@ export async function runInit(options = {}) {
192
252
  if (registeredAiAgent) {
193
253
  console.log(`✔ AI agent detected: ${registeredAiAgent.agent_name} [${detectedAi.detection_source}] (${registeredAiAgent.agent_id})`);
194
254
  }
255
+ if (machinePrereqsWritten.length > 0) {
256
+ console.log(`\u2714 Machine prerequisites for ${detectedAi.name}:`);
257
+ for (const filePath of machinePrereqsWritten) {
258
+ console.log(` - ${filePath}`);
259
+ }
260
+ }
195
261
  if (detectedExport) {
196
262
  console.log(`\u2714 Agent instructions written to ${detectedExport.relativePath} (${detectedExport.created ? 'created' : 'updated'})`);
197
263
  }
@@ -265,25 +331,36 @@ export async function runInit(options = {}) {
265
331
  // Install post-merge hook for auto-release of claims after merge
266
332
  installPostMergeHookIfMissing(cwd);
267
333
  if (!testMode) {
268
- const onboardingPreflight = runBootstrapProfile({ cwd, refresh: true });
334
+ // Shared empty-memory rule (see docs/concepts/workspace-bootstrapping.md):
335
+ // repo with content → bclaw_bootstrap extraction; greenfield → bootstrap
336
+ // loop. The brownfield preflight scan is skipped on greenfield — there is
337
+ // nothing to harvest yet.
338
+ const emptyMemoryRec = resolveEmptyMemoryRecommendation(cwd);
269
339
  console.log('');
270
- console.log('Onboarding preflight:');
271
- for (const line of renderBootstrapSummary(onboardingPreflight).split('\n')) {
272
- console.log(` ${line}`);
273
- }
274
- if (onboardingPreflight.importPlan.suggestion_count > 0) {
275
- console.log('');
276
- console.log(`Next step: run 'brainclaw bootstrap --apply' to import ${onboardingPreflight.importPlan.suggestion_count} suggested item(s) into canonical memory.`);
277
- console.log(`Rollback: run 'brainclaw bootstrap --uninstall' to deactivate the last bootstrap-managed import.`);
278
- }
279
- if ((onboardingPreflight.importPlan.interview?.question_count ?? 0) > 0) {
280
- console.log('');
281
- console.log(`Interview: run 'brainclaw bootstrap --interview --audience cli' for terminal agents or '--audience ide_chat' for IDE chat agents.`);
282
- console.log(`Apply confirmed answers: write a JSON answers file and run 'brainclaw bootstrap --answers-file <path> --apply'.`);
340
+ if (emptyMemoryRec.route === 'ideate') {
341
+ console.log(`Onboarding: ${emptyMemoryRec.text}`);
283
342
  }
284
- else if ((onboardingPreflight.profile.gaps?.length ?? 0) > 0) {
285
- console.log('');
286
- console.log(`Next step: review the onboarding gaps, then use 'brainclaw bootstrap --json' as the basis for an interview/import flow.`);
343
+ else {
344
+ const onboardingPreflight = runBootstrapProfile({ cwd, refresh: true });
345
+ console.log('Onboarding preflight:');
346
+ console.log(` ${emptyMemoryRec.text}`);
347
+ for (const line of renderBootstrapSummary(onboardingPreflight).split('\n')) {
348
+ console.log(` ${line}`);
349
+ }
350
+ if (onboardingPreflight.importPlan.suggestion_count > 0) {
351
+ console.log('');
352
+ console.log(`Next step: run 'brainclaw bootstrap --apply' to import ${onboardingPreflight.importPlan.suggestion_count} suggested item(s) into canonical memory.`);
353
+ console.log(`Rollback: run 'brainclaw bootstrap --uninstall' to deactivate the last bootstrap-managed import.`);
354
+ }
355
+ if ((onboardingPreflight.importPlan.interview?.question_count ?? 0) > 0) {
356
+ console.log('');
357
+ console.log(`Interview: run 'brainclaw bootstrap --interview --audience cli' for terminal agents or '--audience ide_chat' for IDE chat agents.`);
358
+ console.log(`Apply confirmed answers: write a JSON answers file and run 'brainclaw bootstrap --answers-file <path> --apply'.`);
359
+ }
360
+ else if ((onboardingPreflight.profile.gaps?.length ?? 0) > 0) {
361
+ console.log('');
362
+ console.log(`Next step: review the onboarding gaps, then use 'brainclaw bootstrap --json' as the basis for an interview/import flow.`);
363
+ }
287
364
  }
288
365
  }
289
366
  console.log('');
@@ -293,7 +370,16 @@ export async function runInit(options = {}) {
293
370
  else {
294
371
  console.log(`Tip: run 'brainclaw init' again later to refresh the detected agent's integration files on this project.`);
295
372
  }
296
- console.log(`Tip: run 'brainclaw context --json' to load the shared memory into your agent session.`);
373
+ console.log(`Tip: in an agent session, call the bclaw_work MCP tool (intent: "consult") to load the shared memory; from a terminal, 'brainclaw context --json' does the same.`);
374
+ }
375
+ function safeRunMachinePrereqs(agentName) {
376
+ try {
377
+ return runGlobalInstall([agentName]);
378
+ }
379
+ catch {
380
+ // Non-fatal: machine-scope writes are best-effort, never block init.
381
+ return [];
382
+ }
297
383
  }
298
384
  function installPostMergeHookIfMissing(cwd) {
299
385
  try {
@@ -483,6 +569,12 @@ function buildInitConfig(input) {
483
569
  storageDir: input.storageDir,
484
570
  topology: input.topology,
485
571
  ignoreStrategy: input.ignoreStrategy,
572
+ // Solo-agent fresh default: the human running init is the default curator.
573
+ // Without it, approval_policy=review + curators=[] = every candidate sits
574
+ // in pending forever — a surprise the 2026-06-10 front-door audit flagged.
575
+ // mergeConfigWithDefaults preserves any explicit curators list on an
576
+ // existing store, so this only takes effect on fresh installs.
577
+ curatorName: input.currentAgent.name,
486
578
  });
487
579
  const config = input.existingConfig
488
580
  ? mergeConfigWithDefaults(input.existingConfig, fallbackConfig)
@@ -512,6 +604,16 @@ function buildInitConfig(input) {
512
604
  max_items_per_section: Math.min(markdown.max_items_per_section, 20),
513
605
  };
514
606
  }
607
+ // pln#567 (decision A) — the event journal is ON by default for projects
608
+ // created through init. Set HERE, never in defaultConfig:
609
+ // createTestWorkspace builds its config straight from defaultConfig, so a dual
610
+ // default there would make the whole core suite dual-write (trp_65176454).
611
+ // Existing stores keep their current value, including unset legacy configs:
612
+ // `migrate --enable-journal` is the explicit path that turns them on and
613
+ // backfills genesis before future dual-writes depend on the journal.
614
+ if (input.defaultJournalMode === true && config.store?.journal?.mode === undefined) {
615
+ config.store = { ...config.store, journal: { ...config.store?.journal, mode: 'dual' } };
616
+ }
515
617
  return config;
516
618
  }
517
619
  function mergeConfigWithDefaults(existingConfig, fallbackConfig) {
@@ -284,6 +284,7 @@ export function handleBclawLoop(options) {
284
284
  input: req.input,
285
285
  dispatch: req.dispatch,
286
286
  assignment_id: req.assignment_id,
287
+ claim_id: req.claim_id,
287
288
  actor,
288
289
  }, options.cwd);
289
290
  const newEvents = findNewLoopEvents(loop.id, beforeEvents, options.cwd);
@@ -309,6 +310,9 @@ export function handleBclawLoop(options) {
309
310
  : undefined,
310
311
  actor,
311
312
  caller_agent_id: req.agentId,
313
+ // pln#562 step 4 — a dispatched instance proves itself via its
314
+ // claim env; claim-bound slots reject same-named siblings.
315
+ caller_claim_id: process.env.BRAINCLAW_CLAIM_ID?.trim() || undefined,
312
316
  }, options.cwd);
313
317
  const newEvents = findNewLoopEvents(loop.id, beforeEvents, options.cwd);
314
318
  return successResponse('complete_turn', { loop, next_expected: computeNextExpected(loop) }, [loopArtifactEntry(loop.id), ...loopEventArtifacts(newEvents)], [sideEffectUpdate('loop', loop.id), ...loopEventSideEffects(newEvents)], [], Date.now() - startMs, summarizeLoop(loop));
@@ -1,6 +1,6 @@
1
1
  import { applyBootstrapImport, renderBootstrapInterview, renderBootstrapSummary, runBootstrapProfile, uninstallBootstrapImport } from '../core/bootstrap.js';
2
2
  import { buildAgentToolingContext, renderAgentToolingSummary } from '../core/agent-context.js';
3
- import { buildCoordinationSnapshot } from '../core/coordination.js';
3
+ import { buildCoordinationSnapshot, buildCrossProjectSnapshot } from '../core/coordination.js';
4
4
  import { scanDescendantPlans } from './list-plans.js';
5
5
  import { buildContext } from '../core/context.js';
6
6
  import { buildExecutionContext, renderExecutionContextSummary } from '../core/execution-context.js';
@@ -13,6 +13,7 @@ import { listClaims, assessClaimLiveness } from '../core/claims.js';
13
13
  import { listAssignments } from '../core/assignments.js';
14
14
  import { listAgentRuns } from '../core/agentruns.js';
15
15
  import { reconcileAgentRun } from '../core/agentrun-reconciler.js';
16
+ import { isObserverMode } from '../core/observer-mode.js';
16
17
  import { getDispatchStatus } from '../core/dispatch-status.js';
17
18
  import { listActionRequired } from '../core/actions.js';
18
19
  import { queryRuntimeEvents } from '../core/events.js';
@@ -35,6 +36,8 @@ import { listAvailableProjects, switchProject } from './switch.js';
35
36
  import { resolveEffectiveCwd } from '../core/store-resolution.js';
36
37
  import { resolveProjectCwd } from '../core/cross-project.js';
37
38
  import { readUnseenEvents, buildNotificationSummary } from '../core/event-log.js';
39
+ import { boundListResult, DEFAULT_FIND_CHAR_BUDGET } from '../core/entity-operations.js';
40
+ import { handoffDiffPreviewNote } from '../core/handoff-snapshot.js';
38
41
  import { BootstrapInterviewAnswerSchema, AssignmentStatusSchema, AgentRunStatusSchema, AgentRunTransportSchema, ActionRequiredStatusSchema, ActionRequiredKindSchema } from '../core/schema.js';
39
42
  import { SCHEMA_VERSION, createToolErrorResponse, normaliseFormat, renderContextForMcp, } from './mcp.js';
40
43
  function normalizeBootstrapInterviewAnswersArg(value) {
@@ -60,6 +63,33 @@ function normalizeBootstrapInterviewAudienceArg(value) {
60
63
  }
61
64
  return 'any';
62
65
  }
66
+ /**
67
+ * trp#449 class (pln#542): bound an arbitrary object payload by repeatedly
68
+ * halving its largest array field until the serialized size fits the budget.
69
+ * Returns the bounded payload plus a per-field omitted count so the caller
70
+ * can advertise what was dropped.
71
+ */
72
+ function boundObjectArrays(payload, charBudget) {
73
+ const omitted = {};
74
+ let current = { ...payload };
75
+ while (JSON.stringify(current).length > charBudget) {
76
+ let largestKey;
77
+ let largestLen = 1;
78
+ for (const [key, value] of Object.entries(current)) {
79
+ if (Array.isArray(value) && value.length > largestLen) {
80
+ largestKey = key;
81
+ largestLen = value.length;
82
+ }
83
+ }
84
+ if (!largestKey)
85
+ break;
86
+ const arr = current[largestKey];
87
+ const newLen = Math.max(1, Math.floor(arr.length / 2));
88
+ omitted[largestKey] = (omitted[largestKey] ?? 0) + (arr.length - newLen);
89
+ current = { ...current, [largestKey]: arr.slice(0, newLen) };
90
+ }
91
+ return { payload: current, omitted };
92
+ }
63
93
  function getReviewAssignee(tags) {
64
94
  for (const tag of tags) {
65
95
  if (tag.startsWith('assignee:')) {
@@ -78,12 +108,19 @@ export function handleMcpReadToolCall(name, args = {}, context = {}) {
78
108
  // workspace store-chain children. Throws on unknown project — surfaces
79
109
  // visibly as a tool error rather than silently falling back to the
80
110
  // current project, which would mislead the caller.
111
+ // Precedence rule: once routing succeeds, per-handler "project as filter"
112
+ // logic is skipped (routing already scopes to the right store) — pln#359.
81
113
  const projectArg = args.project;
82
114
  const targetProjectArg = name === 'bclaw_switch' ? undefined : projectArg;
115
+ let projectRoutingApplied = false;
83
116
  if (targetProjectArg) {
84
117
  cwd = resolveProjectCwd(targetProjectArg, cwd);
118
+ projectRoutingApplied = true;
85
119
  }
86
120
  if (name === 'bclaw_get_context') {
121
+ // pln#542: budget_tokens caps the relevance-ranked fill (~4 chars/token).
122
+ // Explicit maxChars wins when both are given.
123
+ const budgetTokens = typeof args.budget_tokens === 'number' && args.budget_tokens > 0 ? args.budget_tokens : undefined;
87
124
  const result = buildContext({
88
125
  target: args.path,
89
126
  project: targetProjectArg,
@@ -93,7 +130,7 @@ export function handleMcpReadToolCall(name, args = {}, context = {}) {
93
130
  profile: args.profile,
94
131
  includePending: args.includePending,
95
132
  maxItems: args.maxItems,
96
- maxChars: args.maxChars,
133
+ maxChars: args.maxChars ?? (budgetTokens ? budgetTokens * 4 : undefined),
97
134
  digest: args.digest,
98
135
  sinceSession: args.since_session,
99
136
  bootstrap: args.bootstrap,
@@ -133,10 +170,22 @@ export function handleMcpReadToolCall(name, args = {}, context = {}) {
133
170
  suggestions.push('\n💡 Tip: Use bclaw_get_capabilities, bclaw_list_tools, or bclaw_search_tools for detailed discovery');
134
171
  enrichedContent = content + suggestions.join('\n');
135
172
  }
136
- // Check for unseen events from other agents
137
- const agentName = args.agent ?? resolveCurrentAgentName(cwd);
138
- const unseenEvents = readUnseenEvents(agentName, cwd);
139
- const notifications = buildNotificationSummary(unseenEvents);
173
+ // Unseen-event notifications. When buildContext already computed the
174
+ // event-cursor diff (the converged novelty mechanism, pln#542), reuse its
175
+ // histogram the cursor was advanced by that read, so a second
176
+ // readUnseenEvents would observe nothing.
177
+ let notifications;
178
+ let unseenEventCount;
179
+ if (result.context_diff?.source === 'event_cursor') {
180
+ notifications = result.context_diff.event_summary;
181
+ unseenEventCount = result.context_diff.unseen_event_count;
182
+ }
183
+ else {
184
+ const agentName = args.agent ?? resolveCurrentAgentName(cwd);
185
+ const unseenEvents = readUnseenEvents(agentName, cwd);
186
+ notifications = buildNotificationSummary(unseenEvents);
187
+ unseenEventCount = unseenEvents.length;
188
+ }
140
189
  return {
141
190
  content: [{ type: 'text', text: enrichedContent || 'No relevant memory found.' }],
142
191
  structuredContent: {
@@ -151,7 +200,7 @@ export function handleMcpReadToolCall(name, args = {}, context = {}) {
151
200
  name: tool.name,
152
201
  type: tool.type,
153
202
  })),
154
- ...(notifications ? { pending_notifications: notifications, unseen_event_count: unseenEvents.length } : {}),
203
+ ...(notifications ? { pending_notifications: notifications, unseen_event_count: unseenEventCount } : {}),
155
204
  },
156
205
  };
157
206
  }
@@ -197,12 +246,38 @@ export function handleMcpReadToolCall(name, args = {}, context = {}) {
197
246
  lines.push(`Suggestion: ${suggestion}`);
198
247
  }
199
248
  }
200
- if (handoff.snapshot?.diff) {
201
- lines.push('', 'Uncommitted Git Diff:', handoff.snapshot.diff);
249
+ // trp#449 class (pln#542): the embedded git diff is unbounded — cap it so
250
+ // a large handoff snapshot never overflows the MCP token budget.
251
+ // budget_tokens tightens the cap (~4 chars/token).
252
+ const handoffBudgetTokens = typeof args.budget_tokens === 'number' && args.budget_tokens > 0 ? args.budget_tokens : undefined;
253
+ const diffCharBudget = handoffBudgetTokens ? Math.min(handoffBudgetTokens * 4, DEFAULT_FIND_CHAR_BUDGET) : DEFAULT_FIND_CHAR_BUDGET;
254
+ let boundedHandoff = handoff;
255
+ let diffTruncated = false;
256
+ if (handoff.snapshot?.diff && handoff.snapshot.diff.length > diffCharBudget) {
257
+ diffTruncated = true;
258
+ boundedHandoff = {
259
+ ...handoff,
260
+ snapshot: {
261
+ ...handoff.snapshot,
262
+ diff: `${handoff.snapshot.diff.slice(0, diffCharBudget)}\n… [diff truncated to ${diffCharBudget} chars — read the worktree branch for the full diff]`,
263
+ },
264
+ };
265
+ }
266
+ if (boundedHandoff.snapshot?.diff) {
267
+ lines.push('', 'Uncommitted Git Diff:', boundedHandoff.snapshot.diff);
268
+ // pln#569 — the inline diff is a capped preview when a digest is present;
269
+ // tell the reader the full diff lives on the worktree branch.
270
+ const note = handoffDiffPreviewNote(boundedHandoff.snapshot);
271
+ if (!diffTruncated && note)
272
+ lines.push(note);
202
273
  }
203
274
  return {
204
275
  content: [{ type: 'text', text: lines.join('\n') }],
205
- structuredContent: { handoff, schema_version: SCHEMA_VERSION },
276
+ structuredContent: {
277
+ handoff: boundedHandoff,
278
+ ...(diffTruncated ? { diff_truncated: true } : {}),
279
+ schema_version: SCHEMA_VERSION,
280
+ },
206
281
  };
207
282
  }
208
283
  if (name === 'bclaw_bootstrap') {
@@ -210,6 +285,23 @@ export function handleMcpReadToolCall(name, args = {}, context = {}) {
210
285
  if (args.apply && args.uninstall) {
211
286
  throw new Error('bclaw_bootstrap does not allow apply and uninstall at the same time.');
212
287
  }
288
+ // Mirror the CLI confirmAction gate: apply/uninstall mutate canonical
289
+ // memory, so they refuse without an explicit yes:true instead of relying
290
+ // on the host honouring the headlessApproval annotation.
291
+ if ((args.apply || args.uninstall) && args.yes !== true) {
292
+ const action = args.uninstall ? 'uninstall' : 'apply';
293
+ return {
294
+ content: [{
295
+ type: 'text',
296
+ text: `bclaw_bootstrap ${action} modifies canonical memory and requires explicit confirmation. Confirm with the user, then re-call with yes: true. No changes were made.`,
297
+ }],
298
+ structuredContent: {
299
+ confirmation_required: true,
300
+ action,
301
+ schema_version: SCHEMA_VERSION,
302
+ },
303
+ };
304
+ }
213
305
  if (args.uninstall) {
214
306
  const result = uninstallBootstrapImport(cwd);
215
307
  const text = !result.receipt
@@ -371,11 +463,34 @@ export function handleMcpReadToolCall(name, args = {}, context = {}) {
371
463
  return !plan || plan.status === 'todo';
372
464
  }).length
373
465
  : 0;
466
+ // pln#559 step 3 — attention_required is now the FULL composite the
467
+ // Attention section already displays in the extension: pending actions
468
+ // + non-auto (human-review) candidates + blocked assignments + stale
469
+ // runs. The 2026-06-10 calibration: the badge previously read only
470
+ // pendingActions and chronically undercounted while the Attention
471
+ // section showed N >> badge. The badge must NEVER be smaller than the
472
+ // section header it represents.
473
+ const pendingCandidates = listCandidates('pending', cwd);
474
+ const pendingHumanCandidates = pendingCandidates.filter((c) => resolvedSource(c) !== 'auto');
475
+ const allAssignments = listAssignments(cwd);
476
+ const blockedAssignments = allAssignments.filter((a) => a.status === 'blocked');
477
+ const allRuns = listAgentRuns(cwd);
478
+ const staleRuns = allRuns.filter((r) => r.status === 'blocked' || r.status === 'waiting_input' || r.status === 'failed');
479
+ const attentionRequiredComposite = pendingActions.length +
480
+ pendingHumanCandidates.length +
481
+ blockedAssignments.length +
482
+ staleRuns.length;
374
483
  const summary = {
375
484
  project_id: config.project_id,
376
485
  agent,
377
486
  current_host: currentHost,
378
- attention_required: pendingActions.length,
487
+ attention_required: attentionRequiredComposite,
488
+ attention_breakdown: {
489
+ pending_actions: pendingActions.length,
490
+ pending_human_candidates: pendingHumanCandidates.length,
491
+ blocked_assignments: blockedAssignments.length,
492
+ stale_runs: staleRuns.length,
493
+ },
379
494
  in_progress: activeClaims.length,
380
495
  plans: {
381
496
  in_progress: state.plan_items.filter((p) => p.status === 'in_progress').length,
@@ -491,9 +606,22 @@ export function handleMcpReadToolCall(name, args = {}, context = {}) {
491
606
  lines.push(`- ${other.name}: ${other.claim_count} claim(s) on ${other.scopes.join(', ')}`);
492
607
  }
493
608
  }
609
+ // trp#449 class (pln#542): the board aggregates many unbounded arrays —
610
+ // bound the structured payload by size. budget_tokens tightens the cap.
611
+ const boardBudgetTokens = typeof args.budget_tokens === 'number' && args.budget_tokens > 0 ? args.budget_tokens : undefined;
612
+ const boardCharBudget = boardBudgetTokens ? Math.min(boardBudgetTokens * 4, DEFAULT_FIND_CHAR_BUDGET) : DEFAULT_FIND_CHAR_BUDGET;
613
+ const { payload: boundedBoard, omitted } = boundObjectArrays(board, boardCharBudget);
494
614
  return {
495
615
  content: [{ type: 'text', text: lines.join('\n') }],
496
- structuredContent: { ...board },
616
+ structuredContent: {
617
+ ...boundedBoard,
618
+ ...(Object.keys(omitted).length > 0
619
+ ? {
620
+ omitted_for_size: omitted,
621
+ hint: `Payload size-bounded: ${Object.entries(omitted).map(([k, n]) => `${n} ${k}`).join(', ')} omitted. Use bclaw_find(entity=…) with filters to read the full lists.`,
622
+ }
623
+ : {}),
624
+ },
497
625
  };
498
626
  }
499
627
  if (name === 'bclaw_search') {
@@ -512,10 +640,29 @@ export function handleMcpReadToolCall(name, args = {}, context = {}) {
512
640
  });
513
641
  const total = allResults.length;
514
642
  const page = allResults.slice(offset, offset + limit);
515
- const lines = page.map((result) => `[${result.id}] (${result.section}) score=${result.score.toFixed(2)}: ${result.text.slice(0, 120)}`);
643
+ // trp#449 class — bound the page by size (pln#542). budget_tokens tightens
644
+ // the cap (~4 chars/token); the default mirrors bclaw_find's budget.
645
+ const budgetTokens = typeof args.budget_tokens === 'number' && args.budget_tokens > 0 ? args.budget_tokens : undefined;
646
+ const charBudget = budgetTokens ? Math.min(budgetTokens * 4, DEFAULT_FIND_CHAR_BUDGET) : DEFAULT_FIND_CHAR_BUDGET;
647
+ const bounded = boundListResult({ entity: 'search_result', total, items: page }, offset, charBudget);
648
+ const lines = bounded.items.map((result) => `[${result.id}] (${result.section}) score=${result.score.toFixed(2)}: ${result.text.slice(0, 120)}`);
649
+ const nextActions = bounded.has_more
650
+ ? [{ tool: 'bclaw_search', args: { query, offset: bounded.next_offset, limit }, when: 'to fetch the next page' }]
651
+ : [];
516
652
  return {
517
- content: [{ type: 'text', text: page.length > 0 ? lines.join('\n') : 'No results found.' }],
518
- structuredContent: { total, offset, limit, results: page },
653
+ content: [{ type: 'text', text: bounded.items.length > 0 ? lines.join('\n') : 'No results found.' }],
654
+ structuredContent: {
655
+ total,
656
+ offset,
657
+ limit,
658
+ results: bounded.items,
659
+ returned: bounded.returned,
660
+ has_more: bounded.has_more,
661
+ ...(bounded.next_offset !== undefined ? { next_offset: bounded.next_offset } : {}),
662
+ ...(bounded.omitted_for_size ? { omitted_for_size: bounded.omitted_for_size } : {}),
663
+ ...(bounded.hint ? { hint: bounded.hint } : {}),
664
+ next_actions: nextActions,
665
+ },
519
666
  };
520
667
  }
521
668
  if (name === 'bclaw_estimation_report') {
@@ -563,7 +710,10 @@ export function handleMcpReadToolCall(name, args = {}, context = {}) {
563
710
  const assignee = String(args.assignee).toLowerCase();
564
711
  plans = plans.filter((plan) => plan.assignee?.toLowerCase() === assignee);
565
712
  }
566
- if (args.project) {
713
+ if (args.project && !projectRoutingApplied) {
714
+ // Only filter by plan.project metadata when routing was NOT applied.
715
+ // When routing succeeded, cwd is already scoped to the target project store,
716
+ // so filtering by plan.project label would incorrectly narrow results.
567
717
  const project = String(args.project).toLowerCase();
568
718
  plans = plans.filter((plan) => plan.project?.toLowerCase() === project);
569
719
  }
@@ -863,22 +1013,27 @@ export function handleMcpReadToolCall(name, args = {}, context = {}) {
863
1013
  // committed but never called bclaw_assignment_update) and surfaces
864
1014
  // delivered_but_unverified for spawns past the 60s grace with no
865
1015
  // life-sign — see runtime_note run_77e65e77 for the empirical case.
866
- try {
867
- if (runId) {
868
- reconcileAgentRun(runId, cwd);
869
- }
870
- else if (assignmentId) {
871
- for (const run of listAgentRuns(cwd, { assignment_id: assignmentId })) {
872
- reconcileAgentRun(run.id, cwd);
1016
+ // Observer mode (BRAINCLAW_OBSERVER=1) suppresses this pre-read sweep —
1017
+ // a dashboard fetching assignment events must not be allowed to transition
1018
+ // agent_run records as a side effect of the read.
1019
+ if (!isObserverMode()) {
1020
+ try {
1021
+ if (runId) {
1022
+ reconcileAgentRun(runId, cwd);
873
1023
  }
874
- }
875
- else if (claimId) {
876
- for (const run of listAgentRuns(cwd, { claim_id: claimId })) {
877
- reconcileAgentRun(run.id, cwd);
1024
+ else if (assignmentId) {
1025
+ for (const run of listAgentRuns(cwd, { assignment_id: assignmentId })) {
1026
+ reconcileAgentRun(run.id, cwd);
1027
+ }
1028
+ }
1029
+ else if (claimId) {
1030
+ for (const run of listAgentRuns(cwd, { claim_id: claimId })) {
1031
+ reconcileAgentRun(run.id, cwd);
1032
+ }
878
1033
  }
879
1034
  }
1035
+ catch { /* defensive: never block events query on reconcile failure */ }
880
1036
  }
881
- catch { /* defensive: never block events query on reconcile failure */ }
882
1037
  let events = queryRuntimeEvents({
883
1038
  ...(id ? { id } : {}),
884
1039
  ...(assignmentId ? { assignment_id: assignmentId } : {}),
@@ -1601,6 +1756,17 @@ export function handleMcpReadToolCall(name, args = {}, context = {}) {
1601
1756
  return handleMcpReadToolCall('bclaw_get_agent_board', args, context);
1602
1757
  case 'board_summary':
1603
1758
  return handleMcpReadToolCall('bclaw_get_agent_board_summary', args, context);
1759
+ case 'cross_project': {
1760
+ // pln#558 step 3 — lightweight endpoint for the VS Code extension's
1761
+ // SYSTEM section: returns linked_projects + incoming_signals only,
1762
+ // so the dashboard no longer pulls the full coordination snapshot
1763
+ // just to render two summary lists.
1764
+ const snap = buildCrossProjectSnapshot(cwd);
1765
+ return {
1766
+ content: [{ type: 'text', text: JSON.stringify(snap, null, 2) }],
1767
+ structuredContent: snap,
1768
+ };
1769
+ }
1604
1770
  case 'delta': {
1605
1771
  const since = args.since;
1606
1772
  if (typeof since !== 'string' || !since) {
@@ -1609,7 +1775,7 @@ export function handleMcpReadToolCall(name, args = {}, context = {}) {
1609
1775
  return handleMcpReadToolCall('bclaw_get_context', { ...args, since_session: since }, context);
1610
1776
  }
1611
1777
  default:
1612
- throw new Error(`bclaw_context: unknown kind '${kind}'. Expected memory | execution | board | board_summary | delta.`);
1778
+ throw new Error(`bclaw_context: unknown kind '${kind}'. Expected memory | execution | board | board_summary | cross_project | delta.`);
1613
1779
  }
1614
1780
  }
1615
1781
  if (name === 'bclaw_get_thread') {
@@ -1635,11 +1801,13 @@ export function handleMcpReadToolCall(name, args = {}, context = {}) {
1635
1801
  }
1636
1802
  const tailLogLines = typeof args.tail_log_lines === 'number' ? args.tail_log_lines : undefined;
1637
1803
  const stallThresholdMs = typeof args.stall_threshold_ms === 'number' ? args.stall_threshold_ms : undefined;
1804
+ const baseRef = typeof args.base_ref === 'string' && args.base_ref ? args.base_ref : undefined;
1638
1805
  const status = getDispatchStatus({
1639
1806
  target_id: targetId,
1640
1807
  cwd,
1641
1808
  tail_log_lines: tailLogLines,
1642
1809
  stall_threshold_ms: stallThresholdMs,
1810
+ base_ref: baseRef,
1643
1811
  });
1644
1812
  // Text view: short, single-screen summary so an agent can decide what to do
1645
1813
  // without parsing the structured payload.
@@ -1658,6 +1826,7 @@ export function handleMcpReadToolCall(name, args = {}, context = {}) {
1658
1826
  `Runtime: pid=${status.runtime.pid ?? '-'} alive=${status.runtime.pid_alive ?? 'unknown'} ack=${status.runtime.ack_file.exists}`,
1659
1827
  ` stdout: ${status.runtime.log_files.stdout?.exists ? `${status.runtime.log_files.stdout.size_bytes}B` : 'absent'}`,
1660
1828
  ` stderr: ${status.runtime.log_files.stderr?.exists ? `${status.runtime.log_files.stderr.size_bytes}B` : 'absent'}`,
1829
+ ` git: commits_ahead=${status.runtime.commits_ahead ?? 'n/a'} dirty_tracked=${status.runtime.dirty_tracked ?? 'n/a'}`,
1661
1830
  ];
1662
1831
  return {
1663
1832
  content: [{ type: 'text', text: lines.join('\n') }],