From 723b7be84aac2325fb54a96e42c3f6202bb08e79 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Wed, 5 Aug 2026 10:58:05 -0400 Subject: [PATCH] change: send down proj context with pi tool/skills --- index.ts | 49 +- package.json | 2 +- src/executor.ts | 2 +- src/utils.ts | 1257 ++++++++++++++++++++++++----------------------- 4 files changed, 673 insertions(+), 637 deletions(-) diff --git a/index.ts b/index.ts index 6e543e7..d2e13da 100644 --- a/index.ts +++ b/index.ts @@ -1287,12 +1287,17 @@ async function handleReset( ctx: ExtensionContext, args: string[], ): Promise { + let sourcePath: string; + let prdKey: string | undefined; + let progress: ProgressTracker; + if (args[0]) { const taskFile = resolveTaskArg(args[0], ctx.cwd); const found = findProgressFile(ctx.cwd, taskFile); const projectDir = found ? path.dirname(path.dirname(found.path)) : ctx.cwd; - const progress = new ProgressTracker(projectDir, taskFile, found?.prdKey); - progress.reset(); + sourcePath = taskFile; + prdKey = found?.prdKey; + progress = new ProgressTracker(projectDir, taskFile, prdKey); } else { const found = findProgressFile(ctx.cwd); if (!found) { @@ -1315,13 +1320,41 @@ async function handleReset( ctx.ui.notify("Reset cancelled.", "info"); return; } - const progress = new ProgressTracker( - projectDir, - selected.sourcePath, - selected.prdKey, - ); - progress.reset(); + sourcePath = selected.sourcePath; + prdKey = selected.prdKey; + progress = new ProgressTracker(projectDir, sourcePath, prdKey); } + // Ask whether to also clear the progress markers (checkboxes/status) in the + // source PRD/README file itself, not just .ralpi/progress.json. + const taskIds = Object.keys(progress.getState().tasks); + if (taskIds.length > 0) { + const choice = await ctx.ui.select( + `Also reset progress markers in the source file (${path.basename( + sourcePath, + )})?`, + [ + "Yes — clear checkboxes/status markers in the source PRD/README", + "No — only reset .ralpi/progress.json", + ], + ); + if (choice === undefined) { + ctx.ui.notify("Reset cancelled.", "info"); + return; + } + if (choice.startsWith("Yes")) { + progress.reset(); + for (const id of taskIds) { + updateTaskInFile(sourcePath, id, "pending"); + } + ctx.ui.notify( + `Progress reset — cleared ${taskIds.length} task marker(s) in ${path.basename(sourcePath)}.`, + "info", + ); + return; + } + } + + progress.reset(); ctx.ui.notify("Progress reset. All task statuses cleared.", "info"); } diff --git a/package.json b/package.json index da4c149..8773040 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@mikefreno/ralpi", - "version": "0.4.4", + "version": "0.5.0", "description": "Execute tasks from task files/PRD's using DAG-based dependency resolution with persistent progress tracking", "keywords": [ "pi-package", diff --git a/src/executor.ts b/src/executor.ts index 4202846..84a23cd 100644 --- a/src/executor.ts +++ b/src/executor.ts @@ -1434,7 +1434,7 @@ async function runFollowUpSession( undefined, model, config.thinkingLevel, - true, // noSkills — follow-up sessions don't need the skills catalog + false, // noSkills=false — follow-up sessions load skills too (ctx.modelRegistry as any).runtime as ModelRuntime, ); diff --git a/src/utils.ts b/src/utils.ts index 5b13216..ec3c3fe 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,21 +1,21 @@ import * as fs from "node:fs"; import * as path from "node:path"; import type { - RalpiConfig, - PRDProgress, - ProgressState, - ToolUsage, + RalpiConfig, + PRDProgress, + ProgressState, + ToolUsage, } from "./types"; import { DEFAULT_CONFIG } from "./types"; import { parseTaskFile } from "./parser"; import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent"; import { - createAgentSession, - DefaultResourceLoader, - getAgentDir, - SessionManager, - SettingsManager, - type ModelRuntime, + createAgentSession, + DefaultResourceLoader, + getAgentDir, + SessionManager, + SettingsManager, + type ModelRuntime, } from "@earendil-works/pi-coding-agent"; // ─── Directory Helpers ─────────────────────────────────────────────────────── @@ -24,17 +24,17 @@ import { * Ensure a directory exists, creating it recursively if needed */ export function ensureDir(dirPath: string): void { - if (!fs.existsSync(dirPath)) { - fs.mkdirSync(dirPath, { recursive: true }); - } + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }); + } } /** * Write file content, creating parent directories if needed */ export function writeFileSafe(filePath: string, content: string): void { - ensureDir(path.dirname(filePath)); - fs.writeFileSync(filePath, content, "utf-8"); + ensureDir(path.dirname(filePath)); + fs.writeFileSync(filePath, content, "utf-8"); } // ─── Loop-Active State ────────────────────────────────────────────────────── @@ -45,16 +45,16 @@ export function writeFileSafe(filePath: string, content: string): void { * the loop non-interactively when a reload interrupts in-progress tasks. */ export interface LoopActiveState { - taskFile: string; - mode: "parallel" | "sequential"; - startedAt: string; - taskIds: string[]; - prdKey: string; - /** Loop option snapshot at loop start, so a reload can resume without - * re-prompting the user. */ - autoCommit?: boolean; - autoReview?: boolean; - saveReviews?: boolean; + taskFile: string; + mode: "parallel" | "sequential"; + startedAt: string; + taskIds: string[]; + prdKey: string; + /** Loop option snapshot at loop start, so a reload can resume without + * re-prompting the user. */ + autoCommit?: boolean; + autoReview?: boolean; + saveReviews?: boolean; } /** @@ -66,53 +66,53 @@ 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, + projectDir: string, + state: LoopActiveState, ): void { - writeFileSafe( - path.join(projectDir, LOOP_ACTIVE_FILE), - JSON.stringify(state, null, 2), - ); + 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; - } + 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 - } + 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; + 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 ──────────────────────────────────────────────────── @@ -124,41 +124,41 @@ export function findRalpiDir(startDir: string): string | null { * For a specific sourcePath, finds the matching PRD entry. */ export function findProgressFile( - startDir: string, - sourcePath?: string, + startDir: string, + sourcePath?: string, ): { path: string; state: ProgressState; prdKey?: string } | null { - let current = path.resolve(startDir); - const root = path.parse(current).root; + let current = path.resolve(startDir); + const root = path.parse(current).root; - while (current !== root) { - const candidate = path.join(current, ".ralpi", "progress.json"); - if (fs.existsSync(candidate)) { - try { - const raw = fs.readFileSync(candidate, "utf-8"); - const state = JSON.parse(raw) as ProgressState; + while (current !== root) { + const candidate = path.join(current, ".ralpi", "progress.json"); + if (fs.existsSync(candidate)) { + try { + const raw = fs.readFileSync(candidate, "utf-8"); + const state = JSON.parse(raw) as ProgressState; - // If looking for a specific source path, find matching PRD - if (sourcePath && state.prds) { - const resolvedSource = path.resolve(sourcePath); - for (const [key, prd] of Object.entries(state.prds)) { - if (path.resolve(prd.sourcePath) === resolvedSource) { - return { path: candidate, state, prdKey: key }; - } - } - // No matching PRD found, continue walking up - current = path.dirname(current); - continue; - } + // If looking for a specific source path, find matching PRD + if (sourcePath && state.prds) { + const resolvedSource = path.resolve(sourcePath); + for (const [key, prd] of Object.entries(state.prds)) { + if (path.resolve(prd.sourcePath) === resolvedSource) { + return { path: candidate, state, prdKey: key }; + } + } + // No matching PRD found, continue walking up + current = path.dirname(current); + continue; + } - return { path: candidate, state }; - } catch { - return null; - } - } - current = path.dirname(current); - } + return { path: candidate, state }; + } catch { + return null; + } + } + current = path.dirname(current); + } - return null; + return null; } /** @@ -167,42 +167,42 @@ export function findProgressFile( * loops have progress simultaneously. */ export function listPRDsSorted( - state: ProgressState, + state: ProgressState, ): Array<{ key: string; prd: PRDProgress }> { - const entries: Array<{ key: string; prd: PRDProgress }> = []; + const entries: Array<{ key: string; prd: PRDProgress }> = []; - if (state.prds) { - for (const [key, prd] of Object.entries(state.prds)) { - entries.push({ key, prd }); - } - } else { - // Legacy flat mode — single PRD - entries.push({ - key: "legacy", - prd: { - sourcePath: state.sourcePath, - tasks: state.tasks, - startedAt: state.startedAt, - lastUpdatedAt: state.lastUpdatedAt, - paused: state.paused, - }, - }); - } + if (state.prds) { + for (const [key, prd] of Object.entries(state.prds)) { + entries.push({ key, prd }); + } + } else { + // Legacy flat mode — single PRD + entries.push({ + key: "legacy", + prd: { + sourcePath: state.sourcePath, + tasks: state.tasks, + startedAt: state.startedAt, + lastUpdatedAt: state.lastUpdatedAt, + paused: state.paused, + }, + }); + } - entries.sort((a, b) => { - return ( - new Date(b.prd.lastUpdatedAt).getTime() - - new Date(a.prd.lastUpdatedAt).getTime() - ); - }); + entries.sort((a, b) => { + return ( + new Date(b.prd.lastUpdatedAt).getTime() - + new Date(a.prd.lastUpdatedAt).getTime() + ); + }); - return entries; + return entries; } export interface PRDResumeSummary { - total: number; - completed: number; - failed: number; + total: number; + completed: number; + failed: number; } /** @@ -218,28 +218,28 @@ export interface PRDResumeSummary { * when the source file is missing or unparseable. */ export function countPRDResumeStats( - prd: PRDProgress, - sourcePath: string, + prd: PRDProgress, + sourcePath: string, ): PRDResumeSummary { - const touched = Object.entries(prd.tasks); - const failed = touched.filter(([, t]) => t.status === "failed").length; - const completedIds = new Set( - touched.filter(([, t]) => t.status === "completed").map(([id]) => id), - ); + const touched = Object.entries(prd.tasks); + const failed = touched.filter(([, t]) => t.status === "failed").length; + const completedIds = new Set( + touched.filter(([, t]) => t.status === "completed").map(([id]) => id), + ); - let total: number; - try { - const project = parseTaskFile(sourcePath); - total = project.tasks.length; - for (const task of project.tasks) { - if (task.status === "completed") completedIds.add(task.id); - } - } catch { - // PRD file missing/unparseable — fall back to touched-task counts - total = touched.length; - } + let total: number; + try { + const project = parseTaskFile(sourcePath); + total = project.tasks.length; + for (const task of project.tasks) { + if (task.status === "completed") completedIds.add(task.id); + } + } catch { + // PRD file missing/unparseable — fall back to touched-task counts + total = touched.length; + } - return { total, completed: completedIds.size, failed }; + return { total, completed: completedIds.size, failed }; } // ─── Model Resolution ─────────────────────────────────────────────────────── @@ -249,23 +249,23 @@ export function countPRDResumeStats( * Returns undefined if spec is empty, malformed, or not found. */ export function resolveModelSpec( - modelRegistry: - | { find(provider: string, modelId: string): unknown } - | undefined, - spec: string, - onWarning?: (msg: string) => void, + modelRegistry: + | { find(provider: string, modelId: string): unknown } + | undefined, + spec: string, + onWarning?: (msg: string) => void, ): unknown | undefined { - if (!spec) return undefined; - const slashIdx = spec.indexOf("/"); - if (slashIdx === -1) { - onWarning?.( - `ralpi config: skipping model "${spec}" — expected / format`, - ); - return undefined; - } - const provider = spec.slice(0, slashIdx); - const modelId = spec.slice(slashIdx + 1); - return modelRegistry?.find(provider, modelId); + if (!spec) return undefined; + const slashIdx = spec.indexOf("/"); + if (slashIdx === -1) { + onWarning?.( + `ralpi config: skipping model "${spec}" — expected / format`, + ); + return undefined; + } + const provider = spec.slice(0, slashIdx); + const modelId = spec.slice(slashIdx + 1); + return modelRegistry?.find(provider, modelId); } // ─── Config ────────────────────────────────────────────────────────────────── @@ -273,59 +273,59 @@ export function resolveModelSpec( /** Try to use the `yaml` package (real dependency in package.json). * Falls back to a flat key:value parser when unavailable. */ const parseSimpleYaml: (content: string) => Record = (() => { - try { - // eslint-disable-next-line @typescript-eslint/no-var-requires - const { parse } = require("yaml"); - return (content: string) => parse(content) ?? {}; - } catch { - return (content: string) => { - const result: Record = {}; - for (const line of content.split("\n")) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith("#")) continue; - const match = trimmed.match(/^([^:]+):\s*(.*)$/); - if (match) { - const value = match[2].trim(); - if (value === "true") result[match[1].trim()] = true; - else if (value === "false") result[match[1].trim()] = false; - else if (/^\d+$/.test(value)) - result[match[1].trim()] = parseInt(value, 10); - else if (/^\d+\.\d+$/.test(value)) - result[match[1].trim()] = parseFloat(value); - else result[match[1].trim()] = value; - } - } - return result; - }; - } + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { parse } = require("yaml"); + return (content: string) => parse(content) ?? {}; + } catch { + return (content: string) => { + const result: Record = {}; + for (const line of content.split("\n")) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const match = trimmed.match(/^([^:]+):\s*(.*)$/); + if (match) { + const value = match[2].trim(); + if (value === "true") result[match[1].trim()] = true; + else if (value === "false") result[match[1].trim()] = false; + else if (/^\d+$/.test(value)) + result[match[1].trim()] = parseInt(value, 10); + else if (/^\d+\.\d+$/.test(value)) + result[match[1].trim()] = parseFloat(value); + else result[match[1].trim()] = value; + } + } + return result; + }; + } })(); /** * Deep merge configuration objects */ function mergeConfig( - defaults: RalpiConfig, - overrides: Record, + defaults: RalpiConfig, + overrides: Record, ): RalpiConfig { - const result = { ...defaults }; + const result = { ...defaults }; - for (const [key, value] of Object.entries(overrides)) { - if (typeof value === "object" && value !== null && !Array.isArray(value)) { - (result as any)[key] = { ...(defaults as any)[key], ...value }; - } else { - (result as any)[key] = value; - } - } + for (const [key, value] of Object.entries(overrides)) { + if (typeof value === "object" && value !== null && !Array.isArray(value)) { + (result as any)[key] = { ...(defaults as any)[key], ...value }; + } else { + (result as any)[key] = value; + } + } - return result as RalpiConfig; + return result as RalpiConfig; } /** Path to the global ralpi config under the user's Pi home directory. */ const GLOBAL_CONFIG_PATH = path.join( - process.env.HOME || "/tmp", - ".pi", - "ralpi", - "config.yaml", + process.env.HOME || "/tmp", + ".pi", + "ralpi", + "config.yaml", ); /** @@ -337,37 +337,37 @@ const GLOBAL_CONFIG_PATH = path.join( * 3. `DEFAULT_CONFIG` in `src/types.ts` */ export function loadConfig(projectDir: string): RalpiConfig { - // Start with defaults - const merged: RalpiConfig = { ...DEFAULT_CONFIG }; + // Start with defaults + const merged: RalpiConfig = { ...DEFAULT_CONFIG }; - // Layer 1: global config (~/.pi/ralpi/config.yaml) - tryLoadConfigFile(GLOBAL_CONFIG_PATH, merged); + // Layer 1: global config (~/.pi/ralpi/config.yaml) + tryLoadConfigFile(GLOBAL_CONFIG_PATH, merged); - // Layer 2: project config (.ralpi/config.yaml) — overrides global - tryLoadConfigFile(path.join(projectDir, ".ralpi", "config.yaml"), merged); + // Layer 2: project config (.ralpi/config.yaml) — overrides global + tryLoadConfigFile(path.join(projectDir, ".ralpi", "config.yaml"), merged); - return merged; + return merged; - /** Attempt to load a single config file and merge into `acc` in place. */ - function tryLoadConfigFile(filePath: string, acc: RalpiConfig): void { - if (!fs.existsSync(filePath)) return; - try { - const content = fs.readFileSync(filePath, "utf-8"); - const parsed = parseSimpleYaml(content); - Object.assign(acc, mergeConfig(acc, parsed)); - // Track which execution keys were explicitly set in this YAML so the - // loop-startup prompts can be skipped for fields the user already set. - const exec = parsed?.execution; - if (exec && typeof exec === "object" && !Array.isArray(exec)) { - acc.execution.explicitKeys ??= new Set(); - for (const key of Object.keys(exec)) { - acc.execution.explicitKeys.add(key); - } - } - } catch { - // Malformed config — skip silently - } - } + /** Attempt to load a single config file and merge into `acc` in place. */ + function tryLoadConfigFile(filePath: string, acc: RalpiConfig): void { + if (!fs.existsSync(filePath)) return; + try { + const content = fs.readFileSync(filePath, "utf-8"); + const parsed = parseSimpleYaml(content); + Object.assign(acc, mergeConfig(acc, parsed)); + // Track which execution keys were explicitly set in this YAML so the + // loop-startup prompts can be skipped for fields the user already set. + const exec = parsed?.execution; + if (exec && typeof exec === "object" && !Array.isArray(exec)) { + acc.execution.explicitKeys ??= new Set(); + for (const key of Object.keys(exec)) { + acc.execution.explicitKeys.add(key); + } + } + } catch { + // Malformed config — skip silently + } + } } // ─── Task Resolution ───────────────────────────────────────────────────────── @@ -377,33 +377,33 @@ export function loadConfig(projectDir: string): RalpiConfig { * Strips leading `@` (from autocomplete) before resolution. */ export function resolveTaskArg(arg: string, cwd: string): string { - // Strip leading @ from autocomplete - const cleanArg = arg.startsWith("@") ? arg.slice(1) : arg; + // Strip leading @ from autocomplete + const cleanArg = arg.startsWith("@") ? arg.slice(1) : arg; - const candidates = [ - path.resolve(cwd, cleanArg), - path.resolve(cwd, cleanArg + ".md"), - path.resolve(cwd, cleanArg + ".yaml"), - path.resolve(cwd, cleanArg + ".yml"), - ]; + const candidates = [ + path.resolve(cwd, cleanArg), + path.resolve(cwd, cleanArg + ".md"), + path.resolve(cwd, cleanArg + ".yaml"), + path.resolve(cwd, cleanArg + ".yml"), + ]; - for (const candidate of candidates) { - if (fs.existsSync(candidate)) return candidate; - } + for (const candidate of candidates) { + if (fs.existsSync(candidate)) return candidate; + } - // Try looking for README.md in the arg directory - try { - if (fs.statSync(path.resolve(cwd, cleanArg)).isDirectory()) { - const readme = path.resolve(cwd, cleanArg, "README.md"); - if (fs.existsSync(readme)) return readme; - } - } catch { - // Directory doesn't exist, fall through to error - } + // Try looking for README.md in the arg directory + try { + if (fs.statSync(path.resolve(cwd, cleanArg)).isDirectory()) { + const readme = path.resolve(cwd, cleanArg, "README.md"); + if (fs.existsSync(readme)) return readme; + } + } catch { + // Directory doesn't exist, fall through to error + } - throw new Error( - `Task file not found: ${cleanArg}\nSearched: ${candidates.join("\n ")}`, - ); + throw new Error( + `Task file not found: ${cleanArg}\nSearched: ${candidates.join("\n ")}`, + ); } // ─── Formatting ────────────────────────────────────────────────────────────── @@ -412,134 +412,134 @@ export function resolveTaskArg(arg: string, cwd: string): string { * Format duration in milliseconds to human-readable string */ export function formatDuration(ms: number): string { - const seconds = Math.floor(ms / 1000); - const minutes = Math.floor(seconds / 60); - const hours = Math.floor(minutes / 60); + const seconds = Math.floor(ms / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); - if (hours > 0) { - return `${hours}h ${minutes % 60}m`; - } - if (minutes > 0) { - return `${minutes}m ${seconds % 60}s`; - } - return `${seconds}s`; + if (hours > 0) { + return `${hours}h ${minutes % 60}m`; + } + if (minutes > 0) { + return `${minutes}m ${seconds % 60}s`; + } + return `${seconds}s`; } /** * Format progress status for display. Accepts a single PRDProgress entry. */ export function formatProgressStatus(state: PRDProgress): string { - const lines: string[] = []; - const tasks = state.tasks; - const total = Object.keys(tasks).length; - const completed = Object.values(tasks).filter( - (t) => t.status === "completed", - ).length; - const failed = Object.values(tasks).filter( - (t) => t.status === "failed", - ).length; - const inProgress = Object.values(tasks).filter( - (t) => t.status === "in_progress", - ).length; + const lines: string[] = []; + const tasks = state.tasks; + const total = Object.keys(tasks).length; + const completed = Object.values(tasks).filter( + (t) => t.status === "completed", + ).length; + const failed = Object.values(tasks).filter( + (t) => t.status === "failed", + ).length; + const inProgress = Object.values(tasks).filter( + (t) => t.status === "in_progress", + ).length; - lines.push("## Progress"); - lines.push(""); - lines.push( - `Total: ${total} | Completed: ${completed} | Failed: ${failed} | In Progress: ${inProgress}`, - ); - lines.push(""); + lines.push("## Progress"); + lines.push(""); + lines.push( + `Total: ${total} | Completed: ${completed} | Failed: ${failed} | In Progress: ${inProgress}`, + ); + lines.push(""); - for (const [id, info] of Object.entries(tasks)) { - const statusIcon = - info.status === "completed" - ? "[x]" - : info.status === "in_progress" - ? "[~]" - : info.status === "failed" - ? "[!]" - : "[ ]"; + for (const [id, info] of Object.entries(tasks)) { + const statusIcon = + info.status === "completed" + ? "[x]" + : info.status === "in_progress" + ? "[~]" + : info.status === "failed" + ? "[!]" + : "[ ]"; - const duration = info.durationMs - ? ` (${formatDuration(info.durationMs)})` - : ""; + const duration = info.durationMs + ? ` (${formatDuration(info.durationMs)})` + : ""; - lines.push(`- ${statusIcon} ${id}${duration}`); + lines.push(`- ${statusIcon} ${id}${duration}`); - if (info.error) { - lines.push(` Error: ${info.error}`); - } - } + if (info.error) { + lines.push(` Error: ${info.error}`); + } + } - lines.push(""); - lines.push(`Started: ${state.startedAt}`); - lines.push(`Updated: ${state.lastUpdatedAt}`); - lines.push(`Paused: ${state.paused ? "yes" : "no"}`); + lines.push(""); + lines.push(`Started: ${state.startedAt}`); + lines.push(`Updated: ${state.lastUpdatedAt}`); + lines.push(`Paused: ${state.paused ? "yes" : "no"}`); - return lines.join("\n"); + return lines.join("\n"); } /** * Format progress status for all PRDs in a ProgressState. */ export function formatAllPRDsStatus(state: ProgressState): string { - const prds = state.prds; - if (!prds || Object.keys(prds).length <= 1) { - // Single PRD — use simple format - const prd = prds - ? Object.values(prds)[0] - : (state as unknown as PRDProgress); - return formatProgressStatus(prd); - } + const prds = state.prds; + if (!prds || Object.keys(prds).length <= 1) { + // Single PRD — use simple format + const prd = prds + ? Object.values(prds)[0] + : (state as unknown as PRDProgress); + return formatProgressStatus(prd); + } - const lines: string[] = []; - lines.push("## Progress (all PRDs)"); - lines.push(""); + const lines: string[] = []; + lines.push("## Progress (all PRDs)"); + lines.push(""); - for (const [key, prd] of Object.entries(prds)) { - const tasks = prd.tasks; - const total = Object.keys(tasks).length; - const completed = Object.values(tasks).filter( - (t) => t.status === "completed", - ).length; - const failed = Object.values(tasks).filter( - (t) => t.status === "failed", - ).length; - const inProgress = Object.values(tasks).filter( - (t) => t.status === "in_progress", - ).length; + for (const [key, prd] of Object.entries(prds)) { + const tasks = prd.tasks; + const total = Object.keys(tasks).length; + const completed = Object.values(tasks).filter( + (t) => t.status === "completed", + ).length; + const failed = Object.values(tasks).filter( + (t) => t.status === "failed", + ).length; + const inProgress = Object.values(tasks).filter( + (t) => t.status === "in_progress", + ).length; - lines.push(`### ${key}`); - lines.push(`Source: ${path.relative(process.cwd(), prd.sourcePath)}`); - lines.push( - `Total: ${total} | Completed: ${completed} | Failed: ${failed} | In Progress: ${inProgress}`, - ); - lines.push(""); + lines.push(`### ${key}`); + lines.push(`Source: ${path.relative(process.cwd(), prd.sourcePath)}`); + lines.push( + `Total: ${total} | Completed: ${completed} | Failed: ${failed} | In Progress: ${inProgress}`, + ); + lines.push(""); - for (const [id, info] of Object.entries(tasks)) { - const statusIcon = - info.status === "completed" - ? "[x]" - : info.status === "in_progress" - ? "[~]" - : info.status === "failed" - ? "[!]" - : "[ ]"; + for (const [id, info] of Object.entries(tasks)) { + const statusIcon = + info.status === "completed" + ? "[x]" + : info.status === "in_progress" + ? "[~]" + : info.status === "failed" + ? "[!]" + : "[ ]"; - const duration = info.durationMs - ? ` (${formatDuration(info.durationMs)})` - : ""; + const duration = info.durationMs + ? ` (${formatDuration(info.durationMs)})` + : ""; - lines.push(`- ${statusIcon} ${id}${duration}`); + lines.push(`- ${statusIcon} ${id}${duration}`); - if (info.error) { - lines.push(` Error: ${info.error}`); - } - } + if (info.error) { + lines.push(` Error: ${info.error}`); + } + } - lines.push(""); - } + lines.push(""); + } - return lines.join("\n"); + return lines.join("\n"); } // ─── Async Agent Session ──────────────────────────────────────────────────── @@ -552,165 +552,168 @@ export function formatAllPRDsStatus(state: ProgressState): string { * responsive and allowing progress updates during task execution. */ export async function runAgentSession( - taskPrompt: string, - cwd: string, - timeoutMs: number, - onEvent?: (event: AgentSessionEvent) => void, - signal?: AbortSignal, - model?: unknown, - thinkingLevel?: unknown, - /** When true, skip loading the skills catalog for this session. Used by - * focused follow-up sessions (commit/review) that don't need skills — - * keeps the context lean and avoids dragging in unrelated overhead. */ - noSkills = false, - /** Parent session's model runtime. Must be passed so extension-registered - * providers (e.g., neuralwatt with its streamSimple wrapper for 429 - * rate-limit normalization) are available. When omitted, the SDK creates - * a fresh runtime from models.json only — extension providers are lost. */ - modelRuntime?: ModelRuntime, + taskPrompt: string, + cwd: string, + timeoutMs: number, + onEvent?: (event: AgentSessionEvent) => void, + signal?: AbortSignal, + model?: unknown, + thinkingLevel?: unknown, + /** When true, skip loading the skills catalog for this session. Used by + * focused follow-up sessions (commit/review) that don't need skills — + * keeps the context lean and avoids dragging in unrelated overhead. */ + noSkills = false, + /** Parent session's model runtime. Must be passed so extension-registered + * providers (e.g., neuralwatt with its streamSimple wrapper for 429 + * rate-limit normalization) are available. When omitted, the SDK creates + * a fresh runtime from models.json only — extension providers are lost. */ + modelRuntime?: ModelRuntime, ): Promise<{ - success: boolean; - text: string; - error?: string; - toolUsage: ToolUsage; - stopReason?: string; - events: AgentSessionEvent[]; + success: boolean; + text: string; + error?: string; + toolUsage: ToolUsage; + stopReason?: string; + events: AgentSessionEvent[]; }> { - const toolUsage: ToolUsage = { - read: 0, - write: 0, - edit: 0, - bash: 0, - other: 0, - }; - // Wire timeout via abort signal (only when set; 0 means inherit Pi's defaults) - let timeoutHandle: NodeJS.Timeout | null = null; - if (timeoutMs > 0) { - timeoutHandle = setTimeout(() => { - if (sessionRef?.session) sessionRef.session.agent.abort(); - }, timeoutMs); - } + const toolUsage: ToolUsage = { + read: 0, + write: 0, + edit: 0, + bash: 0, + other: 0, + }; + // Wire timeout via abort signal (only when set; 0 means inherit Pi's defaults) + let timeoutHandle: NodeJS.Timeout | null = null; + if (timeoutMs > 0) { + timeoutHandle = setTimeout(() => { + if (sessionRef?.session) sessionRef.session.agent.abort(); + }, timeoutMs); + } - const sessionRef: { - session?: Awaited>["session"]; - } = {}; + const sessionRef: { + session?: Awaited>["session"]; + } = {}; - try { - const loader = new DefaultResourceLoader({ - cwd, - agentDir: getAgentDir(), - noExtensions: true, - noSkills, - noPromptTemplates: true, - noThemes: true, - noContextFiles: true, - }); - await loader.reload(); + try { + // Loop sessions load the full normal pi context: extensions (so all + // extension-provided tools register), skills, and project context + // (AGENTS.md / CLAUDE.md) + const loader = new DefaultResourceLoader({ + cwd, + agentDir: getAgentDir(), + noSkills, + noPromptTemplates: true, + noThemes: true, + noExtensions: false, + noContextFiles: false, + }); + await loader.reload(); - const result = await createAgentSession({ - cwd, - sessionManager: SessionManager.inMemory(), - resourceLoader: loader, - settingsManager: SettingsManager.create(cwd, getAgentDir()), - modelRuntime, - tools: ["read", "bash", "edit", "write", "grep", "find", "ls"], - model: model as any, - thinkingLevel: thinkingLevel as any, - }); - sessionRef.session = result.session; + const result = await createAgentSession({ + cwd, + sessionManager: SessionManager.inMemory(), + resourceLoader: loader, + settingsManager: SettingsManager.create(cwd, getAgentDir()), + modelRuntime, + // No `tools` allowlist: matches a normal pi session's tool set. + model: model as any, + thinkingLevel: thinkingLevel as any, + }); + sessionRef.session = result.session; - // Wire external abort signal - const abortHandler = () => result.session.agent.abort(); - signal?.addEventListener("abort", abortHandler, { once: true }); + // Wire external abort signal + const abortHandler = () => result.session.agent.abort(); + signal?.addEventListener("abort", abortHandler, { once: true }); - let finalText = ""; - let errorMessage: string | undefined; - let stopReason: string | undefined; + let finalText = ""; + let errorMessage: string | undefined; + let stopReason: string | undefined; - const unsubscribe = result.session.subscribe((event) => { - onEvent?.(event); + const unsubscribe = result.session.subscribe((event) => { + onEvent?.(event); - if (event.type === "message_end") { - const message = event.message as { - role?: string; - content?: unknown; - stopReason?: string; - errorMessage?: string; - }; - if (message.role !== "assistant") return; - if (message.stopReason) stopReason = message.stopReason; - if (message.errorMessage) errorMessage = message.errorMessage; - const text = extractAssistantText(message.content); - if (text) finalText = text; - } + if (event.type === "message_end") { + const message = event.message as { + role?: string; + content?: unknown; + stopReason?: string; + errorMessage?: string; + }; + if (message.role !== "assistant") return; + if (message.stopReason) stopReason = message.stopReason; + if (message.errorMessage) errorMessage = message.errorMessage; + const text = extractAssistantText(message.content); + if (text) finalText = text; + } - if (event.type === "tool_execution_start") { - const name = event.toolName; - if (name in toolUsage) { - (toolUsage as unknown as Record)[name]++; - } else { - toolUsage.other++; - } - } - }); + if (event.type === "tool_execution_start") { + const name = event.toolName; + if (name in toolUsage) { + (toolUsage as unknown as Record)[name]++; + } else { + toolUsage.other++; + } + } + }); - if (signal?.aborted) throw new Error("Aborted before prompt"); + if (signal?.aborted) throw new Error("Aborted before prompt"); - await result.session.prompt(taskPrompt); - await result.session.agent.waitForIdle(); + await result.session.prompt(taskPrompt); + await result.session.agent.waitForIdle(); - unsubscribe(); - result.session.dispose(); - signal?.removeEventListener("abort", abortHandler); - if (timeoutHandle) clearTimeout(timeoutHandle); + unsubscribe(); + result.session.dispose(); + signal?.removeEventListener("abort", abortHandler); + if (timeoutHandle) clearTimeout(timeoutHandle); - if (errorMessage && !finalText) { - return { - success: false, - text: "", - error: errorMessage, - toolUsage, - stopReason, - events: [], // streamed to file - }; - } + if (errorMessage && !finalText) { + return { + success: false, + text: "", + error: errorMessage, + toolUsage, + stopReason, + events: [], // streamed to file + }; + } - return { - success: true, - text: finalText.trim(), - toolUsage, - stopReason, - events: [], - }; - } catch (error) { - if (timeoutHandle) clearTimeout(timeoutHandle); - return { - success: false, - text: "", - error: error instanceof Error ? error.message : String(error), - toolUsage, - events: [], - }; - } finally { - sessionRef.session?.dispose(); - } + return { + success: true, + text: finalText.trim(), + toolUsage, + stopReason, + events: [], + }; + } catch (error) { + if (timeoutHandle) clearTimeout(timeoutHandle); + return { + success: false, + text: "", + error: error instanceof Error ? error.message : String(error), + toolUsage, + events: [], + }; + } finally { + sessionRef.session?.dispose(); + } } /** * Extract assistant text from message content (text blocks only). */ function extractAssistantText(content: unknown): string { - if (typeof content === "string") return content; - if (!Array.isArray(content)) return ""; - return content - .filter( - (c): c is { type: string; text?: string } => - !!c && - typeof c === "object" && - (c as { type?: string }).type === "text", - ) - .map((c) => (c as { text?: string }).text ?? "") - .join(""); + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + return content + .filter( + (c): c is { type: string; text?: string } => + !!c && + typeof c === "object" && + (c as { type?: string }).type === "text", + ) + .map((c) => (c as { text?: string }).text ?? "") + .join(""); } // ─── Git Commit Capture ────────────────────────────────────────────────────── @@ -721,16 +724,16 @@ function extractAssistantText(content: unknown): string { * that still needs committing. */ export function hasUncommittedChanges(projectDir: string): boolean { - const { execSync } = require("node:child_process"); - try { - const output = execSync("git status --porcelain", { - cwd: projectDir, - encoding: "utf-8", - }).trim(); - return output.length > 0; - } catch { - return false; - } + const { execSync } = require("node:child_process"); + try { + const output = execSync("git status --porcelain", { + cwd: projectDir, + encoding: "utf-8", + }).trim(); + return output.length > 0; + } catch { + return false; + } } /** @@ -745,18 +748,18 @@ export function hasUncommittedChanges(projectDir: string): boolean { * would strand committed code in `.ralpi/worktrees/` forever. */ export function hasTrackedUncommittedChanges(projectDir: string): boolean { - const { execSync } = require("node:child_process"); - try { - const output = execSync("git status --porcelain", { - cwd: projectDir, - encoding: "utf-8", - }).trim(); - return output - .split("\n") - .some((line: string) => line.length > 0 && !line.startsWith("??")); - } catch { - return false; - } + const { execSync } = require("node:child_process"); + try { + const output = execSync("git status --porcelain", { + cwd: projectDir, + encoding: "utf-8", + }).trim(); + return output + .split("\n") + .some((line: string) => line.length > 0 && !line.startsWith("??")); + } catch { + return false; + } } /** @@ -764,30 +767,30 @@ export function hasTrackedUncommittedChanges(projectDir: string): boolean { * Includes untracked files, which `git diff` alone would miss. */ export function getGitStatusPorcelain(projectDir: string): string { - const { execSync } = require("node:child_process"); - try { - return execSync("git status --porcelain", { - cwd: projectDir, - encoding: "utf-8", - }).trim(); - } catch { - return ""; - } + const { execSync } = require("node:child_process"); + try { + return execSync("git status --porcelain", { + cwd: projectDir, + encoding: "utf-8", + }).trim(); + } catch { + return ""; + } } /** * Get the current git diff for tracked uncommitted changes. */ export function getGitDiff(projectDir: string): string { - const { execSync } = require("node:child_process"); - try { - return execSync("git diff", { - cwd: projectDir, - encoding: "utf-8", - }).trim(); - } catch { - return ""; - } + const { execSync } = require("node:child_process"); + try { + return execSync("git diff", { + cwd: projectDir, + encoding: "utf-8", + }).trim(); + } catch { + return ""; + } } /** @@ -795,49 +798,49 @@ export function getGitDiff(projectDir: string): string { * Returns commit messages and a summary string */ export function captureGitCommits(projectDir: string): { - commitMessages: string[]; - commitSummary: string; + commitMessages: string[]; + commitSummary: string; } { - const { execSync } = require("node:child_process"); + const { execSync } = require("node:child_process"); - try { - // Check if this is a git repo - execSync("git rev-parse --git-dir", { cwd: projectDir, stdio: "pipe" }); - } catch { - return { commitMessages: [], commitSummary: "" }; - } + try { + // Check if this is a git repo + execSync("git rev-parse --git-dir", { cwd: projectDir, stdio: "pipe" }); + } catch { + return { commitMessages: [], commitSummary: "" }; + } - const commitMessages: string[] = []; - let commitSummary = ""; + const commitMessages: string[] = []; + let commitSummary = ""; - try { - // Get recent commits (last 5) with short hash and subject - const output = execSync("git log --oneline -5 --no-decorate", { - cwd: projectDir, - encoding: "utf-8", - }).trim(); + try { + // Get recent commits (last 5) with short hash and subject + const output = execSync("git log --oneline -5 --no-decorate", { + cwd: projectDir, + encoding: "utf-8", + }).trim(); - if (output) { - const lines = output.split("\n").filter((l: string) => l.trim()); - for (const line of lines) { - // Format: "abc1234 Commit message" - const parts = line.split(" ", 2); - if (parts.length >= 2) { - commitMessages.push(parts[1]); - } - } + if (output) { + const lines = output.split("\n").filter((l: string) => l.trim()); + for (const line of lines) { + // Format: "abc1234 Commit message" + const parts = line.split(" ", 2); + if (parts.length >= 2) { + commitMessages.push(parts[1]); + } + } - // Build summary from commit subjects - commitSummary = commitMessages.slice(0, 3).join("; "); - if (commitMessages.length > 3) { - commitSummary += ` (+${commitMessages.length - 3} more)`; - } - } - } catch { - // Git command failed, return empty - } + // Build summary from commit subjects + commitSummary = commitMessages.slice(0, 3).join("; "); + if (commitMessages.length > 3) { + commitSummary += ` (+${commitMessages.length - 3} more)`; + } + } + } catch { + // Git command failed, return empty + } - return { commitMessages, commitSummary }; + return { commitMessages, commitSummary }; } /** @@ -846,39 +849,39 @@ export function captureGitCommits(projectDir: string): { * Used by the auto-review agent to review a commit against the task. */ export function getLatestCommitDiff( - projectDir: string, + projectDir: string, ): { hash: string; subject: string; diff: string } | null { - const { execSync } = require("node:child_process"); + const { execSync } = require("node:child_process"); - try { - execSync("git rev-parse --git-dir", { cwd: projectDir, stdio: "pipe" }); - } catch { - return null; - } + try { + execSync("git rev-parse --git-dir", { cwd: projectDir, stdio: "pipe" }); + } catch { + return null; + } - try { - const hash = execSync("git rev-parse --short HEAD", { - cwd: projectDir, - encoding: "utf-8", - }).trim(); + try { + const hash = execSync("git rev-parse --short HEAD", { + cwd: projectDir, + encoding: "utf-8", + }).trim(); - const subject = execSync("git log -1 --format=%s", { - cwd: projectDir, - encoding: "utf-8", - }).trim(); + const subject = execSync("git log -1 --format=%s", { + cwd: projectDir, + encoding: "utf-8", + }).trim(); - // Full diff of the latest commit: stat overview + patch. - // maxBuffer set high — the prompt builder truncates to MAX_DIFF_BYTES. - const diff = execSync("git show HEAD --stat --patch", { - cwd: projectDir, - encoding: "utf-8", - maxBuffer: 10 * 1024 * 1024, - }).trim(); + // Full diff of the latest commit: stat overview + patch. + // maxBuffer set high — the prompt builder truncates to MAX_DIFF_BYTES. + const diff = execSync("git show HEAD --stat --patch", { + cwd: projectDir, + encoding: "utf-8", + maxBuffer: 10 * 1024 * 1024, + }).trim(); - return { hash, subject, diff }; - } catch { - return null; - } + return { hash, subject, diff }; + } catch { + return null; + } } /** @@ -888,18 +891,18 @@ export function getLatestCommitDiff( * output (baseRef..HEAD) — including any commits the task agent makes. */ export function captureGitHead(projectDir: string): string | undefined { - const { execSync } = require("node:child_process"); - try { - const sha = execSync("git rev-parse HEAD", { - cwd: projectDir, - encoding: "utf-8", - stdio: ["pipe", "pipe", "pipe"], - }).trim(); - // Guard against injection — only accept hex SHAs. - return /^[0-9a-f]{7,40}$/i.test(sha) ? sha : undefined; - } catch { - return undefined; - } + const { execSync } = require("node:child_process"); + try { + const sha = execSync("git rev-parse HEAD", { + cwd: projectDir, + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + }).trim(); + // Guard against injection — only accept hex SHAs. + return /^[0-9a-f]{7,40}$/i.test(sha) ? sha : undefined; + } catch { + return undefined; + } } /** @@ -911,49 +914,49 @@ export function captureGitHead(projectDir: string): string | undefined { * or null when git is unavailable / baseRef is invalid / no changes exist. */ export function getCommitRangeDiff( - projectDir: string, - baseRef: string, + projectDir: string, + baseRef: string, ): { hash: string; subject: string; diff: string } | null { - const { execSync } = require("node:child_process"); + const { execSync } = require("node:child_process"); - // Only pass validated hex SHAs to the shell. - if (!/^[0-9a-f]{7,40}$/i.test(baseRef)) return null; + // Only pass validated hex SHAs to the shell. + if (!/^[0-9a-f]{7,40}$/i.test(baseRef)) return null; - try { - execSync("git rev-parse --git-dir", { - cwd: projectDir, - stdio: "pipe", - }); - } catch { - return null; - } + try { + execSync("git rev-parse --git-dir", { + cwd: projectDir, + stdio: "pipe", + }); + } catch { + return null; + } - try { - const hash = execSync("git rev-parse --short HEAD", { - cwd: projectDir, - encoding: "utf-8", - }).trim(); + try { + const hash = execSync("git rev-parse --short HEAD", { + cwd: projectDir, + encoding: "utf-8", + }).trim(); - const subject = execSync("git log -1 --format=%s", { - cwd: projectDir, - encoding: "utf-8", - }).trim(); + const subject = execSync("git log -1 --format=%s", { + cwd: projectDir, + encoding: "utf-8", + }).trim(); - // Diff from baseRef to HEAD — shows all committed changes made since - // the snapshot. Includes stat overview + full patch. - // - // maxBuffer is set high (10 MB) so larger tasks don't cause execSync to - // throw. The review prompt builder truncates to MAX_DIFF_BYTES (50 KB) - // before sending to the model, so the full diff in memory is fine. - const diff = execSync(`git diff ${baseRef} HEAD --stat --patch`, { - cwd: projectDir, - encoding: "utf-8", - maxBuffer: 10 * 1024 * 1024, - }).trim(); + // Diff from baseRef to HEAD — shows all committed changes made since + // the snapshot. Includes stat overview + full patch. + // + // maxBuffer is set high (10 MB) so larger tasks don't cause execSync to + // throw. The review prompt builder truncates to MAX_DIFF_BYTES (50 KB) + // before sending to the model, so the full diff in memory is fine. + const diff = execSync(`git diff ${baseRef} HEAD --stat --patch`, { + cwd: projectDir, + encoding: "utf-8", + maxBuffer: 10 * 1024 * 1024, + }).trim(); - if (!diff) return null; // no changes since baseRef - return { hash, subject, diff }; - } catch { - return null; - } + if (!diff) return null; // no changes since baseRef + return { hash, subject, diff }; + } catch { + return null; + } }