brainclaw 1.25.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 (36) 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 +84 -13
  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 +69 -17
  30. package/dist/core/io.js +27 -0
  31. package/dist/core/project-discovery.js +7 -1
  32. package/dist/core/runtime.js +23 -0
  33. package/dist/facts.js +12 -12
  34. package/dist/facts.json +11 -11
  35. package/docs/code-map.md +36 -27
  36. package/package.json +1 -1
@@ -1,7 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import { readAuditLog } from './audit.js';
3
3
  import { listCandidates } from './candidates.js';
4
- import { entityRecordPaths } from './io.js';
4
+ import { sessionSnapshotRecordPaths } from './io.js';
5
5
  import { loadVersionedJsonFile } from './migration.js';
6
6
  import { buildNotificationSummary, hasEventCursor, readUnseenEvents, seedCursorToEnd } from './event-log.js';
7
7
  import { SessionSnapshotSchema } from './schema.js';
@@ -44,11 +44,25 @@ export function resolveContextDiffSince(options) {
44
44
  * Uses the shared primitive rather than a fourth hand-rolled pair of paths (io.ts).
45
45
  */
46
46
  function loadSessionSnapshot(sessionId, cwd) {
47
- for (const snapshotPath of entityRecordPaths('sessions', sessionId, cwd ?? process.cwd())) {
47
+ // pln#670 snapshots carry a type-suffixed name (`<id>.snapshot.json`);
48
+ // the helper also probes the pre-split `<id>.json` layouts.
49
+ for (const snapshotPath of sessionSnapshotRecordPaths(sessionId, cwd ?? process.cwd())) {
48
50
  if (!fs.existsSync(snapshotPath))
49
51
  continue;
50
52
  try {
51
- return SessionSnapshotSchema.parse(loadVersionedJsonFile('session_snapshot', snapshotPath).document);
53
+ // Deliberately NOT type-strict (pln#649 + pln#670): this reader answers
54
+ // "when did session X start" to anchor the diff window, and a
55
+ // current_session record for the same id is an equally authoritative
56
+ // source of `started_at`. The suffixed probes come first, so a real
57
+ // snapshot (richer: context_target, git_sha) still wins when both exist.
58
+ // Contrast with session-start's loadSessionSnapshot, which must stay
59
+ // strict — its consumers treat the record as a genuine snapshot.
60
+ const snapshot = SessionSnapshotSchema.parse(loadVersionedJsonFile('session_snapshot', snapshotPath).document);
61
+ // A suffix-bearing lookup can construct another snapshot's path (codex
62
+ // review). Its started_at is useful only when the stored identity matches.
63
+ if (snapshot.session_id !== sessionId)
64
+ continue;
65
+ return snapshot;
52
66
  }
53
67
  catch {
54
68
  // An unparseable record in one layout must not mask a good one in the other.
@@ -28,7 +28,7 @@ import { deleteAssignment, listAssignments, loadAssignment, saveAssignment, tran
28
28
  import { listAgentRuns } from './agentruns.js';
29
29
  import { reconcileAgentRun, reconcileDeadPidRunningAgentRunAtRead, reconcileStrandedFailureClaimAtRead, TERMINAL_STATUSES } from './agentrun-reconciler.js';
30
30
  import { isObserverMode } from './observer-mode.js';
31
- import { deleteRuntimeNote, listRuntimeNotes, saveRuntimeNote, } from './runtime.js';
31
+ import { deleteRuntimeNote, listRuntimeNotes, parkRuntimeNoteBackup, saveRuntimeNote, } from './runtime.js';
32
32
  import { createSequence, deleteSequence, listSequences, updateSequence, } from './sequence.js';
33
33
  import { createConstraint, createDecision, createTrap, } from './operations/memory-write.js';
34
34
  import { deleteMemoryItem, findMemoryItemInChain, updateMemoryItem, } from './operations/memory-mutation.js';
@@ -853,10 +853,22 @@ export function removeEntity(name, id, cwd, purge = false) {
853
853
  const note = notes.find((n) => n.id === id);
854
854
  if (!note)
855
855
  throw new EntityNotFoundError(name, id);
856
+ // trp_dc9ca61e — the tool contract says "archives by default", but this
857
+ // path hard-deleted regardless of `purge` (runtime_note has no lifecycle,
858
+ // so there was no soft state to land in). Default remove now parks the
859
+ // raw record under gc-backups — the same net the retention sweeps use —
860
+ // and fails CLOSED when the park is impossible: silently downgrading an
861
+ // archive into a hard-delete is the defect being fixed.
862
+ if (!purge) {
863
+ const backupPath = parkRuntimeNoteBackup(note, cwd);
864
+ if (!backupPath) {
865
+ throw new Error(`runtime_note '${id}' could not be archived to gc-backups; pass purge:true to hard-delete`);
866
+ }
867
+ }
856
868
  const ok = deleteRuntimeNote(note, cwd);
857
869
  if (!ok)
858
870
  throw new EntityNotFoundError(name, id);
859
- return { entity: name, id, archived: false, purged: true };
871
+ return { entity: name, id, archived: !purge, purged: purge };
860
872
  }
861
873
  case 'candidate': {
862
874
  // Remove = archive to rejected. `purge` would delete the file; not exposed yet.
@@ -123,7 +123,10 @@ export function ageStaleWarnings(warnings, cwd, options = {}) {
123
123
  const overflow = ids.length - shown.length;
124
124
  return `${ids.length} ${entity}${ids.length === 1 ? '' : 's'}: ${shown.join(', ')}${overflow > 0 ? ` +${overflow} more` : ''}`;
125
125
  });
126
- aggregate = `${folded.length} stale item${folded.length === 1 ? '' : 's'} you've already been offered (${parts.join('; ')}) — bclaw_get each id to review, or bclaw_transition to retire.`;
126
+ // trp_dc9ca61e do not recommend bclaw_transition for runtime_notes: they
127
+ // have no lifecycle and the call errors. bclaw_remove (archive by default)
128
+ // is their retirement path.
129
+ aggregate = `${folded.length} stale item${folded.length === 1 ? '' : 's'} you've already been offered (${parts.join('; ')}) — bclaw_get each id to review; retire with bclaw_transition, or bclaw_remove for runtime_notes (no lifecycle).`;
127
130
  }
128
131
  return { warnings: detail, aggregate, served_ids, folded_ids };
129
132
  }
@@ -6,7 +6,7 @@ import { detectAiAgent } from './ai-agent-detection.js';
6
6
  import { requireRegisteredAgentIdentity } from './agent-registry.js';
7
7
  import { loadConfig } from './config.js';
8
8
  import { resolveCurrentHostId } from './host.js';
9
- import { memoryDir } from './io.js';
9
+ import { isSessionSnapshotRecordFilename, memoryDir } from './io.js';
10
10
  import { loadVersionedJsonFile, saveVersionedJsonFile } from './migration.js';
11
11
  import { CurrentSessionStateSchema } from './schema.js';
12
12
  const SESSIONS_DIR = 'sessions';
@@ -87,7 +87,7 @@ export function loadCurrentSession(cwd) {
87
87
  // Multiple parallel agents can have the same agent name/user in one repo;
88
88
  // a live different PID is a different agent instance, not our session.
89
89
  if (fs.existsSync(dir) && currentAgent) {
90
- const files = fs.readdirSync(dir).filter(f => f.endsWith('.json'));
90
+ const files = listCurrentSessionFiles(dir);
91
91
  const legacyPidlessCandidates = [];
92
92
  for (const file of files) {
93
93
  try {
@@ -133,15 +133,25 @@ export function loadCurrentSession(cwd) {
133
133
  * Load a specific session by ID.
134
134
  */
135
135
  export function loadSessionById(sessionId, cwd) {
136
- const filepath = sessionFilePath(sessionId, cwd);
136
+ let filepath;
137
+ try {
138
+ filepath = sessionFilePath(sessionId, cwd);
139
+ }
140
+ catch {
141
+ // Reserved '.snapshot' alias id — such a current_session record can never exist.
142
+ return undefined;
143
+ }
137
144
  if (!fs.existsSync(filepath))
138
145
  return undefined;
139
146
  try {
140
147
  const migration = loadVersionedJsonFile('current_session', filepath);
141
- return {
148
+ const session = {
142
149
  ...CurrentSessionStateSchema.parse(migration.document),
143
150
  schema_version: migration.metadata.currentVersion,
144
151
  };
152
+ // The filename is only an index, never an identity (codex review): do not
153
+ // adopt a record whose payload names a different session.
154
+ return session.session_id === sessionId ? session : undefined;
145
155
  }
146
156
  catch {
147
157
  return undefined;
@@ -154,7 +164,7 @@ export function loadAllSessions(cwd) {
154
164
  const dir = sessionsDir(cwd);
155
165
  if (!fs.existsSync(dir))
156
166
  return [];
157
- const files = fs.readdirSync(dir).filter(f => f.endsWith('.json'));
167
+ const files = listCurrentSessionFiles(dir);
158
168
  const sessions = [];
159
169
  for (const file of files) {
160
170
  try {
@@ -178,7 +188,15 @@ export function saveCurrentSession(session, cwd) {
178
188
  if (!fs.existsSync(dir)) {
179
189
  fs.mkdirSync(dir, { recursive: true });
180
190
  }
191
+ // sessionFilePath throws on a '.snapshot' alias id — a write must never
192
+ // construct a snapshot filename (codex review P1).
181
193
  const filepath = sessionFilePath(session.session_id, cwd);
194
+ // A plain legacy `<id>.json` can still hold a pre-split snapshot (the old
195
+ // 'read'-mode write bug parked snapshots in this directory). Only overwrite
196
+ // a record that PROVES it is this exact current_session entry (codex review).
197
+ if (fs.existsSync(filepath) && !loadSessionById(session.session_id, cwd)) {
198
+ throw new Error(`Refusing to overwrite non-current_session record at '${filepath}'`);
199
+ }
182
200
  saveVersionedJsonFile('current_session', filepath, CurrentSessionStateSchema.parse(session));
183
201
  }
184
202
  /**
@@ -186,10 +204,15 @@ export function saveCurrentSession(session, cwd) {
186
204
  */
187
205
  export function clearCurrentSession(cwd, sessionId) {
188
206
  if (sessionId) {
189
- // Remove specific session file
190
- const filepath = sessionFilePath(sessionId, cwd);
207
+ // Remove specific session file. A filename is never enough authority to
208
+ // delete a record (codex review P1): the '.snapshot' alias id throws in
209
+ // sessionFilePath (caught → no-op), and an existing file is only unlinked
210
+ // when loadSessionById PROVES it is this exact current_session record —
211
+ // never a pre-split snapshot parked under a plain `<id>.json`.
191
212
  try {
192
- fs.unlinkSync(filepath);
213
+ if (loadSessionById(sessionId, cwd)) {
214
+ fs.unlinkSync(sessionFilePath(sessionId, cwd));
215
+ }
193
216
  }
194
217
  catch { /* ignore */ }
195
218
  return;
@@ -221,26 +244,30 @@ export function gcStaleSessions(cwd, ttlOverride) {
221
244
  const ttlMs = parseDurationToMs(ttlOverride ?? loadConfigSafe(cwd)?.implicit_session_ttl ?? '4h');
222
245
  const now = Date.now();
223
246
  let removed = 0;
224
- const files = fs.readdirSync(dir).filter(f => f.endsWith('.json'));
247
+ const files = listCurrentSessionFiles(dir);
225
248
  for (const file of files) {
249
+ const filepath = path.join(dir, file);
226
250
  try {
227
- const migration = loadVersionedJsonFile('current_session', path.join(dir, file));
251
+ // POSITIVE proof before deletion (codex review): a bare `<id>.json` in
252
+ // this directory can still be a pre-split snapshot (old 'read'-mode
253
+ // write bug). Only a record carrying the current_session discriminant
254
+ // may be collected; anything unidentifiable is preserved, never deleted.
255
+ const raw = JSON.parse(fs.readFileSync(filepath, 'utf-8'));
256
+ if (typeof raw.last_seen_at !== 'string')
257
+ continue;
258
+ const migration = loadVersionedJsonFile('current_session', filepath);
228
259
  const session = {
229
260
  ...CurrentSessionStateSchema.parse(migration.document),
230
261
  schema_version: migration.metadata.currentVersion,
231
262
  };
232
263
  if (now - Date.parse(session.last_seen_at) > ttlMs) {
233
- fs.unlinkSync(path.join(dir, file));
264
+ fs.unlinkSync(filepath);
234
265
  removed++;
235
266
  }
236
267
  }
237
268
  catch {
238
- // Remove unparseable files too
239
- try {
240
- fs.unlinkSync(path.join(dir, file));
241
- removed++;
242
- }
243
- catch { /* ignore */ }
269
+ // An unidentifiable record is not proven stale current_session state —
270
+ // preserving it beats risking the deletion of a legacy snapshot.
244
271
  }
245
272
  }
246
273
  return removed;
@@ -249,7 +276,32 @@ export function gcStaleSessions(cwd, ttlOverride) {
249
276
  function sessionsDir(cwd) {
250
277
  return path.join(memoryDir(cwd), SESSIONS_DIR);
251
278
  }
279
+ /**
280
+ * pln#670 — current_session scanners must be type-strict: session_snapshot
281
+ * records use the `<id>.snapshot.json` suffix and can share a directory with
282
+ * current_session records. Without this exclusion, gcStaleSessions would
283
+ * delete a stray snapshot as "unparseable" and loadCurrentSession could adopt
284
+ * one as a session candidate.
285
+ */
286
+ function listCurrentSessionFiles(dir) {
287
+ // Case-insensitive on purpose (codex review P1): Windows filesystems match
288
+ // names case-insensitively, so `X.SNAPSHOT.json` IS the snapshot path a
289
+ // lower-case probe resolves — excluding only the exact-case suffix would let
290
+ // gcStaleSessions delete it as an unparseable current_session.
291
+ return fs.readdirSync(dir).filter(f => f.toLowerCase().endsWith('.json') && !isSessionSnapshotRecordFilename(f));
292
+ }
293
+ /** True when `<sessionId>.json` cannot collide with a session_snapshot filename. */
294
+ function isCurrentSessionFilename(sessionId) {
295
+ return !isSessionSnapshotRecordFilename(`${sessionId}.json`);
296
+ }
252
297
  function sessionFilePath(sessionId, cwd) {
298
+ // pln#670 review fix (codex P1): a session id ending in ".snapshot" would
299
+ // produce `<base>.snapshot.json` — the snapshot filename of session <base>
300
+ // in a shared directory. Refuse the alias instead of silently colliding
301
+ // across record types; readers treat the throw as "no such record".
302
+ if (!isCurrentSessionFilename(sessionId)) {
303
+ throw new Error(`session id '${sessionId}' is reserved for session_snapshot records — the '.snapshot' suffix would collide across record types`);
304
+ }
253
305
  return path.join(sessionsDir(cwd), `${sessionId}.json`);
254
306
  }
255
307
  function resolveCurrentUser() {
package/dist/core/io.js CHANGED
@@ -121,6 +121,33 @@ export function entityRecordDirs(subdir, cwd = process.cwd(), preferredDirName)
121
121
  export function entityRecordPaths(subdir, id, cwd, preferredDirName) {
122
122
  return entityRecordDirs(subdir, cwd ?? process.cwd(), preferredDirName).map((d) => path.join(d, `${id}.json`));
123
123
  }
124
+ export const SESSION_SNAPSHOT_FILENAME_SUFFIX = '.snapshot.json';
125
+ /**
126
+ * Filesystem type discriminator for session snapshots (codex review, pln#670).
127
+ * Case-fold before the suffix comparison because default Windows filesystems
128
+ * are case-insensitive: `X.SNAPSHOT.json` IS the path a lower-case probe
129
+ * resolves, and every suffix decision must agree on its type.
130
+ */
131
+ export function isSessionSnapshotRecordFilename(filename) {
132
+ return filename.toLowerCase().endsWith(SESSION_SNAPSHOT_FILENAME_SUFFIX);
133
+ }
134
+ /**
135
+ * EVERY path a session_snapshot record for `sessionId` can occupy, canonical first.
136
+ *
137
+ * session_snapshot and current_session are two different record types that share
138
+ * the `sessions` directory family AND the same session_id — only the filename keeps
139
+ * them apart (pln#670). Snapshots are written as `<id>.snapshot.json` so a
140
+ * current_session `<id>.json` for the same session can never clobber them, whatever
141
+ * directory each resolver picks. The plain `<id>.json` probes cover records written
142
+ * before the split; readers must schema-validate every candidate.
143
+ */
144
+ export function sessionSnapshotRecordPaths(sessionId, cwd, preferredDirName) {
145
+ const dirs = entityRecordDirs('sessions', cwd ?? process.cwd(), preferredDirName);
146
+ return [
147
+ ...dirs.map((d) => path.join(d, `${sessionId}.snapshot.json`)),
148
+ ...dirs.map((d) => path.join(d, `${sessionId}.json`)),
149
+ ];
150
+ }
124
151
  export function memoryDir(cwd = process.cwd(), preferredDirName) {
125
152
  return path.join(cwd, preferredDirName ?? MEMORY_DIR);
126
153
  }
@@ -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') ||
@@ -68,6 +68,29 @@ export function runtimeNotePath(note, cwd) {
68
68
  ? path.join(sharedAgentDir(note.agent, cwd), `${note.id}.json`)
69
69
  : path.join(hostAgentDir(visibility, hostId, note.agent, cwd), `${note.id}.json`);
70
70
  }
71
+ /**
72
+ * Park one runtime note's raw record under `.brainclaw/gc-backups/` — the same
73
+ * park-don't-delete net the retention sweeps use (trp_dc9ca61e). Daily-bucketed
74
+ * JSONL so removals do not explode into one file per note. Returns the backup
75
+ * path, or undefined when the source record cannot be read.
76
+ */
77
+ export function parkRuntimeNoteBackup(note, cwd) {
78
+ try {
79
+ const sourcePath = runtimeNotePath(note, cwd);
80
+ const content = fs.readFileSync(sourcePath, 'utf-8');
81
+ const parsed = JSON.parse(content);
82
+ parsed._removed_at = new Date().toISOString();
83
+ parsed._removal_type = 'bclaw_remove';
84
+ const day = new Date().toISOString().slice(0, 10);
85
+ const backupPath = path.join(cwd ?? process.cwd(), '.brainclaw', 'gc-backups', `removed-runtime-notes-${day}.jsonl`);
86
+ fs.mkdirSync(path.dirname(backupPath), { recursive: true });
87
+ fs.appendFileSync(backupPath, JSON.stringify(parsed) + '\n', 'utf-8');
88
+ return backupPath;
89
+ }
90
+ catch {
91
+ return undefined;
92
+ }
93
+ }
71
94
  export function deleteRuntimeNote(note, cwd) {
72
95
  return mutate({ cwd }, () => {
73
96
  const filepath = runtimeNotePath(note, cwd);
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.0 on 2026-08-16T20:27:49.404Z
3
3
  export const FACTS = {
4
- "version": "1.25.0",
5
- "generated_at": "2026-08-10T23:31:26.947Z",
4
+ "version": "1.26.0",
5
+ "generated_at": "2026-08-16T20:27:49.404Z",
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-16T20:27:47.271Z",
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": 75,
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": 132,
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.0",
3
+ "generated_at": "2026-08-16T20:27:49.404Z",
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-16T20:27:47.271Z",
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": 75,
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": 132,
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.0",
4
4
  "description": "Shared project memory for humans and coding agents.",
5
5
  "type": "module",
6
6
  "repository": {