reference updates
This commit is contained in:
@@ -1,19 +1,25 @@
|
||||
import type { RalphConfig } from "./types";
|
||||
import { DEFAULT_CONFIG } from "./types";
|
||||
|
||||
export { DEFAULT_CONFIG };
|
||||
|
||||
// CLI
|
||||
export const SLASH_COMMAND = "/ralph";
|
||||
export const COMMANDS = ["run", "plan", "status", "resume", "next", "reset"] as const;
|
||||
export const SLASH_COMMAND = "/ralpi";
|
||||
export const COMMANDS = [
|
||||
"run",
|
||||
"plan",
|
||||
"status",
|
||||
"resume",
|
||||
"next",
|
||||
"reset",
|
||||
] as const;
|
||||
|
||||
// Task file detection
|
||||
export const TASK_FILE_NAMES = [
|
||||
"README.md",
|
||||
"PRD.md",
|
||||
"tasks.md",
|
||||
"tasks.yaml",
|
||||
"tasks.yml",
|
||||
"README.md",
|
||||
"PRD.md",
|
||||
"tasks.md",
|
||||
"tasks.yaml",
|
||||
"tasks.yml",
|
||||
] as const;
|
||||
|
||||
// Reflection parsing
|
||||
|
||||
657
src/executor.ts
657
src/executor.ts
@@ -1,29 +1,29 @@
|
||||
import * as path from "node:path";
|
||||
import type { Task, Project, Reflection, ToolUsage } from "./types";
|
||||
import type { RalphConfig } from "./types";
|
||||
import type { RalpiConfig } from "./types";
|
||||
import type { ProgressTracker } from "./progress";
|
||||
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import { buildTaskPrompt } from "./prompts";
|
||||
import { extractReflection } from "./reflection";
|
||||
import { WidgetBatcher } from "./widget-batcher";
|
||||
import {
|
||||
runAgentSession,
|
||||
writeFileSafe,
|
||||
ensureDir,
|
||||
captureGitCommits,
|
||||
formatDuration,
|
||||
runAgentSession,
|
||||
writeFileSafe,
|
||||
ensureDir,
|
||||
captureGitCommits,
|
||||
formatDuration,
|
||||
} from "./utils";
|
||||
|
||||
/** Optional callback to post a progress message into the chat history. */
|
||||
export type SendChatMessage = (
|
||||
content: string,
|
||||
/** Extra data passed to the message renderer for the expanded view. */
|
||||
meta?: { toolCalls?: ToolCallEntry[] },
|
||||
content: string,
|
||||
/** Extra data passed to the message renderer for the expanded view. */
|
||||
meta?: { toolCalls?: ToolCallEntry[] },
|
||||
) => void;
|
||||
|
||||
export interface ToolCallEntry {
|
||||
name: string;
|
||||
label: string;
|
||||
name: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
// ─── Run Single Task ────────────────────────────────────────────────────────
|
||||
@@ -33,176 +33,176 @@ export interface ToolCallEntry {
|
||||
* Non-blocking — the TUI remains responsive throughout.
|
||||
*/
|
||||
export async function runTask(
|
||||
task: Task,
|
||||
project: Project,
|
||||
config: RalphConfig,
|
||||
depReflections: Reflection[],
|
||||
ctx: ExtensionContext,
|
||||
sendChatMessage?: SendChatMessage,
|
||||
projectDir: string = project.sourceDir,
|
||||
batcher?: WidgetBatcher,
|
||||
task: Task,
|
||||
project: Project,
|
||||
config: RalpiConfig,
|
||||
depReflections: Reflection[],
|
||||
ctx: ExtensionContext,
|
||||
sendChatMessage?: SendChatMessage,
|
||||
projectDir: string = project.sourceDir,
|
||||
batcher?: WidgetBatcher,
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
reflection?: Reflection;
|
||||
error?: string;
|
||||
durationMs: number;
|
||||
toolUsage?: ToolUsage;
|
||||
outputPreview?: string;
|
||||
sessionFile?: string;
|
||||
commitMessages?: string[];
|
||||
commitSummary?: string;
|
||||
success: boolean;
|
||||
reflection?: Reflection;
|
||||
error?: string;
|
||||
durationMs: number;
|
||||
toolUsage?: ToolUsage;
|
||||
outputPreview?: string;
|
||||
sessionFile?: string;
|
||||
commitMessages?: string[];
|
||||
commitSummary?: string;
|
||||
}> {
|
||||
const startMs = Date.now();
|
||||
const startMs = Date.now();
|
||||
|
||||
// Build prompt
|
||||
const prompt = buildTaskPrompt(
|
||||
task,
|
||||
project,
|
||||
depReflections,
|
||||
config.prompts.projectContext,
|
||||
);
|
||||
// Build prompt
|
||||
const prompt = buildTaskPrompt(
|
||||
task,
|
||||
project,
|
||||
depReflections,
|
||||
config.prompts.projectContext,
|
||||
);
|
||||
|
||||
// Write prompt to .ralph/ with timestamp (for debugging)
|
||||
const ralphDir = path.join(projectDir, ".ralph");
|
||||
ensureDir(ralphDir);
|
||||
const promptFile = path.join(ralphDir, `prompt-${startMs}.md`);
|
||||
writeFileSafe(promptFile, prompt);
|
||||
// Write prompt to .ralpi/ with timestamp (for debugging)
|
||||
const ralpiDir = path.join(projectDir, ".ralpi");
|
||||
ensureDir(ralpiDir);
|
||||
const promptFile = path.join(ralpiDir, `prompt-${startMs}.md`);
|
||||
writeFileSafe(promptFile, prompt);
|
||||
|
||||
// Footer shows just the task title (no batch prefix)
|
||||
ctx.ui.setStatus("ralph", task.title);
|
||||
// Footer shows just the task title (no batch prefix)
|
||||
ctx.ui.setStatus("ralpi", task.title);
|
||||
|
||||
const taskHeader = `${task.id} · ${task.title}`;
|
||||
const taskHeader = `${task.id} · ${task.title}`;
|
||||
|
||||
// Live progress widget above the editor — animated spinner + tool call tree
|
||||
// Using setWidget instead of setWorkingMessage because the working message area
|
||||
// is only visible during parent agent streaming, not during extension command execution.
|
||||
// Widget key is unique per task so parallel tasks each get their own widget.
|
||||
const widgetKey = `ralph-task-${task.id}`;
|
||||
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||
let frameIndex = 0;
|
||||
const theme = ctx.ui.theme;
|
||||
const MAX_COLLAPSED = 3;
|
||||
// Live progress widget above the editor — animated spinner + tool call tree
|
||||
// Using setWidget instead of setWorkingMessage because the working message area
|
||||
// is only visible during parent agent streaming, not during extension command execution.
|
||||
// Widget key is unique per task so parallel tasks each get their own widget.
|
||||
const widgetKey = `ralpi-task-${task.id}`;
|
||||
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||
let frameIndex = 0;
|
||||
const theme = ctx.ui.theme;
|
||||
const MAX_COLLAPSED = 3;
|
||||
|
||||
const toolCalls: ToolCallEntry[] = [];
|
||||
const toolCalls: ToolCallEntry[] = [];
|
||||
|
||||
const updateWidget = () => {
|
||||
const frame = theme.fg("accent", SPINNER_FRAMES[frameIndex]);
|
||||
const lines = [`${frame} ${taskHeader}`];
|
||||
const updateWidget = () => {
|
||||
const frame = theme.fg("accent", SPINNER_FRAMES[frameIndex]);
|
||||
const lines = [`${frame} ${taskHeader}`];
|
||||
|
||||
if (toolCalls.length > 0) {
|
||||
const shown = toolCalls.slice(-MAX_COLLAPSED);
|
||||
const remaining = toolCalls.length - shown.length;
|
||||
if (toolCalls.length > 0) {
|
||||
const shown = toolCalls.slice(-MAX_COLLAPSED);
|
||||
const remaining = toolCalls.length - shown.length;
|
||||
|
||||
if (remaining > 0) {
|
||||
lines.push(theme.fg("dim", ` ├── ${remaining} more`));
|
||||
}
|
||||
if (remaining > 0) {
|
||||
lines.push(theme.fg("dim", ` ├── ${remaining} more`));
|
||||
}
|
||||
|
||||
for (let i = 0; i < shown.length; i++) {
|
||||
const entry = shown[i];
|
||||
const isLast = i === shown.length - 1;
|
||||
const branch = isLast ? " └── " : " ├── ";
|
||||
const tag = theme.fg("accent", `[${entry.name}]`);
|
||||
lines.push(`${branch}${tag} ${entry.label}`);
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < shown.length; i++) {
|
||||
const entry = shown[i];
|
||||
const isLast = i === shown.length - 1;
|
||||
const branch = isLast ? " └── " : " ├── ";
|
||||
const tag = theme.fg("accent", `[${entry.name}]`);
|
||||
lines.push(`${branch}${tag} ${entry.label}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (batcher) {
|
||||
batcher.schedule(widgetKey, lines);
|
||||
} else {
|
||||
ctx.ui.setWidget(widgetKey, lines);
|
||||
}
|
||||
};
|
||||
if (batcher) {
|
||||
batcher.schedule(widgetKey, lines);
|
||||
} else {
|
||||
ctx.ui.setWidget(widgetKey, lines);
|
||||
}
|
||||
};
|
||||
|
||||
// Smooth spinner animation at 100ms intervals
|
||||
const spinnerTimer = setInterval(() => {
|
||||
frameIndex = (frameIndex + 1) % SPINNER_FRAMES.length;
|
||||
updateWidget();
|
||||
}, 100);
|
||||
// Smooth spinner animation at 100ms intervals
|
||||
const spinnerTimer = setInterval(() => {
|
||||
frameIndex = (frameIndex + 1) % SPINNER_FRAMES.length;
|
||||
updateWidget();
|
||||
}, 100);
|
||||
|
||||
// Initial display
|
||||
updateWidget();
|
||||
// Initial display
|
||||
updateWidget();
|
||||
|
||||
// Use task-level timeout if set, otherwise fall back to config
|
||||
const timeoutMs = task.timeoutMs ?? config.execution.timeoutMs;
|
||||
// Use task-level timeout if set, otherwise fall back to config
|
||||
const timeoutMs = task.timeoutMs ?? config.execution.timeoutMs;
|
||||
|
||||
// Pre-create session file path so events stream to disk (avoids 300+ MB in-memory accumulation)
|
||||
const sessionsDir = path.join(ralphDir, "sessions");
|
||||
ensureDir(sessionsDir);
|
||||
const sessionFilePath = path.join(sessionsDir, `${task.id}-${startMs}.txt`);
|
||||
// Pre-create session file path so events stream to disk (avoids 300+ MB in-memory accumulation)
|
||||
const sessionsDir = path.join(ralpiDir, "sessions");
|
||||
ensureDir(sessionsDir);
|
||||
const sessionFilePath = path.join(sessionsDir, `${task.id}-${startMs}.txt`);
|
||||
|
||||
// Run task asynchronously via Pi SDK — event loop stays responsive
|
||||
const output = await runAgentSession(
|
||||
prompt,
|
||||
projectDir,
|
||||
timeoutMs,
|
||||
(event) => {
|
||||
if (event.type === "tool_execution_start") {
|
||||
const label = formatToolArg(event.toolName, event.args);
|
||||
toolCalls.push({
|
||||
name: event.toolName,
|
||||
label,
|
||||
});
|
||||
updateWidget();
|
||||
}
|
||||
},
|
||||
undefined, // no abort signal
|
||||
sessionFilePath, // stream events to file
|
||||
);
|
||||
// Run task asynchronously via Pi SDK — event loop stays responsive
|
||||
const output = await runAgentSession(
|
||||
prompt,
|
||||
projectDir,
|
||||
timeoutMs,
|
||||
(event) => {
|
||||
if (event.type === "tool_execution_start") {
|
||||
const label = formatToolArg(event.toolName, event.args);
|
||||
toolCalls.push({
|
||||
name: event.toolName,
|
||||
label,
|
||||
});
|
||||
updateWidget();
|
||||
}
|
||||
},
|
||||
undefined, // no abort signal
|
||||
sessionFilePath, // stream events to file
|
||||
);
|
||||
|
||||
const durationMs = Date.now() - startMs;
|
||||
const durationMs = Date.now() - startMs;
|
||||
|
||||
// Clear progress widget and status after task finishes
|
||||
clearInterval(spinnerTimer);
|
||||
if (batcher) {
|
||||
batcher.scheduleRemove(widgetKey);
|
||||
} else {
|
||||
ctx.ui.setWidget(widgetKey, undefined);
|
||||
}
|
||||
ctx.ui.setStatus("ralph", undefined);
|
||||
// Clear progress widget and status after task finishes
|
||||
clearInterval(spinnerTimer);
|
||||
if (batcher) {
|
||||
batcher.scheduleRemove(widgetKey);
|
||||
} else {
|
||||
ctx.ui.setWidget(widgetKey, undefined);
|
||||
}
|
||||
ctx.ui.setStatus("ralpi", undefined);
|
||||
|
||||
if (!output.success) {
|
||||
sendChatMessage?.(`✗ ${taskHeader} — ${output.error}`);
|
||||
ctx.ui.notify(`Task ${task.id} failed: ${output.error}`, "error");
|
||||
return {
|
||||
success: false,
|
||||
error: output.error,
|
||||
durationMs,
|
||||
sessionFile: sessionFilePath, // events streamed to file for debugging
|
||||
};
|
||||
}
|
||||
if (!output.success) {
|
||||
sendChatMessage?.(`✗ ${taskHeader} — ${output.error}`);
|
||||
ctx.ui.notify(`Task ${task.id} failed: ${output.error}`, "error");
|
||||
return {
|
||||
success: false,
|
||||
error: output.error,
|
||||
durationMs,
|
||||
sessionFile: sessionFilePath, // events streamed to file for debugging
|
||||
};
|
||||
}
|
||||
|
||||
const agentText = output.text;
|
||||
const toolUsage = output.toolUsage;
|
||||
const agentText = output.text;
|
||||
const toolUsage = output.toolUsage;
|
||||
|
||||
// Capture git commits made during this task
|
||||
const { commitMessages, commitSummary } = captureGitCommits(projectDir);
|
||||
// Capture git commits made during this task
|
||||
const { commitMessages, commitSummary } = captureGitCommits(projectDir);
|
||||
|
||||
// Session file already written by runAgentSession (events streamed to disk)
|
||||
const sessionFile = sessionFilePath;
|
||||
// Session file already written by runAgentSession (events streamed to disk)
|
||||
const sessionFile = sessionFilePath;
|
||||
|
||||
// Build output preview (first 500 chars of agent text)
|
||||
const outputPreview =
|
||||
agentText.length > 500
|
||||
? agentText.slice(0, 500) + "\n... (truncated, see session file)"
|
||||
: agentText;
|
||||
// Build output preview (first 500 chars of agent text)
|
||||
const outputPreview =
|
||||
agentText.length > 500
|
||||
? agentText.slice(0, 500) + "\n... (truncated, see session file)"
|
||||
: agentText;
|
||||
|
||||
// Extract reflection from agent output
|
||||
const reflection = extractReflection(agentText, task.id, task.title);
|
||||
// Extract reflection from agent output
|
||||
const reflection = extractReflection(agentText, task.id, task.title);
|
||||
|
||||
// Post completion chat message — header only, renderer builds the expandable tree
|
||||
const dur = formatDuration(durationMs);
|
||||
sendChatMessage?.(`✓ ${taskHeader} (${dur})`, { toolCalls });
|
||||
// Post completion chat message — header only, renderer builds the expandable tree
|
||||
const dur = formatDuration(durationMs);
|
||||
sendChatMessage?.(`✓ ${taskHeader} (${dur})`, { toolCalls });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
reflection: reflection ?? undefined,
|
||||
durationMs,
|
||||
toolUsage,
|
||||
outputPreview,
|
||||
sessionFile,
|
||||
commitMessages,
|
||||
commitSummary,
|
||||
};
|
||||
return {
|
||||
success: true,
|
||||
reflection: reflection ?? undefined,
|
||||
durationMs,
|
||||
toolUsage,
|
||||
outputPreview,
|
||||
sessionFile,
|
||||
commitMessages,
|
||||
commitSummary,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Execute Batch ───────────────────────────────────────────────────────────
|
||||
@@ -211,198 +211,198 @@ export async function runTask(
|
||||
* Execute a batch of tasks (sequentially or in parallel)
|
||||
*/
|
||||
export async function executeBatch(
|
||||
tasks: Task[],
|
||||
project: Project,
|
||||
config: RalphConfig,
|
||||
progress: ProgressTracker,
|
||||
ctx: ExtensionContext,
|
||||
options?: { parallel?: boolean },
|
||||
sendChatMessage?: SendChatMessage,
|
||||
projectDir?: string,
|
||||
tasks: Task[],
|
||||
project: Project,
|
||||
config: RalpiConfig,
|
||||
progress: ProgressTracker,
|
||||
ctx: ExtensionContext,
|
||||
options?: { parallel?: boolean },
|
||||
sendChatMessage?: SendChatMessage,
|
||||
projectDir?: string,
|
||||
): Promise<void> {
|
||||
// Defensive: ensure tasks is an iterable array
|
||||
if (!Array.isArray(tasks)) {
|
||||
throw new Error(
|
||||
`executeBatch received invalid tasks: expected array, got ${typeof tasks}`,
|
||||
);
|
||||
}
|
||||
// Defensive: ensure tasks is an iterable array
|
||||
if (!Array.isArray(tasks)) {
|
||||
throw new Error(
|
||||
`executeBatch received invalid tasks: expected array, got ${typeof tasks}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Check if we should run parallel
|
||||
const shouldParallel =
|
||||
options?.parallel && tasks.length > 1 && config.execution.maxParallel > 0;
|
||||
// Check if we should run parallel
|
||||
const shouldParallel =
|
||||
options?.parallel && tasks.length > 1 && config.execution.maxParallel > 0;
|
||||
|
||||
if (shouldParallel) {
|
||||
await executeBatchParallel(
|
||||
tasks,
|
||||
project,
|
||||
config,
|
||||
progress,
|
||||
ctx,
|
||||
sendChatMessage,
|
||||
projectDir,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (shouldParallel) {
|
||||
await executeBatchParallel(
|
||||
tasks,
|
||||
project,
|
||||
config,
|
||||
progress,
|
||||
ctx,
|
||||
sendChatMessage,
|
||||
projectDir,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Execute sequentially
|
||||
for (const task of tasks) {
|
||||
await executeTask(
|
||||
task,
|
||||
project,
|
||||
config,
|
||||
progress,
|
||||
ctx,
|
||||
sendChatMessage,
|
||||
projectDir,
|
||||
);
|
||||
}
|
||||
// Execute sequentially
|
||||
for (const task of tasks) {
|
||||
await executeTask(
|
||||
task,
|
||||
project,
|
||||
config,
|
||||
progress,
|
||||
ctx,
|
||||
sendChatMessage,
|
||||
projectDir,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute tasks in parallel using child processes
|
||||
*/
|
||||
async function executeBatchParallel(
|
||||
tasks: Task[],
|
||||
project: Project,
|
||||
config: RalphConfig,
|
||||
progress: ProgressTracker,
|
||||
ctx: ExtensionContext,
|
||||
sendChatMessage?: SendChatMessage,
|
||||
projectDir?: string,
|
||||
tasks: Task[],
|
||||
project: Project,
|
||||
config: RalpiConfig,
|
||||
progress: ProgressTracker,
|
||||
ctx: ExtensionContext,
|
||||
sendChatMessage?: SendChatMessage,
|
||||
projectDir?: string,
|
||||
): Promise<void> {
|
||||
const maxParallel = config.execution.maxParallel;
|
||||
const batcher = new WidgetBatcher(ctx);
|
||||
const results: Array<{ task: Task; result: Promise<any> }> = [];
|
||||
const maxParallel = config.execution.maxParallel;
|
||||
const batcher = new WidgetBatcher(ctx);
|
||||
const results: Array<{ task: Task; result: Promise<any> }> = [];
|
||||
|
||||
for (const task of tasks) {
|
||||
results.push({
|
||||
task,
|
||||
result: executeTask(
|
||||
task,
|
||||
project,
|
||||
config,
|
||||
progress,
|
||||
ctx,
|
||||
sendChatMessage,
|
||||
projectDir,
|
||||
batcher,
|
||||
),
|
||||
});
|
||||
for (const task of tasks) {
|
||||
results.push({
|
||||
task,
|
||||
result: executeTask(
|
||||
task,
|
||||
project,
|
||||
config,
|
||||
progress,
|
||||
ctx,
|
||||
sendChatMessage,
|
||||
projectDir,
|
||||
batcher,
|
||||
),
|
||||
});
|
||||
|
||||
// Limit concurrency
|
||||
if (results.length >= maxParallel) {
|
||||
const first = results.shift();
|
||||
if (first) await first.result;
|
||||
}
|
||||
}
|
||||
// Limit concurrency
|
||||
if (results.length >= maxParallel) {
|
||||
const first = results.shift();
|
||||
if (first) await first.result;
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for remaining tasks
|
||||
for (const { result } of results) {
|
||||
await result;
|
||||
}
|
||||
// Wait for remaining tasks
|
||||
for (const { result } of results) {
|
||||
await result;
|
||||
}
|
||||
|
||||
// Flush and stop the batcher after all tasks complete
|
||||
batcher.stop();
|
||||
// Flush and stop the batcher after all tasks complete
|
||||
batcher.stop();
|
||||
}
|
||||
|
||||
// ─── Execute Single Task with Retry ──────────────────────────────────────────
|
||||
|
||||
async function executeTask(
|
||||
task: Task,
|
||||
project: Project,
|
||||
config: RalphConfig,
|
||||
progress: ProgressTracker,
|
||||
ctx: ExtensionContext,
|
||||
sendChatMessage?: SendChatMessage,
|
||||
projectDir: string = project.sourceDir,
|
||||
batcher?: WidgetBatcher,
|
||||
task: Task,
|
||||
project: Project,
|
||||
config: RalpiConfig,
|
||||
progress: ProgressTracker,
|
||||
ctx: ExtensionContext,
|
||||
sendChatMessage?: SendChatMessage,
|
||||
projectDir: string = project.sourceDir,
|
||||
batcher?: WidgetBatcher,
|
||||
): Promise<void> {
|
||||
const maxRetries = config.execution.maxRetries;
|
||||
let retries = 0;
|
||||
const maxRetries = config.execution.maxRetries;
|
||||
let retries = 0;
|
||||
|
||||
while (retries <= maxRetries) {
|
||||
try {
|
||||
// Mark as in progress
|
||||
progress.markInProgress(task.id);
|
||||
while (retries <= maxRetries) {
|
||||
try {
|
||||
// Mark as in progress
|
||||
progress.markInProgress(task.id);
|
||||
|
||||
// Get dependency reflections
|
||||
const depReflections = progress.getDependencyReflections(
|
||||
task.dependencies || [],
|
||||
);
|
||||
// Get dependency reflections
|
||||
const depReflections = progress.getDependencyReflections(
|
||||
task.dependencies || [],
|
||||
);
|
||||
|
||||
// Run the task
|
||||
const result = await runTask(
|
||||
task,
|
||||
project,
|
||||
config,
|
||||
depReflections,
|
||||
ctx,
|
||||
sendChatMessage,
|
||||
projectDir,
|
||||
batcher,
|
||||
);
|
||||
// Run the task
|
||||
const result = await runTask(
|
||||
task,
|
||||
project,
|
||||
config,
|
||||
depReflections,
|
||||
ctx,
|
||||
sendChatMessage,
|
||||
projectDir,
|
||||
batcher,
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
// Save reflection
|
||||
if (result.reflection) {
|
||||
saveReflectionToFile(projectDir, config, result.reflection);
|
||||
}
|
||||
if (result.success) {
|
||||
// Save reflection
|
||||
if (result.reflection) {
|
||||
saveReflectionToFile(projectDir, config, result.reflection);
|
||||
}
|
||||
|
||||
// Mark completed with all metadata
|
||||
progress.markCompleted(
|
||||
task.id,
|
||||
result.durationMs,
|
||||
result.reflection,
|
||||
result.toolUsage,
|
||||
result.sessionFile,
|
||||
result.outputPreview,
|
||||
result.commitMessages,
|
||||
result.commitSummary,
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Mark completed with all metadata
|
||||
progress.markCompleted(
|
||||
task.id,
|
||||
result.durationMs,
|
||||
result.reflection,
|
||||
result.toolUsage,
|
||||
result.sessionFile,
|
||||
result.outputPreview,
|
||||
result.commitMessages,
|
||||
result.commitSummary,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Task failed, check if we should retry
|
||||
if (retries < maxRetries) {
|
||||
retries = progress.incrementRetry(task.id);
|
||||
ctx.ui.notify(
|
||||
`Retrying task ${task.id} (${retries}/${maxRetries}): ${result.error}`,
|
||||
"warning",
|
||||
);
|
||||
// Task failed, check if we should retry
|
||||
if (retries < maxRetries) {
|
||||
retries = progress.incrementRetry(task.id);
|
||||
ctx.ui.notify(
|
||||
`Retrying task ${task.id} (${retries}/${maxRetries}): ${result.error}`,
|
||||
"warning",
|
||||
);
|
||||
|
||||
// Exponential backoff
|
||||
const delay = config.execution.retryDelayMs * 2 ** (retries - 1);
|
||||
await sleep(delay);
|
||||
} else {
|
||||
// Max retries exceeded
|
||||
progress.markFailed(task.id, result.error || "Unknown error");
|
||||
throw new Error(`Task ${task.id} failed: ${result.error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
progress.markFailed(task.id, errorMsg);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
// Exponential backoff
|
||||
const delay = config.execution.retryDelayMs * 2 ** (retries - 1);
|
||||
await sleep(delay);
|
||||
} else {
|
||||
// Max retries exceeded
|
||||
progress.markFailed(task.id, result.error || "Unknown error");
|
||||
throw new Error(`Task ${task.id} failed: ${result.error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
progress.markFailed(task.id, errorMsg);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Save Reflection to File ────────────────────────────────────────────────
|
||||
|
||||
function saveReflectionToFile(
|
||||
sourceDir: string,
|
||||
config: RalphConfig,
|
||||
reflection: Reflection,
|
||||
sourceDir: string,
|
||||
config: RalpiConfig,
|
||||
reflection: Reflection,
|
||||
): void {
|
||||
const reflectionsDir = path.join(sourceDir, config.paths.reflectionsDir);
|
||||
ensureDir(reflectionsDir);
|
||||
const filePath = path.join(reflectionsDir, `${reflection.taskId}.json`);
|
||||
writeFileSafe(filePath, JSON.stringify(reflection, null, 2));
|
||||
const reflectionsDir = path.join(sourceDir, config.paths.reflectionsDir);
|
||||
ensureDir(reflectionsDir);
|
||||
const filePath = path.join(reflectionsDir, `${reflection.taskId}.json`);
|
||||
writeFileSafe(filePath, JSON.stringify(reflection, null, 2));
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
// ─── Tool Call Formatting ────────────────────────────────────────────────
|
||||
@@ -411,31 +411,34 @@ function sleep(ms: number): Promise<void> {
|
||||
* Format a tool call argument into a short label.
|
||||
*/
|
||||
function formatToolArg(name: string, args: unknown): string {
|
||||
const a = args as Record<string, unknown>;
|
||||
switch (name) {
|
||||
case "bash":
|
||||
return truncateMiddle(String(a.command ?? ""), 70);
|
||||
case "write":
|
||||
case "read":
|
||||
return truncateMiddle(String(a.path ?? ""), 60);
|
||||
case "edit":
|
||||
return truncateMiddle(String(a.path ?? ""), 60);
|
||||
case "grep":
|
||||
return `${a.pattern ?? "?"} — ${truncateMiddle(String(a.path ?? ""), 40)}`;
|
||||
case "find":
|
||||
return `${a.path ?? "."} — ${a.glob ?? "*"}`;
|
||||
case "ls":
|
||||
return truncateMiddle(String(a.path ?? "."), 60);
|
||||
default:
|
||||
return name;
|
||||
}
|
||||
const a = args as Record<string, unknown>;
|
||||
switch (name) {
|
||||
case "bash":
|
||||
return truncateMiddle(String(a.command ?? ""), 70);
|
||||
case "write":
|
||||
case "read":
|
||||
return truncateMiddle(String(a.path ?? ""), 60);
|
||||
case "edit":
|
||||
return truncateMiddle(String(a.path ?? ""), 60);
|
||||
case "grep":
|
||||
return `${a.pattern ?? "?"} — ${truncateMiddle(
|
||||
String(a.path ?? ""),
|
||||
40,
|
||||
)}`;
|
||||
case "find":
|
||||
return `${a.path ?? "."} — ${a.glob ?? "*"}`;
|
||||
case "ls":
|
||||
return truncateMiddle(String(a.path ?? "."), 60);
|
||||
default:
|
||||
return name;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate a long string in the middle, keeping start and end visible.
|
||||
*/
|
||||
function truncateMiddle(s: string, maxLen: number): string {
|
||||
if (s.length <= maxLen) return s;
|
||||
const half = Math.floor((maxLen - 3) / 2);
|
||||
return s.slice(0, half) + "…" + s.slice(s.length - half);
|
||||
if (s.length <= maxLen) return s;
|
||||
const half = Math.floor((maxLen - 3) / 2);
|
||||
return s.slice(0, half) + "…" + s.slice(s.length - half);
|
||||
}
|
||||
|
||||
456
src/progress.ts
456
src/progress.ts
@@ -1,6 +1,12 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import type { ProgressState, PRDProgress, Task, Reflection, ToolUsage } from "./types";
|
||||
import type {
|
||||
ProgressState,
|
||||
PRDProgress,
|
||||
Task,
|
||||
Reflection,
|
||||
ToolUsage,
|
||||
} from "./types";
|
||||
import { ensureDir } from "./utils";
|
||||
|
||||
/**
|
||||
@@ -8,258 +14,264 @@ 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, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages persistent progress state for a ralph execution.
|
||||
* State is stored as JSON in .ralph/progress.json.
|
||||
* State is stored as JSON in .ralpi/progress.json.
|
||||
* Supports multiple PRDs in progress simultaneously via the `prds` field.
|
||||
* 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, ".ralph");
|
||||
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 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 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 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);
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/** Set paused state */
|
||||
setPaused(paused: boolean): void {
|
||||
const prd = this.getPRD();
|
||||
prd.paused = paused;
|
||||
this.save();
|
||||
}
|
||||
/** Set paused state */
|
||||
setPaused(paused: boolean): void {
|
||||
const prd = this.getPRD();
|
||||
prd.paused = paused;
|
||||
this.save();
|
||||
}
|
||||
|
||||
/** Get the raw PRD state (for status display) */
|
||||
getState(): PRDProgress {
|
||||
return this.getPRD();
|
||||
}
|
||||
/** Get the raw PRD state (for status display) */
|
||||
getState(): PRDProgress {
|
||||
return this.getPRD();
|
||||
}
|
||||
|
||||
/** Get all PRDs (for multi-PRD status display) */
|
||||
getAllPRDs(): Record<string, PRDProgress> {
|
||||
return this.state.prds ?? {};
|
||||
}
|
||||
/** Get all PRDs (for multi-PRD status display) */
|
||||
getAllPRDs(): Record<string, PRDProgress> {
|
||||
return this.state.prds ?? {};
|
||||
}
|
||||
|
||||
/** Get the PRD key for this tracker */
|
||||
getKey(): string {
|
||||
return this.prdKey;
|
||||
}
|
||||
/** Get the PRD key for this tracker */
|
||||
getKey(): string {
|
||||
return this.prdKey;
|
||||
}
|
||||
|
||||
/** Reset all progress for this PRD */
|
||||
reset(): void {
|
||||
const prd = this.getPRD();
|
||||
Object.assign(prd, this.freshPRD(prd.sourcePath));
|
||||
this.save();
|
||||
}
|
||||
/** 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 };
|
||||
}
|
||||
}
|
||||
private ensureTask(prd: PRDProgress, taskId: string): void {
|
||||
if (!prd.tasks[taskId]) {
|
||||
prd.tasks[taskId] = { status: "pending", retries: 0 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
10
src/types.ts
10
src/types.ts
@@ -137,9 +137,9 @@ export interface PRDProgress {
|
||||
|
||||
// ─── Configuration ────────────────────────────────────────────────────────────
|
||||
|
||||
export interface RalphConfig {
|
||||
export interface RalpiConfig {
|
||||
paths: {
|
||||
/** Directory for ralph state files */
|
||||
/** Directory for ralpi state files */
|
||||
stateDir: string;
|
||||
/** Directory for per-task reflections */
|
||||
reflectionsDir: string;
|
||||
@@ -162,10 +162,10 @@ export interface RalphConfig {
|
||||
};
|
||||
}
|
||||
|
||||
export const DEFAULT_CONFIG: RalphConfig = {
|
||||
export const DEFAULT_CONFIG: RalpiConfig = {
|
||||
paths: {
|
||||
stateDir: ".ralph",
|
||||
reflectionsDir: ".ralph/reflections",
|
||||
stateDir: ".ralpi",
|
||||
reflectionsDir: ".ralpi/reflections",
|
||||
},
|
||||
execution: {
|
||||
maxRetries: 3,
|
||||
|
||||
18
src/utils.ts
18
src/utils.ts
@@ -1,7 +1,7 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import type {
|
||||
RalphConfig,
|
||||
RalpiConfig,
|
||||
PRDProgress,
|
||||
ProgressState,
|
||||
ToolUsage,
|
||||
@@ -39,7 +39,7 @@ export function writeFileSafe(filePath: string, content: string): void {
|
||||
// ─── Progress Discovery ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Find the nearest .ralph/progress.json by walking up from the given directory.
|
||||
* Find the nearest .ralpi/progress.json by walking up from the given directory.
|
||||
* For a specific sourcePath, finds the matching PRD entry.
|
||||
*/
|
||||
export function findProgressFile(
|
||||
@@ -50,7 +50,7 @@ export function findProgressFile(
|
||||
const root = path.parse(current).root;
|
||||
|
||||
while (current !== root) {
|
||||
const candidate = path.join(current, ".ralph", "progress.json");
|
||||
const candidate = path.join(current, ".ralpi", "progress.json");
|
||||
if (fs.existsSync(candidate)) {
|
||||
try {
|
||||
const raw = fs.readFileSync(candidate, "utf-8");
|
||||
@@ -113,9 +113,9 @@ function parseSimpleYaml(content: string): Record<string, any> {
|
||||
* Deep merge configuration objects
|
||||
*/
|
||||
function mergeConfig(
|
||||
defaults: RalphConfig,
|
||||
defaults: RalpiConfig,
|
||||
overrides: Record<string, any>,
|
||||
): RalphConfig {
|
||||
): RalpiConfig {
|
||||
const result = { ...defaults };
|
||||
|
||||
for (const [key, value] of Object.entries(overrides)) {
|
||||
@@ -126,14 +126,14 @@ function mergeConfig(
|
||||
}
|
||||
}
|
||||
|
||||
return result as RalphConfig;
|
||||
return result as RalpiConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load configuration from .ralph/config.yaml or return defaults
|
||||
* Load configuration from .ralpi/config.yaml or return defaults
|
||||
*/
|
||||
export function loadConfig(projectDir: string): RalphConfig {
|
||||
const configPath = path.join(projectDir, ".ralph", "config.yaml");
|
||||
export function loadConfig(projectDir: string): RalpiConfig {
|
||||
const configPath = path.join(projectDir, ".ralpi", "config.yaml");
|
||||
|
||||
// Return defaults silently when config file does not exist
|
||||
if (!fs.existsSync(configPath)) {
|
||||
|
||||
Reference in New Issue
Block a user