wendkeep 0.66.4 → 0.67.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.en.md +78 -5
  3. package/README.md +78 -5
  4. package/docs/en/commands/costs-and-observability.md +21 -7
  5. package/docs/en/commands/maintenance-and-diagnostics.md +13 -1
  6. package/docs/en/commands/operating-profiles.md +65 -10
  7. package/docs/en/commands/sessions-and-import.md +22 -1
  8. package/docs/en/commands/verify.md +5 -3
  9. package/docs/pt-BR/commands/costs-and-observability.md +21 -7
  10. package/docs/pt-BR/commands/maintenance-and-diagnostics.md +12 -1
  11. package/docs/pt-BR/commands/operating-profiles.md +66 -11
  12. package/docs/pt-BR/commands/sessions-and-import.md +20 -0
  13. package/docs/pt-BR/commands/verify.md +6 -3
  14. package/hooks/change-nag.mjs +8 -0
  15. package/hooks/codex-rollout-meta.mjs +112 -0
  16. package/hooks/codex-subagent-graph.mjs +903 -0
  17. package/hooks/harness-doctor.mjs +82 -1
  18. package/hooks/import-sessions.mjs +185 -50
  19. package/hooks/operating-profile-runtime.mjs +36 -2
  20. package/hooks/operating-profile-task-store.mjs +77 -0
  21. package/hooks/session-identity.mjs +40 -5
  22. package/hooks/session-observability-lifecycle.mjs +129 -0
  23. package/hooks/session-observability-state.mjs +241 -0
  24. package/hooks/session-observability-store.mjs +436 -0
  25. package/hooks/session-observability.mjs +647 -21
  26. package/hooks/session-stop.mjs +339 -11
  27. package/hooks/subagent-stop.mjs +266 -12
  28. package/hooks/subagent-usage.mjs +65 -0
  29. package/hooks/token-usage.mjs +81 -4
  30. package/package.json +3 -3
  31. package/packages/harness/src/operating-profile.mjs +127 -0
  32. package/packages/harness/src/sensors-core.mjs +41 -1
  33. package/packages/integrations/src/prompt-content.mjs +123 -0
  34. package/packages/integrations/src/transcripts.mjs +16 -10
  35. package/src/cost.mjs +40 -6
  36. package/src/doctor.mjs +4 -1
  37. package/src/profile.mjs +95 -17
  38. package/src/rebuild-costs.mjs +220 -34
  39. package/src/skills-seed.mjs +38 -2
  40. package/src/sync-defs.mjs +6 -1
