wendkeep 0.20.1 → 0.22.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,62 @@ 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.22.0] — 2026-07-08
8
+
9
+ Hardening — 10 audit-confirmed bugs fixed (each survived an adversarial refuter).
10
+
11
+ ### Fixed
12
+ - **Archive trusted stale evidence**: `verify` now seals `evidencia.json` with a `.evidence-hash`
13
+ (the `tarefas.md` hash it ran against); the archive gate rejects evidence gone stale (a sensor
14
+ task added/edited after the last green verify). `src/verify.mjs`, `src/change.mjs`.
15
+ - **Archiving a non-active change wiped the active pointer**: `archiveChange` now only clears
16
+ `CURRENT_CHANGE` when the archived slug IS the active one. `hooks/change-core.mjs`.
17
+ - **Non-atomic archive**: a destination-exists guard fails BEFORE promoting specs (same-day slug
18
+ reuse no longer half-promotes `07-Specs` then errors on the move); `renameSync` wrapped.
19
+ - **Archived proposta kept `status: active`** → flipped to `status: archived` on archive.
20
+ - **Import dropped Codex sessions whose `session_meta` exceeded 16KB** (~31% in production): the
21
+ reader now grows the buffer to the first newline instead of a fixed prefix. `hooks/import-sessions.mjs`.
22
+ - **Cost was silently $0 for untabled models**: `normalizeModelName` strips a `[1m]` context tag
23
+ generically (so `claude-opus-4-8[1m]` prices), `claude-sonnet-5` added, plus approximate Codex
24
+ `gpt-5.4`/`gpt-5.3-codex` aliases. `hooks/token-usage.mjs`, `hooks/pricing.json`.
25
+ - **Imported session titles came from harness meta-prompts** ("Generate a concise title…"): those
26
+ utility prompts are now filtered in both parsers' `shouldIgnoreUserText`.
27
+ - **Session↔change link died on reopen**: the change wikilink moved from an append after
28
+ `## Encerramento` (stripped every turn) to a durable `## Mudanças` section before it, which
29
+ accumulates every change the session touched. `hooks/session-stop.mjs`.
30
+ - **`init --force` duplicated every hook group**: now refreshes the managed entry in place instead
31
+ of appending a second identical group. `src/init.mjs`.
32
+ - **Injected DIGEST carried dead wikilinks**: `buildBrainDigest` now keeps only targets that
33
+ resolve to a real note and drops truncated placeholders. `hooks/brain-core.mjs`.
34
+
35
+ ### Changed
36
+ - **Docs coherence**: `--help` moved `--top` from `import` to `cost`, gave `import` its real flags
37
+ (`--source`/`--stamp-ids`/`--from`/`--codex-from`/`--limit`/`--dry-run`, "Claude + Codex"), and
38
+ added `verify [--deep]`. README dropped the stale "v0.1" framing, fixed the 5→6 skill list
39
+ (adds `wk-verify`), made the `docs/` link absolute, and documented the `<wk_process>` router +
40
+ the G0 scaffold gate.
41
+
42
+ ## [0.21.0] — 2026-07-08
43
+
44
+ Process enforcement — fixes from a real planning failure (production session): the model planned
45
+ in chat, never invoked the wk-* skills, left the change scaffold raw and archived it with
46
+ `--force`, minting a bogus ADR.
47
+
48
+ ### Added
49
+ - **`<wk_process>` router injected every session** (brain-inject): the enforcement layer the
50
+ skills were missing. Plan → wk-brainstorming + wk-planning; record → `change new` + FILL
51
+ proposta/design/tarefas; implement → wk-tdd; close → verify + wk-verify + archive. States
52
+ explicitly that `archive --force` is the user's call, never the agent's. Localized (pt-BR/en).
53
+ - **G0 — anti-scaffold gate**: `change archive` now blocks when proposta/design/tarefas still
54
+ carry the scaffold placeholders (`(motivo da mudança)`, `(abordagem técnica)`,
55
+ `(primeira tarefa)` + en variants) — an unfilled scaffold is not a completed change.
56
+ `--force` still escapes (human hatch); new `scaffoldPlaceholders(dir)` in change-core.
57
+
58
+ ### Fixed
59
+ - **session-ensure now stamps `session_id`** in the notes it creates — the 4th note-creation
60
+ path, missed in 0.18.0 (it has its own skeleton builder). Notes born from UserPromptSubmit
61
+ (no SessionStart, e.g. resumed windows) were coming out without identity.
62
+
7
63
  ## [0.20.1] — 2026-07-06
8
64
 
9
65
  ### Changed
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  **A persistent‑memory harness for AI coding agents, built on your Obsidian vault.** Every Claude Code / Codex session is captured turn‑by‑turn into local Markdown — with token/cost tracking, auto‑extracted decisions, bugs and learnings, and a curated memory layer injected back at the start of the next session. On top of that memory core sits a native, zero‑dependency **change lifecycle** (spec → change → TDD → sensor‑gated archive) that keeps intent, work and proof wikilinked in one graph. 100% local, open‑core.
6
6
 
