wendkeep 0.30.0 → 0.31.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,71 @@ 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.31.0] — 2026-07-09
8
+
9
+ ### Added — enforcement do loop a2 (o loop deixa de ser opcional na prática)
10
+
11
+ - **5 hooks de lifecycle novos**, wired por default pelo `wendkeep init` (invocação **node-direta**
12
+ `node node_modules/wendkeep/hooks/<name>.mjs` quando o pacote está instalado no projeto —
13
+ ~100-250ms vs segundos do npx no Windows; fallback npx):
14
+ - **`change-context`** (UserPromptSubmit) — re-injeta a change ativa (`<active_change_ping>`:
15
+ slug + tarefas abertas) SÓ quando o estado mudou desde a última injeção (hash em sentinela por
16
+ sessão). Sem change ativa: prompt com cara de tarefa ganha `<wk_skill_gate>` mandando invocar
17
+ a Skill wk-workflow ANTES de editar — 1x por sessão.
18
+ - **`change-warn`** (PostToolUse `Edit|Write|MultiEdit`) — edição de código sem change ativa
19
+ gera aviso 1x/sessão (nunca bloqueia; ignora vault/.claude/.agent/.brain e não-código).
20
+ - **`change-guard`** (PreToolUse `Bash`) — `wendkeep change archive --force` vindo do agente é
21
+ **negado** (deny; escape: `WENDKEEP_ALLOW_FORCE=1` no ambiente); `git commit` com change ativa
22
+ E (`--no-verify` OU sensor crítico vermelho) vira **ask** (o usuário decide). Fast-path sem
23
+ I/O para comandos comuns.
24
+ - **`change-nag`** (Stop) — change ativa com tarefas abertas bloqueia o encerramento 1x/sessão
25
+ cobrando fechamento honesto (done / verify / **ou informar a pendência ao usuário**).
26
+ Anti-loop absoluto via `stop_hook_active`.
27
+ - **`plan-capture`** (PostToolUse `ExitPlanMode`) — **a ponte determinística plan-mode → vault**:
28
+ plano aprovado no plan mode do Claude Code vira change no vault (proposta do Contexto, design
29
+ do corpo, tarefas dos checkboxes) ou anexa `plano-aprovado.md` à change ativa. Não depende de
30
+ a LLM lembrar do processo.
31
+ - **`wendkeep change abandon [slug]`** — a saída legítima para change que não vai adiante: move
32
+ para `_arquivo/<data>-<slug>-abandonada` com `status: abandoned`, SEM ADR, SEM promoção de
33
+ specs; limpa o ponteiro só se era a ativa. Elimina o motivo real de `--force` em scaffold.
34
+ - **`quickGateState(vaultBase)`** + sentinelas por sessão (`.brain/.change-*-<sid>`, GC >7 dias no
35
+ Stop) em change-core — fonte única do estado do gate para hooks e CLI.
36
+ - **`wendkeep sync-defs --reseed`** — re-semeia as skills wk-* de `.brain/skills` com os seeds da
37
+ versão instalada (é como um vault existente recebe as descriptions/HARD-GATE novos).
38
+
39
+ ### Changed — gate endurecido + ativação da skill
40
+
41
+ - **Verdict SEMPRE exigido no archive** (breaking-ish): sem `verdict.json` o archive bloqueia,
42
+ mesmo sem `[req:]` — `wendkeep verify --deep` grava o verdict trivial automático (1 comando).
43
+ Changes em andamento criadas antes de 0.31.0 precisam de um `verify --deep` antes do archive.
44
+ - **G0 inescapável**: `--force` deixa de pular o check de scaffold — um scaffold cru NUNCA é
45
+ arquivável (era o buraco que mintou ADR falso em produção).
46
+ - **`--force` e trivialidade rastreáveis**: ADR ganha `forced: true` (+ aviso ⚠️ no corpo) quando
47
+ `--force` pulou tarefa aberta, e `trivial: true` quando a change não declarou `[req:]`/`[sensor:]`.
48
+ - **Promoção de specs = união frontmatter + disco**: o archive promove também os deltas REAIS
49
+ achados em `specs/*/spec.md` mesmo com `specs: []` na proposta (warning por cap não listada;
50
+ o `exemplo` placeholder do scaffold é filtrado). Fecha o buraco que deixava 07-Specs vazio com
51
+ delta preenchido no disco.
52
+ - **Ativação da skill (paridade Superpowers)**: descriptions das wk-* reescritas com gatilhos
53
+ concretos ("Use SEMPRE que o usuário pedir para implementar/criar/corrigir/refatorar…
54
+ Invoque ANTES de editar qualquer arquivo"); `<HARD-GATE>` no corpo da wk-workflow; o
55
+ `<wk_process>` do brain-inject agora manda **invocar a Skill** (verbo de skill) e cita `abandon`.
56
+
57
+ ### Fixed
58
+
59
+ - **Seções apagadas pelo Stop (classe de bug, visto em produção)**: qualquer seção inserida entre
60
+ `## Pendências` e `## Encerramento` era descartada pelo finalize (`replacePendingSection`
61
+ reconstruía o span inteiro) — atingia `## Subagents & Workflows` (frontmatter sobrevivia, corpo
62
+ sumia), `## Progresso do plano` e `## Mudanças`. Duas camadas: `upsertSection` agora ancora
63
+ ANTES de `## Pendências`, e o finalize preserva seções desconhecidas dentro do span. Notas
64
+ antigas se autocuram no próximo Stop/backfill (os dados persistem em `subagents/`).
65
+
66
+ ### Known limitation (aceita e documentada)
67
+
68
+ - Hooks do mesmo evento rodam em **paralelo** no Claude Code: quando o `change-nag` bloqueia o
69
+ Stop, o `session-stop` já finalizou a nota — o turno de continuação não é logado nela
70
+ (recuperável via `session-backfill`/`import`; perda máx. de 1 turno, 1x por sessão).
71
+
7
72
  ## [0.30.0] — 2026-07-09
8
73
 
9
74
  ### Changed
package/bin/wendkeep.mjs CHANGED
@@ -44,7 +44,8 @@ Usage:
44
44
 
45
45
  wendkeep doctor [--vault P] Run a vault health check.
46
46
  wendkeep change <sub> Change lifecycle: new [--simple] | list | show | status |
47
- done <id> | undone <id> | diff | archive [--force].
47
+ done <id> | undone <id> | diff | archive [--force] | abandon [slug].
48
+ archive exige verdict (rode verify --deep); abandon descarta sem ADR.
48
49
  wendkeep spec <sub> Living specs: list | show <capability>.
49
50
  wendkeep sensors <sub> list | add <id> "<command>" [--severity --type --report].
50
51
  wendkeep cost [opts] Aggregate AI-coding spend across the vault's sessions.
@@ -70,6 +71,8 @@ Usage:
70
71
  wendkeep sync-defs [opts] Copy versioned defs from the vault's .brain into the
71
72
  project: .brain/agents/*.toml -> .codex/agents,
72
73
  .brain/skills/<name> -> .claude/skills. --vault P --project P.
74
+ --reseed re-semeia as skills wk-* com os seeds da versão instalada
75
+ (sobrescreve edições manuais nas wk-*) antes de copiar.
73
76
  wendkeep --version Print version.
74
77
  wendkeep --help Show this help.
75
78
  `;
@@ -7,7 +7,7 @@ import { join } from 'node:path';
7
7
  import { pathToFileURL } from 'node:url';
8
8
  import { getVaultBase, readHookInput, writeHookOutput } from './obsidian-common.mjs';
9
9
  import { brainDir } from './brain-core.mjs';
10
- import { buildActiveChangeInjection } from './change-core.mjs';
10
+ import { buildActiveChangeInjection, changeCtxState, writeSentinel } from './change-core.mjs';
11
11
  import { buildLessonsInjection } from './lessons-core.mjs';
12
12
  import { getLocale } from './locale.mjs';
13
13
 
@@ -19,23 +19,23 @@ function processRouter(localeId) {
19
19
  if (localeId === 'en') {
20
20
  return [
21
21
  '<wk_process>',
22
- 'Spec-driven process (mandatory for any non-trivial task):',
23
- '1. Plan: skill wk-brainstorming (approved design) → wk-planning (task plan).',
22
+ 'Spec-driven process (mandatory for any non-trivial task): INVOKE the wk-workflow Skill BEFORE editing any file.',
23
+ '1. Plan: invoke the wk-brainstorming Skill (approved design) → wk-planning (task plan).',
24
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.',
25
25
  '3. Implement: wk-tdd per task; tick `- [x]` as you finish. Something broke? wk-debugging.',
26
- '4. Close: `wendkeep verify` (+ `--deep` + skill wk-verify) → `wendkeep change archive`.',
27
- 'NEVER `archive --force` on your own — a red gate means pending work; --force is the user\'s call, not yours.',
26
+ '4. Close: `wendkeep verify` (+ `--deep` + the wk-verify Skill) → `wendkeep change archive`.',
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`.',
28
28
  '</wk_process>',
29
29
  ].join('\n');
30
30
  }
31
31
  return [
32
32
  '<wk_process>',
33
- 'Processo spec-driven (obrigatório em tarefa não-trivial):',
34
- '1. Planejar: skill wk-brainstorming (design aprovado) → wk-planning (plano de tarefas).',
33
+ 'Processo spec-driven (obrigatório em tarefa não-trivial): INVOQUE a Skill wk-workflow ANTES de editar qualquer arquivo.',
34
+ '1. Planejar: invoque a Skill wk-brainstorming (design aprovado) → wk-planning (plano de tarefas).',
35
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.',
36
36
  '3. Implementar: wk-tdd por tarefa; marque `- [x]` ao concluir. Quebrou algo? wk-debugging.',
37
- '4. Fechar: `wendkeep verify` (+ `--deep` + skill wk-verify) → `wendkeep change archive`.',
38
- 'PROIBIDO `archive --force` por conta própria — gate vermelho significa trabalho pendente; --force é decisão do usuário, não sua.',
37
+ '4. Fechar: `wendkeep verify` (+ `--deep` + Skill wk-verify) → `wendkeep change archive`.',
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`.',
39
39
  '</wk_process>',
40
40
  ].join('\n');
41
41
  }
@@ -65,12 +65,19 @@ export function buildInjection(vaultBase) {
65
65
  if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
66
66
  try {
67
67
  const input = readHookInput();
68
+ const vaultBase = getVaultBase(input);
68
69
  writeHookOutput({
69
70
  hookSpecificOutput: {
70
71
  hookEventName: 'SessionStart',
71
- additionalContext: buildInjection(getVaultBase(input)),
72
+ additionalContext: buildInjection(vaultBase),
72
73
  },
73
74
  });
75
+ // Sentinela do change-context: a change ativa acabou de ser injetada aqui, então o hook de
76
+ // UserPromptSubmit não precisa re-pingar no 1º prompt. Bônus — nunca derruba a injeção.
77
+ try {
78
+ const st = changeCtxState(vaultBase);
79
+ if (st) writeSentinel(vaultBase, 'ctx', input.session_id || input.sessionId || '', st.hash);
80
+ } catch { /* sentinela é bônus */ }
74
81
  } catch (error) {
75
82
  process.stderr.write(`[brain] inject falhou: ${error.message}\n`);
76
83
  writeHookOutput({});
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env node
2
+ // UserPromptSubmit hook. Dois papéis, ambos quiet-by-default (a maioria dos prompts não injeta nada):
3
+ // 1. Com change ativa: re-injeta <active_change_ping> (slug + tarefas abertas) SÓ quando o
4
+ // estado mudou desde a última injeção (hash em sentinela por sessão) — o nudge do
5
+ // SessionStart dilui/compacta; este devolve o loop ao contexto no momento certo.
6
+ // 2. Sem change ativa: prompt com cara de tarefa ganha <wk_skill_gate> mandando invocar a
7
+ // Skill wk-workflow ANTES de editar — 1x por sessão. É o empurrão de ativação da skill.
8
+ // Fail-open; brain-inject grava a sentinela ctx no SessionStart para não duplicar no 1º prompt.
9
+ import { pathToFileURL } from 'node:url';
10
+ import { getVaultBase, readHookInput, writeHookOutput } from './obsidian-common.mjs';
11
+ import { changeCtxState, readSentinel, writeSentinel } from './change-core.mjs';
12
+
13
+ // Conservador de propósito: verbos de tarefa comuns (pt+en) + tamanho mínimo. Falso-negativo
14
+ // custa só o nudge; falso-positivo em pergunta curta viraria ruído.
15
+ const TASK_RE = /\b(implement\w*|cri[ae]\w*|corrig\w*|conserta\w*|refator\w*|adicion\w*|remov\w*|migr\w*|constru\w*|desenvolv\w*|fix\w*|add|build\w*|create\w*|refactor\w*|develop\w*|escrev\w*|write)\b/i;
16
+
17
+ export function looksLikeTask(prompt) {
18
+ const p = String(prompt || '').trim();
19
+ return p.length >= 20 && TASK_RE.test(p);
20
+ }
21
+
22
+ // Retorna { context, hash? } quando há algo a injetar; null = silêncio.
23
+ export function buildChangePing(vaultBase, sessionId, prompt = '') {
24
+ const st = changeCtxState(vaultBase);
25
+ if (st) {
26
+ if (readSentinel(vaultBase, 'ctx', sessionId) === st.hash) return null;
27
+ writeSentinel(vaultBase, 'ctx', sessionId, st.hash);
28
+ const lines = st.openTasks.map((t) => `- [ ] ${t.id} ${t.text}`);
29
+ const context = [
30
+ '<active_change_ping>',
31
+ `Mudança ativa: ${st.slug}${st.openTasks.length ? ` — tarefa(s) aberta(s):` : ' — sem tarefas abertas.'}`,
32
+ ...lines,
33
+ 'Ao concluir uma tarefa: `wendkeep change done <id>`. Antes de arquivar: `wendkeep verify`.',
34
+ '</active_change_ping>',
35
+ ].join('\n');
36
+ return { context, hash: st.hash };
37
+ }
38
+ // Sem change ativa: gate de skill para prompt-tarefa, 1x por sessão.
39
+ if (!looksLikeTask(prompt)) return null;
40
+ if (readSentinel(vaultBase, 'gate', sessionId)) return null;
41
+ writeSentinel(vaultBase, 'gate', sessionId);
42
+ return {
43
+ context: [
44
+ '<wk_skill_gate>',
45
+ 'Este pedido parece uma tarefa de código e NÃO há change ativa no vault. Antes de editar qualquer arquivo, invoque a Skill wk-workflow — ela orquestra o loop a2 (`wendkeep change new <slug>` → tarefas → verify → archive) e registra tudo no vault. Ignore apenas se a mudança for trivial (typo, 1 linha).',
46
+ '</wk_skill_gate>',
47
+ ].join('\n'),
48
+ };
49
+ }
50
+
51
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
52
+ try {
53
+ const input = readHookInput();
54
+ const sid = input.session_id || input.sessionId || '';
55
+ const ping = buildChangePing(getVaultBase(input), sid, input.prompt || '');
56
+ if (!ping) { writeHookOutput({}); }
57
+ else writeHookOutput({ hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: ping.context } });
58
+ } catch {
59
+ writeHookOutput({});
60
+ }
61
+ }
@@ -1,10 +1,10 @@
1
1
  // hooks/change-core.mjs
