wendkeep 0.49.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,80 @@ 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
+
47
+ ## [0.50.0] — 2026-07-23
48
+
49
+ ### Fixed
50
+
51
+ - **Nota de sessão não empilha mais frontmatter.** Uma nota real fechou com 4 blocos de
52
+ frontmatter no topo: o Obsidian só parseia o primeiro, então `type`/`date`/`provider`/
53
+ `status`/`tags`/`source` sumiram do painel de propriedades e os outros 3 blocos viraram
54
+ texto do corpo. Duas falhas somadas: (1) `upsertSessionFrontmatter` **prependava** um
55
+ frontmatter novo quando o regex não casava — numa nota existente isso nunca é "faltou
56
+ frontmatter", é conteúdo truncado; (2) os escritores da nota faziam read-modify-write com
57
+ `writeFileSync` cru, sem lock. Como `subagent-stop` dispara uma vez por subagent (a sessão
58
+ danificada teve 46), um hook lia o arquivo já truncado por outro e caía no prepend.
59
+ Agora todo hook que reescreve a nota (`token-usage`, `subagent-usage`,
60
+ `session-observability`, `session-stop`, `session-ensure`, `decision-capture`, `task-log`)
61
+ grava por `mutateSessionNote`: lock por `mkdir` + escrita atômica (`tmp` + `rename`), e
62
+ frontmatter ilegível **aborta** a gravação em vez de prependar. Um teste-guarda estrutural
63
+ impede que um escritor novo volte ao `writeFileSync` cru. Capability
64
+ `session-observability` (OBS-5, OBS-6).
65
+ - **Lock liberado em caminho acentuado.** `fs.rmSync(dir, { recursive: true, force: true })`
66
+ é um **no-op silencioso** no Windows (Node 24) quando o caminho contém caractere
67
+ não-ASCII — não remove e não lança (medido: 20/20 falhas em `02-Sessões`, `ação`,
68
+ `Mudanças`; 0/20 em ASCII). Como toda nota de sessão vive sob `02-Sessões/`, o lock ficava
69
+ preso e o segundo escritor desistia — perda silenciosa de turnos. A liberação passa a usar
70
+ `rmdirSync`. O mesmo defeito estava latente no lock do `SESSION_REGISTRY.json`, que
71
+ travaria após a primeira mutação num vault sob pasta acentuada.
72
+
73
+ ### Added
74
+
75
+ - **`wendkeep doctor` surfaça notas de sessão com frontmatter empilhado.** Nova seção
76
+ `[notas]`: conta e lista as notas danificadas pela escrita concorrente (versões
77
+ anteriores a esta); quando não há nenhuma, diz `frontmatter íntegro`. `---` no corpo
78
+ (regra horizontal, separador de tabela) não é falso positivo. Capability `vault-doctor`
79
+ (DIAG-5).
80
+
7
81
  ## [0.49.0] — 2026-07-23
8
82
 
9
83
  ### Added
package/README.md CHANGED
@@ -150,7 +150,7 @@ Restart Codex and Claude Code after reseeding their generated skills.
150
150
  | `wendkeep doctor [--vault P]` | Run a vault health check (integrity of sessions, registry, links). |
151
151
  | `wendkeep --version` / `--help` | Version / usage. |
152
152
 
153
- Session notes use one live `## Agentes, tokens e custos` snapshot. Main-agent and subagent hooks recompose it atomically, with costs, token dimensions, reasoning tokens and effort per model/source.
153
+ Session notes use one live `## Agentes, tokens e custos` snapshot. Main-agent and subagent hooks recompose it atomically, with costs, token dimensions, reasoning tokens and effort per model/source. Every hook that rewrites a session note takes a per-file lock and writes through a temp file + rename, so the `SubagentStop` fan-out (one hook run per subagent) can never leave a note half-written; a note whose frontmatter reads back damaged is left untouched rather than patched.
154
154
 
155
155
  ## Retroactive memory (`import`) — install today, remember yesterday
156
156
 
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
@@ -13,6 +13,7 @@ import {
13
13
  } from './obsidian-common.mjs';
14
14
  import { getLocale } from './locale.mjs';
15
15
  import { resolveSessionEntry } from './session-identity.mjs';
16
+ import { mutateSessionNote } from './session-note-io.mjs';
16
17
 
17
18
  // Decision notes follow the ADR naming convention: ADR-NNNN-<slug>, NNNN a 4-digit sequential
18
19
  // number assigned in the order decisions are made (getNextAdrNumber scans the whole 04-Decisões).
