wendkeep 0.38.2 → 0.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,46 @@ All notable changes to **wendkeep** are documented here. Format based on
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this project follows
5
5
  [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.39.0] — 2026-07-13
8
+
9
+ ### Added
10
+
11
+ - Configuração provider-neutral `.wendkeep.json`, descoberta do diretório da sessão para os
12
+ pais, permitindo que Codex e Claude Code resolvam o mesmo vault sem variável global do
13
+ Windows. Caminhos relativos ao projeto e caminhos absolutos são suportados.
14
+ - Identidade estável `projectId`, espelhada em `.brain/PROJECT.json`; vínculos que apontem
15
+ para o vault de outro projeto são rejeitados antes de qualquer gravação.
16
+ - `doctor` informa caminho e origem do vínculo efetivo. Comandos CLI executados dentro do
17
+ projeto também descobrem o vault local, mantendo `--vault` como override explícito.
18
+
19
+ ### Changed
20
+
21
+ - `wendkeep init` cria o vínculo projeto→vault de forma idempotente e adota instalações
22
+ antigas registradas em `.claude/settings.json`, sem mover ou dividir o histórico.
23
+ - Um vínculo local sempre vence um `OBSIDIAN_VAULT_PATH` herdado pelo processo. A variável
24
+ permanece apenas para compatibilidade de comandos manuais legados.
25
+
26
+ ### Fixed
27
+
28
+ - Hooks do Codex deixam de gravar sessões no vault doméstico `~/wendkeep-vault` quando o
29
+ processo não recebe o ambiente privado do Claude Code. Sem vínculo local ou payload
30
+ explícito, o hook agora falha de modo seguro, emite diagnóstico e não cria arquivos.
31
+ - Projetos simultâneos deixam de compartilhar acidentalmente sessões, mudanças e grafo por
32
+ causa de uma variável global de usuário apontando para um único vault.
33
+
34
+ ### Migration
35
+
36
+ - Após atualizar, execute uma vez `wendkeep init --project . --vault <vault> --yes`, depois
37
+ `wendkeep sync-defs --project . --reseed` e `wendkeep doctor --project .`; reinicie Codex e
38
+ Claude Code para recarregar os artefatos gerados.
39
+
40
+ ## [0.38.3] — 2026-07-12
41
+
42
+ ### Fixed
43
+
44
+ - Sessões do Codex Desktop voltam a ser criadas quando `SessionStart` ou o primeiro `UserPromptSubmit` não fornecem `transcript_path`: o resolvedor usa o UUID canônico de `CODEX_THREAD_ID` e associa o rollout à mesma entrada assim que o transcript materializa.
45
+ - A barreira cross-provider permanece fail-closed: quando `CODEX_THREAD_ID` e `session_meta.payload.session_id` estão presentes, divergência entre eles adia a escrita em vez de contaminar outra sessão.
46
+
7
47
  ## [0.38.2] — 2026-07-12
8
48
 
9
49
  ### Fixed
package/README.md CHANGED
@@ -60,7 +60,7 @@ npx wendkeep init
60
60
  `wendkeep init` is interactive and **idempotent**. It will:
61
61
 
62
62
  1. Create the vault folder taxonomy and a templated `README.md` (default vault: `<project>/.<project-name>-vault`, e.g. `.MyApp-vault`; override with `--vault`).
