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 +34 -0
- package/README.md +1 -1
- package/hooks/decision-capture.mjs +6 -4
- package/hooks/harness-doctor.mjs +43 -1
- 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 +5 -1
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
|
-
|
|
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,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
|
-
|
|
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/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 {
|
|
@@ -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
|
-
|
|
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
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
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.
|
|
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.
|
|
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) {
|