wendkeep 0.49.0 → 0.50.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,40 @@ 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.50.0] — 2026-07-23
8
+
9
+ ### Fixed
10
+
11
+ - **Nota de sessão não empilha mais frontmatter.** Uma nota real fechou com 4 blocos de
12
+ frontmatter no topo: o Obsidian só parseia o primeiro, então `type`/`date`/`provider`/
13
+ `status`/`tags`/`source` sumiram do painel de propriedades e os outros 3 blocos viraram
14
+ texto do corpo. Duas falhas somadas: (1) `upsertSessionFrontmatter` **prependava** um
15
+ frontmatter novo quando o regex não casava — numa nota existente isso nunca é "faltou
16
+ frontmatter", é conteúdo truncado; (2) os escritores da nota faziam read-modify-write com
17
+ `writeFileSync` cru, sem lock. Como `subagent-stop` dispara uma vez por subagent (a sessão
18
+ danificada teve 46), um hook lia o arquivo já truncado por outro e caía no prepend.
19
+ Agora todo hook que reescreve a nota (`token-usage`, `subagent-usage`,
20
+ `session-observability`, `session-stop`, `session-ensure`, `decision-capture`, `task-log`)
21
+ grava por `mutateSessionNote`: lock por `mkdir` + escrita atômica (`tmp` + `rename`), e
22
+ frontmatter ilegível **aborta** a gravação em vez de prependar. Um teste-guarda estrutural
23
+ impede que um escritor novo volte ao `writeFileSync` cru. Capability
24
+ `session-observability` (OBS-5, OBS-6).
25
+ - **Lock liberado em caminho acentuado.** `fs.rmSync(dir, { recursive: true, force: true })`
26
+ é um **no-op silencioso** no Windows (Node 24) quando o caminho contém caractere
27
+ não-ASCII — não remove e não lança (medido: 20/20 falhas em `02-Sessões`, `ação`,
28
+ `Mudanças`; 0/20 em ASCII). Como toda nota de sessão vive sob `02-Sessões/`, o lock ficava
29
+ preso e o segundo escritor desistia — perda silenciosa de turnos. A liberação passa a usar
30
+ `rmdirSync`. O mesmo defeito estava latente no lock do `SESSION_REGISTRY.json`, que
31
+ travaria após a primeira mutação num vault sob pasta acentuada.
32
+
33
+ ### Added
34
+
35
+ - **`wendkeep doctor` surfaça notas de sessão com frontmatter empilhado.** Nova seção
36
+ `[notas]`: conta e lista as notas danificadas pela escrita concorrente (versões
37
+ anteriores a esta); quando não há nenhuma, diz `frontmatter íntegro`. `---` no corpo
38
+ (regra horizontal, separador de tabela) não é falso positivo. Capability `vault-doctor`
39
+ (DIAG-5).
40
+
7
41
  ## [0.49.0] — 2026-07-23
8
42
 
9
43
  ### 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
 
@@ -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 };
@@ -1,7 +1,7 @@
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';
@@ -88,6 +88,48 @@ const unquoteControl = (v) => String(v ?? '').replace(/^"(.*)"$/, '$1').trim();
88
88
  // O control marca `inactive` quando a sessão-mãe encerra, mesmo com um workflow/subagente
89
89
  // ainda vivo em background. Se a nota da sessão foi escrita há pouco apesar do `inactive`,
90
90
  // sinaliza a atividade recente — o doctor deixa de dizer "inativa" quando não está.
