import { promises as fs } from 'node:fs'; import path from 'node:path'; import type { Mission, MissionTask, TaskStatus } from './types.js'; import { normalizeTaskStatus } from './types.js'; const TASKS_LOCK_FILE = '.TASKS.md.lock'; const TASKS_LOCK_STALE_MS = 5 * 60 * 1000; const TASKS_LOCK_WAIT_MS = 5 * 1000; const TASKS_LOCK_RETRY_MS = 100; const DEFAULT_TABLE_HEADER = [ '| id | status | milestone | description | pr | notes |', '|----|--------|-----------|-------------|----|-------|', ] as const; const DEFAULT_TASKS_PREAMBLE = [ '# Tasks', '', '> Single-writer: orchestrator only. Workers read but never modify.', '', ...DEFAULT_TABLE_HEADER, ] as const; interface ParsedTableRow { readonly lineIndex: number; readonly cells: string[]; } interface ParsedTable { readonly headerLineIndex: number; readonly separatorLineIndex: number; readonly headers: string[]; readonly rows: ParsedTableRow[]; readonly idColumn: number; readonly statusColumn: number; } function normalizeHeaderName(input: string): string { return input.trim().toLowerCase(); } function splitMarkdownRow(line: string): string[] { const trimmed = line.trim(); if (!trimmed.startsWith('|')) { return []; } const parts = trimmed.split(/(? part.trim().replace(/\\\|/g, '|')); } function isSeparatorRow(cells: readonly string[]): boolean { return ( cells.length > 0 && cells.every((cell) => { const value = cell.trim(); return /^:?-{3,}:?$/.test(value); }) ); } function parseTable(content: string): ParsedTable | undefined { const lines = content.split(/\r?\n/); let headerLineIndex = -1; let separatorLineIndex = -1; let headers: string[] = []; for (let index = 0; index < lines.length; index += 1) { const cells = splitMarkdownRow(lines[index] as string); if (cells.length === 0) { continue; } const normalized = cells.map(normalizeHeaderName); if (!normalized.includes('id') || !normalized.includes('status')) { continue; } if (index + 1 >= lines.length) { continue; } const separatorCells = splitMarkdownRow(lines[index + 1] as string); if (!isSeparatorRow(separatorCells)) { continue; } headerLineIndex = index; separatorLineIndex = index + 1; headers = normalized; break; } if (headerLineIndex < 0 || separatorLineIndex < 0) { return undefined; } const idColumn = headers.indexOf('id'); const statusColumn = headers.indexOf('status'); if (idColumn < 0 || statusColumn < 0) { return undefined; } const rows: ParsedTableRow[] = []; let sawData = false; for (let index = separatorLineIndex + 1; index < lines.length; index += 1) { const rawLine = lines[index] as string; const trimmed = rawLine.trim(); if (!trimmed.startsWith('|')) { if (sawData) { break; } continue; } const cells = splitMarkdownRow(rawLine); if (cells.length === 0) { if (sawData) { break; } continue; } sawData = true; const normalizedRow = [...cells]; while (normalizedRow.length < headers.length) { normalizedRow.push(''); } rows.push({ lineIndex: index, cells: normalizedRow }); } return { headerLineIndex, separatorLineIndex, headers, rows, idColumn, statusColumn, }; } function escapeTableCell(value: string): string { return value.replace(/\|/g, '\\|').replace(/\r?\n/g, ' ').trim(); } function formatTableRow(cells: readonly string[]): string { const escaped = cells.map((cell) => escapeTableCell(cell)); return `| ${escaped.join(' | ')} |`; } function parseDependencies(raw: string | undefined): string[] { if (raw === undefined || raw.trim().length === 0) { return []; } return raw .split(',') .map((value) => value.trim()) .filter((value) => value.length > 0); } function resolveTasksFilePath(mission: Mission): string { if (path.isAbsolute(mission.tasksFile)) { return mission.tasksFile; } return path.join(mission.projectPath, mission.tasksFile); } function isNodeErrorWithCode(error: unknown, code: string): boolean { return ( typeof error === 'object' && error !== null && 'code' in error && (error as { code?: string }).code === code ); } async function delay(ms: number): Promise { await new Promise((resolve) => { setTimeout(resolve, ms); }); } async function acquireLock(lockPath: string): Promise { const startedAt = Date.now(); while (Date.now() - startedAt < TASKS_LOCK_WAIT_MS) { try { const handle = await fs.open(lockPath, 'wx'); await handle.writeFile( JSON.stringify( { pid: process.pid, acquiredAt: new Date().toISOString(), }, null, 2, ), ); await handle.close(); return; } catch (error) { if (!isNodeErrorWithCode(error, 'EEXIST')) { throw error; } try { const stats = await fs.stat(lockPath); if (Date.now() - stats.mtimeMs > TASKS_LOCK_STALE_MS) { await fs.rm(lockPath, { force: true }); await delay(TASKS_LOCK_RETRY_MS); continue; } } catch (statError) { if (!isNodeErrorWithCode(statError, 'ENOENT')) { throw statError; } } await delay(TASKS_LOCK_RETRY_MS); } } throw new Error(`Timed out acquiring TASKS lock: ${lockPath}`); } async function releaseLock(lockPath: string): Promise { await fs.rm(lockPath, { force: true }); } async function writeAtomic(filePath: string, content: string): Promise { const directory = path.dirname(filePath); await fs.mkdir(directory, { recursive: true }); const tempPath = path.join( directory, `.TASKS.md.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`, ); await fs.writeFile(tempPath, content, 'utf8'); await fs.rename(tempPath, filePath); } export function parseTasksFile(content: string): MissionTask[] { const parsedTable = parseTable(content); if (parsedTable === undefined) { return []; } const headerToColumn = new Map(); parsedTable.headers.forEach((header, index) => { headerToColumn.set(header, index); }); const descriptionColumn = headerToColumn.get('description') ?? headerToColumn.get('title') ?? -1; const milestoneColumn = headerToColumn.get('milestone') ?? -1; const prColumn = headerToColumn.get('pr') ?? -1; const notesColumn = headerToColumn.get('notes') ?? -1; const assigneeColumn = headerToColumn.get('assignee') ?? -1; const dependenciesColumn = headerToColumn.get('dependencies') ?? -1; const tasks: MissionTask[] = []; for (const row of parsedTable.rows) { const id = row.cells[parsedTable.idColumn]?.trim(); if (id === undefined || id.length === 0) { continue; } const rawStatusValue = row.cells[parsedTable.statusColumn] ?? ''; const normalized = normalizeTaskStatus(rawStatusValue); const title = descriptionColumn >= 0 ? (row.cells[descriptionColumn] ?? '') : ''; const milestone = milestoneColumn >= 0 ? (row.cells[milestoneColumn] ?? '') : ''; const pr = prColumn >= 0 ? (row.cells[prColumn] ?? '') : ''; const notes = notesColumn >= 0 ? (row.cells[notesColumn] ?? '') : ''; const assignee = assigneeColumn >= 0 ? (row.cells[assigneeColumn] ?? '') : ''; const dependenciesRaw = dependenciesColumn >= 0 ? (row.cells[dependenciesColumn] ?? '') : ''; tasks.push({ id, title, status: normalized.status, dependencies: parseDependencies(dependenciesRaw), milestone: milestone.length > 0 ? milestone : undefined, pr: pr.length > 0 ? pr : undefined, notes: notes.length > 0 ? notes : undefined, assignee: assignee.length > 0 ? assignee : undefined, rawStatus: normalized.rawStatus, line: row.lineIndex + 1, }); } return tasks; } export function writeTasksFile(tasks: MissionTask[]): string { const lines: string[] = [...DEFAULT_TASKS_PREAMBLE]; for (const task of tasks) { lines.push( formatTableRow([ task.id, task.status, task.milestone ?? '', task.title, task.pr ?? '', task.notes ?? '', ]), ); } return `${lines.join('\n')}\n`; } export async function updateTaskStatus( mission: Mission, taskId: string, status: TaskStatus, ): Promise { const tasksFilePath = resolveTasksFilePath(mission); const lockPath = path.join(path.dirname(tasksFilePath), TASKS_LOCK_FILE); await fs.mkdir(path.dirname(tasksFilePath), { recursive: true }); await acquireLock(lockPath); try { let content: string; try { content = await fs.readFile(tasksFilePath, 'utf8'); } catch (error) { if (isNodeErrorWithCode(error, 'ENOENT')) { throw new Error(`TASKS file not found: ${tasksFilePath}`); } throw error; } const table = parseTable(content); if (table === undefined) { throw new Error(`Could not parse TASKS table in ${tasksFilePath}`); } const matchingRows = table.rows.filter((row) => { const rowTaskId = row.cells[table.idColumn]?.trim(); return rowTaskId === taskId; }); if (matchingRows.length === 0) { throw new Error(`Task not found in TASKS.md: ${taskId}`); } if (matchingRows.length > 1) { throw new Error(`Duplicate task IDs found in TASKS.md: ${taskId}`); } const targetRow = matchingRows[0] as ParsedTableRow; const updatedCells = [...targetRow.cells]; updatedCells[table.statusColumn] = status; const lines = content.split(/\r?\n/); lines[targetRow.lineIndex] = formatTableRow(updatedCells); const updatedContent = `${lines.join('\n').replace(/\n+$/, '')}\n`; await writeAtomic(tasksFilePath, updatedContent); } finally { await releaseLock(lockPath); } }