wendkeep 0.2.6 → 0.2.7

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
@@ -38,13 +38,13 @@ npx wendkeep init
38
38
  1. Create the vault folder taxonomy and a templated `README.md` (default vault: `<project>/.<project-name>-vault`, e.g. `.MyApp-vault`; override with `--vault`).
39
39
  2. **Merge** the three session hooks and `OBSIDIAN_VAULT_PATH` into `.claude/settings.json` — without clobbering your existing settings (a `.bak` is saved; an unparseable file is left untouched and a `.new` is written for you to merge).
40
40
  3. Add the `wendkeep-vault` MCP server to `.mcp.json` (skip with `--no-mcp`).
41
- 4. Offer to pin **companion** plugins/MCP (multi-choice; `context-mode` pre-checked). Each is wired through the most agent-agnostic path it supports — `context-mode` as an `.mcp.json` MCP server (any agent), `understand-anything` via a `understand-inject` SessionStart hook that injects its domain graph when generated, and the Claude Code plugin layer (`extraKnownMarketplaces` + `enabledPlugins`) as a bonus. Control with `--companions <csv>` or `--no-companions`. (`caveman` additionally runs its own cross-agent installer on non-Claude agents.)
41
+ 4. Offer to pin **companion** plugins/MCP (multi-choice; `context-mode` + `dotcontext` pre-checked). Each is wired through the most agent-agnostic path it supports — `context-mode` and `dotcontext` as `.mcp.json` MCP servers (any agent), `understand-anything` via a `understand-inject` SessionStart hook that injects its domain graph when generated, and the Claude Code plugin layer (`extraKnownMarketplaces` + `enabledPlugins`) as a bonus. **`dotcontext`** (execution/PREVC harness) also gets its lifecycle hooks wired (SessionStart-last + Stop + PostToolUse, pinned CLI) and a starter `.context/config/sensors.json` seeded with a `wendkeep validate-memory` sensor plus one per detected `package.json` script (test/typecheck/lint/build) — add the `.context/runtime|cache|logs|plans|agents|skills/` paths to `.gitignore` (wendkeep doesn't touch git). If dotcontext is already configured globally (`~/.claude.json`), the project MCP entry is **skipped automatically** to avoid a duplicate; tune with `--dotcontext-mcp <auto|project|none>` and `--dotcontext-hooks <full|light|none>` (light drops PostToolUse). Control selection with `--companions <csv>` or `--no-companions`. (`caveman` additionally runs its own cross-agent installer on non-Claude agents.)
42
42
  5. Install a **color system** into the vault's `.obsidian/`: a CSS snippet that accents notes by type (session/decision/bug/learning, via the `cssclasses` the hooks emit) plus graph color groups by folder. Non-destructive merge into `appearance.json`/`graph.json`; skip with `--no-colors`.
43
43
  6. Seed the **curated memory layer**: `.brain/CORE.md` (the hand-curated hot layer, with the 3 required sections) and `.brain/COMPACTION_PROTOCOL.md` (the protocol guide). The auto layers (`DIGEST.md`, `index.jsonl`) are generated by the hooks. Validate the curated layer with `wendkeep validate-memory` (cap 25 lines, 3 sections, no secrets/PII).
44
44
  7. Seed the **definitions layer**: `.brain/agents/` + `.brain/skills/` (versioned source of truth for custom agents/skills, with a README + one example each). Run `wendkeep sync-defs` to copy them into the project's agent dirs (`.brain/agents/*.toml` → `.codex/agents/`, `.brain/skills/<name>/` → `.claude/skills/`).
45
45
 
46
46
  ```bash
47
- npx wendkeep init --vault "~/vaults/work" --project . --yes # non-interactive (companions: context-mode)
47
+ npx wendkeep init --vault "~/vaults/work" --project . --yes # non-interactive (companions: context-mode,dotcontext)
48
48
  npx wendkeep init --companions "context-mode,caveman" --yes
49
49
  npx wendkeep init --no-companions --yes
50
50
  ```
package/bin/wendkeep.mjs CHANGED
@@ -29,10 +29,13 @@ Usage:
29
29
  --vault <path> Obsidian vault folder (default: <project>/.<project-name>-vault).
30
30
  --project <path> Project root to wire (default: current directory).
31
31
  --no-mcp Do not add the mcpvault MCP server to .mcp.json.
32
- --companions <csv> Companion plugins/MCP to pin: context-mode,caveman,understand-anything
33
- (default when interactive/--yes: context-mode).
32
+ --companions <csv> Companion plugins/MCP to pin: context-mode,dotcontext,caveman,understand-anything
33
+ (default when interactive/--yes: context-mode,dotcontext).
34
34
  --no-companions Skip companion plugins/MCP entirely.
35
35
  --no-colors Skip the Obsidian color system (.obsidian snippet + graph groups).
36
+ --dotcontext-mcp <v> dotcontext MCP placement: auto (default; skip project entry
37
+ if already global), project, or none.
38
+ --dotcontext-hooks <v> dotcontext hooks: full (default), light (no PostToolUse), none.
36
39
  --yes, -y Non-interactive; accept defaults.
37
40
  --force Overwrite existing wendkeep config blocks.
38
41
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wendkeep",
3
- "version": "0.2.6",
3
+ "version": "0.2.7",
4
4
  "description": "Automatically capture AI coding agent sessions (Claude Code, Codex) as local Markdown in your Obsidian vault — turn-by-turn history, cost/token tracking, auto-extracted decisions/bugs/learnings, rendered in the graph. Local-first.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,83 @@
1
+ // dotcontext .context/config seeding + MCP-placement resolution.
2
+ // Seeds `memory-validation` (`wendkeep validate-memory`, valid in any wendkeep
3
+ // project) plus a sensor for each detected package.json script (test/typecheck/
4
+ // lint/build). Project-specific commands beyond that are left to `context init`.
5
+ // Non-destructive. Also resolves whether to add the project MCP entry (skip it
6
+ // when dotcontext is already configured globally in ~/.claude.json).
7
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
8
+ import { homedir } from 'node:os';
9
+ import { join } from 'node:path';
10
+
11
+ // package.json script -> sensor (only seeded if the script exists in the project).
12
+ const SCRIPT_SENSORS = [
13
+ { script: 'typecheck', id: 'typecheck', name: 'Typecheck', severity: 'critical' },
14
+ { script: 'test', id: 'tests', name: 'Tests', severity: 'critical' },
15
+ { script: 'lint', id: 'lint', name: 'Lint', severity: 'warning' },
16
+ { script: 'build', id: 'build', name: 'Build', severity: 'warning' },
17
+ ];
18
+
19
+ export function renderSensorsJson(scripts = {}) {
20
+ const sensors = [
21
+ {
22
+ id: 'memory-validation',
23
+ name: 'CORE validation',
24
+ description: 'valida .brain/CORE.md (cap 25 + 3 seções, sem segredos) — wendkeep',
25
+ severity: 'critical',
26
+ command: 'npx wendkeep validate-memory',
27
+ },
28
+ ];
29
+ for (const m of SCRIPT_SENSORS) {
30
+ if (scripts && scripts[m.script]) {
31
+ sensors.push({
32
+ id: m.id,
33
+ name: m.name,
34
+ description: `npm run ${m.script}`,
35
+ severity: m.severity,
36
+ command: `npm run ${m.script}`,
37
+ });
38
+ }
39
+ }
40
+ return `${JSON.stringify({ version: 1, source: 'manual', sensors }, null, 2)}\n`;
41
+ }
42
+
43
+ function readProjectScripts(projectPath) {
44
+ try {
45
+ return JSON.parse(readFileSync(join(projectPath, 'package.json'), 'utf8')).scripts || {};
46
+ } catch {
47
+ return {};
48
+ }
49
+ }
50
+
51
+ // Create <project>/.context/config/sensors.json if absent (sensors auto-detected
52
+ // from the project's package.json scripts). Returns paths created.
53
+ export function seedDotcontext(projectPath) {
54
+ const created = [];
55
+ const configDir = join(projectPath, '.context', 'config');
56
+ mkdirSync(configDir, { recursive: true });
57
+ const sensorsPath = join(configDir, 'sensors.json');
58
+ if (!existsSync(sensorsPath)) {
59
+ writeFileSync(sensorsPath, renderSensorsJson(readProjectScripts(projectPath)), 'utf8');
60
+ created.push(sensorsPath);
61
+ }
62
+ return created;
63
+ }
64
+
65
+ // True if ~/.claude.json already declares a global dotcontext MCP server. Best-effort.
66
+ export function globalHasDotcontext(claudeJsonPath = join(homedir(), '.claude.json')) {
67
+ try {
68
+ const data = JSON.parse(readFileSync(claudeJsonPath, 'utf8'));
69
+ return !!data?.mcpServers?.dotcontext;
70
+ } catch {
71
+ return false;
72
+ }
73
+ }
74
+
75
+ // Whether to SKIP the project-scoped dotcontext MCP entry.
76
+ // 'none' -> always skip
77
+ // 'project' -> never skip
78
+ // 'auto'/-- -> skip iff dotcontext is already global (avoid a duplicate server)
79
+ export function resolveDotcontextSkipMcp(flag, globalHas) {
80
+ if (flag === 'none') return true;
81
+ if (flag === 'project') return false;
82
+ return !!globalHas;
83
+ }
package/src/init.mjs CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  companionMcpPatch,
20
20
  companionHookSpecs,
21
21
  cavemanInstallCommand,
22
+ DOTCONTEXT_GITIGNORE,
22
23
  } from './taxonomy.mjs';
