import fs from "node:fs"; import path from "node:path"; /** * Walk the `docs/work/epics/` tree under `workRoot` and return a structured * state object. Each epic folder under `epics/` 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: {}, }; const epicsRoot = path.join(workRoot, "epics"); if (!fs.existsSync(epicsRoot)) return state; for (const entry of fs.readdirSync(epicsRoot)) { const epicDir = path.join(epicsRoot, 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, prd: epicMeta.prd && epicMeta.prd !== "null" ? epicMeta.prd : null, 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, depends_on: Array.isArray(storyMeta["depends-on"]) ? storyMeta["depends-on"] : [], blocks: Array.isArray(storyMeta.blocks) ? storyMeta.blocks : [], }; } state.epics[epicMeta.id ?? entry] = epicEntry; } const { ready, blocked } = computeReadyBlocked(state); state.ready = ready; state.blocked = blocked; state.needs_prd_ship = computeNeedsPrdShip(state, workRoot); return state; } /** * Find epics whose status is "done" + have a non-null `prd:` link + the * linked PRD's status is NOT yet "shipped". Surfacing these in state lets * the reviewer (or a post-merge hook) trigger `pnpm work prd-ship `. */ export function computeNeedsPrdShip(state, workRoot) { const out = []; const prdsDir = path.join(workRoot, "prds"); if (!fs.existsSync(prdsDir)) return out; // Index PRDs by id -> { path, status } const prdIndex = new Map(); for (const file of fs.readdirSync(prdsDir)) { if (!file.endsWith(".prd.md")) continue; const prdPath = path.join(prdsDir, file); const meta = parseFrontmatter(prdPath); if (meta.id) prdIndex.set(meta.id, { path: prdPath, status: meta.status }); } for (const [epicId, epic] of Object.entries(state.epics)) { if (epic.status !== "done") continue; if (!epic.prd) continue; const prd = prdIndex.get(epic.prd); if (!prd) continue; // PRD link points to a missing file — orchestrator concern, not ours if (prd.status === "shipped") continue; // already done out.push({ epic: epicId, prd: epic.prd, prd_status: prd.status ?? "unknown", action: `pnpm work prd-ship ${epic.prd} --auto-commits`, }); } return out; } /** * Given a built state object, compute the ready + blocked story sets. * * "ready" = not done AND all depends_on stories are done * "blocked" = not done AND at least one depends_on story is not done * * depends_on references are either same-epic (just story id) or cross-epic * (`/`). * * Returns { ready: [...], blocked: [...] } where each entry is: * { epic, story, title, [waiting_on?] } */ export function computeReadyBlocked(state) { // Build a flat map: "/" => { status, title, depends_on } const flat = new Map(); for (const [epic, epicEntry] of Object.entries(state.epics)) { for (const [story, storyEntry] of Object.entries(epicEntry.stories)) { flat.set(`${epic}/${story}`, { epic, story, status: storyEntry.status, title: storyEntry.title, depends_on: storyEntry.depends_on, }); } } // Resolve a depends_on reference (which may be `/` or just ``) // relative to the current epic. function resolveRef(ref, currentEpic) { if (ref.includes("/")) return ref; return `${currentEpic}/${ref}`; } const ready = []; const blocked = []; for (const entry of flat.values()) { if (entry.status === "done") continue; const refs = entry.depends_on.map((r) => resolveRef(r, entry.epic)); const waitingOn = refs.filter((r) => { const dep = flat.get(r); return !dep || dep.status !== "done"; }); if (waitingOn.length === 0) { ready.push({ epic: entry.epic, story: entry.story, title: entry.title }); } else { blocked.push({ epic: entry.epic, story: entry.story, title: entry.title, waiting_on: waitingOn, }); } } return { ready, blocked }; } /** * Read a markdown file's YAML frontmatter (between leading `---` delimiters) * and return a flat object of string or array values. Numeric / bool values are * left as strings; array values like `[a, b, "c"]` are parsed into JS arrays. */ 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(); // Array values like [a, b, "c"] or [] if (value.startsWith("[") && value.endsWith("]")) { const inner = value.slice(1, -1).trim(); if (inner === "") { out[m[1]] = []; } else { out[m[1]] = inner.split(",").map((s) => { let v = s.trim(); if (v.startsWith('"') && v.endsWith('"')) v = v.slice(1, -1); if (v.startsWith("'") && v.endsWith("'")) v = v.slice(1, -1); return v; }); } continue; } // Strings (with optional quotes) 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 }; }