brainclaw 1.8.0 → 1.9.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.
Files changed (140) hide show
  1. package/README.md +12 -11
  2. package/dist/brainclaw-vscode.vsix +0 -0
  3. package/dist/cli.js +138 -13
  4. package/dist/commands/add-step.js +1 -1
  5. package/dist/commands/bootstrap.js +2 -26
  6. package/dist/commands/check-security-mcp.js +50 -33
  7. package/dist/commands/check-security.js +86 -43
  8. package/dist/commands/claim.js +22 -21
  9. package/dist/commands/confirm.js +26 -0
  10. package/dist/commands/context-diff.js +1 -1
  11. package/dist/commands/dispatch-watch.js +142 -0
  12. package/dist/commands/doctor.js +113 -2
  13. package/dist/commands/estimation-report.js +115 -16
  14. package/dist/commands/harvest.js +285 -22
  15. package/dist/commands/init.js +123 -21
  16. package/dist/commands/loops-handlers.js +4 -0
  17. package/dist/commands/mcp-read-handlers.js +198 -29
  18. package/dist/commands/mcp.js +588 -92
  19. package/dist/commands/memory.js +21 -17
  20. package/dist/commands/migrate.js +81 -17
  21. package/dist/commands/prune.js +78 -4
  22. package/dist/commands/reflect.js +26 -20
  23. package/dist/commands/register-agent.js +57 -1
  24. package/dist/commands/repair.js +20 -0
  25. package/dist/commands/session-end.js +15 -6
  26. package/dist/commands/session-start.js +18 -1
  27. package/dist/commands/setup-security.js +39 -18
  28. package/dist/commands/setup.js +26 -27
  29. package/dist/commands/stale.js +16 -2
  30. package/dist/commands/uninstall.js +126 -34
  31. package/dist/commands/update-step.js +6 -0
  32. package/dist/commands/worktree.js +60 -0
  33. package/dist/core/actions.js +12 -3
  34. package/dist/core/agent-capability.js +11 -13
  35. package/dist/core/agent-files.js +844 -547
  36. package/dist/core/agent-integrations.js +0 -3
  37. package/dist/core/agent-inventory.js +67 -0
  38. package/dist/core/agent-registry.js +163 -29
  39. package/dist/core/agentrun-reconciler.js +33 -2
  40. package/dist/core/agentruns.js +7 -1
  41. package/dist/core/ai-agent-detection.js +31 -44
  42. package/dist/core/archival.js +15 -9
  43. package/dist/core/assignment-reconciler.js +56 -0
  44. package/dist/core/assignment-sweeper.js +127 -4
  45. package/dist/core/assignments.js +69 -11
  46. package/dist/core/bootstrap.js +233 -67
  47. package/dist/core/brainclaw-version.js +22 -0
  48. package/dist/core/candidates.js +21 -1
  49. package/dist/core/claims.js +313 -150
  50. package/dist/core/config.js +6 -1
  51. package/dist/core/context-diff.js +148 -20
  52. package/dist/core/context.js +129 -8
  53. package/dist/core/coordination.js +22 -3
  54. package/dist/core/dispatch-status.js +79 -5
  55. package/dist/core/dispatcher.js +64 -11
  56. package/dist/core/entity-operations.js +45 -24
  57. package/dist/core/entity-registry.js +31 -5
  58. package/dist/core/event-log.js +138 -21
  59. package/dist/core/events/checkpoint.js +258 -0
  60. package/dist/core/events/genesis.js +220 -0
  61. package/dist/core/events/journal.js +507 -0
  62. package/dist/core/events/materialize.js +126 -0
  63. package/dist/core/events/registry-post-image.js +110 -0
  64. package/dist/core/events/verify.js +109 -0
  65. package/dist/core/execution-adapters.js +23 -0
  66. package/dist/core/facade-schema.js +38 -0
  67. package/dist/core/gc-semantic.js +130 -5
  68. package/dist/core/handoff-snapshot.js +68 -0
  69. package/dist/core/ids.js +19 -8
  70. package/dist/core/instruction-templates.js +34 -115
  71. package/dist/core/io.js +39 -3
  72. package/dist/core/json-store.js +10 -1
  73. package/dist/core/lock.js +153 -28
  74. package/dist/core/loops/bootstrap-acquire.js +25 -1
  75. package/dist/core/loops/facade-schema.js +2 -0
  76. package/dist/core/loops/hooks/survey-signals-baseline.js +36 -0
  77. package/dist/core/loops/index.js +1 -0
  78. package/dist/core/loops/presets/bootstrap.js +7 -0
  79. package/dist/core/loops/store.js +17 -0
  80. package/dist/core/loops/verbs.js +24 -1
  81. package/dist/core/markdown.js +8 -76
  82. package/dist/core/mcp-command-resolution.js +245 -0
  83. package/dist/core/memory-compactor.js +5 -3
  84. package/dist/core/memory-lifecycle.js +282 -0
  85. package/dist/core/merge-risk.js +150 -0
  86. package/dist/core/messaging.js +8 -1
  87. package/dist/core/migration.js +11 -1
  88. package/dist/core/observer-mode.js +26 -0
  89. package/dist/core/operations/memory-mutation.js +90 -65
  90. package/dist/core/operations/plan.js +27 -1
  91. package/dist/core/protocol-skills.js +210 -0
  92. package/dist/core/reflection-safety.js +6 -7
  93. package/dist/core/reputation.js +84 -2
  94. package/dist/core/runtime-signals.js +71 -9
  95. package/dist/core/runtime.js +84 -1
  96. package/dist/core/schema.js +114 -0
  97. package/dist/core/security-detectors.js +125 -0
  98. package/dist/core/security-extract.js +189 -0
  99. package/dist/core/security-guard.js +107 -29
  100. package/dist/core/security-packages.js +121 -0
  101. package/dist/core/security-scoring.js +76 -9
  102. package/dist/core/security.js +34 -2
  103. package/dist/core/sequence.js +11 -2
  104. package/dist/core/setup-flow.js +141 -13
  105. package/dist/core/staleness.js +72 -1
  106. package/dist/core/state.js +250 -54
  107. package/dist/core/store-resolution.js +19 -5
  108. package/dist/core/worktree.js +72 -8
  109. package/dist/facts.js +8 -8
  110. package/dist/facts.json +7 -7
  111. package/docs/PROTOCOL.md +223 -0
  112. package/docs/cli.md +11 -10
  113. package/docs/concepts/coordinator-runbook.md +129 -0
  114. package/docs/concepts/event-log-store-critique-A.md +333 -0
  115. package/docs/concepts/event-log-store-critique-B.md +353 -0
  116. package/docs/concepts/event-log-store-phase0-measurements.md +58 -0
  117. package/docs/concepts/event-log-store-proposal-A.md +365 -0
  118. package/docs/concepts/event-log-store-proposal-B.md +404 -0
  119. package/docs/concepts/event-log-store.md +928 -0
  120. package/docs/concepts/identity-model-proposal.md +371 -0
  121. package/docs/concepts/memory.md +5 -4
  122. package/docs/concepts/observer-protocol.md +361 -0
  123. package/docs/concepts/parallel-merge-protocol.md +71 -0
  124. package/docs/concepts/plans-and-claims.md +43 -0
  125. package/docs/concepts/skills.md +78 -0
  126. package/docs/concepts/workspace-bootstrapping.md +61 -0
  127. package/docs/integrations/agents.md +4 -4
  128. package/docs/integrations/cline.md +10 -11
  129. package/docs/integrations/codex.md +2 -2
  130. package/docs/integrations/continue.md +5 -5
  131. package/docs/integrations/copilot.md +14 -12
  132. package/docs/integrations/openclaw.md +7 -6
  133. package/docs/integrations/overview.md +7 -7
  134. package/docs/integrations/roo.md +3 -3
  135. package/docs/integrations/windsurf.md +6 -6
  136. package/docs/mcp-schema-changelog.md +29 -2
  137. package/docs/quickstart.md +48 -47
  138. package/docs/security.md +174 -15
  139. package/docs/storage.md +4 -2
  140. package/package.json +8 -6
@@ -2,180 +2,14 @@ import fs from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import { spawnSync } from 'node:child_process';
5
- import { fileURLToPath } from 'node:url';
6
5
  import yaml from 'yaml';
7
- import { MCP_HEADLESS_AUTO_TOOL_NAMES, REMOVED_IN_V1_TOOLS } from '../commands/mcp.js';
6
+ import { MCP_HEADLESS_AUTO_TOOL_NAMES, MCP_CANONICAL_GRAMMAR_TOOL_NAMES, REMOVED_IN_V1_TOOLS } from '../commands/mcp.js';
8
7
  import { renderToml, tomlArrayTableHasEntry } from './toml-writer.js';
