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 +49 -0
- package/README.md +1 -1
- package/hooks/decision-capture.mjs +6 -4
- package/hooks/harness-doctor.mjs +81 -3
- package/hooks/linked-notes.mjs +763 -763
- package/hooks/obsidian-common.mjs +5 -2
- 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 +15 -7
- package/package.json +2 -2
- package/src/doctor.mjs +25 -1
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
|
-
|
|
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 };
|
package/hooks/harness-doctor.mjs
CHANGED
|
@@ -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
|
+
}
|