brainclaw 1.6.0 → 1.7.1

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
@@ -14,7 +14,7 @@ If you've ever:
14
14
  - watched two coworkers (or two agents) **edit the same files** without knowing it,
15
15
  - or **gave up running multiple agents in parallel** because keeping them in sync was a pain,
16
16
 
17
- brainclaw gives you durable shared state across sessions, agents, and teammates. Plans, claims, handoffs, decisions, and traps live in `.brainclaw/`, work identically across any compatible agent (Claude Code, Codex, Copilot, Cline, OpenCode, Cursor, Windsurf, Kilocode, Roo Code, Continue, Mistral Vibe, Antigravity/Gemini CLI, …), and stay accessible whether you orchestrate them in parallel or pick them up one after another.
17
+ brainclaw gives you durable shared state across sessions, agents, and teammates. Plans, claims, handoffs, decisions, and traps live in `.brainclaw/`, work identically across any compatible agent (Claude Code, Codex, Copilot, Cline, OpenCode, Cursor, Windsurf, Kilocode, Roo Code, Continue, Mistral Vibe, Hermes, Antigravity/Gemini CLI, …), and stay accessible whether you orchestrate them in parallel or pick them up one after another.
18
18
 
19
19
  Use it two ways — **together or separately**:
20
20
 
@@ -81,7 +81,8 @@ brainclaw is designed to sit alongside the coding agents teams are already using
81
81
 
82
82
  | Logo | Agent | Tier | What brainclaw configures |
83
83
  |---|---|---|---|
