brainclaw 1.20.3 → 1.21.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.
Binary file
@@ -6,7 +6,7 @@ import { loadConfig, saveConfig } from '../core/config.js';
6
6
  import { isAgentIntegrationName, upsertAgentIntegrationDeclaration } from '../core/agent-integrations.js';
7
7
  import { resolveInstructions, loadInstructions } from '../core/instructions.js';
8
8
  import { detectAiAgent } from '../core/ai-agent-detection.js';
9
- import { AGENT_EXPORT_REGISTRY, resolveExportTarget, resolveExportTargetByFormat, resolveLiveCompanionPath, writeExportFile, writeLiveCompanionFile, buildHygieneSection, describeAutoConfigWrite, writeExportCompanionFiles, collectExportGitignoreEntries, ensureGitignoreEntries, BRAINCLAW_EXCLUSIVE_DIRECTORIES, } from '../core/agent-files.js';
9
+ import { AGENT_EXPORT_REGISTRY, resolveExportTarget, resolveExportTargetByFormat, resolveLiveCompanionPath, writeExportFile, writeLiveCompanionFile, buildHygieneSection, describeAutoConfigWrite, writeExportCompanionFiles, collectExportGitignoreEntries, ensureGitignoreEntries, BRAINCLAW_EXCLUSIVE_DIRECTORIES, BRAINCLAW_PROTOCOL_ARTIFACT_IGNORES, } from '../core/agent-files.js';
10
10
  import { buildCoordinationSnapshot } from '../core/coordination.js';
11
11
  import { listClaims } from '../core/claims.js';
12
12
  import { listCandidates } from '../core/candidates.js';
@@ -61,7 +61,7 @@ export function runExport(options) {
61
61
  });
62
62
  if (liveResult)
63
63
  gitignoreEntries.push(liveResult.relativePath);
64
- ensureGitignoreEntries(cwd, [...gitignoreEntries, ...BRAINCLAW_EXCLUSIVE_DIRECTORIES]);
64
+ ensureGitignoreEntries(cwd, [...gitignoreEntries, ...BRAINCLAW_EXCLUSIVE_DIRECTORIES, ...BRAINCLAW_PROTOCOL_ARTIFACT_IGNORES]);
65
65
  declareAgentIntegrationFromTarget(cwd, target.agentName, 'manual');
66
66
  console.log(`✔ Written to ${target.relativePath} (${result.created ? 'created' : 'updated'})`);
