wendkeep 0.6.1 → 0.8.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,45 @@ 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.8.0] — 2026-07-05
8
+
9
+ Reach: internationalization + agent-agnostic distribution.
10
+
11
+ ### Added
12
+ - **Vault locale (i18n):** `wendkeep init --locale en` creates an English vault
13
+ (`02-Sessions`, `04-Decisions`, `08-Changes`, …, English months, English change scaffold,
14
+ English CORE skeleton, localized theme/graph groups). The locale is a vault property
15
+ (`.brain/config.json`), locked at init; absent = `pt-BR` — existing vaults are untouched
16
+ and never renamed. Parsers are **bilingual everywhere** (`Requisito|Requirement`,
17
+ `mata mutante|kill mutant`, CORE section sets), so mixed content never breaks.
18
+ - **AGENTS.md managed section:** `sync-defs`/`init` maintain a marker-delimited section in
19
+ the project's `AGENTS.md` (loop summary + skill inventory) — one file that Codex, Amp,
20
+ Cursor, Zed and any AGENTS.md-reading agent picks up. User content around it is preserved.
21
+ - **Harness contract v1.2** (`docs/14`): locale + AGENTS.md channel.
22
+
23
+ ### Deferred
24
+ - Extra mutation-report formats (mutmut/PIT) and per-agent session-hook wiring — backlog
25
+ (`docs/17`).
26
+
27
+ ## [0.7.0] — 2026-07-05
28
+
29
+ Ergonomics: the loop without hand-editing files.
30
+
31
+ ### Added
32
+ - **`change status`** — one screen: tasks (done/open with `[req:]`/`[sensor:]`), sensor
33
+ evidence, verdict state (ok / stale / incomplete / absent), mutation round.
34
+ - **`change done <id>` / `undone <id>`** — tick tasks from the CLI (exact-id anchored).
35
+ - **`change diff`** — dry-run preview of the spec promotion (`+` ADDED / `~` MODIFIED /
36
+ `-` REMOVED / `!` warnings) without touching `07-Specs`.
37
+ - **`spec list` / `spec show <capability>`** — read-only views over the living specs.
38
+ - **`sensors list`** — the sensors from `wendkeep.sensors.json`; a **JSON Schema** for the
39
+ file now ships in the package (`schema/`) and the init seed points `$schema` at it.
40
+ - README: "the loop in five minutes" worked example.
41
+
42
+ ### Fixed
43
+ - `change` subcommands without a positional argument no longer mistake the `--vault` value
44
+ for a slug.
45
+
7
46
  ## [0.6.1] — 2026-07-05
8
47
 
9
48
  Hardening: CI + real-world gate holes found by self-audit.
@@ -141,6 +180,8 @@ Initial release — the capture engine, extracted from a system in daily product
141
180
  - `wendkeep init` (cross-platform installer) + optional `@bitbonsai/mcpvault` MCP server.
142
181
 
143
182
  <!-- Only v0.4.0+ is tagged in git (history starts here); older versions link to npm. -->
183
+ [0.8.0]: https://github.com/rogersialves/wendkeep/releases/tag/v0.8.0
184
+ [0.7.0]: https://github.com/rogersialves/wendkeep/releases/tag/v0.7.0
144
185
  [0.6.1]: https://github.com/rogersialves/wendkeep/releases/tag/v0.6.1
145
186
  [0.6.0]: https://github.com/rogersialves/wendkeep/releases/tag/v0.6.0
146
187
  [0.5.0]: https://github.com/rogersialves/wendkeep/releases/tag/v0.5.0
package/README.md CHANGED
@@ -70,8 +70,11 @@ No re‑copying, no snapshot to re‑sync — the package is the single source o
70
70
  |---|---|
71
71
  | `wendkeep init` | Set up wendkeep in a project (vault taxonomy + settings + MCP + skills). |
72
72
  | `wendkeep hook <name>` | Run a session hook; invoked by `settings.json` (reads agent JSON on stdin). |
73
- | `wendkeep change <sub>` | Change lifecycle: `new <slug>` / `list` / `show <slug>` / `archive <slug>`. |
74
- | `wendkeep verify [--change s]` | Run the active (or named) change's task sensors; record evidence (the gate). |
73
+ | `wendkeep change <sub>` | Change lifecycle: `new [--simple]` / `list` / `show` / `status` / `done <id>` / `undone <id>` / `diff` / `archive [--force]`. |
74
+ | `wendkeep verify [--deep]` | Run the change's task sensors; `--deep` assembles the independent-verification package. |
75
+ | `wendkeep spec <sub>` | Living specs: `list` / `show <capability>`. |
76
+ | `wendkeep sensors list` | Show the sensors from `wendkeep.sensors.json` (JSON Schema included in the package). |
77
+ | `wendkeep lesson add "t" "l"` | Record a project-local lesson (injected at the next SessionStart). |
75
78
  | `wendkeep sync-defs` | Copy `.brain/agents\|skills` into the project (`.codex/agents`, `.claude/skills`). |
