wendkeep 0.29.1 → 0.30.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,43 @@ 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.30.0] — 2026-07-09
8
+
9
+ ### Changed
10
+ - **Decision notes follow the ADR convention: `ADR-<NNNN>-<slug>`.** Every decision note now carries
11
+ a 4-digit, zero-padded sequential number assigned in the order decisions are made (`ADR-0001`,
12
+ `ADR-0002`, …) — replacing the old `YYYY-MM-DD-escolha-<slug>` filenames from the interactive and
13
+ prose captures. The number goes in the filename, in an `adr:` frontmatter field, and as an
14
+ `# ADR-NNNN — <title>` H1 prefix. The native `wendkeep change archive` ADR and the
15
+ `createLinkedNotes` heuristic ADR widen from 3 to 4 digits (`ADR-007` → `ADR-0007`) to match.
16
+ - **Decision capture dedups by `content_key`, not by filename.** Because the filename now carries a
17
+ fresh ADR number it can't dedup, so a decision already recorded in the target folder (same
18
+ normalized question) is skipped by content — both the AskUserQuestion hook (`captureDecision`) and
19
+ the agnostic prose capture (`captureProseDecisions`). New `decisionKeyExists` / `padAdr` helpers.
20
+
21
+ ### Added
22
+ - **`wendkeep renumber-decisions`** — retroactive fix for vaults that accumulated the three historical
23
+ naming eras (`ADR-NNN`, dated `escolha`, hand-written). Renumbers **every** note in 04-Decisões to
24
+ `ADR-<NNNN>-<slug>` in strict chronological order, renames the files in place, and **rewrites every
25
+ wikilink to them across the whole vault** (full-path, basename, and `|ADR-006` display aliases).
26
+ Normalizes each note's `type: decision` / `adr:` / H1. Preview by default (writes nothing); pass
27
+ `--apply` to commit the renames. Idempotent — a second run on a canonical vault is a no-op.
28
+ `--vault P` / `--json`. New `hooks/renumber-decisions.mjs` (`planRenumber`, `renumberDecisions`,
29
+ `slugFromDecisionName`, `decisionSortKey`, `normalizeDecisionContent`, `rewriteLinks`).
30
+
31
+ ## [0.29.2] — 2026-07-09
32
+
33
+ ### Fixed
34
+ - **Iteration turn marker renamed `codex-turn` → `wk-turn`** (provider-neutral). A **Claude**
35
+ session's iterations carried `<!-- codex-turn: … -->` — a legacy name from when this was a
36
+ Codex-only tool, confusing in the note source. The marker is a dedup key, so the change is
37
+ backward-compatible: `hasTurnMarker` still recognizes the legacy name, and `insertIteration`
38
+ **self-migrates** any `codex-turn` → `wk-turn` on the next write (backfill re-processes older
39
+ notes). Shared helpers `turnMarker` / `hasTurnMarker` / `normalizeTurnMarkers` in obsidian-common;
40
+ `vault-health` recognizes both.
41
+ - Note-visible fallback text "Checkpoint registrado pelo hook Stop do **Codex**" → provider-neutral.
42
+ - Stderr log prefix `[codex-obsidian]` → `[wendkeep]` across the hooks.
43
+
7
44
  ## [0.29.1] — 2026-07-09
8
45
 
9
46
  ### Added
package/bin/wendkeep.mjs CHANGED
@@ -60,6 +60,9 @@ Usage:
60
60
  wendkeep verify [--deep] [--change s] Run a change's task sensors + record evidence (the gate);
61
61
  --deep assembles the verification package for the wk-verify pass.
62
62
  wendkeep dashboard [--force] (Re)generate the vault's folder-filtered Bases + 00-Dashboard MOC.
63
+ wendkeep renumber-decisions Renumber 04-Decisões to ADR-<NNNN>-<slug> in chronological order,
64
+ renaming files + rewriting every wikilink. Preview by default; --apply to
65
+ write. --vault P · --json.
63
66
  wendkeep lesson add "t" "l" Record a project-local lesson (injected at SessionStart).
64
67
  wendkeep validate-memory [path] Validate .brain/CORE.md against the compaction
65
68
  protocol (cap 25, 3 sections, no secrets/PII). Uses
@@ -162,6 +165,11 @@ async function main() {
162
165
  runDashboard(rest);
163
166
  break;
164
167
  }
168
+ case 'renumber-decisions': {
169
+ const { runRenumberDecisions } = await import('../src/renumber.mjs');
170
+ runRenumberDecisions(rest);
171
+ break;
172
+ }
165
173
  case '--version':
166
174
  case '-v':
167
175
  process.stdout.write(`${version()}\n`);
