fix width exceeding, release prep

This commit is contained in:
2026-05-31 08:18:46 -04:00
parent ab1e2eb430
commit 3c01652b90
9 changed files with 518 additions and 301 deletions

1
.gitignore vendored
View File

@@ -1,4 +1,3 @@
node_modules
dist
.pi-lens
package-lock.json

View File

@@ -4,18 +4,17 @@
A Pi coding agent extension that registers the `/ralpi` slash command. Not a standalone app — it runs inside Pi's extension host.
## Build
## Type checking
```
npm run build # tsc → dist/
npm run watch # tsc --watch
npm run typecheck # tsc --noEmit
```
No bundler, no linter, no test framework. Plain `tsc` with strict mode.
No build step needed — Pi loads extensions via [jiti](https://github.com/unjs/jiti), which compiles TypeScript at runtime. `index.ts` is the entry point directly.
## Entry point
`index.ts` at repo root (not `src/`). Exports a default function receiving `ExtensionAPI`. The `tsconfig.json` sets `rootDir: "./"` so `index.ts` compiles to `dist/index.js`.
`index.ts` at repo root (not `src/`). Exports a default function receiving `ExtensionAPI`.
## External dependencies

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Michael Freno
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -11,6 +11,7 @@ import {
buildSequentialPlan,
formatExecutionPlan,
getReadyTasks,
getBlockedTasks,
} from "./src/dag";
import { ProgressTracker } from "./src/progress";
import { buildPlanPrompt } from "./src/prompts";
@@ -69,7 +70,7 @@ async function selectExecutionMode(
config: import("./src/types").RalpiConfig,
): Promise<ExecutionMode> {
const mode = await ctx.ui.select("Execution mode for this run?", [
`Parallel (where dependencies allow)-[${config.execution.maxParallel} max]`,
`Parallel (where dependencies allow)[${config.execution.maxParallel} max]`,
"Sequential (one at a time)",
]);
const isParallel = mode?.startsWith("Parallel") ?? false;
@@ -125,6 +126,9 @@ async function executePlanBatches(
sendChatMessage?: SendChatMessage,
projectDir?: string,
): Promise<void> {
// Track failed task IDs across batches to block downstream tasks
const failedTaskIds = new Set(progress.getFailedTaskIds());
for (const batch of plan.batches) {
if (progress.getState().paused) {
ctx.ui.notify(
@@ -157,6 +161,43 @@ async function executePlanBatches(
const status = progress.getTaskStatus(task.id);
updateTaskInFile(taskFile, task.id, status);
}
// Update failed task IDs after batch completes
const newFailed = progress.getFailedTaskIds();
for (const id of newFailed) {
failedTaskIds.add(id);
}
// In sequential mode, stop after any failure
if (mode === "sequential" && failedTaskIds.size > 0) {
break;
}
// In parallel mode, rebuild the plan to filter out newly blocked tasks
if (mode === "parallel") {
const completed = new Set(progress.getCompletedTaskIds());
const newPlan = buildExecutionPlan(
project,
completed,
undefined,
failedTaskIds,
);
// Replace remaining batches with filtered ones
const currentIdx = plan.batches.indexOf(batch);
const remainingBatches = newPlan.batches.filter(
(b) => b.batchIndex > currentIdx,
);
// Update the plan's batches in-place
plan.batches.length = 0;
plan.batches.push(...remainingBatches);
// Skip empty batches
if (remainingBatches.length === 0) {
break;
}
}
}
}

View File

@@ -2,7 +2,6 @@
"name": "ralpi",
"version": "0.1.0",
"description": "Execute tasks from task files/PRD's using DAG-based dependency resolution with persistent progress tracking",
"main": "dist/index.js",
"keywords": [
"pi-package",
"pi-extension",
@@ -12,22 +11,40 @@
"ralpi-loop",
"prd"
],
"author": "",
"author": "Michael Freno",
"license": "MIT",
"homepage": "https://github.com/mikefreno/ralpi",
"repository": {
"type": "git",
"url": "git+https://github.com/mikefreno/ralpi.git"
},
"bugs": {
"url": "https://github.com/mikefreno/ralpi/issues"
},
"files": [
"dist/",
"index.ts",
"src/",
"skills/",
"prompts/",
"index.ts"
"README.md",
"LICENSE"
],
"scripts": {
"build": "tsc",
"watch": "tsc --watch",
"prepublishOnly": "npm run build"
"typecheck": "tsc --noEmit",
"prepublishOnly": "tsc --noEmit"
},
"engines": {
"bun": ">=1.1.0"
},
"pi": {
"extensions": [
"./dist/index.js"
"./index.ts"
],
"skills": [
"./skills"
],
"prompts": [
"./prompts"
]
},
"dependencies": {
@@ -37,6 +54,17 @@
"@earendil-works/pi-coding-agent": "*",
"@earendil-works/pi-tui": "*"
},
"peerDependenciesMeta": {
"@earendil-works/pi-coding-agent": {
"optional": true
},
"@earendil-works/pi-tui": {
"optional": true
}
},
"publishConfig": {
"access": "public"
},
"devDependencies": {
"@types/node": "^20.0.0",
"typescript": "^5.3.0"

View File

@@ -1,5 +1,33 @@
import type { Task, ExecutionBatch, ExecutionPlan, Project } from "./types";
// ─── Blocked Tasks ───────────────────────────────────────────────────────────
/**
* Find tasks that are blocked (direct or transitive) due to failed dependencies.
* Returns a Set of blocked task IDs.
*/
export function getBlockedTasks(
pendingTasks: Task[],
failedTaskIds: Set<string>,
): 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;
}
}
}
return blocked;
}
// ─── Main Entry ──────────────────────────────────────────────────────────────
/**
@@ -10,16 +38,15 @@ export function buildExecutionPlan(
project: Project,
completed: Set<string>,
parallelGroup?: number,
failedTaskIds: Set<string> = new Set(),
): ExecutionPlan {
const allTasks = new Map(project.tasks.map((t) => [t.id, t]));
// Filter out already completed tasks
const pendingTasks = 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, allTasks, completed),
batches: buildParallelGroupBatches(pendingTasks, failedTaskIds),
totalTasks: pendingTasks.length,
skippedTasks: project.tasks.filter((t) => completed.has(t.id)),
};
@@ -27,7 +54,7 @@ export function buildExecutionPlan(
// Use dependency-based Kahn's algorithm
return {
batches: buildBatches(pendingTasks, allTasks, completed),
batches: buildBatches(pendingTasks, failedTaskIds),
totalTasks: pendingTasks.length,
skippedTasks: project.tasks.filter((t) => completed.has(t.id)),
};
@@ -41,9 +68,18 @@ export function buildExecutionPlan(
export function buildSequentialPlan(
project: Project,
completed: Set<string>,
failedTaskIds: Set<string> = new Set(),
): ExecutionPlan {
const pendingTasks = project.tasks.filter((t) => !completed.has(t.id));
const batches: ExecutionBatch[] = pendingTasks.map((task, i) => ({
// 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,
}));
@@ -51,7 +87,7 @@ export function buildSequentialPlan(
return {
batches,
totalTasks: pendingTasks.length,
skippedTasks: project.tasks.filter((t) => completed.has(t.id)),
skippedTasks,
};
}
@@ -59,12 +95,15 @@ export function buildSequentialPlan(
function buildBatches(
pendingTasks: Task[],
allTasks: Map<string, Task>,
completed: Set<string>,
failedTaskIds: Set<string>,
): ExecutionBatch[] {
const batches: ExecutionBatch[] = [];
const done = new Set(completed);
const remaining = new Set(pendingTasks.map((t) => t.id));
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
@@ -74,7 +113,7 @@ function buildBatches(
const deps = task.dependencies || [];
const depsSatisfied = deps.every(
(dep) => done.has(dep) || !allTasks.has(dep),
(dep) => done.has(dep) || !pendingSet.has(dep),
);
if (depsSatisfied) {
@@ -108,12 +147,14 @@ function buildBatches(
*/
function buildParallelGroupBatches(
pendingTasks: Task[],
allTasks: Map<string, Task>,
completed: Set<string>,
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 pendingTasks) {
for (const task of activeTasks) {
const group = task.parallelGroup ?? 0;
if (!groups.has(group)) groups.set(group, []);
groups.get(group)!.push(task);
@@ -121,7 +162,7 @@ function buildParallelGroupBatches(
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,
batchIndex: i,
}));

View File

@@ -1,3 +1,4 @@
import { truncateToWidth } from "@earendil-works/pi-tui";
import * as path from "node:path";
import type { Task, Project, Reflection, ToolUsage } from "./types";
import type { RalpiConfig } from "./types";
@@ -83,6 +84,27 @@ class ModelRoundRobin {
this.assignments.delete(taskId);
}
}
/**
* Advance a task to the next model slot without going through freed slots.
* Used for model failover — when the current model is down, skip to the
* next one instead of re-assigning the same freed index.
*/
advance(taskId: string): unknown {
const currentIndex = this.assignments.get(taskId);
if (currentIndex === undefined) {
// No current assignment — fresh assign (fallback, shouldn't happen)
return this.assign(taskId);
}
// If this index was freed (e.g. from an earlier release call that raced),
// remove it from freeSlots so it's not handed out to another task.
const freeIdx = this.freeSlots.indexOf(currentIndex);
if (freeIdx !== -1) this.freeSlots.splice(freeIdx, 1);
// Advance to the next index (circular)
const nextIndex = (currentIndex + 1) % this.models.length;
this.assignments.set(taskId, nextIndex);
return this.models[nextIndex];
}
}
/** Shared state for parallel-batch widget. Each running task writes its
@@ -162,9 +184,13 @@ export async function runTask(
} else {
// Build widget lines from current state. Live widgets can't expand/collapse
// like chat messages, so we always truncate to MAX_COLLAPSED recent calls.
const buildLines = (t: typeof ctx.ui.theme): string[] => {
const truncateWidth = 74; // Account for widget container padding
const buildLines = (t: typeof ctx.ui.theme, width?: number): string[] => {
const effectiveWidth = width
? Math.min(width, truncateWidth)
: truncateWidth;
const frame = t.fg("accent", SPINNER_FRAMES[frameIndex]);
const lines = [`${frame} ${taskHeader}`];
const lines = [truncateToWidth(`${frame} ${taskHeader}`, effectiveWidth)];
if (toolCalls.length > 0) {
if (toolCalls.length <= MAX_COLLAPSED) {
@@ -173,18 +199,27 @@ export async function runTask(
const isLast = i === toolCalls.length - 1;
const branch = isLast ? " └── " : " ├── ";
const tag = t.fg("accent", `[${entry.name}]`);
lines.push(`${branch}${tag} ${entry.label}`);
lines.push(
truncateToWidth(`${branch}${tag} ${entry.label}`, effectiveWidth),
);
}
} else {
const shown = toolCalls.slice(-MAX_COLLAPSED);
const remaining = toolCalls.length - shown.length;
lines.push(t.fg("dim", ` ├── …${remaining} earlier`));
lines.push(
truncateToWidth(
t.fg("dim", ` ├── …${remaining} earlier`),
effectiveWidth,
),
);
for (let i = 0; i < shown.length; i++) {
const entry = shown[i];
const isLast = i === shown.length - 1;
const branch = isLast ? " └── " : " ├── ";
const tag = t.fg("accent", `[${entry.name}]`);
lines.push(`${branch}${tag} ${entry.label}`);
lines.push(
truncateToWidth(`${branch}${tag} ${entry.label}`, effectiveWidth),
);
}
}
}
@@ -194,7 +229,7 @@ export async function runTask(
ctx.ui.setWidget(widgetKey, (tui, t) => {
widgetTui = tui;
return {
render: () => buildLines(t),
render: (width?: number) => buildLines(t, width),
invalidate: () => widgetTui?.requestRender(),
};
});
@@ -378,19 +413,29 @@ export async function executeBatch(
// Execute sequentially
for (const task of tasks) {
const model = roundRobin?.assign(task.id);
await executeTask(
task,
project,
config,
progress,
ctx,
sendChatMessage,
projectDir,
undefined,
model,
roundRobin,
);
try {
const model = roundRobin?.assign(task.id);
await executeTask(
task,
project,
config,
progress,
ctx,
sendChatMessage,
projectDir,
undefined,
model,
roundRobin,
);
} catch (error) {
// Task failed — stop the batch. Dependent tasks are blocked by
// the DAG layer (getBlockedTasks) so they won't appear in this batch.
roundRobin?.release(task.id);
const errorMsg = error instanceof Error ? error.message : String(error);
progress.markFailed(task.id, errorMsg);
ctx.ui.notify(`Task ${task.id} failed: ${errorMsg}`, "error");
break;
}
}
}
@@ -414,7 +459,11 @@ async function executeBatchParallel(
const widgetKey = `ralpi-parallel-${Date.now()}`;
let widgetTui: { requestRender(): void } | null = null;
const buildBatchLines = (t: typeof ctx.ui.theme): string[] => {
const buildBatchLines = (
t: typeof ctx.ui.theme,
width?: number,
): string[] => {
const effectiveWidth = width || 74;
const lines: string[] = [];
const sortedIds = Array.from(sharedState.keys()).sort();
@@ -425,7 +474,9 @@ async function executeBatchParallel(
? "✓"
: "✗"
: t.fg("accent", SPINNER_FRAMES[entry.frameIndex]);
lines.push(`${frame} ${entry.taskHeader}`);
lines.push(
truncateToWidth(`${frame} ${entry.taskHeader}`, effectiveWidth),
);
if (entry.toolCalls.length > 0) {
if (entry.toolCalls.length <= MAX_COLLAPSED) {
@@ -434,18 +485,27 @@ async function executeBatchParallel(
const isLast = i === entry.toolCalls.length - 1;
const branch = isLast ? " └── " : " ├── ";
const tag = t.fg("accent", `[${tc.name}]`);
lines.push(`${branch}${tag} ${tc.label}`);
lines.push(
truncateToWidth(`${branch}${tag} ${tc.label}`, effectiveWidth),
);
}
} else {
const shown = entry.toolCalls.slice(-MAX_COLLAPSED);
const remaining = entry.toolCalls.length - shown.length;
lines.push(t.fg("dim", ` ├── …${remaining} earlier`));
lines.push(
truncateToWidth(
t.fg("dim", ` ├── …${remaining} earlier`),
effectiveWidth,
),
);
for (let i = 0; i < shown.length; i++) {
const tc = shown[i];
const isLast = i === shown.length - 1;
const branch = isLast ? " └── " : " ├── ";
const tag = t.fg("accent", `[${tc.name}]`);
lines.push(`${branch}${tag} ${tc.label}`);
lines.push(
truncateToWidth(`${branch}${tag} ${tc.label}`, effectiveWidth),
);
}
}
}
@@ -456,7 +516,7 @@ async function executeBatchParallel(
ctx.ui.setWidget(widgetKey, (tui, t) => {
widgetTui = tui;
return {
render: () => buildBatchLines(t),
render: (width?: number) => buildBatchLines(t, width),
invalidate: () => widgetTui?.requestRender(),
};
});
@@ -488,7 +548,15 @@ async function executeBatchParallel(
sharedState,
assignedModel,
roundRobin,
),
).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);
const errorMsg = error instanceof Error ? error.message : String(error);
progress.markFailed(task.id, errorMsg);
ctx.ui.notify(`Task ${task.id} failed: ${errorMsg}`, "error");
}),
});
// Limit concurrency
@@ -531,9 +599,11 @@ async function executeTask(
let currentModel: unknown = assignedModel ?? config.model;
while (modelAttempt < maxModelAttempts) {
// Get the next model from round-robin (on first try, use the pre-assigned model)
// On subsequent model attempts, advance to the next model.
// Uses advance() instead of assign() so we don't get stuck on
// the same freed slot when the current model is down.
if (modelAttempt > 0 && roundRobin) {
currentModel = roundRobin.assign(task.id);
currentModel = roundRobin.advance(task.id);
}
let retries = 0;
@@ -584,7 +654,9 @@ async function executeTask(
// Agent session failed (provider error).
// If we have more models, cycle immediately — don't waste retries.
if (roundRobin && modelAttempt < maxModelAttempts - 1) {
roundRobin.release(task.id);
// Don't release — advance() already handles the transition.
// release() would put the slot in freeSlots, then assign()
// would pick it right back up, getting stuck on the same model.
modelAttempt++;
ctx.ui.notify(
`Task ${task.id}: model failed, trying next (${modelAttempt + 1}/${maxModelAttempts}): ${result.error}`,
@@ -607,13 +679,20 @@ async function executeTask(
} else {
// Max retries exceeded
progress.markFailed(task.id, result.error || "Unknown error");
throw new Error(`Task ${task.id} failed: ${result.error}`);
ctx.ui.notify(
`Task ${task.id} failed after ${maxRetries} retries: ${
result.error || "Unknown error"
}`,
"error",
);
return;
}
} catch (error) {
roundRobin?.release(task.id);
const errorMsg = error instanceof Error ? error.message : String(error);
progress.markFailed(task.id, errorMsg);
throw error;
ctx.ui.notify(`Task ${task.id} failed: ${errorMsg}`, "error");
return;
}
}
@@ -621,9 +700,13 @@ async function executeTask(
modelAttempt++;
}
// All models exhausted
// All models exhausted — release the slot
roundRobin?.release(task.id);
progress.markFailed(task.id, "All configured models exhausted");
throw new Error(`Task ${task.id} failed: all configured models exhausted`);
ctx.ui.notify(
`Task ${task.id} failed: all configured models exhausted`,
"error",
);
}
// ─── Save Reflection to File ────────────────────────────────────────────────

View File

@@ -1,11 +1,11 @@
import * as fs from "node:fs";
import * as path from "node:path";
import type {
ProgressState,
PRDProgress,
Task,
Reflection,
ToolUsage,
ProgressState,
PRDProgress,
Task,
Reflection,
ToolUsage,
} from "./types";
import { ensureDir } from "./utils";
@@ -14,11 +14,11 @@ import { ensureDir } from "./utils";
* e.g., "tasks/feature-x/README.md" → "tasks-feature-x-README"
*/
export function derivePRDKey(projectDir: string, sourcePath: string): string {
const rel = path.relative(projectDir, sourcePath);
return rel
.replace(/[^a-zA-Z0-9_-]/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
const rel = path.relative(projectDir, sourcePath);
return rel
.replace(/[^a-zA-Z0-9_-]/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
}
/**
@@ -28,250 +28,258 @@ export function derivePRDKey(projectDir: string, sourcePath: string): string {
* Falls back to legacy flat format for backward compatibility.
*/
export class ProgressTracker {
private statePath: string;
private state: ProgressState;
private prdKey: string;
private statePath: string;
private state: ProgressState;
private prdKey: string;
constructor(projectDir: string, sourcePath: string, prdKey?: string) {
const stateDir = path.join(projectDir, ".ralpi");
ensureDir(stateDir);
this.statePath = path.join(stateDir, "progress.json");
this.prdKey = prdKey ?? derivePRDKey(projectDir, sourcePath);
this.state = this.loadOrCreate(sourcePath);
}
constructor(projectDir: string, sourcePath: string, prdKey?: string) {
const stateDir = path.join(projectDir, ".ralpi");
ensureDir(stateDir);
this.statePath = path.join(stateDir, "progress.json");
this.prdKey = prdKey ?? derivePRDKey(projectDir, sourcePath);
this.state = this.loadOrCreate(sourcePath);
}
/** Load existing state or create a fresh one */
private loadOrCreate(sourcePathHint: string): ProgressState {
if (fs.existsSync(this.statePath)) {
try {
const raw = fs.readFileSync(this.statePath, "utf-8");
const parsed = JSON.parse(raw) as ProgressState;
/** Load existing state or create a fresh one */
private loadOrCreate(sourcePathHint: string): ProgressState {
if (fs.existsSync(this.statePath)) {
try {
const raw = fs.readFileSync(this.statePath, "utf-8");
const parsed = JSON.parse(raw) as ProgressState;
// Multi-PRD mode: check if we have a PRD entry
if (parsed.prds?.[this.prdKey]) {
// Found PRD entry — use it, but keep legacy fields for compat
return parsed;
}
// Multi-PRD mode: check if we have a PRD entry
if (parsed.prds?.[this.prdKey]) {
// Found PRD entry — use it, but keep legacy fields for compat
return parsed;
}
// Legacy flat mode: check if the source path matches
if (path.resolve(parsed.sourcePath) === path.resolve(sourcePathHint)) {
// Migrate legacy state to PRD mode
parsed.prds = {
[this.prdKey]: {
sourcePath: parsed.sourcePath,
tasks: parsed.tasks,
startedAt: parsed.startedAt,
lastUpdatedAt: parsed.lastUpdatedAt,
paused: parsed.paused,
},
};
return parsed;
}
// Legacy flat mode: check if the source path matches
if (path.resolve(parsed.sourcePath) === path.resolve(sourcePathHint)) {
// Migrate legacy state to PRD mode
parsed.prds = {
[this.prdKey]: {
sourcePath: parsed.sourcePath,
tasks: parsed.tasks,
startedAt: parsed.startedAt,
lastUpdatedAt: parsed.lastUpdatedAt,
paused: parsed.paused,
},
};
return parsed;
}
// Different PRD — create new entry alongside existing ones
if (parsed.prds) {
parsed.prds[this.prdKey] = this.freshPRD(sourcePathHint);
return parsed;
}
// Different PRD — create new entry alongside existing ones
if (parsed.prds) {
parsed.prds[this.prdKey] = this.freshPRD(sourcePathHint);
return parsed;
}
// Legacy flat state exists but for a different source — promote it to PRD mode
const legacyKey = derivePRDKey(
path.dirname(this.statePath),
parsed.sourcePath,
);
parsed.prds = {
[legacyKey]: {
sourcePath: parsed.sourcePath,
tasks: parsed.tasks,
startedAt: parsed.startedAt,
lastUpdatedAt: parsed.lastUpdatedAt,
paused: parsed.paused,
},
[this.prdKey]: this.freshPRD(sourcePathHint),
};
return parsed;
} catch {
// Fall through to create new
}
}
// Legacy flat state exists but for a different source — promote it to PRD mode
const legacyKey = derivePRDKey(
path.dirname(this.statePath),
parsed.sourcePath,
);
parsed.prds = {
[legacyKey]: {
sourcePath: parsed.sourcePath,
tasks: parsed.tasks,
startedAt: parsed.startedAt,
lastUpdatedAt: parsed.lastUpdatedAt,
paused: parsed.paused,
},
[this.prdKey]: this.freshPRD(sourcePathHint),
};
return parsed;
} catch {
// Fall through to create new
}
}
return this.freshState(sourcePathHint);
}
return this.freshState(sourcePathHint);
}
private freshPRD(sourcePath: string): PRDProgress {
return {
sourcePath,
tasks: {},
startedAt: new Date().toISOString(),
lastUpdatedAt: new Date().toISOString(),
paused: false,
};
}
private freshPRD(sourcePath: string): PRDProgress {
return {
sourcePath,
tasks: {},
startedAt: new Date().toISOString(),
lastUpdatedAt: new Date().toISOString(),
paused: false,
};
}
private freshState(sourcePath: string): ProgressState {
return {
sourcePath,
tasks: {},
startedAt: new Date().toISOString(),
lastUpdatedAt: new Date().toISOString(),
paused: false,
prds: {
[this.prdKey]: {
sourcePath,
tasks: {},
startedAt: new Date().toISOString(),
lastUpdatedAt: new Date().toISOString(),
paused: false,
},
},
};
}
private freshState(sourcePath: string): ProgressState {
return {
sourcePath,
tasks: {},
startedAt: new Date().toISOString(),
lastUpdatedAt: new Date().toISOString(),
paused: false,
prds: {
[this.prdKey]: {
sourcePath,
tasks: {},
startedAt: new Date().toISOString(),
lastUpdatedAt: new Date().toISOString(),
paused: false,
},
},
};
}
/** Get the PRD-scoped progress entry */
private getPRD(): PRDProgress {
if (!this.state.prds) {
// Should not happen after loadOrCreate, but guard anyway
this.state.prds = { [this.prdKey]: this.freshPRD(this.state.sourcePath) };
}
if (!this.state.prds[this.prdKey]) {
this.state.prds[this.prdKey] = this.freshPRD(this.state.sourcePath);
}
return this.state.prds[this.prdKey];
}
/** Get the PRD-scoped progress entry */
private getPRD(): PRDProgress {
if (!this.state.prds) {
// Should not happen after loadOrCreate, but guard anyway
this.state.prds = { [this.prdKey]: this.freshPRD(this.state.sourcePath) };
}
if (!this.state.prds[this.prdKey]) {
this.state.prds[this.prdKey] = this.freshPRD(this.state.sourcePath);
}
return this.state.prds[this.prdKey];
}
/** Save current state to disk */
save(): void {
const prd = this.getPRD();
prd.lastUpdatedAt = new Date().toISOString();
// Sync legacy flat fields with current PRD for backward compat
this.state.sourcePath = prd.sourcePath;
this.state.tasks = prd.tasks;
this.state.startedAt = prd.startedAt;
this.state.lastUpdatedAt = prd.lastUpdatedAt;
this.state.paused = prd.paused;
fs.writeFileSync(
this.statePath,
JSON.stringify(this.state, null, 2),
"utf-8",
);
}
/** Save current state to disk */
save(): void {
const prd = this.getPRD();
prd.lastUpdatedAt = new Date().toISOString();
// Sync legacy flat fields with current PRD for backward compat
this.state.sourcePath = prd.sourcePath;
this.state.tasks = prd.tasks;
this.state.startedAt = prd.startedAt;
this.state.lastUpdatedAt = prd.lastUpdatedAt;
this.state.paused = prd.paused;
fs.writeFileSync(
this.statePath,
JSON.stringify(this.state, null, 2),
"utf-8",
);
}
/** Mark a task as in progress */
markInProgress(taskId: string): void {
const prd = this.getPRD();
this.ensureTask(prd, taskId);
prd.tasks[taskId].status = "in_progress";
prd.tasks[taskId].startedAt = new Date().toISOString();
this.save();
}
/** Mark a task as in progress */
markInProgress(taskId: string): void {
const prd = this.getPRD();
this.ensureTask(prd, taskId);
prd.tasks[taskId].status = "in_progress";
prd.tasks[taskId].startedAt = new Date().toISOString();
this.save();
}
/** Mark a task as completed */
markCompleted(
taskId: string,
durationMs: number,
reflection?: Reflection,
toolUsage?: ToolUsage,
sessionFile?: string,
outputPreview?: string,
commitMessages?: string[],
commitSummary?: string,
): void {
const prd = this.getPRD();
this.ensureTask(prd, taskId);
prd.tasks[taskId].status = "completed";
prd.tasks[taskId].completedAt = new Date().toISOString();
prd.tasks[taskId].durationMs = durationMs;
if (reflection) prd.tasks[taskId].reflection = reflection;
if (toolUsage) prd.tasks[taskId].toolUsage = toolUsage;
if (sessionFile) prd.tasks[taskId].sessionFile = sessionFile;
if (outputPreview) prd.tasks[taskId].outputPreview = outputPreview;
if (commitMessages) prd.tasks[taskId].commitMessages = commitMessages;
if (commitSummary) prd.tasks[taskId].commitSummary = commitSummary;
this.save();
}
/** Mark a task as completed */
markCompleted(
taskId: string,
durationMs: number,
reflection?: Reflection,
toolUsage?: ToolUsage,
sessionFile?: string,
outputPreview?: string,
commitMessages?: string[],
commitSummary?: string,
): void {
const prd = this.getPRD();
this.ensureTask(prd, taskId);
prd.tasks[taskId].status = "completed";
prd.tasks[taskId].completedAt = new Date().toISOString();
prd.tasks[taskId].durationMs = durationMs;
if (reflection) prd.tasks[taskId].reflection = reflection;
if (toolUsage) prd.tasks[taskId].toolUsage = toolUsage;
if (sessionFile) prd.tasks[taskId].sessionFile = sessionFile;
if (outputPreview) prd.tasks[taskId].outputPreview = outputPreview;
if (commitMessages) prd.tasks[taskId].commitMessages = commitMessages;
if (commitSummary) prd.tasks[taskId].commitSummary = commitSummary;
this.save();
}
/** Mark a task as failed */
markFailed(taskId: string, error: string): void {
const prd = this.getPRD();
this.ensureTask(prd, taskId);
prd.tasks[taskId].status = "failed";
prd.tasks[taskId].error = error;
this.save();
}
/** Mark a task as failed */
markFailed(taskId: string, error: string): void {
const prd = this.getPRD();
this.ensureTask(prd, taskId);
prd.tasks[taskId].status = "failed";
prd.tasks[taskId].error = error;
this.save();
}
/** Get task status */
getTaskStatus(taskId: string): Task["status"] {
const prd = this.getPRD();
return prd.tasks[taskId]?.status ?? "pending";
}
/** Get task status */
getTaskStatus(taskId: string): Task["status"] {
const prd = this.getPRD();
return prd.tasks[taskId]?.status ?? "pending";
}
/** Get IDs of all completed tasks */
getCompletedTaskIds(): string[] {
const prd = this.getPRD();
return Object.entries(prd.tasks)
.filter(([, info]) => info.status === "completed")
.map(([id]) => id);
}
/** Get IDs of all completed tasks */
getCompletedTaskIds(): string[] {
const prd = this.getPRD();
return Object.entries(prd.tasks)
.filter(([, info]) => info.status === "completed")
.map(([id]) => id);
}
/** Get all reflections from completed tasks */
getAllReflections(): Reflection[] {
const prd = this.getPRD();
const reflections: Reflection[] = [];
for (const info of Object.values(prd.tasks)) {
if (info.reflection) reflections.push(info.reflection);
}
return reflections;
}
/** Get IDs of all failed tasks */
getFailedTaskIds(): string[] {
const prd = this.getPRD();
return Object.entries(prd.tasks)
.filter(([, info]) => info.status === "failed")
.map(([id]) => id);
}
/** Get reflections for specific dependency tasks */
getDependencyReflections(depIds: string[]): Reflection[] {
const prd = this.getPRD();
return depIds
.map((id) => prd.tasks[id]?.reflection)
.filter((r): r is Reflection => r !== undefined);
}
/** Get all reflections from completed tasks */
getAllReflections(): Reflection[] {
const prd = this.getPRD();
const reflections: Reflection[] = [];
for (const info of Object.values(prd.tasks)) {
if (info.reflection) reflections.push(info.reflection);
}
return reflections;
}
/** Increment retry count */
incrementRetry(taskId: string): number {
const prd = this.getPRD();
this.ensureTask(prd, taskId);
prd.tasks[taskId].retries++;
this.save();
return prd.tasks[taskId].retries;
}
/** Get reflections for specific dependency tasks */
getDependencyReflections(depIds: string[]): Reflection[] {
const prd = this.getPRD();
return depIds
.map((id) => prd.tasks[id]?.reflection)
.filter((r): r is Reflection => r !== undefined);
}
/** Set paused state */
setPaused(paused: boolean): void {
const prd = this.getPRD();
prd.paused = paused;
this.save();
}
/** Increment retry count */
incrementRetry(taskId: string): number {
const prd = this.getPRD();
this.ensureTask(prd, taskId);
prd.tasks[taskId].retries++;
this.save();
return prd.tasks[taskId].retries;
}
/** Get the raw PRD state (for status display) */
getState(): PRDProgress {
return this.getPRD();
}
/** Set paused state */
setPaused(paused: boolean): void {
const prd = this.getPRD();
prd.paused = paused;
this.save();
}
/** Get all PRDs (for multi-PRD status display) */
getAllPRDs(): Record<string, PRDProgress> {
return this.state.prds ?? {};
}
/** Get the raw PRD state (for status display) */
getState(): PRDProgress {
return this.getPRD();
}
/** Get the PRD key for this tracker */
getKey(): string {
return this.prdKey;
}
/** Get all PRDs (for multi-PRD status display) */
getAllPRDs(): Record<string, PRDProgress> {
return this.state.prds ?? {};
}
/** Reset all progress for this PRD */
reset(): void {
const prd = this.getPRD();
Object.assign(prd, this.freshPRD(prd.sourcePath));
this.save();
}
/** Get the PRD key for this tracker */
getKey(): string {
return this.prdKey;
}
private ensureTask(prd: PRDProgress, taskId: string): void {
if (!prd.tasks[taskId]) {
prd.tasks[taskId] = { status: "pending", retries: 0 };
}
}
/** Reset all progress for this PRD */
reset(): void {
const prd = this.getPRD();
Object.assign(prd, this.freshPRD(prd.sourcePath));
this.save();
}
private ensureTask(prd: PRDProgress, taskId: string): void {
if (!prd.tasks[taskId]) {
prd.tasks[taskId] = { status: "pending", retries: 0 };
}
}
}

View File

@@ -1,18 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"module": "ES2022",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./",
"noEmit": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
"resolveJsonModule": true
},
"include": ["index.ts", "src/**/*"],
"exclude": ["node_modules", "dist"]