brainclaw 1.25.0 → 1.26.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/brainclaw-vscode.vsix +0 -0
  2. package/dist/commands/code-map.js +1 -4
  3. package/dist/commands/mcp.js +7 -7
  4. package/dist/commands/session-start.js +137 -15
  5. package/dist/core/bootstrap.js +28 -4
  6. package/dist/core/code-map/aggregate.js +36 -31
  7. package/dist/core/code-map/backend.js +4 -4
  8. package/dist/core/code-map/core.js +1 -0
  9. package/dist/core/code-map/export.js +4 -4
  10. package/dist/core/code-map/finalizer.js +57 -2
  11. package/dist/core/code-map/freshness.js +78 -13
  12. package/dist/core/code-map/impact.js +36 -4
  13. package/dist/core/code-map/indexes.js +37 -0
  14. package/dist/core/code-map/lang/python/index.js +4 -2
  15. package/dist/core/code-map/lang/query-runtime.js +2 -0
  16. package/dist/core/code-map/lang/typescript/index.js +4 -2
  17. package/dist/core/code-map/lang/usages.js +333 -0
  18. package/dist/core/code-map/memory-reader.js +15 -0
  19. package/dist/core/code-map/query.js +209 -58
  20. package/dist/core/code-map/refresh.js +0 -0
  21. package/dist/core/code-map/resolve.js +27 -2
  22. package/dist/core/code-map/store.js +1 -0
  23. package/dist/core/code-map/types.js +55 -9
  24. package/dist/core/code-map/vocabulary.js +6 -0
  25. package/dist/core/code-map/work-section.js +12 -14
  26. package/dist/core/context-diff.js +17 -3
  27. package/dist/core/entity-operations.js +14 -2
  28. package/dist/core/hint-aging.js +4 -1
  29. package/dist/core/identity.js +284 -91
  30. package/dist/core/io.js +192 -0
  31. package/dist/core/project-discovery.js +7 -1
  32. package/dist/core/runtime.js +99 -11
  33. package/dist/core/store-resolution.js +5 -21
  34. package/dist/facts.js +12 -12
  35. package/dist/facts.json +11 -11
  36. package/docs/code-map.md +36 -27
  37. package/package.json +1 -1
package/dist/core/io.js CHANGED
@@ -1,4 +1,6 @@
1
+ import crypto from 'node:crypto';
1
2
  import fs from 'node:fs';
3
+ import os from 'node:os';
2
4
  import path from 'node:path';
3
5
  import { withLock, cleanStaleLocks } from './lock.js';
4
6
  export { mutate } from './mutation-pipeline.js';
@@ -121,9 +123,199 @@ export function entityRecordDirs(subdir, cwd = process.cwd(), preferredDirName)
121
123
  export function entityRecordPaths(subdir, id, cwd, preferredDirName) {
122
124
  return entityRecordDirs(subdir, cwd ?? process.cwd(), preferredDirName).map((d) => path.join(d, `${id}.json`));
123
125
  }
