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/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
@@ -2,7 +2,7 @@ import crypto from 'node:crypto';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { resolveCurrentHostId, sanitizeHostId } from './host.js';
5
- import { resolveEntityDir } from './io.js';
5
+ import { resolveEntityDir, sanitizeAgentPathSegment } from './io.js';
6
6
  import { mutate } from './mutation-pipeline.js';
7
7
  import { loadVersionedJsonFile, saveVersionedJsonFile } from './migration.js';
8
8
  import { RuntimeNoteSchema } from './schema.js';
@@ -19,14 +19,60 @@ function privateRuntimeDir(cwd, mode = 'read') {
19
19
  return resolveEntityDir('runtime-private', cwd ?? process.cwd(), mode);
20
20
  }
21
21
  function sharedAgentDir(agent, cwd, mode = 'read') {
22
- return path.join(sharedRuntimeDir(cwd, mode), agent);
22
+ // pln#673 the agent name is env-controlled and becomes a path segment:
23
+ // normalize it so a traversal cannot be expressed at all.
24
+ return path.join(sharedRuntimeDir(cwd, mode), sanitizeAgentPathSegment(agent));
25
+ }
26
+ /**
27
+ * Return a pre-normalization directory only when it is provably one direct
28
+ * child of `baseDir`. Compatibility reads must not reintroduce the traversal
29
+ * the normalized write path closes: the agent name is still env-controlled.
30
+ *
31
+ * Dots inside a segment are retained for existing names such as
32
+ * `Legacy.Agent`; separators, Win32 aliases (including trailing dots/spaces),
33
+ * and platform-invalid components are not legacy data we can safely probe.
34
+ */
35
+ const UNSAFE_LEGACY_AGENT_SEGMENT_RE = /[<>:"/\\|?*\u0000-\u001F]/;
36
+ const WIN32_RESERVED_LEGACY_AGENT_BASENAME_RE = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i;
37
+ function legacyAgentDir(baseDir, agent) {
38
+ if (agent.length === 0
39
+ || agent !== agent.trim()
40
+ || agent.endsWith('.')
41
+ || UNSAFE_LEGACY_AGENT_SEGMENT_RE.test(agent)
42
+ || WIN32_RESERVED_LEGACY_AGENT_BASENAME_RE.test(agent.split('.')[0]))
43
+ return undefined;
44
+ const base = path.resolve(baseDir);
45
+ const candidate = path.resolve(path.join(base, agent));
46
+ return path.dirname(candidate) === base ? candidate : undefined;
47
+ }
48
+ /**
49
+ * Both directories an agent's notes can occupy, canonical first (pln#673).
50
+ * Writes always use the normalized segment; reads also probe the RAW name so
51
+ * notes written before the normalization stay visible — the dual-read pattern
52
+ * pln#648/pln#670 already use for relocated records. Deduped when the name is
53
+ * already canonical, which is the case for every agent brainclaw produces.
54
+ */
55
+ function agentDirCandidates(baseDir, agent) {
56
+ const canonical = path.join(baseDir, sanitizeAgentPathSegment(agent));
57
+ const raw = legacyAgentDir(baseDir, agent);
58
+ return raw && canonical !== raw ? [canonical, raw] : [canonical];
23
59
  }
24
60
  function hostRootDir(visibility, hostId, cwd, mode = 'read') {
25
61
  const baseDir = visibility === 'machine' ? machineRuntimeDir(cwd, mode) : privateRuntimeDir(cwd, mode);
26
62
  return path.join(baseDir, sanitizeHostId(hostId));
27
63
  }
28
64
  function hostAgentDir(visibility, hostId, agent, cwd, mode = 'read') {
29
- return path.join(hostRootDir(visibility, hostId, cwd, mode), agent);
65
+ // pln#673 same normalization as the shared tree; the host segment was
66
+ // already sanitized (sanitizeHostId), the agent segment was not.
67
+ return path.join(hostRootDir(visibility, hostId, cwd, mode), sanitizeAgentPathSegment(agent));
68
+ }
69
+ /** A contained raw path that can be retired after an update reaches its canonical location. */
70
+ function legacyRuntimeNotePath(note, visibility, hostId, cwd) {
71
+ const base = visibility === 'shared'
72
+ ? sharedRuntimeDir(cwd, 'write')
73
+ : hostRootDir(visibility, hostId, cwd, 'write');
74
+ const legacyDir = legacyAgentDir(base, note.agent);
75
+ return legacyDir ? path.join(legacyDir, `${note.id}.json`) : undefined;
30
76
  }
31
77
  export function ensureRuntimeDir(agent, cwd, visibility = 'shared', hostId) {
32
78
  const dir = visibility === 'shared'
@@ -57,6 +103,13 @@ export function saveRuntimeNote(note, cwd) {
57
103
  registryFaultPoint('after_registry_journal');
58
104
  }
59
105
  saveVersionedJsonFile('runtime_note', filepath, parsed);
106
+ // An update to a pre-normalization record must not leave two physical
107
+ // copies with the same id. Retire only the verified-contained raw path,
108
+ // and only after the canonical write succeeds.
109
+ const legacyPath = legacyRuntimeNotePath(note, visibility, hostId, cwd);
110
+ if (legacyPath && legacyPath !== filepath && fs.existsSync(legacyPath)) {
111
+ fs.unlinkSync(legacyPath);
112
+ }
60
113
  appendEvent({ action: 'create', item_type: 'runtime_note', item_id: note.id, agent: note.agent, agent_id: note.agent_id }, cwd);
61
114
  commitMemoryChange(`runtime note: ${note.note_type ?? 'note'} (${note.agent})`, cwd);
62
115
  });
