wendkeep 0.29.2 → 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,95 @@ 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
+
72
+ ## [0.30.0] — 2026-07-09
73
+
74
+ ### Changed
75
+ - **Decision notes follow the ADR convention: `ADR-<NNNN>-<slug>`.** Every decision note now carries
76
+ a 4-digit, zero-padded sequential number assigned in the order decisions are made (`ADR-0001`,
77
+ `ADR-0002`, …) — replacing the old `YYYY-MM-DD-escolha-<slug>` filenames from the interactive and
78
+ prose captures. The number goes in the filename, in an `adr:` frontmatter field, and as an
79
+ `# ADR-NNNN — <title>` H1 prefix. The native `wendkeep change archive` ADR and the
80
+ `createLinkedNotes` heuristic ADR widen from 3 to 4 digits (`ADR-007` → `ADR-0007`) to match.
81
+ - **Decision capture dedups by `content_key`, not by filename.** Because the filename now carries a
82
+ fresh ADR number it can't dedup, so a decision already recorded in the target folder (same
83
+ normalized question) is skipped by content — both the AskUserQuestion hook (`captureDecision`) and
84
+ the agnostic prose capture (`captureProseDecisions`). New `decisionKeyExists` / `padAdr` helpers.
85
+
86
+ ### Added
87
+ - **`wendkeep renumber-decisions`** — retroactive fix for vaults that accumulated the three historical
88
+ naming eras (`ADR-NNN`, dated `escolha`, hand-written). Renumbers **every** note in 04-Decisões to
89
+ `ADR-<NNNN>-<slug>` in strict chronological order, renames the files in place, and **rewrites every
90
+ wikilink to them across the whole vault** (full-path, basename, and `|ADR-006` display aliases).
91
+ Normalizes each note's `type: decision` / `adr:` / H1. Preview by default (writes nothing); pass
92
+ `--apply` to commit the renames. Idempotent — a second run on a canonical vault is a no-op.
93
+ `--vault P` / `--json`. New `hooks/renumber-decisions.mjs` (`planRenumber`, `renumberDecisions`,
94
+ `slugFromDecisionName`, `decisionSortKey`, `normalizeDecisionContent`, `rewriteLinks`).
95
+
7
96
  ## [0.29.2] — 2026-07-09
8
97
 
9
98
  ### Fixed
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.
@@ -60,6 +61,9 @@ Usage:
60
61
  wendkeep verify [--deep] [--change s] Run a change's task sensors + record evidence (the gate);
61
62
  --deep assembles the verification package for the wk-verify pass.
62
63
  wendkeep dashboard [--force] (Re)generate the vault's folder-filtered Bases + 00-Dashboard MOC.
64
+ wendkeep renumber-decisions Renumber 04-Decisões to ADR-<NNNN>-<slug> in chronological order,
65
+ renaming files + rewriting every wikilink. Preview by default; --apply to
66
+ write. --vault P · --json.
63
67
  wendkeep lesson add "t" "l" Record a project-local lesson (injected at SessionStart).
64
68
  wendkeep validate-memory [path] Validate .brain/CORE.md against the compaction
65
69
  protocol (cap 25, 3 sections, no secrets/PII). Uses
@@ -67,6 +71,8 @@ Usage:
67
71
  wendkeep sync-defs [opts] Copy versioned defs from the vault's .brain into the
68
72
  project: .brain/agents/*.toml -> .codex/agents,
69
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.
70
76
  wendkeep --version Print version.
71
77
  wendkeep --help Show this help.
72
78
  `;
@@ -162,6 +168,11 @@ async function main() {
162
168
  runDashboard(rest);
163
169
  break;
164
170
  }
171
+ case 'renumber-decisions': {
172
+ const { runRenumberDecisions } = await import('../src/renumber.mjs');
173
+ runRenumberDecisions(rest);
174
+ break;
175
+ }
165
176
  case '--version':
166
177
  case '-v':
167
178
  process.stdout.write(`${version()}\n`);
@@ -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 */ }
@@ -265,16 +347,19 @@ export function archiveChange(vaultBase, slug, { gate = gateGreen, dateStr, adrN
265
347
  // — not the year root — so all ADRs sit together in the vault's convention.
266
348
  const adrDirRel = monthFolderRelFromDateStr(loc.folders.decisions, dateStr, vaultBase);
267
349
  ensureDir(join(vaultBase, adrDirRel));
268
- const num = String(adrNum).padStart(3, '0');
350
+ const num = String(adrNum).padStart(4, '0');
269
351
  const adrRel = join(adrDirRel, `ADR-${num}-${slug}.md`);
270
352
  const capLine = promoted.length
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
+ }