Skip to content
This repository was archived by the owner on Aug 11, 2026. It is now read-only.

Commit f4b7a36

Browse files
committed
feat(migrate): add --logs-to-ledger to migrate logs/*.md into ledger.jsonl
Converts legacy per-task .md files in logs/ into append-only ledger records. Resolves ID conflicts caused by the old core allowing duplicate IDs between board/ and logs/ — completed tasks take precedence and conflicting board tasks are renamed to the next available ID. Made-with: Cursor
1 parent 9e33fca commit f4b7a36

5 files changed

Lines changed: 295 additions & 4 deletions

File tree

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@brainfile/cli",
3-
"version": "0.17.1",
3+
"version": "0.17.2",
44
"description": "Command-line interface for Brainfile task management",
55
"main": "dist/index.js",
66
"types": "dist/index.d.ts",
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import * as fs from 'fs';
2+
import * as os from 'os';
3+
import * as path from 'path';
4+
import { migrateCommand } from '../commands/migrate';
5+
import { readTaskFile, writeTaskFile, readLedger } from '@brainfile/core';
6+
7+
function setupV2Workspace(tempDir: string): { dotDir: string; boardDir: string; logsDir: string } {
8+
const dotDir = path.join(tempDir, '.brainfile');
9+
const boardDir = path.join(dotDir, 'board');
10+
const logsDir = path.join(dotDir, 'logs');
11+
fs.mkdirSync(boardDir, { recursive: true });
12+
fs.mkdirSync(logsDir, { recursive: true });
13+
14+
const config = `---
15+
title: Test Board
16+
schema: https://brainfile.md/v2/board.json
17+
columns:
18+
- id: todo
19+
title: To Do
20+
- id: in-progress
21+
title: In Progress
22+
---
23+
`;
24+
fs.writeFileSync(path.join(dotDir, 'brainfile.md'), config, 'utf-8');
25+
return { dotDir, boardDir, logsDir };
26+
}
27+
28+
function writeLogTask(logsDir: string, id: string, title: string, extra: Record<string, unknown> = {}): void {
29+
const task = { id, title, completedAt: '2026-01-15T12:00:00Z', ...extra };
30+
writeTaskFile(path.join(logsDir, `${id}.md`), task as any, `Completed ${title}.`);
31+
}
32+
33+
function writeBoardTask(boardDir: string, id: string, title: string, extra: Record<string, unknown> = {}): void {
34+
const task = { id, title, column: 'todo', position: 0, ...extra };
35+
writeTaskFile(path.join(boardDir, `${id}.md`), task as any, '');
36+
}
37+
38+
describe('migrate --logs-to-ledger', () => {
39+
let tempDir: string;
40+
let originalCwd: string;
41+
42+
beforeEach(() => {
43+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'brainfile-logs-ledger-'));
44+
originalCwd = process.cwd();
45+
process.chdir(tempDir);
46+
});
47+
48+
afterEach(() => {
49+
process.chdir(originalCwd);
50+
fs.rmSync(tempDir, { recursive: true, force: true });
51+
});
52+
53+
it('migrates log .md files into ledger.jsonl and removes them', () => {
54+
const { logsDir } = setupV2Workspace(tempDir);
55+
writeLogTask(logsDir, 'task-1', 'First completed task');
56+
writeLogTask(logsDir, 'task-2', 'Second completed task', { tags: ['bug'] });
57+
58+
migrateCommand({ logsToLedger: true });
59+
60+
const records = readLedger(logsDir);
61+
expect(records.length).toBe(2);
62+
expect(records.map((r) => r.id).sort()).toEqual(['task-1', 'task-2']);
63+
expect(records[0].completedAt).toBe('2026-01-15T12:00:00Z');
64+
65+
expect(fs.existsSync(path.join(logsDir, 'task-1.md'))).toBe(false);
66+
expect(fs.existsSync(path.join(logsDir, 'task-2.md'))).toBe(false);
67+
});
68+
69+
it('skips log tasks already in the ledger', () => {
70+
const { logsDir } = setupV2Workspace(tempDir);
71+
writeLogTask(logsDir, 'task-1', 'Already tracked');
72+
73+
// Pre-populate ledger with task-1
74+
const ledgerPath = path.join(logsDir, 'ledger.jsonl');
75+
fs.writeFileSync(ledgerPath, JSON.stringify({
76+
id: 'task-1', type: 'task', title: 'Already tracked',
77+
filesChanged: ['task-1.md'], createdAt: '2026-01-01T00:00:00Z',
78+
completedAt: '2026-01-15T12:00:00Z', cycleTimeHours: 360, summary: 'done',
79+
}) + '\n');
80+
81+
migrateCommand({ logsToLedger: true });
82+
83+
const records = readLedger(logsDir);
84+
expect(records.length).toBe(1);
85+
// .md file kept (no --force)
86+
expect(fs.existsSync(path.join(logsDir, 'task-1.md'))).toBe(true);
87+
});
88+
89+
it('removes stale .md files with --force when already in ledger', () => {
90+
const { logsDir } = setupV2Workspace(tempDir);
91+
writeLogTask(logsDir, 'task-1', 'Already tracked');
92+
93+
const ledgerPath = path.join(logsDir, 'ledger.jsonl');
94+
fs.writeFileSync(ledgerPath, JSON.stringify({
95+
id: 'task-1', type: 'task', title: 'Already tracked',
96+
filesChanged: ['task-1.md'], createdAt: '2026-01-01T00:00:00Z',
97+
completedAt: '2026-01-15T12:00:00Z', cycleTimeHours: 360, summary: 'done',
98+
}) + '\n');
99+
100+
migrateCommand({ logsToLedger: true, force: true });
101+
102+
expect(fs.existsSync(path.join(logsDir, 'task-1.md'))).toBe(false);
103+
expect(readLedger(logsDir).length).toBe(1);
104+
});
105+
106+
it('resolves ID conflicts by renaming the board task', () => {
107+
const { boardDir, logsDir } = setupV2Workspace(tempDir);
108+
109+
// Simulate the old bug: task-1 exists in both board/ and logs/
110+
writeLogTask(logsDir, 'task-1', 'Original completed task');
111+
writeBoardTask(boardDir, 'task-1', 'Duplicate board task');
112+
writeBoardTask(boardDir, 'task-5', 'Another board task');
113+
114+
migrateCommand({ logsToLedger: true });
115+
116+
// Log task should be in ledger
117+
const records = readLedger(logsDir);
118+
expect(records.length).toBe(1);
119+
expect(records[0].id).toBe('task-1');
120+
expect(records[0].title).toBe('Original completed task');
121+
122+
// Old board/task-1.md should be gone
123+
expect(fs.existsSync(path.join(boardDir, 'task-1.md'))).toBe(false);
124+
125+
// Board task should have been renamed (next available after task-5 + ledger task-1)
126+
const boardFiles = fs.readdirSync(boardDir).filter((f) => f.endsWith('.md'));
127+
expect(boardFiles).toContain('task-5.md');
128+
expect(boardFiles.length).toBe(2);
129+
130+
// The renamed task should have the same title as the duplicate
131+
const renamedFile = boardFiles.find((f) => f !== 'task-5.md')!;
132+
const renamedTask = readTaskFile(path.join(boardDir, renamedFile));
133+
expect(renamedTask).not.toBeNull();
134+
expect(renamedTask!.task.title).toBe('Duplicate board task');
135+
136+
// Log .md file should be cleaned up
137+
expect(fs.existsSync(path.join(logsDir, 'task-1.md'))).toBe(false);
138+
});
139+
140+
it('handles epic- and adr- prefixed tasks', () => {
141+
const { logsDir } = setupV2Workspace(tempDir);
142+
writeLogTask(logsDir, 'epic-1', 'Done epic');
143+
writeLogTask(logsDir, 'adr-1', 'Done ADR');
144+
145+
migrateCommand({ logsToLedger: true });
146+
147+
const records = readLedger(logsDir);
148+
expect(records.length).toBe(2);
149+
expect(records.find((r) => r.id === 'epic-1')?.type).toBe('epic');
150+
expect(records.find((r) => r.id === 'adr-1')?.type).toBe('adr');
151+
});
152+
153+
it('does nothing when logs/ has no .md files', () => {
154+
const { logsDir } = setupV2Workspace(tempDir);
155+
156+
migrateCommand({ logsToLedger: true });
157+
158+
expect(fs.existsSync(path.join(logsDir, 'ledger.jsonl'))).toBe(false);
159+
});
160+
});

src/cli.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ Brainfile file resolution (when you don't pass --file):
160160
.option('--dir <path>', 'Directory containing legacy brainfile files (default: cwd)')
161161
.option('--force', 'Overwrite existing migration outputs (task files/backups)')
162162
.option('--v2', 'Deprecated alias; migration now targets v2 by default')
163+
.option('--logs-to-ledger', 'Migrate logs/*.md files into ledger.jsonl (resolves ID conflicts with board)')
163164
.action(migrateCommand);
164165

165166
const listCmd = program

src/commands/migrate.ts

Lines changed: 131 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,16 @@ import * as fs from 'fs';
22
import * as path from 'path';
33
import chalk from 'chalk';
44
import { Brainfile } from '@brainfile/core';
5-
import { writeTaskFile, taskFileName, type Task } from '@brainfile/core';
5+
import {
6+
writeTaskFile,
7+
taskFileName,
8+
readTasksDir,
9+
readLedger,
10+
buildLedgerRecord,
11+
appendLedgerRecord,
12+
generateNextFileTaskId,
13+
type Task,
14+
} from '@brainfile/core';
615
import { ensureDotBrainfileGitignore, removeLegacyStateFile } from '../utils/dot-brainfile';
716
import { ensureV2Dirs } from '../utils/v2-detect';
817
import { probeWorkspaceFormat, type WorkspaceProbe } from '../utils/workspace-format';
@@ -14,13 +23,20 @@ interface MigrateOptions {
1423
force?: boolean;
1524
/** Deprecated alias; migration always targets v2 now */
1625
v2?: boolean;
26+
/** Migrate logs/*.md files into ledger.jsonl and clean up */
27+
logsToLedger?: boolean;
1728
}
1829

