wendkeep 0.36.0 → 0.38.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,35 @@ 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.38.0] — 2026-07-12
8
+
9
+ ### Added
10
+
11
+ - Registry multi-sessão v2: `SESSION_REGISTRY.json` passa a ser a autoridade por conversa, enquanto `CURRENT_SESSION.md` vira um dashboard compatível com todas as sessões ativas.
12
+ - `wendkeep session list|show|use` e `wendkeep change bind <slug> --session <id>` permitem inspecionar sessões concorrentes e transferir explicitamente o vínculo de uma change.
13
+ - Metadados de auditoria da observabilidade registram caller, conversa canônica, transcript e instante da atualização.
14
+
15
+ ### Fixed
16
+
17
+ - Roteamento cross-provider agora é fail-closed: Codex usa `session_meta.payload.session_id`, Claude usa o `sessionId` do transcript, e writers não recorrem ao foco global quando a identidade é ambígua.
18
+ - Atualizações concorrentes do registry usam lock, releitura e rename atômico; patches vazios não apagam transcript ou metadados válidos.
19
+ - `cost rebuild` reporta entradas órfãs, ausentes ou incompatíveis como estado não verde em vez de omiti-las silenciosamente.
20
+
21
+ ## [0.37.0] — 2026-07-11
22
+
23
+ ### Added
24
+
25
+ - Observabilidade consolidada em `## Agentes, tokens e custos`: Stop, SubagentStop, importação e rebuild usam um único writer atômico para main + subagents.
26
+ - Ledger por modelo/origem com reasoning tokens e effort, sem alterar a regra de preço do modelo.
27
+ - Novo `wendkeep cost rebuild`, dry-run por padrão; `--apply` reconstrói sessões antigas via `SESSION_REGISTRY` e grava `.brain/COST_REBUILD.json`.
28
+ - Preços API-equivalentes de GPT-5.6 Sol, Terra e Luna.
29
+
30
+ ### Fixed
31
+
32
+ - Custos de subagents são atribuídos ao modelo que realmente os executou, em vez do modelo principal.
33
+ - Migração remove os headings legados sem perder reaberturas ou iterações mal posicionadas.
34
+ - Totais combinados de tokens/custo são atualizados também no `SubagentStop` e persistidos em campos compatíveis com os dashboards existentes.
35
+
7
36
  ## [0.35.0] — 2026-07-11
8
37
 
9
38
  ### Fixed
@@ -870,9 +899,3 @@ Initial release — the capture engine, extracted from a system in daily product
870
899
  [0.2.7]: https://www.npmjs.com/package/wendkeep/v/0.2.7
871
900
  [0.2.0]: https://www.npmjs.com/package/wendkeep/v/0.2.0
872
901
  [0.1.0]: https://www.npmjs.com/package/wendkeep/v/0.1.0
873
- # 0.36.0 — 2026-07-11
874
-
875
- - Custos de subagents agora são atribuídos ao modelo que realmente os executou por meio de um ledger por sessão; campos legados continuam compatíveis.
876
- - Novo `wendkeep cost rebuild`, dry-run por padrão, reconstrói sessões antigas via `SESSION_REGISTRY` e, com `--apply`, grava um manifesto auditável em `.brain/COST_REBUILD.json`.
877
- - Totais combinados de tokens/custo passam a ser persistidos explicitamente nas notas.
878
- - Preços API-equivalentes de GPT-5.6 Sol, Terra e Luna adicionados à tabela editável e ao fallback interno.
package/README.md CHANGED
@@ -119,6 +119,10 @@ No re‑copying, no snapshot to re‑sync — the package is the single source o
119
119
  | `wendkeep cost [--since d]` | Aggregate AI-coding spend across the vault's sessions — total, by model, by day (`--json`). |
120
120
  | `wendkeep import [opts]` | **Retroactive memory** — backfill past **Claude + Codex** sessions into the vault (deduped by `session_id`). `--source all\|claude\|codex` / `--from <dir>` / `--codex-from <dir>` / `--since d` / `--limit n` / `--dry-run` / `--json`. |
121
121
  | `wendkeep cost rebuild [opts]` | Recalcula custos históricos do transcript principal e subagents usando `SESSION_REGISTRY`. Dry-run por padrão; `--apply` atualiza notas e grava `.brain/COST_REBUILD.json`. Aceita `--session`, `--limit` e `--json`. |
122
+ | `wendkeep session list\|show\|use` | Lista o registry multi-sessão, mostra uma conversa ou muda somente o foco humano de `CURRENT_SESSION.md`. |
123
+ | `wendkeep change bind <slug> --session <id>` | Vincula ou transfere uma change para uma conversa canônica sem esconder as demais pendências. |
124
+
125
+ Session notes use one live `## Agentes, tokens e custos` snapshot. Main-agent and subagent hooks recompose it atomically, with costs, token dimensions, reasoning tokens and effort per model/source.
122
126
  | `wendkeep lesson add "t" "l"` | Record a project-local lesson (injected at the next SessionStart). |
