/** Direct markdown parsing of backlog task files for AC read/toggle. */ import { readdir } from "node:fs/promises"; import { repoPath } from "./checklist-config"; const AC_BEGIN = ""; const AC_END = ""; const AC_REGEX = /^- \[([ x])\] #(\d+) (.+)$/; export interface AcceptanceCriterion { index: number; checked: boolean; text: string; } export interface TaskInfo { title: string; status: string; criteria: AcceptanceCriterion[]; filePath: string; } /** * Find the task markdown file in the backlog directory. * Files are named like "task-high.3 - Some-title.md" where the taskId * is the prefix before " - ". Case-insensitive match. */ async function findTaskFile(repo: string, taskId: string): Promise { const base = repoPath(repo); const tasksDir = `${base}/backlog/tasks`; const exact = `${tasksDir}/${taskId}.md`; if (await Bun.file(exact).exists()) return exact; const files = await readdir(tasksDir); const lower = taskId.toLowerCase(); const match = files.find((f) => f.toLowerCase().startsWith(lower + " - ") && f.endsWith(".md")); if (match) return `${tasksDir}/${match}`; throw new Error(`Task file not found for ${taskId} in ${tasksDir}`); } function parseTitle(content: string): string { const fmMatch = content.match(/^---\n[\s\S]*?title:\s*["']?(.+?)["']?\s*\n[\s\S]*?---/); if (fmMatch?.[1]) return fmMatch[1]; const headingMatch = content.match(/^#\s+(.+)$/m); return headingMatch?.[1] || "Untitled Task"; } function parseStatus(content: string): string { const match = content.match(/^status:\s*["']?(.+?)["']?\s*$/m); return match?.[1] || "Unknown"; } function parseAC(content: string): AcceptanceCriterion[] { const normalized = content.replace(/\r\n/g, "\n"); const beginIdx = normalized.indexOf(AC_BEGIN); const endIdx = normalized.indexOf(AC_END); if (beginIdx === -1 || endIdx === -1) return []; const acContent = normalized.substring(beginIdx + AC_BEGIN.length, endIdx); const lines = acContent.split("\n").filter((l) => l.trim()); const criteria: AcceptanceCriterion[] = []; for (const line of lines) { const match = line.match(AC_REGEX); if (match?.[1] && match?.[2] && match?.[3]) { criteria.push({ checked: match[1] === "x", text: match[3], index: parseInt(match[2], 10), }); } } return criteria; } export async function readTask(repo: string, taskId: string): Promise { const filePath = await findTaskFile(repo, taskId); const content = await Bun.file(filePath).text(); return { title: parseTitle(content), status: parseStatus(content), criteria: parseAC(content), filePath, }; } /** * Toggle an AC item to checked. Idempotent: if already checked, no-op. */ export async function checkAC(repo: string, taskId: string, acIndex: number): Promise { const filePath = await findTaskFile(repo, taskId); let content = await Bun.file(filePath).text(); const unchecked = `- [ ] #${acIndex} `; const checked = `- [x] #${acIndex} `; if (content.includes(unchecked)) { content = content.replace(unchecked, checked); await Bun.write(filePath, content); } return { title: parseTitle(content), status: parseStatus(content), criteria: parseAC(content), filePath, }; }