wendkeep 0.52.0 → 0.54.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 +59 -0
- package/bin/wendkeep.mjs +16 -2
- package/hooks/derived-sections.mjs +208 -0
- package/hooks/harness-doctor.mjs +29 -0
- package/hooks/session-stop.mjs +15 -1
- package/package.json +2 -2
- package/src/doctor.mjs +8 -3
- package/src/note.mjs +16 -1
- package/src/sync-defs.mjs +4 -3
- package/src/sync.mjs +76 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,65 @@ 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.54.0] — 2026-07-25
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **`wendkeep sync [--project P]`** — roda `init` → `sync-defs` → `doctor` no **projeto
|
|
12
|
+
corrente**, num comando só. São os três passos que se repetem idênticos depois de cada
|
|
13
|
+
atualização do pacote; repetir à mão convida a esquecer o `sync-defs`, e aí as skills
|
|
14
|
+
`wk-*` ficam na versão anterior enquanto o pacote já é o novo. Para no primeiro passo que
|
|
15
|
+
falha. O `npm install` fica **fora** de propósito: um processo não se auto-substitui e
|
|
16
|
+
continua rodando, e é o passo que mais varia entre projetos (npm/pnpm/workspace/cooldown).
|
|
17
|
+
O comando opera onde é invocado, nunca sobre uma lista de repositórios — o wendkeep é um
|
|
18
|
+
pacote público. Capability `cli-safety` (CLI-SYNC-1).
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- **`sync` blinda contra o *env bleed*.** O vault é re-resolvido com `resolveProjectVault`
|
|
23
|
+
**depois** do `init` (que pode tê-lo acabado de criar) e repassado explícito aos passos
|
|
24
|
+
seguintes. Sem isso, `sync-defs` cairia em `OBSIDIAN_VAULT_PATH` e, num projeto novo, o
|
|
25
|
+
env global da máquina levaria as skills para o vault de outro projeto. Capability
|
|
26
|
+
`project-vault-routing` (PVR-SYNC-1).
|
|
27
|
+
|
|
28
|
+
### Changed
|
|
29
|
+
|
|
30
|
+
- **`runSyncDefs` e `runDoctor` devolvem o código de saída** em vez de chamar
|
|
31
|
+
`process.exit`; quem sai é o `bin`. Ambos saíam incondicionalmente, o que matava o
|
|
32
|
+
processo no segundo passo de qualquer encadeamento. Os códigos de saída observáveis de
|
|
33
|
+
`wendkeep doctor` e `wendkeep sync-defs` não mudaram. Capability `cli-safety` (CLI-SYNC-2).
|
|
34
|
+
|
|
35
|
+
## [0.53.0] — 2026-07-25
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
|
|
39
|
+
- **As seções derivadas do corpo refletem a sessão inteira.** O `## Encerramento` listava 15
|
|
40
|
+
decisões e 5 aprendizados enquanto o corpo da nota mostrava 3 decisões e *"Nenhum
|
|
41
|
+
aprendizado registrado ainda."* — medido num vault real. A causa é ausência de escrita:
|
|
42
|
+
os placeholders nascem no template e, depois disso, só `decision-capture` escrevia — e
|
|
43
|
+
apenas ADRs capturados via `AskUserQuestion`. As seções de **Bugs e Aprendizados nunca
|
|
44
|
+
tiveram writer nenhum**. O dado certo já estava no lugar certo e era descartado:
|
|
45
|
+
`finalizeSessionFile` recebe a lista mesclada (capturados + varredura) e usava só para o
|
|
46
|
+
Encerramento; agora as três seções saem do mesmo objeto, na mesma passada.
|
|
47
|
+
- **A proveniência declarada manda na atribuição.** `noteReferencesSession` casava substring
|
|
48
|
+
no arquivo inteiro, então uma derivada que citava outra sessão em `related:` era atribuída
|
|
49
|
+
às duas — caso real: um BUG com `session:` de uma sessão e `source:` de outra ia para
|
|
50
|
+
ambas. Agora `source:` decide quando existe; sem ele (nota legada) qualquer referência
|
|
51
|
+
ainda vale, preservando DRV-5. Capability `derived-notes` (DRV-10, DRV-12).
|
|
52
|
+
|
|
53
|
+
### Added
|
|
54
|
+
|
|
55
|
+
- **`wendkeep note repair-sections [--apply] [--json]`** — reconstrói as seções derivadas
|
|
56
|
+
das notas já fechadas, que não se corrigem sozinhas (o fecho só roda em sessão que ainda
|
|
57
|
+
fecha). Lê as derivadas uma vez e indexa por sessão (O(N+M), não O(N·M)); dry-run por
|
|
58
|
+
padrão; grava sob o mesmo lock dos hooks. Descarta o placeholder em qualquer variante,
|
|
59
|
+
**preserva prosa** escrita na seção, e é no-op em nota que não tem os headings.
|
|
60
|
+
Capability `derived-notes` (DRV-11).
|
|
61
|
+
- **`wendkeep doctor` surfaça seções derivadas desatualizadas.** Nova seção `[derivadas]`
|
|
62
|
+
com as notas afetadas, quantos links faltam e o comando de reparo. Só falta conta como
|
|
63
|
+
sintoma — link a mais pode ser curadoria do dono do vault. Capability `vault-doctor`
|
|
64
|
+
(DIAG-6).
|
|
65
|
+
|
|
7
66
|
## [0.52.0] — 2026-07-25
|
|
8
67
|
|
|
9
68
|
### Fixed
|
package/bin/wendkeep.mjs
CHANGED
|
@@ -42,6 +42,12 @@ Usage:
|
|
|
42
42
|
wendkeep hook <name> Run a session hook (used by settings.json). Reads the
|
|
43
43
|
agent's JSON on stdin. Names: ${RUNNABLE_HOOKS.join(', ')}.
|
|
44
44
|
|
|
45
|
+
wendkeep sync [--project P] Run init -> sync-defs -> doctor on the CURRENT project, in one
|
|
46
|
+
command — the three steps that repeat identically after every
|
|
47
|
+
package update. Stops at the first failing step. Install the
|
|
48
|
+
package first (npm i -D wendkeep@latest); a running process
|
|
49
|
+
cannot replace itself. · --vault P · --yes.
|
|
50
|
+
|
|
45
51
|
wendkeep doctor [--vault P] Run a vault health check.
|
|
46
52
|
wendkeep change <sub> Change lifecycle: new [--simple] | use | bind <slug> --session <id> | continue | list | show |
|
|
47
53
|
status | done <id> | undone <id> | diff | archive [--force] | abandon | relink | backlink.
|
|
@@ -84,6 +90,9 @@ Usage:
|
|
|
84
90
|
(damage from pre-lock concurrent writes) into a single block: base keys
|
|
85
91
|
from the original block, values from the newest. Dry-run by default;
|
|
86
92
|
--apply writes under the same lock as the hooks · --json.
|
|
93
|
+
wendkeep note repair-sections [--apply] Rebuild the derived sections (decisions/bugs/learnings)
|
|
94
|
+
in session notes from the linked derived notes — the body used to lag
|
|
95
|
+
behind the closing block. Dry-run by default · --apply · --json.
|
|
87
96
|
wendkeep lesson add "t" "l" Record a project-local lesson (injected at SessionStart).
|
|
88
97
|
wendkeep validate-memory [path] Validate .brain/CORE.md against the compaction
|
|
89
98
|
protocol (cap 25, 3 sections, no secrets/PII). Uses
|
|
@@ -162,7 +171,12 @@ async function main() {
|
|
|
162
171
|
break;
|
|
163
172
|
case 'doctor': {
|
|
164
173
|
const { runDoctor } = await import('../src/doctor.mjs');
|
|
165
|
-
runDoctor(rest);
|
|
174
|
+
process.exit(runDoctor(rest));
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
case 'sync': {
|
|
178
|
+
const { runSync } = await import('../src/sync.mjs');
|
|
179
|
+
process.exit(await runSync(rest));
|
|
166
180
|
break;
|
|
167
181
|
}
|
|
168
182
|
case 'validate-memory': {
|
|
@@ -172,7 +186,7 @@ async function main() {
|
|
|
172
186
|
}
|
|
173
187
|
case 'sync-defs': {
|
|
174
188
|
const { runSyncDefs } = await import('../src/sync-defs.mjs');
|
|
175
|
-
runSyncDefs(rest);
|
|
189
|
+
process.exit(runSyncDefs(rest));
|
|
176
190
|
break;
|
|
177
191
|
}
|
|
178
192
|
case 'change': {
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// As três seções derivadas do corpo da nota de sessão: Decisões, Bugs e Aprendizados.
|
|
2
|
+
//
|
|
3
|
+
// Até a 0.52.0 ninguém escrevia nas seções de bug e aprendizado, e a de decisões só recebia
|
|
4
|
+
// os ADRs capturados via AskUserQuestion — um ADR gerado por `change archive` aparecia no
|
|
5
|
+
// Encerramento e nunca no corpo. Num vault real: 15 decisões e 5 aprendizados no
|
|
6
|
+
// Encerramento contra 3 decisões e um placeholder no corpo.
|
|
7
|
+
//
|
|
8
|
+
// O fecho (finalizeSessionFile) e o reparo retroativo (repairDerivedSections) usam as MESMAS
|
|
9
|
+
// funções daqui. Foi a lição do note repair-frontmatter: quando reparo e comportamento
|
|
10
|
+
// corrente divergem, o doctor passa a acusar o que o reparo não conserta.
|
|
11
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
12
|
+
import { join, relative } from 'node:path';
|
|
13
|
+
import { getLocale } from './locale.mjs';
|
|
14
|
+
import { mutateSessionNote } from './session-note-io.mjs';
|
|
15
|
+
import { toVaultRelative, wikilinkFromRel } from './obsidian-common.mjs';
|
|
16
|
+
|
|
17
|
+
export const DERIVED_SECTIONS = [
|
|
18
|
+
{ key: 'decisions', heading: 'Decisões geradas nesta sessão', folderKey: 'decisions' },
|
|
19
|
+
{ key: 'bugs', heading: 'Bugs gerados nesta sessão', folderKey: 'bugs' },
|
|
20
|
+
{ key: 'learnings', heading: 'Aprendizados gerados nesta sessão', folderKey: 'learnings' },
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
// Ordena pelo caminho: agrupa por tipo e deixa a numeração (ADR-0003, ADR-0005, …) em ordem.
|
|
24
|
+
// O Encerramento herda a ordem de varredura do filesystem, que não é garantida.
|
|
25
|
+
export function derivedSectionLines(items) {
|
|
26
|
+
return [...new Set(items || [])].sort().map((rel) => `- ${wikilinkFromRel(rel)}`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Aplica as três seções ao conteúdo. Fecho e reparo passam por aqui — se cada um tivesse
|
|
30
|
+
// seu upsert, voltariam a divergir (a lição do note repair-frontmatter).
|
|
31
|
+
export function applyDerivedSections(content, created) {
|
|
32
|
+
let next = content;
|
|
33
|
+
for (const { key, heading } of DERIVED_SECTIONS) {
|
|
34
|
+
// Lista vazia devolve o conteúdo intocado, então a seção sem itens mantém o
|
|
35
|
+
// placeholder — seção vazia é informação ("não gerou aprendizado"), não defeito.
|
|
36
|
+
next = defaultUpsert(next, heading, derivedSectionLines(created?.[key]));
|
|
37
|
+
}
|
|
38
|
+
return next;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const SESSION_LINK = /\[\[((?:02-Sess|02-Session)[^\]|]+)/g;
|
|
42
|
+
|
|
43
|
+
// A sessão que GEROU a nota, não toda sessão que a nota cita.
|
|
44
|
+
//
|
|
45
|
+
// `findLinkedDerivedNotes` casa por substring no arquivo inteiro, então uma derivada que
|
|
46
|
+
// menciona outra sessão em `related:` ou na prosa é atribuída às duas. Caso real neste
|
|
47
|
+
// vault: BUG-0001 declara `session:` de uma sessão e `source:`/`related:` de outra, e a
|
|
48
|
+
// varredura devolve a nota para ambas. No Encerramento o erro é regenerado a cada reopen;
|
|
49
|
+
// escrito no corpo ele fica. Por isso a proveniência declarada manda quando existe.
|
|
50
|
+
export function provenanceSessions(content) {
|
|
51
|
+
const fm = content.match(/^---\n([\s\S]*?)\n---/);
|
|
52
|
+
const scope = fm ? fm[1] : content;
|
|
53
|
+
// `source:` é o campo de proveniência canônico do vault — é o que `note relink` faz
|
|
54
|
+
// backfill (DRV-9). Só quando ele falta (nota legada) caímos para qualquer referência.
|
|
55
|
+
const source = scope.match(/^source:\n((?:\s+- .*\n?)+)/m) || scope.match(/^source:\s*(.+)$/m);
|
|
56
|
+
const hay = source ? source[1] : content;
|
|
57
|
+
const found = new Set();
|
|
58
|
+
for (const m of hay.matchAll(SESSION_LINK)) found.add(m[1].trim());
|
|
59
|
+
return [...found];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Lê as notas derivadas UMA vez e indexa por sessão referenciada. O fecho pode varrer por
|
|
63
|
+
// sessão (uma só, custo irrelevante); o reparo percorre N sessões e pagaria O(N*M) leituras.
|
|
64
|
+
export function indexDerivedBySession(vaultBase) {
|
|
65
|
+
const folders = getLocale(vaultBase).folders;
|
|
66
|
+
const index = new Map(); // sessão (rel, sem .md) -> { decisions:[], bugs:[], learnings:[] }
|
|
67
|
+
|
|
68
|
+
const record = (sessionKey, key, rel) => {
|
|
69
|
+
if (!index.has(sessionKey)) index.set(sessionKey, { decisions: [], bugs: [], learnings: [] });
|
|
70
|
+
index.get(sessionKey)[key].push(rel);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
for (const { key, folderKey } of DERIVED_SECTIONS) {
|
|
74
|
+
const walk = (dir) => {
|
|
75
|
+
let entries = [];
|
|
76
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
77
|
+
for (const entry of entries) {
|
|
78
|
+
const abs = join(dir, entry.name);
|
|
79
|
+
if (entry.isDirectory()) { walk(abs); continue; }
|
|
80
|
+
if (!entry.name.endsWith('.md')) continue;
|
|
81
|
+
let content;
|
|
82
|
+
try { content = readFileSync(abs, 'utf-8'); } catch { continue; }
|
|
83
|
+
const rel = toVaultRelative(vaultBase, abs);
|
|
84
|
+
for (const target of provenanceSessions(content)) record(target, key, rel);
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
walk(join(vaultBase, folders[folderKey]));
|
|
88
|
+
}
|
|
89
|
+
return index;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const SESSIONS_DIR_CANDIDATES = ['02-Sessões', '02-Sessions'];
|
|
93
|
+
|
|
94
|
+
export function listSessionNotes(vaultBase) {
|
|
95
|
+
const out = [];
|
|
96
|
+
for (const dirName of SESSIONS_DIR_CANDIDATES) {
|
|
97
|
+
const walk = (dir) => {
|
|
98
|
+
let entries = [];
|
|
99
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
100
|
+
for (const entry of entries) {
|
|
101
|
+
const abs = join(dir, entry.name);
|
|
102
|
+
if (entry.isDirectory()) { walk(abs); continue; }
|
|
103
|
+
if (entry.name.endsWith('.md')) out.push(abs);
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
walk(join(vaultBase, dirName));
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const sectionBody = (content, heading) => {
|
|
112
|
+
const i = content.indexOf(`## ${heading}`);
|
|
113
|
+
if (i < 0) return null;
|
|
114
|
+
const j = content.indexOf('\n## ', i + 3);
|
|
115
|
+
return content.slice(i, j < 0 ? content.length : j);
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
// O que a nota DEVERIA listar mas não lista. Excedente (link posto à mão) não é sintoma
|
|
119
|
+
// deste bug e não é reportado — pode ser curadoria do dono do vault.
|
|
120
|
+
export function missingDerivedLinks(content, entry) {
|
|
121
|
+
const missing = { decisions: [], bugs: [], learnings: [] };
|
|
122
|
+
for (const { key, heading } of DERIVED_SECTIONS) {
|
|
123
|
+
const body = sectionBody(content, heading);
|
|
124
|
+
if (body === null) continue;
|
|
125
|
+
for (const rel of entry?.[key] || []) {
|
|
126
|
+
const link = wikilinkFromRel(rel);
|
|
127
|
+
if (!body.includes(link) && !body.includes(rel)) missing[key].push(rel);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return missing;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export const countMissing = (missing) =>
|
|
134
|
+
DERIVED_SECTIONS.reduce((n, { key }) => n + (missing[key]?.length || 0), 0);
|
|
135
|
+
|
|
136
|
+
// Junta o que a seção já lista com o que o índice conhece, para que o reparo seja aditivo:
|
|
137
|
+
// um link posto à mão sobrevive ao conserto.
|
|
138
|
+
function mergedForNote(entry) {
|
|
139
|
+
return {
|
|
140
|
+
decisions: entry?.decisions || [],
|
|
141
|
+
bugs: entry?.bugs || [],
|
|
142
|
+
learnings: entry?.learnings || [],
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function repairDerivedSections(vaultBase, { apply = false, lockTimeoutMs } = {}) {
|
|
147
|
+
const index = indexDerivedBySession(vaultBase);
|
|
148
|
+
const repaired = [];
|
|
149
|
+
const skipped = [];
|
|
150
|
+
|
|
151
|
+
for (const abs of listSessionNotes(vaultBase)) {
|
|
152
|
+
const rel = relative(vaultBase, abs).replaceAll('\\', '/');
|
|
153
|
+
const entry = index.get(rel.replace(/\.md$/, '')) || index.get(rel);
|
|
154
|
+
if (!entry) continue;
|
|
155
|
+
|
|
156
|
+
let content;
|
|
157
|
+
try { content = readFileSync(abs, 'utf-8'); } catch { skipped.push({ file: rel, reason: 'leitura falhou' }); continue; }
|
|
158
|
+
|
|
159
|
+
const missing = countMissing(missingDerivedLinks(content, entry));
|
|
160
|
+
if (!missing) continue;
|
|
161
|
+
|
|
162
|
+
if (!apply) { repaired.push({ file: rel, missing }); continue; }
|
|
163
|
+
|
|
164
|
+
const outcome = mutateSessionNote(
|
|
165
|
+
abs,
|
|
166
|
+
(original) => applyDerivedSections(original, mergedForNote(entry)),
|
|
167
|
+
lockTimeoutMs ? { timeoutMs: lockTimeoutMs } : {},
|
|
168
|
+
);
|
|
169
|
+
if (!outcome.written) { skipped.push({ file: rel, reason: `gravação não ocorreu (${outcome.reason})` }); continue; }
|
|
170
|
+
repaired.push({ file: rel, missing });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return { applied: apply, repaired, skipped };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// O placeholder do template ("Nenhuma decisão registrada ainda.") não começa com `- `, então
|
|
177
|
+
// o filtro de lista já o descarta. Mas a variante COM bullet passaria no filtro e viveria
|
|
178
|
+
// para sempre junto dos links reais — é o mesmo caso que `shouldDropFileListLine` já cobre
|
|
179
|
+
// defensivamente nas seções de arquivos.
|
|
180
|
+
export const isDerivedPlaceholder = (line) => /^-?\s*Nenhum[ao]?\s+\S+.*registrad[ao]s?\s+ainda\.?$/i.test(line.trim());
|
|
181
|
+
|
|
182
|
+
// Upsert próprio (o reparo não pode depender do hook de Stop, que carrega transcript, locale
|
|
183
|
+
// e o mundo). Duas diferenças deliberadas em relação ao upsertListSection do session-stop:
|
|
184
|
+
// descarta o placeholder em qualquer variante, e PRESERVA prosa — uma nota que o dono do
|
|
185
|
+
// vault escreveu na seção não pode sumir num conserto automático.
|
|
186
|
+
function defaultUpsert(content, heading, lines) {
|
|
187
|
+
if (!lines.length) return content;
|
|
188
|
+
const marker = `\n## ${heading}\n`;
|
|
189
|
+
const start = content.indexOf(marker);
|
|
190
|
+
if (start === -1) return content; // heading ausente: nunca inventa seção em nota alheia
|
|
191
|
+
const bodyStart = start + marker.length;
|
|
192
|
+
const nextRel = content.slice(bodyStart).search(/\n## /);
|
|
193
|
+
const bodyEnd = nextRel === -1 ? content.length : bodyStart + nextRel;
|
|
194
|
+
|
|
195
|
+
const prose = [];
|
|
196
|
+
const items = [];
|
|
197
|
+
const add = (line) => { if (!items.includes(line)) items.push(line); };
|
|
198
|
+
for (const raw of content.slice(bodyStart, bodyEnd).split('\n')) {
|
|
199
|
+
const line = raw.trimEnd();
|
|
200
|
+
if (!line.trim() || isDerivedPlaceholder(line)) continue;
|
|
201
|
+
if (line.startsWith('- ')) add(line);
|
|
202
|
+
else prose.push(line); // comentário do agente/dono do vault: sobrevive ao conserto
|
|
203
|
+
}
|
|
204
|
+
for (const line of lines) add(line);
|
|
205
|
+
|
|
206
|
+
const body = [...prose, ...(prose.length ? [''] : []), ...items].join('\n');
|
|
207
|
+
return `${content.slice(0, bodyStart)}\n${body}\n\n${content.slice(bodyEnd).replace(/^\n+/, '')}`;
|
|
208
|
+
}
|
package/hooks/harness-doctor.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import { relinkDerivedNotes } from './linked-notes.mjs';
|
|
|
7
7
|
import { buildEffectiveRequirementPackage, checkSpecsState, evaluateVerdict, tasksHashOf, validateSpecImpact } from './spec-core.mjs';
|
|
8
8
|
import { getLocale } from './locale.mjs';
|
|
9
9
|
import { priceForModel } from './token-usage.mjs';
|
|
10
|
+
import { countMissing, indexDerivedBySession, listSessionNotes, missingDerivedLinks } from './derived-sections.mjs';
|
|
10
11
|
import { readControl } from './obsidian-common.mjs';
|
|
11
12
|
|
|
12
13
|
export function checkHarness(vaultBase, projectRoot) {
|
|
@@ -179,6 +180,34 @@ export function renderUnpricedModelLines(unpriced) {
|
|
|
179
180
|
return lines;
|
|
180
181
|
}
|
|
181
182
|
|
|
183
|
+
// As seções derivadas do corpo da nota de sessão ficavam para trás do Encerramento: num
|
|
184
|
+
// vault real, 15 decisões e 5 aprendizados listados no fecho contra 3 e um placeholder no
|
|
185
|
+
// corpo. Notas fechadas antes da correção não se consertam sozinhas — o doctor as aponta.
|
|
186
|
+
export function checkStaleDerivedSections(vaultBase) {
|
|
187
|
+
const index = indexDerivedBySession(vaultBase);
|
|
188
|
+
const notes = [];
|
|
189
|
+
|
|
190
|
+
for (const abs of listSessionNotes(vaultBase)) {
|
|
191
|
+
const rel = relative(vaultBase, abs).replaceAll('\\', '/');
|
|
192
|
+
const entry = index.get(rel.replace(/\.md$/, '')) || index.get(rel);
|
|
193
|
+
if (!entry) continue;
|
|
194
|
+
let content;
|
|
195
|
+
try { content = readFileSync(abs, 'utf-8'); } catch { continue; }
|
|
196
|
+
// Só falta conta. Link a mais pode ser curadoria do dono do vault, não sintoma.
|
|
197
|
+
const missing = countMissing(missingDerivedLinks(content, entry));
|
|
198
|
+
if (missing) notes.push({ file: rel, missing });
|
|
199
|
+
}
|
|
200
|
+
return { notes };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function renderStaleDerivedSectionLines(stale) {
|
|
204
|
+
const lines = [`[derivadas] ${stale.notes.length} sessão(ões) com seções desatualizadas`];
|
|
205
|
+
for (const { file, missing } of stale.notes) lines.push(` ✗ ${file} (${missing} link(s) faltando)`);
|
|
206
|
+
if (stale.notes.length) lines.push(' → wendkeep note repair-sections --apply');
|
|
207
|
+
else lines.push(' seções derivadas em dia ✓');
|
|
208
|
+
return lines;
|
|
209
|
+
}
|
|
210
|
+
|
|
182
211
|
// Formatador puro pra que a saída do doctor seja testável sem process.exit.
|
|
183
212
|
export function renderStackedFrontmatterLines(vaultBase, stacked) {
|
|
184
213
|
const lines = [`[notas] ${stacked.count} sessão(ões) com frontmatter empilhado`];
|
package/hooks/session-stop.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import { getLocale } from './locale.mjs';
|
|
|
11
11
|
import { updateSessionObservability } from './session-observability.mjs';
|
|
12
12
|
import { resolveSessionEntry } from './session-identity.mjs';
|
|
13
13
|
import { mutateSessionNote } from './session-note-io.mjs';
|
|
14
|
+
import { applyDerivedSections, provenanceSessions } from './derived-sections.mjs';
|
|
14
15
|
import {
|
|
15
16
|
ensureDir,
|
|
16
17
|
findActiveSessionByTranscript,
|
|
@@ -804,6 +805,14 @@ export function extractPending(text) {
|
|
|
804
805
|
}
|
|
805
806
|
|
|
806
807
|
function noteReferencesSession(content, sessionRel) {
|
|
808
|
+
// Quando a derivada declara `source:`, ele manda: uma nota que apenas CITA outra sessão
|
|
809
|
+
// (em `related:` ou na prosa) não pertence a ela. Sem `source:` (nota legada), qualquer
|
|
810
|
+
// referência vale — é o que DRV-5 estabeleceu e o que `note relink` existe para corrigir.
|
|
811
|
+
const declared = provenanceSessions(content);
|
|
812
|
+
if (declared.length) {
|
|
813
|
+
const key = sessionRel.replace(/\.md$/, '').replaceAll('\\', '/');
|
|
814
|
+
return declared.some((target) => target.replace(/\.md$/, '') === key);
|
|
815
|
+
}
|
|
807
816
|
const sessionLink = wikilinkFromRel(sessionRel);
|
|
808
817
|
return content.includes(sessionRel) || content.includes(sessionLink);
|
|
809
818
|
}
|
|
@@ -924,7 +933,12 @@ ${formatPendingClosing(pending)}
|
|
|
924
933
|
`;
|
|
925
934
|
|
|
926
935
|
mutateSessionNote(sessionPath, (content) => replaceClosingSection(
|
|
927
|
-
|
|
936
|
+
// As três seções derivadas saem do MESMO `created` que monta o Encerramento — antes
|
|
937
|
+
// elas ficavam de fora deste write e a nota mentia no corpo (ver hooks/derived-sections.mjs).
|
|
938
|
+
applyDerivedSections(
|
|
939
|
+
replacePendingSection(updateFrontmatter(content, endedAt), pending),
|
|
940
|
+
created,
|
|
941
|
+
),
|
|
928
942
|
closing,
|
|
929
943
|
));
|
|
930
944
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wendkeep",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.54.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.52.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, checkStackedFrontmatter, renderStackedFrontmatterLines, checkUnpricedModels, renderUnpricedModelLines } from '../hooks/harness-doctor.mjs';
|
|
7
|
+
import { checkHarness, checkVaultLinks, checkSessionActivity, checkStackedFrontmatter, renderStackedFrontmatterLines, checkUnpricedModels, renderUnpricedModelLines, checkStaleDerivedSections, renderStaleDerivedSectionLines } from '../hooks/harness-doctor.mjs';
|
|
8
8
|
import { checkSyncDefs } from './sync-defs.mjs';
|
|
9
9
|
import { resolveProjectVault } from './project-vault.mjs';
|
|
10
10
|
|
|
@@ -34,7 +34,7 @@ export function runDoctor(argv) {
|
|
|
34
34
|
});
|
|
35
35
|
} catch (error) {
|
|
36
36
|
process.stderr.write(`wendkeep doctor: ${error.message}\n`);
|
|
37
|
-
|
|
37
|
+
return 2;
|
|
38
38
|
}
|
|
39
39
|
const vaultBase = resolution.base;
|
|
40
40
|
process.stdout.write(`[vault] ${resolution.source}: ${vaultBase} (project: ${projectRoot})\n`);
|
|
@@ -76,6 +76,9 @@ export function runDoctor(argv) {
|
|
|
76
76
|
// 3c. Modelo fora de pricing.json fecha a sessão com custo zero, sem erro — só aparece aqui.
|
|
77
77
|
process.stdout.write(`\n${renderUnpricedModelLines(checkUnpricedModels(vaultBase)).join('\n')}\n`);
|
|
78
78
|
|
|
79
|
+
// 3d. Seções derivadas do corpo que ficaram para trás do Encerramento (notas pré-0.53.0).
|
|
80
|
+
process.stdout.write(`\n${renderStaleDerivedSectionLines(checkStaleDerivedSections(vaultBase)).join('\n')}\n`);
|
|
81
|
+
|
|
79
82
|
// 4. Sessão: não mente "inativa" quando há atividade recente (workflow/subagente em background).
|
|
80
83
|
const act = checkSessionActivity(vaultBase);
|
|
81
84
|
if (act.lastSession) {
|
|
@@ -87,5 +90,7 @@ export function runDoctor(argv) {
|
|
|
87
90
|
process.stdout.write(`[sessão] última: ${act.lastSession} (${label})\n`);
|
|
88
91
|
}
|
|
89
92
|
|
|
90
|
-
|
|
93
|
+
// Devolve o código em vez de sair: `wendkeep sync` encadeia este comando, e um
|
|
94
|
+
// process.exit aqui mataria a cadeia. Quem faz o exit é o bin.
|
|
95
|
+
return healthStatus !== 0 || errors.length ? 1 : 0;
|
|
91
96
|
}
|
package/src/note.mjs
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
import { getLocale } from '../hooks/locale.mjs';
|
|
17
17
|
import { buildManualBugNote, buildManualLearningNote, relinkDerivedNotes } from '../hooks/linked-notes.mjs';
|
|
18
18
|
import { repairStackedFrontmatter } from '../hooks/frontmatter-repair.mjs';
|
|
19
|
+
import { repairDerivedSections } from '../hooks/derived-sections.mjs';
|
|
19
20
|
|
|
20
21
|
const TYPES = {
|
|
21
22
|
bug: { folderKey: 'bugs', prefix: 'BUG', build: buildManualBugNote },
|
|
@@ -60,8 +61,22 @@ export function runNote(argv) {
|
|
|
60
61
|
process.exit(0);
|
|
61
62
|
}
|
|
62
63
|
|
|
64
|
+
if (sub === 'repair-sections') {
|
|
65
|
+
const vaultRaw = opt(rest, '--vault') || process.env.OBSIDIAN_VAULT_PATH;
|
|
66
|
+
if (!vaultRaw) { process.stderr.write('wendkeep note repair-sections: no vault (--vault or OBSIDIAN_VAULT_PATH).\n'); process.exit(2); }
|
|
67
|
+
const vaultBase = isAbsolute(vaultRaw) ? vaultRaw : resolve(process.cwd(), vaultRaw);
|
|
68
|
+
if (!existsSync(vaultBase)) { process.stderr.write(`wendkeep note repair-sections: vault not found: ${vaultBase}\n`); process.exit(2); }
|
|
69
|
+
const r = repairDerivedSections(vaultBase, { apply: rest.includes('--apply') });
|
|
70
|
+
if (rest.includes('--json')) { process.stdout.write(`${JSON.stringify(r, null, 2)}\n`); process.exit(0); }
|
|
71
|
+
process.stdout.write(`${r.repaired.length} sessão(ões) com seções derivadas desatualizadas${r.applied ? ' reparada(s)' : ' seriam reparada(s)'}\n`);
|
|
72
|
+
for (const n of r.repaired) process.stdout.write(` ${n.file} (${n.missing} link(s) faltando)\n`);
|
|
73
|
+
for (const s of r.skipped) process.stdout.write(` pulado: ${s.file} (${s.reason})\n`);
|
|
74
|
+
if (!r.applied && r.repaired.length) process.stdout.write('\ndry-run — nada escrito. Rode com --apply para reconstruir as seções.\n');
|
|
75
|
+
process.exit(0);
|
|
76
|
+
}
|
|
77
|
+
|
|
63
78
|
if (sub !== 'new') {
|
|
64
|
-
process.stderr.write('wendkeep note: subcomando desconhecido (use `note new --type bug|learning "<título>"`, `note relink [--apply]` ou `note repair-
|
|
79
|
+
process.stderr.write('wendkeep note: subcomando desconhecido (use `note new --type bug|learning "<título>"`, `note relink [--apply]`, `note repair-frontmatter [--apply]` ou `note repair-sections [--apply]`).\n');
|
|
65
80
|
process.exit(2);
|
|
66
81
|
}
|
|
67
82
|
|
package/src/sync-defs.mjs
CHANGED
|
@@ -173,7 +173,7 @@ export function runSyncDefs(argv) {
|
|
|
173
173
|
const base = vault || process.env.OBSIDIAN_VAULT_PATH;
|
|
174
174
|
if (!base) {
|
|
175
175
|
process.stderr.write('wendkeep sync-defs: no vault. Pass --vault <path> or set OBSIDIAN_VAULT_PATH.\n');
|
|
176
|
-
|
|
176
|
+
return 2;
|
|
177
177
|
}
|
|
178
178
|
const vaultBase = isAbsolute(base) ? base : resolve(process.cwd(), base);
|
|
179
179
|
const projectPath = resolve(project || process.cwd());
|
|
@@ -184,7 +184,7 @@ export function runSyncDefs(argv) {
|
|
|
184
184
|
process.stderr.write(`wendkeep sync-defs --check: drift detectado\n - ${r.issues.join('\n - ')}\n`);
|
|
185
185
|
process.stderr.write('rode `wendkeep sync-defs --reseed` e reinicie Claude Code/Codex\n');
|
|
186
186
|
}
|
|
187
|
-
|
|
187
|
+
return r.ok ? 0 : 1;
|
|
188
188
|
}
|
|
189
189
|
// --reseed (0.31.0): sobrescreve as wk-* de .brain/skills com os seeds da versão instalada
|
|
190
190
|
// ANTES de copiar — é como um vault existente recebe descriptions/HARD-GATE novos.
|
|
@@ -198,7 +198,8 @@ export function runSyncDefs(argv) {
|
|
|
198
198
|
);
|
|
199
199
|
if (r.agents.length) process.stdout.write(` agents: ${r.agents.join(', ')}\n`);
|
|
200
200
|
if (r.skills.length) process.stdout.write(` skills: ${r.skills.join(', ')}\n`);
|
|
201
|
-
|
|
201
|
+
// Devolve o código: encadeia este comando (ver src/sync.mjs).
|
|
202
|
+
return 0;
|
|
202
203
|
}
|
|
203
204
|
|
|
204
205
|
// --- seeding (init) ---------------------------------------------------------
|
package/src/sync.mjs
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// `wendkeep sync` — os três passos que se repetem iguais a cada atualização do pacote:
|
|
2
|
+
// init -> sync-defs -> doctor, no PROJETO CORRENTE.
|
|
3
|
+
//
|
|
4
|
+
// O `npm install` fica de fora de propósito: um processo não se auto-substitui e continua
|
|
5
|
+
// rodando (o código em execução seguiria sendo o antigo), e é o passo que mais varia entre
|
|
6
|
+
// projetos — npm, pnpm, workspace root, política de cooldown. É onde um comando "esperto"
|
|
7
|
+
// quebraria no repositório de outra pessoa.
|
|
8
|
+
//
|
|
9
|
+
// E opera onde é invocado, nunca sobre uma lista de projetos: o wendkeep é um pacote
|
|
10
|
+
// público, e um comando que varresse repositórios por nome só funcionaria numa máquina.
|
|
11
|
+
import { resolve } from 'node:path';
|
|
12
|
+
|
|
13
|
+
function opt(argv, name) {
|
|
14
|
+
const i = argv.indexOf(name);
|
|
15
|
+
if (i >= 0) return argv[i + 1];
|
|
16
|
+
const eq = argv.find((a) => a.startsWith(`${name}=`));
|
|
17
|
+
return eq ? eq.slice(name.length + 1) : undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const step = (n, label) => process.stdout.write(`\n[${n}/3] ${label}\n`);
|
|
21
|
+
|
|
22
|
+
export async function runSync(argv) {
|
|
23
|
+
const projectRaw = opt(argv, '--project');
|
|
24
|
+
const vaultRaw = opt(argv, '--vault');
|
|
25
|
+
const projectPath = resolve(projectRaw && !projectRaw.startsWith('--') ? projectRaw : process.cwd());
|
|
26
|
+
const passthrough = argv.filter((a) => a === '--yes' || a === '-y' || a === '--force');
|
|
27
|
+
|
|
28
|
+
// 1. init — idempotente: refaz a fiação sem sobrescrever o que já está configurado.
|
|
29
|
+
step(1, 'init');
|
|
30
|
+
const { runInit } = await import('./init.mjs');
|
|
31
|
+
try {
|
|
32
|
+
await runInit(['--project', projectPath, ...(vaultRaw ? ['--vault', vaultRaw] : []), ...passthrough]);
|
|
33
|
+
} catch (error) {
|
|
34
|
+
process.stderr.write(`wendkeep sync: init falhou — ${error.message}\n`);
|
|
35
|
+
return 1;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Resolve o vault DEPOIS do init (que pode tê-lo acabado de criar) e repassa explícito.
|
|
39
|
+
// Sem isso, sync-defs cairia em `--vault || OBSIDIAN_VAULT_PATH` e o env global da
|
|
40
|
+
// máquina sequestraria um projeto recém-inicializado — o env bleed conhecido.
|
|
41
|
+
let vaultBase;
|
|
42
|
+
try {
|
|
43
|
+
const { resolveProjectVault } = await import('./project-vault.mjs');
|
|
44
|
+
vaultBase = resolveProjectVault({
|
|
45
|
+
startDir: projectPath,
|
|
46
|
+
explicitVault: vaultRaw || '',
|
|
47
|
+
validateIdentity: !vaultRaw,
|
|
48
|
+
}).base;
|
|
49
|
+
} catch (error) {
|
|
50
|
+
process.stderr.write(`wendkeep sync: vault não resolvido — ${error.message}\n`);
|
|
51
|
+
return 2;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 2. sync-defs — propaga as skills/agents da versão instalada para o projeto.
|
|
55
|
+
step(2, 'sync-defs');
|
|
56
|
+
const { runSyncDefs } = await import('./sync-defs.mjs');
|
|
57
|
+
const defsCode = runSyncDefs(['--vault', vaultBase, '--project', projectPath]);
|
|
58
|
+
if (defsCode) {
|
|
59
|
+
// Seguir para o doctor aqui seria enganoso: ele acusaria um `defs stale` que este
|
|
60
|
+
// passo deveria ter resolvido.
|
|
61
|
+
process.stderr.write('wendkeep sync: sync-defs falhou — doctor não executado.\n');
|
|
62
|
+
return defsCode;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// 3. doctor — relata. Sair != 0 é o resultado esperado num vault com pendências, então o
|
|
66
|
+
// código é propagado sem ser tratado como falha da cadeia.
|
|
67
|
+
step(3, 'doctor');
|
|
68
|
+
const { runDoctor } = await import('./doctor.mjs');
|
|
69
|
+
const doctorCode = runDoctor(['--vault', vaultBase, '--project', projectPath]);
|
|
70
|
+
|
|
71
|
+
// Nunca afirmar "tudo em dia": o doctor sai 0 mesmo tendo listado órfãos, seções
|
|
72
|
+
// desatualizadas ou modelos sem preço — essas checagens não são fatais. Uma linha final
|
|
73
|
+
// otimista contradiria o relatório logo acima dela.
|
|
74
|
+
process.stdout.write(`\nwendkeep sync: 3 passo(s) concluído(s)${doctorCode ? ' — doctor reportou erros' : ' — veja o relatório do doctor acima'}\n`);
|
|
75
|
+
return doctorCode;
|
|
76
|
+
}
|