diff --git a/src/dag.ts b/src/dag.ts index a98a235..3ec29b1 100644 --- a/src/dag.ts +++ b/src/dag.ts @@ -1,4 +1,10 @@ -import type { Task, ExecutionBatch, ExecutionPlan, Project } from "./types"; +import type { + Task, + ExecutionBatch, + ExecutionPlan, + Project, + ParallelGroup, +} from "./types"; // ─── Blocked Tasks ─────────────────────────────────────────────────────────── @@ -7,25 +13,25 @@ import type { Task, ExecutionBatch, ExecutionPlan, Project } from "./types"; * Returns a Set of blocked task IDs. */ export function getBlockedTasks( - pendingTasks: Task[], - failedTaskIds: Set, + pendingTasks: Task[], + failedTaskIds: Set, ): Set { - const blocked = new Set(); + const blocked = new Set(); - let changed = true; - while (changed) { - changed = false; - for (const task of pendingTasks) { - if (blocked.has(task.id)) continue; - const deps = task.dependencies || []; - if (deps.some((dep) => failedTaskIds.has(dep))) { - blocked.add(task.id); - changed = true; - } - } - } + let changed = true; + while (changed) { + changed = false; + for (const task of pendingTasks) { + if (blocked.has(task.id)) continue; + const deps = task.dependencies || []; + if (deps.some((dep) => failedTaskIds.has(dep))) { + blocked.add(task.id); + changed = true; + } + } + } - return blocked; + return blocked; } // ─── Main Entry ────────────────────────────────────────────────────────────── @@ -35,29 +41,44 @@ export function getBlockedTasks( * Returns ordered batches of parallelizable tasks. */ export function buildExecutionPlan( - project: Project, - completed: Set, - parallelGroup?: number, - failedTaskIds: Set = new Set(), + project: Project, + completed: Set, + parallelGroup?: number, + failedTaskIds: Set = new Set(), ): ExecutionPlan { - // Filter out already completed tasks - const pendingTasks = project.tasks.filter((t) => !completed.has(t.id)); + // Filter out already completed tasks + const pendingTasks = project.tasks.filter((t) => !completed.has(t.id)); + const skippedTasks = project.tasks.filter((t) => completed.has(t.id)); - // If parallel_group is explicitly set, use group-based batching - if (parallelGroup !== undefined) { - return { - batches: buildParallelGroupBatches(pendingTasks, failedTaskIds), - totalTasks: pendingTasks.length, - skippedTasks: project.tasks.filter((t) => completed.has(t.id)), - }; - } + // With explicitly declared parallel groups, all groups are independent. + // Since there are no cross-group dependencies by definition, standard + // Kahn's algorithm produces the correct plan — tasks ready in any group + // appear in the same batch, and intra-group dependencies (e.g. "21 must + // be done before 22, 23, 24") are respected automatically. + // The parallel groups are preserved as metadata for display/documentation. + if (project.parallelGroups && project.parallelGroups.length > 0) { + return { + batches: buildGroupAwareBatches(project, pendingTasks, failedTaskIds), + totalTasks: pendingTasks.length, + skippedTasks, + }; + } - // Use dependency-based Kahn's algorithm - return { - batches: buildBatches(pendingTasks, failedTaskIds), - totalTasks: pendingTasks.length, - skippedTasks: project.tasks.filter((t) => completed.has(t.id)), - }; + // If parallel_group is explicitly set (legacy config flag), use group-based batching + if (parallelGroup !== undefined) { + return { + batches: buildParallelGroupBatchesLegacy(pendingTasks, failedTaskIds), + totalTasks: pendingTasks.length, + skippedTasks, + }; + } + + // Use dependency-based Kahn's algorithm + return { + batches: buildBatches(pendingTasks, failedTaskIds), + totalTasks: pendingTasks.length, + skippedTasks, + }; } // ─── Sequential Plan ───────────────────────────────────────────────────────── @@ -66,106 +87,165 @@ export function buildExecutionPlan( * Build a sequential execution plan (one task per batch) */ export function buildSequentialPlan( - project: Project, - completed: Set, - failedTaskIds: Set = new Set(), + project: Project, + completed: Set, + failedTaskIds: Set = new Set(), ): ExecutionPlan { - const pendingTasks = project.tasks.filter((t) => !completed.has(t.id)); + const pendingTasks = project.tasks.filter((t) => !completed.has(t.id)); - // Mark tasks with failed dependencies as skipped - const blocked = getBlockedTasks(pendingTasks, failedTaskIds); - const skippedTasks = project.tasks.filter( - (t) => completed.has(t.id) || blocked.has(t.id), - ); - const activeTasks = pendingTasks.filter((t) => !blocked.has(t.id)); + // Mark tasks with failed dependencies as skipped + const blocked = getBlockedTasks(pendingTasks, failedTaskIds); + const skippedTasks = project.tasks.filter( + (t) => completed.has(t.id) || blocked.has(t.id), + ); + const activeTasks = pendingTasks.filter((t) => !blocked.has(t.id)); - const batches: ExecutionBatch[] = activeTasks.map((task, i) => ({ - tasks: [task], - batchIndex: i, - })); + const batches: ExecutionBatch[] = activeTasks.map((task, i) => ({ + tasks: [task], + batchIndex: i, + })); - return { - batches, - totalTasks: pendingTasks.length, - skippedTasks, - }; + return { + batches, + totalTasks: pendingTasks.length, + skippedTasks, + }; } // ─── Kahn's Algorithm (Dependency-Based Batching) ──────────────────────────── function buildBatches( - pendingTasks: Task[], - failedTaskIds: Set, + pendingTasks: Task[], + failedTaskIds: Set, ): ExecutionBatch[] { - const batches: ExecutionBatch[] = []; - const done = new Set(); - const blocked = getBlockedTasks(pendingTasks, failedTaskIds); - const pendingSet = new Set(pendingTasks.map((t) => t.id)); - const remaining = new Set( - pendingTasks.filter((t) => !blocked.has(t.id)).map((t) => t.id), - ); + const batches: ExecutionBatch[] = []; + const done = new Set(); + const blocked = getBlockedTasks(pendingTasks, failedTaskIds); + const pendingSet = new Set(pendingTasks.map((t) => t.id)); + const remaining = new Set( + pendingTasks.filter((t) => !blocked.has(t.id)).map((t) => t.id), + ); - while (remaining.size > 0) { - // Find tasks whose dependencies are all satisfied - const ready: Task[] = []; - for (const task of pendingTasks) { - if (!remaining.has(task.id)) continue; + while (remaining.size > 0) { + // Find tasks whose dependencies are all satisfied + const ready: Task[] = []; + for (const task of pendingTasks) { + if (!remaining.has(task.id)) continue; - const deps = task.dependencies || []; - const depsSatisfied = deps.every( - (dep) => done.has(dep) || !pendingSet.has(dep), - ); + const deps = task.dependencies || []; + const depsSatisfied = deps.every( + (dep) => done.has(dep) || !pendingSet.has(dep), + ); - if (depsSatisfied) { - ready.push(task); - } - } + if (depsSatisfied) { + ready.push(task); + } + } - // Cycle detection: no tasks ready but some remain - if (ready.length === 0) { - const cycleTasks = Array.from(remaining); - throw new Error( - `Dependency cycle detected among tasks: ${cycleTasks.join(", ")}`, - ); - } + // Cycle detection: no tasks ready but some remain + if (ready.length === 0) { + const cycleTasks = Array.from(remaining); + throw new Error( + `Dependency cycle detected among tasks: ${cycleTasks.join(", ")}`, + ); + } - batches.push({ tasks: ready, batchIndex: batches.length }); - for (const task of ready) { - done.add(task.id); - remaining.delete(task.id); - } - } + batches.push({ tasks: ready, batchIndex: batches.length }); + for (const task of ready) { + done.add(task.id); + remaining.delete(task.id); + } + } - return batches; + return batches; } -// ─── Parallel Group Batching ───────────────────────────────────────────────── +// ─── Group-Aware Batching ──────────────────────────────────────────────────── /** - * Build batches from explicit parallel_group values. - * Groups execute in ascending order; tasks within a group run concurrently. + * Build batches respecting both explicit parallel groups and intra-group + * dependencies. Since parallel group declarations imply no cross-group + * dependencies, all tasks whose dependencies are satisfied — across any + * group — can run concurrently in the same batch. This means groups + * "proceed independently" as the user specified: tasks from different + * groups can appear in the same batch when ready. + * + * Intra-group dependencies (e.g., "21 must be done before 22, 23, 24") + * are handled by Kahn's algorithm: if 21 has deps satisfied but 22 doesn't, + * only 21 appears in the current batch. */ -function buildParallelGroupBatches( - pendingTasks: Task[], - failedTaskIds: Set, +function buildGroupAwareBatches( + _project: Project, + pendingTasks: Task[], + failedTaskIds: Set, ): ExecutionBatch[] { - const blocked = getBlockedTasks(pendingTasks, failedTaskIds); - const activeTasks = pendingTasks.filter((t) => !blocked.has(t.id)); + const blocked = getBlockedTasks(pendingTasks, failedTaskIds); + const activeTasks = pendingTasks.filter((t) => !blocked.has(t.id)); - const groups = new Map(); + // Standard Kahn's algorithm across ALL tasks — parallel groups are + // metadata for display, not scheduling constraints. + const pendingSet = new Set(pendingTasks.map((t) => t.id)); + const done = new Set(); + const remaining = new Set(activeTasks.map((t) => t.id)); + const batches: ExecutionBatch[] = []; - for (const task of activeTasks) { - const group = task.parallelGroup ?? 0; - if (!groups.has(group)) groups.set(group, []); - groups.get(group)!.push(task); - } + while (remaining.size > 0) { + const ready: Task[] = []; + for (const task of activeTasks) { + if (!remaining.has(task.id)) continue; + const deps = task.dependencies || []; + const depsSatisfied = deps.every( + (dep) => done.has(dep) || !pendingSet.has(dep), + ); + if (depsSatisfied) { + ready.push(task); + } + } - const sortedGroups = Array.from(groups.entries()).sort((a, b) => a[0] - b[0]); + if (ready.length === 0) { + throw new Error( + `Dependency cycle detected: ${Array.from(remaining).join(", ")}`, + ); + } - return sortedGroups.map(([_groupNum, tasks], i) => ({ - tasks, - batchIndex: i, - })); + batches.push({ tasks: ready, batchIndex: batches.length }); + for (const task of ready) { + done.add(task.id); + remaining.delete(task.id); + } + } + + return batches; +} + +// ─── Legacy Parallel Group Batching ───────────────────────────────────────── + +/** + * Legacy: build batches from explicit parallel_group values only. + * Groups execute in ascending order; tasks within a group run concurrently. + * Does NOT respect intra-group dependencies. + */ +function buildParallelGroupBatchesLegacy( + pendingTasks: Task[], + failedTaskIds: Set, +): ExecutionBatch[] { + const blocked = getBlockedTasks(pendingTasks, failedTaskIds); + const activeTasks = pendingTasks.filter((t) => !blocked.has(t.id)); + + const groups = new Map(); + + for (const task of activeTasks) { + const group = task.parallelGroup ?? 0; + if (!groups.has(group)) groups.set(group, []); + groups.get(group)!.push(task); + } + + const sortedGroups = Array.from(groups.entries()).sort((a, b) => a[0] - b[0]); + + return sortedGroups.map(([_groupNum, tasks], i) => ({ + tasks, + batchIndex: i, + })); } // ─── Cycle Detection ───────────────────────────────────────────────────────── @@ -174,51 +254,51 @@ function buildParallelGroupBatches( * Detect cycles in the task dependency graph */ export function detectCycles(project: Project): string[] { - const adj = new Map(); - for (const task of project.tasks) { - adj.set(task.id, task.dependencies || []); - } + const adj = new Map(); + for (const task of project.tasks) { + adj.set(task.id, task.dependencies || []); + } - const WHITE = 0; - const GRAY = 1; - const BLACK = 2; - const color = new Map(); + const WHITE = 0; + const GRAY = 1; + const BLACK = 2; + const color = new Map(); - for (const task of project.tasks) { - color.set(task.id, WHITE); - } + for (const task of project.tasks) { + color.set(task.id, WHITE); + } - const cycleNodes: string[] = []; + const cycleNodes: string[] = []; - function dfs(node: string): boolean { - color.set(node, GRAY); - const deps = adj.get(node) || []; + function dfs(node: string): boolean { + color.set(node, GRAY); + const deps = adj.get(node) || []; - for (const dep of deps) { - if (!adj.has(dep)) continue; - const depColor = color.get(dep); + for (const dep of deps) { + if (!adj.has(dep)) continue; + const depColor = color.get(dep); - if (depColor === GRAY) { - cycleNodes.push(dep); - return true; - } - if (depColor === WHITE && dfs(dep)) { - cycleNodes.push(node); - return true; - } - } + if (depColor === GRAY) { + cycleNodes.push(dep); + return true; + } + if (depColor === WHITE && dfs(dep)) { + cycleNodes.push(node); + return true; + } + } - color.set(node, BLACK); - return false; - } + color.set(node, BLACK); + return false; + } - for (const task of project.tasks) { - if (color.get(task.id) === WHITE) { - dfs(task.id); - } - } + for (const task of project.tasks) { + if (color.get(task.id) === WHITE) { + dfs(task.id); + } + } - return [...new Set(cycleNodes)]; + return [...new Set(cycleNodes)]; } // ─── Ready Tasks ───────────────────────────────────────────────────────────── @@ -227,14 +307,14 @@ export function detectCycles(project: Project): string[] { * Get tasks that are ready to execute (all dependencies completed) */ export function getReadyTasks( - project: Project, - completed: Set, + project: Project, + completed: Set, ): Task[] { - return project.tasks.filter((task) => { - if (completed.has(task.id)) return false; - const deps = task.dependencies || []; - return deps.every((dep) => completed.has(dep)); - }); + return project.tasks.filter((task) => { + if (completed.has(task.id)) return false; + const deps = task.dependencies || []; + return deps.every((dep) => completed.has(dep)); + }); } // ─── Critical Path ─────────────────────────────────────────────────────────── @@ -243,70 +323,70 @@ export function getReadyTasks( * Calculate the critical path (longest path through the DAG) */ export function getCriticalPath(project: Project): Task[] { - const taskMap = new Map(project.tasks.map((t) => [t.id, t])); - const dist = new Map(); - const prev = new Map(); + const taskMap = new Map(project.tasks.map((t) => [t.id, t])); + const dist = new Map(); + const prev = new Map(); - // Initialize - for (const task of project.tasks) { - dist.set(task.id, 1); - prev.set(task.id, null); - } + // Initialize + for (const task of project.tasks) { + dist.set(task.id, 1); + prev.set(task.id, null); + } - // Topological sort - const sorted: Task[] = []; - const visited = new Set(); + // Topological sort + const sorted: Task[] = []; + const visited = new Set(); - function visit(id: string) { - if (visited.has(id)) return; - visited.add(id); - const task = taskMap.get(id); - if (!task) return; + function visit(id: string) { + if (visited.has(id)) return; + visited.add(id); + const task = taskMap.get(id); + if (!task) return; - for (const dep of task.dependencies || []) { - visit(dep); - } - sorted.push(task); - } + for (const dep of task.dependencies || []) { + visit(dep); + } + sorted.push(task); + } - for (const task of project.tasks) { - visit(task.id); - } + for (const task of project.tasks) { + visit(task.id); + } - // Relax edges - for (const task of sorted) { - for (const dep of task.dependencies || []) { - const depDist = dist.get(dep); - if (depDist === undefined) continue; + // Relax edges + for (const task of sorted) { + for (const dep of task.dependencies || []) { + const depDist = dist.get(dep); + if (depDist === undefined) continue; - const newDist = depDist + 1; - const currentDist = dist.get(task.id) ?? 0; - if (newDist > currentDist) { - dist.set(task.id, newDist); - prev.set(task.id, dep); - } - } - } + const newDist = depDist + 1; + const currentDist = dist.get(task.id) ?? 0; + if (newDist > currentDist) { + dist.set(task.id, newDist); + prev.set(task.id, dep); + } + } + } - // Trace back from the longest path end - let maxTask = project.tasks[0]; - for (const task of project.tasks) { - const taskDist = dist.get(task.id) ?? 0; - const maxDist = dist.get(maxTask.id) ?? 0; - if (taskDist > maxDist) { - maxTask = task; - } - } + // Trace back from the longest path end + let maxTask = project.tasks[0]; + for (const task of project.tasks) { + const taskDist = dist.get(task.id) ?? 0; + const maxDist = dist.get(maxTask.id) ?? 0; + if (taskDist > maxDist) { + maxTask = task; + } + } - const path: Task[] = []; - let current: string | null = maxTask.id; - while (current) { - const task = taskMap.get(current); - if (task) path.unshift(task); - current = prev.get(current) || null; - } + const path: Task[] = []; + let current: string | null = maxTask.id; + while (current) { + const task = taskMap.get(current); + if (task) path.unshift(task); + current = prev.get(current) || null; + } - return path; + return path; } // ─── Format Dependency Chain ───────────────────────────────────────────────── @@ -316,86 +396,86 @@ export function getCriticalPath(project: Project): Task[] { * Rooted at tasks with no dependencies, showing what depends on what. */ export function formatDependencyChain(project: Project): string { - const taskMap = new Map(project.tasks.map((t) => [t.id, t])); - const lines: string[] = []; + const taskMap = new Map(project.tasks.map((t) => [t.id, t])); + const lines: string[] = []; - lines.push("## Dependency Chain"); - lines.push(""); + lines.push("## Dependency Chain"); + lines.push(""); - if (project.tasks.length === 0) { - lines.push("(no tasks)"); - return lines.join("\n"); - } + if (project.tasks.length === 0) { + lines.push("(no tasks)"); + return lines.join("\n"); + } - // Build reverse dependency map: taskId → [dependent taskIds] - const dependents = new Map(); - for (const task of project.tasks) { - dependents.set(task.id, []); - } - for (const task of project.tasks) { - for (const dep of task.dependencies) { - if (dependents.has(dep)) { - dependents.get(dep)!.push(task.id); - } - } - } + // Build reverse dependency map: taskId → [dependent taskIds] + const dependents = new Map(); + for (const task of project.tasks) { + dependents.set(task.id, []); + } + for (const task of project.tasks) { + for (const dep of task.dependencies) { + if (dependents.has(dep)) { + dependents.get(dep)!.push(task.id); + } + } + } - // Root tasks: those with no dependencies - const roots = project.tasks.filter((t) => t.dependencies.length === 0); - const rendered = new Set(); + // Root tasks: those with no dependencies + const roots = project.tasks.filter((t) => t.dependencies.length === 0); + const rendered = new Set(); - function renderNode(taskId: string, prefix: string, isLast: boolean): void { - const task = taskMap.get(taskId); - if (!task) return; + function renderNode(taskId: string, prefix: string, isLast: boolean): void { + const task = taskMap.get(taskId); + if (!task) return; - const alreadyRendered = rendered.has(taskId); - rendered.add(taskId); + const alreadyRendered = rendered.has(taskId); + rendered.add(taskId); - const connector = prefix ? (isLast ? "└── " : "├── ") : ""; + const connector = prefix ? (isLast ? "└── " : "├── ") : ""; - if (alreadyRendered) { - lines.push(`${prefix}${connector}${task.id} · ${task.title}`); - return; - } + if (alreadyRendered) { + lines.push(`${prefix}${connector}${task.id} · ${task.title}`); + return; + } - const deps = - task.dependencies.length > 0 - ? ` ← needs ${task.dependencies.join(", ")}` - : " (root)"; + const deps = + task.dependencies.length > 0 + ? ` ← needs ${task.dependencies.join(", ")}` + : " (root)"; - lines.push( - `${prefix}${connector}${task.id} · ${task.title}${prefix ? "" : deps}`, - ); + lines.push( + `${prefix}${connector}${task.id} · ${task.title}${prefix ? "" : deps}`, + ); - const children = (dependents.get(taskId) || []) - .filter((c) => c !== taskId) - .sort(); + const children = (dependents.get(taskId) || []) + .filter((c) => c !== taskId) + .sort(); - for (let i = 0; i < children.length; i++) { - const childPrefix = prefix + (isLast ? " " : "│ "); - renderNode(children[i], childPrefix, i === children.length - 1); - } - } + for (let i = 0; i < children.length; i++) { + const childPrefix = prefix + (isLast ? " " : "│ "); + renderNode(children[i], childPrefix, i === children.length - 1); + } + } - for (let i = 0; i < roots.length; i++) { - renderNode(roots[i].id, "", i === roots.length - 1); - } + for (let i = 0; i < roots.length; i++) { + renderNode(roots[i].id, "", i === roots.length - 1); + } - // Tasks not reached from any root (have deps but no root-traversable path) - const unreached = project.tasks.filter((t) => !rendered.has(t.id)); - if (unreached.length > 0) { - lines.push(""); - lines.push("Orphan tasks (dependencies not in task list):"); - for (const t of unreached) { - const deps = - t.dependencies.length > 0 - ? ` ← needs ${t.dependencies.join(", ")}` - : ""; - lines.push(` ${t.id} · ${t.title}${deps}`); - } - } + // Tasks not reached from any root (have deps but no root-traversable path) + const unreached = project.tasks.filter((t) => !rendered.has(t.id)); + if (unreached.length > 0) { + lines.push(""); + lines.push("Orphan tasks (dependencies not in task list):"); + for (const t of unreached) { + const deps = + t.dependencies.length > 0 + ? ` ← needs ${t.dependencies.join(", ")}` + : ""; + lines.push(` ${t.id} · ${t.title}${deps}`); + } + } - return lines.join("\n"); + return lines.join("\n"); } // ─── Format Execution Plan ─────────────────────────────────────────────────── @@ -403,27 +483,52 @@ export function formatDependencyChain(project: Project): string { /** * Format the execution plan for display */ -export function formatExecutionPlan(plan: ExecutionPlan): string { - const lines: string[] = []; - lines.push("## Execution Plan"); - lines.push(""); - lines.push(`Total tasks: ${plan.totalTasks}`); - lines.push(`Batches: ${plan.batches.length}`); +/** + * Format the execution plan for display, optionally with parallel group annotations + */ +export function formatExecutionPlan( + plan: ExecutionPlan, + parallelGroups?: ParallelGroup[], +): string { + const lines: string[] = []; + lines.push("## Execution Plan"); + lines.push(""); + lines.push(`Total tasks: ${plan.totalTasks}`); + lines.push(`Batches: ${plan.batches.length}`); - if (plan.skippedTasks.length > 0) { - lines.push( - `Already completed: ${plan.skippedTasks.map((t) => t.id).join(", ")}`, - ); - } - lines.push(""); + // Build a lookup: taskId → group label + const groupLabel = new Map(); + if (parallelGroups) { + for (const g of parallelGroups) { + for (const id of g.taskIds) { + if (g.label) { + groupLabel.set(id, g.label); + } + } + } + } - for (const batch of plan.batches) { - lines.push(`### Batch ${batch.batchIndex + 1}`); - for (const task of batch.tasks) { - lines.push(`- ${task.id}: ${task.title}`); - } - lines.push(""); - } + if (plan.skippedTasks.length > 0) { + lines.push( + `Already completed: ${plan.skippedTasks.map((t) => t.id).join(", ")}`, + ); + } + lines.push(""); - return lines.join("\n"); + for (const batch of plan.batches) { + lines.push(`### Batch ${batch.batchIndex + 1}`); + for (const task of batch.tasks) { + const annotation = groupLabel.has(task.id) + ? ` _(${groupLabel.get(task.id)})_` + : ""; + const deps = + task.dependencies.length > 0 + ? ` ← needs ${task.dependencies.join(", ")}` + : ""; + lines.push(`- ${task.id}: ${task.title}${annotation}${deps}`); + } + lines.push(""); + } + + return lines.join("\n"); } diff --git a/src/parser.ts b/src/parser.ts index eab6538..60fa2f2 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 } from "./types"; +import type { Task, Project, ParallelGroup } from "./types"; // Lazy-loaded yaml package let YAML_module: typeof import("yaml") | undefined; @@ -56,6 +56,7 @@ function parseFioFormat( const lines = content.split("\n"); const tasks: Task[] = []; const dependencies: Record = {}; + const parallelGroups: ParallelGroup[] = []; let inTasks = false; let inDeps = false; @@ -151,8 +152,9 @@ function parseFioFormat( // Format 1: Natural language "X depends on A, B, C" // Supports optional markdown list prefix: "- 13 depends on 17, 18, 19" + // Also handles "also depends on": "- 08 also depends on 05, 06" const dependsMatch = line.match( - /^(?:\s*[-*]\s+)?(\d+)\s+depends\s+on\s+([\d,\s]+)/i, + /^(?:\s*[-*]\s+)?(\d+)\s+(?:also\s+)?depends\s+on\s+([\d,\s]+)/i, ); if (dependsMatch) { const [, taskId, depsList] = dependsMatch; @@ -164,7 +166,11 @@ function parseFioFormat( .map((t) => t.padStart(2, "0")); if (!dependencies[taskIdPadded]) dependencies[taskIdPadded] = []; - dependencies[taskIdPadded].push(...depIds); + for (const depId of depIds) { + if (!dependencies[taskIdPadded].includes(depId)) { + dependencies[taskIdPadded].push(depId); + } + } } // Parse meta blocks for task configuration (timeout, etc.) @@ -178,6 +184,91 @@ function parseFioFormat( task.timeoutMs = parseTimeoutValue(Number(value), unit); } } + + // Format 2: "X, Y, Z can be done in parallel (label)" + // "- 01, 02, 03, 04 can be done in parallel (Play Store prep)" + const parallelMatch = line.match( + /^(?:\s*[-*]\s+)?((?:0?\d+\s*,\s*)*0?\d+)\s+can\s+be\s+done\s+in\s+parallel(?:\s+\(([^)]+)\))?$/i, + ); + if (parallelMatch) { + const [, idsStr, label] = parallelMatch; + const taskIds = idsStr + .split(",") + .map((t) => t.trim()) + .filter((t) => /^\d+$/.test(t)) + .map((t) => t.padStart(2, "0")); + + if (taskIds.length > 0) { + parallelGroups.push({ + index: parallelGroups.length, + label: label ? label.trim() : undefined, + taskIds, + }); + } + } + + // Format 3: "A must be done before B, C" or "A, B must be done before C" + // "- 21 must be done before 22, 23, 24 (backend integration foundation)" + // "- 02, 03 must be done before 04" + const mustBeforeMatch = line.match( + /^(?:\s*[-*]\s+)?((?:0?\d+\s*,\s*)*0?\d+)\s+must\s+be\s+done\s+before\s+((?:0?\d+\s*,\s*)*0?\d+)(?:\s+\(([^)]+)\))?$/i, + ); + if (mustBeforeMatch) { + const [, fromIdsStr, toIdsStr] = mustBeforeMatch; + const fromIds = fromIdsStr + .split(",") + .map((t) => t.trim()) + .filter((t) => /^\d+$/.test(t)) + .map((t) => t.padStart(2, "0")); + const toIds = toIdsStr + .split(",") + .map((t) => t.trim()) + .filter((t) => /^\d+$/.test(t)) + .map((t) => t.padStart(2, "0")); + + // Each "to" task depends on ALL "from" tasks + for (const toId of toIds) { + if (!dependencies[toId]) dependencies[toId] = []; + for (const fromId of fromIds) { + if (!dependencies[toId].includes(fromId)) { + dependencies[toId].push(fromId); + } + } + } + } + + // Format 4: "X, Y, Z depend on A" or "X depends on A, B, C" + // "- 22, 23, 24 depend on 21" + // "- 05, 06 depend on 02, 03, 04" + // "- 08 also depends on 05, 06" ("also" is ignored) + // Strip optional "also" before matching + const cleanedLine = line.replace(/\balso\b/i, ""); + const dependOnMatch = cleanedLine.match( + /^(?:\s*[-*]\s+)?((?:0?\d+\s*,\s*)*0?\d+)\s+depend(?:s)?\s+on\s+((?:0?\d+\s*,\s*)*0?\d+)(?:\s+\(([^)]+)\))?$/i, + ); + if (dependOnMatch) { + const [, fromIdsStr, toIdsStr] = dependOnMatch; + const fromIds = fromIdsStr + .split(",") + .map((t) => t.trim()) + .filter((t) => /^\d+$/.test(t)) + .map((t) => t.padStart(2, "0")); + const toIds = toIdsStr + .split(",") + .map((t) => t.trim()) + .filter((t) => /^\d+$/.test(t)) + .map((t) => t.padStart(2, "0")); + + // Each "from" task depends on ALL "to" tasks + for (const fromId of fromIds) { + if (!dependencies[fromId]) dependencies[fromId] = []; + for (const toId of toIds) { + if (!dependencies[fromId].includes(toId)) { + dependencies[fromId].push(toId); + } + } + } + } } } @@ -203,9 +294,20 @@ function parseFioFormat( } } + // Apply parallelGroup to tasks + for (const group of parallelGroups) { + for (const taskId of group.taskIds) { + const task = tasks.find((t) => t.id === taskId); + if (task) { + task.parallelGroup = group.index; + } + } + } + return { tasks, dependencies, + parallelGroups: parallelGroups.length > 0 ? parallelGroups : undefined, sourcePath, sourceDir, exitCriteria, diff --git a/src/types.ts b/src/types.ts index cdce6b0..efbfd87 100644 --- a/src/types.ts +++ b/src/types.ts @@ -29,6 +29,15 @@ export interface Task { index?: number; } +export interface ParallelGroup { + /** Group index (0-based, determines execution order) */ + index: number; + /** Human-readable label for the group (e.g. "Play Store prep") */ + label?: string; + /** Task IDs in this group — all can run concurrently */ + taskIds: string[]; +} + export interface Project { /** Project-level objective / goal */ objective?: string; @@ -36,6 +45,8 @@ export interface Project { tasks: Task[]; /** Explicit dependency map: taskId → [dependency taskIds] */ dependencies: Record; + /** Explicit parallel groups from "can be done in parallel" declarations */ + parallelGroups?: ParallelGroup[]; /** Exit criteria (from README ## Exit Criteria section) */ exitCriteria?: string[]; /** Path to the source task file */