wendkeep 0.37.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,20 @@ 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
+
7
21
  ## [0.37.0] — 2026-07-11
8
22
 
9
23
  ### Added
package/README.md CHANGED
@@ -119,6 +119,8 @@ 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. |
122
124
 
123
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.
124
126
  | `wendkeep lesson add "t" "l"` | Record a project-local lesson (injected at the next SessionStart). |
package/README.pt-BR.md CHANGED
@@ -116,6 +116,8 @@ 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. |
119
121
 
120
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.
121
123
  | `wendkeep stats [--vault P]` | Uma linha compartilhável: sessões · prompts · gasto · período · modelos (`--json`). |
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);
@@ -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,
@@ -0,0 +1,75 @@
1
+ import { existsSync, readFileSync } from 'fs';
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
+ }
11
+
12
+ 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
+ export function resolveSessionIdentity(vaultBase, input = {}, provider = detectProvider()) {
43
+ const transcriptPath = input.transcript_path || input.transcriptPath || '';
44
+ const inspected = inspectTranscriptIdentity(transcriptPath);
45
+ const hookId = input.session_id || input.sessionId || '';
46
+ if (!transcriptPath || !inspected.canonicalConversationId) {
47
+ return { state: 'deferred', provider, transcriptPath, diagnostics: ['transcript ausente ou sem identidade canônica'] };
48
+ }
49
+ if (!compatible(provider, inspected.transcriptProvider)) {
50
+ return { state: 'deferred', provider, transcriptPath, diagnostics: [`provider ${provider} incompatível com transcript ${inspected.transcriptProvider}`] };
51
+ }
52
+
53
+ const registry = readSessionRegistry(vaultBase);
54
+ const byTranscript = Object.entries(registry.sessions || {}).find(([, entry]) => {
55
+ const paths = [...(Array.isArray(entry?.transcript_paths) ? entry.transcript_paths : []), entry?.transcript_path].filter(Boolean);
56
+ return paths.some((path) => transcriptsMatch(path, transcriptPath));
57
+ });
58
+ const canonicalConversationId = byTranscript?.[0] || inspected.canonicalConversationId;
59
+ return {
60
+ state: 'resolved',
61
+ provider,
62
+ canonicalConversationId,
63
+ hookSessionId: hookId,
64
+ transcriptPath,
65
+ transcriptId: inspected.transcriptId,
66
+ parentConversationId: inspected.parentConversationId,
67
+ diagnostics: [],
68
+ };
69
+ }
70
+
71
+ export function resolveSessionEntry(vaultBase, input = {}, provider = detectProvider()) {
72
+ const identity = resolveSessionIdentity(vaultBase, input, provider);
73
+ if (identity.state !== 'resolved') return { identity, entry: null };
74
+ return { identity, entry: readSessionRegistry(vaultBase).sessions?.[identity.canonicalConversationId] || null };
75
+ }
@@ -2,6 +2,7 @@
2
2
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
3
3
  import { collectSessionUsage } from './token-usage.mjs';
4
4
  import { collectSubagentUsage, sessionDirFromTranscript } from './subagent-usage.mjs';
5
+ import { inspectTranscriptIdentity } from './session-identity.mjs';
5
6
 
6
7
  const HEADING = '## Agentes, tokens e custos';
7
8
  const LEGACY_HEADINGS = ['## Uso de tokens e custos', '## Subagents & Workflows'];
@@ -153,9 +154,22 @@ export function buildSessionObservability({ sessionContent, transcriptPath }) {
153
154
  return { snapshot, content: upsertObservabilitySection(content, renderSessionObservability(snapshot)) };
154
155
  }
155
156
 