123
127
  | `wendkeep sync-defs` | Copy `.brain/agents\|skills` into `.codex/agents`, `.claude/skills`, `.agents/skills`; `--check` detects drift. |
124
128
  | `wendkeep validate-memory [path]` | Validate `.brain/CORE.md` (cap 25, 3 sections, no secrets/PII). |
package/README.pt-BR.md CHANGED
@@ -116,6 +116,10 @@ Sem recopiar, sem snapshot pra re‑sincronizar — o pacote é a única fonte d
116
116
  | `wendkeep sensors <sub>` | `list` / `add <id> "<comando>"` — vê/edita `wendkeep.sensors.json` (JSON Schema incluso). |
117
117
  | `wendkeep cost [opts]` | Agrega o gasto de IA nas sessões do cofre — total, por modelo, por dia · `--top [N]` · `--trend [day\|week\|month]` (+ projeção) · `--write` (gera `00-Custo.md`) · `--json`. |
118
118
  | `wendkeep cost rebuild [opts]` | Reconstrói custos históricos do transcript principal e subagents via `SESSION_REGISTRY`. Dry-run por padrão; `--apply` grava notas e `.brain/COST_REBUILD.json`. |
119
+ | `wendkeep session list\|show\|use` | Lista o registry multi-sessão, mostra uma conversa ou muda somente o foco humano de `CURRENT_SESSION.md`. |
120
+ | `wendkeep change bind <slug> --session <id>` | Vincula ou transfere uma change para uma conversa canônica sem esconder as demais pendências. |
121
+
122
+ As notas de sessão usam um único snapshot vivo `## Agentes, tokens e custos`. Os hooks do agente principal e dos subagents recompõem o bloco atomicamente, incluindo custo, dimensões de tokens, reasoning e effort por modelo/origem.
119
123
  | `wendkeep stats [--vault P]` | Uma linha compartilhável: sessões · prompts · gasto · período · modelos (`--json`). |
120
124
  | `wendkeep import [opts]` | **Memória retroativa** — importa sessões passadas de **Claude + Codex** pro cofre (dedup por `session_id`). `--source all\|claude\|codex` / `--from <dir>` / `--codex-from <dir>` / `--stamp-ids` / `--since d` / `--limit n` / `--dry-run` / `--json`. |
121
125
  | `wendkeep dashboard [--force]` | (Re)gera os Bases filtrados por pasta + o MOC `00-Dashboard`. |
package/bin/wendkeep.mjs CHANGED
@@ -43,9 +43,10 @@ Usage:
43
43
  agent's JSON on stdin. Names: ${RUNNABLE_HOOKS.join(', ')}.
44
44
 
45
45
  wendkeep doctor [--vault P] Run a vault health check.
46
- wendkeep change <sub> Change lifecycle: new [--simple] | use | continue | list | show |
46
+ wendkeep change <sub> Change lifecycle: new [--simple] | use | bind <slug> --session <id> | continue | list | show |
47
47
  status | done <id> | undone <id> | diff | archive [--force] | abandon.
48
48
  archive exige verdict (rode verify --deep); abandon descarta sem ADR.
49
+ wendkeep session <sub> Session registry: list | show <id> | use <id>.
49
50
  wendkeep spec <sub> Specs: list | show | effective [--change] [--json] | migrate | rebase.
50
51
  wendkeep sensors <sub> list | add <id> "<command>" [--severity --type --report].
51
52
  wendkeep cost [opts] Aggregate AI-coding spend across the vault's sessions.
@@ -132,6 +133,11 @@ async function main() {
132
133
  runChange(rest);
133
134
  break;
134
135
  }
