wendkeep 0.50.0 → 0.52.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,46 @@ 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.52.0] — 2026-07-25
8
+
9
+ ### Fixed
10
+
11
+ - **`claude-opus-5` e `claude-mythos-5` deixam de custar $0.** Modelo ausente de
12
+ `hooks/pricing.json` faz `priceForModel` devolver `null` e a parcela dele do custo virar
13
+ zero — sem erro, sem aviso, sem nada no `doctor`. Num vault real:
14
+ `claude-opus-5 $0.0000` no `wendkeep cost`, enquanto Opus 4.8 e Fable 5 somavam $462.
15
+ Adicionados com os preços de API: Opus 5 a $5 input / $0,50 cache read / $25 output
16
+ (mesmo tier do Opus 4.8) e Mythos 5 a $10 / $1 / $50 (mesmo tier do Fable 5), com os
17
+ aliases das variantes de id. Notas já fechadas com custo zerado se corrigem com
18
+ `wendkeep cost rebuild`.
19
+
20
+ ### Added
21
+
22
+ - **`wendkeep doctor` surfaça modelos sem preço.** Nova seção `[preços]`: lista os modelos
23
+ que aparecem nas notas de sessão com uso registrado mas sem entrada na tabela, e aponta
24
+ `hooks/pricing.json`. Cada modelo citado é consultado direto em `priceForModel` — **não**
25
+ se infere pelo sintoma "custo zerado", que não funciona: na nota que motivou a correção,
26
+ `modelo: "claude-opus-4.8 + claude-fable-5 + claude-opus-5"` fecha com **$415** porque os
27
+ dois primeiros têm preço, e só a fatia do Opus 5 está zerada. Um detector por custo zero
28
+ passaria batido justamente no caso real. Capability `session-observability` (OBS-9, OBS-10).
29
+
30
+ ## [0.51.0] — 2026-07-25
31
+
32
+ ### Added
33
+
34
+ - **`wendkeep note repair-frontmatter [--apply]` — conserta as notas de sessão empilhadas.**
35
+ A 0.50.0 fechou a causa (lock + escrita atômica) e ensinou o `doctor` a **apontar** as
36
+ notas danificadas, mas não havia comando para consertá-las — a única checagem do doctor
37
+ sem conserto ao lado. O reparo funde os blocos num só sem perder nada: as chaves-base vêm
38
+ do bloco original (o de baixo, o único que as tem) e os valores gerenciados do bloco mais
39
+ recente (o do topo, último prepend). O merge opera sobre o texto bruto de cada chave, sem
40
+ reserializar YAML, então listas aninhadas atravessam byte-a-byte. Dry-run por padrão como
41
+ o `note relink`; `--apply` grava pelo mesmo lock dos hooks. Antes de gravar valida que o
42
+ resultado tem um bloco só, que nenhuma chave sumiu e que o corpo sobreviveu — falhando
43
+ qualquer uma, pula e reporta. O `doctor` agora imprime
44
+ `→ wendkeep note repair-frontmatter --apply` quando a contagem é > 0. Capability
45
+ `session-observability` (OBS-7, OBS-8) e `vault-doctor` (DIAG-5).
46
+
7
47
  ## [0.50.0] — 2026-07-23
8
48
 
9
49
  ### Fixed
package/bin/wendkeep.mjs CHANGED
@@ -80,6 +80,10 @@ Usage:
80
80
  wendkeep note relink [--apply] Backfill orphan derived notes (BUG/APR without a source session),
81
81
  linking each to the modal source session of its type/month cohort. Dry-run
82
82
  by default; --apply writes; skips notes with no sibling to infer from.
83
+ wendkeep note repair-frontmatter [--apply] Merge stacked frontmatter blocks in session notes
84
+ (damage from pre-lock concurrent writes) into a single block: base keys
85
+ from the original block, values from the newest. Dry-run by default;
86
+ --apply writes under the same lock as the hooks · --json.
83
87
  wendkeep lesson add "t" "l" Record a project-local lesson (injected at SessionStart).
84
88
  wendkeep validate-memory [path] Validate .brain/CORE.md against the compaction
85
89
  protocol (cap 25, 3 sections, no secrets/PII). Uses
