wendkeep 0.31.0 → 0.32.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,50 @@ 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.32.0] — 2026-07-09
8
+
9
+ ### Added
10
+
11
+ - **Contrato explícito `spec_impact`** para changes novas: `pending`, `required` ou `none`.
12
+ Changes materiais (`required`) precisam listar a capability e manter um delta real em
13
+ `specs/<capability>/spec.md`; `none` exige justificativa em `spec_impact_reason`.
14
+ - **Snapshots imutáveis de planos aprovados** em `planos/<sha256-12>.md`, deduplicados por
15
+ conteúdo. `plano-aprovado.md` passa a ser o índice dos snapshots, sem sobrescrever planos
16
+ anteriores da mesma change.
17
+ - Diagnósticos de `spec_impact` no `wendkeep doctor`, incluindo estado pendente, delta ausente e
18
+ divergência entre `specs:` e o conteúdo real no disco.
19
+
20
+ ### Changed
21
+
22
+ - Hooks Claude de alta frequência usam `node` com caminho ancorado em
23
+ `${CLAUDE_PROJECT_DIR}`. O `init` migra automaticamente os comandos relativos de 0.31.0 sem
24
+ duplicar grupos, inclusive quando o agente muda o `cwd` para um subprojeto.
25
+ - `wk-workflow`, `wk-brainstorming`, `wk-planning` e o roteador de SessionStart agora exigem a
26
+ classificação do impacto, o delta da capability e a rastreabilidade `[req:ID]` antes do archive.
27
+ - O archive passa a ser **fail-closed** para specs: placeholder, arquivo ausente ou falha de
28
+ promoção bloqueiam o move e a criação do ADR.
29
+ - Ao arquivar, links da sessão para a change ativa são reescritos para o caminho em `_arquivo`.
30
+ Decisões capturadas também entram imediatamente na seção de decisões da sessão ativa.
31
+
32
+ ### Fixed
33
+
34
+ - **Planos aprovados descartados no Claude Code atual**: `plan-capture` agora lê
35
+ `tool_response.plan` no payload estruturado de `PostToolUse:ExitPlanMode`, mantendo os formatos
36
+ textuais legados e rejeições como no-op.
37
+ - **52 falhas `MODULE_NOT_FOUND` observadas em produção** quando `change-warn`/`change-guard`
38
+ rodavam a partir de `mobile-app` ou `backend-core`.
39
+ - A criação automática de change por plano aprovado agora preserva o backlink da sessão resolvido
40
+ pelo `transcript_path`/registry.
41
+ - `vault-health` não exige `## Encerramento` de uma sessão ainda ativa; continua validando a ordem
42
+ completa quando a sessão está finalizada.
43
+
44
+ ### Migration
45
+
46
+ - Rode `wendkeep init --force` para migrar hooks relativos instalados pela 0.31.0.
47
+ - Rode `wendkeep sync-defs --reseed` para atualizar as skills wk-* em vaults existentes.
48
+ - Changes antigas sem `spec_impact` continuam legíveis e são diagnosticadas como legadas; antes do
49
+ próximo archive, classifique-as explicitamente como `required` ou `none`.
50
+
7
51
  ## [0.31.0] — 2026-07-09
8
52
 
9
53
  ### Added — enforcement do loop a2 (o loop deixa de ser opcional na prática)