7
- > Status: `v0.1` — extracted from a system in daily production use. The capture engine, cost tracking and graph wiring are battle‑tested; the cross‑platform installer (`wendkeep init`) is the new part. See [`docs/`](docs/) for the project's strategy and decision log.
7
+ > Extracted from a system in daily production use: the capture engine, cost tracking and graph wiring are battle‑tested; the cross‑platform installer (`wendkeep init`) and the native change loop are the newer parts. See [`docs/`](https://github.com/rogersialves/wendkeep/tree/main/docs) for the project's strategy and decision log.
8
8
 
9
9
  ---
10
10
 
@@ -49,7 +49,7 @@ npx wendkeep init
49
49
  Control with `--companions <csv>` or `--no-companions`. The Claude Code plugin layer (`extraKnownMarketplaces` + `enabledPlugins`) is wired as a bonus where the companion has one.
50
50
  5. Install a **color system** into the vault's `.obsidian/`: a CSS snippet that accents notes by type (session/decision/bug/learning, via the `cssclasses` the hooks emit) plus graph color groups by folder. Non-destructive merge into `appearance.json`/`graph.json`; skip with `--no-colors`.
51
51
  6. Seed the **curated memory layer**: `.brain/CORE.md` (the hand-curated hot layer, with the 3 required sections) and `.brain/COMPACTION_PROTOCOL.md` (the protocol guide). The auto layers (`DIGEST.md`, `index.jsonl`) are generated by the hooks. Validate the curated layer with `wendkeep validate-memory` (cap 25 lines, 3 sections, no secrets/PII).
52
- 7. Seed the **definitions + skills layer**: `.brain/agents/` + `.brain/skills/` (versioned source of truth), including the native process skills `wk-workflow` / `wk-tdd` / `wk-debugging` / `wk-brainstorming` / `wk-planning`. `init` runs `wendkeep sync-defs` for you, delivering them to `.codex/agents/` + `.claude/skills/` (re‑run `sync-defs` after editing `.brain`).
52
+ 7. Seed the **definitions + skills layer**: `.brain/agents/` + `.brain/skills/` (versioned source of truth), including the native process skills `wk-workflow` / `wk-tdd` / `wk-debugging` / `wk-brainstorming` / `wk-planning` / `wk-verify` (some ship bundled templates, e.g. `wk-verify`'s `verdict-template.json` + reviewer prompt). `init` runs `wendkeep sync-defs` for you, delivering them to `.codex/agents/` + `.claude/skills/` (re‑run `sync-defs` after editing `.brain`).
53
53
  8. Seed the **change/spec lifecycle**: the `07-Specs/` + `08-Mudanças/` folders and a native `wendkeep.sensors.json` (a `validate-memory` sensor plus one per detected `package.json` script). Drives `wendkeep change` / `wendkeep verify` — see **Change lifecycle** below.
54
54
 
55
55
  ```bash
@@ -170,14 +170,16 @@ agent session ──hooks──▶ wendkeep ──▶ Markdown in vault ──
170
170
  (Claude/Codex) (Node) (02-Sessões/…) (CORE+DIGEST, backlinks)
171
171
  ```
172
172
 
173
- The agent's settings.json points each hook at `npx wendkeep hook …`. On `Stop`, wendkeep parses the session transcript, appends the turn, updates the token/cost table, and (idempotently) emits any decision/bug/learning notes. On the next `SessionStart`, the curated memory is injected back into context.
173
+ The agent's settings.json points each hook at `npx wendkeep hook …`. On `Stop`, wendkeep parses the session transcript, appends the turn, updates the token/cost table, and (idempotently) emits any decision/bug/learning notes. On every `SessionStart`, `brain-inject` injects back the curated memory (CORE + DIGEST), the active change, project lessons, and a `<wk_process>` router that routes any non‑trivial task through the a2 loop (plan → `change new` + fill the scaffold → TDD → verify → archive).
174
174
 
175
- ## Known limitations (`v0.1`)
175
+ The archive **gate** blocks unless: the change scaffold is filled (G0), no task is open (G1), every declared critical sensor is green (with fresh evidence), and — when the change declares `[req:]` — an independent `verdict.json` covers them. `--force` is the human escape hatch; the agent is instructed never to use it on its own.
176
+
177
+ ## Notes & roadmap
176
178
 
177
179
  - **Vault folder names default to Portuguese** (`02-Sessões`, `04-Decisões`, …). Pass `wendkeep init --locale en` for an English vault (`02-Sessions`, `04-Decisions`, English scaffold/skills). The locale is a vault property, locked at init; parsers are bilingual so mixed content never breaks.
178
180
  - **Search is keyword/frontmatter scoring**, not on‑device embeddings (that's on the roadmap).
179
181
  - **Transcript formats are agent‑internal** and can change between agent versions; parsing is isolated but may need updates.
180
- - Installer wires **Claude Code** settings + `.mcp.json`. Codex hooks run on the same scripts but are not auto‑wired yet.
182
+ - Installer wires **Claude Code** settings + `.mcp.json`. Codex hooks run on the same scripts but are not auto‑wired yet (import already covers past Codex sessions via `--source codex`).
181
183
 
182
184
  ## License
183
185
 
package/bin/wendkeep.mjs CHANGED
@@ -48,9 +48,14 @@ Usage:
48
48
  wendkeep spec <sub> Living specs: list | show <capability>.
49
49
  wendkeep sensors <sub> list | add <id> "<command>" [--severity --type --report].
50
50
  wendkeep cost [opts] Aggregate AI-coding spend across the vault's sessions.
51
- wendkeep import [opts] Backfill: import this project's past Claude sessions into the vault.
52
51
  --since <date> · --top [N] (priciest sessions) · --json.
53
- wendkeep verify [--change s] Run a change's task sensors + record evidence (the gate).
52
+ wendkeep import [opts] Backfill: import this project's past Claude + Codex sessions into
53
+ the vault (deduped by session_id). --source all|claude|codex (default
54
+ all) · --stamp-ids (backfill session_id in existing notes) ·
55
+ --from <dir> · --codex-from <dir> · --since <date> · --limit N ·
56
+ --dry-run · --json.
57
+ wendkeep verify [--deep] [--change s] Run a change's task sensors + record evidence (the gate);
58
+ --deep assembles the verification package for the wk-verify pass.
54
59
  wendkeep lesson add "t" "l" Record a project-local lesson (injected at SessionStart).
55
60
  wendkeep validate-memory [path] Validate .brain/CORE.md against the compaction
56
61
  protocol (cap 25, 3 sections, no secrets/PII). Uses
@@ -1,7 +1,7 @@
1
1
  // .agent/hooks/brain-core.mjs
2
2
  // Camada fria do brain: indexa o frontmatter das notas de sessão (0 token LLM).
3
- import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
4
- import { join } from 'node:path';
3
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
4
+ import { basename, join } from 'node:path';
5
5
  import { ensureDir, stripYamlQuotes, toVaultRelative } from './obsidian-common.mjs';
6
6
  import { getLocale } from './locale.mjs';
7
7
 
@@ -123,10 +123,26 @@ export function buildBrainDigest(vaultBase, rows = null) {
123
123
  return out;
124
124
  };
125
125
 
126
- const decisions = pick('decisions', DIGEST_CAPS.decisions).sort((a, b) => adrNumber(b) - adrNumber(a));
126
+ // The digest is INJECTED into every session, so a dead wikilink is dead weight in the model's
127
+ // context. Keep only targets that resolve to a real note (by vault-relative path or basename)
128
+ // and drop placeholder paths (a truncated `…` from a summary line). `pick` collects extra so
129
+ // caps still fill after filtering.
130
+ const known = new Set();
131
+ for (const r of data) {
132
+ const rel = String(r.file || '').replace(/\.md$/i, '');
133
+ if (rel) { known.add(rel); known.add(basename(rel)); }
134
+ }
135
+ const resolves = (p) => {
136
+ const t = String(p || '').replace(/\.md$/i, '').trim();
137
+ if (!t || t.includes('...') || t.includes('…')) return false;
138
+ return known.has(t) || known.has(basename(t)) || existsSync(join(vaultBase, `${t}.md`));
139
+ };
140
+ const pickLive = (kind, max) => pick(kind, max * 4).filter(resolves).slice(0, max);
141
+
142
+ const decisions = pickLive('decisions', DIGEST_CAPS.decisions).sort((a, b) => adrNumber(b) - adrNumber(a));
127
143
  const sessions = byDateDesc.slice(0, DIGEST_CAPS.sessions);
128
- const bugs = pick('bugs', DIGEST_CAPS.bugs);
129
- const learnings = pick('learnings', DIGEST_CAPS.learnings);
144
+ const bugs = pickLive('bugs', DIGEST_CAPS.bugs);
145
+ const learnings = pickLive('learnings', DIGEST_CAPS.learnings);
130
146
 
131
147
  const lines = ['<!-- AUTO-GERADO por brain-core.mjs (0 token LLM). NÃO editar. Rebuild: node .agent/hooks/brain-reindex.mjs -->'];
132
148
  for (const d of decisions) lines.push(`- Decisão: [[${d}]]`);
@@ -9,6 +9,36 @@ import { getVaultBase, readHookInput, writeHookOutput } from './obsidian-common.
9
9
  import { brainDir } from './brain-core.mjs';
10
10
  import { buildActiveChangeInjection } from './change-core.mjs';
11
11
  import { buildLessonsInjection } from './lessons-core.mjs';
12
+ import { getLocale } from './locale.mjs';
13
+
14
+ // The process ROUTER — the enforcement layer. The wk-* skills are passive files; without a
15
+ // standing instruction the model plans in chat, leaves the change scaffold raw and forces the
16
+ // gate (seen in production: change archived with `(primeira tarefa)` open, via --force). This
17
+ // block is injected EVERY session so planning always routes through the a2 loop.
18
+ function processRouter(localeId) {
19
+ if (localeId === 'en') {
20
+ return [
21
+ '<wk_process>',
22
+ 'Spec-driven process (mandatory for any non-trivial task):',
23
+ '1. Plan: skill wk-brainstorming (approved design) → wk-planning (task plan).',
24
+ '2. Record: `wendkeep change new <slug>` and FILL 08-Changes/<slug>/ — proposta.md (why/what), design.md (the approved approach), tarefas.md (the plan\'s tasks, `- [ ] N.N` with [req:ID]/[sensor:id]). Never leave the scaffold placeholders.',
25
+ '3. Implement: wk-tdd per task; tick `- [x]` as you finish. Something broke? wk-debugging.',
26
+ '4. Close: `wendkeep verify` (+ `--deep` + skill wk-verify) → `wendkeep change archive`.',
27
+ 'NEVER `archive --force` on your own — a red gate means pending work; --force is the user\'s call, not yours.',
28
+ '</wk_process>',
29
+ ].join('\n');
30
+ }
31
+ return [
32
+ '<wk_process>',
33
+ 'Processo spec-driven (obrigatório em tarefa não-trivial):',
34
+ '1. Planejar: skill wk-brainstorming (design aprovado) → wk-planning (plano de tarefas).',
35
+ '2. Registrar: `wendkeep change new <slug>` e PREENCHA 08-Mudanças/<slug>/ — proposta.md (porquê/o quê), design.md (a abordagem aprovada), tarefas.md (as tarefas do plano, `- [ ] N.N` com [req:ID]/[sensor:id]). Nunca deixe os placeholders do scaffold.',
36
+ '3. Implementar: wk-tdd por tarefa; marque `- [x]` ao concluir. Quebrou algo? wk-debugging.',
37
+ '4. Fechar: `wendkeep verify` (+ `--deep` + skill wk-verify) → `wendkeep change archive`.',
38
+ 'PROIBIDO `archive --force` por conta própria — gate vermelho significa trabalho pendente; --force é decisão do usuário, não sua.',
39
+ '</wk_process>',
40
+ ].join('\n');
41
+ }
12
42
 
13
43
  const MAX_LINES = 45; // CORE ≤25 + DIGEST ≤15 + folga; salvaguarda se o CORE crescer à mão
14
44
 
@@ -26,9 +56,10 @@ export function buildInjection(vaultBase) {
26
56
  lines.push('*…truncado pelo budget — fonte completa: .brain/CORE.md + .brain/DIGEST.md*');
27
57
  }
28
58
  const brain = ['<brain_memory>', ...lines, pointer, '</brain_memory>'].join('\n');
59
+ const router = processRouter(getLocale(vaultBase).id);
29
60
  const change = buildActiveChangeInjection(vaultBase);
30
61
  const lessons = buildLessonsInjection(vaultBase);
31
- return [brain, change, lessons].filter(Boolean).join('\n');
62
+ return [brain, router, change, lessons].filter(Boolean).join('\n');
32
63
  }
33
64
 
34
65
  if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
@@ -53,6 +53,25 @@ ${en ? '(behaviour / scenarios)' : '(comportamento / cenários)'}
53
53
  return { proposta, design, tarefas, specDelta };
54
54
  }
55
55
 
56
+ // Scaffold placeholder markers per file (pt + en, mirrors renderChangeScaffold). A change whose
57
+ // proposta/design/tarefas still carry these was never actually planned — archiving it mints a
58
+ // bogus ADR and pollutes _arquivo (seen in production). The archive gate blocks on them.
59
+ const SCAFFOLD_MARKERS = [
60
+ ['proposta.md', ['(motivo da mudança)', '(escopo da mudança)', '(reason for the change)', '(scope of the change)']],
61
+ ['design.md', ['(abordagem técnica)', '(technical approach)']],
62
+ ['tarefas.md', ['(primeira tarefa)', '(first task)']],
63
+ ];
64
+
65
+ export function scaffoldPlaceholders(dir) {
66
+ const found = [];
67
+ for (const [file, markers] of SCAFFOLD_MARKERS) {
68
+ let content = '';
69
+ try { content = readFileSync(join(dir, file), 'utf8'); } catch { continue; }
70
+ for (const m of markers) if (content.includes(m)) found.push(`${file}: ${m}`);
71
+ }
72
+ return found;
73
+ }
74
+
56
75
  export function activeChange(vaultBase) {
57
76
  try {
58
77
  const m = readFileSync(join(vaultBase, POINTER), 'utf8').match(/^change:\s*(.+)$/m);
@@ -203,8 +222,16 @@ export function archiveChange(vaultBase, slug, { gate = gateGreen, dateStr, adrN
203
222
  if (!verdict.ok) return { ok: false, failing: verdict.failing || [] };
204
223
 
205
224
  const destRel = join(chDir, ARCHIVE_DIR, `${dateStr}-${slug}`);
225
+ const destAbs = join(vaultBase, destRel);
206
226
  const changeWikilink = wikilinkFromRel(join(destRel, 'proposta'));
207
227
 
228
+ // Atomicity guard: fail BEFORE promoting specs if the destination already exists (e.g. a slug
229
+ // reused after a same-day archive). Otherwise promoteSpecs would commit to 07-Specs and the
230
+ // later renameSync would fail, leaving a half-archived state.
231
+ if (existsSync(destAbs)) {
232
+ return { ok: false, failing: [`destino de arquivo já existe: ${destRel} — renomeie o slug ou remova o arquivo antigo`] };
233
+ }
234
+
208
235
  // Promote spec deltas into the living 07-Specs BEFORE moving (deltas live in src).
209
236
  let promoted = [];
210
237
  let specWarnings = [];
@@ -221,7 +248,18 @@ export function archiveChange(vaultBase, slug, { gate = gateGreen, dateStr, adrN
221
248
  try { reqIds = [...new Set(parseTasks(readFileSync(join(src, 'tarefas.md'), 'utf8')).map((t) => t.req).filter(Boolean))]; } catch { /* sem tarefas */ }
222
249
 
223
250
  ensureDir(join(vaultBase, chDir, ARCHIVE_DIR));
224
- renameSync(src, join(vaultBase, destRel));
251
+ try {
252
+ renameSync(src, destAbs);
253
+ } catch (error) {
254
+ return { ok: false, failing: [`falha ao mover a mudança para ${destRel}: ${error.message} (07-Specs pode ter sido promovido — verifique)`] };
255
+ }
256
+
257
+ // Flip the archived proposta's frontmatter status so it no longer reads as active.
258
+ try {
259
+ const pp = join(destAbs, 'proposta.md');
260
+ const c = readFileSync(pp, 'utf8').replace(/^status:\s*active\s*$/m, 'status: archived');
261
+ writeFileSync(pp, c, 'utf8');
262
+ } catch { /* proposta ilegível — segue */ }
225
263
 
226
264
  const [year] = String(dateStr).split('-');
227
265
  const adrDirRel = join(loc.folders.decisions, year);
@@ -249,6 +287,8 @@ tags:
249
287
  Mudança ${changeWikilink} concluída e arquivada.${capLine}${reqLine}
250
288
  `, 'utf8');
251
289
 
252
- clearActiveChange(vaultBase);
290
+ // Only clear the pointer when the archived change IS the active one — archiving some other
291
+ // slug explicitly must not blank the pointer of a different, still-active change.
292
+ if (activeChange(vaultBase) === slug) clearActiveChange(vaultBase);
253
293
  return { ok: true, failing: [], archivedRel: destRel, adrRel, promoted, specWarnings };
254
294
  }
@@ -97,15 +97,39 @@ function readPrefix(path, bytes = 4096) {
97
97
  }
98
98
  }
99
99
 
100
+ // Read the first physical line in full, growing the buffer until a newline is found (capped).
101
+ // A fixed prefix truncated any rollout whose session_meta line exceeded the window, silently
102
+ // dropping that session from discovery — Codex meta lines can be large (env, git, instructions).
103
+ function readFirstLine(path, maxBytes = 4 * 1024 * 1024) {
104
+ let fd;
105
+ try {
106
+ fd = openSync(path, 'r');
107
+ const chunk = Buffer.alloc(65536);
108
+ let acc = '';
109
+ let pos = 0;
110
+ while (pos < maxBytes) {
111
+ const n = readSync(fd, chunk, 0, chunk.length, pos);
112
+ if (n <= 0) break;
113
+ acc += chunk.slice(0, n).toString('utf-8');
114
+ const nl = acc.indexOf('\n');
115
+ if (nl >= 0) return acc.slice(0, nl);
116
+ pos += n;
117
+ }
118
+ return acc; // single-line file, or gave up at the cap
119
+ } catch {
120
+ return '';
121
+ } finally {
122
+ if (fd !== undefined) { try { closeSync(fd); } catch { /* already closed */ } }
123
+ }
124
+ }
125
+
100
126
  // Pull the session_meta payload (id + cwd). session_meta is line 1 of a rollout.
101
127
  function readSessionMeta(path) {
102
- for (const line of readPrefix(path, 16384).split('\n')) {
103
- if (!line.trim()) continue;
104
- let e;
105
- try { e = JSON.parse(line); } catch { return null; } // partial/oversized first line -> skip
106
- return e.type === 'session_meta' ? (e.payload || {}) : null;
107
- }
108
- return null;
128
+ const line = readFirstLine(path);
129
+ if (!line.trim()) return null;
130
+ let e;
131
+ try { e = JSON.parse(line); } catch { return null; }
132
+ return e.type === 'session_meta' ? (e.payload || {}) : null;
109
133
  }
110
134
 
111
135
  // The `session_id` recorded in a note's frontmatter (empty when absent).
@@ -30,6 +30,13 @@
30
30
  "cachedInput": 0.3,
31
31
  "output": 15
32
32
  },
33
+ "claude-sonnet-5": {
34
+ "label": "Claude Sonnet 5 API",
35
+ "provider": "anthropic",
36
+ "input": 3,
37
+ "cachedInput": 0.3,
38
+ "output": 15
39
+ },
33
40
  "claude-haiku-4.5": {
34
41
  "label": "Claude Haiku 4.5 API",
35
42
  "provider": "anthropic",
@@ -35,7 +35,7 @@ function sessionIdFromInput(input) {
35
35
  return input.session_id || input.sessionId || input.codex_session_id || '';
36
36
  }
37
37
 
38
- function buildSessionContent({ relPath, now, summary = 'session', reason = 'Sessão criada automaticamente pelo hook UserPromptSubmit.' }) {
38
+ function buildSessionContent({ relPath, now, summary = 'session', sessionId = '', reason = 'Sessão criada automaticamente pelo hook UserPromptSubmit.' }) {
39
39
  const date = formatDate(now);
40
40
  const startedAt = formatLocalIso(now);
41
41
  const titleTime = formatTime(now).slice(0, 5);
@@ -48,6 +48,7 @@ date: ${date}
48
48
  started_at: ${startedAt}
49
49
  ended_at:
50
50
  provider: ${provider.id}
51
+ session_id: ${sessionId ? yamlQuote(sessionId) : ''}
51
52
  status: active
52
53
  summary: ${yamlQuote(summary)}
53
54
  cssclasses:
@@ -267,7 +268,7 @@ function createSession({ vaultBase, sessionId, input, now }) {
267
268
  const summary = sessionSummaryFromInput(input);
268
269
  const { absPath, relPath } = allocateSessionPath(vaultBase, now, summary);
269
270
  const startedAt = formatLocalIso(now);
270
- writeFileSync(absPath, buildSessionContent({ relPath, now, summary }), 'utf-8');
271
+ writeFileSync(absPath, buildSessionContent({ relPath, now, summary, sessionId }), 'utf-8');
271
272
  writeControl(vaultBase, {
272
273
  status: 'active',
273
274
  session_file: relPath,
@@ -53,7 +53,11 @@ function shouldIgnoreUserText(text) {
53
53
  || /^# AGENTS\.md instructions/.test(trimmed)
54
54
  || trimmed.startsWith('<permissions instructions>')
55
55
  || trimmed.includes('You are Codex, a coding agent')
56
- || trimmed.startsWith('## Memory');
56
+ || trimmed.startsWith('## Memory')
57
+ // Harness utility meta-prompts (title generation, classifiers) — not real user turns; they
58
+ // were leaking into note titles/summaries on import.
59
+ || /^Generate a concise( UI)? title/i.test(trimmed)
60
+ || /^You are a helpful assistant\. You will be presented with a user prompt/i.test(trimmed);
57
61
  }
58
62
 
59
63
  function addUnique(list, value) {
@@ -1086,12 +1090,18 @@ function main() {
1086
1090
  findLinkedDerivedNotes(vaultBase, sessionRel),
1087
1091
  );
1088
1092
  finalizeSessionFile(sessionPath, tx, created, endedAt);
1089
- // Link bidirecional sessão↔change ativa no grafo Obsidian. Fail-quiet: nunca derruba o Stop.
1093
+ // Link durável sessão↔change: uma seção "Mudanças" ANTES de `## Encerramento`. O append antigo
1094
+ // (após o Encerramento) era apagado a cada reopen por stripClosingSection, perdendo a aresta do
1095
+ // grafo quando a change fechava antes do turno seguinte. Aqui sobrevive ao reopen e acumula toda
1096
+ // change que passou pela sessão (upsertListSection deduplica). Fail-quiet: nunca derruba o Stop.
1090
1097
  try {
1091
1098
  const chgLink = activeChangeLink(vaultBase);
1092
- if (chgLink) {
1093
- const cur = readFileSync(sessionPath, 'utf8');
1094
- if (!cur.includes(chgLink)) writeFileSync(sessionPath, `${cur.trimEnd()}\n\n${chgLink}\n`, 'utf8');
1099
+ const wl = (chgLink.match(/\[\[[^\]]+\]\]/) || [])[0];
1100
+ if (wl) {
1101
+ let cur = readFileSync(sessionPath, 'utf8');
1102
+ cur = ensureSection(cur, 'Mudanças', '\n## Encerramento');
1103
+ cur = upsertListSection(cur, 'Mudanças', [`- ${wl}`], null);
1104
+ writeFileSync(sessionPath, cur, 'utf8');
1095
1105
  }
1096
1106
  } catch { /* nunca derruba o Stop */ }
1097
1107
  writeControl(vaultBase, {
@@ -41,6 +41,13 @@ const DEFAULT_PRICE_REFERENCE = {
41
41
  cachedInput: 0.3,
42
42
  output: 15,
43
43
  },
44
+ 'claude-sonnet-5': {
45
+ label: 'Claude Sonnet 5 API',
46
+ provider: 'anthropic',
47
+ input: 3,
48
+ cachedInput: 0.3,
49
+ output: 15,
50
+ },
44
51
  'claude-haiku-4.5': {
45
52
  label: 'Claude Haiku 4.5 API',
46
53
  provider: 'anthropic',
@@ -79,6 +86,14 @@ const MODEL_ALIASES = {
79
86
  'gpt-5.5': 'gpt-5.5',
80
87
  'gpt-5_5': 'gpt-5.5',
81
88
  'openai/gpt-5.5': 'gpt-5.5',
89
+ // Older/adjacent Codex model ids: priced approximately at the gpt-5.5 tier until confirmed
90
+ // (better a close estimate than a silent $0). Codex sessions surface these via session_meta.
91
+ 'gpt-5.4': 'gpt-5.5',
92
+ 'gpt-5-4': 'gpt-5.5',
93
+ 'gpt-5.4-mini': 'gpt-5.5',
94
+ 'gpt-5.3-codex': 'gpt-5.5',
95
+ 'gpt-5.3': 'gpt-5.5',
96
+ 'openai/gpt-5.4': 'gpt-5.5',
82
97
  'claude-opus-4.7': 'claude-opus-4.7',
83
98
  'claude-opus-4-7': 'claude-opus-4.7',
84
99
  'anthropic/claude-opus-4.7': 'claude-opus-4.7',
@@ -91,6 +106,9 @@ const MODEL_ALIASES = {
91
106
  'claude-sonnet-4-6': 'claude-sonnet-4.6',
92
107
  'anthropic/claude-sonnet-4.6': 'claude-sonnet-4.6',
93
108
  'anthropic/claude-sonnet-4-6': 'claude-sonnet-4.6',
109
+ 'claude-sonnet-5': 'claude-sonnet-5',
110
+ 'claude-sonnet-5-0': 'claude-sonnet-5',
111
+ 'anthropic/claude-sonnet-5': 'claude-sonnet-5',
94
112
  'claude-haiku-4.5': 'claude-haiku-4.5',
95
113
  'claude-haiku-4-5': 'claude-haiku-4.5',
96
114
  'claude-haiku-4-5-20251001': 'claude-haiku-4.5',
@@ -191,7 +209,9 @@ export function addUsage(target, usage) {
191
209
 
192
210
  function normalizeModelName(model) {
193
211
  const clean = String(model || 'unknown').trim() || 'unknown';
194
- const lower = clean.toLowerCase();
212
+ // Strip a trailing context-window tag (e.g. `claude-opus-4-8[1m]`, `claude-fable-5[1m]`) so the
213
+ // 1M variant of ANY model maps to its base price instead of falling through to $0.
214
+ const lower = clean.toLowerCase().replace(/\[[^\]]*\]$/, '');
195
215
  if (MODEL_ALIASES[lower]) return MODEL_ALIASES[lower];
196
216
  // Fallback: remove sufixo de data (ex.: claude-opus-4-8-20260528) e tenta de novo.
197
217
  const noDate = lower.replace(/-\d{8}$/, '');
@@ -280,7 +300,9 @@ function shouldIgnoreUserText(text) {
280
300
  || text.startsWith('<command-name>')
281
301
  || text.startsWith('<ide_')
282
302
  || text.startsWith('## Memory')
283
- || text.includes('You are Codex, a coding agent');
303
+ || text.includes('You are Codex, a coding agent')
304
+ || /^Generate a concise( UI)? title/i.test(text)
305
+ || /^You are a helpful assistant\. You will be presented with a user prompt/i.test(text);
284
306
  }
285
307
 
286
308
  function emptyParseResult(transcriptPath) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wendkeep",
3
- "version": "0.20.1",
3
+ "version": "0.22.0",
4
4
  "description": "A persistent-memory harness for AI coding agents on your Obsidian vault: turn-by-turn session capture plus a native, zero-dependency spec→change→verify→archive loop (sensor-gated, independent verdict, mutation discrimination). Local-first, agent-agnostic (Claude Code, Codex, Cursor…).",
5
5
  "type": "module",
6
6
  "bin": {
package/src/change.mjs CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  parseTasks,
9
9
  setTaskDone,
10
10
  archiveChange,
11
+ scaffoldPlaceholders,
11
12
  } from '../hooks/change-core.mjs';
12
13
  import { evaluateGate, requiredSensors } from '../hooks/sensors-core.mjs';
13
14
  import { evaluateVerdict, tasksHashOf, parseSpecsList, parseDelta, parseRequirements, applyDelta } from '../hooks/spec-core.mjs';
@@ -154,6 +155,13 @@ export function runChange(argv) {
154
155
  if (!slug) { process.stderr.write('wendkeep change archive: missing <slug> and no active change\n'); process.exit(2); }
155
156
  // Real gate (Pilar C): every sensor a task declared must be green in evidencia.json.
156
157
  const gate = (dir) => {
158
+ // G0: um scaffold nunca preenchido não é uma mudança concluída — arquivar geraria um
159
+ // ADR falso. Bloqueia antes de qualquer outro check (o --force ainda escapa, mas a
160
+ // regra injetada proíbe o agente de usá-lo sem o usuário pedir).
161
+ const placeholders = scaffoldPlaceholders(dir);
162
+ if (placeholders.length && !rest.includes('--force')) {
163
+ return { ok: false, failing: [`scaffold não preenchido (${placeholders.join('; ')}) — preencha proposta/design/tarefas com o design e o plano antes de arquivar`] };
164
+ }
157
165
  let tarefasMd = '';
158
166
  try { tarefasMd = readFileSync(join(dir, 'tarefas.md'), 'utf8'); } catch { /* no tasks */ }
159
167
  const tasks = parseTasks(tarefasMd);
@@ -163,6 +171,15 @@ export function runChange(argv) {
163
171
  return { ok: false, failing: [`${open.length} tarefa(s) aberta(s) (ex.: ${open[0].id} ${open[0].text}) — conclua ou use --force`] };
164
172
  }
165
173
  const required = requiredSensors(tasks);
174
+ // Evidence freshness: block if tarefas.md changed since verify sealed the evidence
175
+ // (e.g. a sensor task added/edited after the last green verify).
176
+ if (required.length) {
177
+ let evHash = '';
178
+ try { evHash = readFileSync(join(dir, '.evidence-hash'), 'utf8').trim(); } catch { /* pre-seal evidence */ }
179
+ if (evHash && evHash !== tasksHashOf(tarefasMd)) {
180
+ return { ok: false, failing: ['evidência stale (tarefas.md mudou desde o último verify) — rode `wendkeep verify` de novo'] };
181
+ }
182
+ }
166
183
  const reqIds = [...new Set(tasks.map((t) => t.req).filter(Boolean))];
167
184
  let evidence = [];
168
185
  try { evidence = JSON.parse(readFileSync(join(dir, 'evidencia.json'), 'utf8')); } catch { /* no evidence */ }
package/src/init.mjs CHANGED
@@ -104,8 +104,17 @@ export function mergeSettings(existing, { vaultPath, withMcp, force, companions
104
104
  for (const h of allSpecs) {
105
105
  const command = h.command ?? hookCommand(h.name);
106
106
  const groups = Array.isArray(s.hooks[h.event]) ? [...s.hooks[h.event]] : [];
107
- const present = groups.some((g) => (g.hooks || []).some((x) => x.command === command));
108
- if (present && !force) {
107
+ const owning = groups.find((g) => (g.hooks || []).some((x) => x.command === command));
108
+ if (owning) {
109
+ // Already wired: never add a duplicate group (the old `if (present && !force)` fell through
110
+ // under --force and appended a second identical group). Under --force, refresh the managed
111
+ // entry's fields in place — without disturbing any sibling hooks the user grouped with it.
112
+ if (force) {
113
+ const hk = owning.hooks.find((x) => x.command === command);
114
+ hk.timeout = h.timeout;
115
+ if (h.statusMessage) hk.statusMessage = h.statusMessage;
116
+ if (h.matcher && (owning.hooks || []).length === 1) owning.matcher = h.matcher;
117
+ }
109
118
  s.hooks[h.event] = groups;
110
119
  continue;
111
120
  }
package/src/verify.mjs CHANGED
@@ -38,6 +38,9 @@ export function runVerify(argv) {
38
38
  const sensors = loadSensors(projectRoot);
39
39
  const evidence = runSensors(sensors, ids, { cwd: projectRoot });
40
40
  writeFileSync(join(changeDir, 'evidencia.json'), `${JSON.stringify(evidence, null, 2)}\n`, 'utf8');
41
+ // Freshness seal: bind this evidence to the tarefas.md it was produced against, so the archive
42
+ // gate can reject evidence gone stale (a sensor task added after this verify run).
43
+ writeFileSync(join(changeDir, '.evidence-hash'), tasksHashOf(tarefas), 'utf8');
41
44
 
42
45
  // Mutation survivors -> fix tasks (Wave B), bounded at 3 rounds then escalate. A surviving
43
46
  // mutant always fails verify (exit 1): the suite does not discriminate yet. A clean report