@@ -0,0 +1,135 @@
1
+ // Reparo das notas de sessão que ficaram com frontmatter empilhado.
2
+ //
3
+ // O dano vem das versões anteriores à session-note-atomic-write: sem lock, um hook lia a
4
+ // nota já truncada por outro e prependava um frontmatter novo. Como o prepend entra pelo
5
+ // TOPO, o bloco de baixo é o original (o único com type/date/provider/source) e o de cima
6
+ // é a gravação mais recente. Ficar com um só perde metade da informação; a fusão é que é a
7
+ // resposta.
8
+ //
9
+ // A causa já está fechada — isto aqui limpa o que ficou para trás.
10
+ import { readFileSync } from 'node:fs';
11
+ import { relative } from 'node:path';
12
+ import { checkStackedFrontmatter } from './harness-doctor.mjs';
13
+ import { mutateSessionNote } from './session-note-io.mjs';
14
+
15
+ // Separa os blocos de frontmatter empilhados no topo do corpo da nota.
16
+ //
17
+ // A regra de "empilhado" é a MESMA de `checkStackedFrontmatter`: só conta como bloco o que
18
+ // reabre com `---` logo após o fechamento do anterior. Detector e reparador precisam
19
+ // concordar — se divergissem, o doctor acusaria uma nota que o reparo não conserta (ou
20
+ // pior, o reparo comeria corpo que o doctor considera são). Um `---` no meio do texto
21
+ // (regra horizontal, tabela) fica no corpo, onde deve ficar.
22
+ export function splitStackedFrontmatter(content) {
23
+ const blocks = [];
24
+ let rest = typeof content === 'string' ? content : '';
25
+
26
+ while (/^---\n/.test(rest)) {
27
+ const close = rest.indexOf('\n---', 4);
28
+ if (close < 0) break;
29
+ blocks.push(rest.slice(0, close + 4));
30
+ rest = rest.slice(close + 4).replace(/^[\r\n]+/, '');
31
+ }
32
+
33
+ return { blocks, body: rest };
34
+ }
35
+
36
+ // Quebra o miolo de um frontmatter em entradas top-level, preservando as linhas literais.
37
+ // Uma linha `^chave:` abre a entrada; o que vier indentado/em branco pertence a ela. Nada é
38
+ // reinterpretado — listas YAML aninhadas atravessam byte-a-byte, sem reserializar aspas,
39
+ // recuo ou ordem (o que geraria um diff gigante numa nota de 228 KB).
40
+ function parseEntries(block) {
41
+ const inner = block.replace(/^---\n/, '').replace(/\n---$/, '');
42
+ const entries = new Map();
43
+ let current = null;
44
+
45
+ for (const line of inner.split('\n')) {
46
+ const root = line.match(/^([A-Za-z0-9_-]+):/);
47
+ if (root) {
48
+ current = root[1];
49
+ entries.set(current, [line]);
50
+ continue;
51
+ }
52
+ if (current) entries.get(current).push(line);
53
+ }
54
+
55
+ // Linha em branco no fim de uma entrada é layout, não valor.
56
+ for (const lines of entries.values()) {
57
+ while (lines.length > 1 && lines[lines.length - 1].trim() === '') lines.pop();
58
+ }
59
+ return entries;
60
+ }
61
+
62
+ // Funde os blocos empilhados num só. Devolve `null` quando não há nada a fundir.
63
+ export function mergeStackedFrontmatter(content) {
64
+ const { blocks, body } = splitStackedFrontmatter(content);
65
+ if (blocks.length < 2) return null;
66
+
67
+ // A base é o bloco de BAIXO: é o original, o único com as chaves-base, e define a ordem
68
+ // das chaves. Depois aplica de baixo para cima, então o bloco do topo — a gravação mais
69
+ // recente — é o último a escrever e vence. Chave que só existe num bloco de cima entra no
70
+ // fim, nunca é descartada.
71
+ const merged = parseEntries(blocks[blocks.length - 1]);
72
+ for (let i = blocks.length - 2; i >= 0; i -= 1) {
73
+ for (const [key, lines] of parseEntries(blocks[i])) merged.set(key, lines);
74
+ }
75
+
76
+ return `---\n${[...merged.values()].flat().join('\n')}\n---\n\n${body}`;
77
+ }
78
+
79
+ // Um reparo que perde dado é pior que o dano que ele conserta: só grava o que passar aqui.
80
+ function validateMerge(original, merged) {
81
+ const after = splitStackedFrontmatter(merged);
82
+ if (after.blocks.length !== 1) return 'resultado não ficou com um bloco só';
83
+
84
+ const kept = new Set(parseEntries(after.blocks[0]).keys());
85
+ const before = splitStackedFrontmatter(original);
86
+ for (const block of before.blocks) {
87
+ for (const key of parseEntries(block).keys()) {
88
+ if (!kept.has(key)) return `chave perdida no merge: ${key}`;
89
+ }
90
+ }
91
+ if (before.body && !merged.endsWith(before.body)) return 'corpo da nota não sobreviveu ao merge';
92
+ return null;
93
+ }
94
+
95
+ // Varre as notas de sessão empilhadas e as funde. Dry-run por padrão.
96
+ export function repairStackedFrontmatter(vaultBase, { apply = false, lockTimeoutMs } = {}) {
97
+ const repaired = [];
98
+ const skipped = [];
99
+
100
+ for (const abs of checkStackedFrontmatter(vaultBase).notes) {
101
+ const rel = relative(vaultBase, abs).replaceAll('\\', '/');
102
+ let original;
103
+ try {
104
+ original = readFileSync(abs, 'utf-8');
105
+ } catch {
106
+ skipped.push({ file: rel, reason: 'leitura falhou' });
107
+ continue;
108
+ }
109
+
110
+ const merged = mergeStackedFrontmatter(original);
111
+ if (merged === null) continue; // detector e merge concordam: nada a fundir
112
+
113
+ const problem = validateMerge(original, merged);
114
+ if (problem) {
115
+ skipped.push({ file: rel, reason: problem });
116
+ continue;
117
+ }
118
+
119
+ const blocks = splitStackedFrontmatter(original).blocks.length;
120
+ if (!apply) {
121
+ repaired.push({ file: rel, blocks });
122
+ continue;
123
+ }
124
+
125
+ // Sob o mesmo lock dos hooks: reparar enquanto um subagente escreve seria repetir o bug.
126
+ const outcome = mutateSessionNote(abs, () => merged, lockTimeoutMs ? { timeoutMs: lockTimeoutMs } : {});
127
+ if (!outcome.written) {
128
+ skipped.push({ file: rel, reason: `gravação não ocorreu (${outcome.reason})` });
129
+ continue;
130
+ }
131
+ repaired.push({ file: rel, blocks });
132
+ }
133
+
134
+ return { applied: apply, repaired, skipped };
135
+ }
@@ -6,6 +6,7 @@ import { activeChange, parseTasks, backfillArtifactLinks } from './change-core.m
6
6
  import { relinkDerivedNotes } from './linked-notes.mjs';
