brainclaw 1.5.5 → 1.7.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 (58) hide show
  1. package/README.md +5 -4
  2. package/dist/brainclaw-vscode.vsix +0 -0
  3. package/dist/cli.js +124 -7
  4. package/dist/commands/bootstrap-loop.js +206 -0
  5. package/dist/commands/loop.js +156 -0
  6. package/dist/commands/loops-handlers.js +110 -55
  7. package/dist/commands/mcp-read-handlers.js +37 -0
  8. package/dist/commands/mcp-schemas.generated.js +33 -0
  9. package/dist/commands/mcp.js +661 -207
  10. package/dist/commands/questions.js +180 -0
  11. package/dist/commands/reply.js +190 -0
  12. package/dist/commands/session-end.js +105 -3
  13. package/dist/commands/session-start.js +32 -53
  14. package/dist/commands/setup.js +64 -13
  15. package/dist/commands/switch.js +17 -1
  16. package/dist/core/agent-capability.js +19 -0
  17. package/dist/core/agent-files.js +86 -0
  18. package/dist/core/agent-integrations.js +34 -0
  19. package/dist/core/agent-inventory.js +27 -0
  20. package/dist/core/agentrun-reconciler.js +130 -0
  21. package/dist/core/ai-agent-detection.js +17 -1
  22. package/dist/core/claims.js +54 -3
  23. package/dist/core/dirty-scope.js +242 -0
  24. package/dist/core/dispatch-status.js +219 -0
  25. package/dist/core/entity-operations.js +128 -9
  26. package/dist/core/execution-adapters.js +38 -2
  27. package/dist/core/facade-schema.js +71 -0
  28. package/dist/core/federation-cloud.js +27 -12
  29. package/dist/core/federation-materialize.js +57 -0
  30. package/dist/core/instruction-templates.js +2 -0
  31. package/dist/core/loops/bootstrap-acquire.js +195 -0
  32. package/dist/core/loops/facade-schema.js +68 -1
  33. package/dist/core/loops/hooks/bootstrap-write.js +144 -0
  34. package/dist/core/loops/hooks/notify-operator.js +148 -0
  35. package/dist/core/loops/hooks/survey-source-reader.js +256 -0
  36. package/dist/core/loops/index.js +8 -2
  37. package/dist/core/loops/next-expected.js +63 -0
  38. package/dist/core/loops/presets/bootstrap.js +75 -0
  39. package/dist/core/loops/presets/index.js +16 -0
  40. package/dist/core/loops/store.js +224 -4
  41. package/dist/core/loops/types.js +346 -1
  42. package/dist/core/loops/verbs.js +739 -6
  43. package/dist/core/schema.js +30 -2
  44. package/dist/core/state.js +62 -0
  45. package/dist/core/worktree.js +58 -0
  46. package/dist/facts.js +360 -5
  47. package/dist/facts.json +359 -4
  48. package/docs/cli.md +10 -7
  49. package/docs/concepts/dispatch-lifecycle.md +228 -0
  50. package/docs/concepts/loop-engine.md +55 -0
  51. package/docs/concepts/multi-agent-workflows.md +167 -166
  52. package/docs/concepts/troubleshooting.md +36 -2
  53. package/docs/index.md +4 -3
  54. package/docs/integrations/hermes.md +78 -0
  55. package/docs/integrations/overview.md +22 -18
  56. package/docs/mcp-schema-changelog.md +8 -1
  57. package/docs/quickstart-existing-project.md +1 -1
  58. package/package.json +5 -4
@@ -1,5 +1,6 @@
1
1
  import crypto from 'node:crypto';
2
2
  import fs from 'node:fs';
3
+ import os from 'node:os';
3
4
  import path from 'node:path';
4
5
  import { buildClaimEnvPrefix } from '../core/execution-profile.js';
5
6
  import { fileURLToPath } from 'node:url';
@@ -11,7 +12,7 @@ import { buildContext, renderContextMarkdown, renderContextPromptTemplate, rende
11
12
  import { buildCoordinationSnapshot } from '../core/coordination.js';
12
13
  import { checkBrainclawInstallableUpdate, getInstalledBrainclawVersion, readDiskBrainclawVersion, renderBrainclawInstallableUpdateNotice } from '../core/brainclaw-version.js';
13
14
  import { loadConfig } from '../core/config.js';
14
- import { loadState, persistState, saveState } from '../core/state.js';
15
+ import { collectLoadValidationWarnings, findLoadValidationWarning, loadState, persistState, saveState } from '../core/state.js';
15
16
  import { generateIdWithLabel } from '../core/ids.js';
16
17
  import { memoryExists } from '../core/io.js';
17
18
  import { generateCandidateIdWithLabel, loadCandidate, saveCandidate } from '../core/candidates.js';
@@ -35,10 +36,11 @@ import { buildOperationalIdentity, loadAllSessions, loadSessionById } from '../c
35
36
  import { validateMcpInput, validateMcpField } from '../core/input-validation.js';
36
37
  import { createCapability, createTool as createRegistryTool } from '../core/registries.js';
37
38
  import { detectAiAgent } from '../core/ai-agent-detection.js';
38
- import { checkGitPresence, scanGitRepos, parseRoots, parseRepoSelection, parseAgentSelection, runGlobalInstall, initReposAndConfigureAgents, readSetupState, ALL_KNOWN_AGENTS, } from './setup.js';
39
+ import { checkGitPresence, scanGitRepos, parseRoots, parseRepoSelection, parseAgentSelection, getDetectedSetupAgentNames, getInstalledAgentNames, runGlobalInstall, initReposAndConfigureAgents, readSetupState, ALL_KNOWN_AGENTS, } from './setup.js';
40
+ import { buildAgentInventory } from '../core/agent-inventory.js';
39
41
  import { resolveEffectiveCwd, resolveProjectRef, resolveTargetStore } from '../core/store-resolution.js';
40
42
  import { probeForQuickSetup, buildQuickSetupProbeResponse, buildOnboardingPreview } from '../core/setup-flow.js';
41
- import { ensureUserStore } from '../core/setup-state.js';
43
+ import { ensureUserStore, resolveHomeDir } from '../core/setup-state.js';
42
44
  import { createPlan, addStep as addStepOp, completeStep as completeStepOp, updateStep as updateStepOp, deleteStep as deleteStepOp, deletePlan as deletePlanOp } from '../core/operations/plan.js';
43
45
  import { sendMessage, ackMessage, countActionable, getThread, hasActiveAssignment } from '../core/messaging.js';
44
46
  import { dispatch, dispatchReview, generateDispatchBrief } from '../core/dispatcher.js';
@@ -48,6 +50,7 @@ import { WorkRequestSchema, CoordinateRequestSchema } from '../core/facade-schem
48
50
  import { getSpawnableAgents, getCapabilityProfile, buildInvokeCommand, validateAgentForDispatch } from '../core/agent-capability.js';
49
51
  import { attemptExecution } from '../core/execution.js';
50
52
  import { createAgentRun, transitionAgentRun } from '../core/agentruns.js';
53
+ import { sweepDeadPidRunningAgentRunsAtRead } from '../core/agentrun-reconciler.js';
51
54
  import { createAssignment, generateAssignmentId, patchAssignmentMessageId, transitionAssignment, bumpActiveAssignmentHeartbeat, } from '../core/assignments.js';
52
55
  import { harvestCandidates } from './harvest.js';
53
56
  export const SCHEMA_VERSION = '1.0.0';
@@ -402,11 +405,25 @@ export const MCP_READ_TOOLS = [
402
405
  required: ['thread_id'],
403
406
  },
404
407
  },
408
+ {
409
+ name: 'bclaw_dispatch_status',
410
+ 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.',
411
+ annotations: { tier: 'facade', category: 'coordination', headlessApproval: 'auto' },
412
+ inputSchema: {
413
+ type: 'object',
414
+ properties: {
415
+ 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.' },
416
+ 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.' },
417
+ 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).' },
418
+ },
419
+ required: ['target_id'],
420
+ },
421
+ },
405
422
  ];
