wendkeep 0.30.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,115 @@ 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
+
51
+ ## [0.31.0] — 2026-07-09
52
+
53
+ ### Added — enforcement do loop a2 (o loop deixa de ser opcional na prática)
54
+
55
+ - **5 hooks de lifecycle novos**, wired por default pelo `wendkeep init` (invocação **node-direta**
56
+ `node node_modules/wendkeep/hooks/<name>.mjs` quando o pacote está instalado no projeto —
57
+ ~100-250ms vs segundos do npx no Windows; fallback npx):
58
+ - **`change-context`** (UserPromptSubmit) — re-injeta a change ativa (`<active_change_ping>`:
59
+ slug + tarefas abertas) SÓ quando o estado mudou desde a última injeção (hash em sentinela por
60
+ sessão). Sem change ativa: prompt com cara de tarefa ganha `<wk_skill_gate>` mandando invocar
61
+ a Skill wk-workflow ANTES de editar — 1x por sessão.
62
+ - **`change-warn`** (PostToolUse `Edit|Write|MultiEdit`) — edição de código sem change ativa
63
+ gera aviso 1x/sessão (nunca bloqueia; ignora vault/.claude/.agent/.brain e não-código).
64
+ - **`change-guard`** (PreToolUse `Bash`) — `wendkeep change archive --force` vindo do agente é
65
+ **negado** (deny; escape: `WENDKEEP_ALLOW_FORCE=1` no ambiente); `git commit` com change ativa
66
+ E (`--no-verify` OU sensor crítico vermelho) vira **ask** (o usuário decide). Fast-path sem
67
+ I/O para comandos comuns.
68
+ - **`change-nag`** (Stop) — change ativa com tarefas abertas bloqueia o encerramento 1x/sessão
69
+ cobrando fechamento honesto (done / verify / **ou informar a pendência ao usuário**).
70
+ Anti-loop absoluto via `stop_hook_active`.
71
+ - **`plan-capture`** (PostToolUse `ExitPlanMode`) — **a ponte determinística plan-mode → vault**:
72
+ plano aprovado no plan mode do Claude Code vira change no vault (proposta do Contexto, design
73
+ do corpo, tarefas dos checkboxes) ou anexa `plano-aprovado.md` à change ativa. Não depende de
74
+ a LLM lembrar do processo.
75
+ - **`wendkeep change abandon [slug]`** — a saída legítima para change que não vai adiante: move
76
+ para `_arquivo/<data>-<slug>-abandonada` com `status: abandoned`, SEM ADR, SEM promoção de
77
+ specs; limpa o ponteiro só se era a ativa. Elimina o motivo real de `--force` em scaffold.
78
+ - **`quickGateState(vaultBase)`** + sentinelas por sessão (`.brain/.change-*-<sid>`, GC >7 dias no
79
+ Stop) em change-core — fonte única do estado do gate para hooks e CLI.
80
+ - **`wendkeep sync-defs --reseed`** — re-semeia as skills wk-* de `.brain/skills` com os seeds da
81
+ versão instalada (é como um vault existente recebe as descriptions/HARD-GATE novos).
82
+
83
+ ### Changed — gate endurecido + ativação da skill
84
+
85
+ - **Verdict SEMPRE exigido no archive** (breaking-ish): sem `verdict.json` o archive bloqueia,
86
+ mesmo sem `[req:]` — `wendkeep verify --deep` grava o verdict trivial automático (1 comando).
87
+ Changes em andamento criadas antes de 0.31.0 precisam de um `verify --deep` antes do archive.
88
+ - **G0 inescapável**: `--force` deixa de pular o check de scaffold — um scaffold cru NUNCA é
89
+ arquivável (era o buraco que mintou ADR falso em produção).
90
+ - **`--force` e trivialidade rastreáveis**: ADR ganha `forced: true` (+ aviso ⚠️ no corpo) quando
91
+ `--force` pulou tarefa aberta, e `trivial: true` quando a change não declarou `[req:]`/`[sensor:]`.
92
+ - **Promoção de specs = união frontmatter + disco**: o archive promove também os deltas REAIS
93
+ achados em `specs/*/spec.md` mesmo com `specs: []` na proposta (warning por cap não listada;
94
+ o `exemplo` placeholder do scaffold é filtrado). Fecha o buraco que deixava 07-Specs vazio com
95
+ delta preenchido no disco.
96
+ - **Ativação da skill (paridade Superpowers)**: descriptions das wk-* reescritas com gatilhos
97
+ concretos ("Use SEMPRE que o usuário pedir para implementar/criar/corrigir/refatorar…
98
+ Invoque ANTES de editar qualquer arquivo"); `<HARD-GATE>` no corpo da wk-workflow; o
99
+ `<wk_process>` do brain-inject agora manda **invocar a Skill** (verbo de skill) e cita `abandon`.
100
+
101
+ ### Fixed
102
+
103
+ - **Seções apagadas pelo Stop (classe de bug, visto em produção)**: qualquer seção inserida entre
104
+ `## Pendências` e `## Encerramento` era descartada pelo finalize (`replacePendingSection`
105
+ reconstruía o span inteiro) — atingia `## Subagents & Workflows` (frontmatter sobrevivia, corpo
106
+ sumia), `## Progresso do plano` e `## Mudanças`. Duas camadas: `upsertSection` agora ancora
107
+ ANTES de `## Pendências`, e o finalize preserva seções desconhecidas dentro do span. Notas
108
+ antigas se autocuram no próximo Stop/backfill (os dados persistem em `subagents/`).
109
+
110
+ ### Known limitation (aceita e documentada)
111
+
112
+ - Hooks do mesmo evento rodam em **paralelo** no Claude Code: quando o `change-nag` bloqueia o
113
+ Stop, o `session-stop` já finalizou a nota — o turno de continuação não é logado nela
114
+ (recuperável via `session-backfill`/`import`; perda máx. de 1 turno, 1x por sessão).
115
+
7
116
  ## [0.30.0] — 2026-07-09
8
117
 
9
118
  ### 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).',
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.',
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
+ '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
- '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).',
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.',
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
+ '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
- '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';
@@ -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);
@@ -183,6 +190,79 @@ export function activeChangeLink(vaultBase) {
183
190
  return slug ? `Change ativa: [[${getLocale(vaultBase).folders.changes}/${slug}/proposta]]` : '';
184
191
  }
