brainclaw 1.10.2 → 1.11.1

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.
@@ -1095,6 +1095,98 @@ function replaceBrainclawHooks(entries, canonical) {
1095
1095
  kept.push(canonical);
1096
1096
  return kept;
1097
1097
  }
1098
+ /**
1099
+ * Canonical Claude Code session-hook commands.
1100
+ *
1101
+ * `--hook` (pln#596) makes session-start / context-diff / session-end degrade to
1102
+ * exit 0 + ~/.brainclaw/hook.log on failure instead of hard-erroring the prompt
1103
+ * loop (trp#917). `2>/dev/null` is kept to suppress incidental stderr noise —
1104
+ * the actionable diagnostic now goes to hook.log, not stderr, so it survives the
1105
+ * redirect (the bug was the non-zero EXIT, not the stream).
1106
+ */
1107
+ function buildClaudeCodeHookCommands(bclawBin) {
1108
+ return {
1109
+ session: `f=.claude/.bclaw-session; if [ ! -f "$f" ]; then touch "$f"; ${bclawBin} session-start --include-context --hook 2>/dev/null; else ${bclawBin} context-diff --hook 2>/dev/null; fi`,
1110
+ stop: `rm -f .claude/.bclaw-session; ${bclawBin} session-end --auto-release --reflect --reflect-handoff --dispatch-review --hook 2>/dev/null`,
1111
+ checkEvents: `${bclawBin} check-events 2>/dev/null`,
1112
+ };
1113
+ }
1114
+ /**
1115
+ * Sanitize the brainclaw session hooks in ONE Claude Code settings file: collapse
1116
+ * every recognized brainclaw hook (across UserPromptSubmit / Stop / PostToolUse)
1117
+ * to a single canonical entry, repairing stale/broken forms (e.g. the legacy
1118
+ * `node session-start` with the cli.js arg dropped, or dead install paths).
1119
+ *
1120
+ * Only touches events that ALREADY contain a brainclaw hook — it never injects
1121
+ * hooks into a file (or event) that lacked them, so user-scope settings without
1122
+ * brainclaw hooks stay untouched. This is the cross-scope counterpart to
1123
+ * `ensureClaudeCodeSettings`, which only rewrites the cwd project file (pln#596 /
1124
+ * trp#918: setup's git-repo discovery never reaches the launch dir or user scope,
1125
+ * so broken hooks accumulate exactly where the agent executes them).
1126
+ */
1127
+ export function fixClaudeCodeHooksInFile(filePath) {
1128
+ if (!fs.existsSync(filePath))
1129
+ return { filePath, existed: false, changed: false, collapsed: 0 };
1130
+ const existing = readJsonObject(filePath); // returns {} for missing, undefined for unparseable
1131
+ if (existing === undefined)
1132
+ return { filePath, existed: true, changed: false, collapsed: 0 };
1133
+ const hooksObj = isJsonObject(existing.hooks) ? existing.hooks : undefined;
1134
+ if (!hooksObj)
1135
+ return { filePath, existed: true, changed: false, collapsed: 0 };
1136
+ const bclawBin = getBclawCliParts().map(quoteShellArg).join(' ');
1137
+ const cmds = buildClaudeCodeHookCommands(bclawBin);
1138
+ const countBrainclawHooks = (arr) => Array.isArray(arr)
1139
+ ? arr.reduce((n, e) => n + (isJsonObject(e) && Array.isArray(e.hooks)
1140
+ ? e.hooks.filter((h) => isJsonObject(h) && typeof h.command === 'string' && isBrainclawHookCommand(h.command)).length
1141
+ : 0), 0)
1142
+ : 0;
1143
+ const events = [
1144
+ ['UserPromptSubmit', buildCommandHookEntry(cmds.session)],
1145
+ ['Stop', buildCommandHookEntry(cmds.stop)],
1146
+ ['PostToolUse', buildMatchedCommandHookEntry('mcp__brainclaw__', cmds.checkEvents)],
1147
+ ];
1148
+ const nextHooks = { ...hooksObj };
1149
+ let collapsed = 0;
1150
+ let touched = false;
1151
+ for (const [event, canonical] of events) {
1152
+ const arr = Array.isArray(hooksObj[event]) ? hooksObj[event] : undefined;
1153
+ if (!arr)
1154
+ continue;
1155
+ const before = countBrainclawHooks(arr);
1156
+ if (before === 0)
1157
+ continue; // never add hooks to an event that had none
1158
+ nextHooks[event] = replaceBrainclawHooks(arr, canonical);
1159
+ collapsed += Math.max(0, before - 1); // N brainclaw entries → 1 canonical
1160
+ touched = true;
1161
+ }
1162
+ if (!touched)
1163
+ return { filePath, existed: true, changed: false, collapsed: 0 };
1164
+ const { updated } = writeJsonFileIfChanged(filePath, { ...existing, hooks: nextHooks });
1165
+ return { filePath, existed: true, changed: updated, collapsed };
1166
+ }
1167
+ /**
1168
+ * Run `fixClaudeCodeHooksInFile` across every Claude Code settings scope that can
1169
+ * carry brainclaw hooks: user-scope (`~/.claude/settings*.json`) and the cwd
1170
+ * project (`<cwd>/.claude/settings*.json`). Independent of git-repo discovery.
1171
+ */
1172
+ export function fixClaudeCodeHooksAllScopes(cwd, homeDir) {
1173
+ const candidates = [
1174
+ path.join(homeDir, '.claude', 'settings.json'),
1175
+ path.join(homeDir, '.claude', 'settings.local.json'),
1176
+ path.join(cwd, '.claude', 'settings.json'),
1177
+ path.join(cwd, '.claude', 'settings.local.json'),
1178
+ ];
1179
+ const seen = new Set();
1180
+ const results = [];
1181
+ for (const candidate of candidates) {
1182
+ const resolved = path.resolve(candidate);
1183
+ if (seen.has(resolved))
1184
+ continue;
1185
+ seen.add(resolved);
1186
+ results.push(fixClaudeCodeHooksInFile(resolved));
1187
+ }
1188
+ return results;
1189
+ }
1098
1190
  export function ensureProjectDevDependency(cwd) {
1099
1191
  const filePath = path.join(cwd, 'package.json');
1100
1192
  if (!fs.existsSync(filePath))
@@ -1242,10 +1334,8 @@ export function ensureClaudeCodeSettings(cwd) {
1242
1334
  // binary resolution succeeded (hidden by 2>/dev/null).
1243
1335
  const hooks = isJsonObject(existing.hooks) ? { ...existing.hooks } : {};
1244
1336
  const bclawBin = getBclawCliParts().map(quoteShellArg).join(' ');
1245
- const sessionCommand = `f=.claude/.bclaw-session; if [ ! -f "$f" ]; then touch "$f"; ${bclawBin} session-start --include-context 2>/dev/null; else ${bclawBin} context-diff 2>/dev/null; fi`;
1246
- const stopCommand = `rm -f .claude/.bclaw-session; ${bclawBin} session-end --auto-release --reflect --reflect-handoff --dispatch-review 2>/dev/null`;
1247
1337
  // PostToolUse — check for unseen events after any brainclaw MCP tool call
1248
- const checkEventsCommand = `${bclawBin} check-events 2>/dev/null`;
1338
+ const { session: sessionCommand, stop: stopCommand, checkEvents: checkEventsCommand } = buildClaudeCodeHookCommands(bclawBin);
1249
1339
  hooks.UserPromptSubmit = replaceBrainclawHooks(Array.isArray(hooks.UserPromptSubmit) ? hooks.UserPromptSubmit : [], buildCommandHookEntry(sessionCommand));
1250
1340
  hooks.Stop = replaceBrainclawHooks(Array.isArray(hooks.Stop) ? hooks.Stop : [], buildCommandHookEntry(stopCommand));
1251
1341
  hooks.PostToolUse = replaceBrainclawHooks(Array.isArray(hooks.PostToolUse) ? hooks.PostToolUse : [], buildMatchedCommandHookEntry('mcp__brainclaw__', checkEventsCommand));
@@ -314,6 +314,18 @@ export function resolveCurrentAgentIdentity(cwd, preferredDirName, _homeDir) {
314
314
  // In multi-agent setups this always resolves to the wrong agent.
315
315
  // The field remains in config for display (status, doctor) and for resolveExistingCurrentAgent
316
316
  // which is used during setup/init only.
317
+ // Single-registered-agent fallback (pln#596). When there is NO identity signal
318
+ // at all — no env id/name AND no detected native agent — but exactly one agent
319
+ // is registered in this scope, resolving it is unambiguous. This is the
320
+ // solo-dev / single-CLI case (a fresh hook with no BRAINCLAW_AGENT_NAME). The
321
+ // pln#562 guard against config.current_agent only matters at ≥2 agents, so this
322
+ // preserves multi-agent safety: ≥2 registered → still falls through to undefined.
323
+ if (!envAgentId && !envAgentName && !detected) {
324
+ const registered = listAgentIdentities(cwd, preferredDirName);
325
+ if (registered.length === 1) {
326
+ return registered[0];
327
+ }
328
+ }
317
329
  return undefined;
318
330
  }
319
331
  export function resolveRegisteredAgentIdentity(options = {}) {
@@ -416,7 +428,10 @@ export function requireRegisteredAgentIdentity(options = {}) {
416
428
  throw new AgentIdentityResolutionError(`Environment agent '${normalizedEnv}' is not registered.`, { agent_name: normalizedEnv });
417
429
  }
418
430
  }
419
- throw new AgentIdentityResolutionError('No registered agent identity resolved. Use --agent/--agent-id or configure a current agent with `brainclaw register-agent <name> --set-current`.');
431
+ throw new AgentIdentityResolutionError('No registered agent identity resolved. Pass `--agent <name>` or `--agent-id <id>`, '
432
+ + 'set $BRAINCLAW_AGENT_NAME, or register an agent with `brainclaw register-agent <name>` '
433
+ + '— a single registered agent is then resolved automatically. '
434
+ + '(`--set-current` alone does NOT affect resolution.)');
420
435
  }
421
436
  /**
422
437
  * Resolve agent identity for session start, returning both the resolved identity and whether
@@ -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
@@ -0,0 +1,43 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ /**
5
+ * Append a one-line, timestamped diagnostic to ~/.brainclaw/hook.log.
6
+ *
7
+ * brainclaw session hooks (UserPromptSubmit / Stop) historically wrapped every
8
+ * CLI call in `2>/dev/null`, which turned an actionable failure (e.g. "no
9
+ * registered agent identity resolved") into a contentless "hook error: No
10
+ * stderr output" on every prompt (trp#917). Hook-mode commands now degrade to
11
+ * exit 0 and drop a line here instead, so the failure is silent to the agent's
12
+ * prompt loop but still debuggable.
13
+ *
14
+ * Best-effort and never throws — a logging failure must not break a hook.
15
+ */
16
+ const MAX_LOG_BYTES = 256 * 1024;
17
+ export function hookLogPath(homeDir = os.homedir()) {
18
+ return path.join(homeDir, '.brainclaw', 'hook.log');
19
+ }
20
+ export function logHookDiagnostic(message, homeDir = os.homedir()) {
21
+ try {
22
+ const file = hookLogPath(homeDir);
23
+ fs.mkdirSync(path.dirname(file), { recursive: true });
24
+ // Size-cap: when the log grows past the cap, keep only the tail so it never
25
+ // balloons unbounded across thousands of prompts.
26
+ try {
27
+ const stat = fs.statSync(file);
28
+ if (stat.size > MAX_LOG_BYTES) {
29
+ const tail = fs.readFileSync(file, 'utf-8').slice(-Math.floor(MAX_LOG_BYTES / 2));
30
+ fs.writeFileSync(file, tail, 'utf-8');
31
+ }
32
+ }
33
+ catch {
34
+ /* no existing file — nothing to truncate */
35
+ }
36
+ const line = `${new Date().toISOString()} ${message.replace(/\s+/g, ' ').trim()}\n`;
37
+ fs.appendFileSync(file, line, 'utf-8');
38
+ }
39
+ catch {
40
+ /* best-effort — never break a hook over logging */
41
+ }
42
+ }
43
+ //# sourceMappingURL=hook-log.js.map
@@ -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