brainclaw 1.28.2 → 1.28.4

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 (49) hide show
  1. package/README.md +6 -0
  2. package/dist/brainclaw-vscode.vsix +0 -0
  3. package/dist/commands/harvest.js +67 -25
  4. package/dist/commands/loops-handlers.js +28 -1
  5. package/dist/commands/mcp-catalog.js +25 -4
  6. package/dist/commands/mcp-read-handlers.js +15 -1
  7. package/dist/commands/mcp-schemas.generated.js +13 -0
  8. package/dist/commands/mcp-write-coordination.js +97 -26
  9. package/dist/commands/mcp-write-entities.js +5 -2
  10. package/dist/commands/mcp-write-memory.js +87 -1
  11. package/dist/commands/mcp.js +41 -9
  12. package/dist/core/code-map/aggregate.js +20 -7
  13. package/dist/core/code-map/backend.js +17 -7
  14. package/dist/core/code-map/cascade-jobs.js +174 -0
  15. package/dist/core/code-map/cascade-worker.js +15 -0
  16. package/dist/core/code-map/cascade.js +63 -26
  17. package/dist/core/code-map/query.js +6 -3
  18. package/dist/core/context.js +16 -3
  19. package/dist/core/dispatch-status.js +36 -14
  20. package/dist/core/dispatcher.js +28 -20
  21. package/dist/core/entity-operations.js +80 -8
  22. package/dist/core/entity-registry.js +3 -3
  23. package/dist/core/execution-adapters.js +18 -1
  24. package/dist/core/facade-schema.js +10 -0
  25. package/dist/core/ideation-loop-close.js +3 -1
  26. package/dist/core/lane-result-file.js +72 -0
  27. package/dist/core/loop-turn-dispatch.js +2 -0
  28. package/dist/core/loops/brief-assembly.js +19 -11
  29. package/dist/core/loops/next-expected.js +56 -1
  30. package/dist/core/loops/reconcile-turn.js +8 -0
  31. package/dist/core/loops/result-reducers.js +14 -12
  32. package/dist/core/loops/store.js +4 -0
  33. package/dist/core/loops/types.js +14 -2
  34. package/dist/core/loops/verbs.js +8 -1
  35. package/dist/core/loops/worker-reply-contract.js +1 -1
  36. package/dist/core/protocol-tool-policy.js +1 -0
  37. package/dist/core/review-loop-turn-dispatch.js +1 -0
  38. package/dist/core/schema.js +24 -1
  39. package/dist/core/search.js +3 -2
  40. package/dist/core/spawn-check.js +9 -1
  41. package/dist/core/worktree.js +14 -7
  42. package/dist/facts.js +10 -9
  43. package/dist/facts.json +9 -8
  44. package/docs/cli.md +35 -2
  45. package/docs/code-map.md +20 -9
  46. package/docs/concepts/ideation-loop.md +35 -14
  47. package/docs/integrations/mcp.md +15 -5
  48. package/docs/mcp-schema-changelog.md +72 -6
  49. package/package.json +1 -1
@@ -12,6 +12,8 @@
12
12
  * @module
13
13
  */
14
14
  import crypto from 'node:crypto';
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
15
17
  import { spawnSync } from 'node:child_process';
16
18
  import { buildClaimEnvPrefix } from '../core/execution-profile.js';
17
19
  import { resolveProjectCwd } from '../core/cross-project.js';
@@ -21,8 +23,7 @@ import { appendAuditEntry } from '../core/audit.js';
21
23
  import { nowISO } from '../core/ids.js';
22
24
  import { validateMcpField } from '../core/input-validation.js';
23
25
  import { generateCandidateIdWithLabel, saveCandidate } from '../core/candidates.js';
24
- import { DEFAULT_PROTOCOLS } from '../core/loops/types.js';
25
- import { capLoopArtifactBody } from '../core/loops/result-reducers.js';
26
+ import { DEFAULT_PROTOCOLS, LOOP_PROPOSAL_BODY_MAX_BYTES } from '../core/loops/types.js';
26
27
  import { validateLoopProjectResolution } from '../core/loops/project-resolution.js';
27
28
  import { coordinateNextActions, dispatchNextActions } from '../core/next-actions.js';
28
29
  import { agentValidationFailedWarning, consultAutoExecuteNoOpWarning, planAlreadyAssignedWarning, pushStructuredWarning, scopeAlreadyClaimedWarning, } from '../core/warnings.js';
@@ -308,6 +309,25 @@ export async function handleBclawCoordinate(args, ctx) {
308
309
  return { response: createToolErrorResponse('validation_error', parseResult.error.message) };
309
310
  }
310
311
  const req = parseResult.data;
312
+ // A proposal is the caller's task contract. Silently replacing its tail with
313
+ // memory changed the questions workers answered during DGX dogfooding. Keep
314
+ // the whole task or reject before mutation; never truncate it in-band.
315
+ if (req.intent === 'ideate') {
316
+ const taskBytes = Buffer.byteLength(req.task, 'utf8');
317
+ if (taskBytes > LOOP_PROPOSAL_BODY_MAX_BYTES) {
318
+ return {
319
+ response: createToolErrorResponse('ideate_task_too_large', `ideation task is ${taskBytes} bytes; the lossless limit is ${LOOP_PROPOSAL_BODY_MAX_BYTES} bytes. Shorten it or attach a referenced artifact before retrying; no loop was created.`, { task_bytes: taskBytes, task_limit_bytes: LOOP_PROPOSAL_BODY_MAX_BYTES, task_truncated: false }),
320
+ };
321
+ }
322
+ if (req.criticPerspectives) {
323
+ const targetCount = req.targetAgents?.length ?? 0;
324
+ if (targetCount === 0 || req.criticPerspectives.length !== targetCount) {
325
+ return {
326
+ response: createToolErrorResponse('ideate_perspective_count_mismatch', `criticPerspectives must contain exactly one instruction per targetAgents entry (targets=${targetCount}, perspectives=${req.criticPerspectives.length}); no loop was created.`),
327
+ };
328
+ }
329
+ }
330
+ }
311
331
  // pln#511 step 2 — preset selector validation. Presets are kind-