@@ -64,9 +117,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
- return visibility === 'shared'
68
- ? path.join(sharedAgentDir(note.agent, cwd), `${note.id}.json`)
69
- : path.join(hostAgentDir(visibility, hostId, note.agent, cwd), `${note.id}.json`);
120
+ // pln#673 the canonical (normalized) location, plus the RAW-name fallback
121
+ // for notes written before the normalization: this function answers "where is
122
+ // THIS note", and a record must not become invisible (nor undeletable)
123
+ // because its directory predates the fix. Canonical first; the raw candidate
124
+ // only wins when it actually holds the file.
125
+ const base = visibility === 'shared'
126
+ ? sharedRuntimeDir(cwd)
127
+ : hostRootDir(visibility, hostId, cwd);
128
+ const candidates = agentDirCandidates(base, note.agent).map((dir) => path.join(dir, `${note.id}.json`));
129
+ return candidates.find((candidate) => fs.existsSync(candidate)) ?? candidates[0];
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
- const agents = agent
145
- ? [agent]
146
- : fs.readdirSync(dir).filter((entry) => fs.statSync(path.join(dir, entry)).isDirectory());
204
+ // pln#673 a filtered read probes BOTH the normalized directory and the raw
205
+ // name (a pre-normalization directory must stay readable); an unfiltered read
206
+ // enumerates whatever is on disk, which covers both by construction. The
207
+ // candidates are absolute, so the join below must not prepend `dir` again.
208
+ const agentDirectories = agent
209
+ ? agentDirCandidates(dir, agent)
210
+ : fs.readdirSync(dir)
211
+ .filter((entry) => fs.statSync(path.join(dir, entry)).isDirectory())
212
+ .map((entry) => path.join(dir, entry));
147
213
  const notes = [];
148
- for (const a of agents) {
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.0 on 2026-08-16T20:27:49.404Z
2
+ // Source: brainclaw v1.26.2 on 2026-08-22T12:18:05.969Z
3
3
  export const FACTS = {
4
- "version": "1.26.0",
5
- "generated_at": "2026-08-16T20:27:49.404Z",
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-16T20:27:47.271Z",
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": 75,
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": 132,
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.0",
3
- "generated_at": "2026-08-16T20:27:49.404Z",
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-16T20:27:47.271Z",
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": 75,
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": 132,
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/commands/mcp.ts:MCP_CANONICAL_GRAMMAR_TOOL_NAMES` and are derived from
96
- the tool catalog, not hand-curated. Hermes and other narrow-surface agents
97
- include exactly this set in their MCP `tools.include`.
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/commands/mcp.ts`](../src/commands/mcp.ts) — `MCP_CANONICAL_GRAMMAR_TOOL_NAMES` |
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)` | Drive a turn in an existing multi-turn loop (`turn`, `complete_turn`, `advance`, `close`; implementation loops add `bind` to dispatch the linked sequence and `verify` to run the opener-configured `command_green` check). Do not call `bclaw_loop(intent="open")` directly without dispatch — use `bclaw_coordinate(intent="review", open_loop: true)` instead. |
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