wendkeep 0.29.2 → 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 +24 -0
- package/bin/wendkeep.mjs +8 -0
- package/hooks/change-core.mjs +1 -1
- package/hooks/decision-capture.mjs +37 -9
- package/hooks/linked-notes.mjs +3 -3
- package/hooks/renumber-decisions.mjs +198 -0
- package/package.json +1 -1
- package/src/renumber.mjs +32 -0
- package/src/taxonomy.mjs +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,30 @@ 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
|
+
|
|
7
31
|
## [0.29.2] — 2026-07-09
|
|
8
32
|
|
|
9
33
|
### Fixed
|
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`);
|
package/hooks/change-core.mjs
CHANGED
|
@@ -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(
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
}
|
package/hooks/linked-notes.mjs
CHANGED
|
@@ -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
|
|
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(
|
|
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(
|
|
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);
|
|
@@ -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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wendkeep",
|
|
3
|
-
"version": "0.
|
|
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": {
|
package/src/renumber.mjs
ADDED
|
@@ -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
|
+
}
|