import { createLogger } from '@shared/utils/logger'; import { isWindowsishPath, normalizePathForComparison } from '@shared/utils/platformPath'; import { createTaskChangeDiagnosticFromWarning, mergeTaskChangeReviewDiagnostics, } from '@shared/utils/taskChangeReviewability'; import { createHash } from 'crypto'; import { diffLines } from 'diff'; import { open, readFile } from 'fs/promises'; import * as path from 'path'; import type { FileChangeSummary, FileEditEvent, FileEditTimeline, LedgerChangeRelation, LedgerContentState, SnippetDiff, TaskChangeJournalStamp, TaskChangeProvenance, TaskChangeReviewDiagnostic, TaskChangeScope, TaskChangeSetV2, } from '@shared/types'; const logger = createLogger('Service:TaskChangeLedgerReader'); const TASK_CHANGE_JOURNAL_SCHEMA_VERSION = 1; const TASK_CHANGE_SUMMARY_SCHEMA_VERSION = 2; const TASK_CHANGE_FRESHNESS_SCHEMA_VERSION = 2; const TASK_CHANGE_LEDGER_DIRNAME = '.board-task-changes'; const TASK_CHANGE_FRESHNESS_DIRNAME = '.board-task-change-freshness'; const MAX_TASK_ID_ARTIFACT_SEGMENT_LENGTH = 120; function isWindowsReservedArtifactSegment(segment: string): boolean { const stem = segment.split('.')[0]?.toUpperCase() ?? ''; return ( !segment || stem === 'CON' || stem === 'PRN' || stem === 'AUX' || stem === 'NUL' || /^COM[1-9]$/.test(stem) || /^LPT[1-9]$/.test(stem) ); } function encodeTaskId(taskId: string): string { const encoded = encodeURIComponent(taskId); return isWindowsReservedArtifactSegment(encoded) || encoded.length > MAX_TASK_ID_ARTIFACT_SEGMENT_LENGTH ? `task-id-${createHash('sha256').update(taskId).digest('hex').slice(0, 32)}` : encoded; } function taskIdArtifactSegments(taskId: string): string[] { const safe = encodeTaskId(taskId); const legacy = encodeURIComponent(taskId); return safe === legacy ? [safe] : [safe, legacy]; } function taskArtifactPathCandidates( projectDir: string, taskId: string, dirName: string, fileSuffix: string ): string[] { return taskIdArtifactSegments(taskId).map((segment) => path.join(projectDir, dirName, `${segment}${fileSuffix}`) ); } function decodeLedgerTextBlob(buffer: Buffer): string | null { for (const byte of buffer) { if (byte === 0 || byte < 9 || (byte > 13 && byte < 32)) { return null; } } const text = buffer.toString('utf8'); return Buffer.from(text, 'utf8').equals(buffer) ? text : null; } type LedgerConfidence = 'exact' | 'high' | 'medium' | 'low' | 'ambiguous'; interface LedgerContentRef { sha256: string; sizeBytes: number; blobRef?: string; unavailableReason?: string; } interface LedgerEvent { schemaVersion: typeof TASK_CHANGE_JOURNAL_SCHEMA_VERSION; eventId: string; taskId: string; taskRef: string; taskRefKind: 'canonical' | 'display' | 'unknown'; phase: 'work' | 'review'; executionSeq: number; sessionId: string; agentId?: string; memberName?: string; toolUseId: string; source: | 'file_edit' | 'file_write' | 'notebook_edit' | 'bash_simulated_sed' | 'shell_snapshot' | 'powershell_snapshot' | 'post_tool_hook_snapshot' | 'opencode_toolpart_write' | 'opencode_toolpart_edit' | 'opencode_toolpart_apply_patch'; operation: 'create' | 'modify' | 'delete'; confidence: LedgerConfidence; workspaceRoot: string; worktreePath?: string; worktreeBranch?: string; baseWorkspaceRoot?: string; dirtyLeaderWarning?: string; filePath: string; relativePath: string; timestamp: string; toolStatus: 'succeeded' | 'failed' | 'killed' | 'backgrounded'; before: LedgerContentRef | null; after: LedgerContentRef | null; beforeState?: LedgerContentState; afterState?: LedgerContentState; relation?: LedgerChangeRelation; oldString?: string; newString?: string; linesAdded?: number; linesRemoved?: number; replaceAll?: boolean; warnings?: string[]; sourceRuntime?: 'opencode'; sourceProvider?: 'opencode'; sourceImportKey?: string; evidenceProof?: string; supersedesEventId?: string; snapshotId?: string; snapshotSource?: string; } interface LedgerNotice { schemaVersion: typeof TASK_CHANGE_JOURNAL_SCHEMA_VERSION; noticeId: string; taskId: string; taskRef: string; taskRefKind: 'canonical' | 'display' | 'unknown'; phase: 'work' | 'review'; executionSeq: number; sessionId: string; agentId?: string; memberName?: string; toolUseId: string; timestamp: string; severity: 'warning'; message: string; code?: 'multi-scope-skipped' | 'journal-recovered' | 'writer-lock-stolen'; } interface LedgerBundleFileV1 { filePath: string; relativePath: string; eventIds: string[]; linesAdded: number; linesRemoved: number; isNewFile: boolean; latestAfterHash: string | null; } interface LedgerBundleV1 { schemaVersion: 1; source: 'task-change-ledger'; taskId: string; generatedAt: string; eventCount: number; files: LedgerBundleFileV1[]; totalLinesAdded: number; totalLinesRemoved: number; totalFiles: number; confidence: 'high' | 'medium' | 'low'; warnings: string[]; events: LedgerEvent[]; notices?: LedgerNotice[]; } interface LedgerSummaryContributorV2 { actorKey: string; agentId?: string; memberName?: string; eventCount: number; noticeCount: number; touchedFileCount: number; visibleFileCount: number; toolUseCount: number; cumulativeLinesAdded: number; cumulativeLinesRemoved: number; firstTimestamp: string; lastTimestamp: string; } interface LedgerSummaryScopeV2 { confidence: TaskChangeScope['confidence']; primaryActorKey?: string; primaryAgentId?: string; primaryMemberName?: string; memberName: string; agentIds?: string[]; memberNames?: string[]; startTimestamp: string; endTimestamp: string; toolUseIds: string[]; toolUseCount: number; toolUseIdsTruncated?: boolean; phaseSet: ('work' | 'review')[]; executionSeqRange?: { start: number; end: number }; confidenceBreakdown?: TaskChangeScope['confidenceBreakdown']; visibleFileCount: number; contributors: LedgerSummaryContributorV2[]; worktreePaths?: string[]; worktreeBranches?: string[]; baseWorkspaceRoots?: string[]; dirtyLeaderWarnings?: string[]; } interface LedgerSummaryFileV2 { changeKey: string; filePath: string; relativePath: string; displayPath?: string; linesAdded: number; linesRemoved: number; diffStatKnown: boolean; eventCount: number; firstTimestamp: string; lastTimestamp: string; latestOperation: 'create' | 'modify' | 'delete'; createdInTask: boolean; deletedInTask: boolean; baselineExists?: boolean; finalExists?: boolean; latestBeforeHash: string | null; latestAfterHash: string | null; latestBeforeState?: LedgerContentState; latestAfterState?: LedgerContentState; contentAvailability: 'full-text' | 'hash-only' | 'metadata-only'; reviewability: 'full-text' | 'partial-text' | 'metadata-only'; relation?: LedgerChangeRelation; worktreePath?: string; worktreeBranch?: string; baseWorkspaceRoot?: string; dirtyLeaderWarning?: string; primaryActorKey?: string; agentIds: string[]; memberNames?: string[]; executionSeqRange?: { start: number; end: number }; warnings?: string[]; } interface LedgerSummaryBundleV2 { schemaVersion: typeof TASK_CHANGE_SUMMARY_SCHEMA_VERSION; source: 'task-change-ledger'; bundleKind: 'summary'; taskId: string; generatedAt: string; journalStamp: TaskChangeJournalStamp; integrity: 'ok' | 'recovered' | 'partial'; eventCount: number; noticeCount: number; scope: LedgerSummaryScopeV2; files: LedgerSummaryFileV2[]; totalLinesAdded: number; totalLinesRemoved: number; diffStatCompleteness: 'complete' | 'partial'; totalFiles: number; confidence: 'high' | 'medium' | 'low'; warningCount: number; warnings: string[]; } interface LedgerFreshnessV2 { schemaVersion: typeof TASK_CHANGE_FRESHNESS_SCHEMA_VERSION; source: 'task-change-ledger'; taskId: string; updatedAt: string; journalStamp: TaskChangeJournalStamp; eventCount: number; noticeCount: number; integrity: 'ok' | 'recovered' | 'partial'; bundleSchemaVersion: 2; bundleKind: 'summary'; } interface JournalReadResult { entries: T[]; recovered: boolean; } interface JournalData { events: LedgerEvent[]; notices: LedgerNotice[]; recovered: boolean; } interface SummaryBundleRead { bundle: LedgerSummaryBundleV2; provenance: TaskChangeProvenance; mode: 'validated' | 'degraded'; degradedWarning?: string; } export class TaskChangeLedgerReader { async readTaskChanges(params: { teamName: string; taskId: string; projectDir: string; projectPath?: string; includeDetails: boolean; }): Promise { const bundleRead = await this.tryReadSummaryBundleV2( params.projectDir, params.taskId, params.projectPath ); if (params.includeDetails) { const journal = await this.readJournalData(params.projectDir, params.taskId); if (journal) { return this.buildDetailedResult({ teamName: params.teamName, taskId: params.taskId, projectDir: params.projectDir, projectPath: params.projectPath, journal, bundle: bundleRead?.bundle, provenance: bundleRead?.provenance ?? this.buildLedgerProvenanceFromJournal( (await this.readJournalStampFromDisk(params.projectDir, params.taskId)) ?? {}, undefined, journal.recovered ? 'recovered' : 'ok' ), }); } const legacy = await this.readLegacyBundleV1(params.projectDir, params.taskId); if (legacy) { return this.buildLegacyResult({ teamName: params.teamName, taskId: params.taskId, projectDir: params.projectDir, projectPath: params.projectPath, bundle: legacy, includeDetails: true, }); } if (bundleRead) { const result = this.buildSummaryResultFromBundle({ teamName: params.teamName, taskId: params.taskId, projectPath: params.projectPath, bundle: bundleRead.bundle, provenance: bundleRead.provenance, extraWarnings: bundleRead.degradedWarning ? [bundleRead.degradedWarning] : undefined, }); return { ...result, warnings: [ ...result.warnings, 'Ledger journal was unavailable; detailed snippets could not be loaded.', ], }; } return null; } if (bundleRead?.mode === 'validated') { return this.buildSummaryResultFromBundle({ teamName: params.teamName, taskId: params.taskId, projectPath: params.projectPath, bundle: bundleRead.bundle, provenance: bundleRead.provenance, }); } const journal = await this.readJournalData(params.projectDir, params.taskId); if (journal) { return this.buildJournalFallbackSummary({ teamName: params.teamName, taskId: params.taskId, projectDir: params.projectDir, projectPath: params.projectPath, journal, }); } if (bundleRead) { return this.buildSummaryResultFromBundle({ teamName: params.teamName, taskId: params.taskId, projectPath: params.projectPath, bundle: bundleRead.bundle, provenance: bundleRead.provenance, extraWarnings: bundleRead.degradedWarning ? [bundleRead.degradedWarning] : undefined, }); } const legacy = await this.readLegacyBundleV1(params.projectDir, params.taskId); if (legacy) { return this.buildLegacyResult({ teamName: params.teamName, taskId: params.taskId, projectDir: params.projectDir, projectPath: params.projectPath, bundle: legacy, includeDetails: false, }); } return null; } private async tryReadSummaryBundleV2( projectDir: string, taskId: string, _projectPath?: string ): Promise { const [bundle, freshness, journalStamp] = await Promise.all([ this.readSummaryBundleV2(projectDir, taskId), this.readFreshnessV2(projectDir, taskId), this.readJournalStampFromDisk(projectDir, taskId), ]); if (!bundle) { return null; } const provenance = this.buildLedgerProvenanceFromSummaryBundle(bundle); if ( freshness && this.bundleMatchesFreshness(bundle, freshness) && freshness.integrity !== 'partial' ) { return { bundle, provenance, mode: 'validated' }; } if ( !freshness && journalStamp && JSON.stringify(journalStamp) === JSON.stringify(bundle.journalStamp) && bundle.integrity !== 'partial' ) { return { bundle, provenance: this.buildLedgerProvenanceFromSummaryBundle(bundle, journalStamp), mode: 'validated', }; } if (!freshness && !journalStamp) { return { bundle, provenance, mode: 'degraded', degradedWarning: 'Task change summary used bundle v2 without live validation because freshness and journal files were unavailable.', }; } return { bundle, provenance, mode: 'degraded', degradedWarning: 'Task change summary bypassed bundle v2 fast-path because bundle freshness did not match the current ledger generation.', }; } private async readSummaryBundleV2( projectDir: string, taskId: string ): Promise { const bundlePaths = taskArtifactPathCandidates( projectDir, taskId, path.join(TASK_CHANGE_LEDGER_DIRNAME, 'bundles'), '.json' ); for (const bundlePath of bundlePaths) { try { const raw = await readFile(bundlePath, 'utf8'); const parsed = JSON.parse(raw) as Partial; if ( parsed?.schemaVersion !== TASK_CHANGE_SUMMARY_SCHEMA_VERSION || parsed.source !== 'task-change-ledger' || parsed.bundleKind !== 'summary' || parsed.taskId !== taskId || !Array.isArray(parsed.files) ) { return null; } return parsed as LedgerSummaryBundleV2; } catch { continue; } } logger.debug(`No v2 task-change bundle for ${taskId}.`); return null; } private async readFreshnessV2( projectDir: string, taskId: string ): Promise { const freshnessPaths = taskArtifactPathCandidates( projectDir, taskId, TASK_CHANGE_FRESHNESS_DIRNAME, '.json' ); for (const freshnessPath of freshnessPaths) { try { const raw = await readFile(freshnessPath, 'utf8'); const parsed = JSON.parse(raw) as Partial; if ( parsed?.schemaVersion !== TASK_CHANGE_FRESHNESS_SCHEMA_VERSION || parsed.source !== 'task-change-ledger' || parsed.taskId !== taskId || parsed.bundleKind !== 'summary' ) { return null; } return parsed as LedgerFreshnessV2; } catch { continue; } } return null; } private async readLegacyBundleV1( projectDir: string, taskId: string ): Promise { const bundlePaths = taskArtifactPathCandidates( projectDir, taskId, path.join(TASK_CHANGE_LEDGER_DIRNAME, 'bundles'), '.json' ); for (const bundlePath of bundlePaths) { try { const raw = await readFile(bundlePath, 'utf8'); const parsed = JSON.parse(raw) as Partial; if ( parsed?.schemaVersion !== 1 || parsed.source !== 'task-change-ledger' || parsed.taskId !== taskId || !Array.isArray(parsed.events) ) { return null; } return parsed as LedgerBundleV1; } catch { continue; } } return null; } private async readJournalData(projectDir: string, taskId: string): Promise { const [events, notices] = await Promise.all([ this.readJournalEntries({ filePath: taskArtifactPathCandidates( projectDir, taskId, path.join(TASK_CHANGE_LEDGER_DIRNAME, 'events'), '.jsonl' ), taskId, schemaVersion: TASK_CHANGE_JOURNAL_SCHEMA_VERSION, idField: 'eventId', }), this.readJournalEntries({ filePath: taskArtifactPathCandidates( projectDir, taskId, path.join(TASK_CHANGE_LEDGER_DIRNAME, 'notices'), '.jsonl' ), taskId, schemaVersion: TASK_CHANGE_JOURNAL_SCHEMA_VERSION, idField: 'noticeId', }), ]); if (events.entries.length === 0 && notices.entries.length === 0) { return null; } return { events: events.entries, notices: notices.entries, recovered: events.recovered || notices.recovered, }; } private async readJournalEntries(params: { filePath: string | string[]; taskId: string; schemaVersion: number; idField: 'eventId' | 'noticeId'; }): Promise> { let raw: string | null = null; for (const filePath of Array.isArray(params.filePath) ? params.filePath : [params.filePath]) { try { raw = await readFile(filePath, 'utf8'); break; } catch { continue; } } if (raw === null) { return { entries: [], recovered: false }; } const entries: T[] = []; const seenIds = new Set(); let recovered = false; for (const line of raw.split('\n')) { if (!line.trim()) continue; try { const parsed = JSON.parse(line) as T & Record; const id = parsed?.[params.idField]; if ( parsed?.schemaVersion !== params.schemaVersion || parsed.taskId !== params.taskId || typeof id !== 'string' ) { recovered = true; continue; } if (seenIds.has(id)) { recovered = true; continue; } seenIds.add(id); entries.push(parsed); } catch { recovered = true; } } return { entries, recovered }; } private bundleMatchesFreshness( bundle: LedgerSummaryBundleV2, freshness: LedgerFreshnessV2 ): boolean { return ( JSON.stringify(bundle.journalStamp) === JSON.stringify(freshness.journalStamp) && bundle.eventCount === freshness.eventCount && bundle.noticeCount === freshness.noticeCount && freshness.bundleSchemaVersion === bundle.schemaVersion && freshness.bundleKind === bundle.bundleKind ); } private buildLedgerProvenance( journalStamp: TaskChangeJournalStamp, integrity: 'ok' | 'recovered' | 'partial', bundleSchemaVersion?: number ): TaskChangeProvenance { return { sourceKind: 'ledger', sourceFingerprint: this.hashFingerprintPayload({ journalStamp, integrity, ...(bundleSchemaVersion ? { bundleSchemaVersion } : {}), }), journalStamp, ...(bundleSchemaVersion ? { bundleSchemaVersion } : {}), integrity, }; } private buildLedgerProvenanceFromJournal( journalStamp: TaskChangeJournalStamp, bundleSchemaVersion?: number, integrity: 'ok' | 'recovered' | 'partial' = 'ok' ): TaskChangeProvenance { return this.buildLedgerProvenance(journalStamp, integrity, bundleSchemaVersion); } private buildLedgerProvenanceFromSummaryBundle( bundle: LedgerSummaryBundleV2, journalStamp: TaskChangeJournalStamp = bundle.journalStamp ): TaskChangeProvenance { return { sourceKind: 'ledger', sourceFingerprint: this.hashFingerprintPayload(this.buildProjectedSummaryIdentity(bundle)), journalStamp, bundleSchemaVersion: bundle.schemaVersion, integrity: bundle.integrity, }; } private buildProjectedSummaryIdentity(bundle: LedgerSummaryBundleV2): unknown { return { kind: 'ledger-summary-v2-projected-identity', schemaVersion: bundle.schemaVersion, bundleKind: bundle.bundleKind, taskId: bundle.taskId, integrity: bundle.integrity, totalFiles: bundle.totalFiles, totalLinesAdded: bundle.totalLinesAdded, totalLinesRemoved: bundle.totalLinesRemoved, diffStatCompleteness: bundle.diffStatCompleteness, confidence: bundle.confidence, files: [...bundle.files] .map((file) => ({ changeKey: this.normalizeSummaryChangeKey(file), filePath: normalizePathForComparison(file.filePath), relativePath: normalizePathForComparison(file.relativePath), displayPath: file.displayPath ? normalizePathForComparison(file.displayPath) : undefined, linesAdded: file.linesAdded, linesRemoved: file.linesRemoved, diffStatKnown: file.diffStatKnown, latestOperation: file.latestOperation, createdInTask: file.createdInTask, deletedInTask: file.deletedInTask, baselineExists: file.baselineExists, finalExists: file.finalExists, latestBeforeHash: file.latestBeforeHash, latestAfterHash: file.latestAfterHash, latestBeforeState: this.contentStateFingerprint(file.latestBeforeState), latestAfterState: this.contentStateFingerprint(file.latestAfterState), contentAvailability: file.contentAvailability, reviewability: file.reviewability, relation: file.relation ? { kind: file.relation.kind, oldPath: normalizePathForComparison(file.relation.oldPath), newPath: normalizePathForComparison(file.relation.newPath), } : undefined, worktreePath: file.worktreePath ? normalizePathForComparison(file.worktreePath) : undefined, worktreeBranch: file.worktreeBranch, baseWorkspaceRoot: file.baseWorkspaceRoot ? normalizePathForComparison(file.baseWorkspaceRoot) : undefined, })) .sort( (left, right) => left.changeKey.localeCompare(right.changeKey) || left.filePath.localeCompare(right.filePath) ), }; } private contentStateFingerprint(state: LedgerContentState | undefined): unknown { if (!state) { return undefined; } return { exists: state.exists, sha256: state.sha256, sizeBytes: state.sizeBytes, unavailableReason: state.unavailableReason, }; } private hashFingerprintPayload(payload: unknown): string { return createHash('sha256').update(JSON.stringify(payload)).digest('hex'); } private async readJournalStampFromDisk( projectDir: string, taskId: string ): Promise { const readFileStamp = async (filePaths: string[]) => { let handle: Awaited> | null = null; for (const filePath of filePaths) { try { handle = await open(filePath, 'r'); const fileStat = await handle.stat(); if (!fileStat.isFile()) { continue; } const tailLength = Math.min(fileStat.size, 4096); const tail = Buffer.alloc(tailLength); if (tailLength > 0) { await handle.read(tail, 0, tailLength, fileStat.size - tailLength); } return { bytes: fileStat.size, mtimeMs: fileStat.mtimeMs, tailSha256: tailLength > 0 ? createHash('sha256').update(tail).digest('hex') : null, }; } catch { continue; } finally { await handle?.close().catch(() => undefined); handle = null; } } return undefined; }; const [events, notices] = await Promise.all([ readFileStamp( taskArtifactPathCandidates( projectDir, taskId, path.join(TASK_CHANGE_LEDGER_DIRNAME, 'events'), '.jsonl' ) ), readFileStamp( taskArtifactPathCandidates( projectDir, taskId, path.join(TASK_CHANGE_LEDGER_DIRNAME, 'notices'), '.jsonl' ) ), ]); if (!events && !notices) { return null; } return { ...(events ? { events } : {}), ...(notices ? { notices } : {}), }; } private buildSummaryResultFromBundle(params: { teamName: string; taskId: string; projectPath?: string; bundle: LedgerSummaryBundleV2; provenance: TaskChangeProvenance; extraWarnings?: string[]; }): TaskChangeSetV2 { const warnings = [...params.bundle.warnings, ...(params.extraWarnings ?? [])]; return { teamName: params.teamName, taskId: params.taskId, files: params.bundle.files.map((file) => this.mapV2SummaryFile(file, params.projectPath)), totalLinesAdded: params.bundle.totalLinesAdded, totalLinesRemoved: params.bundle.totalLinesRemoved, totalFiles: params.bundle.totalFiles, confidence: params.bundle.confidence, computedAt: params.bundle.generatedAt, scope: this.mapV2Scope(params.taskId, params.bundle.scope, params.bundle.files), warnings, reviewDiagnostics: this.withSummaryStateDiagnostics( this.buildReviewDiagnosticsFromWarnings(warnings, 'ledger'), { integrity: params.bundle.integrity, diffStatCompleteness: params.bundle.diffStatCompleteness, } ), diffStatCompleteness: params.bundle.diffStatCompleteness, provenance: params.provenance, }; } private async buildDetailedResult(params: { teamName: string; taskId: string; projectDir: string; projectPath?: string; journal: JournalData; bundle?: LedgerSummaryBundleV2; provenance: TaskChangeProvenance; }): Promise { const projectedEvents = this.projectJournalEventsForUi(params.journal.events); const snippets = await this.buildSnippets(params.projectDir, projectedEvents); const groupedSnippets = this.groupSnippets(snippets); const warnings = this.collectWarnings(projectedEvents, params.journal.notices, { recovered: params.journal.recovered, }); const reviewDiagnostics = this.collectReviewDiagnostics( projectedEvents, params.journal.notices, { recovered: params.journal.recovered, } ); let files: FileChangeSummary[]; let totalLinesAdded: number; let totalLinesRemoved: number; let totalFiles: number; let confidence: TaskChangeSetV2['confidence']; let scope: TaskChangeScope; let diffStatCompleteness: 'complete' | 'partial' | undefined; if (params.bundle) { files = params.bundle.files.map((file) => { const groupKey = this.groupKeyForFileSummary( file.filePath, file.relation, file.worktreePath ); const entry = groupedSnippets.get(groupKey); return { ...this.mapV2SummaryFile(file, params.projectPath), snippets: entry?.snippets ?? [], timeline: entry ? this.buildTimeline(file.filePath, entry.snippets) : undefined, }; }); totalLinesAdded = params.bundle.totalLinesAdded; totalLinesRemoved = params.bundle.totalLinesRemoved; totalFiles = params.bundle.totalFiles; confidence = params.bundle.confidence; scope = this.mapV2Scope(params.taskId, params.bundle.scope, params.bundle.files); diffStatCompleteness = params.bundle.diffStatCompleteness; } else { const fallback = this.buildFallbackFilesFromGroupedSnippets( groupedSnippets, params.projectPath ); files = fallback.files; totalLinesAdded = fallback.totalLinesAdded; totalLinesRemoved = fallback.totalLinesRemoved; totalFiles = fallback.files.length; confidence = projectedEvents.some((event) => event.confidence === 'low') ? 'low' : projectedEvents.some((event) => event.confidence === 'medium') ? 'medium' : 'high'; scope = this.buildFallbackScope( params.taskId, files, projectedEvents, params.journal.notices ); diffStatCompleteness = fallback.files.every((file) => file.diffStatKnown !== false) ? 'complete' : 'partial'; const fallbackWarning = 'Ledger detail view fell back to journal reconstruction because summary bundle v2 was unavailable.'; warnings.push(fallbackWarning); this.addReviewDiagnostic( reviewDiagnostics, createTaskChangeDiagnosticFromWarning(fallbackWarning, 'summary') ); } return { teamName: params.teamName, taskId: params.taskId, files, totalLinesAdded, totalLinesRemoved, totalFiles, confidence, computedAt: params.bundle?.generatedAt ?? new Date().toISOString(), scope, warnings, reviewDiagnostics: this.withSummaryStateDiagnostics(reviewDiagnostics, { integrity: params.bundle?.integrity ?? params.provenance.integrity, diffStatCompleteness, }), ...(diffStatCompleteness ? { diffStatCompleteness } : {}), provenance: params.provenance, }; } private async buildJournalFallbackSummary(params: { teamName: string; taskId: string; projectDir: string; projectPath?: string; journal: JournalData; }): Promise { const provenance = this.buildLedgerProvenanceFromJournal( (await this.readJournalStampFromDisk(params.projectDir, params.taskId)) ?? {}, undefined, params.journal.recovered ? 'recovered' : 'ok' ); const projectedEvents = this.projectJournalEventsForUi(params.journal.events); const snippets = projectedEvents.map((event) => this.eventToSnippet(event, null, null)); const grouped = this.groupSnippets(snippets); const fallback = this.buildFallbackFilesFromGroupedSnippets(grouped, params.projectPath); const fallbackWarning = 'Task change summary fell back to journal reconstruction.'; const warnings = [ ...this.collectWarnings(projectedEvents, params.journal.notices, { recovered: params.journal.recovered, }), fallbackWarning, ]; const reviewDiagnostics = this.collectReviewDiagnostics( projectedEvents, params.journal.notices, { recovered: params.journal.recovered, } ); this.addReviewDiagnostic( reviewDiagnostics, createTaskChangeDiagnosticFromWarning(fallbackWarning, 'summary') ); const diffStatCompleteness = fallback.files.every((file) => file.diffStatKnown !== false) ? 'complete' : 'partial'; return { teamName: params.teamName, taskId: params.taskId, files: fallback.files.map((file) => ({ ...file, snippets: [] })), totalLinesAdded: fallback.totalLinesAdded, totalLinesRemoved: fallback.totalLinesRemoved, totalFiles: fallback.files.length, confidence: projectedEvents.some((event) => event.confidence === 'low') ? 'low' : projectedEvents.some((event) => event.confidence === 'medium') ? 'medium' : 'high', computedAt: new Date().toISOString(), scope: this.buildFallbackScope( params.taskId, fallback.files, projectedEvents, params.journal.notices ), warnings, reviewDiagnostics: this.withSummaryStateDiagnostics(reviewDiagnostics, { integrity: provenance.integrity, diffStatCompleteness, }), diffStatCompleteness, provenance, }; } private async buildLegacyResult(params: { teamName: string; taskId: string; projectDir: string; projectPath?: string; bundle: LedgerBundleV1; includeDetails: boolean; }): Promise { const snippets = params.includeDetails ? await this.buildSnippets(params.projectDir, params.bundle.events) : params.bundle.events.map((event) => this.eventToSnippet(event, null, null)); const grouped = this.groupSnippets(snippets); const fallback = this.buildFallbackFilesFromGroupedSnippets(grouped, params.projectPath); const warnings = new Set(params.bundle.warnings ?? []); warnings.add( 'Task change ledger used legacy bundle v1 compatibility mode; summary was derived from legacy events.' ); for (const notice of params.bundle.notices ?? []) warnings.add(notice.message); const warningList = [...warnings]; const diffStatCompleteness = fallback.files.every((file) => file.diffStatKnown !== false) ? 'complete' : 'partial'; return { teamName: params.teamName, taskId: params.taskId, files: params.includeDetails ? fallback.files : fallback.files.map((file) => ({ ...file, snippets: [], timeline: undefined })), totalLinesAdded: fallback.totalLinesAdded, totalLinesRemoved: fallback.totalLinesRemoved, totalFiles: fallback.files.length, confidence: params.bundle.confidence, computedAt: params.bundle.generatedAt, scope: this.buildFallbackScope( params.taskId, fallback.files, params.bundle.events, params.bundle.notices ?? [] ), warnings: warningList, reviewDiagnostics: this.withSummaryStateDiagnostics( this.buildReviewDiagnosticsFromWarnings(warningList, 'ledger'), { diffStatCompleteness } ), diffStatCompleteness, provenance: { sourceKind: 'ledger', sourceFingerprint: this.hashFingerprintPayload({ legacyTaskId: params.taskId, generatedAt: params.bundle.generatedAt, eventCount: params.bundle.eventCount, }), }, }; } private mapV2SummaryFile(file: LedgerSummaryFileV2, projectPath?: string): FileChangeSummary { const displayPath = file.displayPath ?? file.filePath; const filePath = this.normalizeLedgerFilePath(file.filePath); const agentIds = Array.isArray(file.agentIds) ? file.agentIds : []; return { filePath, relativePath: this.relativePath(displayPath, projectPath, file.relativePath), snippets: [], linesAdded: file.linesAdded, linesRemoved: file.linesRemoved, isNewFile: Boolean( file.createdInTask && file.latestOperation !== 'delete' && file.relation?.kind !== 'rename' ), changeKey: this.normalizeSummaryChangeKey(file), diffStatKnown: file.diffStatKnown, ledgerSummary: { latestOperation: file.latestOperation, createdInTask: file.createdInTask, deletedInTask: file.deletedInTask, contentAvailability: file.contentAvailability, reviewability: file.reviewability, ...(file.relation ? { relation: file.relation } : {}), ...(file.latestBeforeState ? { beforeState: file.latestBeforeState } : {}), ...(file.latestAfterState ? { afterState: file.latestAfterState } : {}), ...(file.primaryActorKey ? { primaryActorKey: file.primaryActorKey } : {}), ...(agentIds.length > 0 ? { agentIds } : {}), ...(file.memberNames ? { memberNames: file.memberNames } : {}), ...(file.executionSeqRange ? { executionSeqRange: file.executionSeqRange } : {}), ...(file.worktreePath ? { worktreePath: file.worktreePath } : {}), ...(file.worktreeBranch ? { worktreeBranch: file.worktreeBranch } : {}), ...(file.baseWorkspaceRoot ? { baseWorkspaceRoot: file.baseWorkspaceRoot } : {}), ...(file.dirtyLeaderWarning ? { dirtyLeaderWarning: file.dirtyLeaderWarning } : {}), }, }; } private normalizeSummaryChangeKey(file: LedgerSummaryFileV2): string { if (file.relation) { return this.relationChangeKey(file.relation, file.worktreePath); } const slashNormalized = file.changeKey.replace(/\\/g, '/'); const pathKeyMatch = /^(path|create|delete):(.+)$/.exec(slashNormalized); if (pathKeyMatch) { return `${pathKeyMatch[1]}:${normalizePathForComparison(pathKeyMatch[2] ?? '')}`; } return slashNormalized; } private mapV2Scope( taskId: string, scope: LedgerSummaryScopeV2, files: LedgerSummaryFileV2[] ): TaskChangeScope { const agentIds = Array.isArray(scope.agentIds) ? scope.agentIds : []; return { taskId, memberName: scope.memberName || scope.primaryMemberName || scope.primaryAgentId || scope.primaryActorKey || '', startLine: 0, endLine: 0, startTimestamp: scope.startTimestamp, endTimestamp: scope.endTimestamp, toolUseIds: scope.toolUseIds, filePaths: files.map((file) => this.normalizeLedgerFilePath(file.filePath)), confidence: scope.confidence, ...(scope.primaryActorKey ? { primaryActorKey: scope.primaryActorKey } : {}), ...(scope.primaryAgentId ? { primaryAgentId: scope.primaryAgentId } : {}), ...(scope.primaryMemberName ? { primaryMemberName: scope.primaryMemberName } : {}), ...(agentIds.length > 0 ? { agentIds } : {}), ...(scope.memberNames ? { memberNames: scope.memberNames } : {}), ...(scope.toolUseCount !== undefined ? { toolUseCount: scope.toolUseCount } : {}), ...(scope.toolUseIdsTruncated ? { toolUseIdsTruncated: true } : {}), ...(scope.phaseSet ? { phaseSet: scope.phaseSet } : {}), ...(scope.executionSeqRange ? { executionSeqRange: scope.executionSeqRange } : {}), ...(scope.confidenceBreakdown ? { confidenceBreakdown: scope.confidenceBreakdown } : {}), ...(scope.contributors ? { contributors: scope.contributors } : {}), ...(scope.worktreePaths ? { worktreePaths: scope.worktreePaths } : {}), ...(scope.worktreeBranches ? { worktreeBranches: scope.worktreeBranches } : {}), ...(scope.baseWorkspaceRoots ? { baseWorkspaceRoots: scope.baseWorkspaceRoots } : {}), ...(scope.dirtyLeaderWarnings ? { dirtyLeaderWarnings: scope.dirtyLeaderWarnings } : {}), }; } private async buildSnippets(projectDir: string, events: LedgerEvent[]): Promise { return Promise.all( events.map(async (event) => { const beforeContent = await this.readContentRef(projectDir, event.before); const afterContent = await this.readContentRef(projectDir, event.after); return this.eventToSnippet(event, beforeContent, afterContent); }) ); } private projectJournalEventsForUi(events: LedgerEvent[]): LedgerEvent[] { const selectedBySourceImportKey = new Map< string, { event: LedgerEvent; index: number; rank: number } >(); const passthrough: { event: LedgerEvent; index: number }[] = []; events.forEach((event, index) => { const sourceImportKey = this.sourceImportKeyForEvent(event); if (!sourceImportKey) { passthrough.push({ event, index }); return; } const rank = this.evidenceRankForEvent(event); const existing = selectedBySourceImportKey.get(sourceImportKey); if (!existing || rank >= existing.rank) { selectedBySourceImportKey.set(sourceImportKey, { event, index, rank }); } }); return [ ...passthrough, ...[...selectedBySourceImportKey.values()].map(({ event, index }) => ({ event, index })), ] .sort((left, right) => left.index - right.index) .map(({ event }) => event); } private sourceImportKeyForEvent(event: LedgerEvent): string | null { if ( event.sourceImportKey && (event.sourceRuntime === 'opencode' || event.sourceProvider === 'opencode' || event.source === 'opencode_toolpart_write' || event.source === 'opencode_toolpart_edit' || event.source === 'opencode_toolpart_apply_patch') ) { return event.sourceImportKey; } return null; } private evidenceRankForEvent(event: LedgerEvent): number { const hasFullText = this.hasFullTextEvidence(event); switch (event.evidenceProof) { case 'opencode-snapshot': return hasFullText ? 50 : 35; case 'inverse-apply-patch-chain': case 'inverse-edit-chain': case 'toolpart-chain': return hasFullText ? 40 : 25; case 'metadata-only-fallback': return 10; default: return hasFullText ? 30 : 5; } } private hasFullTextEvidence(event: Pick): boolean { if (event.operation === 'create') { return event.after !== null; } if (event.operation === 'delete') { return event.before !== null; } return event.before !== null && event.after !== null; } private async readContentRef( projectDir: string, ref: LedgerContentRef | null ): Promise { if (!ref?.blobRef) { return null; } try { const buffer = await readFile( path.join(projectDir, TASK_CHANGE_LEDGER_DIRNAME, 'blobs', ref.blobRef) ); return decodeLedgerTextBlob(buffer); } catch { return null; } } private eventToSnippet( event: LedgerEvent, beforeContent: string | null, afterContent: string | null ): SnippetDiff { const filePath = this.normalizeLedgerFilePath(event.filePath); return { toolUseId: event.toolUseId, filePath, toolName: this.mapToolName(event.source), type: this.mapSnippetType(event), oldString: event.oldString ?? beforeContent ?? '', newString: event.newString ?? afterContent ?? '', replaceAll: event.replaceAll ?? false, timestamp: event.timestamp, isError: false, ledger: { eventId: event.eventId, source: event.confidence === 'exact' ? 'ledger-exact' : 'ledger-snapshot', confidence: event.confidence, originalFullContent: beforeContent, modifiedFullContent: afterContent, beforeHash: event.before?.sha256 ?? null, afterHash: event.after?.sha256 ?? null, operation: event.operation, beforeState: event.beforeState, afterState: event.afterState, relation: event.relation, executionSeq: event.executionSeq, linesAdded: event.linesAdded, linesRemoved: event.linesRemoved, worktreePath: event.worktreePath, worktreeBranch: event.worktreeBranch, baseWorkspaceRoot: event.baseWorkspaceRoot, dirtyLeaderWarning: event.dirtyLeaderWarning, textAvailability: beforeContent !== null && afterContent !== null ? 'full-text' : event.oldString !== undefined || event.newString !== undefined ? 'patch-text' : 'unavailable', }, }; } private mapToolName(eventSource: LedgerEvent['source']): SnippetDiff['toolName'] { switch (eventSource) { case 'file_edit': return 'Edit'; case 'file_write': case 'opencode_toolpart_write': return 'Write'; case 'notebook_edit': return 'NotebookEdit'; case 'opencode_toolpart_edit': case 'opencode_toolpart_apply_patch': return 'Edit'; case 'bash_simulated_sed': case 'shell_snapshot': return 'Bash'; case 'powershell_snapshot': return 'PowerShell'; case 'post_tool_hook_snapshot': return 'PostToolUse'; } } private mapSnippetType(event: LedgerEvent): SnippetDiff['type'] { if (event.source === 'file_write' || event.source === 'opencode_toolpart_write') { return event.operation === 'create' ? 'write-new' : 'write-update'; } if (event.source === 'notebook_edit') { return 'notebook-edit'; } if (event.source === 'shell_snapshot' || event.source === 'powershell_snapshot') { return 'shell-snapshot'; } if (event.source === 'post_tool_hook_snapshot') { return 'hook-snapshot'; } return 'edit'; } private groupSnippets( snippets: SnippetDiff[] ): Map { const grouped = new Map< string, { filePath: string; relation?: LedgerChangeRelation; snippets: SnippetDiff[] } >(); for (const snippet of snippets) { const groupKey = this.groupKeyForSnippet(snippet); const existing = grouped.get(groupKey); if (existing) { existing.snippets.push(snippet); } else { grouped.set(groupKey, { filePath: snippet.filePath, ...(snippet.ledger?.relation ? { relation: snippet.ledger.relation } : {}), snippets: [snippet], }); } } return grouped; } private buildFallbackFilesFromGroupedSnippets( grouped: Map< string, { filePath: string; relation?: LedgerChangeRelation; snippets: SnippetDiff[] } >, projectPath?: string ): { files: FileChangeSummary[]; totalLinesAdded: number; totalLinesRemoved: number } { const files: FileChangeSummary[] = []; for (const entry of grouped.values()) { const relation = entry.relation ?? this.relationForSnippets(entry.snippets); let linesAdded = 0; let linesRemoved = 0; for (const snippet of entry.snippets) { if ( typeof snippet.ledger?.linesAdded === 'number' || typeof snippet.ledger?.linesRemoved === 'number' ) { linesAdded += snippet.ledger?.linesAdded ?? 0; linesRemoved += snippet.ledger?.linesRemoved ?? 0; continue; } const { added, removed } = this.countLineChanges(snippet.oldString, snippet.newString); linesAdded += added; linesRemoved += removed; } const displayPath = this.resolveGroupedDisplayPath(entry.filePath, relation, entry.snippets); const worktreeLedger = entry.snippets.find((snippet) => snippet.ledger?.worktreePath)?.ledger; const firstLedger = entry.snippets.find((snippet) => snippet.ledger)?.ledger; const lastLedger = [...entry.snippets].reverse().find((snippet) => snippet.ledger)?.ledger; const baselineExists = firstLedger?.beforeState?.exists; const finalExists = lastLedger?.afterState?.exists; const isCreatedLifecycle = baselineExists === false && finalExists === true; const fallbackIsCreated = entry.snippets.some( (snippet) => snippet.type === 'write-new' || snippet.ledger?.operation === 'create' ); files.push({ filePath: displayPath, relativePath: this.relativePath(displayPath, projectPath), snippets: entry.snippets, linesAdded, linesRemoved, isNewFile: relation?.kind !== 'rename' && (baselineExists === undefined || finalExists === undefined ? fallbackIsCreated : isCreatedLifecycle), changeKey: relation ? this.relationChangeKey(relation, worktreeLedger?.worktreePath) : `path:${normalizePathForComparison(displayPath)}`, diffStatKnown: true, ledgerSummary: { ...(relation ? { relation } : {}), latestOperation: entry.snippets[entry.snippets.length - 1]?.ledger?.operation ?? (entry.snippets[entry.snippets.length - 1]?.type === 'write-new' ? 'create' : 'modify'), ...(worktreeLedger?.worktreePath ? { worktreePath: worktreeLedger.worktreePath } : {}), ...(worktreeLedger?.worktreeBranch ? { worktreeBranch: worktreeLedger.worktreeBranch } : {}), ...(worktreeLedger?.baseWorkspaceRoot ? { baseWorkspaceRoot: worktreeLedger.baseWorkspaceRoot } : {}), ...(worktreeLedger?.dirtyLeaderWarning ? { dirtyLeaderWarning: worktreeLedger.dirtyLeaderWarning } : {}), }, timeline: this.buildTimeline(displayPath, entry.snippets), }); } const totalLinesAdded = files.reduce((sum, file) => sum + file.linesAdded, 0); const totalLinesRemoved = files.reduce((sum, file) => sum + file.linesRemoved, 0); return { files, totalLinesAdded, totalLinesRemoved }; } private buildFallbackScope( taskId: string, files: FileChangeSummary[], events: LedgerEvent[], notices: LedgerNotice[] ): TaskChangeScope { const primaryMemberName = events.find((event) => event.memberName)?.memberName; const primaryAgentId = events.find((event) => event.agentId)?.agentId; const worktreePaths = [ ...new Set(events.flatMap((event) => (event.worktreePath ? [event.worktreePath] : []))), ].sort(); const worktreeBranches = [ ...new Set(events.flatMap((event) => (event.worktreeBranch ? [event.worktreeBranch] : []))), ].sort(); const baseWorkspaceRoots = [ ...new Set( events.flatMap((event) => (event.baseWorkspaceRoot ? [event.baseWorkspaceRoot] : [])) ), ].sort(); const dirtyLeaderWarnings = [ ...new Set( events.flatMap((event) => (event.dirtyLeaderWarning ? [event.dirtyLeaderWarning] : [])) ), ].sort(); return { taskId, memberName: primaryMemberName ?? primaryAgentId ?? '', startLine: 0, endLine: 0, startTimestamp: events[0]?.timestamp ?? notices[0]?.timestamp ?? '', endTimestamp: events[events.length - 1]?.timestamp ?? notices[notices.length - 1]?.timestamp ?? '', toolUseIds: [ ...new Set([...events.map((event) => event.toolUseId), ...notices.map((n) => n.toolUseId)]), ], filePaths: files.map((file) => file.filePath), confidence: { tier: events.some((event) => event.confidence !== 'exact') ? 2 : 1, label: events.some((event) => event.confidence !== 'exact') ? 'medium' : 'high', reason: 'Scoped by orchestrator task-change ledger', }, ...(primaryMemberName ? { primaryMemberName } : {}), ...(primaryAgentId ? { primaryAgentId } : {}), ...(events.some((event) => !!event.memberName) ? { memberNames: [ ...new Set(events.flatMap((event) => (event.memberName ? [event.memberName] : []))), ].sort(), } : {}), ...(events.length > 0 ? { executionSeqRange: { start: Math.min(...events.map((event) => event.executionSeq)), end: Math.max(...events.map((event) => event.executionSeq)), }, } : {}), ...(worktreePaths.length > 0 ? { worktreePaths } : {}), ...(worktreeBranches.length > 0 ? { worktreeBranches } : {}), ...(baseWorkspaceRoots.length > 0 ? { baseWorkspaceRoots } : {}), ...(dirtyLeaderWarnings.length > 0 ? { dirtyLeaderWarnings } : {}), }; } private addReviewDiagnostic( diagnostics: TaskChangeReviewDiagnostic[], diagnostic: TaskChangeReviewDiagnostic ): void { const existingIndex = diagnostics.findIndex( (existing) => existing.code === diagnostic.code && existing.message === diagnostic.message ); if (existingIndex >= 0) { const existing = diagnostics[existingIndex]; if (existing) { diagnostics[existingIndex] = mergeTaskChangeReviewDiagnostics(existing, diagnostic); } return; } diagnostics.push(diagnostic); } private buildReviewDiagnosticsFromWarnings( warnings: string[], source: TaskChangeReviewDiagnostic['source'] ): TaskChangeReviewDiagnostic[] { const diagnostics: TaskChangeReviewDiagnostic[] = []; for (const warning of warnings) { this.addReviewDiagnostic(diagnostics, createTaskChangeDiagnosticFromWarning(warning, source)); } return diagnostics; } private diagnosticFromNotice(notice: LedgerNotice): TaskChangeReviewDiagnostic { if (notice.code === 'multi-scope-skipped') { return { code: 'multi_scope_no_safe_diff', severity: 'info', reviewBlocking: false, message: 'Activity was observed while multiple task scopes were active, so file edits were not safely assigned to this task.', source: 'ledger', }; } if (notice.code === 'journal-recovered') { return { code: 'ledger_integrity_recovered', severity: 'warning', reviewBlocking: true, message: 'The task-change ledger was recovered from malformed journal lines.', source: 'ledger', }; } if (notice.code === 'writer-lock-stolen') { return { code: 'unsafe_or_untrusted_evidence', severity: 'warning', reviewBlocking: true, message: 'The task-change ledger writer lock changed while evidence was being recorded.', source: 'ledger', }; } return createTaskChangeDiagnosticFromWarning(notice.message, 'ledger'); } private collectReviewDiagnostics( events: LedgerEvent[], notices: LedgerNotice[], options: { recovered: boolean } ): TaskChangeReviewDiagnostic[] { const diagnostics: TaskChangeReviewDiagnostic[] = []; for (const notice of notices) { this.addReviewDiagnostic(diagnostics, this.diagnosticFromNotice(notice)); } for (const event of events) { for (const warning of event.warnings ?? []) { this.addReviewDiagnostic( diagnostics, createTaskChangeDiagnosticFromWarning(warning, 'ledger') ); } if (event.toolStatus === 'failed') { this.addReviewDiagnostic(diagnostics, { code: 'tool_failed_after_edit', severity: 'warning', reviewBlocking: true, message: `Tool ${event.toolUseId} failed after changing files.`, source: 'ledger', }); } if (event.toolStatus === 'killed') { this.addReviewDiagnostic(diagnostics, { code: 'tool_killed_after_edit', severity: 'warning', reviewBlocking: true, message: `Background tool ${event.toolUseId} was killed after changing files.`, source: 'ledger', }); } } if (options.recovered) { this.addReviewDiagnostic(diagnostics, { code: 'ledger_integrity_recovered', severity: 'warning', reviewBlocking: true, message: 'The task-change ledger was recovered from malformed journal lines.', source: 'ledger', }); } return diagnostics; } private withSummaryStateDiagnostics( diagnostics: TaskChangeReviewDiagnostic[], state: { integrity?: 'ok' | 'recovered' | 'partial'; diffStatCompleteness?: 'complete' | 'partial'; } ): TaskChangeReviewDiagnostic[] { const next = [...diagnostics]; if (state.diffStatCompleteness === 'partial') { this.addReviewDiagnostic(next, { code: 'diff_stat_partial', severity: 'warning', reviewBlocking: true, message: 'Some file change statistics are incomplete.', source: 'summary', }); } if (state.integrity === 'partial') { this.addReviewDiagnostic(next, { code: 'ledger_integrity_partial', severity: 'warning', reviewBlocking: true, message: 'The task-change ledger is partially available.', source: 'ledger', }); } else if (state.integrity === 'recovered') { this.addReviewDiagnostic(next, { code: 'ledger_integrity_recovered', severity: 'warning', reviewBlocking: true, message: 'The task-change ledger was recovered from malformed journal lines.', source: 'ledger', }); } return next; } private collectWarnings( events: LedgerEvent[], notices: LedgerNotice[], options: { recovered: boolean } ): string[] { const warnings = new Set(); for (const notice of notices) warnings.add(notice.message); for (const event of events) { for (const warning of event.warnings ?? []) warnings.add(warning); if (event.toolStatus === 'failed') { warnings.add(`Tool ${event.toolUseId} failed after changing files.`); } if (event.toolStatus === 'killed') { warnings.add(`Background tool ${event.toolUseId} was killed after changing files.`); } } if (options.recovered) { warnings.add('Task change ledger recovered from malformed journal lines.'); } return [...warnings]; } private buildTimeline(filePath: string, snippets: SnippetDiff[]): FileEditTimeline { const events: FileEditEvent[] = snippets.map((snippet, index) => { const { added, removed } = this.countLineChanges(snippet.oldString, snippet.newString); return { toolUseId: snippet.toolUseId, toolName: snippet.toolName, timestamp: snippet.timestamp, summary: this.summaryForSnippet(snippet, added, removed), linesAdded: added, linesRemoved: removed, snippetIndex: index, }; }); const firstMs = Date.parse(events[0]?.timestamp ?? ''); const lastMs = Date.parse(events[events.length - 1]?.timestamp ?? ''); return { filePath, events, durationMs: Number.isFinite(firstMs) && Number.isFinite(lastMs) ? Math.max(0, lastMs - firstMs) : 0, }; } private summaryForSnippet(snippet: SnippetDiff, added: number, removed: number): string { if (snippet.type === 'write-new') return `Created file (${added} lines)`; if (snippet.type === 'write-update') return `Rewrote file (+${added}/-${removed})`; if (snippet.type === 'shell-snapshot') { return `${snippet.toolName === 'PowerShell' ? 'PowerShell' : 'Shell'} changed file (+${added}/-${removed})`; } if (snippet.type === 'hook-snapshot') return `Hook changed file (+${added}/-${removed})`; if (snippet.type === 'notebook-edit') return `Edited notebook (+${added}/-${removed})`; return `Edited file (+${added}/-${removed})`; } private countLineChanges(before: string, after: string): { added: number; removed: number } { let added = 0; let removed = 0; for (const change of diffLines(before, after)) { if (change.added) added += change.count ?? 0; if (change.removed) removed += change.count ?? 0; } return { added, removed }; } private groupKeyForSnippet(snippet: SnippetDiff): string { return this.groupKeyForFileSummary( snippet.filePath, snippet.ledger?.relation, snippet.ledger?.worktreePath ); } private groupKeyForFileSummary( filePath: string, relation?: LedgerChangeRelation, worktreePath?: string ): string { if (relation) { return this.relationChangeKey(relation, worktreePath); } return `path:${normalizePathForComparison(filePath)}`; } private relationChangeKey(relation: LedgerChangeRelation, worktreePath?: string): string { const pathPart = `${normalizePathForComparison(relation.oldPath)}->${normalizePathForComparison(relation.newPath)}`; return worktreePath ? `${relation.kind}:${normalizePathForComparison(worktreePath)}:${pathPart}` : `${relation.kind}:${pathPart}`; } private relationForSnippets(snippets: SnippetDiff[]): LedgerChangeRelation | undefined { return snippets.find((snippet) => snippet.ledger?.relation)?.ledger?.relation; } private resolveGroupedDisplayPath( fallbackPath: string, relation: LedgerChangeRelation | undefined, snippets: SnippetDiff[] ): string { if (!relation) { return fallbackPath; } const newPathSnippet = snippets.find((snippet) => this.pathMatchesRelationPath(snippet.filePath, relation.newPath) ); if (newPathSnippet) { return newPathSnippet.filePath; } const createdSnippet = snippets.find( (snippet) => snippet.ledger?.operation === 'create' || snippet.type === 'write-new' ); if (createdSnippet) { return createdSnippet.filePath; } return ( this.resolveRelatedPathFromRelation(fallbackPath, relation.oldPath, relation.newPath) ?? fallbackPath ); } private pathMatchesRelationPath(filePath: string, relationPath: string): boolean { const caseInsensitive = this.isWindowsReviewPath(filePath) || this.isWindowsReviewPath(relationPath); const normalizedFilePath = this.normalizeRelationComparisonPath(filePath, caseInsensitive); const normalizedRelationPath = this.normalizeRelationComparisonPath( relationPath, caseInsensitive ); return ( normalizedFilePath === normalizedRelationPath || normalizedFilePath.endsWith(`/${normalizedRelationPath}`) ); } private resolveRelatedPathFromRelation( anchorPath: string, anchorRelationPath: string, targetRelationPath: string ): string | null { const slashAnchor = anchorPath.replace(/\\/g, '/'); const slashAnchorRelation = anchorRelationPath.replace(/\\/g, '/'); const caseInsensitive = this.isWindowsReviewPath(anchorPath) || this.isWindowsReviewPath(anchorRelationPath); const normalizedAnchor = this.normalizeRelationComparisonPath(anchorPath, caseInsensitive); const normalizedAnchorRelation = this.normalizeRelationComparisonPath( anchorRelationPath, caseInsensitive ); if (!this.matchesRelationSuffix(normalizedAnchor, normalizedAnchorRelation)) { return null; } return this.normalizeLedgerFilePath( `${slashAnchor.slice(0, slashAnchor.length - slashAnchorRelation.length)}${targetRelationPath.replace(/\\/g, '/')}` ); } private normalizeRelationComparisonPath(filePath: string, caseInsensitive: boolean): string { const normalized = normalizePathForComparison(filePath); return caseInsensitive ? normalized.toLowerCase() : normalized; } private isWindowsReviewPath(filePath: string): boolean { return isWindowsishPath(filePath) || filePath.includes('\\'); } private matchesRelationSuffix(normalizedPath: string, normalizedRelationPath: string): boolean { return ( normalizedPath === normalizedRelationPath || normalizedPath.endsWith(`/${normalizedRelationPath}`) ); } private normalizeLedgerFilePath(filePath: string): string { const slashPath = filePath.replace(/\\/g, '/'); const isWindowsAbsolute = /^[A-Za-z]:\//.test(slashPath) || slashPath.startsWith('//'); if (isWindowsAbsolute || (process.platform !== 'win32' && path.isAbsolute(filePath))) { return path.normalize(filePath); } return slashPath; } private relativePath( filePath: string, projectPath?: string, explicitRelativePath?: string ): string { if (explicitRelativePath) { return explicitRelativePath.replace(/\\/g, '/'); } const normalizedFilePath = filePath.replace(/\\/g, '/'); const normalizedProjectPath = projectPath?.replace(/\\/g, '/'); const comparableFilePath = normalizePathForComparison(normalizedFilePath); const comparableProjectPath = normalizedProjectPath ? normalizePathForComparison(normalizedProjectPath) : undefined; if ( normalizedProjectPath && comparableProjectPath && comparableFilePath.startsWith(`${comparableProjectPath}/`) ) { return normalizedFilePath.slice(normalizedProjectPath.length + 1); } return normalizedFilePath.split('/').slice(-3).join('/'); } }