312
332
  // specific in v1: only intent='ideate' carries them. Unknown names
313
333
  // are rejected up-front against the registry so the handler never
@@ -387,9 +407,17 @@ export async function handleBclawCoordinate(args, ctx) {
387
407
  // for state-mutating helpers; the outer `cwd` (source) stays in scope
388
408
  // for the few cases that genuinely need source attribution.
389
409
  const dispatchCwd = resolveProjectCwd(req.project, cwd);
390
- const isCrossProject = dispatchCwd !== cwd;
410
+ const isCrossProject = path.resolve(dispatchCwd) !== path.resolve(cwd);
391
411
  if (isCrossProject && req.autoExecute !== false) {
392
- warnings.push(`cross-project dispatch (project='${req.project}') — auto-spawn disabled; the target agent picks up the brief async via its own bclaw_work.`);
412
+ return {
413
+ response: createToolErrorResponse('cross_project_auto_execute_unsupported', `cross-project dispatch (project='${req.project}') cannot auto-execute from the source process; admission refused before creating a claim, assignment, or loop.`, {
414
+ next_actions: [{
415
+ tool: 'bclaw_coordinate',
416
+ args: { ...req, autoExecute: false },
417
+ when: 'create an inbox-only cross-project assignment that the target agent will pick up with bclaw_work',
418
+ }],
419
+ }),
420
+ };
393
421
  }
394
422
  const effectiveAutoExecute = isCrossProject ? false : req.autoExecute;
395
423
  // pln#692 P0 — an explicit checkout ref is part of admission, not worktree
@@ -418,8 +446,9 @@ export async function handleBclawCoordinate(args, ctx) {
418
446
  // pln#692 P0 — admission must prove that a multi-agent ideation request can
419
447
  // satisfy the first worker-produced phase gate BEFORE openLoop (or any
420
448
  // identity/claim/assignment mutation). The default critique phase requires
421
- // three distinct critique artifacts, so accepting one executable critic
422
- // creates a loop that cannot converge without manual artifact injection.
449
+ // three distinct critique artifacts. Capacity is therefore counted per
450
+ // requested critic INSTANCE, not per unique agent identity: each occurrence
451
+ // becomes an isolated slot with its own claim, worktree and turn authority.
423
452
  if (req.intent === 'ideate'
424
453
  && req.preset !== 'bootstrap'
425
454
  && Array.isArray(req.targetAgents)
@@ -427,23 +456,27 @@ export async function handleBclawCoordinate(args, ctx) {
427
456
  const critiquePhase = DEFAULT_PROTOCOLS.ideation.phases.find((phase) => phase.name === 'critique');
428
457
  const gate = critiquePhase?.advance_gate;
429
458
  const requiredCritics = gate?.kind === 'min_artifacts_by_type' ? gate.n : 0;
430
- const uniqueTargets = [...new Set(req.targetAgents)];
431
- const checks = uniqueTargets.map((agent) => ({
459
+ const checks = req.targetAgents.map((agent, instanceIndex) => ({
432
460
  agent,
461
+ instanceIndex,
433
462
  check: validateAgentForDispatch(agent, { requireSpawnable: true }),
434
463
  }));
435
464
  const executableTargets = checks.filter(({ check }) => check.valid).map(({ agent }) => agent);
436
- const invalidTargets = checks.filter(({ check }) => !check.valid).map(({ agent, check }) => ({
465
+ const invalidTargets = checks.filter(({ check }) => !check.valid).map(({ agent, instanceIndex, check }) => ({
437
466
  agent,
467
+ instance_index: instanceIndex,
438
468
  code: check.code,
439
469
  reason: check.reason,
440
470
  }));
441
471
  if (requiredCritics > 0 && executableTargets.length < requiredCritics) {
442
472
  const availableTargets = getSpawnableAgents()
443
473
  .map((profile) => profile.name)
444
- .filter((agent, index, all) => agent !== senderAgent && all.indexOf(agent) === index)
474
+ .filter((agent, index, all) => all.indexOf(agent) === index)
445
475
  .filter((agent) => validateAgentForDispatch(agent, { requireSpawnable: true }).valid);
446
- const recoveryTargets = availableTargets.slice(0, requiredCritics);
476
+ const recoveryAgent = executableTargets[0] ?? availableTargets[0];
477
+ const recoveryTargets = recoveryAgent
478
+ ? Array.from({ length: requiredCritics }, () => recoveryAgent)
479
+ : [];
447
480
  const nextActions = recoveryTargets.length >= requiredCritics
448
481
  ? [{
449
482
  tool: 'bclaw_coordinate',
@@ -451,21 +484,20 @@ export async function handleBclawCoordinate(args, ctx) {
451
484
  intent: 'ideate', task: req.task, scope: req.scope,
452
485
  targetAgents: recoveryTargets, autoExecute: effectiveAutoExecute !== false,
453
486
  },
454
- when: `retry with at least ${requiredCritics} distinct executable critics`,
487
+ when: `retry with at least ${requiredCritics} executable critic instances`,
455
488
  }]
456
489
  : [{
457
490
  tool: 'bclaw_context',
458
491
  args: { kind: 'execution', includeAgentTooling: true },
459
- when: `configure at least ${requiredCritics} spawnable critic identities before retrying`,
492
+ when: 'configure at least one spawnable critic identity before retrying',
460
493
  }];
461
494
  return {
462
- response: createToolErrorResponse('ideate_gate_capacity_unavailable', `ideation admission refused before mutation: critique gate requires ${requiredCritics} distinct executable critic(s), observed ${executableTargets.length}`, {
495
+ response: createToolErrorResponse('ideate_gate_capacity_unavailable', `ideation admission refused before mutation: critique gate requires ${requiredCritics} executable critic instance(s), observed ${executableTargets.length}`, {
463
496
  gate: { phase: 'critique', kind: gate?.kind, expected: requiredCritics, observed: executableTargets.length },
464
497
  requested_targets: req.targetAgents,
465
498
  executable_targets: executableTargets,
466
499
  invalid_targets: invalidTargets,
467
500
  blockers: [
468
- ...(uniqueTargets.length < req.targetAgents.length ? ['duplicate target identities do not add executable capacity'] : []),
469
501
  ...(invalidTargets.length > 0 ? ['one or more requested targets are not spawnable'] : []),
470
502
  `missing executable critic capacity: ${requiredCritics - executableTargets.length}`,
471
503
  ],
@@ -729,6 +761,18 @@ export async function handleBclawCoordinate(args, ctx) {
729
761
  contextEnvelope: options?.contextEnvelope,
730
762
  });
731
763
  };
764
+ const compactDeliveryEntry = (entry) => {
765
+ if (!entry.command || entry.command.length <= 2048)
766
+ return entry;
767
+ const dir = path.join(dispatchCwd, '.brainclaw', 'coordination', 'runtime', 'manual-commands');
768
+ fs.mkdirSync(dir, { recursive: true });
769
+ const ext = entry.shell === 'cmd' ? 'cmd' : 'sh';
770
+ const ref = entry.assignment_id ?? entry.message_id;
771
+ const commandFile = path.join(dir, `${ref}.${ext}`);
772
+ fs.writeFileSync(commandFile, entry.command, { encoding: 'utf8', mode: 0o600 });
773
+ const { command, ...rest } = entry;
774
+ return { ...rest, command_file: commandFile, command_bytes: Buffer.byteLength(command, 'utf8') };
775
+ };
732
776
  const toMessageSummary = (deliveryPlan) => deliveryPlan.map((entry) => ({
733
777
  agent: entry.agent,
734
778
  message_id: entry.message_id,
@@ -1008,7 +1052,7 @@ export async function handleBclawCoordinate(args, ctx) {
1008
1052
  // existing length===0 guard skips loop creation. Skipped when open_loop
1009
1053
  // is off, preflight=false, or BRAINCLAW_NO_SPAWN is set (handled inside
1010
1054
  // preflightAgents). Cross-project dispatch never auto-spawns, so skip.
1011
- if (req.open_loop === true && req.preflight !== false && !req.project && loopReviewerAgents.length > 0) {
1055
+ if (req.open_loop === true && req.preflight !== false && !isCrossProject && loopReviewerAgents.length > 0) {
1012
1056
  try {
1013
1057
  const { preflightAgents } = await import('../core/spawn-check.js');
1014
1058
  const pf = await preflightAgents(loopReviewerAgents, { cwd: dispatchCwd });
@@ -1158,7 +1202,7 @@ export async function handleBclawCoordinate(args, ctx) {
1158
1202
  // (that is the double-spawn hole), and do NOT release the (possibly shared) claim; leave
1159
1203
  // the slot for reconcile/self-heal. LEGACY: the unchanged inline mint runs.
1160
1204
  let usedTurnOwned = false;
1161
- if (turnOwnedReviewEnabled() && !req.project) {
1205
+ if (turnOwnedReviewEnabled() && !isCrossProject) {
1162
1206
  const prep = prepareTurnOwnedReviewDispatch({
1163
1207
  loopId: loop.id,
1164
1208
  slotId: slot.slot_id,
@@ -1837,11 +1881,19 @@ export async function handleBclawCoordinate(args, ctx) {
1837
1881
  },
1838
1882
  ];
1839
1883
  if (explicitTargets) {
1840
- for (const agent of req.targetAgents) {
1884
+ const defaultPerspectives = [
1885
+ 'Challenge assumptions and verify the proposal against concrete evidence.',
1886
+ 'Focus on failure modes, operational risks, and recovery paths; challenge earlier contributions explicitly.',
1887
+ 'Develop competing alternatives and compare their costs and trade-offs; resolve or sharpen earlier disagreements.',
1888
+ ];
1889
+ for (const [index, agent] of req.targetAgents.entries()) {
1841
1890
  const criticIdentity = findAgentIdentityByName(agent, dispatchCwd) ?? ensureAgentRegisteredForDispatch(agent, dispatchCwd);
1842
1891
  slots.push({
1843
1892
  role: 'critic',
1844
1893
  agent,
1894
+ perspective: req.criticPerspectives?.[index]
1895
+ ?? defaultPerspectives[index]
1896
+ ?? `Challenge the conversation from an independent perspective ${index + 1}; avoid repeating prior contributions.`,
1845
1897
  ...(criticIdentity?.agent_id ? { agent_id: criticIdentity.agent_id } : {}),
1846
1898
  });
1847
1899
  }
@@ -1869,7 +1921,12 @@ export async function handleBclawCoordinate(args, ctx) {
1869
1921
  stop_condition: presetSelected.stop_condition,
1870
1922
  protocol: presetSelected.protocol,
1871
1923
  }
1872
- : {}),
1924
+ : {
1925
+ protocol: {
1926
+ iteration: DEFAULT_PROTOCOLS.ideation.iteration,
1927
+ ideation_schedule: req.ideation_schedule,
1928
+ },
1929
+ }),
1873
1930
  }, dispatchCwd);
1874
1931
  loopId = loop.id;
1875
1932
  artifacts.push({ type: 'loop', id: loop.id });
@@ -1883,14 +1940,13 @@ export async function handleBclawCoordinate(args, ctx) {
1883
1940
  // loop doesn't contain. The task text is already captured on
1884
1941
  // the thread (title + goal).
1885
1942
  if (!presetSelected) {
1886
- const proposalBody = capLoopArtifactBody(req.task);
1887
1943
  const updated = add_artifact({
1888
1944
  id: loop.id,
1889
1945
  actor: creatorActor,
1890
1946
  artifact: {
1891
1947
  phase: 'proposal',
1892
1948
  type: 'proposal',
1893
- body: proposalBody,
1949
+ body: req.task,
1894
1950
  produced_by: creatorActor,
1895
1951
  },
1896
1952
  }, dispatchCwd);
@@ -1909,8 +1965,11 @@ export async function handleBclawCoordinate(args, ctx) {
1909
1965
  };
1910
1966
  }
1911
1967
  } // end else (non-bootstrap open path)
1912
- // pln#492 phase 2.d.2 multi-agent dispatch. Skipped in single-
1913
- // agent mode (the champion drives manually).
1968
+ // Multi-agent ideation keeps artifact capacity separate from execution
1969
+ // concurrency. All requested critic slots are durable, but sequential is
1970
+ // the default scheduling policy: only the first open slot is dispatched
1971
+ // now, and the next one is taken after this result is harvested. Explicit
1972
+ // parallel mode retains the historical immediate fan-out.
1914
1973
  //
1915
1974
  // pln#511 step 2 — initial phase comes from the actual loop's
1916
1975
  // first phase, not a hardcoded 'proposal'. Presets like bootstrap
@@ -1968,7 +2027,10 @@ export async function handleBclawCoordinate(args, ctx) {
1968
2027
  throw new Error('ideate dispatch: loop disappeared after advance');
1969
2028
  }
1970
2029
  dispatchedPhase = advancedLoop.current_phase;
1971
- const criticSlots = advancedLoop.slots.filter((s) => s.role === 'critic');
2030
+ const allCriticSlots = advancedLoop.slots.filter((s) => s.role === 'critic');
2031
+ const criticSlots = req.ideation_schedule === 'parallel'
2032
+ ? allCriticSlots
2033
+ : allCriticSlots.slice(0, 1);
1972
2034
  for (const slot of criticSlots) {
1973
2035
  if (!slot.agent)
1974
2036
  continue;
@@ -1988,7 +2050,10 @@ export async function handleBclawCoordinate(args, ctx) {
1988
2050
  const briefResult = buildIdeationBrief({
1989
2051
  thread: advancedLoop,
1990
2052
  slotRole: slot.role,
2053
+ slotPerspective: slot.perspective,
1991
2054
  memoryProvider: provider,
2055
+ seedText: req.task,
2056
+ scopeHints: req.scope ? [req.scope] : [],
1992
2057
  });
1993
2058
  // pln#626 Phase 2 (Option B) — spawn the critic as a worktree-isolated
1994
2059
  // worker, mirroring the intent=assign / review chain. Each critic gets
@@ -2000,7 +2065,7 @@ export async function handleBclawCoordinate(args, ctx) {
2000
2065
  // stray edit harmless (it lands in the throwaway checkout, not master).
2001
2066
  const criticScope = `ideate-loop:${loopId}:${slot.slot_id}`;
2002
2067
  const criticDescription = `Ideation critic turn for loop ${loopId} slot ${slot.slot_id} (phase ${advancedLoop.current_phase}). `
2003
- + `Critique the proposal and reply with your critique — do not edit code. ${req.task}`;
2068
+ + `Critique proposal artifact ${proposalArtifactId} and reply with evidence — do not edit code.`;
2004
2069
  try {
2005
2070
  const claimResult = createCoordinatorClaim({
2006
2071
  agent: slot.agent,
@@ -2201,9 +2266,15 @@ export async function handleBclawCoordinate(args, ctx) {
2201
2266
  proposal_artifact_id: proposalArtifactId,
2202
2267
  selected_targets: explicitTargets ? req.targetAgents : [],
2203
2268
  mode: explicitTargets ? 'multi_agent' : 'single_agent',
2269
+ ...(explicitTargets ? {
2270
+ ideation_schedule: req.ideation_schedule,
2271
+ pending_critics: Math.max(0, req.targetAgents.length - dispatchedCritics),
2272
+ } : {}),
2204
2273
  dispatched_critics: dispatchedCritics,
2205
2274
  current_phase: dispatchedPhase,
2206
- delivery_plan: preparedCritics.map((p) => p.entry),
2275
+ task_bytes: Buffer.byteLength(req.task, 'utf8'),
2276
+ task_truncated: false,
2277
+ delivery_plan: preparedCritics.map((p) => compactDeliveryEntry(p.entry)),
2207
2278
  ...(ideateExecStatus
2208
2279
  ? { execution_status: ideateExecStatus }
2209
2280
  : explicitTargets
@@ -479,9 +479,12 @@ export function handleBclawCreate(payload, ctx) {
479
479
  const result = createEntity(entity, data, targetCwd);
480
480
  appendAuditEntry({ actor: actor ?? 'unknown', ...(actorId ? { actor_id: actorId } : {}), action: 'create', item_id: result.id, item_type: entity }, targetCwd);
481
481
  const createText = `✔ created ${entity} ${result.id}${autoSwitched ? ` (auto-switched → ${targetScope.resolved_project.name ?? targetScope.resolved_project.path})` : ''}`;
482
+ const proximityText = result.nearby_items?.length
483
+ ? `Nearby existing ${entity} item(s): ${result.nearby_items.map((item) => `${item.id} (${item.reason})`).join(', ')}. Creation was kept; review before adding another duplicate.`
484
+ : undefined;
482
485
  const createContent = autoRepair
483
- ? [{ type: 'text', text: createText }, { type: 'text', text: renderAutoRepairWarning(autoRepair, actor ?? 'unknown') }]
484
- : [{ type: 'text', text: createText }];
486
+ ? [{ type: 'text', text: createText }, { type: 'text', text: renderAutoRepairWarning(autoRepair, actor ?? 'unknown') }, ...(proximityText ? [{ type: 'text', text: proximityText }] : [])]
487
+ : [{ type: 'text', text: createText }, ...(proximityText ? [{ type: 'text', text: proximityText }] : [])];
485
488
  // pln#634 — a freshly created plan whose steps are never added is the most
486
489
  // common half-finished shape in the store; a sequence with no readiness
487
490
  // check is the second. Only those two emit a follow-up.
@@ -20,7 +20,8 @@ import { deleteMemoryItem, updateMemoryItem } from '../core/operations/memory-mu
20
20
  import { assessMemoryPressure, buildCompactionTemplate, applyCompaction } from '../core/gc-semantic.js';
21
21
  import { createRuntimeNote } from './runtime-note.js';
22
22
  import { createCandidateFromInput } from './reflect.js';
23
- import { harvestCandidates } from './harvest.js';
23
+ import { harvestCandidates, harvestLaneResults, integrateLaneResults } from './harvest.js';
24
+ import { dispatchReviewLoopTurn } from '../core/review-loop-turn-dispatch.js';
24
25
  import { ensureTrust, scanMcpWriteText, appendSecurityWarnings } from './mcp-write-support.js';
25
26
  import { toolResponse, createToolErrorResponse, } from './mcp-contract.js';
26
27
  function scoreKeywordMatches(text, patterns) {
@@ -448,4 +449,89 @@ export function handleBclawHarvestCandidates(payload, _ctx) {
448
449
  }),
449
450
  };
450
451
  }
452
+ /** MCP parity for the CLI lane-result harvest path (distinct from candidates). */
453
+ export async function handleBclawHarvestLane(payload) {
454
+ const { args, cwd, connectionSessionId } = payload;
455
+ const resolved = ensureTrust(args, { nameField: 'agent', idField: 'agentId' }, 'trusted', cwd, connectionSessionId);
456
+ if (resolved.error) {
457
+ return { response: createToolErrorResponse(resolved.error.kind, resolved.error.message, resolved.error.details) };
458
+ }
459
+ const assignmentId = typeof args.assignmentId === 'string' ? args.assignmentId : undefined;
460
+ const all = args.all === true;
461
+ if (!assignmentId && !all) {
462
+ return { response: createToolErrorResponse('validation_error', 'Provide assignmentId, or set all=true to scan every managed lane.') };
463
+ }
464
+ if (assignmentId && all) {
465
+ return { response: createToolErrorResponse('validation_error', 'assignmentId and all=true are mutually exclusive.') };
466
+ }
467
+ const worktreePaths = Array.isArray(args.worktreePaths) ? args.worktreePaths.filter((value) => typeof value === 'string') : undefined;
468
+ const dryRun = args.dryRun === true;
469
+ const actor = resolved.identity.agent_name;
470
+ if (args.integrate === true) {
471
+ const integrated = integrateLaneResults({ assignmentId, worktreePaths, dryRun, cwd, agent: actor });
472
+ const dispatchedTurns = [];
473
+ if (!dryRun) {
474
+ for (const next of integrated.next_turns) {
475
+ const dispatched = await dispatchReviewLoopTurn({
476
+ loopId: next.loop_id,
477
+ slot: { slot_id: next.slot_id, role: next.role, agent: next.agent, agent_id: next.agent_id },
478
+ phase: next.phase,
479
+ task: next.task,
480
+ dispatcherAgent: actor,
481
+ dispatcherAgentId: resolved.identity.agent_id,
482
+ cwd,
483
+ });
484
+ dispatchedTurns.push({
485
+ loop_id: next.loop_id,
486
+ agent: next.agent,
487
+ iteration: next.iteration,
488
+ execution_status: dispatched.execution_status,
489
+ error: dispatched.error,
490
+ });
491
+ }
492
+ }
493
+ return {
494
+ response: toolResponse({
495
+ content: [{ type: 'text', text: `✔ Lane integrate${dryRun ? ' (dry-run)' : ''}: ${integrated.integrated.length} integrated, ${dispatchedTurns.length} re-dispatched, ${integrated.errors.length} error(s).` }],
496
+ ...integrated,
497
+ dispatched_turns: dispatchedTurns,
498
+ dry_run: dryRun,
499
+ }),
500
+ };
501
+ }
502
+ const harvested = harvestLaneResults({ assignmentId, worktreePaths, dryRun, cwd, agent: actor });
503
+ const continuationActions = harvested.continuations.flatMap((continuation) => {
504
+ const next = continuation.next_expected;
505
+ if (!next)
506
+ return [];
507
+ if (next.action === 'turn' && next.slot_id) {
508
+ return [{
509
+ tool: 'bclaw_loop',
510
+ args: { intent: 'turn', loop_id: continuation.loop_id, slot_id: next.slot_id, dispatch: true },
511
+ when: next.reason ?? 'dispatch the next sequential loop participant',
512
+ }];
513
+ }
514
+ if (next.action === 'advance') {
515
+ return [{ tool: 'bclaw_loop', args: { intent: 'advance', loop_id: continuation.loop_id }, when: 'the current phase gate is satisfied' }];
516
+ }
517
+ return [{ tool: 'bclaw_loop', args: { intent: 'get', loop_id: continuation.loop_id }, when: next.reason ?? `inspect the expected ${next.action} action` }];
518
+ });
519
+ return {
520
+ response: toolResponse({
521
+ content: [{ type: 'text', text: `✔ Lane harvest${dryRun ? ' (dry-run)' : ''}: ${harvested.harvested.length} harvested, ${harvested.skipped.length} skipped, ${harvested.errors.length} error(s), ${harvested.warnings.length} warning(s).` }],
522
+ structuredContent: {
523
+ harvested: harvested.harvested,
524
+ skipped: harvested.skipped,
525
+ errors: harvested.errors,
526
+ warnings: harvested.warnings,
527
+ continuations: harvested.continuations,
528
+ dry_run: dryRun,
529
+ next_actions: [
530
+ ...continuationActions,
531
+ ...harvested.warnings.flatMap((warning) => warning.next_actions ?? []),
532
+ ],
533
+ },
534
+ }),
535
+ };
536
+ }
451
537
  //# sourceMappingURL=mcp-write-memory.js.map
@@ -48,7 +48,7 @@ import { handleBclawClaim, handleBclawReleaseClaim, handleBclawSessionStart, han
48
48
  // Sequence write handlers extracted in pln#622 PR4.
49
49
  import { handleBclawCreateSequence, handleBclawUpdateSequence, handleBclawDeleteSequence, } from './mcp-write-sequences.js';
50
50
  // Memory write handlers extracted in pln#622 PR4.
51
- import { handleBclawWriteNote, handleBclawQuickCapture, handleBclawCompact, handleBclawDeleteMemory, handleBclawUpdateMemory, handleBclawHarvestCandidates, } from './mcp-write-memory.js';
51
+ import { handleBclawWriteNote, handleBclawQuickCapture, handleBclawCompact, handleBclawDeleteMemory, handleBclawUpdateMemory, handleBclawHarvestCandidates, handleBclawHarvestLane, } from './mcp-write-memory.js';
52
52
  // Admin / provisioning write handlers extracted in pln#622 PR4.
53
53
  import { handleBclawSetup, handleBclawInitProject, handleBclawAddCapability, handleBclawAddTool, } from './mcp-write-admin.js';
54
54
  // Entity write handlers extracted in pln#622 PR4.
@@ -1096,8 +1096,10 @@ async function _executeMcpToolCallInner(payload) {
1096
1096
  if (name === 'bclaw_code_status' || name === 'bclaw_code_find' || name === 'bclaw_code_brief' || name === 'bclaw_code_impact' || name === 'bclaw_code_export' || name === 'bclaw_code_outline' || name === 'bclaw_code_refresh') {
1097
1097
  const { JsonlBackend } = await import('../core/code-map/backend.js');
1098
1098
  const be = new JsonlBackend();
1099
+ // Session-scoped project selection is authoritative for Code Map too.
1100
+ const codeCwd = scopeInfo.cwd;
1099
1101
  if (name === 'bclaw_code_status') {
1100
- const status = await be.status({ cwd, cascade: args.cascade === true });
1102
+ const status = await be.status({ cwd: codeCwd, cascade: args.cascade === true });
1101
1103
  const diskVersion = readDiskBrainclawVersion();
1102
1104
  return {
1103
1105
  response: toolResponse({
@@ -1118,7 +1120,23 @@ async function _executeMcpToolCallInner(payload) {
1118
1120
  }
1119
1121
  if (name === 'bclaw_code_refresh') {
1120
1122
  const scope = args.scope === 'all' ? 'all' : 'changed';
1121
- const result = await be.refresh({ scope, cwd, cascade: args.cascade === true });
1123
+ if (args.cascade === true) {
1124
+ const { startCascadeRefreshJob, summarizeCascadeRefreshJob } = await import('../core/code-map/cascade-jobs.js');
1125
+ const job = startCascadeRefreshJob(codeCwd, scope);
1126
+ if (job) {
1127
+ return {
1128
+ response: toolResponse({
1129
+ content: [{ type: 'text', text: `Code Map cascade started: job=${job.job_id}, projects=${job.projects_total}. Follow with bclaw_code_status(cascade=true).` }],
1130
+ structuredContent: {
1131
+ started: true,
1132
+ ...summarizeCascadeRefreshJob(job),
1133
+ next_actions: [{ tool: 'bclaw_code_status', args: { cascade: true }, when: 'follow progress and terminal per-project diagnostics' }],
1134
+ },
1135
+ }),
1136
+ };
1137
+ }
1138
+ }
1139
+ const result = await be.refresh({ scope, cwd: codeCwd, cascade: args.cascade === true });
1122
1140
  const cascadeNote = result.cascade ? ` cascade=${result.cascade.children_refreshed} child(ren)+root` : '';
1123
1141
  return {
1124
1142
  response: toolResponse({
@@ -1133,7 +1151,7 @@ async function _executeMcpToolCallInner(payload) {
1133
1151
  return { response: createToolErrorResponse('validation_error', 'bclaw_code_find requires a non-empty query.') };
1134
1152
  }
1135
1153
  const limit = typeof args.limit === 'number' ? args.limit : undefined;
1136
- const result = await be.find({ query, limit, cwd });
1154
+ const result = await be.find({ query, limit, cwd: codeCwd });
1137
1155
  return {
1138
1156
  response: toolResponse({
1139
1157
  content: [{ type: 'text', text: `Code Map find "${result.query}": ${result.matches.length} match(es), freshness=${result.freshness_badge.freshness}` }],
@@ -1154,7 +1172,7 @@ async function _executeMcpToolCallInner(payload) {
1154
1172
  const maxEdges = typeof args.maxEdges === 'number' ? args.maxEdges : undefined;
1155
1173
  const minConfidence = typeof args.minConfidence === 'number' ? args.minConfidence : undefined;
1156
1174
  const format = args.format === 'mermaid' ? 'mermaid' : args.format === 'json' ? 'json' : undefined;
1157
- const result = await be.exportGraph({ target, targetKind, direction, depth, maxNodes, maxEdges, minConfidence, format, cwd });
1175
+ const result = await be.exportGraph({ target, targetKind, direction, depth, maxNodes, maxEdges, minConfidence, format, cwd: codeCwd });
1158
1176
  return {
1159
1177
  response: toolResponse({
1160
1178
  content: [{ type: 'text', text: `Code Map export "${result.target}": ${result.nodes.length} node(s), ${result.edges.length} edge(s), depth=${result.limits.max_depth}, freshness=${result.freshness_badge.freshness}` }],
@@ -1170,7 +1188,7 @@ async function _executeMcpToolCallInner(payload) {
1170
1188
  }
1171
1189
  const depth = typeof args.depth === 'number' ? args.depth : undefined;
1172
1190
  const limit = typeof args.limit === 'number' ? args.limit : undefined;
1173
- const result = await be.impact({ target, depth, limit, cwd });
1191
+ const result = await be.impact({ target, depth, limit, cwd: codeCwd });
1174
1192
  return {
1175
1193
  response: toolResponse({
1176
1194
  content: [{ type: 'text', text: `Code Map impact "${result.target}": ${result.risk.counters.direct_dependents} direct, ${result.risk.counters.transitive_dependents} transitive dependent(s), risk=${result.risk.score}, freshness=${result.freshness_badge.freshness}` }],
@@ -1185,7 +1203,7 @@ async function _executeMcpToolCallInner(payload) {
1185
1203
  return { response: createToolErrorResponse('validation_error', 'bclaw_code_outline requires a non-empty path.') };
1186
1204
  }
1187
1205
  const limit = typeof args.limit === 'number' ? args.limit : undefined;
1188
- const result = await be.outline({ path: outlinePath, limit, cwd });
1206
+ const result = await be.outline({ path: outlinePath, limit, cwd: codeCwd });
1189
1207
  return {
1190
1208
  response: toolResponse({
1191
1209
  content: [{ type: 'text', text: `Code Map outline "${result.path}": ${result.symbols.length}/${result.symbol_count} symbol(s), index=${result.index_status}, freshness=${result.freshness_badge.freshness}` }],
@@ -1199,7 +1217,7 @@ async function _executeMcpToolCallInner(payload) {
1199
1217
  return { response: createToolErrorResponse('validation_error', 'bclaw_code_brief requires a non-empty target.') };
1200
1218
  }
1201
1219
  const limit = typeof args.limit === 'number' ? args.limit : undefined;
1202
- const result = await be.brief({ target, limit, cwd });
1220
+ const result = await be.brief({ target, limit, cwd: codeCwd });
1203
1221
  return {
1204
1222
  response: toolResponse({
1205
1223
  content: [{ type: 'text', text: `Code Map brief "${result.target}": ${result.suggested_files_to_read.length} file(s) to read, freshness=${result.freshness_badge.freshness}` }],
@@ -1718,6 +1736,9 @@ async function _executeMcpToolCallInner(payload) {
1718
1736
  if (name === 'bclaw_harvest_candidates') {
1719
1737
  return handleBclawHarvestCandidates(payload, writeMemoryCtx);
1720
1738
  }
1739
+ if (name === 'bclaw_harvest') {
1740
+ return await handleBclawHarvestLane(payload);
1741
+ }
1721
1742
  // ── Canonical CRUD verbs (Phase 3 slice 3b) ──────────────────────
1722
1743
  //
1723
1744
  // Thin wrappers around src/core/entity-operations.ts. Behind
@@ -1817,6 +1838,17 @@ async function _executeMcpToolCallInner(payload) {
1817
1838
  };
1818
1839
  }
1819
1840
  const result = listEntities(entity, targetCwd, filter);
1841
+ const requestedFields = Array.isArray(args.fields)
1842
+ ? args.fields.filter((field) => typeof field === 'string' && field.length > 0)
1843
+ : [];
1844
+ if (requestedFields.length > 0) {
1845
+ result.items = result.items.map((item) => {
1846
+ if (!item || typeof item !== 'object')
1847
+ return item;
1848
+ const row = item;
1849
+ return Object.fromEntries(requestedFields.filter((field) => row[field] !== undefined).map((field) => [field, row[field]]));
1850
+ });
1851
+ }
1820
1852
  // pln#491 — bound the payload (count is already capped by applyPaging;
1821
1853
  // this caps SIZE) so a verbose result set never overflows the MCP token
1822
1854
  // cap and silently pushes the agent to the CLI (trp#449). Advertises
@@ -1832,7 +1864,7 @@ async function _executeMcpToolCallInner(payload) {
1832
1864
  { tool: 'bclaw_get', args: { entity, id: '<id from items>', ...(args.project ? { project: args.project } : {}), ...(args.budget_tokens ? { budget_tokens: args.budget_tokens } : {}) }, when: 'to read one item in full' },
1833
1865
  ];
1834
1866
  if (bounded.has_more) {
1835
- nextActions.push({ tool: 'bclaw_find', args: { entity, filter: { ...filter, offset: bounded.next_offset }, ...(args.project ? { project: args.project } : {}), ...(args.budget_tokens ? { budget_tokens: args.budget_tokens } : {}) }, when: 'to fetch the next page' });
1867
+ nextActions.push({ tool: 'bclaw_find', args: { entity, filter: { ...filter, offset: bounded.next_offset }, ...(requestedFields.length ? { fields: requestedFields } : {}), ...(args.project ? { project: args.project } : {}), ...(args.budget_tokens ? { budget_tokens: args.budget_tokens } : {}) }, when: 'to fetch the next page' });
1836
1868
  }
1837
1869
  // structuredContent is the canonical MCP return channel that clients
1838
1870
  // (VS Code extension, Codex, etc.) read for machine-parseable data.
@@ -183,10 +183,9 @@ function statusRank(s) {
183
183
  }
184
184
  }
185
185
  /**
186
- * Merge per-store badges into one workspace badge: worst status among the INDEXED
187
- * stores (a missing-index child contributes to coverage, never drags the top-line),
188
- * plus coverage + workspace-relative detail path-sets. Only when EVERY store is
189
- * un-indexed is the whole workspace `missing_index`.
186
+ * Merge per-store badges into one workspace badge. Missing child indexes are a
187
+ * PARTIAL workspace, never a fresh one: serving the indexed subset is useful,
188
+ * but the top-line signal must describe the coverage actually searched.
190
189
  */
191
190
  function mergeBadges(perStore) {
192
191
  const total = perStore.length;
@@ -202,14 +201,28 @@ function mergeBadges(perStore) {
202
201
  if (statusRank(p.badge.status) > statusRank(worst))
203
202
  worst = p.badge.status;
204
203
  }
204
+ if (unindexed.length > 0)
205
+ worst = 'partial';
206
+ const statusCounts = {};
207
+ for (const p of perStore) {
208
+ const status = p.hasIndex ? p.badge.status : 'missing_index';
209
+ statusCounts[status] = (statusCounts[status] ?? 0) + 1;
210
+ }
211
+ const exceptionalProjects = perStore
212
+ .filter((p) => p.hasIndex && p.badge.status !== 'fresh')
213
+ .map((p) => ({ path: p.ref.relPath || '.', status: p.badge.status }));
205
214
  const details = {
206
215
  traversal: 'workspace',
207
216
  projects_indexed: indexed.length,
208
217
  projects_total: total,
209
- per_project: Object.fromEntries(perStore.map((p) => [p.ref.relPath || '.', p.badge.status])),
218
+ project_status_counts: statusCounts,
210
219
  };
211
- if (unindexed.length)
220
+ if (unindexed.length) {
221
+ details.unindexed_project_count = unindexed.length;
212
222
  details.unindexed_projects = unindexed;
223
+ }
224
+ if (exceptionalProjects.length)
225
+ details.non_fresh_projects = exceptionalProjects;
213
226
  const prefixMerge = (key) => {
214
227
  const out = [];
215
228
  for (const p of indexed) {
@@ -259,7 +272,7 @@ export function aggregateFind(query, limit, resolved, currentHead) {
259
272
  const r = findInStore(query, { cwd: ref.cwd }, checker, acc);
260
273
  // Per-store badge: drive `partial` from THIS store's own budget-skips (review F2),
261
274
  // NOT the shared checker.exhausted flag — else an early store spending the budget
262
- // would mislabel every fully-fresh later store as `partial` in per_project. Then
275
+ // would mislabel every fully-fresh later store as `partial` in diagnostics. Then
263
276
  // apply per-store HEAD drift against the one workspace HEAD (review F3) so a child
264
277
  // whose index lags the working tree is flagged even under an otherwise-fresh root.
265
278
  let badge = deriveBadge(r.base, acc, false, r.matches.length > 0, r.emptyCandidates);