feat(work): auto-tick epic story bullets when a story finishes

- Add tickStoryBulletInEpic(workRoot, epic, story) helper that finds
  the bullet in the parent epic's `## Stories` section linking to the
  given story folder and flips `- [ ]` to `- [x]`. Idempotent.
- applyApprovedState now ticks the parent epic bullet whenever a
  story flips to status: done (alongside the existing per-task tick
  and epic-status flip). Epic file gets staged on either ticked-or-
  flipped, not just flipped.
- Backfill all 3 existing epics: 21 bullets ticked to match their
  already-done story statuses (binder-wrap-helper x3, library-
  evaluation-policy x9, ci-security-and-supply-chain x9).
This commit is contained in:
2026-05-14 21:05:06 +02:00
parent dd58974067
commit 3fc5c0f1ca
5 changed files with 59 additions and 23 deletions

View File

@@ -191,6 +191,40 @@ export function readFrontmatterStatus(content) {
return m ? m[1].trim() : null;
}
/**
* Tick the bullet in an epic's `## Stories` section that links to the given
* story folder. Idempotent: returns false if the bullet is already ticked or
* not present. Mirrors the per-task tick that already happens inside story
* files, applied at the parent-epic granularity.
*/
export function tickStoryBulletInEpic(workRoot, epicId, storyId) {
const epicFile = path.join(workRoot, epicId, "_epic.md");
if (!fs.existsSync(epicFile)) return false;
const content = fs.readFileSync(epicFile, "utf8");
const lines = content.split("\n");
let inStories = false;
let changed = false;
for (let i = 0; i < lines.length; i++) {
if (lines[i].startsWith("## ")) {
inStories = /^##\s+Stories\b/i.test(lines[i]);
continue;
}
if (!inStories) continue;
if (
lines[i].includes(`(${storyId}/_story.md)`) ||
lines[i].includes(`(./${storyId}/_story.md)`)
) {
if (/\[\s\]/.test(lines[i])) {
lines[i] = lines[i].replace(/\[\s\]/, "[x]");
changed = true;
}
break;
}
}
if (changed) fs.writeFileSync(epicFile, lines.join("\n"));
return changed;
}
/**
* If all stories in an epic are `status: done`, flip the epic's own
* frontmatter to `status: done`. Returns true if it flipped, false otherwise.
@@ -457,12 +491,14 @@ function applyApprovedState(next) {
fs.writeFileSync(next.storyPath, content);
let epicFlipped = false;
let epicBulletTicked = false;
if (storyFlipped) {
epicBulletTicked = tickStoryBulletInEpic(WORK_ROOT, next.epic, next.story);
epicFlipped = flipEpicDoneIfAllStoriesDone(WORK_ROOT, next.epic);
}
const filesToStage = [path.relative(REPO_ROOT, next.storyPath)];
if (epicFlipped) {
if (epicFlipped || epicBulletTicked) {
filesToStage.push(
path.relative(REPO_ROOT, path.join(WORK_ROOT, next.epic, "_epic.md")),
);