Compare commits

...

2 Commits

Author SHA1 Message Date
3892e2a637 bump 2026-06-01 12:58:13 -04:00
8151d19127 more dependancy parsing support 2026-06-01 11:31:33 -04:00
4 changed files with 515 additions and 297 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "@mikefreno/ralpi",
"version": "0.1.8",
"version": "0.1.9",
"description": "Execute tasks from task files/PRD's using DAG-based dependency resolution with persistent progress tracking",
"keywords": [
"pi-package",

View File

@@ -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<string>,
pendingTasks: Task[],
failedTaskIds: Set<string>,
): Set<string> {
const blocked = new Set<string>();
const blocked = new Set<string>();
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<string>,
parallelGroup?: number,
failedTaskIds: Set<string> = new Set(),
project: Project,
completed: Set<string>,
parallelGroup?: number,
failedTaskIds: Set<string> = 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<string>,
failedTaskIds: Set<string> = new Set(),
project: Project,
completed: Set<string>,
failedTaskIds: Set<string> = 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<string>,
pendingTasks: Task[],
failedTaskIds: Set<string>,
): ExecutionBatch[] {
const batches: ExecutionBatch[] = [];
const done = new Set<string>();
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<string>();
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<string>,
function buildGroupAwareBatches(
_project: Project,
pendingTasks: Task[],
failedTaskIds: Set<string>,
): 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<number, Task[]>();
// 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<string>();
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<string>,
): ExecutionBatch[] {
const blocked = getBlockedTasks(pendingTasks, failedTaskIds);
const activeTasks = pendingTasks.filter((t) => !blocked.has(t.id));
const groups = new Map<number, Task[]>();
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<string, string[]>();
for (const task of project.tasks) {
adj.set(task.id, task.dependencies || []);
}
const adj = new Map<string, string[]>();
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<string, number>();
const WHITE = 0;
const GRAY = 1;
const BLACK = 2;
const color = new Map<string, number>();
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<string>,
project: Project,
completed: Set<string>,
): 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<string, number>();
const prev = new Map<string, string | null>();
const taskMap = new Map(project.tasks.map((t) => [t.id, t]));
const dist = new Map<string, number>();
const prev = new Map<string, string | null>();
// 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<string>();
// Topological sort
const sorted: Task[] = [];
const visited = new Set<string>();
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<string, string[]>();
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<string, string[]>();
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<string>();
// Root tasks: those with no dependencies
const roots = project.tasks.filter((t) => t.dependencies.length === 0);
const rendered = new Set<string>();
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<string, string>();
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");
}

View File

@@ -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<string, string[]> = {};
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,

View File

@@ -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<string, string[]>;
/** 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 */