reference updates
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,3 +1,4 @@
|
|||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
.pi-lens
|
.pi-lens
|
||||||
|
package-lock.json
|
||||||
|
|||||||
22
AGENTS.md
22
AGENTS.md
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## What this is
|
## What this is
|
||||||
|
|
||||||
A Pi coding agent extension that registers the `/ralph` slash command. Not a standalone app — it runs inside Pi's extension host.
|
A Pi coding agent extension that registers the `/ralpi` slash command. Not a standalone app — it runs inside Pi's extension host.
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
@@ -32,22 +32,22 @@ The only real npm dependency is `yaml` (^2.4.0).
|
|||||||
- `parser.ts` — task file parsing (Fio, checkbox, YAML formats)
|
- `parser.ts` — task file parsing (Fio, checkbox, YAML formats)
|
||||||
- `dag.ts` — Kahn's algorithm dependency resolution, batch planning
|
- `dag.ts` — Kahn's algorithm dependency resolution, batch planning
|
||||||
- `executor.ts` — task execution, retry, parallel/sequential modes
|
- `executor.ts` — task execution, retry, parallel/sequential modes
|
||||||
- `progress.ts` — `.ralph/progress.json` state management
|
- `progress.ts` — `.ralpi/progress.json` state management
|
||||||
- `prompts.ts` — prompt generation for spawned agent sessions
|
- `prompts.ts` — prompt generation for spawned agent sessions
|
||||||
- `reflection.ts` — reflection extraction from agent output
|
- `reflection.ts` — reflection extraction from agent output
|
||||||
- `utils.ts` — config loading, progress discovery, `runAgentSession()`
|
- `utils.ts` — config loading, progress discovery, `runAgentSession()`
|
||||||
- `types.ts` — all interfaces and `DEFAULT_CONFIG`
|
- `types.ts` — all interfaces and `DEFAULT_CONFIG`
|
||||||
- `widget-batcher.ts` — debounced widget updates for parallel tasks
|
- `widget-batcher.ts` — debounced widget updates for parallel tasks
|
||||||
- `skills/ralph-task/SKILL.md` — Pi skill definition for task execution
|
- `skills/ralpi-use.md` — Pi skill definition for task execution
|
||||||
- `tasks/` — example ralph task files (self-modification history)
|
- `tasks/` — example ralpi task files (self-modification history)
|
||||||
|
|
||||||
## Runtime state
|
## Runtime state
|
||||||
|
|
||||||
All runtime state lives in `.ralph/` (gitignored):
|
All runtime state lives in `.ralpi/` (gitignored):
|
||||||
- `.ralph/progress.json` — execution progress, supports multiple PRDs
|
- `.ralpi/progress.json` — execution progress, supports multiple PRDs
|
||||||
- `.ralph/reflections/` — per-task reflection JSON files
|
- `.ralpi/reflections/` — per-task reflection JSON files
|
||||||
- `.ralph/prompts/` — generated prompts (timestamped, for debugging)
|
- `.ralpi/prompts/` — generated prompts (timestamped, for debugging)
|
||||||
- `.ralph/sessions/` — full session transcripts
|
- `.ralpi/sessions/` — full session transcripts
|
||||||
|
|
||||||
## Task ID convention
|
## Task ID convention
|
||||||
|
|
||||||
@@ -55,8 +55,8 @@ Task IDs are zero-padded strings (`"01"`, `"02"`, etc.). The parser prepends `0`
|
|||||||
|
|
||||||
## Command routing
|
## Command routing
|
||||||
|
|
||||||
`/ralph` with no args → plan. First token looks like a path (`@path`, `./path`, `.md`, etc.) → run. Otherwise dispatches to subcommand (`run`, `plan`, `status`, `resume`, `next`, `reset`).
|
`/ralpi` with no args → plan. First token looks like a path (`@path`, `./path`, `.md`, etc.) → run. Otherwise dispatches to subcommand (`run`, `plan`, `status`, `resume`, `next`, `reset`).
|
||||||
|
|
||||||
## Config
|
## Config
|
||||||
|
|
||||||
Read from `.ralph/config.yaml` in project directory. Falls back to `DEFAULT_CONFIG` in `src/types.ts` when file is missing. Config is loaded at `projectDir` level, not extension level.
|
Read from `.ralpi/config.yaml` in project directory. Falls back to `DEFAULT_CONFIG` in `src/types.ts` when file is missing. Config is loaded at `projectDir` level, not extension level.
|
||||||
|
|||||||
24
README.md
24
README.md
@@ -6,7 +6,7 @@ Execute tasks from task files using DAG-based dependency resolution with persist
|
|||||||
|
|
||||||
- **DAG-based execution**: Tasks are ordered by dependencies using Kahn's algorithm
|
- **DAG-based execution**: Tasks are ordered by dependencies using Kahn's algorithm
|
||||||
- **Parallel batching**: Independent tasks in each batch can run concurrently
|
- **Parallel batching**: Independent tasks in each batch can run concurrently
|
||||||
- **Persistent progress**: Execution state saved to `.ralph/progress.json`
|
- **Persistent progress**: Execution state saved to `.ralpi/progress.json`
|
||||||
- **Reflection system**: Each task produces a reflection for downstream tasks
|
- **Reflection system**: Each task produces a reflection for downstream tasks
|
||||||
- **Retry with backoff**: Failed tasks retry with exponential backoff
|
- **Retry with backoff**: Failed tasks retry with exponential backoff
|
||||||
- **Multiple formats**: Supports Fio README, simple checkboxes, and YAML
|
- **Multiple formats**: Supports Fio README, simple checkboxes, and YAML
|
||||||
@@ -21,12 +21,12 @@ Execute tasks from task files using DAG-based dependency resolution with persist
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```
|
```
|
||||||
/ralph plan [task-file] # Show execution plan
|
/ralpi plan [task-file] # Show execution plan
|
||||||
/ralph run [task-file] # Execute all tasks
|
/ralpi run [task-file] # Execute all tasks
|
||||||
/ralph status [task-file] # Show current progress
|
/ralpi status [task-file] # Show current progress
|
||||||
/ralph resume [task-file] # Resume paused execution
|
/ralpi resume [task-file] # Resume paused execution
|
||||||
/ralph next [task-file] # Execute next batch only
|
/ralpi next [task-file] # Execute next batch only
|
||||||
/ralph reset [task-file] # Reset all progress
|
/ralpi reset [task-file] # Reset all progress
|
||||||
```
|
```
|
||||||
|
|
||||||
## Task File Formats
|
## Task File Formats
|
||||||
@@ -98,7 +98,7 @@ tasks:
|
|||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Create `.ralph/config.yaml`:
|
Create `.ralpi/config.yaml`:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
maxRetries: 3
|
maxRetries: 3
|
||||||
@@ -121,7 +121,7 @@ Supported formats: `10m` (minutes), `600s` (seconds), `3600000` (milliseconds)
|
|||||||
|
|
||||||
## State Files
|
## State Files
|
||||||
|
|
||||||
- `.ralph/progress.json` - Execution progress
|
- `.ralpi/progress.json` - Execution progress
|
||||||
- `.ralph/reflections/` - Per-task reflections
|
- `.ralpi/reflections/` - Per-task reflections
|
||||||
- `.ralph/prompts/` - Generated prompts
|
- `.ralpi/prompts/` - Generated prompts
|
||||||
- `.ralph/sessions/` - Full task output for review
|
- `.ralpi/sessions/` - Full task output for review
|
||||||
|
|||||||
65
package-lock.json
generated
65
package-lock.json
generated
@@ -1,65 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "ralph-loop",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"lockfileVersion": 3,
|
|
||||||
"requires": true,
|
|
||||||
"packages": {
|
|
||||||
"": {
|
|
||||||
"name": "ralph-loop",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"dependencies": {
|
|
||||||
"yaml": "^2.4.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/node": "^20.0.0",
|
|
||||||
"typescript": "^5.3.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/node": {
|
|
||||||
"version": "20.19.41",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz",
|
|
||||||
"integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"undici-types": "~6.21.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/typescript": {
|
|
||||||
"version": "5.9.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"bin": {
|
|
||||||
"tsc": "bin/tsc",
|
|
||||||
"tsserver": "bin/tsserver"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14.17"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/undici-types": {
|
|
||||||
"version": "6.21.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
|
||||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/yaml": {
|
|
||||||
"version": "2.9.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
|
|
||||||
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
|
|
||||||
"license": "ISC",
|
|
||||||
"bin": {
|
|
||||||
"yaml": "bin.mjs"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 14.6"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/eemeli"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
12
package.json
12
package.json
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "ralph-loop",
|
"name": "ralpi-loop",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "Execute tasks from task files using DAG-based dependency resolution with persistent progress tracking",
|
"description": "Execute tasks from task files using DAG-based dependency resolution with persistent progress tracking",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
@@ -8,7 +8,9 @@
|
|||||||
"pi-extension",
|
"pi-extension",
|
||||||
"task-runner",
|
"task-runner",
|
||||||
"dag",
|
"dag",
|
||||||
"task-manager"
|
"task-manager",
|
||||||
|
"ralpi-loop",
|
||||||
|
"prd"
|
||||||
],
|
],
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -24,9 +26,9 @@
|
|||||||
"prepublishOnly": "npm run build"
|
"prepublishOnly": "npm run build"
|
||||||
},
|
},
|
||||||
"pi": {
|
"pi": {
|
||||||
"extensions": ["./dist/index.js"],
|
"extensions": [
|
||||||
"skills": ["./skills"],
|
"./dist/index.js"
|
||||||
"prompts": ["./prompts"]
|
]
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"yaml": "^2.4.0"
|
"yaml": "^2.4.0"
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
# ralph-task
|
---
|
||||||
|
description: Executes individual tasks from ralpi task files using DAG-based dependency resolution, with progress tracking and reflection support
|
||||||
|
---
|
||||||
|
|
||||||
Execute a single task from a ralph task file.
|
# ralpi-task
|
||||||
|
|
||||||
|
Execute a single task from a ralpi task file.
|
||||||
|
|
||||||
## When to Use
|
## When to Use
|
||||||
|
|
||||||
@@ -11,9 +15,9 @@ Execute a single task from a ralph task file.
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```
|
```
|
||||||
/ralph run [task-file] # Run all tasks
|
/ralpi run [task-file] # Run all tasks
|
||||||
/ralph next [task-file] # Run next batch
|
/ralpi next [task-file] # Run next batch
|
||||||
/ralph status [task-file] # Check progress
|
/ralpi status [task-file] # Check progress
|
||||||
```
|
```
|
||||||
|
|
||||||
## Task File Location
|
## Task File Location
|
||||||
@@ -1,19 +1,25 @@
|
|||||||
import type { RalphConfig } from "./types";
|
|
||||||
import { DEFAULT_CONFIG } from "./types";
|
import { DEFAULT_CONFIG } from "./types";
|
||||||
|
|
||||||
export { DEFAULT_CONFIG };
|
export { DEFAULT_CONFIG };
|
||||||
|
|
||||||
// CLI
|
// CLI
|
||||||
export const SLASH_COMMAND = "/ralph";
|
export const SLASH_COMMAND = "/ralpi";
|
||||||
export const COMMANDS = ["run", "plan", "status", "resume", "next", "reset"] as const;
|
export const COMMANDS = [
|
||||||
|
"run",
|
||||||
|
"plan",
|
||||||
|
"status",
|
||||||
|
"resume",
|
||||||
|
"next",
|
||||||
|
"reset",
|
||||||
|
] as const;
|
||||||
|
|
||||||
// Task file detection
|
// Task file detection
|
||||||
export const TASK_FILE_NAMES = [
|
export const TASK_FILE_NAMES = [
|
||||||
"README.md",
|
"README.md",
|
||||||
"PRD.md",
|
"PRD.md",
|
||||||
"tasks.md",
|
"tasks.md",
|
||||||
"tasks.yaml",
|
"tasks.yaml",
|
||||||
"tasks.yml",
|
"tasks.yml",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
// Reflection parsing
|
// Reflection parsing
|
||||||
|
|||||||
657
src/executor.ts
657
src/executor.ts
@@ -1,29 +1,29 @@
|
|||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import type { Task, Project, Reflection, ToolUsage } from "./types";
|
import type { Task, Project, Reflection, ToolUsage } from "./types";
|
||||||
import type { RalphConfig } from "./types";
|
import type { RalpiConfig } from "./types";
|
||||||
import type { ProgressTracker } from "./progress";
|
import type { ProgressTracker } from "./progress";
|
||||||
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||||
import { buildTaskPrompt } from "./prompts";
|
import { buildTaskPrompt } from "./prompts";
|
||||||
import { extractReflection } from "./reflection";
|
import { extractReflection } from "./reflection";
|
||||||
import { WidgetBatcher } from "./widget-batcher";
|
import { WidgetBatcher } from "./widget-batcher";
|
||||||
import {
|
import {
|
||||||
runAgentSession,
|
runAgentSession,
|
||||||
writeFileSafe,
|
writeFileSafe,
|
||||||
ensureDir,
|
ensureDir,
|
||||||
captureGitCommits,
|
captureGitCommits,
|
||||||
formatDuration,
|
formatDuration,
|
||||||
} from "./utils";
|
} from "./utils";
|
||||||
|
|
||||||
/** Optional callback to post a progress message into the chat history. */
|
/** Optional callback to post a progress message into the chat history. */
|
||||||
export type SendChatMessage = (
|
export type SendChatMessage = (
|
||||||
content: string,
|
content: string,
|
||||||
/** Extra data passed to the message renderer for the expanded view. */
|
/** Extra data passed to the message renderer for the expanded view. */
|
||||||
meta?: { toolCalls?: ToolCallEntry[] },
|
meta?: { toolCalls?: ToolCallEntry[] },
|
||||||
) => void;
|
) => void;
|
||||||
|
|
||||||
export interface ToolCallEntry {
|
export interface ToolCallEntry {
|
||||||
name: string;
|
name: string;
|
||||||
label: string;
|
label: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Run Single Task ────────────────────────────────────────────────────────
|
// ─── Run Single Task ────────────────────────────────────────────────────────
|
||||||
@@ -33,176 +33,176 @@ export interface ToolCallEntry {
|
|||||||
* Non-blocking — the TUI remains responsive throughout.
|
* Non-blocking — the TUI remains responsive throughout.
|
||||||
*/
|
*/
|
||||||
export async function runTask(
|
export async function runTask(
|
||||||
task: Task,
|
task: Task,
|
||||||
project: Project,
|
project: Project,
|
||||||
config: RalphConfig,
|
config: RalpiConfig,
|
||||||
depReflections: Reflection[],
|
depReflections: Reflection[],
|
||||||
ctx: ExtensionContext,
|
ctx: ExtensionContext,
|
||||||
sendChatMessage?: SendChatMessage,
|
sendChatMessage?: SendChatMessage,
|
||||||
projectDir: string = project.sourceDir,
|
projectDir: string = project.sourceDir,
|
||||||
batcher?: WidgetBatcher,
|
batcher?: WidgetBatcher,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
reflection?: Reflection;
|
reflection?: Reflection;
|
||||||
error?: string;
|
error?: string;
|
||||||
durationMs: number;
|
durationMs: number;
|
||||||
toolUsage?: ToolUsage;
|
toolUsage?: ToolUsage;
|
||||||
outputPreview?: string;
|
outputPreview?: string;
|
||||||
sessionFile?: string;
|
sessionFile?: string;
|
||||||
commitMessages?: string[];
|
commitMessages?: string[];
|
||||||
commitSummary?: string;
|
commitSummary?: string;
|
||||||
}> {
|
}> {
|
||||||
const startMs = Date.now();
|
const startMs = Date.now();
|
||||||
|
|
||||||
// Build prompt
|
// Build prompt
|
||||||
const prompt = buildTaskPrompt(
|
const prompt = buildTaskPrompt(
|
||||||
task,
|
task,
|
||||||
project,
|
project,
|
||||||
depReflections,
|
depReflections,
|
||||||
config.prompts.projectContext,
|
config.prompts.projectContext,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Write prompt to .ralph/ with timestamp (for debugging)
|
// Write prompt to .ralpi/ with timestamp (for debugging)
|
||||||
const ralphDir = path.join(projectDir, ".ralph");
|
const ralpiDir = path.join(projectDir, ".ralpi");
|
||||||
ensureDir(ralphDir);
|
ensureDir(ralpiDir);
|
||||||
const promptFile = path.join(ralphDir, `prompt-${startMs}.md`);
|
const promptFile = path.join(ralpiDir, `prompt-${startMs}.md`);
|
||||||
writeFileSafe(promptFile, prompt);
|
writeFileSafe(promptFile, prompt);
|
||||||
|
|
||||||
// Footer shows just the task title (no batch prefix)
|
// Footer shows just the task title (no batch prefix)
|
||||||
ctx.ui.setStatus("ralph", task.title);
|
ctx.ui.setStatus("ralpi", task.title);
|
||||||
|
|
||||||
const taskHeader = `${task.id} · ${task.title}`;
|
const taskHeader = `${task.id} · ${task.title}`;
|
||||||
|
|
||||||
// Live progress widget above the editor — animated spinner + tool call tree
|
// Live progress widget above the editor — animated spinner + tool call tree
|
||||||
// Using setWidget instead of setWorkingMessage because the working message area
|
// Using setWidget instead of setWorkingMessage because the working message area
|
||||||
// is only visible during parent agent streaming, not during extension command execution.
|
// is only visible during parent agent streaming, not during extension command execution.
|
||||||
// Widget key is unique per task so parallel tasks each get their own widget.
|
// Widget key is unique per task so parallel tasks each get their own widget.
|
||||||
const widgetKey = `ralph-task-${task.id}`;
|
const widgetKey = `ralpi-task-${task.id}`;
|
||||||
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||||
let frameIndex = 0;
|
let frameIndex = 0;
|
||||||
const theme = ctx.ui.theme;
|
const theme = ctx.ui.theme;
|
||||||
const MAX_COLLAPSED = 3;
|
const MAX_COLLAPSED = 3;
|
||||||
|
|
||||||
const toolCalls: ToolCallEntry[] = [];
|
const toolCalls: ToolCallEntry[] = [];
|
||||||
|
|
||||||
const updateWidget = () => {
|
const updateWidget = () => {
|
||||||
const frame = theme.fg("accent", SPINNER_FRAMES[frameIndex]);
|
const frame = theme.fg("accent", SPINNER_FRAMES[frameIndex]);
|
||||||
const lines = [`${frame} ${taskHeader}`];
|
const lines = [`${frame} ${taskHeader}`];
|
||||||
|
|
||||||
if (toolCalls.length > 0) {
|
if (toolCalls.length > 0) {
|
||||||
const shown = toolCalls.slice(-MAX_COLLAPSED);
|
const shown = toolCalls.slice(-MAX_COLLAPSED);
|
||||||
const remaining = toolCalls.length - shown.length;
|
const remaining = toolCalls.length - shown.length;
|
||||||
|
|
||||||
if (remaining > 0) {
|
if (remaining > 0) {
|
||||||
lines.push(theme.fg("dim", ` ├── ${remaining} more`));
|
lines.push(theme.fg("dim", ` ├── ${remaining} more`));
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 0; i < shown.length; i++) {
|
for (let i = 0; i < shown.length; i++) {
|
||||||
const entry = shown[i];
|
const entry = shown[i];
|
||||||
const isLast = i === shown.length - 1;
|
const isLast = i === shown.length - 1;
|
||||||
const branch = isLast ? " └── " : " ├── ";
|
const branch = isLast ? " └── " : " ├── ";
|
||||||
const tag = theme.fg("accent", `[${entry.name}]`);
|
const tag = theme.fg("accent", `[${entry.name}]`);
|
||||||
lines.push(`${branch}${tag} ${entry.label}`);
|
lines.push(`${branch}${tag} ${entry.label}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (batcher) {
|
if (batcher) {
|
||||||
batcher.schedule(widgetKey, lines);
|
batcher.schedule(widgetKey, lines);
|
||||||
} else {
|
} else {
|
||||||
ctx.ui.setWidget(widgetKey, lines);
|
ctx.ui.setWidget(widgetKey, lines);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Smooth spinner animation at 100ms intervals
|
// Smooth spinner animation at 100ms intervals
|
||||||
const spinnerTimer = setInterval(() => {
|
const spinnerTimer = setInterval(() => {
|
||||||
frameIndex = (frameIndex + 1) % SPINNER_FRAMES.length;
|
frameIndex = (frameIndex + 1) % SPINNER_FRAMES.length;
|
||||||
updateWidget();
|
updateWidget();
|
||||||
}, 100);
|
}, 100);
|
||||||
|
|
||||||
// Initial display
|
// Initial display
|
||||||
updateWidget();
|
updateWidget();
|
||||||
|
|
||||||
// Use task-level timeout if set, otherwise fall back to config
|
// Use task-level timeout if set, otherwise fall back to config
|
||||||
const timeoutMs = task.timeoutMs ?? config.execution.timeoutMs;
|
const timeoutMs = task.timeoutMs ?? config.execution.timeoutMs;
|
||||||
|
|
||||||
// Pre-create session file path so events stream to disk (avoids 300+ MB in-memory accumulation)
|
// Pre-create session file path so events stream to disk (avoids 300+ MB in-memory accumulation)
|
||||||
const sessionsDir = path.join(ralphDir, "sessions");
|
const sessionsDir = path.join(ralpiDir, "sessions");
|
||||||
ensureDir(sessionsDir);
|
ensureDir(sessionsDir);
|
||||||
const sessionFilePath = path.join(sessionsDir, `${task.id}-${startMs}.txt`);
|
const sessionFilePath = path.join(sessionsDir, `${task.id}-${startMs}.txt`);
|
||||||
|
|
||||||
// Run task asynchronously via Pi SDK — event loop stays responsive
|
// Run task asynchronously via Pi SDK — event loop stays responsive
|
||||||
const output = await runAgentSession(
|
const output = await runAgentSession(
|
||||||
prompt,
|
prompt,
|
||||||
projectDir,
|
projectDir,
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
(event) => {
|
(event) => {
|
||||||
if (event.type === "tool_execution_start") {
|
if (event.type === "tool_execution_start") {
|
||||||
const label = formatToolArg(event.toolName, event.args);
|
const label = formatToolArg(event.toolName, event.args);
|
||||||
toolCalls.push({
|
toolCalls.push({
|
||||||
name: event.toolName,
|
name: event.toolName,
|
||||||
label,
|
label,
|
||||||
});
|
});
|
||||||
updateWidget();
|
updateWidget();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
undefined, // no abort signal
|
undefined, // no abort signal
|
||||||
sessionFilePath, // stream events to file
|
sessionFilePath, // stream events to file
|
||||||
);
|
);
|
||||||
|
|
||||||
const durationMs = Date.now() - startMs;
|
const durationMs = Date.now() - startMs;
|
||||||
|
|
||||||
// Clear progress widget and status after task finishes
|
// Clear progress widget and status after task finishes
|
||||||
clearInterval(spinnerTimer);
|
clearInterval(spinnerTimer);
|
||||||
if (batcher) {
|
if (batcher) {
|
||||||
batcher.scheduleRemove(widgetKey);
|
batcher.scheduleRemove(widgetKey);
|
||||||
} else {
|
} else {
|
||||||
ctx.ui.setWidget(widgetKey, undefined);
|
ctx.ui.setWidget(widgetKey, undefined);
|
||||||
}
|
}
|
||||||
ctx.ui.setStatus("ralph", undefined);
|
ctx.ui.setStatus("ralpi", undefined);
|
||||||
|
|
||||||
if (!output.success) {
|
if (!output.success) {
|
||||||
sendChatMessage?.(`✗ ${taskHeader} — ${output.error}`);
|
sendChatMessage?.(`✗ ${taskHeader} — ${output.error}`);
|
||||||
ctx.ui.notify(`Task ${task.id} failed: ${output.error}`, "error");
|
ctx.ui.notify(`Task ${task.id} failed: ${output.error}`, "error");
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: output.error,
|
error: output.error,
|
||||||
durationMs,
|
durationMs,
|
||||||
sessionFile: sessionFilePath, // events streamed to file for debugging
|
sessionFile: sessionFilePath, // events streamed to file for debugging
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const agentText = output.text;
|
const agentText = output.text;
|
||||||
const toolUsage = output.toolUsage;
|
const toolUsage = output.toolUsage;
|
||||||
|
|
||||||
// Capture git commits made during this task
|
// Capture git commits made during this task
|
||||||
const { commitMessages, commitSummary } = captureGitCommits(projectDir);
|
const { commitMessages, commitSummary } = captureGitCommits(projectDir);
|
||||||
|
|
||||||
// Session file already written by runAgentSession (events streamed to disk)
|
// Session file already written by runAgentSession (events streamed to disk)
|
||||||
const sessionFile = sessionFilePath;
|
const sessionFile = sessionFilePath;
|
||||||
|
|
||||||
// Build output preview (first 500 chars of agent text)
|
// Build output preview (first 500 chars of agent text)
|
||||||
const outputPreview =
|
const outputPreview =
|
||||||
agentText.length > 500
|
agentText.length > 500
|
||||||
? agentText.slice(0, 500) + "\n... (truncated, see session file)"
|
? agentText.slice(0, 500) + "\n... (truncated, see session file)"
|
||||||
: agentText;
|
: agentText;
|
||||||
|
|
||||||
// Extract reflection from agent output
|
// Extract reflection from agent output
|
||||||
const reflection = extractReflection(agentText, task.id, task.title);
|
const reflection = extractReflection(agentText, task.id, task.title);
|
||||||
|
|
||||||
// Post completion chat message — header only, renderer builds the expandable tree
|
// Post completion chat message — header only, renderer builds the expandable tree
|
||||||
const dur = formatDuration(durationMs);
|
const dur = formatDuration(durationMs);
|
||||||
sendChatMessage?.(`✓ ${taskHeader} (${dur})`, { toolCalls });
|
sendChatMessage?.(`✓ ${taskHeader} (${dur})`, { toolCalls });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
reflection: reflection ?? undefined,
|
reflection: reflection ?? undefined,
|
||||||
durationMs,
|
durationMs,
|
||||||
toolUsage,
|
toolUsage,
|
||||||
outputPreview,
|
outputPreview,
|
||||||
sessionFile,
|
sessionFile,
|
||||||
commitMessages,
|
commitMessages,
|
||||||
commitSummary,
|
commitSummary,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Execute Batch ───────────────────────────────────────────────────────────
|
// ─── Execute Batch ───────────────────────────────────────────────────────────
|
||||||
@@ -211,198 +211,198 @@ export async function runTask(
|
|||||||
* Execute a batch of tasks (sequentially or in parallel)
|
* Execute a batch of tasks (sequentially or in parallel)
|
||||||
*/
|
*/
|
||||||
export async function executeBatch(
|
export async function executeBatch(
|
||||||
tasks: Task[],
|
tasks: Task[],
|
||||||
project: Project,
|
project: Project,
|
||||||
config: RalphConfig,
|
config: RalpiConfig,
|
||||||
progress: ProgressTracker,
|
progress: ProgressTracker,
|
||||||
ctx: ExtensionContext,
|
ctx: ExtensionContext,
|
||||||
options?: { parallel?: boolean },
|
options?: { parallel?: boolean },
|
||||||
sendChatMessage?: SendChatMessage,
|
sendChatMessage?: SendChatMessage,
|
||||||
projectDir?: string,
|
projectDir?: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Defensive: ensure tasks is an iterable array
|
// Defensive: ensure tasks is an iterable array
|
||||||
if (!Array.isArray(tasks)) {
|
if (!Array.isArray(tasks)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`executeBatch received invalid tasks: expected array, got ${typeof tasks}`,
|
`executeBatch received invalid tasks: expected array, got ${typeof tasks}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we should run parallel
|
// Check if we should run parallel
|
||||||
const shouldParallel =
|
const shouldParallel =
|
||||||
options?.parallel && tasks.length > 1 && config.execution.maxParallel > 0;
|
options?.parallel && tasks.length > 1 && config.execution.maxParallel > 0;
|
||||||
|
|
||||||
if (shouldParallel) {
|
if (shouldParallel) {
|
||||||
await executeBatchParallel(
|
await executeBatchParallel(
|
||||||
tasks,
|
tasks,
|
||||||
project,
|
project,
|
||||||
config,
|
config,
|
||||||
progress,
|
progress,
|
||||||
ctx,
|
ctx,
|
||||||
sendChatMessage,
|
sendChatMessage,
|
||||||
projectDir,
|
projectDir,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute sequentially
|
// Execute sequentially
|
||||||
for (const task of tasks) {
|
for (const task of tasks) {
|
||||||
await executeTask(
|
await executeTask(
|
||||||
task,
|
task,
|
||||||
project,
|
project,
|
||||||
config,
|
config,
|
||||||
progress,
|
progress,
|
||||||
ctx,
|
ctx,
|
||||||
sendChatMessage,
|
sendChatMessage,
|
||||||
projectDir,
|
projectDir,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute tasks in parallel using child processes
|
* Execute tasks in parallel using child processes
|
||||||
*/
|
*/
|
||||||
async function executeBatchParallel(
|
async function executeBatchParallel(
|
||||||
tasks: Task[],
|
tasks: Task[],
|
||||||
project: Project,
|
project: Project,
|
||||||
config: RalphConfig,
|
config: RalpiConfig,
|
||||||
progress: ProgressTracker,
|
progress: ProgressTracker,
|
||||||
ctx: ExtensionContext,
|
ctx: ExtensionContext,
|
||||||
sendChatMessage?: SendChatMessage,
|
sendChatMessage?: SendChatMessage,
|
||||||
projectDir?: string,
|
projectDir?: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const maxParallel = config.execution.maxParallel;
|
const maxParallel = config.execution.maxParallel;
|
||||||
const batcher = new WidgetBatcher(ctx);
|
const batcher = new WidgetBatcher(ctx);
|
||||||
const results: Array<{ task: Task; result: Promise<any> }> = [];
|
const results: Array<{ task: Task; result: Promise<any> }> = [];
|
||||||
|
|
||||||
for (const task of tasks) {
|
for (const task of tasks) {
|
||||||
results.push({
|
results.push({
|
||||||
task,
|
task,
|
||||||
result: executeTask(
|
result: executeTask(
|
||||||
task,
|
task,
|
||||||
project,
|
project,
|
||||||
config,
|
config,
|
||||||
progress,
|
progress,
|
||||||
ctx,
|
ctx,
|
||||||
sendChatMessage,
|
sendChatMessage,
|
||||||
projectDir,
|
projectDir,
|
||||||
batcher,
|
batcher,
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Limit concurrency
|
// Limit concurrency
|
||||||
if (results.length >= maxParallel) {
|
if (results.length >= maxParallel) {
|
||||||
const first = results.shift();
|
const first = results.shift();
|
||||||
if (first) await first.result;
|
if (first) await first.result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for remaining tasks
|
// Wait for remaining tasks
|
||||||
for (const { result } of results) {
|
for (const { result } of results) {
|
||||||
await result;
|
await result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flush and stop the batcher after all tasks complete
|
// Flush and stop the batcher after all tasks complete
|
||||||
batcher.stop();
|
batcher.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Execute Single Task with Retry ──────────────────────────────────────────
|
// ─── Execute Single Task with Retry ──────────────────────────────────────────
|
||||||
|
|
||||||
async function executeTask(
|
async function executeTask(
|
||||||
task: Task,
|
task: Task,
|
||||||
project: Project,
|
project: Project,
|
||||||
config: RalphConfig,
|
config: RalpiConfig,
|
||||||
progress: ProgressTracker,
|
progress: ProgressTracker,
|
||||||
ctx: ExtensionContext,
|
ctx: ExtensionContext,
|
||||||
sendChatMessage?: SendChatMessage,
|
sendChatMessage?: SendChatMessage,
|
||||||
projectDir: string = project.sourceDir,
|
projectDir: string = project.sourceDir,
|
||||||
batcher?: WidgetBatcher,
|
batcher?: WidgetBatcher,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const maxRetries = config.execution.maxRetries;
|
const maxRetries = config.execution.maxRetries;
|
||||||
let retries = 0;
|
let retries = 0;
|
||||||
|
|
||||||
while (retries <= maxRetries) {
|
while (retries <= maxRetries) {
|
||||||
try {
|
try {
|
||||||
// Mark as in progress
|
// Mark as in progress
|
||||||
progress.markInProgress(task.id);
|
progress.markInProgress(task.id);
|
||||||
|
|
||||||
// Get dependency reflections
|
// Get dependency reflections
|
||||||
const depReflections = progress.getDependencyReflections(
|
const depReflections = progress.getDependencyReflections(
|
||||||
task.dependencies || [],
|
task.dependencies || [],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Run the task
|
// Run the task
|
||||||
const result = await runTask(
|
const result = await runTask(
|
||||||
task,
|
task,
|
||||||
project,
|
project,
|
||||||
config,
|
config,
|
||||||
depReflections,
|
depReflections,
|
||||||
ctx,
|
ctx,
|
||||||
sendChatMessage,
|
sendChatMessage,
|
||||||
projectDir,
|
projectDir,
|
||||||
batcher,
|
batcher,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
// Save reflection
|
// Save reflection
|
||||||
if (result.reflection) {
|
if (result.reflection) {
|
||||||
saveReflectionToFile(projectDir, config, result.reflection);
|
saveReflectionToFile(projectDir, config, result.reflection);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark completed with all metadata
|
// Mark completed with all metadata
|
||||||
progress.markCompleted(
|
progress.markCompleted(
|
||||||
task.id,
|
task.id,
|
||||||
result.durationMs,
|
result.durationMs,
|
||||||
result.reflection,
|
result.reflection,
|
||||||
result.toolUsage,
|
result.toolUsage,
|
||||||
result.sessionFile,
|
result.sessionFile,
|
||||||
result.outputPreview,
|
result.outputPreview,
|
||||||
result.commitMessages,
|
result.commitMessages,
|
||||||
result.commitSummary,
|
result.commitSummary,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Task failed, check if we should retry
|
// Task failed, check if we should retry
|
||||||
if (retries < maxRetries) {
|
if (retries < maxRetries) {
|
||||||
retries = progress.incrementRetry(task.id);
|
retries = progress.incrementRetry(task.id);
|
||||||
ctx.ui.notify(
|
ctx.ui.notify(
|
||||||
`Retrying task ${task.id} (${retries}/${maxRetries}): ${result.error}`,
|
`Retrying task ${task.id} (${retries}/${maxRetries}): ${result.error}`,
|
||||||
"warning",
|
"warning",
|
||||||
);
|
);
|
||||||
|
|
||||||
// Exponential backoff
|
// Exponential backoff
|
||||||
const delay = config.execution.retryDelayMs * 2 ** (retries - 1);
|
const delay = config.execution.retryDelayMs * 2 ** (retries - 1);
|
||||||
await sleep(delay);
|
await sleep(delay);
|
||||||
} else {
|
} else {
|
||||||
// Max retries exceeded
|
// Max retries exceeded
|
||||||
progress.markFailed(task.id, result.error || "Unknown error");
|
progress.markFailed(task.id, result.error || "Unknown error");
|
||||||
throw new Error(`Task ${task.id} failed: ${result.error}`);
|
throw new Error(`Task ${task.id} failed: ${result.error}`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
progress.markFailed(task.id, errorMsg);
|
progress.markFailed(task.id, errorMsg);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Save Reflection to File ────────────────────────────────────────────────
|
// ─── Save Reflection to File ────────────────────────────────────────────────
|
||||||
|
|
||||||
function saveReflectionToFile(
|
function saveReflectionToFile(
|
||||||
sourceDir: string,
|
sourceDir: string,
|
||||||
config: RalphConfig,
|
config: RalpiConfig,
|
||||||
reflection: Reflection,
|
reflection: Reflection,
|
||||||
): void {
|
): void {
|
||||||
const reflectionsDir = path.join(sourceDir, config.paths.reflectionsDir);
|
const reflectionsDir = path.join(sourceDir, config.paths.reflectionsDir);
|
||||||
ensureDir(reflectionsDir);
|
ensureDir(reflectionsDir);
|
||||||
const filePath = path.join(reflectionsDir, `${reflection.taskId}.json`);
|
const filePath = path.join(reflectionsDir, `${reflection.taskId}.json`);
|
||||||
writeFileSafe(filePath, JSON.stringify(reflection, null, 2));
|
writeFileSafe(filePath, JSON.stringify(reflection, null, 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function sleep(ms: number): Promise<void> {
|
function sleep(ms: number): Promise<void> {
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Tool Call Formatting ────────────────────────────────────────────────
|
// ─── Tool Call Formatting ────────────────────────────────────────────────
|
||||||
@@ -411,31 +411,34 @@ function sleep(ms: number): Promise<void> {
|
|||||||
* Format a tool call argument into a short label.
|
* Format a tool call argument into a short label.
|
||||||
*/
|
*/
|
||||||
function formatToolArg(name: string, args: unknown): string {
|
function formatToolArg(name: string, args: unknown): string {
|
||||||
const a = args as Record<string, unknown>;
|
const a = args as Record<string, unknown>;
|
||||||
switch (name) {
|
switch (name) {
|
||||||
case "bash":
|
case "bash":
|
||||||
return truncateMiddle(String(a.command ?? ""), 70);
|
return truncateMiddle(String(a.command ?? ""), 70);
|
||||||
case "write":
|
case "write":
|
||||||
case "read":
|
case "read":
|
||||||
return truncateMiddle(String(a.path ?? ""), 60);
|
return truncateMiddle(String(a.path ?? ""), 60);
|
||||||
case "edit":
|
case "edit":
|
||||||
return truncateMiddle(String(a.path ?? ""), 60);
|
return truncateMiddle(String(a.path ?? ""), 60);
|
||||||
case "grep":
|
case "grep":
|
||||||
return `${a.pattern ?? "?"} — ${truncateMiddle(String(a.path ?? ""), 40)}`;
|
return `${a.pattern ?? "?"} — ${truncateMiddle(
|
||||||
case "find":
|
String(a.path ?? ""),
|
||||||
return `${a.path ?? "."} — ${a.glob ?? "*"}`;
|
40,
|
||||||
case "ls":
|
)}`;
|
||||||
return truncateMiddle(String(a.path ?? "."), 60);
|
case "find":
|
||||||
default:
|
return `${a.path ?? "."} — ${a.glob ?? "*"}`;
|
||||||
return name;
|
case "ls":
|
||||||
}
|
return truncateMiddle(String(a.path ?? "."), 60);
|
||||||
|
default:
|
||||||
|
return name;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Truncate a long string in the middle, keeping start and end visible.
|
* Truncate a long string in the middle, keeping start and end visible.
|
||||||
*/
|
*/
|
||||||
function truncateMiddle(s: string, maxLen: number): string {
|
function truncateMiddle(s: string, maxLen: number): string {
|
||||||
if (s.length <= maxLen) return s;
|
if (s.length <= maxLen) return s;
|
||||||
const half = Math.floor((maxLen - 3) / 2);
|
const half = Math.floor((maxLen - 3) / 2);
|
||||||
return s.slice(0, half) + "…" + s.slice(s.length - half);
|
return s.slice(0, half) + "…" + s.slice(s.length - half);
|
||||||
}
|
}
|
||||||
|
|||||||
456
src/progress.ts
456
src/progress.ts
@@ -1,6 +1,12 @@
|
|||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import type { ProgressState, PRDProgress, Task, Reflection, ToolUsage } from "./types";
|
import type {
|
||||||
|
ProgressState,
|
||||||
|
PRDProgress,
|
||||||
|
Task,
|
||||||
|
Reflection,
|
||||||
|
ToolUsage,
|
||||||
|
} from "./types";
|
||||||
import { ensureDir } from "./utils";
|
import { ensureDir } from "./utils";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -8,258 +14,264 @@ import { ensureDir } from "./utils";
|
|||||||
* e.g., "tasks/feature-x/README.md" → "tasks-feature-x-README"
|
* e.g., "tasks/feature-x/README.md" → "tasks-feature-x-README"
|
||||||
*/
|
*/
|
||||||
export function derivePRDKey(projectDir: string, sourcePath: string): string {
|
export function derivePRDKey(projectDir: string, sourcePath: string): string {
|
||||||
const rel = path.relative(projectDir, sourcePath);
|
const rel = path.relative(projectDir, sourcePath);
|
||||||
return rel.replace(/[^a-zA-Z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
|
return rel
|
||||||
|
.replace(/[^a-zA-Z0-9_-]/g, "-")
|
||||||
|
.replace(/-+/g, "-")
|
||||||
|
.replace(/^-|-$/g, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages persistent progress state for a ralph execution.
|
* Manages persistent progress state for a ralph execution.
|
||||||
* State is stored as JSON in .ralph/progress.json.
|
* State is stored as JSON in .ralpi/progress.json.
|
||||||
* Supports multiple PRDs in progress simultaneously via the `prds` field.
|
* Supports multiple PRDs in progress simultaneously via the `prds` field.
|
||||||
* Falls back to legacy flat format for backward compatibility.
|
* Falls back to legacy flat format for backward compatibility.
|
||||||
*/
|
*/
|
||||||
export class ProgressTracker {
|
export class ProgressTracker {
|
||||||
private statePath: string;
|
private statePath: string;
|
||||||
private state: ProgressState;
|
private state: ProgressState;
|
||||||
private prdKey: string;
|
private prdKey: string;
|
||||||
|
|
||||||
constructor(projectDir: string, sourcePath: string, prdKey?: string) {
|
constructor(projectDir: string, sourcePath: string, prdKey?: string) {
|
||||||
const stateDir = path.join(projectDir, ".ralph");
|
const stateDir = path.join(projectDir, ".ralpi");
|
||||||
ensureDir(stateDir);
|
ensureDir(stateDir);
|
||||||
this.statePath = path.join(stateDir, "progress.json");
|
this.statePath = path.join(stateDir, "progress.json");
|
||||||
this.prdKey = prdKey ?? derivePRDKey(projectDir, sourcePath);
|
this.prdKey = prdKey ?? derivePRDKey(projectDir, sourcePath);
|
||||||
this.state = this.loadOrCreate(sourcePath);
|
this.state = this.loadOrCreate(sourcePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Load existing state or create a fresh one */
|
/** Load existing state or create a fresh one */
|
||||||
private loadOrCreate(sourcePathHint: string): ProgressState {
|
private loadOrCreate(sourcePathHint: string): ProgressState {
|
||||||
if (fs.existsSync(this.statePath)) {
|
if (fs.existsSync(this.statePath)) {
|
||||||
try {
|
try {
|
||||||
const raw = fs.readFileSync(this.statePath, "utf-8");
|
const raw = fs.readFileSync(this.statePath, "utf-8");
|
||||||
const parsed = JSON.parse(raw) as ProgressState;
|
const parsed = JSON.parse(raw) as ProgressState;
|
||||||
|
|
||||||
// Multi-PRD mode: check if we have a PRD entry
|
// Multi-PRD mode: check if we have a PRD entry
|
||||||
if (parsed.prds?.[this.prdKey]) {
|
if (parsed.prds?.[this.prdKey]) {
|
||||||
// Found PRD entry — use it, but keep legacy fields for compat
|
// Found PRD entry — use it, but keep legacy fields for compat
|
||||||
return parsed;
|
return parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Legacy flat mode: check if the source path matches
|
// Legacy flat mode: check if the source path matches
|
||||||
if (path.resolve(parsed.sourcePath) === path.resolve(sourcePathHint)) {
|
if (path.resolve(parsed.sourcePath) === path.resolve(sourcePathHint)) {
|
||||||
// Migrate legacy state to PRD mode
|
// Migrate legacy state to PRD mode
|
||||||
parsed.prds = {
|
parsed.prds = {
|
||||||
[this.prdKey]: {
|
[this.prdKey]: {
|
||||||
sourcePath: parsed.sourcePath,
|
sourcePath: parsed.sourcePath,
|
||||||
tasks: parsed.tasks,
|
tasks: parsed.tasks,
|
||||||
startedAt: parsed.startedAt,
|
startedAt: parsed.startedAt,
|
||||||
lastUpdatedAt: parsed.lastUpdatedAt,
|
lastUpdatedAt: parsed.lastUpdatedAt,
|
||||||
paused: parsed.paused,
|
paused: parsed.paused,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
return parsed;
|
return parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Different PRD — create new entry alongside existing ones
|
// Different PRD — create new entry alongside existing ones
|
||||||
if (parsed.prds) {
|
if (parsed.prds) {
|
||||||
parsed.prds[this.prdKey] = this.freshPRD(sourcePathHint);
|
parsed.prds[this.prdKey] = this.freshPRD(sourcePathHint);
|
||||||
return parsed;
|
return parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Legacy flat state exists but for a different source — promote it to PRD mode
|
// Legacy flat state exists but for a different source — promote it to PRD mode
|
||||||
const legacyKey = derivePRDKey(path.dirname(this.statePath), parsed.sourcePath);
|
const legacyKey = derivePRDKey(
|
||||||
parsed.prds = {
|
path.dirname(this.statePath),
|
||||||
[legacyKey]: {
|
parsed.sourcePath,
|
||||||
sourcePath: parsed.sourcePath,
|
);
|
||||||
tasks: parsed.tasks,
|
parsed.prds = {
|
||||||
startedAt: parsed.startedAt,
|
[legacyKey]: {
|
||||||
lastUpdatedAt: parsed.lastUpdatedAt,
|
sourcePath: parsed.sourcePath,
|
||||||
paused: parsed.paused,
|
tasks: parsed.tasks,
|
||||||
},
|
startedAt: parsed.startedAt,
|
||||||
[this.prdKey]: this.freshPRD(sourcePathHint),
|
lastUpdatedAt: parsed.lastUpdatedAt,
|
||||||
};
|
paused: parsed.paused,
|
||||||
return parsed;
|
},
|
||||||
} catch {
|
[this.prdKey]: this.freshPRD(sourcePathHint),
|
||||||
// Fall through to create new
|
};
|
||||||
}
|
return parsed;
|
||||||
}
|
} catch {
|
||||||
|
// Fall through to create new
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return this.freshState(sourcePathHint);
|
return this.freshState(sourcePathHint);
|
||||||
}
|
}
|
||||||
|
|
||||||
private freshPRD(sourcePath: string): PRDProgress {
|
private freshPRD(sourcePath: string): PRDProgress {
|
||||||
return {
|
return {
|
||||||
sourcePath,
|
sourcePath,
|
||||||
tasks: {},
|
tasks: {},
|
||||||
startedAt: new Date().toISOString(),
|
startedAt: new Date().toISOString(),
|
||||||
lastUpdatedAt: new Date().toISOString(),
|
lastUpdatedAt: new Date().toISOString(),
|
||||||
paused: false,
|
paused: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private freshState(sourcePath: string): ProgressState {
|
private freshState(sourcePath: string): ProgressState {
|
||||||
return {
|
return {
|
||||||
sourcePath,
|
sourcePath,
|
||||||
tasks: {},
|
tasks: {},
|
||||||
startedAt: new Date().toISOString(),
|
startedAt: new Date().toISOString(),
|
||||||
lastUpdatedAt: new Date().toISOString(),
|
lastUpdatedAt: new Date().toISOString(),
|
||||||
paused: false,
|
paused: false,
|
||||||
prds: {
|
prds: {
|
||||||
[this.prdKey]: {
|
[this.prdKey]: {
|
||||||
sourcePath,
|
sourcePath,
|
||||||
tasks: {},
|
tasks: {},
|
||||||
startedAt: new Date().toISOString(),
|
startedAt: new Date().toISOString(),
|
||||||
lastUpdatedAt: new Date().toISOString(),
|
lastUpdatedAt: new Date().toISOString(),
|
||||||
paused: false,
|
paused: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get the PRD-scoped progress entry */
|
/** Get the PRD-scoped progress entry */
|
||||||
private getPRD(): PRDProgress {
|
private getPRD(): PRDProgress {
|
||||||
if (!this.state.prds) {
|
if (!this.state.prds) {
|
||||||
// Should not happen after loadOrCreate, but guard anyway
|
// Should not happen after loadOrCreate, but guard anyway
|
||||||
this.state.prds = { [this.prdKey]: this.freshPRD(this.state.sourcePath) };
|
this.state.prds = { [this.prdKey]: this.freshPRD(this.state.sourcePath) };
|
||||||
}
|
}
|
||||||
if (!this.state.prds[this.prdKey]) {
|
if (!this.state.prds[this.prdKey]) {
|
||||||
this.state.prds[this.prdKey] = this.freshPRD(this.state.sourcePath);
|
this.state.prds[this.prdKey] = this.freshPRD(this.state.sourcePath);
|
||||||
}
|
}
|
||||||
return this.state.prds[this.prdKey];
|
return this.state.prds[this.prdKey];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Save current state to disk */
|
/** Save current state to disk */
|
||||||
save(): void {
|
save(): void {
|
||||||
const prd = this.getPRD();
|
const prd = this.getPRD();
|
||||||
prd.lastUpdatedAt = new Date().toISOString();
|
prd.lastUpdatedAt = new Date().toISOString();
|
||||||
// Sync legacy flat fields with current PRD for backward compat
|
// Sync legacy flat fields with current PRD for backward compat
|
||||||
this.state.sourcePath = prd.sourcePath;
|
this.state.sourcePath = prd.sourcePath;
|
||||||
this.state.tasks = prd.tasks;
|
this.state.tasks = prd.tasks;
|
||||||
this.state.startedAt = prd.startedAt;
|
this.state.startedAt = prd.startedAt;
|
||||||
this.state.lastUpdatedAt = prd.lastUpdatedAt;
|
this.state.lastUpdatedAt = prd.lastUpdatedAt;
|
||||||
this.state.paused = prd.paused;
|
this.state.paused = prd.paused;
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
this.statePath,
|
this.statePath,
|
||||||
JSON.stringify(this.state, null, 2),
|
JSON.stringify(this.state, null, 2),
|
||||||
"utf-8",
|
"utf-8",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Mark a task as in progress */
|
/** Mark a task as in progress */
|
||||||
markInProgress(taskId: string): void {
|
markInProgress(taskId: string): void {
|
||||||
const prd = this.getPRD();
|
const prd = this.getPRD();
|
||||||
this.ensureTask(prd, taskId);
|
this.ensureTask(prd, taskId);
|
||||||
prd.tasks[taskId].status = "in_progress";
|
prd.tasks[taskId].status = "in_progress";
|
||||||
prd.tasks[taskId].startedAt = new Date().toISOString();
|
prd.tasks[taskId].startedAt = new Date().toISOString();
|
||||||
this.save();
|
this.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Mark a task as completed */
|
/** Mark a task as completed */
|
||||||
markCompleted(
|
markCompleted(
|
||||||
taskId: string,
|
taskId: string,
|
||||||
durationMs: number,
|
durationMs: number,
|
||||||
reflection?: Reflection,
|
reflection?: Reflection,
|
||||||
toolUsage?: ToolUsage,
|
toolUsage?: ToolUsage,
|
||||||
sessionFile?: string,
|
sessionFile?: string,
|
||||||
outputPreview?: string,
|
outputPreview?: string,
|
||||||
commitMessages?: string[],
|
commitMessages?: string[],
|
||||||
commitSummary?: string,
|
commitSummary?: string,
|
||||||
): void {
|
): void {
|
||||||
const prd = this.getPRD();
|
const prd = this.getPRD();
|
||||||
this.ensureTask(prd, taskId);
|
this.ensureTask(prd, taskId);
|
||||||
prd.tasks[taskId].status = "completed";
|
prd.tasks[taskId].status = "completed";
|
||||||
prd.tasks[taskId].completedAt = new Date().toISOString();
|
prd.tasks[taskId].completedAt = new Date().toISOString();
|
||||||
prd.tasks[taskId].durationMs = durationMs;
|
prd.tasks[taskId].durationMs = durationMs;
|
||||||
if (reflection) prd.tasks[taskId].reflection = reflection;
|
if (reflection) prd.tasks[taskId].reflection = reflection;
|
||||||
if (toolUsage) prd.tasks[taskId].toolUsage = toolUsage;
|
if (toolUsage) prd.tasks[taskId].toolUsage = toolUsage;
|
||||||
if (sessionFile) prd.tasks[taskId].sessionFile = sessionFile;
|
if (sessionFile) prd.tasks[taskId].sessionFile = sessionFile;
|
||||||
if (outputPreview) prd.tasks[taskId].outputPreview = outputPreview;
|
if (outputPreview) prd.tasks[taskId].outputPreview = outputPreview;
|
||||||
if (commitMessages) prd.tasks[taskId].commitMessages = commitMessages;
|
if (commitMessages) prd.tasks[taskId].commitMessages = commitMessages;
|
||||||
if (commitSummary) prd.tasks[taskId].commitSummary = commitSummary;
|
if (commitSummary) prd.tasks[taskId].commitSummary = commitSummary;
|
||||||
this.save();
|
this.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Mark a task as failed */
|
/** Mark a task as failed */
|
||||||
markFailed(taskId: string, error: string): void {
|
markFailed(taskId: string, error: string): void {
|
||||||
const prd = this.getPRD();
|
const prd = this.getPRD();
|
||||||
this.ensureTask(prd, taskId);
|
this.ensureTask(prd, taskId);
|
||||||
prd.tasks[taskId].status = "failed";
|
prd.tasks[taskId].status = "failed";
|
||||||
prd.tasks[taskId].error = error;
|
prd.tasks[taskId].error = error;
|
||||||
this.save();
|
this.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get task status */
|
/** Get task status */
|
||||||
getTaskStatus(taskId: string): Task["status"] {
|
getTaskStatus(taskId: string): Task["status"] {
|
||||||
const prd = this.getPRD();
|
const prd = this.getPRD();
|
||||||
return prd.tasks[taskId]?.status ?? "pending";
|
return prd.tasks[taskId]?.status ?? "pending";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get IDs of all completed tasks */
|
/** Get IDs of all completed tasks */
|
||||||
getCompletedTaskIds(): string[] {
|
getCompletedTaskIds(): string[] {
|
||||||
const prd = this.getPRD();
|
const prd = this.getPRD();
|
||||||
return Object.entries(prd.tasks)
|
return Object.entries(prd.tasks)
|
||||||
.filter(([, info]) => info.status === "completed")
|
.filter(([, info]) => info.status === "completed")
|
||||||
.map(([id]) => id);
|
.map(([id]) => id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get all reflections from completed tasks */
|
/** Get all reflections from completed tasks */
|
||||||
getAllReflections(): Reflection[] {
|
getAllReflections(): Reflection[] {
|
||||||
const prd = this.getPRD();
|
const prd = this.getPRD();
|
||||||
const reflections: Reflection[] = [];
|
const reflections: Reflection[] = [];
|
||||||
for (const info of Object.values(prd.tasks)) {
|
for (const info of Object.values(prd.tasks)) {
|
||||||
if (info.reflection) reflections.push(info.reflection);
|
if (info.reflection) reflections.push(info.reflection);
|
||||||
}
|
}
|
||||||
return reflections;
|
return reflections;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get reflections for specific dependency tasks */
|
/** Get reflections for specific dependency tasks */
|
||||||
getDependencyReflections(depIds: string[]): Reflection[] {
|
getDependencyReflections(depIds: string[]): Reflection[] {
|
||||||
const prd = this.getPRD();
|
const prd = this.getPRD();
|
||||||
return depIds
|
return depIds
|
||||||
.map((id) => prd.tasks[id]?.reflection)
|
.map((id) => prd.tasks[id]?.reflection)
|
||||||
.filter((r): r is Reflection => r !== undefined);
|
.filter((r): r is Reflection => r !== undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Increment retry count */
|
/** Increment retry count */
|
||||||
incrementRetry(taskId: string): number {
|
incrementRetry(taskId: string): number {
|
||||||
const prd = this.getPRD();
|
const prd = this.getPRD();
|
||||||
this.ensureTask(prd, taskId);
|
this.ensureTask(prd, taskId);
|
||||||
prd.tasks[taskId].retries++;
|
prd.tasks[taskId].retries++;
|
||||||
this.save();
|
this.save();
|
||||||
return prd.tasks[taskId].retries;
|
return prd.tasks[taskId].retries;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Set paused state */
|
/** Set paused state */
|
||||||
setPaused(paused: boolean): void {
|
setPaused(paused: boolean): void {
|
||||||
const prd = this.getPRD();
|
const prd = this.getPRD();
|
||||||
prd.paused = paused;
|
prd.paused = paused;
|
||||||
this.save();
|
this.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get the raw PRD state (for status display) */
|
/** Get the raw PRD state (for status display) */
|
||||||
getState(): PRDProgress {
|
getState(): PRDProgress {
|
||||||
return this.getPRD();
|
return this.getPRD();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get all PRDs (for multi-PRD status display) */
|
/** Get all PRDs (for multi-PRD status display) */
|
||||||
getAllPRDs(): Record<string, PRDProgress> {
|
getAllPRDs(): Record<string, PRDProgress> {
|
||||||
return this.state.prds ?? {};
|
return this.state.prds ?? {};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get the PRD key for this tracker */
|
/** Get the PRD key for this tracker */
|
||||||
getKey(): string {
|
getKey(): string {
|
||||||
return this.prdKey;
|
return this.prdKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reset all progress for this PRD */
|
/** Reset all progress for this PRD */
|
||||||
reset(): void {
|
reset(): void {
|
||||||
const prd = this.getPRD();
|
const prd = this.getPRD();
|
||||||
Object.assign(prd, this.freshPRD(prd.sourcePath));
|
Object.assign(prd, this.freshPRD(prd.sourcePath));
|
||||||
this.save();
|
this.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
private ensureTask(prd: PRDProgress, taskId: string): void {
|
private ensureTask(prd: PRDProgress, taskId: string): void {
|
||||||
if (!prd.tasks[taskId]) {
|
if (!prd.tasks[taskId]) {
|
||||||
prd.tasks[taskId] = { status: "pending", retries: 0 };
|
prd.tasks[taskId] = { status: "pending", retries: 0 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
10
src/types.ts
10
src/types.ts
@@ -137,9 +137,9 @@ export interface PRDProgress {
|
|||||||
|
|
||||||
// ─── Configuration ────────────────────────────────────────────────────────────
|
// ─── Configuration ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface RalphConfig {
|
export interface RalpiConfig {
|
||||||
paths: {
|
paths: {
|
||||||
/** Directory for ralph state files */
|
/** Directory for ralpi state files */
|
||||||
stateDir: string;
|
stateDir: string;
|
||||||
/** Directory for per-task reflections */
|
/** Directory for per-task reflections */
|
||||||
reflectionsDir: string;
|
reflectionsDir: string;
|
||||||
@@ -162,10 +162,10 @@ export interface RalphConfig {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_CONFIG: RalphConfig = {
|
export const DEFAULT_CONFIG: RalpiConfig = {
|
||||||
paths: {
|
paths: {
|
||||||
stateDir: ".ralph",
|
stateDir: ".ralpi",
|
||||||
reflectionsDir: ".ralph/reflections",
|
reflectionsDir: ".ralpi/reflections",
|
||||||
},
|
},
|
||||||
execution: {
|
execution: {
|
||||||
maxRetries: 3,
|
maxRetries: 3,
|
||||||
|
|||||||
18
src/utils.ts
18
src/utils.ts
@@ -1,7 +1,7 @@
|
|||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import type {
|
import type {
|
||||||
RalphConfig,
|
RalpiConfig,
|
||||||
PRDProgress,
|
PRDProgress,
|
||||||
ProgressState,
|
ProgressState,
|
||||||
ToolUsage,
|
ToolUsage,
|
||||||
@@ -39,7 +39,7 @@ export function writeFileSafe(filePath: string, content: string): void {
|
|||||||
// ─── Progress Discovery ─────────────────────────────────────────────────────
|
// ─── Progress Discovery ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find the nearest .ralph/progress.json by walking up from the given directory.
|
* Find the nearest .ralpi/progress.json by walking up from the given directory.
|
||||||
* For a specific sourcePath, finds the matching PRD entry.
|
* For a specific sourcePath, finds the matching PRD entry.
|
||||||
*/
|
*/
|
||||||
export function findProgressFile(
|
export function findProgressFile(
|
||||||
@@ -50,7 +50,7 @@ export function findProgressFile(
|
|||||||
const root = path.parse(current).root;
|
const root = path.parse(current).root;
|
||||||
|
|
||||||
while (current !== root) {
|
while (current !== root) {
|
||||||
const candidate = path.join(current, ".ralph", "progress.json");
|
const candidate = path.join(current, ".ralpi", "progress.json");
|
||||||
if (fs.existsSync(candidate)) {
|
if (fs.existsSync(candidate)) {
|
||||||
try {
|
try {
|
||||||
const raw = fs.readFileSync(candidate, "utf-8");
|
const raw = fs.readFileSync(candidate, "utf-8");
|
||||||
@@ -113,9 +113,9 @@ function parseSimpleYaml(content: string): Record<string, any> {
|
|||||||
* Deep merge configuration objects
|
* Deep merge configuration objects
|
||||||
*/
|
*/
|
||||||
function mergeConfig(
|
function mergeConfig(
|
||||||
defaults: RalphConfig,
|
defaults: RalpiConfig,
|
||||||
overrides: Record<string, any>,
|
overrides: Record<string, any>,
|
||||||
): RalphConfig {
|
): RalpiConfig {
|
||||||
const result = { ...defaults };
|
const result = { ...defaults };
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(overrides)) {
|
for (const [key, value] of Object.entries(overrides)) {
|
||||||
@@ -126,14 +126,14 @@ function mergeConfig(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result as RalphConfig;
|
return result as RalpiConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load configuration from .ralph/config.yaml or return defaults
|
* Load configuration from .ralpi/config.yaml or return defaults
|
||||||
*/
|
*/
|
||||||
export function loadConfig(projectDir: string): RalphConfig {
|
export function loadConfig(projectDir: string): RalpiConfig {
|
||||||
const configPath = path.join(projectDir, ".ralph", "config.yaml");
|
const configPath = path.join(projectDir, ".ralpi", "config.yaml");
|
||||||
|
|
||||||
// Return defaults silently when config file does not exist
|
// Return defaults silently when config file does not exist
|
||||||
if (!fs.existsSync(configPath)) {
|
if (!fs.existsSync(configPath)) {
|
||||||
|
|||||||
Reference in New Issue
Block a user