2
2
  // Native change/spec lifecycle in the vault (Pilar B). Vault-facing lib consumed by
3
3
  // the `wendkeep change` CLI (src/change.mjs) and the brain-inject hook. No external deps.
4
- import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from 'node:fs';
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 } from './spec-core.mjs';
7
+ import { parseSpecsList, promoteSpecs, discoverSpecDeltas, tasksHashOf } from './spec-core.mjs';
8
8
  import { getLocale } from './locale.mjs';
9
9
 
10
10
  export const ARCHIVE_DIR = '_arquivo';
@@ -183,6 +183,79 @@ export function activeChangeLink(vaultBase) {
183
183
  return slug ? `Change ativa: [[${getLocale(vaultBase).folders.changes}/${slug}/proposta]]` : '';
184
184
  }
185
185
 
186
+ // --- Estado rápido do gate + sentinelas por sessão (0.31.0) --------------------
187
+ // Fonte única e barata (só leituras, tudo fail-open) do estado do gate, consumida pelos hooks
188
+ // de lifecycle (change-guard/change-nag/change-context) e pelo CLI. null sem change ativa.
189
+ export function quickGateState(vaultBase) {
190
+ const slug = activeChange(vaultBase);
191
+ if (!slug) return null;
192
+ const dir = join(vaultBase, getLocale(vaultBase).folders.changes, slug);
193
+ let tarefasMd = '';
194
+ try { tarefasMd = readFileSync(join(dir, 'tarefas.md'), 'utf8'); } catch { /* sem tarefas */ }
195
+ const openTasks = parseTasks(tarefasMd).filter((t) => !t.done).length;
196
+ let redCritical = false;
197
+ try {
198
+ const ev = JSON.parse(readFileSync(join(dir, 'evidencia.json'), 'utf8'));
199
+ redCritical = (Array.isArray(ev) ? ev : []).some((e) => e.status !== 'green' && (e.severity || 'critical') !== 'warning');
200
+ } catch { /* sem/ilegível = não conta contra o nudge */ }
201
+ let evidenceStale = false;
202
+ try {
203
+ const h = readFileSync(join(dir, '.evidence-hash'), 'utf8').trim();
204
+ evidenceStale = Boolean(h) && h !== tasksHashOf(tarefasMd);
205
+ } catch { /* nunca selada */ }
206
+ return { slug, openTasks, redCritical, evidenceStale, placeholders: scaffoldPlaceholders(dir).length };
207
+ }
208
+
209
+ // Sentinelas por sessão em .brain/ — memória "já avisei/injetei nesta sessão" dos hooks de
210
+ // lifecycle. kind: 'ctx' | 'warn' | 'nag' | 'gate'. session_id sanitizado para nome de arquivo.
211
+ const sanitizeSid = (sid) => String(sid || 'nosession').replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 64);
212
+
213
+ export function sentinelPath(vaultBase, kind, sid) {
214
+ return join(vaultBase, '.brain', `.change-${kind}-${sanitizeSid(sid)}`);
215
+ }
216
+
217
+ export function readSentinel(vaultBase, kind, sid) {
218
+ try { return readFileSync(sentinelPath(vaultBase, kind, sid), 'utf8').trim(); } catch { return ''; }
219
+ }
220
+
221
+ export function writeSentinel(vaultBase, kind, sid, value = '1') {
222
+ try {
223
+ mkdirSync(join(vaultBase, '.brain'), { recursive: true });
224
+ writeFileSync(sentinelPath(vaultBase, kind, sid), value, 'utf8');
225
+ } catch { /* fail-open: pior caso = aviso repetido */ }
226
+ }
227
+
228
+ // Estado da change ativa para o ping do change-context: hash inclui o slug para que trocar de
229
+ // change com tarefas idênticas ainda re-pingue.
230
+ export function changeCtxState(vaultBase, { maxTasks = 5 } = {}) {
231
+ const slug = activeChange(vaultBase);
232
+ if (!slug) return null;
233
+ let md = '';
234
+ try { md = readFileSync(join(vaultBase, getLocale(vaultBase).folders.changes, slug, 'tarefas.md'), 'utf8'); } catch { /* sem tarefas */ }
235
+ return { slug, hash: tasksHashOf(`${slug}\n${md}`), openTasks: parseTasks(md).filter((t) => !t.done).slice(0, maxTasks) };
236
+ }
237
+
238
+ // GC das sentinelas (>7 dias) — seleção pura separada da execução (testável sem depender de
239
+ // unlink funcionar no sandbox). Roda no finalize do session-stop, fail-quiet.
240
+ const SENTINEL_RE = /^\.change-(?:ctx|warn|nag|gate)-/;
241
+
242
+ export function staleSentinelNames(entries, now = Date.now(), maxAgeMs = 7 * 86400000) {
243
+ return (entries || []).filter((e) => SENTINEL_RE.test(e.name) && now - e.mtimeMs > maxAgeMs).map((e) => e.name);
244
+ }
245
+
246
+ export function pruneChangeSentinels(vaultBase, { now = Date.now() } = {}) {
247
+ const dir = join(vaultBase, '.brain');
248
+ let entries = [];
249
+ try {
250
+ entries = readdirSync(dir)
251
+ .map((name) => { try { return { name, mtimeMs: statSync(join(dir, name)).mtimeMs }; } catch { return null; } })
252
+ .filter(Boolean);
253
+ } catch { return []; }
254
+ const stale = staleSentinelNames(entries, now);
255
+ for (const name of stale) { try { unlinkSync(join(dir, name)); } catch { /* fail-quiet */ } }
256
+ return stale;
257
+ }
258
+
186
259
  // Append fix tasks for surviving mutants to a change's tarefas.md (Wave B). Deduped by
