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.
- package/dist/brainclaw-vscode.vsix +0 -0
- package/dist/commands/code-map.js +1 -4
- package/dist/commands/mcp.js +7 -7
- package/dist/commands/session-start.js +137 -15
- package/dist/core/bootstrap.js +28 -4
- package/dist/core/code-map/aggregate.js +36 -31
- package/dist/core/code-map/backend.js +4 -4
- package/dist/core/code-map/core.js +1 -0
- package/dist/core/code-map/export.js +4 -4
- package/dist/core/code-map/finalizer.js +57 -2
- package/dist/core/code-map/freshness.js +78 -13
- package/dist/core/code-map/impact.js +36 -4
- package/dist/core/code-map/indexes.js +37 -0
- package/dist/core/code-map/lang/python/index.js +4 -2
- package/dist/core/code-map/lang/query-runtime.js +2 -0
- package/dist/core/code-map/lang/typescript/index.js +4 -2
- package/dist/core/code-map/lang/usages.js +333 -0
- package/dist/core/code-map/memory-reader.js +15 -0
- package/dist/core/code-map/query.js +209 -58
- package/dist/core/code-map/refresh.js +0 -0
- package/dist/core/code-map/resolve.js +27 -2
- package/dist/core/code-map/store.js +1 -0
- package/dist/core/code-map/types.js +55 -9
- package/dist/core/code-map/vocabulary.js +6 -0
- package/dist/core/code-map/work-section.js +12 -14
- package/dist/core/context-diff.js +17 -3
- package/dist/core/entity-operations.js +14 -2
- package/dist/core/hint-aging.js +4 -1
- package/dist/core/identity.js +284 -91
- package/dist/core/io.js +192 -0
- package/dist/core/project-discovery.js +7 -1
- package/dist/core/runtime.js +99 -11
- package/dist/core/store-resolution.js +5 -21
- package/dist/facts.js +12 -12
- package/dist/facts.json +11 -11
- package/docs/code-map.md +36 -27
- 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
|
-
|
|
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') ||
|
package/dist/core/runtime.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
|
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.
|
|
2
|
+
// Source: brainclaw v1.26.1 on 2026-08-18T15:32:22.089Z
|
|
3
3
|
export const FACTS = {
|
|
4
|
-
"version": "1.
|
|
5
|
-
"generated_at": "2026-08-
|
|
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-
|
|
481
|
-
"node_version": "v24.
|
|
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":
|
|
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":
|
|
498
|
-
"payload_chars_median":
|
|
499
|
-
"payload_tokens_est_median":
|
|
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":
|
|
506
|
-
"payload_chars_median":
|
|
507
|
-
"payload_tokens_est_median":
|
|
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.
|
|
3
|
-
"generated_at": "2026-08-
|
|
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-
|
|
479
|
-
"node_version": "v24.
|
|
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":
|
|
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":
|
|
496
|
-
"payload_chars_median":
|
|
497
|
-
"payload_tokens_est_median":
|
|
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":
|
|
504
|
-
"payload_chars_median":
|
|
505
|
-
"payload_tokens_est_median":
|
|
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
|
|
140
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|