126
+ /**
127
+ * The id grammar every session record filename is built from (pln#672).
128
+ *
129
+ * A session id arrives from the ENVIRONMENT (BRAINCLAW_SESSION_ID and the
130
+ * per-agent variants read by resolveExplicitSessionId) and is interpolated
131
+ * straight into a filename: `<id>.json` / `<id>.snapshot.json`. Unvalidated,
132
+ * `../../../ESCAPED` walks out of the store — reproduced on disk on
133
+ * 2026-08-18: saveCurrentSession wrote outside the store root, and the same
134
+ * path feeds loadSessionById (read) and clearCurrentSession (unlink).
135
+ *
136
+ * Allowed: a leading alphanumeric, then alphanumerics, `.`, `_`, `-`, up to
137
+ * 128 chars — covers brainclaw's own `sess_<hex>` and the UUID-shaped ids
138
+ * some agents export. Refused by construction: path separators, `..`, drive
139
+ * letters and absolute paths, empty ids, and any id starting with a dot.
140
+ * The `.snapshot` suffix stays separately refused for current_session ids
141
+ * (pln#670) — that is a type-collision rule, not a path-safety one.
142
+ */
143
+ const SAFE_SESSION_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
144
+ /**
145
+ * Win32 device namespace (pln#672 review P2, reproduced on a Windows host):
146
+ * `CON`, `NUL`, `COM1`… are not directory entries — `CON.json` opens the
147
+ * console device, `stat` reports a file, and the sessions directory stays
148
+ * empty. A record "written" there is silently lost. The reservation applies
149
+ * to the basename BEFORE the first dot, case-insensitively, so `con.json`
150
+ * and `Con.anything` are covered too. Refused on every platform: the grammar
151
+ * is shared, and an id must mean the same thing on all of them.
152
+ */
153
+ const WIN32_RESERVED_BASENAME_RE = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i;
154
+ export function isSafeSessionId(sessionId) {
155
+ if (!SAFE_SESSION_ID_RE.test(sessionId))
156
+ return false;
157
+ return !WIN32_RESERVED_BASENAME_RE.test(sessionId.split('.')[0]);
158
+ }
159
+ /** Throwing variant for the filename builders — a traversal must be loud, never silent. */
160
+ export function assertSafeSessionId(sessionId) {
161
+ if (!isSafeSessionId(sessionId)) {
162
+ throw new Error(`session id '${sessionId}' is not a valid record identifier: only [A-Za-z0-9._-] (starting with an alphanumeric, max 128 chars) may become a session filename`);
163
+ }
164
+ }
165
+ /**
166
+ * Normalize an agent name into ONE filesystem path segment (pln#673).
167
+ *
168
+ * The agent name arrives from the environment (BRAINCLAW_AGENT_NAME, read by
169
+ * resolveCurrentAgentName) and became a DIRECTORY name unvalidated: proved on
170
+ * disk on 2026-08-18 that `'../../../../outside/PWNED'` made saveRuntimeNote
171
+ * create the directory and write the note ENTIRELY OUTSIDE the store.
172
+ *
173
+ * This is deliberately the SAME normalization the inbox has always used
174
+ * (`agentInboxDir`, messaging.ts) rather than a new convention: lower-case,
175
+ * then every character outside [a-z0-9_-] becomes `_`. Separators and dots
176
+ * cannot survive it, so no traversal can. When that replacement (or a length
177
+ * cap) would collapse distinct raw names, a stable hash suffix keeps their
178
+ * runtime directories separate. It remains IDENTITY for every normal agent
179
+ * name brainclaw produces (claude-code, codex, github-copilot, …) — verified
180
+ * against the real store — so existing normal directories do not move.
181
+ * Readers still probe a contained raw legacy name as a fallback (see
182
+ * runtime.ts) so a non-canonical legacy directory never becomes invisible.
183
+ *
184
+ * Alias resolution is deliberately NOT applied here: mapping `copilot` to
185
+ * `github-copilot` would relocate notes, which is a product decision, not a
186
+ * path-safety one.
187
+ */
188
+ const AGENT_SEGMENT_UNSAFE_RE = /[^a-z0-9_-]/g;
189
+ const WIN32_RESERVED_BASENAME_FOR_SEGMENT_RE = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i;
190
+ // 128 leaves ample room for the record id and works below the 255-byte
191
+ // component limit even when an agent name contains non-BMP characters (which
192
+ // normalize to ASCII underscores). The hash makes truncation collision-safe.
193
+ const MAX_AGENT_PATH_SEGMENT_LENGTH = 128;
194
+ export function sanitizeAgentPathSegment(agent) {
195
+ const source = agent.trim().toLowerCase();
196
+ let normalized = source.replace(AGENT_SEGMENT_UNSAFE_RE, '_');
197
+ if (normalized.length === 0)
198
+ return 'unknown-agent';
199
+ // A Win32 device name is not a usable directory either (mkdir CON fails).
200
+ if (WIN32_RESERVED_BASENAME_FOR_SEGMENT_RE.test(normalized))
201
+ normalized = `${normalized}_`;
202
+ // A replacement would otherwise merge distinct identities (`a.b` and `a_b`)
203
+ // into one runtime directory. Preserve safe canonical names exactly, but add
204
+ // a deterministic discriminator to every lossy or length-capped segment.
205
+ if (source === normalized && normalized.length <= MAX_AGENT_PATH_SEGMENT_LENGTH)
206
+ return normalized;
207
+ const suffix = crypto.createHash('sha256').update(source).digest('hex').slice(0, 16);
208
+ return `${normalized.slice(0, MAX_AGENT_PATH_SEGMENT_LENGTH - suffix.length - 1)}_${suffix}`;
209
+ }
210
+ export const SESSION_SNAPSHOT_FILENAME_SUFFIX = '.snapshot.json';
211
+ /**
212
+ * Filesystem type discriminator for session snapshots (codex review, pln#670).
213
+ * Case-fold before the suffix comparison because default Windows filesystems
214
+ * are case-insensitive: `X.SNAPSHOT.json` IS the path a lower-case probe
215
+ * resolves, and every suffix decision must agree on its type.
216
+ */
217
+ export function isSessionSnapshotRecordFilename(filename) {
218
+ return filename.toLowerCase().endsWith(SESSION_SNAPSHOT_FILENAME_SUFFIX);
219
+ }
220
+ /**
221
+ * EVERY path a session_snapshot record for `sessionId` can occupy, canonical first.
222
+ *
223
+ * session_snapshot and current_session are two different record types that share
224
+ * the `sessions` directory family AND the same session_id — only the filename keeps
225
+ * them apart (pln#670). Snapshots are written as `<id>.snapshot.json` so a
226
+ * current_session `<id>.json` for the same session can never clobber them, whatever
227
+ * directory each resolver picks. The plain `<id>.json` probes cover records written
228
+ * before the split; readers must schema-validate every candidate.
229
+ */
230
+ export function sessionSnapshotRecordPaths(sessionId, cwd, preferredDirName) {
231
+ // pln#672 — the id becomes a filename here too: refuse a traversal loudly
232
+ // rather than build a path that escapes the store.
233
+ assertSafeSessionId(sessionId);
234
+ const dirs = entityRecordDirs('sessions', cwd ?? process.cwd(), preferredDirName);
235
+ return [
236
+ ...dirs.map((d) => path.join(d, `${sessionId}.snapshot.json`)),
237
+ ...dirs.map((d) => path.join(d, `${sessionId}.json`)),
238
+ ];
239
+ }
124
240
  export function memoryDir(cwd = process.cwd(), preferredDirName) {
125
241
  return path.join(cwd, preferredDirName ?? MEMORY_DIR);
126
242
  }