63
- 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).
63
+ 2. Write a provider-neutral **`.wendkeep.json`** binding at the project root and a matching `.brain/PROJECT.json` marker in the vault. Codex and Claude Code discover the same vault from their session `cwd`; no machine-global environment variable is required. Existing `.claude/settings.json` registrations are adopted automatically.
64
64
  3. Add the **`wendkeep-vault`** MCP server to `.mcp.json` so the agent can read/write the vault. Skip with `--no-mcp` — e.g. when the agent already has a vault MCP. (`--no-mcp` skips *only wendkeep's own* MCP; companion MCPs still follow `--companions`.)
65
65
  4. Offer to pin **companion** plugins/MCP (multi-choice; only `context-mode` pre-checked). Each is wired the most agent-agnostic way it supports:
66
66
  - **`context-mode`** — context optimizer + FTS5 memory, as a `.mcp.json` MCP server (any agent). The recommended default.
@@ -96,15 +96,32 @@ npx wendkeep init --no-companions --no-mcp --yes # zero companions,
96
96
 
97
97
  Then open the vault in Obsidian, send a test prompt in your agent, and confirm a note appears under `02-Sessões/…` (or `02-Sessions/…` for an `en` vault).
98
98
 
99
+ ### Project isolation
100
+
101
+ Each project owns a `.wendkeep.json` containing a stable `projectId` and its vault path.
102
+ Relative paths (for example `.NutriGymBrain`) are resolved from the project root; absolute
103
+ paths are also supported. Hooks search upward from the agent's `cwd`, so nested packages use
104
+ the nearest binding. The vault carries the same identity in `.brain/PROJECT.json`; a mismatch
105
+ is rejected before any session is written. If no binding exists, hooks fail closed and never
106
+ create the historical `~/wendkeep-vault` fallback.
107
+
108
+ `OBSIDIAN_VAULT_PATH` remains only as legacy/manual CLI compatibility. It is not used to
109
+ route automatic Codex or Claude hooks and a project-local binding overrides an inherited
110
+ machine value.
111
+
99
112
  ## Updating
100
113
 
101
- Because the hooks live inside the installed package (settings.json calls `npx wendkeep hook <name>`), upgrading is just:
114
+ Because the hooks live inside the installed package, upgrade the package and re-run the
115
+ idempotent init. The init step creates/migrates the provider-neutral project binding:
102
116
 
103
117
  ```bash
104
- npm update wendkeep
118
+ npm install --save-dev wendkeep@latest
119
+ npx --no-install wendkeep init --project . --vault <your-vault> --yes
120
+ npx --no-install wendkeep sync-defs --project . --reseed
121
+ npx --no-install wendkeep doctor --project .
105
122
  ```
106
123
 
107
- No re‑copying, no snapshot to re‑sync the package is the single source of truth.
124
+ Restart Codex and Claude Code after reseeding their generated skills.
108
125
 
109
126
  ## Commands
110
127
 
package/README.pt-BR.md CHANGED
@@ -58,7 +58,7 @@ npx wendkeep init
58
58
  O `wendkeep init` é interativo e **idempotente**. Ele:
59
59
 
60
60
  1. Cria a taxonomia de pastas do cofre e um `README.md` templado (cofre padrão: `<projeto>/.<nome-do-projeto>-vault`, ex.: `.MeuApp-vault`; sobrescreva com `--vault`).
61
- 2. **Mescla** os três hooks de sessão e o `OBSIDIAN_VAULT_PATH` no `.claude/settings.json` sem atropelar suas configs (salva um `.bak`; arquivo ilegível fica intocado e um `.new` é escrito pra você mesclar).
61
+ 2. Grava um vínculo provider-neutral **`.wendkeep.json`** na raiz do projeto e o marcador correspondente `.brain/PROJECT.json` no cofre. Codex e Claude Code descobrem o mesmo vault pelo `cwd` da sessão, sem variável global do Windows. Registros antigos em `.claude/settings.json` são adotados automaticamente.
62
62
  3. Adiciona o servidor MCP **`wendkeep-vault`** ao `.mcp.json` pro agente ler/escrever o cofre. Pule com `--no-mcp` — ex.: quando o agente já tem um MCP de cofre. (`--no-mcp` pula *só o MCP do próprio wendkeep*; os MCPs de companion seguem `--companions`.)
63
63
  4. Oferece fixar plugins/MCP **companion** (múltipla escolha; **nenhum** pré-marcado). Cada um é wirado do jeito mais agnóstico que suporta:
64
64
  - **`context-mode`** — otimizador de contexto + memória FTS5, como servidor MCP no `.mcp.json` (qualquer agente).
@@ -94,15 +94,31 @@ npx wendkeep init --no-companions --no-mcp --yes # zero companions,
94
94
 
95
95
  Depois abra o cofre no Obsidian, mande um prompt de teste no seu agente e confirme que uma nota aparece em `02-Sessões/…` (ou `02-Sessions/…` num cofre `en`).
96
96
 
97
+ ### Isolamento por projeto
98
+
99
+ Cada projeto possui um `.wendkeep.json` com `projectId` estável e caminho do vault. Caminhos
100
+ relativos, como `.NutriGymBrain`, partem da raiz do projeto; caminhos absolutos também são
101
+ aceitos. Os hooks procuram o vínculo mais próximo subindo a partir do `cwd`. O vault guarda a
102
+ mesma identidade em `.brain/PROJECT.json`, e uma divergência bloqueia a escrita. Sem vínculo,
103
+ os hooks falham de modo seguro e nunca criam o antigo fallback `~/wendkeep-vault`.
104
+
105
+ `OBSIDIAN_VAULT_PATH` permanece somente como compatibilidade legada para comandos manuais.
106
+ Ele não roteia hooks automáticos do Codex ou Claude, e o vínculo local prevalece sobre uma
107
+ variável herdada da máquina.
108
+
97
109
  ## Atualizar
98
110
 
99
- Como os hooks vivem dentro do pacote instalado (o settings.json chama `npx wendkeep hook <name>`), atualizar é só:
111
+ Como os hooks vivem dentro do pacote instalado, atualize e rode novamente o `init`
112
+ idempotente. Essa etapa cria ou migra o vínculo provider-neutral:
100
113
 
101
114
  ```bash
102
- npm update wendkeep
115
+ npm install --save-dev wendkeep@latest
116
+ npx --no-install wendkeep init --project . --vault <seu-vault> --yes
117
+ npx --no-install wendkeep sync-defs --project . --reseed
118
+ npx --no-install wendkeep doctor --project .
103
119
  ```
104
120
 
105
- Sem recopiar, sem snapshot pra re‑sincronizar o pacote é a única fonte da verdade.
121
+ Reinicie Codex e Claude Code depois de resemear as skills geradas.
106
122
 
107
123
  ## Comandos
108
124
 
package/bin/wendkeep.mjs CHANGED
@@ -102,8 +102,32 @@ function runHook(name) {
102
102
  process.exit(r.status ?? 0);
103
103
  }
104
104
 
105
+ function optionValue(argv, name) {
106
+ const index = argv.indexOf(name);
107
+ if (index >= 0) return argv[index + 1] || '';
108
+ return argv.find((item) => item.startsWith(`${name}=`))?.slice(name.length + 1) || '';
109
+ }
110
+
111
+ async function preferProjectVault(argv) {
112
+ // Existing command modules still consume OBSIDIAN_VAULT_PATH internally. Populate it
113
+ // only inside this CLI process from the provider-neutral project binding, overriding
114
+ // any inherited machine-global value. An explicit --vault remains authoritative.
115
+ if (optionValue(argv, '--vault')) return;
116
+ try {
117
+ const { resolveProjectVault } = await import('../src/project-vault.mjs');
118
+ const resolved = resolveProjectVault({ startDir: optionValue(argv, '--project') || process.cwd() });
119
+ process.env.OBSIDIAN_VAULT_PATH = resolved.base;
120
+ } catch {
121
+ // Backward-compatible manual CLI behavior: individual commands still explain
122
+ // --vault / legacy env when no project binding exists. Hooks do not use this path.
123
+ }
124
+ }
125
+
105
126
  async function main() {
106
127
  const [cmd, ...rest] = process.argv.slice(2);
128
+ if (cmd && !['init', 'hook', '--version', '-v', '--help', '-h', 'help'].includes(cmd)) {
129
+ await preferProjectVault(rest);
130
+ }
107
131
  switch (cmd) {
108
132
  case 'init': {
109
133
  const { runInit } = await import('../src/init.mjs');
@@ -2,9 +2,10 @@
2
2
  import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'fs';
3
3
  import { basename, dirname, join, relative } from 'path';
4
4
  import { getLocale } from './locale.mjs';
5
+ import { resolveProjectVault } from '../src/project-vault.mjs';
5
6
 
6
- // Neutral fallback only. The vault is normally resolved from the
7
- // OBSIDIAN_VAULT_PATH env var (set by `wendkeep init`) via getVaultBase() below.
7
+ // Deprecated export kept for consumers that imported it before 0.39.0. Automatic
8
+ // hooks never use this fallback: an unbound project fails closed.
8
9
  export const DEFAULT_VAULT_BASE = join(
9
10
  process.env.USERPROFILE || process.env.HOME || process.cwd(),
10
11
  'wendkeep-vault',
@@ -31,19 +32,12 @@ export function writeHookOutput(payload = {}) {
31
32
  process.stdout.write(JSON.stringify(payload));
32
33
  }
33
34
 
34
- // Resolve the vault and report WHERE the path came from, so callers can react to
35
- // an unconfigured install instead of silently writing to the home fallback.
36
- // env -> OBSIDIAN_VAULT_PATH (set by `wendkeep init`)
37
- // payload -> obsidian_vault_path from the hook's JSON input
38
- // default -> DEFAULT_VAULT_BASE (~/wendkeep-vault) — a phantom vault nobody opened
35
+ // Resolve from explicit hook payload or the nearest project-local binding. A legacy
36
+ // `.claude/settings.json` registration is accepted as a migration bridge so Codex can
37
+ // discover projects initialized by older WendKeep versions. Global process env and the
38
+ // historical home fallback are intentionally excluded to prevent cross-project writes.
39
39
  export function resolveVault(input = {}) {
40
- if (process.env.OBSIDIAN_VAULT_PATH) {
41
- return { base: process.env.OBSIDIAN_VAULT_PATH, source: 'env' };
42
- }
43
- if (input && input.obsidian_vault_path) {
44
- return { base: input.obsidian_vault_path, source: 'payload' };
45
- }
46
- return { base: DEFAULT_VAULT_BASE, source: 'default' };
40
+ return resolveProjectVault({ input });
47
41
  }
48
42
 
49
43
  export function getVaultBase(input = {}) {
@@ -60,16 +54,15 @@ export function debugLog(...args) {
60
54
  process.stderr.write(`[wendkeep] ${text}\n`);
61
55
  }
62
56
 
63
- // Warn loudly (stderr) when the vault resolved to the home fallback — i.e. neither
64
- // OBSIDIAN_VAULT_PATH nor a payload path was provided. Without this the hooks write
65
- // notes into ~/wendkeep-vault with zero signal that the install is misconfigured.
66
- // Returns the resolution source so callers can branch if they want.
57
+ // Migration warning for projects that are still discovered through Claude's local
58
+ // settings. Missing bindings throw before this point and are handled by each hook's
59
+ // fail-closed top-level catch.
67
60
  export function warnIfDefaultVault(input = {}) {
68
61
  const { base, source } = resolveVault(input);
69
- if (source === 'default') {
62
+ if (source === 'legacy-project-settings') {
70
63
  process.stderr.write(
71
- `[wendkeep] WARNING: OBSIDIAN_VAULT_PATH não definido gravando no fallback "${base}". ` +
72
- 'Rode `wendkeep init` ou defina OBSIDIAN_VAULT_PATH apontando ao seu vault Obsidian.\n',
64
+ `[wendkeep] Configuração legada do vault detectada em .claude/settings.json ("${base}"). `
65
+ + 'Rode `wendkeep init --yes` para criar .wendkeep.json; Codex e Claude continuarão no mesmo vault.\n',
73
66
  );
74
67
  }
75
68
  return source;
@@ -39,10 +39,41 @@ function compatible(provider, transcriptProvider) {
39
39
  || (provider === 'claude' && transcriptProvider === 'anthropic');
40
40
  }
41
41
 
42
+ function canonicalUuid(value = '') {
43
+ const id = String(value || '').trim().toLowerCase();
44
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(id) ? id : '';
45
+ }
46
+
42
47
  export function resolveSessionIdentity(vaultBase, input = {}, provider = detectProvider()) {
43
48
  const transcriptPath = input.transcript_path || input.transcriptPath || '';
44
49
  const inspected = inspectTranscriptIdentity(transcriptPath);
45
50
  const hookId = input.session_id || input.sessionId || '';
51
+ const codexThreadId = canonicalUuid(input.codex_thread_id || input.codexThreadId || process.env.CODEX_THREAD_ID || '');
52
+
53
+ // Codex Desktop exposes the stable canonical conversation through
54
+ // CODEX_THREAD_ID even when SessionStart/UserPromptSubmit omit transcript_path.
55
+ // This is safer than the hook session_id (which may rotate on resume). Once the
56
+ // rollout materializes, require both canonical sources to agree.
57
+ const transcriptConversationId = canonicalUuid(inspected.canonicalConversationId);
58
+ if (provider === 'codex' && transcriptConversationId && codexThreadId
59
+ && transcriptConversationId !== codexThreadId) {
60
+ return {
61
+ state: 'deferred', provider, transcriptPath,
62
+ diagnostics: [`CODEX_THREAD_ID diverge do session_id do transcript (${codexThreadId} != ${transcriptConversationId})`],
63
+ };
64
+ }
65
+ if (provider === 'codex' && !inspected.canonicalConversationId && codexThreadId) {
66
+ return {
67
+ state: 'resolved',
68
+ provider,
69
+ canonicalConversationId: codexThreadId,
70
+ hookSessionId: hookId,
71
+ transcriptPath,
72
+ transcriptId: transcriptPath ? basename(transcriptPath, '.jsonl') : codexThreadId,
73
+ parentConversationId: '',
74
+ diagnostics: [],
75
+ };
76
+ }
46
77
 
47
78
  // Claude: input.session_id já é o id canônico e estável da conversa — idêntico
48
79
  // ao sessionId que cada linha do transcript grava. Numa sessão nova o arquivo
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wendkeep",
3
- "version": "0.38.2",
3
+ "version": "0.39.0",
4
4
  "description": "A persistent-memory harness for AI coding agents on your Obsidian vault: turn-by-turn session capture plus a native, zero-dependency spec→change→verify→archive loop (sensor-gated, independent verdict, mutation discrimination). Local-first, agent-agnostic (Claude Code, Codex, Cursor…).",
5
5
  "type": "module",
6
6
  "bin": {
@@ -19,7 +19,7 @@
19
19
  "node": ">=18"
20
20
  },
21
21
  "scripts": {
22
- "check": "node --check bin/wendkeep.mjs && node --check src/init.mjs && node --check src/doctor.mjs",
22
+ "check": "node --check bin/wendkeep.mjs && node --check src/init.mjs && node --check src/doctor.mjs && node --check src/project-vault.mjs",
23
23
  "test": "node --test",
24
24
  "release": "node scripts/release.mjs",
25
25
  "release:dry": "node scripts/release.mjs --dry-run"
@@ -46,6 +46,6 @@
46
46
  "url": "https://github.com/rogersialves/wendkeep/issues"
47
47
  },
48
48
  "devDependencies": {
49
- "wendkeep": "^0.38.1"
49
+ "wendkeep": "^0.38.3"
50
50
  }
51
51
  }
package/src/doctor.mjs CHANGED
@@ -2,10 +2,11 @@
2
2
  // harness integrity check (hooks/harness-doctor.mjs). Exits 1 on any error.
3
3
  import { spawnSync } from 'node:child_process';
4
4
  import { existsSync } from 'node:fs';
5
- import { dirname, isAbsolute, join, resolve } from 'node:path';
5
+ import { dirname, join, resolve } from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { checkHarness } from '../hooks/harness-doctor.mjs';
8
8
  import { checkSyncDefs } from './sync-defs.mjs';
9
+ import { resolveProjectVault } from './project-vault.mjs';
9
10
 
10
11
  export function runDoctor(argv) {
11
12
  const here = dirname(fileURLToPath(import.meta.url));
@@ -23,19 +24,28 @@ export function runDoctor(argv) {
23
24
  else passthrough.push(a);
24
25
  }
25
26
 
26
- const env = { ...process.env };
27
- if (vault) env.OBSIDIAN_VAULT_PATH = isAbsolute(vault) ? vault : resolve(process.cwd(), vault);
28
- if (!env.OBSIDIAN_VAULT_PATH) {
29
- process.stderr.write('wendkeep doctor: no vault. Pass --vault <path> or set OBSIDIAN_VAULT_PATH.\n');
27
+ const projectRoot = resolve(project || process.cwd());
28
+ let resolution;
29
+ try {
30
+ resolution = resolveProjectVault({
31
+ startDir: projectRoot,
32
+ explicitVault: vault || '',
33
+ validateIdentity: !vault,
34
+ });
35
+ } catch (error) {
36
+ process.stderr.write(`wendkeep doctor: ${error.message}\n`);
30
37
  process.exit(2);
31
38
  }
32
- const vaultBase = env.OBSIDIAN_VAULT_PATH;
33
- const projectRoot = resolve(project || process.cwd());
39
+ const vaultBase = resolution.base;
40
+ process.stdout.write(`[vault] ${resolution.source}: ${vaultBase} (project: ${projectRoot})\n`);
41
+ if (resolution.source === 'legacy-project-settings') {
42
+ process.stdout.write(' ! migração pendente: rode `wendkeep init --project . --vault "<vault>" --yes` para criar .wendkeep.json\n');
43
+ }
34
44
 
35
45
  // 1. Session/vault integrity (existing check).
36
46
  let healthStatus = 0;
37
47
  if (existsSync(hookFile)) {
38
- const r = spawnSync(process.execPath, [hookFile, ...passthrough], { stdio: 'inherit', env });
48
+ const r = spawnSync(process.execPath, [hookFile, ...passthrough, '--vault', vaultBase], { stdio: 'inherit' });
39
49
  healthStatus = r.status ?? 0;
40
50
  }
41
51
 
package/src/init.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  // `wendkeep init` — cross-platform replacement for setup-vault.ps1.
2
- // Creates the vault taxonomy, NON-DESTRUCTIVELY merges the session hooks +
3
- // OBSIDIAN_VAULT_PATH into .claude/settings.json, and adds the mcpvault server to
2
+ // Creates the vault taxonomy + provider-neutral project binding, NON-DESTRUCTIVELY
3
+ // merges the session hooks into .claude/settings.json, and adds the mcpvault server to
4
4
  // .mcp.json. Idempotent: re-running only adds what is missing.
5
5
  import { spawnSync } from 'node:child_process';
6
6
  import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
@@ -41,6 +41,7 @@ import { seedWkSkills } from './skills-seed.mjs';
41
41
  import { LOCALES, DEFAULT_LOCALE, getLocale, clearLocaleCache, vaultFolders } from '../hooks/locale.mjs';
42
42
  import { seedDotcontext, globalHasDotcontext, resolveDotcontextSkipMcp, renderSensorsJson } from './dotcontext-seed.mjs';
43
43
  import { adoptSpecsState, SPECS_STATE_FILE } from '../hooks/spec-core.mjs';
44
+ import { bindProjectVault, readProjectBinding } from './project-vault.mjs';
44
45
 
45
46
  function parseArgs(argv) {
46
47
  const args = { mcp: true, yes: false, force: false };
@@ -281,7 +282,7 @@ const MESSAGES = {
281
282
  readmeCreated: ', README.md criado', viewsNote: (n) => `, ${n} view(s) + dashboard`,
282
283
  defs: (s, a) => ` defs entregues: ${s} skill(s) -> .claude/skills + .agents/skills, ${a} agent(s) -> .codex/agents`,
283
284
  settingsBadJson: (p) => ` [2/4] settings.json existe mas não é JSON válido -> escrevi ${p}.new (mescle à mão)`,
284
- settings: (verb, added, bak) => ` [2/4] settings.json ${verb} (${added} hook(s) wirados, OBSIDIAN_VAULT_PATH setado${bak})`,
285
+ settings: (verb, added, bak) => ` [2/4] settings.json ${verb} (${added} hook(s) wirados; .wendkeep.json é o vínculo compartilhado${bak})`,
285
286
  mcpBadJson: (p) => ` [3/4] .mcp.json existe mas não é JSON válido -> escrevi ${p}.new (mescle à mão)`,
286
287
  mcp: (verb, names, bak) => ` [3/4] .mcp.json ${verb} (${names}${bak})`,
287
288
  mcpSkipped: ' [3/4] .mcp.json ignorado (--no-mcp, sem companions MCP)',
@@ -306,7 +307,7 @@ const MESSAGES = {
306
307
  readmeCreated: ', README.md created', viewsNote: (n) => `, ${n} view(s) + dashboard`,
307
308
  defs: (s, a) => ` defs delivered: ${s} skill(s) -> .claude/skills + .agents/skills, ${a} agent(s) -> .codex/agents`,
308
309
  settingsBadJson: (p) => ` [2/4] settings.json exists but is not valid JSON -> wrote ${p}.new (merge by hand)`,
309
- settings: (verb, added, bak) => ` [2/4] settings.json ${verb} (${added} hook(s) wired, OBSIDIAN_VAULT_PATH set${bak})`,
310
+ settings: (verb, added, bak) => ` [2/4] settings.json ${verb} (${added} hook(s) wired; .wendkeep.json is the shared binding${bak})`,
310
311
  mcpBadJson: (p) => ` [3/4] .mcp.json exists but is not valid JSON -> wrote ${p}.new (merge by hand)`,
311
312
  mcp: (verb, names, bak) => ` [3/4] .mcp.json ${verb} (${names}${bak})`,
312
313
  mcpSkipped: ' [3/4] .mcp.json skipped (--no-mcp, no MCP companions)',
@@ -334,9 +335,14 @@ export function parseLocaleAnswer(ans) {
334
335
  return 'pt-BR';
335
336
  }
336
337
 
337
- // The vault path this project was set up with read from .claude/settings.json's
338
- // OBSIDIAN_VAULT_PATH (written by a prior init). Empty when the project isn't configured yet.
338
+ // The vault path this project was set up with. New installs use the provider-neutral
339
+ // `.wendkeep.json`; `.claude/settings.json` remains a local migration bridge for projects
340
+ // initialized before 0.39.0.
339
341
  export function detectRegisteredVault(projectPath) {
342
+ try {
343
+ const binding = readProjectBinding(projectPath);
344
+ if (binding?.base) return binding.base;
345
+ } catch { /* init will surface/repair through an explicit --vault */ }
340
346
  try {
341
347
  const s = JSON.parse(readFileSync(join(projectPath, '.claude', 'settings.json'), 'utf8'));
342
348
  const v = s && s.env && s.env.OBSIDIAN_VAULT_PATH;
@@ -357,8 +363,8 @@ export async function runInit(argv) {
357
363
  const projectPath = resolve(args.project || process.cwd());
358
364
  const log = (s) => process.stdout.write(`${s}\n`);
359
365
 
360
- // Recognize an already-configured project: the vault is registered in the project's
361
- // settings.json (OBSIDIAN_VAULT_PATH) and its locale is locked in the vault's config.json.
366
+ // Recognize an already-configured project through `.wendkeep.json`, or through the
367
+ // project-local Claude setting during migration. Locale remains locked in vault config.
362
368
  // On re-run (e.g. after `npm i -D wendkeep@latest`) we reuse both and SKIP the language + vault
363
369
  // prompts — asking again risks a divergent vault from a mistyped name. `--vault` / `--locale`
364
370
  // override; the vault question is a once-per-project thing.
@@ -393,6 +399,10 @@ export async function runInit(argv) {
393
399
  }
394
400
  vaultPath = isAbsolute(vaultPath) ? vaultPath : resolve(projectPath, vaultPath);
395
401
 
402
+ // Persist the provider-neutral binding before any hook/config delivery. This also
403
+ // claims the vault identity and rejects accidental cross-project graph contamination.
404
+ bindProjectVault({ projectRoot: projectPath, vaultPath });
405
+
396
406
  // Companion plugins/MCP selection. --no-companions wins; --companions <csv> is
397
407
  // explicit; an interactive TTY gets a multi-choice prompt (context-mode pre-checked);
398
408
  // otherwise the non-interactive default (context-mode only).
@@ -0,0 +1,221 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import {
3
+ existsSync,
4
+ mkdirSync,
5
+ readFileSync,
6
+ renameSync,
7
+ statSync,
8
+ writeFileSync,
9
+ } from 'node:fs';
10
+ import { basename, dirname, isAbsolute, join, parse, relative, resolve } from 'node:path';
11
+
12
+ export const PROJECT_CONFIG_FILE = '.wendkeep.json';
13
+ export const PROJECT_MARKER_REL = '.brain/PROJECT.json';
14
+ export const PROJECT_CONFIG_SCHEMA = 1;
15
+
16
+ function json(path) {
17
+ try { return JSON.parse(readFileSync(path, 'utf8')); }
18
+ catch (error) {
19
+ const wrapped = new Error(`Configuração WendKeep inválida em "${path}": ${error.message}`);
20
+ wrapped.code = 'WENDKEEP_VAULT_CONFIG_INVALID';
21
+ throw wrapped;
22
+ }
23
+ }
24
+
25
+ function atomicJson(path, value) {
26
+ mkdirSync(dirname(path), { recursive: true });
27
+ const content = `${JSON.stringify(value, null, 2)}\n`;
28
+ if (existsSync(path) && readFileSync(path, 'utf8') === content) return false;
29
+ const temp = `${path}.${process.pid}.${Date.now()}.tmp`;
30
+ writeFileSync(temp, content, 'utf8');
31
+ renameSync(temp, path);
32
+ return true;
33
+ }
34
+
35
+ function startDirectory(value) {
36
+ const candidate = resolve(String(value || process.cwd()));
37
+ try { return statSync(candidate).isFile() ? dirname(candidate) : candidate; }
38
+ catch { return candidate; }
39
+ }
40
+
41
+ function walkParents(start) {
42
+ const result = [];
43
+ let current = startDirectory(start);
44
+ const root = parse(current).root;
45
+ while (true) {
46
+ result.push(current);
47
+ if (current === root) break;
48
+ const parent = dirname(current);
49
+ if (parent === current) break;
50
+ current = parent;
51
+ }
52
+ return result;
53
+ }
54
+
55
+ function inputStart(input = {}, fallback = '') {
56
+ return input.cwd
57
+ || input.project_dir
58
+ || input.projectDir
59
+ || input.workspace?.cwd
60
+ || process.env.CLAUDE_PROJECT_DIR
61
+ || fallback
62
+ || process.cwd();
63
+ }
64
+
65
+ function vaultFromConfig(projectRoot, config) {
66
+ if (!config || config.schemaVersion !== PROJECT_CONFIG_SCHEMA || !config.projectId || !config.vault) {
67
+ const error = new Error(
68
+ `Configuração incompleta em "${join(projectRoot, PROJECT_CONFIG_FILE)}". `
69
+ + 'Rode `wendkeep init --project <path> --vault <path>`.',
70
+ );
71
+ error.code = 'WENDKEEP_VAULT_CONFIG_INVALID';
72
+ throw error;
73
+ }
74
+ return isAbsolute(config.vault) ? resolve(config.vault) : resolve(projectRoot, config.vault);
75
+ }
76
+
77
+ export function readProjectBinding(projectRoot) {
78
+ const root = resolve(projectRoot);
79
+ const path = join(root, PROJECT_CONFIG_FILE);
80
+ if (!existsSync(path)) return null;
81
+ const config = json(path);
82
+ return { config, configPath: path, projectRoot: root, base: vaultFromConfig(root, config) };
83
+ }
84
+
85
+ export function findProjectBinding(start) {
86
+ for (const projectRoot of walkParents(start)) {
87
+ const found = readProjectBinding(projectRoot);
88
+ if (found) return found;
89
+ }
90
+ return null;
91
+ }
92
+
93
+ export function findLegacyProjectVault(start) {
94
+ for (const projectRoot of walkParents(start)) {
95
+ const settingsPath = join(projectRoot, '.claude', 'settings.json');
96
+ if (!existsSync(settingsPath)) continue;
97
+ try {
98
+ const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
99
+ const raw = settings?.env?.OBSIDIAN_VAULT_PATH;
100
+ if (typeof raw === 'string' && raw.trim()) {
101
+ return {
102
+ base: isAbsolute(raw) ? resolve(raw) : resolve(projectRoot, raw),
103
+ projectRoot,
104
+ source: 'legacy-project-settings',
105
+ configPath: settingsPath,
106
+ projectId: '',
107
+ };
108
+ }
109
+ } catch { /* init/doctor explicam JSON inválido; descoberta segue procurando */ }
110
+ }
111
+ return null;
112
+ }
113
+
114
+ export function readVaultMarker(vaultPath) {
115
+ const markerPath = join(resolve(vaultPath), ...PROJECT_MARKER_REL.split('/'));
116
+ if (!existsSync(markerPath)) return null;
117
+ return { marker: json(markerPath), markerPath };
118
+ }
119
+
120
+ function validateMarker(result) {
121
+ const found = readVaultMarker(result.base);
122
+ if (!found) {
123
+ const error = new Error(
124
+ `O vault "${result.base}" ainda não possui ${PROJECT_MARKER_REL}. `
125
+ + `Rode \`wendkeep init --project "${result.projectRoot}" --vault "${result.base}" --yes\`.`,
126
+ );
127
+ error.code = 'WENDKEEP_VAULT_MARKER_MISSING';
128
+ throw error;
129
+ }
130
+ if (found.marker?.projectId !== result.projectId) {
131
+ const error = new Error(
132
+ `Vault de outro projeto: configuração "${result.projectId}" aponta para marcador `
133
+ + `"${found.marker?.projectId || 'ausente'}" em "${found.markerPath}".`,
134
+ );
135
+ error.code = 'WENDKEEP_VAULT_PROJECT_MISMATCH';
136
+ throw error;
137
+ }
138
+ }
139
+
140
+ export function resolveProjectVault({
141
+ input = {},
142
+ startDir = '',
143
+ explicitVault = '',
144
+ allowLegacySettings = true,
145
+ validateIdentity = true,
146
+ } = {}) {
147
+ const start = inputStart(input, startDir);
148
+ const explicit = explicitVault || input?.obsidian_vault_path;
149
+ if (explicit) {
150
+ return {
151
+ base: isAbsolute(explicit) ? resolve(explicit) : resolve(startDirectory(start), explicit),
152
+ source: explicitVault ? 'explicit' : 'payload',
153
+ projectRoot: startDirectory(start),
154
+ projectId: '',
155
+ configPath: '',
156
+ };
157
+ }
158
+
159
+ const binding = findProjectBinding(start);
160
+ if (binding) {
161
+ const result = {
162
+ base: binding.base,
163
+ source: 'project-config',
164
+ projectRoot: binding.projectRoot,
165
+ projectId: binding.config.projectId,
166
+ configPath: binding.configPath,
167
+ };
168
+ if (validateIdentity) validateMarker(result);
169
+ return result;
170
+ }
171
+
172
+ if (allowLegacySettings) {
173
+ const legacy = findLegacyProjectVault(start);
174
+ if (legacy) return legacy;
175
+ }
176
+
177
+ const error = new Error(
178
+ `Nenhum vault WendKeep vinculado ao projeto em "${startDirectory(start)}". `
179
+ + `Crie ${PROJECT_CONFIG_FILE} com \`wendkeep init --project "${startDirectory(start)}" --vault <path> --yes\`.`,
180
+ );
181
+ error.code = 'WENDKEEP_VAULT_UNCONFIGURED';
182
+ throw error;
183
+ }
184
+
185
+ function portableVaultPath(projectRoot, vaultPath) {
186
+ const rel = relative(projectRoot, vaultPath);
187
+ if (rel && !rel.startsWith('..') && !isAbsolute(rel)) return rel.replaceAll('\\', '/');
188
+ return vaultPath;
189
+ }
190
+
191
+ export function bindProjectVault({ projectRoot, vaultPath }) {
192
+ const root = resolve(projectRoot);
193
+ const base = isAbsolute(vaultPath) ? resolve(vaultPath) : resolve(root, vaultPath);
194
+ const existing = readProjectBinding(root);
195
+ const existingMarker = readVaultMarker(base);
196
+ const projectId = existing?.config?.projectId || existingMarker?.marker?.projectId || randomUUID();
197
+
198
+ if (existingMarker?.marker?.projectId && existingMarker.marker.projectId !== projectId) {
199
+ const error = new Error(
200
+ `Não é seguro vincular "${root}" ao vault de outro projeto: `
201
+ + `esperado "${projectId}", encontrado "${existingMarker.marker.projectId}".`,
202
+ );
203
+ error.code = 'WENDKEEP_VAULT_PROJECT_MISMATCH';
204
+ throw error;
205
+ }
206
+
207
+ mkdirSync(join(base, '.brain'), { recursive: true });
208
+ const config = {
209
+ schemaVersion: PROJECT_CONFIG_SCHEMA,
210
+ projectId,
211
+ vault: portableVaultPath(root, base),
212
+ };
213
+ const marker = {
214
+ schemaVersion: PROJECT_CONFIG_SCHEMA,
215
+ projectId,
216
+ projectName: basename(root),
217
+ };
218
+ atomicJson(join(base, ...PROJECT_MARKER_REL.split('/')), marker);
219
+ atomicJson(join(root, PROJECT_CONFIG_FILE), config);
220
+ return { base, projectRoot: root, projectId, config, marker };
221
+ }
@@ -52,7 +52,7 @@ export function renderVaultReadme({ projectName, vaultPath, withMcp = true, loca
52
52
  const mcpIntro = withMcp ? ', and read/written by the **MCPVault** MCP server' : '';
53
53
  const access = [`- **Obsidian:** open this folder with "Open folder as vault" → \`${vaultPath}\``];
54
54
  if (withMcp) access.push('- **Agent (MCP):** the `wendkeep-vault` server (MCPVault) points at this vault (set in `.mcp.json`), giving the agent read/write on the notes.');
55
- access.push('- **Hooks:** `settings.json` calls `npx wendkeep hook <name>`; the vault is located via `OBSIDIAN_VAULT_PATH` (also written by `wendkeep init`).');
55
+ access.push('- **Hooks:** Codex and Claude Code call `npx wendkeep hook <name>`; the vault is discovered from the project-local `.wendkeep.json` binding and checked against `.brain/PROJECT.json`.');
56
56
  return `# Obsidian vault — ${name}
57
57
 
58
58
  > Knowledge base of **${name}**, captured automatically by wendkeep from AI coding-agent
@@ -84,7 +84,7 @@ ${access.join('\n')}
84
84
  const mcpIntro = withMcp ? ', e lida/escrita pelo MCP server **MCPVault**' : '';
85
85
  const access = [`- **Obsidian:** abra esta pasta com "Open folder as vault" → \`${vaultPath}\``];
86
86
  if (withMcp) access.push('- **Agente (MCP):** o servidor `wendkeep-vault` (MCPVault) é apontado para este vault pelo `wendkeep init` (em `.mcp.json`), dando ao agente leitura/escrita das notas.');
87
- access.push('- **Hooks:** `settings.json` chama `npx wendkeep hook <name>`; o vault é localizado via a env `OBSIDIAN_VAULT_PATH` (também gravada pelo `wendkeep init`).');
87
+ access.push('- **Hooks:** Codex e Claude Code chamam `npx wendkeep hook <name>`; o vault é descoberto pelo vínculo local `.wendkeep.json` e validado contra `.brain/PROJECT.json`.');
88
88
  return `# Vault Obsidian — ${name}
89
89
 
90
90
  > Base de conhecimento de **${name}**, capturada automaticamente pelo wendkeep a