wendkeep 0.31.0 → 0.33.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,69 @@ 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.33.0] — 2026-07-11
8
+
9
+ ### Added
10
+
11
+ - **Visão global de changes abertas**: `SessionStart`, `UserPromptSubmit`, `wendkeep change list`
12
+ e `wendkeep change status` sem slug mostram todas as pendências, inclusive as iniciadas por
13
+ outro agente.
14
+ - Ações de takeover explícitas no contexto: Claude, Codex ou outro agente podem retomar uma
15
+ change existente sem perder o restante do backlog.
16
+
17
+ ### Changed
18
+
19
+ - `.brain/CURRENT_CHANGE.md` continua como ponteiro global único, agora marcado como change
20
+ **atual**. Comandos implícitos (`done`, `verify`, `archive`, `abandon`) continuam restritos a ela.
21
+ - Mudanças em qualquer `tarefas.md` invalidam o hash do hook de contexto e reinjetam a lista global
22
+ na sessão afetada.
23
+ - `change-nag` permanece local à change atual; pendências de outra frente não bloqueiam o agente
24
+ em foco.
25
+
26
+ ## [0.32.0] — 2026-07-09
27
+
28
+ ### Added
29
+
30
+ - **Contrato explícito `spec_impact`** para changes novas: `pending`, `required` ou `none`.
31
+ Changes materiais (`required`) precisam listar a capability e manter um delta real em
32
+ `specs/<capability>/spec.md`; `none` exige justificativa em `spec_impact_reason`.
33
+ - **Snapshots imutáveis de planos aprovados** em `planos/<sha256-12>.md`, deduplicados por
34
+ conteúdo. `plano-aprovado.md` passa a ser o índice dos snapshots, sem sobrescrever planos
35
+ anteriores da mesma change.
36
+ - Diagnósticos de `spec_impact` no `wendkeep doctor`, incluindo estado pendente, delta ausente e
37
+ divergência entre `specs:` e o conteúdo real no disco.
38
+
39
+ ### Changed
40
+
41
+ - Hooks Claude de alta frequência usam `node` com caminho ancorado em
42
+ `${CLAUDE_PROJECT_DIR}`. O `init` migra automaticamente os comandos relativos de 0.31.0 sem
43
+ duplicar grupos, inclusive quando o agente muda o `cwd` para um subprojeto.
44
+ - `wk-workflow`, `wk-brainstorming`, `wk-planning` e o roteador de SessionStart agora exigem a
45
+ classificação do impacto, o delta da capability e a rastreabilidade `[req:ID]` antes do archive.
46
+ - O archive passa a ser **fail-closed** para specs: placeholder, arquivo ausente ou falha de
47
+ promoção bloqueiam o move e a criação do ADR.
48
+ - Ao arquivar, links da sessão para a change ativa são reescritos para o caminho em `_arquivo`.
49
+ Decisões capturadas também entram imediatamente na seção de decisões da sessão ativa.
50
+
51
+ ### Fixed
52
+
53
+ - **Planos aprovados descartados no Claude Code atual**: `plan-capture` agora lê
54
+ `tool_response.plan` no payload estruturado de `PostToolUse:ExitPlanMode`, mantendo os formatos
55
+ textuais legados e rejeições como no-op.
56
+ - **52 falhas `MODULE_NOT_FOUND` observadas em produção** quando `change-warn`/`change-guard`
57
+ rodavam a partir de `mobile-app` ou `backend-core`.
58
+ - A criação automática de change por plano aprovado agora preserva o backlink da sessão resolvido
59
+ pelo `transcript_path`/registry.
60
+ - `vault-health` não exige `## Encerramento` de uma sessão ainda ativa; continua validando a ordem
61
+ completa quando a sessão está finalizada.
62
+
63
+ ### Migration
64
+
65
+ - Rode `wendkeep init --force` para migrar hooks relativos instalados pela 0.31.0.
66
+ - Rode `wendkeep sync-defs --reseed` para atualizar as skills wk-* em vaults existentes.
67
+ - Changes antigas sem `spec_impact` continuam legíveis e são diagnosticadas como legadas; antes do
68
+ próximo archive, classifique-as explicitamente como `required` ou `none`.
69
+
7
70
  ## [0.31.0] — 2026-07-09
8
71
 
9
72
  ### Added — enforcement do loop a2 (o loop deixa de ser opcional na prática)
package/README.md CHANGED
@@ -112,7 +112,7 @@ No re‑copying, no snapshot to re‑sync — the package is the single source o
112
112
  |---|---|
113
113
  | `wendkeep init` | Set up wendkeep in a project (vault taxonomy + settings + MCP + skills). |
114
114
  | `wendkeep hook <name>` | Run a session hook; invoked by `settings.json` (reads agent JSON on stdin). |
115
- | `wendkeep change <sub>` | Change lifecycle: `new [--simple]` / `list` / `show` / `status` / `done <id>` / `undone <id>` / `diff` / `archive [--force]`. |
115
+ | `wendkeep change <sub>` | Change lifecycle: `new [--simple]` / `list` (global backlog) / `show` / `status [slug]` / `done <id> [--change slug]` / `undone <id> [--change slug]` / `diff` / `archive [--force]`. |
116
116
  | `wendkeep verify [--deep]` | Run the change's task sensors; `--deep` assembles the independent-verification package. |
117
117
  | `wendkeep spec <sub>` | Living specs: `list` / `show <capability>`. |
118
118
  | `wendkeep sensors <sub>` | `list` / `add <id> "<command>"` — view/edit `wendkeep.sensors.json` (JSON Schema shipped). |
@@ -148,7 +148,7 @@ Beyond capturing sessions, wendkeep is a **harness**: a native, zero‑dependenc
148
148
  explore → propose → apply (TDD) → verify → archive
149
149
  ```
150
150
 
151
- - **Propose** — `wendkeep change new <slug>` scaffolds `08-Mudanças/<slug>/` (`proposta.md`, `design.md`, `tarefas.md`, and a `specs/` delta). The change becomes *active* and is injected at the next `SessionStart`, so the agent resumes work‑in‑progress.
151
+ - **Propose** — `wendkeep change new <slug>` scaffolds `08-Mudanças/<slug>/` (`proposta.md`, `design.md`, `tarefas.md`, and a `specs/` delta). It becomes the global *current* change. Multiple changes may remain open: hooks and `change list/status` expose every pending task, while commands without `--change` use only the current one.
152
152
  - **Apply** — implement each `tarefas.md` task. Tag a task that needs machine proof with `[sensor:<id>]`.
153
153
  - **Verify** — `wendkeep verify` runs the sensors your tasks declared (from `wendkeep.sensors.json` at the project root) and records `evidencia.json`. A critical red fails the gate; a `warning` red is advisory.
154
154
  - **Archive** — `wendkeep change archive <slug>` **gates** on the evidence (blocks unless every declared critical sensor is green), promotes each capability's spec delta (`ADDED`/`MODIFIED`/`REMOVED`) into the living `07-Specs/<capability>.md`, moves the change to `_arquivo/`, and mints an ADR in `04-Decisões/`.
@@ -175,6 +175,8 @@ Declare the capability in `proposta.md` (`specs: [ui]`) and author its delta in
175
175
 
176
176
  ```bash
177
177
  npx wendkeep change status # one screen: tasks / sensors / verdict
178
+ npx wendkeep change list # all open changes + pending tasks
179
+ npx wendkeep change status dark-mode # one change in detail
178
180
  npx wendkeep change done 1.1 # tick a task from the CLI
179
181
  npx wendkeep verify # run the declared sensors -> evidencia.json
180
182
  npx wendkeep verify --deep # assemble the verification package
@@ -194,7 +196,7 @@ agent session ──hooks──▶ wendkeep ──▶ Markdown in vault ──
194
196
  (Claude/Codex) (Node) (02-Sessões/…) (CORE+DIGEST, backlinks)
