wendkeep 0.66.4 → 0.66.5
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 +22 -0
- package/README.en.md +3 -3
- package/README.md +3 -3
- package/docs/en/commands/costs-and-observability.md +21 -7
- package/docs/en/commands/maintenance-and-diagnostics.md +13 -1
- package/docs/en/commands/sessions-and-import.md +15 -0
- package/docs/pt-BR/commands/costs-and-observability.md +21 -7
- package/docs/pt-BR/commands/maintenance-and-diagnostics.md +12 -1
- package/docs/pt-BR/commands/sessions-and-import.md +15 -1
- package/hooks/codex-rollout-meta.mjs +112 -0
- package/hooks/codex-subagent-graph.mjs +903 -0
- package/hooks/harness-doctor.mjs +82 -1
- package/hooks/import-sessions.mjs +185 -50
- package/hooks/session-identity.mjs +40 -5
- package/hooks/session-observability-lifecycle.mjs +129 -0
- package/hooks/session-observability-state.mjs +241 -0
- package/hooks/session-observability-store.mjs +436 -0
- package/hooks/session-observability.mjs +647 -21
- package/hooks/session-stop.mjs +218 -5
- package/hooks/subagent-stop.mjs +266 -12
- package/hooks/subagent-usage.mjs +65 -0
- package/hooks/token-usage.mjs +81 -4
- package/package.json +1 -1
- package/src/cost.mjs +40 -6
- package/src/doctor.mjs +4 -1
- package/src/rebuild-costs.mjs +220 -34
package/hooks/harness-doctor.mjs
CHANGED
|
@@ -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 {
|
|
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
|
|
193
|
-
if (!
|
|
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 {
|
|
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 = {
|
|
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) {
|
|
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
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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) {
|
|
@@ -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
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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()) {
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { statSync } from 'node:fs';
|
|
3
|
+
import { readCodexRolloutMeta } from './codex-rollout-meta.mjs';
|
|
4
|
+
|
|
5
|
+
const stableHash = (value) => createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
|
6
|
+
|
|
7
|
+
function addTranscriptPath(paths, value) {
|
|
8
|
+
if (typeof value === 'string' && value.trim()) paths.add(value.trim());
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function collectActivationPaths(paths, activations) {
|
|
12
|
+
if (!activations || typeof activations !== 'object') return;
|
|
13
|
+
const values = Array.isArray(activations) ? activations : Object.values(activations);
|
|
14
|
+
for (const activation of values) {
|
|
15
|
+
if (!activation || typeof activation !== 'object') continue;
|
|
16
|
+
addTranscriptPath(paths, activation.transcript_path);
|
|
17
|
+
for (const path of activation.transcript_paths || []) addTranscriptPath(paths, path);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function isCodexDescendant(meta) {
|
|
22
|
+
return Boolean(meta?.source && typeof meta.source === 'object' && meta.source.subagent);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Resolve every transcript explicitly attached to the registry entry. Classification is
|
|
26
|
+
// authoritative only when the rollout's first session_meta line can be read: filename and
|
|
27
|
+
// directory layout are deliberately not used as subagent heuristics.
|
|
28
|
+
export function resolveObservabilityRoots(entry, { readMeta = readCodexRolloutMeta } = {}) {
|
|
29
|
+
if (!entry || typeof entry !== 'object') {
|
|
30
|
+
return {
|
|
31
|
+
state: 'degraded',
|
|
32
|
+
rootPaths: [],
|
|
33
|
+
descendantPaths: [],
|
|
34
|
+
diagnostics: [{ code: 'MAIN_TRANSCRIPT_UNRESOLVED', count: 1 }],
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const candidates = new Set();
|
|
39
|
+
addTranscriptPath(candidates, entry.transcript_path);
|
|
40
|
+
for (const path of entry.transcript_paths || []) addTranscriptPath(candidates, path);
|
|
41
|
+
collectActivationPaths(candidates, entry.activations);
|
|
42
|
+
|
|
43
|
+
const rootPaths = [];
|
|
44
|
+
const descendantPaths = [];
|
|
45
|
+
let unreadable = 0;
|
|
46
|
+
for (const path of [...candidates].sort((a, b) => a.localeCompare(b))) {
|
|
47
|
+
const result = readMeta(path);
|
|
48
|
+
if (!result?.ok) {
|
|
49
|
+
unreadable += 1;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (isCodexDescendant(result.meta)) descendantPaths.push(path);
|
|
53
|
+
else rootPaths.push(path);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (unreadable > 0) {
|
|
57
|
+
return {
|
|
58
|
+
state: 'degraded',
|
|
59
|
+
rootPaths,
|
|
60
|
+
descendantPaths,
|
|
61
|
+
diagnostics: [{ code: 'MAIN_TRANSCRIPT_UNRESOLVED', count: unreadable }],
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return { state: 'complete', rootPaths, descendantPaths, diagnostics: [] };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function sameFrontier(left, right) {
|
|
69
|
+
return Boolean(left && right && JSON.stringify(left) === JSON.stringify(right));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function assessObservabilityFreshness({
|
|
73
|
+
checkpoint,
|
|
74
|
+
runtimeState,
|
|
75
|
+
statSource = statSync,
|
|
76
|
+
} = {}) {
|
|
77
|
+
if (!checkpoint) return { fresh: false, status: 'legacy', diagnostics: [] };
|
|
78
|
+
if (checkpoint.state === 'degraded') {
|
|
79
|
+
return {
|
|
80
|
+
fresh: false,
|
|
81
|
+
status: 'degraded',
|
|
82
|
+
diagnostics: checkpoint.diagnostics || [],
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
const manifest = runtimeState?.source_manifest;
|
|
86
|
+
if (!Array.isArray(manifest) || manifest.length === 0) {
|
|
87
|
+
return { fresh: false, status: 'manifest-unproven', diagnostics: [] };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const hashInput = [];
|
|
91
|
+
for (const source of manifest) {
|
|
92
|
+
if (!source || typeof source.path !== 'string' || typeof source.rolloutId !== 'string') {
|
|
93
|
+
return { fresh: false, status: 'manifest-unproven', diagnostics: [] };
|
|
94
|
+
}
|
|
95
|
+
let stat;
|
|
96
|
+
try { stat = statSource(source.path); } catch {
|
|
97
|
+
return {
|
|
98
|
+
fresh: false,
|
|
99
|
+
status: 'stale',
|
|
100
|
+
diagnostics: [{ code: 'STALE_FRONTIER', count: 1 }],
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
if (!stat.isFile() || stat.size !== Number(source.size)
|
|
104
|
+
|| stat.mtimeMs !== Number(source.mtimeMs)) {
|
|
105
|
+
return {
|
|
106
|
+
fresh: false,
|
|
107
|
+
status: 'stale',
|
|
108
|
+
diagnostics: [{ code: 'STALE_FRONTIER', count: 1 }],
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
hashInput.push({ rolloutId: source.rolloutId, size: stat.size, mtimeMs: stat.mtimeMs });
|
|
112
|
+
}
|
|
113
|
+
hashInput.sort((left, right) =>
|
|
114
|
+
`${left.rolloutId}\u0000${left.size}\u0000${left.mtimeMs}`.localeCompare(
|
|
115
|
+
`${right.rolloutId}\u0000${right.size}\u0000${right.mtimeMs}`,
|
|
116
|
+
));
|
|
117
|
+
const stale = stableHash(hashInput) !== checkpoint.frontier.source_manifest_hash
|
|
118
|
+
|| !sameFrontier(runtimeState?.checkpoint_frontier, checkpoint.frontier)
|
|
119
|
+
|| runtimeState?.observability_dirty !== false
|
|
120
|
+
|| runtimeState?.observability_checkpoint_sequence !== runtimeState?.observability_signal_sequence
|
|
121
|
+
|| runtimeState?.observability_checkpoint_sequence !== checkpoint.frontier.signal_sequence;
|
|
122
|
+
return stale
|
|
123
|
+
? {
|
|
124
|
+
fresh: false,
|
|
125
|
+
status: 'stale',
|
|
126
|
+
diagnostics: [{ code: 'STALE_FRONTIER', count: 1 }],
|
|
127
|
+
}
|
|
128
|
+
: { fresh: true, status: 'fresh', diagnostics: [] };
|
|
129
|
+
}
|