brainclaw 1.20.4 → 1.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +162 -46
- 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/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 {
|
|
@@ -18,6 +18,8 @@ import { buildContext } from '../core/context.js';
|
|
|
18
18
|
import { detectCommitsBehindMainDetailed } from '../core/execution-context.js';
|
|
19
19
|
import { checkBrainclawInstallableUpdate, renderBrainclawInstallableUpdateNotice } from '../core/brainclaw-version.js';
|
|
20
20
|
import { loadConfig } from '../core/config.js';
|
|
21
|
+
import { isLocatableId, locateEntity } from '../core/entity-locator.js';
|
|
22
|
+
import { logger } from '../core/logger.js';
|
|
21
23
|
import { generateClaimId, loadClaim, saveClaim, adoptClaimSession, releaseClaimWithCascade, claimBaselineFields } from '../core/claims.js';
|
|
22
24
|
import { releaseClaimNextActions } from '../core/next-actions.js';
|
|
23
25
|
import { reconcileClaimConformity } from '../core/claim-conformity.js';
|
|
@@ -207,7 +209,7 @@ export async function handleBclawClaim(payload, ctx) {
|
|
|
207
209
|
};
|
|
208
210
|
}
|
|
209
211
|
export async function handleBclawReleaseClaim(payload, ctx) {
|
|
210
|
-
const { args,
|
|
212
|
+
const { args, connectionSessionId } = payload;
|
|
211
213
|
const crossProjectError = ctx.blockCrossProjectExecution('claim', args);
|
|
212
214
|
if (crossProjectError) {
|
|
213
215
|
return { response: crossProjectError };
|
|
@@ -216,11 +218,46 @@ export async function handleBclawReleaseClaim(payload, ctx) {
|
|
|
216
218
|
if (!claimId) {
|
|
217
219
|
return { response: createToolErrorResponse('validation_error', 'Missing required argument: id') };
|
|
218
220
|
}
|
|
221
|
+
// ── pln#649 F5, second surface: the CLAIM routes this call.
|
|
222
|
+
//
|
|
223
|
+
// `bclaw_assignment_update` was routed first because that is where the field
|
|
224
|
+
// defect was reproduced. This is the OTHER HALF of the same defect, documented in
|
|
225
|
+
// trp#1327: `bclaw_release_claim` has no `project` parameter either, so a worker
|
|
226
|
+
// whose resolved store is not the claim's got `Claim not found` and the claim
|
|
227
|
+
// stayed active forever. It only LOOKED fixed because completing an assignment
|
|
228
|
+
// cascade-releases its claim with the routed cwd — a worker that calls
|
|
229
|
+
// release_claim directly still fell in the hole.
|
|
230
|
+
//
|
|
231
|
+
// Same shape as the reviewed surface, deliberately: validate the id before any
|
|
232
|
+
// path is built, locate, refuse an ambiguity WITHOUT disclosing which projects
|
|
233
|
+
// (this runs before the ownership check, so an unauthenticated caller must learn a
|
|
234
|
+
// count and an action, never names or store paths), then rebind `cwd` ONCE so every
|
|
235
|
+
// downstream use — ownership check, cascade, plan status — is routed by construction.
|
|
236
|
+
if (!isLocatableId(claimId)) {
|
|
237
|
+
return { response: createToolErrorResponse('validation_error', `Invalid claim id '${claimId}'`) };
|
|
238
|
+
}
|
|
239
|
+
const located = locateEntity('claim', claimId, payload.cwd);
|
|
240
|
+
if (located.status === 'ambiguous') {
|
|
241
|
+
logger.warn(`ambiguous claim routing: ${claimId} found in `
|
|
242
|
+
+ located.matches.map((m) => `${m.project_name ?? '(unnamed)'} @ ${m.cwd}`).join(', '));
|
|
243
|
+
return {
|
|
244
|
+
response: createToolErrorResponse('validation_error', `Claim ${claimId} exists in ${located.matches.length} projects reachable from here. `
|
|
245
|
+
+ 'Refusing to guess which one you meant — call from the project that owns the work, '
|
|
246
|
+
+ 'or ask an operator to resolve the duplicate (details are in the server log).', { claim_id: claimId, match_count: located.matches.length }),
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
const cwd = located.location?.cwd ?? payload.cwd;
|
|
219
250
|
try {
|
|
220
251
|
loadClaim(claimId, cwd); // validate existence before delegating
|
|
221
252
|
}
|
|
222
253
|
catch {
|
|
223
|
-
|
|
254
|
+
const scope = located.enumeration_incomplete
|
|
255
|
+
? `${located.probed.length} reachable project(s), and the search hit its depth ceiling so deeper projects were NOT examined`
|
|
256
|
+
: `all ${located.probed.length} reachable project(s)`;
|
|
257
|
+
logger.warn(`claim not found: ${claimId} — searched ${located.probed.join(', ')}`);
|
|
258
|
+
return {
|
|
259
|
+
response: createToolErrorResponse('not_found', `Claim not found: ${claimId} (searched ${scope})`, { claim_id: claimId, searched_count: located.probed.length, enumeration_incomplete: located.enumeration_incomplete }),
|
|
260
|
+
};
|
|
224
261
|
}
|
|
225
262
|
// pln#562 step 5 + trp#928 — release is ownership-checked like acquisition
|
|
226
263
|
// and adoption. Under the trp#928 tightening the coordinator override is
|
|
@@ -540,7 +577,45 @@ export async function handleBclawSessionEnd(payload, ctx) {
|
|
|
540
577
|
};
|
|
541
578
|
}
|
|
542
579
|
export async function handleBclawAssignmentUpdate(payload, ctx) {
|
|
543
|
-
const { args,
|
|
580
|
+
const { args, connectionSessionId } = payload;
|
|
581
|
+
// ── pln#649 step 3 (F5 of dec#153): the ENTITY routes this call, not the ambient
|
|
582
|
+
// cwd. This is the surface where the field defect was reproduced: a worker whose
|
|
583
|
+
// resolved store was not the assignment's got `Assignment not found`, and the
|
|
584
|
+
// assignment stayed `offered` forever with no way for the coordinator to fix it.
|
|
585
|
+
//
|
|
586
|
+
// ORDER IS LOAD-BEARING (review P2-6 of step 2). Routing MUST happen before
|
|
587
|
+
// ensureTrust: that call resolves identity and trust FROM A STORE, so running it
|
|
588
|
+
// on the ambient store can reject the caller before the locator ever gets to say
|
|
589
|
+
// which store owns the work. Same for loadAssignment below. So `cwd` is rebound
|
|
590
|
+
// once, here, and every downstream use is routed by construction — there is no
|
|
591
|
+
// second site to forget.
|
|
592
|
+
const assignmentIdArg = typeof args.assignment_id === 'string' ? args.assignment_id : undefined;
|
|
593
|
+
if (!assignmentIdArg)
|
|
594
|
+
return { response: createToolErrorResponse('input_error', 'assignment_id is required') };
|
|
595
|
+
if (!isLocatableId(assignmentIdArg)) {
|
|
596
|
+
return { response: createToolErrorResponse('input_error', `Invalid assignment_id '${assignmentIdArg}'`) };
|
|
597
|
+
}
|
|
598
|
+
const located = locateEntity('assignment', assignmentIdArg, payload.cwd);
|
|
599
|
+
if (located.status === 'ambiguous') {
|
|
600
|
+
// Two stores hold this id. Refusing is the point (T3): picking one would be a
|
|
601
|
+
// silent cross-project write, the failure mode dec#153 exists against.
|
|
602
|
+
//
|
|
603
|
+
// BUT SAY NOTHING ABOUT WHICH PROJECTS (review P2-3). This branch runs BEFORE
|
|
604
|
+
// ensureTrust — it has to, because trust is resolved FROM a store and the owning
|
|
605
|
+
// store is not known yet — so an unauthenticated caller who guesses a duplicated
|
|
606
|
+
// id would otherwise be handed project names and absolute paths. The first
|
|
607
|
+
// version of this message did exactly that. Routing before trust is required;
|
|
608
|
+
// DISCLOSING before trust is not, so the operator-facing detail goes to the
|
|
609
|
+
// server log and the caller gets a count and an action.
|
|
610
|
+
logger.warn(`ambiguous assignment routing: ${assignmentIdArg} found in `
|
|
611
|
+
+ located.matches.map((m) => `${m.project_name ?? '(unnamed)'} @ ${m.cwd}`).join(', '));
|
|
612
|
+
return {
|
|
613
|
+
response: createToolErrorResponse('validation_error', `Assignment ${assignmentIdArg} exists in ${located.matches.length} projects reachable from here. `
|
|
614
|
+
+ 'Refusing to guess which one you meant — call from the project that owns the work, '
|
|
615
|
+
+ 'or ask an operator to resolve the duplicate (details are in the server log).', { assignment_id: assignmentIdArg, match_count: located.matches.length }),
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
const cwd = located.location?.cwd ?? payload.cwd;
|
|
544
619
|
// Contributor trust: lowest dispatchable level. The agent-owner guard
|
|
545
620
|
// below ensures only the assigned agent can update its own assignment.
|
|
546
621
|
const resolved = ctx.ensureTrust(args, { nameField: 'agent', idField: 'agentId' }, 'contributor', cwd, connectionSessionId);
|
|
@@ -548,10 +623,8 @@ export async function handleBclawAssignmentUpdate(payload, ctx) {
|
|
|
548
623
|
return { response: createToolErrorResponse(resolved.error.kind, resolved.error.message, resolved.error.details) };
|
|
549
624
|
}
|
|
550
625
|
try {
|
|
551
|
-
const assignmentId =
|
|
626
|
+
const assignmentId = assignmentIdArg;
|
|
552
627
|
const status = typeof args.status === 'string' ? args.status : undefined;
|
|
553
|
-
if (!assignmentId)
|
|
554
|
-
return { response: createToolErrorResponse('input_error', 'assignment_id is required') };
|
|
555
628
|
if (!status)
|
|
556
629
|
return { response: createToolErrorResponse('input_error', 'status is required') };
|
|
557
630
|
const message = args.message;
|
|
@@ -564,7 +637,24 @@ export async function handleBclawAssignmentUpdate(payload, ctx) {
|
|
|
564
637
|
const { loadAssignment, transitionAssignment: transitionAsgn, recordProgress: recordProg } = await import('../core/assignments.js');
|
|
565
638
|
const assignment = loadAssignment(assignmentId, cwd);
|
|
566
639
|
if (!assignment) {
|
|
567
|
-
|
|
640
|
+
// Say WHERE we looked. "not found" used to be indistinguishable from "found
|
|
641
|
+
// in a store I was not routed to" — the exact ambiguity that made the field
|
|
642
|
+
// report hard to diagnose. And an incomplete enumeration must never read as a
|
|
643
|
+
// confident absence (step 2, enumeration_incomplete).
|
|
644
|
+
// The COUNT and the truncation flag are actionable and disclose nothing; the
|
|
645
|
+
// absolute store PATHS did (review P2-4: a contributor in A could submit an
|
|
646
|
+
// absent id and enumerate linked/nested project paths), so they go to the log.
|
|
647
|
+
const scope = located.enumeration_incomplete
|
|
648
|
+
? `${located.probed.length} reachable project(s), and the search hit its depth ceiling so deeper projects were NOT examined`
|
|
649
|
+
: `all ${located.probed.length} reachable project(s)`;
|
|
650
|
+
logger.warn(`assignment not found: ${assignmentId} — searched ${located.probed.join(', ')}`);
|
|
651
|
+
return {
|
|
652
|
+
response: createToolErrorResponse('not_found', `Assignment not found: ${assignmentId} (searched ${scope})`, {
|
|
653
|
+
assignment_id: assignmentId,
|
|
654
|
+
searched_count: located.probed.length,
|
|
655
|
+
enumeration_incomplete: located.enumeration_incomplete,
|
|
656
|
+
}),
|
|
657
|
+
};
|
|
568
658
|
}
|
|
569
659
|
// Agent guard: only the assigned agent can update
|
|
570
660
|
const callerAgent = resolved.identity.agent_name;
|
|
@@ -760,17 +850,49 @@ export async function handleBclawAssignmentAction(payload, ctx) {
|
|
|
760
850
|
return { response: createToolErrorResponse('operation_error', err instanceof Error ? err.message : String(err)) };
|
|
761
851
|
}
|
|
762
852
|
}
|
|
763
|
-
|
|
853
|
+
/**
|
|
854
|
+
* Route a plan-step mutation by its plan when one was named. `planId` is a
|
|
855
|
+
* discriminator, not a suggestion: resolving trust and then editing the ambient
|
|
856
|
+
* store would reproduce the cross-project write defect this handler is meant to
|
|
857
|
+
* avoid. Explicit `project` keeps its existing auto-localisation semantics.
|
|
858
|
+
*/
|
|
859
|
+
function resolvePlanStepWriteTarget(payload, ctx, planId) {
|
|
764
860
|
const { args, cwd, connectionSessionId } = payload;
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
861
|
+
if (args.project !== undefined) {
|
|
862
|
+
const explicit = ctx.resolveExecutionWriteTarget('plan', args, cwd, connectionSessionId);
|
|
863
|
+
if (explicit.block)
|
|
864
|
+
return { response: explicit.block };
|
|
865
|
+
return { targetCwd: explicit.targetCwd };
|
|
768
866
|
}
|
|
769
|
-
|
|
867
|
+
// Short labels are resolved by the plan operation itself. They are not stable
|
|
868
|
+
// filesystem keys, so only a canonical id participates in locator routing.
|
|
869
|
+
if (!isLocatableId(planId))
|
|
870
|
+
return { targetCwd: cwd };
|
|
871
|
+
const located = locateEntity('plan', planId, cwd);
|
|
872
|
+
if (located.status === 'ambiguous') {
|
|
873
|
+
logger.warn(`ambiguous plan-step routing: ${planId} found in `
|
|
874
|
+
+ located.matches.map((m) => `${m.project_name ?? '(unnamed)'} @ ${m.cwd}`).join(', '));
|
|
875
|
+
return {
|
|
876
|
+
response: createToolErrorResponse('validation_error', `Plan ${planId} exists in ${located.matches.length} projects reachable from here. `
|
|
877
|
+
+ 'Refusing to guess which one you meant — call from the project that owns the plan, '
|
|
878
|
+
+ 'or ask an operator to resolve the duplicate (details are in the server log).', { plan_id: planId, match_count: located.matches.length }),
|
|
879
|
+
};
|
|
880
|
+
}
|
|
881
|
+
return { targetCwd: located.location?.cwd ?? cwd };
|
|
882
|
+
}
|
|
883
|
+
export async function handleBclawAddStep(payload, ctx) {
|
|
884
|
+
const { args, connectionSessionId } = payload;
|
|
885
|
+
const stepPlanId = String(args.planId ?? '').trim();
|
|
886
|
+
if (!stepPlanId)
|
|
887
|
+
return { response: createToolErrorResponse('validation_error', 'Missing required argument: planId') };
|
|
888
|
+
const stepTarget = resolvePlanStepWriteTarget(payload, ctx, stepPlanId);
|
|
889
|
+
if (stepTarget.response)
|
|
890
|
+
return { response: stepTarget.response };
|
|
891
|
+
const stepTargetCwd = stepTarget.targetCwd;
|
|
892
|
+
const resolved = ctx.ensureTrust(args, { nameField: 'agent', idField: 'agentId' }, 'contributor', stepTargetCwd, connectionSessionId);
|
|
770
893
|
if (resolved.error) {
|
|
771
894
|
return { response: createToolErrorResponse(resolved.error.kind, resolved.error.message, resolved.error.details) };
|
|
772
895
|
}
|
|
773
|
-
const stepPlanId = String(args.planId ?? '').trim();
|
|
774
896
|
const stepData = args.data && typeof args.data === 'object' && !Array.isArray(args.data)
|
|
775
897
|
? args.data
|
|
776
898
|
: {};
|
|
@@ -782,11 +904,8 @@ export async function handleBclawAddStep(payload, ctx) {
|
|
|
782
904
|
const stepAssignee = (stepData.assignee ?? args.assignee);
|
|
783
905
|
const stepEstimated = (stepData.estimated_effort ?? args.estimated_effort);
|
|
784
906
|
const stepActual = (stepData.actual_effort ?? args.actual_effort);
|
|
785
|
-
if (!stepPlanId)
|
|
786
|
-
return { response: createToolErrorResponse('validation_error', 'Missing required argument: planId') };
|
|
787
907
|
if (!stepText)
|
|
788
908
|
return { response: createToolErrorResponse('validation_error', 'Missing required argument: data.text') };
|
|
789
|
-
const stepTargetCwd = stepLoc.targetCwd;
|
|
790
909
|
try {
|
|
791
910
|
const result = addStepOp({ planId: stepPlanId, text: stepText, assignee: stepAssignee, estimatedEffort: stepEstimated, actualEffort: stepActual }, stepTargetCwd);
|
|
792
911
|
return {
|
|
@@ -807,22 +926,21 @@ export async function handleBclawAddStep(payload, ctx) {
|
|
|
807
926
|
}
|
|
808
927
|
}
|
|
809
928
|
export async function handleBclawCompleteStep(payload, ctx) {
|
|
810
|
-
const { args,
|
|
811
|
-
const csLoc = ctx.resolveExecutionWriteTarget('plan', args, cwd, connectionSessionId);
|
|
812
|
-
if (csLoc.block) {
|
|
813
|
-
return { response: csLoc.block };
|
|
814
|
-
}
|
|
815
|
-
const resolved = ctx.ensureTrust(args, { nameField: 'agent', idField: 'agentId' }, 'contributor', cwd, connectionSessionId);
|
|
816
|
-
if (resolved.error) {
|
|
817
|
-
return { response: createToolErrorResponse(resolved.error.kind, resolved.error.message, resolved.error.details) };
|
|
818
|
-
}
|
|
929
|
+
const { args, connectionSessionId } = payload;
|
|
819
930
|
const csPlanId = String(args.planId ?? '').trim();
|
|
820
931
|
const csStepId = String(args.stepId ?? '').trim();
|
|
821
932
|
if (!csPlanId)
|
|
822
933
|
return { response: createToolErrorResponse('validation_error', 'Missing required argument: planId') };
|
|
823
934
|
if (!csStepId)
|
|
824
935
|
return { response: createToolErrorResponse('validation_error', 'Missing required argument: stepId') };
|
|
825
|
-
const
|
|
936
|
+
const csTarget = resolvePlanStepWriteTarget(payload, ctx, csPlanId);
|
|
937
|
+
if (csTarget.response)
|
|
938
|
+
return { response: csTarget.response };
|
|
939
|
+
const csTargetCwd = csTarget.targetCwd;
|
|
940
|
+
const resolved = ctx.ensureTrust(args, { nameField: 'agent', idField: 'agentId' }, 'contributor', csTargetCwd, connectionSessionId);
|
|
941
|
+
if (resolved.error) {
|
|
942
|
+
return { response: createToolErrorResponse(resolved.error.kind, resolved.error.message, resolved.error.details) };
|
|
943
|
+
}
|
|
826
944
|
try {
|
|
827
945
|
const result = completeStepOp({ planId: csPlanId, stepId: csStepId }, csTargetCwd);
|
|
828
946
|
return {
|
|
@@ -844,15 +962,7 @@ export async function handleBclawCompleteStep(payload, ctx) {
|
|
|
844
962
|
}
|
|
845
963
|
}
|
|
846
964
|
export async function handleBclawUpdateStep(payload, ctx) {
|
|
847
|
-
const { args,
|
|
848
|
-
const usLoc = ctx.resolveExecutionWriteTarget('plan', args, cwd, connectionSessionId);
|
|
849
|
-
if (usLoc.block) {
|
|
850
|
-
return { response: usLoc.block };
|
|
851
|
-
}
|
|
852
|
-
const resolved = ctx.ensureTrust(args, { nameField: 'agent', idField: 'agentId' }, 'contributor', cwd, connectionSessionId);
|
|
853
|
-
if (resolved.error) {
|
|
854
|
-
return { response: createToolErrorResponse(resolved.error.kind, resolved.error.message, resolved.error.details) };
|
|
855
|
-
}
|
|
965
|
+
const { args, connectionSessionId } = payload;
|
|
856
966
|
const usPlanId = String(args.planId ?? '').trim();
|
|
857
967
|
const usStepId = String(args.stepId ?? '').trim();
|
|
858
968
|
if (!usPlanId)
|
|
@@ -863,7 +973,14 @@ export async function handleBclawUpdateStep(payload, ctx) {
|
|
|
863
973
|
if (args.status && !validStatuses.includes(String(args.status))) {
|
|
864
974
|
return { response: createToolErrorResponse('validation_error', `Invalid status: ${args.status}. Valid: ${validStatuses.join(', ')}`) };
|
|
865
975
|
}
|
|
866
|
-
const
|
|
976
|
+
const usTarget = resolvePlanStepWriteTarget(payload, ctx, usPlanId);
|
|
977
|
+
if (usTarget.response)
|
|
978
|
+
return { response: usTarget.response };
|
|
979
|
+
const usTargetCwd = usTarget.targetCwd;
|
|
980
|
+
const resolved = ctx.ensureTrust(args, { nameField: 'agent', idField: 'agentId' }, 'contributor', usTargetCwd, connectionSessionId);
|
|
981
|
+
if (resolved.error) {
|
|
982
|
+
return { response: createToolErrorResponse(resolved.error.kind, resolved.error.message, resolved.error.details) };
|
|
983
|
+
}
|
|
867
984
|
try {
|
|
868
985
|
const result = updateStepOp({
|
|
869
986
|
planId: usPlanId,
|
|
@@ -904,22 +1021,21 @@ export async function handleBclawUpdateStep(payload, ctx) {
|
|
|
904
1021
|
}
|
|
905
1022
|
}
|
|
906
1023
|
export async function handleBclawDeleteStep(payload, ctx) {
|
|
907
|
-
const { args,
|
|
908
|
-
const dsLoc = ctx.resolveExecutionWriteTarget('plan', args, cwd, connectionSessionId);
|
|
909
|
-
if (dsLoc.block) {
|
|
910
|
-
return { response: dsLoc.block };
|
|
911
|
-
}
|
|
912
|
-
const resolved = ctx.ensureTrust(args, { nameField: 'agent', idField: 'agentId' }, 'contributor', cwd, connectionSessionId);
|
|
913
|
-
if (resolved.error) {
|
|
914
|
-
return { response: createToolErrorResponse(resolved.error.kind, resolved.error.message, resolved.error.details) };
|
|
915
|
-
}
|
|
1024
|
+
const { args, connectionSessionId } = payload;
|
|
916
1025
|
const dsPlanId = String(args.planId ?? '').trim();
|
|
917
1026
|
const dsStepId = String(args.stepId ?? '').trim();
|
|
918
1027
|
if (!dsPlanId)
|
|
919
1028
|
return { response: createToolErrorResponse('validation_error', 'Missing required argument: planId') };
|
|
920
1029
|
if (!dsStepId)
|
|
921
1030
|
return { response: createToolErrorResponse('validation_error', 'Missing required argument: stepId') };
|
|
922
|
-
const
|
|
1031
|
+
const dsTarget = resolvePlanStepWriteTarget(payload, ctx, dsPlanId);
|
|
1032
|
+
if (dsTarget.response)
|
|
1033
|
+
return { response: dsTarget.response };
|
|
1034
|
+
const dsTargetCwd = dsTarget.targetCwd;
|
|
1035
|
+
const resolved = ctx.ensureTrust(args, { nameField: 'agent', idField: 'agentId' }, 'contributor', dsTargetCwd, connectionSessionId);
|
|
1036
|
+
if (resolved.error) {
|
|
1037
|
+
return { response: createToolErrorResponse(resolved.error.kind, resolved.error.message, resolved.error.details) };
|
|
1038
|
+
}
|
|
923
1039
|
try {
|
|
924
1040
|
const result = deleteStepOp({ planId: dsPlanId, stepId: dsStepId }, dsTargetCwd);
|
|
925
1041
|
return {
|
|
@@ -13,7 +13,9 @@
|
|
|
13
13
|
*
|
|
14
14
|
* @module
|
|
15
15
|
*/
|
|
16
|
+
import path from 'node:path';
|
|
16
17
|
import { appendAuditEntry } from '../core/audit.js';
|
|
18
|
+
import { isLocatableId, locateEntity } from '../core/entity-locator.js';
|
|
17
19
|
import { nowISO, generateIdWithLabel } from '../core/ids.js';
|
|
18
20
|
import { loadState, persistState, saveState } from '../core/state.js';
|
|
19
21
|
import { ENTITY_REGISTRY } from '../core/entity-registry.js';
|
|
@@ -29,6 +31,53 @@ import { rejectCandidate } from './reject.js';
|
|
|
29
31
|
import { applyHandoffUpdates } from './update-handoff.js';
|
|
30
32
|
import { ensureTrust, resolveMutationIdentity, resolveCanonicalAuthor, renderAutoRepairWarning, scopeMetadataForTarget, scanMcpWriteText, appendSecurityWarnings, } from './mcp-write-support.js';
|
|
31
33
|
import { SCHEMA_VERSION, toolResponse, createToolErrorResponse, } from './mcp-contract.js';
|
|
34
|
+
/** Result shape of mcp.ts's resolveExecutionWriteTarget (structural mirror, PR3b). */
|
|
35
|
+
/** Entity kinds the locator can find by id — the divergence check only applies there. */
|
|
36
|
+
const LOCATABLE_FOR_DIVERGENCE = new Set(['assignment', 'claim', 'agent_run', 'plan', 'loop']);
|
|
37
|
+
/**
|
|
38
|
+
* pln#649 / dec#153 T3: the ENTITY-vs-EXPLICIT-PROJECT refusal, shared by every
|
|
39
|
+
* canonical-grammar surface that receives BOTH authorities at once.
|
|
40
|
+
*
|
|
41
|
+
* IT LIVES HERE BECAUSE THERE ARE THREE CONSUMERS, NOT ONE. The first version was
|
|
42
|
+
* inline in `bclaw_transition` and its comment claimed that was "the ONLY canonical
|
|
43
|
+
* grammar surface where both authorities are supplied at once" — FALSE, found by a
|
|
44
|
+
* Fable audit: `bclaw_update` and `bclaw_remove` take the same pair and had no guard,
|
|
45
|
+
* so the misleading `not found` the guard exists to kill survived on two surfaces
|
|
46
|
+
* operators reach the same way. Copying the block a third time is how the by-id
|
|
47
|
+
* duplication earlier in this plan happened; one function, three call sites.
|
|
48
|
+
*
|
|
49
|
+
* Without it a divergence produces a misleading `not found in <B>`: the record exists,
|
|
50
|
+
* just not where the caller named, which leaves them doubting their id. dec#153 says
|
|
51
|
+
* an explicit divergence must be REFUSED and NAMED so they learn which of their two
|
|
52
|
+
* statements was wrong.
|
|
53
|
+
*
|
|
54
|
+
* DISCLOSURE RULE from the two routed surfaces: the project the caller TYPED is
|
|
55
|
+
* already theirs, so naming it back is free — but WHERE the entity actually lives is
|
|
56
|
+
* new information, so that is a COUNT, never a name.
|
|
57
|
+
*
|
|
58
|
+
* The remedy sentence is deliberately not "drop `project` to be routed by the entity",
|
|
59
|
+
* which the first version said and which is not true: dropping `project` falls back to
|
|
60
|
+
* AMBIENT resolution, not to the entity. An error message that misstates its own fix
|
|
61
|
+
* is worse than a comment that does.
|
|
62
|
+
*/
|
|
63
|
+
function refuseEntityProjectDivergence(entity, id, requestedProject, targetCwd, cwd) {
|
|
64
|
+
if (requestedProject === undefined || !id || !isLocatableId(id))
|
|
65
|
+
return undefined;
|
|
66
|
+
if (!LOCATABLE_FOR_DIVERGENCE.has(entity))
|
|
67
|
+
return undefined;
|
|
68
|
+
const located = locateEntity(entity, id, cwd);
|
|
69
|
+
const target = path.resolve(targetCwd);
|
|
70
|
+
// Only fires when the entity was found SOMEWHERE ELSE: a genuine not-found stays a
|
|
71
|
+
// not-found, and an agreement is never refused.
|
|
72
|
+
if (located.matches.length === 0)
|
|
73
|
+
return undefined;
|
|
74
|
+
if (located.matches.some((m) => path.resolve(m.cwd) === target))
|
|
75
|
+
return undefined;
|
|
76
|
+
return createToolErrorResponse('validation_error', `${entity} '${id}' does not live in project '${String(requestedProject)}' — it exists in `
|
|
77
|
+
+ `${located.matches.length} other reachable project(s). Refusing: the entity and the project you named `
|
|
78
|
+
+ 'disagree, and guessing which one you meant is how a write lands in the wrong project. '
|
|
79
|
+
+ 'Call from the project that owns it, or name that project.', { entity, id, requested_project: String(requestedProject), located_elsewhere_count: located.matches.length });
|
|
80
|
+
}
|
|
32
81
|
export function handleBclawCreatePlan(payload, ctx) {
|
|
33
82
|
const { args, cwd, connectionSessionId } = payload;
|
|
34
83
|
const crossProjectError = ctx.blockCrossProjectExecution('plan', args);
|
|
@@ -462,6 +511,12 @@ export function handleBclawUpdate(payload, ctx) {
|
|
|
462
511
|
const id = String(args.id ?? '');
|
|
463
512
|
const patch = (args.patch ?? {});
|
|
464
513
|
const targetCwd = resolveProjectCwd(args.project, cwd);
|
|
514
|
+
// dec#153 T3 — this surface takes both authorities too (Fable audit; #182's comment
|
|
515
|
+
// claimed transition was the only one). Refuse BEFORE updateEntity, whose
|
|
516
|
+
// `not found` would otherwise be the misleading message the guard exists to kill.
|
|
517
|
+
const updateDivergence = refuseEntityProjectDivergence(entity, id, args.project, targetCwd, cwd);
|
|
518
|
+
if (updateDivergence)
|
|
519
|
+
return { response: updateDivergence };
|
|
465
520
|
const targetScope = scopeMetadataForTarget(args, targetCwd, ctx.scopeInfo);
|
|
466
521
|
const { agent_name, agent_id, auto_repair } = resolveCanonicalAuthor(args, cwd, connectionSessionId);
|
|
467
522
|
// S2 (pln#623): scan the patched text field on the MCP path (CLI parity).
|
|
@@ -497,6 +552,12 @@ export function handleBclawRemove(payload, ctx) {
|
|
|
497
552
|
const id = String(args.id ?? '');
|
|
498
553
|
const purge = args.purge === true;
|
|
499
554
|
const targetCwd = resolveProjectCwd(args.project, cwd);
|
|
555
|
+
// dec#153 T3 — same pair of authorities, and the highest stakes of the three: a
|
|
556
|
+
// divergence here means the caller is about to remove (or purge) in a project that
|
|
557
|
+
// does not hold the entity they named.
|
|
558
|
+
const removeDivergence = refuseEntityProjectDivergence(entity, id, args.project, targetCwd, cwd);
|
|
559
|
+
if (removeDivergence)
|
|
560
|
+
return { response: removeDivergence };
|
|
500
561
|
const targetScope = scopeMetadataForTarget(args, targetCwd, ctx.scopeInfo);
|
|
501
562
|
const { agent_name, agent_id, auto_repair } = resolveCanonicalAuthor(args, cwd, connectionSessionId);
|
|
502
563
|
const result = removeEntity(entity, id, targetCwd, purge);
|
|
@@ -572,6 +633,12 @@ export function handleBclawTransition(payload, ctx) {
|
|
|
572
633
|
const id = String(args.id ?? '');
|
|
573
634
|
const to = String(args.to ?? '');
|
|
574
635
|
const reason = args.reason;
|
|
636
|
+
// pln#649 / dec#153 T3 — shared with bclaw_update and bclaw_remove, which take the
|
|
637
|
+
// same pair of authorities. `bclaw_transition` is the call trp#1327 documents as the
|
|
638
|
+
// coordinator's workaround, so operators reach it; it is not the only one.
|
|
639
|
+
const transitionDivergence = refuseEntityProjectDivergence(entity, id, args.project, targetCwd, cwd);
|
|
640
|
+
if (transitionDivergence)
|
|
641
|
+
return { response: transitionDivergence };
|
|
575
642
|
const targetScope = scopeMetadataForTarget(args, targetCwd, ctx.scopeInfo);
|
|
576
643
|
const { agent_name, agent_id, auto_repair } = resolveCanonicalAuthor(args, cwd, connectionSessionId);
|
|
577
644
|
// trp#928 — claim transitions consume the ReleaseClaimAuth ownership
|
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;
|
package/dist/commands/switch.js
CHANGED
|
@@ -2,7 +2,7 @@ import path from 'node:path';
|
|
|
2
2
|
import { loadActiveProject, saveActiveProject, clearActiveProject } from '../core/active-project.js';
|
|
3
3
|
import { buildOperationalIdentity, loadCurrentSession, loadSessionById, resolveCurrentSessionId, saveCurrentSession } from '../core/identity.js';
|
|
4
4
|
import { memoryExists } from '../core/io.js';
|
|
5
|
-
import { resolveProjectRef } from '../core/store-resolution.js';
|
|
5
|
+
import { resolveEffectiveCwdInfo, resolveProjectRef } from '../core/store-resolution.js';
|
|
6
6
|
import { resolveCrossProjectLinks, resolveProjectCwd } from '../core/cross-project.js';
|
|
7
7
|
import { scanNestedBrainclawProjects } from '../core/workspace-projects.js';
|
|
8
8
|
import { loadConfig } from '../core/config.js';
|
|
@@ -103,10 +103,17 @@ export function listAvailableProjectsForSession(cwd, sessionId) {
|
|
|
103
103
|
if (!wsRoot) {
|
|
104
104
|
throw new Error('No brainclaw workspace found.');
|
|
105
105
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
106
|
+
// ONE RESOLVER, not a second ladder. This recomputed session-then-global locally, so it
|
|
107
|
+
// could report only two of the seven rungs: an agent physically inside a child project
|
|
108
|
+
// (cwd_child), or one anchored by BRAINCLAW_CWD / BRAINCLAW_PROJECT, was marked against
|
|
109
|
+
// the GLOBAL pointer or against nothing at all — while its writes went elsewhere. That
|
|
110
|
+
// divergence between the reader that DISPLAYS and the resolver that WRITES is what kept
|
|
111
|
+
// the pln#648 bug invisible for weeks: green status, data beside it.
|
|
112
|
+
const effective = resolveEffectiveCwdInfo({ baseCwd: cwd, sessionId });
|
|
113
|
+
const activeSource = effective.active_source;
|
|
114
|
+
const active = effective.resolved_project
|
|
115
|
+
? { path: effective.resolved_project.path, name: effective.resolved_project.name }
|
|
116
|
+
: undefined;
|
|
110
117
|
const projects = [];
|
|
111
118
|
const seen = new Set();
|
|
112
119
|
const addProject = (project) => {
|
|
@@ -262,13 +269,33 @@ export function runSwitch(projectRef, options = {}) {
|
|
|
262
269
|
console.log(`✔ Switched to ${switchedName ? `"${switchedName}" (${rel})` : rel}${scopeHint}`);
|
|
263
270
|
}
|
|
264
271
|
}
|
|
272
|
+
/**
|
|
273
|
+
* pln#649 step 5 — status derives from the SAME call that routes a write.
|
|
274
|
+
*
|
|
275
|
+
* This used to walk its own session-then-global ladder, so it could only ever name two of
|
|
276
|
+
* the resolver's seven rungs. An agent inside a child project read back the workspace's
|
|
277
|
+
* GLOBAL pointer — a different project from the one its writes reached. Reporting a source
|
|
278
|
+
* the writer does not use is the exact shape of the defect this plan exists to close, and
|
|
279
|
+
* it is why that defect stayed invisible: the status surface kept saying the reassuring
|
|
280
|
+
* thing.
|
|
281
|
+
*
|
|
282
|
+
* `active_source` is now the resolver's own vocabulary, matching what the MCP surfaces
|
|
283
|
+
* already echo. `cwd` — the resolver's "nothing pointed anywhere, use the directory" rung —
|
|
284
|
+
* keeps the previous no-active-project output, so the operator-facing text is unchanged for
|
|
285
|
+
* the case it described.
|
|
286
|
+
*/
|
|
265
287
|
function showCurrent(wsRoot, cwd, json) {
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
const
|
|
271
|
-
|
|
288
|
+
const effective = resolveEffectiveCwdInfo({ baseCwd: cwd });
|
|
289
|
+
const source = effective.active_source;
|
|
290
|
+
// The pointer records carry switched_at / switched_by; the resolver carries authority.
|
|
291
|
+
// Read the metadata from whichever pointer actually won, and never from the other one.
|
|
292
|
+
const pointer = source === 'session'
|
|
293
|
+
? loadCurrentSession(cwd)?.active_project
|
|
294
|
+
: source === 'global' ? loadActiveProject(wsRoot) : undefined;
|
|
295
|
+
const active = pointer
|
|
296
|
+
?? (source === 'cwd' || !effective.resolved_project
|
|
297
|
+
? undefined
|
|
298
|
+
: { path: effective.resolved_project.path, name: effective.resolved_project.name, switched_at: undefined });
|
|
272
299
|
if (!active) {
|
|
273
300
|
if (json) {
|
|
274
301
|
console.log(JSON.stringify({ active: false, scope: 'none' }));
|
|
@@ -314,7 +341,9 @@ function listProjects(wsRoot, cwd, json) {
|
|
|
314
341
|
const name = p.name ? `${p.name} (${p.relative_path})` : p.relative_path;
|
|
315
342
|
console.log(`${marker}${name}`);
|
|
316
343
|
}
|
|
317
|
-
|
|
344
|
+
// `cwd` is the resolver's equivalent of the old locally-computed 'none': no pointer won,
|
|
345
|
+
// so commands operate on the current directory (pln#649 step 5).
|
|
346
|
+
if (result.active_source === 'cwd') {
|
|
318
347
|
console.log('\nNo active project. Use `brainclaw switch <project>` to set one.');
|
|
319
348
|
}
|
|
320
349
|
}
|