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 CHANGED
@@ -46,7 +46,7 @@ It sits alongside your coding agents and gives them a shared state layer they ca
46
46
  | **Agent-ready context** | compact, prompt-sized context built from real workspace state instead of stale instructions |
47
47
  | **Code Map** | a Tree-sitter symbol + import index (11 languages — JS/TS, Python, PHP, Java, Go, Rust, C#, Ruby, C, C++) so agents ask "where is X / what should I read first" before editing, with related decisions/traps attached — `bclaw_code_find` / `bclaw_code_brief`, see [code map](docs/code-map.md) |
48
48
  | **Native agent files** | auto-writes `CLAUDE.md`, `AGENTS.md`, `GEMINI.md`, `.cursor/rules/`, `.windsurfrules`, and similar local guidance |
49
- | **Multi-turn loops** | review and ideation loops with structured phases, iteration semantics, and per-phase memory filters — see[loop engine](docs/concepts/loop-engine.md) and [ideation loop](docs/concepts/ideation-loop.md) |
49
+ | **Multi-turn loops** | review, ideation, implementation, research, and debug workflows with structured phases, iteration semantics, verification gates, and per-phase memory filters — see [loop engine](docs/concepts/loop-engine.md) |
50
50
  | **Machine AI surface discovery** | detects local coding agents plus desktop AI work surfaces such as ChatGPT Desktop and Gemini CLI |
51
51
  | **Queued surface tasks** | stores project-scoped requests for other local AI surfaces, such as visual generation, drafting, summaries, or research |
52
52
  | **Local-first storage** | plain text + JSON, Git-friendly, no mandatory cloud, no telemetry by default |
@@ -263,14 +263,29 @@ bclaw_loop({ intent: "get", loop_id: "lop_abc" }); // inspect status any time
263
263
 
264
264
  ## The Loop Engine (Multi-Turn Workflows)
265
265
 
266
- Brainclaw's Loop Engine moves beyond manual ping-pong by formalizing multi-turn workflows (review, ideation, testing). It features two distinct review modes:
267
-
268
- - **Asymmetric Mode**: The classic author→reviewer handoff. The reviewer creates findings, and the original author must apply the fixes.
269
- - **Symmetric Mode**: Eliminates unnecessary round-trips. Both the author and reviewer slots can apply fixes directly, drastically speeding up spec and documentation reviews.
270
-
271
- Each loop maintains a structured lifecycle, explicit phases, iteration bounds, and per-phase memory filters, executed seamlessly via `bclaw_loop`.
272
-
273
- **Autonomous convergence (pln#628 Focus 4B + pln#630):** a dispatched reviewer doesn't need to be driven by hand. It writes its verdict (`review_verdict: approve | request_changes`) into its `LANE-RESULT.json`; when the coordinator harvests the lane, brainclaw records the verdict on the loop and **auto-closes it on approve** — the review loop reaches `reviewer_green` with no human ping-pong. On `request_changes`, brainclaw **runs the fix→re-review cycle autonomously**: it bumps the round, retains the worktree, and re-dispatches — through an exactly-once turn-attempt state machine (immutable attempt records behind an atomic launch fence, on by default; kill-switch `BRAINCLAW_TURN_OWNED_REVIEW=0`) so a turn is never double-spawned, with a bounded round cap that lands on `blocked` instead of looping forever.
266
+ Brainclaw's Loop Engine formalizes repeated multi-turn work so agents can
267
+ resume, automate, and audit it rather than relying on manual ping-pong. It is
268
+ one engine with five shipped default workflows: **review, ideation,
269
+ implementation, research, and debug**.
270
+
271
+ | Workflow | Typical outcome | Normal entry point |
272
+ | --- | --- | --- |
273
+ | Review | accepted verdict or bounded fix cycle | `bclaw_coordinate(intent="review", open_loop=true)` |
274
+ | Ideation | memory-confronted plan draft or synthesis | `bclaw_coordinate(intent="ideate")` |
275
+ | Implementation | green verification and handoff | `bclaw_loop(intent="open", kind="implementation", allow_orphan=true)`, then `bind` |
276
+ | Research | evidence-backed synthesis | `bclaw_loop(intent="open", kind="research", allow_orphan=true)` |
277
+ | Debug | reproduced, verified fix and handoff | `bclaw_loop(intent="open", kind="debug", allow_orphan=true)` |
278
+
279
+ Every loop has structured phases, bounded iteration, explicit artifacts, and
280
+ per-phase memory filters. The shared controls are `open`, `turn`,
281
+ `complete_turn`, `advance`, `add_artifact`, `pause`, `resume`, and `close`;
282
+ implementation also adds `bind` and `verify`. `request_input` /
283
+ `provide_input` are cross-cutting clarification primitives for any workflow.
284
+
285
+ Review is a useful specialized path, not the definition of the engine. It has
286
+ asymmetric and symmetric modes and can auto-close on an approved verdict; the
287
+ other workflows use the same lifecycle to converge on a plan, synthesis,
288
+ handoff, or verified fix. See the [Loop Engine guide](docs/concepts/loop-engine.md).
274
289
 
275
290
  ## Enterprise Ready: Mono-repo & Micro-services
276
291
 
@@ -310,7 +325,7 @@ Recent releases have moved a lot of multi-agent parallel work from "risky" to "s
310
325
 
311
326
  - **Per-claim auto-worktree** — each dispatched lane gets its own isolated git worktree; the coordinator integrates with an octopus merge.
312
327
  - **Sequenced parallel execute** — `bclaw_dispatch(intent="execute")` fans out independent lanes across several agent instances and integrates the result.
313
- - **Symmetric review-fix loops** — `bclaw_coordinate(intent="review", open_loop=true, review_mode="symmetric")` runs an alternating review-and-fix conversation across two slots without shared-checkout collisions. The reviewer's verdict is harvested from `LANE-RESULT.json` and the loop **auto-closes on approve** no manual round-trip to converge the approve path.
328
+ - **Loop Engine protocols** — review, ideation, implementation, research, and debug workflows share a persisted lifecycle. Review offers a symmetric auto-fix shortcut; implementation and debug bind verification to the work; ideation and research converge on durable syntheses.
314
329
  - **Cross-platform spawn** — OS-aware prompt delivery (stdin pipe / inline arg) plus a brief-ack file handshake, so spawned workers can be detected and timed out reliably on Windows and Unix.
315
330
  - **Worktree GC is scope-bounded** — symlinks and junctions are no longer followed during cleanup, so post-merge sweeps can't wipe `node_modules` or other neighboring directories.
316
331
  - **MCP runtime self-heal** — when the runtime is corrupted, the server logs an actionable repair pointer; `brainclaw doctor --repair` rebuilds dist in one step.
@@ -327,8 +342,9 @@ Recommended use today:
327
342
 
328
343
  1. for parallel work, dispatch a sequence with `bclaw_dispatch(intent="execute")` — each lane gets its own worktree
329
344
  2. for sequential work in the same project, let one agent claim at a time and rely on handoffs
330
- 3. when reviewing or fixing across agents, prefer symmetric review loops over manual ping-pong
331
- 4. keep multi-machine workflows on a single source of truth until federation lands
345
+ 3. choose the loop by outcome: ideation for a plan, implementation or debug for a verified handoff, research for a synthesis, and review for a verdict
346
+ 4. when reviewing or fixing across agents, prefer symmetric review loops over manual ping-pong
347
+ 5. keep multi-machine workflows on a single source of truth until federation lands
332
348
 
333
349
  ---
334
350
 
Binary file
@@ -2,9 +2,9 @@ import fs from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import { execSync } from 'node:child_process';
5
- import { isSessionSnapshotRecordFilename, memoryExists, resolveEntityDir, sessionSnapshotRecordPaths } from '../core/io.js';
5
+ import { assertSafeSessionId, isSessionSnapshotRecordFilename, memoryExists, resolveEntityDir, sessionSnapshotRecordPaths } from '../core/io.js';
6
6
  import { loadVersionedJsonFile, saveVersionedJsonFile } from '../core/migration.js';
7
- import { buildOperationalIdentity, loadAllSessions, saveCurrentSession } from '../core/identity.js';
7
+ import { buildOperationalIdentity, describeIgnoredSessionIdEnv, loadAllSessions, saveCurrentSession } from '../core/identity.js';
8
8
  import { requireMinimumTrustLevel, resolveCurrentModel, resolveOrAutoRegisterAgentIdentity } from '../core/agent-registry.js';
9
9
  import { buildContext, renderContextPromptTemplate } from '../core/context.js';
10
10
  import { writeContextMarker } from '../core/freshness.js';
@@ -35,8 +35,39 @@ function sessionSnapshotWriteDir(cwd) {
35
35
  return resolveEntityDir('sessions', cwd ?? process.cwd(), 'write');
36
36
  }
37
37
  function sessionSnapshotPath(sessionId, cwd) {
38
+ // pln#672 review P1 — THE SECOND WRITER. This builder was unguarded while
39
+ // sessionFilePathIn / sessionSnapshotRecordPaths were hardened, so an
40
+ // env-controlled traversal still escaped the store HERE, before the
41
+ // current_session write refused it (reproduced by the reviewer:
42
+ // `ESCAPED.snapshot.json` landed outside, and the later throw did not undo
43
+ // the escaped write). A guard on some builders is not a guard.
44
+ assertSafeSessionId(sessionId);
38
45
  return path.join(sessionSnapshotWriteDir(cwd), `${sessionId}.snapshot.json`);
39
46
  }
47
+ /**
48
+ * pln#672 review P2 — the snapshot write needs the same positive proof the
49
+ * current_session write already has. On a case-insensitive filesystem
50
+ * `CaseSnapshot` and `casesnapshot` name the SAME file, so a second session
51
+ * silently overwrote the first one's snapshot. Refuse to replace a record
52
+ * that names a different session; identical-id rewrites (heartbeat, restart)
53
+ * stay allowed.
54
+ */
55
+ function assertSnapshotSlotFree(filepath, sessionId) {
56
+ if (!fs.existsSync(filepath))
57
+ return;
58
+ try {
59
+ const raw = JSON.parse(fs.readFileSync(filepath, 'utf-8'));
60
+ if (typeof raw.session_id === 'string' && raw.session_id !== sessionId) {
61
+ throw new Error(`Refusing to overwrite the session_snapshot of '${raw.session_id}' at '${filepath}' with '${sessionId}' — the two ids resolve to the same filename on this filesystem`);
62
+ }
63
+ }
64
+ catch (err) {
65
+ // A deliberate refusal propagates; an unreadable record does not block a
66
+ // fresh write (it is not proof that another session owns the slot).
67
+ if (err instanceof Error && err.message.startsWith('Refusing to overwrite'))
68
+ throw err;
69
+ }
70
+ }
40
71
  /**
41
72
  * pln#670 — lazy migration of pre-split snapshot records: rename `<id>.json` to
42
73
  * `<id>.snapshot.json` in the CANONICAL sessions directory only. The legacy
@@ -145,6 +176,11 @@ export async function runSessionStart(options = {}) {
145
176
  if (snapshot.stale_surfaces) {
146
177
  console.warn(`⚠ ${snapshot.stale_surfaces.message}`);
147
178
  }
179
+ // pln#672 — a dropped env session id must reach the human too: the whole
180
+ // point of the warning is that the session identity is NOT the one asked for.
181
+ if (snapshot.invalid_session_id_ignored) {
182
+ console.warn(`⚠ ${snapshot.invalid_session_id_ignored.message}`);
183
+ }
148
184
  // Fifth instance of the computed-then-dropped class, caught by the new seam
149
185
  // guard on its first run: built since the shared-checkout detection landed,
150
186
  // read by nothing. Two agents editing one checkout is precisely what a human
@@ -202,7 +238,9 @@ export async function startSession(options = {}) {
202
238
  const dir = sessionSnapshotWriteDir(options.cwd);
203
239
  if (!fs.existsSync(dir))
204
240
  fs.mkdirSync(dir, { recursive: true });
205
- saveVersionedJsonFile('session_snapshot', sessionSnapshotPath(snapshot.session_id, options.cwd), SessionSnapshotSchema.parse(snapshot));
241
+ const snapshotPath = sessionSnapshotPath(snapshot.session_id, options.cwd);
242
+ assertSnapshotSlotFree(snapshotPath, snapshot.session_id);
243
+ saveVersionedJsonFile('session_snapshot', snapshotPath, SessionSnapshotSchema.parse(snapshot));
206
244
  // Resolve git branch and worktree for session tracking
207
245
  let currentBranch;
208
246
  let currentWorktreePath;
@@ -371,6 +409,18 @@ export async function startSession(options = {}) {
371
409
  staleSurfaces = staleSurfaceWarning(freshness, currentVersion);
372
410
  }
373
411
  catch { /* non-fatal */ }
412
+ // pln#672 — report a DROPPED env session id. Never echo the raw value: it is
413
+ // attacker-influenced and would land straight in logs; the variable name and
414
+ // its length are enough to diagnose.
415
+ let invalidSessionIdIgnored;
416
+ const droppedSessionEnv = describeIgnoredSessionIdEnv();
417
+ if (droppedSessionEnv) {
418
+ invalidSessionIdIgnored = {
419
+ code: 'invalid_session_id_ignored',
420
+ message: `${droppedSessionEnv.variable} carries a session id that cannot be a record filename (${droppedSessionEnv.length} chars) — it was ignored and this session runs under '${snapshot.session_id}'. Unset or fix the variable to resume the intended session.`,
421
+ data: { variable: droppedSessionEnv.variable, length: droppedSessionEnv.length, effective_session_id: snapshot.session_id },
422
+ };
423
+ }
374
424
  // Materialize incoming federation signals from linked projects (Phase 0 — local)
375
425
  if (maintenanceMode === 'full') {
376
426
  try {
@@ -406,6 +456,7 @@ export async function startSession(options = {}) {
406
456
  ...(staleClaimsReleased ? { stale_claims_released: staleClaimsReleased } : {}),
407
457
  ...(memoryPressure ? { memory_pressure: memoryPressure } : {}),
408
458
  ...(staleSurfaces ? { stale_surfaces: toWarningDetail(staleSurfaces) } : {}),
459
+ ...(invalidSessionIdIgnored ? { invalid_session_id_ignored: toWarningDetail(invalidSessionIdIgnored) } : {}),
409
460
  ...(autoRegistered ? { auto_registered: true } : {}),
410
461
  };
411
462
  }
@@ -3,7 +3,7 @@ import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import { spawnSync } from 'node:child_process';
5
5
  import yaml from 'yaml';
6
- import { MCP_HEADLESS_AUTO_TOOL_NAMES, MCP_CANONICAL_GRAMMAR_TOOL_NAMES, REMOVED_IN_V1_TOOLS } from './protocol-tool-policy.js';
6
+ import { MCP_HEADLESS_AUTO_TOOL_NAMES, MCP_CANONICAL_GRAMMAR_TOOL_NAMES, MCP_HERMES_WORKFLOW_TOOL_NAMES, REMOVED_IN_V1_TOOLS } from './protocol-tool-policy.js';
7
7
  import { renderToml, tomlArrayTableHasEntry } from './toml-writer.js';
8
8
  import { PROTOCOL_SKILLS, renderProtocolSkill } from './protocol-skills.js';
9
9
  import { getInstalledBrainclawVersion } from './brainclaw-version.js';
@@ -364,7 +364,6 @@ export const LOCAL_ONLY_AGENT_WORKSPACE_FILES = [
364
364
  KILOCODE_MCP_RELATIVE_PATH,
365
365
  KILOCODE_CONFIG_RELATIVE_PATH,
366
366
  MISTRAL_VIBE_CONFIG_RELATIVE_PATH,
367
- HERMES_CONFIG_RELATIVE_PATH,
368
367
  CONTINUE_CONFIG_RELATIVE_PATH,
369
368
  OPENCODE_CONFIG_RELATIVE_PATH,
370
369
  WINDSURF_MCP_RELATIVE_PATH,
@@ -1553,25 +1552,22 @@ export function ensureMistralVibeMcpConfig(cwd) {
1553
1552
  relativePath: MISTRAL_VIBE_CONFIG_RELATIVE_PATH,
1554
1553
  };
1555
1554
  }
1556
- // Hermes' MCP `tools.include` array — narrow canonical-grammar surface. Derived
1557
- // from MCP_CANONICAL_GRAMMAR_TOOL_NAMES (which is itself ALL_TOOLS-derived) so
1558
- // new facade tools or canonical grammar verbs propagate without a manual edit
1559
- // here (pln#546 step 2). REMOVED_IN_V1_TOOLS are stripped so deprecated names
1560
- // don't reappear in user-facing configs.
1561
- //
1562
- // LAZY (pln#564 coordinator fix): computed on first call, NOT at module init.
1563
- // agent-files.ts ↔ commands/mcp.ts form an import cycle; reading the imported
1564
- // MCP_CANONICAL_GRAMMAR_TOOL_NAMES at module-eval time threw a TDZ
1565
- // ("Cannot access 'MCP_CANONICAL_GRAMMAR_TOOL_NAMES' before initialization")
1566
- // when agent-files loaded mid-mcp-init — which broke the MCP server. tsc does
1567
- // not catch this (runtime-only). Deferring the read to call time fixes it.
1568
- let hermesBrainclawMcpToolsCache;
1569
1555
  function getHermesBrainclawMcpTools() {
1570
- if (!hermesBrainclawMcpToolsCache) {
1571
- hermesBrainclawMcpToolsCache = MCP_CANONICAL_GRAMMAR_TOOL_NAMES
1572
- .filter((name) => !REMOVED_IN_V1_TOOLS.has(name));
1573
- }
1574
- return hermesBrainclawMcpToolsCache;
1556
+ return MCP_HERMES_WORKFLOW_TOOL_NAMES
1557
+ .filter((name) => !REMOVED_IN_V1_TOOLS.has(name));
1558
+ }
1559
+ function hasExactMcpToolList(value, expected) {
1560
+ return Array.isArray(value)
1561
+ && value.length === expected.length
1562
+ && value.every((tool, index) => tool === expected[index]);
1563
+ }
1564
+ function isLegacyHermesBrainclawMcpTools(value) {
1565
+ // The original Hermes writer emitted precisely the canonical seven-tool
1566
+ // list. Upgrade that known managed value, but preserve every other list as
1567
+ // an explicit user customization.
1568
+ const legacyTools = MCP_CANONICAL_GRAMMAR_TOOL_NAMES
1569
+ .filter((name) => !REMOVED_IN_V1_TOOLS.has(name));
1570
+ return hasExactMcpToolList(value, legacyTools);
1575
1571
  }
1576
1572
  export function ensureHermesMcpConfig(homeDir, workspacePath) {
1577
1573
  if (!homeDir)
@@ -1617,6 +1613,8 @@ export function ensureHermesMcpConfig(homeDir, workspacePath) {
1617
1613
  }
1618
1614
  }
1619
1615
  const mcpCmd = getBrainclawMcpCommand();
1616
+ const existingInclude = currentTools.include;
1617
+ const managedInclude = getHermesBrainclawMcpTools();
1620
1618
  const desiredEntry = {
1621
1619
  ...current,
1622
1620
  command: typeof current.command === 'string' ? current.command : mcpCmd.command,
@@ -1627,7 +1625,9 @@ export function ensureHermesMcpConfig(homeDir, workspacePath) {
1627
1625
  },
1628
1626
  tools: {
1629
1627
  ...currentTools,
1630
- include: Array.isArray(currentTools.include) ? currentTools.include : getHermesBrainclawMcpTools(),
1628
+ include: Array.isArray(existingInclude) && !isLegacyHermesBrainclawMcpTools(existingInclude)
1629
+ ? existingInclude
1630
+ : managedInclude,
1631
1631
  prompts: typeof currentTools.prompts === 'boolean' ? currentTools.prompts : false,
1632
1632
  resources: typeof currentTools.resources === 'boolean' ? currentTools.resources : false,
1633
1633
  },