brainclaw 1.24.0 → 1.26.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.
Files changed (46) hide show
  1. package/dist/brainclaw-vscode.vsix +0 -0
  2. package/dist/cli/register-code-map.js +9 -2
  3. package/dist/commands/code-map.js +120 -6
  4. package/dist/commands/mcp-catalog.js +46 -0
  5. package/dist/commands/mcp.js +58 -6
  6. package/dist/commands/session-start.js +84 -13
  7. package/dist/core/bootstrap.js +28 -4
  8. package/dist/core/code-map/aggregate.js +36 -31
  9. package/dist/core/code-map/backend.js +162 -5
  10. package/dist/core/code-map/core.js +1 -0
  11. package/dist/core/code-map/export.js +212 -0
  12. package/dist/core/code-map/finalizer.js +57 -2
  13. package/dist/core/code-map/freshness.js +81 -15
  14. package/dist/core/code-map/impact.js +409 -0
  15. package/dist/core/code-map/indexes.js +64 -3
  16. package/dist/core/code-map/lang/python/index.js +4 -2
  17. package/dist/core/code-map/lang/query-runtime.js +2 -0
  18. package/dist/core/code-map/lang/typescript/config.js +271 -0
  19. package/dist/core/code-map/lang/typescript/index.js +24 -6
  20. package/dist/core/code-map/lang/usages.js +333 -0
  21. package/dist/core/code-map/memory-reader.js +15 -0
  22. package/dist/core/code-map/query.js +285 -71
  23. package/dist/core/code-map/refresh.js +0 -0
  24. package/dist/core/code-map/resolve.js +28 -2
  25. package/dist/core/code-map/store.js +1 -0
  26. package/dist/core/code-map/types.js +70 -9
  27. package/dist/core/code-map/vocabulary.js +6 -0
  28. package/dist/core/code-map/work-section.js +12 -14
  29. package/dist/core/context-diff.js +17 -3
  30. package/dist/core/entity-operations.js +14 -2
  31. package/dist/core/federation-pull.js +151 -3
  32. package/dist/core/federation-push.js +16 -3
  33. package/dist/core/hint-aging.js +4 -1
  34. package/dist/core/identity.js +69 -17
  35. package/dist/core/io.js +27 -0
  36. package/dist/core/project-discovery.js +7 -1
  37. package/dist/core/protocol-tool-policy.js +3 -0
  38. package/dist/core/runtime.js +23 -0
  39. package/dist/core/worktree.js +89 -2
  40. package/dist/facts.js +15 -12
  41. package/dist/facts.json +14 -11
  42. package/docs/cli.md +8 -0
  43. package/docs/code-map.md +60 -28
  44. package/docs/integrations/mcp.md +5 -2
  45. package/docs/mcp-schema-changelog.md +11 -1
  46. package/package.json +1 -1
