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:
@@ -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`,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user