wendkeep 0.45.1 → 0.46.1

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 CHANGED
@@ -4,6 +4,129 @@ 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.46.1] — 2026-07-19
8
+
9
+ ### Fixed
10
+
11
+ - **Turnos do Codex sumiam da nota sem nenhum aviso.** No Windows o Codex serializa o payload
12
+ do `Stop` com o campo `last_assistant_message` cortado no meio, sem fechar a string JSON —
13
+ bug upstream ainda aberto ([openai/codex#23784](https://github.com/openai/codex/issues/23784)).
14
+ Sessão em português enche esse campo de acento, então o corte é frequente. O
15
+ `readHookInput` fazia `JSON.parse` cru, lançava, e o `session-stop` saía com código 0
16
+ escrevendo só no stderr — que o Codex descarta. Resultado: a nota era criada, o summary
17
+ atualizava a cada prompt, e nenhuma iteração jamais entrava. Só o `Stop` quebrava porque
18
+ `last_assistant_message` é o único campo exclusivo dele; `SessionStart` e
19
+ `UserPromptSubmit` não o carregam.
20
+ Como esse campo é o **último** do `StopCommandInput`, tudo que o wendkeep consome
21
+ (`session_id`, `turn_id`, `transcript_path`, `cwd`) está no prefixo bem-formado. O
22
+ `readHookInput` passa a recuperar esse prefixo numa passada só, descartando o campo
23
+ truncado — nunca reconstruindo-o, porque metade de uma mensagem é dado inventado.
24
+ - **O hook parou de falhar em silêncio.** Todo caminho de bail do `session-stop` agora emite
25
+ `systemMessage`, que a UI do Codex mostra, com o motivo e o comando de recuperação. O exit
26
+ code continua 0 de propósito: hook de `Stop` que sai diferente de zero trava o turno
27
+ (openai/codex#21921), e trocar turno perdido por sessão travada é pior negócio.
28
+ - **`resolveSessionIdentity` passa a usar o `SESSION_REGISTRY` como fonte do
29
+ `transcript_path`** quando o payload não o traz. O registry já tinha o mapeamento; o lookup
30
+ é que ficava abaixo do gate, inalcançável justo no caso que resolveria. A entrada precisa
31
+ ser do mesmo provider, o que preserva o invariante do incidente de contaminação
32
+ cross-provider de 2026-07-11.
33
+ - **`wendkeep import` deixou de ser cego para a sessão danificada.** O dedup perguntava
34
+ "existe registro?", não "existe conteúdo?" — e como o `session-start` registra antes do
35
+ `session-stop` escrever, **as sessões esvaziadas pelo bug acima eram exatamente as que o
36
+ comando de recuperação se recusava a consertar.** Agora a decisão compara os turnos do
37
+ transcript com os marcadores `wk-turn` já na nota: cobertura completa pula, parcial ou
38
+ vazia completa a nota existente sem criar uma segunda. Sem flag opt-in — quem roda `import`
39
+ depois de perder sessão não tem como saber que precisaria de uma. O relatório ganhou a
40
+ categoria `repaired`, separada de `imported` (nota nova) e de `skipped` (já completa).
41
+ - **Sessões importadas ganhavam título de bloco injetado pelo harness.** Seis notas de um
42
+ mesmo projeto ficaram chamadas `<recommended_plugins> Here is a list of plugins that ar`,
43
+ no frontmatter e no nome do arquivo. Causa de uma linha: `buildIterationBlock` seleciona
44
+ `userPrompts.at(-1)` e o `deriveSummary` usava `.find(Boolean)` — o harness injeta o bloco
45
+ como **primeiro** prompt do turno e o pedido do usuário vem por **último**. Mesmo dado,
46
+ ponta oposta. As duas seleções agora são a mesma, com `isBootstrapPrompt` (que passou a
47
+ reconhecer `<recommended_plugins>`) como rede, aplicado ao prompt inteiro e não linha a
48
+ linha — filtrar por linha cairia na linha seguinte do próprio bloco injetado.
49
+
50
+ ### Recuperação
51
+
52
+ - Quem perdeu turnos de sessões Codex antes desta versão recupera com
53
+ `wendkeep import --source codex`. O rollout do Codex fica íntegro em disco, e o import agora
54
+ completa a nota existente em vez de pulá-la. Rodar mais de uma vez é no-op.
55
+ - Notas já criadas com título poluído **não** são renomeadas automaticamente: mexer em nome de
56
+ arquivo quebra wikilink e reorganiza o grafo, e isso é decisão do dono do vault.
57
+
58
+ ## [0.46.0] — 2026-07-18
59
+
60
+ ### Added
61
+
62
+ - `wendkeep init` agora escreve também `<projeto>/.codex/hooks.json`, e não só
63
+ `.claude/settings.json`. Fechava aqui o buraco mais confuso do onboarding com Codex: o
64
+ `.mcp.json` gerado deixava o vault **alcançável**, então tudo parecia certo — mas sem hooks
65
+ não havia sessão, `CURRENT_SESSION.md` nunca aparecia e o `registrySessions` ficava em 0. As
66
+ saídas eram escrever o `.codex/hooks.json` à mão ou rodar `wendkeep import --source codex`
67
+ depois do fato, ambas descobertas tarde demais. Um projeto novo com Codex nasce com sessão.
68
+ - Sete hooks wirados, marcados `codex: true` em `src/taxonomy.mjs`: `brain-inject`
69
+ (SessionStart, matcher `startup|clear|compact`), `session-start` (SessionStart, `startup`),
70
+ `session-ensure` e `change-context` (UserPromptSubmit, sem matcher), `session-stop` e
71
+ `change-nag` (Stop, sem matcher) e `subagent-stop` (SubagentStop).
72
+ - Cinco hooks ficaram **deliberadamente de fora**, cada um com um comentário `// codex:` no
73
+ `src/taxonomy.mjs` explicando o porquê — projetar um hook que não funciona é pior que não
74
+ projetá-lo. `change-guard` lê `tool_input.command`, mas a superfície de shell do Codex é
75
+ `exec` (`custom_tool_call`, com `tool_input` string crua) ou `exec_command`/`shell_command`:
76
+ o guard falharia **aberto**, dando uma sensação de proteção que não existe. `change-warn` lê
77
+ `tool_input.file_path`, e o `apply_patch` do Codex manda um envelope de patch sem esse campo.
78
+ `plan-capture` não tem equivalente — `update_plan` é a lista de TODO corrente e dispara no
79
+ meio do turno, não no fim do plano. `decision-capture` depende de `AskUserQuestion`, uma
80
+ ferramenta exclusiva do Claude. E `task-log` depende de `TaskCompleted`, que não existe no
81
+ enum de eventos de hook do Codex.
82
+ - A projeção Codex tem três diferenças em relação ao formato do `settings.json`, todas
83
+ **silenciosas quando erradas** — daí valerem registro. (1) A chave de timeout é `timeoutSec`,
84
+ não `timeout`. (2) O comando é sempre `npx wendkeep hook <nome>`, nunca a forma node-direta:
85
+ aquela emite `${CLAUDE_PROJECT_DIR}`, que não existe no Codex, então a flag `preferLocal` é
86
+ ignorada de propósito na projeção. (3) As chaves de evento são PascalCase — o snake_case que
87
+ se vê em `[hooks.state]` no `~/.codex/config.toml` é o rótulo interno do evento, não a chave
88
+ do JSON.
89
+ - Merge não-destrutivo, mesma disciplina do `mergeSettings`: reconhece um grupo já wirado e
90
+ nunca duplica em re-`init`, preserva hooks de terceiros e hooks irmãos agrupados junto,
91
+ `--force` atualiza `timeoutSec`/`statusMessage` no lugar, e um `.bak` é salvo. Arquivo
92
+ existente ilegível não é tocado — a proposta vai para `.codex/hooks.json.new`.
93
+ - Testes: `tests/init-codex-hooks.test.mjs` (12 unitários sobre `mergeCodexHooks`) e um e2e em
94
+ `tests/init-vault.test.mjs`. Suíte completa: 397 passando.
95
+
96
+ ### Fixed
97
+
98
+ - Hooks Codex do wendkeep rodavam com o timeout **default de 600s**, não com o configurado. A
99
+ chave correta é `timeoutSec`; `timeout` não é campo, não é rejeitado e simplesmente não é
100
+ lido, então o valor caía no default sem um único aviso. Todo `.codex/hooks.json` escrito à
101
+ mão antes disso (o do NutriGym, entre outros) carrega o erro. `mergeCodexHooks` migra a
102
+ chave legada in place, **mesmo sem `--force`** — é correção de bug, não refresh opcional.
103
+ - `src/taxonomy.mjs` carregava um NUL (`0x00`) e um `0x1f` **literais** dentro da classe de
104
+ caracteres do `deriveVaultDirName`. Por causa do NUL o `file` classificava o fonte como
105
+ binário, e o ripgrep pula binário por default — um Grep por qualquer termo no arquivo
106
+ voltava vazio, em silêncio. Não era erro, era ausência de resultado, e justo no arquivo onde
107
+ vivem os specs de hook e as constantes de companion. Os dois bytes viraram as sequências de
108
+ escape `\x00` e `\x1f`; o regex é byte-idêntico em comportamento (#7).
109
+
110
+ ### Changed
111
+
112
+ - A numeração dos passos do `init` foi de `[n/4]` para `[n/5]`. O novo passo 3 é o
113
+ `.codex/hooks.json`, então `.mcp.json` passou a ser `[4/5]` e as cores `[5/5]`.
114
+
115
+ ### Migration
116
+
117
+ - **Quem já tem hooks Codex do wendkeep vai ver um prompt "Hooks need review" a mais neste
118
+ upgrade.** Isso é esperado, não regressão: a identidade do hook é hasheada, e corrigir
119
+ `timeout` → `timeoutSec` muda o conteúdo, logo muda o hash, logo o Codex pede re-aprovação.
120
+ Uma vez só.
121
+ - Independente disso, todo hook nasce Untrusted: o Codex enumera mas **não executa** até o
122
+ usuário aprovar no prompt de startup. O `init` não tem como pré-aprovar —
123
+ `--dangerously-bypass-hook-trust` é por invocação e não persiste `trusted_hash` — então
124
+ passou a imprimir um aviso explicando o prompt em vez de deixar o usuário achar que o wiring
125
+ falhou.
126
+ - O `init` **não** escreve `[features] hooks = true` no `.codex/config.toml`, de propósito. O
127
+ Codex declara essa feature como `Stage::Stable` com `default_enabled: true`, então a linha
128
+ seria no-op — e a camada de config do projeto é trust-gated como um todo de qualquer forma.
129
+
7
130
  ## [0.45.1] — 2026-07-18
8
131
 
9
132
  ### Fixed
package/README.md CHANGED
@@ -13,7 +13,7 @@
13
13
 
14
14
  **In the graph:** 🔵 session · 🟣 decision · 🔴 bug · 🟢 learning · 🟡 change — every note, backlinked.
15
15
 
16
- **A persistent‑memory harness for AI coding agents, built on your Obsidian vault.** Every Claude Code session is captured turn‑by‑turn into local Markdown — and Codex sessions too, once its hooks are wired (`init` wires Claude Code automatically; `import` backfills Codex either way) — with token/cost tracking, auto‑extracted decisions, bugs and learnings, and a curated memory layer injected back at the start of the next session. On top of that memory core sits a native, zero‑dependency **change lifecycle** (spec → change → TDD → sensor‑gated archive) that keeps intent, work and proof wikilinked in one graph. 100% local, open‑core.
16
+ **A persistent‑memory harness for AI coding agents, built on your Obsidian vault.** Every Claude Code **and Codex** session is captured turn‑by‑turn into local Markdown — `init` wires both (Codex asks you to approve its hooks once; `import` backfills past sessions either way) — with token/cost tracking, auto‑extracted decisions, bugs and learnings, and a curated memory layer injected back at the start of the next session. On top of that memory core sits a native, zero‑dependency **change lifecycle** (spec → change → TDD → sensor‑gated archive) that keeps intent, work and proof wikilinked in one graph. 100% local, open‑core.
17
17
 
18
18
  ```bash
19
19
  npm i -D wendkeep && npx wendkeep init # captures from the next session on
@@ -40,13 +40,13 @@ Decisions, dead ends, the reason you chose X over Y — gone next session. The p
40
40
  | **Derive** — decisions, bugs, learnings | Pulled from the transcript into their own notes, backlinked to the session. Your history becomes navigable, not archival. |
41
41
  | **Recall** — injected back | A budget‑capped `CORE` + `DIGEST` and every open change are fed to the agent at the next `SessionStart`. It resumes where it left off. |
42
42
  | **Cost** — what it all cost | Per‑model, cache‑aware token pricing per session — plus `cost --trend` with a run‑rate projection across the whole vault. |
43
- | **Multi‑agent** — one vault, both agents | Tags every note with the agent that wrote it: Claude Code is detected from its environment, anything else is recorded as Codex. One shared graph, whichever agent you are in. |
43
+ | **Multi‑agent** — one vault, both agents | `init` wires the session hooks into `.claude/settings.json` *and* `.codex/hooks.json`, and every note is tagged with the agent that wrote it: Claude Code is detected from its environment, anything else is recorded as Codex. One shared graph, whichever agent you are in. |
44
44
  | **Local‑first** — no cloud, no account | Everything is plain Markdown on your disk. An optional MCP server (`@bitbonsai/mcpvault`) lets the agent read/write the vault. |
45
45
 
46
46
  ## Requirements
47
47
 
48
48
  - Node.js ≥ 18
49
- - An AI coding agent with hooks. `init` wires **Claude Code** automatically; the same hook scripts handle Codex transcripts, but wiring them into Codex is manual today (see [Notes & roadmap](#notes--roadmap))
49
+ - An AI coding agent with hooks. `init` wires **Claude Code** and **Codex** automatically — Codex gets the seven hooks its event model supports and enumerates them untrusted, so approve its “Hooks need review” prompt once at startup (see [Notes & roadmap](#notes--roadmap))
50
50
  - Obsidian (to view the graph) — optional but the point
51
51
 
52
52
  ## Install & set up
@@ -60,19 +60,20 @@ npx wendkeep init
60
60
  `wendkeep init` is interactive and **idempotent**. It will:
61
61
 
62
62
  1. Create the vault folder taxonomy and a templated `README.md` (default vault: `<project>/.<project-name>-vault`, e.g. `.MyApp-vault`; override with `--vault`).
63
- 2. Write a provider-neutral **`.wendkeep.json`** binding at the project root and a matching `.brain/PROJECT.json` marker in the vault, then merge the session hooks into **`.claude/settings.json`**. The binding is provider-neutral by design: any agent resolves the same vault from its session `cwd`, with no machine-global environment variable. (Only Claude Code gets its hooks wired automatically — Codex hook wiring is manual.) Existing `.claude/settings.json` registrations are adopted automatically.
64
- 3. Add the **`wendkeep-vault`** MCP server to `.mcp.json` so the agent can read/write the vault. Skip with `--no-mcp` e.g. when the agent already has a vault MCP. (`--no-mcp` skips *only wendkeep's own* MCP; companion MCPs still follow `--companions`.)
65
- 4. Offer to pin **companion** plugins/MCP (multi-choice; **none** pre-checkedwendkeep is a neutral harness and presumes no third-party plugin). Each is wired the most agent-agnostic way it supports:
63
+ 2. Write a provider-neutral **`.wendkeep.json`** binding at the project root and a matching `.brain/PROJECT.json` marker in the vault, then merge the session hooks into **`.claude/settings.json`**. The binding is provider-neutral by design: any agent resolves the same vault from its session `cwd`, with no machine-global environment variable. Older registrations already in `.claude/settings.json` are adopted automatically.
64
+ 3. Wire the Codex session hooks in **`.codex/hooks.json`** seven of the twelve: `brain-inject` + `session-start` on `SessionStart`, `session-ensure` + `change-context` on `UserPromptSubmit`, `session-stop` + `change-nag` on `Stop`, `subagent-stop` on `SubagentStop`, always in the `npx wendkeep hook <name>` form. The other five are left out because Codex offers no equivalent payload, tool *or* event: `change-guard` (a `PreToolUse` gate reading `tool_input.command`, but Codex's `exec` carries `tool_input` as a raw string rather than an object, so the gate would fail *open*), `change-warn` (a `PostToolUse` nudge resolving `tool_input.file_path`, a field `apply_patch`'s envelope does not carry — nothing to resolve, and nothing to gate), `plan-capture` (there is no `ExitPlanMode`; `update_plan` is a running TODO list, not an approval), `decision-capture` (`AskUserQuestion` is a Claude-only tool) and `task-log` (`TaskCompleted` is not in Codex's event enum). See [Notes & roadmap](#notes--roadmap) for the per-hook detail. The merge is non-destructive, on the same discipline as `settings.json`: it recognizes an already-wired group and never duplicates on re-init, preserves third-party hooks, saves a `.bak`, and `--force` updates `timeoutSec`/`statusMessage` in place; an unparseable `.codex/hooks.json` is left untouched with the merge written to `.codex/hooks.json.new`. **Codex enumerates every hook as untrusted and runs none of them until you approve the “Hooks need review” prompt at startup — `init` cannot pre-approve them**, and it prints a warning saying so. Anyone who already had hand-written wendkeep hooks in Codex gets one re-review prompt: `init` migrates the legacy `timeout` key (which Codex neither rejects nor reads, falling through to a 600s default) to `timeoutSec`, and that changes the hook's hashed identity.
65
+ 4. Add the **`wendkeep-vault`** MCP server to `.mcp.json` so the agent can read/write the vault. Skip with `--no-mcp`e.g. when the agent already has a vault MCP. (`--no-mcp` skips *only wendkeep's own* MCP; companion MCPs still follow `--companions`.)
66
+ 5. Offer to pin **companion** plugins/MCP (multi-choice; **none** pre-checked — wendkeep is a neutral harness and presumes no third-party plugin). Each is wired the most agent-agnostic way it supports:
66
67
  - **`context-mode`** — context optimizer + FTS5 memory, wired as a Claude Code plugin. It ships its own MCP server, so wendkeep deliberately adds no `.mcp.json` entry (registering both cold-started two servers at once). On non-Claude agents, add the MCP by hand: `npx -y context-mode`.
67
68
  - **`understand-anything`** — project domain graph, via a `understand-inject` SessionStart hook that injects the graph when generated.
68
69
  - **`caveman`** — token-compression mode; runs its own cross-agent installer on non-Claude agents.
69
70
  - **`dotcontext`** — *legacy, not recommended, and hidden from the picker.* wendkeep's native a2 loop (`change` / `verify` / gate) already does its job, so installing it **duplicates the harness**. Reachable only via an explicit `--companions dotcontext` for anyone already invested (tune with `--dotcontext-mcp` / `--dotcontext-hooks`).
70
71
 
71
72
  Control with `--companions <csv>` or `--no-companions`. The Claude Code plugin layer (`extraKnownMarketplaces` + `enabledPlugins`) is wired as a bonus where the companion has one.
72
- 5. Install a **color system** into the vault's `.obsidian/`: a CSS snippet that accents notes by type (session/decision/bug/learning, via the `cssclasses` the hooks emit) plus graph color groups by folder. Non-destructive merge into `appearance.json`/`graph.json`; skip with `--no-colors`.
73
- 6. Seed the **curated memory layer**: `.brain/CORE.md` (the hand-curated hot layer, with the 3 required sections) and `.brain/COMPACTION_PROTOCOL.md` (the protocol guide). The auto layers (`DIGEST.md`, `index.jsonl`) are generated by the hooks. Validate the curated layer with `wendkeep validate-memory` (cap 25 lines, 3 sections, no secrets/PII).
74
- 7. Seed the **definitions + skills layer**: `.brain/agents/` + `.brain/skills/` (versioned source of truth), including the native process skills `wk-workflow` / `wk-tdd` / `wk-debugging` / `wk-brainstorming` / `wk-planning` / `wk-verify`. `init` delivers the skills to `.claude/skills/` and `.agents/skills/`, and the agent definitions (`.brain/agents/*.toml`) to `.codex/agents/`, plus a managed section in `AGENTS.md` that indexes the skills for Codex; `sync-defs --check` detects stale copies.
75
- 8. Seed the **change/spec lifecycle**: the `07-Specs/` + `08-Mudanças/` folders and a native `wendkeep.sensors.json` — a critical `memory-validation` sensor (`npx wendkeep validate-memory`) plus one for each of `typecheck` / `test` / `lint` / `build` found in your `package.json`. Add your own with `wendkeep sensors add`. Drives `wendkeep change` / `wendkeep verify` — see **Change lifecycle** below.
73
+ 6. Install a **color system** into the vault's `.obsidian/`: a CSS snippet that accents notes by type (session/decision/bug/learning, via the `cssclasses` the hooks emit) plus graph color groups by folder. Non-destructive merge into `appearance.json`/`graph.json`; skip with `--no-colors`.
74
+ 7. Seed the **curated memory layer**: `.brain/CORE.md` (the hand-curated hot layer, with the 3 required sections) and `.brain/COMPACTION_PROTOCOL.md` (the protocol guide). The auto layers (`DIGEST.md`, `index.jsonl`) are generated by the hooks. Validate the curated layer with `wendkeep validate-memory` (cap 25 lines, 3 sections, no secrets/PII).
75
+ 8. Seed the **definitions + skills layer**: `.brain/agents/` + `.brain/skills/` (versioned source of truth), including the native process skills `wk-workflow` / `wk-tdd` / `wk-debugging` / `wk-brainstorming` / `wk-planning` / `wk-verify` (some ship templates — e.g. `wk-verify`'s `verdict-template.json` + reviewer prompt). `init` runs `wendkeep sync-defs` for you, delivering the skills to `.claude/skills/` and `.agents/skills/`, and the agent definitions (`.brain/agents/*.toml`) to `.codex/agents/`, plus a managed section in `AGENTS.md` that indexes the skills for Codex; `sync-defs --check` detects stale copies (re-run `sync-defs` after editing `.brain`).
76
+ 9. Seed the **change/spec lifecycle**: the `07-Specs/` + `08-Mudanças/` folders and a native `wendkeep.sensors.json` — a critical `memory-validation` sensor (`npx wendkeep validate-memory`) plus one for each of `typecheck` / `test` / `lint` / `build` found in your `package.json`. Add your own with `wendkeep sensors add`. Drives `wendkeep change` / `wendkeep verify` — see **Change lifecycle** below.
76
77
 
77
78
  ```bash
78
79
  npx wendkeep init --vault "~/vaults/work" --project . --yes # non-interactive (no companions unless you ask)
@@ -262,7 +263,7 @@ agent session ──hooks──▶ wendkeep ──▶ Markdown in vault ──
262
263
  (Claude/Codex) (Node) (02-Sessões/…) (CORE+DIGEST, backlinks)
263
264
  ```
264
265
 
265
- The agent's settings.json points each hook at `npx wendkeep hook …`; the change-lifecycle hooks run the installed script directly (`node` on `${CLAUDE_PROJECT_DIR}/node_modules/wendkeep/hooks/<name>.mjs`) when the package is present locally, skipping an npx resolve on every event. On `Stop`, wendkeep parses the session transcript, appends the turn, updates the token/cost table, and (idempotently) emits any decision/bug/learning notes. On `SessionStart` — startup, `/clear` and `/compact` — `brain-inject` injects back curated memory (CORE + DIGEST), every open change with its pending tasks, the global current-change marker, project lessons, and a `<wk_process>` router. Claude, Codex, or another agent can therefore resume work started elsewhere without hiding the rest of the backlog.
266
+ The agent's settings.json points each hook at `npx wendkeep hook …`; **in Claude Code** the change-lifecycle hooks instead run the installed script directly (`node` on `${CLAUDE_PROJECT_DIR}/node_modules/wendkeep/hooks/<name>.mjs`) when the package is present locally, skipping an npx resolve on every event. `.codex/hooks.json` mirrors the same groups with PascalCase event keys, but always uses the `npx` form (`${CLAUDE_PROJECT_DIR}` does not exist in Codex) and spells its timeout `timeoutSec` — a plain `timeout` is neither a field nor an error there, it silently falls through to a 600s default, so `init` migrates that legacy key in place. On `Stop`, wendkeep parses the session transcript, appends the turn, updates the token/cost table, and (idempotently) emits any decision/bug/learning notes. On `SessionStart` — startup, `/clear` and `/compact` — `brain-inject` injects back curated memory (CORE + DIGEST), every open change with its pending tasks, the global current-change marker, project lessons, and a `<wk_process>` router. Claude, Codex, or another agent can therefore resume work started elsewhere without hiding the rest of the backlog.
266
267
 
267
268
  The archive **gate** blocks unless: the change scaffold is filled (G0), no task is open (G1), every declared critical sensor is green (with fresh evidence), and a `verdict.json` is present and current. `--force` waives G1 only — G0 is inescapable by design (a placeholder change forced through once minted a fake ADR), and no flag turns a red sensor or a missing verdict green. The agent is instructed never to use it on its own.
268
269
 
@@ -271,7 +272,8 @@ The archive **gate** blocks unless: the change scaffold is filled (G0), no task
271
272
  - **Vault folder names default to Portuguese** (`02-Sessões`, `04-Decisões`, …). Pass `wendkeep init --locale en` for an English vault (`02-Sessions`, `04-Decisions`, English scaffold/skills). The locale is a vault property, locked at init; parsers are bilingual so mixed content never breaks.
272
273
  - **Search is keyword/frontmatter scoring**, not on‑device embeddings (that's on the roadmap).
273
274
  - **Transcript formats are agent‑internal** and can change between agent versions; parsing is isolated but may need updates.
274
- - Installer wires **Claude Code** settings + `.mcp.json`. The hook scripts handle Codex too (they parse Codex transcripts and tag notes with the real provider), but `init` does not wire them into Codex yet point Codex at `npx wendkeep hook <name>` by hand, or use `import --source codex` to backfill past Codex sessions.
275
+ - Installer wires **both agents**: `.claude/settings.json` + `.mcp.json` for Claude Code, `.codex/hooks.json` for Codex. **Five hooks stay Claude‑only** because Codex has no equivalent payload, tool or event: `change-guard` (a `PreToolUse` gate that reads `tool_input.command`, but Codex's `exec` carries `tool_input` as a raw string rather than an object — the gate would fail *open*), `change-warn` (a `PostToolUse` nudge that resolves `tool_input.file_path`, which `apply_patch`'s envelope simply does not carry nothing to resolve, and nothing to gate), `plan-capture` (no `ExitPlanMode` `update_plan` is a running TODO list, not an approval), `decision-capture` (`AskUserQuestion` is Claude‑only) and `task-log` (`TaskCompleted` is not in Codex's event enum).
276
+ - **Codex hooks start untrusted.** They are enumerated but not executed until you approve the “Hooks need review” prompt; `init` cannot pre‑approve them (`--dangerously-bypass-hook-trust` is per‑invocation and stores no trusted hash). Trust is keyed to the hook's identity, so hand‑written wendkeep Codex hooks predating `0.46.0` — which ran at the 600s default because they used `timeout` instead of `timeoutSec` — cost one re‑review after `init` corrects the key. Expected, not a regression. `import --source codex` still backfills past Codex sessions either way.
275
277
 
276
278
  ---
277
279
 
package/README.pt-BR.md CHANGED
@@ -13,7 +13,7 @@
13
13
 
14
14
  **No grafo:** 🔵 sessão · 🟣 decisão · 🔴 bug · 🟢 aprendizado · 🟡 mudança — cada nota, com backlink.
15
15
 
16
- **Um harness de memória persistente para agentes de código, construído sobre o seu cofre Obsidian.** Cada sessão do Claude Code é capturada turno a turno em Markdown local — e as do Codex também, uma vez que os hooks dele estejam wirados (o `init` wira o Claude Code automaticamente; o `import` importa as do Codex de todo jeito) — com rastreio de tokens/custo, decisões, bugs e aprendizados extraídos automaticamente, e uma camada de memória curada injetada de volta no início da próxima sessão. Sobre esse núcleo de memória fica um **ciclo de mudança** nativo e sem dependências (spec → change → TDD → archive com gate por sensor) que mantém intenção, trabalho e prova wikilinkados num só grafo. 100% local, open‑core.
16
+ **Um harness de memória persistente para agentes de código, construído sobre o seu cofre Obsidian.** Cada sessão do Claude Code e do Codex é capturada turno a turno em Markdown local — o `init` wira os hooks dos dois agentes (no Codex, valendo depois que você aprovar o prompt de confiança dele); o `import` importa as sessões passadas de qualquer um dos dois — com rastreio de tokens/custo, decisões, bugs e aprendizados extraídos automaticamente, e uma camada de memória curada injetada de volta no início da próxima sessão. Sobre esse núcleo de memória fica um **ciclo de mudança** nativo e sem dependências (spec → change → TDD → archive com gate por sensor) que mantém intenção, trabalho e prova wikilinkados num só grafo. 100% local, open‑core.
17
17
 
18
18
  ```bash
19
19
  npm i -D wendkeep && npx wendkeep init # captura a partir da próxima sessão
@@ -38,13 +38,13 @@ Decisões, becos sem saída, o motivo de você ter escolhido X em vez de Y — s
38
38
  | **Deriva** — decisões, bugs, aprendizados | Puxados do transcript pra notas próprias, com backlink pra sessão. Seu histórico fica navegável, não arquivístico. |
39
39
  | **Recall** — injetado de volta | Um `CORE` + `DIGEST` com budget capado e todas as changes abertas são injetados no agente no próximo `SessionStart`. Ele retoma de onde parou. |
40
40
  | **Custo** — quanto tudo custou | Preço por modelo, ciente de cache, por sessão — mais `cost --trend` com projeção run‑rate no cofre inteiro. |
41
- | **Multi‑agente** — um cofre, os dois agentes | Marca cada nota com o agente que a escreveu: o Claude Code é detectado pelo ambiente dele, qualquer outro é registrado como Codex. Um grafo só, esteja você em qual agente estiver. |
41
+ | **Multi‑agente** — um cofre, os dois agentes | O `init` wira os hooks de sessão no `.claude/settings.json` *e* no `.codex/hooks.json`, e cada nota é marcada com o agente que a escreveu: o Claude Code é detectado pelo ambiente dele, qualquer outro é registrado como Codex. Um grafo só, esteja você em qual agente estiver. |
42
42
  | **Local‑first** — sem nuvem, sem conta | Tudo é Markdown puro no seu disco. Um MCP opcional (`@bitbonsai/mcpvault`) deixa o agente ler/escrever o cofre. |
43
43
 
44
44
  ## Requisitos
45
45
 
46
46
  - Node.js ≥ 18
47
- - Um agente de código com hooks. O `init` wira o **Claude Code** automaticamente; os mesmos scripts de hook dão conta dos transcripts do Codex, mas wirá-los no Codex é manual hoje (veja [Notas & roadmap](#notas--roadmap))
47
+ - Um agente de código com hooks. O `init` wira o **Claude Code** e o **Codex** automaticamente — no Codex ele wira os sete hooks que o modelo de eventos de suporta, e eles nascem *Untrusted*, então aprove o "Hooks need review" no primeiro startup (veja [Notas & roadmap](#notas--roadmap))
48
48
  - Obsidian (pra ver o grafo) — opcional, mas é o ponto
49
49
 
50
50
  ## Instalar & configurar
@@ -58,19 +58,20 @@ npx wendkeep init
58
58
  O `wendkeep init` é interativo e **idempotente**. Ele:
59
59
 
60
60
  1. Cria a taxonomia de pastas do cofre e um `README.md` templado (cofre padrão: `<projeto>/.<nome-do-projeto>-vault`, ex.: `.MeuApp-vault`; sobrescreva com `--vault`).
61
- 2. Grava um vínculo provider-neutral **`.wendkeep.json`** na raiz do projeto e o marcador correspondente `.brain/PROJECT.json` no cofre, e faz merge dos hooks de sessão no **`.claude/settings.json`**. O vínculo é provider-neutral de propósito: qualquer agente resolve o mesmo cofre pelo `cwd` da sessão, sem variável global da máquina. (Só o Claude Code tem os hooks wirados automaticamente — no Codex a fiação é manual.) Registros antigos em `.claude/settings.json` são adotados automaticamente.
62
- 3. Adiciona o servidor MCP **`wendkeep-vault`** ao `.mcp.json` pro agente ler/escrever o cofre. Pule com `--no-mcp` — ex.: quando o agente tem um MCP de cofre. (`--no-mcp` pula *só o MCP do próprio wendkeep*; os MCPs de companion seguem `--companions`.)
63
- 4. Oferece fixar plugins/MCP **companion** (múltipla escolha; **nenhum** pré-marcado o wendkeep é um harness neutro e não presume plugin de terceiro). Cada um é wirado do jeito mais agnóstico que suporta:
61
+ 2. Grava um vínculo provider-neutral **`.wendkeep.json`** na raiz do projeto e o marcador correspondente `.brain/PROJECT.json` no cofre, e faz merge dos hooks de sessão no **`.claude/settings.json`**. O vínculo é provider-neutral de propósito: qualquer agente resolve o mesmo cofre pelo `cwd` da sessão, sem variável global da máquina. Registros antigos em `.claude/settings.json` são adotados automaticamente.
62
+ 3. Wira os hooks de sessão do Codex em **`.codex/hooks.json`** — sete dos doze: `brain-inject` + `session-start` no `SessionStart`, `session-ensure` + `change-context` no `UserPromptSubmit`, `session-stop` + `change-nag` no `Stop`, `subagent-stop` no `SubagentStop`, sempre na forma `npx wendkeep hook <name>`. Os outros cinco ficam de fora por falta de payload, ferramenta ou evento equivalente no Codex: `change-guard` (gate `PreToolUse` que lê `tool_input.command`; no `exec` do Codex o `tool_input` existe, mas como string crua em vez de objeto — o gate degradaria para liberar tudo, falhando *aberto*), `change-warn` (*nudge* `PostToolUse` que resolve `tool_input.file_path`, campo que o envelope do `apply_patch` não carrega não o que resolver nem o que barrar), `plan-capture` (não existe `ExitPlanMode`; o `update_plan` é a lista de TODO em andamento, não uma aprovação), `decision-capture` (`AskUserQuestion` é ferramenta só do Claude) e `task-log` (`TaskCompleted` não está no enum de eventos do Codex). O merge é não‑destrutivo, na mesma disciplina do `settings.json`: reconhece o grupo já wirado e não duplica em re‑init, preserva hooks de terceiros, salva um `.bak`, e o `--force` atualiza `timeoutSec`/`statusMessage` no lugar; um `.codex/hooks.json` ilegível não é tocado e o merge vai pro `.codex/hooks.json.new`. **O Codex enumera todo hook como Untrusted e executa depois que você aprovar o "Hooks need review" no startup — o `init` não consegue pré-aprovar**, e ele imprime um aviso sobre isso. Quem já tinha hooks wendkeep no Codex escritos à mão leva um prompt de re-revisão: o `init` migra a chave legada `timeout` (que o Codex não rejeita nem lê, caindo no default de 600s) pra `timeoutSec`, e isso muda a identidade com hash do hook.
63
+ 4. Adiciona o servidor MCP **`wendkeep-vault`** ao `.mcp.json` pro agente ler/escrever o cofre. Pule com `--no-mcp` ex.: quando o agente já tem um MCP de cofre. (`--no-mcp` pula *só o MCP do próprio wendkeep*; os MCPs de companion seguem `--companions`.)
64
+ 5. Oferece fixar plugins/MCP **companion** (múltipla escolha; **nenhum** pré-marcado — o wendkeep é um harness neutro e não presume plugin de terceiro). Cada um é wirado do jeito mais agnóstico que suporta:
64
65
  - **`context-mode`** — otimizador de contexto + memória FTS5, wirado como plugin do Claude Code. Ele traz o próprio servidor MCP, então o wendkeep de propósito não adiciona entrada no `.mcp.json` (registrar os dois subia dois servidores ao mesmo tempo). Em agentes não‑Claude, adicione o MCP à mão: `npx -y context-mode`.
65
66
  - **`understand-anything`** — grafo de domínio do projeto, via um hook `understand-inject` no SessionStart que injeta o grafo quando gerado.
66
67
  - **`caveman`** — modo de compressão de tokens; roda seu próprio instalador cross‑agent em agentes não‑Claude.
67
68
  - **`dotcontext`** — *legado, não recomendado, e oculto do seletor.* O loop a2 nativo do wendkeep (`change` / `verify` / gate) já faz o trabalho dele, então instalar **duplica o harness**. Alcançável só via um `--companions dotcontext` explícito, pra quem já usa (ajuste com `--dotcontext-mcp` / `--dotcontext-hooks`).
68
69
 
69
70
  Controle com `--companions <csv>` ou `--no-companions`. A camada de plugin do Claude Code (`extraKnownMarketplaces` + `enabledPlugins`) é wirada como bônus onde o companion tiver uma.
70
- 5. Instala um **sistema de cores** no `.obsidian/` do cofre: um snippet CSS que colore notas por tipo (sessão/decisão/bug/aprendizado, via as `cssclasses` que os hooks emitem) mais grupos de cor do grafo por pasta. Merge não‑destrutivo em `appearance.json`/`graph.json`; pule com `--no-colors`.
71
- 6. Semeia a **camada de memória curada**: `.brain/CORE.md` (a camada quente curada à mão, com as 3 seções obrigatórias) e `.brain/COMPACTION_PROTOCOL.md` (o guia do protocolo). As camadas automáticas (`DIGEST.md`, `index.jsonl`) são geradas pelos hooks. Valide a camada curada com `wendkeep validate-memory` (cap 25 linhas, 3 seções, sem segredos/PII).
72
- 7. Semeia a **camada de definições + skills**: `.brain/agents/` + `.brain/skills/` (fonte da verdade versionada), incluindo as skills de processo nativas `wk-workflow` / `wk-tdd` / `wk-debugging` / `wk-brainstorming` / `wk-planning` / `wk-verify` (algumas trazem templates, ex.: o `verdict-template.json` + prompt de revisor da `wk-verify`). O `init` roda o `wendkeep sync-defs` pra você, entregando as skills em `.claude/skills/` e `.agents/skills/`, e as definições de agent (`.brain/agents/*.toml`) em `.codex/agents/`, mais uma seção gerenciada no `AGENTS.md` que indexa as skills pro Codex; o `sync-defs --check` detecta cópias defasadas (rode `sync-defs` de novo após editar o `.brain`).
73
- 8. Semeia o **ciclo change/spec**: as pastas `07-Specs/` + `08-Mudanças/` e um `wendkeep.sensors.json` nativo — um sensor crítico `memory-validation` (`npx wendkeep validate-memory`) mais um para cada `typecheck` / `test` / `lint` / `build` encontrado no seu `package.json`. Adicione os seus com `wendkeep sensors add`. É o que alimenta o `wendkeep change` / `wendkeep verify` — veja **Ciclo de mudança** abaixo.
71
+ 6. Instala um **sistema de cores** no `.obsidian/` do cofre: um snippet CSS que colore notas por tipo (sessão/decisão/bug/aprendizado, via as `cssclasses` que os hooks emitem) mais grupos de cor do grafo por pasta. Merge não‑destrutivo em `appearance.json`/`graph.json`; pule com `--no-colors`.
72
+ 7. Semeia a **camada de memória curada**: `.brain/CORE.md` (a camada quente curada à mão, com as 3 seções obrigatórias) e `.brain/COMPACTION_PROTOCOL.md` (o guia do protocolo). As camadas automáticas (`DIGEST.md`, `index.jsonl`) são geradas pelos hooks. Valide a camada curada com `wendkeep validate-memory` (cap 25 linhas, 3 seções, sem segredos/PII).
73
+ 8. Semeia a **camada de definições + skills**: `.brain/agents/` + `.brain/skills/` (fonte da verdade versionada), incluindo as skills de processo nativas `wk-workflow` / `wk-tdd` / `wk-debugging` / `wk-brainstorming` / `wk-planning` / `wk-verify` (algumas trazem templates, ex.: o `verdict-template.json` + prompt de revisor da `wk-verify`). O `init` roda o `wendkeep sync-defs` pra você, entregando as skills em `.claude/skills/` e `.agents/skills/`, e as definições de agent (`.brain/agents/*.toml`) em `.codex/agents/`, mais uma seção gerenciada no `AGENTS.md` que indexa as skills pro Codex; o `sync-defs --check` detecta cópias defasadas (rode `sync-defs` de novo após editar o `.brain`).
74
+ 9. Semeia o **ciclo change/spec**: as pastas `07-Specs/` + `08-Mudanças/` e um `wendkeep.sensors.json` nativo — um sensor crítico `memory-validation` (`npx wendkeep validate-memory`) mais um para cada `typecheck` / `test` / `lint` / `build` encontrado no seu `package.json`. Adicione os seus com `wendkeep sensors add`. É o que alimenta o `wendkeep change` / `wendkeep verify` — veja **Ciclo de mudança** abaixo.
74
75
 
75
76
  ```bash
76
77
  npx wendkeep init --vault "~/vaults/work" --project . --yes # não-interativo
@@ -259,7 +260,7 @@ sessão do agente ──hooks──▶ wendkeep ──▶ Markdown no cofre ─
259
260
  (Claude/Codex) (Node) (02-Sessões/…) (CORE+DIGEST, backlinks)
260
261
  ```
261
262
 
262
- O settings.json do agente aponta cada hook pra `npx wendkeep hook …`; os hooks do ciclo de mudança rodam o script instalado direto (`node` em `${CLAUDE_PROJECT_DIR}/node_modules/wendkeep/hooks/<name>.mjs`) quando o pacote está presente local, pulando uma resolução do npx a cada evento. No `Stop`, o wendkeep parseia o transcript, anexa o turno, atualiza a tabela de tokens/custo e (idempotentemente) emite qualquer nota de decisão/bug/aprendizado. No `SessionStart` — startup, `/clear` e `/compact` — o `brain-inject` injeta a memória curada (CORE + DIGEST), todas as changes abertas com suas pendências, o marcador global da change atual, as lições do projeto e o roteador `<wk_process>`. Claude, Codex ou outro agente podem assim retomar trabalho iniciado em outro lugar sem ocultar o restante do backlog.
263
+ O settings.json do agente aponta cada hook pra `npx wendkeep hook …`; no Claude Code, os hooks do ciclo de mudança rodam o script instalado direto (`node` em `${CLAUDE_PROJECT_DIR}/node_modules/wendkeep/hooks/<name>.mjs`) quando o pacote está presente local, pulando uma resolução do npx a cada evento. O `.codex/hooks.json` usa sempre a forma `npx` — o `${CLAUDE_PROJECT_DIR}` não existe no Codex — com chaves de evento em PascalCase e o timeout em `timeoutSec`. No `Stop`, o wendkeep parseia o transcript, anexa o turno, atualiza a tabela de tokens/custo e (idempotentemente) emite qualquer nota de decisão/bug/aprendizado. No `SessionStart` — startup, `/clear` e `/compact` — o `brain-inject` injeta a memória curada (CORE + DIGEST), todas as changes abertas com suas pendências, o marcador global da change atual, as lições do projeto e o roteador `<wk_process>`. Claude, Codex ou outro agente podem assim retomar trabalho iniciado em outro lugar sem ocultar o restante do backlog.
263
264
 
264
265
  O **gate** do archive bloqueia a não ser que: o scaffold da change esteja preenchido (G0), nenhuma tarefa esteja aberta (G1), todo sensor crítico declarado esteja verde (com evidência fresca) e exista um `verdict.json` presente e atual. O `--force` dispensa só o G1 — o G0 é inescapável por design (uma change placeholder forçada uma vez cunhou um ADR falso), e nenhuma flag torna verde um sensor vermelho ou um verdict ausente. O agente é instruído a nunca usar por conta própria.
265
266
 
@@ -268,7 +269,8 @@ O **gate** do archive bloqueia a não ser que: o scaffold da change esteja preen
268
269
  - **Nomes das pastas do cofre são em Português por padrão** (`02-Sessões`, `04-Decisões`, …). Passe `wendkeep init --locale en` pra um cofre em inglês (`02-Sessions`, `04-Decisions`, scaffold/skills em inglês). O locale é uma propriedade do cofre, travada no init; os parsers são bilíngues, então conteúdo misto nunca quebra.
269
270
  - **Busca é scoring por keyword/frontmatter**, não embeddings on‑device (isso está no roadmap).
270
271
  - **Formatos de transcript são internos ao agente** e podem mudar entre versões; o parsing é isolado mas pode precisar de atualizações.
271
- - O instalador wira settings do **Claude Code** + `.mcp.json`. Os scripts de hook dão conta do Codex também (parseiam transcripts Codex e marcam as notas com o provedor real), mas o `init` ainda não os wira no Codex aponte o Codex pra `npx wendkeep hook <name>` à mão, ou use `import --source codex` pra importar sessões Codex passadas.
272
+ - O instalador wira settings do **Claude Code** + **`.codex/hooks.json`** + `.mcp.json`. **No Codex vão sete dos doze hooks** — os outros cinco não têm payload, ferramenta ou evento equivalente: `change-guard` (gate `PreToolUse` que lê `tool_input.command`, mas o `exec` do Codex carrega `tool_input` como string crua, não objeto — o gate falharia *aberto*), `change-warn` (*nudge* `PostToolUse` que resolve `tool_input.file_path`, campo ausente do envelope do `apply_patch` — não há o que resolver nem o que barrar), `plan-capture` (não existe `ExitPlanMode`; o `update_plan` é lista de TODO em andamento, não aprovação), `decision-capture` (`AskUserQuestion` é ferramenta só do Claude) e `task-log` (`TaskCompleted` não está no enum de eventos do Codex). Ou seja: captura de sessão, custo e memória funciona igual, mas os avisos de mudança ligados a ferramenta e a captura de plano/decisão/tarefa são só do Claude. Os hooks também só rodam depois que você aprovar o "Hooks need review" — o `init` não consegue pré-aprovar. Pra sessões Codex anteriores ao wiring, use `import --source codex`.
273
+ - **Os hooks do Codex nascem Untrusted.** Eles são enumerados, mas não executados, até você aprovar o "Hooks need review"; o `init` não consegue pré‑aprovar (o `--dangerously-bypass-hook-trust` vale só por invocação e não grava nenhum trusted hash). A confiança é atrelada à identidade do hook, então quem tinha hooks wendkeep do Codex escritos à mão antes da `0.46.0` — que rodavam no default de 600s por usarem `timeout` em vez de `timeoutSec` — paga uma re‑revisão única depois que o `init` corrige a chave. Isso é esperado, não é regressão.
272
274
 
273
275
  ---
274
276
 
@@ -17,7 +17,7 @@ import {
17
17
  import { buildSessionContent, allocateSessionPath } from './session-start.mjs';
18
18
  import { createLinkedNotes } from './linked-notes.mjs';
19
19
  import { updateSessionObservability } from './session-observability.mjs';
20
- import { readSessionRegistry, upsertSessionRegistry, formatLocalIso, formatDate, providerMeta } from './obsidian-common.mjs';
20
+ import { readSessionRegistry, upsertSessionRegistry, formatLocalIso, formatDate, providerMeta, isBootstrapPrompt } from './obsidian-common.mjs';
21
21
  import { getLocale } from './locale.mjs';
22
22
  import { captureProseDecisions } from './decision-capture.mjs';
23
23
 
@@ -154,6 +154,36 @@ export function capturedSessionIds(vaultBase) {
154
154
  return ids;
155
155
  }
156
156
 
157
+ // session_id -> absolute note path, for the sessions that already have a note on disk. The
158
+ // registry alone is not enough: it records a session the moment session-start runs, which is
159
+ // exactly the state a damaged session is stuck in (registered, note empty).
160
+ export function capturedSessionNotes(vaultBase) {
161
+ const notes = new Map();
162
+ const registry = readSessionRegistry(vaultBase).sessions || {};
163
+ for (const [id, entry] of Object.entries(registry)) {
164
+ if (!entry?.session_file) continue;
165
+ const abs = join(vaultBase, ...String(entry.session_file).split('/'));
166
+ if (existsSync(abs)) notes.set(id, abs);
167
+ }
168
+ try {
169
+ const sessionsDir = join(vaultBase, getLocale(vaultBase).folders.sessions);
170
+ for (const path of walkFiles(sessionsDir, /\.md$/i)) {
171
+ const id = noteSessionId(path);
172
+ if (id && !notes.has(id)) notes.set(id, path);
173
+ }
174
+ } catch { /* registry alone is enough */ }
175
+ return notes;
176
+ }
177
+
178
+ // Turn ids already memorialized in a note, read from the `wk-turn` markers insertIteration
179
+ // writes. Missing/unreadable note = nothing captured.
180
+ export function noteTurnIds(notePath) {
181
+ try {
182
+ const md = readFileSync(notePath, 'utf-8');
183
+ return new Set([...md.matchAll(/<!-- (?:wk|codex)-turn: ([^\s]+) -->/g)].map((m) => m[1]));
184
+ } catch { return new Set(); }
185
+ }
186
+
157
187
  export function discoverCodexTranscripts(projectPath, fromDir) {
158
188
  const dir = fromDir || defaultCodexSessionsDir();
159
189
  if (!dir || !existsSync(dir)) return { dir, transcripts: [] };
@@ -168,9 +198,14 @@ export function discoverCodexTranscripts(projectPath, fromDir) {
168
198
  }
169
199
 
170
200
  // Session objective for the note title/frontmatter: first real user prompt, one line.
171
- function deriveSummary(tx) {
172
- for (const turn of tx.turns || []) {
173
- const prompt = (turn.userPrompts || []).find(Boolean);
201
+ // Mirrors buildIterationBlock's selection (`userPrompts.at(-1)`) on purpose: the harness
202
+ // injects preamble as the FIRST prompt of a turn and the user's request lands LAST, so taking
203
+ // the first titled six Vendiva sessions "<recommended_plugins> Here is a list of plugins".
204
+ // isBootstrapPrompt is the belt: it runs per whole prompt, not per line — filtering by line
205
+ // would fall through to the next line of the SAME injected block.
206
+ export function deriveSummary(tx) {
207
+ for (const turn of tx?.turns || []) {
208
+ const prompt = (turn.userPrompts || []).filter((p) => p && !isBootstrapPrompt(p)).at(-1);
174
209
  if (prompt) {
175
210
  return prompt.replace(/[\r\n#]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 80) || 'session';
176
211
  }
@@ -313,15 +348,18 @@ export function runImport(vaultBase, opts = {}) {
313
348
  codexDir = d.dir;
314
349
  transcripts.push(...d.transcripts);
315
350
  }
316
- const captured = capturedSessionIds(vaultBase);
351
+ const notes = capturedSessionNotes(vaultBase);
317
352
  const sinceMs = since ? Date.parse(since) : 0;
318
- const report = { source: src, claudeDir, codexDir, scanned: transcripts.length, imported: 0, skipped: 0, errors: [], sessions: [] };
353
+ const report = { source: src, claudeDir, codexDir, scanned: transcripts.length, imported: 0, repaired: 0, skipped: 0, errors: [], sessions: [] };
319
354
 
320
355
  let done = 0;
321
356
  for (const t of transcripts) {
322
- if (captured.has(t.sessionId)) { report.skipped++; continue; }
323
357
  if (limit && done >= limit) break;
324
358
 
359
+ // Every transcript is parsed now, including already-captured ones: partial coverage is
360
+ // undetectable without the turn list. The old presence-only check was cheaper but made
361
+ // the recovery command blind to exactly the sessions it exists to repair. Narrow a large
362
+ // vault with --since / --limit.
325
363
  let tx;
326
364
  try {
327
365
  tx = parseTranscript(t.path);
@@ -332,6 +370,31 @@ export function runImport(vaultBase, opts = {}) {
332
370
  const turns = tx.turns || [];
333
371
  if (!turns.length) { report.skipped++; continue; }
334
372
 
373
+ const existingNote = notes.get(t.sessionId);
374
+ if (existingNote) {
375
+ const have = noteTurnIds(existingNote);
376
+ const missing = turns.filter((turn) => !have.has(String(turn.turnId)));
377
+ if (!missing.length) { report.skipped++; continue; }
378
+ if (dryRun) {
379
+ report.sessions.push({ sessionId: t.sessionId, turns: missing.length, repaired: true, dryRun: true });
380
+ report.repaired++;
381
+ done++;
382
+ continue;
383
+ }
384
+ try {
385
+ for (const turn of missing) {
386
+ insertIteration(existingNote, buildIterationBlock(tx, { turn_id: turn.turnId, now: turn.timestamp }), turn.turnId, tx);
387
+ }
388
+ try { updateSessionObservability({ sessionPath: existingNote, transcriptPath: t.path }); } catch { /* best-effort */ }
389
+ report.sessions.push({ sessionId: t.sessionId, turns: missing.length, repaired: true });
390
+ report.repaired++;
391
+ done++;
392
+ } catch (error) {
393
+ report.errors.push({ sessionId: t.sessionId, error: error.message });
394
+ }
395
+ continue;
396
+ }
397
+
335
398
  const startTs = turns[0].timestamp || '';
336
399
  if (sinceMs && startTs && Number.isFinite(Date.parse(startTs)) && Date.parse(startTs) < sinceMs) {
337
400
  report.skipped++;
@@ -23,10 +23,52 @@ export const VAULT_COMPLEMENT_RULES = [
23
23
  'Atualize `SHARED_MEMORY.md` somente quando a síntese mudar estado ativo que outro agente precise saber.',
24
24
  ];
25
25
 
26
+ // Codex on Windows serializes the Stop payload with `last_assistant_message` cut mid-string
27
+ // and never closed when the assistant text carries non-ASCII (openai/codex#23784). That field
28
+ // is LAST in codex-rs's StopCommandInput, so everything wendkeep consumes — session_id,
29
+ // turn_id, transcript_path, cwd — sits in the intact prefix.
30
+ //
31
+ // One pass, tracking quotes/escapes/depth, remembering the offset of the last top-level comma
32
+ // that was NOT inside a string. Re-closing there yields the well-formed prefix. Deliberately
33
+ // NOT a decreasing brute-force parse: this runs on every turn and the payload can be tens of
34
+ // KB. The truncated field is dropped, never reconstructed — half an assistant message is
35
+ // invented data, and it is the one field we do not need.
36
+ export function salvageTruncatedJson(raw) {
37
+ const text = String(raw || '');
38
+ if (text[0] !== '{') return null;
39
+ let inString = false;
40
+ let escaped = false;
41
+ let depth = 0;
42
+ let lastBoundary = -1;
43
+ for (let i = 0; i < text.length; i += 1) {
44
+ const ch = text[i];
45
+ if (escaped) { escaped = false; continue; }
46
+ if (ch === '\\') { if (inString) escaped = true; continue; }
47
+ if (ch === '"') { inString = !inString; continue; }
48
+ if (inString) continue;
49
+ if (ch === '{' || ch === '[') depth += 1;
50
+ else if (ch === '}' || ch === ']') depth -= 1;
51
+ else if (ch === ',' && depth === 1) lastBoundary = i;
52
+ }
53
+ if (lastBoundary === -1) return null;
54
+ try {
55
+ const parsed = JSON.parse(`${text.slice(0, lastBoundary)}}`);
56
+ return parsed && typeof parsed === 'object' ? parsed : null;
57
+ } catch { return null; }
58
+ }
59
+
26
60
  export function readHookInput() {
27
61
  const raw = readFileSync(0, 'utf-8').trim();
28
62
  if (!raw) return {};
29
- return JSON.parse(raw);
63
+ try {
64
+ return JSON.parse(raw);
65
+ } catch (error) {
66
+ const salvaged = salvageTruncatedJson(raw);
67
+ // `_wk` prefix: the object is the harness payload merged with our own metadata, and a
68
+ // silent key collision here would be worse than the ugly prefix.
69
+ if (salvaged) return { ...salvaged, _wkSalvaged: true };
70
+ throw error;
71
+ }
30
72
  }
31
73
 
32
74
  export function writeHookOutput(payload = {}) {
@@ -503,6 +545,10 @@ export function isBootstrapPrompt(text = '') {
503
545
  return clean.startsWith('# AGENTS.md instructions')
504
546
  || clean.startsWith('<environment_context>')
505
547
  || clean.startsWith('<permissions instructions>')
548
+ // Codex injects the available-plugins catalogue as the first userPrompt of turn 1.
549
+ // Anchored with startsWith on purpose: matching the bare substring would discard a
550
+ // legitimate prompt that merely asks about plugins.
551
+ || clean.startsWith('<recommended_plugins>')
506
552
  || clean.includes('You are Codex, a coding agent')
507
553
  || clean.startsWith('## Memory');
508
554
  }
@@ -96,6 +96,30 @@ export function resolveSessionIdentity(vaultBase, input = {}, provider = detectP
96
96
  };
97
97
  }
98
98
 
99
+ // Codex on Windows can deliver a Stop payload whose transcript_path never arrives (or is
100
+ // lost to a truncated JSON, openai/codex#23784). The registry already knows the mapping —
101
+ // the lookup below just sat under this gate, unreachable in the one case it solves. The
102
+ // comment above says we require "rollout/registry"; the registry half was never wired.
103
+ // Requiring the entry's provider to match preserves the cross-provider invariant from the
104
+ // 2026-07-11 incident: we are not minting a canonical id, we are finding an ALREADY
105
+ // REGISTERED session whose key is the hook's own id. A resume with a fresh id simply
106
+ // misses and stays deferred.
107
+ if (!transcriptPath && hookId) {
108
+ const entry = readSessionRegistry(vaultBase).sessions?.[hookId];
109
+ if (entry?.transcript_path && entry.provider === provider) {
110
+ return {
111
+ state: 'resolved',
112
+ provider,
113
+ canonicalConversationId: hookId,
114
+ hookSessionId: hookId,
115
+ transcriptPath: entry.transcript_path,
116
+ transcriptId: entry.transcript_id || basename(entry.transcript_path, '.jsonl'),
117
+ parentConversationId: '',
118
+ diagnostics: ['transcript recuperado do SESSION_REGISTRY'],
119
+ };
120
+ }
121
+ }
122
+
99
123
  if (!transcriptPath || !inspected.canonicalConversationId) {
100
124
  return { state: 'deferred', provider, transcriptPath, diagnostics: ['transcript ausente ou sem identidade canônica'] };
101
125
  }
@@ -584,6 +584,16 @@ function formatTokenLine(usage, model) {
584
584
  return cost ? `${line} — ≈ API equivalente (não é cobrança do plano)` : line;
585
585
  }
586
586
 
587
+ // User-facing explanation for a turn that could not be memorialized. Names the upstream bug
588
+ // when the payload arrived salvaged, because "wendkeep didn't record it" reads as a wendkeep
589
+ // defect and the user would have nowhere to look.
590
+ export function bailMessage(why, input = {}) {
591
+ const truncated = input._wkSalvaged
592
+ ? ' O payload do Stop chegou truncado (openai/codex#23784).'
593
+ : '';
594
+ return `[wendkeep] Turno não registrado: ${why}.${truncated} Recupere com \`wendkeep import --source codex\`.`;
595
+ }
596
+
587
597
  export function buildIterationBlock(tx, input) {
588
598
  const turnId = input.turn_id || tx.latestTurnId || `${Date.now()}`;
589
599
  const turn = selectTurn(tx, turnId);
@@ -1033,8 +1043,11 @@ function main() {
1033
1043
  const transcriptPath = input.transcript_path || input.transcriptPath || '';
1034
1044
  const { identity, entry } = resolveSessionEntry(vaultBase, input);
1035
1045
  if (identity.state !== 'resolved' || !entry?.session_file) {
1036
- process.stderr.write(`[wendkeep] Stop sem identidade segura: ${identity.diagnostics?.join('; ') || 'sessão não registrada'}\n`);
1037
- writeHookOutput({});
1046
+ const why = identity.diagnostics?.join('; ') || 'sessão não registrada';
1047
+ process.stderr.write(`[wendkeep] Stop sem identidade segura: ${why}\n`);
1048
+ // stderr alone is a black hole here: Codex discards it, which is how an entire session of
1049
+ // lost turns produced no signal at all. systemMessage is what the UI actually shows.
1050
+ writeHookOutput({ systemMessage: bailMessage(why, input) });
1038
1051
  return;
1039
1052
  }
1040
1053
 
@@ -1160,6 +1173,9 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
1160
1173
  main();
1161
1174
  } catch (error) {
1162
1175
  process.stderr.write(`[wendkeep] Stop falhou: ${error.message}\n`);
1163
- writeHookOutput({});
1176
+ // Same reasoning as the identity bail: stderr is discarded by Codex. Exit stays 0 —
1177
+ // a non-zero Stop hook blocks the turn (openai/codex#21921), and trading a lost turn
1178
+ // for a stuck session is a worse deal.
1179
+ writeHookOutput({ systemMessage: bailMessage(error.message) });
1164
1180
  }
1165
1181
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wendkeep",
3
- "version": "0.45.1",
3
+ "version": "0.46.1",
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/init.mjs CHANGED
@@ -16,6 +16,9 @@ import {
16
16
  hookCommand,
17
17
  hookCommandLocal,
18
18
  hookCommandLocalLegacy,
19
+ codexHookSpecs,
20
+ codexHookEntry,
21
+ CODEX_MATCHER_EVENTS,
19
22
  deriveVaultDirName,
20
23
  selectableCompanions,
21
24
  resolveCompanions,
@@ -192,6 +195,47 @@ export function mergeSettings(existing, { vaultPath, withMcp, force, companions
192
195
  return { settings: s, added };
193
196
  }
194
197
 
198
+ // Codex counterpart of mergeSettings: projects the SAME hook specs into the shape Codex
199
+ // reads from <project>/.codex/hooks.json. Deliberately simpler than mergeSettings — there
200
+ // is only one command form (npx), so there is no dual-recognition to do. The one migration
201
+ // it does handle is the legacy `timeout` key, which Codex silently ignores in favour of a
202
+ // 600s default; rewriting it to `timeoutSec` invalidates the stored trusted_hash and costs
203
+ // the user one "Hooks need review" prompt. That is the point.
204
+ export function mergeCodexHooks(existing, { force = false } = {}) {
205
+ const file = existing && typeof existing === 'object' ? { ...existing } : {};
206
+ file.hooks = { ...(file.hooks || {}) };
207
+ const specs = codexHookSpecs([...SESSION_HOOKS, ...CHANGE_NUDGE_HOOKS, ...CHANGE_GATE_HOOKS])
208
+ .sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
209
+ for (const h of specs) {
210
+ const entry = codexHookEntry(h);
211
+ const owns = (x) => x.command === entry.command;
212
+ const groups = Array.isArray(file.hooks[h.event]) ? [...file.hooks[h.event]] : [];
213
+ const owning = groups.find((g) => (g.hooks || []).some(owns));
214
+ if (owning) {
215
+ const hk = owning.hooks.find(owns);
216
+ // `timeout` is the pre-0.46 key: Codex never read it. Migrate it even without --force,
217
+ // otherwise the hook keeps running at the 600s default forever.
218
+ const legacyTimeout = 'timeout' in hk;
219
+ if (force || legacyTimeout) {
220
+ if (legacyTimeout) {
221
+ if (hk.timeoutSec === undefined) hk.timeoutSec = hk.timeout;
222
+ delete hk.timeout;
223
+ }
224
+ if (force) {
225
+ hk.timeoutSec = entry.timeoutSec;
226
+ if (entry.statusMessage) hk.statusMessage = entry.statusMessage;
227
+ }
228
+ }
229
+ file.hooks[h.event] = groups;
230
+ continue;
231
+ }
232
+ const matcher = CODEX_MATCHER_EVENTS.has(h.event) ? h.matcher : null;
233
+ groups.push(matcher ? { matcher, hooks: [entry] } : { hooks: [entry] });
234
+ file.hooks[h.event] = groups;
235
+ }
236
+ return file;
237
+ }
238
+
195
239
  export function mergeMcp(existing, { vaultPath, withVault = true, companions = [], skipMcp = [] }) {
196
240
  const m = existing && typeof existing === 'object' ? { ...existing } : {};
197
241
  m.mcpServers = { ...(m.mcpServers || {}) };
@@ -278,16 +322,19 @@ const MESSAGES = {
278
322
  lCompanions: ' companions ', lColors: ' cores ',
279
323
  skipped: 'ignorado', none: 'nenhum',
280
324
  colorsOn: 'wendkeep-colors (snippet + grupos do grafo)',
281
- taxonomy: (n, c, loc, readme, views) => ` [1/4] taxonomia do vault: ${n} pastas (${c} criadas, locale ${loc})${readme}, .brain + change/spec + sensores semeados${views}`,
325
+ taxonomy: (n, c, loc, readme, views) => ` [1/5] taxonomia do vault: ${n} pastas (${c} criadas, locale ${loc})${readme}, .brain + change/spec + sensores semeados${views}`,
282
326
  readmeCreated: ', README.md criado', viewsNote: (n) => `, ${n} view(s) + dashboard`,
283
327
  defs: (s, a) => ` defs entregues: ${s} skill(s) -> .claude/skills + .agents/skills, ${a} agent(s) -> .codex/agents`,
284
- settingsBadJson: (p) => ` [2/4] settings.json existe mas não é JSON válido -> escrevi ${p}.new (mescle à mão)`,
285
- settings: (verb, added, bak) => ` [2/4] settings.json ${verb} (${added} hook(s) wirados; .wendkeep.json é o vínculo compartilhado${bak})`,
286
- mcpBadJson: (p) => ` [3/4] .mcp.json existe mas não é JSON válido -> escrevi ${p}.new (mescle à mão)`,
287
- mcp: (verb, names, bak) => ` [3/4] .mcp.json ${verb} (${names}${bak})`,
288
- mcpSkipped: ' [3/4] .mcp.json ignorado (--no-mcp, sem companions MCP)',
289
- colorsSkipped: ' [4/4] cores ignoradas (--no-colors)',
290
- colors: (r) => ` [4/4] cores: ${r}`,
328
+ settingsBadJson: (p) => ` [2/5] settings.json existe mas não é JSON válido -> escrevi ${p}.new (mescle à mão)`,
329
+ settings: (verb, added, bak) => ` [2/5] settings.json ${verb} (${added} hook(s) wirados; .wendkeep.json é o vínculo compartilhado${bak})`,
330
+ codexBadJson: (p) => ` [3/5] .codex/hooks.json existe mas não é JSON válido -> escrevi ${p}.new (mescle à mão)`,
331
+ codexHooks: (verb, bak) => ` [3/5] .codex/hooks.json ${verb} (hooks de sessão do Codex${bak})`,
332
+ codexTrust: ' [!] o Codex só executa esses hooks depois que você aprovar "Hooks need review" no startup o init não consegue pré-aprovar.',
333
+ mcpBadJson: (p) => ` [4/5] .mcp.json existe mas não é JSON válido -> escrevi ${p}.new (mescle à mão)`,
334
+ mcp: (verb, names, bak) => ` [4/5] .mcp.json ${verb} (${names}${bak})`,
335
+ mcpSkipped: ' [4/5] .mcp.json ignorado (--no-mcp, sem companions MCP)',
336
+ colorsSkipped: ' [5/5] cores ignoradas (--no-colors)',
337
+ colors: (r) => ` [5/5] cores: ${r}`,
291
338
  merged: 'mesclado', created: 'criado', bakSaved: ', .bak salvo',
292
339
  nextSteps: '\nPróximos passos:',
293
340
  step1: (v) => ` 1. Abra o vault no Obsidian: "Abrir pasta como cofre" -> ${v}`,
@@ -303,16 +350,19 @@ const MESSAGES = {
303
350
  lCompanions: ' companions ', lColors: ' colors ',
304
351
  skipped: 'skipped', none: 'none',
305
352
  colorsOn: 'wendkeep-colors (snippet + graph groups)',
306
- taxonomy: (n, c, loc, readme, views) => ` [1/4] vault taxonomy: ${n} folders (${c} created, locale ${loc})${readme}, .brain + change/spec + sensors seeded${views}`,
353
+ taxonomy: (n, c, loc, readme, views) => ` [1/5] vault taxonomy: ${n} folders (${c} created, locale ${loc})${readme}, .brain + change/spec + sensors seeded${views}`,
307
354
  readmeCreated: ', README.md created', viewsNote: (n) => `, ${n} view(s) + dashboard`,
308
355
  defs: (s, a) => ` defs delivered: ${s} skill(s) -> .claude/skills + .agents/skills, ${a} agent(s) -> .codex/agents`,
309
- settingsBadJson: (p) => ` [2/4] settings.json exists but is not valid JSON -> wrote ${p}.new (merge by hand)`,
310
- settings: (verb, added, bak) => ` [2/4] settings.json ${verb} (${added} hook(s) wired; .wendkeep.json is the shared binding${bak})`,
311
- mcpBadJson: (p) => ` [3/4] .mcp.json exists but is not valid JSON -> wrote ${p}.new (merge by hand)`,
312
- mcp: (verb, names, bak) => ` [3/4] .mcp.json ${verb} (${names}${bak})`,
313
- mcpSkipped: ' [3/4] .mcp.json skipped (--no-mcp, no MCP companions)',
314
- colorsSkipped: ' [4/4] colors skipped (--no-colors)',
315
- colors: (r) => ` [4/4] colors: ${r}`,
356
+ settingsBadJson: (p) => ` [2/5] settings.json exists but is not valid JSON -> wrote ${p}.new (merge by hand)`,
357
+ settings: (verb, added, bak) => ` [2/5] settings.json ${verb} (${added} hook(s) wired; .wendkeep.json is the shared binding${bak})`,
358
+ codexBadJson: (p) => ` [3/5] .codex/hooks.json exists but is not valid JSON -> wrote ${p}.new (merge by hand)`,
359
+ codexHooks: (verb, bak) => ` [3/5] .codex/hooks.json ${verb} (Codex session hooks${bak})`,
360
+ codexTrust: ' [!] Codex only runs these hooks after you approve "Hooks need review" at startup — init cannot pre-approve them.',
361
+ mcpBadJson: (p) => ` [4/5] .mcp.json exists but is not valid JSON -> wrote ${p}.new (merge by hand)`,
362
+ mcp: (verb, names, bak) => ` [4/5] .mcp.json ${verb} (${names}${bak})`,
363
+ mcpSkipped: ' [4/5] .mcp.json skipped (--no-mcp, no MCP companions)',
364
+ colorsSkipped: ' [5/5] colors skipped (--no-colors)',
365
+ colors: (r) => ` [5/5] colors: ${r}`,
316
366
  merged: 'merged', created: 'created', bakSaved: ', .bak saved',
317
367
  nextSteps: '\nNext steps:',
318
368
  step1: (v) => ` 1. Open the vault in Obsidian: "Open folder as vault" -> ${v}`,
@@ -538,6 +588,22 @@ export async function runInit(argv) {
538
588
  log(M.settings(hadFile ? M.merged : M.created, added, hadFile ? M.bakSaved : ''));
539
589
  }
540
590
 
591
+ // 2b. .codex/hooks.json -----------------------------------------------------
592
+ // Without this, Codex opens with the vault reachable (via .mcp.json) but no session:
593
+ // CURRENT_SESSION.md never gets written and registrySessions stays 0.
594
+ const codexPath = join(projectPath, '.codex', 'hooks.json');
595
+ const codexRead = readJsonSafe(codexPath);
596
+ if (!codexRead.ok) {
597
+ writeJson(`${codexPath}.new`, mergeCodexHooks(null, { force: true }));
598
+ log(M.codexBadJson(codexPath));
599
+ } else {
600
+ const hadFile = codexRead.data !== null;
601
+ if (hadFile) backup(codexPath);
602
+ writeJson(codexPath, mergeCodexHooks(codexRead.data, { force: args.force }));
603
+ log(M.codexHooks(hadFile ? M.merged : M.created, hadFile ? M.bakSaved : ''));
604
+ }
605
+ log(M.codexTrust);
606
+
541
607
  // 3. .mcp.json --------------------------------------------------------------
542
608
  // Written when mcpvault is wanted OR a selected companion ships an MCP server.
543
609
  const companionMcp = companionMcpPatch(companions, skipMcp);
package/src/taxonomy.mjs CHANGED
@@ -102,15 +102,17 @@ export const SESSION_HOOKS = [
102
102
  // timeout 45 (was 15): measured ~4s warm via npx, but Windows startup contention (several npx
103
103
  // cold-starts at once — a sibling MCP took 26s in a real log) blew 15s and silently dropped the
104
104
  // memory injection for the whole session.
105
- { event: 'SessionStart', matcher: 'startup|clear|compact', name: 'brain-inject', timeout: 45, order: -10, statusMessage: 'wendkeep: injecting memory + active change' },
106
- { event: 'SessionStart', matcher: 'startup', name: 'session-start', timeout: 30, statusMessage: 'wendkeep: opening Obsidian session' },
107
- { event: 'Stop', matcher: null, name: 'session-stop', timeout: 60, statusMessage: 'wendkeep: writing session checkpoint' },
108
- { event: 'UserPromptSubmit', matcher: null, name: 'session-ensure', timeout: 30, statusMessage: 'wendkeep: ensuring active session' },
105
+ { event: 'SessionStart', matcher: 'startup|clear|compact', name: 'brain-inject', timeout: 45, order: -10, codex: true, statusMessage: 'wendkeep: injecting memory + active change' },
106
+ { event: 'SessionStart', matcher: 'startup', name: 'session-start', timeout: 30, codex: true, statusMessage: 'wendkeep: opening Obsidian session' },
107
+ { event: 'Stop', matcher: null, name: 'session-stop', timeout: 60, codex: true, statusMessage: 'wendkeep: writing session checkpoint' },
108
+ { event: 'UserPromptSubmit', matcher: null, name: 'session-ensure', timeout: 30, codex: true, statusMessage: 'wendkeep: ensuring active session' },
109
109
  // Capture an interactive decision (AskUserQuestion) — options + the user's choice — into 04-Decisões.
110
+ // codex: AskUserQuestion is a Claude-only tool; there is nothing to match on.
110
111
  { event: 'PostToolUse', matcher: 'AskUserQuestion', name: 'decision-capture', timeout: 15, statusMessage: 'wendkeep: recording decision' },
111
112
  // Refresh subagent/workflow telemetry as each subagent finishes (resilient to a missed Stop).
112
- { event: 'SubagentStop', matcher: null, name: 'subagent-stop', timeout: 20, statusMessage: 'wendkeep: subagent telemetry' },
113
+ { event: 'SubagentStop', matcher: null, name: 'subagent-stop', timeout: 20, codex: true, statusMessage: 'wendkeep: subagent telemetry' },
113
114
  // Log plan/task progress into the active session note when a task is marked complete.
115
+ // codex: TaskCompleted is not in Codex's hook event enum.
114
116
  { event: 'TaskCompleted', matcher: null, name: 'task-log', timeout: 10, statusMessage: 'wendkeep: plan progress' },
115
117
  ];
116
118
 
@@ -134,15 +136,39 @@ export function hookCommandLocalLegacy(name) {
134
136
  // preservar a opção futura de gates opt-in; hoje o init wira TODOS por default.
135
137
  // preferLocal: alta frequência → invocação node-direta quando houver instalação local.
136
138
  export const CHANGE_NUDGE_HOOKS = [
137
- { event: 'UserPromptSubmit', matcher: null, name: 'change-context', timeout: 15, order: 10, preferLocal: true, statusMessage: 'wendkeep: change ping' },
139
+ { event: 'UserPromptSubmit', matcher: null, name: 'change-context', timeout: 15, order: 10, preferLocal: true, codex: true, statusMessage: 'wendkeep: change ping' },
140
+ // codex: reads tool_input.file_path, which Codex's apply_patch envelope does not carry.
138
141
  { event: 'PostToolUse', matcher: 'Edit|Write|MultiEdit', name: 'change-warn', timeout: 10, order: 10, preferLocal: true, statusMessage: 'wendkeep: change warn' },
142
+ // codex: no ExitPlanMode equivalent — update_plan is the running TODO list, not an approval.
139
143
  { event: 'PostToolUse', matcher: 'ExitPlanMode', name: 'plan-capture', timeout: 15, order: 10, preferLocal: true, statusMessage: 'wendkeep: capturing approved plan' },
140
- { event: 'Stop', matcher: null, name: 'change-nag', timeout: 15, order: 10, preferLocal: true, statusMessage: 'wendkeep: open tasks check' },
144
+ { event: 'Stop', matcher: null, name: 'change-nag', timeout: 15, order: 10, preferLocal: true, codex: true, statusMessage: 'wendkeep: open tasks check' },
141
145
  ];
142
146
  export const CHANGE_GATE_HOOKS = [
147
+ // codex: reads tool_input.command; Codex's exec sends a raw string and exec_command an argv,
148
+ // so the guard would silently fail OPEN — worse than absent, since the docs would promise it.
143
149
  { event: 'PreToolUse', matcher: 'Bash', name: 'change-guard', timeout: 10, order: 10, preferLocal: true, statusMessage: 'wendkeep: change gate' },
144
150
  ];
145
151
 
152
+ // --- Codex projection ---------------------------------------------------------
153
+ // Codex reads <project>/.codex/hooks.json (PascalCase event keys, same group shape as
154
+ // Claude's settings.json). Only specs that opt in with `codex: true` are projected — the
155
+ // rest carry a `// codex:` comment above them saying why. Three deltas from Claude, each
156
+ // verified against codex-rs and each silent when wrong: the timeout key is `timeoutSec`
157
+ // (`timeout` is not a field and falls through to a 600s default), there is no
158
+ // ${CLAUDE_PROJECT_DIR} so `preferLocal` never applies, and matcher is only honoured on
159
+ // SessionStart (UserPromptSubmit/Stop null it at discovery).
160
+ export const CODEX_MATCHER_EVENTS = new Set(['SessionStart']);
161
+
162
+ export function codexHookSpecs(specs) {
163
+ return specs.filter((h) => h.codex === true && !h.command);
164
+ }
165
+
166
+ export function codexHookEntry(spec) {
167
+ const entry = { type: 'command', command: hookCommand(spec.name), timeoutSec: spec.timeout };
168
+ if (spec.statusMessage) entry.statusMessage = spec.statusMessage;
169
+ return entry;
170
+ }
171
+
146
172
  // --- companion plugins / MCP --------------------------------------------------
147
173
  // Optional tools wendkeep init can pin alongside the vault. Each is wired through
148
174
  // the MOST agent-agnostic mechanism it supports; the Claude Code plugin entry
@@ -330,7 +356,7 @@ export function deriveVaultDirName(projectPath) {
330
356
  .pop() || '';
331
357
  const clean = base
332
358
  .replace(/^[.\s]+/, '') // drop leading dots/space so we never get `..name`
333
- .replace(/[<>:"/\\|?*-]/g, '-') // FS-unsafe chars -> dash
359
+ .replace(/[<>:"/\\|?*\x00-\x1f]/g, '-') // FS-unsafe chars -> dash
334
360
  .replace(/\s+/g, '-') // whitespace -> dash
335
361
  .replace(/-+/g, '-') // collapse dash runs
336
362
  .replace(/^-+|-+$/g, '') // trim edge dashes