import fs from "node:fs"; import path from "node:path"; const SKIP_FOLDERS = new Set(["_templates", "prds"]); const SKIP_FILES = new Set(["README.md", "_state.json"]); /** * Walk the `docs/work/` tree starting at `workRoot` and return a structured * state object. Each epic folder must contain `_epic.md`; each story * subfolder must contain `_story.md`. * * Returns: * { * updated_at: ISO string, * epics: { * : { * status: "todo" | "in-progress" | "done", * title: string, * stories: { * : { * status: "todo" | "in-progress" | "done", * title: string, * ac_total: number, // total checkboxes in Tasks section * ac_completed: number, // - [x] checkboxes * } * } * } * } * } */ export function buildState(workRoot) { const state = { updated_at: new Date().toISOString(), epics: {}, }; if (!fs.existsSync(workRoot)) return state; for (const entry of fs.readdirSync(workRoot)) { if (SKIP_FOLDERS.has(entry) || SKIP_FILES.has(entry)) continue; const epicDir = path.join(workRoot, entry); if (!fs.statSync(epicDir).isDirectory()) continue; const epicFile = path.join(epicDir, "_epic.md"); if (!fs.existsSync(epicFile)) continue; const epicMeta = parseFrontmatter(epicFile); const epicEntry = { status: epicMeta.status ?? "todo", title: epicMeta.title ?? entry, stories: {}, }; for (const sub of fs.readdirSync(epicDir)) { const subPath = path.join(epicDir, sub); if (!fs.statSync(subPath).isDirectory()) continue; const storyFile = path.join(subPath, "_story.md"); if (!fs.existsSync(storyFile)) continue; const storyMeta = parseFrontmatter(storyFile); const storyContent = fs.readFileSync(storyFile, "utf8"); const { total, completed } = countTaskCheckboxes(storyContent); epicEntry.stories[storyMeta.id ?? sub] = { status: storyMeta.status ?? "todo", title: storyMeta.title ?? sub, ac_total: total, ac_completed: completed, }; } state.epics[epicMeta.id ?? entry] = epicEntry; } return state; } /** * Read a markdown file's YAML frontmatter (between leading `---` delimiters) * and return a flat object of string-valued keys. Numeric / bool values are * left as strings; this is enough for our needs (status, id, title fields). */ export function parseFrontmatter(filePath) { const src = fs.readFileSync(filePath, "utf8"); const match = src.match(/^---\n([\s\S]+?)\n---/); if (!match) return {}; const out = {}; for (const line of match[1].split("\n")) { const m = line.match(/^([\w-]+):\s*(.*)$/); if (!m) continue; let value = m[2].trim(); if (value.startsWith('"') && value.endsWith('"')) value = value.slice(1, -1); if (value.startsWith("'") && value.endsWith("'")) value = value.slice(1, -1); out[m[1]] = value; } return out; } /** * Count `- [x]` and `- [ ]` checkboxes inside the file's `## Tasks` section. * Returns { total, completed }. */ export function countTaskCheckboxes(content) { const lines = content.split("\n"); let inTasks = false; let total = 0; let completed = 0; for (const line of lines) { if (line.startsWith("## ")) { inTasks = /^##\s+Tasks\b/i.test(line); continue; } if (!inTasks) continue; const m = line.match(/^[\s>-]*\[(.)\]/); if (m) { total++; if (m[1] === "x" || m[1] === "X") completed++; } } return { total, completed }; }