7
7
  import { buildEffectiveRequirementPackage, checkSpecsState, evaluateVerdict, tasksHashOf, validateSpecImpact } from './spec-core.mjs';
8
8
  import { getLocale } from './locale.mjs';
9
+ import { priceForModel } from './token-usage.mjs';
9
10
  import { readControl } from './obsidian-common.mjs';
10
11
 
11
12
  export function checkHarness(vaultBase, projectRoot) {
@@ -122,11 +123,69 @@ export function checkStackedFrontmatter(vaultBase) {
122
123
  return { count: notes.length, notes };
123
124
  }
124
125
 
126
+ // Um modelo fora de `pricing.json` faz `priceForModel` devolver null e a parcela dele do custo
127
+ // virar zero — sem erro, sem aviso. Modelo novo (claude-opus-5, claude-mythos-5) cai nisso por
128
+ // default. A checagem é sobre o vault, não sobre o caminho de cálculo: o cálculo roda em hook a
129
+ // cada turno, onde avisar viraria ruído e lançar derrubaria a captura da sessão.
130
+ //
131
+ // Cada modelo citado na nota é consultado direto em `priceForModel` — NÃO se infere pelo
132
+ // sintoma "custo zerado". Numa sessão multi-modelo (`modelo: "claude-opus-4.8 + claude-opus-5"`)
133
+ // os modelos precificados mantêm o total acima de zero e escondem o que falta: no vault que
134
+ // motivou esta change, a nota fecha com $415 e a fatia do Opus 5 é a única zerada.
135
+ export function checkUnpricedModels(vaultBase) {
136
+ const counts = new Map();
137
+
138
+ const modelsOf = (frontmatter) => {
139
+ // `modelos:` é a lista canônica; `modelo:` é o rótulo agregado (junta com " + ").
140
+ const list = frontmatter.match(/^modelos:\n((?:\s+- .*\n?)+)/m);
141
+ if (list) return list[1].split('\n').map((l) => l.replace(/^\s*-\s*/, '')).filter(Boolean);
142
+ const label = (frontmatter.match(/^modelo:\s*(.+)$/m) || [])[1] || '';
143
+ return label.split('+');
144
+ };
145
+
146
+ const walk = (dir) => {
147
+ let entries = [];
148
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
149
+ for (const entry of entries) {
150
+ const abs = join(dir, entry.name);
151
+ if (entry.isDirectory()) { walk(abs); continue; }
152
+ if (!entry.name.endsWith('.md')) continue;
153
+ let content;
154
+ try { content = readFileSync(abs, 'utf-8'); } catch { continue; }
155
+ const fm = content.match(/^---\n([\s\S]*?)\n---/);
156
+ if (!fm) continue;
157
+ // Sessão sem uso registrado não é sintoma de nada — custo zero ali é correto.
158
+ if (!(Number((fm[1].match(/^tokens_total:\s*(.+)$/m) || [])[1]) > 0)) continue;
159
+ for (const raw of modelsOf(fm[1])) {
160
+ const model = raw.trim().replace(/^["']|["']$/g, '');
161
+ if (!model || model === 'unknown') continue;
162
+ if (priceForModel(model)) continue;
163
+ counts.set(model, (counts.get(model) || 0) + 1);
164
+ }
165
+ }
166
+ };
167
+
168
+ walk(join(vaultBase, '02-Sessões'));
169
+ return { models: [...counts].map(([model, notes]) => ({ model, notes })) };
170
+ }
171
+
172
+ export function renderUnpricedModelLines(unpriced) {
173
+ const lines = [`[preços] ${unpriced.models.length} modelo(s) sem preço na tabela`];
174
+ for (const { model, notes } of unpriced.models) {
175
+ lines.push(` ✗ ${model} (${notes} nota(s) com custo zerado)`);
176
+ }
177
+ if (unpriced.models.length) lines.push(' → adicione o modelo em hooks/pricing.json');
178
+ else lines.push(' tabela de preços completa ✓');
179
+ return lines;
180
+ }
181
+
125
182
  // Formatador puro pra que a saída do doctor seja testável sem process.exit.
126
183
  export function renderStackedFrontmatterLines(vaultBase, stacked) {
127
184
  const lines = [`[notas] ${stacked.count} sessão(ões) com frontmatter empilhado`];
128
185
  for (const abs of stacked.notes) lines.push(` ✗ ${relative(vaultBase, abs)}`);
129
- if (!stacked.count) lines.push(' frontmatter íntegro ✓');
186
+ // Como as demais checagens do doctor: nunca apontar um problema sem oferecer o conserto.
187
+ if (stacked.count) lines.push(' → wendkeep note repair-frontmatter --apply');
188
+ else lines.push(' frontmatter íntegro ✓');
130
189
  return lines;
131
190
  }
132
191
 
@@ -26,6 +26,13 @@
26
26
  "cachedInput": 0.5,
27
27
  "output": 25
28
28
  },
29
+ "claude-opus-5": {
30
+ "label": "Claude Opus 5 API",
31
+ "provider": "anthropic",
32
+ "input": 5,
33
+ "cachedInput": 0.5,
34
+ "output": 25
35
+ },
29
36
  "claude-sonnet-4.6": {
30
37
  "label": "Claude Sonnet 4.6 API",
31
38
  "provider": "anthropic",
@@ -53,6 +60,13 @@
53
60
  "input": 10,
54
61
  "cachedInput": 1,
55
62
  "output": 50
63
+ },
64
+ "claude-mythos-5": {
65
+ "label": "Claude Mythos 5 API",
66
+ "provider": "anthropic",
67
+ "input": 10,
68
+ "cachedInput": 1,
69
+ "output": 50
56
70
  }
57
71
  }
58
72
  }
