wendkeep 0.32.0 → 0.34.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.
@@ -11,6 +11,14 @@ export function tasksHashOf(md) {
11
11
  return createHash('sha1').update(String(md)).digest('hex').slice(0, 12);
12
12
  }
13
13
 
14
+ export function contentHashOf(value) {
15
+ return createHash('sha256').update(String(value)).digest('hex');
16
+ }
17
+
18
+ export const SPECS_STATE_FILE = '.brain/SPECS_STATE.json';
19
+ export const SPEC_BASELINE_FILE = '.spec-base.json';
20
+ export const MANAGED_SPEC_MARKER = '<!-- wendkeep:managed-spec — generated from 08-Mudanças; do not edit directly -->';
21
+
14
22
  // Parse is BILINGUAL always (mixed vaults never break); rendering follows the vault locale.
15
23
  const REQ_RE = /^### (?:Requisito|Requirement):\s*(.+)$/gm;
16
24
 
@@ -21,7 +29,7 @@ export function parseRequirements(md) {
21
29
  for (let i = 0; i < matches.length; i += 1) {
22
30
  const raw = matches[i][1].trim();
23
31
  // Identity is the ID (e.g. GATE-1) when the heading is "<ID> — <nome>"; else the whole text.
24
- const idM = raw.match(/^([A-Z][A-Z0-9]*-\d+)\s*—\s*(.+)$/);
32
+ const idM = raw.match(/^([A-Z][A-Z0-9]*(?:-[A-Z0-9]+)*-\d+)\s*—\s*(.+)$/);
25
33
  const id = idM ? idM[1] : null;
26
34
  const name = idM ? idM[2].trim() : raw;
27
35
  const start = matches[i].index + matches[i][0].length;
@@ -75,7 +83,148 @@ export function applyDelta(reqs, delta) {
75
83
  export function renderSpec(capability, reqs, { footer, reqHeading = 'Requisito' } = {}) {
76
84
  const blocks = reqs.map((r) => `### ${reqHeading}: ${r.id ? `${r.id} — ${r.name}` : r.name}\n${r.body}`).join('\n\n');
77
85
  const foot = footer ? `\n\n> ${footer}\n` : '\n';
78
- return `---\ntype: spec\ncssclasses:\n - topic-spec\ntags:\n - spec\n---\n\n# ${capability}\n\n## Requisitos\n\n${blocks}${foot}`;
86
+ return `${MANAGED_SPEC_MARKER}\n---\ntype: spec\ncssclasses:\n - topic-spec\ntags:\n - spec\n---\n\n# ${capability}\n\n## Requisitos\n\n${blocks}${foot}`;
87
+ }
88
+
89
+ function readLivingSpecs(vaultBase) {
90
+ const specsDir = join(vaultBase, getLocale(vaultBase).folders.specs);
91
+ const specs = {};
92
+ let files = [];
93
+ try { files = readdirSync(specsDir).filter((f) => f.endsWith('.md') && f !== 'README.md'); } catch { return specs; }
94
+ for (const file of files) {
95
+ const capability = file.replace(/\.md$/, '');
96
+ const md = readFileSync(join(specsDir, file), 'utf8');
97
+ specs[capability] = {
98
+ hash: contentHashOf(md),
99
+ requirements: Object.fromEntries(parseRequirements(md).map((r) => [r.id || r.name, contentHashOf(JSON.stringify(r))])),
100
+ };
101
+ }
102
+ return specs;
103
+ }
104
+
105
+ export function livingSpecCapabilities(vaultBase) {
106
+ return Object.keys(readLivingSpecs(vaultBase));
107
+ }
108
+
109
+ export function adoptSpecsState(vaultBase) {
110
+ const state = { version: 1, generatedAt: new Date().toISOString(), specs: readLivingSpecs(vaultBase) };
111
+ ensureDir(join(vaultBase, '.brain'));
112
+ writeFileSync(join(vaultBase, SPECS_STATE_FILE), `${JSON.stringify(state, null, 2)}\n`, 'utf8');
113
+ return state;
114
+ }
115
+
116
+ export function readSpecsState(vaultBase) {
117
+ try { return JSON.parse(readFileSync(join(vaultBase, SPECS_STATE_FILE), 'utf8')); } catch { return null; }
118
+ }
119
+
120
+ export function checkSpecsState(vaultBase) {
121
+ const recorded = readSpecsState(vaultBase);
122
+ if (!recorded) return { ok: false, missing: true, changed: [] };
123
+ const current = readLivingSpecs(vaultBase);
124
+ const names = new Set([...Object.keys(recorded.specs || {}), ...Object.keys(current)]);
125
+ const changed = [...names].filter((name) => recorded.specs?.[name]?.hash !== current[name]?.hash);
126
+ return { ok: changed.length === 0, missing: false, changed, current, recorded };
127
+ }
128
+
129
+ function recordPromotedSpecs(vaultBase, capabilities) {
130
+ const existing = readSpecsState(vaultBase);
131
+ if (!existing) return adoptSpecsState(vaultBase);
132
+ const current = readLivingSpecs(vaultBase);
133
+ const specs = { ...(existing.specs || {}) };
134
+ for (const capability of capabilities) {
135
+ if (current[capability]) specs[capability] = current[capability];
136
+ else delete specs[capability];
137
+ }
138
+ const state = { version: 1, generatedAt: new Date().toISOString(), specs };
139
+ writeFileSync(join(vaultBase, SPECS_STATE_FILE), `${JSON.stringify(state, null, 2)}\n`, 'utf8');
140
+ return state;
141
+ }
142
+
143
+ export function captureSpecBaseline(vaultBase, changeDir, { refresh = false } = {}) {
144
+ const path = join(changeDir, SPEC_BASELINE_FILE);
145
+ if (!refresh && existsSync(path)) {
146
+ try { return JSON.parse(readFileSync(path, 'utf8')); } catch { /* rebuild malformed baseline */ }
147
+ }
148
+ const baseline = { version: 1, capturedAt: new Date().toISOString(), specs: readLivingSpecs(vaultBase) };
149
+ writeFileSync(path, `${JSON.stringify(baseline, null, 2)}\n`, 'utf8');
150
+ return baseline;
151
+ }
152
+
153
+ export function specConflicts(vaultBase, changeDir, capabilities = discoverSpecDeltas(changeDir)) {
154
+ let baseline = null;
155
+ try { baseline = JSON.parse(readFileSync(join(changeDir, SPEC_BASELINE_FILE), 'utf8')); } catch { /* legacy change */ }
156
+ if (!baseline) return [];
157
+ const current = readLivingSpecs(vaultBase);
158
+ const conflicts = [];
159
+ for (const capability of capabilities) {
160
+ let delta;
161
+ try { delta = parseDelta(readFileSync(join(changeDir, 'specs', capability, 'spec.md'), 'utf8')); } catch { continue; }
162
+ const baseReqs = baseline.specs?.[capability]?.requirements || {};
163
+ const currentReqs = current[capability]?.requirements || {};
164
+ for (const req of delta.added || []) {
165
+ const key = req.id || req.name;
166
+ if (!(key in baseReqs) && key in currentReqs) conflicts.push(`${capability}:${key} foi adicionado por outra change`);
167
+ }
168
+ for (const req of delta.modified || []) {
169
+ const key = req.id || req.name;
170
+ if (baseReqs[key] !== currentReqs[key]) conflicts.push(`${capability}:${key} mudou desde a abertura da change`);
171
+ }
172
+ for (const key of delta.removed || []) {
173
+ if (baseReqs[key] !== currentReqs[key]) conflicts.push(`${capability}:${key} mudou desde a abertura da change`);
174
+ }
175
+ }
176
+ return conflicts;
177
+ }
178
+
179
+ export function resolveEffectiveSpecs(vaultBase, changeDir, capabilities = discoverSpecDeltas(changeDir)) {
180
+ const specsDir = join(vaultBase, getLocale(vaultBase).folders.specs);
181
+ const result = [];
182
+ const warnings = [];
183
+ const errors = [];
184
+ for (const capability of capabilities) {
185
+ let living = [];
186
+ try { living = parseRequirements(readFileSync(join(specsDir, `${capability}.md`), 'utf8')); } catch { /* new capability */ }
187
+ let deltaMd = '';
188
+ try { deltaMd = readFileSync(join(changeDir, 'specs', capability, 'spec.md'), 'utf8'); } catch { /* unchanged living capability */ }
189
+ if (deltaMd && isPlaceholderDelta(deltaMd)) deltaMd = '';
190
+ const delta = deltaMd ? parseDelta(deltaMd) : { added: [], modified: [], removed: [] };
191
+ const operations = new Map(living.map((r) => [r.id || r.name, { operation: 'BASE', source: 'living' }]));
192
+ for (const r of delta.added) operations.set(r.id || r.name, { operation: 'ADDED', source: 'change' });
193
+ for (const r of delta.modified) operations.set(r.id || r.name, { operation: 'MODIFIED', source: 'change' });
194
+ for (const key of delta.removed) operations.delete(key);
195
+ const applied = applyDelta(living, delta);
196
+ warnings.push(...applied.warnings.map((w) => `${capability}: ${w}`));
197
+ for (const warning of applied.warnings) {
198
+ if (/^(ADDED já existe|MODIFIED inexistente|REMOVED inexistente)/.test(warning)) errors.push(`${capability}: ${warning}`);
199
+ }
200
+ result.push({
201
+ capability,
202
+ requirements: applied.reqs.map((r) => ({ ...r, capability, ...(operations.get(r.id || r.name) || { operation: 'BASE', source: 'living' }) })),
203
+ });
204
+ }
205
+ const serializable = result.map((s) => ({ capability: s.capability, requirements: s.requirements }));
206
+ return { specs: result, requirements: result.flatMap((s) => s.requirements), warnings, errors, hash: contentHashOf(JSON.stringify(serializable)) };
207
+ }
208
+
209
+ export function buildEffectiveRequirementPackage(vaultBase, changeDir, reqIds = []) {
210
+ let listed = [];
211
+ try { listed = parseSpecsList(readFileSync(join(changeDir, 'proposta.md'), 'utf8')); } catch { /* caller validates change */ }
212
+ const changed = [...new Set([...listed, ...discoverSpecDeltas(changeDir)])];
213
+ const capabilities = [...new Set([...livingSpecCapabilities(vaultBase), ...changed])];
214
+ const effective = resolveEffectiveSpecs(vaultBase, changeDir, capabilities);
215
+ const byId = new Map(effective.requirements.filter((r) => r.id).map((r) => [r.id, r]));
216
+ const missing = reqIds.filter((id) => !byId.has(id));
217
+ const requirements = reqIds.map((id) => byId.get(id)).filter(Boolean);
218
+ const relevantCaps = new Set([...changed, ...requirements.map((r) => r.capability)]);
219
+ const relevant = effective.specs.filter((spec) => relevantCaps.has(spec.capability));
220
+ return {
221
+ ...effective,
222
+ specs: relevant,
223
+ requirements,
224
+ missing,
225
+ changedCapabilities: changed,
226
+ hash: contentHashOf(JSON.stringify(relevant)),
227
+ };
79
228
  }
80
229
 
81
230
  export function parseSpecsList(propostaMd) {
@@ -160,6 +309,13 @@ export function promoteSpecs(vaultBase, changeDir, specs, { changeWikilink, date
160
309
  const specsDir = loc.folders.specs;
161
310
  const promoted = [];
162
311
  const warnings = [];
312
+ const state = checkSpecsState(vaultBase);
313
+ const unmanaged = state.missing ? [] : state.changed.filter((capability) => specs.includes(capability));
314
+ if (unmanaged.length) {
315
+ throw new Error(`07-Specs alterado fora do WendKeep: ${unmanaged.join(', ')} — mova o delta para 08-Mudanças/<change>/specs`);
316
+ }
317
+ const conflicts = specConflicts(vaultBase, changeDir, specs);
318
+ if (conflicts.length) throw new Error(`conflito de spec: ${conflicts.join('; ')} — reconcilie o delta e rode \`wendkeep spec rebase --change <slug> --accept-current\``);
163
319
  for (const cap of specs) {
164
320
  let deltaMd;
165
321
  try { deltaMd = readFileSync(join(changeDir, 'specs', cap, 'spec.md'), 'utf8'); }
@@ -176,13 +332,14 @@ export function promoteSpecs(vaultBase, changeDir, specs, { changeWikilink, date
176
332
  writeFileSync(livePath, renderSpec(cap, applied.reqs, { footer, reqHeading: loc.reqHeading }), 'utf8');
177
333
  promoted.push(cap);
178
334
  }
335
+ recordPromotedSpecs(vaultBase, promoted);
179
336
  return { promoted, warnings };
180
337
  }
181
338
 
182
339
  // Gate check for the independent verdict (Wave A). A requirement-bearing change must have
183
340
  // a verdict that is ok and covers every declared req id. A requirement-less change passes:
184
341
  // nothing for an independent verifier to check — the sensor gate is already the proof.
185
- export function evaluateVerdict(verdict, reqIds, { tasksHash } = {}) {
342
+ export function evaluateVerdict(verdict, reqIds, { tasksHash, effectiveSpecHash } = {}) {
186
343
  const ids = reqIds || [];
187
344
  if (ids.length === 0) return { ok: true, missing: [] };
188
345
  if (!verdict || verdict.ok !== true) return { ok: false, missing: [] };
@@ -191,6 +348,9 @@ export function evaluateVerdict(verdict, reqIds, { tasksHash } = {}) {
191
348
  if (tasksHash && verdict.tasksHash && verdict.tasksHash !== tasksHash) {
192
349
  return { ok: false, missing: [], stale: true };
193
350
  }
351
+ if (effectiveSpecHash && verdict.effectiveSpecHash && verdict.effectiveSpecHash !== effectiveSpecHash) {
352
+ return { ok: false, missing: [], stale: true };
353
+ }
194
354
  const covered = new Set((verdict.coverage || []).filter((c) => c.covered).map((c) => c.req));
195
355
  const missing = ids.filter((r) => !covered.has(r));
196
356
  return { ok: missing.length === 0, missing };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wendkeep",
3
- "version": "0.32.0",
3
+ "version": "0.34.0",
4
4
  "description": "A persistent-memory harness for AI coding agents on your Obsidian vault: turn-by-turn session capture plus a native, zero-dependency spec→change→verify→archive loop (sensor-gated, independent verdict, mutation discrimination). Local-first, agent-agnostic (Claude Code, Codex, Cursor…).",
5
5
  "type": "module",
6
6
  "bin": {
package/src/change.mjs CHANGED
@@ -3,8 +3,12 @@ import { readFileSync } from 'node:fs';
3
3
  import { isAbsolute, join, resolve } from 'node:path';
4
4
  import {
5
5
  newChange,
6
+ useChange,
7
+ continueChange,
6
8
  activeChange,
9
+ allChangesState,
7
10
  listChanges,
11
+ renderOpenChanges,
8
12
  parseTasks,
9
13
  setTaskDone,
10
14
  archiveChange,
@@ -12,7 +16,7 @@ import {
12
16
  scaffoldPlaceholders,
13
17
  } from '../hooks/change-core.mjs';
14
18
  import { evaluateGate, requiredSensors } from '../hooks/sensors-core.mjs';
15
- import { evaluateVerdict, tasksHashOf, parseSpecsList, parseDelta, parseRequirements, applyDelta, validateSpecImpact } from '../hooks/spec-core.mjs';
19
+ import { buildEffectiveRequirementPackage, evaluateVerdict, tasksHashOf, parseSpecsList, parseDelta, parseRequirements, applyDelta, validateSpecImpact } from '../hooks/spec-core.mjs';
16
20
  import { getNextAdrNumber, readControl } from '../hooks/obsidian-common.mjs';
17
21
  import { getLocale } from '../hooks/locale.mjs';
18
22
 
@@ -60,10 +64,36 @@ export function runChange(argv) {
60
64
  process.exit(0);
61
65
  }
62
66
 
67
+ if (sub === 'use') {
68
+ const slug = slugArg();
69
+ if (!slug) { process.stderr.write('wendkeep change use: missing <slug>\n'); process.exit(2); }
70
+ const r = useChange(vaultBase, slug);
71
+ if (!r.ok) { process.stderr.write(`wendkeep change use: ${r.error}\n`); process.exit(2); }
72
+ process.stdout.write(`current change: ${slug}\n`);
73
+ process.exit(0);
74
+ }
75
+
76
+ if (sub === 'continue') {
77
+ const positionals = rest.filter((a, i) => !a.startsWith('-') && !VALUE_FLAGS.has(rest[i - 1]));
78
+ const [archivedSlug, newSlug] = positionals;
79
+ if (!archivedSlug || !newSlug) {
80
+ process.stderr.write('wendkeep change continue: use <archived-slug> <new-slug>\n');
81
+ process.exit(2);
82
+ }
83
+ let sessionRel = '';
84
+ try { sessionRel = readControl(vaultBase).session_file || ''; } catch { /* no control */ }
85
+ const r = continueChange(vaultBase, archivedSlug, newSlug, {
86
+ dateStr: today(), simple: rest.includes('--simple'), sessionRel,
87
+ });
88
+ if (!r.ok) { process.stderr.write(`wendkeep change continue: ${r.error}\n`); process.exit(2); }
89
+ process.stdout.write(`change created: ${r.rel} (continues ${r.archived}; active)\n`);
90
+ process.exit(0);
91
+ }
92
+
63
93
  if (sub === 'list') {
64
- const { active, archived } = listChanges(vaultBase);
65
- const cur = activeChange(vaultBase);
66
- process.stdout.write(`active: ${active.map((s) => (s === cur ? `*${s}` : s)).join(', ') || '(none)'}\n`);
94
+ const state = allChangesState(vaultBase);
95
+ const { archived } = listChanges(vaultBase);
96
+ process.stdout.write(`${renderOpenChanges(state, { tag: '' }) || 'open changes: (none)'}\n`);
67
97
  process.stdout.write(`archived: ${archived.join(', ') || '(none)'}\n`);
68
98
  process.exit(0);
69
99
  }
@@ -82,8 +112,16 @@ export function runChange(argv) {
82
112
  }
83
113
 
84
114
  if (sub === 'status') {
85
- const slug = slugArg() || activeChange(vaultBase);
86
- if (!slug) { process.stderr.write('wendkeep change status: no change (arg or active)\n'); process.exit(2); }
115
+ const slug = slugArg();
116
+ if (!slug) {
117
+ const state = allChangesState(vaultBase);
118
+ if (!state.changes.length && !state.pointerWarning) {
119
+ process.stderr.write('wendkeep change status: no open changes\n');
120
+ process.exit(2);
121
+ }
122
+ process.stdout.write(`${renderOpenChanges(state, { tag: '' })}\n`);
123
+ process.exit(0);
124
+ }
87
125
  const dir = join(vaultBase, getLocale(vaultBase).folders.changes, slug);
88
126
  let tarefasMd;
89
127
  try { tarefasMd = readFileSync(join(dir, 'tarefas.md'), 'utf8'); }
@@ -103,12 +141,16 @@ export function runChange(argv) {
103
141
  if (evidence) for (const e of evidence) process.stdout.write(` ${e.status === 'green' ? '✓' : '✗'} ${e.id} (${e.severity || 'critical'})\n`);
104
142
  else process.stdout.write('evidencia: ausente\n');
105
143
  const reqIds = [...new Set(tasks.map((t) => t.req).filter(Boolean))];
144
+ const effective = buildEffectiveRequirementPackage(vaultBase, dir, reqIds);
145
+ if (effective.errors.length || effective.missing.length) {
146
+ process.stdout.write(`spec efetiva: inválida (${[...effective.errors, ...effective.missing.map((id) => `req órfão ${id}`)].join('; ')})\n`);
147
+ }
106
148
  let verdict = null;
107
149
  try { verdict = JSON.parse(readFileSync(join(dir, 'verdict.json'), 'utf8')); } catch { /* sem verdict */ }
108
150
  if (!verdict) process.stdout.write(`verdict: ausente — rode \`wendkeep verify --deep\`${reqIds.length ? ' + wk-verify' : ' (verdict trivial automático)'}\n`);
109
151
  else if (!reqIds.length) process.stdout.write(`verdict: ${verdict.ok === true ? 'ok (trivial)' : 'não-ok — re-verifique'}\n`);
110
152
  else {
111
- const v = evaluateVerdict(verdict, reqIds, { tasksHash: tasksHashOf(tarefasMd) });
153
+ const v = evaluateVerdict(verdict, reqIds, { tasksHash: tasksHashOf(tarefasMd), effectiveSpecHash: effective.hash });
112
154
  process.stdout.write(`verdict: ${v.ok ? 'ok' : v.stale ? 'stale — re-verifique' : `incompleto: falta ${v.missing.join(', ')}`}\n`);
113
155
  }
114
156
  try { process.stdout.write(`mutation-round: ${readFileSync(join(dir, '.mutation-round'), 'utf8').trim()}/3\n`); } catch { /* sem rodadas */ }
@@ -185,6 +227,9 @@ export function runChange(argv) {
185
227
  }
186
228
  }
187
229
  const reqIds = [...new Set(tasks.map((t) => t.req).filter(Boolean))];
230
+ const effective = buildEffectiveRequirementPackage(vaultBase, dir, reqIds);
231
+ if (effective.errors.length) return { ok: false, failing: [`spec efetiva inválida: ${effective.errors.join('; ')}`] };
232
+ if (effective.missing.length) return { ok: false, failing: [`requisito(s) órfão(s) na spec efetiva: ${effective.missing.join(', ')}`] };
188
233
  let evidence = [];
189
234
  try { evidence = JSON.parse(readFileSync(join(dir, 'evidencia.json'), 'utf8')); } catch { /* no evidence */ }
190
235
  const s = evaluateGate(evidence, required);
@@ -204,8 +249,16 @@ export function runChange(argv) {
204
249
  if (verdict.tasksHash && verdict.tasksHash !== hash) {
205
250
  return { ok: false, failing: [`verdict stale (tarefas.md mudou depois da verificação) — re-verifique: \`wendkeep verify --deep\`${reqIds.length ? ' + wk-verify' : ''}`] };
206
251
  }
252
+ let verification = null;
253
+ try { verification = JSON.parse(readFileSync(join(dir, 'verificacao.json'), 'utf8')); } catch { /* none */ }
254
+ if (verification?.effectiveSpecHash && verification.effectiveSpecHash !== effective.hash) {
255
+ return { ok: false, failing: ['pacote de verificação stale (spec efetiva mudou) — rode `wendkeep verify --deep` novamente'] };
256
+ }
257
+ if (reqIds.length && verification?.effectiveSpecHash && !verdict.effectiveSpecHash) {
258
+ return { ok: false, failing: ['verdict sem effectiveSpecHash — rode a skill wk-verify novamente'] };
259
+ }
207
260
  if (reqIds.length) {
208
- const v = evaluateVerdict(verdict, reqIds, { tasksHash: hash });
261
+ const v = evaluateVerdict(verdict, reqIds, { tasksHash: hash, effectiveSpecHash: effective.hash });
209
262
  if (!v.ok) {
210
263
  if (v.stale) return { ok: false, failing: ['verdict stale (tarefas.md mudou depois da verificação) — re-verifique: `wendkeep verify --deep` + wk-verify'] };
211
264
  return { ok: false, failing: [`verdict incompleto: falta ${v.missing.join(', ')}`] };
@@ -240,6 +293,6 @@ export function runChange(argv) {
240
293
  process.exit(0);
241
294
  }
242
295
 
243
- process.stderr.write(`wendkeep change: unknown subcommand "${sub}". Known: new, list, show, status, done, undone, diff, archive, abandon.\n`);
296
+ process.stderr.write(`wendkeep change: unknown subcommand "${sub}". Known: new, use, continue, list, show, status, done, undone, diff, archive, abandon.\n`);
244
297
  process.exit(2);
245
298
  }
package/src/doctor.mjs CHANGED
@@ -5,6 +5,7 @@ import { existsSync } from 'node:fs';
5
5
  import { dirname, isAbsolute, join, resolve } from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { checkHarness } from '../hooks/harness-doctor.mjs';
8
+ import { checkSyncDefs } from './sync-defs.mjs';
8
9
 
9
10
  export function runDoctor(argv) {
10
11
  const here = dirname(fileURLToPath(import.meta.url));
@@ -40,6 +41,11 @@ export function runDoctor(argv) {
40
41
 
41
42
  // 2. Harness integrity (Wave B).
42
43
  const { errors, warnings } = checkHarness(vaultBase, projectRoot);
44
+ const defs = checkSyncDefs(vaultBase, projectRoot);
45
+ if (!defs.ok) {
46
+ warnings.push(...defs.issues.map((issue) => `defs: ${issue}`));
47
+ warnings.push('defs stale — rode `wendkeep sync-defs --reseed` e reinicie Claude Code/Codex');
48
+ }
43
49
  process.stdout.write(`\n[harness] ${errors.length} erro(s), ${warnings.length} aviso(s)\n`);
44
50
  for (const e of errors) process.stdout.write(` ✗ ${e}\n`);
45
51
  for (const w of warnings) process.stdout.write(` ! ${w}\n`);
package/src/init.mjs CHANGED
@@ -3,7 +3,7 @@
3
3
  // OBSIDIAN_VAULT_PATH into .claude/settings.json, and adds the mcpvault server to
4
4
  // .mcp.json. Idempotent: re-running only adds what is missing.
5
5
  import { spawnSync } from 'node:child_process';
6
- import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
6
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
7
7
  import { basename, isAbsolute, join, resolve } from 'node:path';
8
8
  import { createInterface } from 'node:readline/promises';
9
9
  import {
@@ -40,6 +40,7 @@ import { seedDefinitions, syncDefs } from './sync-defs.mjs';
40
40
  import { seedWkSkills } from './skills-seed.mjs';
41
41
  import { LOCALES, DEFAULT_LOCALE, getLocale, clearLocaleCache, vaultFolders } from '../hooks/locale.mjs';
42
42
  import { seedDotcontext, globalHasDotcontext, resolveDotcontextSkipMcp, renderSensorsJson } from './dotcontext-seed.mjs';
43
+ import { adoptSpecsState, SPECS_STATE_FILE } from '../hooks/spec-core.mjs';
43
44
 
44
45
  function parseArgs(argv) {
45
46
  const args = { mcp: true, yes: false, force: false };
@@ -278,7 +279,7 @@ const MESSAGES = {
278
279
  colorsOn: 'wendkeep-colors (snippet + grupos do grafo)',
279
280
  taxonomy: (n, c, loc, readme, views) => ` [1/4] taxonomia do vault: ${n} pastas (${c} criadas, locale ${loc})${readme}, .brain + change/spec + sensores semeados${views}`,
280
281
  readmeCreated: ', README.md criado', viewsNote: (n) => `, ${n} view(s) + dashboard`,
281
- defs: (s, a) => ` defs entregues: ${s} skill(s) -> .claude/skills, ${a} agent(s) -> .codex/agents`,
282
+ defs: (s, a) => ` defs entregues: ${s} skill(s) -> .claude/skills + .agents/skills, ${a} agent(s) -> .codex/agents`,
282
283
  settingsBadJson: (p) => ` [2/4] settings.json existe mas não é JSON válido -> escrevi ${p}.new (mescle à mão)`,
283
284
  settings: (verb, added, bak) => ` [2/4] settings.json ${verb} (${added} hook(s) wirados, OBSIDIAN_VAULT_PATH setado${bak})`,
284
285
  mcpBadJson: (p) => ` [3/4] .mcp.json existe mas não é JSON válido -> escrevi ${p}.new (mescle à mão)`,
@@ -303,7 +304,7 @@ const MESSAGES = {
303
304
  colorsOn: 'wendkeep-colors (snippet + graph groups)',
304
305
  taxonomy: (n, c, loc, readme, views) => ` [1/4] vault taxonomy: ${n} folders (${c} created, locale ${loc})${readme}, .brain + change/spec + sensors seeded${views}`,
305
306
  readmeCreated: ', README.md created', viewsNote: (n) => `, ${n} view(s) + dashboard`,
306
- defs: (s, a) => ` defs delivered: ${s} skill(s) -> .claude/skills, ${a} agent(s) -> .codex/agents`,
307
+ defs: (s, a) => ` defs delivered: ${s} skill(s) -> .claude/skills + .agents/skills, ${a} agent(s) -> .codex/agents`,
307
308
  settingsBadJson: (p) => ` [2/4] settings.json exists but is not valid JSON -> wrote ${p}.new (merge by hand)`,
308
309
  settings: (verb, added, bak) => ` [2/4] settings.json ${verb} (${added} hook(s) wired, OBSIDIAN_VAULT_PATH set${bak})`,
309
310
  mcpBadJson: (p) => ` [3/4] .mcp.json exists but is not valid JSON -> wrote ${p}.new (merge by hand)`,
@@ -482,8 +483,12 @@ export async function runInit(argv) {
482
483
  const specsReadme = join(vaultPath, loc.folders.specs, 'README.md');
483
484
  if (!existsSync(specsReadme)) {
484
485
  writeFileSync(specsReadme, en
485
- ? `# Specs — living contract\n\nThe project's capabilities (requirements/scenarios). Changes in \`${loc.folders.changes}/\` promote deltas here on \`wendkeep change archive\`.\n`
486
- : `# Specs — contrato vivo\n\nCapacidades do projeto (requisitos/cenários). Changes em \`${loc.folders.changes}/\` promovem deltas aqui no \`wendkeep change archive\`.\n`, 'utf8');
486
+ ? `# Specs — generated living contract\n\nRead-only: do not author here. Write deltas only in \`${loc.folders.changes}/<slug>/specs/\`; archive promotes them here.\n`
487
+ : `# Specs — contrato consolidado gerado\n\nSomente leitura: não edite aqui. Escreva deltas apenas em \`${loc.folders.changes}/<slug>/specs/\`; o archive promove para cá.\n`, 'utf8');
488
+ }
489
+ if (!existsSync(join(vaultPath, SPECS_STATE_FILE))) {
490
+ const livingSpecs = readdirSync(join(vaultPath, loc.folders.specs)).filter((name) => name.endsWith('.md') && name !== 'README.md');
491
+ if (!livingSpecs.length) adoptSpecsState(vaultPath);
487
492
  }
488
493
  const changeTpl = join(vaultPath, 'Templates', 'Change.md');
489
494
  if (!existsSync(changeTpl)) {
@@ -1,6 +1,6 @@
1
1
  // src/skills-seed.mjs — native, zero-dep process skills (Pilar A: the HOW layer).
2
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.
3
+ // .claude/skills + .agents/skills. Wendkeep-flavored, concise native prose.
4
4
  import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
5
5
  import { join } from 'node:path';
6
6
 
@@ -29,8 +29,9 @@ vault cego. Exceção única: mudança trivial (typo, 1 linha).
29
29
  - \`proposta.md\` — *por quê* e *o que muda* (o WHAT).
30
30
  - \`design.md\` — a abordagem técnica.
31
31
  - \`tarefas.md\` — a lista de tarefas \`- [ ] N.N descrição\`.
32
- A mudança vira a *ativa* (ponteiro \`.brain/CURRENT_CHANGE.md\`) e é injetada no
33
- próximo SessionStart, então você retoma o trabalho em curso automaticamente.
32
+ A mudança vira a *atual* (ponteiro global \`.brain/CURRENT_CHANGE.md\`). Podem existir
33
+ várias changes abertas: hooks e \`change list/status\` mostram todas as pendências; comandos
34
+ sem \`--change\` usam somente a atual.
34
35
  Antes de implementar, resolva \`spec_impact\` na proposta:
35
36
  - \`required\`: liste a capability em \`specs:\` e preencha
36
37
  \`specs/<capability>/spec.md\` com ADDED/MODIFIED/REMOVED; ligue tarefas com \`[req:ID]\`.
@@ -40,12 +41,13 @@ vault cego. Exceção única: mudança trivial (typo, 1 linha).
40
41
  (teste vermelho antes do código). Marque \`- [x]\` ao concluir. Declare nas tarefas:
41
42
  - \`[sensor:<id>]\` — a prova automatizada (roda no verify).
42
43
  - \`[req:<ID>]\` — o requisito do spec que a tarefa satisfaz (ex.: \`[req:GATE-1]\`),
43
- quando a change mexe numa capability de \`07-Specs\`.
44
+ quando a change mexe numa capability. Toda autoria de spec ocorre somente em
45
+ \`08-Mudanças/<slug>/specs/<capability>/spec.md\`; \`07-Specs\` é gerado/read-only.
44
46
  Ex.: \`- [ ] 2.1 valida CORE [req:MEM-1] [sensor:memory-validation]\`.
45
47
  4. **Verify** — \`wendkeep verify\` roda os sensores → \`evidencia.json\`. Depois
46
48
  \`wendkeep verify --deep\` monta o *pacote de verificação* pro passe independente.
47
49
  5. **Verify deep** — a skill **wk-verify** (passe fresco, autor≠verificador) lê o pacote,
48
- re-deriva a cobertura do \`07-Specs\` e grava \`verdict.json\`. Change trivial (sem
50
+ usa somente os requisitos autocontidos de \`verificacao.json\` e grava \`verdict.json\`. Change trivial (sem
49
51
  \`[req:]\`) recebe verdict automático — pula este passe.
50
52
  6. **Archive** — \`wendkeep change archive <slug>\`. O *gate* exige sensores verdes **E**
51
53
  \`verdict.json\` cobrindo os \`[req:]\`. Passando, promove os deltas pro \`07-Specs\`,
@@ -53,7 +55,9 @@ vault cego. Exceção única: mudança trivial (typo, 1 linha).
53
55
 
54
56
  ## Regras
55
57
 
56
- - Uma mudança ativa por vez. Termine (archive) antes de abrir outra.
58
+ - Várias changes podem ficar abertas. \`CURRENT_CHANGE.md\` marca uma atual, sem esconder as
59
+ outras pendências. Claude, Codex ou outro agente assumem uma change existente com
60
+ \`wendkeep change use <slug>\` ou \`--change <slug>\` quando disponível.
57
61
  - Se uma tarefa não precisa de prova automatizada, não declare sensor — o gate só exige
58
62
  o que você declarou. Sem \`[sensor:]\`, o archive não trava.
59
63
  - A proposta linka a sessão de origem; a sessão linka a mudança ativa. É de propósito:
@@ -74,7 +78,8 @@ vermelho pedindo por ele — e nunca escreva um teste que passaria sob a impleme
74
78
 
75
79
  ## Derive do spec, não do código
76
80
 
77
- Escreva a asserção a partir do *critério de aceite* do requisito (\`07-Specs\`), não lendo a
81
+ Escreva a asserção a partir do *critério de aceite* da spec efetiva
82
+ (\`wendkeep spec effective --change <slug>\`), não lendo a
78
83
  implementação. Cada asserção mira o resultado que o spec definiu. Escrever o teste lendo o
79
84
  código = ele só confirma o que o código já faz, bugs inclusos.
80
85
 
@@ -211,13 +216,13 @@ nunca tivesse visto a implementação. Contexto fresco, read-only.
211
216
  ## O que fazer
212
217
 
213
218
  1. Leia o pacote \`08-Mudanças/<slug>/verificacao.json\` (requisitos, tarefas, evidência).
214
- 2. **Re-derive a cobertura do \`07-Specs\`** — pra cada \`[req:ID]\` da change, cheque se o
215
- comportamento definido no requisito está coberto por um teste que discrimina (não
219
+ 2. **Re-derive a cobertura do pacote autocontido** — pra cada requisito completo em
220
+ \`verificacao.json\`, cheque se o comportamento está coberto por um teste que discrimina (não
216
221
  passaria sob impl errada). Evidência \`arquivo:linha\`.
217
222
  3. Outcome check ancorado no spec: o resultado observável bate com o critério de aceite?
218
223
  4. Grave \`08-Mudanças/<slug>/verdict.json\`:
219
- \`{ "slug": "...", "ok": true, "coverage": [{ "req": "GATE-1", "covered": true, "evidence": "arquivo:linha" }], "tasksHash": "<copie do verificacao.json>", "notes": [] }\`.
220
- O \`tasksHash\` vem do pacote — é o selo de frescor; sem ele (ou com tarefas alteradas
224
+ \`{ "slug": "...", "ok": true, "coverage": [{ "req": "GATE-1", "covered": true, "evidence": "arquivo:linha" }], "tasksHash": "<copie do verificacao.json>", "effectiveSpecHash": "<copie do verificacao.json>", "notes": [] }\`.
225
+ \`tasksHash\` e \`effectiveSpecHash\` vêm do pacote — são selos de frescor; sem eles (ou com tarefas/spec alteradas
221
226
  depois), o gate rejeita o verdict como stale.
222
227
 
223
228
  ## Regras
@@ -248,13 +253,15 @@ leaves the vault blind. Single exception: a trivial change (typo, one line).
248
253
 
249
254
  1. **Explore** — understand the problem before proposing.
250
255
  2. **Propose** — \`wendkeep change new <slug>\` scaffolds \`08-Changes/<slug>/\`
251
- (proposta/design/tarefas + a \`specs/\` delta). The change becomes *active* and is
252
- injected at the next SessionStart.
256
+ (proposta/design/tarefas + a \`specs/\` delta). The change becomes *current* through global
257
+ \`.brain/CURRENT_CHANGE.md\`. Multiple changes may stay open; hooks and \`change list/status\`
258
+ show every pending task, while commands without \`--change\` use only the current change.
253
259
  Before implementation, resolve \`spec_impact\`: \`required\` needs the capability listed in
254
260
  \`specs:\` plus a real \`specs/<capability>/spec.md\` delta and \`[req:ID]\` links; \`none\`
255
261
  needs a real \`spec_impact_reason\`. \`pending\` is never ready for implementation/archive.
256
262
  3. **Apply** — implement each task in tarefas.md with **wk-tdd** (red test first). Tag tasks:
257
263
  \`[sensor:<id>]\` (automated proof) and \`[req:<ID>]\` (the spec requirement it satisfies).
264
+ Author specs only in \`08-Changes/<slug>/specs/\`; \`07-Specs\` is generated/read-only.
258
265
  4. **Verify** — \`wendkeep verify\` runs the sensors; then \`wendkeep verify --deep\` builds
259
266
  the verification package.
260
267
  5. **Verify deep** — the **wk-verify** skill (fresh, author≠verifier) writes \`verdict.json\`.
@@ -263,7 +270,9 @@ leaves the vault blind. Single exception: a trivial change (typo, one line).
263
270
  verdict AND no open tasks. It promotes the delta into \`07-Specs\` and mints an ADR.
264
271
 
265
272
  ## Rules
266
- - One active change at a time. Finish (archive) before starting another.
273
+ - Multiple changes may stay open. \`CURRENT_CHANGE.md\` marks one current change without hiding
274
+ other pending tasks. Any agent may take over an existing change with
275
+ \`wendkeep change use <slug>\` or \`--change <slug>\` where available.
267
276
  - No \`[sensor:]\` on a task = no automated gate for it. No \`[req:]\` = no independent verdict.
268
277
  - The graph links session ↔ change ↔ requirement ↔ decision. That is the point.
269
278
  `;
@@ -280,7 +289,7 @@ would pass under the wrong implementation.
280
289
  4. **Refactor** with the greens protecting you.
281
290
 
282
291
  ## Derive from the spec, not the code
283
- Write assertions from the requirement's acceptance criteria (\`07-Specs\`), not by reading the
292
+ Write assertions from the effective requirement (\`wendkeep spec effective --change <slug>\`), not by reading the
284
293
  implementation. Reading the code to write the test = it only confirms what the code already does.
285
294
 
286
295
  ## Non-shallow litmus
@@ -369,17 +378,18 @@ the author — even if you wrote the code, enter as if you'd never seen it. Fres
369
378
 
370
379
  ## What to do
371
380
  1. Read the package \`08-Changes/<slug>/verificacao.json\` (requirements, tasks, evidence).
372
- 2. **Re-derive coverage from \`07-Specs\`** — for each \`[req:ID]\`, check the requirement's behaviour
381
+ 2. **Re-derive coverage from the self-contained package** — for each complete requirement in
382
+ \`verificacao.json\`, check its behaviour
373
383
  is covered by a test that discriminates (wouldn't pass under a wrong impl). \`file:line\` evidence.
374
384
  3. Spec-anchored outcome check: does the observable result match the acceptance criterion?
375
385
  4. Write \`08-Changes/<slug>/verdict.json\`:
376
- \`{ "slug": "...", "ok": true, "coverage": [{ "req": "GATE-1", "covered": true, "evidence": "file:line" }], "tasksHash": "<copy from the package>", "notes": [] }\`.
386
+ \`{ "slug": "...", "ok": true, "coverage": [{ "req": "GATE-1", "covered": true, "evidence": "file:line" }], "tasksHash": "<copy from the package>", "effectiveSpecHash": "<copy from the package>", "notes": [] }\`.
377
387
 
378
388
  ## Rules
379
389
  - **Author ≠ verifier.** On Claude, spawn a read-only sub-agent for real isolation.
380
390
  - \`ok: false\` if any requirement lacks discriminating coverage. A gap is red, not "almost".
381
391
  - Don't fix here — a gap becomes a fix task; re-verify after.
382
- - The archive gate **requires** a fresh \`verdict.json\` (matching \`tasksHash\`) covering every \`[req:]\`.
392
+ - The archive gate **requires** a fresh \`verdict.json\` (matching \`tasksHash\` and \`effectiveSpecHash\`) covering every \`[req:]\`.
383
393
 
384
394
  ## Templates (in this folder)
385
395
  - \`spec-reviewer-prompt.md\` — hand it to the verifier sub-agent you spawn (read-only, author≠verifier).
@@ -396,6 +406,7 @@ const VERDICT_TEMPLATE = `{
396
406
  { "req": "GATE-1", "covered": true, "evidence": "tests/foo.test.mjs:42" }
397
407
  ],
398
408
  "tasksHash": "<copie de verificacao.json — selo de frescor / copy from verificacao.json — freshness seal>",
409
+ "effectiveSpecHash": "<copie de verificacao.json / copy from verificacao.json>",
399
410
  "notes": []
400
411
  }
401
412
  `;
@@ -409,8 +420,8 @@ Claude). Ele NÃO é o autor: entra fresco, read-only, não edita nada.
409
420
  Você é o verificador independente de uma mudança do wendkeep. Não escreveu este código —
410
421
  entre como se nunca o tivesse visto. Read-only.
411
422
 
412
- Leia o pacote \`08-Mudanças/<slug>/verificacao.json\` (requisitos, tarefas, evidência) e os
413
- specs vivos em \`07-Specs/<capability>.md\`.
423
+ Leia somente o pacote autocontido \`08-Mudanças/<slug>/verificacao.json\` (requisitos completos,
424
+ tarefas e evidência). Não reabra \`07-Specs\`: ele ainda não contém deltas não arquivados.
414
425
 
415
426
  Para cada \`[req:ID]\` da mudança:
416
427
  1. Leia o **critério de aceite do requisito** no spec — NÃO leia a implementação primeiro.
@@ -421,7 +432,7 @@ Para cada \`[req:ID]\` da mudança:
421
432
 
422
433
  Grave \`08-Mudanças/<slug>/verdict.json\` no formato de \`verdict-template.json\`. \`ok: false\` se
423
434
  qualquer \`[req:]\` não tem cobertura que discrimina. Não conserte aqui — gap vira tarefa de
424
- correção. O \`tasksHash\` vem do pacote (selo de frescor; alterou tarefa depois, o gate rejeita).
435
+ correção. \`tasksHash\` e \`effectiveSpecHash\` vêm do pacote; alterações posteriores deixam o verdict stale.
425
436
  ---
426
437
  `;
427
438
 
@@ -434,8 +445,8 @@ It is NOT the author: fresh context, read-only, edits nothing.
434
445
  You are the independent verifier of a wendkeep change. You did not write this code — enter as if
435
446
  you'd never seen it. Read-only.
436
447
 
437
- Read the package \`08-Changes/<slug>/verificacao.json\` (requirements, tasks, evidence) and the
438
- living specs in \`07-Specs/<capability>.md\`.
448
+ Read only the self-contained \`08-Changes/<slug>/verificacao.json\` package (complete requirements,
449
+ tasks, evidence). Do not reopen \`07-Specs\`: it does not contain unarchived deltas yet.
439
450
 
440
451
  For each \`[req:ID]\`:
441
452
  1. Read the requirement's **acceptance criterion** in the spec — do NOT read the implementation first.
@@ -446,7 +457,7 @@ For each \`[req:ID]\`:
446
457
 
447
458
  Write \`08-Changes/<slug>/verdict.json\` in the shape of \`verdict-template.json\`. \`ok: false\` if any
448
459
  \`[req:]\` lacks discriminating coverage. Don't fix here — a gap becomes a fix task. \`tasksHash\`
449
- comes from the package (freshness seal; edit a task later and the gate rejects it as stale).
460
+ and \`effectiveSpecHash\` come from the package (freshness seals; later task/spec edits make verdict stale).
450
461
  ---
451
462
  `;
452
463