wendkeep 0.48.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,55 @@ 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
+
41
+ ## [0.49.0] — 2026-07-23
42
+
43
+ ### Added
44
+
45
+ - **`wendkeep doctor` agora surfaça os órfãos do grafo com o comando de reparo.** Nova seção
46
+ `[links]`: conta notas derivadas sem sessão-fonte (→ `note relink`), artefatos de change
47
+ sem backlink (→ `change backlink`) e o estado das cores do grafo (→ `theme sync`); quando
48
+ tudo conectado, diz `grafo conectado`. Reusa as funções de reparo em dry-run — zero lógica
49
+ nova de detecção. Antes o dono do vault só descobria os órfãos olhando o grafo no Obsidian
50
+ e não sabia qual comando rodava.
51
+ - **Sessão não mente "inativa" com atividade recente.** Nova seção `[sessão]`: quando o
52
+ control marca `inactive` mas a nota da sessão foi escrita há pouco, o doctor sinaliza
53
+ possível workflow/subagente em segundo plano — o control só reflete o lifecycle da
54
+ sessão-mãe, não a atividade de background. Capability `vault-doctor` (DIAG-1..4).
55
+
7
56
  ## [0.48.0] — 2026-07-23
8
57
 
9
58
  ### 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,10 +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
- import { existsSync, readFileSync, readdirSync } from 'node:fs';
4
- import { join } from 'node:path';
5
- import { activeChange, parseTasks } from './change-core.mjs';
3
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
4
+ import { join, relative } from 'node:path';
5
+ import { activeChange, parseTasks, backfillArtifactLinks } from './change-core.mjs';
6
+ import { relinkDerivedNotes } from './linked-notes.mjs';
6
7
  import { buildEffectiveRequirementPackage, checkSpecsState, evaluateVerdict, tasksHashOf, validateSpecImpact } from './spec-core.mjs';
7
8
  import { getLocale } from './locale.mjs';
9
+ import { readControl } from './obsidian-common.mjs';
8
10
 
9
11
  export function checkHarness(vaultBase, projectRoot) {
10
12
  const loc = getLocale(vaultBase);
@@ -63,3 +65,79 @@ export function checkHarness(vaultBase, projectRoot) {
63
65
 
64
66
  return { errors, warnings };
65
67
  }
68
+
69
+ // --- diagnóstico de links do grafo (read-only, reusa os reparos em dry-run) -----
70
+ // Surfaça os órfãos que o doctor não enxergava: notas derivadas sem sessão-fonte,
71
+ // artefatos de change sem backlink, e o estado das cores do grafo. Cada não-zero tem um
72
+ // comando de reparo (note relink / change backlink / theme sync).
73
+ export function checkVaultLinks(vaultBase) {
74
+ let derivedOrphans = 0;
75
+ try { derivedOrphans = relinkDerivedNotes(vaultBase, {}).linked.length; } catch { /* sem notas derivadas */ }
76
+ let artifactOrphans = 0;
77
+ try { artifactOrphans = backfillArtifactLinks(vaultBase, {}).changed.length; } catch { /* sem changes */ }
78
+ let graphColors = null; // true=com grupos · false=vazio/ausente de cores · null=sem graph.json
79
+ try {
80
+ const g = JSON.parse(readFileSync(join(vaultBase, '.obsidian', 'graph.json'), 'utf8'));
81
+ graphColors = Array.isArray(g.colorGroups) && g.colorGroups.length > 0;
82
+ } catch { graphColors = null; }
83
+ return { derivedOrphans, artifactOrphans, graphColors };
84
+ }
85
+
86
+ const unquoteControl = (v) => String(v ?? '').replace(/^"(.*)"$/, '$1').trim();
87
+
88
+ // O control marca `inactive` quando a sessão-mãe encerra, mesmo com um workflow/subagente
89
+ // ainda vivo em background. Se a nota da sessão foi escrita há pouco apesar do `inactive`,
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
+
133
+ export function checkSessionActivity(vaultBase, { now = Date.now(), windowMs = 5 * 60000 } = {}) {
134
+ const control = readControl(vaultBase);
135
+ const active = unquoteControl(control.status) === 'active';
136
+ const sessionRel = unquoteControl(active ? control.session_file : (control.last_session_file || control.session_file));
137
+ let ageMs = null;
138
+ if (sessionRel) {
139
+ try { ageMs = now - statSync(join(vaultBase, sessionRel)).mtimeMs; } catch { ageMs = null; }
140
+ }
141
+ const backgroundSuspected = !active && sessionRel !== '' && ageMs !== null && ageMs >= 0 && ageMs < windowMs;
142
+ return { lastSession: sessionRel, active, ageMs, backgroundSuspected };
143
+ }