wendkeep 0.34.1 → 0.35.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,24 @@ 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.35.0] — 2026-07-11
8
+
9
+ ### Fixed
10
+
11
+ - **Wikilinks para changes arquivadas não quebram mais.** `archive` e `abandon` movem a pasta
12
+ para `_arquivo/<data>-<slug>/` — e todo wikilink gravado ANTES do move (sessões fechadas,
13
+ decisões, outras changes) morria, aparecendo cinza no grafo (visto em produção). Agora o move
14
+ **reescreve os wikilinks no vault inteiro** (`[[08-Mudanças/<slug>/…]]` →
15
+ `[[08-Mudanças/_arquivo/<data>-<slug>/…]]`, full-path com e sem alias; nunca por basename —
16
+ `proposta`/`design` existem em toda change). Fail-quiet: a reescrita nunca derruba o archive.
17
+
18
+ ### Added
19
+
20
+ - **`wendkeep change relink [--apply] [--json]`** — cura retroativa para vaults com links já
21
+ mortos (changes arquivadas antes da 0.35.0): mapeia cada slug morto para o dir datado em
22
+ `_arquivo/` e reescreve. Dry-run por default; slug ambíguo (arquivado 2×) é reportado e pulado
23
+ — nunca chuta; sem archive correspondente vira aviso.
24
+
7
25
  ## [0.34.1] — 2026-07-11
8
26
 
9
27
  ### Fixed
@@ -381,12 +381,6 @@ export function archiveChange(vaultBase, slug, { gate = gateGreen, dateStr, adrN
381
381
  const loc = getLocale(vaultBase);
382
382
  const chDir = loc.folders.changes;
383
383
  const src = join(vaultBase, chDir, slug);
384
- let sourceSessionRel = '';
385
- try {
386
- const proposal = readFileSync(join(src, 'proposta.md'), 'utf8');
387
- const m = proposal.match(/\[\[((?:02-Sessões|02-Sessions)\/[^\]|]+?)(?:\|[^\]]+)?\]\]/);
388
- if (m) sourceSessionRel = m[1].endsWith('.md') ? m[1] : `${m[1]}.md`;
389
- } catch { /* sem source */ }
390
384
  const verdict = gate(src);
391
385
  if (!verdict.ok) return { ok: false, failing: verdict.failing || [] };
392
386
 
