brainclaw 1.11.1 → 1.13.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.
@@ -7,14 +7,14 @@ import { fileURLToPath } from 'node:url';
7
7
  import { Worker } from 'node:worker_threads';
8
8
  import { generatedSchemas } from './mcp-schemas.generated.js';
9
9
  import { getTriggeredItems, renderTriggeredItems } from '../core/lifecycle.js';
10
- import { resolveCrossProjectWritableTarget, resolveProjectCwd, writeCrossProjectSignal } from '../core/cross-project.js';
10
+ import { resolveCrossProjectLinks, resolveCrossProjectWritableTarget, resolveProjectCwd, writeCrossProjectSignal } from '../core/cross-project.js';
11
11
  import { buildContext, renderContextMarkdown, renderContextPromptTemplate, renderContextBriefing } from '../core/context.js';
12
12
  import { buildCoordinationSnapshot } from '../core/coordination.js';
13
13
  import { checkBrainclawInstallableUpdate, getInstalledBrainclawVersion, readDiskBrainclawVersion, renderBrainclawInstallableUpdateNotice } from '../core/brainclaw-version.js';
14
14
  import { loadConfig } from '../core/config.js';
15
15
  import { collectLoadValidationWarnings, findLoadValidationWarning, loadState, persistState, saveState } from '../core/state.js';
16
16
  import { generateIdWithLabel } from '../core/ids.js';
17
- import { memoryExists } from '../core/io.js';
17
+ import { memoryExists, MEMORY_DIR } from '../core/io.js';
18
18
  import { generateCandidateIdWithLabel, loadCandidate, saveCandidate } from '../core/candidates.js';
19
19
  import { createEntity, getEntity, listEntities, boundListResult, DEFAULT_FIND_CHAR_BUDGET, removeEntity, transitionEntity, updateEntity, } from '../core/entity-operations.js';
20
20
  import { relocateEntity } from '../core/operations/relocate.js';
@@ -31,16 +31,18 @@ import { rejectCandidate } from './reject.js';
31
31
  import { startSession } from './session-start.js';
32
32
  import { endSession } from './session-end.js';
33
33
  import { applyHandoffUpdates } from './update-handoff.js';
34
- import { AgentIdentityResolutionError, AgentTrustError, findAgentIdentityById, findAgentIdentityByName, hasMinimumTrustLevel, normalizeAgentName, requireMinimumTrustLevel, requireRegisteredAgentIdentity, resolveCurrentAgentIdentity, resolveCurrentModel, ensureAgentRegisteredForDispatch, } from '../core/agent-registry.js';
34
+ import { AgentIdentityResolutionError, AgentTrustError, findAgentIdentityById, findAgentIdentityByName, hasMinimumTrustLevel, normalizeAgentName, requireMinimumTrustLevel, requireRegisteredAgentIdentity, resolveCurrentAgentIdentity, resolveCurrentModel, ensureAgentRegisteredForDispatch, resolveOrAutoRegisterAgentIdentity, } from '../core/agent-registry.js';
35
35
  import { appendAuditEntry } from '../core/audit.js';
36
36
  import { nowISO, generateId } from '../core/ids.js';
37
- import { buildOperationalIdentity, loadAllSessions, loadSessionById } from '../core/identity.js';
37
+ import { buildOperationalIdentity, loadAllSessions, loadCurrentSession, loadSessionById, saveCurrentSession } from '../core/identity.js';
38
38
  import { validateMcpInput, validateMcpField } from '../core/input-validation.js';
39
39
  import { createCapability, createTool as createRegistryTool } from '../core/registries.js';
40
40
  import { detectAiAgent } from '../core/ai-agent-detection.js';
41
+ import { isObserverMode } from '../core/observer-mode.js';
41
42
  import { checkGitPresence, scanGitRepos, parseRoots, parseRepoSelection, parseAgentSelection, getDetectedSetupAgentNames, getInstalledAgentNames, runGlobalInstall, initReposAndConfigureAgents, readSetupState, ALL_KNOWN_AGENTS, } from './setup.js';
42
43
  import { buildAgentInventory } from '../core/agent-inventory.js';
43
- import { resolveEffectiveCwd, resolveEffectiveCwdInfo, resolveProjectRef, resolveTargetStore } from '../core/store-resolution.js';
44
+ import { findOutermostBrainclawRoot, resolveEffectiveCwd, resolveEffectiveCwdInfo, resolveProjectRef, resolveTargetStore } from '../core/store-resolution.js';
45
+ import { switchProject } from './switch.js';
44
46
  import { assessBootstrapNeed, probeForQuickSetup, buildQuickSetupProbeResponse, buildOnboardingPreview, resolveEmptyMemoryRecommendation } from '../core/setup-flow.js';
45
47
  import { ensureUserStore, resolveHomeDir } from '../core/setup-state.js';
46
48
  import { createPlan, addStep as addStepOp, completeStep as completeStepOp, updateStep as updateStepOp, deleteStep as deleteStepOp, deletePlan as deletePlanOp } from '../core/operations/plan.js';
@@ -655,13 +657,17 @@ const MCP_WRITE_TOOLS = [
655
657
  },
