From 3c01652b90b8361b60ee244322ec7b9cc88a8f78 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Sun, 31 May 2026 08:18:46 -0400 Subject: [PATCH] fix width exceeding, release prep --- .gitignore | 1 - AGENTS.md | 9 +- LICENSE | 21 +++ index.ts | 43 ++++- package.json | 44 ++++- src/dag.ts | 71 ++++++-- src/executor.ts | 149 +++++++++++---- src/progress.ts | 470 ++++++++++++++++++++++++------------------------ tsconfig.json | 11 +- 9 files changed, 518 insertions(+), 301 deletions(-) create mode 100644 LICENSE diff --git a/.gitignore b/.gitignore index 8dc9c97..7b422a4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,3 @@ node_modules -dist .pi-lens package-lock.json diff --git a/AGENTS.md b/AGENTS.md index f0eb54b..a0d4610 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5d350cc --- /dev/null +++ b/LICENSE @@ -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. diff --git a/index.ts b/index.ts index 60b5c53..255ccf8 100644 --- a/index.ts +++ b/index.ts @@ -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 { 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 { + // 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; + } + } } } diff --git a/package.json b/package.json index ecbd460..b5ed266 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/src/dag.ts b/src/dag.ts index bd7d0e6..ee5b356 100644 --- a/src/dag.ts +++ b/src/dag.ts @@ -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, +): Set { + const blocked = new Set(); + + let changed = true; + while (changed) { + changed = false; + for (const task of pendingTasks) { + if (blocked.has(task.id)) continue; + const deps = task.dependencies || []; + if (deps.some((dep) => failedTaskIds.has(dep))) { + blocked.add(task.id); + changed = true; + } + } + } + + return blocked; +} + // ─── Main Entry ────────────────────────────────────────────────────────────── /** @@ -10,16 +38,15 @@ export function buildExecutionPlan( project: Project, completed: Set, parallelGroup?: number, + failedTaskIds: Set = 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, + failedTaskIds: Set = 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, - completed: Set, + failedTaskIds: Set, ): ExecutionBatch[] { const batches: ExecutionBatch[] = []; - const done = new Set(completed); - const remaining = new Set(pendingTasks.map((t) => t.id)); + const done = new Set(); + const blocked = getBlockedTasks(pendingTasks, failedTaskIds); + const pendingSet = new Set(pendingTasks.map((t) => t.id)); + const remaining = new Set( + pendingTasks.filter((t) => !blocked.has(t.id)).map((t) => t.id), + ); while (remaining.size > 0) { // Find tasks whose dependencies are all satisfied @@ -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, - completed: Set, + failedTaskIds: Set, ): ExecutionBatch[] { + const blocked = getBlockedTasks(pendingTasks, failedTaskIds); + const activeTasks = pendingTasks.filter((t) => !blocked.has(t.id)); + const groups = new Map(); - for (const task of 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, })); diff --git a/src/executor.ts b/src/executor.ts index 8569423..03ae4cf 100644 --- a/src/executor.ts +++ b/src/executor.ts @@ -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 ──────────────────────────────────────────────── diff --git a/src/progress.ts b/src/progress.ts index 2be01dc..531e9ce 100644 --- a/src/progress.ts +++ b/src/progress.ts @@ -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 { - 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 { + 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 }; + } + } } diff --git a/tsconfig.json b/tsconfig.json index ad9c562..f842c64 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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"]