agent-ecosystem/test/main/services/team/TaskChangeLedgerReader.test.ts

1338 lines
43 KiB
TypeScript

import { TaskChangeLedgerReader } from '@main/services/team/TaskChangeLedgerReader';
import { createHash } from 'crypto';
import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises';
import * as os from 'os';
import * as path from 'path';
import { afterEach, describe, expect, it } from 'vitest';
const TASK_ID = 'task-1';
function safeTaskIdSegment(taskId: string): string {
return `task-id-${createHash('sha256').update(taskId).digest('hex').slice(0, 32)}`;
}
function sha(content: string): string {
return createHash('sha256').update(content).digest('hex');
}
describe('TaskChangeLedgerReader', () => {
let tmpDir: string | null = null;
afterEach(async () => {
if (tmpDir) {
await rm(tmpDir, { recursive: true, force: true });
tmpDir = null;
}
});
it('returns warning-only notice bundles even when no file events exist', async () => {
tmpDir = await makeLedgerBundle({
events: [],
notices: [
{
schemaVersion: 1,
noticeId: 'notice-1',
taskId: TASK_ID,
taskRef: TASK_ID,
taskRefKind: 'canonical',
phase: 'work',
executionSeq: 1,
sessionId: 'session-1',
toolUseId: 'tool-1',
timestamp: '2026-03-01T10:00:00.000Z',
severity: 'warning',
message:
'Task change ledger skipped attribution because multiple task scopes were active.',
},
],
});
const reader = new TaskChangeLedgerReader();
const result = await reader.readTaskChanges({
teamName: 'team',
taskId: TASK_ID,
projectDir: tmpDir,
includeDetails: true,
});
expect(result).not.toBeNull();
expect(result?.files).toEqual([]);
expect(result?.warnings).toContain(
'Task change ledger skipped attribution because multiple task scopes were active.'
);
expect(result?.scope.toolUseIds).toEqual(['tool-1']);
});
it('reads ledger artifacts stored under Windows-safe task id segments', async () => {
tmpDir = await fsTempDir();
const taskId = 'CON';
const bundleDir = path.join(tmpDir, '.board-task-changes', 'bundles');
await mkdir(bundleDir, { recursive: true });
await writeFile(
path.join(bundleDir, `${safeTaskIdSegment(taskId)}.json`),
JSON.stringify({
schemaVersion: 1,
source: 'task-change-ledger',
taskId,
generatedAt: '2026-03-01T10:00:00.000Z',
eventCount: 0,
files: [],
totalLinesAdded: 0,
totalLinesRemoved: 0,
totalFiles: 0,
confidence: 'high',
warnings: ['reserved segment safe path'],
events: [],
}),
'utf8'
);
const reader = new TaskChangeLedgerReader();
const result = await reader.readTaskChanges({
teamName: 'team',
taskId,
projectDir: tmpDir,
includeDetails: true,
});
expect(result?.warnings).toContain('reserved segment safe path');
});
it('reads ledger artifacts stored under hashed long task id segments', async () => {
tmpDir = await fsTempDir();
const taskId = `task-${'x'.repeat(180)}`;
const bundleDir = path.join(tmpDir, '.board-task-changes', 'bundles');
await mkdir(bundleDir, { recursive: true });
await writeFile(
path.join(bundleDir, `${safeTaskIdSegment(taskId)}.json`),
JSON.stringify({
schemaVersion: 1,
source: 'task-change-ledger',
taskId,
generatedAt: '2026-03-01T10:00:00.000Z',
eventCount: 0,
files: [],
totalLinesAdded: 0,
totalLinesRemoved: 0,
totalFiles: 0,
confidence: 'high',
warnings: ['long task id safe path'],
events: [],
}),
'utf8'
);
const reader = new TaskChangeLedgerReader();
const result = await reader.readTaskChanges({
teamName: 'team',
taskId,
projectDir: tmpDir,
includeDetails: true,
});
expect(result?.warnings).toContain('long task id safe path');
});
it('maps ledger state and rename relation into snippets', async () => {
tmpDir = await makeLedgerBundle({
events: [
{
schemaVersion: 1,
eventId: 'event-1',
taskId: TASK_ID,
taskRef: TASK_ID,
taskRefKind: 'canonical',
phase: 'work',
executionSeq: 1,
sessionId: 'session-1',
toolUseId: 'tool-1',
source: 'shell_snapshot',
operation: 'modify',
confidence: 'high',
workspaceRoot: '/repo',
filePath: '/repo/src/new.ts',
relativePath: 'src/new.ts',
timestamp: '2026-03-01T10:00:00.000Z',
toolStatus: 'succeeded',
before: null,
after: null,
beforeState: { exists: true, unavailableReason: 'binary file' },
afterState: { exists: true, unavailableReason: 'binary file' },
relation: { kind: 'rename', oldPath: 'src/old.ts', newPath: 'src/new.ts' },
linesAdded: 0,
linesRemoved: 0,
},
],
});
const reader = new TaskChangeLedgerReader();
const result = await reader.readTaskChanges({
teamName: 'team',
taskId: TASK_ID,
projectDir: tmpDir,
projectPath: '/repo',
includeDetails: true,
});
const snippet = result?.files[0]?.snippets[0];
expect(snippet?.ledger?.beforeState?.unavailableReason).toBe('binary file');
expect(snippet?.ledger?.relation).toEqual({
kind: 'rename',
oldPath: 'src/old.ts',
newPath: 'src/new.ts',
});
expect(result?.files[0]?.relativePath).toBe('src/new.ts');
});
it('maps OpenCode toolpart sources into normal review snippet semantics', async () => {
tmpDir = await makeLedgerBundle({
events: [
{
schemaVersion: 1,
eventId: 'event-write',
taskId: TASK_ID,
taskRef: TASK_ID,
taskRefKind: 'canonical',
phase: 'work',
executionSeq: 1,
sessionId: 'opencode-session-1',
memberName: 'bob',
toolUseId: 'part-write',
source: 'opencode_toolpart_write',
operation: 'create',
confidence: 'exact',
workspaceRoot: '/repo',
filePath: '/repo/src/new.ts',
relativePath: 'src/new.ts',
timestamp: '2026-03-01T10:00:00.000Z',
toolStatus: 'succeeded',
before: null,
after: null,
oldString: '',
newString: 'export const value = 1;\n',
linesAdded: 1,
linesRemoved: 0,
},
{
schemaVersion: 1,
eventId: 'event-edit',
taskId: TASK_ID,
taskRef: TASK_ID,
taskRefKind: 'canonical',
phase: 'work',
executionSeq: 2,
sessionId: 'opencode-session-1',
memberName: 'bob',
toolUseId: 'part-edit',
source: 'opencode_toolpart_edit',
operation: 'modify',
confidence: 'exact',
workspaceRoot: '/repo',
filePath: '/repo/src/new.ts',
relativePath: 'src/new.ts',
timestamp: '2026-03-01T10:01:00.000Z',
toolStatus: 'succeeded',
before: null,
after: null,
oldString: 'value = 1',
newString: 'value = 2',
linesAdded: 1,
linesRemoved: 1,
},
{
schemaVersion: 1,
eventId: 'event-apply-patch',
taskId: TASK_ID,
taskRef: TASK_ID,
taskRefKind: 'canonical',
phase: 'work',
executionSeq: 3,
sessionId: 'opencode-session-1',
memberName: 'bob',
toolUseId: 'part-apply-patch',
source: 'opencode_toolpart_apply_patch',
operation: 'modify',
confidence: 'medium',
workspaceRoot: '/repo',
filePath: '/repo/src/new.ts',
relativePath: 'src/new.ts',
timestamp: '2026-03-01T10:02:00.000Z',
toolStatus: 'succeeded',
before: null,
after: null,
beforeState: { exists: true, unavailableReason: 'opencode-apply-patch-before-content-unavailable' },
afterState: { exists: true, unavailableReason: 'opencode-apply-patch-final-content-unavailable' },
linesAdded: 0,
linesRemoved: 0,
},
],
});
const reader = new TaskChangeLedgerReader();
const result = await reader.readTaskChanges({
teamName: 'team',
taskId: TASK_ID,
projectDir: tmpDir,
projectPath: '/repo',
includeDetails: true,
});
const snippets = result?.files[0]?.snippets ?? [];
expect(snippets.map((snippet) => snippet.toolName)).toEqual(['Write', 'Edit', 'Edit']);
expect(snippets.map((snippet) => snippet.type)).toEqual(['write-new', 'edit', 'edit']);
expect(snippets[2]?.ledger?.source).toBe('ledger-snapshot');
});
it('projects partial OpenCode snapshot journal evidence to a later full-text upgrade', async () => {
tmpDir = await fsTempDir();
const eventsDir = path.join(tmpDir, '.board-task-changes', 'events');
const blobsDir = path.join(tmpDir, '.board-task-changes', 'blobs');
await mkdir(eventsDir, { recursive: true });
await mkdir(blobsDir, { recursive: true });
const beforeContent = 'export const value = 1;\n';
const afterContent = 'export const value = 2;\n';
await writeFile(path.join(blobsDir, 'before.txt'), beforeContent, 'utf8');
await writeFile(path.join(blobsDir, 'after.txt'), afterContent, 'utf8');
const sourceImportKey = 'opencode\0session-1\0part-edit\0src/file.ts';
const baseEvent = {
schemaVersion: 1,
taskId: TASK_ID,
taskRef: TASK_ID,
taskRefKind: 'canonical',
phase: 'work',
executionSeq: 1,
sessionId: 'opencode-session-1',
memberName: 'bob',
toolUseId: 'part-edit',
source: 'opencode_toolpart_edit',
operation: 'modify',
confidence: 'high',
workspaceRoot: '/repo',
filePath: '/repo/src/file.ts',
relativePath: 'src/file.ts',
timestamp: '2026-03-01T10:00:00.000Z',
toolStatus: 'succeeded',
sourceRuntime: 'opencode',
sourceProvider: 'opencode',
sourceImportKey,
evidenceProof: 'opencode-snapshot',
beforeState: { exists: true, sha256: sha(beforeContent), sizeBytes: beforeContent.length },
afterState: { exists: true, sha256: sha(afterContent), sizeBytes: afterContent.length },
linesAdded: 1,
linesRemoved: 1,
};
await writeFile(
path.join(eventsDir, `${encodeURIComponent(TASK_ID)}.jsonl`),
[
{
...baseEvent,
eventId: 'event-partial',
before: null,
after: { sha256: sha(afterContent), sizeBytes: afterContent.length, blobRef: 'after.txt' },
},
{
...baseEvent,
eventId: 'event-full',
supersedesEventId: 'event-partial',
before: { sha256: sha(beforeContent), sizeBytes: beforeContent.length, blobRef: 'before.txt' },
after: { sha256: sha(afterContent), sizeBytes: afterContent.length, blobRef: 'after.txt' },
},
]
.map((entry) => JSON.stringify(entry))
.join('\n') + '\n',
'utf8'
);
const reader = new TaskChangeLedgerReader();
const result = await reader.readTaskChanges({
teamName: 'team',
taskId: TASK_ID,
projectDir: tmpDir,
projectPath: '/repo',
includeDetails: true,
});
expect(result?.files).toHaveLength(1);
const snippets = result?.files[0]?.snippets ?? [];
expect(snippets).toHaveLength(1);
expect(snippets[0]?.ledger?.eventId).toBe('event-full');
expect(snippets[0]?.ledger?.originalFullContent).toBe(beforeContent);
expect(snippets[0]?.ledger?.modifiedFullContent).toBe(afterContent);
});
it('hides suppressed OpenCode journal imports without hiding legitimate same-file imports', async () => {
tmpDir = await fsTempDir();
const eventsDir = path.join(tmpDir, '.board-task-changes', 'events');
const blobsDir = path.join(tmpDir, '.board-task-changes', 'blobs');
await mkdir(eventsDir, { recursive: true });
await mkdir(blobsDir, { recursive: true });
const beforeContent = 'export const value = 1;\n';
const staleAfterContent = 'export const value = "ambient";\n';
const legitAfterContent = 'export const value = 2;\n';
await writeFile(path.join(blobsDir, 'before.txt'), beforeContent, 'utf8');
await writeFile(path.join(blobsDir, 'stale-after.txt'), staleAfterContent, 'utf8');
await writeFile(path.join(blobsDir, 'legit-after.txt'), legitAfterContent, 'utf8');
const staleSourceImportKey = 'opencode\0session-1\0part-stale\0src/file.ts';
const legitSourceImportKey = 'opencode\0session-1\0part-legit\0src/file.ts';
const baseEvent = {
schemaVersion: 1,
taskId: TASK_ID,
taskRef: TASK_ID,
taskRefKind: 'canonical',
phase: 'work',
executionSeq: 1,
sessionId: 'opencode-session-1',
memberName: 'bob',
source: 'opencode_toolpart_edit',
operation: 'modify',
confidence: 'high',
workspaceRoot: '/repo',
filePath: '/repo/src/file.ts',
relativePath: 'src/file.ts',
timestamp: '2026-03-01T10:00:00.000Z',
toolStatus: 'succeeded',
sourceRuntime: 'opencode',
sourceProvider: 'opencode',
evidenceProof: 'opencode-snapshot',
beforeState: { exists: true, sha256: sha(beforeContent), sizeBytes: beforeContent.length },
};
await writeFile(
path.join(eventsDir, `${encodeURIComponent(TASK_ID)}.jsonl`),
[
{
...baseEvent,
eventId: 'event-stale',
toolUseId: 'part-stale',
sourceImportKey: staleSourceImportKey,
before: { sha256: sha(beforeContent), sizeBytes: beforeContent.length, blobRef: 'before.txt' },
after: {
sha256: sha(staleAfterContent),
sizeBytes: staleAfterContent.length,
blobRef: 'stale-after.txt',
},
afterState: {
exists: true,
sha256: sha(staleAfterContent),
sizeBytes: staleAfterContent.length,
},
linesAdded: 1,
linesRemoved: 1,
},
{
...baseEvent,
eventId: 'event-stale-suppressed',
toolUseId: 'opencode-snapshot-only-suppression',
sourceImportKey: staleSourceImportKey,
before: null,
after: null,
afterState: {
exists: true,
sha256: sha(staleAfterContent),
sizeBytes: staleAfterContent.length,
},
linesAdded: 0,
linesRemoved: 0,
suppressed: true,
suppressionReason: 'snapshot-only evidence does not prove file authorship',
suppressedAt: '2026-03-01T10:01:00.000Z',
supersedesEventId: 'event-stale',
},
{
...baseEvent,
eventId: 'event-legit',
toolUseId: 'part-legit',
sourceImportKey: legitSourceImportKey,
before: { sha256: sha(beforeContent), sizeBytes: beforeContent.length, blobRef: 'before.txt' },
after: {
sha256: sha(legitAfterContent),
sizeBytes: legitAfterContent.length,
blobRef: 'legit-after.txt',
},
afterState: {
exists: true,
sha256: sha(legitAfterContent),
sizeBytes: legitAfterContent.length,
},
linesAdded: 1,
linesRemoved: 1,
},
]
.map((entry) => JSON.stringify(entry))
.join('\n') + '\n',
'utf8'
);
const reader = new TaskChangeLedgerReader();
const result = await reader.readTaskChanges({
teamName: 'team',
taskId: TASK_ID,
projectDir: tmpDir,
projectPath: '/repo',
includeDetails: true,
});
expect(result?.files).toHaveLength(1);
expect(result?.files[0]?.relativePath).toBe('src/file.ts');
expect(result?.files[0]?.linesAdded).toBe(1);
expect(result?.files[0]?.linesRemoved).toBe(1);
const snippets = result?.files[0]?.snippets ?? [];
expect(snippets).toHaveLength(1);
expect(snippets[0]?.ledger?.eventId).toBe('event-legit');
expect(snippets[0]?.ledger?.originalFullContent).toBe(beforeContent);
expect(snippets[0]?.ledger?.modifiedFullContent).toBe(legitAfterContent);
});
it('groups rename relations in summary-only bundles without losing absolute paths', async () => {
const relation = { kind: 'rename', oldPath: 'src/old.ts', newPath: 'src/new.ts' };
tmpDir = await makeLedgerBundle({
events: [
{
schemaVersion: 1,
eventId: 'event-old',
taskId: TASK_ID,
taskRef: TASK_ID,
taskRefKind: 'canonical',
phase: 'work',
executionSeq: 1,
sessionId: 'session-1',
toolUseId: 'tool-1',
source: 'shell_snapshot',
operation: 'delete',
confidence: 'high',
workspaceRoot: '/repo',
filePath: '/repo/src/old.ts',
relativePath: 'src/old.ts',
timestamp: '2026-03-01T10:00:00.000Z',
toolStatus: 'succeeded',
before: null,
after: null,
relation,
linesAdded: 0,
linesRemoved: 2,
},
{
schemaVersion: 1,
eventId: 'event-new',
taskId: TASK_ID,
taskRef: TASK_ID,
taskRefKind: 'canonical',
phase: 'work',
executionSeq: 1,
sessionId: 'session-1',
toolUseId: 'tool-1',
source: 'shell_snapshot',
operation: 'create',
confidence: 'high',
workspaceRoot: '/repo',
filePath: '/repo/src/new.ts',
relativePath: 'src/new.ts',
timestamp: '2026-03-01T10:00:01.000Z',
toolStatus: 'succeeded',
before: null,
after: null,
relation,
linesAdded: 3,
linesRemoved: 0,
},
],
});
const reader = new TaskChangeLedgerReader();
const result = await reader.readTaskChanges({
teamName: 'team',
taskId: TASK_ID,
projectDir: tmpDir,
projectPath: '/repo',
includeDetails: false,
});
expect(result?.files).toHaveLength(1);
expect(result?.files[0]?.filePath).toBe('/repo/src/new.ts');
expect(result?.files[0]?.relativePath).toBe('src/new.ts');
expect(result?.files[0]?.isNewFile).toBe(false);
expect(result?.files[0]?.linesAdded).toBe(3);
expect(result?.files[0]?.linesRemoved).toBe(2);
});
it('resolves Windows rename relation paths case-insensitively in legacy fallback grouping', async () => {
const relation = { kind: 'rename', oldPath: 'src/OLD.ts', newPath: 'src/NEW.ts' };
tmpDir = await makeLedgerBundle({
events: [
{
schemaVersion: 1,
eventId: 'event-old',
taskId: TASK_ID,
taskRef: TASK_ID,
taskRefKind: 'canonical',
phase: 'work',
executionSeq: 1,
sessionId: 'session-1',
toolUseId: 'tool-1',
source: 'shell_snapshot',
operation: 'delete',
confidence: 'high',
workspaceRoot: 'C:\\Repo',
filePath: 'C:\\Repo\\SRC\\Old.ts',
relativePath: 'SRC\\Old.ts',
timestamp: '2026-03-01T10:00:00.000Z',
toolStatus: 'succeeded',
before: null,
after: null,
relation,
linesAdded: 0,
linesRemoved: 2,
},
],
});
const reader = new TaskChangeLedgerReader();
const result = await reader.readTaskChanges({
teamName: 'team',
taskId: TASK_ID,
projectDir: tmpDir,
projectPath: 'C:\\Repo',
includeDetails: false,
});
expect(result?.files).toHaveLength(1);
expect(result?.files[0]?.filePath.replace(/\\/g, '/')).toBe('C:/Repo/src/NEW.ts');
expect(result?.files[0]?.ledgerSummary?.relation).toEqual(relation);
});
it('does not synthesize rename display paths from unsafe suffix matches', async () => {
const relation = { kind: 'rename', oldPath: 'new.ts', newPath: 'old.ts' };
tmpDir = await makeLedgerBundle({
events: [
{
schemaVersion: 1,
eventId: 'event-old',
taskId: TASK_ID,
taskRef: TASK_ID,
taskRefKind: 'canonical',
phase: 'work',
executionSeq: 1,
sessionId: 'session-1',
toolUseId: 'tool-1',
source: 'shell_snapshot',
operation: 'delete',
confidence: 'high',
workspaceRoot: 'C:\\Repo',
filePath: 'C:\\Repo\\src\\renew.ts',
relativePath: 'src\\renew.ts',
timestamp: '2026-03-01T10:00:00.000Z',
toolStatus: 'succeeded',
before: null,
after: null,
relation,
linesAdded: 0,
linesRemoved: 1,
},
],
});
const reader = new TaskChangeLedgerReader();
const result = await reader.readTaskChanges({
teamName: 'team',
taskId: TASK_ID,
projectDir: tmpDir,
projectPath: 'C:\\Repo',
includeDetails: false,
});
expect(result?.files).toHaveLength(1);
expect(result?.files[0]?.filePath.replace(/\\/g, '/')).toBe('C:/Repo/src/renew.ts');
});
it('does not mark delete-then-create lifecycle on an existing path as a new file', async () => {
const filePath = '/repo/src/replaced.ts';
const original = 'export const value = 1;\n';
const modified = 'export const value = 2;\n';
tmpDir = await makeLedgerBundle({
events: [
{
schemaVersion: 1,
eventId: 'event-delete',
taskId: TASK_ID,
taskRef: TASK_ID,
taskRefKind: 'canonical',
phase: 'work',
executionSeq: 1,
sessionId: 'session-1',
toolUseId: 'tool-1',
source: 'shell_snapshot',
operation: 'delete',
confidence: 'high',
workspaceRoot: '/repo',
filePath,
relativePath: 'src/replaced.ts',
timestamp: '2026-03-01T10:00:00.000Z',
toolStatus: 'succeeded',
before: null,
after: null,
oldString: original,
newString: '',
beforeState: { exists: true, sha256: sha(original) },
afterState: { exists: false },
linesAdded: 0,
linesRemoved: 1,
},
{
schemaVersion: 1,
eventId: 'event-create',
taskId: TASK_ID,
taskRef: TASK_ID,
taskRefKind: 'canonical',
phase: 'work',
executionSeq: 2,
sessionId: 'session-1',
toolUseId: 'tool-2',
source: 'shell_snapshot',
operation: 'create',
confidence: 'high',
workspaceRoot: '/repo',
filePath,
relativePath: 'src/replaced.ts',
timestamp: '2026-03-01T10:01:00.000Z',
toolStatus: 'succeeded',
before: null,
after: null,
oldString: '',
newString: modified,
beforeState: { exists: false },
afterState: { exists: true, sha256: sha(modified) },
linesAdded: 1,
linesRemoved: 0,
},
],
});
const reader = new TaskChangeLedgerReader();
const result = await reader.readTaskChanges({
teamName: 'team',
taskId: TASK_ID,
projectDir: tmpDir,
projectPath: '/repo',
includeDetails: true,
});
expect(result?.files).toHaveLength(1);
expect(result?.files[0]?.filePath).toBe(filePath);
expect(result?.files[0]?.isNewFile).toBe(false);
});
it('preserves v2 worktree metadata from centralized ledger summaries', async () => {
tmpDir = await fsTempDir();
const bundleDir = path.join(tmpDir, '.board-task-changes', 'bundles');
await mkdir(bundleDir, { recursive: true });
await writeFile(
path.join(bundleDir, `${encodeURIComponent(TASK_ID)}.json`),
JSON.stringify({
schemaVersion: 2,
source: 'task-change-ledger',
bundleKind: 'summary',
taskId: TASK_ID,
generatedAt: '2026-03-01T10:00:00.000Z',
journalStamp: {},
integrity: 'ok',
eventCount: 1,
noticeCount: 0,
scope: {
confidence: { tier: 1, label: 'high', reason: 'bundle' },
memberName: 'alice',
agentIds: ['alice@team'],
startTimestamp: '2026-03-01T10:00:00.000Z',
endTimestamp: '2026-03-01T10:00:00.000Z',
toolUseIds: ['tool-1'],
toolUseCount: 1,
phaseSet: ['work'],
visibleFileCount: 1,
contributors: [],
worktreePaths: ['/repo/.claude/worktrees/team-atlas-alice-12345678'],
worktreeBranches: ['worktree-team-atlas-alice-12345678'],
baseWorkspaceRoots: ['/repo'],
dirtyLeaderWarnings: ['Leader workspace had uncommitted changes.'],
},
files: [
{
changeKey: 'path:/repo/.claude/worktrees/team-atlas-alice-12345678/src/a.ts',
filePath: '/repo/.claude/worktrees/team-atlas-alice-12345678/src/a.ts',
relativePath: 'src/a.ts',
linesAdded: 1,
linesRemoved: 0,
diffStatKnown: true,
eventCount: 1,
firstTimestamp: '2026-03-01T10:00:00.000Z',
lastTimestamp: '2026-03-01T10:00:00.000Z',
latestOperation: 'modify',
createdInTask: false,
deletedInTask: false,
latestBeforeHash: null,
latestAfterHash: null,
contentAvailability: 'full-text',
reviewability: 'full-text',
agentIds: ['alice@team'],
worktreePath: '/repo/.claude/worktrees/team-atlas-alice-12345678',
worktreeBranch: 'worktree-team-atlas-alice-12345678',
baseWorkspaceRoot: '/repo',
dirtyLeaderWarning: 'Leader workspace had uncommitted changes.',
},
],
totalLinesAdded: 1,
totalLinesRemoved: 0,
diffStatCompleteness: 'complete',
totalFiles: 1,
confidence: 'high',
warningCount: 0,
warnings: [],
}),
'utf8'
);
const reader = new TaskChangeLedgerReader();
const result = await reader.readTaskChanges({
teamName: 'team',
taskId: TASK_ID,
projectDir: tmpDir,
projectPath: '/repo',
includeDetails: false,
});
expect(result?.scope.worktreePaths).toEqual([
'/repo/.claude/worktrees/team-atlas-alice-12345678',
]);
expect(result?.files[0]?.ledgerSummary?.worktreePath).toBe(
'/repo/.claude/worktrees/team-atlas-alice-12345678'
);
expect(result?.files[0]?.ledgerSummary?.worktreeBranch).toBe(
'worktree-team-atlas-alice-12345678'
);
expect(result?.files[0]?.filePath).toBe(
'/repo/.claude/worktrees/team-atlas-alice-12345678/src/a.ts'
);
});
it('keeps v2 provenance fingerprint stable when only raw journal metadata changes', async () => {
tmpDir = await makeSummaryLedgerBundleV2({
bundle: {
journalStamp: { events: { bytes: 10, mtimeMs: 1, tailSha256: 'raw-a' } },
eventCount: 1,
noticeCount: 0,
warningCount: 0,
warnings: [],
},
file: {
eventCount: 1,
firstTimestamp: '2026-03-01T10:00:00.000Z',
lastTimestamp: '2026-03-01T10:00:00.000Z',
agentIds: ['alice@team'],
},
});
const reader = new TaskChangeLedgerReader();
const first = await reader.readTaskChanges({
teamName: 'team',
taskId: TASK_ID,
projectDir: tmpDir,
projectPath: '/repo',
includeDetails: false,
});
tmpDir = await makeSummaryLedgerBundleV2({
bundle: {
generatedAt: '2026-03-01T11:00:00.000Z',
journalStamp: { events: { bytes: 999, mtimeMs: 99, tailSha256: 'raw-b' } },
eventCount: 7,
noticeCount: 3,
warningCount: 1,
warnings: ['raw journal had a recovered warning'],
},
file: {
eventCount: 7,
firstTimestamp: '2026-03-01T09:00:00.000Z',
lastTimestamp: '2026-03-01T11:00:00.000Z',
agentIds: ['alice@team', 'bob@team'],
},
});
const second = await reader.readTaskChanges({
teamName: 'team',
taskId: TASK_ID,
projectDir: tmpDir,
projectPath: '/repo',
includeDetails: false,
});
expect(first?.provenance?.sourceFingerprint).toBe(second?.provenance?.sourceFingerprint);
});
it('changes v2 provenance fingerprint when projected file evidence changes', async () => {
tmpDir = await makeSummaryLedgerBundleV2({
file: {
latestAfterHash: sha('after-v1'),
latestAfterState: { exists: true, sha256: sha('after-v1'), sizeBytes: 8 },
},
});
const reader = new TaskChangeLedgerReader();
const first = await reader.readTaskChanges({
teamName: 'team',
taskId: TASK_ID,
projectDir: tmpDir,
projectPath: '/repo',
includeDetails: false,
});
tmpDir = await makeSummaryLedgerBundleV2({
file: {
latestAfterHash: sha('after-v2'),
latestAfterState: { exists: true, sha256: sha('after-v2'), sizeBytes: 8 },
},
});
const second = await reader.readTaskChanges({
teamName: 'team',
taskId: TASK_ID,
projectDir: tmpDir,
projectPath: '/repo',
includeDetails: false,
});
expect(first?.provenance?.sourceFingerprint).not.toBe(second?.provenance?.sourceFingerprint);
});
it('keeps identical relative rename relations isolated by worktree path', async () => {
tmpDir = await fsTempDir();
const bundleDir = path.join(tmpDir, '.board-task-changes', 'bundles');
await mkdir(bundleDir, { recursive: true });
await writeFile(
path.join(bundleDir, `${encodeURIComponent(TASK_ID)}.json`),
JSON.stringify({
schemaVersion: 2,
source: 'task-change-ledger',
bundleKind: 'summary',
taskId: TASK_ID,
generatedAt: '2026-03-01T10:00:00.000Z',
journalStamp: {},
integrity: 'ok',
eventCount: 2,
noticeCount: 0,
scope: {
confidence: { tier: 1, label: 'high', reason: 'bundle' },
memberName: 'alice',
agentIds: ['alice@team', 'bob@team'],
startTimestamp: '2026-03-01T10:00:00.000Z',
endTimestamp: '2026-03-01T10:01:00.000Z',
toolUseIds: ['tool-1', 'tool-2'],
toolUseCount: 2,
phaseSet: ['work'],
visibleFileCount: 2,
contributors: [],
worktreePaths: ['/repo/.claude/worktrees/team-a', '/repo/.claude/worktrees/team-b'],
},
files: [
{
changeKey: 'rename:/repo/.claude/worktrees/team-a:src/old.ts->src/new.ts',
filePath: '/repo/.claude/worktrees/team-a/src/new.ts',
relativePath: 'src/new.ts',
displayPath: 'src/new.ts',
linesAdded: 0,
linesRemoved: 0,
diffStatKnown: true,
eventCount: 1,
firstTimestamp: '2026-03-01T10:00:00.000Z',
lastTimestamp: '2026-03-01T10:00:00.000Z',
latestOperation: 'modify',
createdInTask: false,
deletedInTask: false,
latestBeforeHash: null,
latestAfterHash: null,
contentAvailability: 'full-text',
reviewability: 'full-text',
agentIds: ['alice@team'],
relation: { kind: 'rename', oldPath: 'src/old.ts', newPath: 'src/new.ts' },
worktreePath: '/repo/.claude/worktrees/team-a',
},
{
changeKey: 'rename:/repo/.claude/worktrees/team-b:src/old.ts->src/new.ts',
filePath: '/repo/.claude/worktrees/team-b/src/new.ts',
relativePath: 'src/new.ts',
displayPath: 'src/new.ts',
linesAdded: 0,
linesRemoved: 0,
diffStatKnown: true,
eventCount: 1,
firstTimestamp: '2026-03-01T10:01:00.000Z',
lastTimestamp: '2026-03-01T10:01:00.000Z',
latestOperation: 'modify',
createdInTask: false,
deletedInTask: false,
latestBeforeHash: null,
latestAfterHash: null,
contentAvailability: 'full-text',
reviewability: 'full-text',
agentIds: ['bob@team'],
relation: { kind: 'rename', oldPath: 'src/old.ts', newPath: 'src/new.ts' },
worktreePath: '/repo/.claude/worktrees/team-b',
},
],
totalLinesAdded: 0,
totalLinesRemoved: 0,
diffStatCompleteness: 'complete',
totalFiles: 2,
confidence: 'high',
warningCount: 0,
warnings: [],
}),
'utf8'
);
const reader = new TaskChangeLedgerReader();
const result = await reader.readTaskChanges({
teamName: 'team',
taskId: TASK_ID,
projectDir: tmpDir,
projectPath: '/repo',
includeDetails: false,
});
expect(result?.files).toHaveLength(2);
expect(new Set(result?.files.map((file) => file.changeKey))).toEqual(
new Set([
'rename:/repo/.claude/worktrees/team-a:src/old.ts->src/new.ts',
'rename:/repo/.claude/worktrees/team-b:src/old.ts->src/new.ts',
])
);
});
it('keeps worktree relation keys isolated when rebuilding directly from journal events', async () => {
tmpDir = await fsTempDir();
const eventsDir = path.join(tmpDir, '.board-task-changes', 'events');
await mkdir(eventsDir, { recursive: true });
await writeFile(
path.join(eventsDir, `${encodeURIComponent(TASK_ID)}.jsonl`),
[
{
schemaVersion: 1,
eventId: 'event-a',
taskId: TASK_ID,
taskRef: TASK_ID,
taskRefKind: 'canonical',
phase: 'work',
executionSeq: 1,
sessionId: 'session-a',
agentId: 'alice@team',
toolUseId: 'tool-a',
source: 'shell_snapshot',
operation: 'modify',
confidence: 'high',
workspaceRoot: '/repo/.claude/worktrees/team-a',
worktreePath: '/repo/.claude/worktrees/team-a',
filePath: '/repo/.claude/worktrees/team-a/src/new.ts',
relativePath: 'src/new.ts',
timestamp: '2026-03-01T10:00:00.000Z',
toolStatus: 'succeeded',
before: null,
after: null,
oldString: 'export const a = 1;\n',
newString: 'export const a = 2;\n',
relation: { kind: 'rename', oldPath: 'src/old.ts', newPath: 'src/new.ts' },
linesAdded: 1,
linesRemoved: 1,
},
{
schemaVersion: 1,
eventId: 'event-b',
taskId: TASK_ID,
taskRef: TASK_ID,
taskRefKind: 'canonical',
phase: 'work',
executionSeq: 2,
sessionId: 'session-b',
agentId: 'bob@team',
toolUseId: 'tool-b',
source: 'shell_snapshot',
operation: 'modify',
confidence: 'high',
workspaceRoot: '/repo/.claude/worktrees/team-b',
worktreePath: '/repo/.claude/worktrees/team-b',
filePath: '/repo/.claude/worktrees/team-b/src/new.ts',
relativePath: 'src/new.ts',
timestamp: '2026-03-01T10:01:00.000Z',
toolStatus: 'succeeded',
before: null,
after: null,
oldString: 'export const b = 1;\n',
newString: 'export const b = 2;\n',
relation: { kind: 'rename', oldPath: 'src/old.ts', newPath: 'src/new.ts' },
linesAdded: 1,
linesRemoved: 1,
},
]
.map((entry) => JSON.stringify(entry))
.join('\n') + '\n',
'utf8'
);
const reader = new TaskChangeLedgerReader();
const result = await reader.readTaskChanges({
teamName: 'team',
taskId: TASK_ID,
projectDir: tmpDir,
projectPath: '/repo',
includeDetails: false,
});
expect(result?.files).toHaveLength(2);
expect(new Set(result?.files.map((file) => file.changeKey))).toEqual(
new Set([
'rename:/repo/.claude/worktrees/team-a:src/old.ts->src/new.ts',
'rename:/repo/.claude/worktrees/team-b:src/old.ts->src/new.ts',
])
);
});
it('falls back to journal summary when bundle and freshness describe different generations', async () => {
tmpDir = await fsTempDir();
const bundleDir = path.join(tmpDir, '.board-task-changes', 'bundles');
const eventsDir = path.join(tmpDir, '.board-task-changes', 'events');
const freshnessDir = path.join(tmpDir, '.board-task-change-freshness');
await mkdir(bundleDir, { recursive: true });
await mkdir(eventsDir, { recursive: true });
await mkdir(freshnessDir, { recursive: true });
await writeFile(
path.join(bundleDir, `${encodeURIComponent(TASK_ID)}.json`),
JSON.stringify({
schemaVersion: 2,
source: 'task-change-ledger',
bundleKind: 'summary',
taskId: TASK_ID,
generatedAt: '2026-03-01T10:00:00.000Z',
journalStamp: { events: { bytes: 10, mtimeMs: 1, tailSha256: 'bundle' } },
integrity: 'ok',
eventCount: 1,
noticeCount: 0,
scope: {
confidence: { tier: 1, label: 'high', reason: 'bundle' },
memberName: 'bundle-agent',
agentIds: ['bundle-agent'],
startTimestamp: '2026-03-01T10:00:00.000Z',
endTimestamp: '2026-03-01T10:00:00.000Z',
toolUseIds: ['bundle-tool'],
toolUseCount: 1,
phaseSet: ['work'],
visibleFileCount: 1,
contributors: [],
},
files: [
{
changeKey: 'path:/repo/stale.ts',
filePath: '/repo/stale.ts',
relativePath: 'stale.ts',
linesAdded: 1,
linesRemoved: 0,
diffStatKnown: true,
eventCount: 1,
firstTimestamp: '2026-03-01T10:00:00.000Z',
lastTimestamp: '2026-03-01T10:00:00.000Z',
latestOperation: 'modify',
createdInTask: false,
deletedInTask: false,
latestBeforeHash: null,
latestAfterHash: null,
contentAvailability: 'metadata-only',
reviewability: 'metadata-only',
agentIds: ['bundle-agent'],
},
],
totalLinesAdded: 1,
totalLinesRemoved: 0,
diffStatCompleteness: 'complete',
totalFiles: 1,
confidence: 'high',
warningCount: 0,
warnings: [],
}),
'utf8'
);
await writeFile(
path.join(freshnessDir, `${encodeURIComponent(TASK_ID)}.json`),
JSON.stringify({
schemaVersion: 2,
source: 'task-change-ledger',
taskId: TASK_ID,
updatedAt: '2026-03-01T10:00:01.000Z',
journalStamp: { events: { bytes: 20, mtimeMs: 2, tailSha256: 'freshness' } },
eventCount: 1,
noticeCount: 0,
integrity: 'ok',
bundleSchemaVersion: 2,
bundleKind: 'summary',
}),
'utf8'
);
await writeFile(
path.join(eventsDir, `${encodeURIComponent(TASK_ID)}.jsonl`),
`${JSON.stringify({
schemaVersion: 1,
eventId: 'event-1',
taskId: TASK_ID,
taskRef: TASK_ID,
taskRefKind: 'canonical',
phase: 'work',
executionSeq: 1,
sessionId: 'session-1',
toolUseId: 'journal-tool',
source: 'file_edit',
operation: 'modify',
confidence: 'exact',
workspaceRoot: '/repo',
filePath: '/repo/journal.ts',
relativePath: 'journal.ts',
timestamp: '2026-03-01T10:00:02.000Z',
toolStatus: 'succeeded',
before: null,
after: null,
oldString: 'const a = 1;\n',
newString: 'const a = 2;\n',
linesAdded: 1,
linesRemoved: 1,
})}\n`,
'utf8'
);
const reader = new TaskChangeLedgerReader();
const result = await reader.readTaskChanges({
teamName: 'team',
taskId: TASK_ID,
projectDir: tmpDir,
projectPath: '/repo',
includeDetails: false,
});
expect(result?.files).toHaveLength(1);
expect(result?.files[0]?.filePath).toBe('/repo/journal.ts');
expect(result?.warnings).toContain('Task change summary fell back to journal reconstruction.');
});
});
async function makeLedgerBundle(params: {
events: unknown[];
notices?: unknown[];
}): Promise<string> {
const dir = await fsTempDir();
const bundleDir = path.join(dir, '.board-task-changes', 'bundles');
await mkdir(bundleDir, { recursive: true });
await writeFile(
path.join(bundleDir, `${encodeURIComponent(TASK_ID)}.json`),
JSON.stringify({
schemaVersion: 1,
source: 'task-change-ledger',
taskId: TASK_ID,
generatedAt: '2026-03-01T10:00:00.000Z',
eventCount: params.events.length,
files: params.events.map((event) => {
const record = event as {
filePath?: string;
relativePath?: string;
eventId?: string;
linesAdded?: number;
linesRemoved?: number;
operation?: string;
after?: { sha256?: string } | null;
};
return {
filePath: record.filePath,
relativePath: record.relativePath,
eventIds: [record.eventId],
linesAdded: record.linesAdded ?? 0,
linesRemoved: record.linesRemoved ?? 0,
isNewFile: record.operation === 'create',
latestAfterHash: record.after?.sha256 ?? null,
};
}),
totalLinesAdded: 0,
totalLinesRemoved: 0,
totalFiles: params.events.length,
confidence: 'high',
warnings: [],
events: params.events,
...(params.notices ? { notices: params.notices } : {}),
}),
'utf8'
);
return dir;
}
async function makeSummaryLedgerBundleV2(params: {
bundle?: Record<string, unknown>;
file?: Record<string, unknown>;
} = {}): Promise<string> {
const dir = await fsTempDir();
const bundleDir = path.join(dir, '.board-task-changes', 'bundles');
await mkdir(bundleDir, { recursive: true });
const file = {
changeKey: 'path:/repo/src/file.ts',
filePath: '/repo/src/file.ts',
relativePath: 'src/file.ts',
linesAdded: 1,
linesRemoved: 1,
diffStatKnown: true,
eventCount: 1,
firstTimestamp: '2026-03-01T10:00:00.000Z',
lastTimestamp: '2026-03-01T10:00:00.000Z',
latestOperation: 'modify',
createdInTask: false,
deletedInTask: false,
latestBeforeHash: sha('before'),
latestAfterHash: sha('after'),
latestBeforeState: { exists: true, sha256: sha('before'), sizeBytes: 6 },
latestAfterState: { exists: true, sha256: sha('after'), sizeBytes: 5 },
contentAvailability: 'full-text',
reviewability: 'full-text',
agentIds: ['alice@team'],
...params.file,
};
await writeFile(
path.join(bundleDir, `${encodeURIComponent(TASK_ID)}.json`),
JSON.stringify({
schemaVersion: 2,
source: 'task-change-ledger',
bundleKind: 'summary',
taskId: TASK_ID,
generatedAt: '2026-03-01T10:00:00.000Z',
journalStamp: { events: { bytes: 10, mtimeMs: 1, tailSha256: 'raw' } },
integrity: 'ok',
eventCount: 1,
noticeCount: 0,
scope: {
confidence: { tier: 1, label: 'high', reason: 'bundle' },
memberName: 'alice',
agentIds: ['alice@team'],
startTimestamp: '2026-03-01T10:00:00.000Z',
endTimestamp: '2026-03-01T10:00:00.000Z',
toolUseIds: ['tool-1'],
toolUseCount: 1,
phaseSet: ['work'],
visibleFileCount: 1,
contributors: [],
},
files: [file],
totalLinesAdded: 1,
totalLinesRemoved: 1,
diffStatCompleteness: 'complete',
totalFiles: 1,
confidence: 'high',
warningCount: 0,
warnings: [],
...params.bundle,
}),
'utf8'
);
return dir;
}
async function fsTempDir(): Promise<string> {
return mkdtemp(path.join(os.tmpdir(), 'ledger-reader-'));
}