243
+ /**
244
+ * Walk UP from a directory and return the outermost .brainclaw/ root found.
245
+ * Bypasses resolveEffectiveCwd / active project entirely — the answer depends
246
+ * only on the filesystem, which is what makes it safe for identity-level state
247
+ * that must NOT follow the active project (pln#648: a session record anchored
248
+ * on the effective cwd moved with every switch, out of the resolver's reach).
249
+ *
250
+ * Lives HERE, in a leaf module: identity.ts needs it, and store-resolution.ts
251
+ * imports identity.ts — the import cycle that blocked pln#648's first attempt.
252
+ * store-resolution re-exports it for its existing callers.
253
+ *
254
+ * Stops at the filesystem root, at $HOME (a user-level store is never a
255
+ * workspace root), and never climbs ABOVE BRAINCLAW_STORE_BOUNDARY when set —
256
+ * the containment contract tests and agent shells rely on (a leaked parent
257
+ * store must not widen the walk into the host machine).
258
+ */
259
+ export function findOutermostBrainclawRoot(startDir) {
260
+ let dir = path.resolve(startDir);
261
+ const root = path.parse(dir).root;
262
+ const home = os.homedir();
263
+ const boundaryRaw = process.env.BRAINCLAW_STORE_BOUNDARY?.trim();
264
+ const boundary = boundaryRaw ? path.resolve(boundaryRaw) : undefined;
265
+ let outermost;
266
+ while (dir !== root && dir !== home) {
267
+ if (fs.existsSync(path.join(dir, MEMORY_DIR, 'config.yaml'))) {
268
+ outermost = dir;
269
+ }
270
+ if (boundary && dir === boundary)
271
+ break;
272
+ const parent = path.dirname(dir);
273
+ if (parent === dir)
274
+ break;
275
+ dir = parent;
276
+ }
277
+ return outermost;
278
+ }
279
+ /**
280
+ * The workspace anchor for identity-level state (pln#648 review P1): walking
281
+ * UP, the NEAREST store declaring `store_type: workspace` wins; only when no
282
+ * workspace is declared does the outermost store answer. Without the role
283
+ * check, two sibling declared workspaces under a common parent store would
284
+ * anchor to that parent and see each other's sessions — breaking exactly the
285
+ * isolation `resolveWorkspaceRoot` (chain-based, role-aware) guarantees.
286
+ * The role is read from the raw YAML — the same convention the store-chain
287
+ * walk uses (`store_type` is not part of the typed Config surface) — so this
288
+ * stays a leaf-module fs answer with no config.ts dependency.
289
+ * Same stops as the outermost walk: filesystem root, $HOME, and never above
290
+ * BRAINCLAW_STORE_BOUNDARY.
291
+ */
292
+ export function findSessionAnchorRoot(startDir) {
293
+ let dir = path.resolve(startDir);
294
+ const root = path.parse(dir).root;
295
+ const home = os.homedir();
296
+ const boundaryRaw = process.env.BRAINCLAW_STORE_BOUNDARY?.trim();
297
+ const boundary = boundaryRaw ? path.resolve(boundaryRaw) : undefined;
298
+ let outermost;
299
+ while (dir !== root && dir !== home) {
300
+ const configPath = path.join(dir, MEMORY_DIR, 'config.yaml');
301
+ if (fs.existsSync(configPath)) {
302
+ outermost = dir;
303
+ try {
304
+ if (/^store_type:\s*workspace\b/m.test(fs.readFileSync(configPath, 'utf-8'))) {
305
+ return dir;
306
+ }
307
+ }
308
+ catch { /* unreadable config — treat as a plain store */ }
309
+ }
310
+ if (boundary && dir === boundary)
311
+ break;
312
+ const parent = path.dirname(dir);
313
+ if (parent === dir)
314
+ break;
315
+ dir = parent;
316
+ }
317
+ return outermost;
318
+ }
127
319
  export function memoryPath(filename, cwd, preferredDirName) {
128
320
  return path.join(memoryDir(cwd, preferredDirName), filename);
129
321
  }