195
197
  ```
196
198
 
197
- The agent's settings.json points each hook at `npx wendkeep hook …`. On `Stop`, wendkeep parses the session transcript, appends the turn, updates the token/cost table, and (idempotently) emits any decision/bug/learning notes. On every `SessionStart`, `brain-inject` injects back the curated memory (CORE + DIGEST), the active change, project lessons, and a `<wk_process>` router that routes any non‑trivial task through the a2 loop (plan `change new` + fill the scaffold TDD → verify → archive).
199
+ The agent's settings.json points each hook at `npx wendkeep hook …`. On `Stop`, wendkeep parses the session transcript, appends the turn, updates the token/cost table, and (idempotently) emits any decision/bug/learning notes. On every `SessionStart`, `brain-inject` injects back curated memory (CORE + DIGEST), every open change with its pending tasks, the global current-change marker, project lessons, and a `<wk_process>` router. Claude, Codex, or another agent can therefore resume work started elsewhere without hiding the rest of the backlog.
198
200
 
199
201
  The archive **gate** blocks unless: the change scaffold is filled (G0), no task is open (G1), every declared critical sensor is green (with fresh evidence), and — when the change declares `[req:]` — an independent `verdict.json` covers them. `--force` is the human escape hatch; the agent is instructed never to use it on its own.
200
202
 
package/README.pt-BR.md CHANGED
@@ -110,7 +110,7 @@ Sem recopiar, sem snapshot pra re‑sincronizar — o pacote é a única fonte d
110
110
  |---|---|
111
111
  | `wendkeep init` | Configura o wendkeep num projeto (taxonomia do cofre + settings + MCP + skills). |
112
112
  | `wendkeep hook <name>` | Roda um hook de sessão; invocado pelo `settings.json` (lê o JSON do agente no stdin). |
113
- | `wendkeep change <sub>` | Ciclo de mudança: `new [--simple]` / `list` / `show` / `status` / `done <id>` / `undone <id>` / `diff` / `archive [--force]`. |
113
+ | `wendkeep change <sub>` | Ciclo de mudança: `new [--simple]` / `list` (backlog global) / `show` / `status [slug]` / `done <id> [--change slug]` / `undone <id> [--change slug]` / `diff` / `archive [--force]`. |
114
114
  | `wendkeep verify [--deep]` | Roda os sensores das tarefas da change; `--deep` monta o pacote de verificação independente. |
115
115
  | `wendkeep spec <sub>` | Specs vivos: `list` / `show <capability>`. |
116
116
  | `wendkeep sensors <sub>` | `list` / `add <id> "<comando>"` — vê/edita `wendkeep.sensors.json` (JSON Schema incluso). |
@@ -147,7 +147,7 @@ Além de capturar sessões, o wendkeep é um **harness**: um loop nativo e sem d
147
147
  explore → propose → apply (TDD) → verify → archive
148
148
  ```
149
149
 
150
- - **Propose** — `wendkeep change new <slug>` faz o scaffold de `08-Mudanças/<slug>/` (`proposta.md`, `design.md`, `tarefas.md` e um delta `specs/`). A change vira *ativa* e é injetada no próximo `SessionStart`, então o agente retoma o trabalho em curso.
150
+ - **Propose** — `wendkeep change new <slug>` faz o scaffold de `08-Mudanças/<slug>/` (`proposta.md`, `design.md`, `tarefas.md` e um delta `specs/`). A change vira a *atual* global. Várias changes podem ficar abertas: hooks e `change list/status` mostram todas as pendências, enquanto comandos sem `--change` usam somente a atual.
151
151
  - **Apply** — implemente cada tarefa de `tarefas.md`. Taggeie a tarefa que precisa de prova de máquina com `[sensor:<id>]`.
152
152
  - **Verify** — `wendkeep verify` roda os sensores que suas tarefas declararam (do `wendkeep.sensors.json` na raiz do projeto) e grava `evidencia.json`. Um vermelho crítico falha o gate; um vermelho `warning` é aviso.
153
153
  - **Archive** — `wendkeep change archive <slug>` faz **gate** na evidência (bloqueia a não ser que todo sensor crítico declarado esteja verde), promove o delta de cada capability (`ADDED`/`MODIFIED`/`REMOVED`) pro `07-Specs/<capability>.md` vivo, move a change pro `_arquivo/` e cunha um ADR em `04-Decisões/`.
@@ -163,7 +163,7 @@ sessão do agente ──hooks──▶ wendkeep ──▶ Markdown no cofre ─
163
163
  (Claude/Codex) (Node) (02-Sessões/…) (CORE+DIGEST, backlinks)
