Something went wrong. Try again.
source dump of claude code forked from oppi.li/claude-code
Something went wrong. Try again.
30 kB · 961 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962import { createHash, randomUUID, type UUID } from 'crypto'import { stat } from 'fs/promises'import { isAbsolute, join, relative, sep } from 'path'import { getOriginalCwd, getSessionId } from '../bootstrap/state.js'import type { AttributionSnapshotMessage, FileAttributionState,} from '../types/logs.js'import { getCwd } from './cwd.js'import { logForDebugging } from './debug.js'import { execFileNoThrowWithCwd } from './execFileNoThrow.js'import { getFsImplementation } from './fsOperations.js'import { isGeneratedFile } from './generatedFiles.js'import { getRemoteUrlForDir, resolveGitDir } from './git/gitFilesystem.js'import { findGitRoot, gitExe } from './git.js'import { logError } from './log.js'import { getCanonicalName, type ModelName } from './model/model.js'import { sequential } from './sequential.js'
/** * List of repos where internal model names are allowed in trailers. * Includes both SSH and HTTPS URL formats. * * NOTE: This is intentionally a repo allowlist, not an org-wide check. * The anthropics and anthropic-experimental orgs contain PUBLIC repos * (e.g. anthropics/claude-code, anthropic-experimental/sandbox-runtime). * Undercover mode must stay ON in those to prevent codename leaks. * Only add repos here that are confirmed PRIVATE. */const INTERNAL_MODEL_REPOS = [ 'github.com:anthropics/claude-cli-internal', 'github.com/anthropics/claude-cli-internal', 'github.com:anthropics/anthropic', 'github.com/anthropics/anthropic', 'github.com:anthropics/apps', 'github.com/anthropics/apps', 'github.com:anthropics/casino', 'github.com/anthropics/casino', 'github.com:anthropics/dbt', 'github.com/anthropics/dbt', 'github.com:anthropics/dotfiles', 'github.com/anthropics/dotfiles', 'github.com:anthropics/terraform-config', 'github.com/anthropics/terraform-config', 'github.com:anthropics/hex-export', 'github.com/anthropics/hex-export', 'github.com:anthropics/feedback-v2', 'github.com/anthropics/feedback-v2', 'github.com:anthropics/labs', 'github.com/anthropics/labs', 'github.com:anthropics/argo-rollouts', 'github.com/anthropics/argo-rollouts', 'github.com:anthropics/starling-configs', 'github.com/anthropics/starling-configs', 'github.com:anthropics/ts-tools', 'github.com/anthropics/ts-tools', 'github.com:anthropics/ts-capsules', 'github.com/anthropics/ts-capsules', 'github.com:anthropics/feldspar-testing', 'github.com/anthropics/feldspar-testing', 'github.com:anthropics/trellis', 'github.com/anthropics/trellis', 'github.com:anthropics/claude-for-hiring', 'github.com/anthropics/claude-for-hiring', 'github.com:anthropics/forge-web', 'github.com/anthropics/forge-web', 'github.com:anthropics/infra-manifests', 'github.com/anthropics/infra-manifests', 'github.com:anthropics/mycro_manifests', 'github.com/anthropics/mycro_manifests', 'github.com:anthropics/mycro_configs', 'github.com/anthropics/mycro_configs', 'github.com:anthropics/mobile-apps', 'github.com/anthropics/mobile-apps',]
/** * Get the repo root for attribution operations. * Uses getCwd() which respects agent worktree overrides (AsyncLocalStorage), * then resolves to git root to handle `cd subdir` case. * Falls back to getOriginalCwd() if git root can't be determined. */export function getAttributionRepoRoot(): string { const cwd = getCwd() return findGitRoot(cwd) ?? getOriginalCwd()}
// Cache for repo classification result. Primed once per process.// 'internal' = remote matches INTERNAL_MODEL_REPOS allowlist// 'external' = has a remote, not on allowlist (public/open-source repo)// 'none' = no remote URL (not a git repo, or no remote configured)let repoClassCache: 'internal' | 'external' | 'none' | null = null
/** * Synchronously return the cached repo classification. * Returns null if the async check hasn't run yet. */export function getRepoClassCached(): 'internal' | 'external' | 'none' | null { return repoClassCache}
/** * Synchronously return the cached result of isInternalModelRepo(). * Returns false if the check hasn't run yet (safe default: don't leak). */export function isInternalModelRepoCached(): boolean { return repoClassCache === 'internal'}
/** * Check if the current repo is in the allowlist for internal model names. * Memoized - only checks once per process. */export const isInternalModelRepo = sequential(async (): Promise<boolean> => { if (repoClassCache !== null) { return repoClassCache === 'internal' }
const cwd = getAttributionRepoRoot() const remoteUrl = await getRemoteUrlForDir(cwd)
if (!remoteUrl) { repoClassCache = 'none' return false } const isInternal = INTERNAL_MODEL_REPOS.some(repo => remoteUrl.includes(repo)) repoClassCache = isInternal ? 'internal' : 'external' return isInternal})
/** * Sanitize a surface key to use public model names. * Converts internal model variants to their public equivalents. */export function sanitizeSurfaceKey(surfaceKey: string): string { // Split surface key into surface and model parts (e.g., "cli/opus-4-5-fast" -> ["cli", "opus-4-5-fast"]) const slashIndex = surfaceKey.lastIndexOf('/') if (slashIndex === -1) { return surfaceKey }
const surface = surfaceKey.slice(0, slashIndex) const model = surfaceKey.slice(slashIndex + 1) const sanitizedModel = sanitizeModelName(model)
return `${surface}/${sanitizedModel}`}
// @[MODEL LAUNCH]: Add a mapping for the new model ID so git commit trailers show the public name./** * Sanitize a model name to its public equivalent. * Maps internal variants to their public names based on model family. */export function sanitizeModelName(shortName: string): string { // Map internal variants to public equivalents based on model family if (shortName.includes('opus-4-6')) return 'claude-opus-4-6' if (shortName.includes('opus-4-5')) return 'claude-opus-4-5' if (shortName.includes('opus-4-1')) return 'claude-opus-4-1' if (shortName.includes('opus-4')) return 'claude-opus-4' if (shortName.includes('sonnet-4-6')) return 'claude-sonnet-4-6' if (shortName.includes('sonnet-4-5')) return 'claude-sonnet-4-5' if (shortName.includes('sonnet-4')) return 'claude-sonnet-4' if (shortName.includes('sonnet-3-7')) return 'claude-sonnet-3-7' if (shortName.includes('haiku-4-5')) return 'claude-haiku-4-5' if (shortName.includes('haiku-3-5')) return 'claude-haiku-3-5' // Unknown models get a generic name return 'claude'}
/** * Attribution state for tracking Claude's contributions to files. */export type AttributionState = { // File states keyed by relative path (from cwd) fileStates: Map<string, FileAttributionState> // Session baseline states for net change calculation sessionBaselines: Map<string, { contentHash: string; mtime: number }> // Surface from which edits were made surface: string // HEAD SHA at session start (for detecting external commits) startingHeadSha: string | null // Total prompts in session (for steer count calculation) promptCount: number // Prompts at last commit (to calculate steers for current commit) promptCountAtLastCommit: number // Permission prompt tracking permissionPromptCount: number permissionPromptCountAtLastCommit: number // ESC press tracking (user cancelled permission prompt) escapeCount: number escapeCountAtLastCommit: number}
/** * Summary of Claude's contribution for a commit. */export type AttributionSummary = { claudePercent: number claudeChars: number humanChars: number surfaces: string[]}
/** * Per-file attribution details for git notes. */export type FileAttribution = { claudeChars: number humanChars: number percent: number surface: string}
/** * Full attribution data for git notes JSON. */export type AttributionData = { version: 1 summary: AttributionSummary files: Record<string, FileAttribution> surfaceBreakdown: Record<string, { claudeChars: number; percent: number }> excludedGenerated: string[] sessions: string[]}
/** * Get the current client surface from environment. */export function getClientSurface(): string { return process.env.CLAUDE_CODE_ENTRYPOINT ?? 'cli'}
/** * Build a surface key that includes the model name. * Format: "surface/model" (e.g., "cli/claude-sonnet") */export function buildSurfaceKey(surface: string, model: ModelName): string { return `${surface}/${getCanonicalName(model)}`}
/** * Compute SHA-256 hash of content. */export function computeContentHash(content: string): string { return createHash('sha256').update(content).digest('hex')}
/** * Normalize file path to relative path from cwd for consistent tracking. * Resolves symlinks to handle /tmp vs /private/tmp on macOS. */export function normalizeFilePath(filePath: string): string { const fs = getFsImplementation() const cwd = getAttributionRepoRoot()
if (!isAbsolute(filePath)) { return filePath }
// Resolve symlinks in both paths for consistent comparison // (e.g., /tmp -> /private/tmp on macOS) let resolvedPath = filePath let resolvedCwd = cwd
try { resolvedPath = fs.realpathSync(filePath) } catch { // File may not exist yet, use original path }
try { resolvedCwd = fs.realpathSync(cwd) } catch { // Keep original cwd }
if ( resolvedPath.startsWith(resolvedCwd + sep) || resolvedPath === resolvedCwd ) { // Normalize to forward slashes so keys match git diff output on Windows return relative(resolvedCwd, resolvedPath).replaceAll(sep, '/') }
// Fallback: try original comparison if (filePath.startsWith(cwd + sep) || filePath === cwd) { return relative(cwd, filePath).replaceAll(sep, '/') }
return filePath}
/** * Expand a relative path to absolute path. */export function expandFilePath(filePath: string): string { if (isAbsolute(filePath)) { return filePath } return join(getAttributionRepoRoot(), filePath)}
/** * Create an empty attribution state for a new session. */export function createEmptyAttributionState(): AttributionState { return { fileStates: new Map(), sessionBaselines: new Map(), surface: getClientSurface(), startingHeadSha: null, promptCount: 0, promptCountAtLastCommit: 0, permissionPromptCount: 0, permissionPromptCountAtLastCommit: 0, escapeCount: 0, escapeCountAtLastCommit: 0, }}
/** * Compute the character contribution for a file modification. * Returns the FileAttributionState to store, or null if tracking failed. */function computeFileModificationState( existingFileStates: Map<string, FileAttributionState>, filePath: string, oldContent: string, newContent: string, mtime: number,): FileAttributionState | null { const normalizedPath = normalizeFilePath(filePath)
try { // Calculate Claude's character contribution let claudeContribution: number
if (oldContent === '' || newContent === '') { // New file or full deletion - contribution is the content length claudeContribution = oldContent === '' ? newContent.length : oldContent.length } else { // Find actual changed region via common prefix/suffix matching. // This correctly handles same-length replacements (e.g., "Esc" → "esc") // where Math.abs(newLen - oldLen) would be 0. const minLen = Math.min(oldContent.length, newContent.length) let prefixEnd = 0 while ( prefixEnd < minLen && oldContent[prefixEnd] === newContent[prefixEnd] ) { prefixEnd++ } let suffixLen = 0 while ( suffixLen < minLen - prefixEnd && oldContent[oldContent.length - 1 - suffixLen] === newContent[newContent.length - 1 - suffixLen] ) { suffixLen++ } const oldChangedLen = oldContent.length - prefixEnd - suffixLen const newChangedLen = newContent.length - prefixEnd - suffixLen claudeContribution = Math.max(oldChangedLen, newChangedLen) }
// Get current file state if it exists const existingState = existingFileStates.get(normalizedPath) const existingContribution = existingState?.claudeContribution ?? 0
return { contentHash: computeContentHash(newContent), claudeContribution: existingContribution + claudeContribution, mtime, } } catch (error) { logError(error as Error) return null }}
/** * Get a file's modification time (mtimeMs), falling back to Date.now() if * the file doesn't exist. This is async so it can be precomputed before * entering a sync setAppState callback. */export async function getFileMtime(filePath: string): Promise<number> { const normalizedPath = normalizeFilePath(filePath) const absPath = expandFilePath(normalizedPath) try { const stats = await stat(absPath) return stats.mtimeMs } catch { return Date.now() }}
/** * Track a file modification by Claude. * Called after Edit/Write tool completes. */export function trackFileModification( state: AttributionState, filePath: string, oldContent: string, newContent: string, _userModified: boolean, mtime: number = Date.now(),): AttributionState { const normalizedPath = normalizeFilePath(filePath) const newFileState = computeFileModificationState( state.fileStates, filePath, oldContent, newContent, mtime, ) if (!newFileState) { return state }
const newFileStates = new Map(state.fileStates) newFileStates.set(normalizedPath, newFileState)
logForDebugging( `Attribution: Tracked ${newFileState.claudeContribution} chars for ${normalizedPath}`, )
return { ...state, fileStates: newFileStates, }}
/** * Track a file creation by Claude (e.g., via bash command). * Used when Claude creates a new file through a non-tracked mechanism. */export function trackFileCreation( state: AttributionState, filePath: string, content: string, mtime: number = Date.now(),): AttributionState { // A creation is simply a modification from empty to the new content return trackFileModification(state, filePath, '', content, false, mtime)}
/** * Track a file deletion by Claude (e.g., via bash rm command). * Used when Claude deletes a file through a non-tracked mechanism. */export function trackFileDeletion( state: AttributionState, filePath: string, oldContent: string,): AttributionState { const normalizedPath = normalizeFilePath(filePath) const existingState = state.fileStates.get(normalizedPath) const existingContribution = existingState?.claudeContribution ?? 0 const deletedChars = oldContent.length
const newFileState: FileAttributionState = { contentHash: '', // Empty hash for deleted files claudeContribution: existingContribution + deletedChars, mtime: Date.now(), }
const newFileStates = new Map(state.fileStates) newFileStates.set(normalizedPath, newFileState)
logForDebugging( `Attribution: Tracked deletion of ${normalizedPath} (${deletedChars} chars removed, total contribution: ${newFileState.claudeContribution})`, )
return { ...state, fileStates: newFileStates, }}
// --
/** * Track multiple file changes in bulk, mutating a single Map copy. * This avoids the O(n²) cost of copying the Map per file when processing * large git diffs (e.g., jj operations that touch hundreds of thousands of files). */export function trackBulkFileChanges( state: AttributionState, changes: ReadonlyArray<{ path: string type: 'modified' | 'created' | 'deleted' oldContent: string newContent: string mtime?: number }>,): AttributionState { // Create ONE copy of the Map, then mutate it for each file const newFileStates = new Map(state.fileStates)
for (const change of changes) { const mtime = change.mtime ?? Date.now() if (change.type === 'deleted') { const normalizedPath = normalizeFilePath(change.path) const existingState = newFileStates.get(normalizedPath) const existingContribution = existingState?.claudeContribution ?? 0 const deletedChars = change.oldContent.length
newFileStates.set(normalizedPath, { contentHash: '', claudeContribution: existingContribution + deletedChars, mtime, })
logForDebugging( `Attribution: Tracked deletion of ${normalizedPath} (${deletedChars} chars removed, total contribution: ${existingContribution + deletedChars})`, ) } else { const newFileState = computeFileModificationState( newFileStates, change.path, change.oldContent, change.newContent, mtime, ) if (newFileState) { const normalizedPath = normalizeFilePath(change.path) newFileStates.set(normalizedPath, newFileState)
logForDebugging( `Attribution: Tracked ${newFileState.claudeContribution} chars for ${normalizedPath}`, ) } } }
return { ...state, fileStates: newFileStates, }}
/** * Calculate final attribution for staged files. * Compares session baseline to committed state. */export async function calculateCommitAttribution( states: AttributionState[], stagedFiles: string[],): Promise<AttributionData> { const cwd = getAttributionRepoRoot() const sessionId = getSessionId()
const files: Record<string, FileAttribution> = {} const excludedGenerated: string[] = [] const surfaces = new Set<string>() const surfaceCounts: Record<string, number> = {}
let totalClaudeChars = 0 let totalHumanChars = 0
// Merge file states from all sessions const mergedFileStates = new Map<string, FileAttributionState>() const mergedBaselines = new Map< string, { contentHash: string; mtime: number } >()
for (const state of states) { surfaces.add(state.surface)
// Merge baselines (earliest baseline wins) // Handle both Map and plain object (in case of serialization) const baselines = state.sessionBaselines instanceof Map ? state.sessionBaselines : new Map( Object.entries( (state.sessionBaselines ?? {}) as Record< string, { contentHash: string; mtime: number } >, ), ) for (const [path, baseline] of baselines) { if (!mergedBaselines.has(path)) { mergedBaselines.set(path, baseline) } }
// Merge file states (accumulate contributions) // Handle both Map and plain object (in case of serialization) const fileStates = state.fileStates instanceof Map ? state.fileStates : new Map( Object.entries( (state.fileStates ?? {}) as Record<string, FileAttributionState>, ), ) for (const [path, fileState] of fileStates) { const existing = mergedFileStates.get(path) if (existing) { mergedFileStates.set(path, { ...fileState, claudeContribution: existing.claudeContribution + fileState.claudeContribution, }) } else { mergedFileStates.set(path, fileState) } } }
// Process files in parallel const fileResults = await Promise.all( stagedFiles.map(async file => { // Skip generated files if (isGeneratedFile(file)) { return { type: 'generated' as const, file } }
const absPath = join(cwd, file) const fileState = mergedFileStates.get(file) const baseline = mergedBaselines.get(file)
// Get the surface for this file const fileSurface = states[0]!.surface
let claudeChars = 0 let humanChars = 0
// Check if file was deleted const deleted = await isFileDeleted(file)
if (deleted) { // File was deleted if (fileState) { // Claude deleted this file (tracked deletion) claudeChars = fileState.claudeContribution humanChars = 0 } else { // Human deleted this file (untracked deletion) // Use diff size to get the actual change size const diffSize = await getGitDiffSize(file) humanChars = diffSize > 0 ? diffSize : 100 // Minimum attribution for a deletion } } else { try { // Only need file size, not content - stat() avoids loading GB-scale // build artifacts into memory when they appear in the working tree. // stats.size (bytes) is an adequate proxy for char count here. const stats = await stat(absPath)
if (fileState) { // We have tracked modifications for this file claudeChars = fileState.claudeContribution humanChars = 0 } else if (baseline) { // File was modified but not tracked - human modification const diffSize = await getGitDiffSize(file) humanChars = diffSize > 0 ? diffSize : stats.size } else { // New file not created by Claude humanChars = stats.size } } catch { // File doesn't exist or stat failed - skip it return null } }
// Ensure non-negative values claudeChars = Math.max(0, claudeChars) humanChars = Math.max(0, humanChars)
const total = claudeChars + humanChars const percent = total > 0 ? Math.round((claudeChars / total) * 100) : 0
return { type: 'file' as const, file, claudeChars, humanChars, percent, surface: fileSurface, } }), )
// Aggregate results for (const result of fileResults) { if (!result) continue
if (result.type === 'generated') { excludedGenerated.push(result.file) continue }
files[result.file] = { claudeChars: result.claudeChars, humanChars: result.humanChars, percent: result.percent, surface: result.surface, }
totalClaudeChars += result.claudeChars totalHumanChars += result.humanChars
surfaceCounts[result.surface] = (surfaceCounts[result.surface] ?? 0) + result.claudeChars }
const totalChars = totalClaudeChars + totalHumanChars const claudePercent = totalChars > 0 ? Math.round((totalClaudeChars / totalChars) * 100) : 0
// Calculate surface breakdown (percentage of total content per surface) const surfaceBreakdown: Record< string, { claudeChars: number; percent: number } > = {} for (const [surface, chars] of Object.entries(surfaceCounts)) { // Calculate what percentage of TOTAL content this surface contributed const percent = totalChars > 0 ? Math.round((chars / totalChars) * 100) : 0 surfaceBreakdown[surface] = { claudeChars: chars, percent } }
return { version: 1, summary: { claudePercent, claudeChars: totalClaudeChars, humanChars: totalHumanChars, surfaces: Array.from(surfaces), }, files, surfaceBreakdown, excludedGenerated, sessions: [sessionId], }}
/** * Get the size of changes for a file from git diff. * Returns the number of characters added/removed (absolute difference). * For new files, returns the total file size. * For deleted files, returns the size of the deleted content. */export async function getGitDiffSize(filePath: string): Promise<number> { const cwd = getAttributionRepoRoot()
try { // Use git diff --stat to get a summary of changes const result = await execFileNoThrowWithCwd( gitExe(), ['diff', '--cached', '--stat', '--', filePath], { cwd, timeout: 5000 }, )
if (result.code !== 0 || !result.stdout) { return 0 }
// Parse the stat output to extract additions and deletions // Format: " file | 5 ++---" or " file | 10 +" const lines = result.stdout.split('\n').filter(Boolean) let totalChanges = 0
for (const line of lines) { // Skip the summary line (e.g., "1 file changed, 3 insertions(+), 2 deletions(-)") if (line.includes('file changed') || line.includes('files changed')) { const insertMatch = line.match(/(\d+) insertions?/) const deleteMatch = line.match(/(\d+) deletions?/)
// Use line-based changes and approximate chars per line (~40 chars average) const insertions = insertMatch ? parseInt(insertMatch[1]!, 10) : 0 const deletions = deleteMatch ? parseInt(deleteMatch[1]!, 10) : 0 totalChanges += (insertions + deletions) * 40 } }
return totalChanges } catch { return 0 }}
/** * Check if a file was deleted in the staged changes. */export async function isFileDeleted(filePath: string): Promise<boolean> { const cwd = getAttributionRepoRoot()
try { const result = await execFileNoThrowWithCwd( gitExe(), ['diff', '--cached', '--name-status', '--', filePath], { cwd, timeout: 5000 }, )
if (result.code === 0 && result.stdout) { // Format: "D\tfilename" for deleted files return result.stdout.trim().startsWith('D\t') } } catch { // Ignore errors }
return false}
/** * Get staged files from git. */export async function getStagedFiles(): Promise<string[]> { const cwd = getAttributionRepoRoot()
try { const result = await execFileNoThrowWithCwd( gitExe(), ['diff', '--cached', '--name-only'], { cwd, timeout: 5000 }, )
if (result.code === 0 && result.stdout) { return result.stdout.split('\n').filter(Boolean) } } catch (error) { logError(error as Error) }
return []}
// formatAttributionTrailer moved to attributionTrailer.ts for tree-shaking// (contains excluded strings that should not be in external builds)
/** * Check if we're in a transient git state (rebase, merge, cherry-pick). */export async function isGitTransientState(): Promise<boolean> { const gitDir = await resolveGitDir(getAttributionRepoRoot()) if (!gitDir) return false
const indicators = [ 'rebase-merge', 'rebase-apply', 'MERGE_HEAD', 'CHERRY_PICK_HEAD', 'BISECT_LOG', ]
const results = await Promise.all( indicators.map(async indicator => { try { await stat(join(gitDir, indicator)) return true } catch { return false } }), )
return results.some(exists => exists)}
/** * Convert attribution state to snapshot message for persistence. */export function stateToSnapshotMessage( state: AttributionState, messageId: UUID,): AttributionSnapshotMessage { const fileStates: Record<string, FileAttributionState> = {}
for (const [path, fileState] of state.fileStates) { fileStates[path] = fileState }
return { type: 'attribution-snapshot', messageId, surface: state.surface, fileStates, promptCount: state.promptCount, promptCountAtLastCommit: state.promptCountAtLastCommit, permissionPromptCount: state.permissionPromptCount, permissionPromptCountAtLastCommit: state.permissionPromptCountAtLastCommit, escapeCount: state.escapeCount, escapeCountAtLastCommit: state.escapeCountAtLastCommit, }}
/** * Restore attribution state from snapshot messages. */export function restoreAttributionStateFromSnapshots( snapshots: AttributionSnapshotMessage[],): AttributionState { const state = createEmptyAttributionState()
// Snapshots are full-state dumps (see stateToSnapshotMessage), not deltas. // The last snapshot has the most recent count for every path — fileStates // never shrinks. Iterating and SUMMING counts across snapshots causes // quadratic growth on restore (837 snapshots × 280 files → 1.15 quadrillion // "chars" tracked for a 5KB file over a 5-day session). const lastSnapshot = snapshots[snapshots.length - 1] if (!lastSnapshot) { return state }
state.surface = lastSnapshot.surface for (const [path, fileState] of Object.entries(lastSnapshot.fileStates)) { state.fileStates.set(path, fileState) }
// Restore prompt counts from the last snapshot (most recent state) state.promptCount = lastSnapshot.promptCount ?? 0 state.promptCountAtLastCommit = lastSnapshot.promptCountAtLastCommit ?? 0 state.permissionPromptCount = lastSnapshot.permissionPromptCount ?? 0 state.permissionPromptCountAtLastCommit = lastSnapshot.permissionPromptCountAtLastCommit ?? 0 state.escapeCount = lastSnapshot.escapeCount ?? 0 state.escapeCountAtLastCommit = lastSnapshot.escapeCountAtLastCommit ?? 0
return state}
/** * Restore attribution state from log snapshots on session resume. */export function attributionRestoreStateFromLog( attributionSnapshots: AttributionSnapshotMessage[], onUpdateState: (newState: AttributionState) => void,): void { const state = restoreAttributionStateFromSnapshots(attributionSnapshots) onUpdateState(state)}
/** * Increment promptCount and save an attribution snapshot. * Used to persist the prompt count across compaction. * * @param attribution - Current attribution state * @param saveSnapshot - Function to save the snapshot (allows async handling by caller) * @returns New attribution state with incremented promptCount */export function incrementPromptCount( attribution: AttributionState, saveSnapshot: (snapshot: AttributionSnapshotMessage) => void,): AttributionState { const newAttribution = { ...attribution, promptCount: attribution.promptCount + 1, } const snapshot = stateToSnapshotMessage(newAttribution, randomUUID()) saveSnapshot(snapshot) return newAttribution}