156
- export function updateSessionObservability({ sessionPath, transcriptPath }) {
157
+ export function updateSessionObservability({ sessionPath, transcriptPath, caller = 'unknown', canonicalConversationId = '' }) {
157
158
  if (!sessionPath || !existsSync(sessionPath)) return null;
158
- const result = buildSessionObservability({ sessionContent: readFileSync(sessionPath, 'utf8'), transcriptPath });
159
+ const sessionContent = readFileSync(sessionPath, 'utf8');
160
+ const noteProvider = sessionContent.match(/^provider:\s*"?([^"\n]+)"?/m)?.[1]?.trim() || 'unknown';
161
+ const identity = inspectTranscriptIdentity(transcriptPath);
162
+ if ((noteProvider === 'codex' && identity.transcriptProvider !== 'openai')
163
+ || (noteProvider === 'claude' && identity.transcriptProvider !== 'anthropic')) {
164
+ throw new Error(`observability provider mismatch: note=${noteProvider}, transcript=${identity.transcriptProvider}`);
165
+ }
166
+ let annotated = setFrontmatterField(sessionContent, 'observability_caller', `"${caller}"`);
167
+ annotated = setFrontmatterField(annotated, 'observability_session_id', `"${canonicalConversationId || identity.canonicalConversationId || ''}"`);
168
+ annotated = setFrontmatterField(annotated, 'observability_transcript_id', `"${identity.transcriptId || ''}"`);
169
+ if (!/^observability_updated_at:/m.test(annotated)) {
170
+ annotated = setFrontmatterField(annotated, 'observability_updated_at', `"${new Date().toISOString()}"`);
171
+ }
172
+ const result = buildSessionObservability({ sessionContent: annotated, transcriptPath });
159
173
  if (!result) return null;
160
174
  writeFileSync(sessionPath, result.content, 'utf8');
161
175
  return result.snapshot;
@@ -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
  export function buildSessionContent({ relPath, now, summary = 'session', provider: providerId, sessionId = '' }) {
35
36
  const date = formatDate(now);
@@ -150,16 +151,29 @@ function buildAdditionalContext({ relPath, startedAt, vaultBase }) {
150
151
 
151
152
  function main() {
152
153
  const input = readHookInput();
153
- const vaultBase = getVaultBase(input);
154
- warnIfDefaultVault(input);
155
- const now = new Date();
156
- const sessionId = input.session_id || input.sessionId || '';
157
- const control = readControl(vaultBase);
154
+ const vaultBase = getVaultBase(input);
155
+ warnIfDefaultVault(input);
156
+ const now = new Date();
157
+ const provider = providerMeta();
158
+ const identity = resolveSessionIdentity(vaultBase, input, provider.id);
159
+ if (identity.state !== 'resolved') {
160
+ writeHookOutput({
161
+ hookSpecificOutput: {
162
+ hookEventName: 'SessionStart',
163
+ additionalContext: `<obsidian_session_deferred>Memória global disponível, mas nenhuma escrita de sessão foi feita: ${identity.diagnostics.join('; ')}.</obsidian_session_deferred>`,
164
+ },
165
+ systemMessage: `[wendkeep] Identidade de sessão adiada: ${identity.diagnostics.join('; ')}`,
166
+ });
167
+ return;
168
+ }
169
+ const sessionId = identity.canonicalConversationId;
170
+ const transcriptPath = identity.transcriptPath;
171
+ const control = readControl(vaultBase);
158
172
 
159
173
  // Fecha sessões `active` órfãs (sem evento de fim — janela fechada/crash) antes
160
174
  // de seguir. Preserva a deste transcript: pode ser reaproveitada logo abaixo.
161
175
  try {
162
- sweepStaleSessionsFile(vaultBase, now, undefined, input.transcript_path || input.transcriptPath || '');
176
+ sweepStaleSessionsFile(vaultBase, now, undefined, transcriptPath);
163
177
  } catch (error) {
164
178
  process.stderr.write(`[wendkeep] sweep de sessões falhou: ${error.message}\n`);
165
179
  }
@@ -172,12 +186,15 @@ function main() {
172
186
  if (control.status === 'active' && control.session_file && control.session_id === sessionId) {
173
187
  const activePath = join(vaultBase, control.session_file);
174
188
  if (existsSync(activePath)) {
175
- upsertSessionRegistry(vaultBase, sessionId, {
189
+ upsertSessionRegistry(vaultBase, sessionId, {
176
190
  session_file: control.session_file,
177
191
  status: 'active',
178
192
  started_at: control.started_at,
179
- ended_at: '',
180
- });
193
+ ended_at: '',
194
+ provider: provider.id,
195
+ transcript_path: transcriptPath,
196
+ transcript_id: identity.transcriptId,
197
+ });
181
198
  writeHookOutput({
182
199
  hookSpecificOutput: {
183
200
  hookEventName: 'SessionStart',
@@ -220,7 +237,9 @@ function main() {
220
237
  status: 'active',
221
238
  started_at: startedAt,
222
239
  ended_at: '',
223
- transcript_path: input.transcript_path || input.transcriptPath || known.transcript_path || '',
240
+ transcript_path: transcriptPath || known.transcript_path || '',
241
+ transcript_id: identity.transcriptId,
242
+ provider: provider.id,
224
243
  });
225
244
  writeHookOutput({
226
245
  hookSpecificOutput: {
@@ -235,8 +254,7 @@ function main() {
235
254
  // Re-init da conversa (compactação/resume) traz um session_id novo e cai fora
236
255
  // da janela de reuso; o transcript continua o mesmo. Reaproveita a sessão ativa
237
256
  // desse transcript em vez de criar um placeholder `HH-MM-codex`.
238
- const transcriptPath = input.transcript_path || input.transcriptPath || '';
239
- if (transcriptPath) {
257
+ if (transcriptPath) {
240
258
  const match = findActiveSessionByTranscript(vaultBase, transcriptPath);
241
259
  if (match) {
242
260
  // fail-safe: a nota do registro pode ter sumido do disco (git stash/checkout/
@@ -262,7 +280,9 @@ function main() {
262
280
  status: 'active',
263
281
  started_at: startedAt,
264
282
  ended_at: '',
265
- transcript_path: transcriptPath,
283
+ transcript_path: transcriptPath,
284
+ transcript_id: identity.transcriptId,
285
+ provider: provider.id,
266
286
  });
267
287
  writeHookOutput({
268
288
  hookSpecificOutput: {
@@ -277,7 +297,7 @@ function main() {
277
297
  const summary = sessionSummaryFromInput(input);
278
298
  const { absPath, relPath } = allocateSessionPath(vaultBase, now, summary);
279
299
  const startedAt = formatLocalIso(now);
280
- writeFileSync(absPath, buildSessionContent({ relPath, now, summary, sessionId }), 'utf-8');
300
+ writeFileSync(absPath, buildSessionContent({ relPath, now, summary, sessionId, provider: provider.id }), 'utf-8');
281
301
  writeControl(vaultBase, {
282
302
  status: 'active',
283
303
  session_file: relPath,
@@ -289,8 +309,11 @@ function main() {
289
309
  session_file: relPath,
290
310
  status: 'active',
291
311
  started_at: startedAt,
292
- ended_at: '',
293
- });
312
+ ended_at: '',
313
+ provider: provider.id,
314
+ transcript_path: transcriptPath,
315
+ transcript_id: identity.transcriptId,
316
+ });
294
317
 
295
318
  writeHookOutput({
296
319
  hookSpecificOutput: {
@@ -298,7 +321,7 @@ function main() {
298
321
  additionalContext: buildAdditionalContext({ relPath, startedAt, vaultBase }),
299
322
  },
300
323
  systemMessage: [
301
- `Sessão ${providerMeta().label} criada em ${relPath}.`,
324
+ `Sessão ${provider.label} criada em ${relPath}.`,
302
325
  `${basename(controlPath(vaultBase))} atualizado.`,
303
326
  'Iterações devem ser anexadas, nunca sobrescritas.',
304
327
  ].join(' '),
@@ -9,6 +9,7 @@ import { buildBrainDigest, buildBrainIndex } from './brain-core.mjs';
9
9
  import { activeChangeLink, pruneChangeSentinels } from './change-core.mjs';
10
10
  import { getLocale } from './locale.mjs';
11
11
  import { updateSessionObservability } from './session-observability.mjs';
12
+ import { resolveSessionEntry } from './session-identity.mjs';
12
13
  import {
13
14
  ensureDir,
14
15
  findActiveSessionByTranscript,
@@ -1022,15 +1023,20 @@ function main() {
1022
1023
 
1023
1024
  const vaultBase = getVaultBase(input);
1024
1025
  warnIfDefaultVault(input);
1025
- const control = readControl(vaultBase);
1026
- const transcriptPath = input.transcript_path || input.transcriptPath || '';
1026
+ const control = readControl(vaultBase);
1027
+ const transcriptPath = input.transcript_path || input.transcriptPath || '';
1028
+ const { identity, entry } = resolveSessionEntry(vaultBase, input);
1029
+ if (identity.state !== 'resolved' || !entry?.session_file) {
1030
+ process.stderr.write(`[wendkeep] Stop sem identidade segura: ${identity.diagnostics?.join('; ') || 'sessão não registrada'}\n`);
1031
+ writeHookOutput({});
1032
+ return;
1033
+ }
1027
1034
 
1028
1035
  // Roteia o turn pela sessão DO PRÓPRIO transcript (registry), não pelo
1029
1036
  // CURRENT_SESSION global — que sessões concorrentes sobrescrevem, fazendo
1030
1037
  // o turn cair na nota de outra conversa. Sem match por transcript NÃO caímos
1031
1038
  // no global (contaminaria nota alheia): pulamos e o backfill recupera depois.
1032
- const matched = transcriptPath ? findActiveSessionByTranscript(vaultBase, transcriptPath) : null;
1033
- const sessionRel = matched?.session_file || '';
1039
+ const sessionRel = entry.session_file;
1034
1040
  if (!sessionRel) {
1035
1041
  writeHookOutput({});
1036
1042
  return;
@@ -1044,7 +1050,7 @@ function main() {
1044
1050
 
1045
1051
  const tx = parseTranscript(input.transcript_path || input.transcriptPath);
1046
1052
  const turnId = input.turn_id || tx.latestTurnId || String(Date.now());
1047
- const sessionId = matched?.sessionId || control.session_id || tx.sessionId;
1053
+ const sessionId = identity.canonicalConversationId;
1048
1054
  const logged = insertIteration(sessionPath, buildIterationBlock(tx, input), turnId, tx);
1049
1055
 
1050
1056
  try {
@@ -1054,7 +1060,7 @@ function main() {
1054
1060
  }
1055
1061
 
1056
1062
  try {
1057
- updateSessionObservability({ sessionPath, transcriptPath });
1063
+ updateSessionObservability({ sessionPath, transcriptPath, caller: 'stop', canonicalConversationId: sessionId });
1058
1064
  } catch (error) {
1059
1065
  process.stderr.write(`[wendkeep] Token usage falhou: ${error.message}\n`);
1060
1066
  }
@@ -1076,7 +1082,9 @@ function main() {
1076
1082
  // started_at de sessões concorrentes que sobrescrevem o ponteiro global.
1077
1083
  ended_at: '',
1078
1084
  last_turn_id: logged ? turnId : control.last_logged_turn_id,
1079
- transcript_path: transcriptPath,
1085
+ transcript_path: transcriptPath,
1086
+ transcript_id: identity.transcriptId,
1087
+ provider: identity.provider,
1080
1088
  });
1081
1089
  pingObsidianVault(input.obsidian_api_key);
1082
1090
  writeHookOutput({});
@@ -1095,7 +1103,9 @@ function main() {
1095
1103
  // grafo quando a change fechava antes do turno seguinte. Aqui sobrevive ao reopen e acumula toda
1096
1104
  // change que passou pela sessão (upsertListSection deduplica). Fail-quiet: nunca derruba o Stop.
1097
1105
  try {
1098
- const chgLink = activeChangeLink(vaultBase);
1106
+ const chgLink = entry.change_slug
1107
+ ? `Change ativa: [[${getLocale(vaultBase).folders.changes}/${entry.change_slug}/proposta]]`
1108
+ : activeChangeLink(vaultBase);
1099
1109
  const wl = (chgLink.match(/\[\[[^\]]+\]\]/) || [])[0];
1100
1110
  if (wl) {
1101
1111
  let cur = readFileSync(sessionPath, 'utf8');
@@ -1119,7 +1129,9 @@ function main() {
1119
1129
  // started_at omitido: preserva o da própria entry (ver branch acima).
1120
1130
  ended_at: endedAt,
1121
1131
  last_turn_id: turnId,
1122
- transcript_path: transcriptPath,
1132
+ transcript_path: transcriptPath,
1133
+ transcript_id: identity.transcriptId,
1134
+ provider: identity.provider,
1123
1135
  });
1124
1136
 
1125
1137
  // Reconstrói índice (camada fria) + digest (camada quente) ao finalizar. Nunca derruba o Stop.
@@ -8,17 +8,19 @@
8
8
  import { existsSync } from 'fs';
9
9
  import { join } from 'path';
10
10
  import { pathToFileURL } from 'url';
11
- import { readHookInput, writeHookOutput, getVaultBase, findActiveSessionByTranscript, readControl } from './obsidian-common.mjs';
11
+ import { readHookInput, writeHookOutput, getVaultBase } from './obsidian-common.mjs';
12
12
  import { updateSessionObservability } from './session-observability.mjs';
13
+ import { resolveSessionEntry } from './session-identity.mjs';
13
14
 
14
15
  export function refreshSubagents(vaultBase, input) {
15
- const transcriptPath = input.transcript_path || input.transcriptPath || '';
16
- const matched = transcriptPath ? findActiveSessionByTranscript(vaultBase, transcriptPath) : null;
17
- const sessionRel = matched?.session_file || readControl(vaultBase).session_file || '';
16
+ const { identity, entry } = resolveSessionEntry(vaultBase, input);
17
+ if (identity.state !== 'resolved') return false;
18
+ const transcriptPath = identity.transcriptPath;
19
+ const sessionRel = entry?.session_file || '';
18
20
  if (!sessionRel) return false;
19
21
  const sessionPath = join(vaultBase, sessionRel);
20
22
  if (!existsSync(sessionPath)) return false;
21
- updateSessionObservability({ sessionPath, transcriptPath });
23
+ updateSessionObservability({ sessionPath, transcriptPath, caller: 'subagent-stop', canonicalConversationId: identity.canonicalConversationId });
22
24
  return true;
23
25
  }
24
26
 
@@ -8,8 +8,9 @@
8
8
  import { existsSync, readFileSync, writeFileSync } from 'fs';
9
9
  import { join } from 'path';
10
10
  import { pathToFileURL } from 'url';
11
- import { readHookInput, writeHookOutput, getVaultBase, findActiveSessionByTranscript, readControl, formatHourMinute } from './obsidian-common.mjs';
11
+ import { readHookInput, writeHookOutput, getVaultBase, formatHourMinute } from './obsidian-common.mjs';
12
12
  import { getLocale } from './locale.mjs';
13
+ import { resolveSessionEntry } from './session-identity.mjs';
13
14
 
14
15
  // Pull the task's human text from whatever field the payload carries.
15
16
  export function taskText(input) {
@@ -41,9 +42,9 @@ export function appendProgress(content, line, heading) {
41
42
  export function logTask(vaultBase, input) {
42
43
  const text = taskText(input);
43
44
  if (!text) return false;
44
- const transcriptPath = input.transcript_path || input.transcriptPath || '';
45
- const matched = transcriptPath ? findActiveSessionByTranscript(vaultBase, transcriptPath) : null;
46
- const sessionRel = matched?.session_file || readControl(vaultBase).session_file || '';
45
+ const { identity, entry } = resolveSessionEntry(vaultBase, input);
46
+ if (identity.state !== 'resolved') return false;
47
+ const sessionRel = entry?.session_file || '';
47
48
  if (!sessionRel) return false;
48
49
  const sessionPath = join(vaultBase, sessionRel);
49
50
  if (!existsSync(sessionPath)) return false;
@@ -366,7 +366,7 @@ function parseCodexLines(lines, result) {
366
366
  const payload = event.payload || {};
367
367
 
368
368
  if (event.type === 'session_meta') {
369
- result.sessionId = payload.id || result.sessionId;
369
+ result.sessionId = payload.session_id || payload.id || result.sessionId;
370
370
  currentProvider = normalizeProvider(payload.model_provider || currentProvider);
371
371
  currentModel = normalizeModelName(payload.model || currentModel);
372
372
  result.provider = currentProvider;
@@ -118,13 +118,11 @@ function checkSession({ vaultBase, sessionRel, control, registry }) {
118
118
  }
119
119
  if (hasDefaultPending(content)) warnings.push('Pendências ainda contém placeholders padrão.');
120
120
 
121
- const registryEntry = registry.sessions?.[control.session_id];
122
- if (control.session_id && !registryEntry) {
123
- failures.push(`SESSION_REGISTRY não possui session_id ativo: ${control.session_id}`);
124
- } else if (registryEntry) {
125
- if (registryEntry.session_file !== sessionRel) {
126
- failures.push('SESSION_REGISTRY diverge do CURRENT_SESSION.md para a sessão ativa.');
127
- }
121
+ const registryPair = Object.entries(registry.sessions || {}).find(([, entry]) => entry?.session_file === sessionRel);
122
+ const registryEntry = registryPair?.[1];
123
+ if (!registryEntry) {
124
+ failures.push(`SESSION_REGISTRY não possui a sessão: ${sessionRel}`);
125
+ } else {
128
126
  if (!registryEntry.transcript_path) {
129
127
  warnings.push('SESSION_REGISTRY não possui transcript_path para a sessão ativa.');
130
128
  } else if (!existsSync(registryEntry.transcript_path)) {
@@ -166,10 +164,15 @@ export function runVaultHealth({ vaultBase, session = '' }) {
166
164
  failures.push(...sessionResult.failures);
167
165
  warnings.push(...sessionResult.warnings);
168
166
 
169
- const staleDone = Object.values(registry.sessions || {})
167
+ const staleDone = Object.values(registry.sessions || {})
170
168
  .filter((item) => item.status === 'active' && item.ended_at)
171
169
  .length;
172
- if (staleDone) warnings.push(`${staleDone} entradas active com ended_at no SESSION_REGISTRY.`);
170
+ if (staleDone) warnings.push(`${staleDone} entradas active com ended_at no SESSION_REGISTRY.`);
171
+ const activeEntries = Object.values(registry.sessions || {}).filter((item) => item?.status === 'active');
172
+ for (const entry of activeEntries) {
173
+ if (!entry.session_file) failures.push('SESSION_REGISTRY possui sessão ativa sem session_file.');
174
+ if (!entry.transcript_path) warnings.push(`Sessão ativa sem transcript_path: ${entry.session_file || '(sem arquivo)'}`);
175
+ }
173
176
 
174
177
  const locF = getLocale(vaultBase).folders;
175
178
  const derivedFolders = [locF.decisions, locF.bugs, locF.learnings];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wendkeep",
3
- "version": "0.37.0",
3
+ "version": "0.38.0",
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/change.mjs CHANGED
@@ -18,7 +18,7 @@ import {
18
18
  } from '../hooks/change-core.mjs';
19
19
  import { evaluateGate, requiredSensors } from '../hooks/sensors-core.mjs';
20
20
  import { buildEffectiveRequirementPackage, evaluateVerdict, tasksHashOf, parseSpecsList, parseDelta, parseRequirements, applyDelta, validateSpecImpact } from '../hooks/spec-core.mjs';
21
- import { getNextAdrNumber, readControl } from '../hooks/obsidian-common.mjs';
21
+ import { getNextAdrNumber, readControl, readSessionRegistry, upsertSessionRegistry } from '../hooks/obsidian-common.mjs';
22
22
  import { getLocale } from '../hooks/locale.mjs';
23
23
 
24
24
  function resolveVault(argv) {
@@ -51,7 +51,7 @@ function today() {
51
51
  export function runChange(argv) {
52
52
  const [sub, ...rest] = argv;
53
53
  const vaultBase = resolveVault(rest);
54
- const VALUE_FLAGS = new Set(['--vault', '--change', '--project']);
54
+ const VALUE_FLAGS = new Set(['--vault', '--change', '--project', '--session']);
55
55
  const slugArg = () => rest.find((a, i) => !a.startsWith('-') && !VALUE_FLAGS.has(rest[i - 1]));
56
56
 
57
57
  if (sub === 'new') {
@@ -74,6 +74,18 @@ export function runChange(argv) {
74
74
  process.exit(0);
75
75
  }
76
76
 
77
+ if (sub === 'bind') {
78
+ const slug = slugArg();
79
+ const sessionId = opt(rest, '--session');
80
+ if (!slug || !sessionId) { process.stderr.write('wendkeep change bind: use <slug> --session <id>\n'); process.exit(2); }
81
+ const state = allChangesState(vaultBase);
82
+ if (!state.changes.some((item) => item.slug === slug)) { process.stderr.write(`wendkeep change bind: open change not found: ${slug}\n`); process.exit(2); }
83
+ if (!readSessionRegistry(vaultBase).sessions?.[sessionId]) { process.stderr.write(`wendkeep change bind: session not found: ${sessionId}\n`); process.exit(2); }
84
+ upsertSessionRegistry(vaultBase, sessionId, { change_slug: slug });
85
+ process.stdout.write(`session ${sessionId} -> change ${slug}\n`);
86
+ process.exit(0);
87
+ }
88
+
77
89
  if (sub === 'continue') {
78
90
  const positionals = rest.filter((a, i) => !a.startsWith('-') && !VALUE_FLAGS.has(rest[i - 1]));
79
91
  const [archivedSlug, newSlug] = positionals;
@@ -9,19 +9,19 @@ export function rebuildSessionCosts(vaultBase, { apply = false, session = '', li
9
9
  const registry = readSessionRegistry(vaultBase);
10
10
  const report = { version: 1, generatedAt: new Date().toISOString(), mode: apply ? 'apply' : 'dry-run', scanned: 0, changed: 0, unchanged: 0, missing: [], errors: [], sessions: [] };
11
11
  const entries = Object.entries(registry.sessions || {}).map(([sessionId, value]) => ({ sessionId, ...value }))
12
- .filter((e) => e.session_file && e.transcript_path)
12
+ .filter((e) => e.session_file)
13
13
  .filter((e) => !session || e.sessionId === session || e.session_file === session);
14
14
  for (const entry of entries) {
15
15
  if (limit && report.scanned >= limit) break;
16
16
  report.scanned += 1;
17
17
  const note = join(vaultBase, entry.session_file);
18
- if (!existsSync(note) || !existsSync(entry.transcript_path)) {
19
- report.missing.push({ sessionId: entry.sessionId, session: entry.session_file, note: existsSync(note), transcript: existsSync(entry.transcript_path) });
18
+ if (!entry.transcript_path || !existsSync(note) || !existsSync(entry.transcript_path)) {
19
+ report.missing.push({ sessionId: entry.sessionId, session: entry.session_file, note: existsSync(note), transcript: !!entry.transcript_path && existsSync(entry.transcript_path), transcriptPath: entry.transcript_path || '' });
20
20
  continue;
21
21
  }
22
22
  const before = readFileSync(note, 'utf8');
23
23
  try {
24
- updateSessionObservability({ sessionPath: note, transcriptPath: entry.transcript_path });
24
+ updateSessionObservability({ sessionPath: note, transcriptPath: entry.transcript_path, caller: 'cost-rebuild', canonicalConversationId: entry.sessionId });
25
25
  const after = readFileSync(note, 'utf8');
26
26
  const changed = before !== after;
27
27
  if (changed) report.changed += 1; else report.unchanged += 1;
@@ -32,7 +32,7 @@ export function rebuildSessionCosts(vaultBase, { apply = false, session = '', li
32
32
  report.errors.push({ sessionId: entry.sessionId, session: entry.session_file, error: error.message });
33
33
  }
34
34
  }
35
- report.ok = report.errors.length === 0;
35
+ report.ok = report.errors.length === 0 && report.missing.length === 0;
36
36
  if (apply) writeFileSync(join(vaultBase, '.brain', 'COST_REBUILD.json'), `${JSON.stringify(report, null, 2)}\n`, 'utf8');
37
37
  return report;
38
38
  }
@@ -0,0 +1,37 @@
1
+ import { isAbsolute, resolve } from 'node:path';
2
+ import { readControl, readSessionRegistry, writeControl } from '../hooks/obsidian-common.mjs';
3
+
4
+ function vaultOf(argv) {
5
+ const i = argv.indexOf('--vault');
6
+ const raw = i >= 0 ? argv[i + 1] : argv.find((a) => a.startsWith('--vault='))?.slice(8) || process.env.OBSIDIAN_VAULT_PATH;
7
+ if (!raw) throw new Error('pass --vault <path> or set OBSIDIAN_VAULT_PATH');
8
+ return isAbsolute(raw) ? raw : resolve(process.cwd(), raw);
9
+ }
10
+
11
+ function positionals(argv) {
12
+ return argv.filter((arg, index) => !arg.startsWith('-') && argv[index - 1] !== '--vault');
13
+ }
14
+
15
+ export function runSession(argv) {
16
+ const vault = vaultOf(argv);
17
+ const [sub, id] = positionals(argv);
18
+ const registry = readSessionRegistry(vault);
19
+ const rows = Object.entries(registry.sessions || {}).sort((a, b) => String(b[1].last_seen || '').localeCompare(String(a[1].last_seen || '')));
20
+ if (sub === 'list') {
21
+ for (const [sessionId, item] of rows) process.stdout.write(`${sessionId}\t${item.status || 'unknown'}\t${item.provider || 'unknown'}\t${item.change_slug || '-'}\t${item.session_file || '-'}\n`);
22
+ return;
23
+ }
24
+ const entry = registry.sessions?.[id];
25
+ if (!entry) throw new Error(`session not found: ${id || '(missing id)'}`);
26
+ if (sub === 'show') {
27
+ process.stdout.write(`${JSON.stringify({ session_id: id, ...entry }, null, 2)}\n`);
28
+ return;
29
+ }
30
+ if (sub === 'use') {
31
+ const control = readControl(vault);
32
+ writeControl(vault, { ...control, status: entry.status || 'active', session_id: id, session_file: entry.session_file || '', started_at: entry.started_at || '' });
33
+ process.stdout.write(`session focus: ${id}\n`);
34
+ return;
35
+ }
36
+ throw new Error('use: wendkeep session list | show <id> | use <id>');
37
+ }