656
658
  {
657
659
  name: 'bclaw_release_claim',
658
- description: 'Release a work claim.',
660
+ description: 'Release a work claim. Callers own their own claims; a trusted+ coordinator releasing another agent\'s claim MUST pass coordinator_override:true (audited).',
659
661
  annotations: { tier: 'standard', category: 'coordination', headlessApproval: 'auto' },
660
662
  inputSchema: {
661
663
  type: 'object',
662
664
  properties: {
663
665
  id: { type: 'string', description: 'Claim ID to release.' },
664
666
  planStatus: { type: 'string', description: 'Optional: update linked plan status.' },
667
+ coordinator_override: {
668
+ type: 'boolean',
669
+ description: 'Opt-in override for a trusted+ caller releasing a claim they do NOT own (cross-agent teardown, ghost-claim cleanup). Rejected for contributor-level callers; audited when used. trp#928.',
670
+ },
665
671
  },
666
672
  required: ['id'],
667
673
  },
@@ -1187,7 +1193,7 @@ const MCP_WRITE_TOOLS = [
1187
1193
  type: 'object',
1188
1194
  properties: {
1189
1195
  entity: { type: 'string', description: 'Entity name: plan | decision | constraint | trap | handoff | runtime_note | candidate | sequence | claim | action | assignment | agent_run | cross_project_link. Others not yet wired.' },
1190
- 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.' },
1196
+ filter: { type: 'object', description: 'Filter keys (ANY entity): 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-SCOPED keys (rejected with a validation_error if used with any other entity): assignment_id, claim_id, message_id — ONLY for entity="agent_run". Unknown/mis-scoped keys are rejected loudly.' },
1191
1197
  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`.' },
1192
1198
  budget_tokens: { type: 'number', description: 'Optional token budget for the page payload (~4 chars/token). Tightens the default size cap; pagination metadata (has_more/next_offset) still applies.' },
1193
1199
  },
@@ -1255,7 +1261,7 @@ const MCP_WRITE_TOOLS = [
1255
1261
  },
1256
1262
  {
1257
1263
  name: 'bclaw_transition',
1258
- description: 'Transition an entity to a new status. Validated against EntityRegistry.transitions. Returns the triggered side-effect tags. Pass `project` to transition an entity in a linked project instead of the current one.',
1264
+ description: 'Transition an entity to a new status. Validated against EntityRegistry.transitions. Returns the triggered side-effect tags. Pass `project` to transition an entity in a linked project instead of the current one. For entity="claim": released/stale transitions are ownership-checked — non-owners must pass coordinator_override:true (trusted+ trust level required).',
1259
1265
  annotations: { tier: 'standard', category: 'memory', headlessApproval: 'prompt' },
1260
1266
  inputSchema: {
1261
1267
  type: 'object',
@@ -1265,6 +1271,7 @@ const MCP_WRITE_TOOLS = [
1265
1271
  to: { type: 'string', description: 'Target status.' },
1266
1272
  reason: { type: 'string', description: 'Optional free-text reason, audited alongside the transition.' },
1267
1273
  project: { type: 'string', description: 'Optional: name of a linked project to transition the entity in. Defaults to the current project.' },
1274
+ coordinator_override: { type: 'boolean', description: 'entity="claim" only: opt-in override for a trusted+ caller releasing/staling a claim they do NOT own. Audited when used. trp#928.' },
1268
1275
  },
1269
1276
  required: ['entity', 'id', 'to'],
1270
1277
  },
@@ -1828,10 +1835,22 @@ function ensureTrust(args, fields, level, cwd, sessionId) {
1828
1835
  * missing field — which would then be silently GC'd by the state sync loop
1829
1836
  * (see fix plan pln_5f44426c).
1830
1837
  *
1831
- * pln#562 step 3 — resolution failure is a HARD error. The old fallback to
1832
- * author:'unknown' produced records that passed creation but were schema-
1833
- * invalid on read and silently GC'd: a write that lies about succeeding.
1834
- * Callers map the throw to a validation_error tool response.
1838
+ * pln#562 step 3 — a write that would create a record with a missing/'unknown'
1839
+ * author must never be silent (that produced records that passed creation but
1840
+ * were schema-invalid on read and silently GC'd from disk).
1841
+ *
1842
+ * pln#608 — extended with auto-repair: when the caller has no session but a
1843
+ * derivable agent name (arg / $BRAINCLAW_AGENT_NAME / detected AI agent),
1844
+ * fall through to `resolveOrAutoRegisterAgentIdentity` and materialize the
1845
+ * session via `buildOperationalIdentity({ persistImplicitSession: true })`
1846
+ * (same mechanic as switchProject:86-106 and session-start). The freshly-
1847
+ * created session is tagged `auto_created` so aggressive harvesting can
1848
+ * distinguish it from operator sessions (pln#602). The caller receives
1849
+ * `auto_repair` and surfaces it as a warning — never silent.
1850
+ *
1851
+ * KEEP (still a hard error, doctrine boundary): the identity is ambiguous
1852
+ * (no name in args, no env signal, no detectable agent). We do not invent
1853
+ * an identity — invoke intent is unclear and the write would misattribute.
1835
1854
  */
1836
1855
  function resolveCanonicalAuthor(args, cwd, connectionSessionId) {
1837
1856
  const resolved = resolveMutationIdentity(args, { nameField: 'agent', idField: 'agentId' }, cwd, connectionSessionId);
@@ -1841,9 +1860,92 @@ function resolveCanonicalAuthor(args, cwd, connectionSessionId) {
1841
1860
  agent_id: resolved.identity.agent_id,
1842
1861
  };
1843
1862
  }
1844
- const detail = 'error' in resolved && resolved.error ? resolved.error.message : 'no registered agent identity resolved';
1845
- throw new Error(`cannot resolve mutation author: ${detail} `
1846
- + 'Start a session (bclaw_session_start) or pass a registered agent before writing.');
1863
+ const strictError = 'error' in resolved && resolved.error ? resolved.error : undefined;
1864
+ // KEEP (doctrine boundary): a pinned principal that rejected the caller args
1865
+ // is a SPOOF/MISMATCH, not an ambiguous first-write. Never auto-repair over
1866
+ // it — silently re-attributing would defeat pln#562 step 3. The strict error
1867
+ // already carries the pointer to a curator override.
1868
+ if (resolveConnectionPrincipal(cwd, connectionSessionId)) {
1869
+ throw new Error(`cannot resolve mutation author: ${strictError?.message ?? 'principal mismatch'}`);
1870
+ }
1871
+ // Observer processes are read-only dashboards/inspectors. Even when an env
1872
+ // variable leaks an agent name into the observer process, canonical writes
1873
+ // must not use the auto-repair path because it can mint identity/session
1874
+ // state as a side effect.
1875
+ if (isObserverMode()) {
1876
+ throw new Error(`cannot resolve mutation author: ${strictError?.message ?? 'observer mode cannot auto-repair identity/session state'}`);
1877
+ }
1878
+ const explicitName = typeof args.agent === 'string' ? args.agent : undefined;
1879
+ const explicitId = typeof args.agentId === 'string' ? args.agentId : undefined;
1880
+ // resolveOrAutoRegisterAgentIdentity's fall-through helper only reads
1881
+ // BRAINCLAW_AGENT / OPENCLAW_AGENT. resolveCurrentAgentIdentity also honors
1882
+ // BRAINCLAW_AGENT_NAME, and dispatched workers set both. Normalize here so
1883
+ // an env-declared name is a first-class signal to the auto-repair path.
1884
+ const envAgentName = explicitName
1885
+ ?? (process.env.BRAINCLAW_AGENT_NAME?.trim() || undefined)
1886
+ ?? (process.env.BRAINCLAW_AGENT?.trim() || undefined);
1887
+ let identity;
1888
+ let autoRegistered;
1889
+ try {
1890
+ const outcome = resolveOrAutoRegisterAgentIdentity({
1891
+ agentName: envAgentName,
1892
+ agentId: explicitId,
1893
+ cwd,
1894
+ allowCurrent: true,
1895
+ allowEnv: true,
1896
+ });
1897
+ identity = outcome.identity;
1898
+ autoRegistered = outcome.auto_registered;
1899
+ }
1900
+ catch (err) {
1901
+ // Genuine ambiguity — no derivable name. Stays a hard error (KEEP: doctrine
1902
+ // boundary is "ambiguous intent → refuse with next_action", not silence).
1903
+ const detail = err instanceof Error ? err.message : (strictError?.message ?? String(err));
1904
+ throw new Error(`cannot resolve mutation author: ${detail} `
1905
+ + 'Pass a registered agent, set $BRAINCLAW_AGENT_NAME, '
1906
+ + 'or register with `brainclaw register-agent <name>` before writing.', { cause: err });
1907
+ }
1908
+ const explicitSessionId = connectionSessionId?.trim() || explicitSessionIdFromEnv();
1909
+ const hadSessionBefore = explicitSessionId
1910
+ ? Boolean(loadSessionById(explicitSessionId, cwd))
1911
+ : Boolean(loadCurrentSession(cwd));
1912
+ let sessionAutoCreated;
1913
+ try {
1914
+ const opIdentity = buildOperationalIdentity(identity.agent_name, cwd, {
1915
+ agentId: identity.agent_id,
1916
+ sessionId: explicitSessionId,
1917
+ persistImplicitSession: true,
1918
+ });
1919
+ if (!hadSessionBefore && opIdentity.session_id) {
1920
+ sessionAutoCreated = opIdentity.session_id;
1921
+ const session = loadSessionById(opIdentity.session_id, cwd);
1922
+ if (session && !session.auto_created) {
1923
+ saveCurrentSession({ ...session, auto_created: true }, cwd);
1924
+ }
1925
+ }
1926
+ }
1927
+ catch { /* best-effort — write can still proceed without a persisted session */ }
1928
+ const autoRepair = (autoRegistered || sessionAutoCreated)
1929
+ ? {
1930
+ ...(autoRegistered ? { agent_auto_registered: true } : {}),
1931
+ ...(sessionAutoCreated ? { session_auto_created: sessionAutoCreated } : {}),
1932
+ }
1933
+ : undefined;
1934
+ return {
1935
+ agent_name: identity.agent_name,
1936
+ agent_id: identity.agent_id,
1937
+ ...(autoRepair ? { auto_repair: autoRepair } : {}),
1938
+ };
1939
+ }
1940
+ function renderAutoRepairWarning(auto_repair, agent_name) {
1941
+ const parts = [];
1942
+ if (auto_repair.agent_auto_registered) {
1943
+ parts.push(`agent '${agent_name}' auto-registered (first use). Run \`brainclaw register-agent ${agent_name}\` to set capabilities and trust level.`);
1944
+ }
1945
+ if (auto_repair.session_auto_created) {
1946
+ parts.push(`session ${auto_repair.session_auto_created} auto-created for this write.`);
1947
+ }
1948
+ return `⚠️ auto-repair: ${parts.join(' ')}`;
1847
1949
  }
