wendkeep 0.30.0 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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'))) {
@@ -0,0 +1,168 @@
1
+ #!/usr/bin/env node
2
+ // PostToolUse hook (matcher: ExitPlanMode). A ponte determinística entre o plan mode do Claude
3
+ // Code e o vault: quando o usuário APROVA um plano, o plano vira registro — anexado à change
4
+ // ativa, ou uma change nova criada e preenchida a partir dele (proposta do Contexto, design do
5
+ // corpo, tarefas dos checkboxes). Não depende de a LLM lembrar do processo. Rejeição = no-op.
6
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
7
+ import { createHash } from 'node:crypto';
8
+ import { join } from 'node:path';
9
+ import { pathToFileURL } from 'node:url';
10
+ import {
11
+ findActiveSessionByTranscript,
12
+ formatDate,
13
+ getVaultBase,
14
+ readControl,
15
+ readHookInput,
16
+ slugify,
17
+ wikilinkFromRel,
18
+ writeHookOutput,
19
+ } from './obsidian-common.mjs';
20
+ import { activeChange, newChange } from './change-core.mjs';
21
+ import { getLocale } from './locale.mjs';
22
+
23
+ // O plano aprovado chega por um de três canais, conforme a versão do Claude Code:
24
+ // tool_input.plan (legado), o bloco "## Approved Plan" do tool_response, ou o arquivo de plano
25
+ // citado em "saved to: <path>". Rejeição (ou ausência de sinal de aprovação) → null.
26
+ export function extractPlan(input) {
27
+ const tr = input?.tool_response ?? input?.toolResponse ?? '';
28
+ const resp = typeof tr === 'string' ? tr : JSON.stringify(tr || '');
29
+ if (/doesn'?t want to proceed|user rejected|rejected the plan/i.test(resp)) return null;
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;
42
+ if (direct && String(direct).trim()) return String(direct);
43
+ const marker = resp.match(/## Approved Plan[^\n]*:\s*\n([\s\S]+)$/);
44
+ if (marker && marker[1].trim()) return marker[1].trim();
45
+ const path = resp.match(/saved to:\s*([^\n]+\.md)/i);
46
+ if (path) { try { return readFileSync(path[1].trim(), 'utf8'); } catch { /* arquivo inacessível */ } }
47
+ return null;
48
+ }
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
+
71
+ export function planSlug(plan) {
72
+ const h1 = String(plan || '').match(/^#\s+(.+)$/m);
73
+ return h1 ? slugify(h1[1], 'plano-aprovado', 60) : 'plano-aprovado';
74
+ }
75
+
76
+ function sectionBody(plan, headings) {
77
+ for (const h of headings) {
78
+ const m = String(plan).match(new RegExp(`^##\\s+${h}\\s*\\n([\\s\\S]*?)(?=\\n##\\s|$)`, 'm'));
79
+ if (m && m[1].trim()) return m[1].trim();
80
+ }
81
+ return '';
82
+ }
83
+
84
+ // Checkboxes do plano viram tarefas numeradas 1.N (estado [x] preservado). [] quando não há.
85
+ export function planTasks(plan) {
86
+ const boxes = [...String(plan || '').matchAll(/^\s*-\s+\[( |x)\]\s+(.+)$/gm)];
87
+ return boxes.map((m, i) => `- [${m[1]}] 1.${i + 1} ${m[2].trim()}`);
88
+ }
89
+
90
+ export function capturePlan(vaultBase, input) {
91
+ const plan = extractPlan(input);
92
+ if (!plan) return null;
93
+ const loc = getLocale(vaultBase);
94
+ const en = loc.id === 'en';
95
+ const dateStr = formatDate(new Date());
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
+ || '';
101
+
102
+ const active = activeChange(vaultBase);
103
+ if (active) {
104
+ const dir = join(vaultBase, loc.folders.changes, active);
105
+ persistApprovedPlan(dir, active, rawNote, plan, dateStr);
106
+ return {
107
+ slug: active, created: false,
108
+ context: `<plan_captured>\n${en
109
+ ? `Approved plan attached to the active change "${active}" (plano-aprovado.md). Sync tarefas.md with the plan's tasks.`
110
+ : `Plano aprovado anexado à change ativa "${active}" (plano-aprovado.md). Sincronize tarefas.md com as tarefas do plano.`}\n</plan_captured>`,
111
+ };
112
+ }
113
+
114
+ const slug = planSlug(plan);
115
+ newChange(vaultBase, slug, { dateStr, sessionRel });
116
+ const dir = join(vaultBase, loc.folders.changes, slug);
117
+ const ctx = sectionBody(plan, ['Contexto', 'Context']) || plan.trim().split('\n').slice(0, 6).join('\n');
118
+ const source = sessionRel ? `\n - "${wikilinkFromRel(sessionRel)}"` : ' []';
119
+ writeFileSync(join(dir, 'proposta.md'), `---
120
+ type: change
121
+ status: active
122
+ date: ${dateStr}
123
+ cssclasses:
124
+ - topic-change
125
+ tags:
126
+ - mudanca
127
+ source:${source}
128
+ spec_impact: pending
129
+ spec_impact_reason: ""
130
+ specs: []
131
+ ---
132
+
133
+ # ${slug}
134
+
135
+ ${en ? '## Why' : '## Por quê'}
136
+
137
+ ${ctx}
138
+
139
+ ${en ? '## What changes' : '## O que muda'}
140
+
141
+ ${en ? 'See design.md and plano-aprovado.md (captured from the approved plan-mode plan).' : 'Ver design.md e plano-aprovado.md (capturados do plano aprovado no plan mode).'}
142
+ `, 'utf8');
143
+ writeFileSync(join(dir, 'design.md'), `# ${slug} — design\n\n${plan.trim()}\n`, 'utf8');
144
+ const tasks = planTasks(plan);
145
+ if (tasks.length) writeFileSync(join(dir, 'tarefas.md'), `# ${slug} — ${en ? 'tasks' : 'tarefas'}\n\n${tasks.join('\n')}\n`, 'utf8');
146
+ persistApprovedPlan(dir, slug, rawNote, plan, dateStr);
147
+ return {
148
+ slug, created: true,
149
+ context: `<plan_captured>\n${en
150
+ ? `Approved plan recorded in the vault: change "${slug}" created and set active (${loc.folders.changes}/${slug}/). Review tarefas.md${tasks.length ? '' : ' — the plan had no checkboxes, fill the tasks'} and follow the a2 loop (wk-tdd per task, \`wendkeep change done <id>\`, \`wendkeep verify\`).`
151
+ : `Plano aprovado — change "${slug}" registrada no vault e ativa (${loc.folders.changes}/${slug}/). Revise tarefas.md${tasks.length ? '' : ' — o plano não tinha checkboxes, preencha as tarefas'} e siga o loop a2 (wk-tdd por tarefa, \`wendkeep change done <id>\`, \`wendkeep verify\`).`}\n</plan_captured>`,
152
+ };
153
+ }
154
+
155
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
156
+ try {
157
+ const input = readHookInput();
158
+ const r = capturePlan(getVaultBase(input), input);
159
+ if (!r) { writeHookOutput({}); }
160
+ else writeHookOutput({ hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: r.context } });
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
+ } });
167
+ }
168
+ }
@@ -6,7 +6,7 @@ import { pathToFileURL } from 'url';
6
6
  import { createLinkedNotes } from './linked-notes.mjs';
7
7
  import { addUsage, costBreakdown, emptyTokenUsage, normalizeClaudeUsage, normalizeCodexUsage, priceForModel, updateSessionUsage } from './token-usage.mjs';
8
8
  import { buildBrainDigest, buildBrainIndex } from './brain-core.mjs';
9
- import { activeChangeLink } from './change-core.mjs';
9
+ import { activeChangeLink, pruneChangeSentinels } from './change-core.mjs';
10
10
  import { getLocale } from './locale.mjs';
11
11
  import { upsertSubagentUsage } from './subagent-usage.mjs';
12
12
  import {
@@ -855,12 +855,19 @@ function replacePendingSection(content, pending) {
855
855
  const end = content.indexOf(closingMarker, start + marker.length);
856
856
  if (end === -1) return content;
857
857
 
858
+ // Preserva seções que outros writers inseriram dentro do span (## Subagents & Workflows,
859
+ // ## Progresso do plano, ## Mudanças…) — só o texto das Pendências em si é regenerado.
860
+ const span = content.slice(start + marker.length, end);
861
+ const innerIdx = span.indexOf('\n## ');
862
+ const preserved = innerIdx === -1 ? '' : span.slice(innerIdx).trimEnd();
863
+
858
864
  return [
859
865
  content.slice(0, start).trimEnd(),
860
866
  '',
861
867
  '## Pendências',
862
868
  '',
863
869
  formatPendingSection(pending),
870
+ ...(preserved ? [preserved] : []),
864
871
  content.slice(end),
865
872
  ].join('\n');
866
873
  }
@@ -1135,6 +1142,9 @@ function main() {
1135
1142
  process.stderr.write(`[wendkeep] brain index/digest falhou: ${error.message}\n`);
1136
1143
  }
1137
1144
 
1145
+ // GC das sentinelas dos hooks de lifecycle (>7 dias) — fail-quiet, nunca derruba o Stop.
1146
+ try { pruneChangeSentinels(vaultBase); } catch { /* bônus */ }
1147
+
1138
1148
  pingObsidianVault(input.obsidian_api_key);
1139
1149
  writeHookOutput({});
1140
1150
  }
@@ -1,7 +1,7 @@
1
1
  // hooks/spec-core.mjs — living spec (07-Specs) + change delta merge (OpenSpec native).
2
2
  // Pure parsing/merge + promoteSpecs (fs). No import from change-core (avoids a cycle).
3
3
  import { createHash } from 'node:crypto';
4
- import { readFileSync, writeFileSync } from 'node:fs';
4
+ import { 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,73 @@ 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
+
133
+ // Um delta ainda no estado do scaffold (só o requisito "(nome)"/"(name)", nada removido) não é
134
+ // contrato — a promoção o filtra. Um delta com REMOVED é sempre intenção real.
135
+ export function isPlaceholderDelta(md) {
136
+ const d = parseDelta(md);
137
+ if (d.removed.length) return false;
138
+ const all = [...d.added, ...d.modified];
139
+ if (!all.length) return true;
140
+ return all.every((r) => /^\((?:nome|name)\)$/.test(r.name));
141
+ }
142
+
143
+ // Capabilities com delta REAL no disco (<change>/specs/<cap>/spec.md), independente do
144
+ // frontmatter `specs:` da proposta — o scaffold deixa `specs: []` e o buraco engolia deltas
145
+ // preenchidos mas não listados (visto em produção: 07-Specs vazio com delta real no disco).
146
+ export function discoverSpecDeltas(changeDir) {
147
+ let names = [];
148
+ try { names = readdirSync(join(changeDir, 'specs')); } catch { return []; }
149
+ const caps = [];
150
+ for (const cap of names) {
151
+ try { if (!isPlaceholderDelta(readFileSync(join(changeDir, 'specs', cap, 'spec.md'), 'utf8'))) caps.push(cap); }
152
+ catch { /* sem spec.md */ }
153
+ }
154
+ return caps;
155
+ }
156
+
90
157
  // Merge each capability's delta (in the change) into the living spec in 07-Specs.
91
158
  export function promoteSpecs(vaultBase, changeDir, specs, { changeWikilink, dateStr } = {}) {
92
159
  const loc = getLocale(vaultBase);
@@ -94,9 +161,11 @@ export function promoteSpecs(vaultBase, changeDir, specs, { changeWikilink, date
94
161
  const promoted = [];
95
162
  const warnings = [];
96
163
  for (const cap of specs) {
97
- let delta;
98
- try { delta = parseDelta(readFileSync(join(changeDir, 'specs', cap, 'spec.md'), 'utf8')); }
99
- 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);
100
169
  const livePath = join(vaultBase, specsDir, `${cap}.md`);
101
170
  let current = [];
102
171
  try { current = parseRequirements(readFileSync(livePath, 'utf8')); } catch { /* nova capability */ }
@@ -208,7 +208,9 @@ function upsertSection(content, heading, body) {
208
208
  const esc = heading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
209
209
  const re = new RegExp(`${esc}\\n[\\s\\S]*?(?=\\n## |$)`);
210
210
  if (re.test(content)) return content.replace(re, `${body}\n`);
211
- for (const anchor of ['\n## Encerramento', '\n## Issues Linear', '\n## Pendências']) {
211
+ // Âncora ANTES de '## Pendências': o finalize do Stop reescreve o span Pendências→Encerramento
212
+ // inteiro, então qualquer seção inserida lá dentro seria apagada (visto em produção).
213
+ for (const anchor of ['\n## Pendências', '\n## Issues Linear', '\n## Encerramento']) {
212
214
  const i = content.indexOf(anchor);
213
215
  if (i >= 0) return `${content.slice(0, i)}\n\n${body}\n${content.slice(i)}`;
214
216
  }
@@ -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.30.0",
3
+ "version": "0.32.0",
4
4
  "description": "A persistent-memory harness for AI coding agents on your Obsidian vault: turn-by-turn session capture plus a native, zero-dependency spec→change→verify→archive loop (sensor-gated, independent verdict, mutation discrimination). Local-first, agent-agnostic (Claude Code, Codex, Cursor…).",
5
5
  "type": "module",
6
6
  "bin": {
package/src/change.mjs CHANGED
@@ -8,10 +8,11 @@ import {
8
8
  parseTasks,
9
9
  setTaskDone,
10
10
  archiveChange,
11
+ abandonChange,
11
12
  scaffoldPlaceholders,
12
13
  } from '../hooks/change-core.mjs';
13
14
  import { evaluateGate, requiredSensors } from '../hooks/sensors-core.mjs';
14
- import { evaluateVerdict, tasksHashOf, parseSpecsList, parseDelta, parseRequirements, applyDelta } from '../hooks/spec-core.mjs';
15
+ import { evaluateVerdict, tasksHashOf, parseSpecsList, parseDelta, parseRequirements, applyDelta, validateSpecImpact } from '../hooks/spec-core.mjs';
15
16
  import { getNextAdrNumber, readControl } from '../hooks/obsidian-common.mjs';
16
17
  import { getLocale } from '../hooks/locale.mjs';
17
18
 
@@ -104,8 +105,8 @@ export function runChange(argv) {
104
105
  const reqIds = [...new Set(tasks.map((t) => t.req).filter(Boolean))];
105
106
  let verdict = null;
106
107
  try { verdict = JSON.parse(readFileSync(join(dir, 'verdict.json'), 'utf8')); } catch { /* sem verdict */ }
107
- if (!reqIds.length) process.stdout.write('verdict: não exigido (sem [req:])\n');
108
- else if (!verdict) process.stdout.write('verdict: ausente rode `wendkeep verify --deep` + wk-verify\n');
108
+ if (!verdict) process.stdout.write(`verdict: ausente rode \`wendkeep verify --deep\`${reqIds.length ? ' + wk-verify' : ' (verdict trivial automático)'}\n`);
109
+ else if (!reqIds.length) process.stdout.write(`verdict: ${verdict.ok === true ? 'ok (trivial)' : 'não-ok — re-verifique'}\n`);
109
110
  else {
110
111
  const v = evaluateVerdict(verdict, reqIds, { tasksHash: tasksHashOf(tarefasMd) });
111
112
  process.stdout.write(`verdict: ${v.ok ? 'ok' : v.stale ? 'stale — re-verifique' : `incompleto: falta ${v.missing.join(', ')}`}\n`);
@@ -156,12 +157,15 @@ export function runChange(argv) {
156
157
  // Real gate (Pilar C): every sensor a task declared must be green in evidencia.json.
157
158
  const gate = (dir) => {
158
159
  // G0: um scaffold nunca preenchido não é uma mudança concluída — arquivar geraria um
159
- // ADR falso. Bloqueia antes de qualquer outro check (o --force ainda escapa, mas a
160
- // regra injetada proíbe o agente de usá-lo sem o usuário pedir).
160
+ // ADR falso. INESCAPÁVEL desde 0.31.0 (--force não pula visto em produção: change
161
+ // 100% placeholder arquivada via --force mintou ADR falso). Saída legítima: abandon.
161
162
  const placeholders = scaffoldPlaceholders(dir);
162
- if (placeholders.length && !rest.includes('--force')) {
163
- return { ok: false, failing: [`scaffold não preenchido (${placeholders.join('; ')}) — preencha proposta/design/tarefas com o design e o plano antes de arquivar`] };
163
+ if (placeholders.length) {
164
+ return { ok: false, failing: [`scaffold não preenchido (${placeholders.join('; ')}) — preencha proposta/design/tarefas antes de arquivar, ou \`wendkeep change abandon ${slug}\` se a change não vai adiante (--force não pula este check)`] };
164
165
  }
166
+ const impact = validateSpecImpact(dir);
167
+ for (const warning of impact.warnings) process.stderr.write(`aviso spec: ${warning}\n`);
168
+ if (!impact.ok) return { ok: false, failing: impact.errors };
165
169
  let tarefasMd = '';
166
170
  try { tarefasMd = readFileSync(join(dir, 'tarefas.md'), 'utf8'); } catch { /* no tasks */ }
167
171
  const tasks = parseTasks(tarefasMd);
@@ -185,17 +189,38 @@ export function runChange(argv) {
185
189
  try { evidence = JSON.parse(readFileSync(join(dir, 'evidencia.json'), 'utf8')); } catch { /* no evidence */ }
186
190
  const s = evaluateGate(evidence, required);
187
191
  if (!s.ok) return s;
188
- // Independent verdict (Wave A): required only when the change declares [req:] tasks.
192
+ // Verdict SEMPRE exigido (0.31.0) a exigência universal vive AQUI no gate; a semântica
193
+ // reqless→ok de evaluateVerdict (spec-core) não muda porque `verify --deep` e `change
194
+ // status` dependem dela. Change sem [req:] destrava com o auto-verdict do verify --deep.
189
195
  let verdict = null;
190
196
  try { verdict = JSON.parse(readFileSync(join(dir, 'verdict.json'), 'utf8')); } catch { /* none */ }
191
- const v = evaluateVerdict(verdict, reqIds, { tasksHash: tasksHashOf(tarefasMd) });
192
- if (!v.ok) {
193
- if (v.stale) return { ok: false, failing: ['verdict stale (tarefas.md mudou depois da verificação) — re-verifique: `wendkeep verify --deep` + wk-verify'] };
194
- return { ok: false, failing: verdict ? [`verdict incompleto: falta ${v.missing.join(', ')}`] : ['sem verdict — rode `wendkeep verify --deep` + skill wk-verify'] };
197
+ const hash = tasksHashOf(tarefasMd);
198
+ if (!verdict) {
199
+ return { ok: false, failing: [reqIds.length
200
+ ? 'sem verdict — rode `wendkeep verify --deep` + skill wk-verify'
201
+ : 'sem verdict — rode `wendkeep verify --deep` (verdict trivial automático)'] };
202
+ }
203
+ if (verdict.ok !== true) return { ok: false, failing: ['verdict não-ok — re-verifique a change antes de arquivar'] };
204
+ if (verdict.tasksHash && verdict.tasksHash !== hash) {
205
+ return { ok: false, failing: [`verdict stale (tarefas.md mudou depois da verificação) — re-verifique: \`wendkeep verify --deep\`${reqIds.length ? ' + wk-verify' : ''}`] };
206
+ }
207
+ if (reqIds.length) {
208
+ const v = evaluateVerdict(verdict, reqIds, { tasksHash: hash });
209
+ if (!v.ok) {
210
+ if (v.stale) return { ok: false, failing: ['verdict stale (tarefas.md mudou depois da verificação) — re-verifique: `wendkeep verify --deep` + wk-verify'] };
211
+ return { ok: false, failing: [`verdict incompleto: falta ${v.missing.join(', ')}`] };
212
+ }
195
213
  }
196
214
  return { ok: true, failing: [] };
197
215
  };
198
- const r = archiveChange(vaultBase, slug, { dateStr: today(), adrNum: getNextAdrNumber(vaultBase), gate });
216
+ // Rastro auditável: forced quando o --force de fato pulou G1 (tarefa aberta); trivial
217
+ // quando a change não declarou nenhuma prova ([req:]/[sensor:]).
218
+ let tasks = [];
219
+ try { tasks = parseTasks(readFileSync(join(vaultBase, getLocale(vaultBase).folders.changes, slug, 'tarefas.md'), 'utf8')); } catch { /* sem tarefas */ }
220
+ const forced = rest.includes('--force') && tasks.some((t) => !t.done);
221
+ const trivial = !tasks.some((t) => t.req) && !tasks.some((t) => t.sensor);
222
+ if (trivial) process.stderr.write('aviso: change trivial (sem [req:]/[sensor:]) — ADR marcado trivial: true\n');
223
+ const r = archiveChange(vaultBase, slug, { dateStr: today(), adrNum: getNextAdrNumber(vaultBase), gate, adrFlags: { forced, trivial } });
199
224
  if (!r.ok) {
200
225
  process.stderr.write(`change archive BLOCKED (gate): ${r.failing.join('; ')}\n`);
201
226
  process.exit(1);
@@ -206,6 +231,15 @@ export function runChange(argv) {
206
231
  process.exit(0);
207
232
  }
208
233
 
209
- process.stderr.write(`wendkeep change: unknown subcommand "${sub}". Known: new, list, show, status, done, undone, diff, archive.\n`);
234
+ if (sub === 'abandon') {
235
+ const slug = slugArg() || activeChange(vaultBase);
236
+ if (!slug) { process.stderr.write('wendkeep change abandon: missing <slug> and no active change\n'); process.exit(2); }
237
+ const r = abandonChange(vaultBase, slug, { dateStr: today() });
238
+ if (!r.ok) { process.stderr.write(`wendkeep change abandon: ${r.failing.join('; ')}\n`); process.exit(2); }
239
+ process.stdout.write(`abandoned: ${r.archivedRel}\n`);
240
+ process.exit(0);
241
+ }
242
+
243
+ process.stderr.write(`wendkeep change: unknown subcommand "${sub}". Known: new, list, show, status, done, undone, diff, archive, abandon.\n`);
210
244
  process.exit(2);
211
245
  }