fix: context overflow, retry, and reflection isolation

- Cap commit diffs in review/commit prompts at 50KB to prevent
  context window overflow on follow-up sessions
- Skip skills catalog (noSkills) in commit/review follow-up sessions
  for leaner context
- Wire Pi's SettingsManager into runAgentSession so Pi's built-in
  retry (exponential backoff, provider retry) applies to ralpi
  sessions — removes ralpi's duplicate manual retry loop
- Remove maxRetries/retryDelayMs from ralpi config; rely on Pi's
  retry.* settings (with manual override support)
- Remove retries field from progress.json and incrementRetry() from
  ProgressTracker
- Add model failover to follow-up sessions (commit/review cycle
  through the model pool on connection errors)
- Namespace reflection files by PRD key under
  .ralpi/reflections/<prdKey>/ so task sets don't overwrite each
  other
- Skip loop-startup prompts for config fields explicitly set in YAML
- Remove (default) annotations from loop options prompts
- Default commitTimeoutMs/reviewTimeoutMs to 0 (inherit Pi defaults)
This commit is contained in:
2026-07-17 09:02:44 -04:00
parent 6c398eef64
commit 087c64ff18
8 changed files with 788 additions and 253 deletions

View File

@@ -4,7 +4,7 @@ import type { Task, Project, Reflection, ToolUsage } 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 { buildTaskPrompt, buildReviewPrompt, MAX_DIFF_BYTES } from "./prompts";
import { extractReflection } from "./reflection";
import {
runAgentSession,
@@ -14,6 +14,8 @@ import {
hasUncommittedChanges,
getGitStatusPorcelain,
getGitDiff,
getLatestCommitDiff,
resolveModelSpec,
formatDuration,
} from "./utils";
import { updateTaskInFile } from "./parser";
@@ -74,6 +76,11 @@ class ModelRoundRobin {
return this.models.length;
}
/** All resolved models in the pool (for follow-up session failover). */
get allModels(): unknown[] {
return this.models;
}
assign(taskId: string): unknown {
let index: number;
if (this.freeSlots.length > 0) {
@@ -627,14 +634,19 @@ async function executeTask(
roundRobin?: ModelRoundRobin | null,
batchRender?: () => void,
): Promise<void> {
const maxRetries = config.execution.maxRetries;
// Model failover: when a provider/API is down, cycle through available models.
// result.success === false always means an agent-session failure (API error,
// provider unreachable, etc.), not a task-work error.
// Pi's built-in retry (via SettingsManager) handles transient errors with
// exponential backoff within each model. Ralpi only handles model cycling.
const maxModelAttempts = roundRobin ? roundRobin.length : 1;
let modelAttempt = 0;
let currentModel: unknown = assignedModel ?? config.model;
// Resolve implModel from config (used in sequential mode when no round-robin assignment).
// In parallel mode, the round-robin assignedModel takes precedence.
const implModel = resolveModelSpec(
ctx.modelRegistry as { find(p: string, m: string): unknown } | undefined,
config.execution.implModel,
(msg) => ctx.ui.notify(msg, "warning"),
);
let currentModel: unknown = assignedModel ?? implModel ?? config.model;
while (modelAttempt < maxModelAttempts) {
// On subsequent model attempts, advance to the next model.
@@ -644,46 +656,53 @@ async function executeTask(
currentModel = roundRobin.advance(task.id);
}
let retries = 0;
while (retries <= maxRetries) {
try {
// Mark as in progress
progress.markInProgress(task.id);
// Auto-update the PRD source file checkbox
try {
// Mark as in progress
progress.markInProgress(task.id);
// Auto-update the PRD source file checkbox
try {
updateTaskInFile(project.sourcePath, task.id, "in_progress");
} catch {
// Best-effort: don't fail the task over a checkbox update
}
updateTaskInFile(project.sourcePath, task.id, "in_progress");
} catch {
// Best-effort: don't fail the task over a checkbox update
}
// Get dependency reflections
const depReflections = progress.getDependencyReflections(
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,
parallelState,
currentModel,
batchRender,
);
// Run the task
const result = await runTask(
task,
project,
config,
depReflections,
ctx,
sendChatMessage,
projectDir,
parallelState,
currentModel,
batchRender,
);
if (result.success) {
// ── Auto-Commit: Trigger follow-up agent session for uncommitted changes ──
let finalCommitMessages = result.commitMessages ?? [];
let finalCommitSummary = result.commitSummary ?? "";
if (result.success) {
// ── Auto-Commit: optionally trigger follow-up agent session for uncommitted changes ──
let finalCommitMessages = result.commitMessages ?? [];
let finalCommitSummary = result.commitSummary ?? "";
if (config.execution.autoCommit) {
try {
if (hasUncommittedChanges(projectDir)) {
const status = getGitStatusPorcelain(projectDir);
const diff = getGitDiff(projectDir);
let diff = getGitDiff(projectDir);
let diffNote = "";
if (diff.length > MAX_DIFF_BYTES) {
diffNote =
"\n\n... (diff truncated: omitted " +
(diff.length - MAX_DIFF_BYTES).toLocaleString() +
" bytes; run `git diff` to view the full diff)";
diff = diff.slice(0, MAX_DIFF_BYTES);
}
const commitPrompt = [
`## Auto-Commit for Task ${task.id}: ${task.title}`,
"",
@@ -703,118 +722,34 @@ async function executeTask(
"### Current Tracked Diff (git diff)",
"```diff",
diff || "(no tracked diff output)",
diffNote,
"```",
].join("\n");
// ── Commit widget setup ──
const commitWidgetKey = `ralpi-commit-${task.id}`;
let commitFrameIndex = 0;
const commitToolCalls: ToolCallEntry[] = [];
let commitWidgetTui: { requestRender(): void } | null = null;
// Resolve commit model (fall back to current task model)
const commitModel =
resolveModelSpec(
ctx.modelRegistry as
| { find(p: string, m: string): unknown }
| undefined,
config.execution.commitModel,
(msg) => ctx.ui.notify(msg, "warning"),
) ?? currentModel;
const commitHeader = `commit for ${task.id} · ${task.title}`;
// Build failover list: primary model first, then the rest of the pool.
const commitModels = buildFailoverModels(commitModel, roundRobin);
const buildCommitLines = (
t: typeof ctx.ui.theme,
width?: number,
): string[] => {
const effectiveWidth = width || 74;
const frame = t.fg(
"accent",
SPINNER_FRAMES[commitFrameIndex % SPINNER_FRAMES.length],
);
const lines = [
truncateToWidth(`~ ${frame} ${commitHeader}`, effectiveWidth),
];
if (commitToolCalls.length > 0) {
if (commitToolCalls.length <= MAX_COLLAPSED) {
for (let i = 0; i < commitToolCalls.length; i++) {
const entry = commitToolCalls[i];
const isLast = i === commitToolCalls.length - 1;
const branch = isLast ? " └── " : " ├── ";
const tag = t.fg("accent", `[${entry.name}]`);
lines.push(
truncateToWidth(
`${branch}${tag} ${entry.label}`,
effectiveWidth,
),
);
}
} else {
const shown = commitToolCalls.slice(-MAX_COLLAPSED);
const remaining = commitToolCalls.length - shown.length;
lines.push(
truncateToWidth(
t.fg("dim", ` ├── …${remaining} earlier`),
effectiveWidth,
),
);
for (let i = 0; i < shown.length; i++) {
const entry = shown[i];
const isLast = i === shown.length - 1;
const branch = isLast ? " └── " : " ├── ";
const tag = t.fg("accent", `[${entry.name}]`);
lines.push(
truncateToWidth(
`${branch}${tag} ${entry.label}`,
effectiveWidth,
),
);
}
}
}
return lines;
};
ctx.ui.setWidget(commitWidgetKey, (tui, t) => {
commitWidgetTui = tui;
return {
render: (width?: number) => buildCommitLines(t, width),
invalidate: () => commitWidgetTui?.requestRender(),
};
});
const requestCommitRender = () =>
commitWidgetTui?.requestRender();
const commitSpinnerTimer = setInterval(() => {
commitFrameIndex =
(commitFrameIndex + 1) % SPINNER_FRAMES.length;
requestCommitRender();
}, 100);
// Use a short timeout for the commit session (60s should be enough)
const commitTimeout = Math.min(
60_000,
config.execution.timeoutMs,
);
let commitResult: Awaited<ReturnType<typeof runAgentSession>>;
try {
commitResult = await runAgentSession(
const { result: commitResult, toolCalls: commitToolCalls } =
await runFollowUpSession(
ctx,
config,
commitPrompt,
projectDir,
commitTimeout,
(event) => {
if (event.type === "tool_execution_start") {
const label = formatToolArg(event.toolName, event.args);
commitToolCalls.push({
name: event.toolName,
label,
});
requestCommitRender();
}
},
undefined,
currentModel,
config.thinkingLevel,
`commit for ${task.id} · ${task.title}`,
`commit-${task.id}`,
config.execution.commitTimeoutMs,
commitModels,
);
} finally {
clearInterval(commitSpinnerTimer);
ctx.ui.setWidget(commitWidgetKey, undefined);
}
if (commitResult.success) {
// Re-capture commits made during this follow-up session
@@ -846,94 +781,152 @@ async function executeTask(
}`,
);
}
}
// 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.outputPreview,
finalCommitMessages,
finalCommitSummary,
);
// Auto-update the PRD source file checkbox
// ── Auto-Review: optionally spawn a review agent to review the latest commit ──
if (config.execution.autoReview) {
try {
updateTaskInFile(project.sourcePath, task.id, "completed");
} catch {
// Best-effort: don't fail the task over a checkbox update
const commitInfo = getLatestCommitDiff(projectDir);
if (commitInfo && commitInfo.diff) {
const reviewPrompt = buildReviewPrompt(
task,
project,
commitInfo.hash,
commitInfo.subject,
commitInfo.diff,
config.prompts.projectContext,
);
// Resolve review model (fall back to current task model)
const reviewModel =
resolveModelSpec(
ctx.modelRegistry as
| { find(p: string, m: string): unknown }
| undefined,
config.execution.reviewModel,
(msg) => ctx.ui.notify(msg, "warning"),
) ?? currentModel;
// Build failover list: primary model first, then the rest of the pool.
const reviewModels = buildFailoverModels(reviewModel, roundRobin);
const { result: reviewResult, toolCalls: reviewToolCalls } =
await runFollowUpSession(
ctx,
config,
reviewPrompt,
projectDir,
`review for ${task.id} · ${task.title}`,
`review-${task.id}`,
config.execution.reviewTimeoutMs,
reviewModels,
);
if (reviewResult.success) {
const reviewText = reviewResult.text.trim();
// Post review as a chat message with tool calls
const preview =
reviewText.length > 500
? reviewText.slice(0, 500) + "\n... (truncated)"
: reviewText;
sendChatMessage?.(
`⚑ review for ${task.id} · ${task.title}\n${preview}`,
{ toolCalls: reviewToolCalls },
);
} else {
sendChatMessage?.(
`~ review for ${task.id} · ${task.title} — review session failed: ${reviewResult.error}`,
{ toolCalls: reviewToolCalls },
);
}
}
} catch (error) {
// Don't fail the task if auto-review fails
sendChatMessage?.(
`~ review for ${task.id} · ${task.title} — auto-review error: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
roundRobin?.release(task.id);
return;
}
// Agent session failed (provider error).
// If we have more models, cycle immediately — don't waste retries.
if (roundRobin && modelAttempt < maxModelAttempts - 1) {
// Don't release — advance() already handles the transition.
// release() would put the slot in freeSlots, then assign()
// would pick it right back up, getting stuck on the same model.
modelAttempt++;
sendChatMessage?.(
`~ ${task.id} · ${task.title} — trying model ${modelAttempt + 1}/${maxModelAttempts} (previous: ${result.error})`,
// Save reflection
if (result.reflection) {
saveReflectionToFile(
projectDir,
config,
result.reflection,
progress.getKey(),
);
break; // exit retry loop, cycle to next model
}
// No more models — use normal retry logic
if (retries < maxRetries) {
retries = progress.incrementRetry(task.id);
sendChatMessage?.(
`~ ${task.id} · ${task.title} — retrying (${retries}/${maxRetries}): ${result.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");
// Don't update PRD — retry exhaustion is transient, not terminal
sendChatMessage?.(`${task.id} · ${task.title}${result.error}`);
ctx.ui.notify(
`Task ${task.id} failed after ${maxRetries} retries: ${
result.error || "Unknown error"
}`,
"error",
);
return;
}
} catch (error) {
roundRobin?.release(task.id);
batchRender?.();
const errorMsg = error instanceof Error ? error.message : String(error);
progress.markFailed(task.id, errorMsg);
// Mark completed with all metadata
progress.markCompleted(
task.id,
result.durationMs,
result.reflection,
result.toolUsage,
result.outputPreview,
finalCommitMessages,
finalCommitSummary,
);
// Auto-update the PRD source file checkbox
try {
updateTaskInFile(project.sourcePath, task.id, "failed");
updateTaskInFile(project.sourcePath, task.id, "completed");
} catch {
// Best-effort
// Best-effort: don't fail the task over a checkbox update
}
sendChatMessage?.(`${task.id} · ${task.title}${errorMsg}`);
ctx.ui.notify(`Task ${task.id} failed: ${errorMsg}`, "error");
roundRobin?.release(task.id);
return;
}
}
// If we broke out (model cycling), continue the outer loop
modelAttempt++;
// Agent session failed (provider error).
// Pi's built-in retry already exhausted for this model. Cycle to the next.
if (roundRobin && modelAttempt < maxModelAttempts - 1) {
modelAttempt++;
sendChatMessage?.(
`~ ${task.id} · ${task.title} — cycling to model ${modelAttempt + 1}/${maxModelAttempts} (previous: ${result.error})`,
);
continue; // next model in the outer while loop
}
// All models exhausted.
progress.markFailed(task.id, result.error || "Unknown error");
try {
updateTaskInFile(project.sourcePath, task.id, "failed");
} catch {
// Best-effort
}
sendChatMessage?.(`${task.id} · ${task.title}${result.error}`);
ctx.ui.notify(
`Task ${task.id} failed across ${maxModelAttempts} models: ${
result.error || "Unknown error"
}`,
"error",
);
roundRobin?.release(task.id);
return;
} catch (error) {
roundRobin?.release(task.id);
batchRender?.();
const errorMsg = error instanceof Error ? error.message : String(error);
progress.markFailed(task.id, errorMsg);
// Auto-update the PRD source file checkbox
try {
updateTaskInFile(project.sourcePath, task.id, "failed");
} catch {
// Best-effort
}
sendChatMessage?.(`${task.id} · ${task.title}${errorMsg}`);
ctx.ui.notify(`Task ${task.id} failed: ${errorMsg}`, "error");
return;
}
}
// All models exhausted — release the slot
roundRobin?.release(task.id);
batchRender?.();
progress.markFailed(task.id, "All configured models exhausted");
// Don't update PRD — model exhaustion is transient, not terminal
sendChatMessage?.(
`${task.id} · ${task.title} — all ${maxModelAttempts} models exhausted`,
);
@@ -949,17 +942,167 @@ function saveReflectionToFile(
sourceDir: string,
config: RalpiConfig,
reflection: Reflection,
prdKey: string,
): void {
const reflectionsDir = path.join(sourceDir, config.paths.reflectionsDir);
const reflectionsDir = path.join(
sourceDir,
config.paths.reflectionsDir,
prdKey,
);
ensureDir(reflectionsDir);
const filePath = path.join(reflectionsDir, `${reflection.taskId}.json`);
writeFileSafe(filePath, JSON.stringify(reflection, null, 2));
}
// ─── Follow-Up Sessions (Commit / Review) ─────────────────────────────────────
/**
* Run a follow-up agent session (commit, review, etc.) with a live spinner
* widget. Handles widget setup, spinner animation, session execution, and
* cleanup. Cycles through `models` on connection failure so a flaky provider
* doesn't kill the commit/review step. Returns the session result and
* captured tool calls.
*/
async function runFollowUpSession(
ctx: ExtensionContext,
config: RalpiConfig,
prompt: string,
projectDir: string,
header: string,
widgetKeySuffix: string,
timeoutMs: number,
models: unknown[],
): Promise<{
result: Awaited<ReturnType<typeof runAgentSession>>;
toolCalls: ToolCallEntry[];
}> {
const toolCalls: ToolCallEntry[] = [];
let frameIndex = 0;
let widgetTui: { requestRender(): void } | null = null;
const widgetKey = `ralpi-${widgetKeySuffix}-${Date.now()}`;
const truncateWidth = 74;
const buildLines = (t: typeof ctx.ui.theme, width?: number): string[] => {
const effectiveWidth = width
? Math.min(width, truncateWidth)
: truncateWidth;
const frame = t.fg(
"accent",
SPINNER_FRAMES[frameIndex % SPINNER_FRAMES.length],
);
const lines = [truncateToWidth(`~ ${frame} ${header}`, effectiveWidth)];
if (toolCalls.length > 0) {
if (toolCalls.length <= MAX_COLLAPSED) {
for (let i = 0; i < toolCalls.length; i++) {
const entry = toolCalls[i];
const isLast = i === toolCalls.length - 1;
const branch = isLast ? " └── " : " ├── ";
const tag = t.fg("accent", `[${entry.name}]`);
lines.push(
truncateToWidth(`${branch}${tag} ${entry.label}`, effectiveWidth),
);
}
} else {
const shown = toolCalls.slice(-MAX_COLLAPSED);
const remaining = toolCalls.length - shown.length;
lines.push(
truncateToWidth(
t.fg("dim", ` ├── …${remaining} earlier`),
effectiveWidth,
),
);
for (let i = 0; i < shown.length; i++) {
const entry = shown[i];
const isLast = i === shown.length - 1;
const branch = isLast ? " └── " : " ├── ";
const tag = t.fg("accent", `[${entry.name}]`);
lines.push(
truncateToWidth(`${branch}${tag} ${entry.label}`, effectiveWidth),
);
}
}
}
return lines;
};
ctx.ui.setWidget(widgetKey, (tui, t) => {
widgetTui = tui;
return {
render: (width?: number) => buildLines(t, width),
invalidate: () => widgetTui?.requestRender(),
};
});
const requestRender = () => widgetTui?.requestRender();
const spinnerTimer = setInterval(() => {
frameIndex = (frameIndex + 1) % SPINNER_FRAMES.length;
requestRender();
}, 100);
let result: Awaited<ReturnType<typeof runAgentSession>> | undefined;
try {
for (let attempt = 0; attempt < models.length; attempt++) {
const model = models[attempt];
result = 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 });
requestRender();
}
},
undefined,
model,
config.thinkingLevel,
true, // noSkills — follow-up sessions don't need the skills catalog
);
if (result.success) break;
// If there's a next model to try, cycle; otherwise give up.
if (attempt < models.length - 1) {
// Clear partial tool calls from the failed attempt so the widget
// reflects only the successful (or final) attempt.
toolCalls.length = 0;
requestRender();
}
}
} finally {
clearInterval(spinnerTimer);
ctx.ui.setWidget(widgetKey, undefined);
}
// result is always set — the loop runs at least once (models.length >= 1)
return { result: result!, toolCalls };
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
/**
* Build a model failover list for a follow-up session.
*
* The primary model goes first; the remaining models from the round-robin
* pool are appended (deduped) so a flaky provider doesn't kill the commit
* or review step. When there's no round-robin (sequential mode), the
* primary model is returned as a single-element list.
*/
function buildFailoverModels(
primary: unknown,
roundRobin: ModelRoundRobin | null | undefined,
): unknown[] {
const models: unknown[] = [primary];
if (roundRobin) {
for (const m of roundRobin.allModels) {
if (m !== primary) models.push(m);
}
}
return models;
}
// ─── Tool Call Formatting ────────────────────────────────────────────────

View File

@@ -237,15 +237,6 @@ export class ProgressTracker {
.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;
}
/** Set paused state */
setPaused(paused: boolean): void {
const prd = this.getPRD();
@@ -277,7 +268,7 @@ export class ProgressTracker {
private ensureTask(prd: PRDProgress, taskId: string): void {
if (!prd.tasks[taskId]) {
prd.tasks[taskId] = { status: "pending", retries: 0 };
prd.tasks[taskId] = { status: "pending" };
}
}
}

View File

@@ -1,6 +1,29 @@
import type { Task, Project, Reflection } from "./types";
import { readTaskSpec } from "./parser";
/** Maximum bytes of a commit diff embedded in a review/commit prompt.
* Diffs larger than this are truncated to avoid blowing past the model's
* context window. The agent can always run `git show HEAD` itself to
* inspect the full diff when it needs more detail.
*
* ~50 KB ≈ 12.5K tokens — comfortably fits even on models with a 128K
* context window once system-prompt overhead is accounted for. */
export const MAX_DIFF_BYTES = 50_000;
/**
* Truncate a diff to MAX_DIFF_BYTES, appending a clear notice when truncated.
*/
function truncateDiff(diff: string): string {
if (diff.length <= MAX_DIFF_BYTES) return diff;
const omitted = diff.length - MAX_DIFF_BYTES;
return (
diff.slice(0, MAX_DIFF_BYTES) +
"\n\n... (diff truncated: omitted " +
omitted.toLocaleString() +
" bytes; run `git show HEAD` to view the full diff)"
);
}
// ─── Task Prompt ─────────────────────────────────────────────────────────────
/**
@@ -136,7 +159,91 @@ export function buildTaskPrompt(
return parts.join("\n");
}
// ─── Plan Prompt ─────────────────────────────────────────────────────────────
// ─── Review Prompt ───────────────────────────────────────────────────────────
/**
* Build the prompt for the auto-review agent.
* Includes the task description and the latest commit diff so the reviewer
* can assess whether the commit fulfills the task requirements.
*/
export function buildReviewPrompt(
task: Task,
project: Project,
commitHash: string,
commitSubject: string,
commitDiff: string,
projectContext?: string,
): string {
const parts: string[] = [];
parts.push(`# Code Review: Task ${task.id}: ${task.title}`);
parts.push("");
// ── Task Description ──
parts.push("## Task Description");
if (task.description) {
parts.push(task.description);
} else {
parts.push(task.title);
}
parts.push("");
// ── Task Specification ──
if (task.file) {
const spec = readTaskSpec(project.sourceDir, task.file);
if (spec) {
parts.push("## Task Specification");
parts.push(`Full details from \`${task.file}\`:`);
parts.push("");
parts.push(spec);
parts.push("");
}
}
// ── Commit Under Review ──
parts.push("## Commit Under Review");
parts.push(`Commit: ${commitHash}${commitSubject}`);
parts.push("");
parts.push("### Diff");
parts.push("```diff");
parts.push(truncateDiff(commitDiff));
parts.push("```");
parts.push("");
// ── Project Context ──
if (projectContext) {
parts.push("## Additional Context");
parts.push(projectContext);
parts.push("");
}
// ── Review Instructions ──
parts.push("## Review Instructions");
parts.push(
"Review the commit above against the task description. Check for:",
);
parts.push(
"- **Correctness**: Does the implementation fulfill the task requirements?",
);
parts.push("- **Completeness**: Are all aspects of the task addressed?");
parts.push(
"- **Code quality**: Are there obvious bugs, anti-patterns, or issues?",
);
parts.push(
"- **Missing changes**: Are there files that should have been modified but weren't?",
);
parts.push("");
parts.push(
"Provide a concise review with any issues found. If the commit looks good, say so explicitly.",
);
return parts.join("\n");
}
/**
* Build the prompt for a dry-run / plan display
@@ -155,9 +262,10 @@ export function buildPlanPrompt(project: Project): string {
lines.push("## Tasks");
for (const task of project.tasks) {
const deps = task.dependencies.length > 0
? ` (depends on: ${task.dependencies.join(", ")})`
: "";
const deps =
task.dependencies.length > 0
? ` (depends on: ${task.dependencies.join(", ")})`
: "";
lines.push(`- [ ] ${task.id}: ${task.title}${deps}`);
}
lines.push("");

View File

@@ -115,7 +115,6 @@ export interface TaskProgressInfo {
status: Task["status"];
startedAt?: string;
completedAt?: string;
retries: number;
durationMs?: number;
reflection?: Reflection;
error?: string;
@@ -167,16 +166,31 @@ export interface RalpiConfig {
reflectionsDir: string;
};
execution: {
/** Maximum retries per task */
maxRetries: number;
/** Delay between retries in milliseconds */
retryDelayMs: number;
/** Task execution timeout in milliseconds */
timeoutMs: number;
/** Maximum parallel tasks (0 = unlimited) */
maxParallel: number;
/** Round-robin model list for parallel tasks (empty = inherit parent model) */
models: string[];
/** Spawn a follow-up agent to commit changes after each task completes */
autoCommit: boolean;
/** Spawn a review agent to review the commit against the task description */
autoReview: boolean;
/** Keys under `execution:` explicitly present in a loaded config YAML.
* Used to skip interactive prompts for fields the user already set. */
explicitKeys?: Set<string>;
/** Model for commit sessions in <provider>/<model> format (empty = inherit task model) */
commitModel: string;
/** Model for review sessions in <provider>/<model> format (empty = inherit task model) */
reviewModel: string;
/** Model for task implementation in <provider>/<model> format (empty = inherit parent model; only used in sequential mode when models is empty) */
implModel: string;
/** Timeout for auto-commit agent sessions in milliseconds */
commitTimeoutMs: number;
/** Timeout for auto-review agent sessions in milliseconds */
reviewTimeoutMs: number;
/** Maximum total duration for the entire loop execution in milliseconds (0 = no limit). Checked between batches — in-progress tasks finish naturally. */
loopTimeoutMs: number;
};
prompts: {
/** Additional context injected into every task prompt */
@@ -196,11 +210,17 @@ export const DEFAULT_CONFIG: RalpiConfig = {
reflectionsDir: ".ralpi/reflections",
},
execution: {
maxRetries: 0,
retryDelayMs: 0,
timeoutMs: 0, // 0 = inherit Pi's own defaults (no ralpi-level timeout)
maxParallel: 3,
models: [],
autoCommit: true,
autoReview: false,
commitModel: "",
reviewModel: "",
implModel: "",
commitTimeoutMs: 0, // 0 = inherit Pi's own defaults (no ralpi-level timeout)
reviewTimeoutMs: 0, // 0 = inherit Pi's own defaults (no ralpi-level timeout)
loopTimeoutMs: 0, // 0 = no limit
},
prompts: {
projectContext: "",

View File

@@ -13,6 +13,7 @@ import {
DefaultResourceLoader,
getAgentDir,
SessionManager,
SettingsManager,
} from "@earendil-works/pi-coding-agent";
// ─── Directory Helpers ───────────────────────────────────────────────────────
@@ -152,6 +153,70 @@ export function findProgressFile(
return null;
}
/**
* List all PRDs from a ProgressState, sorted by lastUpdatedAt descending
* (most recent first). Used by resume to offer a selection when multiple
* loops have progress simultaneously.
*/
export function listPRDsSorted(
state: ProgressState,
): 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,
},
});
}
entries.sort((a, b) => {
return (
new Date(b.prd.lastUpdatedAt).getTime() -
new Date(a.prd.lastUpdatedAt).getTime()
);
});
return entries;
}
// ─── Model Resolution ───────────────────────────────────────────────────────
/**
* Resolve a "<provider>/<model>" spec string via the model registry.
* 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,
): unknown | undefined {
if (!spec) return undefined;
const slashIdx = spec.indexOf("/");
if (slashIdx === -1) {
onWarning?.(
`ralpi config: skipping model "${spec}" — expected <provider>/<model> format`,
);
return undefined;
}
const provider = spec.slice(0, slashIdx);
const modelId = spec.slice(slashIdx + 1);
return modelRegistry?.find(provider, modelId);
}
// ─── Config ──────────────────────────────────────────────────────────────────
/** Try to use the `yaml` package (real dependency in package.json).
@@ -239,6 +304,15 @@ export function loadConfig(projectDir: string): RalpiConfig {
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<string>();
for (const key of Object.keys(exec)) {
acc.execution.explicitKeys.add(key);
}
}
} catch {
// Malformed config — skip silently
}
@@ -434,6 +508,10 @@ export async function runAgentSession(
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,
): Promise<{
success: boolean;
text: string;
@@ -466,7 +544,7 @@ export async function runAgentSession(
cwd,
agentDir: getAgentDir(),
noExtensions: true,
noSkills: false,
noSkills,
noPromptTemplates: true,
noThemes: true,
noContextFiles: true,
@@ -477,6 +555,7 @@ export async function runAgentSession(
cwd,
sessionManager: SessionManager.inMemory(),
resourceLoader: loader,
settingsManager: SettingsManager.create(cwd, getAgentDir()),
tools: ["read", "bash", "edit", "write", "grep", "find", "ls"],
model: model as any,
thinkingLevel: thinkingLevel as any,
@@ -675,3 +754,43 @@ export function captureGitCommits(projectDir: string): {
return { commitMessages, commitSummary };
}
/**
* Get the diff of the latest commit (HEAD).
* Returns the short hash, subject, and full diff (stat + patch).
* Used by the auto-review agent to review a commit against the task.
*/
export function getLatestCommitDiff(
projectDir: string,
): { hash: string; subject: string; diff: string } | null {
const { execSync } = require("node:child_process");
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();
const subject = execSync("git log -1 --format=%s", {
cwd: projectDir,
encoding: "utf-8",
}).trim();
// Full diff of the latest commit: stat overview + patch
const diff = execSync("git show HEAD --stat --patch", {
cwd: projectDir,
encoding: "utf-8",
maxBuffer: 1024 * 1024,
}).trim();
return { hash, subject, diff };
} catch {
return null;
}
}