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