handles phased tasks
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mikefreno/ralpi",
|
"name": "@mikefreno/ralpi",
|
||||||
"version": "0.2.2",
|
"version": "0.2.3",
|
||||||
"description": "Execute tasks from task files/PRD's using DAG-based dependency resolution with persistent progress tracking",
|
"description": "Execute tasks from task files/PRD's using DAG-based dependency resolution with persistent progress tracking",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"pi-package",
|
"pi-package",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import type { Task, Project, ParallelGroup } from "./types";
|
import type { Task, Project, ParallelGroup, Phase } from "./types";
|
||||||
|
|
||||||
// Lazy-loaded yaml package
|
// Lazy-loaded yaml package
|
||||||
let YAML_module: typeof import("yaml") | undefined;
|
let YAML_module: typeof import("yaml") | undefined;
|
||||||
@@ -22,6 +22,7 @@ function loadYaml(): typeof import("yaml") {
|
|||||||
* Parse a task file (markdown or YAML) into a Project structure.
|
* Parse a task file (markdown or YAML) into a Project structure.
|
||||||
* Supports:
|
* Supports:
|
||||||
* - Fio README format (numbered tasks with dependency graph)
|
* - Fio README format (numbered tasks with dependency graph)
|
||||||
|
* - Phased format (## Phase N — Title sections with tasks and dependencies)
|
||||||
* - Simple checkbox format (- [ ] task)
|
* - Simple checkbox format (- [ ] task)
|
||||||
* - YAML format (tasks: [...])
|
* - YAML format (tasks: [...])
|
||||||
*/
|
*/
|
||||||
@@ -36,7 +37,7 @@ export function parseTaskFile(filePath: string): Project {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Markdown: detect format
|
// Markdown: detect format
|
||||||
if (hasDependenciesSection(content)) {
|
if (hasDependenciesSection(content) || hasPhaseHeadings(content)) {
|
||||||
return parseFioFormat(content, absolutePath, dir);
|
return parseFioFormat(content, absolutePath, dir);
|
||||||
}
|
}
|
||||||
return parseSimpleCheckbox(content, absolutePath, dir);
|
return parseSimpleCheckbox(content, absolutePath, dir);
|
||||||
@@ -50,6 +51,10 @@ const DEP_HEADING_RE = /^(?:##\s+)?Dependencies\s*$/m;
|
|||||||
const TASK_HEADING_RE = /^(?:##\s+)?Tasks\s*$/m;
|
const TASK_HEADING_RE = /^(?:##\s+)?Tasks\s*$/m;
|
||||||
/** Match other markdown headings (## Something). */
|
/** Match other markdown headings (## Something). */
|
||||||
const ANY_MD_HEADING_RE = /^##\s/;
|
const ANY_MD_HEADING_RE = /^##\s/;
|
||||||
|
/** Match phase headings: ## Phase 1 — Push-to-Talk MVP */
|
||||||
|
const PHASE_HEADING_RE = /^\s*##\s+Phase\s+(\d+)\s*[—–:-]\s*(.+)$/i;
|
||||||
|
/** Detect plain phase headings too: Phase 1 — Title (no ##) */
|
||||||
|
const PHASE_HEADING_PLAIN_RE = /^Phase\s+(\d+)\s*[—–:-]\s*(.+)$/i;
|
||||||
/**
|
/**
|
||||||
* Detect a plain (non-markdown) section heading like "Exit criteria".
|
* Detect a plain (non-markdown) section heading like "Exit criteria".
|
||||||
* A plain heading must:
|
* A plain heading must:
|
||||||
@@ -67,6 +72,10 @@ function hasDependenciesSection(content: string): boolean {
|
|||||||
return DEP_HEADING_RE.test(content);
|
return DEP_HEADING_RE.test(content);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasPhaseHeadings(content: string): boolean {
|
||||||
|
return PHASE_HEADING_RE.test(content) || PHASE_HEADING_PLAIN_RE.test(content);
|
||||||
|
}
|
||||||
|
|
||||||
function parseFioFormat(
|
function parseFioFormat(
|
||||||
content: string,
|
content: string,
|
||||||
sourcePath: string,
|
sourcePath: string,
|
||||||
@@ -76,10 +85,38 @@ function parseFioFormat(
|
|||||||
const tasks: Task[] = [];
|
const tasks: Task[] = [];
|
||||||
const dependencies: Record<string, string[]> = {};
|
const dependencies: Record<string, string[]> = {};
|
||||||
const parallelGroups: ParallelGroup[] = [];
|
const parallelGroups: ParallelGroup[] = [];
|
||||||
|
const phases: Phase[] = [];
|
||||||
|
let currentPhase: number | null = null;
|
||||||
|
let currentPhaseTitle = "";
|
||||||
let inTasks = false;
|
let inTasks = false;
|
||||||
let inDeps = false;
|
let inDeps = false;
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
|
// Check for phase headings first
|
||||||
|
const phaseMatch =
|
||||||
|
line.match(PHASE_HEADING_RE) || line.match(PHASE_HEADING_PLAIN_RE);
|
||||||
|
if (phaseMatch) {
|
||||||
|
// Save previous phase if exists
|
||||||
|
if (currentPhase !== null) {
|
||||||
|
const phaseTaskIds = tasks
|
||||||
|
.filter((t) => t.phase === currentPhase)
|
||||||
|
.map((t) => t.id);
|
||||||
|
if (phaseTaskIds.length > 0) {
|
||||||
|
phases.push({
|
||||||
|
number: currentPhase,
|
||||||
|
title: currentPhaseTitle,
|
||||||
|
taskIds: phaseTaskIds,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Start new phase
|
||||||
|
currentPhase = parseInt(phaseMatch[1], 10);
|
||||||
|
currentPhaseTitle = phaseMatch[2].trim();
|
||||||
|
inTasks = true;
|
||||||
|
inDeps = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (TASK_HEADING_RE.test(line)) {
|
if (TASK_HEADING_RE.test(line)) {
|
||||||
inTasks = true;
|
inTasks = true;
|
||||||
inDeps = false;
|
inDeps = false;
|
||||||
@@ -91,10 +128,13 @@ function parseFioFormat(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Reset state on any other section heading — both ##-style and plain
|
// Reset state on any other section heading — both ##-style and plain
|
||||||
|
// BUT NOT phase headings (already handled above)
|
||||||
if (
|
if (
|
||||||
(ANY_MD_HEADING_RE.test(line) || isPlainSectionHeader(line)) &&
|
(ANY_MD_HEADING_RE.test(line) || isPlainSectionHeader(line)) &&
|
||||||
!TASK_HEADING_RE.test(line) &&
|
!TASK_HEADING_RE.test(line) &&
|
||||||
!DEP_HEADING_RE.test(line)
|
!DEP_HEADING_RE.test(line) &&
|
||||||
|
!PHASE_HEADING_RE.test(line) &&
|
||||||
|
!PHASE_HEADING_PLAIN_RE.test(line)
|
||||||
) {
|
) {
|
||||||
inTasks = false;
|
inTasks = false;
|
||||||
inDeps = false;
|
inDeps = false;
|
||||||
@@ -118,6 +158,7 @@ function parseFioFormat(
|
|||||||
dependencies: [],
|
dependencies: [],
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
index: tasks.length,
|
index: tasks.length,
|
||||||
|
phase: currentPhase ?? undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -292,6 +333,43 @@ function parseFioFormat(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Save final phase if we were in one
|
||||||
|
if (currentPhase !== null) {
|
||||||
|
const phaseTaskIds = tasks
|
||||||
|
.filter((t) => t.phase === currentPhase)
|
||||||
|
.map((t) => t.id);
|
||||||
|
if (phaseTaskIds.length > 0) {
|
||||||
|
phases.push({
|
||||||
|
number: currentPhase,
|
||||||
|
title: currentPhaseTitle,
|
||||||
|
taskIds: phaseTaskIds,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add implicit phase-boundary dependencies
|
||||||
|
// First task of each phase (except phase 1) depends on last task of previous phase
|
||||||
|
if (phases.length > 1) {
|
||||||
|
for (let i = 1; i < phases.length; i++) {
|
||||||
|
const prevPhase = phases[i - 1];
|
||||||
|
const currPhase = phases[i];
|
||||||
|
if (prevPhase.taskIds.length === 0 || currPhase.taskIds.length === 0)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
const lastTaskOfPrevPhase =
|
||||||
|
prevPhase.taskIds[prevPhase.taskIds.length - 1];
|
||||||
|
const firstTaskOfCurrPhase = currPhase.taskIds[0];
|
||||||
|
|
||||||
|
// Add dependency if not already present
|
||||||
|
if (!dependencies[firstTaskOfCurrPhase]) {
|
||||||
|
dependencies[firstTaskOfCurrPhase] = [];
|
||||||
|
}
|
||||||
|
if (!dependencies[firstTaskOfCurrPhase].includes(lastTaskOfPrevPhase)) {
|
||||||
|
dependencies[firstTaskOfCurrPhase].push(lastTaskOfPrevPhase);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Extract exit criteria — detect both ## Exit Criteria and plain Exit criteria
|
// Extract exit criteria — detect both ## Exit Criteria and plain Exit criteria
|
||||||
const exitCriteria: string[] = [];
|
const exitCriteria: string[] = [];
|
||||||
const exitCriteriaRe = /^(?:##\s+)?Exit\s+Criteria/i;
|
const exitCriteriaRe = /^(?:##\s+)?Exit\s+Criteria/i;
|
||||||
@@ -330,6 +408,7 @@ function parseFioFormat(
|
|||||||
tasks,
|
tasks,
|
||||||
dependencies,
|
dependencies,
|
||||||
parallelGroups: parallelGroups.length > 0 ? parallelGroups : undefined,
|
parallelGroups: parallelGroups.length > 0 ? parallelGroups : undefined,
|
||||||
|
phases: phases.length > 0 ? phases : undefined,
|
||||||
sourcePath,
|
sourcePath,
|
||||||
sourceDir,
|
sourceDir,
|
||||||
exitCriteria,
|
exitCriteria,
|
||||||
|
|||||||
13
src/types.ts
13
src/types.ts
@@ -27,6 +27,8 @@ export interface Task {
|
|||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
/** Original index in task list for deterministic ordering */
|
/** Original index in task list for deterministic ordering */
|
||||||
index?: number;
|
index?: number;
|
||||||
|
/** Phase number this task belongs to (1-indexed, from ## Phase N headings) */
|
||||||
|
phase?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ParallelGroup {
|
export interface ParallelGroup {
|
||||||
@@ -38,6 +40,15 @@ export interface ParallelGroup {
|
|||||||
taskIds: string[];
|
taskIds: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Phase {
|
||||||
|
/** Phase number (1-indexed, matches the heading number) */
|
||||||
|
number: number;
|
||||||
|
/** Phase title (e.g. "Push-to-Talk MVP") */
|
||||||
|
title: string;
|
||||||
|
/** Task IDs in this phase, in order */
|
||||||
|
taskIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface Project {
|
export interface Project {
|
||||||
/** Project-level objective / goal */
|
/** Project-level objective / goal */
|
||||||
objective?: string;
|
objective?: string;
|
||||||
@@ -47,6 +58,8 @@ export interface Project {
|
|||||||
dependencies: Record<string, string[]>;
|
dependencies: Record<string, string[]>;
|
||||||
/** Explicit parallel groups from "can be done in parallel" declarations */
|
/** Explicit parallel groups from "can be done in parallel" declarations */
|
||||||
parallelGroups?: ParallelGroup[];
|
parallelGroups?: ParallelGroup[];
|
||||||
|
/** Phased sections from ## Phase N headings (in order) */
|
||||||
|
phases?: Phase[];
|
||||||
/** Exit criteria (from README ## Exit Criteria section) */
|
/** Exit criteria (from README ## Exit Criteria section) */
|
||||||
exitCriteria?: string[];
|
exitCriteria?: string[];
|
||||||
/** Path to the source task file */
|
/** Path to the source task file */
|
||||||
|
|||||||
521
tests/parser-phased.test.ts
Normal file
521
tests/parser-phased.test.ts
Normal file
@@ -0,0 +1,521 @@
|
|||||||
|
/**
|
||||||
|
* Tests for phased task format parsing
|
||||||
|
* Covers: phase detection, task parsing, phase boundaries, implicit dependencies
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, test, expect } from "bun:test";
|
||||||
|
import { parseTaskFile } from "../src/parser";
|
||||||
|
import type { Task } from "../src/types";
|
||||||
|
import { tempDir, writeTaskFile } from "./helpers";
|
||||||
|
|
||||||
|
/** Parse a task file from an inline template literal. */
|
||||||
|
function parse(content: string) {
|
||||||
|
const { dir, cleanup } = tempDir();
|
||||||
|
try {
|
||||||
|
const filePath = writeTaskFile(dir, "README.md", content);
|
||||||
|
return { project: parseTaskFile(filePath), cleanup };
|
||||||
|
} catch (e) {
|
||||||
|
cleanup();
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Phased task format", () => {
|
||||||
|
describe("Phase detection", () => {
|
||||||
|
test("detects phased format with markdown headings", () => {
|
||||||
|
const content = `# Voice Conversation
|
||||||
|
|
||||||
|
## Phase 1 - MVP
|
||||||
|
- [ ] 01 - Build voice pipeline
|
||||||
|
- [ ] 02 - Add audio playback
|
||||||
|
|
||||||
|
## Phase 2 - Streaming
|
||||||
|
- [ ] 03 - WebSocket channel
|
||||||
|
- [ ] 04 - Streaming STT
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
- 02 depends on 01
|
||||||
|
`;
|
||||||
|
const { project, cleanup } = parse(content);
|
||||||
|
try {
|
||||||
|
expect(project.phases).toBeDefined();
|
||||||
|
expect(project.phases?.length).toBe(2);
|
||||||
|
expect(project.phases?.[0].number).toBe(1);
|
||||||
|
expect(project.phases?.[0].title).toBe("MVP");
|
||||||
|
expect(project.phases?.[1].number).toBe(2);
|
||||||
|
expect(project.phases?.[1].title).toBe("Streaming");
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("detects phased format with plain headings", () => {
|
||||||
|
const content = `# Voice Conversation
|
||||||
|
|
||||||
|
Phase 1 - MVP
|
||||||
|
- [ ] 01 - Build voice pipeline
|
||||||
|
- [ ] 02 - Add audio playback
|
||||||
|
|
||||||
|
Phase 2 - Streaming
|
||||||
|
- [ ] 03 - WebSocket channel
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
`;
|
||||||
|
const { project, cleanup } = parse(content);
|
||||||
|
try {
|
||||||
|
expect(project.phases).toBeDefined();
|
||||||
|
expect(project.phases?.length).toBe(2);
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("supports various separators in phase headings", () => {
|
||||||
|
const variants = [
|
||||||
|
"## Phase 1 - MVP",
|
||||||
|
"## Phase 1 - MVP",
|
||||||
|
"## Phase 1 - MVP",
|
||||||
|
"## Phase 1: MVP",
|
||||||
|
"## Phase 1 - MVP", // multiple spaces
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const heading of variants) {
|
||||||
|
const content = `# Test
|
||||||
|
|
||||||
|
${heading}
|
||||||
|
- [ ] 01 - Task
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
`;
|
||||||
|
const { project, cleanup } = parse(content);
|
||||||
|
try {
|
||||||
|
expect(project.phases).toBeDefined();
|
||||||
|
expect(project.phases?.length).toBe(1);
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles phase headings with extra whitespace", () => {
|
||||||
|
const content = `# Test
|
||||||
|
|
||||||
|
## Phase 1 - MVP
|
||||||
|
- [ ] 01 - Task
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
`;
|
||||||
|
const { project, cleanup } = parse(content);
|
||||||
|
try {
|
||||||
|
expect(project.phases).toBeDefined();
|
||||||
|
expect(project.phases?.length).toBe(1);
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Task parsing within phases", () => {
|
||||||
|
test("assigns phase number to tasks", () => {
|
||||||
|
const content = `# Test
|
||||||
|
|
||||||
|
## Phase 1 - MVP
|
||||||
|
- [ ] 01 - Task A
|
||||||
|
- [ ] 02 - Task B
|
||||||
|
|
||||||
|
## Phase 2 - Enhancement
|
||||||
|
- [ ] 03 - Task C
|
||||||
|
- [ ] 04 - Task D
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
`;
|
||||||
|
const { project, cleanup } = parse(content);
|
||||||
|
try {
|
||||||
|
expect(project.tasks[0].id).toBe("01");
|
||||||
|
expect(project.tasks[0].phase).toBe(1);
|
||||||
|
expect(project.tasks[1].id).toBe("02");
|
||||||
|
expect(project.tasks[1].phase).toBe(1);
|
||||||
|
expect(project.tasks[2].id).toBe("03");
|
||||||
|
expect(project.tasks[2].phase).toBe(2);
|
||||||
|
expect(project.tasks[3].id).toBe("04");
|
||||||
|
expect(project.tasks[3].phase).toBe(2);
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tracks task IDs in each phase", () => {
|
||||||
|
const content = `# Test
|
||||||
|
|
||||||
|
## Phase 1 - Foundation
|
||||||
|
- [ ] 01 - Setup
|
||||||
|
- [ ] 02 - Config
|
||||||
|
|
||||||
|
## Phase 2 - Implementation
|
||||||
|
- [ ] 03 - Feature A
|
||||||
|
- [ ] 04 - Feature B
|
||||||
|
- [ ] 05 - Feature C
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
`;
|
||||||
|
const { project, cleanup } = parse(content);
|
||||||
|
try {
|
||||||
|
expect(project.phases?.[0].taskIds).toEqual(["01", "02"]);
|
||||||
|
expect(project.phases?.[1].taskIds).toEqual(["03", "04", "05"]);
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles tasks with different statuses in phases", () => {
|
||||||
|
const content = `# Test
|
||||||
|
|
||||||
|
## Phase 1 - MVP
|
||||||
|
- [x] 01 - Done task
|
||||||
|
- [ ] 02 - Pending task
|
||||||
|
- [~] 03 - In progress
|
||||||
|
|
||||||
|
## Phase 2 - Next
|
||||||
|
- [ ] 04 - Future task
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
`;
|
||||||
|
const { project, cleanup } = parse(content);
|
||||||
|
try {
|
||||||
|
expect(project.tasks[0].status).toBe("completed");
|
||||||
|
expect(project.tasks[1].status).toBe("pending");
|
||||||
|
expect(project.tasks[2].status).toBe("in_progress");
|
||||||
|
expect(project.tasks[3].status).toBe("pending");
|
||||||
|
|
||||||
|
expect(project.phases?.[0].taskIds).toEqual(["01", "02", "03"]);
|
||||||
|
expect(project.phases?.[1].taskIds).toEqual(["04"]);
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles empty phases", () => {
|
||||||
|
const content = `# Test
|
||||||
|
|
||||||
|
## Phase 1 - Empty
|
||||||
|
|
||||||
|
## Phase 2 - Has tasks
|
||||||
|
- [ ] 01 - Task
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
`;
|
||||||
|
const { project, cleanup } = parse(content);
|
||||||
|
try {
|
||||||
|
expect(project.phases?.length).toBe(1);
|
||||||
|
expect(project.phases?.[0].number).toBe(2);
|
||||||
|
expect(project.phases?.[0].taskIds).toEqual(["01"]);
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Implicit phase-boundary dependencies", () => {
|
||||||
|
test("adds dependency from first task of phase 2 to last task of phase 1", () => {
|
||||||
|
const content = `# Test
|
||||||
|
|
||||||
|
## Phase 1 - MVP
|
||||||
|
- [ ] 01 - Setup
|
||||||
|
- [ ] 02 - Build
|
||||||
|
|
||||||
|
## Phase 2 - Enhancement
|
||||||
|
- [ ] 03 - Feature
|
||||||
|
- [ ] 04 - Test
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
`;
|
||||||
|
const { project, cleanup } = parse(content);
|
||||||
|
try {
|
||||||
|
// Task 03 should depend on task 02 (implicit phase boundary)
|
||||||
|
expect(
|
||||||
|
project.tasks.find((t: Task) => t.id === "03")?.dependencies,
|
||||||
|
).toContain("02");
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("adds dependencies across multiple phases", () => {
|
||||||
|
const content = `# Test
|
||||||
|
|
||||||
|
## Phase 1 - Foundation
|
||||||
|
- [ ] 01 - Setup
|
||||||
|
|
||||||
|
## Phase 2 - Core
|
||||||
|
- [ ] 02 - Build
|
||||||
|
- [ ] 03 - Test
|
||||||
|
|
||||||
|
## Phase 3 — Polish
|
||||||
|
- [ ] 04 — Refine
|
||||||
|
- [ ] 05 — Release
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
`;
|
||||||
|
const { project, cleanup } = parse(content);
|
||||||
|
try {
|
||||||
|
// Task 02 depends on task 01 (phase 1 → 2 boundary)
|
||||||
|
expect(
|
||||||
|
project.tasks.find((t: Task) => t.id === "02")?.dependencies,
|
||||||
|
).toContain("01");
|
||||||
|
|
||||||
|
// Task 04 depends on task 03 (phase 2 → 3 boundary)
|
||||||
|
expect(
|
||||||
|
project.tasks.find((t: Task) => t.id === "04")?.dependencies,
|
||||||
|
).toContain("03");
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not duplicate explicit dependencies", () => {
|
||||||
|
const content = `# Test
|
||||||
|
|
||||||
|
## Phase 1 - MVP
|
||||||
|
- [ ] 01 - Setup
|
||||||
|
- [ ] 02 - Build
|
||||||
|
|
||||||
|
## Phase 2 — Enhancement
|
||||||
|
- [ ] 03 — Feature
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
- 03 depends on 02
|
||||||
|
`;
|
||||||
|
const { project, cleanup } = parse(content);
|
||||||
|
try {
|
||||||
|
const task03 = project.tasks.find((t: Task) => t.id === "03");
|
||||||
|
const depCount = task03?.dependencies.filter(
|
||||||
|
(d: string) => d === "02",
|
||||||
|
).length;
|
||||||
|
expect(depCount).toBe(1); // Should not duplicate
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles single phase (no boundaries)", () => {
|
||||||
|
const content = `# Test
|
||||||
|
|
||||||
|
## Phase 1 - All tasks
|
||||||
|
- [ ] 01 - Task A
|
||||||
|
- [ ] 02 - Task B
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
`;
|
||||||
|
const { project, cleanup } = parse(content);
|
||||||
|
try {
|
||||||
|
// No implicit dependencies should be added
|
||||||
|
expect(project.tasks[0].dependencies).toEqual([]);
|
||||||
|
expect(project.tasks[1].dependencies).toEqual([]);
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("works alongside explicit dependencies", () => {
|
||||||
|
const content = `# Test
|
||||||
|
|
||||||
|
## Phase 1 - MVP
|
||||||
|
- [ ] 01 - Setup
|
||||||
|
- [ ] 02 - Build
|
||||||
|
|
||||||
|
## Phase 2 - Enhancement
|
||||||
|
- [ ] 03 - Feature A
|
||||||
|
- [ ] 04 - Feature B
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
- 04 depends on 03
|
||||||
|
`;
|
||||||
|
const { project, cleanup } = parse(content);
|
||||||
|
try {
|
||||||
|
// Task 03 has implicit dependency on task 02
|
||||||
|
expect(
|
||||||
|
project.tasks.find((t: Task) => t.id === "03")?.dependencies,
|
||||||
|
).toContain("02");
|
||||||
|
|
||||||
|
// Task 04 has explicit dependency on task 03
|
||||||
|
expect(
|
||||||
|
project.tasks.find((t: Task) => t.id === "04")?.dependencies,
|
||||||
|
).toContain("03");
|
||||||
|
|
||||||
|
// Task 04 should NOT have implicit dependency on task 02
|
||||||
|
expect(
|
||||||
|
project.tasks.find((t: Task) => t.id === "04")?.dependencies,
|
||||||
|
).not.toContain("02");
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Mixed formats", () => {
|
||||||
|
test("phased format with arrow dependencies", () => {
|
||||||
|
const content = `# Test
|
||||||
|
|
||||||
|
## Phase 1 - Setup
|
||||||
|
- [ ] 01 - Initialize
|
||||||
|
- [ ] 02 - Configure
|
||||||
|
|
||||||
|
## Phase 2 - Build
|
||||||
|
- [ ] 03 - Compile
|
||||||
|
- [ ] 04 - Bundle
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
- 01 → 02
|
||||||
|
- 03 → 04
|
||||||
|
`;
|
||||||
|
const { project, cleanup } = parse(content);
|
||||||
|
try {
|
||||||
|
expect(project.phases?.length).toBe(2);
|
||||||
|
expect(
|
||||||
|
project.tasks.find((t: Task) => t.id === "02")?.dependencies,
|
||||||
|
).toContain("01");
|
||||||
|
expect(
|
||||||
|
project.tasks.find((t: Task) => t.id === "04")?.dependencies,
|
||||||
|
).toContain("03");
|
||||||
|
expect(
|
||||||
|
project.tasks.find((t: Task) => t.id === "03")?.dependencies,
|
||||||
|
).toContain("02");
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("phased format with parallel groups", () => {
|
||||||
|
const content = `# Test
|
||||||
|
|
||||||
|
## Phase 1 - MVP
|
||||||
|
- [ ] 01 - Setup
|
||||||
|
- [ ] 02 - Build
|
||||||
|
|
||||||
|
## Phase 2 - Enhancement
|
||||||
|
- [ ] 03 - Feature
|
||||||
|
- [ ] 04 - Test
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
- 01, 02 can be done in parallel
|
||||||
|
- 03, 04 can be done in parallel
|
||||||
|
`;
|
||||||
|
const { project, cleanup } = parse(content);
|
||||||
|
try {
|
||||||
|
expect(project.phases?.length).toBe(2);
|
||||||
|
expect(project.parallelGroups?.length).toBe(2);
|
||||||
|
expect(
|
||||||
|
project.tasks.find((t: Task) => t.id === "03")?.dependencies,
|
||||||
|
).toContain("02");
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("phased format with exit criteria", () => {
|
||||||
|
const content = `# Test
|
||||||
|
|
||||||
|
## Phase 1 - MVP
|
||||||
|
- [ ] 01 - Build
|
||||||
|
- [ ] 02 - Test
|
||||||
|
|
||||||
|
## Phase 2 - Release
|
||||||
|
- [ ] 03 - Deploy
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
## Exit Criteria
|
||||||
|
- All tests pass
|
||||||
|
- Deployment successful
|
||||||
|
`;
|
||||||
|
const { project, cleanup } = parse(content);
|
||||||
|
try {
|
||||||
|
expect(project.phases?.length).toBe(2);
|
||||||
|
expect(project.exitCriteria?.length).toBe(2);
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Real-world example", () => {
|
||||||
|
test("parses voice conversation PRD correctly", () => {
|
||||||
|
const content = `# Voice Conversation
|
||||||
|
|
||||||
|
Objective: Add full voice conversation capability
|
||||||
|
|
||||||
|
## Phase 1 - Push-to-Talk MVP
|
||||||
|
- [ ] 01 - Build voice pipeline orchestrator → \`01-voice-pipeline-orchestrator.md\`
|
||||||
|
- [ ] 02 - Build auto-playback audio module → \`02-auto-playback-audio-module.md\`
|
||||||
|
- [ ] 03 - Wire voice mode toggle into chat UI → \`03-voice-mode-toggle-ui.md\`
|
||||||
|
- [ ] 04 - End-to-end push-to-talk integration test → \`04-push-to-talk-integration-test.md\`
|
||||||
|
|
||||||
|
## Phase 2 - Streaming & Real-Time
|
||||||
|
- [ ] 05 - Build WebSocket voice channel → \`05-websocket-voice-channel.md\`
|
||||||
|
- [ ] 06 - Implement streaming STT pipeline → \`06-streaming-stt-pipeline.md\`
|
||||||
|
- [ ] 07 - Implement streaming TTS pipeline → \`07-streaming-tts-pipeline.md\`
|
||||||
|
|
||||||
|
## Phase 3 - Optimization & Hardening
|
||||||
|
- [ ] 08 - Model quantization and VRAM budget manager → \`08-model-quantization.md\`
|
||||||
|
- [ ] 09 - Latency profiling and pipeline optimization → \`09-latency-profiling.md\`
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
- 02 depends on 01
|
||||||
|
- 03 depends on 01, 02
|
||||||
|
- 04 depends on 03
|
||||||
|
- 06 depends on 05
|
||||||
|
- 07 depends on 05
|
||||||
|
- 09 depends on 08
|
||||||
|
|
||||||
|
## Exit Criteria
|
||||||
|
- Users can hold multi-turn voice conversations
|
||||||
|
- Total round-trip latency under 3s
|
||||||
|
`;
|
||||||
|
const { project, cleanup } = parse(content);
|
||||||
|
try {
|
||||||
|
// Verify phases
|
||||||
|
expect(project.phases?.length).toBe(3);
|
||||||
|
expect(project.phases?.[0].title).toBe("Push-to-Talk MVP");
|
||||||
|
expect(project.phases?.[1].title).toBe("Streaming & Real-Time");
|
||||||
|
expect(project.phases?.[2].title).toBe("Optimization & Hardening");
|
||||||
|
|
||||||
|
// Verify task phases
|
||||||
|
expect(project.tasks[0].phase).toBe(1);
|
||||||
|
expect(project.tasks[4].phase).toBe(2);
|
||||||
|
expect(project.tasks[7].phase).toBe(3);
|
||||||
|
|
||||||
|
// Verify phase boundaries
|
||||||
|
// Task 05 (first in phase 2) depends on task 04 (last in phase 1)
|
||||||
|
expect(
|
||||||
|
project.tasks.find((t: Task) => t.id === "05")?.dependencies,
|
||||||
|
).toContain("04");
|
||||||
|
|
||||||
|
// Task 08 (first in phase 3) depends on task 07 (last in phase 2)
|
||||||
|
expect(
|
||||||
|
project.tasks.find((t: Task) => t.id === "08")?.dependencies,
|
||||||
|
).toContain("07");
|
||||||
|
|
||||||
|
// Verify explicit dependencies still work
|
||||||
|
expect(
|
||||||
|
project.tasks.find((t: Task) => t.id === "02")?.dependencies,
|
||||||
|
).toContain("01");
|
||||||
|
expect(
|
||||||
|
project.tasks.find((t: Task) => t.id === "03")?.dependencies,
|
||||||
|
).toContain("01");
|
||||||
|
expect(
|
||||||
|
project.tasks.find((t: Task) => t.id === "03")?.dependencies,
|
||||||
|
).toContain("02");
|
||||||
|
|
||||||
|
// Verify task files
|
||||||
|
expect(project.tasks[0].file).toBe("01-voice-pipeline-orchestrator.md");
|
||||||
|
expect(project.tasks[1].file).toBe("02-auto-playback-audio-module.md");
|
||||||
|
|
||||||
|
// Verify exit criteria
|
||||||
|
expect(project.exitCriteria?.length).toBe(2);
|
||||||
|
expect(project.objective).toBe("Voice Conversation");
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user