wendkeep 0.65.0 → 0.66.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 CHANGED
@@ -4,6 +4,38 @@ 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.66.0] — 2026-07-29
8
+
9
+ ### Added
10
+
11
+ - **O workspace privado Integrations passa a ter um kernel canônico para Claude Code e Codex.**
12
+ Catálogo/projeção de hooks, envelope e provider, filtros de conteúdo, normalização de uso,
13
+ parsers de transcript e identidade de sessão/turno agora vivem em módulos puros sob
14
+ `packages/integrations/src/`, sem filesystem, ambiente global, Vault ou registry no import.
15
+ - **O tarball instalado prova a fronteira completa fora do checkout.** O teste executa `init` e
16
+ um hook `session-ensure` em consumidor temporário, valida estado persistido e as projeções
17
+ Claude/Codex contra o kernel empacotado, e confirma que Integrations permanece interno à
18
+ única publicação `wendkeep`.
19
+
20
+ ### Changed
21
+
22
+ - **As fachadas históricas agora injetam efeitos no kernel de Integrations.** Taxonomia e hooks
23
+ preservam assinaturas e identidade dos exports enquanto stdin/stdout, `process.env`, leitura de
24
+ transcripts, acesso ao Vault e ao registry continuam nas bordas; paths, schemas, configs e
25
+ sessões existentes não exigem migração.
26
+ - **Os workspaces privados deixaram de vazar por deep imports do wildcard raiz.** A allowlist de
27
+ exports mantém `wendkeep/harness`, `wendkeep/vault` e os caminhos históricos publicados, mas
28
+ bloqueia `packages/*`, inclusive variantes percent-encoded; MCP e Integrations seguem como
29
+ adapters irmãos sem dependência direta.
30
+
31
+ ### Fixed
32
+
33
+ - **A reconexão de sessão por transcript agora respeita o provider.** Uma entrada do registry de
34
+ outro host é ignorada sem substituir uma identidade canônica válida já inspecionada.
35
+ - **A suíte integral mantém determinismo sob carga de I/O no Windows.** O runner limita a duas as
36
+ files executadas em paralelo, preservando a concorrência multiprocesso dentro dos testes de
37
+ CAS/locks e todos os asserts, sem falsos vermelhos causados por starvation entre suítes.
38
+
7
39
  ## [0.65.0] — 2026-07-29
8
40
 
9
41
  ### Added
package/README.en.md CHANGED
@@ -17,14 +17,24 @@
17
17
 
18
18
  The runtime is being separated into six physical boundaries — `cli`, `harness`, `vault`, `mcp`,
19
19
  `integrations`, and `pi` — without fragmenting installation. The private `cli`, `harness`,
20
- `vault`, and `mcp` workspaces now canonically own the executable runtime, Operating Profiles/the
21
- sensor engine, safe binding/the Shared Project Memory v2 kernel, and the MCP configuration kernel,
22
- respectively. The root package exposes Harness and Vault through `wendkeep/harness` and
23
- `wendkeep/vault`; CLI and MCP remain private surfaces, reached only through the binaries and the
24
- configuration effects of `init`. Historical imports keep working through compatibility facades
20
+ `vault`, `mcp`, and `integrations` workspaces now canonically own the executable runtime,
21
+ Operating Profiles/the sensor engine, safe binding/the Shared Project Memory v2 kernel, the MCP
22
+ configuration kernel, and pure Claude/Codex integration rules, respectively. The root package
23
+ exposes Harness and Vault through `wendkeep/harness` and `wendkeep/vault`; CLI, MCP, and
24
+ Integrations remain private surfaces, reached only through the binaries, the configuration effects
25
+ of `init`, and historical facades. Historical imports keep working through compatibility facades
25
26
  and no session data needs migration;
26
27
  see the [modular architecture](docs/en/architecture.md).
27
28
 
