brainclaw 1.5.5 → 1.6.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 (43) hide show
  1. package/dist/brainclaw-vscode.vsix +0 -0
  2. package/dist/cli.js +124 -7
  3. package/dist/commands/bootstrap-loop.js +206 -0
  4. package/dist/commands/loop.js +156 -0
  5. package/dist/commands/loops-handlers.js +110 -55
  6. package/dist/commands/mcp-read-handlers.js +37 -0
  7. package/dist/commands/mcp.js +621 -202
  8. package/dist/commands/questions.js +180 -0
  9. package/dist/commands/reply.js +190 -0
  10. package/dist/commands/session-end.js +105 -3
  11. package/dist/commands/session-start.js +32 -53
  12. package/dist/commands/switch.js +17 -1
  13. package/dist/core/agentrun-reconciler.js +65 -0
  14. package/dist/core/claims.js +29 -0
  15. package/dist/core/dispatch-status.js +219 -0
  16. package/dist/core/entity-operations.js +128 -9
  17. package/dist/core/execution-adapters.js +38 -2
  18. package/dist/core/facade-schema.js +55 -0
  19. package/dist/core/federation-cloud.js +27 -12
  20. package/dist/core/federation-materialize.js +57 -0
  21. package/dist/core/instruction-templates.js +2 -0
  22. package/dist/core/loops/bootstrap-acquire.js +195 -0
  23. package/dist/core/loops/facade-schema.js +68 -1
  24. package/dist/core/loops/hooks/bootstrap-write.js +144 -0
  25. package/dist/core/loops/hooks/notify-operator.js +148 -0
  26. package/dist/core/loops/hooks/survey-source-reader.js +256 -0
  27. package/dist/core/loops/index.js +8 -2
  28. package/dist/core/loops/next-expected.js +63 -0
  29. package/dist/core/loops/presets/bootstrap.js +75 -0
  30. package/dist/core/loops/presets/index.js +16 -0
  31. package/dist/core/loops/store.js +224 -4
  32. package/dist/core/loops/types.js +346 -1
  33. package/dist/core/loops/verbs.js +739 -6
  34. package/dist/core/schema.js +28 -2
  35. package/dist/core/state.js +62 -0
  36. package/dist/facts.js +7 -5
  37. package/dist/facts.json +6 -4
  38. package/docs/concepts/dispatch-lifecycle.md +228 -0
  39. package/docs/concepts/loop-engine.md +55 -0
  40. package/docs/concepts/multi-agent-workflows.md +167 -166
  41. package/docs/concepts/troubleshooting.md +10 -2
  42. package/docs/integrations/overview.md +14 -12
  43. package/package.json +1 -1
@@ -11,7 +11,7 @@ import { buildContext, renderContextMarkdown, renderContextPromptTemplate, rende
11
11
  import { buildCoordinationSnapshot } from '../core/coordination.js';
12
12
  import { checkBrainclawInstallableUpdate, getInstalledBrainclawVersion, readDiskBrainclawVersion, renderBrainclawInstallableUpdateNotice } from '../core/brainclaw-version.js';
13
13
  import { loadConfig } from '../core/config.js';
14
- import { loadState, persistState, saveState } from '../core/state.js';
14
+ import { collectLoadValidationWarnings, findLoadValidationWarning, loadState, persistState, saveState } from '../core/state.js';
15
15
  import { generateIdWithLabel } from '../core/ids.js';
16
16
  import { memoryExists } from '../core/io.js';
17
17
  import { generateCandidateIdWithLabel, loadCandidate, saveCandidate } from '../core/candidates.js';
@@ -48,6 +48,7 @@ import { WorkRequestSchema, CoordinateRequestSchema } from '../core/facade-schem
48
48
  import { getSpawnableAgents, getCapabilityProfile, buildInvokeCommand, validateAgentForDispatch } from '../core/agent-capability.js';
49
49
  import { attemptExecution } from '../core/execution.js';
50
50
  import { createAgentRun, transitionAgentRun } from '../core/agentruns.js';
51
+ import { sweepDeadPidRunningAgentRunsAtRead } from '../core/agentrun-reconciler.js';
51
52
  import { createAssignment, generateAssignmentId, patchAssignmentMessageId, transitionAssignment, bumpActiveAssignmentHeartbeat, } from '../core/assignments.js';
52
53
  import { harvestCandidates } from './harvest.js';
53
54
  export const SCHEMA_VERSION = '1.0.0';
@@ -402,11 +403,25 @@ export const MCP_READ_TOOLS = [
402
403
  required: ['thread_id'],
403
404
  },
404
405
  },
406
+ {
407
+ name: 'bclaw_dispatch_status',
408
+ description: 'Consolidated dispatch status — given a `target_id` (asgn_/clm_/lop_/run_), resolves all linked entities (assignment, claim, loop, agent_run), reads the on-disk artefacts (brief-ack sentinel + per-assignment stdout/stderr log tails), checks OS pid liveness, and returns a single health verdict + a recommended next action. Replaces the five separate `bclaw_find` / `bclaw_get` calls a caller would otherwise make to verify a dispatch is actually doing useful work. Particularly useful right after `bclaw_coordinate` returns `execution_status="delivered_and_started"` — that response\'s `verify_with` hint points at this tool by name. See docs/concepts/dispatch-lifecycle.md for the full entity model and FSM details.',
409
+ annotations: { tier: 'facade', category: 'coordination', headlessApproval: 'auto' },
410
+ inputSchema: {
411
+ type: 'object',
412
+ properties: {
413
+ target_id: { type: 'string', description: 'Any one of: an assignment id (`asgn_…`), a claim id (`clm_…`), a loop id (`lop_…`), or an agent_run id (`run_…`). The tool resolves to the assignment scope internally and fetches the rest.' },
414
+ tail_log_lines: { type: 'number', description: 'How many trailing lines of each captured log file (stdout / stderr) to include in the response. Default 20. Pass 0 to omit tails and only return size_bytes.' },
415
+ stall_threshold_ms: { type: 'number', description: 'Age in ms past which a `running` agent_run with a live pid but no recent activity is considered `stalled`. Default 300000 (5 min).' },
416
+ },
417
+ required: ['target_id'],
418
+ },
419
+ },
405
420
  ];
