Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 69 additions & 2 deletions src/doctor.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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<ClaudeRetentionNote | undefined> {
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<string, unknown> | 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 {
Expand Down Expand Up @@ -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')
Expand Down
54 changes: 54 additions & 0 deletions tests/doctor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>): Promise<void> {
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')
})
})
})