brainclaw 1.20.4 → 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 {
@@ -18,6 +18,8 @@ import { buildContext } from '../core/context.js';
18
18
  import { detectCommitsBehindMainDetailed } from '../core/execution-context.js';
19
19
  import { checkBrainclawInstallableUpdate, renderBrainclawInstallableUpdateNotice } from '../core/brainclaw-version.js';
20
20
  import { loadConfig } from '../core/config.js';
21
+ import { isLocatableId, locateEntity } from '../core/entity-locator.js';
22
+ import { logger } from '../core/logger.js';
21
23
  import { generateClaimId, loadClaim, saveClaim, adoptClaimSession, releaseClaimWithCascade, claimBaselineFields } from '../core/claims.js';
22
24
  import { releaseClaimNextActions } from '../core/next-actions.js';
23
25
  import { reconcileClaimConformity } from '../core/claim-conformity.js';
@@ -207,7 +209,7 @@ export async function handleBclawClaim(payload, ctx) {
207
209
  };
208
210
  }
209
211
  export async function handleBclawReleaseClaim(payload, ctx) {
210
- const { args, cwd, connectionSessionId } = payload;
212
+ const { args, connectionSessionId } = payload;
211
213
  const crossProjectError = ctx.blockCrossProjectExecution('claim', args);
212
214
  if (crossProjectError) {
213
215
  return { response: crossProjectError };
@@ -216,11 +218,46 @@ export async function handleBclawReleaseClaim(payload, ctx) {
216
218
  if (!claimId) {
217
219
  return { response: createToolErrorResponse('validation_error', 'Missing required argument: id') };
218
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;
219
250
  try {
220
251
  loadClaim(claimId, cwd); // validate existence before delegating
221
252
  }
222
253
  catch {
223
- 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
+ };
224
261
  }
225
262
  // pln#562 step 5 + trp#928 — release is ownership-checked like acquisition
226
263
  // and adoption. Under the trp#928 tightening the coordinator override is
@@ -540,7 +577,45 @@ export async function handleBclawSessionEnd(payload, ctx) {
540
577
  };
541
578
  }
542
579
  export async function handleBclawAssignmentUpdate(payload, ctx) {
543
- 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;
544
619
  // Contributor trust: lowest dispatchable level. The agent-owner guard
545
620
  // below ensures only the assigned agent can update its own assignment.
546
621
  const resolved = ctx.ensureTrust(args, { nameField: 'agent', idField: 'agentId' }, 'contributor', cwd, connectionSessionId);
@@ -548,10 +623,8 @@ export async function handleBclawAssignmentUpdate(payload, ctx) {
548
623
  return { response: createToolErrorResponse(resolved.error.kind, resolved.error.message, resolved.error.details) };
549
624
  }
550
625
  try {
551
- const assignmentId = typeof args.assignment_id === 'string' ? args.assignment_id : undefined;
626
+ const assignmentId = assignmentIdArg;
552
627
  const status = typeof args.status === 'string' ? args.status : undefined;
553
- if (!assignmentId)
554
- return { response: createToolErrorResponse('input_error', 'assignment_id is required') };
555
628
  if (!status)
556
629
  return { response: createToolErrorResponse('input_error', 'status is required') };
557
630
  const message = args.message;
@@ -564,7 +637,24 @@ export async function handleBclawAssignmentUpdate(payload, ctx) {
564
637
  const { loadAssignment, transitionAssignment: transitionAsgn, recordProgress: recordProg } = await import('../core/assignments.js');
565
638
  const assignment = loadAssignment(assignmentId, cwd);
566
639
  if (!assignment) {
567
- 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
+ };
568
658
  }
569
659
  // Agent guard: only the assigned agent can update
570
660
  const callerAgent = resolved.identity.agent_name;
@@ -760,17 +850,49 @@ export async function handleBclawAssignmentAction(payload, ctx) {
760
850
  return { response: createToolErrorResponse('operation_error', err instanceof Error ? err.message : String(err)) };
761
851
  }
762
852
  }
763
- 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) {
764
860
  const { args, cwd, connectionSessionId } = payload;
765
- const stepLoc = ctx.resolveExecutionWriteTarget('plan', args, cwd, connectionSessionId);
766
- if (stepLoc.block) {
767
- 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 };
768
866
  }
769
- 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);
770
893
  if (resolved.error) {
771
894
  return { response: createToolErrorResponse(resolved.error.kind, resolved.error.message, resolved.error.details) };
772
895
  }
773
- const stepPlanId = String(args.planId ?? '').trim();
774
896
  const stepData = args.data && typeof args.data === 'object' && !Array.isArray(args.data)
775
897
  ? args.data
776
898
  : {};
@@ -782,11 +904,8 @@ export async function handleBclawAddStep(payload, ctx) {
782
904
  const stepAssignee = (stepData.assignee ?? args.assignee);
783
905
  const stepEstimated = (stepData.estimated_effort ?? args.estimated_effort);
784
906
  const stepActual = (stepData.actual_effort ?? args.actual_effort);
785
- if (!stepPlanId)
786
- return { response: createToolErrorResponse('validation_error', 'Missing required argument: planId') };
787
907
  if (!stepText)
788
908
  return { response: createToolErrorResponse('validation_error', 'Missing required argument: data.text') };
789
- const stepTargetCwd = stepLoc.targetCwd;
790
909
  try {
791
910
  const result = addStepOp({ planId: stepPlanId, text: stepText, assignee: stepAssignee, estimatedEffort: stepEstimated, actualEffort: stepActual }, stepTargetCwd);
792
911
  return {
@@ -807,22 +926,21 @@ export async function handleBclawAddStep(payload, ctx) {
807
926
  }
808
927
  }
809
928
  export async function handleBclawCompleteStep(payload, ctx) {
810
- const { args, cwd, connectionSessionId } = payload;
811
- const csLoc = ctx.resolveExecutionWriteTarget('plan', args, cwd, connectionSessionId);
812
- if (csLoc.block) {
813
- return { response: csLoc.block };
814
- }
815
- const resolved = ctx.ensureTrust(args, { nameField: 'agent', idField: 'agentId' }, 'contributor', cwd, connectionSessionId);
816
- if (resolved.error) {
817
- return { response: createToolErrorResponse(resolved.error.kind, resolved.error.message, resolved.error.details) };
818
- }
929
+ const { args, connectionSessionId } = payload;
819
930
  const csPlanId = String(args.planId ?? '').trim();
820
931
  const csStepId = String(args.stepId ?? '').trim();
821
932
  if (!csPlanId)
822
933
  return { response: createToolErrorResponse('validation_error', 'Missing required argument: planId') };
823
934
  if (!csStepId)
824
935
  return { response: createToolErrorResponse('validation_error', 'Missing required argument: stepId') };
825
- 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
+ }
826
944
  try {
827
945
  const result = completeStepOp({ planId: csPlanId, stepId: csStepId }, csTargetCwd);
828
946
  return {
@@ -844,15 +962,7 @@ export async function handleBclawCompleteStep(payload, ctx) {
844
962
  }
845
963
  }
846
964
  export async function handleBclawUpdateStep(payload, ctx) {
847
- const { args, cwd, connectionSessionId } = payload;
848
- const usLoc = ctx.resolveExecutionWriteTarget('plan', args, cwd, connectionSessionId);
849
- if (usLoc.block) {
850
- return { response: usLoc.block };
851
- }
852
- const resolved = ctx.ensureTrust(args, { nameField: 'agent', idField: 'agentId' }, 'contributor', cwd, connectionSessionId);
853
- if (resolved.error) {
854
- return { response: createToolErrorResponse(resolved.error.kind, resolved.error.message, resolved.error.details) };
855
- }
965
+ const { args, connectionSessionId } = payload;
856
966
  const usPlanId = String(args.planId ?? '').trim();
857
967
  const usStepId = String(args.stepId ?? '').trim();
858
968
  if (!usPlanId)
@@ -863,7 +973,14 @@ export async function handleBclawUpdateStep(payload, ctx) {
863
973
  if (args.status && !validStatuses.includes(String(args.status))) {
864
974
  return { response: createToolErrorResponse('validation_error', `Invalid status: ${args.status}. Valid: ${validStatuses.join(', ')}`) };
865
975
  }
866
- 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
+ }
867
984
  try {
868
985
  const result = updateStepOp({
869
986
  planId: usPlanId,
@@ -904,22 +1021,21 @@ export async function handleBclawUpdateStep(payload, ctx) {
904
1021
  }
905
1022
  }
906
1023
  export async function handleBclawDeleteStep(payload, ctx) {
907
- const { args, cwd, connectionSessionId } = payload;
908
- const dsLoc = ctx.resolveExecutionWriteTarget('plan', args, cwd, connectionSessionId);
909
- if (dsLoc.block) {
910
- return { response: dsLoc.block };
911
- }
912
- const resolved = ctx.ensureTrust(args, { nameField: 'agent', idField: 'agentId' }, 'contributor', cwd, connectionSessionId);
913
- if (resolved.error) {
914
- return { response: createToolErrorResponse(resolved.error.kind, resolved.error.message, resolved.error.details) };
915
- }
1024
+ const { args, connectionSessionId } = payload;
916
1025
  const dsPlanId = String(args.planId ?? '').trim();
917
1026
  const dsStepId = String(args.stepId ?? '').trim();
918
1027
  if (!dsPlanId)
919
1028
  return { response: createToolErrorResponse('validation_error', 'Missing required argument: planId') };
920
1029
  if (!dsStepId)
921
1030
  return { response: createToolErrorResponse('validation_error', 'Missing required argument: stepId') };
922
- 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
+ }
923
1039
  try {
924
1040
  const result = deleteStepOp({ planId: dsPlanId, stepId: dsStepId }, dsTargetCwd);
925
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;
@@ -2,7 +2,7 @@ import path from 'node:path';
2
2
  import { loadActiveProject, saveActiveProject, clearActiveProject } from '../core/active-project.js';
3
3
  import { buildOperationalIdentity, loadCurrentSession, loadSessionById, resolveCurrentSessionId, saveCurrentSession } from '../core/identity.js';
4
4
  import { memoryExists } from '../core/io.js';
5
- import { resolveProjectRef } from '../core/store-resolution.js';
5
+ import { resolveEffectiveCwdInfo, resolveProjectRef } from '../core/store-resolution.js';
6
6
  import { resolveCrossProjectLinks, resolveProjectCwd } from '../core/cross-project.js';
7
7
  import { scanNestedBrainclawProjects } from '../core/workspace-projects.js';
8
8
  import { loadConfig } from '../core/config.js';
@@ -103,10 +103,17 @@ export function listAvailableProjectsForSession(cwd, sessionId) {
103
103
  if (!wsRoot) {
104
104
  throw new Error('No brainclaw workspace found.');
105
105
  }
106
- const sessionActive = (sessionId ? loadSessionById(sessionId, cwd) : loadCurrentSession(cwd))?.active_project;
107
- const globalActive = loadActiveProject(wsRoot);
108
- const active = sessionActive ?? globalActive;
109
- const activeSource = sessionActive ? 'session' : globalActive ? 'global' : 'none';
106
+ // ONE RESOLVER, not a second ladder. This recomputed session-then-global locally, so it
107
+ // could report only two of the seven rungs: an agent physically inside a child project
108
+ // (cwd_child), or one anchored by BRAINCLAW_CWD / BRAINCLAW_PROJECT, was marked against
109
+ // the GLOBAL pointer or against nothing at all while its writes went elsewhere. That
110
+ // divergence between the reader that DISPLAYS and the resolver that WRITES is what kept
111
+ // the pln#648 bug invisible for weeks: green status, data beside it.
112
+ const effective = resolveEffectiveCwdInfo({ baseCwd: cwd, sessionId });
113
+ const activeSource = effective.active_source;
114
+ const active = effective.resolved_project
115
+ ? { path: effective.resolved_project.path, name: effective.resolved_project.name }
116
+ : undefined;
110
117
  const projects = [];
111
118
  const seen = new Set();
112
119
  const addProject = (project) => {
@@ -262,13 +269,33 @@ export function runSwitch(projectRef, options = {}) {
262
269
  console.log(`✔ Switched to ${switchedName ? `"${switchedName}" (${rel})` : rel}${scopeHint}`);
263
270
  }
264
271
  }
272
+ /**
273
+ * pln#649 step 5 — status derives from the SAME call that routes a write.
274
+ *
275
+ * This used to walk its own session-then-global ladder, so it could only ever name two of
276
+ * the resolver's seven rungs. An agent inside a child project read back the workspace's
277
+ * GLOBAL pointer — a different project from the one its writes reached. Reporting a source
278
+ * the writer does not use is the exact shape of the defect this plan exists to close, and
279
+ * it is why that defect stayed invisible: the status surface kept saying the reassuring
280
+ * thing.
281
+ *
282
+ * `active_source` is now the resolver's own vocabulary, matching what the MCP surfaces
283
+ * already echo. `cwd` — the resolver's "nothing pointed anywhere, use the directory" rung —
284
+ * keeps the previous no-active-project output, so the operator-facing text is unchanged for
285
+ * the case it described.
286
+ */
265
287
  function showCurrent(wsRoot, cwd, json) {
266
- // F5: prefer the session's own active project so an agent sees its own
267
- // session-scoped switch, not just the shared global pointer.
268
- const sessionActive = loadCurrentSession(cwd)?.active_project;
269
- const globalActive = loadActiveProject(wsRoot);
270
- const active = sessionActive ?? globalActive;
271
- const source = sessionActive ? 'session' : globalActive ? 'global' : 'none';
288
+ const effective = resolveEffectiveCwdInfo({ baseCwd: cwd });
289
+ const source = effective.active_source;
290
+ // The pointer records carry switched_at / switched_by; the resolver carries authority.
291
+ // Read the metadata from whichever pointer actually won, and never from the other one.
292
+ const pointer = source === 'session'
293
+ ? loadCurrentSession(cwd)?.active_project
294
+ : source === 'global' ? loadActiveProject(wsRoot) : undefined;
295
+ const active = pointer
296
+ ?? (source === 'cwd' || !effective.resolved_project
297
+ ? undefined
298
+ : { path: effective.resolved_project.path, name: effective.resolved_project.name, switched_at: undefined });
272
299
  if (!active) {
273
300
  if (json) {
274
301
  console.log(JSON.stringify({ active: false, scope: 'none' }));
@@ -314,7 +341,9 @@ function listProjects(wsRoot, cwd, json) {
314
341
  const name = p.name ? `${p.name} (${p.relative_path})` : p.relative_path;
315
342
  console.log(`${marker}${name}`);
316
343
  }
317
- if (result.active_source === 'none') {
344
+ // `cwd` is the resolver's equivalent of the old locally-computed 'none': no pointer won,
345
+ // so commands operate on the current directory (pln#649 step 5).
346
+ if (result.active_source === 'cwd') {
318
347
  console.log('\nNo active project. Use `brainclaw switch <project>` to set one.');
319
348
  }
320
349
  }