164
164
  ```
165
165
 
166
- O settings.json do agente aponta cada hook pra `npx wendkeep hook …`. No `Stop`, o wendkeep parseia o transcript, anexa o turno, atualiza a tabela de tokens/custo e (idempotentemente) emite qualquer nota de decisão/bug/aprendizado. Em todo `SessionStart`, o `brain-inject` injeta de volta a memória curada (CORE + DIGEST), a change ativa, as lições do projeto e um roteador `<wk_process>` que roteia qualquer tarefa não‑trivial pelo loop a2 (planejar `change new` + preencher o scaffold TDD → verify → archive).
166
+ O settings.json do agente aponta cada hook pra `npx wendkeep hook …`. No `Stop`, o wendkeep parseia o transcript, anexa o turno, atualiza a tabela de tokens/custo e (idempotentemente) emite qualquer nota de decisão/bug/aprendizado. Em todo `SessionStart`, o `brain-inject` injeta a memória curada (CORE + DIGEST), todas as changes abertas com suas pendências, o marcador global da change atual, as lições do projeto e o roteador `<wk_process>`. Claude, Codex ou outro agente podem assim retomar trabalho iniciado em outro lugar sem ocultar o restante do backlog.
167
167
 
168
168
  O **gate** do archive bloqueia a não ser que: o scaffold da change esteja preenchido (G0), nenhuma tarefa esteja aberta (G1), todo sensor crítico declarado esteja verde (com evidência fresca) e — quando a change declara `[req:]` — um `verdict.json` independente cubra eles. O `--force` é a saída de emergência humana; o agente é instruído a nunca usar por conta própria.
169
169
 
@@ -1,6 +1,6 @@
1
1
  // .agent/hooks/brain-inject.mjs
2
2
  // Injeção da camada quente no SessionStart (Claude/Codex/Copilot): CORE curado +
3
- // DIGEST auto + 1-linha pointer do recall. Budget-capada. Nunca derruba o hook.
3
+ // DIGEST auto + 1-linha pointer do recall + backlog completo de changes. Nunca derruba o hook.
4
4
  // Uso (hook): node .agent/hooks/brain-inject.mjs (input JSON via stdin)
5
5
  import { readFileSync } from 'node:fs';
6
6
  import { join } from 'node:path';
@@ -21,7 +21,7 @@ function processRouter(localeId) {
21
21
  '<wk_process>',
22
22
  'Spec-driven process (mandatory for any non-trivial task): INVOKE the wk-workflow Skill BEFORE editing any file.',
23
23
  '1. Plan: invoke the wk-brainstorming Skill (approved design) → wk-planning (task plan).',
24
- '2. Record: `wendkeep change new <slug>` and FILL 08-Changes/<slug>/ — proposta.md (why/what), design.md (the approved approach), tarefas.md (the plan\'s tasks, `- [ ] N.N` with [req:ID]/[sensor:id]). Never leave the scaffold placeholders.',
24
+ '2. Record: `wendkeep change new <slug>` and FILL proposta/design/tasks. Resolve `spec_impact`: `required` needs `specs/<capability>/spec.md` + [req:ID]; `none` needs a reason. Never leave pending/placeholders.',
25
25
  '3. Implement: wk-tdd per task; tick `- [x]` as you finish. Something broke? wk-debugging.',
26
26
  '4. Close: `wendkeep verify` (+ `--deep` + the wk-verify Skill) → `wendkeep change archive`.',
27
27
  'NEVER `archive --force` on your own — a red gate means pending work; --force is the user\'s call, not yours. Dead end? `wendkeep change abandon`.',
@@ -32,7 +32,7 @@ function processRouter(localeId) {
32
32
  '<wk_process>',
33
33
  'Processo spec-driven (obrigatório em tarefa não-trivial): INVOQUE a Skill wk-workflow ANTES de editar qualquer arquivo.',
34
34
  '1. Planejar: invoque a Skill wk-brainstorming (design aprovado) → wk-planning (plano de tarefas).',
35
- '2. Registrar: `wendkeep change new <slug>` e PREENCHA 08-Mudanças/<slug>/ — proposta.md (porquê/o quê), design.md (a abordagem aprovada), tarefas.md (as tarefas do plano, `- [ ] N.N` com [req:ID]/[sensor:id]). Nunca deixe os placeholders do scaffold.',
35
+ '2. Registrar: `wendkeep change new <slug>` e PREENCHA proposta/design/tarefas. Resolva `spec_impact`: `required` exige `specs/<capability>/spec.md` + [req:ID]; `none` exige justificativa. Nunca deixe pending/placeholders.',
36
36
  '3. Implementar: wk-tdd por tarefa; marque `- [x]` ao concluir. Quebrou algo? wk-debugging.',
37
37
  '4. Fechar: `wendkeep verify` (+ `--deep` + Skill wk-verify) → `wendkeep change archive`.',
38
38
  'PROIBIDO `archive --force` por conta própria — gate vermelho significa trabalho pendente; --force é decisão do usuário, não sua. Beco sem saída? `wendkeep change abandon`.',
@@ -72,8 +72,8 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
72
72
  additionalContext: buildInjection(vaultBase),
73
73
  },
74
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.
75
+ // Sentinela do change-context: o backlog completo acabou de ser injetado aqui, então o hook
76
+ // UserPromptSubmit não precisa re-pingar no 1º prompt. Bônus — nunca derruba a injeção.
77
77
  try {
78
78
  const st = changeCtxState(vaultBase);
79
79
  if (st) writeSentinel(vaultBase, 'ctx', input.session_id || input.sessionId || '', st.hash);
@@ -1,14 +1,13 @@
1
1
  #!/usr/bin/env node
2
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
3
+ // 1. Com changes abertas: re-injeta <open_changes_ping> (backlog completo) SÓ quando o estado
4
+ // mudou desde a última injeção (hash em sentinela por sessão).
5
+ // 2. Sem changes abertas: prompt com cara de tarefa ganha <wk_skill_gate> mandando invocar a
7
6
  // Skill wk-workflow ANTES de editar — 1x por sessão. É o empurrão de ativação da skill.
8
7
  // Fail-open; brain-inject grava a sentinela ctx no SessionStart para não duplicar no 1º prompt.
9
8
  import { pathToFileURL } from 'node:url';
10
9
  import { getVaultBase, readHookInput, writeHookOutput } from './obsidian-common.mjs';
11
- import { changeCtxState, readSentinel, writeSentinel } from './change-core.mjs';
10
+ import { changeCtxState, readSentinel, renderOpenChanges, writeSentinel } from './change-core.mjs';
12
11
 
13
12
  // Conservador de propósito: verbos de tarefa comuns (pt+en) + tamanho mínimo. Falso-negativo
14
13
  // custa só o nudge; falso-positivo em pergunta curta viraria ruído.
@@ -25,17 +24,9 @@ export function buildChangePing(vaultBase, sessionId, prompt = '') {
25
24
  if (st) {
26
25
  if (readSentinel(vaultBase, 'ctx', sessionId) === st.hash) return null;
27
26
  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 };
27
+ return { context: renderOpenChanges(st, { tag: 'open_changes_ping' }), hash: st.hash };
37
28
  }
38
- // Sem change ativa: gate de skill para prompt-tarefa, 1x por sessão.
29
+ // Sem changes abertas: gate de skill para prompt-tarefa, 1x por sessão.
39
30
  if (!looksLikeTask(prompt)) return null;
40
31
  if (readSentinel(vaultBase, 'gate', sessionId)) return null;
41
32
  writeSentinel(vaultBase, 'gate', sessionId);
@@ -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);
@@ -160,22 +167,67 @@ export function listChanges(vaultBase) {
160
167
  try {
161
168
  archived = readdirSync(join(base, ARCHIVE_DIR)).filter((n) => !n.startsWith('.'));
162
169
  } catch { /* none */ }
163
- return { active, archived };
170
+ return { active: active.sort(), archived: archived.sort() };
164
171
  }
165
172
 
166
- export function buildActiveChangeInjection(vaultBase, { maxTasks = 8 } = {}) {
167
- const slug = activeChange(vaultBase);
168
- if (!slug) return '';
173
+ // Visão derivada de TODAS as changes abertas. CURRENT_CHANGE continua sendo o único foco para
174
+ // comandos implícitos; provider/session nunca filtram a fila, para que outro agente possa assumir
175
+ // o trabalho. O hash leva o conteúdo inteiro de cada tarefas.md — não apenas as contagens — pois
176
+ // ele controla a reinjeção por sessão dos hooks.
177
+ export function allChangesState(vaultBase) {
178
+ const current = activeChange(vaultBase);
179
+ const { active } = listChanges(vaultBase);
169
180
  const chDir = getLocale(vaultBase).folders.changes;
170
- let md = '';
171
- try { md = readFileSync(join(vaultBase, chDir, slug, 'tarefas.md'), 'utf8'); } catch { return ''; }
172
- const open = parseTasks(md).filter((t) => !t.done).slice(0, maxTasks);
173
- const lines = open.map((t) => `- [ ] ${t.id} ${t.text}`);
174
- const more = open.length === maxTasks ? '\n*…mais tarefas em tarefas.md*' : '';
175
- return `<active_change>
176
- Mudança ativa: ${slug} — [[${chDir}/${slug}/proposta]]. Tarefas abertas:
177
- ${lines.join('\n')}${more}
178
- </active_change>`;
181
+ const fingerprint = [`current:${current}`];
182
+ const changes = active.map((slug) => {
183
+ let md = '';
184
+ let warning = '';
185
+ try { md = readFileSync(join(vaultBase, chDir, slug, 'tarefas.md'), 'utf8'); }
186
+ catch { warning = 'tarefas.md ausente ou ilegível'; }
187
+ const tasks = parseTasks(md);
188
+ const openTasks = tasks.filter((t) => !t.done);
189
+ fingerprint.push(`slug:${slug}`, `tasks:${md}`, `warning:${warning}`);
190
+ return {
191
+ slug,
192
+ current: slug === current,
193
+ openTasks,
194
+ openCount: openTasks.length,
195
+ doneCount: tasks.length - openTasks.length,
196
+ warning,
197
+ };
198
+ });
199
+ changes.sort((a, b) => Number(b.current) - Number(a.current) || a.slug.localeCompare(b.slug));
200
+ const pointerWarning = current && !changes.some((change) => change.current)
201
+ ? `CURRENT_CHANGE aponta para change inexistente: ${current}`
202
+ : '';
203
+ if (pointerWarning) fingerprint.push(`pointer-warning:${pointerWarning}`);
204
+ return { current, changes, pointerWarning, hash: tasksHashOf(fingerprint.join('\n')) };
205
+ }
206
+
207
+ export function renderOpenChanges(state, { tag = 'open_changes' } = {}) {
208
+ if (!state?.changes?.length && !state?.pointerWarning) return '';
209
+ const lines = [];
210
+ if (tag) lines.push(`<${tag}>`);
211
+ if (state.current) lines.push(`Change atual (comandos sem --change): ${state.current}.`);
212
+ else lines.push('Nenhuma change atual selecionada; comandos sem --change continuam recusados.');
213
+ if (state.pointerWarning) lines.push(`Aviso: ${state.pointerWarning}.`);
214
+ for (const change of state.changes || []) {
215
+ const label = change.current ? 'ATUAL' : 'ABERTA';
216
+ lines.push(`### ${label} — ${change.slug} (${change.openCount} aberta(s), ${change.doneCount} concluída(s))`);
217
+ if (change.warning) lines.push(`- Aviso: ${change.warning}.`);
218
+ else if (!change.openTasks.length) lines.push('- Nenhuma tarefa aberta.');
219
+ else for (const task of change.openTasks) lines.push(`- [ ] ${task.id} ${task.text}`);
220
+ }
221
+ if (state.current) lines.push('Para change atual: `wendkeep change done <id>`; antes de archive: `wendkeep verify`.');
222
+ lines.push('Qualquer agente pode assumir uma change: selecione-a com `wendkeep change new <slug-existente>` ou use `--change <slug>` quando disponível.');
223
+ if (tag) lines.push(`</${tag}>`);
224
+ return lines.join('\n');
225
+ }
226
+
227
+ // Mantém o nome exportado para consumidores internos existentes, mas agora injeta o backlog
228
+ // completo em vez de ocultar changes não selecionadas.
229
+ export function buildActiveChangeInjection(vaultBase) {
230
+ return renderOpenChanges(allChangesState(vaultBase));
179
231
  }
