wendkeep 0.31.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 +44 -0
- package/hooks/brain-inject.mjs +2 -2
- package/hooks/change-core.mjs +29 -3
- package/hooks/decision-capture.mjs +41 -2
- package/hooks/harness-doctor.mjs +4 -1
- package/hooks/plan-capture.mjs +61 -9
- package/hooks/spec-core.mjs +49 -4
- package/hooks/vault-health.mjs +6 -5
- package/package.json +1 -1
- package/src/change.mjs +4 -1
- package/src/init.mjs +26 -5
- package/src/skills-seed.mjs +26 -0
- package/src/taxonomy.mjs +4 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,50 @@ All notable changes to **wendkeep** are documented here. Format based on
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this project follows
|
|
5
5
|
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [0.32.0] — 2026-07-09
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **Contrato explícito `spec_impact`** para changes novas: `pending`, `required` ou `none`.
|
|
12
|
+
Changes materiais (`required`) precisam listar a capability e manter um delta real em
|
|
13
|
+
`specs/<capability>/spec.md`; `none` exige justificativa em `spec_impact_reason`.
|
|
14
|
+
- **Snapshots imutáveis de planos aprovados** em `planos/<sha256-12>.md`, deduplicados por
|
|
15
|
+
conteúdo. `plano-aprovado.md` passa a ser o índice dos snapshots, sem sobrescrever planos
|
|
16
|
+
anteriores da mesma change.
|
|
17
|
+
- Diagnósticos de `spec_impact` no `wendkeep doctor`, incluindo estado pendente, delta ausente e
|
|
18
|
+
divergência entre `specs:` e o conteúdo real no disco.
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- Hooks Claude de alta frequência usam `node` com caminho ancorado em
|
|
23
|
+
`${CLAUDE_PROJECT_DIR}`. O `init` migra automaticamente os comandos relativos de 0.31.0 sem
|
|
24
|
+
duplicar grupos, inclusive quando o agente muda o `cwd` para um subprojeto.
|
|
25
|
+
- `wk-workflow`, `wk-brainstorming`, `wk-planning` e o roteador de SessionStart agora exigem a
|
|
26
|
+
classificação do impacto, o delta da capability e a rastreabilidade `[req:ID]` antes do archive.
|
|
27
|
+
- O archive passa a ser **fail-closed** para specs: placeholder, arquivo ausente ou falha de
|
|
28
|
+
promoção bloqueiam o move e a criação do ADR.
|
|
29
|
+
- Ao arquivar, links da sessão para a change ativa são reescritos para o caminho em `_arquivo`.
|
|
30
|
+
Decisões capturadas também entram imediatamente na seção de decisões da sessão ativa.
|
|
31
|
+
|
|
32
|
+
### Fixed
|
|
33
|
+
|
|
34
|
+
- **Planos aprovados descartados no Claude Code atual**: `plan-capture` agora lê
|
|
35
|
+
`tool_response.plan` no payload estruturado de `PostToolUse:ExitPlanMode`, mantendo os formatos
|
|
36
|
+
textuais legados e rejeições como no-op.
|
|
37
|
+
- **52 falhas `MODULE_NOT_FOUND` observadas em produção** quando `change-warn`/`change-guard`
|
|
38
|
+
rodavam a partir de `mobile-app` ou `backend-core`.
|
|
39
|
+
- A criação automática de change por plano aprovado agora preserva o backlink da sessão resolvido
|
|
40
|
+
pelo `transcript_path`/registry.
|
|
41
|
+
- `vault-health` não exige `## Encerramento` de uma sessão ainda ativa; continua validando a ordem
|
|
42
|
+
completa quando a sessão está finalizada.
|
|
43
|
+
|
|
44
|
+
### Migration
|
|
45
|
+
|
|
46
|
+
- Rode `wendkeep init --force` para migrar hooks relativos instalados pela 0.31.0.
|
|
47
|
+
- Rode `wendkeep sync-defs --reseed` para atualizar as skills wk-* em vaults existentes.
|
|
48
|
+
- Changes antigas sem `spec_impact` continuam legíveis e são diagnosticadas como legadas; antes do
|
|
49
|
+
próximo archive, classifique-as explicitamente como `required` ou `none`.
|
|
50
|
+
|
|
7
51
|
## [0.31.0] — 2026-07-09
|
|
8
52
|
|
|
9
53
|
### Added — enforcement do loop a2 (o loop deixa de ser opcional na prática)
|
package/hooks/brain-inject.mjs
CHANGED
|
@@ -21,7 +21,7 @@ function processRouter(localeId) {
|
|
|
21
21
|
'<wk_process>',
|
|
22
22
|
'Spec-driven process (mandatory for any non-trivial task): INVOKE the wk-workflow Skill BEFORE editing any file.',
|
|
23
23
|
'1. Plan: invoke the wk-brainstorming Skill (approved design) → wk-planning (task plan).',
|
|
24
|
-
'2. Record: `wendkeep change new <slug>` and FILL
|
|
24
|
+
'2. Record: `wendkeep change new <slug>` and FILL proposta/design/tasks. Resolve `spec_impact`: `required` needs `specs/<capability>/spec.md` + [req:ID]; `none` needs a reason. Never leave pending/placeholders.',
|
|
25
25
|
'3. Implement: wk-tdd per task; tick `- [x]` as you finish. Something broke? wk-debugging.',
|
|
26
26
|
'4. Close: `wendkeep verify` (+ `--deep` + the wk-verify Skill) → `wendkeep change archive`.',
|
|
27
27
|
'NEVER `archive --force` on your own — a red gate means pending work; --force is the user\'s call, not yours. Dead end? `wendkeep change abandon`.',
|
|
@@ -32,7 +32,7 @@ function processRouter(localeId) {
|
|
|
32
32
|
'<wk_process>',
|
|
33
33
|
'Processo spec-driven (obrigatório em tarefa não-trivial): INVOQUE a Skill wk-workflow ANTES de editar qualquer arquivo.',
|
|
34
34
|
'1. Planejar: invoque a Skill wk-brainstorming (design aprovado) → wk-planning (plano de tarefas).',
|
|
35
|
-
'2. Registrar: `wendkeep change new <slug>` e PREENCHA
|
|
35
|
+
'2. Registrar: `wendkeep change new <slug>` e PREENCHA proposta/design/tarefas. Resolva `spec_impact`: `required` exige `specs/<capability>/spec.md` + [req:ID]; `none` exige justificativa. Nunca deixe pending/placeholders.',
|
|
36
36
|
'3. Implementar: wk-tdd por tarefa; marque `- [x]` ao concluir. Quebrou algo? wk-debugging.',
|
|
37
37
|
'4. Fechar: `wendkeep verify` (+ `--deep` + Skill wk-verify) → `wendkeep change archive`.',
|
|
38
38
|
'PROIBIDO `archive --force` por conta própria — gate vermelho significa trabalho pendente; --force é decisão do usuário, não sua. Beco sem saída? `wendkeep change abandon`.',
|
package/hooks/change-core.mjs
CHANGED
|
@@ -14,9 +14,13 @@ export function changeDirRel(slug, vaultBase) {
|
|
|
14
14
|
return join(getLocale(vaultBase).folders.changes, slug);
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
export function renderChangeScaffold({ slug, sessionRel, dateStr, locale = 'pt-BR' }) {
|
|
17
|
+
export function renderChangeScaffold({ slug, sessionRel, dateStr, locale = 'pt-BR', simple = false }) {
|
|
18
18
|
const en = locale === 'en';
|
|
19
19
|
const source = sessionRel ? `\n - "${wikilinkFromRel(sessionRel)}"` : ' []';
|
|
20
|
+
const impact = simple ? 'none' : 'pending';
|
|
21
|
+
const impactReason = simple
|
|
22
|
+
? (en ? 'Simple change with no product-contract impact.' : 'Mudança simples sem impacto no contrato do produto.')
|
|
23
|
+
: '';
|
|
20
24
|
const proposta = `---
|
|
21
25
|
type: change
|
|
22
26
|
status: active
|
|
@@ -26,6 +30,8 @@ cssclasses:
|
|
|
26
30
|
tags:
|
|
27
31
|
- mudanca
|
|
28
32
|
source:${source}
|
|
33
|
+
spec_impact: ${impact}
|
|
34
|
+
spec_impact_reason: ${JSON.stringify(impactReason)}
|
|
29
35
|
specs: []
|
|
30
36
|
---
|
|
31
37
|
|
|
@@ -96,13 +102,14 @@ export function newChange(vaultBase, slug, { sessionRel = '', dateStr, simple =
|
|
|
96
102
|
const dir = join(vaultBase, loc.folders.changes, slug);
|
|
97
103
|
const existed = existsSync(join(dir, 'proposta.md'));
|
|
98
104
|
mkdirSync(dir, { recursive: true });
|
|
99
|
-
const files = renderChangeScaffold({ slug, sessionRel, dateStr, locale: loc.id });
|
|
105
|
+
const files = renderChangeScaffold({ slug, sessionRel, dateStr, locale: loc.id, simple });
|
|
100
106
|
const write = (name, content) => {
|
|
101
107
|
const f = join(dir, name);
|
|
102
108
|
if (!existsSync(f)) writeFileSync(f, content, 'utf8');
|
|
103
109
|
};
|
|
104
110
|
write('proposta.md', files.proposta);
|
|
105
111
|
write('tarefas.md', files.tarefas);
|
|
112
|
+
if (!existed) write('.spec-impact-v1', '1\n');
|
|
106
113
|
// Auto-sizing (Wave B): a --simple change skips the design + spec-delta scaffold.
|
|
107
114
|
if (!simple) {
|
|
108
115
|
write('design.md', files.design);
|
|
@@ -291,6 +298,12 @@ export function archiveChange(vaultBase, slug, { gate = gateGreen, dateStr, adrN
|
|
|
291
298
|
const loc = getLocale(vaultBase);
|
|
292
299
|
const chDir = loc.folders.changes;
|
|
293
300
|
const src = join(vaultBase, chDir, slug);
|
|
301
|
+
let sourceSessionRel = '';
|
|
302
|
+
try {
|
|
303
|
+
const proposal = readFileSync(join(src, 'proposta.md'), 'utf8');
|
|
304
|
+
const m = proposal.match(/\[\[((?:02-Sessões|02-Sessions)\/[^\]|]+?)(?:\|[^\]]+)?\]\]/);
|
|
305
|
+
if (m) sourceSessionRel = m[1].endsWith('.md') ? m[1] : `${m[1]}.md`;
|
|
306
|
+
} catch { /* sem source */ }
|
|
294
307
|
const verdict = gate(src);
|
|
295
308
|
if (!verdict.ok) return { ok: false, failing: verdict.failing || [] };
|
|
296
309
|
|
|
@@ -324,7 +337,9 @@ export function archiveChange(vaultBase, slug, { gate = gateGreen, dateStr, adrN
|
|
|
324
337
|
...res.warnings,
|
|
325
338
|
];
|
|
326
339
|
}
|
|
327
|
-
} catch {
|
|
340
|
+
} catch (error) {
|
|
341
|
+
return { ok: false, failing: [`falha ao promover specs: ${error.message}`] };
|
|
342
|
+
}
|
|
328
343
|
|
|
329
344
|
let reqIds = [];
|
|
330
345
|
try { reqIds = [...new Set(parseTasks(readFileSync(join(src, 'tarefas.md'), 'utf8')).map((t) => t.req).filter(Boolean))]; } catch { /* sem tarefas */ }
|
|
@@ -343,6 +358,17 @@ export function archiveChange(vaultBase, slug, { gate = gateGreen, dateStr, adrN
|
|
|
343
358
|
writeFileSync(pp, c, 'utf8');
|
|
344
359
|
} catch { /* proposta ilegível — segue */ }
|
|
345
360
|
|
|
361
|
+
// A sessão guardava o link da change ativa; após o move, reescreva para o caminho arquivado.
|
|
362
|
+
if (sourceSessionRel) {
|
|
363
|
+
try {
|
|
364
|
+
const sessionPath = join(vaultBase, sourceSessionRel);
|
|
365
|
+
const oldLink = wikilinkFromRel(join(chDir, slug, 'proposta'));
|
|
366
|
+
const archivedLink = wikilinkFromRel(join(destRel, 'proposta'));
|
|
367
|
+
const current = readFileSync(sessionPath, 'utf8');
|
|
368
|
+
if (current.includes(oldLink)) writeFileSync(sessionPath, current.replaceAll(oldLink, archivedLink), 'utf8');
|
|
369
|
+
} catch { /* backlink é reparo auxiliar; archive já está íntegro */ }
|
|
370
|
+
}
|
|
371
|
+
|
|
346
372
|
// ADR goes in the same dated month folder as session-derived decisions (04-Decisões/ano/MM-MMM/)
|
|
347
373
|
// — not the year root — so all ADRs sit together in the vault's convention.
|
|
348
374
|
const adrDirRel = monthFolderRelFromDateStr(loc.folders.decisions, dateStr, vaultBase);
|
|
@@ -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'))) {
|
package/hooks/plan-capture.mjs
CHANGED
|
@@ -3,10 +3,20 @@
|
|
|
3
3
|
// Code e o vault: quando o usuário APROVA um plano, o plano vira registro — anexado à change
|
|
4
4
|
// ativa, ou uma change nova criada e preenchida a partir dele (proposta do Contexto, design do
|
|
5
5
|
// corpo, tarefas dos checkboxes). Não depende de a LLM lembrar do processo. Rejeição = no-op.
|
|
6
|
-
import { readFileSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { createHash } from 'node:crypto';
|
|
7
8
|
import { join } from 'node:path';
|
|
8
9
|
import { pathToFileURL } from 'node:url';
|
|
9
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
findActiveSessionByTranscript,
|
|
12
|
+
formatDate,
|
|
13
|
+
getVaultBase,
|
|
14
|
+
readControl,
|
|
15
|
+
readHookInput,
|
|
16
|
+
slugify,
|
|
17
|
+
wikilinkFromRel,
|
|
18
|
+
writeHookOutput,
|
|
19
|
+
} from './obsidian-common.mjs';
|
|
10
20
|
import { activeChange, newChange } from './change-core.mjs';
|
|
11
21
|
import { getLocale } from './locale.mjs';
|
|
12
22
|
|
|
@@ -17,8 +27,18 @@ export function extractPlan(input) {
|
|
|
17
27
|
const tr = input?.tool_response ?? input?.toolResponse ?? '';
|
|
18
28
|
const resp = typeof tr === 'string' ? tr : JSON.stringify(tr || '');
|
|
19
29
|
if (/doesn'?t want to proceed|user rejected|rejected the plan/i.test(resp)) return null;
|
|
20
|
-
if (!/approved (your |the )?plan|Approved Plan/i.test(resp)) return null;
|
|
21
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;
|
|
22
42
|
if (direct && String(direct).trim()) return String(direct);
|
|
23
43
|
const marker = resp.match(/## Approved Plan[^\n]*:\s*\n([\s\S]+)$/);
|
|
24
44
|
if (marker && marker[1].trim()) return marker[1].trim();
|
|
@@ -27,6 +47,27 @@ export function extractPlan(input) {
|
|
|
27
47
|
return null;
|
|
28
48
|
}
|
|
29
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
|
+
|
|
30
71
|
export function planSlug(plan) {
|
|
31
72
|
const h1 = String(plan || '').match(/^#\s+(.+)$/m);
|
|
32
73
|
return h1 ? slugify(h1[1], 'plano-aprovado', 60) : 'plano-aprovado';
|
|
@@ -53,11 +94,15 @@ export function capturePlan(vaultBase, input) {
|
|
|
53
94
|
const en = loc.id === 'en';
|
|
54
95
|
const dateStr = formatDate(new Date());
|
|
55
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
|
+
|| '';
|
|
56
101
|
|
|
57
102
|
const active = activeChange(vaultBase);
|
|
58
103
|
if (active) {
|
|
59
104
|
const dir = join(vaultBase, loc.folders.changes, active);
|
|
60
|
-
|
|
105
|
+
persistApprovedPlan(dir, active, rawNote, plan, dateStr);
|
|
61
106
|
return {
|
|
62
107
|
slug: active, created: false,
|
|
63
108
|
context: `<plan_captured>\n${en
|
|
@@ -67,9 +112,10 @@ export function capturePlan(vaultBase, input) {
|
|
|
67
112
|
}
|
|
68
113
|
|
|
69
114
|
const slug = planSlug(plan);
|
|
70
|
-
newChange(vaultBase, slug, { dateStr });
|
|
115
|
+
newChange(vaultBase, slug, { dateStr, sessionRel });
|
|
71
116
|
const dir = join(vaultBase, loc.folders.changes, slug);
|
|
72
117
|
const ctx = sectionBody(plan, ['Contexto', 'Context']) || plan.trim().split('\n').slice(0, 6).join('\n');
|
|
118
|
+
const source = sessionRel ? `\n - "${wikilinkFromRel(sessionRel)}"` : ' []';
|
|
73
119
|
writeFileSync(join(dir, 'proposta.md'), `---
|
|
74
120
|
type: change
|
|
75
121
|
status: active
|
|
@@ -78,7 +124,9 @@ cssclasses:
|
|
|
78
124
|
- topic-change
|
|
79
125
|
tags:
|
|
80
126
|
- mudanca
|
|
81
|
-
source
|
|
127
|
+
source:${source}
|
|
128
|
+
spec_impact: pending
|
|
129
|
+
spec_impact_reason: ""
|
|
82
130
|
specs: []
|
|
83
131
|
---
|
|
84
132
|
|
|
@@ -95,7 +143,7 @@ ${en ? 'See design.md and plano-aprovado.md (captured from the approved plan-mod
|
|
|
95
143
|
writeFileSync(join(dir, 'design.md'), `# ${slug} — design\n\n${plan.trim()}\n`, 'utf8');
|
|
96
144
|
const tasks = planTasks(plan);
|
|
97
145
|
if (tasks.length) writeFileSync(join(dir, 'tarefas.md'), `# ${slug} — ${en ? 'tasks' : 'tarefas'}\n\n${tasks.join('\n')}\n`, 'utf8');
|
|
98
|
-
|
|
146
|
+
persistApprovedPlan(dir, slug, rawNote, plan, dateStr);
|
|
99
147
|
return {
|
|
100
148
|
slug, created: true,
|
|
101
149
|
context: `<plan_captured>\n${en
|
|
@@ -110,7 +158,11 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
|
|
|
110
158
|
const r = capturePlan(getVaultBase(input), input);
|
|
111
159
|
if (!r) { writeHookOutput({}); }
|
|
112
160
|
else writeHookOutput({ hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: r.context } });
|
|
113
|
-
} catch {
|
|
114
|
-
|
|
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
|
+
} });
|
|
115
167
|
}
|
|
116
168
|
}
|
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, readdirSync, 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,49 @@ 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
|
+
|
|
90
133
|
// Um delta ainda no estado do scaffold (só o requisito "(nome)"/"(name)", nada removido) não é
|
|
91
134
|
// contrato — a promoção o filtra. Um delta com REMOVED é sempre intenção real.
|
|
92
135
|
export function isPlaceholderDelta(md) {
|
|
@@ -118,9 +161,11 @@ export function promoteSpecs(vaultBase, changeDir, specs, { changeWikilink, date
|
|
|
118
161
|
const promoted = [];
|
|
119
162
|
const warnings = [];
|
|
120
163
|
for (const cap of specs) {
|
|
121
|
-
let
|
|
122
|
-
try {
|
|
123
|
-
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);
|
|
124
169
|
const livePath = join(vaultBase, specsDir, `${cap}.md`);
|
|
125
170
|
let current = [];
|
|
126
171
|
try { current = parseRequirements(readFileSync(livePath, 'utf8')); } catch { /* nova capability */ }
|
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
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
scaffoldPlaceholders,
|
|
13
13
|
} from '../hooks/change-core.mjs';
|
|
14
14
|
import { evaluateGate, requiredSensors } from '../hooks/sensors-core.mjs';
|
|
15
|
-
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';
|
|
16
16
|
import { getNextAdrNumber, readControl } from '../hooks/obsidian-common.mjs';
|
|
17
17
|
import { getLocale } from '../hooks/locale.mjs';
|
|
18
18
|
|
|
@@ -163,6 +163,9 @@ export function runChange(argv) {
|
|
|
163
163
|
if (placeholders.length) {
|
|
164
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)`] };
|
|
165
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 };
|
|
166
169
|
let tarefasMd = '';
|
|
167
170
|
try { tarefasMd = readFileSync(join(dir, 'tarefas.md'), 'utf8'); } catch { /* no tasks */ }
|
|
168
171
|
const tasks = parseTasks(tarefasMd);
|
package/src/init.mjs
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
mcpServerEntry,
|
|
16
16
|
hookCommand,
|
|
17
17
|
hookCommandLocal,
|
|
18
|
+
hookCommandLocalLegacy,
|
|
18
19
|
deriveVaultDirName,
|
|
19
20
|
selectableCompanions,
|
|
20
21
|
resolveCompanions,
|
|
@@ -105,6 +106,16 @@ export function hookCommandFor(name, projectPath) {
|
|
|
105
106
|
return hookCommand(name);
|
|
106
107
|
}
|
|
107
108
|
|
|
109
|
+
function localHookAvailable(name, projectPath) {
|
|
110
|
+
try {
|
|
111
|
+
return !!projectPath && existsSync(join(projectPath, 'node_modules', 'wendkeep', 'hooks', `${name}.mjs`));
|
|
112
|
+
} catch { return false; }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function localHookArg(name) {
|
|
116
|
+
return `${'${CLAUDE_PROJECT_DIR}'}/node_modules/wendkeep/hooks/${name}.mjs`;
|
|
117
|
+
}
|
|
118
|
+
|
|
108
119
|
export function mergeSettings(existing, { vaultPath, withMcp, force, companions = [], skipMcp = [], dotcontextHookLevel = 'full', projectPath = '' }) {
|
|
109
120
|
const s = existing && typeof existing === 'object' ? { ...existing } : {};
|
|
110
121
|
s.hooks = { ...(s.hooks || {}) };
|
|
@@ -120,19 +131,26 @@ export function mergeSettings(existing, { vaultPath, withMcp, force, companions
|
|
|
120
131
|
...companionHookSpecs(companions, { dotcontextHookLevel }),
|
|
121
132
|
].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
|
122
133
|
for (const h of allSpecs) {
|
|
123
|
-
const
|
|
134
|
+
const useLocal = !h.command && h.preferLocal && localHookAvailable(h.name, projectPath);
|
|
135
|
+
const command = h.command ?? (useLocal ? 'node' : hookCommand(h.name));
|
|
136
|
+
const args = useLocal ? [localHookArg(h.name)] : undefined;
|
|
124
137
|
// Dual-recognition: um hook nomeado é reconhecido tanto na forma npx quanto na node-direta,
|
|
125
138
|
// para que trocar a forma preferida (ou re-initar noutra máquina) nunca duplique o grupo.
|
|
126
|
-
const candidates = h.command ? [h.command] : [hookCommand(h.name), hookCommandLocal(h.name)];
|
|
139
|
+
const candidates = h.command ? [h.command] : [hookCommand(h.name), hookCommandLocal(h.name), hookCommandLocalLegacy(h.name)];
|
|
140
|
+
const ownsHook = (x) => candidates.includes(x.command)
|
|
141
|
+
|| (x.command === 'node' && Array.isArray(x.args) && x.args[0] === localHookArg(h.name));
|
|
127
142
|
const groups = Array.isArray(s.hooks[h.event]) ? [...s.hooks[h.event]] : [];
|
|
128
|
-
const owning = groups.find((g) => (g.hooks || []).some(
|
|
143
|
+
const owning = groups.find((g) => (g.hooks || []).some(ownsHook));
|
|
129
144
|
if (owning) {
|
|
130
145
|
// Already wired: never add a duplicate group (the old `if (present && !force)` fell through
|
|
131
146
|
// under --force and appended a second identical group). Under --force, refresh the managed
|
|
132
147
|
// entry's fields in place — without disturbing any sibling hooks the user grouped with it.
|
|
133
|
-
|
|
134
|
-
|
|
148
|
+
const hk = owning.hooks.find(ownsHook);
|
|
149
|
+
const brokenRelative = hk?.command === hookCommandLocalLegacy(h.name);
|
|
150
|
+
if (force || brokenRelative) {
|
|
135
151
|
hk.command = command;
|
|
152
|
+
if (args) hk.args = args;
|
|
153
|
+
else delete hk.args;
|
|
136
154
|
hk.timeout = h.timeout;
|
|
137
155
|
if (h.statusMessage) hk.statusMessage = h.statusMessage;
|
|
138
156
|
if (h.matcher && (owning.hooks || []).length === 1) owning.matcher = h.matcher;
|
|
@@ -141,6 +159,7 @@ export function mergeSettings(existing, { vaultPath, withMcp, force, companions
|
|
|
141
159
|
continue;
|
|
142
160
|
}
|
|
143
161
|
const entry = { type: 'command', command, timeout: h.timeout, statusMessage: h.statusMessage };
|
|
162
|
+
if (args) entry.args = args;
|
|
144
163
|
const group = h.matcher ? { matcher: h.matcher, hooks: [entry] } : { hooks: [entry] };
|
|
145
164
|
groups.push(group);
|
|
146
165
|
s.hooks[h.event] = groups;
|
|
@@ -530,6 +549,8 @@ export async function runInit(argv) {
|
|
|
530
549
|
log(M.mcpSkipped);
|
|
531
550
|
}
|
|
532
551
|
|
|
552
|
+
log(' [!] ignore runtime do wendkeep no Git quando o vault for versionado: .brain/.change-*');
|
|
553
|
+
|
|
533
554
|
// 4. Vault color system (.obsidian) -----------------------------------------
|
|
534
555
|
if (args.noColors) {
|
|
535
556
|
log(M.colorsSkipped);
|
package/src/skills-seed.mjs
CHANGED
|
@@ -31,6 +31,11 @@ vault cego. Exceção única: mudança trivial (typo, 1 linha).
|
|
|
31
31
|
- \`tarefas.md\` — a lista de tarefas \`- [ ] N.N descrição\`.
|
|
32
32
|
A mudança vira a *ativa* (ponteiro \`.brain/CURRENT_CHANGE.md\`) e é injetada no
|
|
33
33
|
próximo SessionStart, então você retoma o trabalho em curso automaticamente.
|
|
34
|
+
Antes de implementar, resolva \`spec_impact\` na proposta:
|
|
35
|
+
- \`required\`: liste a capability em \`specs:\` e preencha
|
|
36
|
+
\`specs/<capability>/spec.md\` com ADDED/MODIFIED/REMOVED; ligue tarefas com \`[req:ID]\`.
|
|
37
|
+
- \`none\`: registre uma justificativa real em \`spec_impact_reason\`.
|
|
38
|
+
\`pending\` nunca é estado pronto para implementação ou archive.
|
|
34
39
|
3. **Apply** — implemente cada tarefa de \`tarefas.md\` com disciplina **wk-tdd**
|
|
35
40
|
(teste vermelho antes do código). Marque \`- [x]\` ao concluir. Declare nas tarefas:
|
|
36
41
|
- \`[sensor:<id>]\` — a prova automatizada (roda no verify).
|
|
@@ -139,6 +144,7 @@ qualquer código.
|
|
|
139
144
|
Antes de fechar o design, resolva cada zona cinza: decide com o usuário, ou registra como
|
|
140
145
|
**assumption assinada** ("assumo X porque Y — corrija se errado"). Cinza declinado pelo
|
|
141
146
|
usuário é *registrado*, não descartado no silêncio. Nada sai do design silenciosamente ambíguo.
|
|
147
|
+
Declare também a capability e se o design tem \`spec_impact: required\` ou \`none\`.
|
|
142
148
|
|
|
143
149
|
## Tabela out-of-scope
|
|
144
150
|
|
|
@@ -170,6 +176,10 @@ Mapeie os arquivos: quais criar/modificar e a responsabilidade de cada um. Arqui
|
|
|
170
176
|
mudam juntos ficam juntos; um arquivo, uma responsabilidade. É aqui que a decomposição
|
|
171
177
|
trava.
|
|
172
178
|
|
|
179
|
+
Resolva o contrato antes de decompor: \`spec_impact: required\` exige capability + delta real em
|
|
180
|
+
\`specs/<capability>/spec.md\`; \`spec_impact: none\` exige justificativa. Cada comportamento do
|
|
181
|
+
delta recebe ID e as tarefas correspondentes usam \`[req:ID]\`.
|
|
182
|
+
|
|
173
183
|
## Tarefas bite-sized (TDD)
|
|
174
184
|
|
|
175
185
|
Cada tarefa termina num entregável testável de forma independente. Cada passo é uma ação
|
|
@@ -240,6 +250,9 @@ leaves the vault blind. Single exception: a trivial change (typo, one line).
|
|
|
240
250
|
2. **Propose** — \`wendkeep change new <slug>\` scaffolds \`08-Changes/<slug>/\`
|
|
241
251
|
(proposta/design/tarefas + a \`specs/\` delta). The change becomes *active* and is
|
|
242
252
|
injected at the next SessionStart.
|
|
253
|
+
Before implementation, resolve \`spec_impact\`: \`required\` needs the capability listed in
|
|
254
|
+
\`specs:\` plus a real \`specs/<capability>/spec.md\` delta and \`[req:ID]\` links; \`none\`
|
|
255
|
+
needs a real \`spec_impact_reason\`. \`pending\` is never ready for implementation/archive.
|
|
243
256
|
3. **Apply** — implement each task in tarefas.md with **wk-tdd** (red test first). Tag tasks:
|
|
244
257
|
\`[sensor:<id>]\` (automated proof) and \`[req:<ID>]\` (the spec requirement it satisfies).
|
|
245
258
|
4. **Verify** — \`wendkeep verify\` runs the sensors; then \`wendkeep verify --deep\` builds
|
|
@@ -310,6 +323,7 @@ Use it when the idea is still vague. Turn it into an approved design BEFORE writ
|
|
|
310
323
|
## Closure gate — no dangling ambiguity
|
|
311
324
|
Resolve every gray area: decide with the user, or log a **signed-off assumption** ("assuming X
|
|
312
325
|
because Y — correct me if wrong"). A declined gray area is recorded, not silently dropped.
|
|
326
|
+
Also declare the capability and whether the design has \`spec_impact: required\` or \`none\`.
|
|
313
327
|
|
|
314
328
|
## Out-of-scope table
|
|
315
329
|
List explicitly what the change does **not** do. Undeclared scope becomes creep.
|
|
@@ -331,6 +345,10 @@ Use it after an approved design. Produce a plan an engineer with no project cont
|
|
|
331
345
|
Map the files: what to create/modify and each one's responsibility. Files that change together
|
|
332
346
|
live together; one file, one responsibility.
|
|
333
347
|
|
|
348
|
+
Resolve the contract first: \`spec_impact: required\` needs a capability and a real delta at
|
|
349
|
+
\`specs/<capability>/spec.md\`; \`spec_impact: none\` needs a reason. Give each behaviour an ID
|
|
350
|
+
and link the corresponding tasks with \`[req:ID]\`.
|
|
351
|
+
|
|
334
352
|
## Bite-sized tasks (TDD)
|
|
335
353
|
Each task ends in an independently testable deliverable. Each step is a 2–5 min action:
|
|
336
354
|
write the failing test (show code) → run and see it fail (exact command + expected) → minimal
|
|
@@ -434,6 +452,10 @@ comes from the package (freshness seal; edit a task later and the gate rejects i
|
|
|
434
452
|
|
|
435
453
|
const PLAN_TEMPLATE_PT = `# Template — plano de tarefas (TDD, bite-sized)
|
|
436
454
|
|
|
455
|
+
## Impacto em specs
|
|
456
|
+
- \`spec_impact\`: \`required\` | \`none\`
|
|
457
|
+
- Capability/delta: \`specs/<capability>/spec.md\` ou justificativa de \`none\`
|
|
458
|
+
|
|
437
459
|
## Arquivos
|
|
438
460
|
- Criar: \`caminho/exato.mjs\`
|
|
439
461
|
- Modificar: \`caminho/existente.mjs:120-140\`
|
|
@@ -457,6 +479,10 @@ tarefas (uma função é \`x()\` em toda parte). Cada tarefa termina num entreg
|
|
|
457
479
|
|
|
458
480
|
const PLAN_TEMPLATE_EN = `# Template — task plan (TDD, bite-sized)
|
|
459
481
|
|
|
482
|
+
## Spec impact
|
|
483
|
+
- \`spec_impact\`: \`required\` | \`none\`
|
|
484
|
+
- Capability/delta: \`specs/<capability>/spec.md\` or the \`none\` rationale
|
|
485
|
+
|
|
460
486
|
## Files
|
|
461
487
|
- Create: \`exact/path.mjs\`
|
|
462
488
|
- Modify: \`exact/existing.mjs:120-140\`
|
package/src/taxonomy.mjs
CHANGED
|
@@ -121,6 +121,10 @@ export function hookCommand(name) {
|
|
|
121
121
|
// de segundos no Windows). Usada pelos hooks de ALTA FREQUÊNCIA (por prompt / por tool-call)
|
|
122
122
|
// quando o projeto tem wendkeep instalado localmente; o init decide (hookCommandFor).
|
|
123
123
|
export function hookCommandLocal(name) {
|
|
124
|
+
return `node "${'${CLAUDE_PROJECT_DIR}'}/node_modules/wendkeep/hooks/${name}.mjs"`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function hookCommandLocalLegacy(name) {
|
|
124
128
|
return `node node_modules/wendkeep/hooks/${name}.mjs`;
|
|
125
129
|
}
|
|
126
130
|
|