@@ -8,7 +8,88 @@ import { buildEffectiveRequirementPackage, checkSpecsState, evaluateVerdict, tas
8
8
  import { getLocale } from './locale.mjs';
9
9
  import { priceForModel } from './token-usage.mjs';
10
10
  import { countMissing, indexDerivedBySession, listSessionNotes, missingDerivedLinks } from './derived-sections.mjs';
11
- import { readControl } from './obsidian-common.mjs';
11
+ import { readControl, readSessionRegistry } from './obsidian-common.mjs';
12
+ import { parseObservabilityCheckpoint } from './session-observability-state.mjs';
13
+ import { readObservabilityStore } from './session-observability-store.mjs';
14
+ import { assessObservabilityFreshness } from './session-observability-lifecycle.mjs';
15
+
16
+ export function checkSessionObservability(vaultBase, deps = {}) {
17
+ const readRegistry = deps.readRegistry || readSessionRegistry;
18
+ const readStore = deps.readStore || readObservabilityStore;
19
+ const readNote = deps.readNote || readFileSync;
20
+ const statSource = deps.statSource || statSync;
21
+ const registry = readRegistry(vaultBase);
22
+ const result = { ok: true, scanned: 0, healthy: 0, issues: [] };
23
+ const entries = Object.entries(registry?.sessions || {})
24
+ .map(([sessionId, entry]) => ({ sessionId, ...entry }))
25
+ .filter((entry) => entry.session_file)
26
+ .sort((a, b) => a.sessionId.localeCompare(b.sessionId));
27
+
28
+ for (const entry of entries) {
29
+ if (entry.provider && entry.provider !== 'codex') continue;
30
+ const notePath = join(vaultBase, entry.session_file);
31
+ let content;
32
+ try { content = readNote(notePath, 'utf8'); } catch { continue; }
33
+ if (!entry.provider && /^provider:\s*["']?claude/m.test(content)) continue;
34
+ result.scanned += 1;
35
+ const command = `wendkeep cost rebuild --session ${entry.sessionId} --json`;
36
+ const checkpoint = parseObservabilityCheckpoint(content);
37
+ if (!checkpoint) {
38
+ result.issues.push({
39
+ sessionId: entry.sessionId, status: 'legacy', diagnostics: [], command,
40
+ });
41
+ continue;
42
+ }
43
+ if (checkpoint.state === 'degraded') {
44
+ result.issues.push({
45
+ sessionId: entry.sessionId,
46
+ status: 'degraded',
47
+ diagnostics: checkpoint.diagnostics,
48
+ command,
49
+ });
50
+ continue;
51
+ }
52
+
53
+ let runtime;
54
+ try { runtime = readStore(vaultBase, entry.sessionId); } catch {
55
+ runtime = null;
56
+ }
57
+ const assessment = assessObservabilityFreshness({
58
+ checkpoint,
59
+ runtimeState: runtime,
60
+ statSource,
61
+ });
62
+ if (!assessment.fresh) {
63
+ result.issues.push({
64
+ sessionId: entry.sessionId,
65
+ status: assessment.status,
66
+ diagnostics: assessment.diagnostics,
67
+ command,
68
+ });
69
+ continue;
70
+ }
71
+ result.healthy += 1;
72
+ }
73
+ result.ok = result.issues.length === 0;
74
+ return result;
75
+ }
76
+
77
+ export function renderSessionObservabilityLines(result) {
78
+ const issues = result?.issues || [];
79
+ const lines = [
80
+ `[observabilidade] ${result?.healthy || 0} saudável(is) · ${issues.length} reparável(is)`,
81
+ ];
82
+ for (const issue of issues) {
83
+ const diagnostics = issue.diagnostics?.length
84
+ ? ` (${issue.diagnostics.map(({ code, count }) => `${code}:${count}`).join(', ')})`
85
+ : '';
86
+ lines.push(` ✗ ${issue.sessionId}: ${issue.status}${diagnostics}`);
87
+ lines.push(` → ${issue.command}`);
88
+ lines.push(` → ${issue.command} --apply`);
89
+ }
90
+ if (!issues.length) lines.push(' ✓ frontiers, checkpoints e manifests consistentes');
91
+ return lines;
92
+ }
12
93
 
13
94
  export function checkHarness(vaultBase, projectRoot) {
14
95
  const loc = getLocale(vaultBase);
@@ -16,10 +16,20 @@ 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 { updateSessionObservability } from './session-observability.mjs';
19
+ import {
20
+ composeSessionObservability,
21
+ publishSessionObservability,
22
+ } from './session-observability.mjs';
20
23
  import { readSessionRegistry, upsertSessionRegistry, removeSessionRegistryEntry, formatLocalIso, formatDate, providerMeta, isBootstrapPrompt } from './obsidian-common.mjs';
21
24
  import { getLocale } from './locale.mjs';
22
25
  import { captureProseDecisions } from './decision-capture.mjs';
26
+ import { readCodexRolloutMeta } from './codex-rollout-meta.mjs';
27
+ import {
28
+ parseObservabilityCheckpoint,
29
+ sanitizeObservabilityDiagnostics,
30
+ } from './session-observability-state.mjs';
31
+ import { readObservabilityStore } from './session-observability-store.mjs';
32
+ import { assessObservabilityFreshness } from './session-observability-lifecycle.mjs';
23
33
 
24
34
  // Claude encodes a project's absolute path as its `.claude/projects` dir name by replacing each
25
35
  // path separator and the drive colon with '-'. `C:\GitHub\WendKeep` -> `C--GitHub-WendKeep`.
@@ -97,41 +107,6 @@ function readPrefix(path, bytes = 4096) {
97
107
  }
98
108
  }
99
109
 
100
- // Read the first physical line in full, growing the buffer until a newline is found (capped).
101
- // A fixed prefix truncated any rollout whose session_meta line exceeded the window, silently
102
- // dropping that session from discovery — Codex meta lines can be large (env, git, instructions).
103
- function readFirstLine(path, maxBytes = 4 * 1024 * 1024) {
104
- let fd;
105
- try {
106
- fd = openSync(path, 'r');
107
- const chunk = Buffer.alloc(65536);
108
- let acc = '';
109
- let pos = 0;
110
- while (pos < maxBytes) {
111
- const n = readSync(fd, chunk, 0, chunk.length, pos);
112
- if (n <= 0) break;
113
- acc += chunk.slice(0, n).toString('utf-8');
114
- const nl = acc.indexOf('\n');
115
- if (nl >= 0) return acc.slice(0, nl);
116
- pos += n;
117
- }
118
- return acc; // single-line file, or gave up at the cap
119
- } catch {
120
- return '';
121
- } finally {
122
- if (fd !== undefined) { try { closeSync(fd); } catch { /* already closed */ } }
123
- }
124
- }
125
-
126
- // Pull the session_meta payload (id + cwd). session_meta is line 1 of a rollout.
127
- function readSessionMeta(path) {
128
- const line = readFirstLine(path);
129
- if (!line.trim()) return null;
130
- let e;
131
- try { e = JSON.parse(line); } catch { return null; }
132
- return e.type === 'session_meta' ? (e.payload || {}) : null;
133
- }
134
-
135
110
  // The `session_id` recorded in a note's frontmatter (empty when absent).
136
111
  function noteSessionId(path) {
137
112
  const m = readPrefix(path, 2048).match(/^session_id:\s*["']?([^"'\r\n]+)["']?\s*$/m);
@@ -189,8 +164,9 @@ export function discoverCodexTranscripts(projectPath, fromDir) {
189
164
  if (!dir || !existsSync(dir)) return { dir, transcripts: [] };
190
165
  const transcripts = [];
191
166
  for (const path of walkFiles(dir, /\.jsonl$/i)) {
192
- const meta = readSessionMeta(path);
193
- if (!meta || !meta.id) continue;
167
+ const metaResult = readCodexRolloutMeta(path);
168
+ if (!metaResult.ok || !metaResult.meta.id) continue;
169
+ const meta = metaResult.meta;
194
170
  if (projectPath && !cwdMatchesProject(meta.cwd, projectPath)) continue;
195
171
  // A subagent thread's rollout is a SIBLING file of its parent's — same dir, own id. The
196
172
  // meta says what the file IS; ignoring it turned hierarchy into a duplicate session note.
@@ -248,11 +224,6 @@ export function importSession(vaultBase, txPath, opts = {}) {
248
224
  insertIteration(absPath, block, turn.turnId, tx, vaultBase);
249
225
  }
250
226
 
251
- // Cost + subagent telemetry, exactly like the live Stop hook. Fail-open.
252
- try {
253
- updateSessionObservability({ vaultBase, sessionPath: absPath, transcriptPath: txPath });
254
- } catch { /* observability is best-effort */ }
255
-
256
227
  // Finalize: derived notes + closing section + ended_at from the last turn.
257
228
  const endedAt = formatLocalIso(endDate);
258
229
  const created = mergeCreatedNotes(
@@ -271,6 +242,17 @@ export function importSession(vaultBase, txPath, opts = {}) {
271
242
  imported: true,
272
243
  });
273
244
 
245
+ // Materialize only after the authoritative registry entry exists. This gives import the
246
+ // same causal CAS + runtime manifest as live hooks instead of a note-only legacy snapshot.
247
+ try {
248
+ reconcileImportedObservability({
249
+ vaultBase,
250
+ sessionId,
251
+ sessionPath: absPath,
252
+ transcriptPath: txPath,
253
+ });
254
+ } catch { /* observability is best-effort and reported on a later reconciliation */ }
255
+
274
256
  return { sessionId, relPath, turns: turns.length };
275
257
  }
276
258
 
@@ -333,12 +315,105 @@ export function stampSessionIds(vaultBase) {
333
315
  return report;
334
316
  }
335
317
 
318
+ export function reconcileImportedObservability({
319
+ vaultBase,
320
+ sessionId,
321
+ sessionPath,
322
+ transcriptPath,
323
+ dryRun = false,
324
+ } = {}, dependencies = {}) {
325
+ const readRegistry = dependencies.readRegistry || readSessionRegistry;
326
+ const readStore = dependencies.readStore || readObservabilityStore;
327
+ const compose = dependencies.compose || composeSessionObservability;
328
+ const publish = dependencies.publish || publishSessionObservability;
329
+ if (!vaultBase || !sessionId || !sessionPath || !existsSync(sessionPath)) {
330
+ return {
331
+ status: 'degraded',
332
+ diagnostics: [{ code: 'PARENT_META_INVALID', count: 1 }],
333
+ };
334
+ }
335
+
336
+ let entry;
337
+ let content;
338
+ let runtimeState;
339
+ try {
340
+ entry = readRegistry(vaultBase)?.sessions?.[sessionId];
341
+ content = readFileSync(sessionPath, 'utf8');
342
+ runtimeState = readStore(vaultBase, sessionId);
343
+ } catch {
344
+ return { status: 'degraded', diagnostics: [{ code: 'CACHE_INVALID', count: 1 }] };
345
+ }
346
+ if (!entry) {
347
+ return {
348
+ status: 'degraded',
349
+ diagnostics: [{ code: 'PARENT_META_INVALID', count: 1 }],
350
+ };
351
+ }
352
+ const checkpoint = parseObservabilityCheckpoint(content);
353
+ const assessment = assessObservabilityFreshness({ checkpoint, runtimeState });
354
+ if (assessment.fresh) return { status: 'fresh', diagnostics: [] };
355
+
356
+ let candidate;
357
+ try {
358
+ candidate = compose({
359
+ sessionContent: content,
360
+ sessionEntry: { ...entry, transcript_path: entry.transcript_path || transcriptPath },
361
+ canonicalConversationId: sessionId,
362
+ runtimeState,
363
+ allowNone: true,
364
+ mode: 'offline',
365
+ });
366
+ } catch {
367
+ return {
368
+ status: 'degraded',
369
+ diagnostics: [{ code: 'PARENT_META_INVALID', count: 1 }],
370
+ };
371
+ }
372
+ const diagnostics = sanitizeObservabilityDiagnostics(candidate?.diagnostics || []);
373
+ if (candidate?.state === 'degraded') return { status: 'degraded', diagnostics };
374
+ if (candidate?.state !== 'complete' && candidate?.state !== 'none') {
375
+ return {
376
+ status: 'degraded',
377
+ diagnostics: [{ code: 'PARENT_META_INVALID', count: 1 }],
378
+ };
379
+ }
380
+ if (dryRun) return { status: 'would-reconcile', diagnostics };
381
+
382
+ let outcome;
383
+ try {
384
+ outcome = publish({
385
+ vaultBase,
386
+ sessionPath,
387
+ canonicalConversationId: sessionId,
388
+ candidate,
389
+ caller: 'import-reconcile',
390
+ allowSourceRefresh: true,
391
+ allowDegradedRecovery: true,
392
+ });
393
+ } catch {
394
+ return { status: 'degraded', diagnostics: [{ code: 'CACHE_INVALID', count: 1 }] };
395
+ }
396
+ if (outcome?.status === 'published' || outcome?.status === 'unchanged') {
397
+ return { status: 'published', diagnostics };
398
+ }
399
+ if (outcome?.status === 'stale' || outcome?.status === 'conflict') {
400
+ return {
401
+ status: 'stale',
402
+ diagnostics: [{ code: 'STALE_FRONTIER', count: 1 }],
403
+ };
404
+ }
405
+ return { status: 'degraded', diagnostics };
406
+ }
407
+
336
408
  // Import every not-yet-captured transcript from the requested source(s). Deduped by session_id
337
409
  // against the registry. Options: { projectPath, source ('all'|'claude'|'codex'), from, codexFrom,
338
410
  // since (ISO/date), limit, dryRun }. importSession is provider-agnostic, so both sources share
339
411
  // the same dedup + import loop.
340
412
  export function runImport(vaultBase, opts = {}) {
341
- const { projectPath = process.cwd(), source = 'all', from = '', codexFrom = '', since = '', limit = 0, dryRun = false } = opts;
413
+ const {
414
+ projectPath = process.cwd(), source = 'all', from = '', codexFrom = '', since = '',
415
+ limit = 0, dryRun = false, reconcileObservability = reconcileImportedObservability,
416
+ } = opts;
342
417
  const src = String(source).toLowerCase();
343
418
  const transcripts = [];
344
419
  let claudeDir = '';
@@ -355,7 +430,57 @@ export function runImport(vaultBase, opts = {}) {
355
430
  }
356
431
  const notes = capturedSessionNotes(vaultBase);
357
432
  const sinceMs = since ? Date.parse(since) : 0;
358
- const report = { source: src, claudeDir, codexDir, scanned: transcripts.length, imported: 0, repaired: 0, skipped: 0, subagents: 0, errors: [], sessions: [] };
433
+ const report = {
434
+ source: src, claudeDir, codexDir, scanned: transcripts.length, imported: 0,
435
+ repaired: 0, skipped: 0, subagents: 0, observabilityReconciled: 0,
436
+ observabilityFresh: 0, observabilityDegraded: 0, errors: [], sessions: [],
437
+ };
438
+
439
+ const degradedObservability = () => ({
440
+ status: 'degraded',
441
+ diagnostics: [{ code: 'CACHE_INVALID', count: 1 }],
442
+ });
443
+ const reportableObservabilityStatuses = new Set([
444
+ 'degraded', 'fresh', 'published', 'stale', 'unchanged', 'would-reconcile',
445
+ ]);
446
+ const sanitizeReportedObservability = (result) => {
447
+ try {
448
+ const status = String(result?.status || 'degraded');
449
+ if (!reportableObservabilityStatuses.has(status)) return degradedObservability();
450
+ const projected = (result?.diagnostics || []).map((diagnostic) => ({
451
+ code: diagnostic?.code,
452
+ count: diagnostic?.count,
453
+ }));
454
+ return { status, diagnostics: sanitizeObservabilityDiagnostics(projected) };
455
+ } catch {
456
+ return degradedObservability();
457
+ }
458
+ };
459
+
460
+ const reconcileExistingObservability = (transcript, sessionPath, turns) => {
461
+ if (typeof reconcileObservability !== 'function') return null;
462
+ let result;
463
+ try {
464
+ result = reconcileObservability({
465
+ vaultBase,
466
+ sessionId: transcript.sessionId,
467
+ sessionPath,
468
+ transcriptPath: transcript.path,
469
+ dryRun,
470
+ });
471
+ } catch {
472
+ result = degradedObservability();
473
+ }
474
+ const observability = sanitizeReportedObservability(result);
475
+ if (observability.status === 'fresh' || observability.status === 'unchanged') {
476
+ report.observabilityFresh++;
477
+ } else if (observability.status === 'published' || observability.status === 'would-reconcile') {
478
+ report.observabilityReconciled++;
479
+ } else {
480
+ report.observabilityDegraded++;
481
+ }
482
+ return observability;
483
+ };
359
484
 
360
485
  let done = 0;
361
486
  for (const t of transcripts) {
@@ -390,7 +515,14 @@ export function runImport(vaultBase, opts = {}) {
390
515
  if (existingNote) {
391
516
  const have = noteTurnIds(existingNote);
392
517
  const missing = turns.filter((turn) => !have.has(String(turn.turnId)));
393
- if (!missing.length) { report.skipped++; continue; }
518
+ if (!missing.length) {
519
+ const observability = reconcileExistingObservability(t, existingNote, 0);
520
+ if (observability) report.sessions.push({
521
+ sessionId: t.sessionId, turns: 0, observability,
522
+ });
523
+ report.skipped++;
524
+ continue;
525
+ }
394
526
  if (dryRun) {
395
527
  report.sessions.push({ sessionId: t.sessionId, turns: missing.length, repaired: true, dryRun: true });
396
528
  report.repaired++;
@@ -404,10 +536,13 @@ export function runImport(vaultBase, opts = {}) {
404
536
  turn.turnId, tx, vaultBase,
405
537
  );
406
538
  }
407
- try {
408
- updateSessionObservability({ vaultBase, sessionPath: existingNote, transcriptPath: t.path });
409
- } catch { /* best-effort */ }
410
- report.sessions.push({ sessionId: t.sessionId, turns: missing.length, repaired: true });
539
+ const observability = reconcileExistingObservability(t, existingNote, missing.length);
540
+ report.sessions.push({
541
+ sessionId: t.sessionId,
542
+ turns: missing.length,
543
+ repaired: true,
544
+ ...(observability ? { observability } : {}),
545
+ });
411
546
  report.repaired++;
412
547
  done++;
413
548
  } catch (error) {
@@ -3,6 +3,7 @@ import { resolve } from 'node:path';
3
3
 
4
4
  import {
5
5
  DEFAULT_OPERATING_PROFILE,
6
+ evaluateTaskOperatingProfileLease,
6
7
  normalizeOperatingProfile,
7
8
  operatingProfilePolicy,
8
9
  resolveOperatingProfile,
@@ -68,6 +69,22 @@ function sessionOverride(entry) {
68
69
  }
69
70
  }
70
71
 
72
+ function nonNegativeSequence(value, fallback = null) {
73
+ const parsed = Number(value);
74
+ return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : fallback;
75
+ }
76
+
77
+ function taskLeaseContext(entry, input, sessionId) {
78
+ return {
79
+ sessionId,
80
+ turnId: input?.turn_id || input?.turnId || entry?.last_prompt_turn_id || '',
81
+ turnSequence: nonNegativeSequence(
82
+ input?.turn_sequence ?? input?.turnSequence,
83
+ nonNegativeSequence(entry?.last_turn_sequence),
84
+ ),
85
+ };
86
+ }
87
+
71
88
  function canonicalPath(value) {
72
89
  const path = resolve(value).replaceAll('\\', '/');
73
90
  return process.platform === 'win32' ? path.toLowerCase() : path;
@@ -105,7 +122,8 @@ function matchingProjectBinding(vaultResolution, input) {
105
122
  }
106
123
  }
107
124
 
108
- // Resolution precedence for hooks: explicit session override -> project binding -> GOVERN.
125
+ // Resolution precedence for hooks: active request lease -> explicit session override
126
+ // -> project binding -> GOVERN.
109
127
  // Binding corruption is never interpreted as OFF: an authoritative Vault keeps the Keep Core
110
128
  // alive under GOVERN and carries a visible diagnostic to each entrypoint.
111
129
  export function resolveHookOperatingProfile({
@@ -134,7 +152,20 @@ export function resolveHookOperatingProfile({
134
152
  const entry = identity.state === 'resolved'
135
153
  ? readSessionRegistry(vaultResolution.base).sessions?.[identity.canonicalConversationId] || null
136
154
  : null;
137
- const selected = sessionOverride(entry) || project;
155
+ const base = sessionOverride(entry) || project;
156
+ const taskLease = evaluateTaskOperatingProfileLease(
157
+ entry?.operating_profile_task,
158
+ taskLeaseContext(entry, input, identity.canonicalConversationId || ''),
159
+ );
160
+ const selected = taskLease.state === 'active'
161
+ ? {
162
+ profile: taskLease.profile,
163
+ source: 'task-lease',
164
+ valid: true,
165
+ configured: true,
166
+ raw: taskLease.profile,
167
+ }
168
+ : base;
138
169
  return {
139
170
  ...selected,
140
171
  policy: operatingProfilePolicy(selected.profile),
@@ -142,6 +173,9 @@ export function resolveHookOperatingProfile({
142
173
  projectRoot: vaultResolution.projectRoot,
143
174
  identity,
144
175
  entry,
176
+ baseProfile: base.profile,
177
+ baseSource: base.source,
178
+ taskLease,
145
179
  resolution: vaultResolution,
146
180
  ...(bindingError ? { bindingError } : {}),
147
181
  };
@@ -0,0 +1,77 @@
1
+ import { randomUUID } from 'node:crypto';
2
+
3
+ import {
4
+ createTaskOperatingProfileLease,
5
+ } from '../src/operating-profile.mjs';
6
+ import { mutateSessionRegistry } from './obsidian-common.mjs';
7
+
8
+ function isoTimestamp(now) {
9
+ if (typeof now === 'string') return now;
10
+ if (now instanceof Date) return now.toISOString();
11
+ return new Date().toISOString();
12
+ }
13
+
14
+ function missingSessionError(sessionId) {
15
+ const error = new Error(`sessão não encontrada: ${sessionId}`);
16
+ error.code = 'WENDKEEP_SESSION_NOT_FOUND';
17
+ return error;
18
+ }
19
+
20
+ export function setSessionTaskOperatingProfile(vaultBase, sessionId, profile, {
21
+ reason,
22
+ leaseId = randomUUID(),
23
+ now,
24
+ } = {}) {
25
+ const issuedAt = isoTimestamp(now);
26
+ return mutateSessionRegistry(vaultBase, (registry) => {
27
+ const sessions = registry.sessions || (registry.sessions = {});
28
+ if (!Object.hasOwn(sessions, sessionId)) throw missingSessionError(sessionId);
29
+ const current = sessions[sessionId];
30
+ const turnId = typeof current.last_prompt_turn_id === 'string'
31
+ ? current.last_prompt_turn_id.trim()
32
+ : '';
33
+ const hasRegisteredTurn = Boolean(
34
+ turnId
35
+ && current.turn_sequences
36
+ && Object.hasOwn(current.turn_sequences, turnId)
37
+ && current.turn_sequences[turnId] === current.last_turn_sequence
38
+ );
39
+ const lease = createTaskOperatingProfileLease({
40
+ profile,
41
+ reason,
42
+ sessionId,
43
+ turnId,
44
+ turnSequence: hasRegisteredTurn ? current.last_turn_sequence : undefined,
45
+ leaseId,
46
+ issuedAt,
47
+ });
48
+ sessions[sessionId] = {
49
+ ...current,
50
+ operating_profile_task: lease,
51
+ updated_at: issuedAt,
52
+ };
53
+ return lease;
54
+ });
55
+ }
56
+
57
+ export function consumeSessionTaskOperatingProfile(vaultBase, sessionId, leaseId, {
58
+ now,
59
+ } = {}) {
60
+ if (!sessionId || !leaseId) return false;
61
+ const consumedAt = isoTimestamp(now);
62
+ return mutateSessionRegistry(vaultBase, (registry) => {
63
+ const current = registry.sessions?.[sessionId];
64
+ const lease = current?.operating_profile_task;
65
+ if (!lease || lease.state !== 'active' || lease.lease_id !== leaseId) return false;
66
+ registry.sessions[sessionId] = {
67
+ ...current,
68
+ operating_profile_task: {
69
+ ...lease,
70
+ state: 'consumed',
71
+ consumed_at: consumedAt,
72
+ },
73
+ updated_at: consumedAt,
74
+ };
75
+ return true;
76
+ });
77
+ }
@@ -5,14 +5,49 @@ import {
5
5
  inspectTranscriptIdentityContent,
6
6
  resolveSessionIdentitySnapshot,
7
7
  } from '../packages/integrations/src/session-identity.mjs';
8
+ import { readCodexRolloutMeta } from './codex-rollout-meta.mjs';
9
+
10
+ function unknownTranscriptIdentity() {
11
+ return {
12
+ transcriptProvider: 'unknown',
13
+ provider: 'unknown',
14
+ canonicalConversationId: '',
15
+ transcriptId: '',
16
+ parentConversationId: '',
17
+ };
18
+ }
19
+
20
+ function inspectCodexMeta(meta, fallbackTranscriptId) {
21
+ return {
22
+ transcriptProvider: 'openai',
23
+ provider: 'codex',
24
+ canonicalConversationId: meta.session_id || meta.id || '',
25
+ transcriptId: meta.id || fallbackTranscriptId,
26
+ parentConversationId: meta.parent_thread_id || meta.forked_from_id || '',
27
+ };
28
+ }
29
+
30
+ function withoutCodexSessionMeta(content) {
31
+ return String(content || '').split('\n').filter((line) => {
32
+ try { return JSON.parse(line)?.type !== 'session_meta'; } catch { return true; }
33
+ }).join('\n');
34
+ }
8
35
 
9
36
  export function inspectTranscriptIdentity(transcriptPath) {
10
- const content = transcriptPath && existsSync(transcriptPath)
11
- ? readFileSync(transcriptPath, 'utf-8')
12
- : '';
13
- return inspectTranscriptIdentityContent(content, {
14
- fallbackTranscriptId: transcriptPath ? basename(transcriptPath, '.jsonl') : '',
37
+ const fallbackTranscriptId = transcriptPath ? basename(transcriptPath, '.jsonl') : '';
38
+ if (!transcriptPath || !existsSync(transcriptPath)) return unknownTranscriptIdentity();
39
+
40
+ const codexMeta = readCodexRolloutMeta(transcriptPath);
41
+ if (codexMeta.ok) return inspectCodexMeta(codexMeta.meta, fallbackTranscriptId);
42
+
43
+ // Claude JSONL has no session_meta header. Preserve its existing full-content inspection,
44
+ // but never reinterpret a later/misplaced Codex meta as the file identity.
45
+ let content;
46
+ try { content = readFileSync(transcriptPath, 'utf-8'); } catch { return unknownTranscriptIdentity(); }
47
+ const inspected = inspectTranscriptIdentityContent(withoutCodexSessionMeta(content), {
48
+ fallbackTranscriptId,
15
49
  });
50
+ return inspected.provider === 'claude' ? inspected : unknownTranscriptIdentity();
16
51
  }
17
52
 
18
53
  export function resolveSessionIdentity(vaultBase, input = {}, provider = detectProvider()) {