wendkeep 0.46.1 → 0.47.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,63 @@ 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.47.0] — 2026-07-23
8
+
9
+ ### Added
10
+
11
+ - **Grafo do vault conectado: todo artefato de change linka o hub.** `change new` gera
12
+ `design.md` e `tarefas.md` com um backlink full-path pro `proposta.md` da própria change —
13
+ antes nasciam sem nenhum wikilink, ilhas no grafo do Obsidian (num vault real: 48 de 63
14
+ `.md` de `08-Mudanças` órfãos). O full-path (nunca basename, ambíguo entre changes) sobrevive
15
+ ao archive: o `rewriteChangeLinks` retargeta pro `_arquivo` junto com os demais links.
16
+ - **Auto-heal do backlink em `spec.md`.** O delta de spec é escrito à mão, então `verify` e
17
+ `archive` passam a injetar o backlink pro proposta em cada `specs/<capability>/spec.md` que
18
+ estiver sem — idempotente. Nenhum artefato de change fica desconectado do plano↔sessão.
19
+ - **`wendkeep change backlink [--apply]`** — backfill dos artefatos órfãos que já existem no
20
+ vault (changes open e arquivadas), apontando cada `design`/`tarefas`/`spec` pro proposta no
21
+ seu local atual. Dry-run por default; `--apply` escreve. Idempotente.
22
+ - **`wendkeep theme sync`** — re-aplica o color system (snippet CSS + graph color groups) num
23
+ vault existente, sem re-`init`. Recupera o grafo que ficou cinza quando o Obsidian (dono do
24
+ `graph.json`) zera os `colorGroups`. O instalador virou módulo compartilhado com o `init`,
25
+ então os dois nunca divergem.
26
+ - **Snippet define `--file-line-width`.** O CSS gerado passa a definir a largura de linha
27
+ legível (1400px, ajustável) sobre o `body` com `!important` — antes era edição manual em
28
+ cada vault. As capabilities `change-graph` e `vault-theme` documentam o contrato em `07-Specs`.
29
+
30
+ ## [0.46.2] — 2026-07-19
31
+
32
+ ### Fixed
33
+
34
+ - **Rollout de subagent do Codex virava sessão top-level no import.** Um subagent do Codex
35
+ não é um arquivo em `<transcript>/subagents/` — é um rollout **irmão** no
36
+ `~/.codex/sessions/`, cujo `session_meta` declara `source.subagent` e aponta pro pai via
37
+ `parent_thread_id`. O import ignorava o marcador e materializava o subagent como uma
38
+ "sessão" própria, com o contexto do pai inteiro replicado (subagent herda o histórico) —
39
+ no caso real, uma nota fantasma de 23 turnos duplicando a conversa da sessão-mãe. Agora a
40
+ descoberta expõe o marcador, o import conta subagents à parte no relatório (nunca em
41
+ `skipped`) e nenhuma nota é criada. A entrada de registry que o import antigo escreveu
42
+ para um subagent é removida — self-healing do nosso próprio dado errado, nunca limpeza
43
+ genérica: entrada com o mesmo id mas transcript diferente é preservada.
44
+ - **Telemetria de subagent do Codex nunca chegava à sessão-mãe — nem ao vivo.** A descoberta
45
+ (`collectSubagentUsage`) era Claude-shaped: procurava um diretório `subagents/` ao lado do
46
+ transcript, que no Codex não existe. Resultado: toda sessão Codex fechava com
47
+ `subagents_count: 0`, tanto no import quanto no hook vivo `SubagentStop` wirado na 0.46.0
48
+ — o custo dos subagents simplesmente não existia no vault. A nova
49
+ `collectCodexSubagentUsage` acha os irmãos por `parent_thread_id` (no dia do rollout pai e
50
+ no dia seguinte, cobrindo spawn que cruza a meia-noite UTC — o caso real passou a seis
51
+ minutos disso) e devolve o mesmo agregado que o writer já consome: vivo e import passam a
52
+ atribuir pelo mesmo caminho.
53
+ - **O bloco injetado ainda aparecia como fala do usuário no "Contexto conversado".** O fix
54
+ da 0.46.1 protegeu o título, mas a linha `**Usuário:**` da iteração vinha de um segundo
55
+ filtro (`shouldIgnoreUserText`) que duplicava por cópia a lista do `isBootstrapPrompt` — e
56
+ as cópias divergiram. O filtro agora delega: um lugar só para o próximo bloco que o
57
+ harness inventar.
58
+ - Bytes NUL literais em `hooks/token-usage.mjs` e `hooks/subagent-usage.mjs` escapados —
59
+ mesma classe do fix de `taxonomy.mjs` (#7): o byte cru fazia o `file` classificar o fonte
60
+ como binário e o ripgrep pulá-lo em silêncio. Entrou `tests/source-hygiene.test.mjs`
61
+ barrando byte de controle em qualquer fonte publicado; ele pegou uma quarta ocorrência
62
+ introduzida durante esta própria mudança.
63
+
7
64
  ## [0.46.1] — 2026-07-19
8
65
 
9
66
  ### 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.
@@ -252,9 +252,13 @@ npx wendkeep change archive dark-mode # gate: sensors + verdict + no op
252
252
  ```
253
253
 
254
254
  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*. A change that names
256
- no `[req:]` still runs `verify --deep`, but skips the `wk-verify` reading pass: the
257
- command writes a trivial verdict on its own and the sensor gate is the real proof.
255
+ Obsidian graph now links *session ↔ change ↔ requirement ↔ decision*. Every generated
256
+ artifact (`design`/`tarefas`) is born linking its change's `proposta` hub, and hand-authored
257
+ `spec.md` deltas are auto-linked on `verify`/`archive` no change artifact is a graph island.
258
+ To backfill older changes that predate this, run `wendkeep change backlink --apply` (dry-run
259
+ without `--apply`). A change that names no `[req:]` still runs `verify --deep`, but skips the
260
+ `wk-verify` reading pass: the command writes a trivial verdict on its own and the sensor gate
261
+ is the real proof.
258
262
 
259
263
  ## How it works
260
264
 
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].
@@ -175,6 +178,11 @@ async function main() {
175
178
  runSession(rest);
176
179
  break;
177
180
  }
181
+ case 'theme': {
182
+ const { runTheme } = await import('../src/theme.mjs');
183
+ runTheme(rest);
184
+ break;
185
+ }
178
186
  case 'verify': {
179
187
  const { runVerify } = await import('../src/verify.mjs');
180
188
  runVerify(rest);
@@ -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.
@@ -17,7 +17,7 @@ import {
17
17
  import { buildSessionContent, allocateSessionPath } from './session-start.mjs';
18
18
  import { createLinkedNotes } from './linked-notes.mjs';
19
19
  import { updateSessionObservability } from './session-observability.mjs';
20
- import { readSessionRegistry, upsertSessionRegistry, formatLocalIso, formatDate, providerMeta, isBootstrapPrompt } from './obsidian-common.mjs';
20
+ import { readSessionRegistry, upsertSessionRegistry, removeSessionRegistryEntry, formatLocalIso, formatDate, providerMeta, isBootstrapPrompt } from './obsidian-common.mjs';
21
21
  import { getLocale } from './locale.mjs';
22
22
  import { captureProseDecisions } from './decision-capture.mjs';
23
23
 
@@ -192,7 +192,12 @@ export function discoverCodexTranscripts(projectPath, fromDir) {
192
192
  const meta = readSessionMeta(path);
193
193
  if (!meta || !meta.id) continue;
194
194
  if (projectPath && !cwdMatchesProject(meta.cwd, projectPath)) continue;
195
- transcripts.push({ path, sessionId: meta.id, cwd: meta.cwd || '' });
195
+ // A subagent thread's rollout is a SIBLING file of its parent's — same dir, own id. The
196
+ // meta says what the file IS; ignoring it turned hierarchy into a duplicate session note.
197
+ const subagent = meta.source?.subagent
198
+ ? { parentThreadId: meta.parent_thread_id || meta.source.subagent.thread_spawn?.parent_thread_id || '', nickname: meta.source.subagent.thread_spawn?.agent_nickname || '' }
199
+ : null;
200
+ transcripts.push({ path, sessionId: meta.id, cwd: meta.cwd || '', subagent });
196
201
  }
197
202
  return { dir, transcripts };
198
203
  }
@@ -350,10 +355,21 @@ export function runImport(vaultBase, opts = {}) {
350
355
  }
351
356
  const notes = capturedSessionNotes(vaultBase);
352
357
  const sinceMs = since ? Date.parse(since) : 0;
353
- const report = { source: src, claudeDir, codexDir, scanned: transcripts.length, imported: 0, repaired: 0, skipped: 0, errors: [], sessions: [] };
358
+ const report = { source: src, claudeDir, codexDir, scanned: transcripts.length, imported: 0, repaired: 0, skipped: 0, subagents: 0, errors: [], sessions: [] };
354
359
 
355
360
  let done = 0;
356
361
  for (const t of transcripts) {
362
+ // A subagent rollout is telemetry of its PARENT session, never a session of its own —
363
+ // importing it materialized the parent's replayed context as a ghost note. Counted apart
364
+ // from `skipped` (which means "session already covered"). If import <=0.46.1 registered
365
+ // it as a top-level session, that entry is our own bad write: heal it.
366
+ if (t.subagent) {
367
+ report.subagents++;
368
+ if (!dryRun) {
369
+ try { removeSessionRegistryEntry(vaultBase, t.sessionId, t.path); } catch { /* best-effort */ }
370
+ }
371
+ continue;
372
+ }
357
373
  if (limit && done >= limit) break;
358
374
 
359
375
  // Every transcript is parsed now, including already-captured ones: partial coverage is
@@ -355,6 +355,25 @@ function meaningfulPatch(patch = {}) {
355
355
  }));
356
356
  }
357
357
 
358
+ // Remove one registry entry, but ONLY when its transcript matches the given path — this is
359
+ // self-healing for entries wendkeep itself mis-wrote (a subagent rollout registered as a
360
+ // top-level session by import <=0.46.1), never generic registry cleanup. An entry with the
361
+ // same id but a different transcript belongs to someone else's state and is preserved.
362
+ export function removeSessionRegistryEntry(vaultBase, sessionId, transcriptPath) {
363
+ if (!sessionId) return false;
364
+ let removed = false;
365
+ mutateSessionRegistry(vaultBase, (registry) => {
366
+ const entry = registry.sessions[sessionId];
367
+ if (!entry) return null;
368
+ const paths = [...(Array.isArray(entry.transcript_paths) ? entry.transcript_paths : []), entry.transcript_path].filter(Boolean);
369
+ if (!paths.some((p) => transcriptsMatch(p, transcriptPath))) return null;
370
+ delete registry.sessions[sessionId];
371
+ removed = true;
372
+ return null;
373
+ });
374
+ return removed;
375
+ }
376
+
358
377
  export function upsertSessionRegistry(vaultBase, sessionId, patch) {
359
378
  if (!sessionId) return null;
360
379
  const clean = meaningfulPatch(patch);
@@ -1,7 +1,7 @@
1
1
  // Single atomic writer for session usage, models, reasoning/effort and subagents.
2
2
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
3
3
  import { collectSessionUsage } from './token-usage.mjs';
4
- import { collectSubagentUsage, sessionDirFromTranscript } from './subagent-usage.mjs';
4
+ import { collectSubagentUsage, collectCodexSubagentUsage, sessionDirFromTranscript } from './subagent-usage.mjs';
5
5
  import { inspectTranscriptIdentity } from './session-identity.mjs';
6
6
 
7
7
  const HEADING = '## Agentes, tokens e custos';
@@ -137,7 +137,11 @@ ${renderSubagents(subagents)}`;
137
137
  export function buildSessionObservability({ sessionContent, transcriptPath }) {
138
138
  const main = collectSessionUsage({ sessionContent, transcriptPath });
139
139
  if (!main) return null;
140
- const subagents = collectSubagentUsage(sessionDirFromTranscript(transcriptPath));
140
+ // Claude layout first (<transcript>/subagents/); when absent, the Codex layout — sibling
141
+ // rollouts linked by parent_thread_id. The Codex collector self-gates: a Claude transcript
142
+ // has no session_meta line, so it returns null and this stays a strict fallback.
143
+ const subagents = collectSubagentUsage(sessionDirFromTranscript(transcriptPath))
144
+ || collectCodexSubagentUsage(transcriptPath);
141
145
  const ledger = [...mainLedger(main), ...subagentLedger(subagents)];
142
146
  const sub = subagents?.aggregate || { count: 0, tokens: 0, cost: 0, wasted: 0, tools: [] };
143
147
  let content = main.content;
@@ -18,6 +18,7 @@ import {
18
18
  formatLocalIso,
19
19
  getNextAdrNumber,
20
20
  getVaultBase,
21
+ isBootstrapPrompt,
21
22
  warnIfDefaultVault,
22
23
  listMarkdownFiles,
23
24
  readControl,
@@ -53,11 +54,12 @@ const SYNTHETIC_EVENT_TAG = /^<\/?(?:task-notification|system-reminder|local-com
53
54
 
54
55
  function shouldIgnoreUserText(text) {
55
56
  const trimmed = String(text || '').trim();
57
+ // Bootstrap detection is DELEGATED, not copied: this used to duplicate isBootstrapPrompt's
58
+ // prefix list and the copies drifted — <recommended_plugins> made it into one and not the
59
+ // other, so the title came out clean while the same block still showed as "Usuário" in the
60
+ // conversation context. One filter, one place to add the next injected block.
56
61
  return SYNTHETIC_EVENT_TAG.test(trimmed)
57
- || /^# AGENTS\.md instructions/.test(trimmed)
58
- || trimmed.startsWith('<permissions instructions>')
59
- || trimmed.includes('You are Codex, a coding agent')
60
- || trimmed.startsWith('## Memory')
62
+ || isBootstrapPrompt(trimmed)
61
63
  // Harness utility meta-prompts (title generation, classifiers) — not real user turns; they
62
64
  // were leaking into note titles/summaries on import.
63
65
  || /^Generate a concise( UI)? title/i.test(trimmed)
@@ -76,6 +76,109 @@ const round4 = (n) => Math.round((Number(n) || 0) * 10000) / 10000;
76
76
 
77
77
  // Aggregate every subagent transcript under <sessionDir>/subagents. null when the dir is
78
78
  // absent (Codex / a session with no subagents) or nothing parseable.
79
+ // --- Codex discovery ----------------------------------------------------------
80
+ // A Codex subagent is not a file under `<transcript>/subagents/` — it is a SIBLING rollout
81
+ // in ~/.codex/sessions/YYYY/MM/DD/, whose session_meta declares source.subagent and points
82
+ // back via parent_thread_id. Without this, every Codex session closed with subagents_count 0,
83
+ // live (SubagentStop) and on import alike.
84
+
85
+ // First line of a rollout, bounded — Codex meta lines can be large (env, git, instructions).
86
+ function readRolloutMeta(path, maxBytes = 4 * 1024 * 1024) {
87
+ try {
88
+ const text = readFileSync(path, 'utf-8');
89
+ if (text.length > maxBytes) return null;
90
+ const line = text.slice(0, text.indexOf('\n') === -1 ? text.length : text.indexOf('\n'));
91
+ const e = JSON.parse(line);
92
+ return e.type === 'session_meta' ? (e.payload || {}) : null;
93
+ } catch { return null; }
94
+ }
95
+
96
+ // Sibling day dirs to scan: the parent rollout's own dir plus the NEXT day — a session that
97
+ // crosses midnight UTC spawns its subagent in the other day's folder, and the first real case
98
+ // (Vendiva, 23:54 UTC) missed that by six minutes.
99
+ function codexSiblingDirs(transcriptPath) {
100
+ const dir = join(transcriptPath, '..');
101
+ const m = String(dir).replace(/[\\/]+$/, '').match(/(\d{4})[\\/](\d{2})[\\/](\d{2})$/);
102
+ if (!m) return [dir];
103
+ const next = new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]) + 1));
104
+ const pad = (n) => String(n).padStart(2, '0');
105
+ const nextDir = join(dir, '..', '..', '..', String(next.getUTCFullYear()), pad(next.getUTCMonth() + 1), pad(next.getUTCDate()));
106
+ return [dir, nextDir];
107
+ }
108
+
109
+ export function collectCodexSubagentUsage(transcriptPath) {
110
+ const meta = readRolloutMeta(transcriptPath);
111
+ if (!meta?.id) return null; // not a Codex rollout — the Claude path stays untouched
112
+ const canonicalId = meta.id;
113
+
114
+ const subagents = [];
115
+ const allTools = new Set();
116
+ const usageAgg = { input: 0, cached: 0, cacheWrite: 0, output: 0, reasoning: 0, total: 0 };
117
+ const modelMap = new Map();
118
+ let count = 0;
119
+ let calls = 0;
120
+ let cost = 0;
121
+
122
+ for (const dayDir of codexSiblingDirs(transcriptPath)) {
123
+ let names;
124
+ try { names = readdirSync(dayDir); } catch { continue; }
125
+ for (const n of names) {
126
+ if (!/\.jsonl$/i.test(n)) continue;
127
+ const f = join(dayDir, n);
128
+ if (f === transcriptPath) continue;
129
+ const sib = readRolloutMeta(f);
130
+ if (!sib?.source?.subagent) continue;
131
+ const parent = sib.parent_thread_id || sib.source.subagent.thread_spawn?.parent_thread_id || '';
132
+ if (parent !== canonicalId) continue;
133
+
134
+ const summary = summarizeTokenUsage(parseTokenUsageFromTranscript(f));
135
+ if (!summary.calls) continue;
136
+ const tokens = tokensTotal(summary.totals);
137
+ for (const t of summary.tools) allTools.add(t);
138
+
139
+ subagents.push({
140
+ id: String(sib.id || basename(f, '.jsonl')).slice(0, 12),
141
+ agentType: sib.source.subagent.thread_spawn?.agent_nickname || 'codex-subagent',
142
+ workflow: null,
143
+ model: summary.models[0] || '?',
144
+ effort: summary.pensamento || '',
145
+ tools: summary.tools.length,
146
+ toolNames: summary.tools,
147
+ calls: summary.calls,
148
+ tokens,
149
+ cost: round4(summary.costs.model),
150
+ modelRows: summary.modelRows,
151
+ });
152
+
153
+ for (const row of summary.modelRows || []) {
154
+ const rowEffort = summary.pensamento || '';
155
+ const key = `${row.provider || '?'}\u0000${row.model || '?'}\u0000${rowEffort}`;
156
+ const current = modelMap.get(key) || { provider: row.provider || '?', model: row.model || '?', effort: rowEffort, calls: 0, tokens: 0, cost: 0,
157
+ usage: { input: 0, cached: 0, cacheWrite: 0, output: 0, reasoning: 0, total: 0 } };
158
+ current.calls += row.calls || 0;
159
+ current.tokens += tokensTotal(row.usage);
160
+ current.cost += row.costs?.model || 0;
161
+ for (const k of Object.keys(current.usage)) current.usage[k] += row.usage?.[k] || 0;
162
+ modelMap.set(key, current);
163
+ }
164
+
165
+ count += 1;
166
+ calls += summary.calls;
167
+ cost += summary.costs.model;
168
+ for (const k of Object.keys(usageAgg)) usageAgg[k] += summary.totals[k] || 0;
169
+ }
170
+ }
171
+
172
+ if (!count) return null;
173
+ return {
174
+ subagents,
175
+ workflows: [],
176
+ aggregate: { count, calls, tokens: tokensTotal(usageAgg), cost: round4(cost), wasted: 0, usage: usageAgg, tools: [...allTools],
177
+ modelRows: [...modelMap.values()].map((r) => ({ ...r, cost: round4(r.cost), source: 'subagent' })),
178
+ },
179
+ };
180
+ }
181
+
79
182
  export function collectSubagentUsage(sessionDir) {
80
183
  const subDir = join(sessionDir, 'subagents');
81
184
  if (!existsSync(subDir)) return null;
@@ -612,7 +612,7 @@ const USAGE_FIELDS = ['provider', 'pensamento', 'input', 'cache_write', 'cache_r
612
612
 
613
613
  export function sameUsageData(a, b) {
614
614
  if (!a || !b) return false;
615
- const listEqual = (x, y) => (x || []).join('') === (y || []).join('');
615
+ const listEqual = (x, y) => (x || []).join('\u0000') === (y || []).join('\u0000');
616
616
  return USAGE_FIELDS.every((f) => (a[f] ?? null) === (b[f] ?? null))
617
617
  && listEqual(a.modelos, b.modelos) && listEqual(a.tools, b.tools);
618
618
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wendkeep",
3
- "version": "0.46.1",
3
+ "version": "0.47.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
- // enable it (non-destructive), and add graph color groups. Returns a short note.
266
- // Unparseable user JSON is left untouched (we only merge into valid/absent files).
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
- const loc = getLocale(vaultPath);
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/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
+ }
@@ -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) {