brainclaw 1.24.0 → 1.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/brainclaw-vscode.vsix +0 -0
- package/dist/cli/register-code-map.js +9 -2
- package/dist/commands/code-map.js +120 -6
- package/dist/commands/mcp-catalog.js +46 -0
- package/dist/commands/mcp.js +58 -6
- package/dist/commands/session-start.js +84 -13
- package/dist/core/bootstrap.js +28 -4
- package/dist/core/code-map/aggregate.js +36 -31
- package/dist/core/code-map/backend.js +162 -5
- package/dist/core/code-map/core.js +1 -0
- package/dist/core/code-map/export.js +212 -0
- package/dist/core/code-map/finalizer.js +57 -2
- package/dist/core/code-map/freshness.js +81 -15
- package/dist/core/code-map/impact.js +409 -0
- package/dist/core/code-map/indexes.js +64 -3
- 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/config.js +271 -0
- package/dist/core/code-map/lang/typescript/index.js +24 -6
- 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 +285 -71
- package/dist/core/code-map/refresh.js +0 -0
- package/dist/core/code-map/resolve.js +28 -2
- package/dist/core/code-map/store.js +1 -0
- package/dist/core/code-map/types.js +70 -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/federation-pull.js +151 -3
- package/dist/core/federation-push.js +16 -3
- package/dist/core/hint-aging.js +4 -1
- package/dist/core/identity.js +69 -17
- package/dist/core/io.js +27 -0
- package/dist/core/project-discovery.js +7 -1
- package/dist/core/protocol-tool-policy.js +3 -0
- package/dist/core/runtime.js +23 -0
- package/dist/core/worktree.js +89 -2
- package/dist/facts.js +15 -12
- package/dist/facts.json +14 -11
- package/docs/cli.md +8 -0
- package/docs/code-map.md +60 -28
- package/docs/integrations/mcp.md +5 -2
- package/docs/mcp-schema-changelog.md +11 -1
- package/package.json +1 -1
package/dist/core/identity.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
247
|
+
const files = listCurrentSessionFiles(dir);
|
|
225
248
|
for (const file of files) {
|
|
249
|
+
const filepath = path.join(dir, file);
|
|
226
250
|
try {
|
|
227
|
-
|
|
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(
|
|
264
|
+
fs.unlinkSync(filepath);
|
|
234
265
|
removed++;
|
|
235
266
|
}
|
|
236
267
|
}
|
|
237
268
|
catch {
|
|
238
|
-
//
|
|
239
|
-
|
|
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
|
-
|
|
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') ||
|
|
@@ -48,6 +48,9 @@ export const MCP_HEADLESS_AUTO_TOOL_NAMES = [
|
|
|
48
48
|
'bclaw_code_status',
|
|
49
49
|
'bclaw_code_find',
|
|
50
50
|
'bclaw_code_brief',
|
|
51
|
+
'bclaw_code_impact',
|
|
52
|
+
'bclaw_code_export',
|
|
53
|
+
'bclaw_code_outline',
|
|
51
54
|
'bclaw_send_message',
|
|
52
55
|
'bclaw_ack_message',
|
|
53
56
|
'bclaw_write_note',
|
package/dist/core/runtime.js
CHANGED
|
@@ -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/core/worktree.js
CHANGED
|
@@ -7,6 +7,7 @@ import yaml from 'yaml';
|
|
|
7
7
|
import { logger } from './logger.js';
|
|
8
8
|
import { loadConfig } from './config.js';
|
|
9
9
|
import { parsePorcelainZ, isSystemDirtyPath } from './dirty-scope.js';
|
|
10
|
+
import { entityRecordDirs } from './io.js';
|
|
10
11
|
/** Normalizes a path for use in git CLI arguments (forward slashes on Windows). */
|
|
11
12
|
function gitPath(p) {
|
|
12
13
|
return p.replace(/\\/g, '/');
|
|
@@ -1548,6 +1549,68 @@ export function probeLocalBranch(mainWorktreePath, branchName) {
|
|
|
1548
1549
|
export function isGitRepo(cwd) {
|
|
1549
1550
|
return runGit(['rev-parse', '--is-inside-work-tree'], cwd).ok;
|
|
1550
1551
|
}
|
|
1552
|
+
/**
|
|
1553
|
+
* Comparison key for worktree paths: PHYSICAL identity when the path exists
|
|
1554
|
+
* (realpath expands Windows 8.3 short names — `RUNNER~1` and `runneradmin`
|
|
1555
|
+
* are the same directory but different strings, and git always reports the
|
|
1556
|
+
* long form while a claim may carry the short one), else plain resolution.
|
|
1557
|
+
* Forward slashes, case-folded on win32.
|
|
1558
|
+
*/
|
|
1559
|
+
function worktreePathKey(p) {
|
|
1560
|
+
let resolved;
|
|
1561
|
+
try {
|
|
1562
|
+
resolved = fs.realpathSync.native(p);
|
|
1563
|
+
}
|
|
1564
|
+
catch {
|
|
1565
|
+
resolved = path.resolve(p);
|
|
1566
|
+
}
|
|
1567
|
+
resolved = resolved.replace(/\\/g, '/').replace(/\/+$/, '');
|
|
1568
|
+
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
|
1569
|
+
}
|
|
1570
|
+
/**
|
|
1571
|
+
* Worktree paths referenced by an ACTIVE, non-expired claim — the set the GC
|
|
1572
|
+
* must never touch.
|
|
1573
|
+
*
|
|
1574
|
+
* Read directly from the claims record dirs (both layouts, pln#649) instead of
|
|
1575
|
+
* claims.ts: claims.ts imports worktree.ts, so the dependency can only point
|
|
1576
|
+
* this way. The parse is deliberately lenient — an unreadable claim simply does
|
|
1577
|
+
* not protect anything; it never blocks the GC of OTHER worktrees.
|
|
1578
|
+
*
|
|
1579
|
+
* Scope note: dispatch claims are project-local, so the project store is the
|
|
1580
|
+
* right authority here; workspace-level claims (cross-project) never carry a
|
|
1581
|
+
* lane worktree_path.
|
|
1582
|
+
*/
|
|
1583
|
+
function activeClaimWorktreePaths(cwd) {
|
|
1584
|
+
const out = new Set();
|
|
1585
|
+
const now = new Date();
|
|
1586
|
+
for (const dir of entityRecordDirs('claims', cwd)) {
|
|
1587
|
+
let files;
|
|
1588
|
+
try {
|
|
1589
|
+
files = fs.readdirSync(dir).filter((f) => f.endsWith('.json'));
|
|
1590
|
+
}
|
|
1591
|
+
catch {
|
|
1592
|
+
continue;
|
|
1593
|
+
}
|
|
1594
|
+
for (const f of files) {
|
|
1595
|
+
try {
|
|
1596
|
+
const claim = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf-8'));
|
|
1597
|
+
if (claim.status !== 'active')
|
|
1598
|
+
continue;
|
|
1599
|
+
if (typeof claim.worktree_path !== 'string' || !claim.worktree_path)
|
|
1600
|
+
continue;
|
|
1601
|
+
// Mirror isClaimExpired: a zombie claim past its expiry must not make a
|
|
1602
|
+
// worktree un-GC-able forever.
|
|
1603
|
+
if (claim.expires_at && new Date(claim.expires_at) < now)
|
|
1604
|
+
continue;
|
|
1605
|
+
out.add(worktreePathKey(claim.worktree_path));
|
|
1606
|
+
}
|
|
1607
|
+
catch {
|
|
1608
|
+
/* lenient by design — see above */
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
return out;
|
|
1613
|
+
}
|
|
1551
1614
|
/**
|
|
1552
1615
|
* Removes worktrees whose branch has been fully merged into the current branch
|
|
1553
1616
|
* (typically master/main after a merge). Also removes brainclaw-managed
|
|
@@ -1561,6 +1624,16 @@ export function isGitRepo(cwd) {
|
|
|
1561
1624
|
* - content (`git cherry HEAD <branch>`, patch-id): catches squash merges,
|
|
1562
1625
|
* which is GitHub's default merge strategy on this repo and previously left
|
|
1563
1626
|
* every squashed lane un-GC-able forever.
|
|
1627
|
+
*
|
|
1628
|
+
* ACTIVE-CLAIM GATE (incident 2026-08-10): a freshly-dispatched lane worktree
|
|
1629
|
+
* has no commits of its own — its branch IS an ancestor of HEAD, so both merged
|
|
1630
|
+
* probes say "merged" — and before the agent's first write it has no uncommitted
|
|
1631
|
+
* changes either. Both historical gates therefore pass during a lane's startup
|
|
1632
|
+
* window, and the post-merge hook destroyed a live codex lane 7 minutes after
|
|
1633
|
+
* spawn (worktree emptied under the running agent). The coordination store is
|
|
1634
|
+
* the authority on liveness: a worktree referenced by an active claim is
|
|
1635
|
+
* untouchable — merged or not, clean or not, force or not. The escape hatch is
|
|
1636
|
+
* releasing the claim, never bypassing it.
|
|
1564
1637
|
*/
|
|
1565
1638
|
export function cleanMergedWorktrees(mainWorktreePath, options = {}) {
|
|
1566
1639
|
const result = { removed: [], skipped: [], pruned: false };
|
|
@@ -1580,9 +1653,16 @@ export function cleanMergedWorktrees(mainWorktreePath, options = {}) {
|
|
|
1580
1653
|
.filter(Boolean)
|
|
1581
1654
|
: []);
|
|
1582
1655
|
const worktrees = listWorktrees(mainWorktreePath);
|
|
1656
|
+
const protectedPaths = activeClaimWorktreePaths(mainWorktreePath);
|
|
1583
1657
|
for (const wt of worktrees) {
|
|
1584
1658
|
if (wt.is_main)
|
|
1585
1659
|
continue;
|
|
1660
|
+
// Active-claim gate — see the function doc. Checked BEFORE the merged
|
|
1661
|
+
// probes and BEFORE `force`: a live dispatched lane is never GC-able.
|
|
1662
|
+
if (protectedPaths.has(worktreePathKey(wt.path))) {
|
|
1663
|
+
result.skipped.push({ path: wt.path, reason: 'active claim' });
|
|
1664
|
+
continue;
|
|
1665
|
+
}
|
|
1586
1666
|
// trp#926 — a lane's branch is "merged" if EITHER git says its commits are
|
|
1587
1667
|
// ancestors of HEAD (fast-forward / merge-commit) OR every commit's patch
|
|
1588
1668
|
// is already on HEAD (squash-merge, catching GitHub's default strategy).
|
|
@@ -1621,7 +1701,7 @@ export function cleanMergedWorktrees(mainWorktreePath, options = {}) {
|
|
|
1621
1701
|
}
|
|
1622
1702
|
}
|
|
1623
1703
|
// Clean orphan brainclaw worktree directories (no matching git worktree)
|
|
1624
|
-
cleanOrphanWorktreeDirs(mainWorktreePath, worktrees, result, options.dryRun);
|
|
1704
|
+
cleanOrphanWorktreeDirs(mainWorktreePath, worktrees, result, options.dryRun, protectedPaths);
|
|
1625
1705
|
return result;
|
|
1626
1706
|
}
|
|
1627
1707
|
/** A worker whose heartbeat file was touched within this window looks alive. */
|
|
@@ -1722,7 +1802,7 @@ export function gcWorktreeIfHarvested(mainWorktreePath, worktreePath, options =
|
|
|
1722
1802
|
* Removes brainclaw-managed worktree directories under ~/.brainclaw/worktrees/
|
|
1723
1803
|
* that no longer have a corresponding git worktree entry.
|
|
1724
1804
|
*/
|
|
1725
|
-
function cleanOrphanWorktreeDirs(mainWorktreePath, activeWorktrees, result, dryRun) {
|
|
1805
|
+
function cleanOrphanWorktreeDirs(mainWorktreePath, activeWorktrees, result, dryRun, protectedPaths = new Set()) {
|
|
1726
1806
|
const base = worktreesBaseDir(mainWorktreePath);
|
|
1727
1807
|
if (!fs.existsSync(base))
|
|
1728
1808
|
return;
|
|
@@ -1740,6 +1820,13 @@ function cleanOrphanWorktreeDirs(mainWorktreePath, activeWorktrees, result, dryR
|
|
|
1740
1820
|
const dirPath = path.resolve(path.join(base, entry.name));
|
|
1741
1821
|
if (activePaths.has(dirPath))
|
|
1742
1822
|
continue;
|
|
1823
|
+
// Active-claim gate: a dir whose git admin entry vanished can still host a
|
|
1824
|
+
// LIVE agent (the 2026-08-10 incident left exactly this state behind). If a
|
|
1825
|
+
// claim still points here, it is not debris.
|
|
1826
|
+
if (protectedPaths.has(worktreePathKey(dirPath))) {
|
|
1827
|
+
result.skipped.push({ path: dirPath, reason: 'active claim' });
|
|
1828
|
+
continue;
|
|
1829
|
+
}
|
|
1743
1830
|
// This directory is not referenced by any git worktree — it's orphaned
|
|
1744
1831
|
if (dryRun) {
|
|
1745
1832
|
result.removed.push(dirPath);
|
package/dist/facts.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
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.0 on 2026-08-16T20:27:49.404Z
|
|
3
3
|
export const FACTS = {
|
|
4
|
-
"version": "1.
|
|
5
|
-
"generated_at": "2026-08-
|
|
4
|
+
"version": "1.26.0",
|
|
5
|
+
"generated_at": "2026-08-16T20:27:49.404Z",
|
|
6
6
|
"tools": {
|
|
7
|
-
"count":
|
|
8
|
-
"published_count":
|
|
7
|
+
"count": 70,
|
|
8
|
+
"published_count": 68,
|
|
9
9
|
"names": [
|
|
10
10
|
"bclaw_bootstrap",
|
|
11
11
|
"bclaw_release_notes",
|
|
@@ -34,6 +34,9 @@ export const FACTS = {
|
|
|
34
34
|
"bclaw_code_status",
|
|
35
35
|
"bclaw_code_find",
|
|
36
36
|
"bclaw_code_brief",
|
|
37
|
+
"bclaw_code_impact",
|
|
38
|
+
"bclaw_code_export",
|
|
39
|
+
"bclaw_code_outline",
|
|
37
40
|
"bclaw_code_refresh",
|
|
38
41
|
"bclaw_dispatch",
|
|
39
42
|
"bclaw_send_message",
|
|
@@ -474,8 +477,8 @@ export const FACTS = {
|
|
|
474
477
|
},
|
|
475
478
|
"bench": {
|
|
476
479
|
"schema": "brainclaw.bench.v1",
|
|
477
|
-
"generated_at": "2026-08-
|
|
478
|
-
"node_version": "v24.
|
|
480
|
+
"generated_at": "2026-08-16T20:27:47.271Z",
|
|
481
|
+
"node_version": "v24.19.0",
|
|
479
482
|
"platform": "linux-x64",
|
|
480
483
|
"repeats": 3,
|
|
481
484
|
"scenarios": [
|
|
@@ -483,7 +486,7 @@ export const FACTS = {
|
|
|
483
486
|
"name": "cold_onboard",
|
|
484
487
|
"volume": "empty",
|
|
485
488
|
"description": "fresh machine → init → first useful context. Baseline for time-to-first-value.",
|
|
486
|
-
"duration_ms_median":
|
|
489
|
+
"duration_ms_median": 75,
|
|
487
490
|
"payload_chars_median": 1640,
|
|
488
491
|
"payload_tokens_est_median": 410
|
|
489
492
|
},
|
|
@@ -491,7 +494,7 @@ export const FACTS = {
|
|
|
491
494
|
"name": "warm_work",
|
|
492
495
|
"volume": "medium",
|
|
493
496
|
"description": "bclaw_work consult over a real-shaped store (~200 plans / 500 handoffs / 450 claims).",
|
|
494
|
-
"duration_ms_median":
|
|
497
|
+
"duration_ms_median": 132,
|
|
495
498
|
"payload_chars_median": 2626,
|
|
496
499
|
"payload_tokens_est_median": 657
|
|
497
500
|
},
|
|
@@ -499,9 +502,9 @@ export const FACTS = {
|
|
|
499
502
|
"name": "first_edit",
|
|
500
503
|
"volume": "medium",
|
|
501
504
|
"description": "code_find + code_brief on the fresh-agent path (missing index, first touch).",
|
|
502
|
-
"duration_ms_median":
|
|
503
|
-
"payload_chars_median":
|
|
504
|
-
"payload_tokens_est_median":
|
|
505
|
+
"duration_ms_median": 11,
|
|
506
|
+
"payload_chars_median": 1305,
|
|
507
|
+
"payload_tokens_est_median": 326
|
|
505
508
|
}
|
|
506
509
|
]
|
|
507
510
|
}
|
package/dist/facts.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "1.
|
|
3
|
-
"generated_at": "2026-08-
|
|
2
|
+
"version": "1.26.0",
|
|
3
|
+
"generated_at": "2026-08-16T20:27:49.404Z",
|
|
4
4
|
"tools": {
|
|
5
|
-
"count":
|
|
6
|
-
"published_count":
|
|
5
|
+
"count": 70,
|
|
6
|
+
"published_count": 68,
|
|
7
7
|
"names": [
|
|
8
8
|
"bclaw_bootstrap",
|
|
9
9
|
"bclaw_release_notes",
|
|
@@ -32,6 +32,9 @@
|
|
|
32
32
|
"bclaw_code_status",
|
|
33
33
|
"bclaw_code_find",
|
|
34
34
|
"bclaw_code_brief",
|
|
35
|
+
"bclaw_code_impact",
|
|
36
|
+
"bclaw_code_export",
|
|
37
|
+
"bclaw_code_outline",
|
|
35
38
|
"bclaw_code_refresh",
|
|
36
39
|
"bclaw_dispatch",
|
|
37
40
|
"bclaw_send_message",
|
|
@@ -472,8 +475,8 @@
|
|
|
472
475
|
},
|
|
473
476
|
"bench": {
|
|
474
477
|
"schema": "brainclaw.bench.v1",
|
|
475
|
-
"generated_at": "2026-08-
|
|
476
|
-
"node_version": "v24.
|
|
478
|
+
"generated_at": "2026-08-16T20:27:47.271Z",
|
|
479
|
+
"node_version": "v24.19.0",
|
|
477
480
|
"platform": "linux-x64",
|
|
478
481
|
"repeats": 3,
|
|
479
482
|
"scenarios": [
|
|
@@ -481,7 +484,7 @@
|
|
|
481
484
|
"name": "cold_onboard",
|
|
482
485
|
"volume": "empty",
|
|
483
486
|
"description": "fresh machine → init → first useful context. Baseline for time-to-first-value.",
|
|
484
|
-
"duration_ms_median":
|
|
487
|
+
"duration_ms_median": 75,
|
|
485
488
|
"payload_chars_median": 1640,
|
|
486
489
|
"payload_tokens_est_median": 410
|
|
487
490
|
},
|
|
@@ -489,7 +492,7 @@
|
|
|
489
492
|
"name": "warm_work",
|
|
490
493
|
"volume": "medium",
|
|
491
494
|
"description": "bclaw_work consult over a real-shaped store (~200 plans / 500 handoffs / 450 claims).",
|
|
492
|
-
"duration_ms_median":
|
|
495
|
+
"duration_ms_median": 132,
|
|
493
496
|
"payload_chars_median": 2626,
|
|
494
497
|
"payload_tokens_est_median": 657
|
|
495
498
|
},
|
|
@@ -497,9 +500,9 @@
|
|
|
497
500
|
"name": "first_edit",
|
|
498
501
|
"volume": "medium",
|
|
499
502
|
"description": "code_find + code_brief on the fresh-agent path (missing index, first touch).",
|
|
500
|
-
"duration_ms_median":
|
|
501
|
-
"payload_chars_median":
|
|
502
|
-
"payload_tokens_est_median":
|
|
503
|
+
"duration_ms_median": 11,
|
|
504
|
+
"payload_chars_median": 1305,
|
|
505
|
+
"payload_tokens_est_median": 326
|
|
503
506
|
}
|
|
504
507
|
]
|
|
505
508
|
}
|
package/docs/cli.md
CHANGED
|
@@ -652,6 +652,14 @@ Search the symbol index by name (function / class / component / hook / type). Re
|
|
|
652
652
|
### `brainclaw code-map brief <target> [--limit <n>]`
|
|
653
653
|
|
|
654
654
|
Given a symbol or path, return a ranked reading list (`suggested_files_to_read`) plus related memory (decisions/traps/constraints) — what to read before editing.
|
|
655
|
+
### `brainclaw code-map export <symbol-or-path> [--direction outgoing|incoming|both]`
|
|
656
|
+
|
|
657
|
+
Export a compact **local** Code Map subgraph around one symbol or file. The default
|
|
658
|
+
is one hop in both directions; hard caps always apply (depth 4, 100 nodes, 200
|
|
659
|
+
edges), so the command never defaults to a whole-graph export. `--max-nodes`,
|
|
660
|
+
`--max-edges`, and `--depth` only tighten the result; `--min-confidence` has a
|
|
661
|
+
hard floor of 0.5. JSON keeps each edge's `kind`, `source`, and `confidence`.
|
|
662
|
+
Use `--format mermaid` for a diagram projected from that same JSON model.
|
|
655
663
|
|
|
656
664
|
```bash
|
|
657
665
|
brainclaw code-map refresh --all
|