From a82a88f92578a090b82f22e627e33cd67b26bebe Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Mon, 24 Aug 2026 12:55:38 -0700 Subject: [PATCH 1/3] fix(usage): support legacy and current Codex rollouts --- src/lib/usage-index.mjs | 136 +++++++++++++++++++++++++++--- tests/kit/usage-index-v6.test.mjs | 126 ++++++++++++++++++++++++++- 2 files changed, 249 insertions(+), 13 deletions(-) diff --git a/src/lib/usage-index.mjs b/src/lib/usage-index.mjs index f43c026..d76cdf1 100644 --- a/src/lib/usage-index.mjs +++ b/src/lib/usage-index.mjs @@ -70,8 +70,11 @@ import { * `isApiErrorMessage: true` — some builds emit the placeholder without * that flag set. A v8-cached session parsed from such a transcript still * carries `""` in `models` and a $0 usage row, so it must be - * re-derived or the placeholder keeps showing as a real "model in play". */ -export const SCHEMA_VERSION = 9; + * re-derived or the placeholder keeps showing as a real "model in play". + * v10: Codex rollout messages can arrive in the `item_completed` envelope; + * v9-cached records parsed from those files have token rows but zero + * prompts/responses, so every Codex record must be re-derived. */ +export const SCHEMA_VERSION = 10; /** Silence longer than this ends a stretch of engagement. A session is split * into active sub-intervals at gaps ABOVE this bound (exactly this much is not @@ -620,6 +623,49 @@ function parseClaude(raw, { id, dirName, withTurns = false }) { return { session: seal(rec), turns }; } +/** Newer Codex rollouts wrap messages in `item_completed` and use a different + * content-block discriminator for each message role (`Text` for agent output, + * `text` for user input). Keep that wire detail at the parser boundary so the + * rest of the scorecard consumes the same prompt/response model for both + * generations. */ +function codexItemText(item) { + if (typeof item?.text === 'string') return item.text; + if (typeof item?.message === 'string') return item.message; + if (!Array.isArray(item?.content)) return ''; + return item.content + .filter((block) => (block?.type === 'Text' || block?.type === 'text') && typeof block.text === 'string') + .map((block) => block.text) + .join('\n'); +} + +function codexParseStats() { + return { + legacyEvents: 0, itemCompletedEvents: 0, tokenCountEvents: 0, + prompts: 0, responses: 0, unknownItemTypes: {}, + }; +} + +function codexEvent(payload, stats) { + if (payload?.type === 'user_message') { + stats.legacyEvents++; + return { kind: 'prompt', text: typeof payload.message === 'string' ? payload.message : '' }; + } + if (payload?.type === 'agent_message') { + stats.legacyEvents++; + return { kind: 'response', text: typeof payload.message === 'string' ? payload.message : '' }; + } + if (payload?.type !== 'item_completed') return null; + + stats.itemCompletedEvents++; + const item = payload.item; + if (item?.type === 'UserMessage') return { kind: 'prompt', text: codexItemText(item) }; + if (item?.type === 'AgentMessage') return { kind: 'response', text: codexItemText(item) }; + + const type = typeof item?.type === 'string' ? item.type : ''; + stats.unknownItemTypes[type] = (stats.unknownItemTypes[type] ?? 0) + 1; + return null; +} + /** * Parse one Codex rollout. `total_token_usage` is CUMULATIVE, so the LAST * token_count event is the session total — summing them would multiply the @@ -638,6 +684,7 @@ function parseClaude(raw, { id, dirName, withTurns = false }) { function parseCodex(raw, { id, withTurns = false }) { const rec = blankSession(id, 'codex'); const turns = []; + const stats = codexParseStats(); let lastUsage = null; let lastUsageAt = null; let firstPrompt = ''; @@ -669,6 +716,7 @@ function parseCodex(raw, { id, withTurns = false }) { if (e.type !== 'event_msg') continue; if (p.type === 'token_count') { + stats.tokenCountEvents++; const t = p.info?.total_token_usage; if (t && typeof t === 'object') { lastUsage = t; lastUsageAt = ms; } // Every token_count also carries a live rate-limit snapshot — keep the @@ -700,18 +748,22 @@ function parseCodex(raw, { id, withTurns = false }) { } continue; } - if (p.type === 'user_message') { + const event = codexEvent(p, stats); + if (!event) continue; + if (event.kind === 'prompt') { rec.prompts++; - const text = typeof p.message === 'string' ? p.message : ''; + stats.prompts++; + const text = event.text; if (!firstPrompt) firstPrompt = text; // Codex rollouts record only real prompts as user_message events — tool // output travels in other event types that are not surfaced as turns — - // so every Codex user turn is kind 'prompt' by construction. + // so every normalized Codex user turn is kind 'prompt' by construction. if (withTurns && text) turns.push({ role: 'user', at: new Date(ms).toISOString(), text, prompt: true, kind: 'prompt' }); continue; } - if (p.type === 'agent_message') { + if (event.kind === 'response') { rec.responses++; + stats.responses++; const at = Number.isFinite(ms) ? ms : (rec.start ?? Date.now()); const pk = punchKey(at); rec.punchcard[pk] = (rec.punchcard[pk] ?? 0) + 1; @@ -719,7 +771,7 @@ function parseCodex(raw, { id, withTurns = false }) { turns.push({ role: 'assistant', at: new Date(at).toISOString(), model: rec.models[rec.models.length - 1] ?? 'unknown', - text: typeof p.message === 'string' ? p.message : '', tools: [], + text: event.text, tools: [], }); } } @@ -743,7 +795,7 @@ function parseCodex(raw, { id, withTurns = false }) { } rec.title = maskSecrets(clip(firstPrompt)) || '(untitled)'; - return { session: seal(rec), turns }; + return { session: seal(rec), turns, parseStats: stats }; } // ── file discovery ────────────────────────────────────────────────────────── @@ -775,6 +827,46 @@ function rootHealth(dir) { } } +function emptyCodexDiagnostics() { + return { + files: 0, cachedFiles: 0, parsedFiles: 0, unparsedFiles: 0, + filesWithTokens: 0, filesWithResponses: 0, + legacyEvents: 0, itemCompletedEvents: 0, tokenCountEvents: 0, + prompts: 0, responses: 0, unknownItemTypes: {}, warnings: [], + }; +} + +function addCodexParseDiagnostics(target, stats) { + if (!stats) return; + target.parsedFiles++; + if (stats.tokenCountEvents > 0) target.filesWithTokens++; + if (stats.responses > 0) target.filesWithResponses++; + target.legacyEvents += stats.legacyEvents; + target.itemCompletedEvents += stats.itemCompletedEvents; + target.tokenCountEvents += stats.tokenCountEvents; + target.prompts += stats.prompts; + target.responses += stats.responses; + for (const [type, count] of Object.entries(stats.unknownItemTypes ?? {})) { + target.unknownItemTypes[type] = (target.unknownItemTypes[type] ?? 0) + count; + } +} + +function finalizeCodexHealth(root, diagnostics) { + const warnings = []; + const tokenFiles = diagnostics.filesWithTokens; + const responseFiles = diagnostics.filesWithResponses; + if (tokenFiles > 0 && responseFiles === 0) warnings.push('zero-response-yield'); + else if (tokenFiles > responseFiles) warnings.push('partial-response-yield'); + if (Object.keys(diagnostics.unknownItemTypes).length) warnings.push('unknown-item-types'); + diagnostics.warnings = warnings; + const hasYieldWarning = warnings.includes('zero-response-yield') || warnings.includes('partial-response-yield'); + const status = root.status === 'ok' && hasYieldWarning + ? 'degraded' : root.status; + const reason = status === 'degraded' && root.status === 'ok' + ? (warnings.includes('zero-response-yield') ? 'parse-yield-zero' : 'parse-yield-partial') : root.reason; + return { ...root, status, reason, diagnostics }; +} + function defaultRoots() { return { claude: path.join(claudeDir(), 'projects'), @@ -995,6 +1087,7 @@ function aggregate(records, { days, now, cutoff, deps }) { let input = 0, output = 0, cacheRead = 0, cacheWrite = 0, cost = 0; let firstDay = null; + const activeDays = new Set(); for (const row of rec.usage) { if (firstDay === null || row.day < firstDay) firstDay = row.day; // `day` prices the row at the rate in effect WHEN THOSE TOKENS WERE @@ -1015,14 +1108,16 @@ function aggregate(records, { days, now, cutoff, deps }) { cost += rowCost; const rowTokens = row.input + row.output + row.cacheRead + row.cacheWrite; - if (!byDay[row.day]) byDay[row.day] = { tokens: 0, cost: 0, sessions: 0 }; + if (!byDay[row.day]) byDay[row.day] = { tokens: 0, cost: 0, sessions: 0, sessionsActive: 0 }; byDay[row.day].tokens += rowTokens; byDay[row.day].cost = round(byDay[row.day].cost + rowCost); + activeDays.add(row.day); const m = bucket(byModel, row.model); m.responses += row.responses; m.input += row.input; m.output += row.output; m.cacheRead += row.cacheRead; m.cacheWrite += row.cacheWrite; m.tokens += rowTokens; m.cost = round(m.cost + rowCost); } + for (const day of activeDays) byDay[day].sessionsActive++; const verdict = deps.classify({ title: rec.title, skill: rec.skill, plugin: rec.plugin, @@ -1260,6 +1355,7 @@ async function scan(o = {}) { const cache = force ? null : readCache(cacheFile); const entries = {}; const records = []; + const codexDiagnostics = emptyCodexDiagnostics(); const total = candidates.length; let scanned = 0; @@ -1267,13 +1363,27 @@ async function scan(o = {}) { for (const c of candidates) { const key = { mtime: c.stat.mtimeMs, size: c.stat.size }; const hit = cache?.entries?.[c.file]; - let session = (hit && hit.mtime === key.mtime && hit.size === key.size) ? hit.session : null; + const cacheHit = !!(hit && hit.mtime === key.mtime && hit.size === key.size + && (c.provider !== 'codex' || hit.parseStats)); + let session = cacheHit ? hit.session : null; + let parseStats = cacheHit ? hit.parseStats : null; if (!session) { const parsed = parseFile(c); session = parsed ? parsed.session : null; + parseStats = parsed?.parseStats ?? null; + } + if (c.provider === 'codex') { + codexDiagnostics.files++; + if (cacheHit) codexDiagnostics.cachedFiles++; + if (session) addCodexParseDiagnostics(codexDiagnostics, parseStats); + else codexDiagnostics.unparsedFiles++; } if (session) { - entries[c.file] = { ...key, session, ...(c.dbFile ? { dbFile: c.dbFile } : {}) }; + entries[c.file] = { + ...key, session, + ...(parseStats ? { parseStats } : {}), + ...(c.dbFile ? { dbFile: c.dbFile } : {}), + }; records.push(session); } scanned++; @@ -1339,7 +1449,9 @@ async function scan(o = {}) { } const result = aggregate(applyCodexLedger(records, ledger), { days, now, cutoff, deps }); result.sourceHealth = { - claude: claudeHealth, codex: codexHealth, opencode: opencodeHealth, codexLedger: codexLedgerHealth, + claude: claudeHealth, + codex: finalizeCodexHealth(codexHealth, codexDiagnostics), + opencode: opencodeHealth, codexLedger: codexLedgerHealth, }; return result; } diff --git a/tests/kit/usage-index-v6.test.mjs b/tests/kit/usage-index-v6.test.mjs index 3313b03..573a59e 100644 --- a/tests/kit/usage-index-v6.test.mjs +++ b/tests/kit/usage-index-v6.test.mjs @@ -7,7 +7,7 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { buildIndex, _resetForTest } from '../../src/lib/usage-index.mjs'; +import { buildIndex, readSession, _resetForTest } from '../../src/lib/usage-index.mjs'; const NOW = Date.parse('2026-07-25T12:00:00.000Z'); const T0 = '2026-07-24T09:00:00.000Z'; @@ -55,6 +55,31 @@ function rollout(id, { threadSource = 'user' } = {}) { }); } +function rolloutItemCompleted(id, { mixed = false } = {}) { + const line = (o) => `${JSON.stringify(o)}\n`; + const meta = line({ timestamp: T0, type: 'session_meta', payload: { + id, cwd: '/Users/me/proj', thread_source: 'user', model_provider: 'openai', + } }); + const context = line({ timestamp: T0, type: 'turn_context', payload: { model: 'gpt-5.6' } }); + const legacy = mixed + ? line({ timestamp: T0, type: 'event_msg', payload: { type: 'user_message', message: 'legacy prompt' } }) + + line({ timestamp: T1, type: 'event_msg', payload: { type: 'agent_message', message: 'legacy response' } }) + : ''; + const current = line({ timestamp: T0, type: 'event_msg', payload: { + type: 'item_completed', item: { type: 'UserMessage', id: 'user-1', content: [{ type: 'text', text: 'current prompt' }] }, + } }) + line({ + timestamp: T0, type: 'event_msg', payload: { + type: 'token_count', + info: { total_token_usage: { input_tokens: 100, cached_input_tokens: 40, output_tokens: 20, total_tokens: 120 } }, + }, + }) + line({ timestamp: T1, type: 'event_msg', payload: { + type: 'item_completed', item: { type: 'AgentMessage', id: 'agent-1', content: [{ type: 'Text', text: 'current response' }] }, + } }) + line({ timestamp: T1, type: 'event_msg', payload: { + type: 'item_completed', item: { type: 'CommandExecution', id: 'command-1', command: 'echo hidden' }, + } }); + return meta + context + legacy + current; +} + function sandbox(files) { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-usage-v6-')); const day = path.join(dir, 'codex', '2026', '07', '24'); @@ -92,6 +117,105 @@ test('parseCodex captures reasoning output and the LAST rate-limit snapshot', as assert.equal(agg.codexRateLimits[0].windows[0].usedPercent, 9); }); +test('parseCodex normalizes current item_completed messages and exposes bounded diagnostics', async () => { + _resetForTest(); + const id = 'item-completed-1'; + const sb = sandbox({ [`rollout-2026-07-24T09-00-00-${id}.jsonl`]: rolloutItemCompleted(id) }); + const agg = await buildIndex(opts(sb)); + const s = agg.sessions.find((x) => x.id === id); + assert.ok(s, 'current-format session is retained'); + assert.equal(s.prompts, 1); + assert.equal(s.responses, 1); + assert.equal(s.tokens, 120); + assert.equal(agg.sourceHealth.codex.status, 'ok'); + assert.deepEqual(agg.sourceHealth.codex.diagnostics, { + files: 1, cachedFiles: 0, parsedFiles: 1, unparsedFiles: 0, + filesWithTokens: 1, filesWithResponses: 1, + legacyEvents: 0, itemCompletedEvents: 3, tokenCountEvents: 1, + prompts: 1, responses: 1, unknownItemTypes: { CommandExecution: 1 }, warnings: ['unknown-item-types'], + }); + + const detail = await readSession(id, opts(sb)); + assert.deepEqual(detail.turns.map((t) => [t.role, t.text]), [ + ['user', 'current prompt'], ['assistant', 'current response'], + ]); +}); + +test('parseCodex accepts a mixed legacy/current rollout without dropping either message family', async () => { + _resetForTest(); + const id = 'item-completed-mixed'; + const sb = sandbox({ [`rollout-2026-07-24T09-00-00-${id}.jsonl`]: rolloutItemCompleted(id, { mixed: true }) }); + const agg = await buildIndex(opts(sb)); + const s = agg.sessions.find((x) => x.id === id); + assert.equal(s.prompts, 2); + assert.equal(s.responses, 2); + assert.equal(agg.sourceHealth.codex.diagnostics.legacyEvents, 2); + assert.equal(agg.sourceHealth.codex.diagnostics.itemCompletedEvents, 3); +}); + +test('Codex source health degrades when token-bearing files yield zero normalized responses', async () => { + _resetForTest(); + const id = 'item-completed-zero'; + const line = (o) => `${JSON.stringify(o)}\n`; + const raw = line({ timestamp: T0, type: 'session_meta', payload: { id, cwd: '/Users/me/proj' } }) + + line({ timestamp: T0, type: 'turn_context', payload: { model: 'gpt-5.6' } }) + + line({ timestamp: T0, type: 'event_msg', payload: { + type: 'token_count', info: { total_token_usage: { input_tokens: 100, cached_input_tokens: 0, output_tokens: 20, total_tokens: 120 } }, + } }); + const sb = sandbox({ [`rollout-2026-07-24T09-00-00-${id}.jsonl`]: raw }); + const agg = await buildIndex(opts(sb)); + assert.equal(agg.totals.sessions, 0); + assert.equal(agg.sourceHealth.codex.status, 'degraded'); + assert.equal(agg.sourceHealth.codex.reason, 'parse-yield-zero'); + assert.deepEqual(agg.sourceHealth.codex.diagnostics.warnings, ['zero-response-yield']); +}); + +test('Codex source health exposes partial response yield across token-bearing files', async () => { + _resetForTest(); + const id = 'item-completed-partial'; + const line = (o) => `${JSON.stringify(o)}\n`; + const zero = line({ timestamp: T0, type: 'session_meta', payload: { id: 'zero-yield', cwd: '/Users/me/proj' } }) + + line({ timestamp: T0, type: 'event_msg', payload: { + type: 'token_count', info: { total_token_usage: { input_tokens: 100, cached_input_tokens: 0, output_tokens: 20, total_tokens: 120 } }, + } }); + const sb = sandbox({ + [`rollout-2026-07-24T09-00-00-${id}.jsonl`]: rollout(id), + 'rollout-2026-07-24T09-00-01-zero-yield.jsonl': zero, + }); + const agg = await buildIndex(opts(sb)); + assert.equal(agg.totals.sessions, 1); + assert.equal(agg.sourceHealth.codex.status, 'degraded'); + assert.equal(agg.sourceHealth.codex.reason, 'parse-yield-partial'); + assert.deepEqual(agg.sourceHealth.codex.diagnostics.warnings, ['partial-response-yield']); + assert.equal(agg.sourceHealth.codex.diagnostics.filesWithTokens, 2); + assert.equal(agg.sourceHealth.codex.diagnostics.filesWithResponses, 1); +}); + +test('schema 10 reparses a legacy Codex cache instead of trusting zero-turn records', async () => { + _resetForTest(); + const id = 'schema-bump-codex'; + const fileName = `rollout-2026-07-24T09-00-00-${id}.jsonl`; + const sb = sandbox({ [fileName]: rolloutItemCompleted(id) }); + await buildIndex(opts(sb)); + + const cache = JSON.parse(fs.readFileSync(sb.cachePath, 'utf8')); + const file = path.join(sb.roots.codex, '2026', '07', '24', fileName); + cache.schemaVersion = 9; + cache.entries[file].session.prompts = 0; + cache.entries[file].session.responses = 0; + cache.entries[file].session.usage = []; + fs.writeFileSync(sb.cachePath, JSON.stringify(cache)); + + _resetForTest(); + const agg = await buildIndex(opts(sb)); + const s = agg.sessions.find((x) => x.id === id); + assert.equal(s.prompts, 1); + assert.equal(s.responses, 1); + assert.equal(s.tokens, 120); + assert.equal(agg.sourceHealth.codex.diagnostics.cachedFiles, 0); + assert.equal(agg.sourceHealth.codex.diagnostics.parsedFiles, 1); +}); + test('reasoning tokens are annotation only — token totals are unchanged by them', async () => { _resetForTest(); const sb = sandbox({ 'rollout-2026-07-24T09-00-00-bbbb2222.jsonl': rollout('bbbb2222') }); From 6f85bdf360385a430d4128582977546a22d1556f Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Mon, 24 Aug 2026 12:55:42 -0700 Subject: [PATCH 2/3] feat(usage): expose parse health and active-day counts --- src/lib/dashboard/client.mjs | 8 +++++-- tests/kit/usage-index.test.mjs | 42 ++++++++++++++++++++++++++++++++-- 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/src/lib/dashboard/client.mjs b/src/lib/dashboard/client.mjs index bb09e53..a7c1ef9 100644 --- a/src/lib/dashboard/client.mjs +++ b/src/lib/dashboard/client.mjs @@ -1069,7 +1069,7 @@ export const JS = ` var grp=SOURCE_HEALTH_GROUPS[g],present=[]; for(var p=0; p' @@ -1190,7 +1193,8 @@ export const JS = ` document.getElementById("u-days-note").textContent="api-equivalent · "+usageDays+"-day window"; document.getElementById("u-daybars").innerHTML=days.length?days.map(function(x){ var c=fld(x.v,"cost"), h=maxDay?Math.max(2,c/maxDay*100):2; - var tip=x.day+" · "+fmtUsd(c)+" · "+fmtTok(fld(x.v,"tokens"))+" tok · "+fmtNum(fld(x.v,"sessions"))+" sessions"; + var tip=x.day+" · "+fmtUsd(c)+" · "+fmtTok(fld(x.v,"tokens"))+" tok · "+fmtNum(fld(x.v,"sessions"))+" started"; + if(x.v&&x.v.sessionsActive!==undefined) tip+=" · "+fmtNum(fld(x.v,"sessionsActive"))+" active"; return '
' +''+esc(x.day.slice(8))+"
"; }).join(""):'
no days in window.
'; diff --git a/tests/kit/usage-index.test.mjs b/tests/kit/usage-index.test.mjs index 355a3b1..697e4a1 100644 --- a/tests/kit/usage-index.test.mjs +++ b/tests/kit/usage-index.test.mjs @@ -671,6 +671,40 @@ test('a session that opens before midnight is counted on its first billed day', ); }); +test('byDay preserves first-billed sessions and adds active sessions for later token days', async () => { + _resetForTest(); + const sb = soloSandbox(); + const iso = (d) => new Date(d).toISOString(); + fs.writeFileSync(path.join(sb.claude, 'multi-day-7777.jsonl'), [ + JSON.stringify({ + type: 'user', sessionId: 'multi-day-7777', cwd: '/Users/me/proj', + timestamp: iso(new Date(2026, 6, 24, 23, 58)), + message: { role: 'user', content: [{ type: 'text', text: 'day one' }] }, + }), + JSON.stringify({ + type: 'assistant', sessionId: 'multi-day-7777', cwd: '/Users/me/proj', + timestamp: iso(new Date(2026, 6, 24, 23, 59)), + message: { role: 'assistant', model: 'claude-opus-5', usage: { input_tokens: 7, output_tokens: 3 }, content: [] }, + }), + JSON.stringify({ + type: 'user', sessionId: 'multi-day-7777', cwd: '/Users/me/proj', + timestamp: iso(new Date(2026, 6, 25, 0, 4)), + message: { role: 'user', content: [{ type: 'text', text: 'day two' }] }, + }), + JSON.stringify({ + type: 'assistant', sessionId: 'multi-day-7777', cwd: '/Users/me/proj', + timestamp: iso(new Date(2026, 6, 25, 0, 5)), + message: { role: 'assistant', model: 'claude-opus-5', usage: { input_tokens: 11, output_tokens: 5 }, content: [] }, + }), + ].join('\n') + '\n'); + + const agg = await buildIndex(opts(sb, { days: 30 })); + assert.equal(agg.byDay['2026-07-24'].sessions, 1); + assert.equal(agg.byDay['2026-07-24'].sessionsActive, 1); + assert.equal(agg.byDay['2026-07-25'].sessions, 0); + assert.equal(agg.byDay['2026-07-25'].sessionsActive, 1); +}); + test('a dropped-connection turn (isApiErrorMessage) counts as an exception, never a $0 model', async () => { // Claude Code synthesizes a local placeholder turn — model: "", // isApiErrorMessage: true, all-zero usage — when a request's connection @@ -810,7 +844,9 @@ test('an empty corpus yields a zeroed Aggregate rather than throwing', async () // A never-used host (root simply doesn't exist yet) reads as absent, not ok — // "zero sessions" and "we never found the directory" must stay distinguishable. assert.deepEqual(agg.sourceHealth.claude, { status: 'absent', reason: null }); - assert.deepEqual(agg.sourceHealth.codex, { status: 'absent', reason: null }); + assert.equal(agg.sourceHealth.codex.status, 'absent'); + assert.equal(agg.sourceHealth.codex.reason, null); + assert.equal(agg.sourceHealth.codex.diagnostics.files, 0); }); test('buildIndex reports ok claude/codex root health when the transcript roots exist', async () => { @@ -818,7 +854,9 @@ test('buildIndex reports ok claude/codex root health when the transcript roots e const sb = sandbox(); const agg = await buildIndex(opts(sb)); assert.deepEqual(agg.sourceHealth.claude, { status: 'ok', reason: null }); - assert.deepEqual(agg.sourceHealth.codex, { status: 'ok', reason: null }); + assert.equal(agg.sourceHealth.codex.status, 'ok'); + assert.equal(agg.sourceHealth.codex.reason, null); + assert.equal(agg.sourceHealth.codex.diagnostics.files, 1); }); test('an unreadable Claude root degrades rather than silently reading as zero sessions', async () => { From 27306c69e8fb561f5e92f8ba02fd2b246157ca93 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Mon, 24 Aug 2026 12:55:46 -0700 Subject: [PATCH 3/3] docs(issue-170): document compatibility and health contracts --- docs/TRANSCRIPTS.md | 53 ++++++------ docs/USAGE-SCORECARD-METRICS.md | 82 +++++++++++-------- ...ge-scorecard-local-transcript-analytics.md | 7 +- ...sed-operations-and-explicit-degradation.md | 5 +- 4 files changed, 84 insertions(+), 63 deletions(-) diff --git a/docs/TRANSCRIPTS.md b/docs/TRANSCRIPTS.md index 5298435..f98d6a9 100644 --- a/docs/TRANSCRIPTS.md +++ b/docs/TRANSCRIPTS.md @@ -36,8 +36,8 @@ rewritten; rule 3 of the module header, `usage-index.mjs:22-29`): | Host | Store | Discovered by | |---|---|---| -| Claude Code | `~/.claude/projects//.jsonl` | `listClaude` (`usage-index.mjs:786`) — exactly one level of project directories | -| Codex CLI | `~/.codex/sessions///
/rollout--.jsonl` | `listCodex` (`usage-index.mjs:758`) — the `yyyy/mm/dd` tree walk | +| Claude Code | `~/.claude/projects//.jsonl` | `listClaude` (`usage-index.mjs:876-886`) — exactly one level of project directories | +| Codex CLI | `~/.codex/sessions///
/rollout--.jsonl` | `listCodex` (`usage-index.mjs:891-904`) — the `yyyy/mm/dd` tree walk | Roots come from `defaultRoots()` (`usage-index.mjs:750-754`) and are injectable for tests. A malformed line is skipped, never fatal (`jsonLines`, @@ -78,14 +78,17 @@ Codex rollout lines carry `type` + `payload`. The parser (`parseCodex`, | `session_meta` | Authoritative session id, `cwd`, and `thread_source` (`usage-index.mjs:592-597`) — `"subagent"` marks a thread_spawn replay whose tokens are excluded from aggregation (`usage-index.mjs:662`; `USAGE-SCORECARD-METRICS.md` Appendix A, Bug B) | | `turn_context` | The model id in effect from this point on (`usage-index.mjs:598`) | | `event_msg` → `token_count` | A **cumulative** usage snapshot; only the last one is kept (`usage-index.mjs:662-664`) | -| `event_msg` → `user_message` | A real human prompt — Codex does not route tool output through this event (`usage-index.mjs:637-645`) | -| `event_msg` → `agent_message` | A model response (`usage-index.mjs:647-658`) | - -Codex tool calls and tool outputs travel in event types the parser does not -surface as turns at all — so a Codex transcript renders as a prompt/response -conversation without the tool-result interleaving a Claude transcript shows. -That is a fidelity gap (less detail), not an attribution bug (nothing is -mislabelled). +| `event_msg` → `user_message` | A legacy-format real human prompt — Codex does not route tool output through this event | +| `event_msg` → `agent_message` | A legacy-format model response | +| `event_msg` → `item_completed` → `UserMessage` | A current-format real human prompt; text blocks use the observed lowercase `text` discriminator | +| `event_msg` → `item_completed` → `AgentMessage` | A current-format model response; text blocks use the observed uppercase `Text` discriminator | + +The parser normalizes both message generations into the same prompt/response +turn model. Unknown `item_completed` item types are ignored for those metrics +and counted in Codex source diagnostics; they are not silently reclassified as +human prompts, model responses, or existing tool metrics. Codex tool calls and +tool outputs therefore still travel in event types the parser does not surface +as turns — a fidelity gap, not an attribution bug. --- @@ -95,8 +98,8 @@ The same parsers serve two very different callers, switched by `withTurns`: | Path | Entry point | `withTurns` | Message bodies | Cached? | |---|---|---|---|---| -| **Scan** — the aggregate index behind the Scorecard/Findings/Sessions views | `buildIndex` → `parseFile` (`usage-index.mjs:746`) | `false` | never held — holding them would balloon memory across 3,000+ files (`usage-index.mjs:490-493`) | yes: per-file derived records in `~/.config/agentic-kit/usage-index.json`, keyed `(path, mtime, size)`, invalidated wholesale by `SCHEMA_VERSION` (`usage-index.mjs:51`) | -| **Reader** — one transcript for the Transcript view | `readSession` (`usage-index.mjs:1363`) | `true` | full turn list built | **never** — every call re-reads and re-parses the one file | +| **Scan** — the aggregate index behind the Scorecard/Findings/Sessions views | `buildIndex` → `parseFile` (`usage-index.mjs:925`) | `false` | never held — holding them would balloon memory across 3,000+ files (`usage-index.mjs:490-493`) | yes: per-file derived records in `~/.config/agentic-kit/usage-index.json`, keyed `(path, mtime, size)`, invalidated wholesale by `SCHEMA_VERSION` (`usage-index.mjs:77`) | +| **Reader** — one transcript for the Transcript view | `readSession` (`usage-index.mjs:1566`) | `true` | full turn list built | **never** — every call re-reads and re-parses the one file | ![Figure: one parser, two read paths — the scan path (withTurns false) caches per-file records keyed by path, mtime and size; the reader path (withTurns true) builds full turns and is never cached](assets/transcript-read-paths.svg) @@ -117,7 +120,7 @@ recorded in `USAGE-SCORECARD-METRICS.md` Appendix A). |---|---|---| | `role` | all | `"user"` or `"assistant"` — the **Messages-API role**, not the author (see below) | | `at` | all | ISO timestamp | -| `text` | all | Flattened display text (`claudeText`, `usage-index.mjs:437` — binary payloads dropped: a pasted screenshot renders as `[image]`, a tool result is prefixed `[tool result]`) | +| `text` | all | Flattened display text (`claudeText`, `usage-index.mjs:459-479` — binary payloads dropped: a pasted screenshot renders as `[image]`, a tool result is prefixed `[tool result]`) | | `model` | assistant | The model id; the literal string `exception` for an API-error placeholder turn (`usage-index.mjs:526`) | | `tools` | assistant | Tool names invoked in the turn | | `prompt` | user | `isHumanPrompt`'s verdict (`usage-index.mjs:441-450`) — drives the **prompt counts** | @@ -175,42 +178,42 @@ and image-only pastes get the right kind" (the two edges). ## 4. The `readSession` pipeline — how one session becomes a payload -`readSession(id, opts)` (`usage-index.mjs:1457-1513`) is the only way +`readSession(id, opts)` (`usage-index.mjs:1566-1621`) is the only way transcript content leaves the module, and every step is a gate: ### 4.1 Locate, contain, bound 1. **Id grammar before any filesystem access** — `VALID_ID` - (`/^[A-Za-z0-9._-]{1,128}$/`, `usage-index.mjs:83`) rejects traversal - shapes with `ERR_INVALID_SESSION_ID` (`usage-index.mjs:1399-1406`). -2. **Locate by id** across both roots (`locate`, `usage-index.mjs:1409`), + (`/^[A-Za-z0-9._-]{1,128}$/`, `usage-index.mjs:95`) rejects traversal + shapes with `ERR_INVALID_SESSION_ID` (`usage-index.mjs:1508-1512`). +2. **Locate by id** across both roots (`locate`, `usage-index.mjs:1518`), consulting the scan cache when present but never requiring it — `readSession` works with no prior `buildIndex`. -3. **Realpath containment** (`usage-index.mjs:1388-1402`) — the resolved file +3. **Realpath containment** (`usage-index.mjs:1587-1601`) — the resolved file must live under a transcript root *after* `realpathSync` collapses symlinks; a symlink planted inside a root pointing at `/etc/anything` passes a lexical `startsWith` but fails this. Roots are realpath'd too so a symlinked dotfiles setup still works. -4. **Size cap** — `MAX_SESSION_BYTES` (64 MB, `usage-index.mjs:70`): a +4. **Size cap** — `MAX_SESSION_BYTES` (64 MB, `usage-index.mjs:90`): a transcript is read whole and JSON-expands ~5×, so an unbounded read is a memory-amplification primitive. Oversized reads as unavailable, not risky. ### 4.2 Parse and price The file is parsed with `withTurns: true` by the provider's parser -(`usage-index.mjs:1502-1509`), and `meta` is assembled -(`usage-index.mjs:1518-1545`) with the same fields the Sessions view rows +(`usage-index.mjs:1614-1618`), and `meta` is assembled +(`usage-index.mjs:1624-1653`) with the same fields the Sessions view rows carry — `prompts`, `responses`, `exceptions`, `sidechain`, `threadSource`, `models`, `tools`, `skill`/`plugin`, worktree — plus a `cost` priced from the same per-model usage rows `aggregate()` uses. ### 4.3 Mask, then truncate — both marked, differently -Every turn body is passed through `maskSecrets` (`usage-index.mjs:196` — the +Every turn body is passed through `maskSecrets` (`usage-index.mjs:208` — the 23 secret shapes) **server-side, before serialization**, then length-capped at `MAX_TURN_CHARS` (40,000, -`usage-index.mjs:77`) with the marker appended -(`usage-index.mjs:1552-1561`). Two invariants: +`usage-index.mjs:89`) with the marker appended +(`usage-index.mjs:1661-1670`). Two invariants: - **Presence is the signal.** `truncated`/`originalChars` are emitted only when the slice fired, so a complete turn cannot be misread as abridged. @@ -366,7 +369,7 @@ was wrong before, for the curious. assembled `meta` left `cost` undefined, and `fmtUsd(undefined)` renders the truthy string `"$0.00"` — a fixed-looking zero on a panel whose whole subject is cost. `meta.cost` is now priced via `sessionCost()` from the - same per-model usage rows `aggregate()` uses (`usage-index.mjs:1538-1542`). + same per-model usage rows `aggregate()` uses (`usage-index.mjs:1651`). - **Aggregate-side incidents** (the v4/v5 cache bumps, the Codex parsing defects) are recorded in `USAGE-SCORECARD-METRICS.md` Appendix A. diff --git a/docs/USAGE-SCORECARD-METRICS.md b/docs/USAGE-SCORECARD-METRICS.md index 0a0e32c..838f0dc 100644 --- a/docs/USAGE-SCORECARD-METRICS.md +++ b/docs/USAGE-SCORECARD-METRICS.md @@ -57,12 +57,12 @@ Every metric section below follows the same shape: Two transcript stores, read-only, parsed at most once per file (cache keyed by `(path, mtime, size)`; `SCHEMA_VERSION` invalidates the whole cache on a -schema change — `src/lib/usage-index.mjs:65`): +schema change — `src/lib/usage-index.mjs:10`): | Transcript host | Store | Format | |---|---|---| | Claude Code | `~/.claude/projects//.jsonl` | one JSON object per line: `user`/`assistant` turns, each assistant turn carrying its own `usage` object | -| Codex CLI | `~/.codex/sessions///
/rollout--.jsonl` | one JSON object per line: `session_meta`, `turn_context`, and `event_msg` records, the latter carrying **cumulative** `token_count` snapshots, not per-turn deltas | +| Codex CLI | `~/.codex/sessions///
/rollout--.jsonl` | one JSON object per line: `session_meta`, `turn_context`, and `event_msg` records; the latter carry **cumulative** `token_count` snapshots plus legacy messages or newer `item_completed` envelopes | The parsers are `parseClaude` (`usage-index.mjs:480-563`) and `parseCodex` (`usage-index.mjs:580-681`). Both are pure functions over the raw file bytes — @@ -94,6 +94,12 @@ tabbar — means a degraded, absent, or deliberately unread source stays visible regardless of which tab is active, and cannot be mistaken for healthy empty data. See [ADR-0023 §7](adr/0023-fail-closed-operations-and-explicit-degradation.md) for why the four fields are tracked to different degrees of external documentation. +Codex diagnostics additionally record files scanned, parse yield, token-bearing +files, response-bearing files, and unknown item types; a readable root with +token-bearing files but zero normalized responses is degraded as +`parse-yield-zero`, and a root where only some token-bearing files yield +responses is degraded as `parse-yield-partial`, rather than reported as healthy +empty or complete usage. The current persisted field named `provider` identifies which host transcript parser produced a session row; it is not sufficient evidence of the inference provider. The Proposed model in @@ -128,14 +134,14 @@ responses = Σ over included sessions of session.responses **Source:** - Filter: a parsed record with zero assistant turns is dropped entirely — "no - assistant turn → not a session" (`usage-index.mjs:988`) — and a record whose + assistant turn → not a session" (`usage-index.mjs:1083`) — and a record whose last activity falls outside the requested window is dropped too - (`usage-index.mjs:989`). + (`usage-index.mjs:1084`). - `responses` accumulation: Claude increments per assistant message - (`usage-index.mjs:557-562`); Codex increments per `agent_message` event - (`usage-index.mjs:704-708`). +(`usage-index.mjs:568-571`); Codex increments per `agent_message` event +(`usage-index.mjs:653-662`). - Totals: `totals.responses += s.responses` per included session - (`usage-index.mjs:937`). +(`usage-index.mjs:1175`). - Render: `kpi("sessions", fmtNum(t.sessions), fmtNum(t.responses)+" assistant turns", "")` (`dashboard/client.mjs`). @@ -213,7 +219,7 @@ already in effect on the given day, comparing ISO date strings lexicographically so no `Date` parsing is involved and the module stays clock-free. -`aggregate()` passes each usage row's own `day` (`usage-index.mjs:979`), which +`aggregate()` passes each usage row's own `day` (`usage-index.mjs:1100-1103`), which it already has because rows are keyed by `(day, model)`. **This is the whole point:** tokens metered in August must still read as August's rate when the panel is opened in December. Pricing by *today's* date instead would restate a @@ -295,7 +301,7 @@ tokens = input + output + cacheRead + cacheWrite (summed across all rows in wi ``` **Source:** `t.tokens` from `totals`, accumulated per row at -`usage-index.mjs:988` (`rowTokens = row.input + row.output + row.cacheRead + +`usage-index.mjs:1108` (`rowTokens = row.input + row.output + row.cacheRead + row.cacheWrite`) and rolled into `totals.tokens` via `addTo` (`usage-index.mjs:1080-1086`). Rendered with `fmtTok()` (`dashboard/client.mjs`): `≥1e9` → `"X.XB"`, `≥1e6` → `"X.XM"`, @@ -311,7 +317,7 @@ per row is **gross input minus cached input** — Claude's parser reads `cache_read_input_tokens` and `cache_creation_input_tokens` as separate fields the provider already reports separately (`usage-index.mjs:598-599`); Codex's parser subtracts `cached_input_tokens` from `input_tokens` explicitly -(`usage-index.mjs:719-728`, `input: Math.max(0, gross - cacheRead)`) because +(`usage-index.mjs:780-789`, `input: Math.max(0, gross - cacheRead)`) because Codex's own `input_tokens` field **includes** cached tokens and would double-count them against the separately-reported `cacheRead` figure if left as-is. This is asserted by test: @@ -405,15 +411,15 @@ session data, and each needs its own fix: sorts intervals and merges any two that overlap **or exactly touch** (`s <= curEnd`, `usage-index.mjs:105`), returning total covered seconds rounded to the nearest second. -- `activeIntervals()` (`usage-index.mjs:405-416`) — splits one session's +- `activeIntervals()` (`usage-index.mjs:427-438`) — splits one session's sorted timestamp list into sub-intervals wherever a gap exceeds `IDLE_GAP_MS`; "a run of one timestamp yields a zero-length interval and so contributes nothing" (comment, `usage-index.mjs:399-403`). - Aggregation: `totals.engagedSeconds = mergeIntervals(sessions.flatMap(s => - s._active))` (`usage-index.mjs:980`); `totals.spanUnionSeconds = - mergeIntervals(sessions.map(s => s._span))` (`usage-index.mjs:979`); + s._active))` (`usage-index.mjs:1219`); `totals.spanUnionSeconds = + mergeIntervals(sessions.map(s => s._span))` (`usage-index.mjs:1218`); `totals.spanMinutes` is a running sum of `s._span[1] - s._span[0]` across - the loop (`usage-index.mjs:941`, finalized `usage-index.mjs:978`). + the loop (`usage-index.mjs:1179`, finalized `usage-index.mjs:1217`). - Render: `fmtHours()` (`dashboard/client.mjs`, `≥10h` rounds to the nearest hour, else one decimal place) and `fmtMins()` (`dashboard/client.mjs`, `≥60min` rounds to hours, else whole @@ -452,26 +458,34 @@ rather than buried. **Displayed as:** the bar chart directly under the hero row, one bar per calendar day in the window, height proportional to that day's cost. Hovering -a bar shows `day · cost · tokens · sessions`. +a bar shows `day · cost · tokens · first-billed sessions · active sessions`. **Formula:** ```text byDay[day].cost = Σ costOf(row) for every usage row whose day == that key +byDay[day].sessions = count of sessions whose first billed usage row is that day +byDay[day].sessionsActive = count of distinct sessions with any usage row that day ``` **Source:** the day key is the row's own `row.day`, computed once at parse time as **local calendar day**, not UTC -(`usage-index.mjs:595`/`usage-index.mjs:732` call `localDay(at)`) — so a +(`usage-index.mjs:598`/`usage-index.mjs:784` call `localDay(at)`) — so a session that runs from 23:58 local to 00:05 local is billed to the day its *first* row landed on (test: `tests/kit/usage-index.test.mjs:634`, "a session that opens before midnight is counted on its first billed day"). Accumulation: -`byDay[row.day].cost += rowCost` (`usage-index.mjs:1020`). Bar height: +`byDay[row.day].cost += rowCost` (`usage-index.mjs:1111`). Bar height: `h = maxDay ? max(2, cost/maxDay*100) : 2` (`dashboard/client.mjs`) — every non-empty day gets a visually nonzero bar (floor of 2%), so a very cheap day is never rendered as invisible. +The existing `sessions` field remains first-billed-day attribution so its sum +continues to equal `totals.sessions`. `sessionsActive` is additive: a session +that has token-bearing rows on multiple days is counted once on each of those +days. Neither field claims that the session was continuously active for the +whole calendar day. + **What this does not model:** a day is only present in `byDay` if at least one usage row landed on it — a day with zero activity produces no bar at all (not a zero-height bar), which is why the chart in the reference screenshot @@ -528,9 +542,9 @@ punchcard[dow + "-" + hour] += 1 per assistant/agent_message response, at its ``` **Source:** incremented once per Claude assistant turn -(`usage-index.mjs:557-562`, keyed by `punchKey(at)`) and once per Codex -`agent_message` (`usage-index.mjs:704-708`), merged into the window-level -`punchcard` object per session (`usage-index.mjs:1053`). Cell intensity is +(`usage-index.mjs:568-571`, keyed by `punchKey(at)`) and once per Codex +`agent_message` (`usage-index.mjs:653-662`), merged into the window-level +`punchcard` object per session (`usage-index.mjs:1197`). Cell intensity is linear against the single busiest cell in the window: `v = pcMax ? n/pcMax : 0` (`dashboard/client.mjs`) — this is a **relative**, not absolute, scale, so the heatmap's brightest cell is always @@ -563,9 +577,9 @@ byModel[model].sessions = count of DISTINCT sessions whose s.models includes th ``` **Source:** cost/tokens/responses accumulate inside the usage-row loop -(`usage-index.mjs:949-975`); the `sessions` count is deliberately computed +(`usage-index.mjs:1089-1116`); the `sessions` count is deliberately computed **separately**, once per session over its `s.models` array -(`usage-index.mjs:951-956`) rather than inside the cost loop, precisely +(`usage-index.mjs:1190-1194`) rather than inside the cost loop, precisely **so that a model can appear in `byModel` — with a nonzero session count — even in a session that contributed zero cost/tokens/responses for that model.** This is not an edge case invented for this document: it is the @@ -575,11 +589,11 @@ excluded subagent-replay session still shows up as "used," at zero cost, rather than vanishing. `byModel[...].responses` is populated from `row.responses` -(`usage-index.mjs:1017`), which in turn comes from the `responses` field +(`usage-index.mjs:1114`), which in turn comes from the `responses` field passed into `addUsage()` at the call site — `1` per Claude assistant turn -(`usage-index.mjs:537-543`), or `rec.responses` (the session's whole response +(`usage-index.mjs:568-604`), or `rec.responses` (the session's whole response count) once per Codex session, passed at the single point Codex calls -`addUsage` (`usage-index.mjs:732-738`). +`addUsage` (`usage-index.mjs:784-790`). **Render:** `bar(name, fmtUsd(cost), fmtTok(tokens)+" · "+fmtNum(responses)+" resp", pct(cost, topModelCost), false)` (`dashboard/client.mjs`), @@ -601,7 +615,7 @@ time, someone was genuinely waiting on it — but it is never pushed into `rec.models` and `addUsage()` is never called for it, so it can no longer create a `byModel` row of any kind. It increments a separate `rec.exceptions` counter instead (`usage-index.mjs:523`), rolled up into -`totals.exceptions` (`usage-index.mjs:1048-1052`) and surfaced per-session +`totals.exceptions` (`usage-index.mjs:1177`) and surfaced per-session (`usage-index.mjs:973-987`, alongside the existing `sidechain`/`threadSource` flags — inspectable in the Sessions tab, never hidden). When `totals.exceptions > 0`, the panel header shows a small `"· N @@ -886,7 +900,7 @@ both credential-free for ak: `windowDurationMins: 10080` (the weekly). Windows are therefore keyed and labelled by duration (`windowLabel`, `quota.mjs:44`), never by slot name. The same rule applies to the historical snapshots parsed out of rollouts: the -normalizer at `usage-index.mjs:644-668` keeps a flat `windows` list keyed by +normalizer at `usage-index.mjs:729-746` keeps a flat `windows` list keyed by `window_minutes`. **Freshness is part of the number.** Both sides carry `fetchedAt`; the view @@ -911,13 +925,13 @@ Codex ≥0.140 maintains its own SQLite thread ledger (`~/.codex/state_N.sqlite` — the `N` is a migration generation, so `codexStateDb` (`codex-state.mjs:30`) globs and takes the newest). `readCodexState` (`:49`) reads per-thread `thread_source` (`user` vs `subagent`) plus `thread_spawn_edges`, and -`applyCodexLedger` (`usage-index.mjs:1317-1328`) overlays that onto parsed +`applyCodexLedger` (`usage-index.mjs:1469-1479`) overlays that onto parsed sessions: a ledger-identified subagent has its token usage stripped — its rollout replays the parent's entire token history (ccusage/ccusage#950 measured up to 91× inflation) — while the session record stays visible. The rollout's own `session_meta.thread_source` sniff remains as the fallback when the ledger is absent or migrated beyond recognition. Codex sessions also carry -`reasoningOutput` (`usage-index.mjs:739-740`) — reasoning tokens are a **subset** +`reasoningOutput` (`usage-index.mjs:794`) — reasoning tokens are a **subset** of output tokens and are annotation only, never added to any sum. ## 14. Known limitations, restated as a single checklist @@ -962,14 +976,14 @@ commit `540be18` on this branch. `parseCodex`'s single `addUsage()` call never included a `responses` field — Claude's parser passes `responses: 1` per assistant turn -(`usage-index.mjs:556`, the current equivalent), but Codex's call +(`usage-index.mjs:598`, the current equivalent), but Codex's call passed no such field at all. Because `byModel[model].responses` is summed -directly from each usage row's `responses` field (`usage-index.mjs:1022`, +directly from each usage row's `responses` field (`usage-index.mjs:1114`, `m.responses += row.responses`), **every** Codex model in §10's "Models in Play" list displayed `0 resp` regardless of real token/cost volume or actual `agent_message` count. **Fix:** `parseCodex` now passes `responses: rec.responses` (the session's own tallied response count, -`usage-index.mjs:707`) on its `addUsage()` call. +`usage-index.mjs:765`) on its `addUsage()` call. #### Bug B — subagent thread-replay could double-bill tokens @@ -989,9 +1003,9 @@ at face value (correctly avoiding the separate naive-summing bug **[C5]** documents, since it already used last-event-only logic — see §4's worked example) but performed **no de-duplication** against a parent session a subagent file might be replaying. **Fix:** the parser now reads -`session_meta.thread_source` (`usage-index.mjs:628-632`, confirmed as a real +`session_meta.thread_source` (`usage-index.mjs:697-704`, confirmed as a real Codex rollout field by **[C7]**) and skips the `addUsage()` call entirely -when its value is `'subagent'` (`usage-index.mjs:662`, guard condition +when its value is `'subagent'` (`usage-index.mjs:780-790`, guard condition `rec.threadSource !== 'subagent'`). The session record itself is **not** dropped — it remains visible in the Sessions tab with `threadSource` surfaced (mirroring the existing `sidechain` flag Claude sessions already diff --git a/docs/adr/0009-usage-scorecard-local-transcript-analytics.md b/docs/adr/0009-usage-scorecard-local-transcript-analytics.md index 4ba12a7..3bb6697 100644 --- a/docs/adr/0009-usage-scorecard-local-transcript-analytics.md +++ b/docs/adr/0009-usage-scorecard-local-transcript-analytics.md @@ -2,8 +2,11 @@ - **Status:** Implemented - **Date:** 2026-07-25 -- **Updated:** 2026-08-04 -- **Update note:** Added the explicit OpenRouter account-analytics cache boundary for issue #59, +- **Updated:** 2026-08-24 +- **Update note:** Issue #170 added backward-compatible parsing for legacy Codex messages and + `item_completed` envelopes, bumped the derived-index schema to force reparse of stale zero-turn + records, added Codex parse-yield diagnostics, and separated first-billed-day session counts from + token-bearing active-day counts. The earlier OpenRouter account-analytics cache boundary for issue #59, aligned Usage with the dashboard's shared three-area navigation, and documented independent host, inference-provider, provenance, and model facts in session rows. ADR-0023 subsequently classified SQLite source failures and made transient OpenCode failures preserve last-good records diff --git a/docs/adr/0023-fail-closed-operations-and-explicit-degradation.md b/docs/adr/0023-fail-closed-operations-and-explicit-degradation.md index d81d674..d7ca4a5 100644 --- a/docs/adr/0023-fail-closed-operations-and-explicit-degradation.md +++ b/docs/adr/0023-fail-closed-operations-and-explicit-degradation.md @@ -1,9 +1,10 @@ # ADR-0023 — Fail-closed mutations and explicit degraded operation evidence - **Status:** Implemented -- **Updated:** 2026-08-07 — §9 permanently unmeasurable quantities, §10 stated exclusions +- **Updated:** 2026-08-24 — issue #170 parser-yield diagnostics distinguish readable Codex roots from + readable roots whose transcript schema produces no normalized responses - **Date:** 2026-08-04 -- **Updated:** 2026-08-06 +- **Previous update:** 2026-08-06 - **Update note:** Generalized setup preflight into a required host-adapter trust contract, added Codex registration/OpenCode approval disclosure, documented current-UID installation-mode boundaries, and surfaced usage-source health in the dashboard UI. Closed a parity gap §7 left