wendkeep 0.4.0 → 0.6.1

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 ADDED
@@ -0,0 +1,151 @@
1
+ # Changelog
2
+
3
+ All notable changes to **wendkeep** are documented here. Format based on
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this project follows
5
+ [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [0.6.1] — 2026-07-05
8
+
9
+ Hardening: CI + real-world gate holes found by self-audit.
10
+
11
+ ### Added
12
+ - **CI (GitHub Actions):** test + check matrix on ubuntu/windows × Node 18/20/22.
13
+ - **Open-task gate:** `change archive` blocks while tasks are open (`- [ ]`, including mutation
14
+ fix-tasks `M.n` — a surviving mutant can no longer be archived). Explicit escape: `--force`.
15
+ - **Freshness seal (`tasksHash`):** `verify --deep` fingerprints `tarefas.md` into the package
16
+ and verdict; the gate rejects a verdict minted against different tasks as stale. Pre-0.6.1
17
+ verdicts (no hash) still accepted.
18
+ - **Auto-lesson on mutation escalation:** the 3rd surviving round records a project-local lesson.
19
+ - **Session link in proposta:** `change new` fills `source:` with the active session (graph edge
20
+ proposta → sessão).
21
+
22
+ ### Fixed
23
+ - `.mutation-round` now resets when the report comes back clean (a future survivor starts a
24
+ fresh 3-round cycle instead of instantly escalating).
25
+ - `verify` exits 1 when mutants survive (was 0 — CI couldn't see it).
26
+ - `.brain/lessons/` capped at 50 (oldest pruned) instead of growing unbounded.
27
+
28
+ ## [0.6.0] — 2026-07-05
29
+
30
+ Enforcement layer (Wave B of the TLC-parity program) — closes TLC parity.
31
+
32
+ ### Added
33
+ - **Discrimination sensor (`type: mutation`):** delegates to the project's mutation tool and
34
+ parses its mutation-testing-elements report; surviving mutants become fix tasks in the active
35
+ change (`- [ ] M.n mata mutante file:line`), bounded to 3 rounds before escalating.
36
+ - **Harness self-check:** `wendkeep doctor` now validates the a2 state — an invalid
37
+ `wendkeep.sensors.json`, a broken `CURRENT_CHANGE` pointer, changes without a `proposta.md`,
38
+ an orphan `[req:]` (unknown requirement), and stale verdicts.
39
+ - **Lessons loop:** `wendkeep lesson add "<trigger>" "<lesson>"` records a project-local lesson in
40
+ `.brain/lessons/`; `brain-inject` surfaces the recent ones as a `<lessons>` block at SessionStart.
41
+ - **Auto-sizing:** `wendkeep change new <slug> --simple` scaffolds only proposta + tarefas
42
+ (no design / spec-delta) for trivial changes.
43
+ - **Harness contract v1.1** (`docs/14-harness-contract.md`): the mutation + lesson formats.
44
+
45
+ ## [0.5.0] — 2026-07-05
46
+
47
+ Verification & credibility layer (Wave A of the TLC-parity program). The gate stops being
48
+ "green sensors" alone and starts requiring an independent verdict for changes that touch a spec.
49
+
50
+ ### Added
51
+ - **Requirement IDs + traceability:** living-spec requirements carry a stable ID
52
+ (`### Requisito: GATE-1 — nome`); tasks reference them with `[req:<ID>]`; the archive ADR
53
+ lists the requirements it satisfied. Rastro req → task → verdict → ADR.
54
+ - **`wendkeep verify --deep`:** assembles a verification package (`verificacao.json`) for an
55
+ independent pass. A trivial change (no `[req:]`, sensors green) gets an auto verdict.
56
+ - **Independent verdict gate:** `change archive` now also requires `verdict.json` (`ok`, covering
57
+ every declared `[req:]`) for requirement-bearing changes.
58
+ - **TLC-grade process skills:** rewrote `wk-tdd` (spec-derived assertions, non-shallow litmus,
59
+ test adequacy, test-learning) and `wk-brainstorming` (closure gate + out-of-scope); new
60
+ `wk-verify` (fresh read-only verifier, author≠verifier).
61
+ - **Harness contract v1** (`docs/14-harness-contract.md`): the extension-point formats.
62
+
63
+ ### Changed
64
+ - Requirement-less changes are unaffected — the sensor gate remains their proof. The verdict
65
+ requirement applies only when a change declares `[req:]` tasks. Specs from 0.4.0 (headings
66
+ without an ID) stay valid.
67
+
68
+ ## [0.4.0] — 2026-07-05
69
+
70
+ Spec promotion (the living contract) + harness fixes.
71
+
72
+ ### Added
73
+ - **Spec promotion** — `wendkeep change archive` merges each capability's spec delta
74
+ (`## ADDED` / `## MODIFIED` / `## REMOVED Requirements`) into the living
75
+ `07-Specs/<capability>.md`. Multi-capability per change via `specs: [slugs]` in the
76
+ proposta; the living spec footer wikilinks the archived change (`hooks/spec-core.mjs`).
77
+ - Change scaffold now seeds an example spec delta at `specs/exemplo/spec.md`.
78
+ - `wendkeep change archive` prints promoted capabilities and surfaces delta warnings
79
+ (ADDED-already-exists / MODIFIED-missing) without blocking.
80
+
81
+ ### Changed
82
+ - `wendkeep init` now runs `sync-defs` itself — process skills and agents are delivered
83
+ to `.claude/skills` / `.codex/agents` immediately (no manual step).
84
+ - The archive gate and `wendkeep verify` share one rule: only **critical** (or missing)
85
+ sensors block; a red `warning` sensor is advisory. Evidence records each sensor's severity.
86
+ - README documents the change/verify/skills commands and the a2 loop.
87
+
88
+ ## [0.3.0] — 2026-07-05
89
+
90
+ The a2 native harness — a zero-dependency spec→change→proof loop on the vault memory
91
+ core (recreates the best of OpenSpec + dotcontext + superpowers, natively).
92
+
93
+ ### Added
94
+ - **Pilar B — change lifecycle:** `wendkeep change new|list|show|archive`. Scaffolds
95
+ `08-Mudanças/<slug>/` (proposta/design/tarefas); the active change is injected at the
96
+ next `SessionStart`; archive moves the change to `_arquivo/` and mints an ADR in
97
+ `04-Decisões/`. New vault folders `07-Specs/`, `08-Mudanças/` (`hooks/change-core.mjs`).
98
+ - **Pilar C — verify + gate:** `wendkeep verify` runs a change's task-declared sensors
99
+ (`[sensor:<id>]` hints) from a native `wendkeep.sensors.json`, records `evidencia.json`;
100
+ `change archive` gates on the evidence (`hooks/sensors-core.mjs`).
101
+ - **Pilar A — process skills:** native `wk-workflow` / `wk-tdd` / `wk-debugging` /
102
+ `wk-brainstorming` / `wk-planning` seeded into `.brain/skills` (`src/skills-seed.mjs`).
103
+
104
+ ## [0.2.7] — 2026-06-30
105
+
106
+ ### Added
107
+ - **Definitions layer:** `.brain/agents/` + `.brain/skills/` as versioned source of truth,
108
+ copied into the project with `wendkeep sync-defs`.
109
+ - **dotcontext seed:** a starter `.context/config/sensors.json` (a `validate-memory` sensor
110
+ plus one per detected `package.json` script) when the dotcontext companion is selected.
111
+
112
+ ## [0.2.1] – [0.2.6] — 2026-06-29
113
+
114
+ Rapid iteration on the companion + memory layers (same day):
115
+ - **Companions** wired the most agent-agnostic way (context-mode / dotcontext as MCP,
116
+ understand-anything via a domain-graph SessionStart injector, caveman via installer).
117
+ - **Obsidian color system** — a mode-agnostic CSS snippet (note-type accents) + graph
118
+ color groups, merged non-destructively into `.obsidian/`.
119
+ - **Curated memory protocol** — `.brain/CORE.md` + `COMPACTION_PROTOCOL.md` and
120
+ `wendkeep validate-memory` (cap 25 lines, 3 sections, no secrets/PII).
121
+ - Cross-platform caveman installer fix (npx non-interactive; Gemini excluded).
122
+ - Derived notes grouped by month under the year.
123
+
124
+ ## [0.2.0] — 2026-06-29
125
+
126
+ ### Added
127
+ - Companion plugins/MCP selection in `wendkeep init` (context-mode, understand-anything,
128
+ caveman) with idempotent settings/`.mcp.json` merging.
129
+
130
+ ## [0.1.0] — 2026-06-29
131
+
132
+ Initial release — the capture engine, extracted from a system in daily production use.
133
+
134
+ ### Added
135
+ - Automatic session capture (`SessionStart` / `UserPromptSubmit` / `Stop` hooks) into
136
+ `02-Sessões/` as turn-by-turn Markdown.
137
+ - Multi-agent provider detection (Claude Code, Codex, Copilot).
138
+ - Token & cost tracking (cache-aware `pricing.json`).
139
+ - Auto-extracted derived notes (decisions / bugs / learnings), backlinked to the session.
140
+ - Curated memory (`.brain/` cold index + `CORE` + `DIGEST` injected at `SessionStart`).
141
+ - `wendkeep init` (cross-platform installer) + optional `@bitbonsai/mcpvault` MCP server.
142
+
143
+ <!-- Only v0.4.0+ is tagged in git (history starts here); older versions link to npm. -->
144
+ [0.6.1]: https://github.com/rogersialves/wendkeep/releases/tag/v0.6.1
145
+ [0.6.0]: https://github.com/rogersialves/wendkeep/releases/tag/v0.6.0
146
+ [0.5.0]: https://github.com/rogersialves/wendkeep/releases/tag/v0.5.0
147
+ [0.4.0]: https://github.com/rogersialves/wendkeep/releases/tag/v0.4.0
148
+ [0.3.0]: https://www.npmjs.com/package/wendkeep/v/0.3.0
149
+ [0.2.7]: https://www.npmjs.com/package/wendkeep/v/0.2.7
150
+ [0.2.0]: https://www.npmjs.com/package/wendkeep/v/0.2.0
151
+ [0.1.0]: https://www.npmjs.com/package/wendkeep/v/0.1.0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Roger Alves
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # wendkeep
2
2
 
3
+ ![test](https://github.com/rogersialves/wendkeep/actions/workflows/test.yml/badge.svg)
4
+
3
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.
4
6
 
5
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.
package/bin/wendkeep.mjs CHANGED
@@ -45,6 +45,7 @@ Usage:
45
45
  wendkeep doctor [--vault P] Run a vault health check.
46
46
  wendkeep change <sub> Change lifecycle: new <slug> | list | show <slug> | archive <slug>.
47
47
  wendkeep verify [--change s] Run a change's task sensors + record evidence (the gate).
48
+ wendkeep lesson add "t" "l" Record a project-local lesson (injected at SessionStart).
48
49
  wendkeep validate-memory [path] Validate .brain/CORE.md against the compaction
49
50
  protocol (cap 25, 3 sections, no secrets/PII). Uses
50
51
  --vault <path> or OBSIDIAN_VAULT_PATH if no path given.
@@ -111,6 +112,11 @@ async function main() {
111
112
  runVerify(rest);
112
113
  break;
113
114
  }
115
+ case 'lesson': {
116
+ const { runLesson } = await import('../src/lessons.mjs');
117
+ runLesson(rest);
118
+ break;
119
+ }
114
120
  case '--version':
115
121
  case '-v':
116
122
  process.stdout.write(`${version()}\n`);
@@ -8,6 +8,7 @@ import { pathToFileURL } from 'node:url';
8
8
  import { getVaultBase, readHookInput, writeHookOutput } from './obsidian-common.mjs';
9
9
  import { brainDir } from './brain-core.mjs';
10
10
  import { buildActiveChangeInjection } from './change-core.mjs';
11
+ import { buildLessonsInjection } from './lessons-core.mjs';
11
12
 
12
13
  const MAX_LINES = 45; // CORE ≤25 + DIGEST ≤15 + folga; salvaguarda se o CORE crescer à mão
13
14
 
@@ -26,7 +27,8 @@ export function buildInjection(vaultBase) {
26
27
  }
27
28
  const brain = ['<brain_memory>', ...lines, pointer, '</brain_memory>'].join('\n');
28
29
  const change = buildActiveChangeInjection(vaultBase);
29
- return change ? `${brain}\n${change}` : brain;
30
+ const lessons = buildLessonsInjection(vaultBase);
31
+ return [brain, change, lessons].filter(Boolean).join('\n');
30
32
  }
31
33
 
32
34
  if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
@@ -79,7 +79,7 @@ export function clearActiveChange(vaultBase) {
79
79
  if (existsSync(p)) writeFileSync(p, 'change:\n', 'utf8');
80
80
  }
81
81
 
82
- export function newChange(vaultBase, slug, { sessionRel = '', dateStr }) {
82
+ export function newChange(vaultBase, slug, { sessionRel = '', dateStr, simple = false }) {
83
83
  const dir = join(vaultBase, CHANGES_DIR, slug);
84
84
  const existed = existsSync(join(dir, 'proposta.md'));
85
85
  mkdirSync(dir, { recursive: true });
@@ -89,13 +89,15 @@ export function newChange(vaultBase, slug, { sessionRel = '', dateStr }) {
89
89
  if (!existsSync(f)) writeFileSync(f, content, 'utf8');
90
90
  };
91
91
  write('proposta.md', files.proposta);
92
- write('design.md', files.design);
93
92
  write('tarefas.md', files.tarefas);
94
- // Seed an example spec delta so the promotion format is discoverable.
95
- const exampleDelta = join(dir, 'specs', 'exemplo', 'spec.md');
96
- if (!existsSync(exampleDelta)) {
97
- mkdirSync(join(dir, 'specs', 'exemplo'), { recursive: true });
98
- writeFileSync(exampleDelta, files.specDelta, 'utf8');
93
+ // Auto-sizing (Wave B): a --simple change skips the design + spec-delta scaffold.
94
+ if (!simple) {
95
+ write('design.md', files.design);
96
+ const exampleDelta = join(dir, 'specs', 'exemplo', 'spec.md');
97
+ if (!existsSync(exampleDelta)) {
98
+ mkdirSync(join(dir, 'specs', 'exemplo'), { recursive: true });
99
+ writeFileSync(exampleDelta, files.specDelta, 'utf8');
100
+ }
99
101
  }
100
102
  setActiveChange(vaultBase, slug);
101
103
  return { rel: changeDirRel(slug), created: !existed };
@@ -105,13 +107,18 @@ export function parseTasks(md) {
105
107
  const tasks = [];
106
108
  const re = /^-\s+\[( |x)\]\s+(\S+)\s+(.*)$/gm;
107
109
  const sensorRe = /\[sensor:\s*([\w.-]+)\]/;
110
+ const reqRe = /\[req:\s*([A-Z][A-Z0-9]*-\d+)\]/;
108
111
  let m;
109
112
  while ((m = re.exec(String(md))) !== null) {
110
113
  let text = m[3].trim();
111
114
  const sm = text.match(sensorRe);
115
+ const rm = text.match(reqRe);
112
116
  const sensor = sm ? sm[1] : undefined;
113
- if (sm) text = text.replace(sensorRe, '').replace(/\s+/g, ' ').trim();
114
- tasks.push({ id: m[2], text, done: m[1] === 'x', ...(sensor ? { sensor } : {}) });
117
+ const req = rm ? rm[1] : undefined;
118
+ if (sm) text = text.replace(sensorRe, '');
119
+ if (rm) text = text.replace(reqRe, '');
120
+ text = text.replace(/\s+/g, ' ').trim();
121
+ tasks.push({ id: m[2], text, done: m[1] === 'x', ...(sensor ? { sensor } : {}), ...(req ? { req } : {}) });
115
122
  }
116
123
  return tasks;
117
124
  }
@@ -151,6 +158,29 @@ export function activeChangeLink(vaultBase) {
151
158
  return slug ? `Change ativa: [[${CHANGES_DIR}/${slug}/proposta]]` : '';
152
159
  }
153
160
 
161
+ // Append fix tasks for surviving mutants to a change's tarefas.md (Wave B). Deduped by
162
+ // file:line, numbered M.<n> continuing from any existing fix tasks. Returns count added.
163
+ export function appendFixTasks(changeDir, mutants, sensorId) {
164
+ const path = join(changeDir, 'tarefas.md');
165
+ let md = '';
166
+ 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]}`));
168
+ const nums = [...md.matchAll(/^-\s+\[[ x]\]\s+M\.(\d+)\b/gm)].map((m) => Number(m[1]));
169
+ let n = nums.length ? Math.max(...nums) : 0;
170
+ const lines = [];
171
+ for (const mut of mutants || []) {
172
+ const key = `${mut.file}:${mut.line}`;
173
+ if (existing.has(key)) continue;
174
+ existing.add(key);
175
+ n += 1;
176
+ lines.push(`- [ ] M.${n} mata mutante ${mut.file}:${mut.line} (${mut.mutator}) [sensor:${sensorId}]`);
177
+ }
178
+ if (!lines.length) return 0;
179
+ const sep = md === '' || md.endsWith('\n') ? '' : '\n';
180
+ writeFileSync(path, `${md}${sep}${lines.join('\n')}\n`, 'utf8');
181
+ return lines.length;
182
+ }
183
+
154
184
  // Gate seam (Pilar B stub; Pilar C replaces with real sensor evidence).
155
185
  export function gateGreen() {
156
186
  return { ok: true, failing: [] };
@@ -176,6 +206,9 @@ export function archiveChange(vaultBase, slug, { gate = gateGreen, dateStr, adrN
176
206
  }
177
207
  } catch { /* proposta ilegível — segue só com ADR */ }
178
208
 
209
+ let reqIds = [];
210
+ try { reqIds = [...new Set(parseTasks(readFileSync(join(src, 'tarefas.md'), 'utf8')).map((t) => t.req).filter(Boolean))]; } catch { /* sem tarefas */ }
211
+
179
212
  ensureDir(join(vaultBase, CHANGES_DIR, ARCHIVE_DIR));
180
213
  renameSync(src, join(vaultBase, destRel));
181
214
 
@@ -187,6 +220,7 @@ export function archiveChange(vaultBase, slug, { gate = gateGreen, dateStr, adrN
187
220
  const capLine = promoted.length
188
221
  ? `\n\nCapabilities: ${promoted.map((c) => wikilinkFromRel(join(SPECS_DIR, c))).join(', ')}.`
189
222
  : '';
223
+ const reqLine = reqIds.length ? `\n\nRequisitos: ${reqIds.join(', ')}.` : '';
190
224
  writeFileSync(join(vaultBase, adrRel), `---
191
225
  type: decision
192
226
  status: accepted
@@ -201,7 +235,7 @@ tags:
201
235
 
202
236
  ## Decisão
203
237
 
204
- Mudança ${changeWikilink} concluída e arquivada.${capLine}
238
+ Mudança ${changeWikilink} concluída e arquivada.${capLine}${reqLine}
205
239
  `, 'utf8');
206
240
 
207
241
  clearActiveChange(vaultBase);
@@ -0,0 +1,77 @@
1
+ // hooks/harness-doctor.mjs — integrity checks for the a2 harness state (Wave B).
2
+ // Pure-ish (fs reads only). `wendkeep doctor` reports errors (exit 1) + warnings.
3
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { activeChange, parseTasks } from './change-core.mjs';
6
+ import { parseRequirements, parseSpecsList, parseDelta, evaluateVerdict } from './spec-core.mjs';
7
+
8
+ const CHANGES_DIR = '08-Mudanças';
9
+ const SPECS_DIR = '07-Specs';
10
+
11
+ export function checkHarness(vaultBase, projectRoot) {
12
+ const errors = [];
13
+ const warnings = [];
14
+
15
+ // 1. wendkeep.sensors.json well-formed.
16
+ const sensorsPath = join(projectRoot, 'wendkeep.sensors.json');
17
+ if (existsSync(sensorsPath)) {
18
+ try {
19
+ const data = JSON.parse(readFileSync(sensorsPath, 'utf8'));
20
+ if (!Array.isArray(data.sensors)) errors.push('wendkeep.sensors.json: "sensors" não é lista');
21
+ else for (const s of data.sensors) if (!s.id || !s.command) errors.push(`sensor sem id/command: ${JSON.stringify(s.id || '?')}`);
22
+ } catch { errors.push('wendkeep.sensors.json: JSON inválido'); }
23
+ }
24
+
25
+ // Known requirement ids across the living specs (+ spec-without-source warning).
26
+ const knownReqs = new Set();
27
+ try {
28
+ for (const f of readdirSync(join(vaultBase, SPECS_DIR))) {
29
+ if (!f.endsWith('.md') || f === 'README.md') continue;
30
+ const md = readFileSync(join(vaultBase, SPECS_DIR, f), 'utf8');
31
+ for (const r of parseRequirements(md)) if (r.id) knownReqs.add(r.id);
32
+ if (!/^>\s+Atualizado por/m.test(md)) warnings.push(`spec sem origem: ${SPECS_DIR}/${f}`);
33
+ }
34
+ } catch { /* sem 07-Specs */ }
35
+
36
+ // 2/3. Changes: malformed dirs; the active change's deltas add to knownReqs.
37
+ const active = activeChange(vaultBase);
38
+ let names = [];
39
+ try { names = readdirSync(join(vaultBase, CHANGES_DIR)).filter((n) => n !== '_arquivo'); } catch { /* none */ }
40
+ for (const name of names) {
41
+ const dir = join(vaultBase, CHANGES_DIR, name);
42
+ let entries;
43
+ try { entries = readdirSync(dir); } catch { continue; } // a file, not a change dir
44
+ if (!entries.includes('proposta.md')) { errors.push(`change sem proposta.md: ${name}`); continue; }
45
+ if (name === active) {
46
+ try {
47
+ for (const cap of parseSpecsList(readFileSync(join(dir, 'proposta.md'), 'utf8'))) {
48
+ try {
49
+ const d = parseDelta(readFileSync(join(dir, 'specs', cap, 'spec.md'), 'utf8'));
50
+ for (const r of [...d.added, ...d.modified]) if (r.id) knownReqs.add(r.id);
51
+ } catch { /* sem delta pra essa cap */ }
52
+ }
53
+ } catch { /* proposta ilegível */ }
54
+ }
55
+ }
56
+
57
+ // 4/5. Active-change pointer resolves; [req:] orphans; stale verdict.
58
+ if (active) {
59
+ const dir = join(vaultBase, CHANGES_DIR, active);
60
+ if (!existsSync(join(dir, 'proposta.md'))) {
61
+ errors.push(`ponteiro CURRENT_CHANGE aponta pra change inexistente: ${active}`);
62
+ } else {
63
+ let tasks = [];
64
+ try { tasks = parseTasks(readFileSync(join(dir, 'tarefas.md'), 'utf8')); } catch { /* sem tarefas */ }
65
+ const reqIds = [...new Set(tasks.map((t) => t.req).filter(Boolean))];
66
+ for (const rid of reqIds) if (!knownReqs.has(rid)) errors.push(`req órfão em ${active}: [req:${rid}] não existe em 07-Specs nem no delta`);
67
+ let verdict = null;
68
+ try { verdict = JSON.parse(readFileSync(join(dir, 'verdict.json'), 'utf8')); } catch { /* sem verdict */ }
69
+ if (verdict && reqIds.length) {
70
+ const v = evaluateVerdict(verdict, reqIds);
71
+ if (!v.ok) warnings.push(`verdict stale em ${active}: falta cobrir ${v.missing.join(', ')}`);
72
+ }
73
+ }
74
+ }
75
+
76
+ return { errors, warnings };
77
+ }
@@ -0,0 +1,52 @@
1
+ // hooks/lessons-core.mjs — project-local lessons from verification failures (Wave B).
2
+ // A failure distilled into a terse lesson; brain-inject surfaces the recent ones at
3
+ // SessionStart so the framework gets sharper on your codebase. No external deps.
4
+ import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+
7
+ const MAX_LESSONS = 50; // dir cap (#7): oldest (filename asc = date asc) pruned first
8
+
9
+ function slugify(s) {
10
+ return String(s)
11
+ .toLowerCase()
12
+ .normalize('NFD').replace(/[̀-ͯ]/g, '')
13
+ .replace(/[^a-z0-9]+/g, '-')
14
+ .replace(/^-+|-+$/g, '')
15
+ .slice(0, 50) || 'licao';
16
+ }
17
+
18
+ export function addLesson(vaultBase, { trigger, lesson, sourceChange = '', dateStr = '' }) {
19
+ const dir = join(vaultBase, '.brain', 'lessons');
20
+ mkdirSync(dir, { recursive: true });
21
+ const path = join(dir, `${dateStr ? `${dateStr}-` : ''}${slugify(trigger)}.md`);
22
+ writeFileSync(
23
+ path,
24
+ `---\ntype: lesson\ntrigger: ${JSON.stringify(String(trigger))}\nsource: ${sourceChange}\ndate: ${dateStr}\n---\n\n${lesson}\n`,
25
+ 'utf8',
26
+ );
27
+ // Cap the directory (#7): prune oldest-by-name (date-prefixed = chronological).
28
+ try {
29
+ const files = readdirSync(dir).filter((f) => f.endsWith('.md')).sort();
30
+ for (const f of files.slice(0, Math.max(0, files.length - MAX_LESSONS))) {
31
+ unlinkSync(join(dir, f));
32
+ }
33
+ } catch { /* prune é bônus */ }
34
+ return path;
35
+ }
36
+
37
+ // Compact <lessons> block of the most recent lessons (filename-sorted desc; a date prefix
38
+ // makes that chronological). Budget-capped by `max`. '' when there are none.
39
+ export function buildLessonsInjection(vaultBase, { max = 5 } = {}) {
40
+ const dir = join(vaultBase, '.brain', 'lessons');
41
+ let files;
42
+ try { files = readdirSync(dir).filter((f) => f.endsWith('.md')).sort().reverse().slice(0, max); }
43
+ catch { return ''; }
44
+ const lines = [];
45
+ for (const f of files) {
46
+ try {
47
+ const body = readFileSync(join(dir, f), 'utf8').replace(/^---[\s\S]*?---\n/, '').trim().split('\n')[0];
48
+ if (body) lines.push(`- ${body}`);
49
+ } catch { /* skip */ }
50
+ }
51
+ return lines.length ? `<lessons>\nLições do projeto (de falhas anteriores):\n${lines.join('\n')}\n</lessons>` : '';
52
+ }
@@ -26,11 +26,33 @@ export function runSensors(sensors, ids, { spawn = spawnSync, cwd, now } = {}) {
26
26
  const s = byId[id];
27
27
  if (!s) { evidence.push({ id, status: 'red', ts, severity: 'critical', note: 'sensor não definido' }); continue; }
28
28
  const r = spawn(s.command, [], { cwd, shell: true, stdio: 'ignore' });
29
- evidence.push({ id, status: (r.status ?? 1) === 0 ? 'green' : 'red', ts, severity: s.severity || 'critical' });
29
+ const entry = { id, status: (r.status ?? 1) === 0 ? 'green' : 'red', ts, severity: s.severity || 'critical' };
30
+ if (s.type === 'mutation' && s.report) {
31
+ // Delegated mutation (Wave B): read the tool's mutation-testing-elements report and
32
+ // attach surviving mutants so verify can turn them into fix tasks.
33
+ try { entry.survivors = parseMutationReport(JSON.parse(readFileSync(join(cwd || '.', s.report), 'utf8'))); }
34
+ catch { /* report ausente/ilegível — segue só com o exit code */ }
35
+ }
36
+ evidence.push(entry);
30
37
  }
31
38
  return evidence;
32
39
  }
33
40
 
41
+ // Parse a mutation-testing-elements report (Stryker et al.): return surviving mutants
42
+ // (Survived | NoCoverage) as {file, line, mutator}.
43
+ export function parseMutationReport(json) {
44
+ const out = [];
45
+ const files = json && json.files ? json.files : {};
46
+ for (const [file, data] of Object.entries(files)) {
47
+ for (const m of (data && data.mutants) || []) {
48
+ if (m.status === 'Survived' || m.status === 'NoCoverage') {
49
+ out.push({ file, line: m.location && m.location.start ? m.location.start.line : null, mutator: m.mutatorName || 'unknown' });
50
+ }
51
+ }
52
+ }
53
+ return out;
54
+ }
55
+
34
56
  // A required sensor blocks the gate when it is missing (never verified) or red at a
35
57
  // non-warning severity. Warnings are advisory: a red warning does not block archive.
36
58
  // Severity comes from the evidence entry (written by runSensors); absent -> critical.
@@ -1,9 +1,15 @@
1
1
  // hooks/spec-core.mjs — living spec (07-Specs) + change delta merge (OpenSpec native).
2
2
  // Pure parsing/merge + promoteSpecs (fs). No import from change-core (avoids a cycle).
3
+ import { createHash } from 'node:crypto';
3
4
  import { readFileSync, writeFileSync } from 'node:fs';
4
5
  import { join } from 'node:path';
5
6
  import { ensureDir } from './obsidian-common.mjs';
6
7
 
8
+ // Short stable fingerprint of tarefas.md — freshness check between package/verdict and gate.
9
+ export function tasksHashOf(md) {
10
+ return createHash('sha1').update(String(md)).digest('hex').slice(0, 12);
11
+ }
12
+
7
13
  const SPECS_DIR = '07-Specs';
8
14
  const REQ_RE = /^### Requisito:\s*(.+)$/gm;
9
15
 
@@ -12,11 +18,15 @@ export function parseRequirements(md) {
12
18
  const matches = [...text.matchAll(REQ_RE)];
13
19
  const reqs = [];
14
20
  for (let i = 0; i < matches.length; i += 1) {
15
- const name = matches[i][1].trim();
21
+ const raw = matches[i][1].trim();
22
+ // Identity is the ID (e.g. GATE-1) when the heading is "<ID> — <nome>"; else the whole text.
23
+ const idM = raw.match(/^([A-Z][A-Z0-9]*-\d+)\s*—\s*(.+)$/);
24
+ const id = idM ? idM[1] : null;
25
+ const name = idM ? idM[2].trim() : raw;
16
26
  const start = matches[i].index + matches[i][0].length;
17
27
  const end = i + 1 < matches.length ? matches[i + 1].index : text.length;
18
28
  const body = text.slice(start, end).replace(/(\n>[^\n]*)+\s*$/, '').trim();
19
- reqs.push({ name, body });
29
+ reqs.push({ id, name, body });
20
30
  }
21
31
  return reqs;
22
32
  }
@@ -33,35 +43,36 @@ export function parseDelta(md) {
33
43
  return {
34
44
  added: parseRequirements(grab('ADDED')),
35
45
  modified: parseRequirements(grab('MODIFIED')),
36
- removed: parseRequirements(grab('REMOVED')).map((r) => r.name),
46
+ removed: parseRequirements(grab('REMOVED')).map((r) => r.id || r.name),
37
47
  };
38
48
  }
39
49
 
40
50
  export function applyDelta(reqs, delta) {
41
- const order = reqs.map((r) => r.name);
42
- const map = new Map(reqs.map((r) => [r.name, r.body]));
51
+ const keyOf = (r) => r.id || r.name; // identity = ID when present, else the name
52
+ const order = reqs.map(keyOf);
53
+ const map = new Map(reqs.map((r) => [keyOf(r), r])); // key -> full {id,name,body}
43
54
  const warnings = [];
44
55
  for (const r of delta.added || []) {
45
- if (map.has(r.name)) warnings.push(`ADDED já existe: ${r.name}`); else order.push(r.name);
46
- map.set(r.name, r.body);
56
+ const k = keyOf(r);
57
+ if (map.has(k)) warnings.push(`ADDED já existe: ${k}`); else order.push(k);
58
+ map.set(k, r);
47
59
  }
48
60
  for (const r of delta.modified || []) {
49
- if (!map.has(r.name)) { warnings.push(`MODIFIED inexistente: ${r.name}`); order.push(r.name); }
50
- map.set(r.name, r.body);
61
+ const k = keyOf(r);
62
+ if (!map.has(k)) { warnings.push(`MODIFIED inexistente: ${k}`); order.push(k); }
63
+ map.set(k, r);
51
64
  }
52
- for (const name of delta.removed || []) {
53
- if (!map.has(name)) warnings.push(`REMOVED inexistente: ${name}`);
54
- map.delete(name);
65
+ for (const k of delta.removed || []) {
66
+ if (!map.has(k)) warnings.push(`REMOVED inexistente: ${k}`);
67
+ map.delete(k);
55
68
  }
56
69
  const seen = new Set();
57
- const out = order
58
- .filter((n) => map.has(n) && !seen.has(n) && seen.add(n))
59
- .map((n) => ({ name: n, body: map.get(n) }));
70
+ const out = order.filter((k) => map.has(k) && !seen.has(k) && seen.add(k)).map((k) => map.get(k));
60
71
  return { reqs: out, warnings };
61
72
  }
62
73
 
63
74
  export function renderSpec(capability, reqs, { footer } = {}) {
64
- const blocks = reqs.map((r) => `### Requisito: ${r.name}\n${r.body}`).join('\n\n');
75
+ const blocks = reqs.map((r) => `### Requisito: ${r.id ? `${r.id} — ${r.name}` : r.name}\n${r.body}`).join('\n\n');
65
76
  const foot = footer ? `\n\n> ${footer}\n` : '\n';
66
77
  return `---\ntype: spec\ncssclasses:\n - topic-spec\ntags:\n - spec\n---\n\n# ${capability}\n\n## Requisitos\n\n${blocks}${foot}`;
67
78
  }
@@ -95,3 +106,20 @@ export function promoteSpecs(vaultBase, changeDir, specs, { changeWikilink, date
95
106
  }
96
107
  return { promoted, warnings };
97
108
  }
109
+
110
+ // Gate check for the independent verdict (Wave A). A requirement-bearing change must have
111
+ // a verdict that is ok and covers every declared req id. A requirement-less change passes:
112
+ // nothing for an independent verifier to check — the sensor gate is already the proof.
113
+ export function evaluateVerdict(verdict, reqIds, { tasksHash } = {}) {
114
+ const ids = reqIds || [];
115
+ if (ids.length === 0) return { ok: true, missing: [] };
116
+ if (!verdict || verdict.ok !== true) return { ok: false, missing: [] };
117
+ // Freshness (G3/#6): a verdict minted against a different tarefas.md is stale. Verdicts
118
+ // without a hash (pre-0.6.1) are accepted for backward compat.
119
+ if (tasksHash && verdict.tasksHash && verdict.tasksHash !== tasksHash) {
120
+ return { ok: false, missing: [], stale: true };
121
+ }
122
+ const covered = new Set((verdict.coverage || []).filter((c) => c.covered).map((c) => c.req));
123
+ const missing = ids.filter((r) => !covered.has(r));
124
+ return { ok: missing.length === 0, missing };
125
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wendkeep",
3
- "version": "0.4.0",
3
+ "version": "0.6.1",
4
4
  "description": "Automatically capture AI coding agent sessions (Claude Code, Codex) as local Markdown in your Obsidian vault — turn-by-turn history, cost/token tracking, auto-extracted decisions/bugs/learnings, rendered in the graph. Local-first.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -11,7 +11,8 @@
11
11
  "bin",
12
12
  "src",
13
13
  "hooks",
14
- "README.md"
14
+ "README.md",
15
+ "CHANGELOG.md"
15
16
  ],
16
17
  "engines": {
17
18
  "node": ">=18"
@@ -32,5 +33,13 @@
32
33
  "token-cost"
33
34
  ],
34
35
  "author": "Roger Alves",
35
- "license": "MIT"
36
+ "license": "MIT",
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "git+https://github.com/rogersialves/wendkeep.git"
40
+ },
41
+ "homepage": "https://github.com/rogersialves/wendkeep#readme",
42
+ "bugs": {
43
+ "url": "https://github.com/rogersialves/wendkeep/issues"
44
+ }
36
45
  }
package/src/change.mjs CHANGED
@@ -9,7 +9,8 @@ import {
9
9
  archiveChange,
10
10
  } from '../hooks/change-core.mjs';
11
11
  import { evaluateGate, requiredSensors } from '../hooks/sensors-core.mjs';
12
- import { getNextAdrNumber } from '../hooks/obsidian-common.mjs';
12
+ import { evaluateVerdict, tasksHashOf } from '../hooks/spec-core.mjs';
13
+ import { getNextAdrNumber, readControl } from '../hooks/obsidian-common.mjs';
13
14
 
14
15
  function resolveVault(argv) {
15
16
  let vault;
@@ -39,7 +40,10 @@ export function runChange(argv) {
39
40
  if (sub === 'new') {
40
41
  const slug = slugArg();
41
42
  if (!slug) { process.stderr.write('wendkeep change new: missing <slug>\n'); process.exit(2); }
42
- const r = newChange(vaultBase, slug, { dateStr: today() });
43
+ // G2: link the active session into the proposta's source: (graph edge proposta->sessão).
44
+ let sessionRel = '';
45
+ try { sessionRel = readControl(vaultBase).session_file || ''; } catch { /* sem control */ }
46
+ const r = newChange(vaultBase, slug, { dateStr: today(), simple: rest.includes('--simple'), sessionRel });
43
47
  process.stdout.write(`change ${r.created ? 'created' : 'exists'}: ${r.rel} (active)\n`);
44
48
  process.exit(0);
45
49
  }
@@ -70,15 +74,33 @@ export function runChange(argv) {
70
74
  if (!slug) { process.stderr.write('wendkeep change archive: missing <slug> and no active change\n'); process.exit(2); }
71
75
  // Real gate (Pilar C): every sensor a task declared must be green in evidencia.json.
72
76
  const gate = (dir) => {
73
- let required = [];
74
- try { required = requiredSensors(parseTasks(readFileSync(join(dir, 'tarefas.md'), 'utf8'))); } catch { /* no tasks */ }
77
+ let tarefasMd = '';
78
+ try { tarefasMd = readFileSync(join(dir, 'tarefas.md'), 'utf8'); } catch { /* no tasks */ }
79
+ const tasks = parseTasks(tarefasMd);
80
+ // G1: uma change não arquiva com tarefa aberta (inclui fix-tasks M.n de mutação).
81
+ const open = tasks.filter((t) => !t.done);
82
+ if (open.length && !rest.includes('--force')) {
83
+ return { ok: false, failing: [`${open.length} tarefa(s) aberta(s) (ex.: ${open[0].id} ${open[0].text}) — conclua ou use --force`] };
84
+ }
85
+ const required = requiredSensors(tasks);
86
+ const reqIds = [...new Set(tasks.map((t) => t.req).filter(Boolean))];
75
87
  let evidence = [];
76
88
  try { evidence = JSON.parse(readFileSync(join(dir, 'evidencia.json'), 'utf8')); } catch { /* no evidence */ }
77
- return evaluateGate(evidence, required);
89
+ const s = evaluateGate(evidence, required);
90
+ if (!s.ok) return s;
91
+ // Independent verdict (Wave A): required only when the change declares [req:] tasks.
92
+ let verdict = null;
93
+ try { verdict = JSON.parse(readFileSync(join(dir, 'verdict.json'), 'utf8')); } catch { /* none */ }
94
+ const v = evaluateVerdict(verdict, reqIds, { tasksHash: tasksHashOf(tarefasMd) });
95
+ if (!v.ok) {
96
+ if (v.stale) return { ok: false, failing: ['verdict stale (tarefas.md mudou depois da verificação) — re-verifique: `wendkeep verify --deep` + wk-verify'] };
97
+ return { ok: false, failing: verdict ? [`verdict incompleto: falta ${v.missing.join(', ')}`] : ['sem verdict — rode `wendkeep verify --deep` + skill wk-verify'] };
98
+ }
99
+ return { ok: true, failing: [] };
78
100
  };
79
101
  const r = archiveChange(vaultBase, slug, { dateStr: today(), adrNum: getNextAdrNumber(vaultBase), gate });
80
102
  if (!r.ok) {
81
- process.stderr.write(`change archive BLOCKED (gate): failing sensors: ${r.failing.join(', ')} — run \`wendkeep verify\`.\n`);
103
+ process.stderr.write(`change archive BLOCKED (gate): ${r.failing.join('; ')}\n`);
82
104
  process.exit(1);
83
105
  }
84
106
  process.stdout.write(`archived: ${r.archivedRel}; ADR: ${r.adrRel}\n`);
package/src/doctor.mjs CHANGED
@@ -1,36 +1,48 @@
1
- // `wendkeep doctor` — run the bundled vault-health check against a vault.
2
- // Resolves the vault from --vault or the OBSIDIAN_VAULT_PATH env var, then runs
3
- // hooks/vault-health.mjs (which reads getVaultBase()).
1
+ // `wendkeep doctor` — vault/session integrity (hooks/vault-health.mjs) PLUS the a2
2
+ // harness integrity check (hooks/harness-doctor.mjs). Exits 1 on any error.
4
3
  import { spawnSync } from 'node:child_process';
5
4
  import { existsSync } from 'node:fs';
6
5
  import { dirname, isAbsolute, join, resolve } from 'node:path';
7
6
  import { fileURLToPath } from 'node:url';
7
+ import { checkHarness } from '../hooks/harness-doctor.mjs';
8
8
 
9
9
  export function runDoctor(argv) {
10
10
  const here = dirname(fileURLToPath(import.meta.url));
11
11
  const hookFile = join(here, '..', 'hooks', 'vault-health.mjs');
12
- if (!existsSync(hookFile)) {
13
- process.stderr.write(`wendkeep doctor: vault-health.mjs not found at ${hookFile}\n`);
14
- process.exit(2);
15
- }
16
12
 
17
13
  let vault;
14
+ let project;
18
15
  const passthrough = [];
19
16
  for (let i = 0; i < argv.length; i += 1) {
20
17
  const a = argv[i];
21
18
  if (a === '--vault') vault = argv[++i];
22
19
  else if (a.startsWith('--vault=')) vault = a.slice(8);
20
+ else if (a === '--project') project = argv[++i];
21
+ else if (a.startsWith('--project=')) project = a.slice(10);
23
22
  else passthrough.push(a);
24
23
  }
25
24
 
26
25
  const env = { ...process.env };
27
26
  if (vault) env.OBSIDIAN_VAULT_PATH = isAbsolute(vault) ? vault : resolve(process.cwd(), vault);
28
-
29
27
  if (!env.OBSIDIAN_VAULT_PATH) {
30
28
  process.stderr.write('wendkeep doctor: no vault. Pass --vault <path> or set OBSIDIAN_VAULT_PATH.\n');
31
29
  process.exit(2);
32
30
  }
31
+ const vaultBase = env.OBSIDIAN_VAULT_PATH;
32
+ const projectRoot = resolve(project || process.cwd());
33
+
34
+ // 1. Session/vault integrity (existing check).
35
+ let healthStatus = 0;
36
+ if (existsSync(hookFile)) {
37
+ const r = spawnSync(process.execPath, [hookFile, ...passthrough], { stdio: 'inherit', env });
38
+ healthStatus = r.status ?? 0;
39
+ }
40
+
41
+ // 2. Harness integrity (Wave B).
42
+ const { errors, warnings } = checkHarness(vaultBase, projectRoot);
43
+ process.stdout.write(`\n[harness] ${errors.length} erro(s), ${warnings.length} aviso(s)\n`);
44
+ for (const e of errors) process.stdout.write(` ✗ ${e}\n`);
45
+ for (const w of warnings) process.stdout.write(` ! ${w}\n`);
33
46
 
34
- const r = spawnSync(process.execPath, [hookFile, ...passthrough], { stdio: 'inherit', env });
35
- process.exit(r.status ?? 0);
47
+ process.exit(healthStatus !== 0 || errors.length ? 1 : 0);
36
48
  }
@@ -0,0 +1,35 @@
1
+ // `wendkeep lesson add "<trigger>" "<lesson>"` — record a project-local lesson (Wave B).
2
+ // The wk-verify / wk-debugging skills call this when a verification fails.
3
+ import { isAbsolute, resolve } from 'node:path';
4
+ import { addLesson } from '../hooks/lessons-core.mjs';
5
+
6
+ function opt(argv, name) {
7
+ const i = argv.indexOf(name);
8
+ if (i >= 0) return argv[i + 1];
9
+ const eq = argv.find((a) => a.startsWith(`${name}=`));
10
+ return eq ? eq.slice(name.length + 1) : undefined;
11
+ }
12
+
13
+ function today() {
14
+ const d = new Date();
15
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
16
+ }
17
+
18
+ export function runLesson(argv) {
19
+ const [sub, ...rest] = argv;
20
+ if (sub !== 'add') {
21
+ process.stderr.write(`wendkeep lesson: unknown "${sub}". Use: lesson add "<trigger>" "<lesson>"\n`);
22
+ process.exit(2);
23
+ }
24
+ const vaultRaw = opt(rest, '--vault') || process.env.OBSIDIAN_VAULT_PATH;
25
+ if (!vaultRaw) { process.stderr.write('wendkeep lesson: no vault (--vault or OBSIDIAN_VAULT_PATH).\n'); process.exit(2); }
26
+ const vaultBase = isAbsolute(vaultRaw) ? vaultRaw : resolve(process.cwd(), vaultRaw);
27
+
28
+ const positional = rest.filter((a, i) => !a.startsWith('--') && rest[i - 1] !== '--vault' && rest[i - 1] !== '--change');
29
+ const [trigger, lesson] = positional;
30
+ if (!trigger || !lesson) { process.stderr.write('wendkeep lesson add: precisa "<trigger>" "<lesson>"\n'); process.exit(2); }
31
+
32
+ const p = addLesson(vaultBase, { trigger, lesson, sourceChange: opt(rest, '--change') || '', dateStr: today() });
33
+ process.stdout.write(`lesson: ${p}\n`);
34
+ process.exit(0);
35
+ }
@@ -23,14 +23,19 @@ Use ao começar qualquer mudança não-trivial. O loop mantém memória (vault)
23
23
  A mudança vira a *ativa* (ponteiro \`.brain/CURRENT_CHANGE.md\`) e é injetada no
24
24
  próximo SessionStart, então você retoma o trabalho em curso automaticamente.
25
25
  3. **Apply** — implemente cada tarefa de \`tarefas.md\` com disciplina **wk-tdd**
26
- (teste vermelho antes do código). Marque \`- [x]\` ao concluir cada uma.
27
- Em tarefas cujo resultado precisa de *prova*, declare o sensor no fim da linha:
28
- \`- [ ] 2.1 valida CORE [sensor:memory-validation]\`.
29
- 4. **Verify** \`wendkeep verify\`. Roda os sensores declarados pelas tarefas e grava
30
- \`evidencia.json\`. Verde = prova registrada.
31
- 5. **Archive** — \`wendkeep change archive <slug>\`. O *gate* trava se algum sensor
32
- declarado não estiver verde. Passando, move a mudança pro \`_arquivo\` e gera um ADR
33
- em \`04-Decisões/\`.
26
+ (teste vermelho antes do código). Marque \`- [x]\` ao concluir. Declare nas tarefas:
27
+ - \`[sensor:<id>]\` a prova automatizada (roda no verify).
28
+ - \`[req:<ID>]\` o requisito do spec que a tarefa satisfaz (ex.: \`[req:GATE-1]\`),
29
+ quando a change mexe numa capability de \`07-Specs\`.
30
+ Ex.: \`- [ ] 2.1 valida CORE [req:MEM-1] [sensor:memory-validation]\`.
31
+ 4. **Verify** — \`wendkeep verify\` roda os sensores \`evidencia.json\`. Depois
32
+ \`wendkeep verify --deep\` monta o *pacote de verificação* pro passe independente.
33
+ 5. **Verify deep** — a skill **wk-verify** (passe fresco, autor≠verificador) lê o pacote,
34
+ re-deriva a cobertura do \`07-Specs\` e grava \`verdict.json\`. Change trivial (sem
35
+ \`[req:]\`) recebe verdict automático — pula este passe.
36
+ 6. **Archive** — \`wendkeep change archive <slug>\`. O *gate* exige sensores verdes **E**
37
+ \`verdict.json\` cobrindo os \`[req:]\`. Passando, promove os deltas pro \`07-Specs\`,
38
+ move a change pro \`_arquivo\` e gera um ADR em \`04-Decisões/\`.
34
39
 
35
40
  ## Regras
36
41
 
@@ -41,25 +46,46 @@ Use ao começar qualquer mudança não-trivial. O loop mantém memória (vault)
41
46
  o grafo do Obsidian mostra plano↔sessão↔decisão.
42
47
  `;
43
48
 
44
- const TDD = `# TDD — Red, Green, Refactor
49
+ const TDD = `# TDD — Red, Green, Refactor (testes que discriminam)
45
50
 
46
- Use ao implementar qualquer comportamento. Nunca escreva código de produção sem um
47
- teste vermelho pedindo por ele.
51
+ Use ao implementar qualquer comportamento. Nunca escreva código de produção sem um teste
52
+ vermelho pedindo por ele — e nunca escreva um teste que passaria sob a implementação errada.
48
53
 
49
54
  ## O ciclo
50
55
 
51
- 1. **Red** — escreva o menor teste que falha por faltar o comportamento desejado.
52
- 2. **Rode e veja falhar** — confirme que falha *pelo motivo certo* (não por erro de
53
- import/typo). Um teste que nunca falhou não prova nada.
54
- 3. **Green** — o código mínimo que faz o teste passar. Nada além disso.
55
- 4. **Refactor** — limpe com os testes verdes te protegendo. Rode de novo.
56
+ 1. **Red** — o menor teste que falha por faltar o comportamento.
57
+ 2. **Rode e veja falhar** — pelo motivo certo (não import/typo). Teste que nunca falhou não prova nada.
58
+ 3. **Green** o código mínimo pra passar.
59
+ 4. **Refactor** — limpe com os verdes te protegendo.
60
+
61
+ ## Derive do spec, não do código
62
+
63
+ Escreva a asserção a partir do *critério de aceite* do requisito (\`07-Specs\`), não lendo a
64
+ implementação. Cada asserção mira o resultado que o spec definiu. Escrever o teste lendo o
65
+ código = ele só confirma o que o código já faz, bugs inclusos.
66
+
67
+ ## Litmus não-raso
68
+
69
+ Rejeite asserção que passaria sob uma implementação errada. Afirme **valor ou estado
70
+ persistido**, nunca "o mock foi chamado". "Testado depois" é rejeitado — o teste nasce
71
+ junto com o código da tarefa.
72
+
73
+ ## Adequação (necessário e suficiente)
74
+
75
+ - Todo critério de aceite tem uma asserção — com evidência \`arquivo:linha\`.
76
+ - Todo teste rastreia um requisito (\`[req:ID]\`). Teste sem requisito = escopo à toa.
77
+ - Nem raso, nem inflado: cobre o que o spec pede, nada além.
78
+
79
+ ## Aprende o projeto
80
+
81
+ Antes de escrever, amostre 5–10 testes existentes: estilo, localização, framework,
82
+ profundidade por camada — trate essa profundidade como *piso*, nunca teto. Leia
83
+ \`AGENTS.md\` / \`.cursor/rules\` / configs de CI pros padrões declarados.
56
84
 
57
85
  ## Regras
58
86
 
59
- - Um comportamento por teste. Nome do teste descreve o comportamento, não a implementação.
60
- - Passos pequenos: um ciclo red→green leva minutos, não horas.
61
- - Não teste detalhe interno que você vai refatorar — teste a interface observável.
62
- - Bug encontrado = primeiro um teste que o reproduz (vermelho), depois a correção (verde).
87
+ - Um comportamento por teste; o nome descreve o comportamento, não a implementação.
88
+ - Bug encontrado = primeiro o teste que o reproduz (vermelho), depois a correção.
63
89
  `;
64
90
 
65
91
  const DEBUGGING = `# Depuração sistemática
@@ -99,6 +125,17 @@ qualquer código.
99
125
  3. **Proponha 2–3 abordagens** — com trade-offs e sua recomendação primeiro.
100
126
  4. **Apresente o design em seções** — escale ao tamanho do problema; confirme cada seção.
101
127
 
128
+ ## Closure gate — nada de ambiguidade solta
129
+
130
+ Antes de fechar o design, resolva cada zona cinza: decide com o usuário, ou registra como
131
+ **assumption assinada** ("assumo X porque Y — corrija se errado"). Cinza declinado pelo
132
+ usuário é *registrado*, não descartado no silêncio. Nada sai do design silenciosamente ambíguo.
133
+
134
+ ## Tabela out-of-scope
135
+
136
+ Liste explicitamente o que a mudança **não** faz. Escopo não declarado vira creep; a tabela
137
+ é o contrato do que ficou de fora — e base pra próxima change.
138
+
102
139
  ## Gate rígido
103
140
 
104
141
  NÃO escreva código, scaffold nem tome ação de implementação até apresentar um design e o
@@ -139,12 +176,40 @@ de 2–5 min:
139
176
  - Nomes/assinaturas consistentes entre tarefas (uma função é \`x()\` em toda parte).
140
177
  `;
141
178
 
179
+ const VERIFY = `# Verificação independente — o passe fresco
180
+
181
+ Use no passo *verify deep* do loop, depois de \`wendkeep verify --deep\`. Você é o
182
+ **verificador**, não o autor — mesmo tendo escrito o código, entre neste passe como se
183
+ nunca tivesse visto a implementação. Contexto fresco, read-only.
184
+
185
+ ## O que fazer
186
+
187
+ 1. Leia o pacote \`08-Mudanças/<slug>/verificacao.json\` (requisitos, tarefas, evidência).
188
+ 2. **Re-derive a cobertura do \`07-Specs\`** — pra cada \`[req:ID]\` da change, cheque se o
189
+ comportamento definido no requisito está coberto por um teste que discrimina (não
190
+ passaria sob impl errada). Evidência \`arquivo:linha\`.
191
+ 3. Outcome check ancorado no spec: o resultado observável bate com o critério de aceite?
192
+ 4. Grave \`08-Mudanças/<slug>/verdict.json\`:
193
+ \`{ "slug": "...", "ok": true, "coverage": [{ "req": "GATE-1", "covered": true, "evidence": "arquivo:linha" }], "tasksHash": "<copie do verificacao.json>", "notes": [] }\`.
194
+ O \`tasksHash\` vem do pacote — é o selo de frescor; sem ele (ou com tarefas alteradas
195
+ depois), o gate rejeita o verdict como stale.
196
+
197
+ ## Regras
198
+
199
+ - **Autor ≠ verificador.** No Claude, spawn um sub-agente read-only pra este passe
200
+ (isolamento real). Nos outros, entre num contexto limpo e re-derive do spec, não da memória.
201
+ - \`ok: false\` se algum requisito não tem cobertura que discrimina. Gap não é "quase lá" — é vermelho.
202
+ - Não conserte aqui. Gap vira tarefa de correção na change; re-verifica depois.
203
+ - O gate do \`archive\` **exige** \`verdict.json\` com \`ok\` cobrindo todo \`[req:]\`. Sem isso, não arquiva.
204
+ `;
205
+
142
206
  export const WK_SKILLS = [
143
207
  skill('wk-workflow', 'Use ao começar qualquer mudança não-trivial — orquestra o loop a2 (explore, propose, apply, verify, archive) nos comandos wendkeep.', WORKFLOW),
144
- skill('wk-tdd', 'Use ao implementar qualquer comportamento — disciplina Red, Green, Refactor.', TDD),
208
+ skill('wk-tdd', 'Use ao implementar qualquer comportamento — Red/Green/Refactor com testes que discriminam (derivados do spec, litmus não-raso, adequação).', TDD),
145
209
  skill('wk-debugging', 'Use quando algo falha ou quebra — depuração sistemática por hipótese antes de corrigir.', DEBUGGING),
146
- skill('wk-brainstorming', 'Use quando a ideia ainda é vaga — transforma ideia em design aprovado antes de código.', BRAINSTORMING),
210
+ skill('wk-brainstorming', 'Use quando a ideia ainda é vaga — vira design aprovado, com closure gate e tabela out-of-scope, antes de código.', BRAINSTORMING),
147
211
  skill('wk-planning', 'Use após um design aprovado — decompõe em plano de tarefas TDD bite-sized.', PLANNING),
212
+ skill('wk-verify', 'Use no verify deep — passe independente read-only (autor≠verificador) que re-deriva a cobertura do spec e grava verdict.json.', VERIFY),
148
213
  ];
149
214
 
150
215
  // Seed each skill into <brainDir>/skills/<name>/SKILL.md if absent (non-destructive).
package/src/taxonomy.mjs CHANGED
@@ -35,6 +35,8 @@ export const HOOK_FILES = [
35
35
  'change-core.mjs',
36
36
  'spec-core.mjs',
37
37
  'sensors-core.mjs',
38
+ 'harness-doctor.mjs',
39
+ 'lessons-core.mjs',
38
40
  'brain-inject.mjs',
39
41
  'brain-recall.mjs',
40
42
  'brain-reindex.mjs',
package/src/verify.mjs CHANGED
@@ -1,10 +1,17 @@
1
1
  // `wendkeep verify [--change <slug>]` — run a change's task sensors, record evidence.
2
2
  // Sensors run at the PROJECT root (--project or cwd); the change + evidence live in
3
3
  // the VAULT. Writes 08-Mudanças/<slug>/evidencia.json; exit 1 if a critical sensor is red.
4
- import { readFileSync, writeFileSync } from 'node:fs';
4
+ import { readFileSync, unlinkSync, writeFileSync } from 'node:fs';
5
5
  import { isAbsolute, join, resolve } from 'node:path';
6
- import { parseTasks, activeChange } from '../hooks/change-core.mjs';
6
+ import { parseTasks, activeChange, appendFixTasks } from '../hooks/change-core.mjs';
7
7
  import { loadSensors, requiredSensors, runSensors, evaluateGate } from '../hooks/sensors-core.mjs';
8
+ import { tasksHashOf } from '../hooks/spec-core.mjs';
9
+ import { addLesson } from '../hooks/lessons-core.mjs';
10
+
11
+ function today() {
12
+ const d = new Date();
13
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
14
+ }
8
15
 
9
16
  function opt(argv, name) {
10
17
  const i = argv.indexOf(name);
@@ -31,6 +38,37 @@ export function runVerify(argv) {
31
38
  const evidence = runSensors(sensors, ids, { cwd: projectRoot });
32
39
  writeFileSync(join(changeDir, 'evidencia.json'), `${JSON.stringify(evidence, null, 2)}\n`, 'utf8');
33
40
 
41
+ // Mutation survivors -> fix tasks (Wave B), bounded at 3 rounds then escalate. A surviving
42
+ // mutant always fails verify (exit 1): the suite does not discriminate yet. A clean report
43
+ // resets the round counter so a future survivor starts a fresh cycle.
44
+ const withSurvivors = evidence.filter((e) => e.survivors && e.survivors.length);
45
+ const roundFile = join(changeDir, '.mutation-round');
46
+ if (!withSurvivors.length) {
47
+ try { unlinkSync(roundFile); } catch { /* nunca houve rodada */ }
48
+ } else {
49
+ let round = 0;
50
+ try { round = Number(readFileSync(roundFile, 'utf8').trim()) || 0; } catch { /* first round */ }
51
+ if (round >= 3) {
52
+ process.stderr.write('verify: mutantes ainda sobrevivem após 3 rodadas — revise os testes à mão.\n');
53
+ const flat = withSurvivors.flatMap((e) => e.survivors.map((s) => `${s.file}:${s.line}`));
54
+ try {
55
+ addLesson(vaultBase, {
56
+ trigger: `mutantes persistentes em ${slug}`,
57
+ lesson: `3 rodadas de fix-tasks não mataram: ${flat.join(', ')} — os testes desses pontos não discriminam.`,
58
+ sourceChange: slug,
59
+ dateStr: today(),
60
+ });
61
+ } catch { /* lesson é bônus, nunca derruba o verify */ }
62
+ } else {
63
+ let added = 0;
64
+ for (const e of withSurvivors) added += appendFixTasks(changeDir, e.survivors, e.id);
65
+ writeFileSync(roundFile, String(round + 1), 'utf8');
66
+ process.stdout.write(`verify: ${added} fix-task(s) de mutação (rodada ${round + 1}/3)\n`);
67
+ }
68
+ process.stderr.write('verify: mutantes sobreviventes — a suíte não discrimina ainda.\n');
69
+ process.exit(1);
70
+ }
71
+
34
72
  // Same rule as the archive gate: evidence carries severity, so evaluateGate blocks
35
73
  // only on critical/missing — a red warning is advisory and passes verify.
36
74
  const { ok, failing } = evaluateGate(evidence, ids);
@@ -39,6 +77,29 @@ export function runVerify(argv) {
39
77
  process.stdout.write(` ${mark} ${e.id}${e.severity === 'warning' && e.status === 'red' ? ' (warning)' : ''}\n`);
40
78
  }
41
79
  if (!ok) { process.stderr.write(`verify: critical sensors red: ${failing.join(', ')}\n`); process.exit(1); }
80
+
81
+ // --deep (Q2=B): assemble the verification package the wk-verify skill judges. A trivial
82
+ // change (no [req:] tasks, sensors green) gets an auto verdict — no agent pass needed.
83
+ if (argv.includes('--deep')) {
84
+ const tasks = parseTasks(tarefas);
85
+ const reqIds = [...new Set(tasks.map((t) => t.req).filter(Boolean))];
86
+ const tasksHash = tasksHashOf(tarefas);
87
+ const pkg = {
88
+ slug,
89
+ tasksHash,
90
+ requirements: reqIds.map((id) => ({ id })),
91
+ tasks: tasks.map((t) => ({ id: t.id, text: t.text, req: t.req || null, done: t.done })),
92
+ sensors: evidence,
93
+ };
94
+ writeFileSync(join(changeDir, 'verificacao.json'), `${JSON.stringify(pkg, null, 2)}\n`, 'utf8');
95
+ if (reqIds.length === 0) {
96
+ writeFileSync(join(changeDir, 'verdict.json'), `${JSON.stringify({ slug, ok: true, coverage: [], tasksHash, notes: ['trivial: sem requisito'] }, null, 2)}\n`, 'utf8');
97
+ process.stdout.write('verify --deep: pacote + verdict trivial escritos\n');
98
+ } else {
99
+ process.stdout.write('verify --deep: pacote escrito — rode a skill wk-verify pra gravar verdict.json\n');
100
+ }
101
+ process.exit(0);
102
+ }
42
103
  process.stdout.write(`verify OK (${ids.length} sensor(s))\n`);
43
104
  process.exit(0);
44
105
  }