29
+ In the **0.66 Integrations Kernel** phase, `packages/integrations/src/` becomes the canonical
30
+ authority for the hook catalog and projection, the envelope/provider, transcript content and usage
31
+ filters and parsers, and session identity. These rules are pure: stdin/stdout, environment,
32
+ filesystem, Vault, and registry effects remain in the historical facades. MCP and Integrations are
33
+ sibling adapters with no dependency between them, and the direction remains
34
+ `cli/mcp/integrations/pi → Harness → Vault`. Hooks, sessions, paths, configuration, and schemas
35
+ remain equivalent; the private `@wendkeep/integrations` workspace stays inside the single
36
+ published `wendkeep` package, with no public `wendkeep/integrations` subpath. Pi is the next phase.
37
+
28
38
  In the **0.65 MCP Configuration Kernel** phase, `packages/mcp/src/config.mjs` becomes the
29
39
  canonical authority for the MCPVault entry, catalog-described server selection, and `.mcp.json`
30
40
  merging. `src/taxonomy.mjs` supplies descriptors while `src/init.mjs` retains only filesystem
package/README.md CHANGED
@@ -17,14 +17,24 @@
17
17
 
18
18
  The runtime is being separated into six physical boundaries — `cli`, `harness`, `vault`, `mcp`,
19
19
  `integrations`, and `pi` — without fragmenting installation. The private `cli`, `harness`,
20
- `vault`, and `mcp` workspaces now canonically own the executable runtime, Operating Profiles/the
21
- sensor engine, safe binding/the Shared Project Memory v2 kernel, and the MCP configuration kernel,
22
- respectively. The root package exposes Harness and Vault through `wendkeep/harness` and
23
- `wendkeep/vault`; CLI and MCP remain private surfaces, reached only through the binaries and the
24
- configuration effects of `init`. Historical imports keep working through compatibility facades
20
+ `vault`, `mcp`, and `integrations` workspaces now canonically own the executable runtime,
21
+ Operating Profiles/the sensor engine, safe binding/the Shared Project Memory v2 kernel, the MCP
22
+ configuration kernel, and pure Claude/Codex integration rules, respectively. The root package
23
+ exposes Harness and Vault through `wendkeep/harness` and `wendkeep/vault`; CLI, MCP, and
24
+ Integrations remain private surfaces, reached only through the binaries, the configuration effects
25
+ of `init`, and historical facades. Historical imports keep working through compatibility facades
25
26
  and no session data needs migration;
26
27
  see the [modular architecture](docs/en/architecture.md).
27
28
 
29
+ In the **0.66 Integrations Kernel** phase, `packages/integrations/src/` becomes the canonical
30
+ authority for the hook catalog and projection, the envelope/provider, transcript content and usage
31
+ filters and parsers, and session identity. These rules are pure: stdin/stdout, environment,
32
+ filesystem, Vault, and registry effects remain in the historical facades. MCP and Integrations are
33
+ sibling adapters with no dependency between them, and the direction remains
34
+ `cli/mcp/integrations/pi → Harness → Vault`. Hooks, sessions, paths, configuration, and schemas
35
+ remain equivalent; the private `@wendkeep/integrations` workspace stays inside the single
36
+ published `wendkeep` package, with no public `wendkeep/integrations` subpath. Pi is the next phase.
37
+
28
38
  In the **0.65 MCP Configuration Kernel** phase, `packages/mcp/src/config.mjs` becomes the
29
39
  canonical authority for the MCPVault entry, catalog-described server selection, and `.mcp.json`
30
40
  merging. `src/taxonomy.mjs` supplies descriptors while `src/init.mjs` retains only filesystem
