brainclaw 0.22.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -78,6 +78,8 @@ import { runExplore } from './commands/explore.js';
78
78
  import { getInstalledBrainclawVersion } from './core/brainclaw-version.js';
79
79
  import { cleanOrphanFiles, memoryDir } from './core/io.js';
80
80
  import { initLogLevel, logger } from './core/logger.js';
81
+ import { resolveEffectiveCwd } from './core/store-resolution.js';
82
+ import { runSwitch } from './commands/switch.js';
81
83
  const program = new Command();
82
84
  function collect(value, previous) {
83
85
  return [...previous, value];
@@ -88,12 +90,19 @@ program
88
90
  .version(getInstalledBrainclawVersion())
89
91
  .option('--verbose', 'Show info-level log messages on stderr')
90
92
  .option('--debug', 'Show debug-level log messages on stderr')
93
+ .option('--cwd <path>', 'Override working directory for this invocation')
91
94
  .hook('preAction', (_thisCommand, actionCommand) => {
92
95
  const root = actionCommand.optsWithGlobals();
93
96
  initLogLevel({ verbose: root.verbose, debug: root.debug });
94
- const removed = cleanOrphanFiles(memoryDir());
97
+ // Resolve effective cwd (--cwd > BRAINCLAW_PROJECT > active-project > process.cwd)
98
+ const effectiveCwd = resolveEffectiveCwd({ explicitCwd: root.cwd });
99
+ if (effectiveCwd !== process.cwd()) {
100
+ // Store resolved cwd so commands can read it via optsWithGlobals().cwd
101
+ actionCommand.setOptionValue('cwd', effectiveCwd);
102
+ }
103
+ const removed = cleanOrphanFiles(memoryDir(effectiveCwd));
95
104
  if (removed > 0) {
96
- logger.info(`Cleaned ${removed} orphan lock/tmp file(s) in ${memoryDir()}`);
105
+ logger.info(`Cleaned ${removed} orphan lock/tmp file(s) in ${memoryDir(effectiveCwd)}`);
97
106
  }
98
107
  });
99
108
  // --- init ---
@@ -1090,6 +1099,21 @@ program
1090
1099
  .action((options) => {
1091
1100
  runExplore({ query: options.query });
1092
1101
  });
1102
+ program
1103
+ .command('switch [project]')
1104
+ .description('Set the active project for subsequent commands')
1105
+ .option('--list', 'List available projects in the workspace')
1106
+ .option('--clear', 'Clear the active project (revert to cwd)')
1107
+ .option('--json', 'Output as JSON')
1108
+ .action((project, options) => {
1109
+ const globalOpts = options.parent?.parent ? program.opts() : {};
1110
+ runSwitch(project, {
1111
+ list: options.list,
1112
+ clear: options.clear,
1113
+ json: options.json,
1114
+ cwd: globalOpts.cwd,
1115
+ });
1116
+ });
1093
1117
  program.parseAsync(process.argv).catch((err) => {
1094
1118
  console.error(err);
1095
1119
  process.exit(1);
@@ -29,7 +29,7 @@ import { validateMcpInput, validateMcpField } from '../core/input-validation.js'
29
29
  import { buildEstimationReport } from './estimation-report.js';
30
30
  import { detectAiAgent } from '../core/ai-agent-detection.js';
31
31
  import { checkGitPresence, scanGitRepos, parseRoots, parseRepoSelection, parseAgentSelection, runGlobalInstall, initReposAndConfigureAgents, readSetupState, ALL_KNOWN_AGENTS, } from './setup.js';
32
- import { resolveTargetStore, resolveStoreChain } from '../core/store-resolution.js';
32
+ import { resolveEffectiveCwd, resolveTargetStore, resolveStoreChain } from '../core/store-resolution.js';
33
33
  import { probeForQuickSetup, buildQuickSetupProbeResponse, buildOnboardingPreview } from '../core/setup-flow.js';
34
34
  import { ensureUserStore } from '../core/setup-state.js';
35
35
  import { readUnseenEvents, buildNotificationSummary } from '../core/event-log.js';
@@ -888,7 +888,7 @@ export class McpServerConnection {
888
888
  }
889
889
  }
890
890
  export function runMcp() {
891
- const cwd = process.cwd();
891
+ const cwd = resolveEffectiveCwd();
892
892
  if (!memoryExists(cwd)) {
893
893
  console.error('Project memory not initialized. Run `brainclaw init` first.');
894
894
  process.exit(1);
@@ -991,7 +991,7 @@ function getReviewAssignee(tags) {
991
991
  return undefined;
992
992
  }
993
993
  export function handleMcpReadToolCall(name, args = {}, context = {}) {
994
- const cwd = context.cwd ?? process.cwd();
994
+ const cwd = context.cwd ?? resolveEffectiveCwd();
995
995
  if (name === 'bclaw_get_context') {
996
996
  const result = buildContext({
997
997
  target: args.path,
@@ -0,0 +1,141 @@
1
+ import path from 'node:path';
2
+ import { loadActiveProject, saveActiveProject, clearActiveProject } from '../core/active-project.js';
3
+ import { memoryExists } from '../core/io.js';
4
+ import { resolveProjectRef, resolveWorkspaceRoot } from '../core/store-resolution.js';
5
+ import { scanNestedBrainclawProjects } from '../core/workspace-projects.js';
6
+ import { loadConfig } from '../core/config.js';
7
+ export function runSwitch(projectRef, options = {}) {
8
+ const cwd = options.cwd ?? process.cwd();
9
+ const wsRoot = resolveWorkspaceRoot(cwd);
10
+ if (!wsRoot) {
11
+ console.error('Error: no brainclaw workspace found. Run `brainclaw init` first.');
12
+ process.exit(1);
13
+ }
14
+ // --list: show available projects
15
+ if (options.list) {
16
+ listProjects(wsRoot, options.json ?? false);
17
+ return;
18
+ }
19
+ // --clear: remove active project
20
+ if (options.clear) {
21
+ clearActiveProject(wsRoot);
22
+ if (options.json) {
23
+ console.log(JSON.stringify({ cleared: true }));
24
+ }
25
+ else {
26
+ console.log('✔ Active project cleared. Commands will use current directory.');
27
+ }
28
+ return;
29
+ }
30
+ // No argument: show current active project
31
+ if (!projectRef) {
32
+ showCurrent(wsRoot, options.json ?? false);
33
+ return;
34
+ }
35
+ // Switch to project
36
+ const resolved = resolveProjectRef(projectRef, cwd);
37
+ if (!resolved) {
38
+ console.error(`Error: cannot resolve project "${projectRef}".`);
39
+ console.error('Use `brainclaw switch --list` to see available projects.');
40
+ process.exit(1);
41
+ }
42
+ let projectName;
43
+ try {
44
+ const config = loadConfig(resolved);
45
+ projectName = config.project_name;
46
+ }
47
+ catch {
48
+ // name is optional
49
+ }
50
+ saveActiveProject(wsRoot, {
51
+ path: resolved,
52
+ name: projectName,
53
+ switched_at: new Date().toISOString(),
54
+ switched_by: process.env.BRAINCLAW_AGENT_NAME ?? process.env.USER ?? 'unknown',
55
+ });
56
+ if (options.json) {
57
+ console.log(JSON.stringify({ switched: true, path: resolved, name: projectName }));
58
+ }
59
+ else {
60
+ const rel = path.relative(wsRoot, resolved) || '.';
61
+ console.log(`✔ Switched to ${projectName ? `"${projectName}" (${rel})` : rel}`);
62
+ }
63
+ }
64
+ function showCurrent(wsRoot, json) {
65
+ const active = loadActiveProject(wsRoot);
66
+ if (!active) {
67
+ if (json) {
68
+ console.log(JSON.stringify({ active: false }));
69
+ }
70
+ else {
71
+ console.log('No active project. Commands use current directory.');
72
+ console.log('Use `brainclaw switch <project>` to set one.');
73
+ }
74
+ return;
75
+ }
76
+ const rel = path.relative(wsRoot, active.path) || '.';
77
+ if (json) {
78
+ console.log(JSON.stringify({ active: true, ...active, relative_path: rel }));
79
+ }
80
+ else {
81
+ console.log(`Active project: ${active.name ? `"${active.name}" (${rel})` : rel}`);
82
+ console.log(` switched at: ${active.switched_at}`);
83
+ if (active.switched_by)
84
+ console.log(` switched by: ${active.switched_by}`);
85
+ }
86
+ }
87
+ function listProjects(wsRoot, json) {
88
+ const active = loadActiveProject(wsRoot);
89
+ const projects = [];
90
+ // Add workspace root itself
91
+ if (memoryExists(wsRoot)) {
92
+ try {
93
+ const config = loadConfig(wsRoot);
94
+ projects.push({
95
+ name: config.project_name,
96
+ path: wsRoot,
97
+ relative_path: '.',
98
+ active: active?.path === wsRoot,
99
+ });
100
+ }
101
+ catch {
102
+ projects.push({
103
+ path: wsRoot,
104
+ relative_path: '.',
105
+ active: active?.path === wsRoot,
106
+ });
107
+ }
108
+ }
109
+ // Discover child projects
110
+ const children = scanNestedBrainclawProjects(wsRoot, 4);
111
+ for (const child of children) {
112
+ const childPath = path.resolve(child.path);
113
+ if (childPath === wsRoot)
114
+ continue;
115
+ const rel = path.relative(wsRoot, childPath) || '.';
116
+ projects.push({
117
+ name: child.project_name,
118
+ path: childPath,
119
+ relative_path: rel,
120
+ active: active?.path === childPath,
121
+ });
122
+ }
123
+ if (json) {
124
+ console.log(JSON.stringify({ workspace: wsRoot, projects }, null, 2));
125
+ return;
126
+ }
127
+ if (projects.length === 0) {
128
+ console.log('No brainclaw projects found in this workspace.');
129
+ return;
130
+ }
131
+ console.log(`Projects in ${wsRoot}:\n`);
132
+ for (const p of projects) {
133
+ const marker = p.active ? '→ ' : ' ';
134
+ const name = p.name ? `${p.name} (${p.relative_path})` : p.relative_path;
135
+ console.log(`${marker}${name}`);
136
+ }
137
+ if (!active) {
138
+ console.log('\nNo active project. Use `brainclaw switch <project>` to set one.');
139
+ }
140
+ }
141
+ //# sourceMappingURL=switch.js.map
@@ -0,0 +1,50 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { MEMORY_DIR } from './io.js';
4
+ const ACTIVE_PROJECT_FILE = 'active-project.json';
5
+ /**
6
+ * Load the active project for a workspace.
7
+ * Returns undefined when no active project is set or the file is unreadable.
8
+ */
9
+ export function loadActiveProject(workspaceRoot) {
10
+ const filePath = path.join(workspaceRoot, MEMORY_DIR, ACTIVE_PROJECT_FILE);
11
+ if (!fs.existsSync(filePath)) {
12
+ return undefined;
13
+ }
14
+ try {
15
+ const raw = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
16
+ if (typeof raw.path !== 'string' || !raw.path) {
17
+ return undefined;
18
+ }
19
+ return {
20
+ path: raw.path,
21
+ name: typeof raw.name === 'string' ? raw.name : undefined,
22
+ switched_at: typeof raw.switched_at === 'string' ? raw.switched_at : new Date().toISOString(),
23
+ switched_by: typeof raw.switched_by === 'string' ? raw.switched_by : undefined,
24
+ };
25
+ }
26
+ catch {
27
+ return undefined;
28
+ }
29
+ }
30
+ /**
31
+ * Persist the active project for a workspace.
32
+ */
33
+ export function saveActiveProject(workspaceRoot, project) {
34
+ const dir = path.join(workspaceRoot, MEMORY_DIR);
35
+ if (!fs.existsSync(dir)) {
36
+ fs.mkdirSync(dir, { recursive: true });
37
+ }
38
+ const filePath = path.join(dir, ACTIVE_PROJECT_FILE);
39
+ fs.writeFileSync(filePath, JSON.stringify(project, null, 2) + '\n', 'utf-8');
40
+ }
41
+ /**
42
+ * Clear the active project (revert to process.cwd() default).
43
+ */
44
+ export function clearActiveProject(workspaceRoot) {
45
+ const filePath = path.join(workspaceRoot, MEMORY_DIR, ACTIVE_PROJECT_FILE);
46
+ if (fs.existsSync(filePath)) {
47
+ fs.unlinkSync(filePath);
48
+ }
49
+ }
50
+ //# sourceMappingURL=active-project.js.map
@@ -1,5 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
+ import YAML from 'yaml';
3
4
  import { MEMORY_DIR } from './io.js';
4
5
  const MULTI_PROJECT_MARKERS = [
5
6
  'pnpm-workspace.yaml',
@@ -11,6 +12,32 @@ const MULTI_PROJECT_MARKERS = [
11
12
  const MULTI_PROJECT_DIRS = ['apps', 'packages', 'services'];
12
13
  export function analyzeRepository(cwd) {
13
14
  const reasons = [];
15
+ // ── Signal 1: Existing brainclaw config already declares multi-project ──
16
+ const configPath = path.join(cwd, MEMORY_DIR, 'config.yaml');
17
+ if (fs.existsSync(configPath)) {
18
+ try {
19
+ const raw = YAML.parse(fs.readFileSync(configPath, 'utf-8'));
20
+ if (raw) {
21
+ const mode = raw.project_mode;
22
+ const projects = raw.projects;
23
+ const strategy = projects?.strategy ?? 'manual';
24
+ const knownCount = Array.isArray(projects?.known) ? projects.known.length : 0;
25
+ if (mode === 'multi-project' || strategy === 'folder' || knownCount > 0) {
26
+ reasons.push(`Existing brainclaw config: project_mode=${mode ?? 'auto'}, strategy=${strategy}` +
27
+ (knownCount > 0 ? `, ${knownCount} known project(s)` : ''));
28
+ }
29
+ }
30
+ }
31
+ catch {
32
+ // Config unreadable — fall through to heuristic detection.
33
+ }
34
+ }
35
+ // ── Signal 2: Child brainclaw stores (subdirectories with .brainclaw/) ──
36
+ const scan = scanChildStoresShallow(cwd);
37
+ if (scan.length > 0) {
38
+ reasons.push(`Found ${scan.length} child brainclaw store(s): ${scan.join(', ')}`);
39
+ }
40
+ // ── Signal 3: Classic monorepo / workspace markers ──
14
41
  for (const marker of MULTI_PROJECT_MARKERS) {
15
42
  if (fs.existsSync(path.join(cwd, marker))) {
16
43
  reasons.push(`Found workspace marker: ${marker}`);
@@ -46,6 +73,33 @@ export function analyzeRepository(cwd) {
46
73
  reasons: ['No monorepo or multi-project markers detected'],
47
74
  };
48
75
  }
76
+ /**
77
+ * Quick depth-1 scan for subdirectories that contain a .brainclaw/ store.
78
+ * Returns relative paths of child stores found.
79
+ */
80
+ function scanChildStoresShallow(cwd) {
81
+ const childStores = [];
82
+ let entries;
83
+ try {
84
+ entries = fs.readdirSync(cwd, { withFileTypes: true });
85
+ }
86
+ catch {
87
+ return childStores;
88
+ }
89
+ for (const entry of entries) {
90
+ if (!entry.isDirectory())
91
+ continue;
92
+ if (SKIP_DIRS.has(entry.name))
93
+ continue;
94
+ if (entry.name.startsWith('.'))
95
+ continue;
96
+ const childBrainclaw = path.join(cwd, entry.name, MEMORY_DIR);
97
+ if (fs.existsSync(childBrainclaw)) {
98
+ childStores.push(entry.name);
99
+ }
100
+ }
101
+ return childStores;
102
+ }
49
103
  /** Markers whose presence indicates a service/project boundary worth initialising. */
50
104
  const SERVICE_MARKERS = [
51
105
  'package.json',
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
+ import { loadActiveProject } from './active-project.js';
4
5
  import { loadConfig } from './config.js';
5
6
  import { MEMORY_DIR } from './io.js';
6
7
  import { summarizeWorkspaceProjects } from './workspace-projects.js';
@@ -84,6 +85,98 @@ export function resolveTargetStore(cwd = process.cwd(), target = 'local', option
84
85
  const match = chain.find((s) => s.role === 'user');
85
86
  return match?.cwd ?? os.homedir();
86
87
  }
88
+ /**
89
+ * Single source of truth for the effective working directory.
90
+ *
91
+ * Priority:
92
+ * 1. explicitCwd (--cwd flag)
93
+ * 2. BRAINCLAW_PROJECT env var → resolved by name/path from workspace
94
+ * 3. active-project.json in workspace root
95
+ * 4. process.cwd()
96
+ */
97
+ export function resolveEffectiveCwd(options = {}) {
98
+ // 1. Explicit --cwd flag
99
+ if (options.explicitCwd) {
100
+ return path.resolve(options.explicitCwd);
101
+ }
102
+ // 2. BRAINCLAW_PROJECT env var
103
+ const envProject = process.env.BRAINCLAW_PROJECT;
104
+ if (envProject) {
105
+ const resolved = resolveProjectRef(envProject, process.cwd(), options.storeChainOptions);
106
+ if (resolved)
107
+ return resolved;
108
+ }
109
+ // 3. active-project.json from workspace root
110
+ const wsRoot = resolveWorkspaceRoot(process.cwd(), options.storeChainOptions);
111
+ if (wsRoot) {
112
+ const active = loadActiveProject(wsRoot);
113
+ if (active && fs.existsSync(path.join(active.path, MEMORY_DIR, 'config.yaml'))) {
114
+ return active.path;
115
+ }
116
+ }
117
+ // 4. Default
118
+ return process.cwd();
119
+ }
120
+ /**
121
+ * Find the workspace root (farthest store in the chain, or the one with
122
+ * role=workspace). Returns undefined when no store exists.
123
+ */
124
+ export function resolveWorkspaceRoot(cwd = process.cwd(), options = {}) {
125
+ const chain = resolveStoreChain(cwd, options);
126
+ if (chain.length === 0)
127
+ return undefined;
128
+ const ws = chain.find((s) => s.role === 'workspace');
129
+ return ws?.cwd ?? chain[chain.length - 1].cwd;
130
+ }
131
+ /**
132
+ * Resolve a project reference (name or relative path) to an absolute path.
133
+ * Returns undefined when the reference cannot be resolved to a valid brainclaw project.
134
+ */
135
+ export function resolveProjectRef(ref, cwd = process.cwd(), storeChainOptions) {
136
+ const wsRoot = resolveWorkspaceRoot(cwd, storeChainOptions);
137
+ if (!wsRoot)
138
+ return undefined;
139
+ // Try as absolute path
140
+ if (path.isAbsolute(ref)) {
141
+ return fs.existsSync(path.join(ref, MEMORY_DIR, 'config.yaml')) ? ref : undefined;
142
+ }
143
+ // Try as relative path from workspace root
144
+ const asPath = path.resolve(wsRoot, ref);
145
+ if (fs.existsSync(path.join(asPath, MEMORY_DIR, 'config.yaml'))) {
146
+ return asPath;
147
+ }
148
+ // Try by project name: scan child stores for matching project_name
149
+ const chain = resolveStoreChain(wsRoot, storeChainOptions);
150
+ for (const store of chain) {
151
+ if (store.cwd === wsRoot)
152
+ continue; // skip workspace itself
153
+ try {
154
+ const config = loadConfig(store.cwd);
155
+ if (config.project_name === ref)
156
+ return store.cwd;
157
+ }
158
+ catch {
159
+ // skip unreadable configs
160
+ }
161
+ }
162
+ // Try discovering child projects by scanning filesystem
163
+ try {
164
+ const wsConfig = loadConfig(wsRoot);
165
+ const summary = summarizeWorkspaceProjects(wsRoot, wsConfig);
166
+ for (const project of summary.discovered_projects) {
167
+ const projectPath = path.resolve(wsRoot, project.path);
168
+ if (project.project_name === ref) {
169
+ if (fs.existsSync(path.join(projectPath, MEMORY_DIR, 'config.yaml'))) {
170
+ return projectPath;
171
+ }
172
+ }
173
+ }
174
+ }
175
+ catch {
176
+ // fall through
177
+ }
178
+ return undefined;
179
+ }
87
180
  /**
88
181
  * Resolve the most specific child store that should answer a context request.
89
182
  *
@@ -104,6 +197,13 @@ export function resolveContextStoreCwd(cwd = process.cwd(), target) {
104
197
  if (!absoluteTarget) {
105
198
  return cwd;
106
199
  }
200
+ // ── Fast path: walk from target upward to cwd looking for a child store ──
201
+ // This works regardless of project_mode or strategy configuration.
202
+ const childStore = findClosestStoreBelow(absoluteTarget, primary.cwd);
203
+ if (childStore) {
204
+ return childStore;
205
+ }
206
+ // ── Fallback: use workspace project discovery (folder mode, registry, etc.) ──
107
207
  let config;
108
208
  try {
109
209
  config = loadConfig(primary.cwd);
@@ -127,6 +227,37 @@ export function resolveContextStoreCwd(cwd = process.cwd(), target) {
127
227
  }
128
228
  return cwd;
129
229
  }
230
+ /**
231
+ * Walk from `target` upward toward `ceiling` (exclusive), returning the first
232
+ * directory that contains a `.brainclaw/config.yaml`. Returns undefined when
233
+ * no child store is found between target and ceiling.
234
+ *
235
+ * This deliberately bypasses workspace project discovery so that child stores
236
+ * are resolved even when the parent config is set to auto/manual mode.
237
+ */
238
+ function findClosestStoreBelow(target, ceiling) {
239
+ const resolvedCeiling = path.resolve(ceiling);
240
+ // If target is a file, start from its parent directory
241
+ let current;
242
+ try {
243
+ current = fs.statSync(target).isDirectory() ? path.resolve(target) : path.resolve(path.dirname(target));
244
+ }
245
+ catch {
246
+ // Target doesn't exist on disk — try its parent as a directory
247
+ current = path.resolve(path.dirname(target));
248
+ }
249
+ while (current !== resolvedCeiling) {
250
+ const configPath = path.join(current, MEMORY_DIR, 'config.yaml');
251
+ if (fs.existsSync(configPath)) {
252
+ return current;
253
+ }
254
+ const parent = path.dirname(current);
255
+ if (parent === current)
256
+ break; // filesystem root
257
+ current = parent;
258
+ }
259
+ return undefined;
260
+ }
130
261
  /**
131
262
  * Return true if `dir` is at or below `ancestor` in the filesystem hierarchy.
132
263
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brainclaw",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "description": "Shared project memory for humans and coding agents.",
5
5
  "type": "module",
6
6
  "bin": {