wendkeep 0.30.0 → 0.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +109 -0
- package/bin/wendkeep.mjs +4 -1
- package/hooks/brain-inject.mjs +19 -12
- package/hooks/change-context.mjs +61 -0
- package/hooks/change-core.mjs +143 -12
- package/hooks/change-guard.mjs +59 -0
- package/hooks/change-nag.mjs +31 -0
- package/hooks/change-warn.mjs +48 -0
- package/hooks/decision-capture.mjs +41 -2
- package/hooks/harness-doctor.mjs +4 -1
- package/hooks/plan-capture.mjs +168 -0
- package/hooks/session-stop.mjs +11 -1
- package/hooks/spec-core.mjs +73 -4
- package/hooks/subagent-usage.mjs +3 -1
- package/hooks/vault-health.mjs +6 -5
- package/package.json +1 -1
- package/src/change.mjs +48 -14
- package/src/init.mjs +56 -14
- package/src/skills-seed.mjs +56 -13
- package/src/sync-defs.mjs +8 -0
- package/src/taxonomy.mjs +35 -0
|
@@ -33,9 +33,20 @@ export function decisionKeyExists(dir, key) {
|
|
|
33
33
|
return false;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
//
|
|
36
|
+
// Claude Code >= 2.1 returns `{ answers: { Question: choice } }` in tool_response.
|
|
37
|
+
// Older releases expose a text tool_output: `... "Question"="chosen labels" "Q2"="..."`.
|
|
37
38
|
export function parseAnswers(output) {
|
|
38
39
|
const map = {};
|
|
40
|
+
if (output && typeof output === 'object') {
|
|
41
|
+
const answers = output.answers && typeof output.answers === 'object'
|
|
42
|
+
? output.answers
|
|
43
|
+
: output;
|
|
44
|
+
for (const [question, answer] of Object.entries(answers)) {
|
|
45
|
+
if (typeof answer === 'string') map[clean(question)] = clean(answer);
|
|
46
|
+
else if (Array.isArray(answer)) map[clean(question)] = answer.map(clean).filter(Boolean).join(',');
|
|
47
|
+
}
|
|
48
|
+
if (Object.keys(map).length) return map;
|
|
49
|
+
}
|
|
39
50
|
const re = /"([^"]+)"\s*=\s*"([^"]*)"/g;
|
|
40
51
|
let m;
|
|
41
52
|
while ((m = re.exec(String(output || '')))) map[m[1].trim()] = m[2].trim();
|
|
@@ -172,7 +183,35 @@ export function captureDecision(vaultBase, input) {
|
|
|
172
183
|
writeFileSync(filePath, buildDecisionCaptureNote({
|
|
173
184
|
questions, answers, dateStr, startedAt: formatLocalIso(now), sessionRel, provider, localeId: loc.id, adrNum, contentKey,
|
|
174
185
|
}), 'utf-8');
|
|
175
|
-
|
|
186
|
+
const rel = toVaultRelative(vaultBase, filePath);
|
|
187
|
+
if (sessionRel) {
|
|
188
|
+
try {
|
|
189
|
+
const sessionPath = join(vaultBase, sessionRel);
|
|
190
|
+
let session = readFileSync(sessionPath, 'utf8');
|
|
191
|
+
const wikilink = wikilinkFromRel(rel);
|
|
192
|
+
const link = `- ${wikilink}`;
|
|
193
|
+
if (!session.includes(wikilink)) {
|
|
194
|
+
const heading = '\n## Decisões geradas nesta sessão\n';
|
|
195
|
+
const at = session.indexOf(heading);
|
|
196
|
+
if (at !== -1) {
|
|
197
|
+
const bodyStart = at + heading.length;
|
|
198
|
+
const nextRel = session.slice(bodyStart).search(/\n## /);
|
|
199
|
+
const bodyEnd = nextRel === -1 ? session.length : bodyStart + nextRel;
|
|
200
|
+
const body = session.slice(bodyStart, bodyEnd)
|
|
201
|
+
.replace(/\n?Nenhuma decisão registrada ainda\.\s*/i, '\n')
|
|
202
|
+
.trim();
|
|
203
|
+
const merged = [body, link].filter(Boolean).join('\n');
|
|
204
|
+
session = `${session.slice(0, bodyStart)}\n${merged}\n\n${session.slice(bodyEnd).replace(/^\n+/, '')}`;
|
|
205
|
+
} else {
|
|
206
|
+
const anchor = session.indexOf('\n## Encerramento');
|
|
207
|
+
const section = `\n## Decisões geradas nesta sessão\n\n${link}\n`;
|
|
208
|
+
session = anchor === -1 ? `${session.trimEnd()}${section}` : `${session.slice(0, anchor).trimEnd()}${section}${session.slice(anchor)}`;
|
|
209
|
+
}
|
|
210
|
+
writeFileSync(sessionPath, session, 'utf8');
|
|
211
|
+
}
|
|
212
|
+
} catch { /* backlink auxiliar nunca derruba a captura */ }
|
|
213
|
+
}
|
|
214
|
+
return { rel, skipped: false };
|
|
176
215
|
}
|
|
177
216
|
|
|
178
217
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
package/hooks/harness-doctor.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { activeChange, parseTasks } from './change-core.mjs';
|
|
6
|
-
import { parseRequirements, parseSpecsList, parseDelta, evaluateVerdict } from './spec-core.mjs';
|
|
6
|
+
import { parseRequirements, parseSpecsList, parseDelta, evaluateVerdict, validateSpecImpact } from './spec-core.mjs';
|
|
7
7
|
import { getLocale } from './locale.mjs';
|
|
8
8
|
|
|
9
9
|
export function checkHarness(vaultBase, projectRoot) {
|
|
@@ -43,6 +43,9 @@ export function checkHarness(vaultBase, projectRoot) {
|
|
|
43
43
|
let entries;
|
|
44
44
|
try { entries = readdirSync(dir); } catch { continue; } // a file, not a change dir
|
|
45
45
|
if (!entries.includes('proposta.md')) { errors.push(`change sem proposta.md: ${name}`); continue; }
|
|
46
|
+
const impact = validateSpecImpact(dir);
|
|
47
|
+
errors.push(...impact.errors.map((e) => `${name}: ${e}`));
|
|
48
|
+
warnings.push(...impact.warnings.map((w) => `${name}: ${w}`));
|
|
46
49
|
if (name === active) {
|
|
47
50
|
try {
|
|
48
51
|
for (const cap of parseSpecsList(readFileSync(join(dir, 'proposta.md'), 'utf8'))) {
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// PostToolUse hook (matcher: ExitPlanMode). A ponte determinística entre o plan mode do Claude
|
|
3
|
+
// Code e o vault: quando o usuário APROVA um plano, o plano vira registro — anexado à change
|
|
4
|
+
// ativa, ou uma change nova criada e preenchida a partir dele (proposta do Contexto, design do
|
|
5
|
+
// corpo, tarefas dos checkboxes). Não depende de a LLM lembrar do processo. Rejeição = no-op.
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { createHash } from 'node:crypto';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { pathToFileURL } from 'node:url';
|
|
10
|
+
import {
|
|
11
|
+
findActiveSessionByTranscript,
|
|
12
|
+
formatDate,
|
|
13
|
+
getVaultBase,
|
|
14
|
+
readControl,
|
|
15
|
+
readHookInput,
|
|
16
|
+
slugify,
|
|
17
|
+
wikilinkFromRel,
|
|
18
|
+
writeHookOutput,
|
|
19
|
+
} from './obsidian-common.mjs';
|
|
20
|
+
import { activeChange, newChange } from './change-core.mjs';
|
|
21
|
+
import { getLocale } from './locale.mjs';
|
|
22
|
+
|
|
23
|
+
// O plano aprovado chega por um de três canais, conforme a versão do Claude Code:
|
|
24
|
+
// tool_input.plan (legado), o bloco "## Approved Plan" do tool_response, ou o arquivo de plano
|
|
25
|
+
// citado em "saved to: <path>". Rejeição (ou ausência de sinal de aprovação) → null.
|
|
26
|
+
export function extractPlan(input) {
|
|
27
|
+
const tr = input?.tool_response ?? input?.toolResponse ?? '';
|
|
28
|
+
const resp = typeof tr === 'string' ? tr : JSON.stringify(tr || '');
|
|
29
|
+
if (/doesn'?t want to proceed|user rejected|rejected the plan/i.test(resp)) return null;
|
|
30
|
+
const direct = input?.tool_input?.plan ?? input?.toolInput?.plan;
|
|
31
|
+
// Claude Code atual entrega o plano aprovado como objeto estruturado no PostToolUse.
|
|
32
|
+
// Esse evento só ocorre após sucesso; flags negativas explícitas continuam no-op.
|
|
33
|
+
if (tr && typeof tr === 'object') {
|
|
34
|
+
if (tr.approved === false || tr.rejected === true || tr.cancelled === true) return null;
|
|
35
|
+
const structured = tr.plan ?? direct;
|
|
36
|
+
if (structured && String(structured).trim()) return String(structured);
|
|
37
|
+
const structuredPath = tr.filePath ?? tr.planFilePath;
|
|
38
|
+
if (structuredPath) { try { return readFileSync(String(structuredPath), 'utf8'); } catch { /* inacessível */ } }
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
if (!/approved (your |the )?plan|Approved Plan/i.test(resp)) return null;
|
|
42
|
+
if (direct && String(direct).trim()) return String(direct);
|
|
43
|
+
const marker = resp.match(/## Approved Plan[^\n]*:\s*\n([\s\S]+)$/);
|
|
44
|
+
if (marker && marker[1].trim()) return marker[1].trim();
|
|
45
|
+
const path = resp.match(/saved to:\s*([^\n]+\.md)/i);
|
|
46
|
+
if (path) { try { return readFileSync(path[1].trim(), 'utf8'); } catch { /* arquivo inacessível */ } }
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function planIndex(slug) {
|
|
51
|
+
return `---\ntype: plan-index\ntags:\n - plano\n---\n\n# ${slug} — planos aprovados\n`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function persistApprovedPlan(dir, slug, rawNote, plan, dateStr) {
|
|
55
|
+
const hash = createHash('sha256').update(String(plan)).digest('hex').slice(0, 12);
|
|
56
|
+
const snapshotsDir = join(dir, 'planos');
|
|
57
|
+
mkdirSync(snapshotsDir, { recursive: true });
|
|
58
|
+
const snapshot = join(snapshotsDir, `${hash}.md`);
|
|
59
|
+
if (!existsSync(snapshot)) writeFileSync(snapshot, rawNote, 'utf8');
|
|
60
|
+
|
|
61
|
+
const indexPath = join(dir, 'plano-aprovado.md');
|
|
62
|
+
let index = existsSync(indexPath) ? readFileSync(indexPath, 'utf8') : planIndex(slug);
|
|
63
|
+
const entry = `- [[planos/${hash}|${dateStr} — ${hash}]]`;
|
|
64
|
+
if (!index.includes(`[[planos/${hash}|`)) {
|
|
65
|
+
index = `${index.trimEnd()}\n\n${entry}\n`;
|
|
66
|
+
writeFileSync(indexPath, index, 'utf8');
|
|
67
|
+
}
|
|
68
|
+
return { hash, snapshot };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function planSlug(plan) {
|
|
72
|
+
const h1 = String(plan || '').match(/^#\s+(.+)$/m);
|
|
73
|
+
return h1 ? slugify(h1[1], 'plano-aprovado', 60) : 'plano-aprovado';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function sectionBody(plan, headings) {
|
|
77
|
+
for (const h of headings) {
|
|
78
|
+
const m = String(plan).match(new RegExp(`^##\\s+${h}\\s*\\n([\\s\\S]*?)(?=\\n##\\s|$)`, 'm'));
|
|
79
|
+
if (m && m[1].trim()) return m[1].trim();
|
|
80
|
+
}
|
|
81
|
+
return '';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Checkboxes do plano viram tarefas numeradas 1.N (estado [x] preservado). [] quando não há.
|
|
85
|
+
export function planTasks(plan) {
|
|
86
|
+
const boxes = [...String(plan || '').matchAll(/^\s*-\s+\[( |x)\]\s+(.+)$/gm)];
|
|
87
|
+
return boxes.map((m, i) => `- [${m[1]}] 1.${i + 1} ${m[2].trim()}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function capturePlan(vaultBase, input) {
|
|
91
|
+
const plan = extractPlan(input);
|
|
92
|
+
if (!plan) return null;
|
|
93
|
+
const loc = getLocale(vaultBase);
|
|
94
|
+
const en = loc.id === 'en';
|
|
95
|
+
const dateStr = formatDate(new Date());
|
|
96
|
+
const rawNote = `---\ntype: plan\ndate: ${dateStr}\ntags:\n - plano\n---\n\n${plan.trim()}\n`;
|
|
97
|
+
const transcriptPath = input?.transcript_path ?? input?.transcriptPath ?? '';
|
|
98
|
+
const sessionRel = findActiveSessionByTranscript(vaultBase, transcriptPath)?.session_file
|
|
99
|
+
|| readControl(vaultBase).session_file
|
|
100
|
+
|| '';
|
|
101
|
+
|
|
102
|
+
const active = activeChange(vaultBase);
|
|
103
|
+
if (active) {
|
|
104
|
+
const dir = join(vaultBase, loc.folders.changes, active);
|
|
105
|
+
persistApprovedPlan(dir, active, rawNote, plan, dateStr);
|
|
106
|
+
return {
|
|
107
|
+
slug: active, created: false,
|
|
108
|
+
context: `<plan_captured>\n${en
|
|
109
|
+
? `Approved plan attached to the active change "${active}" (plano-aprovado.md). Sync tarefas.md with the plan's tasks.`
|
|
110
|
+
: `Plano aprovado anexado à change ativa "${active}" (plano-aprovado.md). Sincronize tarefas.md com as tarefas do plano.`}\n</plan_captured>`,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const slug = planSlug(plan);
|
|
115
|
+
newChange(vaultBase, slug, { dateStr, sessionRel });
|
|
116
|
+
const dir = join(vaultBase, loc.folders.changes, slug);
|
|
117
|
+
const ctx = sectionBody(plan, ['Contexto', 'Context']) || plan.trim().split('\n').slice(0, 6).join('\n');
|
|
118
|
+
const source = sessionRel ? `\n - "${wikilinkFromRel(sessionRel)}"` : ' []';
|
|
119
|
+
writeFileSync(join(dir, 'proposta.md'), `---
|
|
120
|
+
type: change
|
|
121
|
+
status: active
|
|
122
|
+
date: ${dateStr}
|
|
123
|
+
cssclasses:
|
|
124
|
+
- topic-change
|
|
125
|
+
tags:
|
|
126
|
+
- mudanca
|
|
127
|
+
source:${source}
|
|
128
|
+
spec_impact: pending
|
|
129
|
+
spec_impact_reason: ""
|
|
130
|
+
specs: []
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
# ${slug}
|
|
134
|
+
|
|
135
|
+
${en ? '## Why' : '## Por quê'}
|
|
136
|
+
|
|
137
|
+
${ctx}
|
|
138
|
+
|
|
139
|
+
${en ? '## What changes' : '## O que muda'}
|
|
140
|
+
|
|
141
|
+
${en ? 'See design.md and plano-aprovado.md (captured from the approved plan-mode plan).' : 'Ver design.md e plano-aprovado.md (capturados do plano aprovado no plan mode).'}
|
|
142
|
+
`, 'utf8');
|
|
143
|
+
writeFileSync(join(dir, 'design.md'), `# ${slug} — design\n\n${plan.trim()}\n`, 'utf8');
|
|
144
|
+
const tasks = planTasks(plan);
|
|
145
|
+
if (tasks.length) writeFileSync(join(dir, 'tarefas.md'), `# ${slug} — ${en ? 'tasks' : 'tarefas'}\n\n${tasks.join('\n')}\n`, 'utf8');
|
|
146
|
+
persistApprovedPlan(dir, slug, rawNote, plan, dateStr);
|
|
147
|
+
return {
|
|
148
|
+
slug, created: true,
|
|
149
|
+
context: `<plan_captured>\n${en
|
|
150
|
+
? `Approved plan recorded in the vault: change "${slug}" created and set active (${loc.folders.changes}/${slug}/). Review tarefas.md${tasks.length ? '' : ' — the plan had no checkboxes, fill the tasks'} and follow the a2 loop (wk-tdd per task, \`wendkeep change done <id>\`, \`wendkeep verify\`).`
|
|
151
|
+
: `Plano aprovado — change "${slug}" registrada no vault e ativa (${loc.folders.changes}/${slug}/). Revise tarefas.md${tasks.length ? '' : ' — o plano não tinha checkboxes, preencha as tarefas'} e siga o loop a2 (wk-tdd por tarefa, \`wendkeep change done <id>\`, \`wendkeep verify\`).`}\n</plan_captured>`,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
156
|
+
try {
|
|
157
|
+
const input = readHookInput();
|
|
158
|
+
const r = capturePlan(getVaultBase(input), input);
|
|
159
|
+
if (!r) { writeHookOutput({}); }
|
|
160
|
+
else writeHookOutput({ hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: r.context } });
|
|
161
|
+
} catch (error) {
|
|
162
|
+
process.stderr.write(`[wendkeep] plan-capture falhou: ${error.message}\n`);
|
|
163
|
+
writeHookOutput({ hookSpecificOutput: {
|
|
164
|
+
hookEventName: 'PostToolUse',
|
|
165
|
+
additionalContext: `<plan_capture_error>O plano aprovado não foi persistido: ${error.message}</plan_capture_error>`,
|
|
166
|
+
} });
|
|
167
|
+
}
|
|
168
|
+
}
|
package/hooks/session-stop.mjs
CHANGED
|
@@ -6,7 +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
|
+
import { activeChangeLink, pruneChangeSentinels } from './change-core.mjs';
|
|
10
10
|
import { getLocale } from './locale.mjs';
|
|
11
11
|
import { upsertSubagentUsage } from './subagent-usage.mjs';
|
|
12
12
|
import {
|
|
@@ -855,12 +855,19 @@ function replacePendingSection(content, pending) {
|
|
|
855
855
|
const end = content.indexOf(closingMarker, start + marker.length);
|
|
856
856
|
if (end === -1) return content;
|
|
857
857
|
|
|
858
|
+
// Preserva seções que outros writers inseriram dentro do span (## Subagents & Workflows,
|
|
859
|
+
// ## Progresso do plano, ## Mudanças…) — só o texto das Pendências em si é regenerado.
|
|
860
|
+
const span = content.slice(start + marker.length, end);
|
|
861
|
+
const innerIdx = span.indexOf('\n## ');
|
|
862
|
+
const preserved = innerIdx === -1 ? '' : span.slice(innerIdx).trimEnd();
|
|
863
|
+
|
|
858
864
|
return [
|
|
859
865
|
content.slice(0, start).trimEnd(),
|
|
860
866
|
'',
|
|
861
867
|
'## Pendências',
|
|
862
868
|
'',
|
|
863
869
|
formatPendingSection(pending),
|
|
870
|
+
...(preserved ? [preserved] : []),
|
|
864
871
|
content.slice(end),
|
|
865
872
|
].join('\n');
|
|
866
873
|
}
|
|
@@ -1135,6 +1142,9 @@ function main() {
|
|
|
1135
1142
|
process.stderr.write(`[wendkeep] brain index/digest falhou: ${error.message}\n`);
|
|
1136
1143
|
}
|
|
1137
1144
|
|
|
1145
|
+
// GC das sentinelas dos hooks de lifecycle (>7 dias) — fail-quiet, nunca derruba o Stop.
|
|
1146
|
+
try { pruneChangeSentinels(vaultBase); } catch { /* bônus */ }
|
|
1147
|
+
|
|
1138
1148
|
pingObsidianVault(input.obsidian_api_key);
|
|
1139
1149
|
writeHookOutput({});
|
|
1140
1150
|
}
|
package/hooks/spec-core.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// hooks/spec-core.mjs — living spec (07-Specs) + change delta merge (OpenSpec native).
|
|
2
2
|
// Pure parsing/merge + promoteSpecs (fs). No import from change-core (avoids a cycle).
|
|
3
3
|
import { createHash } from 'node:crypto';
|
|
4
|
-
import { readFileSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import { ensureDir } from './obsidian-common.mjs';
|
|
7
7
|
import { getLocale } from './locale.mjs';
|
|
@@ -87,6 +87,73 @@ export function parseSpecsList(propostaMd) {
|
|
|
87
87
|
return [];
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
function yamlScalar(text, key) {
|
|
91
|
+
const m = String(text).match(new RegExp(`^${key}:\\s*(.*)$`, 'm'));
|
|
92
|
+
return m ? m[1].trim().replace(/^['"]|['"]$/g, '') : '';
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function parseSpecImpact(propostaMd) {
|
|
96
|
+
const text = String(propostaMd || '');
|
|
97
|
+
const status = yamlScalar(text, 'spec_impact') || yamlScalar(text, 'spec-impact');
|
|
98
|
+
const reason = yamlScalar(text, 'spec_impact_reason') || yamlScalar(text, 'spec-impact-reason');
|
|
99
|
+
return { status, reason };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function validateSpecImpact(changeDir) {
|
|
103
|
+
let proposta = '';
|
|
104
|
+
try { proposta = readFileSync(join(changeDir, 'proposta.md'), 'utf8'); }
|
|
105
|
+
catch { return { ok: false, errors: ['proposta.md ausente'], warnings: [], status: '', listed: [], onDisk: [] }; }
|
|
106
|
+
|
|
107
|
+
const { status, reason } = parseSpecImpact(proposta);
|
|
108
|
+
const listed = parseSpecsList(proposta);
|
|
109
|
+
const onDisk = discoverSpecDeltas(changeDir);
|
|
110
|
+
const enforced = existsSync(join(changeDir, '.spec-impact-v1'));
|
|
111
|
+
const errors = [];
|
|
112
|
+
const warnings = [];
|
|
113
|
+
|
|
114
|
+
if (!status) {
|
|
115
|
+
if (enforced) errors.push('spec_impact ausente — classifique como required ou none');
|
|
116
|
+
else warnings.push('change legada sem spec_impact — migre para required ou none antes do próximo ciclo');
|
|
117
|
+
return { ok: errors.length === 0, errors, warnings, status: '', listed, onDisk, legacy: !enforced };
|
|
118
|
+
}
|
|
119
|
+
if (!['pending', 'required', 'none'].includes(status)) errors.push(`spec_impact inválido: ${status}`);
|
|
120
|
+
if (status === 'pending') errors.push('spec_impact pending — classifique o impacto antes de arquivar');
|
|
121
|
+
if (status === 'none') {
|
|
122
|
+
if (!reason) errors.push('spec_impact none exige justificativa em spec_impact_reason');
|
|
123
|
+
if (listed.length || onDisk.length) errors.push('spec_impact none contradiz specs/deltas declarados');
|
|
124
|
+
}
|
|
125
|
+
if (status === 'required') {
|
|
126
|
+
if (!listed.length) errors.push('spec_impact required exige ao menos uma capability em specs');
|
|
127
|
+
for (const cap of listed) if (!onDisk.includes(cap)) errors.push(`delta real ausente para capability ${cap}`);
|
|
128
|
+
for (const cap of onDisk) if (!listed.includes(cap)) errors.push(`delta ${cap} existe no disco mas não está listado em specs`);
|
|
129
|
+
}
|
|
130
|
+
return { ok: errors.length === 0, errors, warnings, status, reason, listed, onDisk, legacy: false };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Um delta ainda no estado do scaffold (só o requisito "(nome)"/"(name)", nada removido) não é
|
|
134
|
+
// contrato — a promoção o filtra. Um delta com REMOVED é sempre intenção real.
|
|
135
|
+
export function isPlaceholderDelta(md) {
|
|
136
|
+
const d = parseDelta(md);
|
|
137
|
+
if (d.removed.length) return false;
|
|
138
|
+
const all = [...d.added, ...d.modified];
|
|
139
|
+
if (!all.length) return true;
|
|
140
|
+
return all.every((r) => /^\((?:nome|name)\)$/.test(r.name));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Capabilities com delta REAL no disco (<change>/specs/<cap>/spec.md), independente do
|
|
144
|
+
// frontmatter `specs:` da proposta — o scaffold deixa `specs: []` e o buraco engolia deltas
|
|
145
|
+
// preenchidos mas não listados (visto em produção: 07-Specs vazio com delta real no disco).
|
|
146
|
+
export function discoverSpecDeltas(changeDir) {
|
|
147
|
+
let names = [];
|
|
148
|
+
try { names = readdirSync(join(changeDir, 'specs')); } catch { return []; }
|
|
149
|
+
const caps = [];
|
|
150
|
+
for (const cap of names) {
|
|
151
|
+
try { if (!isPlaceholderDelta(readFileSync(join(changeDir, 'specs', cap, 'spec.md'), 'utf8'))) caps.push(cap); }
|
|
152
|
+
catch { /* sem spec.md */ }
|
|
153
|
+
}
|
|
154
|
+
return caps;
|
|
155
|
+
}
|
|
156
|
+
|
|
90
157
|
// Merge each capability's delta (in the change) into the living spec in 07-Specs.
|
|
91
158
|
export function promoteSpecs(vaultBase, changeDir, specs, { changeWikilink, dateStr } = {}) {
|
|
92
159
|
const loc = getLocale(vaultBase);
|
|
@@ -94,9 +161,11 @@ export function promoteSpecs(vaultBase, changeDir, specs, { changeWikilink, date
|
|
|
94
161
|
const promoted = [];
|
|
95
162
|
const warnings = [];
|
|
96
163
|
for (const cap of specs) {
|
|
97
|
-
let
|
|
98
|
-
try {
|
|
99
|
-
catch {
|
|
164
|
+
let deltaMd;
|
|
165
|
+
try { deltaMd = readFileSync(join(changeDir, 'specs', cap, 'spec.md'), 'utf8'); }
|
|
166
|
+
catch { throw new Error(`delta ausente para ${cap}`); }
|
|
167
|
+
if (isPlaceholderDelta(deltaMd)) throw new Error(`delta placeholder para ${cap}`);
|
|
168
|
+
const delta = parseDelta(deltaMd);
|
|
100
169
|
const livePath = join(vaultBase, specsDir, `${cap}.md`);
|
|
101
170
|
let current = [];
|
|
102
171
|
try { current = parseRequirements(readFileSync(livePath, 'utf8')); } catch { /* nova capability */ }
|
package/hooks/subagent-usage.mjs
CHANGED
|
@@ -208,7 +208,9 @@ function upsertSection(content, heading, body) {
|
|
|
208
208
|
const esc = heading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
209
209
|
const re = new RegExp(`${esc}\\n[\\s\\S]*?(?=\\n## |$)`);
|
|
210
210
|
if (re.test(content)) return content.replace(re, `${body}\n`);
|
|
211
|
-
|
|
211
|
+
// Âncora ANTES de '## Pendências': o finalize do Stop reescreve o span Pendências→Encerramento
|
|
212
|
+
// inteiro, então qualquer seção inserida lá dentro seria apagada (visto em produção).
|
|
213
|
+
for (const anchor of ['\n## Pendências', '\n## Issues Linear', '\n## Encerramento']) {
|
|
212
214
|
const i = content.indexOf(anchor);
|
|
213
215
|
if (i >= 0) return `${content.slice(0, i)}\n\n${body}\n${content.slice(i)}`;
|
|
214
216
|
}
|
package/hooks/vault-health.mjs
CHANGED
|
@@ -71,14 +71,14 @@ function hasDefaultPending(content) {
|
|
|
71
71
|
.some((line) => DEFAULT_PENDING_PATTERNS.some((pattern) => pattern.test(line.trim())));
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
function usageSectionIsPlaced(content) {
|
|
74
|
+
function usageSectionIsPlaced(content, { active = false } = {}) {
|
|
75
75
|
const usage = content.indexOf('\n## Uso de tokens e custos');
|
|
76
76
|
if (usage === -1) return true;
|
|
77
77
|
const changed = content.indexOf('\n## Arquivos criados ou alterados');
|
|
78
78
|
const pending = content.indexOf('\n## Pendências');
|
|
79
79
|
const closing = content.indexOf('\n## Encerramento');
|
|
80
|
-
if (pending === -1 || closing === -1) return false;
|
|
81
|
-
return usage < pending && usage < closing && (changed === -1 || usage > changed);
|
|
80
|
+
if (pending === -1 || (!active && closing === -1)) return false;
|
|
81
|
+
return usage < pending && (active || usage < closing) && (changed === -1 || usage > changed);
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
function linkedNotesFromSession(content) {
|
|
@@ -102,14 +102,15 @@ function checkSession({ vaultBase, sessionRel, control, registry }) {
|
|
|
102
102
|
return { failures, warnings, metrics };
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
const content = readFileSync(sessionPath, 'utf-8');
|
|
105
|
+
const content = readFileSync(sessionPath, 'utf-8');
|
|
106
|
+
const activeSession = control.status === 'active' && control.session_file === sessionRel;
|
|
106
107
|
const duplicates = findDuplicateTurnMarkers(content);
|
|
107
108
|
metrics.turnMarkers = (content.match(/<!-- (?:wk-turn|codex-turn):/g) || []).length;
|
|
108
109
|
metrics.duplicateTurnMarkers = duplicates.length;
|
|
109
110
|
|
|
110
111
|
if (duplicates.length) failures.push(`Marcadores de turno duplicados: ${duplicates.join(', ')}`);
|
|
111
112
|
if (hasHeadingAfterClosing(content)) failures.push('Há headings/iterações após ## Encerramento.');
|
|
112
|
-
if (!usageSectionIsPlaced(content)) failures.push('## Uso de tokens e custos está fora da posição esperada.');
|
|
113
|
+
if (!usageSectionIsPlaced(content, { active: activeSession })) failures.push('## Uso de tokens e custos está fora da posição esperada.');
|
|
113
114
|
if (hasDefaultPending(content)) warnings.push('Pendências ainda contém placeholders padrão.');
|
|
114
115
|
|
|
115
116
|
const registryEntry = registry.sessions?.[control.session_id];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wendkeep",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.32.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
|
@@ -8,10 +8,11 @@ import {
|
|
|
8
8
|
parseTasks,
|
|
9
9
|
setTaskDone,
|
|
10
10
|
archiveChange,
|
|
11
|
+
abandonChange,
|
|
11
12
|
scaffoldPlaceholders,
|
|
12
13
|
} from '../hooks/change-core.mjs';
|
|
13
14
|
import { evaluateGate, requiredSensors } from '../hooks/sensors-core.mjs';
|
|
14
|
-
import { evaluateVerdict, tasksHashOf, parseSpecsList, parseDelta, parseRequirements, applyDelta } from '../hooks/spec-core.mjs';
|
|
15
|
+
import { evaluateVerdict, tasksHashOf, parseSpecsList, parseDelta, parseRequirements, applyDelta, validateSpecImpact } from '../hooks/spec-core.mjs';
|
|
15
16
|
import { getNextAdrNumber, readControl } from '../hooks/obsidian-common.mjs';
|
|
16
17
|
import { getLocale } from '../hooks/locale.mjs';
|
|
17
18
|
|
|
@@ -104,8 +105,8 @@ export function runChange(argv) {
|
|
|
104
105
|
const reqIds = [...new Set(tasks.map((t) => t.req).filter(Boolean))];
|
|
105
106
|
let verdict = null;
|
|
106
107
|
try { verdict = JSON.parse(readFileSync(join(dir, 'verdict.json'), 'utf8')); } catch { /* sem verdict */ }
|
|
107
|
-
if (!
|
|
108
|
-
else if (!
|
|
108
|
+
if (!verdict) process.stdout.write(`verdict: ausente — rode \`wendkeep verify --deep\`${reqIds.length ? ' + wk-verify' : ' (verdict trivial automático)'}\n`);
|
|
109
|
+
else if (!reqIds.length) process.stdout.write(`verdict: ${verdict.ok === true ? 'ok (trivial)' : 'não-ok — re-verifique'}\n`);
|
|
109
110
|
else {
|
|
110
111
|
const v = evaluateVerdict(verdict, reqIds, { tasksHash: tasksHashOf(tarefasMd) });
|
|
111
112
|
process.stdout.write(`verdict: ${v.ok ? 'ok' : v.stale ? 'stale — re-verifique' : `incompleto: falta ${v.missing.join(', ')}`}\n`);
|
|
@@ -156,12 +157,15 @@ export function runChange(argv) {
|
|
|
156
157
|
// Real gate (Pilar C): every sensor a task declared must be green in evidencia.json.
|
|
157
158
|
const gate = (dir) => {
|
|
158
159
|
// G0: um scaffold nunca preenchido não é uma mudança concluída — arquivar geraria um
|
|
159
|
-
// ADR falso.
|
|
160
|
-
//
|
|
160
|
+
// ADR falso. INESCAPÁVEL desde 0.31.0 (--force não pula — visto em produção: change
|
|
161
|
+
// 100% placeholder arquivada via --force mintou ADR falso). Saída legítima: abandon.
|
|
161
162
|
const placeholders = scaffoldPlaceholders(dir);
|
|
162
|
-
if (placeholders.length
|
|
163
|
-
return { ok: false, failing: [`scaffold não preenchido (${placeholders.join('; ')}) — preencha proposta/design/tarefas
|
|
163
|
+
if (placeholders.length) {
|
|
164
|
+
return { ok: false, failing: [`scaffold não preenchido (${placeholders.join('; ')}) — preencha proposta/design/tarefas antes de arquivar, ou \`wendkeep change abandon ${slug}\` se a change não vai adiante (--force não pula este check)`] };
|
|
164
165
|
}
|
|
166
|
+
const impact = validateSpecImpact(dir);
|
|
167
|
+
for (const warning of impact.warnings) process.stderr.write(`aviso spec: ${warning}\n`);
|
|
168
|
+
if (!impact.ok) return { ok: false, failing: impact.errors };
|
|
165
169
|
let tarefasMd = '';
|
|
166
170
|
try { tarefasMd = readFileSync(join(dir, 'tarefas.md'), 'utf8'); } catch { /* no tasks */ }
|
|
167
171
|
const tasks = parseTasks(tarefasMd);
|
|
@@ -185,17 +189,38 @@ export function runChange(argv) {
|
|
|
185
189
|
try { evidence = JSON.parse(readFileSync(join(dir, 'evidencia.json'), 'utf8')); } catch { /* no evidence */ }
|
|
186
190
|
const s = evaluateGate(evidence, required);
|
|
187
191
|
if (!s.ok) return s;
|
|
188
|
-
//
|
|
192
|
+
// Verdict SEMPRE exigido (0.31.0) — a exigência universal vive AQUI no gate; a semântica
|
|
193
|
+
// reqless→ok de evaluateVerdict (spec-core) não muda porque `verify --deep` e `change
|
|
194
|
+
// status` dependem dela. Change sem [req:] destrava com o auto-verdict do verify --deep.
|
|
189
195
|
let verdict = null;
|
|
190
196
|
try { verdict = JSON.parse(readFileSync(join(dir, 'verdict.json'), 'utf8')); } catch { /* none */ }
|
|
191
|
-
const
|
|
192
|
-
if (!
|
|
193
|
-
|
|
194
|
-
|
|
197
|
+
const hash = tasksHashOf(tarefasMd);
|
|
198
|
+
if (!verdict) {
|
|
199
|
+
return { ok: false, failing: [reqIds.length
|
|
200
|
+
? 'sem verdict — rode `wendkeep verify --deep` + skill wk-verify'
|
|
201
|
+
: 'sem verdict — rode `wendkeep verify --deep` (verdict trivial automático)'] };
|
|
202
|
+
}
|
|
203
|
+
if (verdict.ok !== true) return { ok: false, failing: ['verdict não-ok — re-verifique a change antes de arquivar'] };
|
|
204
|
+
if (verdict.tasksHash && verdict.tasksHash !== hash) {
|
|
205
|
+
return { ok: false, failing: [`verdict stale (tarefas.md mudou depois da verificação) — re-verifique: \`wendkeep verify --deep\`${reqIds.length ? ' + wk-verify' : ''}`] };
|
|
206
|
+
}
|
|
207
|
+
if (reqIds.length) {
|
|
208
|
+
const v = evaluateVerdict(verdict, reqIds, { tasksHash: hash });
|
|
209
|
+
if (!v.ok) {
|
|
210
|
+
if (v.stale) return { ok: false, failing: ['verdict stale (tarefas.md mudou depois da verificação) — re-verifique: `wendkeep verify --deep` + wk-verify'] };
|
|
211
|
+
return { ok: false, failing: [`verdict incompleto: falta ${v.missing.join(', ')}`] };
|
|
212
|
+
}
|
|
195
213
|
}
|
|
196
214
|
return { ok: true, failing: [] };
|
|
197
215
|
};
|
|
198
|
-
|
|
216
|
+
// Rastro auditável: forced só quando o --force de fato pulou G1 (tarefa aberta); trivial
|
|
217
|
+
// quando a change não declarou nenhuma prova ([req:]/[sensor:]).
|
|
218
|
+
let tasks = [];
|
|
219
|
+
try { tasks = parseTasks(readFileSync(join(vaultBase, getLocale(vaultBase).folders.changes, slug, 'tarefas.md'), 'utf8')); } catch { /* sem tarefas */ }
|
|
220
|
+
const forced = rest.includes('--force') && tasks.some((t) => !t.done);
|
|
221
|
+
const trivial = !tasks.some((t) => t.req) && !tasks.some((t) => t.sensor);
|
|
222
|
+
if (trivial) process.stderr.write('aviso: change trivial (sem [req:]/[sensor:]) — ADR marcado trivial: true\n');
|
|
223
|
+
const r = archiveChange(vaultBase, slug, { dateStr: today(), adrNum: getNextAdrNumber(vaultBase), gate, adrFlags: { forced, trivial } });
|
|
199
224
|
if (!r.ok) {
|
|
200
225
|
process.stderr.write(`change archive BLOCKED (gate): ${r.failing.join('; ')}\n`);
|
|
201
226
|
process.exit(1);
|
|
@@ -206,6 +231,15 @@ export function runChange(argv) {
|
|
|
206
231
|
process.exit(0);
|
|
207
232
|
}
|
|
208
233
|
|
|
209
|
-
|
|
234
|
+
if (sub === 'abandon') {
|
|
235
|
+
const slug = slugArg() || activeChange(vaultBase);
|
|
236
|
+
if (!slug) { process.stderr.write('wendkeep change abandon: missing <slug> and no active change\n'); process.exit(2); }
|
|
237
|
+
const r = abandonChange(vaultBase, slug, { dateStr: today() });
|
|
238
|
+
if (!r.ok) { process.stderr.write(`wendkeep change abandon: ${r.failing.join('; ')}\n`); process.exit(2); }
|
|
239
|
+
process.stdout.write(`abandoned: ${r.archivedRel}\n`);
|
|
240
|
+
process.exit(0);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
process.stderr.write(`wendkeep change: unknown subcommand "${sub}". Known: new, list, show, status, done, undone, diff, archive, abandon.\n`);
|
|
210
244
|
process.exit(2);
|
|
211
245
|
}
|