84
- | [![OpenClaw](https://img.shields.io/badge/OpenClaw-FF6B35?logoColor=white)](https://github.com/openclaw/openclaw) | **[OpenClaw](https://github.com/openclaw/openclaw)** | B | MCP + brainclaw skill (SKILL.md) for structured project memory |
84
+ | [![OpenClaw](https://img.shields.io/badge/OpenClaw-FF6B35?logoColor=white)](https://github.com/openclaw/openclaw) | **[OpenClaw](https://github.com/openclaw/openclaw)** | B | MCP + brainclaw skill (SKILL.md) for structured project memory |
85
+ | [![Hermes](https://img.shields.io/badge/Hermes-111111?logoColor=white)](https://github.com/NousResearch/hermes-agent) | **[Hermes Agent](https://github.com/NousResearch/hermes-agent)** | B | MCP + universal `.agents/skills/brainclaw/SKILL.md` |
85
86
  | [![NanoClaw](https://img.shields.io/badge/NanoClaw-4A90D9?logoColor=white)](https://github.com/qwibitai/nanoclaw) | **[NanoClaw](https://github.com/qwibitai/nanoclaw)** | C | brainclaw skill — messaging agent (WhatsApp, Telegram, Slack) |
86
87
  | [![NemoClaw](https://img.shields.io/badge/NemoClaw-76B900?logo=nvidia&logoColor=white)](https://github.com/NVIDIA/NemoClaw) | **[NemoClaw](https://github.com/NVIDIA/NemoClaw)** | C | brainclaw skill — NVIDIA enterprise agent stack |
87
88
  | [![PicoClaw](https://img.shields.io/badge/PicoClaw-00ADD8?logo=go&logoColor=white)](https://github.com/sipeed/picoclaw) | **[PicoClaw](https://github.com/sipeed/picoclaw)** | C | brainclaw skill — edge/IoT agent (Go, <10MB RAM) |
@@ -132,7 +133,7 @@ npm install -g brainclaw
132
133
  brainclaw setup-machine --yes
133
134
  ```
134
135
 
135
- This detects the installed agents on the current machine, writes the machine-level MCP and user config Brainclaw manages, and does **not** scan or initialize repositories.
136
+ This detects the installed coding agents on the current machine, writes the machine-level MCP and user config Brainclaw manages for that detected set, and does **not** scan or initialize repositories.
136
137
 
137
138
  ### 4. Initialize or refresh the current project
138
139
 
@@ -231,7 +232,7 @@ Still sharp:
231
232
  1. **Same-checkout concurrent edits** — running two agents in the *same* working tree (no per-claim worktree) is still the wrong answer. Use the dispatch path (auto-worktree per claim) instead of raw concurrent CLI sessions.
232
233
  2. **Cross-machine sync** — federation across machines is on the roadmap, not in v1.x. Today brainclaw's store is local and one-machine-per-project.
233
234
  3. **Spawn-and-forget assumptions** — spawned workers don't always commit their work cleanly. The brief-ack file confirms the spawn started; in the worst case the coordinator harvests open changes.
234
- 4. **Live state for hook-less agents** — Tier B/C agents without lifecycle hooks (Cursor, Cline, Windsurf, Copilot, Continue, Kilocode, Mistral Vibe) get live context via `.live.md` companions regenerated on session-end and handoff, not via real-time push.
235
+ 4. **Live state for hook-less agents** — Tier B/C agents without lifecycle hooks (Cursor, Cline, Windsurf, Copilot, Continue, Kilocode, Mistral Vibe, Hermes) get live context via `.live.md` companions regenerated on session-end and handoff, not via real-time push.
235
236
 
236
237
  Recommended use today:
237
238
 
@@ -342,11 +343,34 @@ npm run test:coverage # with coverage report
342
343
 
343
344
  ## Changelog
344
345
 
345
- For older releases (v0.x and the early v1.0 launch series), `git log` on `master` is the source of truth — every release commit follows the `chore(release): bump version to <semver>` convention, and the matching feature/fix commits reference their plan id (e.g. `feat(mcp): self-heal ... (pln#478)`).
346
-
347
- ### v1.5.3
348
-
349
- - **Cross-project canonical grammar + CLI parity** (pln#359, all phases) the canonical grammar (`bclaw_find / get / create / update / remove / transition`), `bclaw_context`, and `bclaw_coordinate` now accept an optional `project: <name>` argument that routes the operation to a linked project. Two link kinds are recognised: `cross_project_links` (sibling/peer projects in `config.yaml`, `brainclaw link list`) and workspace store-chain children. Arbitrary directory paths are rejected — adoption requires an explicit link, which gives the user a single point of control over what an agent can reach. Identity is sourced from the caller's home registry; entity writes + audit log entries land in the target. Unknown project names throw `validation_error` with a hint listing the configured links — no silent fallback. Cross-project `bclaw_coordinate` is **inbox-only**: claim/assignment/message all land in the target, the target agent picks the brief up async via its own `bclaw_work`, and auto-spawn from the source process is force-disabled because the spawn cwd / worktree are tied to the target's git repo (a warning surfaces in `FacadeResponse.warnings`). The CLI exposes the same as a global `--project <name>` flag, mutually exclusive with `--cwd`. Refs: helper `resolveProjectCwd` in `src/core/cross-project.ts`, MCP write/read handler dispatch in `src/commands/mcp.ts` and `src/commands/mcp-read-handlers.ts`, `--project` plumbing in `src/cli.ts` preAction, surface advertisement in `src/core/instruction-templates.ts`, plus tests in `tests/unit/cross-project.test.ts` (10 unit cases on the helper), `tests/unit/bclaw-coordinate.test.ts` (4 cross-project routing cases), and `tests/cli-cross-project.test.ts` (5 e2e cases). Closes the `--cwd` workaround pattern that had been the day-to-day shape of multi-project sessions.
346
+ For older releases (v0.x and the early v1.0 launch series), `git log` on `master` is the source of truth — every release commit follows the `chore(release): bump version to <semver>` convention, and the matching feature/fix commits reference their plan id (e.g. `feat(mcp): self-heal ... (pln#478)`).
347
+
348
+ ### v1.7.1
349
+
350
+ - **MCP project context isolation fix** — `bclaw_switch` now keeps MCP switches
351
+ session-scoped even when the agent session has to be resolved or created on
352
+ the fly. Session lookup honors explicit session IDs, avoids adopting another
353
+ live process's session, detects Codex via native `CODEX_*` runtime variables,
354
+ and `bclaw_switch(list=true)` reports the session active project with
355
+ `active_source`.
356
+
357
+ ### v1.7.0
358
+
359
+ - **Dispatch reliability + scope-aware dirty guard** — evidence-first
360
+ `agent_run` reconciliation avoids false terminal states, `bclaw_coordinate`
361
+ accepts pinned refs and a scope-aware `allow_dirty` guard, and the Hermes
362
+ agent integration joins the supported surfaces.
363
+
364
+ ### v1.6.0
365
+
366
+ - **Bootstrap loop + cross-project agent workflow** — the bootstrap ideation
367
+ preset can materialize `PROJECT.md`, `bclaw_init_project` initializes and links
368
+ arbitrary project paths, and `project=` routing reaches `bclaw_work` /
369
+ `bclaw_loop` for linked-project operations.
370
+
371
+ ### v1.5.3
372
+
373
+ - **Cross-project canonical grammar + CLI parity** (pln#359, all phases) — the canonical grammar (`bclaw_find / get / create / update / remove / transition`), `bclaw_context`, and `bclaw_coordinate` now accept an optional `project: <name>` argument that routes the operation to a linked project. Two link kinds are recognised: `cross_project_links` (sibling/peer projects in `config.yaml`, `brainclaw link list`) and workspace store-chain children. Arbitrary directory paths are rejected — adoption requires an explicit link, which gives the user a single point of control over what an agent can reach. Identity is sourced from the caller's home registry; entity writes + audit log entries land in the target. Unknown project names throw `validation_error` with a hint listing the configured links — no silent fallback. Cross-project `bclaw_coordinate` is **inbox-only**: claim/assignment/message all land in the target, the target agent picks the brief up async via its own `bclaw_work`, and auto-spawn from the source process is force-disabled because the spawn cwd / worktree are tied to the target's git repo (a warning surfaces in `FacadeResponse.warnings`). The CLI exposes the same as a global `--project <name>` flag, mutually exclusive with `--cwd`. Refs: helper `resolveProjectCwd` in `src/core/cross-project.ts`, MCP write/read handler dispatch in `src/commands/mcp.ts` and `src/commands/mcp-read-handlers.ts`, `--project` plumbing in `src/cli.ts` preAction, surface advertisement in `src/core/instruction-templates.ts`, plus tests in `tests/unit/cross-project.test.ts` (10 unit cases on the helper), `tests/unit/bclaw-coordinate.test.ts` (4 cross-project routing cases), and `tests/cli-cross-project.test.ts` (5 e2e cases). Closes the `--cwd` workaround pattern that had been the day-to-day shape of multi-project sessions.
350
374
  - **Site facts contract** (umbrella `pln_7fdfd70d` sprint 0) — new `scripts/emit-site-facts.mjs` emits `dist/facts.{js,json}` from `MCP_TOOL_NAMES` + `ENTITY_NAMES` so the brainclaw-site (and any consumer) can pull live tool/entity counts at build time without forking the values into a hand-maintained config. The package `files` list ships `dist/facts.json`; build:cli runs the emitter as part of the chain.
351
375
 
352
376
  ### v1.5.2
Binary file
@@ -106,6 +106,25 @@ export const generatedSchemas = {
106
106
  ],
107
107
  "additionalProperties": false
108
108
  },
109
+ {
110
+ "type": "object",
111
+ "properties": {
112
+ "kind": {
113
+ "type": "string",
114
+ "const": "min_iterations"
115
+ },
116
+ "n": {
117
+ "type": "integer",
118
+ "exclusiveMinimum": 0,
119
+ "maximum": 9007199254740991
120
+ }
121
+ },
122
+ "required": [
123
+ "kind",
124
+ "n"
125
+ ],
126
+ "additionalProperties": false
127
+ },
109
128
  {
110
129
  "type": "object",
111
130
  "properties": {
@@ -161,6 +180,19 @@ export const generatedSchemas = {
161
180
  ],
162
181
  "additionalProperties": false
163
182
  },
183
+ {
184
+ "type": "object",
185
+ "properties": {
186
+ "kind": {
187
+ "type": "string",
188
+ "const": "no_open_questions"
189
+ }
190
+ },
191
+ "required": [
192
+ "kind"
193
+ ],
194
+ "additionalProperties": false
195
+ },
164
196
  {
165
197
  "type": "object",
166
198
  "properties": {
@@ -254,6 +286,7 @@ export const generatedSchemas = {
254
286
  "open",
255
287
  "assigned",
256
288
  "working",
289
+ "waiting_input",
257
290
  "done",
258
291
  "failed",
259
292
  "cancelled"
@@ -1,5 +1,6 @@
1
1
  import crypto from 'node:crypto';
2
2
  import fs from 'node:fs';
3
+ import os from 'node:os';
3
4
  import path from 'node:path';
4
5
  import { buildClaimEnvPrefix } from '../core/execution-profile.js';
5
6
  import { fileURLToPath } from 'node:url';
@@ -35,10 +36,11 @@ import { buildOperationalIdentity, loadAllSessions, loadSessionById } from '../c
35
36
  import { validateMcpInput, validateMcpField } from '../core/input-validation.js';
36
37
  import { createCapability, createTool as createRegistryTool } from '../core/registries.js';
37
38
  import { detectAiAgent } from '../core/ai-agent-detection.js';
38
- import { checkGitPresence, scanGitRepos, parseRoots, parseRepoSelection, parseAgentSelection, runGlobalInstall, initReposAndConfigureAgents, readSetupState, ALL_KNOWN_AGENTS, } from './setup.js';
39
+ import { checkGitPresence, scanGitRepos, parseRoots, parseRepoSelection, parseAgentSelection, getDetectedSetupAgentNames, getInstalledAgentNames, runGlobalInstall, initReposAndConfigureAgents, readSetupState, ALL_KNOWN_AGENTS, } from './setup.js';
40
+ import { buildAgentInventory } from '../core/agent-inventory.js';
39
41
  import { resolveEffectiveCwd, resolveProjectRef, resolveTargetStore } from '../core/store-resolution.js';
40
42
  import { probeForQuickSetup, buildQuickSetupProbeResponse, buildOnboardingPreview } from '../core/setup-flow.js';
41
- import { ensureUserStore } from '../core/setup-state.js';
43
+ import { ensureUserStore, resolveHomeDir } from '../core/setup-state.js';
42
44
  import { createPlan, addStep as addStepOp, completeStep as completeStepOp, updateStep as updateStepOp, deleteStep as deleteStepOp, deletePlan as deletePlanOp } from '../core/operations/plan.js';
43
45
  import { sendMessage, ackMessage, countActionable, getThread, hasActiveAssignment } from '../core/messaging.js';
44
46
  import { dispatch, dispatchReview, generateDispatchBrief } from '../core/dispatcher.js';
@@ -421,7 +423,7 @@ export const MCP_READ_TOOLS = [
421
423
  const MCP_WRITE_TOOLS = [
422
424
  {
423
425
  name: 'bclaw_dispatch',
424
- description: 'Unified dispatch entry for sequence-lane parallelization (parallelize plans across lanes). To open a NEW review of a commit/branch, use `bclaw_coordinate(intent="review", open_loop=true, targetAgents=[…])` instead — bclaw_dispatch is for sequence-driven execution, NOT for opening reviews. `intent` discriminator: analysis (sequence lane status, read-only), execute (default — analyze + generate briefs + send to agents), review (routes an EXISTING already-reflected handoff to a reviewer — only for handoffs produced by `session-end --reflect-handoff` or similar). Consolidates the legacy bclaw_dispatch_analysis / bclaw_dispatch / bclaw_dispatch_review. Returns FacadeResponse; for verification semantics see the same response-validation guidance documented on `bclaw_coordinate`.',
426
+ description: 'Unified dispatch entry for sequence-lane parallelization (parallelize plans across lanes). To open a NEW review of a commit/branch, use `bclaw_coordinate(intent="review", open_loop=true, targetAgents=[…])` instead — bclaw_dispatch is for sequence-driven execution, NOT for opening new reviews. `intent` discriminator: analysis (sequence lane status, read-only), execute (default — analyze + generate briefs + send to agents), review (routes an EXISTING already-reflected handoff to a reviewer — only for handoffs produced by `session-end --reflect-handoff` or similar). Consolidates the legacy bclaw_dispatch_analysis / bclaw_dispatch / bclaw_dispatch_review. Returns FacadeResponse; for verification semantics see the same response-validation guidance documented on `bclaw_coordinate`.',
425
427
  annotations: { tier: 'facade', category: 'coordination', headlessApproval: 'prompt' },
426
428
  inputSchema: {
427
429
  type: 'object',
@@ -947,6 +949,8 @@ const MCP_WRITE_TOOLS = [
947
949
  agent: { type: 'string', description: 'Caller agent name.' },
948
950
  agentId: { type: 'string', description: 'Caller registered agent id.' },
949
951
  project: { type: 'string', description: 'Optional (pln#359 phase 1b): name of a linked project to dispatch into. When set, claim/assignment/message all land in the target project — the target agent picks the brief up async via its own bclaw_work. Auto-spawn is disabled in cross-project mode. Accepts cross_project_links and workspace store-chain children (see `brainclaw link list`).' },
952
+ allow_dirty: { type: 'boolean', description: 'Override the scope-aware dirty-working-tree guard (trp#371 Tier 2). The guard runs only for worktree-spawning intents (assign/review/reroute) and blocks only when uncommitted files overlap — or cannot be proven disjoint from — the dispatch scope (the worker spawns from HEAD and will not see them). `.brainclaw/` and `.git/` are always excluded. Set true to proceed anyway (the block is downgraded to a warning that lists the overlapping files). Boolean; the string "true"/"false" are also coerced.' },
953
+ ref: { type: 'string', description: 'Optional git ref (commit/branch/tag) for assign/review/reroute: the dispatched worker builds its worktree from this ref instead of HEAD. When set, uncommitted working-tree changes are intentionally out of scope and the dirty guard allows the dispatch. Ignored by consult/ideate/summarize (no worktree).' },
950
954
  },
951
955
  required: ['intent', 'task'],
952
956
  },
@@ -1260,6 +1264,7 @@ export const FACADE_ORDER = [
1260
1264
  'bclaw_context',
1261
1265
  'bclaw_coordinate',
1262
1266
  'bclaw_dispatch',
1267
+ 'bclaw_dispatch_status',
1263
1268
  'bclaw_loop',
1264
1269
  'bclaw_setup',
1265
1270
  ];
@@ -2390,8 +2395,14 @@ async function _executeMcpToolCallInner(payload) {
2390
2395
  const repos = scanGitRepos(roots);
2391
2396
  const selectedRepos = parseRepoSelection(choice, repos, cwd);
2392
2397
  const detected = detectAiAgent(env);
2393
- const agentList = ALL_KNOWN_AGENTS.map((a, i) => ` ${i + 1}) ${a}${a === detected?.name ? ' ← detected' : ''}`).join('\n');
2394
- return { response: toolResponse({ content: [{ type: 'text', text: `Selected ${selectedRepos.length} repo(s). Detected AI agent: ${detected?.name ?? 'none'}.\n\nAvailable agents:\n${agentList}\n\nAsk the user which agents to configure.` }], structuredContent: { pending_question: 'agent_selection', roots: rootsArg, repo_selection: choice, selected_repos: selectedRepos.map((r) => ({ path: r.path, name: r.name })), detected_agent: detected?.name ?? null, all_agents: ALL_KNOWN_AGENTS, prompt: 'Please ask the user: "Which agents to configure? Reply: (d)etected, (a)ll, or agent names like claude-code,cursor"' } }) };
2398
+ const installedAgents = getInstalledAgentNames(buildAgentInventory(resolveHomeDir(env) ?? os.homedir(), env));
2399
+ const detectedSetupAgents = getDetectedSetupAgentNames(detected?.name, installedAgents);
2400
+ const agentList = ALL_KNOWN_AGENTS.map((a, i) => {
2401
+ const tag = a === detected?.name ? ' ← detected' : installedAgents.includes(a) ? ' ← installed' : '';
2402
+ return ` ${i + 1}) ${a}${tag}`;
2403
+ }).join('\n');
2404
+ const detectedLine = detectedSetupAgents.length > 0 ? `\nDetected install set: ${detectedSetupAgents.join(', ')}\n` : '\n';
2405
+ return { response: toolResponse({ content: [{ type: 'text', text: `Selected ${selectedRepos.length} repo(s). Detected AI agent: ${detected?.name ?? 'none'}.${detectedLine}\nAvailable agents:\n${agentList}\n\nAsk the user which agents to configure.` }], structuredContent: { pending_question: 'agent_selection', roots: rootsArg, repo_selection: choice, selected_repos: selectedRepos.map((r) => ({ path: r.path, name: r.name })), detected_agent: detected?.name ?? null, installed_agents: installedAgents, detected_setup_agents: detectedSetupAgents, all_agents: ALL_KNOWN_AGENTS, prompt: 'Please ask the user: "Which agents to configure? Reply: (d)etected installed, (a)ll, or agent names like claude-code,cursor"' } }) };
2395
2406
  }
2396
2407
  if (step === 'agent_selection') {
2397
2408
  if (!rootsArg || !repoSelectionArg) {
@@ -2401,7 +2412,8 @@ async function _executeMcpToolCallInner(payload) {
2401
2412
  const repos = scanGitRepos(roots);
2402
2413
  const selectedRepos = parseRepoSelection(repoSelectionArg, repos, cwd);
2403
2414
  const detected = detectAiAgent(env);
2404
- const selectedAgents = parseAgentSelection(choice, detected?.name);
2415
+ const installedAgents = getInstalledAgentNames(buildAgentInventory(resolveHomeDir(env) ?? os.homedir(), env));
2416
+ const selectedAgents = parseAgentSelection(choice, detected?.name, installedAgents);
2405
2417
  const summary = [];
2406
2418
  const written = runGlobalInstall(selectedAgents, env);
2407
2419
  for (const f of written)
@@ -4426,31 +4438,11 @@ async function _executeMcpToolCallInner(payload) {
4426
4438
  };
4427
4439
  }
4428
4440
  }
4429
- // can_30c295b4 — pre-flight uncommitted-changes check.
4430
- // Dispatches that spawn a worker (review/assign/consult/ideate) clone
4431
- // the source repo at HEAD into a worktree. Any uncommitted edits in
4432
- // the source cwd are invisible to the worker, so the worker reviews
4433
- // stale code without any error signal. Refuse the dispatch by default
4434
- // when the source cwd is a git repo with a dirty working tree.
4435
- // Override via allow_dirty=true when the caller knows the dispatched
4436
- // work doesn't depend on the modified files (e.g. tests, docs-only
4437
- // worker tasks). Has no effect when cwd is not a git repo.
4438
- if (!req.allow_dirty && (req.intent === 'review' || req.intent === 'assign' || req.intent === 'consult' || req.intent === 'ideate')) {
4439
- try {
4440
- const { spawnSync } = await import('node:child_process');
4441
- const probe = spawnSync('git', ['-C', cwd, 'status', '--porcelain'], { encoding: 'utf8', timeout: 5000 });
4442
- if (probe.status === 0 && typeof probe.stdout === 'string' && probe.stdout.trim().length > 0) {
4443
- const fileCount = probe.stdout.trim().split('\n').length;
4444
- return {
4445
- response: createToolErrorResponse('dirty_working_tree', `Refusing to dispatch: source working tree has ${fileCount} uncommitted file(s). The spawned worker will not see these changes (worktrees branch from HEAD). Commit or stash before dispatching, or pass allow_dirty=true to override. Source cwd: ${cwd}`),
4446
- };
4447
- }
4448
- // probe.status !== 0 → not a git repo (or git unavailable) → skip the check silently
4449
- }
4450
- catch {
4451
- // best-effort: never block dispatch on a pre-flight check failure unrelated to the actual dirty state
4452
- }
4453
- }
4441
+ // can_30c295b4 / trp#371 Tier 2 the scope-aware dirty-working-tree
4442
+ // guard runs LOWER DOWN, after dispatchCwd / isCrossProject are
4443
+ // resolved (so it probes the dispatch TARGET, not the source, and only
4444
+ // for the intents that actually spawn a worktree worker). See the
4445
+ // assessDirtyDispatchGuard call after the cross-project block.
4454
4446
  const warnings = [];
4455
4447
  const artifacts = [];
4456
4448
  const side_effects = [];
@@ -4504,6 +4496,43 @@ async function _executeMcpToolCallInner(payload) {
4504
4496
  warnings.push(`cross-project dispatch (project='${req.project}') — auto-spawn disabled; the target agent picks up the brief async via its own bclaw_work.`);
4505
4497
  }
4506
4498
  const effectiveAutoExecute = isCrossProject ? false : req.autoExecute;
4499
+ // can_30c295b4 / trp#371 Tier 2 — scope-aware dirty-working-tree guard.
4500
+ // Only intents that spawn a worktree worker from HEAD can review/edit
4501
+ // stale code, so consult/ideate/summarize are NOT guarded (no worktree
4502
+ // → nothing to protect). Cross-project dispatch is inbox-only (no local
4503
+ // worktree spawned here) so it is skipped too — the target agent builds
4504
+ // its own worktree later via bclaw_work. The guard compares the dirty
4505
+ // files against the dispatch scope and only blocks when overlap can't be
4506
+ // ruled out; allow_dirty=true downgrades a block to a warning, and an
4507
+ // explicit ref makes working-tree dirt intentionally out of scope.
4508
+ const WORKTREE_SPAWNING_INTENTS = new Set(['assign', 'review', 'reroute']);
4509
+ if (!isCrossProject && WORKTREE_SPAWNING_INTENTS.has(req.intent)) {
4510
+ // Probe with the SAME scope the dispatch will actually claim, so the
4511
+ // resolution mirrors reality (codex r1): assign falls back to the task
4512
+ // text (mcp ~assignScope), reroute to the targeted active claim's scope.
4513
+ let guardScope = req.scope;
4514
+ if (req.intent === 'assign') {
4515
+ guardScope = req.scope ?? req.task;
4516
+ }
4517
+ else if (req.intent === 'reroute' && !req.scope) {
4518
+ guardScope = listClaims(dispatchCwd).find((c) => c.status === 'active')?.scope;
4519
+ }
4520
+ const { assessDirtyDispatchGuard } = await import('../core/dirty-scope.js');
4521
+ const assessment = assessDirtyDispatchGuard({
4522
+ cwd: dispatchCwd,
4523
+ scope: guardScope,
4524
+ allowDirty: req.allow_dirty,
4525
+ checkoutRef: req.ref,
4526
+ });
4527
+ if (assessment.decision === 'block') {
4528
+ return {
4529
+ response: createToolErrorResponse('dirty_working_tree', `${assessment.reason} (cwd: ${dispatchCwd})`),
4530
+ };
4531
+ }
4532
+ if (assessment.decision === 'warn') {
4533
+ warnings.push(`dirty_working_tree: ${assessment.reason}`);
4534
+ }
4535
+ }
4507
4536
  /** Run E2E execution phase on prepared delivery entries. Returns overall execution status. */
4508
4537
  const runCoordinateExecution = async (prepared, opts) => {
4509
4538
  let overall = 'inbox_only';
@@ -4756,6 +4785,10 @@ async function _executeMcpToolCallInner(payload) {
4756
4785
  dispatcherAgent: senderAgent,
4757
4786
  sessionId: connectionSessionId,
4758
4787
  cwd: dispatchCwd,
4788
+ // createCoordinatorClaim guarantees the worktree reflects this ref
4789
+ // (resets a stale branch / re-points a reused worktree) — see the
4790
+ // worktreeBaseRef invariant there (pln#520 Tier 2).
4791
+ worktreeBaseRef: req.ref,
4759
4792
  });
4760
4793
  const claimId = claimResult.claimId;
4761
4794
  if (claimResult.worktreeWarning) {
@@ -4996,6 +5029,7 @@ async function _executeMcpToolCallInner(payload) {
4996
5029
  dispatcherAgent: senderAgent,
4997
5030
  sessionId: connectionSessionId,
4998
5031
  cwd: dispatchCwd,
5032
+ worktreeBaseRef: req.ref,
4999
5033
  });
5000
5034
  if (claimResult.worktreeWarning)
5001
5035
  out.warnings.push(claimResult.worktreeWarning);
@@ -5184,6 +5218,7 @@ async function _executeMcpToolCallInner(payload) {
5184
5218
  dispatcherAgent: senderAgent,
5185
5219
  sessionId: connectionSessionId,
5186
5220
  cwd: dispatchCwd,
5221
+ worktreeBaseRef: req.ref,
5187
5222
  });
5188
5223
  newClaimId = rerouteClaimResult.claimId;
5189
5224
  if (rerouteClaimResult.worktreeWarning) {
@@ -1,4 +1,5 @@
1
1
  import fs from 'node:fs';
2
+ import os from 'node:os';
2
3
  import path from 'node:path';
3
4
  import readline from 'node:readline/promises';
4
5
  import { spawnSync } from 'node:child_process';
@@ -7,7 +8,7 @@ import { detectAiAgent } from '../core/ai-agent-detection.js';
7
8
  import { buildAiSurfaceInventory, renderAiSurfaceUsageHints } from '../core/ai-surface-inventory.js';
8
9
  import { buildMachineProfile, saveMachineProfile, loadMachineProfile } from '../core/machine-profile.js';
9
10
  import { buildAgentInventory, saveAgentInventory, loadAgentInventory } from '../core/agent-inventory.js';
10
- import { ensureClaudeCodeUserSettings, ensureClaudeCodeUserCommand, ensureCursorMcpConfig, ensureWindsurfMcpConfig, ensureAntigravityMcpConfig, ensureContinueUserMcpConfig, ensureContinueUserPermissions, ensureCodexMcpConfig, writeDetectedAgentAutoConfig, describeAutoConfigWrite, ensureGitignoreEntries, collectWorkspaceGitignoreEntries, BRAINCLAW_EXCLUSIVE_DIRECTORIES, } from '../core/agent-files.js';
11
+ import { ensureClaudeCodeUserSettings, ensureClaudeCodeUserCommand, ensureCursorMcpConfig, ensureWindsurfMcpConfig, ensureAntigravityMcpConfig, ensureContinueUserMcpConfig, ensureContinueUserPermissions, ensureCodexMcpConfig, ensureHermesMcpConfig, writeDetectedAgentAutoConfig, describeAutoConfigWrite, ensureGitignoreEntries, collectWorkspaceGitignoreEntries, BRAINCLAW_EXCLUSIVE_DIRECTORIES, } from '../core/agent-files.js';
11
12
  import { MEMORY_DIR, memoryExists, ensureMemoryDir } from '../core/io.js';
12
13
  import { saveConfig, defaultConfig } from '../core/config.js';
13
14
  import { readSetupState, resolveHomeDir, writeSetupState } from '../core/setup-state.js';
@@ -25,6 +26,9 @@ export const ALL_KNOWN_AGENTS = [
25
26
  'antigravity',
26
27
  'continue',
27
28
  'roo',
29
+ 'kilocode',
30
+ 'mistral-vibe',
31
+ 'hermes',
28
32
  'openclaw',
29
33
  'nanoclaw',
30
34
  'nemoclaw',
@@ -135,13 +139,46 @@ export function parseRepoSelection(choice, repos, cwd = process.cwd()) {
135
139
  return indices.map((i) => repos[i]);
136
140
  }
137
141
  // ─── Step 4: Agent selection ──────────────────────────────────────────────────
138
- export function parseAgentSelection(choice, detected) {
142
+ function uniqueKnownAgents(names) {
143
+ const seen = new Set();
144
+ const result = [];
145
+ for (const name of names) {
146
+ if (!name || !ALL_KNOWN_AGENTS.includes(name))
147
+ continue;
148
+ if (seen.has(name))
149
+ continue;
150
+ seen.add(name);
151
+ result.push(name);
152
+ }
153
+ return result;
154
+ }
155
+ export function getInstalledAgentNames(inventory) {
156
+ return uniqueKnownAgents(inventory?.agents.filter((agent) => agent.installed).map((agent) => agent.name) ?? []);
157
+ }
158
+ export function getDetectedSetupAgentNames(detected, installedAgents = []) {
159
+ return uniqueKnownAgents([
160
+ detected,
161
+ ...ALL_KNOWN_AGENTS.filter((agent) => installedAgents.includes(agent)),
162
+ ]);
163
+ }
164
+ export function parseAgentSelection(choice, detected, installedAgents = []) {
139
165
  const c = choice.trim().toLowerCase();
140
166
  if (c === 'a' || c === 'all')
141
167
  return [...ALL_KNOWN_AGENTS];
142
- if (c === 'd' || c === 'detected')
143
- return detected ? [detected] : [];
144
- return c.split(',').map((a) => a.trim()).filter((a) => ALL_KNOWN_AGENTS.includes(a));
168
+ if (c === 'd' || c === 'detected' || c === 'installed') {
169
+ return getDetectedSetupAgentNames(detected, installedAgents);
170
+ }
171
+ const selected = [];
172
+ for (const token of c.split(',').map((a) => a.trim()).filter(Boolean)) {
173
+ const index = Number.parseInt(token, 10);
174
+ if (/^\d+$/.test(token) && index >= 1 && index <= ALL_KNOWN_AGENTS.length) {
175
+ selected.push(ALL_KNOWN_AGENTS[index - 1]);
176
+ continue;
177
+ }
178
+ if (ALL_KNOWN_AGENTS.includes(token))
179
+ selected.push(token);
180
+ }
181
+ return uniqueKnownAgents(selected);
145
182
  }
146
183
  // ─── Step 5: Global install ───────────────────────────────────────────────────
147
184
  export function initUserStore(home, env = process.env) {
@@ -237,6 +274,11 @@ export function runGlobalInstall(selectedAgents, env = process.env) {
237
274
  if (r && (r.created || r.updated))
238
275
  written.push(r.filePath);
239
276
  }
277
+ if (selectedAgents.includes('hermes')) {
278
+ const r = ensureHermesMcpConfig(home);
279
+ if (r && (r.created || r.updated))
280
+ written.push(r.filePath);
281
+ }
240
282
  return written;
241
283
  }
242
284
  // ─── Step 6: Init repos + configure agents ────────────────────────────────────
@@ -331,30 +373,35 @@ function logDetectedAgentSurfaces(detectedName, detectedSurfaces) {
331
373
  console.log('These surfaces are tracked separately from coding agents and will use tailored onboarding flows.');
332
374
  }
333
375
  }
334
- async function resolveSelectedAgentsForSetup(options, detectedName) {
376
+ async function resolveSelectedAgentsForSetup(options, detectedName, installedAgents = []) {
377
+ const detectedSetupAgents = getDetectedSetupAgentNames(detectedName, installedAgents);
335
378
  console.log('Supported agents:');
336
379
  ALL_KNOWN_AGENTS.forEach((a, i) => {
337
380
  const tag = a === detectedName ? ' ← detected' : '';
338
- console.log(` ${i + 1}) ${a}${tag}`);
381
+ const installedTag = installedAgents.includes(a) ? ' ← installed' : '';
382
+ console.log(` ${i + 1}) ${a}${tag}${tag ? '' : installedTag}`);
339
383
  });
384
+ if (detectedSetupAgents.length > 0) {
385
+ console.log(`Detected install set: ${detectedSetupAgents.join(', ')}`);
386
+ }
340
387
  let agentChoice;
341
388
  if (options.agents) {
342
389
  agentChoice = options.agents;
343
390
  }
344
391
  else if (options.yes || !process.stdin.isTTY) {
345
- agentChoice = detectedName ? 'detected' : 'all';
392
+ agentChoice = detectedSetupAgents.length > 0 ? 'detected' : 'all';
346
393
  }
347
394
  else {
348
- const defaultChoice = detectedName ? 'detected' : 'all';
395
+ const defaultChoice = detectedSetupAgents.length > 0 ? 'detected' : 'all';
349
396
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
350
397
  try {
351
- agentChoice = (await rl.question(`Configure agents: (d)etected, (a)ll, or numbers e.g. 1,3 [${defaultChoice}]: `)).trim() || defaultChoice;
398
+ agentChoice = (await rl.question(`Configure agents: (d)etected installed, (a)ll, names, or numbers e.g. 1,3 [${defaultChoice}]: `)).trim() || defaultChoice;
352
399
  }
353
400
  finally {
354
401
  rl.close();
355
402
  }
356
403
  }
357
- const selectedAgents = parseAgentSelection(agentChoice, detectedName);
404
+ const selectedAgents = parseAgentSelection(agentChoice, detectedName, installedAgents);
358
405
  console.log(`Selected agents: ${selectedAgents.length === 0 ? '(none)' : selectedAgents.join(', ')}`);
359
406
  return selectedAgents;
360
407
  }
@@ -364,10 +411,12 @@ export async function runSetupMachine(options = {}) {
364
411
  const detectedName = detectedAi?.name;
365
412
  const testMode = process.env.BRAINCLAW_TEST_MODE === '1';
366
413
  const detectedSurfaces = testMode ? [] : buildAiSurfaceInventory();
414
+ const agentInventory = testMode ? undefined : buildAgentInventory(resolveHomeDir(env) ?? os.homedir(), env);
415
+ const installedAgents = getInstalledAgentNames(agentInventory);
367
416
  console.log(BRAINCLAW_ASCII);
368
417
  console.log('Machine bootstrap only — no repositories will be scanned or initialized.');
369
418
  logDetectedAgentSurfaces(detectedName, detectedSurfaces);
370
- const selectedAgents = await resolveSelectedAgentsForSetup(options, detectedName);
419
+ const selectedAgents = await resolveSelectedAgentsForSetup(options, detectedName, installedAgents);
371
420
  console.log('\n→ Installing machine-level brainclaw prerequisites...');
372
421
  const written = runGlobalInstall(selectedAgents, env);
373
422
  if (written.length > 0) {
@@ -484,8 +533,10 @@ export async function runSetup(options = {}) {
484
533
  const detectedName = detectedAi?.name;
485
534
  const testMode = process.env.BRAINCLAW_TEST_MODE === '1';
486
535
  const detectedSurfaces = testMode ? [] : buildAiSurfaceInventory();
536
+ const agentInventory = testMode ? undefined : buildAgentInventory(resolveHomeDir(env) ?? os.homedir(), env);
537
+ const installedAgents = getInstalledAgentNames(agentInventory);
487
538
  logDetectedAgentSurfaces(detectedName, detectedSurfaces);
488
- const selectedAgents = await resolveSelectedAgentsForSetup(options, detectedName);
539
+ const selectedAgents = await resolveSelectedAgentsForSetup(options, detectedName, installedAgents);
489
540
  // Step 5: Global install
490
541
  console.log('\n→ Installing global brainclaw prerequisites...');
491
542
  const written = runGlobalInstall(selectedAgents, env);
@@ -1,9 +1,9 @@
1
1
  import path from 'node:path';
2
2
  import { loadActiveProject, saveActiveProject, clearActiveProject } from '../core/active-project.js';
3
- import { loadCurrentSession, saveCurrentSession } from '../core/identity.js';
3
+ import { buildOperationalIdentity, loadCurrentSession, saveCurrentSession } from '../core/identity.js';
4
4
  import { memoryExists } from '../core/io.js';
5
5
  import { resolveProjectRef } from '../core/store-resolution.js';
6
- import { resolveProjectCwd } from '../core/cross-project.js';
6
+ import { resolveCrossProjectLinks, resolveProjectCwd } from '../core/cross-project.js';
7
7
  import { scanNestedBrainclawProjects } from '../core/workspace-projects.js';
8
8
  import { loadConfig } from '../core/config.js';
9
9
  /**
@@ -43,8 +43,12 @@ export function switchProject(projectRef, options = {}) {
43
43
  }
44
44
  catch { /* name is optional */ }
45
45
  const now = new Date().toISOString();
46
- const session = loadCurrentSession(cwd);
47
46
  const sessionOnly = options.sessionOnly ?? true;
47
+ let session = loadCurrentSession(cwd);
48
+ if (!session && sessionOnly) {
49
+ buildOperationalIdentity(undefined, cwd, { persistImplicitSession: true });
50
+ session = loadCurrentSession(cwd);
51
+ }
48
52
  if (session && sessionOnly) {
49
53
  saveCurrentSession({
50
54
  ...session,
@@ -52,6 +56,9 @@ export function switchProject(projectRef, options = {}) {
52
56
  }, cwd);
53
57
  return { switched: true, path: resolved, name: projectName, scope: 'session', workspace_root: wsRoot };
54
58
  }
59
+ if (sessionOnly) {
60
+ throw new Error('Cannot switch project without an active agent session. Start with bclaw_work or bclaw_session_start first.');
61
+ }
55
62
  if (session) {
56
63
  // Also write to session even when not sessionOnly
57
64
  saveCurrentSession({
@@ -75,15 +82,30 @@ export function listAvailableProjects(cwd) {
75
82
  if (!wsRoot) {
76
83
  throw new Error('No brainclaw workspace found.');
77
84
  }
78
- const active = loadActiveProject(wsRoot);
85
+ const sessionActive = loadCurrentSession(cwd)?.active_project;
86
+ const globalActive = loadActiveProject(wsRoot);
87
+ const active = sessionActive ?? globalActive;
88
+ const activeSource = sessionActive ? 'session' : globalActive ? 'global' : 'none';
79
89
  const projects = [];
90
+ const seen = new Set();
91
+ const addProject = (project) => {
92
+ const projectPath = path.resolve(project.path);
93
+ if (seen.has(projectPath))
94
+ return;
95
+ seen.add(projectPath);
96
+ projects.push({
97
+ ...project,
98
+ path: projectPath,
99
+ active: active?.path ? path.resolve(active.path) === projectPath : false,
100
+ });
101
+ };
80
102
  if (memoryExists(wsRoot)) {
81
103
  try {
82
104
  const config = loadConfig(wsRoot);
83
- projects.push({ name: config.project_name, path: wsRoot, relative_path: '.', active: active?.path === wsRoot });
105
+ addProject({ name: config.project_name, path: wsRoot, relative_path: '.' });
84
106
  }
85
107
  catch {
86
- projects.push({ path: wsRoot, relative_path: '.', active: active?.path === wsRoot });
108
+ addProject({ path: wsRoot, relative_path: '.' });
87
109
  }
88
110
  }
89
111
  const children = scanNestedBrainclawProjects(wsRoot, 7);
@@ -92,9 +114,19 @@ export function listAvailableProjects(cwd) {
92
114
  if (childPath === wsRoot)
93
115
  continue;
94
116
  const rel = path.relative(wsRoot, childPath) || '.';
95
- projects.push({ name: child.project_name, path: childPath, relative_path: rel, active: active?.path === childPath });
117
+ addProject({ name: child.project_name, path: childPath, relative_path: rel });
118
+ }
119
+ for (const link of resolveCrossProjectLinks(wsRoot)) {
120
+ if (!link.available)
121
+ continue;
122
+ const linkPath = path.resolve(link.absolutePath);
123
+ addProject({
124
+ name: link.projectName,
125
+ path: linkPath,
126
+ relative_path: path.relative(wsRoot, linkPath) || '.',
127
+ });
96
128
  }
97
- return { workspace_root: wsRoot, projects };
129
+ return { workspace_root: wsRoot, active_source: activeSource, projects };
98
130
  }
99
131
  export function runSwitch(projectRef, options = {}) {
100
132
  // Use real cwd, not effective cwd — switch must see the full workspace
@@ -23,6 +23,7 @@ const AGENT_ALIASES = {
23
23
  'gemini': 'antigravity',
24
24
  'mistral': 'mistral-vibe',
25
25
  'vibe': 'mistral-vibe',
26
+ 'hermes-agent': 'hermes',
26
27
  };
27
28
  /** Resolve an alias to its canonical agent name, or return the input unchanged. */
28
29
  export function resolveAgentAlias(name) {
@@ -227,6 +228,24 @@ const PROFILES = {
227
228
  invoke_review_template: 'vibe --prompt "{prompt}" --auto-approve --max-turns 5',
228
229
  invoke_consult_template: 'vibe --prompt "{prompt}" --auto-approve --max-turns 3',
229
230
  },
231
+ // Hermes Agent (Nous Research) — autonomous, skills-first agent with native
232
+ // MCP client support via ~/.hermes/config.yaml. Brainclaw uses Hermes as a
233
+ // Tier B surface for now: MCP + universal .agents/skills/ skill, no native
234
+ // Brainclaw hooks until a dedicated Hermes plugin is shipped and validated.
235
+ hermes: {
236
+ name: 'hermes', category: 'autonomous-agent', workflowModel: 'task-based',
237
+ hasMcp: true, hasHooks: false, hasAutoApprove: false, hasSkills: true, hasRules: false,
238
+ instructionFile: 'AGENTS.md', sharedInstructionFile: true, mcpConfigScope: 'machine', templateTier: 'B',
239
+ role_capabilities: ['execute', 'review', 'consult'],
240
+ runtime: { mcp_direct: true, hooks: false, canBeSpawnedCli: true, canSpawnOtherCli: false, inbox: false },
241
+ max_concurrent_tasks: 1,
242
+ prompt_delivery: { methods: ['inline_arg', 'temp_file'], preferred: 'inline_arg', max_inline_length: 8000 },
243
+ execution_env: { surface: 'cli' },
244
+ invoke_template: 'hermes chat -q "{prompt}"',
245
+ invoke_binary: 'hermes',
246
+ invoke_review_template: 'hermes chat -q "{prompt}"',
247
+ invoke_consult_template: 'hermes chat -q "{prompt}"',
248
+ },
230
249
  // --- Autonomous agents (headless, task-based or scheduled) ---
231
250
  openclaw: {
232
251
  name: 'openclaw', category: 'autonomous-agent', workflowModel: 'task-based',