Files
agentic-dev/scripts/work/state-builder.mjs
Danijel Martinek 756e36c720 refactor(work): move epic folders into docs/work/epics/
The previous layout placed epic folders directly under docs/work/
alongside prds/ and _system/. Tightening: epics now live in their
own docs/work/epics/ subfolder, peer to prds/ and _system/. Same
shape as the existing prds/ bucket.

Final docs/work/ layout:
  README.md
  prds/<slug>.prd.md
  _system/_state.json
  epics/<slug>/_epic.md + <story-folder>/_story.md

Renames (git mv preserves history):
- docs/work/binder-wrap-helper/
    -> docs/work/epics/binder-wrap-helper/
- docs/work/library-evaluation-policy/
    -> docs/work/epics/library-evaluation-policy/
- docs/work/ci-security-and-supply-chain/
    -> docs/work/epics/ci-security-and-supply-chain/

Tooling updates:
- state-builder.mjs walks workRoot/epics/ directly; SKIP_FOLDERS
  obsoleted (no more sibling folders to filter out).
- dispatch.mjs's findNextTask, tickStoryBulletInEpic, and
  flipEpicDoneIfAllStoriesDone all join with "epics" segment.
- prd-ship.mjs's deriveShippingCommits walks workRoot/epics/ and
  git-logs docs/work/epics/<epic>/.
- decomposer.prompt.md emits epics under docs/work/epics/<epic-id>/.
- handoff + grill-with-docs glossary references updated.
- Glossary entry for Epic updated.

Reserved future shape: when a task-tracker integration (ClickUp,
Linear) ships, the epics/ subfolder hosts <task-id>-<slug>/
folders. Today it just hosts bare slugs.
2026-05-14 21:21:51 +02:00

236 lines
7.3 KiB
JavaScript

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: {
* <epic-id>: {
* status: "todo" | "in-progress" | "done",
* title: string,
* stories: {
* <story-id>: {
* 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 <id>`.
*/
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
* (`<epic>/<story>`).
*
* Returns { ready: [...], blocked: [...] } where each entry is:
* { epic, story, title, [waiting_on?] }
*/
export function computeReadyBlocked(state) {
// Build a flat map: "<epic>/<story>" => { 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 `<epic>/<story>` or just `<story>`)
// 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 };
}