wendkeep 0.30.0 → 0.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +109 -0
- package/bin/wendkeep.mjs +4 -1
- package/hooks/brain-inject.mjs +19 -12
- package/hooks/change-context.mjs +61 -0
- package/hooks/change-core.mjs +143 -12
- package/hooks/change-guard.mjs +59 -0
- package/hooks/change-nag.mjs +31 -0
- package/hooks/change-warn.mjs +48 -0
- package/hooks/decision-capture.mjs +41 -2
- package/hooks/harness-doctor.mjs +4 -1
- package/hooks/plan-capture.mjs +168 -0
- package/hooks/session-stop.mjs +11 -1
- package/hooks/spec-core.mjs +73 -4
- package/hooks/subagent-usage.mjs +3 -1
- package/hooks/vault-health.mjs +6 -5
- package/package.json +1 -1
- package/src/change.mjs +48 -14
- package/src/init.mjs +56 -14
- package/src/skills-seed.mjs +56 -13
- package/src/sync-defs.mjs +8 -0
- package/src/taxonomy.mjs +35 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,115 @@ 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
|
+
|
|
51
|
+
## [0.31.0] — 2026-07-09
|
|
52
|
+
|
|
53
|
+
### Added — enforcement do loop a2 (o loop deixa de ser opcional na prática)
|
|
54
|
+
|
|
55
|
+
- **5 hooks de lifecycle novos**, wired por default pelo `wendkeep init` (invocação **node-direta**
|
|
56
|
+
`node node_modules/wendkeep/hooks/<name>.mjs` quando o pacote está instalado no projeto —
|
|
57
|
+
~100-250ms vs segundos do npx no Windows; fallback npx):
|
|
58
|
+
- **`change-context`** (UserPromptSubmit) — re-injeta a change ativa (`<active_change_ping>`:
|
|
59
|
+
slug + tarefas abertas) SÓ quando o estado mudou desde a última injeção (hash em sentinela por
|
|
60
|
+
sessão). Sem change ativa: prompt com cara de tarefa ganha `<wk_skill_gate>` mandando invocar
|
|
61
|
+
a Skill wk-workflow ANTES de editar — 1x por sessão.
|
|
62
|
+
- **`change-warn`** (PostToolUse `Edit|Write|MultiEdit`) — edição de código sem change ativa
|
|
63
|
+
gera aviso 1x/sessão (nunca bloqueia; ignora vault/.claude/.agent/.brain e não-código).
|
|
64
|
+
- **`change-guard`** (PreToolUse `Bash`) — `wendkeep change archive --force` vindo do agente é
|
|
65
|
+
**negado** (deny; escape: `WENDKEEP_ALLOW_FORCE=1` no ambiente); `git commit` com change ativa
|
|
66
|
+
E (`--no-verify` OU sensor crítico vermelho) vira **ask** (o usuário decide). Fast-path sem
|
|
67
|
+
I/O para comandos comuns.
|
|
68
|
+
- **`change-nag`** (Stop) — change ativa com tarefas abertas bloqueia o encerramento 1x/sessão
|
|
69
|
+
cobrando fechamento honesto (done / verify / **ou informar a pendência ao usuário**).
|
|
70
|
+
Anti-loop absoluto via `stop_hook_active`.
|
|
71
|
+
- **`plan-capture`** (PostToolUse `ExitPlanMode`) — **a ponte determinística plan-mode → vault**:
|
|
72
|
+
plano aprovado no plan mode do Claude Code vira change no vault (proposta do Contexto, design
|
|
73
|
+
do corpo, tarefas dos checkboxes) ou anexa `plano-aprovado.md` à change ativa. Não depende de
|
|
74
|
+
a LLM lembrar do processo.
|
|
75
|
+
- **`wendkeep change abandon [slug]`** — a saída legítima para change que não vai adiante: move
|
|
76
|
+
para `_arquivo/<data>-<slug>-abandonada` com `status: abandoned`, SEM ADR, SEM promoção de
|
|
77
|
+
specs; limpa o ponteiro só se era a ativa. Elimina o motivo real de `--force` em scaffold.
|
|
78
|
+
- **`quickGateState(vaultBase)`** + sentinelas por sessão (`.brain/.change-*-<sid>`, GC >7 dias no
|
|
79
|
+
Stop) em change-core — fonte única do estado do gate para hooks e CLI.
|
|
80
|
+
- **`wendkeep sync-defs --reseed`** — re-semeia as skills wk-* de `.brain/skills` com os seeds da
|
|
81
|
+
versão instalada (é como um vault existente recebe as descriptions/HARD-GATE novos).
|
|
82
|
+
|
|
83
|
+
### Changed — gate endurecido + ativação da skill
|
|
84
|
+
|
|
85
|
+
- **Verdict SEMPRE exigido no archive** (breaking-ish): sem `verdict.json` o archive bloqueia,
|
|
86
|
+
mesmo sem `[req:]` — `wendkeep verify --deep` grava o verdict trivial automático (1 comando).
|
|
87
|
+
Changes em andamento criadas antes de 0.31.0 precisam de um `verify --deep` antes do archive.
|
|
88
|
+
- **G0 inescapável**: `--force` deixa de pular o check de scaffold — um scaffold cru NUNCA é
|
|
89
|
+
arquivável (era o buraco que mintou ADR falso em produção).
|
|
90
|
+
- **`--force` e trivialidade rastreáveis**: ADR ganha `forced: true` (+ aviso ⚠️ no corpo) quando
|
|
91
|
+
`--force` pulou tarefa aberta, e `trivial: true` quando a change não declarou `[req:]`/`[sensor:]`.
|
|
92
|
+
- **Promoção de specs = união frontmatter + disco**: o archive promove também os deltas REAIS
|
|
93
|
+
achados em `specs/*/spec.md` mesmo com `specs: []` na proposta (warning por cap não listada;
|
|
94
|
+
o `exemplo` placeholder do scaffold é filtrado). Fecha o buraco que deixava 07-Specs vazio com
|
|
95
|
+
delta preenchido no disco.
|
|
96
|
+
- **Ativação da skill (paridade Superpowers)**: descriptions das wk-* reescritas com gatilhos
|
|
97
|
+
concretos ("Use SEMPRE que o usuário pedir para implementar/criar/corrigir/refatorar…
|
|
98
|
+
Invoque ANTES de editar qualquer arquivo"); `<HARD-GATE>` no corpo da wk-workflow; o
|
|
99
|
+
`<wk_process>` do brain-inject agora manda **invocar a Skill** (verbo de skill) e cita `abandon`.
|
|
100
|
+
|
|
101
|
+
### Fixed
|
|
102
|
+
|
|
103
|
+
- **Seções apagadas pelo Stop (classe de bug, visto em produção)**: qualquer seção inserida entre
|
|
104
|
+
`## Pendências` e `## Encerramento` era descartada pelo finalize (`replacePendingSection`
|
|
105
|
+
reconstruía o span inteiro) — atingia `## Subagents & Workflows` (frontmatter sobrevivia, corpo
|
|
106
|
+
sumia), `## Progresso do plano` e `## Mudanças`. Duas camadas: `upsertSection` agora ancora
|
|
107
|
+
ANTES de `## Pendências`, e o finalize preserva seções desconhecidas dentro do span. Notas
|
|
108
|
+
antigas se autocuram no próximo Stop/backfill (os dados persistem em `subagents/`).
|
|
109
|
+
|
|
110
|
+
### Known limitation (aceita e documentada)
|
|
111
|
+
|
|
112
|
+
- Hooks do mesmo evento rodam em **paralelo** no Claude Code: quando o `change-nag` bloqueia o
|
|
113
|
+
Stop, o `session-stop` já finalizou a nota — o turno de continuação não é logado nela
|
|
114
|
+
(recuperável via `session-backfill`/`import`; perda máx. de 1 turno, 1x por sessão).
|
|
115
|
+
|
|
7
116
|
## [0.30.0] — 2026-07-09
|
|
8
117
|
|
|
9
118
|
### 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:
|
|
24
|
-
'2. Record: `wendkeep change new <slug>` and FILL
|
|
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
|
+
'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
|
-
'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:
|
|
35
|
-
'2. Registrar: `wendkeep change new <slug>` e PREENCHA
|
|
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
|
+
'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
|
-
'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';
|
|
@@ -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);
|
|
@@ -183,6 +190,79 @@ export function activeChangeLink(vaultBase) {
|
|
|
183
190
|
return slug ? `Change ativa: [[${getLocale(vaultBase).folders.changes}/${slug}/proposta]]` : '';
|
|
184
191
|
}
|
|
185
192
|
|
|
193
|
+
// --- Estado rápido do gate + sentinelas por sessão (0.31.0) --------------------
|
|
194
|
+
// Fonte única e barata (só leituras, tudo fail-open) do estado do gate, consumida pelos hooks
|
|
195
|
+
// de lifecycle (change-guard/change-nag/change-context) e pelo CLI. null sem change ativa.
|
|
196
|
+
export function quickGateState(vaultBase) {
|
|
197
|
+
const slug = activeChange(vaultBase);
|
|
198
|
+
if (!slug) return null;
|
|
199
|
+
const dir = join(vaultBase, getLocale(vaultBase).folders.changes, slug);
|
|
200
|
+
let tarefasMd = '';
|
|
201
|
+
try { tarefasMd = readFileSync(join(dir, 'tarefas.md'), 'utf8'); } catch { /* sem tarefas */ }
|
|
202
|
+
const openTasks = parseTasks(tarefasMd).filter((t) => !t.done).length;
|
|
203
|
+
let redCritical = false;
|
|
204
|
+
try {
|
|
205
|
+
const ev = JSON.parse(readFileSync(join(dir, 'evidencia.json'), 'utf8'));
|
|
206
|
+
redCritical = (Array.isArray(ev) ? ev : []).some((e) => e.status !== 'green' && (e.severity || 'critical') !== 'warning');
|
|
207
|
+
} catch { /* sem/ilegível = não conta contra o nudge */ }
|
|
208
|
+
let evidenceStale = false;
|
|
209
|
+
try {
|
|
210
|
+
const h = readFileSync(join(dir, '.evidence-hash'), 'utf8').trim();
|
|
211
|
+
evidenceStale = Boolean(h) && h !== tasksHashOf(tarefasMd);
|
|
212
|
+
} catch { /* nunca selada */ }
|
|
213
|
+
return { slug, openTasks, redCritical, evidenceStale, placeholders: scaffoldPlaceholders(dir).length };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Sentinelas por sessão em .brain/ — memória "já avisei/injetei nesta sessão" dos hooks de
|
|
217
|
+
// lifecycle. kind: 'ctx' | 'warn' | 'nag' | 'gate'. session_id sanitizado para nome de arquivo.
|
|
218
|
+
const sanitizeSid = (sid) => String(sid || 'nosession').replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 64);
|
|
219
|
+
|
|
220
|
+
export function sentinelPath(vaultBase, kind, sid) {
|
|
221
|
+
return join(vaultBase, '.brain', `.change-${kind}-${sanitizeSid(sid)}`);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function readSentinel(vaultBase, kind, sid) {
|
|
225
|
+
try { return readFileSync(sentinelPath(vaultBase, kind, sid), 'utf8').trim(); } catch { return ''; }
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function writeSentinel(vaultBase, kind, sid, value = '1') {
|
|
229
|
+
try {
|
|
230
|
+
mkdirSync(join(vaultBase, '.brain'), { recursive: true });
|
|
231
|
+
writeFileSync(sentinelPath(vaultBase, kind, sid), value, 'utf8');
|
|
232
|
+
} catch { /* fail-open: pior caso = aviso repetido */ }
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Estado da change ativa para o ping do change-context: hash inclui o slug para que trocar de
|
|
236
|
+
// change com tarefas idênticas ainda re-pingue.
|
|
237
|
+
export function changeCtxState(vaultBase, { maxTasks = 5 } = {}) {
|
|
238
|
+
const slug = activeChange(vaultBase);
|
|
239
|
+
if (!slug) return null;
|
|
240
|
+
let md = '';
|
|
241
|
+
try { md = readFileSync(join(vaultBase, getLocale(vaultBase).folders.changes, slug, 'tarefas.md'), 'utf8'); } catch { /* sem tarefas */ }
|
|
242
|
+
return { slug, hash: tasksHashOf(`${slug}\n${md}`), openTasks: parseTasks(md).filter((t) => !t.done).slice(0, maxTasks) };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// GC das sentinelas (>7 dias) — seleção pura separada da execução (testável sem depender de
|
|
246
|
+
// unlink funcionar no sandbox). Roda no finalize do session-stop, fail-quiet.
|
|
247
|
+
const SENTINEL_RE = /^\.change-(?:ctx|warn|nag|gate)-/;
|
|
248
|
+
|
|
249
|
+
export function staleSentinelNames(entries, now = Date.now(), maxAgeMs = 7 * 86400000) {
|
|
250
|
+
return (entries || []).filter((e) => SENTINEL_RE.test(e.name) && now - e.mtimeMs > maxAgeMs).map((e) => e.name);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function pruneChangeSentinels(vaultBase, { now = Date.now() } = {}) {
|
|
254
|
+
const dir = join(vaultBase, '.brain');
|
|
255
|
+
let entries = [];
|
|
256
|
+
try {
|
|
257
|
+
entries = readdirSync(dir)
|
|
258
|
+
.map((name) => { try { return { name, mtimeMs: statSync(join(dir, name)).mtimeMs }; } catch { return null; } })
|
|
259
|
+
.filter(Boolean);
|
|
260
|
+
} catch { return []; }
|
|
261
|
+
const stale = staleSentinelNames(entries, now);
|
|
262
|
+
for (const name of stale) { try { unlinkSync(join(dir, name)); } catch { /* fail-quiet */ } }
|
|
263
|
+
return stale;
|
|
264
|
+
}
|
|
265
|
+
|
|
186
266
|
// Append fix tasks for surviving mutants to a change's tarefas.md (Wave B). Deduped by
|
|
187
267
|
// file:line, numbered M.<n> continuing from any existing fix tasks. Returns count added.
|
|
188
268
|
export function appendFixTasks(changeDir, mutants, sensorId) {
|
|
@@ -214,10 +294,16 @@ export function gateGreen() {
|
|
|
214
294
|
return { ok: true, failing: [] };
|
|
215
295
|
}
|
|
216
296
|
|
|
217
|
-
export function archiveChange(vaultBase, slug, { gate = gateGreen, dateStr, adrNum }) {
|
|
297
|
+
export function archiveChange(vaultBase, slug, { gate = gateGreen, dateStr, adrNum, adrFlags = {} }) {
|
|
218
298
|
const loc = getLocale(vaultBase);
|
|
219
299
|
const chDir = loc.folders.changes;
|
|
220
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 */ }
|
|
221
307
|
const verdict = gate(src);
|
|
222
308
|
if (!verdict.ok) return { ok: false, failing: verdict.failing || [] };
|
|
223
309
|
|
|
@@ -233,16 +319,27 @@ export function archiveChange(vaultBase, slug, { gate = gateGreen, dateStr, adrN
|
|
|
233
319
|
}
|
|
234
320
|
|
|
235
321
|
// Promote spec deltas into the living 07-Specs BEFORE moving (deltas live in src).
|
|
322
|
+
// UNIÃO frontmatter + disco (0.31.0): o scaffold deixa `specs: []`, então um delta real
|
|
323
|
+
// preenchido em specs/<cap>/ mas não listado era silenciosamente ignorado. Deltas ainda em
|
|
324
|
+
// placeholder (o `exemplo` do scaffold) são filtrados por discoverSpecDeltas.
|
|
236
325
|
let promoted = [];
|
|
237
326
|
let specWarnings = [];
|
|
238
327
|
try {
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
328
|
+
let listed = [];
|
|
329
|
+
try { listed = parseSpecsList(readFileSync(join(src, 'proposta.md'), 'utf8')); } catch { /* proposta ilegível */ }
|
|
330
|
+
const onDisk = discoverSpecDeltas(src);
|
|
331
|
+
const union = [...new Set([...listed, ...onDisk])];
|
|
332
|
+
if (union.length) {
|
|
333
|
+
const res = promoteSpecs(vaultBase, src, union, { changeWikilink, dateStr });
|
|
242
334
|
promoted = res.promoted;
|
|
243
|
-
specWarnings =
|
|
335
|
+
specWarnings = [
|
|
336
|
+
...onDisk.filter((c) => !listed.includes(c)).map((c) => `spec no disco não listada no frontmatter da proposta: ${c} — promovida assim mesmo`),
|
|
337
|
+
...res.warnings,
|
|
338
|
+
];
|
|
244
339
|
}
|
|
245
|
-
} catch {
|
|
340
|
+
} catch (error) {
|
|
341
|
+
return { ok: false, failing: [`falha ao promover specs: ${error.message}`] };
|
|
342
|
+
}
|
|
246
343
|
|
|
247
344
|
let reqIds = [];
|
|
248
345
|
try { reqIds = [...new Set(parseTasks(readFileSync(join(src, 'tarefas.md'), 'utf8')).map((t) => t.req).filter(Boolean))]; } catch { /* sem tarefas */ }
|
|
@@ -261,6 +358,17 @@ export function archiveChange(vaultBase, slug, { gate = gateGreen, dateStr, adrN
|
|
|
261
358
|
writeFileSync(pp, c, 'utf8');
|
|
262
359
|
} catch { /* proposta ilegível — segue */ }
|
|
263
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
|
+
|
|
264
372
|
// ADR goes in the same dated month folder as session-derived decisions (04-Decisões/ano/MM-MMM/)
|
|
265
373
|
// — not the year root — so all ADRs sit together in the vault's convention.
|
|
266
374
|
const adrDirRel = monthFolderRelFromDateStr(loc.folders.decisions, dateStr, vaultBase);
|
|
@@ -271,10 +379,13 @@ export function archiveChange(vaultBase, slug, { gate = gateGreen, dateStr, adrN
|
|
|
271
379
|
? `\n\nCapabilities: ${promoted.map((c) => wikilinkFromRel(join(loc.folders.specs, c))).join(', ')}.`
|
|
272
380
|
: '';
|
|
273
381
|
const reqLine = reqIds.length ? `\n\nRequisitos: ${reqIds.join(', ')}.` : '';
|
|
382
|
+
// Rastro auditável (0.31.0): um archive forçado ou sem prova declarada fica marcado no ADR.
|
|
383
|
+
const flagLines = `${adrFlags.forced ? '\nforced: true' : ''}${adrFlags.trivial ? '\ntrivial: true' : ''}`;
|
|
384
|
+
const forcedNote = adrFlags.forced ? '\n\n> ⚠️ Arquivada com --force — havia tarefa(s) aberta(s) pulada(s) no gate.' : '';
|
|
274
385
|
writeFileSync(join(vaultBase, adrRel), `---
|
|
275
386
|
type: decision
|
|
276
387
|
status: accepted
|
|
277
|
-
date: ${dateStr}
|
|
388
|
+
date: ${dateStr}${flagLines}
|
|
278
389
|
cssclasses:
|
|
279
390
|
- topic-decision
|
|
280
391
|
tags:
|
|
@@ -285,7 +396,7 @@ tags:
|
|
|
285
396
|
|
|
286
397
|
## Decisão
|
|
287
398
|
|
|
288
|
-
Mudança ${changeWikilink} concluída e arquivada.${capLine}${reqLine}
|
|
399
|
+
Mudança ${changeWikilink} concluída e arquivada.${capLine}${reqLine}${forcedNote}
|
|
289
400
|
`, 'utf8');
|
|
290
401
|
|
|
291
402
|
// Only clear the pointer when the archived change IS the active one — archiving some other
|
|
@@ -293,3 +404,23 @@ Mudança ${changeWikilink} concluída e arquivada.${capLine}${reqLine}
|
|
|
293
404
|
if (activeChange(vaultBase) === slug) clearActiveChange(vaultBase);
|
|
294
405
|
return { ok: true, failing: [], archivedRel: destRel, adrRel, promoted, specWarnings };
|
|
295
406
|
}
|
|
407
|
+
|
|
408
|
+
// Abandono (0.31.0): a saída legítima para uma change que não vai adiante — o que antes só o
|
|
409
|
+
// `archive --force` "resolvia", minting um ADR falso. Sem ADR, sem promoteSpecs (abandono não é
|
|
410
|
+
// decisão arquitetural nem promove contrato); move para _arquivo com sufixo -abandonada.
|
|
411
|
+
export function abandonChange(vaultBase, slug, { dateStr }) {
|
|
412
|
+
const chDir = getLocale(vaultBase).folders.changes;
|
|
413
|
+
const src = join(vaultBase, chDir, slug);
|
|
414
|
+
if (!existsSync(join(src, 'proposta.md'))) return { ok: false, failing: [`change não encontrada: ${slug}`] };
|
|
415
|
+
const destRel = join(chDir, ARCHIVE_DIR, `${dateStr}-${slug}-abandonada`);
|
|
416
|
+
const destAbs = join(vaultBase, destRel);
|
|
417
|
+
if (existsSync(destAbs)) return { ok: false, failing: [`destino já existe: ${destRel}`] };
|
|
418
|
+
ensureDir(join(vaultBase, chDir, ARCHIVE_DIR));
|
|
419
|
+
try { renameSync(src, destAbs); } catch (e) { return { ok: false, failing: [`falha ao mover: ${e.message}`] }; }
|
|
420
|
+
try {
|
|
421
|
+
const pp = join(destAbs, 'proposta.md');
|
|
422
|
+
writeFileSync(pp, readFileSync(pp, 'utf8').replace(/^status:\s*active\s*$/m, 'status: abandoned'), 'utf8');
|
|
423
|
+
} catch { /* proposta sem frontmatter — segue */ }
|
|
424
|
+
if (activeChange(vaultBase) === slug) clearActiveChange(vaultBase);
|
|
425
|
+
return { ok: true, failing: [], archivedRel: destRel };
|
|
426
|
+
}
|
|
@@ -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
|
+
}
|