185
192
 
193
+ // --- Estado rápido do gate + sentinelas por sessão (0.31.0) --------------------
194
+ // Fonte única e barata (só leituras, tudo fail-open) do estado do gate, consumida pelos hooks
195
+ // de lifecycle (change-guard/change-nag/change-context) e pelo CLI. null sem change ativa.
196
+ export function quickGateState(vaultBase) {
197
+ const slug = activeChange(vaultBase);
198
+ if (!slug) return null;
199
+ const dir = join(vaultBase, getLocale(vaultBase).folders.changes, slug);
200
+ let tarefasMd = '';
201
+ try { tarefasMd = readFileSync(join(dir, 'tarefas.md'), 'utf8'); } catch { /* sem tarefas */ }
202
+ const openTasks = parseTasks(tarefasMd).filter((t) => !t.done).length;
203
+ let redCritical = false;
204
+ try {
205
+ const ev = JSON.parse(readFileSync(join(dir, 'evidencia.json'), 'utf8'));
206
+ redCritical = (Array.isArray(ev) ? ev : []).some((e) => e.status !== 'green' && (e.severity || 'critical') !== 'warning');
207
+ } catch { /* sem/ilegível = não conta contra o nudge */ }
208
+ let evidenceStale = false;
209
+ try {
210
+ const h = readFileSync(join(dir, '.evidence-hash'), 'utf8').trim();
211
+ evidenceStale = Boolean(h) && h !== tasksHashOf(tarefasMd);
212
+ } catch { /* nunca selada */ }
213
+ return { slug, openTasks, redCritical, evidenceStale, placeholders: scaffoldPlaceholders(dir).length };
214
+ }
215
+
216
+ // Sentinelas por sessão em .brain/ — memória "já avisei/injetei nesta sessão" dos hooks de
217
+ // lifecycle. kind: 'ctx' | 'warn' | 'nag' | 'gate'. session_id sanitizado para nome de arquivo.
218
+ const sanitizeSid = (sid) => String(sid || 'nosession').replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 64);
219
+
220
+ export function sentinelPath(vaultBase, kind, sid) {
221
+ return join(vaultBase, '.brain', `.change-${kind}-${sanitizeSid(sid)}`);
222
+ }
223
+
224
+ export function readSentinel(vaultBase, kind, sid) {
225
+ try { return readFileSync(sentinelPath(vaultBase, kind, sid), 'utf8').trim(); } catch { return ''; }
226
+ }
227
+
228
+ export function writeSentinel(vaultBase, kind, sid, value = '1') {
229
+ try {
230
+ mkdirSync(join(vaultBase, '.brain'), { recursive: true });
231
+ writeFileSync(sentinelPath(vaultBase, kind, sid), value, 'utf8');
232
+ } catch { /* fail-open: pior caso = aviso repetido */ }
233
+ }
234
+
235
+ // Estado da change ativa para o ping do change-context: hash inclui o slug para que trocar de
236
+ // change com tarefas idênticas ainda re-pingue.
237
+ export function changeCtxState(vaultBase, { maxTasks = 5 } = {}) {
238
+ const slug = activeChange(vaultBase);
239
+ if (!slug) return null;
240
+ let md = '';
241
+ try { md = readFileSync(join(vaultBase, getLocale(vaultBase).folders.changes, slug, 'tarefas.md'), 'utf8'); } catch { /* sem tarefas */ }
242
+ return { slug, hash: tasksHashOf(`${slug}\n${md}`), openTasks: parseTasks(md).filter((t) => !t.done).slice(0, maxTasks) };
243
+ }
244
+
245
+ // GC das sentinelas (>7 dias) — seleção pura separada da execução (testável sem depender de
246
+ // unlink funcionar no sandbox). Roda no finalize do session-stop, fail-quiet.
247
+ const SENTINEL_RE = /^\.change-(?:ctx|warn|nag|gate)-/;
248
+
249
+ export function staleSentinelNames(entries, now = Date.now(), maxAgeMs = 7 * 86400000) {
250
+ return (entries || []).filter((e) => SENTINEL_RE.test(e.name) && now - e.mtimeMs > maxAgeMs).map((e) => e.name);
251
+ }
252
+
253
+ export function pruneChangeSentinels(vaultBase, { now = Date.now() } = {}) {
254
+ const dir = join(vaultBase, '.brain');
255
+ let entries = [];
256
+ try {
257
+ entries = readdirSync(dir)
258
+ .map((name) => { try { return { name, mtimeMs: statSync(join(dir, name)).mtimeMs }; } catch { return null; } })
259
+ .filter(Boolean);
260
+ } catch { return []; }
261
+ const stale = staleSentinelNames(entries, now);
262
+ for (const name of stale) { try { unlinkSync(join(dir, name)); } catch { /* fail-quiet */ } }
263
+ return stale;
264
+ }
265
+
186
266
  // Append fix tasks for surviving mutants to a change's tarefas.md (Wave B). Deduped by
