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.
This commit is contained in:
2026-05-14 21:21:51 +02:00
parent bae4b66fa4
commit 756e36c720
33 changed files with 59 additions and 51 deletions

View File

@@ -4,7 +4,7 @@
*
* Decomposer dispatcher — takes an approved PRD and invokes the decomposer
* agent to write the epic folder + per-requirement story files under
* docs/work/<epic-slug>/.
* docs/work/epics/<epic-slug>/.
*
* Default mode (no --execute): print the dispatch plan + validate the PRD
* (refuses to proceed on draft / in-review / shipped). Safe anywhere.
@@ -82,7 +82,7 @@ export function printDecomposePlan(prdId, prdPath, frontmatter) {
console.log();
console.log(` Decomposer prompt: .sandcastle/decomposer.prompt.md`);
console.log(
` Output: docs/work/<epic-slug>/_epic.md + per-requirement story files`,
` Output: docs/work/epics/<epic-slug>/_epic.md + per-requirement story files`,
);
console.log();
console.log("To run for real:");
@@ -205,7 +205,7 @@ export async function executeDecompose(prdId, prdPath, prdText) {
console.log();
console.log("=== Suggested next steps ===");
console.log(
` 1. Inspect the new epic folder under docs/work/<epic-slug>/ on branch ${result.branch}`,
` 1. Inspect the new epic folder under docs/work/epics/<epic-slug>/ on branch ${result.branch}`,
);
console.log(
` 2. Review the generated stories + tasks; edit anything that should change`,

View File

@@ -30,7 +30,13 @@ export function findNextTask(workRoot = WORK_ROOT) {
const state = buildState(workRoot);
if (state.ready.length === 0) return null;
const next = state.ready[0];
const storyPath = path.join(workRoot, next.epic, next.story, "_story.md");
const storyPath = path.join(
workRoot,
"epics",
next.epic,
next.story,
"_story.md",
);
if (!fs.existsSync(storyPath)) return null;
const storyContent = fs.readFileSync(storyPath, "utf8");
const { bulletLine, bulletIndex } = findFirstUncheckedBullet(storyContent);
@@ -198,7 +204,7 @@ export function readFrontmatterStatus(content) {
* files, applied at the parent-epic granularity.
*/
export function tickStoryBulletInEpic(workRoot, epicId, storyId) {
const epicFile = path.join(workRoot, epicId, "_epic.md");
const epicFile = path.join(workRoot, "epics", epicId, "_epic.md");
if (!fs.existsSync(epicFile)) return false;
const content = fs.readFileSync(epicFile, "utf8");
const lines = content.split("\n");
@@ -230,7 +236,7 @@ export function tickStoryBulletInEpic(workRoot, epicId, storyId) {
* frontmatter to `status: done`. Returns true if it flipped, false otherwise.
*/
export function flipEpicDoneIfAllStoriesDone(workRoot, epicId) {
const epicDir = path.join(workRoot, epicId);
const epicDir = path.join(workRoot, "epics", epicId);
const epicFile = path.join(epicDir, "_epic.md");
if (!fs.existsSync(epicFile)) return false;
const epicContent = fs.readFileSync(epicFile, "utf8");
@@ -500,7 +506,10 @@ function applyApprovedState(next) {
const filesToStage = [path.relative(REPO_ROOT, next.storyPath)];
if (epicFlipped || epicBulletTicked) {
filesToStage.push(
path.relative(REPO_ROOT, path.join(WORK_ROOT, next.epic, "_epic.md")),
path.relative(
REPO_ROOT,
path.join(WORK_ROOT, "epics", next.epic, "_epic.md"),
),
);
}
const commitMsg = epicFlipped

View File

@@ -144,11 +144,13 @@ export function flipPrdStatus(text, { shippedDate, commits } = {}) {
*/
export function deriveShippingCommits(repoRoot, prdId, workRoot) {
// Find the epic whose `prd:` matches this prdId
const entries = fs.readdirSync(workRoot, { withFileTypes: true });
const epicsRoot = path.join(workRoot, "epics");
if (!fs.existsSync(epicsRoot)) return [];
const entries = fs.readdirSync(epicsRoot, { withFileTypes: true });
let epicDir = null;
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const epicFile = path.join(workRoot, entry.name, "_epic.md");
const epicFile = path.join(epicsRoot, entry.name, "_epic.md");
if (!fs.existsSync(epicFile)) continue;
const { frontmatter } = parseFrontmatter(fs.readFileSync(epicFile, "utf8"));
if (frontmatter.prd === prdId) {
@@ -160,7 +162,7 @@ export function deriveShippingCommits(repoRoot, prdId, workRoot) {
try {
const log = execSync(
`git log --format=%h --reverse -- docs/work/${epicDir}/`,
`git log --format=%h --reverse -- docs/work/epics/${epicDir}/`,
{ cwd: repoRoot, encoding: "utf8" },
);
return log.trim().split("\n").filter(Boolean);

View File

@@ -1,13 +1,10 @@
import fs from "node:fs";
import path from "node:path";
const SKIP_FOLDERS = new Set(["_templates", "_system", "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`.
* 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:
* {
@@ -34,11 +31,11 @@ export function buildState(workRoot) {
epics: {},
};
if (!fs.existsSync(workRoot)) return state;
const epicsRoot = path.join(workRoot, "epics");
if (!fs.existsSync(epicsRoot)) 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);
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;