180
232
 
181
233
  export function activeChangeLink(vaultBase) {
@@ -225,14 +277,18 @@ export function writeSentinel(vaultBase, kind, sid, value = '1') {
225
277
  } catch { /* fail-open: pior caso = aviso repetido */ }
226
278
  }
227
279
 
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) };
280
+ // Estado global usado pelo change-context: hash cobre qualquer tarefa aberta, inclusive de uma
281
+ // change que não esteja no ponteiro. As propriedades slug/openTasks preservam compatibilidade com
282
+ // consumidores antigos e descrevem somente a atual.
283
+ export function changeCtxState(vaultBase) {
284
+ const state = allChangesState(vaultBase);
285
+ if (!state.changes.length && !state.pointerWarning) return null;
286
+ const selected = state.changes.find((change) => change.current);
287
+ return {
288
+ ...state,
289
+ slug: state.current,
290
+ openTasks: selected?.openTasks || [],
291
+ };
236
292
  }
237
293
 
238
294
  // GC das sentinelas (>7 dias) — seleção pura separada da execução (testável sem depender de
@@ -291,6 +347,12 @@ export function archiveChange(vaultBase, slug, { gate = gateGreen, dateStr, adrN
291
347
  const loc = getLocale(vaultBase);
292
348
  const chDir = loc.folders.changes;
293
349
  const src = join(vaultBase, chDir, slug);
350
+ let sourceSessionRel = '';
351
+ try {
352
+ const proposal = readFileSync(join(src, 'proposta.md'), 'utf8');
353
+ const m = proposal.match(/\[\[((?:02-Sessões|02-Sessions)\/[^\]|]+?)(?:\|[^\]]+)?\]\]/);
354
+ if (m) sourceSessionRel = m[1].endsWith('.md') ? m[1] : `${m[1]}.md`;
355
+ } catch { /* sem source */ }
294
356
  const verdict = gate(src);
295
357
  if (!verdict.ok) return { ok: false, failing: verdict.failing || [] };
296
358
 
@@ -324,7 +386,9 @@ export function archiveChange(vaultBase, slug, { gate = gateGreen, dateStr, adrN
324
386
  ...res.warnings,
325
387
  ];
326
388
  }
327
- } catch { /* promoção falhou — segue só com ADR */ }
389
+ } catch (error) {
390
+ return { ok: false, failing: [`falha ao promover specs: ${error.message}`] };
391
+ }
328
392
 
329
393
  let reqIds = [];
330
394
  try { reqIds = [...new Set(parseTasks(readFileSync(join(src, 'tarefas.md'), 'utf8')).map((t) => t.req).filter(Boolean))]; } catch { /* sem tarefas */ }
