Compare commits

..

13 Commits

Author SHA1 Message Date
dd61249e8d version bump 2026-07-23 17:52:49 -04:00
a0973ae1d2 fix: resume no longer clears worktrees 2026-07-22 23:30:31 -04:00
6dcbd064a6 fix: review skipped silently when task diff exceeds 1MB maxBuffer
getCommitRangeDiff and getLatestCommitDiff had maxBuffer set to 1MB.
When a task produced a larger diff (common for 10+ minute tasks),
execSync threw, the catch block returned null, and the review loop
broke immediately with no message — reviews were silently skipped for
larger tasks while smaller tasks reviewed fine.

- Increase maxBuffer to 10MB in both functions (the review prompt
  builder already truncates to MAX_DIFF_BYTES = 50KB before sending
  to the model, so the full diff in memory is fine)
- Add a diagnostic sendChatMessage when the review loop is skipped
  (baseRef undefined or no diff), so silent skips are visible
2026-07-22 21:53:29 -04:00
74c9ead7af fix: review fires for all tasks — commit then review committed diff, merge on pass
Previous flow reviewed uncommitted changes, so when a task agent
self-committed its work the review was silently skipped (no uncommitted
changes → review loop body never entered). This caused reviews to fire
inconsistently across tasks.

New review-gated flow (when autoReview is on):
1. Execute task
2. Ensure committed — commit session fallback when the agent didn't
   self-commit (handles both self-commit and no-commit agents)
3. Review the COMPLETE task diff (baseRef..HEAD) captured before
   execution, so the reviewer sees all commits not just the latest
4. On fail → re-execute with feedback → commit → re-review (same
   baseRef, so reviewer sees complete state including fixes)
5. On pass → merge worktree (all changes already committed)

Commit is now mandated when autoReview is on (autoCommit forced true,
not asked at startup). autoCommit only asked when autoReview is off.

Add captureGitHead + getCommitRangeDiff helpers to utils.ts. Switch
review prompt from buildReviewPromptUncommitted to buildReviewPrompt
(reviewing committed changes, not uncommitted).
2026-07-22 18:29:29 -04:00
0ef540ed47 feat: review-gated loop triggers on autoReview alone, ask review before commit at startup
- Review now runs whenever autoReview is enabled (not only when both
  autoCommit && autoReview). On fail, the task re-executes with review
  feedback injected and loops until pass or maxReviewRetries exhausted.
- Commit after a passing review is gated by autoCommit; review-only mode
  leaves changes uncommitted for manual inspection.
- Remove legacy post-commit review path and unused imports
  (buildReviewPrompt, getLatestCommitDiff).
- Reorder selectLoopOptions: ask autoReview first, then saveReviews,
  then autoCommit (reworded to 'commit after a passing review' when
  review is enabled).
- Update types.ts doc comments and README to reflect new semantics.
2026-07-22 17:43:57 -04:00
9fcf944cb5 feat: per prd worktrees 2026-07-22 16:37:02 -04:00
0034272c54 feat: better branch titles 2026-07-22 11:17:24 -04:00
c46f8f1783 feat: batch-level conflict resolution for worktree merges
Add reattemptMerge/abortMerge/completeMerge/hasMergeConflicts helpers to
worktree.ts. When a worktree merge conflicts, executeTask now defers to
batch-level resolution instead of immediately marking the task failed.

After all tasks in a batch finish, resolveConflictsSession re-attempts the
merge to recreate the conflict state, spawns an agent session with
buildConflictResolutionPrompt to resolve conflict markers and commit, then
verifies completion. This keeps parallel task slots unblocked — conflicts
are resolved sequentially at the batch boundary.
2026-07-20 15:45:12 -04:00
46da29ee22 feat: separate workspaces per parallel execution 2026-07-20 12:27:22 -04:00
519b12b3d9 follow-up restructure.
- loop until review pass
- review now comes prior to commit
2026-07-20 10:29:15 -04:00
6aa3f6bd9f feat: persist full review output to disk and render in expandable view
Review text was truncated to 500 chars in the chat message body with no
way to access the full content — once a review exceeded that limit it was
lost from the UI. Now the full review body is rendered in the expanded
(Ctrl+O) view like the implementation tool-call tree, and can optionally
be persisted to disk per-loop.

Changes:
- Add saveReviews config flag + reviewsDir path option
- Add loop-start prompt to opt into saving reviews to disk when
  auto-review is enabled (skipped when explicitly set in YAML)
- Pass full reviewText via message details instead of inlining a slice
- Extend ralpi-progress renderer to show full review body when expanded,
  dim hint to expand when collapsed
- Add saveReviewToFile() writing to .ralpi/reviews/<prdKey>/<taskId>.md,
  mirroring the per-loop reflections layout so many loops don't collide
2026-07-17 16:03:53 -04:00
008489a91b fix: pass parent modelRuntime so extension providers work in ralpi sessions
ralpi's runAgentSession was letting createAgentSession create a fresh
ModelRuntime from models.json only. This excluded extension-registered
providers like neuralwatt, whose streamSimple wrapper handles:
- 429 rate-limit header parsing (concurrent/TPM/admission/RPM)
- Rate-limit error normalization for Pi's retry logic
- Context-overflow error normalization

Without the wrapper, neuralwatt rate-limit and context-overflow errors
surfaced as raw unrecoverable errors instead of being retried by Pi's
built-in retry. Now passes ctx.modelRegistry.runtime (the underlying
ModelRuntime) through to createAgentSession.
2026-07-17 11:11:21 -04:00
087c64ff18 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)
2026-07-17 09:02:44 -04:00
12 changed files with 2795 additions and 661 deletions

View File

