wendkeep 0.6.0 → 0.7.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,46 @@ 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.7.0] — 2026-07-05
8
+
9
+ Ergonomics: the loop without hand-editing files.
10
+
11
+ ### Added
12
+ - **`change status`** — one screen: tasks (done/open with `[req:]`/`[sensor:]`), sensor
13
+ evidence, verdict state (ok / stale / incomplete / absent), mutation round.
14
+ - **`change done <id>` / `undone <id>`** — tick tasks from the CLI (exact-id anchored).
15
+ - **`change diff`** — dry-run preview of the spec promotion (`+` ADDED / `~` MODIFIED /
16
+ `-` REMOVED / `!` warnings) without touching `07-Specs`.
17
+ - **`spec list` / `spec show <capability>`** — read-only views over the living specs.
18
+ - **`sensors list`** — the sensors from `wendkeep.sensors.json`; a **JSON Schema** for the
19
+ file now ships in the package (`schema/`) and the init seed points `$schema` at it.
20
+ - README: "the loop in five minutes" worked example.
21
+
22
+ ### Fixed
23
+ - `change` subcommands without a positional argument no longer mistake the `--vault` value
24
+ for a slug.
25
+
26
+ ## [0.6.1] — 2026-07-05
27
+
28
+ Hardening: CI + real-world gate holes found by self-audit.
29
+
30
+ ### Added
31
+ - **CI (GitHub Actions):** test + check matrix on ubuntu/windows × Node 18/20/22.
32
+ - **Open-task gate:** `change archive` blocks while tasks are open (`- [ ]`, including mutation
33
+ fix-tasks `M.n` — a surviving mutant can no longer be archived). Explicit escape: `--force`.
34
+ - **Freshness seal (`tasksHash`):** `verify --deep` fingerprints `tarefas.md` into the package
35
+ and verdict; the gate rejects a verdict minted against different tasks as stale. Pre-0.6.1
36
+ verdicts (no hash) still accepted.
37
+ - **Auto-lesson on mutation escalation:** the 3rd surviving round records a project-local lesson.
38
+ - **Session link in proposta:** `change new` fills `source:` with the active session (graph edge
39
+ proposta → sessão).
40
+
41
+ ### Fixed
42
+ - `.mutation-round` now resets when the report comes back clean (a future survivor starts a
43
+ fresh 3-round cycle instead of instantly escalating).
44
+ - `verify` exits 1 when mutants survive (was 0 — CI couldn't see it).
45
+ - `.brain/lessons/` capped at 50 (oldest pruned) instead of growing unbounded.
46
+
7
47
  ## [0.6.0] — 2026-07-05
8
48
 
9
49
  Enforcement layer (Wave B of the TLC-parity program) — closes TLC parity.
@@ -120,6 +160,8 @@ Initial release — the capture engine, extracted from a system in daily product
120
160
  - `wendkeep init` (cross-platform installer) + optional `@bitbonsai/mcpvault` MCP server.
121
161
 
122
162
  <!-- Only v0.4.0+ is tagged in git (history starts here); older versions link to npm. -->
163
+ [0.7.0]: https://github.com/rogersialves/wendkeep/releases/tag/v0.7.0
164
+ [0.6.1]: https://github.com/rogersialves/wendkeep/releases/tag/v0.6.1
123
165
  [0.6.0]: https://github.com/rogersialves/wendkeep/releases/tag/v0.6.0
124
166
  [0.5.0]: https://github.com/rogersialves/wendkeep/releases/tag/v0.5.0
125
167
  [0.4.0]: https://github.com/rogersialves/wendkeep/releases/tag/v0.4.0
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.
@@ -68,8 +70,11 @@ No re‑copying, no snapshot to re‑sync — the package is the single source o
68
70
  |---|---|
69
71
  | `wendkeep init` | Set up wendkeep in a project (vault taxonomy + settings + MCP + skills). |
70
72
  | `wendkeep hook <name>` | Run a session hook; invoked by `settings.json` (reads agent JSON on stdin). |
71
- | `wendkeep change <sub>` | Change lifecycle: `new <slug>` / `list` / `show <slug>` / `archive <slug>`. |
72
- | `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). |
73
78
  | `wendkeep sync-defs` | Copy `.brain/agents\|skills` into the project (`.codex/agents`, `.claude/skills`). |
74
79
  | `wendkeep validate-memory [path]` | Validate `.brain/CORE.md` (cap 25, 3 sections, no secrets/PII). |
75
80
  | `wendkeep doctor [--vault P]` | Run a vault health check (integrity of sessions, registry, links). |
@@ -88,7 +93,37 @@ explore → propose → apply (TDD) → verify → archive
88
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.
89
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/`.
90
95
 
91
- `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.
92
127
 
93
128
  ## How it works
94
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`);
@@ -123,6 +123,17 @@ export function parseTasks(md) {
123
123
  return tasks;
124
124
  }
