wendkeep 0.38.3 → 0.40.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,66 @@ 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.40.0] — 2026-07-16
8
+
9
+ ### Added
10
+
11
+ - `parseTasks` captura **todos** os `[req:]` de uma tarefa em `reqs: string[]` (`req` permanece
12
+ como alias do primeiro, retrocompatível). Antes, só o primeiro entrava no pacote de
13
+ verificação e os demais sumiam sem aviso.
14
+ - Heading de requisito aceita ID puro (`### Requisito: GATE-1`) como identidade, além do
15
+ formato preferido `### Requisito: <ID> — <nome>`. Diagnóstico de requisito órfão agora
16
+ ensina o formato esperado com exemplo concreto.
17
+ - `findProjectRoot`: `wendkeep verify` executado de um subdiretório sobe a árvore até achar
18
+ `wendkeep.sensors.json`/`.wendkeep.json` (à la `.git`); `--project` continua autoritativo.
19
+ - `--help`/`-h` universal: qualquer subcomando com `--help` imprime a ajuda e sai com 0,
20
+ interceptado antes da resolução de vault — nunca executa o comando.
21
+
22
+ ### Fixed
23
+
24
+ - Regex de ID de requisito unificada entre tarefa e spec (`REQ_ID_RE_SRC`): IDs
25
+ multi-segmento (`API-AUTH-2`) agora são reconhecidos também nas tarefas.
26
+ - `wendkeep verify` distingue `wendkeep.sensors.json` ausente (aviso com path + dica
27
+ `--project`) de JSON inválido (erro alto com a mensagem do parse). Antes, ambos viravam
28
+ "sensor não definido" para todos os sensores.
29
+ - `wendkeep import` com flag desconhecida agora falha com exit 2 citando a flag, em vez de
30
+ cair no default destrutivo `--source all` (que chegou a importar 78 sessões sem querer).
31
+ - Templates seed (skills de workflow pt/en) documentam o formato de heading de requisito e o
32
+ suporte a múltiplos `[req:]` por tarefa.
33
+
34
+ ## [0.39.0] — 2026-07-13
35
+
36
+ ### Added
37
+
38
+ - Configuração provider-neutral `.wendkeep.json`, descoberta do diretório da sessão para os
39
+ pais, permitindo que Codex e Claude Code resolvam o mesmo vault sem variável global do
40
+ Windows. Caminhos relativos ao projeto e caminhos absolutos são suportados.
41
+ - Identidade estável `projectId`, espelhada em `.brain/PROJECT.json`; vínculos que apontem
42
+ para o vault de outro projeto são rejeitados antes de qualquer gravação.
43
+ - `doctor` informa caminho e origem do vínculo efetivo. Comandos CLI executados dentro do
44
+ projeto também descobrem o vault local, mantendo `--vault` como override explícito.
45
+
46
+ ### Changed
47
+
48
+ - `wendkeep init` cria o vínculo projeto→vault de forma idempotente e adota instalações
49
+ antigas registradas em `.claude/settings.json`, sem mover ou dividir o histórico.
50
+ - Um vínculo local sempre vence um `OBSIDIAN_VAULT_PATH` herdado pelo processo. A variável
51
+ permanece apenas para compatibilidade de comandos manuais legados.
52
+
53
+ ### Fixed
54
+
55
+ - Hooks do Codex deixam de gravar sessões no vault doméstico `~/wendkeep-vault` quando o
56
+ processo não recebe o ambiente privado do Claude Code. Sem vínculo local ou payload
57
+ explícito, o hook agora falha de modo seguro, emite diagnóstico e não cria arquivos.
58
+ - Projetos simultâneos deixam de compartilhar acidentalmente sessões, mudanças e grafo por
59
+ causa de uma variável global de usuário apontando para um único vault.
60
+
61
+ ### Migration
62
+
63
+ - Após atualizar, execute uma vez `wendkeep init --project . --vault <vault> --yes`, depois
64
+ `wendkeep sync-defs --project . --reseed` e `wendkeep doctor --project .`; reinicie Codex e
65
+ Claude Code para recarregar os artefatos gerados.
66
+
7
67
  ## [0.38.3] — 2026-07-12
8
68
 
9
69
  ### 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,39 @@ 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