406
421
  const MCP_WRITE_TOOLS = [
407
422
  {
408
423
  name: 'bclaw_dispatch',
409
- description: 'Unified dispatch entry for sequence-lane parallelization. `intent` discriminator: analysis (sequence lane status, read-only), execute (default — analyze + generate briefs + send), review (routes an EXISTING reviewable handoff to a reviewer — NOT for opening new reviews; use bclaw_coordinate(intent=review, open_loop=true) for that). Consolidates bclaw_dispatch_analysis / bclaw_dispatch / bclaw_dispatch_review.',
424
+ description: 'Unified dispatch entry for sequence-lane parallelization (parallelize plans across lanes). To open a NEW review of a commit/branch, use `bclaw_coordinate(intent="review", open_loop=true, targetAgents=[…])` instead — bclaw_dispatch is for sequence-driven execution, NOT for opening reviews. `intent` discriminator: analysis (sequence lane status, read-only), execute (default — analyze + generate briefs + send to agents), review (routes an EXISTING already-reflected handoff to a reviewer — only for handoffs produced by `session-end --reflect-handoff` or similar). Consolidates the legacy bclaw_dispatch_analysis / bclaw_dispatch / bclaw_dispatch_review. Returns FacadeResponse; for verification semantics see the same response-validation guidance documented on `bclaw_coordinate`.',
410
425
  annotations: { tier: 'facade', category: 'coordination', headlessApproval: 'prompt' },
411
426
  inputSchema: {
412
427
  type: 'object',
@@ -482,6 +497,21 @@ const MCP_WRITE_TOOLS = [
482
497
  },
483
498
  },
484
499
  },
500
+ {
501
+ name: 'bclaw_init_project',
502
+ description: "Initialize brainclaw at an arbitrary path AND register it as a cross_project_link in the caller's store. Lets an agent operating in workspace A bootstrap a brainclaw project in folder B in one MCP call.",
503
+ annotations: { tier: 'standard', category: 'session', headlessApproval: 'prompt' },
504
+ inputSchema: {
505
+ type: 'object',
506
+ properties: {
507
+ path: { type: 'string', description: 'Absolute or relative path of the target folder. Resolved via path.resolve(callerCwd, path).' },
508
+ force: { type: 'boolean', description: 'Pass --force to init (rebuild managed config). Default false.' },
509
+ project_mode: { type: 'string', description: 'Optional project mode (single-project, multi-project, auto).' },
510
+ link_as: { type: 'string', description: 'Optional name to register the cross_project_link under. Defaults to path basename.' },
511
+ },
512
+ required: ['path'],
513
+ },
514
+ },
485
515
  {
486
516
  name: 'bclaw_write_note',
487
517
  description: 'Add a runtime note. Requires contributor trust level or above. Use crossProject to push a runtime-note signal to a linked project (requires role: publisher in cross_project_links config).',
@@ -630,23 +660,33 @@ const MCP_WRITE_TOOLS = [
630
660
  },
631
661
  {
632
662
  name: 'bclaw_add_step',
633
- description: 'Add a sub-step to a plan item. Requires contributor trust level or above.',
663
+ description: 'Add a sub-step to a plan item. Canonical shape is `{ planId, data: { text, title?, assignee? } }`; legacy top-level `{ text, assignee }` still works for backward compatibility. If both are present, data.* wins and a warning is emitted. Requires contributor trust level or above. Pass `project` to target a step in a plan that lives in a linked project (same pattern as the canonical-grammar tools).',
634
664
  annotations: { tier: 'standard', category: 'coordination', headlessApproval: 'auto' },
635
665
  inputSchema: {
636
666
  type: 'object',
637
667
  properties: {
638
668
  planId: { type: 'string', description: 'Plan item ID.' },
639
- text: { type: 'string', description: 'Step description.' },
669
+ data: {
670
+ type: 'object',
671
+ description: 'Canonical step payload: { text, title?, assignee? }. title is accepted as an alias for text.',
672
+ properties: {
673
+ text: { type: 'string', description: 'Step description.' },
674
+ title: { type: 'string', description: 'Alias for text.' },
675
+ assignee: { type: 'string', description: 'Optional assignee.' },
676
+ },
677
+ },
678
+ text: { type: 'string', description: 'Legacy top-level step description; prefer data.text.' },
640
679
  agent: { type: 'string', description: 'Agent name.' },
641
680
  agentId: { type: 'string', description: 'Registered agent id.' },
642
- assignee: { type: 'string', description: 'Optional assignee.' },
681
+ assignee: { type: 'string', description: 'Legacy top-level optional assignee; prefer data.assignee.' },
682
+ project: { type: 'string', description: 'Optional: name (or path/basename) of a linked project to add the step in. Defaults to the current project. Same resolution as canonical-grammar tools — accepts cross_project_links and workspace store-chain children.' },
643
683
  },
644
- required: ['planId', 'text'],
684
+ required: ['planId'],
645
685
  },
646
686
  },
647
687
  {
648
688
  name: 'bclaw_complete_step',
649
- description: 'Mark a plan sub-step as done. Requires contributor trust level or above.',
689
+ description: 'Mark a plan sub-step as done. Requires contributor trust level or above. Pass `project` to operate on a plan in a linked project.',
650
690
  annotations: { tier: 'standard', category: 'coordination', headlessApproval: 'auto' },
651
691
  inputSchema: {
652
692
  type: 'object',
@@ -655,13 +695,14 @@ const MCP_WRITE_TOOLS = [
655
695
  stepId: { type: 'string', description: 'Step ID to complete.' },
656
696
  agent: { type: 'string', description: 'Agent name.' },
657
697
  agentId: { type: 'string', description: 'Registered agent id.' },
698
+ project: { type: 'string', description: 'Optional: name of a linked project to complete the step in. Defaults to the current project.' },
658
699
  },
659
700
  required: ['planId', 'stepId'],
660
701
  },
661
702
  },
662
703
  {
663
704
  name: 'bclaw_update_step',
664
- description: 'Update a plan sub-step (status, text, assignee). Supports all step statuses: todo, in_progress, testing, done, blocked. Requires contributor trust level or above.',
705
+ description: 'Update a plan sub-step (status, text, assignee). Supports all step statuses: todo, in_progress, testing, done, blocked. Requires contributor trust level or above. Pass `project` to operate on a plan in a linked project.',
665
706
  annotations: { tier: 'standard', category: 'coordination', headlessApproval: 'auto' },
666
707
  inputSchema: {
667
708
  type: 'object',
@@ -673,13 +714,14 @@ const MCP_WRITE_TOOLS = [
673
714
  assignee: { type: 'string', description: 'New assignee (empty string to unassign).' },
674
715
  agent: { type: 'string', description: 'Agent name.' },
675
716
  agentId: { type: 'string', description: 'Registered agent id.' },
717
+ project: { type: 'string', description: 'Optional: name of a linked project to update the step in. Defaults to the current project.' },
676
718
  },
677
719
  required: ['planId', 'stepId'],
678
720
  },
679
721
  },
680
722
  {
681
723
  name: 'bclaw_delete_step',
682
- description: 'Remove a sub-step from a plan. Requires contributor trust level or above.',
724
+ description: 'Remove a sub-step from a plan. Requires contributor trust level or above. Pass `project` to operate on a plan in a linked project.',
683
725
  annotations: { tier: 'standard', category: 'coordination', headlessApproval: 'prompt' },
684
726
  inputSchema: {
685
727
  type: 'object',
@@ -688,6 +730,7 @@ const MCP_WRITE_TOOLS = [
688
730
  stepId: { type: 'string', description: 'Step ID to delete.' },
689
731
  agent: { type: 'string', description: 'Agent name.' },
690
732
  agentId: { type: 'string', description: 'Registered agent id.' },
733
+ project: { type: 'string', description: 'Optional: name of a linked project to delete the step from. Defaults to the current project.' },
691
734
  },
692
735
  required: ['planId', 'stepId'],
693
736
  },
@@ -877,6 +920,7 @@ const MCP_WRITE_TOOLS = [
877
920
  task: { type: 'string', description: 'Optional task description (used as claim description when creating a claim).' },
878
921
  messageId: { type: 'string', description: 'Optional message/thread ID for traceability.' },
879
922
  contextTarget: { type: 'string', description: 'Optional path passed to bclaw_get_context to filter memory.' },
923
+ project: { type: 'string', description: 'Optional linked project name/path. Routes session, context, claims, audit, and bootstrap probe to that project. Defaults to the current cwd.' },
880
924
  agent: { type: 'string', description: 'Agent name.' },
881
925
  agentId: { type: 'string', description: 'Registered agent id.' },
882
926
  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 },
@@ -886,13 +930,13 @@ const MCP_WRITE_TOOLS = [
886
930
  },
887
931
  {
888
932
  name: 'bclaw_coordinate',
889
- description: 'Multi-agent coordination facade: assign tasks to agents (with claims), consult agents (no claim), create a review candidate, open an ideation loop, reroute an active claim to another agent, or summarize a thread. Returns a FacadeResponse with selected_targets, delivery_plan, artifacts, and side_effects.',
933
+ description: 'Multi-agent coordination facade: assign tasks to agents (with claims), consult agents (no claim), create a review candidate, open an ideation loop, reroute an active claim to another agent, or summarize a thread. Returns a FacadeResponse with selected_targets, delivery_plan, artifacts, side_effects, and execution_status. IMPORTANT — execution_status semantics: `delivered_and_started` means the spawn wrapper touched the brief-ack sentinel (`.brainclaw/coordination/runtime/ack/<assignment_id>.ack`) — NOT that the worker is doing useful work. Spawned workers may still die silently before consuming the brief (cf. trap trp_38f63ea4). To verify a dispatch is actually alive, query `bclaw_find(entity="agent_run", filter={assignment_id})` then check `status==="running"` AND OS-level pid liveness (Windows: `Get-Process -Id <pid>` ; POSIX: `kill -0 <pid>`) AND `last_event_at` within the last few minutes. See docs/concepts/troubleshooting.md §"Inbox messages stuck / brief-ack never arrived" for the diagnostic playbook, and docs/integrations/<agent>.md for per-agent spawn semantics (notably codex.md re sandbox MCP availability).',
890
934
  annotations: { tier: 'facade', category: 'coordination', headlessApproval: 'auto' },
891
935
  inputSchema: {
892
936
  type: 'object',
893
937
  properties: {
894
938
  intent: { type: 'string', enum: ['assign', 'consult', 'review', 'reroute', 'summarize', 'ideate'], description: 'Coordination intent. "assign" creates a claim per target agent and dispatches the brief. "consult" dispatches without creating claims. "review" creates a review candidate. "ideate" opens an ideation loop with the task as the proposal seed (driver wire-up: pln#492 phase 2.d). "reroute" releases the current claim and reassigns. "summarize" reads a thread and returns a summary.' },
895
- task: { type: 'string', description: 'Brief or task description delivered to target agents.' },
939
+ task: { type: 'string', description: 'Brief or task description delivered to target agents. WARNING: the brief is delivered to a worker that may be sandboxed and not have brainclaw MCP wired in its session — notably codex spawned with `--sandbox workspace-write`. Briefs that instruct MCP-call protocols (e.g. require the worker to call `bclaw_send_message`, `bclaw_assignment_update`, `bclaw_complete_step`) will hang the worker silently. Prefer file-based protocols (write findings/reply to a markdown file in the worktree + commit fixes directly on the branch) for these agents. The coordinator then harvests the file and lifecycle-closes the assignment itself. See docs/integrations/<agent>.md for per-agent capability matrix.' },
896
940
  scope: { type: 'string', description: 'File or feature scope. Used as claim scope for assign/reroute; as thread id for summarize if threadId is absent.' },
897
941
  targetAgents: { type: 'array', items: { type: 'string' }, description: 'Agent names to target. If omitted, all spawnable agents are used.' },
898
942
  constraints: { type: 'object', description: 'Optional structured constraints passed alongside the brief (e.g. deadline, reviewCriteria).' },
@@ -924,7 +968,7 @@ const MCP_WRITE_TOOLS = [
924
968
  intent: {
925
969
  type: 'string',
926
970
  enum: ['open', 'get', 'list', 'turn', 'complete_turn', 'advance', 'add_artifact', 'pause', 'resume', 'close'],
927
- description: 'Loop lifecycle intent. See docs/concepts/loop-engine.md for semantics.',
971
+ 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.).',
928
972
  },
929
973
  loop_id: { type: 'string', description: 'Target loop id (lop_…). Required for every intent except open and list.' },
930
974
  kind: { type: 'string', enum: ['review', 'ideation', 'implementation', 'research', 'debug'], description: 'Loop kind for open / list filter.' },
@@ -935,7 +979,7 @@ const MCP_WRITE_TOOLS = [
935
979
  linked: { type: 'object', description: 'Optional top-level plan/sequence refs (open).' },
936
980
  stop_condition: { type: 'object', description: 'Optional stop_condition override (open). Composite any/all supported.' },
937
981
  mode: { type: 'string', enum: ['asymmetric', 'symmetric'], description: 'Review mode selector for open (review kind only).' },
938
- status: { type: 'string', description: 'Filter value for list, or target final_status for close.' },
982
+ status: { type: 'string', description: 'For intent="list": filter value (any loop status). For intent="close": target final status — accepted values are `completed` | `cancelled` | `blocked` only (NOT `failed`; map crashed/dead loops to `cancelled` with a `reason`).' },
939
983
  include_events: { type: 'boolean', description: 'get: include the event journal in the response.' },
940
984
  limit: { type: 'number', description: 'list: max loops returned.' },
941
985
  offset: { type: 'number', description: 'list: pagination offset.' },
@@ -952,6 +996,7 @@ const MCP_WRITE_TOOLS = [
952
996
  reason: { type: 'string', description: 'advance / pause / close: optional reason string.' },
953
997
  expected_version: { type: 'number', description: 'Accepted for RFC compatibility on mutating intents, but not enforced until lock/CAS wiring lands.' },
954
998
  client_request_id: { type: 'string', description: 'Accepted for RFC compatibility on mutating intents, but not enforced until lock/idempotency wiring lands.' },
999
+ project: { type: 'string', description: 'Optional linked project name/path. Routes loop reads and mutations to that project. Defaults to the current cwd.' },
955
1000
  agent: { type: 'string', description: 'Caller agent name.' },
956
1001
  agentId: { type: 'string', description: 'Caller registered agent id (enforced for slot-bound auth in complete_turn).' },
957
1002
  },
@@ -1038,13 +1083,13 @@ const MCP_WRITE_TOOLS = [
1038
1083
  // Promoted to `standard` tier at the v1.0 cut.
1039
1084
  {
1040
1085
  name: 'bclaw_find',
1041
- description: 'Canonical list query over a brainclaw entity. Default read filter excludes records with provenance.kind="legacy" and auto_reflect records below 0.6 confidence — override via filter.includeLegacy / filter.minAutoReflectConfidence. Pass `project` to query a linked project instead of the current one.',
1086
+ description: 'Canonical list query over a brainclaw entity. Default read filter excludes records with provenance.kind="legacy" and auto_reflect records below 0.6 confidence — override via filter.includeLegacy / filter.minAutoReflectConfidence. Tag filters accept `tag: string` for one tag or `tags: string[]` for any-match. For entity="agent_run", filters also accept assignment_id, claim_id, and message_id. Pass `project` to query a linked project instead of the current one.',
1042
1087
  annotations: { tier: 'standard', category: 'memory', headlessApproval: 'auto' },
1043
1088
  inputSchema: {
1044
1089
  type: 'object',
1045
1090
  properties: {
1046
1091
  entity: { type: 'string', description: 'Entity name: plan | decision | constraint | trap | handoff | runtime_note | candidate | claim | action | assignment | agent_run | cross_project_link. Others not yet wired.' },
1047
- filter: { type: 'object', description: 'Filter keys: status, tag, author, plan_id, limit, offset, includeLegacy (bool, default false), minAutoReflectConfidence (0-1, default 0.6).' },
1092
+ 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.' },
1048
1093
  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`.' },
1049
1094
  },
1050
1095
  required: ['entity'],
@@ -2377,6 +2422,102 @@ async function _executeMcpToolCallInner(payload) {
2377
2422
  }
2378
2423
  return { response: toolResponse({ content: [{ type: 'text', text: `Unknown step: "${step}". Valid steps: project_roots, repo_selection, agent_selection.` }], structuredContent: { error: 'unknown_step', step } }, true) };
2379
2424
  }
2425
+ if (name === 'bclaw_init_project') {
2426
+ const rawPath = typeof args.path === 'string' ? args.path.trim() : '';
2427
+ if (!rawPath) {
2428
+ return { response: createToolErrorResponse('validation_error', 'path is required') };
2429
+ }
2430
+ const force = args.force === true;
2431
+ const projectModeArg = typeof args.project_mode === 'string' ? args.project_mode : undefined;
2432
+ const linkAs = typeof args.link_as === 'string' && args.link_as.trim().length > 0
2433
+ ? args.link_as.trim()
2434
+ : undefined;
2435
+ const resolvedPath = path.isAbsolute(rawPath) ? rawPath : path.resolve(cwd, rawPath);
2436
+ let wasAlreadyInitialized = false;
2437
+ if (memoryExists(resolvedPath) && !force) {
2438
+ wasAlreadyInitialized = true;
2439
+ }
2440
+ else {
2441
+ if (!fs.existsSync(resolvedPath)) {
2442
+ try {
2443
+ fs.mkdirSync(resolvedPath, { recursive: true });
2444
+ }
2445
+ catch (err) {
2446
+ return {
2447
+ response: createToolErrorResponse('init_project_failed', `Failed to create target directory '${resolvedPath}': ${err instanceof Error ? err.message : String(err)}`),
2448
+ };
2449
+ }
2450
+ }
2451
+ try {
2452
+ const { runInit } = await import('./init.js');
2453
+ await runInit({
2454
+ yes: true,
2455
+ cwd: resolvedPath,
2456
+ force,
2457
+ ...(projectModeArg ? { projectMode: projectModeArg } : {}),
2458
+ });
2459
+ }
2460
+ catch (err) {
2461
+ return {
2462
+ response: createToolErrorResponse('init_project_failed', `runInit failed for '${resolvedPath}': ${err instanceof Error ? err.message : String(err)}`),
2463
+ };
2464
+ }
2465
+ }
2466
+ let projectName;
2467
+ try {
2468
+ projectName = loadConfig(resolvedPath).project_name;
2469
+ }
2470
+ catch {
2471
+ projectName = path.basename(resolvedPath);
2472
+ }
2473
+ let linkName;
2474
+ try {
2475
+ const { addCrossProjectLink } = await import('../core/cross-project.js');
2476
+ const link = addCrossProjectLink({
2477
+ path: resolvedPath,
2478
+ name: linkAs ?? projectName,
2479
+ cwd,
2480
+ force,
2481
+ });
2482
+ linkName = link.name ?? path.basename(resolvedPath);
2483
+ }
2484
+ catch (err) {
2485
+ const message = err instanceof Error ? err.message : String(err);
2486
+ // Treat a duplicate link as idempotent success when the caller did
2487
+ // not request --force; the project itself is initialised correctly
2488
+ // and the existing link already points at it.
2489
+ if (/already exists/i.test(message) && !force) {
2490
+ try {
2491
+ const { resolveCrossProjectLinks } = await import('../core/cross-project.js');
2492
+ const existing = resolveCrossProjectLinks(cwd).find((l) => l.absolutePath === resolvedPath || l.path === rawPath);
2493
+ linkName = existing?.name ?? linkAs ?? projectName;
2494
+ }
2495
+ catch {
2496
+ linkName = linkAs ?? projectName;
2497
+ }
2498
+ }
2499
+ else {
2500
+ return {
2501
+ response: createToolErrorResponse('init_project_failed', `Failed to register cross_project_link: ${message}`),
2502
+ };
2503
+ }
2504
+ }
2505
+ const summary = wasAlreadyInitialized
2506
+ ? `✔ ${resolvedPath} already initialised; linked as '${linkName}'.`
2507
+ : `✔ Initialised brainclaw at ${resolvedPath} and linked as '${linkName}'.`;
2508
+ return {
2509
+ response: toolResponse({
2510
+ content: [{ type: 'text', text: summary }],
2511
+ structuredContent: {
2512
+ status: 'ok',
2513
+ project_name: projectName,
2514
+ path: resolvedPath,
2515
+ link_id: linkName,
2516
+ was_already_initialized: wasAlreadyInitialized,
2517
+ },
2518
+ }),
2519
+ };
2520
+ }
2380
2521
  if (name === 'bclaw_write_note') {
2381
2522
  const resolved = ensureTrust(args, { nameField: 'agent', idField: 'agentId' }, 'contributor', cwd, connectionSessionId);
2382
2523
  if (resolved.error) {
@@ -2887,7 +3028,7 @@ async function _executeMcpToolCallInner(payload) {
2887
3028
  if (resolved.error && resolved.error.kind !== 'identity_error') {
2888
3029
  return { response: createToolErrorResponse(resolved.error.kind, resolved.error.message, resolved.error.details) };
2889
3030
  }
2890
- const result = startSession({
3031
+ const result = await startSession({
2891
3032
  agent: resolved.identity?.agent_name ?? (typeof args.agent === 'string' ? args.agent : undefined),
2892
3033
  agentId: resolved.identity?.agent_id ?? (typeof args.agentId === 'string' ? args.agentId : undefined),
2893
3034
  context: args.context,
@@ -3030,7 +3171,7 @@ async function _executeMcpToolCallInner(payload) {
3030
3171
  if (resolved.error) {
3031
3172
  return { response: createToolErrorResponse(resolved.error.kind, resolved.error.message, resolved.error.details) };
3032
3173
  }
3033
- const result = endSession({
3174
+ const result = await endSession({
3034
3175
  session: args.session,
3035
3176
  agent: resolved.identity?.agent_name,
3036
3177
  agentId: resolved.identity?.agent_id,
@@ -3575,13 +3716,22 @@ async function _executeMcpToolCallInner(payload) {
3575
3716
  return { response: createToolErrorResponse(resolved.error.kind, resolved.error.message, resolved.error.details) };
3576
3717
  }
3577
3718
  const stepPlanId = String(args.planId ?? '').trim();
3578
- const stepText = String(args.text ?? '').trim();
3719
+ const stepData = args.data && typeof args.data === 'object' && !Array.isArray(args.data)
3720
+ ? args.data
3721
+ : {};
3722
+ if ((args.text !== undefined || args.assignee !== undefined) && Object.keys(stepData).length > 0) {
3723
+ console.warn('[brainclaw:warn] bclaw_add_step received legacy top-level fields alongside data.*; using data.* values');
3724
+ }
3725
+ const stepTextRaw = stepData.text ?? stepData.title ?? args.text;
3726
+ const stepText = typeof stepTextRaw === 'string' ? stepTextRaw.trim() : '';
3727
+ const stepAssignee = (stepData.assignee ?? args.assignee);
3579
3728
  if (!stepPlanId)
3580
3729
  return { response: createToolErrorResponse('validation_error', 'Missing required argument: planId') };
3581
3730
  if (!stepText)
3582
- return { response: createToolErrorResponse('validation_error', 'Missing required argument: text') };
3731
+ return { response: createToolErrorResponse('validation_error', 'Missing required argument: data.text') };
3732
+ const stepTargetCwd = resolveProjectCwd(args.project, cwd);
3583
3733
  try {
3584
- const result = addStepOp({ planId: stepPlanId, text: stepText, assignee: args.assignee }, cwd);
3734
+ const result = addStepOp({ planId: stepPlanId, text: stepText, assignee: stepAssignee }, stepTargetCwd);
3585
3735
  return {
3586
3736
  response: toolResponse({
3587
3737
  content: [{ type: 'text', text: `✔ Step added: [${result.stepId}] ${stepText} (${result.doneSteps}/${result.totalSteps} done)` }],
@@ -3614,8 +3764,9 @@ async function _executeMcpToolCallInner(payload) {
3614
3764
  return { response: createToolErrorResponse('validation_error', 'Missing required argument: planId') };
3615
3765
  if (!csStepId)
3616
3766
  return { response: createToolErrorResponse('validation_error', 'Missing required argument: stepId') };
3767
+ const csTargetCwd = resolveProjectCwd(args.project, cwd);
3617
3768
  try {
3618
- const result = completeStepOp({ planId: csPlanId, stepId: csStepId }, cwd);
3769
+ const result = completeStepOp({ planId: csPlanId, stepId: csStepId }, csTargetCwd);
3619
3770
  return {
3620
3771
  response: toolResponse({
3621
3772
  content: [{ type: 'text', text: `✔ Step completed: [${result.stepId}] (${result.doneSteps}/${result.totalSteps} done)` }],
@@ -3653,6 +3804,7 @@ async function _executeMcpToolCallInner(payload) {
3653
3804
  if (args.status && !validStatuses.includes(String(args.status))) {
3654
3805
  return { response: createToolErrorResponse('validation_error', `Invalid status: ${args.status}. Valid: ${validStatuses.join(', ')}`) };
3655
3806
  }
3807
+ const usTargetCwd = resolveProjectCwd(args.project, cwd);
3656
3808
  try {
3657
3809
  const result = updateStepOp({
3658
3810
  planId: usPlanId,
@@ -3660,7 +3812,7 @@ async function _executeMcpToolCallInner(payload) {
3660
3812
  status: args.status,
3661
3813
  text: args.text,
3662
3814
  assignee: args.assignee,
3663
- }, cwd);
3815
+ }, usTargetCwd);
3664
3816
  const changes = [];
3665
3817
  if (args.status)
3666
3818
  changes.push(`status=${args.status}`);
@@ -3701,8 +3853,9 @@ async function _executeMcpToolCallInner(payload) {
3701
3853
  return { response: createToolErrorResponse('validation_error', 'Missing required argument: planId') };
3702
3854
  if (!dsStepId)
3703
3855
  return { response: createToolErrorResponse('validation_error', 'Missing required argument: stepId') };
3856
+ const dsTargetCwd = resolveProjectCwd(args.project, cwd);
3704
3857
  try {
3705
- const result = deleteStepOp({ planId: dsPlanId, stepId: dsStepId }, cwd);
3858
+ const result = deleteStepOp({ planId: dsPlanId, stepId: dsStepId }, dsTargetCwd);
3706
3859
  return {
3707
3860
  response: toolResponse({
3708
3861
  content: [{ type: 'text', text: `✔ Step deleted: [${result.stepId}] (${result.doneSteps}/${result.totalSteps} remaining)` }],
@@ -4075,16 +4228,17 @@ async function _executeMcpToolCallInner(payload) {
4075
4228
  return { response: createToolErrorResponse('validation_error', parseResult.error.message) };
4076
4229
  }
4077
4230
  const workReq = parseResult.data;
4231
+ const targetCwd = resolveProjectCwd(workReq.project, cwd);
4078
4232
  const useCompact = workReq.compact !== false; // default true
4079
4233
  const warnings = [];
4080
4234
  // Step 1: implicit session start (handles auto-registration internally)
4081
4235
  let sessionResult;
4082
4236
  try {
4083
- sessionResult = startSession({
4237
+ sessionResult = await startSession({
4084
4238
  agent: typeof args.agent === 'string' ? args.agent : undefined,
4085
4239
  agentId: typeof args.agentId === 'string' ? args.agentId : undefined,
4086
4240
  context: workReq.contextTarget,
4087
- cwd,
4241
+ cwd: targetCwd,
4088
4242
  });
4089
4243
  }
4090
4244
  catch (sessionErr) {
@@ -4102,14 +4256,14 @@ async function _executeMcpToolCallInner(payload) {
4102
4256
  try {
4103
4257
  let sinceSession;
4104
4258
  if (workReq.intent === 'resume') {
4105
- const previousSession = loadAllSessions(cwd)
4259
+ const previousSession = loadAllSessions(targetCwd)
4106
4260
  .find((s) => s.agent === sessionResult.agent && s.session_id !== sessionResult.session_id);
4107
4261
  sinceSession = previousSession?.session_id;
4108
4262
  }
4109
4263
  contextResult = buildContext({
4110
4264
  target: workReq.contextTarget ?? workReq.scope,
4111
4265
  agent: sessionResult.agent,
4112
- cwd,
4266
+ cwd: targetCwd,
4113
4267
  sinceSession,
4114
4268
  });
4115
4269
  }
@@ -4118,7 +4272,7 @@ async function _executeMcpToolCallInner(payload) {
4118
4272
  let claimId;
4119
4273
  let claimStatus = 'none';
4120
4274
  if (workReq.intent === 'execute' && workReq.scope) {
4121
- const existingClaims = listClaims(cwd).filter((c) => c.status === 'active' && c.agent === sessionResult.agent && c.scope === workReq.scope);
4275
+ const existingClaims = listClaims(targetCwd).filter((c) => c.status === 'active' && c.agent === sessionResult.agent && c.scope === workReq.scope);
4122
4276
  if (existingClaims.length > 0) {
4123
4277
  claimId = existingClaims[0].id;
4124
4278
  claimStatus = 'existing';
@@ -4139,11 +4293,11 @@ async function _executeMcpToolCallInner(payload) {
4139
4293
  status: 'active',
4140
4294
  plan_id: workReq.planId,
4141
4295
  model: currentModel,
4142
- }, cwd);
4143
- appendAuditEntry({ actor: sessionResult.agent, actor_id: sessionResult.agent_id, action: 'claim', item_id: claimId, item_type: 'claim', scope: workReq.scope, session_id: sessionResult.session_id }, cwd);
4296
+ }, targetCwd);
4297
+ appendAuditEntry({ actor: sessionResult.agent, actor_id: sessionResult.agent_id, action: 'claim', item_id: claimId, item_type: 'claim', scope: workReq.scope, session_id: sessionResult.session_id }, targetCwd);
4144
4298
  claimStatus = 'created';
4145
4299
  // Policy check post-claim
4146
- const policyResult = checkPolicy({ scope: workReq.scope, agent: sessionResult.agent, agentId: sessionResult.agent_id, cwd });
4300
+ const policyResult = checkPolicy({ scope: workReq.scope, agent: sessionResult.agent, agentId: sessionResult.agent_id, cwd: targetCwd });
4147
4301
  for (const w of policyResult.warnings.filter((pw) => pw.kind !== 'no_claim')) {
4148
4302
  const idLabel = w.id ? ` (${w.id})` : '';
4149
4303
  warnings.push(`[${w.kind}]${idLabel} ${w.message}`);
@@ -4185,6 +4339,34 @@ async function _executeMcpToolCallInner(payload) {
4185
4339
  _full_context_hint: 'Use bclaw_context(kind="memory") for the full payload.',
4186
4340
  };
4187
4341
  }
4342
+ // pln#513 step 1 — bootstrap hint. When the project lacks a usable
4343
+ // PROJECT.md (absent or 0 bytes), surface a hint so the agent knows
4344
+ // the canonical entry-point. Cheap probe — one fs.statSync, no
4345
+ // gating flag. The literal next_action mirrors the documented
4346
+ // canonical-grammar call so callers can suggest it verbatim.
4347
+ let bootstrapRecommended;
4348
+ let nextAction;
4349
+ try {
4350
+ const fsMod = await import('node:fs');
4351
+ const pathMod = await import('node:path');
4352
+ const projectMdPath = pathMod.join(targetCwd, 'PROJECT.md');
4353
+ let needsBootstrap = false;
4354
+ try {
4355
+ const stat = fsMod.statSync(projectMdPath);
4356
+ if (!stat.isFile() || stat.size === 0)
4357
+ needsBootstrap = true;
4358
+ }
4359
+ catch {
4360
+ needsBootstrap = true; // ENOENT → absent
4361
+ }
4362
+ bootstrapRecommended = needsBootstrap;
4363
+ if (needsBootstrap) {
4364
+ nextAction = "bclaw_coordinate(intent='ideate', preset='bootstrap')";
4365
+ }
4366
+ }
4367
+ catch {
4368
+ // Best-effort: never block bclaw_work on the probe.
4369
+ }
4188
4370
  const facadeResponse = {
4189
4371
  status: 'ok',
4190
4372
  intent: workReq.intent,
@@ -4195,12 +4377,17 @@ async function _executeMcpToolCallInner(payload) {
4195
4377
  session_id: sessionResult.session_id,
4196
4378
  warnings,
4197
4379
  duration_ms: Date.now() - startMs,
4380
+ bootstrap_recommended: bootstrapRecommended,
4381
+ next_action: nextAction,
4198
4382
  };
4199
4383
  const summaryParts = [`✔ bclaw_work [${workReq.intent}] session=${sessionResult.session_id}`];
4200
4384
  if (claimId)
4201
4385
  summaryParts.push(`claim=${claimId} (${claimStatus})`);
4202
4386
  if (useCompact)
4203
4387
  summaryParts.push('mode=compact (use bclaw_context for full payload)');
4388
+ if (bootstrapRecommended) {
4389
+ summaryParts.push(`💡 PROJECT.md missing — call ${nextAction} to open a bootstrap loop`);
4390
+ }
4204
4391
  if (warnings.length > 0)
4205
4392
  summaryParts.push(warnings.map((w) => `⚠ ${w}`).join('\n'));
4206
4393
  return {
@@ -4218,15 +4405,89 @@ async function _executeMcpToolCallInner(payload) {
4218
4405
  return { response: createToolErrorResponse('validation_error', parseResult.error.message) };
4219
4406
  }
4220
4407
  const req = parseResult.data;
4408
+ // pln#511 step 2 — preset selector validation. Presets are kind-
4409
+ // specific in v1: only intent='ideate' carries them. Unknown names
4410
+ // are rejected up-front against the registry so the handler never
4411
+ // silently falls back to the kind-default. The bootstrap preset
4412
+ // also enforces a dispatch constraint (can_753a083a): the champion
4413
+ // must be a human-connected agent, never a sandboxed worker —
4414
+ // checked below once the senderAgent is resolved.
4415
+ if (req.preset !== undefined) {
4416
+ if (req.intent !== 'ideate') {
4417
+ return {
4418
+ response: createToolErrorResponse('preset_kind_mismatch', `preset='${req.preset}' is only valid for intent='ideate' in v1; got intent='${req.intent}'. Loop presets are kind-specific — open the loop without a preset, or call with intent='ideate'.`),
4419
+ };
4420
+ }
4421
+ const { PRESETS: PRESETS_REGISTRY } = await import('../core/loops/presets/index.js');
4422
+ if (!(req.preset in PRESETS_REGISTRY)) {
4423
+ const validNames = Object.keys(PRESETS_REGISTRY).join(', ') || '(none)';
4424
+ return {
4425
+ response: createToolErrorResponse('unknown_preset', `Unknown preset '${req.preset}'. Valid preset names: ${validNames}.`),
4426
+ };
4427
+ }
4428
+ }
4429
+ // can_30c295b4 — pre-flight uncommitted-changes check.
4430
+ // Dispatches that spawn a worker (review/assign/consult/ideate) clone
4431
+ // the source repo at HEAD into a worktree. Any uncommitted edits in
4432
+ // the source cwd are invisible to the worker, so the worker reviews
4433
+ // stale code without any error signal. Refuse the dispatch by default
4434
+ // when the source cwd is a git repo with a dirty working tree.
4435
+ // Override via allow_dirty=true when the caller knows the dispatched
4436
+ // work doesn't depend on the modified files (e.g. tests, docs-only
4437
+ // worker tasks). Has no effect when cwd is not a git repo.
4438
+ if (!req.allow_dirty && (req.intent === 'review' || req.intent === 'assign' || req.intent === 'consult' || req.intent === 'ideate')) {
4439
+ try {
4440
+ const { spawnSync } = await import('node:child_process');
4441
+ const probe = spawnSync('git', ['-C', cwd, 'status', '--porcelain'], { encoding: 'utf8', timeout: 5000 });
4442
+ if (probe.status === 0 && typeof probe.stdout === 'string' && probe.stdout.trim().length > 0) {
4443
+ const fileCount = probe.stdout.trim().split('\n').length;
4444
+ return {
4445
+ response: createToolErrorResponse('dirty_working_tree', `Refusing to dispatch: source working tree has ${fileCount} uncommitted file(s). The spawned worker will not see these changes (worktrees branch from HEAD). Commit or stash before dispatching, or pass allow_dirty=true to override. Source cwd: ${cwd}`),
4446
+ };
4447
+ }
4448
+ // probe.status !== 0 → not a git repo (or git unavailable) → skip the check silently
4449
+ }
4450
+ catch {
4451
+ // best-effort: never block dispatch on a pre-flight check failure unrelated to the actual dirty state
4452
+ }
4453
+ }
4221
4454
  const warnings = [];
4222
4455
  const artifacts = [];
4223
4456
  const side_effects = [];
4457
+ // can_5e62334e — codex sandboxed dispatches cannot commit in worktrees
4458
+ // because `.git` is a file pointer to the parent repo's
4459
+ // .git/worktrees/<wt>/ directory, which lives OUTSIDE the worktree's
4460
+ // writable root that `--sandbox workspace-write` permits. Any
4461
+ // codex worker that runs `git commit` will fail with `index.lock:
4462
+ // Permission denied`. Warn callers up-front so briefs don't request
4463
+ // per-bug commits; the coordinator must harvest the worktree
4464
+ // diff via `git diff` and commit from a non-sandboxed cwd.
4465
+ if (Array.isArray(req.targetAgents) && req.targetAgents.includes('codex')) {
4466
+ warnings.push('codex --sandbox workspace-write cannot commit to git in worktrees (.git is outside writable root). Briefs MUST NOT request per-bug commits; codex will produce uncommitted edits, then the coordinator must harvest via `git diff HEAD` from the worktree path and commit from the main repo. See can_5e62334e for context.');
4467
+ }
4224
4468
  const senderAgent = typeof args.agent === 'string' && args.agent.trim()
4225
4469
  ? args.agent.trim()
4226
4470
  : 'bclaw_coordinate';
4227
4471
  const senderAgentId = typeof args.agentId === 'string' && args.agentId.trim()
4228
4472
  ? args.agentId.trim()
4229
4473
  : undefined;
4474
+ // pln#511 step 2 — bootstrap preset dispatch constraint (can_753a083a).
4475
+ // The bootstrap loop's champion must be a human-connected agent: it
4476
+ // asks the operator clarifying questions and writes PROJECT.md. A
4477
+ // sandboxed worker (codex / github-copilot) cannot reach the human,
4478
+ // so the loop would stall in `clarify`. Enforce by requiring
4479
+ // targetAgents to be empty (= single-agent / self-champion mode)
4480
+ // or to contain only the caller. Other presets are unrestricted.
4481
+ if (req.preset === 'bootstrap') {
4482
+ const targets = req.targetAgents ?? [];
4483
+ const onlyCaller = targets.length === 0
4484
+ || (targets.length === 1 && targets[0] === senderAgent);
4485
+ if (!onlyCaller) {
4486
+ return {
4487
+ response: createToolErrorResponse('bootstrap_preset_not_dispatchable', `preset='bootstrap' cannot dispatch to other agents (can_753a083a): the champion must be a human-connected agent. Got targetAgents=${JSON.stringify(targets)}; pass an empty array or [${JSON.stringify(senderAgent)}].`),
4488
+ };
4489
+ }
4490
+ }
4230
4491
  const commandHints = [];
4231
4492
  const preparedInvokes = [];
4232
4493
  // pln#359 phase 1b — cross-project routing. When `project` is set, all
@@ -5043,186 +5304,318 @@ async function _executeMcpToolCallInner(payload) {
5043
5304
  : messages.map((m, i) => `[${i + 1}] ${m.from} → ${m.to}: ${m.text}`).join('\n');
5044
5305
  result = { thread_id: threadId, message_count: messages.length, summary };
5045
5306
  }
5046
- else if (req.intent === 'ideate') {
5047
- // pln#492 phase 2.c (open + proposal) + 2.d.2 (multi-agent dispatch).
5048
- // Single-agent mode (no targetAgents): open the loop with the task
5049
- // as a proposal seed and stop there the champion drives manually
5050
- // via bclaw_loop intent='turn' / 'advance'. Multi-agent mode
5051
- // (targetAgents passed): also advance to critique and dispatch a
5052
- // turn to each critic slot with a context-filtered, BM25-ranked,
5053
- // size-capped brief assembled by buildIdeationBrief.
5054
- const loopsModuleRef = await import('../core/loops/index.js');
5055
- const { openLoop, add_artifact, advance, turn, getLoop, buildIdeationBrief } = loopsModuleRef;
5056
- const senderIdentity = ((senderAgentId ? findAgentIdentityById(senderAgentId, dispatchCwd) : undefined)
5057
- ?? findAgentIdentityByName(senderAgent, dispatchCwd)
5058
- ?? ensureAgentRegisteredForDispatch(senderAgent, dispatchCwd));
5059
- const authorAgentId = senderIdentity?.agent_id ?? senderAgentId;
5060
- const creatorActor = authorAgentId ?? senderAgent;
5061
- const explicitTargets = req.targetAgents && req.targetAgents.length > 0;
5062
- const slots = [
5063
- {
5064
- role: 'champion',
5065
- agent: senderAgent,
5066
- ...(authorAgentId ? { agent_id: authorAgentId } : {}),
5067
- },
5068
- ];
5069
- if (explicitTargets) {
5070
- for (const agent of req.targetAgents) {
5071
- const criticIdentity = findAgentIdentityByName(agent, dispatchCwd) ?? ensureAgentRegisteredForDispatch(agent, dispatchCwd);
5072
- slots.push({
5073
- role: 'critic',
5074
- agent,
5075
- ...(criticIdentity?.agent_id ? { agent_id: criticIdentity.agent_id } : {}),
5076
- });
5307
+ else if (req.intent === 'ideate')
5308
+ ideate: {
5309
+ // pln#492 phase 2.c (open + proposal) + 2.d.2 (multi-agent dispatch).
5310
+ // Single-agent mode (no targetAgents): open the loop with the task
5311
+ // as a proposal seed and stop there — the champion drives manually
5312
+ // via bclaw_loop intent='turn' / 'advance'. Multi-agent mode
5313
+ // (targetAgents passed): also advance to critique and dispatch a
5314
+ // turn to each critic slot with a context-filtered, BM25-ranked,
5315
+ // size-capped brief assembled by buildIdeationBrief.
5316
+ //
5317
+ // pln#511 step 2 when `req.preset` is set, the loop opens with
5318
+ // the preset's phases / stop_condition / protocol instead of the
5319
+ // kind-default ideation chain. Bootstrap preset enforces single-
5320
+ // agent / self-champion mode (validated above), so the multi-
5321
+ // agent dispatch branch never runs for it.
5322
+ //
5323
+ // pln#513 step 2 — labelled block (ideate:) so the bootstrap
5324
+ // join-or-lock path can break out early after assigning result.
5325
+ const loopsModuleRef = await import('../core/loops/index.js');
5326
+ const { openLoop, add_artifact, advance, turn, getLoop, buildIdeationBrief } = loopsModuleRef;
5327
+ const presetSelected = req.preset
5328
+ ? (await import('../core/loops/presets/index.js')).PRESETS[req.preset]
5329
+ : undefined;
5330
+ // pln#513 step 2 — bootstrap join-or-lock. The bootstrap preset is
5331
+ // project-singleton: two concurrent callers must converge on the same
5332
+ // loop rather than open duplicates. Strategy:
5333
+ // 1. Find existing bootstrap loop in {open, paused} → join.
5334
+ // 2. Else check for an active coordination claim (someone is
5335
+ // mid-open). Re-find once; surface bootstrap_coordination_in_progress
5336
+ // if still nothing.
5337
+ // 3. Else acquire the lock, fall through to the normal open path,
5338
+ // release the lock on the way out (success or fail).
5339
+ // The lock is opportunistic, not blocking — a fast retry-in-place
5340
+ // not a wait-on-mutex. Keeps the verb short and predictable.
5341
+ // pln#518 step 1 — bootstrap join-or-lock now delegates to the shared
5342
+ // acquireBootstrapLoop helper (src/core/loops/bootstrap-acquire.ts).
5343
+ // Both the CLI and this MCP handler converge on the same singleton
5344
+ // acquire path, eliminating the race where two concurrent callers both
5345
+ // passed the local scan and called openLoop directly.
5346
+ const senderIdentity = ((senderAgentId ? findAgentIdentityById(senderAgentId, dispatchCwd) : undefined)
5347
+ ?? findAgentIdentityByName(senderAgent, dispatchCwd)
5348
+ ?? ensureAgentRegisteredForDispatch(senderAgent, dispatchCwd));
5349
+ const authorAgentId = senderIdentity?.agent_id ?? senderAgentId;
5350
+ const creatorActor = authorAgentId ?? senderAgent;
5351
+ let bootstrapOpenedLoop;
5352
+ if (req.preset === 'bootstrap') {
5353
+ const { acquireBootstrapLoop, BootstrapCoordinationInProgressError: BcipError } = await import('../core/loops/bootstrap-acquire.js');
5354
+ let acqResult;
5355
+ try {
5356
+ acqResult = acquireBootstrapLoop({
5357
+ actor: senderAgent,
5358
+ agent_id: authorAgentId,
5359
+ created_by: creatorActor,
5360
+ title: req.task.slice(0, 120),
5361
+ goal: req.scope,
5362
+ model: currentModel,
5363
+ }, dispatchCwd);
5364
+ }
5365
+ catch (err) {
5366
+ if (err instanceof BcipError) {
5367
+ return {
5368
+ response: createToolErrorResponse('bootstrap_coordination_in_progress', err.message),
5369
+ };
5370
+ }
5371
+ throw err;
5372
+ }
5373
+ warnings.push(...acqResult.warnings);
5374
+ if (acqResult.action === 'joined') {
5375
+ const jLoop = acqResult.loop;
5376
+ artifacts.push({ type: 'loop', id: jLoop.id });
5377
+ result = {
5378
+ loop_id: jLoop.id,
5379
+ joined_existing: true,
5380
+ current_phase: jLoop.current_phase,
5381
+ status: jLoop.status,
5382
+ mode: 'single_agent',
5383
+ preset: req.preset,
5384
+ };
5385
+ // Skip the rest of the ideate flow — we joined an existing loop.
5386
+ break ideate;
5387
+ }
5388
+ // action === 'opened': helper already called openLoop + released lock.
5389
+ bootstrapOpenedLoop = acqResult.loop;
5077
5390
  }
5078
- }
5079
- let loopId;
5080
- let proposalArtifactId;
5081
- try {
5082
- const loop = openLoop({
5083
- kind: 'ideation',
5084
- title: req.task.slice(0, 120),
5085
- goal: req.scope,
5086
- created_by: creatorActor,
5087
- slots,
5088
- }, dispatchCwd);
5089
- loopId = loop.id;
5090
- artifacts.push({ type: 'loop', id: loop.id });
5091
- side_effects.push({ action: 'create', entity: 'loop', id: loop.id });
5092
- const proposalBody = req.task.slice(0, 4000);
5093
- const updated = add_artifact({
5094
- id: loop.id,
5095
- actor: creatorActor,
5096
- artifact: {
5097
- phase: 'proposal',
5098
- type: 'proposal',
5099
- body: proposalBody,
5100
- produced_by: creatorActor,
5391
+ // pln#511 step 2 — bootstrap preset always runs in single-agent
5392
+ // mode: the champion drives the whole loop. Even when the caller
5393
+ // passes targetAgents=[caller] (validated as the only legal non-
5394
+ // empty form), we don't add critic slots and we don't take the
5395
+ // multi-agent dispatch branch. Treating that idiom as "single
5396
+ // agent / self-champion" matches what the constraint check
5397
+ // already enforced upstream.
5398
+ const explicitTargets = Boolean(req.targetAgents
5399
+ && req.targetAgents.length > 0
5400
+ && req.preset !== 'bootstrap');
5401
+ const slots = [
5402
+ {
5403
+ role: 'champion',
5404
+ agent: senderAgent,
5405
+ ...(authorAgentId ? { agent_id: authorAgentId } : {}),
5101
5406
  },
5102
- }, dispatchCwd);
5103
- const lastArtifact = updated.artifacts[updated.artifacts.length - 1];
5104
- proposalArtifactId = lastArtifact?.artifact_id;
5105
- if (proposalArtifactId) {
5106
- artifacts.push({ type: 'artifact', id: proposalArtifactId });
5107
- side_effects.push({ action: 'create', entity: 'artifact', id: proposalArtifactId });
5108
- }
5109
- }
5110
- catch (err) {
5111
- const msg = err instanceof Error ? err.message : String(err);
5112
- return {
5113
- response: createToolErrorResponse('ideate_failed', `ideate intent: failed to open ideation loop — ${msg}`),
5114
- };
5115
- }
5116
- // pln#492 phase 2.d.2 — multi-agent dispatch. Skipped in single-
5117
- // agent mode (the champion drives manually).
5118
- let dispatchedCritics = 0;
5119
- let dispatchedPhase = 'proposal';
5120
- if (explicitTargets) {
5121
- try {
5122
- // Build a search-backed BriefMemoryProvider. Maps user-facing
5123
- // memory categories the brief asks for onto src/core/search.ts
5124
- // sections (BM25 there). Loop-internal categories
5125
- // (critique_history / revision_history / synthesis_artifact)
5126
- // are pulled by the assembler from the thread directly.
5127
- const searchModule = await import('../core/search.js');
5128
- const sectionByCategory = {
5129
- traps: 'traps',
5130
- decisions: 'decisions',
5131
- constraints: 'constraints',
5132
- handoffs: 'handoffs',
5133
- plans: 'plans',
5134
- candidates: 'candidates',
5135
- };
5136
- const provider = {
5137
- fetch(category, query, topK) {
5138
- const section = sectionByCategory[category];
5139
- if (!section)
5140
- return [];
5141
- const results = searchModule.search({
5142
- query,
5143
- section,
5144
- maxResults: topK,
5145
- cwd: dispatchCwd,
5146
- includePending: section === 'candidates',
5147
- });
5148
- return results.map((r) => ({
5149
- id: r.id,
5150
- category,
5151
- text: r.text,
5152
- score: r.score,
5153
- }));
5154
- },
5155
- };
5156
- // Advance proposal → critique. proposal has no advance_gate so
5157
- // this is unconditional. After advance, the loop sits at the
5158
- // critique phase ready for critic turns.
5159
- advance({ id: loopId, actor: creatorActor }, dispatchCwd);
5160
- const advancedLoop = getLoop(loopId, dispatchCwd);
5161
- if (!advancedLoop) {
5162
- throw new Error('ideate dispatch: loop disappeared after advance');
5163
- }
5164
- dispatchedPhase = advancedLoop.current_phase;
5165
- const criticSlots = advancedLoop.slots.filter((s) => s.role === 'critic');
5166
- for (const slot of criticSlots) {
5167
- if (!slot.agent)
5168
- continue;
5169
- const briefResult = buildIdeationBrief({
5170
- thread: advancedLoop,
5171
- slotRole: slot.role,
5172
- memoryProvider: provider,
5407
+ ];
5408
+ if (explicitTargets) {
5409
+ for (const agent of req.targetAgents) {
5410
+ const criticIdentity = findAgentIdentityByName(agent, dispatchCwd) ?? ensureAgentRegisteredForDispatch(agent, dispatchCwd);
5411
+ slots.push({
5412
+ role: 'critic',
5413
+ agent,
5414
+ ...(criticIdentity?.agent_id ? { agent_id: criticIdentity.agent_id } : {}),
5173
5415
  });
5174
- turn({
5175
- id: loopId,
5176
- slot_id: slot.slot_id,
5177
- actor: creatorActor,
5178
- input: briefResult.text,
5416
+ }
5417
+ }
5418
+ let loopId;
5419
+ let proposalArtifactId;
5420
+ if (bootstrapOpenedLoop) {
5421
+ // Bootstrap case: helper already opened the loop and released its lock.
5422
+ loopId = bootstrapOpenedLoop.id;
5423
+ artifacts.push({ type: 'loop', id: bootstrapOpenedLoop.id });
5424
+ side_effects.push({ action: 'create', entity: 'loop', id: bootstrapOpenedLoop.id });
5425
+ }
5426
+ else {
5427
+ try {
5428
+ const loop = openLoop({
5429
+ kind: 'ideation',
5430
+ title: req.task.slice(0, 120),
5431
+ goal: req.scope,
5432
+ created_by: creatorActor,
5433
+ slots,
5434
+ ...(presetSelected
5435
+ ? {
5436
+ phases: presetSelected.phases,
5437
+ stop_condition: presetSelected.stop_condition,
5438
+ protocol: presetSelected.protocol,
5439
+ }
5440
+ : {}),
5179
5441
  }, dispatchCwd);
5180
- const queued = queueCoordinateMessage({
5181
- agent: slot.agent,
5182
- text: briefResult.text,
5183
- messageType: 'rfc',
5184
- ref: loopId,
5185
- scope: req.scope,
5186
- tags: ['coordinate', 'ideate', 'loop'],
5187
- payload: {
5188
- intent: 'ideate',
5189
- loop_id: loopId,
5190
- slot_id: slot.slot_id,
5191
- phase: advancedLoop.current_phase,
5192
- iteration: advancedLoop.iteration_count,
5193
- proposal_artifact_id: proposalArtifactId,
5442
+ loopId = loop.id;
5443
+ artifacts.push({ type: 'loop', id: loop.id });
5444
+ side_effects.push({ action: 'create', entity: 'loop', id: loop.id });
5445
+ // pln#511 step 2 — skip the proposal-seed artifact when a preset
5446
+ // is in use. The kind-default ideation chain opens at phase
5447
+ // 'proposal' and the seed artifact lives there; presets define
5448
+ // their own initial phase + seeding semantics (bootstrap starts
5449
+ // at 'survey' and produces a signals_report). Forcing a
5450
+ // 'proposal'-phased artifact here would dangle on a phase the
5451
+ // loop doesn't contain. The task text is already captured on
5452
+ // the thread (title + goal).
5453
+ if (!presetSelected) {
5454
+ const proposalBody = req.task.slice(0, 4000);
5455
+ const updated = add_artifact({
5456
+ id: loop.id,
5457
+ actor: creatorActor,
5458
+ artifact: {
5459
+ phase: 'proposal',
5460
+ type: 'proposal',
5461
+ body: proposalBody,
5462
+ produced_by: creatorActor,
5463
+ },
5464
+ }, dispatchCwd);
5465
+ const lastArtifact = updated.artifacts[updated.artifacts.length - 1];
5466
+ proposalArtifactId = lastArtifact?.artifact_id;
5467
+ if (proposalArtifactId) {
5468
+ artifacts.push({ type: 'artifact', id: proposalArtifactId });
5469
+ side_effects.push({ action: 'create', entity: 'artifact', id: proposalArtifactId });
5470
+ }
5471
+ }
5472
+ }
5473
+ catch (err) {
5474
+ const msg = err instanceof Error ? err.message : String(err);
5475
+ return {
5476
+ response: createToolErrorResponse('ideate_failed', `ideate intent: failed to open ideation loop — ${msg}`),
5477
+ };
5478
+ }
5479
+ } // end else (non-bootstrap open path)
5480
+ // pln#492 phase 2.d.2 — multi-agent dispatch. Skipped in single-
5481
+ // agent mode (the champion drives manually).
5482
+ //
5483
+ // pln#511 step 2 — initial phase comes from the actual loop's
5484
+ // first phase, not a hardcoded 'proposal'. Presets like bootstrap
5485
+ // open at 'survey'; the kind-default ideation chain still opens
5486
+ // at 'proposal', so this is backward compatible.
5487
+ let dispatchedCritics = 0;
5488
+ let dispatchedPhase = presetSelected ? presetSelected.phases[0].name : 'proposal';
5489
+ if (explicitTargets) {
5490
+ try {
5491
+ // Build a search-backed BriefMemoryProvider. Maps user-facing
5492
+ // memory categories the brief asks for onto src/core/search.ts
5493
+ // sections (BM25 there). Loop-internal categories
5494
+ // (critique_history / revision_history / synthesis_artifact)
5495
+ // are pulled by the assembler from the thread directly.
5496
+ const searchModule = await import('../core/search.js');
5497
+ const sectionByCategory = {
5498
+ traps: 'traps',
5499
+ decisions: 'decisions',
5500
+ constraints: 'constraints',
5501
+ handoffs: 'handoffs',
5502
+ plans: 'plans',
5503
+ candidates: 'candidates',
5504
+ };
5505
+ const provider = {
5506
+ fetch(category, query, topK) {
5507
+ const section = sectionByCategory[category];
5508
+ if (!section)
5509
+ return [];
5510
+ const results = searchModule.search({
5511
+ query,
5512
+ section,
5513
+ maxResults: topK,
5514
+ cwd: dispatchCwd,
5515
+ includePending: section === 'candidates',
5516
+ });
5517
+ return results.map((r) => ({
5518
+ id: r.id,
5519
+ category,
5520
+ text: r.text,
5521
+ score: r.score,
5522
+ }));
5194
5523
  },
5195
- commandMode: 'consult',
5196
- });
5197
- void queued;
5198
- dispatchedCritics += 1;
5199
- if (briefResult.truncated) {
5200
- warnings.push(`Brief for critic slot ${slot.slot_id} (${slot.agent}) truncated: ${briefResult.droppedItems} memory items dropped to fit cap`);
5524
+ };
5525
+ // Advance proposal → critique. proposal has no advance_gate so
5526
+ // this is unconditional. After advance, the loop sits at the
5527
+ // critique phase ready for critic turns.
5528
+ advance({ id: loopId, actor: creatorActor }, dispatchCwd);
5529
+ const advancedLoop = getLoop(loopId, dispatchCwd);
5530
+ if (!advancedLoop) {
5531
+ throw new Error('ideate dispatch: loop disappeared after advance');
5532
+ }
5533
+ dispatchedPhase = advancedLoop.current_phase;
5534
+ const criticSlots = advancedLoop.slots.filter((s) => s.role === 'critic');
5535
+ for (const slot of criticSlots) {
5536
+ if (!slot.agent)
5537
+ continue;
5538
+ const briefResult = buildIdeationBrief({
5539
+ thread: advancedLoop,
5540
+ slotRole: slot.role,
5541
+ memoryProvider: provider,
5542
+ });
5543
+ turn({
5544
+ id: loopId,
5545
+ slot_id: slot.slot_id,
5546
+ actor: creatorActor,
5547
+ input: briefResult.text,
5548
+ }, dispatchCwd);
5549
+ const queued = queueCoordinateMessage({
5550
+ agent: slot.agent,
5551
+ text: briefResult.text,
5552
+ messageType: 'rfc',
5553
+ ref: loopId,
5554
+ scope: req.scope,
5555
+ tags: ['coordinate', 'ideate', 'loop'],
5556
+ payload: {
5557
+ intent: 'ideate',
5558
+ loop_id: loopId,
5559
+ slot_id: slot.slot_id,
5560
+ phase: advancedLoop.current_phase,
5561
+ iteration: advancedLoop.iteration_count,
5562
+ proposal_artifact_id: proposalArtifactId,
5563
+ },
5564
+ commandMode: 'consult',
5565
+ });
5566
+ void queued;
5567
+ dispatchedCritics += 1;
5568
+ if (briefResult.truncated) {
5569
+ warnings.push(`Brief for critic slot ${slot.slot_id} (${slot.agent}) truncated: ${briefResult.droppedItems} memory items dropped to fit cap`);
5570
+ }
5201
5571
  }
5202
5572
  }
5573
+ catch (err) {
5574
+ const msg = err instanceof Error ? err.message : String(err);
5575
+ warnings.push(`ideate dispatch failed: ${msg}; loop ${loopId} stays at proposal phase`);
5576
+ facadeStatus = 'partial';
5577
+ }
5203
5578
  }
5204
- catch (err) {
5205
- const msg = err instanceof Error ? err.message : String(err);
5206
- warnings.push(`ideate dispatch failed: ${msg}; loop ${loopId} stays at proposal phase`);
5207
- facadeStatus = 'partial';
5579
+ if (!explicitTargets) {
5580
+ warnings.push(presetSelected
5581
+ ? `ideate single-agent mode (preset='${req.preset}'): loop opened at phase '${dispatchedPhase}'; champion drives manually via bclaw_loop intent='turn' / 'advance'.`
5582
+ : "ideate single-agent mode: loop opened with proposal seed; champion drives manually via bclaw_loop intent='turn' / 'advance'. Pass targetAgents to enable multi-agent auto-dispatch.");
5208
5583
  }
5584
+ result = {
5585
+ loop_id: loopId,
5586
+ proposal_artifact_id: proposalArtifactId,
5587
+ selected_targets: explicitTargets ? req.targetAgents : [],
5588
+ mode: explicitTargets ? 'multi_agent' : 'single_agent',
5589
+ dispatched_critics: dispatchedCritics,
5590
+ current_phase: dispatchedPhase,
5591
+ ...(presetSelected ? { preset: req.preset } : {}),
5592
+ };
5593
+ // pln#518 step 1 — bootstrap lock is now managed inside acquireBootstrapLoop;
5594
+ // no release needed here.
5209
5595
  }
5210
- if (!explicitTargets) {
5211
- warnings.push("ideate single-agent mode: loop opened with proposal seed; champion drives manually via bclaw_loop intent='turn' / 'advance'. Pass targetAgents to enable multi-agent auto-dispatch.");
5212
- }
5213
- result = {
5214
- loop_id: loopId,
5215
- proposal_artifact_id: proposalArtifactId,
5216
- selected_targets: explicitTargets ? req.targetAgents : [],
5217
- mode: explicitTargets ? 'multi_agent' : 'single_agent',
5218
- dispatched_critics: dispatchedCritics,
5219
- current_phase: dispatchedPhase,
5220
- };
5221
- }
5222
5596
  // Extract execution_status from result if present (assign/reroute set it)
5223
5597
  const resultExecStatus = (result && typeof result === 'object' && 'execution_status' in result)
5224
5598
  ? result.execution_status
5225
5599
  : undefined;
5600
+ // pln#503 phase 3.3: when execution_status === 'delivered_and_started',
5601
+ // attach a self-documenting `verify_with` hint pointing at the assignment
5602
+ // record. Callers should not take delivered_and_started at face value —
5603
+ // it only attests the brief-ack sentinel was touched, not that the worker
5604
+ // is doing useful work. The hint tells them exactly which canonical-
5605
+ // grammar call to make next to verify spawn liveness.
5606
+ let verifyWith;
5607
+ if (resultExecStatus === 'delivered_and_started') {
5608
+ const firstAssignment = artifacts.find((a) => a.type === 'assignment');
5609
+ if (firstAssignment) {
5610
+ verifyWith = {
5611
+ action: 'bclaw_find',
5612
+ entity: 'agent_run',
5613
+ filter: { assignment_id: firstAssignment.id },
5614
+ expected_when_alive: 'agent_run with status="running" AND OS pid alive AND last_event_at within the last few minutes',
5615
+ see_also: 'docs/concepts/dispatch-lifecycle.md',
5616
+ };
5617
+ }
5618
+ }
5226
5619
  const facadeResponse = {
5227
5620
  status: facadeStatus,
5228
5621
  intent: req.intent,
@@ -5232,6 +5625,7 @@ async function _executeMcpToolCallInner(payload) {
5232
5625
  warnings,
5233
5626
  duration_ms: Date.now() - startMs,
5234
5627
  ...(resultExecStatus ? { execution_status: resultExecStatus } : {}),
5628
+ ...(verifyWith ? { verify_with: verifyWith } : {}),
5235
5629
  };
5236
5630
  const summaryParts = [`✔ bclaw_coordinate [${req.intent}] targets=${resolvedAgents.length}`];
5237
5631
  if (resultExecStatus)
@@ -5247,7 +5641,8 @@ async function _executeMcpToolCallInner(payload) {
5247
5641
  }
5248
5642
  if (name === 'bclaw_loop') {
5249
5643
  const { handleBclawLoop } = await import('./loops-handlers.js');
5250
- const result = handleBclawLoop({ args: args, cwd });
5644
+ const targetCwd = resolveProjectCwd(args?.project, cwd);
5645
+ const result = handleBclawLoop({ args: args, cwd: targetCwd });
5251
5646
  return {
5252
5647
  response: toolResponse({
5253
5648
  content: [{ type: 'text', text: result.summary }],
@@ -5321,10 +5716,12 @@ async function _executeMcpToolCallInner(payload) {
5321
5716
  // applied when it hadn't. Under the new contract, an unknown key is
5322
5717
  // a validation_error listing the keys actually honored.
5323
5718
  const KNOWN_FILTER_KEYS = new Set([
5324
- 'status', 'tag', 'author', 'plan_id', 'source', 'auto_generated',
5719
+ 'status', 'tag', 'tags', 'author', 'plan_id', 'source', 'auto_generated',
5720
+ 'assignment_id', 'claim_id', 'message_id',
5325
5721
  'limit', 'offset', 'includeLegacy', 'minAutoReflectConfidence',
5326
5722
  ]);
5327
- const unknownKeys = Object.keys(filter).filter((k) => !KNOWN_FILTER_KEYS.has(k));
5723
+ const agentRunOnlyFilterKeys = new Set(['assignment_id', 'claim_id', 'message_id']);
5724
+ const unknownKeys = Object.keys(filter).filter((k) => !KNOWN_FILTER_KEYS.has(k) || (agentRunOnlyFilterKeys.has(k) && entity !== 'agent_run'));
5328
5725
  if (unknownKeys.length > 0) {
5329
5726
  return {
5330
5727
  response: createToolErrorResponse('validation_error', `Unknown filter key(s): ${unknownKeys.map((k) => `"${k}"`).join(', ')}. ` +
@@ -5332,6 +5729,7 @@ async function _executeMcpToolCallInner(payload) {
5332
5729
  };
5333
5730
  }
5334
5731
  const result = listEntities(entity, targetCwd, filter);
5732
+ const warnings = collectLoadValidationWarnings(entity, targetCwd);
5335
5733
  // structuredContent is the canonical MCP return channel that clients
5336
5734
  // (VS Code extension, Codex, etc.) read for machine-parseable data.
5337
5735
  // Prior to this fix we spread `...result` at top-level of the
@@ -5341,7 +5739,7 @@ async function _executeMcpToolCallInner(payload) {
5341
5739
  return {
5342
5740
  response: toolResponse({
5343
5741
  content: [{ type: 'text', text: `✔ ${result.total} ${entity} item(s)` }],
5344
- structuredContent: { ...result },
5742
+ structuredContent: { ...result, warnings },
5345
5743
  }),
5346
5744
  };
5347
5745
  }
@@ -5354,6 +5752,21 @@ async function _executeMcpToolCallInner(payload) {
5354
5752
  const entity = String(args.entity ?? '');
5355
5753
  const id = String(args.id ?? '');
5356
5754
  const targetCwd = resolveProjectCwd(args.project, cwd);
5755
+ const validationWarning = findLoadValidationWarning(entity, id, targetCwd);
5756
+ if (validationWarning) {
5757
+ return {
5758
+ response: toolResponse({
5759
+ content: [{ type: 'text', text: `✖ ${entity} ${id} failed validation at load` }],
5760
+ structuredContent: {
5761
+ ok: false,
5762
+ error: 'validation_failed',
5763
+ entity_id: validationWarning.entity_id,
5764
+ validation_errors: validationWarning.validation_errors,
5765
+ path: validationWarning.path,
5766
+ },
5767
+ }, true),
5768
+ };
5769
+ }
5357
5770
  const item = getEntity(entity, id, targetCwd);
5358
5771
  return {
5359
5772
  response: toolResponse({
@@ -5514,7 +5927,7 @@ export async function executeMcpToolCall(payload) {
5514
5927
  }
5515
5928
  else {
5516
5929
  // First-ever connection for this claim — start a fresh session + adopt.
5517
- const sessionResult = startSession({ cwd, maintenanceMode: 'fast' });
5930
+ const sessionResult = await startSession({ cwd, maintenanceMode: 'fast' });
5518
5931
  autoSessionId = sessionResult.session_id;
5519
5932
  effectiveConnectionSessionId = autoSessionId;
5520
5933
  try {
@@ -5545,6 +5958,12 @@ export async function executeMcpToolCall(payload) {
5545
5958
  }
5546
5959
  catch { /* best-effort */ }
5547
5960
  }
5961
+ if ((payload.name === 'bclaw_find' || payload.name === 'bclaw_get') && payload.args.entity === 'agent_run') {
5962
+ try {
5963
+ sweepDeadPidRunningAgentRunsAtRead(cwd);
5964
+ }
5965
+ catch { /* best-effort */ }
5966
+ }
5548
5967
  // ── Delegate to inner handler ───────────────────────────────────────────────
5549
5968
  const outcome = await _executeMcpToolCallInner({
5550
5969
  ...payload,