wendkeep 0.29.2 → 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 +89 -0
- package/bin/wendkeep.mjs +12 -1
- package/hooks/brain-inject.mjs +17 -10
- package/hooks/change-context.mjs +61 -0
- package/hooks/change-core.mjs +116 -11
- 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 +37 -9
- package/hooks/linked-notes.mjs +3 -3
- package/hooks/plan-capture.mjs +116 -0
- package/hooks/renumber-decisions.mjs +198 -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/renumber.mjs +32 -0
- package/src/skills-seed.mjs +30 -13
- package/src/sync-defs.mjs +8 -0
- package/src/taxonomy.mjs +32 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,95 @@ 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
|
+
|
|
72
|
+
## [0.30.0] — 2026-07-09
|
|
73
|
+
|
|
74
|
+
### Changed
|
|
75
|
+
- **Decision notes follow the ADR convention: `ADR-<NNNN>-<slug>`.** Every decision note now carries
|
|
76
|
+
a 4-digit, zero-padded sequential number assigned in the order decisions are made (`ADR-0001`,
|
|
77
|
+
`ADR-0002`, …) — replacing the old `YYYY-MM-DD-escolha-<slug>` filenames from the interactive and
|
|
78
|
+
prose captures. The number goes in the filename, in an `adr:` frontmatter field, and as an
|
|
79
|
+
`# ADR-NNNN — <title>` H1 prefix. The native `wendkeep change archive` ADR and the
|
|
80
|
+
`createLinkedNotes` heuristic ADR widen from 3 to 4 digits (`ADR-007` → `ADR-0007`) to match.
|
|
81
|
+
- **Decision capture dedups by `content_key`, not by filename.** Because the filename now carries a
|
|
82
|
+
fresh ADR number it can't dedup, so a decision already recorded in the target folder (same
|
|
83
|
+
normalized question) is skipped by content — both the AskUserQuestion hook (`captureDecision`) and
|
|
84
|
+
the agnostic prose capture (`captureProseDecisions`). New `decisionKeyExists` / `padAdr` helpers.
|
|
85
|
+
|
|
86
|
+
### Added
|
|
87
|
+
- **`wendkeep renumber-decisions`** — retroactive fix for vaults that accumulated the three historical
|
|
88
|
+
naming eras (`ADR-NNN`, dated `escolha`, hand-written). Renumbers **every** note in 04-Decisões to
|
|
89
|
+
`ADR-<NNNN>-<slug>` in strict chronological order, renames the files in place, and **rewrites every
|
|
90
|
+
wikilink to them across the whole vault** (full-path, basename, and `|ADR-006` display aliases).
|
|
91
|
+
Normalizes each note's `type: decision` / `adr:` / H1. Preview by default (writes nothing); pass
|
|
92
|
+
`--apply` to commit the renames. Idempotent — a second run on a canonical vault is a no-op.
|
|
93
|
+
`--vault P` / `--json`. New `hooks/renumber-decisions.mjs` (`planRenumber`, `renumberDecisions`,
|
|
94
|
+
`slugFromDecisionName`, `decisionSortKey`, `normalizeDecisionContent`, `rewriteLinks`).
|
|
95
|
+
|
|
7
96
|
## [0.29.2] — 2026-07-09
|
|
8
97
|
|
|
9
98
|
### Fixed
|
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.
|
|
@@ -60,6 +61,9 @@ Usage:
|
|
|
60
61
|
wendkeep verify [--deep] [--change s] Run a change's task sensors + record evidence (the gate);
|
|
61
62
|
--deep assembles the verification package for the wk-verify pass.
|
|
62
63
|
wendkeep dashboard [--force] (Re)generate the vault's folder-filtered Bases + 00-Dashboard MOC.
|
|
64
|
+
wendkeep renumber-decisions Renumber 04-Decisões to ADR-<NNNN>-<slug> in chronological order,
|
|
65
|
+
renaming files + rewriting every wikilink. Preview by default; --apply to
|
|
66
|
+
write. --vault P · --json.
|
|
63
67
|
wendkeep lesson add "t" "l" Record a project-local lesson (injected at SessionStart).
|
|
64
68
|
wendkeep validate-memory [path] Validate .brain/CORE.md against the compaction
|
|
65
69
|
protocol (cap 25, 3 sections, no secrets/PII). Uses
|
|
@@ -67,6 +71,8 @@ Usage:
|
|
|
67
71
|
wendkeep sync-defs [opts] Copy versioned defs from the vault's .brain into the
|
|
68
72
|
project: .brain/agents/*.toml -> .codex/agents,
|
|
69
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.
|
|
70
76
|
wendkeep --version Print version.
|
|
71
77
|
wendkeep --help Show this help.
|
|
72
78
|
`;
|
|
@@ -162,6 +168,11 @@ async function main() {
|
|
|
162
168
|
runDashboard(rest);
|
|
163
169
|
break;
|
|
164
170
|
}
|
|
171
|
+
case 'renumber-decisions': {
|
|
172
|
+
const { runRenumberDecisions } = await import('../src/renumber.mjs');
|
|
173
|
+
runRenumberDecisions(rest);
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
165
176
|
case '--version':
|
|
166
177
|
case '-v':
|
|
167
178
|
process.stdout.write(`${version()}\n`);
|
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 */ }
|
|
@@ -265,16 +347,19 @@ export function archiveChange(vaultBase, slug, { gate = gateGreen, dateStr, adrN
|
|
|
265
347
|
// — not the year root — so all ADRs sit together in the vault's convention.
|
|
266
348
|
const adrDirRel = monthFolderRelFromDateStr(loc.folders.decisions, dateStr, vaultBase);
|
|
267
349
|
ensureDir(join(vaultBase, adrDirRel));
|
|
268
|
-
const num = String(adrNum).padStart(
|
|
350
|
+
const num = String(adrNum).padStart(4, '0');
|
|
269
351
|
const adrRel = join(adrDirRel, `ADR-${num}-${slug}.md`);
|
|
270
352
|
const capLine = promoted.length
|
|
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
|
+
}
|