91
+ // Conta blocos de frontmatter empilhados no TOPO da nota — a assinatura do prepend que a
92
+ // escrita concorrente sem lock produzia. `---` no corpo (regra horizontal, separador de
93
+ // tabela) não conta: só reabertura imediata após o fechamento do bloco anterior.
94
+ function stackedFrontmatterBlocks(content) {
95
+ let rest = content;
96
+ let blocks = 0;
97
+ while (/^---\n/.test(rest)) {
98
+ const close = rest.indexOf('\n---', 4);
99
+ if (close < 0) break;
100
+ blocks += 1;
101
+ rest = rest.slice(close + 4).trimStart();
102
+ }
103
+ return blocks;
104
+ }
105
+
106
+ export function checkStackedFrontmatter(vaultBase) {
107
+ const root = join(vaultBase, '02-Sessões');
108
+ const notes = [];
109
+ const walk = (dir) => {
110
+ let entries = [];
111
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
112
+ for (const entry of entries) {
113
+ const abs = join(dir, entry.name);
114
+ if (entry.isDirectory()) { walk(abs); continue; }
115
+ if (!entry.name.endsWith('.md')) continue;
116
+ try {
117
+ if (stackedFrontmatterBlocks(readFileSync(abs, 'utf-8')) > 1) notes.push(abs);
118
+ } catch { /* nota ilegível não é o dano que esta checagem descreve */ }
119
+ }
120
+ };
121
+ walk(root);
122
+ return { count: notes.length, notes };
123
+ }
124
+
125
+ // Formatador puro pra que a saída do doctor seja testável sem process.exit.
126
+ export function renderStackedFrontmatterLines(vaultBase, stacked) {
127
+ const lines = [`[notas] ${stacked.count} sessão(ões) com frontmatter empilhado`];
128
+ for (const abs of stacked.notes) lines.push(` ✗ ${relative(vaultBase, abs)}`);
129
+ if (!stacked.count) lines.push(' frontmatter íntegro ✓');
130
+ return lines;
131
+ }
132
+
91
133
  export function checkSessionActivity(vaultBase, { now = Date.now(), windowMs = 5 * 60000 } = {}) {
92
134
  const control = readControl(vaultBase);
93
135
  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
 
@@ -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 {
@@ -788,7 +789,11 @@ function buildUsageFrontmatter(agg, entries) {
788
789
  function upsertSessionFrontmatter(content, agg, entries) {
789
790
  const managedYaml = buildUsageFrontmatter(agg, entries);
790
791
  const match = content.match(/^---\n([\s\S]*?)\n---/);
791
- if (!match) return `---\n${managedYaml}\n---\n\n${content}`;
792
+ // Fail-closed: a nota de sessão SEMPRE nasce com frontmatter (session-start). Não casar
793
+ // aqui significa conteúdo truncado — tipicamente uma leitura que pegou o arquivo no meio
794
+ // da escrita de outro hook. Prependar um bloco novo transformava essa leitura ruim em
795
+ // dano permanente (notas com 4 frontmatters empilhados, vistas em produção).
796
+ if (!match) return null;
792
797
 
793
798
  const clean = stripManagedFrontmatter(match[1]);
794
799
  const nextFrontmatter = [clean, managedYaml].filter(Boolean).join('\n');
@@ -960,6 +965,7 @@ export function collectSessionUsage({ sessionContent, transcriptPath }) {
960
965
 
961
966
  const agg = aggregateEntries(entries);
962
967
  const withFrontmatter = upsertSessionFrontmatter(sessionContent, agg, entries);
968
+ if (withFrontmatter === null) return null; // conteúdo corrompido: nenhum escritor grava
963
969
  return {
964
970
  summary,
965
971
  aggregate: agg,
@@ -968,13 +974,15 @@ export function collectSessionUsage({ sessionContent, transcriptPath }) {
968
974
  };
969
975
  }
970
976
 
971
- export function updateSessionUsage({ vaultBase, sessionRel, sessionPath, transcriptPath }) {
977
+ export function updateSessionUsage({ vaultBase, sessionRel, sessionPath, transcriptPath, lockTimeoutMs }) {
972
978
  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;
979
+ let result = null;
980
+ const outcome = mutateSessionNote(sessionPath, (sessionContent) => {
981
+ result = collectSessionUsage({ sessionContent, transcriptPath });
982
+ if (!result) return null; // sem usage OU conteúdo corrompido: não grava
983
+ return upsertUsageSection(result.content, buildUsageSection(result.aggregate, result.entries, result.summary));
984
+ }, lockTimeoutMs ? { timeoutMs: lockTimeoutMs } : {});
985
+ return outcome.written || outcome.reason === 'unchanged' ? result : null;
978
986
  }
979
987
 
980
988
  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.50.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 } 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,10 @@ 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
+
72
76
  // 4. Sessão: não mente "inativa" quando há atividade recente (workflow/subagente em background).
73
77
  const act = checkSessionActivity(vaultBase);
74
78
  if (act.lastSession) {