brainclaw 1.26.0 → 1.26.2
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/README.md +28 -12
- package/dist/brainclaw-vscode.vsix +0 -0
- package/dist/commands/session-start.js +54 -3
- package/dist/core/agent-files.js +21 -21
- package/dist/core/identity.js +256 -115
- package/dist/core/io.js +165 -0
- package/dist/core/protocol-tool-policy.js +44 -0
- package/dist/core/runtime.js +76 -11
- package/dist/core/store-resolution.js +5 -21
- package/dist/facts.js +6 -6
- package/dist/facts.json +5 -5
- package/docs/PROTOCOL.md +6 -4
- package/docs/cli.md +1 -1
- package/docs/concepts/loop-engine.md +60 -34
- package/docs/integrations/hermes.md +42 -3
- package/docs/integrations/mcp.md +17 -5
- package/docs/product/agent-first-model.md +33 -33
- 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,6 +123,90 @@ 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
|
+
}
|
|
124
210
|
export const SESSION_SNAPSHOT_FILENAME_SUFFIX = '.snapshot.json';
|
|
125
211
|
/**
|
|
126
212
|
* Filesystem type discriminator for session snapshots (codex review, pln#670).
|
|
@@ -142,6 +228,9 @@ export function isSessionSnapshotRecordFilename(filename) {
|
|
|
142
228
|
* before the split; readers must schema-validate every candidate.
|
|
143
229
|
*/
|
|
144
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);
|
|
145
234
|
const dirs = entityRecordDirs('sessions', cwd ?? process.cwd(), preferredDirName);
|
|
146
235
|
return [
|
|
147
236
|
...dirs.map((d) => path.join(d, `${sessionId}.snapshot.json`)),
|
|
@@ -151,6 +240,82 @@ export function sessionSnapshotRecordPaths(sessionId, cwd, preferredDirName) {
|
|
|
151
240
|
export function memoryDir(cwd = process.cwd(), preferredDirName) {
|
|
152
241
|
return path.join(cwd, preferredDirName ?? MEMORY_DIR);
|
|
153
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
|
+
}
|
|
154
319
|
export function memoryPath(filename, cwd, preferredDirName) {
|
|
155
320
|
return path.join(memoryDir(cwd, preferredDirName), filename);
|
|
156
321
|
}
|
|
@@ -86,6 +86,50 @@ export const MCP_CANONICAL_GRAMMAR_TOOL_NAMES = [
|
|
|
86
86
|
'bclaw_update',
|
|
87
87
|
'bclaw_transition',
|
|
88
88
|
];
|
|
89
|
+
/**
|
|
90
|
+
* Curated MCP workflow surface for Hermes. Hermes receives the shared Tier B
|
|
91
|
+
* instructions, which prescribe session/claim closure, inbox coordination,
|
|
92
|
+
* step updates, and Code Map discovery in addition to the canonical memory
|
|
93
|
+
* grammar. Keep this list aligned with that instruction contract; it is an
|
|
94
|
+
* advertised-tool policy, not a headless auto-approval policy.
|
|
95
|
+
*/
|
|
96
|
+
export const MCP_HERMES_WORKFLOW_TOOL_NAMES = [
|
|
97
|
+
...MCP_CANONICAL_GRAMMAR_TOOL_NAMES,
|
|
98
|
+
'bclaw_remove',
|
|
99
|
+
'bclaw_move',
|
|
100
|
+
'bclaw_session_start',
|
|
101
|
+
'bclaw_session_end',
|
|
102
|
+
'bclaw_claim',
|
|
103
|
+
'bclaw_release_claim',
|
|
104
|
+
'bclaw_add_step',
|
|
105
|
+
'bclaw_complete_step',
|
|
106
|
+
'bclaw_update_step',
|
|
107
|
+
'bclaw_delete_step',
|
|
108
|
+
'bclaw_list_sequences',
|
|
109
|
+
'bclaw_create_sequence',
|
|
110
|
+
'bclaw_update_sequence',
|
|
111
|
+
'bclaw_delete_sequence',
|
|
112
|
+
'bclaw_read_inbox',
|
|
113
|
+
'bclaw_ack_message',
|
|
114
|
+
'bclaw_send_message',
|
|
115
|
+
'bclaw_correct_handoff',
|
|
116
|
+
'bclaw_write_note',
|
|
117
|
+
'bclaw_quick_capture',
|
|
118
|
+
'bclaw_search',
|
|
119
|
+
'bclaw_setup',
|
|
120
|
+
'bclaw_bootstrap',
|
|
121
|
+
'bclaw_switch',
|
|
122
|
+
'bclaw_release_notes',
|
|
123
|
+
'bclaw_coordinate',
|
|
124
|
+
'bclaw_dispatch',
|
|
125
|
+
'bclaw_loop',
|
|
126
|
+
'bclaw_dispatch_status',
|
|
127
|
+
'bclaw_assignment_update',
|
|
128
|
+
'bclaw_code_find',
|
|
129
|
+
'bclaw_code_brief',
|
|
130
|
+
'bclaw_code_status',
|
|
131
|
+
'bclaw_code_refresh',
|
|
132
|
+
];
|
|
89
133
|
/**
|
|
90
134
|
* Tools removed from the MCP surface at the v1.0 cut (Phase 3 slice 3i).
|
|
91
135
|
* Hidden from every `tools/list` response; direct `tools/call` still works
|
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,16 @@ 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];
|
|
70
130
|
}
|
|
71
131
|
/**
|
|
72
132
|
* Park one runtime note's raw record under `.brainclaw/gc-backups/` — the same
|
|
@@ -141,12 +201,17 @@ export function listSharedJournaledRuntimeNotes(cwd) {
|
|
|
141
201
|
function readAgentNotes(dir, agent) {
|
|
142
202
|
if (!fs.existsSync(dir))
|
|
143
203
|
return [];
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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));
|
|
147
213
|
const notes = [];
|
|
148
|
-
for (const
|
|
149
|
-
const agentDirectory = path.join(dir, a);
|
|
214
|
+
for (const agentDirectory of agentDirectories) {
|
|
150
215
|
if (!fs.existsSync(agentDirectory))
|
|
151
216
|
continue;
|
|
152
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.26.
|
|
2
|
+
// Source: brainclaw v1.26.2 on 2026-08-22T12:18:05.969Z
|
|
3
3
|
export const FACTS = {
|
|
4
|
-
"version": "1.26.
|
|
5
|
-
"generated_at": "2026-08-
|
|
4
|
+
"version": "1.26.2",
|
|
5
|
+
"generated_at": "2026-08-22T12:18:05.969Z",
|
|
6
6
|
"tools": {
|
|
7
7
|
"count": 70,
|
|
8
8
|
"published_count": 68,
|
|
@@ -477,7 +477,7 @@ export const FACTS = {
|
|
|
477
477
|
},
|
|
478
478
|
"bench": {
|
|
479
479
|
"schema": "brainclaw.bench.v1",
|
|
480
|
-
"generated_at": "2026-08-
|
|
480
|
+
"generated_at": "2026-08-22T12:18:03.871Z",
|
|
481
481
|
"node_version": "v24.19.0",
|
|
482
482
|
"platform": "linux-x64",
|
|
483
483
|
"repeats": 3,
|
|
@@ -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": 76,
|
|
490
490
|
"payload_chars_median": 1640,
|
|
491
491
|
"payload_tokens_est_median": 410
|
|
492
492
|
},
|
|
@@ -494,7 +494,7 @@ 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":
|
|
497
|
+
"duration_ms_median": 123,
|
|
498
498
|
"payload_chars_median": 2626,
|
|
499
499
|
"payload_tokens_est_median": 657
|
|
500
500
|
},
|
package/dist/facts.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "1.26.
|
|
3
|
-
"generated_at": "2026-08-
|
|
2
|
+
"version": "1.26.2",
|
|
3
|
+
"generated_at": "2026-08-22T12:18:05.969Z",
|
|
4
4
|
"tools": {
|
|
5
5
|
"count": 70,
|
|
6
6
|
"published_count": 68,
|
|
@@ -475,7 +475,7 @@
|
|
|
475
475
|
},
|
|
476
476
|
"bench": {
|
|
477
477
|
"schema": "brainclaw.bench.v1",
|
|
478
|
-
"generated_at": "2026-08-
|
|
478
|
+
"generated_at": "2026-08-22T12:18:03.871Z",
|
|
479
479
|
"node_version": "v24.19.0",
|
|
480
480
|
"platform": "linux-x64",
|
|
481
481
|
"repeats": 3,
|
|
@@ -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": 76,
|
|
488
488
|
"payload_chars_median": 1640,
|
|
489
489
|
"payload_tokens_est_median": 410
|
|
490
490
|
},
|
|
@@ -492,7 +492,7 @@
|
|
|
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":
|
|
495
|
+
"duration_ms_median": 123,
|
|
496
496
|
"payload_chars_median": 2626,
|
|
497
497
|
"payload_tokens_est_median": 657
|
|
498
498
|
},
|
package/docs/PROTOCOL.md
CHANGED
|
@@ -92,9 +92,11 @@ surface stays small; richer ergonomic helpers are facades on top.
|
|
|
92
92
|
The reference implementation surfaces these as MCP tools named
|
|
93
93
|
`bclaw_work`, `bclaw_context`, `bclaw_find`, `bclaw_get`, `bclaw_create`,
|
|
94
94
|
`bclaw_update`, `bclaw_transition`. The same names appear in
|
|
95
|
-
`src/
|
|
96
|
-
|
|
97
|
-
|
|
95
|
+
`src/core/protocol-tool-policy.ts:MCP_CANONICAL_GRAMMAR_TOOL_NAMES`; the
|
|
96
|
+
catalog derivation is checked against that static core policy in tests. This is
|
|
97
|
+
the minimum grammar, not a universal agent allowlist. Hermes receives the
|
|
98
|
+
broader `MCP_HERMES_WORKFLOW_TOOL_NAMES` surface because its generated
|
|
99
|
+
instructions also require lifecycle, inbox, coordination, and Code Map tools.
|
|
98
100
|
|
|
99
101
|
### 4.1 Coordination verbs (experimental — protocol v0.2 candidates)
|
|
100
102
|
|
|
@@ -197,7 +199,7 @@ the wire format for cross-project signaling.
|
|
|
197
199
|
| Protocol concept | Reference implementation in brainclaw |
|
|
198
200
|
|-------------------------------|-----------------------------------------------------------------------|
|
|
199
201
|
| Entity schemas | [`src/core/schema.ts`](../src/core/schema.ts) |
|
|
200
|
-
| Canonical grammar tool set | [`src/
|
|
202
|
+
| Canonical grammar tool set | [`src/core/protocol-tool-policy.ts`](../src/core/protocol-tool-policy.ts) — `MCP_CANONICAL_GRAMMAR_TOOL_NAMES` |
|
|
201
203
|
| MCP tool catalog | [`src/commands/mcp.ts`](../src/commands/mcp.ts) — `ALL_TOOLS` |
|
|
202
204
|
| Per-agent writer wiring | [`src/core/agent-files.ts`](../src/core/agent-files.ts) — `AGENT_WIRING_REGISTRY` |
|
|
203
205
|
| Capability profiles | [`src/core/agent-capability.ts`](../src/core/agent-capability.ts) |
|
package/docs/cli.md
CHANGED
|
@@ -1984,7 +1984,7 @@ The default catalog is intentionally small and centred on the canonical grammar.
|
|
|
1984
1984
|
|---|---|
|
|
1985
1985
|
| `bclaw_coordinate(intent)` | Assign, consult, review, reroute, or summarize across agents. Pass `open_loop: true` on `intent="review"` to also dispatch the reviewer turn. |
|
|
1986
1986
|
| `bclaw_dispatch(intent)` | Parallelize execute across a sequence's lanes (analysis / execute / review). |
|
|
1987
|
-
| `bclaw_loop(intent)` |
|
|
1987
|
+
| `bclaw_loop(intent)` | Open, inspect, or drive a multi-turn loop. The public lifecycle is `open`, `get`, `list`, `turn`, `complete_turn`, `advance`, `add_artifact`, `pause`, `resume`, and `close`; implementation loops also add `bind` and `verify`, and any kind may use `request_input` / `provide_input`. `bclaw_coordinate` / `bclaw_dispatch` remain the ergonomic review and ideation shortcuts. A direct `open` must include `allow_orphan: true` to acknowledge that the caller will dispatch or drive it. |
|
|
1988
1988
|
|
|
1989
1989
|
**Sequences**:
|
|
1990
1990
|
|