brainclaw 0.28.0 → 0.29.2
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 +11 -0
- package/dist/commands/mcp.js +49 -0
- package/dist/commands/switch.js +31 -8
- package/dist/commands/who.js +96 -0
- package/dist/core/bootstrap.js +61 -10
- package/dist/core/context.js +73 -0
- package/dist/core/event-log.js +1 -0
- package/dist/core/identity.js +156 -17
- package/dist/core/repo-analysis.js +67 -0
- package/dist/core/schema.js +55 -15
- package/dist/core/store-resolution.js +14 -4
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -1157,6 +1157,17 @@ program
|
|
|
1157
1157
|
cwd: globalOpts.cwd,
|
|
1158
1158
|
});
|
|
1159
1159
|
});
|
|
1160
|
+
program
|
|
1161
|
+
.command('who')
|
|
1162
|
+
.description('Show active agent sessions on this workspace')
|
|
1163
|
+
.option('--json', 'Output as JSON')
|
|
1164
|
+
.option('--all', 'Include stale sessions')
|
|
1165
|
+
.option('--gc', 'Remove stale sessions')
|
|
1166
|
+
.action(async (options) => {
|
|
1167
|
+
const globalOpts = program.opts();
|
|
1168
|
+
const { runWho } = await import('./commands/who.js');
|
|
1169
|
+
runWho({ json: options.json, all: options.all, gc: options.gc, cwd: globalOpts.cwd });
|
|
1170
|
+
});
|
|
1160
1171
|
program.parseAsync(process.argv).catch((err) => {
|
|
1161
1172
|
console.error(err);
|
|
1162
1173
|
process.exit(1);
|
package/dist/commands/mcp.js
CHANGED
|
@@ -9,6 +9,7 @@ import { buildContext, renderContextMarkdown, renderContextPromptTemplate } from
|
|
|
9
9
|
import { buildExecutionContext, renderExecutionContextSummary } from '../core/execution-context.js';
|
|
10
10
|
import { checkBrainclawInstallableUpdate, renderBrainclawInstallableUpdateNotice } from '../core/brainclaw-version.js';
|
|
11
11
|
import { loadConfig } from '../core/config.js';
|
|
12
|
+
import { loadAllSessions, gcStaleSessions } from '../core/identity.js';
|
|
12
13
|
import { loadState, persistState, saveState } from '../core/state.js';
|
|
13
14
|
import { memoryExists } from '../core/io.js';
|
|
14
15
|
import { generateCandidateIdWithLabel, listArchivedCandidates, listCandidates, saveCandidate } from '../core/candidates.js';
|
|
@@ -307,6 +308,17 @@ export const MCP_READ_TOOLS = [
|
|
|
307
308
|
},
|
|
308
309
|
},
|
|
309
310
|
},
|
|
311
|
+
{
|
|
312
|
+
name: 'bclaw_who',
|
|
313
|
+
description: 'List all active agent sessions on this workspace. Shows user, agent, active project, claims, and last activity for each session.',
|
|
314
|
+
inputSchema: {
|
|
315
|
+
type: 'object',
|
|
316
|
+
properties: {
|
|
317
|
+
all: { type: 'boolean', description: 'Include stale sessions (default: false).' },
|
|
318
|
+
gc: { type: 'boolean', description: 'Remove stale sessions and return count.' },
|
|
319
|
+
},
|
|
320
|
+
},
|
|
321
|
+
},
|
|
310
322
|
];
|
|
311
323
|
const MCP_WRITE_TOOLS = [
|
|
312
324
|
{
|
|
@@ -1767,6 +1779,42 @@ export function handleMcpReadToolCall(name, args = {}, context = {}) {
|
|
|
1767
1779
|
structuredContent: { agent: currentAgentName, conflicts, total: conflicts.length, schema_version: SCHEMA_VERSION },
|
|
1768
1780
|
};
|
|
1769
1781
|
}
|
|
1782
|
+
if (name === 'bclaw_who') {
|
|
1783
|
+
// loadAllSessions and gcStaleSessions imported at top of file
|
|
1784
|
+
const doGc = args.gc === true;
|
|
1785
|
+
const showAll = args.all === true;
|
|
1786
|
+
if (doGc) {
|
|
1787
|
+
const removed = gcStaleSessions(cwd);
|
|
1788
|
+
return {
|
|
1789
|
+
content: [{ type: 'text', text: `✔ Removed ${removed} stale session(s).` }],
|
|
1790
|
+
structuredContent: { gc: true, removed, schema_version: SCHEMA_VERSION },
|
|
1791
|
+
};
|
|
1792
|
+
}
|
|
1793
|
+
const allSessions = loadAllSessions(cwd);
|
|
1794
|
+
const ttlMs = 4 * 60 * 60 * 1000;
|
|
1795
|
+
const now = Date.now();
|
|
1796
|
+
const sessions = showAll
|
|
1797
|
+
? allSessions
|
|
1798
|
+
: allSessions.filter((s) => (now - Date.parse(s.last_seen_at)) <= ttlMs);
|
|
1799
|
+
const activeClaims = listClaims(cwd).filter((c) => c.status === 'active');
|
|
1800
|
+
const output = sessions.map((s) => ({
|
|
1801
|
+
session_id: s.session_id,
|
|
1802
|
+
user: s.user ?? 'unknown',
|
|
1803
|
+
agent: s.agent,
|
|
1804
|
+
agent_id: s.agent_id,
|
|
1805
|
+
project: s.active_project?.name ?? s.active_project?.path ?? null,
|
|
1806
|
+
claims: activeClaims.filter((c) => c.agent_id === s.agent_id).length,
|
|
1807
|
+
last_seen_at: s.last_seen_at,
|
|
1808
|
+
stale: (now - Date.parse(s.last_seen_at)) > ttlMs,
|
|
1809
|
+
}));
|
|
1810
|
+
const lines = sessions.length === 0
|
|
1811
|
+
? 'No active sessions.'
|
|
1812
|
+
: output.map((s) => `${s.user} | ${s.agent} | ${s.project ?? '(root)'} | ${s.claims} claims | ${s.stale ? 'stale' : 'active'}`).join('\n');
|
|
1813
|
+
return {
|
|
1814
|
+
content: [{ type: 'text', text: lines }],
|
|
1815
|
+
structuredContent: { sessions: output, total: output.length, schema_version: SCHEMA_VERSION },
|
|
1816
|
+
};
|
|
1817
|
+
}
|
|
1770
1818
|
if (name === 'bclaw_doctor') {
|
|
1771
1819
|
// Capture doctor JSON output by redirecting console.log
|
|
1772
1820
|
const captured = [];
|
|
@@ -2188,6 +2236,7 @@ export async function executeMcpToolCall(payload) {
|
|
|
2188
2236
|
id: claimId,
|
|
2189
2237
|
agent: identity.agent,
|
|
2190
2238
|
agent_id: identity.agent_id,
|
|
2239
|
+
user: process.env.USER || process.env.USERNAME || undefined,
|
|
2191
2240
|
project_id: identity.project_id,
|
|
2192
2241
|
host_id: identity.host_id,
|
|
2193
2242
|
session_id: identity.session_id,
|
package/dist/commands/switch.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { loadActiveProject, saveActiveProject, clearActiveProject } from '../core/active-project.js';
|
|
3
|
+
import { loadCurrentSession, saveCurrentSession } from '../core/identity.js';
|
|
3
4
|
import { memoryExists } from '../core/io.js';
|
|
4
5
|
import { resolveProjectRef, resolveWorkspaceRoot } from '../core/store-resolution.js';
|
|
5
6
|
import { scanNestedBrainclawProjects } from '../core/workspace-projects.js';
|
|
@@ -18,6 +19,11 @@ export function runSwitch(projectRef, options = {}) {
|
|
|
18
19
|
}
|
|
19
20
|
// --clear: remove active project
|
|
20
21
|
if (options.clear) {
|
|
22
|
+
const session = loadCurrentSession(cwd);
|
|
23
|
+
if (session?.active_project) {
|
|
24
|
+
const { active_project: _removed, ...rest } = session;
|
|
25
|
+
saveCurrentSession(rest, cwd);
|
|
26
|
+
}
|
|
21
27
|
clearActiveProject(wsRoot);
|
|
22
28
|
if (options.json) {
|
|
23
29
|
console.log(JSON.stringify({ cleared: true }));
|
|
@@ -47,18 +53,35 @@ export function runSwitch(projectRef, options = {}) {
|
|
|
47
53
|
catch {
|
|
48
54
|
// name is optional
|
|
49
55
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
+
const now = new Date().toISOString();
|
|
57
|
+
const session = loadCurrentSession(cwd);
|
|
58
|
+
const scopedToSession = options.session ?? !!session;
|
|
59
|
+
let scope;
|
|
60
|
+
if (scopedToSession && session) {
|
|
61
|
+
// Write to session state — only this agent sees this switch
|
|
62
|
+
saveCurrentSession({
|
|
63
|
+
...session,
|
|
64
|
+
active_project: { path: resolved, name: projectName, switched_at: now },
|
|
65
|
+
}, cwd);
|
|
66
|
+
scope = 'session';
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
// Fall back to global active-project.json
|
|
70
|
+
saveActiveProject(wsRoot, {
|
|
71
|
+
path: resolved,
|
|
72
|
+
name: projectName,
|
|
73
|
+
switched_at: now,
|
|
74
|
+
switched_by: process.env.BRAINCLAW_AGENT_NAME ?? process.env.USER ?? 'unknown',
|
|
75
|
+
});
|
|
76
|
+
scope = 'global';
|
|
77
|
+
}
|
|
56
78
|
if (options.json) {
|
|
57
|
-
console.log(JSON.stringify({ switched: true, path: resolved, name: projectName }));
|
|
79
|
+
console.log(JSON.stringify({ switched: true, path: resolved, name: projectName, scope }));
|
|
58
80
|
}
|
|
59
81
|
else {
|
|
60
82
|
const rel = path.relative(wsRoot, resolved) || '.';
|
|
61
|
-
|
|
83
|
+
const scopeHint = scope === 'session' ? ' (session-scoped)' : '';
|
|
84
|
+
console.log(`✔ Switched to ${projectName ? `"${projectName}" (${rel})` : rel}${scopeHint}`);
|
|
62
85
|
}
|
|
63
86
|
}
|
|
64
87
|
function showCurrent(wsRoot, json) {
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { loadAllSessions, gcStaleSessions } from '../core/identity.js';
|
|
2
|
+
import { listClaims } from '../core/claims.js';
|
|
3
|
+
export function runWho(options = {}) {
|
|
4
|
+
const cwd = options.cwd ?? process.cwd();
|
|
5
|
+
if (options.gc) {
|
|
6
|
+
const removed = gcStaleSessions(cwd);
|
|
7
|
+
if (options.json) {
|
|
8
|
+
console.log(JSON.stringify({ gc: true, removed }));
|
|
9
|
+
}
|
|
10
|
+
else {
|
|
11
|
+
console.log(`✔ Removed ${removed} stale session(s).`);
|
|
12
|
+
}
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
const allSessions = loadAllSessions(cwd);
|
|
16
|
+
const ttlMs = 4 * 60 * 60 * 1000; // 4h default
|
|
17
|
+
const now = Date.now();
|
|
18
|
+
const sessions = options.all
|
|
19
|
+
? allSessions
|
|
20
|
+
: allSessions.filter(s => (now - Date.parse(s.last_seen_at)) <= ttlMs);
|
|
21
|
+
const activeClaims = listClaims(cwd).filter(c => c.status === 'active');
|
|
22
|
+
const enriched = sessions.map(s => {
|
|
23
|
+
const age = now - Date.parse(s.last_seen_at);
|
|
24
|
+
const stale = age > ttlMs;
|
|
25
|
+
const dead = s.pid ? !isPidAlive(s.pid) : false;
|
|
26
|
+
const status = dead ? 'dead' : stale ? 'stale' : 'active';
|
|
27
|
+
return {
|
|
28
|
+
session_id: s.session_id,
|
|
29
|
+
user: s.user ?? 'unknown',
|
|
30
|
+
agent: s.agent,
|
|
31
|
+
agent_id: s.agent_id,
|
|
32
|
+
host_id: s.host_id,
|
|
33
|
+
project: s.active_project?.name ?? s.active_project?.path ?? null,
|
|
34
|
+
claims: activeClaims.filter(c => c.agent_id === s.agent_id).length,
|
|
35
|
+
started_at: s.started_at,
|
|
36
|
+
last_seen_at: s.last_seen_at,
|
|
37
|
+
status,
|
|
38
|
+
pid: s.pid,
|
|
39
|
+
};
|
|
40
|
+
});
|
|
41
|
+
if (options.json) {
|
|
42
|
+
console.log(JSON.stringify({ sessions: enriched, total: enriched.length }, null, 2));
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (enriched.length === 0) {
|
|
46
|
+
console.log('No active sessions.');
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
// Table header
|
|
50
|
+
const header = ['USER', 'AGENT', 'PROJECT', 'CLAIMS', 'STATUS', 'LAST SEEN'];
|
|
51
|
+
const rows = [];
|
|
52
|
+
for (const s of enriched) {
|
|
53
|
+
const age = now - Date.parse(s.last_seen_at);
|
|
54
|
+
const project = s.project ?? '(workspace root)';
|
|
55
|
+
rows.push([
|
|
56
|
+
s.user,
|
|
57
|
+
s.agent,
|
|
58
|
+
project.length > 25 ? project.slice(0, 22) + '...' : project,
|
|
59
|
+
String(s.claims),
|
|
60
|
+
s.status,
|
|
61
|
+
formatAge(age),
|
|
62
|
+
]);
|
|
63
|
+
}
|
|
64
|
+
// Calculate column widths
|
|
65
|
+
const widths = header.map((h, i) => Math.max(h.length, ...rows.map(r => r[i].length)));
|
|
66
|
+
console.log('Active sessions:\n');
|
|
67
|
+
console.log(' ' + header.map((h, i) => h.padEnd(widths[i])).join(' '));
|
|
68
|
+
console.log(' ' + widths.map(w => '─'.repeat(w)).join(' '));
|
|
69
|
+
for (const row of rows) {
|
|
70
|
+
console.log(' ' + row.map((cell, i) => cell.padEnd(widths[i])).join(' '));
|
|
71
|
+
}
|
|
72
|
+
console.log(`\n${enriched.length} session(s).`);
|
|
73
|
+
}
|
|
74
|
+
function isPidAlive(pid) {
|
|
75
|
+
try {
|
|
76
|
+
// process.kill(pid, 0) throws if process doesn't exist
|
|
77
|
+
process.kill(pid, 0);
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
function formatAge(ms) {
|
|
85
|
+
const minutes = Math.floor(ms / 60_000);
|
|
86
|
+
if (minutes < 1)
|
|
87
|
+
return 'just now';
|
|
88
|
+
if (minutes < 60)
|
|
89
|
+
return `${minutes}min ago`;
|
|
90
|
+
const hours = Math.floor(minutes / 60);
|
|
91
|
+
if (hours < 24)
|
|
92
|
+
return `${hours}h ago`;
|
|
93
|
+
const days = Math.floor(hours / 24);
|
|
94
|
+
return `${days}d ago`;
|
|
95
|
+
}
|
|
96
|
+
//# sourceMappingURL=who.js.map
|
package/dist/core/bootstrap.js
CHANGED
|
@@ -8,7 +8,7 @@ import { resolveEntityDir } from './io.js';
|
|
|
8
8
|
import { mutate } from './mutation-pipeline.js';
|
|
9
9
|
import { BootstrapApplicationReceiptSchema, BootstrapInterviewAnswerSchema, BootstrapInterviewPlanSchema, BootstrapInterviewQuestionSchema, BootstrapImportPlanDocumentSchema, BootstrapProfileDocumentSchema, BootstrapSuggestionDocumentSchema, MemorySeedDocumentSchema, } from './schema.js';
|
|
10
10
|
import { loadVersionedJsonFile, saveVersionedJsonFile } from './migration.js';
|
|
11
|
-
import { analyzeRepository } from './repo-analysis.js';
|
|
11
|
+
import { analyzeRepository, findNestedAgentsFiles } from './repo-analysis.js';
|
|
12
12
|
import { buildExecutionContext, compactExecutionContext } from './execution-context.js';
|
|
13
13
|
import { buildAgentToolingContext } from './agent-context.js';
|
|
14
14
|
import { createInstruction, loadInstructions, saveInstruction } from './instructions.js';
|
|
@@ -101,6 +101,7 @@ export function runBootstrapProfile(options = {}) {
|
|
|
101
101
|
importPlan,
|
|
102
102
|
lastApplication,
|
|
103
103
|
reusedProfile: false,
|
|
104
|
+
subProjects: artifacts.subProjects,
|
|
104
105
|
};
|
|
105
106
|
}
|
|
106
107
|
export function listBootstrapSeeds(cwd) {
|
|
@@ -183,6 +184,18 @@ export function renderBootstrapSummary(result) {
|
|
|
183
184
|
if (result.lastApplication && !result.lastApplication.uninstalled_at) {
|
|
184
185
|
lines.push(`Last bootstrap import: ${result.lastApplication.managed_artifacts.length} managed artifact(s) from ${result.lastApplication.applied_at}`);
|
|
185
186
|
}
|
|
187
|
+
if (result.subProjects && result.subProjects.length > 0) {
|
|
188
|
+
lines.push('');
|
|
189
|
+
lines.push(`Sub-projects discovered (${result.subProjects.length}):`);
|
|
190
|
+
for (const sp of result.subProjects.slice(0, 20)) {
|
|
191
|
+
lines.push(` ${sp}`);
|
|
192
|
+
}
|
|
193
|
+
if (result.subProjects.length > 20) {
|
|
194
|
+
lines.push(` ... and ${result.subProjects.length - 20} more`);
|
|
195
|
+
}
|
|
196
|
+
lines.push('');
|
|
197
|
+
lines.push('Use: brainclaw bootstrap --for <sub-project-path> --refresh');
|
|
198
|
+
}
|
|
186
199
|
if (result.importPlan.suggestions.length > 0) {
|
|
187
200
|
lines.push('');
|
|
188
201
|
lines.push('Import proposal:');
|
|
@@ -231,14 +244,19 @@ export function renderBootstrapInterview(result, audience = 'any') {
|
|
|
231
244
|
function buildBootstrapArtifacts(input) {
|
|
232
245
|
const sourcesScanned = [];
|
|
233
246
|
const seeds = [];
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
247
|
+
// When target is an absolute directory path, use it as the scan root
|
|
248
|
+
// so that instruction files, README, and AGENTS.md are discovered from
|
|
249
|
+
// the target scope — not from the workspace root. This fixes bootstrap
|
|
250
|
+
// returning wrong signals for monorepo sub-projects.
|
|
251
|
+
const scanRoot = resolveBootstrapScanRoot(input.cwd, input.target);
|
|
252
|
+
const workspace = classifyWorkspace(scanRoot);
|
|
253
|
+
const nativeInstructionFiles = discoverNativeInstructionFiles(scanRoot);
|
|
254
|
+
const readmePath = findFirstExisting(scanRoot, README_CANDIDATES);
|
|
237
255
|
if (readmePath) {
|
|
238
256
|
sourcesScanned.push('README');
|
|
239
257
|
seeds.push(...extractReadmeSeeds(readmePath, input.target));
|
|
240
258
|
}
|
|
241
|
-
const agentsPath = path.join(
|
|
259
|
+
const agentsPath = path.join(scanRoot, 'AGENTS.md');
|
|
242
260
|
const agentsPresent = fs.existsSync(agentsPath);
|
|
243
261
|
if (agentsPresent) {
|
|
244
262
|
sourcesScanned.push('AGENTS.md');
|
|
@@ -246,9 +264,9 @@ function buildBootstrapArtifacts(input) {
|
|
|
246
264
|
}
|
|
247
265
|
if (nativeInstructionFiles.length > 0) {
|
|
248
266
|
sourcesScanned.push('native_instructions');
|
|
249
|
-
seeds.push(...extractNativeInstructionSeeds(nativeInstructionFiles.map((relativePath) => path.join(
|
|
267
|
+
seeds.push(...extractNativeInstructionSeeds(nativeInstructionFiles.map((relativePath) => path.join(scanRoot, relativePath)), scanRoot, input.target));
|
|
250
268
|
}
|
|
251
|
-
const manifestResult = extractManifestSeeds(
|
|
269
|
+
const manifestResult = extractManifestSeeds(scanRoot, input.target);
|
|
252
270
|
if (manifestResult.seeds.length > 0) {
|
|
253
271
|
sourcesScanned.push(...manifestResult.sources);
|
|
254
272
|
seeds.push(...manifestResult.seeds);
|
|
@@ -265,16 +283,16 @@ function buildBootstrapArtifacts(input) {
|
|
|
265
283
|
sourcesScanned.push('local_mcp');
|
|
266
284
|
seeds.push(...extractMcpSeeds(agentTooling.mcp_servers, input.target));
|
|
267
285
|
}
|
|
268
|
-
const repoAnalysis = analyzeRepository(
|
|
286
|
+
const repoAnalysis = analyzeRepository(scanRoot);
|
|
269
287
|
sourcesScanned.push('repo-analysis');
|
|
270
288
|
seeds.push(...extractRepoAnalysisSeeds(repoAnalysis, input.target));
|
|
271
289
|
// Additional brownfield sources (step 12)
|
|
272
|
-
const additionalSeeds = extractAdditionalBrownfieldSeeds(
|
|
290
|
+
const additionalSeeds = extractAdditionalBrownfieldSeeds(scanRoot, input.target);
|
|
273
291
|
if (additionalSeeds.seeds.length > 0) {
|
|
274
292
|
sourcesScanned.push(...additionalSeeds.sources);
|
|
275
293
|
seeds.push(...additionalSeeds.seeds);
|
|
276
294
|
}
|
|
277
|
-
const gitProbe = probeGit(
|
|
295
|
+
const gitProbe = probeGit(scanRoot, input.target);
|
|
278
296
|
if (gitProbe.available) {
|
|
279
297
|
sourcesScanned.push('git');
|
|
280
298
|
seeds.push(...gitProbe.hotspotSeeds);
|
|
@@ -342,6 +360,13 @@ function buildBootstrapArtifacts(input) {
|
|
|
342
360
|
schema_version: DERIVED_SCHEMA_VERSION,
|
|
343
361
|
})),
|
|
344
362
|
importPlan,
|
|
363
|
+
// For multi-project workspaces without a target, list discovered sub-projects
|
|
364
|
+
subProjects: (!input.target && repoAnalysis.recommendedMode === 'multi-project')
|
|
365
|
+
? findNestedAgentsFiles(scanRoot, 8)
|
|
366
|
+
.filter((p) => p !== 'AGENTS.md') // exclude root AGENTS.md
|
|
367
|
+
.map((p) => path.dirname(p))
|
|
368
|
+
.filter((d) => d !== '.')
|
|
369
|
+
: undefined,
|
|
345
370
|
};
|
|
346
371
|
}
|
|
347
372
|
function extractReadmeSeeds(filepath, target) {
|
|
@@ -846,6 +871,9 @@ function buildSummary(input) {
|
|
|
846
871
|
parts.push(`Onboarding mode: ${input.onboardingMode}.`);
|
|
847
872
|
parts.push(`Confidence: ${input.confidence}.`);
|
|
848
873
|
parts.push(`Repository mode looks ${input.repoAnalysis.recommendedMode}.`);
|
|
874
|
+
if (input.repoAnalysis.recommendedMode === 'multi-project' && !input.target) {
|
|
875
|
+
parts.push('This is a multi-project workspace — use --for <path> to bootstrap a specific sub-project.');
|
|
876
|
+
}
|
|
849
877
|
if (input.agentsPresent) {
|
|
850
878
|
parts.push('AGENTS.md detected and summarized.');
|
|
851
879
|
}
|
|
@@ -1639,6 +1667,29 @@ function normalizeTarget(target) {
|
|
|
1639
1667
|
const trimmed = target?.trim();
|
|
1640
1668
|
return trimmed && trimmed.length > 0 ? trimmed : undefined;
|
|
1641
1669
|
}
|
|
1670
|
+
/**
|
|
1671
|
+
* Resolve where to scan for project files (README, AGENTS.md, manifests).
|
|
1672
|
+
* If target is an absolute directory path, scan from there.
|
|
1673
|
+
* Otherwise fall back to cwd (the workspace/store root).
|
|
1674
|
+
*/
|
|
1675
|
+
function resolveBootstrapScanRoot(cwd, target) {
|
|
1676
|
+
if (!target)
|
|
1677
|
+
return cwd;
|
|
1678
|
+
const resolved = path.isAbsolute(target) ? target : path.resolve(cwd, target);
|
|
1679
|
+
try {
|
|
1680
|
+
if (fs.statSync(resolved).isDirectory())
|
|
1681
|
+
return resolved;
|
|
1682
|
+
}
|
|
1683
|
+
catch { /* not a directory or doesn't exist */ }
|
|
1684
|
+
// Target is a file path or glob — use its parent directory if it exists
|
|
1685
|
+
const parent = path.dirname(resolved);
|
|
1686
|
+
try {
|
|
1687
|
+
if (fs.statSync(parent).isDirectory())
|
|
1688
|
+
return parent;
|
|
1689
|
+
}
|
|
1690
|
+
catch { /* fall back to cwd */ }
|
|
1691
|
+
return cwd;
|
|
1692
|
+
}
|
|
1642
1693
|
// ─── Step 12: Additional brownfield sources ──────────────────────────────────
|
|
1643
1694
|
const CI_WORKFLOW_DIRS = ['.github/workflows', '.gitlab'];
|
|
1644
1695
|
const CI_FILES = ['.gitlab-ci.yml', 'Jenkinsfile', '.circleci/config.yml'];
|
package/dist/core/context.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
+
import { loadActiveProject } from './active-project.js';
|
|
3
|
+
import { checkBrainclawInstallableUpdate, renderBrainclawInstallableUpdateNotice } from './brainclaw-version.js';
|
|
2
4
|
import { loadConfig } from './config.js';
|
|
5
|
+
import { loadCurrentSession, loadAllSessions } from './identity.js';
|
|
3
6
|
import { resolveCrossProjectLinks, loadCrossProjectState } from './cross-project.js';
|
|
4
7
|
import { buildContextDiff } from './context-diff.js';
|
|
5
8
|
import { resolveContextStoreCwd, resolveStoreChain } from './store-resolution.js';
|
|
@@ -502,6 +505,7 @@ export function buildContext(options = {}) {
|
|
|
502
505
|
return undefined;
|
|
503
506
|
}
|
|
504
507
|
})(),
|
|
508
|
+
active_project: findActiveProjectInChain(contextCwd, storeChain),
|
|
505
509
|
cross_project_items: crossProjectItems.length > 0 ? crossProjectItems : undefined,
|
|
506
510
|
claim_conflicts: detectClaimConflicts(myClaims, otherActiveClaims),
|
|
507
511
|
workflow_hints: buildWorkflowHints(myClaims, openWork, state.plan_items),
|
|
@@ -550,7 +554,43 @@ export function renderContextMarkdown(result, explain = false) {
|
|
|
550
554
|
lines.push(`Agent ID: ${result.agent_id}`);
|
|
551
555
|
}
|
|
552
556
|
lines.push(`Project mode: ${result.project_mode} (${result.project_strategy})`);
|
|
557
|
+
if (result.active_project) {
|
|
558
|
+
const ap = result.active_project;
|
|
559
|
+
const age = Math.floor((Date.now() - Date.parse(ap.switched_at)) / 3_600_000);
|
|
560
|
+
const sourceHint = ap.source === 'session' ? ', session-scoped' : ', global';
|
|
561
|
+
lines.push(`Active project: ${ap.name ?? ap.path} (switched ${age}h ago by ${ap.switched_by ?? 'unknown'}${sourceHint})`);
|
|
562
|
+
if (ap.source === 'global') {
|
|
563
|
+
lines.push(` ⚠ This is a global switch — all agents on this host see the same project. Use \`brainclaw switch <project>\` during a session for agent-scoped switching.`);
|
|
564
|
+
}
|
|
565
|
+
lines.push(` All commands target this project. Use \`brainclaw switch --clear\` to return to workspace root or \`brainclaw switch <project>\` to change.`);
|
|
566
|
+
}
|
|
553
567
|
lines.push(`Current host: ${result.current_host}`);
|
|
568
|
+
// Show other active sessions
|
|
569
|
+
try {
|
|
570
|
+
const allSessions = loadAllSessions();
|
|
571
|
+
const ttlMs = 4 * 60 * 60 * 1000;
|
|
572
|
+
const now = Date.now();
|
|
573
|
+
const otherSessions = allSessions.filter(s => s.agent_id !== result.agent_id
|
|
574
|
+
&& (now - Date.parse(s.last_seen_at)) <= ttlMs);
|
|
575
|
+
if (otherSessions.length > 0) {
|
|
576
|
+
const summaries = otherSessions.map(s => {
|
|
577
|
+
const proj = s.active_project?.name ?? s.active_project?.path;
|
|
578
|
+
return `${s.user ?? 'unknown'}/${s.agent}${proj ? ` on ${proj}` : ''}`;
|
|
579
|
+
});
|
|
580
|
+
lines.push(`Other active agents: ${summaries.join(', ')}`);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
catch { /* ignore — sessions dir may not exist yet */ }
|
|
584
|
+
// Check for brainclaw update (lightweight local manifest read only)
|
|
585
|
+
try {
|
|
586
|
+
const config = loadConfig();
|
|
587
|
+
const updateCheck = checkBrainclawInstallableUpdate(config, process.cwd());
|
|
588
|
+
const notice = renderBrainclawInstallableUpdateNotice(updateCheck);
|
|
589
|
+
if (notice) {
|
|
590
|
+
lines.push(`⚠ ${notice}`);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
catch { /* ignore — update check is best-effort */ }
|
|
554
594
|
lines.push(`Memory version: ${result.memory_version}`);
|
|
555
595
|
lines.push(`Memory density: ${result.memory_density}`);
|
|
556
596
|
lines.push(`Bootstrap available: ${result.bootstrap_available ? 'yes' : 'no'}`);
|
|
@@ -758,6 +798,11 @@ export function renderContextPromptTemplate(result, compact = false) {
|
|
|
758
798
|
}
|
|
759
799
|
lines.push(`project_mode: ${result.project_mode}`);
|
|
760
800
|
lines.push(`project_strategy: ${result.project_strategy}`);
|
|
801
|
+
if (result.active_project) {
|
|
802
|
+
lines.push(`active_project: ${result.active_project.name ?? result.active_project.path}`);
|
|
803
|
+
lines.push(`active_project_switched: ${result.active_project.switched_at}`);
|
|
804
|
+
lines.push(`active_project_source: ${result.active_project.source ?? 'global'}`);
|
|
805
|
+
}
|
|
761
806
|
lines.push(`current_host: ${result.current_host}`);
|
|
762
807
|
lines.push(`memory_version: ${result.memory_version}`);
|
|
763
808
|
lines.push(`memory_density: ${result.memory_density}`);
|
|
@@ -1303,6 +1348,34 @@ function scopesOverlap(a, b) {
|
|
|
1303
1348
|
}
|
|
1304
1349
|
return null;
|
|
1305
1350
|
}
|
|
1351
|
+
// --- Active project resolution ---
|
|
1352
|
+
function findActiveProjectInChain(contextCwd, _storeChain) {
|
|
1353
|
+
// 1. Session-scoped active project (per-agent, highest priority)
|
|
1354
|
+
const session = loadCurrentSession(contextCwd);
|
|
1355
|
+
if (session?.active_project) {
|
|
1356
|
+
return {
|
|
1357
|
+
path: session.active_project.path,
|
|
1358
|
+
name: session.active_project.name,
|
|
1359
|
+
switched_at: session.active_project.switched_at,
|
|
1360
|
+
switched_by: session.agent,
|
|
1361
|
+
source: 'session',
|
|
1362
|
+
};
|
|
1363
|
+
}
|
|
1364
|
+
// 2. Global active-project.json (walk up from contextCwd)
|
|
1365
|
+
let dir = path.resolve(contextCwd);
|
|
1366
|
+
const root = path.parse(dir).root;
|
|
1367
|
+
const home = process.env.HOME || process.env.USERPROFILE || root;
|
|
1368
|
+
while (dir !== root && dir !== home) {
|
|
1369
|
+
const ap = loadActiveProject(dir);
|
|
1370
|
+
if (ap)
|
|
1371
|
+
return { ...ap, source: 'global' };
|
|
1372
|
+
const parent = path.dirname(dir);
|
|
1373
|
+
if (parent === dir)
|
|
1374
|
+
break;
|
|
1375
|
+
dir = parent;
|
|
1376
|
+
}
|
|
1377
|
+
return undefined;
|
|
1378
|
+
}
|
|
1306
1379
|
// --- Workflow hints ---
|
|
1307
1380
|
function buildWorkflowHints(myClaims, openWork, plans) {
|
|
1308
1381
|
const hints = [];
|
package/dist/core/event-log.js
CHANGED
|
@@ -12,6 +12,7 @@ export function appendEvent(event, cwd) {
|
|
|
12
12
|
ts: event.ts ?? nowISO(),
|
|
13
13
|
agent: event.agent ?? 'unknown',
|
|
14
14
|
agent_id: event.agent_id,
|
|
15
|
+
user: event.user ?? process.env.USER ?? process.env.USERNAME,
|
|
15
16
|
action: event.action,
|
|
16
17
|
item_type: event.item_type,
|
|
17
18
|
item_id: event.item_id,
|
package/dist/core/identity.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import crypto from 'node:crypto';
|
|
2
2
|
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
3
4
|
import path from 'node:path';
|
|
4
5
|
import { requireRegisteredAgentIdentity } from './agent-registry.js';
|
|
5
6
|
import { loadConfig } from './config.js';
|
|
@@ -7,7 +8,9 @@ import { resolveCurrentHostId } from './host.js';
|
|
|
7
8
|
import { memoryDir } from './io.js';
|
|
8
9
|
import { loadVersionedJsonFile, saveVersionedJsonFile } from './migration.js';
|
|
9
10
|
import { CurrentSessionStateSchema } from './schema.js';
|
|
10
|
-
const
|
|
11
|
+
const SESSIONS_DIR = 'sessions';
|
|
12
|
+
const LEGACY_SESSION_FILE = '.current-session';
|
|
13
|
+
// --- Public API ---
|
|
11
14
|
export function resolveCurrentSessionId(env = process.env, cwd, options = {}) {
|
|
12
15
|
const value = env.BRAINCLAW_SESSION_ID?.trim()
|
|
13
16
|
|| env.OPENCLAW_SESSION_ID?.trim()
|
|
@@ -64,11 +67,54 @@ export function resolveEventSessionId(event) {
|
|
|
64
67
|
? metadataSession
|
|
65
68
|
: undefined;
|
|
66
69
|
}
|
|
70
|
+
/**
|
|
71
|
+
* Load the current session for this agent+user combo.
|
|
72
|
+
* Checks sessions/ directory first, falls back to legacy .current-session.
|
|
73
|
+
*/
|
|
67
74
|
export function loadCurrentSession(cwd) {
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
75
|
+
const dir = sessionsDir(cwd);
|
|
76
|
+
const currentUser = resolveCurrentUser();
|
|
77
|
+
const currentAgent = resolveCurrentAgentName();
|
|
78
|
+
// 1. Look in sessions/ directory for a matching session
|
|
79
|
+
if (fs.existsSync(dir)) {
|
|
80
|
+
const files = fs.readdirSync(dir).filter(f => f.endsWith('.json'));
|
|
81
|
+
const ttlMs = parseDurationToMs(loadConfigSafe(cwd)?.implicit_session_ttl ?? '4h');
|
|
82
|
+
const now = Date.now();
|
|
83
|
+
for (const file of files) {
|
|
84
|
+
try {
|
|
85
|
+
const session = CurrentSessionStateSchema.parse(loadVersionedJsonFile('current_session', path.join(dir, file)).document);
|
|
86
|
+
// Match by agent+user (or agent only if user not set in old sessions)
|
|
87
|
+
const userMatch = !session.user || !currentUser || session.user === currentUser;
|
|
88
|
+
const agentMatch = !currentAgent || session.agent === currentAgent;
|
|
89
|
+
const alive = (now - Date.parse(session.last_seen_at)) <= ttlMs;
|
|
90
|
+
if (userMatch && agentMatch && alive) {
|
|
91
|
+
return session;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
// skip invalid session files
|
|
96
|
+
}
|
|
97
|
+
}
|
|
71
98
|
}
|
|
99
|
+
// 2. Legacy fallback: .current-session
|
|
100
|
+
const legacyPath = path.join(memoryDir(cwd), LEGACY_SESSION_FILE);
|
|
101
|
+
if (fs.existsSync(legacyPath)) {
|
|
102
|
+
try {
|
|
103
|
+
return CurrentSessionStateSchema.parse(loadVersionedJsonFile('current_session', legacyPath).document);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Load a specific session by ID.
|
|
113
|
+
*/
|
|
114
|
+
export function loadSessionById(sessionId, cwd) {
|
|
115
|
+
const filepath = sessionFilePath(sessionId, cwd);
|
|
116
|
+
if (!fs.existsSync(filepath))
|
|
117
|
+
return undefined;
|
|
72
118
|
try {
|
|
73
119
|
return CurrentSessionStateSchema.parse(loadVersionedJsonFile('current_session', filepath).document);
|
|
74
120
|
}
|
|
@@ -76,43 +122,134 @@ export function loadCurrentSession(cwd) {
|
|
|
76
122
|
return undefined;
|
|
77
123
|
}
|
|
78
124
|
}
|
|
125
|
+
/**
|
|
126
|
+
* Load ALL sessions (active + stale) from the sessions/ directory.
|
|
127
|
+
*/
|
|
128
|
+
export function loadAllSessions(cwd) {
|
|
129
|
+
const dir = sessionsDir(cwd);
|
|
130
|
+
if (!fs.existsSync(dir))
|
|
131
|
+
return [];
|
|
132
|
+
const files = fs.readdirSync(dir).filter(f => f.endsWith('.json'));
|
|
133
|
+
const sessions = [];
|
|
134
|
+
for (const file of files) {
|
|
135
|
+
try {
|
|
136
|
+
sessions.push(CurrentSessionStateSchema.parse(loadVersionedJsonFile('current_session', path.join(dir, file)).document));
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
// skip invalid
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return sessions.sort((a, b) => b.last_seen_at.localeCompare(a.last_seen_at));
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Save a session to the sessions/ directory.
|
|
146
|
+
*/
|
|
79
147
|
export function saveCurrentSession(session, cwd) {
|
|
80
|
-
|
|
148
|
+
const dir = sessionsDir(cwd);
|
|
149
|
+
if (!fs.existsSync(dir)) {
|
|
150
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
151
|
+
}
|
|
152
|
+
const filepath = sessionFilePath(session.session_id, cwd);
|
|
153
|
+
saveVersionedJsonFile('current_session', filepath, CurrentSessionStateSchema.parse(session));
|
|
81
154
|
}
|
|
155
|
+
/**
|
|
156
|
+
* Clear a session. If sessionId is provided, only clear that specific session.
|
|
157
|
+
*/
|
|
82
158
|
export function clearCurrentSession(cwd, sessionId) {
|
|
83
|
-
|
|
84
|
-
|
|
159
|
+
if (sessionId) {
|
|
160
|
+
// Remove specific session file
|
|
161
|
+
const filepath = sessionFilePath(sessionId, cwd);
|
|
162
|
+
try {
|
|
163
|
+
fs.unlinkSync(filepath);
|
|
164
|
+
}
|
|
165
|
+
catch { /* ignore */ }
|
|
85
166
|
return;
|
|
86
167
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
168
|
+
// Clear the session for the current agent+user
|
|
169
|
+
const session = loadCurrentSession(cwd);
|
|
170
|
+
if (session) {
|
|
171
|
+
const filepath = sessionFilePath(session.session_id, cwd);
|
|
172
|
+
try {
|
|
173
|
+
fs.unlinkSync(filepath);
|
|
91
174
|
}
|
|
175
|
+
catch { /* ignore */ }
|
|
92
176
|
}
|
|
177
|
+
// Also clean legacy file
|
|
178
|
+
const legacyPath = path.join(memoryDir(cwd), LEGACY_SESSION_FILE);
|
|
93
179
|
try {
|
|
94
|
-
fs.unlinkSync(
|
|
180
|
+
fs.unlinkSync(legacyPath);
|
|
95
181
|
}
|
|
96
|
-
catch {
|
|
97
|
-
|
|
182
|
+
catch { /* ignore */ }
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Remove stale sessions that have exceeded the TTL.
|
|
186
|
+
* Returns the number of sessions removed.
|
|
187
|
+
*/
|
|
188
|
+
export function gcStaleSessions(cwd, ttlOverride) {
|
|
189
|
+
const dir = sessionsDir(cwd);
|
|
190
|
+
if (!fs.existsSync(dir))
|
|
191
|
+
return 0;
|
|
192
|
+
const ttlMs = parseDurationToMs(ttlOverride ?? loadConfigSafe(cwd)?.implicit_session_ttl ?? '4h');
|
|
193
|
+
const now = Date.now();
|
|
194
|
+
let removed = 0;
|
|
195
|
+
const files = fs.readdirSync(dir).filter(f => f.endsWith('.json'));
|
|
196
|
+
for (const file of files) {
|
|
197
|
+
try {
|
|
198
|
+
const session = CurrentSessionStateSchema.parse(loadVersionedJsonFile('current_session', path.join(dir, file)).document);
|
|
199
|
+
if (now - Date.parse(session.last_seen_at) > ttlMs) {
|
|
200
|
+
fs.unlinkSync(path.join(dir, file));
|
|
201
|
+
removed++;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
// Remove unparseable files too
|
|
206
|
+
try {
|
|
207
|
+
fs.unlinkSync(path.join(dir, file));
|
|
208
|
+
removed++;
|
|
209
|
+
}
|
|
210
|
+
catch { /* ignore */ }
|
|
211
|
+
}
|
|
98
212
|
}
|
|
213
|
+
return removed;
|
|
214
|
+
}
|
|
215
|
+
// --- Internal helpers ---
|
|
216
|
+
function sessionsDir(cwd) {
|
|
217
|
+
return path.join(memoryDir(cwd), SESSIONS_DIR);
|
|
218
|
+
}
|
|
219
|
+
function sessionFilePath(sessionId, cwd) {
|
|
220
|
+
return path.join(sessionsDir(cwd), `${sessionId}.json`);
|
|
99
221
|
}
|
|
100
|
-
function
|
|
101
|
-
return
|
|
222
|
+
function resolveCurrentUser() {
|
|
223
|
+
return process.env.USER || process.env.USERNAME || os.userInfo().username || undefined;
|
|
224
|
+
}
|
|
225
|
+
function resolveCurrentAgentName() {
|
|
226
|
+
return process.env.BRAINCLAW_AGENT_NAME || process.env.CLAUDE_CODE_VERSION ? 'claude-code' : undefined;
|
|
227
|
+
}
|
|
228
|
+
function loadConfigSafe(cwd) {
|
|
229
|
+
try {
|
|
230
|
+
return loadConfig(cwd);
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
return undefined;
|
|
234
|
+
}
|
|
102
235
|
}
|
|
103
236
|
function resolveImplicitSession(cwd, options) {
|
|
104
237
|
const current = loadCurrentSession(cwd);
|
|
105
238
|
const persistImplicit = options.persistImplicit ?? true;
|
|
106
|
-
const ttlMs = parseDurationToMs(
|
|
239
|
+
const ttlMs = parseDurationToMs(loadConfigSafe(cwd)?.implicit_session_ttl ?? '4h');
|
|
107
240
|
const now = new Date();
|
|
241
|
+
const currentUser = resolveCurrentUser();
|
|
108
242
|
if (current
|
|
109
243
|
&& current.agent === options.agentName
|
|
110
244
|
&& current.agent_id === options.agentId
|
|
111
245
|
&& current.host_id === options.hostId
|
|
246
|
+
&& (!current.user || !currentUser || current.user === currentUser)
|
|
112
247
|
&& now.getTime() - Date.parse(current.last_seen_at) <= ttlMs) {
|
|
113
248
|
const refreshed = {
|
|
114
249
|
...current,
|
|
115
250
|
last_seen_at: now.toISOString(),
|
|
251
|
+
user: current.user || currentUser,
|
|
252
|
+
pid: process.pid,
|
|
116
253
|
};
|
|
117
254
|
if (persistImplicit) {
|
|
118
255
|
saveCurrentSession(refreshed, cwd);
|
|
@@ -126,6 +263,8 @@ function resolveImplicitSession(cwd, options) {
|
|
|
126
263
|
agent: options.agentName,
|
|
127
264
|
agent_id: options.agentId,
|
|
128
265
|
host_id: options.hostId,
|
|
266
|
+
user: currentUser,
|
|
267
|
+
pid: process.pid,
|
|
129
268
|
};
|
|
130
269
|
if (persistImplicit) {
|
|
131
270
|
saveCurrentSession(created, cwd);
|
|
@@ -50,6 +50,16 @@ export function analyzeRepository(cwd) {
|
|
|
50
50
|
if (matchedDirs.length > 0) {
|
|
51
51
|
reasons.push(`Found top-level project folders: ${matchedDirs.join(', ')}`);
|
|
52
52
|
}
|
|
53
|
+
// ── Signal 4: Multiple AGENTS.md files in the tree ──
|
|
54
|
+
const agentsFiles = findNestedAgentsFiles(cwd, 8);
|
|
55
|
+
if (agentsFiles.length > 1) {
|
|
56
|
+
reasons.push(`Found ${agentsFiles.length} AGENTS.md files: ${agentsFiles.slice(0, 5).join(', ')}${agentsFiles.length > 5 ? ` (+${agentsFiles.length - 5} more)` : ''}`);
|
|
57
|
+
}
|
|
58
|
+
// ── Signal 5: Multiple Dockerfiles or docker-compose files (service-oriented workspace) ──
|
|
59
|
+
const dockerComposeFiles = findFilesShallow(cwd, ['docker-compose.yml', 'docker-compose.yaml'], 6);
|
|
60
|
+
if (dockerComposeFiles.length > 1) {
|
|
61
|
+
reasons.push(`Found ${dockerComposeFiles.length} docker-compose files`);
|
|
62
|
+
}
|
|
53
63
|
const packageJsonPath = path.join(cwd, 'package.json');
|
|
54
64
|
if (fs.existsSync(packageJsonPath)) {
|
|
55
65
|
try {
|
|
@@ -184,4 +194,61 @@ export function scanWorkspaceBoundaries(rootDir, maxDepth = 3) {
|
|
|
184
194
|
walk(rootDir, 1);
|
|
185
195
|
return { suggestions, alreadyInitialised };
|
|
186
196
|
}
|
|
197
|
+
/**
|
|
198
|
+
* Find AGENTS.md files up to maxDepth levels deep.
|
|
199
|
+
* Returns relative paths from cwd.
|
|
200
|
+
*/
|
|
201
|
+
export function findNestedAgentsFiles(cwd, maxDepth) {
|
|
202
|
+
const results = [];
|
|
203
|
+
function walk(dir, depth) {
|
|
204
|
+
if (depth > maxDepth || results.length > 10)
|
|
205
|
+
return;
|
|
206
|
+
let entries;
|
|
207
|
+
try {
|
|
208
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
for (const entry of entries) {
|
|
214
|
+
if (entry.isFile() && entry.name === 'AGENTS.md') {
|
|
215
|
+
results.push(path.relative(cwd, path.join(dir, entry.name)));
|
|
216
|
+
}
|
|
217
|
+
if (entry.isDirectory() && !SKIP_DIRS.has(entry.name) && !entry.name.startsWith('.')) {
|
|
218
|
+
walk(path.join(dir, entry.name), depth + 1);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
walk(cwd, 0);
|
|
223
|
+
return results;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Find specific filenames up to maxDepth levels deep.
|
|
227
|
+
* Returns relative paths from cwd.
|
|
228
|
+
*/
|
|
229
|
+
function findFilesShallow(cwd, filenames, maxDepth) {
|
|
230
|
+
const nameSet = new Set(filenames);
|
|
231
|
+
const results = [];
|
|
232
|
+
function walk(dir, depth) {
|
|
233
|
+
if (depth > maxDepth || results.length > 10)
|
|
234
|
+
return;
|
|
235
|
+
let entries;
|
|
236
|
+
try {
|
|
237
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
for (const entry of entries) {
|
|
243
|
+
if (entry.isFile() && nameSet.has(entry.name)) {
|
|
244
|
+
results.push(path.relative(cwd, path.join(dir, entry.name)));
|
|
245
|
+
}
|
|
246
|
+
if (entry.isDirectory() && !SKIP_DIRS.has(entry.name) && !entry.name.startsWith('.')) {
|
|
247
|
+
walk(path.join(dir, entry.name), depth + 1);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
walk(cwd, 0);
|
|
252
|
+
return results;
|
|
253
|
+
}
|
|
187
254
|
//# sourceMappingURL=repo-analysis.js.map
|
package/dist/core/schema.js
CHANGED
|
@@ -33,6 +33,30 @@ function coerceEffortToMinutes(val) {
|
|
|
33
33
|
}
|
|
34
34
|
return undefined;
|
|
35
35
|
}
|
|
36
|
+
/** Coerce tags from JSON string to array when MCP clients serialize arrays as strings.
|
|
37
|
+
* Accepts: string[] (passthrough), '["a","b"]' (JSON parse), 'a,b' (comma split). */
|
|
38
|
+
function coerceTags(val) {
|
|
39
|
+
if (Array.isArray(val))
|
|
40
|
+
return val;
|
|
41
|
+
if (typeof val === 'string') {
|
|
42
|
+
const trimmed = val.trim();
|
|
43
|
+
if (trimmed.startsWith('[')) {
|
|
44
|
+
try {
|
|
45
|
+
const parsed = JSON.parse(trimmed);
|
|
46
|
+
if (Array.isArray(parsed))
|
|
47
|
+
return parsed;
|
|
48
|
+
}
|
|
49
|
+
catch { /* fall through */ }
|
|
50
|
+
}
|
|
51
|
+
if (trimmed.length > 0)
|
|
52
|
+
return trimmed.split(',').map(t => t.trim()).filter(Boolean);
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
return val;
|
|
56
|
+
}
|
|
57
|
+
/** Resilient tags schema that accepts string[] or JSON-serialized string. */
|
|
58
|
+
export const TagsSchema = z.preprocess(coerceTags, z.array(z.string()));
|
|
59
|
+
export const TagsWithDefaultSchema = z.preprocess(coerceTags, z.array(z.string()).default([]));
|
|
36
60
|
// --- Entry schemas ---
|
|
37
61
|
export const ConstraintStatusSchema = z.enum(['active', 'resolved', 'expired']);
|
|
38
62
|
export const ConstraintCategorySchema = z.enum(['architecture', 'performance', 'security', 'reliability', 'compatibility', 'process', 'other']);
|
|
@@ -58,7 +82,7 @@ export const ConstraintSchema = z.object({
|
|
|
58
82
|
status: ConstraintStatusSchema,
|
|
59
83
|
category: ConstraintCategorySchema.optional(),
|
|
60
84
|
scope: MemoryScopeSchema.optional(),
|
|
61
|
-
tags:
|
|
85
|
+
tags: TagsSchema,
|
|
62
86
|
related_paths: z.array(z.string()).optional(),
|
|
63
87
|
expires_at: z.string().optional(),
|
|
64
88
|
});
|
|
@@ -78,7 +102,7 @@ export const DecisionSchema = z.object({
|
|
|
78
102
|
scope: MemoryScopeSchema.optional(),
|
|
79
103
|
related_paths: z.array(z.string()).optional(),
|
|
80
104
|
plan_id: z.string().optional(),
|
|
81
|
-
tags:
|
|
105
|
+
tags: TagsSchema,
|
|
82
106
|
});
|
|
83
107
|
export const TrapSchema = z.object({
|
|
84
108
|
schema_version: z.number().int().positive().optional(),
|
|
@@ -94,7 +118,7 @@ export const TrapSchema = z.object({
|
|
|
94
118
|
status: TrapStatusSchema.default('active'),
|
|
95
119
|
severity: SeveritySchema,
|
|
96
120
|
scope: MemoryScopeSchema.optional(),
|
|
97
|
-
tags:
|
|
121
|
+
tags: TagsSchema,
|
|
98
122
|
related_paths: z.array(z.string()).optional(),
|
|
99
123
|
plan_id: z.string().optional(),
|
|
100
124
|
visibility: MemoryVisibilitySchema.default('shared'),
|
|
@@ -119,7 +143,7 @@ export const HandoffSchema = z.object({
|
|
|
119
143
|
status: HandoffStatusSchema,
|
|
120
144
|
project: z.string().optional(),
|
|
121
145
|
plan_id: z.string().optional(),
|
|
122
|
-
tags:
|
|
146
|
+
tags: TagsSchema,
|
|
123
147
|
related_paths: z.array(z.string()).optional(),
|
|
124
148
|
snapshot: z.object({
|
|
125
149
|
diff: z.string().optional(),
|
|
@@ -150,7 +174,7 @@ export const PlanItemSchema = z.object({
|
|
|
150
174
|
priority: PrioritySchema,
|
|
151
175
|
assignee: z.string().optional(),
|
|
152
176
|
project: z.string().optional(),
|
|
153
|
-
tags:
|
|
177
|
+
tags: TagsSchema,
|
|
154
178
|
related_paths: z.array(z.string()).optional(),
|
|
155
179
|
depends_on: z.array(z.string()).default([]),
|
|
156
180
|
steps: z.array(PlanStepSchema).optional(),
|
|
@@ -170,7 +194,7 @@ export const InstructionEntrySchema = z.object({
|
|
|
170
194
|
updated_at: z.string(),
|
|
171
195
|
author: z.string(),
|
|
172
196
|
model: z.string().optional(),
|
|
173
|
-
tags:
|
|
197
|
+
tags: TagsWithDefaultSchema,
|
|
174
198
|
active: z.boolean().default(true),
|
|
175
199
|
supersedes: z.string().optional(),
|
|
176
200
|
});
|
|
@@ -183,7 +207,7 @@ export const ProjectCapabilitySchema = z.object({
|
|
|
183
207
|
category: z.string(), // e.g. "auth", "api", "storage", "testing"
|
|
184
208
|
provided_by: z.string().optional(), // path to implementation
|
|
185
209
|
requires: z.array(z.string()).optional(), // capability IDs this depends on
|
|
186
|
-
tags:
|
|
210
|
+
tags: TagsSchema,
|
|
187
211
|
example_usage: z.string().optional(),
|
|
188
212
|
status: CapabilityStatusSchema.default('stable'),
|
|
189
213
|
related_paths: z.array(z.string()).optional(),
|
|
@@ -205,7 +229,7 @@ export const ProjectToolSchema = z.object({
|
|
|
205
229
|
requires: z.array(z.string()).optional(), // tool IDs this depends on
|
|
206
230
|
suggests_for: z.array(z.string()).optional(), // agent types or domains
|
|
207
231
|
invocation_example: z.string().optional(),
|
|
208
|
-
tags:
|
|
232
|
+
tags: TagsSchema,
|
|
209
233
|
status: CapabilityStatusSchema.default('stable'),
|
|
210
234
|
related_paths: z.array(z.string()).optional(),
|
|
211
235
|
created_at: z.string(),
|
|
@@ -271,7 +295,7 @@ export const CandidateSchema = z.object({
|
|
|
271
295
|
host_id: z.string().optional(),
|
|
272
296
|
session_id: z.string().optional(),
|
|
273
297
|
source: z.string().optional(),
|
|
274
|
-
tags:
|
|
298
|
+
tags: TagsSchema,
|
|
275
299
|
status: CandidateStatusSchema,
|
|
276
300
|
// type-specific optional fields
|
|
277
301
|
severity: SeveritySchema.optional(),
|
|
@@ -324,6 +348,8 @@ export const ClaimSchema = z.object({
|
|
|
324
348
|
id: z.string(),
|
|
325
349
|
agent: z.string(),
|
|
326
350
|
agent_id: z.string().optional(),
|
|
351
|
+
/** OS user who created this claim. */
|
|
352
|
+
user: z.string().optional(),
|
|
327
353
|
project_id: z.string().optional(),
|
|
328
354
|
host_id: z.string().optional(),
|
|
329
355
|
session_id: z.string().optional(),
|
|
@@ -349,7 +375,7 @@ export const RuntimeNoteSchema = z.object({
|
|
|
349
375
|
created_at: z.string(),
|
|
350
376
|
project: z.string().optional(),
|
|
351
377
|
plan_id: z.string().optional(),
|
|
352
|
-
tags:
|
|
378
|
+
tags: TagsSchema,
|
|
353
379
|
visibility: MemoryVisibilitySchema.default('shared'),
|
|
354
380
|
host_id: z.string().optional(),
|
|
355
381
|
expires_at: z.string().optional(),
|
|
@@ -376,7 +402,7 @@ export const AiSurfaceTaskRequestSchema = z.object({
|
|
|
376
402
|
status: AiSurfaceTaskStatusSchema.default('queued'),
|
|
377
403
|
requested_outputs: z.array(z.string()).default([]),
|
|
378
404
|
related_paths: z.array(z.string()).optional(),
|
|
379
|
-
tags:
|
|
405
|
+
tags: TagsWithDefaultSchema,
|
|
380
406
|
claimed_at: z.string().optional(),
|
|
381
407
|
completed_at: z.string().optional(),
|
|
382
408
|
result_note: z.string().optional(),
|
|
@@ -402,7 +428,7 @@ export const RuntimeEventSchema = z.object({
|
|
|
402
428
|
event_type: RuntimeEventTypeSchema,
|
|
403
429
|
created_at: z.string(),
|
|
404
430
|
text: z.string(),
|
|
405
|
-
tags:
|
|
431
|
+
tags: TagsWithDefaultSchema,
|
|
406
432
|
// Optional routing and type hints for candidate generation
|
|
407
433
|
candidate_type: CandidateTypeSchema.optional(),
|
|
408
434
|
severity: SeveritySchema.optional(),
|
|
@@ -468,6 +494,14 @@ export const SessionSnapshotSchema = z.object({
|
|
|
468
494
|
initial_context_hash: z.string().optional(),
|
|
469
495
|
git_sha: z.string().optional(),
|
|
470
496
|
});
|
|
497
|
+
export const SessionActiveProjectSchema = z.object({
|
|
498
|
+
/** Absolute path to the project directory. */
|
|
499
|
+
path: z.string(),
|
|
500
|
+
/** Project name from config.yaml (when available). */
|
|
501
|
+
name: z.string().optional(),
|
|
502
|
+
/** ISO timestamp of the switch. */
|
|
503
|
+
switched_at: z.string(),
|
|
504
|
+
}).strict();
|
|
471
505
|
export const CurrentSessionStateSchema = z.object({
|
|
472
506
|
schema_version: z.number().int().positive().optional(),
|
|
473
507
|
session_id: z.string(),
|
|
@@ -476,6 +510,12 @@ export const CurrentSessionStateSchema = z.object({
|
|
|
476
510
|
agent: z.string(),
|
|
477
511
|
agent_id: z.string(),
|
|
478
512
|
host_id: z.string(),
|
|
513
|
+
/** OS user who started this session. */
|
|
514
|
+
user: z.string().optional(),
|
|
515
|
+
/** Process ID of the agent process (for liveness detection). */
|
|
516
|
+
pid: z.number().int().positive().optional(),
|
|
517
|
+
/** Session-scoped active project (overrides global active-project.json). */
|
|
518
|
+
active_project: SessionActiveProjectSchema.optional(),
|
|
479
519
|
});
|
|
480
520
|
export const MemorySeedKindSchema = z.enum([
|
|
481
521
|
'command',
|
|
@@ -516,7 +556,7 @@ export const MemorySeedDocumentSchema = z.object({
|
|
|
516
556
|
source_ref: z.string(),
|
|
517
557
|
confidence: MemorySeedConfidenceSchema,
|
|
518
558
|
related_paths: z.array(z.string()).optional(),
|
|
519
|
-
tags:
|
|
559
|
+
tags: TagsWithDefaultSchema,
|
|
520
560
|
promotion_hint: z.enum(['constraint', 'decision', 'trap']).optional(),
|
|
521
561
|
});
|
|
522
562
|
export const BootstrapProfileDocumentSchema = z.object({
|
|
@@ -547,7 +587,7 @@ export const BootstrapSuggestionDocumentSchema = z.object({
|
|
|
547
587
|
source_refs: z.array(z.string()).default([]),
|
|
548
588
|
layer: z.enum(['global', 'project', 'agent']).optional(),
|
|
549
589
|
scope: z.string().optional(),
|
|
550
|
-
tags:
|
|
590
|
+
tags: TagsWithDefaultSchema,
|
|
551
591
|
related_paths: z.array(z.string()).optional(),
|
|
552
592
|
category: ConstraintCategorySchema.optional(),
|
|
553
593
|
outcome: DecisionOutcomeSchema.optional(),
|
|
@@ -581,7 +621,7 @@ export const BootstrapInterviewAnswerSuggestionSchema = z.object({
|
|
|
581
621
|
confidence: MemorySeedConfidenceSchema.optional(),
|
|
582
622
|
layer: z.enum(['global', 'project', 'agent']).optional(),
|
|
583
623
|
scope: z.string().optional(),
|
|
584
|
-
tags:
|
|
624
|
+
tags: TagsWithDefaultSchema,
|
|
585
625
|
related_paths: z.array(z.string()).optional(),
|
|
586
626
|
category: ConstraintCategorySchema.optional(),
|
|
587
627
|
outcome: DecisionOutcomeSchema.optional(),
|
|
@@ -3,6 +3,7 @@ import os from 'node:os';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { loadActiveProject } from './active-project.js';
|
|
5
5
|
import { loadConfig } from './config.js';
|
|
6
|
+
import { loadCurrentSession } from './identity.js';
|
|
6
7
|
import { MEMORY_DIR } from './io.js';
|
|
7
8
|
import { summarizeWorkspaceProjects } from './workspace-projects.js';
|
|
8
9
|
/**
|
|
@@ -91,8 +92,9 @@ export function resolveTargetStore(cwd = process.cwd(), target = 'local', option
|
|
|
91
92
|
* Priority:
|
|
92
93
|
* 1. explicitCwd (--cwd flag)
|
|
93
94
|
* 2. BRAINCLAW_PROJECT env var → resolved by name/path from workspace
|
|
94
|
-
* 3. active
|
|
95
|
-
* 4.
|
|
95
|
+
* 3. Session-scoped active project (from .current-session)
|
|
96
|
+
* 4. Global active-project.json in workspace root
|
|
97
|
+
* 5. process.cwd()
|
|
96
98
|
*/
|
|
97
99
|
export function resolveEffectiveCwd(options = {}) {
|
|
98
100
|
// 1. Explicit --cwd flag
|
|
@@ -106,7 +108,15 @@ export function resolveEffectiveCwd(options = {}) {
|
|
|
106
108
|
if (resolved)
|
|
107
109
|
return resolved;
|
|
108
110
|
}
|
|
109
|
-
// 3. active
|
|
111
|
+
// 3. Session-scoped active project (per-agent, no cross-agent interference)
|
|
112
|
+
const session = loadCurrentSession(process.cwd());
|
|
113
|
+
if (session?.active_project) {
|
|
114
|
+
const sp = session.active_project;
|
|
115
|
+
if (fs.existsSync(path.join(sp.path, MEMORY_DIR, 'config.yaml'))) {
|
|
116
|
+
return sp.path;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// 4. Global active-project.json from workspace root
|
|
110
120
|
const wsRoot = resolveWorkspaceRoot(process.cwd(), options.storeChainOptions);
|
|
111
121
|
if (wsRoot) {
|
|
112
122
|
const active = loadActiveProject(wsRoot);
|
|
@@ -114,7 +124,7 @@ export function resolveEffectiveCwd(options = {}) {
|
|
|
114
124
|
return active.path;
|
|
115
125
|
}
|
|
116
126
|
}
|
|
117
|
-
//
|
|
127
|
+
// 5. Default
|
|
118
128
|
return process.cwd();
|
|
119
129
|
}
|
|
120
130
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brainclaw",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.29.2",
|
|
4
4
|
"description": "Shared project memory for humans and coding agents.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"license": "BUSL-1.1",
|
|
46
46
|
"homepage": "https://brainclaw.dev",
|
|
47
47
|
"engines": {
|
|
48
|
-
"node": ">=
|
|
48
|
+
"node": ">=18.0.0"
|
|
49
49
|
},
|
|
50
50
|
"dependencies": {
|
|
51
51
|
"commander": "^13.1.0",
|