@@ -222,7 +222,13 @@ function discoverFiles(cwd, files, dirs) {
222
222
  }
223
223
  return results;
224
224
  }
225
- function isManagedByBrainclaw(filePath) {
225
+ /**
226
+ * True when an instruction file was generated by `brainclaw export` (or carries
227
+ * a managed section marker). Exported for the bootstrap scan (pln#671): a
228
+ * managed export derives FROM brainclaw memory, so proposing it as a bootstrap
229
+ * import would feed brainclaw its own output back as new knowledge.
230
+ */
231
+ export function isManagedByBrainclaw(filePath) {
226
232
  try {
227
233
  const content = fs.readFileSync(filePath, 'utf-8').slice(0, 200);
228
234
  return content.includes('brainclaw') && (content.includes('Managed by brainclaw') ||
@@ -2,7 +2,7 @@ import crypto from 'node:crypto';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { resolveCurrentHostId, sanitizeHostId } from './host.js';
5
- import { resolveEntityDir } from './io.js';
5
+ import { resolveEntityDir, sanitizeAgentPathSegment } from './io.js';
6
6
  import { mutate } from './mutation-pipeline.js';
7
7
  import { loadVersionedJsonFile, saveVersionedJsonFile } from './migration.js';
8
8
  import { RuntimeNoteSchema } from './schema.js';
@@ -19,14 +19,60 @@ function privateRuntimeDir(cwd, mode = 'read') {
19
19
  return resolveEntityDir('runtime-private', cwd ?? process.cwd(), mode);
20
20
  }
21
21
  function sharedAgentDir(agent, cwd, mode = 'read') {
22
- return path.join(sharedRuntimeDir(cwd, mode), agent);
22
+ // pln#673 the agent name is env-controlled and becomes a path segment:
23
+ // normalize it so a traversal cannot be expressed at all.
24
+ return path.join(sharedRuntimeDir(cwd, mode), sanitizeAgentPathSegment(agent));
25
+ }
26
+ /**
27
+ * Return a pre-normalization directory only when it is provably one direct
28
+ * child of `baseDir`. Compatibility reads must not reintroduce the traversal
29
+ * the normalized write path closes: the agent name is still env-controlled.
30
+ *
31
+ * Dots inside a segment are retained for existing names such as
32
+ * `Legacy.Agent`; separators, Win32 aliases (including trailing dots/spaces),
33
+ * and platform-invalid components are not legacy data we can safely probe.
34
+ */
35
+ const UNSAFE_LEGACY_AGENT_SEGMENT_RE = /[<>:"/\\|?*\u0000-\u001F]/;
36
+ const WIN32_RESERVED_LEGACY_AGENT_BASENAME_RE = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i;
37
+ function legacyAgentDir(baseDir, agent) {
38
+ if (agent.length === 0
39
+ || agent !== agent.trim()
40
+ || agent.endsWith('.')
41
+ || UNSAFE_LEGACY_AGENT_SEGMENT_RE.test(agent)
42
+ || WIN32_RESERVED_LEGACY_AGENT_BASENAME_RE.test(agent.split('.')[0]))
43
+ return undefined;
44
+ const base = path.resolve(baseDir);
45
+ const candidate = path.resolve(path.join(base, agent));
46
+ return path.dirname(candidate) === base ? candidate : undefined;
47
+ }
48
+ /**
49
+ * Both directories an agent's notes can occupy, canonical first (pln#673).
50
+ * Writes always use the normalized segment; reads also probe the RAW name so
51
+ * notes written before the normalization stay visible — the dual-read pattern
52
+ * pln#648/pln#670 already use for relocated records. Deduped when the name is
53
+ * already canonical, which is the case for every agent brainclaw produces.
54
+ */
55
+ function agentDirCandidates(baseDir, agent) {
56
+ const canonical = path.join(baseDir, sanitizeAgentPathSegment(agent));
57
+ const raw = legacyAgentDir(baseDir, agent);
58
+ return raw && canonical !== raw ? [canonical, raw] : [canonical];
23
59
  }
24
60
  function hostRootDir(visibility, hostId, cwd, mode = 'read') {
25
61
  const baseDir = visibility === 'machine' ? machineRuntimeDir(cwd, mode) : privateRuntimeDir(cwd, mode);
26
62
  return path.join(baseDir, sanitizeHostId(hostId));
27
63
  }
28
64
  function hostAgentDir(visibility, hostId, agent, cwd, mode = 'read') {
29
- return path.join(hostRootDir(visibility, hostId, cwd, mode), agent);
65
+ // pln#673 same normalization as the shared tree; the host segment was
66
+ // already sanitized (sanitizeHostId), the agent segment was not.
67
+ return path.join(hostRootDir(visibility, hostId, cwd, mode), sanitizeAgentPathSegment(agent));
68
+ }
69
+ /** A contained raw path that can be retired after an update reaches its canonical location. */
70
+ function legacyRuntimeNotePath(note, visibility, hostId, cwd) {
71
+ const base = visibility === 'shared'
72
+ ? sharedRuntimeDir(cwd, 'write')
73
+ : hostRootDir(visibility, hostId, cwd, 'write');
74
+ const legacyDir = legacyAgentDir(base, note.agent);
75
+ return legacyDir ? path.join(legacyDir, `${note.id}.json`) : undefined;
30
76
  }
31
77
  export function ensureRuntimeDir(agent, cwd, visibility = 'shared', hostId) {
32
78
  const dir = visibility === 'shared'
@@ -57,6 +103,13 @@ export function saveRuntimeNote(note, cwd) {
57
103
  registryFaultPoint('after_registry_journal');
58
104
  }
59
105
  saveVersionedJsonFile('runtime_note', filepath, parsed);
106
+ // An update to a pre-normalization record must not leave two physical
107
+ // copies with the same id. Retire only the verified-contained raw path,
108
+ // and only after the canonical write succeeds.
109
+ const legacyPath = legacyRuntimeNotePath(note, visibility, hostId, cwd);
110
+ if (legacyPath && legacyPath !== filepath && fs.existsSync(legacyPath)) {
111
+ fs.unlinkSync(legacyPath);
112
+ }
60
113
  appendEvent({ action: 'create', item_type: 'runtime_note', item_id: note.id, agent: note.agent, agent_id: note.agent_id }, cwd);
61
114
  commitMemoryChange(`runtime note: ${note.note_type ?? 'note'} (${note.agent})`, cwd);
62
115
  });
@@ -64,9 +117,39 @@ export function saveRuntimeNote(note, cwd) {
64
117
  export function runtimeNotePath(note, cwd) {
65
118
  const visibility = note.visibility ?? 'shared';
66
119
  const hostId = sanitizeHostId(note.host_id ?? resolveCurrentHostId());
67
- return visibility === 'shared'
68
- ? path.join(sharedAgentDir(note.agent, cwd), `${note.id}.json`)
69
- : path.join(hostAgentDir(visibility, hostId, note.agent, cwd), `${note.id}.json`);
120
+ // pln#673 the canonical (normalized) location, plus the RAW-name fallback
121
+ // for notes written before the normalization: this function answers "where is
122
+ // THIS note", and a record must not become invisible (nor undeletable)
123
+ // because its directory predates the fix. Canonical first; the raw candidate
124
+ // only wins when it actually holds the file.
125
+ const base = visibility === 'shared'
126
+ ? sharedRuntimeDir(cwd)
127
+ : hostRootDir(visibility, hostId, cwd);
128
+ const candidates = agentDirCandidates(base, note.agent).map((dir) => path.join(dir, `${note.id}.json`));
129
+ return candidates.find((candidate) => fs.existsSync(candidate)) ?? candidates[0];
130
+ }
131
+ /**
132
+ * Park one runtime note's raw record under `.brainclaw/gc-backups/` — the same
133
+ * park-don't-delete net the retention sweeps use (trp_dc9ca61e). Daily-bucketed
134
+ * JSONL so removals do not explode into one file per note. Returns the backup
135
+ * path, or undefined when the source record cannot be read.
136
+ */
137
+ export function parkRuntimeNoteBackup(note, cwd) {
138
+ try {
139
+ const sourcePath = runtimeNotePath(note, cwd);
140
+ const content = fs.readFileSync(sourcePath, 'utf-8');
141
+ const parsed = JSON.parse(content);
142
+ parsed._removed_at = new Date().toISOString();
143
+ parsed._removal_type = 'bclaw_remove';
144
+ const day = new Date().toISOString().slice(0, 10);
145
+ const backupPath = path.join(cwd ?? process.cwd(), '.brainclaw', 'gc-backups', `removed-runtime-notes-${day}.jsonl`);
146
+ fs.mkdirSync(path.dirname(backupPath), { recursive: true });
147
+ fs.appendFileSync(backupPath, JSON.stringify(parsed) + '\n', 'utf-8');
148
+ return backupPath;
149
+ }
150
+ catch {
151
+ return undefined;
152
+ }
70
153
  }
71
154
  export function deleteRuntimeNote(note, cwd) {
72
155
  return mutate({ cwd }, () => {
@@ -118,12 +201,17 @@ export function listSharedJournaledRuntimeNotes(cwd) {
118
201
  function readAgentNotes(dir, agent) {
119
202
  if (!fs.existsSync(dir))
120
203
  return [];
121
- const agents = agent
122
- ? [agent]
123
- : fs.readdirSync(dir).filter((entry) => fs.statSync(path.join(dir, entry)).isDirectory());
204
+ // pln#673 a filtered read probes BOTH the normalized directory and the raw
205
+ // name (a pre-normalization directory must stay readable); an unfiltered read
206
+ // enumerates whatever is on disk, which covers both by construction. The
207
+ // candidates are absolute, so the join below must not prepend `dir` again.
208
+ const agentDirectories = agent
209
+ ? agentDirCandidates(dir, agent)
210
+ : fs.readdirSync(dir)
211
+ .filter((entry) => fs.statSync(path.join(dir, entry)).isDirectory())
212
+ .map((entry) => path.join(dir, entry));
124
213
  const notes = [];
125
- for (const a of agents) {
126
- const agentDirectory = path.join(dir, a);
214
+ for (const agentDirectory of agentDirectories) {
127
215
  if (!fs.existsSync(agentDirectory))
128
216
  continue;
129
217
  const files = fs.readdirSync(agentDirectory).filter((file) => file.endsWith('.json'));
@@ -4,7 +4,11 @@ import path from 'node:path';
4
4
  import { loadActiveProject } from './active-project.js';
5
5
  import { loadConfig } from './config.js';
6
6
  import { loadCurrentSession, loadSessionById, resolveExplicitSessionId } from './identity.js';
7
- import { MEMORY_DIR } from './io.js';
7
+ import { findOutermostBrainclawRoot, MEMORY_DIR } from './io.js';
8
+ // pln#648 — the walk moved to io.ts (leaf) so identity.ts can anchor session
9
+ // records on it without importing this module (which imports identity.ts).
10
+ // Re-exported here to keep the existing API surface.
11
+ export { findOutermostBrainclawRoot } from './io.js';
8
12
  import { summarizeWorkspaceProjects } from './workspace-projects.js';
9
13
  /**
10
14
  * Walk up the filesystem from `cwd`, collecting every `.brainclaw/` directory
@@ -423,26 +427,6 @@ export function resolveProjectRef(ref, cwd = process.cwd(), storeChainOptions) {
423
427
  }
424
428
  return undefined;
425
429
  }
426
- /**
427
- * Walk UP from a directory and return the outermost .brainclaw/ root found.
428
- * This bypasses resolveEffectiveCwd / active project to find the true workspace root.
429
- */
430
- export function findOutermostBrainclawRoot(startDir) {
431
- let dir = path.resolve(startDir);
432
- const root = path.parse(dir).root;
433
- const home = os.homedir();
434
- let outermost;
435
- while (dir !== root && dir !== home) {
436
- if (fs.existsSync(path.join(dir, MEMORY_DIR, 'config.yaml'))) {
437
- outermost = dir;
438
- }
439
- const parent = path.dirname(dir);
440
- if (parent === dir)
441
- break;
442
- dir = parent;
443
- }
444
- return outermost;
445
- }
446
430
  /**
447
431
  * Resolve the most specific child store that should answer a context request.
448
432
  *
package/dist/facts.js CHANGED
@@ -1,8 +1,8 @@
1
1
  // Generated by scripts/emit-site-facts.mjs at build time. Do not edit manually.
2
- // Source: brainclaw v1.25.0 on 2026-08-10T23:31:26.947Z
2
+ // Source: brainclaw v1.26.1 on 2026-08-18T15:32:22.089Z
3
3
  export const FACTS = {
4
- "version": "1.25.0",
5
- "generated_at": "2026-08-10T23:31:26.947Z",
4
+ "version": "1.26.1",
5
+ "generated_at": "2026-08-18T15:32:22.089Z",
6
6
  "tools": {
7
7
  "count": 70,
8
8
  "published_count": 68,
@@ -477,8 +477,8 @@ export const FACTS = {
477
477
  },
478
478
  "bench": {
479
479
  "schema": "brainclaw.bench.v1",
480
- "generated_at": "2026-08-10T23:31:25.464Z",
481
- "node_version": "v24.18.0",
480
+ "generated_at": "2026-08-18T15:32:19.941Z",
481
+ "node_version": "v24.19.0",
482
482
  "platform": "linux-x64",
483
483
  "repeats": 3,
484
484
  "scenarios": [
@@ -486,7 +486,7 @@ export const FACTS = {
486
486
  "name": "cold_onboard",
487
487
  "volume": "empty",
488
488
  "description": "fresh machine → init → first useful context. Baseline for time-to-first-value.",
489
- "duration_ms_median": 59,
489
+ "duration_ms_median": 86,
490
490
  "payload_chars_median": 1640,
491
491
  "payload_tokens_est_median": 410
492
492
  },
@@ -494,17 +494,17 @@ export const FACTS = {
494
494
  "name": "warm_work",
495
495
  "volume": "medium",
496
496
  "description": "bclaw_work consult over a real-shaped store (~200 plans / 500 handoffs / 450 claims).",
497
- "duration_ms_median": 88,
498
- "payload_chars_median": 2625,
499
- "payload_tokens_est_median": 656
497
+ "duration_ms_median": 128,
498
+ "payload_chars_median": 2626,
499
+ "payload_tokens_est_median": 657
500
500
  },
501
501
  {
502
502
  "name": "first_edit",
503
503
  "volume": "medium",
504
504
  "description": "code_find + code_brief on the fresh-agent path (missing index, first touch).",
505
- "duration_ms_median": 10,
506
- "payload_chars_median": 499,
507
- "payload_tokens_est_median": 125
505
+ "duration_ms_median": 11,
506
+ "payload_chars_median": 1305,
507
+ "payload_tokens_est_median": 326
508
508
  }
509
509
  ]
510
510
  }
package/dist/facts.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
- "version": "1.25.0",
3
- "generated_at": "2026-08-10T23:31:26.947Z",
2
+ "version": "1.26.1",
3
+ "generated_at": "2026-08-18T15:32:22.089Z",
4
4
  "tools": {
5
5
  "count": 70,
6
6
  "published_count": 68,
@@ -475,8 +475,8 @@
475
475
  },
476
476
  "bench": {
477
477
  "schema": "brainclaw.bench.v1",
478
- "generated_at": "2026-08-10T23:31:25.464Z",
479
- "node_version": "v24.18.0",
478
+ "generated_at": "2026-08-18T15:32:19.941Z",
479
+ "node_version": "v24.19.0",
480
480
  "platform": "linux-x64",
481
481
  "repeats": 3,
482
482
  "scenarios": [
@@ -484,7 +484,7 @@
484
484
  "name": "cold_onboard",
485
485
  "volume": "empty",
486
486
  "description": "fresh machine → init → first useful context. Baseline for time-to-first-value.",
487
- "duration_ms_median": 59,
487
+ "duration_ms_median": 86,
488
488
  "payload_chars_median": 1640,
489
489
  "payload_tokens_est_median": 410
490
490
  },
@@ -492,17 +492,17 @@
492
492
  "name": "warm_work",
493
493
  "volume": "medium",
494
494
  "description": "bclaw_work consult over a real-shaped store (~200 plans / 500 handoffs / 450 claims).",
495
- "duration_ms_median": 88,
496
- "payload_chars_median": 2625,
497
- "payload_tokens_est_median": 656
495
+ "duration_ms_median": 128,
496
+ "payload_chars_median": 2626,
497
+ "payload_tokens_est_median": 657
498
498
  },
499
499
  {
500
500
  "name": "first_edit",
501
501
  "volume": "medium",
502
502
  "description": "code_find + code_brief on the fresh-agent path (missing index, first touch).",
503
- "duration_ms_median": 10,
504
- "payload_chars_median": 499,
505
- "payload_tokens_est_median": 125
503
+ "duration_ms_median": 11,
504
+ "payload_chars_median": 1305,
505
+ "payload_tokens_est_median": 326
506
506
  }
507
507
  ]
508
508
  }
package/docs/code-map.md CHANGED
@@ -136,35 +136,44 @@ call `bclaw_code_refresh` and retry.
136
136
 
137
137
  ## Freshness badge model
138
138
 
139
- Every Code Map response carries a freshness badge so a stale index is always
140
- visible rather than silently misleading. The status is one of:
139
+ Every Code Map response has one top-level `freshness` field:
140
+ `fresh`, `stale`, `partial`, or `missing`. It is the synthetic index signal that
141
+ an agent uses to decide whether to refresh, and it has the same meaning on
142
+ `bclaw_work`, `bclaw_code_status`, `bclaw_code_find`, and `bclaw_code_brief`.
143
+
144
+ ```json
145
+ {
146
+ "freshness": "fresh",
147
+ "details": {
148
+ "index": {
149
+ "status": "fresh",
150
+ "stale_file_count": 0,
151
+ "partial_reason": null,
152
+ "git_head_changed": null
153
+ },
154
+ "spot_check": {
155
+ "status": "stale",
156
+ "checked_files": 1,
157
+ "stale_changed_files": ["src/example.ts"],
158
+ "deleted_files": [],
159
+ "unchecked_files": [],
160
+ "budget_exhausted": false,
161
+ "partial_reason": null
162
+ }
163
+ }
164
+ }
165
+ ```
141
166
 
142
- | Status | Meaning | Fix |
143
- |---|---|---|
144
- | `fresh` | Index matches the working tree, the extractor config, and the parser binaries. | — |
145
- | `stale_changed_files` | One or more indexed files have changed on disk since they were parsed. | `refresh --changed` |
146
- | `stale_extractor` | The extractor configuration (ignore rules, size caps, supported extensions, query budget, or active language set) changed since these shards were produced. | `refresh --changed` (heals on the cheap path) |
147
- | `stale_grammar` | A Tree-sitter grammar (or the engine glue) binary changed since these shards were produced. | `refresh --changed` (heals on the cheap path) |
148
- | `partial` | The index could not be fully read/built this pass (e.g. the project lock was held by a live writer). | retry |
149
- | `missing_index` | No index exists yet for this project. | `refresh --all` |
150
-
151
- Staleness reasons are kept separate on purpose: a content change
152
- (`stale_changed_files`) is independent from a config change (`stale_extractor`)
153
- which is independent from a parser-binary change (`stale_grammar`). The badge
154
- surfaces the dominant reason; `--json` output and the manifest carry the per-file
155
- counts.
156
-
157
- **Index freshness vs this call's spot-check.** `bclaw_code_status` reports the
158
- *index* freshness (the manifest state). `bclaw_code_find` / `bclaw_code_brief`
159
- additionally run a bounded, per-query *spot-check* of the files they actually
160
- touch — so a single call can read `stale_changed_files` (a file it looked at
161
- changed on disk) or `partial` (the spot-check hit its budget) even while the index
162
- itself is `fresh`. When the call-level status diverges from the index, the badge
163
- carries an `index_status` detail so the two are not confused, e.g.
164
- `{ status: "partial", details: { index_status: "fresh", partial_reason:
165
- "lazy_check_budget_exhausted" } }` reads as *"index fresh, this call's spot-check
166
- incomplete (budget)"* — not a contradiction with a `fresh` `status()`.
167
+ `details.index` is the index diagnosis: its detailed `status` may be
168
+ `stale_changed_files`, `stale_extractor`, `stale_grammar`, or
169
+ `stale_git_head`. `details.spot_check` is a bounded, read-only observation of
170
+ the candidates touched by `find` or `brief`; it is `not_run` on `status` and on
171
+ a work section with no query. A stale or partial spot-check never silently
172
+ changes the shared top-level signal. It gives the agent precise evidence for an
173
+ explicit `bclaw_code_refresh(scope="changed")`, then a retry.
167
174
 
175
+ No read command parses files or refreshes the index. `bclaw_work` can suggest
176
+ that explicit refresh, but never performs it lazily.
168
177
  ## Lifecycle — pull-based, no daemon
169
178
 
170
179
  Code Map never runs in the background and never auto-reindexes. The model is lazy
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brainclaw",
3
- "version": "1.25.0",
3
+ "version": "1.26.1",
4
4
  "description": "Shared project memory for humans and coding agents.",
5
5
  "type": "module",
6
6
  "repository": {