wendkeep 0.3.0 → 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
  ```
@@ -4,6 +4,7 @@
4
4
  import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from 'node:fs';
5
5
  import { join } from 'node:path';
6
6
  import { ensureDir, wikilinkFromRel } from './obsidian-common.mjs';
7
+ import { parseSpecsList, promoteSpecs } from './spec-core.mjs';
7
8
 
8
9
  export const CHANGES_DIR = '08-Mudanças';
9
10
  export const SPECS_DIR = '07-Specs';
@@ -48,7 +49,15 @@ specs: []
48
49
 
49
50
  - [ ] 1.1 (primeira tarefa)
50
51
  `;
51
- return { proposta, design, tarefas };
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 };
52
61
  }
53
62
 
54
63
  export function activeChange(vaultBase) {
@@ -82,6 +91,12 @@ export function newChange(vaultBase, slug, { sessionRel = '', dateStr }) {
82
91
  write('proposta.md', files.proposta);
83
92
  write('design.md', files.design);
84
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
+ }
85
100
  setActiveChange(vaultBase, slug);
86
101
  return { rel: changeDirRel(slug), created: !existed };
87
102
  }
@@ -147,6 +162,20 @@ export function archiveChange(vaultBase, slug, { gate = gateGreen, dateStr, adrN
147
162
  if (!verdict.ok) return { ok: false, failing: verdict.failing || [] };
148
163
 
149
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
+
150
179
  ensureDir(join(vaultBase, CHANGES_DIR, ARCHIVE_DIR));
151
180
  renameSync(src, join(vaultBase, destRel));
152
181
 
@@ -155,7 +184,9 @@ export function archiveChange(vaultBase, slug, { gate = gateGreen, dateStr, adrN
155
184
  ensureDir(join(vaultBase, adrDirRel));
156
185
  const num = String(adrNum).padStart(3, '0');
157
186
  const adrRel = join(adrDirRel, `ADR-${num}-${slug}.md`);
158
- const changeWikilink = wikilinkFromRel(join(destRel, 'proposta'));
187
+ const capLine = promoted.length
188
+ ? `\n\nCapabilities: ${promoted.map((c) => wikilinkFromRel(join(SPECS_DIR, c))).join(', ')}.`
189
+ : '';
159
190
  writeFileSync(join(vaultBase, adrRel), `---
160
191
  type: decision
161
192
  status: accepted
@@ -170,9 +201,9 @@ tags:
170
201
 
171
202
  ## Decisão
172
203
 
173
- Mudança ${changeWikilink} concluída e arquivada.
204
+ Mudança ${changeWikilink} concluída e arquivada.${capLine}
174
205
  `, 'utf8');
175
206
 
176
207
  clearActiveChange(vaultBase);
177
- return { ok: true, failing: [], archivedRel: destRel, adrRel };
208
+ return { ok: true, failing: [], archivedRel: destRel, adrRel, promoted, specWarnings };
178
209
  }
@@ -24,15 +24,23 @@ export function runSensors(sensors, ids, { spawn = spawnSync, cwd, now } = {}) {
24
24
  const evidence = [];
25
25
  for (const id of ids) {
26
26
  const s = byId[id];
27
- if (!s) { evidence.push({ id, status: 'red', ts, note: 'sensor não definido' }); continue; }
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 });
29
+ evidence.push({ id, status: (r.status ?? 1) === 0 ? 'green' : 'red', ts, severity: s.severity || 'critical' });
30
30
  }
31
31
  return evidence;
32
32
  }
33
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.
34
37
  export function evaluateGate(evidence, requiredIds) {
35
- const byId = Object.fromEntries((evidence || []).map((e) => [e.id, e.status]));
36
- const failing = (requiredIds || []).filter((id) => byId[id] !== 'green');
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
+ });
37
45
  return { ok: failing.length === 0, failing };
38
46
  }
@@ -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.3.0",
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 CHANGED
@@ -82,6 +82,8 @@ export function runChange(argv) {
82
82
  process.exit(1);
83
83
  }
84
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`);
85
87
  process.exit(0);
86
88
  }
87
89
 
package/src/init.mjs CHANGED
@@ -31,7 +31,7 @@ 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';
34
+ import { seedDefinitions, syncDefs } from './sync-defs.mjs';
35
35
  import { seedWkSkills } from './skills-seed.mjs';
36
36
  import { seedDotcontext, globalHasDotcontext, resolveDotcontextSkipMcp, renderSensorsJson } from './dotcontext-seed.mjs';
37
37
 
@@ -295,6 +295,12 @@ export async function runInit(argv) {
295
295
  writeFileSync(sensorsFile, renderSensorsJson(scripts), 'utf8');
296
296
  }
297
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
+ }
298
304
 
299
305
  // 2. .claude/settings.json --------------------------------------------------
300
306
  const settingsPath = join(projectPath, '.claude', 'settings.json');
package/src/taxonomy.mjs CHANGED
@@ -33,6 +33,7 @@ export const HOOK_FILES = [
33
33
  'pricing.json',
34
34
  'brain-core.mjs',
35
35
  'change-core.mjs',
36
+ 'spec-core.mjs',
36
37
  'sensors-core.mjs',
37
38
  'brain-inject.mjs',
38
39
  'brain-recall.mjs',
package/src/verify.mjs CHANGED
@@ -31,10 +31,13 @@ export function runVerify(argv) {
31
31
  const evidence = runSensors(sensors, ids, { cwd: projectRoot });
32
32
  writeFileSync(join(changeDir, 'evidencia.json'), `${JSON.stringify(evidence, null, 2)}\n`, 'utf8');
33
33
 
34
- const severity = Object.fromEntries(sensors.map((s) => [s.id, s.severity || 'critical']));
35
- const critical = ids.filter((id) => severity[id] !== 'warning');
36
- const { ok, failing } = evaluateGate(evidence, critical);
37
- for (const e of evidence) process.stdout.write(` ${e.status === 'green' ? '✓' : '✗'} ${e.id}\n`);
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
+ }
38
41
  if (!ok) { process.stderr.write(`verify: critical sensors red: ${failing.join(', ')}\n`); process.exit(1); }
39
42
  process.stdout.write(`verify OK (${ids.length} sensor(s))\n`);
40
43
  process.exit(0);