1930
/**
2031
* Migrate legacy workspace layouts to v2 (.brainfile/brainfile.md + board/ + logs/).
2132
*/
2233
export function migrateCommand(options: MigrateOptions = {}) {
2334
try {
35+
if (options.logsToLedger) {
36+
migrateLogsToLedger(options);
37+
return;
38+
}
39+
2440
const rootDir = path.resolve(options.dir || process.cwd());
2541
const probe = probeWorkspaceFormat(rootDir);
2642

@@ -279,6 +295,120 @@ function migrateBrainfileToV2(brainfilePath: string, options: MigrateOptions): v
279295
console.log(chalk.gray(` Backup: ${backupPath}`));
280296
}
281297

298+
/**
299+
* Extract the type prefix from a task ID (e.g. "task" from "task-42", "epic" from "epic-3").
300+
*/
301+
function getTypePrefix(taskId: string): string {
302+
const match = taskId.match(/^(.+)-\d+$/);
303+
return match ? match[1] : 'task';
304+
}
305+
306+
/**
307+
* Migrate legacy logs/*.md files into ledger.jsonl.
308+
*
309+
* When the old core allowed ID reuse (e.g. task-1 in both board/ and logs/),
310+
* this resolves the conflict by keeping the log entry (it was completed first)
311+
* and renaming the board task to the next available ID.
312+
*/
313+
function migrateLogsToLedger(options: MigrateOptions): void {
314+
const rootDir = path.resolve(options.dir || process.cwd());
315+
const probe = probeWorkspaceFormat(rootDir);
316+
317+
if (probe.format === 'empty') {
318+
console.error(chalk.red('Error: No brainfile workspace found.'));
319+
process.exit(1);
320+
return;
321+
}
322+
323+
const { logsDir, boardDir } = probe.paths;
324+
325+
if (!fs.existsSync(logsDir)) {
326+
console.log(chalk.gray('No logs/ directory found. Nothing to migrate.'));
327+
return;
328+
}
329+
330+
const logDocs = readTasksDir(logsDir);
331+
if (logDocs.length === 0) {
332+
console.log(chalk.gray('No .md log files found in logs/. Nothing to migrate.'));
333+
return;
334+
}
335+
336+
// Read only the actual ledger.jsonl — don't use readLedger() which falls back
337+
// to reading the very .md files we're about to migrate.
338+
const ledgerPath = path.join(logsDir, 'ledger.jsonl');
339+
const existingLedgerIds = new Set<string>();
340+
if (fs.existsSync(ledgerPath)) {
341+
const lines = fs.readFileSync(ledgerPath, 'utf-8').split('\n');
342+
for (const line of lines) {
343+
const trimmed = line.trim();
344+
if (!trimmed) continue;
345+
try {
346+
const record = JSON.parse(trimmed);
347+
if (record?.id) existingLedgerIds.add(record.id);
348+
} catch { /* skip malformed lines */ }
349+
}
350+
}
351+
352+
const boardDocs = fs.existsSync(boardDir) ? readTasksDir(boardDir) : [];
353+
const boardIdMap = new Map(boardDocs.map((d) => [d.task.id, d]));
354+
355+
let migratedCount = 0;
356+
let skippedCount = 0;
357+
let conflictCount = 0;
358+
359+
for (const doc of logDocs) {
360+
const taskId = doc.task.id;
361+
362+
if (existingLedgerIds.has(taskId)) {
363+
console.log(chalk.gray(` Skip: ${taskId} (already in ledger)`));
364+
if (options.force) {
365+
fs.rmSync(doc.filePath!, { force: true });
366+
console.log(chalk.gray(` Removed stale log file: ${path.basename(doc.filePath!)}`));
367+
}
368+
skippedCount++;
369+
continue;
370+
}
371+
372+
const record = buildLedgerRecord(doc.task, doc.body, {
373+
completedAt: doc.task.completedAt || doc.task.updatedAt || new Date().toISOString(),
374+
});
375+
appendLedgerRecord(logsDir, record);
376+
existingLedgerIds.add(taskId);
377+
migratedCount++;
378+
379+
if (boardIdMap.has(taskId)) {
380+
const boardDoc = boardIdMap.get(taskId)!;
381+
const prefix = getTypePrefix(taskId);
382+
const newId = generateNextFileTaskId(boardDir, logsDir, prefix);
383+
384+
const renamedTask: Task = { ...boardDoc.task, id: newId };
385+
const newBoardPath = path.join(boardDir, taskFileName(newId));
386+
writeTaskFile(newBoardPath, renamedTask, boardDoc.body);
387+
fs.rmSync(boardDoc.filePath!, { force: true });
388+
389+
console.log(chalk.yellow(` Conflict: board/${taskId}${newId} (completed task takes precedence)`));
390+
conflictCount++;
391+
}
392+
393+
fs.rmSync(doc.filePath!, { force: true });
394+
console.log(chalk.gray(` Migrated: ${taskId}`));
395+
}
396+
397+
console.log('');
398+
console.log(chalk.green(`Logs-to-ledger migration complete.`));
399+
console.log(chalk.gray(` Migrated to ledger: ${migratedCount}`));
400+
if (skippedCount > 0) {
401+
console.log(chalk.gray(` Skipped (in ledger): ${skippedCount}`));
402+
}
403+
if (conflictCount > 0) {
404+
console.log(chalk.yellow(` ID conflicts fixed: ${conflictCount} board task(s) renamed`));
405+
}
406+
if (skippedCount > 0 && !options.force) {
407+
console.log('');
408+
console.log(chalk.gray('Tip: Use --force to also remove stale .md files that are already in the ledger.'));
409+
}
410+
}
411+
282412
function backupAndRemoveLegacyRoot(rootBrainfilePath: string, dotDir: string): string {
283413
const backupPath = uniquePath(path.join(dotDir, 'brainfile.root.legacy.bak'));
284414
fs.mkdirSync(dotDir, { recursive: true });

0 commit comments

Comments
 (0)