wendkeep 0.46.2 → 0.48.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 +36 -0
- package/README.md +10 -4
- package/bin/wendkeep.mjs +12 -1
- package/hooks/change-core.mjs +105 -2
- package/hooks/linked-notes.mjs +72 -1
- package/package.json +1 -1
- package/src/change.mjs +11 -1
- package/src/init.mjs +5 -33
- package/src/note.mjs +17 -2
- package/src/theme.mjs +89 -0
- package/src/vault-theme.mjs +11 -0
- package/src/verify.mjs +4 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,42 @@ 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.48.0] — 2026-07-23
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **`wendkeep note relink [--apply]` — backfill de proveniência das notas derivadas
|
|
12
|
+
órfãs.** BUG/APR legadas (criadas por versão antiga, sem `source:` de sessão) ficam ilhas
|
|
13
|
+
no grafo — num vault real: 13 de 15 BUG e 3 de 8 APR sem nenhum link de entrada nem de
|
|
14
|
+
saída. A origem não está registrada no órfão, mas os irmãos não-órfãos do mesmo tipo
|
|
15
|
+
carregam a sessão-fonte real. O comando liga cada órfão herdando a sessão **modal** (mais
|
|
16
|
+
comum) dos irmãos do mesmo tipo e mês, injetando `source:` + `related:` no frontmatter.
|
|
17
|
+
Dry-run por default; `--apply` escreve; idempotente; pula e reporta o órfão sem nenhum
|
|
18
|
+
irmão-fonte pra inferir (nunca chuta). Documentado em `derived-notes` (DRV-9).
|
|
19
|
+
|
|
20
|
+
## [0.47.0] — 2026-07-23
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- **Grafo do vault conectado: todo artefato de change linka o hub.** `change new` gera
|
|
25
|
+
`design.md` e `tarefas.md` com um backlink full-path pro `proposta.md` da própria change —
|
|
26
|
+
antes nasciam sem nenhum wikilink, ilhas no grafo do Obsidian (num vault real: 48 de 63
|
|
27
|
+
`.md` de `08-Mudanças` órfãos). O full-path (nunca basename, ambíguo entre changes) sobrevive
|
|
28
|
+
ao archive: o `rewriteChangeLinks` retargeta pro `_arquivo` junto com os demais links.
|
|
29
|
+
- **Auto-heal do backlink em `spec.md`.** O delta de spec é escrito à mão, então `verify` e
|
|
30
|
+
`archive` passam a injetar o backlink pro proposta em cada `specs/<capability>/spec.md` que
|
|
31
|
+
estiver sem — idempotente. Nenhum artefato de change fica desconectado do plano↔sessão.
|
|
32
|
+
- **`wendkeep change backlink [--apply]`** — backfill dos artefatos órfãos que já existem no
|
|
33
|
+
vault (changes open e arquivadas), apontando cada `design`/`tarefas`/`spec` pro proposta no
|
|
34
|
+
seu local atual. Dry-run por default; `--apply` escreve. Idempotente.
|
|
35
|
+
- **`wendkeep theme sync`** — re-aplica o color system (snippet CSS + graph color groups) num
|
|
36
|
+
vault existente, sem re-`init`. Recupera o grafo que ficou cinza quando o Obsidian (dono do
|
|
37
|
+
`graph.json`) zera os `colorGroups`. O instalador virou módulo compartilhado com o `init`,
|
|
38
|
+
então os dois nunca divergem.
|
|
39
|
+
- **Snippet define `--file-line-width`.** O CSS gerado passa a definir a largura de linha
|
|
40
|
+
legível (1400px, ajustável) sobre o `body` com `!important` — antes era edição manual em
|
|
41
|
+
cada vault. As capabilities `change-graph` e `vault-theme` documentam o contrato em `07-Specs`.
|
|
42
|
+
|
|
7
43
|
## [0.46.2] — 2026-07-19
|
|
8
44
|
|
|
9
45
|
### Fixed
|
package/README.md
CHANGED
|
@@ -70,7 +70,7 @@ npx wendkeep init
|
|
|
70
70
|
- **`dotcontext`** — *legacy, not recommended, and hidden from the picker.* wendkeep's native a2 loop (`change` / `verify` / gate) already does its job, so installing it **duplicates the harness**. Reachable only via an explicit `--companions dotcontext` for anyone already invested (tune with `--dotcontext-mcp` / `--dotcontext-hooks`).
|
|
71
71
|
|
|
72
72
|
Control with `--companions <csv>` or `--no-companions`. The Claude Code plugin layer (`extraKnownMarketplaces` + `enabledPlugins`) is wired as a bonus where the companion has one.
|
|
73
|
-
6. Install a **color system** into the vault's `.obsidian/`: a CSS snippet that accents notes by type (session/decision/bug/learning, via the `cssclasses` the hooks emit) plus graph color groups by folder. Non-destructive merge into `appearance.json`/`graph.json`; skip with `--no-colors`.
|
|
73
|
+
6. Install a **color system** into the vault's `.obsidian/`: a CSS snippet that accents notes by type (session/decision/bug/learning, via the `cssclasses` the hooks emit) plus graph color groups by folder. Non-destructive merge into `appearance.json`/`graph.json`; skip with `--no-colors`. Re-apply it any time on an existing vault with `wendkeep theme sync` — Obsidian owns `graph.json` and can drop the color groups (a grey graph); the re-sync restores them without a full re-`init`.
|
|
74
74
|
7. Seed the **curated memory layer**: `.brain/CORE.md` (the hand-curated hot layer, with the 3 required sections) and `.brain/COMPACTION_PROTOCOL.md` (the protocol guide). The auto layers (`DIGEST.md`, `index.jsonl`) are generated by the hooks. Validate the curated layer with `wendkeep validate-memory` (cap 25 lines, 3 sections, no secrets/PII).
|
|
75
75
|
8. Seed the **definitions + skills layer**: `.brain/agents/` + `.brain/skills/` (versioned source of truth), including the native process skills `wk-workflow` / `wk-tdd` / `wk-debugging` / `wk-brainstorming` / `wk-planning` / `wk-verify` (some ship templates — e.g. `wk-verify`'s `verdict-template.json` + reviewer prompt). `init` runs `wendkeep sync-defs` for you, delivering the skills to `.claude/skills/` and `.agents/skills/`, and the agent definitions (`.brain/agents/*.toml`) to `.codex/agents/`, plus a managed section in `AGENTS.md` that indexes the skills for Codex; `sync-defs --check` detects stale copies (re-run `sync-defs` after editing `.brain`).
|
|
76
76
|
9. Seed the **change/spec lifecycle**: the `07-Specs/` + `08-Mudanças/` folders and a native `wendkeep.sensors.json` — a critical `memory-validation` sensor (`npx wendkeep validate-memory`) plus one for each of `typecheck` / `test` / `lint` / `build` found in your `package.json`. Add your own with `wendkeep sensors add`. Drives `wendkeep change` / `wendkeep verify` — see **Change lifecycle** below.
|
|
@@ -184,6 +184,8 @@ wendkeep note new --type learning "a regex without /g only ever returns the firs
|
|
|
184
184
|
|
|
185
185
|
It prints the created path, numbers from the current max (recursive scan), files it in the month folder for today (`--date YYYY-MM-DD` to override), and links the active session in `source:` so the graph stays connected. Agents get this rule injected at SessionStart — they call the command instead of guessing a filename.
|
|
186
186
|
|
|
187
|
+
**Reconnecting legacy notes.** Derived notes created by older versions carry no `source:` session and sit as islands in the graph. `wendkeep note relink` backfills them: each orphan inherits the modal source session of its type/month cohort (the session its non-orphan siblings already point to). Dry-run by default; `--apply` writes; notes with no sibling to infer from are skipped and reported.
|
|
188
|
+
|
|
187
189
|
**Migrating an existing vault.** Notes created before `0.41.0` have date-prefixed names (`2026-07-16-bug-<slug>.md`) and may sit in legacy `DIA N` subfolders. One command per tree renumbers them chronologically, moves them up into the month folder, and rewrites every wikilink across the vault:
|
|
188
190
|
|
|
189
191
|
```bash
|
|
@@ -252,9 +254,13 @@ npx wendkeep change archive dark-mode # gate: sensors + verdict + no op
|
|
|
252
254
|
```
|
|
253
255
|
|
|
254
256
|
The archive promotes the delta into generated `07-Specs/ui.md`, mints an ADR, and the
|
|
255
|
-
Obsidian graph now links *session ↔ change ↔ requirement ↔ decision*.
|
|
256
|
-
|
|
257
|
-
|
|
257
|
+
Obsidian graph now links *session ↔ change ↔ requirement ↔ decision*. Every generated
|
|
258
|
+
artifact (`design`/`tarefas`) is born linking its change's `proposta` hub, and hand-authored
|
|
259
|
+
`spec.md` deltas are auto-linked on `verify`/`archive` — no change artifact is a graph island.
|
|
260
|
+
To backfill older changes that predate this, run `wendkeep change backlink --apply` (dry-run
|
|
261
|
+
without `--apply`). A change that names no `[req:]` still runs `verify --deep`, but skips the
|
|
262
|
+
`wk-verify` reading pass: the command writes a trivial verdict on its own and the sensor gate
|
|
263
|
+
is the real proof.
|
|
258
264
|
|
|
259
265
|
## How it works
|
|
260
266
|
|
package/bin/wendkeep.mjs
CHANGED
|
@@ -44,8 +44,11 @@ Usage:
|
|
|
44
44
|
|
|
45
45
|
wendkeep doctor [--vault P] Run a vault health check.
|
|
46
46
|
wendkeep change <sub> Change lifecycle: new [--simple] | use | bind <slug> --session <id> | continue | list | show |
|
|
47
|
-
status | done <id> | undone <id> | diff | archive [--force] | abandon.
|
|
47
|
+
status | done <id> | undone <id> | diff | archive [--force] | abandon | relink | backlink.
|
|
48
48
|
archive exige verdict (rode verify --deep); abandon descarta sem ADR.
|
|
49
|
+
backlink [--apply]: injeta o backlink pro proposta em design/tarefas/spec órfãos (open + _arquivo).
|
|
50
|
+
wendkeep theme sync Re-aplica o color system (snippet CSS + graph color groups) num vault
|
|
51
|
+
existente — recupera o grafo cinza sem re-init. --vault P.
|
|
49
52
|
wendkeep session <sub> Session registry: list | show <id> | use <id>.
|
|
50
53
|
wendkeep spec <sub> Specs: list | show | effective [--change] [--json] | migrate | rebase.
|
|
51
54
|
wendkeep sensors <sub> list | add <id> "<command>" [--severity --type --report].
|
|
@@ -74,6 +77,9 @@ Usage:
|
|
|
74
77
|
wendkeep renumber-learnings Same for 06-Aprendizados/06-Learnings with APR-<NNNN>-<slug>.
|
|
75
78
|
wendkeep note new --type bug|learning "<título>" Create a numbered derived note (BUG-/APR-NNNN)
|
|
76
79
|
in the month folder and print its vault path. --date YYYY-MM-DD · --vault P.
|
|
80
|
+
wendkeep note relink [--apply] Backfill orphan derived notes (BUG/APR without a source session),
|
|
81
|
+
linking each to the modal source session of its type/month cohort. Dry-run
|
|
82
|
+
by default; --apply writes; skips notes with no sibling to infer from.
|
|
77
83
|
wendkeep lesson add "t" "l" Record a project-local lesson (injected at SessionStart).
|
|
78
84
|
wendkeep validate-memory [path] Validate .brain/CORE.md against the compaction
|
|
79
85
|
protocol (cap 25, 3 sections, no secrets/PII). Uses
|
|
@@ -175,6 +181,11 @@ async function main() {
|
|
|
175
181
|
runSession(rest);
|
|
176
182
|
break;
|
|
177
183
|
}
|
|
184
|
+
case 'theme': {
|
|
185
|
+
const { runTheme } = await import('../src/theme.mjs');
|
|
186
|
+
runTheme(rest);
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
178
189
|
case 'verify': {
|
|
179
190
|
const { runVerify } = await import('../src/verify.mjs');
|
|
180
191
|
runVerify(rest);
|
package/hooks/change-core.mjs
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
// Native change/spec lifecycle in the vault (Pilar B). Vault-facing lib consumed by
|
|
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, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
5
|
-
import { dirname, join } from 'node:path';
|
|
5
|
+
import { dirname, join, relative } from 'node:path';
|
|
6
6
|
import { ensureDir, wikilinkFromRel, monthFolderRelFromDateStr } from './obsidian-common.mjs';
|
|
7
7
|
import { parseSpecsList, promoteSpecs, discoverSpecDeltas, tasksHashOf, captureSpecBaseline, REQ_ID_RE_SRC } from './spec-core.mjs';
|
|
8
|
-
import { getLocale } from './locale.mjs';
|
|
8
|
+
import { getLocale, LOCALES } from './locale.mjs';
|
|
9
9
|
|
|
10
10
|
export const ARCHIVE_DIR = '_arquivo';
|
|
11
11
|
const POINTER = '.brain/CURRENT_CHANGE.md';
|
|
@@ -39,12 +39,21 @@ specs: []
|
|
|
39
39
|
|
|
40
40
|
${en ? '## Why\n\n(reason for the change)\n\n## What changes\n\n(scope of the change)' : '## Por quê\n\n(motivo da mudança)\n\n## O que muda\n\n(escopo da mudança)'}
|
|
41
41
|
`;
|
|
42
|
+
// Hub backlink: design/tarefas link the change's proposta so no generated artifact is a
|
|
43
|
+
// graph island. Full-path (never basename — proposta/design exist in every change) so the
|
|
44
|
+
// archive's rewriteChangeLinks retargets it to _arquivo when the change moves.
|
|
45
|
+
const changesFolder = (LOCALES[locale] || LOCALES['pt-BR']).folders.changes;
|
|
46
|
+
const hubLink = `> ${en ? 'Change:' : 'Mudança:'} [[${changesFolder}/${slug}/proposta]]`;
|
|
42
47
|
const design = `# ${slug} — design
|
|
43
48
|
|
|
49
|
+
${hubLink}
|
|
50
|
+
|
|
44
51
|
${en ? '## Approach\n\n(technical approach)' : '## Abordagem\n\n(abordagem técnica)'}
|
|
45
52
|
`;
|
|
46
53
|
const tarefas = `# ${slug} — ${en ? 'tasks' : 'tarefas'}
|
|
47
54
|
|
|
55
|
+
${hubLink}
|
|
56
|
+
|
|
48
57
|
- [ ] 1.1 ${en ? '(first task)' : '(primeira tarefa)'}
|
|
49
58
|
`;
|
|
50
59
|
const reqHeading = en ? 'Requirement' : 'Requisito';
|
|
@@ -419,6 +428,10 @@ export function archiveChange(vaultBase, slug, { gate = gateGreen, dateStr, adrN
|
|
|
419
428
|
let reqIds = [];
|
|
420
429
|
try { reqIds = [...new Set(parseTasks(readFileSync(join(src, 'tarefas.md'), 'utf8')).flatMap((t) => t.reqs ?? []))]; } catch { /* sem tarefas */ }
|
|
421
430
|
|
|
431
|
+
// Backlink dos artefatos escritos à mão (spec.md) ANTES do move — o rewriteChangeLinks
|
|
432
|
+
// abaixo retargeta o wikilink pro _arquivo junto com os demais. Fail-quiet.
|
|
433
|
+
try { healSpecBacklinks(src, vaultBase); } catch { /* heal é bônus */ }
|
|
434
|
+
|
|
422
435
|
ensureDir(join(vaultBase, chDir, ARCHIVE_DIR));
|
|
423
436
|
try {
|
|
424
437
|
renameSync(src, destAbs);
|
|
@@ -512,6 +525,96 @@ function rewriteChangeLinks(vaultBase, fromRel, toRel) {
|
|
|
512
525
|
return touched;
|
|
513
526
|
}
|
|
514
527
|
|
|
528
|
+
// --- backlinks de artefato -> hub proposta (grafo conectado) -------------------
|
|
529
|
+
// design/tarefas nascem linkando o hub (renderChangeScaffold); spec.md é escrito à mão,
|
|
530
|
+
// então recebe o backlink por auto-heal (verify/archive) + backfill dos vaults existentes.
|
|
531
|
+
function backlinkLabel(loc) {
|
|
532
|
+
return loc.id === 'en' ? 'Change:' : 'Mudança:';
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// Injeta a linha de backlink logo abaixo do primeiro heading — mas NUNCA acima de um bloco
|
|
536
|
+
// de frontmatter YAML: prepender antes do `---` quebra o frontmatter (Obsidian para de ler
|
|
537
|
+
// cssclasses/tags). Ordem: preserva o frontmatter, depois cai abaixo do 1º heading do corpo,
|
|
538
|
+
// senão no topo do corpo.
|
|
539
|
+
function insertBacklink(content, backlinkLine) {
|
|
540
|
+
let head = '';
|
|
541
|
+
let body = content;
|
|
542
|
+
const fm = content.match(/^---\n[\s\S]*?\n---\n?/);
|
|
543
|
+
if (fm) { head = fm[0]; body = content.slice(fm[0].length); }
|
|
544
|
+
const h = body.match(/^\s*#[^\n]*\n/);
|
|
545
|
+
if (h) {
|
|
546
|
+
const rest = body.slice(h[0].length).replace(/^\n+/, '');
|
|
547
|
+
return `${head}${h[0]}\n${backlinkLine}\n\n${rest}`;
|
|
548
|
+
}
|
|
549
|
+
const rest = body.replace(/^\n+/, '');
|
|
550
|
+
return `${head}${backlinkLine}\n\n${rest}`;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// Auto-heal (0.47): garante o backlink pro proposta em cada specs/<cap>/spec.md do change.
|
|
554
|
+
// Idempotente (pula quem já tem o link exato). Chamado no verify e no archive (antes do move,
|
|
555
|
+
// pra o rewriteChangeLinks retargetar pro _arquivo). Retorna quantos arquivos healou.
|
|
556
|
+
export function healSpecBacklinks(changeDir, vaultBase) {
|
|
557
|
+
const loc = getLocale(vaultBase);
|
|
558
|
+
const propRel = `${relative(vaultBase, changeDir).replaceAll('\\', '/')}/proposta`;
|
|
559
|
+
const link = `[[${propRel}]]`;
|
|
560
|
+
const line = `> ${backlinkLabel(loc)} ${link}`;
|
|
561
|
+
let caps = [];
|
|
562
|
+
try { caps = readdirSync(join(changeDir, 'specs'), { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name); }
|
|
563
|
+
catch { return 0; }
|
|
564
|
+
let healed = 0;
|
|
565
|
+
for (const cap of caps) {
|
|
566
|
+
const p = join(changeDir, 'specs', cap, 'spec.md');
|
|
567
|
+
let c;
|
|
568
|
+
try { c = readFileSync(p, 'utf8'); } catch { continue; }
|
|
569
|
+
if (c.includes(link)) continue;
|
|
570
|
+
try { writeFileSync(p, insertBacklink(c, line), 'utf8'); healed += 1; } catch { /* readonly — segue */ }
|
|
571
|
+
}
|
|
572
|
+
return healed;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// Backfill (0.47): cura design/tarefas/spec órfãos em changes já existentes — open e _arquivo.
|
|
576
|
+
// Aponta cada artefato pro proposta no seu local atual (arquivado usa o path do _arquivo).
|
|
577
|
+
// Dry-run por default; `apply` escreve. Idempotente (pula quem já linka o proposta certo).
|
|
578
|
+
export function backfillArtifactLinks(vaultBase, { apply = false } = {}) {
|
|
579
|
+
const loc = getLocale(vaultBase);
|
|
580
|
+
const chDir = loc.folders.changes;
|
|
581
|
+
const label = backlinkLabel(loc);
|
|
582
|
+
const base = join(vaultBase, chDir);
|
|
583
|
+
const dirs = [];
|
|
584
|
+
try {
|
|
585
|
+
for (const name of readdirSync(base)) {
|
|
586
|
+
if (name === ARCHIVE_DIR) continue;
|
|
587
|
+
const d = join(base, name);
|
|
588
|
+
if (existsSync(join(d, 'proposta.md'))) dirs.push(d);
|
|
589
|
+
}
|
|
590
|
+
} catch { /* sem changes */ }
|
|
591
|
+
try {
|
|
592
|
+
for (const name of readdirSync(join(base, ARCHIVE_DIR))) {
|
|
593
|
+
const d = join(base, ARCHIVE_DIR, name);
|
|
594
|
+
if (existsSync(join(d, 'proposta.md'))) dirs.push(d);
|
|
595
|
+
}
|
|
596
|
+
} catch { /* sem arquivo */ }
|
|
597
|
+
|
|
598
|
+
const changed = [];
|
|
599
|
+
for (const dir of dirs) {
|
|
600
|
+
const propRel = `${relative(vaultBase, dir).replaceAll('\\', '/')}/proposta`;
|
|
601
|
+
const link = `[[${propRel}]]`;
|
|
602
|
+
const line = `> ${label} ${link}`;
|
|
603
|
+
let caps = [];
|
|
604
|
+
try { caps = readdirSync(join(dir, 'specs'), { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => join('specs', e.name, 'spec.md')); }
|
|
605
|
+
catch { /* sem specs */ }
|
|
606
|
+
for (const rel of ['design.md', 'tarefas.md', ...caps]) {
|
|
607
|
+
const p = join(dir, rel);
|
|
608
|
+
let c;
|
|
609
|
+
try { c = readFileSync(p, 'utf8'); } catch { continue; }
|
|
610
|
+
if (c.includes(link)) continue;
|
|
611
|
+
if (apply) { try { writeFileSync(p, insertBacklink(c, line), 'utf8'); } catch { continue; } }
|
|
612
|
+
changed.push(relative(vaultBase, p).replaceAll('\\', '/'));
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
return { applied: apply, scanned: dirs.length, changed };
|
|
616
|
+
}
|
|
617
|
+
|
|
515
618
|
// Cura retroativa (vaults pré-0.35): wikilinks para changes que já moveram sem reescrita.
|
|
516
619
|
// Dry-run por default; match por slug no nome datado do archive (`<data>-<slug>[-abandonada]`);
|
|
517
620
|
// ambíguo (mesmo slug arquivado 2×) é reportado e pulado — nunca chuta.
|
package/hooks/linked-notes.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { existsSync, readFileSync, readdirSync, writeFileSync } from 'fs';
|
|
3
|
-
import { basename, join } from 'path';
|
|
3
|
+
import { basename, join, relative } from 'path';
|
|
4
4
|
import {
|
|
5
5
|
monthFolderRelFromDateStr,
|
|
6
6
|
derivedContentKey,
|
|
@@ -63,6 +63,77 @@ function sessionYamlLinks(sessionRel) {
|
|
|
63
63
|
].join('\n');
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
// --- note relink: backfill de proveniência das notas derivadas órfãs (DRV-9) -----
|
|
67
|
+
// Nota derivada legada (BUG/APR criada por wendkeep antigo) nasce sem `source:` de sessão —
|
|
68
|
+
// ilha no grafo. A origem não está registrada nela, mas os irmãos NÃO-órfãos do mesmo tipo
|
|
69
|
+
// carregam a sessão-fonte real: o órfão herda a sessão MODAL (mais comum) do seu (tipo, mês).
|
|
70
|
+
|
|
71
|
+
// Extrai a sessão do primeiro `source: - [[...]]` do frontmatter (vazio se não houver).
|
|
72
|
+
function sourceSessionOf(content) {
|
|
73
|
+
const m = content.match(/^source:\s*\n\s*-\s*"?\[\[([^\]"|]+)/m);
|
|
74
|
+
return m ? m[1].trim() : '';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Injeta source+related antes do `---` de fechamento do frontmatter. Sem frontmatter, no-op.
|
|
78
|
+
function insertSourceLinks(content, sessionRel) {
|
|
79
|
+
const m = content.match(/^(---\n[\s\S]*?\n)(---\n)/);
|
|
80
|
+
if (!m) return content;
|
|
81
|
+
return `${m[1]}${sessionYamlLinks(sessionRel)}\n${m[2]}${content.slice(m[0].length)}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function modalKey(counts) {
|
|
85
|
+
const entries = Object.entries(counts || {});
|
|
86
|
+
if (!entries.length) return '';
|
|
87
|
+
entries.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
|
|
88
|
+
return entries[0][0];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function relinkDerivedNotes(vaultBase, { apply = false } = {}) {
|
|
92
|
+
const loc = getLocale(vaultBase);
|
|
93
|
+
const monthOf = (abs) => relative(vaultBase, abs).replaceAll('\\', '/').split('/').slice(0, 3).join('/');
|
|
94
|
+
const linked = [];
|
|
95
|
+
const skipped = [];
|
|
96
|
+
for (const [folderKey, prefix] of [['bugs', 'BUG'], ['learnings', 'APR']]) {
|
|
97
|
+
const root = join(vaultBase, loc.folders[folderKey]);
|
|
98
|
+
const files = [];
|
|
99
|
+
const walk = (dir) => {
|
|
100
|
+
let entries;
|
|
101
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
102
|
+
for (const e of entries) {
|
|
103
|
+
const p = join(dir, e.name);
|
|
104
|
+
if (e.isDirectory()) walk(p);
|
|
105
|
+
else if (e.name.endsWith('.md') && e.name.startsWith(`${prefix}-`)) files.push(p);
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
walk(root);
|
|
109
|
+
const byMonth = {};
|
|
110
|
+
const typeWide = {};
|
|
111
|
+
const orphans = [];
|
|
112
|
+
for (const p of files) {
|
|
113
|
+
let c;
|
|
114
|
+
try { c = readFileSync(p, 'utf8'); } catch { continue; }
|
|
115
|
+
const src = sourceSessionOf(c);
|
|
116
|
+
if (src) {
|
|
117
|
+
(byMonth[monthOf(p)] ??= {})[src] = ((byMonth[monthOf(p)] || {})[src] || 0) + 1;
|
|
118
|
+
typeWide[src] = (typeWide[src] || 0) + 1;
|
|
119
|
+
} else {
|
|
120
|
+
orphans.push({ p, c });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
for (const o of orphans) {
|
|
124
|
+
const rel = relative(vaultBase, o.p).replaceAll('\\', '/');
|
|
125
|
+
const session = modalKey(byMonth[monthOf(o.p)]) || modalKey(typeWide);
|
|
126
|
+
if (!session) { skipped.push({ file: rel, reason: 'sem irmão com source para inferir' }); continue; }
|
|
127
|
+
if (apply) {
|
|
128
|
+
try { writeFileSync(o.p, insertSourceLinks(o.c, session), 'utf8'); }
|
|
129
|
+
catch { skipped.push({ file: rel, reason: 'escrita falhou' }); continue; }
|
|
130
|
+
}
|
|
131
|
+
linked.push({ file: rel, session: basename(session) });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return { applied: apply, linked, skipped };
|
|
135
|
+
}
|
|
136
|
+
|
|
66
137
|
function extractIssueRefs(tx) {
|
|
67
138
|
const text = [
|
|
68
139
|
assistantText(tx),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wendkeep",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.48.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
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
archiveChange,
|
|
15
15
|
abandonChange,
|
|
16
16
|
relinkChanges,
|
|
17
|
+
backfillArtifactLinks,
|
|
17
18
|
scaffoldPlaceholders,
|
|
18
19
|
} from '../hooks/change-core.mjs';
|
|
19
20
|
import { evaluateGate, requiredSensors } from '../hooks/sensors-core.mjs';
|
|
@@ -308,6 +309,15 @@ export function runChange(argv) {
|
|
|
308
309
|
process.exit(0);
|
|
309
310
|
}
|
|
310
311
|
|
|
312
|
+
if (sub === 'backlink') {
|
|
313
|
+
const r = backfillArtifactLinks(vaultBase, { apply: rest.includes('--apply') });
|
|
314
|
+
if (rest.includes('--json')) { process.stdout.write(`${JSON.stringify(r, null, 2)}\n`); process.exit(0); }
|
|
315
|
+
process.stdout.write(`${r.changed.length} artefato(s) órfão(s) em ${r.scanned} change(s)${r.applied ? ' · reescritos' : ''}\n`);
|
|
316
|
+
for (const f of r.changed) process.stdout.write(` ${f}\n`);
|
|
317
|
+
if (!r.applied && r.changed.length) process.stdout.write('\ndry-run — nada escrito. Rode com --apply para injetar os backlinks.\n');
|
|
318
|
+
process.exit(0);
|
|
319
|
+
}
|
|
320
|
+
|
|
311
321
|
if (sub === 'abandon') {
|
|
312
322
|
const slug = slugArg() || activeChange(vaultBase);
|
|
313
323
|
if (!slug) { process.stderr.write('wendkeep change abandon: missing <slug> and no active change\n'); process.exit(2); }
|
|
@@ -317,6 +327,6 @@ export function runChange(argv) {
|
|
|
317
327
|
process.exit(0);
|
|
318
328
|
}
|
|
319
329
|
|
|
320
|
-
process.stderr.write(`wendkeep change: unknown subcommand "${sub}". Known: new, use, continue, list, show, status, done, undone, diff, archive, abandon, relink.\n`);
|
|
330
|
+
process.stderr.write(`wendkeep change: unknown subcommand "${sub}". Known: new, use, continue, list, show, status, done, undone, diff, archive, abandon, relink, backlink.\n`);
|
|
321
331
|
process.exit(2);
|
|
322
332
|
}
|
package/src/init.mjs
CHANGED
|
@@ -31,13 +31,7 @@ import {
|
|
|
31
31
|
import { renderVaultReadme } from './vault-readme.mjs';
|
|
32
32
|
import { seedVaultViews } from './vault-views.mjs';
|
|
33
33
|
import { canInteractiveSelect, selectCompanionsInteractive } from './companion-select.mjs';
|
|
34
|
-
import {
|
|
35
|
-
SNIPPET_NAME,
|
|
36
|
-
renderColorSnippetCss,
|
|
37
|
-
mergeAppearance,
|
|
38
|
-
graphColorGroups,
|
|
39
|
-
mergeGraphColorGroups,
|
|
40
|
-
} from './vault-theme.mjs';
|
|
34
|
+
import { syncVaultTheme } from './theme.mjs';
|
|
41
35
|
import { renderCoreSkeleton, renderCompactionProtocol } from './validate-core.mjs';
|
|
42
36
|
import { seedDefinitions, syncDefs } from './sync-defs.mjs';
|
|
43
37
|
import { seedWkSkills } from './skills-seed.mjs';
|
|
@@ -261,33 +255,11 @@ function runCavemanInstaller(log) {
|
|
|
261
255
|
}
|
|
262
256
|
}
|
|
263
257
|
|
|
264
|
-
// Install the vault color system into .obsidian: write wendkeep's CSS snippet,
|
|
265
|
-
//
|
|
266
|
-
// Unparseable user JSON is left untouched
|
|
258
|
+
// Install the vault color system into .obsidian: write wendkeep's CSS snippet, enable it
|
|
259
|
+
// (non-destructive), and add graph color groups. Shared installer lives in theme.mjs so
|
|
260
|
+
// `init` and `theme sync` never drift. Unparseable user JSON is left untouched.
|
|
267
261
|
function installVaultColors(vaultPath) {
|
|
268
|
-
|
|
269
|
-
const obsidianDir = join(vaultPath, '.obsidian');
|
|
270
|
-
const snippetsDir = join(obsidianDir, 'snippets');
|
|
271
|
-
mkdirSync(snippetsDir, { recursive: true });
|
|
272
|
-
writeFileSync(join(snippetsDir, `${SNIPPET_NAME}.css`), renderColorSnippetCss(loc), 'utf8');
|
|
273
|
-
|
|
274
|
-
let enabled = false;
|
|
275
|
-
const appPath = join(obsidianDir, 'appearance.json');
|
|
276
|
-
const appRead = readJsonSafe(appPath);
|
|
277
|
-
if (appRead.ok) {
|
|
278
|
-
writeJson(appPath, mergeAppearance(appRead.data || {}, SNIPPET_NAME));
|
|
279
|
-
enabled = true;
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
let groups = 0;
|
|
283
|
-
const graphPath = join(obsidianDir, 'graph.json');
|
|
284
|
-
const graphRead = readJsonSafe(graphPath);
|
|
285
|
-
if (graphRead.ok) {
|
|
286
|
-
const merged = mergeGraphColorGroups(graphRead.data || {}, graphColorGroups(loc));
|
|
287
|
-
writeJson(graphPath, merged);
|
|
288
|
-
groups = graphColorGroups(loc).length;
|
|
289
|
-
}
|
|
290
|
-
return `snippet ${SNIPPET_NAME}.css${enabled ? ' (enabled)' : ' (enable by hand: appearance.json unreadable)'} + ${groups} graph group(s)`;
|
|
262
|
+
return syncVaultTheme(vaultPath).summary;
|
|
291
263
|
}
|
|
292
264
|
|
|
293
265
|
// --- main -------------------------------------------------------------------
|
package/src/note.mjs
CHANGED
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
toVaultRelative,
|
|
15
15
|
} from '../hooks/obsidian-common.mjs';
|
|
16
16
|
import { getLocale } from '../hooks/locale.mjs';
|
|
17
|
-
import { buildManualBugNote, buildManualLearningNote } from '../hooks/linked-notes.mjs';
|
|
17
|
+
import { buildManualBugNote, buildManualLearningNote, relinkDerivedNotes } from '../hooks/linked-notes.mjs';
|
|
18
18
|
|
|
19
19
|
const TYPES = {
|
|
20
20
|
bug: { folderKey: 'bugs', prefix: 'BUG', build: buildManualBugNote },
|
|
@@ -30,8 +30,23 @@ function opt(argv, name) {
|
|
|
30
30
|
|
|
31
31
|
export function runNote(argv) {
|
|
32
32
|
const [sub, ...rest] = argv;
|
|
33
|
+
|
|
34
|
+
if (sub === 'relink') {
|
|
35
|
+
const vaultRaw = opt(rest, '--vault') || process.env.OBSIDIAN_VAULT_PATH;
|
|
36
|
+
if (!vaultRaw) { process.stderr.write('wendkeep note relink: no vault (--vault or OBSIDIAN_VAULT_PATH).\n'); process.exit(2); }
|
|
37
|
+
const vaultBase = isAbsolute(vaultRaw) ? vaultRaw : resolve(process.cwd(), vaultRaw);
|
|
38
|
+
if (!existsSync(vaultBase)) { process.stderr.write(`wendkeep note relink: vault not found: ${vaultBase}\n`); process.exit(2); }
|
|
39
|
+
const r = relinkDerivedNotes(vaultBase, { apply: rest.includes('--apply') });
|
|
40
|
+
if (rest.includes('--json')) { process.stdout.write(`${JSON.stringify(r, null, 2)}\n`); process.exit(0); }
|
|
41
|
+
process.stdout.write(`${r.linked.length} nota(s) derivada(s) órfã(s)${r.applied ? ' linkadas' : ' seriam linkadas'}\n`);
|
|
42
|
+
for (const l of r.linked) process.stdout.write(` ${l.file} -> ${l.session}\n`);
|
|
43
|
+
for (const s of r.skipped) process.stdout.write(` pulado: ${s.file} (${s.reason})\n`);
|
|
44
|
+
if (!r.applied && r.linked.length) process.stdout.write('\ndry-run — nada escrito. Rode com --apply para injetar os backlinks.\n');
|
|
45
|
+
process.exit(0);
|
|
46
|
+
}
|
|
47
|
+
|
|
33
48
|
if (sub !== 'new') {
|
|
34
|
-
process.stderr.write('wendkeep note: subcomando desconhecido (use `note new --type bug|learning "<título>"`).\n');
|
|
49
|
+
process.stderr.write('wendkeep note: subcomando desconhecido (use `note new --type bug|learning "<título>"` ou `note relink [--apply]`).\n');
|
|
35
50
|
process.exit(2);
|
|
36
51
|
}
|
|
37
52
|
|
package/src/theme.mjs
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// `wendkeep theme sync` — re-apply the vault color system into .obsidian on an EXISTING
|
|
2
|
+
// vault, idempotently: rewrite the CSS snippet, enable it (non-destructive), and (re)merge
|
|
3
|
+
// the graph color groups. The installer is shared with `wendkeep init` so the two never drift.
|
|
4
|
+
//
|
|
5
|
+
// Why a re-sync exists: .obsidian/graph.json is owned by Obsidian, which can drop the
|
|
6
|
+
// colorGroups wendkeep wrote (vault init'd by an older version, or Obsidian rewriting the
|
|
7
|
+
// file). Without a re-apply path the only fix was a full re-init. This command restores the
|
|
8
|
+
// graph colors without touching anything else.
|
|
9
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
10
|
+
import { isAbsolute, join, resolve } from 'node:path';
|
|
11
|
+
import { getLocale } from '../hooks/locale.mjs';
|
|
12
|
+
import {
|
|
13
|
+
SNIPPET_NAME,
|
|
14
|
+
renderColorSnippetCss,
|
|
15
|
+
mergeAppearance,
|
|
16
|
+
graphColorGroups,
|
|
17
|
+
mergeGraphColorGroups,
|
|
18
|
+
} from './vault-theme.mjs';
|
|
19
|
+
|
|
20
|
+
// { ok, data }. ok=false means the file EXISTS but is unparseable — never clobber it.
|
|
21
|
+
// Absent -> ok=true, data=null (we create it).
|
|
22
|
+
function readJsonSafe(path) {
|
|
23
|
+
if (!existsSync(path)) return { ok: true, data: null };
|
|
24
|
+
try { return { ok: true, data: JSON.parse(readFileSync(path, 'utf8')) }; }
|
|
25
|
+
catch { return { ok: false, data: null }; }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function writeJson(path, obj) {
|
|
29
|
+
writeFileSync(path, `${JSON.stringify(obj, null, 2)}\n`, 'utf8');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Pure-ish installer (does file I/O, no process exit). Returns a short summary + counts.
|
|
33
|
+
// Consumed by both `wendkeep init` (installVaultColors) and `wendkeep theme sync`.
|
|
34
|
+
export function syncVaultTheme(vaultPath) {
|
|
35
|
+
const loc = getLocale(vaultPath);
|
|
36
|
+
const obsidianDir = join(vaultPath, '.obsidian');
|
|
37
|
+
const snippetsDir = join(obsidianDir, 'snippets');
|
|
38
|
+
mkdirSync(snippetsDir, { recursive: true });
|
|
39
|
+
writeFileSync(join(snippetsDir, `${SNIPPET_NAME}.css`), renderColorSnippetCss(loc), 'utf8');
|
|
40
|
+
|
|
41
|
+
let enabled = false;
|
|
42
|
+
const appPath = join(obsidianDir, 'appearance.json');
|
|
43
|
+
const appRead = readJsonSafe(appPath);
|
|
44
|
+
if (appRead.ok) {
|
|
45
|
+
writeJson(appPath, mergeAppearance(appRead.data || {}, SNIPPET_NAME));
|
|
46
|
+
enabled = true;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let groups = 0;
|
|
50
|
+
const graphPath = join(obsidianDir, 'graph.json');
|
|
51
|
+
const graphRead = readJsonSafe(graphPath);
|
|
52
|
+
if (graphRead.ok) {
|
|
53
|
+
writeJson(graphPath, mergeGraphColorGroups(graphRead.data || {}, graphColorGroups(loc)));
|
|
54
|
+
groups = graphColorGroups(loc).length;
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
enabled,
|
|
58
|
+
groups,
|
|
59
|
+
summary: `snippet ${SNIPPET_NAME}.css${enabled ? ' (enabled)' : ' (enable by hand: appearance.json unreadable)'} + ${groups} graph group(s)`,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function resolveVault(argv) {
|
|
64
|
+
let vault;
|
|
65
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
66
|
+
const a = argv[i];
|
|
67
|
+
if (a === '--vault') vault = argv[++i];
|
|
68
|
+
else if (a.startsWith('--vault=')) vault = a.slice(8);
|
|
69
|
+
}
|
|
70
|
+
const base = vault || process.env.OBSIDIAN_VAULT_PATH;
|
|
71
|
+
if (!base) {
|
|
72
|
+
process.stderr.write('wendkeep theme: no vault. Pass --vault <path> or set OBSIDIAN_VAULT_PATH.\n');
|
|
73
|
+
process.exit(2);
|
|
74
|
+
}
|
|
75
|
+
return isAbsolute(base) ? base : resolve(process.cwd(), base);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function runTheme(argv) {
|
|
79
|
+
const [sub, ...rest] = argv;
|
|
80
|
+
if (sub !== 'sync') {
|
|
81
|
+
process.stderr.write(`wendkeep theme: unknown subcommand "${sub || ''}". Known: sync.\n`);
|
|
82
|
+
process.exit(2);
|
|
83
|
+
}
|
|
84
|
+
const vaultBase = resolveVault(rest);
|
|
85
|
+
const r = syncVaultTheme(vaultBase);
|
|
86
|
+
process.stdout.write(`theme sync: ${r.summary}\n`);
|
|
87
|
+
process.stdout.write('feche o Obsidian antes de rodar e reabra depois — ele é dono do graph.json e recarrega as cores na abertura.\n');
|
|
88
|
+
process.exit(0);
|
|
89
|
+
}
|
package/src/vault-theme.mjs
CHANGED
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
|
|
9
9
|
export const SNIPPET_NAME = 'wendkeep-colors';
|
|
10
10
|
|
|
11
|
+
// Readable line width for note content — Obsidian's default (~700px) wastes wide monitors.
|
|
12
|
+
// Ajuste aqui (1200px / 1400px / 100vw) para mudar em todos os vaults de uma vez.
|
|
13
|
+
export const READABLE_LINE_WIDTH = '1400px';
|
|
14
|
+
|
|
11
15
|
import { LOCALES } from '../hooks/locale.mjs';
|
|
12
16
|
const PT = LOCALES['pt-BR'];
|
|
13
17
|
|
|
@@ -86,6 +90,13 @@ ${rootVars}
|
|
|
86
90
|
.theme-dark { --us-line-tint: 44%; }
|
|
87
91
|
.theme-light { --us-line-tint: 36%; }
|
|
88
92
|
|
|
93
|
+
/* --- Readable line width: wider than Obsidian's ~700px default ------------ */
|
|
94
|
+
/* !important porque o Obsidian declara --file-line-width com specificity alta; sem ele
|
|
95
|
+
a regra do body não vence e a linha volta pro ~700px. */
|
|
96
|
+
body {
|
|
97
|
+
--file-line-width: ${READABLE_LINE_WIDTH} !important;
|
|
98
|
+
}
|
|
99
|
+
|
|
89
100
|
/* --- File explorer: accented folders ------------------------------------- */
|
|
90
101
|
${FX} {
|
|
91
102
|
border-left: 3px solid transparent;
|
package/src/verify.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// the VAULT. Writes 08-Mudanças/<slug>/evidencia.json; exit 1 if a critical sensor is red.
|
|
4
4
|
import { readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
5
5
|
import { isAbsolute, join, resolve } from 'node:path';
|
|
6
|
-
import { parseTasks, activeChange, appendFixTasks } from '../hooks/change-core.mjs';
|
|
6
|
+
import { parseTasks, activeChange, appendFixTasks, healSpecBacklinks } from '../hooks/change-core.mjs';
|
|
7
7
|
import { loadSensorsDetailed, findProjectRoot, requiredSensors, runSensors, evaluateGate } from '../hooks/sensors-core.mjs';
|
|
8
8
|
import {
|
|
9
9
|
buildEffectiveRequirementPackage,
|
|
@@ -41,6 +41,9 @@ export function runVerify(argv) {
|
|
|
41
41
|
try { tarefas = readFileSync(join(changeDir, 'tarefas.md'), 'utf8'); }
|
|
42
42
|
catch { process.stderr.write(`wendkeep verify: change not found: ${slug}\n`); process.exit(2); }
|
|
43
43
|
|
|
44
|
+
// Backlink dos specs escritos à mão pro hub proposta (grafo conectado). Idempotente, fail-quiet.
|
|
45
|
+
try { healSpecBacklinks(changeDir, vaultBase); } catch { /* heal é bônus */ }
|
|
46
|
+
|
|
44
47
|
const ids = requiredSensors(parseTasks(tarefas));
|
|
45
48
|
const loaded = loadSensorsDetailed(projectRoot);
|
|
46
49
|
if (loaded.error) {
|