Merge branch 'worktree-work-system-v1': work-system v1 MVP — state + pnpm work CLI
This commit is contained in:
107
docs/work/_state.json
Normal file
107
docs/work/_state.json
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
{
|
||||||
|
"updated_at": "2026-05-13T05:47:28.219Z",
|
||||||
|
"epics": {
|
||||||
|
"agent-workflow-docs-v1": {
|
||||||
|
"status": "done",
|
||||||
|
"title": "Agent-workflow docs rollout",
|
||||||
|
"stories": {
|
||||||
|
"01-docs-rewrite": {
|
||||||
|
"status": "done",
|
||||||
|
"title": "Surface conformance system across top-level docs",
|
||||||
|
"ac_total": 8,
|
||||||
|
"ac_completed": 8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"conformance-hardening-v1": {
|
||||||
|
"status": "done",
|
||||||
|
"title": "Conformance hardening v1 — AST manifest parsing + dev-seed boot assertion",
|
||||||
|
"stories": {
|
||||||
|
"01-ast-manifest-source": {
|
||||||
|
"status": "done",
|
||||||
|
"title": "Replace regex manifest source parser with AST",
|
||||||
|
"ac_total": 5,
|
||||||
|
"ac_completed": 5
|
||||||
|
},
|
||||||
|
"02-dev-seed-assertion": {
|
||||||
|
"status": "done",
|
||||||
|
"title": "Extend assertFeatureConformance to all bind-dev-seed paths",
|
||||||
|
"ac_total": 6,
|
||||||
|
"ac_completed": 6
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"conformance-system-v1": {
|
||||||
|
"status": "done",
|
||||||
|
"title": "Conformance system v1",
|
||||||
|
"stories": {
|
||||||
|
"01-define-feature-helper": {
|
||||||
|
"status": "done",
|
||||||
|
"title": "defineFeature helper + Instrumented/Captured/Audited brands",
|
||||||
|
"ac_total": 9,
|
||||||
|
"ac_completed": 9
|
||||||
|
},
|
||||||
|
"02-boot-assertions": {
|
||||||
|
"status": "done",
|
||||||
|
"title": "assertFeatureConformance + boot wiring",
|
||||||
|
"ac_total": 11,
|
||||||
|
"ac_completed": 11
|
||||||
|
},
|
||||||
|
"03-a-structural-eslint-rules": {
|
||||||
|
"status": "done",
|
||||||
|
"title": "Structural ESLint rules (feature-must-have-manifest, usecase-must-have-test-file, required-cores-installed)",
|
||||||
|
"ac_total": 10,
|
||||||
|
"ac_completed": 10
|
||||||
|
},
|
||||||
|
"03-b-ast-eslint-rules": {
|
||||||
|
"status": "done",
|
||||||
|
"title": "AST-aware ESLint rules (no-undeclared-event-publish, no-undeclared-audit)",
|
||||||
|
"ac_total": 8,
|
||||||
|
"ac_completed": 8
|
||||||
|
},
|
||||||
|
"04-ci-drift-gate": {
|
||||||
|
"status": "done",
|
||||||
|
"title": "CI drift gate — pnpm conformance with cross-feature event closure",
|
||||||
|
"ac_total": 6,
|
||||||
|
"ac_completed": 6
|
||||||
|
},
|
||||||
|
"05-generator-updates": {
|
||||||
|
"status": "done",
|
||||||
|
"title": "Generator updates — emit feature.manifest.ts + self-asserting bind-production",
|
||||||
|
"ac_total": 7,
|
||||||
|
"ac_completed": 7
|
||||||
|
},
|
||||||
|
"06-feature-migrations": {
|
||||||
|
"status": "done",
|
||||||
|
"title": "Migrate blog/media/navigation/marketing-pages to conformance pattern",
|
||||||
|
"ac_total": 7,
|
||||||
|
"ac_completed": 7
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"frontend-conformance-v1": {
|
||||||
|
"status": "done",
|
||||||
|
"title": "Frontend conformance rules v1",
|
||||||
|
"stories": {
|
||||||
|
"01-frontend-rules": {
|
||||||
|
"status": "done",
|
||||||
|
"title": "Three structural frontend conformance ESLint rules",
|
||||||
|
"ac_total": 6,
|
||||||
|
"ac_completed": 6
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"work-system-v1": {
|
||||||
|
"status": "done",
|
||||||
|
"title": "Work system v1 (MVP) — state tracking + pnpm work CLI",
|
||||||
|
"stories": {
|
||||||
|
"01-state-builder-and-cli": {
|
||||||
|
"status": "done",
|
||||||
|
"title": "State builder + pnpm work CLI",
|
||||||
|
"ac_total": 5,
|
||||||
|
"ac_completed": 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
33
docs/work/work-system-v1/01-state-builder-and-cli/_story.md
Normal file
33
docs/work/work-system-v1/01-state-builder-and-cli/_story.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
---
|
||||||
|
id: 01-state-builder-and-cli
|
||||||
|
epic: work-system-v1
|
||||||
|
title: State builder + pnpm work CLI
|
||||||
|
type: technical-story
|
||||||
|
status: done
|
||||||
|
feature: scripts
|
||||||
|
depends-on: []
|
||||||
|
blocks: []
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
`pnpm work status` shows the current epic/story state across `docs/work/`.
|
||||||
|
`pnpm work next` prints the next unblocked story. `pnpm work rebuild-state`
|
||||||
|
regenerates `_state.json` from markdown.
|
||||||
|
|
||||||
|
## In scope
|
||||||
|
- `scripts/work/state-builder.mjs` — pure function, fully tested
|
||||||
|
- `scripts/work/cli.mjs` — argument dispatch + I/O wrapping the builder
|
||||||
|
- `docs/work/_state.json` — initial committed snapshot
|
||||||
|
- pnpm work script entry
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
- Per-task state (tasks are markdown checkboxes, not separate entries)
|
||||||
|
- Orchestrator dispatch logic
|
||||||
|
- Dependency-graph analysis
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
- [x] Epic + story scaffold
|
||||||
|
- [x] state-builder.mjs + tests
|
||||||
|
- [x] cli.mjs + tests
|
||||||
|
- [x] _state.json initial commit + pnpm work script entry
|
||||||
|
- [x] Final verification + closeout
|
||||||
28
docs/work/work-system-v1/_epic.md
Normal file
28
docs/work/work-system-v1/_epic.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
---
|
||||||
|
id: work-system-v1
|
||||||
|
prd: null
|
||||||
|
title: Work system v1 (MVP) — state tracking + pnpm work CLI
|
||||||
|
type: epic
|
||||||
|
status: done
|
||||||
|
features: [scripts]
|
||||||
|
created: 2026-05-13
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Filesystem-only state tracking for `docs/work/` + a `pnpm work` CLI with
|
||||||
|
status / next / rebuild-state subcommands. Foundation for future
|
||||||
|
orchestration; no agent dispatch in v1.
|
||||||
|
|
||||||
|
## Why
|
||||||
|
Agents and humans both need a fast way to see "what's the next unblocked
|
||||||
|
work?" and "where are we?" — without scanning every story file by hand.
|
||||||
|
|
||||||
|
## Out of scope (v2+)
|
||||||
|
- Sandcastle / agent dispatch
|
||||||
|
- PRD and ADR elicitation skills
|
||||||
|
- Decomposer / implementer / reviewer prompts
|
||||||
|
- DAG analysis (depends-on / blocks)
|
||||||
|
- Pre-commit hooks (the state file is rebuilt manually for now)
|
||||||
|
|
||||||
|
## Stories
|
||||||
|
- [x] [01 — State builder + CLI](01-state-builder-and-cli/_story.md)
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
"test:stories": "turbo run test:stories",
|
"test:stories": "turbo run test:stories",
|
||||||
"typecheck": "turbo run typecheck",
|
"typecheck": "turbo run typecheck",
|
||||||
"conformance": "node scripts/conformance.mjs",
|
"conformance": "node scripts/conformance.mjs",
|
||||||
|
"work": "node scripts/work/cli.mjs",
|
||||||
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"",
|
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"",
|
||||||
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\""
|
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\""
|
||||||
},
|
},
|
||||||
|
|||||||
87
scripts/work/cli.mjs
Normal file
87
scripts/work/cli.mjs
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* pnpm work — CLI for the local work-system.
|
||||||
|
*
|
||||||
|
* Subcommands:
|
||||||
|
* rebuild-state Rewrites docs/work/_state.json from the current markdown
|
||||||
|
* status Prints a tree of all epics + their stories
|
||||||
|
* next Prints the first non-done story in the first non-done epic
|
||||||
|
*/
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { buildState } from "./state-builder.mjs";
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const REPO_ROOT = path.resolve(__dirname, "..", "..");
|
||||||
|
const WORK_ROOT = path.join(REPO_ROOT, "docs", "work");
|
||||||
|
const STATE_FILE = path.join(WORK_ROOT, "_state.json");
|
||||||
|
|
||||||
|
function rebuildState() {
|
||||||
|
const state = buildState(WORK_ROOT);
|
||||||
|
fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2) + "\n");
|
||||||
|
console.log(`Rebuilt ${path.relative(REPO_ROOT, STATE_FILE)} with ${Object.keys(state.epics).length} epic(s).`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function printStatus() {
|
||||||
|
const state = buildState(WORK_ROOT);
|
||||||
|
const epicIds = Object.keys(state.epics).sort();
|
||||||
|
if (epicIds.length === 0) {
|
||||||
|
console.log("No epics found under docs/work/.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const epicId of epicIds) {
|
||||||
|
const epic = state.epics[epicId];
|
||||||
|
const storyIds = Object.keys(epic.stories).sort();
|
||||||
|
const storyTotals = storyIds.reduce(
|
||||||
|
(acc, sid) => ({
|
||||||
|
total: acc.total + epic.stories[sid].ac_total,
|
||||||
|
done: acc.done + epic.stories[sid].ac_completed,
|
||||||
|
}),
|
||||||
|
{ total: 0, done: 0 },
|
||||||
|
);
|
||||||
|
const epicMark = mark(epic.status);
|
||||||
|
console.log(`${epicMark} ${epicId} — ${epic.title} (${storyTotals.done}/${storyTotals.total} tasks done)`);
|
||||||
|
for (const sid of storyIds) {
|
||||||
|
const s = epic.stories[sid];
|
||||||
|
console.log(` ${mark(s.status)} ${sid} (${s.ac_completed}/${s.ac_total}) — ${s.title}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function printNext() {
|
||||||
|
const state = buildState(WORK_ROOT);
|
||||||
|
const epicIds = Object.keys(state.epics).sort();
|
||||||
|
for (const epicId of epicIds) {
|
||||||
|
const epic = state.epics[epicId];
|
||||||
|
if (epic.status === "done") continue;
|
||||||
|
const storyIds = Object.keys(epic.stories).sort();
|
||||||
|
for (const sid of storyIds) {
|
||||||
|
const s = epic.stories[sid];
|
||||||
|
if (s.status !== "done") {
|
||||||
|
console.log(`${epicId} / ${sid} — ${s.title}`);
|
||||||
|
console.log(` status: ${s.status}, tasks: ${s.ac_completed}/${s.ac_total}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log("All epics + stories are done. ✓");
|
||||||
|
}
|
||||||
|
|
||||||
|
function mark(status) {
|
||||||
|
if (status === "done") return "✓";
|
||||||
|
if (status === "in-progress") return "→";
|
||||||
|
if (status === "blocked") return "✗";
|
||||||
|
return "○";
|
||||||
|
}
|
||||||
|
|
||||||
|
function usage() {
|
||||||
|
console.log("Usage: pnpm work <rebuild-state|status|next>");
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cmd = process.argv[2];
|
||||||
|
if (cmd === "rebuild-state") rebuildState();
|
||||||
|
else if (cmd === "status") printStatus();
|
||||||
|
else if (cmd === "next") printNext();
|
||||||
|
else usage();
|
||||||
42
scripts/work/cli.test.mjs
Normal file
42
scripts/work/cli.test.mjs
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { execSync } from "node:child_process";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const CLI = path.join(__dirname, "cli.mjs");
|
||||||
|
|
||||||
|
function run(args) {
|
||||||
|
try {
|
||||||
|
return execSync(`node "${CLI}" ${args}`, { encoding: "utf8" });
|
||||||
|
} catch (e) {
|
||||||
|
return e.stdout + e.stderr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("pnpm work cli", () => {
|
||||||
|
it("prints usage when no subcommand is given", () => {
|
||||||
|
const out = run("");
|
||||||
|
expect(out).toContain("Usage:");
|
||||||
|
expect(out).toContain("rebuild-state");
|
||||||
|
expect(out).toContain("status");
|
||||||
|
expect(out).toContain("next");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rebuild-state writes _state.json", () => {
|
||||||
|
const out = run("rebuild-state");
|
||||||
|
expect(out).toContain("Rebuilt");
|
||||||
|
expect(out).toContain("epic");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("status prints a tree", () => {
|
||||||
|
const out = run("status");
|
||||||
|
// We expect at least one epic-line marker character
|
||||||
|
expect(out).toMatch(/[✓→○]/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("next prints the next non-done story OR confirms all done", () => {
|
||||||
|
const out = run("next");
|
||||||
|
expect(out.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
118
scripts/work/state-builder.mjs
Normal file
118
scripts/work/state-builder.mjs
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const SKIP_FOLDERS = new Set(["_templates", "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`.
|
||||||
|
*
|
||||||
|
* 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: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!fs.existsSync(workRoot)) 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);
|
||||||
|
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,
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
state.epics[epicMeta.id ?? entry] = epicEntry;
|
||||||
|
}
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read a markdown file's YAML frontmatter (between leading `---` delimiters)
|
||||||
|
* and return a flat object of string-valued keys. Numeric / bool values are
|
||||||
|
* left as strings; this is enough for our needs (status, id, title fields).
|
||||||
|
*/
|
||||||
|
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();
|
||||||
|
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 };
|
||||||
|
}
|
||||||
152
scripts/work/state-builder.test.mjs
Normal file
152
scripts/work/state-builder.test.mjs
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import path from "node:path";
|
||||||
|
import os from "node:os";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import { buildState, parseFrontmatter, countTaskCheckboxes } from "./state-builder.mjs";
|
||||||
|
|
||||||
|
function makeWorkTree({ epics }) {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "work-state-"));
|
||||||
|
for (const [epicId, epicData] of Object.entries(epics)) {
|
||||||
|
const epicDir = path.join(root, epicId);
|
||||||
|
fs.mkdirSync(epicDir, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(epicDir, "_epic.md"),
|
||||||
|
`---
|
||||||
|
id: ${epicId}
|
||||||
|
title: ${epicData.title ?? epicId}
|
||||||
|
status: ${epicData.status ?? "todo"}
|
||||||
|
---
|
||||||
|
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
for (const [storyId, storyData] of Object.entries(epicData.stories ?? {})) {
|
||||||
|
const storyDir = path.join(epicDir, storyId);
|
||||||
|
fs.mkdirSync(storyDir, { recursive: true });
|
||||||
|
const tasksBlock = (storyData.tasks ?? [])
|
||||||
|
.map((t) => `- [${t.done ? "x" : " "}] ${t.label}`)
|
||||||
|
.join("\n");
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(storyDir, "_story.md"),
|
||||||
|
`---
|
||||||
|
id: ${storyId}
|
||||||
|
title: ${storyData.title ?? storyId}
|
||||||
|
status: ${storyData.status ?? "todo"}
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
${tasksBlock}
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("buildState", () => {
|
||||||
|
it("returns an empty epics map for a non-existent workRoot", () => {
|
||||||
|
const state = buildState("/nonexistent/path");
|
||||||
|
expect(state.epics).toEqual({});
|
||||||
|
expect(typeof state.updated_at).toBe("string");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collects epics + stories with status + task counts", () => {
|
||||||
|
const root = makeWorkTree({
|
||||||
|
epics: {
|
||||||
|
epic1: {
|
||||||
|
status: "in-progress",
|
||||||
|
stories: {
|
||||||
|
story1: {
|
||||||
|
status: "done",
|
||||||
|
tasks: [
|
||||||
|
{ label: "a", done: true },
|
||||||
|
{ label: "b", done: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
story2: {
|
||||||
|
status: "todo",
|
||||||
|
tasks: [
|
||||||
|
{ label: "c", done: false },
|
||||||
|
{ label: "d", done: false },
|
||||||
|
{ label: "e", done: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = buildState(root);
|
||||||
|
expect(state.epics).toEqual({
|
||||||
|
epic1: {
|
||||||
|
status: "in-progress",
|
||||||
|
title: "epic1",
|
||||||
|
stories: {
|
||||||
|
story1: { status: "done", title: "story1", ac_total: 2, ac_completed: 2 },
|
||||||
|
story2: { status: "todo", title: "story2", ac_total: 3, ac_completed: 0 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips _templates and prds folders", () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "work-skip-"));
|
||||||
|
fs.mkdirSync(path.join(root, "_templates"), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(root, "_templates", "_epic.md"), "---\nid: x\n---");
|
||||||
|
fs.mkdirSync(path.join(root, "prds"), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(root, "prds", "_epic.md"), "---\nid: y\n---");
|
||||||
|
expect(buildState(root).epics).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips directories without _epic.md", () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "work-skip2-"));
|
||||||
|
fs.mkdirSync(path.join(root, "incomplete"), { recursive: true });
|
||||||
|
expect(buildState(root).epics).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("countTaskCheckboxes", () => {
|
||||||
|
it("counts both unchecked and checked", () => {
|
||||||
|
const content = `## Tasks
|
||||||
|
- [x] done one
|
||||||
|
- [ ] open one
|
||||||
|
- [X] capital X also counts
|
||||||
|
`;
|
||||||
|
expect(countTaskCheckboxes(content)).toEqual({ total: 3, completed: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only counts inside the ## Tasks section", () => {
|
||||||
|
const content = `## Tasks
|
||||||
|
- [x] in tasks
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- [x] outside, should not count
|
||||||
|
`;
|
||||||
|
expect(countTaskCheckboxes(content)).toEqual({ total: 1, completed: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns zeros when no Tasks section exists", () => {
|
||||||
|
expect(countTaskCheckboxes(`## Goal\n\nFoo\n`)).toEqual({ total: 0, completed: 0 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseFrontmatter", () => {
|
||||||
|
it("extracts simple string keys", () => {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "fm-"));
|
||||||
|
const fp = path.join(dir, "f.md");
|
||||||
|
fs.writeFileSync(fp, `---
|
||||||
|
id: x
|
||||||
|
title: A title
|
||||||
|
status: done
|
||||||
|
---
|
||||||
|
|
||||||
|
Body`);
|
||||||
|
expect(parseFrontmatter(fp)).toEqual({ id: "x", title: "A title", status: "done" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns {} when no frontmatter present", () => {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "fm2-"));
|
||||||
|
const fp = path.join(dir, "f.md");
|
||||||
|
fs.writeFileSync(fp, "# Just a heading\n");
|
||||||
|
expect(parseFrontmatter(fp)).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user