Compare commits
13 Commits
6c398eef64
...
dd61249e8d
| Author | SHA1 | Date | |
|---|---|---|---|
| dd61249e8d | |||
| a0973ae1d2 | |||
| 6dcbd064a6 | |||
| 74c9ead7af | |||
| 0ef540ed47 | |||
| 9fcf944cb5 | |||
| 0034272c54 | |||
| c46f8f1783 | |||
| 46da29ee22 | |||
| 519b12b3d9 | |||
| 6aa3f6bd9f | |||
| 008489a91b | |||
| 087c64ff18 |
13
AGENTS.md
13
AGENTS.md
@@ -19,6 +19,7 @@ No build step needed — Pi loads extensions via [jiti](https://github.com/unjs/
|
||||
## External dependencies
|
||||
|
||||
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-tui` — `Box`, `Text` for custom message renderer
|
||||
|
||||
@@ -44,6 +45,7 @@ The only real npm dependency is `yaml` (^2.4.0).
|
||||
## Runtime state
|
||||
|
||||
All runtime state lives in `.ralpi/` in the **project directory** (not this extension directory):
|
||||
|
||||
- `.ralpi/progress.json` — execution progress, supports multiple PRDs
|
||||
- `.ralpi/reflections/` — per-task reflection JSON files
|
||||
- `.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
|
||||
|
||||
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
|
||||
|
||||
34
README.md
34
README.md
@@ -120,6 +120,15 @@ execution:
|
||||
models: # round-robin in <provider>/<model> format
|
||||
- google/gemini-3.5-flash # 1st and 3rd 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:
|
||||
projectContext: "Additional context for all tasks"
|
||||
```
|
||||
@@ -134,6 +143,31 @@ prompts:
|
||||
> **NOTE**: this is only used in parallel execution, in sequential mode the
|
||||
> 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
|
||||
|
||||
- `.ralpi/progress.json` - Execution progress
|
||||
|
||||
793
index.ts
793
index.ts
@@ -15,11 +15,10 @@ import {
|
||||
import { ProgressTracker } from "./src/progress";
|
||||
import { buildPlanPrompt } from "./src/prompts";
|
||||
import { formatReflections } from "./src/reflection";
|
||||
import {
|
||||
executeBatch,
|
||||
SPINNER_FRAMES,
|
||||
type SendChatMessage,
|
||||
} from "./src/executor";
|
||||
import { verdictGlyph, verdictSummary, formatFindings } from "./src/review";
|
||||
import type { ReviewResult } from "./src/types";
|
||||
import { executeBatch, type SendChatMessage } from "./src/executor";
|
||||
import { cleanupStaleWorktrees } from "./src/worktree";
|
||||
import {
|
||||
loadConfig,
|
||||
resolveTaskArg,
|
||||
@@ -29,6 +28,8 @@ import {
|
||||
deleteLoopActive,
|
||||
readLoopActive,
|
||||
findRalpiDir,
|
||||
listPRDsSorted,
|
||||
formatDuration,
|
||||
} from "./src/utils";
|
||||
|
||||
const COMMANDS = ["plan", "resume", "reset"] as const;
|
||||
@@ -120,6 +121,124 @@ function buildPlanByMode(
|
||||
: 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. */
|
||||
async function executePlanBatches(
|
||||
plan: ReturnType<typeof buildPlanByMode>,
|
||||
@@ -131,8 +250,11 @@ async function executePlanBatches(
|
||||
mode: ExecutionMode,
|
||||
sendChatMessage?: SendChatMessage,
|
||||
projectDir?: string,
|
||||
isResume?: boolean,
|
||||
): 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) {
|
||||
const allTaskIds = plan.batches.flatMap((b) => b.tasks.map((t) => t.id));
|
||||
writeLoopActive(projectDir, {
|
||||
@@ -141,14 +263,49 @@ async function executePlanBatches(
|
||||
startedAt: new Date().toISOString(),
|
||||
taskIds: allTaskIds,
|
||||
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
|
||||
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 {
|
||||
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) {
|
||||
ctx.ui.notify(
|
||||
"Execution paused. Use /ralpi resume to continue.",
|
||||
@@ -223,6 +380,13 @@ async function executePlanBatches(
|
||||
if (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;
|
||||
toolCalls?: Array<{ name: string; label: string }>;
|
||||
reviewText?: string;
|
||||
reviewPath?: string;
|
||||
reviewResult?: ReviewResult;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
@@ -248,6 +415,45 @@ export default function ralpiLoopExtension(pi: ExtensionAPI): void {
|
||||
// Header line — e.g. "✓ 05 · billing-subscriptions-trials (2m 14s)"
|
||||
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
|
||||
if (details?.toolCalls && details.toolCalls.length > 0) {
|
||||
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
|
||||
// ExtensionContext is torn down and widgets (created via ctx.ui.setWidget)
|
||||
// disappear. This handler detects the reload, reads the persisted loop-active
|
||||
// marker and progress.json, and re-creates live-status widgets that show
|
||||
// task progress with spinner animation and tool calls from session files.
|
||||
// ralpi runs task agent sessions in-process (createAgentSession), so they
|
||||
// do NOT survive a /reload. When the new session starts, this handler
|
||||
// reads the persisted loop-active marker + progress.json: if any task is
|
||||
// still `in_progress`, the loop was interrupted mid-task and we resume it
|
||||
// (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) => {
|
||||
if (event.reason !== "reload") return;
|
||||
|
||||
@@ -306,89 +514,9 @@ export default function ralpiLoopExtension(pi: ExtensionAPI): void {
|
||||
if (!loopState) return;
|
||||
|
||||
// Load progress state
|
||||
let abortPolling = false;
|
||||
const progressPath = path.join(projectDir, ".ralpi", "progress.json");
|
||||
const sessionsDir = path.join(projectDir, ".ralpi", "sessions");
|
||||
|
||||
// Parse the task file to get task titles
|
||||
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). */
|
||||
/** Re-read progress from disk. */
|
||||
const readTasks = (): Record<string, { status: string }> | null => {
|
||||
try {
|
||||
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();
|
||||
if (initialTasks) {
|
||||
const remaining = Object.values(initialTasks).filter(
|
||||
(t) => t.status === "in_progress",
|
||||
).length;
|
||||
if (remaining === 0) {
|
||||
ctx.ui.notify("All ralpi tasks completed during reload.", "info");
|
||||
const inProgressIds = Object.entries(initialTasks).flatMap(([id, t]) =>
|
||||
t.status === "in_progress" ? [id] : [],
|
||||
);
|
||||
|
||||
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);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Show a status notification for the reconnect
|
||||
const taskCount = loopState.taskIds.length;
|
||||
ctx.ui.notify(
|
||||
`Reconnected to running ralpi execution (${taskCount} tasks, ${loopState.mode} mode)`,
|
||||
`ralpi loop was interrupted by reload with ${inProgressIds.length} in-progress task(s). ` +
|
||||
`Resuming execution (${taskCount} tasks, ${loopState.mode} mode)...`,
|
||||
"info",
|
||||
);
|
||||
|
||||
// Shared state for the widget
|
||||
let tickCount = 0;
|
||||
const MAX_COLLAPSED = 3;
|
||||
|
||||
if (loopState.mode === "parallel") {
|
||||
// ── Parallel mode: single batch widget ──
|
||||
const widgetKey = `ralpi-parallel-reconnect-${Date.now()}`;
|
||||
let widgetTui: { requestRender(): void } | null = null;
|
||||
|
||||
const buildBatchLines = (t: typeof ctx.ui.theme): string[] => {
|
||||
const tasks = readTasks();
|
||||
if (!tasks) return [t.fg("dim", "(waiting for progress...)")];
|
||||
|
||||
const lines: string[] = [];
|
||||
// Only show tasks that have started (in_progress, completed, failed).
|
||||
// Pending/unstarted tasks are noise after a reload.
|
||||
const sortedIds = [...loopState.taskIds].sort().filter((id) => {
|
||||
const info = tasks[id];
|
||||
return info && info.status !== "pending";
|
||||
// Build the sendProgress wrapper so resumed task messages render the
|
||||
// same expandable tool-call tree as an interactive run.
|
||||
const sendProgress: SendChatMessage = (
|
||||
content: string,
|
||||
meta?: {
|
||||
toolCalls?: Array<{ name: string; label: string }>;
|
||||
reviewText?: string;
|
||||
reviewPath?: string;
|
||||
reviewResult?: ReviewResult;
|
||||
},
|
||||
) => {
|
||||
pi.sendMessage({
|
||||
customType: "ralpi-progress",
|
||||
content,
|
||||
display: true,
|
||||
details: {
|
||||
phase: "progress",
|
||||
toolCalls: meta?.toolCalls,
|
||||
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) => {
|
||||
widgetTui = tui;
|
||||
return {
|
||||
render: () => buildBatchLines(t),
|
||||
invalidate: () => widgetTui?.requestRender(),
|
||||
};
|
||||
});
|
||||
// Load config from the project directory so model + thinking level
|
||||
// resolve the same way the interactive command handler does.
|
||||
const config = loadConfig(projectDir);
|
||||
|
||||
// 100ms tick: advances spinner frame every tick, refreshes
|
||||
// progress + tool calls every 5 ticks (500ms).
|
||||
const tickTimer = setInterval(() => {
|
||||
if (abortPolling) return;
|
||||
tickCount++;
|
||||
widgetTui?.requestRender();
|
||||
|
||||
if (tickCount % 5 === 0) {
|
||||
const tasks = readTasks();
|
||||
if (!tasks) return;
|
||||
const activeCount = Object.values(tasks).filter(
|
||||
(t) => t.status === "in_progress",
|
||||
).length;
|
||||
if (activeCount === 0) {
|
||||
clearInterval(tickTimer);
|
||||
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",
|
||||
try {
|
||||
await resumeLoop(
|
||||
ctx,
|
||||
loopState.taskFile,
|
||||
projectDir,
|
||||
loopState.prdKey,
|
||||
sendProgress,
|
||||
config.model ?? ctx.model,
|
||||
pi.getThinkingLevel(),
|
||||
{
|
||||
mode: loopState.mode,
|
||||
autoCommit: loopState.autoCommit ?? config.execution.autoCommit,
|
||||
autoReview: loopState.autoReview ?? config.execution.autoReview,
|
||||
saveReviews: loopState.saveReviews ?? config.execution.saveReviews,
|
||||
skipFinalStatus: false,
|
||||
},
|
||||
);
|
||||
} 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.
|
||||
// Uses "ralpi-progress" customType with a "progress" phase so the
|
||||
// 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 = (
|
||||
content: string,
|
||||
meta?: { toolCalls?: Array<{ name: string; label: string }> },
|
||||
meta?: {
|
||||
toolCalls?: Array<{ name: string; label: string }>;
|
||||
reviewText?: string;
|
||||
reviewPath?: string;
|
||||
reviewResult?: ReviewResult;
|
||||
},
|
||||
) => {
|
||||
pi.sendMessage({
|
||||
customType: "ralpi-progress",
|
||||
content,
|
||||
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 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);
|
||||
|
||||
// Show dependency chain + execution plan before starting
|
||||
@@ -830,44 +841,32 @@ async function handleRun(
|
||||
|
||||
// ─── /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,
|
||||
args: string[],
|
||||
sendChatMessage?: SendChatMessage,
|
||||
parentModel?: unknown,
|
||||
parentThinkingLevel?: unknown,
|
||||
taskFile: string,
|
||||
projectDir: string,
|
||||
prdKey: string | undefined,
|
||||
sendChatMessage: SendChatMessage | undefined,
|
||||
parentModel: unknown,
|
||||
parentThinkingLevel: unknown,
|
||||
options?: {
|
||||
mode?: ExecutionMode;
|
||||
autoCommit?: boolean;
|
||||
autoReview?: boolean;
|
||||
saveReviews?: boolean;
|
||||
skipFinalStatus?: boolean;
|
||||
},
|
||||
): 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);
|
||||
if (!Array.isArray(project.tasks)) {
|
||||
throw new Error(
|
||||
@@ -877,21 +876,63 @@ async function handleResume(
|
||||
const config = loadConfig(projectDir);
|
||||
config.model = parentModel ?? ctx.model;
|
||||
config.thinkingLevel = parentThinkingLevel;
|
||||
const progress = new ProgressTracker(projectDir, taskFile, found.prdKey);
|
||||
const progress = new ProgressTracker(projectDir, taskFile, prdKey);
|
||||
|
||||
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 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);
|
||||
|
||||
// Print remaining batches before executing
|
||||
const formattedPlan = formatExecutionPlan(plan);
|
||||
if (mode === "parallel") {
|
||||
ctx.ui.notify(
|
||||
`${formattedPlan}\n\nResuming parallel execution...`,
|
||||
"info",
|
||||
);
|
||||
ctx.ui.notify(`${formattedPlan}\n\nResuming parallel execution...`, "info");
|
||||
} else {
|
||||
ctx.ui.notify(
|
||||
`${formattedPlan}\n\nResuming sequential execution...`,
|
||||
@@ -909,10 +950,69 @@ async function handleResume(
|
||||
mode,
|
||||
sendChatMessage,
|
||||
projectDir,
|
||||
true, // isResume — preserve in-progress worktrees, continue them
|
||||
);
|
||||
|
||||
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 ─────────────────────────────────────────────────────────────
|
||||
// (removed — use /ralpi run to execute tasks)
|
||||
@@ -941,12 +1041,11 @@ async function handleReset(
|
||||
return;
|
||||
}
|
||||
const projectDir = path.dirname(path.dirname(found.path));
|
||||
const progress = new ProgressTracker(
|
||||
projectDir,
|
||||
found.state.prds
|
||||
? Object.values(found.state.prds)[0].sourcePath
|
||||
: found.state.sourcePath,
|
||||
);
|
||||
// Use the most recently updated PRD (first in sorted order)
|
||||
const prds = listPRDsSorted(found.state);
|
||||
const sourcePath =
|
||||
prds.length > 0 ? prds[0].prd.sourcePath : found.state.sourcePath;
|
||||
const progress = new ProgressTracker(projectDir, sourcePath);
|
||||
progress.reset();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"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",
|
||||
"keywords": [
|
||||
"pi-package",
|
||||
|
||||
@@ -26,5 +26,10 @@ export const TASK_FILE_NAMES = [
|
||||
export const REFLECTION_HEADER = "## REFLECTION";
|
||||
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
|
||||
export const DEFAULT_PI_ARGS = ["--no-stream"] as const;
|
||||
|
||||
1116
src/executor.ts
1116
src/executor.ts
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@ import type {
|
||||
Task,
|
||||
Reflection,
|
||||
ToolUsage,
|
||||
ReviewResult,
|
||||
} from "./types";
|
||||
import { ensureDir } from "./utils";
|
||||
|
||||
@@ -174,6 +175,8 @@ export class ProgressTracker {
|
||||
outputPreview?: string,
|
||||
commitMessages?: string[],
|
||||
commitSummary?: string,
|
||||
review?: ReviewResult,
|
||||
reviewRetries?: number,
|
||||
): void {
|
||||
const prd = this.getPRD();
|
||||
this.ensureTask(prd, taskId);
|
||||
@@ -185,6 +188,9 @@ export class ProgressTracker {
|
||||
if (outputPreview) prd.tasks[taskId].outputPreview = outputPreview;
|
||||
if (commitMessages) prd.tasks[taskId].commitMessages = commitMessages;
|
||||
if (commitSummary) prd.tasks[taskId].commitSummary = commitSummary;
|
||||
if (review) prd.tasks[taskId].review = review;
|
||||
if (reviewRetries !== undefined)
|
||||
prd.tasks[taskId].reviewRetries = reviewRetries;
|
||||
this.save();
|
||||
}
|
||||
|
||||
@@ -237,15 +243,6 @@ export class ProgressTracker {
|
||||
.filter((r): r is Reflection => r !== undefined);
|
||||
}
|
||||
|
||||
/** Increment retry count */
|
||||
incrementRetry(taskId: string): number {
|
||||
const prd = this.getPRD();
|
||||
this.ensureTask(prd, taskId);
|
||||
prd.tasks[taskId].retries++;
|
||||
this.save();
|
||||
return prd.tasks[taskId].retries;
|
||||
}
|
||||
|
||||
/** Set paused state */
|
||||
setPaused(paused: boolean): void {
|
||||
const prd = this.getPRD();
|
||||
@@ -253,6 +250,26 @@ export class ProgressTracker {
|
||||
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) */
|
||||
getState(): PRDProgress {
|
||||
return this.getPRD();
|
||||
@@ -277,7 +294,7 @@ export class ProgressTracker {
|
||||
|
||||
private ensureTask(prd: PRDProgress, taskId: string): void {
|
||||
if (!prd.tasks[taskId]) {
|
||||
prd.tasks[taskId] = { status: "pending", retries: 0 };
|
||||
prd.tasks[taskId] = { status: "pending" };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
358
src/prompts.ts
358
src/prompts.ts
@@ -1,6 +1,29 @@
|
||||
import type { Task, Project, Reflection } from "./types";
|
||||
import type { Task, Project, Reflection, ReviewResult } from "./types";
|
||||
import { readTaskSpec } from "./parser";
|
||||
|
||||
/** Maximum bytes of a commit diff embedded in a review/commit prompt.
|
||||
* Diffs larger than this are truncated to avoid blowing past the model's
|
||||
* context window. The agent can always run `git show HEAD` itself to
|
||||
* inspect the full diff when it needs more detail.
|
||||
*
|
||||
* ~50 KB ≈ 12.5K tokens — comfortably fits even on models with a 128K
|
||||
* context window once system-prompt overhead is accounted for. */
|
||||
export const MAX_DIFF_BYTES = 50_000;
|
||||
|
||||
/**
|
||||
* Truncate a diff to MAX_DIFF_BYTES, appending a clear notice when truncated.
|
||||
*/
|
||||
function truncateDiff(diff: string): string {
|
||||
if (diff.length <= MAX_DIFF_BYTES) return diff;
|
||||
const omitted = diff.length - MAX_DIFF_BYTES;
|
||||
return (
|
||||
diff.slice(0, MAX_DIFF_BYTES) +
|
||||
"\n\n... (diff truncated: omitted " +
|
||||
omitted.toLocaleString() +
|
||||
" bytes; run `git show HEAD` to view the full diff)"
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Task Prompt ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -12,6 +35,9 @@ export function buildTaskPrompt(
|
||||
project: Project,
|
||||
depReflections: Reflection[],
|
||||
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 {
|
||||
const parts: string[] = [];
|
||||
|
||||
@@ -107,6 +133,32 @@ export function buildTaskPrompt(
|
||||
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 ──
|
||||
|
||||
parts.push("## REFLECTION (REQUIRED)");
|
||||
@@ -136,7 +188,208 @@ export function buildTaskPrompt(
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
// ─── Plan Prompt ─────────────────────────────────────────────────────────────
|
||||
// ─── Review Prompt ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build the prompt for the auto-review agent.
|
||||
* Includes the task description and the latest commit diff so the reviewer
|
||||
* can assess whether the commit fulfills the task requirements.
|
||||
*/
|
||||
export function buildReviewPrompt(
|
||||
task: Task,
|
||||
project: Project,
|
||||
commitHash: string,
|
||||
commitSubject: string,
|
||||
commitDiff: string,
|
||||
projectContext?: string,
|
||||
): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
parts.push(`# Code Review: Task ${task.id}: ${task.title}`);
|
||||
parts.push("");
|
||||
|
||||
// ── Task Description ──
|
||||
|
||||
parts.push("## Task Description");
|
||||
if (task.description) {
|
||||
parts.push(task.description);
|
||||
} else {
|
||||
parts.push(task.title);
|
||||
}
|
||||
parts.push("");
|
||||
|
||||
// ── Task Specification ──
|
||||
|
||||
if (task.file) {
|
||||
const spec = readTaskSpec(project.sourceDir, task.file);
|
||||
if (spec) {
|
||||
parts.push("## Task Specification");
|
||||
parts.push(`Full details from \`${task.file}\`:`);
|
||||
parts.push("");
|
||||
parts.push(spec);
|
||||
parts.push("");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Commit Under Review ──
|
||||
|
||||
parts.push("## Commit Under Review");
|
||||
parts.push(`Commit: ${commitHash} — ${commitSubject}`);
|
||||
parts.push("");
|
||||
parts.push("### Diff");
|
||||
parts.push("```diff");
|
||||
parts.push(truncateDiff(commitDiff));
|
||||
parts.push("```");
|
||||
parts.push("");
|
||||
|
||||
// ── Project Context ──
|
||||
|
||||
if (projectContext) {
|
||||
parts.push("## Additional Context");
|
||||
parts.push(projectContext);
|
||||
parts.push("");
|
||||
}
|
||||
|
||||
// ── Review Instructions ──
|
||||
|
||||
parts.push("## Review Instructions");
|
||||
parts.push(
|
||||
"Review the commit above against the task description. Check for:",
|
||||
);
|
||||
parts.push(...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
|
||||
@@ -155,7 +408,8 @@ export function buildPlanPrompt(project: Project): string {
|
||||
|
||||
lines.push("## Tasks");
|
||||
for (const task of project.tasks) {
|
||||
const deps = task.dependencies.length > 0
|
||||
const deps =
|
||||
task.dependencies.length > 0
|
||||
? ` (depends on: ${task.dependencies.join(", ")})`
|
||||
: "";
|
||||
lines.push(`- [ ] ${task.id}: ${task.title}${deps}`);
|
||||
@@ -172,3 +426,101 @@ export function buildPlanPrompt(project: Project): string {
|
||||
|
||||
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
211
src/review.ts
Normal 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");
|
||||
}
|
||||
100
src/types.ts
100
src/types.ts
@@ -103,6 +103,37 @@ export interface Reflection {
|
||||
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 {
|
||||
read: number;
|
||||
write: number;
|
||||
@@ -115,9 +146,10 @@ export interface TaskProgressInfo {
|
||||
status: Task["status"];
|
||||
startedAt?: string;
|
||||
completedAt?: string;
|
||||
retries: number;
|
||||
durationMs?: number;
|
||||
reflection?: Reflection;
|
||||
/** Structured review result (when autoReview is enabled) */
|
||||
review?: ReviewResult;
|
||||
error?: string;
|
||||
/** Tool usage counts from parsed subprocess output */
|
||||
toolUsage?: ToolUsage;
|
||||
@@ -127,6 +159,8 @@ export interface TaskProgressInfo {
|
||||
commitMessages?: string[];
|
||||
/** Summary derived from git commits */
|
||||
commitSummary?: string;
|
||||
/** Number of review-fix re-execution attempts made (review-gated mode) */
|
||||
reviewRetries?: number;
|
||||
}
|
||||
|
||||
export interface ProgressState {
|
||||
@@ -165,18 +199,59 @@ export interface RalpiConfig {
|
||||
stateDir: string;
|
||||
/** Directory for per-task reflections */
|
||||
reflectionsDir: string;
|
||||
/** Directory for per-loop review output (mirrors reflectionsDir) */
|
||||
reviewsDir: string;
|
||||
};
|
||||
execution: {
|
||||
/** Maximum retries per task */
|
||||
maxRetries: number;
|
||||
/** Delay between retries in milliseconds */
|
||||
retryDelayMs: number;
|
||||
/** Task execution timeout in milliseconds */
|
||||
timeoutMs: number;
|
||||
/** Maximum parallel tasks (0 = unlimited) */
|
||||
maxParallel: number;
|
||||
/** Round-robin model list for parallel tasks (empty = inherit parent model) */
|
||||
models: string[];
|
||||
/** Spawn a follow-up agent to commit changes after each task completes */
|
||||
autoCommit: boolean;
|
||||
/** Spawn a review agent to review the 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: {
|
||||
/** Additional context injected into every task prompt */
|
||||
@@ -194,13 +269,24 @@ export const DEFAULT_CONFIG: RalpiConfig = {
|
||||
paths: {
|
||||
stateDir: ".ralpi",
|
||||
reflectionsDir: ".ralpi/reflections",
|
||||
reviewsDir: ".ralpi/reviews",
|
||||
},
|
||||
execution: {
|
||||
maxRetries: 0,
|
||||
retryDelayMs: 0,
|
||||
timeoutMs: 0, // 0 = inherit Pi's own defaults (no ralpi-level timeout)
|
||||
maxParallel: 3,
|
||||
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: {
|
||||
projectContext: "",
|
||||
|
||||
214
src/utils.ts
214
src/utils.ts
@@ -13,6 +13,8 @@ import {
|
||||
DefaultResourceLoader,
|
||||
getAgentDir,
|
||||
SessionManager,
|
||||
SettingsManager,
|
||||
type ModelRuntime,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
|
||||
// ─── 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.
|
||||
* 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 {
|
||||
taskFile: string;
|
||||
@@ -46,6 +49,11 @@ export interface LoopActiveState {
|
||||
startedAt: string;
|
||||
taskIds: string[];
|
||||
prdKey: string;
|
||||
/** Loop option snapshot at loop start, so a reload can resume without
|
||||
* re-prompting the user. */
|
||||
autoCommit?: boolean;
|
||||
autoReview?: boolean;
|
||||
saveReviews?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -152,6 +160,70 @@ export function findProgressFile(
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all PRDs from a ProgressState, sorted by lastUpdatedAt descending
|
||||
* (most recent first). Used by resume to offer a selection when multiple
|
||||
* loops have progress simultaneously.
|
||||
*/
|
||||
export function listPRDsSorted(
|
||||
state: ProgressState,
|
||||
): Array<{ key: string; prd: PRDProgress }> {
|
||||
const entries: Array<{ key: string; prd: PRDProgress }> = [];
|
||||
|
||||
if (state.prds) {
|
||||
for (const [key, prd] of Object.entries(state.prds)) {
|
||||
entries.push({ key, prd });
|
||||
}
|
||||
} else {
|
||||
// Legacy flat mode — single PRD
|
||||
entries.push({
|
||||
key: "legacy",
|
||||
prd: {
|
||||
sourcePath: state.sourcePath,
|
||||
tasks: state.tasks,
|
||||
startedAt: state.startedAt,
|
||||
lastUpdatedAt: state.lastUpdatedAt,
|
||||
paused: state.paused,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
entries.sort((a, b) => {
|
||||
return (
|
||||
new Date(b.prd.lastUpdatedAt).getTime() -
|
||||
new Date(a.prd.lastUpdatedAt).getTime()
|
||||
);
|
||||
});
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
// ─── Model Resolution ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve a "<provider>/<model>" spec string via the model registry.
|
||||
* Returns undefined if spec is empty, malformed, or not found.
|
||||
*/
|
||||
export function resolveModelSpec(
|
||||
modelRegistry:
|
||||
| { find(provider: string, modelId: string): unknown }
|
||||
| undefined,
|
||||
spec: string,
|
||||
onWarning?: (msg: string) => void,
|
||||
): unknown | undefined {
|
||||
if (!spec) return undefined;
|
||||
const slashIdx = spec.indexOf("/");
|
||||
if (slashIdx === -1) {
|
||||
onWarning?.(
|
||||
`ralpi config: skipping model "${spec}" — expected <provider>/<model> format`,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
const provider = spec.slice(0, slashIdx);
|
||||
const modelId = spec.slice(slashIdx + 1);
|
||||
return modelRegistry?.find(provider, modelId);
|
||||
}
|
||||
|
||||
// ─── Config ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Try to use the `yaml` package (real dependency in package.json).
|
||||
@@ -239,6 +311,15 @@ export function loadConfig(projectDir: string): RalpiConfig {
|
||||
const content = fs.readFileSync(filePath, "utf-8");
|
||||
const parsed = parseSimpleYaml(content);
|
||||
Object.assign(acc, mergeConfig(acc, parsed));
|
||||
// Track which execution keys were explicitly set in this YAML so the
|
||||
// loop-startup prompts can be skipped for fields the user already set.
|
||||
const exec = parsed?.execution;
|
||||
if (exec && typeof exec === "object" && !Array.isArray(exec)) {
|
||||
acc.execution.explicitKeys ??= new Set<string>();
|
||||
for (const key of Object.keys(exec)) {
|
||||
acc.execution.explicitKeys.add(key);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Malformed config — skip silently
|
||||
}
|
||||
@@ -434,6 +515,15 @@ export async function runAgentSession(
|
||||
signal?: AbortSignal,
|
||||
model?: unknown,
|
||||
thinkingLevel?: unknown,
|
||||
/** When true, skip loading the skills catalog for this session. Used by
|
||||
* focused follow-up sessions (commit/review) that don't need skills —
|
||||
* keeps the context lean and avoids dragging in unrelated overhead. */
|
||||
noSkills = false,
|
||||
/** Parent session's model runtime. Must be passed so extension-registered
|
||||
* providers (e.g., neuralwatt with its streamSimple wrapper for 429
|
||||
* rate-limit normalization) are available. When omitted, the SDK creates
|
||||
* a fresh runtime from models.json only — extension providers are lost. */
|
||||
modelRuntime?: ModelRuntime,
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
text: string;
|
||||
@@ -466,7 +556,7 @@ export async function runAgentSession(
|
||||
cwd,
|
||||
agentDir: getAgentDir(),
|
||||
noExtensions: true,
|
||||
noSkills: false,
|
||||
noSkills,
|
||||
noPromptTemplates: true,
|
||||
noThemes: true,
|
||||
noContextFiles: true,
|
||||
@@ -477,6 +567,8 @@ export async function runAgentSession(
|
||||
cwd,
|
||||
sessionManager: SessionManager.inMemory(),
|
||||
resourceLoader: loader,
|
||||
settingsManager: SettingsManager.create(cwd, getAgentDir()),
|
||||
modelRuntime,
|
||||
tools: ["read", "bash", "edit", "write", "grep", "find", "ls"],
|
||||
model: model as any,
|
||||
thinkingLevel: thinkingLevel as any,
|
||||
@@ -675,3 +767,121 @@ export function captureGitCommits(projectDir: string): {
|
||||
|
||||
return { commitMessages, commitSummary };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the diff of the latest commit (HEAD).
|
||||
* Returns the short hash, subject, and full diff (stat + patch).
|
||||
* Used by the auto-review agent to review a commit against the task.
|
||||
*/
|
||||
export function getLatestCommitDiff(
|
||||
projectDir: string,
|
||||
): { hash: string; subject: string; diff: string } | null {
|
||||
const { execSync } = require("node:child_process");
|
||||
|
||||
try {
|
||||
execSync("git rev-parse --git-dir", { cwd: projectDir, stdio: "pipe" });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const hash = execSync("git rev-parse --short HEAD", {
|
||||
cwd: projectDir,
|
||||
encoding: "utf-8",
|
||||
}).trim();
|
||||
|
||||
const subject = execSync("git log -1 --format=%s", {
|
||||
cwd: projectDir,
|
||||
encoding: "utf-8",
|
||||
}).trim();
|
||||
|
||||
// Full diff of the latest commit: stat overview + patch.
|
||||
// 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
377
src/worktree.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user