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
|
@@ -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 } 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
|
|
|
@@ -60,5 +60,29 @@ export function runDoctor(argv) {
|
|
|
60
60
|
for (const e of errors) process.stdout.write(` ✗ ${e}\n`);
|
|
61
61
|
for (const w of warnings) process.stdout.write(` ! ${w}\n`);
|
|
62
62
|
|
|
63
|
+
// 3. Link/graph health — órfãos que o grafo do Obsidian mostraria, com o comando de reparo.
|
|
64
|
+
const links = checkVaultLinks(vaultBase);
|
|
65
|
+
const graphLabel = links.graphColors === true ? 'com cores' : links.graphColors === false ? 'sem cores' : 'sem graph.json';
|
|
66
|
+
process.stdout.write(`\n[links] ${links.derivedOrphans} derivada(s) órfã(s) · ${links.artifactOrphans} artefato(s) órfão(s) · grafo: ${graphLabel}\n`);
|
|
67
|
+
if (links.derivedOrphans) process.stdout.write(' → wendkeep note relink --apply\n');
|
|
68
|
+
if (links.artifactOrphans) process.stdout.write(' → wendkeep change backlink --apply\n');
|
|
69
|
+
if (links.graphColors === false) process.stdout.write(' → wendkeep theme sync (feche o Obsidian antes)\n');
|
|
70
|
+
if (!links.derivedOrphans && !links.artifactOrphans && links.graphColors !== false) process.stdout.write(' grafo conectado ✓\n');
|
|
71
|
+
|
|
72
|
+
// 3b. Notas de sessão com frontmatter empilhado — dano de escrita concorrente (pré-lock).
|
|
73
|
+
const stacked = checkStackedFrontmatter(vaultBase);
|
|
74
|
+
process.stdout.write(`\n${renderStackedFrontmatterLines(vaultBase, stacked).join('\n')}\n`);
|
|
75
|
+
|
|
76
|
+
// 4. Sessão: não mente "inativa" quando há atividade recente (workflow/subagente em background).
|
|
77
|
+
const act = checkSessionActivity(vaultBase);
|
|
78
|
+
if (act.lastSession) {
|
|
79
|
+
const label = act.active
|
|
80
|
+
? 'ativa'
|
|
81
|
+
: act.backgroundSuspected
|
|
82
|
+
? `inativa no control, mas escrita há ${Math.round((act.ageMs || 0) / 1000)}s — possível workflow/subagente em background`
|
|
83
|
+
: 'inativa';
|
|
84
|
+
process.stdout.write(`[sessão] última: ${act.lastSession} (${label})\n`);
|
|
85
|
+
}
|
|
86
|
+
|
|
63
87
|
process.exit(healthStatus !== 0 || errors.length ? 1 : 0);
|
|
64
88
|
}
|