wendkeep 0.30.0 → 0.31.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 +65 -0
- package/bin/wendkeep.mjs +4 -1
- package/hooks/brain-inject.mjs +17 -10
- package/hooks/change-context.mjs +61 -0
- package/hooks/change-core.mjs +115 -10
- package/hooks/change-guard.mjs +59 -0
- package/hooks/change-nag.mjs +31 -0
- package/hooks/change-warn.mjs +48 -0
- package/hooks/plan-capture.mjs +116 -0
- package/hooks/session-stop.mjs +11 -1
- package/hooks/spec-core.mjs +25 -1
- package/hooks/subagent-usage.mjs +3 -1
- package/package.json +1 -1
- package/src/change.mjs +44 -13
- package/src/init.mjs +34 -13
- package/src/skills-seed.mjs +30 -13
- package/src/sync-defs.mjs +8 -0
- package/src/taxonomy.mjs +31 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,71 @@ 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.31.0] — 2026-07-09
|
|
8
|
+
|
|
9
|
+
### Added — enforcement do loop a2 (o loop deixa de ser opcional na prática)
|
|
10
|
+
|
|
11
|
+
- **5 hooks de lifecycle novos**, wired por default pelo `wendkeep init` (invocação **node-direta**
|
|
12
|
+
`node node_modules/wendkeep/hooks/<name>.mjs` quando o pacote está instalado no projeto —
|
|
13
|
+
~100-250ms vs segundos do npx no Windows; fallback npx):
|
|
14
|
+
- **`change-context`** (UserPromptSubmit) — re-injeta a change ativa (`<active_change_ping>`:
|
|
15
|
+
slug + tarefas abertas) SÓ quando o estado mudou desde a última injeção (hash em sentinela por
|
|
16
|
+
sessão). Sem change ativa: prompt com cara de tarefa ganha `<wk_skill_gate>` mandando invocar
|
|
17
|
+
a Skill wk-workflow ANTES de editar — 1x por sessão.
|
|
18
|
+
- **`change-warn`** (PostToolUse `Edit|Write|MultiEdit`) — edição de código sem change ativa
|
|
19
|
+
gera aviso 1x/sessão (nunca bloqueia; ignora vault/.claude/.agent/.brain e não-código).
|
|
20
|
+
- **`change-guard`** (PreToolUse `Bash`) — `wendkeep change archive --force` vindo do agente é
|
|
21
|
+
**negado** (deny; escape: `WENDKEEP_ALLOW_FORCE=1` no ambiente); `git commit` com change ativa
|
|
22
|
+
E (`--no-verify` OU sensor crítico vermelho) vira **ask** (o usuário decide). Fast-path sem
|
|
23
|
+
I/O para comandos comuns.
|
|
24
|
+
- **`change-nag`** (Stop) — change ativa com tarefas abertas bloqueia o encerramento 1x/sessão
|
|
25
|
+
cobrando fechamento honesto (done / verify / **ou informar a pendência ao usuário**).
|
|
26
|
+
Anti-loop absoluto via `stop_hook_active`.
|
|
27
|
+
- **`plan-capture`** (PostToolUse `ExitPlanMode`) — **a ponte determinística plan-mode → vault**:
|
|
28
|
+
plano aprovado no plan mode do Claude Code vira change no vault (proposta do Contexto, design
|
|
29
|
+
do corpo, tarefas dos checkboxes) ou anexa `plano-aprovado.md` à change ativa. Não depende de
|
|
30
|
+
a LLM lembrar do processo.
|
|
31
|
+
- **`wendkeep change abandon [slug]`** — a saída legítima para change que não vai adiante: move
|
|
32
|
+
para `_arquivo/<data>-<slug>-abandonada` com `status: abandoned`, SEM ADR, SEM promoção de
|
|
33
|
+
specs; limpa o ponteiro só se era a ativa. Elimina o motivo real de `--force` em scaffold.
|
|
34
|
+
- **`quickGateState(vaultBase)`** + sentinelas por sessão (`.brain/.change-*-<sid>`, GC >7 dias no
|
|
35
|
+
Stop) em change-core — fonte única do estado do gate para hooks e CLI.
|
|
36
|
+
- **`wendkeep sync-defs --reseed`** — re-semeia as skills wk-* de `.brain/skills` com os seeds da
|
|
37
|
+
versão instalada (é como um vault existente recebe as descriptions/HARD-GATE novos).
|
|
38
|
+
|
|
39
|
+
### Changed — gate endurecido + ativação da skill
|
|
40
|
+
|
|
41
|
+
- **Verdict SEMPRE exigido no archive** (breaking-ish): sem `verdict.json` o archive bloqueia,
|
|
42
|
+
mesmo sem `[req:]` — `wendkeep verify --deep` grava o verdict trivial automático (1 comando).
|
|
43
|
+
Changes em andamento criadas antes de 0.31.0 precisam de um `verify --deep` antes do archive.
|
|
44
|
+
- **G0 inescapável**: `--force` deixa de pular o check de scaffold — um scaffold cru NUNCA é
|
|
45
|
+
arquivável (era o buraco que mintou ADR falso em produção).
|
|
46
|
+
- **`--force` e trivialidade rastreáveis**: ADR ganha `forced: true` (+ aviso ⚠️ no corpo) quando
|
|
47
|
+
`--force` pulou tarefa aberta, e `trivial: true` quando a change não declarou `[req:]`/`[sensor:]`.
|
|
48
|
+
- **Promoção de specs = união frontmatter + disco**: o archive promove também os deltas REAIS
|
|
49
|
+
achados em `specs/*/spec.md` mesmo com `specs: []` na proposta (warning por cap não listada;
|
|
50
|
+
o `exemplo` placeholder do scaffold é filtrado). Fecha o buraco que deixava 07-Specs vazio com
|
|
51
|
+
delta preenchido no disco.
|
|
52
|
+
- **Ativação da skill (paridade Superpowers)**: descriptions das wk-* reescritas com gatilhos
|
|
53
|
+
concretos ("Use SEMPRE que o usuário pedir para implementar/criar/corrigir/refatorar…
|
|
54
|
+
Invoque ANTES de editar qualquer arquivo"); `<HARD-GATE>` no corpo da wk-workflow; o
|
|
55
|
+
`<wk_process>` do brain-inject agora manda **invocar a Skill** (verbo de skill) e cita `abandon`.
|
|
56
|
+
|
|
57
|
+
### Fixed
|
|
58
|
+
|
|
59
|
+
- **Seções apagadas pelo Stop (classe de bug, visto em produção)**: qualquer seção inserida entre
|
|
60
|
+
`## Pendências` e `## Encerramento` era descartada pelo finalize (`replacePendingSection`
|
|
61
|
+
reconstruía o span inteiro) — atingia `## Subagents & Workflows` (frontmatter sobrevivia, corpo
|
|
62
|
+
sumia), `## Progresso do plano` e `## Mudanças`. Duas camadas: `upsertSection` agora ancora
|
|
63
|
+
ANTES de `## Pendências`, e o finalize preserva seções desconhecidas dentro do span. Notas
|
|
64
|
+
antigas se autocuram no próximo Stop/backfill (os dados persistem em `subagents/`).
|
|
65
|
+
|
|
66
|
+
### Known limitation (aceita e documentada)
|
|
67
|
+
|
|
68
|
+
- Hooks do mesmo evento rodam em **paralelo** no Claude Code: quando o `change-nag` bloqueia o
|
|
69
|
+
Stop, o `session-stop` já finalizou a nota — o turno de continuação não é logado nela
|
|
70
|
+
(recuperável via `session-backfill`/`import`; perda máx. de 1 turno, 1x por sessão).
|
|
71
|
+
|
|
7
72
|
## [0.30.0] — 2026-07-09
|
|
8
73
|
|
|
9
74
|
### Changed
|
package/bin/wendkeep.mjs
CHANGED
|
@@ -44,7 +44,8 @@ Usage:
|
|
|
44
44
|
|
|
45
45
|
wendkeep doctor [--vault P] Run a vault health check.
|
|
46
46
|
wendkeep change <sub> Change lifecycle: new [--simple] | list | show | status |
|
|
47
|
-
done <id> | undone <id> | diff | archive [--force].
|
|
47
|
+
done <id> | undone <id> | diff | archive [--force] | abandon [slug].
|
|
48
|
+
archive exige verdict (rode verify --deep); abandon descarta sem ADR.
|
|
48
49
|
wendkeep spec <sub> Living specs: list | show <capability>.
|
|
49
50
|
wendkeep sensors <sub> list | add <id> "<command>" [--severity --type --report].
|
|
50
51
|
wendkeep cost [opts] Aggregate AI-coding spend across the vault's sessions.
|
|
@@ -70,6 +71,8 @@ Usage:
|
|
|
70
71
|
wendkeep sync-defs [opts] Copy versioned defs from the vault's .brain into the
|
|
71
72
|
project: .brain/agents/*.toml -> .codex/agents,
|
|
72
73
|
.brain/skills/<name> -> .claude/skills. --vault P --project P.
|
|
74
|
+
--reseed re-semeia as skills wk-* com os seeds da versão instalada
|
|
75
|
+
(sobrescreve edições manuais nas wk-*) antes de copiar.
|
|
73
76
|
wendkeep --version Print version.
|
|
74
77
|
wendkeep --help Show this help.
|
|
75
78
|
`;
|
package/hooks/brain-inject.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import { join } from 'node:path';
|
|
|
7
7
|
import { pathToFileURL } from 'node:url';
|
|
8
8
|
import { getVaultBase, readHookInput, writeHookOutput } from './obsidian-common.mjs';
|
|
9
9
|
import { brainDir } from './brain-core.mjs';
|
|
10
|
-
import { buildActiveChangeInjection } from './change-core.mjs';
|
|
10
|
+
import { buildActiveChangeInjection, changeCtxState, writeSentinel } from './change-core.mjs';
|
|
11
11
|
import { buildLessonsInjection } from './lessons-core.mjs';
|
|
12
12
|
import { getLocale } from './locale.mjs';
|
|
13
13
|
|
|
@@ -19,23 +19,23 @@ function processRouter(localeId) {
|
|
|
19
19
|
if (localeId === 'en') {
|
|
20
20
|
return [
|
|
21
21
|
'<wk_process>',
|
|
22
|
-
'Spec-driven process (mandatory for any non-trivial task):',
|
|
23
|
-
'1. Plan:
|
|
22
|
+
'Spec-driven process (mandatory for any non-trivial task): INVOKE the wk-workflow Skill BEFORE editing any file.',
|
|
23
|
+
'1. Plan: invoke the wk-brainstorming Skill (approved design) → wk-planning (task plan).',
|
|
24
24
|
'2. Record: `wendkeep change new <slug>` and FILL 08-Changes/<slug>/ — proposta.md (why/what), design.md (the approved approach), tarefas.md (the plan\'s tasks, `- [ ] N.N` with [req:ID]/[sensor:id]). Never leave the scaffold placeholders.',
|
|
25
25
|
'3. Implement: wk-tdd per task; tick `- [x]` as you finish. Something broke? wk-debugging.',
|
|
26
|
-
'4. Close: `wendkeep verify` (+ `--deep` +
|
|
27
|
-
'NEVER `archive --force` on your own — a red gate means pending work; --force is the user\'s call, not yours.',
|
|
26
|
+
'4. Close: `wendkeep verify` (+ `--deep` + the wk-verify Skill) → `wendkeep change archive`.',
|
|
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`.',
|
|
28
28
|
'</wk_process>',
|
|
29
29
|
].join('\n');
|
|
30
30
|
}
|
|
31
31
|
return [
|
|
32
32
|
'<wk_process>',
|
|
33
|
-
'Processo spec-driven (obrigatório em tarefa não-trivial):',
|
|
34
|
-
'1. Planejar:
|
|
33
|
+
'Processo spec-driven (obrigatório em tarefa não-trivial): INVOQUE a Skill wk-workflow ANTES de editar qualquer arquivo.',
|
|
34
|
+
'1. Planejar: invoque a Skill wk-brainstorming (design aprovado) → wk-planning (plano de tarefas).',
|
|
35
35
|
'2. Registrar: `wendkeep change new <slug>` e PREENCHA 08-Mudanças/<slug>/ — proposta.md (porquê/o quê), design.md (a abordagem aprovada), tarefas.md (as tarefas do plano, `- [ ] N.N` com [req:ID]/[sensor:id]). Nunca deixe os placeholders do scaffold.',
|
|
36
36
|
'3. Implementar: wk-tdd por tarefa; marque `- [x]` ao concluir. Quebrou algo? wk-debugging.',
|
|
37
|
-
'4. Fechar: `wendkeep verify` (+ `--deep` +
|
|
38
|
-
'PROIBIDO `archive --force` por conta própria — gate vermelho significa trabalho pendente; --force é decisão do usuário, não sua.',
|
|
37
|
+
'4. Fechar: `wendkeep verify` (+ `--deep` + Skill wk-verify) → `wendkeep change archive`.',
|
|
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`.',
|
|
39
39
|
'</wk_process>',
|
|
40
40
|
].join('\n');
|
|
41
41
|
}
|
|
@@ -65,12 +65,19 @@ export function buildInjection(vaultBase) {
|
|
|
65
65
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
66
66
|
try {
|
|
67
67
|
const input = readHookInput();
|
|
68
|
+
const vaultBase = getVaultBase(input);
|
|
68
69
|
writeHookOutput({
|
|
69
70
|
hookSpecificOutput: {
|
|
70
71
|
hookEventName: 'SessionStart',
|
|
71
|
-
additionalContext: buildInjection(
|
|
72
|
+
additionalContext: buildInjection(vaultBase),
|
|
72
73
|
},
|
|
73
74
|
});
|
|
75
|
+
// Sentinela do change-context: a change ativa acabou de ser injetada aqui, então o hook de
|
|
76
|
+
// UserPromptSubmit não precisa re-pingar no 1º prompt. Bônus — nunca derruba a injeção.
|
|
77
|
+
try {
|
|
78
|
+
const st = changeCtxState(vaultBase);
|
|
79
|
+
if (st) writeSentinel(vaultBase, 'ctx', input.session_id || input.sessionId || '', st.hash);
|
|
80
|
+
} catch { /* sentinela é bônus */ }
|
|
74
81
|
} catch (error) {
|
|
75
82
|
process.stderr.write(`[brain] inject falhou: ${error.message}\n`);
|
|
76
83
|
writeHookOutput({});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// UserPromptSubmit hook. Dois papéis, ambos quiet-by-default (a maioria dos prompts não injeta nada):
|
|
3
|
+
// 1. Com change ativa: re-injeta <active_change_ping> (slug + tarefas abertas) SÓ quando o
|
|
4
|
+
// estado mudou desde a última injeção (hash em sentinela por sessão) — o nudge do
|
|
5
|
+
// SessionStart dilui/compacta; este devolve o loop ao contexto no momento certo.
|
|
6
|
+
// 2. Sem change ativa: prompt com cara de tarefa ganha <wk_skill_gate> mandando invocar a
|
|
7
|
+
// Skill wk-workflow ANTES de editar — 1x por sessão. É o empurrão de ativação da skill.
|
|
8
|
+
// Fail-open; brain-inject grava a sentinela ctx no SessionStart para não duplicar no 1º prompt.
|
|
9
|
+
import { pathToFileURL } from 'node:url';
|
|
10
|
+
import { getVaultBase, readHookInput, writeHookOutput } from './obsidian-common.mjs';
|
|
11
|
+
import { changeCtxState, readSentinel, writeSentinel } from './change-core.mjs';
|
|
12
|
+
|
|
13
|
+
// Conservador de propósito: verbos de tarefa comuns (pt+en) + tamanho mínimo. Falso-negativo
|
|
14
|
+
// custa só o nudge; falso-positivo em pergunta curta viraria ruído.
|
|
15
|
+
const TASK_RE = /\b(implement\w*|cri[ae]\w*|corrig\w*|conserta\w*|refator\w*|adicion\w*|remov\w*|migr\w*|constru\w*|desenvolv\w*|fix\w*|add|build\w*|create\w*|refactor\w*|develop\w*|escrev\w*|write)\b/i;
|
|
16
|
+
|
|
17
|
+
export function looksLikeTask(prompt) {
|
|
18
|
+
const p = String(prompt || '').trim();
|
|
19
|
+
return p.length >= 20 && TASK_RE.test(p);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Retorna { context, hash? } quando há algo a injetar; null = silêncio.
|
|
23
|
+
export function buildChangePing(vaultBase, sessionId, prompt = '') {
|
|
24
|
+
const st = changeCtxState(vaultBase);
|
|
25
|
+
if (st) {
|
|
26
|
+
if (readSentinel(vaultBase, 'ctx', sessionId) === st.hash) return null;
|
|
27
|
+
writeSentinel(vaultBase, 'ctx', sessionId, st.hash);
|
|
28
|
+
const lines = st.openTasks.map((t) => `- [ ] ${t.id} ${t.text}`);
|
|
29
|
+
const context = [
|
|
30
|
+
'<active_change_ping>',
|
|
31
|
+
`Mudança ativa: ${st.slug}${st.openTasks.length ? ` — tarefa(s) aberta(s):` : ' — sem tarefas abertas.'}`,
|
|
32
|
+
...lines,
|
|
33
|
+
'Ao concluir uma tarefa: `wendkeep change done <id>`. Antes de arquivar: `wendkeep verify`.',
|
|
34
|
+
'</active_change_ping>',
|
|
35
|
+
].join('\n');
|
|
36
|
+
return { context, hash: st.hash };
|
|
37
|
+
}
|
|
38
|
+
// Sem change ativa: gate de skill para prompt-tarefa, 1x por sessão.
|
|
39
|
+
if (!looksLikeTask(prompt)) return null;
|
|
40
|
+
if (readSentinel(vaultBase, 'gate', sessionId)) return null;
|
|
41
|
+
writeSentinel(vaultBase, 'gate', sessionId);
|
|
42
|
+
return {
|
|
43
|
+
context: [
|
|
44
|
+
'<wk_skill_gate>',
|
|
45
|
+
'Este pedido parece uma tarefa de código e NÃO há change ativa no vault. Antes de editar qualquer arquivo, invoque a Skill wk-workflow — ela orquestra o loop a2 (`wendkeep change new <slug>` → tarefas → verify → archive) e registra tudo no vault. Ignore apenas se a mudança for trivial (typo, 1 linha).',
|
|
46
|
+
'</wk_skill_gate>',
|
|
47
|
+
].join('\n'),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
52
|
+
try {
|
|
53
|
+
const input = readHookInput();
|
|
54
|
+
const sid = input.session_id || input.sessionId || '';
|
|
55
|
+
const ping = buildChangePing(getVaultBase(input), sid, input.prompt || '');
|
|
56
|
+
if (!ping) { writeHookOutput({}); }
|
|
57
|
+
else writeHookOutput({ hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: ping.context } });
|
|
58
|
+
} catch {
|
|
59
|
+
writeHookOutput({});
|
|
60
|
+
}
|
|
61
|
+
}
|
package/hooks/change-core.mjs
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
// hooks/change-core.mjs
|
|
2
2
|
// Native change/spec lifecycle in the vault (Pilar B). Vault-facing lib consumed by
|
|
3
3
|
// the `wendkeep change` CLI (src/change.mjs) and the brain-inject hook. No external deps.
|
|
4
|
-
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
5
5
|
import { dirname, join } from 'node:path';
|
|
6
6
|
import { ensureDir, wikilinkFromRel, monthFolderRelFromDateStr } from './obsidian-common.mjs';
|
|
7
|
-
import { parseSpecsList, promoteSpecs } from './spec-core.mjs';
|
|
7
|
+
import { parseSpecsList, promoteSpecs, discoverSpecDeltas, tasksHashOf } from './spec-core.mjs';
|
|
8
8
|
import { getLocale } from './locale.mjs';
|
|
9
9
|
|
|
10
10
|
export const ARCHIVE_DIR = '_arquivo';
|
|
@@ -183,6 +183,79 @@ export function activeChangeLink(vaultBase) {
|
|
|
183
183
|
return slug ? `Change ativa: [[${getLocale(vaultBase).folders.changes}/${slug}/proposta]]` : '';
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
+
// --- Estado rápido do gate + sentinelas por sessão (0.31.0) --------------------
|
|
187
|
+
// Fonte única e barata (só leituras, tudo fail-open) do estado do gate, consumida pelos hooks
|
|
188
|
+
// de lifecycle (change-guard/change-nag/change-context) e pelo CLI. null sem change ativa.
|
|
189
|
+
export function quickGateState(vaultBase) {
|
|
190
|
+
const slug = activeChange(vaultBase);
|
|
191
|
+
if (!slug) return null;
|
|
192
|
+
const dir = join(vaultBase, getLocale(vaultBase).folders.changes, slug);
|
|
193
|
+
let tarefasMd = '';
|
|
194
|
+
try { tarefasMd = readFileSync(join(dir, 'tarefas.md'), 'utf8'); } catch { /* sem tarefas */ }
|
|
195
|
+
const openTasks = parseTasks(tarefasMd).filter((t) => !t.done).length;
|
|
196
|
+
let redCritical = false;
|
|
197
|
+
try {
|
|
198
|
+
const ev = JSON.parse(readFileSync(join(dir, 'evidencia.json'), 'utf8'));
|
|
199
|
+
redCritical = (Array.isArray(ev) ? ev : []).some((e) => e.status !== 'green' && (e.severity || 'critical') !== 'warning');
|
|
200
|
+
} catch { /* sem/ilegível = não conta contra o nudge */ }
|
|
201
|
+
let evidenceStale = false;
|
|
202
|
+
try {
|
|
203
|
+
const h = readFileSync(join(dir, '.evidence-hash'), 'utf8').trim();
|
|
204
|
+
evidenceStale = Boolean(h) && h !== tasksHashOf(tarefasMd);
|
|
205
|
+
} catch { /* nunca selada */ }
|
|
206
|
+
return { slug, openTasks, redCritical, evidenceStale, placeholders: scaffoldPlaceholders(dir).length };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Sentinelas por sessão em .brain/ — memória "já avisei/injetei nesta sessão" dos hooks de
|
|
210
|
+
// lifecycle. kind: 'ctx' | 'warn' | 'nag' | 'gate'. session_id sanitizado para nome de arquivo.
|
|
211
|
+
const sanitizeSid = (sid) => String(sid || 'nosession').replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 64);
|
|
212
|
+
|
|
213
|
+
export function sentinelPath(vaultBase, kind, sid) {
|
|
214
|
+
return join(vaultBase, '.brain', `.change-${kind}-${sanitizeSid(sid)}`);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function readSentinel(vaultBase, kind, sid) {
|
|
218
|
+
try { return readFileSync(sentinelPath(vaultBase, kind, sid), 'utf8').trim(); } catch { return ''; }
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function writeSentinel(vaultBase, kind, sid, value = '1') {
|
|
222
|
+
try {
|
|
223
|
+
mkdirSync(join(vaultBase, '.brain'), { recursive: true });
|
|
224
|
+
writeFileSync(sentinelPath(vaultBase, kind, sid), value, 'utf8');
|
|
225
|
+
} catch { /* fail-open: pior caso = aviso repetido */ }
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Estado da change ativa para o ping do change-context: hash inclui o slug para que trocar de
|
|
229
|
+
// change com tarefas idênticas ainda re-pingue.
|
|
230
|
+
export function changeCtxState(vaultBase, { maxTasks = 5 } = {}) {
|
|
231
|
+
const slug = activeChange(vaultBase);
|
|
232
|
+
if (!slug) return null;
|
|
233
|
+
let md = '';
|
|
234
|
+
try { md = readFileSync(join(vaultBase, getLocale(vaultBase).folders.changes, slug, 'tarefas.md'), 'utf8'); } catch { /* sem tarefas */ }
|
|
235
|
+
return { slug, hash: tasksHashOf(`${slug}\n${md}`), openTasks: parseTasks(md).filter((t) => !t.done).slice(0, maxTasks) };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// GC das sentinelas (>7 dias) — seleção pura separada da execução (testável sem depender de
|
|
239
|
+
// unlink funcionar no sandbox). Roda no finalize do session-stop, fail-quiet.
|
|
240
|
+
const SENTINEL_RE = /^\.change-(?:ctx|warn|nag|gate)-/;
|
|
241
|
+
|
|
242
|
+
export function staleSentinelNames(entries, now = Date.now(), maxAgeMs = 7 * 86400000) {
|
|
243
|
+
return (entries || []).filter((e) => SENTINEL_RE.test(e.name) && now - e.mtimeMs > maxAgeMs).map((e) => e.name);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function pruneChangeSentinels(vaultBase, { now = Date.now() } = {}) {
|
|
247
|
+
const dir = join(vaultBase, '.brain');
|
|
248
|
+
let entries = [];
|
|
249
|
+
try {
|
|
250
|
+
entries = readdirSync(dir)
|
|
251
|
+
.map((name) => { try { return { name, mtimeMs: statSync(join(dir, name)).mtimeMs }; } catch { return null; } })
|
|
252
|
+
.filter(Boolean);
|
|
253
|
+
} catch { return []; }
|
|
254
|
+
const stale = staleSentinelNames(entries, now);
|
|
255
|
+
for (const name of stale) { try { unlinkSync(join(dir, name)); } catch { /* fail-quiet */ } }
|
|
256
|
+
return stale;
|
|
257
|
+
}
|
|
258
|
+
|
|
186
259
|
// Append fix tasks for surviving mutants to a change's tarefas.md (Wave B). Deduped by
|
|
187
260
|
// file:line, numbered M.<n> continuing from any existing fix tasks. Returns count added.
|
|
188
261
|
export function appendFixTasks(changeDir, mutants, sensorId) {
|
|
@@ -214,7 +287,7 @@ export function gateGreen() {
|
|
|
214
287
|
return { ok: true, failing: [] };
|
|
215
288
|
}
|
|
216
289
|
|
|
217
|
-
export function archiveChange(vaultBase, slug, { gate = gateGreen, dateStr, adrNum }) {
|
|
290
|
+
export function archiveChange(vaultBase, slug, { gate = gateGreen, dateStr, adrNum, adrFlags = {} }) {
|
|
218
291
|
const loc = getLocale(vaultBase);
|
|
219
292
|
const chDir = loc.folders.changes;
|
|
220
293
|
const src = join(vaultBase, chDir, slug);
|
|
@@ -233,16 +306,25 @@ export function archiveChange(vaultBase, slug, { gate = gateGreen, dateStr, adrN
|
|
|
233
306
|
}
|
|
234
307
|
|
|
235
308
|
// Promote spec deltas into the living 07-Specs BEFORE moving (deltas live in src).
|
|
309
|
+
// UNIÃO frontmatter + disco (0.31.0): o scaffold deixa `specs: []`, então um delta real
|
|
310
|
+
// preenchido em specs/<cap>/ mas não listado era silenciosamente ignorado. Deltas ainda em
|
|
311
|
+
// placeholder (o `exemplo` do scaffold) são filtrados por discoverSpecDeltas.
|
|
236
312
|
let promoted = [];
|
|
237
313
|
let specWarnings = [];
|
|
238
314
|
try {
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
315
|
+
let listed = [];
|
|
316
|
+
try { listed = parseSpecsList(readFileSync(join(src, 'proposta.md'), 'utf8')); } catch { /* proposta ilegível */ }
|
|
317
|
+
const onDisk = discoverSpecDeltas(src);
|
|
318
|
+
const union = [...new Set([...listed, ...onDisk])];
|
|
319
|
+
if (union.length) {
|
|
320
|
+
const res = promoteSpecs(vaultBase, src, union, { changeWikilink, dateStr });
|
|
242
321
|
promoted = res.promoted;
|
|
243
|
-
specWarnings =
|
|
322
|
+
specWarnings = [
|
|
323
|
+
...onDisk.filter((c) => !listed.includes(c)).map((c) => `spec no disco não listada no frontmatter da proposta: ${c} — promovida assim mesmo`),
|
|
324
|
+
...res.warnings,
|
|
325
|
+
];
|
|
244
326
|
}
|
|
245
|
-
} catch { /*
|
|
327
|
+
} catch { /* promoção falhou — segue só com ADR */ }
|
|
246
328
|
|
|
247
329
|
let reqIds = [];
|
|
248
330
|
try { reqIds = [...new Set(parseTasks(readFileSync(join(src, 'tarefas.md'), 'utf8')).map((t) => t.req).filter(Boolean))]; } catch { /* sem tarefas */ }
|
|
@@ -271,10 +353,13 @@ export function archiveChange(vaultBase, slug, { gate = gateGreen, dateStr, adrN
|
|
|
271
353
|
? `\n\nCapabilities: ${promoted.map((c) => wikilinkFromRel(join(loc.folders.specs, c))).join(', ')}.`
|
|
272
354
|
: '';
|
|
273
355
|
const reqLine = reqIds.length ? `\n\nRequisitos: ${reqIds.join(', ')}.` : '';
|
|
356
|
+
// Rastro auditável (0.31.0): um archive forçado ou sem prova declarada fica marcado no ADR.
|
|
357
|
+
const flagLines = `${adrFlags.forced ? '\nforced: true' : ''}${adrFlags.trivial ? '\ntrivial: true' : ''}`;
|
|
358
|
+
const forcedNote = adrFlags.forced ? '\n\n> ⚠️ Arquivada com --force — havia tarefa(s) aberta(s) pulada(s) no gate.' : '';
|
|
274
359
|
writeFileSync(join(vaultBase, adrRel), `---
|
|
275
360
|
type: decision
|
|
276
361
|
status: accepted
|
|
277
|
-
date: ${dateStr}
|
|
362
|
+
date: ${dateStr}${flagLines}
|
|
278
363
|
cssclasses:
|
|
279
364
|
- topic-decision
|
|
280
365
|
tags:
|
|
@@ -285,7 +370,7 @@ tags:
|
|
|
285
370
|
|
|
286
371
|
## Decisão
|
|
287
372
|
|
|
288
|
-
Mudança ${changeWikilink} concluída e arquivada.${capLine}${reqLine}
|
|
373
|
+
Mudança ${changeWikilink} concluída e arquivada.${capLine}${reqLine}${forcedNote}
|
|
289
374
|
`, 'utf8');
|
|
290
375
|
|
|
291
376
|
// Only clear the pointer when the archived change IS the active one — archiving some other
|
|
@@ -293,3 +378,23 @@ Mudança ${changeWikilink} concluída e arquivada.${capLine}${reqLine}
|
|
|
293
378
|
if (activeChange(vaultBase) === slug) clearActiveChange(vaultBase);
|
|
294
379
|
return { ok: true, failing: [], archivedRel: destRel, adrRel, promoted, specWarnings };
|
|
295
380
|
}
|
|
381
|
+
|
|
382
|
+
// Abandono (0.31.0): a saída legítima para uma change que não vai adiante — o que antes só o
|
|
383
|
+
// `archive --force` "resolvia", minting um ADR falso. Sem ADR, sem promoteSpecs (abandono não é
|
|
384
|
+
// decisão arquitetural nem promove contrato); move para _arquivo com sufixo -abandonada.
|
|
385
|
+
export function abandonChange(vaultBase, slug, { dateStr }) {
|
|
386
|
+
const chDir = getLocale(vaultBase).folders.changes;
|
|
387
|
+
const src = join(vaultBase, chDir, slug);
|
|
388
|
+
if (!existsSync(join(src, 'proposta.md'))) return { ok: false, failing: [`change não encontrada: ${slug}`] };
|
|
389
|
+
const destRel = join(chDir, ARCHIVE_DIR, `${dateStr}-${slug}-abandonada`);
|
|
390
|
+
const destAbs = join(vaultBase, destRel);
|
|
391
|
+
if (existsSync(destAbs)) return { ok: false, failing: [`destino já existe: ${destRel}`] };
|
|
392
|
+
ensureDir(join(vaultBase, chDir, ARCHIVE_DIR));
|
|
393
|
+
try { renameSync(src, destAbs); } catch (e) { return { ok: false, failing: [`falha ao mover: ${e.message}`] }; }
|
|
394
|
+
try {
|
|
395
|
+
const pp = join(destAbs, 'proposta.md');
|
|
396
|
+
writeFileSync(pp, readFileSync(pp, 'utf8').replace(/^status:\s*active\s*$/m, 'status: abandoned'), 'utf8');
|
|
397
|
+
} catch { /* proposta sem frontmatter — segue */ }
|
|
398
|
+
if (activeChange(vaultBase) === slug) clearActiveChange(vaultBase);
|
|
399
|
+
return { ok: true, failing: [], archivedRel: destRel };
|
|
400
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// PreToolUse hook (matcher: Bash). O gate mecânico do loop a2 no ponto de execução:
|
|
3
|
+
// R1 — `wendkeep|wk change archive --force` vindo do AGENTE é negado (deny). Gate vermelho
|
|
4
|
+
// significa trabalho pendente; --force é decisão do usuário (escape: WENDKEEP_ALLOW_FORCE=1
|
|
5
|
+
// no ambiente do processo — env inline no texto do comando NÃO conta).
|
|
6
|
+
// R2 — `git commit` com change ativa E (--no-verify OU sensor crítico vermelho) vira `ask`
|
|
7
|
+
// (o usuário decide com 1 clique; falso-positivo custa pouco).
|
|
8
|
+
// Fast-path: comando sem wendkeep/wk/git sai sem NENHUM I/O. Fail-open.
|
|
9
|
+
import { pathToFileURL } from 'node:url';
|
|
10
|
+
import { getVaultBase, readHookInput, writeHookOutput } from './obsidian-common.mjs';
|
|
11
|
+
import { activeChange, quickGateState } from './change-core.mjs';
|
|
12
|
+
|
|
13
|
+
const FORCE_RE = /\b(?:wendkeep|wk)\s+change\s+archive\b[^|&;\n]*--force\b/;
|
|
14
|
+
const GIT_SEG_RE = /(^|&&|;|\|)\s*git\b[^|&;]*\bcommit\b/;
|
|
15
|
+
const FAST_RE = /\b(?:wendkeep|wk|git)\b/;
|
|
16
|
+
|
|
17
|
+
export function guardDecision(command, { vaultBase, env = process.env } = {}) {
|
|
18
|
+
const cmd = String(command || '');
|
|
19
|
+
if (!FAST_RE.test(cmd)) return null; // fast-path: zero I/O para o caso comum
|
|
20
|
+
|
|
21
|
+
// R1: archive --force — puro regex, ainda sem I/O. Reason fala com o AGENTE (deny).
|
|
22
|
+
if (FORCE_RE.test(cmd)) {
|
|
23
|
+
if (env.WENDKEEP_ALLOW_FORCE === '1') return null;
|
|
24
|
+
return {
|
|
25
|
+
permissionDecision: 'deny',
|
|
26
|
+
permissionDecisionReason: '`change archive --force` é decisão do usuário, não sua. Gate vermelho = trabalho pendente: rode `wendkeep change status` e conclua as tarefas, ou `wendkeep change abandon <slug>` se a change não vai adiante. Se o usuário pediu o force explicitamente, peça a ele para rodar com WENDKEEP_ALLOW_FORCE=1.',
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// R2: git commit — 1ª leitura de fs só acontece aqui. Reason fala com o USUÁRIO (ask).
|
|
31
|
+
const m = cmd.match(GIT_SEG_RE);
|
|
32
|
+
if (m) {
|
|
33
|
+
const slug = activeChange(vaultBase);
|
|
34
|
+
if (!slug) return null;
|
|
35
|
+
const seg = cmd.slice(m.index + m[1].length).split(/&&|;|\|/)[0];
|
|
36
|
+
const noVerify = /\s--no-verify\b/.test(seg);
|
|
37
|
+
const gate = noVerify ? null : quickGateState(vaultBase);
|
|
38
|
+
if (noVerify || (gate && gate.redCritical)) {
|
|
39
|
+
return {
|
|
40
|
+
permissionDecision: 'ask',
|
|
41
|
+
permissionDecisionReason: noVerify
|
|
42
|
+
? `git commit --no-verify com a change "${slug}" ativa — commitar pulando os hooks?`
|
|
43
|
+
: `A change ativa "${slug}" tem sensor crítico vermelho (wendkeep verify falhou). Commitar mesmo assim?`,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
51
|
+
try {
|
|
52
|
+
const input = readHookInput();
|
|
53
|
+
const d = guardDecision(input.tool_input?.command, { vaultBase: getVaultBase(input) });
|
|
54
|
+
if (d) writeHookOutput({ hookSpecificOutput: { hookEventName: 'PreToolUse', ...d } });
|
|
55
|
+
// allow implícito: exit 0 sem output
|
|
56
|
+
} catch {
|
|
57
|
+
writeHookOutput({}); // fail-open = allow
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Stop hook. Se a change ativa tem tarefas abertas quando o agente tenta encerrar o turno,
|
|
3
|
+
// bloqueia UMA vez por sessão cobrando fechamento honesto: marcar done, rodar verify, OU
|
|
4
|
+
// informar a pendência ao usuário e encerrar (a saída honesta é obrigatória no reason — sem
|
|
5
|
+
// ela o modelo é incentivado a marcar done falso só para conseguir parar).
|
|
6
|
+
// Anti-loop absoluto: stop_hook_active é o PRIMEIRO check, antes de qualquer I/O.
|
|
7
|
+
import { pathToFileURL } from 'node:url';
|
|
8
|
+
import { getVaultBase, readHookInput, writeHookOutput } from './obsidian-common.mjs';
|
|
9
|
+
import { quickGateState, readSentinel, writeSentinel } from './change-core.mjs';
|
|
10
|
+
|
|
11
|
+
export function nagDecision(input, vaultBase) {
|
|
12
|
+
if (input && input.stop_hook_active) return null; // anti-loop: sempre primeiro
|
|
13
|
+
const gate = quickGateState(vaultBase);
|
|
14
|
+
if (!gate || !gate.openTasks) return null;
|
|
15
|
+
const sid = input?.session_id || input?.sessionId || '';
|
|
16
|
+
if (readSentinel(vaultBase, 'nag', sid)) return null;
|
|
17
|
+
writeSentinel(vaultBase, 'nag', sid);
|
|
18
|
+
return {
|
|
19
|
+
decision: 'block',
|
|
20
|
+
reason: `A change ativa "${gate.slug}" tem ${gate.openTasks} tarefa(s) aberta(s). Antes de encerrar: marque as concluídas com \`wendkeep change done <id>\`, rode \`wendkeep verify\`, ou informe a pendência ao usuário e encerre.`,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
25
|
+
try {
|
|
26
|
+
const input = readHookInput();
|
|
27
|
+
writeHookOutput(nagDecision(input, getVaultBase(input)) || {});
|
|
28
|
+
} catch {
|
|
29
|
+
writeHookOutput({});
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// PostToolUse hook (matcher: Edit|Write|MultiEdit). Quando o agente edita CÓDIGO sem change
|
|
3
|
+
// ativa, avisa UMA vez por sessão que trabalho não-trivial deve rotear pelo loop a2. Nunca
|
|
4
|
+
// bloqueia — mudança trivial pode seguir. Ignora edições no vault e em .claude/.agent/.brain.
|
|
5
|
+
import { isAbsolute, resolve } from 'node:path';
|
|
6
|
+
import { pathToFileURL } from 'node:url';
|
|
7
|
+
import { getVaultBase, readHookInput, writeHookOutput } from './obsidian-common.mjs';
|
|
8
|
+
import { activeChange, readSentinel, writeSentinel } from './change-core.mjs';
|
|
9
|
+
|
|
10
|
+
const CODE_EXT = /\.(ts|tsx|js|jsx|mjs|cjs|py|prisma|sql|go|rs|java|cs)$/i;
|
|
11
|
+
|
|
12
|
+
export function isCodeFile(p) {
|
|
13
|
+
return CODE_EXT.test(String(p || '').replace(/\\/g, '/'));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const norm = (p) => String(p || '').replace(/\\/g, '/');
|
|
17
|
+
|
|
18
|
+
// Retorna o additionalContext do aviso, ou null (caso comum).
|
|
19
|
+
export function warnDecision(filePath, { vaultBase, cwd = '.', sessionId = '' } = {}) {
|
|
20
|
+
if (!filePath || !isCodeFile(filePath)) return null;
|
|
21
|
+
if (activeChange(vaultBase)) return null;
|
|
22
|
+
const abs = norm(isAbsolute(filePath) ? filePath : resolve(cwd, filePath));
|
|
23
|
+
// Dentro do vault (NTFS é case-insensitive) ou em dirs de config de agente: não é código do projeto.
|
|
24
|
+
if (abs.toLowerCase().startsWith(`${norm(resolve(vaultBase)).toLowerCase()}/`)) return null;
|
|
25
|
+
if (/\/(\.claude|\.agent|\.brain)\//.test(`${abs}/`)) return null;
|
|
26
|
+
if (readSentinel(vaultBase, 'warn', sessionId)) return null;
|
|
27
|
+
writeSentinel(vaultBase, 'warn', sessionId);
|
|
28
|
+
return [
|
|
29
|
+
'<change_warn>',
|
|
30
|
+
`Você editou código (${filePath}) sem change ativa. Para trabalho não-trivial, roteie pelo processo: \`wendkeep change new <slug>\` e preencha proposta/design/tarefas (skill wk-workflow). Ignore se for um ajuste trivial — este aviso não repete nesta sessão.`,
|
|
31
|
+
'</change_warn>',
|
|
32
|
+
].join('\n');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
36
|
+
try {
|
|
37
|
+
const input = readHookInput();
|
|
38
|
+
const warn = warnDecision(input.tool_input?.file_path, {
|
|
39
|
+
vaultBase: getVaultBase(input),
|
|
40
|
+
cwd: input.cwd || '.',
|
|
41
|
+
sessionId: input.session_id || input.sessionId || '',
|
|
42
|
+
});
|
|
43
|
+
if (!warn) { writeHookOutput({}); }
|
|
44
|
+
else writeHookOutput({ hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: warn } });
|
|
45
|
+
} catch {
|
|
46
|
+
writeHookOutput({});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
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 { readFileSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { pathToFileURL } from 'node:url';
|
|
9
|
+
import { formatDate, getVaultBase, readHookInput, slugify, writeHookOutput } from './obsidian-common.mjs';
|
|
10
|
+
import { activeChange, newChange } from './change-core.mjs';
|
|
11
|
+
import { getLocale } from './locale.mjs';
|
|
12
|
+
|
|
13
|
+
// O plano aprovado chega por um de três canais, conforme a versão do Claude Code:
|
|
14
|
+
// tool_input.plan (legado), o bloco "## Approved Plan" do tool_response, ou o arquivo de plano
|
|
15
|
+
// citado em "saved to: <path>". Rejeição (ou ausência de sinal de aprovação) → null.
|
|
16
|
+
export function extractPlan(input) {
|
|
17
|
+
const tr = input?.tool_response ?? input?.toolResponse ?? '';
|
|
18
|
+
const resp = typeof tr === 'string' ? tr : JSON.stringify(tr || '');
|
|
19
|
+
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
|
+
const direct = input?.tool_input?.plan ?? input?.toolInput?.plan;
|
|
22
|
+
if (direct && String(direct).trim()) return String(direct);
|
|
23
|
+
const marker = resp.match(/## Approved Plan[^\n]*:\s*\n([\s\S]+)$/);
|
|
24
|
+
if (marker && marker[1].trim()) return marker[1].trim();
|
|
25
|
+
const path = resp.match(/saved to:\s*([^\n]+\.md)/i);
|
|
26
|
+
if (path) { try { return readFileSync(path[1].trim(), 'utf8'); } catch { /* arquivo inacessível */ } }
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function planSlug(plan) {
|
|
31
|
+
const h1 = String(plan || '').match(/^#\s+(.+)$/m);
|
|
32
|
+
return h1 ? slugify(h1[1], 'plano-aprovado', 60) : 'plano-aprovado';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function sectionBody(plan, headings) {
|
|
36
|
+
for (const h of headings) {
|
|
37
|
+
const m = String(plan).match(new RegExp(`^##\\s+${h}\\s*\\n([\\s\\S]*?)(?=\\n##\\s|$)`, 'm'));
|
|
38
|
+
if (m && m[1].trim()) return m[1].trim();
|
|
39
|
+
}
|
|
40
|
+
return '';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Checkboxes do plano viram tarefas numeradas 1.N (estado [x] preservado). [] quando não há.
|
|
44
|
+
export function planTasks(plan) {
|
|
45
|
+
const boxes = [...String(plan || '').matchAll(/^\s*-\s+\[( |x)\]\s+(.+)$/gm)];
|
|
46
|
+
return boxes.map((m, i) => `- [${m[1]}] 1.${i + 1} ${m[2].trim()}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function capturePlan(vaultBase, input) {
|
|
50
|
+
const plan = extractPlan(input);
|
|
51
|
+
if (!plan) return null;
|
|
52
|
+
const loc = getLocale(vaultBase);
|
|
53
|
+
const en = loc.id === 'en';
|
|
54
|
+
const dateStr = formatDate(new Date());
|
|
55
|
+
const rawNote = `---\ntype: plan\ndate: ${dateStr}\ntags:\n - plano\n---\n\n${plan.trim()}\n`;
|
|
56
|
+
|
|
57
|
+
const active = activeChange(vaultBase);
|
|
58
|
+
if (active) {
|
|
59
|
+
const dir = join(vaultBase, loc.folders.changes, active);
|
|
60
|
+
writeFileSync(join(dir, 'plano-aprovado.md'), rawNote, 'utf8');
|
|
61
|
+
return {
|
|
62
|
+
slug: active, created: false,
|
|
63
|
+
context: `<plan_captured>\n${en
|
|
64
|
+
? `Approved plan attached to the active change "${active}" (plano-aprovado.md). Sync tarefas.md with the plan's tasks.`
|
|
65
|
+
: `Plano aprovado anexado à change ativa "${active}" (plano-aprovado.md). Sincronize tarefas.md com as tarefas do plano.`}\n</plan_captured>`,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const slug = planSlug(plan);
|
|
70
|
+
newChange(vaultBase, slug, { dateStr });
|
|
71
|
+
const dir = join(vaultBase, loc.folders.changes, slug);
|
|
72
|
+
const ctx = sectionBody(plan, ['Contexto', 'Context']) || plan.trim().split('\n').slice(0, 6).join('\n');
|
|
73
|
+
writeFileSync(join(dir, 'proposta.md'), `---
|
|
74
|
+
type: change
|
|
75
|
+
status: active
|
|
76
|
+
date: ${dateStr}
|
|
77
|
+
cssclasses:
|
|
78
|
+
- topic-change
|
|
79
|
+
tags:
|
|
80
|
+
- mudanca
|
|
81
|
+
source: []
|
|
82
|
+
specs: []
|
|
83
|
+
---
|
|
84
|
+
|
|
85
|
+
# ${slug}
|
|
86
|
+
|
|
87
|
+
${en ? '## Why' : '## Por quê'}
|
|
88
|
+
|
|
89
|
+
${ctx}
|
|
90
|
+
|
|
91
|
+
${en ? '## What changes' : '## O que muda'}
|
|
92
|
+
|
|
93
|
+
${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).'}
|
|
94
|
+
`, 'utf8');
|
|
95
|
+
writeFileSync(join(dir, 'design.md'), `# ${slug} — design\n\n${plan.trim()}\n`, 'utf8');
|
|
96
|
+
const tasks = planTasks(plan);
|
|
97
|
+
if (tasks.length) writeFileSync(join(dir, 'tarefas.md'), `# ${slug} — ${en ? 'tasks' : 'tarefas'}\n\n${tasks.join('\n')}\n`, 'utf8');
|
|
98
|
+
writeFileSync(join(dir, 'plano-aprovado.md'), rawNote, 'utf8');
|
|
99
|
+
return {
|
|
100
|
+
slug, created: true,
|
|
101
|
+
context: `<plan_captured>\n${en
|
|
102
|
+
? `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\`).`
|
|
103
|
+
: `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>`,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
108
|
+
try {
|
|
109
|
+
const input = readHookInput();
|
|
110
|
+
const r = capturePlan(getVaultBase(input), input);
|
|
111
|
+
if (!r) { writeHookOutput({}); }
|
|
112
|
+
else writeHookOutput({ hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: r.context } });
|
|
113
|
+
} catch {
|
|
114
|
+
writeHookOutput({});
|
|
115
|
+
}
|
|
116
|
+
}
|
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 { 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,30 @@ export function parseSpecsList(propostaMd) {
|
|
|
87
87
|
return [];
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
// Um delta ainda no estado do scaffold (só o requisito "(nome)"/"(name)", nada removido) não é
|
|
91
|
+
// contrato — a promoção o filtra. Um delta com REMOVED é sempre intenção real.
|
|
92
|
+
export function isPlaceholderDelta(md) {
|
|
93
|
+
const d = parseDelta(md);
|
|
94
|
+
if (d.removed.length) return false;
|
|
95
|
+
const all = [...d.added, ...d.modified];
|
|
96
|
+
if (!all.length) return true;
|
|
97
|
+
return all.every((r) => /^\((?:nome|name)\)$/.test(r.name));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Capabilities com delta REAL no disco (<change>/specs/<cap>/spec.md), independente do
|
|
101
|
+
// frontmatter `specs:` da proposta — o scaffold deixa `specs: []` e o buraco engolia deltas
|
|
102
|
+
// preenchidos mas não listados (visto em produção: 07-Specs vazio com delta real no disco).
|
|
103
|
+
export function discoverSpecDeltas(changeDir) {
|
|
104
|
+
let names = [];
|
|
105
|
+
try { names = readdirSync(join(changeDir, 'specs')); } catch { return []; }
|
|
106
|
+
const caps = [];
|
|
107
|
+
for (const cap of names) {
|
|
108
|
+
try { if (!isPlaceholderDelta(readFileSync(join(changeDir, 'specs', cap, 'spec.md'), 'utf8'))) caps.push(cap); }
|
|
109
|
+
catch { /* sem spec.md */ }
|
|
110
|
+
}
|
|
111
|
+
return caps;
|
|
112
|
+
}
|
|
113
|
+
|
|
90
114
|
// Merge each capability's delta (in the change) into the living spec in 07-Specs.
|
|
91
115
|
export function promoteSpecs(vaultBase, changeDir, specs, { changeWikilink, dateStr } = {}) {
|
|
92
116
|
const loc = getLocale(vaultBase);
|
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wendkeep",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.31.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,6 +8,7 @@ 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';
|
|
@@ -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,11 +157,11 @@ 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
|
}
|
|
165
166
|
let tarefasMd = '';
|
|
166
167
|
try { tarefasMd = readFileSync(join(dir, 'tarefas.md'), 'utf8'); } catch { /* no tasks */ }
|
|
@@ -185,17 +186,38 @@ export function runChange(argv) {
|
|
|
185
186
|
try { evidence = JSON.parse(readFileSync(join(dir, 'evidencia.json'), 'utf8')); } catch { /* no evidence */ }
|
|
186
187
|
const s = evaluateGate(evidence, required);
|
|
187
188
|
if (!s.ok) return s;
|
|
188
|
-
//
|
|
189
|
+
// Verdict SEMPRE exigido (0.31.0) — a exigência universal vive AQUI no gate; a semântica
|
|
190
|
+
// reqless→ok de evaluateVerdict (spec-core) não muda porque `verify --deep` e `change
|
|
191
|
+
// status` dependem dela. Change sem [req:] destrava com o auto-verdict do verify --deep.
|
|
189
192
|
let verdict = null;
|
|
190
193
|
try { verdict = JSON.parse(readFileSync(join(dir, 'verdict.json'), 'utf8')); } catch { /* none */ }
|
|
191
|
-
const
|
|
192
|
-
if (!
|
|
193
|
-
|
|
194
|
-
|
|
194
|
+
const hash = tasksHashOf(tarefasMd);
|
|
195
|
+
if (!verdict) {
|
|
196
|
+
return { ok: false, failing: [reqIds.length
|
|
197
|
+
? 'sem verdict — rode `wendkeep verify --deep` + skill wk-verify'
|
|
198
|
+
: 'sem verdict — rode `wendkeep verify --deep` (verdict trivial automático)'] };
|
|
199
|
+
}
|
|
200
|
+
if (verdict.ok !== true) return { ok: false, failing: ['verdict não-ok — re-verifique a change antes de arquivar'] };
|
|
201
|
+
if (verdict.tasksHash && verdict.tasksHash !== hash) {
|
|
202
|
+
return { ok: false, failing: [`verdict stale (tarefas.md mudou depois da verificação) — re-verifique: \`wendkeep verify --deep\`${reqIds.length ? ' + wk-verify' : ''}`] };
|
|
203
|
+
}
|
|
204
|
+
if (reqIds.length) {
|
|
205
|
+
const v = evaluateVerdict(verdict, reqIds, { tasksHash: hash });
|
|
206
|
+
if (!v.ok) {
|
|
207
|
+
if (v.stale) return { ok: false, failing: ['verdict stale (tarefas.md mudou depois da verificação) — re-verifique: `wendkeep verify --deep` + wk-verify'] };
|
|
208
|
+
return { ok: false, failing: [`verdict incompleto: falta ${v.missing.join(', ')}`] };
|
|
209
|
+
}
|
|
195
210
|
}
|
|
196
211
|
return { ok: true, failing: [] };
|
|
197
212
|
};
|
|
198
|
-
|
|
213
|
+
// Rastro auditável: forced só quando o --force de fato pulou G1 (tarefa aberta); trivial
|
|
214
|
+
// quando a change não declarou nenhuma prova ([req:]/[sensor:]).
|
|
215
|
+
let tasks = [];
|
|
216
|
+
try { tasks = parseTasks(readFileSync(join(vaultBase, getLocale(vaultBase).folders.changes, slug, 'tarefas.md'), 'utf8')); } catch { /* sem tarefas */ }
|
|
217
|
+
const forced = rest.includes('--force') && tasks.some((t) => !t.done);
|
|
218
|
+
const trivial = !tasks.some((t) => t.req) && !tasks.some((t) => t.sensor);
|
|
219
|
+
if (trivial) process.stderr.write('aviso: change trivial (sem [req:]/[sensor:]) — ADR marcado trivial: true\n');
|
|
220
|
+
const r = archiveChange(vaultBase, slug, { dateStr: today(), adrNum: getNextAdrNumber(vaultBase), gate, adrFlags: { forced, trivial } });
|
|
199
221
|
if (!r.ok) {
|
|
200
222
|
process.stderr.write(`change archive BLOCKED (gate): ${r.failing.join('; ')}\n`);
|
|
201
223
|
process.exit(1);
|
|
@@ -206,6 +228,15 @@ export function runChange(argv) {
|
|
|
206
228
|
process.exit(0);
|
|
207
229
|
}
|
|
208
230
|
|
|
209
|
-
|
|
231
|
+
if (sub === 'abandon') {
|
|
232
|
+
const slug = slugArg() || activeChange(vaultBase);
|
|
233
|
+
if (!slug) { process.stderr.write('wendkeep change abandon: missing <slug> and no active change\n'); process.exit(2); }
|
|
234
|
+
const r = abandonChange(vaultBase, slug, { dateStr: today() });
|
|
235
|
+
if (!r.ok) { process.stderr.write(`wendkeep change abandon: ${r.failing.join('; ')}\n`); process.exit(2); }
|
|
236
|
+
process.stdout.write(`abandoned: ${r.archivedRel}\n`);
|
|
237
|
+
process.exit(0);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
process.stderr.write(`wendkeep change: unknown subcommand "${sub}". Known: new, list, show, status, done, undone, diff, archive, abandon.\n`);
|
|
210
241
|
process.exit(2);
|
|
211
242
|
}
|
package/src/init.mjs
CHANGED
|
@@ -9,9 +9,12 @@ import { createInterface } from 'node:readline/promises';
|
|
|
9
9
|
import {
|
|
10
10
|
VAULT_FOLDERS,
|
|
11
11
|
SESSION_HOOKS,
|
|
12
|
+
CHANGE_NUDGE_HOOKS,
|
|
13
|
+
CHANGE_GATE_HOOKS,
|
|
12
14
|
MCP_SERVER_KEY,
|
|
13
15
|
mcpServerEntry,
|
|
14
16
|
hookCommand,
|
|
17
|
+
hookCommandLocal,
|
|
15
18
|
deriveVaultDirName,
|
|
16
19
|
selectableCompanions,
|
|
17
20
|
resolveCompanions,
|
|
@@ -91,27 +94,45 @@ function backup(path) {
|
|
|
91
94
|
|
|
92
95
|
// --- merges -----------------------------------------------------------------
|
|
93
96
|
|
|
94
|
-
|
|
97
|
+
// Comando preferido para um hook: node-direto quando o projeto tem o pacote local (alta
|
|
98
|
+
// frequência sem cold-start de npx — ver R3 do design 0.31.0); senão o npx portátil.
|
|
99
|
+
export function hookCommandFor(name, projectPath) {
|
|
100
|
+
try {
|
|
101
|
+
if (projectPath && existsSync(join(projectPath, 'node_modules', 'wendkeep', 'hooks', `${name}.mjs`))) {
|
|
102
|
+
return hookCommandLocal(name);
|
|
103
|
+
}
|
|
104
|
+
} catch { /* fs indisponível — npx */ }
|
|
105
|
+
return hookCommand(name);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function mergeSettings(existing, { vaultPath, withMcp, force, companions = [], skipMcp = [], dotcontextHookLevel = 'full', projectPath = '' }) {
|
|
95
109
|
const s = existing && typeof existing === 'object' ? { ...existing } : {};
|
|
96
110
|
s.hooks = { ...(s.hooks || {}) };
|
|
97
111
|
let added = 0;
|
|
98
|
-
// wendkeep's own session hooks +
|
|
99
|
-
// `order` (default 0) so higher-order companion
|
|
100
|
-
// fold AFTER wendkeep's own within each event. A spec
|
|
101
|
-
// (dotcontext dispatch) instead of a wendkeep hook `name`.
|
|
102
|
-
const allSpecs = [
|
|
103
|
-
|
|
104
|
-
|
|
112
|
+
// wendkeep's own session hooks + the change-lifecycle hooks (0.31.0, default) + any
|
|
113
|
+
// companion-authored hooks. Stable-sort by `order` (default 0) so higher-order companion
|
|
114
|
+
// hooks (e.g. dotcontext's order 100) fold AFTER wendkeep's own within each event. A spec
|
|
115
|
+
// may carry its own `command` (dotcontext dispatch) instead of a wendkeep hook `name`.
|
|
116
|
+
const allSpecs = [
|
|
117
|
+
...SESSION_HOOKS,
|
|
118
|
+
...CHANGE_NUDGE_HOOKS,
|
|
119
|
+
...CHANGE_GATE_HOOKS,
|
|
120
|
+
...companionHookSpecs(companions, { dotcontextHookLevel }),
|
|
121
|
+
].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
|
105
122
|
for (const h of allSpecs) {
|
|
106
|
-
const command = h.command ?? hookCommand(h.name);
|
|
123
|
+
const command = h.command ?? (h.preferLocal ? hookCommandFor(h.name, projectPath) : hookCommand(h.name));
|
|
124
|
+
// Dual-recognition: um hook nomeado é reconhecido tanto na forma npx quanto na node-direta,
|
|
125
|
+
// 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)];
|
|
107
127
|
const groups = Array.isArray(s.hooks[h.event]) ? [...s.hooks[h.event]] : [];
|
|
108
|
-
const owning = groups.find((g) => (g.hooks || []).some((x) => x.command
|
|
128
|
+
const owning = groups.find((g) => (g.hooks || []).some((x) => candidates.includes(x.command)));
|
|
109
129
|
if (owning) {
|
|
110
130
|
// Already wired: never add a duplicate group (the old `if (present && !force)` fell through
|
|
111
131
|
// under --force and appended a second identical group). Under --force, refresh the managed
|
|
112
132
|
// entry's fields in place — without disturbing any sibling hooks the user grouped with it.
|
|
113
133
|
if (force) {
|
|
114
|
-
const hk = owning.hooks.find((x) => x.command
|
|
134
|
+
const hk = owning.hooks.find((x) => candidates.includes(x.command));
|
|
135
|
+
hk.command = command;
|
|
115
136
|
hk.timeout = h.timeout;
|
|
116
137
|
if (h.statusMessage) hk.statusMessage = h.statusMessage;
|
|
117
138
|
if (h.matcher && (owning.hooks || []).length === 1) owning.matcher = h.matcher;
|
|
@@ -477,12 +498,12 @@ export async function runInit(argv) {
|
|
|
477
498
|
const settingsRead = readJsonSafe(settingsPath);
|
|
478
499
|
if (!settingsRead.ok) {
|
|
479
500
|
// Unparseable existing file — never clobber. Drop a .new for manual merge.
|
|
480
|
-
const fresh = mergeSettings(null, { vaultPath, withMcp: args.mcp, force: true, companions, skipMcp, dotcontextHookLevel }).settings;
|
|
501
|
+
const fresh = mergeSettings(null, { vaultPath, withMcp: args.mcp, force: true, companions, skipMcp, dotcontextHookLevel, projectPath }).settings;
|
|
481
502
|
writeJson(`${settingsPath}.new`, fresh);
|
|
482
503
|
log(M.settingsBadJson(settingsPath));
|
|
483
504
|
} else {
|
|
484
505
|
const hadFile = settingsRead.data !== null;
|
|
485
|
-
const { settings, added } = mergeSettings(settingsRead.data, { vaultPath, withMcp: args.mcp, force: args.force, companions, skipMcp, dotcontextHookLevel });
|
|
506
|
+
const { settings, added } = mergeSettings(settingsRead.data, { vaultPath, withMcp: args.mcp, force: args.force, companions, skipMcp, dotcontextHookLevel, projectPath });
|
|
486
507
|
if (hadFile) backup(settingsPath);
|
|
487
508
|
writeJson(settingsPath, settings);
|
|
488
509
|
log(M.settings(hadFile ? M.merged : M.created, added, hadFile ? M.bakSaved : ''));
|
package/src/skills-seed.mjs
CHANGED
|
@@ -16,6 +16,12 @@ const WORKFLOW = `# Loop a2 — o ciclo de trabalho do wendkeep
|
|
|
16
16
|
Use ao começar qualquer mudança não-trivial. O loop mantém memória (vault) e prova
|
|
17
17
|
(sensores) juntas, tudo linkado no grafo do Obsidian.
|
|
18
18
|
|
|
19
|
+
<HARD-GATE>
|
|
20
|
+
NÃO edite arquivos de código antes do passo 2 (Propose / \`wendkeep change new\`).
|
|
21
|
+
Toda tarefa não-trivial passa pelo loop — planejar no chat e sair editando deixa o
|
|
22
|
+
vault cego. Exceção única: mudança trivial (typo, 1 linha).
|
|
23
|
+
</HARD-GATE>
|
|
24
|
+
|
|
19
25
|
## Os passos
|
|
20
26
|
|
|
21
27
|
1. **Explore** — entenda o problema antes de propor. Leia o código/contexto relevante.
|
|
@@ -222,6 +228,12 @@ const WORKFLOW_EN = `# The a2 loop — wendkeep's work cycle
|
|
|
222
228
|
Use it when starting any non-trivial change. The loop keeps memory (vault) and proof
|
|
223
229
|
(sensors) together, wikilinked in the Obsidian graph.
|
|
224
230
|
|
|
231
|
+
<HARD-GATE>
|
|
232
|
+
Do NOT edit code files before step 2 (Propose / \`wendkeep change new\`).
|
|
233
|
+
Every non-trivial task goes through the loop — planning in chat and editing right away
|
|
234
|
+
leaves the vault blind. Single exception: a trivial change (typo, one line).
|
|
235
|
+
</HARD-GATE>
|
|
236
|
+
|
|
225
237
|
## Steps
|
|
226
238
|
|
|
227
239
|
1. **Explore** — understand the problem before proposing.
|
|
@@ -518,21 +530,24 @@ size of the problem>
|
|
|
518
530
|
<verifiable criteria: for each requirement, the test that fails → passes>
|
|
519
531
|
`;
|
|
520
532
|
|
|
533
|
+
// As descriptions são o gatilho de ativação: o harness casa a description com o PEDIDO do
|
|
534
|
+
// usuário ("implementa X", "corrige Y"), não com abstrações ("mudança não-trivial"). Gatilhos
|
|
535
|
+
// concretos + instrução imperativa = a skill dispara sozinha (paridade Superpowers).
|
|
521
536
|
const WK_SKILLS_PT = [
|
|
522
|
-
skill('wk-workflow', 'Use
|
|
537
|
+
skill('wk-workflow', 'Use SEMPRE que o usuário pedir para implementar, criar, corrigir, refatorar, adicionar ou alterar código — qualquer tarefa de código não-trivial. Invoque ANTES de editar qualquer arquivo: orquestra o loop a2 (wendkeep change new → tarefas → verify → archive) e registra tudo no vault.', WORKFLOW),
|
|
523
538
|
skill('wk-tdd', 'Use ao implementar qualquer comportamento — Red/Green/Refactor com testes que discriminam (derivados do spec, litmus não-raso, adequação).', TDD),
|
|
524
|
-
skill('wk-debugging', 'Use quando algo falha ou
|
|
525
|
-
skill('wk-brainstorming', 'Use quando a ideia ainda é vaga — vira design aprovado, com closure gate e tabela out-of-scope, antes de código.', BRAINSTORMING, [{ name: 'design-template.md', content: DESIGN_TEMPLATE_PT }]),
|
|
526
|
-
skill('wk-planning', 'Use após um design aprovado — decompõe em plano de tarefas TDD bite-sized.', PLANNING, [{ name: 'plan-template.md', content: PLAN_TEMPLATE_PT }]),
|
|
539
|
+
skill('wk-debugging', 'Use quando algo falha, quebra, dá erro ou regride — depuração sistemática por hipótese antes de corrigir.', DEBUGGING),
|
|
540
|
+
skill('wk-brainstorming', 'Use quando a ideia ainda é vaga ou o usuário quer discutir/planejar uma feature (inclusive em plan mode) — vira design aprovado, com closure gate e tabela out-of-scope, antes de código.', BRAINSTORMING, [{ name: 'design-template.md', content: DESIGN_TEMPLATE_PT }]),
|
|
541
|
+
skill('wk-planning', 'Use após um design aprovado ou um plano aceito (inclusive plan mode) — decompõe em plano de tarefas TDD bite-sized e registra na change ativa.', PLANNING, [{ name: 'plan-template.md', content: PLAN_TEMPLATE_PT }]),
|
|
527
542
|
skill('wk-verify', 'Use no verify deep — passe independente read-only (autor≠verificador) que re-deriva a cobertura do spec e grava verdict.json.', VERIFY, [{ name: 'spec-reviewer-prompt.md', content: REVIEWER_PROMPT_PT }, { name: 'verdict-template.json', content: VERDICT_TEMPLATE }]),
|
|
528
543
|
];
|
|
529
544
|
|
|
530
545
|
const WK_SKILLS_EN = [
|
|
531
|
-
skill('wk-workflow', 'Use
|
|
546
|
+
skill('wk-workflow', 'Use WHENEVER the user asks to implement, create, fix, refactor, add or change code — any non-trivial coding task. Invoke BEFORE editing any file: it orchestrates the a2 loop (wendkeep change new → tasks → verify → archive) and records everything in the vault.', WORKFLOW_EN),
|
|
532
547
|
skill('wk-tdd', 'Use when implementing any behaviour — Red/Green/Refactor with tests that discriminate (spec-derived, non-shallow litmus, adequacy).', TDD_EN),
|
|
533
|
-
skill('wk-debugging', 'Use when something fails or
|
|
534
|
-
skill('wk-brainstorming', 'Use when the idea is still vague — turns it into an approved design, with a closure gate and out-of-scope table, before code.', BRAINSTORMING_EN, [{ name: 'design-template.md', content: DESIGN_TEMPLATE_EN }]),
|
|
535
|
-
skill('wk-planning', 'Use after an approved design — decomposes it into a bite-sized TDD task plan.', PLANNING_EN, [{ name: 'plan-template.md', content: PLAN_TEMPLATE_EN }]),
|
|
548
|
+
skill('wk-debugging', 'Use when something fails, breaks, errors or regresses — systematic hypothesis-driven debugging before fixing.', DEBUGGING_EN),
|
|
549
|
+
skill('wk-brainstorming', 'Use when the idea is still vague or the user wants to discuss/plan a feature (plan mode included) — turns it into an approved design, with a closure gate and out-of-scope table, before code.', BRAINSTORMING_EN, [{ name: 'design-template.md', content: DESIGN_TEMPLATE_EN }]),
|
|
550
|
+
skill('wk-planning', 'Use after an approved design or an accepted plan (plan mode included) — decomposes it into a bite-sized TDD task plan recorded in the active change.', PLANNING_EN, [{ name: 'plan-template.md', content: PLAN_TEMPLATE_EN }]),
|
|
536
551
|
skill('wk-verify', 'Use in verify deep — an independent read-only pass (author≠verifier) that re-derives spec coverage and writes verdict.json.', VERIFY_EN, [{ name: 'spec-reviewer-prompt.md', content: REVIEWER_PROMPT_EN }, { name: 'verdict-template.json', content: VERDICT_TEMPLATE }]),
|
|
537
552
|
];
|
|
538
553
|
|
|
@@ -545,20 +560,22 @@ export const WK_SKILLS = WK_SKILLS_PT;
|
|
|
545
560
|
// Seed each skill into <brainDir>/skills/<name>/ if absent (non-destructive): SKILL.md plus any
|
|
546
561
|
// bundled template/prompt files. Existing files are never overwritten, so re-seeding an older
|
|
547
562
|
// install just fills in the new template files alongside its SKILL.md.
|
|
548
|
-
|
|
563
|
+
// { refresh: true } (sync-defs --reseed) SOBRESCREVE as wk-* com os seeds atuais — é como um
|
|
564
|
+
// vault existente recebe descriptions/HARD-GATE novos (edições manuais nas wk-* são perdidas).
|
|
565
|
+
export function seedWkSkills(brainDir, localeId = 'pt-BR', { refresh = false } = {}) {
|
|
549
566
|
const created = [];
|
|
550
567
|
for (const s of wkSkills(localeId)) {
|
|
551
568
|
const dir = join(brainDir, 'skills', s.name);
|
|
552
569
|
mkdirSync(dir, { recursive: true });
|
|
553
|
-
const
|
|
570
|
+
const write = (name, content) => {
|
|
554
571
|
const f = join(dir, name);
|
|
555
|
-
if (!existsSync(f)) {
|
|
572
|
+
if (refresh || !existsSync(f)) {
|
|
556
573
|
writeFileSync(f, content, 'utf8');
|
|
557
574
|
created.push(f);
|
|
558
575
|
}
|
|
559
576
|
};
|
|
560
|
-
|
|
561
|
-
for (const file of s.files || [])
|
|
577
|
+
write('SKILL.md', s.body);
|
|
578
|
+
for (const file of s.files || []) write(file.name, file.content);
|
|
562
579
|
}
|
|
563
580
|
return created;
|
|
564
581
|
}
|
package/src/sync-defs.mjs
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
// cross-platform robustness.
|
|
8
8
|
import { copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
|
|
9
9
|
import { isAbsolute, join, resolve } from 'node:path';
|
|
10
|
+
import { seedWkSkills } from './skills-seed.mjs';
|
|
11
|
+
import { getLocale } from '../hooks/locale.mjs';
|
|
10
12
|
|
|
11
13
|
// Managed AGENTS.md section (0.8.0): the agent-agnostic distribution channel. Codex, Amp,
|
|
12
14
|
// Cursor, Zed et al. read AGENTS.md — one file covers them all. Only the content between
|
|
@@ -114,6 +116,12 @@ export function runSyncDefs(argv) {
|
|
|
114
116
|
}
|
|
115
117
|
const vaultBase = isAbsolute(base) ? base : resolve(process.cwd(), base);
|
|
116
118
|
const projectPath = resolve(project || process.cwd());
|
|
119
|
+
// --reseed (0.31.0): sobrescreve as wk-* de .brain/skills com os seeds da versão instalada
|
|
120
|
+
// ANTES de copiar — é como um vault existente recebe descriptions/HARD-GATE novos.
|
|
121
|
+
if (argv.includes('--reseed')) {
|
|
122
|
+
const n = seedWkSkills(join(vaultBase, '.brain'), getLocale(vaultBase).id, { refresh: true });
|
|
123
|
+
process.stdout.write(`wendkeep sync-defs: ${n.length} arquivo(s) de skill re-semeados em .brain/skills\n`);
|
|
124
|
+
}
|
|
117
125
|
const r = syncDefs(vaultBase, projectPath);
|
|
118
126
|
process.stdout.write(
|
|
119
127
|
`wendkeep sync-defs: ${r.agents.length} agent(s) -> .codex/agents, ${r.skills.length} skill(s) -> .claude/skills\n`,
|
package/src/taxonomy.mjs
CHANGED
|
@@ -50,6 +50,11 @@ export const HOOK_FILES = [
|
|
|
50
50
|
'task-log.mjs',
|
|
51
51
|
'vault-health.mjs',
|
|
52
52
|
'understand-inject.mjs',
|
|
53
|
+
'change-context.mjs',
|
|
54
|
+
'change-warn.mjs',
|
|
55
|
+
'change-guard.mjs',
|
|
56
|
+
'change-nag.mjs',
|
|
57
|
+
'plan-capture.mjs',
|
|
53
58
|
];
|
|
54
59
|
|
|
55
60
|
// Hook scripts that are safe to invoke directly via `wendkeep hook <name>`.
|
|
@@ -68,6 +73,11 @@ export const RUNNABLE_HOOKS = [
|
|
|
68
73
|
'brain-reindex',
|
|
69
74
|
'vault-health',
|
|
70
75
|
'understand-inject',
|
|
76
|
+
'change-context',
|
|
77
|
+
'change-warn',
|
|
78
|
+
'change-guard',
|
|
79
|
+
'change-nag',
|
|
80
|
+
'plan-capture',
|
|
71
81
|
];
|
|
72
82
|
|
|
73
83
|
// The MCP server entry wendkeep wires into .mcp.json so the agent can read/write the
|
|
@@ -107,6 +117,27 @@ export function hookCommand(name) {
|
|
|
107
117
|
return `npx wendkeep hook ${name}`;
|
|
108
118
|
}
|
|
109
119
|
|
|
120
|
+
// Forma node-direta do comando de hook: 1 processo (~100-250ms) em vez dos 3 do npx (cold-start
|
|
121
|
+
// de segundos no Windows). Usada pelos hooks de ALTA FREQUÊNCIA (por prompt / por tool-call)
|
|
122
|
+
// quando o projeto tem wendkeep instalado localmente; o init decide (hookCommandFor).
|
|
123
|
+
export function hookCommandLocal(name) {
|
|
124
|
+
return `node node_modules/wendkeep/hooks/${name}.mjs`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Hooks do lifecycle de change (0.31.0) — enforcement do loop a2. Nudges (contexto/aviso/
|
|
128
|
+
// cobrança/captura de plano) e gate (deny/ask no Bash). Separados em dois grupos para
|
|
129
|
+
// preservar a opção futura de gates opt-in; hoje o init wira TODOS por default.
|
|
130
|
+
// preferLocal: alta frequência → invocação node-direta quando houver instalação local.
|
|
131
|
+
export const CHANGE_NUDGE_HOOKS = [
|
|
132
|
+
{ event: 'UserPromptSubmit', matcher: null, name: 'change-context', timeout: 15, order: 10, preferLocal: true, statusMessage: 'wendkeep: change ping' },
|
|
133
|
+
{ event: 'PostToolUse', matcher: 'Edit|Write|MultiEdit', name: 'change-warn', timeout: 10, order: 10, preferLocal: true, statusMessage: 'wendkeep: change warn' },
|
|
134
|
+
{ event: 'PostToolUse', matcher: 'ExitPlanMode', name: 'plan-capture', timeout: 15, order: 10, preferLocal: true, statusMessage: 'wendkeep: capturing approved plan' },
|
|
135
|
+
{ event: 'Stop', matcher: null, name: 'change-nag', timeout: 15, order: 10, preferLocal: true, statusMessage: 'wendkeep: open tasks check' },
|
|
136
|
+
];
|
|
137
|
+
export const CHANGE_GATE_HOOKS = [
|
|
138
|
+
{ event: 'PreToolUse', matcher: 'Bash', name: 'change-guard', timeout: 10, order: 10, preferLocal: true, statusMessage: 'wendkeep: change gate' },
|
|
139
|
+
];
|
|
140
|
+
|
|
110
141
|
// --- companion plugins / MCP --------------------------------------------------
|
|
111
142
|
// Optional tools wendkeep init can pin alongside the vault. Each is wired through
|
|
112
143
|
// the MOST agent-agnostic mechanism it supports; the Claude Code plugin entry
|