76
79
  | `wendkeep validate-memory [path]` | Validate `.brain/CORE.md` (cap 25, 3 sections, no secrets/PII). |
77
80
  | `wendkeep doctor [--vault P]` | Run a vault health check (integrity of sessions, registry, links). |
@@ -90,7 +93,37 @@ explore → propose → apply (TDD) → verify → archive
90
93
  - **Verify** — `wendkeep verify` runs the sensors your tasks declared (from `wendkeep.sensors.json` at the project root) and records `evidencia.json`. A critical red fails the gate; a `warning` red is advisory.
91
94
  - **Archive** — `wendkeep change archive <slug>` **gates** on the evidence (blocks unless every declared critical sensor is green), promotes each capability's spec delta (`ADDED`/`MODIFIED`/`REMOVED`) into the living `07-Specs/<capability>.md`, moves the change to `_arquivo/`, and mints an ADR in `04-Decisões/`.
92
95
 
93
- `wendkeep init` also seeds **native process skills** (`wk-workflow`, `wk-tdd`, `wk-debugging`, `wk-brainstorming`, `wk-planning`) into `.brain/skills` and delivers them to `.claude/skills` — the *how* layer, zero‑dep. Optional companions (`context-mode`, `dotcontext`, `understand-anything`, `caveman`) remain an opt‑in extra layer.
96
+ `wendkeep init` also seeds **native process skills** (`wk-workflow`, `wk-tdd`, `wk-debugging`, `wk-brainstorming`, `wk-planning`, `wk-verify`) into `.brain/skills` and delivers them to `.claude/skills` — the *how* layer, zero‑dep. Optional companions (`context-mode`, `dotcontext`, `understand-anything`, `caveman`) remain an opt‑in extra layer.
97
+
98
+ ### The loop in five minutes
99
+
100
+ ```bash
101
+ npx wendkeep init --yes # vault + hooks + sensors + skills
102
+ npx wendkeep change new dark-mode # proposta/design/tarefas — change is now active
103
+ ```
104
+
105
+ Edit `tarefas.md` — tag proof and requirement per task:
106
+
107
+ ```markdown
108
+ - [ ] 1.1 toggle persists across sessions [req:UI-1] [sensor:tests]
109
+ ```
110
+
111
+ Declare the capability in `proposta.md` (`specs: [ui]`) and author its delta in
112
+ `specs/ui/spec.md` (`## ADDED Requirements` → `### Requisito: UI-1 — dark mode toggle`). Then:
113
+
114
+ ```bash
115
+ npx wendkeep change status # one screen: tasks / sensors / verdict
116
+ npx wendkeep change done 1.1 # tick a task from the CLI
117
+ npx wendkeep verify # run the declared sensors -> evidencia.json
118
+ npx wendkeep verify --deep # assemble the verification package
119
+ # the wk-verify skill (fresh, read-only pass) writes verdict.json
120
+ npx wendkeep change diff # preview what will land in 07-Specs
121
+ npx wendkeep change archive dark-mode # gate: sensors + verdict + no open tasks
122
+ ```
123
+
124
+ The archive promotes the delta into the living `07-Specs/ui.md`, mints an ADR, and the
125
+ Obsidian graph now links *session ↔ change ↔ requirement ↔ decision*. A change that names
126
+ no `[req:]` skips the independent verdict — the sensor gate is its proof.
94
127
 
95
128
  ## How it works
96
129
 
package/bin/wendkeep.mjs CHANGED
@@ -43,7 +43,10 @@ Usage:
43
43
  agent's JSON on stdin. Names: ${RUNNABLE_HOOKS.join(', ')}.
44
44
 
45
45
  wendkeep doctor [--vault P] Run a vault health check.
46
- wendkeep change <sub> Change lifecycle: new <slug> | list | show <slug> | archive <slug>.
46
+ wendkeep change <sub> Change lifecycle: new [--simple] | list | show | status |
47
+ done <id> | undone <id> | diff | archive [--force].
48
+ wendkeep spec <sub> Living specs: list | show <capability>.
49
+ wendkeep sensors list Show the sensors from wendkeep.sensors.json.
47
50
  wendkeep verify [--change s] Run a change's task sensors + record evidence (the gate).
48
51
  wendkeep lesson add "t" "l" Record a project-local lesson (injected at SessionStart).
49
52
  wendkeep validate-memory [path] Validate .brain/CORE.md against the compaction
@@ -117,6 +120,16 @@ async function main() {
117
120
  runLesson(rest);
118
121
  break;
119
122
  }
123
+ case 'spec': {
124
+ const { runSpec } = await import('../src/spec.mjs');
125
+ runSpec(rest);
126
+ break;
127
+ }
128
+ case 'sensors': {
129
+ const { runSensors } = await import('../src/sensors.mjs');
130
+ runSensors(rest);
131
+ break;
132
+ }
120
133
  case '--version':
121
134
  case '-v':
122
135
  process.stdout.write(`${version()}\n`);
@@ -3,6 +3,7 @@
3
3
  import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
5
  import { ensureDir, stripYamlQuotes, toVaultRelative } from './obsidian-common.mjs';