@@ -21,7 +21,7 @@ function processRouter(localeId) {
21
21
  '<wk_process>',
22
22
  'Spec-driven process (mandatory for any non-trivial task): INVOKE the wk-workflow Skill BEFORE editing any file.',
23
23
  '1. Plan: invoke the wk-brainstorming Skill (approved design) → wk-planning (task plan).',
24
- '2. Record: `wendkeep change new <slug>` and FILL 08-Changes/<slug>/ — proposta.md (why/what), design.md (the approved approach), tarefas.md (the plan\'s tasks, `- [ ] N.N` with [req:ID]/[sensor:id]). Never leave the scaffold placeholders.',
24
+ '2. Record: `wendkeep change new <slug>` and FILL proposta/design/tasks. Resolve `spec_impact`: `required` needs `specs/<capability>/spec.md` + [req:ID]; `none` needs a reason. Never leave pending/placeholders.',
25
25
  '3. Implement: wk-tdd per task; tick `- [x]` as you finish. Something broke? wk-debugging.',
26
26
  '4. Close: `wendkeep verify` (+ `--deep` + the wk-verify Skill) → `wendkeep change archive`.',
27
27
  'NEVER `archive --force` on your own — a red gate means pending work; --force is the user\'s call, not yours. Dead end? `wendkeep change abandon`.',
@@ -32,7 +32,7 @@ function processRouter(localeId) {
32
32
  '<wk_process>',
33
33
  'Processo spec-driven (obrigatório em tarefa não-trivial): INVOQUE a Skill wk-workflow ANTES de editar qualquer arquivo.',
34
34
  '1. Planejar: invoque a Skill wk-brainstorming (design aprovado) → wk-planning (plano de tarefas).',
35
- '2. Registrar: `wendkeep change new <slug>` e PREENCHA 08-Mudanças/<slug>/ — proposta.md (porquê/o quê), design.md (a abordagem aprovada), tarefas.md (as tarefas do plano, `- [ ] N.N` com [req:ID]/[sensor:id]). Nunca deixe os placeholders do scaffold.',
35
+ '2. Registrar: `wendkeep change new <slug>` e PREENCHA proposta/design/tarefas. Resolva `spec_impact`: `required` exige `specs/<capability>/spec.md` + [req:ID]; `none` exige justificativa. Nunca deixe pending/placeholders.',
36
36
  '3. Implementar: wk-tdd por tarefa; marque `- [x]` ao concluir. Quebrou algo? wk-debugging.',
37
37
  '4. Fechar: `wendkeep verify` (+ `--deep` + Skill wk-verify) → `wendkeep change archive`.',
38
38
  'PROIBIDO `archive --force` por conta própria — gate vermelho significa trabalho pendente; --force é decisão do usuário, não sua. Beco sem saída? `wendkeep change abandon`.',
@@ -14,9 +14,13 @@ export function changeDirRel(slug, vaultBase) {
14
14
  return join(getLocale(vaultBase).folders.changes, slug);
15
15
  }
16
16
 
17
- export function renderChangeScaffold({ slug, sessionRel, dateStr, locale = 'pt-BR' }) {
17
+ export function renderChangeScaffold({ slug, sessionRel, dateStr, locale = 'pt-BR', simple = false }) {
18
18
  const en = locale === 'en';
19
19
  const source = sessionRel ? `\n - "${wikilinkFromRel(sessionRel)}"` : ' []';
20
+ const impact = simple ? 'none' : 'pending';
21
+ const impactReason = simple
22
+ ? (en ? 'Simple change with no product-contract impact.' : 'Mudança simples sem impacto no contrato do produto.')
23
+ : '';
20
24
  const proposta = `---
21
25
  type: change
22
26
  status: active
@@ -26,6 +30,8 @@ cssclasses:
26
30
  tags:
27
31
  - mudanca
28
32
  source:${source}
33
+ spec_impact: ${impact}
34
+ spec_impact_reason: ${JSON.stringify(impactReason)}
29
35
  specs: []
30
36
  ---
31
37
 
@@ -96,13 +102,14 @@ export function newChange(vaultBase, slug, { sessionRel = '', dateStr, simple =
96
102
  const dir = join(vaultBase, loc.folders.changes, slug);
97
103
  const existed = existsSync(join(dir, 'proposta.md'));
98
104
  mkdirSync(dir, { recursive: true });
99
- const files = renderChangeScaffold({ slug, sessionRel, dateStr, locale: loc.id });
105
+ const files = renderChangeScaffold({ slug, sessionRel, dateStr, locale: loc.id, simple });
100
106
  const write = (name, content) => {
101
107
  const f = join(dir, name);
102
108
  if (!existsSync(f)) writeFileSync(f, content, 'utf8');
103
109
  };
104
110
  write('proposta.md', files.proposta);
105
111
  write('tarefas.md', files.tarefas);
112
+ if (!existed) write('.spec-impact-v1', '1\n');
106
113
  // Auto-sizing (Wave B): a --simple change skips the design + spec-delta scaffold.
107
114
  if (!simple) {
108
115
  write('design.md', files.design);
@@ -291,6 +298,12 @@ export function archiveChange(vaultBase, slug, { gate = gateGreen, dateStr, adrN
291
298
  const loc = getLocale(vaultBase);
292
299
  const chDir = loc.folders.changes;
293
300
  const src = join(vaultBase, chDir, slug);
301
+ let sourceSessionRel = '';
302
+ try {
303
+ const proposal = readFileSync(join(src, 'proposta.md'), 'utf8');
304
+ const m = proposal.match(/\[\[((?:02-Sessões|02-Sessions)\/[^\]|]+?)(?:\|[^\]]+)?\]\]/);
305
+ if (m) sourceSessionRel = m[1].endsWith('.md') ? m[1] : `${m[1]}.md`;
306
+ } catch { /* sem source */ }
294
307
  const verdict = gate(src);
295
308
  if (!verdict.ok) return { ok: false, failing: verdict.failing || [] };
296
309
 
@@ -324,7 +337,9 @@ export function archiveChange(vaultBase, slug, { gate = gateGreen, dateStr, adrN
324
337
  ...res.warnings,
325
338
  ];
326
339
  }
327
- } catch { /* promoção falhou — segue só com ADR */ }
340
+ } catch (error) {
341
+ return { ok: false, failing: [`falha ao promover specs: ${error.message}`] };
342
+ }
328
343
 
329
344
  let reqIds = [];
330
345
  try { reqIds = [...new Set(parseTasks(readFileSync(join(src, 'tarefas.md'), 'utf8')).map((t) => t.req).filter(Boolean))]; } catch { /* sem tarefas */ }
@@ -343,6 +358,17 @@ export function archiveChange(vaultBase, slug, { gate = gateGreen, dateStr, adrN
343
358
  writeFileSync(pp, c, 'utf8');
344
359
  } catch { /* proposta ilegível — segue */ }
345
360
 
361
+ // A sessão guardava o link da change ativa; após o move, reescreva para o caminho arquivado.
362
+ if (sourceSessionRel) {
363
+ try {
364
+ const sessionPath = join(vaultBase, sourceSessionRel);
365
+ const oldLink = wikilinkFromRel(join(chDir, slug, 'proposta'));
366
+ const archivedLink = wikilinkFromRel(join(destRel, 'proposta'));
367
+ const current = readFileSync(sessionPath, 'utf8');
368
+ if (current.includes(oldLink)) writeFileSync(sessionPath, current.replaceAll(oldLink, archivedLink), 'utf8');
369
+ } catch { /* backlink é reparo auxiliar; archive já está íntegro */ }
370
+ }
371
+
346
372
  // ADR goes in the same dated month folder as session-derived decisions (04-Decisões/ano/MM-MMM/)
347
373
  // — not the year root — so all ADRs sit together in the vault's convention.
348
374
  const adrDirRel = monthFolderRelFromDateStr(loc.folders.decisions, dateStr, vaultBase);
@@ -33,9 +33,20 @@ export function decisionKeyExists(dir, key) {
33
33
  return false;
34
34
  }
35
35
 
36
- // The AskUserQuestion tool_output reads: `... "Question"="chosen labels" "Q2"="..."`.
36
+ // Claude Code >= 2.1 returns `{ answers: { Question: choice } }` in tool_response.
37
+ // Older releases expose a text tool_output: `... "Question"="chosen labels" "Q2"="..."`.
37
38
  export function parseAnswers(output) {
38
39
  const map = {};
40
+ if (output && typeof output === 'object') {
41
+ const answers = output.answers && typeof output.answers === 'object'
42
+ ? output.answers
43
+ : output;
44
+ for (const [question, answer] of Object.entries(answers)) {
45
+ if (typeof answer === 'string') map[clean(question)] = clean(answer);
46
+ else if (Array.isArray(answer)) map[clean(question)] = answer.map(clean).filter(Boolean).join(',');
47
+ }
48
+ if (Object.keys(map).length) return map;
49
+ }
39
50
  const re = /"([^"]+)"\s*=\s*"([^"]*)"/g;
40
51
  let m;
41
52
  while ((m = re.exec(String(output || '')))) map[m[1].trim()] = m[2].trim();
@@ -172,7 +183,35 @@ export function captureDecision(vaultBase, input) {
172
183
  writeFileSync(filePath, buildDecisionCaptureNote({
173
184
  questions, answers, dateStr, startedAt: formatLocalIso(now), sessionRel, provider, localeId: loc.id, adrNum, contentKey,
174
185
  }), 'utf-8');
175
- return { rel: toVaultRelative(vaultBase, filePath), skipped: false };
186
+ const rel = toVaultRelative(vaultBase, filePath);
187
+ if (sessionRel) {
188
+ try {
189
+ const sessionPath = join(vaultBase, sessionRel);
190
+ let session = readFileSync(sessionPath, 'utf8');
191
+ const wikilink = wikilinkFromRel(rel);
192
+ const link = `- ${wikilink}`;
193
+ if (!session.includes(wikilink)) {
194
+ const heading = '\n## Decisões geradas nesta sessão\n';
195
+ const at = session.indexOf(heading);
196
+ if (at !== -1) {
197
+ const bodyStart = at + heading.length;
198
+ const nextRel = session.slice(bodyStart).search(/\n## /);
199
+ const bodyEnd = nextRel === -1 ? session.length : bodyStart + nextRel;
200
+ const body = session.slice(bodyStart, bodyEnd)
201
+ .replace(/\n?Nenhuma decisão registrada ainda\.\s*/i, '\n')
202
+ .trim();
203
+ const merged = [body, link].filter(Boolean).join('\n');
204
+ session = `${session.slice(0, bodyStart)}\n${merged}\n\n${session.slice(bodyEnd).replace(/^\n+/, '')}`;
205
+ } else {
206
+ const anchor = session.indexOf('\n## Encerramento');
207
+ const section = `\n## Decisões geradas nesta sessão\n\n${link}\n`;
208
+ session = anchor === -1 ? `${session.trimEnd()}${section}` : `${session.slice(0, anchor).trimEnd()}${section}${session.slice(anchor)}`;
209
+ }
210
+ writeFileSync(sessionPath, session, 'utf8');
211
+ }
212
+ } catch { /* backlink auxiliar nunca derruba a captura */ }
213
+ }
214
+ return { rel, skipped: false };
176
215
  }
