brainclaw 1.6.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
 
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);
@@ -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',
@@ -338,6 +338,7 @@ export const AGENT_EXPORT_REGISTRY = [
338
338
  { agentName: 'roo', format: 'roo', relativePath: '.roo/rules/brainclaw.md' },
339
339
  { agentName: 'kilocode', format: 'kilocode', relativePath: '.kilo/rules/brainclaw.md' },
340
340
  { agentName: 'mistral-vibe', format: 'agents-md', relativePath: 'AGENTS.md' },
341
+ { agentName: 'hermes', format: 'agents-md', relativePath: 'AGENTS.md' },
341
342
  { agentName: 'opencode', format: 'agents-md', relativePath: 'AGENTS.md' },
342
343
  { agentName: 'antigravity', format: 'gemini-md', relativePath: 'GEMINI.md' },
343
344
  { agentName: 'brainclaw', format: 'board-md', relativePath: 'BOARD.md' },
@@ -456,6 +457,8 @@ const ROO_MCP_RELATIVE_PATH = '.roo/mcp.json';
456
457
  const KILOCODE_MCP_RELATIVE_PATH = '.kilo/mcp.json';
457
458
  const KILOCODE_CONFIG_RELATIVE_PATH = 'kilo.jsonc';
458
459
  const MISTRAL_VIBE_CONFIG_RELATIVE_PATH = '.vibe/config.toml';
460
+ const HERMES_CONFIG_RELATIVE_PATH = '.hermes/config.yaml';
461
+ const HERMES_EXTERNAL_SKILLS_RELATIVE_PATH = '.agents/skills';
459
462
  const CONTINUE_CONFIG_RELATIVE_PATH = '.continue/config.json';
460
463
  const CONTINUE_PERMISSIONS_RELATIVE_PATH = '.continue/permissions.yaml';
461
464
  const OPENCODE_CONFIG_RELATIVE_PATH = 'opencode.json';
@@ -493,6 +496,7 @@ export const LOCAL_ONLY_AGENT_WORKSPACE_FILES = [
493
496
  KILOCODE_MCP_RELATIVE_PATH,
494
497
  KILOCODE_CONFIG_RELATIVE_PATH,
495
498
  MISTRAL_VIBE_CONFIG_RELATIVE_PATH,
499
+ HERMES_CONFIG_RELATIVE_PATH,
496
500
  CONTINUE_CONFIG_RELATIVE_PATH,
497
501
  OPENCODE_CONFIG_RELATIVE_PATH,
498
502
  WINDSURF_MCP_RELATIVE_PATH,
@@ -1278,6 +1282,78 @@ export function ensureMistralVibeMcpConfig(cwd) {
1278
1282
  relativePath: MISTRAL_VIBE_CONFIG_RELATIVE_PATH,
1279
1283
  };
1280
1284
  }
1285
+ const HERMES_BRAINCLAW_MCP_TOOLS = [
1286
+ 'bclaw_work',
1287
+ 'bclaw_context',
1288
+ 'bclaw_find',
1289
+ 'bclaw_get',
1290
+ 'bclaw_create',
1291
+ 'bclaw_update',
1292
+ 'bclaw_transition',
1293
+ ];
1294
+ export function ensureHermesMcpConfig(homeDir, workspacePath) {
1295
+ if (!homeDir)
1296
+ return undefined;
1297
+ const filePath = path.join(homeDir, HERMES_CONFIG_RELATIVE_PATH);
1298
+ let existing = {};
1299
+ let existed = false;
1300
+ if (fs.existsSync(filePath)) {
1301
+ existed = true;
1302
+ try {
1303
+ const parsed = yaml.parse(fs.readFileSync(filePath, 'utf-8'));
1304
+ existing = isJsonObject(parsed) ? { ...parsed } : {};
1305
+ }
1306
+ catch {
1307
+ existing = {};
1308
+ }
1309
+ }
1310
+ const mcpServers = isJsonObject(existing.mcp_servers) ? { ...existing.mcp_servers } : {};
1311
+ const current = isJsonObject(mcpServers.brainclaw) ? { ...mcpServers.brainclaw } : {};
1312
+ const currentEnv = isJsonObject(current.env) ? { ...current.env } : {};
1313
+ const currentTools = isJsonObject(current.tools) ? { ...current.tools } : {};
1314
+ const skills = isJsonObject(existing.skills) ? { ...existing.skills } : {};
1315
+ const externalDirs = Array.isArray(skills.external_dirs)
1316
+ ? skills.external_dirs.filter((value) => typeof value === 'string')
1317
+ : [];
1318
+ if (workspacePath) {
1319
+ const projectSkillsDir = path.resolve(workspacePath, HERMES_EXTERNAL_SKILLS_RELATIVE_PATH);
1320
+ const normalized = projectSkillsDir.replace(/\\/g, '/').toLowerCase();
1321
+ if (!externalDirs.some((dir) => dir.replace(/\\/g, '/').toLowerCase() === normalized)) {
1322
+ externalDirs.push(projectSkillsDir);
1323
+ }
1324
+ }
1325
+ const mcpCmd = getBrainclawMcpCommand();
1326
+ mcpServers.brainclaw = {
1327
+ ...current,
1328
+ command: typeof current.command === 'string' ? current.command : mcpCmd.command,
1329
+ args: Array.isArray(current.args) ? current.args : mcpCmd.args,
1330
+ env: {
1331
+ ...currentEnv,
1332
+ BRAINCLAW_AGENT: 'hermes',
1333
+ },
1334
+ tools: {
1335
+ ...currentTools,
1336
+ include: Array.isArray(currentTools.include) ? currentTools.include : HERMES_BRAINCLAW_MCP_TOOLS,
1337
+ prompts: typeof currentTools.prompts === 'boolean' ? currentTools.prompts : false,
1338
+ resources: typeof currentTools.resources === 'boolean' ? currentTools.resources : false,
1339
+ },
1340
+ };
1341
+ const nextConfig = {
1342
+ ...existing,
1343
+ mcp_servers: mcpServers,
1344
+ ...(externalDirs.length > 0 ? { skills: { ...skills, external_dirs: externalDirs } } : {}),
1345
+ };
1346
+ const content = `# Managed by brainclaw — preserves existing Hermes settings\n${yaml.stringify(nextConfig)}`;
1347
+ const { created, updated } = writeTextFileIfChanged(filePath, content);
1348
+ return {
1349
+ kind: 'mcp',
1350
+ label: 'Hermes MCP settings',
1351
+ created: !existed && created,
1352
+ updated: existed && updated,
1353
+ filePath,
1354
+ relativePath: HERMES_CONFIG_RELATIVE_PATH,
1355
+ };
1356
+ }
1281
1357
  export function ensureCodexMcpConfig(homeDir, env = process.env) {
1282
1358
  const codexHome = env.CODEX_HOME?.trim() || (homeDir ? path.join(homeDir, '.codex') : null);
1283
1359
  if (!codexHome)
@@ -1814,6 +1890,13 @@ export function writeDetectedAgentAutoConfig(agentName, cwd, env = process.env)
1814
1890
  return [ensureKilocodeMcpConfig(cwd), ensureKilocodeConfig(cwd), ensureUniversalBrainclawSkill(cwd)];
1815
1891
  case 'mistral-vibe':
1816
1892
  return [ensureMistralVibeMcpConfig(cwd), ensureUniversalBrainclawSkill(cwd)];
1893
+ case 'hermes': {
1894
+ const results = [ensureUniversalBrainclawSkill(cwd)];
1895
+ const mcp = ensureHermesMcpConfig(resolveHomeDir(env), cwd);
1896
+ if (mcp)
1897
+ results.push(mcp);
1898
+ return results;
1899
+ }
1817
1900
  case 'codex': {
1818
1901
  const results = [ensureUniversalBrainclawSkill(cwd)];
1819
1902
  const result = ensureCodexMcpConfig(resolveHomeDir(env), env);
@@ -1916,6 +1999,8 @@ export function writeExportCompanionFiles(format, cwd, env = process.env) {
1916
1999
  results.push(hooks);
1917
2000
  return results;
1918
2001
  }
2002
+ case 'agents-md':
2003
+ return [ensureUniversalBrainclawSkill(cwd)];
1919
2004
  default:
1920
2005
  return [];
1921
2006
  }
@@ -1956,6 +2041,7 @@ export function patchAllMcpConfigs(cwd, env = process.env) {
1956
2041
  ensureAntigravityMcpConfig(homeDir),
1957
2042
  ensureOpenClawMcpConfig(homeDir),
1958
2043
  ensureCodexMcpConfig(homeDir, env),
2044
+ ensureHermesMcpConfig(homeDir),
1959
2045
  ];
1960
2046
  for (const r of userConfigs) {
1961
2047
  if (r)