@@ -117,6 +117,9 @@ const MODEL_ALIASES = {
117
117
  'claude-opus-4-8': 'claude-opus-4.8',
118
118
  'anthropic/claude-opus-4.8': 'claude-opus-4.8',
119
119
  'anthropic/claude-opus-4-8': 'claude-opus-4.8',
120
+ 'claude-opus-5': 'claude-opus-5',
121
+ 'claude-opus-5-0': 'claude-opus-5',
122
+ 'anthropic/claude-opus-5': 'claude-opus-5',
120
123
  'claude-sonnet-4.6': 'claude-sonnet-4.6',
121
124
  'claude-sonnet-4-6': 'claude-sonnet-4.6',
122
125
  'anthropic/claude-sonnet-4.6': 'claude-sonnet-4.6',
@@ -132,6 +135,8 @@ const MODEL_ALIASES = {
132
135
  'claude-fable-5': 'claude-fable-5',
133
136
  'claude-fable-5[1m]': 'claude-fable-5',
134
137
  'anthropic/claude-fable-5': 'claude-fable-5',
138
+ 'claude-mythos-5': 'claude-mythos-5',
139
+ 'anthropic/claude-mythos-5': 'claude-mythos-5',
135
140
  };
136
141
 
137
142
  const MANAGED_FRONTMATTER_KEYS = new Set([
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wendkeep",
3
- "version": "0.50.0",
3
+ "version": "0.52.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/doctor.mjs CHANGED
@@ -4,7 +4,7 @@ import { spawnSync } from 'node:child_process';
4
4
  import { existsSync } from 'node:fs';
5
5
  import { dirname, join, resolve } from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
- import { checkHarness, checkVaultLinks, checkSessionActivity, checkStackedFrontmatter, renderStackedFrontmatterLines } from '../hooks/harness-doctor.mjs';
7
+ import { checkHarness, checkVaultLinks, checkSessionActivity, checkStackedFrontmatter, renderStackedFrontmatterLines, checkUnpricedModels, renderUnpricedModelLines } from '../hooks/harness-doctor.mjs';
8
8
  import { checkSyncDefs } from './sync-defs.mjs';
9
9
  import { resolveProjectVault } from './project-vault.mjs';
10
10
 
@@ -73,6 +73,9 @@ export function runDoctor(argv) {
73
73
  const stacked = checkStackedFrontmatter(vaultBase);
74
74
  process.stdout.write(`\n${renderStackedFrontmatterLines(vaultBase, stacked).join('\n')}\n`);
75
75
 
76
+ // 3c. Modelo fora de pricing.json fecha a sessão com custo zero, sem erro — só aparece aqui.
77
+ process.stdout.write(`\n${renderUnpricedModelLines(checkUnpricedModels(vaultBase)).join('\n')}\n`);
78
+
76
79
  // 4. Sessão: não mente "inativa" quando há atividade recente (workflow/subagente em background).
77
80
  const act = checkSessionActivity(vaultBase);
78
81
  if (act.lastSession) {
package/src/note.mjs CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  } from '../hooks/obsidian-common.mjs';
16
16
  import { getLocale } from '../hooks/locale.mjs';
17
17
  import { buildManualBugNote, buildManualLearningNote, relinkDerivedNotes } from '../hooks/linked-notes.mjs';
18
+ import { repairStackedFrontmatter } from '../hooks/frontmatter-repair.mjs';
18
19
 
19
20
  const TYPES = {
20
21
  bug: { folderKey: 'bugs', prefix: 'BUG', build: buildManualBugNote },
@@ -45,8 +46,22 @@ export function runNote(argv) {
45
46
  process.exit(0);
46
47
  }
47
48
 
49
+ if (sub === 'repair-frontmatter') {
50
+ const vaultRaw = opt(rest, '--vault') || process.env.OBSIDIAN_VAULT_PATH;
51
+ if (!vaultRaw) { process.stderr.write('wendkeep note repair-frontmatter: no vault (--vault or OBSIDIAN_VAULT_PATH).\n'); process.exit(2); }
52
+ const vaultBase = isAbsolute(vaultRaw) ? vaultRaw : resolve(process.cwd(), vaultRaw);
53
+ if (!existsSync(vaultBase)) { process.stderr.write(`wendkeep note repair-frontmatter: vault not found: ${vaultBase}\n`); process.exit(2); }
54
+ const r = repairStackedFrontmatter(vaultBase, { apply: rest.includes('--apply') });
55
+ if (rest.includes('--json')) { process.stdout.write(`${JSON.stringify(r, null, 2)}\n`); process.exit(0); }
56
+ process.stdout.write(`${r.repaired.length} nota(s) com frontmatter empilhado${r.applied ? ' reparada(s)' : ' seriam reparada(s)'}\n`);
57
+ for (const n of r.repaired) process.stdout.write(` ${n.file} (${n.blocks} blocos -> 1)\n`);
58
+ for (const s of r.skipped) process.stdout.write(` pulado: ${s.file} (${s.reason})\n`);
59
+ if (!r.applied && r.repaired.length) process.stdout.write('\ndry-run — nada escrito. Rode com --apply para fundir os blocos.\n');
60
+ process.exit(0);
61
+ }
62
+
48
63
  if (sub !== 'new') {
49
- process.stderr.write('wendkeep note: subcomando desconhecido (use `note new --type bug|learning "<título>"` ou `note relink [--apply]`).\n');
64
+ process.stderr.write('wendkeep note: subcomando desconhecido (use `note new --type bug|learning "<título>"`, `note relink [--apply]` ou `note repair-frontmatter [--apply]`).\n');
50
65
  process.exit(2);
51
66
  }
52
67