125
125
 
126
+ // Toggle a task checkbox by its exact id (0.7.0 ergonomics). Returns false when absent.
127
+ export function setTaskDone(changeDir, taskId, done = true) {
128
+ const path = join(changeDir, 'tarefas.md');
129
+ const md = readFileSync(path, 'utf8');
130
+ const esc = String(taskId).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
131
+ const re = new RegExp(`^(-\\s+\\[)( |x)(\\]\\s+${esc}\\s)`, 'm');
132
+ if (!re.test(md)) return false;
133
+ writeFileSync(path, md.replace(re, `$1${done ? 'x' : ' '}$3`), 'utf8');
134
+ return true;
135
+ }
136
+
126
137
  export function listChanges(vaultBase) {
127
138
  const base = join(vaultBase, CHANGES_DIR);
128
139
  const active = [];
@@ -1,9 +1,11 @@
1
1
  // hooks/lessons-core.mjs — project-local lessons from verification failures (Wave B).
2
2
  // A failure distilled into a terse lesson; brain-inject surfaces the recent ones at
3
3
  // SessionStart so the framework gets sharper on your codebase. No external deps.
4
- import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
4
+ import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs';
5
5
  import { join } from 'node:path';
6
6
 
7
+ const MAX_LESSONS = 50; // dir cap (#7): oldest (filename asc = date asc) pruned first
8
+
7
9
  function slugify(s) {
8
10
  return String(s)
9
11
  .toLowerCase()
@@ -22,6 +24,13 @@ export function addLesson(vaultBase, { trigger, lesson, sourceChange = '', dateS
22
24
  `---\ntype: lesson\ntrigger: ${JSON.stringify(String(trigger))}\nsource: ${sourceChange}\ndate: ${dateStr}\n---\n\n${lesson}\n`,
23
25
  'utf8',
24
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 */ }
25
34
  return path;
26
35
  }
27
36
 
@@ -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
 
@@ -104,10 +110,15 @@ export function promoteSpecs(vaultBase, changeDir, specs, { changeWikilink, date
104
110
  // Gate check for the independent verdict (Wave A). A requirement-bearing change must have
105
111
  // a verdict that is ok and covers every declared req id. A requirement-less change passes:
106
112
  // nothing for an independent verifier to check — the sensor gate is already the proof.
107
- export function evaluateVerdict(verdict, reqIds) {
113
+ export function evaluateVerdict(verdict, reqIds, { tasksHash } = {}) {
108
114
  const ids = reqIds || [];
109
115
  if (ids.length === 0) return { ok: true, missing: [] };
110
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
+ }
111
122
  const covered = new Set((verdict.coverage || []).filter((c) => c.covered).map((c) => c.req));
112
123
  const missing = ids.filter((r) => !covered.has(r));
113
124
  return { ok: missing.length === 0, missing };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wendkeep",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
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,6 +11,7 @@
11
11
  "bin",
12
12
  "src",
13
13
  "hooks",
14
+ "schema",
14
15
  "README.md",
15
16
  "CHANGELOG.md"
16
17
  ],
@@ -0,0 +1,34 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://raw.githubusercontent.com/rogersialves/wendkeep/main/schema/wendkeep.sensors.schema.json",
4
+ "title": "wendkeep.sensors.json",
5
+ "description": "Native sensor config for the wendkeep harness (harness contract v1.1).",
6
+ "type": "object",
7
+ "required": ["version", "sensors"],
8
+ "properties": {
9
+ "$schema": { "type": "string" },
10
+ "version": { "const": 1 },
11
+ "source": { "type": "string" },
12
+ "sensors": {
13
+ "type": "array",
14
+ "items": {
15
+ "type": "object",
16
+ "required": ["id", "command"],
17
+ "properties": {
18
+ "id": { "type": "string", "minLength": 1 },
19
+ "name": { "type": "string" },
20
+ "description": { "type": "string" },
21
+ "severity": { "enum": ["critical", "warning"], "default": "critical" },
22
+ "type": { "enum": ["command", "mutation"], "default": "command" },
23
+ "command": { "type": "string", "minLength": 1 },
24
+ "report": {
25
+ "type": "string",
26
+ "description": "type: mutation only — path (project-relative) to the mutation-testing-elements report."
27
+ }
28
+ },
29
+ "additionalProperties": false
30
+ }
31
+ }
32
+ },
33
+ "additionalProperties": false
34
+ }
package/src/change.mjs CHANGED
@@ -6,11 +6,12 @@ import {
6
6
  activeChange,
7
7
  listChanges,
8
8
  parseTasks,
9
+ setTaskDone,
9
10
  archiveChange,
10
11
  } from '../hooks/change-core.mjs';
11
12
  import { evaluateGate, requiredSensors } from '../hooks/sensors-core.mjs';
12
- import { evaluateVerdict } from '../hooks/spec-core.mjs';
13
- import { getNextAdrNumber } from '../hooks/obsidian-common.mjs';
13
+ import { evaluateVerdict, tasksHashOf, parseSpecsList, parseDelta, parseRequirements, applyDelta } from '../hooks/spec-core.mjs';
14
+ import { getNextAdrNumber, readControl } from '../hooks/obsidian-common.mjs';
14
15
 
15
16
  function resolveVault(argv) {
16
17
  let vault;
@@ -27,6 +28,13 @@ function resolveVault(argv) {
27
28
  return isAbsolute(base) ? base : resolve(process.cwd(), base);
28
29
  }
29
30
 
31
+ function opt(argv, name) {
32
+ const i = argv.indexOf(name);
33
+ if (i >= 0) return argv[i + 1];
34
+ const eq = argv.find((a) => a.startsWith(`${name}=`));
35
+ return eq ? eq.slice(name.length + 1) : undefined;
36
+ }
37
+
30
38
  function today() {
31
39
  const d = new Date();
32
40
  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
@@ -35,12 +43,16 @@ function today() {
35
43
  export function runChange(argv) {
36
44
  const [sub, ...rest] = argv;
37
45
  const vaultBase = resolveVault(rest);
38
- const slugArg = () => rest.find((a) => !a.startsWith('-'));
46
+ const VALUE_FLAGS = new Set(['--vault', '--change', '--project']);
47
+ const slugArg = () => rest.find((a, i) => !a.startsWith('-') && !VALUE_FLAGS.has(rest[i - 1]));
39
48
 
40
49
  if (sub === 'new') {
41
50
  const slug = slugArg();
42
51
  if (!slug) { process.stderr.write('wendkeep change new: missing <slug>\n'); process.exit(2); }
43
- const r = newChange(vaultBase, slug, { dateStr: today(), simple: rest.includes('--simple') });
52
+ // G2: link the active session into the proposta's source: (graph edge proposta->sessão).
53
+ let sessionRel = '';
54
+ try { sessionRel = readControl(vaultBase).session_file || ''; } catch { /* sem control */ }
55
+ const r = newChange(vaultBase, slug, { dateStr: today(), simple: rest.includes('--simple'), sessionRel });
44
56
  process.stdout.write(`change ${r.created ? 'created' : 'exists'}: ${r.rel} (active)\n`);
45
57
  process.exit(0);
46
58
  }
@@ -66,13 +78,89 @@ export function runChange(argv) {
66
78
  process.exit(0);
67
79
  }
68
80
 
81
+ if (sub === 'status') {
82
+ const slug = slugArg() || activeChange(vaultBase);
83
+ if (!slug) { process.stderr.write('wendkeep change status: no change (arg or active)\n'); process.exit(2); }
84
+ const dir = join(vaultBase, '08-Mudanças', slug);
85
+ let tarefasMd;
86
+ try { tarefasMd = readFileSync(join(dir, 'tarefas.md'), 'utf8'); }
87
+ catch { process.stderr.write(`wendkeep change status: not found: ${slug}\n`); process.exit(2); }
88
+ const tasks = parseTasks(tarefasMd);
89
+ const done = tasks.filter((t) => t.done).length;
90
+ process.stdout.write(`change: ${slug}${slug === activeChange(vaultBase) ? ' (ativa)' : ''}\n`);
91
+ let specs = [];
92
+ try { specs = parseSpecsList(readFileSync(join(dir, 'proposta.md'), 'utf8')); } catch { /* sem proposta */ }
93
+ process.stdout.write(`specs: ${specs.join(', ') || '(nenhuma)'}\n`);
94
+ process.stdout.write(`tarefas: ${done} done / ${tasks.length - done} open\n`);
95
+ for (const t of tasks) {
96
+ process.stdout.write(` [${t.done ? 'x' : ' '}] ${t.id} ${t.text}${t.req ? ` [req:${t.req}]` : ''}${t.sensor ? ` [sensor:${t.sensor}]` : ''}\n`);
97
+ }
98
+ let evidence = null;
99
+ try { evidence = JSON.parse(readFileSync(join(dir, 'evidencia.json'), 'utf8')); } catch { /* sem evidência */ }
100
+ if (evidence) for (const e of evidence) process.stdout.write(` ${e.status === 'green' ? '✓' : '✗'} ${e.id} (${e.severity || 'critical'})\n`);
101
+ else process.stdout.write('evidencia: ausente\n');
102
+ const reqIds = [...new Set(tasks.map((t) => t.req).filter(Boolean))];
103
+ let verdict = null;
104
+ try { verdict = JSON.parse(readFileSync(join(dir, 'verdict.json'), 'utf8')); } catch { /* sem verdict */ }
105
+ if (!reqIds.length) process.stdout.write('verdict: não exigido (sem [req:])\n');
106
+ else if (!verdict) process.stdout.write('verdict: ausente — rode `wendkeep verify --deep` + wk-verify\n');
107
+ else {
108
+ const v = evaluateVerdict(verdict, reqIds, { tasksHash: tasksHashOf(tarefasMd) });
109
+ process.stdout.write(`verdict: ${v.ok ? 'ok' : v.stale ? 'stale — re-verifique' : `incompleto: falta ${v.missing.join(', ')}`}\n`);
110
+ }
111
+ try { process.stdout.write(`mutation-round: ${readFileSync(join(dir, '.mutation-round'), 'utf8').trim()}/3\n`); } catch { /* sem rodadas */ }
112
+ process.exit(0);
113
+ }
114
+
115
+ if (sub === 'done' || sub === 'undone') {
116
+ const taskId = slugArg();
117
+ if (!taskId) { process.stderr.write(`wendkeep change ${sub}: missing <taskId>\n`); process.exit(2); }
118
+ const slug = opt(rest, '--change') || activeChange(vaultBase);
119
+ if (!slug) { process.stderr.write(`wendkeep change ${sub}: no active change\n`); process.exit(2); }
120
+ const dir = join(vaultBase, '08-Mudanças', slug);
121
+ let ok = false;
122
+ try { ok = setTaskDone(dir, taskId, sub === 'done'); } catch { /* sem tarefas.md */ }
123
+ if (!ok) { process.stderr.write(`wendkeep change ${sub}: task não encontrada: ${taskId}\n`); process.exit(2); }
124
+ process.stdout.write(`task ${taskId}: ${sub === 'done' ? '[x]' : '[ ]'}\n`);
125
+ process.exit(0);
126
+ }
127
+
128
+ if (sub === 'diff') {
129
+ const slug = slugArg() || activeChange(vaultBase);
130
+ if (!slug) { process.stderr.write('wendkeep change diff: no change (arg or active)\n'); process.exit(2); }
131
+ const dir = join(vaultBase, '08-Mudanças', slug);
132
+ let specs = [];
133
+ try { specs = parseSpecsList(readFileSync(join(dir, 'proposta.md'), 'utf8')); }
134
+ catch { process.stderr.write(`wendkeep change diff: not found: ${slug}\n`); process.exit(2); }
135
+ if (!specs.length) { process.stdout.write('diff: sem specs declaradas na proposta\n'); process.exit(0); }
136
+ for (const cap of specs) {
137
+ let delta;
138
+ try { delta = parseDelta(readFileSync(join(dir, 'specs', cap, 'spec.md'), 'utf8')); }
139
+ catch { process.stdout.write(`! sem delta para ${cap}\n`); continue; }
140
+ process.stdout.write(`spec: ${cap}\n`);
141
+ for (const r of delta.added) process.stdout.write(` + ${r.id || r.name} (ADDED)\n`);
142
+ for (const r of delta.modified) process.stdout.write(` ~ ${r.id || r.name} (MODIFIED)\n`);
143
+ for (const k of delta.removed) process.stdout.write(` - ${k} (REMOVED)\n`);
144
+ let living = [];
145
+ try { living = parseRequirements(readFileSync(join(vaultBase, '07-Specs', `${cap}.md`), 'utf8')); } catch { /* nova capability */ }
146
+ for (const w of applyDelta(living, delta).warnings) process.stdout.write(` ! ${w}\n`);
147
+ }
148
+ process.exit(0);
149
+ }
150
+
69
151
  if (sub === 'archive') {
70
152
  const slug = slugArg() || activeChange(vaultBase);
71
153
  if (!slug) { process.stderr.write('wendkeep change archive: missing <slug> and no active change\n'); process.exit(2); }
72
154
  // Real gate (Pilar C): every sensor a task declared must be green in evidencia.json.
73
155
  const gate = (dir) => {
74
- let tasks = [];
75
- try { tasks = parseTasks(readFileSync(join(dir, 'tarefas.md'), 'utf8')); } catch { /* no tasks */ }
156
+ let tarefasMd = '';
157
+ try { tarefasMd = readFileSync(join(dir, 'tarefas.md'), 'utf8'); } catch { /* no tasks */ }
158
+ const tasks = parseTasks(tarefasMd);
159
+ // G1: uma change não arquiva com tarefa aberta (inclui fix-tasks M.n de mutação).
160
+ const open = tasks.filter((t) => !t.done);
161
+ if (open.length && !rest.includes('--force')) {
162
+ return { ok: false, failing: [`${open.length} tarefa(s) aberta(s) (ex.: ${open[0].id} ${open[0].text}) — conclua ou use --force`] };
163
+ }
76
164
  const required = requiredSensors(tasks);
77
165
  const reqIds = [...new Set(tasks.map((t) => t.req).filter(Boolean))];
78
166
  let evidence = [];
@@ -82,8 +170,11 @@ export function runChange(argv) {
82
170
  // Independent verdict (Wave A): required only when the change declares [req:] tasks.
83
171
  let verdict = null;
84
172
  try { verdict = JSON.parse(readFileSync(join(dir, 'verdict.json'), 'utf8')); } catch { /* none */ }
85
- const v = evaluateVerdict(verdict, reqIds);
86
- if (!v.ok) return { ok: false, failing: verdict ? [`verdict incompleto: falta ${v.missing.join(', ')}`] : ['sem verdict — rode `wendkeep verify --deep` + skill wk-verify'] };
173
+ const v = evaluateVerdict(verdict, reqIds, { tasksHash: tasksHashOf(tarefasMd) });
174
+ if (!v.ok) {
175
+ if (v.stale) return { ok: false, failing: ['verdict stale (tarefas.md mudou depois da verificação) — re-verifique: `wendkeep verify --deep` + wk-verify'] };
176
+ return { ok: false, failing: verdict ? [`verdict incompleto: falta ${v.missing.join(', ')}`] : ['sem verdict — rode `wendkeep verify --deep` + skill wk-verify'] };
177
+ }
87
178
  return { ok: true, failing: [] };
88
179
  };
89
180
  const r = archiveChange(vaultBase, slug, { dateStr: today(), adrNum: getNextAdrNumber(vaultBase), gate });
@@ -97,6 +188,6 @@ export function runChange(argv) {
97
188
  process.exit(0);
98
189
  }
99
190
 
100
- process.stderr.write(`wendkeep change: unknown subcommand "${sub}". Known: new, list, show, archive.\n`);
191
+ process.stderr.write(`wendkeep change: unknown subcommand "${sub}". Known: new, list, show, status, done, undone, diff, archive.\n`);
101
192
  process.exit(2);
102
193
  }
@@ -37,7 +37,7 @@ export function renderSensorsJson(scripts = {}) {
37
37
  });
38
38
  }
39
39
  }
40
- return `${JSON.stringify({ version: 1, source: 'manual', sensors }, null, 2)}\n`;
40
+ return `${JSON.stringify({ $schema: 'https://raw.githubusercontent.com/rogersialves/wendkeep/main/schema/wendkeep.sensors.schema.json', version: 1, source: 'manual', sensors }, null, 2)}\n`;
41
41
  }
42
42
 
43
43
  function readProjectScripts(projectPath) {
@@ -0,0 +1,23 @@
1
+ // `wendkeep sensors list` — read-only view over wendkeep.sensors.json (0.7.0).
2
+ import { resolve } from 'node:path';
3
+ import { loadSensors } from '../hooks/sensors-core.mjs';
4
+
5
+ export function runSensors(argv) {
6
+ const [sub, ...rest] = argv;
7
+ if (sub !== 'list') {
8
+ process.stderr.write(`wendkeep sensors: unknown subcommand "${sub}". Known: list.\n`);
9
+ process.exit(2);
10
+ }
11
+ let project;
12
+ for (let i = 0; i < rest.length; i += 1) {
13
+ if (rest[i] === '--project') project = rest[++i];
14
+ else if (rest[i].startsWith('--project=')) project = rest[i].slice(10);
15
+ }
16
+ const projectRoot = resolve(project || process.cwd());
17
+ const sensors = loadSensors(projectRoot);
18
+ if (!sensors.length) { process.stdout.write('sensors: (nenhum — crie wendkeep.sensors.json na raiz)\n'); process.exit(0); }
19
+ for (const s of sensors) {
20
+ process.stdout.write(`${s.id}: ${s.type || 'command'} · ${s.severity || 'critical'} · ${s.command}\n`);
21
+ }
22
+ process.exit(0);
23
+ }
@@ -190,7 +190,9 @@ nunca tivesse visto a implementação. Contexto fresco, read-only.
190
190
  passaria sob impl errada). Evidência \`arquivo:linha\`.
191
191
  3. Outcome check ancorado no spec: o resultado observável bate com o critério de aceite?
192
192
  4. Grave \`08-Mudanças/<slug>/verdict.json\`:
193
- \`{ "slug": "...", "ok": true, "coverage": [{ "req": "GATE-1", "covered": true, "evidence": "arquivo:linha" }], "notes": [] }\`.
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.
194
196
 
195
197
  ## Regras
196
198
 
package/src/spec.mjs ADDED
@@ -0,0 +1,53 @@
1
+ // `wendkeep spec <sub>` — read-only views over the living specs in 07-Specs (0.7.0).
2
+ import { readFileSync, readdirSync } from 'node:fs';
3
+ import { isAbsolute, join, resolve } from 'node:path';
4
+ import { parseRequirements } from '../hooks/spec-core.mjs';
5
+
6
+ function resolveVault(argv) {
7
+ let vault;
8
+ for (let i = 0; i < argv.length; i += 1) {
9
+ const a = argv[i];
10
+ if (a === '--vault') vault = argv[++i];
11
+ else if (a.startsWith('--vault=')) vault = a.slice(8);
12
+ }
13
+ const base = vault || process.env.OBSIDIAN_VAULT_PATH;
14
+ if (!base) {
15
+ process.stderr.write('wendkeep spec: no vault. Pass --vault <path> or set OBSIDIAN_VAULT_PATH.\n');
16
+ process.exit(2);
17
+ }
18
+ return isAbsolute(base) ? base : resolve(process.cwd(), base);
19
+ }
20
+
21
+ export function runSpec(argv) {
22
+ const [sub, ...rest] = argv;
23
+ const vaultBase = resolveVault(rest);
24
+ const specsDir = join(vaultBase, '07-Specs');
25
+
26
+ if (sub === 'list') {
27
+ let files = [];
28
+ try { files = readdirSync(specsDir).filter((f) => f.endsWith('.md') && f !== 'README.md'); } catch { /* sem specs */ }
29
+ if (!files.length) { process.stdout.write('specs: (nenhuma)\n'); process.exit(0); }
30
+ for (const f of files) {
31
+ const md = readFileSync(join(specsDir, f), 'utf8');
32
+ const n = parseRequirements(md).length;
33
+ const upd = md.match(/> Atualizado por .* em (\d{4}-\d{2}-\d{2})/);
34
+ process.stdout.write(`${f.replace(/\.md$/, '')}: ${n} requisito(s)${upd ? ` (atualizado ${upd[1]})` : ''}\n`);
35
+ }
36
+ process.exit(0);
37
+ }
38
+
39
+ if (sub === 'show') {
40
+ const cap = rest.find((a, i) => !a.startsWith('-') && rest[i - 1] !== '--vault');
41
+ if (!cap) { process.stderr.write('wendkeep spec show: missing <capability>\n'); process.exit(2); }
42
+ let md;
43
+ try { md = readFileSync(join(specsDir, `${cap}.md`), 'utf8'); }
44
+ catch { process.stderr.write(`wendkeep spec show: not found: ${cap}\n`); process.exit(2); }
45
+ const reqs = parseRequirements(md);
46
+ process.stdout.write(`${cap}: ${reqs.length} requisito(s)\n`);
47
+ for (const r of reqs) process.stdout.write(` ${r.id ? `${r.id} — ` : ''}${r.name}\n`);
48
+ process.exit(0);
49
+ }
50
+
51
+ process.stderr.write(`wendkeep spec: unknown subcommand "${sub}". Known: list, show.\n`);
52
+ process.exit(2);
53
+ }
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
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,20 +38,35 @@ 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
 
34
- // Mutation survivors -> fix tasks (Wave B), bounded at 3 rounds then escalate.
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.
35
44
  const withSurvivors = evidence.filter((e) => e.survivors && e.survivors.length);
36
- if (withSurvivors.length) {
37
- const roundFile = join(changeDir, '.mutation-round');
45
+ const roundFile = join(changeDir, '.mutation-round');
46
+ if (!withSurvivors.length) {
47
+ try { unlinkSync(roundFile); } catch { /* nunca houve rodada */ }
48
+ } else {
38
49
  let round = 0;
39
50
  try { round = Number(readFileSync(roundFile, 'utf8').trim()) || 0; } catch { /* first round */ }
40
51
  if (round >= 3) {
41
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 */ }
42
62
  } else {
43
63
  let added = 0;
44
64
  for (const e of withSurvivors) added += appendFixTasks(changeDir, e.survivors, e.id);
45
65
  writeFileSync(roundFile, String(round + 1), 'utf8');
46
66
  process.stdout.write(`verify: ${added} fix-task(s) de mutação (rodada ${round + 1}/3)\n`);
47
67
  }
68
+ process.stderr.write('verify: mutantes sobreviventes — a suíte não discrimina ainda.\n');
69
+ process.exit(1);
48
70
  }
49
71
 
50
72
  // Same rule as the archive gate: evidence carries severity, so evaluateGate blocks
@@ -61,15 +83,17 @@ export function runVerify(argv) {
61
83
  if (argv.includes('--deep')) {
62
84
  const tasks = parseTasks(tarefas);
63
85
  const reqIds = [...new Set(tasks.map((t) => t.req).filter(Boolean))];
86
+ const tasksHash = tasksHashOf(tarefas);
64
87
  const pkg = {
65
88
  slug,
89
+ tasksHash,
66
90
  requirements: reqIds.map((id) => ({ id })),
67
91
  tasks: tasks.map((t) => ({ id: t.id, text: t.text, req: t.req || null, done: t.done })),
68
92
  sensors: evidence,
69
93
  };
70
94
  writeFileSync(join(changeDir, 'verificacao.json'), `${JSON.stringify(pkg, null, 2)}\n`, 'utf8');
71
95
  if (reqIds.length === 0) {
72
- writeFileSync(join(changeDir, 'verdict.json'), `${JSON.stringify({ slug, ok: true, coverage: [], notes: ['trivial: sem requisito'] }, null, 2)}\n`, 'utf8');
96
+ writeFileSync(join(changeDir, 'verdict.json'), `${JSON.stringify({ slug, ok: true, coverage: [], tasksHash, notes: ['trivial: sem requisito'] }, null, 2)}\n`, 'utf8');
73
97
  process.stdout.write('verify --deep: pacote + verdict trivial escritos\n');
74
98
  } else {
75
99
  process.stdout.write('verify --deep: pacote escrito — rode a skill wk-verify pra gravar verdict.json\n');