+ // Universal --help: any subcommand with --help/-h prints usage and never executes.
129
+ // Intercepted BEFORE vault resolution so it works anywhere — help must never depend
130
+ // on project state, and no command may treat --help as a runnable default.
131
+ if (cmd && (rest.includes('--help') || rest.includes('-h'))) {
132
+ process.stdout.write(HELP);
133
+ process.exit(0);
134
+ }
135
+ if (cmd && !['init', 'hook', '--version', '-v', '--help', '-h', 'help'].includes(cmd)) {
136
+ await preferProjectVault(rest);
137
+ }
107
138
  switch (cmd) {
108
139
  case 'init': {
109
140
  const { runInit } = await import('../src/init.mjs');
@@ -4,7 +4,7 @@
4
4
  import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
5
5
  import { dirname, join } from 'node:path';
6
6
  import { ensureDir, wikilinkFromRel, monthFolderRelFromDateStr } from './obsidian-common.mjs';
7
- import { parseSpecsList, promoteSpecs, discoverSpecDeltas, tasksHashOf, captureSpecBaseline } from './spec-core.mjs';
7
+ import { parseSpecsList, promoteSpecs, discoverSpecDeltas, tasksHashOf, captureSpecBaseline, REQ_ID_RE_SRC } from './spec-core.mjs';
8
8
  import { getLocale } from './locale.mjs';
9
9
 
10
10
  export const ARCHIVE_DIR = '_arquivo';
@@ -161,18 +161,18 @@ export function parseTasks(md) {
161
161
  const tasks = [];
162
162
  const re = /^-\s+\[( |x)\]\s+(\S+)\s+(.*)$/gm;
163
163
  const sensorRe = /\[sensor:\s*([\w.-]+)\]/;
164
- const reqRe = /\[req:\s*([A-Z][A-Z0-9]*-\d+)\]/;
164
+ const reqReG = new RegExp(`\\[req:\\s*(${REQ_ID_RE_SRC})\\]`, 'g');
165
165
  let m;
166
166
  while ((m = re.exec(String(md))) !== null) {
167
167
  let text = m[3].trim();
168
168
  const sm = text.match(sensorRe);
169
- const rm = text.match(reqRe);
169
+ const reqs = [...text.matchAll(reqReG)].map((r) => r[1]);
170
170
  const sensor = sm ? sm[1] : undefined;
171
- const req = rm ? rm[1] : undefined;
172
171
  if (sm) text = text.replace(sensorRe, '');
173
- if (rm) text = text.replace(reqRe, '');
172
+ if (reqs.length) text = text.replace(reqReG, '');
174
173
  text = text.replace(/\s+/g, ' ').trim();
175
- tasks.push({ id: m[2], text, done: m[1] === 'x', ...(sensor ? { sensor } : {}), ...(req ? { req } : {}) });
174
+ // `req` stays as alias of the first id older consumers keep working.
175
+ tasks.push({ id: m[2], text, done: m[1] === 'x', ...(sensor ? { sensor } : {}), ...(reqs.length ? { req: reqs[0], reqs } : {}) });
176
176
  }
177
177
  return tasks;
178
178
  }
@@ -419,7 +419,7 @@ export function archiveChange(vaultBase, slug, { gate = gateGreen, dateStr, adrN
419
419
  }
420
420
 
421
421
  let reqIds = [];
422
- try { reqIds = [...new Set(parseTasks(readFileSync(join(src, 'tarefas.md'), 'utf8')).map((t) => t.req).filter(Boolean))]; } catch { /* sem tarefas */ }
422
+ try { reqIds = [...new Set(parseTasks(readFileSync(join(src, 'tarefas.md'), 'utf8')).flatMap((t) => t.reqs ?? []))]; } catch { /* sem tarefas */ }
423
423
 
424
424
  ensureDir(join(vaultBase, chDir, ARCHIVE_DIR));
425
425
  try {
@@ -41,7 +41,7 @@ export function checkHarness(vaultBase, projectRoot) {
41
41
  let tasks = [];
42
42
  let tarefasMd = '';
43
43
  try { tarefasMd = readFileSync(join(dir, 'tarefas.md'), 'utf8'); tasks = parseTasks(tarefasMd); } catch { /* sem tarefas */ }
44
- const reqIds = [...new Set(tasks.map((t) => t.req).filter(Boolean))];
44
+ const reqIds = [...new Set(tasks.flatMap((t) => t.reqs ?? []))];
45
45
  const effective = buildEffectiveRequirementPackage(vaultBase, dir, reqIds);
46
46
  errors.push(...effective.errors.map((e) => `${name}: spec efetiva inválida: ${e}`));
47
47
  if (effective.missing.length) errors.push(`req órfão em ${name}: ${effective.missing.map((id) => `[req:${id}]`).join(', ')} não existe na spec efetiva`);
@@ -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;
@@ -2,15 +2,37 @@
2
2
  // Pure-ish: `spawn` is injectable so runs are testable without a shell. Config lives
3
3
  // at the PROJECT ROOT (wendkeep.sensors.json); evidence lives per-change in the vault.
4
4
  import { spawnSync } from 'node:child_process';
5
- import { readFileSync } from 'node:fs';
6
- import { join } from 'node:path';
5
+ import { existsSync, readFileSync } from 'node:fs';
6
+ import { dirname, join, resolve } from 'node:path';
7
7
 
8
8
  export function loadSensors(projectRoot, file = 'wendkeep.sensors.json') {
9
+ return loadSensorsDetailed(projectRoot, file).sensors;
10
+ }
11
+
12
+ // Missing config and broken config are different failures: absent file usually means
13
+ // wrong cwd (subdirectory), broken JSON means the config itself needs fixing. Collapsing
14
+ // both into [] made every sensor report "sensor não definido" — a misleading diagnosis.
15
+ export function loadSensorsDetailed(projectRoot, file = 'wendkeep.sensors.json') {
16
+ const path = join(projectRoot, file);
17
+ if (!existsSync(path)) return { sensors: [], missing: true, error: null, path };
9
18
  try {
10
- const data = JSON.parse(readFileSync(join(projectRoot, file), 'utf8'));
11
- return Array.isArray(data.sensors) ? data.sensors : [];
12
- } catch {
13
- return [];
19
+ const data = JSON.parse(readFileSync(path, 'utf8'));
20
+ return { sensors: Array.isArray(data.sensors) ? data.sensors : [], missing: false, error: null, path };
21
+ } catch (e) {
22
+ return { sensors: [], missing: false, error: e.message, path };
23
+ }
24
+ }
25
+
26
+ // Climb the directory tree looking for a project marker (wendkeep.sensors.json or
27
+ // .wendkeep.json), like git does with .git — shells in agent harnesses keep their cwd
28
+ // across commands, so verify is often run from a subdirectory.
29
+ export function findProjectRoot(startDir) {
30
+ let dir = resolve(startDir);
31
+ for (;;) {
32
+ if (existsSync(join(dir, 'wendkeep.sensors.json')) || existsSync(join(dir, '.wendkeep.json'))) return dir;
33
+ const parent = dirname(dir);
34
+ if (parent === dir) return null;
35
+ dir = parent;
14
36
  }
15
37
  }
16
38
 
@@ -22,15 +22,29 @@ export const MANAGED_SPEC_MARKER = '<!-- wendkeep:managed-spec — generated fro
22
22
  // Parse is BILINGUAL always (mixed vaults never break); rendering follows the vault locale.
23
23
  const REQ_RE = /^### (?:Requisito|Requirement):\s*(.+)$/gm;
24
24
 
25
+ // Single source of truth for requirement-id shape — task tags ([req:ID]) and spec
26
+ // headings MUST agree, or coverage silently misses (multi-segment ids like API-AUTH-2).
27
+ export const REQ_ID_RE_SRC = '[A-Z][A-Z0-9]*(?:-[A-Z0-9]+)*-\\d+';
28
+
29
+ // Orphan diagnostics must teach the fix, not just name the ids — the heading format
30
+ // is the most common cause and lives only here.
31
+ export function formatOrphanReqs(ids) {
32
+ const list = ids.join(', ');
33
+ const ex = ids[0] || 'GATE-1';
34
+ return `requisito(s) órfão(s) na spec efetiva: ${list} — heading esperado no spec.md da change: "### Requisito: ${ex} — <nome>" (ou só "### Requisito: ${ex}")`;
35
+ }
36
+
25
37
  export function parseRequirements(md) {
26
38
  const text = String(md);
27
39
  const matches = [...text.matchAll(REQ_RE)];
28
40
  const reqs = [];
29
41
  for (let i = 0; i < matches.length; i += 1) {
30
42
  const raw = matches[i][1].trim();
31
- // Identity is the ID (e.g. GATE-1) when the heading is "<ID> — <nome>"; else the whole text.
32
- const idM = raw.match(/^([A-Z][A-Z0-9]*(?:-[A-Z0-9]+)*-\d+)\s*—\s*(.+)$/);
33
- const id = idM ? idM[1] : null;
43
+ // Identity is the ID (e.g. GATE-1) when the heading is "<ID> — <nome>" or a bare
44
+ // "<ID>"; else the whole text. Bare ids keep specs writable without the em-dash.
45
+ const idM = raw.match(new RegExp(`^(${REQ_ID_RE_SRC})\\s*—\\s*(.+)$`));
46
+ const bare = idM ? null : raw.match(new RegExp(`^(${REQ_ID_RE_SRC})$`));
47
+ const id = idM ? idM[1] : bare ? bare[1] : null;
34
48
  const name = idM ? idM[2].trim() : raw;
35
49
  const start = matches[i].index + matches[i][0].length;
36
50
  const end = i + 1 < matches.length ? matches[i + 1].index : text.length;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wendkeep",
3
- "version": "0.38.3",
3
+ "version": "0.40.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.2"
49
+ "wendkeep": "^0.39.0"
50
50
  }
51
51
  }
package/src/change.mjs CHANGED
@@ -17,7 +17,7 @@ import {
17
17
  scaffoldPlaceholders,
18
18
  } from '../hooks/change-core.mjs';
19
19
  import { evaluateGate, requiredSensors } from '../hooks/sensors-core.mjs';
20
- import { buildEffectiveRequirementPackage, evaluateVerdict, tasksHashOf, parseSpecsList, parseDelta, parseRequirements, applyDelta, validateSpecImpact } from '../hooks/spec-core.mjs';
20
+ import { buildEffectiveRequirementPackage, evaluateVerdict, formatOrphanReqs, tasksHashOf, parseSpecsList, parseDelta, parseRequirements, applyDelta, validateSpecImpact } from '../hooks/spec-core.mjs';
21
21
  import { getNextAdrNumber, readControl, readSessionRegistry, upsertSessionRegistry } from '../hooks/obsidian-common.mjs';
22
22
  import { getLocale } from '../hooks/locale.mjs';
23
23
 
@@ -147,13 +147,13 @@ export function runChange(argv) {
147
147
  process.stdout.write(`specs: ${specs.join(', ') || '(nenhuma)'}\n`);
148
148
  process.stdout.write(`tarefas: ${done} done / ${tasks.length - done} open\n`);
149
149
  for (const t of tasks) {
150
- process.stdout.write(` [${t.done ? 'x' : ' '}] ${t.id} ${t.text}${t.req ? ` [req:${t.req}]` : ''}${t.sensor ? ` [sensor:${t.sensor}]` : ''}\n`);
150
+ process.stdout.write(` [${t.done ? 'x' : ' '}] ${t.id} ${t.text}${(t.reqs ?? []).map((r) => ` [req:${r}]`).join('')}${t.sensor ? ` [sensor:${t.sensor}]` : ''}\n`);
151
151
  }
152
152
  let evidence = null;
153
153
  try { evidence = JSON.parse(readFileSync(join(dir, 'evidencia.json'), 'utf8')); } catch { /* sem evidência */ }
154
154
  if (evidence) for (const e of evidence) process.stdout.write(` ${e.status === 'green' ? '✓' : '✗'} ${e.id} (${e.severity || 'critical'})\n`);
155
155
  else process.stdout.write('evidencia: ausente\n');
156
- const reqIds = [...new Set(tasks.map((t) => t.req).filter(Boolean))];
156
+ const reqIds = [...new Set(tasks.flatMap((t) => t.reqs ?? []))];
157
157
  const effective = buildEffectiveRequirementPackage(vaultBase, dir, reqIds);
158
158
  if (effective.errors.length || effective.missing.length) {
159
159
  process.stdout.write(`spec efetiva: inválida (${[...effective.errors, ...effective.missing.map((id) => `req órfão ${id}`)].join('; ')})\n`);
@@ -239,10 +239,10 @@ export function runChange(argv) {
239
239
  return { ok: false, failing: ['evidência stale (tarefas.md mudou desde o último verify) — rode `wendkeep verify` de novo'] };
240
240
  }
241
241
  }
242
- const reqIds = [...new Set(tasks.map((t) => t.req).filter(Boolean))];
242
+ const reqIds = [...new Set(tasks.flatMap((t) => t.reqs ?? []))];
243
243
  const effective = buildEffectiveRequirementPackage(vaultBase, dir, reqIds);
244
244
  if (effective.errors.length) return { ok: false, failing: [`spec efetiva inválida: ${effective.errors.join('; ')}`] };
245
- if (effective.missing.length) return { ok: false, failing: [`requisito(s) órfão(s) na spec efetiva: ${effective.missing.join(', ')}`] };
245
+ if (effective.missing.length) return { ok: false, failing: [formatOrphanReqs(effective.missing)] };
246
246
  let evidence = [];
247
247
  try { evidence = JSON.parse(readFileSync(join(dir, 'evidencia.json'), 'utf8')); } catch { /* no evidence */ }
248
248
  const s = evaluateGate(evidence, required);
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/import.mjs CHANGED
@@ -12,7 +12,22 @@ function opt(argv, name) {
12
12
  return eq ? eq.slice(name.length + 1) : undefined;
13
13
  }
14
14
 
15
+ const KNOWN_FLAGS = new Set([
16
+ '--vault', '--project', '--source', '--from', '--codex-from', '--since', '--limit',
17
+ '--dry-run', '--json', '--rescan-decisions', '--stamp-ids', '--help', '-h',
18
+ ]);
19
+
15
20
  export function runImportCli(argv) {
21
+ // Import writes to the vault; an unrecognized flag must never fall through to the
22
+ // destructive default (--source all). Fail fast, point at --help.
23
+ for (const a of argv) {
24
+ if (!a.startsWith('-')) continue;
25
+ const name = a.includes('=') ? a.slice(0, a.indexOf('=')) : a;
26
+ if (!KNOWN_FLAGS.has(name)) {
27
+ process.stderr.write(`wendkeep import: flag desconhecida "${name}" (use --help)\n`);
28
+ process.exit(2);
29
+ }
30
+ }
16
31
  const vaultRaw = opt(argv, '--vault') || process.env.OBSIDIAN_VAULT_PATH;
17
32
  if (!vaultRaw) { process.stderr.write('wendkeep import: no vault (--vault or OBSIDIAN_VAULT_PATH).\n'); process.exit(2); }
18
33
  const vaultBase = isAbsolute(vaultRaw) ? vaultRaw : resolve(process.cwd(), vaultRaw);
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
+ }
@@ -35,15 +35,18 @@ vault cego. Exceção única: mudança trivial (typo, 1 linha).
35
35
  Antes de implementar, resolva \`spec_impact\` na proposta:
36
36
  - \`required\`: liste a capability em \`specs:\` e preencha
37
37
  \`specs/<capability>/spec.md\` com ADDED/MODIFIED/REMOVED; ligue tarefas com \`[req:ID]\`.
38
+ Heading de requisito: \`### Requisito: <ID> — <nome>\` (ou só \`### Requisito: <ID>\`);
39
+ o ID é a identidade (ex.: \`GATE-1\`, \`API-AUTH-2\`).
38
40
  - \`none\`: registre uma justificativa real em \`spec_impact_reason\`.
39
41
  \`pending\` nunca é estado pronto para implementação ou archive.
40
42
  3. **Apply** — implemente cada tarefa de \`tarefas.md\` com disciplina **wk-tdd**
41
43
  (teste vermelho antes do código). Marque \`- [x]\` ao concluir. Declare nas tarefas:
42
44
  - \`[sensor:<id>]\` — a prova automatizada (roda no verify).
43
45
  - \`[req:<ID>]\` — o requisito do spec que a tarefa satisfaz (ex.: \`[req:GATE-1]\`),
44
- quando a change mexe numa capability. Toda autoria de spec ocorre somente em
46
+ quando a change mexe numa capability. Uma tarefa pode declarar vários
47
+ \`[req:]\` — todos contam na cobertura. Toda autoria de spec ocorre somente em
45
48
  \`08-Mudanças/<slug>/specs/<capability>/spec.md\`; \`07-Specs\` é gerado/read-only.
46
- Ex.: \`- [ ] 2.1 valida CORE [req:MEM-1] [sensor:memory-validation]\`.
49
+ Ex.: \`- [ ] 2.1 valida CORE [req:MEM-1] [req:MEM-2] [sensor:memory-validation]\`.
47
50
  4. **Verify** — \`wendkeep verify\` roda os sensores → \`evidencia.json\`. Depois
48
51
  \`wendkeep verify --deep\` monta o *pacote de verificação* pro passe independente.
49
52
  5. **Verify deep** — a skill **wk-verify** (passe fresco, autor≠verificador) lê o pacote,
@@ -259,8 +262,11 @@ leaves the vault blind. Single exception: a trivial change (typo, one line).
259
262
  Before implementation, resolve \`spec_impact\`: \`required\` needs the capability listed in
260
263
  \`specs:\` plus a real \`specs/<capability>/spec.md\` delta and \`[req:ID]\` links; \`none\`
261
264
  needs a real \`spec_impact_reason\`. \`pending\` is never ready for implementation/archive.
265
+ Requirement heading: \`### Requirement: <ID> — <name>\` (or bare \`### Requirement: <ID>\`);
266
+ the ID is the identity (e.g. \`GATE-1\`, \`API-AUTH-2\`).
262
267
  3. **Apply** — implement each task in tarefas.md with **wk-tdd** (red test first). Tag tasks:
263
- \`[sensor:<id>]\` (automated proof) and \`[req:<ID>]\` (the spec requirement it satisfies).
268
+ \`[sensor:<id>]\` (automated proof) and \`[req:<ID>]\` (the spec requirement it satisfies;
269
+ a task may declare several \`[req:]\` tags — all of them count toward coverage).
264
270
  Author specs only in \`08-Changes/<slug>/specs/\`; \`07-Specs\` is generated/read-only.
265
271
  4. **Verify** — \`wendkeep verify\` runs the sensors; then \`wendkeep verify --deep\` builds
266
272
  the verification package.
package/src/spec.mjs CHANGED
@@ -49,7 +49,7 @@ export function runSpec(argv) {
49
49
  tasks = parseTasks(readFileSync(join(changeDir, 'tarefas.md'), 'utf8'));
50
50
  }
51
51
  catch { process.stderr.write(`wendkeep spec effective: change not found: ${slug}\n`); process.exit(2); }
52
- const reqIds = [...new Set(tasks.map((task) => task.req).filter(Boolean))];
52
+ const reqIds = [...new Set(tasks.flatMap((task) => task.reqs ?? []))];
53
53
  const effective = buildEffectiveRequirementPackage(vaultBase, changeDir, reqIds);
54
54
  if (effective.errors.length) {
55
55
  process.stderr.write(`wendkeep spec effective: invalid delta: ${effective.errors.join('; ')}\n`);
@@ -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
package/src/verify.mjs CHANGED
@@ -4,10 +4,11 @@
4
4
  import { readFileSync, unlinkSync, writeFileSync } from 'node:fs';
5
5
  import { isAbsolute, join, resolve } from 'node:path';
6
6
  import { parseTasks, activeChange, appendFixTasks } from '../hooks/change-core.mjs';
7
- import { loadSensors, requiredSensors, runSensors, evaluateGate } from '../hooks/sensors-core.mjs';
7
+ import { loadSensorsDetailed, findProjectRoot, requiredSensors, runSensors, evaluateGate } from '../hooks/sensors-core.mjs';
8
8
  import {
9
9
  buildEffectiveRequirementPackage,
10
10
  captureSpecBaseline,
11
+ formatOrphanReqs,
11
12
  tasksHashOf,
12
13
  } from '../hooks/spec-core.mjs';
13
14
  import { addLesson } from '../hooks/lessons-core.mjs';
@@ -29,7 +30,9 @@ export function runVerify(argv) {
29
30
  const vaultRaw = opt(argv, '--vault') || process.env.OBSIDIAN_VAULT_PATH;
30
31
  if (!vaultRaw) { process.stderr.write('wendkeep verify: no vault (--vault or OBSIDIAN_VAULT_PATH).\n'); process.exit(2); }
31
32
  const vaultBase = isAbsolute(vaultRaw) ? vaultRaw : resolve(process.cwd(), vaultRaw);
32
- const projectRoot = resolve(opt(argv, '--project') || process.cwd());
33
+ // --project wins; otherwise climb from cwd to the nearest project marker (agent shells
34
+ // keep their cwd across commands, so verify from a subdirectory is a recurring miss).
35
+ const projectRoot = resolve(opt(argv, '--project') || findProjectRoot(process.cwd()) || process.cwd());
33
36
  const slug = opt(argv, '--change') || activeChange(vaultBase);
34
37
  if (!slug) { process.stderr.write('wendkeep verify: no change (--change or active).\n'); process.exit(2); }
35
38
 
@@ -39,7 +42,15 @@ export function runVerify(argv) {
39
42
  catch { process.stderr.write(`wendkeep verify: change not found: ${slug}\n`); process.exit(2); }
40
43
 
41
44
  const ids = requiredSensors(parseTasks(tarefas));
42
- const sensors = loadSensors(projectRoot);
45
+ const loaded = loadSensorsDetailed(projectRoot);
46
+ if (loaded.error) {
47
+ process.stderr.write(`wendkeep verify: wendkeep.sensors.json inválido em ${loaded.path}: ${loaded.error}\n`);
48
+ process.exit(2);
49
+ }
50
+ if (loaded.missing && ids.length) {
51
+ process.stderr.write(`wendkeep verify: wendkeep.sensors.json não encontrado em ${loaded.path} — rode da raiz do projeto ou use --project <raiz>\n`);
52
+ }
53
+ const sensors = loaded.sensors;
43
54
  const evidence = runSensors(sensors, ids, { cwd: projectRoot });
44
55
  writeFileSync(join(changeDir, 'evidencia.json'), `${JSON.stringify(evidence, null, 2)}\n`, 'utf8');
45
56
  // Freshness seal: bind this evidence to the tarefas.md it was produced against, so the archive
@@ -90,7 +101,7 @@ export function runVerify(argv) {
90
101
  // change (no [req:] tasks, sensors green) gets an auto verdict — no agent pass needed.
91
102
  if (argv.includes('--deep')) {
92
103
  const tasks = parseTasks(tarefas);
93
- const reqIds = [...new Set(tasks.map((t) => t.req).filter(Boolean))];
104
+ const reqIds = [...new Set(tasks.flatMap((t) => t.reqs ?? []))];
94
105
  const tasksHash = tasksHashOf(tarefas);
95
106
  captureSpecBaseline(vaultBase, changeDir);
96
107
  const effective = buildEffectiveRequirementPackage(vaultBase, changeDir, reqIds);
@@ -99,7 +110,7 @@ export function runVerify(argv) {
99
110
  process.exit(1);
100
111
  }
101
112
  if (effective.missing.length) {
102
- process.stderr.write(`verify --deep: requisito(s) órfão(s) na spec efetiva: ${effective.missing.join(', ')}\n`);
113
+ process.stderr.write(`verify --deep: ${formatOrphanReqs(effective.missing)}\n`);
103
114
  process.exit(1);
104
115
  }
105
116
  const pkg = {
@@ -116,7 +127,7 @@ export function runVerify(argv) {
116
127
  body: req.body,
117
128
  };
118
129
  }),
119
- tasks: tasks.map((t) => ({ id: t.id, text: t.text, req: t.req || null, done: t.done })),
130
+ tasks: tasks.map((t) => ({ id: t.id, text: t.text, req: t.req || null, reqs: t.reqs || [], done: t.done })),
120
131
  sensors: evidence,
121
132
  };
122
133
  writeFileSync(join(changeDir, 'verificacao.json'), `${JSON.stringify(pkg, null, 2)}\n`, 'utf8');