wendkeep 0.42.0 → 0.45.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 +38 -0
- package/hooks/change-core.mjs +4 -6
- package/hooks/linked-notes.mjs +22 -14
- package/hooks/spec-core.mjs +42 -0
- package/hooks/token-usage.mjs +18 -4
- package/package.json +2 -2
- package/src/init.mjs +2 -7
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,44 @@ 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.45.0] — 2026-07-18
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- Observabilidade: o note de sessão deixa de ser reescrito com timestamp novo a cada Stop
|
|
12
|
+
quando o uso não muda. A preservação de `atualizado_em` (`token-usage.mjs`) comparava
|
|
13
|
+
`previous` (parseado do note) com `current` (recém-computado) via `JSON.stringify` —
|
|
14
|
+
sensível à ordem das chaves, que difere entre parse e build, então a comparação **sempre**
|
|
15
|
+
falhava e o timestamp era re-stampado toda vez. Novo `sameUsageData(a, b)` compara os campos
|
|
16
|
+
de uso de forma ordem-insensível (ignorando `atualizado_em`). Corrige o churn de reescrita e
|
|
17
|
+
o teste flaky "same sources produce byte-identical markdown".
|
|
18
|
+
|
|
19
|
+
## [0.44.0] — 2026-07-17
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- `wendkeep change new` não cria mais o placeholder `specs/exemplo/spec.md` (nem a pasta
|
|
24
|
+
`specs/`). Era ruído — sempre deletado à mão, e `discoverSpecDeltas` já o filtrava. Quando
|
|
25
|
+
a change resolve `spec_impact: required`, o autor escreve `specs/<capability>/spec.md`
|
|
26
|
+
direto; o formato do delta vive na skill wk-workflow. O filtro de `exemplo` fica (compat
|
|
27
|
+
com changes antigas em voo).
|
|
28
|
+
- README gerado do `07-Specs` reescrito para explicar o ponto mais confundido: specs são
|
|
29
|
+
**por capability, não por mudança** (N changes promovem no mesmo arquivo; o histórico
|
|
30
|
+
por-change vive em `_arquivo`). `promoteSpecs` passa a garantir/atualizar esse README a
|
|
31
|
+
cada archive (`ensureSpecsReadme`), então vaults existentes recebem o texto novo no
|
|
32
|
+
próximo archive — não só os criados via `init`.
|
|
33
|
+
|
|
34
|
+
## [0.43.0] — 2026-07-17
|
|
35
|
+
|
|
36
|
+
### Fixed
|
|
37
|
+
|
|
38
|
+
- Dedup de nota derivada era assimétrico: aprendizado deduplicava recursivamente (vault
|
|
39
|
+
inteiro), mas **bug e decisão só olhavam a pasta do mês** (`existingKeysForSession`, scan
|
|
40
|
+
não-recursivo). Uma nota da sessão numa subpasta `DIA` legada não era vista, então um
|
|
41
|
+
re-import/re-captura da mesma sessão criava uma duplicata de bug/decisão. Agora
|
|
42
|
+
`existingKeysForSession` varre a pasta derivada recursivamente (como o de aprendizado),
|
|
43
|
+
mantendo a semântica per-sessão (só notas que referenciam a sessão contam).
|
|
44
|
+
|
|
7
45
|
## [0.42.0] — 2026-07-17
|
|
8
46
|
|
|
9
47
|
### Changed
|
package/hooks/change-core.mjs
CHANGED
|
@@ -110,14 +110,12 @@ export function newChange(vaultBase, slug, { sessionRel = '', dateStr, simple =
|
|
|
110
110
|
write('proposta.md', files.proposta);
|
|
111
111
|
write('tarefas.md', files.tarefas);
|
|
112
112
|
if (!existed) write('.spec-impact-v1', '1\n');
|
|
113
|
-
// Auto-sizing (Wave B): a --simple change skips the design
|
|
113
|
+
// Auto-sizing (Wave B): a --simple change skips the design scaffold.
|
|
114
|
+
// No `specs/exemplo` placeholder: it was pure noise (always hand-deleted). When a change
|
|
115
|
+
// resolves `spec_impact: required`, the author writes `specs/<capability>/spec.md` directly
|
|
116
|
+
// — the delta format lives in the wk-workflow skill (and `renderChangeScaffold().specDelta`).
|
|
114
117
|
if (!simple) {
|
|
115
118
|
write('design.md', files.design);
|
|
116
|
-
const exampleDelta = join(dir, 'specs', 'exemplo', 'spec.md');
|
|
117
|
-
if (!existsSync(exampleDelta)) {
|
|
118
|
-
mkdirSync(join(dir, 'specs', 'exemplo'), { recursive: true });
|
|
119
|
-
writeFileSync(exampleDelta, files.specDelta, 'utf8');
|
|
120
|
-
}
|
|
121
119
|
}
|
|
122
120
|
if (!existed) captureSpecBaseline(vaultBase, dir);
|
|
123
121
|
setActiveChange(vaultBase, slug);
|
package/hooks/linked-notes.mjs
CHANGED
|
@@ -553,10 +553,6 @@ ${L.learn.futureHint}
|
|
|
553
553
|
|
|
554
554
|
const derivedFoldersFor = (vaultBase) => { const f = getLocale(vaultBase).folders; return { bugs: f.bugs, decisions: f.decisions, learnings: f.learnings }; };
|
|
555
555
|
|
|
556
|
-
function listMd(dir) {
|
|
557
|
-
try { return readdirSync(dir).filter((f) => f.endsWith('.md')); } catch { return []; }
|
|
558
|
-
}
|
|
559
|
-
|
|
560
556
|
// Chaves content_key das derivadas já existentes que linkam esta sessão.
|
|
561
557
|
// Vault-wide learning content_keys (recursive over the learnings folder). existingKeysForSession
|
|
562
558
|
// only looks at the current session + month, so the same lesson re-extracted on a later day/
|
|
@@ -582,19 +578,31 @@ function collectLearningKeys(vaultBase) {
|
|
|
582
578
|
return keys;
|
|
583
579
|
}
|
|
584
580
|
|
|
585
|
-
|
|
581
|
+
// dateStr kept for call compatibility; no longer used to narrow the scan — a session's note may
|
|
582
|
+
// sit in a legacy `DIA` subfolder, not just the month folder, so we walk the whole derived tree
|
|
583
|
+
// (like collectLearningKeys). The per-session semantics stay: only notes referencing THIS session
|
|
584
|
+
// count, so bugs/decisions from other sessions never leak in.
|
|
585
|
+
export function existingKeysForSession(vaultBase, sessionRel, dateStr) { // eslint-disable-line no-unused-vars
|
|
586
586
|
const wikilink = wikilinkFromRel(sessionRel);
|
|
587
587
|
const out = { bugs: [], decisions: [], learnings: [] };
|
|
588
588
|
for (const [type, folder] of Object.entries(derivedFoldersFor(vaultBase))) {
|
|
589
|
-
const
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
const
|
|
595
|
-
if (
|
|
596
|
-
|
|
597
|
-
|
|
589
|
+
const root = join(vaultBase, folder);
|
|
590
|
+
const walk = (d) => {
|
|
591
|
+
let entries;
|
|
592
|
+
try { entries = readdirSync(d, { withFileTypes: true }); } catch { return; }
|
|
593
|
+
for (const e of entries) {
|
|
594
|
+
const p = join(d, e.name);
|
|
595
|
+
if (e.isDirectory()) { walk(p); continue; }
|
|
596
|
+
if (!e.name.endsWith('.md')) continue;
|
|
597
|
+
try {
|
|
598
|
+
const c = readFileSync(p, 'utf-8');
|
|
599
|
+
if (!c.includes(sessionRel) && !c.includes(wikilink)) continue;
|
|
600
|
+
const m = c.match(/^content_key:\s*"?(.*?)"?\s*$/m);
|
|
601
|
+
if (m && m[1]) out[type].push(m[1]);
|
|
602
|
+
} catch { /* ignora nota ilegível */ }
|
|
603
|
+
}
|
|
604
|
+
};
|
|
605
|
+
walk(root);
|
|
598
606
|
}
|
|
599
607
|
return out;
|
|
600
608
|
}
|
package/hooks/spec-core.mjs
CHANGED
|
@@ -317,6 +317,47 @@ export function discoverSpecDeltas(changeDir) {
|
|
|
317
317
|
return caps;
|
|
318
318
|
}
|
|
319
319
|
|
|
320
|
+
// The 07-Specs README, explaining the most-confused point: specs are per-CAPABILITY, not
|
|
321
|
+
// per-change (many changes promote into the same file; the per-change record lives in
|
|
322
|
+
// _arquivo). Generated + read-only. Written on init and refreshed on every archive so
|
|
323
|
+
// existing vaults self-heal. Bilingual by vault locale.
|
|
324
|
+
export function ensureSpecsReadme(vaultBase) {
|
|
325
|
+
const loc = getLocale(vaultBase);
|
|
326
|
+
const en = loc.id === 'en';
|
|
327
|
+
const dir = join(vaultBase, loc.folders.specs);
|
|
328
|
+
ensureDir(dir);
|
|
329
|
+
const body = en
|
|
330
|
+
? `# Specs — generated living contract
|
|
331
|
+
|
|
332
|
+
**One file per _capability_, not per change.** Each file is the current, cumulative contract
|
|
333
|
+
of a capability — the sum of every change's spec delta that was promoted here. Many changes
|
|
334
|
+
touch the same capability, so N changes collapse into far fewer spec files.
|
|
335
|
+
|
|
336
|
+
Think of it like source code vs commits: this folder is the *current code* of each capability;
|
|
337
|
+
the *commits* that built it are the archived changes in \`${loc.folders.changes}/_arquivo/\`.
|
|
338
|
+
|
|
339
|
+
- **Generated + read-only.** Never author here. Write deltas only in
|
|
340
|
+
\`${loc.folders.changes}/<slug>/specs/<capability>/spec.md\` (ADDED/MODIFIED/REMOVED);
|
|
341
|
+
\`wendkeep change archive\` promotes them into this folder.
|
|
342
|
+
- Per-change history → \`${loc.folders.changes}/_arquivo/\`. Current contract → here.
|
|
343
|
+
`
|
|
344
|
+
: `# Specs — contrato consolidado gerado
|
|
345
|
+
|
|
346
|
+
**Um arquivo por _capability_, não por mudança.** Cada arquivo é o contrato atual e acumulado
|
|
347
|
+
de uma capability — a soma de todos os deltas de spec promovidos aqui. Várias mudanças tocam a
|
|
348
|
+
mesma capability, então N mudanças colapsam em bem menos specs.
|
|
349
|
+
|
|
350
|
+
Pense como código-fonte vs commits: esta pasta é o *código atual* de cada capability; os
|
|
351
|
+
*commits* que a construíram são as mudanças arquivadas em \`${loc.folders.changes}/_arquivo/\`.
|
|
352
|
+
|
|
353
|
+
- **Gerado + somente leitura.** Nunca edite aqui. Escreva deltas apenas em
|
|
354
|
+
\`${loc.folders.changes}/<slug>/specs/<capability>/spec.md\` (ADDED/MODIFIED/REMOVED); o
|
|
355
|
+
\`wendkeep change archive\` promove para esta pasta.
|
|
356
|
+
- Histórico por mudança → \`${loc.folders.changes}/_arquivo/\`. Contrato atual → aqui.
|
|
357
|
+
`;
|
|
358
|
+
writeFileSync(join(dir, 'README.md'), body, 'utf8');
|
|
359
|
+
}
|
|
360
|
+
|
|
320
361
|
// Merge each capability's delta (in the change) into the living spec in 07-Specs.
|
|
321
362
|
export function promoteSpecs(vaultBase, changeDir, specs, { changeWikilink, dateStr } = {}) {
|
|
322
363
|
const loc = getLocale(vaultBase);
|
|
@@ -347,6 +388,7 @@ export function promoteSpecs(vaultBase, changeDir, specs, { changeWikilink, date
|
|
|
347
388
|
promoted.push(cap);
|
|
348
389
|
}
|
|
349
390
|
recordPromotedSpecs(vaultBase, promoted);
|
|
391
|
+
ensureSpecsReadme(vaultBase); // self-heal the explainer so existing vaults get it on archive
|
|
350
392
|
return { promoted, warnings };
|
|
351
393
|
}
|
|
352
394
|
|
package/hooks/token-usage.mjs
CHANGED
|
@@ -604,6 +604,19 @@ function transcriptIdFromPath(transcriptPath) {
|
|
|
604
604
|
return basename(String(transcriptPath || '')).replace(/\.jsonl?$/i, '') || 'desconhecido';
|
|
605
605
|
}
|
|
606
606
|
|
|
607
|
+
// The usage fields that define whether a transcript's entry "changed" — everything except the
|
|
608
|
+
// key (transcript_id) and the timestamp (atualizado_em). Order-insensitive by design: a new
|
|
609
|
+
// field here is the single place to keep preservation correct.
|
|
610
|
+
const USAGE_FIELDS = ['provider', 'pensamento', 'input', 'cache_write', 'cache_read',
|
|
611
|
+
'output', 'reasoning', 'total', 'custo_usd', 'prompts', 'tool_calls', 'chamadas_llm'];
|
|
612
|
+
|
|
613
|
+
export function sameUsageData(a, b) {
|
|
614
|
+
if (!a || !b) return false;
|
|
615
|
+
const listEqual = (x, y) => (x || []).join('') === (y || []).join('');
|
|
616
|
+
return USAGE_FIELDS.every((f) => (a[f] ?? null) === (b[f] ?? null))
|
|
617
|
+
&& listEqual(a.modelos, b.modelos) && listEqual(a.tools, b.tools);
|
|
618
|
+
}
|
|
619
|
+
|
|
607
620
|
function entryFromSummary(summary, transcriptId) {
|
|
608
621
|
return {
|
|
609
622
|
transcript_id: transcriptId,
|
|
@@ -931,10 +944,11 @@ export function collectSessionUsage({ sessionContent, transcriptPath }) {
|
|
|
931
944
|
const transcriptId = transcriptIdFromPath(transcriptPath);
|
|
932
945
|
const previous = existingEntries.find((entry) => entry.transcript_id === transcriptId);
|
|
933
946
|
const current = entryFromSummary(summary, transcriptId);
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
947
|
+
// Preserve the old timestamp when the usage data is unchanged, so an unchanged session note
|
|
948
|
+
// stays byte-identical (no needless rewrite on every Stop). Compared semantically, NOT via
|
|
949
|
+
// JSON.stringify: the parsed-from-note entry and the freshly-built one have different key
|
|
950
|
+
// orders, which made the old stringify compare always mismatch — preservation never fired.
|
|
951
|
+
if (previous && sameUsageData(previous, current)) current.atualizado_em = previous.atualizado_em;
|
|
938
952
|
let entries = existingEntries.filter((e) => e.transcript_id !== transcriptId);
|
|
939
953
|
|
|
940
954
|
if (!existingEntries.length) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wendkeep",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.45.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.44.0"
|
|
50
50
|
}
|
|
51
51
|
}
|
package/src/init.mjs
CHANGED
|
@@ -40,7 +40,7 @@ import { seedDefinitions, syncDefs } from './sync-defs.mjs';
|
|
|
40
40
|
import { seedWkSkills } from './skills-seed.mjs';
|
|
41
41
|
import { LOCALES, DEFAULT_LOCALE, getLocale, clearLocaleCache, vaultFolders } from '../hooks/locale.mjs';
|
|
42
42
|
import { seedDotcontext, globalHasDotcontext, resolveDotcontextSkipMcp, renderSensorsJson } from './dotcontext-seed.mjs';
|
|
43
|
-
import { adoptSpecsState, SPECS_STATE_FILE } from '../hooks/spec-core.mjs';
|
|
43
|
+
import { adoptSpecsState, ensureSpecsReadme, SPECS_STATE_FILE } from '../hooks/spec-core.mjs';
|
|
44
44
|
import { bindProjectVault, readProjectBinding } from './project-vault.mjs';
|
|
45
45
|
|
|
46
46
|
function parseArgs(argv) {
|
|
@@ -490,12 +490,7 @@ export async function runInit(argv) {
|
|
|
490
490
|
seedWkSkills(brainDir, loc.id); // Pilar A: native process skills, in the vault locale.
|
|
491
491
|
// Seed the change/spec layer starters (Pilar B) — non-destructive.
|
|
492
492
|
const en = loc.id === 'en';
|
|
493
|
-
|
|
494
|
-
if (!existsSync(specsReadme)) {
|
|
495
|
-
writeFileSync(specsReadme, en
|
|
496
|
-
? `# Specs — generated living contract\n\nRead-only: do not author here. Write deltas only in \`${loc.folders.changes}/<slug>/specs/\`; archive promotes them here.\n`
|
|
497
|
-
: `# Specs — contrato consolidado gerado\n\nSomente leitura: não edite aqui. Escreva deltas apenas em \`${loc.folders.changes}/<slug>/specs/\`; o archive promove para cá.\n`, 'utf8');
|
|
498
|
-
}
|
|
493
|
+
ensureSpecsReadme(vaultPath); // same explainer promoteSpecs refreshes (per-capability, read-only)
|
|
499
494
|
if (!existsSync(join(vaultPath, SPECS_STATE_FILE))) {
|
|
500
495
|
const livingSpecs = readdirSync(join(vaultPath, loc.folders.specs)).filter((name) => name.endsWith('.md') && name !== 'README.md');
|
|
501
496
|
if (!livingSpecs.length) adoptSpecsState(vaultPath);
|