wendkeep 0.2.7 → 0.4.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # wendkeep
2
2
 
3
- **Automatically capture your AI coding agent sessions as local Markdown in your Obsidian vault.** Every Claude Code / Codex session is written turn‑by‑turn into your vault — with token/cost tracking, auto‑extracted decisions, bugs and learnings, and a curated memory layer injected back at the start of the next session. 100% local, open‑core.
3
+ **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
4
 
5
5
  > 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.
6
6
 
@@ -41,7 +41,8 @@ npx wendkeep init
41
41
  4. Offer to pin **companion** plugins/MCP (multi-choice; `context-mode` + `dotcontext` pre-checked). Each is wired through the most agent-agnostic path it supports — `context-mode` and `dotcontext` as `.mcp.json` MCP servers (any agent), `understand-anything` via a `understand-inject` SessionStart hook that injects its domain graph when generated, and the Claude Code plugin layer (`extraKnownMarketplaces` + `enabledPlugins`) as a bonus. **`dotcontext`** (execution/PREVC harness) also gets its lifecycle hooks wired (SessionStart-last + Stop + PostToolUse, pinned CLI) and a starter `.context/config/sensors.json` seeded with a `wendkeep validate-memory` sensor plus one per detected `package.json` script (test/typecheck/lint/build) — add the `.context/runtime|cache|logs|plans|agents|skills/` paths to `.gitignore` (wendkeep doesn't touch git). If dotcontext is already configured globally (`~/.claude.json`), the project MCP entry is **skipped automatically** to avoid a duplicate; tune with `--dotcontext-mcp <auto|project|none>` and `--dotcontext-hooks <full|light|none>` (light drops PostToolUse). Control selection with `--companions <csv>` or `--no-companions`. (`caveman` additionally runs its own cross-agent installer on non-Claude agents.)
42
42
  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`.
43
43
  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).
44
- 7. Seed the **definitions layer**: `.brain/agents/` + `.brain/skills/` (versioned source of truth for custom agents/skills, with a README + one example each). Run `wendkeep sync-defs` to copy them into the project's agent dirs (`.brain/agents/*.toml` `.codex/agents/`, `.brain/skills/<name>/` `.claude/skills/`).
44
+ 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`).
45
+ 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.
45
46
 
46
47
  ```bash
47
48
  npx wendkeep init --vault "~/vaults/work" --project . --yes # non-interactive (companions: context-mode,dotcontext)
@@ -65,11 +66,30 @@ No re‑copying, no snapshot to re‑sync — the package is the single source o
65
66
 
66
67
  | Command | What it does |
67
68
  |---|---|
68
- | `wendkeep init` | Set up wendkeep in a project (vault taxonomy + settings + MCP). |
69
+ | `wendkeep init` | Set up wendkeep in a project (vault taxonomy + settings + MCP + skills). |
69
70
  | `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 sync-defs` | Copy `.brain/agents\|skills` into the project (`.codex/agents`, `.claude/skills`). |
74
+ | `wendkeep validate-memory [path]` | Validate `.brain/CORE.md` (cap 25, 3 sections, no secrets/PII). |
70
75
  | `wendkeep doctor [--vault P]` | Run a vault health check (integrity of sessions, registry, links). |
71
76
  | `wendkeep --version` / `--help` | Version / usage. |
72
77
 
78
+ ## Change lifecycle — the a2 loop (spec‑driven, native)
79
+
80
+ Beyond capturing sessions, wendkeep is a **harness**: a native, zero‑dependency loop that keeps *intent* (specs), *work* (changes) and *proof* (sensors) together in the vault, wikilinked into the Obsidian graph.
81
+
82
+ ```
83
+ explore → propose → apply (TDD) → verify → archive
84
+ ```
85
+
86
+ - **Propose** — `wendkeep change new <slug>` scaffolds `08-Mudanças/<slug>/` (`proposta.md`, `design.md`, `tarefas.md`, and a `specs/` delta). The change becomes *active* and is injected at the next `SessionStart`, so the agent resumes work‑in‑progress.
87
+ - **Apply** — implement each `tarefas.md` task. Tag a task that needs machine proof with `[sensor:<id>]`.
88
+ - **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
+ - **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
+
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.
92
+
73
93
  ## How it works
74
94
 
75
95
  ```
package/bin/wendkeep.mjs CHANGED
@@ -43,6 +43,8 @@ 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>.
47
+ wendkeep verify [--change s] Run a change's task sensors + record evidence (the gate).
46
48
  wendkeep validate-memory [path] Validate .brain/CORE.md against the compaction
47
49
  protocol (cap 25, 3 sections, no secrets/PII). Uses
48
50
  --vault <path> or OBSIDIAN_VAULT_PATH if no path given.
@@ -99,6 +101,16 @@ async function main() {
99
101
  runSyncDefs(rest);
100
102
  break;
101
103
  }
104
+ case 'change': {
105
+ const { runChange } = await import('../src/change.mjs');
106
+ runChange(rest);
107
+ break;
108
+ }
109
+ case 'verify': {
110
+ const { runVerify } = await import('../src/verify.mjs');
111
+ runVerify(rest);
112
+ break;
113
+ }
102
114
  case '--version':
103
115
  case '-v':
104
116
  process.stdout.write(`${version()}\n`);
@@ -7,6 +7,7 @@ import { join } from 'node:path';
7
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
+ import { buildActiveChangeInjection } from './change-core.mjs';
10
11
 
11
12
  const MAX_LINES = 45; // CORE ≤25 + DIGEST ≤15 + folga; salvaguarda se o CORE crescer à mão
12
13
 
@@ -23,7 +24,9 @@ export function buildInjection(vaultBase) {
23
24
  lines = lines.slice(0, MAX_LINES);
24
25
  lines.push('*…truncado pelo budget — fonte completa: .brain/CORE.md + .brain/DIGEST.md*');
25
26
  }
26
- return ['<brain_memory>', ...lines, pointer, '</brain_memory>'].join('\n');
27
+ const brain = ['<brain_memory>', ...lines, pointer, '</brain_memory>'].join('\n');
28
+ const change = buildActiveChangeInjection(vaultBase);
29
+ return change ? `${brain}\n${change}` : brain;
27
30
  }
28
31
 
29
32
  if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
@@ -0,0 +1,209 @@
1
+ // hooks/change-core.mjs
2
+ // Native change/spec lifecycle in the vault (Pilar B). Vault-facing lib consumed by
3
+ // the `wendkeep change` CLI (src/change.mjs) and the brain-inject hook. No external deps.
4
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import { ensureDir, wikilinkFromRel } from './obsidian-common.mjs';
7
+ import { parseSpecsList, promoteSpecs } from './spec-core.mjs';
8
+
9
+ export const CHANGES_DIR = '08-Mudanças';
10
+ export const SPECS_DIR = '07-Specs';
11
+ export const ARCHIVE_DIR = '_arquivo';
12
+ const POINTER = '.brain/CURRENT_CHANGE.md';
13
+
14
+ export function changeDirRel(slug) {
15
+ return join(CHANGES_DIR, slug);
16
+ }
17
+
18
+ export function renderChangeScaffold({ slug, sessionRel, dateStr }) {
19
+ const source = sessionRel ? `\n - "${wikilinkFromRel(sessionRel)}"` : ' []';
20
+ const proposta = `---
21
+ type: change
22
+ status: active
23
+ date: ${dateStr}
24
+ cssclasses:
25
+ - topic-change
26
+ tags:
27
+ - mudanca
28
+ source:${source}
29
+ specs: []
30
+ ---
31
+
32
+ # ${slug}
33
+
34
+ ## Por quê
35
+
36
+ (motivo da mudança)
37
+
38
+ ## O que muda
39
+
40
+ (escopo da mudança)
41
+ `;
42
+ const design = `# ${slug} — design
43
+
44
+ ## Abordagem
45
+
46
+ (abordagem técnica)
47
+ `;
48
+ const tarefas = `# ${slug} — tarefas
49
+
50
+ - [ ] 1.1 (primeira tarefa)
51
+ `;
52
+ const specDelta = `## ADDED Requirements
53
+ ### Requisito: (nome)
54
+ (comportamento / cenários)
55
+
56
+ ## MODIFIED Requirements
57
+
58
+ ## REMOVED Requirements
59
+ `;
60
+ return { proposta, design, tarefas, specDelta };
61
+ }
62
+
63
+ export function activeChange(vaultBase) {
64
+ try {
65
+ const m = readFileSync(join(vaultBase, POINTER), 'utf8').match(/^change:\s*(.+)$/m);
66
+ return m ? m[1].trim() : '';
67
+ } catch {
68
+ return '';
69
+ }
70
+ }
71
+
72
+ export function setActiveChange(vaultBase, slug) {
73
+ mkdirSync(join(vaultBase, '.brain'), { recursive: true });
74
+ writeFileSync(join(vaultBase, POINTER), `change: ${slug}\n`, 'utf8');
75
+ }
76
+
77
+ export function clearActiveChange(vaultBase) {
78
+ const p = join(vaultBase, POINTER);
79
+ if (existsSync(p)) writeFileSync(p, 'change:\n', 'utf8');
80
+ }
81
+
82
+ export function newChange(vaultBase, slug, { sessionRel = '', dateStr }) {
83
+ const dir = join(vaultBase, CHANGES_DIR, slug);
84
+ const existed = existsSync(join(dir, 'proposta.md'));
85
+ mkdirSync(dir, { recursive: true });
86
+ const files = renderChangeScaffold({ slug, sessionRel, dateStr });
87
+ const write = (name, content) => {
88
+ const f = join(dir, name);
89
+ if (!existsSync(f)) writeFileSync(f, content, 'utf8');
90
+ };
91
+ write('proposta.md', files.proposta);
92
+ write('design.md', files.design);
93
+ 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');
99
+ }
100
+ setActiveChange(vaultBase, slug);
101
+ return { rel: changeDirRel(slug), created: !existed };
102
+ }
103
+
104
+ export function parseTasks(md) {
105
+ const tasks = [];
106
+ const re = /^-\s+\[( |x)\]\s+(\S+)\s+(.*)$/gm;
107
+ const sensorRe = /\[sensor:\s*([\w.-]+)\]/;
108
+ let m;
109
+ while ((m = re.exec(String(md))) !== null) {
110
+ let text = m[3].trim();
111
+ const sm = text.match(sensorRe);
112
+ 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 } : {}) });
115
+ }
116
+ return tasks;
117
+ }
118
+
119
+ export function listChanges(vaultBase) {
120
+ const base = join(vaultBase, CHANGES_DIR);
121
+ const active = [];
122
+ let archived = [];
123
+ try {
124
+ for (const name of readdirSync(base)) {
125
+ if (name === ARCHIVE_DIR) continue;
126
+ if (existsSync(join(base, name, 'proposta.md'))) active.push(name);
127
+ }
128
+ } catch { /* no changes dir yet */ }
129
+ try {
130
+ archived = readdirSync(join(base, ARCHIVE_DIR)).filter((n) => !n.startsWith('.'));
131
+ } catch { /* none */ }
132
+ return { active, archived };
133
+ }
134
+
135
+ export function buildActiveChangeInjection(vaultBase, { maxTasks = 8 } = {}) {
136
+ const slug = activeChange(vaultBase);
137
+ if (!slug) return '';
138
+ let md = '';
139
+ try { md = readFileSync(join(vaultBase, CHANGES_DIR, slug, 'tarefas.md'), 'utf8'); } catch { return ''; }
140
+ const open = parseTasks(md).filter((t) => !t.done).slice(0, maxTasks);
141
+ const lines = open.map((t) => `- [ ] ${t.id} ${t.text}`);
142
+ const more = open.length === maxTasks ? '\n*…mais tarefas em tarefas.md*' : '';
143
+ return `<active_change>
144
+ Mudança ativa: ${slug} — [[${CHANGES_DIR}/${slug}/proposta]]. Tarefas abertas:
145
+ ${lines.join('\n')}${more}
146
+ </active_change>`;
147
+ }
148
+
149
+ export function activeChangeLink(vaultBase) {
150
+ const slug = activeChange(vaultBase);
151
+ return slug ? `Change ativa: [[${CHANGES_DIR}/${slug}/proposta]]` : '';
152
+ }
153
+
154
+ // Gate seam (Pilar B stub; Pilar C replaces with real sensor evidence).
155
+ export function gateGreen() {
156
+ return { ok: true, failing: [] };
157
+ }
158
+
159
+ export function archiveChange(vaultBase, slug, { gate = gateGreen, dateStr, adrNum }) {
160
+ const src = join(vaultBase, CHANGES_DIR, slug);
161
+ const verdict = gate(src);
162
+ if (!verdict.ok) return { ok: false, failing: verdict.failing || [] };
163
+
164
+ const destRel = join(CHANGES_DIR, ARCHIVE_DIR, `${dateStr}-${slug}`);
165
+ const changeWikilink = wikilinkFromRel(join(destRel, 'proposta'));
166
+
167
+ // Promote spec deltas into the living 07-Specs BEFORE moving (deltas live in src).
168
+ let promoted = [];
169
+ let specWarnings = [];
170
+ try {
171
+ const specs = parseSpecsList(readFileSync(join(src, 'proposta.md'), 'utf8'));
172
+ if (specs.length) {
173
+ const res = promoteSpecs(vaultBase, src, specs, { changeWikilink, dateStr });
174
+ promoted = res.promoted;
175
+ specWarnings = res.warnings;
176
+ }
177
+ } catch { /* proposta ilegível — segue só com ADR */ }
178
+
179
+ ensureDir(join(vaultBase, CHANGES_DIR, ARCHIVE_DIR));
180
+ renameSync(src, join(vaultBase, destRel));
181
+
182
+ const [year] = String(dateStr).split('-');
183
+ const adrDirRel = join('04-Decisões', year);
184
+ ensureDir(join(vaultBase, adrDirRel));
185
+ const num = String(adrNum).padStart(3, '0');
186
+ const adrRel = join(adrDirRel, `ADR-${num}-${slug}.md`);
187
+ const capLine = promoted.length
188
+ ? `\n\nCapabilities: ${promoted.map((c) => wikilinkFromRel(join(SPECS_DIR, c))).join(', ')}.`
189
+ : '';
190
+ writeFileSync(join(vaultBase, adrRel), `---
191
+ type: decision
192
+ status: accepted
193
+ date: ${dateStr}
194
+ cssclasses:
195
+ - topic-decision
196
+ tags:
197
+ - decisao
198
+ ---
199
+
200
+ # ADR-${num} — ${slug}
201
+
202
+ ## Decisão
203
+
204
+ Mudança ${changeWikilink} concluída e arquivada.${capLine}
205
+ `, 'utf8');
206
+
207
+ clearActiveChange(vaultBase);
208
+ return { ok: true, failing: [], archivedRel: destRel, adrRel, promoted, specWarnings };
209
+ }
@@ -0,0 +1,46 @@
1
+ // hooks/sensors-core.mjs — native sensor runner + evidence gate (Pilar C).
2
+ // Pure-ish: `spawn` is injectable so runs are testable without a shell. Config lives
3
+ // at the PROJECT ROOT (wendkeep.sensors.json); evidence lives per-change in the vault.
4
+ import { spawnSync } from 'node:child_process';
5
+ import { readFileSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+
8
+ export function loadSensors(projectRoot, file = 'wendkeep.sensors.json') {
9
+ try {
10
+ const data = JSON.parse(readFileSync(join(projectRoot, file), 'utf8'));
11
+ return Array.isArray(data.sensors) ? data.sensors : [];
12
+ } catch {
13
+ return [];
14
+ }
15
+ }
16
+
17
+ export function requiredSensors(tasks) {
18
+ return [...new Set((tasks || []).map((t) => t.sensor).filter(Boolean))];
19
+ }
20
+
21
+ export function runSensors(sensors, ids, { spawn = spawnSync, cwd, now } = {}) {
22
+ const byId = Object.fromEntries((sensors || []).map((s) => [s.id, s]));
23
+ const ts = now || new Date().toISOString();
24
+ const evidence = [];
25
+ for (const id of ids) {
26
+ const s = byId[id];
27
+ if (!s) { evidence.push({ id, status: 'red', ts, severity: 'critical', note: 'sensor não definido' }); continue; }
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' });
30
+ }
31
+ return evidence;
32
+ }
33
+
34
+ // A required sensor blocks the gate when it is missing (never verified) or red at a
35
+ // non-warning severity. Warnings are advisory: a red warning does not block archive.
36
+ // Severity comes from the evidence entry (written by runSensors); absent -> critical.
37
+ export function evaluateGate(evidence, requiredIds) {
38
+ const byId = Object.fromEntries((evidence || []).map((e) => [e.id, e]));
39
+ const failing = (requiredIds || []).filter((id) => {
40
+ const e = byId[id];
41
+ if (!e) return true; // never verified
42
+ if (e.status === 'green') return false;
43
+ return (e.severity || 'critical') !== 'warning';
44
+ });
45
+ return { ok: failing.length === 0, failing };
46
+ }
@@ -6,6 +6,7 @@ import { pathToFileURL } from 'url';
6
6
  import { createLinkedNotes } from './linked-notes.mjs';
7
7
  import { addUsage, costBreakdown, emptyTokenUsage, normalizeClaudeUsage, normalizeCodexUsage, priceForModel, updateSessionUsage } from './token-usage.mjs';
8
8
  import { buildBrainDigest, buildBrainIndex } from './brain-core.mjs';
9
+ import { activeChangeLink } from './change-core.mjs';
9
10
  import {
10
11
  ensureDir,
11
12
  findActiveSessionByTranscript,
@@ -1072,6 +1073,14 @@ function main() {
1072
1073
  findLinkedDerivedNotes(vaultBase, sessionRel),
1073
1074
  );
1074
1075
  finalizeSessionFile(sessionPath, tx, created, endedAt);
1076
+ // Link bidirecional sessão↔change ativa no grafo Obsidian. Fail-quiet: nunca derruba o Stop.
1077
+ try {
1078
+ const chgLink = activeChangeLink(vaultBase);
1079
+ if (chgLink) {
1080
+ const cur = readFileSync(sessionPath, 'utf8');
1081
+ if (!cur.includes(chgLink)) writeFileSync(sessionPath, `${cur.trimEnd()}\n\n${chgLink}\n`, 'utf8');
1082
+ }
1083
+ } catch { /* nunca derruba o Stop */ }
1075
1084
  writeControl(vaultBase, {
1076
1085
  status: 'inactive',
1077
1086
  session_file: '',
@@ -0,0 +1,97 @@
1
+ // hooks/spec-core.mjs — living spec (07-Specs) + change delta merge (OpenSpec native).
2
+ // Pure parsing/merge + promoteSpecs (fs). No import from change-core (avoids a cycle).
3
+ import { readFileSync, writeFileSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { ensureDir } from './obsidian-common.mjs';
6
+
7
+ const SPECS_DIR = '07-Specs';
8
+ const REQ_RE = /^### Requisito:\s*(.+)$/gm;
9
+
10
+ export function parseRequirements(md) {
11
+ const text = String(md);
12
+ const matches = [...text.matchAll(REQ_RE)];
13
+ const reqs = [];
14
+ for (let i = 0; i < matches.length; i += 1) {
15
+ const name = matches[i][1].trim();
16
+ const start = matches[i].index + matches[i][0].length;
17
+ const end = i + 1 < matches.length ? matches[i + 1].index : text.length;
18
+ const body = text.slice(start, end).replace(/(\n>[^\n]*)+\s*$/, '').trim();
19
+ reqs.push({ name, body });
20
+ }
21
+ return reqs;
22
+ }
23
+
24
+ export function parseDelta(md) {
25
+ const text = String(md);
26
+ const grab = (label) => {
27
+ const m = text.match(new RegExp(`^##\\s+${label} Requirements\\s*$`, 'm'));
28
+ if (!m) return '';
29
+ const rest = text.slice(m.index + m[0].length);
30
+ const next = rest.search(/^##\s+\w+ Requirements\s*$/m);
31
+ return next === -1 ? rest : rest.slice(0, next);
32
+ };
33
+ return {
34
+ added: parseRequirements(grab('ADDED')),
35
+ modified: parseRequirements(grab('MODIFIED')),
36
+ removed: parseRequirements(grab('REMOVED')).map((r) => r.name),
37
+ };
38
+ }
39
+
40
+ 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]));
43
+ const warnings = [];
44
+ 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);
47
+ }
48
+ 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);
51
+ }
52
+ for (const name of delta.removed || []) {
53
+ if (!map.has(name)) warnings.push(`REMOVED inexistente: ${name}`);
54
+ map.delete(name);
55
+ }
56
+ 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) }));
60
+ return { reqs: out, warnings };
61
+ }
62
+
63
+ export function renderSpec(capability, reqs, { footer } = {}) {
64
+ const blocks = reqs.map((r) => `### Requisito: ${r.name}\n${r.body}`).join('\n\n');
65
+ const foot = footer ? `\n\n> ${footer}\n` : '\n';
66
+ return `---\ntype: spec\ncssclasses:\n - topic-spec\ntags:\n - spec\n---\n\n# ${capability}\n\n## Requisitos\n\n${blocks}${foot}`;
67
+ }
68
+
69
+ export function parseSpecsList(propostaMd) {
70
+ const text = String(propostaMd);
71
+ const inline = text.match(/^specs:\s*\[(.*?)\]\s*$/m);
72
+ if (inline) return inline[1].split(',').map((s) => s.trim().replace(/['"]/g, '')).filter(Boolean);
73
+ const block = text.match(/^specs:\s*\n((?:[ \t]+-[ \t]*.+\n?)+)/m);
74
+ if (block) return block[1].split('\n').map((l) => l.replace(/^[ \t]*-[ \t]*/, '').trim().replace(/['"]/g, '')).filter(Boolean);
75
+ return [];
76
+ }
77
+
78
+ // Merge each capability's delta (in the change) into the living spec in 07-Specs.
79
+ export function promoteSpecs(vaultBase, changeDir, specs, { changeWikilink, dateStr } = {}) {
80
+ const promoted = [];
81
+ const warnings = [];
82
+ for (const cap of specs) {
83
+ let delta;
84
+ try { delta = parseDelta(readFileSync(join(changeDir, 'specs', cap, 'spec.md'), 'utf8')); }
85
+ catch { warnings.push(`sem delta para ${cap}`); continue; }
86
+ const livePath = join(vaultBase, SPECS_DIR, `${cap}.md`);
87
+ let current = [];
88
+ try { current = parseRequirements(readFileSync(livePath, 'utf8')); } catch { /* nova capability */ }
89
+ const applied = applyDelta(current, delta);
90
+ warnings.push(...applied.warnings.map((w) => `${cap}: ${w}`));
91
+ ensureDir(join(vaultBase, SPECS_DIR));
92
+ const footer = changeWikilink ? `Atualizado por ${changeWikilink} em ${dateStr}.` : '';
93
+ writeFileSync(livePath, renderSpec(cap, applied.reqs, { footer }), 'utf8');
94
+ promoted.push(cap);
95
+ }
96
+ return { promoted, warnings };
97
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wendkeep",
3
- "version": "0.2.7",
3
+ "version": "0.4.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": {
package/src/change.mjs ADDED
@@ -0,0 +1,92 @@
1
+ // `wendkeep change <sub>` — native change lifecycle CLI (Pilar B).
2
+ import { readFileSync } from 'node:fs';
3
+ import { isAbsolute, join, resolve } from 'node:path';
4
+ import {
5
+ newChange,
6
+ activeChange,
7
+ listChanges,
8
+ parseTasks,
9
+ archiveChange,
10
+ } from '../hooks/change-core.mjs';
11
+ import { evaluateGate, requiredSensors } from '../hooks/sensors-core.mjs';
12
+ import { getNextAdrNumber } from '../hooks/obsidian-common.mjs';
13
+
14
+ function resolveVault(argv) {
15
+ let vault;
16
+ for (let i = 0; i < argv.length; i += 1) {
17
+ const a = argv[i];
18
+ if (a === '--vault') vault = argv[++i];
19
+ else if (a.startsWith('--vault=')) vault = a.slice(8);
20
+ }
21
+ const base = vault || process.env.OBSIDIAN_VAULT_PATH;
22
+ if (!base) {
23
+ process.stderr.write('wendkeep change: no vault. Pass --vault <path> or set OBSIDIAN_VAULT_PATH.\n');
24
+ process.exit(2);
25
+ }
26
+ return isAbsolute(base) ? base : resolve(process.cwd(), base);
27
+ }
28
+
29
+ function today() {
30
+ const d = new Date();
31
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
32
+ }
33
+
34
+ export function runChange(argv) {
35
+ const [sub, ...rest] = argv;
36
+ const vaultBase = resolveVault(rest);
37
+ const slugArg = () => rest.find((a) => !a.startsWith('-'));
38
+
39
+ if (sub === 'new') {
40
+ const slug = slugArg();
41
+ if (!slug) { process.stderr.write('wendkeep change new: missing <slug>\n'); process.exit(2); }
42
+ const r = newChange(vaultBase, slug, { dateStr: today() });
43
+ process.stdout.write(`change ${r.created ? 'created' : 'exists'}: ${r.rel} (active)\n`);
44
+ process.exit(0);
45
+ }
46
+
47
+ if (sub === 'list') {
48
+ const { active, archived } = listChanges(vaultBase);
49
+ const cur = activeChange(vaultBase);
50
+ process.stdout.write(`active: ${active.map((s) => (s === cur ? `*${s}` : s)).join(', ') || '(none)'}\n`);
51
+ process.stdout.write(`archived: ${archived.join(', ') || '(none)'}\n`);
52
+ process.exit(0);
53
+ }
54
+
55
+ if (sub === 'show') {
56
+ const slug = slugArg();
57
+ if (!slug) { process.stderr.write('wendkeep change show: missing <slug>\n'); process.exit(2); }
58
+ let md;
59
+ try { md = readFileSync(join(vaultBase, '08-Mudanças', slug, 'tarefas.md'), 'utf8'); }
60
+ catch { process.stderr.write(`wendkeep change show: not found: ${slug}\n`); process.exit(2); }
61
+ const tasks = parseTasks(md);
62
+ const open = tasks.filter((t) => !t.done).length;
63
+ process.stdout.write(`${slug}: ${tasks.length} task(s), ${open} open\n`);
64
+ for (const t of tasks) process.stdout.write(` [${t.done ? 'x' : ' '}] ${t.id} ${t.text}\n`);
65
+ process.exit(0);
66
+ }
67
+
68
+ if (sub === 'archive') {
69
+ const slug = slugArg() || activeChange(vaultBase);
70
+ if (!slug) { process.stderr.write('wendkeep change archive: missing <slug> and no active change\n'); process.exit(2); }
71
+ // Real gate (Pilar C): every sensor a task declared must be green in evidencia.json.
72
+ const gate = (dir) => {
73
+ let required = [];
74
+ try { required = requiredSensors(parseTasks(readFileSync(join(dir, 'tarefas.md'), 'utf8'))); } catch { /* no tasks */ }
75
+ let evidence = [];
76
+ try { evidence = JSON.parse(readFileSync(join(dir, 'evidencia.json'), 'utf8')); } catch { /* no evidence */ }
77
+ return evaluateGate(evidence, required);
78
+ };
79
+ const r = archiveChange(vaultBase, slug, { dateStr: today(), adrNum: getNextAdrNumber(vaultBase), gate });
80
+ if (!r.ok) {
81
+ process.stderr.write(`change archive BLOCKED (gate): failing sensors: ${r.failing.join(', ')} — run \`wendkeep verify\`.\n`);
82
+ process.exit(1);
83
+ }
84
+ process.stdout.write(`archived: ${r.archivedRel}; ADR: ${r.adrRel}\n`);
85
+ if (r.promoted && r.promoted.length) process.stdout.write(`specs promovidas: ${r.promoted.join(', ')}\n`);
86
+ if (r.specWarnings && r.specWarnings.length) for (const w of r.specWarnings) process.stderr.write(` aviso spec: ${w}\n`);
87
+ process.exit(0);
88
+ }
89
+
90
+ process.stderr.write(`wendkeep change: unknown subcommand "${sub}". Known: new, list, show, archive.\n`);
91
+ process.exit(2);
92
+ }
package/src/init.mjs CHANGED
@@ -31,8 +31,9 @@ import {
31
31
  mergeGraphColorGroups,
32
32
  } from './vault-theme.mjs';
33
33
  import { renderCoreSkeleton, renderCompactionProtocol } from './validate-core.mjs';
34
- import { seedDefinitions } from './sync-defs.mjs';
35
- import { seedDotcontext, globalHasDotcontext, resolveDotcontextSkipMcp } from './dotcontext-seed.mjs';
34
+ import { seedDefinitions, syncDefs } from './sync-defs.mjs';
35
+ import { seedWkSkills } from './skills-seed.mjs';
36
+ import { seedDotcontext, globalHasDotcontext, resolveDotcontextSkipMcp, renderSensorsJson } from './dotcontext-seed.mjs';
36
37
 
37
38
  function parseArgs(argv) {
38
39
  const args = { mcp: true, yes: false, force: false };
@@ -280,7 +281,26 @@ export async function runInit(argv) {
280
281
  // Seed the definitions layer (.brain/agents + .brain/skills): versioned source of
281
282
  // truth for custom agents/skills. `wendkeep sync-defs` copies them to the agent dirs.
282
283
  seedDefinitions(brainDir);
283
- log(` [1/4] vault taxonomy: ${VAULT_FOLDERS.length} folders (${created} created)${readmeNote}, .brain seeded (CORE + agents/skills)`);
284
+ seedWkSkills(brainDir); // Pilar A: native process skills (wk-workflow/tdd/debugging/...).
285
+ // Seed the change/spec layer starters (Pilar B) — non-destructive.
286
+ const specsReadme = join(vaultPath, '07-Specs', 'README.md');
287
+ if (!existsSync(specsReadme)) writeFileSync(specsReadme, '# Specs — contrato vivo\n\nCapacidades do projeto (requisitos/cenários). Changes em `08-Mudanças/` promovem deltas aqui no `wendkeep change archive`.\n', 'utf8');
288
+ const changeTpl = join(vaultPath, 'Templates', 'Change.md');
289
+ if (!existsSync(changeTpl)) writeFileSync(changeTpl, '---\ntype: change\nstatus: active\ncssclasses:\n - topic-change\n---\n\n# <slug>\n\n## Por quê\n\n## O que muda\n', 'utf8');
290
+ // Seed the native sensor config (Pilar C) at project root — non-destructive.
291
+ const sensorsFile = join(projectPath, 'wendkeep.sensors.json');
292
+ if (!existsSync(sensorsFile)) {
293
+ let scripts = {};
294
+ try { scripts = JSON.parse(readFileSync(join(projectPath, 'package.json'), 'utf8')).scripts || {}; } catch { /* no package.json */ }
295
+ writeFileSync(sensorsFile, renderSensorsJson(scripts), 'utf8');
296
+ }
297
+ log(` [1/4] vault taxonomy: ${VAULT_FOLDERS.length} folders (${created} created)${readmeNote}, .brain + change/spec + sensors seeded`);
298
+ // Deliver the seeded defs (agents + wk process skills) to the project so they're
299
+ // usable immediately — no separate `wendkeep sync-defs` step needed.
300
+ const synced = syncDefs(vaultPath, projectPath);
301
+ if (synced.skills.length || synced.agents.length) {
302
+ log(` defs delivered: ${synced.skills.length} skill(s) -> .claude/skills, ${synced.agents.length} agent(s) -> .codex/agents`);
303
+ }
284
304
 
285
305
  // 2. .claude/settings.json --------------------------------------------------
286
306
  const settingsPath = join(projectPath, '.claude', 'settings.json');
@@ -0,0 +1,163 @@
1
+ // src/skills-seed.mjs — native, zero-dep process skills (Pilar A: the HOW layer).
2
+ // Seeded into the vault's .brain/skills; distributed by `wendkeep sync-defs` to
3
+ // .claude/skills. Wendkeep-flavored (reference change/verify), concise native prose.
4
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+
7
+ function skill(name, description, body) {
8
+ return { name, description, body: `---\nname: ${name}\ndescription: ${description}\n---\n${body}` };
9
+ }
10
+
11
+ const WORKFLOW = `# Loop a2 — o ciclo de trabalho do wendkeep
12
+
13
+ Use ao começar qualquer mudança não-trivial. O loop mantém memória (vault) e prova
14
+ (sensores) juntas, tudo linkado no grafo do Obsidian.
15
+
16
+ ## Os passos
17
+
18
+ 1. **Explore** — entenda o problema antes de propor. Leia o código/contexto relevante.
19
+ 2. **Propose** — \`wendkeep change new <slug>\`. Isso cria \`08-Mudanças/<slug>/\` com:
20
+ - \`proposta.md\` — *por quê* e *o que muda* (o WHAT).
21
+ - \`design.md\` — a abordagem técnica.
22
+ - \`tarefas.md\` — a lista de tarefas \`- [ ] N.N descrição\`.
23
+ A mudança vira a *ativa* (ponteiro \`.brain/CURRENT_CHANGE.md\`) e é injetada no
24
+ próximo SessionStart, então você retoma o trabalho em curso automaticamente.
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/\`.
34
+
35
+ ## Regras
36
+
37
+ - Uma mudança ativa por vez. Termine (archive) antes de abrir outra.
38
+ - Se uma tarefa não precisa de prova automatizada, não declare sensor — o gate só exige
39
+ o que você declarou. Sem \`[sensor:]\`, o archive não trava.
40
+ - A proposta linka a sessão de origem; a sessão linka a mudança ativa. É de propósito:
41
+ o grafo do Obsidian mostra plano↔sessão↔decisão.
42
+ `;
43
+
44
+ const TDD = `# TDD — Red, Green, Refactor
45
+
46
+ Use ao implementar qualquer comportamento. Nunca escreva código de produção sem um
47
+ teste vermelho pedindo por ele.
48
+
49
+ ## O ciclo
50
+
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
+
57
+ ## Regras
58
+
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).
63
+ `;
64
+
65
+ const DEBUGGING = `# Depuração sistemática
66
+
67
+ Use quando algo falha, quebra ou dá resultado errado. Uma hipótese por vez — não saia
68
+ mudando coisas no escuro.
69
+
70
+ ## O método
71
+
72
+ 1. **Reproduza** — um caso mínimo e determinístico que dispara a falha. Sem repro
73
+ confiável, você não sabe se corrigiu.
74
+ 2. **Uma hipótese** — a causa mais provável, específica o suficiente pra testar.
75
+ 3. **Isole** — confirme ou descarte a hipótese ANTES de corrigir: bisect, log no ponto
76
+ suspeito, comente metade. Prove onde está, não onde você acha que está.
77
+ 4. **Corrija a raiz** — o defeito real, não o sintoma. Se só some o sintoma, você não
78
+ entendeu a causa.
79
+ 5. **Verifique** — o repro do passo 1 sumiu E a suíte segue verde. Sem regressão.
80
+
81
+ ## Regras
82
+
83
+ - Mudou várias coisas de uma vez e "consertou"? Você não sabe o que consertou. Reverta,
84
+ isole uma variável por vez.
85
+ - Leia a mensagem de erro inteira e a stack — a linha decisiva costuma estar ali.
86
+ - "Não faz sentido" = uma suposição sua está errada. Cheque as suposições, não o improvável.
87
+ `;
88
+
89
+ const BRAINSTORMING = `# Brainstorming — ideia vira design
90
+
91
+ Use quando a ideia ainda é vaga. Transforme-a num design aprovado ANTES de escrever
92
+ qualquer código.
93
+
94
+ ## Como
95
+
96
+ 1. **Explore o contexto** — arquivos, docs, histórico relevante.
97
+ 2. **Pergunte uma coisa por vez** — propósito, restrições, critério de sucesso. Prefira
98
+ múltipla escolha. Não despeje dez perguntas juntas.
99
+ 3. **Proponha 2–3 abordagens** — com trade-offs e sua recomendação primeiro.
100
+ 4. **Apresente o design em seções** — escale ao tamanho do problema; confirme cada seção.
101
+
102
+ ## Gate rígido
103
+
104
+ NÃO escreva código, scaffold nem tome ação de implementação até apresentar um design e o
105
+ usuário aprovar. Vale pra TODO projeto, por mais simples que pareça — "simples demais pra
106
+ precisar de design" é onde suposições não-checadas mais custam. O design pode ser curto,
107
+ mas tem que existir e ser aprovado.
108
+
109
+ Ao aprovar, o próximo passo é **wk-planning** (design → plano). Não pule pra implementação.
110
+ `;
111
+
112
+ const PLANNING = `# Planejamento — design vira plano de tarefas
113
+
114
+ Use depois de um design aprovado. Produza um plano que um dev sem contexto do projeto
115
+ consiga executar.
116
+
117
+ ## Antes das tarefas
118
+
119
+ Mapeie os arquivos: quais criar/modificar e a responsabilidade de cada um. Arquivos que
120
+ mudam juntos ficam juntos; um arquivo, uma responsabilidade. É aqui que a decomposição
121
+ trava.
122
+
123
+ ## Tarefas bite-sized (TDD)
124
+
125
+ Cada tarefa termina num entregável testável de forma independente. Cada passo é uma ação
126
+ de 2–5 min:
127
+
128
+ - Escreva o teste que falha (mostre o código do teste).
129
+ - Rode e veja falhar (comando exato + saída esperada).
130
+ - Implementação mínima (mostre o código).
131
+ - Rode e veja passar.
132
+ - Checkpoint: suíte verde.
133
+
134
+ ## Regras
135
+
136
+ - Caminhos de arquivo exatos, sempre. Código real em cada passo — nada de "TODO",
137
+ "tratar erros apropriadamente", "similar à Task N".
138
+ - DRY, YAGNI. Corte features que o design não pediu.
139
+ - Nomes/assinaturas consistentes entre tarefas (uma função é \`x()\` em toda parte).
140
+ `;
141
+
142
+ export const WK_SKILLS = [
143
+ 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),
145
+ 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),
147
+ skill('wk-planning', 'Use após um design aprovado — decompõe em plano de tarefas TDD bite-sized.', PLANNING),
148
+ ];
149
+
150
+ // Seed each skill into <brainDir>/skills/<name>/SKILL.md if absent (non-destructive).
151
+ export function seedWkSkills(brainDir) {
152
+ const created = [];
153
+ for (const s of WK_SKILLS) {
154
+ const dir = join(brainDir, 'skills', s.name);
155
+ mkdirSync(dir, { recursive: true });
156
+ const f = join(dir, 'SKILL.md');
157
+ if (!existsSync(f)) {
158
+ writeFileSync(f, s.body, 'utf8');
159
+ created.push(f);
160
+ }
161
+ }
162
+ return created;
163
+ }
package/src/taxonomy.mjs CHANGED
@@ -15,6 +15,8 @@ export const VAULT_FOLDERS = [
15
15
  '04-Decisões',
16
16
  '05-Bugs',
17
17
  '06-Aprendizados',
18
+ '07-Specs',
19
+ '08-Mudanças',
18
20
  'Templates',
19
21
  '.brain',
20
22
  ];
@@ -30,6 +32,9 @@ export const HOOK_FILES = [
30
32
  'token-usage.mjs',
31
33
  'pricing.json',
32
34
  'brain-core.mjs',
35
+ 'change-core.mjs',
36
+ 'spec-core.mjs',
37
+ 'sensors-core.mjs',
33
38
  'brain-inject.mjs',
34
39
  'brain-recall.mjs',
35
40
  'brain-reindex.mjs',
@@ -29,6 +29,8 @@ const FOLDER_PALETTE = [
29
29
  ['04-Decisões', 'violet'],
30
30
  ['05-Bugs', 'red'],
31
31
  ['06-Aprendizados', 'green'],
32
+ ['07-Specs', 'teal'],
33
+ ['08-Mudanças', 'amber'],
32
34
  ['Templates', 'slate'],
33
35
  ];
34
36
 
@@ -38,6 +40,8 @@ export const NOTE_COLORS = {
38
40
  decision: { cssClass: 'topic-decision', folder: '04-Decisões', palette: 'violet' },
39
41
  bug: { cssClass: 'topic-bug', folder: '05-Bugs', palette: 'red' },
40
42
  learning: { cssClass: 'topic-learning', folder: '06-Aprendizados', palette: 'green' },
43
+ spec: { cssClass: 'topic-spec', folder: '07-Specs', palette: 'teal' },
44
+ change: { cssClass: 'topic-change', folder: '08-Mudanças', palette: 'amber' },
41
45
  };
42
46
 
43
47
  // cssclasses that get the note-accent treatment (the four note types + a home note).
package/src/verify.mjs ADDED
@@ -0,0 +1,44 @@
1
+ // `wendkeep verify [--change <slug>]` — run a change's task sensors, record evidence.
2
+ // Sensors run at the PROJECT root (--project or cwd); the change + evidence live in
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';
5
+ import { isAbsolute, join, resolve } from 'node:path';
6
+ import { parseTasks, activeChange } from '../hooks/change-core.mjs';
7
+ import { loadSensors, requiredSensors, runSensors, evaluateGate } from '../hooks/sensors-core.mjs';
8
+
9
+ function opt(argv, name) {
10
+ const i = argv.indexOf(name);
11
+ if (i >= 0) return argv[i + 1];
12
+ const eq = argv.find((a) => a.startsWith(`${name}=`));
13
+ return eq ? eq.slice(name.length + 1) : undefined;
14
+ }
15
+
16
+ export function runVerify(argv) {
17
+ const vaultRaw = opt(argv, '--vault') || process.env.OBSIDIAN_VAULT_PATH;
18
+ if (!vaultRaw) { process.stderr.write('wendkeep verify: no vault (--vault or OBSIDIAN_VAULT_PATH).\n'); process.exit(2); }
19
+ const vaultBase = isAbsolute(vaultRaw) ? vaultRaw : resolve(process.cwd(), vaultRaw);
20
+ const projectRoot = resolve(opt(argv, '--project') || process.cwd());
21
+ const slug = opt(argv, '--change') || activeChange(vaultBase);
22
+ if (!slug) { process.stderr.write('wendkeep verify: no change (--change or active).\n'); process.exit(2); }
23
+
24
+ const changeDir = join(vaultBase, '08-Mudanças', slug);
25
+ let tarefas = '';
26
+ try { tarefas = readFileSync(join(changeDir, 'tarefas.md'), 'utf8'); }
27
+ catch { process.stderr.write(`wendkeep verify: change not found: ${slug}\n`); process.exit(2); }
28
+
29
+ const ids = requiredSensors(parseTasks(tarefas));
30
+ const sensors = loadSensors(projectRoot);
31
+ const evidence = runSensors(sensors, ids, { cwd: projectRoot });
32
+ writeFileSync(join(changeDir, 'evidencia.json'), `${JSON.stringify(evidence, null, 2)}\n`, 'utf8');
33
+
34
+ // Same rule as the archive gate: evidence carries severity, so evaluateGate blocks
35
+ // only on critical/missing — a red warning is advisory and passes verify.
36
+ const { ok, failing } = evaluateGate(evidence, ids);
37
+ for (const e of evidence) {
38
+ const mark = e.status === 'green' ? '✓' : (e.severity === 'warning' ? '!' : '✗');
39
+ process.stdout.write(` ${mark} ${e.id}${e.severity === 'warning' && e.status === 'red' ? ' (warning)' : ''}\n`);
40
+ }
41
+ if (!ok) { process.stderr.write(`verify: critical sensors red: ${failing.join(', ')}\n`); process.exit(1); }
42
+ process.stdout.write(`verify OK (${ids.length} sensor(s))\n`);
43
+ process.exit(0);
44
+ }