wendkeep 0.22.0 → 0.23.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,34 @@ 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.23.0] — 2026-07-08
8
+
9
+ Vault structure — generated views + housekeeping (audit wave 2).
10
+
11
+ ### Added
12
+ - **Generated Bases + Dashboard MOC**: `wendkeep init` now writes one folder-filtered `.base`
13
+ per taxonomy area (sessions/decisions/bugs/learnings/specs/changes) and a `00-Dashboard.md`
14
+ that embeds them — the vault's structural index. Filters are **by folder**
15
+ (`file.inFolder("05-Bugs")`), fixing the tag-filter that hid ~1/3 of bugs. New
16
+ `wendkeep dashboard [--force]` (re)generates them; non-destructive (never clobbers your own
17
+ bases). Locale-aware. `src/vault-views.mjs`.
18
+
19
+ ### Changed
20
+ - **Archive ADRs land in the dated month folder** (`04-Decisões/<year>/<MM-MMM>/`) alongside
21
+ session-derived decisions, instead of the year root. `hooks/change-core.mjs`.
22
+ - **`SESSION_REGISTRY` is pruned** on the idle sweep: `done` entries older than 90 days, then a
23
+ cap of 200 most-recent — active entries are never touched. Bounds the per-hook read/serialize
24
+ cost that had grown to 330 entries / ~170 KB in production. `hooks/obsidian-common.mjs`.
25
+ - **Generated note names truncate on a word boundary** instead of mid-word (`slugify` gained a
26
+ boundary-aware `maxLen`). `hooks/obsidian-common.mjs`, `hooks/linked-notes.mjs`.
27
+ - **Learnings dedup vault-wide**: a learning already recorded anywhere in `06-Aprendizados`
28
+ (by `content_key`) is not re-emitted on a later day/session. `hooks/linked-notes.mjs`.
29
+
30
+ ### Deferred
31
+ - Unifying the two `buildSessionContent` skeletons (session-start / session-ensure) stays as
32
+ tracked tech-debt — pure refactor, high regression risk in the capture layer, and the
33
+ user-facing drift (`session_id`) was already closed in 0.18/0.21.
34
+
7
35
  ## [0.22.0] — 2026-07-08
8
36
 
9
37
  Hardening — 10 audit-confirmed bugs fixed (each survived an adversarial refuter).
package/bin/wendkeep.mjs CHANGED
@@ -56,6 +56,7 @@ Usage:
56
56
  --dry-run · --json.
57
57
  wendkeep verify [--deep] [--change s] Run a change's task sensors + record evidence (the gate);
58
58
  --deep assembles the verification package for the wk-verify pass.
59
+ wendkeep dashboard [--force] (Re)generate the vault's folder-filtered Bases + 00-Dashboard MOC.
59
60
  wendkeep lesson add "t" "l" Record a project-local lesson (injected at SessionStart).
60
61
  wendkeep validate-memory [path] Validate .brain/CORE.md against the compaction
61
62
  protocol (cap 25, 3 sections, no secrets/PII). Uses
@@ -148,6 +149,11 @@ async function main() {
148
149
  runImportCli(rest);
149
150
  break;
150
151
  }
152
+ case 'dashboard': {
153
+ const { runDashboard } = await import('../src/vault-views.mjs');
154
+ runDashboard(rest);
155
+ break;
156
+ }
151
157
  case '--version':
152
158
  case '-v':
153
159
  process.stdout.write(`${version()}\n`);
@@ -3,7 +3,7 @@
3
3
  // the `wendkeep change` CLI (src/change.mjs) and the brain-inject hook. No external deps.
4
4
  import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from 'node:fs';
5
5
  import { dirname, join } from 'node:path';
6
- import { ensureDir, wikilinkFromRel } from './obsidian-common.mjs';
6
+ import { ensureDir, wikilinkFromRel, monthFolderRelFromDateStr } from './obsidian-common.mjs';
7
7
  import { parseSpecsList, promoteSpecs } from './spec-core.mjs';
8
8
  import { getLocale } from './locale.mjs';
9
9
 
