diff --git a/src/doctor.ts b/src/doctor.ts index 02974da4..818da52a 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -1,8 +1,10 @@ import { existsSync } from 'fs' -import { dirname } from 'path' +import { readFile } from 'fs/promises' +import { dirname, join } from 'path' import { Chalk } from 'chalk' +import { getClaudeConfigDirs } from './providers/claude.js' import { getAllProviders } from './providers/index.js' import type { Provider } from './providers/types.js' import { @@ -56,9 +58,23 @@ export type DoctorProviderReport = { error?: string } +export type ClaudeRetentionNote = { + /// Effective transcript retention in days. Claude Code deletes session + /// files older than cleanupPeriodDays at startup; 30 is its default when + /// the setting is absent. + effectiveDays: number + /// True when cleanupPeriodDays is explicitly set in settings.json. + configured: boolean + settingsPath: string +} + export type DoctorReport = { generatedAt: string providers: DoctorProviderReport[] + /// Present when the Claude provider is in the report and a config dir was + /// found. Surfaced because deleted transcripts are unrecoverable: daily + /// totals survive in CodeBurn's cache, but per-session detail does not. + claudeRetention?: ClaudeRetentionNote } export type CollectDoctorOptions = { @@ -283,13 +299,48 @@ export async function collectDoctorReport( } providers.sort((a, b) => (a.displayName < b.displayName ? -1 : a.displayName > b.displayName ? 1 : 0)) - return { generatedAt: new Date().toISOString(), providers } + const report: DoctorReport = { generatedAt: new Date().toISOString(), providers } + if (providers.some(p => p.provider === 'claude')) { + const retention = await collectClaudeRetention() + if (retention) report.claudeRetention = retention + } + return report } finally { if (prevSuppress === undefined) delete process.env['CODEBURN_SUPPRESS_CACHE_WRITES'] else process.env['CODEBURN_SUPPRESS_CACHE_WRITES'] = prevSuppress } } +// Claude Code's documented default when cleanupPeriodDays is absent. +const CLAUDE_DEFAULT_CLEANUP_DAYS = 30 +// Below this, long-horizon views depend entirely on CodeBurn's daily cache; +// the doctor line turns into a warning. +const CLAUDE_RETENTION_WARN_DAYS = 365 + +async function collectClaudeRetention(): Promise { + for (const dir of await getClaudeConfigDirs()) { + const settingsPath = join(dir, 'settings.json') + let raw: string + try { + raw = await readFile(settingsPath, 'utf-8') + } catch { + continue + } + try { + const parsed: unknown = JSON.parse(raw) + const days = (parsed as Record | null)?.['cleanupPeriodDays'] + if (typeof days === 'number' && Number.isFinite(days)) { + return { effectiveDays: days, configured: true, settingsPath } + } + return { effectiveDays: CLAUDE_DEFAULT_CLEANUP_DAYS, configured: false, settingsPath } + } catch { + // Unparseable settings: report the default; Claude Code would apply it too. + return { effectiveDays: CLAUDE_DEFAULT_CLEANUP_DAYS, configured: false, settingsPath } + } + } + return undefined +} + // ── Render ──────────────────────────────────────────────────────────────── export function renderDoctorJson(report: DoctorReport): string { @@ -364,6 +415,22 @@ export function renderDoctorTable( } } + if (report.claudeRetention) { + const r = report.claudeRetention + const source = r.configured ? 'cleanupPeriodDays' : 'cleanupPeriodDays not set; Claude Code default' + const line = `Claude Code deletes transcripts after ${r.effectiveDays} day${r.effectiveDays === 1 ? '' : 's'} (${source}).` + out.push('') + if (r.effectiveDays < CLAUDE_RETENTION_WARN_DAYS) { + out.push( + c.yellow(line) + ' ' + + `Daily totals survive in CodeBurn's cache, but per-session detail older than that is gone for good. ` + + `To keep it, set "cleanupPeriodDays": 3650 in ${r.settingsPath}.`, + ) + } else { + out.push(c.dim(line + ' Long transcript retention: per-session detail is preserved.')) + } + } + out.push('') const broken = report.providers.filter(r => r.status === 'error' || r.status === 'errors') const empty = report.providers.filter(r => r.status === 'empty') diff --git a/tests/doctor.test.ts b/tests/doctor.test.ts index ce4c15da..d8ca4113 100644 --- a/tests/doctor.test.ts +++ b/tests/doctor.test.ts @@ -328,3 +328,57 @@ describe('doctor is inert', () => { } }) }) + +// ── Claude transcript retention note ─────────────────────────────────────── +describe('collectDoctorReport - claude retention note', () => { + async function withClaudeConfig(settings: string | null, fn: () => Promise): Promise { + const prev = process.env['CLAUDE_CONFIG_DIR'] + const prevMulti = process.env['CLAUDE_CONFIG_DIRS'] + delete process.env['CLAUDE_CONFIG_DIRS'] + const dir = join(tmpDir, 'claude-config') + await mkdir(dir, { recursive: true }) + if (settings !== null) await writeFile(join(dir, 'settings.json'), settings) + process.env['CLAUDE_CONFIG_DIR'] = dir + try { + await fn() + } finally { + if (prev === undefined) delete process.env['CLAUDE_CONFIG_DIR'] + else process.env['CLAUDE_CONFIG_DIR'] = prev + if (prevMulti !== undefined) process.env['CLAUDE_CONFIG_DIRS'] = prevMulti + } + } + + it('reports an explicit cleanupPeriodDays and renders it as preserved when long', async () => { + await withClaudeConfig(JSON.stringify({ cleanupPeriodDays: 3650 }), async () => { + const provider = fakeProvider({ name: 'claude' }) + const report = await collectDoctorReport('all', { providers: [provider], cache: emptyCache() }) + expect(report.claudeRetention).toMatchObject({ effectiveDays: 3650, configured: true }) + const table = renderDoctorTable(report, { color: false }) + expect(table).toContain('deletes transcripts after 3650 days') + expect(table).toContain('per-session detail is preserved') + }) + }) + + it('reports the 30-day default and renders a warning with the settings path', async () => { + await withClaudeConfig(JSON.stringify({ theme: 'dark' }), async () => { + const provider = fakeProvider({ name: 'claude' }) + const report = await collectDoctorReport('all', { providers: [provider], cache: emptyCache() }) + expect(report.claudeRetention).toMatchObject({ effectiveDays: 30, configured: false }) + const table = renderDoctorTable(report, { color: false }) + expect(table).toContain('deletes transcripts after 30 days') + expect(table).toContain('cleanupPeriodDays not set') + expect(table).toContain('"cleanupPeriodDays": 3650') + expect(table).toContain(report.claudeRetention!.settingsPath) + }) + }) + + it('emits no note when claude is not in the report or has no settings file', async () => { + await withClaudeConfig(null, async () => { + const claudeless = await collectDoctorReport('all', { providers: [fakeProvider({ name: 'codex' })], cache: emptyCache() }) + expect(claudeless.claudeRetention).toBeUndefined() + const noSettings = await collectDoctorReport('all', { providers: [fakeProvider({ name: 'claude' })], cache: emptyCache() }) + expect(noSettings.claudeRetention).toBeUndefined() + expect(renderDoctorTable(noSettings, { color: false })).not.toContain('deletes transcripts') + }) + }) +})