23
24
  import { renderVaultReadme } from './vault-readme.mjs';
24
25
  import { canInteractiveSelect, selectCompanionsInteractive } from './companion-select.mjs';
@@ -31,6 +32,7 @@ import {
31
32
  } from './vault-theme.mjs';
32
33
  import { renderCoreSkeleton, renderCompactionProtocol } from './validate-core.mjs';
33
34
  import { seedDefinitions } from './sync-defs.mjs';
35
+ import { seedDotcontext, globalHasDotcontext, resolveDotcontextSkipMcp } from './dotcontext-seed.mjs';
34
36
 
35
37
  function parseArgs(argv) {
36
38
  const args = { mcp: true, yes: false, force: false };
@@ -43,6 +45,10 @@ function parseArgs(argv) {
43
45
  else if (a === '--force') args.force = true;
44
46
  else if (a === '--no-companions') args.noCompanions = true;
45
47
  else if (a === '--no-colors') args.noColors = true;
48
+ else if (a === '--dotcontext-mcp') args.dotcontextMcp = argv[++i];
49
+ else if (a.startsWith('--dotcontext-mcp=')) args.dotcontextMcp = a.slice(17);
50
+ else if (a === '--dotcontext-hooks') args.dotcontextHooks = argv[++i];
51
+ else if (a.startsWith('--dotcontext-hooks=')) args.dotcontextHooks = a.slice(19);
46
52
  else if (a === '--companions') args.companions = argv[++i];
47
53
  else if (a.startsWith('--companions=')) args.companions = a.slice(13);
48
54
  else if (a.startsWith('--vault=')) args.vault = a.slice(8);
@@ -80,13 +86,19 @@ function backup(path) {
80
86
 
81
87
  // --- merges -----------------------------------------------------------------
82
88
 
83
- export function mergeSettings(existing, { vaultPath, withMcp, force, companions = [] }) {
89
+ export function mergeSettings(existing, { vaultPath, withMcp, force, companions = [], skipMcp = [], dotcontextHookLevel = 'full' }) {
84
90
  const s = existing && typeof existing === 'object' ? { ...existing } : {};
85
91
  s.hooks = { ...(s.hooks || {}) };
86
92
  let added = 0;
87
- // wendkeep's own session hooks + any companion-authored hooks (e.g. understand-inject).
88
- for (const h of [...SESSION_HOOKS, ...companionHookSpecs(companions)]) {
89
- const command = hookCommand(h.name);
93
+ // wendkeep's own session hooks + any companion-authored hooks. Stable-sort by
94
+ // `order` (default 0) so higher-order companion hooks (e.g. dotcontext's order 100)
95
+ // fold AFTER wendkeep's own within each event. A spec may carry its own `command`
96
+ // (dotcontext dispatch) instead of a wendkeep hook `name`.
97
+ const allSpecs = [...SESSION_HOOKS, ...companionHookSpecs(companions, { dotcontextHookLevel })].sort(
98
+ (a, b) => (a.order ?? 0) - (b.order ?? 0),
99
+ );
100
+ for (const h of allSpecs) {
101
+ const command = h.command ?? hookCommand(h.name);
90
102
  const groups = Array.isArray(s.hooks[h.event]) ? [...s.hooks[h.event]] : [];
91
103
  const present = groups.some((g) => (g.hooks || []).some((x) => x.command === command));
92
104
  if (present && !force) {
@@ -110,7 +122,7 @@ export function mergeSettings(existing, { vaultPath, withMcp, force, companions
110
122
  s.enabledPlugins = { ...(s.enabledPlugins || {}), ...cp.enabledPlugins };
111
123
  }
112
124
 
113
- const companionMcp = Object.keys(companionMcpPatch(companions));
125
+ const companionMcp = Object.keys(companionMcpPatch(companions, skipMcp));
114
126
  if (withMcp || companionMcp.length) {
115
127
  const set = new Set(Array.isArray(s.enabledMcpjsonServers) ? s.enabledMcpjsonServers : []);
116
128
  if (withMcp) set.add(MCP_SERVER_KEY);
@@ -120,11 +132,11 @@ export function mergeSettings(existing, { vaultPath, withMcp, force, companions
120
132
  return { settings: s, added };
121
133
  }
122
134
 
123
- export function mergeMcp(existing, { vaultPath, withVault = true, companions = [] }) {
135
+ export function mergeMcp(existing, { vaultPath, withVault = true, companions = [], skipMcp = [] }) {
124
136
  const m = existing && typeof existing === 'object' ? { ...existing } : {};
125
137
  m.mcpServers = { ...(m.mcpServers || {}) };
126
138
  if (withVault) m.mcpServers[MCP_SERVER_KEY] = mcpServerEntry(vaultPath);
127
- Object.assign(m.mcpServers, companionMcpPatch(companions));
139
+ Object.assign(m.mcpServers, companionMcpPatch(companions, skipMcp));
128
140
  return m;
129
141
  }
130
142
 
@@ -229,6 +241,16 @@ export async function runInit(argv) {
229
241
  log(` companions : ${companions.length ? companions.join(', ') : 'none'}`);
230
242
  log(` colors : ${args.noColors ? 'skipped' : 'wendkeep-colors (snippet + graph groups)'}\n`);
231
243
 
244
+ // dotcontext controls (only relevant when selected): hook level + MCP placement.
245
+ // Skip the project MCP entry when dotcontext is already global (avoids a duplicate
246
+ // server / version clash); --dotcontext-mcp project|none and --dotcontext-hooks
247
+ // full|light|none override.
248
+ const dotcontextHookLevel = args.dotcontextHooks || 'full';
249
+ const skipMcp = [];
250
+ if (companions.includes('dotcontext') && resolveDotcontextSkipMcp(args.dotcontextMcp, globalHasDotcontext())) {
251
+ skipMcp.push('dotcontext');
252
+ }
253
+
232
254
  // 1. Vault taxonomy ---------------------------------------------------------
233
255
  if (!existsSync(vaultPath)) mkdirSync(vaultPath, { recursive: true });
234
256
  let created = 0;
@@ -265,12 +287,12 @@ export async function runInit(argv) {
265
287
  const settingsRead = readJsonSafe(settingsPath);
266
288
  if (!settingsRead.ok) {
267
289
  // Unparseable existing file — never clobber. Drop a .new for manual merge.
268
- const fresh = mergeSettings(null, { vaultPath, withMcp: args.mcp, force: true, companions }).settings;
290
+ const fresh = mergeSettings(null, { vaultPath, withMcp: args.mcp, force: true, companions, skipMcp, dotcontextHookLevel }).settings;
269
291
  writeJson(`${settingsPath}.new`, fresh);
270
292
  log(` [2/4] settings.json exists but is not valid JSON -> wrote ${settingsPath}.new (merge by hand)`);
271
293
  } else {
272
294
  const hadFile = settingsRead.data !== null;
273
- const { settings, added } = mergeSettings(settingsRead.data, { vaultPath, withMcp: args.mcp, force: args.force, companions });
295
+ const { settings, added } = mergeSettings(settingsRead.data, { vaultPath, withMcp: args.mcp, force: args.force, companions, skipMcp, dotcontextHookLevel });
274
296
  if (hadFile) backup(settingsPath);
275
297
  writeJson(settingsPath, settings);
276
298
  log(` [2/4] settings.json ${hadFile ? 'merged' : 'created'} (${added} hook(s) wired, OBSIDIAN_VAULT_PATH set${hadFile ? ', .bak saved' : ''})`);
@@ -278,11 +300,11 @@ export async function runInit(argv) {
278
300
 
279
301
  // 3. .mcp.json --------------------------------------------------------------
280
302
  // Written when mcpvault is wanted OR a selected companion ships an MCP server.
281
- const companionMcp = companionMcpPatch(companions);
303
+ const companionMcp = companionMcpPatch(companions, skipMcp);
282
304
  if (args.mcp || Object.keys(companionMcp).length) {
283
305
  const mcpPath = join(projectPath, '.mcp.json');
284
306
  const mcpRead = readJsonSafe(mcpPath);
285
- const mcpOpts = { vaultPath, withVault: args.mcp, companions };
307
+ const mcpOpts = { vaultPath, withVault: args.mcp, companions, skipMcp };
286
308
  const names = [args.mcp && MCP_SERVER_KEY, ...Object.keys(companionMcp)].filter(Boolean).join(', ');
287
309
  if (!mcpRead.ok) {
288
310
  writeJson(`${mcpPath}.new`, mergeMcp(null, mcpOpts));
@@ -309,6 +331,15 @@ export async function runInit(argv) {
309
331
  // plugin entry is already wired in settings.json regardless.
310
332
  if (companions.includes('caveman')) runCavemanInstaller(log);
311
333
 
334
+ // dotcontext: MCP + lifecycle hooks are wired by mergeSettings/mergeMcp above;
335
+ // here we seed the versioned .context/config starter and print the gitignore note.
336
+ if (companions.includes('dotcontext')) {
337
+ const seeded = seedDotcontext(projectPath);
338
+ const mcpNote = skipMcp.includes('dotcontext') ? 'MCP global (project entry skipped)' : 'MCP (project)';
339
+ log(`\n dotcontext: ${mcpNote} + hooks(${dotcontextHookLevel}); .context/config seeded (${seeded.length} file(s)).`);
340
+ log(` [!] add to .gitignore (wendkeep não toca git): ${DOTCONTEXT_GITIGNORE.join(' ')}`);
341
+ }
342
+
312
343
  log('\nNext steps:');
313
344
  log(` 1. Open the vault in Obsidian: "Open folder as vault" -> ${vaultPath}`);
314
345
  log(' 2. Make sure wendkeep is installed where the agent runs (npm i -D wendkeep, or -g).');
package/src/taxonomy.mjs CHANGED
@@ -113,6 +113,17 @@ export const COMPANIONS = [
113
113
  // No MCP, no agnostic hook: on non-Claude agents, run its own installer
114
114
  // (see cavemanInstallCommand — npx, non-interactive, Gemini excluded).
115
115
  },
116
+ {
117
+ id: 'dotcontext',
118
+ label: 'dotcontext — orquestração de execução (PREVC + sensores + gate E→V)',
119
+ default: true,
120
+ // MCP-only (no Claude Code plugin). Agent-agnostic server, @latest surface.
121
+ mcp: { key: 'dotcontext', entry: { type: 'stdio', command: 'npx', args: ['-y', '@dotcontext/mcp@latest'], env: {} } },
122
+ // Lifecycle hooks via the pinned CLI; wired by wendkeep (single writer).
123
+ dotcontextHooks: { cliVersion: '1.1.1', source: 'claude-code' },
124
+ // Seed a starter .context/config (one neutral sensor).
125
+ contextSeed: true,
126
+ },
116
127
  ];
117
128
 
118
129
  const COMPANION_BY_ID = Object.fromEntries(COMPANIONS.map((c) => [c.id, c]));
@@ -139,6 +150,7 @@ export function companionSettingsPatch(ids) {
139
150
  for (const id of ids) {
140
151
  const c = COMPANION_BY_ID[id];
141
152
  if (!c) continue;
153
+ if (!c.marketplace || !c.plugin) continue; // MCP-only companions (e.g. dotcontext) have no plugin layer
142
154
  extraKnownMarketplaces[c.id] = { source: c.marketplace };
143
155
  enabledPlugins[`${c.plugin}@${c.id}`] = true;
144
156
  }
@@ -146,9 +158,13 @@ export function companionSettingsPatch(ids) {
146
158
  }
147
159
 
148
160
  // The .mcp.json fragment (agent-agnostic layer) for MCP-capable companions only.
149
- export function companionMcpPatch(ids) {
161
+ // `skip` omits ids whose MCP is already configured elsewhere (e.g. dotcontext set
162
+ // globally in ~/.claude.json — avoids a duplicate project-scoped server).
163
+ export function companionMcpPatch(ids, skip = []) {
164
+ const skipSet = new Set(skip);
150
165
  const servers = {};
151
166
  for (const id of ids) {
167
+ if (skipSet.has(id)) continue;
152
168
  const c = COMPANION_BY_ID[id];
153
169
  if (c?.mcp) servers[c.mcp.key] = c.mcp.entry;
154
170
  }
@@ -158,7 +174,10 @@ export function companionMcpPatch(ids) {
158
174
  // SessionStart hook specs wendkeep must author for companions that lack a native
159
175
  // one (only Understand-Anything's domain-graph injector today). Same shape as
160
176
  // SESSION_HOOKS so the same merge logic wires them.
161
- export function companionHookSpecs(ids) {
177
+ // `dotcontextHookLevel`: 'full' (SessionStart+Stop+PostToolUse), 'light' (drops the
178
+ // per-tool PostToolUse trace hook — cuts latency when other PostToolUse hooks exist),
179
+ // or 'none' (MCP only, no lifecycle hooks).
180
+ export function companionHookSpecs(ids, { dotcontextHookLevel = 'full' } = {}) {
162
181
  const specs = [];
163
182
  for (const id of ids) {
164
183
  const c = COMPANION_BY_ID[id];
@@ -168,13 +187,42 @@ export function companionHookSpecs(ids) {
168
187
  matcher: null,
169
188
  name: c.wendkeepHook,
170
189
  timeout: 15,
190
+ order: 0,
171
191
  statusMessage: `wendkeep: ${c.id} domain graph`,
172
192
  });
173
193
  }
194
+ // dotcontext: lifecycle hooks (SessionStart last + Stop + PostToolUse) that
195
+ // dispatch to its pinned CLI. They carry an explicit `command` (not a wendkeep
196
+ // hook name) and order 100 so they fold AFTER wendkeep's own session hooks.
197
+ if (c?.dotcontextHooks && dotcontextHookLevel !== 'none') {
198
+ const command = dotcontextHookCommand(c.dotcontextHooks.cliVersion, c.dotcontextHooks.source);
199
+ const base = { command, timeout: 8, order: 100, statusMessage: `${c.id}: harness hook` };
200
+ specs.push({ event: 'SessionStart', matcher: null, ...base });
201
+ specs.push({ event: 'Stop', matcher: null, ...base });
202
+ if (dotcontextHookLevel === 'full') {
203
+ specs.push({ event: 'PostToolUse', matcher: 'Write|Edit|Bash', ...base });
204
+ }
205
+ }
174
206
  }
175
207
  return specs;
176
208
  }
177
209
 
210
+ // The pinned dotcontext CLI hook-dispatch command (runs on each lifecycle event).
211
+ export function dotcontextHookCommand(cliVersion, source) {
212
+ return `npx -y @dotcontext/cli@${cliVersion} hook dispatch --source ${source}`;
213
+ }
214
+
215
+ // .context/ subpaths that should be gitignored (runtime/cache/regenerable). wendkeep
216
+ // does not touch git — init prints these as a note for the user to add.
217
+ export const DOTCONTEXT_GITIGNORE = [
218
+ '.context/runtime/',
219
+ '.context/cache/',
220
+ '.context/logs/',
221
+ '.context/plans/',
222
+ '.context/agents/',
223
+ '.context/skills/',
224
+ ];
225
+
178
226
  // Agents wendkeep installs caveman into via its cross-agent installer. Gemini is
179
227
  // excluded on purpose: its CLI rejects caveman's agent tool names and crashes
180
228
  // (libuv assertion) mid-install. caveman has no --exclude flag — only an --only