Add loop-active marker, YAML task file support, and auto-updating PRD checkboxes
- Persist loop-active state for widget re-instantiation after session reload - Add YAML task file parsing and update support via yaml library - Auto-update PRD source file checkboxes on task status changes - Add batchRender callback for real-time parallel widget animation - Normalize tabs-to-spaces indentation across source files - Use padStart(2, '0') for ID formatting instead of hardcoded prefix - Enable parallel execution for single-task DAG batches
This commit is contained in:
533
src/dag.ts
533
src/dag.ts
@@ -7,25 +7,25 @@ import type { Task, ExecutionBatch, ExecutionPlan, Project } from "./types";
|
|||||||
* Returns a Set of blocked task IDs.
|
* Returns a Set of blocked task IDs.
|
||||||
*/
|
*/
|
||||||
export function getBlockedTasks(
|
export function getBlockedTasks(
|
||||||
pendingTasks: Task[],
|
pendingTasks: Task[],
|
||||||
failedTaskIds: Set<string>,
|
failedTaskIds: Set<string>,
|
||||||
): Set<string> {
|
): Set<string> {
|
||||||
const blocked = new Set<string>();
|
const blocked = new Set<string>();
|
||||||
|
|
||||||
let changed = true;
|
let changed = true;
|
||||||
while (changed) {
|
while (changed) {
|
||||||
changed = false;
|
changed = false;
|
||||||
for (const task of pendingTasks) {
|
for (const task of pendingTasks) {
|
||||||
if (blocked.has(task.id)) continue;
|
if (blocked.has(task.id)) continue;
|
||||||
const deps = task.dependencies || [];
|
const deps = task.dependencies || [];
|
||||||
if (deps.some((dep) => failedTaskIds.has(dep))) {
|
if (deps.some((dep) => failedTaskIds.has(dep))) {
|
||||||
blocked.add(task.id);
|
blocked.add(task.id);
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return blocked;
|
return blocked;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Main Entry ──────────────────────────────────────────────────────────────
|
// ─── Main Entry ──────────────────────────────────────────────────────────────
|
||||||
@@ -35,29 +35,29 @@ export function getBlockedTasks(
|
|||||||
* Returns ordered batches of parallelizable tasks.
|
* Returns ordered batches of parallelizable tasks.
|
||||||
*/
|
*/
|
||||||
export function buildExecutionPlan(
|
export function buildExecutionPlan(
|
||||||
project: Project,
|
project: Project,
|
||||||
completed: Set<string>,
|
completed: Set<string>,
|
||||||
parallelGroup?: number,
|
parallelGroup?: number,
|
||||||
failedTaskIds: Set<string> = new Set(),
|
failedTaskIds: Set<string> = new Set(),
|
||||||
): ExecutionPlan {
|
): ExecutionPlan {
|
||||||
// Filter out already completed tasks
|
// Filter out already completed tasks
|
||||||
const pendingTasks = project.tasks.filter((t) => !completed.has(t.id));
|
const pendingTasks = project.tasks.filter((t) => !completed.has(t.id));
|
||||||
|
|
||||||
// If parallel_group is explicitly set, use group-based batching
|
// If parallel_group is explicitly set, use group-based batching
|
||||||
if (parallelGroup !== undefined) {
|
if (parallelGroup !== undefined) {
|
||||||
return {
|
return {
|
||||||
batches: buildParallelGroupBatches(pendingTasks, failedTaskIds),
|
batches: buildParallelGroupBatches(pendingTasks, failedTaskIds),
|
||||||
totalTasks: pendingTasks.length,
|
totalTasks: pendingTasks.length,
|
||||||
skippedTasks: project.tasks.filter((t) => completed.has(t.id)),
|
skippedTasks: project.tasks.filter((t) => completed.has(t.id)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use dependency-based Kahn's algorithm
|
// Use dependency-based Kahn's algorithm
|
||||||
return {
|
return {
|
||||||
batches: buildBatches(pendingTasks, failedTaskIds),
|
batches: buildBatches(pendingTasks, failedTaskIds),
|
||||||
totalTasks: pendingTasks.length,
|
totalTasks: pendingTasks.length,
|
||||||
skippedTasks: project.tasks.filter((t) => completed.has(t.id)),
|
skippedTasks: project.tasks.filter((t) => completed.has(t.id)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Sequential Plan ─────────────────────────────────────────────────────────
|
// ─── Sequential Plan ─────────────────────────────────────────────────────────
|
||||||
@@ -66,77 +66,77 @@ export function buildExecutionPlan(
|
|||||||
* Build a sequential execution plan (one task per batch)
|
* Build a sequential execution plan (one task per batch)
|
||||||
*/
|
*/
|
||||||
export function buildSequentialPlan(
|
export function buildSequentialPlan(
|
||||||
project: Project,
|
project: Project,
|
||||||
completed: Set<string>,
|
completed: Set<string>,
|
||||||
failedTaskIds: Set<string> = new Set(),
|
failedTaskIds: Set<string> = new Set(),
|
||||||
): ExecutionPlan {
|
): 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
|
// Mark tasks with failed dependencies as skipped
|
||||||
const blocked = getBlockedTasks(pendingTasks, failedTaskIds);
|
const blocked = getBlockedTasks(pendingTasks, failedTaskIds);
|
||||||
const skippedTasks = project.tasks.filter(
|
const skippedTasks = project.tasks.filter(
|
||||||
(t) => completed.has(t.id) || blocked.has(t.id),
|
(t) => completed.has(t.id) || blocked.has(t.id),
|
||||||
);
|
);
|
||||||
const activeTasks = pendingTasks.filter((t) => !blocked.has(t.id));
|
const activeTasks = pendingTasks.filter((t) => !blocked.has(t.id));
|
||||||
|
|
||||||
const batches: ExecutionBatch[] = activeTasks.map((task, i) => ({
|
const batches: ExecutionBatch[] = activeTasks.map((task, i) => ({
|
||||||
tasks: [task],
|
tasks: [task],
|
||||||
batchIndex: i,
|
batchIndex: i,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
batches,
|
batches,
|
||||||
totalTasks: pendingTasks.length,
|
totalTasks: pendingTasks.length,
|
||||||
skippedTasks,
|
skippedTasks,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Kahn's Algorithm (Dependency-Based Batching) ────────────────────────────
|
// ─── Kahn's Algorithm (Dependency-Based Batching) ────────────────────────────
|
||||||
|
|
||||||
function buildBatches(
|
function buildBatches(
|
||||||
pendingTasks: Task[],
|
pendingTasks: Task[],
|
||||||
failedTaskIds: Set<string>,
|
failedTaskIds: Set<string>,
|
||||||
): ExecutionBatch[] {
|
): ExecutionBatch[] {
|
||||||
const batches: ExecutionBatch[] = [];
|
const batches: ExecutionBatch[] = [];
|
||||||
const done = new Set<string>();
|
const done = new Set<string>();
|
||||||
const blocked = getBlockedTasks(pendingTasks, failedTaskIds);
|
const blocked = getBlockedTasks(pendingTasks, failedTaskIds);
|
||||||
const pendingSet = new Set(pendingTasks.map((t) => t.id));
|
const pendingSet = new Set(pendingTasks.map((t) => t.id));
|
||||||
const remaining = new Set(
|
const remaining = new Set(
|
||||||
pendingTasks.filter((t) => !blocked.has(t.id)).map((t) => t.id),
|
pendingTasks.filter((t) => !blocked.has(t.id)).map((t) => t.id),
|
||||||
);
|
);
|
||||||
|
|
||||||
while (remaining.size > 0) {
|
while (remaining.size > 0) {
|
||||||
// Find tasks whose dependencies are all satisfied
|
// Find tasks whose dependencies are all satisfied
|
||||||
const ready: Task[] = [];
|
const ready: Task[] = [];
|
||||||
for (const task of pendingTasks) {
|
for (const task of pendingTasks) {
|
||||||
if (!remaining.has(task.id)) continue;
|
if (!remaining.has(task.id)) continue;
|
||||||
|
|
||||||
const deps = task.dependencies || [];
|
const deps = task.dependencies || [];
|
||||||
const depsSatisfied = deps.every(
|
const depsSatisfied = deps.every(
|
||||||
(dep) => done.has(dep) || !pendingSet.has(dep),
|
(dep) => done.has(dep) || !pendingSet.has(dep),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (depsSatisfied) {
|
if (depsSatisfied) {
|
||||||
ready.push(task);
|
ready.push(task);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cycle detection: no tasks ready but some remain
|
// Cycle detection: no tasks ready but some remain
|
||||||
if (ready.length === 0) {
|
if (ready.length === 0) {
|
||||||
const cycleTasks = Array.from(remaining);
|
const cycleTasks = Array.from(remaining);
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Dependency cycle detected among tasks: ${cycleTasks.join(", ")}`,
|
`Dependency cycle detected among tasks: ${cycleTasks.join(", ")}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
batches.push({ tasks: ready, batchIndex: batches.length });
|
batches.push({ tasks: ready, batchIndex: batches.length });
|
||||||
for (const task of ready) {
|
for (const task of ready) {
|
||||||
done.add(task.id);
|
done.add(task.id);
|
||||||
remaining.delete(task.id);
|
remaining.delete(task.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return batches;
|
return batches;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Parallel Group Batching ─────────────────────────────────────────────────
|
// ─── Parallel Group Batching ─────────────────────────────────────────────────
|
||||||
@@ -146,26 +146,26 @@ function buildBatches(
|
|||||||
* Groups execute in ascending order; tasks within a group run concurrently.
|
* Groups execute in ascending order; tasks within a group run concurrently.
|
||||||
*/
|
*/
|
||||||
function buildParallelGroupBatches(
|
function buildParallelGroupBatches(
|
||||||
pendingTasks: Task[],
|
pendingTasks: Task[],
|
||||||
failedTaskIds: Set<string>,
|
failedTaskIds: Set<string>,
|
||||||
): ExecutionBatch[] {
|
): ExecutionBatch[] {
|
||||||
const blocked = getBlockedTasks(pendingTasks, failedTaskIds);
|
const blocked = getBlockedTasks(pendingTasks, failedTaskIds);
|
||||||
const activeTasks = pendingTasks.filter((t) => !blocked.has(t.id));
|
const activeTasks = pendingTasks.filter((t) => !blocked.has(t.id));
|
||||||
|
|
||||||
const groups = new Map<number, Task[]>();
|
const groups = new Map<number, Task[]>();
|
||||||
|
|
||||||
for (const task of activeTasks) {
|
for (const task of activeTasks) {
|
||||||
const group = task.parallelGroup ?? 0;
|
const group = task.parallelGroup ?? 0;
|
||||||
if (!groups.has(group)) groups.set(group, []);
|
if (!groups.has(group)) groups.set(group, []);
|
||||||
groups.get(group)!.push(task);
|
groups.get(group)!.push(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sortedGroups = Array.from(groups.entries()).sort((a, b) => a[0] - b[0]);
|
const sortedGroups = Array.from(groups.entries()).sort((a, b) => a[0] - b[0]);
|
||||||
|
|
||||||
return sortedGroups.map(([_groupNum, tasks], i) => ({
|
return sortedGroups.map(([_groupNum, tasks], i) => ({
|
||||||
tasks,
|
tasks,
|
||||||
batchIndex: i,
|
batchIndex: i,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Cycle Detection ─────────────────────────────────────────────────────────
|
// ─── Cycle Detection ─────────────────────────────────────────────────────────
|
||||||
@@ -174,51 +174,51 @@ function buildParallelGroupBatches(
|
|||||||
* Detect cycles in the task dependency graph
|
* Detect cycles in the task dependency graph
|
||||||
*/
|
*/
|
||||||
export function detectCycles(project: Project): string[] {
|
export function detectCycles(project: Project): string[] {
|
||||||
const adj = new Map<string, string[]>();
|
const adj = new Map<string, string[]>();
|
||||||
for (const task of project.tasks) {
|
for (const task of project.tasks) {
|
||||||
adj.set(task.id, task.dependencies || []);
|
adj.set(task.id, task.dependencies || []);
|
||||||
}
|
}
|
||||||
|
|
||||||
const WHITE = 0;
|
const WHITE = 0;
|
||||||
const GRAY = 1;
|
const GRAY = 1;
|
||||||
const BLACK = 2;
|
const BLACK = 2;
|
||||||
const color = new Map<string, number>();
|
const color = new Map<string, number>();
|
||||||
|
|
||||||
for (const task of project.tasks) {
|
for (const task of project.tasks) {
|
||||||
color.set(task.id, WHITE);
|
color.set(task.id, WHITE);
|
||||||
}
|
}
|
||||||
|
|
||||||
const cycleNodes: string[] = [];
|
const cycleNodes: string[] = [];
|
||||||
|
|
||||||
function dfs(node: string): boolean {
|
function dfs(node: string): boolean {
|
||||||
color.set(node, GRAY);
|
color.set(node, GRAY);
|
||||||
const deps = adj.get(node) || [];
|
const deps = adj.get(node) || [];
|
||||||
|
|
||||||
for (const dep of deps) {
|
for (const dep of deps) {
|
||||||
if (!adj.has(dep)) continue;
|
if (!adj.has(dep)) continue;
|
||||||
const depColor = color.get(dep);
|
const depColor = color.get(dep);
|
||||||
|
|
||||||
if (depColor === GRAY) {
|
if (depColor === GRAY) {
|
||||||
cycleNodes.push(dep);
|
cycleNodes.push(dep);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (depColor === WHITE && dfs(dep)) {
|
if (depColor === WHITE && dfs(dep)) {
|
||||||
cycleNodes.push(node);
|
cycleNodes.push(node);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
color.set(node, BLACK);
|
color.set(node, BLACK);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const task of project.tasks) {
|
for (const task of project.tasks) {
|
||||||
if (color.get(task.id) === WHITE) {
|
if (color.get(task.id) === WHITE) {
|
||||||
dfs(task.id);
|
dfs(task.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return [...new Set(cycleNodes)];
|
return [...new Set(cycleNodes)];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Ready Tasks ─────────────────────────────────────────────────────────────
|
// ─── Ready Tasks ─────────────────────────────────────────────────────────────
|
||||||
@@ -227,14 +227,14 @@ export function detectCycles(project: Project): string[] {
|
|||||||
* Get tasks that are ready to execute (all dependencies completed)
|
* Get tasks that are ready to execute (all dependencies completed)
|
||||||
*/
|
*/
|
||||||
export function getReadyTasks(
|
export function getReadyTasks(
|
||||||
project: Project,
|
project: Project,
|
||||||
completed: Set<string>,
|
completed: Set<string>,
|
||||||
): Task[] {
|
): Task[] {
|
||||||
return project.tasks.filter((task) => {
|
return project.tasks.filter((task) => {
|
||||||
if (completed.has(task.id)) return false;
|
if (completed.has(task.id)) return false;
|
||||||
const deps = task.dependencies || [];
|
const deps = task.dependencies || [];
|
||||||
return deps.every((dep) => completed.has(dep));
|
return deps.every((dep) => completed.has(dep));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Critical Path ───────────────────────────────────────────────────────────
|
// ─── Critical Path ───────────────────────────────────────────────────────────
|
||||||
@@ -243,70 +243,159 @@ export function getReadyTasks(
|
|||||||
* Calculate the critical path (longest path through the DAG)
|
* Calculate the critical path (longest path through the DAG)
|
||||||
*/
|
*/
|
||||||
export function getCriticalPath(project: Project): Task[] {
|
export function getCriticalPath(project: Project): Task[] {
|
||||||
const taskMap = new Map(project.tasks.map((t) => [t.id, t]));
|
const taskMap = new Map(project.tasks.map((t) => [t.id, t]));
|
||||||
const dist = new Map<string, number>();
|
const dist = new Map<string, number>();
|
||||||
const prev = new Map<string, string | null>();
|
const prev = new Map<string, string | null>();
|
||||||
|
|
||||||
// Initialize
|
// Initialize
|
||||||
for (const task of project.tasks) {
|
for (const task of project.tasks) {
|
||||||
dist.set(task.id, 1);
|
dist.set(task.id, 1);
|
||||||
prev.set(task.id, null);
|
prev.set(task.id, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Topological sort
|
// Topological sort
|
||||||
const sorted: Task[] = [];
|
const sorted: Task[] = [];
|
||||||
const visited = new Set<string>();
|
const visited = new Set<string>();
|
||||||
|
|
||||||
function visit(id: string) {
|
function visit(id: string) {
|
||||||
if (visited.has(id)) return;
|
if (visited.has(id)) return;
|
||||||
visited.add(id);
|
visited.add(id);
|
||||||
const task = taskMap.get(id);
|
const task = taskMap.get(id);
|
||||||
if (!task) return;
|
if (!task) return;
|
||||||
|
|
||||||
for (const dep of task.dependencies || []) {
|
for (const dep of task.dependencies || []) {
|
||||||
visit(dep);
|
visit(dep);
|
||||||
}
|
}
|
||||||
sorted.push(task);
|
sorted.push(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const task of project.tasks) {
|
for (const task of project.tasks) {
|
||||||
visit(task.id);
|
visit(task.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Relax edges
|
// Relax edges
|
||||||
for (const task of sorted) {
|
for (const task of sorted) {
|
||||||
for (const dep of task.dependencies || []) {
|
for (const dep of task.dependencies || []) {
|
||||||
const depDist = dist.get(dep);
|
const depDist = dist.get(dep);
|
||||||
if (depDist === undefined) continue;
|
if (depDist === undefined) continue;
|
||||||
|
|
||||||
const newDist = depDist + 1;
|
const newDist = depDist + 1;
|
||||||
const currentDist = dist.get(task.id) ?? 0;
|
const currentDist = dist.get(task.id) ?? 0;
|
||||||
if (newDist > currentDist) {
|
if (newDist > currentDist) {
|
||||||
dist.set(task.id, newDist);
|
dist.set(task.id, newDist);
|
||||||
prev.set(task.id, dep);
|
prev.set(task.id, dep);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trace back from the longest path end
|
// Trace back from the longest path end
|
||||||
let maxTask = project.tasks[0];
|
let maxTask = project.tasks[0];
|
||||||
for (const task of project.tasks) {
|
for (const task of project.tasks) {
|
||||||
const taskDist = dist.get(task.id) ?? 0;
|
const taskDist = dist.get(task.id) ?? 0;
|
||||||
const maxDist = dist.get(maxTask.id) ?? 0;
|
const maxDist = dist.get(maxTask.id) ?? 0;
|
||||||
if (taskDist > maxDist) {
|
if (taskDist > maxDist) {
|
||||||
maxTask = task;
|
maxTask = task;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const path: Task[] = [];
|
const path: Task[] = [];
|
||||||
let current: string | null = maxTask.id;
|
let current: string | null = maxTask.id;
|
||||||
while (current) {
|
while (current) {
|
||||||
const task = taskMap.get(current);
|
const task = taskMap.get(current);
|
||||||
if (task) path.unshift(task);
|
if (task) path.unshift(task);
|
||||||
current = prev.get(current) || null;
|
current = prev.get(current) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return path;
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Format Dependency Chain ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format the dependency DAG as a tree for display.
|
||||||
|
* 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[] = [];
|
||||||
|
|
||||||
|
lines.push("## Dependency Chain");
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
const alreadyRendered = rendered.has(taskId);
|
||||||
|
rendered.add(taskId);
|
||||||
|
|
||||||
|
const connector = prefix ? (isLast ? "└── " : "├── ") : "";
|
||||||
|
|
||||||
|
if (alreadyRendered) {
|
||||||
|
lines.push(`${prefix}${connector}${task.id} · ${task.title}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deps =
|
||||||
|
task.dependencies.length > 0
|
||||||
|
? ` ← needs ${task.dependencies.join(", ")}`
|
||||||
|
: " (root)";
|
||||||
|
|
||||||
|
lines.push(
|
||||||
|
`${prefix}${connector}${task.id} · ${task.title}${prefix ? "" : deps}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
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 < 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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Format Execution Plan ───────────────────────────────────────────────────
|
// ─── Format Execution Plan ───────────────────────────────────────────────────
|
||||||
@@ -315,26 +404,26 @@ export function getCriticalPath(project: Project): Task[] {
|
|||||||
* Format the execution plan for display
|
* Format the execution plan for display
|
||||||
*/
|
*/
|
||||||
export function formatExecutionPlan(plan: ExecutionPlan): string {
|
export function formatExecutionPlan(plan: ExecutionPlan): string {
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
lines.push("## Execution Plan");
|
lines.push("## Execution Plan");
|
||||||
lines.push("");
|
lines.push("");
|
||||||
lines.push(`Total tasks: ${plan.totalTasks}`);
|
lines.push(`Total tasks: ${plan.totalTasks}`);
|
||||||
lines.push(`Batches: ${plan.batches.length}`);
|
lines.push(`Batches: ${plan.batches.length}`);
|
||||||
|
|
||||||
if (plan.skippedTasks.length > 0) {
|
if (plan.skippedTasks.length > 0) {
|
||||||
lines.push(
|
lines.push(
|
||||||
`Already completed: ${plan.skippedTasks.map((t) => t.id).join(", ")}`,
|
`Already completed: ${plan.skippedTasks.map((t) => t.id).join(", ")}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
lines.push("");
|
lines.push("");
|
||||||
|
|
||||||
for (const batch of plan.batches) {
|
for (const batch of plan.batches) {
|
||||||
lines.push(`### Batch ${batch.batchIndex + 1}`);
|
lines.push(`### Batch ${batch.batchIndex + 1}`);
|
||||||
for (const task of batch.tasks) {
|
for (const task of batch.tasks) {
|
||||||
lines.push(`- ${task.id}: ${task.title}`);
|
lines.push(`- ${task.id}: ${task.title}`);
|
||||||
}
|
}
|
||||||
lines.push("");
|
lines.push("");
|
||||||
}
|
}
|
||||||
|
|
||||||
return lines.join("\n");
|
return lines.join("\n");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
captureGitCommits,
|
captureGitCommits,
|
||||||
formatDuration,
|
formatDuration,
|
||||||
} from "./utils";
|
} from "./utils";
|
||||||
|
import { updateTaskInFile } from "./parser";
|
||||||
|
|
||||||
/** Optional callback to post a progress message into the chat history. */
|
/** Optional callback to post a progress message into the chat history. */
|
||||||
export type SendChatMessage = (
|
export type SendChatMessage = (
|
||||||
@@ -33,7 +34,18 @@ export interface ToolCallEntry {
|
|||||||
* messages rendered by registerMessageRenderer). */
|
* messages rendered by registerMessageRenderer). */
|
||||||
const MAX_COLLAPSED = 3;
|
const MAX_COLLAPSED = 3;
|
||||||
|
|
||||||
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
export const SPINNER_FRAMES = [
|
||||||
|
"⠋",
|
||||||
|
"⠙",
|
||||||
|
"⠹",
|
||||||
|
"⠸",
|
||||||
|
"⠼",
|
||||||
|
"⠴",
|
||||||
|
"⠦",
|
||||||
|
"⠧",
|
||||||
|
"⠇",
|
||||||
|
"⠏",
|
||||||
|
];
|
||||||
|
|
||||||
// ─── Model Round-Robin ─────────────────────────────────────────────────────
|
// ─── Model Round-Robin ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -135,6 +147,7 @@ export async function runTask(
|
|||||||
projectDir: string = project.sourceDir,
|
projectDir: string = project.sourceDir,
|
||||||
parallelState?: ParallelWidgetState,
|
parallelState?: ParallelWidgetState,
|
||||||
assignedModel?: unknown,
|
assignedModel?: unknown,
|
||||||
|
batchRender?: () => void,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
reflection?: Reflection;
|
reflection?: Reflection;
|
||||||
@@ -271,8 +284,10 @@ export async function runTask(
|
|||||||
if (entry) {
|
if (entry) {
|
||||||
entry.toolCalls.push({ name: event.toolName, label });
|
entry.toolCalls.push({ name: event.toolName, label });
|
||||||
}
|
}
|
||||||
|
batchRender?.();
|
||||||
|
} else {
|
||||||
|
requestRender();
|
||||||
}
|
}
|
||||||
requestRender();
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
undefined, // no abort signal
|
undefined, // no abort signal
|
||||||
@@ -291,6 +306,7 @@ export async function runTask(
|
|||||||
entry.done = true;
|
entry.done = true;
|
||||||
entry.success = output.success;
|
entry.success = output.success;
|
||||||
}
|
}
|
||||||
|
batchRender?.();
|
||||||
} else {
|
} else {
|
||||||
ctx.ui.setWidget(widgetKey, undefined);
|
ctx.ui.setWidget(widgetKey, undefined);
|
||||||
}
|
}
|
||||||
@@ -393,9 +409,12 @@ export async function executeBatch(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we should run parallel
|
// Check if we should run parallel.
|
||||||
|
// Use the parallel path whenever the user selected parallel mode,
|
||||||
|
// even for single-task batches produced by DAG dependency chains.
|
||||||
|
// Only sequential mode should inherit the parent session model.
|
||||||
const shouldParallel =
|
const shouldParallel =
|
||||||
options?.parallel && tasks.length > 1 && config.execution.maxParallel > 0;
|
options?.parallel && tasks.length > 0 && config.execution.maxParallel > 0;
|
||||||
|
|
||||||
if (shouldParallel) {
|
if (shouldParallel) {
|
||||||
await executeBatchParallel(
|
await executeBatchParallel(
|
||||||
@@ -429,6 +448,12 @@ export async function executeBatch(
|
|||||||
|
|
||||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
progress.markFailed(task.id, errorMsg);
|
progress.markFailed(task.id, errorMsg);
|
||||||
|
// Auto-update the PRD source file checkbox
|
||||||
|
try {
|
||||||
|
updateTaskInFile(project.sourcePath, task.id, "failed");
|
||||||
|
} catch {
|
||||||
|
// Best-effort
|
||||||
|
}
|
||||||
sendChatMessage?.(`✗ ${task.id} · ${task.title} — ${errorMsg}`);
|
sendChatMessage?.(`✗ ${task.id} · ${task.title} — ${errorMsg}`);
|
||||||
ctx.ui.notify(`Task ${task.id} failed: ${errorMsg}`, "error");
|
ctx.ui.notify(`Task ${task.id} failed: ${errorMsg}`, "error");
|
||||||
break;
|
break;
|
||||||
@@ -518,14 +543,18 @@ async function executeBatchParallel(
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Single spinner timer drives all tasks in the batch
|
// Batch-render trigger: re-render on spinner ticks AND content changes.
|
||||||
|
// Spinner animation requires requestRender() on every tick; without it,
|
||||||
|
// spinner frames advance in memory but the display never updates.
|
||||||
|
const requestBatchRender = () => widgetTui?.requestRender();
|
||||||
|
|
||||||
const spinnerTimer = setInterval(() => {
|
const spinnerTimer = setInterval(() => {
|
||||||
for (const entry of sharedState.values()) {
|
for (const entry of sharedState.values()) {
|
||||||
if (!entry.done) {
|
if (!entry.done) {
|
||||||
entry.frameIndex = (entry.frameIndex + 1) % SPINNER_FRAMES.length;
|
entry.frameIndex = (entry.frameIndex + 1) % SPINNER_FRAMES.length;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
widgetTui?.requestRender();
|
requestBatchRender();
|
||||||
}, 100);
|
}, 100);
|
||||||
|
|
||||||
const results: Array<{ task: Task; result: Promise<any> }> = [];
|
const results: Array<{ task: Task; result: Promise<any> }> = [];
|
||||||
@@ -545,13 +574,21 @@ async function executeBatchParallel(
|
|||||||
sharedState,
|
sharedState,
|
||||||
assignedModel,
|
assignedModel,
|
||||||
roundRobin,
|
roundRobin,
|
||||||
|
requestBatchRender,
|
||||||
).catch((error) => {
|
).catch((error) => {
|
||||||
// Safety net: one task failure should never crash the batch.
|
// Safety net: one task failure should never crash the batch.
|
||||||
// executeTask already marks failed and notifies, but catch as
|
// executeTask already marks failed and notifies, but catch as
|
||||||
// a last resort so the error doesn't propagate and crash pi.
|
// a last resort so the error doesn't propagate and crash pi.
|
||||||
roundRobin?.release(task.id);
|
roundRobin?.release(task.id);
|
||||||
|
requestBatchRender();
|
||||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
progress.markFailed(task.id, errorMsg);
|
progress.markFailed(task.id, errorMsg);
|
||||||
|
// Auto-update the PRD source file checkbox
|
||||||
|
try {
|
||||||
|
updateTaskInFile(project.sourcePath, task.id, "failed");
|
||||||
|
} catch {
|
||||||
|
// Best-effort
|
||||||
|
}
|
||||||
sendChatMessage?.(`✗ ${task.id} · ${task.title} — ${errorMsg}`);
|
sendChatMessage?.(`✗ ${task.id} · ${task.title} — ${errorMsg}`);
|
||||||
ctx.ui.notify(`Task ${task.id} failed: ${errorMsg}`, "error");
|
ctx.ui.notify(`Task ${task.id} failed: ${errorMsg}`, "error");
|
||||||
}),
|
}),
|
||||||
@@ -586,6 +623,7 @@ async function executeTask(
|
|||||||
parallelState?: ParallelWidgetState,
|
parallelState?: ParallelWidgetState,
|
||||||
assignedModel?: unknown,
|
assignedModel?: unknown,
|
||||||
roundRobin?: ModelRoundRobin | null,
|
roundRobin?: ModelRoundRobin | null,
|
||||||
|
batchRender?: () => void,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const maxRetries = config.execution.maxRetries;
|
const maxRetries = config.execution.maxRetries;
|
||||||
|
|
||||||
@@ -609,6 +647,12 @@ async function executeTask(
|
|||||||
try {
|
try {
|
||||||
// Mark as in progress
|
// Mark as in progress
|
||||||
progress.markInProgress(task.id);
|
progress.markInProgress(task.id);
|
||||||
|
// Auto-update the PRD source file checkbox
|
||||||
|
try {
|
||||||
|
updateTaskInFile(project.sourcePath, task.id, "in_progress");
|
||||||
|
} catch {
|
||||||
|
// Best-effort: don't fail the task over a checkbox update
|
||||||
|
}
|
||||||
|
|
||||||
// Get dependency reflections
|
// Get dependency reflections
|
||||||
const depReflections = progress.getDependencyReflections(
|
const depReflections = progress.getDependencyReflections(
|
||||||
@@ -626,6 +670,7 @@ async function executeTask(
|
|||||||
projectDir,
|
projectDir,
|
||||||
parallelState,
|
parallelState,
|
||||||
currentModel,
|
currentModel,
|
||||||
|
batchRender,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
@@ -645,6 +690,12 @@ async function executeTask(
|
|||||||
result.commitMessages,
|
result.commitMessages,
|
||||||
result.commitSummary,
|
result.commitSummary,
|
||||||
);
|
);
|
||||||
|
// Auto-update the PRD source file checkbox
|
||||||
|
try {
|
||||||
|
updateTaskInFile(project.sourcePath, task.id, "completed");
|
||||||
|
} catch {
|
||||||
|
// Best-effort: don't fail the task over a checkbox update
|
||||||
|
}
|
||||||
roundRobin?.release(task.id);
|
roundRobin?.release(task.id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -675,6 +726,7 @@ async function executeTask(
|
|||||||
} else {
|
} else {
|
||||||
// Max retries exceeded
|
// Max retries exceeded
|
||||||
progress.markFailed(task.id, result.error || "Unknown error");
|
progress.markFailed(task.id, result.error || "Unknown error");
|
||||||
|
// Don't update PRD — retry exhaustion is transient, not terminal
|
||||||
sendChatMessage?.(`✗ ${task.id} · ${task.title} — ${result.error}`);
|
sendChatMessage?.(`✗ ${task.id} · ${task.title} — ${result.error}`);
|
||||||
ctx.ui.notify(
|
ctx.ui.notify(
|
||||||
`Task ${task.id} failed after ${maxRetries} retries: ${
|
`Task ${task.id} failed after ${maxRetries} retries: ${
|
||||||
@@ -686,8 +738,15 @@ async function executeTask(
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
roundRobin?.release(task.id);
|
roundRobin?.release(task.id);
|
||||||
|
batchRender?.();
|
||||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
progress.markFailed(task.id, errorMsg);
|
progress.markFailed(task.id, errorMsg);
|
||||||
|
// Auto-update the PRD source file checkbox
|
||||||
|
try {
|
||||||
|
updateTaskInFile(project.sourcePath, task.id, "failed");
|
||||||
|
} catch {
|
||||||
|
// Best-effort
|
||||||
|
}
|
||||||
sendChatMessage?.(`✗ ${task.id} · ${task.title} — ${errorMsg}`);
|
sendChatMessage?.(`✗ ${task.id} · ${task.title} — ${errorMsg}`);
|
||||||
ctx.ui.notify(`Task ${task.id} failed: ${errorMsg}`, "error");
|
ctx.ui.notify(`Task ${task.id} failed: ${errorMsg}`, "error");
|
||||||
return;
|
return;
|
||||||
@@ -700,7 +759,9 @@ async function executeTask(
|
|||||||
|
|
||||||
// All models exhausted — release the slot
|
// All models exhausted — release the slot
|
||||||
roundRobin?.release(task.id);
|
roundRobin?.release(task.id);
|
||||||
|
batchRender?.();
|
||||||
progress.markFailed(task.id, "All configured models exhausted");
|
progress.markFailed(task.id, "All configured models exhausted");
|
||||||
|
// Don't update PRD — model exhaustion is transient, not terminal
|
||||||
sendChatMessage?.(
|
sendChatMessage?.(
|
||||||
`✗ ${task.id} · ${task.title} — all ${maxModelAttempts} models exhausted`,
|
`✗ ${task.id} · ${task.title} — all ${maxModelAttempts} models exhausted`,
|
||||||
);
|
);
|
||||||
|
|||||||
148
src/parser.ts
148
src/parser.ts
@@ -2,6 +2,20 @@ import * as fs from "node:fs";
|
|||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import type { Task, Project } from "./types";
|
import type { Task, Project } from "./types";
|
||||||
|
|
||||||
|
// Lazy-loaded yaml package
|
||||||
|
let YAML_module: typeof import("yaml") | undefined;
|
||||||
|
function loadYaml(): typeof import("yaml") {
|
||||||
|
if (YAML_module) return YAML_module;
|
||||||
|
try {
|
||||||
|
YAML_module = require("yaml");
|
||||||
|
} catch {
|
||||||
|
throw new Error(
|
||||||
|
"YAML parsing requires the 'yaml' package. Run: npm install yaml",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return YAML_module!;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Main Entry ──────────────────────────────────────────────────────────────
|
// ─── Main Entry ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -75,7 +89,7 @@ function parseFioFormat(
|
|||||||
const [, status, id, title, file] = match;
|
const [, status, id, title, file] = match;
|
||||||
const timeoutMs = parseTimeoutFromLine(line);
|
const timeoutMs = parseTimeoutFromLine(line);
|
||||||
tasks.push({
|
tasks.push({
|
||||||
id: `0${id}`,
|
id: id.padStart(2, "0"),
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
description: undefined,
|
description: undefined,
|
||||||
file: file || undefined,
|
file: file || undefined,
|
||||||
@@ -96,12 +110,12 @@ function parseFioFormat(
|
|||||||
);
|
);
|
||||||
if (arrowMatch) {
|
if (arrowMatch) {
|
||||||
const [, from, targets] = arrowMatch;
|
const [, from, targets] = arrowMatch;
|
||||||
const fromId = `0${from}`;
|
const fromId = from.padStart(2, "0");
|
||||||
const targetIds = targets
|
const targetIds = targets
|
||||||
.split(",")
|
.split(",")
|
||||||
.map((t) => t.trim())
|
.map((t) => t.trim())
|
||||||
.filter((t) => t)
|
.filter((t) => t)
|
||||||
.map((t) => `0${t}`);
|
.map((t) => t.padStart(2, "0"));
|
||||||
|
|
||||||
// Each target depends on the source
|
// Each target depends on the source
|
||||||
for (const toId of targetIds) {
|
for (const toId of targetIds) {
|
||||||
@@ -117,12 +131,12 @@ function parseFioFormat(
|
|||||||
);
|
);
|
||||||
if (dependsMatch) {
|
if (dependsMatch) {
|
||||||
const [, taskId, depsList] = dependsMatch;
|
const [, taskId, depsList] = dependsMatch;
|
||||||
const taskIdPadded = `0${taskId}`;
|
const taskIdPadded = taskId.padStart(2, "0");
|
||||||
const depIds = depsList
|
const depIds = depsList
|
||||||
.split(",")
|
.split(",")
|
||||||
.map((t) => t.trim())
|
.map((t) => t.trim())
|
||||||
.filter((t) => t)
|
.filter((t) => t)
|
||||||
.map((t) => `0${t}`);
|
.map((t) => t.padStart(2, "0"));
|
||||||
|
|
||||||
if (!dependencies[taskIdPadded]) dependencies[taskIdPadded] = [];
|
if (!dependencies[taskIdPadded]) dependencies[taskIdPadded] = [];
|
||||||
dependencies[taskIdPadded].push(...depIds);
|
dependencies[taskIdPadded].push(...depIds);
|
||||||
@@ -134,7 +148,7 @@ function parseFioFormat(
|
|||||||
);
|
);
|
||||||
if (metaMatch) {
|
if (metaMatch) {
|
||||||
const [, taskId, value, unit] = metaMatch;
|
const [, taskId, value, unit] = metaMatch;
|
||||||
const task = tasks.find((t) => t.id === `0${taskId}`);
|
const task = tasks.find((t) => t.id === taskId.padStart(2, "0"));
|
||||||
if (task) {
|
if (task) {
|
||||||
task.timeoutMs = parseTimeoutValue(Number(value), unit);
|
task.timeoutMs = parseTimeoutValue(Number(value), unit);
|
||||||
}
|
}
|
||||||
@@ -210,16 +224,7 @@ function parseYaml(
|
|||||||
sourcePath: string,
|
sourcePath: string,
|
||||||
sourceDir: string,
|
sourceDir: string,
|
||||||
): Project {
|
): Project {
|
||||||
// Lazy-load yaml (may not be installed)
|
const YAML = loadYaml();
|
||||||
let YAML: typeof import("yaml");
|
|
||||||
try {
|
|
||||||
YAML = require("yaml");
|
|
||||||
} catch {
|
|
||||||
throw new Error(
|
|
||||||
"YAML parsing requires the 'yaml' package. Run: npm install yaml",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const doc = YAML.parse(content);
|
const doc = YAML.parse(content);
|
||||||
const tasks: Task[] = [];
|
const tasks: Task[] = [];
|
||||||
|
|
||||||
@@ -263,35 +268,108 @@ export function readTaskSpec(taskDir: string, taskFile: string): string {
|
|||||||
// ─── Task File Updater ───────────────────────────────────────────────────────
|
// ─── Task File Updater ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update task status in the source markdown file
|
* Update task status in the source file (markdown or YAML).
|
||||||
|
*
|
||||||
|
* Handles three formats:
|
||||||
|
* 1. Fio numbered format: `- [ ] 01 – Title` — matches by task number in the file
|
||||||
|
* 2. Simple checkbox: `- [ ] Title` — matches by checkbox position (index)
|
||||||
|
* 3. YAML: uses `yaml` library to parse, update, and stringify
|
||||||
*/
|
*/
|
||||||
export function updateTaskInFile(
|
export function updateTaskInFile(
|
||||||
filePath: string,
|
filePath: string,
|
||||||
taskId: string,
|
taskId: string,
|
||||||
status: Task["status"],
|
status: Task["status"],
|
||||||
): void {
|
): void {
|
||||||
let content = fs.readFileSync(filePath, "utf-8");
|
const ext = path.extname(filePath).toLowerCase();
|
||||||
const char = statusToChar(status);
|
|
||||||
|
|
||||||
// Try Fio numbered format first
|
// Handle YAML format
|
||||||
const fioPattern = new RegExp(
|
if (ext === ".yaml" || ext === ".yml") {
|
||||||
`(^-\\s+\\[)(.)(\\]\\s+${escapeRegex(taskId)}\\s*[—–-])`,
|
updateTaskInYaml(filePath, taskId, status);
|
||||||
"m",
|
|
||||||
);
|
|
||||||
if (fioPattern.test(content)) {
|
|
||||||
content = content.replace(fioPattern, `$1${char}$3`);
|
|
||||||
fs.writeFileSync(filePath, content, "utf-8");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try simple checkbox format
|
let content = fs.readFileSync(filePath, "utf-8");
|
||||||
const simplePattern = new RegExp(
|
const char = statusToChar(status);
|
||||||
`(-\\s+\\[)(.)(\\]\\s+${escapeRegex(taskId)})`,
|
|
||||||
"m",
|
// Strategy 1: Fio numbered format — match by explicit task ID in the file
|
||||||
);
|
// Try both padded (01) and raw (1) variations
|
||||||
if (simplePattern.test(content)) {
|
const rawId = parseInt(taskId, 10).toString();
|
||||||
content = content.replace(simplePattern, `$1${char}$3`);
|
const idPatterns = new Set([escapeRegex(taskId), escapeRegex(rawId)]);
|
||||||
fs.writeFileSync(filePath, content, "utf-8");
|
|
||||||
|
for (const idPattern of idPatterns) {
|
||||||
|
const fioRegex = new RegExp(
|
||||||
|
`(^-\\s+\\[)(.)(\\]\\s+${idPattern}\\s*[—–:-])`,
|
||||||
|
"m",
|
||||||
|
);
|
||||||
|
const match = content.match(fioRegex);
|
||||||
|
if (match) {
|
||||||
|
content = content.replace(fioRegex, `$1${char}$3`);
|
||||||
|
fs.writeFileSync(filePath, content, "utf-8");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy 2: Simple checkbox by position (task IDs are zero-padded indices)
|
||||||
|
const targetIndex = parseInt(taskId, 10);
|
||||||
|
if (!isNaN(targetIndex)) {
|
||||||
|
const lines = content.split("\n");
|
||||||
|
let checkboxIdx = 0;
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const m = lines[i].match(/^(\s*-+\s+\[)(.)(\].*)$/);
|
||||||
|
if (m) {
|
||||||
|
if (checkboxIdx === targetIndex) {
|
||||||
|
lines[i] = m[1] + char + m[3];
|
||||||
|
fs.writeFileSync(filePath, lines.join("\n"), "utf-8");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
checkboxIdx++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update task status in a YAML task file using the yaml library's
|
||||||
|
* Document API, which preserves comments and formatting.
|
||||||
|
*
|
||||||
|
* Matches by explicit `id` field first, then falls back to
|
||||||
|
* position-based matching (for files without explicit IDs).
|
||||||
|
*/
|
||||||
|
function updateTaskInYaml(
|
||||||
|
filePath: string,
|
||||||
|
taskId: string,
|
||||||
|
status: Task["status"],
|
||||||
|
): void {
|
||||||
|
const YAML = loadYaml();
|
||||||
|
const content = fs.readFileSync(filePath, "utf-8");
|
||||||
|
const doc = YAML.parseDocument(content);
|
||||||
|
const tasks = doc.get("tasks");
|
||||||
|
if (!tasks || !YAML.isSeq(tasks)) return;
|
||||||
|
|
||||||
|
const rawId = parseInt(taskId, 10).toString();
|
||||||
|
|
||||||
|
// Strategy 1: Match by explicit id field
|
||||||
|
for (const item of tasks.items) {
|
||||||
|
if (!YAML.isMap(item)) continue;
|
||||||
|
const idVal = item.get("id");
|
||||||
|
if (idVal === undefined || idVal === null) continue;
|
||||||
|
const idStr = String(idVal);
|
||||||
|
if (idStr === taskId || idStr === rawId) {
|
||||||
|
item.set("status", status);
|
||||||
|
fs.writeFileSync(filePath, String(doc), "utf-8");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy 2: Fall back to position-based matching
|
||||||
|
// (for YAML files without explicit id fields)
|
||||||
|
const targetIndex = parseInt(taskId, 10);
|
||||||
|
if (!isNaN(targetIndex) && targetIndex < tasks.items.length) {
|
||||||
|
const item = tasks.items[targetIndex];
|
||||||
|
if (YAML.isMap(item)) {
|
||||||
|
item.set("status", status);
|
||||||
|
fs.writeFileSync(filePath, String(doc), "utf-8");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
72
src/utils.ts
72
src/utils.ts
@@ -34,6 +34,78 @@ export function writeFileSafe(filePath: string, content: string): void {
|
|||||||
fs.writeFileSync(filePath, content, "utf-8");
|
fs.writeFileSync(filePath, content, "utf-8");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Loop-Active State ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* State persisted to disk when a ralpi execution loop is active.
|
||||||
|
* Used to re-instantiate widgets after a session reload.
|
||||||
|
*/
|
||||||
|
export interface LoopActiveState {
|
||||||
|
taskFile: string;
|
||||||
|
mode: "parallel" | "sequential";
|
||||||
|
startedAt: string;
|
||||||
|
taskIds: string[];
|
||||||
|
prdKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Path (relative to projectDir) where the loop-active marker is stored.
|
||||||
|
*/
|
||||||
|
const LOOP_ACTIVE_FILE = ".ralpi/loop-active.json";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write the loop-active marker, indicating an execution loop is running.
|
||||||
|
*/
|
||||||
|
export function writeLoopActive(
|
||||||
|
projectDir: string,
|
||||||
|
state: LoopActiveState,
|
||||||
|
): void {
|
||||||
|
writeFileSafe(
|
||||||
|
path.join(projectDir, LOOP_ACTIVE_FILE),
|
||||||
|
JSON.stringify(state, null, 2),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the loop-active marker, if present.
|
||||||
|
*/
|
||||||
|
export function readLoopActive(projectDir: string): LoopActiveState | null {
|
||||||
|
const filePath = path.join(projectDir, LOOP_ACTIVE_FILE);
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(filePath, "utf-8");
|
||||||
|
return JSON.parse(raw) as LoopActiveState;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete the loop-active marker.
|
||||||
|
*/
|
||||||
|
export function deleteLoopActive(projectDir: string): void {
|
||||||
|
const filePath = path.join(projectDir, LOOP_ACTIVE_FILE);
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(filePath);
|
||||||
|
} catch {
|
||||||
|
// Ignore if already gone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discover the project directory by walking up to find `.ralpi/`.
|
||||||
|
*/
|
||||||
|
export function findRalpiDir(startDir: string): string | null {
|
||||||
|
let current = path.resolve(startDir);
|
||||||
|
const root = path.parse(current).root;
|
||||||
|
while (current !== root) {
|
||||||
|
if (fs.existsSync(path.join(current, ".ralpi"))) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
current = path.dirname(current);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Async Agent Session ────────────────────────────────────────────────────
|
// ─── Async Agent Session ────────────────────────────────────────────────────
|
||||||
|
|
||||||
// ─── Progress Discovery ─────────────────────────────────────────────────────
|
// ─── Progress Discovery ─────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user