wendkeep 0.34.0 → 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 +29 -0
- package/hooks/change-core.mjs +89 -18
- package/hooks/subagent-usage.mjs +2 -4
- package/package.json +1 -1
- package/src/change.mjs +13 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,35 @@ 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
|
+
|
|
25
|
+
## [0.34.1] — 2026-07-11
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
|
|
29
|
+
- **Tabela "Por subagent" ilegível na nota de sessão**: a tabela markdown vivia dentro de
|
|
30
|
+
`<details>` e o Obsidian (reading view) trata o bloco como HTML cru — sem renderizar a
|
|
31
|
+
tabela, tudo virava uma linha só de pipes embaralhados (visto em produção). A seção agora usa
|
|
32
|
+
um sub-heading `### Por subagent (N)` + tabela markdown pura: renderiza correto e continua
|
|
33
|
+
colapsável via fold nativo de heading. Notas existentes se autocuram no próximo Stop/refresh
|
|
34
|
+
(a seção inteira é regenerada pelo upsert).
|
|
35
|
+
|
|
7
36
|
## [0.34.0] — 2026-07-11
|
|
8
37
|
|
|
9
38
|
### Added
|
package/hooks/change-core.mjs
CHANGED
|
@@ -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
|
-
//
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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/hooks/subagent-usage.mjs
CHANGED
|
@@ -181,13 +181,11 @@ export function renderSubagentSection(c) {
|
|
|
181
181
|
- **Workflows:** ${wf}
|
|
182
182
|
- **Tools (subagents):** ${tools}${wasteLine}
|
|
183
183
|
|
|
184
|
-
|
|
184
|
+
### Por subagent (${a.count})
|
|
185
185
|
|
|
186
186
|
| Agent | Tipo | Workflow | Modelo | Tools | Tokens | Custo |
|
|
187
187
|
|---|---|---|---|---:|---:|---:|
|
|
188
|
-
${rows}
|
|
189
|
-
|
|
190
|
-
</details>`;
|
|
188
|
+
${rows}`;
|
|
191
189
|
}
|
|
192
190
|
|
|
193
191
|
function setFrontmatterField(content, key, value) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wendkeep",
|
|
3
|
-
"version": "0.
|
|
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
|
}
|