@@ -261,8 +261,9 @@ export function archiveChange(vaultBase, slug, { gate = gateGreen, dateStr, adrN
261
261
  writeFileSync(pp, c, 'utf8');
262
262
  } catch { /* proposta ilegível — segue */ }
263
263
 
264
- const [year] = String(dateStr).split('-');
265
- const adrDirRel = join(loc.folders.decisions, year);
264
+ // ADR goes in the same dated month folder as session-derived decisions (04-Decisões/ano/MM-MMM/)
265
+ // not the year root — so all ADRs sit together in the vault's convention.
266
+ const adrDirRel = monthFolderRelFromDateStr(loc.folders.decisions, dateStr, vaultBase);
266
267
  ensureDir(join(vaultBase, adrDirRel));
267
268
  const num = String(adrNum).padStart(3, '0');
268
269
  const adrRel = join(adrDirRel, `ADR-${num}-${slug}.md`);
@@ -477,6 +477,30 @@ function listMd(dir) {
477
477
  }
478
478
 
479
479
  // Chaves content_key das derivadas já existentes que linkam esta sessão.
480
+ // Vault-wide learning content_keys (recursive over the learnings folder). existingKeysForSession
481
+ // only looks at the current session + month, so the same lesson re-extracted on a later day/
482
+ // session was duplicated. This dedups a learning against everything already learned in the vault.
483
+ function collectLearningKeys(vaultBase) {
484
+ const keys = new Set();
485
+ const root = join(vaultBase, getLocale(vaultBase).folders.learnings);
486
+ const walk = (d) => {
487
+ let entries;
488
+ try { entries = readdirSync(d, { withFileTypes: true }); } catch { return; }
489
+ for (const e of entries) {
490
+ const p = join(d, e.name);
491
+ if (e.isDirectory()) walk(p);
492
+ else if (e.name.endsWith('.md')) {
493
+ try {
494
+ const m = readFileSync(p, 'utf-8').match(/^content_key:\s*"?(.*?)"?\s*$/m);
495
+ if (m && m[1]) keys.add(m[1]);
496
+ } catch { /* nota ilegível */ }
497
+ }
498
+ }
499
+ };
500
+ walk(root);
501
+ return keys;
502
+ }
503
+
480
504
  function existingKeysForSession(vaultBase, sessionRel, dateStr) {
481
505
  const wikilink = wikilinkFromRel(sessionRel);
482
506
  const out = { bugs: [], decisions: [], learnings: [] };
@@ -518,7 +542,7 @@ export function createLinkedNotes(vaultBase, dateStr, sessionRel, tx, options =
518
542
  const issueRef = issueRefs[0] || '';
519
543
  const bugKey = derivedContentKey(bugDetails.rootCause);
520
544
  if (!alreadyHasKey(existingKeys.bugs, bugKey)) {
521
- const causeSlug = slugify(bugDetails.rootCause.slice(0, 40), 'bug');
545
+ const causeSlug = slugify(bugDetails.rootCause, 'bug', 40);
522
546
  const fileName = issueRef ? `${issueRef}-${causeSlug}.md` : `${dateStr}-bug-${causeSlug}.md`;
523
547
  const filePath = join(bugsDir, fileName);
524
548
  if (!existsSync(filePath)) writeFileSync(filePath, buildBugNoteContent(bugDetails, issueRef, dateStr, sessionRel, provider, bugKey, loc.id), 'utf-8');
@@ -531,7 +555,7 @@ export function createLinkedNotes(vaultBase, dateStr, sessionRel, tx, options =
531
555
  if (decisionDetails) {
532
556
  const decisionKey = derivedContentKey(decisionDetails.title);
533
557
  if (!alreadyHasKey(existingKeys.decisions, decisionKey)) {
534
- const titleSlug = slugify(decisionDetails.title.slice(0, 40), 'decisao');
558
+ const titleSlug = slugify(decisionDetails.title, 'decisao', 40);
535
559
  const existing = adrFileExistsBySlug(decisionsDir, titleSlug);
536
560
  const fileName = existing || `ADR-${String(getNextAdrNumber(vaultBase)).padStart(3, '0')}-${titleSlug}.md`;
537
561
  const filePath = join(decisionsDir, fileName);
@@ -546,10 +570,12 @@ export function createLinkedNotes(vaultBase, dateStr, sessionRel, tx, options =
546
570
 
547
571
  const learnings = extractLearningDetails(tx, bugDetails);
548
572
  if (learnings) {
573
+ const vaultLearningKeys = collectLearningKeys(vaultBase); // vault-wide dedup
549
574
  for (const learning of learnings) {
550
575
  const learningKey = derivedContentKey(learning.title);
551
576
  if (alreadyHasKey(existingKeys.learnings, learningKey)) continue;
552
- const learningSlug = slugify(learning.title.slice(0, 40), 'aprendizado');
577
+ if (vaultLearningKeys.has(learningKey)) continue; // already learned elsewhere in the vault
578
+ const learningSlug = slugify(learning.title, 'aprendizado', 40);
553
579
  const fileName = `${dateStr}-${learningSlug}.md`;
554
580
  const filePath = join(learningsDir, fileName);
555
581
  if (!existsSync(filePath)) writeFileSync(filePath, buildLearningNoteContent(learning, dateStr, sessionRel, provider, learningKey, loc.id), 'utf-8');
@@ -289,12 +289,38 @@ export function sweepStaleSessions(registry, nowMs, maxIdleMs, excludeTranscript
289
289
  return closed;
290
290
  }
291
291
 
292
- // Wrapper de IO: varre as ociosas, grava o registry e fecha a NOTA `.md` de cada
292
+ // Registry retention. The registry is read/serialized in full on every hook and scanned O(N)
293
+ // for routing — it only needs active + recent sessions (historical audit lives in the notes).
294
+ // Left unbounded it grew to 330 entries / ~170 KB in production.
295
+ export const REGISTRY_KEEP_DONE = 200;
296
+ export const REGISTRY_DONE_MAX_AGE_MS = 90 * 24 * 60 * 60 * 1000;
297
+
298
+ // Pure: drop 'done' entries older than maxAgeMs, then cap the remaining 'done' at keepDone
299
+ // (newest by ended_at/updated_at/started_at kept). Never touches active entries. Mutates the
300
+ // registry and returns how many were pruned.
301
+ export function pruneRegistry(registry, nowMs, { keepDone = REGISTRY_KEEP_DONE, maxAgeMs = REGISTRY_DONE_MAX_AGE_MS } = {}) {
302
+ const sessions = registry?.sessions || {};
303
+ const stamp = (v) => Date.parse((v && (v.ended_at || v.updated_at || v.started_at)) || '') || 0;
304
+ let pruned = 0;
305
+ for (const [id, v] of Object.entries(sessions)) {
306
+ if (!v || v.status !== 'done') continue;
307
+ const t = stamp(v);
308
+ if (t && nowMs - t > maxAgeMs) { delete sessions[id]; pruned += 1; }
309
+ }
310
+ const done = Object.entries(sessions)
311
+ .filter(([, v]) => v && v.status === 'done')
312
+ .sort((a, b) => stamp(b[1]) - stamp(a[1]));
313
+ for (const [id] of done.slice(keepDone)) { delete sessions[id]; pruned += 1; }
314
+ return pruned;
315
+ }
316
+
317
+ // Wrapper de IO: varre as ociosas, poda o registry, grava e fecha a NOTA `.md` de cada
293
318
  // sessão encerrada (mantém vault e registry alinhados). Devolve quantas fechou.
294
319
  export function sweepStaleSessionsFile(vaultBase, now = new Date(), maxIdleMs = SESSION_IDLE_CLOSE_MS, excludeTranscriptPath = '') {
295
320
  const registry = readSessionRegistry(vaultBase);
296
321
  const closed = sweepStaleSessions(registry, now.getTime(), maxIdleMs, excludeTranscriptPath);
297
- if (closed.length) writeSessionRegistry(vaultBase, registry);
322
+ const pruned = pruneRegistry(registry, now.getTime());
323
+ if (closed.length || pruned) writeSessionRegistry(vaultBase, registry);
298
324
  for (const { session_file, ended_at } of closed) {
299
325
  try { closeSessionNoteFile(vaultBase, session_file, ended_at); } catch { /* nunca derruba o sweep */ }
300
326
  }
@@ -335,15 +361,20 @@ export function closeSessionNote(content, endedAt) {
335
361
  return next;
336
362
  }
337
363
 
338
- export function slugify(text, fallback = 'nota') {
339
- const slug = String(text || '')
364
+ export function slugify(text, fallback = 'nota', maxLen = 60) {
365
+ let slug = String(text || '')
340
366
  .normalize('NFD')
341
367
  .replace(/[\u0300-\u036f]/g, '')
342
368
  .toLowerCase()
343
369
  .replace(/[^a-z0-9]+/g, '-')
344
- .replace(/^-+|-+$/g, '')
345
- .slice(0, 70)
346
- .replace(/-+$/g, '');
370
+ .replace(/^-+|-+$/g, '');
371
+ if (slug.length > maxLen) {
372
+ // Truncate on a word boundary (last '-' before maxLen) when a reasonable one exists,
373
+ // instead of cutting mid-word \u2014 keeps generated note names readable.
374
+ const cut = slug.slice(0, maxLen);
375
+ const lastDash = cut.lastIndexOf('-');
376
+ slug = (lastDash > maxLen * 0.5 ? cut.slice(0, lastDash) : cut).replace(/-+$/g, '');
377
+ }
347
378
  return slug || fallback;
348
379
  }
349
380
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wendkeep",
3
- "version": "0.22.0",
3
+ "version": "0.23.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": {
package/src/init.mjs CHANGED
@@ -22,6 +22,7 @@ import {
22
22
  DOTCONTEXT_GITIGNORE,
23
23
  } from './taxonomy.mjs';
24
24
  import { renderVaultReadme } from './vault-readme.mjs';
25
+ import { seedVaultViews } from './vault-views.mjs';
25
26
  import { canInteractiveSelect, selectCompanionsInteractive } from './companion-select.mjs';
26
27
  import {
27
28
  SNIPPET_NAME,
@@ -364,7 +365,13 @@ export async function runInit(argv) {
364
365
  try { scripts = JSON.parse(readFileSync(join(projectPath, 'package.json'), 'utf8')).scripts || {}; } catch { /* no package.json */ }
365
366
  writeFileSync(sensorsFile, renderSensorsJson(scripts), 'utf8');
366
367
  }
367
- log(` [1/4] vault taxonomy: ${folders.length} folders (${created} created, locale ${loc.id})${readmeNote}, .brain + change/spec + sensors seeded`);
368
+ // Generated Bases + Dashboard MOC (folder-filtered views over the taxonomy) — non-destructive.
369
+ let viewsNote = '';
370
+ try {
371
+ const views = seedVaultViews(vaultPath);
372
+ if (views.length) viewsNote = `, ${views.length} view(s) + dashboard`;
373
+ } catch { /* views são bônus — nunca derrubam o init */ }
374
+ log(` [1/4] vault taxonomy: ${folders.length} folders (${created} created, locale ${loc.id})${readmeNote}, .brain + change/spec + sensors seeded${viewsNote}`);
368
375
  // Deliver the seeded defs (agents + wk process skills) to the project so they're
369
376
  // usable immediately — no separate `wendkeep sync-defs` step needed.
370
377
  const synced = syncDefs(vaultPath, projectPath);
@@ -0,0 +1,99 @@
1
+ // Generated Obsidian Bases + a Dashboard MOC — the vault's structural index (0.23.0).
2
+ // Each taxonomy folder gets a folder-filtered `.base` (NOT tag-filtered: a tag filter hid ~1/3
3
+ // of the corpus in production) and 00-Dashboard.md embeds them. Locale-aware; non-destructive
4
+ // (never overwrites a user's own base) unless force is passed. Zero runtime deps.
5
+ import { existsSync, writeFileSync } from 'node:fs';
6
+ import { isAbsolute, join, resolve } from 'node:path';
7
+ import { getLocale } from '../hooks/locale.mjs';
8
+
9
+ // Per-area view: which folder, which frontmatter columns, and their display labels.
10
+ function areaSpecs(loc) {
11
+ const f = loc.folders;
12
+ const en = loc.id === 'en';
13
+ const date = en ? 'Date' : 'Data';
14
+ return [
15
+ { key: 'sessoes', folder: f.sessions, title: en ? 'Sessions' : 'Sessões',
16
+ cols: ['date', 'provider', 'custo_modelo_usd', 'prompts', 'tool_calls'],
17
+ labels: { date, provider: en ? 'Agent' : 'Agente', custo_modelo_usd: 'US$', prompts: 'Prompts', tool_calls: 'Tools' } },
18
+ { key: 'decisoes', folder: f.decisions, title: en ? 'Decisions' : 'Decisões',
19
+ cols: ['date', 'status'], labels: { date, status: 'Status' } },
20
+ { key: 'bugs', folder: f.bugs, title: 'Bugs',
21
+ cols: ['date', 'status', 'severity'], labels: { date, status: 'Status', severity: en ? 'Severity' : 'Severidade' } },
22
+ { key: 'aprendizados', folder: f.learnings, title: en ? 'Learnings' : 'Aprendizados',
23
+ cols: ['date'], labels: { date } },
24
+ { key: 'specs', folder: f.specs, title: 'Specs', cols: ['date'], labels: { date } },
25
+ { key: 'mudancas', folder: f.changes, title: en ? 'Changes' : 'Mudanças',
26
+ cols: ['date', 'status'], labels: { date, status: 'Status' } },
27
+ ];
28
+ }
29
+
30
+ // A minimal, valid Base: folder filter + one table view. No formulas (that's where Base syntax
31
+ // errors hide) — the folder filter is the whole fix.
32
+ export function renderBase(spec) {
33
+ const props = spec.cols.map((c) => ` ${c}:\n displayName: ${spec.labels[c] || c}`).join('\n');
34
+ const order = ['file.name', ...spec.cols].map((c) => ` - ${c}`).join('\n');
35
+ return `filters:
36
+ and:
37
+ - file.inFolder("${spec.folder}")
38
+ - file.ext == "md"
39
+ properties:
40
+ ${props}
41
+ views:
42
+ - type: table
43
+ name: ${spec.title}
44
+ order:
45
+ ${order}
46
+ `;
47
+ }
48
+
49
+ export function renderDashboard(specs, en) {
50
+ const blocks = specs.map((s) => `## ${s.title}\n\n![[${s.key}.base]]`).join('\n\n---\n\n');
51
+ return `---
52
+ tags:
53
+ - dashboard
54
+ cssclasses:
55
+ - topic-dashboard
56
+ ---
57
+
58
+ # ${en ? 'Vault — Overview' : 'Vault — Visão Geral'}
59
+
60
+ > ${en ? 'Generated by wendkeep. Each section embeds a folder-filtered Base view (needs the Obsidian Bases core plugin).' : 'Gerado pelo wendkeep. Cada seção embeda uma view Base filtrada por pasta (requer o core plugin Bases do Obsidian).'}
61
+
62
+ ---
63
+
64
+ ${blocks}
65
+ `;
66
+ }
67
+
68
+ // Write one `<key>.base` per area + 00-Dashboard.md. Non-destructive unless force. Returns the
69
+ // list of files written.
70
+ export function seedVaultViews(vaultBase, { force = false } = {}) {
71
+ const loc = getLocale(vaultBase);
72
+ const specs = areaSpecs(loc);
73
+ const written = [];
74
+ for (const s of specs) {
75
+ const p = join(vaultBase, `${s.key}.base`);
76
+ if (force || !existsSync(p)) { writeFileSync(p, renderBase(s), 'utf8'); written.push(`${s.key}.base`); }
77
+ }
78
+ const dash = join(vaultBase, '00-Dashboard.md');
79
+ if (force || !existsSync(dash)) { writeFileSync(dash, renderDashboard(specs, loc.id === 'en'), 'utf8'); written.push('00-Dashboard.md'); }
80
+ return written;
81
+ }
82
+
83
+ function opt(argv, name) {
84
+ const i = argv.indexOf(name);
85
+ if (i >= 0) return argv[i + 1];
86
+ const eq = argv.find((a) => a.startsWith(`${name}=`));
87
+ return eq ? eq.slice(name.length + 1) : undefined;
88
+ }
89
+
90
+ // `wendkeep dashboard [--vault P] [--force]` — (re)generate the Bases + Dashboard.
91
+ export function runDashboard(argv) {
92
+ const vaultRaw = opt(argv, '--vault') || process.env.OBSIDIAN_VAULT_PATH;
93
+ if (!vaultRaw) { process.stderr.write('wendkeep dashboard: no vault (--vault or OBSIDIAN_VAULT_PATH).\n'); process.exit(2); }
94
+ const vaultBase = isAbsolute(vaultRaw) ? vaultRaw : resolve(process.cwd(), vaultRaw);
95
+ if (!existsSync(vaultBase)) { process.stderr.write(`wendkeep dashboard: vault not found: ${vaultBase}\n`); process.exit(2); }
96
+ const written = seedVaultViews(vaultBase, { force: argv.includes('--force') });
97
+ process.stdout.write(written.length ? `dashboard: ${written.length} arquivo(s) — ${written.join(', ')}\n` : 'dashboard: nada a fazer (use --force para regenerar)\n');
98
+ process.exit(0);
99
+ }