9
- /**
10
- * Resolve the brainclaw command for MCP configs.
11
- * Returns `{ command: "<node>", args: ["<cli.js>", "mcp"] }` so the config
12
- * works in non-login shells (VS Code Server, MCP subprocesses) on all OSes.
13
- *
14
- * Strategy:
15
- * 1. Find the brainclaw bin via which/where
16
- * 2. Trace from the bin/shim to the actual cli.js entry point
17
- * 3. Pair it with the absolute node path
18
- * Falls back to 'npx brainclaw mcp' if resolution fails.
19
- */
20
- function resolveBrainclawMcpCommand() {
21
- const nodeBin = process.execPath;
22
- // 1. Try to resolve the cli.js from the installed brainclaw binary
23
- const cliJs = resolveBrainclawCliJs();
24
- if (cliJs) {
25
- return { command: nodeBin, args: [cliJs, 'mcp'] };
26
- }
27
- // 2. Fallback: npx (relies on PATH, may resolve wrong version)
28
- return { command: 'npx', args: ['brainclaw', 'mcp'] };
29
- }
30
- /**
31
- * Trace from the brainclaw bin/shim to the actual dist/cli.js file.
32
- * Works on Windows (.cmd shim), macOS/Linux (symlink to bin stub).
33
- */
34
- function resolveBrainclawCliJs() {
35
- // Strategy A: find via which/where and trace to cli.js
36
- const whichCmd = os.platform() === 'win32' ? 'where' : 'which';
37
- try {
38
- const result = spawnSync(whichCmd, ['brainclaw'], { encoding: 'utf-8', timeout: 3000 });
39
- if (result.status === 0) {
40
- const resolved = result.stdout.trim().split(/\r?\n/)[0]?.trim();
41
- if (resolved) {
42
- const cliJs = traceToCliJs(resolved);
43
- if (cliJs)
44
- return cliJs;
45
- }
46
- }
47
- }
48
- catch {
49
- // Non-fatal — try next strategy
50
- }
51
- // Strategy B: resolve from this file's own package (we ARE brainclaw)
52
- try {
53
- const ownCliJs = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'cli.js');
54
- if (fs.existsSync(ownCliJs))
55
- return ownCliJs;
56
- }
57
- catch {
58
- // Non-fatal
59
- }
60
- return undefined;
61
- }
62
- /**
63
- * Given a bin path (shim or symlink), trace to the dist/cli.js entry point.
64
- *
65
- * Windows: .cmd shim contains a line like `"%_prog%" "%dp0%\node_modules\brainclaw\dist\cli.js" %*`
66
- * Unix: bin is a symlink → resolve to real path → go up to package root → dist/cli.js
67
- */
68
- function traceToCliJs(binPath) {
69
- const isWindows = os.platform() === 'win32';
70
- if (isWindows) {
71
- // Read the .cmd shim and extract the cli.js path
72
- const cmdPath = binPath.endsWith('.cmd') ? binPath : `${binPath}.cmd`;
73
- try {
74
- const content = fs.readFileSync(cmdPath, 'utf-8');
75
- // Match patterns like: "%dp0%\node_modules\brainclaw\dist\cli.js"
76
- const match = content.match(/%dp0%\\([^\s"]+cli\.js)/);
77
- if (match) {
78
- const shimDir = path.dirname(cmdPath);
79
- const cliJs = path.resolve(shimDir, match[1]);
80
- if (fs.existsSync(cliJs))
81
- return cliJs;
82
- }
83
- }
84
- catch {
85
- // Fall through
86
- }
87
- }
88
- else {
89
- // Unix: follow symlink chain to the real bin, then find cli.js
90
- try {
91
- const realBin = fs.realpathSync(binPath);
92
- // Typical layout: .../node_modules/.bin/brainclaw → ../brainclaw/dist/cli.js
93
- // Or: .../node_modules/brainclaw/dist/cli.js (direct)
94
- if (realBin.endsWith('cli.js') && fs.existsSync(realBin))
95
- return realBin;
96
- // The bin stub typically lives at node_modules/brainclaw/dist/cli.js
97
- // or node_modules/.bin/brainclaw → ../brainclaw/dist/cli.js
98
- const packageRoot = findPackageRoot(realBin);
99
- if (packageRoot) {
100
- const cliJs = path.join(packageRoot, 'dist', 'cli.js');
101
- if (fs.existsSync(cliJs))
102
- return cliJs;
103
- }
104
- }
105
- catch {
106
- // Fall through
107
- }
108
- }
109
- return undefined;
110
- }
111
- /** Walk up from a file to find the nearest directory containing package.json with name "brainclaw". */
112
- function findPackageRoot(from) {
113
- let dir = path.dirname(from);
114
- for (let i = 0; i < 10; i++) {
115
- const pkgPath = path.join(dir, 'package.json');
116
- try {
117
- if (fs.existsSync(pkgPath)) {
118
- const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
119
- if (pkg.name === 'brainclaw')
120
- return dir;
121
- }
122
- }
123
- catch { /* continue */ }
124
- const parent = path.dirname(dir);
125
- if (parent === dir)
126
- break;
127
- dir = parent;
128
- }
129
- return undefined;
130
- }
131
- /** Cached MCP command — resolved once per process. */
132
- let cachedMcpCommand;
133
- function getBrainclawMcpCommand() {
134
- if (!cachedMcpCommand) {
135
- cachedMcpCommand = resolveBrainclawMcpCommand();
136
- }
137
- return cachedMcpCommand;
138
- }
139
- /** Reset the cached MCP command so it gets re-resolved on next access. */
140
- export function resetMcpCommandCache() {
141
- cachedMcpCommand = undefined;
142
- }
143
- /** Module-level flag: when true, brainclawMcpEntry overwrites existing paths. */
144
- let _forceResolve = false;
145
- /**
146
- * Build a complete MCP server entry with relay model env injection.
147
- * Merges with the existing entry to preserve manual edits (e.g. custom command
148
- * path, additional env vars, extra args). Only sets defaults for missing fields.
149
- *
150
- * When `workspacePath` is provided, injects BRAINCLAW_CWD into the env so
151
- * the MCP server resolves the correct workspace root regardless of the IDE's
152
- * process.cwd() at launch time.
153
- */
154
- function brainclawMcpEntry(agentName, existing, workspacePath) {
155
- const defaults = getBrainclawMcpCommand();
156
- const ex = isJsonObject(existing) ? existing : {};
157
- const exEnv = isJsonObject(ex.env) ? ex.env : {};
158
- // When _forceResolve is true (post-upgrade), always use newly resolved paths.
159
- // Otherwise preserve existing command if it's an absolute path (manual edit).
160
- // CRITICAL: once we decide to preserve the command, we MUST also preserve
161
- // the args. Previously args was always overwritten, which silently clobbered
162
- // manual customizations (--cwd, --debug, etc.) and broke setups on DGX.
163
- // See trp#12 + pln#450.
164
- const useExisting = !_forceResolve && typeof ex.command === 'string' && ex.command !== 'npx';
165
- const existingArgs = Array.isArray(ex.args) ? ex.args : undefined;
166
- return {
167
- command: useExisting ? ex.command : defaults.command,
168
- args: useExisting && existingArgs ? existingArgs : defaults.args,
169
- // Merge env: preserve user-added vars, ensure BRAINCLAW_AGENT is set
170
- env: {
171
- ...exEnv,
172
- BRAINCLAW_AGENT: agentName,
173
- ...(workspacePath ? { BRAINCLAW_CWD: workspacePath } : {}),
174
- },
175
- // Preserve timeout if set
176
- ...(typeof ex.timeout === 'number' ? { timeout: ex.timeout } : {}),
177
- };
178
- }
8
+ import { PROTOCOL_SKILLS, renderProtocolSkill } from './protocol-skills.js';
9
+ import { getInstalledBrainclawVersion } from './brainclaw-version.js';
10
+ import { isAgentInstalledPerInventory } from './agent-inventory.js';
11
+ import { brainclawMcpEntry, buildHookCommand, getBclawCliParts, getBrainclawMcpCommand, isForceResolveEnabled, quoteShellArg, resetMcpCommandCache, withForcedResolve, } from './mcp-command-resolution.js';
12
+ export { resetMcpCommandCache };
179
13
  export const BRAINCLAW_SECTION_START = '<!-- brainclaw:start -->';
180
14
  export const BRAINCLAW_SECTION_END = '<!-- brainclaw:end -->';
181
15
  export function buildBrainclawSection(storageDir) {
@@ -295,8 +129,10 @@ export function ensureGitignoreEntries(cwd, entries) {
295
129
  const toAdd = entries.filter((e) => !lines.has(e));
296
130
  if (toAdd.length === 0)
297
131
  return;
132
+ const banner = '# Agent instruction files (generated by brainclaw)';
133
+ const bannerBlock = lines.has(banner) ? '' : `${banner}\n`;
298
134
  const separator = current.trimEnd().length > 0 ? '\n' : '';
299
- const next = `${current.trimEnd()}${separator}\n# Agent instruction files (generated by brainclaw)\n${toAdd.join('\n')}\n`;
135
+ const next = `${current.trimEnd()}${separator}\n${bannerBlock}${toAdd.join('\n')}\n`;
300
136
  fs.writeFileSync(gitignorePath, next, 'utf-8');
301
137
  }
302
138
  export function collectWorkspaceGitignoreEntries(cwd, results) {
@@ -506,32 +342,289 @@ export const LOCAL_ONLY_AGENT_WORKSPACE_FILES = [
506
342
  function isJsonObject(value) {
507
343
  return typeof value === 'object' && value !== null && !Array.isArray(value);
508
344
  }
345
+ function stripBom(text) {
346
+ return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
347
+ }
348
+ /**
349
+ * Read a JSON config file. Returns `{}` when the file doesn't exist, and
350
+ * `undefined` when the file exists but cannot be parsed as a JSON object.
351
+ * Callers MUST treat `undefined` as "abort the write" — overwriting a file we
352
+ * could not parse destroys user-owned configuration (a UTF-8 BOM or JSONC
353
+ * comments used to wipe entire settings files this way).
354
+ */
509
355
  function readJsonObject(filePath) {
510
356
  if (!fs.existsSync(filePath)) {
511
357
  return {};
512
358
  }
513
359
  try {
514
- const parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
515
- return isJsonObject(parsed) ? parsed : {};
360
+ const parsed = JSON.parse(stripBom(fs.readFileSync(filePath, 'utf-8')));
361
+ return isJsonObject(parsed) ? parsed : undefined;
516
362
  }
517
363
  catch {
518
- return {};
364
+ return undefined;
519
365
  }
520
366
  }
367
+ /**
368
+ * Same contract as readJsonObject, but tolerates JSONC comments and trailing
369
+ * commas (VS Code files). Token-based, so comments inside string values
370
+ * ("https://…") never corrupt the parse.
371
+ */
521
372
  function readJsoncObject(filePath) {
522
373
  if (!fs.existsSync(filePath)) {
523
374
  return {};
524
375
  }
525
376
  try {
526
- const raw = fs.readFileSync(filePath, 'utf-8');
527
- const withoutBlockComments = raw.replace(/\/\*[\s\S]*?\*\//g, '');
528
- const withoutLineComments = withoutBlockComments.replace(/^\s*\/\/.*$/gm, '');
529
- const parsed = JSON.parse(withoutLineComments);
530
- return isJsonObject(parsed) ? parsed : {};
377
+ const parsed = parseJsonc(stripBom(fs.readFileSync(filePath, 'utf-8')));
378
+ return isJsonObject(parsed) ? parsed : undefined;
531
379
  }
532
380
  catch {
533
- return {};
381
+ return undefined;
382
+ }
383
+ }
384
+ /**
385
+ * Result for a writer that found an unparseable existing file: warn once and
386
+ * leave the file exactly as it is.
387
+ */
388
+ function skippedAutoConfigResult(kind, label, filePath, relativePath) {
389
+ const warning = `cannot parse ${filePath} — file left untouched. Fix its syntax (or remove it) and re-run.`;
390
+ process.stderr.write(`[brainclaw] Warning: ${warning}\n`);
391
+ return {
392
+ kind,
393
+ label,
394
+ created: false,
395
+ updated: false,
396
+ filePath,
397
+ ...(relativePath ? { relativePath } : {}),
398
+ skipped: true,
399
+ warning,
400
+ };
401
+ }
402
+ /** Tokenize JSONC, preserving offsets. Returns undefined on malformed input. */
403
+ function tokenizeJsonc(text) {
404
+ const tokens = [];
405
+ let i = 0;
406
+ const n = text.length;
407
+ while (i < n) {
408
+ const ch = text[i];
409
+ if (ch === ' ' || ch === '\t' || ch === '\r' || ch === '\n') {
410
+ i++;
411
+ continue;
412
+ }
413
+ if (ch === '/' && text[i + 1] === '/') {
414
+ while (i < n && text[i] !== '\n')
415
+ i++;
416
+ continue;
417
+ }
418
+ if (ch === '/' && text[i + 1] === '*') {
419
+ const close = text.indexOf('*/', i + 2);
420
+ if (close === -1)
421
+ return undefined;
422
+ i = close + 2;
423
+ continue;
424
+ }
425
+ if (ch === '"') {
426
+ const start = i;
427
+ i++;
428
+ while (i < n && text[i] !== '"') {
429
+ i += text[i] === '\\' ? 2 : 1;
430
+ }
431
+ if (i >= n)
432
+ return undefined;
433
+ i++;
434
+ let value;
435
+ try {
436
+ value = JSON.parse(text.slice(start, i));
437
+ }
438
+ catch {
439
+ return undefined;
440
+ }
441
+ tokens.push({ type: 'string', start, end: i, value });
442
+ continue;
443
+ }
444
+ if ('{}[]:,'.includes(ch)) {
445
+ tokens.push({ type: 'punct', start: i, end: i + 1, value: ch });
446
+ i++;
447
+ continue;
448
+ }
449
+ const start = i;
450
+ while (i < n && !' \t\r\n{}[]:,/"'.includes(text[i]))
451
+ i++;
452
+ if (i === start)
453
+ return undefined;
454
+ tokens.push({ type: 'literal', start, end: i });
455
+ }
456
+ return tokens;
457
+ }
458
+ /** Parse JSONC text (comments + trailing commas) into a JS value. Throws on malformed input. */
459
+ function parseJsonc(text) {
460
+ const tokens = tokenizeJsonc(text);
461
+ if (!tokens)
462
+ throw new Error('malformed JSONC');
463
+ let pos = 0;
464
+ const next = () => {
465
+ const t = tokens[pos];
466
+ if (!t)
467
+ throw new Error('unexpected end of JSONC');
468
+ return t;
469
+ };
470
+ const parseValue = () => {
471
+ const t = next();
472
+ if (t.type === 'string') {
473
+ pos++;
474
+ return t.value;
475
+ }
476
+ if (t.type === 'literal') {
477
+ pos++;
478
+ return JSON.parse(text.slice(t.start, t.end));
479
+ }
480
+ if (t.value === '{') {
481
+ pos++;
482
+ const obj = {};
483
+ if (next().value === '}') {
484
+ pos++;
485
+ return obj;
486
+ }
487
+ for (;;) {
488
+ const key = next();
489
+ if (key.type !== 'string')
490
+ throw new Error('expected object key');
491
+ pos++;
492
+ if (next().value !== ':')
493
+ throw new Error('expected colon');
494
+ pos++;
495
+ obj[key.value] = parseValue();
496
+ const sep = next();
497
+ if (sep.value === ',') {
498
+ pos++;
499
+ if (next().value === '}') {
500
+ pos++;
501
+ return obj;
502
+ }
503
+ continue;
504
+ }
505
+ if (sep.value === '}') {
506
+ pos++;
507
+ return obj;
508
+ }
509
+ throw new Error('expected comma or closing brace');
510
+ }
511
+ }
512
+ if (t.value === '[') {
513
+ pos++;
514
+ const arr = [];
515
+ if (next().value === ']') {
516
+ pos++;
517
+ return arr;
518
+ }
519
+ for (;;) {
520
+ arr.push(parseValue());
521
+ const sep = next();
522
+ if (sep.value === ',') {
523
+ pos++;
524
+ if (next().value === ']') {
525
+ pos++;
526
+ return arr;
527
+ }
528
+ continue;
529
+ }
530
+ if (sep.value === ']') {
531
+ pos++;
532
+ return arr;
533
+ }
534
+ throw new Error('expected comma or closing bracket');
535
+ }
536
+ }
537
+ throw new Error('unexpected token');
538
+ };
539
+ const value = parseValue();
540
+ if (pos !== tokens.length)
541
+ throw new Error('trailing content after JSONC value');
542
+ return value;
543
+ }
544
+ /** Returns the token index just past the value starting at tokens[idx], or undefined. */
545
+ function skipJsoncValue(tokens, idx) {
546
+ const tok = tokens[idx];
547
+ if (!tok)
548
+ return undefined;
549
+ if (tok.type === 'string' || tok.type === 'literal')
550
+ return idx + 1;
551
+ if (tok.value === '{' || tok.value === '[') {
552
+ const close = tok.value === '{' ? '}' : ']';
553
+ let depth = 0;
554
+ for (let i = idx; i < tokens.length; i++) {
555
+ const t = tokens[i];
556
+ if (t.type !== 'punct')
557
+ continue;
558
+ if (t.value === tok.value)
559
+ depth++;
560
+ else if (t.value === close && --depth === 0)
561
+ return i + 1;
562
+ }
563
+ }
564
+ return undefined;
565
+ }
566
+ /**
567
+ * Set `keyPath` to `valueJson` (a rendered JSON snippet) inside a JSONC text,
568
+ * preserving all comments, whitespace, and unrelated content byte-for-byte.
569
+ * Missing intermediate objects are created. Returns undefined when the input
570
+ * cannot be edited safely (malformed, non-object root, non-object intermediate).
571
+ */
572
+ function setJsoncValue(text, keyPath, valueJson) {
573
+ if (keyPath.length === 0)
574
+ return undefined;
575
+ const tokens = tokenizeJsonc(text);
576
+ if (!tokens || tokens.length === 0 || tokens[0].value !== '{')
577
+ return undefined;
578
+ let objStart = 0;
579
+ for (let depth = 0; depth < keyPath.length; depth++) {
580
+ const key = keyPath[depth];
581
+ const openTok = tokens[objStart];
582
+ if (openTok.value !== '{')
583
+ return undefined;
584
+ const objEnd = skipJsoncValue(tokens, objStart);
585
+ if (objEnd === undefined)
586
+ return undefined;
587
+ // Scan direct members of this object for `key`
588
+ let i = objStart + 1;
589
+ let found = -1;
590
+ while (i < objEnd - 1) {
591
+ const keyTok = tokens[i];
592
+ if (!keyTok || keyTok.type !== 'string')
593
+ return undefined;
594
+ const colon = tokens[i + 1];
595
+ if (!colon || colon.value !== ':')
596
+ return undefined;
597
+ const valueIdx = i + 2;
598
+ const valueEnd = skipJsoncValue(tokens, valueIdx);
599
+ if (valueEnd === undefined)
600
+ return undefined;
601
+ if (keyTok.value === key) {
602
+ found = valueIdx;
603
+ break;
604
+ }
605
+ i = valueEnd;
606
+ if (tokens[i]?.value === ',')
607
+ i++;
608
+ }
609
+ if (found === -1) {
610
+ // Key absent — insert it (with the remaining path nested) after the brace.
611
+ let snippet = valueJson;
612
+ for (let d = keyPath.length - 1; d > depth; d--) {
613
+ snippet = `{ ${JSON.stringify(keyPath[d])}: ${snippet} }`;
614
+ }
615
+ const hasMembers = tokens[objStart + 1]?.value !== '}';
616
+ const insertAt = openTok.end;
617
+ const member = `${JSON.stringify(key)}: ${snippet}`;
618
+ const insertion = hasMembers ? ` ${member},` : ` ${member} `;
619
+ return text.slice(0, insertAt) + insertion + text.slice(insertAt);
620
+ }
621
+ if (depth === keyPath.length - 1) {
622
+ const valueEnd = skipJsoncValue(tokens, found);
623
+ return text.slice(0, tokens[found].start) + valueJson + text.slice(tokens[valueEnd - 1].end);
624
+ }
625
+ objStart = found;
534
626
  }
627
+ return undefined;
535
628
  }
536
629
  function writeTextFileIfChanged(filePath, content) {
537
630
  const existed = fs.existsSync(filePath);
@@ -548,7 +641,17 @@ function writeJsonFileIfChanged(filePath, next) {
548
641
  return writeTextFileIfChanged(filePath, serialized);
549
642
  }
550
643
  function resolveHomeDir(env) {
551
- return env.HOME?.trim() || env.USERPROFILE?.trim() || undefined;
644
+ const fromEnv = env.HOME?.trim() || env.USERPROFILE?.trim();
645
+ if (fromEnv && fs.existsSync(fromEnv))
646
+ return fromEnv;
647
+ // Injected envs (tests, embedders) opt out of the machine fallback. For the
648
+ // real process env, fall back to os.homedir(): Git Bash exports HOME as a
649
+ // POSIX path (/c/Users/x) that breaks every user-level writer on Windows,
650
+ // and some CI shells unset HOME/USERPROFILE entirely.
651
+ if (env !== process.env)
652
+ return undefined;
653
+ const fallback = os.homedir();
654
+ return fallback && fs.existsSync(fallback) ? fallback : undefined;
552
655
  }
553
656
  function runGit(cwd, args, input) {
554
657
  const result = spawnSync('git', args, {
@@ -635,7 +738,10 @@ Steps:
635
738
  }
636
739
  export function ensureClineMcpConfig(cwd) {
637
740
  const filePath = path.join(cwd, '.vscode', 'cline_mcp_settings.json');
638
- const existing = readJsonObject(filePath);
741
+ const existing = readJsoncObject(filePath);
742
+ if (existing === undefined) {
743
+ return skippedAutoConfigResult('mcp', 'Cline MCP settings', filePath, CLINE_MCP_RELATIVE_PATH);
744
+ }
639
745
  const mcpServers = isJsonObject(existing.mcpServers) ? { ...existing.mcpServers } : {};
640
746
  mcpServers.brainclaw = {
641
747
  ...brainclawMcpEntry('cline', mcpServers.brainclaw, cwd),
@@ -661,6 +767,9 @@ export function ensureWindsurfMcpConfig(homeDir) {
661
767
  }
662
768
  const filePath = path.join(homeDir, '.codeium', 'windsurf', 'mcp_config.json');
663
769
  const existing = readJsonObject(filePath);
770
+ if (existing === undefined) {
771
+ return skippedAutoConfigResult('mcp', 'Windsurf MCP settings', filePath, WINDSURF_MCP_RELATIVE_PATH);
772
+ }
664
773
  const mcpServers = isJsonObject(existing.mcpServers) ? { ...existing.mcpServers } : {};
665
774
  mcpServers.brainclaw = {
666
775
  ...brainclawMcpEntry('windsurf', mcpServers.brainclaw),
@@ -797,9 +906,35 @@ CLI fallback only: \`brainclaw context --json\` / \`brainclaw claim create\` / \
797
906
  relativePath: UNIVERSAL_SKILL_RELATIVE_PATH,
798
907
  };
799
908
  }
909
+ /**
910
+ * Write the protocol-skills pack (pln#519) to the universal `.agents/skills/`
911
+ * path — one SKILL.md per workflow (session / memory-capture / multi-agent).
912
+ * Orthogonal to the agent-PROFILE skill above; same agents discover both via
913
+ * the shared `.agents/skills/` convention, so no per-agent branching is needed.
914
+ * Idempotent (writeTextFileIfChanged). Called only for skill-capable agents.
915
+ */
916
+ export function ensureProtocolSkills(cwd) {
917
+ const version = getInstalledBrainclawVersion();
918
+ return PROTOCOL_SKILLS.map((skill) => {
919
+ const relativePath = `.agents/skills/${skill.id}/SKILL.md`;
920
+ const filePath = path.join(cwd, '.agents', 'skills', skill.id, 'SKILL.md');
921
+ const { created, updated } = writeTextFileIfChanged(filePath, renderProtocolSkill(skill, version));
922
+ return {
923
+ kind: 'skill',
924
+ label: `Protocol-skill ${skill.id} (${relativePath})`,
925
+ created,
926
+ updated,
927
+ filePath,
928
+ relativePath,
929
+ };
930
+ });
931
+ }
800
932
  export function ensureCopilotMcpConfig(cwd) {
801
933
  const filePath = path.join(cwd, '.vscode', 'settings.json');
802
- const existing = readJsonObject(filePath);
934
+ const existing = readJsoncObject(filePath);
935
+ if (existing === undefined) {
936
+ return skippedAutoConfigResult('mcp', 'Copilot MCP settings (.vscode/settings.json)', filePath, COPILOT_MCP_RELATIVE_PATH);
937
+ }
803
938
  const copilotMcpKey = 'github.copilot.chat.mcpServers';
804
939
  const mcpServers = isJsonObject(existing[copilotMcpKey]) ? { ...existing[copilotMcpKey] } : {};
805
940
  mcpServers.brainclaw = brainclawMcpEntry('github-copilot', mcpServers.brainclaw, cwd);
@@ -823,7 +958,10 @@ export function ensureCopilotMcpConfig(cwd) {
823
958
  */
824
959
  export function ensureVscodeMcpConfig(cwd) {
825
960
  const filePath = path.join(cwd, '.vscode', 'mcp.json');
826
- const existing = readJsonObject(filePath);
961
+ const existing = readJsoncObject(filePath);
962
+ if (existing === undefined) {
963
+ return skippedAutoConfigResult('mcp', 'VS Code MCP config (.vscode/mcp.json)', filePath, VSCODE_MCP_RELATIVE_PATH);
964
+ }
827
965
  const servers = isJsonObject(existing.servers) ? { ...existing.servers } : {};
828
966
  servers.brainclaw = brainclawMcpEntry('github-copilot', servers.brainclaw, cwd);
829
967
  const { created, updated } = writeJsonFileIfChanged(filePath, {
@@ -842,7 +980,10 @@ export function ensureVscodeMcpConfig(cwd) {
842
980
  const BRAINCLAW_EXTENSION_ID = 'brainclaw.brainclaw-vscode';
843
981
  export function ensureVscodeExtensionRecommendation(cwd) {
844
982
  const filePath = path.join(cwd, '.vscode', 'extensions.json');
845
- const existing = readJsonObject(filePath);
983
+ const existing = readJsoncObject(filePath);
984
+ if (existing === undefined) {
985
+ return skippedAutoConfigResult('recommendation', 'VS Code extension recommendation (.vscode/extensions.json)', filePath, VSCODE_EXTENSIONS_RELATIVE_PATH);
986
+ }
846
987
  const recommendations = Array.isArray(existing.recommendations)
847
988
  ? [...existing.recommendations]
848
989
  : [];
@@ -900,57 +1041,47 @@ function buildMatchedCommandHookEntry(matcher, command) {
900
1041
  hooks: [{ type: 'command', command }],
901
1042
  };
902
1043
  }
903
- function containsCommandHook(entries, command) {
904
- return entries.some((entry) => isJsonObject(entry) &&
905
- Array.isArray(entry.hooks) &&
906
- entry.hooks.some((h) => isJsonObject(h) && h.command === command));
907
- }
908
1044
  /**
909
- * Replace a legacy command hook with a new one, or add the new one if neither exists.
910
- * This enables clean upgrades: old hooks are swapped out, new hooks are added if fresh.
1045
+ * Recognize a hook command emitted by any brainclaw version: `npx brainclaw …`,
1046
+ * absolute bin paths ending in /brainclaw, the `.bclaw-session` marker wrapper,
1047
+ * and the brainclaw-specific `check-events` subcommand (whose broken legacy
1048
+ * form — bare node.exe, cli.js arg dropped — contained no other marker).
911
1049
  */
912
- function replaceOrAddCommandHook(entries, newCommand, legacyCommand) {
913
- if (containsCommandHook(entries, newCommand))
914
- return;
915
- // Find and replace legacy command
916
- for (let i = 0; i < entries.length; i++) {
917
- const entry = entries[i];
918
- if (isJsonObject(entry) && Array.isArray(entry.hooks)) {
919
- for (const h of entry.hooks) {
920
- if (isJsonObject(h) && h.command === legacyCommand) {
921
- h.command = newCommand;
922
- return;
923
- }
924
- }
925
- }
926
- }
927
- // Neither new nor legacy found — add fresh
928
- entries.push(buildCommandHookEntry(newCommand));
929
- }
1050
+ function isBrainclawHookCommand(command) {
1051
+ // Review follow-up L2 (lop_e2d566765b8b4ce3): match brainclaw/bclaw only in
1052
+ // COMMAND position (start / path separator / shell delimiter, optional binary
1053
+ // extension) and check-events only as a standalone shell word — the old
1054
+ // substring regex ate any user hook that merely MENTIONED these words.
1055
+ if (command.includes('.bclaw-session'))
1056
+ return true;
1057
+ if (/(^|\s)check-events(\s|$)/.test(command))
1058
+ return true;
1059
+ return /(^|[\s/\\"'`;&|(])(brainclaw|bclaw)(\.(cmd|exe|js|mjs|ps1))?([\s"')`;&|]|$)/.test(command);
1060
+ }
1061
+ /** Test-only export — hook recognition is the L2 contract worth pinning. */
1062
+ export const __agentFilesTesting = { isBrainclawHookCommand };
930
1063
  /**
931
- * Replace a hook matching any of the legacy patterns, or add fresh.
932
- * Used for hooks where the command string changes across versions.
1064
+ * Remove every brainclaw-emitted hook from `entries`, then append exactly one
1065
+ * canonical entry. Keyed on recognition, not exact command text, so upgrades
1066
+ * replace stale/broken variants instead of accumulating duplicates (we observed
1067
+ * 2× UserPromptSubmit + 3× Stop hooks piled up across upgrades in the wild).
1068
+ * User-authored hooks are preserved untouched.
933
1069
  */
934
- function replaceOrAddCommandHookByPattern(entries, newCommand, legacyPatterns) {
935
- // Already present with the exact new command
936
- if (entries.some(entry => isJsonObject(entry) && Array.isArray(entry.hooks) &&
937
- entry.hooks.some(h => isJsonObject(h) && typeof h.command === 'string' && h.command === newCommand)))
938
- return;
939
- // Find and replace any entry containing a legacy pattern substring
1070
+ function replaceBrainclawHooks(entries, canonical) {
1071
+ const kept = [];
940
1072
  for (const entry of entries) {
941
- if (!isJsonObject(entry) || !Array.isArray(entry.hooks))
1073
+ if (!isJsonObject(entry) || !Array.isArray(entry.hooks)) {
1074
+ kept.push(entry);
942
1075
  continue;
943
- for (const h of entry.hooks) {
944
- if (!isJsonObject(h) || typeof h.command !== 'string')
945
- continue;
946
- if (legacyPatterns.some(p => h.command.includes(p))) {
947
- h.command = newCommand;
948
- return;
949
- }
950
1076
  }
1077
+ const hooks = entry.hooks;
1078
+ const remaining = hooks.filter((h) => !(isJsonObject(h) && typeof h.command === 'string' && isBrainclawHookCommand(h.command)));
1079
+ if (remaining.length === 0)
1080
+ continue;
1081
+ kept.push(remaining.length === hooks.length ? entry : { ...entry, hooks: remaining });
951
1082
  }
952
- // No match — add fresh
953
- entries.push(buildCommandHookEntry(newCommand));
1083
+ kept.push(canonical);
1084
+ return kept;
954
1085
  }
955
1086
  export function ensureProjectDevDependency(cwd) {
956
1087
  const filePath = path.join(cwd, 'package.json');
@@ -984,6 +1115,9 @@ export function ensureProjectDevDependency(cwd) {
984
1115
  export function ensureClaudeCodeMcpConfig(cwd) {
985
1116
  const filePath = path.join(cwd, '.mcp.json');
986
1117
  const existing = readJsonObject(filePath);
1118
+ if (existing === undefined) {
1119
+ return skippedAutoConfigResult('mcp', 'Claude Code MCP server', filePath, CLAUDE_CODE_MCP_RELATIVE_PATH);
1120
+ }
987
1121
  const mcpServers = isJsonObject(existing.mcpServers) ? { ...existing.mcpServers } : {};
988
1122
  mcpServers.brainclaw = brainclawMcpEntry('claude-code', mcpServers.brainclaw, cwd);
989
1123
  const { created, updated } = writeJsonFileIfChanged(filePath, {
@@ -1017,6 +1151,9 @@ export function ensureClaudeCodeUserSettings(homeDir, env = process.env) {
1017
1151
  return undefined;
1018
1152
  const filePath = path.join(homeDir, '.claude', 'settings.json');
1019
1153
  const existing = readJsonObject(filePath);
1154
+ if (existing === undefined) {
1155
+ return skippedAutoConfigResult('mcp', 'Claude Code user settings — MCP + permissions (global, all projects)', filePath);
1156
+ }
1020
1157
  // MCP server
1021
1158
  const mcpServers = isJsonObject(existing.mcpServers) ? { ...existing.mcpServers } : {};
1022
1159
  mcpServers.brainclaw = brainclawMcpEntry('claude-code', mcpServers.brainclaw);
@@ -1058,6 +1195,9 @@ export function ensureClaudeCodeUserCommand(homeDir) {
1058
1195
  export function ensureClaudeCodeSettings(cwd) {
1059
1196
  const filePath = path.join(cwd, '.claude', 'settings.local.json');
1060
1197
  const existing = readJsonObject(filePath);
1198
+ if (existing === undefined) {
1199
+ return skippedAutoConfigResult('rule', 'Claude Code settings (permissions + session hooks)', filePath, CLAUDE_CODE_SETTINGS_RELATIVE_PATH);
1200
+ }
1061
1201
  // Merge permissions.allow
1062
1202
  const permissions = isJsonObject(existing.permissions) ? { ...existing.permissions } : {};
1063
1203
  const allow = Array.isArray(permissions.allow) ? [...permissions.allow] : [];
@@ -1084,40 +1224,19 @@ export function ensureClaudeCodeSettings(cwd) {
1084
1224
  }
1085
1225
  }
1086
1226
  permissions.additionalDirectories = additionalDirs;
1087
- // Merge hooks — UserPromptSubmit opens a session on first prompt, diff on subsequent
1227
+ // Merge hooks — UserPromptSubmit opens a session on first prompt, diff on subsequent.
1228
+ // getBclawCliParts() keeps the cli.js argument; the previous builder used only
1229
+ // the bare command, emitting broken `node.exe session-start` hooks whenever
1230
+ // binary resolution succeeded (hidden by 2>/dev/null).
1088
1231
  const hooks = isJsonObject(existing.hooks) ? { ...existing.hooks } : {};
1089
- const mcpCmd = getBrainclawMcpCommand();
1090
- // For shell hooks, normalize Windows backslashes to forward slashes and quote if needed
1091
- const bclawBin = mcpCmd.command === 'npx'
1092
- ? 'npx brainclaw'
1093
- : `"${mcpCmd.command.replace(/\\/g, '/')}"`;
1232
+ const bclawBin = getBclawCliParts().map(quoteShellArg).join(' ');
1094
1233
  const sessionCommand = `f=.claude/.bclaw-session; if [ ! -f "$f" ]; then touch "$f"; ${bclawBin} session-start --include-context 2>/dev/null; else ${bclawBin} context-diff 2>/dev/null; fi`;
1095
1234
  const stopCommand = `rm -f .claude/.bclaw-session; ${bclawBin} session-end --auto-release --reflect --reflect-handoff --dispatch-review 2>/dev/null`;
1096
- // Legacy commands to replace on upgrade (substring patterns to match old hooks)
1097
- const legacyPatterns = [
1098
- 'brainclaw context 2>/dev/null',
1099
- 'brainclaw session-start --include-context 2>/dev/null',
1100
- 'brainclaw session-end --auto-release',
1101
- 'brainclaw context-diff 2>/dev/null',
1102
- ];
1103
- const userPromptHooks = Array.isArray(hooks.UserPromptSubmit) ? [...hooks.UserPromptSubmit] : [];
1104
- replaceOrAddCommandHookByPattern(userPromptHooks, sessionCommand, legacyPatterns);
1105
- hooks.UserPromptSubmit = userPromptHooks;
1106
- const stopHooks = Array.isArray(hooks.Stop) ? [...hooks.Stop] : [];
1107
- replaceOrAddCommandHookByPattern(stopHooks, stopCommand, legacyPatterns);
1108
- hooks.Stop = stopHooks;
1109
1235
  // PostToolUse — check for unseen events after any brainclaw MCP tool call
1110
1236
  const checkEventsCommand = `${bclawBin} check-events 2>/dev/null`;
1111
- const postToolHooks = Array.isArray(hooks.PostToolUse) ? [...hooks.PostToolUse] : [];
1112
- replaceOrAddCommandHookByPattern(postToolHooks, checkEventsCommand, ['npx brainclaw check-events']);
1113
- // Preserve matcher for PostToolUse only fire on brainclaw MCP tool calls
1114
- for (const entry of postToolHooks) {
1115
- if (isJsonObject(entry) && Array.isArray(entry.hooks) &&
1116
- entry.hooks.some(h => isJsonObject(h) && typeof h.command === 'string' && h.command.includes('check-events'))) {
1117
- entry.matcher = 'mcp__brainclaw__';
1118
- }
1119
- }
1120
- hooks.PostToolUse = postToolHooks;
1237
+ hooks.UserPromptSubmit = replaceBrainclawHooks(Array.isArray(hooks.UserPromptSubmit) ? hooks.UserPromptSubmit : [], buildCommandHookEntry(sessionCommand));
1238
+ hooks.Stop = replaceBrainclawHooks(Array.isArray(hooks.Stop) ? hooks.Stop : [], buildCommandHookEntry(stopCommand));
1239
+ hooks.PostToolUse = replaceBrainclawHooks(Array.isArray(hooks.PostToolUse) ? hooks.PostToolUse : [], buildMatchedCommandHookEntry('mcp__brainclaw__', checkEventsCommand));
1121
1240
  const { created, updated } = writeJsonFileIfChanged(filePath, {
1122
1241
  ...existing,
1123
1242
  permissions,
@@ -1138,6 +1257,9 @@ export function ensureCursorMcpConfig(homeDir) {
1138
1257
  }
1139
1258
  const filePath = path.join(homeDir, '.cursor', 'mcp.json');
1140
1259
  const existing = readJsonObject(filePath);
1260
+ if (existing === undefined) {
1261
+ return skippedAutoConfigResult('mcp', 'Cursor MCP settings', filePath, CURSOR_MCP_RELATIVE_PATH);
1262
+ }
1141
1263
  const mcpServers = isJsonObject(existing.mcpServers) ? { ...existing.mcpServers } : {};
1142
1264
  mcpServers.brainclaw = brainclawMcpEntry('cursor', mcpServers.brainclaw);
1143
1265
  const { created, updated } = writeJsonFileIfChanged(filePath, {
@@ -1156,6 +1278,9 @@ export function ensureCursorMcpConfig(homeDir) {
1156
1278
  export function ensureRooMcpConfig(cwd) {
1157
1279
  const filePath = path.join(cwd, '.roo', 'mcp.json');
1158
1280
  const existing = readJsonObject(filePath);
1281
+ if (existing === undefined) {
1282
+ return skippedAutoConfigResult('mcp', 'Roo Code MCP settings', filePath, ROO_MCP_RELATIVE_PATH);
1283
+ }
1159
1284
  const mcpServers = isJsonObject(existing.mcpServers) ? { ...existing.mcpServers } : {};
1160
1285
  mcpServers.brainclaw = {
1161
1286
  ...brainclawMcpEntry('roo', mcpServers.brainclaw, cwd),
@@ -1176,22 +1301,46 @@ export function ensureRooMcpConfig(cwd) {
1176
1301
  }
1177
1302
  export function ensureKilocodeConfig(cwd) {
1178
1303
  const filePath = path.join(cwd, KILOCODE_CONFIG_RELATIVE_PATH);
1304
+ const label = 'Kilo Code permissions (kilo.jsonc)';
1179
1305
  const existing = readJsoncObject(filePath);
1180
- const permission = isJsonObject(existing.permission) ? { ...existing.permission } : {};
1181
- permission.external_directory = 'deny';
1182
- const { created, updated } = writeTextFileIfChanged(filePath, `${JSON.stringify({ ...existing, permission }, null, 2)}\n`);
1183
- return {
1306
+ if (existing === undefined) {
1307
+ return skippedAutoConfigResult('permissions', label, filePath, KILOCODE_CONFIG_RELATIVE_PATH);
1308
+ }
1309
+ const noop = {
1184
1310
  kind: 'permissions',
1185
- label: 'Kilo Code permissions (kilo.jsonc)',
1186
- created,
1187
- updated,
1311
+ label,
1312
+ created: false,
1313
+ updated: false,
1188
1314
  filePath,
1189
1315
  relativePath: KILOCODE_CONFIG_RELATIVE_PATH,
1190
1316
  };
1317
+ const permission = isJsonObject(existing.permission) ? existing.permission : {};
1318
+ if (permission.external_directory === 'deny') {
1319
+ return noop;
1320
+ }
1321
+ const existed = fs.existsSync(filePath);
1322
+ if (!existed) {
1323
+ const { created, updated } = writeTextFileIfChanged(filePath, `${JSON.stringify({ permission: { external_directory: 'deny' } }, null, 2)}\n`);
1324
+ return { ...noop, created, updated };
1325
+ }
1326
+ // Surgical JSONC edit — kilo.jsonc is a user-owned file where comments are
1327
+ // part of the official format; a parse→stringify round-trip would strip them.
1328
+ const raw = stripBom(fs.readFileSync(filePath, 'utf-8'));
1329
+ const next = raw.trim().length === 0
1330
+ ? `${JSON.stringify({ permission: { external_directory: 'deny' } }, null, 2)}\n`
1331
+ : setJsoncValue(raw, ['permission', 'external_directory'], '"deny"');
1332
+ if (next === undefined) {
1333
+ return skippedAutoConfigResult('permissions', label, filePath, KILOCODE_CONFIG_RELATIVE_PATH);
1334
+ }
1335
+ fs.writeFileSync(filePath, next, 'utf-8');
1336
+ return { ...noop, updated: true };
1191
1337
  }
1192
1338
  export function ensureKilocodeMcpConfig(cwd) {
1193
1339
  const filePath = path.join(cwd, '.kilo', 'mcp.json');
1194
1340
  const existing = readJsonObject(filePath);
1341
+ if (existing === undefined) {
1342
+ return skippedAutoConfigResult('mcp', 'Kilo Code MCP settings', filePath, KILOCODE_MCP_RELATIVE_PATH);
1343
+ }
1195
1344
  const mcpServers = isJsonObject(existing.mcpServers) ? { ...existing.mcpServers } : {};
1196
1345
  mcpServers.brainclaw = {
1197
1346
  ...brainclawMcpEntry('kilocode', mcpServers.brainclaw, cwd),
@@ -1282,48 +1431,71 @@ export function ensureMistralVibeMcpConfig(cwd) {
1282
1431
  relativePath: MISTRAL_VIBE_CONFIG_RELATIVE_PATH,
1283
1432
  };
1284
1433
  }
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
- ];
1434
+ // Hermes' MCP `tools.include` array — narrow canonical-grammar surface. Derived
1435
+ // from MCP_CANONICAL_GRAMMAR_TOOL_NAMES (which is itself ALL_TOOLS-derived) so
1436
+ // new facade tools or canonical grammar verbs propagate without a manual edit
1437
+ // here (pln#546 step 2). REMOVED_IN_V1_TOOLS are stripped so deprecated names
1438
+ // don't reappear in user-facing configs.
1439
+ //
1440
+ // LAZY (pln#564 coordinator fix): computed on first call, NOT at module init.
1441
+ // agent-files.ts ↔ commands/mcp.ts form an import cycle; reading the imported
1442
+ // MCP_CANONICAL_GRAMMAR_TOOL_NAMES at module-eval time threw a TDZ
1443
+ // ("Cannot access 'MCP_CANONICAL_GRAMMAR_TOOL_NAMES' before initialization")
1444
+ // when agent-files loaded mid-mcp-init — which broke the MCP server. tsc does
1445
+ // not catch this (runtime-only). Deferring the read to call time fixes it.
1446
+ let hermesBrainclawMcpToolsCache;
1447
+ function getHermesBrainclawMcpTools() {
1448
+ if (!hermesBrainclawMcpToolsCache) {
1449
+ hermesBrainclawMcpToolsCache = MCP_CANONICAL_GRAMMAR_TOOL_NAMES
1450
+ .filter((name) => !REMOVED_IN_V1_TOOLS.has(name));
1451
+ }
1452
+ return hermesBrainclawMcpToolsCache;
1453
+ }
1294
1454
  export function ensureHermesMcpConfig(homeDir, workspacePath) {
1295
1455
  if (!homeDir)
1296
1456
  return undefined;
1297
1457
  const filePath = path.join(homeDir, HERMES_CONFIG_RELATIVE_PATH);
1458
+ const label = 'Hermes MCP settings';
1459
+ // Parse the existing file as a YAML *document* so we can update only the
1460
+ // brainclaw-managed subtree. A parse→stringify round-trip of the whole file
1461
+ // destroys user comments, anchors, and key order; a parse failure must
1462
+ // abort instead of replacing the user's Hermes config with a stub.
1463
+ let doc;
1298
1464
  let existing = {};
1299
- let existed = false;
1300
1465
  if (fs.existsSync(filePath)) {
1301
- existed = true;
1302
- try {
1303
- const parsed = yaml.parse(fs.readFileSync(filePath, 'utf-8'));
1304
- existing = isJsonObject(parsed) ? { ...parsed } : {};
1466
+ doc = yaml.parseDocument(stripBom(fs.readFileSync(filePath, 'utf-8')));
1467
+ if (doc.errors.length > 0) {
1468
+ return skippedAutoConfigResult('mcp', label, filePath, HERMES_CONFIG_RELATIVE_PATH);
1469
+ }
1470
+ const parsed = doc.toJS();
1471
+ if (parsed == null) {
1472
+ doc = undefined; // empty file — treat as fresh create
1305
1473
  }
1306
- catch {
1307
- existing = {};
1474
+ else if (isJsonObject(parsed)) {
1475
+ existing = parsed;
1476
+ }
1477
+ else {
1478
+ return skippedAutoConfigResult('mcp', label, filePath, HERMES_CONFIG_RELATIVE_PATH);
1308
1479
  }
1309
1480
  }
1310
- const mcpServers = isJsonObject(existing.mcp_servers) ? { ...existing.mcp_servers } : {};
1311
- const current = isJsonObject(mcpServers.brainclaw) ? { ...mcpServers.brainclaw } : {};
1481
+ const mcpServersJs = isJsonObject(existing.mcp_servers) ? existing.mcp_servers : {};
1482
+ const current = isJsonObject(mcpServersJs.brainclaw) ? { ...mcpServersJs.brainclaw } : {};
1312
1483
  const currentEnv = isJsonObject(current.env) ? { ...current.env } : {};
1313
1484
  const currentTools = isJsonObject(current.tools) ? { ...current.tools } : {};
1314
- const skills = isJsonObject(existing.skills) ? { ...existing.skills } : {};
1485
+ const skills = isJsonObject(existing.skills) ? existing.skills : {};
1315
1486
  const externalDirs = Array.isArray(skills.external_dirs)
1316
1487
  ? skills.external_dirs.filter((value) => typeof value === 'string')
1317
1488
  : [];
1489
+ const newExternalDirs = [];
1318
1490
  if (workspacePath) {
1319
1491
  const projectSkillsDir = path.resolve(workspacePath, HERMES_EXTERNAL_SKILLS_RELATIVE_PATH);
1320
1492
  const normalized = projectSkillsDir.replace(/\\/g, '/').toLowerCase();
1321
1493
  if (!externalDirs.some((dir) => dir.replace(/\\/g, '/').toLowerCase() === normalized)) {
1322
- externalDirs.push(projectSkillsDir);
1494
+ newExternalDirs.push(projectSkillsDir);
1323
1495
  }
1324
1496
  }
1325
1497
  const mcpCmd = getBrainclawMcpCommand();
1326
- mcpServers.brainclaw = {
1498
+ const desiredEntry = {
1327
1499
  ...current,
1328
1500
  command: typeof current.command === 'string' ? current.command : mcpCmd.command,
1329
1501
  args: Array.isArray(current.args) ? current.args : mcpCmd.args,
@@ -1333,23 +1505,67 @@ export function ensureHermesMcpConfig(homeDir, workspacePath) {
1333
1505
  },
1334
1506
  tools: {
1335
1507
  ...currentTools,
1336
- include: Array.isArray(currentTools.include) ? currentTools.include : HERMES_BRAINCLAW_MCP_TOOLS,
1508
+ include: Array.isArray(currentTools.include) ? currentTools.include : getHermesBrainclawMcpTools(),
1337
1509
  prompts: typeof currentTools.prompts === 'boolean' ? currentTools.prompts : false,
1338
1510
  resources: typeof currentTools.resources === 'boolean' ? currentTools.resources : false,
1339
1511
  },
1340
1512
  };
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);
1513
+ if (!doc) {
1514
+ const nextConfig = {
1515
+ mcp_servers: { brainclaw: desiredEntry },
1516
+ ...(externalDirs.length + newExternalDirs.length > 0
1517
+ ? { skills: { ...skills, external_dirs: [...externalDirs, ...newExternalDirs] } }
1518
+ : {}),
1519
+ };
1520
+ const content = `# brainclaw manages the mcp_servers.brainclaw entry below\n${yaml.stringify(nextConfig)}`;
1521
+ const { created, updated } = writeTextFileIfChanged(filePath, content);
1522
+ return {
1523
+ kind: 'mcp',
1524
+ label,
1525
+ created,
1526
+ updated,
1527
+ filePath,
1528
+ relativePath: HERMES_CONFIG_RELATIVE_PATH,
1529
+ };
1530
+ }
1531
+ let changed = false;
1532
+ try {
1533
+ const currentRaw = isJsonObject(mcpServersJs.brainclaw) ? mcpServersJs.brainclaw : undefined;
1534
+ if (JSON.stringify(currentRaw ?? null) !== JSON.stringify(desiredEntry)) {
1535
+ doc.setIn(['mcp_servers', 'brainclaw'], desiredEntry);
1536
+ changed = true;
1537
+ }
1538
+ if (newExternalDirs.length > 0) {
1539
+ if (doc.getIn(['skills', 'external_dirs']) === undefined) {
1540
+ doc.setIn(['skills', 'external_dirs'], [...externalDirs, ...newExternalDirs]);
1541
+ }
1542
+ else {
1543
+ for (const dir of newExternalDirs) {
1544
+ doc.addIn(['skills', 'external_dirs'], dir);
1545
+ }
1546
+ }
1547
+ changed = true;
1548
+ }
1549
+ }
1550
+ catch {
1551
+ return skippedAutoConfigResult('mcp', label, filePath, HERMES_CONFIG_RELATIVE_PATH);
1552
+ }
1553
+ if (!changed) {
1554
+ return {
1555
+ kind: 'mcp',
1556
+ label,
1557
+ created: false,
1558
+ updated: false,
1559
+ filePath,
1560
+ relativePath: HERMES_CONFIG_RELATIVE_PATH,
1561
+ };
1562
+ }
1563
+ fs.writeFileSync(filePath, doc.toString(), 'utf-8');
1348
1564
  return {
1349
1565
  kind: 'mcp',
1350
- label: 'Hermes MCP settings',
1351
- created: !existed && created,
1352
- updated: existed && updated,
1566
+ label,
1567
+ created: false,
1568
+ updated: true,
1353
1569
  filePath,
1354
1570
  relativePath: HERMES_CONFIG_RELATIVE_PATH,
1355
1571
  };
@@ -1472,7 +1688,7 @@ export function ensureCodexMcpConfig(homeDir, env = process.env) {
1472
1688
  content = content + brainclawBlock + '\n';
1473
1689
  changed = true;
1474
1690
  }
1475
- else if (_forceResolve) {
1691
+ else if (isForceResolveEnabled()) {
1476
1692
  const replaced = replaceTomlSection(content, 'mcp_servers.brainclaw', brainclawBlock.slice(1) + '\n');
1477
1693
  if (replaced !== content) {
1478
1694
  content = replaced;
@@ -1480,8 +1696,8 @@ export function ensureCodexMcpConfig(homeDir, env = process.env) {
1480
1696
  }
1481
1697
  }
1482
1698
  // Per-tool approval blocks: ALWAYS sync to the current catalog, regardless
1483
- // of _forceResolve. These sections are purely machine-managed (no user edits
1484
- // expected) and must match the narrowed headless-auto catalog.
1699
+ // of force-resolve state. These sections are purely machine-managed (no user
1700
+ // edits expected) and must match the narrowed headless-auto catalog.
1485
1701
  const hasToolSections = /^\[mcp_servers\.brainclaw\.tools\./m.test(content);
1486
1702
  if (hasToolSections) {
1487
1703
  const replaced = replaceTomlSection(content, 'mcp_servers.brainclaw.tools', toolsBlock.slice(1));
@@ -1555,6 +1771,9 @@ function replaceTomlSection(fileContent, sectionName, newBlock) {
1555
1771
  export function ensureContinueMcpConfig(cwd) {
1556
1772
  const filePath = path.join(cwd, '.continue', 'config.json');
1557
1773
  const existing = readJsonObject(filePath);
1774
+ if (existing === undefined) {
1775
+ return skippedAutoConfigResult('mcp', 'Continue MCP settings', filePath, CONTINUE_CONFIG_RELATIVE_PATH);
1776
+ }
1558
1777
  // Continue uses an array for mcpServers, not a keyed object
1559
1778
  const mcpServers = Array.isArray(existing.mcpServers) ? [...existing.mcpServers] : [];
1560
1779
  const existingIdx = mcpServers.findIndex((entry) => isJsonObject(entry) && entry.name === 'brainclaw');
@@ -1583,6 +1802,9 @@ export function ensureContinueUserMcpConfig(homeDir) {
1583
1802
  return undefined;
1584
1803
  const filePath = path.join(homeDir, '.continue', 'config.json');
1585
1804
  const existing = readJsonObject(filePath);
1805
+ if (existing === undefined) {
1806
+ return skippedAutoConfigResult('mcp', 'Continue MCP settings (global, all projects)', filePath);
1807
+ }
1586
1808
  const mcpServers = Array.isArray(existing.mcpServers) ? [...existing.mcpServers] : [];
1587
1809
  const existingIdx = mcpServers.findIndex((entry) => isJsonObject(entry) && entry.name === 'brainclaw');
1588
1810
  if (existingIdx >= 0) {
@@ -1622,40 +1844,69 @@ export function ensureContinueUserPermissions(homeDir) {
1622
1844
  if (!homeDir)
1623
1845
  return undefined;
1624
1846
  const filePath = path.join(homeDir, CONTINUE_PERMISSIONS_RELATIVE_PATH);
1625
- let existing = {};
1847
+ const label = 'Continue tool permissions';
1848
+ const noop = { kind: 'permissions', label, created: false, updated: false, filePath };
1849
+ // Update only the per-tool `allow` flags in the YAML document; never
1850
+ // round-trip the whole user file (comments/anchors/key order survive) and
1851
+ // never replace an unparseable file with a stub.
1852
+ let doc;
1853
+ let existingTools = {};
1626
1854
  if (fs.existsSync(filePath)) {
1627
- try {
1628
- const parsed = yaml.parse(fs.readFileSync(filePath, 'utf-8'));
1629
- existing = isJsonObject(parsed) ? { ...parsed } : {};
1855
+ doc = yaml.parseDocument(stripBom(fs.readFileSync(filePath, 'utf-8')));
1856
+ if (doc.errors.length > 0) {
1857
+ return skippedAutoConfigResult('permissions', label, filePath);
1630
1858
  }
1631
- catch {
1632
- existing = {};
1859
+ const parsed = doc.toJS();
1860
+ if (parsed == null) {
1861
+ doc = undefined;
1862
+ }
1863
+ else if (isJsonObject(parsed)) {
1864
+ existingTools = isJsonObject(parsed.tools) ? parsed.tools : {};
1865
+ }
1866
+ else {
1867
+ return skippedAutoConfigResult('permissions', label, filePath);
1633
1868
  }
1634
1869
  }
1635
- const toolsObj = isJsonObject(existing.tools) ? { ...existing.tools } : {};
1636
- for (const name of getHeadlessAutoApprovedToolNames()) {
1637
- const current = isJsonObject(toolsObj[name]) ? { ...toolsObj[name] } : {};
1638
- toolsObj[name] = {
1639
- ...current,
1640
- allow: true,
1641
- };
1870
+ if (!doc) {
1871
+ const toolsObj = {};
1872
+ for (const name of getHeadlessAutoApprovedToolNames()) {
1873
+ toolsObj[name] = { allow: true };
1874
+ }
1875
+ const content = `# brainclaw manages the per-tool allow flags below\n${yaml.stringify({ tools: toolsObj })}`;
1876
+ const { created, updated } = writeTextFileIfChanged(filePath, content);
1877
+ return { ...noop, created, updated };
1642
1878
  }
1643
- const content = `# Managed by brainclaw — do not edit manually\n${yaml.stringify({
1644
- ...existing,
1645
- tools: toolsObj,
1646
- })}`;
1647
- const { created, updated } = writeTextFileIfChanged(filePath, content);
1648
- return {
1649
- kind: 'permissions',
1650
- label: 'Continue tool permissions',
1651
- created,
1652
- updated,
1653
- filePath,
1654
- };
1879
+ let changed = false;
1880
+ try {
1881
+ for (const name of getHeadlessAutoApprovedToolNames()) {
1882
+ const current = existingTools[name];
1883
+ const allow = isJsonObject(current) ? current.allow : undefined;
1884
+ if (allow !== true) {
1885
+ if (current !== undefined && !isJsonObject(current)) {
1886
+ doc.setIn(['tools', name], { allow: true });
1887
+ }
1888
+ else {
1889
+ doc.setIn(['tools', name, 'allow'], true);
1890
+ }
1891
+ changed = true;
1892
+ }
1893
+ }
1894
+ }
1895
+ catch {
1896
+ return skippedAutoConfigResult('permissions', label, filePath);
1897
+ }
1898
+ if (!changed) {
1899
+ return noop;
1900
+ }
1901
+ fs.writeFileSync(filePath, doc.toString(), 'utf-8');
1902
+ return { ...noop, updated: true };
1655
1903
  }
1656
1904
  export function ensureOpenCodeMcpConfig(cwd) {
1657
1905
  const filePath = path.join(cwd, 'opencode.json');
1658
1906
  const existing = readJsonObject(filePath);
1907
+ if (existing === undefined) {
1908
+ return skippedAutoConfigResult('mcp', 'OpenCode MCP config', filePath, OPENCODE_CONFIG_RELATIVE_PATH);
1909
+ }
1659
1910
  const mcp = isJsonObject(existing.mcp) ? { ...existing.mcp } : {};
1660
1911
  const mcpCmd = getBrainclawMcpCommand();
1661
1912
  mcp.brainclaw = {
@@ -1683,6 +1934,9 @@ export function ensureAntigravityMcpConfig(homeDir) {
1683
1934
  }
1684
1935
  const filePath = path.join(homeDir, '.gemini', 'antigravity', 'mcp_config.json');
1685
1936
  const existing = readJsonObject(filePath);
1937
+ if (existing === undefined) {
1938
+ return skippedAutoConfigResult('mcp', 'Antigravity MCP config', filePath, ANTIGRAVITY_MCP_RELATIVE_PATH);
1939
+ }
1686
1940
  const mcpServers = isJsonObject(existing.mcpServers) ? { ...existing.mcpServers } : {};
1687
1941
  mcpServers.brainclaw = brainclawMcpEntry('antigravity', mcpServers.brainclaw);
1688
1942
  const { created, updated } = writeJsonFileIfChanged(filePath, {
@@ -1698,35 +1952,6 @@ export function ensureAntigravityMcpConfig(homeDir) {
1698
1952
  relativePath: ANTIGRAVITY_MCP_RELATIVE_PATH,
1699
1953
  };
1700
1954
  }
1701
- function quoteShellArg(arg) {
1702
- if (/^[A-Za-z0-9_./:=+-]+$/.test(arg))
1703
- return arg;
1704
- return `"${arg.replace(/"/g, '\\"')}"`;
1705
- }
1706
- /**
1707
- * Resolve the brainclaw CLI invocation for hook configs.
1708
- * Returns shell-safe parts like `["<node>", "<cli.js>"]` or `["npx", "brainclaw"]`.
1709
- */
1710
- function getBclawCliParts() {
1711
- const mcpCmd = getBrainclawMcpCommand();
1712
- if (mcpCmd.command === 'npx')
1713
- return ['npx', 'brainclaw'];
1714
- const argsWithoutMcp = [...mcpCmd.args];
1715
- if (argsWithoutMcp[argsWithoutMcp.length - 1] === 'mcp') {
1716
- argsWithoutMcp.pop();
1717
- }
1718
- return [
1719
- mcpCmd.command.replace(/\\/g, '/'),
1720
- ...argsWithoutMcp.map((arg) => arg.replace(/\\/g, '/')),
1721
- ];
1722
- }
1723
- function buildHookCommand(args, shell = os.platform() === 'win32' ? 'powershell' : 'bash') {
1724
- const rendered = [...getBclawCliParts(), ...args].map(quoteShellArg).join(' ');
1725
- if (shell === 'powershell') {
1726
- return `& ${rendered} 2>$null`;
1727
- }
1728
- return `${rendered} 2>/dev/null`;
1729
- }
1730
1955
  /**
1731
1956
  * Writes `.cursor/hooks.json` — Cursor's native hooks config.
1732
1957
  * Events: sessionStart, beforeSubmitPrompt, stop (Cursor uses camelCase).
@@ -1735,6 +1960,9 @@ function buildHookCommand(args, shell = os.platform() === 'win32' ? 'powershell'
1735
1960
  export function ensureCursorHooks(cwd) {
1736
1961
  const filePath = path.join(cwd, CURSOR_HOOKS_RELATIVE_PATH);
1737
1962
  const existing = readJsonObject(filePath);
1963
+ if (existing === undefined) {
1964
+ return skippedAutoConfigResult('rule', 'Cursor session hooks', filePath, CURSOR_HOOKS_RELATIVE_PATH);
1965
+ }
1738
1966
  const hooks = isJsonObject(existing.hooks) ? { ...existing.hooks } : {};
1739
1967
  const sessionStartCmd = buildHookCommand(['session-start', '--include-context']);
1740
1968
  const contextDiffCmd = buildHookCommand(['context-diff']);
@@ -1765,6 +1993,9 @@ export function ensureAntigravityHooks(homeDir) {
1765
1993
  return undefined;
1766
1994
  const filePath = path.join(homeDir, ANTIGRAVITY_HOOKS_RELATIVE_PATH);
1767
1995
  const existing = readJsonObject(filePath);
1996
+ if (existing === undefined) {
1997
+ return skippedAutoConfigResult('rule', 'Antigravity session hooks', filePath, ANTIGRAVITY_HOOKS_RELATIVE_PATH);
1998
+ }
1768
1999
  const sessionStartCmd = buildHookCommand(['session-start', '--include-context']);
1769
2000
  const contextDiffCmd = buildHookCommand(['context-diff']);
1770
2001
  const sessionEndCmd = buildHookCommand(['session-end', '--auto-release', '--reflect', '--reflect-handoff', '--dispatch-review']);
@@ -1792,6 +2023,9 @@ export function ensureAntigravityHooks(homeDir) {
1792
2023
  export function ensureCopilotHooks(cwd) {
1793
2024
  const filePath = path.join(cwd, COPILOT_HOOKS_RELATIVE_PATH);
1794
2025
  const existing = readJsonObject(filePath);
2026
+ if (existing === undefined) {
2027
+ return skippedAutoConfigResult('rule', 'Copilot session hooks', filePath, COPILOT_HOOKS_RELATIVE_PATH);
2028
+ }
1795
2029
  const hooks = isJsonObject(existing.hooks) ? { ...existing.hooks } : {};
1796
2030
  hooks.sessionStart = [{
1797
2031
  type: 'command',
@@ -1831,6 +2065,9 @@ export function ensureOpenClawMcpConfig(homeDir) {
1831
2065
  }
1832
2066
  const filePath = path.join(homeDir, '.openclaw', 'mcp.json');
1833
2067
  const existing = readJsonObject(filePath);
2068
+ if (existing === undefined) {
2069
+ return skippedAutoConfigResult('mcp', 'OpenClaw MCP config', filePath, OPENCLAW_MCP_RELATIVE_PATH);
2070
+ }
1834
2071
  const mcpServers = isJsonObject(existing.mcpServers) ? { ...existing.mcpServers } : {};
1835
2072
  mcpServers.brainclaw = brainclawMcpEntry('openclaw', mcpServers.brainclaw);
1836
2073
  const { created, updated } = writeJsonFileIfChanged(filePath, {
@@ -1846,212 +2083,272 @@ export function ensureOpenClawMcpConfig(homeDir) {
1846
2083
  relativePath: OPENCLAW_MCP_RELATIVE_PATH,
1847
2084
  };
1848
2085
  }
1849
- export function writeDetectedAgentAutoConfig(agentName, cwd, env = process.env) {
1850
- switch (agentName) {
1851
- case 'claude-code': {
1852
- const results = [
1853
- ensureClaudeCodeMcpConfig(cwd),
1854
- ensureClaudeCodeCommand(cwd),
1855
- ensureClaudeCodeSettings(cwd),
1856
- ensureVscodeExtensionRecommendation(cwd),
1857
- ];
1858
- const userSettings = ensureClaudeCodeUserSettings(resolveHomeDir(env));
1859
- if (userSettings)
1860
- results.push(userSettings);
1861
- const userCmd = ensureClaudeCodeUserCommand(resolveHomeDir(env));
1862
- if (userCmd)
1863
- results.push(userCmd);
1864
- const dep = ensureProjectDevDependency(cwd);
1865
- if (dep)
1866
- results.push(dep);
1867
- return results;
1868
- }
1869
- case 'cline':
1870
- return [ensureClineMcpConfig(cwd)];
1871
- case 'windsurf': {
1872
- const results = [ensureWindsurfModernRules(cwd)];
1873
- const mcp = ensureWindsurfMcpConfig(resolveHomeDir(env));
1874
- if (mcp)
1875
- results.push(mcp);
1876
- return results;
1877
- }
1878
- case 'github-copilot':
1879
- return [ensureCopilotMcpConfig(cwd), ensureCopilotSkill(cwd), ensureCopilotHooks(cwd), ensureUniversalBrainclawSkill(cwd), ensureVscodeExtensionRecommendation(cwd)];
1880
- case 'cursor': {
1881
- const results = [ensureCursorMdc(cwd), ensureCursorHooks(cwd), ensureUniversalBrainclawSkill(cwd)];
1882
- const mcp = ensureCursorMcpConfig(resolveHomeDir(env));
1883
- if (mcp)
1884
- results.push(mcp);
1885
- return results;
1886
- }
1887
- case 'roo':
1888
- return [ensureRooMcpConfig(cwd), ensureUniversalBrainclawSkill(cwd)];
1889
- case 'kilocode':
1890
- return [ensureKilocodeMcpConfig(cwd), ensureKilocodeConfig(cwd), ensureUniversalBrainclawSkill(cwd)];
1891
- case 'mistral-vibe':
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
- }
1900
- case 'codex': {
1901
- const results = [ensureUniversalBrainclawSkill(cwd)];
1902
- const result = ensureCodexMcpConfig(resolveHomeDir(env), env);
1903
- if (result)
1904
- results.push(result);
1905
- return results;
1906
- }
1907
- case 'continue': {
1908
- const results = [ensureContinueMcpConfig(cwd)];
1909
- const homeDir = resolveHomeDir(env);
1910
- const userMcp = ensureContinueUserMcpConfig(homeDir);
1911
- if (userMcp)
1912
- results.push(userMcp);
1913
- const perms = ensureContinueUserPermissions(homeDir);
1914
- if (perms)
1915
- results.push(perms);
1916
- return results;
1917
- }
1918
- case 'opencode':
1919
- return [ensureOpenCodeMcpConfig(cwd), ensureUniversalBrainclawSkill(cwd)];
1920
- case 'antigravity': {
1921
- const homeDir = resolveHomeDir(env);
1922
- const results = [];
1923
- const mcp = ensureAntigravityMcpConfig(homeDir);
1924
- if (mcp)
1925
- results.push(mcp);
1926
- const hooks = ensureAntigravityHooks(homeDir);
1927
- if (hooks)
1928
- results.push(hooks);
1929
- return results;
1930
- }
1931
- case 'openclaw': {
1932
- const result = ensureOpenClawMcpConfig(resolveHomeDir(env));
1933
- return result ? [result] : [];
1934
- }
1935
- default:
1936
- return [];
2086
+ // Shared writer builders referenced across multiple agents.
2087
+ const writeUniversalSkill = (ctx) => ensureUniversalBrainclawSkill(ctx.cwd);
2088
+ const writeProtocolSkills = (ctx) => ensureProtocolSkills(ctx.cwd);
2089
+ const writeVscodeExtensionRec = (ctx) => ensureVscodeExtensionRecommendation(ctx.cwd);
2090
+ /**
2091
+ * Per-agent writer wiring. The keys are canonical agent names from
2092
+ * `AgentName` (see agent-capability.ts) — keep in sync with AGENT_EXPORT_REGISTRY.
2093
+ * Agents missing from this map yield an empty writer list (no-op detection),
2094
+ * which the drift test below guards against.
2095
+ */
2096
+ export const AGENT_WIRING_REGISTRY = {
2097
+ 'claude-code': {
2098
+ // .claude/settings.local.json bundles permissions + session/Stop/PostToolUse
2099
+ // hooks in one file, so it lives in workspaceWriters — not duplicated in
2100
+ // hookWriters (which would double-count the result).
2101
+ workspaceWriters: [
2102
+ (ctx) => ensureClaudeCodeMcpConfig(ctx.cwd),
2103
+ (ctx) => ensureClaudeCodeCommand(ctx.cwd),
2104
+ (ctx) => ensureClaudeCodeSettings(ctx.cwd),
2105
+ writeVscodeExtensionRec,
2106
+ (ctx) => ensureProjectDevDependency(ctx.cwd),
2107
+ ],
2108
+ userWriters: [
2109
+ (ctx) => ensureClaudeCodeUserSettings(ctx.homeDir, ctx.env),
2110
+ (ctx) => ensureClaudeCodeUserCommand(ctx.homeDir),
2111
+ ],
2112
+ hookWriters: [],
2113
+ },
2114
+ cline: {
2115
+ workspaceWriters: [(ctx) => ensureClineMcpConfig(ctx.cwd)],
2116
+ userWriters: [],
2117
+ hookWriters: [],
2118
+ },
2119
+ windsurf: {
2120
+ workspaceWriters: [(ctx) => ensureWindsurfModernRules(ctx.cwd)],
2121
+ userWriters: [(ctx) => ensureWindsurfMcpConfig(ctx.homeDir)],
2122
+ hookWriters: [],
2123
+ },
2124
+ 'github-copilot': {
2125
+ workspaceWriters: [
2126
+ (ctx) => ensureCopilotMcpConfig(ctx.cwd),
2127
+ (ctx) => ensureCopilotSkill(ctx.cwd),
2128
+ writeUniversalSkill,
2129
+ writeProtocolSkills,
2130
+ writeVscodeExtensionRec,
2131
+ ],
2132
+ userWriters: [],
2133
+ hookWriters: [(ctx) => ensureCopilotHooks(ctx.cwd)],
2134
+ },
2135
+ cursor: {
2136
+ workspaceWriters: [
2137
+ (ctx) => ensureCursorMdc(ctx.cwd),
2138
+ writeUniversalSkill,
2139
+ writeProtocolSkills,
2140
+ ],
2141
+ userWriters: [(ctx) => ensureCursorMcpConfig(ctx.homeDir)],
2142
+ hookWriters: [(ctx) => ensureCursorHooks(ctx.cwd)],
2143
+ },
2144
+ roo: {
2145
+ workspaceWriters: [
2146
+ (ctx) => ensureRooMcpConfig(ctx.cwd),
2147
+ writeUniversalSkill,
2148
+ writeProtocolSkills,
2149
+ ],
2150
+ userWriters: [],
2151
+ hookWriters: [],
2152
+ },
2153
+ kilocode: {
2154
+ workspaceWriters: [
2155
+ (ctx) => ensureKilocodeMcpConfig(ctx.cwd),
2156
+ (ctx) => ensureKilocodeConfig(ctx.cwd),
2157
+ writeUniversalSkill,
2158
+ writeProtocolSkills,
2159
+ ],
2160
+ userWriters: [],
2161
+ hookWriters: [],
2162
+ },
2163
+ 'mistral-vibe': {
2164
+ workspaceWriters: [
2165
+ (ctx) => ensureMistralVibeMcpConfig(ctx.cwd),
2166
+ writeUniversalSkill,
2167
+ writeProtocolSkills,
2168
+ ],
2169
+ userWriters: [],
2170
+ hookWriters: [],
2171
+ },
2172
+ hermes: {
2173
+ workspaceWriters: [
2174
+ writeUniversalSkill,
2175
+ writeProtocolSkills,
2176
+ ],
2177
+ userWriters: [(ctx) => ensureHermesMcpConfig(ctx.homeDir, ctx.workspacePath ?? ctx.cwd)],
2178
+ hookWriters: [],
2179
+ },
2180
+ codex: {
2181
+ workspaceWriters: [
2182
+ writeUniversalSkill,
2183
+ writeProtocolSkills,
2184
+ ],
2185
+ userWriters: [(ctx) => ensureCodexMcpConfig(ctx.homeDir, ctx.env)],
2186
+ hookWriters: [],
2187
+ },
2188
+ continue: {
2189
+ workspaceWriters: [(ctx) => ensureContinueMcpConfig(ctx.cwd)],
2190
+ userWriters: [
2191
+ (ctx) => ensureContinueUserMcpConfig(ctx.homeDir),
2192
+ (ctx) => ensureContinueUserPermissions(ctx.homeDir),
2193
+ ],
2194
+ hookWriters: [],
2195
+ },
2196
+ opencode: {
2197
+ workspaceWriters: [
2198
+ (ctx) => ensureOpenCodeMcpConfig(ctx.cwd),
2199
+ writeUniversalSkill,
2200
+ writeProtocolSkills,
2201
+ ],
2202
+ userWriters: [],
2203
+ hookWriters: [],
2204
+ },
2205
+ antigravity: {
2206
+ workspaceWriters: [],
2207
+ userWriters: [(ctx) => ensureAntigravityMcpConfig(ctx.homeDir)],
2208
+ // Antigravity hook config lives under the user home — keep it in
2209
+ // hookWriters (semantic grouping) but skip with the inventory gate like
2210
+ // the other user-level fabricators.
2211
+ hookWriters: [(ctx) => ensureAntigravityHooks(ctx.homeDir)],
2212
+ },
2213
+ openclaw: {
2214
+ workspaceWriters: [],
2215
+ userWriters: [(ctx) => ensureOpenClawMcpConfig(ctx.homeDir)],
2216
+ hookWriters: [],
2217
+ },
2218
+ // Pure SKILL.md surfaces — nanoclaw/nemoclaw/picoclaw/zeroclaw have no MCP
2219
+ // config and their instruction file is written by the export pipeline.
2220
+ nanoclaw: { workspaceWriters: [], userWriters: [], hookWriters: [] },
2221
+ nemoclaw: { workspaceWriters: [], userWriters: [], hookWriters: [] },
2222
+ picoclaw: { workspaceWriters: [], userWriters: [], hookWriters: [] },
2223
+ zeroclaw: { workspaceWriters: [], userWriters: [], hookWriters: [] },
2224
+ // claude-sonnet: shares CLAUDE.md surface with claude-code; the workspace
2225
+ // and user wiring is identical, so detection should reuse claude-code's
2226
+ // descriptor rather than duplicate it.
2227
+ 'claude-sonnet': {
2228
+ workspaceWriters: [
2229
+ (ctx) => ensureClaudeCodeMcpConfig(ctx.cwd),
2230
+ (ctx) => ensureClaudeCodeCommand(ctx.cwd),
2231
+ (ctx) => ensureClaudeCodeSettings(ctx.cwd),
2232
+ writeVscodeExtensionRec,
2233
+ (ctx) => ensureProjectDevDependency(ctx.cwd),
2234
+ ],
2235
+ userWriters: [
2236
+ (ctx) => ensureClaudeCodeUserSettings(ctx.homeDir, ctx.env),
2237
+ (ctx) => ensureClaudeCodeUserCommand(ctx.homeDir),
2238
+ ],
2239
+ hookWriters: [],
2240
+ },
2241
+ };
2242
+ /**
2243
+ * Drain a writer's return value into the result list (skip null/undefined,
2244
+ * flatten arrays from writers like ensureProtocolSkills).
2245
+ */
2246
+ function pushWriterResult(results, value) {
2247
+ if (!value)
2248
+ return;
2249
+ if (Array.isArray(value)) {
2250
+ for (const r of value)
2251
+ results.push(r);
2252
+ }
2253
+ else {
2254
+ results.push(value);
1937
2255
  }
1938
2256
  }
2257
+ /**
2258
+ * Run a descriptor's writers against the given context. The `skipUserIfNotInstalled`
2259
+ * flag consults `isAgentInstalledPerInventory` and drops user-level writers when
2260
+ * the agent isn't present on this machine — preventing init from fabricating
2261
+ * `~/.codex/config.toml` (etc.) on machines that never had codex installed.
2262
+ */
2263
+ function runAgentWriters(descriptor, ctx, agentName, opts = {}) {
2264
+ const out = [];
2265
+ for (const fn of descriptor.workspaceWriters)
2266
+ pushWriterResult(out, fn(ctx));
2267
+ for (const fn of descriptor.hookWriters)
2268
+ pushWriterResult(out, fn(ctx));
2269
+ // User-level writers fabricate machine-wide config. When agents-inventory is
2270
+ // available and reports the agent as NOT installed, skip them — see the
2271
+ // brief's "consult agent-inventory before fabricating user-level configs".
2272
+ const installed = opts.skipUserIfNotInstalled
2273
+ ? isAgentInstalledPerInventory(agentName)
2274
+ : undefined;
2275
+ const skipUser = opts.skipUserIfNotInstalled && installed === false;
2276
+ if (!skipUser) {
2277
+ for (const fn of descriptor.userWriters)
2278
+ pushWriterResult(out, fn(ctx));
2279
+ }
2280
+ if (opts.kindFilter) {
2281
+ return out.filter((r) => r.kind === opts.kindFilter);
2282
+ }
2283
+ return out;
2284
+ }
2285
+ export function writeDetectedAgentAutoConfig(agentName, cwd, env = process.env) {
2286
+ const descriptor = AGENT_WIRING_REGISTRY[agentName];
2287
+ if (!descriptor)
2288
+ return [];
2289
+ const ctx = { cwd, homeDir: resolveHomeDir(env), env, workspacePath: cwd };
2290
+ return runAgentWriters(descriptor, ctx, agentName);
2291
+ }
2292
+ /**
2293
+ * Map an ExportFormat to the agent whose wiring should run. For formats shared
2294
+ * by multiple agents (e.g. agents-md is reused by codex / opencode / mistral /
2295
+ * hermes), the registry order in AGENT_EXPORT_REGISTRY determines the winner —
2296
+ * matching the existing dedupe behaviour in `brainclaw export --all`.
2297
+ */
2298
+ function resolveAgentForFormat(format) {
2299
+ return AGENT_EXPORT_REGISTRY.find((t) => t.format === format)?.agentName;
2300
+ }
1939
2301
  export function writeExportCompanionFiles(format, cwd, env = process.env) {
1940
- switch (format) {
1941
- case 'claude-md': {
1942
- const results = [
1943
- ensureClaudeCodeMcpConfig(cwd),
1944
- ensureClaudeCodeCommand(cwd),
1945
- ensureClaudeCodeSettings(cwd),
1946
- ];
1947
- const userSettings = ensureClaudeCodeUserSettings(resolveHomeDir(env));
1948
- if (userSettings)
1949
- results.push(userSettings);
1950
- const userCmd = ensureClaudeCodeUserCommand(resolveHomeDir(env));
1951
- if (userCmd)
1952
- results.push(userCmd);
1953
- const dep = ensureProjectDevDependency(cwd);
1954
- if (dep)
1955
- results.push(dep);
1956
- return results;
1957
- }
1958
- case 'cline':
1959
- return [ensureClineMcpConfig(cwd)];
1960
- case 'windsurf': {
1961
- const results = [ensureWindsurfModernRules(cwd)];
1962
- const mcp = ensureWindsurfMcpConfig(resolveHomeDir(env));
1963
- if (mcp)
1964
- results.push(mcp);
1965
- return results;
1966
- }
1967
- case 'copilot-instructions':
1968
- return [ensureVscodeMcpConfig(cwd), ensureCopilotMcpConfig(cwd), ensureCopilotSkill(cwd), ensureCopilotHooks(cwd)];
1969
- case 'cursor-rules': {
1970
- const results = [ensureCursorMdc(cwd), ensureCursorHooks(cwd)];
1971
- const mcp = ensureCursorMcpConfig(resolveHomeDir(env));
1972
- if (mcp)
1973
- results.push(mcp);
1974
- return results;
1975
- }
1976
- case 'roo':
1977
- return [ensureRooMcpConfig(cwd)];
1978
- case 'kilocode':
1979
- return [ensureKilocodeMcpConfig(cwd), ensureKilocodeConfig(cwd), ensureUniversalBrainclawSkill(cwd)];
1980
- case 'continue': {
1981
- const results = [ensureContinueMcpConfig(cwd)];
1982
- const homeDir = resolveHomeDir(env);
1983
- const userMcp = ensureContinueUserMcpConfig(homeDir);
1984
- if (userMcp)
1985
- results.push(userMcp);
1986
- const perms = ensureContinueUserPermissions(homeDir);
1987
- if (perms)
1988
- results.push(perms);
1989
- return results;
1990
- }
1991
- case 'gemini-md': {
1992
- const homeDir = resolveHomeDir(env);
1993
- const results = [];
1994
- const mcp = ensureAntigravityMcpConfig(homeDir);
1995
- if (mcp)
1996
- results.push(mcp);
1997
- const hooks = ensureAntigravityHooks(homeDir);
1998
- if (hooks)
1999
- results.push(hooks);
2000
- return results;
2001
- }
2002
- case 'agents-md':
2003
- return [ensureUniversalBrainclawSkill(cwd)];
2004
- default:
2005
- return [];
2006
- }
2302
+ const agentName = resolveAgentForFormat(format);
2303
+ if (!agentName)
2304
+ return [];
2305
+ const descriptor = AGENT_WIRING_REGISTRY[agentName];
2306
+ if (!descriptor)
2307
+ return [];
2308
+ const ctx = { cwd, homeDir: resolveHomeDir(env), env, workspacePath: cwd };
2309
+ // Export is "I want this surface even if the agent isn't installed yet" —
2310
+ // the user explicitly asked for it, so we don't gate on the inventory.
2311
+ return runAgentWriters(descriptor, ctx, agentName);
2007
2312
  }
2008
2313
  /**
2009
2314
  * Patch all MCP config files to use the currently resolved brainclaw binary.
2010
2315
  *
2011
2316
  * Called after upgrade / version --publish-local to fix stale paths.
2012
- * Re-resolves the brainclaw command, then re-runs all ensure*McpConfig()
2013
- * functions with forceResolve=true so existing absolute paths are overwritten.
2317
+ * Re-resolves the brainclaw command, then iterates AGENT_WIRING_REGISTRY with
2318
+ * force-resolve enabled, filtering writer output to `kind: 'mcp'`. Agents that
2319
+ * aren't installed on this machine skip their user-level writers (avoids
2320
+ * minting unrelated user configs as a side effect of an upgrade).
2014
2321
  *
2015
2322
  * Returns the list of configs that were actually updated (not just created).
2016
2323
  */
2017
2324
  export function patchAllMcpConfigs(cwd, env = process.env) {
2018
- // 1. Clear cached path so resolution picks up the new install location
2325
+ // Clear cached path so resolution picks up the new install location
2019
2326
  resetMcpCommandCache();
2020
- // 2. Set force-resolve mode so brainclawMcpEntry overwrites existing paths
2021
- _forceResolve = true;
2022
- const results = [];
2023
- const homeDir = resolveHomeDir(env);
2024
- try {
2025
- // Workspace-level configs
2026
- results.push(ensureClaudeCodeMcpConfig(cwd));
2027
- results.push(ensureVscodeMcpConfig(cwd));
2028
- results.push(ensureVscodeExtensionRecommendation(cwd));
2029
- results.push(ensureCopilotMcpConfig(cwd));
2030
- results.push(ensureClineMcpConfig(cwd));
2031
- results.push(ensureRooMcpConfig(cwd));
2032
- results.push(ensureContinueMcpConfig(cwd));
2033
- results.push(ensureOpenCodeMcpConfig(cwd));
2034
- // Machine-level configs (in ~ or platform-specific)
2035
- const userConfigs = [
2036
- ensureClaudeCodeUserSettings(homeDir, env),
2037
- ensureCursorMcpConfig(homeDir),
2038
- ensureWindsurfMcpConfig(homeDir),
2039
- ensureContinueUserMcpConfig(homeDir),
2040
- ensureContinueUserPermissions(homeDir),
2041
- ensureAntigravityMcpConfig(homeDir),
2042
- ensureOpenClawMcpConfig(homeDir),
2043
- ensureCodexMcpConfig(homeDir, env),
2044
- ensureHermesMcpConfig(homeDir),
2045
- ];
2046
- for (const r of userConfigs) {
2047
- if (r)
2048
- results.push(r);
2327
+ const ctx = { cwd, homeDir: resolveHomeDir(env), env, workspacePath: cwd };
2328
+ // Run inside withForcedResolve so brainclawMcpEntry overwrites existing
2329
+ // absolute paths in user configs (the whole point of the patch pass).
2330
+ const results = withForcedResolve(() => {
2331
+ const acc = [];
2332
+ for (const [agentName, descriptor] of Object.entries(AGENT_WIRING_REGISTRY)) {
2333
+ const agentResults = runAgentWriters(descriptor, ctx, agentName, {
2334
+ skipUserIfNotInstalled: true,
2335
+ kindFilter: 'mcp',
2336
+ });
2337
+ for (const r of agentResults)
2338
+ acc.push(r);
2049
2339
  }
2340
+ return acc;
2341
+ });
2342
+ // Dedupe by filePath — claude-code and claude-sonnet share writers; running
2343
+ // each one twice would emit duplicate "Updated …/.mcp.json" lines.
2344
+ const seen = new Set();
2345
+ const deduped = [];
2346
+ for (const r of results) {
2347
+ if (seen.has(r.filePath))
2348
+ continue;
2349
+ seen.add(r.filePath);
2350
+ deduped.push(r);
2050
2351
  }
2051
- finally {
2052
- // Always reset force-resolve mode
2053
- _forceResolve = false;
2054
- }
2055
- return results.filter(r => r.created || r.updated);
2352
+ return deduped.filter((r) => r.created || r.updated);
2056
2353
  }
2057
2354
  //# sourceMappingURL=agent-files.js.map