@@ -19,6 +19,7 @@ No build step needed — Pi loads extensions via [jiti](https://github.com/unjs/
## External dependencies ## External dependencies
The extension imports from Pi SDK packages (not in `package.json` — provided by the host): The extension imports from Pi SDK packages (not in `package.json` — provided by the host):
- `@earendil-works/pi-coding-agent``ExtensionAPI`, `ExtensionContext`, `createAgentSession`, etc. - `@earendil-works/pi-coding-agent``ExtensionAPI`, `ExtensionContext`, `createAgentSession`, etc.
- `@earendil-works/pi-tui``Box`, `Text` for custom message renderer - `@earendil-works/pi-tui``Box`, `Text` for custom message renderer
@@ -44,6 +45,7 @@ The only real npm dependency is `yaml` (^2.4.0).
## Runtime state ## Runtime state
All runtime state lives in `.ralpi/` in the **project directory** (not this extension directory): All runtime state lives in `.ralpi/` in the **project directory** (not this extension directory):
- `.ralpi/progress.json` — execution progress, supports multiple PRDs - `.ralpi/progress.json` — execution progress, supports multiple PRDs
- `.ralpi/reflections/` — per-task reflection JSON files - `.ralpi/reflections/` — per-task reflection JSON files
- `.ralpi/prompts/` — generated prompts (timestamped, for debugging) - `.ralpi/prompts/` — generated prompts (timestamped, for debugging)
@@ -59,4 +61,13 @@ Task IDs are zero-padded strings (`"01"`, `"02"`, etc.). The parser prepends `0`
## Config ## Config
Read from `.ralpi/config.yaml` in project directory. Falls back to `DEFAULT_CONFIG` in `src/types.ts` when file is missing. Config is loaded at `projectDir` level, not extension level. Read from `.ralpi/config.yaml` in project directory (and global `~/.pi/ralpi/config.yaml`). Falls back to `DEFAULT_CONFIG` in `src/types.ts` when files are missing. Config is loaded at `projectDir` level, not extension level.
Key config fields in `execution`:
- `autoCommit` / `autoReview` — toggle follow-up commit and review agent sessions (also selectable at loop startup via `selectLoopOptions`)
- `models` — round-robin model list for parallel mode
- `implModel` / `commitModel` / `reviewModel``<provider>/<model>` strings resolved via `resolveModelSpec` in `utils.ts`
- `commitTimeoutMs` / `reviewTimeoutMs` — timeouts for follow-up sessions
- `loopTimeoutMs` — max total loop duration in ms (0 = no limit; checked between batches in `executePlanBatches`)
- `timeoutMs` — per-task execution timeout

View File

@@ -120,6 +120,15 @@ execution:
models: # round-robin in <provider>/<model> format models: # round-robin in <provider>/<model> format
- google/gemini-3.5-flash # 1st and 3rd task in parallel - google/gemini-3.5-flash # 1st and 3rd task in parallel
- openai/gpt-5.5 # 2nd task in parallel - openai/gpt-5.5 # 2nd task in parallel
autoCommit: true # commit after each task (mandated when autoReview is on; standalone toggle when off)
autoReview: false # commit → review → loop on fail → merge on pass
implModel: "" # model for task impl (sequential mode, empty = inherit parent)
commitModel: "" # model for commit sessions (empty = inherit task model)
reviewModel: "" # model for review sessions (empty = inherit task model)
timeoutMs: 0 # per-task timeout in ms (0 = inherit Pi's defaults)
commitTimeoutMs: 60000 # timeout for auto-commit agent sessions
reviewTimeoutMs: 120000 # timeout for auto-review agent sessions
loopTimeoutMs: 0 # max total loop duration in ms (0 = no limit)
prompts: prompts:
projectContext: "Additional context for all tasks" projectContext: "Additional context for all tasks"
``` ```
@@ -134,6 +143,31 @@ prompts:
> **NOTE**: this is only used in parallel execution, in sequential mode the > **NOTE**: this is only used in parallel execution, in sequential mode the
> parent pi session's model is used > parent pi session's model is used
#### Auto-review and Auto-commit
At loop startup the review question is asked FIRST. When `autoReview` is
enabled, commit is **mandated** — after task execution, changes are
committed (via a commit agent session when the task agent didn't
self-commit), then the complete task diff (`baseRef..HEAD`) is reviewed
against the task description. On a `fail` verdict the task is
re-executed with the review feedback injected into the prompt (looping
until the review passes or `maxReviewRetries` is exhausted). After
re-execution, changes are committed again and the full diff is
re-reviewed with the same base ref so the reviewer sees the complete
state — original work plus fixes. On pass, the changes are already
committed and the worktree merges.
When `autoReview` is disabled, `autoCommit` runs a follow-up commit
agent after each task with no review. Both options can be overridden at
loop startup via a selection prompt (config YAML values are honored
without prompting when set explicitly).
`commitModel` and `reviewModel` accept `<provider>/<model>` strings (e.g.
`anthropic/claude-sonnet-4`) resolved via the model registry. When empty, the
task's model is inherited. `implModel` sets the model for task implementation
in sequential mode (overridden by `execution.models` round-robin in parallel
mode).
## State Files ## State Files
- `.ralpi/progress.json` - Execution progress - `.ralpi/progress.json` - Execution progress

803
index.ts
View File

@@ -15,11 +15,10 @@ import {
import { ProgressTracker } from "./src/progress"; import { ProgressTracker } from "./src/progress";
import { buildPlanPrompt } from "./src/prompts"; import { buildPlanPrompt } from "./src/prompts";
import { formatReflections } from "./src/reflection"; import { formatReflections } from "./src/reflection";
import { import { verdictGlyph, verdictSummary, formatFindings } from "./src/review";
executeBatch, import type { ReviewResult } from "./src/types";
SPINNER_FRAMES, import { executeBatch, type SendChatMessage } from "./src/executor";
type SendChatMessage, import { cleanupStaleWorktrees } from "./src/worktree";
} from "./src/executor";
import { import {
loadConfig, loadConfig,
resolveTaskArg, resolveTaskArg,
@@ -29,6 +28,8 @@ import {
deleteLoopActive, deleteLoopActive,
readLoopActive, readLoopActive,
findRalpiDir, findRalpiDir,
listPRDsSorted,
formatDuration,
} from "./src/utils"; } from "./src/utils";
const COMMANDS = ["plan", "resume", "reset"] as const; const COMMANDS = ["plan", "resume", "reset"] as const;
@@ -120,6 +121,124 @@ function buildPlanByMode(
: buildSequentialPlan(project, completed); : buildSequentialPlan(project, completed);
} }
/**
* Prompt the user to select auto-review and auto-commit options for this loop.
* Reviews are asked about FIRST. When autoReview is on, commit is always
* mandated (it happens before review) — so autoCommit is forced true and not
* asked about. When autoReview is off, autoCommit is asked as a stand-alone
* toggle. Fields explicitly set in the config YAML are skipped (no prompt).
* Returns the selected options (or config defaults if cancelled).
*/
async function selectLoopOptions(
ctx: ExtensionContext,
config: import("./src/types").RalpiConfig,
): Promise<{ autoCommit: boolean; autoReview: boolean; saveReviews: boolean }> {
const explicit = config.execution.explicitKeys;
// ── 1. Auto-review (asked FIRST) ──
// When enabled, a commit is mandated before review (the task agent's
// changes are committed, then the complete diff is reviewed). On 'fail'
// the task is re-executed with review feedback (looping until pass or
// maxReviewRetries exhausted). On pass the worktree merges.
let autoReview: boolean;
if (explicit?.has("autoReview")) {
autoReview = config.execution.autoReview;
} else {
const reviewChoice = await ctx.ui.select("Auto-review after each task?", [
"Yes — review the task commit and loop on failures (re-execute until pass)",
"No — skip review",
]);
autoReview = reviewChoice
? reviewChoice.startsWith("Yes")
: config.execution.autoReview;
}
// ── 2. Save full review output to disk (only when review is enabled) ──
let saveReviews = false;
if (autoReview) {
if (explicit?.has("saveReviews")) {
saveReviews = config.execution.saveReviews;
} else {
const saveChoice = await ctx.ui.select(
"Save full review output to disk?",
[
"Yes — write each review to .ralpi/reviews/<loop>/<task>.md",
"No — keep reviews in-chat only",
],
);
saveReviews = saveChoice
? saveChoice.startsWith("Yes")
: config.execution.saveReviews;
}
}
// ── 3. Auto-commit ──
// When autoReview is on, commit is always mandated (it happens before the
// review). autoCommit is forced true and not asked about. When review is
// disabled, autoCommit is asked as a stand-alone "commit per task" toggle.
let autoCommit: boolean;
if (autoReview) {
autoCommit = true; // mandated by the review-gated flow
} else if (explicit?.has("autoCommit")) {
autoCommit = config.execution.autoCommit;
} else {
const commitChoice = await ctx.ui.select("Auto-commit after each task?", [
"Yes — stage and commit changes automatically",
"No — skip auto-commit",
]);
autoCommit = commitChoice
? commitChoice.startsWith("Yes")
: config.execution.autoCommit;
}
return { autoCommit, autoReview, saveReviews };
}
/**
* When multiple PRD loops have progress, prompt the user to select which one
* to resume. Returns the selected PRD key and sourcePath.
* If only one PRD exists, returns it without prompting.
* Returns null if no PRDs exist.
*/
async function selectPRDToResume(
ctx: ExtensionContext,
found: NonNullable<ReturnType<typeof findProgressFile>>,
): Promise<{ prdKey: string; sourcePath: string } | null> {
const prds = listPRDsSorted(found.state);
if (prds.length === 0) return null;
if (prds.length === 1) {
return { prdKey: prds[0].key, sourcePath: prds[0].prd.sourcePath };
}
// Multiple PRDs — show selection sorted by most recent first
const options = prds.map((entry) => {
const tasks = entry.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 relPath = path.relative(process.cwd(), entry.prd.sourcePath);
const updated = new Date(entry.prd.lastUpdatedAt).toLocaleString();
return `${relPath}${completed}/${total} done${failed ? `, ${failed} failed` : ""} · ${updated}`;
});
const selected = await ctx.ui.select(
"Multiple loops found. Which to resume?",
options,
);
if (!selected) return null;
const idx = options.indexOf(selected);
if (idx === -1) return null;
return {
prdKey: prds[idx].key,
sourcePath: prds[idx].prd.sourcePath,
};
}
/** Run all batches in a plan, updating the task file after each batch. */ /** Run all batches in a plan, updating the task file after each batch. */
async function executePlanBatches( async function executePlanBatches(
plan: ReturnType<typeof buildPlanByMode>, plan: ReturnType<typeof buildPlanByMode>,
@@ -131,8 +250,11 @@ async function executePlanBatches(
mode: ExecutionMode, mode: ExecutionMode,
sendChatMessage?: SendChatMessage, sendChatMessage?: SendChatMessage,
projectDir?: string, projectDir?: string,
isResume?: boolean,
): Promise<void> { ): Promise<void> {
// Write loop-active marker so widgets can be re-instantiated after a reload // Write loop-active marker so a session reload can detect an interrupted
// loop and resume it (in-process agent sessions die on reload — the marker
// + progress.json in_progress tasks are the signal to re-run them).
if (projectDir) { if (projectDir) {
const allTaskIds = plan.batches.flatMap((b) => b.tasks.map((t) => t.id)); const allTaskIds = plan.batches.flatMap((b) => b.tasks.map((t) => t.id));
writeLoopActive(projectDir, { writeLoopActive(projectDir, {
@@ -141,14 +263,49 @@ async function executePlanBatches(
startedAt: new Date().toISOString(), startedAt: new Date().toISOString(),
taskIds: allTaskIds, taskIds: allTaskIds,
prdKey: progress.getKey(), prdKey: progress.getKey(),
autoCommit: config.execution.autoCommit,
autoReview: config.execution.autoReview,
saveReviews: config.execution.saveReviews,
}); });
// Clean up stale worktrees from interrupted runs before starting.
// On resume this MUST be skipped: an interrupted in-progress task's
// worktree still carries its committed branch, which createWorktree()
// reuses to continue the task rather than restarting from scratch.
// The stale-worktree sweep only runs for fresh loops so concurrent
// loops (other PRDs) are still scoped out via the prdKey filter above.
if (!isResume && config.execution.worktrees !== "never" && projectDir) {
const removed = cleanupStaleWorktrees(
projectDir,
config.paths.stateDir,
progress.getKey(),
);
if (removed.length > 0) {
ctx.ui.notify(
`Cleaned up ${removed.length} stale worktree(s) from previous run.`,
"info",
);
}
}
} }
// Track failed task IDs across batches to block downstream tasks // Track failed task IDs across batches to block downstream tasks
const failedTaskIds = new Set(progress.getFailedTaskIds()); const failedTaskIds = new Set(progress.getFailedTaskIds());
// Loop-level execution timeout: stop starting new batches once elapsed.
// In-progress tasks finish naturally; we just skip remaining batches.
const loopStart = Date.now();
const loopTimeoutMs = config.execution.loopTimeoutMs;
let loopTimedOut = false;
try { try {
for (const batch of plan.batches) { for (const batch of plan.batches) {
// Check loop timeout before starting a new batch
if (loopTimeoutMs > 0 && Date.now() - loopStart > loopTimeoutMs) {
loopTimedOut = true;
break;
}
if (progress.getState().paused) { if (progress.getState().paused) {
ctx.ui.notify( ctx.ui.notify(
"Execution paused. Use /ralpi resume to continue.", "Execution paused. Use /ralpi resume to continue.",
@@ -223,6 +380,13 @@ async function executePlanBatches(
if (projectDir) { if (projectDir) {
deleteLoopActive(projectDir); deleteLoopActive(projectDir);
} }
if (loopTimedOut) {
const elapsed = formatDuration(Date.now() - loopStart);
ctx.ui.notify(
`Loop execution timeout reached (${elapsed}). Remaining tasks skipped. Use /ralpi resume to continue.`,
"warning",
);
}
} }
} }
@@ -239,6 +403,9 @@ export default function ralpiLoopExtension(pi: ExtensionAPI): void {
| { | {
phase?: string; phase?: string;
toolCalls?: Array<{ name: string; label: string }>; toolCalls?: Array<{ name: string; label: string }>;
reviewText?: string;
reviewPath?: string;
reviewResult?: ReviewResult;
} }
| undefined; | undefined;
@@ -248,6 +415,45 @@ export default function ralpiLoopExtension(pi: ExtensionAPI): void {
// Header line — e.g. "✓ 05 · billing-subscriptions-trials (2m 14s)" // Header line — e.g. "✓ 05 · billing-subscriptions-trials (2m 14s)"
lines.push(String(message.content)); lines.push(String(message.content));
// Structured review: when we have a ReviewResult, render verdict +
// findings tree. In expanded mode show findings detail; collapsed
// shows the verdict summary + a hint to expand.
const hasReview = !!details?.reviewText || !!details?.reviewResult;
if (details?.reviewResult) {
const rv = details.reviewResult;
const glyph = verdictGlyph(rv.verdict);
const summary = verdictSummary(rv);
if (expanded) {
// Show verdict, summary, then findings tree, then raw text.
lines.push(` ${glyph} VERDICT: ${rv.verdict.toUpperCase()}`);
lines.push(` ${rv.summary}`);
if (rv.findings.length > 0) {
lines.push(` ${formatFindings(rv)}`);
}
if (details.reviewText) {
const body = details.reviewText.split("\n");
for (const line of body) {
lines.push(` ${line}`);
}
}
} else {
const hint = details.reviewPath
? `press Ctrl+O for full review · saved to ${details.reviewPath}`
: "press Ctrl+O for full review";
lines.push(theme.fg("dim", ` ├── ${glyph} ${summary} · ${hint}`));
}
} else if (hasReview && expanded && details!.reviewText) {
const body = details!.reviewText.split("\n");
for (const line of body) {
lines.push(` ${line}`);
}
} else if (hasReview && !expanded) {
const hint = details?.reviewPath
? `press Ctrl+O for full review · saved to ${details.reviewPath}`
: "press Ctrl+O for full review";
lines.push(theme.fg("dim", ` ├── ${hint}`));
}
// Build tool-call tree // Build tool-call tree
if (details?.toolCalls && details.toolCalls.length > 0) { if (details?.toolCalls && details.toolCalls.length > 0) {
const all = details.toolCalls; const all = details.toolCalls;
@@ -287,13 +493,15 @@ export default function ralpiLoopExtension(pi: ExtensionAPI): void {
}, },
); );
// ─── Reload detection: re-instantiate widgets when session reloads ────── // ─── Reload detection: resume interrupted loops when session reloads ──
// //
// When the user types /reload while ralpi tasks are executing, the old // ralpi runs task agent sessions in-process (createAgentSession), so they
// ExtensionContext is torn down and widgets (created via ctx.ui.setWidget) // do NOT survive a /reload. When the new session starts, this handler
// disappear. This handler detects the reload, reads the persisted loop-active // reads the persisted loop-active marker + progress.json: if any task is
// marker and progress.json, and re-creates live-status widgets that show // still `in_progress`, the loop was interrupted mid-task and we resume it
// task progress with spinner animation and tool calls from session files. // (resetting those tasks to pending so the DAG re-schedules them), using
// the mode + loop options snapshotted in loop-active.json so the resume is
// non-interactive.
pi.on("session_start", async (event, ctx) => { pi.on("session_start", async (event, ctx) => {
if (event.reason !== "reload") return; if (event.reason !== "reload") return;
@@ -306,89 +514,9 @@ export default function ralpiLoopExtension(pi: ExtensionAPI): void {
if (!loopState) return; if (!loopState) return;
// Load progress state // Load progress state
let abortPolling = false;
const progressPath = path.join(projectDir, ".ralpi", "progress.json"); const progressPath = path.join(projectDir, ".ralpi", "progress.json");
const sessionsDir = path.join(projectDir, ".ralpi", "sessions");
// Parse the task file to get task titles /** Re-read progress from disk. */
const titleMap = new Map<string, string>();
try {
const project = parseTaskFile(loopState.taskFile);
for (const task of project.tasks) {
titleMap.set(task.id, task.title);
}
} catch {
// If parsing fails, just use IDs without titles
}
/** Read recent tool calls from a task's session file. */
const readRecentToolCalls = (
taskId: string,
maxLines = 30,
): Array<{ name: string; label: string }> => {
try {
const files = fs
.readdirSync(sessionsDir)
.filter((f) => f.startsWith(taskId + "-"))
.sort();
if (files.length === 0) return [];
const sessionPath = path.join(sessionsDir, files[files.length - 1]);
const content = fs.readFileSync(sessionPath, "utf-8");
const lines = content
.split("\n")
.filter((l) => l.trim())
.slice(-maxLines);
const calls: Array<{ name: string; label: string }> = [];
for (const line of lines) {
try {
const event = JSON.parse(line);
if (event.type === "tool_execution_start") {
calls.push({
name: event.toolName,
label: formatToolLabel(event.toolName, event.args),
});
}
} catch {
// Skip malformed lines
}
}
return calls;
} catch {
return [];
}
};
/**
* Strip control characters and newlines from a display label so it
* does not break TUI layout (tree branches, text width calculation).
*/
function sanitizeLabel(s: string): string {
return s
.replace(/\r?\n/g, " ")
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "")
.trim();
}
/** Format a tool call argument into a short label. */
function formatToolLabel(name: string, args: unknown): string {
const a = args as Record<string, unknown> | undefined;
if (!a) return name;
if (name === "bash")
return sanitizeLabel(String(a.command ?? "").slice(0, 70));
if (name === "write" || name === "read" || name === "edit")
return sanitizeLabel(String(a.path ?? "").slice(0, 60));
if (name === "grep")
return sanitizeLabel(
`${a.pattern ?? "?"}${String(a.path ?? "").slice(0, 40)}`,
);
if (name === "find")
return sanitizeLabel(`${a.path ?? "."}${a.glob ?? "*"}`);
if (name === "ls")
return sanitizeLabel(String(a.path ?? ".").slice(0, 60));
return name;
}
/** Re-read progress from disk (old tasks still writing to it). */
const readTasks = (): Record<string, { status: string }> | null => { const readTasks = (): Record<string, { status: string }> | null => {
try { try {
const raw = fs.readFileSync(progressPath, "utf-8"); const raw = fs.readFileSync(progressPath, "utf-8");
@@ -399,226 +527,89 @@ export default function ralpiLoopExtension(pi: ExtensionAPI): void {
} }
}; };
// Early exit: if all tasks already finished during the reload, just clean up // ralpi agent sessions run in-process (createAgentSession), so they do
// NOT survive a session reload. Any task left `in_progress` is therefore
// stalled — its agent died with the previous session. Detect that state
// and actively resume the loop instead of passively polling (which would
// spin forever waiting for a dead task to complete).
const initialTasks = readTasks(); const initialTasks = readTasks();
if (initialTasks) { if (initialTasks) {
const remaining = Object.values(initialTasks).filter( const inProgressIds = Object.entries(initialTasks).flatMap(([id, t]) =>
(t) => t.status === "in_progress", t.status === "in_progress" ? [id] : [],
).length; );
if (remaining === 0) {
ctx.ui.notify("All ralpi tasks completed during reload.", "info"); if (inProgressIds.length === 0) {
// Nothing was mid-flight — loop either finished cleanly between
// the reload landing and this handler running, or was stopped
// between tasks. Clean up the stale marker and bail.
ctx.ui.notify(
"ralpi loop has no in-progress task to resume — marking complete.",
"info",
);
deleteLoopActive(projectDir); deleteLoopActive(projectDir);
return; return;
} }
}
// Show a status notification for the reconnect const taskCount = loopState.taskIds.length;
const taskCount = loopState.taskIds.length; ctx.ui.notify(
ctx.ui.notify( `ralpi loop was interrupted by reload with ${inProgressIds.length} in-progress task(s). ` +
`Reconnected to running ralpi execution (${taskCount} tasks, ${loopState.mode} mode)`, `Resuming execution (${taskCount} tasks, ${loopState.mode} mode)...`,
"info", "info",
); );
// Shared state for the widget // Build the sendProgress wrapper so resumed task messages render the
let tickCount = 0; // same expandable tool-call tree as an interactive run.
const MAX_COLLAPSED = 3; const sendProgress: SendChatMessage = (
content: string,
if (loopState.mode === "parallel") { meta?: {
// ── Parallel mode: single batch widget ── toolCalls?: Array<{ name: string; label: string }>;
const widgetKey = `ralpi-parallel-reconnect-${Date.now()}`; reviewText?: string;
let widgetTui: { requestRender(): void } | null = null; reviewPath?: string;
reviewResult?: ReviewResult;
const buildBatchLines = (t: typeof ctx.ui.theme): string[] => { },
const tasks = readTasks(); ) => {
if (!tasks) return [t.fg("dim", "(waiting for progress...)")]; pi.sendMessage({
customType: "ralpi-progress",
const lines: string[] = []; content,
// Only show tasks that have started (in_progress, completed, failed). display: true,
// Pending/unstarted tasks are noise after a reload. details: {
const sortedIds = [...loopState.taskIds].sort().filter((id) => { phase: "progress",
const info = tasks[id]; toolCalls: meta?.toolCalls,
return info && info.status !== "pending"; reviewText: meta?.reviewText,
reviewPath: meta?.reviewPath,
reviewResult: meta?.reviewResult,
},
}); });
// If no tasks have started yet, show nothing — polling will pick up
// changes within 500ms.
if (sortedIds.length === 0) return [t.fg("dim", "(starting tasks...)")];
for (const id of sortedIds) {
const info = tasks[id]!;
const title = titleMap.get(id);
const header = title ? `${id} · ${title}` : id;
// Status icon
if (info.status === "completed") {
lines.push(`${t.fg("success", "✓")} ${header}`);
} else if (info.status === "failed") {
lines.push(`${t.fg("error", "✗")} ${header}`);
} else if (info.status === "in_progress") {
const frame = t.fg(
"accent",
SPINNER_FRAMES[tickCount % SPINNER_FRAMES.length],
);
lines.push(`${frame} ${header}`);
// Show recent tool calls for active tasks
const toolCalls = readRecentToolCalls(id);
if (toolCalls.length > 0) {
if (toolCalls.length <= MAX_COLLAPSED) {
for (let i = 0; i < toolCalls.length; i++) {
const tc = toolCalls[i];
const isLast = i === toolCalls.length - 1;
const branch = isLast ? " └── " : " ├── ";
lines.push(
`${branch}${t.fg("accent", `[${tc.name}]`)} ${tc.label}`,
);
}
} else {
const shown = toolCalls.slice(-MAX_COLLAPSED);
const remaining = toolCalls.length - shown.length;
lines.push(t.fg("dim", ` ├── …${remaining} earlier`));
for (let i = 0; i < shown.length; i++) {
const tc = shown[i];
const isLast = i === shown.length - 1;
const branch = isLast ? " └── " : " ├── ";
lines.push(
`${branch}${t.fg("accent", `[${tc.name}]`)} ${tc.label}`,
);
}
}
}
}
}
return lines;
}; };
ctx.ui.setWidget(widgetKey, (tui, t) => { // Load config from the project directory so model + thinking level
widgetTui = tui; // resolve the same way the interactive command handler does.
return { const config = loadConfig(projectDir);
render: () => buildBatchLines(t),
invalidate: () => widgetTui?.requestRender(),
};
});
// 100ms tick: advances spinner frame every tick, refreshes try {
// progress + tool calls every 5 ticks (500ms). await resumeLoop(
const tickTimer = setInterval(() => { ctx,
if (abortPolling) return; loopState.taskFile,
tickCount++; projectDir,
widgetTui?.requestRender(); loopState.prdKey,
sendProgress,
if (tickCount % 5 === 0) { config.model ?? ctx.model,
const tasks = readTasks(); pi.getThinkingLevel(),
if (!tasks) return; {
const activeCount = Object.values(tasks).filter( mode: loopState.mode,
(t) => t.status === "in_progress", autoCommit: loopState.autoCommit ?? config.execution.autoCommit,
).length; autoReview: loopState.autoReview ?? config.execution.autoReview,
if (activeCount === 0) { saveReviews: loopState.saveReviews ?? config.execution.saveReviews,
clearInterval(tickTimer); skipFinalStatus: false,
ctx.ui.setWidget(widgetKey, undefined); },
deleteLoopActive(projectDir);
}
}
}, 100);
// Clean up timer when extension is shut down
pi.on("session_shutdown", () => {
abortPolling = true;
clearInterval(tickTimer);
});
} else {
// ── Sequential mode: per-task widget ──
const currentTaskId = loopState.taskIds.find((id) => {
const tasks = readTasks();
return tasks?.[id]?.status === "in_progress";
});
if (currentTaskId) {
const widgetKey = `ralpi-task-${currentTaskId}`;
let widgetTui: { requestRender(): void } | null = null;
const buildLines = (t: typeof ctx.ui.theme): string[] => {
const tasks = readTasks();
const info = tasks?.[currentTaskId];
const title = titleMap.get(currentTaskId);
const header = title ? `${currentTaskId} · ${title}` : currentTaskId;
const lines: string[] = [];
if (!info || info.status === "pending") {
return [t.fg("dim", "(starting task...)")];
}
if (info.status === "completed") {
lines.push(`${t.fg("success", "✓")} ${header}`);
} else if (info.status === "failed") {
lines.push(`${t.fg("error", "✗")} ${header}`);
} else if (info.status === "in_progress") {
const frame = t.fg(
"accent",
SPINNER_FRAMES[tickCount % SPINNER_FRAMES.length],
);
lines.push(`${frame} ${header}`);
// Show recent tool calls
const toolCalls = readRecentToolCalls(currentTaskId);
if (toolCalls.length > 0) {
const shown = toolCalls.slice(-MAX_COLLAPSED);
const remaining = toolCalls.length - shown.length;
if (remaining > 0) {
lines.push(t.fg("dim", ` ├── …${remaining} earlier`));
}
for (let i = 0; i < shown.length; i++) {
const tc = shown[i];
const isLast = i === shown.length - 1;
const branch = isLast ? " └── " : " ├── ";
lines.push(
`${branch}${t.fg("accent", `[${tc.name}]`)} ${tc.label}`,
);
}
}
}
return lines;
};
ctx.ui.setWidget(widgetKey, (tui, t) => {
widgetTui = tui;
return {
render: () => buildLines(t),
invalidate: () => widgetTui?.requestRender(),
};
});
const tickTimer = setInterval(() => {
if (abortPolling) return;
tickCount++;
widgetTui?.requestRender();
if (tickCount % 5 === 0) {
const tasks = readTasks();
if (!tasks) return;
const status = tasks[currentTaskId]?.status;
if (status !== "in_progress") {
clearInterval(tickTimer);
// Keep widget visible a moment, then clean up
setTimeout(() => {
ctx.ui.setWidget(widgetKey, undefined);
deleteLoopActive(projectDir);
}, 3000);
}
}
}, 100);
pi.on("session_shutdown", () => {
abortPolling = true;
clearInterval(tickTimer);
});
} else {
// No task actively in progress — show a "resume" hint
ctx.ui.notify(
"No running task found. Use /ralpi resume to continue execution.",
"warning",
); );
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
ctx.ui.notify(`ralpi auto-resume failed: ${msg}`, "error");
// Leave loop-active.json in place so the user can retry via
// /ralpi resume after addressing the underlying error.
} }
return;
} }
}); });
@@ -631,16 +622,29 @@ export default function ralpiLoopExtension(pi: ExtensionAPI): void {
// Wraps pi.sendMessage() for posting status to the chat history. // Wraps pi.sendMessage() for posting status to the chat history.
// Uses "ralpi-progress" customType with a "progress" phase so the // Uses "ralpi-progress" customType with a "progress" phase so the
// renderer omits the label prefix entirely (no [INFO] etc.). // renderer omits the label prefix entirely (no [INFO] etc.).
// Accepts an optional meta object with toolCalls for the expandable view. // Accepts an optional meta object with toolCalls for the expandable view,
// and reviewText/reviewPath/reviewResult for review messages so the expanded
// (Ctrl+O) view can render the full review body without truncation.
const sendProgress: SendChatMessage = ( const sendProgress: SendChatMessage = (
content: string, content: string,
meta?: { toolCalls?: Array<{ name: string; label: string }> }, meta?: {
toolCalls?: Array<{ name: string; label: string }>;
reviewText?: string;
reviewPath?: string;
reviewResult?: ReviewResult;
},
) => { ) => {
pi.sendMessage({ pi.sendMessage({
customType: "ralpi-progress", customType: "ralpi-progress",
content, content,
display: true, display: true,
details: { phase: "progress", toolCalls: meta?.toolCalls }, details: {
phase: "progress",
toolCalls: meta?.toolCalls,
reviewText: meta?.reviewText,
reviewPath: meta?.reviewPath,
reviewResult: meta?.reviewResult,
},
}); });
}; };
@@ -784,6 +788,13 @@ async function handleRun(
const completed = buildCompletedSet(progress, project); const completed = buildCompletedSet(progress, project);
const mode = await selectExecutionMode(ctx, project, taskFile, config); const mode = await selectExecutionMode(ctx, project, taskFile, config);
const { autoCommit, autoReview, saveReviews } = await selectLoopOptions(
ctx,
config,
);
config.execution.autoCommit = autoCommit;
config.execution.autoReview = autoReview;
config.execution.saveReviews = saveReviews;
const plan = buildPlanByMode(mode, project, completed); const plan = buildPlanByMode(mode, project, completed);
// Show dependency chain + execution plan before starting // Show dependency chain + execution plan before starting
@@ -830,44 +841,32 @@ async function handleRun(
// ─── /ralpi resume ─────────────────────────────────────────────────────────── // ─── /ralpi resume ───────────────────────────────────────────────────────────
async function handleResume( /**
* Resume core: given a resolved task file, project dir, and PRD key,
* build the remaining plan and execute it. Used by both the explicit
* `/ralpi resume` command and the auto-resume on session reload.
*
* `mode` and loop options (`autoCommit`/`autoReview`/`saveReviews`) may be
* passed to skip interactive prompts — this is how a reload resumes
* non-interactively using the snapshot stored in loop-active.json.
* When omitted, the user is prompted as usual.
*/
async function resumeLoop(
ctx: ExtensionContext, ctx: ExtensionContext,
args: string[], taskFile: string,
sendChatMessage?: SendChatMessage, projectDir: string,
parentModel?: unknown, prdKey: string | undefined,
parentThinkingLevel?: unknown, sendChatMessage: SendChatMessage | undefined,
parentModel: unknown,
parentThinkingLevel: unknown,
options?: {
mode?: ExecutionMode;
autoCommit?: boolean;
autoReview?: boolean;
saveReviews?: boolean;
skipFinalStatus?: boolean;
},
): Promise<void> { ): Promise<void> {
let taskFile: string;
let projectDir: string;
let found: ReturnType<typeof findProgressFile>;
if (args[0]) {
taskFile = resolveTaskArg(args[0], process.cwd());
found = findProgressFile(process.cwd(), taskFile);
if (!found) {
ctx.ui.notify(
`No existing progress for ${args[0]}. Start with /ralpi run ${args[0]}`,
"warning",
);
return;
}
projectDir = path.dirname(path.dirname(found.path));
} else {
found = findProgressFile(process.cwd());
if (!found) {
ctx.ui.notify(
"No .ralpi/progress.json found. Start with /ralpi run [task-file]",
"warning",
);
return;
}
projectDir = path.dirname(path.dirname(found.path));
// For no-arg resume, use the first PRD's source path or legacy sourcePath
taskFile = found.state.prds
? Object.values(found.state.prds)[0].sourcePath
: found.state.sourcePath;
}
const project = parseTaskFile(taskFile); const project = parseTaskFile(taskFile);
if (!Array.isArray(project.tasks)) { if (!Array.isArray(project.tasks)) {
throw new Error( throw new Error(
@@ -877,21 +876,63 @@ async function handleResume(
const config = loadConfig(projectDir); const config = loadConfig(projectDir);
config.model = parentModel ?? ctx.model; config.model = parentModel ?? ctx.model;
config.thinkingLevel = parentThinkingLevel; config.thinkingLevel = parentThinkingLevel;
const progress = new ProgressTracker(projectDir, taskFile, found.prdKey); const progress = new ProgressTracker(projectDir, taskFile, prdKey);
progress.setPaused(false); progress.setPaused(false);
// Any task left `in_progress` died with the previous session (ralpi runs
// agents in-process). Reset them to `pending` so the DAG re-schedules
// them cleanly. Without this they'd still be re-run (they're not in the
// completed set), but the progress.json would carry a stale in_progress
// state during the rebuild window.
const resetIds = progress.resetInProgressToPending();
if (resetIds.length > 0) {
// Keep the source-file checkboxes in sync so a later parse sees these
// tasks as `pending` rather than `in_progress`.
for (const id of resetIds) {
try {
updateTaskInFile(taskFile, id, "pending");
} catch {
// Best-effort — progress.json is the source of truth for scheduling.
}
}
ctx.ui.notify(
`Reset stalled in-progress task(s) to pending: ${resetIds.join(", ")}`,
"info",
);
}
const completed = buildCompletedSet(progress, project); const completed = buildCompletedSet(progress, project);
const mode = await selectExecutionMode(ctx, project, taskFile, config); const mode =
options?.mode ??
(await selectExecutionMode(ctx, project, taskFile, config));
let autoCommit: boolean;
let autoReview: boolean;
let saveReviews: boolean;
if (
options?.autoCommit !== undefined &&
options?.autoReview !== undefined &&
options?.saveReviews !== undefined
) {
autoCommit = options.autoCommit;
autoReview = options.autoReview;
saveReviews = options.saveReviews;
} else {
const opt = await selectLoopOptions(ctx, config);
autoCommit = opt.autoCommit;
autoReview = opt.autoReview;
saveReviews = opt.saveReviews;
}
config.execution.autoCommit = autoCommit;
config.execution.autoReview = autoReview;
config.execution.saveReviews = saveReviews;
const plan = buildPlanByMode(mode, project, completed); const plan = buildPlanByMode(mode, project, completed);
// Print remaining batches before executing // Print remaining batches before executing
const formattedPlan = formatExecutionPlan(plan); const formattedPlan = formatExecutionPlan(plan);
if (mode === "parallel") { if (mode === "parallel") {
ctx.ui.notify( ctx.ui.notify(`${formattedPlan}\n\nResuming parallel execution...`, "info");
`${formattedPlan}\n\nResuming parallel execution...`,
"info",
);
} else { } else {
ctx.ui.notify( ctx.ui.notify(
`${formattedPlan}\n\nResuming sequential execution...`, `${formattedPlan}\n\nResuming sequential execution...`,
@@ -909,9 +950,68 @@ async function handleResume(
mode, mode,
sendChatMessage, sendChatMessage,
projectDir, projectDir,
true, // isResume — preserve in-progress worktrees, continue them
); );
ctx.ui.notify(formatProgressStatus(progress.getState()), "info"); if (!options?.skipFinalStatus) {
ctx.ui.notify(formatProgressStatus(progress.getState()), "info");
}
}
async function handleResume(
ctx: ExtensionContext,
args: string[],
sendChatMessage?: SendChatMessage,
parentModel?: unknown,
parentThinkingLevel?: unknown,
): Promise<void> {
let taskFile: string;
let projectDir: string;
let prdKey: string | undefined;
if (args[0]) {
taskFile = resolveTaskArg(args[0], process.cwd());
const found = findProgressFile(process.cwd(), taskFile);
if (!found) {
ctx.ui.notify(
`No existing progress for ${args[0]}. Start with /ralpi run ${args[0]}`,
"warning",
);
return;
}
projectDir = path.dirname(path.dirname(found.path));
prdKey = found.prdKey;
} else {
const found = findProgressFile(process.cwd());
if (!found) {
ctx.ui.notify(
"No .ralpi/progress.json found. Start with /ralpi run [task-file]",
"warning",
);
return;
}
projectDir = path.dirname(path.dirname(found.path));
// When no specific task file is given, let the user select which loop
// to resume from multiple PRDs (sorted by most recent first).
const selected = await selectPRDToResume(ctx, found);
if (!selected) {
ctx.ui.notify("Resume cancelled.", "info");
return;
}
taskFile = selected.sourcePath;
prdKey = selected.prdKey;
}
await resumeLoop(
ctx,
taskFile,
projectDir,
prdKey,
sendChatMessage,
parentModel,
parentThinkingLevel,
);
} }
// ─── /ralpi next ───────────────────────────────────────────────────────────── // ─── /ralpi next ─────────────────────────────────────────────────────────────
@@ -941,12 +1041,11 @@ async function handleReset(
return; return;
} }
const projectDir = path.dirname(path.dirname(found.path)); const projectDir = path.dirname(path.dirname(found.path));
const progress = new ProgressTracker( // Use the most recently updated PRD (first in sorted order)
projectDir, const prds = listPRDsSorted(found.state);
found.state.prds const sourcePath =
? Object.values(found.state.prds)[0].sourcePath prds.length > 0 ? prds[0].prd.sourcePath : found.state.sourcePath;
: found.state.sourcePath, const progress = new ProgressTracker(projectDir, sourcePath);
);
progress.reset(); progress.reset();
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "@mikefreno/ralpi", "name": "@mikefreno/ralpi",
"version": "0.2.5", "version": "0.3.0",
"description": "Execute tasks from task files/PRD's using DAG-based dependency resolution with persistent progress tracking", "description": "Execute tasks from task files/PRD's using DAG-based dependency resolution with persistent progress tracking",
"keywords": [ "keywords": [
"pi-package", "pi-package",

View File

@@ -26,5 +26,10 @@ export const TASK_FILE_NAMES = [
export const REFLECTION_HEADER = "## REFLECTION"; export const REFLECTION_HEADER = "## REFLECTION";
export const REFLECTION_PATTERN = /##\s*REFLECTION\s*\n([\s\S]*?)(?=\n```|$)/i; export const REFLECTION_PATTERN = /##\s*REFLECTION\s*\n([\s\S]*?)(?=\n```|$)/i;
// Review verdict parsing
export const REVIEW_HEADER = "## REVIEW VERDICT";
export const REVIEW_PATTERN =
/##\s*REVIEW\s+VERDICT\s*\n([\s\S]*?)(?=\n```|$)/i;
// Pi subprocess // Pi subprocess
export const DEFAULT_PI_ARGS = ["--no-stream"] as const; export const DEFAULT_PI_ARGS = ["--no-stream"] as const;

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,7 @@ import type {
Task, Task,
Reflection, Reflection,
ToolUsage, ToolUsage,
ReviewResult,
} from "./types"; } from "./types";
import { ensureDir } from "./utils"; import { ensureDir } from "./utils";
@@ -174,6 +175,8 @@ export class ProgressTracker {
outputPreview?: string, outputPreview?: string,
commitMessages?: string[], commitMessages?: string[],
commitSummary?: string, commitSummary?: string,
review?: ReviewResult,
reviewRetries?: number,
): void { ): void {
const prd = this.getPRD(); const prd = this.getPRD();
this.ensureTask(prd, taskId); this.ensureTask(prd, taskId);
@@ -185,6 +188,9 @@ export class ProgressTracker {
if (outputPreview) prd.tasks[taskId].outputPreview = outputPreview; if (outputPreview) prd.tasks[taskId].outputPreview = outputPreview;
if (commitMessages) prd.tasks[taskId].commitMessages = commitMessages; if (commitMessages) prd.tasks[taskId].commitMessages = commitMessages;
if (commitSummary) prd.tasks[taskId].commitSummary = commitSummary; if (commitSummary) prd.tasks[taskId].commitSummary = commitSummary;
if (review) prd.tasks[taskId].review = review;
if (reviewRetries !== undefined)
prd.tasks[taskId].reviewRetries = reviewRetries;
this.save(); this.save();
} }
@@ -237,15 +243,6 @@ export class ProgressTracker {
.filter((r): r is Reflection => r !== undefined); .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 */ /** Set paused state */
setPaused(paused: boolean): void { setPaused(paused: boolean): void {
const prd = this.getPRD(); const prd = this.getPRD();
@@ -253,6 +250,26 @@ export class ProgressTracker {
this.save(); this.save();
} }
/** Reset all `in_progress` tasks back to `pending`.
*
* Used after a session reload: in-process agent sessions die with the
* parent session, so any task left `in_progress` is actually stalled.
* Resetting ensures the DAG re-schedules it on the next resume. Returns
* the IDs that were reset. */
resetInProgressToPending(): string[] {
const prd = this.getPRD();
const reset: string[] = [];
for (const [id, info] of Object.entries(prd.tasks)) {
if (info.status === "in_progress") {
info.status = "pending";
delete info.startedAt;
reset.push(id);
}
}
if (reset.length > 0) this.save();
return reset;
}
/** Get the raw PRD state (for status display) */ /** Get the raw PRD state (for status display) */
getState(): PRDProgress { getState(): PRDProgress {
return this.getPRD(); return this.getPRD();
@@ -277,7 +294,7 @@ export class ProgressTracker {
private ensureTask(prd: PRDProgress, taskId: string): void { private ensureTask(prd: PRDProgress, taskId: string): void {
if (!prd.tasks[taskId]) { 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 type { Task, Project, Reflection, ReviewResult } from "./types";
import { readTaskSpec } from "./parser"; 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 ───────────────────────────────────────────────────────────── // ─── Task Prompt ─────────────────────────────────────────────────────────────
/** /**
@@ -12,6 +35,9 @@ export function buildTaskPrompt(
project: Project, project: Project,
depReflections: Reflection[], depReflections: Reflection[],
projectContext?: string, projectContext?: string,
/** Review feedback from a rejected review — injected when re-executing
* a task in review-gated mode so the agent knows what to fix. */
reviewFeedback?: ReviewResult,
): string { ): string {
const parts: string[] = []; const parts: string[] = [];
@@ -107,6 +133,32 @@ export function buildTaskPrompt(
parts.push(""); parts.push("");
} }
// ── Previous Review Feedback (re-execution only) ──
if (reviewFeedback) {
parts.push("## Previous Review Feedback — FIX REQUIRED");
parts.push(
"A review agent examined your previous attempt and rejected it.",
);
parts.push(`Verdict: **${reviewFeedback.verdict.toUpperCase()}**`);
parts.push(`Summary: ${reviewFeedback.summary}`);
parts.push("");
if (reviewFeedback.findings.length > 0) {
parts.push("You MUST address these findings:");
for (const finding of reviewFeedback.findings) {
const loc = finding.file
? finding.line
? ` (${finding.file}:${finding.line})`
: ` (${finding.file})`
: "";
parts.push(`- [${finding.severity}]${loc} ${finding.message}`);
}
parts.push("");
}
parts.push("Fix every issue above. Do not re-introduce the same problems.");
parts.push("");
}
// ── Reflection Instructions ── // ── Reflection Instructions ──
parts.push("## REFLECTION (REQUIRED)"); parts.push("## REFLECTION (REQUIRED)");
@@ -136,7 +188,208 @@ export function buildTaskPrompt(
return parts.join("\n"); 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(...reviewInstructions());
parts.push("");
parts.push(
"Provide a concise review with any issues found. Your free-form prose",
);
parts.push("precedes the structured verdict block below.");
parts.push(...reviewVerdictBlock());
return parts.join("\n");
}
// ─── Uncommitted-Changes Review Prompt ──────────────────────────────────────
/**
* Build a review prompt for uncommitted working-tree changes (pre-commit).
* Used in review-gated mode: the review runs BEFORE committing so a rejected
* review triggers a re-execution instead of a bad commit.
*/
export function buildReviewPromptUncommitted(
task: Task,
project: Project,
status: string,
diff: string,
projectContext?: string,
): string {
const parts: string[] = [];
parts.push(`# Code Review (pre-commit): 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("");
}
}
// ── Uncommitted Changes Under Review ──
parts.push("## Uncommitted Changes Under Review");
parts.push(
"Review the working-tree changes below against the task description.",
);
parts.push("");
parts.push("### Current Changes (git status --porcelain)");
parts.push("```text");
parts.push(status || "(no status output)");
parts.push("```");
parts.push("");
parts.push("### Current Tracked Diff (git diff)");
parts.push("```diff");
parts.push(truncateDiff(diff) || "(no tracked diff output)");
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 uncommitted changes above against the task description. Check for:",
);
parts.push(...reviewInstructions());
parts.push("");
parts.push(
"Provide a concise review with any issues found. Your free-form prose",
);
parts.push("precedes the structured verdict block below.");
parts.push(...reviewVerdictBlock());
return parts.join("\n");
}
// ─── Shared Review Prompt Helpers ───────────────────────────────────────────
function reviewInstructions(): string[] {
return [
"- **Correctness**: Does the implementation fulfill the task requirements?",
"- **Completeness**: Are all aspects of the task addressed?",
"- **Code quality**: Are there obvious bugs, anti-patterns, or issues?",
"- **Missing changes**: Are there files that should have been modified but weren't?",
];
}
function reviewVerdictBlock(): string[] {
return [
"## REVIEW VERDICT (REQUIRED)",
"End your response with a verdict block in EXACTLY this format:",
"",
"```",
"## REVIEW VERDICT",
"VERDICT: [pass | warn | fail]",
"SUMMARY: [1-2 sentence overall assessment]",
"FINDINGS:",
"- [blocker] file:line description (use severity: blocker|warning|nit|info)",
"- [warning] file:line description",
"```",
"",
"Verdict guidance:",
"- **pass**: the implementation fully satisfies the task requirements; no",
" action needed. Use an empty FINDINGS section (just the header).",
"- **warn**: the implementation is acceptable but has minor issues worth fixing",
" in a follow-up; not blocking.",
"- **fail**: the implementation does not satisfy the task, or has serious bugs",
" that must be fixed before proceeding.",
"",
"Each FINDINGS line uses the form `- [severity] [file:line] message`.",
"The `file:line` part is optional. Severity must be one of:",
"`blocker`, `warning`, `nit`, `info`.",
];
}
/** /**
* Build the prompt for a dry-run / plan display * Build the prompt for a dry-run / plan display
@@ -155,9 +408,10 @@ export function buildPlanPrompt(project: Project): string {
lines.push("## Tasks"); lines.push("## Tasks");
for (const task of project.tasks) { for (const task of project.tasks) {
const deps = task.dependencies.length > 0 const deps =
? ` (depends on: ${task.dependencies.join(", ")})` task.dependencies.length > 0
: ""; ? ` (depends on: ${task.dependencies.join(", ")})`
: "";
lines.push(`- [ ] ${task.id}: ${task.title}${deps}`); lines.push(`- [ ] ${task.id}: ${task.title}${deps}`);
} }
lines.push(""); lines.push("");
@@ -172,3 +426,101 @@ export function buildPlanPrompt(project: Project): string {
return lines.join("\n"); return lines.join("\n");
} }
// ─── Conflict Resolution Prompt ─────────────────────────────────────────────
/**
* Build the prompt for a conflict-resolution agent session.
*
* The main repo is in a merge-conflict state (from `reattemptMerge`). The
* agent must resolve all conflict markers in the conflicted files, stage the
* resolved files, and commit to complete the merge.
*/
export function buildConflictResolutionPrompt(
task: Task,
project: Project,
conflicts: string[],
branch: string,
projectContext?: string,
): string {
const parts: string[] = [];
parts.push(`# Merge Conflict Resolution: Task ${task.id}: ${task.title}`);
parts.push("");
parts.push(
`A merge of branch \`${branch}\` into the current branch produced conflicts.`,
);
parts.push("You must resolve all conflicts and complete the merge.");
parts.push("");
// ── Task Context ──
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("");
}
}
// ── Conflicted Files ──
parts.push("## Conflicted Files");
parts.push(
"The following files have unresolved merge conflicts (conflict markers `<<<<<<<`, `=======`, `>>>>>>>`):",
);
parts.push("");
for (const f of conflicts) {
parts.push(`- \`${f}\``);
}
parts.push("");
// ── Project Context ──
if (projectContext) {
parts.push("## Additional Context");
parts.push(projectContext);
parts.push("");
}
// ── Resolution Instructions ──
parts.push("## Resolution Instructions");
parts.push(
"1. Read each conflicted file to understand both sides of the conflict.",
);
parts.push(
"2. Edit each file to remove all conflict markers (`<<<<<<<`, `=======`, `>>>>>>>`).",
);
parts.push(
" Keep the correct changes from both sides — do NOT blindly pick one side.",
);
parts.push(
" The goal is a correct union of both the task's changes and the main branch.",
);
parts.push(
"3. After resolving all conflicts, stage the resolved files with `git add <files>`.",
);
parts.push(
"4. Complete the merge with `git commit` — use the default merge message.",
);
parts.push("");
parts.push(
"Resolve ALL conflicts. Do NOT abort the merge. Do NOT leave any conflict markers.",
);
return parts.join("\n");
}

211
src/review.ts Normal file
View File

@@ -0,0 +1,211 @@
import * as fs from "node:fs";
import * as path from "node:path";
import type { ReviewResult, ReviewFinding, ReviewVerdict } from "./types";
import { REVIEW_PATTERN } from "./constants";
import { ensureDir, writeFileSafe } from "./utils";
// ─── Extract Structured Review ──────────────────────────────────────────────
/**
* Extract a structured review verdict from the review agent's output text.
* Mirrors extractReflection() — parses a `## REVIEW VERDICT` block emitted at
* the end of the response.
*
* The raw text is preserved on the ReviewResult so the expanded (Ctrl+O) view
* can still render the full free-form prose. Returns null when no verdict
* block is found (caller falls back to free-form text handling).
*/
export function extractReview(
output: string,
taskId: string,
commitHash: string,
): ReviewResult | null {
const match = output.match(REVIEW_PATTERN);
if (!match) return null;
const block = match[1];
const verdict = extractVerdict(block);
if (!verdict) return null; // verdict is the one required field
const summary = extractField(block, "SUMMARY") ?? "";
const findings = extractFindings(block);
return {
taskId,
verdict,
summary: summary || verdictLabel(verdict),
findings,
commitHash,
rawText: output.trim(),
timestamp: new Date().toISOString(),
};
}
function extractVerdict(block: string): ReviewVerdict | null {
const raw = extractField(block, "VERDICT");
if (!raw) return null;
const v = raw.toLowerCase().trim();
if (v === "pass" || v === "warn" || v === "fail") return v;
// Tolerate common synonyms
if (v === "warning" || v === "minor") return "warn";
if (v === "fail" || v === "failing" || v === "blocker") return "fail";
if (v === "ok" || v === "passing" || v === "approve") return "pass";
return null;
}
// Allowlisted static regexes — `field` is always a known literal, but we use
// a static map rather than string interpolation so there's no dynamic regex
// construction at all (`new RegExp` from a variable trips ReDoS linters).
const FIELD_PATTERNS: Record<string, RegExp> = {
VERDICT: /VERDICT:\s*(.+?)$/im,
SUMMARY: /SUMMARY:\s*(.+?)$/im,
};
function extractField(block: string, field: string): string | null {
const regex = FIELD_PATTERNS[field.toUpperCase()];
if (!regex) return null;
const match = block.match(regex);
return match ? match[1].trim() : null;
}
/**
* Parse FINDINGS: lines into structured ReviewFinding objects.
* Each finding line is expected as:
* - [severity] [file:line] message
* where severity is one of blocker|warning|nit|info.
* Falls back gracefully — an unparseable line becomes an info-severity
* finding with the raw line as the message.
*/
function extractFindings(block: string): ReviewFinding[] {
// Match the FINDINGS: header, then capture all following bullet lines.
const regex = /FINDINGS:\s*\n((?:[-*]\s+.+\n?)+)/i;
const match = block.match(regex);
if (!match) return [];
const lines = match[1]
.split("\n")
.map((l) => l.replace(/^[-*]\s*/, "").trim())
.filter(Boolean);
const findings: ReviewFinding[] = [];
const severityRe = /^\[(blocker|warning|warn|nit|info)\]\s*(.*)$/i;
for (const line of lines) {
const sm = line.match(severityRe);
if (sm) {
let sev = sm[1].toLowerCase();
if (sev === "warn") sev = "warning";
const rest = sm[2].trim();
const { file, line: lineNum, message } = parseFileRef(rest);
findings.push({
severity: sev as ReviewFinding["severity"],
file,
line: lineNum,
message,
});
} else {
// No severity bracket — treat as info
const { file, line: lineNum, message } = parseFileRef(line);
findings.push({ severity: "info", file, line: lineNum, message });
}
}
return findings;
}
/** Parse an optional `file:line` prefix from a finding message. */
function parseFileRef(rest: string): {
file?: string;
line?: number;
message: string;
} {
const m = rest.match(/^([\w./-]+):(\d+)\s*[-—]?\s*(.*)$/);
if (m) {
return { file: m[1], line: Number(m[2]), message: m[3].trim() || rest };
}
return { message: rest };
}
function verdictLabel(v: ReviewVerdict): string {
switch (v) {
case "pass":
return "Commit satisfies the task requirements.";
case "warn":
return "Commit passes with minor issues worth addressing.";
case "fail":
return "Commit does not satisfy the task requirements.";
}
}
// ─── Save / Load Structured Reviews ─────────────────────────────────────────
/**
* Save a structured review as JSON alongside (or instead of) the markdown
* body. Mirrors saveReflectionToFile's per-loop layout so a repo can hold
* many loops without collisions:
* .ralpi/reviews/<prdKey>/<taskId>.json
*/
export function saveReviewToFile(
sourceDir: string,
reviewsDir: string,
review: ReviewResult,
prdKey: string,
): string {
const dir = path.join(sourceDir, reviewsDir, prdKey);
ensureDir(dir);
const filePath = path.join(dir, `${review.taskId}.json`);
writeFileSafe(filePath, JSON.stringify(review, null, 2));
return filePath;
}
/**
* Load a structured review from disk.
*/
export function loadReview(
sourceDir: string,
reviewsDir: string,
taskId: string,
prdKey: string,
): ReviewResult | null {
const filePath = path.join(sourceDir, reviewsDir, prdKey, `${taskId}.json`);
if (!fs.existsSync(filePath)) return null;
try {
return JSON.parse(fs.readFileSync(filePath, "utf-8")) as ReviewResult;
} catch {
return null;
}
}
// ─── Formatting ──────────────────────────────────────────────────────────────
/** Verdict glyph for compact display in chat headers / widgets. */
export function verdictGlyph(v: ReviewVerdict): string {
switch (v) {
case "pass":
return "✓";
case "warn":
return "⚠";
case "fail":
return "✗";
}
}
/** Short label: "PASS · 0 findings", "WARN · 2 findings", "FAIL · 3 findings" */
export function verdictSummary(review: ReviewResult): string {
const n = review.findings.length;
const noun = n === 1 ? "finding" : "findings";
return `${review.verdict.toUpperCase()} · ${n} ${noun}`;
}
/**
* Format findings as an indented markdown tree for the expanded view.
*/
export function formatFindings(review: ReviewResult): string {
if (review.findings.length === 0) return "(no findings)";
const lines: string[] = [];
for (const f of review.findings) {
const loc = f.file ? (f.line ? `${f.file}:${f.line}` : f.file) : "";
lines.push(` - [${f.severity}]${loc ? ` ${loc}` : ""}${f.message}`);
}
return lines.join("\n");
}

View File

@@ -103,6 +103,37 @@ export interface Reflection {
timestamp: string; timestamp: string;
} }
// ─── Review Model ────────────────────────────────────────────────────────────
export type ReviewVerdict = "pass" | "warn" | "fail";
export interface ReviewFinding {
/** Severity of the finding */
severity: "blocker" | "warning" | "nit" | "info";
/** File path if applicable */
file?: string;
/** Line number if applicable */
line?: number;
/** Description of the issue */
message: string;
}
export interface ReviewResult {
taskId: string;
/** Overall verdict */
verdict: ReviewVerdict;
/** 1-2 sentence overall assessment */
summary: string;
/** Structured findings (empty when verdict is "pass") */
findings: ReviewFinding[];
/** Commit hash the review was performed against */
commitHash: string;
/** Full free-form review text (preserved for display) */
rawText: string;
/** ISO timestamp */
timestamp: string;
}
export interface ToolUsage { export interface ToolUsage {
read: number; read: number;
write: number; write: number;
@@ -115,9 +146,10 @@ export interface TaskProgressInfo {
status: Task["status"]; status: Task["status"];
startedAt?: string; startedAt?: string;
completedAt?: string; completedAt?: string;
retries: number;
durationMs?: number; durationMs?: number;
reflection?: Reflection; reflection?: Reflection;
/** Structured review result (when autoReview is enabled) */
review?: ReviewResult;
error?: string; error?: string;
/** Tool usage counts from parsed subprocess output */ /** Tool usage counts from parsed subprocess output */
toolUsage?: ToolUsage; toolUsage?: ToolUsage;
@@ -127,6 +159,8 @@ export interface TaskProgressInfo {
commitMessages?: string[]; commitMessages?: string[];
/** Summary derived from git commits */ /** Summary derived from git commits */
commitSummary?: string; commitSummary?: string;
/** Number of review-fix re-execution attempts made (review-gated mode) */
reviewRetries?: number;
} }
export interface ProgressState { export interface ProgressState {
@@ -165,18 +199,59 @@ export interface RalpiConfig {
stateDir: string; stateDir: string;
/** Directory for per-task reflections */ /** Directory for per-task reflections */
reflectionsDir: string; reflectionsDir: string;
/** Directory for per-loop review output (mirrors reflectionsDir) */
reviewsDir: string;
}; };
execution: { execution: {
/** Maximum retries per task */
maxRetries: number;
/** Delay between retries in milliseconds */
retryDelayMs: number;
/** Task execution timeout in milliseconds */ /** Task execution timeout in milliseconds */
timeoutMs: number; timeoutMs: number;
/** Maximum parallel tasks (0 = unlimited) */ /** Maximum parallel tasks (0 = unlimited) */
maxParallel: number; maxParallel: number;
/** Round-robin model list for parallel tasks (empty = inherit parent model) */ /** Round-robin model list for parallel tasks (empty = inherit parent model) */
models: string[]; models: string[];
/** Spawn a follow-up agent to commit changes after each task completes */
autoCommit: boolean;
/** Spawn a review agent to review the task's committed changes against
* the task description. When autoReview is on, commit is mandated:
* changes are committed (via commit session fallback when the agent
* didn't self-commit), then the COMPLETE diff (baseRef..HEAD) is
* reviewed. On 'fail' the task is re-executed with feedback (loops
* until pass or maxReviewRetries). On pass the worktree merges.
* When autoReview is off, autoCommit controls standalone commit. */
autoReview: boolean;
/** Persist the full review output to `.ralpi/reviews/<task-id>.md`.
* Only active when autoReview is true and the user opts in at loop start. */
saveReviews: 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;
/** Max review-fix re-execution attempts before giving up (0 = no retries;
* review runs once, reject = stop). Active whenever autoReview is
* enabled. On exhaustion the task proceeds with its committed changes
* (the worktree merges) unless reviewBlockOnFail is set. */
maxReviewRetries: number;
/** When true, a 'fail' review verdict after exhausting maxReviewRetries
* marks the task as failed instead of proceeding with its committed
* changes (the worktree does not merge). */
reviewBlockOnFail: boolean;
/** Maximum total duration for the entire loop execution in milliseconds (0 = no limit). Checked between batches — in-progress tasks finish naturally. */
loopTimeoutMs: number;
/** Isolate each task in a separate git worktree so parallel tasks can't
* stomp each other's files, and review/commit see a clean single-task diff.
* - "never": all tasks run in the shared working tree (default, backward compat)
* - "parallel": only when maxParallel > 1 and mode is parallel
* - "always": every task gets its own worktree */
worktrees: "always" | "parallel" | "never";
}; };
prompts: { prompts: {
/** Additional context injected into every task prompt */ /** Additional context injected into every task prompt */
@@ -194,13 +269,24 @@ export const DEFAULT_CONFIG: RalpiConfig = {
paths: { paths: {
stateDir: ".ralpi", stateDir: ".ralpi",
reflectionsDir: ".ralpi/reflections", reflectionsDir: ".ralpi/reflections",
reviewsDir: ".ralpi/reviews",
}, },
execution: { execution: {
maxRetries: 0,
retryDelayMs: 0,
timeoutMs: 0, // 0 = inherit Pi's own defaults (no ralpi-level timeout) timeoutMs: 0, // 0 = inherit Pi's own defaults (no ralpi-level timeout)
maxParallel: 3, maxParallel: 3,
models: [], models: [],
autoCommit: true,
autoReview: false,
saveReviews: 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)
maxReviewRetries: 2, // 2 re-execution attempts on review rejection before giving up
reviewBlockOnFail: false, // false = commit anyway after retries exhausted
loopTimeoutMs: 0, // 0 = no limit
worktrees: "parallel", // worktree isolation for parallel tasks by default
}, },
prompts: { prompts: {
projectContext: "", projectContext: "",

View File

@@ -13,6 +13,8 @@ import {
DefaultResourceLoader, DefaultResourceLoader,
getAgentDir, getAgentDir,
SessionManager, SessionManager,
SettingsManager,
type ModelRuntime,
} from "@earendil-works/pi-coding-agent"; } from "@earendil-works/pi-coding-agent";
// ─── Directory Helpers ─────────────────────────────────────────────────────── // ─── Directory Helpers ───────────────────────────────────────────────────────
@@ -38,7 +40,8 @@ export function writeFileSafe(filePath: string, content: string): void {
/** /**
* State persisted to disk when a ralpi execution loop is active. * State persisted to disk when a ralpi execution loop is active.
* Used to re-instantiate widgets after a session reload. * Used to re-instantiate widgets after a session reload, and to resume
* the loop non-interactively when a reload interrupts in-progress tasks.
*/ */
export interface LoopActiveState { export interface LoopActiveState {
taskFile: string; taskFile: string;
@@ -46,6 +49,11 @@ export interface LoopActiveState {
startedAt: string; startedAt: string;
taskIds: string[]; taskIds: string[];
prdKey: 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;
} }
/** /**
@@ -152,6 +160,70 @@ export function findProgressFile(
return null; 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 ────────────────────────────────────────────────────────────────── // ─── Config ──────────────────────────────────────────────────────────────────
/** Try to use the `yaml` package (real dependency in package.json). /** Try to use the `yaml` package (real dependency in package.json).
@@ -239,6 +311,15 @@ export function loadConfig(projectDir: string): RalpiConfig {
const content = fs.readFileSync(filePath, "utf-8"); const content = fs.readFileSync(filePath, "utf-8");
const parsed = parseSimpleYaml(content); const parsed = parseSimpleYaml(content);
Object.assign(acc, mergeConfig(acc, parsed)); 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 { } catch {
// Malformed config — skip silently // Malformed config — skip silently
} }
@@ -434,6 +515,15 @@ export async function runAgentSession(
signal?: AbortSignal, signal?: AbortSignal,
model?: unknown, model?: unknown,
thinkingLevel?: 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<{ ): Promise<{
success: boolean; success: boolean;
text: string; text: string;
@@ -466,7 +556,7 @@ export async function runAgentSession(
cwd, cwd,
agentDir: getAgentDir(), agentDir: getAgentDir(),
noExtensions: true, noExtensions: true,
noSkills: false, noSkills,
noPromptTemplates: true, noPromptTemplates: true,
noThemes: true, noThemes: true,
noContextFiles: true, noContextFiles: true,
@@ -477,6 +567,8 @@ export async function runAgentSession(
cwd, cwd,
sessionManager: SessionManager.inMemory(), sessionManager: SessionManager.inMemory(),
resourceLoader: loader, resourceLoader: loader,
settingsManager: SettingsManager.create(cwd, getAgentDir()),
modelRuntime,
tools: ["read", "bash", "edit", "write", "grep", "find", "ls"], tools: ["read", "bash", "edit", "write", "grep", "find", "ls"],
model: model as any, model: model as any,
thinkingLevel: thinkingLevel as any, thinkingLevel: thinkingLevel as any,
@@ -675,3 +767,121 @@ export function captureGitCommits(projectDir: string): {
return { commitMessages, commitSummary }; 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.
// 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;
}
}
/**
* Capture the current HEAD commit SHA. Returns the full 40-char SHA, or
* undefined when not a git repo / git unavailable. Used to snapshot the
* worktree HEAD before a task runs so the review can diff the complete task
* 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;
}
}
/**
* Get the diff from `baseRef` to HEAD — the complete set of committed changes
* made since the base reference. Used by the review-gated loop so the reviewer
* sees the full task diff (all commits, not just the latest) across execution
* attempts and re-execution fixes. `baseRef` must be a validated hex SHA from
* captureGitHead(). Returns the short HEAD hash, HEAD subject, and range diff,
* or null when git is unavailable / baseRef is invalid / no changes exist.
*/
export function getCommitRangeDiff(
projectDir: string,
baseRef: string,
): { hash: string; subject: string; diff: string } | null {
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;
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();
// 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;
}
}

377
src/worktree.ts Normal file
View File

@@ -0,0 +1,377 @@
import * as path from "node:path";
import { ensureDir } from "./utils";
// ─── Types ───────────────────────────────────────────────────────────────────
export interface WorktreeHandle {
/** Absolute path to the worktree working directory. */
dir: string;
/** Branch name: slugified task title, or `ralpi/<prdKey>/<taskId>` as a fallback. */
branch: string;
/** Main repo directory (where the primary working tree lives). */
mainDir: string;
}
export interface MergeResult {
success: boolean;
/** File paths that conflicted (empty when merge succeeds). */
conflicts: string[];
/** Human-readable status message. */
message: string;
}
// ─── Git Helpers ─────────────────────────────────────────────────────────────
/** Run a git command, returning trimmed stdout. Returns null on failure. */
function git(args: string, cwd: string): string | null {
const { execSync } = require("node:child_process") as {
execSync: (cmd: string, opts: object) => string;
};
try {
return execSync(`git ${args}`, {
cwd,
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
}).trim();
} catch {
return null;
}
}
/** Run a git command that may fail; returns { ok, stdout, stderr }. */
function gitRaw(
args: string,
cwd: string,
): { ok: boolean; stdout: string; stderr: string } {
const { execSync } = require("node:child_process") as {
execSync: (cmd: string, opts: object) => string;
};
try {
const stdout = execSync(`git ${args}`, {
cwd,
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
});
return { ok: true, stdout: stdout.trim(), stderr: "" };
} catch (err: unknown) {
const e = err as {
stdout?: string;
stderr?: string;
message?: string;
};
return {
ok: false,
stdout: (e.stdout ?? "").toString().trim(),
stderr: (e.stderr ?? "").toString().trim(),
};
}
}
/** Check if a directory is inside a git repository. */
export function isGitRepo(dir: string): boolean {
return git("rev-parse --git-dir", dir) !== null;
}
/** Get the current HEAD commit hash of a directory. */
export function getGitHead(dir: string): string | null {
return git("rev-parse HEAD", dir);
}
/** Get the current branch name of a directory. */
export function getCurrentBranch(dir: string): string | null {
return git("rev-parse --abbrev-ref HEAD", dir);
}
// ─── Worktree Lifecycle ──────────────────────────────────────────────────────
/**
* Path to the worktree directory for a given task.
* Lives inside `.ralpi/worktrees/<prdKey>/<taskId>` in the main repo so all
* ralpi state stays co-located and multiple loops (different PRDs) can run
* concurrently without colliding on shared task IDs. The directory itself
* is untracked git metadata (registered in `.git/worktrees/`), so it won't
* pollute `git status` in the main working tree.
*/
export function worktreePath(
mainDir: string,
stateDir: string,
prdKey: string,
taskId: string,
): string {
return path.join(mainDir, stateDir, "worktrees", prdKey, taskId);
}
/**
* Normalise a task ID into a valid git branch suffix.
* Zero-padded IDs like "01" are already valid; this ensures any stray
* characters are replaced.
*/
function safeBranchSuffix(taskId: string): string {
return taskId.replace(/[^a-zA-Z0-9_-]/g, "-");
}
/**
* Sanitise a free-form task title into a git-branch-safe slug.
*
* Lowercases, replaces runs of non-alphanumeric characters with single
* hyphens, trims leading/trailing hyphens, and caps the length so the
* branch name stays readable and within reasonable git limits.
*
* Returns an empty string when the title produces no usable slug.
*/
function slugifyTitle(title: string): string {
return title
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 60);
}
/**
* Create a git worktree for a task.
*
* The worktree is created at `<mainDir>/.ralpi/worktrees/<prdKey>/<taskId>`
* on a new branch. When `taskTitle` is provided the branch name is the slugified title
* alone (e.g. `fix-plans-tab-grammar-casing-icons`); otherwise it falls back
* to `ralpi/<prdKey>/<taskId>`. Based at `baseRef` (defaults to the current
* HEAD of `mainDir`).
*
* The worktree directory always uses the bare `taskId` for a stable path;
* stale-worktree cleanup identifies ralpi worktrees by that path, not by
* branch name, so descriptive branch names are safe.
*
* Returns null if `mainDir` is not a git repo or the worktree creation fails.
*/
export function createWorktree(
mainDir: string,
stateDir: string,
taskId: string,
prdKey: string,
baseRef?: string,
taskTitle?: string,
): WorktreeHandle | null {
if (!isGitRepo(mainDir)) return null;
const safeId = safeBranchSuffix(taskId);
const slug = taskTitle ? slugifyTitle(taskTitle) : "";
const branch = slug || `ralpi/${prdKey}/${safeId}`;
const wtDir = worktreePath(mainDir, stateDir, prdKey, taskId);
// Prune metadata for worktree directories that no longer exist on disk
// (e.g. from a crashed previous run that left stale `.git/worktrees/` entries).
git("worktree prune", mainDir);
// ── Reuse an already-registered worktree (resume) ──
// A resumed loop skips `cleanupStaleWorktrees`, so the interrupted task's
// worktree — and the branch carrying its committed work — survives. Reuse
// it instead of destroying and recreating from the base ref; otherwise the
// prior session's commits are lost and the task restarts from scratch.
const existing = git(`worktree list --porcelain`, mainDir);
if (existing && existing.includes(`worktree ${wtDir}`)) {
// The worktree is registered — sanity-check it's a valid checkout.
if (getGitHead(wtDir)) {
return { dir: wtDir, branch, mainDir };
}
// Registered but broken (dir gone / checkout corrupt) — drop its
// metadata and fall through to fresh creation below.
git(`worktree remove --force "${wtDir}"`, mainDir);
}
// Fresh creation.
const ref = baseRef ?? getGitHead(mainDir);
if (!ref) return null;
// Ensure the parent directory exists so `git worktree add` can create
// the worktree directory inside it.
ensureDir(path.dirname(wtDir));
// Delete a stale branch if it exists from a previous run so `-b` doesn't
// fail on the new worktree.
git(`branch -D "${branch}"`, mainDir);
const result = gitRaw(
`worktree add -b "${branch}" "${wtDir}" "${ref}"`,
mainDir,
);
if (!result.ok) {
// Fall back to detached HEAD worktree if branch creation fails
// (e.g. the branch name somehow conflicts).
const fallback = gitRaw(
`worktree add --detach "${wtDir}" "${ref}"`,
mainDir,
);
if (!fallback.ok) return null;
}
return { dir: wtDir, branch, mainDir };
}
/**
* Merge a worktree's branch back into the current branch of the main repo.
*
* Uses `--no-ff` to always create a merge commit, preserving the task
* branch's history. On conflict, the merge is aborted and the conflicts
* are returned so the caller can mark the task as failed.
*/
export function mergeWorktree(mainDir: string, branch: string): MergeResult {
// Attempt the merge.
const result = gitRaw(`merge --no-ff --no-edit "${branch}"`, mainDir);
if (result.ok) {
return {
success: true,
conflicts: [],
message: `Merged ${branch} into ${getCurrentBranch(mainDir) ?? "HEAD"}`,
};
}
// Merge failed — likely conflicts. Collect the list of conflicting files.
const status = git("diff --name-only --diff-filter=U", mainDir) ?? "";
const conflicts = status
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
// Abort the merge so the main repo's working tree is left clean.
git("merge --abort", mainDir);
return {
success: false,
conflicts,
message:
conflicts.length > 0
? `Merge conflicts in: ${conflicts.join(", ")}`
: `Merge of ${branch} failed: ${result.stderr || result.stdout}`,
};
}
/**
* Re-attempt a merge WITHOUT aborting on conflict.
*
* Unlike `mergeWorktree`, this leaves the main repo in a merge-conflict
* state so a conflict-resolution agent can see the conflict markers in the
* working tree and resolve them manually. The caller is responsible for
* committing the resolved merge or aborting it.
*
* Returns:
* - `clean: true` → merge succeeded (nothing staged to commit yet; the
* caller should `git commit` or `git merge --abort` to finalise)
* - `clean: false` → conflicts; working tree has conflict markers
*/
export function reattemptMerge(
mainDir: string,
branch: string,
): { clean: boolean; conflicts: string[] } {
// Use --no-commit so even a clean merge doesn't auto-commit — the caller
// controls when the merge commit lands.
const result = gitRaw(`merge --no-ff --no-commit "${branch}"`, mainDir);
if (result.ok) {
return { clean: true, conflicts: [] };
}
// Merge produced conflicts — collect them but DO NOT abort.
const status = git("diff --name-only --diff-filter=U", mainDir) ?? "";
const conflicts = status
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
return { clean: false, conflicts };
}
/** Abort an in-progress merge in the main repo. */
export function abortMerge(mainDir: string): void {
git("merge --abort", mainDir);
}
/** Check if there are unmerged paths (conflicts) in the working tree. */
export function hasMergeConflicts(mainDir: string): boolean {
const status = git("diff --name-only --diff-filter=U", mainDir) ?? "";
return status.trim().length > 0;
}
/** Complete the in-progress merge by committing. Returns true on success. */
export function completeMerge(mainDir: string): boolean {
const result = gitRaw("commit --no-edit", mainDir);
return result.ok;
}
/**
* Remove a worktree and delete its branch.
*
* Called after a successful merge to clean up. Safe to call even if the
* worktree or branch no longer exists.
*/
export function removeWorktree(mainDir: string, wt: WorktreeHandle): void {
git(`worktree remove --force "${wt.dir}"`, mainDir);
git(`branch -D "${wt.branch}"`, mainDir);
git("worktree prune", mainDir);
}
/**
* Clean up stale worktrees from interrupted runs.
*
* Identifies ralpi-owned worktrees by their path living under
* `<mainDir>/<stateDir>/worktrees/` and removes them. Called at the start
* of a loop to ensure a clean slate. Returns the list of removed worktree
* directories.
*
* When `prdKey` is provided, cleanup is scoped to
* `<mainDir>/<stateDir>/worktrees/<prdKey>/` so that worktrees belonging to
* other concurrently running loops (different PRDs) are left untouched.
* When omitted, all ralpi-managed worktrees are cleaned.
*/
export function cleanupStaleWorktrees(
mainDir: string,
stateDir: string,
prdKey?: string,
): string[] {
const removed: string[] = [];
// Prune metadata for worktree directories that no longer exist on disk.
git("worktree prune", mainDir);
const list = git("worktree list --porcelain", mainDir);
if (!list) return removed;
// Worktrees we manage live under <mainDir>/<stateDir>/worktrees/.
// When a prdKey is given, narrow to that PRD's subdir so concurrent
// loops (other PRDs) are not disturbed.
const managedRoot = path.resolve(
mainDir,
stateDir,
"worktrees",
...(prdKey ? [prdKey] : []),
);
// Parse worktree list: each entry is `worktree <path>` followed by metadata.
const wtLines = list
.split("\n")
.filter((l) => l.startsWith("worktree "))
.map((l) => l.slice("worktree ".length).trim());
for (const wtDir of wtLines) {
// Skip the main working tree (always first in the list).
if (path.resolve(wtDir) === path.resolve(mainDir)) continue;
// Only touch worktrees that live under the ralpi worktrees directory.
const resolved = path.resolve(wtDir);
if (
resolved !== managedRoot &&
!resolved.startsWith(managedRoot + path.sep)
)
continue;
// Remove the worktree and its branch.
git(`worktree remove --force "${wtDir}"`, mainDir);
const branch = git(`rev-parse --abbrev-ref HEAD`, wtDir);
if (branch && branch !== "HEAD" && branch !== "detached") {
git(`branch -D "${branch}"`, mainDir);
}
removed.push(wtDir);
}
git("worktree prune", mainDir);
return removed;
}