initial import: @mikefreno/omp-ralpi (omp port)

This commit is contained in:
2026-08-10 09:46:09 -04:00
commit a9757c6fce
36 changed files with 14616 additions and 0 deletions

35
src/constants.ts Normal file
View File

@@ -0,0 +1,35 @@
import { DEFAULT_CONFIG } from "./types";
export { DEFAULT_CONFIG };
// CLI
export const SLASH_COMMAND = "/ralpi";
export const COMMANDS = [
"run",
"plan",
"status",
"resume",
"next",
"reset",
] as const;
// Task file detection
export const TASK_FILE_NAMES = [
"README.md",
"PRD.md",
"tasks.md",
"tasks.yaml",
"tasks.yml",
] as const;
// Reflection parsing
export const REFLECTION_HEADER = "## REFLECTION";
export const REFLECTION_PATTERN = /##\s*REFLECTION\s*\n([\s\S]*?)(?=\n```|$)/i;
// Review verdict parsing
export const REVIEW_HEADER = "## REVIEW VERDICT";
export const REVIEW_PATTERN =
/##\s*REVIEW\s+VERDICT\s*\n([\s\S]*?)(?=\n```|$)/i;
// Pi subprocess
export const DEFAULT_PI_ARGS = ["--no-stream"] as const;

540
src/dag.ts Normal file
View File

@@ -0,0 +1,540 @@
import type {
Task,
ExecutionBatch,
ExecutionPlan,
Project,
ParallelGroup,
} from "./types";
// ─── Blocked Tasks ───────────────────────────────────────────────────────────
/**
* Find tasks that are blocked (direct or transitive) due to failed dependencies.
* Returns a Set of blocked task IDs.
*/
export function getBlockedTasks(
pendingTasks: Task[],
failedTaskIds: Set<string>,
): Set<string> {
const blocked = new Set<string>();
let changed = true;
while (changed) {
changed = false;
for (const task of pendingTasks) {
if (blocked.has(task.id)) continue;
const deps = task.dependencies || [];
if (deps.some((dep) => failedTaskIds.has(dep) || blocked.has(dep))) {
blocked.add(task.id);
changed = true;
}
}
}
return blocked;
}
// ─── Main Entry ──────────────────────────────────────────────────────────────
/**
* Build an execution plan from project tasks using DAG analysis.
* Returns ordered batches of parallelizable tasks.
*/
export function buildExecutionPlan(
project: Project,
completed: Set<string>,
parallelGroup?: number,
failedTaskIds: Set<string> = new Set(),
): ExecutionPlan {
// Filter out already completed AND failed tasks
// Failed tasks should not be re-scheduled — they're only re-attempted
// via the retry mechanism inside executeTask, not via the DAG.
const pendingTasks = project.tasks.filter(
(t) => !completed.has(t.id) && !failedTaskIds.has(t.id),
);
const skippedTasks = project.tasks.filter(
(t) => completed.has(t.id) || failedTaskIds.has(t.id),
);
// With explicitly declared parallel groups, all groups are independent.
// Since there are no cross-group dependencies by definition, standard
// Kahn's algorithm produces the correct plan — tasks ready in any group
// appear in the same batch, and intra-group dependencies (e.g. "21 must
// be done before 22, 23, 24") are respected automatically.
// The parallel groups are preserved as metadata for display/documentation.
if (project.parallelGroups && project.parallelGroups.length > 0) {
return {
batches: buildGroupAwareBatches(project, pendingTasks, failedTaskIds),
totalTasks: pendingTasks.length,
skippedTasks,
};
}
// If parallel_group is explicitly set (legacy config flag), use group-based batching
if (parallelGroup !== undefined) {
return {
batches: buildParallelGroupBatchesLegacy(pendingTasks, failedTaskIds),
totalTasks: pendingTasks.length,
skippedTasks,
};
}
// Use dependency-based Kahn's algorithm
return {
batches: buildBatches(pendingTasks, failedTaskIds),
totalTasks: pendingTasks.length,
skippedTasks,
};
}
// ─── Sequential Plan ─────────────────────────────────────────────────────────
/**
* Build a sequential execution plan (one task per batch)
*/
export function buildSequentialPlan(
project: Project,
completed: Set<string>,
failedTaskIds: Set<string> = new Set(),
): ExecutionPlan {
const pendingTasks = project.tasks.filter((t) => !completed.has(t.id));
// Mark tasks with failed dependencies as skipped
const blocked = getBlockedTasks(pendingTasks, failedTaskIds);
const skippedTasks = project.tasks.filter(
(t) => completed.has(t.id) || blocked.has(t.id),
);
const activeTasks = pendingTasks.filter((t) => !blocked.has(t.id));
const batches: ExecutionBatch[] = activeTasks.map((task, i) => ({
tasks: [task],
batchIndex: i,
}));
return {
batches,
totalTasks: pendingTasks.length,
skippedTasks,
};
}
// ─── Kahn's Algorithm (Dependency-Based Batching) ────────────────────────────
function buildBatches(
pendingTasks: Task[],
failedTaskIds: Set<string>,
): ExecutionBatch[] {
const batches: ExecutionBatch[] = [];
const done = new Set<string>();
const blocked = getBlockedTasks(pendingTasks, failedTaskIds);
const pendingSet = new Set(pendingTasks.map((t) => t.id));
const remaining = new Set(
pendingTasks.filter((t) => !blocked.has(t.id)).map((t) => t.id),
);
while (remaining.size > 0) {
// Find tasks whose dependencies are all satisfied
const ready: Task[] = [];
for (const task of pendingTasks) {
if (!remaining.has(task.id)) continue;
const deps = task.dependencies || [];
const depsSatisfied = deps.every(
(dep) => done.has(dep) || !pendingSet.has(dep),
);
if (depsSatisfied) {
ready.push(task);
}
}
// Cycle detection: no tasks ready but some remain
if (ready.length === 0) {
const cycleTasks = Array.from(remaining);
throw new Error(
`Dependency cycle detected among tasks: ${cycleTasks.join(", ")}`,
);
}
batches.push({ tasks: ready, batchIndex: batches.length });
for (const task of ready) {
done.add(task.id);
remaining.delete(task.id);
}
}
return batches;
}
// ─── Group-Aware Batching ────────────────────────────────────────────────────
/**
* Build batches respecting both explicit parallel groups and intra-group
* dependencies. Since parallel group declarations imply no cross-group
* dependencies, all tasks whose dependencies are satisfied — across any
* group — can run concurrently in the same batch. This means groups
* "proceed independently" as the user specified: tasks from different
* groups can appear in the same batch when ready.
*
* Intra-group dependencies (e.g., "21 must be done before 22, 23, 24")
* are handled by Kahn's algorithm: if 21 has deps satisfied but 22 doesn't,
* only 21 appears in the current batch.
*/
function buildGroupAwareBatches(
_project: Project,
pendingTasks: Task[],
failedTaskIds: Set<string>,
): ExecutionBatch[] {
const blocked = getBlockedTasks(pendingTasks, failedTaskIds);
const activeTasks = pendingTasks.filter((t) => !blocked.has(t.id));
// Standard Kahn's algorithm across ALL tasks — parallel groups are
// metadata for display, not scheduling constraints.
const pendingSet = new Set(pendingTasks.map((t) => t.id));
const done = new Set<string>();
const remaining = new Set(activeTasks.map((t) => t.id));
const batches: ExecutionBatch[] = [];
while (remaining.size > 0) {
const ready: Task[] = [];
for (const task of activeTasks) {
if (!remaining.has(task.id)) continue;
const deps = task.dependencies || [];
const depsSatisfied = deps.every(
(dep) => done.has(dep) || !pendingSet.has(dep),
);
if (depsSatisfied) {
ready.push(task);
}
}
if (ready.length === 0) {
throw new Error(
`Dependency cycle detected: ${Array.from(remaining).join(", ")}`,
);
}
batches.push({ tasks: ready, batchIndex: batches.length });
for (const task of ready) {
done.add(task.id);
remaining.delete(task.id);
}
}
return batches;
}
// ─── Legacy Parallel Group Batching ─────────────────────────────────────────
/**
* Legacy: build batches from explicit parallel_group values only.
* Groups execute in ascending order; tasks within a group run concurrently.
* Does NOT respect intra-group dependencies.
*/
function buildParallelGroupBatchesLegacy(
pendingTasks: Task[],
failedTaskIds: Set<string>,
): ExecutionBatch[] {
const blocked = getBlockedTasks(pendingTasks, failedTaskIds);
const activeTasks = pendingTasks.filter((t) => !blocked.has(t.id));
const groups = new Map<number, Task[]>();
for (const task of activeTasks) {
const group = task.parallelGroup ?? 0;
if (!groups.has(group)) groups.set(group, []);
groups.get(group)!.push(task);
}
const sortedGroups = Array.from(groups.entries()).sort((a, b) => a[0] - b[0]);
return sortedGroups.map(([_groupNum, tasks], i) => ({
tasks,
batchIndex: i,
}));
}
// ─── Cycle Detection ─────────────────────────────────────────────────────────
/**
* Detect cycles in the task dependency graph
*/
export function detectCycles(project: Project): string[] {
const adj = new Map<string, string[]>();
for (const task of project.tasks) {
adj.set(task.id, task.dependencies || []);
}
const WHITE = 0;
const GRAY = 1;
const BLACK = 2;
const color = new Map<string, number>();
for (const task of project.tasks) {
color.set(task.id, WHITE);
}
const cycleNodes: string[] = [];
function dfs(node: string): boolean {
color.set(node, GRAY);
const deps = adj.get(node) || [];
for (const dep of deps) {
if (!adj.has(dep)) continue;
const depColor = color.get(dep);
if (depColor === GRAY) {
cycleNodes.push(dep);
return true;
}
if (depColor === WHITE && dfs(dep)) {
cycleNodes.push(node);
return true;
}
}
color.set(node, BLACK);
return false;
}
for (const task of project.tasks) {
if (color.get(task.id) === WHITE) {
dfs(task.id);
}
}
return [...new Set(cycleNodes)];
}
// ─── Ready Tasks ─────────────────────────────────────────────────────────────
/**
* Get tasks that are ready to execute (all dependencies completed)
*/
export function getReadyTasks(
project: Project,
completed: Set<string>,
): Task[] {
return project.tasks.filter((task) => {
if (completed.has(task.id)) return false;
const deps = task.dependencies || [];
return deps.every((dep) => completed.has(dep));
});
}
// ─── Critical Path ───────────────────────────────────────────────────────────
/**
* Calculate the critical path (longest path through the DAG)
*/
export function getCriticalPath(project: Project): Task[] {
const taskMap = new Map(project.tasks.map((t) => [t.id, t]));
const dist = new Map<string, number>();
const prev = new Map<string, string | null>();
// Initialize
for (const task of project.tasks) {
dist.set(task.id, 1);
prev.set(task.id, null);
}
// Topological sort
const sorted: Task[] = [];
const visited = new Set<string>();
function visit(id: string) {
if (visited.has(id)) return;
visited.add(id);
const task = taskMap.get(id);
if (!task) return;
for (const dep of task.dependencies || []) {
visit(dep);
}
sorted.push(task);
}
for (const task of project.tasks) {
visit(task.id);
}
// Relax edges
for (const task of sorted) {
for (const dep of task.dependencies || []) {
const depDist = dist.get(dep);
if (depDist === undefined) continue;
const newDist = depDist + 1;
const currentDist = dist.get(task.id) ?? 0;
if (newDist > currentDist) {
dist.set(task.id, newDist);
prev.set(task.id, dep);
}
}
}
// Trace back from the longest path end
let maxTask = project.tasks[0];
for (const task of project.tasks) {
const taskDist = dist.get(task.id) ?? 0;
const maxDist = dist.get(maxTask.id) ?? 0;
if (taskDist > maxDist) {
maxTask = task;
}
}
const path: Task[] = [];
let current: string | null = maxTask.id;
while (current) {
const task = taskMap.get(current);
if (task) path.unshift(task);
current = prev.get(current) || null;
}
return path;
}
// ─── Format Dependency Chain ─────────────────────────────────────────────────
/**
* Format the dependency DAG as a tree for display.
* Rooted at tasks with no dependencies, showing what depends on what.
*/
export function formatDependencyChain(project: Project): string {
const taskMap = new Map(project.tasks.map((t) => [t.id, t]));
const lines: string[] = [];
lines.push("## Dependency Chain");
lines.push("");
if (project.tasks.length === 0) {
lines.push("(no tasks)");
return lines.join("\n");
}
// Build reverse dependency map: taskId → [dependent taskIds]
const dependents = new Map<string, string[]>();
for (const task of project.tasks) {
dependents.set(task.id, []);
}
for (const task of project.tasks) {
for (const dep of task.dependencies) {
if (dependents.has(dep)) {
dependents.get(dep)!.push(task.id);
}
}
}
// Root tasks: those with no dependencies
const roots = project.tasks.filter((t) => t.dependencies.length === 0);
const rendered = new Set<string>();
function renderNode(taskId: string, prefix: string, isLast: boolean): void {
const task = taskMap.get(taskId);
if (!task) return;
const alreadyRendered = rendered.has(taskId);
rendered.add(taskId);
const connector = prefix ? (isLast ? "└── " : "├── ") : "";
if (alreadyRendered) {
lines.push(`${prefix}${connector}${task.id} · ${task.title}`);
return;
}
const deps =
task.dependencies.length > 0
? ` ← needs ${task.dependencies.join(", ")}`
: " (root)";
lines.push(
`${prefix}${connector}${task.id} · ${task.title}${prefix ? "" : deps}`,
);
const children = (dependents.get(taskId) || [])
.filter((c) => c !== taskId)
.sort();
for (let i = 0; i < children.length; i++) {
const childPrefix = prefix + (isLast ? " " : "│ ");
renderNode(children[i], childPrefix, i === children.length - 1);
}
}
for (let i = 0; i < roots.length; i++) {
renderNode(roots[i].id, "", i === roots.length - 1);
}
// Tasks not reached from any root (have deps but no root-traversable path)
const unreached = project.tasks.filter((t) => !rendered.has(t.id));
if (unreached.length > 0) {
lines.push("");
lines.push("Orphan tasks (dependencies not in task list):");
for (const t of unreached) {
const deps =
t.dependencies.length > 0
? ` ← needs ${t.dependencies.join(", ")}`
: "";
lines.push(` ${t.id} · ${t.title}${deps}`);
}
}
return lines.join("\n");
}
// ─── Format Execution Plan ───────────────────────────────────────────────────
/**
* Format the execution plan for display
*/
/**
* Format the execution plan for display, optionally with parallel group annotations
*/
export function formatExecutionPlan(
plan: ExecutionPlan,
parallelGroups?: ParallelGroup[],
): string {
const lines: string[] = [];
lines.push("## Execution Plan");
lines.push("");
lines.push(`Total tasks: ${plan.totalTasks}`);
lines.push(`Batches: ${plan.batches.length}`);
// Build a lookup: taskId → group label
const groupLabel = new Map<string, string>();
if (parallelGroups) {
for (const g of parallelGroups) {
for (const id of g.taskIds) {
if (g.label) {
groupLabel.set(id, g.label);
}
}
}
}
if (plan.skippedTasks.length > 0) {
lines.push(
`Already completed: ${plan.skippedTasks.map((t) => t.id).join(", ")}`,
);
}
lines.push("");
for (const batch of plan.batches) {
lines.push(`### Batch ${batch.batchIndex + 1}`);
for (const task of batch.tasks) {
const annotation = groupLabel.has(task.id)
? ` _(${groupLabel.get(task.id)})_`
: "";
const deps =
task.dependencies.length > 0
? ` ← needs ${task.dependencies.join(", ")}`
: "";
lines.push(`- ${task.id}: ${task.title}${annotation}${deps}`);
}
lines.push("");
}
return lines.join("\n");
}