@@ -441,16 +435,10 @@ export function archiveChange(vaultBase, slug, { gate = gateGreen, dateStr, adrN
441
435
  writeFileSync(pp, c, 'utf8');
442
436
  } catch { /* proposta ilegível — segue */ }
443
437
 
444
- // A sessão guardava o link da change ativa; após o move, reescreva para o caminho arquivado.
445
- if (sourceSessionRel) {
446
- try {
447
- const sessionPath = join(vaultBase, sourceSessionRel);
448
- const oldLink = wikilinkFromRel(join(chDir, slug, 'proposta'));
449
- const archivedLink = wikilinkFromRel(join(destRel, 'proposta'));
450
- const current = readFileSync(sessionPath, 'utf8');
451
- if (current.includes(oldLink)) writeFileSync(sessionPath, current.replaceAll(oldLink, archivedLink), 'utf8');
452
- } catch { /* backlink é reparo auxiliar; archive já está íntegro */ }
453
- }
438
+ // O move quebrava TODO wikilink gravado antes (sessões fechadas, decisões, outras changes
439
+ // links cinza no grafo, visto em produção). Reescreve vault-wide; fail-quiet.
440
+ let linksRewritten = 0;
441
+ try { linksRewritten = rewriteChangeLinks(vaultBase, `${chDir}/${slug}`, destRel.replaceAll('\\', '/')); } catch { /* archive já íntegro */ }
454
442
 
455
443
  // ADR goes in the same dated month folder as session-derived decisions (04-Decisões/ano/MM-MMM/)
456
444
  // — not the year root — so all ADRs sit together in the vault's convention.
@@ -485,7 +473,88 @@ Mudança ${changeWikilink} concluída e arquivada.${capLine}${reqLine}${forcedNo
485
473
  // Only clear the pointer when the archived change IS the active one — archiving some other
486
474
  // slug explicitly must not blank the pointer of a different, still-active change.
487
475
  if (activeChange(vaultBase) === slug) clearActiveChange(vaultBase);
488
- return { ok: true, failing: [], archivedRel: destRel, adrRel, promoted, specWarnings };
476
+ return { ok: true, failing: [], archivedRel: destRel, adrRel, promoted, specWarnings, linksRewritten };
477
+ }
478
+
479
+ // --- reescrita de wikilinks pós-move (0.35.0) ----------------------------------
480
+ // Todo .md do vault (inclui .brain e _arquivo — uma change arquivada pode linkar outra).
481
+ function allVaultMarkdown(vaultBase) {
482
+ const out = [];
483
+ const skip = new Set(['.git', '.obsidian', 'node_modules']);
484
+ const walk = (dir) => {
485
+ let entries;
486
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
487
+ for (const e of entries) {
488
+ if (skip.has(e.name)) continue;
489
+ if (e.name.startsWith('.') && e.name !== '.brain') continue;
490
+ const abs = join(dir, e.name);
491
+ if (e.isDirectory()) walk(abs);
492
+ else if (e.name.endsWith('.md')) out.push(abs);
493
+ }
494
+ };
495
+ walk(vaultBase);
496
+ return out;
497
+ }
498
+
499
+ // Reescreve `[[fromRel/...]]`, `[[fromRel]]` e `[[fromRel|alias]]` em todo o vault.
500
+ // NUNCA por basename: `proposta`/`design` existem em toda change — só full-path é seguro.
501
+ function rewriteChangeLinks(vaultBase, fromRel, toRel) {
502
+ let touched = 0;
503
+ for (const abs of allVaultMarkdown(vaultBase)) {
504
+ let content;
505
+ try { content = readFileSync(abs, 'utf8'); } catch { continue; }
506
+ const next = content
507
+ .split(`[[${fromRel}/`).join(`[[${toRel}/`)
508
+ .split(`[[${fromRel}]]`).join(`[[${toRel}]]`)
509
+ .split(`[[${fromRel}|`).join(`[[${toRel}|`);
510
+ if (next !== content) {
511
+ try { writeFileSync(abs, next, 'utf8'); touched += 1; } catch { /* nota readonly — segue */ }
512
+ }
513
+ }
514
+ return touched;
515
+ }
516
+
517
+ // Cura retroativa (vaults pré-0.35): wikilinks para changes que já moveram sem reescrita.
518
+ // Dry-run por default; match por slug no nome datado do archive (`<data>-<slug>[-abandonada]`);
519
+ // ambíguo (mesmo slug arquivado 2×) é reportado e pulado — nunca chuta.
520
+ export function relinkChanges(vaultBase, { apply = false } = {}) {
521
+ const chDir = getLocale(vaultBase).folders.changes;
522
+ const archiveAbs = join(vaultBase, chDir, ARCHIVE_DIR);
523
+ let archived = [];
524
+ try { archived = readdirSync(archiveAbs, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name); } catch { /* sem arquivo */ }
525
+ const slugOf = (name) => name.replace(/^\d{4}-\d{2}-\d{2}-/, '').replace(/-abandonada$/, '');
526
+
527
+ // Slugs mortos referenciados em algum .md: [[<chDir>/<seg>/...]] | [[<chDir>/<seg>]] | [[...|
528
+ const linkRe = new RegExp(`\\[\\[${chDir.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\$&')}/([^/\\]|]+)(?=[/\\]|])`, 'g');
529
+ const files = allVaultMarkdown(vaultBase);
530
+ const dead = new Set();
531
+ for (const abs of files) {
532
+ let content;
533
+ try { content = readFileSync(abs, 'utf8'); } catch { continue; }
534
+ for (const m of content.matchAll(linkRe)) {
535
+ const seg = m[1];
536
+ if (seg === ARCHIVE_DIR) continue;
537
+ if (!existsSync(join(vaultBase, chDir, seg, 'proposta.md'))) dead.add(seg);
538
+ }
539
+ }
540
+
541
+ const rewritten = [];
542
+ const ambiguous = [];
543
+ const orphans = [];
544
+ const renames = [];
545
+ for (const seg of dead) {
546
+ const matches = archived.filter((name) => slugOf(name) === seg);
547
+ if (matches.length === 1) renames.push({ from: `${chDir}/${seg}`, to: `${chDir}/${ARCHIVE_DIR}/${matches[0]}` });
548
+ else if (matches.length > 1) ambiguous.push(`${seg} → ${matches.join(', ')}`);
549
+ else orphans.push(seg);
550
+ }
551
+
552
+ let filesTouched = 0;
553
+ if (apply) {
554
+ for (const r of renames) filesTouched += rewriteChangeLinks(vaultBase, r.from, r.to);
555
+ }
556
+ rewritten.push(...renames);
557
+ return { applied: apply, scanned: files.length, filesTouched, rewritten, ambiguous, orphans };
489
558
  }
490
559
 
491
560
  // Abandono (0.31.0): a saída legítima para uma change que não vai adiante — o que antes só o
@@ -504,6 +573,8 @@ export function abandonChange(vaultBase, slug, { dateStr }) {
504
573
  const pp = join(destAbs, 'proposta.md');
505
574
  writeFileSync(pp, readFileSync(pp, 'utf8').replace(/^status:\s*active\s*$/m, 'status: abandoned'), 'utf8');
506
575
  } catch { /* proposta sem frontmatter — segue */ }
576
+ let linksRewritten = 0;
577
+ try { linksRewritten = rewriteChangeLinks(vaultBase, `${chDir}/${slug}`, destRel.replaceAll('\\', '/')); } catch { /* abandono já íntegro */ }
507
578
  if (activeChange(vaultBase) === slug) clearActiveChange(vaultBase);
508
- return { ok: true, failing: [], archivedRel: destRel };
579
+ return { ok: true, failing: [], archivedRel: destRel, linksRewritten };
509
580
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wendkeep",
3
- "version": "0.34.1",
3
+ "version": "0.35.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/change.mjs CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  setTaskDone,
14
14
  archiveChange,
15
15
  abandonChange,
16
+ relinkChanges,
16
17
  scaffoldPlaceholders,
17
18
  } from '../hooks/change-core.mjs';
