initial import: @mikefreno/omp-ralpi (omp port)
This commit is contained in:
35
src/constants.ts
Normal file
35
src/constants.ts
Normal 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
540
src/dag.ts
Normal 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
274
src/diff.ts
Normal 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
1918
src/executor.ts
Normal file
File diff suppressed because it is too large
Load Diff
773
src/parser.ts
Normal file
773
src/parser.ts
Normal 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
327
src/progress.ts
Normal 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
642
src/prompts.ts
Normal 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
128
src/reflection.ts
Normal 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
214
src/review.ts
Normal 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
112
src/task-manager-prompt.ts
Normal 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
330
src/types.ts
Normal 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
1045
src/utils.ts
Normal file
File diff suppressed because it is too large
Load Diff
92
src/widget-batcher.ts
Normal file
92
src/widget-batcher.ts
Normal 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
564
src/worktree.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user