274
src/diff.ts Normal file
View File

@@ -0,0 +1,274 @@
/**
* Reusable unified-diff engine: parses a diff into per-file +/ stats and
* filters out noise files (locks, build output, vendor, generated, media
* binaries) so review prompts feed the model only clean, review-relevant
* changes.
*
* Ported from @piex-dev/review's `EXCLUDED_PATTERNS` + `parseDiff` (MIT).
* Kept the excluded-files-not-totaled behavior that fixed the upstream
* double-count bug.
*/
// ─── Types ──────────────────────────────────────────────────────────────────
/** Per-file diff stats. */
export interface FileDiff {
/** File path as it appears in the diff (`a/` path). */
path: string;
/** Number of added lines (excluding the `+++` header). */
linesAdded: number;
/** Number of removed lines (excluding the `---` header). */
linesRemoved: number;
/** File extension (empty when the path has none). */
ext: string;
}
/** An excluded (noise) file with the reason it was filtered. */
export interface ExcludedFile extends FileDiff {
/** Why the file was excluded (e.g. "lockfile"). */
reason: string;
}
/** Result of parsing a unified diff. */
export interface DiffSummary {
/** Files kept in scope (review-relevant). */
files: FileDiff[];
/** Files filtered out as noise. */
excluded: ExcludedFile[];
/** Sum of added lines over included files only. */
totalAdded: number;
/** Sum of removed lines over included files only. */
totalRemoved: number;
}
/** Caller-supplied overrides for the noise filter. */
export interface DiffOptions {
/** Additional exclusion regexes merged into EXCLUDED_PATTERNS. */
extraPatterns?: RegExp[];
/** Pathspec allowlist — files matching these stay in scope even if a
* default rule would exclude them. */
ignorePaths?: string[];
}
// ─── Noise-Filter Rules ─────────────────────────────────────────────────────
/** Default noise-exclusion rules, ported from @piex-dev/review (MIT).
* Each entry is a regex tested against the file path plus a human-readable
* reason surfaced in the "Excluded Files" prompt section. */
export const EXCLUDED_PATTERNS: { pattern: RegExp; reason: string }[] = [
// Lockfiles
{ pattern: /(^|\/)package-lock\.json$/i, reason: "lockfile" },
{ pattern: /(^|\/)yarn\.lock$/i, reason: "lockfile" },
{ pattern: /(^|\/)pnpm-lock\.yaml$/i, reason: "lockfile" },
{ pattern: /(^|\/)Cargo\.lock$/i, reason: "lockfile" },
{ pattern: /(^|\/)Gemfile\.lock$/i, reason: "lockfile" },
{ pattern: /\.lock$/i, reason: "lockfile" },
// Minified assets
{ pattern: /\.min\.(js|css)$/i, reason: "minified asset" },
// Generated / tooling output
{ pattern: /\.generated\./i, reason: "generated file" },
{ pattern: /\.snap$/i, reason: "snapshot" },
{ pattern: /\.map$/i, reason: "source map" },
// Build output directories
{ pattern: /(^|\/)(dist|build|out|coverage)\//i, reason: "build output" },
// Dependency trees
{ pattern: /(^|\/)node_modules\//i, reason: "dependency" },
{ pattern: /(^|\/)vendor\//i, reason: "vendored dependency" },
// Image / font / binary extensions
{
pattern:
/\.(png|jpe?g|gif|webp|svg|ico|avif|bmp|woff2?|ttf|otf|eot|pdf|zip|tar|gz|mp[34]|wav|ogg|flac|wasm|bin|exe|dll|so|a|o|class|jar|pyc)$/i,
reason: "binary/media asset",
},
];
/**
* Return the exclusion reason for a file path, or undefined when the file is
* review-relevant. Extra caller-supplied patterns are merged into the default
* rule set.
*/
export function isExcluded(
fp: string,
extraPatterns?: RegExp[],
): string | undefined {
for (const rule of EXCLUDED_PATTERNS) {
if (rule.pattern.test(fp)) return rule.reason;
}
if (extraPatterns) {
for (const p of extraPatterns) {
if (p.test(fp)) return "extra ignore pattern";
}
}
return undefined;
}
/**
* Safely compile user-supplied regex strings into RegExp objects. Invalid
* patterns (that don't compile) are skipped so a bad config value never
* crashes review prompt building.
*/
export function compileIgnorePatterns(patterns: string[]): RegExp[] {
const out: RegExp[] = [];
for (const p of patterns) {
if (!p) continue;
try {
out.push(new RegExp(p));
} catch {
// Skip malformed patterns silently
}
}
return out;
}
// ─── Chunking + Counting Helpers ────────────────────────────────────────────
/** Split a raw diff into per-file chunks, each starting at a `diff --git`
* line. The leading non-diff preamble (e.g. a `--stat` block) is dropped —
* per-file stats are derived from the patch chunks themselves. */
function chunkDiff(raw: string): string[] {
if (!raw) return [];
const lines = raw.split("\n");
const chunks: string[] = [];
let current: string[] = [];
let started = false;
for (const line of lines) {
if (line.startsWith("diff --git ")) {
if (started && current.length > 0) chunks.push(current.join("\n"));
current = [line];
started = true;
} else if (started) {
current.push(line);
}
}
if (started && current.length > 0) chunks.push(current.join("\n"));
return chunks;
}
/** Parse the `a/<path>` from a `diff --git a/… b/…` header. Returns null for
* malformed chunks that lack the a/… b/… header (guarded, never crashes). */
function chunkPath(chunk: string): string | null {
const m = chunk.match(/^diff --git a\/(.+?) b\//);
return m ? m[1] : null;
}
/** Count added/removed lines in a chunk, excluding the `+++`/`---` headers. */
function countLines(chunk: string): { added: number; removed: number } {
let added = 0;
let removed = 0;
for (const line of chunk.split("\n")) {
if (line.startsWith("+") && !line.startsWith("+++")) added++;
else if (line.startsWith("-") && !line.startsWith("---")) removed++;
}
return { added, removed };
}
/** Extract the file extension from a path (no ext → empty string). */
function getExt(fp: string): string {
const base = fp.split("/").pop() ?? "";
const idx = base.lastIndexOf(".");
return idx > 0 ? base.slice(idx + 1) : "";
}
/** Convert a git pathspec glob into a regex (supports `*`, `**`, `?`). */
function globToRegExp(glob: string): RegExp {
let re = "";
for (let i = 0; i < glob.length; i++) {
const c = glob[i];
if (c === "*") {
if (glob[i + 1] === "*") {
re += ".*";
i++;
} else {
re += "[^/]*";
}
} else if (c === "?") {
re += "[^/]";
} else if (c === ".") {
re += "\\.";
} else {
re += c;
}
}
return new RegExp(`^${re}$`);
}
/** Whether a file path matches a pathspec allowlist entry. */
function matchesPathspec(pathspec: string, fp: string): boolean {
const ps = pathspec.trim();
if (!ps) return false;
// Directory prefix: "tests/" or a bare dir name matches everything under it.
if (ps.endsWith("/") && fp.startsWith(ps)) return true;
if (ps.includes("*") || ps.includes("?")) return globToRegExp(ps).test(fp);
// Plain path — exact file or prefix directory.
if (fp === ps) return true;
if (fp.startsWith(ps + "/")) return true;
return false;
}
/** Decide whether a file path is kept in scope or noise-excluded. */
function classify(
path: string,
opts?: DiffOptions,
): { kept: boolean; reason?: string } {
const reason = isExcluded(path, opts?.extraPatterns);
if (reason === undefined) return { kept: true };
// Excluded by a rule, but an ignorePaths allowlist can keep it in scope.
const keptByPathspec =
opts?.ignorePaths?.some((ps) => matchesPathspec(ps, path)) ?? false;
return keptByPathspec ? { kept: true } : { kept: false, reason };
}
// ─── Public API ─────────────────────────────────────────────────────────────
/**
* Parse a unified diff into per-file +/ stats, splitting excluded (noise)
* files from included files. Totals are summed over included files only.
* Malformed chunks (no a/… b/… header) are skipped without crashing.
*/
export function parseDiff(raw: string, opts?: DiffOptions): DiffSummary {
const files: FileDiff[] = [];
const excluded: ExcludedFile[] = [];
let totalAdded = 0;
let totalRemoved = 0;
for (const chunk of chunkDiff(raw)) {
if (!chunk) continue;
const path = chunkPath(chunk);
if (path === null) continue; // malformed chunk — skip
const { added, removed } = countLines(chunk);
const base: FileDiff = {
path,
linesAdded: added,
linesRemoved: removed,
ext: getExt(path),
};
const decision = classify(path, opts);
if (decision.kept) {
files.push(base);
totalAdded += added;
totalRemoved += removed;
} else if (decision.reason) {
excluded.push({ ...base, reason: decision.reason });
}
}
return { files, excluded, totalAdded, totalRemoved };
}
/**
* Return the diff re-emitted with excluded (noise) file chunks removed, so an
* inlined review diff never contains filtered content. The stat preamble is
* dropped — the per-file summary table carries that information. Empty string
* when every changed file is noise.
*/
export function filterNoise(raw: string, opts?: DiffOptions): string {
const kept: string[] = [];
for (const chunk of chunkDiff(raw)) {
if (!chunk) continue;
const path = chunkPath(chunk);
if (path === null) continue;
const decision = classify(path, opts);
if (decision.kept) kept.push(chunk);
}
return kept.join("\n");
}

1918
src/executor.ts Normal file

File diff suppressed because it is too large Load Diff

773
src/parser.ts Normal file
View File

@@ -0,0 +1,773 @@
import * as fs from "node:fs";
import * as path from "node:path";
import type { Task, Project, ParallelGroup, Phase } from "./types";
// Lazy-loaded yaml package
let YAML_module: typeof import("yaml") | undefined;
function loadYaml(): typeof import("yaml") {
if (YAML_module) return YAML_module;
try {
YAML_module = require("yaml");
} catch {
throw new Error(
"YAML parsing requires the 'yaml' package. Run: npm install yaml",
);
}
return YAML_module!;
}
// ─── Main Entry ──────────────────────────────────────────────────────────────
/**
* Parse a task file (markdown or YAML) into a Project structure.
* Supports:
* - Fio README format (numbered tasks with dependency graph)
* - Phased format (## Phase N — Title sections with tasks and dependencies)
* - Simple checkbox format (- [ ] task)
* - YAML format (tasks: [...])
*/
export function parseTaskFile(filePath: string): Project {
const absolutePath = path.resolve(filePath);
const content = fs.readFileSync(absolutePath, "utf-8");
const ext = path.extname(filePath).toLowerCase();
const dir = path.dirname(absolutePath);
if (ext === ".yaml" || ext === ".yml") {
return parseYaml(content, absolutePath, dir);
}
// Markdown: detect format
if (hasDependenciesSection(content) || hasPhaseHeadings(content)) {
return parseFioFormat(content, absolutePath, dir);
}
return parseSimpleCheckbox(content, absolutePath, dir);
}
// ─── Fio Format Parser ───────────────────────────────────────────────────────
/** Match both markdown heading (## Dependencies) and plain heading (Dependencies). */
const DEP_HEADING_RE = /^(?:##\s+)?Dependencies\s*$/m;
/** Match both markdown heading (## Tasks) and plain heading (Tasks). */
const TASK_HEADING_RE = /^(?:##\s+)?Tasks\s*$/m;
/** Match other markdown headings (## Something). */
const ANY_MD_HEADING_RE = /^##\s/;
/** Match phase headings: ## Phase 1 — Push-to-Talk MVP */
const PHASE_HEADING_RE = /^\s*##\s+Phase\s+(\d+)\s*[—–:-]\s*(.+)$/i;
/** Detect plain phase headings too: Phase 1 — Title (no ##) */
const PHASE_HEADING_PLAIN_RE = /^Phase\s+(\d+)\s*[—–:-]\s*(.+)$/i;
/**
* Detect a plain (non-markdown) section heading like "Exit criteria".
* A plain heading must:
* - Start with a letter
* - Contain only letters and spaces
* - Have no colons (avoids matching "Objective:" and "Status legend:")
* - Not be a task/dep line (doesn't start with "-")
*/
function isPlainSectionHeader(line: string): boolean {
const trimmed = line.trim();
return trimmed.length > 0 && /^[A-Za-z][A-Za-z\s]*$/.test(trimmed);
}
function hasDependenciesSection(content: string): boolean {
return DEP_HEADING_RE.test(content);
}
function hasPhaseHeadings(content: string): boolean {
return PHASE_HEADING_RE.test(content) || PHASE_HEADING_PLAIN_RE.test(content);
}
function parseFioFormat(
content: string,
sourcePath: string,
sourceDir: string,
): Project {
const lines = content.split("\n");
const tasks: Task[] = [];
const dependencies: Record<string, string[]> = {};
const parallelGroups: ParallelGroup[] = [];
const phases: Phase[] = [];
let currentPhase: number | null = null;
let currentPhaseTitle = "";
let inTasks = false;
let inDeps = false;
for (const line of lines) {
// Check for phase headings first
const phaseMatch =
line.match(PHASE_HEADING_RE) || line.match(PHASE_HEADING_PLAIN_RE);
if (phaseMatch) {
// Save previous phase if exists
if (currentPhase !== null) {
const phaseTaskIds = tasks
.filter((t) => t.phase === currentPhase)
.map((t) => t.id);
if (phaseTaskIds.length > 0) {
phases.push({
number: currentPhase,
title: currentPhaseTitle,
taskIds: phaseTaskIds,
});
}
}
// Start new phase
currentPhase = parseInt(phaseMatch[1], 10);
currentPhaseTitle = phaseMatch[2].trim();
inTasks = true;
inDeps = false;
continue;
}
if (TASK_HEADING_RE.test(line)) {
inTasks = true;
inDeps = false;
continue;
}
if (DEP_HEADING_RE.test(line)) {
inTasks = false;
inDeps = true;
continue;
}
// Reset state on any other section heading — both ##-style and plain
// BUT NOT phase headings (already handled above)
if (
(ANY_MD_HEADING_RE.test(line) || isPlainSectionHeader(line)) &&
!TASK_HEADING_RE.test(line) &&
!DEP_HEADING_RE.test(line) &&
!PHASE_HEADING_RE.test(line) &&
!PHASE_HEADING_PLAIN_RE.test(line)
) {
inTasks = false;
inDeps = false;
continue;
}
if (inTasks) {
// Match all tasks on a line (supports compact single-line formats).
// ID is digits optionally followed by a single lowercase letter
// (e.g. "01", "02b", "10c") — see normalizeTaskId for the shape.
const taskPattern =
/-+\s+\[(.)\]\s+(\d+[a-z]?)\s+[—–:-]\s+(.+?)(?:\s+(?=-+\s+\[)|\s*→\s*`([^`]+)`|$)/g;
let match: RegExpExecArray | null;
while ((match = taskPattern.exec(line)) !== null) {
const [, status, id, title, file] = match;
const timeoutMs = parseTimeoutFromLine(line);
tasks.push({
id: normalizeTaskId(id),
title: title.trim(),
description: undefined,
file: file || undefined,
status: charToStatus(status),
dependencies: [],
timeoutMs,
index: tasks.length,
phase: currentPhase ?? undefined,
});
}
}
if (inDeps) {
// Arrow notation (supports both -> and unicode \u2192)
// "01 -> 02,03,06" means 02, 03, 06 depend on 01
// "02 \u2192 08" — single arrow with unicode
// "03 \u2192 04 \u2192 05" — chained: 04 depends on 03, 05 depends on 04
// "05, 07, 08 \u2192 13" — multi-prereq: 13 depends on 05, 07, 08
// Supports optional markdown list prefix: "- 01 -> 02,03,06"
const hasArrow = /->/.test(line) || /\u2192/.test(line);
if (hasArrow) {
// Strip optional list prefix and parenthetical description
const cleaned = line
.replace(/^(\s*[-*]\s+)?/, "")
.replace(/\s*\(.*\)\s*$/, "");
// Split on arrows to get segments
const segments = cleaned
.split(/->|\u2192/)
.map((s) => s.trim())
.filter(Boolean);
if (segments.length >= 2) {
for (let i = 0; i < segments.length - 1; i++) {
// Left segment: source(s) (comma-separated)
const fromIds = segments[i]
.split(",")
.map((t) => t.trim())
.filter((t) => /^\d+[a-z]?$/.test(t))
.map((t) => normalizeTaskId(t));
// Right segment: target(s) (comma-separated)
const toIds = segments[i + 1]
.split(",")
.map((t) => t.trim())
.filter((t) => /^\d+[a-z]?$/.test(t))
.map((t) => normalizeTaskId(t));
for (const toId of toIds) {
if (!dependencies[toId]) dependencies[toId] = [];
for (const fromId of fromIds) {
if (!dependencies[toId].includes(fromId)) {
dependencies[toId].push(fromId);
}
}
}
}
}
}
// Format 1: Natural language "X depends on A, B, C"
// Supports optional markdown list prefix: "- 13 depends on 17, 18, 19"
// Also handles "also depends on": "- 08 also depends on 05, 06"
// The dep list char class includes lowercase letters so lettered IDs
// (e.g. "02b") don't truncate the capture. Per-id validation is
// done by the filter below, so trailing prose can't leak in.
const dependsMatch = line.match(
/^(?:\s*[-*]\s+)?(\d+[a-z]?)\s+(?:also\s+)?depends\s+on\s+([\d,\s a-z]+)/i,
);
if (dependsMatch) {
const [, taskId, depsList] = dependsMatch;
const taskIdPadded = normalizeTaskId(taskId);
const depIds = depsList
.split(",")
.map((t) => t.trim())
.filter((t) => /^\d+[a-z]?$/.test(t))
.map((t) => normalizeTaskId(t));
if (!dependencies[taskIdPadded]) dependencies[taskIdPadded] = [];
for (const depId of depIds) {
if (!dependencies[taskIdPadded].includes(depId)) {
dependencies[taskIdPadded].push(depId);
}
}
}
// Parse meta blocks for task configuration (timeout, etc.)
const metaMatch = line.match(
/^0?(\d+[a-z]?)\s+\[timeout\]\s*=?\s*(\d+)(?:m|min|s|ms)?/i,
);
if (metaMatch) {
const [, taskId, value, unit] = metaMatch;
const task = tasks.find((t) => t.id === normalizeTaskId(taskId));
if (task) {
task.timeoutMs = parseTimeoutValue(Number(value), unit);
}
}
// Format 2: "X, Y, Z can be done in parallel (label)"
// "- 01, 02, 03, 04 can be done in parallel (Play Store prep)"
const parallelMatch = line.match(
/^(?:\s*[-*]\s+)?((?:0?\d+[a-z]?\s*,\s*)*0?\d+[a-z]?)\s+can\s+be\s+done\s+in\s+parallel(?:\s+\(([^)]+)\))?$/i,
);
if (parallelMatch) {
const [, idsStr, label] = parallelMatch;
const taskIds = idsStr
.split(",")
.map((t) => t.trim())
.filter((t) => /^\d+[a-z]?$/.test(t))
.map((t) => normalizeTaskId(t));
if (taskIds.length > 0) {
parallelGroups.push({
index: parallelGroups.length,
label: label ? label.trim() : undefined,
taskIds,
});
}
}
// Format 3: "A must be done before B, C" or "A, B must be done before C"
// "- 21 must be done before 22, 23, 24 (backend integration foundation)"
// "- 02, 03 must be done before 04"
const mustBeforeMatch = line.match(
/^(?:\s*[-*]\s+)?((?:0?\d+[a-z]?\s*,\s*)*0?\d+[a-z]?)\s+must\s+be\s+done\s+before\s+((?:0?\d+[a-z]?\s*,\s*)*0?\d+[a-z]?)(?:\s+\(([^)]+)\))?$/i,
);
if (mustBeforeMatch) {
const [, fromIdsStr, toIdsStr] = mustBeforeMatch;
const fromIds = fromIdsStr
.split(",")
.map((t) => t.trim())
.filter((t) => /^\d+[a-z]?$/.test(t))
.map((t) => normalizeTaskId(t));
const toIds = toIdsStr
.split(",")
.map((t) => t.trim())
.filter((t) => /^\d+[a-z]?$/.test(t))
.map((t) => normalizeTaskId(t));
// Each "to" task depends on ALL "from" tasks
for (const toId of toIds) {
if (!dependencies[toId]) dependencies[toId] = [];
for (const fromId of fromIds) {
if (!dependencies[toId].includes(fromId)) {
dependencies[toId].push(fromId);
}
}
}
}
// Format 4: "X, Y, Z depend on A" or "X depends on A, B, C"
// "- 22, 23, 24 depend on 21"
// "- 05, 06 depend on 02, 03, 04"
// "- 08 also depends on 05, 06" ("also" is ignored)
// Strip optional "also" before matching
const cleanedLine = line.replace(/\balso\b/i, "");
const dependOnMatch = cleanedLine.match(
/^(?:\s*[-*]\s+)?((?:0?\d+[a-z]?\s*,\s*)*0?\d+[a-z]?)\s+depend(?:s)?\s+on\s+((?:0?\d+[a-z]?\s*,\s*)*0?\d+[a-z]?)(?:\s+\(([^)]+)\))?$/i,
);
if (dependOnMatch) {
const [, fromIdsStr, toIdsStr] = dependOnMatch;
const fromIds = fromIdsStr
.split(",")
.map((t) => t.trim())
.filter((t) => /^\d+[a-z]?$/.test(t))
.map((t) => normalizeTaskId(t));
const toIds = toIdsStr
.split(",")
.map((t) => t.trim())
.filter((t) => /^\d+[a-z]?$/.test(t))
.map((t) => normalizeTaskId(t));
// Each "from" task depends on ALL "to" tasks
for (const fromId of fromIds) {
if (!dependencies[fromId]) dependencies[fromId] = [];
for (const toId of toIds) {
if (!dependencies[fromId].includes(toId)) {
dependencies[fromId].push(toId);
}
}
}
}
}
}
// Save final phase if we were in one
if (currentPhase !== null) {
const phaseTaskIds = tasks
.filter((t) => t.phase === currentPhase)
.map((t) => t.id);
if (phaseTaskIds.length > 0) {
phases.push({
number: currentPhase,
title: currentPhaseTitle,
taskIds: phaseTaskIds,
});
}
}
// Add implicit phase-boundary dependencies
// First task of each phase (except phase 1) depends on last task of previous phase
if (phases.length > 1) {
for (let i = 1; i < phases.length; i++) {
const prevPhase = phases[i - 1];
const currPhase = phases[i];
if (prevPhase.taskIds.length === 0 || currPhase.taskIds.length === 0)
continue;
const lastTaskOfPrevPhase =
prevPhase.taskIds[prevPhase.taskIds.length - 1];
const firstTaskOfCurrPhase = currPhase.taskIds[0];
// Add dependency if not already present
if (!dependencies[firstTaskOfCurrPhase]) {
dependencies[firstTaskOfCurrPhase] = [];
}
if (!dependencies[firstTaskOfCurrPhase].includes(lastTaskOfPrevPhase)) {
dependencies[firstTaskOfCurrPhase].push(lastTaskOfPrevPhase);
}
}
}
// Extract exit criteria — detect both ## Exit Criteria and plain Exit criteria
const exitCriteria: string[] = [];
const exitCriteriaRe = /^(?:##\s+)?Exit\s+Criteria/i;
const exitIdx = lines.findIndex((l) => exitCriteriaRe.test(l));
if (exitIdx >= 0) {
for (let i = exitIdx + 1; i < lines.length; i++) {
// Stop at any new section heading (##-style or plain)
if (/^##\s/.test(lines[i]) || isPlainSectionHeader(lines[i])) break;
const m = lines[i].match(/^-\s+(.+)$/);
if (m) exitCriteria.push(m[1].trim());
}
}
// Extract objective from top-level heading
const objectiveMatch = content.match(/^#\s+(.+)$/m);
const objective = objectiveMatch ? objectiveMatch[1].trim() : undefined;
// Apply dependencies map to task.dependencies arrays
for (const task of tasks) {
if (dependencies[task.id]) {
task.dependencies = dependencies[task.id];
}
}
// Apply parallelGroup to tasks
for (const group of parallelGroups) {
for (const taskId of group.taskIds) {
const task = tasks.find((t) => t.id === taskId);
if (task) {
task.parallelGroup = group.index;
}
}
}
return {
tasks,
dependencies,
parallelGroups: parallelGroups.length > 0 ? parallelGroups : undefined,
phases: phases.length > 0 ? phases : undefined,
sourcePath,
sourceDir,
exitCriteria,
objective,
};
}
// ─── Simple Checkbox Parser ──────────────────────────────────────────────────
function parseSimpleCheckbox(
content: string,
sourcePath: string,
sourceDir: string,
): Project {
const tasks: Task[] = [];
const lines = content.split("\n");
let idx = 0;
for (const line of lines) {
const match = line.match(/^-+\s+\[(.)\]\s+(.+)$/);
if (match) {
const [, statusChar, title] = match;
const id = `${String(idx).padStart(2, "0")}`;
tasks.push({
id,
title: title.trim(),
status: charToStatus(statusChar),
dependencies: [],
});
idx++;
}
}
return { tasks, dependencies: {}, sourcePath, sourceDir };
}
// ─── YAML Parser ─────────────────────────────────────────────────────────────
function parseYaml(
content: string,
sourcePath: string,
sourceDir: string,
): Project {
const YAML = loadYaml();
const doc = YAML.parse(content);
const tasks: Task[] = [];
if (doc.tasks && Array.isArray(doc.tasks)) {
doc.tasks.forEach((t: any, idx: number) => {
tasks.push({
id: t.id || `${String(idx).padStart(2, "0")}`,
title: t.title || t.name || `Task ${idx}`,
description: t.description,
file: t.file,
status: (t.status as Task["status"]) || "pending",
dependencies: t.depends_on || t.dependencies || [],
parallelGroup: t.parallel_group,
timeoutMs: parseTimeoutFromMeta(t.timeout),
index: idx,
});
});
}
return {
tasks,
dependencies: doc.dependencies || {},
sourcePath,
sourceDir,
exitCriteria: doc.exit_criteria || doc.exitCriteria,
objective: doc.objective,
};
}
// ─── Task Spec Reader ────────────────────────────────────────────────────────
/**
* Read the detailed task specification from a task file
*/
export function readTaskSpec(taskDir: string, taskFile: string): string {
const fullPath = path.resolve(taskDir, taskFile);
if (!fs.existsSync(fullPath)) return "";
return fs.readFileSync(fullPath, "utf-8");
}
// ─── Task File Updater ───────────────────────────────────────────────────────
/**
* Update task status in the source file (markdown or YAML).
*
* Handles three formats:
* 1. Fio numbered format: `- [ ] 01 Title` — matches by task number in the file
* 2. Simple checkbox: `- [ ] Title` — matches by checkbox position (index)
* 3. YAML: uses `yaml` library to parse, update, and stringify
*/
export function updateTaskInFile(
filePath: string,
taskId: string,
status: Task["status"],
): void {
const ext = path.extname(filePath).toLowerCase();
// Handle YAML format
if (ext === ".yaml" || ext === ".yml") {
updateTaskInYaml(filePath, taskId, status);
return;
}
let content = fs.readFileSync(filePath, "utf-8");
const char = statusToChar(status);
// Strategy 1: Fio numbered format — match by explicit task ID in the file.
// For pure-digit IDs, also try the parsed numeric form (parity with the
// pre-lettered behavior). Lettered IDs ("02b", "02c") only have one valid
// form — the parseInt fallback would silently drop the letter suffix and
// create false-positive partial matches, so we skip it for them.
const idPatterns = new Set([escapeRegex(taskId)]);
if (!taskId.startsWith("0") && /^\d+$/.test(taskId)) {
const rawId = parseInt(taskId, 10).toString();
idPatterns.add(escapeRegex(rawId));
}
for (const idPattern of idPatterns) {
const fioRegex = new RegExp(
`(^-\\s+\\[)(.)(\\]\\s+${idPattern}\\s*[—–:-])`,
"m",
);
const match = content.match(fioRegex);
if (match) {
content = content.replace(fioRegex, `$1${char}$3`);
fs.writeFileSync(filePath, content, "utf-8");
return;
}
}
// Strategy 2: Simple checkbox by position (task IDs are zero-padded indices)
const targetIndex = parseInt(taskId, 10);
if (!isNaN(targetIndex)) {
const lines = content.split("\n");
let checkboxIdx = 0;
for (let i = 0; i < lines.length; i++) {
const m = lines[i].match(/^(\s*-+\s+\[)(.)(\].*)$/);
if (m) {
if (checkboxIdx === targetIndex) {
lines[i] = m[1] + char + m[3];
fs.writeFileSync(filePath, lines.join("\n"), "utf-8");
return;
}
checkboxIdx++;
}
}
}
}
/**
* Update task status in a YAML task file using the yaml library's
* Document API, which preserves comments and formatting.
*
* Matches by explicit `id` field first, then falls back to
* position-based matching (for files without explicit IDs).
*/
function updateTaskInYaml(
filePath: string,
taskId: string,
status: Task["status"],
): void {
const YAML = loadYaml();
const content = fs.readFileSync(filePath, "utf-8");
const doc = YAML.parseDocument(content);
const tasks = doc.get("tasks");
if (!tasks || !YAML.isSeq(tasks)) return;
// Build alternate ID forms for matching. For lettered IDs ("02b"), the
// verbatim form is the only valid pattern — parseInt would drop the suffix.
const idVariants: string[] = [taskId];
if (/^\d+$/.test(taskId)) {
idVariants.push(parseInt(taskId, 10).toString());
}
// Strategy 1: Match by explicit id field
for (const item of tasks.items) {
if (!YAML.isMap(item)) continue;
const idVal = item.get("id");
if (idVal === undefined || idVal === null) continue;
const idStr = String(idVal);
if (idVariants.includes(idStr)) {
item.set("status", status);
fs.writeFileSync(filePath, String(doc), "utf-8");
return;
}
}
// Strategy 2: Fall back to position-based matching
// (for YAML files without explicit id fields)
const targetIndex = parseInt(taskId, 10);
if (!isNaN(targetIndex) && targetIndex < tasks.items.length) {
const item = tasks.items[targetIndex];
if (YAML.isMap(item)) {
item.set("status", status);
fs.writeFileSync(filePath, String(doc), "utf-8");
}
}
}
// ─── Auto-Detect Dependencies ────────────────────────────────────────────────
/**
* Auto-detect dependencies by analyzing task file references
*/
export function autoDetectDependencies(project: Project): Project {
const tasks = project.tasks.map((t) => ({
...t,
dependencies: [...t.dependencies],
}));
const taskFiles = new Map(
tasks
.filter((t) => t.file)
.map((t) => [path.resolve(project.sourceDir, t.file!), t]),
);
for (const [filePath, task] of taskFiles) {
if (!fs.existsSync(filePath)) continue;
const content = fs.readFileSync(filePath, "utf-8");
// Check if this task's file references another task's file
for (const [file, refTask] of taskFiles) {
if (refTask.id === task.id) continue;
if (content.includes(file) || content.includes(refTask.title)) {
if (!task.dependencies.includes(refTask.id)) {
task.dependencies.push(refTask.id);
}
}
}
}
const dependencies: Record<string, string[]> = {};
for (const task of tasks) {
if (task.dependencies.length > 0) {
dependencies[task.id] = task.dependencies;
}
}
return { ...project, tasks, dependencies };
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
// ─── Timeout Parsing ────────────────────────────────────────────────────────
/**
* Parse timeout from a task line (e.g., "timeout: 15m" or "# timeout=30s")
*/
function parseTimeoutFromLine(line: string): number | undefined {
// Match patterns like "timeout: 15m", "# timeout=30s", "timeout: 5min"
const match = line.match(/(?:timeout|timelimit)[\s:=]+(\d+)(?:m|min|s|ms)?/i);
if (match) {
return parseTimeoutValue(Number(match[1]), match[2]);
}
return undefined;
}
/**
* Parse a timeout value with unit suffix
*/
function parseTimeoutValue(value: number, unit?: string): number {
const u = (unit || "m").toLowerCase();
switch (u) {
case "ms":
return value;
case "s":
return value * 1000;
case "m":
case "min":
return value * 60 * 1000;
default:
return value * 60 * 1000; // default to minutes
}
}
/**
* Parse timeout from YAML meta field (string or number)
* Supports: "15m", "30s", "5min", 15 (minutes), 900000 (ms)
*/
function parseTimeoutFromMeta(
timeout: string | number | undefined,
): number | undefined {
if (timeout === undefined) return undefined;
if (typeof timeout === "number") {
// Assume minutes if < 1000, milliseconds if >= 1000
return timeout < 1000 ? timeout * 60 * 1000 : timeout;
}
const match = timeout.match(/^(\d+)(ms|s|m|min)?$/i);
if (match) {
return parseTimeoutValue(Number(match[1]), match[2]);
}
return undefined;
}
/**
* Normalize a task ID: zero-pad the digit portion to 2 chars, preserve any
* single lowercase letter suffix. Idempotent on already-normalized IDs.
*
* "1" → "01"
* "2" → "02"
* "2b" → "02b"
* "02b" → "02b"
* "10" → "10"
* "10b" → "10b"
*
* Pass-through for IDs that don't match the expected shape (defensive — the
* upstream regexes restrict matches, but a stray value should not be silently
* re-shaped).
*/
function normalizeTaskId(id: string): string {
const match = id.match(/^(\d+)([a-z])?$/);
if (!match) return id;
const [, digits, letter] = match;
return digits.padStart(2, "0") + (letter ?? "");
}
function charToStatus(char: string): Task["status"] {
switch (char) {
case " ":
return "pending";
case "~":
return "in_progress";
case "x":
return "completed";
case "!":
return "failed";
case "-":
return "skipped";
default:
return "pending";
}
}
function statusToChar(status: Task["status"]): string {
switch (status) {
case "pending":
return " ";
case "in_progress":
return "~";
case "completed":
return "x";
case "failed":
return "!";
case "skipped":
return "-";
}
}
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

327
src/progress.ts Normal file
View File

@@ -0,0 +1,327 @@
import * as fs from "node:fs";
import * as path from "node:path";
import type {
ProgressState,
PRDProgress,
Task,
Reflection,
ToolUsage,
ReviewResult,
} from "./types";
import { ensureDir } from "./utils";
/**
* Derive a stable PRD key from a source path relative to the project dir.
* e.g., "tasks/feature-x/README.md" → "tasks-feature-x-README"
*/
export function derivePRDKey(projectDir: string, sourcePath: string): string {
const rel = path.relative(projectDir, sourcePath);
return rel
.replace(/[^a-zA-Z0-9_-]/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
}
/**
* Manages persistent progress state for a ralph execution.
* State is stored as JSON in .ralpi/progress.json.
* Supports multiple PRDs in progress simultaneously via the `prds` field.
* Falls back to legacy flat format for backward compatibility.
*/
export class ProgressTracker {
private statePath: string;
private state: ProgressState;
private prdKey: string;
constructor(projectDir: string, sourcePath: string, prdKey?: string) {
const stateDir = path.join(projectDir, ".ralpi");
ensureDir(stateDir);
this.statePath = path.join(stateDir, "progress.json");
this.prdKey = prdKey ?? derivePRDKey(projectDir, sourcePath);
this.state = this.loadOrCreate(sourcePath);
}
/** Load existing state or create a fresh one */
private loadOrCreate(sourcePathHint: string): ProgressState {
if (fs.existsSync(this.statePath)) {
try {
const raw = fs.readFileSync(this.statePath, "utf-8");
const parsed = JSON.parse(raw) as ProgressState;
// Multi-PRD mode: check if we have a PRD entry
if (parsed.prds?.[this.prdKey]) {
// Found PRD entry — use it, but keep legacy fields for compat
return parsed;
}
// Legacy flat mode: check if the source path matches
if (path.resolve(parsed.sourcePath) === path.resolve(sourcePathHint)) {
// Migrate legacy state to PRD mode
parsed.prds = {
[this.prdKey]: {
sourcePath: parsed.sourcePath,
tasks: parsed.tasks,
startedAt: parsed.startedAt,
lastUpdatedAt: parsed.lastUpdatedAt,
paused: parsed.paused,
},
};
return parsed;
}
// Different PRD — create new entry alongside existing ones
if (parsed.prds) {
parsed.prds[this.prdKey] = this.freshPRD(sourcePathHint);
return parsed;
}
// Legacy flat state exists but for a different source — promote it to PRD mode
const legacyKey = derivePRDKey(
path.dirname(this.statePath),
parsed.sourcePath,
);
parsed.prds = {
[legacyKey]: {
sourcePath: parsed.sourcePath,
tasks: parsed.tasks,
startedAt: parsed.startedAt,
lastUpdatedAt: parsed.lastUpdatedAt,
paused: parsed.paused,
},
[this.prdKey]: this.freshPRD(sourcePathHint),
};
return parsed;
} catch {
// Fall through to create new
}
}
return this.freshState(sourcePathHint);
}
private freshPRD(sourcePath: string): PRDProgress {
return {
sourcePath,
tasks: {},
startedAt: new Date().toISOString(),
lastUpdatedAt: new Date().toISOString(),
paused: false,
};
}
private freshState(sourcePath: string): ProgressState {
return {
sourcePath,
tasks: {},
startedAt: new Date().toISOString(),
lastUpdatedAt: new Date().toISOString(),
paused: false,
prds: {
[this.prdKey]: {
sourcePath,
tasks: {},
startedAt: new Date().toISOString(),
lastUpdatedAt: new Date().toISOString(),
paused: false,
},
},
};
}
/** Get the PRD-scoped progress entry */
private getPRD(): PRDProgress {
if (!this.state.prds) {
// Should not happen after loadOrCreate, but guard anyway
this.state.prds = { [this.prdKey]: this.freshPRD(this.state.sourcePath) };
}
if (!this.state.prds[this.prdKey]) {
this.state.prds[this.prdKey] = this.freshPRD(this.state.sourcePath);
}
return this.state.prds[this.prdKey];
}
/** Save current state to disk */
save(): void {
// Merge into the freshest on-disk state instead of writing the
// construction-time snapshot verbatim. Each ProgressTracker instance
// (one per PRD loop) snapshots the WHOLE state at construction; when
// two loops run concurrently in one project, saving a stale snapshot
// would silently revert the OTHER loop's task status changes — tasks
// get wrongly written back to "pending" while their worktrees carry
// real work, stranding it on the next resume.
let disk: ProgressState | null = null;
try {
if (fs.existsSync(this.statePath)) {
const raw = fs.readFileSync(this.statePath, "utf-8");
disk = JSON.parse(raw) as ProgressState;
}
} catch {
disk = null;
}
if (disk && disk.prds) {
// Keep THIS tracker's in-memory PRD (its own tasks are the source
// of truth — all status mutations happened on it), but adopt the
// on-disk entries for OTHER PRDs instead of writing the stale
// construction-time snapshot over them.
const mine = this.getPRD();
this.state = disk;
this.state.prds ??= {};
this.state.prds[this.prdKey] = mine;
}
const prd = this.getPRD();
prd.lastUpdatedAt = new Date().toISOString();
// Sync legacy flat fields with current PRD for backward compat
this.state.sourcePath = prd.sourcePath;
this.state.tasks = prd.tasks;
this.state.startedAt = prd.startedAt;
this.state.lastUpdatedAt = prd.lastUpdatedAt;
this.state.paused = prd.paused;
fs.writeFileSync(
this.statePath,
JSON.stringify(this.state, null, 2),
"utf-8",
);
}
/** Mark a task as in progress */
markInProgress(taskId: string): void {
const prd = this.getPRD();
this.ensureTask(prd, taskId);
prd.tasks[taskId].status = "in_progress";
prd.tasks[taskId].startedAt = new Date().toISOString();
this.save();
}
/** Mark a task as completed */
markCompleted(
taskId: string,
durationMs: number,
reflection?: Reflection,
toolUsage?: ToolUsage,
outputPreview?: string,
commitMessages?: string[],
commitSummary?: string,
review?: ReviewResult,
reviewRetries?: number,
): void {
const prd = this.getPRD();
this.ensureTask(prd, taskId);
prd.tasks[taskId].status = "completed";
prd.tasks[taskId].completedAt = new Date().toISOString();
prd.tasks[taskId].durationMs = durationMs;
if (reflection) prd.tasks[taskId].reflection = reflection;
if (toolUsage) prd.tasks[taskId].toolUsage = toolUsage;
if (outputPreview) prd.tasks[taskId].outputPreview = outputPreview;
if (commitMessages) prd.tasks[taskId].commitMessages = commitMessages;
if (commitSummary) prd.tasks[taskId].commitSummary = commitSummary;
if (review) prd.tasks[taskId].review = review;
if (reviewRetries !== undefined)
prd.tasks[taskId].reviewRetries = reviewRetries;
this.save();
}
/** Mark a task as failed */
markFailed(taskId: string, error: string): void {
const prd = this.getPRD();
this.ensureTask(prd, taskId);
prd.tasks[taskId].status = "failed";
prd.tasks[taskId].error = error;
this.save();
}
/** Get task status */
getTaskStatus(taskId: string): Task["status"] {
const prd = this.getPRD();
return prd.tasks[taskId]?.status ?? "pending";
}
/** Get IDs of all completed tasks */
getCompletedTaskIds(): string[] {
const prd = this.getPRD();
return Object.entries(prd.tasks)
.filter(([, info]) => info.status === "completed")
.map(([id]) => id);
}
/** Get IDs of all failed tasks */
getFailedTaskIds(): string[] {
const prd = this.getPRD();
return Object.entries(prd.tasks)
.filter(([, info]) => info.status === "failed")
.map(([id]) => id);
}
/** Get all reflections from completed tasks */
getAllReflections(): Reflection[] {
const prd = this.getPRD();
const reflections: Reflection[] = [];
for (const info of Object.values(prd.tasks)) {
if (info.reflection) reflections.push(info.reflection);
}
return reflections;
}
/** Get reflections for specific dependency tasks */
getDependencyReflections(depIds: string[]): Reflection[] {
const prd = this.getPRD();
return depIds
.map((id) => prd.tasks[id]?.reflection)
.filter((r): r is Reflection => r !== undefined);
}
/** Set paused state */
setPaused(paused: boolean): void {
const prd = this.getPRD();
prd.paused = paused;
this.save();
}
/** Reset all `in_progress` tasks back to `pending`.
*
* Used after a session reload: in-process agent sessions die with the
* parent session, so any task left `in_progress` is actually stalled.
* Resetting ensures the DAG re-schedules it on the next resume. Returns
* the IDs that were reset. */
resetInProgressToPending(): string[] {
const prd = this.getPRD();
const reset: string[] = [];
for (const [id, info] of Object.entries(prd.tasks)) {
if (info.status === "in_progress") {
info.status = "pending";
delete info.startedAt;
reset.push(id);
}
}
if (reset.length > 0) this.save();
return reset;
}
/** Get the raw PRD state (for status display) */
getState(): PRDProgress {
return this.getPRD();
}
/** Get all PRDs (for multi-PRD status display) */
getAllPRDs(): Record<string, PRDProgress> {
return this.state.prds ?? {};
}
/** Get the PRD key for this tracker */
getKey(): string {
return this.prdKey;
}
/** Reset all progress for this PRD */
reset(): void {
const prd = this.getPRD();
Object.assign(prd, this.freshPRD(prd.sourcePath));
this.save();
}
private ensureTask(prd: PRDProgress, taskId: string): void {
if (!prd.tasks[taskId]) {
prd.tasks[taskId] = { status: "pending" };
}
}
}

642
src/prompts.ts Normal file
View File

@@ -0,0 +1,642 @@
import type { Task, Project, Reflection, ReviewResult } from "./types";
import { readTaskSpec } from "./parser";
import {
parseDiff,
filterNoise,
type DiffSummary,
type DiffOptions,
} from "./diff";
/** Maximum bytes of an inlined review diff before we stop inlining it and
* instead list the changed files + tell the model to `read` them.
* Diffs larger than this are never byte-truncated into a review prompt —
* truncation loses the middle of a large diff, so the file-list + read
* instruction is strictly better.
*
* ~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;
/** Max included files before an oversized diff is replaced by a read
* instruction rather than inlined. */
const MAX_REVIEW_FILES = 20;
/** Optional knobs for the review prompt builders. */
export interface ReviewPromptOptions {
/** Extra context injected into the prompt (config.prompts.projectContext). */
projectContext?: string;
/** Per-review custom focus/instructions (config.prompts.reviewFocus). */
focus?: string;
/** Noise-filter overrides (config.review.*). */
diffOptions?: DiffOptions;
}
// ─── Task Prompt ─────────────────────────────────────────────────────────────
/**
* Build the prompt for a single task execution.
* Injects task details, dependency reflections, and project context.
*/
export function buildTaskPrompt(
task: Task,
project: Project,
depReflections: Reflection[],
projectContext?: string,
/** Review feedback from a rejected review — injected when re-executing
* a task in review-gated mode so the agent knows what to fix. */
reviewFeedback?: ReviewResult,
): string {
const parts: string[] = [];
// ── Header ──
parts.push(`# Task ${task.id}: ${task.title}`);
parts.push("");
// ── Project Objective ──
if (project.objective) {
parts.push("## Project Objective");
parts.push(project.objective);
parts.push("");
}
// ── Exit Criteria ──
if (project.exitCriteria && project.exitCriteria.length > 0) {
parts.push("## Exit Criteria");
for (const criterion of project.exitCriteria) {
parts.push(`- ${criterion}`);
}
parts.push("");
}
// ── Task Description ──
if (task.description) {
parts.push("## Description");
parts.push(task.description);
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("");
}
}
// ── Dependencies ──
if (task.dependencies && task.dependencies.length > 0) {
parts.push("## Dependencies");
parts.push(`This task depends on: ${task.dependencies.join(", ")}`);
parts.push("");
}
// ── Dependency Reflections ──
if (depReflections.length > 0) {
parts.push("## Completed Dependency Reflections");
parts.push(
"The following tasks have been completed. Use their reflections for context:",
);
parts.push("");
for (const ref of depReflections) {
parts.push(`### Task ${ref.taskId}: ${ref.title}`);
parts.push(`**Summary:** ${ref.summary}`);
if (ref.keyLearnings && ref.keyLearnings.length > 0) {
parts.push("**Key Learnings:**");
for (const learning of ref.keyLearnings) {
parts.push(`- ${learning}`);
}
}
if (ref.filesChanged && ref.filesChanged.length > 0) {
parts.push(`**Files Changed:** ${ref.filesChanged.join(", ")}`);
}
if (ref.blockers && ref.blockers.length > 0) {
parts.push(`**Known Issues:** ${ref.blockers.join("; ")}`);
}
parts.push("");
}
}
// ── Project Context ──
if (projectContext) {
parts.push("## Additional Context");
parts.push(projectContext);
parts.push("");
}
// ── Previous Review Feedback (re-execution only) ──
if (reviewFeedback) {
parts.push("## Previous Review Feedback — FIX REQUIRED");
parts.push(
"A review agent examined your previous attempt and rejected it.",
);
parts.push(`Verdict: **${reviewFeedback.verdict.toUpperCase()}**`);
parts.push(`Summary: ${reviewFeedback.summary}`);
parts.push("");
if (reviewFeedback.findings.length > 0) {
parts.push("You MUST address these findings:");
for (const finding of reviewFeedback.findings) {
const loc = finding.file
? finding.line
? ` (${finding.file}:${finding.line})`
: ` (${finding.file})`
: "";
parts.push(`- [${finding.severity}]${loc} ${finding.message}`);
}
parts.push("");
}
parts.push("Fix every issue above. Do not re-introduce the same problems.");
parts.push("");
}
// ── Reflection Instructions ──
parts.push("## REFLECTION (REQUIRED)");
parts.push(
"When the task is COMPLETE, end your response with a reflection section.",
);
parts.push("Use EXACTLY this format at the END of your response:");
parts.push("");
parts.push("```");
parts.push("## REFLECTION");
parts.push("SUMMARY: [1-2 sentence description of what was accomplished]");
parts.push("FILES: [comma-separated list of files created or modified]");
parts.push("LEARNINGS:");
parts.push("- [key decision, pattern, or architectural choice]");
parts.push("- [important API or interface details]");
parts.push("- [anything downstream tasks need to know]");
parts.push("BLOCKERS: [any unresolved issues, or 'none']");
parts.push("```");
parts.push("");
parts.push(
"Also use the `memory` tool to save important learnings that will",
);
parts.push(
"be useful across future sessions (architecture decisions, API patterns, etc.)",
);
return parts.join("\n");
}
// ─── 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,
opts: ReviewPromptOptions = {},
): 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("");
// ── Changed-Files Summary + Exclusions (noise-filtered scope) ──
const summary = parseDiff(commitDiff, opts.diffOptions);
const filtered = filterNoise(commitDiff, opts.diffOptions);
parts.push(buildFileSummaryTable(summary));
const excluded = renderExcludedFiles(summary);
if (excluded) parts.push(excluded);
parts.push("");
// ── Diff (inline, or file-list + read instruction when oversized) ──
parts.push(renderDiffSection(summary, filtered, "### Diff"));
parts.push("");
// ── Custom Review Focus ──
if (opts.focus) {
parts.push("## Custom Review Focus");
parts.push(opts.focus);
parts.push("");
}
// ── Project Context ──
if (opts.projectContext) {
parts.push("## Additional Context");
parts.push(opts.projectContext);
parts.push("");
}
// ── Review Instructions ──
parts.push("## Review Instructions");
parts.push(
"Review the 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");
}
// ─── 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,
opts: ReviewPromptOptions = {},
): 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("");
// ── Changed-Files Summary + Exclusions (noise-filtered scope) ──
const summary = parseDiff(diff, opts.diffOptions);
const filtered = filterNoise(diff, opts.diffOptions);
parts.push(buildFileSummaryTable(summary));
const excluded = renderExcludedFiles(summary);
if (excluded) parts.push(excluded);
parts.push("");
// ── Diff (inline, or file-list + read instruction when oversized) ──
parts.push(
renderDiffSection(summary, filtered, "### Current Tracked Diff (git diff)"),
);
parts.push("");
// ── Custom Review Focus ──
if (opts.focus) {
parts.push("## Custom Review Focus");
parts.push(opts.focus);
parts.push("");
}
// ── Project Context ──
if (opts.projectContext) {
parts.push("## Additional Context");
parts.push(opts.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 ───────────────────────────────────────────
/** Whether an oversized/wide diff should be replaced by a file-list + read
* instruction instead of being inlined. Thresholds: cleaned diff over
* MAX_DIFF_BYTES, or more than MAX_REVIEW_FILES included files. */
function shouldSkipInline(summary: DiffSummary, filteredLength: number): boolean {
return (
filteredLength > MAX_DIFF_BYTES || summary.files.length > MAX_REVIEW_FILES
);
}
/**
* Render a per-file +/ summary Markdown table (with type column and a total
* line) from a parsed diff. Handles the empty/all-noise diff gracefully — an
* empty table with zero totals, no crash.
*/
function buildFileSummaryTable(summary: DiffSummary): string {
const lines: string[] = [];
lines.push("### Changed Files");
lines.push("");
lines.push("| File | +/ | Type |");
lines.push("|------|-----|------|");
if (summary.files.length === 0) {
lines.push("| _(no included changes)_ | — | — |");
} else {
for (const f of summary.files) {
lines.push(
`| \`${f.path}\` | +${f.linesAdded}/-${f.linesRemoved} | ${f.ext || "—"} |`,
);
}
}
lines.push(`| **Total** | **+${summary.totalAdded}/-${summary.totalRemoved}** | |`);
return lines.join("\n");
}
/**
* Render the `### Excluded Files (n)` bullet list (path, +/ counts, reason).
* Returns an empty string when there are no exclusions so callers omit the
* section entirely (no empty heading).
*/
function renderExcludedFiles(summary: DiffSummary): string {
if (summary.excluded.length === 0) return "";
const lines: string[] = [];
lines.push(`### Excluded Files (${summary.excluded.length})`);
lines.push("");
for (const f of summary.excluded) {
lines.push(
`- \`${f.path}\` (+${f.linesAdded}/-${f.linesRemoved}) — ${f.reason}`,
);
}
return lines.join("\n");
}
/**
* Render the diff section of a review prompt. Under the threshold, inline the
* noise-filtered diff. Over the threshold (size or file count), emit a
* file-list + read-instruction notice and never byte-truncate the diff.
*/
function renderDiffSection(
summary: DiffSummary,
filtered: string,
heading: string,
): string {
if (shouldSkipInline(summary, filtered.length)) {
return `${heading} — _Diff too large (${filtered.length.toLocaleString()} chars, ${summary.files.length} files). Use \`read\` to inspect the changed files._`;
}
const lines: string[] = [];
lines.push(heading);
lines.push("```diff");
lines.push(filtered || "(no included changes)");
lines.push("```");
return lines.join("\n");
}
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; `critical` is accepted as a blocker synonym)",
"- [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`. The `critical` token is accepted",
"and treated as `blocker`.",
];
}
/**
* Build the prompt for a dry-run / plan display
*/
export function buildPlanPrompt(project: Project): string {
const lines: string[] = [];
lines.push("# Project Plan");
lines.push("");
if (project.objective) {
lines.push("## Objective");
lines.push(project.objective);
lines.push("");
}
lines.push("## Tasks");
for (const task of project.tasks) {
const deps =
task.dependencies.length > 0
? ` (depends on: ${task.dependencies.join(", ")})`
: "";
lines.push(`- [ ] ${task.id}: ${task.title}${deps}`);
}
lines.push("");
if (project.exitCriteria && project.exitCriteria.length > 0) {
lines.push("## Exit Criteria");
for (const criterion of project.exitCriteria) {
lines.push(`- ${criterion}`);
}
lines.push("");
}
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");
}

128
src/reflection.ts Normal file
View File

@@ -0,0 +1,128 @@
import * as fs from "node:fs";
import * as path from "node:path";
import type { Reflection } from "./types";
import { REFLECTION_PATTERN } from "./constants";
import { ensureDir, writeFileSafe } from "./utils";
// ─── Extract Reflection ──────────────────────────────────────────────────────
/**
* Extract a reflection block from pi's output text
*/
export function extractReflection(
output: string,
taskId: string,
title: string,
): Reflection | null {
const match = output.match(REFLECTION_PATTERN);
if (!match) return null;
const block = match[1];
const summary = extractField(block, "SUMMARY");
const files = extractField(block, "FILES");
const learnings = extractList(block, "LEARNINGS");
const blockersRaw = extractField(block, "BLOCKERS");
const blockers =
blockersRaw && blockersRaw.toLowerCase() !== "none"
? blockersRaw.split(",").map(b => b.trim()).filter(Boolean)
: undefined;
return {
taskId,
title,
summary: summary || "Task completed",
keyLearnings: learnings || [],
filesChanged: files
? files.split(",").map(f => f.trim()).filter(Boolean)
: [],
blockers,
timestamp: new Date().toISOString(),
};
}
function extractField(block: string, field: string): string | null {
const regex = new RegExp(`${field}:\\s*(.+?)$`, "im");
const match = block.match(regex);
return match ? match[1].trim() : null;
}
function extractList(block: string, field: string): string[] | null {
const regex = new RegExp(`${field}:\\s*\\n((?:- .+\\n?)+)`, "im");
const match = block.match(regex);
if (!match) return null;
return match[1]
.split("\n")
.map(l => l.replace(/^-\\s*/, "").trim())
.filter(Boolean);
}
// ─── Save / Load Reflections ────────────────────────────────────────────────
/**
* Save a reflection to a file
*/
export function saveReflection(
reflectionsDir: string,
reflection: Reflection,
): void {
ensureDir(reflectionsDir);
const filePath = path.join(
reflectionsDir,
`${reflection.taskId}.json`,
);
writeFileSafe(filePath, JSON.stringify(reflection, null, 2));
}
/**
* Load a reflection from a file
*/
export function loadReflection(
reflectionsDir: string,
taskId: string,
): Reflection | null {
const filePath = path.join(reflectionsDir, `${taskId}.json`);
if (!fs.existsSync(filePath)) return null;
try {
return JSON.parse(fs.readFileSync(filePath, "utf-8")) as Reflection;
} catch {
return null;
}
}
// ─── Format Reflections ──────────────────────────────────────────────────────
/**
* Format reflections for display
*/
export function formatReflections(reflections: Reflection[]): string {
if (reflections.length === 0) return "No reflections yet.";
const lines: string[] = [];
lines.push("## Task Reflections");
lines.push("");
for (const ref of reflections) {
lines.push(`### ${ref.taskId}: ${ref.title}`);
lines.push(`Summary: ${ref.summary}`);
if (ref.keyLearnings.length > 0) {
lines.push("Learnings:");
for (const l of ref.keyLearnings) {
lines.push(` - ${l}`);
}
}
if (ref.filesChanged.length > 0) {
lines.push(`Files: ${ref.filesChanged.join(", ")}`);
}
if (ref.blockers && ref.blockers.length > 0) {
lines.push(`Blockers: ${ref.blockers.join("; ")}`);
}
lines.push("");
}
return lines.join("\n");
}

214
src/review.ts Normal file
View File

@@ -0,0 +1,214 @@
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[] = [];
// `critical` is accepted and normalized to ralpi's `blocker` severity,
// providing parity with @piex-dev/review's critical/warning/info grading.
const severityRe = /^\[(blocker|critical|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";
else if (sev === "critical") sev = "blocker";
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");
}

112
src/task-manager-prompt.ts Normal file
View File

@@ -0,0 +1,112 @@
import * as fs from "node:fs";
import * as path from "node:path";
const TEMPLATE_REL = path.join("prompts", "task-manager.md");
/**
* Strip leading YAML frontmatter (--- delimited) from template content.
* Local port of the helper omp does not export from the package root.
*/
function stripFrontmatter(content: string): string {
const m = /^---\r?\n[\s\S]*?\r?\n---\r?\n/.exec(content);
return m ? content.slice(m[0].length) : content;
}
/**
* Parse command arguments respecting quoted strings (bash-style).
* Ported from pi's core/prompt-templates.js so the task-manager template
* receives the same arg-splitting a real `/task-manager` invocation would.
*/
function parseCommandArgs(argsString: string): string[] {
const args: string[] = [];
let current = "";
let inQuote: string | null = null;
for (let i = 0; i < argsString.length; i++) {
const char = argsString[i];
if (inQuote) {
if (char === inQuote) {
inQuote = null;
} else {
current += char;
}
} else if (char === '"' || char === "'") {
inQuote = char;
} else if (/\s/.test(char)) {
if (current) {
args.push(current);
current = "";
}
} else {
current += char;
}
}
if (current) args.push(current);
return args;
}
/**
* Substitute argument placeholders in template content.
* Faithful port of pi's substituteArgs (core/prompt-templates.js):
* - $1, $2, ... positional args
* - $@ / $ARGUMENTS all args joined
* - ${N:-default} positional N with default when missing/empty
* - ${@:-default} all args with default when empty
* - ${@:N} / ${@:N:L} bash-style slicing
*
* Replacement runs once over the template only; argument/default values
* containing patterns like $1 or $@ are NOT recursively substituted.
*/
function substituteArgs(content: string, args: string[]): string {
const allArgs = args.join(" ");
return content.replace(
/\$\{(\d+|ARGUMENTS|@):-([^}]*)\}|\$\{@:(\d+)(?::(\d+))?\}|\$(ARGUMENTS|@|\d+)/g,
(_match, defaultTarget, defaultValue, sliceStart, sliceLength, simple) => {
if (defaultTarget) {
const value =
defaultTarget === "@" || defaultTarget === "ARGUMENTS"
? allArgs
: args[parseInt(defaultTarget, 10) - 1];
return value ? value : defaultValue;
}
if (sliceStart) {
let start = parseInt(sliceStart, 10) - 1; // 1-indexed → 0-indexed
if (start < 0) start = 0;
if (sliceLength) {
const length = parseInt(sliceLength, 10);
return args.slice(start, start + length).join(" ");
}
return args.slice(start).join(" ");
}
if (simple === "ARGUMENTS" || simple === "@") {
return allArgs;
}
const index = parseInt(simple, 10) - 1;
return args[index] ?? "";
},
);
}
/**
* Load and expand the task-manager prompt template bundled with the extension.
*
* `pi.sendUserMessage()` sends with `expandPromptTemplates: false`, so it will
* NOT expand a `/task-manager` invocation — and `@task-manager` is an
* @-mention, not a template invocation anyway. We therefore read the
* template ourselves, strip its frontmatter, substitute args ($@ etc.), and
* return the fully-expanded prompt body ready to send as a user message.
*
* @param extensionDir Absolute path to the extension root (where index.ts
* lives), used to locate `prompts/task-manager.md`.
* @param argsString Raw argument string from the slash command (may be "").
* @throws if the template file is missing or unreadable.
*/
export function loadTaskManagerPrompt(
extensionDir: string,
argsString: string,
): string {
const templatePath = path.join(extensionDir, TEMPLATE_REL);
const raw = fs.readFileSync(templatePath, "utf-8");
const body = stripFrontmatter(raw);
const args = parseCommandArgs(argsString);
return substituteArgs(body, args).trim();
}

330
src/types.ts Normal file
View File

@@ -0,0 +1,330 @@
// ─── Task Model ───────────────────────────────────────────────────────────────
export type TaskStatus =
| "pending"
| "in_progress"
| "completed"
| "failed"
| "skipped";
export type TaskStatusChar = " " | "~" | "x" | "!" | "-";
export interface Task {
/** Unique task identifier */
id: string;
/** Task title */
title: string;
/** Detailed task description */
description?: string;
/** Path to detailed spec file (relative to sourceDir) */
file?: string;
/** Current status */
status: TaskStatus;
/** Task IDs this task depends on */
dependencies: string[];
/** Explicit parallel group (optional, overrides dependency-based batching) */
parallelGroup?: number;
/** Task-level timeout in milliseconds (parsed from meta block) */
timeoutMs?: number;
/** Original index in task list for deterministic ordering */
index?: number;
/** Phase number this task belongs to (1-indexed, from ## Phase N headings) */
phase?: number;
}
export interface ParallelGroup {
/** Group index (0-based, determines execution order) */
index: number;
/** Human-readable label for the group (e.g. "Play Store prep") */
label?: string;
/** Task IDs in this group — all can run concurrently */
taskIds: string[];
}
export interface Phase {
/** Phase number (1-indexed, matches the heading number) */
number: number;
/** Phase title (e.g. "Push-to-Talk MVP") */
title: string;
/** Task IDs in this phase, in order */
taskIds: string[];
}
export interface Project {
/** Project-level objective / goal */
objective?: string;
/** All tasks in the project */
tasks: Task[];
/** Explicit dependency map: taskId → [dependency taskIds] */
dependencies: Record<string, string[]>;
/** Explicit parallel groups from "can be done in parallel" declarations */
parallelGroups?: ParallelGroup[];
/** Phased sections from ## Phase N headings (in order) */
phases?: Phase[];
/** Exit criteria (from README ## Exit Criteria section) */
exitCriteria?: string[];
/** Path to the source task file */
sourcePath: string;
/** Directory containing the source file */
sourceDir: string;
}
// ─── Execution Plan ───────────────────────────────────────────────────────────
export interface ExecutionBatch {
/** Tasks that can run concurrently in this batch */
tasks: Task[];
/** Batch number (0-indexed) */
batchIndex: number;
}
export interface ExecutionPlan {
/** Ordered batches (each batch contains parallelizable tasks) */
batches: ExecutionBatch[];
/** Total task count */
totalTasks: number;
/** Tasks skipped (already completed) */
skippedTasks: Task[];
}
// ─── Progress Model ───────────────────────────────────────────────────────────
export interface Reflection {
taskId: string;
title: string;
/** What was accomplished */
summary: string;
/** Key decisions, patterns, and learnings for downstream tasks */
keyLearnings: string[];
/** Files created or modified */
filesChanged: string[];
/** Unresolved issues or caveats */
blockers?: string[];
/** ISO timestamp */
timestamp: string;
}
// ─── Review Model ────────────────────────────────────────────────────────────
export type ReviewVerdict = "pass" | "warn" | "fail";
export interface ReviewFinding {
/** Severity of the finding */
severity: "blocker" | "warning" | "nit" | "info";
/** File path if applicable */
file?: string;
/** Line number if applicable */
line?: number;
/** Description of the issue */
message: string;
}
export interface ReviewResult {
taskId: string;
/** Overall verdict */
verdict: ReviewVerdict;
/** 1-2 sentence overall assessment */
summary: string;
/** Structured findings (empty when verdict is "pass") */
findings: ReviewFinding[];
/** Commit hash the review was performed against */
commitHash: string;
/** Full free-form review text (preserved for display) */
rawText: string;
/** ISO timestamp */
timestamp: string;
}
export interface ToolUsage {
read: number;
write: number;
edit: number;
bash: number;
other: number;
}
export interface TaskProgressInfo {
status: Task["status"];
startedAt?: string;
completedAt?: string;
durationMs?: number;
reflection?: Reflection;
/** Structured review result (when autoReview is enabled) */
review?: ReviewResult;
error?: string;
/** Tool usage counts from parsed subprocess output */
toolUsage?: ToolUsage;
/** Truncated output preview for expanded view */
outputPreview?: string;
/** Git commit messages from task execution */
commitMessages?: string[];
/** Summary derived from git commits */
commitSummary?: string;
/** Number of review-fix re-execution attempts made (review-gated mode) */
reviewRetries?: number;
}
export interface ProgressState {
/** Path to the source task file (legacy single-PRD mode) */
sourcePath: string;
/** Per-task status tracking (legacy single-PRD mode) */
tasks: Record<string, TaskProgressInfo>;
/** When execution started (legacy single-PRD mode) */
startedAt: string;
/** When execution last updated (legacy single-PRD mode) */
lastUpdatedAt: string;
/** Whether execution is currently paused/stopped (legacy single-PRD mode) */
paused: boolean;
/** Multiple PRDs tracked simultaneously (keyed by normalized source path) */
prds?: Record<string, PRDProgress>;
}
export interface PRDProgress {
/** Path to the source task file for this PRD */
sourcePath: string;
/** Per-task status tracking */
tasks: Record<string, TaskProgressInfo>;
/** When execution started */
startedAt: string;
/** When execution last updated */
lastUpdatedAt: string;
/** Whether execution is currently paused/stopped */
paused: boolean;
}
// ─── Configuration ────────────────────────────────────────────────────────────
export interface RalpiConfig {
paths: {
/** Directory for ralpi state files */
stateDir: string;
/** Directory for per-task reflections */
reflectionsDir: string;
/** Directory for per-loop review output (mirrors reflectionsDir) */
reviewsDir: string;
};
execution: {
/** Task execution timeout in milliseconds */
timeoutMs: number;
/** Maximum parallel tasks (0 = unlimited) */
maxParallel: number;
/** Round-robin model list for parallel tasks (empty = inherit parent model) */
models: string[];
/** Spawn a follow-up agent to commit changes after each task completes */
autoCommit: boolean;
/** Spawn a review agent to review the task's committed changes against
* the task description. When autoReview is on, commit is mandated:
* changes are committed (via commit session fallback when the agent
* didn't self-commit), then the COMPLETE diff (baseRef..HEAD) is
* reviewed. On 'fail' the task is re-executed with feedback (loops
* until pass or maxReviewRetries). On pass the worktree merges.
* When autoReview is off, autoCommit controls standalone commit. */
autoReview: boolean;
/** Persist the full review output to `.ralpi/reviews/<task-id>.md`.
* Only active when autoReview is true and the user opts in at loop start. */
saveReviews: boolean;
/** Keys under `execution:` explicitly present in a loaded config YAML.
* Used to skip interactive prompts for fields the user already set. */
explicitKeys?: Set<string>;
/** Model for commit sessions in <provider>/<model> format (empty = inherit task model) */
commitModel: string;
/** Model for review sessions in <provider>/<model> format (empty = inherit task model) */
reviewModel: string;
/** Model for task implementation in <provider>/<model> format (empty = inherit parent model; only used in sequential mode when models is empty) */
implModel: string;
/** Timeout for auto-commit agent sessions in milliseconds */
commitTimeoutMs: number;
/** Timeout for auto-review agent sessions in milliseconds */
reviewTimeoutMs: number;
/** Max review-fix re-execution attempts before giving up (0 = no retries;
* review runs once, reject = stop). Active whenever autoReview is
* enabled. On exhaustion the task proceeds with its committed changes
* (the worktree merges) unless reviewBlockOnFail is set. */
maxReviewRetries: number;
/** When true, a 'fail' review verdict after exhausting maxReviewRetries
* marks the task as failed instead of proceeding with its committed
* changes (the worktree does not merge). */
reviewBlockOnFail: boolean;
/** Maximum total duration for the entire loop execution in milliseconds (0 = no limit). Checked between batches — in-progress tasks finish naturally. */
loopTimeoutMs: number;
/** Max attempts on the SAME model before cycling to the next model on
* failure. Pi retries transient HTTP errors within a single prompt,
* but a sustained provider hiccup can still exhaust those in-call
* retries mid-session. Re-running the whole session a few times on
* the same model avoids flapping to a different model (and losing
* model-specific context) on the first hard failure. Applies to task
* execution, commit/review follow-up sessions, and review-fix
* re-execution alike. After this many attempts on one model, ralpi
* advances to the next model in the round-robin pool. */
maxSameModelAttempts: 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";
/** Chat rendering style for tool calls during task execution.
* - "compact": single completion message per task with an expandable
* tool-call tree (collapsed shows last 3, expanded shows all).
* - "verbose": per-event stream — each tool start/end and assistant
* turn is its own chat line (piolium/pygienium-style). */
chatStyle: "compact" | "verbose";
};
prompts: {
/** Additional context injected into every task prompt */
projectContext: string;
/** Custom prompt suffix for reflection extraction */
reflectionPrompt: string;
/** Per-review custom focus/instructions (e.g. "check security only").
* Injected as a `### Custom Review Focus` section in committed and
* uncommitted review prompts when non-empty. */
reviewFocus: string;
};
review: {
/** Extra noise-filter exclusion regexes (strings compiled to RegExp),
* merged into EXCLUDED_PATTERNS for review diffs. */
extraIgnorePatterns: string[];
/** Pathspec allowlist — files matching these stay in scope even when a
* default noise rule would exclude them. */
ignorePaths: string[];
};
/** Parent session model to inherit in child agent sessions */
model?: unknown;
/** Parent session thinking level to inherit in child agent sessions */
thinkingLevel?: unknown;
}
export const DEFAULT_CONFIG: RalpiConfig = {
paths: {
stateDir: ".ralpi",
reflectionsDir: ".ralpi/reflections",
reviewsDir: ".ralpi/reviews",
},
execution: {
timeoutMs: 0, // 0 = inherit Pi's own defaults (no ralpi-level timeout)
maxParallel: 3,
models: [],
autoCommit: true,
autoReview: false,
saveReviews: false,
commitModel: "",
reviewModel: "",
implModel: "",
commitTimeoutMs: 0, // 0 = inherit Pi's own defaults (no ralpi-level timeout)
reviewTimeoutMs: 0, // 0 = inherit Pi's own defaults (no ralpi-level timeout)
maxReviewRetries: 2, // 2 re-execution attempts on review rejection before giving up
reviewBlockOnFail: false, // false = commit anyway after retries exhausted
loopTimeoutMs: 0, // 0 = no limit
worktrees: "parallel", // worktree isolation for parallel tasks by default
maxSameModelAttempts: 5, // retry the same model up to 5 times before cycling to the next
chatStyle: "compact", // compact = completion message with tool-call tree; verbose = per-event stream
},
prompts: {
projectContext: "",
reflectionPrompt: "",
reviewFocus: "",
},
review: {
extraIgnorePatterns: [],
ignorePaths: [],
},
};

1045
src/utils.ts Normal file

File diff suppressed because it is too large Load Diff

92
src/widget-batcher.ts Normal file
View File

@@ -0,0 +1,92 @@
import type { ExtensionContext } from "@oh-my-pi/pi-coding-agent";
/**
* Batches widget updates from multiple parallel tasks into a single
* render cycle, preventing TUI thrashing when agents update independently.
*
* Uses microtask debouncing: updates within the same event-loop tick
* are coalesced into one flush. No artificial interval — updates hit the
* screen as soon as the current tick yields, but never duplicatively.
*/
export class WidgetBatcher {
/** Pending widget updates keyed by widget key. */
private pending: Map<string, string[]> = new Map();
/** Widget keys scheduled for removal. */
private pendingRemovals: Set<string> = new Set();
/** Whether a microtask flush is already queued. */
private scheduled = false;
/** Whether a flush is currently executing (prevents re-entry). */
private flushing = false;
constructor(private ctx: ExtensionContext) {}
/**
* Schedule a widget update. Flushed asynchronously at end of the
* current event-loop tick; multiple calls in the same tick coalesce.
*/
schedule(key: string, lines: string[]): void {
this.pending.set(key, lines);
this.scheduleFlush();
}
/**
* Remove a widget (e.g., when a task completes).
* Flushed asynchronously at end of the current tick.
*/
scheduleRemove(key: string): void {
this.pending.delete(key);
this.pendingRemovals.add(key);
this.scheduleFlush();
}
/** Synchronously flush all pending updates. */
flush(): void {
this.doFlush();
}
/** Flush remaining updates then stop scheduling. */
stop(): void {
this.doFlush();
}
// ── Internal ────────────────────────────────────────────────────────
private scheduleFlush(): void {
if (this.scheduled) return;
this.scheduled = true;
queueMicrotask(() => {
this.scheduled = false;
this.doFlush();
});
}
private doFlush(): void {
if (this.flushing) return;
this.flushing = true;
// Atomically swap — new schedule()/scheduleRemove() calls land on fresh
// collections, so the batch we iterate stays immutable and nothing is lost.
const toRender = this.pending;
const toRemove = this.pendingRemovals;
this.pending = new Map();
this.pendingRemovals = new Set();
// Apply removals first
for (const key of toRemove) {
this.ctx.ui.setWidget(key, undefined);
}
// Sort by key for deterministic, stable ordering across every flush.
// Task IDs are zero-padded ("008", "012", "013") so alpha sort = numeric order.
const sortedKeys = Array.from(toRender.keys()).sort();
for (const key of sortedKeys) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this.ctx.ui.setWidget(key, toRender.get(key)!);
}
this.flushing = false;
}
}

564
src/worktree.ts Normal file
View File

@@ -0,0 +1,564 @@
import * as fs from "node:fs";
import * as path from "node:path";
import {
ensureDir,
hasUncommittedChanges,
hasTrackedUncommittedChanges,
} 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);
}
/**
* Canonicalize a directory path, resolving symlinks.
*
* `git worktree list --porcelain` emits REAL paths (symlinks resolved,
* e.g. `/private/tmp/...` for `/tmp/...` on macOS), while `path.join` on a
* caller-supplied path keeps the literal spelling. Comparing the two
* verbatim silently fails — resume then can't see an existing worktree,
* `createWorktree` falls through to a fresh `worktree add` that fails
* because the directory already exists, returns null, and the task agent
* ends up running in the MAIN repo with no worktree merge at all.
*
* All worktree path computation and porcelain comparisons go through this
* so literal vs real paths can never diverge.
*/
function canonicalDir(dir: string): string {
try {
return fs.realpathSync(dir);
} catch {
return path.resolve(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 {
// Canonicalize FIRST: every path below (worktree dir, porcelain
// comparisons, branch refs) must share one spelling of the repo path.
mainDir = canonicalDir(mainDir);
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 the branch the worktree is ACTUALLY checked out on, NOT the
// slug recomputed from the (possibly changed) task title. Mismatch
// happens routinely on resume: the task agent may have created its
// own feature branch (e.g. `proctored-exam-delivery-mode-10-exam-...`)
// once it saw the convention in the git log, the title may have been
// edited between runs, or an older ralpi version used a different
// naming scheme. Returning the slug here makes `git merge <slug>`
// fail with "not something we can merge" because no such ref exists —
// exactly the spurious merge-conflict we see on resumes.
const actual = getCurrentBranch(wtDir);
if (actual && actual !== "HEAD" && actual !== "detached") {
return { dir: wtDir, branch: actual, mainDir };
}
// Detached-HEAD worktree (e.g. left by a prior `--detach` fallback).
// The slug ref doesn't exist as a branch — create one matching the
// slug from the worktree's current HEAD so the merge step resolves.
git(`branch "${branch}" HEAD`, mainDir);
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(
canonicalDir(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;
}
/** Result of attempting to finalize a single in-progress worktree on resume. */
export interface FinalizeResult {
/** Task IDs whose committed branch was merged into main and cleaned up. */
finalized: string[];
/** Task IDs left to re-run (no worktree, dirty tree, nothing committed,
* or merge conflict — work is preserved for re-execution). */
rerun: string[];
/** Task IDs that hit a merge conflict; their committed branch + worktree
* are left intact for manual resolution. Excluded from `rerun` so the
* scheduler does not blindly re-execute conflicting work. */
conflicts: Record<string, string[]>;
}
/**
* Finalize worktrees that already hold committed, clean work that was never
* merged into main (typically because the loop was interrupted between the
* task commit and the merge/finalize step).
*
* For each task ID:
* - If no worktree exists / is registered → re-run (fresh worktree later).
* - If the worktree has uncommitted edits to TRACKED files (e.g. an
* interrupted agent mid-edit) → re-run, preserving the worktree so
* `createWorktree` reuses it and the agent continues where it left off.
* Untracked files are ignored here — they never block a merge, and a
* worktree whose task work is fully committed is "done" even if it
* carries stray untracked files. Counting `??` entries would strand the
* committed branch in `.ralpi/worktrees/` forever on every resume.
* - If the worktree has no commits ahead of main → re-run.
* - If the worktree has ≥1 commit ahead of main → merge the branch into
* main (`--no-ff`) and report finalized. Fully clean worktrees are then
* removed; worktrees that also carry untracked files are kept so that
* (possibly meaningful) uncommitted files aren't destroyed — the next
* fresh-loop sweep cleans them up. On merge conflict the merge is
* aborted (main left clean), the worktree is preserved, and the task is
* reported in `conflicts`.
*
* This is the self-healing path for an interrupted review-gated loop:
* tasks that finished (commit + review already saved) but never got their
* merge are completed here, so `/ralpi-resume` does not wastefully re-run
* finished work.
*/
export function finalizeCommittedWorktrees(
mainDir: string,
stateDir: string,
prdKey: string,
taskIds: string[],
): FinalizeResult {
mainDir = canonicalDir(mainDir);
const result: FinalizeResult = { finalized: [], rerun: [], conflicts: {} };
const mainHead = getGitHead(mainDir);
for (const taskId of taskIds) {
const wtDir = worktreePath(mainDir, stateDir, prdKey, taskId);
// No worktree directory on disk → nothing to finalize.
if (!fs.existsSync(wtDir)) {
result.rerun.push(taskId);
continue;
}
// Confirm the worktree is actually registered with git (not a leftover
// dir from a half-cleaned-up run). If registered but broken, drop its
// metadata so a fresh worktree can be created on re-run.
const list = git("worktree list --porcelain", mainDir) ?? "";
if (!list.includes(`worktree ${wtDir}`)) {
result.rerun.push(taskId);
continue;
}
// Broken checkout → re-run (createWorktree will recreate it).
const branch = getCurrentBranch(wtDir);
if (!branch || branch === "HEAD" || branch === "detached") {
result.rerun.push(taskId);
continue;
}
// Uncommitted edits to TRACKED files (an interrupted agent mid-edit) →
// re-run, keeping the worktree so the agent resumes in place. Untracked
// files alone do NOT count as dirty here (see doc comment above).
if (hasTrackedUncommittedChanges(wtDir)) {
result.rerun.push(taskId);
continue;
}
// No commits ahead of main → nothing to merge.
const aheadStr =
mainHead !== null
? git(`rev-list --count ${mainHead}..HEAD`, wtDir)
: null;
const ahead = aheadStr !== null ? parseInt(aheadStr, 10) : 0;
if (Number.isNaN(ahead) || ahead <= 0) {
result.rerun.push(taskId);
continue;
}
// Committed + no tracked edits → finalize. mergeWorktree aborts on
// conflict, leaving main's working tree clean.
const merge = mergeWorktree(mainDir, branch);
if (merge.success) {
// Remove the worktree only when it's fully clean. If it still carries
// untracked files, keep it so that uncommitted work isn't destroyed
// (the branch is merged; the leftover worktree is swept by the next
// fresh-loop cleanup).
if (!hasUncommittedChanges(wtDir)) {
removeWorktree(mainDir, { dir: wtDir, branch, mainDir });
}
result.finalized.push(taskId);
continue;
}
// Conflict — preserve the worktree for manual resolution and report.
result.conflicts[taskId] = merge.conflicts;
}
git("worktree prune", mainDir);
return result;
}
/**
* Whether a worktree still holds work worth preserving (committed commits
* ahead of main, or uncommitted changes). Used by the task-failure path so a
* failed/timeout agent's partial output isn't force-deleted with the
* worktree.
*/
export function worktreeHasPreservableWork(
mainDir: string,
wt: WorktreeHandle,
): boolean {
mainDir = canonicalDir(mainDir);
// Any uncommitted changes (tracked edits or untracked files) count — the
// agent may have been mid-write when it failed.
if (hasUncommittedChanges(wt.dir)) return true;
const mainHead = getGitHead(mainDir);
if (!mainHead) return true;
const aheadStr = git(`rev-list --count ${mainHead}..${wt.branch}`, mainDir);
const ahead = aheadStr !== null ? parseInt(aheadStr, 10) : 0;
return !Number.isNaN(ahead) && ahead > 0;
}