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 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) {
@@ -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
- if (!manifest) {
67
- return {
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
- const base = badge(manifest.freshness.status, {
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',
@@ -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 { convergeAssignmentToTerminal } from '../assignments.js';
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
- const session = options.sessionId
135
- ? loadSessionById(options.sessionId, anchorCwd)
136
- : loadCurrentSession(anchorCwd);
137
- if (session?.active_project) {
138
- const sp = session.active_project;
139
- if (fs.existsSync(path.join(sp.path, MEMORY_DIR, 'config.yaml'))) {
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) {
@@ -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
- .replace(/[.-]+$/, ''); // no trailing dot/dash
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.slice(0, 48);
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
- function runGit(args, cwd) {
185
- const result = spawnSync('git', args, { cwd, encoding: 'utf-8', timeout: 15000 });
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.10.1 on 2026-06-21T19:26:16.599Z
2
+ // Source: brainclaw v1.11.0 on 2026-06-24T07:26:01.488Z
3
3
  export const FACTS = {
4
- "version": "1.10.1",
5
- "generated_at": "2026-06-21T19:26:16.599Z",
4
+ "version": "1.11.0",
5
+ "generated_at": "2026-06-24T07:26:01.488Z",
6
6
  "tools": {
7
- "count": 66,
8
- "published_count": 65,
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.10.1",
3
- "generated_at": "2026-06-21T19:26:16.599Z",
2
+ "version": "1.11.0",
3
+ "generated_at": "2026-06-24T07:26:01.488Z",
4
4
  "tools": {
5
- "count": 66,
6
- "published_count": 65,
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
- There is no nested-project *boundary*, so the scope follows **where you run it**:
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 aggregated view that keeps **separate per-child indexes and federates
184
- them** at the root (a query spanning services without double-indexing).
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
@@ -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:35fd83b0d124df94`
108
- (updated 2026-06-20 for 1.10.0: Code Map tools added to the published surface —
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brainclaw",
3
- "version": "1.10.1",
3
+ "version": "1.11.0",
4
4
  "description": "Shared project memory for humans and coding agents.",
5
5
  "type": "module",
6
6
  "repository": {