wendkeep 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -35,12 +35,15 @@ npx wendkeep init
35
35
 
36
36
  `wendkeep init` is interactive and **idempotent**. It will:
37
37
 
38
- 1. Create the vault folder taxonomy (default vault: `<project>/.wendkeep-vault`, override with `--vault`).
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
42
 
42
43
  ```bash
43
- npx wendkeep init --vault "~/vaults/work" --project . --yes # non-interactive
44
+ npx wendkeep init --vault "~/vaults/work" --project . --yes # non-interactive (companions: context-mode)
45
+ npx wendkeep init --companions "context-mode,caveman" --yes
46
+ npx wendkeep init --no-companions --yes
44
47
  ```
45
48
 
46
49
  Then open the vault in Obsidian, send a test prompt in your agent, and confirm a note appears under `02-Sessões/…`.
package/bin/wendkeep.mjs CHANGED
@@ -26,9 +26,12 @@ const HELP = `wendkeep ${version()} — capture AI coding agent sessions into yo
26
26
 
27
27
  Usage:
28
28
  wendkeep init [options] Set up wendkeep in a project (cross-platform).
29
- --vault <path> Obsidian vault folder (default: <project>/.wendkeep-vault).
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).
34
+ --no-companions Skip companion plugins/MCP entirely.
32
35
  --yes, -y Non-interactive; accept defaults.
33
36
  --force Overwrite existing wendkeep config blocks.
34
37
 
@@ -30,8 +30,48 @@ export function writeHookOutput(payload = {}) {
30
30
  process.stdout.write(JSON.stringify(payload));
31
31
  }
32
32
 
33
+ // Resolve the vault and report WHERE the path came from, so callers can react to
34
+ // an unconfigured install instead of silently writing to the home fallback.
35
+ // env -> OBSIDIAN_VAULT_PATH (set by `wendkeep init`)
36
+ // payload -> obsidian_vault_path from the hook's JSON input
37
+ // default -> DEFAULT_VAULT_BASE (~/wendkeep-vault) — a phantom vault nobody opened
38
+ export function resolveVault(input = {}) {
39
+ if (process.env.OBSIDIAN_VAULT_PATH) {
40
+ return { base: process.env.OBSIDIAN_VAULT_PATH, source: 'env' };
41
+ }
42
+ if (input && input.obsidian_vault_path) {
43
+ return { base: input.obsidian_vault_path, source: 'payload' };
44
+ }
45
+ return { base: DEFAULT_VAULT_BASE, source: 'default' };
46
+ }
47
+
33
48
  export function getVaultBase(input = {}) {
34
- return process.env.OBSIDIAN_VAULT_PATH || input.obsidian_vault_path || DEFAULT_VAULT_BASE;
49
+ return resolveVault(input).base;
50
+ }
51
+
52
+ // Diagnostic logger. No-op unless WENDKEEP_DEBUG is set, so it never pollutes the
53
+ // stdout hook contract during normal runs but makes fail-open paths debuggable.
54
+ export function debugLog(...args) {
55
+ if (!process.env.WENDKEEP_DEBUG) return;
56
+ const text = args
57
+ .map((a) => (a && a.stack ? a.stack : String(a)))
58
+ .join(' ');
59
+ process.stderr.write(`[wendkeep] ${text}\n`);
60
+ }
61
+
62
+ // Warn loudly (stderr) when the vault resolved to the home fallback — i.e. neither
63
+ // OBSIDIAN_VAULT_PATH nor a payload path was provided. Without this the hooks write
64
+ // notes into ~/wendkeep-vault with zero signal that the install is misconfigured.
65
+ // Returns the resolution source so callers can branch if they want.
66
+ export function warnIfDefaultVault(input = {}) {
67
+ const { base, source } = resolveVault(input);
68
+ if (source === 'default') {
69
+ process.stderr.write(
70
+ `[wendkeep] WARNING: OBSIDIAN_VAULT_PATH não definido — gravando no fallback "${base}". ` +
71
+ 'Rode `wendkeep init` ou defina OBSIDIAN_VAULT_PATH apontando ao seu vault Obsidian.\n',
72
+ );
73
+ }
74
+ return source;
35
75
  }
36
76
 
37
77
  // Detecta o agente real que está executando o hook. Claude Code expõe
@@ -9,6 +9,8 @@ import {
9
9
  formatLocalIso,
10
10
  formatTime,
11
11
  getVaultBase,
12
+ warnIfDefaultVault,
13
+ debugLog,
12
14
  readControl,
13
15
  readHookInput,
14
16
  readSessionRegistry,
@@ -298,6 +300,7 @@ function outputActiveContext({ relPath, startedAt, vaultBase, message }) {
298
300
  function main() {
299
301
  const input = readHookInput();
300
302
  const vaultBase = getVaultBase(input);
303
+ warnIfDefaultVault(input);
301
304
  const now = new Date();
302
305
  const sessionId = sessionIdFromInput(input);
303
306
 
@@ -318,7 +321,9 @@ function main() {
318
321
  return;
319
322
  }
320
323
  }
321
- } catch {}
324
+ } catch (err) {
325
+ debugLog('session-ensure fast-path skipped:', err);
326
+ }
322
327
 
323
328
  const control = readControl(vaultBase);
324
329
 
@@ -10,6 +10,7 @@ import {
10
10
  formatLocalIso,
11
11
  formatTime,
12
12
  getVaultBase,
13
+ warnIfDefaultVault,
13
14
  providerMeta,
14
15
  readControl,
15
16
  readHookInput,
@@ -146,6 +147,7 @@ function buildAdditionalContext({ relPath, startedAt, vaultBase }) {
146
147
  function main() {
147
148
  const input = readHookInput();
148
149
  const vaultBase = getVaultBase(input);
150
+ warnIfDefaultVault(input);
149
151
  const now = new Date();
150
152
  const sessionId = input.session_id || input.sessionId || '';
151
153
  const control = readControl(vaultBase);
@@ -14,6 +14,7 @@ import {
14
14
  formatLocalIso,
15
15
  getNextAdrNumber,
16
16
  getVaultBase,
17
+ warnIfDefaultVault,
17
18
  listMarkdownFiles,
18
19
  readControl,
19
20
  readHookInput,
@@ -997,6 +998,7 @@ function main() {
997
998
  }
998
999
 
999
1000
  const vaultBase = getVaultBase(input);
1001
+ warnIfDefaultVault(input);
1000
1002
  const control = readControl(vaultBase);
1001
1003
  const transcriptPath = input.transcript_path || input.transcriptPath || '';
1002
1004
 
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env node
2
+ // understand-inject — SessionStart hook (agent-agnostic, run via `npx wendkeep hook
3
+ // understand-inject`). If the Understand-Anything domain graph has been generated
4
+ // (`.understand-anything/knowledge-graph.json` at the project root), inject a cheap
5
+ // slice of it into the session; otherwise stay silent. Never breaks the session.
6
+ import { readFileSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ import { pathToFileURL } from 'node:url';
9
+ import { readHookInput, writeHookOutput } from './obsidian-common.mjs';
10
+
11
+ const DOMAIN_TYPES = new Set(['domain', 'flow', 'step']);
12
+
13
+ // Build the injection text from the project's domain graph. Returns '' when the
14
+ // graph is absent/unreadable/empty so the caller can stay silent (fail-quiet).
15
+ // Reads only a bounded slice — the full knowledge-graph.json can be large.
16
+ export function buildUnderstandInjection(projectRoot, { maxNodes = 30 } = {}) {
17
+ const graphPath = join(projectRoot, '.understand-anything', 'knowledge-graph.json');
18
+ let graph;
19
+ try {
20
+ graph = JSON.parse(readFileSync(graphPath, 'utf8'));
21
+ } catch {
22
+ return '';
23
+ }
24
+ const nodes = Array.isArray(graph?.nodes) ? graph.nodes : [];
25
+ if (!nodes.length) return '';
26
+
27
+ const domainNodes = nodes.filter((n) => DOMAIN_TYPES.has(n?.type));
28
+ const source = domainNodes.length ? domainNodes : nodes;
29
+ const picked = source.slice(0, maxNodes);
30
+
31
+ const lines = picked.map((n) => {
32
+ const title = n?.name || n?.title || n?.id || '(sem nome)';
33
+ const kind = n?.type ? `[${n.type}] ` : '';
34
+ const summary = String(n?.summary || n?.description || '')
35
+ .split('\n')[0]
36
+ .slice(0, 200);
37
+ return `- ${kind}${title}${summary ? ` — ${summary}` : ''}`;
38
+ });
39
+
40
+ const remaining = source.length - picked.length;
41
+ const more = remaining > 0
42
+ ? `*…+${remaining} nós. Grafo completo: .understand-anything/knowledge-graph.json*`
43
+ : '';
44
+
45
+ return [
46
+ '<understand_domain_graph>',
47
+ 'Domain-graph do projeto (Understand-Anything) — mapa de domínios/fluxos:',
48
+ ...lines,
49
+ more,
50
+ '</understand_domain_graph>',
51
+ ]
52
+ .filter(Boolean)
53
+ .join('\n');
54
+ }
55
+
56
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
57
+ try {
58
+ readHookInput(); // drain stdin (agent JSON); project root comes from cwd
59
+ const context = buildUnderstandInjection(process.cwd());
60
+ if (!context) {
61
+ writeHookOutput({});
62
+ } else {
63
+ writeHookOutput({
64
+ hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: context },
65
+ });
66
+ }
67
+ } catch (error) {
68
+ process.stderr.write(`[understand-inject] falhou: ${error.message}\n`);
69
+ writeHookOutput({});
70
+ }
71
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wendkeep",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
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": {
@@ -17,7 +17,8 @@
17
17
  "node": ">=18"