177
216
 
178
217
  if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
@@ -3,7 +3,7 @@
3
3
  import { existsSync, readFileSync, readdirSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
5
  import { activeChange, parseTasks } from './change-core.mjs';
6
- import { parseRequirements, parseSpecsList, parseDelta, evaluateVerdict } from './spec-core.mjs';
6
+ import { parseRequirements, parseSpecsList, parseDelta, evaluateVerdict, validateSpecImpact } from './spec-core.mjs';
7
7
  import { getLocale } from './locale.mjs';
8
8
 
9
9
  export function checkHarness(vaultBase, projectRoot) {
@@ -43,6 +43,9 @@ export function checkHarness(vaultBase, projectRoot) {
43
43
  let entries;
44
44
  try { entries = readdirSync(dir); } catch { continue; } // a file, not a change dir
45
45
  if (!entries.includes('proposta.md')) { errors.push(`change sem proposta.md: ${name}`); continue; }
46
+ const impact = validateSpecImpact(dir);
47
+ errors.push(...impact.errors.map((e) => `${name}: ${e}`));
48
+ warnings.push(...impact.warnings.map((w) => `${name}: ${w}`));
46
49
  if (name === active) {
47
50
  try {
48
51
  for (const cap of parseSpecsList(readFileSync(join(dir, 'proposta.md'), 'utf8'))) {
@@ -3,10 +3,20 @@
3
3
  // Code e o vault: quando o usuário APROVA um plano, o plano vira registro — anexado à change
4
4
  // ativa, ou uma change nova criada e preenchida a partir dele (proposta do Contexto, design do
5
5
  // corpo, tarefas dos checkboxes). Não depende de a LLM lembrar do processo. Rejeição = no-op.
6
- import { readFileSync, writeFileSync } from 'node:fs';
6
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
7
+ import { createHash } from 'node:crypto';
7
8
  import { join } from 'node:path';
8
9
  import { pathToFileURL } from 'node:url';
9
- import { formatDate, getVaultBase, readHookInput, slugify, writeHookOutput } from './obsidian-common.mjs';
10
+ import {
11
+ findActiveSessionByTranscript,
12
+ formatDate,
13
+ getVaultBase,
14
+ readControl,
15
+ readHookInput,
16
+ slugify,
17
+ wikilinkFromRel,
18
+ writeHookOutput,
19
+ } from './obsidian-common.mjs';
10
20
  import { activeChange, newChange } from './change-core.mjs';
11
21
  import { getLocale } from './locale.mjs';
12
22
 
@@ -17,8 +27,18 @@ export function extractPlan(input) {
17
27
  const tr = input?.tool_response ?? input?.toolResponse ?? '';
18
28
  const resp = typeof tr === 'string' ? tr : JSON.stringify(tr || '');
19
29
  if (/doesn'?t want to proceed|user rejected|rejected the plan/i.test(resp)) return null;
20
- if (!/approved (your |the )?plan|Approved Plan/i.test(resp)) return null;
21
30
  const direct = input?.tool_input?.plan ?? input?.toolInput?.plan;
31
+ // Claude Code atual entrega o plano aprovado como objeto estruturado no PostToolUse.
32
+ // Esse evento só ocorre após sucesso; flags negativas explícitas continuam no-op.
33
+ if (tr && typeof tr === 'object') {
34
+ if (tr.approved === false || tr.rejected === true || tr.cancelled === true) return null;
35
+ const structured = tr.plan ?? direct;
36
+ if (structured && String(structured).trim()) return String(structured);
37
+ const structuredPath = tr.filePath ?? tr.planFilePath;
38
+ if (structuredPath) { try { return readFileSync(String(structuredPath), 'utf8'); } catch { /* inacessível */ } }
39
+ return null;
40
+ }
41
+ if (!/approved (your |the )?plan|Approved Plan/i.test(resp)) return null;
22
42
  if (direct && String(direct).trim()) return String(direct);
23
43
  const marker = resp.match(/## Approved Plan[^\n]*:\s*\n([\s\S]+)$/);
24
44
  if (marker && marker[1].trim()) return marker[1].trim();
@@ -27,6 +47,27 @@ export function extractPlan(input) {
27
47
  return null;
28
48
  }
29
49
 
50
+ function planIndex(slug) {
51
+ return `---\ntype: plan-index\ntags:\n - plano\n---\n\n# ${slug} — planos aprovados\n`;
52
+ }
53
+
54
+ export function persistApprovedPlan(dir, slug, rawNote, plan, dateStr) {
55
+ const hash = createHash('sha256').update(String(plan)).digest('hex').slice(0, 12);
56
+ const snapshotsDir = join(dir, 'planos');
57
+ mkdirSync(snapshotsDir, { recursive: true });
58
+ const snapshot = join(snapshotsDir, `${hash}.md`);
59
+ if (!existsSync(snapshot)) writeFileSync(snapshot, rawNote, 'utf8');
60
+
61
+ const indexPath = join(dir, 'plano-aprovado.md');
62
+ let index = existsSync(indexPath) ? readFileSync(indexPath, 'utf8') : planIndex(slug);
63
+ const entry = `- [[planos/${hash}|${dateStr} — ${hash}]]`;
64
+ if (!index.includes(`[[planos/${hash}|`)) {
65
+ index = `${index.trimEnd()}\n\n${entry}\n`;
66
+ writeFileSync(indexPath, index, 'utf8');
67
+ }
68
+ return { hash, snapshot };
69
+ }
70
+
30
71
  export function planSlug(plan) {
31
72
  const h1 = String(plan || '').match(/^#\s+(.+)$/m);
32
73
  return h1 ? slugify(h1[1], 'plano-aprovado', 60) : 'plano-aprovado';
@@ -53,11 +94,15 @@ export function capturePlan(vaultBase, input) {
53
94
  const en = loc.id === 'en';
54
95
  const dateStr = formatDate(new Date());
55
96
  const rawNote = `---\ntype: plan\ndate: ${dateStr}\ntags:\n - plano\n---\n\n${plan.trim()}\n`;
97
+ const transcriptPath = input?.transcript_path ?? input?.transcriptPath ?? '';
98
+ const sessionRel = findActiveSessionByTranscript(vaultBase, transcriptPath)?.session_file
99
+ || readControl(vaultBase).session_file
100
+ || '';
56
101
 
57
102
  const active = activeChange(vaultBase);
58
103
  if (active) {
59
104
  const dir = join(vaultBase, loc.folders.changes, active);
60
- writeFileSync(join(dir, 'plano-aprovado.md'), rawNote, 'utf8');
105
+ persistApprovedPlan(dir, active, rawNote, plan, dateStr);
61
106
  return {
62
107
  slug: active, created: false,
63
108
  context: `<plan_captured>\n${en
@@ -67,9 +112,10 @@ export function capturePlan(vaultBase, input) {
67
112
  }
68
113
 
69
114
  const slug = planSlug(plan);
70
- newChange(vaultBase, slug, { dateStr });
115
+ newChange(vaultBase, slug, { dateStr, sessionRel });
71
116
  const dir = join(vaultBase, loc.folders.changes, slug);
72
117
  const ctx = sectionBody(plan, ['Contexto', 'Context']) || plan.trim().split('\n').slice(0, 6).join('\n');
118
+ const source = sessionRel ? `\n - "${wikilinkFromRel(sessionRel)}"` : ' []';
73
119
  writeFileSync(join(dir, 'proposta.md'), `---
74
120
  type: change
75
121
  status: active
@@ -78,7 +124,9 @@ cssclasses:
78
124
  - topic-change
79
125
  tags:
80
126
  - mudanca
81
- source: []
127
+ source:${source}
128
+ spec_impact: pending
129
+ spec_impact_reason: ""
82
130
  specs: []
83
131
  ---
84
132
 
@@ -95,7 +143,7 @@ ${en ? 'See design.md and plano-aprovado.md (captured from the approved plan-mod
95
143
  writeFileSync(join(dir, 'design.md'), `# ${slug} — design\n\n${plan.trim()}\n`, 'utf8');
96
144
  const tasks = planTasks(plan);
97
145
  if (tasks.length) writeFileSync(join(dir, 'tarefas.md'), `# ${slug} — ${en ? 'tasks' : 'tarefas'}\n\n${tasks.join('\n')}\n`, 'utf8');
98
- writeFileSync(join(dir, 'plano-aprovado.md'), rawNote, 'utf8');
146
+ persistApprovedPlan(dir, slug, rawNote, plan, dateStr);
99
147
  return {
100
148
  slug, created: true,
101
149
  context: `<plan_captured>\n${en
@@ -110,7 +158,11 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
110
158
  const r = capturePlan(getVaultBase(input), input);
111
159
  if (!r) { writeHookOutput({}); }
112
160
  else writeHookOutput({ hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: r.context } });
113
- } catch {
114
- writeHookOutput({});
161
+ } catch (error) {
162
+ process.stderr.write(`[wendkeep] plan-capture falhou: ${error.message}\n`);
163
+ writeHookOutput({ hookSpecificOutput: {
164
+ hookEventName: 'PostToolUse',
165
+ additionalContext: `<plan_capture_error>O plano aprovado não foi persistido: ${error.message}</plan_capture_error>`,
166
+ } });
115
167
  }
116
168
  }
@@ -1,7 +1,7 @@
1
1
  // hooks/spec-core.mjs — living spec (07-Specs) + change delta merge (OpenSpec native).
2
2
  // Pure parsing/merge + promoteSpecs (fs). No import from change-core (avoids a cycle).
3
3
  import { createHash } from 'node:crypto';
4
- import { readFileSync, readdirSync, writeFileSync } from 'node:fs';
4
+ import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
5
5
  import { join } from 'node:path';
6
6
  import { ensureDir } from './obsidian-common.mjs';
7
7
  import { getLocale } from './locale.mjs';
@@ -87,6 +87,49 @@ export function parseSpecsList(propostaMd) {
87
87
  return [];
88
88
  }
89
89
 
90
+ function yamlScalar(text, key) {
91
+ const m = String(text).match(new RegExp(`^${key}:\\s*(.*)$`, 'm'));
92
+ return m ? m[1].trim().replace(/^['"]|['"]$/g, '') : '';
93
+ }
94
+
95
+ export function parseSpecImpact(propostaMd) {
96
+ const text = String(propostaMd || '');
97
+ const status = yamlScalar(text, 'spec_impact') || yamlScalar(text, 'spec-impact');
98
+ const reason = yamlScalar(text, 'spec_impact_reason') || yamlScalar(text, 'spec-impact-reason');
99
+ return { status, reason };
100
+ }
101
+
102
+ export function validateSpecImpact(changeDir) {
103
+ let proposta = '';
104
+ try { proposta = readFileSync(join(changeDir, 'proposta.md'), 'utf8'); }
105
+ catch { return { ok: false, errors: ['proposta.md ausente'], warnings: [], status: '', listed: [], onDisk: [] }; }
106
+
107
+ const { status, reason } = parseSpecImpact(proposta);
108
+ const listed = parseSpecsList(proposta);
109
+ const onDisk = discoverSpecDeltas(changeDir);
110
+ const enforced = existsSync(join(changeDir, '.spec-impact-v1'));
111
+ const errors = [];
112
+ const warnings = [];
113
+
114
+ if (!status) {
115
+ if (enforced) errors.push('spec_impact ausente — classifique como required ou none');
116
+ else warnings.push('change legada sem spec_impact — migre para required ou none antes do próximo ciclo');
117
+ return { ok: errors.length === 0, errors, warnings, status: '', listed, onDisk, legacy: !enforced };
118
+ }
119
+ if (!['pending', 'required', 'none'].includes(status)) errors.push(`spec_impact inválido: ${status}`);
120
+ if (status === 'pending') errors.push('spec_impact pending — classifique o impacto antes de arquivar');
121
+ if (status === 'none') {
122
+ if (!reason) errors.push('spec_impact none exige justificativa em spec_impact_reason');
123
+ if (listed.length || onDisk.length) errors.push('spec_impact none contradiz specs/deltas declarados');
124
+ }
125
+ if (status === 'required') {
126
+ if (!listed.length) errors.push('spec_impact required exige ao menos uma capability em specs');
127
+ for (const cap of listed) if (!onDisk.includes(cap)) errors.push(`delta real ausente para capability ${cap}`);
128
+ for (const cap of onDisk) if (!listed.includes(cap)) errors.push(`delta ${cap} existe no disco mas não está listado em specs`);
129
+ }
130
+ return { ok: errors.length === 0, errors, warnings, status, reason, listed, onDisk, legacy: false };
131
+ }
132
+
90
133
  // Um delta ainda no estado do scaffold (só o requisito "(nome)"/"(name)", nada removido) não é
91
134
  // contrato — a promoção o filtra. Um delta com REMOVED é sempre intenção real.
92
135
  export function isPlaceholderDelta(md) {
@@ -118,9 +161,11 @@ export function promoteSpecs(vaultBase, changeDir, specs, { changeWikilink, date
118
161
  const promoted = [];
119
162
  const warnings = [];
120
163
  for (const cap of specs) {
121
- let delta;
122
- try { delta = parseDelta(readFileSync(join(changeDir, 'specs', cap, 'spec.md'), 'utf8')); }
123
- catch { warnings.push(`sem delta para ${cap}`); continue; }
164
+ let deltaMd;
165
+ try { deltaMd = readFileSync(join(changeDir, 'specs', cap, 'spec.md'), 'utf8'); }
166
+ catch { throw new Error(`delta ausente para ${cap}`); }
167
+ if (isPlaceholderDelta(deltaMd)) throw new Error(`delta placeholder para ${cap}`);
168
+ const delta = parseDelta(deltaMd);
124
169
  const livePath = join(vaultBase, specsDir, `${cap}.md`);
125
170
  let current = [];
126
171
  try { current = parseRequirements(readFileSync(livePath, 'utf8')); } catch { /* nova capability */ }
@@ -71,14 +71,14 @@ function hasDefaultPending(content) {
71
71
  .some((line) => DEFAULT_PENDING_PATTERNS.some((pattern) => pattern.test(line.trim())));
72
72
  }
73
73
 
74
- function usageSectionIsPlaced(content) {
74
+ function usageSectionIsPlaced(content, { active = false } = {}) {
75
75
  const usage = content.indexOf('\n## Uso de tokens e custos');
76
76
  if (usage === -1) return true;
77
77
  const changed = content.indexOf('\n## Arquivos criados ou alterados');
78
78
  const pending = content.indexOf('\n## Pendências');
79
79
  const closing = content.indexOf('\n## Encerramento');
80
- if (pending === -1 || closing === -1) return false;
81
- return usage < pending && usage < closing && (changed === -1 || usage > changed);
80
+ if (pending === -1 || (!active && closing === -1)) return false;
81
+ return usage < pending && (active || usage < closing) && (changed === -1 || usage > changed);
82
82
  }
83
83
 
84
84
  function linkedNotesFromSession(content) {
@@ -102,14 +102,15 @@ function checkSession({ vaultBase, sessionRel, control, registry }) {
102
102
  return { failures, warnings, metrics };
103
103
  }
104
104
 
105
- const content = readFileSync(sessionPath, 'utf-8');
105
+ const content = readFileSync(sessionPath, 'utf-8');
106
+ const activeSession = control.status === 'active' && control.session_file === sessionRel;
106
107
  const duplicates = findDuplicateTurnMarkers(content);
107
108
  metrics.turnMarkers = (content.match(/<!-- (?:wk-turn|codex-turn):/g) || []).length;
108
109
  metrics.duplicateTurnMarkers = duplicates.length;
109
110
 
110
111
  if (duplicates.length) failures.push(`Marcadores de turno duplicados: ${duplicates.join(', ')}`);
111
112
  if (hasHeadingAfterClosing(content)) failures.push('Há headings/iterações após ## Encerramento.');
112
- if (!usageSectionIsPlaced(content)) failures.push('## Uso de tokens e custos está fora da posição esperada.');
113
+ if (!usageSectionIsPlaced(content, { active: activeSession })) failures.push('## Uso de tokens e custos está fora da posição esperada.');
113
114
  if (hasDefaultPending(content)) warnings.push('Pendências ainda contém placeholders padrão.');
114
115
 
115
116
  const registryEntry = registry.sessions?.[control.session_id];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wendkeep",
3
- "version": "0.31.0",
3
+ "version": "0.32.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": {
package/src/change.mjs CHANGED
@@ -12,7 +12,7 @@ import {
12
12
  scaffoldPlaceholders,
13
13
  } from '../hooks/change-core.mjs';
14
14
  import { evaluateGate, requiredSensors } from '../hooks/sensors-core.mjs';
15
- import { evaluateVerdict, tasksHashOf, parseSpecsList, parseDelta, parseRequirements, applyDelta } from '../hooks/spec-core.mjs';
15
+ import { evaluateVerdict, tasksHashOf, parseSpecsList, parseDelta, parseRequirements, applyDelta, validateSpecImpact } from '../hooks/spec-core.mjs';
16
16
  import { getNextAdrNumber, readControl } from '../hooks/obsidian-common.mjs';
17
17
  import { getLocale } from '../hooks/locale.mjs';
18
18
 
@@ -163,6 +163,9 @@ export function runChange(argv) {
163
163
  if (placeholders.length) {
164
164
  return { ok: false, failing: [`scaffold não preenchido (${placeholders.join('; ')}) — preencha proposta/design/tarefas antes de arquivar, ou \`wendkeep change abandon ${slug}\` se a change não vai adiante (--force não pula este check)`] };
165
165
  }
166
+ const impact = validateSpecImpact(dir);
167
+ for (const warning of impact.warnings) process.stderr.write(`aviso spec: ${warning}\n`);
168
+ if (!impact.ok) return { ok: false, failing: impact.errors };
166
169
  let tarefasMd = '';
167
170
  try { tarefasMd = readFileSync(join(dir, 'tarefas.md'), 'utf8'); } catch { /* no tasks */ }
168
171
  const tasks = parseTasks(tarefasMd);
package/src/init.mjs CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  mcpServerEntry,
16
16
  hookCommand,
17
17
  hookCommandLocal,
18
+ hookCommandLocalLegacy,
18
19
  deriveVaultDirName,
19
20
  selectableCompanions,
20
21
  resolveCompanions,
@@ -105,6 +106,16 @@ export function hookCommandFor(name, projectPath) {
105
106
  return hookCommand(name);
106
107
  }
107
108
 
109
+ function localHookAvailable(name, projectPath) {
110
+ try {
111
+ return !!projectPath && existsSync(join(projectPath, 'node_modules', 'wendkeep', 'hooks', `${name}.mjs`));
112
+ } catch { return false; }
113
+ }
114
+
115
+ function localHookArg(name) {
116
+ return `${'${CLAUDE_PROJECT_DIR}'}/node_modules/wendkeep/hooks/${name}.mjs`;
117
+ }
118
+
108
119
  export function mergeSettings(existing, { vaultPath, withMcp, force, companions = [], skipMcp = [], dotcontextHookLevel = 'full', projectPath = '' }) {
109
120
  const s = existing && typeof existing === 'object' ? { ...existing } : {};
110
121
  s.hooks = { ...(s.hooks || {}) };
@@ -120,19 +131,26 @@ export function mergeSettings(existing, { vaultPath, withMcp, force, companions
120
131
  ...companionHookSpecs(companions, { dotcontextHookLevel }),
121
132
  ].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
122
133
  for (const h of allSpecs) {
123
- const command = h.command ?? (h.preferLocal ? hookCommandFor(h.name, projectPath) : hookCommand(h.name));
134
+ const useLocal = !h.command && h.preferLocal && localHookAvailable(h.name, projectPath);
135
+ const command = h.command ?? (useLocal ? 'node' : hookCommand(h.name));
136
+ const args = useLocal ? [localHookArg(h.name)] : undefined;
124
137
  // Dual-recognition: um hook nomeado é reconhecido tanto na forma npx quanto na node-direta,
125
138
  // para que trocar a forma preferida (ou re-initar noutra máquina) nunca duplique o grupo.
126
- const candidates = h.command ? [h.command] : [hookCommand(h.name), hookCommandLocal(h.name)];
139
+ const candidates = h.command ? [h.command] : [hookCommand(h.name), hookCommandLocal(h.name), hookCommandLocalLegacy(h.name)];
140
+ const ownsHook = (x) => candidates.includes(x.command)
141
+ || (x.command === 'node' && Array.isArray(x.args) && x.args[0] === localHookArg(h.name));
127
142
  const groups = Array.isArray(s.hooks[h.event]) ? [...s.hooks[h.event]] : [];
128
- const owning = groups.find((g) => (g.hooks || []).some((x) => candidates.includes(x.command)));
143
+ const owning = groups.find((g) => (g.hooks || []).some(ownsHook));
129
144
  if (owning) {
130
145
  // Already wired: never add a duplicate group (the old `if (present && !force)` fell through
131
146
  // under --force and appended a second identical group). Under --force, refresh the managed
132
147
  // entry's fields in place — without disturbing any sibling hooks the user grouped with it.
133
- if (force) {
134
- const hk = owning.hooks.find((x) => candidates.includes(x.command));
148
+ const hk = owning.hooks.find(ownsHook);
149
+ const brokenRelative = hk?.command === hookCommandLocalLegacy(h.name);
150
+ if (force || brokenRelative) {
135
151
  hk.command = command;
152
+ if (args) hk.args = args;
153
+ else delete hk.args;
136
154
  hk.timeout = h.timeout;
137
155
  if (h.statusMessage) hk.statusMessage = h.statusMessage;
138
156
  if (h.matcher && (owning.hooks || []).length === 1) owning.matcher = h.matcher;
@@ -141,6 +159,7 @@ export function mergeSettings(existing, { vaultPath, withMcp, force, companions
141
159
  continue;
142
160
  }
143
161
  const entry = { type: 'command', command, timeout: h.timeout, statusMessage: h.statusMessage };
162
+ if (args) entry.args = args;
144
163
  const group = h.matcher ? { matcher: h.matcher, hooks: [entry] } : { hooks: [entry] };
145
164
  groups.push(group);
146
165
  s.hooks[h.event] = groups;
@@ -530,6 +549,8 @@ export async function runInit(argv) {
530
549
  log(M.mcpSkipped);
531
550
  }
532
551
 
552
+ log(' [!] ignore runtime do wendkeep no Git quando o vault for versionado: .brain/.change-*');
553
+
533
554
  // 4. Vault color system (.obsidian) -----------------------------------------
534
555
  if (args.noColors) {
535
556
  log(M.colorsSkipped);
@@ -31,6 +31,11 @@ vault cego. Exceção única: mudança trivial (typo, 1 linha).
31
31
  - \`tarefas.md\` — a lista de tarefas \`- [ ] N.N descrição\`.
32
32
  A mudança vira a *ativa* (ponteiro \`.brain/CURRENT_CHANGE.md\`) e é injetada no
33
33
  próximo SessionStart, então você retoma o trabalho em curso automaticamente.
34
+ Antes de implementar, resolva \`spec_impact\` na proposta:
35
+ - \`required\`: liste a capability em \`specs:\` e preencha
36
+ \`specs/<capability>/spec.md\` com ADDED/MODIFIED/REMOVED; ligue tarefas com \`[req:ID]\`.
37
+ - \`none\`: registre uma justificativa real em \`spec_impact_reason\`.
38
+ \`pending\` nunca é estado pronto para implementação ou archive.
34
39
  3. **Apply** — implemente cada tarefa de \`tarefas.md\` com disciplina **wk-tdd**
35
40
  (teste vermelho antes do código). Marque \`- [x]\` ao concluir. Declare nas tarefas:
36
41
  - \`[sensor:<id>]\` — a prova automatizada (roda no verify).
@@ -139,6 +144,7 @@ qualquer código.
139
144
  Antes de fechar o design, resolva cada zona cinza: decide com o usuário, ou registra como
140
145
  **assumption assinada** ("assumo X porque Y — corrija se errado"). Cinza declinado pelo
141
146
  usuário é *registrado*, não descartado no silêncio. Nada sai do design silenciosamente ambíguo.
147
+ Declare também a capability e se o design tem \`spec_impact: required\` ou \`none\`.
142
148
 
143
149
  ## Tabela out-of-scope
144
150
 
@@ -170,6 +176,10 @@ Mapeie os arquivos: quais criar/modificar e a responsabilidade de cada um. Arqui
170
176
  mudam juntos ficam juntos; um arquivo, uma responsabilidade. É aqui que a decomposição
171
177
  trava.
172
178
 
179
+ Resolva o contrato antes de decompor: \`spec_impact: required\` exige capability + delta real em
180
+ \`specs/<capability>/spec.md\`; \`spec_impact: none\` exige justificativa. Cada comportamento do
181
+ delta recebe ID e as tarefas correspondentes usam \`[req:ID]\`.
182
+
173
183
  ## Tarefas bite-sized (TDD)
174
184
 
175
185
  Cada tarefa termina num entregável testável de forma independente. Cada passo é uma ação
@@ -240,6 +250,9 @@ leaves the vault blind. Single exception: a trivial change (typo, one line).
240
250
  2. **Propose** — \`wendkeep change new <slug>\` scaffolds \`08-Changes/<slug>/\`
241
251
  (proposta/design/tarefas + a \`specs/\` delta). The change becomes *active* and is
242
252
  injected at the next SessionStart.
253
+ Before implementation, resolve \`spec_impact\`: \`required\` needs the capability listed in
254
+ \`specs:\` plus a real \`specs/<capability>/spec.md\` delta and \`[req:ID]\` links; \`none\`
255
+ needs a real \`spec_impact_reason\`. \`pending\` is never ready for implementation/archive.
243
256
  3. **Apply** — implement each task in tarefas.md with **wk-tdd** (red test first). Tag tasks:
244
257
  \`[sensor:<id>]\` (automated proof) and \`[req:<ID>]\` (the spec requirement it satisfies).
245
258
  4. **Verify** — \`wendkeep verify\` runs the sensors; then \`wendkeep verify --deep\` builds
@@ -310,6 +323,7 @@ Use it when the idea is still vague. Turn it into an approved design BEFORE writ
310
323
  ## Closure gate — no dangling ambiguity
311
324
  Resolve every gray area: decide with the user, or log a **signed-off assumption** ("assuming X
312
325
  because Y — correct me if wrong"). A declined gray area is recorded, not silently dropped.
326
+ Also declare the capability and whether the design has \`spec_impact: required\` or \`none\`.
313
327
 
314
328
  ## Out-of-scope table
315
329
  List explicitly what the change does **not** do. Undeclared scope becomes creep.
@@ -331,6 +345,10 @@ Use it after an approved design. Produce a plan an engineer with no project cont
331
345
  Map the files: what to create/modify and each one's responsibility. Files that change together
332
346
  live together; one file, one responsibility.
333
347
 
348
+ Resolve the contract first: \`spec_impact: required\` needs a capability and a real delta at
349
+ \`specs/<capability>/spec.md\`; \`spec_impact: none\` needs a reason. Give each behaviour an ID
350
+ and link the corresponding tasks with \`[req:ID]\`.
351
+
334
352
  ## Bite-sized tasks (TDD)
335
353
  Each task ends in an independently testable deliverable. Each step is a 2–5 min action:
336
354
  write the failing test (show code) → run and see it fail (exact command + expected) → minimal
@@ -434,6 +452,10 @@ comes from the package (freshness seal; edit a task later and the gate rejects i
434
452
 
435
453
  const PLAN_TEMPLATE_PT = `# Template — plano de tarefas (TDD, bite-sized)
436
454
 
455
+ ## Impacto em specs
456
+ - \`spec_impact\`: \`required\` | \`none\`
457
+ - Capability/delta: \`specs/<capability>/spec.md\` ou justificativa de \`none\`
458
+
437
459
  ## Arquivos
438
460
  - Criar: \`caminho/exato.mjs\`
439
461
  - Modificar: \`caminho/existente.mjs:120-140\`
@@ -457,6 +479,10 @@ tarefas (uma função é \`x()\` em toda parte). Cada tarefa termina num entreg
457
479
 
458
480
  const PLAN_TEMPLATE_EN = `# Template — task plan (TDD, bite-sized)
459
481
 
482
+ ## Spec impact
483
+ - \`spec_impact\`: \`required\` | \`none\`
484
+ - Capability/delta: \`specs/<capability>/spec.md\` or the \`none\` rationale
485
+
460
486
  ## Files
461
487
  - Create: \`exact/path.mjs\`
462
488
  - Modify: \`exact/existing.mjs:120-140\`
package/src/taxonomy.mjs CHANGED
@@ -121,6 +121,10 @@ export function hookCommand(name) {
121
121
  // de segundos no Windows). Usada pelos hooks de ALTA FREQUÊNCIA (por prompt / por tool-call)
122
122
  // quando o projeto tem wendkeep instalado localmente; o init decide (hookCommandFor).
123
123
  export function hookCommandLocal(name) {
124
+ return `node "${'${CLAUDE_PROJECT_DIR}'}/node_modules/wendkeep/hooks/${name}.mjs"`;
125
+ }
126
+
127
+ export function hookCommandLocalLegacy(name) {
124
128
  return `node node_modules/wendkeep/hooks/${name}.mjs`;
125
129
  }
126
130