406
423
  const MCP_WRITE_TOOLS = [
407
424
  {
408
425
  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.',
426
+ 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 new 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
427
  annotations: { tier: 'facade', category: 'coordination', headlessApproval: 'prompt' },
411
428
  inputSchema: {
412
429
  type: 'object',
@@ -482,6 +499,21 @@ const MCP_WRITE_TOOLS = [
482
499
  },
483
500
  },
484
501
  },
502
+ {
503
+ name: 'bclaw_init_project',
504
+ 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.",
505
+ annotations: { tier: 'standard', category: 'session', headlessApproval: 'prompt' },
506
+ inputSchema: {
507
+ type: 'object',
508
+ properties: {
509
+ path: { type: 'string', description: 'Absolute or relative path of the target folder. Resolved via path.resolve(callerCwd, path).' },
510
+ force: { type: 'boolean', description: 'Pass --force to init (rebuild managed config). Default false.' },
511
+ project_mode: { type: 'string', description: 'Optional project mode (single-project, multi-project, auto).' },
512
+ link_as: { type: 'string', description: 'Optional name to register the cross_project_link under. Defaults to path basename.' },
513
+ },
514
+ required: ['path'],
515
+ },
516
+ },
485
517
  {
486
518
  name: 'bclaw_write_note',
487
519
  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 +662,33 @@ const MCP_WRITE_TOOLS = [
630
662
  },
631
663
  {
632
664
  name: 'bclaw_add_step',
633
- description: 'Add a sub-step to a plan item. Requires contributor trust level or above.',
665
+ 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
666
  annotations: { tier: 'standard', category: 'coordination', headlessApproval: 'auto' },
635
667
  inputSchema: {
636
668
  type: 'object',
637
669
  properties: {
638
670
  planId: { type: 'string', description: 'Plan item ID.' },
639
- text: { type: 'string', description: 'Step description.' },
671
+ data: {
672
+ type: 'object',
673
+ description: 'Canonical step payload: { text, title?, assignee? }. title is accepted as an alias for text.',
674
+ properties: {
675
+ text: { type: 'string', description: 'Step description.' },
676
+ title: { type: 'string', description: 'Alias for text.' },
677
+ assignee: { type: 'string', description: 'Optional assignee.' },
678
+ },
679
+ },
680
+ text: { type: 'string', description: 'Legacy top-level step description; prefer data.text.' },
640
681
  agent: { type: 'string', description: 'Agent name.' },
641
682
  agentId: { type: 'string', description: 'Registered agent id.' },
642
- assignee: { type: 'string', description: 'Optional assignee.' },
683
+ assignee: { type: 'string', description: 'Legacy top-level optional assignee; prefer data.assignee.' },
684
+ 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
685
  },
644
- required: ['planId', 'text'],
686
+ required: ['planId'],
645
687
  },
646
688
  },
647
689
  {
648
690
  name: 'bclaw_complete_step',
649
- description: 'Mark a plan sub-step as done. Requires contributor trust level or above.',
691
+ 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
692
  annotations: { tier: 'standard', category: 'coordination', headlessApproval: 'auto' },
651
693
  inputSchema: {
652
694
  type: 'object',
@@ -655,13 +697,14 @@ const MCP_WRITE_TOOLS = [
655
697
  stepId: { type: 'string', description: 'Step ID to complete.' },
656
698
  agent: { type: 'string', description: 'Agent name.' },
657
699
  agentId: { type: 'string', description: 'Registered agent id.' },
700
+ project: { type: 'string', description: 'Optional: name of a linked project to complete the step in. Defaults to the current project.' },
658
701
  },
659
702
  required: ['planId', 'stepId'],
660
703
  },
661
704
  },
662
705
  {
663
706
  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.',
707
+ 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
708
  annotations: { tier: 'standard', category: 'coordination', headlessApproval: 'auto' },
666
709
  inputSchema: {
667
710
  type: 'object',
@@ -673,13 +716,14 @@ const MCP_WRITE_TOOLS = [
673
716
  assignee: { type: 'string', description: 'New assignee (empty string to unassign).' },
674
717
  agent: { type: 'string', description: 'Agent name.' },
675
718
  agentId: { type: 'string', description: 'Registered agent id.' },
719
+ project: { type: 'string', description: 'Optional: name of a linked project to update the step in. Defaults to the current project.' },
676
720
  },
677
721
  required: ['planId', 'stepId'],
678
722
  },
679
723
  },
680
724
  {
681
725
  name: 'bclaw_delete_step',
682
- description: 'Remove a sub-step from a plan. Requires contributor trust level or above.',
726
+ 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
727
  annotations: { tier: 'standard', category: 'coordination', headlessApproval: 'prompt' },
684
728
  inputSchema: {
685
729
  type: 'object',
@@ -688,6 +732,7 @@ const MCP_WRITE_TOOLS = [
688
732
  stepId: { type: 'string', description: 'Step ID to delete.' },
689
733
  agent: { type: 'string', description: 'Agent name.' },
690
734
  agentId: { type: 'string', description: 'Registered agent id.' },
735
+ project: { type: 'string', description: 'Optional: name of a linked project to delete the step from. Defaults to the current project.' },
691
736
  },
692
737
  required: ['planId', 'stepId'],
693
738
  },
@@ -877,6 +922,7 @@ const MCP_WRITE_TOOLS = [
877
922
  task: { type: 'string', description: 'Optional task description (used as claim description when creating a claim).' },
878
923
  messageId: { type: 'string', description: 'Optional message/thread ID for traceability.' },
879
924
  contextTarget: { type: 'string', description: 'Optional path passed to bclaw_get_context to filter memory.' },
925
+ 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
926
  agent: { type: 'string', description: 'Agent name.' },
881
927
  agentId: { type: 'string', description: 'Registered agent id.' },
882
928
  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 +932,13 @@ const MCP_WRITE_TOOLS = [
886
932
  },
887
933
  {
888
934
  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.',
935
+ 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
936
  annotations: { tier: 'facade', category: 'coordination', headlessApproval: 'auto' },
891
937
  inputSchema: {
892
938
  type: 'object',
893
939
  properties: {
894
940
  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.' },
941
+ 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
942
  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
943
  targetAgents: { type: 'array', items: { type: 'string' }, description: 'Agent names to target. If omitted, all spawnable agents are used.' },
898
944
  constraints: { type: 'object', description: 'Optional structured constraints passed alongside the brief (e.g. deadline, reviewCriteria).' },
@@ -903,6 +949,8 @@ const MCP_WRITE_TOOLS = [
903
949
  agent: { type: 'string', description: 'Caller agent name.' },
904
950
  agentId: { type: 'string', description: 'Caller registered agent id.' },
905
951
  project: { type: 'string', description: 'Optional (pln#359 phase 1b): name of a linked project to dispatch into. When set, claim/assignment/message all land in the target project — the target agent picks the brief up async via its own bclaw_work. Auto-spawn is disabled in cross-project mode. Accepts cross_project_links and workspace store-chain children (see `brainclaw link list`).' },
952
+ allow_dirty: { type: 'boolean', description: 'Override the scope-aware dirty-working-tree guard (trp#371 Tier 2). The guard runs only for worktree-spawning intents (assign/review/reroute) and blocks only when uncommitted files overlap — or cannot be proven disjoint from — the dispatch scope (the worker spawns from HEAD and will not see them). `.brainclaw/` and `.git/` are always excluded. Set true to proceed anyway (the block is downgraded to a warning that lists the overlapping files). Boolean; the string "true"/"false" are also coerced.' },
953
+ ref: { type: 'string', description: 'Optional git ref (commit/branch/tag) for assign/review/reroute: the dispatched worker builds its worktree from this ref instead of HEAD. When set, uncommitted working-tree changes are intentionally out of scope and the dirty guard allows the dispatch. Ignored by consult/ideate/summarize (no worktree).' },
906
954
  },
907
955
  required: ['intent', 'task'],
908
956
  },
@@ -924,7 +972,7 @@ const MCP_WRITE_TOOLS = [
924
972
  intent: {
925
973
  type: 'string',
926
974
  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.',
975
+ 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
976
  },
929
977
  loop_id: { type: 'string', description: 'Target loop id (lop_…). Required for every intent except open and list.' },
930
978
  kind: { type: 'string', enum: ['review', 'ideation', 'implementation', 'research', 'debug'], description: 'Loop kind for open / list filter.' },
@@ -935,7 +983,7 @@ const MCP_WRITE_TOOLS = [
935
983
  linked: { type: 'object', description: 'Optional top-level plan/sequence refs (open).' },
936
984
  stop_condition: { type: 'object', description: 'Optional stop_condition override (open). Composite any/all supported.' },
937
985
  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.' },
986
+ 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
987
  include_events: { type: 'boolean', description: 'get: include the event journal in the response.' },
940
988
  limit: { type: 'number', description: 'list: max loops returned.' },
941
989
  offset: { type: 'number', description: 'list: pagination offset.' },
@@ -952,6 +1000,7 @@ const MCP_WRITE_TOOLS = [
952
1000
  reason: { type: 'string', description: 'advance / pause / close: optional reason string.' },
953
1001
  expected_version: { type: 'number', description: 'Accepted for RFC compatibility on mutating intents, but not enforced until lock/CAS wiring lands.' },
954
1002
  client_request_id: { type: 'string', description: 'Accepted for RFC compatibility on mutating intents, but not enforced until lock/idempotency wiring lands.' },
1003
+ project: { type: 'string', description: 'Optional linked project name/path. Routes loop reads and mutations to that project. Defaults to the current cwd.' },
955
1004
  agent: { type: 'string', description: 'Caller agent name.' },
956
1005
  agentId: { type: 'string', description: 'Caller registered agent id (enforced for slot-bound auth in complete_turn).' },
957
1006
  },
@@ -1038,13 +1087,13 @@ const MCP_WRITE_TOOLS = [
1038
1087
  // Promoted to `standard` tier at the v1.0 cut.
1039
1088
  {
1040
1089
  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.',
1090
+ 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
1091
  annotations: { tier: 'standard', category: 'memory', headlessApproval: 'auto' },
1043
1092
  inputSchema: {
1044
1093
  type: 'object',
1045
1094
  properties: {
1046
1095
  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).' },
1096
+ 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
1097
  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
1098
  },
1050
1099
  required: ['entity'],
@@ -1215,6 +1264,7 @@ export const FACADE_ORDER = [
1215
1264
  'bclaw_context',
1216
1265
  'bclaw_coordinate',
1217
1266
  'bclaw_dispatch',
1267
+ 'bclaw_dispatch_status',
1218
1268
  'bclaw_loop',
1219
1269
  'bclaw_setup',
1220
1270
  ];
@@ -2345,8 +2395,14 @@ async function _executeMcpToolCallInner(payload) {
2345
2395
  const repos = scanGitRepos(roots);
2346
2396
  const selectedRepos = parseRepoSelection(choice, repos, cwd);
2347
2397
  const detected = detectAiAgent(env);
2348
- const agentList = ALL_KNOWN_AGENTS.map((a, i) => ` ${i + 1}) ${a}${a === detected?.name ? ' ← detected' : ''}`).join('\n');
2349
- return { response: toolResponse({ content: [{ type: 'text', text: `Selected ${selectedRepos.length} repo(s). Detected AI agent: ${detected?.name ?? 'none'}.\n\nAvailable agents:\n${agentList}\n\nAsk the user which agents to configure.` }], structuredContent: { pending_question: 'agent_selection', roots: rootsArg, repo_selection: choice, selected_repos: selectedRepos.map((r) => ({ path: r.path, name: r.name })), detected_agent: detected?.name ?? null, all_agents: ALL_KNOWN_AGENTS, prompt: 'Please ask the user: "Which agents to configure? Reply: (d)etected, (a)ll, or agent names like claude-code,cursor"' } }) };
2398
+ const installedAgents = getInstalledAgentNames(buildAgentInventory(resolveHomeDir(env) ?? os.homedir(), env));
2399
+ const detectedSetupAgents = getDetectedSetupAgentNames(detected?.name, installedAgents);
2400
+ const agentList = ALL_KNOWN_AGENTS.map((a, i) => {
2401
+ const tag = a === detected?.name ? ' ← detected' : installedAgents.includes(a) ? ' ← installed' : '';
2402
+ return ` ${i + 1}) ${a}${tag}`;
2403
+ }).join('\n');
2404
+ const detectedLine = detectedSetupAgents.length > 0 ? `\nDetected install set: ${detectedSetupAgents.join(', ')}\n` : '\n';
2405
+ return { response: toolResponse({ content: [{ type: 'text', text: `Selected ${selectedRepos.length} repo(s). Detected AI agent: ${detected?.name ?? 'none'}.${detectedLine}\nAvailable agents:\n${agentList}\n\nAsk the user which agents to configure.` }], structuredContent: { pending_question: 'agent_selection', roots: rootsArg, repo_selection: choice, selected_repos: selectedRepos.map((r) => ({ path: r.path, name: r.name })), detected_agent: detected?.name ?? null, installed_agents: installedAgents, detected_setup_agents: detectedSetupAgents, all_agents: ALL_KNOWN_AGENTS, prompt: 'Please ask the user: "Which agents to configure? Reply: (d)etected installed, (a)ll, or agent names like claude-code,cursor"' } }) };
2350
2406
  }
2351
2407
  if (step === 'agent_selection') {
2352
2408
  if (!rootsArg || !repoSelectionArg) {
@@ -2356,7 +2412,8 @@ async function _executeMcpToolCallInner(payload) {
2356
2412
  const repos = scanGitRepos(roots);
2357
2413
  const selectedRepos = parseRepoSelection(repoSelectionArg, repos, cwd);
2358
2414
  const detected = detectAiAgent(env);
2359
- const selectedAgents = parseAgentSelection(choice, detected?.name);
2415
+ const installedAgents = getInstalledAgentNames(buildAgentInventory(resolveHomeDir(env) ?? os.homedir(), env));
2416
+ const selectedAgents = parseAgentSelection(choice, detected?.name, installedAgents);
2360
2417
  const summary = [];
2361
2418
  const written = runGlobalInstall(selectedAgents, env);
2362
2419
  for (const f of written)
@@ -2377,6 +2434,102 @@ async function _executeMcpToolCallInner(payload) {
2377
2434
  }
2378
2435
  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
2436
  }
2437
+ if (name === 'bclaw_init_project') {
2438
+ const rawPath = typeof args.path === 'string' ? args.path.trim() : '';
2439
+ if (!rawPath) {
2440
+ return { response: createToolErrorResponse('validation_error', 'path is required') };
2441
+ }
2442
+ const force = args.force === true;
2443
+ const projectModeArg = typeof args.project_mode === 'string' ? args.project_mode : undefined;
2444
+ const linkAs = typeof args.link_as === 'string' && args.link_as.trim().length > 0
2445
+ ? args.link_as.trim()
2446
+ : undefined;
2447
+ const resolvedPath = path.isAbsolute(rawPath) ? rawPath : path.resolve(cwd, rawPath);
2448
+ let wasAlreadyInitialized = false;
2449
+ if (memoryExists(resolvedPath) && !force) {
2450
+ wasAlreadyInitialized = true;
2451
+ }
2452
+ else {
2453
+ if (!fs.existsSync(resolvedPath)) {
2454
+ try {
2455
+ fs.mkdirSync(resolvedPath, { recursive: true });
2456
+ }
2457
+ catch (err) {
2458
+ return {
2459
+ response: createToolErrorResponse('init_project_failed', `Failed to create target directory '${resolvedPath}': ${err instanceof Error ? err.message : String(err)}`),
2460
+ };
2461
+ }
2462
+ }
2463
+ try {
2464
+ const { runInit } = await import('./init.js');
2465
+ await runInit({
2466
+ yes: true,
2467
+ cwd: resolvedPath,
2468
+ force,
2469
+ ...(projectModeArg ? { projectMode: projectModeArg } : {}),
2470
+ });
2471
+ }
2472
+ catch (err) {
2473
+ return {
2474
+ response: createToolErrorResponse('init_project_failed', `runInit failed for '${resolvedPath}': ${err instanceof Error ? err.message : String(err)}`),
2475
+ };
2476
+ }
2477
+ }
2478
+ let projectName;
2479
+ try {
2480
+ projectName = loadConfig(resolvedPath).project_name;
2481
+ }
2482
+ catch {
2483
+ projectName = path.basename(resolvedPath);
2484
+ }
2485
+ let linkName;
2486
+ try {
2487
+ const { addCrossProjectLink } = await import('../core/cross-project.js');
2488
+ const link = addCrossProjectLink({
2489
+ path: resolvedPath,
2490
+ name: linkAs ?? projectName,
2491
+ cwd,
2492
+ force,
2493
+ });
2494
+ linkName = link.name ?? path.basename(resolvedPath);
2495
+ }
2496
+ catch (err) {
2497
+ const message = err instanceof Error ? err.message : String(err);
2498
+ // Treat a duplicate link as idempotent success when the caller did
2499
+ // not request --force; the project itself is initialised correctly
2500
+ // and the existing link already points at it.
2501
+ if (/already exists/i.test(message) && !force) {
2502
+ try {
2503
+ const { resolveCrossProjectLinks } = await import('../core/cross-project.js');
2504
+ const existing = resolveCrossProjectLinks(cwd).find((l) => l.absolutePath === resolvedPath || l.path === rawPath);
2505
+ linkName = existing?.name ?? linkAs ?? projectName;
2506
+ }
2507
+ catch {
2508
+ linkName = linkAs ?? projectName;
2509
+ }
2510
+ }
2511
+ else {
2512
+ return {
2513
+ response: createToolErrorResponse('init_project_failed', `Failed to register cross_project_link: ${message}`),
2514
+ };
2515
+ }
2516
+ }
2517
+ const summary = wasAlreadyInitialized
2518
+ ? `✔ ${resolvedPath} already initialised; linked as '${linkName}'.`
2519
+ : `✔ Initialised brainclaw at ${resolvedPath} and linked as '${linkName}'.`;
2520
+ return {
2521
+ response: toolResponse({
2522
+ content: [{ type: 'text', text: summary }],
2523
+ structuredContent: {
2524
+ status: 'ok',
2525
+ project_name: projectName,
2526
+ path: resolvedPath,
2527
+ link_id: linkName,
2528
+ was_already_initialized: wasAlreadyInitialized,
2529
+ },
2530
+ }),
2531
+ };
2532
+ }
2380
2533
  if (name === 'bclaw_write_note') {
2381
2534
  const resolved = ensureTrust(args, { nameField: 'agent', idField: 'agentId' }, 'contributor', cwd, connectionSessionId);
2382
2535
  if (resolved.error) {
@@ -2887,7 +3040,7 @@ async function _executeMcpToolCallInner(payload) {
2887
3040
  if (resolved.error && resolved.error.kind !== 'identity_error') {
2888
3041
  return { response: createToolErrorResponse(resolved.error.kind, resolved.error.message, resolved.error.details) };
2889
3042
  }
2890
- const result = startSession({
3043
+ const result = await startSession({
2891
3044
  agent: resolved.identity?.agent_name ?? (typeof args.agent === 'string' ? args.agent : undefined),
2892
3045
  agentId: resolved.identity?.agent_id ?? (typeof args.agentId === 'string' ? args.agentId : undefined),
2893
3046
  context: args.context,
@@ -3030,7 +3183,7 @@ async function _executeMcpToolCallInner(payload) {
3030
3183
  if (resolved.error) {
3031
3184
  return { response: createToolErrorResponse(resolved.error.kind, resolved.error.message, resolved.error.details) };
3032
3185
  }
3033
- const result = endSession({
3186
+ const result = await endSession({
3034
3187
  session: args.session,
3035
3188
  agent: resolved.identity?.agent_name,
3036
3189
  agentId: resolved.identity?.agent_id,
@@ -3575,13 +3728,22 @@ async function _executeMcpToolCallInner(payload) {
3575
3728
  return { response: createToolErrorResponse(resolved.error.kind, resolved.error.message, resolved.error.details) };
3576
3729
  }
3577
3730
  const stepPlanId = String(args.planId ?? '').trim();
3578
- const stepText = String(args.text ?? '').trim();
3731
+ const stepData = args.data && typeof args.data === 'object' && !Array.isArray(args.data)
3732
+ ? args.data
3733
+ : {};
3734
+ if ((args.text !== undefined || args.assignee !== undefined) && Object.keys(stepData).length > 0) {
3735
+ console.warn('[brainclaw:warn] bclaw_add_step received legacy top-level fields alongside data.*; using data.* values');
3736
+ }
3737
+ const stepTextRaw = stepData.text ?? stepData.title ?? args.text;
3738
+ const stepText = typeof stepTextRaw === 'string' ? stepTextRaw.trim() : '';
3739
+ const stepAssignee = (stepData.assignee ?? args.assignee);
3579
3740
  if (!stepPlanId)
3580
3741
  return { response: createToolErrorResponse('validation_error', 'Missing required argument: planId') };
3581
3742
  if (!stepText)
3582
- return { response: createToolErrorResponse('validation_error', 'Missing required argument: text') };
3743
+ return { response: createToolErrorResponse('validation_error', 'Missing required argument: data.text') };
3744
+ const stepTargetCwd = resolveProjectCwd(args.project, cwd);
3583
3745
  try {
3584
- const result = addStepOp({ planId: stepPlanId, text: stepText, assignee: args.assignee }, cwd);
3746
+ const result = addStepOp({ planId: stepPlanId, text: stepText, assignee: stepAssignee }, stepTargetCwd);
3585
3747
  return {
3586
3748
  response: toolResponse({
3587
3749
  content: [{ type: 'text', text: `✔ Step added: [${result.stepId}] ${stepText} (${result.doneSteps}/${result.totalSteps} done)` }],
@@ -3614,8 +3776,9 @@ async function _executeMcpToolCallInner(payload) {
3614
3776
  return { response: createToolErrorResponse('validation_error', 'Missing required argument: planId') };
3615
3777
  if (!csStepId)
3616
3778
  return { response: createToolErrorResponse('validation_error', 'Missing required argument: stepId') };
3779
+ const csTargetCwd = resolveProjectCwd(args.project, cwd);
3617
3780
  try {
3618
- const result = completeStepOp({ planId: csPlanId, stepId: csStepId }, cwd);
3781
+ const result = completeStepOp({ planId: csPlanId, stepId: csStepId }, csTargetCwd);
3619
3782
  return {
3620
3783
  response: toolResponse({
3621
3784
  content: [{ type: 'text', text: `✔ Step completed: [${result.stepId}] (${result.doneSteps}/${result.totalSteps} done)` }],
@@ -3653,6 +3816,7 @@ async function _executeMcpToolCallInner(payload) {
3653
3816
  if (args.status && !validStatuses.includes(String(args.status))) {
3654
3817
  return { response: createToolErrorResponse('validation_error', `Invalid status: ${args.status}. Valid: ${validStatuses.join(', ')}`) };
3655
3818
  }
3819
+ const usTargetCwd = resolveProjectCwd(args.project, cwd);
3656
3820
  try {
3657
3821
  const result = updateStepOp({
3658
3822
  planId: usPlanId,
@@ -3660,7 +3824,7 @@ async function _executeMcpToolCallInner(payload) {
3660
3824
  status: args.status,
3661
3825
  text: args.text,
3662
3826
  assignee: args.assignee,
3663
- }, cwd);
3827
+ }, usTargetCwd);
3664
3828
  const changes = [];
3665
3829
  if (args.status)
3666
3830
  changes.push(`status=${args.status}`);
@@ -3701,8 +3865,9 @@ async function _executeMcpToolCallInner(payload) {
3701
3865
  return { response: createToolErrorResponse('validation_error', 'Missing required argument: planId') };
3702
3866
  if (!dsStepId)
3703
3867
  return { response: createToolErrorResponse('validation_error', 'Missing required argument: stepId') };
3868
+ const dsTargetCwd = resolveProjectCwd(args.project, cwd);
3704
3869
  try {
3705
- const result = deleteStepOp({ planId: dsPlanId, stepId: dsStepId }, cwd);
3870
+ const result = deleteStepOp({ planId: dsPlanId, stepId: dsStepId }, dsTargetCwd);
3706
3871
  return {
3707
3872
  response: toolResponse({
3708
3873
  content: [{ type: 'text', text: `✔ Step deleted: [${result.stepId}] (${result.doneSteps}/${result.totalSteps} remaining)` }],
@@ -4075,16 +4240,17 @@ async function _executeMcpToolCallInner(payload) {
4075
4240
  return { response: createToolErrorResponse('validation_error', parseResult.error.message) };
4076
4241
  }
4077
4242
  const workReq = parseResult.data;
4243
+ const targetCwd = resolveProjectCwd(workReq.project, cwd);
4078
4244
  const useCompact = workReq.compact !== false; // default true
4079
4245
  const warnings = [];
4080
4246
  // Step 1: implicit session start (handles auto-registration internally)
4081
4247
  let sessionResult;
4082
4248
  try {
4083
- sessionResult = startSession({
4249
+ sessionResult = await startSession({
4084
4250
  agent: typeof args.agent === 'string' ? args.agent : undefined,
4085
4251
  agentId: typeof args.agentId === 'string' ? args.agentId : undefined,
4086
4252
  context: workReq.contextTarget,
4087
- cwd,
4253
+ cwd: targetCwd,
4088
4254
  });
4089
4255
  }
4090
4256
  catch (sessionErr) {
@@ -4102,14 +4268,14 @@ async function _executeMcpToolCallInner(payload) {
4102
4268
  try {
4103
4269
  let sinceSession;
4104
4270
  if (workReq.intent === 'resume') {
4105
- const previousSession = loadAllSessions(cwd)
4271
+ const previousSession = loadAllSessions(targetCwd)
4106
4272
  .find((s) => s.agent === sessionResult.agent && s.session_id !== sessionResult.session_id);
4107
4273
  sinceSession = previousSession?.session_id;
4108
4274
  }
4109
4275
  contextResult = buildContext({
4110
4276
  target: workReq.contextTarget ?? workReq.scope,
4111
4277
  agent: sessionResult.agent,
4112
- cwd,
4278
+ cwd: targetCwd,
4113
4279
  sinceSession,
4114
4280
  });
4115
4281
  }
@@ -4118,7 +4284,7 @@ async function _executeMcpToolCallInner(payload) {
4118
4284
  let claimId;
4119
4285
  let claimStatus = 'none';
4120
4286
  if (workReq.intent === 'execute' && workReq.scope) {
4121
- const existingClaims = listClaims(cwd).filter((c) => c.status === 'active' && c.agent === sessionResult.agent && c.scope === workReq.scope);
4287
+ const existingClaims = listClaims(targetCwd).filter((c) => c.status === 'active' && c.agent === sessionResult.agent && c.scope === workReq.scope);
4122
4288
  if (existingClaims.length > 0) {
4123
4289
  claimId = existingClaims[0].id;
4124
4290
  claimStatus = 'existing';
@@ -4139,11 +4305,11 @@ async function _executeMcpToolCallInner(payload) {
4139
4305
  status: 'active',
4140
4306
  plan_id: workReq.planId,
4141
4307
  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);
4308
+ }, targetCwd);
4309
+ 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
4310
  claimStatus = 'created';
4145
4311
  // Policy check post-claim
4146
- const policyResult = checkPolicy({ scope: workReq.scope, agent: sessionResult.agent, agentId: sessionResult.agent_id, cwd });
4312
+ const policyResult = checkPolicy({ scope: workReq.scope, agent: sessionResult.agent, agentId: sessionResult.agent_id, cwd: targetCwd });
4147
4313
  for (const w of policyResult.warnings.filter((pw) => pw.kind !== 'no_claim')) {
4148
4314
  const idLabel = w.id ? ` (${w.id})` : '';
4149
4315
  warnings.push(`[${w.kind}]${idLabel} ${w.message}`);
@@ -4185,6 +4351,34 @@ async function _executeMcpToolCallInner(payload) {
4185
4351
  _full_context_hint: 'Use bclaw_context(kind="memory") for the full payload.',
4186
4352
  };
4187
4353
  }
4354
+ // pln#513 step 1 — bootstrap hint. When the project lacks a usable
4355
+ // PROJECT.md (absent or 0 bytes), surface a hint so the agent knows
4356
+ // the canonical entry-point. Cheap probe — one fs.statSync, no
4357
+ // gating flag. The literal next_action mirrors the documented
4358
+ // canonical-grammar call so callers can suggest it verbatim.
4359
+ let bootstrapRecommended;
4360
+ let nextAction;
4361
+ try {
4362
+ const fsMod = await import('node:fs');
4363
+ const pathMod = await import('node:path');
4364
+ const projectMdPath = pathMod.join(targetCwd, 'PROJECT.md');
4365
+ let needsBootstrap = false;
4366
+ try {
4367
+ const stat = fsMod.statSync(projectMdPath);
4368
+ if (!stat.isFile() || stat.size === 0)
4369
+ needsBootstrap = true;
4370
+ }
4371
+ catch {
4372
+ needsBootstrap = true; // ENOENT → absent
4373
+ }
4374
+ bootstrapRecommended = needsBootstrap;
4375
+ if (needsBootstrap) {
4376
+ nextAction = "bclaw_coordinate(intent='ideate', preset='bootstrap')";
4377
+ }
4378
+ }
4379
+ catch {
4380
+ // Best-effort: never block bclaw_work on the probe.
4381
+ }
4188
4382
  const facadeResponse = {
4189
4383
  status: 'ok',
4190
4384
  intent: workReq.intent,
@@ -4195,12 +4389,17 @@ async function _executeMcpToolCallInner(payload) {
4195
4389
  session_id: sessionResult.session_id,
4196
4390
  warnings,
4197
4391
  duration_ms: Date.now() - startMs,
4392
+ bootstrap_recommended: bootstrapRecommended,
4393
+ next_action: nextAction,
4198
4394
  };
4199
4395
  const summaryParts = [`✔ bclaw_work [${workReq.intent}] session=${sessionResult.session_id}`];
4200
4396
  if (claimId)
4201
4397
  summaryParts.push(`claim=${claimId} (${claimStatus})`);
4202
4398
  if (useCompact)
4203
4399
  summaryParts.push('mode=compact (use bclaw_context for full payload)');
4400
+ if (bootstrapRecommended) {
4401
+ summaryParts.push(`💡 PROJECT.md missing — call ${nextAction} to open a bootstrap loop`);
4402
+ }
4204
4403
  if (warnings.length > 0)
4205
4404
  summaryParts.push(warnings.map((w) => `⚠ ${w}`).join('\n'));
4206
4405
  return {
@@ -4218,15 +4417,69 @@ async function _executeMcpToolCallInner(payload) {
4218
4417
  return { response: createToolErrorResponse('validation_error', parseResult.error.message) };
4219
4418
  }
4220
4419
  const req = parseResult.data;
4420
+ // pln#511 step 2 — preset selector validation. Presets are kind-
4421
+ // specific in v1: only intent='ideate' carries them. Unknown names
4422
+ // are rejected up-front against the registry so the handler never
4423
+ // silently falls back to the kind-default. The bootstrap preset
4424
+ // also enforces a dispatch constraint (can_753a083a): the champion
4425
+ // must be a human-connected agent, never a sandboxed worker —
4426
+ // checked below once the senderAgent is resolved.
4427
+ if (req.preset !== undefined) {
4428
+ if (req.intent !== 'ideate') {
4429
+ return {
4430
+ 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'.`),
4431
+ };
4432
+ }
4433
+ const { PRESETS: PRESETS_REGISTRY } = await import('../core/loops/presets/index.js');
4434
+ if (!(req.preset in PRESETS_REGISTRY)) {
4435
+ const validNames = Object.keys(PRESETS_REGISTRY).join(', ') || '(none)';
4436
+ return {
4437
+ response: createToolErrorResponse('unknown_preset', `Unknown preset '${req.preset}'. Valid preset names: ${validNames}.`),
4438
+ };
4439
+ }
4440
+ }
4441
+ // can_30c295b4 / trp#371 Tier 2 — the scope-aware dirty-working-tree
4442
+ // guard runs LOWER DOWN, after dispatchCwd / isCrossProject are
4443
+ // resolved (so it probes the dispatch TARGET, not the source, and only
4444
+ // for the intents that actually spawn a worktree worker). See the
4445
+ // assessDirtyDispatchGuard call after the cross-project block.
4221
4446
  const warnings = [];
4222
4447
  const artifacts = [];
4223
4448
  const side_effects = [];
4449
+ // can_5e62334e — codex sandboxed dispatches cannot commit in worktrees
4450
+ // because `.git` is a file pointer to the parent repo's
4451
+ // .git/worktrees/<wt>/ directory, which lives OUTSIDE the worktree's
4452
+ // writable root that `--sandbox workspace-write` permits. Any
4453
+ // codex worker that runs `git commit` will fail with `index.lock:
4454
+ // Permission denied`. Warn callers up-front so briefs don't request
4455
+ // per-bug commits; the coordinator must harvest the worktree
4456
+ // diff via `git diff` and commit from a non-sandboxed cwd.
4457
+ if (Array.isArray(req.targetAgents) && req.targetAgents.includes('codex')) {
4458
+ 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.');
4459
+ }
4224
4460
  const senderAgent = typeof args.agent === 'string' && args.agent.trim()
4225
4461
  ? args.agent.trim()
4226
4462
  : 'bclaw_coordinate';
4227
4463
  const senderAgentId = typeof args.agentId === 'string' && args.agentId.trim()
4228
4464
  ? args.agentId.trim()
4229
4465
  : undefined;
4466
+ // pln#511 step 2 — bootstrap preset dispatch constraint (can_753a083a).
4467
+ // The bootstrap loop's champion must be a human-connected agent: it
4468
+ // asks the operator clarifying questions and writes PROJECT.md. A
4469
+ // sandboxed worker (codex / github-copilot) cannot reach the human,
4470
+ // so the loop would stall in `clarify`. Enforce by requiring
4471
+ // targetAgents to be empty (= single-agent / self-champion mode)
4472
+ // or to contain only the caller. Other presets are unrestricted.
4473
+ if (req.preset === 'bootstrap') {
4474
+ const targets = req.targetAgents ?? [];
4475
+ const onlyCaller = targets.length === 0
4476
+ || (targets.length === 1 && targets[0] === senderAgent);
4477
+ if (!onlyCaller) {
4478
+ return {
4479
+ 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)}].`),
4480
+ };
4481
+ }
4482
+ }
4230
4483
  const commandHints = [];
4231
4484
  const preparedInvokes = [];
4232
4485
  // pln#359 phase 1b — cross-project routing. When `project` is set, all
@@ -4243,6 +4496,43 @@ async function _executeMcpToolCallInner(payload) {
4243
4496
  warnings.push(`cross-project dispatch (project='${req.project}') — auto-spawn disabled; the target agent picks up the brief async via its own bclaw_work.`);
4244
4497
  }
4245
4498
  const effectiveAutoExecute = isCrossProject ? false : req.autoExecute;
4499
+ // can_30c295b4 / trp#371 Tier 2 — scope-aware dirty-working-tree guard.
4500
+ // Only intents that spawn a worktree worker from HEAD can review/edit
4501
+ // stale code, so consult/ideate/summarize are NOT guarded (no worktree
4502
+ // → nothing to protect). Cross-project dispatch is inbox-only (no local
4503
+ // worktree spawned here) so it is skipped too — the target agent builds
4504
+ // its own worktree later via bclaw_work. The guard compares the dirty
4505
+ // files against the dispatch scope and only blocks when overlap can't be
4506
+ // ruled out; allow_dirty=true downgrades a block to a warning, and an
4507
+ // explicit ref makes working-tree dirt intentionally out of scope.
4508
+ const WORKTREE_SPAWNING_INTENTS = new Set(['assign', 'review', 'reroute']);
4509
+ if (!isCrossProject && WORKTREE_SPAWNING_INTENTS.has(req.intent)) {
4510
+ // Probe with the SAME scope the dispatch will actually claim, so the
4511
+ // resolution mirrors reality (codex r1): assign falls back to the task
4512
+ // text (mcp ~assignScope), reroute to the targeted active claim's scope.
4513
+ let guardScope = req.scope;
4514
+ if (req.intent === 'assign') {
4515
+ guardScope = req.scope ?? req.task;
4516
+ }
4517
+ else if (req.intent === 'reroute' && !req.scope) {
4518
+ guardScope = listClaims(dispatchCwd).find((c) => c.status === 'active')?.scope;
4519
+ }
4520
+ const { assessDirtyDispatchGuard } = await import('../core/dirty-scope.js');
4521
+ const assessment = assessDirtyDispatchGuard({
4522
+ cwd: dispatchCwd,
4523
+ scope: guardScope,
4524
+ allowDirty: req.allow_dirty,
4525
+ checkoutRef: req.ref,
4526
+ });
4527
+ if (assessment.decision === 'block') {
4528
+ return {
4529
+ response: createToolErrorResponse('dirty_working_tree', `${assessment.reason} (cwd: ${dispatchCwd})`),
4530
+ };
4531
+ }
4532
+ if (assessment.decision === 'warn') {
4533
+ warnings.push(`dirty_working_tree: ${assessment.reason}`);
4534
+ }
4535
+ }
4246
4536
  /** Run E2E execution phase on prepared delivery entries. Returns overall execution status. */
4247
4537
  const runCoordinateExecution = async (prepared, opts) => {
4248
4538
  let overall = 'inbox_only';
@@ -4495,6 +4785,10 @@ async function _executeMcpToolCallInner(payload) {
4495
4785
  dispatcherAgent: senderAgent,
4496
4786
  sessionId: connectionSessionId,
4497
4787
  cwd: dispatchCwd,
4788
+ // createCoordinatorClaim guarantees the worktree reflects this ref
4789
+ // (resets a stale branch / re-points a reused worktree) — see the
4790
+ // worktreeBaseRef invariant there (pln#520 Tier 2).
4791
+ worktreeBaseRef: req.ref,
4498
4792
  });
4499
4793
  const claimId = claimResult.claimId;
4500
4794
  if (claimResult.worktreeWarning) {
@@ -4735,6 +5029,7 @@ async function _executeMcpToolCallInner(payload) {
4735
5029
  dispatcherAgent: senderAgent,
4736
5030
  sessionId: connectionSessionId,
4737
5031
  cwd: dispatchCwd,
5032
+ worktreeBaseRef: req.ref,
4738
5033
  });
4739
5034
  if (claimResult.worktreeWarning)
4740
5035
  out.warnings.push(claimResult.worktreeWarning);
@@ -4923,6 +5218,7 @@ async function _executeMcpToolCallInner(payload) {
4923
5218
  dispatcherAgent: senderAgent,
4924
5219
  sessionId: connectionSessionId,
4925
5220
  cwd: dispatchCwd,
5221
+ worktreeBaseRef: req.ref,
4926
5222
  });
4927
5223
  newClaimId = rerouteClaimResult.claimId;
4928
5224
  if (rerouteClaimResult.worktreeWarning) {
@@ -5043,186 +5339,318 @@ async function _executeMcpToolCallInner(payload) {
5043
5339
  : messages.map((m, i) => `[${i + 1}] ${m.from} → ${m.to}: ${m.text}`).join('\n');
5044
5340
  result = { thread_id: threadId, message_count: messages.length, summary };
5045
5341
  }
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
- });
5342
+ else if (req.intent === 'ideate')
5343
+ ideate: {
5344
+ // pln#492 phase 2.c (open + proposal) + 2.d.2 (multi-agent dispatch).
5345
+ // Single-agent mode (no targetAgents): open the loop with the task
5346
+ // as a proposal seed and stop there — the champion drives manually
5347
+ // via bclaw_loop intent='turn' / 'advance'. Multi-agent mode
5348
+ // (targetAgents passed): also advance to critique and dispatch a
5349
+ // turn to each critic slot with a context-filtered, BM25-ranked,
5350
+ // size-capped brief assembled by buildIdeationBrief.
5351
+ //
5352
+ // pln#511 step 2 when `req.preset` is set, the loop opens with
5353
+ // the preset's phases / stop_condition / protocol instead of the
5354
+ // kind-default ideation chain. Bootstrap preset enforces single-
5355
+ // agent / self-champion mode (validated above), so the multi-
5356
+ // agent dispatch branch never runs for it.
5357
+ //
5358
+ // pln#513 step 2 — labelled block (ideate:) so the bootstrap
5359
+ // join-or-lock path can break out early after assigning result.
5360
+ const loopsModuleRef = await import('../core/loops/index.js');
5361
+ const { openLoop, add_artifact, advance, turn, getLoop, buildIdeationBrief } = loopsModuleRef;
5362
+ const presetSelected = req.preset
5363
+ ? (await import('../core/loops/presets/index.js')).PRESETS[req.preset]
5364
+ : undefined;
5365
+ // pln#513 step 2 — bootstrap join-or-lock. The bootstrap preset is
5366
+ // project-singleton: two concurrent callers must converge on the same
5367
+ // loop rather than open duplicates. Strategy:
5368
+ // 1. Find existing bootstrap loop in {open, paused} → join.
5369
+ // 2. Else check for an active coordination claim (someone is
5370
+ // mid-open). Re-find once; surface bootstrap_coordination_in_progress
5371
+ // if still nothing.
5372
+ // 3. Else acquire the lock, fall through to the normal open path,
5373
+ // release the lock on the way out (success or fail).
5374
+ // The lock is opportunistic, not blocking — a fast retry-in-place
5375
+ // not a wait-on-mutex. Keeps the verb short and predictable.
5376
+ // pln#518 step 1 — bootstrap join-or-lock now delegates to the shared
5377
+ // acquireBootstrapLoop helper (src/core/loops/bootstrap-acquire.ts).
5378
+ // Both the CLI and this MCP handler converge on the same singleton
5379
+ // acquire path, eliminating the race where two concurrent callers both
5380
+ // passed the local scan and called openLoop directly.
5381
+ const senderIdentity = ((senderAgentId ? findAgentIdentityById(senderAgentId, dispatchCwd) : undefined)
5382
+ ?? findAgentIdentityByName(senderAgent, dispatchCwd)
5383
+ ?? ensureAgentRegisteredForDispatch(senderAgent, dispatchCwd));
5384
+ const authorAgentId = senderIdentity?.agent_id ?? senderAgentId;
5385
+ const creatorActor = authorAgentId ?? senderAgent;
5386
+ let bootstrapOpenedLoop;
5387
+ if (req.preset === 'bootstrap') {
5388
+ const { acquireBootstrapLoop, BootstrapCoordinationInProgressError: BcipError } = await import('../core/loops/bootstrap-acquire.js');
5389
+ let acqResult;
5390
+ try {
5391
+ acqResult = acquireBootstrapLoop({
5392
+ actor: senderAgent,
5393
+ agent_id: authorAgentId,
5394
+ created_by: creatorActor,
5395
+ title: req.task.slice(0, 120),
5396
+ goal: req.scope,
5397
+ model: currentModel,
5398
+ }, dispatchCwd);
5399
+ }
5400
+ catch (err) {
5401
+ if (err instanceof BcipError) {
5402
+ return {
5403
+ response: createToolErrorResponse('bootstrap_coordination_in_progress', err.message),
5404
+ };
5405
+ }
5406
+ throw err;
5407
+ }
5408
+ warnings.push(...acqResult.warnings);
5409
+ if (acqResult.action === 'joined') {
5410
+ const jLoop = acqResult.loop;
5411
+ artifacts.push({ type: 'loop', id: jLoop.id });
5412
+ result = {
5413
+ loop_id: jLoop.id,
5414
+ joined_existing: true,
5415
+ current_phase: jLoop.current_phase,
5416
+ status: jLoop.status,
5417
+ mode: 'single_agent',
5418
+ preset: req.preset,
5419
+ };
5420
+ // Skip the rest of the ideate flow — we joined an existing loop.
5421
+ break ideate;
5422
+ }
5423
+ // action === 'opened': helper already called openLoop + released lock.
5424
+ bootstrapOpenedLoop = acqResult.loop;
5077
5425
  }
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,
5426
+ // pln#511 step 2 — bootstrap preset always runs in single-agent
5427
+ // mode: the champion drives the whole loop. Even when the caller
5428
+ // passes targetAgents=[caller] (validated as the only legal non-
5429
+ // empty form), we don't add critic slots and we don't take the
5430
+ // multi-agent dispatch branch. Treating that idiom as "single
5431
+ // agent / self-champion" matches what the constraint check
5432
+ // already enforced upstream.
5433
+ const explicitTargets = Boolean(req.targetAgents
5434
+ && req.targetAgents.length > 0
5435
+ && req.preset !== 'bootstrap');
5436
+ const slots = [
5437
+ {
5438
+ role: 'champion',
5439
+ agent: senderAgent,
5440
+ ...(authorAgentId ? { agent_id: authorAgentId } : {}),
5101
5441
  },
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,
5442
+ ];
5443
+ if (explicitTargets) {
5444
+ for (const agent of req.targetAgents) {
5445
+ const criticIdentity = findAgentIdentityByName(agent, dispatchCwd) ?? ensureAgentRegisteredForDispatch(agent, dispatchCwd);
5446
+ slots.push({
5447
+ role: 'critic',
5448
+ agent,
5449
+ ...(criticIdentity?.agent_id ? { agent_id: criticIdentity.agent_id } : {}),
5173
5450
  });
5174
- turn({
5175
- id: loopId,
5176
- slot_id: slot.slot_id,
5177
- actor: creatorActor,
5178
- input: briefResult.text,
5451
+ }
5452
+ }
5453
+ let loopId;
5454
+ let proposalArtifactId;
5455
+ if (bootstrapOpenedLoop) {
5456
+ // Bootstrap case: helper already opened the loop and released its lock.
5457
+ loopId = bootstrapOpenedLoop.id;
5458
+ artifacts.push({ type: 'loop', id: bootstrapOpenedLoop.id });
5459
+ side_effects.push({ action: 'create', entity: 'loop', id: bootstrapOpenedLoop.id });
5460
+ }
5461
+ else {
5462
+ try {
5463
+ const loop = openLoop({
5464
+ kind: 'ideation',
5465
+ title: req.task.slice(0, 120),
5466
+ goal: req.scope,
5467
+ created_by: creatorActor,
5468
+ slots,
5469
+ ...(presetSelected
5470
+ ? {
5471
+ phases: presetSelected.phases,
5472
+ stop_condition: presetSelected.stop_condition,
5473
+ protocol: presetSelected.protocol,
5474
+ }
5475
+ : {}),
5179
5476
  }, 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,
5477
+ loopId = loop.id;
5478
+ artifacts.push({ type: 'loop', id: loop.id });
5479
+ side_effects.push({ action: 'create', entity: 'loop', id: loop.id });
5480
+ // pln#511 step 2 — skip the proposal-seed artifact when a preset
5481
+ // is in use. The kind-default ideation chain opens at phase
5482
+ // 'proposal' and the seed artifact lives there; presets define
5483
+ // their own initial phase + seeding semantics (bootstrap starts
5484
+ // at 'survey' and produces a signals_report). Forcing a
5485
+ // 'proposal'-phased artifact here would dangle on a phase the
5486
+ // loop doesn't contain. The task text is already captured on
5487
+ // the thread (title + goal).
5488
+ if (!presetSelected) {
5489
+ const proposalBody = req.task.slice(0, 4000);
5490
+ const updated = add_artifact({
5491
+ id: loop.id,
5492
+ actor: creatorActor,
5493
+ artifact: {
5494
+ phase: 'proposal',
5495
+ type: 'proposal',
5496
+ body: proposalBody,
5497
+ produced_by: creatorActor,
5498
+ },
5499
+ }, dispatchCwd);
5500
+ const lastArtifact = updated.artifacts[updated.artifacts.length - 1];
5501
+ proposalArtifactId = lastArtifact?.artifact_id;
5502
+ if (proposalArtifactId) {
5503
+ artifacts.push({ type: 'artifact', id: proposalArtifactId });
5504
+ side_effects.push({ action: 'create', entity: 'artifact', id: proposalArtifactId });
5505
+ }
5506
+ }
5507
+ }
5508
+ catch (err) {
5509
+ const msg = err instanceof Error ? err.message : String(err);
5510
+ return {
5511
+ response: createToolErrorResponse('ideate_failed', `ideate intent: failed to open ideation loop — ${msg}`),
5512
+ };
5513
+ }
5514
+ } // end else (non-bootstrap open path)
5515
+ // pln#492 phase 2.d.2 — multi-agent dispatch. Skipped in single-
5516
+ // agent mode (the champion drives manually).
5517
+ //
5518
+ // pln#511 step 2 — initial phase comes from the actual loop's
5519
+ // first phase, not a hardcoded 'proposal'. Presets like bootstrap
5520
+ // open at 'survey'; the kind-default ideation chain still opens
5521
+ // at 'proposal', so this is backward compatible.
5522
+ let dispatchedCritics = 0;
5523
+ let dispatchedPhase = presetSelected ? presetSelected.phases[0].name : 'proposal';
5524
+ if (explicitTargets) {
5525
+ try {
5526
+ // Build a search-backed BriefMemoryProvider. Maps user-facing
5527
+ // memory categories the brief asks for onto src/core/search.ts
5528
+ // sections (BM25 there). Loop-internal categories
5529
+ // (critique_history / revision_history / synthesis_artifact)
5530
+ // are pulled by the assembler from the thread directly.
5531
+ const searchModule = await import('../core/search.js');
5532
+ const sectionByCategory = {
5533
+ traps: 'traps',
5534
+ decisions: 'decisions',
5535
+ constraints: 'constraints',
5536
+ handoffs: 'handoffs',
5537
+ plans: 'plans',
5538
+ candidates: 'candidates',
5539
+ };
5540
+ const provider = {
5541
+ fetch(category, query, topK) {
5542
+ const section = sectionByCategory[category];
5543
+ if (!section)
5544
+ return [];
5545
+ const results = searchModule.search({
5546
+ query,
5547
+ section,
5548
+ maxResults: topK,
5549
+ cwd: dispatchCwd,
5550
+ includePending: section === 'candidates',
5551
+ });
5552
+ return results.map((r) => ({
5553
+ id: r.id,
5554
+ category,
5555
+ text: r.text,
5556
+ score: r.score,
5557
+ }));
5194
5558
  },
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`);
5559
+ };
5560
+ // Advance proposal → critique. proposal has no advance_gate so
5561
+ // this is unconditional. After advance, the loop sits at the
5562
+ // critique phase ready for critic turns.
5563
+ advance({ id: loopId, actor: creatorActor }, dispatchCwd);
5564
+ const advancedLoop = getLoop(loopId, dispatchCwd);
5565
+ if (!advancedLoop) {
5566
+ throw new Error('ideate dispatch: loop disappeared after advance');
5567
+ }
5568
+ dispatchedPhase = advancedLoop.current_phase;
5569
+ const criticSlots = advancedLoop.slots.filter((s) => s.role === 'critic');
5570
+ for (const slot of criticSlots) {
5571
+ if (!slot.agent)
5572
+ continue;
5573
+ const briefResult = buildIdeationBrief({
5574
+ thread: advancedLoop,
5575
+ slotRole: slot.role,
5576
+ memoryProvider: provider,
5577
+ });
5578
+ turn({
5579
+ id: loopId,
5580
+ slot_id: slot.slot_id,
5581
+ actor: creatorActor,
5582
+ input: briefResult.text,
5583
+ }, dispatchCwd);
5584
+ const queued = queueCoordinateMessage({
5585
+ agent: slot.agent,
5586
+ text: briefResult.text,
5587
+ messageType: 'rfc',
5588
+ ref: loopId,
5589
+ scope: req.scope,
5590
+ tags: ['coordinate', 'ideate', 'loop'],
5591
+ payload: {
5592
+ intent: 'ideate',
5593
+ loop_id: loopId,
5594
+ slot_id: slot.slot_id,
5595
+ phase: advancedLoop.current_phase,
5596
+ iteration: advancedLoop.iteration_count,
5597
+ proposal_artifact_id: proposalArtifactId,
5598
+ },
5599
+ commandMode: 'consult',
5600
+ });
5601
+ void queued;
5602
+ dispatchedCritics += 1;
5603
+ if (briefResult.truncated) {
5604
+ warnings.push(`Brief for critic slot ${slot.slot_id} (${slot.agent}) truncated: ${briefResult.droppedItems} memory items dropped to fit cap`);
5605
+ }
5201
5606
  }
5202
5607
  }
5608
+ catch (err) {
5609
+ const msg = err instanceof Error ? err.message : String(err);
5610
+ warnings.push(`ideate dispatch failed: ${msg}; loop ${loopId} stays at proposal phase`);
5611
+ facadeStatus = 'partial';
5612
+ }
5203
5613
  }
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';
5614
+ if (!explicitTargets) {
5615
+ warnings.push(presetSelected
5616
+ ? `ideate single-agent mode (preset='${req.preset}'): loop opened at phase '${dispatchedPhase}'; champion drives manually via bclaw_loop intent='turn' / 'advance'.`
5617
+ : "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
5618
  }
5619
+ result = {
5620
+ loop_id: loopId,
5621
+ proposal_artifact_id: proposalArtifactId,
5622
+ selected_targets: explicitTargets ? req.targetAgents : [],
5623
+ mode: explicitTargets ? 'multi_agent' : 'single_agent',
5624
+ dispatched_critics: dispatchedCritics,
5625
+ current_phase: dispatchedPhase,
5626
+ ...(presetSelected ? { preset: req.preset } : {}),
5627
+ };
5628
+ // pln#518 step 1 — bootstrap lock is now managed inside acquireBootstrapLoop;
5629
+ // no release needed here.
5209
5630
  }
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
5631
  // Extract execution_status from result if present (assign/reroute set it)
5223
5632
  const resultExecStatus = (result && typeof result === 'object' && 'execution_status' in result)
5224
5633
  ? result.execution_status
5225
5634
  : undefined;
5635
+ // pln#503 phase 3.3: when execution_status === 'delivered_and_started',
5636
+ // attach a self-documenting `verify_with` hint pointing at the assignment
5637
+ // record. Callers should not take delivered_and_started at face value —
5638
+ // it only attests the brief-ack sentinel was touched, not that the worker
5639
+ // is doing useful work. The hint tells them exactly which canonical-
5640
+ // grammar call to make next to verify spawn liveness.
5641
+ let verifyWith;
5642
+ if (resultExecStatus === 'delivered_and_started') {
5643
+ const firstAssignment = artifacts.find((a) => a.type === 'assignment');
5644
+ if (firstAssignment) {
5645
+ verifyWith = {
5646
+ action: 'bclaw_find',
5647
+ entity: 'agent_run',
5648
+ filter: { assignment_id: firstAssignment.id },
5649
+ expected_when_alive: 'agent_run with status="running" AND OS pid alive AND last_event_at within the last few minutes',
5650
+ see_also: 'docs/concepts/dispatch-lifecycle.md',
5651
+ };
5652
+ }
5653
+ }
5226
5654
  const facadeResponse = {
5227
5655
  status: facadeStatus,
5228
5656
  intent: req.intent,
@@ -5232,6 +5660,7 @@ async function _executeMcpToolCallInner(payload) {
5232
5660
  warnings,
5233
5661
  duration_ms: Date.now() - startMs,
5234
5662
  ...(resultExecStatus ? { execution_status: resultExecStatus } : {}),
5663
+ ...(verifyWith ? { verify_with: verifyWith } : {}),
5235
5664
  };
5236
5665
  const summaryParts = [`✔ bclaw_coordinate [${req.intent}] targets=${resolvedAgents.length}`];
5237
5666
  if (resultExecStatus)
@@ -5247,7 +5676,8 @@ async function _executeMcpToolCallInner(payload) {
5247
5676
  }
5248
5677
  if (name === 'bclaw_loop') {
5249
5678
  const { handleBclawLoop } = await import('./loops-handlers.js');
5250
- const result = handleBclawLoop({ args: args, cwd });
5679
+ const targetCwd = resolveProjectCwd(args?.project, cwd);
5680
+ const result = handleBclawLoop({ args: args, cwd: targetCwd });
5251
5681
  return {
5252
5682
  response: toolResponse({
5253
5683
  content: [{ type: 'text', text: result.summary }],
@@ -5321,10 +5751,12 @@ async function _executeMcpToolCallInner(payload) {
5321
5751
  // applied when it hadn't. Under the new contract, an unknown key is
5322
5752
  // a validation_error listing the keys actually honored.
5323
5753
  const KNOWN_FILTER_KEYS = new Set([
5324
- 'status', 'tag', 'author', 'plan_id', 'source', 'auto_generated',
5754
+ 'status', 'tag', 'tags', 'author', 'plan_id', 'source', 'auto_generated',
5755
+ 'assignment_id', 'claim_id', 'message_id',
5325
5756
  'limit', 'offset', 'includeLegacy', 'minAutoReflectConfidence',
5326
5757
  ]);
5327
- const unknownKeys = Object.keys(filter).filter((k) => !KNOWN_FILTER_KEYS.has(k));
5758
+ const agentRunOnlyFilterKeys = new Set(['assignment_id', 'claim_id', 'message_id']);
5759
+ const unknownKeys = Object.keys(filter).filter((k) => !KNOWN_FILTER_KEYS.has(k) || (agentRunOnlyFilterKeys.has(k) && entity !== 'agent_run'));
5328
5760
  if (unknownKeys.length > 0) {
5329
5761
  return {
5330
5762
  response: createToolErrorResponse('validation_error', `Unknown filter key(s): ${unknownKeys.map((k) => `"${k}"`).join(', ')}. ` +
@@ -5332,6 +5764,7 @@ async function _executeMcpToolCallInner(payload) {
5332
5764
  };
5333
5765
  }
5334
5766
  const result = listEntities(entity, targetCwd, filter);
5767
+ const warnings = collectLoadValidationWarnings(entity, targetCwd);
5335
5768
  // structuredContent is the canonical MCP return channel that clients
5336
5769
  // (VS Code extension, Codex, etc.) read for machine-parseable data.
5337
5770
  // Prior to this fix we spread `...result` at top-level of the
@@ -5341,7 +5774,7 @@ async function _executeMcpToolCallInner(payload) {
5341
5774
  return {
5342
5775
  response: toolResponse({
5343
5776
  content: [{ type: 'text', text: `✔ ${result.total} ${entity} item(s)` }],
5344
- structuredContent: { ...result },
5777
+ structuredContent: { ...result, warnings },
5345
5778
  }),
5346
5779
  };
5347
5780
  }
@@ -5354,6 +5787,21 @@ async function _executeMcpToolCallInner(payload) {
5354
5787
  const entity = String(args.entity ?? '');
5355
5788
  const id = String(args.id ?? '');
5356
5789
  const targetCwd = resolveProjectCwd(args.project, cwd);
5790
+ const validationWarning = findLoadValidationWarning(entity, id, targetCwd);
5791
+ if (validationWarning) {
5792
+ return {
5793
+ response: toolResponse({
5794
+ content: [{ type: 'text', text: `✖ ${entity} ${id} failed validation at load` }],
5795
+ structuredContent: {
5796
+ ok: false,
5797
+ error: 'validation_failed',
5798
+ entity_id: validationWarning.entity_id,
5799
+ validation_errors: validationWarning.validation_errors,
5800
+ path: validationWarning.path,
5801
+ },
5802
+ }, true),
5803
+ };
5804
+ }
5357
5805
  const item = getEntity(entity, id, targetCwd);
5358
5806
  return {
5359
5807
  response: toolResponse({
@@ -5514,7 +5962,7 @@ export async function executeMcpToolCall(payload) {
5514
5962
  }
5515
5963
  else {
5516
5964
  // First-ever connection for this claim — start a fresh session + adopt.
5517
- const sessionResult = startSession({ cwd, maintenanceMode: 'fast' });
5965
+ const sessionResult = await startSession({ cwd, maintenanceMode: 'fast' });
5518
5966
  autoSessionId = sessionResult.session_id;
5519
5967
  effectiveConnectionSessionId = autoSessionId;
5520
5968
  try {
@@ -5545,6 +5993,12 @@ export async function executeMcpToolCall(payload) {
5545
5993
  }
5546
5994
  catch { /* best-effort */ }
5547
5995
  }
5996
+ if ((payload.name === 'bclaw_find' || payload.name === 'bclaw_get') && payload.args.entity === 'agent_run') {
5997
+ try {
5998
+ sweepDeadPidRunningAgentRunsAtRead(cwd);
5999
+ }
6000
+ catch { /* best-effort */ }
6001
+ }
5548
6002
  // ── Delegate to inner handler ───────────────────────────────────────────────
5549
6003
  const outcome = await _executeMcpToolCallInner({
5550
6004
  ...payload,