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 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
+ }
@@ -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`];
@@ -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
- replacePendingSection(updateFrontmatter(content, endedAt), pending),
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.52.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.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
- process.exit(2);
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
- process.exit(healthStatus !== 0 || errors.length ? 1 : 0);
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-frontmatter [--apply]`).\n');
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
- process.exit(2);
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
- process.exit(r.ok ? 0 : 1);
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
- process.exit(0);
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
+ }