almost
This commit is contained in:
449
src/utils.ts
449
src/utils.ts
@@ -1,8 +1,19 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import type { RalphConfig, ProgressState, Task } from "./types";
|
||||
import type {
|
||||
RalphConfig,
|
||||
PRDProgress,
|
||||
ProgressState,
|
||||
ToolUsage,
|
||||
} from "./types";
|
||||
import { DEFAULT_CONFIG } from "./types";
|
||||
import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
|
||||
import {
|
||||
createAgentSession,
|
||||
DefaultResourceLoader,
|
||||
getAgentDir,
|
||||
SessionManager,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
|
||||
// ─── Directory Helpers ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -23,39 +34,50 @@ export function writeFileSafe(filePath: string, content: string): void {
|
||||
fs.writeFileSync(filePath, content, "utf-8");
|
||||
}
|
||||
|
||||
// ─── Command Helpers ─────────────────────────────────────────────────────────
|
||||
// ─── Async Agent Session ────────────────────────────────────────────────────
|
||||
|
||||
// ─── Progress Discovery ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Check if a command exists in PATH
|
||||
* Find the nearest .ralph/progress.json by walking up from the given directory.
|
||||
* For a specific sourcePath, finds the matching PRD entry.
|
||||
*/
|
||||
export function commandExists(command: string): boolean {
|
||||
try {
|
||||
const { execSync } = require("node:child_process");
|
||||
execSync(`which ${command}`, { stdio: "ignore" });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
export function findProgressFile(
|
||||
startDir: string,
|
||||
sourcePath?: string,
|
||||
): { path: string; state: ProgressState; prdKey?: string } | null {
|
||||
let current = path.resolve(startDir);
|
||||
const root = path.parse(current).root;
|
||||
|
||||
/**
|
||||
* Get the path to the pi executable
|
||||
*/
|
||||
export function getPiPath(): string {
|
||||
// Check if PI_PATH environment variable is set
|
||||
const envPath = process.env.PI_PATH;
|
||||
if (envPath && fs.existsSync(envPath)) {
|
||||
return envPath;
|
||||
while (current !== root) {
|
||||
const candidate = path.join(current, ".ralph", "progress.json");
|
||||
if (fs.existsSync(candidate)) {
|
||||
try {
|
||||
const raw = fs.readFileSync(candidate, "utf-8");
|
||||
const state = JSON.parse(raw) as ProgressState;
|
||||
|
||||
// If looking for a specific source path, find matching PRD
|
||||
if (sourcePath && state.prds) {
|
||||
const resolvedSource = path.resolve(sourcePath);
|
||||
for (const [key, prd] of Object.entries(state.prds)) {
|
||||
if (path.resolve(prd.sourcePath) === resolvedSource) {
|
||||
return { path: candidate, state, prdKey: key };
|
||||
}
|
||||
}
|
||||
// No matching PRD found, continue walking up
|
||||
current = path.dirname(current);
|
||||
continue;
|
||||
}
|
||||
|
||||
return { path: candidate, state };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
current = path.dirname(current);
|
||||
}
|
||||
|
||||
// Try to find pi in PATH
|
||||
if (commandExists("pi")) {
|
||||
return "pi";
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
"pi executable not found. Set PI_PATH or ensure pi is in PATH.",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Config ──────────────────────────────────────────────────────────────────
|
||||
@@ -71,7 +93,7 @@ function parseSimpleYaml(content: string): Record<string, any> {
|
||||
const match = trimmed.match(/^([^:]+):\s*(.+)$/);
|
||||
if (match) {
|
||||
const key = match[1].trim();
|
||||
let value = match[2].trim();
|
||||
let value: string | boolean | number = match[2].trim();
|
||||
|
||||
// Parse booleans
|
||||
if (value === "true") value = true;
|
||||
@@ -113,13 +135,18 @@ function mergeConfig(
|
||||
export function loadConfig(projectDir: string): RalphConfig {
|
||||
const configPath = path.join(projectDir, ".ralph", "config.yaml");
|
||||
|
||||
// Return defaults silently when config file does not exist
|
||||
if (!fs.existsSync(configPath)) {
|
||||
return { ...DEFAULT_CONFIG };
|
||||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(configPath, "utf-8");
|
||||
// Simple YAML parsing (key: value format)
|
||||
const config = parseSimpleYaml(content);
|
||||
return mergeConfig(DEFAULT_CONFIG, config);
|
||||
} catch (error) {
|
||||
console.warn("Failed to load .ralph/config.yaml, using defaults:", error);
|
||||
} catch {
|
||||
// Malformed config — fall back to defaults silently
|
||||
return { ...DEFAULT_CONFIG };
|
||||
}
|
||||
}
|
||||
@@ -127,17 +154,18 @@ export function loadConfig(projectDir: string): RalphConfig {
|
||||
// ─── Task Resolution ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve a task argument to a file path
|
||||
* Resolve a task argument to a file path.
|
||||
* Strips leading `@` (from autocomplete) before resolution.
|
||||
*/
|
||||
export function resolveTaskArg(
|
||||
arg: string,
|
||||
cwd: string,
|
||||
): string {
|
||||
export function resolveTaskArg(arg: string, cwd: string): string {
|
||||
// Strip leading @ from autocomplete
|
||||
const cleanArg = arg.startsWith("@") ? arg.slice(1) : arg;
|
||||
|
||||
const candidates = [
|
||||
path.resolve(cwd, arg),
|
||||
path.resolve(cwd, arg + ".md"),
|
||||
path.resolve(cwd, arg + ".yaml"),
|
||||
path.resolve(cwd, arg + ".yml"),
|
||||
path.resolve(cwd, cleanArg),
|
||||
path.resolve(cwd, cleanArg + ".md"),
|
||||
path.resolve(cwd, cleanArg + ".yaml"),
|
||||
path.resolve(cwd, cleanArg + ".yml"),
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
@@ -145,13 +173,17 @@ export function resolveTaskArg(
|
||||
}
|
||||
|
||||
// Try looking for README.md in the arg directory
|
||||
if (fs.statSync(path.resolve(cwd, arg)).isDirectory()) {
|
||||
const readme = path.resolve(cwd, arg, "README.md");
|
||||
if (fs.existsSync(readme)) return readme;
|
||||
try {
|
||||
if (fs.statSync(path.resolve(cwd, cleanArg)).isDirectory()) {
|
||||
const readme = path.resolve(cwd, cleanArg, "README.md");
|
||||
if (fs.existsSync(readme)) return readme;
|
||||
}
|
||||
} catch {
|
||||
// Directory doesn't exist, fall through to error
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Task file not found: ${arg}\nSearched: ${candidates.join("\n ")}`,
|
||||
`Task file not found: ${cleanArg}\nSearched: ${candidates.join("\n ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -175,33 +207,38 @@ export function formatDuration(ms: number): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Format progress status for display
|
||||
* Format progress status for display. Accepts a single PRDProgress entry.
|
||||
*/
|
||||
export function formatProgressStatus(state: ProgressState): string {
|
||||
export function formatProgressStatus(state: PRDProgress): string {
|
||||
const lines: string[] = [];
|
||||
const tasks = state.tasks;
|
||||
const total = Object.keys(tasks).length;
|
||||
const completed = Object.values(tasks).filter(
|
||||
t => t.status === "completed",
|
||||
(t) => t.status === "completed",
|
||||
).length;
|
||||
const failed = Object.values(tasks).filter(
|
||||
t => t.status === "failed",
|
||||
(t) => t.status === "failed",
|
||||
).length;
|
||||
const inProgress = Object.values(tasks).filter(
|
||||
t => t.status === "in_progress",
|
||||
(t) => t.status === "in_progress",
|
||||
).length;
|
||||
|
||||
lines.push("## Progress");
|
||||
lines.push("");
|
||||
lines.push(`Total: ${total} | Completed: ${completed} | Failed: ${failed} | In Progress: ${inProgress}`);
|
||||
lines.push(
|
||||
`Total: ${total} | Completed: ${completed} | Failed: ${failed} | In Progress: ${inProgress}`,
|
||||
);
|
||||
lines.push("");
|
||||
|
||||
for (const [id, info] of Object.entries(tasks)) {
|
||||
const statusIcon =
|
||||
info.status === "completed" ? "[x]" :
|
||||
info.status === "in_progress" ? "[~]" :
|
||||
info.status === "failed" ? "[!]" :
|
||||
"[ ]";
|
||||
info.status === "completed"
|
||||
? "[x]"
|
||||
: info.status === "in_progress"
|
||||
? "[~]"
|
||||
: info.status === "failed"
|
||||
? "[!]"
|
||||
: "[ ]";
|
||||
|
||||
const duration = info.durationMs
|
||||
? ` (${formatDuration(info.durationMs)})`
|
||||
@@ -222,58 +259,274 @@ export function formatProgressStatus(state: ProgressState): string {
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// ─── Pi Subprocess ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Spawn a pi subprocess with the given prompt file
|
||||
* Format progress status for all PRDs in a ProgressState.
|
||||
*/
|
||||
export function spawnPi(
|
||||
promptFile: string,
|
||||
piPath: string,
|
||||
args?: string[],
|
||||
): { stdout: string; stderr: string; code: number | null } {
|
||||
const spawnArgs = ["--prompt", promptFile, ...(args || [])];
|
||||
export function formatAllPRDsStatus(state: ProgressState): string {
|
||||
const prds = state.prds;
|
||||
if (!prds || Object.keys(prds).length <= 1) {
|
||||
// Single PRD — use simple format
|
||||
const prd = prds
|
||||
? Object.values(prds)[0]
|
||||
: (state as unknown as PRDProgress);
|
||||
return formatProgressStatus(prd);
|
||||
}
|
||||
|
||||
const result = spawnSync(piPath, spawnArgs, {
|
||||
encoding: "utf-8",
|
||||
timeout: 60 * 60 * 1000, // 1 hour
|
||||
maxBuffer: 10 * 1024 * 1024, // 10MB
|
||||
});
|
||||
const lines: string[] = [];
|
||||
lines.push("## Progress (all PRDs)");
|
||||
lines.push("");
|
||||
|
||||
return {
|
||||
stdout: result.stdout || "",
|
||||
stderr: result.stderr || "",
|
||||
code: result.status,
|
||||
};
|
||||
}
|
||||
for (const [key, prd] of Object.entries(prds)) {
|
||||
const tasks = prd.tasks;
|
||||
const total = Object.keys(tasks).length;
|
||||
const completed = Object.values(tasks).filter(
|
||||
(t) => t.status === "completed",
|
||||
).length;
|
||||
const failed = Object.values(tasks).filter(
|
||||
(t) => t.status === "failed",
|
||||
).length;
|
||||
const inProgress = Object.values(tasks).filter(
|
||||
(t) => t.status === "in_progress",
|
||||
).length;
|
||||
|
||||
/**
|
||||
* Extract text content from pi event stream output
|
||||
*/
|
||||
export function extractTextFromEvent(output: string): string {
|
||||
// If output is JSON event stream, extract text fields
|
||||
if (output.startsWith("{") || output.startsWith("data:")) {
|
||||
const lines = output.split("\n");
|
||||
const texts: string[] = [];
|
||||
lines.push(`### ${key}`);
|
||||
lines.push(`Source: ${path.relative(process.cwd(), prd.sourcePath)}`);
|
||||
lines.push(
|
||||
`Total: ${total} | Completed: ${completed} | Failed: ${failed} | In Progress: ${inProgress}`,
|
||||
);
|
||||
lines.push("");
|
||||
|
||||
for (const line of lines) {
|
||||
// Try to parse NDJSON events
|
||||
if (line.startsWith("data: ")) {
|
||||
try {
|
||||
const event = JSON.parse(line.slice(6));
|
||||
if (event.type === "text" && event.text) {
|
||||
texts.push(event.text);
|
||||
}
|
||||
} catch {
|
||||
texts.push(line.slice(6));
|
||||
}
|
||||
} else if (line.trim()) {
|
||||
texts.push(line);
|
||||
for (const [id, info] of Object.entries(tasks)) {
|
||||
const statusIcon =
|
||||
info.status === "completed"
|
||||
? "[x]"
|
||||
: info.status === "in_progress"
|
||||
? "[~]"
|
||||
: info.status === "failed"
|
||||
? "[!]"
|
||||
: "[ ]";
|
||||
|
||||
const duration = info.durationMs
|
||||
? ` (${formatDuration(info.durationMs)})`
|
||||
: "";
|
||||
|
||||
lines.push(`- ${statusIcon} ${id}${duration}`);
|
||||
|
||||
if (info.error) {
|
||||
lines.push(` Error: ${info.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
return texts.join("\n");
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
return output;
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// ─── Async Agent Session ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Run a task prompt through an in-process Pi agent session (async, non-blocking).
|
||||
*
|
||||
* Unlike the old spawnPi() which used spawnSync and froze the TUI,
|
||||
* this uses createAgentSession from the Pi SDK, keeping the event loop
|
||||
* responsive and allowing progress updates during task execution.
|
||||
*/
|
||||
export async function runAgentSession(
|
||||
taskPrompt: string,
|
||||
cwd: string,
|
||||
timeoutMs: number,
|
||||
onEvent?: (event: AgentSessionEvent) => void,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
text: string;
|
||||
error?: string;
|
||||
toolUsage: ToolUsage;
|
||||
stopReason?: string;
|
||||
events: AgentSessionEvent[];
|
||||
}> {
|
||||
const toolUsage: ToolUsage = {
|
||||
read: 0,
|
||||
write: 0,
|
||||
edit: 0,
|
||||
bash: 0,
|
||||
other: 0,
|
||||
};
|
||||
const recordedEvents: AgentSessionEvent[] = [];
|
||||
|
||||
// Wire timeout via abort signal
|
||||
const timeoutHandle = setTimeout(() => {
|
||||
if (sessionRef?.session) sessionRef.session.agent.abort();
|
||||
}, timeoutMs);
|
||||
|
||||
const sessionRef: {
|
||||
session?: Awaited<ReturnType<typeof createAgentSession>>["session"];
|
||||
} = {};
|
||||
|
||||
try {
|
||||
const loader = new DefaultResourceLoader({
|
||||
cwd,
|
||||
agentDir: getAgentDir(),
|
||||
noExtensions: true,
|
||||
noSkills: false,
|
||||
noPromptTemplates: true,
|
||||
noThemes: true,
|
||||
noContextFiles: true,
|
||||
});
|
||||
await loader.reload();
|
||||
|
||||
const result = await createAgentSession({
|
||||
cwd,
|
||||
sessionManager: SessionManager.inMemory(),
|
||||
resourceLoader: loader,
|
||||
tools: ["read", "bash", "edit", "write", "grep", "find", "ls"],
|
||||
});
|
||||
sessionRef.session = result.session;
|
||||
|
||||
// Wire external abort signal
|
||||
const abortHandler = () => result.session.agent.abort();
|
||||
signal?.addEventListener("abort", abortHandler, { once: true });
|
||||
|
||||
let finalText = "";
|
||||
let errorMessage: string | undefined;
|
||||
let stopReason: string | undefined;
|
||||
|
||||
const unsubscribe = result.session.subscribe((event) => {
|
||||
recordedEvents.push(event);
|
||||
onEvent?.(event);
|
||||
|
||||
if (event.type === "message_end") {
|
||||
const message = event.message as {
|
||||
role?: string;
|
||||
content?: unknown;
|
||||
stopReason?: string;
|
||||
errorMessage?: string;
|
||||
};
|
||||
if (message.role !== "assistant") return;
|
||||
if (message.stopReason) stopReason = message.stopReason;
|
||||
if (message.errorMessage) errorMessage = message.errorMessage;
|
||||
const text = extractAssistantText(message.content);
|
||||
if (text) finalText = text;
|
||||
}
|
||||
|
||||
if (event.type === "tool_execution_start") {
|
||||
const name = event.toolName;
|
||||
if (name in toolUsage) {
|
||||
(toolUsage as unknown as Record<string, number>)[name]++;
|
||||
} else {
|
||||
toolUsage.other++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (signal?.aborted) throw new Error("Aborted before prompt");
|
||||
|
||||
await result.session.prompt(taskPrompt);
|
||||
await result.session.agent.waitForIdle();
|
||||
|
||||
unsubscribe();
|
||||
result.session.dispose();
|
||||
signal?.removeEventListener("abort", abortHandler);
|
||||
clearTimeout(timeoutHandle);
|
||||
|
||||
if (errorMessage && !finalText) {
|
||||
return {
|
||||
success: false,
|
||||
text: "",
|
||||
error: errorMessage,
|
||||
toolUsage,
|
||||
stopReason,
|
||||
events: recordedEvents,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
text: finalText.trim(),
|
||||
toolUsage,
|
||||
stopReason,
|
||||
events: recordedEvents,
|
||||
};
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutHandle);
|
||||
return {
|
||||
success: false,
|
||||
text: "",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
toolUsage,
|
||||
events: recordedEvents,
|
||||
};
|
||||
} finally {
|
||||
sessionRef.session?.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract assistant text from message content (text blocks only).
|
||||
*/
|
||||
function extractAssistantText(content: unknown): string {
|
||||
if (typeof content === "string") return content;
|
||||
if (!Array.isArray(content)) return "";
|
||||
return content
|
||||
.filter(
|
||||
(c): c is { type: string; text?: string } =>
|
||||
!!c &&
|
||||
typeof c === "object" &&
|
||||
(c as { type?: string }).type === "text",
|
||||
)
|
||||
.map((c) => (c as { text?: string }).text ?? "")
|
||||
.join("");
|
||||
}
|
||||
|
||||
// ─── Git Commit Capture ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Capture recent git commits made during task execution
|
||||
* Returns commit messages and a summary string
|
||||
*/
|
||||
export function captureGitCommits(projectDir: string): {
|
||||
commitMessages: string[];
|
||||
commitSummary: string;
|
||||
} {
|
||||
const { execSync } = require("node:child_process");
|
||||
|
||||
try {
|
||||
// Check if this is a git repo
|
||||
execSync("git rev-parse --git-dir", { cwd: projectDir, stdio: "pipe" });
|
||||
} catch {
|
||||
return { commitMessages: [], commitSummary: "" };
|
||||
}
|
||||
|
||||
const commitMessages: string[] = [];
|
||||
let commitSummary = "";
|
||||
|
||||
try {
|
||||
// Get recent commits (last 5) with short hash and subject
|
||||
const output = execSync("git log --oneline -5 --no-decorate", {
|
||||
cwd: projectDir,
|
||||
encoding: "utf-8",
|
||||
}).trim();
|
||||
|
||||
if (output) {
|
||||
const lines = output.split("\n").filter((l: string) => l.trim());
|
||||
for (const line of lines) {
|
||||
// Format: "abc1234 Commit message"
|
||||
const parts = line.split(" ", 2);
|
||||
if (parts.length >= 2) {
|
||||
commitMessages.push(parts[1]);
|
||||
}
|
||||
}
|
||||
|
||||
// Build summary from commit subjects
|
||||
commitSummary = commitMessages.slice(0, 3).join("; ");
|
||||
if (commitMessages.length > 3) {
|
||||
commitSummary += ` (+${commitMessages.length - 3} more)`;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Git command failed, return empty
|
||||
}
|
||||
|
||||
return { commitMessages, commitSummary };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user