doctor: surface Claude Code transcript retention (#757)

Claude Code deletes session transcripts after cleanupPeriodDays (default
30) at startup. Daily totals survive in the daily cache, but per-session
detail older than the cutoff is unrecoverable, and most users learn this
only after losing months of history. Doctor now reports the effective
retention and, below one year, warns with the exact settings.json edit
that preserves transcripts.
This commit is contained in:
Resham Joshi 2026-07-20 05:25:12 -07:00 committed by GitHub
parent 380be66b4f
commit e99ee4bf37
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 123 additions and 2 deletions

View file

@ -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<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 {
@ -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')

View file

@ -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')
})
})
})