18
18
  },
19
19
  "scripts": {
20
- "check": "node --check bin/wendkeep.mjs && node --check src/init.mjs && node --check src/doctor.mjs"
20
+ "check": "node --check bin/wendkeep.mjs && node --check src/init.mjs && node --check src/doctor.mjs",
21
+ "test": "node --test"
21
22
  },
22
23
  "keywords": [
23
24
  "claude-code",
@@ -0,0 +1,111 @@
1
+ // Interactive companion checkbox selector for `wendkeep init`.
2
+ // The state machine (initial/reduce/render/mapKey) is pure and tested; the raw-TTY
3
+ // event loop is a thin shell over it, with a text fallback when raw mode is absent.
4
+ import readline from 'node:readline';
5
+
6
+ const HINT = 'Espaço marca/desmarca · ↑/↓ move · a=todos · n=nenhum · Enter confirma';
7
+
8
+ export function initialCompanionMenu(companions) {
9
+ return {
10
+ items: companions.map((c) => ({ id: c.id, label: c.label, checked: !!c.default })),
11
+ cursor: 0,
12
+ };
13
+ }
14
+
15
+ export function reduceCompanionMenu(state, action) {
16
+ const n = state.items.length;
17
+ switch (action) {
18
+ case 'up':
19
+ return { ...state, cursor: (state.cursor - 1 + n) % n };
20
+ case 'down':
21
+ return { ...state, cursor: (state.cursor + 1) % n };
22
+ case 'space':
23
+ return {
24
+ ...state,
25
+ items: state.items.map((it, i) => (i === state.cursor ? { ...it, checked: !it.checked } : it)),
26
+ };
27
+ case 'all':
28
+ return { ...state, items: state.items.map((it) => ({ ...it, checked: true })) };
29
+ case 'none':
30
+ return { ...state, items: state.items.map((it) => ({ ...it, checked: false })) };
31
+ case 'enter':
32
+ return { ...state, done: true, selected: state.items.filter((it) => it.checked).map((it) => it.id) };
33
+ default:
34
+ return state;
35
+ }
36
+ }
37
+
38
+ export function renderCompanionMenu(state) {
39
+ const lines = [`Companions — ${HINT}:`];
40
+ state.items.forEach((it, i) => {
41
+ const cursor = i === state.cursor ? '>' : ' ';
42
+ const box = it.checked ? '[x]' : '[ ]';
43
+ lines.push(`${cursor} ${box} ${it.label}`);
44
+ });
45
+ return lines.join('\n');
46
+ }
47
+
48
+ export function mapKey(str, key = {}) {
49
+ const name = key.name;
50
+ if (name === 'up' || name === 'k') return 'up';
51
+ if (name === 'down' || name === 'j') return 'down';
52
+ if (name === 'space' || str === ' ') return 'space';
53
+ if (name === 'return' || name === 'enter') return 'enter';
54
+ if (str === 'a') return 'all';
55
+ if (str === 'n') return 'none';
56
+ return null;
57
+ }
58
+
59
+ // True when stdin/stdout can drive an interactive raw-mode menu.
60
+ export function canInteractiveSelect(input = process.stdin, output = process.stdout) {
61
+ return !!(input && output && input.isTTY && output.isTTY && typeof input.setRawMode === 'function');
62
+ }
63
+
64
+ // Run the raw-TTY checkbox menu; resolves with the selected ids. Caller must check
65
+ // canInteractiveSelect() first (otherwise use the text fallback).
66
+ export function selectCompanionsInteractive(companions, { input = process.stdin, output = process.stdout } = {}) {
67
+ return new Promise((resolve) => {
68
+ let state = initialCompanionMenu(companions);
69
+ let drawnLines = 0;
70
+
71
+ readline.emitKeypressEvents(input);
72
+ input.setRawMode(true);
73
+ input.resume();
74
+
75
+ const draw = () => {
76
+ if (drawnLines) {
77
+ readline.moveCursor(output, 0, -drawnLines);
78
+ readline.clearScreenDown(output);
79
+ }
80
+ const text = renderCompanionMenu(state);
81
+ output.write(`${text}\n`);
82
+ drawnLines = text.split('\n').length;
83
+ };
84
+
85
+ const cleanup = () => {
86
+ input.removeListener('keypress', onKey);
87
+ if (typeof input.setRawMode === 'function') input.setRawMode(false);
88
+ input.pause();
89
+ };
90
+
91
+ const onKey = (str, key = {}) => {
92
+ if (key.ctrl && key.name === 'c') {
93
+ cleanup();
94
+ output.write('\n');
95
+ process.exit(130);
96
+ }
97
+ const action = mapKey(str, key);
98
+ if (!action) return;
99
+ state = reduceCompanionMenu(state, action);
100
+ if (state.done) {
101
+ cleanup();
102
+ resolve(state.selected);
103
+ return;
104
+ }
105
+ draw();
106
+ };
107
+
108
+ input.on('keypress', onKey);
109
+ draw();
110
+ });
111
+ }
package/src/init.mjs CHANGED
@@ -2,8 +2,9 @@
2
2
  // Creates the vault taxonomy, NON-DESTRUCTIVELY merges the session hooks +
3
3
  // OBSIDIAN_VAULT_PATH into .claude/settings.json, and adds the mcpvault server to
4
4
  // .mcp.json. Idempotent: re-running only adds what is missing.
5
+ import { spawnSync } from 'node:child_process';
5
6
  import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
6
- import { isAbsolute, join, resolve } from 'node:path';
7
+ import { basename, isAbsolute, join, resolve } from 'node:path';
7
8
  import { createInterface } from 'node:readline/promises';
8
9
  import {
9
10
  VAULT_FOLDERS,
@@ -11,7 +12,16 @@ import {
11
12
  MCP_SERVER_KEY,
12
13
  mcpServerEntry,
13
14
  hookCommand,
15
+ deriveVaultDirName,
16
+ COMPANIONS,
17
+ resolveCompanions,
18
+ companionSettingsPatch,
19
+ companionMcpPatch,
20
+ companionHookSpecs,
21
+ cavemanInstallerCommand,
14
22
  } from './taxonomy.mjs';
23
+ import { renderVaultReadme } from './vault-readme.mjs';
24
+ import { canInteractiveSelect, selectCompanionsInteractive } from './companion-select.mjs';
15
25
 
16
26
  function parseArgs(argv) {
17
27
  const args = { mcp: true, yes: false, force: false };
@@ -22,6 +32,9 @@ function parseArgs(argv) {
22
32
  else if (a === '--no-mcp') args.mcp = false;
23
33
  else if (a === '--yes' || a === '-y') args.yes = true;
24
34
  else if (a === '--force') args.force = true;
35
+ else if (a === '--no-companions') args.noCompanions = true;
36
+ else if (a === '--companions') args.companions = argv[++i];
37
+ else if (a.startsWith('--companions=')) args.companions = a.slice(13);
25
38
  else if (a.startsWith('--vault=')) args.vault = a.slice(8);
26
39
  else if (a.startsWith('--project=')) args.project = a.slice(10);
27
40
  }
@@ -57,11 +70,12 @@ function backup(path) {
57
70
 
58
71
  // --- merges -----------------------------------------------------------------
59
72
 
60
- function mergeSettings(existing, { vaultPath, withMcp, force }) {
73
+ export function mergeSettings(existing, { vaultPath, withMcp, force, companions = [] }) {
61
74
  const s = existing && typeof existing === 'object' ? { ...existing } : {};
62
75
  s.hooks = { ...(s.hooks || {}) };
63
76
  let added = 0;
64
- for (const h of SESSION_HOOKS) {
77
+ // wendkeep's own session hooks + any companion-authored hooks (e.g. understand-inject).
78
+ for (const h of [...SESSION_HOOKS, ...companionHookSpecs(companions)]) {
65
79
  const command = hookCommand(h.name);
66
80
  const groups = Array.isArray(s.hooks[h.event]) ? [...s.hooks[h.event]] : [];
67
81
  const present = groups.some((g) => (g.hooks || []).some((x) => x.command === command));
@@ -76,18 +90,31 @@ function mergeSettings(existing, { vaultPath, withMcp, force }) {
76
90
  added += 1;
77
91
  }
78
92
  s.env = { ...(s.env || {}), OBSIDIAN_VAULT_PATH: vaultPath };
79
- if (withMcp) {
93
+
94
+ // Claude Code plugin layer for the selected companions (additive, non-clobbering).
95
+ const cp = companionSettingsPatch(companions);
96
+ if (Object.keys(cp.extraKnownMarketplaces).length) {
97
+ s.extraKnownMarketplaces = { ...(s.extraKnownMarketplaces || {}), ...cp.extraKnownMarketplaces };
98
+ }
99
+ if (Object.keys(cp.enabledPlugins).length) {
100
+ s.enabledPlugins = { ...(s.enabledPlugins || {}), ...cp.enabledPlugins };
101
+ }
102
+
103
+ const companionMcp = Object.keys(companionMcpPatch(companions));
104
+ if (withMcp || companionMcp.length) {
80
105
  const set = new Set(Array.isArray(s.enabledMcpjsonServers) ? s.enabledMcpjsonServers : []);
81
- set.add(MCP_SERVER_KEY);
106
+ if (withMcp) set.add(MCP_SERVER_KEY);
107
+ for (const key of companionMcp) set.add(key);
82
108
  s.enabledMcpjsonServers = [...set];
83
109
  }
84
110
  return { settings: s, added };
85
111
  }
86
112
 
87
- function mergeMcp(existing, vaultPath) {
113
+ export function mergeMcp(existing, { vaultPath, withVault = true, companions = [] }) {
88
114
  const m = existing && typeof existing === 'object' ? { ...existing } : {};
89
115
  m.mcpServers = { ...(m.mcpServers || {}) };
90
- m.mcpServers[MCP_SERVER_KEY] = mcpServerEntry(vaultPath);
116
+ if (withVault) m.mcpServers[MCP_SERVER_KEY] = mcpServerEntry(vaultPath);
117
+ Object.assign(m.mcpServers, companionMcpPatch(companions));
91
118
  return m;
92
119
  }
93
120
 
@@ -100,10 +127,10 @@ export async function runInit(argv) {
100
127
 
101
128
  let vaultPath = args.vault;
102
129
  if (!vaultPath) {
103
- const fallback = join(projectPath, '.wendkeep-vault');
130
+ const fallback = join(projectPath, deriveVaultDirName(projectPath));
104
131
  if (process.stdin.isTTY && !args.yes) {
105
132
  const rl = createInterface({ input: process.stdin, output: process.stdout });
106
- const ans = (await rl.question(`Obsidian vault path [${fallback}]: `)).trim();
133
+ const ans = (await rl.question(`Obsidian vault path (Enter aceita o padrão, ou digite outro caminho)\n [${fallback}]\n> `)).trim();
107
134
  rl.close();
108
135
  vaultPath = ans || fallback;
109
136
  } else {
@@ -112,10 +139,38 @@ export async function runInit(argv) {
112
139
  }
113
140
  vaultPath = isAbsolute(vaultPath) ? vaultPath : resolve(projectPath, vaultPath);
114
141
 
142
+ // Companion plugins/MCP selection. --no-companions wins; --companions <csv> is
143
+ // explicit; an interactive TTY gets a multi-choice prompt (context-mode pre-checked);
144
+ // otherwise the non-interactive default (context-mode only).
145
+ let companions;
146
+ if (args.noCompanions) {
147
+ companions = [];
148
+ } else if (args.companions !== undefined) {
149
+ companions = resolveCompanions({ companionsFlag: args.companions });
150
+ } else if (process.stdin.isTTY && !args.yes) {
151
+ log('\nCompanions (plugins/MCP opcionais — context-mode é a principal):');
152
+ if (canInteractiveSelect()) {
153
+ companions = await selectCompanionsInteractive(COMPANIONS);
154
+ } else {
155
+ // Text fallback (no raw-mode TTY): list + comma input with clear instructions.
156
+ for (const c of COMPANIONS) log(` ${c.default ? '[x]' : '[ ]'} ${c.label}`);
157
+ const def = resolveCompanions({}).join(',');
158
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
159
+ const ans = (await rl.question(
160
+ `Digite os ids separados por vírgula (Enter aceita [${def}], "none" p/ nenhum): `,
161
+ )).trim();
162
+ rl.close();
163
+ companions = ans.toLowerCase() === 'none' ? [] : resolveCompanions({ companionsFlag: ans || def });
164
+ }
165
+ } else {
166
+ companions = resolveCompanions({});
167
+ }
168
+
115
169
  log('\nwendkeep init');
116
- log(` project : ${projectPath}`);
117
- log(` vault : ${vaultPath}`);
118
- log(` mcp : ${args.mcp ? 'mcpvault (wendkeep-vault)' : 'skipped'}\n`);
170
+ log(` project : ${projectPath}`);
171
+ log(` vault : ${vaultPath}`);
172
+ log(` mcp : ${args.mcp ? 'mcpvault (wendkeep-vault)' : 'skipped'}`);
173
+ log(` companions : ${companions.length ? companions.join(', ') : 'none'}\n`);
119
174
 
120
175
  // 1. Vault taxonomy ---------------------------------------------------------
121
176
  if (!existsSync(vaultPath)) mkdirSync(vaultPath, { recursive: true });
@@ -127,39 +182,66 @@ export async function runInit(argv) {
127
182
  created += 1;
128
183
  }
129
184
  }
130
- log(` [1/3] vault taxonomy: ${VAULT_FOLDERS.length} folders (${created} created)`);
185
+ // Generate a project-templated vault README (non-destructive: never clobber one).
186
+ const readmePath = join(vaultPath, 'README.md');
187
+ let readmeNote = '';
188
+ if (!existsSync(readmePath)) {
189
+ writeFileSync(readmePath, renderVaultReadme({ projectName: basename(projectPath), vaultPath, withMcp: args.mcp }), 'utf8');
190
+ readmeNote = ', README.md created';
191
+ }
192
+ log(` [1/3] vault taxonomy: ${VAULT_FOLDERS.length} folders (${created} created)${readmeNote}`);
131
193
 
132
194
  // 2. .claude/settings.json --------------------------------------------------
133
195
  const settingsPath = join(projectPath, '.claude', 'settings.json');
134
196
  const settingsRead = readJsonSafe(settingsPath);
135
197
  if (!settingsRead.ok) {
136
198
  // Unparseable existing file — never clobber. Drop a .new for manual merge.
137
- const fresh = mergeSettings(null, { vaultPath, withMcp: args.mcp, force: true }).settings;
199
+ const fresh = mergeSettings(null, { vaultPath, withMcp: args.mcp, force: true, companions }).settings;
138
200
  writeJson(`${settingsPath}.new`, fresh);
139
201
  log(` [2/3] settings.json exists but is not valid JSON -> wrote ${settingsPath}.new (merge by hand)`);
140
202
  } else {
141
203
  const hadFile = settingsRead.data !== null;
142
- const { settings, added } = mergeSettings(settingsRead.data, { vaultPath, withMcp: args.mcp, force: args.force });
204
+ const { settings, added } = mergeSettings(settingsRead.data, { vaultPath, withMcp: args.mcp, force: args.force, companions });
143
205
  if (hadFile) backup(settingsPath);
144
206
  writeJson(settingsPath, settings);
145
207
  log(` [2/3] settings.json ${hadFile ? 'merged' : 'created'} (${added} hook(s) wired, OBSIDIAN_VAULT_PATH set${hadFile ? ', .bak saved' : ''})`);
146
208
  }
147
209
 
148
210
  // 3. .mcp.json --------------------------------------------------------------
149
- if (args.mcp) {
211
+ // Written when mcpvault is wanted OR a selected companion ships an MCP server.
212
+ const companionMcp = companionMcpPatch(companions);
213
+ if (args.mcp || Object.keys(companionMcp).length) {
150
214
  const mcpPath = join(projectPath, '.mcp.json');
151
215
  const mcpRead = readJsonSafe(mcpPath);
216
+ const mcpOpts = { vaultPath, withVault: args.mcp, companions };
217
+ const names = [args.mcp && MCP_SERVER_KEY, ...Object.keys(companionMcp)].filter(Boolean).join(', ');
152
218
  if (!mcpRead.ok) {
153
- writeJson(`${mcpPath}.new`, mergeMcp(null, vaultPath));
219
+ writeJson(`${mcpPath}.new`, mergeMcp(null, mcpOpts));
154
220
  log(` [3/3] .mcp.json exists but is not valid JSON -> wrote ${mcpPath}.new (merge by hand)`);
155
221
  } else {
156
222
  const hadFile = mcpRead.data !== null;
157
223
  if (hadFile) backup(mcpPath);
158
- writeJson(mcpPath, mergeMcp(mcpRead.data, vaultPath));
159
- log(` [3/3] .mcp.json ${hadFile ? 'merged' : 'created'} (wendkeep-vault server${hadFile ? ', .bak saved' : ''})`);
224
+ writeJson(mcpPath, mergeMcp(mcpRead.data, mcpOpts));
225
+ log(` [3/3] .mcp.json ${hadFile ? 'merged' : 'created'} (${names}${hadFile ? ', .bak saved' : ''})`);
160
226
  }
161
227
  } else {
162
- log(' [3/3] .mcp.json skipped (--no-mcp)');
228
+ log(' [3/3] .mcp.json skipped (--no-mcp, no MCP companions)');
229
+ }
230
+
231
+ // caveman has no MCP / agnostic-hook path: on non-Claude agents its skills come
232
+ // from its own cross-agent installer. Best-effort, fail-soft — the Claude Code
233
+ // plugin entry is already wired in settings.json regardless.
234
+ if (companions.includes('caveman')) {
235
+ const cmd = cavemanInstallerCommand();
236
+ log(`\n caveman: running cross-agent installer (best-effort): ${cmd.command} ${cmd.args.join(' ')}`);
237
+ try {
238
+ const r = spawnSync(cmd.command, cmd.args, { stdio: 'inherit' });
239
+ if (r.status !== 0) {
240
+ log(` [!] caveman installer exited ${r.status ?? '?'} — Claude Code plugin entry still wired; rerun manually if needed.`);
241
+ }
242
+ } catch (e) {
243
+ log(` [!] caveman installer could not run (${e.code || e.message}) — Claude Code plugin entry still wired.`);
244
+ }
163
245
  }
164
246
 
165
247
  log('\nNext steps:');
package/src/taxonomy.mjs CHANGED
@@ -35,6 +35,7 @@ export const HOOK_FILES = [
35
35
  'brain-reindex.mjs',
36
36
  'session-backfill.mjs',
37
37
  'vault-health.mjs',
38
+ 'understand-inject.mjs',
38
39
  ];
39
40
 
40
41
  // Hook scripts that are safe to invoke directly via `wendkeep hook <name>`.
@@ -49,6 +50,7 @@ export const RUNNABLE_HOOKS = [
49
50
  'brain-recall',
50
51
  'brain-reindex',
51
52
  'vault-health',
53
+ 'understand-inject',
52
54
  ];
53
55
 
54
56
  // The MCP server entry wendkeep wires into .mcp.json so the agent can read/write the
@@ -74,3 +76,134 @@ export const SESSION_HOOKS = [
74
76
  export function hookCommand(name) {
75
77
  return `npx wendkeep hook ${name}`;
76
78
  }
79
+
80
+ // --- companion plugins / MCP --------------------------------------------------
81
+ // Optional tools wendkeep init can pin alongside the vault. Each is wired through
82
+ // the MOST agent-agnostic mechanism it supports; the Claude Code plugin entry
83
+ // (extraKnownMarketplaces + enabledPlugins) is an additive bonus, never the base.
84
+ // - mcp: .mcp.json server entry (works on any MCP-capable agent)
85
+ // - wendkeepHook: a wendkeep-authored hook (runs via `npx wendkeep hook`, any agent)
86
+ // - installer: cross-agent install script (used on non-Claude agents)
87
+ // marketplace/plugin shapes are verified against a real ~/.claude/settings.json.
88
+ export const COMPANIONS = [
89
+ {
90
+ id: 'context-mode',
91
+ label: 'context-mode — otimizador de contexto + memória FTS5 (principal)',
92
+ default: true,
93
+ marketplace: { source: 'git', url: 'https://github.com/mksglu/context-mode.git' },
94
+ plugin: 'context-mode',
95
+ // Agent-agnostic: MCP server, self-updating via unpinned npx.
96
+ mcp: { key: 'context-mode', entry: { type: 'stdio', command: 'npx', args: ['-y', 'context-mode'] } },
97
+ },
98
+ {
99
+ id: 'understand-anything',
100
+ label: 'Understand-Anything — domain-graph do projeto',
101
+ default: false,
102
+ marketplace: { source: 'github', repo: 'Egonex-AI/Understand-Anything' },
103
+ plugin: 'understand-anything',
104
+ // No native SessionStart: wendkeep authors a conditional domain-graph injector.
105
+ wendkeepHook: 'understand-inject',
106
+ },
107
+ {
108
+ id: 'caveman',
109
+ label: 'caveman — modo compressão de tokens',
110
+ default: false,
111
+ marketplace: { source: 'git', url: 'https://github.com/JuliusBrussee/caveman.git' },
112
+ plugin: 'caveman',
113
+ // No MCP, no agnostic hook: on non-Claude agents, run its own installer.
114
+ installer: {
115
+ sh: 'https://raw.githubusercontent.com/JuliusBrussee/caveman/main/install.sh',
116
+ ps1: 'https://raw.githubusercontent.com/JuliusBrussee/caveman/main/install.ps1',
117
+ },
118
+ },
119
+ ];
120
+
121
+ const COMPANION_BY_ID = Object.fromEntries(COMPANIONS.map((c) => [c.id, c]));
122
+
123
+ // Resolve the companion ids for the NON-interactive path (the interactive prompt
124
+ // supplies its own selection). --no-companions wins; an explicit flag selects the
125
+ // named ids (unknown dropped) in registry order; otherwise the defaults.
126
+ export function resolveCompanions({ companionsFlag, noCompanions } = {}) {
127
+ if (noCompanions) return [];
128
+ let wanted;
129
+ if (typeof companionsFlag === 'string' && companionsFlag.trim()) {
130
+ const set = new Set(companionsFlag.split(',').map((s) => s.trim()).filter(Boolean));
131
+ wanted = (id) => set.has(id);
132
+ } else {
133
+ wanted = (id) => COMPANION_BY_ID[id]?.default === true;
134
+ }
135
+ return COMPANIONS.filter((c) => wanted(c.id)).map((c) => c.id);
136
+ }
137
+
138
+ // The settings.json fragment (Claude Code plugin layer) for the selected companions.
139
+ export function companionSettingsPatch(ids) {
140
+ const extraKnownMarketplaces = {};
141
+ const enabledPlugins = {};
142
+ for (const id of ids) {
143
+ const c = COMPANION_BY_ID[id];
144
+ if (!c) continue;
145
+ extraKnownMarketplaces[c.id] = { source: c.marketplace };
146
+ enabledPlugins[`${c.plugin}@${c.id}`] = true;
147
+ }
148
+ return { extraKnownMarketplaces, enabledPlugins };
149
+ }
150
+
151
+ // The .mcp.json fragment (agent-agnostic layer) for MCP-capable companions only.
152
+ export function companionMcpPatch(ids) {
153
+ const servers = {};
154
+ for (const id of ids) {
155
+ const c = COMPANION_BY_ID[id];
156
+ if (c?.mcp) servers[c.mcp.key] = c.mcp.entry;
157
+ }
158
+ return servers;
159
+ }
160
+
161
+ // SessionStart hook specs wendkeep must author for companions that lack a native
162
+ // one (only Understand-Anything's domain-graph injector today). Same shape as
163
+ // SESSION_HOOKS so the same merge logic wires them.
164
+ export function companionHookSpecs(ids) {
165
+ const specs = [];
166
+ for (const id of ids) {
167
+ const c = COMPANION_BY_ID[id];
168
+ if (c?.wendkeepHook) {
169
+ specs.push({
170
+ event: 'SessionStart',
171
+ matcher: null,
172
+ name: c.wendkeepHook,
173
+ timeout: 15,
174
+ statusMessage: `wendkeep: ${c.id} domain graph`,
175
+ });
176
+ }
177
+ }
178
+ return specs;
179
+ }
180
+
181
+ // Build the platform-appropriate command to run caveman's own cross-agent installer
182
+ // (used on non-Claude agents — caveman has no MCP/agnostic-hook path). Pure: returns
183
+ // {command, args}; the caller decides whether to spawn it.
184
+ export function cavemanInstallerCommand(platform = process.platform) {
185
+ const { sh, ps1 } = COMPANION_BY_ID.caveman.installer;
186
+ if (platform === 'win32') {
187
+ return { command: 'powershell', args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', `irm ${ps1} | iex`] };
188
+ }
189
+ return { command: 'bash', args: ['-c', `curl -fsSL ${sh} | bash`] };
190
+ }
191
+
192
+ // Derive the default vault folder name from the project folder: `.<project>-vault`.
193
+ // Splits on both separators so it is correct regardless of host OS, sanitizes
194
+ // filesystem-unsafe characters, preserves case, and falls back to a stable name
195
+ // when the basename is empty (root paths). Pure + side-effect free.
196
+ export function deriveVaultDirName(projectPath) {
197
+ const base = String(projectPath || '')
198
+ .replace(/[\\/]+$/, '')
199
+ .split(/[\\/]/)
200
+ .pop() || '';
201
+ const clean = base
202
+ .replace(/^[.\s]+/, '') // drop leading dots/space so we never get `..name`
203
+ .replace(/[<>:"/\\|?*-]/g, '-') // FS-unsafe chars -> dash
204
+ .replace(/\s+/g, '-') // whitespace -> dash
205
+ .replace(/-+/g, '-') // collapse dash runs
206
+ .replace(/^-+|-+$/g, '') // trim edge dashes
207
+ .replace(/\.+$/, ''); // trim trailing dots
208
+ return clean ? `.${clean}-vault` : '.wendkeep-vault';
209
+ }
@@ -0,0 +1,73 @@
1
+ // Renders a neutral, project-templated README for a freshly created wendkeep vault.
2
+ // The structure table is derived from VAULT_FOLDERS so it can never drift from the
3
+ // folders `wendkeep init` actually creates.
4
+ import { VAULT_FOLDERS } from './taxonomy.mjs';
5
+
6
+ const FOLDER_DESCRIPTIONS = {
7
+ '00-Inbox': 'Notas rápidas, captura sem classificação',
8
+ '01-Projeto': 'Arquitetura, padrões, memória do projeto',
9
+ '02-Sessões': 'Uma nota por sessão de trabalho com o agente IA',
10
+ '03-Linear': 'Espelho de issues/tarefas do seu tracker (Linear, Jira, GitHub Issues…)',
11
+ '04-Decisões': 'Architecture Decision Records (ADRs)',
12
+ '05-Bugs': 'Bugs resolvidos com causa raiz documentada',
13
+ '06-Aprendizados': 'Lições extraídas das sessões',
14
+ Templates: 'Templates para novas notas',
15
+ '.brain': 'Memória canônica: CORE.md (curado) + DIGEST.md (auto), injetada no SessionStart',
16
+ };
17
+
18
+ function structureTable() {
19
+ const rows = VAULT_FOLDERS.map((f) => {
20
+ const desc = FOLDER_DESCRIPTIONS[f] || 'Notas do projeto';
21
+ return `| \`${f}/\` | ${desc} |`;
22
+ });
23
+ return ['| Pasta | Conteúdo |', '| --- | --- |', ...rows].join('\n');
24
+ }
25
+
26
+ export function renderVaultReadme({ projectName, vaultPath, withMcp = true }) {
27
+ const name = projectName || 'projeto';
28
+ const mcpIntro = withMcp ? ', e lida/escrita pelo MCP server **MCPVault**' : '';
29
+
30
+ const access = [
31
+ `- **Obsidian:** abra esta pasta com "Open folder as vault" → \`${vaultPath}\``,
32
+ ];
33
+ if (withMcp) {
34
+ access.push(
35
+ '- **Agente (MCP):** o servidor `wendkeep-vault` (MCPVault) é apontado para este vault\n' +
36
+ ' pelo `wendkeep init` (em `.mcp.json`), dando ao agente leitura/escrita das notas.',
37
+ );
38
+ }
39
+ access.push(
40
+ '- **Hooks:** `settings.json` chama `npx wendkeep hook <name>`; o vault é localizado\n' +
41
+ ' via a env `OBSIDIAN_VAULT_PATH` (também gravada pelo `wendkeep init`).',
42
+ );
43
+
44
+ return `# Vault Obsidian — ${name}
45
+
46
+ > Base de conhecimento de **${name}**, capturada automaticamente pelo wendkeep a
47
+ > partir das sessões dos agentes de código IA (**Claude Code** e **Codex**) via
48
+ > hooks de sessão${mcpIntro}.
49
+
50
+ ## Estrutura
51
+
52
+ ${structureTable()}
53
+
54
+ ## Acesso
55
+
56
+ ${access.join('\n')}
57
+
58
+ ## Regras
59
+
60
+ 1. Toda sessão de trabalho relevante gera nota em \`02-Sessões/\` (automático, pelos hooks).
61
+ 2. Decisões de arquitetura viram ADR em \`04-Decisões/\`.
62
+ 3. Bugs com causa raiz vão para \`05-Bugs/\`; lições para \`06-Aprendizados/\`.
63
+ 4. Memória canônica: \`.brain/CORE.md\` (curado) + \`.brain/DIGEST.md\` (auto) — injetados no SessionStart.
64
+ 5. Tags em frontmatter YAML, não inline.
65
+ 6. Wikilinks \`[[nota]]\` para conectar notas entre si.
66
+
67
+ ## Notas
68
+
69
+ - **Idioma das pastas:** os nomes são PT-BR (limitação conhecida; i18n no roadmap do wendkeep).
70
+ - **Versionamento:** as notas de sessão podem conter trechos de transcript — avalie
71
+ antes de commitar este vault em um repositório compartilhado.
72
+ `;
73
+ }