brainclaw 1.10.1 → 1.11.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/README.md +13 -0
- package/dist/brainclaw-vscode.vsix +0 -0
- package/dist/cli.js +11 -0
- package/dist/commands/code-map.js +17 -2
- package/dist/commands/mcp.js +48 -6
- package/dist/commands/move.js +35 -0
- package/dist/core/code-map/backend.js +76 -15
- package/dist/core/code-map/cascade.js +116 -0
- package/dist/core/code-map/refresh.js +0 -0
- package/dist/core/instruction-templates.js +1 -1
- package/dist/core/loops/store.js +31 -1
- package/dist/core/operations/relocate.js +130 -0
- package/dist/core/store-resolution.js +52 -8
- package/dist/core/worktree.js +132 -6
- package/dist/facts.js +7 -6
- package/dist/facts.json +6 -5
- package/docs/cli.md +22 -4
- package/docs/code-map.md +28 -4
- package/docs/concepts/dispatch-lifecycle.md +26 -0
- package/docs/integrations/mcp.md +2 -0
- package/docs/mcp-schema-changelog.md +27 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -417,6 +417,19 @@ npm run test:coverage # with coverage report
|
|
|
417
417
|
|
|
418
418
|
For older releases (v0.x and the early v1.0 launch series), `git log` on `master` is the source of truth — every release commit follows the `chore(release): bump version to <semver>` convention, and the matching feature/fix commits reference their plan id (e.g. `feat(mcp): self-heal ... (pln#478)`).
|
|
419
419
|
|
|
420
|
+
### v1.11.0
|
|
421
|
+
|
|
422
|
+
Monorepo project-resolution + Code Map, cross-project relocation, and dispatch-worktree hygiene — from a multi-project (monorepo) dogfood:
|
|
423
|
+
|
|
424
|
+
- **`bclaw_switch` to the monorepo root now works, and a switch is honored everywhere.** An agent inside a child project could not switch *up* to the workspace-root project, and a session-scoped switch was silently lost (sessions are stored per-cwd, but the resolver read them only at the workspace anchor). The resolver now matches the root project by `project_name`/`project_id` and probes the anchor / physical cwd / workspace root for the session, so `bclaw_switch` is authoritative for every subsequent call — including Code Map — without `--cwd`.
|
|
425
|
+
- **`code-map refresh --cascade`** — monorepo-native, per-project indexing: one command at the root refreshes **every** nested project into its own store plus a child-scoped root store, with **zero double-indexing** (even under nesting). `code-map status --cascade` adds a per-child recap. Opt-in; single-project repos are unchanged.
|
|
426
|
+
- **`brainclaw move <entity> <id> --to <project>` / `bclaw_move`** — id-preserving cross-project relocation, closing the gap the switch bug exposed (items created in the wrong store). Execution-local entities (claim/assignment/agent_run/session) stay put; refuses id collisions and active-claim moves; audits both stores.
|
|
427
|
+
- **Sub-agent worktrees auto-clean on loop close** — a completed review/dispatch loop garbage-collects its worktrees (junction-safe; keeps any that are alive, un-harvested, or carry unmerged commits). Opt out with `BRAINCLAW_NO_WORKTREE_GC=1`.
|
|
428
|
+
|
|
429
|
+
### v1.10.2
|
|
430
|
+
|
|
431
|
+
Dispatch worktree-creation hardening (parallel-lane dispatch on a large repo): the claim-scope branch slug is length-capped *before* its trailing-dot strip (a scope ending in `…Page.astro` no longer truncates to an invalid `feat/…Page.` ref), and `git worktree add` gets a dedicated timeout (120s default; override `BRAINCLAW_WORKTREE_ADD_TIMEOUT_MS`) so a several-hundred-file checkout isn't SIGTERM-killed mid-materialize.
|
|
432
|
+
|
|
420
433
|
### v1.10.1
|
|
421
434
|
|
|
422
435
|
Code Map fast-follows from the 1.10.0 dogfood, plus a lint cleanup:
|
|
Binary file
|
package/dist/cli.js
CHANGED
|
@@ -20,6 +20,7 @@ import { runUpdatePlan } from './commands/update-plan.js';
|
|
|
20
20
|
import { runDeletePlan } from './commands/delete-plan.js';
|
|
21
21
|
import { runPlanResource } from './commands/plan-resource.js';
|
|
22
22
|
import { runCodeMap } from './commands/code-map.js';
|
|
23
|
+
import { runMove } from './commands/move.js';
|
|
23
24
|
import { runSequenceResource } from './commands/sequence.js';
|
|
24
25
|
import { runAddStep } from './commands/add-step.js';
|
|
25
26
|
import { runDeleteStep } from './commands/delete-step.js';
|
|
@@ -606,6 +607,7 @@ program
|
|
|
606
607
|
.option('--json', 'Output as JSON')
|
|
607
608
|
.option('--all', 'For refresh: enumerate all supported files (full refresh)')
|
|
608
609
|
.option('--changed', 'For refresh: only changed files (default)')
|
|
610
|
+
.option('--cascade', 'For refresh/status in a multi-project workspace: cascade across every nested project (each gets its own store; the root store is scoped to files no child owns)')
|
|
609
611
|
.option('--limit <n>', 'Max results for find/brief', (v) => parseInt(v, 10))
|
|
610
612
|
.action((subcommand, args, options) => {
|
|
611
613
|
void runCodeMap(subcommand, args, options).catch((err) => {
|
|
@@ -613,6 +615,15 @@ program
|
|
|
613
615
|
process.exit(1);
|
|
614
616
|
});
|
|
615
617
|
});
|
|
618
|
+
// --- move (cross-project relocation, pln#595) ---
|
|
619
|
+
program
|
|
620
|
+
.command('move <entity> <id>')
|
|
621
|
+
.description('Relocate a brainclaw item to another project, id-preserving (multi-project workspaces). Relocatable: plan, decision, constraint, trap, handoff, sequence.')
|
|
622
|
+
.requiredOption('--to <project>', 'Target project (name, path, or basename)')
|
|
623
|
+
.option('--from <project>', 'Source project (defaults to the current project)')
|
|
624
|
+
.option('--force', 'Move even if an active claim references the item')
|
|
625
|
+
.option('--json', 'Output as JSON')
|
|
626
|
+
.action((entity, id, options) => runMove(entity, id, options));
|
|
616
627
|
// --- list-plans ---
|
|
617
628
|
program
|
|
618
629
|
.command('list-plans')
|
|
@@ -22,13 +22,13 @@ export async function runCodeMap(subcommand, args, options = {}) {
|
|
|
22
22
|
const be = backend();
|
|
23
23
|
const cwd = options.cwd;
|
|
24
24
|
if (normalized === 'status') {
|
|
25
|
-
const status = await be.status({ cwd });
|
|
25
|
+
const status = await be.status({ cwd, cascade: options.cascade });
|
|
26
26
|
printStatus(status, options);
|
|
27
27
|
return;
|
|
28
28
|
}
|
|
29
29
|
if (normalized === 'refresh') {
|
|
30
30
|
const scope = options.all ? 'all' : 'changed';
|
|
31
|
-
const result = await be.refresh({ scope, cwd });
|
|
31
|
+
const result = await be.refresh({ scope, cwd, cascade: options.cascade });
|
|
32
32
|
printRefresh(result, options);
|
|
33
33
|
return;
|
|
34
34
|
}
|
|
@@ -74,6 +74,13 @@ function printStatus(status, options) {
|
|
|
74
74
|
else {
|
|
75
75
|
console.log(' Stats: (none — index not built)');
|
|
76
76
|
}
|
|
77
|
+
if (status.cascade) {
|
|
78
|
+
console.log(` Workspace: ${status.cascade.indexed_children}/${status.cascade.total_children} child project(s) indexed`);
|
|
79
|
+
for (const child of status.cascade.children) {
|
|
80
|
+
const files = child.files_indexed === null ? '' : ` (${child.files_indexed} files)`;
|
|
81
|
+
console.log(` [${child.freshness}] ${child.path}${files}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
77
84
|
}
|
|
78
85
|
function printRefresh(result, options) {
|
|
79
86
|
if (options.json) {
|
|
@@ -86,6 +93,14 @@ function printRefresh(result, options) {
|
|
|
86
93
|
if (result.lock_status)
|
|
87
94
|
console.log(` Status: ${result.lock_status}`);
|
|
88
95
|
console.log(` ${badgeLine(result.freshness_badge)}`);
|
|
96
|
+
if (result.cascade) {
|
|
97
|
+
const c = result.cascade;
|
|
98
|
+
console.log(` Cascade: ${c.children_refreshed} child project(s) + root (scoped)`);
|
|
99
|
+
console.log(` [root] . — ${c.root_result.files_parsed} files parsed`);
|
|
100
|
+
for (const child of c.children) {
|
|
101
|
+
console.log(` ${child.path} — ${child.files_parsed} files parsed (${child.freshness})`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
89
104
|
}
|
|
90
105
|
function printFind(result, options) {
|
|
91
106
|
if (options.json) {
|
package/dist/commands/mcp.js
CHANGED
|
@@ -17,6 +17,7 @@ import { generateIdWithLabel } from '../core/ids.js';
|
|
|
17
17
|
import { memoryExists } 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
|
+
import { relocateEntity } from '../core/operations/relocate.js';
|
|
20
21
|
import { handoffDiffPreviewNote } from '../core/handoff-snapshot.js';
|
|
21
22
|
import { ENTITY_REGISTRY } from '../core/entity-registry.js';
|
|
22
23
|
import { generateClaimId, listClaims, loadClaim, saveClaim, createCoordinatorClaim, adoptClaimSession, attachAssignmentMessageToClaim, linkClaimToAssignment, releaseClaimWithCascade } from '../core/claims.js';
|
|
@@ -452,11 +453,13 @@ export const MCP_READ_TOOLS = [
|
|
|
452
453
|
},
|
|
453
454
|
{
|
|
454
455
|
name: 'bclaw_code_status',
|
|
455
|
-
description: 'Code Map status for this project: store presence, freshness badge (fresh / stale_changed_files / stale_extractor / stale_grammar / stale_git_head / partial / missing_index), and index stats (files, nodes, edges). Read-only; never refreshes. Pair with bclaw_code_refresh when freshness is missing_index or stale.',
|
|
456
|
+
description: 'Code Map status for this project: store presence, freshness badge (fresh / stale_changed_files / stale_extractor / stale_grammar / stale_git_head / partial / missing_index), and index stats (files, nodes, edges). Read-only; never refreshes. Pair with bclaw_code_refresh when freshness is missing_index or stale. In a multi-project workspace, cascade=true adds a per-child recap (which nested projects have a built index vs missing_index).',
|
|
456
457
|
annotations: { tier: 'standard', category: 'discovery', headlessApproval: 'auto' },
|
|
457
458
|
inputSchema: {
|
|
458
459
|
type: 'object',
|
|
459
|
-
properties: {
|
|
460
|
+
properties: {
|
|
461
|
+
cascade: { type: 'boolean', description: 'Multi-project workspace recap: also report per-child store presence + freshness for every nested project. No-op outside a multi-project workspace.' },
|
|
462
|
+
},
|
|
460
463
|
},
|
|
461
464
|
},
|
|
462
465
|
{
|
|
@@ -489,12 +492,13 @@ export const MCP_READ_TOOLS = [
|
|
|
489
492
|
const MCP_WRITE_TOOLS = [
|
|
490
493
|
{
|
|
491
494
|
name: 'bclaw_code_refresh',
|
|
492
|
-
description: 'Rebuild the Code Map index for this project (Tree-sitter parse + shards + indexes, behind the per-project lock). scope="changed" (default) reparses changed files; scope="all" does a full refresh + compaction. A live competing lock fails fast with a clear status — refresh never blocks. Returns the resulting freshness_badge.',
|
|
495
|
+
description: 'Rebuild the Code Map index for this project (Tree-sitter parse + shards + indexes, behind the per-project lock). scope="changed" (default) reparses changed files; scope="all" does a full refresh + compaction. A live competing lock fails fast with a clear status — refresh never blocks. Returns the resulting freshness_badge. In a multi-project workspace, cascade=true refreshes EVERY nested project into its own store + the root store scoped to files no child owns (zero double-indexing) — so one call at the root indexes the whole monorepo per-project.',
|
|
493
496
|
annotations: { tier: 'standard', category: 'discovery', headlessApproval: 'prompt' },
|
|
494
497
|
inputSchema: {
|
|
495
498
|
type: 'object',
|
|
496
499
|
properties: {
|
|
497
500
|
scope: { type: 'string', enum: ['changed', 'all'], description: 'changed (default) reparses changed files only; all does a full refresh with orphan compaction.' },
|
|
501
|
+
cascade: { type: 'boolean', description: 'Multi-project cascade: refresh every nested brainclaw project + a child-scoped root store. No-op outside a multi-project workspace.' },
|
|
498
502
|
},
|
|
499
503
|
},
|
|
500
504
|
},
|
|
@@ -1265,6 +1269,22 @@ const MCP_WRITE_TOOLS = [
|
|
|
1265
1269
|
required: ['entity', 'id', 'to'],
|
|
1266
1270
|
},
|
|
1267
1271
|
},
|
|
1272
|
+
{
|
|
1273
|
+
name: 'bclaw_move',
|
|
1274
|
+
description: 'Relocate a brainclaw item to another project in a multi-project workspace, PRESERVING its id (so pln#/dec# references stay stable). Relocatable entities: plan, decision, constraint, trap, handoff, sequence. Execution-local entities (claim, assignment, agent_run, session) are NOT relocatable — they stay in the project where the work ran. Refuses on id collision in the target, a missing source, or an active claim on the item (unless force). Audits both stores.',
|
|
1275
|
+
annotations: { tier: 'standard', category: 'memory', headlessApproval: 'prompt' },
|
|
1276
|
+
inputSchema: {
|
|
1277
|
+
type: 'object',
|
|
1278
|
+
properties: {
|
|
1279
|
+
entity: { type: 'string', description: 'Entity name (plan, decision, constraint, trap, handoff, sequence).' },
|
|
1280
|
+
id: { type: 'string', description: 'Entity id to move.' },
|
|
1281
|
+
to_project: { type: 'string', description: 'Target project: name, path, or basename.' },
|
|
1282
|
+
from_project: { type: 'string', description: 'Source project (defaults to the current project).' },
|
|
1283
|
+
force: { type: 'boolean', description: 'Move even if an active claim references the item. Default false.' },
|
|
1284
|
+
},
|
|
1285
|
+
required: ['entity', 'id', 'to_project'],
|
|
1286
|
+
},
|
|
1287
|
+
},
|
|
1268
1288
|
];
|
|
1269
1289
|
/**
|
|
1270
1290
|
* Combined catalog of every brainclaw MCP tool descriptor (read + write).
|
|
@@ -2653,7 +2673,7 @@ async function _executeMcpToolCallInner(payload) {
|
|
|
2653
2673
|
const { JsonlBackend } = await import('../core/code-map/backend.js');
|
|
2654
2674
|
const be = new JsonlBackend();
|
|
2655
2675
|
if (name === 'bclaw_code_status') {
|
|
2656
|
-
const status = await be.status({ cwd });
|
|
2676
|
+
const status = await be.status({ cwd, cascade: args.cascade === true });
|
|
2657
2677
|
return {
|
|
2658
2678
|
response: toolResponse({
|
|
2659
2679
|
content: [{ type: 'text', text: `Code Map: ${status.store_exists ? 'store present' : 'no store'} — freshness=${status.freshness_badge.status}` }],
|
|
@@ -2663,10 +2683,11 @@ async function _executeMcpToolCallInner(payload) {
|
|
|
2663
2683
|
}
|
|
2664
2684
|
if (name === 'bclaw_code_refresh') {
|
|
2665
2685
|
const scope = args.scope === 'all' ? 'all' : 'changed';
|
|
2666
|
-
const result = await be.refresh({ scope, cwd });
|
|
2686
|
+
const result = await be.refresh({ scope, cwd, cascade: args.cascade === true });
|
|
2687
|
+
const cascadeNote = result.cascade ? ` cascade=${result.cascade.children_refreshed} child(ren)+root` : '';
|
|
2667
2688
|
return {
|
|
2668
2689
|
response: toolResponse({
|
|
2669
|
-
content: [{ type: 'text', text: `Code Map refresh [${result.scope}]: ran=${result.ran} freshness=${result.freshness_badge.status}${result.lock_status ? ` (${result.lock_status})` : ''}` }],
|
|
2690
|
+
content: [{ type: 'text', text: `Code Map refresh [${result.scope}]: ran=${result.ran} freshness=${result.freshness_badge.status}${cascadeNote}${result.lock_status ? ` (${result.lock_status})` : ''}` }],
|
|
2670
2691
|
structuredContent: { ...result, freshness_badge: result.freshness_badge },
|
|
2671
2692
|
}),
|
|
2672
2693
|
};
|
|
@@ -6619,6 +6640,27 @@ async function _executeMcpToolCallInner(payload) {
|
|
|
6619
6640
|
return { response: createToolErrorResponse('validation_error', error.message) };
|
|
6620
6641
|
}
|
|
6621
6642
|
}
|
|
6643
|
+
if (name === 'bclaw_move') {
|
|
6644
|
+
try {
|
|
6645
|
+
const entity = String(args.entity ?? '');
|
|
6646
|
+
const id = String(args.id ?? '');
|
|
6647
|
+
const toProject = String(args.to_project ?? '');
|
|
6648
|
+
const fromProject = typeof args.from_project === 'string' ? args.from_project : undefined;
|
|
6649
|
+
const force = args.force === true;
|
|
6650
|
+
const { agent_name, agent_id } = resolveCanonicalAuthor(args, cwd, connectionSessionId);
|
|
6651
|
+
const result = relocateEntity({ entity, id, toProject, fromProject, force, cwd, actor: agent_name, actorId: agent_id });
|
|
6652
|
+
const warn = result.warnings.length ? ` (${result.warnings.length} warning(s))` : '';
|
|
6653
|
+
return {
|
|
6654
|
+
response: toolResponse({
|
|
6655
|
+
content: [{ type: 'text', text: `✔ moved ${entity} ${id} → ${result.to}${warn}` }],
|
|
6656
|
+
structuredContent: { ...result },
|
|
6657
|
+
}),
|
|
6658
|
+
};
|
|
6659
|
+
}
|
|
6660
|
+
catch (error) {
|
|
6661
|
+
return { response: createToolErrorResponse('validation_error', error.message) };
|
|
6662
|
+
}
|
|
6663
|
+
}
|
|
6622
6664
|
if (name === 'bclaw_transition') {
|
|
6623
6665
|
try {
|
|
6624
6666
|
const entity = String(args.entity ?? '');
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `brainclaw move <entity> <id> --to <project>` — id-preserving cross-project
|
|
3
|
+
* relocation (pln#595). Thin CLI adapter over core relocateEntity().
|
|
4
|
+
*/
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import { relocateEntity } from '../core/operations/relocate.js';
|
|
7
|
+
export function runMove(entity, id, options) {
|
|
8
|
+
if (!options.to) {
|
|
9
|
+
console.error('Error: --to <project> is required.');
|
|
10
|
+
process.exit(1);
|
|
11
|
+
}
|
|
12
|
+
try {
|
|
13
|
+
const result = relocateEntity({
|
|
14
|
+
entity: entity,
|
|
15
|
+
id,
|
|
16
|
+
toProject: options.to,
|
|
17
|
+
fromProject: options.from,
|
|
18
|
+
force: options.force,
|
|
19
|
+
cwd: options.cwd ?? process.cwd(),
|
|
20
|
+
actor: process.env.BRAINCLAW_AGENT_NAME || os.userInfo().username || 'unknown',
|
|
21
|
+
});
|
|
22
|
+
if (options.json) {
|
|
23
|
+
console.log(JSON.stringify(result, null, 2));
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
console.log(`✔ Moved ${result.entity} ${result.id} (${result.subdir}) → ${result.to}`);
|
|
27
|
+
for (const w of result.warnings)
|
|
28
|
+
console.log(` ⚠ ${w}`);
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
console.error(`Error: ${err.message}`);
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
//# sourceMappingURL=move.js.map
|
|
@@ -15,6 +15,8 @@ import { refresh as runRefresh } from './refresh.js';
|
|
|
15
15
|
import { applyGitHeadDrift } from './freshness.js';
|
|
16
16
|
import { brief as runBrief, find as runFind } from './query.js';
|
|
17
17
|
import { defaultMemoryReader } from './memory-reader.js';
|
|
18
|
+
import { listNestedProjects, refreshWorkspaceCascade } from './cascade.js';
|
|
19
|
+
import { loadConfig } from '../config.js';
|
|
18
20
|
/** spec §9 caps the brief reading list at 12 files. */
|
|
19
21
|
export const BRIEF_FILE_CAP = 12;
|
|
20
22
|
function badge(status, details = {}) {
|
|
@@ -45,6 +47,30 @@ function readCurrentGitHead(root) {
|
|
|
45
47
|
return null;
|
|
46
48
|
}
|
|
47
49
|
}
|
|
50
|
+
/** True when `cwd` is a multi-project workspace (gates the cascade paths). */
|
|
51
|
+
function isMultiProjectWorkspace(cwd) {
|
|
52
|
+
try {
|
|
53
|
+
return loadConfig(cwd).project_mode === 'multi-project';
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/** Per-child store recap for `status(cascade)` in a multi-project workspace. */
|
|
60
|
+
function buildCascadeStatus(rootCwd) {
|
|
61
|
+
const root = rootCwd ?? process.cwd();
|
|
62
|
+
const children = listNestedProjects(root).map((abs) => {
|
|
63
|
+
const m = readManifest(abs);
|
|
64
|
+
return {
|
|
65
|
+
path: path.relative(root, abs).replace(/\\/g, '/') || '.',
|
|
66
|
+
store_exists: m ? true : storeExists(abs),
|
|
67
|
+
freshness: m ? m.freshness.status : 'missing_index',
|
|
68
|
+
files_indexed: m ? m.stats.files_indexed : null,
|
|
69
|
+
};
|
|
70
|
+
});
|
|
71
|
+
const indexed = children.filter((c) => c.freshness !== 'missing_index').length;
|
|
72
|
+
return { children, indexed_children: indexed, total_children: children.length };
|
|
73
|
+
}
|
|
48
74
|
/**
|
|
49
75
|
* P0 JSONL-backed query backend. Reads the durable file store (manifest +
|
|
50
76
|
* shards + indexes); no graph DB. find()/brief() are stubbed for Sprint 1.
|
|
@@ -63,26 +89,31 @@ export class JsonlBackend {
|
|
|
63
89
|
}
|
|
64
90
|
async status(input) {
|
|
65
91
|
const manifest = readManifest(input.cwd, input.preferredDirName);
|
|
66
|
-
|
|
67
|
-
|
|
92
|
+
const result = manifest
|
|
93
|
+
? {
|
|
94
|
+
store_exists: true,
|
|
95
|
+
freshness_badge: this.withHeadDrift(badge(manifest.freshness.status, {
|
|
96
|
+
stale_file_count: manifest.freshness.stale_file_count,
|
|
97
|
+
partial_reason: manifest.freshness.partial_reason,
|
|
98
|
+
}), manifest, input.cwd),
|
|
99
|
+
stats: {
|
|
100
|
+
files_indexed: manifest.stats.files_indexed,
|
|
101
|
+
nodes: manifest.stats.nodes,
|
|
102
|
+
edges: manifest.stats.edges,
|
|
103
|
+
},
|
|
104
|
+
}
|
|
105
|
+
: {
|
|
68
106
|
store_exists: storeExists(input.cwd, input.preferredDirName),
|
|
69
107
|
freshness_badge: badge('missing_index'),
|
|
70
108
|
stats: null,
|
|
71
109
|
};
|
|
110
|
+
// Multi-project recap (opt-in): per-child store presence + freshness, so a
|
|
111
|
+
// status at the monorepo root surfaces the 27-children-missing-index state
|
|
112
|
+
// instead of just the (now child-scoped) root store. DGX Finding 2.
|
|
113
|
+
if (input.cascade && isMultiProjectWorkspace(input.cwd)) {
|
|
114
|
+
result.cascade = buildCascadeStatus(input.cwd);
|
|
72
115
|
}
|
|
73
|
-
|
|
74
|
-
stale_file_count: manifest.freshness.stale_file_count,
|
|
75
|
-
partial_reason: manifest.freshness.partial_reason,
|
|
76
|
-
});
|
|
77
|
-
return {
|
|
78
|
-
store_exists: true,
|
|
79
|
-
freshness_badge: this.withHeadDrift(base, manifest, input.cwd),
|
|
80
|
-
stats: {
|
|
81
|
-
files_indexed: manifest.stats.files_indexed,
|
|
82
|
-
nodes: manifest.stats.nodes,
|
|
83
|
-
edges: manifest.stats.edges,
|
|
84
|
-
},
|
|
85
|
-
};
|
|
116
|
+
return result;
|
|
86
117
|
}
|
|
87
118
|
/**
|
|
88
119
|
* Real refresh (spec §7): resolves project identity (input -> manifest ->
|
|
@@ -92,6 +123,36 @@ export class JsonlBackend {
|
|
|
92
123
|
*/
|
|
93
124
|
async refresh(input) {
|
|
94
125
|
const scope = input.scope ?? 'changed';
|
|
126
|
+
// Multi-project cascade (opt-in): refresh every nested brainclaw project +
|
|
127
|
+
// a child-scoped root store. No-op outside a multi-project workspace — fall
|
|
128
|
+
// through to the normal single-project refresh below. DGX Finding 2.
|
|
129
|
+
if (input.cascade && isMultiProjectWorkspace(input.cwd)) {
|
|
130
|
+
const cascade = await refreshWorkspaceCascade({
|
|
131
|
+
rootCwd: input.cwd ?? process.cwd(),
|
|
132
|
+
scope,
|
|
133
|
+
ownerAgent: input.ownerAgent ?? null,
|
|
134
|
+
ownerAgentId: input.ownerAgentId ?? null,
|
|
135
|
+
});
|
|
136
|
+
const root = cascade.root_result;
|
|
137
|
+
const allProjects = [root, ...cascade.children];
|
|
138
|
+
// A cascade is only fully "acquired" when EVERY project got its lock; if a
|
|
139
|
+
// child or the root was skipped under a live writer, surface that instead
|
|
140
|
+
// of reporting a clean lock_acquired=true over a partial cascade (codex review).
|
|
141
|
+
const skipped = allProjects.filter((p) => !p.lock_acquired);
|
|
142
|
+
return {
|
|
143
|
+
ran: allProjects.some((p) => p.ran),
|
|
144
|
+
scope,
|
|
145
|
+
lock_acquired: skipped.length === 0,
|
|
146
|
+
freshness_badge: badge(root.freshness, {
|
|
147
|
+
files_parsed: root.files_parsed,
|
|
148
|
+
children_refreshed: cascade.children_refreshed,
|
|
149
|
+
}),
|
|
150
|
+
...(skipped.length > 0
|
|
151
|
+
? { lock_status: `${skipped.length} project(s) skipped (lock held): ${skipped.map((p) => p.path).join(', ')}` }
|
|
152
|
+
: {}),
|
|
153
|
+
cascade,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
95
156
|
const manifest = readManifest(input.cwd, input.preferredDirName);
|
|
96
157
|
const projectRoot = input.projectRoot ?? manifest?.project_root ?? input.cwd ?? process.cwd();
|
|
97
158
|
const projectId = input.projectId ?? manifest?.project_id ?? `prj_${path.basename(path.resolve(projectRoot))}`;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Monorepo Code Map cascade (DGX Finding 2, 2026-06-22).
|
|
3
|
+
*
|
|
4
|
+
* Plain `refresh` is topology-blind: run at a multi-project workspace root it
|
|
5
|
+
* builds ONE monolithic index that descends every child subtree, while the 27
|
|
6
|
+
* sibling projects keep `missing_index`. The cascade (opt-in: `--cascade` /
|
|
7
|
+
* `bclaw_code_refresh(cascade=true)`) instead refreshes EACH nested brainclaw
|
|
8
|
+
* project into its own `<child>/.brainclaw/code/` store, and refreshes the root
|
|
9
|
+
* store SCOPED to the files no child owns.
|
|
10
|
+
*
|
|
11
|
+
* Zero double-indexing — and correct under nesting — by a single rule: when
|
|
12
|
+
* refreshing project P, exclude the subtree of every OTHER discovered project
|
|
13
|
+
* that sits strictly under P. So a file is indexed by exactly one project: the
|
|
14
|
+
* most specific brainclaw project that contains it.
|
|
15
|
+
*
|
|
16
|
+
* Topology source = `scanNestedBrainclawProjects` (a pure filesystem scan for
|
|
17
|
+
* nested `.brainclaw/config.yaml`), so it works regardless of
|
|
18
|
+
* `projects.strategy` (folder/manual) and matches what `bclaw_switch --list`
|
|
19
|
+
* shows. Children without a `.brainclaw/` are NOT created here — the cascade
|
|
20
|
+
* refreshes existing brainclaw projects, it does not initialise new ones.
|
|
21
|
+
*/
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import { scanNestedBrainclawProjects } from '../workspace-projects.js';
|
|
24
|
+
import { loadConfig } from '../config.js';
|
|
25
|
+
import { refresh as runRefresh } from './refresh.js';
|
|
26
|
+
import { readManifest } from './store.js';
|
|
27
|
+
function toPosix(p) {
|
|
28
|
+
return p.replace(/\\/g, '/');
|
|
29
|
+
}
|
|
30
|
+
/** True when `dir` is strictly below `ancestor` (not equal, not above, same drive). */
|
|
31
|
+
function isStrictlyUnder(dir, ancestor) {
|
|
32
|
+
const rel = path.relative(path.resolve(ancestor), path.resolve(dir));
|
|
33
|
+
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
34
|
+
}
|
|
35
|
+
function projectIdFor(cwd, fallbackId) {
|
|
36
|
+
const manifest = readManifest(cwd);
|
|
37
|
+
if (manifest?.project_id)
|
|
38
|
+
return manifest.project_id;
|
|
39
|
+
if (fallbackId)
|
|
40
|
+
return fallbackId;
|
|
41
|
+
try {
|
|
42
|
+
const id = loadConfig(cwd).project_id;
|
|
43
|
+
if (id)
|
|
44
|
+
return id;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
/* no config — fall through to a cwd-derived default */
|
|
48
|
+
}
|
|
49
|
+
return `prj_${path.basename(path.resolve(cwd))}`;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Nested brainclaw projects strictly under `rootCwd`, as absolute paths, deduped
|
|
53
|
+
* and sorted. Pure filesystem scan (strategy-agnostic). Shared by the refresh
|
|
54
|
+
* cascade and the `status --cascade` recap so both agree on the project set.
|
|
55
|
+
*/
|
|
56
|
+
export function listNestedProjects(rootCwd) {
|
|
57
|
+
const root = path.resolve(rootCwd);
|
|
58
|
+
return Array.from(new Set(scanNestedBrainclawProjects(root)
|
|
59
|
+
.map((c) => path.resolve(c.path))
|
|
60
|
+
.filter((abs) => abs !== root && isStrictlyUnder(abs, root)))).sort();
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Refresh the whole multi-project workspace: every nested brainclaw project +
|
|
64
|
+
* a child-scoped root store. Callers should only invoke this when the root is a
|
|
65
|
+
* multi-project workspace (the backend gates on `project_mode`).
|
|
66
|
+
*/
|
|
67
|
+
export async function refreshWorkspaceCascade(input) {
|
|
68
|
+
const rootCwd = path.resolve(input.rootCwd);
|
|
69
|
+
// Enumerate nested brainclaw projects strictly under the root (FS scan, so it
|
|
70
|
+
// is strategy-agnostic). De-dup + sort by path for deterministic output.
|
|
71
|
+
const childAbsPaths = listNestedProjects(rootCwd);
|
|
72
|
+
// Every project to refresh, root first.
|
|
73
|
+
const allProjects = [rootCwd, ...childAbsPaths];
|
|
74
|
+
const refreshOne = async (projectCwd, isRoot) => {
|
|
75
|
+
// Exclude the subtree of every OTHER project that sits strictly under this
|
|
76
|
+
// one → each file is owned by exactly the most specific project.
|
|
77
|
+
const nestedUnder = allProjects.filter((p) => p !== projectCwd && isStrictlyUnder(p, projectCwd));
|
|
78
|
+
const extraIgnorePatterns = nestedUnder.map((p) => `${toPosix(path.relative(projectCwd, p))}/**`);
|
|
79
|
+
const projectId = projectIdFor(projectCwd);
|
|
80
|
+
const result = await runRefresh({
|
|
81
|
+
projectId,
|
|
82
|
+
projectRoot: projectCwd,
|
|
83
|
+
scope: input.scope,
|
|
84
|
+
cwd: projectCwd,
|
|
85
|
+
extraIgnorePatterns,
|
|
86
|
+
ownerAgent: input.ownerAgent ?? null,
|
|
87
|
+
ownerAgentId: input.ownerAgentId ?? null,
|
|
88
|
+
});
|
|
89
|
+
return {
|
|
90
|
+
path: isRoot ? '.' : toPosix(path.relative(rootCwd, projectCwd)),
|
|
91
|
+
project_id: projectId,
|
|
92
|
+
is_root: isRoot,
|
|
93
|
+
ran: result.ran,
|
|
94
|
+
lock_acquired: result.lock_acquired,
|
|
95
|
+
files_parsed: result.files_parsed,
|
|
96
|
+
files_compacted: result.files_compacted,
|
|
97
|
+
freshness: result.freshness.status,
|
|
98
|
+
...(result.lock_status ? { lock_status: result.lock_status } : {}),
|
|
99
|
+
};
|
|
100
|
+
};
|
|
101
|
+
// Children first, then the root (sequential — each holds its own project lock
|
|
102
|
+
// briefly; never blocks bclaw_work, rule 8).
|
|
103
|
+
const children = [];
|
|
104
|
+
for (const childCwd of childAbsPaths) {
|
|
105
|
+
children.push(await refreshOne(childCwd, false));
|
|
106
|
+
}
|
|
107
|
+
const rootResult = await refreshOne(rootCwd, true);
|
|
108
|
+
return {
|
|
109
|
+
is_cascade: true,
|
|
110
|
+
root: rootCwd,
|
|
111
|
+
root_result: rootResult,
|
|
112
|
+
children,
|
|
113
|
+
children_refreshed: children.length,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
//# sourceMappingURL=cascade.js.map
|
|
Binary file
|
|
@@ -319,7 +319,7 @@ function renderAvailableTools() {
|
|
|
319
319
|
'## brainclaw — available tools',
|
|
320
320
|
'',
|
|
321
321
|
'**Entry:** `bclaw_work(intent, compact?, budget_tokens?)` · `bclaw_context(kind=memory|execution|board|board_summary|delta)`',
|
|
322
|
-
'**Canonical grammar** (entities: plan, decision, constraint, trap, handoff, runtime_note, candidate, sequence, claim, action, assignment, agent_run): `bclaw_find`, `bclaw_get`, `bclaw_create`, `bclaw_update`, `bclaw_remove`, `bclaw_transition`. Reads accept `budget_tokens` and `project` (cross-project routing — unknown names throw).',
|
|
322
|
+
'**Canonical grammar** (entities: plan, decision, constraint, trap, handoff, runtime_note, candidate, sequence, claim, action, assignment, agent_run): `bclaw_find`, `bclaw_get`, `bclaw_create`, `bclaw_update`, `bclaw_remove`, `bclaw_transition`. Reads accept `budget_tokens` and `project` (cross-project routing — unknown names throw). `bclaw_move(entity, id, to_project)` relocates an item to another project id-preserving in a multi-project workspace (plan/decision/constraint/trap/handoff/sequence; execution entities stay local).',
|
|
323
323
|
'**Session/claims:** `bclaw_session_start`, `bclaw_session_end`, `bclaw_claim`, `bclaw_release_claim` · **steps:** `bclaw_add_step`, `bclaw_complete_step`, `bclaw_update_step`, `bclaw_delete_step` · **sequences:** `bclaw_list_sequences`, `bclaw_create_sequence`, `bclaw_update_sequence`, `bclaw_delete_sequence`',
|
|
324
324
|
'**Inbox:** `bclaw_read_inbox`, `bclaw_ack_message`, `bclaw_send_message`, `bclaw_correct_handoff` · **capture:** `bclaw_write_note`, `bclaw_quick_capture(text, type?)` · **search:** `bclaw_search` · **setup:** `bclaw_setup`, `bclaw_bootstrap`, `bclaw_switch`, `bclaw_release_notes`',
|
|
325
325
|
'**Escalation (orchestrators):** `bclaw_coordinate(intent=review|consult|assign|ideate)` · `bclaw_dispatch(intent=execute)` on an active sequence · `bclaw_loop(intent=turn|complete_turn|advance|close)` to drive turns · `bclaw_dispatch_status(target_id)` to verify',
|
package/dist/core/loops/store.js
CHANGED
|
@@ -3,7 +3,9 @@ import fs from 'node:fs';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { memoryDir, writeFileAtomic } from '../io.js';
|
|
5
5
|
import { nowISO } from '../ids.js';
|
|
6
|
-
import {
|
|
6
|
+
import { logger } from '../logger.js';
|
|
7
|
+
import { convergeAssignmentToTerminal, loadAssignment } from '../assignments.js';
|
|
8
|
+
import { gcWorktreeIfHarvested } from '../worktree.js';
|
|
7
9
|
import { writeProjectMdSafe } from './hooks/bootstrap-write.js';
|
|
8
10
|
import { notifyOperatorOnInputRequested } from './hooks/notify-operator.js';
|
|
9
11
|
import { DEFAULT_PROTOCOLS, LoopArtifactSchema, LoopEventSchema, LoopThreadSchema, } from './types.js';
|
|
@@ -433,6 +435,34 @@ export function closeLoop(input, cwd) {
|
|
|
433
435
|
}
|
|
434
436
|
catch { /* never block loop close on assignment convergence */ }
|
|
435
437
|
}
|
|
438
|
+
// pln#594: GC the dispatched sub-agent worktrees now the loop is done, so
|
|
439
|
+
// review/dispatch worktrees stop accumulating under ~/.brainclaw/worktrees/.
|
|
440
|
+
// Only on a COMPLETED close — cancelled/blocked keep their worktree (+ run
|
|
441
|
+
// logs) for forensics. Best-effort (never blocks the close) and each removal
|
|
442
|
+
// is guarded inside gcWorktreeIfHarvested (skips alive / dirty / un-integrated
|
|
443
|
+
// worktrees, junction-safe). Opt out with BRAINCLAW_NO_WORKTREE_GC=1.
|
|
444
|
+
if (input.final_status === 'completed' && process.env.BRAINCLAW_NO_WORKTREE_GC !== '1') {
|
|
445
|
+
const mainCwd = cwd ?? process.cwd();
|
|
446
|
+
const seen = new Set();
|
|
447
|
+
for (const slot of next.slots) {
|
|
448
|
+
if (!slot.assignment_id)
|
|
449
|
+
continue;
|
|
450
|
+
try {
|
|
451
|
+
const worktreePath = loadAssignment(slot.assignment_id, cwd)?.worktree_path;
|
|
452
|
+
if (!worktreePath || seen.has(worktreePath))
|
|
453
|
+
continue;
|
|
454
|
+
seen.add(worktreePath);
|
|
455
|
+
const decision = gcWorktreeIfHarvested(mainCwd, worktreePath);
|
|
456
|
+
if (decision.removed) {
|
|
457
|
+
logger.info(`loop ${input.id} close: removed worktree ${decision.path} (${decision.reason})`);
|
|
458
|
+
}
|
|
459
|
+
else if (decision.reason !== 'already gone') {
|
|
460
|
+
logger.debug(`loop ${input.id} close: kept worktree ${decision.path} — ${decision.reason}`);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
catch { /* never block loop close on worktree GC */ }
|
|
464
|
+
}
|
|
465
|
+
}
|
|
436
466
|
return next;
|
|
437
467
|
}
|
|
438
468
|
//# sourceMappingURL=store.js.map
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-project relocation (pln#595) — `bclaw move` / `bclaw_move`.
|
|
3
|
+
*
|
|
4
|
+
* Moves a brainclaw item from one project store to another in a multi-project
|
|
5
|
+
* workspace, PRESERVING ITS ID (so `pln#`/`dec#` references stay stable). Born
|
|
6
|
+
* from the monorepo switch bug (DGX Finding 1): items were created in the wrong
|
|
7
|
+
* store and there was no relocation helper — only raw file surgery or a recreate
|
|
8
|
+
* that mints a new id and breaks references.
|
|
9
|
+
*
|
|
10
|
+
* Scope (v1): the portable knowledge / coordination entities stored one file per
|
|
11
|
+
* id under a single directory — plan, decision, constraint, trap, handoff,
|
|
12
|
+
* sequence. Execution-local entities (claim, assignment, agent_run, session) are
|
|
13
|
+
* intentionally NOT relocatable — they belong to the project where the work ran
|
|
14
|
+
* (cross_project_signaling_vs_execution). candidate/runtime_note are deferred
|
|
15
|
+
* (inbox / visibility-split storage) — a follow-up.
|
|
16
|
+
*/
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { resolveEntityDir, writeFileAtomic } from '../io.js';
|
|
20
|
+
import { getEntitySpec } from '../entity-registry.js';
|
|
21
|
+
import { appendAuditEntry } from '../audit.js';
|
|
22
|
+
import { resolveProjectCwd } from '../cross-project.js';
|
|
23
|
+
import { listClaims } from '../claims.js';
|
|
24
|
+
import { listSequences } from '../sequence.js';
|
|
25
|
+
/**
|
|
26
|
+
* Relocatable entity → the store subdir holding its flat `<id>.json` files.
|
|
27
|
+
*
|
|
28
|
+
* v1 covers only the SHARED, flat-per-id stores. Trap is deliberately limited to
|
|
29
|
+
* the shared `traps/` dir: the host/private visibility variants are host-SCOPED
|
|
30
|
+
* (`traps-hosts/<host>/<id>.json`, `traps-private/<host>/<id>.json` — see
|
|
31
|
+
* saveOperationalTrap), so a flat `<variant>/<id>.json` lookup would both miss
|
|
32
|
+
* them and, if it found one, flatten away the host directory (codex review of
|
|
33
|
+
* pln#595). Host/private traps are deferred alongside candidate/runtime_note
|
|
34
|
+
* until relocation preserves the host scope.
|
|
35
|
+
*/
|
|
36
|
+
const RELOCATABLE_SUBDIRS = {
|
|
37
|
+
plan: ['plans'],
|
|
38
|
+
decision: ['decisions'],
|
|
39
|
+
constraint: ['constraints'],
|
|
40
|
+
trap: ['traps'],
|
|
41
|
+
handoff: ['handoffs'],
|
|
42
|
+
sequence: ['sequences'],
|
|
43
|
+
};
|
|
44
|
+
export const RELOCATABLE_ENTITIES = Object.keys(RELOCATABLE_SUBDIRS);
|
|
45
|
+
/**
|
|
46
|
+
* Relocate one entity, id-preserving. Throws on every unsafe condition
|
|
47
|
+
* (non-relocatable entity, same source/target, missing source, target collision,
|
|
48
|
+
* unknown target project, or a live claim unless `force`). Audits BOTH stores.
|
|
49
|
+
*/
|
|
50
|
+
export function relocateEntity(input) {
|
|
51
|
+
const baseCwd = input.cwd ?? process.cwd();
|
|
52
|
+
const subdirs = RELOCATABLE_SUBDIRS[input.entity];
|
|
53
|
+
if (!subdirs) {
|
|
54
|
+
throw new Error(`Cannot move entity '${input.entity}': only portable knowledge/coordination entities are relocatable `
|
|
55
|
+
+ `(${RELOCATABLE_ENTITIES.join(', ')}). Execution-local entities (claim, assignment, agent_run, session, …) `
|
|
56
|
+
+ `stay in the project where the work ran.`);
|
|
57
|
+
}
|
|
58
|
+
if (!input.id?.trim())
|
|
59
|
+
throw new Error('move requires an entity id.');
|
|
60
|
+
const fromCwd = path.resolve(input.fromProject ? resolveProjectCwd(input.fromProject, baseCwd) : baseCwd);
|
|
61
|
+
const toCwd = path.resolve(resolveProjectCwd(input.toProject, baseCwd)); // throws on unknown target
|
|
62
|
+
if (fromCwd === toCwd) {
|
|
63
|
+
throw new Error(`Source and target are the same project (${toCwd}). Nothing to move.`);
|
|
64
|
+
}
|
|
65
|
+
// Locate the source file across the entity's candidate subdirs.
|
|
66
|
+
let srcFile;
|
|
67
|
+
let foundSubdir;
|
|
68
|
+
for (const sd of subdirs) {
|
|
69
|
+
const candidate = path.join(resolveEntityDir(sd, fromCwd, 'read'), `${input.id}.json`);
|
|
70
|
+
if (fs.existsSync(candidate)) {
|
|
71
|
+
srcFile = candidate;
|
|
72
|
+
foundSubdir = sd;
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (!srcFile || !foundSubdir) {
|
|
77
|
+
throw new Error(`${input.entity} '${input.id}' not found in source project (${fromCwd}).`);
|
|
78
|
+
}
|
|
79
|
+
// Validate before moving — never silently relocate a corrupt record.
|
|
80
|
+
let raw;
|
|
81
|
+
try {
|
|
82
|
+
raw = JSON.parse(fs.readFileSync(srcFile, 'utf-8'));
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
throw new Error(`${input.entity} '${input.id}' is unreadable JSON: ${err.message}`, { cause: err });
|
|
86
|
+
}
|
|
87
|
+
getEntitySpec(input.entity).schema.parse(raw);
|
|
88
|
+
// Collision guard — never overwrite an item already in the target.
|
|
89
|
+
const dstDir = resolveEntityDir(foundSubdir, toCwd, 'write');
|
|
90
|
+
const dstFile = path.join(dstDir, `${input.id}.json`);
|
|
91
|
+
if (fs.existsSync(dstFile)) {
|
|
92
|
+
throw new Error(`${input.entity} '${input.id}' already exists in the target project — refusing to overwrite.`);
|
|
93
|
+
}
|
|
94
|
+
// Reference guards (plans): refuse to move work under a live claim; warn on
|
|
95
|
+
// sequences that still point at it (v1 does not rewrite refs).
|
|
96
|
+
const warnings = [];
|
|
97
|
+
if (input.entity === 'plan') {
|
|
98
|
+
const liveClaims = listClaims(fromCwd).filter((c) => c.status === 'active' && c.plan_id === input.id);
|
|
99
|
+
if (liveClaims.length > 0 && !input.force) {
|
|
100
|
+
throw new Error(`${input.id} has ${liveClaims.length} active claim(s) in the source project — refusing to move work mid-flight. `
|
|
101
|
+
+ `Release the claim(s) first, or pass force to override.`);
|
|
102
|
+
}
|
|
103
|
+
if (liveClaims.length > 0) {
|
|
104
|
+
warnings.push(`moved despite ${liveClaims.length} active claim(s) still in the source project (force).`);
|
|
105
|
+
}
|
|
106
|
+
const refSeqs = listSequences(fromCwd).filter((s) => (s.items ?? []).some((it) => it.planId === input.id));
|
|
107
|
+
for (const s of refSeqs) {
|
|
108
|
+
warnings.push(`sequence ${s.id} in the source project still references this plan (items not rewritten).`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// Perform the move: write target first (atomic), then remove the source — so a
|
|
112
|
+
// crash mid-move leaves a duplicate (recoverable) rather than nothing.
|
|
113
|
+
fs.mkdirSync(dstDir, { recursive: true });
|
|
114
|
+
writeFileAtomic(dstFile, `${JSON.stringify(raw, null, 2)}\n`);
|
|
115
|
+
fs.rmSync(srcFile);
|
|
116
|
+
// Audit BOTH stores so provenance survives the move.
|
|
117
|
+
const actor = input.actor ?? 'unknown';
|
|
118
|
+
const auditCommon = {
|
|
119
|
+
action: 'move',
|
|
120
|
+
actor,
|
|
121
|
+
...(input.actorId ? { actor_id: input.actorId } : {}),
|
|
122
|
+
item_id: input.id,
|
|
123
|
+
item_type: input.entity,
|
|
124
|
+
scope: foundSubdir,
|
|
125
|
+
};
|
|
126
|
+
appendAuditEntry({ ...auditCommon, reason: `moved to ${toCwd}` }, fromCwd);
|
|
127
|
+
appendAuditEntry({ ...auditCommon, reason: `moved from ${fromCwd}` }, toCwd);
|
|
128
|
+
return { entity: input.entity, id: input.id, from: fromCwd, to: toCwd, subdir: foundSubdir, warnings };
|
|
129
|
+
}
|
|
130
|
+
//# sourceMappingURL=relocate.js.map
|
|
@@ -130,16 +130,44 @@ export function resolveEffectiveCwdInfo(options = {}) {
|
|
|
130
130
|
if (resolved)
|
|
131
131
|
return { cwd: resolved, active_source: 'env_project', resolved_project: projectInfo(resolved) };
|
|
132
132
|
}
|
|
133
|
-
// 4. Session-scoped active project (per-agent, no cross-agent interference)
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
133
|
+
// 4. Session-scoped active project (per-agent, no cross-agent interference).
|
|
134
|
+
// A session can be persisted under a DIFFERENT store than the anchor we read
|
|
135
|
+
// from: an agent physically inside a child project gets its session created
|
|
136
|
+
// AND switched under that child (cwd_child / the switch handler use the
|
|
137
|
+
// physical cwd), while resolution here anchors at the workspace
|
|
138
|
+
// (BRAINCLAW_CWD). Sessions are stored per-cwd (sessionsDir(cwd)) with no
|
|
139
|
+
// chain search, so reading only the anchor makes a successful bclaw_switch
|
|
140
|
+
// invisible — resolution then silently falls through to cwd_child and pins
|
|
141
|
+
// the agent to the wrong project (DGX Finding 1, 2026-06-22). Probe the
|
|
142
|
+
// anchor, the physical baseCwd, and the workspace root for each; the first
|
|
143
|
+
// session carrying a still-valid active_project wins (anchor first preserves
|
|
144
|
+
// prior precedence when the session lives where we expect).
|
|
145
|
+
const probedSessionCwds = new Set();
|
|
146
|
+
const probeSessionAt = (candidate) => {
|
|
147
|
+
if (!candidate)
|
|
148
|
+
return undefined;
|
|
149
|
+
const probeCwd = path.resolve(candidate);
|
|
150
|
+
if (probedSessionCwds.has(probeCwd))
|
|
151
|
+
return undefined; // dedup — no double read
|
|
152
|
+
probedSessionCwds.add(probeCwd);
|
|
153
|
+
const session = options.sessionId
|
|
154
|
+
? loadSessionById(options.sessionId, probeCwd)
|
|
155
|
+
: loadCurrentSession(probeCwd);
|
|
156
|
+
const sp = session?.active_project;
|
|
157
|
+
if (sp && fs.existsSync(path.join(sp.path, MEMORY_DIR, 'config.yaml'))) {
|
|
140
158
|
return { cwd: sp.path, active_source: 'session', resolved_project: { path: sp.path, name: sp.name } };
|
|
141
159
|
}
|
|
142
|
-
|
|
160
|
+
return undefined;
|
|
161
|
+
};
|
|
162
|
+
// `??` short-circuits: the common case (agent at the anchor, session there)
|
|
163
|
+
// costs exactly one session load and never walks for the workspace root. The
|
|
164
|
+
// baseCwd / workspace-root probes only run when the cheap ones miss — i.e. the
|
|
165
|
+
// monorepo case where the session was stored under the physical child.
|
|
166
|
+
const sessionHit = probeSessionAt(anchorCwd)
|
|
167
|
+
?? probeSessionAt(baseCwd)
|
|
168
|
+
?? probeSessionAt(resolveWorkspaceRoot(baseCwd, options.storeChainOptions));
|
|
169
|
+
if (sessionHit)
|
|
170
|
+
return sessionHit;
|
|
143
171
|
// 5. cwd_child — when anchored and the agent is physically inside a child store
|
|
144
172
|
// STRICTLY under the anchor, resolve THAT child rather than the shared global
|
|
145
173
|
// pointer or the anchor root. This is the independence rule: physical location
|
|
@@ -243,6 +271,22 @@ export function resolveProjectRef(ref, cwd = process.cwd(), storeChainOptions) {
|
|
|
243
271
|
if (fs.existsSync(path.join(asPath, MEMORY_DIR, 'config.yaml'))) {
|
|
244
272
|
return asPath;
|
|
245
273
|
}
|
|
274
|
+
// The workspace root itself is a legal target — it is the "umbrella" project
|
|
275
|
+
// of a monorepo. The chain scan below deliberately skips wsRoot (so a child
|
|
276
|
+
// can never be shadowed by the root), but an agent must still be able to
|
|
277
|
+
// target the root by its own project_name/project_id. Without this, an agent
|
|
278
|
+
// working inside a child cannot switch UP to the monorepo root: bclaw_switch
|
|
279
|
+
// (and project="<root-name>" routing) failed with "Cannot resolve project"
|
|
280
|
+
// (DGX dogfood 2026-06-22, Finding 1). Matching by name/id — not by arbitrary
|
|
281
|
+
// path — preserves the path-injection trust boundary enforced above.
|
|
282
|
+
try {
|
|
283
|
+
const rootConfig = loadConfig(wsRoot);
|
|
284
|
+
if (rootConfig.project_name === ref || rootConfig.project_id === ref)
|
|
285
|
+
return wsRoot;
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
// unreadable workspace-root config — fall through to the child scan
|
|
289
|
+
}
|
|
246
290
|
// Try by project name or project ID: scan child stores
|
|
247
291
|
const chain = resolveStoreChain(wsRoot, storeChainOptions);
|
|
248
292
|
for (const store of chain) {
|
package/dist/core/worktree.js
CHANGED
|
@@ -18,6 +18,13 @@ function gitPath(p) {
|
|
|
18
18
|
*
|
|
19
19
|
* Rules covered: no leading dots/dashes, no trailing dots, no `..`, no
|
|
20
20
|
* `@{`, no control/space/`~^:?*[\\` characters, no trailing `.lock`.
|
|
21
|
+
*
|
|
22
|
+
* Order matters: the 48-char length cap is applied BEFORE the trailing-dot/dash
|
|
23
|
+
* and `.lock` strips — never after. Dogfood 1.10.1: a multi-file scope ending in
|
|
24
|
+
* `…IntegrationHubPage.astro` sanitized fine, but the final `.slice(0, 48)` cut
|
|
25
|
+
* landed on the dot before `astro`, yielding `…IntegrationHubPage.` — a trailing
|
|
26
|
+
* dot git rejects (`fatal: not a valid branch name`). Truncating first, then
|
|
27
|
+
* stripping, guarantees the cap can never re-introduce an invalid ref.
|
|
21
28
|
*/
|
|
22
29
|
export function sanitizeBranchComponent(raw, fallback = 'scope') {
|
|
23
30
|
let slug = raw
|
|
@@ -27,12 +34,13 @@ export function sanitizeBranchComponent(raw, fallback = 'scope') {
|
|
|
27
34
|
.replace(/[^a-zA-Z0-9._-]/g, '-') // conservative whitelist for the rest
|
|
28
35
|
.replace(/-+/g, '-') // collapse dashes
|
|
29
36
|
.replace(/^[.-]+/, '') // no leading dot/dash
|
|
30
|
-
.
|
|
37
|
+
.slice(0, 48) // length cap BEFORE the trailing strips
|
|
38
|
+
.replace(/[.-]+$/, ''); // no trailing dot/dash (cut may have made one)
|
|
31
39
|
if (/\.lock$/i.test(slug))
|
|
32
|
-
slug = slug.slice(0, -'.lock'.length);
|
|
40
|
+
slug = slug.slice(0, -'.lock'.length).replace(/[.-]+$/, '');
|
|
33
41
|
if (!slug)
|
|
34
42
|
slug = fallback;
|
|
35
|
-
return slug
|
|
43
|
+
return slug;
|
|
36
44
|
}
|
|
37
45
|
/**
|
|
38
46
|
* Stack marker → shared directories mapping.
|
|
@@ -181,8 +189,40 @@ export function resolveWorktreePath(mainWorktreePath, branchName) {
|
|
|
181
189
|
const slug = branchName.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 64);
|
|
182
190
|
return path.join(worktreesBaseDir(mainWorktreePath), slug);
|
|
183
191
|
}
|
|
184
|
-
|
|
185
|
-
|
|
192
|
+
/**
|
|
193
|
+
* Default timeout for quick git metadata queries (rev-parse, status, branch…).
|
|
194
|
+
* `git worktree add` is the exception — it materialises the entire working tree
|
|
195
|
+
* and on a large repo / Windows (Defender) easily exceeds this, so it passes an
|
|
196
|
+
* explicit, much larger timeout (see resolveWorktreeAddTimeoutMs).
|
|
197
|
+
*
|
|
198
|
+
* Dogfood 1.10.1: a 662-file site checkout was SIGTERM-killed at ~94% by this
|
|
199
|
+
* flat 15s cap, surfacing as a misleading "git worktree add failed: …Updating
|
|
200
|
+
* files: 94%" — the branch name was fine; the checkout simply ran out of time.
|
|
201
|
+
*/
|
|
202
|
+
const GIT_QUERY_TIMEOUT_MS = 15000;
|
|
203
|
+
/**
|
|
204
|
+
* Timeout for `git worktree add` (full working-tree checkout). Defaults to 120s;
|
|
205
|
+
* override with BRAINCLAW_WORKTREE_ADD_TIMEOUT_MS (milliseconds) for very large
|
|
206
|
+
* repos or slow filesystems.
|
|
207
|
+
*/
|
|
208
|
+
export function resolveWorktreeAddTimeoutMs() {
|
|
209
|
+
const raw = process.env.BRAINCLAW_WORKTREE_ADD_TIMEOUT_MS;
|
|
210
|
+
const n = raw ? Number.parseInt(raw, 10) : NaN;
|
|
211
|
+
return Number.isFinite(n) && n > 0 ? n : 120_000;
|
|
212
|
+
}
|
|
213
|
+
function runGit(args, cwd, timeoutMs = GIT_QUERY_TIMEOUT_MS) {
|
|
214
|
+
const result = spawnSync('git', args, { cwd, encoding: 'utf-8', timeout: timeoutMs });
|
|
215
|
+
// spawnSync kills on timeout (SIGTERM) and sets error.code=ETIMEDOUT; the raw
|
|
216
|
+
// stderr is then just partial progress ("Updating files: …%"), which reads as
|
|
217
|
+
// a cryptic failure. Surface the real cause — the timeout — instead.
|
|
218
|
+
if (result.error?.code === 'ETIMEDOUT') {
|
|
219
|
+
const hint = args[0] === 'worktree' ? ' (large checkout? raise BRAINCLAW_WORKTREE_ADD_TIMEOUT_MS)' : '';
|
|
220
|
+
return {
|
|
221
|
+
ok: false,
|
|
222
|
+
stdout: result.stdout ?? '',
|
|
223
|
+
stderr: `git ${args[0]} timed out after ${timeoutMs}ms and was killed${hint}.`,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
186
226
|
return {
|
|
187
227
|
ok: result.status === 0,
|
|
188
228
|
stdout: result.stdout ?? '',
|
|
@@ -496,7 +536,7 @@ export function createWorktree(mainWorktreePath, branchName, options = {}) {
|
|
|
496
536
|
const worktreeArgs = branchExists
|
|
497
537
|
? ['worktree', 'add', gitTargetPath, branchName]
|
|
498
538
|
: ['worktree', 'add', '-b', branchName, gitTargetPath, baseRef];
|
|
499
|
-
const result = runGit(worktreeArgs, mainWorktreePath);
|
|
539
|
+
const result = runGit(worktreeArgs, mainWorktreePath, resolveWorktreeAddTimeoutMs());
|
|
500
540
|
if (!result.ok) {
|
|
501
541
|
throw new Error(`git worktree add failed: ${result.stderr.trim()}`);
|
|
502
542
|
}
|
|
@@ -945,6 +985,92 @@ export function cleanMergedWorktrees(mainWorktreePath, options = {}) {
|
|
|
945
985
|
cleanOrphanWorktreeDirs(mainWorktreePath, worktrees, result, options.dryRun);
|
|
946
986
|
return result;
|
|
947
987
|
}
|
|
988
|
+
/** A worker whose heartbeat file was touched within this window looks alive. */
|
|
989
|
+
const WORKTREE_GC_LIVENESS_WINDOW_MS = 120_000;
|
|
990
|
+
/**
|
|
991
|
+
* A worker still looks alive when a `.brainclaw-heartbeat-*` sentinel in its
|
|
992
|
+
* worktree was modified within `windowMs`. Cheap liveness signal that needs no
|
|
993
|
+
* agent_run lookup — the spawn wrapper touches the heartbeat periodically.
|
|
994
|
+
*/
|
|
995
|
+
function workerLooksAlive(worktreePath, windowMs) {
|
|
996
|
+
try {
|
|
997
|
+
const now = Date.now();
|
|
998
|
+
for (const name of fs.readdirSync(worktreePath)) {
|
|
999
|
+
if (!name.startsWith('.brainclaw-heartbeat-'))
|
|
1000
|
+
continue;
|
|
1001
|
+
try {
|
|
1002
|
+
if (now - fs.statSync(path.join(worktreePath, name)).mtimeMs < windowMs)
|
|
1003
|
+
return true;
|
|
1004
|
+
}
|
|
1005
|
+
catch { /* ignore unreadable sentinel */ }
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
catch { /* worktree dir unreadable — treat as not-alive */ }
|
|
1009
|
+
return false;
|
|
1010
|
+
}
|
|
1011
|
+
/**
|
|
1012
|
+
* Garbage-collect a single dispatched sub-agent worktree once its work is safely
|
|
1013
|
+
* harvested (pln#594). Used by the loop-close cascade so review/dispatch
|
|
1014
|
+
* worktrees stop accumulating under ~/.brainclaw/worktrees/.
|
|
1015
|
+
*
|
|
1016
|
+
* SAFE BY DEFAULT — returns { removed: false, reason } instead of removing when:
|
|
1017
|
+
* - a worker still looks alive (recent heartbeat) — never bypassed, even by force;
|
|
1018
|
+
* - the worktree has un-harvested edits (anything beyond brainclaw birth-noise /
|
|
1019
|
+
* LANE-RESULT.json / heartbeat — i.e. real uncommitted work);
|
|
1020
|
+
* - the lane branch has commits NOT reachable from the main repo HEAD
|
|
1021
|
+
* (un-integrated work that `branch -D` would drop).
|
|
1022
|
+
* `force` bypasses the dirty + unmerged guards (NOT the liveness guard).
|
|
1023
|
+
* Removal is junction-safe (delegates to removeWorktree → detachWorktreeJunctions).
|
|
1024
|
+
*/
|
|
1025
|
+
export function gcWorktreeIfHarvested(mainWorktreePath, worktreePath, options = {}) {
|
|
1026
|
+
const out = (removed, reason, branch) => ({
|
|
1027
|
+
path: worktreePath, branch, removed, reason,
|
|
1028
|
+
});
|
|
1029
|
+
if (!worktreePath || !fs.existsSync(worktreePath))
|
|
1030
|
+
return out(false, 'already gone');
|
|
1031
|
+
if (workerLooksAlive(worktreePath, options.livenessWindowMs ?? WORKTREE_GC_LIVENESS_WINDOW_MS)) {
|
|
1032
|
+
return out(false, 'worker still active (recent heartbeat)');
|
|
1033
|
+
}
|
|
1034
|
+
const branchRes = runGit(['rev-parse', '--abbrev-ref', 'HEAD'], worktreePath);
|
|
1035
|
+
const branch = branchRes.ok ? branchRes.stdout.trim() : undefined;
|
|
1036
|
+
if (!options.force) {
|
|
1037
|
+
// FAIL CLOSED (codex review): every safety probe that cannot be read must
|
|
1038
|
+
// KEEP the worktree, never fall through to removal. A transient `git status`
|
|
1039
|
+
// timeout on a real, dirty worktree previously skipped the dirty check and
|
|
1040
|
+
// force-removed it — losing un-harvested edits. Same for the HEAD reads.
|
|
1041
|
+
const status = runGit(['status', '--porcelain=v1', '-z', '--untracked-files=normal'], worktreePath);
|
|
1042
|
+
if (!status.ok) {
|
|
1043
|
+
return out(false, 'could not read worktree status — keeping (fail-closed)', branch);
|
|
1044
|
+
}
|
|
1045
|
+
if (!worktreeHasOnlyBirthNoise(status.stdout)) {
|
|
1046
|
+
return out(false, 'un-harvested changes in worktree', branch);
|
|
1047
|
+
}
|
|
1048
|
+
// The lane HEAD must be reachable from the main repo HEAD, else the branch
|
|
1049
|
+
// carries un-integrated commits that `branch -D` would silently drop.
|
|
1050
|
+
const laneHead = runGit(['rev-parse', 'HEAD'], worktreePath);
|
|
1051
|
+
const mainHead = runGit(['rev-parse', 'HEAD'], mainWorktreePath);
|
|
1052
|
+
if (!laneHead.ok || !mainHead.ok) {
|
|
1053
|
+
return out(false, 'could not verify merge status — keeping (fail-closed)', branch);
|
|
1054
|
+
}
|
|
1055
|
+
const ancestor = runGit(['merge-base', '--is-ancestor', laneHead.stdout.trim(), mainHead.stdout.trim()], mainWorktreePath);
|
|
1056
|
+
// exit 0 = ancestor (safe). Non-zero = not an ancestor OR a git error — both
|
|
1057
|
+
// mean "cannot prove integrated", so keep.
|
|
1058
|
+
if (!ancestor.ok)
|
|
1059
|
+
return out(false, 'lane branch has un-integrated commits (or unverifiable)', branch);
|
|
1060
|
+
}
|
|
1061
|
+
try {
|
|
1062
|
+
removeWorktree(mainWorktreePath, worktreePath, { force: true });
|
|
1063
|
+
}
|
|
1064
|
+
catch (err) {
|
|
1065
|
+
return out(false, `removal failed: ${err.message}`, branch);
|
|
1066
|
+
}
|
|
1067
|
+
// Delete the now-redundant dispatch branch (force: it may be a squash-merge
|
|
1068
|
+
// descendant that `-d` would refuse). Best-effort — a kept branch is harmless.
|
|
1069
|
+
if (branch && branch !== 'HEAD' && branch !== '(detached)') {
|
|
1070
|
+
runGit(['branch', '-D', branch], mainWorktreePath);
|
|
1071
|
+
}
|
|
1072
|
+
return out(true, options.force ? 'force-removed' : 'harvested + merged', branch);
|
|
1073
|
+
}
|
|
948
1074
|
/**
|
|
949
1075
|
* Removes brainclaw-managed worktree directories under ~/.brainclaw/worktrees/
|
|
950
1076
|
* that no longer have a corresponding git worktree entry.
|
package/dist/facts.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
// Generated by scripts/emit-site-facts.mjs at build time. Do not edit manually.
|
|
2
|
-
// Source: brainclaw v1.
|
|
2
|
+
// Source: brainclaw v1.11.0 on 2026-06-24T07:26:01.488Z
|
|
3
3
|
export const FACTS = {
|
|
4
|
-
"version": "1.
|
|
5
|
-
"generated_at": "2026-06-
|
|
4
|
+
"version": "1.11.0",
|
|
5
|
+
"generated_at": "2026-06-24T07:26:01.488Z",
|
|
6
6
|
"tools": {
|
|
7
|
-
"count":
|
|
8
|
-
"published_count":
|
|
7
|
+
"count": 67,
|
|
8
|
+
"published_count": 66,
|
|
9
9
|
"names": [
|
|
10
10
|
"bclaw_bootstrap",
|
|
11
11
|
"bclaw_release_notes",
|
|
@@ -72,7 +72,8 @@ export const FACTS = {
|
|
|
72
72
|
"bclaw_create",
|
|
73
73
|
"bclaw_update",
|
|
74
74
|
"bclaw_remove",
|
|
75
|
-
"bclaw_transition"
|
|
75
|
+
"bclaw_transition",
|
|
76
|
+
"bclaw_move"
|
|
76
77
|
]
|
|
77
78
|
},
|
|
78
79
|
"entities": {
|
package/dist/facts.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "1.
|
|
3
|
-
"generated_at": "2026-06-
|
|
2
|
+
"version": "1.11.0",
|
|
3
|
+
"generated_at": "2026-06-24T07:26:01.488Z",
|
|
4
4
|
"tools": {
|
|
5
|
-
"count":
|
|
6
|
-
"published_count":
|
|
5
|
+
"count": 67,
|
|
6
|
+
"published_count": 66,
|
|
7
7
|
"names": [
|
|
8
8
|
"bclaw_bootstrap",
|
|
9
9
|
"bclaw_release_notes",
|
|
@@ -70,7 +70,8 @@
|
|
|
70
70
|
"bclaw_create",
|
|
71
71
|
"bclaw_update",
|
|
72
72
|
"bclaw_remove",
|
|
73
|
-
"bclaw_transition"
|
|
73
|
+
"bclaw_transition",
|
|
74
|
+
"bclaw_move"
|
|
74
75
|
]
|
|
75
76
|
},
|
|
76
77
|
"entities": {
|
package/docs/cli.md
CHANGED
|
@@ -65,6 +65,24 @@ brainclaw --cwd /other/path status # one-off override without switching
|
|
|
65
65
|
|
|
66
66
|
**MCP usage:** The active project also affects MCP tools. When `bclaw_context(kind="memory")` is called without an explicit path, it resolves context from the active project's store. Agents can also use the `BRAINCLAW_PROJECT=<name>` environment variable for the same effect.
|
|
67
67
|
|
|
68
|
+
### `brainclaw move <entity> <id> --to <project>`
|
|
69
|
+
|
|
70
|
+
Relocate a brainclaw item to another project in a multi-project workspace, **preserving its id** — so `pln#`/`dec#` references stay stable. Useful when an item was created in the wrong store (e.g. before a project switch took effect). MCP equivalent: `bclaw_move(entity, id, to_project, …)`.
|
|
71
|
+
|
|
72
|
+
| Option | Description |
|
|
73
|
+
|---|---|
|
|
74
|
+
| `--to <project>` | Target project (name, path, or basename) — required |
|
|
75
|
+
| `--from <project>` | Source project (defaults to the current project) |
|
|
76
|
+
| `--force` | Move even if an active claim references the item |
|
|
77
|
+
| `--json` | Output as JSON |
|
|
78
|
+
|
|
79
|
+
Relocatable entities: `plan`, `decision`, `constraint`, `trap`, `handoff`, `sequence`. **Execution-local entities** (`claim`, `assignment`, `agent_run`, `session`) are rejected — they belong to the project where the work ran. The move refuses an id collision in the target or a plan under an active claim (unless `--force`), audits both stores, and warns about sequences that still reference a moved plan.
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
brainclaw move plan pln_a1b2c3d4 --to global # move a misplaced plan to the root project
|
|
83
|
+
brainclaw move decision dec_99 --from app_a --to app_b
|
|
84
|
+
```
|
|
85
|
+
|
|
68
86
|
---
|
|
69
87
|
|
|
70
88
|
## Initialize and Inspect
|
|
@@ -613,13 +631,13 @@ can ask "where is X / what should I read first" before editing. The MCP equivale
|
|
|
613
631
|
are `bclaw_code_status` / `bclaw_code_find` / `bclaw_code_brief` / `bclaw_code_refresh`.
|
|
614
632
|
Full reference (freshness model, supported languages, WASM bundling): [docs/code-map.md](code-map.md).
|
|
615
633
|
|
|
616
|
-
### `brainclaw code-map status`
|
|
634
|
+
### `brainclaw code-map status [--cascade]`
|
|
617
635
|
|
|
618
|
-
Store presence, freshness badge (`fresh` / `stale_changed_files` / `stale_extractor` / `stale_grammar` / `partial` / `missing_index`), and index stats (files, nodes, edges). Read-only.
|
|
636
|
+
Store presence, freshness badge (`fresh` / `stale_changed_files` / `stale_extractor` / `stale_grammar` / `partial` / `missing_index`), and index stats (files, nodes, edges). Read-only. In a multi-project workspace, `--cascade` adds a per-child recap (which nested projects have a built index vs `missing_index`, plus an aggregate count).
|
|
619
637
|
|
|
620
|
-
### `brainclaw code-map refresh [--all|--changed]`
|
|
638
|
+
### `brainclaw code-map refresh [--all|--changed] [--cascade]`
|
|
621
639
|
|
|
622
|
-
Build or update the index. `--changed` (default) re-parses only touched files; `--all` does a full re-index. Run this when status shows `missing_index` or a stale badge. Fails fast (never blocks) if another writer holds the project lock.
|
|
640
|
+
Build or update the index. `--changed` (default) re-parses only touched files; `--all` does a full re-index. Run this when status shows `missing_index` or a stale badge. Fails fast (never blocks) if another writer holds the project lock. In a multi-project workspace, `--cascade` refreshes **every nested project** into its own store plus a root store scoped to the files no child owns (zero double-indexing) — one command at the root indexes the whole monorepo per-project. See [docs/code-map.md](code-map.md#cascading-a-multi-project-workspace---cascade).
|
|
623
641
|
|
|
624
642
|
### `brainclaw code-map find <query> [--limit <n>]`
|
|
625
643
|
|
package/docs/code-map.md
CHANGED
|
@@ -165,11 +165,12 @@ Code Map is **per project**: the index lives at `<project>/.brainclaw/code/`, an
|
|
|
165
165
|
into subdirectories but skipping `node_modules`, `dist`, `.git`, `.brainclaw`,
|
|
166
166
|
`vendor`, `target`, … at any depth.
|
|
167
167
|
|
|
168
|
-
|
|
168
|
+
By default there is no nested-project *boundary*, so a plain (non-cascade) scope
|
|
169
|
+
follows **where you run it**:
|
|
169
170
|
|
|
170
171
|
| You run refresh / find / brief … | … against |
|
|
171
172
|
|---|---|
|
|
172
|
-
| at the monorepo root | one index covering the whole tree (every child project's source) |
|
|
173
|
+
| at the monorepo root (plain) | one index covering the whole tree (every child project's source) |
|
|
173
174
|
| inside a child project (e.g. `apps/api`) | that child's own index, at `apps/api/.brainclaw/code/` |
|
|
174
175
|
|
|
175
176
|
When an agent works inside a child project, brainclaw's project resolution routes
|
|
@@ -178,10 +179,33 @@ Code Map to **that child** — the same per-project scoping that powers `bclaw_w
|
|
|
178
179
|
juggling. A submodule that is itself an application (under e.g. `apps/`) is indexed
|
|
179
180
|
like any other directory.
|
|
180
181
|
|
|
182
|
+
### Cascading a multi-project workspace (`--cascade`)
|
|
183
|
+
|
|
184
|
+
In a `project_mode: multi-project` workspace, one refresh at the root can index
|
|
185
|
+
the whole monorepo **per project** instead of building one monolithic root index:
|
|
186
|
+
|
|
187
|
+
```bash
|
|
188
|
+
brainclaw code-map refresh --all --cascade # CLI
|
|
189
|
+
# bclaw_code_refresh(scope="all", cascade=true) # MCP
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
This refreshes **every nested brainclaw project** into its own
|
|
193
|
+
`<child>/.brainclaw/code/` store, and refreshes the **root** store *scoped to the
|
|
194
|
+
files no child owns*. The rule is "each file is indexed by exactly the most
|
|
195
|
+
specific brainclaw project that contains it" — so there is **zero
|
|
196
|
+
double-indexing**, even when projects nest inside one another. `--cascade` is
|
|
197
|
+
opt-in; without it, the root refresh keeps its single-tree behaviour (above), and
|
|
198
|
+
single-project repos ignore the flag entirely.
|
|
199
|
+
|
|
200
|
+
`status --cascade` (or `bclaw_code_status(cascade=true)`) adds a per-child recap —
|
|
201
|
+
which nested projects have a built index vs `missing_index`, plus an aggregate
|
|
202
|
+
count — so you can see workspace-wide freshness from the root.
|
|
203
|
+
|
|
181
204
|
**Not yet supported** (roadmap):
|
|
182
205
|
|
|
183
|
-
- A single
|
|
184
|
-
|
|
206
|
+
- A single **federated query** at the root that fans out across the per-child
|
|
207
|
+
indexes and merges the results (today, `--cascade` builds the per-child indexes;
|
|
208
|
+
`find` / `brief` still run against one store at a time).
|
|
185
209
|
- **Cross-service edges** — e.g. linking an API call to the route that defines it in
|
|
186
210
|
another service. Code Map indexes language *symbols* and *module imports*, not
|
|
187
211
|
framework routes or runtime HTTP calls, so it does not (today) map "service A calls
|
|
@@ -165,6 +165,32 @@ The on-behalf commit is guarded by the linked-worktree check (`isLinkedWorktree`
|
|
|
165
165
|
|
|
166
166
|
Integration is strictly additive and opt-in. Plain `brainclaw harvest <assignment_id>` remains report-only; it reads and reports the lane result without committing or mutating assignment / claim state. The on-behalf commit and lifecycle completion happen only when the coordinator passes `--integrate`.
|
|
167
167
|
|
|
168
|
+
### Worktree garbage collection on loop close (pln#594)
|
|
169
|
+
|
|
170
|
+
Closing a loop as **`completed`** garbage-collects the worktrees of its slot
|
|
171
|
+
assignments, so review/dispatch worktrees stop accumulating under
|
|
172
|
+
`~/.brainclaw/worktrees/`. The cascade runs inside `closeLoop` (so it covers MCP,
|
|
173
|
+
CLI, and reconciler-driven closes) and is **safe by default** — each worktree is
|
|
174
|
+
removed only when all of these hold:
|
|
175
|
+
|
|
176
|
+
- the worker no longer looks alive (no `.brainclaw-heartbeat-*` touched within the
|
|
177
|
+
liveness window) — this guard is never bypassed, even with force;
|
|
178
|
+
- the worktree has no un-harvested edits — anything beyond brainclaw birth-noise
|
|
179
|
+
(`.gitignore`, the sidecar), `LANE-RESULT.json`, and the heartbeat counts as
|
|
180
|
+
real work and is preserved;
|
|
181
|
+
- the lane branch carries no commits unreachable from the main repo HEAD (so
|
|
182
|
+
deleting the branch can't drop un-integrated work).
|
|
183
|
+
|
|
184
|
+
A worktree that fails a guard is **kept** (with a debug-log reason) so you can
|
|
185
|
+
harvest or inspect it. A **`cancelled`/`blocked`** close keeps the worktree and
|
|
186
|
+
its run logs for forensics. Removal is junction-safe (`removeWorktree` detaches
|
|
187
|
+
`node_modules`/`dist` junctions first), then the redundant dispatch branch is
|
|
188
|
+
deleted. The whole step is best-effort — it never blocks the close — and can be
|
|
189
|
+
disabled with `BRAINCLAW_NO_WORKTREE_GC=1`. The reusable primitive is
|
|
190
|
+
`gcWorktreeIfHarvested(mainWorktreePath, worktreePath, { force? })` in
|
|
191
|
+
`core/worktree.ts`; `brainclaw worktree clean` remains the manual/TTL backstop
|
|
192
|
+
for anything the cascade keeps.
|
|
193
|
+
|
|
168
194
|
---
|
|
169
195
|
|
|
170
196
|
## Diagnostic playbook
|
package/docs/integrations/mcp.md
CHANGED
|
@@ -107,6 +107,7 @@ Each tool also has an `annotations.category` field: `session`, `context`, `memor
|
|
|
107
107
|
| `bclaw_update` | memory | Partially update mutable fields on a canonical entity |
|
|
108
108
|
| `bclaw_remove` | memory | Archive or purge a canonical entity |
|
|
109
109
|
| `bclaw_transition` | memory | Move an entity through its validated state machine |
|
|
110
|
+
| `bclaw_move` | memory | Relocate an item to another project, id-preserving (multi-project) |
|
|
110
111
|
| `bclaw_code_status` | discovery | Code Map freshness badge + index stats (store presence, files/nodes/edges) |
|
|
111
112
|
| `bclaw_code_find` | discovery | Search the Code Map symbol index by name (function/class/component/hook/type) |
|
|
112
113
|
| `bclaw_code_brief` | discovery | Ranked reading list + related decisions/traps before editing a symbol or path |
|
|
@@ -162,6 +163,7 @@ for the full 1.0.0 changelog.
|
|
|
162
163
|
| `bclaw_update(entity, id, patch)` | Partial merge (updatable fields only) | `bclaw_update_plan`, `bclaw_update_memory` |
|
|
163
164
|
| `bclaw_remove(entity, id, purge?)` | Archive (default) or hard-delete | `bclaw_delete_memory`, `bclaw_delete_plan` |
|
|
164
165
|
| `bclaw_transition(entity, id, to, reason?)` | State machine transition with side-effect tags | `bclaw_accept`, `bclaw_reject`, status-update flows |
|
|
166
|
+
| `bclaw_move(entity, id, to_project, from_project?, force?)` | Id-preserving cross-project relocation (plan/decision/constraint/trap/handoff/sequence; execution entities rejected) | — (new in 1.11.0) |
|
|
165
167
|
|
|
166
168
|
Supported entities: plan, decision, constraint, trap, handoff,
|
|
167
169
|
runtime_note, candidate, sequence, claim, action, assignment, agent_run
|
|
@@ -10,6 +10,24 @@ guarantees this changelog follows.
|
|
|
10
10
|
|
|
11
11
|
## Unreleased
|
|
12
12
|
|
|
13
|
+
**Added — `bclaw_move` cross-project relocation (pln#595)**
|
|
14
|
+
- New canonical-grammar verb `bclaw_move(entity, id, to_project, from_project?, force?)`:
|
|
15
|
+
relocates a brainclaw item to another project in a multi-project workspace,
|
|
16
|
+
PRESERVING its id. Relocatable: plan, decision, constraint, trap, handoff,
|
|
17
|
+
sequence. Execution-local entities (claim, assignment, agent_run, session) are
|
|
18
|
+
rejected. Refuses id collisions / active-claim moves (unless force); audits both
|
|
19
|
+
stores. Additive — no tool removed or renamed.
|
|
20
|
+
|
|
21
|
+
**Changed — Code Map monorepo cascade (DGX Finding 2)**
|
|
22
|
+
- `bclaw_code_refresh` gains an optional `cascade: boolean`. In a
|
|
23
|
+
`project_mode: multi-project` workspace it refreshes every nested
|
|
24
|
+
brainclaw project into its own store + a child-scoped root store
|
|
25
|
+
(zero double-indexing). No-op outside a multi-project workspace.
|
|
26
|
+
- `bclaw_code_status` gains an optional `cascade: boolean` that adds a
|
|
27
|
+
per-child store-presence / freshness recap.
|
|
28
|
+
- No tool was removed or renamed; no required argument changed.
|
|
29
|
+
- Surface fingerprint bumped in the `(current)` section below.
|
|
30
|
+
|
|
13
31
|
**Changed — agent-UX read-path surface (pln#542)**
|
|
14
32
|
- `bclaw_work`, `bclaw_context`, `bclaw_find`, `bclaw_get`, `bclaw_search`
|
|
15
33
|
gain an optional `budget_tokens` argument (relevance-ranked fill).
|
|
@@ -104,10 +122,16 @@ will still succeed. A follow-up PR will strip the dead handler code.
|
|
|
104
122
|
changelog records the published MCP surface fingerprint. When a tool
|
|
105
123
|
name, tier, category, or input schema changes, the test fails until
|
|
106
124
|
this section is updated.
|
|
107
|
-
- MCP public surface fingerprint: `sha256:
|
|
108
|
-
(updated 2026-06-
|
|
125
|
+
- MCP public surface fingerprint: `sha256:188d2eba8828e4fe`
|
|
126
|
+
(updated 2026-06-24 for 1.11.0: `bclaw_move` added (pln#595) AND a `cascade`
|
|
127
|
+
boolean added to `bclaw_code_refresh` / `bclaw_code_status` (DGX Finding 2).
|
|
128
|
+
Additive: one new tool; nothing removed or renamed; no required argument changed.
|
|
129
|
+
Supersedes the per-branch interim hashes sha256:dffcc868ae90e013 and
|
|
130
|
+
sha256:41eb6d55010cdfb5.)
|
|
131
|
+
Previous: `sha256:35fd83b0d124df94`,
|
|
132
|
+
updated 2026-06-20 for 1.10.0: Code Map tools added to the published surface —
|
|
109
133
|
`bclaw_code_find`, `bclaw_code_brief`, `bclaw_code_refresh`, `bclaw_code_status`.
|
|
110
|
-
Additive: no tool removed or renamed.
|
|
134
|
+
Additive: no tool removed or renamed.
|
|
111
135
|
Previous: `sha256:70cf80b9615f631f`,
|
|
112
136
|
updated 2026-06-18 for 1.9.1: monorepo project-scoping fix — session-aware
|
|
113
137
|
effective-cwd resolution and read-path project scoping shift the published
|