187
267
  // file:line, numbered M.<n> continuing from any existing fix tasks. Returns count added.
188
268
  export function appendFixTasks(changeDir, mutants, sensorId) {
@@ -214,10 +294,16 @@ export function gateGreen() {
214
294
  return { ok: true, failing: [] };
215
295
  }
216
296
 
217
- export function archiveChange(vaultBase, slug, { gate = gateGreen, dateStr, adrNum }) {
297
+ export function archiveChange(vaultBase, slug, { gate = gateGreen, dateStr, adrNum, adrFlags = {} }) {
218
298
  const loc = getLocale(vaultBase);
219
299
  const chDir = loc.folders.changes;
220
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 */ }
221
307
  const verdict = gate(src);
222
308
  if (!verdict.ok) return { ok: false, failing: verdict.failing || [] };
223
309
 
@@ -233,16 +319,27 @@ export function archiveChange(vaultBase, slug, { gate = gateGreen, dateStr, adrN
233
319
  }
234
320
 
235
321
  // Promote spec deltas into the living 07-Specs BEFORE moving (deltas live in src).
322
+ // UNIÃO frontmatter + disco (0.31.0): o scaffold deixa `specs: []`, então um delta real
323
+ // preenchido em specs/<cap>/ mas não listado era silenciosamente ignorado. Deltas ainda em
324
+ // placeholder (o `exemplo` do scaffold) são filtrados por discoverSpecDeltas.
236
325
  let promoted = [];
237
326
  let specWarnings = [];
238
327
  try {
239
- const specs = parseSpecsList(readFileSync(join(src, 'proposta.md'), 'utf8'));
240
- if (specs.length) {
241
- const res = promoteSpecs(vaultBase, src, specs, { changeWikilink, dateStr });
328
+ let listed = [];
329
+ try { listed = parseSpecsList(readFileSync(join(src, 'proposta.md'), 'utf8')); } catch { /* proposta ilegível */ }
330
+ const onDisk = discoverSpecDeltas(src);
331
+ const union = [...new Set([...listed, ...onDisk])];
332
+ if (union.length) {
333
+ const res = promoteSpecs(vaultBase, src, union, { changeWikilink, dateStr });
242
334
  promoted = res.promoted;
243
- specWarnings = res.warnings;
335
+ specWarnings = [
336
+ ...onDisk.filter((c) => !listed.includes(c)).map((c) => `spec no disco não listada no frontmatter da proposta: ${c} — promovida assim mesmo`),
337
+ ...res.warnings,
338
+ ];
244
339
  }
245
- } catch { /* proposta ilegível — segue só com ADR */ }
340
+ } catch (error) {
341
+ return { ok: false, failing: [`falha ao promover specs: ${error.message}`] };
342
+ }
246
343
 
247
344
  let reqIds = [];
248
345
  try { reqIds = [...new Set(parseTasks(readFileSync(join(src, 'tarefas.md'), 'utf8')).map((t) => t.req).filter(Boolean))]; } catch { /* sem tarefas */ }
@@ -261,6 +358,17 @@ export function archiveChange(vaultBase, slug, { gate = gateGreen, dateStr, adrN
261
358
  writeFileSync(pp, c, 'utf8');
262
359
  } catch { /* proposta ilegível — segue */ }
263
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
+
264
372
  // ADR goes in the same dated month folder as session-derived decisions (04-Decisões/ano/MM-MMM/)
265
373
  // — not the year root — so all ADRs sit together in the vault's convention.
266
374
  const adrDirRel = monthFolderRelFromDateStr(loc.folders.decisions, dateStr, vaultBase);
@@ -271,10 +379,13 @@ export function archiveChange(vaultBase, slug, { gate = gateGreen, dateStr, adrN
271
379
  ? `\n\nCapabilities: ${promoted.map((c) => wikilinkFromRel(join(loc.folders.specs, c))).join(', ')}.`
272
380
  : '';
273
381
  const reqLine = reqIds.length ? `\n\nRequisitos: ${reqIds.join(', ')}.` : '';
382
+ // Rastro auditável (0.31.0): um archive forçado ou sem prova declarada fica marcado no ADR.
383
+ const flagLines = `${adrFlags.forced ? '\nforced: true' : ''}${adrFlags.trivial ? '\ntrivial: true' : ''}`;
384
+ const forcedNote = adrFlags.forced ? '\n\n> ⚠️ Arquivada com --force — havia tarefa(s) aberta(s) pulada(s) no gate.' : '';
274
385
  writeFileSync(join(vaultBase, adrRel), `---
275
386
  type: decision
276
387
  status: accepted
277
- date: ${dateStr}
388
+ date: ${dateStr}${flagLines}
278
389
  cssclasses:
279
390
  - topic-decision
280
391
  tags:
@@ -285,7 +396,7 @@ tags:
285
396
 
286
397
  ## Decisão
287
398
 
288
- Mudança ${changeWikilink} concluída e arquivada.${capLine}${reqLine}
399
+ Mudança ${changeWikilink} concluída e arquivada.${capLine}${reqLine}${forcedNote}
289
400
  `, 'utf8');
290
401
 
291
402
  // Only clear the pointer when the archived change IS the active one — archiving some other
@@ -293,3 +404,23 @@ Mudança ${changeWikilink} concluída e arquivada.${capLine}${reqLine}
293
404
  if (activeChange(vaultBase) === slug) clearActiveChange(vaultBase);
294
405
  return { ok: true, failing: [], archivedRel: destRel, adrRel, promoted, specWarnings };
295
406
  }
407
+
408
+ // Abandono (0.31.0): a saída legítima para uma change que não vai adiante — o que antes só o
409
+ // `archive --force` "resolvia", minting um ADR falso. Sem ADR, sem promoteSpecs (abandono não é
410
+ // decisão arquitetural nem promove contrato); move para _arquivo com sufixo -abandonada.
411
+ export function abandonChange(vaultBase, slug, { dateStr }) {
412
+ const chDir = getLocale(vaultBase).folders.changes;
413
+ const src = join(vaultBase, chDir, slug);
414
+ if (!existsSync(join(src, 'proposta.md'))) return { ok: false, failing: [`change não encontrada: ${slug}`] };
415
+ const destRel = join(chDir, ARCHIVE_DIR, `${dateStr}-${slug}-abandonada`);
416
+ const destAbs = join(vaultBase, destRel);
417
+ if (existsSync(destAbs)) return { ok: false, failing: [`destino já existe: ${destRel}`] };
418
+ ensureDir(join(vaultBase, chDir, ARCHIVE_DIR));
419
+ try { renameSync(src, destAbs); } catch (e) { return { ok: false, failing: [`falha ao mover: ${e.message}`] }; }
420
+ try {
421
+ const pp = join(destAbs, 'proposta.md');
422
+ writeFileSync(pp, readFileSync(pp, 'utf8').replace(/^status:\s*active\s*$/m, 'status: abandoned'), 'utf8');
423
+ } catch { /* proposta sem frontmatter — segue */ }
424
+ if (activeChange(vaultBase) === slug) clearActiveChange(vaultBase);
425
+ return { ok: true, failing: [], archivedRel: destRel };
426
+ }
@@ -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
+ }