18
19
  import { evaluateGate, requiredSensors } from '../hooks/sensors-core.mjs';
@@ -284,6 +285,17 @@ export function runChange(argv) {
284
285
  process.exit(0);
285
286
  }
286
287
 
288
+ if (sub === 'relink') {
289
+ const r = relinkChanges(vaultBase, { apply: rest.includes('--apply') });
290
+ if (rest.includes('--json')) { process.stdout.write(`${JSON.stringify(r, null, 2)}\n`); process.exit(0); }
291
+ process.stdout.write(`${r.rewritten.length} slug(s) morto(s) mapeado(s)${r.applied ? ` · ${r.filesTouched} arquivo(s) reescritos` : ''}\n`);
292
+ for (const m of r.rewritten) process.stdout.write(` ${m.from} → ${m.to}\n`);
293
+ for (const a of r.ambiguous) process.stdout.write(` ambíguo (pulado): ${a}\n`);
294
+ for (const o of r.orphans) process.stdout.write(` sem archive correspondente: ${o}\n`);
295
+ if (!r.applied) process.stdout.write('\ndry-run — nada foi escrito. Rode com --apply para reescrever os wikilinks.\n');
296
+ process.exit(0);
297
+ }
298
+
287
299
  if (sub === 'abandon') {
288
300
  const slug = slugArg() || activeChange(vaultBase);
289
301
  if (!slug) { process.stderr.write('wendkeep change abandon: missing <slug> and no active change\n'); process.exit(2); }
@@ -293,6 +305,6 @@ export function runChange(argv) {
293
305
  process.exit(0);
294
306
  }
295
307
 
296
- process.stderr.write(`wendkeep change: unknown subcommand "${sub}". Known: new, use, continue, list, show, status, done, undone, diff, archive, abandon.\n`);
308
+ process.stderr.write(`wendkeep change: unknown subcommand "${sub}". Known: new, use, continue, list, show, status, done, undone, diff, archive, abandon, relink.\n`);
297
309
  process.exit(2);
298
310
  }