@@ -265,7 +265,7 @@ export function archiveChange(vaultBase, slug, { gate = gateGreen, dateStr, adrN
265
265
  // — not the year root — so all ADRs sit together in the vault's convention.
266
266
  const adrDirRel = monthFolderRelFromDateStr(loc.folders.decisions, dateStr, vaultBase);
267
267
  ensureDir(join(vaultBase, adrDirRel));
268
- const num = String(adrNum).padStart(3, '0');
268
+ const num = String(adrNum).padStart(4, '0');
269
269
  const adrRel = join(adrDirRel, `ADR-${num}-${slug}.md`);
270
270
  const capLine = promoted.length
271
271
  ? `\n\nCapabilities: ${promoted.map((c) => wikilinkFromRel(join(loc.folders.specs, c))).join(', ')}.`
@@ -3,16 +3,36 @@
3
3
  // options, this records the decision — the question, EVERY option (label + description), and the
4
4
  // user's choice — as a note in 04-Decisões, wikilinked to the session. Explicit, high-signal
5
5
  // decisions get full traceability in the graph (better than heuristic extraction). Fail-open.
6
- import { existsSync, writeFileSync } from 'fs';
6
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from 'fs';
7
7
  import { join } from 'path';
8
8
  import { pathToFileURL } from 'url';
9
9
  import {
10
10
  readHookInput, writeHookOutput, getVaultBase, providerMeta, ensureDir, formatDate,
11
11
  formatLocalIso, monthFolderRelFromDateStr, slugify, findActiveSessionByTranscript,
12
- wikilinkFromRel, readControl, toVaultRelative,
12
+ wikilinkFromRel, readControl, toVaultRelative, getNextAdrNumber, derivedContentKey,
13
13
  } from './obsidian-common.mjs';
14
14
  import { getLocale } from './locale.mjs';
15
15
 
16
+ // Decision notes follow the ADR naming convention: ADR-NNNN-<slug>, NNNN a 4-digit sequential
17
+ // number assigned in the order decisions are made (getNextAdrNumber scans the whole 04-Decisões).
18
+ export const padAdr = (n) => String(n).padStart(4, '0');
19
+
20
+ // Dedup is content-based now (the filename carries a fresh ADR number, so it can't dedup): a
21
+ // decision already recorded (same content_key) in the target folder is not written again.
22
+ export function decisionKeyExists(dir, key) {
23
+ if (!key) return false;
24
+ let names;
25
+ try { names = readdirSync(dir); } catch { return false; }
26
+ for (const n of names) {
27
+ if (!n.endsWith('.md')) continue;
28
+ try {
29
+ const m = readFileSync(join(dir, n), 'utf8').match(/^content_key:\s*"?(.*?)"?\s*$/m);
30
+ if (m && m[1] === key) return true;
31
+ } catch { /* nota ilegível */ }
32
+ }
33
+ return false;
34
+ }
35
+
16
36
  // The AskUserQuestion tool_output reads: `... "Question"="chosen labels" "Q2"="..."`.
17
37
  export function parseAnswers(output) {
18
38
  const map = {};
@@ -25,10 +45,11 @@ export function parseAnswers(output) {
25
45
  const clean = (s) => String(s || '').replace(/\r/g, '').replace(/\n{3,}/g, '\n\n').trim();
26
46
 
27
47
  // Render the decision note from an AskUserQuestion tool call.
28
- export function buildDecisionCaptureNote({ questions, answers, dateStr, startedAt, sessionRel, provider, localeId }) {
48
+ export function buildDecisionCaptureNote({ questions, answers, dateStr, startedAt, sessionRel, provider, localeId, adrNum = 0, contentKey = '' }) {
29
49
  const en = localeId === 'en';
30
50
  const src = sessionRel ? `\n - "${wikilinkFromRel(sessionRel)}"` : ' []';
31
51
  const title = clean(questions[0]?.question || (en ? 'User decision' : 'Decisão do usuário')).slice(0, 90);
52
+ const adrLabel = adrNum ? `ADR-${padAdr(adrNum)} — ` : '';
32
53
 
33
54
  const blocks = questions.map((q) => {
34
55
  const chosen = (answers[clean(q.question)] || '').split(',').map((s) => s.trim()).filter(Boolean);
@@ -52,6 +73,7 @@ subtype: user-choice
52
73
  date: ${dateStr}
53
74
  started_at: ${startedAt}
54
75
  provider: ${provider.id}
76
+ ${adrNum ? `adr: ${adrNum}\n` : ''}content_key: "${contentKey}"
55
77
  cssclasses:
56
78
  - topic-decision
57
79
  tags:
@@ -60,7 +82,7 @@ tags:
60
82
  source:${src}
61
83
  ---
62
84
 
63
- # ${title}
85
+ # ${adrLabel}${title}
64
86
 
65
87
  > ${en ? 'Decision captured from an interactive question (options + the user\'s choice).' : 'Decisão capturada de uma pergunta interativa (opções + a escolha do usuário).'}
66
88
 
@@ -99,7 +121,7 @@ export function extractProseDecisions(tx) {
99
121
  }
100
122
 
101
123
  // Write one decision note per extracted prose decision (same shape as the hook capture).
102
- // Deduped by filename (day + question slug). Returns the vault-relative paths written.
124
+ // Deduped by content_key (question). Returns the vault-relative paths written.
103
125
  export function captureProseDecisions(vaultBase, { tx, dateStr, sessionRel, provider, localeId }) {
104
126
  const written = [];
105
127
  const decisions = extractProseDecisions(tx);
@@ -107,14 +129,17 @@ export function captureProseDecisions(vaultBase, { tx, dateStr, sessionRel, prov
107
129
  const loc = getLocale(vaultBase);
108
130
  const dir = join(vaultBase, monthFolderRelFromDateStr(loc.folders.decisions, dateStr, vaultBase));
109
131
  for (const d of decisions) {
132
+ const contentKey = derivedContentKey(d.question);
110
133
  ensureDir(dir);
111
- const filePath = join(dir, `${dateStr}-escolha-${slugify(d.question, 'decisao', 50)}.md`);
134
+ if (decisionKeyExists(dir, contentKey)) continue;
135
+ const adrNum = getNextAdrNumber(vaultBase);
136
+ const filePath = join(dir, `ADR-${padAdr(adrNum)}-${slugify(d.question, 'decisao', 50)}.md`);
112
137
  if (existsSync(filePath)) continue;
113
138
  writeFileSync(filePath, buildDecisionCaptureNote({
114
139
  questions: [{ question: d.question, multiSelect: false, options: d.options.map((label) => ({ label, description: '' })) }],
115
140
  answers: { [d.question]: d.answer },
116
141
  dateStr, startedAt: `${dateStr}T00:00:00`, sessionRel,
117
- provider: provider || providerMeta(tx?.provider), localeId: localeId || loc.id,
142
+ provider: provider || providerMeta(tx?.provider), localeId: localeId || loc.id, adrNum, contentKey,
118
143
  }), 'utf-8');
119
144
  written.push(toVaultRelative(vaultBase, filePath));
120
145
  }
@@ -137,12 +162,15 @@ export function captureDecision(vaultBase, input) {
137
162
 
138
163
  const dir = join(vaultBase, monthFolderRelFromDateStr(loc.folders.decisions, dateStr, vaultBase));
139
164
  ensureDir(dir);
165
+ const contentKey = derivedContentKey(questions[0]?.question || 'decisao');
166
+ if (decisionKeyExists(dir, contentKey)) return { rel: '', skipped: true };
167
+ const adrNum = getNextAdrNumber(vaultBase);
140
168
  const slug = slugify(questions[0]?.question || 'decisao', 'decisao', 50);
141
- const filePath = join(dir, `${dateStr}-escolha-${slug}.md`);
169
+ const filePath = join(dir, `ADR-${padAdr(adrNum)}-${slug}.md`);
142
170
  if (existsSync(filePath)) return { rel: toVaultRelative(vaultBase, filePath), skipped: true };
143
171
 
144
172
  writeFileSync(filePath, buildDecisionCaptureNote({
145
- questions, answers, dateStr, startedAt: formatLocalIso(now), sessionRel, provider, localeId: loc.id,
173
+ questions, answers, dateStr, startedAt: formatLocalIso(now), sessionRel, provider, localeId: loc.id, adrNum, contentKey,
146
174
  }), 'utf-8');
147
175
  return { rel: toVaultRelative(vaultBase, filePath), skipped: false };
148
176
  }
@@ -38,7 +38,7 @@ function normalizeInline(text, max = 0) {
38
38
 
39
39
  function adrFileExistsBySlug(dir, slug) {
40
40
  try {
41
- return readdirSync(dir).find((file) => /^ADR-\d{3}-.+\.md$/i.test(file) && file.includes(`-${slug}`));
41
+ return readdirSync(dir).find((file) => /^ADR-\d+-.+\.md$/i.test(file) && file.includes(`-${slug}`));
42
42
  } catch {
43
43
  return '';
44
44
  }
@@ -333,7 +333,7 @@ export function extractDecisionDetails(tx) {
333
333
 
334
334
  export function buildDecisionNoteContent(decision, adrNum, dateStr, sessionRel, provider = providerMeta(), contentKey = derivedContentKey(decision.title), localeId = 'pt-BR') {
335
335
  const L = noteLabels(localeId);
336
- const adrId = `ADR-${String(adrNum).padStart(3, '0')}`;
336
+ const adrId = `ADR-${String(adrNum).padStart(4, '0')}`;
337
337
  return `---
338
338
  type: decision
339
339
  date: ${dateStr}
@@ -558,7 +558,7 @@ export function createLinkedNotes(vaultBase, dateStr, sessionRel, tx, options =
558
558
  if (!alreadyHasKey(existingKeys.decisions, decisionKey)) {
559
559
  const titleSlug = slugify(decisionDetails.title, 'decisao', 40);
560
560
  const existing = adrFileExistsBySlug(decisionsDir, titleSlug);
561
- const fileName = existing || `ADR-${String(getNextAdrNumber(vaultBase)).padStart(3, '0')}-${titleSlug}.md`;
561
+ const fileName = existing || `ADR-${String(getNextAdrNumber(vaultBase)).padStart(4, '0')}-${titleSlug}.md`;
562
562
  const filePath = join(decisionsDir, fileName);
563
563
  if (!existsSync(filePath)) {
564
564
  const adrNum = Number(fileName.match(/^ADR-(\d+)/i)?.[1]) || getNextAdrNumber(vaultBase);
@@ -557,6 +557,27 @@ export function wikilinkFromRel(relPath) {
557
557
  return `[[${relPath.replace(/\.md$/i, '').replaceAll('\\', '/')}]]`;
558
558
  }
559
559
 
560
+ // Per-iteration dedup marker (an invisible HTML comment). Provider-neutral name `wk-turn`; the
561
+ // old `codex-turn` (legacy, from when this was a Codex-only tool) is still RECOGNIZED so notes
562
+ // written by older versions keep deduping, and normalizeTurnMarkers migrates them on the next write.
563
+ export const TURN_MARKER = 'wk-turn';
564
+ export const LEGACY_TURN_MARKERS = ['codex-turn'];
565
+
566
+ export function turnMarker(id) {
567
+ return `<!-- ${TURN_MARKER}: ${id} -->`;
568
+ }
569
+
570
+ export function hasTurnMarker(content, id) {
571
+ return [TURN_MARKER, ...LEGACY_TURN_MARKERS].some((m) => String(content || '').includes(`<!-- ${m}: ${id} -->`));
572
+ }
573
+
574
+ // Rewrite any legacy turn markers in a note to the current name (self-healing migration).
575
+ export function normalizeTurnMarkers(content) {
576
+ let c = String(content || '');
577
+ for (const m of LEGACY_TURN_MARKERS) c = c.replaceAll(`<!-- ${m}: `, `<!-- ${TURN_MARKER}: `);
578
+ return c;
579
+ }
580
+
560
581
  export function listMarkdownFiles(dir) {
561
582
  try {
562
583
  return readdirSync(dir).filter((f) => f.endsWith('.md'));
@@ -0,0 +1,198 @@
1
+ #!/usr/bin/env node
2
+ // Retroactive ADR renumbering (0.30.0). The decision folder accumulated three naming eras:
3
+ // canonical ADR-NNN, dated `YYYY-MM-DD-escolha-<slug>` (hook capture pre-0.30), and hand-written
4
+ // `YYYY-MM-DD-<slug>`. This renumbers EVERY note in 04-Decisões to `ADR-<NNNN>-<slug>` in strict
5
+ // chronological order (the order decisions were made), renames the files in place, updates every
6
+ // wikilink to them across the whole vault, and normalizes each note's `type`/`adr`/H1. Idempotent:
7
+ // running it again on an already-canonical vault is a no-op (same order, same names).
8
+ import { readdirSync, readFileSync, renameSync, writeFileSync, existsSync } from 'node:fs';
9
+ import { join, dirname, relative } from 'node:path';
10
+ import { getLocale } from './locale.mjs';
11
+
12
+ export const padAdr = (n) => String(n).padStart(4, '0');
13
+
14
+ // Every .md under 04-Decisões, absolute + vault-relative (posix slashes for wikilinks).
15
+ function walkDecisions(vaultBase, decisionsDir) {
16
+ const out = [];
17
+ const walk = (dir) => {
18
+ let entries;
19
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
20
+ for (const e of entries) {
21
+ const abs = join(dir, e.name);
22
+ if (e.isDirectory()) walk(abs);
23
+ else if (e.name.endsWith('.md')) {
24
+ out.push({ abs, rel: relative(vaultBase, abs).replaceAll('\\', '/'), base: e.name });
25
+ }
26
+ }
27
+ };
28
+ walk(join(vaultBase, decisionsDir));
29
+ return out;
30
+ }
31
+
32
+ // Chronological sort key: date, then time, then existing ADR number, then filename — all derived
33
+ // from (in priority) frontmatter, filename prefix, and the dated folder path.
34
+ export function decisionSortKey({ abs, base, content }) {
35
+ const c = content ?? (existsSync(abs) ? safeRead(abs) : '');
36
+ const fmDate = c.match(/^date:\s*(\d{4}-\d{2}-\d{2})/m);
37
+ const fnDate = base.match(/^(\d{4}-\d{2}-\d{2})/);
38
+ const folderDate = abs.replaceAll('\\', '/').match(/\/(\d{4})\/(\d{2})-[^/]+\/DIA\s+(\d{1,2})\//i);
39
+ let date = '9999-12-31';
40
+ if (fmDate) date = fmDate[1];
41
+ else if (fnDate) date = fnDate[1];
42
+ else if (folderDate) date = `${folderDate[1]}-${folderDate[2]}-${String(folderDate[3]).padStart(2, '0')}`;
43
+
44
+ const fmTime = c.match(/^started_at:\s*\S*T(\d{2}:\d{2}:\d{2})/m);
45
+ const srcTime = c.match(/\[\[[^\]]*\/(\d{2})-(\d{2})-[^\]]*\]\]/); // session wikilink HH-MM prefix
46
+ let time = '00:00:00';
47
+ if (fmTime) time = fmTime[1];
48
+ else if (srcTime) time = `${srcTime[1]}:${srcTime[2]}:00`;
49
+
50
+ const adr = base.match(/^ADR-(\d+)/i);
51
+ const adrNum = adr ? Number(adr[1]) : 999999;
52
+ return `${date}T${time}#${String(adrNum).padStart(6, '0')}#${base}`;
53
+ }
54
+
55
+ function safeRead(abs) {
56
+ try { return readFileSync(abs, 'utf8'); } catch { return ''; }
57
+ }
58
+
59
+ // Descriptive slug from any of the three eras' filenames.
60
+ export function slugFromDecisionName(base) {
61
+ let s = base.replace(/\.md$/i, '');
62
+ s = s.replace(/^ADR-\d+-/i, '');
63
+ s = s.replace(/^\d{4}-\d{2}-\d{2}-/, '');
64
+ s = s.replace(/^escolha-/i, '');
65
+ return s || 'decisao';
66
+ }
67
+
68
+ // Normalize one decision note's body: type -> decision, adr: <num>, and an `ADR-NNNN — ` H1 prefix.
69
+ export function normalizeDecisionContent(content, adrNum) {
70
+ let c = String(content || '');
71
+ const label = `ADR-${padAdr(adrNum)}`;
72
+
73
+ // type: decisao|decisão|decision -> decision
74
+ if (/^type:\s*.*$/m.test(c)) c = c.replace(/^type:\s*.*$/m, 'type: decision');
75
+ else c = c.replace(/^---\n/, `---\ntype: decision\n`);
76
+
77
+ // adr: <num> (replace if present, else insert right after the type line)
78
+ if (/^adr:\s*.*$/m.test(c)) c = c.replace(/^adr:\s*.*$/m, `adr: ${adrNum}`);
79
+ else c = c.replace(/^type: decision$/m, `type: decision\nadr: ${adrNum}`);
80
+
81
+ // H1: strip any existing `ADR-\d+ — ` prefix, then prepend the canonical label.
82
+ c = c.replace(/^#\s+(.*)$/m, (_, title) => {
83
+ const bare = title.replace(/^ADR-\d+\s*[—–-]\s*/i, '').trim();
84
+ return `# ${label} — ${bare}`;
85
+ });
86
+ return c;
87
+ }
88
+
89
+ // Replace every wikilink to a renamed note across one file's text. `renames` carries the old/new
90
+ // vault-relative path (no extension) and basename so both full-path and basename links are caught.
91
+ export function rewriteLinks(content, renames) {
92
+ let c = String(content || '');
93
+ for (const r of renames) {
94
+ if (r.oldRelNoExt === r.newRelNoExt) continue;
95
+ c = c.split(`[[${r.oldRelNoExt}]]`).join(`[[${r.newRelNoExt}]]`);
96
+ c = c.split(`[[${r.oldRelNoExt}|`).join(`[[${r.newRelNoExt}|`);
97
+ c = c.split(`[[${r.oldBaseNoExt}]]`).join(`[[${r.newBaseNoExt}]]`);
98
+ c = c.split(`[[${r.oldBaseNoExt}|`).join(`[[${r.newBaseNoExt}|`);
99
+ // Best-effort: refresh a `|ADR-006]]` display alias to the new padded id.
100
+ if (r.oldAdrLabel) c = c.split(`|${r.oldAdrLabel}]]`).join(`|${r.newAdrLabel}]]`);
101
+ }
102
+ return c;
103
+ }
104
+
105
+ function allVaultMarkdown(vaultBase) {
106
+ const out = [];
107
+ const skip = new Set(['.git', '.obsidian', 'node_modules', '_arquivo']);
108
+ const walk = (dir) => {
109
+ let entries;
110
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
111
+ for (const e of entries) {
112
+ if (e.name.startsWith('.') && e.name !== '.brain') continue;
113
+ const abs = join(dir, e.name);
114
+ if (e.isDirectory()) { if (!skip.has(e.name)) walk(abs); }
115
+ else if (e.name.endsWith('.md')) out.push(abs);
116
+ }
117
+ };
118
+ walk(vaultBase);
119
+ return out;
120
+ }
121
+
122
+ // Build the chronological renumber plan. Pure: reads notes, sorts, computes new names. No writes.
123
+ export function planRenumber(vaultBase) {
124
+ const decisionsDir = getLocale(vaultBase).folders.decisions;
125
+ const notes = walkDecisions(vaultBase, decisionsDir)
126
+ .map((n) => ({ ...n, content: safeRead(n.abs) }))
127
+ .sort((a, b) => decisionSortKey(a).localeCompare(decisionSortKey(b)));
128
+
129
+ const renames = [];
130
+ notes.forEach((n, i) => {
131
+ const num = i + 1;
132
+ const slug = slugFromDecisionName(n.base);
133
+ const newBase = `ADR-${padAdr(num)}-${slug}.md`;
134
+ const folderRel = dirname(n.rel);
135
+ const newRel = folderRel === '.' ? newBase : `${folderRel}/${newBase}`;
136
+ const oldAdr = n.base.match(/^ADR-(\d+)/i);
137
+ renames.push({
138
+ num, slug,
139
+ oldAbs: n.abs,
140
+ newAbs: join(dirname(n.abs), newBase),
141
+ oldRelNoExt: n.rel.replace(/\.md$/i, ''),
142
+ newRelNoExt: newRel.replace(/\.md$/i, ''),
143
+ oldBaseNoExt: n.base.replace(/\.md$/i, ''),
144
+ newBaseNoExt: newBase.replace(/\.md$/i, ''),
145
+ oldAdrLabel: oldAdr ? `ADR-${String(Number(oldAdr[1])).padStart(3, '0')}` : '',
146
+ newAdrLabel: `ADR-${padAdr(num)}`,
147
+ renamed: n.base !== newBase,
148
+ });
149
+ });
150
+ return renames;
151
+ }
152
+
153
+ // Execute the plan: collision-safe rename (via temp), body normalization, vault-wide link rewrite.
154
+ export function renumberDecisions(vaultBase, { apply = false } = {}) {
155
+ const renames = planRenumber(vaultBase);
156
+ const changed = renames.filter((r) => r.renamed);
157
+ const report = {
158
+ total: renames.length,
159
+ renamed: changed.length,
160
+ normalized: 0,
161
+ linksUpdated: 0,
162
+ filesTouched: 0,
163
+ plan: renames.map((r) => ({ num: r.num, from: r.oldRelNoExt, to: r.newRelNoExt, renamed: r.renamed })),
164
+ applied: apply,
165
+ };
166
+ if (!apply) return report;
167
+
168
+ // Phase A — park every renamed source under a temp name so no target clobbers a not-yet-moved source.
169
+ const temps = new Map();
170
+ changed.forEach((r, i) => {
171
+ const tmp = join(dirname(r.oldAbs), `.wk-renum-${i}.tmp`);
172
+ renameSync(r.oldAbs, tmp);
173
+ temps.set(r, tmp);
174
+ });
175
+
176
+ // Phase B — normalize each note's body, then land it at its final path. For a renamed note we
177
+ // write the normalized content back over its temp and renameSync temp -> final (no delete: the
178
+ // temp file becomes the final file). For an unchanged name we just rewrite in place.
179
+ for (const r of renames) {
180
+ const tmp = temps.get(r);
181
+ if (tmp) {
182
+ writeFileSync(tmp, normalizeDecisionContent(safeRead(tmp), r.num), 'utf8');
183
+ renameSync(tmp, r.newAbs);
184
+ } else {
185
+ writeFileSync(r.newAbs, normalizeDecisionContent(safeRead(r.oldAbs), r.num), 'utf8');
186
+ }
187
+ report.normalized += 1;
188
+ }
189
+
190
+ // Phase C — rewrite wikilinks to any renamed note across the whole vault.
191
+ const linkRenames = changed;
192
+ for (const abs of allVaultMarkdown(vaultBase)) {
193
+ const before = safeRead(abs);
194
+ const after = rewriteLinks(before, linkRenames);
195
+ if (after !== before) { writeFileSync(abs, after, 'utf8'); report.filesTouched += 1; report.linksUpdated += 1; }
196
+ }
197
+ return report;
198
+ }
@@ -36,7 +36,9 @@ function parseArgs(argv) {
36
36
  }
37
37
 
38
38
  function hasTurnMarker(content, turnId) {
39
- return content.includes(`<!-- codex-turn: ${turnId} -->`);
39
+ // recognize both the current `wk-turn` and the legacy `codex-turn`
40
+ return content.includes(`<!-- wk-turn: ${turnId} -->`)
41
+ || content.includes(`<!-- codex-turn: ${turnId} -->`);
40
42
  }
41
43
 
42
44
  function sessionEntries(vaultBase, args) {
@@ -135,7 +137,7 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
135
137
  try {
136
138
  main();
137
139
  } catch (error) {
138
- process.stderr.write(`[codex-obsidian] Backfill falhou: ${error.message}\n`);
140
+ process.stderr.write(`[wendkeep] Backfill falhou: ${error.message}\n`);
139
141
  process.exitCode = 1;
140
142
  }
141
143
  }
@@ -387,6 +387,6 @@ function main() {
387
387
  try {
388
388
  main();
389
389
  } catch (error) {
390
- process.stderr.write(`[codex-obsidian] UserPromptSubmit falhou: ${error.message}\n`);
390
+ process.stderr.write(`[wendkeep] UserPromptSubmit falhou: ${error.message}\n`);
391
391
  writeHookOutput({});
392
392
  }
@@ -161,7 +161,7 @@ function main() {
161
161
  try {
162
162
  sweepStaleSessionsFile(vaultBase, now, undefined, input.transcript_path || input.transcriptPath || '');
163
163
  } catch (error) {
164
- process.stderr.write(`[codex-obsidian] sweep de sessões falhou: ${error.message}\n`);
164
+ process.stderr.write(`[wendkeep] sweep de sessões falhou: ${error.message}\n`);
165
165
  }
166
166
 
167
167
  // Reuso da nota apontada pelo CURRENT_SESSION só quando é a MESMA conversa
@@ -309,9 +309,9 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
309
309
  try {
310
310
  main();
311
311
  } catch (error) {
312
- process.stderr.write(`[codex-obsidian] SessionStart falhou: ${error.message}\n`);
312
+ process.stderr.write(`[wendkeep] SessionStart falhou: ${error.message}\n`);
313
313
  writeHookOutput({
314
- systemMessage: `[codex-obsidian] Não foi possível criar a sessão Obsidian: ${error.message}`,
314
+ systemMessage: `[wendkeep] Não foi possível criar a sessão Obsidian: ${error.message}`,
315
315
  });
316
316
  }
317
317
  }
@@ -30,6 +30,9 @@ import {
30
30
  wikilinkFromRel,
31
31
  writeControl,
32
32
  writeHookOutput,
33
+ turnMarker,
34
+ hasTurnMarker,
35
+ normalizeTurnMarkers,
33
36
  } from './obsidian-common.mjs';
34
37
 
35
38
  function extractContentText(content) {
@@ -596,7 +599,7 @@ export function buildIterationBlock(tx, input) {
596
599
  // inclui recortes da conversa do turno, sem despejar outputs brutos.
597
600
  return `
598
601
  ### ${formatTimeForHeading(now)} - ${heading}
599
- <!-- codex-turn: ${turnId} -->
602
+ ${turnMarker(turnId)}
600
603
 
601
604
  **Pedido:** ${compactText(promptText, 1000)}
602
605
 
@@ -609,7 +612,7 @@ ${formatConversation(turn)}
609
612
 
610
613
  **Arquivos detectados no turno:** ${formatInlineList(files, 'Nenhum arquivo detectado automaticamente.')}
611
614
 
612
- **Estado ao final do turno:** ${compactText(latestAssistant || 'Checkpoint registrado pelo hook Stop do Codex.', 900)}
615
+ **Estado ao final do turno:** ${compactText(latestAssistant || 'Checkpoint registrado automaticamente ao final do turno.', 900)}
613
616
  `;
614
617
  }
615
618
 
@@ -751,17 +754,19 @@ function relocateOrphanIterations(content) {
751
754
  }
752
755
 
753
756
  export function insertIteration(sessionPath, block, turnId, tx) {
754
- let content = readFileSync(sessionPath, 'utf-8');
755
- if (content.includes(`<!-- codex-turn: ${turnId} -->`)) {
757
+ const original = readFileSync(sessionPath, 'utf-8');
758
+ // Self-heal: migrate any legacy `codex-turn` markers to the neutral name on this write.
759
+ let content = normalizeTurnMarkers(original);
760
+ if (hasTurnMarker(content, turnId)) {
756
761
  // Turno já registrado: ainda assim repara órfãos e seções dedicadas.
757
762
  const repaired = applyDedicatedSections(relocateOrphanIterations(content), tx);
758
- if (repaired !== content) writeFileSync(sessionPath, repaired, 'utf-8');
763
+ if (repaired !== original) writeFileSync(sessionPath, repaired, 'utf-8');
759
764
  return false;
760
765
  }
761
766
  content = relocateOrphanIterations(content);
762
767
  content = insertIntoIteracoes(content, block);
763
768
  content = applyDedicatedSections(content, tx);
764
- writeFileSync(sessionPath, content, 'utf-8');
769
+ if (content !== original) writeFileSync(sessionPath, content, 'utf-8');
765
770
  return true;
766
771
  }
767
772
 
@@ -1037,7 +1042,7 @@ function main() {
1037
1042
  try {
1038
1043
  applyLinearLinks(sessionPath, tx, vaultBase, sessionRel);
1039
1044
  } catch (error) {
1040
- process.stderr.write(`[codex-obsidian] Linear link falhou: ${error.message}\n`);
1045
+ process.stderr.write(`[wendkeep] Linear link falhou: ${error.message}\n`);
1041
1046
  }
1042
1047
 
1043
1048
  try {
@@ -1048,7 +1053,7 @@ function main() {
1048
1053
  transcriptPath,
1049
1054
  });
1050
1055
  } catch (error) {
1051
- process.stderr.write(`[codex-obsidian] Token usage falhou: ${error.message}\n`);
1056
+ process.stderr.write(`[wendkeep] Token usage falhou: ${error.message}\n`);
1052
1057
  }
1053
1058
 
1054
1059
  // Subagent/workflow telemetry (0.10.0): fold sibling subagent transcripts into the note.
@@ -1056,7 +1061,7 @@ function main() {
1056
1061
  try {
1057
1062
  upsertSubagentUsage(sessionPath, transcriptPath);
1058
1063
  } catch (error) {
1059
- process.stderr.write(`[codex-obsidian] Subagent usage falhou: ${error.message}\n`);
1064
+ process.stderr.write(`[wendkeep] Subagent usage falhou: ${error.message}\n`);
1060
1065
  }
1061
1066
 
1062
1067
  if (!shouldFinalizeSession()) {
@@ -1127,7 +1132,7 @@ function main() {
1127
1132
  const rows = buildBrainIndex(vaultBase);
1128
1133
  buildBrainDigest(vaultBase, rows);
1129
1134
  } catch (error) {
1130
- process.stderr.write(`[codex-obsidian] brain index/digest falhou: ${error.message}\n`);
1135
+ process.stderr.write(`[wendkeep] brain index/digest falhou: ${error.message}\n`);
1131
1136
  }
1132
1137
 
1133
1138
  pingObsidianVault(input.obsidian_api_key);
@@ -1138,7 +1143,7 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
1138
1143
  try {
1139
1144
  main();
1140
1145
  } catch (error) {
1141
- process.stderr.write(`[codex-obsidian] Stop falhou: ${error.message}\n`);
1146
+ process.stderr.write(`[wendkeep] Stop falhou: ${error.message}\n`);
1142
1147
  writeHookOutput({});
1143
1148
  }
1144
1149
  }
@@ -966,7 +966,7 @@ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
966
966
  try {
967
967
  runCli();
968
968
  } catch (error) {
969
- process.stderr.write(`[codex-obsidian] Token usage falhou: ${error.message}\n`);
969
+ process.stderr.write(`[wendkeep] Token usage falhou: ${error.message}\n`);
970
970
  process.exitCode = 1;
971
971
  }
972
972
  }
@@ -39,7 +39,7 @@ function parseArgs(argv) {
39
39
  function findDuplicateTurnMarkers(content) {
40
40
  const seen = new Set();
41
41
  const duplicated = new Set();
42
- const regex = /<!-- codex-turn: ([^>]+) -->/g;
42
+ const regex = /<!-- (?:wk-turn|codex-turn): ([^>]+) -->/g;
43
43
  let match;
44
44
  while ((match = regex.exec(content)) !== null) {
45
45
  const turnId = match[1].trim();
@@ -104,10 +104,10 @@ function checkSession({ vaultBase, sessionRel, control, registry }) {
104
104
 
105
105
  const content = readFileSync(sessionPath, 'utf-8');
106
106
  const duplicates = findDuplicateTurnMarkers(content);
107
- metrics.turnMarkers = (content.match(/<!-- codex-turn:/g) || []).length;
107
+ metrics.turnMarkers = (content.match(/<!-- (?:wk-turn|codex-turn):/g) || []).length;
108
108
  metrics.duplicateTurnMarkers = duplicates.length;
109
109
 
110
- if (duplicates.length) failures.push(`Marcadores codex-turn duplicados: ${duplicates.join(', ')}`);
110
+ if (duplicates.length) failures.push(`Marcadores de turno duplicados: ${duplicates.join(', ')}`);
111
111
  if (hasHeadingAfterClosing(content)) failures.push('Há headings/iterações após ## Encerramento.');
112
112
  if (!usageSectionIsPlaced(content)) failures.push('## Uso de tokens e custos está fora da posição esperada.');
113
113
  if (hasDefaultPending(content)) warnings.push('Pendências ainda contém placeholders padrão.');
@@ -197,7 +197,7 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
197
197
  try {
198
198
  main();
199
199
  } catch (error) {
200
- process.stderr.write(`[codex-obsidian] Vault health falhou: ${error.message}\n`);
200
+ process.stderr.write(`[wendkeep] Vault health falhou: ${error.message}\n`);
201
201
  process.exitCode = 1;
202
202
  }
203
203
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wendkeep",
3
- "version": "0.29.1",
3
+ "version": "0.30.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": {
@@ -0,0 +1,32 @@
1
+ // `wendkeep renumber-decisions` — retroactive ADR fix. Renumbers every note in 04-Decisões to
2
+ // `ADR-<NNNN>-<slug>` in chronological order, renames the files, and rewrites every wikilink to
3
+ // them across the vault. Preview by default; pass --apply to write. Idempotent.
4
+ import { existsSync } from 'node:fs';
5
+ import { isAbsolute, resolve } from 'node:path';
6
+ import { renumberDecisions } from '../hooks/renumber-decisions.mjs';
7
+
8
+ function opt(argv, name) {
9
+ const i = argv.indexOf(name);
10
+ if (i >= 0) return argv[i + 1];
11
+ const eq = argv.find((a) => a.startsWith(`${name}=`));
12
+ return eq ? eq.slice(name.length + 1) : undefined;
13
+ }
14
+
15
+ export function runRenumberDecisions(argv) {
16
+ const vaultRaw = opt(argv, '--vault') || process.env.OBSIDIAN_VAULT_PATH;
17
+ if (!vaultRaw) { process.stderr.write('wendkeep renumber-decisions: no vault (--vault or OBSIDIAN_VAULT_PATH).\n'); process.exit(2); }
18
+ const vaultBase = isAbsolute(vaultRaw) ? vaultRaw : resolve(process.cwd(), vaultRaw);
19
+ if (!existsSync(vaultBase)) { process.stderr.write(`wendkeep renumber-decisions: vault not found: ${vaultBase}\n`); process.exit(2); }
20
+
21
+ const apply = argv.includes('--apply');
22
+ const report = renumberDecisions(vaultBase, { apply });
23
+
24
+ if (argv.includes('--json')) { process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); process.exit(0); }
25
+
26
+ process.stdout.write(`${report.total} decisão(ões) · ${report.renamed} a renomear${apply ? ` · ${report.filesTouched} arquivo(s) com links atualizados` : ''}\n`);
27
+ for (const p of report.plan.filter((x) => x.renamed)) {
28
+ process.stdout.write(` ADR-${String(p.num).padStart(4, '0')} ${p.from}\n → ${p.to}\n`);
29
+ }
30
+ if (!apply) process.stdout.write('\nNada foi escrito (preview). Rode com --apply para renomear e atualizar os wikilinks.\n');
31
+ process.exit(0);
32
+ }
package/src/taxonomy.mjs CHANGED
@@ -45,6 +45,7 @@ export const HOOK_FILES = [
45
45
  'session-backfill.mjs',
46
46
  'import-sessions.mjs',
47
47
  'decision-capture.mjs',
48
+ 'renumber-decisions.mjs',
48
49
  'subagent-stop.mjs',
49
50
  'task-log.mjs',
50
51
  'vault-health.mjs',