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:
2026-05-31 11:44:47 -04:00
parent 30f177b4d9
commit 424e2fa885
5 changed files with 1360 additions and 703 deletions

363
index.ts
View File

@@ -9,17 +9,26 @@ import { parseTaskFile, updateTaskInFile } from "./src/parser";
import {
buildExecutionPlan,
buildSequentialPlan,
formatDependencyChain,
formatExecutionPlan,
} from "./src/dag";
import { ProgressTracker } from "./src/progress";
import { buildPlanPrompt } from "./src/prompts";
import { formatReflections } from "./src/reflection";
import { executeBatch, type SendChatMessage } from "./src/executor";
import {
executeBatch,
SPINNER_FRAMES,
type SendChatMessage,
} from "./src/executor";
import {
loadConfig,
resolveTaskArg,
formatProgressStatus,
findProgressFile,
writeLoopActive,
deleteLoopActive,
readLoopActive,
findRalpiDir,
} from "./src/utils";
const COMMANDS = ["plan", "resume", "reset"] as const;
@@ -123,9 +132,22 @@ async function executePlanBatches(
sendChatMessage?: SendChatMessage,
projectDir?: string,
): Promise<void> {
// Write loop-active marker so widgets can be re-instantiated after a reload
if (projectDir) {
const allTaskIds = plan.batches.flatMap((b) => b.tasks.map((t) => t.id));
writeLoopActive(projectDir, {
taskFile,
mode,
startedAt: new Date().toISOString(),
taskIds: allTaskIds,
prdKey: progress.getKey(),
});
}
// Track failed task IDs across batches to block downstream tasks
const failedTaskIds = new Set(progress.getFailedTaskIds());
try {
for (const batch of plan.batches) {
if (progress.getState().paused) {
ctx.ui.notify(
@@ -196,6 +218,11 @@ async function executePlanBatches(
}
}
}
} finally {
if (projectDir) {
deleteLoopActive(projectDir);
}
}
}
// ─── Extension Entry ────────────────────────────────────────────────────────
@@ -259,6 +286,325 @@ export default function ralpiLoopExtension(pi: ExtensionAPI): void {
},
);
// ─── Reload detection: re-instantiate widgets when session reloads ──────
//
// When the user types /reload while ralpi tasks are executing, the old
// ExtensionContext is torn down and widgets (created via ctx.ui.setWidget)
// disappear. This handler detects the reload, reads the persisted loop-active
// marker and progress.json, and re-creates live-status widgets that show
// task progress with spinner animation and tool calls from session files.
pi.on("session_start", async (event, ctx) => {
if (event.reason !== "reload") return;
// Find the ralpi project directory
const projectDir = findRalpiDir(ctx.cwd);
if (!projectDir) return;
// Check if a task execution loop was active before the reload
const loopState = readLoopActive(projectDir);
if (!loopState) return;
// Load progress state
let abortPolling = false;
const progressPath = path.join(projectDir, ".ralpi", "progress.json");
const sessionsDir = path.join(projectDir, ".ralpi", "sessions");
// Parse the task file to get task titles
const titleMap = new Map<string, string>();
try {
const project = parseTaskFile(loopState.taskFile);
for (const task of project.tasks) {
titleMap.set(task.id, task.title);
}
} catch {
// If parsing fails, just use IDs without titles
}
/** Read recent tool calls from a task's session file. */
const readRecentToolCalls = (
taskId: string,
maxLines = 30,
): Array<{ name: string; label: string }> => {
try {
const files = fs
.readdirSync(sessionsDir)
.filter((f) => f.startsWith(taskId + "-"))
.sort();
if (files.length === 0) return [];
const sessionPath = path.join(sessionsDir, files[files.length - 1]);
const content = fs.readFileSync(sessionPath, "utf-8");
const lines = content
.split("\n")
.filter((l) => l.trim())
.slice(-maxLines);
const calls: Array<{ name: string; label: string }> = [];
for (const line of lines) {
try {
const event = JSON.parse(line);
if (event.type === "tool_execution_start") {
calls.push({
name: event.toolName,
label: formatToolLabel(event.toolName, event.args),
});
}
} catch {
// Skip malformed lines
}
}
return calls;
} catch {
return [];
}
};
/** Format a tool call argument into a short label. */
function formatToolLabel(name: string, args: unknown): string {
const a = args as Record<string, unknown> | undefined;
if (!a) return name;
if (name === "bash") return String(a.command ?? "").slice(0, 70);
if (name === "write" || name === "read" || name === "edit")
return String(a.path ?? "").slice(0, 60);
if (name === "grep")
return `${a.pattern ?? "?"}${String(a.path ?? "").slice(0, 40)}`;
if (name === "find") return `${a.path ?? "."}${a.glob ?? "*"}`;
if (name === "ls") return String(a.path ?? ".").slice(0, 60);
return name;
}
/** Re-read progress from disk (old tasks still writing to it). */
const readTasks = (): Record<string, { status: string }> | null => {
try {
const raw = fs.readFileSync(progressPath, "utf-8");
const parsed = JSON.parse(raw) as Record<string, any>;
return parsed.prds?.[loopState.prdKey]?.tasks ?? parsed.tasks ?? null;
} catch {
return null;
}
};
// Early exit: if all tasks already finished during the reload, just clean up
const initialTasks = readTasks();
if (initialTasks) {
const remaining = Object.values(initialTasks).filter(
(t) => t.status === "in_progress",
).length;
if (remaining === 0) {
ctx.ui.notify("All ralpi tasks completed during reload.", "info");
deleteLoopActive(projectDir);
return;
}
}
// Show a status notification for the reconnect
const taskCount = loopState.taskIds.length;
ctx.ui.notify(
`Reconnected to running ralpi execution (${taskCount} tasks, ${loopState.mode} mode)`,
"info",
);
// Shared state for the widget
let tickCount = 0;
const MAX_COLLAPSED = 3;
if (loopState.mode === "parallel") {
// ── Parallel mode: single batch widget ──
const widgetKey = `ralpi-parallel-reconnect-${Date.now()}`;
let widgetTui: { requestRender(): void } | null = null;
const buildBatchLines = (t: typeof ctx.ui.theme): string[] => {
const tasks = readTasks();
if (!tasks) return [t.fg("dim", "(waiting for progress...)")];
const lines: string[] = [];
// Only show tasks that have started (in_progress, completed, failed).
// Pending/unstarted tasks are noise after a reload.
const sortedIds = [...loopState.taskIds].sort().filter((id) => {
const info = tasks[id];
return info && info.status !== "pending";
});
// If no tasks have started yet, show nothing — polling will pick up
// changes within 500ms.
if (sortedIds.length === 0) return [t.fg("dim", "(starting tasks...)")];
for (const id of sortedIds) {
const info = tasks[id]!;
const title = titleMap.get(id);
const header = title ? `${id} · ${title}` : id;
// Status icon
if (info.status === "completed") {
lines.push(`${t.fg("success", "✓")} ${header}`);
} else if (info.status === "failed") {
lines.push(`${t.fg("error", "✗")} ${header}`);
} else if (info.status === "in_progress") {
const frame = t.fg(
"accent",
SPINNER_FRAMES[tickCount % SPINNER_FRAMES.length],
);
lines.push(`${frame} ${header}`);
// Show recent tool calls for active tasks
const toolCalls = readRecentToolCalls(id);
if (toolCalls.length > 0) {
if (toolCalls.length <= MAX_COLLAPSED) {
for (let i = 0; i < toolCalls.length; i++) {
const tc = toolCalls[i];
const isLast = i === toolCalls.length - 1;
const branch = isLast ? " └── " : " ├── ";
lines.push(
`${branch}${t.fg("accent", `[${tc.name}]`)} ${tc.label}`,
);
}
} else {
const shown = toolCalls.slice(-MAX_COLLAPSED);
const remaining = toolCalls.length - shown.length;
lines.push(t.fg("dim", ` ├── …${remaining} earlier`));
for (let i = 0; i < shown.length; i++) {
const tc = shown[i];
const isLast = i === shown.length - 1;
const branch = isLast ? " └── " : " ├── ";
lines.push(
`${branch}${t.fg("accent", `[${tc.name}]`)} ${tc.label}`,
);
}
}
}
}
}
return lines;
};
ctx.ui.setWidget(widgetKey, (tui, t) => {
widgetTui = tui;
return {
render: () => buildBatchLines(t),
invalidate: () => widgetTui?.requestRender(),
};
});
// 100ms tick: advances spinner frame every tick, refreshes
// progress + tool calls every 5 ticks (500ms).
const tickTimer = setInterval(() => {
if (abortPolling) return;
tickCount++;
widgetTui?.requestRender();
if (tickCount % 5 === 0) {
const tasks = readTasks();
if (!tasks) return;
const activeCount = Object.values(tasks).filter(
(t) => t.status === "in_progress",
).length;
if (activeCount === 0) {
clearInterval(tickTimer);
ctx.ui.setWidget(widgetKey, undefined);
deleteLoopActive(projectDir);
}
}
}, 100);
// Clean up timer when extension is shut down
pi.on("session_shutdown", () => {
abortPolling = true;
clearInterval(tickTimer);
});
} else {
// ── Sequential mode: per-task widget ──
const currentTaskId = loopState.taskIds.find((id) => {
const tasks = readTasks();
return tasks?.[id]?.status === "in_progress";
});
if (currentTaskId) {
const widgetKey = `ralpi-task-${currentTaskId}`;
let widgetTui: { requestRender(): void } | null = null;
const buildLines = (t: typeof ctx.ui.theme): string[] => {
const tasks = readTasks();
const info = tasks?.[currentTaskId];
const title = titleMap.get(currentTaskId);
const header = title ? `${currentTaskId} · ${title}` : currentTaskId;
const lines: string[] = [];
if (!info || info.status === "pending") {
return [t.fg("dim", "(starting task...)")];
}
if (info.status === "completed") {
lines.push(`${t.fg("success", "✓")} ${header}`);
} else if (info.status === "failed") {
lines.push(`${t.fg("error", "✗")} ${header}`);
} else if (info.status === "in_progress") {
const frame = t.fg(
"accent",
SPINNER_FRAMES[tickCount % SPINNER_FRAMES.length],
);
lines.push(`${frame} ${header}`);
// Show recent tool calls
const toolCalls = readRecentToolCalls(currentTaskId);
if (toolCalls.length > 0) {
const shown = toolCalls.slice(-MAX_COLLAPSED);
const remaining = toolCalls.length - shown.length;
if (remaining > 0) {
lines.push(t.fg("dim", ` ├── …${remaining} earlier`));
}
for (let i = 0; i < shown.length; i++) {
const tc = shown[i];
const isLast = i === shown.length - 1;
const branch = isLast ? " └── " : " ├── ";
lines.push(
`${branch}${t.fg("accent", `[${tc.name}]`)} ${tc.label}`,
);
}
}
}
return lines;
};
ctx.ui.setWidget(widgetKey, (tui, t) => {
widgetTui = tui;
return {
render: () => buildLines(t),
invalidate: () => widgetTui?.requestRender(),
};
});
const tickTimer = setInterval(() => {
if (abortPolling) return;
tickCount++;
widgetTui?.requestRender();
if (tickCount % 5 === 0) {
const tasks = readTasks();
if (!tasks) return;
const status = tasks[currentTaskId]?.status;
if (status !== "in_progress") {
clearInterval(tickTimer);
// Keep widget visible a moment, then clean up
setTimeout(() => {
ctx.ui.setWidget(widgetKey, undefined);
deleteLoopActive(projectDir);
}, 3000);
}
}
}, 100);
pi.on("session_shutdown", () => {
abortPolling = true;
clearInterval(tickTimer);
});
} else {
// No task actively in progress — show a "resume" hint
ctx.ui.notify(
"No running task found. Use /ralpi resume to continue execution.",
"warning",
);
}
}
});
pi.registerCommand("ralpi", {
description:
"Execute tasks from a task file using DAG-based dependency resolution",
@@ -423,9 +769,20 @@ async function handleRun(
const mode = await selectExecutionMode(ctx, project, taskFile, config);
const plan = buildPlanByMode(mode, project, completed);
// Show execution plan before starting so user can see batch breakdown
// Show dependency chain + execution plan before starting
const depChain = formatDependencyChain(project);
const formattedPlan = formatExecutionPlan(plan);
ctx.ui.notify(`${formattedPlan}\n\nStarting ${mode} execution...`, "info");
if (mode === "parallel") {
ctx.ui.notify(
`${depChain}\n\n${formattedPlan}\n\nStarting parallel execution...`,
"info",
);
} else {
ctx.ui.notify(
`${formattedPlan}\n\nStarting sequential execution...`,
"info",
);
}
await executePlanBatches(
plan,

View File

@@ -309,6 +309,95 @@ export function getCriticalPath(project: Project): Task[] {
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 ───────────────────────────────────────────────────
/**

View File

@@ -13,6 +13,7 @@ import {
captureGitCommits,
formatDuration,
} from "./utils";
import { updateTaskInFile } from "./parser";
/** Optional callback to post a progress message into the chat history. */
export type SendChatMessage = (
@@ -33,7 +34,18 @@ export interface ToolCallEntry {
* messages rendered by registerMessageRenderer). */
const MAX_COLLAPSED = 3;
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
export const SPINNER_FRAMES = [
"⠋",
"⠙",
"⠹",
"⠸",
"⠼",
"⠴",
"⠦",
"⠧",
"⠇",
"⠏",
];
// ─── Model Round-Robin ─────────────────────────────────────────────────────
@@ -135,6 +147,7 @@ export async function runTask(
projectDir: string = project.sourceDir,
parallelState?: ParallelWidgetState,
assignedModel?: unknown,
batchRender?: () => void,
): Promise<{
success: boolean;
reflection?: Reflection;
@@ -271,9 +284,11 @@ export async function runTask(
if (entry) {
entry.toolCalls.push({ name: event.toolName, label });
}
}
batchRender?.();
} else {
requestRender();
}
}
},
undefined, // no abort signal
sessionFilePath, // stream events to file
@@ -291,6 +306,7 @@ export async function runTask(
entry.done = true;
entry.success = output.success;
}
batchRender?.();
} else {
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 =
options?.parallel && tasks.length > 1 && config.execution.maxParallel > 0;
options?.parallel && tasks.length > 0 && config.execution.maxParallel > 0;
if (shouldParallel) {
await executeBatchParallel(
@@ -429,6 +448,12 @@ export async function executeBatch(
const errorMsg = error instanceof Error ? error.message : String(error);
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}`);
ctx.ui.notify(`Task ${task.id} failed: ${errorMsg}`, "error");
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(() => {
for (const entry of sharedState.values()) {
if (!entry.done) {
entry.frameIndex = (entry.frameIndex + 1) % SPINNER_FRAMES.length;
}
}
widgetTui?.requestRender();
requestBatchRender();
}, 100);
const results: Array<{ task: Task; result: Promise<any> }> = [];
@@ -545,13 +574,21 @@ async function executeBatchParallel(
sharedState,
assignedModel,
roundRobin,
requestBatchRender,
).catch((error) => {
// Safety net: one task failure should never crash the batch.
// executeTask already marks failed and notifies, but catch as
// a last resort so the error doesn't propagate and crash pi.
roundRobin?.release(task.id);
requestBatchRender();
const errorMsg = error instanceof Error ? error.message : String(error);
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}`);
ctx.ui.notify(`Task ${task.id} failed: ${errorMsg}`, "error");
}),
@@ -586,6 +623,7 @@ async function executeTask(
parallelState?: ParallelWidgetState,
assignedModel?: unknown,
roundRobin?: ModelRoundRobin | null,
batchRender?: () => void,
): Promise<void> {
const maxRetries = config.execution.maxRetries;
@@ -609,6 +647,12 @@ async function executeTask(
try {
// Mark as in progress
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
const depReflections = progress.getDependencyReflections(
@@ -626,6 +670,7 @@ async function executeTask(
projectDir,
parallelState,
currentModel,
batchRender,
);
if (result.success) {
@@ -645,6 +690,12 @@ async function executeTask(
result.commitMessages,
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);
return;
}
@@ -675,6 +726,7 @@ async function executeTask(
} else {
// Max retries exceeded
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}`);
ctx.ui.notify(
`Task ${task.id} failed after ${maxRetries} retries: ${
@@ -686,8 +738,15 @@ async function executeTask(
}
} catch (error) {
roundRobin?.release(task.id);
batchRender?.();
const errorMsg = error instanceof Error ? error.message : String(error);
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}`);
ctx.ui.notify(`Task ${task.id} failed: ${errorMsg}`, "error");
return;
@@ -700,7 +759,9 @@ async function executeTask(
// All models exhausted — release the slot
roundRobin?.release(task.id);
batchRender?.();
progress.markFailed(task.id, "All configured models exhausted");
// Don't update PRD — model exhaustion is transient, not terminal
sendChatMessage?.(
`${task.id} · ${task.title} — all ${maxModelAttempts} models exhausted`,
);

View File

@@ -2,6 +2,20 @@ import * as fs from "node:fs";
import * as path from "node:path";
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 ──────────────────────────────────────────────────────────────
/**
@@ -75,7 +89,7 @@ function parseFioFormat(
const [, status, id, title, file] = match;
const timeoutMs = parseTimeoutFromLine(line);
tasks.push({
id: `0${id}`,
id: id.padStart(2, "0"),
title: title.trim(),
description: undefined,
file: file || undefined,
@@ -96,12 +110,12 @@ function parseFioFormat(
);
if (arrowMatch) {
const [, from, targets] = arrowMatch;
const fromId = `0${from}`;
const fromId = from.padStart(2, "0");
const targetIds = targets
.split(",")
.map((t) => t.trim())
.filter((t) => t)
.map((t) => `0${t}`);
.map((t) => t.padStart(2, "0"));
// Each target depends on the source
for (const toId of targetIds) {
@@ -117,12 +131,12 @@ function parseFioFormat(
);
if (dependsMatch) {
const [, taskId, depsList] = dependsMatch;
const taskIdPadded = `0${taskId}`;
const taskIdPadded = taskId.padStart(2, "0");
const depIds = depsList
.split(",")
.map((t) => t.trim())
.filter((t) => t)
.map((t) => `0${t}`);
.map((t) => t.padStart(2, "0"));
if (!dependencies[taskIdPadded]) dependencies[taskIdPadded] = [];
dependencies[taskIdPadded].push(...depIds);
@@ -134,7 +148,7 @@ function parseFioFormat(
);
if (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) {
task.timeoutMs = parseTimeoutValue(Number(value), unit);
}
@@ -210,16 +224,7 @@ function parseYaml(
sourcePath: string,
sourceDir: string,
): Project {
// Lazy-load yaml (may not be installed)
let YAML: typeof import("yaml");
try {
YAML = require("yaml");
} catch {
throw new Error(
"YAML parsing requires the 'yaml' package. Run: npm install yaml",
);
}
const YAML = loadYaml();
const doc = YAML.parse(content);
const tasks: Task[] = [];
@@ -263,35 +268,108 @@ export function readTaskSpec(taskDir: string, taskFile: string): string {
// ─── 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(
filePath: string,
taskId: string,
status: Task["status"],
): void {
let content = fs.readFileSync(filePath, "utf-8");
const char = statusToChar(status);
const ext = path.extname(filePath).toLowerCase();
// Try Fio numbered format first
const fioPattern = new RegExp(
`(^-\\s+\\[)(.)(\\]\\s+${escapeRegex(taskId)}\\s*[—–-])`,
"m",
);
if (fioPattern.test(content)) {
content = content.replace(fioPattern, `$1${char}$3`);
fs.writeFileSync(filePath, content, "utf-8");
// Handle YAML format
if (ext === ".yaml" || ext === ".yml") {
updateTaskInYaml(filePath, taskId, status);
return;
}
// Try simple checkbox format
const simplePattern = new RegExp(
`(-\\s+\\[)(.)(\\]\\s+${escapeRegex(taskId)})`,
let content = fs.readFileSync(filePath, "utf-8");
const char = statusToChar(status);
// Strategy 1: Fio numbered format — match by explicit task ID in the file
// Try both padded (01) and raw (1) variations
const rawId = parseInt(taskId, 10).toString();
const idPatterns = new Set([escapeRegex(taskId), escapeRegex(rawId)]);
for (const idPattern of idPatterns) {
const fioRegex = new RegExp(
`(^-\\s+\\[)(.)(\\]\\s+${idPattern}\\s*[—–:-])`,
"m",
);
if (simplePattern.test(content)) {
content = content.replace(simplePattern, `$1${char}$3`);
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");
}
}
}

View File

@@ -34,6 +34,78 @@ export function writeFileSafe(filePath: string, content: string): void {
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 ────────────────────────────────────────────────────
// ─── Progress Discovery ─────────────────────────────────────────────────────