@@ -78,6 +78,14 @@ When MCP is enabled, `init` preserves existing properties and servers in `.mcp.j
78
78
  and the reconciled proposal is written to `.mcp.json.new`. Since version 0.65, this composition is
79
79
  owned by the private MCP kernel without changing commands, flags, or the public npm surface.
80
80
 
81
+ Pure rules that project Claude/Codex hooks and interpret envelopes, transcripts, usage, and
82
+ identity belong to the private `@wendkeep/integrations` workspace. Historical facades retain
83
+ stdin/stdout, environment, filesystem, Vault, and registry effects. This adds no commands or flags,
84
+ changes neither hooks nor sessions, and requires no Vault, configuration, path, or schema migration.
85
+ MCP and Integrations remain sibling adapters with no dependency between them, and the direction
86
+ remains `cli/mcp/integrations/pi → Harness → Vault`. Everything stays inside the single published
87
+ `wendkeep` package; there is no public `wendkeep/integrations` surface. Pi is the next modular phase.
88
+
81
89
  ## Common errors and diagnosis
82
90
 
83
91
  - Wrong vault: inspect `.wendkeep.json` and run `wendkeep doctor --vault <path>`.
@@ -79,6 +79,14 @@ e adiciona `wendkeep-vault`. Se o JSON existente for inválido, o arquivo origin
79
79
  a byte intacto e a proposta reconciliada é gravada em `.mcp.json.new`. Desde a versão 0.65, essa
80
80
  composição pertence ao kernel MCP privado, sem alterar comandos, flags ou a superfície npm pública.
81
81
 
82
+ As regras puras que projetam os hooks de Claude/Codex e interpretam envelopes, transcripts, uso e
83
+ identidade pertencem ao workspace privado `@wendkeep/integrations`. As fachadas históricas mantêm
84
+ os efeitos de stdin/stdout, ambiente, filesystem, Vault e registry. Isso não acrescenta comandos ou
85
+ flags, não altera hooks ou sessões e não exige migração de cofre, config, paths ou schemas. MCP e
86
+ Integrations permanecem adapters irmãos sem dependência, e a direção continua
87
+ `cli/mcp/integrations/pi → Harness → Vault`. Tudo permanece dentro do único pacote publicado
88
+ `wendkeep`; não existe `wendkeep/integrations` público. A próxima fase modular é Pi.
89
+
82
90
  ## Erros comuns e diagnóstico
83
91
 
84
92
  - Cofre errado: confira `.wendkeep.json` e rode `wendkeep doctor --vault <path>`.
@@ -7,6 +7,26 @@ import { resolveProjectVault } from '../src/project-vault.mjs';
7
7
  import {
8
8
  assertVaultPathSafe, mkdirVaultPath, writeVaultFileAtomic,
9
9
  } from './vault-path-safety.mjs';
10
+ import {
11
+ salvageTruncatedJson,
12
+ parseHookInput,
13
+ stringifyHookOutput,
14
+ detectProvider as detectProviderFromEnvironment,
15
+ providerMeta as providerMetaFromProvider,
16
+ extractHookPrompt,
17
+ } from '../packages/integrations/src/hook-envelope.mjs';
18
+ import {
19
+ isBootstrapPrompt,
20
+ redactSecrets,
21
+ } from '../packages/integrations/src/prompt-content.mjs';
22
+ import { transcriptsMatch } from '../packages/integrations/src/session-identity.mjs';
23
+ export {
24
+ salvageTruncatedJson,
25
+ extractHookPrompt,
26
+ isBootstrapPrompt,
27
+ redactSecrets,
28
+ transcriptsMatch,
29
+ };
10
30
 
11
31
  // Deprecated export kept for consumers that imported it before 0.39.0. Automatic
12
32
  // hooks never use this fallback: an unbound project fails closed.
@@ -27,56 +47,12 @@ export const VAULT_COMPLEMENT_RULES = [
27
47
  'Atualize `SHARED_MEMORY.md` somente quando a síntese mudar estado ativo que outro agente precise saber.',
28
48
  ];
29
49
 
30
- // Codex on Windows serializes the Stop payload with `last_assistant_message` cut mid-string
31
- // and never closed when the assistant text carries non-ASCII (openai/codex#23784). That field
32
- // is LAST in codex-rs's StopCommandInput, so everything wendkeep consumes — session_id,
33
- // turn_id, transcript_path, cwd — sits in the intact prefix.
34
- //
35
- // One pass, tracking quotes/escapes/depth, remembering the offset of the last top-level comma
36
- // that was NOT inside a string. Re-closing there yields the well-formed prefix. Deliberately
37
- // NOT a decreasing brute-force parse: this runs on every turn and the payload can be tens of
38
- // KB. The truncated field is dropped, never reconstructed — half an assistant message is
39
- // invented data, and it is the one field we do not need.
40
- export function salvageTruncatedJson(raw) {
41
- const text = String(raw || '');
42
- if (text[0] !== '{') return null;
43
- let inString = false;
44
- let escaped = false;
45
- let depth = 0;
46
- let lastBoundary = -1;
47
- for (let i = 0; i < text.length; i += 1) {
48
- const ch = text[i];
49
- if (escaped) { escaped = false; continue; }
50
- if (ch === '\\') { if (inString) escaped = true; continue; }
51
- if (ch === '"') { inString = !inString; continue; }
52
- if (inString) continue;
53
- if (ch === '{' || ch === '[') depth += 1;
54
- else if (ch === '}' || ch === ']') depth -= 1;
55
- else if (ch === ',' && depth === 1) lastBoundary = i;
56
- }
57
- if (lastBoundary === -1) return null;
58
- try {
59
- const parsed = JSON.parse(`${text.slice(0, lastBoundary)}}`);
60
- return parsed && typeof parsed === 'object' ? parsed : null;
61
- } catch { return null; }
62
- }
63
-
64
50
  export function readHookInput() {
65
- const raw = readFileSync(0, 'utf-8').trim();
66
- if (!raw) return {};
67
- try {
68
- return JSON.parse(raw);
69
- } catch (error) {
70
- const salvaged = salvageTruncatedJson(raw);
71
- // `_wk` prefix: the object is the harness payload merged with our own metadata, and a
72
- // silent key collision here would be worse than the ugly prefix.
73
- if (salvaged) return { ...salvaged, _wkSalvaged: true };
74
- throw error;
75
- }
51
+ return parseHookInput(readFileSync(0, 'utf-8'));
76
52
  }
77
53
 
78
54
  export function writeHookOutput(payload = {}) {
79
- process.stdout.write(JSON.stringify(payload));
55
+ process.stdout.write(stringifyHookOutput(payload));
80
56
  }
81
57
 
82
58
  // Resolve from explicit hook payload or the nearest project-local binding. A legacy
@@ -118,17 +94,11 @@ export function warnIfDefaultVault(input = {}) {
118
94
  // Detecta o agente real que está executando o hook. Claude Code expõe
119
95
  // CLAUDECODE / CLAUDE_CODE_SESSION_ID / CLAUDE_PROJECT_DIR; Codex não.
120
96
  export function detectProvider() {
121
- if (process.env.CLAUDECODE === '1' || process.env.CLAUDE_CODE_SESSION_ID || process.env.CLAUDE_PROJECT_DIR) {
122
- return 'claude';
123
- }
124
- return 'codex';
97
+ return detectProviderFromEnvironment(process.env);
125
98
  }
126
99
 
127
100
  export function providerMeta(provider = detectProvider()) {
128
- if (provider === 'claude') {
129
- return { id: 'claude', label: 'Claude Code', tag: 'claude', source: 'claude-hook' };
130
- }
131
- return { id: 'codex', label: 'Codex', tag: 'codex', source: 'codex-hook' };
101
+ return providerMetaFromProvider(provider);
132
102
  }
133
103
 
134
104
  export function ensureDir(path) {
@@ -807,44 +777,6 @@ export function keysBate(a = '', b = '') {
807
777
  return a === b || a.startsWith(b) || b.startsWith(a);
808
778
  }
809
779
 
810
- export function extractHookPrompt(input = {}) {
811
- const candidates = [
812
- input.prompt,
813
- input.user_prompt,
814
- input.userPrompt,
815
- input.message,
816
- input.input,
817
- ];
818
-
819
- for (const candidate of candidates) {
820
- if (typeof candidate === 'string' && candidate.trim()) return candidate.trim();
821
- }
822
-
823
- if (Array.isArray(input.messages)) {
824
- const text = input.messages
825
- .map((message) => message?.content || message?.text || '')
826
- .filter((item) => typeof item === 'string' && item.trim())
827
- .join('\n')
828
- .trim();
829
- if (text) return text;
830
- }
831
-
832
- return '';
833
- }
834
-
835
- export function isBootstrapPrompt(text = '') {
836
- const clean = String(text || '').trim();
837
- return clean.startsWith('# AGENTS.md instructions')
838
- || clean.startsWith('<environment_context>')
839
- || clean.startsWith('<permissions instructions>')
840
- // Codex injects the available-plugins catalogue as the first userPrompt of turn 1.
841
- // Anchored with startsWith on purpose: matching the bare substring would discard a
842
- // legitimate prompt that merely asks about plugins.
843
- || clean.startsWith('<recommended_plugins>')
844
- || clean.includes('You are Codex, a coding agent')
845
- || clean.startsWith('## Memory');
846
- }
847
-
848
780
  export function summarizePromptForTitle(text = '', fallback = 'session') {
849
781
  const cleaned = redactSecrets(String(text || ''))
850
782
  .replace(/\[@[^\]]+\]\([^)]+\)/g, ' ')
@@ -901,27 +833,6 @@ export function shouldReuseActiveSession(control = {}, now = new Date()) {
901
833
  return now.getTime() - startedMs <= windowMinutes * 60 * 1000;
902
834
  }
903
835
 
904
- function normalizeTranscript(p) {
905
- return String(p || '').replace(/\\/g, '/').toLowerCase();
906
- }
907
-
908
- function transcriptBasename(p) {
909
- const n = normalizeTranscript(p);
910
- const i = n.lastIndexOf('/');
911
- return i === -1 ? n : n.slice(i + 1);
912
- }
913
-
914
- // Mesmo transcript apesar de caixa/separador diferentes (o Claude Code emite o
915
- // slug do projeto ora `c--`, ora `C--`) ou prefixo de path diferente (WSL vs
916
- // Windows). Compara normalizado e, em último caso, pelo basename
917
- // (`<session_id>.jsonl`, globalmente único). Evita rupturas de sessão no restart.
918
- export function transcriptsMatch(a, b) {
919
- if (!a || !b) return false;
920
- if (normalizeTranscript(a) === normalizeTranscript(b)) return true;
921
- const ba = transcriptBasename(a);
922
- return !!ba && ba === transcriptBasename(b);
923
- }
924
-
925
836
  // O `transcript_path` é estável dentro de uma conversa mesmo quando o
926
837
  // SessionStart re-dispara (compactação/resume) com `session_id` novo. Achar a
927
838
  // sessão ativa do mesmo transcript evita criar placeholders `HH-MM-codex`.
@@ -940,17 +851,6 @@ export function findActiveSessionByTranscript(vaultBase, transcriptPath) {
940
851
  return best;
941
852
  }
942
853
 
943
- export function redactSecrets(text) {
944
- if (!text) return '';
945
- return String(text)
946
- .replace(/\b(sk-[A-Za-z0-9_-]{12,})\b/g, '[REDACTED_SECRET]')
947
- .replace(/\b(whsec_[A-Za-z0-9_/-]{8,})\b/g, '[REDACTED_SECRET]')
948
- .replace(/\b(gh[pousr]_[A-Za-z0-9_]{12,})\b/g, '[REDACTED_SECRET]')
949
- .replace(/\b(xox[baprs]-[A-Za-z0-9-]{12,})\b/g, '[REDACTED_SECRET]')
950
- .replace(/\b([A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|API_KEY)[A-Z0-9_]*)\s*[:=]\s*["']?[^"'\s]+/gi, '$1=[REDACTED_SECRET]')
951
- .replace(/:\/\/([^:\s/@]+):([^@\s/]+)@/g, '://[REDACTED_SECRET]@');
952
- }
953
-
954
854
  export function truncate(text, max = 240) {
955
855
  const clean = redactSecrets(String(text || '').replace(/\s+/g, ' ').trim());
956
856
  if (clean.length <= max) return clean;
@@ -1,152 +1,37 @@
1
1
  import { existsSync, readFileSync } from 'fs';
2
2
  import { basename } from 'path';
3
- import { detectProvider, readSessionRegistry, transcriptsMatch } from './obsidian-common.mjs';
4
-
5
- function parseLines(path) {
6
- if (!path || !existsSync(path)) return [];
7
- return readFileSync(path, 'utf-8').split('\n').filter(Boolean).map((line) => {
8
- try { return JSON.parse(line); } catch { return null; }
9
- }).filter(Boolean);
10
- }
3
+ import { detectProvider, readSessionRegistry } from './obsidian-common.mjs';
4
+ import {
5
+ inspectTranscriptIdentityContent,
6
+ resolveSessionIdentitySnapshot,
7
+ } from '../packages/integrations/src/session-identity.mjs';
11
8
 
12
9
  export function inspectTranscriptIdentity(transcriptPath) {
13
- const lines = parseLines(transcriptPath);
14
- const codexMeta = lines.find((event) => event.type === 'session_meta')?.payload;
15
- if (codexMeta) {
16
- return {
17
- transcriptProvider: 'openai',
18
- provider: 'codex',
19
- canonicalConversationId: codexMeta.session_id || codexMeta.id || '',
20
- transcriptId: codexMeta.id || basename(transcriptPath, '.jsonl'),
21
- parentConversationId: codexMeta.parent_thread_id || codexMeta.forked_from_id || '',
22
- };
23
- }
24
- const claudeEvent = lines.find((event) => event.sessionId);
25
- if (claudeEvent) {
26
- return {
27
- transcriptProvider: 'anthropic',
28
- provider: 'claude',
29
- canonicalConversationId: claudeEvent.sessionId,
30
- transcriptId: basename(transcriptPath, '.jsonl'),
31
- parentConversationId: '',
32
- };
33
- }
34
- return { transcriptProvider: 'unknown', provider: 'unknown', canonicalConversationId: '', transcriptId: '', parentConversationId: '' };
35
- }
36
-
37
- function compatible(provider, transcriptProvider) {
38
- return (provider === 'codex' && transcriptProvider === 'openai')
39
- || (provider === 'claude' && transcriptProvider === 'anthropic');
40
- }
41
-
42
- function canonicalUuid(value = '') {
43
- const id = String(value || '').trim().toLowerCase();
44
- return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(id) ? id : '';
10
+ const content = transcriptPath && existsSync(transcriptPath)
11
+ ? readFileSync(transcriptPath, 'utf-8')
12
+ : '';
13
+ return inspectTranscriptIdentityContent(content, {
14
+ fallbackTranscriptId: transcriptPath ? basename(transcriptPath, '.jsonl') : '',
15
+ });
45
16
  }
46
17
 
47
18
  export function resolveSessionIdentity(vaultBase, input = {}, provider = detectProvider()) {
48
19
  const transcriptPath = input.transcript_path || input.transcriptPath || '';
49
- const inspected = inspectTranscriptIdentity(transcriptPath);
50
- const hookId = input.session_id || input.sessionId || '';
51
- const codexThreadId = canonicalUuid(input.codex_thread_id || input.codexThreadId || process.env.CODEX_THREAD_ID || '');
52
-
53
- // Codex Desktop exposes the stable canonical conversation through
54
- // CODEX_THREAD_ID even when SessionStart/UserPromptSubmit omit transcript_path.
55
- // This is safer than the hook session_id (which may rotate on resume). Once the
56
- // rollout materializes, require both canonical sources to agree.
57
- const transcriptConversationId = canonicalUuid(inspected.canonicalConversationId);
58
- if (provider === 'codex' && transcriptConversationId && codexThreadId
59
- && transcriptConversationId !== codexThreadId) {
60
- return {
61
- state: 'deferred', provider, transcriptPath,
62
- diagnostics: [`CODEX_THREAD_ID diverge do session_id do transcript (${codexThreadId} != ${transcriptConversationId})`],
63
- };
64
- }
65
- if (provider === 'codex' && !inspected.canonicalConversationId && codexThreadId) {
66
- return {
67
- state: 'resolved',
68
- provider,
69
- canonicalConversationId: codexThreadId,
70
- hookSessionId: hookId,
71
- transcriptPath,
72
- transcriptId: transcriptPath ? basename(transcriptPath, '.jsonl') : codexThreadId,
73
- parentConversationId: '',
74
- diagnostics: [],
75
- };
76
- }
77
-
78
- // Claude: input.session_id já é o id canônico e estável da conversa — idêntico
79
- // ao sessionId que cada linha do transcript grava. Numa sessão nova o arquivo
80
- // ainda não materializou em disco quando o hook roda, então inspectTranscriptIdentity
81
- // volta vazio e o gate abaixo adiaria o 1º turno inteiro (SessionStart + 1º prompt
82
- // sem nota; sessão curta nunca registrada). Não adiar: usar o hookId direto.
83
- // Codex NÃO entra aqui de propósito — lá o id do hook no resume é efêmero e ≠ do
84
- // thread canônico, então seguimos exigindo rollout/registry (incidente 2026-07-11,
85
- // contaminação cross-provider de sessão).
86
- if (provider === 'claude' && hookId && !inspected.canonicalConversationId) {
87
- return {
88
- state: 'resolved',
89
- provider,
90
- canonicalConversationId: hookId,
91
- hookSessionId: hookId,
92
- transcriptPath,
93
- transcriptId: transcriptPath ? basename(transcriptPath, '.jsonl') : hookId,
94
- parentConversationId: '',
95
- diagnostics: [],
96
- };
97
- }
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
-
123
- if (!transcriptPath || !inspected.canonicalConversationId) {
124
- return { state: 'deferred', provider, transcriptPath, diagnostics: ['transcript ausente ou sem identidade canônica'] };
125
- }
126
- if (!compatible(provider, inspected.transcriptProvider)) {
127
- return { state: 'deferred', provider, transcriptPath, diagnostics: [`provider ${provider} incompatível com transcript ${inspected.transcriptProvider}`] };
128
- }
129
-
130
- const registry = readSessionRegistry(vaultBase);
131
- const byTranscript = Object.entries(registry.sessions || {}).find(([, entry]) => {
132
- const paths = [...(Array.isArray(entry?.transcript_paths) ? entry.transcript_paths : []), entry?.transcript_path].filter(Boolean);
133
- return paths.some((path) => transcriptsMatch(path, transcriptPath));
134
- });
135
- const canonicalConversationId = byTranscript?.[0] || inspected.canonicalConversationId;
136
- return {
137
- state: 'resolved',
20
+ return resolveSessionIdentitySnapshot({
21
+ input,
138
22
  provider,
139
- canonicalConversationId,
140
- hookSessionId: hookId,
23
+ codexThreadId: process.env.CODEX_THREAD_ID || '',
141
24
  transcriptPath,
142
- transcriptId: inspected.transcriptId,
143
- parentConversationId: inspected.parentConversationId,
144
- diagnostics: [],
145
- };
25
+ inspected: inspectTranscriptIdentity(transcriptPath),
26
+ registry: readSessionRegistry(vaultBase),
27
+ });
146
28
  }
147
29
 
148
30
  export function resolveSessionEntry(vaultBase, input = {}, provider = detectProvider()) {
149
31
  const identity = resolveSessionIdentity(vaultBase, input, provider);
150
32
  if (identity.state !== 'resolved') return { identity, entry: null };
151
- return { identity, entry: readSessionRegistry(vaultBase).sessions?.[identity.canonicalConversationId] || null };
33
+ return {
34
+ identity,
35
+ entry: readSessionRegistry(vaultBase).sessions?.[identity.canonicalConversationId] || null,
36
+ };
152
37
  }