67
67
  if (liveResult) {
@@ -102,7 +102,7 @@ function runExportDetect(cwd, options) {
102
102
  const gitignoreEntries = collectExportGitignoreEntries(cwd, target.relativePath, autoConfigs);
103
103
  if (liveResult)
104
104
  gitignoreEntries.push(liveResult.relativePath);
105
- ensureGitignoreEntries(cwd, [...gitignoreEntries, ...BRAINCLAW_EXCLUSIVE_DIRECTORIES]);
105
+ ensureGitignoreEntries(cwd, [...gitignoreEntries, ...BRAINCLAW_EXCLUSIVE_DIRECTORIES, ...BRAINCLAW_PROTOCOL_ARTIFACT_IGNORES]);
106
106
  declareAgentIntegrationFromTarget(cwd, target.agentName, detected ? 'detected' : 'manual');
107
107
  const source = detected ? `${detected.name} [${detected.detection_source}]` : 'fallback (no agent detected)';
108
108
  console.log(`✔ Detected: ${source}`);
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import readline from 'node:readline/promises';
4
+ import { clearEnumerationMemo } from '../core/entity-locator.js';
4
5
  import { registerAgentIdentity, resolveDefaultAgentName, resolveExistingCurrentAgent } from '../core/agent-registry.js';
5
6
  import { MEMORY_DIR, memoryExists, ensureMemoryDir, memoryPath, writeFileAtomic } from '../core/io.js';
6
7
  import { emptyState, loadState, saveState } from '../core/state.js';
@@ -371,6 +372,16 @@ export async function runInit(options = {}) {
371
372
  console.log(`Tip: run 'brainclaw init' again later to refresh the detected agent's integration files on this project.`);
372
373
  }
373
374
  console.log(`Tip: in an agent session, call the bclaw_work MCP tool (intent: "consult") to load the shared memory; from a terminal, 'brainclaw context --json' does the same.`);
375
+ // A STORE JUST CAME INTO EXISTENCE, so the routing memo's candidate list is stale.
376
+ //
377
+ // `clearEnumerationMemo` was documented as being "for tests, and for any caller that has
378
+ // just created a store" — and that second caller did not exist (Fable audit found the
379
+ // claim describing intent rather than code). The consequence was small but real: for up
380
+ // to the memo TTL, a mutation routed right after `brainclaw init` / `bclaw_init_project`
381
+ // could not see the new project. Wiring it HERE rather than in the MCP handler covers
382
+ // every path that materialises a store, since both the CLI and the tool go through
383
+ // runInit.
384
+ clearEnumerationMemo();
374
385
  }
375
386
  function safeRunMachinePrereqs(agentName) {
376
387
  try {
@@ -12,10 +12,14 @@
12
12
  *
13
13
  * @module
14
14
  */
15
+ import { spawnSync } from 'node:child_process';
15
16
  import { getTriggeredItems, renderTriggeredItems } from '../core/lifecycle.js';
16
17
  import { buildContext } from '../core/context.js';
18
+ import { detectCommitsBehindMainDetailed } from '../core/execution-context.js';
17
19
  import { checkBrainclawInstallableUpdate, renderBrainclawInstallableUpdateNotice } from '../core/brainclaw-version.js';
18
20
  import { loadConfig } from '../core/config.js';
21
+ import { isLocatableId, locateEntity } from '../core/entity-locator.js';
22
+ import { logger } from '../core/logger.js';
19
23
  import { generateClaimId, loadClaim, saveClaim, adoptClaimSession, releaseClaimWithCascade, claimBaselineFields } from '../core/claims.js';
20
24
  import { releaseClaimNextActions } from '../core/next-actions.js';
21
25
  import { reconcileClaimConformity } from '../core/claim-conformity.js';
@@ -44,6 +48,22 @@ export function parseTtl(ttl) {
44
48
  const ms = unit === 'm' ? value * 60_000 : unit === 'h' ? value * 3_600_000 : value * 86_400_000;
45
49
  return new Date(Date.now() + ms).toISOString();
46
50
  }
51
+ /**
52
+ * Current git branch via an argv invocation — never a shell string (pln#618).
53
+ * Returns undefined when git is unavailable, cwd is not a repo, or HEAD is
54
+ * detached (`--show-current` prints nothing there).
55
+ */
56
+ function currentGitBranch(cwd) {
57
+ const result = spawnSync('git', ['branch', '--show-current'], {
58
+ cwd,
59
+ encoding: 'utf-8',
60
+ timeout: 5000,
61
+ windowsHide: true,
62
+ });
63
+ if (result.error || result.status !== 0)
64
+ return undefined;
65
+ return result.stdout.trim() || undefined;
66
+ }
47
67
  export async function handleBclawClaim(payload, ctx) {
48
68
  const { name, args, cwd, connectionSessionId } = payload;
49
69
  // project=X naming a workspace sibling auto-localizes (session+switch then
@@ -156,37 +176,21 @@ export async function handleBclawClaim(payload, ctx) {
156
176
  : '';
157
177
  // Branch guardrail: warn if on master/main without a worktree
158
178
  let branchWarn = '';
159
- if (!worktreePath) {
160
- try {
161
- const { execSync } = await import('node:child_process');
162
- const branch = execSync('git branch --show-current', { cwd: claimCwd, encoding: 'utf-8' }).trim();
163
- if (branch === 'master' || branch === 'main') {
164
- const branchSlug = sanitizeBranchComponent(claimScope);
165
- branchWarn = `\n⚠️ You are on ${branch}. Create a feature branch before editing: git checkout -b feat/${branchSlug}`;
166
- }
167
- }
168
- catch { /* git not available, skip warning */ }
179
+ const currentBranch = currentGitBranch(claimCwd);
180
+ if (!worktreePath && (currentBranch === 'master' || currentBranch === 'main')) {
181
+ const branchSlug = sanitizeBranchComponent(claimScope);
182
+ branchWarn = `\n⚠️ You are on ${currentBranch}. Create a feature branch before editing: git checkout -b feat/${branchSlug}`;
169
183
  }
170
- // Stale-branch detection: warn if behind master
184
+ // Stale-branch detection: warn if behind master/main. Branch names may
185
+ // legally contain shell metacharacters — the revspec goes through an argv
186
+ // invocation, never a shell string (pln#618).
171
187
  let staleBranchWarn = '';
172
- try {
173
- const { execSync: execSyncSB } = await import('node:child_process');
174
- const currentBranch = execSyncSB('git branch --show-current', { cwd: claimCwd, encoding: 'utf-8' }).trim();
175
- if (currentBranch && currentBranch !== 'master' && currentBranch !== 'main') {
176
- for (const mainBranch of ['master', 'main']) {
177
- try {
178
- const behind = execSyncSB(`git rev-list --count ${currentBranch}..${mainBranch}`, { cwd: claimCwd, encoding: 'utf-8' }).trim();
179
- const count = parseInt(behind, 10);
180
- if (count > 0) {
181
- staleBranchWarn = `\n⚠ Branch is ${count} commit(s) behind ${mainBranch}. Consider rebasing before editing.`;
182
- }
183
- break;
184
- }
185
- catch { /* branch doesn't exist, try next */ }
186
- }
188
+ if (currentBranch && currentBranch !== 'master' && currentBranch !== 'main') {
189
+ const behind = detectCommitsBehindMainDetailed(claimCwd, currentBranch);
190
+ if (behind && behind.count > 0) {
191
+ staleBranchWarn = `\n⚠ Branch is ${behind.count} commit(s) behind ${behind.branch}. Consider rebasing before editing.`;
187
192
  }
188
193
  }
189
- catch { /* git not available */ }
190
194
  const worktreeNote = worktreePath ? `\n Worktree: ${worktreePath}` : '';
191
195
  const expiryNote = claimExpiresAt ? `\n Expires: ${claimExpiresAt.slice(0, 16).replace('T', ' ')} UTC` : '';
192
196
  const handoffNote = handoffMode ? `\n Handoff: ${handoffMode} (another agent will review and merge)` : '';
@@ -205,7 +209,7 @@ export async function handleBclawClaim(payload, ctx) {
205
209
  };
206
210
  }
207
211
  export async function handleBclawReleaseClaim(payload, ctx) {
208
- const { args, cwd, connectionSessionId } = payload;
212
+ const { args, connectionSessionId } = payload;
209
213
  const crossProjectError = ctx.blockCrossProjectExecution('claim', args);
210
214
  if (crossProjectError) {
211
215
  return { response: crossProjectError };
@@ -214,11 +218,46 @@ export async function handleBclawReleaseClaim(payload, ctx) {
214
218
  if (!claimId) {
215
219
  return { response: createToolErrorResponse('validation_error', 'Missing required argument: id') };
216
220
  }
221
+ // ── pln#649 F5, second surface: the CLAIM routes this call.
222
+ //
223
+ // `bclaw_assignment_update` was routed first because that is where the field
224
+ // defect was reproduced. This is the OTHER HALF of the same defect, documented in
225
+ // trp#1327: `bclaw_release_claim` has no `project` parameter either, so a worker
226
+ // whose resolved store is not the claim's got `Claim not found` and the claim
227
+ // stayed active forever. It only LOOKED fixed because completing an assignment
228
+ // cascade-releases its claim with the routed cwd — a worker that calls
229
+ // release_claim directly still fell in the hole.
230
+ //
231
+ // Same shape as the reviewed surface, deliberately: validate the id before any
232
+ // path is built, locate, refuse an ambiguity WITHOUT disclosing which projects
233
+ // (this runs before the ownership check, so an unauthenticated caller must learn a
234
+ // count and an action, never names or store paths), then rebind `cwd` ONCE so every
235
+ // downstream use — ownership check, cascade, plan status — is routed by construction.
236
+ if (!isLocatableId(claimId)) {
237
+ return { response: createToolErrorResponse('validation_error', `Invalid claim id '${claimId}'`) };
238
+ }
239
+ const located = locateEntity('claim', claimId, payload.cwd);
240
+ if (located.status === 'ambiguous') {
241
+ logger.warn(`ambiguous claim routing: ${claimId} found in `
242
+ + located.matches.map((m) => `${m.project_name ?? '(unnamed)'} @ ${m.cwd}`).join(', '));
243
+ return {
244
+ response: createToolErrorResponse('validation_error', `Claim ${claimId} exists in ${located.matches.length} projects reachable from here. `
245
+ + 'Refusing to guess which one you meant — call from the project that owns the work, '
246
+ + 'or ask an operator to resolve the duplicate (details are in the server log).', { claim_id: claimId, match_count: located.matches.length }),
247
+ };
248
+ }
249
+ const cwd = located.location?.cwd ?? payload.cwd;
217
250
  try {
218
251
  loadClaim(claimId, cwd); // validate existence before delegating
219
252
  }
220
253
  catch {
221
- return { response: createToolErrorResponse('not_found', `Claim not found: ${claimId}`) };
254
+ const scope = located.enumeration_incomplete
255
+ ? `${located.probed.length} reachable project(s), and the search hit its depth ceiling so deeper projects were NOT examined`
256
+ : `all ${located.probed.length} reachable project(s)`;
257
+ logger.warn(`claim not found: ${claimId} — searched ${located.probed.join(', ')}`);
258
+ return {
259
+ response: createToolErrorResponse('not_found', `Claim not found: ${claimId} (searched ${scope})`, { claim_id: claimId, searched_count: located.probed.length, enumeration_incomplete: located.enumeration_incomplete }),
260
+ };
222
261
  }
223
262
  // pln#562 step 5 + trp#928 — release is ownership-checked like acquisition
224
263
  // and adoption. Under the trp#928 tightening the coordinator override is
@@ -538,7 +577,45 @@ export async function handleBclawSessionEnd(payload, ctx) {
538
577
  };
539
578
  }
540
579
  export async function handleBclawAssignmentUpdate(payload, ctx) {
541
- const { args, cwd, connectionSessionId } = payload;
580
+ const { args, connectionSessionId } = payload;
581
+ // ── pln#649 step 3 (F5 of dec#153): the ENTITY routes this call, not the ambient
582
+ // cwd. This is the surface where the field defect was reproduced: a worker whose
583
+ // resolved store was not the assignment's got `Assignment not found`, and the
584
+ // assignment stayed `offered` forever with no way for the coordinator to fix it.
585
+ //
586
+ // ORDER IS LOAD-BEARING (review P2-6 of step 2). Routing MUST happen before
587
+ // ensureTrust: that call resolves identity and trust FROM A STORE, so running it
588
+ // on the ambient store can reject the caller before the locator ever gets to say
589
+ // which store owns the work. Same for loadAssignment below. So `cwd` is rebound
590
+ // once, here, and every downstream use is routed by construction — there is no
591
+ // second site to forget.
592
+ const assignmentIdArg = typeof args.assignment_id === 'string' ? args.assignment_id : undefined;
593
+ if (!assignmentIdArg)
594
+ return { response: createToolErrorResponse('input_error', 'assignment_id is required') };
595
+ if (!isLocatableId(assignmentIdArg)) {
596
+ return { response: createToolErrorResponse('input_error', `Invalid assignment_id '${assignmentIdArg}'`) };
597
+ }
598
+ const located = locateEntity('assignment', assignmentIdArg, payload.cwd);
599
+ if (located.status === 'ambiguous') {
600
+ // Two stores hold this id. Refusing is the point (T3): picking one would be a
601
+ // silent cross-project write, the failure mode dec#153 exists against.
602
+ //
603
+ // BUT SAY NOTHING ABOUT WHICH PROJECTS (review P2-3). This branch runs BEFORE
604
+ // ensureTrust — it has to, because trust is resolved FROM a store and the owning
605
+ // store is not known yet — so an unauthenticated caller who guesses a duplicated
606
+ // id would otherwise be handed project names and absolute paths. The first
607
+ // version of this message did exactly that. Routing before trust is required;
608
+ // DISCLOSING before trust is not, so the operator-facing detail goes to the
609
+ // server log and the caller gets a count and an action.
610
+ logger.warn(`ambiguous assignment routing: ${assignmentIdArg} found in `
611
+ + located.matches.map((m) => `${m.project_name ?? '(unnamed)'} @ ${m.cwd}`).join(', '));
612
+ return {
613
+ response: createToolErrorResponse('validation_error', `Assignment ${assignmentIdArg} exists in ${located.matches.length} projects reachable from here. `
614
+ + 'Refusing to guess which one you meant — call from the project that owns the work, '
615
+ + 'or ask an operator to resolve the duplicate (details are in the server log).', { assignment_id: assignmentIdArg, match_count: located.matches.length }),
616
+ };
617
+ }
618
+ const cwd = located.location?.cwd ?? payload.cwd;
542
619
  // Contributor trust: lowest dispatchable level. The agent-owner guard
543
620
  // below ensures only the assigned agent can update its own assignment.
544
621
  const resolved = ctx.ensureTrust(args, { nameField: 'agent', idField: 'agentId' }, 'contributor', cwd, connectionSessionId);
@@ -546,10 +623,8 @@ export async function handleBclawAssignmentUpdate(payload, ctx) {
546
623
  return { response: createToolErrorResponse(resolved.error.kind, resolved.error.message, resolved.error.details) };
547
624
  }
548
625
  try {
549
- const assignmentId = typeof args.assignment_id === 'string' ? args.assignment_id : undefined;
626
+ const assignmentId = assignmentIdArg;
550
627
  const status = typeof args.status === 'string' ? args.status : undefined;
551
- if (!assignmentId)
552
- return { response: createToolErrorResponse('input_error', 'assignment_id is required') };
553
628
  if (!status)
554
629
  return { response: createToolErrorResponse('input_error', 'status is required') };
555
630
  const message = args.message;
@@ -562,7 +637,24 @@ export async function handleBclawAssignmentUpdate(payload, ctx) {
562
637
  const { loadAssignment, transitionAssignment: transitionAsgn, recordProgress: recordProg } = await import('../core/assignments.js');
563
638
  const assignment = loadAssignment(assignmentId, cwd);
564
639
  if (!assignment) {
565
- return { response: createToolErrorResponse('not_found', `Assignment not found: ${assignmentId}`) };
640
+ // Say WHERE we looked. "not found" used to be indistinguishable from "found
641
+ // in a store I was not routed to" — the exact ambiguity that made the field
642
+ // report hard to diagnose. And an incomplete enumeration must never read as a
643
+ // confident absence (step 2, enumeration_incomplete).
644
+ // The COUNT and the truncation flag are actionable and disclose nothing; the
645
+ // absolute store PATHS did (review P2-4: a contributor in A could submit an
646
+ // absent id and enumerate linked/nested project paths), so they go to the log.
647
+ const scope = located.enumeration_incomplete
648
+ ? `${located.probed.length} reachable project(s), and the search hit its depth ceiling so deeper projects were NOT examined`
649
+ : `all ${located.probed.length} reachable project(s)`;
650
+ logger.warn(`assignment not found: ${assignmentId} — searched ${located.probed.join(', ')}`);
651
+ return {
652
+ response: createToolErrorResponse('not_found', `Assignment not found: ${assignmentId} (searched ${scope})`, {
653
+ assignment_id: assignmentId,
654
+ searched_count: located.probed.length,
655
+ enumeration_incomplete: located.enumeration_incomplete,
656
+ }),
657
+ };
566
658
  }
567
659
  // Agent guard: only the assigned agent can update
568
660
  const callerAgent = resolved.identity.agent_name;
@@ -758,17 +850,49 @@ export async function handleBclawAssignmentAction(payload, ctx) {
758
850
  return { response: createToolErrorResponse('operation_error', err instanceof Error ? err.message : String(err)) };
759
851
  }
760
852
  }
761
- export async function handleBclawAddStep(payload, ctx) {
853
+ /**
854
+ * Route a plan-step mutation by its plan when one was named. `planId` is a
855
+ * discriminator, not a suggestion: resolving trust and then editing the ambient
856
+ * store would reproduce the cross-project write defect this handler is meant to
857
+ * avoid. Explicit `project` keeps its existing auto-localisation semantics.
858
+ */
859
+ function resolvePlanStepWriteTarget(payload, ctx, planId) {
762
860
  const { args, cwd, connectionSessionId } = payload;
763
- const stepLoc = ctx.resolveExecutionWriteTarget('plan', args, cwd, connectionSessionId);
764
- if (stepLoc.block) {
765
- return { response: stepLoc.block };
861
+ if (args.project !== undefined) {
862
+ const explicit = ctx.resolveExecutionWriteTarget('plan', args, cwd, connectionSessionId);
863
+ if (explicit.block)
864
+ return { response: explicit.block };
865
+ return { targetCwd: explicit.targetCwd };
766
866
  }
767
- const resolved = ctx.ensureTrust(args, { nameField: 'agent', idField: 'agentId' }, 'contributor', cwd, connectionSessionId);
867
+ // Short labels are resolved by the plan operation itself. They are not stable
868
+ // filesystem keys, so only a canonical id participates in locator routing.
869
+ if (!isLocatableId(planId))
870
+ return { targetCwd: cwd };
871
+ const located = locateEntity('plan', planId, cwd);
872
+ if (located.status === 'ambiguous') {
873
+ logger.warn(`ambiguous plan-step routing: ${planId} found in `
874
+ + located.matches.map((m) => `${m.project_name ?? '(unnamed)'} @ ${m.cwd}`).join(', '));
875
+ return {
876
+ response: createToolErrorResponse('validation_error', `Plan ${planId} exists in ${located.matches.length} projects reachable from here. `
877
+ + 'Refusing to guess which one you meant — call from the project that owns the plan, '
878
+ + 'or ask an operator to resolve the duplicate (details are in the server log).', { plan_id: planId, match_count: located.matches.length }),
879
+ };
880
+ }
881
+ return { targetCwd: located.location?.cwd ?? cwd };
882
+ }
883
+ export async function handleBclawAddStep(payload, ctx) {
884
+ const { args, connectionSessionId } = payload;
885
+ const stepPlanId = String(args.planId ?? '').trim();
886
+ if (!stepPlanId)
887
+ return { response: createToolErrorResponse('validation_error', 'Missing required argument: planId') };
888
+ const stepTarget = resolvePlanStepWriteTarget(payload, ctx, stepPlanId);
889
+ if (stepTarget.response)
890
+ return { response: stepTarget.response };
891
+ const stepTargetCwd = stepTarget.targetCwd;
892
+ const resolved = ctx.ensureTrust(args, { nameField: 'agent', idField: 'agentId' }, 'contributor', stepTargetCwd, connectionSessionId);
768
893
  if (resolved.error) {
769
894
  return { response: createToolErrorResponse(resolved.error.kind, resolved.error.message, resolved.error.details) };
770
895
  }
771
- const stepPlanId = String(args.planId ?? '').trim();
772
896
  const stepData = args.data && typeof args.data === 'object' && !Array.isArray(args.data)
773
897
  ? args.data
774
898
  : {};
@@ -780,11 +904,8 @@ export async function handleBclawAddStep(payload, ctx) {
780
904
  const stepAssignee = (stepData.assignee ?? args.assignee);
781
905
  const stepEstimated = (stepData.estimated_effort ?? args.estimated_effort);
782
906
  const stepActual = (stepData.actual_effort ?? args.actual_effort);
783
- if (!stepPlanId)
784
- return { response: createToolErrorResponse('validation_error', 'Missing required argument: planId') };
785
907
  if (!stepText)
786
908
  return { response: createToolErrorResponse('validation_error', 'Missing required argument: data.text') };
787
- const stepTargetCwd = stepLoc.targetCwd;
788
909
  try {
789
910
  const result = addStepOp({ planId: stepPlanId, text: stepText, assignee: stepAssignee, estimatedEffort: stepEstimated, actualEffort: stepActual }, stepTargetCwd);
790
911
  return {
@@ -805,22 +926,21 @@ export async function handleBclawAddStep(payload, ctx) {
805
926
  }
806
927
  }
807
928
  export async function handleBclawCompleteStep(payload, ctx) {
808
- const { args, cwd, connectionSessionId } = payload;
809
- const csLoc = ctx.resolveExecutionWriteTarget('plan', args, cwd, connectionSessionId);
810
- if (csLoc.block) {
811
- return { response: csLoc.block };
812
- }
813
- const resolved = ctx.ensureTrust(args, { nameField: 'agent', idField: 'agentId' }, 'contributor', cwd, connectionSessionId);
814
- if (resolved.error) {
815
- return { response: createToolErrorResponse(resolved.error.kind, resolved.error.message, resolved.error.details) };
816
- }
929
+ const { args, connectionSessionId } = payload;
817
930
  const csPlanId = String(args.planId ?? '').trim();
818
931
  const csStepId = String(args.stepId ?? '').trim();
819
932
  if (!csPlanId)
820
933
  return { response: createToolErrorResponse('validation_error', 'Missing required argument: planId') };
821
934
  if (!csStepId)
822
935
  return { response: createToolErrorResponse('validation_error', 'Missing required argument: stepId') };
823
- const csTargetCwd = csLoc.targetCwd;
936
+ const csTarget = resolvePlanStepWriteTarget(payload, ctx, csPlanId);
937
+ if (csTarget.response)
938
+ return { response: csTarget.response };
939
+ const csTargetCwd = csTarget.targetCwd;
940
+ const resolved = ctx.ensureTrust(args, { nameField: 'agent', idField: 'agentId' }, 'contributor', csTargetCwd, connectionSessionId);
941
+ if (resolved.error) {
942
+ return { response: createToolErrorResponse(resolved.error.kind, resolved.error.message, resolved.error.details) };
943
+ }
824
944
  try {
825
945
  const result = completeStepOp({ planId: csPlanId, stepId: csStepId }, csTargetCwd);
826
946
  return {
@@ -842,15 +962,7 @@ export async function handleBclawCompleteStep(payload, ctx) {
842
962
  }
843
963
  }
844
964
  export async function handleBclawUpdateStep(payload, ctx) {
845
- const { args, cwd, connectionSessionId } = payload;
846
- const usLoc = ctx.resolveExecutionWriteTarget('plan', args, cwd, connectionSessionId);
847
- if (usLoc.block) {
848
- return { response: usLoc.block };
849
- }
850
- const resolved = ctx.ensureTrust(args, { nameField: 'agent', idField: 'agentId' }, 'contributor', cwd, connectionSessionId);
851
- if (resolved.error) {
852
- return { response: createToolErrorResponse(resolved.error.kind, resolved.error.message, resolved.error.details) };
853
- }
965
+ const { args, connectionSessionId } = payload;
854
966
  const usPlanId = String(args.planId ?? '').trim();
855
967
  const usStepId = String(args.stepId ?? '').trim();
856
968
  if (!usPlanId)
@@ -861,7 +973,14 @@ export async function handleBclawUpdateStep(payload, ctx) {
861
973
  if (args.status && !validStatuses.includes(String(args.status))) {
862
974
  return { response: createToolErrorResponse('validation_error', `Invalid status: ${args.status}. Valid: ${validStatuses.join(', ')}`) };
863
975
  }
864
- const usTargetCwd = usLoc.targetCwd;
976
+ const usTarget = resolvePlanStepWriteTarget(payload, ctx, usPlanId);
977
+ if (usTarget.response)
978
+ return { response: usTarget.response };
979
+ const usTargetCwd = usTarget.targetCwd;
980
+ const resolved = ctx.ensureTrust(args, { nameField: 'agent', idField: 'agentId' }, 'contributor', usTargetCwd, connectionSessionId);
981
+ if (resolved.error) {
982
+ return { response: createToolErrorResponse(resolved.error.kind, resolved.error.message, resolved.error.details) };
983
+ }
865
984
  try {
866
985
  const result = updateStepOp({
867
986
  planId: usPlanId,
@@ -902,22 +1021,21 @@ export async function handleBclawUpdateStep(payload, ctx) {
902
1021
  }
903
1022
  }
904
1023
  export async function handleBclawDeleteStep(payload, ctx) {
905
- const { args, cwd, connectionSessionId } = payload;
906
- const dsLoc = ctx.resolveExecutionWriteTarget('plan', args, cwd, connectionSessionId);
907
- if (dsLoc.block) {
908
- return { response: dsLoc.block };
909
- }
910
- const resolved = ctx.ensureTrust(args, { nameField: 'agent', idField: 'agentId' }, 'contributor', cwd, connectionSessionId);
911
- if (resolved.error) {
912
- return { response: createToolErrorResponse(resolved.error.kind, resolved.error.message, resolved.error.details) };
913
- }
1024
+ const { args, connectionSessionId } = payload;
914
1025
  const dsPlanId = String(args.planId ?? '').trim();
915
1026
  const dsStepId = String(args.stepId ?? '').trim();
916
1027
  if (!dsPlanId)
917
1028
  return { response: createToolErrorResponse('validation_error', 'Missing required argument: planId') };
918
1029
  if (!dsStepId)
919
1030
  return { response: createToolErrorResponse('validation_error', 'Missing required argument: stepId') };
920
- const dsTargetCwd = dsLoc.targetCwd;
1031
+ const dsTarget = resolvePlanStepWriteTarget(payload, ctx, dsPlanId);
1032
+ if (dsTarget.response)
1033
+ return { response: dsTarget.response };
1034
+ const dsTargetCwd = dsTarget.targetCwd;
1035
+ const resolved = ctx.ensureTrust(args, { nameField: 'agent', idField: 'agentId' }, 'contributor', dsTargetCwd, connectionSessionId);
1036
+ if (resolved.error) {
1037
+ return { response: createToolErrorResponse(resolved.error.kind, resolved.error.message, resolved.error.details) };
1038
+ }
921
1039
  try {
922
1040
  const result = deleteStepOp({ planId: dsPlanId, stepId: dsStepId }, dsTargetCwd);
923
1041
  return {
@@ -13,7 +13,9 @@
13
13
  *
14
14
  * @module
15
15
  */
16
+ import path from 'node:path';
16
17
  import { appendAuditEntry } from '../core/audit.js';
18
+ import { isLocatableId, locateEntity } from '../core/entity-locator.js';
17
19
  import { nowISO, generateIdWithLabel } from '../core/ids.js';
18
20
  import { loadState, persistState, saveState } from '../core/state.js';
19
21
  import { ENTITY_REGISTRY } from '../core/entity-registry.js';
@@ -29,6 +31,53 @@ import { rejectCandidate } from './reject.js';
29
31
  import { applyHandoffUpdates } from './update-handoff.js';
30
32
  import { ensureTrust, resolveMutationIdentity, resolveCanonicalAuthor, renderAutoRepairWarning, scopeMetadataForTarget, scanMcpWriteText, appendSecurityWarnings, } from './mcp-write-support.js';
31
33
  import { SCHEMA_VERSION, toolResponse, createToolErrorResponse, } from './mcp-contract.js';
34
+ /** Result shape of mcp.ts's resolveExecutionWriteTarget (structural mirror, PR3b). */
35
+ /** Entity kinds the locator can find by id — the divergence check only applies there. */
36
+ const LOCATABLE_FOR_DIVERGENCE = new Set(['assignment', 'claim', 'agent_run', 'plan', 'loop']);
37
+ /**
38
+ * pln#649 / dec#153 T3: the ENTITY-vs-EXPLICIT-PROJECT refusal, shared by every
39
+ * canonical-grammar surface that receives BOTH authorities at once.
40
+ *
41
+ * IT LIVES HERE BECAUSE THERE ARE THREE CONSUMERS, NOT ONE. The first version was
42
+ * inline in `bclaw_transition` and its comment claimed that was "the ONLY canonical
43
+ * grammar surface where both authorities are supplied at once" — FALSE, found by a
44
+ * Fable audit: `bclaw_update` and `bclaw_remove` take the same pair and had no guard,
45
+ * so the misleading `not found` the guard exists to kill survived on two surfaces
46
+ * operators reach the same way. Copying the block a third time is how the by-id
47
+ * duplication earlier in this plan happened; one function, three call sites.
48
+ *
49
+ * Without it a divergence produces a misleading `not found in <B>`: the record exists,
50
+ * just not where the caller named, which leaves them doubting their id. dec#153 says
51
+ * an explicit divergence must be REFUSED and NAMED so they learn which of their two
52
+ * statements was wrong.
53
+ *
54
+ * DISCLOSURE RULE from the two routed surfaces: the project the caller TYPED is
55
+ * already theirs, so naming it back is free — but WHERE the entity actually lives is
56
+ * new information, so that is a COUNT, never a name.
57
+ *
58
+ * The remedy sentence is deliberately not "drop `project` to be routed by the entity",
59
+ * which the first version said and which is not true: dropping `project` falls back to
60
+ * AMBIENT resolution, not to the entity. An error message that misstates its own fix
61
+ * is worse than a comment that does.
62
+ */
63
+ function refuseEntityProjectDivergence(entity, id, requestedProject, targetCwd, cwd) {
64
+ if (requestedProject === undefined || !id || !isLocatableId(id))
65
+ return undefined;
66
+ if (!LOCATABLE_FOR_DIVERGENCE.has(entity))
67
+ return undefined;
68
+ const located = locateEntity(entity, id, cwd);
69
+ const target = path.resolve(targetCwd);
70
+ // Only fires when the entity was found SOMEWHERE ELSE: a genuine not-found stays a
71
+ // not-found, and an agreement is never refused.
72
+ if (located.matches.length === 0)
73
+ return undefined;
74
+ if (located.matches.some((m) => path.resolve(m.cwd) === target))
75
+ return undefined;
76
+ return createToolErrorResponse('validation_error', `${entity} '${id}' does not live in project '${String(requestedProject)}' — it exists in `
77
+ + `${located.matches.length} other reachable project(s). Refusing: the entity and the project you named `
78
+ + 'disagree, and guessing which one you meant is how a write lands in the wrong project. '
79
+ + 'Call from the project that owns it, or name that project.', { entity, id, requested_project: String(requestedProject), located_elsewhere_count: located.matches.length });
80
+ }
32
81
  export function handleBclawCreatePlan(payload, ctx) {
33
82
  const { args, cwd, connectionSessionId } = payload;
34
83
  const crossProjectError = ctx.blockCrossProjectExecution('plan', args);
@@ -462,6 +511,12 @@ export function handleBclawUpdate(payload, ctx) {
462
511
  const id = String(args.id ?? '');
463
512
  const patch = (args.patch ?? {});
464
513
  const targetCwd = resolveProjectCwd(args.project, cwd);
514
+ // dec#153 T3 — this surface takes both authorities too (Fable audit; #182's comment
515
+ // claimed transition was the only one). Refuse BEFORE updateEntity, whose
516
+ // `not found` would otherwise be the misleading message the guard exists to kill.
517
+ const updateDivergence = refuseEntityProjectDivergence(entity, id, args.project, targetCwd, cwd);
518
+ if (updateDivergence)
519
+ return { response: updateDivergence };
465
520
  const targetScope = scopeMetadataForTarget(args, targetCwd, ctx.scopeInfo);
466
521
  const { agent_name, agent_id, auto_repair } = resolveCanonicalAuthor(args, cwd, connectionSessionId);
467
522
  // S2 (pln#623): scan the patched text field on the MCP path (CLI parity).
@@ -497,6 +552,12 @@ export function handleBclawRemove(payload, ctx) {
497
552
  const id = String(args.id ?? '');
498
553
  const purge = args.purge === true;
499
554
  const targetCwd = resolveProjectCwd(args.project, cwd);
555
+ // dec#153 T3 — same pair of authorities, and the highest stakes of the three: a
556
+ // divergence here means the caller is about to remove (or purge) in a project that
557
+ // does not hold the entity they named.
558
+ const removeDivergence = refuseEntityProjectDivergence(entity, id, args.project, targetCwd, cwd);
559
+ if (removeDivergence)
560
+ return { response: removeDivergence };
500
561
  const targetScope = scopeMetadataForTarget(args, targetCwd, ctx.scopeInfo);
501
562
  const { agent_name, agent_id, auto_repair } = resolveCanonicalAuthor(args, cwd, connectionSessionId);
502
563
  const result = removeEntity(entity, id, targetCwd, purge);
@@ -572,6 +633,12 @@ export function handleBclawTransition(payload, ctx) {
572
633
  const id = String(args.id ?? '');
573
634
  const to = String(args.to ?? '');
574
635
  const reason = args.reason;
636
+ // pln#649 / dec#153 T3 — shared with bclaw_update and bclaw_remove, which take the
637
+ // same pair of authorities. `bclaw_transition` is the call trp#1327 documents as the
638
+ // coordinator's workaround, so operators reach it; it is not the only one.
639
+ const transitionDivergence = refuseEntityProjectDivergence(entity, id, args.project, targetCwd, cwd);
640
+ if (transitionDivergence)
641
+ return { response: transitionDivergence };
575
642
  const targetScope = scopeMetadataForTarget(args, targetCwd, ctx.scopeInfo);
576
643
  const { agent_name, agent_id, auto_repair } = resolveCanonicalAuthor(args, cwd, connectionSessionId);
577
644
  // trp#928 — claim transitions consume the ReleaseClaimAuth ownership
@@ -23,6 +23,8 @@ import { loadAllSessions } from '../core/identity.js';
23
23
  // Setup wizard / project-init / registry helpers now live in mcp-write-admin.ts (PR4).
24
24
  // Canonical entity write handlers now live in mcp-write-entities.ts (PR4).
25
25
  import { findOutermostBrainclawRoot, resolveEffectiveCwd, resolveEffectiveCwdInfo, resolveProjectRef } from '../core/store-resolution.js';
26
+ import { isLocatableId, locateEntity } from '../core/entity-locator.js';
27
+ import { logger } from '../core/logger.js';
26
28
  import { switchProject } from './switch.js';
27
29
  import { assessBootstrapNeed, resolveEmptyMemoryRecommendation } from '../core/setup-flow.js';
28
30
  import { WorkRequestSchema } from '../core/facade-schema.js';
@@ -1847,8 +1849,69 @@ export async function executeMcpToolCall(payload) {
1847
1849
  const effective = payload.name === 'bclaw_switch'
1848
1850
  ? { cwd: baseCwd, active_source: 'cwd', resolved_project: undefined }
1849
1851
  : resolveEffectiveCwdInfo({ baseCwd, sessionId: payload.connectionSessionId });
1850
- const cwd = effective.cwd;
1851
1852
  const envClaimId = process.env.BRAINCLAW_CLAIM_ID?.trim() || undefined;
1853
+ // ── pln#649 F5, last surface: a WORKER's ambient mutation is routed by its CLAIM.
1854
+ //
1855
+ // The routed surfaces so far all take an entity id, so the entity could do the
1856
+ // routing. This covers the other half of F5: the mutations a dispatched worker makes
1857
+ // with NO entity to name — capturing a trap, writing a note. Those fell through the
1858
+ // whole ambient ladder (session → cwd_child → shared global pointer) and landed
1859
+ // wherever it pointed, which for a worker is the original field defect.
1860
+ //
1861
+ // The rule is NOT "refuse because nothing was named" — a worker HAS a discriminant:
1862
+ // `BRAINCLAW_CLAIM_ID`, the one selector deliberately preserved in its env
1863
+ // (execution-profile.ts). So the claim names the project, and the write follows it.
1864
+ //
1865
+ // MUTATIONS ONLY, by explicit list. dec#155 keeps READS on the ambient anchor — a
1866
+ // worker reading shared context workspace-wide is correct — and a blanket switch
1867
+ // across ~40 tools would be a behaviour change nobody asked for. This is the set a
1868
+ // dispatched worker actually calls without an entity id; extending it is mechanical.
1869
+ const WORKER_AMBIENT_MUTATIONS = new Set([
1870
+ 'bclaw_write_note', 'bclaw_quick_capture', 'bclaw_send_message', 'bclaw_create',
1871
+ ]);
1872
+ let cwd = effective.cwd;
1873
+ if (envClaimId && WORKER_AMBIENT_MUTATIONS.has(payload.name) && isLocatableId(envClaimId)) {
1874
+ let ambiguity;
1875
+ try {
1876
+ const owner = locateEntity('claim', envClaimId, cwd);
1877
+ if (owner.status === 'ambiguous') {
1878
+ // RESTRICTED REFUSAL — operator decision, 2026-08-06, settling a genuine
1879
+ // disagreement between two reviewers on this exact branch. `ambiguous` means two
1880
+ // reachable stores hold this claim id: the divergence is PROVEN, so dec#153's
1881
+ // refusal applies and falling back to ambient would silently write to a store the
1882
+ // entity does not authorise — the original field defect, in a corner.
1883
+ //
1884
+ // NOT_FOUND DELIBERATELY KEEPS THE AMBIENT ANSWER, which is the "restricted" half
1885
+ // and not an oversight. A claim that cannot be found is not a divergence, it is an
1886
+ // absence, and the benign causes dominate: the enumeration may not reach the
1887
+ // claim's store at all (`enumeration_incomplete`), and a RELEASED or archived claim
1888
+ // legitimately stops being findable — refusing there would break every mutation a
1889
+ // worker makes after releasing, which no decision asks for. The strict reading
1890
+ // (refuse on both) was the other reviewer's position and was considered.
1891
+ ambiguity = owner.matches.length;
1892
+ }
1893
+ else if (owner.status === 'found' && owner.location && path.resolve(owner.location.cwd) !== path.resolve(cwd)) {
1894
+ cwd = owner.location.cwd;
1895
+ }
1896
+ if (ambiguity !== undefined) {
1897
+ // Names and store paths go to the OPERATOR's log, never to the caller: this branch
1898
+ // runs before any trust check, so the response carries a count and an action only
1899
+ // — the same disclosure rule the two entity-routed surfaces were reviewed into.
1900
+ logger.warn(`ambiguous worker-claim routing: ${envClaimId} found in `
1901
+ + `${owner.matches.map((m) => `${m.project_name ?? '(unnamed)'} @ ${m.cwd}`).join(', ')}`);
1902
+ }
1903
+ }
1904
+ catch { /* never break a tool call over routing — fall back to the ambient answer */ }
1905
+ if (ambiguity !== undefined) {
1906
+ return {
1907
+ response: createToolErrorResponse('validation_error', `Your claim '${envClaimId}' exists in ${ambiguity} reachable projects, so this `
1908
+ + `${payload.name} cannot be routed to the project that owns your work. Refusing rather than `
1909
+ + 'guessing: guessing is how a write lands in another project. Ask an operator to resolve the '
1910
+ + 'duplicate claim id (details are in the server log).', { claim_id: envClaimId, match_count: ambiguity }),
1911
+ toolName: payload.name,
1912
+ };
1913
+ }
1914
+ }
1852
1915
  // ── Auto-session ────────────────────────────────────────────────────────────
1853
1916
  let autoSessionId;
1854
1917
  let effectiveConnectionSessionId = payload.connectionSessionId;