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
|
## External dependencies
|
||||||
|
|
||||||
The extension imports from Pi SDK packages (not in `package.json` — provided by the host):
|
The extension imports from Pi SDK packages (not in `package.json` — provided by the host):
|
||||||
|
|
||||||
- `@earendil-works/pi-coding-agent` — `ExtensionAPI`, `ExtensionContext`, `createAgentSession`, etc.
|
- `@earendil-works/pi-coding-agent` — `ExtensionAPI`, `ExtensionContext`, `createAgentSession`, etc.
|
||||||
- `@earendil-works/pi-tui` — `Box`, `Text` for custom message renderer
|
- `@earendil-works/pi-tui` — `Box`, `Text` for custom message renderer
|
||||||
|
|
||||||
@@ -44,6 +45,7 @@ The only real npm dependency is `yaml` (^2.4.0).
|
|||||||
## Runtime state
|
## Runtime state
|
||||||
|
|
||||||
All runtime state lives in `.ralpi/` in the **project directory** (not this extension directory):
|
All runtime state lives in `.ralpi/` in the **project directory** (not this extension directory):
|
||||||
|
|
||||||
- `.ralpi/progress.json` — execution progress, supports multiple PRDs
|
- `.ralpi/progress.json` — execution progress, supports multiple PRDs
|
||||||
- `.ralpi/reflections/` — per-task reflection JSON files
|
- `.ralpi/reflections/` — per-task reflection JSON files
|
||||||
- `.ralpi/prompts/` — generated prompts (timestamped, for debugging)
|
- `.ralpi/prompts/` — generated prompts (timestamped, for debugging)
|
||||||
@@ -59,4 +61,13 @@ Task IDs are zero-padded strings (`"01"`, `"02"`, etc.). The parser prepends `0`
|
|||||||
|
|
||||||
## Config
|
## Config
|
||||||
|
|
||||||
Read from `.ralpi/config.yaml` in project directory. Falls back to `DEFAULT_CONFIG` in `src/types.ts` when file is missing. Config is loaded at `projectDir` level, not extension level.
|
Read from `.ralpi/config.yaml` in project directory (and global `~/.pi/ralpi/config.yaml`). Falls back to `DEFAULT_CONFIG` in `src/types.ts` when files are missing. Config is loaded at `projectDir` level, not extension level.
|
||||||
|
|
||||||
|
Key config fields in `execution`:
|
||||||
|
|
||||||
|
- `autoCommit` / `autoReview` — toggle follow-up commit and review agent sessions (also selectable at loop startup via `selectLoopOptions`)
|
||||||
|
- `models` — round-robin model list for parallel mode
|
||||||
|
- `implModel` / `commitModel` / `reviewModel` — `<provider>/<model>` strings resolved via `resolveModelSpec` in `utils.ts`
|
||||||
|
- `commitTimeoutMs` / `reviewTimeoutMs` — timeouts for follow-up sessions
|
||||||
|
- `loopTimeoutMs` — max total loop duration in ms (0 = no limit; checked between batches in `executePlanBatches`)
|
||||||
|
- `timeoutMs` — per-task execution timeout
|
||||||
|
|||||||
34
README.md
34
README.md
@@ -120,6 +120,15 @@ execution:
|
|||||||
models: # round-robin in <provider>/<model> format
|
models: # round-robin in <provider>/<model> format
|
||||||
- google/gemini-3.5-flash # 1st and 3rd task in parallel
|
- google/gemini-3.5-flash # 1st and 3rd task in parallel
|
||||||
- openai/gpt-5.5 # 2nd task in parallel
|
- openai/gpt-5.5 # 2nd task in parallel
|
||||||
|
autoCommit: true # commit after each task (mandated when autoReview is on; standalone toggle when off)
|
||||||
|
autoReview: false # commit → review → loop on fail → merge on pass
|
||||||
|
implModel: "" # model for task impl (sequential mode, empty = inherit parent)
|
||||||
|
commitModel: "" # model for commit sessions (empty = inherit task model)
|
||||||
|
reviewModel: "" # model for review sessions (empty = inherit task model)
|
||||||
|
timeoutMs: 0 # per-task timeout in ms (0 = inherit Pi's defaults)
|
||||||
|
commitTimeoutMs: 60000 # timeout for auto-commit agent sessions
|
||||||
|
reviewTimeoutMs: 120000 # timeout for auto-review agent sessions
|
||||||
|
loopTimeoutMs: 0 # max total loop duration in ms (0 = no limit)
|
||||||
prompts:
|
prompts:
|
||||||
projectContext: "Additional context for all tasks"
|
projectContext: "Additional context for all tasks"
|
||||||
```
|
```
|
||||||
@@ -134,6 +143,31 @@ prompts:
|
|||||||
> **NOTE**: this is only used in parallel execution, in sequential mode the
|
> **NOTE**: this is only used in parallel execution, in sequential mode the
|
||||||
> parent pi session's model is used
|
> parent pi session's model is used
|
||||||
|
|
||||||
|
#### Auto-review and Auto-commit
|
||||||
|
|
||||||
|
At loop startup the review question is asked FIRST. When `autoReview` is
|
||||||
|
enabled, commit is **mandated** — after task execution, changes are
|
||||||
|
committed (via a commit agent session when the task agent didn't
|
||||||
|
self-commit), then the complete task diff (`baseRef..HEAD`) is reviewed
|
||||||
|
against the task description. On a `fail` verdict the task is
|
||||||
|
re-executed with the review feedback injected into the prompt (looping
|
||||||
|
until the review passes or `maxReviewRetries` is exhausted). After
|
||||||
|
re-execution, changes are committed again and the full diff is
|
||||||
|
re-reviewed with the same base ref so the reviewer sees the complete
|
||||||
|
state — original work plus fixes. On pass, the changes are already
|
||||||
|
committed and the worktree merges.
|
||||||
|
|
||||||
|
When `autoReview` is disabled, `autoCommit` runs a follow-up commit
|
||||||
|
agent after each task with no review. Both options can be overridden at
|
||||||
|
loop startup via a selection prompt (config YAML values are honored
|
||||||
|
without prompting when set explicitly).
|
||||||
|
|
||||||
|
`commitModel` and `reviewModel` accept `<provider>/<model>` strings (e.g.
|
||||||
|
`anthropic/claude-sonnet-4`) resolved via the model registry. When empty, the
|
||||||
|
task's model is inherited. `implModel` sets the model for task implementation
|
||||||
|
in sequential mode (overridden by `execution.models` round-robin in parallel
|
||||||
|
mode).
|
||||||
|
|
||||||
## State Files
|
## State Files
|
||||||
|
|
||||||
- `.ralpi/progress.json` - Execution progress
|
- `.ralpi/progress.json` - Execution progress
|
||||||
|
|||||||
803
index.ts
803
index.ts
@@ -15,11 +15,10 @@ import {
|
|||||||
import { ProgressTracker } from "./src/progress";
|
import { ProgressTracker } from "./src/progress";
|
||||||
import { buildPlanPrompt } from "./src/prompts";
|
import { buildPlanPrompt } from "./src/prompts";
|
||||||
import { formatReflections } from "./src/reflection";
|
import { formatReflections } from "./src/reflection";
|
||||||
import {
|
import { verdictGlyph, verdictSummary, formatFindings } from "./src/review";
|
||||||
executeBatch,
|
import type { ReviewResult } from "./src/types";
|
||||||
SPINNER_FRAMES,
|
import { executeBatch, type SendChatMessage } from "./src/executor";
|
||||||
type SendChatMessage,
|
import { cleanupStaleWorktrees } from "./src/worktree";
|
||||||
} from "./src/executor";
|
|
||||||
import {
|
import {
|
||||||
loadConfig,
|
loadConfig,
|
||||||
resolveTaskArg,
|
resolveTaskArg,
|
||||||
@@ -29,6 +28,8 @@ import {
|
|||||||
deleteLoopActive,
|
deleteLoopActive,
|
||||||
readLoopActive,
|
readLoopActive,
|
||||||
findRalpiDir,
|
findRalpiDir,
|
||||||
|
listPRDsSorted,
|
||||||
|
formatDuration,
|
||||||
} from "./src/utils";
|
} from "./src/utils";
|
||||||
|
|
||||||
const COMMANDS = ["plan", "resume", "reset"] as const;
|
const COMMANDS = ["plan", "resume", "reset"] as const;
|
||||||
@@ -120,6 +121,124 @@ function buildPlanByMode(
|
|||||||
: buildSequentialPlan(project, completed);
|
: buildSequentialPlan(project, completed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prompt the user to select auto-review and auto-commit options for this loop.
|
||||||
|
* Reviews are asked about FIRST. When autoReview is on, commit is always
|
||||||
|
* mandated (it happens before review) — so autoCommit is forced true and not
|
||||||
|
* asked about. When autoReview is off, autoCommit is asked as a stand-alone
|
||||||
|
* toggle. Fields explicitly set in the config YAML are skipped (no prompt).
|
||||||
|
* Returns the selected options (or config defaults if cancelled).
|
||||||
|
*/
|
||||||
|
async function selectLoopOptions(
|
||||||
|
ctx: ExtensionContext,
|
||||||
|
config: import("./src/types").RalpiConfig,
|
||||||
|
): Promise<{ autoCommit: boolean; autoReview: boolean; saveReviews: boolean }> {
|
||||||
|
const explicit = config.execution.explicitKeys;
|
||||||
|
|
||||||
|
// ── 1. Auto-review (asked FIRST) ──
|
||||||
|
// When enabled, a commit is mandated before review (the task agent's
|
||||||
|
// changes are committed, then the complete diff is reviewed). On 'fail'
|
||||||
|
// the task is re-executed with review feedback (looping until pass or
|
||||||
|
// maxReviewRetries exhausted). On pass the worktree merges.
|
||||||
|
let autoReview: boolean;
|
||||||
|
if (explicit?.has("autoReview")) {
|
||||||
|
autoReview = config.execution.autoReview;
|
||||||
|
} else {
|
||||||
|
const reviewChoice = await ctx.ui.select("Auto-review after each task?", [
|
||||||
|
"Yes — review the task commit and loop on failures (re-execute until pass)",
|
||||||
|
"No — skip review",
|
||||||
|
]);
|
||||||
|
autoReview = reviewChoice
|
||||||
|
? reviewChoice.startsWith("Yes")
|
||||||
|
: config.execution.autoReview;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 2. Save full review output to disk (only when review is enabled) ──
|
||||||
|
let saveReviews = false;
|
||||||
|
if (autoReview) {
|
||||||
|
if (explicit?.has("saveReviews")) {
|
||||||
|
saveReviews = config.execution.saveReviews;
|
||||||
|
} else {
|
||||||
|
const saveChoice = await ctx.ui.select(
|
||||||
|
"Save full review output to disk?",
|
||||||
|
[
|
||||||
|
"Yes — write each review to .ralpi/reviews/<loop>/<task>.md",
|
||||||
|
"No — keep reviews in-chat only",
|
||||||
|
],
|
||||||
|
);
|
||||||
|
saveReviews = saveChoice
|
||||||
|
? saveChoice.startsWith("Yes")
|
||||||
|
: config.execution.saveReviews;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 3. Auto-commit ──
|
||||||
|
// When autoReview is on, commit is always mandated (it happens before the
|
||||||
|
// review). autoCommit is forced true and not asked about. When review is
|
||||||
|
// disabled, autoCommit is asked as a stand-alone "commit per task" toggle.
|
||||||
|
let autoCommit: boolean;
|
||||||
|
if (autoReview) {
|
||||||
|
autoCommit = true; // mandated by the review-gated flow
|
||||||
|
} else if (explicit?.has("autoCommit")) {
|
||||||
|
autoCommit = config.execution.autoCommit;
|
||||||
|
} else {
|
||||||
|
const commitChoice = await ctx.ui.select("Auto-commit after each task?", [
|
||||||
|
"Yes — stage and commit changes automatically",
|
||||||
|
"No — skip auto-commit",
|
||||||
|
]);
|
||||||
|
autoCommit = commitChoice
|
||||||
|
? commitChoice.startsWith("Yes")
|
||||||
|
: config.execution.autoCommit;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { autoCommit, autoReview, saveReviews };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When multiple PRD loops have progress, prompt the user to select which one
|
||||||
|
* to resume. Returns the selected PRD key and sourcePath.
|
||||||
|
* If only one PRD exists, returns it without prompting.
|
||||||
|
* Returns null if no PRDs exist.
|
||||||
|
*/
|
||||||
|
async function selectPRDToResume(
|
||||||
|
ctx: ExtensionContext,
|
||||||
|
found: NonNullable<ReturnType<typeof findProgressFile>>,
|
||||||
|
): Promise<{ prdKey: string; sourcePath: string } | null> {
|
||||||
|
const prds = listPRDsSorted(found.state);
|
||||||
|
if (prds.length === 0) return null;
|
||||||
|
if (prds.length === 1) {
|
||||||
|
return { prdKey: prds[0].key, sourcePath: prds[0].prd.sourcePath };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multiple PRDs — show selection sorted by most recent first
|
||||||
|
const options = prds.map((entry) => {
|
||||||
|
const tasks = entry.prd.tasks;
|
||||||
|
const total = Object.keys(tasks).length;
|
||||||
|
const completed = Object.values(tasks).filter(
|
||||||
|
(t) => t.status === "completed",
|
||||||
|
).length;
|
||||||
|
const failed = Object.values(tasks).filter(
|
||||||
|
(t) => t.status === "failed",
|
||||||
|
).length;
|
||||||
|
const relPath = path.relative(process.cwd(), entry.prd.sourcePath);
|
||||||
|
const updated = new Date(entry.prd.lastUpdatedAt).toLocaleString();
|
||||||
|
return `${relPath} — ${completed}/${total} done${failed ? `, ${failed} failed` : ""} · ${updated}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const selected = await ctx.ui.select(
|
||||||
|
"Multiple loops found. Which to resume?",
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
if (!selected) return null;
|
||||||
|
|
||||||
|
const idx = options.indexOf(selected);
|
||||||
|
if (idx === -1) return null;
|
||||||
|
return {
|
||||||
|
prdKey: prds[idx].key,
|
||||||
|
sourcePath: prds[idx].prd.sourcePath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** Run all batches in a plan, updating the task file after each batch. */
|
/** Run all batches in a plan, updating the task file after each batch. */
|
||||||
async function executePlanBatches(
|
async function executePlanBatches(
|
||||||
plan: ReturnType<typeof buildPlanByMode>,
|
plan: ReturnType<typeof buildPlanByMode>,
|
||||||
@@ -131,8 +250,11 @@ async function executePlanBatches(
|
|||||||
mode: ExecutionMode,
|
mode: ExecutionMode,
|
||||||
sendChatMessage?: SendChatMessage,
|
sendChatMessage?: SendChatMessage,
|
||||||
projectDir?: string,
|
projectDir?: string,
|
||||||
|
isResume?: boolean,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Write loop-active marker so widgets can be re-instantiated after a reload
|
// Write loop-active marker so a session reload can detect an interrupted
|
||||||
|
// loop and resume it (in-process agent sessions die on reload — the marker
|
||||||
|
// + progress.json in_progress tasks are the signal to re-run them).
|
||||||
if (projectDir) {
|
if (projectDir) {
|
||||||
const allTaskIds = plan.batches.flatMap((b) => b.tasks.map((t) => t.id));
|
const allTaskIds = plan.batches.flatMap((b) => b.tasks.map((t) => t.id));
|
||||||
writeLoopActive(projectDir, {
|
writeLoopActive(projectDir, {
|
||||||
@@ -141,14 +263,49 @@ async function executePlanBatches(
|
|||||||
startedAt: new Date().toISOString(),
|
startedAt: new Date().toISOString(),
|
||||||
taskIds: allTaskIds,
|
taskIds: allTaskIds,
|
||||||
prdKey: progress.getKey(),
|
prdKey: progress.getKey(),
|
||||||
|
autoCommit: config.execution.autoCommit,
|
||||||
|
autoReview: config.execution.autoReview,
|
||||||
|
saveReviews: config.execution.saveReviews,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Clean up stale worktrees from interrupted runs before starting.
|
||||||
|
// On resume this MUST be skipped: an interrupted in-progress task's
|
||||||
|
// worktree still carries its committed branch, which createWorktree()
|
||||||
|
// reuses to continue the task rather than restarting from scratch.
|
||||||
|
// The stale-worktree sweep only runs for fresh loops so concurrent
|
||||||
|
// loops (other PRDs) are still scoped out via the prdKey filter above.
|
||||||
|
if (!isResume && config.execution.worktrees !== "never" && projectDir) {
|
||||||
|
const removed = cleanupStaleWorktrees(
|
||||||
|
projectDir,
|
||||||
|
config.paths.stateDir,
|
||||||
|
progress.getKey(),
|
||||||
|
);
|
||||||
|
if (removed.length > 0) {
|
||||||
|
ctx.ui.notify(
|
||||||
|
`Cleaned up ${removed.length} stale worktree(s) from previous run.`,
|
||||||
|
"info",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track failed task IDs across batches to block downstream tasks
|
// Track failed task IDs across batches to block downstream tasks
|
||||||
const failedTaskIds = new Set(progress.getFailedTaskIds());
|
const failedTaskIds = new Set(progress.getFailedTaskIds());
|
||||||
|
|
||||||
|
// Loop-level execution timeout: stop starting new batches once elapsed.
|
||||||
|
// In-progress tasks finish naturally; we just skip remaining batches.
|
||||||
|
const loopStart = Date.now();
|
||||||
|
const loopTimeoutMs = config.execution.loopTimeoutMs;
|
||||||
|
let loopTimedOut = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (const batch of plan.batches) {
|
for (const batch of plan.batches) {
|
||||||
|
// Check loop timeout before starting a new batch
|
||||||
|
if (loopTimeoutMs > 0 && Date.now() - loopStart > loopTimeoutMs) {
|
||||||
|
loopTimedOut = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
if (progress.getState().paused) {
|
if (progress.getState().paused) {
|
||||||
ctx.ui.notify(
|
ctx.ui.notify(
|
||||||
"Execution paused. Use /ralpi resume to continue.",
|
"Execution paused. Use /ralpi resume to continue.",
|
||||||
@@ -223,6 +380,13 @@ async function executePlanBatches(
|
|||||||
if (projectDir) {
|
if (projectDir) {
|
||||||
deleteLoopActive(projectDir);
|
deleteLoopActive(projectDir);
|
||||||
}
|
}
|
||||||
|
if (loopTimedOut) {
|
||||||
|
const elapsed = formatDuration(Date.now() - loopStart);
|
||||||
|
ctx.ui.notify(
|
||||||
|
`Loop execution timeout reached (${elapsed}). Remaining tasks skipped. Use /ralpi resume to continue.`,
|
||||||
|
"warning",
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,6 +403,9 @@ export default function ralpiLoopExtension(pi: ExtensionAPI): void {
|
|||||||
| {
|
| {
|
||||||
phase?: string;
|
phase?: string;
|
||||||
toolCalls?: Array<{ name: string; label: string }>;
|
toolCalls?: Array<{ name: string; label: string }>;
|
||||||
|
reviewText?: string;
|
||||||
|
reviewPath?: string;
|
||||||
|
reviewResult?: ReviewResult;
|
||||||
}
|
}
|
||||||
| undefined;
|
| undefined;
|
||||||
|
|
||||||
@@ -248,6 +415,45 @@ export default function ralpiLoopExtension(pi: ExtensionAPI): void {
|
|||||||
// Header line — e.g. "✓ 05 · billing-subscriptions-trials (2m 14s)"
|
// Header line — e.g. "✓ 05 · billing-subscriptions-trials (2m 14s)"
|
||||||
lines.push(String(message.content));
|
lines.push(String(message.content));
|
||||||
|
|
||||||
|
// Structured review: when we have a ReviewResult, render verdict +
|
||||||
|
// findings tree. In expanded mode show findings detail; collapsed
|
||||||
|
// shows the verdict summary + a hint to expand.
|
||||||
|
const hasReview = !!details?.reviewText || !!details?.reviewResult;
|
||||||
|
if (details?.reviewResult) {
|
||||||
|
const rv = details.reviewResult;
|
||||||
|
const glyph = verdictGlyph(rv.verdict);
|
||||||
|
const summary = verdictSummary(rv);
|
||||||
|
if (expanded) {
|
||||||
|
// Show verdict, summary, then findings tree, then raw text.
|
||||||
|
lines.push(` ${glyph} VERDICT: ${rv.verdict.toUpperCase()}`);
|
||||||
|
lines.push(` ${rv.summary}`);
|
||||||
|
if (rv.findings.length > 0) {
|
||||||
|
lines.push(` ${formatFindings(rv)}`);
|
||||||
|
}
|
||||||
|
if (details.reviewText) {
|
||||||
|
const body = details.reviewText.split("\n");
|
||||||
|
for (const line of body) {
|
||||||
|
lines.push(` ${line}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const hint = details.reviewPath
|
||||||
|
? `press Ctrl+O for full review · saved to ${details.reviewPath}`
|
||||||
|
: "press Ctrl+O for full review";
|
||||||
|
lines.push(theme.fg("dim", ` ├── ${glyph} ${summary} · ${hint}`));
|
||||||
|
}
|
||||||
|
} else if (hasReview && expanded && details!.reviewText) {
|
||||||
|
const body = details!.reviewText.split("\n");
|
||||||
|
for (const line of body) {
|
||||||
|
lines.push(` ${line}`);
|
||||||
|
}
|
||||||
|
} else if (hasReview && !expanded) {
|
||||||
|
const hint = details?.reviewPath
|
||||||
|
? `press Ctrl+O for full review · saved to ${details.reviewPath}`
|
||||||
|
: "press Ctrl+O for full review";
|
||||||
|
lines.push(theme.fg("dim", ` ├── ${hint}`));
|
||||||
|
}
|
||||||
|
|
||||||
// Build tool-call tree
|
// Build tool-call tree
|
||||||
if (details?.toolCalls && details.toolCalls.length > 0) {
|
if (details?.toolCalls && details.toolCalls.length > 0) {
|
||||||
const all = details.toolCalls;
|
const all = details.toolCalls;
|
||||||
@@ -287,13 +493,15 @@ export default function ralpiLoopExtension(pi: ExtensionAPI): void {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// ─── Reload detection: re-instantiate widgets when session reloads ──────
|
// ─── Reload detection: resume interrupted loops when session reloads ──
|
||||||
//
|
//
|
||||||
// When the user types /reload while ralpi tasks are executing, the old
|
// ralpi runs task agent sessions in-process (createAgentSession), so they
|
||||||
// ExtensionContext is torn down and widgets (created via ctx.ui.setWidget)
|
// do NOT survive a /reload. When the new session starts, this handler
|
||||||
// disappear. This handler detects the reload, reads the persisted loop-active
|
// reads the persisted loop-active marker + progress.json: if any task is
|
||||||
// marker and progress.json, and re-creates live-status widgets that show
|
// still `in_progress`, the loop was interrupted mid-task and we resume it
|
||||||
// task progress with spinner animation and tool calls from session files.
|
// (resetting those tasks to pending so the DAG re-schedules them), using
|
||||||
|
// the mode + loop options snapshotted in loop-active.json so the resume is
|
||||||
|
// non-interactive.
|
||||||
pi.on("session_start", async (event, ctx) => {
|
pi.on("session_start", async (event, ctx) => {
|
||||||
if (event.reason !== "reload") return;
|
if (event.reason !== "reload") return;
|
||||||
|
|
||||||
@@ -306,89 +514,9 @@ export default function ralpiLoopExtension(pi: ExtensionAPI): void {
|
|||||||
if (!loopState) return;
|
if (!loopState) return;
|
||||||
|
|
||||||
// Load progress state
|
// Load progress state
|
||||||
let abortPolling = false;
|
|
||||||
const progressPath = path.join(projectDir, ".ralpi", "progress.json");
|
const progressPath = path.join(projectDir, ".ralpi", "progress.json");
|
||||||
const sessionsDir = path.join(projectDir, ".ralpi", "sessions");
|
|
||||||
|
|
||||||
// Parse the task file to get task titles
|
/** Re-read progress from disk. */
|
||||||
const titleMap = new Map<string, string>();
|
|
||||||
try {
|
|
||||||
const project = parseTaskFile(loopState.taskFile);
|
|
||||||
for (const task of project.tasks) {
|
|
||||||
titleMap.set(task.id, task.title);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// If parsing fails, just use IDs without titles
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Read recent tool calls from a task's session file. */
|
|
||||||
const readRecentToolCalls = (
|
|
||||||
taskId: string,
|
|
||||||
maxLines = 30,
|
|
||||||
): Array<{ name: string; label: string }> => {
|
|
||||||
try {
|
|
||||||
const files = fs
|
|
||||||
.readdirSync(sessionsDir)
|
|
||||||
.filter((f) => f.startsWith(taskId + "-"))
|
|
||||||
.sort();
|
|
||||||
if (files.length === 0) return [];
|
|
||||||
const sessionPath = path.join(sessionsDir, files[files.length - 1]);
|
|
||||||
const content = fs.readFileSync(sessionPath, "utf-8");
|
|
||||||
const lines = content
|
|
||||||
.split("\n")
|
|
||||||
.filter((l) => l.trim())
|
|
||||||
.slice(-maxLines);
|
|
||||||
const calls: Array<{ name: string; label: string }> = [];
|
|
||||||
for (const line of lines) {
|
|
||||||
try {
|
|
||||||
const event = JSON.parse(line);
|
|
||||||
if (event.type === "tool_execution_start") {
|
|
||||||
calls.push({
|
|
||||||
name: event.toolName,
|
|
||||||
label: formatToolLabel(event.toolName, event.args),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Skip malformed lines
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return calls;
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Strip control characters and newlines from a display label so it
|
|
||||||
* does not break TUI layout (tree branches, text width calculation).
|
|
||||||
*/
|
|
||||||
function sanitizeLabel(s: string): string {
|
|
||||||
return s
|
|
||||||
.replace(/\r?\n/g, " ")
|
|
||||||
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "")
|
|
||||||
.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Format a tool call argument into a short label. */
|
|
||||||
function formatToolLabel(name: string, args: unknown): string {
|
|
||||||
const a = args as Record<string, unknown> | undefined;
|
|
||||||
if (!a) return name;
|
|
||||||
if (name === "bash")
|
|
||||||
return sanitizeLabel(String(a.command ?? "").slice(0, 70));
|
|
||||||
if (name === "write" || name === "read" || name === "edit")
|
|
||||||
return sanitizeLabel(String(a.path ?? "").slice(0, 60));
|
|
||||||
if (name === "grep")
|
|
||||||
return sanitizeLabel(
|
|
||||||
`${a.pattern ?? "?"} — ${String(a.path ?? "").slice(0, 40)}`,
|
|
||||||
);
|
|
||||||
if (name === "find")
|
|
||||||
return sanitizeLabel(`${a.path ?? "."} — ${a.glob ?? "*"}`);
|
|
||||||
if (name === "ls")
|
|
||||||
return sanitizeLabel(String(a.path ?? ".").slice(0, 60));
|
|
||||||
return name;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Re-read progress from disk (old tasks still writing to it). */
|
|
||||||
const readTasks = (): Record<string, { status: string }> | null => {
|
const readTasks = (): Record<string, { status: string }> | null => {
|
||||||
try {
|
try {
|
||||||
const raw = fs.readFileSync(progressPath, "utf-8");
|
const raw = fs.readFileSync(progressPath, "utf-8");
|
||||||
@@ -399,226 +527,89 @@ export default function ralpiLoopExtension(pi: ExtensionAPI): void {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Early exit: if all tasks already finished during the reload, just clean up
|
// ralpi agent sessions run in-process (createAgentSession), so they do
|
||||||
|
// NOT survive a session reload. Any task left `in_progress` is therefore
|
||||||
|
// stalled — its agent died with the previous session. Detect that state
|
||||||
|
// and actively resume the loop instead of passively polling (which would
|
||||||
|
// spin forever waiting for a dead task to complete).
|
||||||
const initialTasks = readTasks();
|
const initialTasks = readTasks();
|
||||||
if (initialTasks) {
|
if (initialTasks) {
|
||||||
const remaining = Object.values(initialTasks).filter(
|
const inProgressIds = Object.entries(initialTasks).flatMap(([id, t]) =>
|
||||||
(t) => t.status === "in_progress",
|
t.status === "in_progress" ? [id] : [],
|
||||||
).length;
|
);
|
||||||
if (remaining === 0) {
|
|
||||||
ctx.ui.notify("All ralpi tasks completed during reload.", "info");
|
if (inProgressIds.length === 0) {
|
||||||
|
// Nothing was mid-flight — loop either finished cleanly between
|
||||||
|
// the reload landing and this handler running, or was stopped
|
||||||
|
// between tasks. Clean up the stale marker and bail.
|
||||||
|
ctx.ui.notify(
|
||||||
|
"ralpi loop has no in-progress task to resume — marking complete.",
|
||||||
|
"info",
|
||||||
|
);
|
||||||
deleteLoopActive(projectDir);
|
deleteLoopActive(projectDir);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Show a status notification for the reconnect
|
const taskCount = loopState.taskIds.length;
|
||||||
const taskCount = loopState.taskIds.length;
|
ctx.ui.notify(
|
||||||
ctx.ui.notify(
|
`ralpi loop was interrupted by reload with ${inProgressIds.length} in-progress task(s). ` +
|
||||||
`Reconnected to running ralpi execution (${taskCount} tasks, ${loopState.mode} mode)`,
|
`Resuming execution (${taskCount} tasks, ${loopState.mode} mode)...`,
|
||||||
"info",
|
"info",
|
||||||
);
|
);
|
||||||
|
|
||||||
// Shared state for the widget
|
// Build the sendProgress wrapper so resumed task messages render the
|
||||||
let tickCount = 0;
|
// same expandable tool-call tree as an interactive run.
|
||||||
const MAX_COLLAPSED = 3;
|
const sendProgress: SendChatMessage = (
|
||||||
|
content: string,
|
||||||
if (loopState.mode === "parallel") {
|
meta?: {
|
||||||
// ── Parallel mode: single batch widget ──
|
toolCalls?: Array<{ name: string; label: string }>;
|
||||||
const widgetKey = `ralpi-parallel-reconnect-${Date.now()}`;
|
reviewText?: string;
|
||||||
let widgetTui: { requestRender(): void } | null = null;
|
reviewPath?: string;
|
||||||
|
reviewResult?: ReviewResult;
|
||||||
const buildBatchLines = (t: typeof ctx.ui.theme): string[] => {
|
},
|
||||||
const tasks = readTasks();
|
) => {
|
||||||
if (!tasks) return [t.fg("dim", "(waiting for progress...)")];
|
pi.sendMessage({
|
||||||
|
customType: "ralpi-progress",
|
||||||
const lines: string[] = [];
|
content,
|
||||||
// Only show tasks that have started (in_progress, completed, failed).
|
display: true,
|
||||||
// Pending/unstarted tasks are noise after a reload.
|
details: {
|
||||||
const sortedIds = [...loopState.taskIds].sort().filter((id) => {
|
phase: "progress",
|
||||||
const info = tasks[id];
|
toolCalls: meta?.toolCalls,
|
||||||
return info && info.status !== "pending";
|
reviewText: meta?.reviewText,
|
||||||
|
reviewPath: meta?.reviewPath,
|
||||||
|
reviewResult: meta?.reviewResult,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// If no tasks have started yet, show nothing — polling will pick up
|
|
||||||
// changes within 500ms.
|
|
||||||
if (sortedIds.length === 0) return [t.fg("dim", "(starting tasks...)")];
|
|
||||||
|
|
||||||
for (const id of sortedIds) {
|
|
||||||
const info = tasks[id]!;
|
|
||||||
const title = titleMap.get(id);
|
|
||||||
const header = title ? `${id} · ${title}` : id;
|
|
||||||
|
|
||||||
// Status icon
|
|
||||||
if (info.status === "completed") {
|
|
||||||
lines.push(`${t.fg("success", "✓")} ${header}`);
|
|
||||||
} else if (info.status === "failed") {
|
|
||||||
lines.push(`${t.fg("error", "✗")} ${header}`);
|
|
||||||
} else if (info.status === "in_progress") {
|
|
||||||
const frame = t.fg(
|
|
||||||
"accent",
|
|
||||||
SPINNER_FRAMES[tickCount % SPINNER_FRAMES.length],
|
|
||||||
);
|
|
||||||
lines.push(`${frame} ${header}`);
|
|
||||||
|
|
||||||
// Show recent tool calls for active tasks
|
|
||||||
const toolCalls = readRecentToolCalls(id);
|
|
||||||
if (toolCalls.length > 0) {
|
|
||||||
if (toolCalls.length <= MAX_COLLAPSED) {
|
|
||||||
for (let i = 0; i < toolCalls.length; i++) {
|
|
||||||
const tc = toolCalls[i];
|
|
||||||
const isLast = i === toolCalls.length - 1;
|
|
||||||
const branch = isLast ? " └── " : " ├── ";
|
|
||||||
lines.push(
|
|
||||||
`${branch}${t.fg("accent", `[${tc.name}]`)} ${tc.label}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const shown = toolCalls.slice(-MAX_COLLAPSED);
|
|
||||||
const remaining = toolCalls.length - shown.length;
|
|
||||||
lines.push(t.fg("dim", ` ├── …${remaining} earlier`));
|
|
||||||
for (let i = 0; i < shown.length; i++) {
|
|
||||||
const tc = shown[i];
|
|
||||||
const isLast = i === shown.length - 1;
|
|
||||||
const branch = isLast ? " └── " : " ├── ";
|
|
||||||
lines.push(
|
|
||||||
`${branch}${t.fg("accent", `[${tc.name}]`)} ${tc.label}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return lines;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
ctx.ui.setWidget(widgetKey, (tui, t) => {
|
// Load config from the project directory so model + thinking level
|
||||||
widgetTui = tui;
|
// resolve the same way the interactive command handler does.
|
||||||
return {
|
const config = loadConfig(projectDir);
|
||||||
render: () => buildBatchLines(t),
|
|
||||||
invalidate: () => widgetTui?.requestRender(),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// 100ms tick: advances spinner frame every tick, refreshes
|
try {
|
||||||
// progress + tool calls every 5 ticks (500ms).
|
await resumeLoop(
|
||||||
const tickTimer = setInterval(() => {
|
ctx,
|
||||||
if (abortPolling) return;
|
loopState.taskFile,
|
||||||
tickCount++;
|
projectDir,
|
||||||
widgetTui?.requestRender();
|
loopState.prdKey,
|
||||||
|
sendProgress,
|
||||||
if (tickCount % 5 === 0) {
|
config.model ?? ctx.model,
|
||||||
const tasks = readTasks();
|
pi.getThinkingLevel(),
|
||||||
if (!tasks) return;
|
{
|
||||||
const activeCount = Object.values(tasks).filter(
|
mode: loopState.mode,
|
||||||
(t) => t.status === "in_progress",
|
autoCommit: loopState.autoCommit ?? config.execution.autoCommit,
|
||||||
).length;
|
autoReview: loopState.autoReview ?? config.execution.autoReview,
|
||||||
if (activeCount === 0) {
|
saveReviews: loopState.saveReviews ?? config.execution.saveReviews,
|
||||||
clearInterval(tickTimer);
|
skipFinalStatus: false,
|
||||||
ctx.ui.setWidget(widgetKey, undefined);
|
},
|
||||||
deleteLoopActive(projectDir);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
|
|
||||||
// Clean up timer when extension is shut down
|
|
||||||
pi.on("session_shutdown", () => {
|
|
||||||
abortPolling = true;
|
|
||||||
clearInterval(tickTimer);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// ── Sequential mode: per-task widget ──
|
|
||||||
const currentTaskId = loopState.taskIds.find((id) => {
|
|
||||||
const tasks = readTasks();
|
|
||||||
return tasks?.[id]?.status === "in_progress";
|
|
||||||
});
|
|
||||||
|
|
||||||
if (currentTaskId) {
|
|
||||||
const widgetKey = `ralpi-task-${currentTaskId}`;
|
|
||||||
let widgetTui: { requestRender(): void } | null = null;
|
|
||||||
|
|
||||||
const buildLines = (t: typeof ctx.ui.theme): string[] => {
|
|
||||||
const tasks = readTasks();
|
|
||||||
const info = tasks?.[currentTaskId];
|
|
||||||
const title = titleMap.get(currentTaskId);
|
|
||||||
const header = title ? `${currentTaskId} · ${title}` : currentTaskId;
|
|
||||||
const lines: string[] = [];
|
|
||||||
|
|
||||||
if (!info || info.status === "pending") {
|
|
||||||
return [t.fg("dim", "(starting task...)")];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (info.status === "completed") {
|
|
||||||
lines.push(`${t.fg("success", "✓")} ${header}`);
|
|
||||||
} else if (info.status === "failed") {
|
|
||||||
lines.push(`${t.fg("error", "✗")} ${header}`);
|
|
||||||
} else if (info.status === "in_progress") {
|
|
||||||
const frame = t.fg(
|
|
||||||
"accent",
|
|
||||||
SPINNER_FRAMES[tickCount % SPINNER_FRAMES.length],
|
|
||||||
);
|
|
||||||
lines.push(`${frame} ${header}`);
|
|
||||||
|
|
||||||
// Show recent tool calls
|
|
||||||
const toolCalls = readRecentToolCalls(currentTaskId);
|
|
||||||
if (toolCalls.length > 0) {
|
|
||||||
const shown = toolCalls.slice(-MAX_COLLAPSED);
|
|
||||||
const remaining = toolCalls.length - shown.length;
|
|
||||||
if (remaining > 0) {
|
|
||||||
lines.push(t.fg("dim", ` ├── …${remaining} earlier`));
|
|
||||||
}
|
|
||||||
for (let i = 0; i < shown.length; i++) {
|
|
||||||
const tc = shown[i];
|
|
||||||
const isLast = i === shown.length - 1;
|
|
||||||
const branch = isLast ? " └── " : " ├── ";
|
|
||||||
lines.push(
|
|
||||||
`${branch}${t.fg("accent", `[${tc.name}]`)} ${tc.label}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return lines;
|
|
||||||
};
|
|
||||||
|
|
||||||
ctx.ui.setWidget(widgetKey, (tui, t) => {
|
|
||||||
widgetTui = tui;
|
|
||||||
return {
|
|
||||||
render: () => buildLines(t),
|
|
||||||
invalidate: () => widgetTui?.requestRender(),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const tickTimer = setInterval(() => {
|
|
||||||
if (abortPolling) return;
|
|
||||||
tickCount++;
|
|
||||||
widgetTui?.requestRender();
|
|
||||||
|
|
||||||
if (tickCount % 5 === 0) {
|
|
||||||
const tasks = readTasks();
|
|
||||||
if (!tasks) return;
|
|
||||||
const status = tasks[currentTaskId]?.status;
|
|
||||||
if (status !== "in_progress") {
|
|
||||||
clearInterval(tickTimer);
|
|
||||||
// Keep widget visible a moment, then clean up
|
|
||||||
setTimeout(() => {
|
|
||||||
ctx.ui.setWidget(widgetKey, undefined);
|
|
||||||
deleteLoopActive(projectDir);
|
|
||||||
}, 3000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
|
|
||||||
pi.on("session_shutdown", () => {
|
|
||||||
abortPolling = true;
|
|
||||||
clearInterval(tickTimer);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// No task actively in progress — show a "resume" hint
|
|
||||||
ctx.ui.notify(
|
|
||||||
"No running task found. Use /ralpi resume to continue execution.",
|
|
||||||
"warning",
|
|
||||||
);
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error instanceof Error ? error.message : String(error);
|
||||||
|
ctx.ui.notify(`ralpi auto-resume failed: ${msg}`, "error");
|
||||||
|
// Leave loop-active.json in place so the user can retry via
|
||||||
|
// /ralpi resume after addressing the underlying error.
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -631,16 +622,29 @@ export default function ralpiLoopExtension(pi: ExtensionAPI): void {
|
|||||||
// Wraps pi.sendMessage() for posting status to the chat history.
|
// Wraps pi.sendMessage() for posting status to the chat history.
|
||||||
// Uses "ralpi-progress" customType with a "progress" phase so the
|
// Uses "ralpi-progress" customType with a "progress" phase so the
|
||||||
// renderer omits the label prefix entirely (no [INFO] etc.).
|
// renderer omits the label prefix entirely (no [INFO] etc.).
|
||||||
// Accepts an optional meta object with toolCalls for the expandable view.
|
// Accepts an optional meta object with toolCalls for the expandable view,
|
||||||
|
// and reviewText/reviewPath/reviewResult for review messages so the expanded
|
||||||
|
// (Ctrl+O) view can render the full review body without truncation.
|
||||||
const sendProgress: SendChatMessage = (
|
const sendProgress: SendChatMessage = (
|
||||||
content: string,
|
content: string,
|
||||||
meta?: { toolCalls?: Array<{ name: string; label: string }> },
|
meta?: {
|
||||||
|
toolCalls?: Array<{ name: string; label: string }>;
|
||||||
|
reviewText?: string;
|
||||||
|
reviewPath?: string;
|
||||||
|
reviewResult?: ReviewResult;
|
||||||
|
},
|
||||||
) => {
|
) => {
|
||||||
pi.sendMessage({
|
pi.sendMessage({
|
||||||
customType: "ralpi-progress",
|
customType: "ralpi-progress",
|
||||||
content,
|
content,
|
||||||
display: true,
|
display: true,
|
||||||
details: { phase: "progress", toolCalls: meta?.toolCalls },
|
details: {
|
||||||
|
phase: "progress",
|
||||||
|
toolCalls: meta?.toolCalls,
|
||||||
|
reviewText: meta?.reviewText,
|
||||||
|
reviewPath: meta?.reviewPath,
|
||||||
|
reviewResult: meta?.reviewResult,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -784,6 +788,13 @@ async function handleRun(
|
|||||||
|
|
||||||
const completed = buildCompletedSet(progress, project);
|
const completed = buildCompletedSet(progress, project);
|
||||||
const mode = await selectExecutionMode(ctx, project, taskFile, config);
|
const mode = await selectExecutionMode(ctx, project, taskFile, config);
|
||||||
|
const { autoCommit, autoReview, saveReviews } = await selectLoopOptions(
|
||||||
|
ctx,
|
||||||
|
config,
|
||||||
|
);
|
||||||
|
config.execution.autoCommit = autoCommit;
|
||||||
|
config.execution.autoReview = autoReview;
|
||||||
|
config.execution.saveReviews = saveReviews;
|
||||||
const plan = buildPlanByMode(mode, project, completed);
|
const plan = buildPlanByMode(mode, project, completed);
|
||||||
|
|
||||||
// Show dependency chain + execution plan before starting
|
// Show dependency chain + execution plan before starting
|
||||||
@@ -830,44 +841,32 @@ async function handleRun(
|
|||||||
|
|
||||||
// ─── /ralpi resume ───────────────────────────────────────────────────────────
|
// ─── /ralpi resume ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function handleResume(
|
/**
|
||||||
|
* Resume core: given a resolved task file, project dir, and PRD key,
|
||||||
|
* build the remaining plan and execute it. Used by both the explicit
|
||||||
|
* `/ralpi resume` command and the auto-resume on session reload.
|
||||||
|
*
|
||||||
|
* `mode` and loop options (`autoCommit`/`autoReview`/`saveReviews`) may be
|
||||||
|
* passed to skip interactive prompts — this is how a reload resumes
|
||||||
|
* non-interactively using the snapshot stored in loop-active.json.
|
||||||
|
* When omitted, the user is prompted as usual.
|
||||||
|
*/
|
||||||
|
async function resumeLoop(
|
||||||
ctx: ExtensionContext,
|
ctx: ExtensionContext,
|
||||||
args: string[],
|
taskFile: string,
|
||||||
sendChatMessage?: SendChatMessage,
|
projectDir: string,
|
||||||
parentModel?: unknown,
|
prdKey: string | undefined,
|
||||||
parentThinkingLevel?: unknown,
|
sendChatMessage: SendChatMessage | undefined,
|
||||||
|
parentModel: unknown,
|
||||||
|
parentThinkingLevel: unknown,
|
||||||
|
options?: {
|
||||||
|
mode?: ExecutionMode;
|
||||||
|
autoCommit?: boolean;
|
||||||
|
autoReview?: boolean;
|
||||||
|
saveReviews?: boolean;
|
||||||
|
skipFinalStatus?: boolean;
|
||||||
|
},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
let taskFile: string;
|
|
||||||
let projectDir: string;
|
|
||||||
let found: ReturnType<typeof findProgressFile>;
|
|
||||||
|
|
||||||
if (args[0]) {
|
|
||||||
taskFile = resolveTaskArg(args[0], process.cwd());
|
|
||||||
found = findProgressFile(process.cwd(), taskFile);
|
|
||||||
if (!found) {
|
|
||||||
ctx.ui.notify(
|
|
||||||
`No existing progress for ${args[0]}. Start with /ralpi run ${args[0]}`,
|
|
||||||
"warning",
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
projectDir = path.dirname(path.dirname(found.path));
|
|
||||||
} else {
|
|
||||||
found = findProgressFile(process.cwd());
|
|
||||||
if (!found) {
|
|
||||||
ctx.ui.notify(
|
|
||||||
"No .ralpi/progress.json found. Start with /ralpi run [task-file]",
|
|
||||||
"warning",
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
projectDir = path.dirname(path.dirname(found.path));
|
|
||||||
// For no-arg resume, use the first PRD's source path or legacy sourcePath
|
|
||||||
taskFile = found.state.prds
|
|
||||||
? Object.values(found.state.prds)[0].sourcePath
|
|
||||||
: found.state.sourcePath;
|
|
||||||
}
|
|
||||||
|
|
||||||
const project = parseTaskFile(taskFile);
|
const project = parseTaskFile(taskFile);
|
||||||
if (!Array.isArray(project.tasks)) {
|
if (!Array.isArray(project.tasks)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -877,21 +876,63 @@ async function handleResume(
|
|||||||
const config = loadConfig(projectDir);
|
const config = loadConfig(projectDir);
|
||||||
config.model = parentModel ?? ctx.model;
|
config.model = parentModel ?? ctx.model;
|
||||||
config.thinkingLevel = parentThinkingLevel;
|
config.thinkingLevel = parentThinkingLevel;
|
||||||
const progress = new ProgressTracker(projectDir, taskFile, found.prdKey);
|
const progress = new ProgressTracker(projectDir, taskFile, prdKey);
|
||||||
|
|
||||||
progress.setPaused(false);
|
progress.setPaused(false);
|
||||||
|
|
||||||
|
// Any task left `in_progress` died with the previous session (ralpi runs
|
||||||
|
// agents in-process). Reset them to `pending` so the DAG re-schedules
|
||||||
|
// them cleanly. Without this they'd still be re-run (they're not in the
|
||||||
|
// completed set), but the progress.json would carry a stale in_progress
|
||||||
|
// state during the rebuild window.
|
||||||
|
const resetIds = progress.resetInProgressToPending();
|
||||||
|
if (resetIds.length > 0) {
|
||||||
|
// Keep the source-file checkboxes in sync so a later parse sees these
|
||||||
|
// tasks as `pending` rather than `in_progress`.
|
||||||
|
for (const id of resetIds) {
|
||||||
|
try {
|
||||||
|
updateTaskInFile(taskFile, id, "pending");
|
||||||
|
} catch {
|
||||||
|
// Best-effort — progress.json is the source of truth for scheduling.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.ui.notify(
|
||||||
|
`Reset stalled in-progress task(s) to pending: ${resetIds.join(", ")}`,
|
||||||
|
"info",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const completed = buildCompletedSet(progress, project);
|
const completed = buildCompletedSet(progress, project);
|
||||||
const mode = await selectExecutionMode(ctx, project, taskFile, config);
|
const mode =
|
||||||
|
options?.mode ??
|
||||||
|
(await selectExecutionMode(ctx, project, taskFile, config));
|
||||||
|
|
||||||
|
let autoCommit: boolean;
|
||||||
|
let autoReview: boolean;
|
||||||
|
let saveReviews: boolean;
|
||||||
|
if (
|
||||||
|
options?.autoCommit !== undefined &&
|
||||||
|
options?.autoReview !== undefined &&
|
||||||
|
options?.saveReviews !== undefined
|
||||||
|
) {
|
||||||
|
autoCommit = options.autoCommit;
|
||||||
|
autoReview = options.autoReview;
|
||||||
|
saveReviews = options.saveReviews;
|
||||||
|
} else {
|
||||||
|
const opt = await selectLoopOptions(ctx, config);
|
||||||
|
autoCommit = opt.autoCommit;
|
||||||
|
autoReview = opt.autoReview;
|
||||||
|
saveReviews = opt.saveReviews;
|
||||||
|
}
|
||||||
|
config.execution.autoCommit = autoCommit;
|
||||||
|
config.execution.autoReview = autoReview;
|
||||||
|
config.execution.saveReviews = saveReviews;
|
||||||
const plan = buildPlanByMode(mode, project, completed);
|
const plan = buildPlanByMode(mode, project, completed);
|
||||||
|
|
||||||
// Print remaining batches before executing
|
// Print remaining batches before executing
|
||||||
const formattedPlan = formatExecutionPlan(plan);
|
const formattedPlan = formatExecutionPlan(plan);
|
||||||
if (mode === "parallel") {
|
if (mode === "parallel") {
|
||||||
ctx.ui.notify(
|
ctx.ui.notify(`${formattedPlan}\n\nResuming parallel execution...`, "info");
|
||||||
`${formattedPlan}\n\nResuming parallel execution...`,
|
|
||||||
"info",
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
ctx.ui.notify(
|
ctx.ui.notify(
|
||||||
`${formattedPlan}\n\nResuming sequential execution...`,
|
`${formattedPlan}\n\nResuming sequential execution...`,
|
||||||
@@ -909,9 +950,68 @@ async function handleResume(
|
|||||||
mode,
|
mode,
|
||||||
sendChatMessage,
|
sendChatMessage,
|
||||||
projectDir,
|
projectDir,
|
||||||
|
true, // isResume — preserve in-progress worktrees, continue them
|
||||||
);
|
);
|
||||||
|
|
||||||
ctx.ui.notify(formatProgressStatus(progress.getState()), "info");
|
if (!options?.skipFinalStatus) {
|
||||||
|
ctx.ui.notify(formatProgressStatus(progress.getState()), "info");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleResume(
|
||||||
|
ctx: ExtensionContext,
|
||||||
|
args: string[],
|
||||||
|
sendChatMessage?: SendChatMessage,
|
||||||
|
parentModel?: unknown,
|
||||||
|
parentThinkingLevel?: unknown,
|
||||||
|
): Promise<void> {
|
||||||
|
let taskFile: string;
|
||||||
|
let projectDir: string;
|
||||||
|
let prdKey: string | undefined;
|
||||||
|
|
||||||
|
if (args[0]) {
|
||||||
|
taskFile = resolveTaskArg(args[0], process.cwd());
|
||||||
|
const found = findProgressFile(process.cwd(), taskFile);
|
||||||
|
if (!found) {
|
||||||
|
ctx.ui.notify(
|
||||||
|
`No existing progress for ${args[0]}. Start with /ralpi run ${args[0]}`,
|
||||||
|
"warning",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
projectDir = path.dirname(path.dirname(found.path));
|
||||||
|
prdKey = found.prdKey;
|
||||||
|
} else {
|
||||||
|
const found = findProgressFile(process.cwd());
|
||||||
|
if (!found) {
|
||||||
|
ctx.ui.notify(
|
||||||
|
"No .ralpi/progress.json found. Start with /ralpi run [task-file]",
|
||||||
|
"warning",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
projectDir = path.dirname(path.dirname(found.path));
|
||||||
|
|
||||||
|
// When no specific task file is given, let the user select which loop
|
||||||
|
// to resume from multiple PRDs (sorted by most recent first).
|
||||||
|
const selected = await selectPRDToResume(ctx, found);
|
||||||
|
if (!selected) {
|
||||||
|
ctx.ui.notify("Resume cancelled.", "info");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
taskFile = selected.sourcePath;
|
||||||
|
prdKey = selected.prdKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
await resumeLoop(
|
||||||
|
ctx,
|
||||||
|
taskFile,
|
||||||
|
projectDir,
|
||||||
|
prdKey,
|
||||||
|
sendChatMessage,
|
||||||
|
parentModel,
|
||||||
|
parentThinkingLevel,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── /ralpi next ─────────────────────────────────────────────────────────────
|
// ─── /ralpi next ─────────────────────────────────────────────────────────────
|
||||||
@@ -941,12 +1041,11 @@ async function handleReset(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const projectDir = path.dirname(path.dirname(found.path));
|
const projectDir = path.dirname(path.dirname(found.path));
|
||||||
const progress = new ProgressTracker(
|
// Use the most recently updated PRD (first in sorted order)
|
||||||
projectDir,
|
const prds = listPRDsSorted(found.state);
|
||||||
found.state.prds
|
const sourcePath =
|
||||||
? Object.values(found.state.prds)[0].sourcePath
|
prds.length > 0 ? prds[0].prd.sourcePath : found.state.sourcePath;
|
||||||
: found.state.sourcePath,
|
const progress = new ProgressTracker(projectDir, sourcePath);
|
||||||
);
|
|
||||||
progress.reset();
|
progress.reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mikefreno/ralpi",
|
"name": "@mikefreno/ralpi",
|
||||||
"version": "0.2.5",
|
"version": "0.3.0",
|
||||||
"description": "Execute tasks from task files/PRD's using DAG-based dependency resolution with persistent progress tracking",
|
"description": "Execute tasks from task files/PRD's using DAG-based dependency resolution with persistent progress tracking",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"pi-package",
|
"pi-package",
|
||||||
|
|||||||
@@ -26,5 +26,10 @@ export const TASK_FILE_NAMES = [
|
|||||||
export const REFLECTION_HEADER = "## REFLECTION";
|
export const REFLECTION_HEADER = "## REFLECTION";
|
||||||
export const REFLECTION_PATTERN = /##\s*REFLECTION\s*\n([\s\S]*?)(?=\n```|$)/i;
|
export const REFLECTION_PATTERN = /##\s*REFLECTION\s*\n([\s\S]*?)(?=\n```|$)/i;
|
||||||
|
|
||||||
|
// Review verdict parsing
|
||||||
|
export const REVIEW_HEADER = "## REVIEW VERDICT";
|
||||||
|
export const REVIEW_PATTERN =
|
||||||
|
/##\s*REVIEW\s+VERDICT\s*\n([\s\S]*?)(?=\n```|$)/i;
|
||||||
|
|
||||||
// Pi subprocess
|
// Pi subprocess
|
||||||
export const DEFAULT_PI_ARGS = ["--no-stream"] as const;
|
export const DEFAULT_PI_ARGS = ["--no-stream"] as const;
|
||||||
|
|||||||
1298
src/executor.ts
1298
src/executor.ts
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@ import type {
|
|||||||
Task,
|
Task,
|
||||||
Reflection,
|
Reflection,
|
||||||
ToolUsage,
|
ToolUsage,
|
||||||
|
ReviewResult,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { ensureDir } from "./utils";
|
import { ensureDir } from "./utils";
|
||||||
|
|
||||||
@@ -174,6 +175,8 @@ export class ProgressTracker {
|
|||||||
outputPreview?: string,
|
outputPreview?: string,
|
||||||
commitMessages?: string[],
|
commitMessages?: string[],
|
||||||
commitSummary?: string,
|
commitSummary?: string,
|
||||||
|
review?: ReviewResult,
|
||||||
|
reviewRetries?: number,
|
||||||
): void {
|
): void {
|
||||||
const prd = this.getPRD();
|
const prd = this.getPRD();
|
||||||
this.ensureTask(prd, taskId);
|
this.ensureTask(prd, taskId);
|
||||||
@@ -185,6 +188,9 @@ export class ProgressTracker {
|
|||||||
if (outputPreview) prd.tasks[taskId].outputPreview = outputPreview;
|
if (outputPreview) prd.tasks[taskId].outputPreview = outputPreview;
|
||||||
if (commitMessages) prd.tasks[taskId].commitMessages = commitMessages;
|
if (commitMessages) prd.tasks[taskId].commitMessages = commitMessages;
|
||||||
if (commitSummary) prd.tasks[taskId].commitSummary = commitSummary;
|
if (commitSummary) prd.tasks[taskId].commitSummary = commitSummary;
|
||||||
|
if (review) prd.tasks[taskId].review = review;
|
||||||
|
if (reviewRetries !== undefined)
|
||||||
|
prd.tasks[taskId].reviewRetries = reviewRetries;
|
||||||
this.save();
|
this.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,15 +243,6 @@ export class ProgressTracker {
|
|||||||
.filter((r): r is Reflection => r !== undefined);
|
.filter((r): r is Reflection => r !== undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Increment retry count */
|
|
||||||
incrementRetry(taskId: string): number {
|
|
||||||
const prd = this.getPRD();
|
|
||||||
this.ensureTask(prd, taskId);
|
|
||||||
prd.tasks[taskId].retries++;
|
|
||||||
this.save();
|
|
||||||
return prd.tasks[taskId].retries;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Set paused state */
|
/** Set paused state */
|
||||||
setPaused(paused: boolean): void {
|
setPaused(paused: boolean): void {
|
||||||
const prd = this.getPRD();
|
const prd = this.getPRD();
|
||||||
@@ -253,6 +250,26 @@ export class ProgressTracker {
|
|||||||
this.save();
|
this.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Reset all `in_progress` tasks back to `pending`.
|
||||||
|
*
|
||||||
|
* Used after a session reload: in-process agent sessions die with the
|
||||||
|
* parent session, so any task left `in_progress` is actually stalled.
|
||||||
|
* Resetting ensures the DAG re-schedules it on the next resume. Returns
|
||||||
|
* the IDs that were reset. */
|
||||||
|
resetInProgressToPending(): string[] {
|
||||||
|
const prd = this.getPRD();
|
||||||
|
const reset: string[] = [];
|
||||||
|
for (const [id, info] of Object.entries(prd.tasks)) {
|
||||||
|
if (info.status === "in_progress") {
|
||||||
|
info.status = "pending";
|
||||||
|
delete info.startedAt;
|
||||||
|
reset.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (reset.length > 0) this.save();
|
||||||
|
return reset;
|
||||||
|
}
|
||||||
|
|
||||||
/** Get the raw PRD state (for status display) */
|
/** Get the raw PRD state (for status display) */
|
||||||
getState(): PRDProgress {
|
getState(): PRDProgress {
|
||||||
return this.getPRD();
|
return this.getPRD();
|
||||||
@@ -277,7 +294,7 @@ export class ProgressTracker {
|
|||||||
|
|
||||||
private ensureTask(prd: PRDProgress, taskId: string): void {
|
private ensureTask(prd: PRDProgress, taskId: string): void {
|
||||||
if (!prd.tasks[taskId]) {
|
if (!prd.tasks[taskId]) {
|
||||||
prd.tasks[taskId] = { status: "pending", retries: 0 };
|
prd.tasks[taskId] = { status: "pending" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
362
src/prompts.ts
362
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";
|
import { readTaskSpec } from "./parser";
|
||||||
|
|
||||||
|
/** Maximum bytes of a commit diff embedded in a review/commit prompt.
|
||||||
|
* Diffs larger than this are truncated to avoid blowing past the model's
|
||||||
|
* context window. The agent can always run `git show HEAD` itself to
|
||||||
|
* inspect the full diff when it needs more detail.
|
||||||
|
*
|
||||||
|
* ~50 KB ≈ 12.5K tokens — comfortably fits even on models with a 128K
|
||||||
|
* context window once system-prompt overhead is accounted for. */
|
||||||
|
export const MAX_DIFF_BYTES = 50_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Truncate a diff to MAX_DIFF_BYTES, appending a clear notice when truncated.
|
||||||
|
*/
|
||||||
|
function truncateDiff(diff: string): string {
|
||||||
|
if (diff.length <= MAX_DIFF_BYTES) return diff;
|
||||||
|
const omitted = diff.length - MAX_DIFF_BYTES;
|
||||||
|
return (
|
||||||
|
diff.slice(0, MAX_DIFF_BYTES) +
|
||||||
|
"\n\n... (diff truncated: omitted " +
|
||||||
|
omitted.toLocaleString() +
|
||||||
|
" bytes; run `git show HEAD` to view the full diff)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Task Prompt ─────────────────────────────────────────────────────────────
|
// ─── Task Prompt ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -12,6 +35,9 @@ export function buildTaskPrompt(
|
|||||||
project: Project,
|
project: Project,
|
||||||
depReflections: Reflection[],
|
depReflections: Reflection[],
|
||||||
projectContext?: string,
|
projectContext?: string,
|
||||||
|
/** Review feedback from a rejected review — injected when re-executing
|
||||||
|
* a task in review-gated mode so the agent knows what to fix. */
|
||||||
|
reviewFeedback?: ReviewResult,
|
||||||
): string {
|
): string {
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
|
|
||||||
@@ -107,6 +133,32 @@ export function buildTaskPrompt(
|
|||||||
parts.push("");
|
parts.push("");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Previous Review Feedback (re-execution only) ──
|
||||||
|
|
||||||
|
if (reviewFeedback) {
|
||||||
|
parts.push("## Previous Review Feedback — FIX REQUIRED");
|
||||||
|
parts.push(
|
||||||
|
"A review agent examined your previous attempt and rejected it.",
|
||||||
|
);
|
||||||
|
parts.push(`Verdict: **${reviewFeedback.verdict.toUpperCase()}**`);
|
||||||
|
parts.push(`Summary: ${reviewFeedback.summary}`);
|
||||||
|
parts.push("");
|
||||||
|
if (reviewFeedback.findings.length > 0) {
|
||||||
|
parts.push("You MUST address these findings:");
|
||||||
|
for (const finding of reviewFeedback.findings) {
|
||||||
|
const loc = finding.file
|
||||||
|
? finding.line
|
||||||
|
? ` (${finding.file}:${finding.line})`
|
||||||
|
: ` (${finding.file})`
|
||||||
|
: "";
|
||||||
|
parts.push(`- [${finding.severity}]${loc} ${finding.message}`);
|
||||||
|
}
|
||||||
|
parts.push("");
|
||||||
|
}
|
||||||
|
parts.push("Fix every issue above. Do not re-introduce the same problems.");
|
||||||
|
parts.push("");
|
||||||
|
}
|
||||||
|
|
||||||
// ── Reflection Instructions ──
|
// ── Reflection Instructions ──
|
||||||
|
|
||||||
parts.push("## REFLECTION (REQUIRED)");
|
parts.push("## REFLECTION (REQUIRED)");
|
||||||
@@ -136,7 +188,208 @@ export function buildTaskPrompt(
|
|||||||
return parts.join("\n");
|
return parts.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Plan Prompt ─────────────────────────────────────────────────────────────
|
// ─── Review Prompt ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the prompt for the auto-review agent.
|
||||||
|
* Includes the task description and the latest commit diff so the reviewer
|
||||||
|
* can assess whether the commit fulfills the task requirements.
|
||||||
|
*/
|
||||||
|
export function buildReviewPrompt(
|
||||||
|
task: Task,
|
||||||
|
project: Project,
|
||||||
|
commitHash: string,
|
||||||
|
commitSubject: string,
|
||||||
|
commitDiff: string,
|
||||||
|
projectContext?: string,
|
||||||
|
): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
|
||||||
|
parts.push(`# Code Review: Task ${task.id}: ${task.title}`);
|
||||||
|
parts.push("");
|
||||||
|
|
||||||
|
// ── Task Description ──
|
||||||
|
|
||||||
|
parts.push("## Task Description");
|
||||||
|
if (task.description) {
|
||||||
|
parts.push(task.description);
|
||||||
|
} else {
|
||||||
|
parts.push(task.title);
|
||||||
|
}
|
||||||
|
parts.push("");
|
||||||
|
|
||||||
|
// ── Task Specification ──
|
||||||
|
|
||||||
|
if (task.file) {
|
||||||
|
const spec = readTaskSpec(project.sourceDir, task.file);
|
||||||
|
if (spec) {
|
||||||
|
parts.push("## Task Specification");
|
||||||
|
parts.push(`Full details from \`${task.file}\`:`);
|
||||||
|
parts.push("");
|
||||||
|
parts.push(spec);
|
||||||
|
parts.push("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Commit Under Review ──
|
||||||
|
|
||||||
|
parts.push("## Commit Under Review");
|
||||||
|
parts.push(`Commit: ${commitHash} — ${commitSubject}`);
|
||||||
|
parts.push("");
|
||||||
|
parts.push("### Diff");
|
||||||
|
parts.push("```diff");
|
||||||
|
parts.push(truncateDiff(commitDiff));
|
||||||
|
parts.push("```");
|
||||||
|
parts.push("");
|
||||||
|
|
||||||
|
// ── Project Context ──
|
||||||
|
|
||||||
|
if (projectContext) {
|
||||||
|
parts.push("## Additional Context");
|
||||||
|
parts.push(projectContext);
|
||||||
|
parts.push("");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Review Instructions ──
|
||||||
|
|
||||||
|
parts.push("## Review Instructions");
|
||||||
|
parts.push(
|
||||||
|
"Review the commit above against the task description. Check for:",
|
||||||
|
);
|
||||||
|
parts.push(...reviewInstructions());
|
||||||
|
parts.push("");
|
||||||
|
parts.push(
|
||||||
|
"Provide a concise review with any issues found. Your free-form prose",
|
||||||
|
);
|
||||||
|
parts.push("precedes the structured verdict block below.");
|
||||||
|
parts.push(...reviewVerdictBlock());
|
||||||
|
|
||||||
|
return parts.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Uncommitted-Changes Review Prompt ──────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a review prompt for uncommitted working-tree changes (pre-commit).
|
||||||
|
* Used in review-gated mode: the review runs BEFORE committing so a rejected
|
||||||
|
* review triggers a re-execution instead of a bad commit.
|
||||||
|
*/
|
||||||
|
export function buildReviewPromptUncommitted(
|
||||||
|
task: Task,
|
||||||
|
project: Project,
|
||||||
|
status: string,
|
||||||
|
diff: string,
|
||||||
|
projectContext?: string,
|
||||||
|
): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
|
||||||
|
parts.push(`# Code Review (pre-commit): Task ${task.id}: ${task.title}`);
|
||||||
|
parts.push("");
|
||||||
|
|
||||||
|
// ── Task Description ──
|
||||||
|
|
||||||
|
parts.push("## Task Description");
|
||||||
|
if (task.description) {
|
||||||
|
parts.push(task.description);
|
||||||
|
} else {
|
||||||
|
parts.push(task.title);
|
||||||
|
}
|
||||||
|
parts.push("");
|
||||||
|
|
||||||
|
// ── Task Specification ──
|
||||||
|
|
||||||
|
if (task.file) {
|
||||||
|
const spec = readTaskSpec(project.sourceDir, task.file);
|
||||||
|
if (spec) {
|
||||||
|
parts.push("## Task Specification");
|
||||||
|
parts.push(`Full details from \`${task.file}\`:`);
|
||||||
|
parts.push("");
|
||||||
|
parts.push(spec);
|
||||||
|
parts.push("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Uncommitted Changes Under Review ──
|
||||||
|
|
||||||
|
parts.push("## Uncommitted Changes Under Review");
|
||||||
|
parts.push(
|
||||||
|
"Review the working-tree changes below against the task description.",
|
||||||
|
);
|
||||||
|
parts.push("");
|
||||||
|
parts.push("### Current Changes (git status --porcelain)");
|
||||||
|
parts.push("```text");
|
||||||
|
parts.push(status || "(no status output)");
|
||||||
|
parts.push("```");
|
||||||
|
parts.push("");
|
||||||
|
parts.push("### Current Tracked Diff (git diff)");
|
||||||
|
parts.push("```diff");
|
||||||
|
parts.push(truncateDiff(diff) || "(no tracked diff output)");
|
||||||
|
parts.push("```");
|
||||||
|
parts.push("");
|
||||||
|
|
||||||
|
// ── Project Context ──
|
||||||
|
|
||||||
|
if (projectContext) {
|
||||||
|
parts.push("## Additional Context");
|
||||||
|
parts.push(projectContext);
|
||||||
|
parts.push("");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Review Instructions ──
|
||||||
|
|
||||||
|
parts.push("## Review Instructions");
|
||||||
|
parts.push(
|
||||||
|
"Review the uncommitted changes above against the task description. Check for:",
|
||||||
|
);
|
||||||
|
parts.push(...reviewInstructions());
|
||||||
|
parts.push("");
|
||||||
|
parts.push(
|
||||||
|
"Provide a concise review with any issues found. Your free-form prose",
|
||||||
|
);
|
||||||
|
parts.push("precedes the structured verdict block below.");
|
||||||
|
parts.push(...reviewVerdictBlock());
|
||||||
|
|
||||||
|
return parts.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Shared Review Prompt Helpers ───────────────────────────────────────────
|
||||||
|
|
||||||
|
function reviewInstructions(): string[] {
|
||||||
|
return [
|
||||||
|
"- **Correctness**: Does the implementation fulfill the task requirements?",
|
||||||
|
"- **Completeness**: Are all aspects of the task addressed?",
|
||||||
|
"- **Code quality**: Are there obvious bugs, anti-patterns, or issues?",
|
||||||
|
"- **Missing changes**: Are there files that should have been modified but weren't?",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function reviewVerdictBlock(): string[] {
|
||||||
|
return [
|
||||||
|
"## REVIEW VERDICT (REQUIRED)",
|
||||||
|
"End your response with a verdict block in EXACTLY this format:",
|
||||||
|
"",
|
||||||
|
"```",
|
||||||
|
"## REVIEW VERDICT",
|
||||||
|
"VERDICT: [pass | warn | fail]",
|
||||||
|
"SUMMARY: [1-2 sentence overall assessment]",
|
||||||
|
"FINDINGS:",
|
||||||
|
"- [blocker] file:line description (use severity: blocker|warning|nit|info)",
|
||||||
|
"- [warning] file:line description",
|
||||||
|
"```",
|
||||||
|
"",
|
||||||
|
"Verdict guidance:",
|
||||||
|
"- **pass**: the implementation fully satisfies the task requirements; no",
|
||||||
|
" action needed. Use an empty FINDINGS section (just the header).",
|
||||||
|
"- **warn**: the implementation is acceptable but has minor issues worth fixing",
|
||||||
|
" in a follow-up; not blocking.",
|
||||||
|
"- **fail**: the implementation does not satisfy the task, or has serious bugs",
|
||||||
|
" that must be fixed before proceeding.",
|
||||||
|
"",
|
||||||
|
"Each FINDINGS line uses the form `- [severity] [file:line] message`.",
|
||||||
|
"The `file:line` part is optional. Severity must be one of:",
|
||||||
|
"`blocker`, `warning`, `nit`, `info`.",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the prompt for a dry-run / plan display
|
* Build the prompt for a dry-run / plan display
|
||||||
@@ -155,9 +408,10 @@ export function buildPlanPrompt(project: Project): string {
|
|||||||
|
|
||||||
lines.push("## Tasks");
|
lines.push("## Tasks");
|
||||||
for (const task of project.tasks) {
|
for (const task of project.tasks) {
|
||||||
const deps = task.dependencies.length > 0
|
const deps =
|
||||||
? ` (depends on: ${task.dependencies.join(", ")})`
|
task.dependencies.length > 0
|
||||||
: "";
|
? ` (depends on: ${task.dependencies.join(", ")})`
|
||||||
|
: "";
|
||||||
lines.push(`- [ ] ${task.id}: ${task.title}${deps}`);
|
lines.push(`- [ ] ${task.id}: ${task.title}${deps}`);
|
||||||
}
|
}
|
||||||
lines.push("");
|
lines.push("");
|
||||||
@@ -172,3 +426,101 @@ export function buildPlanPrompt(project: Project): string {
|
|||||||
|
|
||||||
return lines.join("\n");
|
return lines.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Conflict Resolution Prompt ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the prompt for a conflict-resolution agent session.
|
||||||
|
*
|
||||||
|
* The main repo is in a merge-conflict state (from `reattemptMerge`). The
|
||||||
|
* agent must resolve all conflict markers in the conflicted files, stage the
|
||||||
|
* resolved files, and commit to complete the merge.
|
||||||
|
*/
|
||||||
|
export function buildConflictResolutionPrompt(
|
||||||
|
task: Task,
|
||||||
|
project: Project,
|
||||||
|
conflicts: string[],
|
||||||
|
branch: string,
|
||||||
|
projectContext?: string,
|
||||||
|
): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
|
||||||
|
parts.push(`# Merge Conflict Resolution: Task ${task.id}: ${task.title}`);
|
||||||
|
parts.push("");
|
||||||
|
parts.push(
|
||||||
|
`A merge of branch \`${branch}\` into the current branch produced conflicts.`,
|
||||||
|
);
|
||||||
|
parts.push("You must resolve all conflicts and complete the merge.");
|
||||||
|
parts.push("");
|
||||||
|
|
||||||
|
// ── Task Context ──
|
||||||
|
|
||||||
|
parts.push("## Task Description");
|
||||||
|
if (task.description) {
|
||||||
|
parts.push(task.description);
|
||||||
|
} else {
|
||||||
|
parts.push(task.title);
|
||||||
|
}
|
||||||
|
parts.push("");
|
||||||
|
|
||||||
|
// ── Task Specification ──
|
||||||
|
|
||||||
|
if (task.file) {
|
||||||
|
const spec = readTaskSpec(project.sourceDir, task.file);
|
||||||
|
if (spec) {
|
||||||
|
parts.push("## Task Specification");
|
||||||
|
parts.push(`Full details from \`${task.file}\`:`);
|
||||||
|
parts.push("");
|
||||||
|
parts.push(spec);
|
||||||
|
parts.push("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Conflicted Files ──
|
||||||
|
|
||||||
|
parts.push("## Conflicted Files");
|
||||||
|
parts.push(
|
||||||
|
"The following files have unresolved merge conflicts (conflict markers `<<<<<<<`, `=======`, `>>>>>>>`):",
|
||||||
|
);
|
||||||
|
parts.push("");
|
||||||
|
for (const f of conflicts) {
|
||||||
|
parts.push(`- \`${f}\``);
|
||||||
|
}
|
||||||
|
parts.push("");
|
||||||
|
|
||||||
|
// ── Project Context ──
|
||||||
|
|
||||||
|
if (projectContext) {
|
||||||
|
parts.push("## Additional Context");
|
||||||
|
parts.push(projectContext);
|
||||||
|
parts.push("");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Resolution Instructions ──
|
||||||
|
|
||||||
|
parts.push("## Resolution Instructions");
|
||||||
|
parts.push(
|
||||||
|
"1. Read each conflicted file to understand both sides of the conflict.",
|
||||||
|
);
|
||||||
|
parts.push(
|
||||||
|
"2. Edit each file to remove all conflict markers (`<<<<<<<`, `=======`, `>>>>>>>`).",
|
||||||
|
);
|
||||||
|
parts.push(
|
||||||
|
" Keep the correct changes from both sides — do NOT blindly pick one side.",
|
||||||
|
);
|
||||||
|
parts.push(
|
||||||
|
" The goal is a correct union of both the task's changes and the main branch.",
|
||||||
|
);
|
||||||
|
parts.push(
|
||||||
|
"3. After resolving all conflicts, stage the resolved files with `git add <files>`.",
|
||||||
|
);
|
||||||
|
parts.push(
|
||||||
|
"4. Complete the merge with `git commit` — use the default merge message.",
|
||||||
|
);
|
||||||
|
parts.push("");
|
||||||
|
parts.push(
|
||||||
|
"Resolve ALL conflicts. Do NOT abort the merge. Do NOT leave any conflict markers.",
|
||||||
|
);
|
||||||
|
|
||||||
|
return parts.join("\n");
|
||||||
|
}
|
||||||
|
|||||||
211
src/review.ts
Normal file
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;
|
timestamp: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Review Model ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type ReviewVerdict = "pass" | "warn" | "fail";
|
||||||
|
|
||||||
|
export interface ReviewFinding {
|
||||||
|
/** Severity of the finding */
|
||||||
|
severity: "blocker" | "warning" | "nit" | "info";
|
||||||
|
/** File path if applicable */
|
||||||
|
file?: string;
|
||||||
|
/** Line number if applicable */
|
||||||
|
line?: number;
|
||||||
|
/** Description of the issue */
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReviewResult {
|
||||||
|
taskId: string;
|
||||||
|
/** Overall verdict */
|
||||||
|
verdict: ReviewVerdict;
|
||||||
|
/** 1-2 sentence overall assessment */
|
||||||
|
summary: string;
|
||||||
|
/** Structured findings (empty when verdict is "pass") */
|
||||||
|
findings: ReviewFinding[];
|
||||||
|
/** Commit hash the review was performed against */
|
||||||
|
commitHash: string;
|
||||||
|
/** Full free-form review text (preserved for display) */
|
||||||
|
rawText: string;
|
||||||
|
/** ISO timestamp */
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ToolUsage {
|
export interface ToolUsage {
|
||||||
read: number;
|
read: number;
|
||||||
write: number;
|
write: number;
|
||||||
@@ -115,9 +146,10 @@ export interface TaskProgressInfo {
|
|||||||
status: Task["status"];
|
status: Task["status"];
|
||||||
startedAt?: string;
|
startedAt?: string;
|
||||||
completedAt?: string;
|
completedAt?: string;
|
||||||
retries: number;
|
|
||||||
durationMs?: number;
|
durationMs?: number;
|
||||||
reflection?: Reflection;
|
reflection?: Reflection;
|
||||||
|
/** Structured review result (when autoReview is enabled) */
|
||||||
|
review?: ReviewResult;
|
||||||
error?: string;
|
error?: string;
|
||||||
/** Tool usage counts from parsed subprocess output */
|
/** Tool usage counts from parsed subprocess output */
|
||||||
toolUsage?: ToolUsage;
|
toolUsage?: ToolUsage;
|
||||||
@@ -127,6 +159,8 @@ export interface TaskProgressInfo {
|
|||||||
commitMessages?: string[];
|
commitMessages?: string[];
|
||||||
/** Summary derived from git commits */
|
/** Summary derived from git commits */
|
||||||
commitSummary?: string;
|
commitSummary?: string;
|
||||||
|
/** Number of review-fix re-execution attempts made (review-gated mode) */
|
||||||
|
reviewRetries?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProgressState {
|
export interface ProgressState {
|
||||||
@@ -165,18 +199,59 @@ export interface RalpiConfig {
|
|||||||
stateDir: string;
|
stateDir: string;
|
||||||
/** Directory for per-task reflections */
|
/** Directory for per-task reflections */
|
||||||
reflectionsDir: string;
|
reflectionsDir: string;
|
||||||
|
/** Directory for per-loop review output (mirrors reflectionsDir) */
|
||||||
|
reviewsDir: string;
|
||||||
};
|
};
|
||||||
execution: {
|
execution: {
|
||||||
/** Maximum retries per task */
|
|
||||||
maxRetries: number;
|
|
||||||
/** Delay between retries in milliseconds */
|
|
||||||
retryDelayMs: number;
|
|
||||||
/** Task execution timeout in milliseconds */
|
/** Task execution timeout in milliseconds */
|
||||||
timeoutMs: number;
|
timeoutMs: number;
|
||||||
/** Maximum parallel tasks (0 = unlimited) */
|
/** Maximum parallel tasks (0 = unlimited) */
|
||||||
maxParallel: number;
|
maxParallel: number;
|
||||||
/** Round-robin model list for parallel tasks (empty = inherit parent model) */
|
/** Round-robin model list for parallel tasks (empty = inherit parent model) */
|
||||||
models: string[];
|
models: string[];
|
||||||
|
/** Spawn a follow-up agent to commit changes after each task completes */
|
||||||
|
autoCommit: boolean;
|
||||||
|
/** Spawn a review agent to review the task's committed changes against
|
||||||
|
* the task description. When autoReview is on, commit is mandated:
|
||||||
|
* changes are committed (via commit session fallback when the agent
|
||||||
|
* didn't self-commit), then the COMPLETE diff (baseRef..HEAD) is
|
||||||
|
* reviewed. On 'fail' the task is re-executed with feedback (loops
|
||||||
|
* until pass or maxReviewRetries). On pass the worktree merges.
|
||||||
|
* When autoReview is off, autoCommit controls standalone commit. */
|
||||||
|
autoReview: boolean;
|
||||||
|
/** Persist the full review output to `.ralpi/reviews/<task-id>.md`.
|
||||||
|
* Only active when autoReview is true and the user opts in at loop start. */
|
||||||
|
saveReviews: boolean;
|
||||||
|
/** Keys under `execution:` explicitly present in a loaded config YAML.
|
||||||
|
* Used to skip interactive prompts for fields the user already set. */
|
||||||
|
explicitKeys?: Set<string>;
|
||||||
|
/** Model for commit sessions in <provider>/<model> format (empty = inherit task model) */
|
||||||
|
commitModel: string;
|
||||||
|
/** Model for review sessions in <provider>/<model> format (empty = inherit task model) */
|
||||||
|
reviewModel: string;
|
||||||
|
/** Model for task implementation in <provider>/<model> format (empty = inherit parent model; only used in sequential mode when models is empty) */
|
||||||
|
implModel: string;
|
||||||
|
/** Timeout for auto-commit agent sessions in milliseconds */
|
||||||
|
commitTimeoutMs: number;
|
||||||
|
/** Timeout for auto-review agent sessions in milliseconds */
|
||||||
|
reviewTimeoutMs: number;
|
||||||
|
/** Max review-fix re-execution attempts before giving up (0 = no retries;
|
||||||
|
* review runs once, reject = stop). Active whenever autoReview is
|
||||||
|
* enabled. On exhaustion the task proceeds with its committed changes
|
||||||
|
* (the worktree merges) unless reviewBlockOnFail is set. */
|
||||||
|
maxReviewRetries: number;
|
||||||
|
/** When true, a 'fail' review verdict after exhausting maxReviewRetries
|
||||||
|
* marks the task as failed instead of proceeding with its committed
|
||||||
|
* changes (the worktree does not merge). */
|
||||||
|
reviewBlockOnFail: boolean;
|
||||||
|
/** Maximum total duration for the entire loop execution in milliseconds (0 = no limit). Checked between batches — in-progress tasks finish naturally. */
|
||||||
|
loopTimeoutMs: number;
|
||||||
|
/** Isolate each task in a separate git worktree so parallel tasks can't
|
||||||
|
* stomp each other's files, and review/commit see a clean single-task diff.
|
||||||
|
* - "never": all tasks run in the shared working tree (default, backward compat)
|
||||||
|
* - "parallel": only when maxParallel > 1 and mode is parallel
|
||||||
|
* - "always": every task gets its own worktree */
|
||||||
|
worktrees: "always" | "parallel" | "never";
|
||||||
};
|
};
|
||||||
prompts: {
|
prompts: {
|
||||||
/** Additional context injected into every task prompt */
|
/** Additional context injected into every task prompt */
|
||||||
@@ -194,13 +269,24 @@ export const DEFAULT_CONFIG: RalpiConfig = {
|
|||||||
paths: {
|
paths: {
|
||||||
stateDir: ".ralpi",
|
stateDir: ".ralpi",
|
||||||
reflectionsDir: ".ralpi/reflections",
|
reflectionsDir: ".ralpi/reflections",
|
||||||
|
reviewsDir: ".ralpi/reviews",
|
||||||
},
|
},
|
||||||
execution: {
|
execution: {
|
||||||
maxRetries: 0,
|
|
||||||
retryDelayMs: 0,
|
|
||||||
timeoutMs: 0, // 0 = inherit Pi's own defaults (no ralpi-level timeout)
|
timeoutMs: 0, // 0 = inherit Pi's own defaults (no ralpi-level timeout)
|
||||||
maxParallel: 3,
|
maxParallel: 3,
|
||||||
models: [],
|
models: [],
|
||||||
|
autoCommit: true,
|
||||||
|
autoReview: false,
|
||||||
|
saveReviews: false,
|
||||||
|
commitModel: "",
|
||||||
|
reviewModel: "",
|
||||||
|
implModel: "",
|
||||||
|
commitTimeoutMs: 0, // 0 = inherit Pi's own defaults (no ralpi-level timeout)
|
||||||
|
reviewTimeoutMs: 0, // 0 = inherit Pi's own defaults (no ralpi-level timeout)
|
||||||
|
maxReviewRetries: 2, // 2 re-execution attempts on review rejection before giving up
|
||||||
|
reviewBlockOnFail: false, // false = commit anyway after retries exhausted
|
||||||
|
loopTimeoutMs: 0, // 0 = no limit
|
||||||
|
worktrees: "parallel", // worktree isolation for parallel tasks by default
|
||||||
},
|
},
|
||||||
prompts: {
|
prompts: {
|
||||||
projectContext: "",
|
projectContext: "",
|
||||||
|
|||||||
214
src/utils.ts
214
src/utils.ts
@@ -13,6 +13,8 @@ import {
|
|||||||
DefaultResourceLoader,
|
DefaultResourceLoader,
|
||||||
getAgentDir,
|
getAgentDir,
|
||||||
SessionManager,
|
SessionManager,
|
||||||
|
SettingsManager,
|
||||||
|
type ModelRuntime,
|
||||||
} from "@earendil-works/pi-coding-agent";
|
} from "@earendil-works/pi-coding-agent";
|
||||||
|
|
||||||
// ─── Directory Helpers ───────────────────────────────────────────────────────
|
// ─── Directory Helpers ───────────────────────────────────────────────────────
|
||||||
@@ -38,7 +40,8 @@ export function writeFileSafe(filePath: string, content: string): void {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* State persisted to disk when a ralpi execution loop is active.
|
* State persisted to disk when a ralpi execution loop is active.
|
||||||
* Used to re-instantiate widgets after a session reload.
|
* Used to re-instantiate widgets after a session reload, and to resume
|
||||||
|
* the loop non-interactively when a reload interrupts in-progress tasks.
|
||||||
*/
|
*/
|
||||||
export interface LoopActiveState {
|
export interface LoopActiveState {
|
||||||
taskFile: string;
|
taskFile: string;
|
||||||
@@ -46,6 +49,11 @@ export interface LoopActiveState {
|
|||||||
startedAt: string;
|
startedAt: string;
|
||||||
taskIds: string[];
|
taskIds: string[];
|
||||||
prdKey: string;
|
prdKey: string;
|
||||||
|
/** Loop option snapshot at loop start, so a reload can resume without
|
||||||
|
* re-prompting the user. */
|
||||||
|
autoCommit?: boolean;
|
||||||
|
autoReview?: boolean;
|
||||||
|
saveReviews?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -152,6 +160,70 @@ export function findProgressFile(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all PRDs from a ProgressState, sorted by lastUpdatedAt descending
|
||||||
|
* (most recent first). Used by resume to offer a selection when multiple
|
||||||
|
* loops have progress simultaneously.
|
||||||
|
*/
|
||||||
|
export function listPRDsSorted(
|
||||||
|
state: ProgressState,
|
||||||
|
): Array<{ key: string; prd: PRDProgress }> {
|
||||||
|
const entries: Array<{ key: string; prd: PRDProgress }> = [];
|
||||||
|
|
||||||
|
if (state.prds) {
|
||||||
|
for (const [key, prd] of Object.entries(state.prds)) {
|
||||||
|
entries.push({ key, prd });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Legacy flat mode — single PRD
|
||||||
|
entries.push({
|
||||||
|
key: "legacy",
|
||||||
|
prd: {
|
||||||
|
sourcePath: state.sourcePath,
|
||||||
|
tasks: state.tasks,
|
||||||
|
startedAt: state.startedAt,
|
||||||
|
lastUpdatedAt: state.lastUpdatedAt,
|
||||||
|
paused: state.paused,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.sort((a, b) => {
|
||||||
|
return (
|
||||||
|
new Date(b.prd.lastUpdatedAt).getTime() -
|
||||||
|
new Date(a.prd.lastUpdatedAt).getTime()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Model Resolution ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a "<provider>/<model>" spec string via the model registry.
|
||||||
|
* Returns undefined if spec is empty, malformed, or not found.
|
||||||
|
*/
|
||||||
|
export function resolveModelSpec(
|
||||||
|
modelRegistry:
|
||||||
|
| { find(provider: string, modelId: string): unknown }
|
||||||
|
| undefined,
|
||||||
|
spec: string,
|
||||||
|
onWarning?: (msg: string) => void,
|
||||||
|
): unknown | undefined {
|
||||||
|
if (!spec) return undefined;
|
||||||
|
const slashIdx = spec.indexOf("/");
|
||||||
|
if (slashIdx === -1) {
|
||||||
|
onWarning?.(
|
||||||
|
`ralpi config: skipping model "${spec}" — expected <provider>/<model> format`,
|
||||||
|
);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const provider = spec.slice(0, slashIdx);
|
||||||
|
const modelId = spec.slice(slashIdx + 1);
|
||||||
|
return modelRegistry?.find(provider, modelId);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Config ──────────────────────────────────────────────────────────────────
|
// ─── Config ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Try to use the `yaml` package (real dependency in package.json).
|
/** Try to use the `yaml` package (real dependency in package.json).
|
||||||
@@ -239,6 +311,15 @@ export function loadConfig(projectDir: string): RalpiConfig {
|
|||||||
const content = fs.readFileSync(filePath, "utf-8");
|
const content = fs.readFileSync(filePath, "utf-8");
|
||||||
const parsed = parseSimpleYaml(content);
|
const parsed = parseSimpleYaml(content);
|
||||||
Object.assign(acc, mergeConfig(acc, parsed));
|
Object.assign(acc, mergeConfig(acc, parsed));
|
||||||
|
// Track which execution keys were explicitly set in this YAML so the
|
||||||
|
// loop-startup prompts can be skipped for fields the user already set.
|
||||||
|
const exec = parsed?.execution;
|
||||||
|
if (exec && typeof exec === "object" && !Array.isArray(exec)) {
|
||||||
|
acc.execution.explicitKeys ??= new Set<string>();
|
||||||
|
for (const key of Object.keys(exec)) {
|
||||||
|
acc.execution.explicitKeys.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Malformed config — skip silently
|
// Malformed config — skip silently
|
||||||
}
|
}
|
||||||
@@ -434,6 +515,15 @@ export async function runAgentSession(
|
|||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
model?: unknown,
|
model?: unknown,
|
||||||
thinkingLevel?: unknown,
|
thinkingLevel?: unknown,
|
||||||
|
/** When true, skip loading the skills catalog for this session. Used by
|
||||||
|
* focused follow-up sessions (commit/review) that don't need skills —
|
||||||
|
* keeps the context lean and avoids dragging in unrelated overhead. */
|
||||||
|
noSkills = false,
|
||||||
|
/** Parent session's model runtime. Must be passed so extension-registered
|
||||||
|
* providers (e.g., neuralwatt with its streamSimple wrapper for 429
|
||||||
|
* rate-limit normalization) are available. When omitted, the SDK creates
|
||||||
|
* a fresh runtime from models.json only — extension providers are lost. */
|
||||||
|
modelRuntime?: ModelRuntime,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
text: string;
|
text: string;
|
||||||
@@ -466,7 +556,7 @@ export async function runAgentSession(
|
|||||||
cwd,
|
cwd,
|
||||||
agentDir: getAgentDir(),
|
agentDir: getAgentDir(),
|
||||||
noExtensions: true,
|
noExtensions: true,
|
||||||
noSkills: false,
|
noSkills,
|
||||||
noPromptTemplates: true,
|
noPromptTemplates: true,
|
||||||
noThemes: true,
|
noThemes: true,
|
||||||
noContextFiles: true,
|
noContextFiles: true,
|
||||||
@@ -477,6 +567,8 @@ export async function runAgentSession(
|
|||||||
cwd,
|
cwd,
|
||||||
sessionManager: SessionManager.inMemory(),
|
sessionManager: SessionManager.inMemory(),
|
||||||
resourceLoader: loader,
|
resourceLoader: loader,
|
||||||
|
settingsManager: SettingsManager.create(cwd, getAgentDir()),
|
||||||
|
modelRuntime,
|
||||||
tools: ["read", "bash", "edit", "write", "grep", "find", "ls"],
|
tools: ["read", "bash", "edit", "write", "grep", "find", "ls"],
|
||||||
model: model as any,
|
model: model as any,
|
||||||
thinkingLevel: thinkingLevel as any,
|
thinkingLevel: thinkingLevel as any,
|
||||||
@@ -675,3 +767,121 @@ export function captureGitCommits(projectDir: string): {
|
|||||||
|
|
||||||
return { commitMessages, commitSummary };
|
return { commitMessages, commitSummary };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the diff of the latest commit (HEAD).
|
||||||
|
* Returns the short hash, subject, and full diff (stat + patch).
|
||||||
|
* Used by the auto-review agent to review a commit against the task.
|
||||||
|
*/
|
||||||
|
export function getLatestCommitDiff(
|
||||||
|
projectDir: string,
|
||||||
|
): { hash: string; subject: string; diff: string } | null {
|
||||||
|
const { execSync } = require("node:child_process");
|
||||||
|
|
||||||
|
try {
|
||||||
|
execSync("git rev-parse --git-dir", { cwd: projectDir, stdio: "pipe" });
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const hash = execSync("git rev-parse --short HEAD", {
|
||||||
|
cwd: projectDir,
|
||||||
|
encoding: "utf-8",
|
||||||
|
}).trim();
|
||||||
|
|
||||||
|
const subject = execSync("git log -1 --format=%s", {
|
||||||
|
cwd: projectDir,
|
||||||
|
encoding: "utf-8",
|
||||||
|
}).trim();
|
||||||
|
|
||||||
|
// Full diff of the latest commit: stat overview + patch.
|
||||||
|
// maxBuffer set high — the prompt builder truncates to MAX_DIFF_BYTES.
|
||||||
|
const diff = execSync("git show HEAD --stat --patch", {
|
||||||
|
cwd: projectDir,
|
||||||
|
encoding: "utf-8",
|
||||||
|
maxBuffer: 10 * 1024 * 1024,
|
||||||
|
}).trim();
|
||||||
|
|
||||||
|
return { hash, subject, diff };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capture the current HEAD commit SHA. Returns the full 40-char SHA, or
|
||||||
|
* undefined when not a git repo / git unavailable. Used to snapshot the
|
||||||
|
* worktree HEAD before a task runs so the review can diff the complete task
|
||||||
|
* output (baseRef..HEAD) — including any commits the task agent makes.
|
||||||
|
*/
|
||||||
|
export function captureGitHead(projectDir: string): string | undefined {
|
||||||
|
const { execSync } = require("node:child_process");
|
||||||
|
try {
|
||||||
|
const sha = execSync("git rev-parse HEAD", {
|
||||||
|
cwd: projectDir,
|
||||||
|
encoding: "utf-8",
|
||||||
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
|
}).trim();
|
||||||
|
// Guard against injection — only accept hex SHAs.
|
||||||
|
return /^[0-9a-f]{7,40}$/i.test(sha) ? sha : undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the diff from `baseRef` to HEAD — the complete set of committed changes
|
||||||
|
* made since the base reference. Used by the review-gated loop so the reviewer
|
||||||
|
* sees the full task diff (all commits, not just the latest) across execution
|
||||||
|
* attempts and re-execution fixes. `baseRef` must be a validated hex SHA from
|
||||||
|
* captureGitHead(). Returns the short HEAD hash, HEAD subject, and range diff,
|
||||||
|
* or null when git is unavailable / baseRef is invalid / no changes exist.
|
||||||
|
*/
|
||||||
|
export function getCommitRangeDiff(
|
||||||
|
projectDir: string,
|
||||||
|
baseRef: string,
|
||||||
|
): { hash: string; subject: string; diff: string } | null {
|
||||||
|
const { execSync } = require("node:child_process");
|
||||||
|
|
||||||
|
// Only pass validated hex SHAs to the shell.
|
||||||
|
if (!/^[0-9a-f]{7,40}$/i.test(baseRef)) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
execSync("git rev-parse --git-dir", {
|
||||||
|
cwd: projectDir,
|
||||||
|
stdio: "pipe",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const hash = execSync("git rev-parse --short HEAD", {
|
||||||
|
cwd: projectDir,
|
||||||
|
encoding: "utf-8",
|
||||||
|
}).trim();
|
||||||
|
|
||||||
|
const subject = execSync("git log -1 --format=%s", {
|
||||||
|
cwd: projectDir,
|
||||||
|
encoding: "utf-8",
|
||||||
|
}).trim();
|
||||||
|
|
||||||
|
// Diff from baseRef to HEAD — shows all committed changes made since
|
||||||
|
// the snapshot. Includes stat overview + full patch.
|
||||||
|
//
|
||||||
|
// maxBuffer is set high (10 MB) so larger tasks don't cause execSync to
|
||||||
|
// throw. The review prompt builder truncates to MAX_DIFF_BYTES (50 KB)
|
||||||
|
// before sending to the model, so the full diff in memory is fine.
|
||||||
|
const diff = execSync(`git diff ${baseRef} HEAD --stat --patch`, {
|
||||||
|
cwd: projectDir,
|
||||||
|
encoding: "utf-8",
|
||||||
|
maxBuffer: 10 * 1024 * 1024,
|
||||||
|
}).trim();
|
||||||
|
|
||||||
|
if (!diff) return null; // no changes since baseRef
|
||||||
|
return { hash, subject, diff };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
377
src/worktree.ts
Normal file
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