@@ -343,6 +407,17 @@ export function archiveChange(vaultBase, slug, { gate = gateGreen, dateStr, adrN
343
407
  writeFileSync(pp, c, 'utf8');
344
408
  } catch { /* proposta ilegível — segue */ }
345
409
 
410
+ // A sessão guardava o link da change ativa; após o move, reescreva para o caminho arquivado.
411
+ if (sourceSessionRel) {
412
+ try {
413
+ const sessionPath = join(vaultBase, sourceSessionRel);
414
+ const oldLink = wikilinkFromRel(join(chDir, slug, 'proposta'));
415
+ const archivedLink = wikilinkFromRel(join(destRel, 'proposta'));
416
+ const current = readFileSync(sessionPath, 'utf8');
417
+ if (current.includes(oldLink)) writeFileSync(sessionPath, current.replaceAll(oldLink, archivedLink), 'utf8');
418
+ } catch { /* backlink é reparo auxiliar; archive já está íntegro */ }
419
+ }
420
+
346
421
  // ADR goes in the same dated month folder as session-derived decisions (04-Decisões/ano/MM-MMM/)
347
422
  // — not the year root — so all ADRs sit together in the vault's convention.
348
423
  const adrDirRel = monthFolderRelFromDateStr(loc.folders.decisions, dateStr, vaultBase);
@@ -33,9 +33,20 @@ export function decisionKeyExists(dir, key) {
33
33
  return false;
34
34
  }
35
35
 
36
- // The AskUserQuestion tool_output reads: `... "Question"="chosen labels" "Q2"="..."`.
36
+ // Claude Code >= 2.1 returns `{ answers: { Question: choice } }` in tool_response.
37
+ // Older releases expose a text tool_output: `... "Question"="chosen labels" "Q2"="..."`.
37
38
  export function parseAnswers(output) {
38
39
  const map = {};
40
+ if (output && typeof output === 'object') {
41
+ const answers = output.answers && typeof output.answers === 'object'
42
+ ? output.answers
43
+ : output;
44
+ for (const [question, answer] of Object.entries(answers)) {
45
+ if (typeof answer === 'string') map[clean(question)] = clean(answer);
46
+ else if (Array.isArray(answer)) map[clean(question)] = answer.map(clean).filter(Boolean).join(',');
47
+ }
48
+ if (Object.keys(map).length) return map;
49
+ }
39
50
  const re = /"([^"]+)"\s*=\s*"([^"]*)"/g;
40
51
  let m;
41
52
  while ((m = re.exec(String(output || '')))) map[m[1].trim()] = m[2].trim();
@@ -172,7 +183,35 @@ export function captureDecision(vaultBase, input) {
172
183
  writeFileSync(filePath, buildDecisionCaptureNote({
173
184
  questions, answers, dateStr, startedAt: formatLocalIso(now), sessionRel, provider, localeId: loc.id, adrNum, contentKey,
174
185
  }), 'utf-8');
175
- return { rel: toVaultRelative(vaultBase, filePath), skipped: false };
186
+ const rel = toVaultRelative(vaultBase, filePath);
187
+ if (sessionRel) {
188
+ try {
189
+ const sessionPath = join(vaultBase, sessionRel);
190
+ let session = readFileSync(sessionPath, 'utf8');
191
+ const wikilink = wikilinkFromRel(rel);
192
+ const link = `- ${wikilink}`;
193
+ if (!session.includes(wikilink)) {
194
+ const heading = '\n## Decisões geradas nesta sessão\n';
195
+ const at = session.indexOf(heading);
196
+ if (at !== -1) {
197
+ const bodyStart = at + heading.length;
198
+ const nextRel = session.slice(bodyStart).search(/\n## /);
199
+ const bodyEnd = nextRel === -1 ? session.length : bodyStart + nextRel;
200
+ const body = session.slice(bodyStart, bodyEnd)
201
+ .replace(/\n?Nenhuma decisão registrada ainda\.\s*/i, '\n')
202
+ .trim();
203
+ const merged = [body, link].filter(Boolean).join('\n');
204
+ session = `${session.slice(0, bodyStart)}\n${merged}\n\n${session.slice(bodyEnd).replace(/^\n+/, '')}`;
205
+ } else {
206
+ const anchor = session.indexOf('\n## Encerramento');
207
+ const section = `\n## Decisões geradas nesta sessão\n\n${link}\n`;
208
+ session = anchor === -1 ? `${session.trimEnd()}${section}` : `${session.slice(0, anchor).trimEnd()}${section}${session.slice(anchor)}`;
209
+ }
210
+ writeFileSync(sessionPath, session, 'utf8');
211
+ }
212
+ } catch { /* backlink auxiliar nunca derruba a captura */ }
213
+ }
214
+ return { rel, skipped: false };
176
215
  }
177
216
 
178
217
  if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
@@ -3,7 +3,7 @@
3
3
  import { existsSync, readFileSync, readdirSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
5
  import { activeChange, parseTasks } from './change-core.mjs';
6
- import { parseRequirements, parseSpecsList, parseDelta, evaluateVerdict } from './spec-core.mjs';
6
+ import { parseRequirements, parseSpecsList, parseDelta, evaluateVerdict, validateSpecImpact } from './spec-core.mjs';
7
7
  import { getLocale } from './locale.mjs';
8
8
 
9
9
  export function checkHarness(vaultBase, projectRoot) {
@@ -43,6 +43,9 @@ export function checkHarness(vaultBase, projectRoot) {
43
43
  let entries;
44
44
  try { entries = readdirSync(dir); } catch { continue; } // a file, not a change dir
45
45
  if (!entries.includes('proposta.md')) { errors.push(`change sem proposta.md: ${name}`); continue; }
46
+ const impact = validateSpecImpact(dir);
47
+ errors.push(...impact.errors.map((e) => `${name}: ${e}`));
48
+ warnings.push(...impact.warnings.map((w) => `${name}: ${w}`));
46
49
  if (name === active) {
47
50
  try {
48
51
  for (const cap of parseSpecsList(readFileSync(join(dir, 'proposta.md'), 'utf8'))) {
@@ -3,10 +3,20 @@
3
3
  // Code e o vault: quando o usuário APROVA um plano, o plano vira registro — anexado à change
4
4
  // ativa, ou uma change nova criada e preenchida a partir dele (proposta do Contexto, design do
5
5
  // corpo, tarefas dos checkboxes). Não depende de a LLM lembrar do processo. Rejeição = no-op.
6
- import { readFileSync, writeFileSync } from 'node:fs';
6
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
7
+ import { createHash } from 'node:crypto';
7
8
  import { join } from 'node:path';
8
9
  import { pathToFileURL } from 'node:url';
9
- import { formatDate, getVaultBase, readHookInput, slugify, writeHookOutput } from './obsidian-common.mjs';
10
+ import {
11
+ findActiveSessionByTranscript,
12
+ formatDate,
13
+ getVaultBase,
14
+ readControl,
15
+ readHookInput,
16
+ slugify,
17
+ wikilinkFromRel,
18
+ writeHookOutput,
19
+ } from './obsidian-common.mjs';
10
20
  import { activeChange, newChange } from './change-core.mjs';
11
21
  import { getLocale } from './locale.mjs';
12
22
 
@@ -17,8 +27,18 @@ export function extractPlan(input) {
17
27
  const tr = input?.tool_response ?? input?.toolResponse ?? '';
18
28
  const resp = typeof tr === 'string' ? tr : JSON.stringify(tr || '');
19
29
  if (/doesn'?t want to proceed|user rejected|rejected the plan/i.test(resp)) return null;
20
- if (!/approved (your |the )?plan|Approved Plan/i.test(resp)) return null;
21
30
  const direct = input?.tool_input?.plan ?? input?.toolInput?.plan;
31
+ // Claude Code atual entrega o plano aprovado como objeto estruturado no PostToolUse.
32
+ // Esse evento só ocorre após sucesso; flags negativas explícitas continuam no-op.
33
+ if (tr && typeof tr === 'object') {
34
+ if (tr.approved === false || tr.rejected === true || tr.cancelled === true) return null;
35
+ const structured = tr.plan ?? direct;
36
+ if (structured && String(structured).trim()) return String(structured);
37
+ const structuredPath = tr.filePath ?? tr.planFilePath;
38
+ if (structuredPath) { try { return readFileSync(String(structuredPath), 'utf8'); } catch { /* inacessível */ } }
39
+ return null;
40
+ }
41
+ if (!/approved (your |the )?plan|Approved Plan/i.test(resp)) return null;
22
42
  if (direct && String(direct).trim()) return String(direct);
23
43
  const marker = resp.match(/## Approved Plan[^\n]*:\s*\n([\s\S]+)$/);
24
44
  if (marker && marker[1].trim()) return marker[1].trim();
@@ -27,6 +47,27 @@ export function extractPlan(input) {
27
47
  return null;
28
48
  }
29
49
 
50
+ function planIndex(slug) {
51
+ return `---\ntype: plan-index\ntags:\n - plano\n---\n\n# ${slug} — planos aprovados\n`;
52
+ }
53
+
54
+ export function persistApprovedPlan(dir, slug, rawNote, plan, dateStr) {
55
+ const hash = createHash('sha256').update(String(plan)).digest('hex').slice(0, 12);
56
+ const snapshotsDir = join(dir, 'planos');
57
+ mkdirSync(snapshotsDir, { recursive: true });
58
+ const snapshot = join(snapshotsDir, `${hash}.md`);
59
+ if (!existsSync(snapshot)) writeFileSync(snapshot, rawNote, 'utf8');
60
+
61
+ const indexPath = join(dir, 'plano-aprovado.md');
62
+ let index = existsSync(indexPath) ? readFileSync(indexPath, 'utf8') : planIndex(slug);
63
+ const entry = `- [[planos/${hash}|${dateStr} — ${hash}]]`;
64
+ if (!index.includes(`[[planos/${hash}|`)) {
65
+ index = `${index.trimEnd()}\n\n${entry}\n`;
66
+ writeFileSync(indexPath, index, 'utf8');
67
+ }
68
+ return { hash, snapshot };
69
+ }
70
+
30
71
  export function planSlug(plan) {
31
72
  const h1 = String(plan || '').match(/^#\s+(.+)$/m);
32
73
  return h1 ? slugify(h1[1], 'plano-aprovado', 60) : 'plano-aprovado';
@@ -53,11 +94,15 @@ export function capturePlan(vaultBase, input) {
53
94
  const en = loc.id === 'en';
54
95
  const dateStr = formatDate(new Date());
55
96
  const rawNote = `---\ntype: plan\ndate: ${dateStr}\ntags:\n - plano\n---\n\n${plan.trim()}\n`;
97
+ const transcriptPath = input?.transcript_path ?? input?.transcriptPath ?? '';
98
+ const sessionRel = findActiveSessionByTranscript(vaultBase, transcriptPath)?.session_file
99
+ || readControl(vaultBase).session_file
100
+ || '';
56
101
 
57
102
  const active = activeChange(vaultBase);
58
103
  if (active) {
59
104
  const dir = join(vaultBase, loc.folders.changes, active);
60
- writeFileSync(join(dir, 'plano-aprovado.md'), rawNote, 'utf8');
105
+ persistApprovedPlan(dir, active, rawNote, plan, dateStr);
61
106
  return {
62
107
  slug: active, created: false,
63
108
  context: `<plan_captured>\n${en
@@ -67,9 +112,10 @@ export function capturePlan(vaultBase, input) {
67
112
  }
68
113
 
69
114
  const slug = planSlug(plan);
70
- newChange(vaultBase, slug, { dateStr });
115
+ newChange(vaultBase, slug, { dateStr, sessionRel });
71
116
  const dir = join(vaultBase, loc.folders.changes, slug);
72
117
  const ctx = sectionBody(plan, ['Contexto', 'Context']) || plan.trim().split('\n').slice(0, 6).join('\n');
118
+ const source = sessionRel ? `\n - "${wikilinkFromRel(sessionRel)}"` : ' []';
73
119
  writeFileSync(join(dir, 'proposta.md'), `---
74
120
  type: change
75
121
  status: active
@@ -78,7 +124,9 @@ cssclasses:
78
124
  - topic-change
79
125
  tags:
80
126
  - mudanca
81
- source: []
127
+ source:${source}
128
+ spec_impact: pending
129
+ spec_impact_reason: ""
82
130
  specs: []
83
131
  ---
84
132
 
@@ -95,7 +143,7 @@ ${en ? 'See design.md and plano-aprovado.md (captured from the approved plan-mod
95
143
  writeFileSync(join(dir, 'design.md'), `# ${slug} — design\n\n${plan.trim()}\n`, 'utf8');
96
144
  const tasks = planTasks(plan);
97
145
  if (tasks.length) writeFileSync(join(dir, 'tarefas.md'), `# ${slug} — ${en ? 'tasks' : 'tarefas'}\n\n${tasks.join('\n')}\n`, 'utf8');
98
- writeFileSync(join(dir, 'plano-aprovado.md'), rawNote, 'utf8');
146
+ persistApprovedPlan(dir, slug, rawNote, plan, dateStr);
99
147
  return {
100
148
  slug, created: true,
101
149
  context: `<plan_captured>\n${en
@@ -110,7 +158,11 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
110
158
  const r = capturePlan(getVaultBase(input), input);
111
159
  if (!r) { writeHookOutput({}); }
112
160
  else writeHookOutput({ hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: r.context } });
113
- } catch {
114
- writeHookOutput({});
161
+ } catch (error) {
162
+ process.stderr.write(`[wendkeep] plan-capture falhou: ${error.message}\n`);
163
+ writeHookOutput({ hookSpecificOutput: {
164
+ hookEventName: 'PostToolUse',
165
+ additionalContext: `<plan_capture_error>O plano aprovado não foi persistido: ${error.message}</plan_capture_error>`,
166
+ } });
115
167
  }
116
168
  }
@@ -1,7 +1,7 @@
1
1
  // hooks/spec-core.mjs — living spec (07-Specs) + change delta merge (OpenSpec native).
2
2
  // Pure parsing/merge + promoteSpecs (fs). No import from change-core (avoids a cycle).
3
3
  import { createHash } from 'node:crypto';
4
- import { readFileSync, readdirSync, writeFileSync } from 'node:fs';
4
+ import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
5
5
  import { join } from 'node:path';
6
6
  import { ensureDir } from './obsidian-common.mjs';
7
7
  import { getLocale } from './locale.mjs';
@@ -87,6 +87,49 @@ export function parseSpecsList(propostaMd) {
87
87
  return [];
88
88
  }
89
89
 
90
+ function yamlScalar(text, key) {
91
+ const m = String(text).match(new RegExp(`^${key}:\\s*(.*)$`, 'm'));
92
+ return m ? m[1].trim().replace(/^['"]|['"]$/g, '') : '';
93
+ }
94
+
95
+ export function parseSpecImpact(propostaMd) {
96
+ const text = String(propostaMd || '');
97
+ const status = yamlScalar(text, 'spec_impact') || yamlScalar(text, 'spec-impact');
98
+ const reason = yamlScalar(text, 'spec_impact_reason') || yamlScalar(text, 'spec-impact-reason');
99
+ return { status, reason };
100
+ }
101
+
102
+ export function validateSpecImpact(changeDir) {
103
+ let proposta = '';
104
+ try { proposta = readFileSync(join(changeDir, 'proposta.md'), 'utf8'); }
105
+ catch { return { ok: false, errors: ['proposta.md ausente'], warnings: [], status: '', listed: [], onDisk: [] }; }
106
+
107
+ const { status, reason } = parseSpecImpact(proposta);
108
+ const listed = parseSpecsList(proposta);
109
+ const onDisk = discoverSpecDeltas(changeDir);
110
+ const enforced = existsSync(join(changeDir, '.spec-impact-v1'));
111
+ const errors = [];
112
+ const warnings = [];
113
+
114
+ if (!status) {
115
+ if (enforced) errors.push('spec_impact ausente — classifique como required ou none');
116
+ else warnings.push('change legada sem spec_impact — migre para required ou none antes do próximo ciclo');
117
+ return { ok: errors.length === 0, errors, warnings, status: '', listed, onDisk, legacy: !enforced };
118
+ }
119
+ if (!['pending', 'required', 'none'].includes(status)) errors.push(`spec_impact inválido: ${status}`);
120
+ if (status === 'pending') errors.push('spec_impact pending — classifique o impacto antes de arquivar');
121
+ if (status === 'none') {
122
+ if (!reason) errors.push('spec_impact none exige justificativa em spec_impact_reason');
123
+ if (listed.length || onDisk.length) errors.push('spec_impact none contradiz specs/deltas declarados');
124
+ }
125
+ if (status === 'required') {
126
+ if (!listed.length) errors.push('spec_impact required exige ao menos uma capability em specs');
127
+ for (const cap of listed) if (!onDisk.includes(cap)) errors.push(`delta real ausente para capability ${cap}`);
128
+ for (const cap of onDisk) if (!listed.includes(cap)) errors.push(`delta ${cap} existe no disco mas não está listado em specs`);
129
+ }
130
+ return { ok: errors.length === 0, errors, warnings, status, reason, listed, onDisk, legacy: false };
131
+ }
132
+
90
133
  // Um delta ainda no estado do scaffold (só o requisito "(nome)"/"(name)", nada removido) não é
91
134
  // contrato — a promoção o filtra. Um delta com REMOVED é sempre intenção real.
92
135
  export function isPlaceholderDelta(md) {
@@ -118,9 +161,11 @@ export function promoteSpecs(vaultBase, changeDir, specs, { changeWikilink, date
118
161
  const promoted = [];
119
162
  const warnings = [];
120
163
  for (const cap of specs) {
121
- let delta;
122
- try { delta = parseDelta(readFileSync(join(changeDir, 'specs', cap, 'spec.md'), 'utf8')); }
123
- catch { warnings.push(`sem delta para ${cap}`); continue; }
164
+ let deltaMd;
165
+ try { deltaMd = readFileSync(join(changeDir, 'specs', cap, 'spec.md'), 'utf8'); }
166
+ catch { throw new Error(`delta ausente para ${cap}`); }
167
+ if (isPlaceholderDelta(deltaMd)) throw new Error(`delta placeholder para ${cap}`);
168
+ const delta = parseDelta(deltaMd);
124
169
  const livePath = join(vaultBase, specsDir, `${cap}.md`);
125
170
  let current = [];
126
171
  try { current = parseRequirements(readFileSync(livePath, 'utf8')); } catch { /* nova capability */ }
@@ -71,14 +71,14 @@ function hasDefaultPending(content) {
71
71
  .some((line) => DEFAULT_PENDING_PATTERNS.some((pattern) => pattern.test(line.trim())));
72
72
  }
73
73
 
74
- function usageSectionIsPlaced(content) {
74
+ function usageSectionIsPlaced(content, { active = false } = {}) {
75
75
  const usage = content.indexOf('\n## Uso de tokens e custos');
76
76
  if (usage === -1) return true;
77
77
  const changed = content.indexOf('\n## Arquivos criados ou alterados');
78
78
  const pending = content.indexOf('\n## Pendências');
79
79
  const closing = content.indexOf('\n## Encerramento');
80
- if (pending === -1 || closing === -1) return false;
81
- return usage < pending && usage < closing && (changed === -1 || usage > changed);
80
+ if (pending === -1 || (!active && closing === -1)) return false;
81
+ return usage < pending && (active || usage < closing) && (changed === -1 || usage > changed);
82
82
  }
83
83
 
84
84
  function linkedNotesFromSession(content) {
@@ -102,14 +102,15 @@ function checkSession({ vaultBase, sessionRel, control, registry }) {
102
102
  return { failures, warnings, metrics };
103
103
  }
104
104
 
105
- const content = readFileSync(sessionPath, 'utf-8');
105
+ const content = readFileSync(sessionPath, 'utf-8');
106
+ const activeSession = control.status === 'active' && control.session_file === sessionRel;
106
107
  const duplicates = findDuplicateTurnMarkers(content);
107
108
  metrics.turnMarkers = (content.match(/<!-- (?:wk-turn|codex-turn):/g) || []).length;
108
109
  metrics.duplicateTurnMarkers = duplicates.length;
109
110
 
110
111
  if (duplicates.length) failures.push(`Marcadores de turno duplicados: ${duplicates.join(', ')}`);
111
112
  if (hasHeadingAfterClosing(content)) failures.push('Há headings/iterações após ## Encerramento.');
112
- if (!usageSectionIsPlaced(content)) failures.push('## Uso de tokens e custos está fora da posição esperada.');
113
+ if (!usageSectionIsPlaced(content, { active: activeSession })) failures.push('## Uso de tokens e custos está fora da posição esperada.');
113
114
  if (hasDefaultPending(content)) warnings.push('Pendências ainda contém placeholders padrão.');
114
115
 
115
116
  const registryEntry = registry.sessions?.[control.session_id];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wendkeep",
3
- "version": "0.31.0",
3
+ "version": "0.33.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
@@ -4,7 +4,9 @@ import { isAbsolute, join, resolve } from 'node:path';
4
4
  import {
5
5
  newChange,
6
6
  activeChange,
7
+ allChangesState,
7
8
  listChanges,
9
+ renderOpenChanges,
8
10
  parseTasks,
9
11
  setTaskDone,
10
12
  archiveChange,
@@ -12,7 +14,7 @@ import {
12
14
  scaffoldPlaceholders,
13
15
  } from '../hooks/change-core.mjs';
14
16
  import { evaluateGate, requiredSensors } from '../hooks/sensors-core.mjs';
15
- import { evaluateVerdict, tasksHashOf, parseSpecsList, parseDelta, parseRequirements, applyDelta } from '../hooks/spec-core.mjs';
17
+ import { evaluateVerdict, tasksHashOf, parseSpecsList, parseDelta, parseRequirements, applyDelta, validateSpecImpact } from '../hooks/spec-core.mjs';
16
18
  import { getNextAdrNumber, readControl } from '../hooks/obsidian-common.mjs';
17
19
  import { getLocale } from '../hooks/locale.mjs';
18
20
 
@@ -61,9 +63,9 @@ export function runChange(argv) {
61
63
  }
62
64
 
63
65
  if (sub === 'list') {
64
- const { active, archived } = listChanges(vaultBase);
65
- const cur = activeChange(vaultBase);
66
- process.stdout.write(`active: ${active.map((s) => (s === cur ? `*${s}` : s)).join(', ') || '(none)'}\n`);
66
+ const state = allChangesState(vaultBase);
67
+ const { archived } = listChanges(vaultBase);
68
+ process.stdout.write(`${renderOpenChanges(state, { tag: '' }) || 'open changes: (none)'}\n`);
67
69
  process.stdout.write(`archived: ${archived.join(', ') || '(none)'}\n`);
68
70
  process.exit(0);
69
71
  }
@@ -82,8 +84,16 @@ export function runChange(argv) {
82
84
  }
83
85
 
84
86
  if (sub === 'status') {
85
- const slug = slugArg() || activeChange(vaultBase);
86
- if (!slug) { process.stderr.write('wendkeep change status: no change (arg or active)\n'); process.exit(2); }
87
+ const slug = slugArg();
88
+ if (!slug) {
89
+ const state = allChangesState(vaultBase);
90
+ if (!state.changes.length && !state.pointerWarning) {
91
+ process.stderr.write('wendkeep change status: no open changes\n');
92
+ process.exit(2);
93
+ }
94
+ process.stdout.write(`${renderOpenChanges(state, { tag: '' })}\n`);
95
+ process.exit(0);
96
+ }
87
97
  const dir = join(vaultBase, getLocale(vaultBase).folders.changes, slug);
88
98
  let tarefasMd;
89
99
  try { tarefasMd = readFileSync(join(dir, 'tarefas.md'), 'utf8'); }
@@ -163,6 +173,9 @@ export function runChange(argv) {
163
173
  if (placeholders.length) {
164
174
  return { ok: false, failing: [`scaffold não preenchido (${placeholders.join('; ')}) — preencha proposta/design/tarefas antes de arquivar, ou \`wendkeep change abandon ${slug}\` se a change não vai adiante (--force não pula este check)`] };
165
175
  }
176
+ const impact = validateSpecImpact(dir);
177
+ for (const warning of impact.warnings) process.stderr.write(`aviso spec: ${warning}\n`);
178
+ if (!impact.ok) return { ok: false, failing: impact.errors };
166
179
  let tarefasMd = '';
167
180
  try { tarefasMd = readFileSync(join(dir, 'tarefas.md'), 'utf8'); } catch { /* no tasks */ }
168
181
  const tasks = parseTasks(tarefasMd);
package/src/init.mjs CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  mcpServerEntry,
16
16
  hookCommand,
17
17
  hookCommandLocal,
18
+ hookCommandLocalLegacy,
18
19
  deriveVaultDirName,
19
20
  selectableCompanions,
20
21
  resolveCompanions,
@@ -105,6 +106,16 @@ export function hookCommandFor(name, projectPath) {
105
106
  return hookCommand(name);
106
107
  }
107
108
 
109
+ function localHookAvailable(name, projectPath) {
110
+ try {
111
+ return !!projectPath && existsSync(join(projectPath, 'node_modules', 'wendkeep', 'hooks', `${name}.mjs`));
112
+ } catch { return false; }
113
+ }
114
+
115
+ function localHookArg(name) {
116
+ return `${'${CLAUDE_PROJECT_DIR}'}/node_modules/wendkeep/hooks/${name}.mjs`;
117
+ }
118
+
108
119
  export function mergeSettings(existing, { vaultPath, withMcp, force, companions = [], skipMcp = [], dotcontextHookLevel = 'full', projectPath = '' }) {
109
120
  const s = existing && typeof existing === 'object' ? { ...existing } : {};
110
121
  s.hooks = { ...(s.hooks || {}) };
@@ -120,19 +131,26 @@ export function mergeSettings(existing, { vaultPath, withMcp, force, companions
120
131
  ...companionHookSpecs(companions, { dotcontextHookLevel }),
121
132
  ].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
122
133
  for (const h of allSpecs) {
123
- const command = h.command ?? (h.preferLocal ? hookCommandFor(h.name, projectPath) : hookCommand(h.name));
134
+ const useLocal = !h.command && h.preferLocal && localHookAvailable(h.name, projectPath);
135
+ const command = h.command ?? (useLocal ? 'node' : hookCommand(h.name));
136
+ const args = useLocal ? [localHookArg(h.name)] : undefined;
124
137
  // Dual-recognition: um hook nomeado é reconhecido tanto na forma npx quanto na node-direta,
125
138
  // para que trocar a forma preferida (ou re-initar noutra máquina) nunca duplique o grupo.
126
- const candidates = h.command ? [h.command] : [hookCommand(h.name), hookCommandLocal(h.name)];
139
+ const candidates = h.command ? [h.command] : [hookCommand(h.name), hookCommandLocal(h.name), hookCommandLocalLegacy(h.name)];
140
+ const ownsHook = (x) => candidates.includes(x.command)
141
+ || (x.command === 'node' && Array.isArray(x.args) && x.args[0] === localHookArg(h.name));
127
142
  const groups = Array.isArray(s.hooks[h.event]) ? [...s.hooks[h.event]] : [];
128
- const owning = groups.find((g) => (g.hooks || []).some((x) => candidates.includes(x.command)));
143
+ const owning = groups.find((g) => (g.hooks || []).some(ownsHook));
129
144
  if (owning) {
130
145
  // Already wired: never add a duplicate group (the old `if (present && !force)` fell through
131
146
  // under --force and appended a second identical group). Under --force, refresh the managed
132
147
  // entry's fields in place — without disturbing any sibling hooks the user grouped with it.
133
- if (force) {
134
- const hk = owning.hooks.find((x) => candidates.includes(x.command));
148
+ const hk = owning.hooks.find(ownsHook);
149
+ const brokenRelative = hk?.command === hookCommandLocalLegacy(h.name);
150
+ if (force || brokenRelative) {
135
151
  hk.command = command;
152
+ if (args) hk.args = args;
153
+ else delete hk.args;
136
154
  hk.timeout = h.timeout;
137
155
  if (h.statusMessage) hk.statusMessage = h.statusMessage;
138
156
  if (h.matcher && (owning.hooks || []).length === 1) owning.matcher = h.matcher;
@@ -141,6 +159,7 @@ export function mergeSettings(existing, { vaultPath, withMcp, force, companions
141
159
  continue;
142
160
  }
143
161
  const entry = { type: 'command', command, timeout: h.timeout, statusMessage: h.statusMessage };
162
+ if (args) entry.args = args;
144
163
  const group = h.matcher ? { matcher: h.matcher, hooks: [entry] } : { hooks: [entry] };
145
164
  groups.push(group);
146
165
  s.hooks[h.event] = groups;
@@ -530,6 +549,8 @@ export async function runInit(argv) {
530
549
  log(M.mcpSkipped);
531
550
  }
532
551
 
552
+ log(' [!] ignore runtime do wendkeep no Git quando o vault for versionado: .brain/.change-*');
553
+
533
554
  // 4. Vault color system (.obsidian) -----------------------------------------
534
555
  if (args.noColors) {
535
556
  log(M.colorsSkipped);
@@ -29,8 +29,14 @@ vault cego. Exceção única: mudança trivial (typo, 1 linha).
29
29
  - \`proposta.md\` — *por quê* e *o que muda* (o WHAT).
30
30
  - \`design.md\` — a abordagem técnica.
31
31
  - \`tarefas.md\` — a lista de tarefas \`- [ ] N.N descrição\`.
32
- A mudança vira a *ativa* (ponteiro \`.brain/CURRENT_CHANGE.md\`) e é injetada no
33
- próximo SessionStart, então você retoma o trabalho em curso automaticamente.
32
+ A mudança vira a *atual* (ponteiro global \`.brain/CURRENT_CHANGE.md\`). Podem existir
33
+ várias changes abertas: hooks e \`change list/status\` mostram todas as pendências; comandos
34
+ sem \`--change\` usam somente a atual.
35
+ Antes de implementar, resolva \`spec_impact\` na proposta:
36
+ - \`required\`: liste a capability em \`specs:\` e preencha
37
+ \`specs/<capability>/spec.md\` com ADDED/MODIFIED/REMOVED; ligue tarefas com \`[req:ID]\`.
38
+ - \`none\`: registre uma justificativa real em \`spec_impact_reason\`.
39
+ \`pending\` nunca é estado pronto para implementação ou archive.
34
40
  3. **Apply** — implemente cada tarefa de \`tarefas.md\` com disciplina **wk-tdd**
35
41
  (teste vermelho antes do código). Marque \`- [x]\` ao concluir. Declare nas tarefas:
36
42
  - \`[sensor:<id>]\` — a prova automatizada (roda no verify).
@@ -48,7 +54,9 @@ vault cego. Exceção única: mudança trivial (typo, 1 linha).
48
54
 
49
55
  ## Regras
50
56
 
51
- - Uma mudança ativa por vez. Termine (archive) antes de abrir outra.
57
+ - Várias changes podem ficar abertas. \`CURRENT_CHANGE.md\` marca uma atual, sem esconder as
58
+ outras pendências. Claude, Codex ou outro agente podem assumir uma change existente com
59
+ \`wendkeep change new <slug-existente>\` ou \`--change <slug>\` quando disponível.
52
60
  - Se uma tarefa não precisa de prova automatizada, não declare sensor — o gate só exige
53
61
  o que você declarou. Sem \`[sensor:]\`, o archive não trava.
54
62
  - A proposta linka a sessão de origem; a sessão linka a mudança ativa. É de propósito:
@@ -139,6 +147,7 @@ qualquer código.
139
147
  Antes de fechar o design, resolva cada zona cinza: decide com o usuário, ou registra como
140
148
  **assumption assinada** ("assumo X porque Y — corrija se errado"). Cinza declinado pelo
141
149
  usuário é *registrado*, não descartado no silêncio. Nada sai do design silenciosamente ambíguo.
150
+ Declare também a capability e se o design tem \`spec_impact: required\` ou \`none\`.
142
151
 
143
152
  ## Tabela out-of-scope
144
153
 
@@ -170,6 +179,10 @@ Mapeie os arquivos: quais criar/modificar e a responsabilidade de cada um. Arqui
170
179
  mudam juntos ficam juntos; um arquivo, uma responsabilidade. É aqui que a decomposição
171
180
  trava.
172
181
 
182
+ Resolva o contrato antes de decompor: \`spec_impact: required\` exige capability + delta real em
183
+ \`specs/<capability>/spec.md\`; \`spec_impact: none\` exige justificativa. Cada comportamento do
184
+ delta recebe ID e as tarefas correspondentes usam \`[req:ID]\`.
185
+
173
186
  ## Tarefas bite-sized (TDD)
174
187
 
175
188
  Cada tarefa termina num entregável testável de forma independente. Cada passo é uma ação
@@ -238,8 +251,12 @@ leaves the vault blind. Single exception: a trivial change (typo, one line).
238
251
 
239
252
  1. **Explore** — understand the problem before proposing.
240
253
  2. **Propose** — \`wendkeep change new <slug>\` scaffolds \`08-Changes/<slug>/\`
241
- (proposta/design/tarefas + a \`specs/\` delta). The change becomes *active* and is
242
- injected at the next SessionStart.
254
+ (proposta/design/tarefas + a \`specs/\` delta). The change becomes *current* through global
255
+ \`.brain/CURRENT_CHANGE.md\`. Multiple changes may stay open; hooks and \`change list/status\`
256
+ show every pending task, while commands without \`--change\` use only the current change.
257
+ Before implementation, resolve \`spec_impact\`: \`required\` needs the capability listed in
258
+ \`specs:\` plus a real \`specs/<capability>/spec.md\` delta and \`[req:ID]\` links; \`none\`
259
+ needs a real \`spec_impact_reason\`. \`pending\` is never ready for implementation/archive.
243
260
  3. **Apply** — implement each task in tarefas.md with **wk-tdd** (red test first). Tag tasks:
244
261
  \`[sensor:<id>]\` (automated proof) and \`[req:<ID>]\` (the spec requirement it satisfies).
245
262
  4. **Verify** — \`wendkeep verify\` runs the sensors; then \`wendkeep verify --deep\` builds
@@ -250,7 +267,9 @@ leaves the vault blind. Single exception: a trivial change (typo, one line).
250
267
  verdict AND no open tasks. It promotes the delta into \`07-Specs\` and mints an ADR.
251
268
 
252
269
  ## Rules
253
- - One active change at a time. Finish (archive) before starting another.
270
+ - Multiple changes may stay open. \`CURRENT_CHANGE.md\` marks one current change without hiding
271
+ other pending tasks. Any agent may take over an existing change with
272
+ \`wendkeep change new <existing-slug>\` or \`--change <slug>\` where available.
254
273
  - No \`[sensor:]\` on a task = no automated gate for it. No \`[req:]\` = no independent verdict.
255
274
  - The graph links session ↔ change ↔ requirement ↔ decision. That is the point.
256
275
  `;
@@ -310,6 +329,7 @@ Use it when the idea is still vague. Turn it into an approved design BEFORE writ
310
329
  ## Closure gate — no dangling ambiguity
311
330
  Resolve every gray area: decide with the user, or log a **signed-off assumption** ("assuming X
312
331
  because Y — correct me if wrong"). A declined gray area is recorded, not silently dropped.
332
+ Also declare the capability and whether the design has \`spec_impact: required\` or \`none\`.
313
333
 
314
334
  ## Out-of-scope table
315
335
  List explicitly what the change does **not** do. Undeclared scope becomes creep.
@@ -331,6 +351,10 @@ Use it after an approved design. Produce a plan an engineer with no project cont
331
351
  Map the files: what to create/modify and each one's responsibility. Files that change together
332
352
  live together; one file, one responsibility.
333
353
 
354
+ Resolve the contract first: \`spec_impact: required\` needs a capability and a real delta at
355
+ \`specs/<capability>/spec.md\`; \`spec_impact: none\` needs a reason. Give each behaviour an ID
356
+ and link the corresponding tasks with \`[req:ID]\`.
357
+
334
358
  ## Bite-sized tasks (TDD)
335
359
  Each task ends in an independently testable deliverable. Each step is a 2–5 min action:
336
360
  write the failing test (show code) → run and see it fail (exact command + expected) → minimal
@@ -434,6 +458,10 @@ comes from the package (freshness seal; edit a task later and the gate rejects i
434
458
 
435
459
  const PLAN_TEMPLATE_PT = `# Template — plano de tarefas (TDD, bite-sized)
436
460
 
461
+ ## Impacto em specs
462
+ - \`spec_impact\`: \`required\` | \`none\`
463
+ - Capability/delta: \`specs/<capability>/spec.md\` ou justificativa de \`none\`
464
+
437
465
  ## Arquivos
438
466
  - Criar: \`caminho/exato.mjs\`
439
467
  - Modificar: \`caminho/existente.mjs:120-140\`
@@ -457,6 +485,10 @@ tarefas (uma função é \`x()\` em toda parte). Cada tarefa termina num entreg
457
485
 
458
486
  const PLAN_TEMPLATE_EN = `# Template — task plan (TDD, bite-sized)
459
487
 
488
+ ## Spec impact
489
+ - \`spec_impact\`: \`required\` | \`none\`
490
+ - Capability/delta: \`specs/<capability>/spec.md\` or the \`none\` rationale
491
+
460
492
  ## Files
461
493
  - Create: \`exact/path.mjs\`
462
494
  - Modify: \`exact/existing.mjs:120-140\`
package/src/taxonomy.mjs CHANGED
@@ -121,6 +121,10 @@ export function hookCommand(name) {
121
121
  // de segundos no Windows). Usada pelos hooks de ALTA FREQUÊNCIA (por prompt / por tool-call)
122
122
  // quando o projeto tem wendkeep instalado localmente; o init decide (hookCommandFor).
123
123
  export function hookCommandLocal(name) {
124
+ return `node "${'${CLAUDE_PROJECT_DIR}'}/node_modules/wendkeep/hooks/${name}.mjs"`;
125
+ }
126
+
127
+ export function hookCommandLocalLegacy(name) {
124
128
  return `node node_modules/wendkeep/hooks/${name}.mjs`;
125
129
  }
126
130