187
260
  // file:line, numbered M.<n> continuing from any existing fix tasks. Returns count added.
188
261
  export function appendFixTasks(changeDir, mutants, sensorId) {
@@ -214,7 +287,7 @@ export function gateGreen() {
214
287
  return { ok: true, failing: [] };
215
288
  }
216
289
 
217
- export function archiveChange(vaultBase, slug, { gate = gateGreen, dateStr, adrNum }) {
290
+ export function archiveChange(vaultBase, slug, { gate = gateGreen, dateStr, adrNum, adrFlags = {} }) {
218
291
  const loc = getLocale(vaultBase);
219
292
  const chDir = loc.folders.changes;
220
293
  const src = join(vaultBase, chDir, slug);
@@ -233,16 +306,25 @@ export function archiveChange(vaultBase, slug, { gate = gateGreen, dateStr, adrN
233
306
  }
234
307
 
235
308
  // Promote spec deltas into the living 07-Specs BEFORE moving (deltas live in src).
309
+ // UNIÃO frontmatter + disco (0.31.0): o scaffold deixa `specs: []`, então um delta real
310
+ // preenchido em specs/<cap>/ mas não listado era silenciosamente ignorado. Deltas ainda em
311
+ // placeholder (o `exemplo` do scaffold) são filtrados por discoverSpecDeltas.
236
312
  let promoted = [];
237
313
  let specWarnings = [];
238
314
  try {
239
- const specs = parseSpecsList(readFileSync(join(src, 'proposta.md'), 'utf8'));
240
- if (specs.length) {
241
- const res = promoteSpecs(vaultBase, src, specs, { changeWikilink, dateStr });
315
+ let listed = [];
316
+ try { listed = parseSpecsList(readFileSync(join(src, 'proposta.md'), 'utf8')); } catch { /* proposta ilegível */ }
317
+ const onDisk = discoverSpecDeltas(src);
318
+ const union = [...new Set([...listed, ...onDisk])];
319
+ if (union.length) {
320
+ const res = promoteSpecs(vaultBase, src, union, { changeWikilink, dateStr });
242
321
  promoted = res.promoted;
243
- specWarnings = res.warnings;
322
+ specWarnings = [
323
+ ...onDisk.filter((c) => !listed.includes(c)).map((c) => `spec no disco não listada no frontmatter da proposta: ${c} — promovida assim mesmo`),
324
+ ...res.warnings,
325
+ ];
244
326
  }
245
- } catch { /* proposta ilegível — segue só com ADR */ }
327
+ } catch { /* promoção falhou — segue só com ADR */ }
246
328
 
247
329
  let reqIds = [];
248
330
  try { reqIds = [...new Set(parseTasks(readFileSync(join(src, 'tarefas.md'), 'utf8')).map((t) => t.req).filter(Boolean))]; } catch { /* sem tarefas */ }
@@ -271,10 +353,13 @@ export function archiveChange(vaultBase, slug, { gate = gateGreen, dateStr, adrN
271
353
  ? `\n\nCapabilities: ${promoted.map((c) => wikilinkFromRel(join(loc.folders.specs, c))).join(', ')}.`
272
354
  : '';
273
355
  const reqLine = reqIds.length ? `\n\nRequisitos: ${reqIds.join(', ')}.` : '';
356
+ // Rastro auditável (0.31.0): um archive forçado ou sem prova declarada fica marcado no ADR.
357
+ const flagLines = `${adrFlags.forced ? '\nforced: true' : ''}${adrFlags.trivial ? '\ntrivial: true' : ''}`;
358
+ const forcedNote = adrFlags.forced ? '\n\n> ⚠️ Arquivada com --force — havia tarefa(s) aberta(s) pulada(s) no gate.' : '';
274
359
  writeFileSync(join(vaultBase, adrRel), `---
275
360
  type: decision
276
361
  status: accepted
277
- date: ${dateStr}
362
+ date: ${dateStr}${flagLines}
278
363
  cssclasses:
279
364
  - topic-decision
280
365
  tags:
@@ -285,7 +370,7 @@ tags:
285
370
 
286
371
  ## Decisão
287
372
 
288
- Mudança ${changeWikilink} concluída e arquivada.${capLine}${reqLine}
373
+ Mudança ${changeWikilink} concluída e arquivada.${capLine}${reqLine}${forcedNote}
289
374
  `, 'utf8');
290
375
 
291
376
  // Only clear the pointer when the archived change IS the active one — archiving some other
@@ -293,3 +378,23 @@ Mudança ${changeWikilink} concluída e arquivada.${capLine}${reqLine}
293
378
  if (activeChange(vaultBase) === slug) clearActiveChange(vaultBase);
294
379
  return { ok: true, failing: [], archivedRel: destRel, adrRel, promoted, specWarnings };
295
380
  }
381
+
382
+ // Abandono (0.31.0): a saída legítima para uma change que não vai adiante — o que antes só o
383
+ // `archive --force` "resolvia", minting um ADR falso. Sem ADR, sem promoteSpecs (abandono não é
384
+ // decisão arquitetural nem promove contrato); move para _arquivo com sufixo -abandonada.
385
+ export function abandonChange(vaultBase, slug, { dateStr }) {
386
+ const chDir = getLocale(vaultBase).folders.changes;
387
+ const src = join(vaultBase, chDir, slug);
388
+ if (!existsSync(join(src, 'proposta.md'))) return { ok: false, failing: [`change não encontrada: ${slug}`] };
389
+ const destRel = join(chDir, ARCHIVE_DIR, `${dateStr}-${slug}-abandonada`);
390
+ const destAbs = join(vaultBase, destRel);
391
+ if (existsSync(destAbs)) return { ok: false, failing: [`destino já existe: ${destRel}`] };
392
+ ensureDir(join(vaultBase, chDir, ARCHIVE_DIR));
393
+ try { renameSync(src, destAbs); } catch (e) { return { ok: false, failing: [`falha ao mover: ${e.message}`] }; }
394
+ try {
395
+ const pp = join(destAbs, 'proposta.md');
396
+ writeFileSync(pp, readFileSync(pp, 'utf8').replace(/^status:\s*active\s*$/m, 'status: abandoned'), 'utf8');
397
+ } catch { /* proposta sem frontmatter — segue */ }
398
+ if (activeChange(vaultBase) === slug) clearActiveChange(vaultBase);
399
+ return { ok: true, failing: [], archivedRel: destRel };
400
+ }
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env node
2
+ // PreToolUse hook (matcher: Bash). O gate mecânico do loop a2 no ponto de execução:
3
+ // R1 — `wendkeep|wk change archive --force` vindo do AGENTE é negado (deny). Gate vermelho
4
+ // significa trabalho pendente; --force é decisão do usuário (escape: WENDKEEP_ALLOW_FORCE=1
5
+ // no ambiente do processo — env inline no texto do comando NÃO conta).
6
+ // R2 — `git commit` com change ativa E (--no-verify OU sensor crítico vermelho) vira `ask`
7
+ // (o usuário decide com 1 clique; falso-positivo custa pouco).
8
+ // Fast-path: comando sem wendkeep/wk/git sai sem NENHUM I/O. Fail-open.
9
+ import { pathToFileURL } from 'node:url';
10
+ import { getVaultBase, readHookInput, writeHookOutput } from './obsidian-common.mjs';
11
+ import { activeChange, quickGateState } from './change-core.mjs';
12
+
13
+ const FORCE_RE = /\b(?:wendkeep|wk)\s+change\s+archive\b[^|&;\n]*--force\b/;
14
+ const GIT_SEG_RE = /(^|&&|;|\|)\s*git\b[^|&;]*\bcommit\b/;
15
+ const FAST_RE = /\b(?:wendkeep|wk|git)\b/;
16
+
17
+ export function guardDecision(command, { vaultBase, env = process.env } = {}) {
18
+ const cmd = String(command || '');
19
+ if (!FAST_RE.test(cmd)) return null; // fast-path: zero I/O para o caso comum
20
+
21
+ // R1: archive --force — puro regex, ainda sem I/O. Reason fala com o AGENTE (deny).
22
+ if (FORCE_RE.test(cmd)) {
23
+ if (env.WENDKEEP_ALLOW_FORCE === '1') return null;
24
+ return {
25
+ permissionDecision: 'deny',
26
+ permissionDecisionReason: '`change archive --force` é decisão do usuário, não sua. Gate vermelho = trabalho pendente: rode `wendkeep change status` e conclua as tarefas, ou `wendkeep change abandon <slug>` se a change não vai adiante. Se o usuário pediu o force explicitamente, peça a ele para rodar com WENDKEEP_ALLOW_FORCE=1.',
27
+ };
28
+ }
29
+
30
+ // R2: git commit — 1ª leitura de fs só acontece aqui. Reason fala com o USUÁRIO (ask).
31
+ const m = cmd.match(GIT_SEG_RE);
32
+ if (m) {
33
+ const slug = activeChange(vaultBase);
34
+ if (!slug) return null;
35
+ const seg = cmd.slice(m.index + m[1].length).split(/&&|;|\|/)[0];
36
+ const noVerify = /\s--no-verify\b/.test(seg);
37
+ const gate = noVerify ? null : quickGateState(vaultBase);
38
+ if (noVerify || (gate && gate.redCritical)) {
39
+ return {
40
+ permissionDecision: 'ask',
41
+ permissionDecisionReason: noVerify
42
+ ? `git commit --no-verify com a change "${slug}" ativa — commitar pulando os hooks?`
43
+ : `A change ativa "${slug}" tem sensor crítico vermelho (wendkeep verify falhou). Commitar mesmo assim?`,
44
+ };
45
+ }
46
+ }
47
+ return null;
48
+ }
49
+
50
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
51
+ try {
52
+ const input = readHookInput();
53
+ const d = guardDecision(input.tool_input?.command, { vaultBase: getVaultBase(input) });
54
+ if (d) writeHookOutput({ hookSpecificOutput: { hookEventName: 'PreToolUse', ...d } });
55
+ // allow implícito: exit 0 sem output
56
+ } catch {
57
+ writeHookOutput({}); // fail-open = allow
58
+ }
59
+ }
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env node
2
+ // Stop hook. Se a change ativa tem tarefas abertas quando o agente tenta encerrar o turno,
3
+ // bloqueia UMA vez por sessão cobrando fechamento honesto: marcar done, rodar verify, OU
4
+ // informar a pendência ao usuário e encerrar (a saída honesta é obrigatória no reason — sem
5
+ // ela o modelo é incentivado a marcar done falso só para conseguir parar).
6
+ // Anti-loop absoluto: stop_hook_active é o PRIMEIRO check, antes de qualquer I/O.
7
+ import { pathToFileURL } from 'node:url';
8
+ import { getVaultBase, readHookInput, writeHookOutput } from './obsidian-common.mjs';
9
+ import { quickGateState, readSentinel, writeSentinel } from './change-core.mjs';
10
+
11
+ export function nagDecision(input, vaultBase) {
12
+ if (input && input.stop_hook_active) return null; // anti-loop: sempre primeiro
13
+ const gate = quickGateState(vaultBase);
14
+ if (!gate || !gate.openTasks) return null;
15
+ const sid = input?.session_id || input?.sessionId || '';
16
+ if (readSentinel(vaultBase, 'nag', sid)) return null;
17
+ writeSentinel(vaultBase, 'nag', sid);
18
+ return {
19
+ decision: 'block',
20
+ reason: `A change ativa "${gate.slug}" tem ${gate.openTasks} tarefa(s) aberta(s). Antes de encerrar: marque as concluídas com \`wendkeep change done <id>\`, rode \`wendkeep verify\`, ou informe a pendência ao usuário e encerre.`,
21
+ };
22
+ }
23
+
24
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
25
+ try {
26
+ const input = readHookInput();
27
+ writeHookOutput(nagDecision(input, getVaultBase(input)) || {});
28
+ } catch {
29
+ writeHookOutput({});
30
+ }
31
+ }
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env node
2
+ // PostToolUse hook (matcher: Edit|Write|MultiEdit). Quando o agente edita CÓDIGO sem change
3
+ // ativa, avisa UMA vez por sessão que trabalho não-trivial deve rotear pelo loop a2. Nunca
4
+ // bloqueia — mudança trivial pode seguir. Ignora edições no vault e em .claude/.agent/.brain.
5
+ import { isAbsolute, resolve } from 'node:path';
6
+ import { pathToFileURL } from 'node:url';
7
+ import { getVaultBase, readHookInput, writeHookOutput } from './obsidian-common.mjs';
8
+ import { activeChange, readSentinel, writeSentinel } from './change-core.mjs';
9
+
10
+ const CODE_EXT = /\.(ts|tsx|js|jsx|mjs|cjs|py|prisma|sql|go|rs|java|cs)$/i;
11
+
12
+ export function isCodeFile(p) {
13
+ return CODE_EXT.test(String(p || '').replace(/\\/g, '/'));
14
+ }
15
+
16
+ const norm = (p) => String(p || '').replace(/\\/g, '/');
17
+
18
+ // Retorna o additionalContext do aviso, ou null (caso comum).
19
+ export function warnDecision(filePath, { vaultBase, cwd = '.', sessionId = '' } = {}) {
20
+ if (!filePath || !isCodeFile(filePath)) return null;
21
+ if (activeChange(vaultBase)) return null;
22
+ const abs = norm(isAbsolute(filePath) ? filePath : resolve(cwd, filePath));
23
+ // Dentro do vault (NTFS é case-insensitive) ou em dirs de config de agente: não é código do projeto.
24
+ if (abs.toLowerCase().startsWith(`${norm(resolve(vaultBase)).toLowerCase()}/`)) return null;
25
+ if (/\/(\.claude|\.agent|\.brain)\//.test(`${abs}/`)) return null;
26
+ if (readSentinel(vaultBase, 'warn', sessionId)) return null;
27
+ writeSentinel(vaultBase, 'warn', sessionId);
28
+ return [
29
+ '<change_warn>',
30
+ `Você editou código (${filePath}) sem change ativa. Para trabalho não-trivial, roteie pelo processo: \`wendkeep change new <slug>\` e preencha proposta/design/tarefas (skill wk-workflow). Ignore se for um ajuste trivial — este aviso não repete nesta sessão.`,
31
+ '</change_warn>',
32
+ ].join('\n');
33
+ }
34
+
35
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
36
+ try {
37
+ const input = readHookInput();
38
+ const warn = warnDecision(input.tool_input?.file_path, {
39
+ vaultBase: getVaultBase(input),
40
+ cwd: input.cwd || '.',
41
+ sessionId: input.session_id || input.sessionId || '',
42
+ });
43
+ if (!warn) { writeHookOutput({}); }
44
+ else writeHookOutput({ hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: warn } });
45
+ } catch {
46
+ writeHookOutput({});
47
+ }
48
+ }
@@ -0,0 +1,116 @@
1
+ #!/usr/bin/env node
2
+ // PostToolUse hook (matcher: ExitPlanMode). A ponte determinística entre o plan mode do Claude
3
+ // Code e o vault: quando o usuário APROVA um plano, o plano vira registro — anexado à change
4
+ // ativa, ou uma change nova criada e preenchida a partir dele (proposta do Contexto, design do
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';
7
+ import { join } from 'node:path';
8
+ import { pathToFileURL } from 'node:url';
9
+ import { formatDate, getVaultBase, readHookInput, slugify, writeHookOutput } from './obsidian-common.mjs';
10
+ import { activeChange, newChange } from './change-core.mjs';
11
+ import { getLocale } from './locale.mjs';
12
+
13
+ // O plano aprovado chega por um de três canais, conforme a versão do Claude Code:
14
+ // tool_input.plan (legado), o bloco "## Approved Plan" do tool_response, ou o arquivo de plano
15
+ // citado em "saved to: <path>". Rejeição (ou ausência de sinal de aprovação) → null.
16
+ export function extractPlan(input) {
17
+ const tr = input?.tool_response ?? input?.toolResponse ?? '';
18
+ const resp = typeof tr === 'string' ? tr : JSON.stringify(tr || '');
19
+ 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
+ const direct = input?.tool_input?.plan ?? input?.toolInput?.plan;
22
+ if (direct && String(direct).trim()) return String(direct);
23
+ const marker = resp.match(/## Approved Plan[^\n]*:\s*\n([\s\S]+)$/);
24
+ if (marker && marker[1].trim()) return marker[1].trim();
25
+ const path = resp.match(/saved to:\s*([^\n]+\.md)/i);
26
+ if (path) { try { return readFileSync(path[1].trim(), 'utf8'); } catch { /* arquivo inacessível */ } }
27
+ return null;
28
+ }
29
+
30
+ export function planSlug(plan) {
31
+ const h1 = String(plan || '').match(/^#\s+(.+)$/m);
32
+ return h1 ? slugify(h1[1], 'plano-aprovado', 60) : 'plano-aprovado';
33
+ }
34
+
35
+ function sectionBody(plan, headings) {
36
+ for (const h of headings) {
37
+ const m = String(plan).match(new RegExp(`^##\\s+${h}\\s*\\n([\\s\\S]*?)(?=\\n##\\s|$)`, 'm'));
38
+ if (m && m[1].trim()) return m[1].trim();
39
+ }
40
+ return '';
41
+ }
42
+
43
+ // Checkboxes do plano viram tarefas numeradas 1.N (estado [x] preservado). [] quando não há.
44
+ export function planTasks(plan) {
45
+ const boxes = [...String(plan || '').matchAll(/^\s*-\s+\[( |x)\]\s+(.+)$/gm)];
46
+ return boxes.map((m, i) => `- [${m[1]}] 1.${i + 1} ${m[2].trim()}`);
47
+ }
48
+
49
+ export function capturePlan(vaultBase, input) {
50
+ const plan = extractPlan(input);
51
+ if (!plan) return null;
52
+ const loc = getLocale(vaultBase);
53
+ const en = loc.id === 'en';
54
+ const dateStr = formatDate(new Date());
55
+ const rawNote = `---\ntype: plan\ndate: ${dateStr}\ntags:\n - plano\n---\n\n${plan.trim()}\n`;
56
+
57
+ const active = activeChange(vaultBase);
58
+ if (active) {
59
+ const dir = join(vaultBase, loc.folders.changes, active);
60
+ writeFileSync(join(dir, 'plano-aprovado.md'), rawNote, 'utf8');
61
+ return {
62
+ slug: active, created: false,
63
+ context: `<plan_captured>\n${en
64
+ ? `Approved plan attached to the active change "${active}" (plano-aprovado.md). Sync tarefas.md with the plan's tasks.`
65
+ : `Plano aprovado anexado à change ativa "${active}" (plano-aprovado.md). Sincronize tarefas.md com as tarefas do plano.`}\n</plan_captured>`,
66
+ };
67
+ }
68
+
69
+ const slug = planSlug(plan);
70
+ newChange(vaultBase, slug, { dateStr });
71
+ const dir = join(vaultBase, loc.folders.changes, slug);
72
+ const ctx = sectionBody(plan, ['Contexto', 'Context']) || plan.trim().split('\n').slice(0, 6).join('\n');
73
+ writeFileSync(join(dir, 'proposta.md'), `---
74
+ type: change
75
+ status: active
76
+ date: ${dateStr}
77
+ cssclasses:
78
+ - topic-change
79
+ tags:
80
+ - mudanca
81
+ source: []
82
+ specs: []
83
+ ---
84
+
85
+ # ${slug}
86
+
87
+ ${en ? '## Why' : '## Por quê'}
88
+
89
+ ${ctx}
90
+
91
+ ${en ? '## What changes' : '## O que muda'}
92
+
93
+ ${en ? 'See design.md and plano-aprovado.md (captured from the approved plan-mode plan).' : 'Ver design.md e plano-aprovado.md (capturados do plano aprovado no plan mode).'}
94
+ `, 'utf8');
95
+ writeFileSync(join(dir, 'design.md'), `# ${slug} — design\n\n${plan.trim()}\n`, 'utf8');
96
+ const tasks = planTasks(plan);
97
+ 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');
99
+ return {
100
+ slug, created: true,
101
+ context: `<plan_captured>\n${en
102
+ ? `Approved plan recorded in the vault: change "${slug}" created and set active (${loc.folders.changes}/${slug}/). Review tarefas.md${tasks.length ? '' : ' — the plan had no checkboxes, fill the tasks'} and follow the a2 loop (wk-tdd per task, \`wendkeep change done <id>\`, \`wendkeep verify\`).`
103
+ : `Plano aprovado — change "${slug}" registrada no vault e ativa (${loc.folders.changes}/${slug}/). Revise tarefas.md${tasks.length ? '' : ' — o plano não tinha checkboxes, preencha as tarefas'} e siga o loop a2 (wk-tdd por tarefa, \`wendkeep change done <id>\`, \`wendkeep verify\`).`}\n</plan_captured>`,
104
+ };
105
+ }
106
+
107
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
108
+ try {
109
+ const input = readHookInput();
110
+ const r = capturePlan(getVaultBase(input), input);
111
+ if (!r) { writeHookOutput({}); }
112
+ else writeHookOutput({ hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: r.context } });
113
+ } catch {
114
+ writeHookOutput({});
115
+ }
116
+ }
@@ -6,7 +6,7 @@ import { pathToFileURL } from 'url';
6
6
  import { createLinkedNotes } from './linked-notes.mjs';
7
7
  import { addUsage, costBreakdown, emptyTokenUsage, normalizeClaudeUsage, normalizeCodexUsage, priceForModel, updateSessionUsage } from './token-usage.mjs';
8
8
  import { buildBrainDigest, buildBrainIndex } from './brain-core.mjs';
9
- import { activeChangeLink } from './change-core.mjs';
9
+ import { activeChangeLink, pruneChangeSentinels } from './change-core.mjs';
10
10
  import { getLocale } from './locale.mjs';
11
11
  import { upsertSubagentUsage } from './subagent-usage.mjs';
12
12
  import {
@@ -855,12 +855,19 @@ function replacePendingSection(content, pending) {
855
855
  const end = content.indexOf(closingMarker, start + marker.length);
856
856
  if (end === -1) return content;
857
857
 
858
+ // Preserva seções que outros writers inseriram dentro do span (## Subagents & Workflows,
859
+ // ## Progresso do plano, ## Mudanças…) — só o texto das Pendências em si é regenerado.
860
+ const span = content.slice(start + marker.length, end);
861
+ const innerIdx = span.indexOf('\n## ');
862
+ const preserved = innerIdx === -1 ? '' : span.slice(innerIdx).trimEnd();
863
+
858
864
  return [
859
865
  content.slice(0, start).trimEnd(),
860
866
  '',
861
867
  '## Pendências',
862
868
  '',
863
869
  formatPendingSection(pending),
870
+ ...(preserved ? [preserved] : []),
864
871
  content.slice(end),
865
872
  ].join('\n');
866
873
  }
@@ -1135,6 +1142,9 @@ function main() {
1135
1142
  process.stderr.write(`[wendkeep] brain index/digest falhou: ${error.message}\n`);
1136
1143
  }
1137
1144
 
1145
+ // GC das sentinelas dos hooks de lifecycle (>7 dias) — fail-quiet, nunca derruba o Stop.
1146
+ try { pruneChangeSentinels(vaultBase); } catch { /* bônus */ }
1147
+
1138
1148
  pingObsidianVault(input.obsidian_api_key);
1139
1149
  writeHookOutput({});
1140
1150
  }
@@ -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, writeFileSync } from 'node:fs';
4
+ import { 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,30 @@ export function parseSpecsList(propostaMd) {
87
87
  return [];
88
88
  }
89
89
 
90
+ // Um delta ainda no estado do scaffold (só o requisito "(nome)"/"(name)", nada removido) não é
91
+ // contrato — a promoção o filtra. Um delta com REMOVED é sempre intenção real.
92
+ export function isPlaceholderDelta(md) {
93
+ const d = parseDelta(md);
94
+ if (d.removed.length) return false;
95
+ const all = [...d.added, ...d.modified];
96
+ if (!all.length) return true;
97
+ return all.every((r) => /^\((?:nome|name)\)$/.test(r.name));
98
+ }
99
+
100
+ // Capabilities com delta REAL no disco (<change>/specs/<cap>/spec.md), independente do
101
+ // frontmatter `specs:` da proposta — o scaffold deixa `specs: []` e o buraco engolia deltas
102
+ // preenchidos mas não listados (visto em produção: 07-Specs vazio com delta real no disco).
103
+ export function discoverSpecDeltas(changeDir) {
104
+ let names = [];
105
+ try { names = readdirSync(join(changeDir, 'specs')); } catch { return []; }
106
+ const caps = [];
107
+ for (const cap of names) {
108
+ try { if (!isPlaceholderDelta(readFileSync(join(changeDir, 'specs', cap, 'spec.md'), 'utf8'))) caps.push(cap); }
109
+ catch { /* sem spec.md */ }
110
+ }
111
+ return caps;
112
+ }
113
+
90
114
  // Merge each capability's delta (in the change) into the living spec in 07-Specs.
91
115
  export function promoteSpecs(vaultBase, changeDir, specs, { changeWikilink, dateStr } = {}) {
92
116
  const loc = getLocale(vaultBase);
@@ -208,7 +208,9 @@ function upsertSection(content, heading, body) {
208
208
  const esc = heading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
209
209
  const re = new RegExp(`${esc}\\n[\\s\\S]*?(?=\\n## |$)`);
210
210
  if (re.test(content)) return content.replace(re, `${body}\n`);
211
- for (const anchor of ['\n## Encerramento', '\n## Issues Linear', '\n## Pendências']) {
211
+ // Âncora ANTES de '## Pendências': o finalize do Stop reescreve o span Pendências→Encerramento
212
+ // inteiro, então qualquer seção inserida lá dentro seria apagada (visto em produção).
213
+ for (const anchor of ['\n## Pendências', '\n## Issues Linear', '\n## Encerramento']) {
212
214
  const i = content.indexOf(anchor);
213
215
  if (i >= 0) return `${content.slice(0, i)}\n\n${body}\n${content.slice(i)}`;
214
216
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wendkeep",
3
- "version": "0.30.0",
3
+ "version": "0.31.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
@@ -8,6 +8,7 @@ import {
8
8
  parseTasks,
9
9
  setTaskDone,
10
10
  archiveChange,
11
+ abandonChange,
11
12
  scaffoldPlaceholders,
12
13
  } from '../hooks/change-core.mjs';
13
14
  import { evaluateGate, requiredSensors } from '../hooks/sensors-core.mjs';
@@ -104,8 +105,8 @@ export function runChange(argv) {
104
105
  const reqIds = [...new Set(tasks.map((t) => t.req).filter(Boolean))];
105
106
  let verdict = null;
106
107
  try { verdict = JSON.parse(readFileSync(join(dir, 'verdict.json'), 'utf8')); } catch { /* sem verdict */ }
107
- if (!reqIds.length) process.stdout.write('verdict: não exigido (sem [req:])\n');
108
- else if (!verdict) process.stdout.write('verdict: ausente rode `wendkeep verify --deep` + wk-verify\n');
108
+ if (!verdict) process.stdout.write(`verdict: ausente rode \`wendkeep verify --deep\`${reqIds.length ? ' + wk-verify' : ' (verdict trivial automático)'}\n`);
109
+ else if (!reqIds.length) process.stdout.write(`verdict: ${verdict.ok === true ? 'ok (trivial)' : 'não-ok — re-verifique'}\n`);
109
110
  else {
110
111
  const v = evaluateVerdict(verdict, reqIds, { tasksHash: tasksHashOf(tarefasMd) });
111
112
  process.stdout.write(`verdict: ${v.ok ? 'ok' : v.stale ? 'stale — re-verifique' : `incompleto: falta ${v.missing.join(', ')}`}\n`);
@@ -156,11 +157,11 @@ export function runChange(argv) {
156
157
  // Real gate (Pilar C): every sensor a task declared must be green in evidencia.json.
157
158
  const gate = (dir) => {
158
159
  // G0: um scaffold nunca preenchido não é uma mudança concluída — arquivar geraria um
159
- // ADR falso. Bloqueia antes de qualquer outro check (o --force ainda escapa, mas a
160
- // regra injetada proíbe o agente de usá-lo sem o usuário pedir).
160
+ // ADR falso. INESCAPÁVEL desde 0.31.0 (--force não pula visto em produção: change
161
+ // 100% placeholder arquivada via --force mintou ADR falso). Saída legítima: abandon.
161
162
  const placeholders = scaffoldPlaceholders(dir);
162
- if (placeholders.length && !rest.includes('--force')) {
163
- return { ok: false, failing: [`scaffold não preenchido (${placeholders.join('; ')}) — preencha proposta/design/tarefas com o design e o plano antes de arquivar`] };
163
+ if (placeholders.length) {
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)`] };
164
165
  }
165
166
  let tarefasMd = '';
166
167
  try { tarefasMd = readFileSync(join(dir, 'tarefas.md'), 'utf8'); } catch { /* no tasks */ }
@@ -185,17 +186,38 @@ export function runChange(argv) {
185
186
  try { evidence = JSON.parse(readFileSync(join(dir, 'evidencia.json'), 'utf8')); } catch { /* no evidence */ }
186
187
  const s = evaluateGate(evidence, required);
187
188
  if (!s.ok) return s;
188
- // Independent verdict (Wave A): required only when the change declares [req:] tasks.
189
+ // Verdict SEMPRE exigido (0.31.0) a exigência universal vive AQUI no gate; a semântica
190
+ // reqless→ok de evaluateVerdict (spec-core) não muda porque `verify --deep` e `change
191
+ // status` dependem dela. Change sem [req:] destrava com o auto-verdict do verify --deep.
189
192
  let verdict = null;
190
193
  try { verdict = JSON.parse(readFileSync(join(dir, 'verdict.json'), 'utf8')); } catch { /* none */ }
191
- const v = evaluateVerdict(verdict, reqIds, { tasksHash: tasksHashOf(tarefasMd) });
192
- if (!v.ok) {
193
- if (v.stale) return { ok: false, failing: ['verdict stale (tarefas.md mudou depois da verificação) — re-verifique: `wendkeep verify --deep` + wk-verify'] };
194
- return { ok: false, failing: verdict ? [`verdict incompleto: falta ${v.missing.join(', ')}`] : ['sem verdict — rode `wendkeep verify --deep` + skill wk-verify'] };
194
+ const hash = tasksHashOf(tarefasMd);
195
+ if (!verdict) {
196
+ return { ok: false, failing: [reqIds.length
197
+ ? 'sem verdict — rode `wendkeep verify --deep` + skill wk-verify'
198
+ : 'sem verdict — rode `wendkeep verify --deep` (verdict trivial automático)'] };
199
+ }
200
+ if (verdict.ok !== true) return { ok: false, failing: ['verdict não-ok — re-verifique a change antes de arquivar'] };
201
+ if (verdict.tasksHash && verdict.tasksHash !== hash) {
202
+ return { ok: false, failing: [`verdict stale (tarefas.md mudou depois da verificação) — re-verifique: \`wendkeep verify --deep\`${reqIds.length ? ' + wk-verify' : ''}`] };
203
+ }
204
+ if (reqIds.length) {
205
+ const v = evaluateVerdict(verdict, reqIds, { tasksHash: hash });
206
+ if (!v.ok) {
207
+ if (v.stale) return { ok: false, failing: ['verdict stale (tarefas.md mudou depois da verificação) — re-verifique: `wendkeep verify --deep` + wk-verify'] };
208
+ return { ok: false, failing: [`verdict incompleto: falta ${v.missing.join(', ')}`] };
209
+ }
195
210
  }
196
211
  return { ok: true, failing: [] };
197
212
  };
198
- const r = archiveChange(vaultBase, slug, { dateStr: today(), adrNum: getNextAdrNumber(vaultBase), gate });
213
+ // Rastro auditável: forced quando o --force de fato pulou G1 (tarefa aberta); trivial
214
+ // quando a change não declarou nenhuma prova ([req:]/[sensor:]).
215
+ let tasks = [];
216
+ try { tasks = parseTasks(readFileSync(join(vaultBase, getLocale(vaultBase).folders.changes, slug, 'tarefas.md'), 'utf8')); } catch { /* sem tarefas */ }
217
+ const forced = rest.includes('--force') && tasks.some((t) => !t.done);
218
+ const trivial = !tasks.some((t) => t.req) && !tasks.some((t) => t.sensor);
219
+ if (trivial) process.stderr.write('aviso: change trivial (sem [req:]/[sensor:]) — ADR marcado trivial: true\n');
220
+ const r = archiveChange(vaultBase, slug, { dateStr: today(), adrNum: getNextAdrNumber(vaultBase), gate, adrFlags: { forced, trivial } });
199
221
  if (!r.ok) {
200
222
  process.stderr.write(`change archive BLOCKED (gate): ${r.failing.join('; ')}\n`);
201
223
  process.exit(1);
@@ -206,6 +228,15 @@ export function runChange(argv) {
206
228
  process.exit(0);
207
229
  }
208
230
 
209
- process.stderr.write(`wendkeep change: unknown subcommand "${sub}". Known: new, list, show, status, done, undone, diff, archive.\n`);
231
+ if (sub === 'abandon') {
232
+ const slug = slugArg() || activeChange(vaultBase);
233
+ if (!slug) { process.stderr.write('wendkeep change abandon: missing <slug> and no active change\n'); process.exit(2); }
234
+ const r = abandonChange(vaultBase, slug, { dateStr: today() });
235
+ if (!r.ok) { process.stderr.write(`wendkeep change abandon: ${r.failing.join('; ')}\n`); process.exit(2); }
236
+ process.stdout.write(`abandoned: ${r.archivedRel}\n`);
237
+ process.exit(0);
238
+ }
239
+
240
+ process.stderr.write(`wendkeep change: unknown subcommand "${sub}". Known: new, list, show, status, done, undone, diff, archive, abandon.\n`);
210
241
  process.exit(2);
211
242
  }
package/src/init.mjs CHANGED
@@ -9,9 +9,12 @@ import { createInterface } from 'node:readline/promises';
9
9
  import {
10
10
  VAULT_FOLDERS,
11
11
  SESSION_HOOKS,
12
+ CHANGE_NUDGE_HOOKS,
13
+ CHANGE_GATE_HOOKS,
12
14
  MCP_SERVER_KEY,
13
15
  mcpServerEntry,
14
16
  hookCommand,
17
+ hookCommandLocal,
15
18
  deriveVaultDirName,
16
19
  selectableCompanions,
17
20
  resolveCompanions,
@@ -91,27 +94,45 @@ function backup(path) {
91
94
 
92
95
  // --- merges -----------------------------------------------------------------
93
96
 
94
- export function mergeSettings(existing, { vaultPath, withMcp, force, companions = [], skipMcp = [], dotcontextHookLevel = 'full' }) {
97
+ // Comando preferido para um hook: node-direto quando o projeto tem o pacote local (alta
98
+ // frequência sem cold-start de npx — ver R3 do design 0.31.0); senão o npx portátil.
99
+ export function hookCommandFor(name, projectPath) {
100
+ try {
101
+ if (projectPath && existsSync(join(projectPath, 'node_modules', 'wendkeep', 'hooks', `${name}.mjs`))) {
102
+ return hookCommandLocal(name);
103
+ }
104
+ } catch { /* fs indisponível — npx */ }
105
+ return hookCommand(name);
106
+ }
107
+
108
+ export function mergeSettings(existing, { vaultPath, withMcp, force, companions = [], skipMcp = [], dotcontextHookLevel = 'full', projectPath = '' }) {
95
109
  const s = existing && typeof existing === 'object' ? { ...existing } : {};
96
110
  s.hooks = { ...(s.hooks || {}) };
97
111
  let added = 0;
98
- // wendkeep's own session hooks + any companion-authored hooks. Stable-sort by
99
- // `order` (default 0) so higher-order companion hooks (e.g. dotcontext's order 100)
100
- // fold AFTER wendkeep's own within each event. A spec may carry its own `command`
101
- // (dotcontext dispatch) instead of a wendkeep hook `name`.
102
- const allSpecs = [...SESSION_HOOKS, ...companionHookSpecs(companions, { dotcontextHookLevel })].sort(
103
- (a, b) => (a.order ?? 0) - (b.order ?? 0),
104
- );
112
+ // wendkeep's own session hooks + the change-lifecycle hooks (0.31.0, default) + any
113
+ // companion-authored hooks. Stable-sort by `order` (default 0) so higher-order companion
114
+ // hooks (e.g. dotcontext's order 100) fold AFTER wendkeep's own within each event. A spec
115
+ // may carry its own `command` (dotcontext dispatch) instead of a wendkeep hook `name`.
116
+ const allSpecs = [
117
+ ...SESSION_HOOKS,
118
+ ...CHANGE_NUDGE_HOOKS,
119
+ ...CHANGE_GATE_HOOKS,
120
+ ...companionHookSpecs(companions, { dotcontextHookLevel }),
121
+ ].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
105
122
  for (const h of allSpecs) {
106
- const command = h.command ?? hookCommand(h.name);
123
+ const command = h.command ?? (h.preferLocal ? hookCommandFor(h.name, projectPath) : hookCommand(h.name));
124
+ // Dual-recognition: um hook nomeado é reconhecido tanto na forma npx quanto na node-direta,
125
+ // 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)];
107
127
  const groups = Array.isArray(s.hooks[h.event]) ? [...s.hooks[h.event]] : [];
108
- const owning = groups.find((g) => (g.hooks || []).some((x) => x.command === command));
128
+ const owning = groups.find((g) => (g.hooks || []).some((x) => candidates.includes(x.command)));
109
129
  if (owning) {
110
130
  // Already wired: never add a duplicate group (the old `if (present && !force)` fell through
111
131
  // under --force and appended a second identical group). Under --force, refresh the managed
112
132
  // entry's fields in place — without disturbing any sibling hooks the user grouped with it.
113
133
  if (force) {
114
- const hk = owning.hooks.find((x) => x.command === command);
134
+ const hk = owning.hooks.find((x) => candidates.includes(x.command));
135
+ hk.command = command;
115
136
  hk.timeout = h.timeout;
116
137
  if (h.statusMessage) hk.statusMessage = h.statusMessage;
117
138
  if (h.matcher && (owning.hooks || []).length === 1) owning.matcher = h.matcher;
@@ -477,12 +498,12 @@ export async function runInit(argv) {
477
498
  const settingsRead = readJsonSafe(settingsPath);
478
499
  if (!settingsRead.ok) {
479
500
  // Unparseable existing file — never clobber. Drop a .new for manual merge.
480
- const fresh = mergeSettings(null, { vaultPath, withMcp: args.mcp, force: true, companions, skipMcp, dotcontextHookLevel }).settings;
501
+ const fresh = mergeSettings(null, { vaultPath, withMcp: args.mcp, force: true, companions, skipMcp, dotcontextHookLevel, projectPath }).settings;
481
502
  writeJson(`${settingsPath}.new`, fresh);
482
503
  log(M.settingsBadJson(settingsPath));
483
504
  } else {
484
505
  const hadFile = settingsRead.data !== null;
485
- const { settings, added } = mergeSettings(settingsRead.data, { vaultPath, withMcp: args.mcp, force: args.force, companions, skipMcp, dotcontextHookLevel });
506
+ const { settings, added } = mergeSettings(settingsRead.data, { vaultPath, withMcp: args.mcp, force: args.force, companions, skipMcp, dotcontextHookLevel, projectPath });
486
507
  if (hadFile) backup(settingsPath);
487
508
  writeJson(settingsPath, settings);
488
509
  log(M.settings(hadFile ? M.merged : M.created, added, hadFile ? M.bakSaved : ''));
@@ -16,6 +16,12 @@ const WORKFLOW = `# Loop a2 — o ciclo de trabalho do wendkeep
16
16
  Use ao começar qualquer mudança não-trivial. O loop mantém memória (vault) e prova
17
17
  (sensores) juntas, tudo linkado no grafo do Obsidian.
18
18
 
19
+ <HARD-GATE>
20
+ NÃO edite arquivos de código antes do passo 2 (Propose / \`wendkeep change new\`).
21
+ Toda tarefa não-trivial passa pelo loop — planejar no chat e sair editando deixa o
22
+ vault cego. Exceção única: mudança trivial (typo, 1 linha).
23
+ </HARD-GATE>
24
+
19
25
  ## Os passos
20
26
 
21
27
  1. **Explore** — entenda o problema antes de propor. Leia o código/contexto relevante.
@@ -222,6 +228,12 @@ const WORKFLOW_EN = `# The a2 loop — wendkeep's work cycle
222
228
  Use it when starting any non-trivial change. The loop keeps memory (vault) and proof
223
229
  (sensors) together, wikilinked in the Obsidian graph.
224
230
 
231
+ <HARD-GATE>
232
+ Do NOT edit code files before step 2 (Propose / \`wendkeep change new\`).
233
+ Every non-trivial task goes through the loop — planning in chat and editing right away
234
+ leaves the vault blind. Single exception: a trivial change (typo, one line).
235
+ </HARD-GATE>
236
+
225
237
  ## Steps
226
238
 
227
239
  1. **Explore** — understand the problem before proposing.
@@ -518,21 +530,24 @@ size of the problem>
518
530
  <verifiable criteria: for each requirement, the test that fails → passes>
519
531
  `;
520
532
 
533
+ // As descriptions são o gatilho de ativação: o harness casa a description com o PEDIDO do
534
+ // usuário ("implementa X", "corrige Y"), não com abstrações ("mudança não-trivial"). Gatilhos
535
+ // concretos + instrução imperativa = a skill dispara sozinha (paridade Superpowers).
521
536
  const WK_SKILLS_PT = [
522
- skill('wk-workflow', 'Use ao começar qualquer mudança não-trivial orquestra o loop a2 (explore, propose, apply, verify, archive) nos comandos wendkeep.', WORKFLOW),
537
+ skill('wk-workflow', 'Use SEMPRE que o usuário pedir para implementar, criar, corrigir, refatorar, adicionar ou alterar código — qualquer tarefa de código não-trivial. Invoque ANTES de editar qualquer arquivo: orquestra o loop a2 (wendkeep change new → tarefas → verify archive) e registra tudo no vault.', WORKFLOW),
523
538
  skill('wk-tdd', 'Use ao implementar qualquer comportamento — Red/Green/Refactor com testes que discriminam (derivados do spec, litmus não-raso, adequação).', TDD),
524
- skill('wk-debugging', 'Use quando algo falha ou quebra — depuração sistemática por hipótese antes de corrigir.', DEBUGGING),
525
- skill('wk-brainstorming', 'Use quando a ideia ainda é vaga — vira design aprovado, com closure gate e tabela out-of-scope, antes de código.', BRAINSTORMING, [{ name: 'design-template.md', content: DESIGN_TEMPLATE_PT }]),
526
- skill('wk-planning', 'Use após um design aprovado — decompõe em plano de tarefas TDD bite-sized.', PLANNING, [{ name: 'plan-template.md', content: PLAN_TEMPLATE_PT }]),
539
+ skill('wk-debugging', 'Use quando algo falha, quebra, dá erro ou regride — depuração sistemática por hipótese antes de corrigir.', DEBUGGING),
540
+ skill('wk-brainstorming', 'Use quando a ideia ainda é vaga ou o usuário quer discutir/planejar uma feature (inclusive em plan mode) — vira design aprovado, com closure gate e tabela out-of-scope, antes de código.', BRAINSTORMING, [{ name: 'design-template.md', content: DESIGN_TEMPLATE_PT }]),
541
+ skill('wk-planning', 'Use após um design aprovado ou um plano aceito (inclusive plan mode) — decompõe em plano de tarefas TDD bite-sized e registra na change ativa.', PLANNING, [{ name: 'plan-template.md', content: PLAN_TEMPLATE_PT }]),
527
542
  skill('wk-verify', 'Use no verify deep — passe independente read-only (autor≠verificador) que re-deriva a cobertura do spec e grava verdict.json.', VERIFY, [{ name: 'spec-reviewer-prompt.md', content: REVIEWER_PROMPT_PT }, { name: 'verdict-template.json', content: VERDICT_TEMPLATE }]),
528
543
  ];
529
544
 
530
545
  const WK_SKILLS_EN = [
531
- skill('wk-workflow', 'Use when starting any non-trivial change orchestrates the a2 loop (explore, propose, apply, verify, archive) over the wendkeep commands.', WORKFLOW_EN),
546
+ skill('wk-workflow', 'Use WHENEVER the user asks to implement, create, fix, refactor, add or change code — any non-trivial coding task. Invoke BEFORE editing any file: it orchestrates the a2 loop (wendkeep change new → tasks → verify archive) and records everything in the vault.', WORKFLOW_EN),
532
547
  skill('wk-tdd', 'Use when implementing any behaviour — Red/Green/Refactor with tests that discriminate (spec-derived, non-shallow litmus, adequacy).', TDD_EN),
533
- skill('wk-debugging', 'Use when something fails or breaks — systematic hypothesis-driven debugging before fixing.', DEBUGGING_EN),
534
- skill('wk-brainstorming', 'Use when the idea is still vague — turns it into an approved design, with a closure gate and out-of-scope table, before code.', BRAINSTORMING_EN, [{ name: 'design-template.md', content: DESIGN_TEMPLATE_EN }]),
535
- skill('wk-planning', 'Use after an approved design — decomposes it into a bite-sized TDD task plan.', PLANNING_EN, [{ name: 'plan-template.md', content: PLAN_TEMPLATE_EN }]),
548
+ skill('wk-debugging', 'Use when something fails, breaks, errors or regresses — systematic hypothesis-driven debugging before fixing.', DEBUGGING_EN),
549
+ skill('wk-brainstorming', 'Use when the idea is still vague or the user wants to discuss/plan a feature (plan mode included) — turns it into an approved design, with a closure gate and out-of-scope table, before code.', BRAINSTORMING_EN, [{ name: 'design-template.md', content: DESIGN_TEMPLATE_EN }]),
550
+ skill('wk-planning', 'Use after an approved design or an accepted plan (plan mode included) — decomposes it into a bite-sized TDD task plan recorded in the active change.', PLANNING_EN, [{ name: 'plan-template.md', content: PLAN_TEMPLATE_EN }]),
536
551
  skill('wk-verify', 'Use in verify deep — an independent read-only pass (author≠verifier) that re-derives spec coverage and writes verdict.json.', VERIFY_EN, [{ name: 'spec-reviewer-prompt.md', content: REVIEWER_PROMPT_EN }, { name: 'verdict-template.json', content: VERDICT_TEMPLATE }]),
537
552
  ];
538
553
 
@@ -545,20 +560,22 @@ export const WK_SKILLS = WK_SKILLS_PT;
545
560
  // Seed each skill into <brainDir>/skills/<name>/ if absent (non-destructive): SKILL.md plus any
546
561
  // bundled template/prompt files. Existing files are never overwritten, so re-seeding an older
547
562
  // install just fills in the new template files alongside its SKILL.md.
548
- export function seedWkSkills(brainDir, localeId = 'pt-BR') {
563
+ // { refresh: true } (sync-defs --reseed) SOBRESCREVE as wk-* com os seeds atuais — é como um
564
+ // vault existente recebe descriptions/HARD-GATE novos (edições manuais nas wk-* são perdidas).
565
+ export function seedWkSkills(brainDir, localeId = 'pt-BR', { refresh = false } = {}) {
549
566
  const created = [];
550
567
  for (const s of wkSkills(localeId)) {
551
568
  const dir = join(brainDir, 'skills', s.name);
552
569
  mkdirSync(dir, { recursive: true });
553
- const writeIfAbsent = (name, content) => {
570
+ const write = (name, content) => {
554
571
  const f = join(dir, name);
555
- if (!existsSync(f)) {
572
+ if (refresh || !existsSync(f)) {
556
573
  writeFileSync(f, content, 'utf8');
557
574
  created.push(f);
558
575
  }
559
576
  };
560
- writeIfAbsent('SKILL.md', s.body);
561
- for (const file of s.files || []) writeIfAbsent(file.name, file.content);
577
+ write('SKILL.md', s.body);
578
+ for (const file of s.files || []) write(file.name, file.content);
562
579
  }
563
580
  return created;
564
581
  }
package/src/sync-defs.mjs CHANGED
@@ -7,6 +7,8 @@
7
7
  // cross-platform robustness.
8
8
  import { copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
9
9
  import { isAbsolute, join, resolve } from 'node:path';
10
+ import { seedWkSkills } from './skills-seed.mjs';
11
+ import { getLocale } from '../hooks/locale.mjs';
10
12
 
11
13
  // Managed AGENTS.md section (0.8.0): the agent-agnostic distribution channel. Codex, Amp,
12
14
  // Cursor, Zed et al. read AGENTS.md — one file covers them all. Only the content between
@@ -114,6 +116,12 @@ export function runSyncDefs(argv) {
114
116
  }
115
117
  const vaultBase = isAbsolute(base) ? base : resolve(process.cwd(), base);
116
118
  const projectPath = resolve(project || process.cwd());
119
+ // --reseed (0.31.0): sobrescreve as wk-* de .brain/skills com os seeds da versão instalada
120
+ // ANTES de copiar — é como um vault existente recebe descriptions/HARD-GATE novos.
121
+ if (argv.includes('--reseed')) {
122
+ const n = seedWkSkills(join(vaultBase, '.brain'), getLocale(vaultBase).id, { refresh: true });
123
+ process.stdout.write(`wendkeep sync-defs: ${n.length} arquivo(s) de skill re-semeados em .brain/skills\n`);
124
+ }
117
125
  const r = syncDefs(vaultBase, projectPath);
118
126
  process.stdout.write(
119
127
  `wendkeep sync-defs: ${r.agents.length} agent(s) -> .codex/agents, ${r.skills.length} skill(s) -> .claude/skills\n`,
package/src/taxonomy.mjs CHANGED
@@ -50,6 +50,11 @@ export const HOOK_FILES = [
50
50
  'task-log.mjs',
51
51
  'vault-health.mjs',
52
52
  'understand-inject.mjs',
53
+ 'change-context.mjs',
54
+ 'change-warn.mjs',
55
+ 'change-guard.mjs',
56
+ 'change-nag.mjs',
57
+ 'plan-capture.mjs',
53
58
  ];
54
59
 
55
60
  // Hook scripts that are safe to invoke directly via `wendkeep hook <name>`.
@@ -68,6 +73,11 @@ export const RUNNABLE_HOOKS = [
68
73
  'brain-reindex',
69
74
  'vault-health',
70
75
  'understand-inject',
76
+ 'change-context',
77
+ 'change-warn',
78
+ 'change-guard',
79
+ 'change-nag',
80
+ 'plan-capture',
71
81
  ];
72
82
 
73
83
  // The MCP server entry wendkeep wires into .mcp.json so the agent can read/write the
@@ -107,6 +117,27 @@ export function hookCommand(name) {
107
117
  return `npx wendkeep hook ${name}`;
108
118
  }
109
119
 
120
+ // Forma node-direta do comando de hook: 1 processo (~100-250ms) em vez dos 3 do npx (cold-start
121
+ // de segundos no Windows). Usada pelos hooks de ALTA FREQUÊNCIA (por prompt / por tool-call)
122
+ // quando o projeto tem wendkeep instalado localmente; o init decide (hookCommandFor).
123
+ export function hookCommandLocal(name) {
124
+ return `node node_modules/wendkeep/hooks/${name}.mjs`;
125
+ }
126
+
127
+ // Hooks do lifecycle de change (0.31.0) — enforcement do loop a2. Nudges (contexto/aviso/
128
+ // cobrança/captura de plano) e gate (deny/ask no Bash). Separados em dois grupos para
129
+ // preservar a opção futura de gates opt-in; hoje o init wira TODOS por default.
130
+ // preferLocal: alta frequência → invocação node-direta quando houver instalação local.
131
+ export const CHANGE_NUDGE_HOOKS = [
132
+ { event: 'UserPromptSubmit', matcher: null, name: 'change-context', timeout: 15, order: 10, preferLocal: true, statusMessage: 'wendkeep: change ping' },
133
+ { event: 'PostToolUse', matcher: 'Edit|Write|MultiEdit', name: 'change-warn', timeout: 10, order: 10, preferLocal: true, statusMessage: 'wendkeep: change warn' },
134
+ { event: 'PostToolUse', matcher: 'ExitPlanMode', name: 'plan-capture', timeout: 15, order: 10, preferLocal: true, statusMessage: 'wendkeep: capturing approved plan' },
135
+ { event: 'Stop', matcher: null, name: 'change-nag', timeout: 15, order: 10, preferLocal: true, statusMessage: 'wendkeep: open tasks check' },
136
+ ];
137
+ export const CHANGE_GATE_HOOKS = [
138
+ { event: 'PreToolUse', matcher: 'Bash', name: 'change-guard', timeout: 10, order: 10, preferLocal: true, statusMessage: 'wendkeep: change gate' },
139
+ ];
140
+
110
141
  // --- companion plugins / MCP --------------------------------------------------
111
142
  // Optional tools wendkeep init can pin alongside the vault. Each is wired through
112
143
  // the MOST agent-agnostic mechanism it supports; the Claude Code plugin entry