1848
1950
  function explicitSessionIdFromEnv() {
1849
1951
  return process.env.BRAINCLAW_SESSION_ID?.trim()
@@ -2647,6 +2749,100 @@ function blockCrossProjectExecution(entity, args) {
2647
2749
  return createToolErrorResponse('validation_error', error instanceof Error ? error.message : String(error));
2648
2750
  }
2649
2751
  }
2752
+ function matchesCrossProjectLink(ref, cwd) {
2753
+ const trimmed = ref.trim();
2754
+ if (!trimmed)
2755
+ return false;
2756
+ const linkRoots = new Set([path.resolve(cwd), path.resolve(resolveWorkspaceAnchor(cwd))]);
2757
+ for (const root of linkRoots) {
2758
+ for (const link of resolveCrossProjectLinks(root)) {
2759
+ if (link.projectName === trimmed
2760
+ || link.name === trimmed
2761
+ || link.path === trimmed
2762
+ || link.absolutePath === trimmed
2763
+ || path.basename(link.absolutePath) === trimmed) {
2764
+ return true;
2765
+ }
2766
+ }
2767
+ }
2768
+ return false;
2769
+ }
2770
+ /**
2771
+ * Resolve the destination store for an execution-entity write (plan / claim and
2772
+ * their sub-objects: steps).
2773
+ *
2774
+ * The signaling-only boundary (cnd cross_project_signaling_vs_execution) forbids
2775
+ * driving execution entities into ANOTHER project — but that rule is about
2776
+ * FEDERATION (cross_project_links / other machines), not about workspace siblings
2777
+ * in the same monorepo. Switching into a sibling and creating a plan there is a
2778
+ * purely local operation, and the one the agent actually wants.
2779
+ *
2780
+ * So when `project=X` resolves to a workspace store-chain child (resolveProjectRef
2781
+ * hits — it only matches projects reachable WITHIN this workspace, never a
2782
+ * federated link), we AUTO-LOCALIZE: open a session + session-scoped switch into X
2783
+ * (sticky, per-agent — switchProject auto-creates the session if missing), then
2784
+ * write locally in X. Federated links and unknown names stay blocked.
2785
+ *
2786
+ * DGX dogfood 2026-06-27: without this an agent on the /srv monorepo cannot
2787
+ * `bclaw_create(entity=plan, project=<child>)` — it was rejected as cross-project —
2788
+ * so plans silently fell back to the default project instead.
2789
+ */
2790
+ function resolveExecutionWriteTarget(entity, args, cwd, connectionSessionId) {
2791
+ const targetProject = getCrossProjectArg(args, 'targetProject', 'target_project', 'crossProject', 'cross_project', 'project');
2792
+ if (!targetProject) {
2793
+ return { targetCwd: cwd, autoSwitched: false };
2794
+ }
2795
+ if (matchesCrossProjectLink(targetProject, cwd)) {
2796
+ const block = blockCrossProjectExecution(entity, args);
2797
+ return {
2798
+ block: block ?? createToolErrorResponse('validation_error', `Cross-project execution write blocked: ${targetProject}`),
2799
+ targetCwd: cwd,
2800
+ autoSwitched: false,
2801
+ };
2802
+ }
2803
+ // Workspace store-chain child (or the workspace root / the current project)?
2804
+ const wsHit = resolveProjectRef(targetProject, cwd);
2805
+ if (wsHit) {
2806
+ // Same-workspace → auto-localize. Switch the session into X (sticky,
2807
+ // session-scoped) so subsequent un-qualified writes follow, and persist the
2808
+ // session under the workspace anchor where resolveEffectiveCwd probes for it
2809
+ // — NOT the effective child cwd, or stickiness would be invisible on the next
2810
+ // call. The switch is best-effort: the write still localizes to wsHit below.
2811
+ let autoSwitched = false;
2812
+ try {
2813
+ const anchor = resolveWorkspaceAnchor(cwd);
2814
+ const sessionId = connectionSessionId ?? explicitSessionIdFromEnv();
2815
+ switchProject(targetProject, { cwd: anchor, sessionOnly: true, sessionId });
2816
+ autoSwitched = true;
2817
+ }
2818
+ catch {
2819
+ /* sticky switch is best-effort */
2820
+ }
2821
+ return { targetCwd: wsHit, autoSwitched, resolvedProject: projectInfoForCwd(wsHit) };
2822
+ }
2823
+ // Not a workspace child → federated link or unknown name. The signaling-only
2824
+ // boundary stands: execution entities never cross a federation boundary.
2825
+ const block = blockCrossProjectExecution(entity, args);
2826
+ return {
2827
+ block: block ?? createToolErrorResponse('validation_error', `Unknown project: ${targetProject}`),
2828
+ targetCwd: cwd,
2829
+ autoSwitched: false,
2830
+ };
2831
+ }
2832
+ /**
2833
+ * Workspace anchor for persisting a session switch — mirrors resolveEffectiveCwd's
2834
+ * anchor selection (BRAINCLAW_CWD when it is a real store, else the outermost
2835
+ * store walking up from cwd) so an auto-switch is found on the next resolution.
2836
+ */
2837
+ function resolveWorkspaceAnchor(cwd) {
2838
+ const env = process.env.BRAINCLAW_CWD?.trim();
2839
+ if (env) {
2840
+ const resolved = path.resolve(env);
2841
+ if (fs.existsSync(path.join(resolved, MEMORY_DIR, 'config.yaml')))
2842
+ return resolved;
2843
+ }
2844
+ return findOutermostBrainclawRoot(cwd) ?? cwd;
2845
+ }
2650
2846
  // Read handlers moved to mcp-read-handlers.ts
