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 +74 -0
- package/README.md +1 -1
- package/bin/wendkeep.mjs +4 -0
- package/hooks/decision-capture.mjs +6 -4
- package/hooks/frontmatter-repair.mjs +135 -0
- package/hooks/harness-doctor.mjs +102 -1
- package/hooks/obsidian-common.mjs +5 -2
- package/hooks/pricing.json +14 -0
- package/hooks/session-ensure.mjs +7 -8
- package/hooks/session-note-io.mjs +94 -0
- package/hooks/session-observability.mjs +29 -18
- package/hooks/session-stop.mjs +25 -27
- package/hooks/subagent-usage.mjs +37 -32
- package/hooks/task-log.mjs +3 -5
- package/hooks/token-usage.mjs +20 -7
- package/package.json +2 -2
- package/src/doctor.mjs +8 -1
- package/src/note.mjs +16 -1
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/hooks/harness-doctor.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
package/hooks/pricing.json
CHANGED
|
@@ -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
|
}
|
package/hooks/session-ensure.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { existsSync,
|
|
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
|
|
198
|
-
|
|
199
|
-
|
|
198
|
+
const outcome = mutateSessionNote(sessionPath, (content) => (
|
|
199
|
+
updateSessionDescription(content, { relPath: nextRelPath, summary, startedAt })
|
|
200
|
+
));
|
|
200
201
|
|
|
201
|
-
return { relPath: nextRelPath, summary, changed: nextRelPath !== relPath ||
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
}
|
package/hooks/session-stop.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { existsSync, readdirSync, readFileSync
|
|
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
|
-
|
|
772
|
-
//
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
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 já 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
|
-
|
|
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
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
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
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
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, {
|
package/hooks/subagent-usage.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
}
|
package/hooks/task-log.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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) {
|
package/hooks/token-usage.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|