Merge branch 'worktree-dag-and-readiness-v1': DAG + readiness v1
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"updated_at": "2026-05-13T05:56:35.878Z",
|
"updated_at": "2026-05-13T06:06:23.176Z",
|
||||||
"epics": {
|
"epics": {
|
||||||
"agent-workflow-docs-v1": {
|
"agent-workflow-docs-v1": {
|
||||||
"status": "done",
|
"status": "done",
|
||||||
@@ -9,7 +9,9 @@
|
|||||||
"status": "done",
|
"status": "done",
|
||||||
"title": "Surface conformance system across top-level docs",
|
"title": "Surface conformance system across top-level docs",
|
||||||
"ac_total": 8,
|
"ac_total": 8,
|
||||||
"ac_completed": 8
|
"ac_completed": 8,
|
||||||
|
"depends_on": [],
|
||||||
|
"blocks": []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -21,13 +23,21 @@
|
|||||||
"status": "done",
|
"status": "done",
|
||||||
"title": "Replace regex manifest source parser with AST",
|
"title": "Replace regex manifest source parser with AST",
|
||||||
"ac_total": 5,
|
"ac_total": 5,
|
||||||
"ac_completed": 5
|
"ac_completed": 5,
|
||||||
|
"depends_on": [],
|
||||||
|
"blocks": [
|
||||||
|
"02-dev-seed-assertion"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"02-dev-seed-assertion": {
|
"02-dev-seed-assertion": {
|
||||||
"status": "done",
|
"status": "done",
|
||||||
"title": "Extend assertFeatureConformance to all bind-dev-seed paths",
|
"title": "Extend assertFeatureConformance to all bind-dev-seed paths",
|
||||||
"ac_total": 6,
|
"ac_total": 6,
|
||||||
"ac_completed": 6
|
"ac_completed": 6,
|
||||||
|
"depends_on": [
|
||||||
|
"01-ast-manifest-source"
|
||||||
|
],
|
||||||
|
"blocks": []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -39,43 +49,119 @@
|
|||||||
"status": "done",
|
"status": "done",
|
||||||
"title": "defineFeature helper + Instrumented/Captured/Audited brands",
|
"title": "defineFeature helper + Instrumented/Captured/Audited brands",
|
||||||
"ac_total": 9,
|
"ac_total": 9,
|
||||||
"ac_completed": 9
|
"ac_completed": 9,
|
||||||
|
"depends_on": [],
|
||||||
|
"blocks": [
|
||||||
|
"02-boot-assertions"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"02-boot-assertions": {
|
"02-boot-assertions": {
|
||||||
"status": "done",
|
"status": "done",
|
||||||
"title": "assertFeatureConformance + boot wiring",
|
"title": "assertFeatureConformance + boot wiring",
|
||||||
"ac_total": 11,
|
"ac_total": 11,
|
||||||
"ac_completed": 11
|
"ac_completed": 11,
|
||||||
|
"depends_on": [
|
||||||
|
"01-define-feature-helper"
|
||||||
|
],
|
||||||
|
"blocks": [
|
||||||
|
"03-eslint-rules"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"03-a-structural-eslint-rules": {
|
"03-a-structural-eslint-rules": {
|
||||||
"status": "done",
|
"status": "done",
|
||||||
"title": "Structural ESLint rules (feature-must-have-manifest, usecase-must-have-test-file, required-cores-installed)",
|
"title": "Structural ESLint rules (feature-must-have-manifest, usecase-must-have-test-file, required-cores-installed)",
|
||||||
"ac_total": 10,
|
"ac_total": 10,
|
||||||
"ac_completed": 10
|
"ac_completed": 10,
|
||||||
|
"depends_on": [
|
||||||
|
"02-boot-assertions"
|
||||||
|
],
|
||||||
|
"blocks": [
|
||||||
|
"03-b-ast-aware-eslint-rules"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"03-b-ast-eslint-rules": {
|
"03-b-ast-eslint-rules": {
|
||||||
"status": "done",
|
"status": "done",
|
||||||
"title": "AST-aware ESLint rules (no-undeclared-event-publish, no-undeclared-audit)",
|
"title": "AST-aware ESLint rules (no-undeclared-event-publish, no-undeclared-audit)",
|
||||||
"ac_total": 8,
|
"ac_total": 8,
|
||||||
"ac_completed": 8
|
"ac_completed": 8,
|
||||||
|
"depends_on": [
|
||||||
|
"03-a-structural-eslint-rules"
|
||||||
|
],
|
||||||
|
"blocks": [
|
||||||
|
"04-ci-drift-gate"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"04-ci-drift-gate": {
|
"04-ci-drift-gate": {
|
||||||
"status": "done",
|
"status": "done",
|
||||||
"title": "CI drift gate — pnpm conformance with cross-feature event closure",
|
"title": "CI drift gate — pnpm conformance with cross-feature event closure",
|
||||||
"ac_total": 6,
|
"ac_total": 6,
|
||||||
"ac_completed": 6
|
"ac_completed": 6,
|
||||||
|
"depends_on": [
|
||||||
|
"03-b-ast-eslint-rules"
|
||||||
|
],
|
||||||
|
"blocks": [
|
||||||
|
"05-generator-updates"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"05-generator-updates": {
|
"05-generator-updates": {
|
||||||
"status": "done",
|
"status": "done",
|
||||||
"title": "Generator updates — emit feature.manifest.ts + self-asserting bind-production",
|
"title": "Generator updates — emit feature.manifest.ts + self-asserting bind-production",
|
||||||
"ac_total": 7,
|
"ac_total": 7,
|
||||||
"ac_completed": 7
|
"ac_completed": 7,
|
||||||
|
"depends_on": [
|
||||||
|
"04-ci-drift-gate"
|
||||||
|
],
|
||||||
|
"blocks": [
|
||||||
|
"06-feature-migrations"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"06-feature-migrations": {
|
"06-feature-migrations": {
|
||||||
"status": "done",
|
"status": "done",
|
||||||
"title": "Migrate blog/media/navigation/marketing-pages to conformance pattern",
|
"title": "Migrate blog/media/navigation/marketing-pages to conformance pattern",
|
||||||
"ac_total": 7,
|
"ac_total": 7,
|
||||||
"ac_completed": 7
|
"ac_completed": 7,
|
||||||
|
"depends_on": [
|
||||||
|
"05-generator-updates"
|
||||||
|
],
|
||||||
|
"blocks": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dag-and-readiness-v1": {
|
||||||
|
"status": "done",
|
||||||
|
"title": "DAG + readiness — depends-on / blocks parsing",
|
||||||
|
"stories": {
|
||||||
|
"01-extended-state": {
|
||||||
|
"status": "done",
|
||||||
|
"title": "Frontmatter array parsing + extended state shape",
|
||||||
|
"ac_total": 4,
|
||||||
|
"ac_completed": 4,
|
||||||
|
"depends_on": [],
|
||||||
|
"blocks": [
|
||||||
|
"02-dag-computation"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"02-dag-computation": {
|
||||||
|
"status": "done",
|
||||||
|
"title": "DAG computation — ready + blocked story sets",
|
||||||
|
"ac_total": 3,
|
||||||
|
"ac_completed": 3,
|
||||||
|
"depends_on": [
|
||||||
|
"01-extended-state"
|
||||||
|
],
|
||||||
|
"blocks": [
|
||||||
|
"03-cli-subcommands"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"03-cli-subcommands": {
|
||||||
|
"status": "done",
|
||||||
|
"title": "CLI ready / blocked subcommands",
|
||||||
|
"ac_total": 5,
|
||||||
|
"ac_completed": 5,
|
||||||
|
"depends_on": [
|
||||||
|
"02-dag-computation"
|
||||||
|
],
|
||||||
|
"blocks": []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -87,7 +173,9 @@
|
|||||||
"status": "done",
|
"status": "done",
|
||||||
"title": "Three structural frontend conformance ESLint rules",
|
"title": "Three structural frontend conformance ESLint rules",
|
||||||
"ac_total": 6,
|
"ac_total": 6,
|
||||||
"ac_completed": 6
|
"ac_completed": 6,
|
||||||
|
"depends_on": [],
|
||||||
|
"blocks": []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -99,19 +187,33 @@
|
|||||||
"status": "done",
|
"status": "done",
|
||||||
"title": "Install husky + lint-staged",
|
"title": "Install husky + lint-staged",
|
||||||
"ac_total": 3,
|
"ac_total": 3,
|
||||||
"ac_completed": 3
|
"ac_completed": 3,
|
||||||
|
"depends_on": [],
|
||||||
|
"blocks": [
|
||||||
|
"02-pre-commit-hook"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"02-pre-commit-hook": {
|
"02-pre-commit-hook": {
|
||||||
"status": "done",
|
"status": "done",
|
||||||
"title": "Pre-commit hook script",
|
"title": "Pre-commit hook script",
|
||||||
"ac_total": 2,
|
"ac_total": 2,
|
||||||
"ac_completed": 2
|
"ac_completed": 2,
|
||||||
|
"depends_on": [
|
||||||
|
"01-husky-install"
|
||||||
|
],
|
||||||
|
"blocks": [
|
||||||
|
"03-state-sync-guard"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"03-state-sync-guard": {
|
"03-state-sync-guard": {
|
||||||
"status": "done",
|
"status": "done",
|
||||||
"title": "State-sync guard + tests",
|
"title": "State-sync guard + tests",
|
||||||
"ac_total": 4,
|
"ac_total": 4,
|
||||||
"ac_completed": 4
|
"ac_completed": 4,
|
||||||
|
"depends_on": [
|
||||||
|
"02-pre-commit-hook"
|
||||||
|
],
|
||||||
|
"blocks": []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -123,9 +225,13 @@
|
|||||||
"status": "done",
|
"status": "done",
|
||||||
"title": "State builder + pnpm work CLI",
|
"title": "State builder + pnpm work CLI",
|
||||||
"ac_total": 5,
|
"ac_total": 5,
|
||||||
"ac_completed": 5
|
"ac_completed": 5,
|
||||||
|
"depends_on": [],
|
||||||
|
"blocks": []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"ready": [],
|
||||||
|
"blocked": []
|
||||||
}
|
}
|
||||||
|
|||||||
17
docs/work/dag-and-readiness-v1/01-extended-state/_story.md
Normal file
17
docs/work/dag-and-readiness-v1/01-extended-state/_story.md
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
---
|
||||||
|
id: 01-extended-state
|
||||||
|
epic: dag-and-readiness-v1
|
||||||
|
title: Frontmatter array parsing + extended state shape
|
||||||
|
type: technical-story
|
||||||
|
status: done
|
||||||
|
feature: scripts
|
||||||
|
depends-on: []
|
||||||
|
blocks: [02-dag-computation]
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
- [x] Story scaffold
|
||||||
|
- [x] Extend parseFrontmatter to parse array values like `depends-on: [a, b]`
|
||||||
|
- [x] Extend buildState to include depends_on + blocks per story entry
|
||||||
|
- [x] Update existing tests; add new array-parsing tests
|
||||||
16
docs/work/dag-and-readiness-v1/02-dag-computation/_story.md
Normal file
16
docs/work/dag-and-readiness-v1/02-dag-computation/_story.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
id: 02-dag-computation
|
||||||
|
epic: dag-and-readiness-v1
|
||||||
|
title: DAG computation — ready + blocked story sets
|
||||||
|
type: technical-story
|
||||||
|
status: done
|
||||||
|
feature: scripts
|
||||||
|
depends-on: [01-extended-state]
|
||||||
|
blocks: [03-cli-subcommands]
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
- [x] computeReadyBlocked(state) — pure function
|
||||||
|
- [x] Tests
|
||||||
|
- [x] Wire into buildState output
|
||||||
18
docs/work/dag-and-readiness-v1/03-cli-subcommands/_story.md
Normal file
18
docs/work/dag-and-readiness-v1/03-cli-subcommands/_story.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
id: 03-cli-subcommands
|
||||||
|
epic: dag-and-readiness-v1
|
||||||
|
title: CLI ready / blocked subcommands
|
||||||
|
type: technical-story
|
||||||
|
status: done
|
||||||
|
feature: scripts
|
||||||
|
depends-on: [02-dag-computation]
|
||||||
|
blocks: []
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
- [x] `pnpm work ready` — prints ready stories
|
||||||
|
- [x] `pnpm work blocked` — prints blocked stories with waiting_on
|
||||||
|
- [x] Update `pnpm work next` to use the ready set
|
||||||
|
- [x] CLI smoke tests
|
||||||
|
- [x] Final verification + closeout
|
||||||
21
docs/work/dag-and-readiness-v1/_epic.md
Normal file
21
docs/work/dag-and-readiness-v1/_epic.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
---
|
||||||
|
id: dag-and-readiness-v1
|
||||||
|
prd: null
|
||||||
|
title: DAG + readiness — depends-on / blocks parsing
|
||||||
|
type: epic
|
||||||
|
status: done
|
||||||
|
features: [scripts]
|
||||||
|
created: 2026-05-13
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Extend the state builder + CLI so `pnpm work next` / `pnpm work ready` /
|
||||||
|
`pnpm work blocked` answer "what's actually unblocked right now?"
|
||||||
|
from the depends-on graph instead of just the first non-done story.
|
||||||
|
|
||||||
|
## Stories
|
||||||
|
|
||||||
|
- [x] [01 — Frontmatter array parsing + extended state shape](01-extended-state/_story.md)
|
||||||
|
- [x] [02 — DAG computation: ready + blocked](02-dag-computation/_story.md)
|
||||||
|
- [x] [03 — CLI ready / blocked subcommands](03-cli-subcommands/_story.md)
|
||||||
33
eslint.config.js
Normal file
33
eslint.config.js
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* Root ESLint config — covers scripts/ and any other plain-JS files at the
|
||||||
|
* repo root. Package-level configs (packages/*, apps/*) define their own
|
||||||
|
* eslint.config.js and take precedence for files inside those directories.
|
||||||
|
*/
|
||||||
|
import js from "@eslint/js";
|
||||||
|
|
||||||
|
// Minimal Node.js globals for scripts — avoids a peer-dep on `globals`.
|
||||||
|
const nodeGlobals = {
|
||||||
|
process: "readonly",
|
||||||
|
console: "readonly",
|
||||||
|
URL: "readonly",
|
||||||
|
Buffer: "readonly",
|
||||||
|
__dirname: "readonly",
|
||||||
|
__filename: "readonly",
|
||||||
|
setTimeout: "readonly",
|
||||||
|
clearTimeout: "readonly",
|
||||||
|
setInterval: "readonly",
|
||||||
|
clearInterval: "readonly",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default [
|
||||||
|
{ ignores: ["node_modules/**", "packages/**", "apps/**", "turbo/**"] },
|
||||||
|
js.configs.recommended,
|
||||||
|
{
|
||||||
|
files: ["scripts/**/*.{mjs,cjs,js}"],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2022,
|
||||||
|
sourceType: "module",
|
||||||
|
globals: nodeGlobals,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -20,7 +20,9 @@ const STATE_FILE = path.join(WORK_ROOT, "_state.json");
|
|||||||
function rebuildState() {
|
function rebuildState() {
|
||||||
const state = buildState(WORK_ROOT);
|
const state = buildState(WORK_ROOT);
|
||||||
fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2) + "\n");
|
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).`);
|
console.log(
|
||||||
|
`Rebuilt ${path.relative(REPO_ROOT, STATE_FILE)} with ${Object.keys(state.epics).length} epic(s).`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function printStatus() {
|
function printStatus() {
|
||||||
@@ -41,31 +43,67 @@ function printStatus() {
|
|||||||
{ total: 0, done: 0 },
|
{ total: 0, done: 0 },
|
||||||
);
|
);
|
||||||
const epicMark = mark(epic.status);
|
const epicMark = mark(epic.status);
|
||||||
console.log(`${epicMark} ${epicId} — ${epic.title} (${storyTotals.done}/${storyTotals.total} tasks done)`);
|
console.log(
|
||||||
|
`${epicMark} ${epicId} — ${epic.title} (${storyTotals.done}/${storyTotals.total} tasks done)`,
|
||||||
|
);
|
||||||
for (const sid of storyIds) {
|
for (const sid of storyIds) {
|
||||||
const s = epic.stories[sid];
|
const s = epic.stories[sid];
|
||||||
console.log(` ${mark(s.status)} ${sid} (${s.ac_completed}/${s.ac_total}) — ${s.title}`);
|
console.log(
|
||||||
|
` ${mark(s.status)} ${sid} (${s.ac_completed}/${s.ac_total}) — ${s.title}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function printNext() {
|
function printNext() {
|
||||||
const state = buildState(WORK_ROOT);
|
const state = buildState(WORK_ROOT);
|
||||||
const epicIds = Object.keys(state.epics).sort();
|
if (state.ready.length === 0) {
|
||||||
for (const epicId of epicIds) {
|
if (state.blocked.length > 0) {
|
||||||
const epic = state.epics[epicId];
|
console.log("No ready stories. Blocked:");
|
||||||
if (epic.status === "done") continue;
|
for (const b of state.blocked) {
|
||||||
const storyIds = Object.keys(epic.stories).sort();
|
console.log(
|
||||||
for (const sid of storyIds) {
|
` ${b.epic} / ${b.story} — waiting on: ${b.waiting_on.join(", ")}`,
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
console.log("All epics + stories are done. ✓");
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const r = state.ready[0];
|
||||||
|
console.log(`${r.epic} / ${r.story} — ${r.title}`);
|
||||||
|
console.log(` (use \`pnpm work ready\` to see all ready stories)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function printReady() {
|
||||||
|
const state = buildState(WORK_ROOT);
|
||||||
|
if (state.ready.length === 0) {
|
||||||
|
console.log(
|
||||||
|
"No ready stories. Run `pnpm work blocked` to see what's waiting on what.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
`${state.ready.length} ready stor${state.ready.length === 1 ? "y" : "ies"}:`,
|
||||||
|
);
|
||||||
|
for (const r of state.ready) {
|
||||||
|
console.log(` ${r.epic} / ${r.story} — ${r.title}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function printBlocked() {
|
||||||
|
const state = buildState(WORK_ROOT);
|
||||||
|
if (state.blocked.length === 0) {
|
||||||
|
console.log("No blocked stories.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
`${state.blocked.length} blocked stor${state.blocked.length === 1 ? "y" : "ies"}:`,
|
||||||
|
);
|
||||||
|
for (const b of state.blocked) {
|
||||||
|
console.log(` ${b.epic} / ${b.story} — ${b.title}`);
|
||||||
|
console.log(` waiting on: ${b.waiting_on.join(", ")}`);
|
||||||
}
|
}
|
||||||
console.log("All epics + stories are done. ✓");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function mark(status) {
|
function mark(status) {
|
||||||
@@ -76,7 +114,7 @@ function mark(status) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function usage() {
|
function usage() {
|
||||||
console.log("Usage: pnpm work <rebuild-state|status|next>");
|
console.log("Usage: pnpm work <rebuild-state|status|next|ready|blocked>");
|
||||||
process.exit(2);
|
process.exit(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,4 +122,6 @@ const cmd = process.argv[2];
|
|||||||
if (cmd === "rebuild-state") rebuildState();
|
if (cmd === "rebuild-state") rebuildState();
|
||||||
else if (cmd === "status") printStatus();
|
else if (cmd === "status") printStatus();
|
||||||
else if (cmd === "next") printNext();
|
else if (cmd === "next") printNext();
|
||||||
|
else if (cmd === "ready") printReady();
|
||||||
|
else if (cmd === "blocked") printBlocked();
|
||||||
else usage();
|
else usage();
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ describe("pnpm work cli", () => {
|
|||||||
expect(out).toContain("rebuild-state");
|
expect(out).toContain("rebuild-state");
|
||||||
expect(out).toContain("status");
|
expect(out).toContain("status");
|
||||||
expect(out).toContain("next");
|
expect(out).toContain("next");
|
||||||
|
expect(out).toContain("ready");
|
||||||
|
expect(out).toContain("blocked");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rebuild-state writes _state.json", () => {
|
it("rebuild-state writes _state.json", () => {
|
||||||
@@ -39,4 +41,14 @@ describe("pnpm work cli", () => {
|
|||||||
const out = run("next");
|
const out = run("next");
|
||||||
expect(out.length).toBeGreaterThan(0);
|
expect(out.length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("ready prints something", () => {
|
||||||
|
const out = run("ready");
|
||||||
|
expect(out.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocked prints something", () => {
|
||||||
|
const out = run("blocked");
|
||||||
|
expect(out.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -63,19 +63,84 @@ export function buildState(workRoot) {
|
|||||||
title: storyMeta.title ?? sub,
|
title: storyMeta.title ?? sub,
|
||||||
ac_total: total,
|
ac_total: total,
|
||||||
ac_completed: completed,
|
ac_completed: completed,
|
||||||
|
depends_on: Array.isArray(storyMeta["depends-on"])
|
||||||
|
? storyMeta["depends-on"]
|
||||||
|
: [],
|
||||||
|
blocks: Array.isArray(storyMeta.blocks) ? storyMeta.blocks : [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
state.epics[epicMeta.id ?? entry] = epicEntry;
|
state.epics[epicMeta.id ?? entry] = epicEntry;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { ready, blocked } = computeReadyBlocked(state);
|
||||||
|
state.ready = ready;
|
||||||
|
state.blocked = blocked;
|
||||||
|
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a built state object, compute the ready + blocked story sets.
|
||||||
|
*
|
||||||
|
* "ready" = not done AND all depends_on stories are done
|
||||||
|
* "blocked" = not done AND at least one depends_on story is not done
|
||||||
|
*
|
||||||
|
* depends_on references are either same-epic (just story id) or cross-epic
|
||||||
|
* (`<epic>/<story>`).
|
||||||
|
*
|
||||||
|
* Returns { ready: [...], blocked: [...] } where each entry is:
|
||||||
|
* { epic, story, title, [waiting_on?] }
|
||||||
|
*/
|
||||||
|
export function computeReadyBlocked(state) {
|
||||||
|
// Build a flat map: "<epic>/<story>" => { status, title, depends_on }
|
||||||
|
const flat = new Map();
|
||||||
|
for (const [epic, epicEntry] of Object.entries(state.epics)) {
|
||||||
|
for (const [story, storyEntry] of Object.entries(epicEntry.stories)) {
|
||||||
|
flat.set(`${epic}/${story}`, {
|
||||||
|
epic,
|
||||||
|
story,
|
||||||
|
status: storyEntry.status,
|
||||||
|
title: storyEntry.title,
|
||||||
|
depends_on: storyEntry.depends_on,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve a depends_on reference (which may be `<epic>/<story>` or just `<story>`)
|
||||||
|
// relative to the current epic.
|
||||||
|
function resolveRef(ref, currentEpic) {
|
||||||
|
if (ref.includes("/")) return ref;
|
||||||
|
return `${currentEpic}/${ref}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ready = [];
|
||||||
|
const blocked = [];
|
||||||
|
for (const entry of flat.values()) {
|
||||||
|
if (entry.status === "done") continue;
|
||||||
|
const refs = entry.depends_on.map((r) => resolveRef(r, entry.epic));
|
||||||
|
const waitingOn = refs.filter((r) => {
|
||||||
|
const dep = flat.get(r);
|
||||||
|
return !dep || dep.status !== "done";
|
||||||
|
});
|
||||||
|
if (waitingOn.length === 0) {
|
||||||
|
ready.push({ epic: entry.epic, story: entry.story, title: entry.title });
|
||||||
|
} else {
|
||||||
|
blocked.push({
|
||||||
|
epic: entry.epic,
|
||||||
|
story: entry.story,
|
||||||
|
title: entry.title,
|
||||||
|
waiting_on: waitingOn,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ready, blocked };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read a markdown file's YAML frontmatter (between leading `---` delimiters)
|
* Read a markdown file's YAML frontmatter (between leading `---` delimiters)
|
||||||
* and return a flat object of string-valued keys. Numeric / bool values are
|
* and return a flat object of string or array values. Numeric / bool values are
|
||||||
* left as strings; this is enough for our needs (status, id, title fields).
|
* left as strings; array values like `[a, b, "c"]` are parsed into JS arrays.
|
||||||
*/
|
*/
|
||||||
export function parseFrontmatter(filePath) {
|
export function parseFrontmatter(filePath) {
|
||||||
const src = fs.readFileSync(filePath, "utf8");
|
const src = fs.readFileSync(filePath, "utf8");
|
||||||
@@ -86,8 +151,26 @@ export function parseFrontmatter(filePath) {
|
|||||||
const m = line.match(/^([\w-]+):\s*(.*)$/);
|
const m = line.match(/^([\w-]+):\s*(.*)$/);
|
||||||
if (!m) continue;
|
if (!m) continue;
|
||||||
let value = m[2].trim();
|
let value = m[2].trim();
|
||||||
if (value.startsWith('"') && value.endsWith('"')) value = value.slice(1, -1);
|
// Array values like [a, b, "c"] or []
|
||||||
if (value.startsWith("'") && value.endsWith("'")) value = value.slice(1, -1);
|
if (value.startsWith("[") && value.endsWith("]")) {
|
||||||
|
const inner = value.slice(1, -1).trim();
|
||||||
|
if (inner === "") {
|
||||||
|
out[m[1]] = [];
|
||||||
|
} else {
|
||||||
|
out[m[1]] = inner.split(",").map((s) => {
|
||||||
|
let v = s.trim();
|
||||||
|
if (v.startsWith('"') && v.endsWith('"')) v = v.slice(1, -1);
|
||||||
|
if (v.startsWith("'") && v.endsWith("'")) v = v.slice(1, -1);
|
||||||
|
return v;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Strings (with optional quotes)
|
||||||
|
if (value.startsWith('"') && value.endsWith('"'))
|
||||||
|
value = value.slice(1, -1);
|
||||||
|
if (value.startsWith("'") && value.endsWith("'"))
|
||||||
|
value = value.slice(1, -1);
|
||||||
out[m[1]] = value;
|
out[m[1]] = value;
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
|
|||||||
@@ -2,7 +2,12 @@ import { describe, it, expect } from "vitest";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import { buildState, parseFrontmatter, countTaskCheckboxes } from "./state-builder.mjs";
|
import {
|
||||||
|
buildState,
|
||||||
|
parseFrontmatter,
|
||||||
|
countTaskCheckboxes,
|
||||||
|
computeReadyBlocked,
|
||||||
|
} from "./state-builder.mjs";
|
||||||
|
|
||||||
function makeWorkTree({ epics }) {
|
function makeWorkTree({ epics }) {
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "work-state-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "work-state-"));
|
||||||
@@ -81,8 +86,22 @@ describe("buildState", () => {
|
|||||||
status: "in-progress",
|
status: "in-progress",
|
||||||
title: "epic1",
|
title: "epic1",
|
||||||
stories: {
|
stories: {
|
||||||
story1: { status: "done", title: "story1", ac_total: 2, ac_completed: 2 },
|
story1: {
|
||||||
story2: { status: "todo", title: "story2", ac_total: 3, ac_completed: 0 },
|
status: "done",
|
||||||
|
title: "story1",
|
||||||
|
ac_total: 2,
|
||||||
|
ac_completed: 2,
|
||||||
|
depends_on: [],
|
||||||
|
blocks: [],
|
||||||
|
},
|
||||||
|
story2: {
|
||||||
|
status: "todo",
|
||||||
|
title: "story2",
|
||||||
|
ac_total: 3,
|
||||||
|
ac_completed: 0,
|
||||||
|
depends_on: [],
|
||||||
|
blocks: [],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -91,7 +110,10 @@ describe("buildState", () => {
|
|||||||
it("skips _templates and prds folders", () => {
|
it("skips _templates and prds folders", () => {
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "work-skip-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "work-skip-"));
|
||||||
fs.mkdirSync(path.join(root, "_templates"), { recursive: true });
|
fs.mkdirSync(path.join(root, "_templates"), { recursive: true });
|
||||||
fs.writeFileSync(path.join(root, "_templates", "_epic.md"), "---\nid: x\n---");
|
fs.writeFileSync(
|
||||||
|
path.join(root, "_templates", "_epic.md"),
|
||||||
|
"---\nid: x\n---",
|
||||||
|
);
|
||||||
fs.mkdirSync(path.join(root, "prds"), { recursive: true });
|
fs.mkdirSync(path.join(root, "prds"), { recursive: true });
|
||||||
fs.writeFileSync(path.join(root, "prds", "_epic.md"), "---\nid: y\n---");
|
fs.writeFileSync(path.join(root, "prds", "_epic.md"), "---\nid: y\n---");
|
||||||
expect(buildState(root).epics).toEqual({});
|
expect(buildState(root).epics).toEqual({});
|
||||||
@@ -102,6 +124,56 @@ describe("buildState", () => {
|
|||||||
fs.mkdirSync(path.join(root, "incomplete"), { recursive: true });
|
fs.mkdirSync(path.join(root, "incomplete"), { recursive: true });
|
||||||
expect(buildState(root).epics).toEqual({});
|
expect(buildState(root).epics).toEqual({});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("parses depends-on and blocks frontmatter arrays", () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "work-dep-"));
|
||||||
|
const epicDir = path.join(root, "epic1");
|
||||||
|
fs.mkdirSync(epicDir, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(epicDir, "_epic.md"),
|
||||||
|
`---
|
||||||
|
id: epic1
|
||||||
|
title: epic1
|
||||||
|
status: todo
|
||||||
|
---
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
const story1Dir = path.join(epicDir, "story1");
|
||||||
|
fs.mkdirSync(story1Dir, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(story1Dir, "_story.md"),
|
||||||
|
`---
|
||||||
|
id: story1
|
||||||
|
title: story1
|
||||||
|
status: todo
|
||||||
|
depends-on: []
|
||||||
|
blocks: [story2, other-epic/x]
|
||||||
|
---
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
const story2Dir = path.join(epicDir, "story2");
|
||||||
|
fs.mkdirSync(story2Dir, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(story2Dir, "_story.md"),
|
||||||
|
`---
|
||||||
|
id: story2
|
||||||
|
title: story2
|
||||||
|
status: todo
|
||||||
|
depends-on: [story1]
|
||||||
|
blocks: []
|
||||||
|
---
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const state = buildState(root);
|
||||||
|
expect(state.epics.epic1.stories.story1.depends_on).toEqual([]);
|
||||||
|
expect(state.epics.epic1.stories.story1.blocks).toEqual([
|
||||||
|
"story2",
|
||||||
|
"other-epic/x",
|
||||||
|
]);
|
||||||
|
expect(state.epics.epic1.stories.story2.depends_on).toEqual(["story1"]);
|
||||||
|
expect(state.epics.epic1.stories.story2.blocks).toEqual([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("countTaskCheckboxes", () => {
|
describe("countTaskCheckboxes", () => {
|
||||||
@@ -125,7 +197,10 @@ describe("countTaskCheckboxes", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("returns zeros when no Tasks section exists", () => {
|
it("returns zeros when no Tasks section exists", () => {
|
||||||
expect(countTaskCheckboxes(`## Goal\n\nFoo\n`)).toEqual({ total: 0, completed: 0 });
|
expect(countTaskCheckboxes(`## Goal\n\nFoo\n`)).toEqual({
|
||||||
|
total: 0,
|
||||||
|
completed: 0,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -133,14 +208,21 @@ describe("parseFrontmatter", () => {
|
|||||||
it("extracts simple string keys", () => {
|
it("extracts simple string keys", () => {
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "fm-"));
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "fm-"));
|
||||||
const fp = path.join(dir, "f.md");
|
const fp = path.join(dir, "f.md");
|
||||||
fs.writeFileSync(fp, `---
|
fs.writeFileSync(
|
||||||
|
fp,
|
||||||
|
`---
|
||||||
id: x
|
id: x
|
||||||
title: A title
|
title: A title
|
||||||
status: done
|
status: done
|
||||||
---
|
---
|
||||||
|
|
||||||
Body`);
|
Body`,
|
||||||
expect(parseFrontmatter(fp)).toEqual({ id: "x", title: "A title", status: "done" });
|
);
|
||||||
|
expect(parseFrontmatter(fp)).toEqual({
|
||||||
|
id: "x",
|
||||||
|
title: "A title",
|
||||||
|
status: "done",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns {} when no frontmatter present", () => {
|
it("returns {} when no frontmatter present", () => {
|
||||||
@@ -149,4 +231,157 @@ Body`);
|
|||||||
fs.writeFileSync(fp, "# Just a heading\n");
|
fs.writeFileSync(fp, "# Just a heading\n");
|
||||||
expect(parseFrontmatter(fp)).toEqual({});
|
expect(parseFrontmatter(fp)).toEqual({});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("parses array frontmatter values", () => {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "fm-arr-"));
|
||||||
|
const fp = path.join(dir, "f.md");
|
||||||
|
fs.writeFileSync(
|
||||||
|
fp,
|
||||||
|
`---
|
||||||
|
id: x
|
||||||
|
items: [a, b, "c"]
|
||||||
|
empty: []
|
||||||
|
---
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
expect(parseFrontmatter(fp)).toEqual({
|
||||||
|
id: "x",
|
||||||
|
items: ["a", "b", "c"],
|
||||||
|
empty: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("computeReadyBlocked", () => {
|
||||||
|
it("returns ready for stories whose depends_on are all done", () => {
|
||||||
|
const state = {
|
||||||
|
updated_at: "x",
|
||||||
|
epics: {
|
||||||
|
e: {
|
||||||
|
status: "in-progress",
|
||||||
|
title: "e",
|
||||||
|
stories: {
|
||||||
|
a: {
|
||||||
|
status: "done",
|
||||||
|
title: "a",
|
||||||
|
ac_total: 0,
|
||||||
|
ac_completed: 0,
|
||||||
|
depends_on: [],
|
||||||
|
blocks: [],
|
||||||
|
},
|
||||||
|
b: {
|
||||||
|
status: "todo",
|
||||||
|
title: "b",
|
||||||
|
ac_total: 0,
|
||||||
|
ac_completed: 0,
|
||||||
|
depends_on: ["a"],
|
||||||
|
blocks: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const { ready, blocked } = computeReadyBlocked(state);
|
||||||
|
expect(ready).toEqual([{ epic: "e", story: "b", title: "b" }]);
|
||||||
|
expect(blocked).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns blocked for stories whose depends_on are not done", () => {
|
||||||
|
const state = {
|
||||||
|
updated_at: "x",
|
||||||
|
epics: {
|
||||||
|
e: {
|
||||||
|
status: "in-progress",
|
||||||
|
title: "e",
|
||||||
|
stories: {
|
||||||
|
a: {
|
||||||
|
status: "todo",
|
||||||
|
title: "a",
|
||||||
|
ac_total: 0,
|
||||||
|
ac_completed: 0,
|
||||||
|
depends_on: [],
|
||||||
|
blocks: [],
|
||||||
|
},
|
||||||
|
b: {
|
||||||
|
status: "todo",
|
||||||
|
title: "b",
|
||||||
|
ac_total: 0,
|
||||||
|
ac_completed: 0,
|
||||||
|
depends_on: ["a"],
|
||||||
|
blocks: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const { ready, blocked } = computeReadyBlocked(state);
|
||||||
|
expect(ready).toEqual([{ epic: "e", story: "a", title: "a" }]);
|
||||||
|
expect(blocked).toEqual([
|
||||||
|
{ epic: "e", story: "b", title: "b", waiting_on: ["e/a"] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves cross-epic refs", () => {
|
||||||
|
const state = {
|
||||||
|
updated_at: "x",
|
||||||
|
epics: {
|
||||||
|
e1: {
|
||||||
|
status: "done",
|
||||||
|
title: "e1",
|
||||||
|
stories: {
|
||||||
|
a: {
|
||||||
|
status: "done",
|
||||||
|
title: "a",
|
||||||
|
ac_total: 0,
|
||||||
|
ac_completed: 0,
|
||||||
|
depends_on: [],
|
||||||
|
blocks: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
e2: {
|
||||||
|
status: "in-progress",
|
||||||
|
title: "e2",
|
||||||
|
stories: {
|
||||||
|
b: {
|
||||||
|
status: "todo",
|
||||||
|
title: "b",
|
||||||
|
ac_total: 0,
|
||||||
|
ac_completed: 0,
|
||||||
|
depends_on: ["e1/a"],
|
||||||
|
blocks: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const { ready, blocked } = computeReadyBlocked(state);
|
||||||
|
expect(ready).toEqual([{ epic: "e2", story: "b", title: "b" }]);
|
||||||
|
expect(blocked).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips done stories", () => {
|
||||||
|
const state = {
|
||||||
|
updated_at: "x",
|
||||||
|
epics: {
|
||||||
|
e: {
|
||||||
|
status: "done",
|
||||||
|
title: "e",
|
||||||
|
stories: {
|
||||||
|
a: {
|
||||||
|
status: "done",
|
||||||
|
title: "a",
|
||||||
|
ac_total: 1,
|
||||||
|
ac_completed: 1,
|
||||||
|
depends_on: [],
|
||||||
|
blocks: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const { ready, blocked } = computeReadyBlocked(state);
|
||||||
|
expect(ready).toEqual([]);
|
||||||
|
expect(blocked).toEqual([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user