Merge branch 'worktree-work-system-v1': work-system v1 MVP — state + pnpm work CLI

This commit is contained in:
2026-05-13 07:48:07 +02:00
8 changed files with 568 additions and 0 deletions

107
docs/work/_state.json Normal file
View 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
}
}
}
}
}

View 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

View 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)

View File

@@ -14,6 +14,7 @@
"test:stories": "turbo run test:stories",
"typecheck": "turbo run typecheck",
"conformance": "node scripts/conformance.mjs",
"work": "node scripts/work/cli.mjs",
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"",
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\""
},

87
scripts/work/cli.mjs Normal file
View 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
View 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);
});
});

View 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 };
}

View 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({});
});
});