136
+ case 'session': {
137
+ const { runSession } = await import('../src/session.mjs');
138
+ runSession(rest);
139
+ break;
140
+ }
135
141
  case 'verify': {
136
142
  const { runVerify } = await import('../src/verify.mjs');
137
143
  runVerify(rest);
@@ -9,7 +9,8 @@ import { getVaultBase, readHookInput, writeHookOutput } from './obsidian-common.
9
9
  import { brainDir } from './brain-core.mjs';
10
10
  import { buildActiveChangeInjection, changeCtxState, writeSentinel } from './change-core.mjs';
11
11
  import { buildLessonsInjection } from './lessons-core.mjs';
12
- import { getLocale } from './locale.mjs';
12
+ import { getLocale } from './locale.mjs';
13
+ import { resolveSessionEntry } from './session-identity.mjs';
13
14
 
14
15
  // The process ROUTER — the enforcement layer. The wk-* skills are passive files; without a
15
16
  // standing instruction the model plans in chat, leaves the change scaffold raw and forces the
@@ -42,7 +43,7 @@ function processRouter(localeId) {
42
43
 
43
44
  const MAX_LINES = 45; // CORE ≤25 + DIGEST ≤15 + folga; salvaguarda se o CORE crescer à mão
44
45
 
45
- export function buildInjection(vaultBase) {
46
+ export function buildInjection(vaultBase, input = {}) {
46
47
  const dir = brainDir(vaultBase);
47
48
  const read = (name) => {
48
49
  try { return readFileSync(join(dir, name), 'utf8').trim(); } catch { return ''; }
@@ -58,8 +59,12 @@ export function buildInjection(vaultBase) {
58
59
  const brain = ['<brain_memory>', ...lines, pointer, '</brain_memory>'].join('\n');
59
60
  const router = processRouter(getLocale(vaultBase).id);
60
61
  const change = buildActiveChangeInjection(vaultBase);
61
- const lessons = buildLessonsInjection(vaultBase);
62
- return [brain, router, change, lessons].filter(Boolean).join('\n');
62
+ const lessons = buildLessonsInjection(vaultBase);
63
+ const { identity, entry } = resolveSessionEntry(vaultBase, input);
64
+ const focus = identity.state === 'resolved' && entry?.change_slug
65
+ ? `<session_change>Change vinculada a esta sessão: ${entry.change_slug}. Este vínculo prevalece para writes automáticos; todas as pendências continuam visíveis acima.</session_change>`
66
+ : '';
67
+ return [brain, router, focus, change, lessons].filter(Boolean).join('\n');
63
68
  }
64
69
 
65
70
  if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
@@ -69,14 +74,16 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
69
74
  writeHookOutput({
70
75
  hookSpecificOutput: {
71
76
  hookEventName: 'SessionStart',
72
- additionalContext: buildInjection(vaultBase),
77
+ additionalContext: buildInjection(vaultBase, input),
73
78
  },
74
79
  });
75
80
  // Sentinela do change-context: o backlog completo acabou de ser injetado aqui, então o hook
76
81
  // UserPromptSubmit não precisa re-pingar no 1º prompt. Bônus — nunca derruba a injeção.
77
82
  try {
78
83
  const st = changeCtxState(vaultBase);
79
- if (st) writeSentinel(vaultBase, 'ctx', input.session_id || input.sessionId || '', st.hash);
84
+ const { identity } = resolveSessionEntry(vaultBase, input);
85
+ const sid = identity.state === 'resolved' ? identity.canonicalConversationId : (input.session_id || input.sessionId || '');
86
+ if (st) writeSentinel(vaultBase, 'ctx', sid, st.hash);
80
87
  } catch { /* sentinela é bônus */ }
81
88
  } catch (error) {
82
89
  process.stderr.write(`[brain] inject falhou: ${error.message}\n`);
@@ -8,6 +8,7 @@
8
8
  import { pathToFileURL } from 'node:url';
9
9
  import { getVaultBase, readHookInput, writeHookOutput } from './obsidian-common.mjs';
10
10
  import { changeCtxState, readSentinel, renderOpenChanges, writeSentinel } from './change-core.mjs';
11
+ import { resolveSessionEntry } from './session-identity.mjs';
11
12
 
12
13
  // Conservador de propósito: verbos de tarefa comuns (pt+en) + tamanho mínimo. Falso-negativo
13
14
  // custa só o nudge; falso-positivo em pergunta curta viraria ruído.
@@ -19,12 +20,13 @@ export function looksLikeTask(prompt) {
19
20
  }
20
21
 
21
22
  // Retorna { context, hash? } quando há algo a injetar; null = silêncio.
22
- export function buildChangePing(vaultBase, sessionId, prompt = '') {
23
+ export function buildChangePing(vaultBase, sessionId, prompt = '', changeSlug = '') {
23
24
  const st = changeCtxState(vaultBase);
24
25
  if (st) {
25
26
  if (readSentinel(vaultBase, 'ctx', sessionId) === st.hash) return null;
26
27
  writeSentinel(vaultBase, 'ctx', sessionId, st.hash);
27
- return { context: renderOpenChanges(st, { tag: 'open_changes_ping' }), hash: st.hash };
28
+ const focus = changeSlug ? `\n<session_change>Change vinculada a esta sessão: ${changeSlug}.</session_change>` : '';
29
+ return { context: `${renderOpenChanges(st, { tag: 'open_changes_ping' })}${focus}`, hash: st.hash };
28
30
  }
29
31
  // Sem changes abertas: gate de skill para prompt-tarefa, 1x por sessão.
30
32
  if (!looksLikeTask(prompt)) return null;
@@ -42,8 +44,10 @@ export function buildChangePing(vaultBase, sessionId, prompt = '') {
42
44
  if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
43
45
  try {
44
46
  const input = readHookInput();
45
- const sid = input.session_id || input.sessionId || '';
46
- const ping = buildChangePing(getVaultBase(input), sid, input.prompt || '');
47
+ const vaultBase = getVaultBase(input);
48
+ const { identity, entry } = resolveSessionEntry(vaultBase, input);
49
+ const sid = identity.state === 'resolved' ? identity.canonicalConversationId : (input.session_id || input.sessionId || '');
50
+ const ping = buildChangePing(vaultBase, sid, input.prompt || '', entry?.change_slug || '');
47
51
  if (!ping) { writeHookOutput({}); }
48
52
  else writeHookOutput({ hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: ping.context } });
49
53
  } catch {
@@ -12,6 +12,7 @@ import {
12
12
  wikilinkFromRel, readControl, toVaultRelative, getNextAdrNumber, derivedContentKey,
13
13
  } from './obsidian-common.mjs';
14
14
  import { getLocale } from './locale.mjs';
15
+ import { resolveSessionEntry } from './session-identity.mjs';
15
16
 
16
17
  // Decision notes follow the ADR naming convention: ADR-NNNN-<slug>, NNNN a 4-digit sequential
17
18
  // number assigned in the order decisions are made (getNextAdrNumber scans the whole 04-Decisões).
@@ -168,8 +169,9 @@ export function captureDecision(vaultBase, input) {
168
169
  const dateStr = formatDate(now);
169
170
  const provider = providerMeta(input.provider);
170
171
 
171
- const matched = input.transcript_path ? findActiveSessionByTranscript(vaultBase, input.transcript_path) : null;
172
- const sessionRel = matched?.session_file || readControl(vaultBase).session_file || '';
172
+ const { identity, entry } = resolveSessionEntry(vaultBase, input, provider.id);
173
+ if (identity.state !== 'resolved') return null;
174
+ const sessionRel = entry?.session_file || '';
173
175
 
174
176
  const dir = join(vaultBase, monthFolderRelFromDateStr(loc.folders.decisions, dateStr, vaultBase));
175
177
  ensureDir(dir);
@@ -16,8 +16,7 @@ import {
16
16
  } from './session-stop.mjs';
17
17
  import { buildSessionContent, allocateSessionPath } from './session-start.mjs';
18
18
  import { createLinkedNotes } from './linked-notes.mjs';
19
- import { updateSessionUsage } from './token-usage.mjs';
20
- import { upsertSubagentUsage } from './subagent-usage.mjs';
19
+ import { updateSessionObservability } from './session-observability.mjs';
21
20
  import { readSessionRegistry, upsertSessionRegistry, formatLocalIso, formatDate, providerMeta } from './obsidian-common.mjs';
22
21
  import { getLocale } from './locale.mjs';
23
22
  import { captureProseDecisions } from './decision-capture.mjs';
@@ -211,11 +210,8 @@ export function importSession(vaultBase, txPath, opts = {}) {
211
210
 
212
211
  // Cost + subagent telemetry, exactly like the live Stop hook. Fail-open.
213
212
  try {
214
- updateSessionUsage({ vaultBase, sessionRel: relPath, sessionPath: absPath, transcriptPath: txPath });
215
- } catch { /* usage is best-effort */ }
216
- try {
217
- upsertSubagentUsage(absPath, txPath);
218
- } catch { /* subagent telemetry is best-effort */ }
213
+ updateSessionObservability({ sessionPath: absPath, transcriptPath: txPath });
214
+ } catch { /* observability is best-effort */ }
219
215
 
220
216
  // Finalize: derived notes + closing section + ended_at from the last turn.
221
217
  const endedAt = formatLocalIso(endDate);
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from 'fs';
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'fs';
3
3
  import { basename, dirname, join, relative } from 'path';
4
4
  import { getLocale } from './locale.mjs';
5
5
 
@@ -189,7 +189,7 @@ export function readControl(vaultBase) {
189
189
  return data;
190
190
  }
191
191
 
192
- export function writeControl(vaultBase, data) {
192
+ export function writeControl(vaultBase, data) {
193
193
  const path = controlPath(vaultBase);
194
194
  ensureDir(dirname(path));
195
195
 
@@ -201,8 +201,16 @@ export function writeControl(vaultBase, data) {
201
201
  const sessionId = data.session_id || '';
202
202
  const lastLoggedTurnId = data.last_logged_turn_id || '';
203
203
 
204
- const content = `---
205
- status: "${status}"
204
+ const registry = readSessionRegistry(vaultBase);
205
+ const active = Object.entries(registry.sessions || {})
206
+ .filter(([, item]) => item?.status === 'active' && item.session_file)
207
+ .sort((a, b) => String(b[1].last_seen || b[1].updated_at || '').localeCompare(String(a[1].last_seen || a[1].updated_at || '')));
208
+ const activeRows = active.length
209
+ ? active.map(([id, item]) => `| ${id} | ${item.provider || 'unknown'} | ${item.session_file} | ${item.change_slug || '-'} | ${item.last_seen || item.updated_at || '-'} |`).join('\n')
210
+ : '| - | - | nenhuma | - | - |';
211
+
212
+ const content = `---
213
+ status: "${status}"
206
214
  session_file: "${sessionFile}"
207
215
  last_session_file: "${lastSessionFile}"
208
216
  started_at: "${startedAt}"
@@ -211,13 +219,21 @@ session_id: "${sessionId}"
211
219
  last_logged_turn_id: "${lastLoggedTurnId}"
212
220
  ---
213
221
 
214
- # CURRENT_SESSION
222
+ # CURRENT_SESSION
223
+
224
+ > Visão gerada pelo WendKeep. A autoridade de roteamento é .brain/SESSION_REGISTRY.json; hooks não usam este foco como fallback de escrita.
215
225
 
216
226
  - **Status:** ${status}
217
227
  - **Sessão ativa:** ${sessionFile || 'nenhuma'}
218
228
  - **Última sessão encerrada:** ${lastSessionFile || 'nenhuma'}
219
229
  - **Início:** ${startedAt || 'n/a'}
220
- - **Fim:** ${endedAt || 'n/a'}
230
+ - **Fim:** ${endedAt || 'n/a'}
231
+
232
+ ## Sessões ativas (${active.length})
233
+
234
+ | Conversa | Provider | Sessão | Change vinculada | Último sinal |
235
+ |---|---|---|---|---|
236
+ ${activeRows}
221
237
 
222
238
  Regra crítica: sempre anexar conteúdo à sessão ativa. Nunca sobrescrever o histórico de iterações.
223
239
  `;
@@ -225,22 +241,22 @@ Regra crítica: sempre anexar conteúdo à sessão ativa. Nunca sobrescrever o h
225
241
  writeFileSync(path, content, 'utf-8');
226
242
  }
227
243
 
228
- export function readSessionRegistry(vaultBase) {
244
+ export function readSessionRegistry(vaultBase) {
229
245
  const path = registryPath(vaultBase);
230
- if (!existsSync(path)) return { version: 1, sessions: {} };
246
+ if (!existsSync(path)) return { version: 2, sessions: {} };
231
247
 
232
248
  try {
233
249
  const parsed = JSON.parse(readFileSync(path, 'utf-8'));
234
250
  return {
235
- version: parsed.version || 1,
251
+ version: Math.max(2, parsed.version || 1),
236
252
  sessions: parsed.sessions && typeof parsed.sessions === 'object' ? parsed.sessions : {},
237
253
  };
238
254
  } catch {
239
- return { version: 1, sessions: {} };
255
+ return { version: 2, sessions: {} };
240
256
  }
241
257
  }
242
258
 
243
- export function writeSessionRegistry(vaultBase, registry) {
259
+ export function writeSessionRegistry(vaultBase, registry) {
244
260
  const path = registryPath(vaultBase);
245
261
  ensureDir(dirname(path));
246
262
  // Escrita atômica: grava em tmp e renomeia (rename é atômico no mesmo volume),
@@ -248,22 +264,86 @@ export function writeSessionRegistry(vaultBase, registry) {
248
264
  const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
249
265
  writeFileSync(tmp, `${JSON.stringify(registry, null, 2)}\n`, 'utf-8');
250
266
  renameSync(tmp, path);
251
- }
252
-
253
- export function upsertSessionRegistry(vaultBase, sessionId, patch) {
254
- if (!sessionId) return null;
255
-
256
- const registry = readSessionRegistry(vaultBase);
257
- const current = registry.sessions[sessionId] || {};
258
- const next = {
259
- ...current,
260
- ...patch,
261
- updated_at: patch.updated_at || formatLocalIso(new Date()),
262
- };
263
- registry.sessions[sessionId] = next;
264
- writeSessionRegistry(vaultBase, registry);
265
- return next;
266
- }
267
+ }
268
+
269
+ function registryLockPath(vaultBase) {
270
+ return `${registryPath(vaultBase)}.lock`;
271
+ }
272
+
273
+ function waitBriefly(ms) {
274
+ const signal = new Int32Array(new SharedArrayBuffer(4));
275
+ Atomics.wait(signal, 0, 0, ms);
276
+ }
277
+
278
+ export function mutateSessionRegistry(vaultBase, mutator, { timeoutMs = 2000 } = {}) {
279
+ const lock = registryLockPath(vaultBase);
280
+ ensureDir(dirname(lock));
281
+ const deadline = Date.now() + timeoutMs;
282
+ while (true) {
283
+ try {
284
+ mkdirSync(lock);
285
+ break;
286
+ } catch (error) {
287
+ if (error?.code === 'EEXIST') {
288
+ try {
289
+ if (Date.now() - statSync(lock).mtimeMs > 10_000) {
290
+ rmSync(lock, { recursive: true, force: true });
291
+ continue;
292
+ }
293
+ } catch { /* outro processo pode ter liberado o lock */ }
294
+ }
295
+ if (error?.code !== 'EEXIST' || Date.now() >= deadline) {
296
+ throw new Error(`SESSION_REGISTRY lock indisponível: ${error.message}`);
297
+ }
298
+ waitBriefly(10);
299
+ }
300
+ }
301
+
302
+ try {
303
+ const registry = readSessionRegistry(vaultBase);
304
+ registry.version = 2;
305
+ const result = mutator(registry);
306
+ writeSessionRegistry(vaultBase, registry);
307
+ return result;
308
+ } finally {
309
+ rmSync(lock, { recursive: true, force: true });
310
+ }
311
+ }
312
+
313
+ function meaningfulPatch(patch = {}) {
314
+ const protectedNonEmpty = new Set(['session_file', 'transcript_path', 'transcript_id', 'provider', 'started_at', 'change_slug']);
315
+ return Object.fromEntries(Object.entries(patch).filter(([key, value]) => {
316
+ if (value === undefined || value === null) return false;
317
+ if (value === '' && protectedNonEmpty.has(key)) return false;
318
+ return true;
319
+ }));
320
+ }
321
+
322
+ export function upsertSessionRegistry(vaultBase, sessionId, patch) {
323
+ if (!sessionId) return null;
324
+ const clean = meaningfulPatch(patch);
325
+ const next = mutateSessionRegistry(vaultBase, (registry) => {
326
+ const current = registry.sessions[sessionId] || {};
327
+ const transcriptPaths = [...new Set([
328
+ ...(Array.isArray(current.transcript_paths) ? current.transcript_paths : []),
329
+ current.transcript_path,
330
+ ...(Array.isArray(clean.transcript_paths) ? clean.transcript_paths : []),
331
+ clean.transcript_path,
332
+ ].filter(Boolean))];
333
+ const value = {
334
+ ...current,
335
+ ...clean,
336
+ ...(transcriptPaths.length ? { transcript_paths: transcriptPaths, transcript_path: clean.transcript_path || current.transcript_path || transcriptPaths.at(-1) } : {}),
337
+ last_seen: clean.last_seen || clean.updated_at || formatLocalIso(new Date()),
338
+ updated_at: clean.updated_at || formatLocalIso(new Date()),
339
+ };
340
+ registry.sessions[sessionId] = value;
341
+ return value;
342
+ });
343
+ const focus = readControl(vaultBase);
344
+ writeControl(vaultBase, focus);
345
+ return next;
346
+ }
267
347
 
268
348
  // Sessões sem evento de fim (janela fechada, crash, agente sem SessionEnd) ficam
269
349
  // `active` para sempre. Após este limite ocioso, considera-se a sessão encerrada.
@@ -316,11 +396,12 @@ export function pruneRegistry(registry, nowMs, { keepDone = REGISTRY_KEEP_DONE,
316
396
 
317
397
  // Wrapper de IO: varre as ociosas, poda o registry, grava e fecha a NOTA `.md` de cada
318
398
  // sessão encerrada (mantém vault e registry alinhados). Devolve quantas fechou.
319
- export function sweepStaleSessionsFile(vaultBase, now = new Date(), maxIdleMs = SESSION_IDLE_CLOSE_MS, excludeTranscriptPath = '') {
320
- const registry = readSessionRegistry(vaultBase);
321
- const closed = sweepStaleSessions(registry, now.getTime(), maxIdleMs, excludeTranscriptPath);
322
- const pruned = pruneRegistry(registry, now.getTime());
323
- if (closed.length || pruned) writeSessionRegistry(vaultBase, registry);
399
+ export function sweepStaleSessionsFile(vaultBase, now = new Date(), maxIdleMs = SESSION_IDLE_CLOSE_MS, excludeTranscriptPath = '') {
400
+ const closed = mutateSessionRegistry(vaultBase, (registry) => {
401
+ const result = sweepStaleSessions(registry, now.getTime(), maxIdleMs, excludeTranscriptPath);
402
+ pruneRegistry(registry, now.getTime());
403
+ return result;
404
+ });
324
405
  for (const { session_file, ended_at } of closed) {
325
406
  try { closeSessionNoteFile(vaultBase, session_file, ended_at); } catch { /* nunca derruba o sweep */ }
326
407
  }
@@ -512,13 +593,14 @@ export function transcriptsMatch(a, b) {
512
593
  // O `transcript_path` é estável dentro de uma conversa mesmo quando o
513
594
  // SessionStart re-dispara (compactação/resume) com `session_id` novo. Achar a
514
595
  // sessão ativa do mesmo transcript evita criar placeholders `HH-MM-codex`.
515
- export function findActiveSessionByTranscript(vaultBase, transcriptPath) {
596
+ export function findActiveSessionByTranscript(vaultBase, transcriptPath) {
516
597
  if (!transcriptPath) return null;
517
598
  const registry = readSessionRegistry(vaultBase);
518
599
  let best = null;
519
600
  for (const [sessionId, item] of Object.entries(registry.sessions || {})) {
520
601
  if (!item || item.status !== 'active' || !item.session_file) continue;
521
- if (!transcriptsMatch(item.transcript_path, transcriptPath)) continue;
602
+ const paths = [...(Array.isArray(item.transcript_paths) ? item.transcript_paths : []), item.transcript_path].filter(Boolean);
603
+ if (!paths.some((path) => transcriptsMatch(path, transcriptPath))) continue;
522
604
  if (!best || String(item.started_at || '') > String(best.started_at || '')) {
523
605
  best = { sessionId, session_file: item.session_file, started_at: item.started_at || '' };
524
606
  }
@@ -8,17 +8,17 @@ import { createHash } from 'node:crypto';
8
8
  import { join } from 'node:path';
9
9
  import { pathToFileURL } from 'node:url';
10
10
  import {
11
- findActiveSessionByTranscript,
12
11
  formatDate,
13
12
  getVaultBase,
14
- readControl,
15
13
  readHookInput,
16
14
  slugify,
15
+ upsertSessionRegistry,
17
16
  wikilinkFromRel,
18
17
  writeHookOutput,
19
18
  } from './obsidian-common.mjs';
20
19
  import { activeChange, newChange } from './change-core.mjs';
21
20
  import { getLocale } from './locale.mjs';
21
+ import { resolveSessionEntry } from './session-identity.mjs';
22
22
 
23
23
  // O plano aprovado chega por um de três canais, conforme a versão do Claude Code:
24
24
  // tool_input.plan (legado), o bloco "## Approved Plan" do tool_response, ou o arquivo de plano
@@ -94,15 +94,15 @@ export function capturePlan(vaultBase, input) {
94
94
  const en = loc.id === 'en';
95
95
  const dateStr = formatDate(new Date());
96
96
  const rawNote = `---\ntype: plan\ndate: ${dateStr}\ntags:\n - plano\n---\n\n${plan.trim()}\n`;
97
- const transcriptPath = input?.transcript_path ?? input?.transcriptPath ?? '';
98
- const sessionRel = findActiveSessionByTranscript(vaultBase, transcriptPath)?.session_file
99
- || readControl(vaultBase).session_file
100
- || '';
97
+ const { identity, entry } = resolveSessionEntry(vaultBase, input, 'claude');
98
+ if (identity.state !== 'resolved') return null;
99
+ const sessionRel = entry?.session_file || '';
101
100
 
102
- const active = activeChange(vaultBase);
101
+ const active = entry?.change_slug || activeChange(vaultBase);
103
102
  if (active) {
104
103
  const dir = join(vaultBase, loc.folders.changes, active);
105
104
  persistApprovedPlan(dir, active, rawNote, plan, dateStr);
105
+ upsertSessionRegistry(vaultBase, identity.canonicalConversationId, { change_slug: active });
106
106
  return {
107
107
  slug: active, created: false,
108
108
  context: `<plan_captured>\n${en
@@ -113,6 +113,7 @@ export function capturePlan(vaultBase, input) {
113
113
 
114
114
  const slug = planSlug(plan);
115
115
  newChange(vaultBase, slug, { dateStr, sessionRel });
116
+ upsertSessionRegistry(vaultBase, identity.canonicalConversationId, { change_slug: slug });
116
117
  const dir = join(vaultBase, loc.folders.changes, slug);
117
118
  const ctx = sectionBody(plan, ['Contexto', 'Context']) || plan.trim().split('\n').slice(0, 6).join('\n');
118
119
  const source = sessionRel ? `\n - "${wikilinkFromRel(sessionRel)}"` : ' []';
@@ -29,7 +29,8 @@ import {
29
29
  writeControl,
30
30
  writeHookOutput,
31
31
  yamlQuote,
32
- } from './obsidian-common.mjs';
32
+ } from './obsidian-common.mjs';
33
+ import { resolveSessionIdentity } from './session-identity.mjs';
33
34
 
34
35
  function sessionIdFromInput(input) {
35
36
  return input.session_id || input.sessionId || input.codex_session_id || '';
@@ -239,7 +240,7 @@ function findSessionForInput(vaultBase, input, control) {
239
240
  return { sessionId, relPath: '', startedAt: '', fromRegistry: false };
240
241
  }
241
242
 
242
- function activateExistingSession({ vaultBase, relPath, startedAt, sessionId, input, now }) {
243
+ function activateExistingSession({ vaultBase, relPath, startedAt, sessionId, input, now, identity }) {
243
244
  const sessionPath = join(vaultBase, relPath);
244
245
  if (!existsSync(sessionPath)) return false;
245
246
 
@@ -259,12 +260,14 @@ function activateExistingSession({ vaultBase, relPath, startedAt, sessionId, inp
259
260
  status: 'active',
260
261
  started_at: nextStartedAt,
261
262
  ended_at: '',
262
- transcript_path: input.transcript_path || input.transcriptPath || '',
263
+ transcript_path: identity.transcriptPath,
264
+ transcript_id: identity.transcriptId,
265
+ provider: identity.provider,
263
266
  });
264
267
  return true;
265
268
  }
266
269
 
267
- function createSession({ vaultBase, sessionId, input, now }) {
270
+ function createSession({ vaultBase, sessionId, input, now, identity }) {
268
271
  const summary = sessionSummaryFromInput(input);
269
272
  const { absPath, relPath } = allocateSessionPath(vaultBase, now, summary);
270
273
  const startedAt = formatLocalIso(now);
@@ -283,7 +286,9 @@ function createSession({ vaultBase, sessionId, input, now }) {
283
286
  status: 'active',
284
287
  started_at: startedAt,
285
288
  ended_at: '',
286
- transcript_path: input.transcript_path || input.transcriptPath || '',
289
+ transcript_path: identity.transcriptPath,
290
+ transcript_id: identity.transcriptId,
291
+ provider: identity.provider,
287
292
  });
288
293
  return { relPath, startedAt };
289
294
  }
@@ -303,7 +308,18 @@ function main() {
303
308
  const vaultBase = getVaultBase(input);
304
309
  warnIfDefaultVault(input);
305
310
  const now = new Date();
306
- const sessionId = sessionIdFromInput(input);
311
+ const identity = resolveSessionIdentity(vaultBase, input, providerMeta().id);
312
+ if (identity.state !== 'resolved') {
313
+ writeHookOutput({
314
+ hookSpecificOutput: {
315
+ hookEventName: 'UserPromptSubmit',
316
+ additionalContext: `<obsidian_session_deferred>Memória global disponível, sem escrita vinculada: ${identity.diagnostics.join('; ')}.</obsidian_session_deferred>`,
317
+ },
318
+ systemMessage: `[wendkeep] Identidade de sessão adiada: ${identity.diagnostics.join('; ')}`,
319
+ });
320
+ return;
321
+ }
322
+ const sessionId = identity.canonicalConversationId;
307
323
 
308
324
  // Fast path: skip all writes if control file touched < 5 min ago and session matches
309
325
  try {
@@ -316,7 +332,7 @@ function main() {
316
332
  ctrl.session_file &&
317
333
  !isPlaceholderSessionFile(ctrl.session_file) &&
318
334
  existsSync(join(vaultBase, ctrl.session_file)) &&
319
- (!sessionId || ctrl.session_id === sessionId)
335
+ ctrl.session_id === sessionId
320
336
  ) {
321
337
  writeHookOutput({});
322
338
  return;
@@ -333,7 +349,7 @@ function main() {
333
349
  // Sem reuso-por-janela: só reaproveita a nota do control quando não há
334
350
  // identidade pra checar (!sessionId) ou quando é a MESMA conversa. Conversa
335
351
  // concorrente recente NÃO pode herdar a nota ativa do ponteiro global.
336
- if (existsSync(activePath) && (!sessionId || control.session_id === sessionId)) {
352
+ if (existsSync(activePath) && control.session_id === sessionId) {
337
353
  const titled = maybeRetitleSession({
338
354
  vaultBase,
339
355
  relPath: control.session_file,
@@ -357,25 +373,30 @@ function main() {
357
373
  status: 'active',
358
374
  started_at: control.started_at,
359
375
  ended_at: '',
360
- transcript_path: input.transcript_path || input.transcriptPath || '',
376
+ transcript_path: identity.transcriptPath,
377
+ transcript_id: identity.transcriptId,
378
+ provider: identity.provider,
361
379
  });
362
380
  writeHookOutput({});
363
381
  return;
364
382
  }
365
383
  }
366
384
 
367
- const target = findSessionForInput(vaultBase, input, control);
368
- if (target.relPath && activateExistingSession({ vaultBase, relPath: target.relPath, startedAt: target.startedAt, sessionId: target.sessionId, input, now })) {
385
+ const registered = readSessionRegistry(vaultBase).sessions?.[sessionId];
386
+ const resolvedTarget = registered?.session_file
387
+ ? { sessionId, relPath: registered.session_file, startedAt: registered.started_at || '' }
388
+ : { sessionId, relPath: '', startedAt: '' };
389
+ if (resolvedTarget.relPath && activateExistingSession({ vaultBase, relPath: resolvedTarget.relPath, startedAt: resolvedTarget.startedAt, sessionId, input, now, identity })) {
369
390
  outputActiveContext({
370
- relPath: target.relPath,
371
- startedAt: target.startedAt || formatLocalIso(now),
391
+ relPath: resolvedTarget.relPath,
392
+ startedAt: resolvedTarget.startedAt || formatLocalIso(now),
372
393
  vaultBase,
373
- message: `Sessão Obsidian reaberta em ${target.relPath}. ${basename(controlPath(vaultBase))} atualizado.`,
394
+ message: `Sessão Obsidian reaberta em ${resolvedTarget.relPath}. ${basename(controlPath(vaultBase))} atualizado.`,
374
395
  });
375
396
  return;
376
397
  }
377
398
 
378
- const created = createSession({ vaultBase, sessionId, input, now });
399
+ const created = createSession({ vaultBase, sessionId, input, now, identity });
379
400
  outputActiveContext({
380
401
  relPath: created.relPath,
381
402
  startedAt: created.startedAt,