2651
2847
  import { handleMcpReadToolCall } from './mcp-read-handlers.js';
2652
2848
  export { handleMcpReadToolCall };
@@ -3300,21 +3496,17 @@ async function _executeMcpToolCallInner(payload) {
3300
3496
  }
3301
3497
  }
3302
3498
  if (name === 'bclaw_claim') {
3303
- const crossProjectError = blockCrossProjectExecution('claim', args);
3304
- if (crossProjectError) {
3305
- return { response: crossProjectError };
3306
- }
3307
- // Resolve project-scoped cwd before store resolution (fixes worktree in wrong project)
3308
- let effectiveClaimCwd = cwd;
3309
- const claimProjectArg = args.project;
3310
- if (claimProjectArg) {
3311
- const resolvedProject = resolveProjectRef(claimProjectArg, cwd);
3312
- if (resolvedProject)
3313
- effectiveClaimCwd = resolvedProject;
3314
- }
3499
+ // project=X naming a workspace sibling auto-localizes (session+switch then
3500
+ // claim locally); federated links / unknown names stay blocked.
3501
+ const claimLoc = resolveExecutionWriteTarget('claim', args, cwd, connectionSessionId);
3502
+ if (claimLoc.block) {
3503
+ return { response: claimLoc.block };
3504
+ }
3505
+ const effectiveClaimCwd = claimLoc.targetCwd;
3506
+ const claimAutoSwitched = claimLoc.autoSwitched;
3315
3507
  const storeTarget = args.store ?? 'local';
3316
3508
  const claimCwd = resolveTargetStore(effectiveClaimCwd, storeTarget);
3317
- const resolved = ensureTrust(args, { nameField: 'agent', idField: 'agentId' }, 'contributor', claimCwd, connectionSessionId);
3509
+ const resolved = ensureTrust(args, { nameField: 'agent', idField: 'agentId' }, 'contributor', cwd, connectionSessionId);
3318
3510
  if (resolved.error) {
3319
3511
  return { response: createToolErrorResponse(resolved.error.kind, resolved.error.message, resolved.error.details) };
3320
3512
  }
@@ -3329,10 +3521,13 @@ async function _executeMcpToolCallInner(payload) {
3329
3521
  return { response: createToolErrorResponse('validation_error', descCheck.message) };
3330
3522
  }
3331
3523
  const resolvedIdentity = resolved.identity;
3332
- const identity = buildOperationalIdentity(resolvedIdentity.agent_name, claimCwd, {
3333
- agentId: resolvedIdentity.agent_id,
3334
- sessionId: connectionSessionId,
3335
- });
3524
+ const identity = {
3525
+ ...buildOperationalIdentity(resolvedIdentity.agent_name, cwd, {
3526
+ agentId: resolvedIdentity.agent_id,
3527
+ sessionId: connectionSessionId,
3528
+ }),
3529
+ project_id: loadConfig(claimCwd).project_id,
3530
+ };
3336
3531
  const claimId = generateClaimId();
3337
3532
  let worktreePath;
3338
3533
  let worktreeWarn = '';
@@ -3438,7 +3633,8 @@ async function _executeMcpToolCallInner(payload) {
3438
3633
  const worktreeNote = worktreePath ? `\n Worktree: ${worktreePath}` : '';
3439
3634
  const expiryNote = claimExpiresAt ? `\n Expires: ${claimExpiresAt.slice(0, 16).replace('T', ' ')} UTC` : '';
3440
3635
  const handoffNote = handoffMode ? `\n Handoff: ${handoffMode} (another agent will review and merge)` : '';
3441
- const claimText = `✔ Claimed scope [${claimId}]${worktreeNote}${expiryNote}${handoffNote}${noPlanWarn}${worktreeWarn}${branchWarn}${staleBranchWarn}${policyWarn}${postClaimText ? `\n${postClaimText}` : ''}`;
3636
+ const autoSwitchNote = claimAutoSwitched ? `\n Auto-switched ${projectInfoForCwd(effectiveClaimCwd).name ?? effectiveClaimCwd}` : '';
3637
+ const claimText = `✔ Claimed scope [${claimId}]${worktreeNote}${expiryNote}${handoffNote}${autoSwitchNote}${noPlanWarn}${worktreeWarn}${branchWarn}${staleBranchWarn}${policyWarn}${postClaimText ? `\n${postClaimText}` : ''}`;
3442
3638
  return {
3443
3639
  response: appendLegacyMcpToolWarning(toolResponse({
3444
3640
  content: [{ type: 'text', text: claimText }],
@@ -3446,6 +3642,7 @@ async function _executeMcpToolCallInner(payload) {
3446
3642
  session_id: identity.session_id,
3447
3643
  worktree_path: worktreePath,
3448
3644
  triggered_items: postClaimItems,
3645
+ ...(claimAutoSwitched ? { auto_switched: true, resolved_project: projectInfoForCwd(effectiveClaimCwd) } : {}),
3449
3646
  }), name),
3450
3647
  nextConnectionSessionId: explicitSessionIdFromEnv() ? undefined : identity.session_id,
3451
3648
  };
@@ -3465,18 +3662,43 @@ async function _executeMcpToolCallInner(payload) {
3465
3662
  catch {
3466
3663
  return { response: createToolErrorResponse('not_found', `Claim not found: ${claimId}`) };
3467
3664
  }
3468
- // pln#562 step 5 — release is ownership-checked like acquisition and
3469
- // adoption. The resolved principal must own the claim; trusted+ callers
3470
- // (coordinators) may release others' claims, with an audit entry.
3665
+ // pln#562 step 5 + trp#928 — release is ownership-checked like acquisition
3666
+ // and adoption. Under the trp#928 tightening the coordinator override is
3667
+ // OPT-IN via coordinator_override:true (implicit "trusted+ = always
3668
+ // override" was too magic — a coordinator releasing a worker's claim
3669
+ // should be a visible act, not a silent side-effect of trust). The
3670
+ // ownership check still enforces:
3671
+ // - owner-of-claim releases (identity matches): allowed, no override needed
3672
+ // - non-owner releases without coordinator_override: rejected loudly (the
3673
+ // error message points the caller at coordinator_override so it is
3674
+ // executable — pln#607 rule).
3675
+ // - non-owner releases with coordinator_override:true but not trusted+:
3676
+ // trust_error (privilege escalation prevention).
3677
+ // - non-owner releases with coordinator_override:true and trusted+:
3678
+ // allowed, audited (auditReleaseOverride).
3471
3679
  const releaseIdentity = resolveMutationIdentity(args, { nameField: 'agent', idField: 'agentId' }, cwd, connectionSessionId);
3472
- const releaseAuth = 'identity' in releaseIdentity && releaseIdentity.identity
3473
- ? {
3474
- agent: releaseIdentity.identity.agent_name,
3475
- agent_id: releaseIdentity.identity.agent_id,
3476
- session_id: connectionSessionId,
3477
- override: hasMinimumTrustLevel(releaseIdentity.identity.trust_level ?? 'contributor', 'trusted'),
3680
+ if ('error' in releaseIdentity && releaseIdentity.error) {
3681
+ const { kind, message, details } = releaseIdentity.error;
3682
+ return { response: createToolErrorResponse(kind, message, details) };
3683
+ }
3684
+ if (!('identity' in releaseIdentity) || !releaseIdentity.identity) {
3685
+ return { response: createToolErrorResponse('identity_error', 'No registered agent identity resolved for bclaw_release_claim.') };
3686
+ }
3687
+ const coordinatorOverrideRequested = args.coordinator_override === true;
3688
+ if (coordinatorOverrideRequested) {
3689
+ const trustLevel = releaseIdentity.identity.trust_level ?? 'contributor';
3690
+ if (!hasMinimumTrustLevel(trustLevel, 'trusted')) {
3691
+ return {
3692
+ response: createToolErrorResponse('trust_error', `coordinator_override:true requires trust_level 'trusted' or higher — caller is '${trustLevel}'. Ask a curator to elevate the agent, or have the claim owner release it.`),
3693
+ };
3478
3694
  }
3479
- : undefined;
3695
+ }
3696
+ const releaseAuth = {
3697
+ agent: releaseIdentity.identity.agent_name,
3698
+ agent_id: releaseIdentity.identity.agent_id,
3699
+ session_id: connectionSessionId,
3700
+ override: coordinatorOverrideRequested,
3701
+ };
3480
3702
  let cascadeResult;
3481
3703
  try {
3482
3704
  cascadeResult = releaseClaimWithCascade(claimId, {
@@ -3971,6 +4193,39 @@ async function _executeMcpToolCallInner(payload) {
3971
4193
  actor: callerAgent,
3972
4194
  actor_id: resolved.identity.agent_id,
3973
4195
  }, cwd);
4196
+ // trp#928 — cascade-release the assignment's linked claim on completion.
4197
+ // Before this landing an obedient worker had to make TWO calls to close
4198
+ // the loop (bclaw_assignment_update status=completed AND
4199
+ // bclaw_release_claim); dispatch briefs enumerate both, but not every
4200
+ // sandboxed worker gets through both, and the coordinator's harvest path
4201
+ // only releases on --integrate — so contributor-driven completions left
4202
+ // claims active. The worker's own identity owns the claim (session
4203
+ // adoption), so ownership matches and no coordinator_override is needed.
4204
+ // Silent success/failure is unacceptable: log per-claim outcome.
4205
+ if (status === 'completed' && assignment.claim_id) {
4206
+ try {
4207
+ const { releaseClaimsCascade, logCascadeReleaseResult } = await import('../core/claims.js');
4208
+ const cascade = releaseClaimsCascade([assignment.claim_id], {
4209
+ cwd,
4210
+ planStatus: 'done',
4211
+ auth: {
4212
+ agent: callerAgent,
4213
+ agent_id: resolved.identity.agent_id,
4214
+ session_id: effectiveSessionId,
4215
+ override: false,
4216
+ },
4217
+ });
4218
+ logCascadeReleaseResult({
4219
+ actor: callerAgent,
4220
+ trigger: 'assignment_completed',
4221
+ assignment_id: assignmentId,
4222
+ claim_id: assignment.claim_id,
4223
+ cascade,
4224
+ cwd,
4225
+ });
4226
+ }
4227
+ catch { /* never block the update on cascade release */ }
4228
+ }
3974
4229
  // When accepted: auto-acknowledge the inbox message (replaces bclaw_ack_message)
3975
4230
  if (status === 'accepted' && assignment.message_id) {
3976
4231
  try {
@@ -4232,9 +4487,9 @@ async function _executeMcpToolCallInner(payload) {
4232
4487
  }
4233
4488
  }
4234
4489
  if (name === 'bclaw_add_step') {
4235
- const crossProjectError = blockCrossProjectExecution('plan', args);
4236
- if (crossProjectError) {
4237
- return { response: crossProjectError };
4490
+ const stepLoc = resolveExecutionWriteTarget('plan', args, cwd, connectionSessionId);
4491
+ if (stepLoc.block) {
4492
+ return { response: stepLoc.block };
4238
4493
  }
4239
4494
  const resolved = ensureTrust(args, { nameField: 'agent', idField: 'agentId' }, 'contributor', cwd, connectionSessionId);
4240
4495
  if (resolved.error) {
@@ -4256,7 +4511,7 @@ async function _executeMcpToolCallInner(payload) {
4256
4511
  return { response: createToolErrorResponse('validation_error', 'Missing required argument: planId') };
4257
4512
  if (!stepText)
4258
4513
  return { response: createToolErrorResponse('validation_error', 'Missing required argument: data.text') };
4259
- const stepTargetCwd = resolveProjectCwd(args.project, cwd);
4514
+ const stepTargetCwd = stepLoc.targetCwd;
4260
4515
  try {
4261
4516
  const result = addStepOp({ planId: stepPlanId, text: stepText, assignee: stepAssignee, estimatedEffort: stepEstimated, actualEffort: stepActual }, stepTargetCwd);
4262
4517
  return {
@@ -4277,9 +4532,9 @@ async function _executeMcpToolCallInner(payload) {
4277
4532
  }
4278
4533
  }
4279
4534
  if (name === 'bclaw_complete_step') {
4280
- const crossProjectError = blockCrossProjectExecution('plan', args);
4281
- if (crossProjectError) {
4282
- return { response: crossProjectError };
4535
+ const csLoc = resolveExecutionWriteTarget('plan', args, cwd, connectionSessionId);
4536
+ if (csLoc.block) {
4537
+ return { response: csLoc.block };
4283
4538
  }
4284
4539
  const resolved = ensureTrust(args, { nameField: 'agent', idField: 'agentId' }, 'contributor', cwd, connectionSessionId);
4285
4540
  if (resolved.error) {
@@ -4291,7 +4546,7 @@ async function _executeMcpToolCallInner(payload) {
4291
4546
  return { response: createToolErrorResponse('validation_error', 'Missing required argument: planId') };
4292
4547
  if (!csStepId)
4293
4548
  return { response: createToolErrorResponse('validation_error', 'Missing required argument: stepId') };
4294
- const csTargetCwd = resolveProjectCwd(args.project, cwd);
4549
+ const csTargetCwd = csLoc.targetCwd;
4295
4550
  try {
4296
4551
  const result = completeStepOp({ planId: csPlanId, stepId: csStepId }, csTargetCwd);
4297
4552
  return {
@@ -4313,9 +4568,9 @@ async function _executeMcpToolCallInner(payload) {
4313
4568
  }
4314
4569
  }
4315
4570
  if (name === 'bclaw_update_step') {
4316
- const crossProjectError = blockCrossProjectExecution('plan', args);
4317
- if (crossProjectError) {
4318
- return { response: crossProjectError };
4571
+ const usLoc = resolveExecutionWriteTarget('plan', args, cwd, connectionSessionId);
4572
+ if (usLoc.block) {
4573
+ return { response: usLoc.block };
4319
4574
  }
4320
4575
  const resolved = ensureTrust(args, { nameField: 'agent', idField: 'agentId' }, 'contributor', cwd, connectionSessionId);
4321
4576
  if (resolved.error) {
@@ -4331,7 +4586,7 @@ async function _executeMcpToolCallInner(payload) {
4331
4586
  if (args.status && !validStatuses.includes(String(args.status))) {
4332
4587
  return { response: createToolErrorResponse('validation_error', `Invalid status: ${args.status}. Valid: ${validStatuses.join(', ')}`) };
4333
4588
  }
4334
- const usTargetCwd = resolveProjectCwd(args.project, cwd);
4589
+ const usTargetCwd = usLoc.targetCwd;
4335
4590
  try {
4336
4591
  const result = updateStepOp({
4337
4592
  planId: usPlanId,
@@ -4372,9 +4627,9 @@ async function _executeMcpToolCallInner(payload) {
4372
4627
  }
4373
4628
  }
4374
4629
  if (name === 'bclaw_delete_step') {
4375
- const crossProjectError = blockCrossProjectExecution('plan', args);
4376
- if (crossProjectError) {
4377
- return { response: crossProjectError };
4630
+ const dsLoc = resolveExecutionWriteTarget('plan', args, cwd, connectionSessionId);
4631
+ if (dsLoc.block) {
4632
+ return { response: dsLoc.block };
4378
4633
  }
4379
4634
  const resolved = ensureTrust(args, { nameField: 'agent', idField: 'agentId' }, 'contributor', cwd, connectionSessionId);
4380
4635
  if (resolved.error) {
@@ -4386,7 +4641,7 @@ async function _executeMcpToolCallInner(payload) {
4386
4641
  return { response: createToolErrorResponse('validation_error', 'Missing required argument: planId') };
4387
4642
  if (!dsStepId)
4388
4643
  return { response: createToolErrorResponse('validation_error', 'Missing required argument: stepId') };
4389
- const dsTargetCwd = resolveProjectCwd(args.project, cwd);
4644
+ const dsTargetCwd = dsLoc.targetCwd;
4390
4645
  try {
4391
4646
  const result = deleteStepOp({ planId: dsPlanId, stepId: dsStepId }, dsTargetCwd);
4392
4647
  return {
@@ -4407,10 +4662,11 @@ async function _executeMcpToolCallInner(payload) {
4407
4662
  }
4408
4663
  }
4409
4664
  if (name === 'bclaw_delete_plan') {
4410
- const crossProjectError = blockCrossProjectExecution('plan', args);
4411
- if (crossProjectError) {
4412
- return { response: crossProjectError };
4665
+ const dpLoc = resolveExecutionWriteTarget('plan', args, cwd, connectionSessionId);
4666
+ if (dpLoc.block) {
4667
+ return { response: dpLoc.block };
4413
4668
  }
4669
+ const dpTargetCwd = dpLoc.targetCwd;
4414
4670
  const resolved = ensureTrust(args, { nameField: 'agent', idField: 'agentId' }, 'trusted', cwd, connectionSessionId);
4415
4671
  if (resolved.error) {
4416
4672
  return { response: createToolErrorResponse(resolved.error.kind, resolved.error.message, resolved.error.details) };
@@ -4419,7 +4675,7 @@ async function _executeMcpToolCallInner(payload) {
4419
4675
  if (!dpId)
4420
4676
  return { response: createToolErrorResponse('validation_error', 'Missing required argument: id') };
4421
4677
  try {
4422
- const result = deletePlanOp(dpId, cwd);
4678
+ const result = deletePlanOp(dpId, dpTargetCwd);
4423
4679
  return {
4424
4680
  response: toolResponse({
4425
4681
  content: [{ type: 'text', text: `✔ Plan deleted: [${result.id}] ${result.text.slice(0, 80)}` }],
@@ -6418,17 +6674,38 @@ async function _executeMcpToolCallInner(payload) {
6418
6674
  // only checks known keys), letting the caller believe the filter had
6419
6675
  // applied when it hadn't. Under the new contract, an unknown key is
6420
6676
  // a validation_error listing the keys actually honored.
6677
+ // trp#928 — the entity-scoping error is now first-class: the doc says
6678
+ // assignment_id/claim_id/message_id are entity='agent_run' only, but
6679
+ // before this the rejection message called them 'unknown', misleading
6680
+ // callers who'd cross-reference the description. Now the message names
6681
+ // the constraint AND the entity that DOES accept the key so the user
6682
+ // can fix the call without hunting through docs. (pln#599 docs-vs-facts.)
6421
6683
  const KNOWN_FILTER_KEYS = new Set([
6422
6684
  'status', 'tag', 'tags', 'author', 'plan_id', 'source', 'auto_generated',
6423
6685
  'assignment_id', 'claim_id', 'message_id',
6424
6686
  'limit', 'offset', 'includeLegacy', 'minAutoReflectConfidence',
6425
6687
  ]);
6426
6688
  const agentRunOnlyFilterKeys = new Set(['assignment_id', 'claim_id', 'message_id']);
6427
- const unknownKeys = Object.keys(filter).filter((k) => !KNOWN_FILTER_KEYS.has(k) || (agentRunOnlyFilterKeys.has(k) && entity !== 'agent_run'));
6428
- if (unknownKeys.length > 0) {
6689
+ const providedKeys = Object.keys(filter);
6690
+ const unknownKeys = providedKeys.filter((k) => !KNOWN_FILTER_KEYS.has(k));
6691
+ const misScopedKeys = providedKeys.filter((k) => agentRunOnlyFilterKeys.has(k) && entity !== 'agent_run');
6692
+ if (unknownKeys.length > 0 || misScopedKeys.length > 0) {
6693
+ const parts = [];
6694
+ if (unknownKeys.length > 0) {
6695
+ parts.push(`Unknown filter key(s): ${unknownKeys.map((k) => `"${k}"`).join(', ')}. Accepted keys: ${[...KNOWN_FILTER_KEYS].sort().join(', ')}.`);
6696
+ }
6697
+ if (misScopedKeys.length > 0) {
6698
+ parts.push(`Filter key(s) ${misScopedKeys.map((k) => `"${k}"`).join(', ')} are only valid for entity="agent_run" `
6699
+ + `(this call used entity="${entity}"). `
6700
+ + `Retry with entity="agent_run", or drop the ${misScopedKeys.join('/')} filter.`);
6701
+ }
6429
6702
  return {
6430
- response: createToolErrorResponse('validation_error', `Unknown filter key(s): ${unknownKeys.map((k) => `"${k}"`).join(', ')}. ` +
6431
- `Accepted keys: ${[...KNOWN_FILTER_KEYS].sort().join(', ')}.`, { unknown_keys: unknownKeys, accepted_keys: [...KNOWN_FILTER_KEYS].sort() }),
6703
+ response: createToolErrorResponse('validation_error', parts.join(' '), {
6704
+ unknown_keys: unknownKeys,
6705
+ mis_scoped_keys: misScopedKeys,
6706
+ accepted_keys: [...KNOWN_FILTER_KEYS].sort(),
6707
+ agent_run_only_keys: [...agentRunOnlyFilterKeys],
6708
+ }),
6432
6709
  };
6433
6710
  }
6434
6711
  const result = listEntities(entity, targetCwd, filter);
@@ -6537,15 +6814,22 @@ async function _executeMcpToolCallInner(payload) {
6537
6814
  if (name === 'bclaw_create') {
6538
6815
  try {
6539
6816
  const entity = String(args.entity ?? '');
6540
- // Execution entities stay local: the signaling-only cross-project
6541
- // boundary applies to the canonical verbs too, not just legacy tools.
6817
+ // Execution entities (plan/claim) auto-localize into a workspace sibling
6818
+ // when project=X names one: session+switch then write locally. Only
6819
+ // federated links / unknown names are blocked (signaling-only boundary).
6820
+ let targetCwd;
6821
+ let autoSwitched = false;
6542
6822
  if (entity === 'claim' || entity === 'plan') {
6543
- const crossProjectError = blockCrossProjectExecution(entity, args);
6544
- if (crossProjectError)
6545
- return { response: crossProjectError };
6823
+ const loc = resolveExecutionWriteTarget(entity, args, cwd, connectionSessionId);
6824
+ if (loc.block)
6825
+ return { response: loc.block };
6826
+ targetCwd = loc.targetCwd;
6827
+ autoSwitched = loc.autoSwitched;
6828
+ }
6829
+ else {
6830
+ targetCwd = resolveProjectCwd(args.project, cwd);
6546
6831
  }
6547
6832
  const rawData = (args.data ?? {});
6548
- const targetCwd = resolveProjectCwd(args.project, cwd);
6549
6833
  const targetScope = scopeMetadataForTarget(args, targetCwd, scopeInfo);
6550
6834
  // Auto-fill identity fields. Without this, a caller who omits author/agent
6551
6835
  // creates a schema-invalid record that is silently dropped on read and
@@ -6560,28 +6844,36 @@ async function _executeMcpToolCallInner(payload) {
6560
6844
  const data = { ...rawData };
6561
6845
  let actor = typeof data.author === 'string' ? data.author : undefined;
6562
6846
  let actorId = typeof data.agent_id === 'string' ? data.agent_id : undefined;
6847
+ let autoRepair;
6563
6848
  if (data.author === undefined) {
6564
- const { agent_name, agent_id } = resolveCanonicalAuthor(args, cwd, connectionSessionId);
6565
- data.author = agent_name;
6849
+ const author = resolveCanonicalAuthor(args, cwd, connectionSessionId);
6850
+ data.author = author.agent_name;
6566
6851
  if (data.agent === undefined)
6567
- data.agent = agent_name;
6568
- if (data.agent_id === undefined && agent_id)
6569
- data.agent_id = agent_id;
6570
- actor = agent_name;
6571
- actorId = agent_id;
6852
+ data.agent = author.agent_name;
6853
+ if (data.agent_id === undefined && author.agent_id)
6854
+ data.agent_id = author.agent_id;
6855
+ actor = author.agent_name;
6856
+ actorId = author.agent_id;
6857
+ autoRepair = author.auto_repair;
6572
6858
  }
6573
6859
  else if (data.agent === undefined) {
6574
6860
  data.agent = data.author;
6575
6861
  }
6576
6862
  const result = createEntity(entity, data, targetCwd);
6577
6863
  appendAuditEntry({ actor: actor ?? 'unknown', ...(actorId ? { actor_id: actorId } : {}), action: 'create', item_id: result.id, item_type: entity }, targetCwd);
6864
+ const createText = `✔ created ${entity} ${result.id}${autoSwitched ? ` (auto-switched → ${targetScope.resolved_project.name ?? targetScope.resolved_project.path})` : ''}`;
6865
+ const createContent = autoRepair
6866
+ ? [{ type: 'text', text: createText }, { type: 'text', text: renderAutoRepairWarning(autoRepair, actor ?? 'unknown') }]
6867
+ : [{ type: 'text', text: createText }];
6578
6868
  return {
6579
6869
  response: toolResponse({
6580
- content: [{ type: 'text', text: `✔ created ${entity} ${result.id}` }],
6870
+ content: createContent,
6581
6871
  structuredContent: {
6582
6872
  ...result,
6583
6873
  resolved_project: targetScope.resolved_project,
6584
- active_source: targetScope.active_source,
6874
+ active_source: autoSwitched ? 'auto_switch' : targetScope.active_source,
6875
+ ...(autoSwitched ? { auto_switched: true } : {}),
6876
+ ...(autoRepair ? { auto_repair: autoRepair } : {}),
6585
6877
  },
6586
6878
  }),
6587
6879
  };
@@ -6597,16 +6889,21 @@ async function _executeMcpToolCallInner(payload) {
6597
6889
  const patch = (args.patch ?? {});
6598
6890
  const targetCwd = resolveProjectCwd(args.project, cwd);
6599
6891
  const targetScope = scopeMetadataForTarget(args, targetCwd, scopeInfo);
6600
- const { agent_name, agent_id } = resolveCanonicalAuthor(args, cwd, connectionSessionId);
6892
+ const { agent_name, agent_id, auto_repair } = resolveCanonicalAuthor(args, cwd, connectionSessionId);
6601
6893
  const result = updateEntity(entity, id, patch, targetCwd);
6602
6894
  appendAuditEntry({ actor: agent_name, ...(agent_id ? { actor_id: agent_id } : {}), action: 'update', item_id: id, item_type: entity }, targetCwd);
6895
+ const updateText = `✔ updated ${entity} ${id}`;
6896
+ const updateContent = auto_repair
6897
+ ? [{ type: 'text', text: updateText }, { type: 'text', text: renderAutoRepairWarning(auto_repair, agent_name) }]
6898
+ : [{ type: 'text', text: updateText }];
6603
6899
  return {
6604
6900
  response: toolResponse({
6605
- content: [{ type: 'text', text: `✔ updated ${entity} ${id}` }],
6901
+ content: updateContent,
6606
6902
  structuredContent: {
6607
6903
  ...result,
6608
6904
  resolved_project: targetScope.resolved_project,
6609
6905
  active_source: targetScope.active_source,
6906
+ ...(auto_repair ? { auto_repair } : {}),
6610
6907
  },
6611
6908
  }),
6612
6909
  };
@@ -6622,16 +6919,21 @@ async function _executeMcpToolCallInner(payload) {
6622
6919
  const purge = args.purge === true;
6623
6920
  const targetCwd = resolveProjectCwd(args.project, cwd);
6624
6921
  const targetScope = scopeMetadataForTarget(args, targetCwd, scopeInfo);
6625
- const { agent_name, agent_id } = resolveCanonicalAuthor(args, cwd, connectionSessionId);
6922
+ const { agent_name, agent_id, auto_repair } = resolveCanonicalAuthor(args, cwd, connectionSessionId);
6626
6923
  const result = removeEntity(entity, id, targetCwd, purge);
6627
6924
  appendAuditEntry({ actor: agent_name, ...(agent_id ? { actor_id: agent_id } : {}), action: 'delete', item_id: id, item_type: entity, reason: purge ? 'purged' : 'archived' }, targetCwd);
6925
+ const removeText = `✔ removed ${entity} ${id}`;
6926
+ const removeContent = auto_repair
6927
+ ? [{ type: 'text', text: removeText }, { type: 'text', text: renderAutoRepairWarning(auto_repair, agent_name) }]
6928
+ : [{ type: 'text', text: removeText }];
6628
6929
  return {
6629
6930
  response: toolResponse({
6630
- content: [{ type: 'text', text: `✔ removed ${entity} ${id}` }],
6931
+ content: removeContent,
6631
6932
  structuredContent: {
6632
6933
  ...result,
6633
6934
  resolved_project: targetScope.resolved_project,
6634
6935
  active_source: targetScope.active_source,
6936
+ ...(auto_repair ? { auto_repair } : {}),
6635
6937
  },
6636
6938
  }),
6637
6939
  };
@@ -6647,13 +6949,20 @@ async function _executeMcpToolCallInner(payload) {
6647
6949
  const toProject = String(args.to_project ?? '');
6648
6950
  const fromProject = typeof args.from_project === 'string' ? args.from_project : undefined;
6649
6951
  const force = args.force === true;
6650
- const { agent_name, agent_id } = resolveCanonicalAuthor(args, cwd, connectionSessionId);
6952
+ const { agent_name, agent_id, auto_repair } = resolveCanonicalAuthor(args, cwd, connectionSessionId);
6651
6953
  const result = relocateEntity({ entity, id, toProject, fromProject, force, cwd, actor: agent_name, actorId: agent_id });
6652
6954
  const warn = result.warnings.length ? ` (${result.warnings.length} warning(s))` : '';
6955
+ const moveText = `✔ moved ${entity} ${id} → ${result.to}${warn}`;
6956
+ const moveContent = auto_repair
6957
+ ? [{ type: 'text', text: moveText }, { type: 'text', text: renderAutoRepairWarning(auto_repair, agent_name) }]
6958
+ : [{ type: 'text', text: moveText }];
6653
6959
  return {
6654
6960
  response: toolResponse({
6655
- content: [{ type: 'text', text: `✔ moved ${entity} ${id} → ${result.to}${warn}` }],
6656
- structuredContent: { ...result },
6961
+ content: moveContent,
6962
+ structuredContent: {
6963
+ ...result,
6964
+ ...(auto_repair ? { auto_repair } : {}),
6965
+ },
6657
6966
  }),
6658
6967
  };
6659
6968
  }
@@ -6664,28 +6973,67 @@ async function _executeMcpToolCallInner(payload) {
6664
6973
  if (name === 'bclaw_transition') {
6665
6974
  try {
6666
6975
  const entity = String(args.entity ?? '');
6667
- // Same signaling-only boundary as bclaw_create: no remote lifecycle
6668
- // driving of execution entities through the canonical grammar.
6976
+ // Same auto-localize as bclaw_create: a workspace sibling named by
6977
+ // project=X is switched into and transitioned locally; only federated
6978
+ // links / unknown names are blocked (signaling-only boundary).
6979
+ let targetCwd;
6980
+ let autoSwitched = false;
6669
6981
  if (entity === 'claim' || entity === 'plan') {
6670
- const crossProjectError = blockCrossProjectExecution(entity, args);
6671
- if (crossProjectError)
6672
- return { response: crossProjectError };
6982
+ const loc = resolveExecutionWriteTarget(entity, args, cwd, connectionSessionId);
6983
+ if (loc.block)
6984
+ return { response: loc.block };
6985
+ targetCwd = loc.targetCwd;
6986
+ autoSwitched = loc.autoSwitched;
6987
+ }
6988
+ else {
6989
+ targetCwd = resolveProjectCwd(args.project, cwd);
6673
6990
  }
6674
6991
  const id = String(args.id ?? '');
6675
6992
  const to = String(args.to ?? '');
6676
6993
  const reason = args.reason;
6677
- const targetCwd = resolveProjectCwd(args.project, cwd);
6678
6994
  const targetScope = scopeMetadataForTarget(args, targetCwd, scopeInfo);
6679
- const { agent_name, agent_id } = resolveCanonicalAuthor(args, cwd, connectionSessionId);
6680
- const result = transitionEntity(entity, id, to, targetCwd, reason);
6995
+ const { agent_name, agent_id, auto_repair } = resolveCanonicalAuthor(args, cwd, connectionSessionId);
6996
+ // trp#928 claim transitions consume the ReleaseClaimAuth ownership
6997
+ // check (released/stale both mutate a claim owned by SOME agent). Reuse
6998
+ // the same coordinator_override opt-in as bclaw_release_claim so both
6999
+ // paths have identical trust semantics and the same executable error.
7000
+ let transitionAuth;
7001
+ if (entity === 'claim') {
7002
+ const transitionIdentity = resolveMutationIdentity(args, { nameField: 'agent', idField: 'agentId' }, targetCwd, connectionSessionId);
7003
+ const coordinatorOverrideRequested = args.coordinator_override === true;
7004
+ if (coordinatorOverrideRequested) {
7005
+ const identity = 'identity' in transitionIdentity ? transitionIdentity.identity : undefined;
7006
+ const trustLevel = identity?.trust_level ?? 'contributor';
7007
+ if (!hasMinimumTrustLevel(trustLevel, 'trusted')) {
7008
+ return {
7009
+ response: createToolErrorResponse('trust_error', `coordinator_override:true requires trust_level 'trusted' or higher — caller is '${trustLevel}'.`),
7010
+ };
7011
+ }
7012
+ }
7013
+ transitionAuth = 'identity' in transitionIdentity && transitionIdentity.identity
7014
+ ? {
7015
+ agent: transitionIdentity.identity.agent_name,
7016
+ agent_id: transitionIdentity.identity.agent_id,
7017
+ session_id: connectionSessionId,
7018
+ override: coordinatorOverrideRequested,
7019
+ }
7020
+ : undefined;
7021
+ }
7022
+ const result = transitionEntity(entity, id, to, targetCwd, reason, transitionAuth);
6681
7023
  appendAuditEntry({ actor: agent_name, ...(agent_id ? { actor_id: agent_id } : {}), action: 'update', item_id: id, item_type: entity, reason: `transition ${result.from} → ${to}${reason ? ` (${reason})` : ''}` }, targetCwd);
7024
+ const transitionText = `✔ ${entity} ${id}: ${result.from} → ${to}${autoSwitched ? ` (auto-switched → ${targetScope.resolved_project.name ?? targetScope.resolved_project.path})` : ''}`;
7025
+ const transitionContent = auto_repair
7026
+ ? [{ type: 'text', text: transitionText }, { type: 'text', text: renderAutoRepairWarning(auto_repair, agent_name) }]
7027
+ : [{ type: 'text', text: transitionText }];
6682
7028
  return {
6683
7029
  response: toolResponse({
6684
- content: [{ type: 'text', text: `✔ ${entity} ${id}: ${result.from} → ${to}` }],
7030
+ content: transitionContent,
6685
7031
  structuredContent: {
6686
7032
  ...result,
6687
7033
  resolved_project: targetScope.resolved_project,
6688
- active_source: targetScope.active_source,
7034
+ active_source: autoSwitched ? 'auto_switch' : targetScope.active_source,
7035
+ ...(autoSwitched ? { auto_switched: true } : {}),
7036
+ ...(auto_repair ? { auto_repair } : {}),
6689
7037
  },
6690
7038
  }),
6691
7039
  };