@@ -189,10 +190,11 @@ export function captureDecision(vaultBase, input) {
189
190
  if (sessionRel) {
190
191
  try {
191
192
  const sessionPath = join(vaultBase, sessionRel);
192
- let session = readFileSync(sessionPath, 'utf8');
193
193
  const wikilink = wikilinkFromRel(rel);
194
194
  const link = `- ${wikilink}`;
195
- if (!session.includes(wikilink)) {
195
+ mutateSessionNote(sessionPath, (original) => {
196
+ if (original.includes(wikilink)) return null;
197
+ let session = original;
196
198
  const heading = '\n## Decisões geradas nesta sessão\n';
197
199
  const at = session.indexOf(heading);
198
200
  if (at !== -1) {
@@ -209,8 +211,8 @@ export function captureDecision(vaultBase, input) {
209
211
  const section = `\n## Decisões geradas nesta sessão\n\n${link}\n`;
210
212
  session = anchor === -1 ? `${session.trimEnd()}${section}` : `${session.slice(0, anchor).trimEnd()}${section}${session.slice(anchor)}`;
211
213
  }
212
- writeFileSync(sessionPath, session, 'utf8');
213
- }
214
+ return session;
215
+ });
214
216
  } catch { /* backlink auxiliar nunca derruba a captura */ }
215
217
  }
216
218
  return { rel, skipped: false };
@@ -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
+ }
@@ -1,11 +1,12 @@
1
1
  // hooks/harness-doctor.mjs — integrity checks for the a2 harness state (Wave B).
2
2
  // Pure-ish (fs reads only). `wendkeep doctor` reports errors (exit 1) + warnings.
3
3
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
4
- import { join } from 'node:path';
4
+ import { join, relative } from 'node:path';
5
5
  import { activeChange, parseTasks, backfillArtifactLinks } from './change-core.mjs';
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) {
@@ -88,6 +89,106 @@ const unquoteControl = (v) => String(v ?? '').replace(/^"(.*)"$/, '$1').trim();
88
89
  // O control marca `inactive` quando a sessão-mãe encerra, mesmo com um workflow/subagente
89
90
  // ainda vivo em background. Se a nota da sessão foi escrita há pouco apesar do `inactive`,
90
91
  // sinaliza a atividade recente — o doctor deixa de dizer "inativa" quando não está.
92
+ // Conta blocos de frontmatter empilhados no TOPO da nota — a assinatura do prepend que a
93
+ // escrita concorrente sem lock produzia. `---` no corpo (regra horizontal, separador de
94
+ // tabela) não conta: só reabertura imediata após o fechamento do bloco anterior.
95
+ function stackedFrontmatterBlocks(content) {
96
+ let rest = content;
97
+ let blocks = 0;
98
+ while (/^---\n/.test(rest)) {
99
+ const close = rest.indexOf('\n---', 4);
100
+ if (close < 0) break;
101
+ blocks += 1;
102
+ rest = rest.slice(close + 4).trimStart();
103
+ }
104
+ return blocks;
105
+ }
106
+
107
+ export function checkStackedFrontmatter(vaultBase) {
108
+ const root = join(vaultBase, '02-Sessões');
109
+ const notes = [];
110
+ const walk = (dir) => {
111
+ let entries = [];
112
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
113
+ for (const entry of entries) {
114
+ const abs = join(dir, entry.name);
115
+ if (entry.isDirectory()) { walk(abs); continue; }
116
+ if (!entry.name.endsWith('.md')) continue;
117
+ try {
118
+ if (stackedFrontmatterBlocks(readFileSync(abs, 'utf-8')) > 1) notes.push(abs);
119
+ } catch { /* nota ilegível não é o dano que esta checagem descreve */ }
120
+ }
121
+ };
122
+ walk(root);
123
+ return { count: notes.length, notes };
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
+
182
+ // Formatador puro pra que a saída do doctor seja testável sem process.exit.
183
+ export function renderStackedFrontmatterLines(vaultBase, stacked) {
184
+ const lines = [`[notas] ${stacked.count} sessão(ões) com frontmatter empilhado`];
185
+ for (const abs of stacked.notes) lines.push(` ✗ ${relative(vaultBase, abs)}`);
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 ✓');
189
+ return lines;
190
+ }
191
+
91
192
  export function checkSessionActivity(vaultBase, { now = Date.now(), windowMs = 5 * 60000 } = {}) {
92
193
  const control = readControl(vaultBase);
93
194
  const active = unquoteControl(control.status) === 'active';
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'fs';
3
+ import { releaseLockDir } from './session-note-io.mjs';
3
4
  import { basename, dirname, join, relative } from 'path';
4
5
  import { getLocale } from './locale.mjs';
5
6
  import { resolveProjectVault } from '../src/project-vault.mjs';
@@ -323,7 +324,7 @@ export function mutateSessionRegistry(vaultBase, mutator, { timeoutMs = 2000 } =
323
324
  if (error?.code === 'EEXIST') {
324
325
  try {
325
326
  if (Date.now() - statSync(lock).mtimeMs > 10_000) {
326
- rmSync(lock, { recursive: true, force: true });
327
+ releaseLockDir(lock);
327
328
  continue;
328
329
  }
329
330
  } catch { /* outro processo pode ter liberado o lock */ }
@@ -342,7 +343,9 @@ export function mutateSessionRegistry(vaultBase, mutator, { timeoutMs = 2000 } =
342
343
  writeSessionRegistry(vaultBase, registry);
343
344
  return result;
344
345
  } finally {
345
- rmSync(lock, { recursive: true, force: true });
346
+ // rmSync recursivo não remove diretório em caminho não-ASCII no Windows — ver
347
+ // releaseLockDir. Vault sob pasta acentuada travaria o registry após a 1ª mutação.
348
+ releaseLockDir(lock);
346
349
  }
347
350
  }
348
351
 
@@ -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
  }
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { existsSync, readFileSync, renameSync, statSync, writeFileSync } from 'fs';
2
+ import { existsSync, renameSync, statSync, writeFileSync } from 'fs';
3
3
  import { basename, dirname, join } from 'path';
4
4
  import {
5
5
  controlPath,
@@ -31,6 +31,7 @@ import {
31
31
  yamlQuote,
32
32
  } from './obsidian-common.mjs';
33
33
  import { resolveSessionIdentity } from './session-identity.mjs';
34
+ import { mutateSessionNote } from './session-note-io.mjs';
34
35
 
35
36
  function sessionIdFromInput(input) {
36
37
  return input.session_id || input.sessionId || input.codex_session_id || '';
@@ -194,11 +195,11 @@ function maybeRetitleSession({ vaultBase, relPath, startedAt, input }) {
194
195
  }
195
196
 
196
197
  const sessionPath = join(vaultBase, nextRelPath);
197
- const content = readFileSync(sessionPath, 'utf-8');
198
- const updated = updateSessionDescription(content, { relPath: nextRelPath, summary, startedAt });
199
- if (updated !== content) writeFileSync(sessionPath, updated, 'utf-8');
198
+ const outcome = mutateSessionNote(sessionPath, (content) => (
199
+ updateSessionDescription(content, { relPath: nextRelPath, summary, startedAt })
200
+ ));
200
201
 
201
- return { relPath: nextRelPath, summary, changed: nextRelPath !== relPath || updated !== content };
202
+ return { relPath: nextRelPath, summary, changed: nextRelPath !== relPath || outcome.written };
202
203
  }
203
204
 
204
205
  function stripClosingSection(content) {
@@ -209,9 +210,7 @@ function stripClosingSection(content) {
209
210
  }
210
211
 
211
212
  function reopenSessionFile(sessionPath) {
212
- const content = readFileSync(sessionPath, 'utf-8');
213
- const reopened = stripClosingSection(updateSessionFrontmatter(content));
214
- writeFileSync(sessionPath, reopened, 'utf-8');
213
+ mutateSessionNote(sessionPath, (content) => stripClosingSection(updateSessionFrontmatter(content)));
215
214
  }
216
215
 
217
216
  function findSessionForInput(vaultBase, input, control) {
@@ -0,0 +1,94 @@
1
+ // Gravação da nota de sessão: atômica e serializada.
2
+ //
3
+ // O hook `subagent-stop` dispara uma vez por subagent, então vários processos fazem
4
+ // read-modify-write na MESMA nota ao mesmo tempo. Com `writeFileSync` cru, um leitor pode
5
+ // pegar o arquivo já truncado por outro escritor; quem lê um topo sem `---` acabava
6
+ // prependando um frontmatter novo, empilhando blocos na nota (visto em produção: 4 blocos).
7
+ //
8
+ // `obsidian-common.mjs` já resolvia isso para o SESSION_REGISTRY.json; aqui o mesmo par
9
+ // (tmp + rename, lock por mkdir) fica disponível para a nota de sessão.
10
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, rmdirSync, statSync, writeFileSync } from 'node:fs';
11
+
12
+ export const LOCK_BUSY = Symbol('wendkeep:lock-busy');
13
+
14
+ // ATENÇÃO: no Windows (Node 24), `rmSync(dir, { recursive: true, force: true })` é um NO-OP
15
+ // SILENCIOSO quando o caminho contém caractere não-ASCII — não remove e não lança. Medido:
16
+ // 20/20 falhas em `02-Sessões`, `ação`, `Mudanças`; 0/20 em caminho ASCII. Como TODA nota de
17
+ // sessão vive sob `02-Sessões/`, usar rmSync aqui deixaria o lock preso para sempre e o
18
+ // segundo escritor desistiria de gravar — perdendo turnos em silêncio.
19
+ // O lock é sempre um diretório vazio, então `rmdirSync` basta e funciona em qualquer caminho.
20
+ export function releaseLockDir(lock) {
21
+ try {
22
+ rmdirSync(lock);
23
+ } catch (error) {
24
+ if (error?.code === 'ENOENT') return;
25
+ try { rmSync(lock, { recursive: true, force: true }); } catch { /* lock preso: melhor seguir */ }
26
+ }
27
+ }
28
+
29
+ const FRONTMATTER = /^---\n[\s\S]*?\n---/;
30
+
31
+ export function hasSessionFrontmatter(content) {
32
+ return typeof content === 'string' && FRONTMATTER.test(content);
33
+ }
34
+
35
+ export function writeFileAtomic(path, content, encoding = 'utf-8') {
36
+ // rename é atômico no mesmo volume: ou o leitor vê o arquivo antigo inteiro, ou o novo.
37
+ const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
38
+ writeFileSync(tmp, content, encoding);
39
+ renameSync(tmp, path);
40
+ }
41
+
42
+ function waitBriefly(ms) {
43
+ const signal = new Int32Array(new SharedArrayBuffer(4));
44
+ Atomics.wait(signal, 0, 0, ms);
45
+ }
46
+
47
+ // Roda `fn` com o lock do arquivo tomado. Devolve LOCK_BUSY quando o lock não veio dentro
48
+ // do timeout — o chamador desiste da gravação em vez de gravar sem lock.
49
+ export function withPathLock(path, fn, { timeoutMs = 2000, staleMs = 10_000 } = {}) {
50
+ const lock = `${path}.lock`;
51
+ const deadline = Date.now() + timeoutMs;
52
+
53
+ while (true) {
54
+ try {
55
+ mkdirSync(lock);
56
+ break;
57
+ } catch (error) {
58
+ if (error?.code !== 'EEXIST') throw error;
59
+ try {
60
+ // Lock morto (processo caiu antes do finally) não pode travar a sessão inteira.
61
+ if (Date.now() - statSync(lock).mtimeMs > staleMs) releaseLockDir(lock);
62
+ } catch { /* outro processo pode ter liberado o lock no meio da checagem */ }
63
+ // O deadline é checado SEMPRE, inclusive depois de tentar remover um lock morto:
64
+ // `releaseLockDir` engole a falha, então um `continue` direto giraria para sempre.
65
+ if (Date.now() >= deadline) return LOCK_BUSY;
66
+ waitBriefly(10);
67
+ }
68
+ }
69
+
70
+ try {
71
+ return fn();
72
+ } finally {
73
+ releaseLockDir(lock);
74
+ }
75
+ }
76
+
77
+ // Lock -> read -> mutator -> escrita atômica.
78
+ // O mutator devolve o conteúdo novo, ou `null` para abortar sem gravar (o caminho
79
+ // fail-closed de quem leu uma nota corrompida).
80
+ export function mutateSessionNote(path, mutator, options = {}) {
81
+ if (!path || !existsSync(path)) return { written: false, reason: 'missing', content: null };
82
+
83
+ const outcome = withPathLock(path, () => {
84
+ const original = readFileSync(path, 'utf-8');
85
+ const next = mutator(original);
86
+ if (next === null || next === undefined) return { written: false, reason: 'aborted', content: original };
87
+ if (next === original) return { written: false, reason: 'unchanged', content: original };
88
+ writeFileAtomic(path, next);
89
+ return { written: true, reason: 'ok', content: next };
90
+ }, options);
91
+
92
+ if (outcome === LOCK_BUSY) return { written: false, reason: 'busy', content: null };
93
+ return outcome;
94
+ }
@@ -1,8 +1,9 @@
1
1
  // Single atomic writer for session usage, models, reasoning/effort and subagents.
2
- import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { existsSync } from 'node:fs';
3
3
  import { collectSessionUsage } from './token-usage.mjs';
4
4
  import { collectSubagentUsage, collectCodexSubagentUsage, sessionDirFromTranscript } from './subagent-usage.mjs';
5
5
  import { inspectTranscriptIdentity } from './session-identity.mjs';
6
+ import { hasSessionFrontmatter, mutateSessionNote } from './session-note-io.mjs';
6
7
 
7
8
  const HEADING = '## Agentes, tokens e custos';
8
9
  const LEGACY_HEADINGS = ['## Uso de tokens e custos', '## Subagents & Workflows'];
@@ -158,23 +159,33 @@ export function buildSessionObservability({ sessionContent, transcriptPath }) {
158
159
  return { snapshot, content: upsertObservabilitySection(content, renderSessionObservability(snapshot)) };
159
160
  }
160
161
 
161
- export function updateSessionObservability({ sessionPath, transcriptPath, caller = 'unknown', canonicalConversationId = '' }) {
162
+ export function updateSessionObservability({ sessionPath, transcriptPath, caller = 'unknown', canonicalConversationId = '', lockTimeoutMs }) {
162
163
  if (!sessionPath || !existsSync(sessionPath)) return null;
163
- const sessionContent = readFileSync(sessionPath, 'utf8');
164
- const noteProvider = sessionContent.match(/^provider:\s*"?([^"\n]+)"?/m)?.[1]?.trim() || 'unknown';
165
164
  const identity = inspectTranscriptIdentity(transcriptPath);
166
- if ((noteProvider === 'codex' && identity.transcriptProvider !== 'openai')
167
- || (noteProvider === 'claude' && identity.transcriptProvider !== 'anthropic')) {
168
- throw new Error(`observability provider mismatch: note=${noteProvider}, transcript=${identity.transcriptProvider}`);
169
- }
170
- let annotated = setFrontmatterField(sessionContent, 'observability_caller', `"${caller}"`);
171
- annotated = setFrontmatterField(annotated, 'observability_session_id', `"${canonicalConversationId || identity.canonicalConversationId || ''}"`);
172
- annotated = setFrontmatterField(annotated, 'observability_transcript_id', `"${identity.transcriptId || ''}"`);
173
- if (!/^observability_updated_at:/m.test(annotated)) {
174
- annotated = setFrontmatterField(annotated, 'observability_updated_at', `"${new Date().toISOString()}"`);
175
- }
176
- const result = buildSessionObservability({ sessionContent: annotated, transcriptPath });
177
- if (!result) return null;
178
- writeFileSync(sessionPath, result.content, 'utf8');
179
- return result.snapshot;
165
+ let snapshot = null;
166
+
167
+ // `subagent-stop` dispara uma vez por subagent: sem o lock, dois processos leem a mesma
168
+ // nota e o segundo grava por cima — ou pior, lê o arquivo já truncado pelo primeiro.
169
+ const outcome = mutateSessionNote(sessionPath, (sessionContent) => {
170
+ // Fail-closed: leitura sem frontmatter íntegro é conteúdo truncado, não nota nova.
171
+ if (!hasSessionFrontmatter(sessionContent)) return null;
172
+ const noteProvider = sessionContent.match(/^provider:\s*"?([^"\n]+)"?/m)?.[1]?.trim() || 'unknown';
173
+ if ((noteProvider === 'codex' && identity.transcriptProvider !== 'openai')
174
+ || (noteProvider === 'claude' && identity.transcriptProvider !== 'anthropic')) {
175
+ throw new Error(`observability provider mismatch: note=${noteProvider}, transcript=${identity.transcriptProvider}`);
176
+ }
177
+ let annotated = setFrontmatterField(sessionContent, 'observability_caller', `"${caller}"`);
178
+ annotated = setFrontmatterField(annotated, 'observability_session_id', `"${canonicalConversationId || identity.canonicalConversationId || ''}"`);
179
+ annotated = setFrontmatterField(annotated, 'observability_transcript_id', `"${identity.transcriptId || ''}"`);
180
+ if (!/^observability_updated_at:/m.test(annotated)) {
181
+ annotated = setFrontmatterField(annotated, 'observability_updated_at', `"${new Date().toISOString()}"`);
182
+ }
183
+ const result = buildSessionObservability({ sessionContent: annotated, transcriptPath });
184
+ if (!result) return null;
185
+ snapshot = result.snapshot;
186
+ return result.content;
187
+ }, lockTimeoutMs ? { timeoutMs: lockTimeoutMs } : {});
188
+
189
+ // 'unchanged' também é sucesso: a nota já estava em dia, o snapshot vale.
190
+ return outcome.written || outcome.reason === 'unchanged' ? snapshot : null;
180
191
  }
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { existsSync, readdirSync, readFileSync, writeFileSync } from 'fs';
2
+ import { existsSync, readdirSync, readFileSync } from 'fs';
3
3
  import { join } from 'path';
4
4
  import { request } from 'http';
5
5
  import { pathToFileURL } from 'url';
@@ -10,6 +10,7 @@ import { activeChangeLink, pruneChangeSentinels } from './change-core.mjs';
10
10
  import { getLocale } from './locale.mjs';
11
11
  import { updateSessionObservability } from './session-observability.mjs';
12
12
  import { resolveSessionEntry } from './session-identity.mjs';
13
+ import { mutateSessionNote } from './session-note-io.mjs';
13
14
  import {
14
15
  ensureDir,
15
16
  findActiveSessionByTranscript,
@@ -768,20 +769,21 @@ function relocateOrphanIterations(content) {
768
769
  }
769
770
 
770
771
  export function insertIteration(sessionPath, block, turnId, tx) {
771
- const original = readFileSync(sessionPath, 'utf-8');
772
- // Self-heal: migrate any legacy `codex-turn` markers to the neutral name on this write.
773
- let content = normalizeTurnMarkers(original);
774
- if (hasTurnMarker(content, turnId)) {
775
- // Turno registrado: ainda assim repara órfãos e seções dedicadas.
776
- const repaired = applyDedicatedSections(relocateOrphanIterations(content), tx);
777
- if (repaired !== original) writeFileSync(sessionPath, repaired, 'utf-8');
778
- return false;
779
- }
780
- content = relocateOrphanIterations(content);
781
- content = insertIntoIteracoes(content, block);
782
- content = applyDedicatedSections(content, tx);
783
- if (content !== original) writeFileSync(sessionPath, content, 'utf-8');
784
- return true;
772
+ let inserted = false;
773
+ // Sob lock: outro hook (subagent-stop) pode estar reescrevendo a mesma nota agora.
774
+ mutateSessionNote(sessionPath, (original) => {
775
+ // Self-heal: migrate any legacy `codex-turn` markers to the neutral name on this write.
776
+ let content = normalizeTurnMarkers(original);
777
+ if (hasTurnMarker(content, turnId)) {
778
+ // Turno registrado: ainda assim repara órfãos e seções dedicadas.
779
+ return applyDedicatedSections(relocateOrphanIterations(content), tx);
780
+ }
781
+ content = relocateOrphanIterations(content);
782
+ content = insertIntoIteracoes(content, block);
783
+ inserted = true;
784
+ return applyDedicatedSections(content, tx);
785
+ });
786
+ return inserted;
785
787
  }
786
788
 
787
789
  function shouldFinalizeSession() {
@@ -921,12 +923,10 @@ ${links(created.learnings)}
921
923
  ${formatPendingClosing(pending)}
922
924
  `;
923
925
 
924
- const content = readFileSync(sessionPath, 'utf-8');
925
- const finalized = replaceClosingSection(
926
+ mutateSessionNote(sessionPath, (content) => replaceClosingSection(
926
927
  replacePendingSection(updateFrontmatter(content, endedAt), pending),
927
928
  closing,
928
- );
929
- writeFileSync(sessionPath, finalized, 'utf-8');
929
+ ));
930
930
  }
931
931
 
932
932
  // --- Vínculo Sessão ↔ Issues Linear (03-Linear) -------------------------------
@@ -1007,10 +1007,9 @@ function applyLinearLinks(sessionPath, tx, vaultBase, sessionRel) {
1007
1007
  .sort((a, b) => a[0].localeCompare(b[0], undefined, { numeric: true }))
1008
1008
  .map(([id, rel]) => `- ${id} — ${wikilinkFromRel(rel)}`);
1009
1009
 
1010
- let content = readFileSync(sessionPath, 'utf-8');
1011
- content = ensureSection(content, 'Issues Linear', '\n## Encerramento');
1012
- content = upsertListSection(content, 'Issues Linear', lines, null);
1013
- writeFileSync(sessionPath, content, 'utf-8');
1010
+ mutateSessionNote(sessionPath, (original) => (
1011
+ upsertListSection(ensureSection(original, 'Issues Linear', '\n## Encerramento'), 'Issues Linear', lines, null)
1012
+ ));
1014
1013
  }
1015
1014
 
1016
1015
  // Triggers Obsidian Local REST API to re-index the vault after file writes.
@@ -1129,10 +1128,9 @@ function main() {
1129
1128
  : activeChangeLink(vaultBase);
1130
1129
  const wl = (chgLink.match(/\[\[[^\]]+\]\]/) || [])[0];
1131
1130
  if (wl) {
1132
- let cur = readFileSync(sessionPath, 'utf8');
1133
- cur = ensureSection(cur, 'Mudanças', '\n## Encerramento');
1134
- cur = upsertListSection(cur, 'Mudanças', [`- ${wl}`], null);
1135
- writeFileSync(sessionPath, cur, 'utf8');
1131
+ mutateSessionNote(sessionPath, (cur) => (
1132
+ upsertListSection(ensureSection(cur, 'Mudanças', '\n## Encerramento'), 'Mudanças', [`- ${wl}`], null)
1133
+ ));
1136
1134
  }
1137
1135
  } catch { /* nunca derruba o Stop */ }
1138
1136
  writeControl(vaultBase, {
@@ -2,7 +2,8 @@
2
2
  // only reads the MAIN transcript; a session that spawns subagents/workflows (e.g. a Workflow
3
3
  // run) burns tokens in sibling transcripts the note never recorded. This scans them.
4
4
  // Reuses token-usage.mjs's parser. Provider-gated by structure (Claude Code layout).
5
- import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
5
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
6
+ import { hasSessionFrontmatter, mutateSessionNote } from './session-note-io.mjs';
6
7
  import { basename, join } from 'node:path';
7
8
  import { parseTokenUsageFromTranscript, summarizeTokenUsage } from './token-usage.mjs';
8
9
 
@@ -339,39 +340,43 @@ function upsertSection(content, heading, body) {
339
340
  }
340
341
 
341
342
  // Stop-hook entry: scan the session's subagents/workflows, fold into the note. Fail-open.
342
- export function upsertSubagentUsage(sessionPath, transcriptPath) {
343
+ export function upsertSubagentUsage(sessionPath, transcriptPath, { lockTimeoutMs } = {}) {
343
344
  if (!sessionPath || !existsSync(sessionPath)) return false;
344
345
  const collected = collectSubagentUsage(sessionDirFromTranscript(transcriptPath));
345
346
  if (!collected) return false;
346
347
  const a = collected.aggregate;
347
- let content = readFileSync(sessionPath, 'utf8');
348
- content = setFrontmatterField(content, 'subagents_count', a.count);
349
- content = setFrontmatterField(content, 'subagents_tokens_total', a.tokens);
350
- content = setFrontmatterField(content, 'subagents_custo_usd', a.cost);
351
- content = setFrontmatterField(content, 'subagents_tools', `"${(a.tools || []).join(', ')}"`);
352
- content = setFrontmatterField(content, 'subagents_wasted_usd', a.wasted || 0);
353
- content = setFrontmatterField(content, 'tokens_total_incl_subagents', frontmatterNumber(content, 'tokens_total') + a.tokens);
354
- content = setFrontmatterField(content, 'custo_total_incl_subagents_usd', round4(frontmatterNumber(content, 'custo_modelo_usd') + a.cost));
355
- let mainRows = [];
356
- try {
357
- const main = summarizeTokenUsage(parseTokenUsageFromTranscript(transcriptPath));
358
- mainRows = (main.modelRows || []).map((r) => ({
359
- provider: r.provider || '?', model: r.model || '?', source: 'main', calls: r.calls || 0,
360
- tokens: tokensTotal(r.usage), cost: round4(r.costs?.model || 0),
361
- }));
362
- } catch { /* preserve legacy aggregate fallback */ }
363
- if (!mainRows.length) {
364
- const mainModel = (content.match(/^custo_modelo_label:\s*["']?([^"'\r\n]+)["']?\s*$/m) || [])[1] || '?';
365
- mainRows = [{ model: mainModel, source: 'main', cost: round4(frontmatterNumber(content, 'custo_modelo_usd')), tokens: frontmatterNumber(content, 'tokens_total') }];
366
- }
367
- const ledger = [...mainRows, ...(a.modelRows || [])];
368
- collected.combined = {
369
- tokens: frontmatterNumber(content, 'tokens_total') + a.tokens,
370
- cost: round4(frontmatterNumber(content, 'custo_modelo_usd') + a.cost),
371
- models: ledger,
372
- };
373
- content = setFrontmatterField(content, 'custo_por_modelo_json', `'${JSON.stringify(ledger).replaceAll("'", "''")}'`);
374
- content = upsertSection(content, '## Subagents & Workflows', renderSubagentSection(collected));
375
- writeFileSync(sessionPath, content, 'utf8');
376
- return true;
348
+ const outcome = mutateSessionNote(sessionPath, (original) => {
349
+ // Fail-closed: sem frontmatter íntegro, `setFrontmatterField` viraria no-op silencioso
350
+ // e a gravação só reescreveria conteúdo truncado por cima do original.
351
+ if (!hasSessionFrontmatter(original)) return null;
352
+ let content = original;
353
+ content = setFrontmatterField(content, 'subagents_count', a.count);
354
+ content = setFrontmatterField(content, 'subagents_tokens_total', a.tokens);
355
+ content = setFrontmatterField(content, 'subagents_custo_usd', a.cost);
356
+ content = setFrontmatterField(content, 'subagents_tools', `"${(a.tools || []).join(', ')}"`);
357
+ content = setFrontmatterField(content, 'subagents_wasted_usd', a.wasted || 0);
358
+ content = setFrontmatterField(content, 'tokens_total_incl_subagents', frontmatterNumber(content, 'tokens_total') + a.tokens);
359
+ content = setFrontmatterField(content, 'custo_total_incl_subagents_usd', round4(frontmatterNumber(content, 'custo_modelo_usd') + a.cost));
360
+ let mainRows = [];
361
+ try {
362
+ const main = summarizeTokenUsage(parseTokenUsageFromTranscript(transcriptPath));
363
+ mainRows = (main.modelRows || []).map((r) => ({
364
+ provider: r.provider || '?', model: r.model || '?', source: 'main', calls: r.calls || 0,
365
+ tokens: tokensTotal(r.usage), cost: round4(r.costs?.model || 0),
366
+ }));
367
+ } catch { /* preserve legacy aggregate fallback */ }
368
+ if (!mainRows.length) {
369
+ const mainModel = (content.match(/^custo_modelo_label:\s*["']?([^"'\r\n]+)["']?\s*$/m) || [])[1] || '?';
370
+ mainRows = [{ model: mainModel, source: 'main', cost: round4(frontmatterNumber(content, 'custo_modelo_usd')), tokens: frontmatterNumber(content, 'tokens_total') }];
371
+ }
372
+ const ledger = [...mainRows, ...(a.modelRows || [])];
373
+ collected.combined = {
374
+ tokens: frontmatterNumber(content, 'tokens_total') + a.tokens,
375
+ cost: round4(frontmatterNumber(content, 'custo_modelo_usd') + a.cost),
376
+ models: ledger,
377
+ };
378
+ content = setFrontmatterField(content, 'custo_por_modelo_json', `'${JSON.stringify(ledger).replaceAll("'", "''")}'`);
379
+ return upsertSection(content, '## Subagents & Workflows', renderSubagentSection(collected));
380
+ }, lockTimeoutMs ? { timeoutMs: lockTimeoutMs } : {});
381
+ return outcome.written;
377
382
  }
@@ -5,12 +5,13 @@
5
5
  // change's tarefas.md N.N (id-spaces differ, fuzzy) — it's a progress trail, not a task tracker.
6
6
  // The TaskCompleted payload shape isn't fully pinned, so the task text is pulled defensively from
7
7
  // any plausible field. Fail-open.
8
- import { existsSync, readFileSync, writeFileSync } from 'fs';
8
+ import { existsSync } from 'fs';
9
9
  import { join } from 'path';
10
10
  import { pathToFileURL } from 'url';
11
11
  import { readHookInput, writeHookOutput, getVaultBase, formatHourMinute, providerMeta } from './obsidian-common.mjs';
12
12
  import { getLocale } from './locale.mjs';
13
13
  import { resolveSessionEntry } from './session-identity.mjs';
14
+ import { mutateSessionNote } from './session-note-io.mjs';
14
15
 
15
16
  // Pull the task's human text from whatever field the payload carries.
16
17
  export function taskText(input) {
@@ -51,10 +52,7 @@ export function logTask(vaultBase, input) {
51
52
 
52
53
  const heading = getLocale(vaultBase).id === 'en' ? 'Plan progress' : 'Progresso do plano';
53
54
  const line = `- [x] ${formatHourMinute(new Date()).replace('-', ':')} ${text}`;
54
- const content = readFileSync(sessionPath, 'utf8');
55
- const next = appendProgress(content, line, heading);
56
- if (next !== content) { writeFileSync(sessionPath, next, 'utf8'); return true; }
57
- return false;
55
+ return mutateSessionNote(sessionPath, (content) => appendProgress(content, line, heading)).written;
58
56
  }
59
57
 
60
58
  if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { existsSync, readFileSync, writeFileSync } from 'fs';
3
+ import { mutateSessionNote } from './session-note-io.mjs';
3
4
  import { basename, dirname, join } from 'path';
4
5
  import { fileURLToPath } from 'url';
5
6
  import {
@@ -116,6 +117,9 @@ const MODEL_ALIASES = {
116
117
  'claude-opus-4-8': 'claude-opus-4.8',
117
118
  'anthropic/claude-opus-4.8': 'claude-opus-4.8',
118
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',
119
123
  'claude-sonnet-4.6': 'claude-sonnet-4.6',
120
124
  'claude-sonnet-4-6': 'claude-sonnet-4.6',
121
125
  'anthropic/claude-sonnet-4.6': 'claude-sonnet-4.6',
@@ -131,6 +135,8 @@ const MODEL_ALIASES = {
131
135
  'claude-fable-5': 'claude-fable-5',
132
136
  'claude-fable-5[1m]': 'claude-fable-5',
133
137
  'anthropic/claude-fable-5': 'claude-fable-5',
138
+ 'claude-mythos-5': 'claude-mythos-5',
139
+ 'anthropic/claude-mythos-5': 'claude-mythos-5',
134
140
  };
135
141
 
136
142
  const MANAGED_FRONTMATTER_KEYS = new Set([
@@ -788,7 +794,11 @@ function buildUsageFrontmatter(agg, entries) {
788
794
  function upsertSessionFrontmatter(content, agg, entries) {
789
795
  const managedYaml = buildUsageFrontmatter(agg, entries);
790
796
  const match = content.match(/^---\n([\s\S]*?)\n---/);
791
- if (!match) return `---\n${managedYaml}\n---\n\n${content}`;
797
+ // Fail-closed: a nota de sessão SEMPRE nasce com frontmatter (session-start). Não casar
798
+ // aqui significa conteúdo truncado — tipicamente uma leitura que pegou o arquivo no meio
799
+ // da escrita de outro hook. Prependar um bloco novo transformava essa leitura ruim em
800
+ // dano permanente (notas com 4 frontmatters empilhados, vistas em produção).
801
+ if (!match) return null;
792
802
 
793
803
  const clean = stripManagedFrontmatter(match[1]);
794
804
  const nextFrontmatter = [clean, managedYaml].filter(Boolean).join('\n');
@@ -960,6 +970,7 @@ export function collectSessionUsage({ sessionContent, transcriptPath }) {
960
970
 
961
971
  const agg = aggregateEntries(entries);
962
972
  const withFrontmatter = upsertSessionFrontmatter(sessionContent, agg, entries);
973
+ if (withFrontmatter === null) return null; // conteúdo corrompido: nenhum escritor grava
963
974
  return {
964
975
  summary,
965
976
  aggregate: agg,
@@ -968,13 +979,15 @@ export function collectSessionUsage({ sessionContent, transcriptPath }) {
968
979
  };
969
980
  }
970
981
 
971
- export function updateSessionUsage({ vaultBase, sessionRel, sessionPath, transcriptPath }) {
982
+ export function updateSessionUsage({ vaultBase, sessionRel, sessionPath, transcriptPath, lockTimeoutMs }) {
972
983
  if (!sessionPath || !existsSync(sessionPath)) return null;
973
- const result = collectSessionUsage({ sessionContent: readFileSync(sessionPath, 'utf-8'), transcriptPath });
974
- if (!result) return null;
975
- const withSection = upsertUsageSection(result.content, buildUsageSection(result.aggregate, result.entries, result.summary));
976
- writeFileSync(sessionPath, withSection, 'utf-8');
977
- return result;
984
+ let result = null;
985
+ const outcome = mutateSessionNote(sessionPath, (sessionContent) => {
986
+ result = collectSessionUsage({ sessionContent, transcriptPath });
987
+ if (!result) return null; // sem usage OU conteúdo corrompido: não grava
988
+ return upsertUsageSection(result.content, buildUsageSection(result.aggregate, result.entries, result.summary));
989
+ }, lockTimeoutMs ? { timeoutMs: lockTimeoutMs } : {});
990
+ return outcome.written || outcome.reason === 'unchanged' ? result : null;
978
991
  }
979
992
 
980
993
  function parseCliArgs(argv) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wendkeep",
3
- "version": "0.49.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": {
@@ -46,6 +46,6 @@
46
46
  "url": "https://github.com/rogersialves/wendkeep/issues"
47
47
  },
48
48
  "devDependencies": {
49
- "wendkeep": "^0.45.0"
49
+ "wendkeep": "^0.49.0"
50
50
  }
51
51
  }
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 } 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
 
@@ -69,6 +69,13 @@ export function runDoctor(argv) {
69
69
  if (links.graphColors === false) process.stdout.write(' → wendkeep theme sync (feche o Obsidian antes)\n');
70
70
  if (!links.derivedOrphans && !links.artifactOrphans && links.graphColors !== false) process.stdout.write(' grafo conectado ✓\n');
71
71
 
72
+ // 3b. Notas de sessão com frontmatter empilhado — dano de escrita concorrente (pré-lock).
73
+ const stacked = checkStackedFrontmatter(vaultBase);
74
+ process.stdout.write(`\n${renderStackedFrontmatterLines(vaultBase, stacked).join('\n')}\n`);
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
+
72
79
  // 4. Sessão: não mente "inativa" quando há atividade recente (workflow/subagente em background).
73
80
  const act = checkSessionActivity(vaultBase);
74
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