diff --git a/package.json b/package.json index e0d5ef2..a0ceb70 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "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", "keywords": [ "pi-package", diff --git a/src/parser.ts b/src/parser.ts index 56f687c..27a0fe0 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -1,6 +1,6 @@ import * as fs from "node:fs"; 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 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. * Supports: * - Fio README format (numbered tasks with dependency graph) + * - Phased format (## Phase N — Title sections with tasks and dependencies) * - Simple checkbox format (- [ ] task) * - YAML format (tasks: [...]) */ @@ -36,7 +37,7 @@ export function parseTaskFile(filePath: string): Project { } // Markdown: detect format - if (hasDependenciesSection(content)) { + if (hasDependenciesSection(content) || hasPhaseHeadings(content)) { return parseFioFormat(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; /** Match other markdown headings (## Something). */ 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". * A plain heading must: @@ -67,6 +72,10 @@ function hasDependenciesSection(content: string): boolean { 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( content: string, sourcePath: string, @@ -76,10 +85,38 @@ function parseFioFormat( const tasks: Task[] = []; const dependencies: Record = {}; const parallelGroups: ParallelGroup[] = []; + const phases: Phase[] = []; + let currentPhase: number | null = null; + let currentPhaseTitle = ""; let inTasks = false; let inDeps = false; 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)) { inTasks = true; inDeps = false; @@ -91,10 +128,13 @@ function parseFioFormat( continue; } // Reset state on any other section heading — both ##-style and plain + // BUT NOT phase headings (already handled above) if ( (ANY_MD_HEADING_RE.test(line) || isPlainSectionHeader(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; inDeps = false; @@ -118,6 +158,7 @@ function parseFioFormat( dependencies: [], timeoutMs, 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 const exitCriteria: string[] = []; const exitCriteriaRe = /^(?:##\s+)?Exit\s+Criteria/i; @@ -330,6 +408,7 @@ function parseFioFormat( tasks, dependencies, parallelGroups: parallelGroups.length > 0 ? parallelGroups : undefined, + phases: phases.length > 0 ? phases : undefined, sourcePath, sourceDir, exitCriteria, diff --git a/src/types.ts b/src/types.ts index efbfd87..714f7a6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -27,6 +27,8 @@ export interface Task { timeoutMs?: number; /** Original index in task list for deterministic ordering */ index?: number; + /** Phase number this task belongs to (1-indexed, from ## Phase N headings) */ + phase?: number; } export interface ParallelGroup { @@ -38,6 +40,15 @@ export interface ParallelGroup { 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 { /** Project-level objective / goal */ objective?: string; @@ -47,6 +58,8 @@ export interface Project { dependencies: Record; /** Explicit parallel groups from "can be done in parallel" declarations */ parallelGroups?: ParallelGroup[]; + /** Phased sections from ## Phase N headings (in order) */ + phases?: Phase[]; /** Exit criteria (from README ## Exit Criteria section) */ exitCriteria?: string[]; /** Path to the source task file */ diff --git a/tests/parser-phased.test.ts b/tests/parser-phased.test.ts new file mode 100644 index 0000000..5843b25 --- /dev/null +++ b/tests/parser-phased.test.ts @@ -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(); + } + }); + }); +});