6
+ import { getLocale } from './locale.mjs';
6
7
 
7
8
  export function brainDir(vaultBase) {
8
9
  return join(vaultBase, '.brain');
@@ -60,7 +61,7 @@ function derivedLinks(content) {
60
61
  // Varre 02-Sessões/** e regrava .brain/index.jsonl inteiro. Provider-agnóstico.
61
62
  export function buildBrainIndex(vaultBase) {
62
63
  const rows = [];
63
- for (const fp of walkMd(join(vaultBase, '02-Sessões'))) {
64
+ for (const fp of walkMd(join(vaultBase, getLocale(vaultBase).folders.sessions))) {
64
65
  let content;
65
66
  try { content = readFileSync(fp, 'utf8'); } catch { continue; }
66
67
  const fm = parseFrontmatter(content);
@@ -2,20 +2,20 @@
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, writeFileSync } from 'node:fs';
5
- import { join } from 'node:path';
5
+ import { dirname, join } from 'node:path';
6
6
  import { ensureDir, wikilinkFromRel } from './obsidian-common.mjs';
7
7
  import { parseSpecsList, promoteSpecs } from './spec-core.mjs';
8
+ import { getLocale } from './locale.mjs';
8
9
 
9
- export const CHANGES_DIR = '08-Mudanças';
10
- export const SPECS_DIR = '07-Specs';
11
10
  export const ARCHIVE_DIR = '_arquivo';
12
11
  const POINTER = '.brain/CURRENT_CHANGE.md';
13
12
 
14
- export function changeDirRel(slug) {
15
- return join(CHANGES_DIR, slug);
13
+ export function changeDirRel(slug, vaultBase) {
14
+ return join(getLocale(vaultBase).folders.changes, slug);
16
15
  }
17
16
 
18
- export function renderChangeScaffold({ slug, sessionRel, dateStr }) {
17
+ export function renderChangeScaffold({ slug, sessionRel, dateStr, locale = 'pt-BR' }) {
18
+ const en = locale === 'en';
19
19
  const source = sessionRel ? `\n - "${wikilinkFromRel(sessionRel)}"` : ' []';
20
20
  const proposta = `---
21
21
  type: change
@@ -31,27 +31,20 @@ specs: []
31
31
 
32
32
  # ${slug}
33
33
 
34
- ## Por quê
35
-
36
- (motivo da mudança)
37
-
38
- ## O que muda
39
-
40
- (escopo da mudança)
34
+ ${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
35
  `;
42
36
  const design = `# ${slug} — design
43
37
 
44
- ## Abordagem
45
-
46
- (abordagem técnica)
38
+ ${en ? '## Approach\n\n(technical approach)' : '## Abordagem\n\n(abordagem técnica)'}
47
39
  `;
48
- const tarefas = `# ${slug} — tarefas
40
+ const tarefas = `# ${slug} — ${en ? 'tasks' : 'tarefas'}
49
41
 
50
- - [ ] 1.1 (primeira tarefa)
42
+ - [ ] 1.1 ${en ? '(first task)' : '(primeira tarefa)'}
51
43
  `;
44
+ const reqHeading = en ? 'Requirement' : 'Requisito';
52
45
  const specDelta = `## ADDED Requirements
53
- ### Requisito: (nome)
54
- (comportamento / cenários)
46
+ ### ${reqHeading}: ${en ? '(name)' : '(nome)'}
47
+ ${en ? '(behaviour / scenarios)' : '(comportamento / cenários)'}
55
48
 
56
49
  ## MODIFIED Requirements
57
50
 
@@ -80,10 +73,11 @@ export function clearActiveChange(vaultBase) {
80
73
  }
81
74
 
82
75
  export function newChange(vaultBase, slug, { sessionRel = '', dateStr, simple = false }) {
83
- const dir = join(vaultBase, CHANGES_DIR, slug);
76
+ const loc = getLocale(vaultBase);
77
+ const dir = join(vaultBase, loc.folders.changes, slug);
84
78
  const existed = existsSync(join(dir, 'proposta.md'));
85
79
  mkdirSync(dir, { recursive: true });
86
- const files = renderChangeScaffold({ slug, sessionRel, dateStr });
80
+ const files = renderChangeScaffold({ slug, sessionRel, dateStr, locale: loc.id });
87
81
  const write = (name, content) => {
88
82
  const f = join(dir, name);
89
83
  if (!existsSync(f)) writeFileSync(f, content, 'utf8');
@@ -100,7 +94,7 @@ export function newChange(vaultBase, slug, { sessionRel = '', dateStr, simple =
100
94
  }
101
95
  }
102
96
  setActiveChange(vaultBase, slug);
103
- return { rel: changeDirRel(slug), created: !existed };
97
+ return { rel: changeDirRel(slug, vaultBase), created: !existed };
104
98
  }
105
99
 
106
100
  export function parseTasks(md) {
@@ -123,8 +117,19 @@ export function parseTasks(md) {
123
117
  return tasks;
124
118
  }
125
119
 
120
+ // Toggle a task checkbox by its exact id (0.7.0 ergonomics). Returns false when absent.
121
+ export function setTaskDone(changeDir, taskId, done = true) {
122
+ const path = join(changeDir, 'tarefas.md');
123
+ const md = readFileSync(path, 'utf8');
124
+ const esc = String(taskId).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
125
+ const re = new RegExp(`^(-\\s+\\[)( |x)(\\]\\s+${esc}\\s)`, 'm');
126
+ if (!re.test(md)) return false;
127
+ writeFileSync(path, md.replace(re, `$1${done ? 'x' : ' '}$3`), 'utf8');
128
+ return true;
129
+ }
130
+
126
131
  export function listChanges(vaultBase) {
127
- const base = join(vaultBase, CHANGES_DIR);
132
+ const base = join(vaultBase, getLocale(vaultBase).folders.changes);
128
133
  const active = [];
129
134
  let archived = [];
130
135
  try {
@@ -142,29 +147,33 @@ export function listChanges(vaultBase) {
142
147
  export function buildActiveChangeInjection(vaultBase, { maxTasks = 8 } = {}) {
143
148
  const slug = activeChange(vaultBase);
144
149
  if (!slug) return '';
150
+ const chDir = getLocale(vaultBase).folders.changes;
145
151
  let md = '';
146
- try { md = readFileSync(join(vaultBase, CHANGES_DIR, slug, 'tarefas.md'), 'utf8'); } catch { return ''; }
152
+ try { md = readFileSync(join(vaultBase, chDir, slug, 'tarefas.md'), 'utf8'); } catch { return ''; }
147
153
  const open = parseTasks(md).filter((t) => !t.done).slice(0, maxTasks);
148
154
  const lines = open.map((t) => `- [ ] ${t.id} ${t.text}`);
149
155
  const more = open.length === maxTasks ? '\n*…mais tarefas em tarefas.md*' : '';
150
156
  return `<active_change>
151
- Mudança ativa: ${slug} — [[${CHANGES_DIR}/${slug}/proposta]]. Tarefas abertas:
157
+ Mudança ativa: ${slug} — [[${chDir}/${slug}/proposta]]. Tarefas abertas:
152
158
  ${lines.join('\n')}${more}
153
159
  </active_change>`;
154
160
  }
155
161
 
156
162
  export function activeChangeLink(vaultBase) {
157
163
  const slug = activeChange(vaultBase);
158
- return slug ? `Change ativa: [[${CHANGES_DIR}/${slug}/proposta]]` : '';
164
+ return slug ? `Change ativa: [[${getLocale(vaultBase).folders.changes}/${slug}/proposta]]` : '';
159
165
  }
160
166
 
161
167
  // Append fix tasks for surviving mutants to a change's tarefas.md (Wave B). Deduped by
162
168
  // file:line, numbered M.<n> continuing from any existing fix tasks. Returns count added.
163
169
  export function appendFixTasks(changeDir, mutants, sensorId) {
164
170
  const path = join(changeDir, 'tarefas.md');
171
+ // changeDir = <vault>/<changesDir>/<slug> — derive the vault for the locale verb.
172
+ const verb = getLocale(dirname(dirname(changeDir))).fixTaskVerb;
165
173
  let md = '';
166
174
  try { md = readFileSync(path, 'utf8'); } catch { /* nova */ }
167
- const existing = new Set([...md.matchAll(/mata mutante (\S+):(\d+)/g)].map((m) => `${m[1]}:${m[2]}`));
175
+ // Dedup is bilingual so a vault that switched locale mid-change never duplicates.
176
+ const existing = new Set([...md.matchAll(/(?:mata mutante|kill mutant) (\S+):(\d+)/g)].map((m) => `${m[1]}:${m[2]}`));
168
177
  const nums = [...md.matchAll(/^-\s+\[[ x]\]\s+M\.(\d+)\b/gm)].map((m) => Number(m[1]));
169
178
  let n = nums.length ? Math.max(...nums) : 0;
170
179
  const lines = [];
@@ -173,7 +182,7 @@ export function appendFixTasks(changeDir, mutants, sensorId) {
173
182
  if (existing.has(key)) continue;
174
183
  existing.add(key);
175
184
  n += 1;
176
- lines.push(`- [ ] M.${n} mata mutante ${mut.file}:${mut.line} (${mut.mutator}) [sensor:${sensorId}]`);
185
+ lines.push(`- [ ] M.${n} ${verb} ${mut.file}:${mut.line} (${mut.mutator}) [sensor:${sensorId}]`);
177
186
  }
178
187
  if (!lines.length) return 0;
179
188
  const sep = md === '' || md.endsWith('\n') ? '' : '\n';
@@ -187,11 +196,13 @@ export function gateGreen() {
187
196
  }
188
197
 
189
198
  export function archiveChange(vaultBase, slug, { gate = gateGreen, dateStr, adrNum }) {
190
- const src = join(vaultBase, CHANGES_DIR, slug);
199
+ const loc = getLocale(vaultBase);
200
+ const chDir = loc.folders.changes;
201
+ const src = join(vaultBase, chDir, slug);
191
202
  const verdict = gate(src);
192
203
  if (!verdict.ok) return { ok: false, failing: verdict.failing || [] };
193
204
 
194
- const destRel = join(CHANGES_DIR, ARCHIVE_DIR, `${dateStr}-${slug}`);
205
+ const destRel = join(chDir, ARCHIVE_DIR, `${dateStr}-${slug}`);
195
206
  const changeWikilink = wikilinkFromRel(join(destRel, 'proposta'));
196
207
 
197
208
  // Promote spec deltas into the living 07-Specs BEFORE moving (deltas live in src).
@@ -209,16 +220,16 @@ export function archiveChange(vaultBase, slug, { gate = gateGreen, dateStr, adrN
209
220
  let reqIds = [];
210
221
  try { reqIds = [...new Set(parseTasks(readFileSync(join(src, 'tarefas.md'), 'utf8')).map((t) => t.req).filter(Boolean))]; } catch { /* sem tarefas */ }
211
222
 
212
- ensureDir(join(vaultBase, CHANGES_DIR, ARCHIVE_DIR));
223
+ ensureDir(join(vaultBase, chDir, ARCHIVE_DIR));
213
224
  renameSync(src, join(vaultBase, destRel));
214
225
 
215
226
  const [year] = String(dateStr).split('-');
216
- const adrDirRel = join('04-Decisões', year);
227
+ const adrDirRel = join(loc.folders.decisions, year);
217
228
  ensureDir(join(vaultBase, adrDirRel));
218
229
  const num = String(adrNum).padStart(3, '0');
219
230
  const adrRel = join(adrDirRel, `ADR-${num}-${slug}.md`);
220
231
  const capLine = promoted.length
221
- ? `\n\nCapabilities: ${promoted.map((c) => wikilinkFromRel(join(SPECS_DIR, c))).join(', ')}.`
232
+ ? `\n\nCapabilities: ${promoted.map((c) => wikilinkFromRel(join(loc.folders.specs, c))).join(', ')}.`
222
233
  : '';
223
234
  const reqLine = reqIds.length ? `\n\nRequisitos: ${reqIds.join(', ')}.` : '';
224
235
  writeFileSync(join(vaultBase, adrRel), `---
@@ -4,11 +4,12 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
5
  import { activeChange, parseTasks } from './change-core.mjs';
6
6
  import { parseRequirements, parseSpecsList, parseDelta, evaluateVerdict } from './spec-core.mjs';
7
-
8
- const CHANGES_DIR = '08-Mudanças';
9
- const SPECS_DIR = '07-Specs';
7
+ import { getLocale } from './locale.mjs';
10
8
 
11
9
  export function checkHarness(vaultBase, projectRoot) {
10
+ const loc = getLocale(vaultBase);
11
+ const CHANGES_DIR = loc.folders.changes;
12
+ const SPECS_DIR = loc.folders.specs;
12
13
  const errors = [];
13
14
  const warnings = [];
14
15
 
@@ -12,6 +12,7 @@ import {
12
12
  toVaultRelative,
13
13
  wikilinkFromRel,
14
14
  } from './obsidian-common.mjs';
15
+ import { getLocale } from './locale.mjs';
15
16
 
16
17
  function yamlQuote(value) {
17
18
  return `"${String(value || '').replaceAll('"', '\\"')}"`;
@@ -446,7 +447,7 @@ _Registrar como este conhecimento pode ser reutilizado._
446
447
  `;
447
448
  }
448
449
 
449
- const DERIVED_FOLDERS = { bugs: '05-Bugs', decisions: '04-Decisões', learnings: '06-Aprendizados' };
450
+ const derivedFoldersFor = (vaultBase) => { const f = getLocale(vaultBase).folders; return { bugs: f.bugs, decisions: f.decisions, learnings: f.learnings }; };
450
451
 
451
452
  function listMd(dir) {
452
453
  try { return readdirSync(dir).filter((f) => f.endsWith('.md')); } catch { return []; }
@@ -456,8 +457,8 @@ function listMd(dir) {
456
457
  function existingKeysForSession(vaultBase, sessionRel, dateStr) {
457
458
  const wikilink = wikilinkFromRel(sessionRel);
458
459
  const out = { bugs: [], decisions: [], learnings: [] };
459
- for (const [type, folder] of Object.entries(DERIVED_FOLDERS)) {
460
- const dir = join(vaultBase, monthFolderRelFromDateStr(folder, dateStr));
460
+ for (const [type, folder] of Object.entries(derivedFoldersFor(vaultBase))) {
461
+ const dir = join(vaultBase, monthFolderRelFromDateStr(folder, dateStr, vaultBase));
461
462
  for (const fileName of listMd(dir)) {
462
463
  try {
463
464
  const c = readFileSync(join(dir, fileName), 'utf-8');
@@ -477,9 +478,10 @@ function alreadyHasKey(keys, candidate) {
477
478
  export function createLinkedNotes(vaultBase, dateStr, sessionRel, tx, options = {}) {
478
479
  const linked = { decisions: [], bugs: [], learnings: [] };
479
480
  const provider = providerMeta(options.provider);
480
- const bugsDir = join(vaultBase, monthFolderRelFromDateStr('05-Bugs', dateStr));
481
- const decisionsDir = join(vaultBase, monthFolderRelFromDateStr('04-Decisões', dateStr));
482
- const learningsDir = join(vaultBase, monthFolderRelFromDateStr('06-Aprendizados', dateStr));
481
+ const locF = getLocale(vaultBase).folders;
482
+ const bugsDir = join(vaultBase, monthFolderRelFromDateStr(locF.bugs, dateStr, vaultBase));
483
+ const decisionsDir = join(vaultBase, monthFolderRelFromDateStr(locF.decisions, dateStr, vaultBase));
484
+ const learningsDir = join(vaultBase, monthFolderRelFromDateStr(locF.learnings, dateStr, vaultBase));
483
485
  ensureDir(bugsDir);
484
486
  ensureDir(decisionsDir);
485
487
  ensureDir(learningsDir);
@@ -0,0 +1,73 @@
1
+ // hooks/locale.mjs — vault locale (i18n, 0.8.0). The locale is a property of the VAULT,
2
+ // stored at <vault>/.brain/config.json ({ "locale": "en" }); absent = pt-BR (full backward
3
+ // compat). Parsers stay bilingual everywhere; only RENDERING follows the locale.
4
+ import { readFileSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+
7
+ export const LOCALES = {
8
+ 'pt-BR': {
9
+ id: 'pt-BR',
10
+ folders: {
11
+ inbox: '00-Inbox',
12
+ project: '01-Projeto',
13
+ sessions: '02-Sessões',
14
+ linear: '03-Linear',
15
+ decisions: '04-Decisões',
16
+ bugs: '05-Bugs',
17
+ learnings: '06-Aprendizados',
18
+ specs: '07-Specs',
19
+ changes: '08-Mudanças',
20
+ },
21
+ months: ['01-JAN', '02-FEV', '03-MAR', '04-ABR', '05-MAI', '06-JUN', '07-JUL', '08-AGO', '09-SET', '10-OUT', '11-NOV', '12-DEZ'],
22
+ reqHeading: 'Requisito',
23
+ fixTaskVerb: 'mata mutante',
24
+ coreSections: ['Preferências do Usuário', 'Padrões Ativos', 'Pendências Abertas'],
25
+ },
26
+ en: {
27
+ id: 'en',
28
+ folders: {
29
+ inbox: '00-Inbox',
30
+ project: '01-Project',
31
+ sessions: '02-Sessions',
32
+ linear: '03-Linear',
33
+ decisions: '04-Decisions',
34
+ bugs: '05-Bugs',
35
+ learnings: '06-Learnings',
36
+ specs: '07-Specs',
37
+ changes: '08-Changes',
38
+ },
39
+ months: ['01-JAN', '02-FEB', '03-MAR', '04-APR', '05-MAY', '06-JUN', '07-JUL', '08-AUG', '09-SEP', '10-OCT', '11-NOV', '12-DEC'],
40
+ reqHeading: 'Requirement',
41
+ fixTaskVerb: 'kill mutant',
42
+ coreSections: ['User Preferences', 'Active Patterns', 'Open Items'],
43
+ },
44
+ };
45
+
46
+ export const DEFAULT_LOCALE = 'pt-BR';
47
+
48
+ // Per-process cache: one vault per process (hooks + CLI), reads are hot paths.
49
+ const cache = new Map();
50
+
51
+ export function getLocale(vaultBase) {
52
+ if (!vaultBase) return LOCALES[DEFAULT_LOCALE];
53
+ const key = String(vaultBase);
54
+ if (cache.has(key)) return cache.get(key);
55
+ let id = DEFAULT_LOCALE;
56
+ try {
57
+ const data = JSON.parse(readFileSync(join(key, '.brain', 'config.json'), 'utf8'));
58
+ if (data.locale && LOCALES[data.locale]) id = data.locale;
59
+ } catch { /* sem config = pt-BR */ }
60
+ const loc = LOCALES[id];
61
+ cache.set(key, loc);
62
+ return loc;
63
+ }
64
+
65
+ // Test hook: drop the memoized entry (tests rewrite config.json under one tmpdir).
66
+ export function clearLocaleCache() {
67
+ cache.clear();
68
+ }
69
+
70
+ // The full vault taxonomy for a locale (folders + fixed entries), in creation order.
71
+ export function vaultFolders(loc) {
72
+ return [...Object.values(loc.folders), 'Templates', '.brain'];
73
+ }
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from 'fs';
3
3
  import { basename, dirname, join, relative } from 'path';
4
+ import { getLocale } from './locale.mjs';
4
5
 
5
6
  // Neutral fallback only. The vault is normally resolved from the
6
7
  // OBSIDIAN_VAULT_PATH env var (set by `wendkeep init`) via getVaultBase() below.
@@ -128,26 +129,28 @@ export function formatLocalIso(date = new Date()) {
128
129
  return `${formatDate(date)}T${formatTime(date)}`;
129
130
  }
130
131
 
131
- export function datedFolderRel(rootFolder, date = new Date()) {
132
+ // Locale (0.8.0): month labels + folder names come from the vault locale when a
133
+ // vaultBase is given; without it, pt-BR (backward compat — every legacy caller).
134
+ export function datedFolderRel(rootFolder, date = new Date(), vaultBase) {
132
135
  const p = localDateParts(date);
133
- return join(rootFolder, String(p.year), MONTH_FOLDERS[p.month - 1], `DIA ${pad2(p.day)}`);
136
+ return join(rootFolder, String(p.year), getLocale(vaultBase).months[p.month - 1], `DIA ${pad2(p.day)}`);
134
137
  }
135
138
 
136
139
  // Mesma estrutura datada a partir de uma string 'YYYY-MM-DD' (sessões: até o DIA).
137
- export function datedFolderRelFromDateStr(rootFolder, dateStr) {
140
+ export function datedFolderRelFromDateStr(rootFolder, dateStr, vaultBase) {
138
141
  const [year, month, day] = String(dateStr).split('-');
139
- return join(rootFolder, year, MONTH_FOLDERS[Number(month) - 1], `DIA ${pad2(day)}`);
142
+ return join(rootFolder, year, getLocale(vaultBase).months[Number(month) - 1], `DIA ${pad2(day)}`);
140
143
  }
141
144
 
142
145
  // Estrutura até o MÊS (sem DIA) — usada pelas notas derivadas (decisões/bugs/
143
146
  // aprendizados): tudo do mês fica junto em <pasta>/<ano>/<MM-MMM>/.
144
- export function monthFolderRelFromDateStr(rootFolder, dateStr) {
147
+ export function monthFolderRelFromDateStr(rootFolder, dateStr, vaultBase) {
145
148
  const [year, month] = String(dateStr).split('-');
146
- return join(rootFolder, year, MONTH_FOLDERS[Number(month) - 1]);
149
+ return join(rootFolder, year, getLocale(vaultBase).months[Number(month) - 1]);
147
150
  }
148
151
 
149
- export function sessionFolderRel(date = new Date()) {
150
- return datedFolderRel('02-Sessões', date);
152
+ export function sessionFolderRel(date = new Date(), vaultBase) {
153
+ return datedFolderRel(getLocale(vaultBase).folders.sessions, date, vaultBase);
151
154
  }
152
155
 
153
156
  export function controlPath(vaultBase) {
@@ -532,7 +535,7 @@ export function listMarkdownFiles(dir) {
532
535
  }
533
536
 
534
537
  export function getNextAdrNumber(vaultBase) {
535
- const decisionsDir = join(vaultBase, '04-Decisões');
538
+ const decisionsDir = join(vaultBase, getLocale(vaultBase).folders.decisions);
536
539
  let max = 0;
537
540
  // Varre recursivamente: os ADRs agora vivem em subpastas datadas (AAAA/MM-MMM/DIA DD).
538
541
  const walk = (dir) => {
@@ -115,7 +115,7 @@ Sessão ainda em andamento.
115
115
  }
116
116
 
117
117
  function allocateSessionPath(vaultBase, now, summary = 'session') {
118
- const folderRel = sessionFolderRel(now);
118
+ const folderRel = sessionFolderRel(now, vaultBase);
119
119
  const folderAbs = join(vaultBase, folderRel);
120
120
  ensureDir(folderAbs);
121
121
 
@@ -113,7 +113,7 @@ Sessão ainda em andamento.
113
113
  }
114
114
 
115
115
  function allocateSessionPath(vaultBase, now, summary = 'session') {
116
- const folderRel = sessionFolderRel(now);
116
+ const folderRel = sessionFolderRel(now, vaultBase);
117
117
  const folderAbs = join(vaultBase, folderRel);
118
118
  ensureDir(folderAbs);
119
119
 
@@ -7,6 +7,7 @@ import { createLinkedNotes } from './linked-notes.mjs';
7
7
  import { addUsage, costBreakdown, emptyTokenUsage, normalizeClaudeUsage, normalizeCodexUsage, priceForModel, updateSessionUsage } from './token-usage.mjs';
8
8
  import { buildBrainDigest, buildBrainIndex } from './brain-core.mjs';
9
9
  import { activeChangeLink } from './change-core.mjs';
10
+ import { getLocale } from './locale.mjs';
10
11
  import {
11
12
  ensureDir,
12
13
  findActiveSessionByTranscript,
@@ -781,10 +782,11 @@ function noteReferencesSession(content, sessionRel) {
781
782
 
782
783
  function findLinkedDerivedNotes(vaultBase, sessionRel) {
783
784
  const linked = { decisions: [], bugs: [], learnings: [] };
785
+ const locF = getLocale(vaultBase).folders;
784
786
  const folders = {
785
- decisions: '04-Decisões',
786
- bugs: '05-Bugs',
787
- learnings: '06-Aprendizados',
787
+ decisions: locF.decisions,
788
+ bugs: locF.bugs,
789
+ learnings: locF.learnings,
788
790
  };
789
791
 
790
792
  for (const [key, folder] of Object.entries(folders)) {
@@ -4,14 +4,15 @@ import { createHash } from 'node:crypto';
4
4
  import { readFileSync, writeFileSync } from 'node:fs';
5
5
  import { join } from 'node:path';
6
6
  import { ensureDir } from './obsidian-common.mjs';
7
+ import { getLocale } from './locale.mjs';
7
8
 
8
9
  // Short stable fingerprint of tarefas.md — freshness check between package/verdict and gate.
9
10
  export function tasksHashOf(md) {
10
11
  return createHash('sha1').update(String(md)).digest('hex').slice(0, 12);
11
12
  }
12
13
 
13
- const SPECS_DIR = '07-Specs';
14
- const REQ_RE = /^### Requisito:\s*(.+)$/gm;
14
+ // Parse is BILINGUAL always (mixed vaults never break); rendering follows the vault locale.
15
+ const REQ_RE = /^### (?:Requisito|Requirement):\s*(.+)$/gm;
15
16
 
16
17
  export function parseRequirements(md) {
17
18
  const text = String(md);
@@ -71,8 +72,8 @@ export function applyDelta(reqs, delta) {
71
72
  return { reqs: out, warnings };
72
73
  }
73
74
 
74
- export function renderSpec(capability, reqs, { footer } = {}) {
75
- const blocks = reqs.map((r) => `### Requisito: ${r.id ? `${r.id} — ${r.name}` : r.name}\n${r.body}`).join('\n\n');
75
+ export function renderSpec(capability, reqs, { footer, reqHeading = 'Requisito' } = {}) {
76
+ const blocks = reqs.map((r) => `### ${reqHeading}: ${r.id ? `${r.id} — ${r.name}` : r.name}\n${r.body}`).join('\n\n');
76
77
  const foot = footer ? `\n\n> ${footer}\n` : '\n';
77
78
  return `---\ntype: spec\ncssclasses:\n - topic-spec\ntags:\n - spec\n---\n\n# ${capability}\n\n## Requisitos\n\n${blocks}${foot}`;
78
79
  }
@@ -88,20 +89,22 @@ export function parseSpecsList(propostaMd) {
88
89
 
89
90
  // Merge each capability's delta (in the change) into the living spec in 07-Specs.
90
91
  export function promoteSpecs(vaultBase, changeDir, specs, { changeWikilink, dateStr } = {}) {
92
+ const loc = getLocale(vaultBase);
93
+ const specsDir = loc.folders.specs;
91
94
  const promoted = [];
92
95
  const warnings = [];
93
96
  for (const cap of specs) {
94
97
  let delta;
95
98
  try { delta = parseDelta(readFileSync(join(changeDir, 'specs', cap, 'spec.md'), 'utf8')); }
96
99
  catch { warnings.push(`sem delta para ${cap}`); continue; }
97
- const livePath = join(vaultBase, SPECS_DIR, `${cap}.md`);
100
+ const livePath = join(vaultBase, specsDir, `${cap}.md`);
98
101
  let current = [];
99
102
  try { current = parseRequirements(readFileSync(livePath, 'utf8')); } catch { /* nova capability */ }
100
103
  const applied = applyDelta(current, delta);
101
104
  warnings.push(...applied.warnings.map((w) => `${cap}: ${w}`));
102
- ensureDir(join(vaultBase, SPECS_DIR));
105
+ ensureDir(join(vaultBase, specsDir));
103
106
  const footer = changeWikilink ? `Atualizado por ${changeWikilink} em ${dateStr}.` : '';
104
- writeFileSync(livePath, renderSpec(cap, applied.reqs, { footer }), 'utf8');
107
+ writeFileSync(livePath, renderSpec(cap, applied.reqs, { footer, reqHeading: loc.reqHeading }), 'utf8');
105
108
  promoted.push(cap);
106
109
  }
107
110
  return { promoted, warnings };
@@ -10,6 +10,7 @@ import {
10
10
  readSessionRegistry,
11
11
  wikilinkFromRel,
12
12
  } from './obsidian-common.mjs';
13
+ import { getLocale } from './locale.mjs';
13
14
 
14
15
  const DEFAULT_PENDING_PATTERNS = [
15
16
  /^- \[ \] Revisar resumo da sessão$/i,
@@ -164,7 +165,8 @@ export function runVaultHealth({ vaultBase, session = '' }) {
164
165
  .length;
165
166
  if (staleDone) warnings.push(`${staleDone} entradas active com ended_at no SESSION_REGISTRY.`);
166
167
 
167
- const derivedFolders = ['04-Decisões', '05-Bugs', '06-Aprendizados'];
168
+ const locF = getLocale(vaultBase).folders;
169
+ const derivedFolders = [locF.decisions, locF.bugs, locF.learnings];
168
170
  const derivedCount = derivedFolders.reduce((total, folder) => {
169
171
  const dir = join(vaultBase, folder);
170
172
  return total + (existsSync(dir) ? listMarkdownFiles(dir).length : 0);