Binary file
@@ -3,12 +3,19 @@ export function registerCodeMapCommands(program) {
3
3
  // --- code-map ---
4
4
  program
5
5
  .command('code-map <subcommand> [args...]')
6
- .description('Query the per-project Code Map (status, refresh, find, brief)')
6
+ .description('Query the per-project Code Map (status, refresh, find, brief, impact, export, outline)')
7
7
  .option('--json', 'Output as JSON')
8
8
  .option('--all', 'For refresh: enumerate all supported files (full refresh)')
9
9
  .option('--changed', 'For refresh: only changed files (default)')
10
10
  .option('--cascade', 'For refresh/status in a multi-project workspace: cascade across every nested project (each gets its own store; the root store is scoped to files no child owns)')
11
- .option('--limit <n>', 'Max results for find/brief', (v) => parseInt(v, 10))
11
+ .option('--limit <n>', 'Max results for find/brief/impact/outline', (v) => parseInt(v, 10))
12
+ .option('--depth <n>', 'For impact/export: maximum graph depth (export is hard-capped at 4)', (v) => parseInt(v, 10))
13
+ .option('--direction <direction>', 'For export: outgoing, incoming, or both')
14
+ .option('--format <format>', 'For export: json (default) or mermaid')
15
+ .option('--max-nodes <n>', 'For export: maximum nodes (hard-capped at 100)', (v) => parseInt(v, 10))
16
+ .option('--max-edges <n>', 'For export: maximum edges (hard-capped at 200)', (v) => parseInt(v, 10))
17
+ .option('--min-confidence <n>', 'For export: persisted confidence floor (minimum 0.5)', (v) => parseFloat(v))
18
+ .option('--target-kind <kind>', 'For export: symbol or file (auto-detected by default)')
12
19
  .action((subcommand, args, options) => {
13
20
  void runCodeMap(subcommand, args, options).catch((err) => {
14
21
  console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
@@ -1,12 +1,12 @@
1
1
  /**
2
2
  * `brainclaw code-map <subcommand>` — CLI surface over the Code Map backend
3
3
  * (spec §9). Mirrors plan-resource.ts: a switch over the subcommand delegating
4
- * to a JsonlBackend (status | refresh | find | brief). The backend owns all
4
+ * to a JsonlBackend (status | refresh | find | brief | impact | export | outline). The backend owns
5
5
  * query logic; this file only adapts it to argv + stdout (text or --json), and
6
6
  * every output carries the freshness_badge.
7
7
  */
8
8
  import { JsonlBackend } from '../core/code-map/backend.js';
9
- const KNOWN_SUBCOMMANDS = new Set(['status', 'refresh', 'find', 'brief']);
9
+ const KNOWN_SUBCOMMANDS = new Set(['status', 'refresh', 'find', 'brief', 'impact', 'export', 'outline']);
10
10
  function backend() {
11
11
  return new JsonlBackend();
12
12
  }
@@ -15,10 +15,7 @@ function badgeLine(badge) {
15
15
  const detail = detailKeys.length
16
16
  ? ` (${detailKeys.map((k) => `${k}=${JSON.stringify(badge.details[k])}`).join(', ')})`
17
17
  : '';
18
- // pln#601 — lead with the coarse rollup (uniform across all read surfaces), then
19
- // the precise status + details. `coarse` may be absent on legacy/hand-built badges.
20
- const coarse = badge.coarse ? `${badge.coarse} · ` : '';
21
- return `Freshness: ${coarse}${badge.status}${detail}`;
18
+ return `Freshness: ${badge.freshness}${detail}`;
22
19
  }
23
20
  export async function runCodeMap(subcommand, args, options = {}) {
24
21
  const normalized = (subcommand ?? '').trim().toLowerCase();
@@ -57,6 +54,49 @@ export async function runCodeMap(subcommand, args, options = {}) {
57
54
  printBrief(result, options);
58
55
  return;
59
56
  }
57
+ if (normalized === 'impact') {
58
+ const target = args.join(' ').trim();
59
+ if (!target) {
60
+ console.error('Error: code-map impact requires <symbol-or-path>.');
61
+ console.error(' Usage: brainclaw code-map impact <symbol-or-path> [--depth 2]');
62
+ process.exit(1);
63
+ }
64
+ const result = await be.impact({ target, depth: options.depth, limit: options.limit, cwd });
65
+ printImpact(result, options);
66
+ return;
67
+ }
68
+ if (normalized === 'export') {
69
+ const target = args.join(' ').trim();
70
+ if (!target) {
71
+ console.error('Error: code-map export requires <symbol-or-path>.');
72
+ console.error(' Usage: brainclaw code-map export <symbol-or-path> [--direction both] [--depth 1] [--max-nodes 100] [--max-edges 200]');
73
+ process.exit(1);
74
+ }
75
+ const result = await be.exportGraph({
76
+ target,
77
+ targetKind: options.targetKind,
78
+ direction: options.direction,
79
+ depth: options.depth,
80
+ maxNodes: options.maxNodes,
81
+ maxEdges: options.maxEdges,
82
+ minConfidence: options.minConfidence,
83
+ format: options.format,
84
+ cwd,
85
+ });
86
+ printExport(result, options);
87
+ return;
88
+ }
89
+ if (normalized === 'outline') {
90
+ const target = args.join(' ').trim();
91
+ if (!target) {
92
+ console.error('Error: code-map outline requires <file>.');
93
+ console.error(' Usage: brainclaw code-map outline <file>');
94
+ process.exit(1);
95
+ }
96
+ const result = await be.outline({ path: target, limit: options.limit, cwd });
97
+ printOutline(result, options);
98
+ return;
99
+ }
60
100
  console.error(`Error: unknown code-map subcommand "${subcommand}".`);
61
101
  console.error(` Available: ${[...KNOWN_SUBCOMMANDS].join(', ')}`);
62
102
  process.exit(1);
@@ -144,4 +184,78 @@ function printBrief(result, options) {
144
184
  }
145
185
  }
146
186
  }
187
+ function printImpact(result, options) {
188
+ if (options.json) {
189
+ console.log(JSON.stringify(result, null, 2));
190
+ return;
191
+ }
192
+ const cause = (row) => row.causes.map((item) => `${item.kind}${item.module ? ` ${item.module}` : ''}${item.source_line ? `:${item.source_line}` : ''}`).join(', ');
193
+ console.log(`Code Map impact: "${result.target}"`);
194
+ console.log(` ${badgeLine(result.freshness_badge)}`);
195
+ console.log(` Definition: ${result.definition.entries.length} (${result.definition.match_kind})`);
196
+ for (const entry of result.definition.entries)
197
+ console.log(` ${entry.name} — ${entry.path}`);
198
+ console.log(` Direct dependents: ${result.direct_dependents.length}${result.limits.direct_truncated ? '+' : ''}`);
199
+ for (const dependent of result.direct_dependents)
200
+ console.log(` ${dependent.path} — ${cause(dependent)}`);
201
+ if (result.limits.max_depth > 1) {
202
+ console.log(` Transitive dependents: ${result.transitive_dependents.length}${result.limits.transitive_truncated ? '+' : ''}`);
203
+ for (const dependent of result.transitive_dependents)
204
+ console.log(` [depth ${dependent.depth}] ${dependent.path} — ${cause(dependent)}`);
205
+ }
206
+ console.log(` Tests: ${result.risk.counters.resolved_test_files} resolved, ${result.risk.counters.suggested_test_files} naming suggestion(s)`);
207
+ for (const test of result.tests_for)
208
+ console.log(` [${test.relation}, confidence=${test.confidence}] ${test.path} — ${test.reason}`);
209
+ console.log(` Risk: ${result.risk.score} (${result.risk.formula}; direct=${result.risk.counters.direct_dependents}, transitive=${result.risk.counters.transitive_dependents})`);
210
+ }
211
+ function printExport(result, options) {
212
+ if (options.json) {
213
+ console.log(JSON.stringify(result, null, 2));
214
+ return;
215
+ }
216
+ console.log(`Code Map export: "${result.target}" (${result.target_kind})`);
217
+ console.log(` ${badgeLine(result.freshness_badge)}`);
218
+ console.log(` Roots: ${result.root_node_ids.length}; nodes=${result.nodes.length}/${result.limits.max_nodes}; edges=${result.edges.length}/${result.limits.max_edges}; depth=${result.limits.max_depth}; direction=${result.limits.direction}; min-confidence=${result.limits.min_confidence}`);
219
+ const truncation = Object.entries(result.truncated).filter(([, value]) => value).map(([key]) => key);
220
+ if (truncation.length > 0)
221
+ console.log(` Truncated: ${truncation.join(', ')}`);
222
+ if (result.format === 'mermaid' && result.mermaid) {
223
+ console.log(result.mermaid);
224
+ return;
225
+ }
226
+ for (const edge of result.edges) {
227
+ const source = edge.source ? ` @ ${edge.source.path}${edge.source.line === null || edge.source.line === undefined ? '' : `:${edge.source.line}`}` : '';
228
+ console.log(` ${edge.from} -[${edge.kind}, confidence=${edge.confidence}${source}]-> ${edge.to}`);
229
+ }
230
+ }
231
+ function printOutline(result, options) {
232
+ if (options.json) {
233
+ console.log(JSON.stringify(result, null, 2));
234
+ return;
235
+ }
236
+ console.log(`Code Map outline: "${result.path}"`);
237
+ console.log(` Index: ${result.index_status}`);
238
+ console.log(` ${badgeLine(result.freshness_badge)}`);
239
+ if (!result.file_indexed) {
240
+ console.log(' Symbols: (file not indexed)');
241
+ return;
242
+ }
243
+ console.log(` Parse: ${result.parse_status}`);
244
+ if (result.symbols.length === 0) {
245
+ console.log(' Symbols: (none)');
246
+ }
247
+ else {
248
+ for (const symbol of result.symbols) {
249
+ const subtype = symbol.subtype ? ` ${symbol.subtype}` : '';
250
+ const span = symbol.span ? `${symbol.span.start_line}:${symbol.span.start_col}` : 'unknown';
251
+ const exported = symbol.exported ? ' export' : '';
252
+ console.log(` [${span}] ${symbol.kind}${subtype} ${symbol.name}${exported} confidence=${symbol.confidence}`);
253
+ }
254
+ }
255
+ if (result.truncated)
256
+ console.log(` … ${result.symbol_count - result.symbols.length} more indexed symbol(s)`);
257
+ if (result.diagnostics.length > 0) {
258
+ console.log(` Diagnostics: ${result.diagnostics.length}${result.diagnostics_truncated ? '+' : ''}`);
259
+ }
260
+ }
147
261
  //# sourceMappingURL=code-map.js.map
@@ -409,6 +409,52 @@ export const MCP_READ_TOOLS = [
409
409
  required: ['target'],
410
410
  },
411
411
  },
412
+ {
413
+ name: 'bclaw_code_impact',
414
+ description: 'Explain a symbol or source file\'s local blast radius from existing resolved imports: definition, direct dependents with edge causes, optional bounded transitives, affected test files, low-confidence naming suggestions kept separate, and a count-based risk score. Read-only; never refreshes.',
415
+ annotations: { tier: 'standard', category: 'discovery', headlessApproval: 'auto' },
416
+ inputSchema: {
417
+ type: 'object',
418
+ properties: {
419
+ target: { type: 'string', description: 'Symbol name or source-file path whose impact to inspect.' },
420
+ depth: { type: 'number', description: 'Maximum graph depth. 1 (default) returns direct dependents only; 2+ opts into transitives. Clamped to 4.' },
421
+ limit: { type: 'number', description: 'Maximum rows in each dependent section and in naming suggestions. Clamped to 100.' },
422
+ },
423
+ required: ['target'],
424
+ },
425
+ },
426
+ {
427
+ name: 'bclaw_code_export',
428
+ description: 'Export a compact, deterministic, confidence-filtered local Code Map subgraph around one symbol or file. Requires a target; defaults to one hop and hard caps nodes (100), edges (200), and depth (4), so it never exports a whole graph. JSON retains every edge kind, source, and confidence. format="mermaid" adds a projection of the same JSON nodes/edges. Read-only; never refreshes or calls an external service.',
429
+ annotations: { tier: 'standard', category: 'discovery', headlessApproval: 'auto' },
430
+ inputSchema: {
431
+ type: 'object',
432
+ properties: {
433
+ target: { type: 'string', description: 'Symbol name or source-file path around which to export a local subgraph.' },
434
+ targetKind: { type: 'string', enum: ['symbol', 'file'], description: 'Optional explicit target kind; otherwise a source path is detected safely.' },
435
+ direction: { type: 'string', enum: ['outgoing', 'incoming', 'both'], description: 'Which edge direction(s) to follow. Default: both.' },
436
+ depth: { type: 'number', description: 'Maximum graph distance from the root(s). Default 1; clamped to 0..4.' },
437
+ maxNodes: { type: 'number', description: 'Maximum nodes returned. Default 100; hard-capped at 100.' },
438
+ maxEdges: { type: 'number', description: 'Maximum edges returned. Default 200; hard-capped at 200.' },
439
+ minConfidence: { type: 'number', description: 'Persisted confidence threshold for nodes and relationships. Default/minimum 0.5.' },
440
+ format: { type: 'string', enum: ['json', 'mermaid'], description: 'Default json; mermaid adds a projection derived from the same bounded graph.' },
441
+ },
442
+ required: ['target'],
443
+ },
444
+ },
445
+ {
446
+ name: 'bclaw_code_outline',
447
+ description: 'Return a bounded source-ordered outline for one indexed file: symbol name, kind, subtype, span, exported flag, and persisted confidence. Read-only: it reads the existing file shard only, never parses or refreshes. `index_status` distinguishes a missing index, a file not indexed, and an indexed file with zero symbols.',
448
+ annotations: { tier: 'standard', category: 'discovery', headlessApproval: 'auto' },
449
+ inputSchema: {
450
+ type: 'object',
451
+ properties: {
452
+ path: { type: 'string', description: 'Workspace-relative source file path (for example `src/app/App.tsx`).' },
453
+ limit: { type: 'number', description: 'Maximum symbols to return; clamped to the hard cap of 200.' },
454
+ },
455
+ required: ['path'],
456
+ },
457
+ },
412
458
  ];
413
459
  const MCP_WRITE_TOOLS = [
414
460
  {
@@ -1089,15 +1089,15 @@ async function _executeMcpToolCallInner(payload) {
1089
1089
  }
1090
1090
  // Code Map tools (spec §9). These delegate to the async JsonlBackend, so
1091
1091
  // they are handled here rather than via the synchronous read-tool path.
1092
- // status/find/brief are reads; refresh is a write (prompt approval).
1093
- if (name === 'bclaw_code_status' || name === 'bclaw_code_find' || name === 'bclaw_code_brief' || name === 'bclaw_code_refresh') {
1092
+ // status/find/brief/impact/export/outline are reads; refresh is a write (prompt approval).
1093
+ if (name === 'bclaw_code_status' || name === 'bclaw_code_find' || name === 'bclaw_code_brief' || name === 'bclaw_code_impact' || name === 'bclaw_code_export' || name === 'bclaw_code_outline' || name === 'bclaw_code_refresh') {
1094
1094
  const { JsonlBackend } = await import('../core/code-map/backend.js');
1095
1095
  const be = new JsonlBackend();
1096
1096
  if (name === 'bclaw_code_status') {
1097
1097
  const status = await be.status({ cwd, cascade: args.cascade === true });
1098
1098
  return {
1099
1099
  response: toolResponse({
1100
- content: [{ type: 'text', text: `Code Map: ${status.store_exists ? 'store present' : 'no store'} — freshness=${status.freshness_badge.status}` }],
1100
+ content: [{ type: 'text', text: `Code Map: ${status.store_exists ? 'store present' : 'no store'} — freshness=${status.freshness_badge.freshness}` }],
1101
1101
  structuredContent: { ...status, freshness_badge: status.freshness_badge },
1102
1102
  }),
1103
1103
  };
@@ -1108,7 +1108,7 @@ async function _executeMcpToolCallInner(payload) {
1108
1108
  const cascadeNote = result.cascade ? ` cascade=${result.cascade.children_refreshed} child(ren)+root` : '';
1109
1109
  return {
1110
1110
  response: toolResponse({
1111
- content: [{ type: 'text', text: `Code Map refresh [${result.scope}]: ran=${result.ran} freshness=${result.freshness_badge.status}${cascadeNote}${result.lock_status ? ` (${result.lock_status})` : ''}` }],
1111
+ content: [{ type: 'text', text: `Code Map refresh [${result.scope}]: ran=${result.ran} freshness=${result.freshness_badge.freshness}${cascadeNote}${result.lock_status ? ` (${result.lock_status})` : ''}` }],
1112
1112
  structuredContent: { ...result, freshness_badge: result.freshness_badge },
1113
1113
  }),
1114
1114
  };
@@ -1122,7 +1122,59 @@ async function _executeMcpToolCallInner(payload) {
1122
1122
  const result = await be.find({ query, limit, cwd });
1123
1123
  return {
1124
1124
  response: toolResponse({
1125
- content: [{ type: 'text', text: `Code Map find "${result.query}": ${result.matches.length} match(es), freshness=${result.freshness_badge.status}` }],
1125
+ content: [{ type: 'text', text: `Code Map find "${result.query}": ${result.matches.length} match(es), freshness=${result.freshness_badge.freshness}` }],
1126
+ structuredContent: { ...result, freshness_badge: result.freshness_badge },
1127
+ }),
1128
+ };
1129
+ }
1130
+ // bclaw_code_export
1131
+ if (name === 'bclaw_code_export') {
1132
+ const target = typeof args.target === 'string' ? args.target : '';
1133
+ if (!target.trim()) {
1134
+ return { response: createToolErrorResponse('validation_error', 'bclaw_code_export requires a non-empty target.') };
1135
+ }
1136
+ const targetKind = args.targetKind === 'symbol' || args.targetKind === 'file' ? args.targetKind : undefined;
1137
+ const direction = args.direction === 'outgoing' || args.direction === 'incoming' || args.direction === 'both' ? args.direction : undefined;
1138
+ const depth = typeof args.depth === 'number' ? args.depth : undefined;
1139
+ const maxNodes = typeof args.maxNodes === 'number' ? args.maxNodes : undefined;
1140
+ const maxEdges = typeof args.maxEdges === 'number' ? args.maxEdges : undefined;
1141
+ const minConfidence = typeof args.minConfidence === 'number' ? args.minConfidence : undefined;
1142
+ const format = args.format === 'mermaid' ? 'mermaid' : args.format === 'json' ? 'json' : undefined;
1143
+ const result = await be.exportGraph({ target, targetKind, direction, depth, maxNodes, maxEdges, minConfidence, format, cwd });
1144
+ return {
1145
+ response: toolResponse({
1146
+ content: [{ type: 'text', text: `Code Map export "${result.target}": ${result.nodes.length} node(s), ${result.edges.length} edge(s), depth=${result.limits.max_depth}, freshness=${result.freshness_badge.freshness}` }],
1147
+ structuredContent: { ...result, freshness_badge: result.freshness_badge },
1148
+ }),
1149
+ };
1150
+ }
1151
+ // bclaw_code_impact
1152
+ if (name === 'bclaw_code_impact') {
1153
+ const target = typeof args.target === 'string' ? args.target : '';
1154
+ if (!target.trim()) {
1155
+ return { response: createToolErrorResponse('validation_error', 'bclaw_code_impact requires a non-empty target.') };
1156
+ }
1157
+ const depth = typeof args.depth === 'number' ? args.depth : undefined;
1158
+ const limit = typeof args.limit === 'number' ? args.limit : undefined;
1159
+ const result = await be.impact({ target, depth, limit, cwd });
1160
+ return {
1161
+ response: toolResponse({
1162
+ content: [{ type: 'text', text: `Code Map impact "${result.target}": ${result.risk.counters.direct_dependents} direct, ${result.risk.counters.transitive_dependents} transitive dependent(s), risk=${result.risk.score}, freshness=${result.freshness_badge.freshness}` }],
1163
+ structuredContent: { ...result, freshness_badge: result.freshness_badge },
1164
+ }),
1165
+ };
1166
+ }
1167
+ // bclaw_code_outline
1168
+ if (name === 'bclaw_code_outline') {
1169
+ const outlinePath = typeof args.path === 'string' ? args.path : '';
1170
+ if (!outlinePath.trim()) {
1171
+ return { response: createToolErrorResponse('validation_error', 'bclaw_code_outline requires a non-empty path.') };
1172
+ }
1173
+ const limit = typeof args.limit === 'number' ? args.limit : undefined;
1174
+ const result = await be.outline({ path: outlinePath, limit, cwd });
1175
+ return {
1176
+ response: toolResponse({
1177
+ content: [{ type: 'text', text: `Code Map outline "${result.path}": ${result.symbols.length}/${result.symbol_count} symbol(s), index=${result.index_status}, freshness=${result.freshness_badge.freshness}` }],
1126
1178
  structuredContent: { ...result, freshness_badge: result.freshness_badge },
1127
1179
  }),
1128
1180
  };
@@ -1136,7 +1188,7 @@ async function _executeMcpToolCallInner(payload) {
1136
1188
  const result = await be.brief({ target, limit, cwd });
1137
1189
  return {
1138
1190
  response: toolResponse({
1139
- content: [{ type: 'text', text: `Code Map brief "${result.target}": ${result.suggested_files_to_read.length} file(s) to read, freshness=${result.freshness_badge.status}` }],
1191
+ content: [{ type: 'text', text: `Code Map brief "${result.target}": ${result.suggested_files_to_read.length} file(s) to read, freshness=${result.freshness_badge.freshness}` }],
1140
1192
  structuredContent: { ...result, freshness_badge: result.freshness_badge },
1141
1193
  }),
1142
1194
  };
@@ -2,7 +2,7 @@ import fs from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import { execSync } from 'node:child_process';
5
- import { memoryExists, resolveEntityDir } from '../core/io.js';
5
+ import { isSessionSnapshotRecordFilename, memoryExists, resolveEntityDir, sessionSnapshotRecordPaths } from '../core/io.js';
6
6
  import { loadVersionedJsonFile, saveVersionedJsonFile } from '../core/migration.js';
7
7
  import { buildOperationalIdentity, loadAllSessions, saveCurrentSession } from '../core/identity.js';
8
8
  import { requireMinimumTrustLevel, resolveCurrentModel, resolveOrAutoRegisterAgentIdentity } from '../core/agent-registry.js';
@@ -25,11 +25,55 @@ import { loadHygienePolicy } from '../core/hygiene-policy.js';
25
25
  import { maybeCreateCheckpoint } from '../core/events/checkpoint.js';
26
26
  import { pullSignalsFromLinkedProjects, markSignalProcessed } from '../core/federation-transport.js';
27
27
  import { materializeFederationSignal } from '../core/federation-materialize.js';
28
- function sessionsDir(cwd) {
29
- return resolveEntityDir('sessions', cwd ?? process.cwd(), 'read');
28
+ /**
29
+ * pln#670 snapshot writes always target the canonical directory ('write' mode).
30
+ * The previous 'read'-mode resolution meant a fresh store (no coordination/sessions
31
+ * yet) landed the snapshot in the legacy dir — the current_session home — where
32
+ * saveCurrentSession then clobbered it (same `<session_id>.json` name, same id).
33
+ */
34
+ function sessionSnapshotWriteDir(cwd) {
35
+ return resolveEntityDir('sessions', cwd ?? process.cwd(), 'write');
30
36
  }
31
37
  function sessionSnapshotPath(sessionId, cwd) {
32
- return path.join(sessionsDir(cwd), `${sessionId}.json`);
38
+ return path.join(sessionSnapshotWriteDir(cwd), `${sessionId}.snapshot.json`);
39
+ }
40
+ /**
41
+ * pln#670 — lazy migration of pre-split snapshot records: rename `<id>.json` to
42
+ * `<id>.snapshot.json` in the CANONICAL sessions directory only. The legacy
43
+ * directory is never scanned — it is the current_session home. Only files that
44
+ * validate as session_snapshot are touched; anything else is left in place.
45
+ */
46
+ export function migrateLegacySnapshotNames(cwd) {
47
+ const dir = sessionSnapshotWriteDir(cwd);
48
+ if (!fs.existsSync(dir))
49
+ return 0;
50
+ let renamed = 0;
51
+ for (const file of fs.readdirSync(dir)) {
52
+ // Case-insensitive suffix checks (codex review P1): Windows filesystems
53
+ // match names case-insensitively — an upper-cased `.SNAPSHOT.json` is the
54
+ // same record a lower-case probe reads, and must not be re-suffixed.
55
+ if (!file.toLowerCase().endsWith('.json') || isSessionSnapshotRecordFilename(file))
56
+ continue;
57
+ const from = path.join(dir, file);
58
+ try {
59
+ // Discriminate on the RAW file — the migration loader zod-strips unknown
60
+ // keys, so a current_session record parses as a clean snapshot after it.
61
+ // Key PRESENCE, not value type (codex review P1): the invariant is
62
+ // "never touch a record CARRYING last_seen_at", and `last_seen_at: null`
63
+ // must be rejected too.
64
+ const raw = JSON.parse(fs.readFileSync(from, 'utf-8'));
65
+ if (Object.hasOwn(raw, 'last_seen_at'))
66
+ continue;
67
+ SessionSnapshotSchema.parse(loadVersionedJsonFile('session_snapshot', from).document);
68
+ const to = path.join(dir, `${file.slice(0, -'.json'.length)}.snapshot.json`);
69
+ if (!fs.existsSync(to)) {
70
+ fs.renameSync(from, to);
71
+ renamed++;
72
+ }
73
+ }
74
+ catch { /* not a session_snapshot — leave it alone */ }
75
+ }
76
+ return renamed;
33
77
  }
34
78
  export async function runSessionStart(options = {}) {
35
79
  try {
@@ -155,7 +199,7 @@ export async function startSession(options = {}) {
155
199
  ...(model ? { model } : {}),
156
200
  };
157
201
  // Persist snapshot
158
- const dir = sessionsDir(options.cwd);
202
+ const dir = sessionSnapshotWriteDir(options.cwd);
159
203
  if (!fs.existsSync(dir))
160
204
  fs.mkdirSync(dir, { recursive: true });
161
205
  saveVersionedJsonFile('session_snapshot', sessionSnapshotPath(snapshot.session_id, options.cwd), SessionSnapshotSchema.parse(snapshot));
@@ -218,6 +262,14 @@ export async function startSession(options = {}) {
218
262
  inventoryAdvisory = lines;
219
263
  }
220
264
  catch { /* non-fatal — inventory scan failure should not block session start */ }
265
+ // pln#670 — lazy rename of pre-split snapshot records to the type-suffixed
266
+ // name. Session-start full maintenance is the natural sweep point (no daemon,
267
+ // feedback_lazy_reconcile_pattern); dual-read keeps unrenamed records readable
268
+ // in the meantime.
269
+ try {
270
+ migrateLegacySnapshotNames(options.cwd);
271
+ }
272
+ catch { /* non-fatal — name migration must never block session start */ }
221
273
  // pln#564 step B — cap the runtime-note tree on session start (no LLM gate,
222
274
  // unlike the compaction-phase archiveSessionNotes). Keeps the newest N
223
275
  // session/lifecycle notes per agent + all genuine observations, parks the
@@ -367,14 +419,33 @@ function isPidAlive(pid) {
367
419
  }
368
420
  }
369
421
  export function loadSessionSnapshot(sessionId, cwd) {
370
- const p = sessionSnapshotPath(sessionId, cwd);
371
- if (!fs.existsSync(p))
372
- return undefined;
373
- try {
374
- return SessionSnapshotSchema.parse(loadVersionedJsonFile('session_snapshot', p).document);
375
- }
376
- catch {
377
- return undefined;
422
+ // pln#670 probe the type-suffixed name first, then the pre-split `<id>.json`
423
+ // layouts. SessionSnapshotSchema is non-strict, so a current_session record for
424
+ // the same id would parse too (zod strips unknown keys) — the negative
425
+ // discriminant is `last_seen_at`, which only current_session carries.
426
+ for (const p of sessionSnapshotRecordPaths(sessionId, cwd)) {
427
+ if (!fs.existsSync(p))
428
+ continue;
429
+ try {
430
+ // Discriminate on the RAW file: the migration loader zod-strips unknown
431
+ // keys, so a current_session record would come back looking like a clean
432
+ // snapshot. Key PRESENCE, not value type (codex review P1) — a record
433
+ // carrying `last_seen_at: null` must be rejected too.
434
+ const raw = JSON.parse(fs.readFileSync(p, 'utf-8'));
435
+ if (Object.hasOwn(raw, 'last_seen_at'))
436
+ continue;
437
+ const snapshot = SessionSnapshotSchema.parse(loadVersionedJsonFile('session_snapshot', p).document);
438
+ // The filename is not a type-safe identity boundary by itself (codex
439
+ // review): querying `sess_x.snapshot` also constructs `sess_x.snapshot.json`
440
+ // — the snapshot of sess_x. Only return a payload that names the caller's id.
441
+ if (snapshot.session_id !== sessionId)
442
+ continue;
443
+ return snapshot;
444
+ }
445
+ catch {
446
+ continue;
447
+ }
378
448
  }
449
+ return undefined;
379
450
  }
380
451
  //# sourceMappingURL=session-start.js.map
@@ -9,6 +9,7 @@ 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
11
  import { analyzeRepository, findNestedAgentsFiles } from './repo-analysis.js';
12
+ import { isManagedByBrainclaw } from './project-discovery.js';
12
13
  import { buildExecutionContext, compactExecutionContext } from './execution-context.js';
13
14
  import { buildAgentToolingContext } from './agent-context.js';
14
15
  import { createInstruction, loadInstructions, saveInstruction } from './instructions.js';
@@ -256,15 +257,38 @@ function buildBootstrapArtifacts(input) {
256
257
  sourcesScanned.push('README');
257
258
  seeds.push(...extractReadmeSeeds(readmePath, input.target));
258
259
  }
260
+ // pln#671 — instruction files generated by `brainclaw export` derive FROM
261
+ // brainclaw memory: deriving seeds from them feeds the store its own output
262
+ // back as "new" knowledge. Seed extraction skips managed files; DETECTION
263
+ // (agentsPresent, native_instruction_files, source fingerprint) stays
264
+ // complete — that is environment inventory, not knowledge to import. The
265
+ // skip is traced in sources_scanned so the exclusion is never silent.
259
266
  const agentsPath = path.join(scanRoot, 'AGENTS.md');
260
267
  const agentsPresent = fs.existsSync(agentsPath);
261
268
  if (agentsPresent) {
262
- sourcesScanned.push('AGENTS.md');
263
- seeds.push(...extractAgentsSeeds(agentsPath, input.target));
269
+ if (isManagedByBrainclaw(agentsPath)) {
270
+ sourcesScanned.push('AGENTS.md (brainclaw-managed — skipped)');
271
+ }
272
+ else {
273
+ sourcesScanned.push('AGENTS.md');
274
+ seeds.push(...extractAgentsSeeds(agentsPath, input.target));
275
+ }
264
276
  }
265
277
  if (nativeInstructionFiles.length > 0) {
266
- sourcesScanned.push('native_instructions');
267
- seeds.push(...extractNativeInstructionSeeds(nativeInstructionFiles.map((relativePath) => path.join(scanRoot, relativePath)), scanRoot, input.target));
278
+ // AGENTS.md is handled by its own extractor above — keep it out of the
279
+ // native accounting so a managed AGENTS.md is not counted as skipped twice.
280
+ const nativeCandidates = nativeInstructionFiles
281
+ .filter((relativePath) => path.basename(relativePath) !== 'AGENTS.md');
282
+ const humanAuthored = nativeCandidates
283
+ .filter((relativePath) => !isManagedByBrainclaw(path.join(scanRoot, relativePath)));
284
+ const skippedManaged = nativeCandidates.length - humanAuthored.length;
285
+ if (humanAuthored.length > 0) {
286
+ sourcesScanned.push('native_instructions');
287
+ seeds.push(...extractNativeInstructionSeeds(humanAuthored.map((relativePath) => path.join(scanRoot, relativePath)), scanRoot, input.target));
288
+ }
289
+ if (skippedManaged > 0) {
290
+ sourcesScanned.push(`native_instructions (${skippedManaged} brainclaw-managed — skipped)`);
291
+ }
268
292
  }
269
293
  const manifestResult = extractManifestSeeds(scanRoot, input.target);
270
294
  if (manifestResult.seeds.length > 0) {
@@ -26,7 +26,7 @@ import path from 'node:path';
26
26
  import { loadConfig } from '../config.js';
27
27
  import { listNestedProjects } from './cascade.js';
28
28
  import { readManifest, readImportsIndex } from './store.js';
29
- import { coarseFreshness, applyGitHeadDrift } from './freshness.js';
29
+ import { makeFreshnessBadge, applyGitHeadDrift } from './freshness.js';
30
30
  import { findInStore, briefInStore, makeLazyChecker, newAccumulator, deriveBadge, reserveSourceSlots, attachRelatedMemory, attachMemoryIds, validateStoreEntry, BRIEF_FILE_CAP, LAZY_BUDGET, } from './query.js';
31
31
  /** Same default cap as the single-store find (query.ts DEFAULT_FIND_LIMIT). */
32
32
  const DEFAULT_FIND_LIMIT = 20;
@@ -191,22 +191,11 @@ function statusRank(s) {
191
191
  function mergeBadges(perStore) {
192
192
  const total = perStore.length;
193
193
  const indexed = perStore.filter((p) => p.hasIndex);
194
- const unindexed = perStore
195
- .filter((p) => !p.hasIndex)
196
- .map((p) => p.ref.relPath || '.')
197
- .sort();
194
+ const unindexed = perStore.filter((p) => !p.hasIndex).map((p) => p.ref.relPath || '.').sort();
198
195
  if (indexed.length === 0) {
199
- return {
200
- status: 'missing_index',
201
- coarse: 'missing',
202
- details: {
203
- traversal: 'workspace',
204
- projects_indexed: 0,
205
- projects_total: total,
206
- unindexed_projects: unindexed,
207
- hint: 'run refresh --cascade',
208
- },
209
- };
196
+ return makeFreshnessBadge('missing_index', {
197
+ extra: { traversal: 'workspace', projects_indexed: 0, projects_total: total, unindexed_projects: unindexed, hint: 'run refresh --cascade' },
198
+ });
210
199
  }
211
200
  let worst = indexed[0].badge.status;
212
201
  for (const p of indexed) {
@@ -221,12 +210,11 @@ function mergeBadges(perStore) {
221
210
  };
222
211
  if (unindexed.length)
223
212
  details.unindexed_projects = unindexed;
224
- // Merge the per-store detail path-sets, prefixing each with its store's
225
- // workspace-relative dir so a bare `src/index.ts` isn't ambiguous across packages.
226
213
  const prefixMerge = (key) => {
227
214
  const out = [];
228
215
  for (const p of indexed) {
229
- const arr = p.badge.details?.[key];
216
+ const spot = p.badge.details.spot_check;
217
+ const arr = spot?.[key];
230
218
  if (Array.isArray(arr)) {
231
219
  for (const f of arr)
232
220
  out.push(p.ref.relPath ? `${p.ref.relPath}/${String(f)}` : String(f));
@@ -234,16 +222,25 @@ function mergeBadges(perStore) {
234
222
  }
235
223
  return out.sort();
236
224
  };
237
- for (const key of ['stale_changed_files', 'deleted_files', 'unchecked_files']) {
238
- const merged = prefixMerge(key);
239
- if (merged.length)
240
- details[key] = merged;
241
- }
242
- const partialStore = indexed.find((p) => p.badge.status === 'partial');
243
- if (partialStore) {
244
- details.partial_reason = partialStore.badge.details?.partial_reason ?? 'lazy_check_budget_exhausted';
245
- }
246
- return { status: worst, coarse: coarseFreshness(worst), details };
225
+ const spotChecks = indexed.map((p) => p.badge.details.spot_check);
226
+ const spotStatus = spotChecks.some((spot) => spot?.status === 'partial')
227
+ ? 'partial'
228
+ : spotChecks.some((spot) => spot?.status === 'stale')
229
+ ? 'stale'
230
+ : spotChecks.some((spot) => spot?.status === 'fresh') ? 'fresh' : 'not_run';
231
+ const partialSpot = spotChecks.find((spot) => spot?.status === 'partial');
232
+ return makeFreshnessBadge(worst, {
233
+ spotCheck: {
234
+ status: spotStatus,
235
+ checked_files: spotChecks.reduce((sum, spot) => sum + (spot?.checked_files ?? 0), 0),
236
+ stale_changed_files: prefixMerge('stale_changed_files'),
237
+ deleted_files: prefixMerge('deleted_files'),
238
+ unchecked_files: prefixMerge('unchecked_files'),
239
+ budget_exhausted: spotChecks.some((spot) => spot?.budget_exhausted === true),
240
+ partial_reason: partialSpot?.partial_reason ?? null,
241
+ },
242
+ extra: details,
243
+ });
247
244
  }
248
245
  /**
249
246
  * Aggregated find across a resolved multi-project workspace. Shares ONE lazy budget
@@ -426,9 +423,15 @@ export function aggregateBrief(target, limit, resolved, currentHead, memoryReade
426
423
  const seen = new Set();
427
424
  const mergedDefiningPaths = new Set();
428
425
  const symbolNames = new Set();
426
+ const memorySymbolNames = new Set();
427
+ const memoryImportNames = new Set();
429
428
  for (const p of contributing) {
430
429
  for (const e of p.r.defining)
431
430
  symbolNames.add(e.name);
431
+ for (const name of p.r.memorySymbolNames)
432
+ memorySymbolNames.add(name);
433
+ for (const name of p.r.memoryImportNames)
434
+ memoryImportNames.add(name);
432
435
  for (const dp of p.r.definingPaths)
433
436
  mergedDefiningPaths.add(p.ref.relPath ? `${p.ref.relPath}/${dp}` : dp);
434
437
  for (const rf of p.r.confident) {
@@ -460,8 +463,10 @@ export function aggregateBrief(target, limit, resolved, currentHead, memoryReade
460
463
  const capped = reserveSourceSlots(merged, cap, mergedDefiningPaths);
461
464
  if (symbolNames.size === 0)
462
465
  symbolNames.add(target);
463
- const related = attachRelatedMemory(memoryReader({ cwd: resolved.root }), capped.map((f) => f.path), [...symbolNames]);
464
- const baseEntries = attachMemoryIds(capped, related);
466
+ if (memorySymbolNames.size === 0)
467
+ memorySymbolNames.add(target);
468
+ const related = attachRelatedMemory(memoryReader({ cwd: resolved.root }), capped.map((f) => f.path), [...memorySymbolNames], [...memoryImportNames]);
469
+ const baseEntries = attachMemoryIds(capped, related, mergedDefiningPaths);
465
470
  const suggested = baseEntries.map((s, i) => ({
466
471
  ...s,
467
472
  project: capped[i].project,