Compare commits
9 Commits
7253e51fb9
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 723b7be84a | |||
| bed74f9d33 | |||
| 3a3c293aa7 | |||
| b9a12931fe | |||
| f9b57ec2ed | |||
| b0749467c9 | |||
| f361f05f96 | |||
| 9f1250d1a1 | |||
| fc2f8a879f |
100
AGENTS.md
100
AGENTS.md
@@ -2,7 +2,9 @@
|
||||
|
||||
## What this is
|
||||
|
||||
A Pi coding agent extension that registers the `/ralpi` slash command. Not a standalone app — it runs inside Pi's extension host.
|
||||
A Pi coding agent extension that registers the `/ralpi` slash commands
|
||||
(`/ralpi`, `/ralpi-run`, `/ralpi-plan`, `/ralpi-resume`, `/ralpi-reset`).
|
||||
Not a standalone app — it runs inside Pi's extension host.
|
||||
|
||||
## Type checking
|
||||
|
||||
@@ -10,6 +12,8 @@ A Pi coding agent extension that registers the `/ralpi` slash command. Not a sta
|
||||
npm run typecheck # tsc --noEmit
|
||||
```
|
||||
|
||||
Tests: `bun test` (parser and DAG suites in `tests/`).
|
||||
|
||||
No build step needed — Pi loads extensions via [jiti](https://github.com/unjs/jiti), which compiles TypeScript at runtime. `index.ts` is the entry point directly.
|
||||
|
||||
## Entry point
|
||||
@@ -23,22 +27,41 @@ The extension imports from Pi SDK packages (not in `package.json` — provided b
|
||||
- `@earendil-works/pi-coding-agent` — `ExtensionAPI`, `ExtensionContext`, `createAgentSession`, etc.
|
||||
- `@earendil-works/pi-tui` — `Box`, `Text` for custom message renderer
|
||||
|
||||
The only real npm dependency is `yaml` (^2.4.0).
|
||||
The only real npm dependency is `yaml` (^2.4.0). It is used for parsing YAML
|
||||
task files (`src/parser.ts`) and config files (`parseSimpleYaml` in
|
||||
`src/utils.ts`, which falls back to a flat key:value parser when the package
|
||||
is unavailable).
|
||||
|
||||
## Source structure
|
||||
|
||||
- `index.ts` — extension entry, command routing, UI registration, reload detection
|
||||
- `index.ts` — extension entry, command registration (`ralpi`, `ralpi-run`,
|
||||
`ralpi-plan`, `ralpi-resume`, `ralpi-reset`), execution-mode + loop-option
|
||||
prompts, reload auto-resume via `session_start`, progress message renderer
|
||||
- `src/` — all logic modules:
|
||||
- `parser.ts` — task file parsing (Fio, checkbox, YAML formats)
|
||||
- `dag.ts` — Kahn's algorithm dependency resolution, batch planning
|
||||
- `executor.ts` — task execution, retry, parallel/sequential modes
|
||||
- `progress.ts` — `.ralpi/progress.json` state management
|
||||
- `parser.ts` — task file parsing (Fio/README numbered, phased, checkbox,
|
||||
YAML formats), dependency + parallel-group + timeout parsing,
|
||||
`updateTaskInFile()` for PRD checkbox updates
|
||||
- `dag.ts` — Kahn's algorithm dependency resolution, group-aware batching,
|
||||
cycle detection, sequential/parallel plan builders
|
||||
- `executor.ts` — task execution, parallel/sequential modes, model
|
||||
round-robin + failover, review-gated loop, worktree orchestration,
|
||||
batch-level conflict resolution
|
||||
- `review.ts` — review verdict extraction (`## REVIEW VERDICT`), review
|
||||
save/load to `.ralpi/reviews/`
|
||||
- `worktree.ts` — git worktree create/merge/cleanup helpers, stale-worktree
|
||||
cleanup, `finalizeCommittedWorktrees()`
|
||||
- `progress.ts` — `.ralpi/progress.json` state management (multi-PRD)
|
||||
- `prompts.ts` — prompt generation for spawned agent sessions
|
||||
- `reflection.ts` — reflection extraction from agent output
|
||||
- `utils.ts` — config loading, progress discovery, `runAgentSession()`
|
||||
- `utils.ts` — config loading, progress/PRD discovery, `runAgentSession()`,
|
||||
model resolution (`resolveModelSpec`), loop-active marker
|
||||
- `types.ts` — all interfaces and `DEFAULT_CONFIG`
|
||||
- `widget-batcher.ts` — debounced widget updates for parallel tasks
|
||||
- `constants.ts` — static constants
|
||||
- `task-manager-prompt.ts` — loads and expands the bundled
|
||||
`prompts/task-manager.md` template for `/ralpi-plan`
|
||||
- `constants.ts` — static constants (slash command, task file names,
|
||||
reflection/review patterns)
|
||||
- `tests/` — bun test suites for parser and DAG behavior
|
||||
- `skills/ralpi-use.md` — Pi skill definition for task execution
|
||||
- `prompts/task-manager.md` — Pi prompt for task planning
|
||||
|
||||
@@ -47,27 +70,70 @@ The only real npm dependency is `yaml` (^2.4.0).
|
||||
All runtime state lives in `.ralpi/` in the **project directory** (not this extension directory):
|
||||
|
||||
- `.ralpi/progress.json` — execution progress, supports multiple PRDs
|
||||
- `.ralpi/loop-active.json` — marker written while a loop runs; drives
|
||||
auto-resume after a session reload
|
||||
- `.ralpi/reflections/` — per-task reflection JSON files
|
||||
- `.ralpi/reviews/<prdKey>/` — full review output JSON (only when
|
||||
`saveReviews` is on)
|
||||
- `.ralpi/prompts/` — generated prompts (timestamped, for debugging)
|
||||
- `.ralpi/sessions/` — full session transcripts
|
||||
- `.ralpi/config.yaml` — project-level config (optional)
|
||||
|
||||
There is no `.ralpi/sessions/` directory anymore — full task output is shown
|
||||
inline via expandable `ralpi-progress` chat messages, and review output is
|
||||
persisted under `.ralpi/reviews/`.
|
||||
|
||||
## Task ID convention
|
||||
|
||||
Task IDs are zero-padded strings (`"01"`, `"02"`, etc.). The parser prepends `0` to parsed digits. Never use raw numeric IDs.
|
||||
Task IDs are zero-padded strings (`"01"`, `"02"`, etc.) with an optional
|
||||
single lowercase letter suffix for sub-tasks (`"02b"`, `"02c"`). The parser
|
||||
normalizes `2b` → `02b` (see `normalizeTaskId` in `src/parser.ts`). Never
|
||||
use raw numeric IDs.
|
||||
|
||||
## Command routing
|
||||
|
||||
`/ralpi` with no args → plan. First token looks like a path (`@path`, `./path`, `.md`, etc.) → run. Otherwise dispatches to subcommand (`run`, `plan`, `resume`, `reset`).
|
||||
- `/ralpi` — no args → show plan for `README.md`; first token looks like a
|
||||
path (`@path`, `./path`, `.md`, `.yaml`, etc.) → run; anything else →
|
||||
error suggesting the dash commands
|
||||
- `/ralpi-run [task-file]` — run tasks (auto-resumes when progress already
|
||||
exists for the file; otherwise prompts for execution mode + loop options)
|
||||
- `/ralpi-plan [prompt]` — loads the bundled `prompts/task-manager.md`
|
||||
template and sends it as a user message. Pi's `sendUserMessage()` sends
|
||||
with `expandPromptTemplates: false`, so the extension does its own
|
||||
frontmatter stripping and `$@`/`$1` arg substitution
|
||||
(`loadTaskManagerPrompt` in `src/task-manager-prompt.ts`)
|
||||
- `/ralpi-resume [task-file]` — resume from persisted progress; prompts for
|
||||
the PRD when multiple loops have progress. Reuses the loop snapshot in
|
||||
`loop-active.json` (mode + autoCommit/autoReview/saveReviews) to resume
|
||||
non-interactively
|
||||
- `/ralpi-reset [task-file]` — reset execution progress (does not modify the PRD)
|
||||
|
||||
The old `/ralpi plan|resume|reset` subcommand dispatch, plus `status` and
|
||||
`next`, were removed.
|
||||
|
||||
## Config
|
||||
|
||||
Read from `.ralpi/config.yaml` in project directory (and global `~/.pi/ralpi/config.yaml`). Falls back to `DEFAULT_CONFIG` in `src/types.ts` when files are missing. Config is loaded at `projectDir` level, not extension level.
|
||||
Read from `.ralpi/config.yaml` in project directory (and global
|
||||
`~/.pi/ralpi/config.yaml`), project overrides global. Falls back to
|
||||
`DEFAULT_CONFIG` in `src/types.ts` when files are missing. Config is loaded
|
||||
at `projectDir` level, not extension level. Execution keys explicitly
|
||||
present in a loaded YAML are tracked in `execution.explicitKeys` so the
|
||||
loop-startup interactive prompts (`selectLoopOptions` in `index.ts`) can be
|
||||
skipped for fields the user already set.
|
||||
|
||||
Key config fields in `execution`:
|
||||
|
||||
- `autoCommit` / `autoReview` — toggle follow-up commit and review agent sessions (also selectable at loop startup via `selectLoopOptions`)
|
||||
- `models` — round-robin model list for parallel mode
|
||||
- `implModel` / `commitModel` / `reviewModel` — `<provider>/<model>` strings resolved via `resolveModelSpec` in `utils.ts`
|
||||
- `autoCommit` / `autoReview` / `saveReviews` — loop options (selectable at
|
||||
loop startup via `selectLoopOptions`; review is asked FIRST, commit is
|
||||
mandated when review is on)
|
||||
- `models` — slot-aware round-robin model list for parallel mode, with
|
||||
automatic failover to the next model per task
|
||||
- `implModel` / `commitModel` / `reviewModel` — `<provider>/<model>` strings
|
||||
resolved via `resolveModelSpec` in `utils.ts`
|
||||
- `maxReviewRetries` / `reviewBlockOnFail` — review-gated loop retry behavior
|
||||
- `worktrees` — `"never" | "parallel" | "always"` git worktree isolation
|
||||
(default `"parallel"`; see `shouldUseWorktrees` in `src/executor.ts`)
|
||||
- `commitTimeoutMs` / `reviewTimeoutMs` — timeouts for follow-up sessions
|
||||
- `loopTimeoutMs` — max total loop duration in ms (0 = no limit; checked between batches in `executePlanBatches`)
|
||||
- `loopTimeoutMs` — max total loop duration in ms (0 = no limit; checked
|
||||
between batches in `executePlanBatches`)
|
||||
- `timeoutMs` — per-task execution timeout
|
||||
- `prompts.projectContext` / `prompts.reflectionPrompt` — prompt-level settings
|
||||
|
||||
197
README.md
197
README.md
@@ -8,35 +8,59 @@ pi install npm:@mikefreno/ralpi
|
||||
|
||||
## Features
|
||||
|
||||
- **Parallel batching**: Independent tasks in each batch can run concurrently
|
||||
- **Persistent progress**: Execution state saved to `.ralpi/progress.json`
|
||||
- **DAG-based execution**: Tasks ordered via dependencies (arrow notation, natural language, "must be done before", or YAML)
|
||||
- **Parallel batching**: Independent tasks in each batch run concurrently, round-robin across configured models
|
||||
- **Persistent progress**: Execution state saved to `.ralpi/progress.json`, supporting multiple PRDs simultaneously
|
||||
- **Resume & auto-resume**: `/ralpi-resume` continues paused execution; a session reload mid-loop auto-resumes via `.ralpi/loop-active.json`
|
||||
- **Reflection system**: Each task produces a reflection for downstream tasks
|
||||
- **Retry with backoff**: Failed tasks retry with exponential backoff
|
||||
- **Multiple formats**: Supports simple checkboxes, and YAML
|
||||
- **Tool usage tracking**: Detects and reports tool usage (read, write, edit, bash) from task execution
|
||||
- **Configurable timeouts**: Task-level timeouts via meta blocks, with global fallback
|
||||
- **Session saving**: Saves full task output for expandable session review
|
||||
- **Resume auto-discovery**: Automatically finds and resumes interrupted execution
|
||||
- **Phased plans**: `## Phase N — Title` sections add implicit phase-boundary dependencies
|
||||
- **Model failover**: Unreachable providers cycle to the next model in the list before a task fails
|
||||
- **Auto-commit / auto-review loop**: Optional per-task commit and review-gated re-execution until pass
|
||||
- **Worktree isolation**: Parallel tasks run in separate git worktrees so they can't stomp each other, with batch-level merge-conflict resolution
|
||||
- **Multiple formats**: Fio README (numbered + dependencies), phased, simple checkboxes, and YAML
|
||||
- **Tool usage tracking**: Reports read/write/edit/bash usage from task execution
|
||||
- **Configurable timeouts**: Task-level timeouts (inline, meta block, or YAML) with global fallback
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
/ralpi [task-file] # Execute all tasks
|
||||
/ralpi plan # Alias to /task-manager to plan new tasks
|
||||
/ralpi resume # Resume paused execution
|
||||
/ralpi reset # Reset progress and .ralpi directory - does not modify PRD
|
||||
/ralpi [task-file] # No args → show plan for README.md; path arg → run tasks
|
||||
/ralpi-run [task-file] # Execute tasks from a task file
|
||||
/ralpi-plan [prompt] # Open the Task Manager to plan tasks
|
||||
/ralpi-resume [task-file] # Resume paused/interrupted execution
|
||||
/ralpi-reset [task-file] # Reset execution progress — does not modify the PRD
|
||||
```
|
||||
|
||||
### Highly recommended to use the task-manager prompt for prd construction, it's output pairs perfectly - /task-manager or /ralpi plan
|
||||
`/ralpi` with no arguments shows the execution plan for the default task file. When the first token looks like a path (`@path`, `./path`, `path/file.md`, `.yaml`, etc.) it routes to `/ralpi-run`. Everything else is handled by the dedicated dash commands above (the old `/ralpi plan|resume|reset` subcommand syntax is gone).
|
||||
|
||||
> The task-manager prompt (`/ralpi-plan`) pairs perfectly with ralpi's task file formats — use it for PRD construction.
|
||||
|
||||
## Tasks
|
||||
|
||||
### Simple Checkbox Format
|
||||
|
||||
```markdown
|
||||
- [ ] 01: Setup project structure
|
||||
- [ ] 02: Implement auth
|
||||
- [ ] 03: Build API
|
||||
- [ ] Setup project structure
|
||||
- [ ] Implement auth
|
||||
- [ ] Build API
|
||||
```
|
||||
|
||||
Checkbox-only files get sequential IDs (`01`, `02`, ...). Status characters: `[ ]` pending, `[x]` done, `[~]` in progress, `[!]` failed, `[-]` skipped.
|
||||
|
||||
### Fio Format (numbered tasks + dependencies)
|
||||
|
||||
```markdown
|
||||
# Build a web application
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] 01 — Setup project structure
|
||||
- [ ] 02 — Implement auth
|
||||
- [ ] 03 — Build API
|
||||
|
||||
## Dependencies
|
||||
|
||||
01 -> 02, 03
|
||||
```
|
||||
|
||||
### YAML Format
|
||||
@@ -72,40 +96,98 @@ Use lettered sub-tasks when you discover mid-stream that a step needs to be
|
||||
split. They let you preserve sibling numbering (`01`, `02`, `03`, ...) while
|
||||
adding granularity between two existing steps.
|
||||
|
||||
## Phases
|
||||
|
||||
`## Phase N — Title` headings group tasks into phases and add an implicit
|
||||
dependency from the first task of each phase to the last task of the
|
||||
previous one, so phases always run in order:
|
||||
|
||||
```markdown
|
||||
## Phase 1 — Push-to-Talk MVP
|
||||
|
||||
- [ ] 01 — Voice capture
|
||||
- [ ] 02 — Transmission
|
||||
|
||||
## Phase 2 — Group Chat
|
||||
|
||||
- [ ] 03 — Channels
|
||||
- [ ] 04 — Presence
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
Dependency lines live in a `## Dependencies` section (or a plain
|
||||
`Dependencies` heading). Multiple formats are supported and can be mixed.
|
||||
|
||||
### Arrow Notation (recommended)
|
||||
|
||||
```
|
||||
1 -> 2,3,4
|
||||
5 -> 6
|
||||
This means: "Task 1 must complete before tasks 2, 3, and 4 can start."
|
||||
```
|
||||
|
||||
"Task 1 must complete before tasks 2, 3, and 4 can start." Also supports
|
||||
chains (`03 -> 04 -> 05`) and multi-prereq sources (`05, 07, 08 -> 13`).
|
||||
|
||||
### Natural Language
|
||||
|
||||
```
|
||||
13 depends on 17, 18, 19, 20
|
||||
14 depends on 13, 15, 16
|
||||
22, 23, 24 depend on 21
|
||||
```
|
||||
|
||||
This means: "Task 13 depends on tasks 17, 18, 19, and 20."
|
||||
"Task 13 depends on tasks 17, 18, 19, and 20." `also depends on` is accepted.
|
||||
|
||||
### Parallel Groups (informational only)
|
||||
### "must be done before"
|
||||
|
||||
1, 2, 3, 4 can be done in parallel
|
||||
```
|
||||
21 must be done before 22, 23, 24
|
||||
02, 03 must be done before 04
|
||||
```
|
||||
|
||||
### Parallel Groups
|
||||
|
||||
```
|
||||
1, 2, 3, 4 can be done in parallel (Play Store prep)
|
||||
5, 6, 7, 8 can be done in parallel
|
||||
```
|
||||
|
||||
Note: These lines are ignored by the parser. Use explicit dependencies to control execution order.
|
||||
Tasks listed in a parallel group are allowed to run concurrently. Group
|
||||
declarations imply no cross-group dependencies, and intra-group
|
||||
dependencies are still respected — group-aware batching produces a plan
|
||||
where tasks from any group run as soon as their dependencies are
|
||||
satisfied.
|
||||
|
||||
## Configuration
|
||||
|
||||
### Task-Level Timeout
|
||||
|
||||
You can set a timeout for individual tasks using a meta block in the task file:
|
||||
Timeouts can be set inline on the task line, as an inline comment, via a
|
||||
meta block in the Dependencies section, or in YAML:
|
||||
|
||||
```markdown
|
||||
- [ ] 01: Setup project structure
|
||||
timeout: 10m
|
||||
- [ ] 01 — Setup project structure timeout: 10m
|
||||
- [ ] 02 — Implement auth # timeout=30s
|
||||
```
|
||||
|
||||
Supported formats: `10m` (minutes), `600s` (seconds), `3600000` (milliseconds)
|
||||
```markdown
|
||||
## Dependencies
|
||||
|
||||
01 -> 02
|
||||
01 [timeout] = 10m
|
||||
```
|
||||
|
||||
```yaml
|
||||
tasks:
|
||||
- id: "01"
|
||||
title: Setup project structure
|
||||
timeout: 15m
|
||||
```
|
||||
|
||||
Supported units: `m` / `min` (minutes), `s` (seconds), `ms` (milliseconds).
|
||||
Bare numbers default to minutes; in YAML, numeric values ≥ 1000 are treated
|
||||
as milliseconds.
|
||||
|
||||
### Config files
|
||||
|
||||
@@ -114,23 +196,31 @@ Supported formats: `10m` (minutes), `600s` (seconds), `3600000` (milliseconds)
|
||||
| **Global** | `~/.pi/ralpi/config.yaml` |
|
||||
| **Project** | `./.ralpi/config.yaml` |
|
||||
|
||||
Project config overrides global, which overrides defaults. Keys set
|
||||
explicitly in YAML skip the corresponding loop-startup prompt.
|
||||
|
||||
```yaml
|
||||
execution:
|
||||
maxParallel: 3 # ralpi-level concurrency only
|
||||
models: # round-robin in <provider>/<model> format
|
||||
- google/gemini-3.5-flash # 1st and 3rd task in parallel
|
||||
- openai/gpt-5.5 # 2nd task in parallel
|
||||
autoCommit: true # commit after each task (mandated when autoReview is on; standalone toggle when off)
|
||||
autoReview: false # commit → review → loop on fail → merge on pass
|
||||
implModel: "" # model for task impl (sequential mode, empty = inherit parent)
|
||||
commitModel: "" # model for commit sessions (empty = inherit task model)
|
||||
reviewModel: "" # model for review sessions (empty = inherit task model)
|
||||
timeoutMs: 0 # per-task timeout in ms (0 = inherit Pi's defaults)
|
||||
commitTimeoutMs: 60000 # timeout for auto-commit agent sessions
|
||||
reviewTimeoutMs: 120000 # timeout for auto-review agent sessions
|
||||
loopTimeoutMs: 0 # max total loop duration in ms (0 = no limit)
|
||||
maxParallel: 3 # ralpi-level concurrency only (0 = unlimited)
|
||||
models: # round-robin for parallel tasks, <provider>/<model>
|
||||
- anthropic/claude-sonnet-4
|
||||
- openai/gpt-4o
|
||||
autoCommit: true # commit after each task (mandated when autoReview is on)
|
||||
autoReview: false # commit → review → loop on fail → merge on pass
|
||||
saveReviews: false # persist full review output to .ralpi/reviews/ (only with autoReview)
|
||||
maxReviewRetries: 2 # re-executions on a 'fail' verdict before giving up
|
||||
reviewBlockOnFail: false # true = mark task failed after retries exhausted instead of merging
|
||||
implModel: "" # model for task impl (empty = inherit parent session model)
|
||||
commitModel: "" # model for commit sessions (empty = inherit task model)
|
||||
reviewModel: "" # model for review sessions (empty = inherit task model)
|
||||
timeoutMs: 0 # per-task timeout in ms (0 = inherit Pi's defaults)
|
||||
commitTimeoutMs: 0 # timeout for auto-commit agent sessions (0 = inherit)
|
||||
reviewTimeoutMs: 0 # timeout for auto-review agent sessions (0 = inherit)
|
||||
loopTimeoutMs: 0 # max total loop duration in ms (0 = no limit; checked between batches)
|
||||
worktrees: parallel # "never" | "parallel" (default) | "always" — git worktree isolation
|
||||
prompts:
|
||||
projectContext: "Additional context for all tasks"
|
||||
reflectionPrompt: "" # custom suffix for reflection extraction
|
||||
```
|
||||
|
||||
> `execution.models` uses slot-aware round-robin: with 3 models and 2 concurrent
|
||||
@@ -140,8 +230,9 @@ prompts:
|
||||
> **Automatic failover**: if a provider/API is unreachable (rate limit, 503, etc.),
|
||||
> the task automatically cycles to the next model in the list without counting it
|
||||
> as a task failure. Each model is tried once before the task is marked as failed.
|
||||
> **NOTE**: this is only used in parallel execution, in sequential mode the
|
||||
> parent pi session's model is used
|
||||
> **NOTE**: model lists are only used in parallel execution. In sequential mode
|
||||
> (or parallel mode with no `models` list) the parent pi session's model is used,
|
||||
> unless `implModel` is set.
|
||||
|
||||
#### Auto-review and Auto-commit
|
||||
|
||||
@@ -158,19 +249,27 @@ state — original work plus fixes. On pass, the changes are already
|
||||
committed and the worktree merges.
|
||||
|
||||
When `autoReview` is disabled, `autoCommit` runs a follow-up commit
|
||||
agent after each task with no review. Both options can be overridden at
|
||||
loop startup via a selection prompt (config YAML values are honored
|
||||
without prompting when set explicitly).
|
||||
agent after each task with no review. With `autoReview` on, the user is
|
||||
also asked whether to persist full review output to
|
||||
`.ralpi/reviews/<prdKey>/<task-id>.json` (`saveReviews` — this is what
|
||||
enables review feedback recovery when resuming interrupted loops). Both
|
||||
options can be overridden at loop startup via a selection prompt (config
|
||||
YAML values are honored without prompting when set explicitly).
|
||||
|
||||
`commitModel` and `reviewModel` accept `<provider>/<model>` strings (e.g.
|
||||
`anthropic/claude-sonnet-4`) resolved via the model registry. When empty, the
|
||||
task's model is inherited. `implModel` sets the model for task implementation
|
||||
in sequential mode (overridden by `execution.models` round-robin in parallel
|
||||
mode).
|
||||
(used whenever no round-robin model is assigned — sequential mode, or parallel
|
||||
mode with an empty `models` list; overridden by `execution.models` round-robin
|
||||
in parallel mode).
|
||||
|
||||
## State Files
|
||||
|
||||
- `.ralpi/progress.json` - Execution progress
|
||||
- `.ralpi/reflections/` - Per-task reflections
|
||||
- `.ralpi/prompts/` - Generated prompts
|
||||
- `.ralpi/sessions/` - Full task output for review
|
||||
```
|
||||
.ralpi/progress.json # Execution progress (supports multiple PRDs)
|
||||
.ralpi/loop-active.json # Active-loop marker used for auto-resume after a reload
|
||||
.ralpi/reflections/ # Per-task reflections
|
||||
.ralpi/reviews/<prdKey>/ # Full review output (when saveReviews is on)
|
||||
.ralpi/prompts/ # Generated prompts (timestamped, for debugging)
|
||||
.ralpi/config.yaml # Project-level config (optional)
|
||||
```
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mikefreno/ralpi",
|
||||
"version": "0.3.0",
|
||||
"version": "0.5.0",
|
||||
"description": "Execute tasks from task files/PRD's using DAG-based dependency resolution with persistent progress tracking",
|
||||
"keywords": [
|
||||
"pi-package",
|
||||
|
||||
@@ -1,38 +1,44 @@
|
||||
---
|
||||
description: Executes individual tasks from ralpi task files using DAG-based dependency resolution, with progress tracking and reflection support
|
||||
description: Execute tasks from ralpi task files / PRDs using DAG-based dependency resolution, with persistent progress tracking and reflection support
|
||||
---
|
||||
|
||||
# ralpi-task
|
||||
|
||||
Execute a single task from a ralpi task file.
|
||||
Execute tasks from a ralpi task file (checkbox, Fio, phased, or YAML) using DAG-based dependency resolution, with persistent progress tracking and reflection support.
|
||||
|
||||
## When to Use
|
||||
|
||||
- User asks to execute a specific task from a task file
|
||||
- User provides a task ID and wants to run it
|
||||
- User wants to run the next task in sequence
|
||||
- User asks to execute a task file, PRD, or task list (e.g. "run the tasks", "execute the plan")
|
||||
- User wants to run a full ralpi loop from a task file in the project
|
||||
- User wants to resume an interrupted or paused ralpi run
|
||||
- User wants to plan new tasks with the task-manager prompt
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
/ralpi run [task-file] # Run all tasks
|
||||
/ralpi next [task-file] # Run next batch
|
||||
/ralpi status [task-file] # Check progress
|
||||
/ralpi [task-file] # No args → show plan; path arg → run all tasks
|
||||
/ralpi-run [task-file] # Run all tasks (auto-resumes if progress exists)
|
||||
/ralpi-resume [task-file] # Resume paused/interrupted execution
|
||||
/ralpi-plan [prompt] # Open the Task Manager to plan tasks
|
||||
```
|
||||
|
||||
Note: ralpi runs whole task plans — there is no single-task or next-batch subcommand. To execute only part of a plan, edit the task file and remove/adjust the tasks first.
|
||||
|
||||
## Task File Location
|
||||
|
||||
Default: `README.md` in current directory. Can be overridden with explicit path.
|
||||
Default: `README.md` in the current directory. Can be overridden with an explicit path (`@path`, `./path`, `*.md`, `*.yaml`, `*.yml`).
|
||||
|
||||
## Reflection Format
|
||||
|
||||
After completing a task, include:
|
||||
After completing a task, the task agent ends its response with a reflection block, which the extension parses and passes to downstream tasks:
|
||||
|
||||
```
|
||||
## REFLECTION
|
||||
SUMMARY: [what was done]
|
||||
FILES: [files changed]
|
||||
SUMMARY: [1-2 sentence description of what was accomplished]
|
||||
FILES: [comma-separated list of files created or modified]
|
||||
LEARNINGS:
|
||||
- [key learning]
|
||||
BLOCKERS: [issues or 'none']
|
||||
- [key decision, pattern, or architectural choice]
|
||||
- [important API or interface details]
|
||||
- [anything downstream tasks need to know]
|
||||
BLOCKERS: [any unresolved issues, or 'none']
|
||||
```
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
abortMerge,
|
||||
hasMergeConflicts,
|
||||
completeMerge,
|
||||
worktreeHasPreservableWork,
|
||||
type WorktreeHandle,
|
||||
type MergeResult,
|
||||
} from "./worktree";
|
||||
@@ -1248,7 +1249,7 @@ async function executeTask(
|
||||
}`,
|
||||
"error",
|
||||
);
|
||||
if (wt) removeWorktree(projectDir, wt);
|
||||
cleanupFailedWorktree(projectDir, wt, task, sendChatMessage);
|
||||
roundRobin?.release(task.id);
|
||||
return;
|
||||
} catch (error) {
|
||||
@@ -1264,7 +1265,7 @@ async function executeTask(
|
||||
}
|
||||
sendChatMessage?.(`✗ ${task.id} · ${task.title} — ${errorMsg}`);
|
||||
ctx.ui.notify(`Task ${task.id} failed: ${errorMsg}`, "error");
|
||||
if (wt) removeWorktree(projectDir, wt);
|
||||
cleanupFailedWorktree(projectDir, wt, task, sendChatMessage);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1280,11 +1281,37 @@ async function executeTask(
|
||||
`Task ${task.id} failed: all configured models exhausted`,
|
||||
"error",
|
||||
);
|
||||
if (wt) removeWorktree(projectDir, wt);
|
||||
cleanupFailedWorktree(projectDir, wt, task, sendChatMessage);
|
||||
}
|
||||
|
||||
// ─── Save Reflection to File ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Remove a task worktree after a failure UNLESS it still holds recoverable
|
||||
* work (commits ahead of main, or uncommitted changes).
|
||||
*
|
||||
* `removeWorktree` force-deletes the worktree's branch, which makes any
|
||||
* commits the agent made before failing/timing out unreachable — real code
|
||||
* loss. A preserved worktree is instead picked up on the next resume:
|
||||
* resume-finalize merges committed work into main, or the task re-runs in
|
||||
* place and the agent continues from where it stopped.
|
||||
*/
|
||||
function cleanupFailedWorktree(
|
||||
projectDir: string,
|
||||
wt: WorktreeHandle | null,
|
||||
task: Task,
|
||||
sendChatMessage?: SendChatMessage,
|
||||
): void {
|
||||
if (!wt) return;
|
||||
if (worktreeHasPreservableWork(projectDir, wt)) {
|
||||
sendChatMessage?.(
|
||||
`~ ${task.id} · ${task.title} — task failed but worktree preserved (${wt.branch}); committed work will be merged on resume`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
removeWorktree(projectDir, wt);
|
||||
}
|
||||
|
||||
function saveReflectionToFile(
|
||||
sourceDir: string,
|
||||
config: RalpiConfig,
|
||||
@@ -1407,7 +1434,7 @@ async function runFollowUpSession(
|
||||
undefined,
|
||||
model,
|
||||
config.thinkingLevel,
|
||||
true, // noSkills — follow-up sessions don't need the skills catalog
|
||||
false, // noSkills=false — follow-up sessions load skills too
|
||||
(ctx.modelRegistry as any).runtime as ModelRuntime,
|
||||
);
|
||||
|
||||
|
||||
@@ -142,6 +142,33 @@ export class ProgressTracker {
|
||||
|
||||
/** 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
|
||||
|
||||
104
src/task-manager-prompt.ts
Normal file
104
src/task-manager-prompt.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { stripFrontmatter } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
const TEMPLATE_REL = path.join("prompts", "task-manager.md");
|
||||
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
1261
src/utils.ts
1261
src/utils.ts
File diff suppressed because it is too large
Load Diff
122
src/worktree.ts
122
src/worktree.ts
@@ -1,6 +1,10 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { ensureDir, hasUncommittedChanges } from "./utils";
|
||||
import {
|
||||
ensureDir,
|
||||
hasUncommittedChanges,
|
||||
hasTrackedUncommittedChanges,
|
||||
} from "./utils";
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -83,6 +87,28 @@ 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 ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -152,6 +178,9 @@ export function createWorktree(
|
||||
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);
|
||||
@@ -172,6 +201,23 @@ export function createWorktree(
|
||||
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
|
||||
@@ -340,7 +386,7 @@ export function cleanupStaleWorktrees(
|
||||
// When a prdKey is given, narrow to that PRD's subdir so concurrent
|
||||
// loops (other PRDs) are not disturbed.
|
||||
const managedRoot = path.resolve(
|
||||
mainDir,
|
||||
canonicalDir(mainDir),
|
||||
stateDir,
|
||||
"worktrees",
|
||||
...(prdKey ? [prdKey] : []),
|
||||
@@ -391,20 +437,27 @@ export interface FinalizeResult {
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize in-progress tasks whose worktrees 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).
|
||||
* 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 working tree is dirty (uncommitted edits) → re-run,
|
||||
* preserving the worktree so `createWorktree` reuses it and the agent
|
||||
* continues where it left off.
|
||||
* - If the worktree is clean but has no commits ahead of main → re-run.
|
||||
* - If the worktree is clean AND has ≥1 commit ahead of main → merge the
|
||||
* branch into main (`--no-ff`), remove the worktree, and report finalized.
|
||||
* On merge conflict the merge is aborted (main left clean), the worktree
|
||||
* is preserved, and the task is reported in `conflicts`.
|
||||
* - 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
|
||||
@@ -417,6 +470,7 @@ export function finalizeCommittedWorktrees(
|
||||
prdKey: string,
|
||||
taskIds: string[],
|
||||
): FinalizeResult {
|
||||
mainDir = canonicalDir(mainDir);
|
||||
const result: FinalizeResult = { finalized: [], rerun: [], conflicts: {} };
|
||||
|
||||
const mainHead = getGitHead(mainDir);
|
||||
@@ -446,14 +500,15 @@ export function finalizeCommittedWorktrees(
|
||||
continue;
|
||||
}
|
||||
|
||||
// Dirty working tree (uncommitted edits, e.g. an interrupted agent) →
|
||||
// re-run, keeping the worktree so the agent resumes in place.
|
||||
if (hasUncommittedChanges(wtDir)) {
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Clean tree but nothing committed ahead of main → nothing to merge.
|
||||
// No commits ahead of main → nothing to merge.
|
||||
const aheadStr =
|
||||
mainHead !== null
|
||||
? git(`rev-list --count ${mainHead}..HEAD`, wtDir)
|
||||
@@ -464,11 +519,17 @@ export function finalizeCommittedWorktrees(
|
||||
continue;
|
||||
}
|
||||
|
||||
// Committed + clean → finalize. mergeWorktree aborts on conflict,
|
||||
// leaving main's working tree clean.
|
||||
// Committed + no tracked edits → finalize. mergeWorktree aborts on
|
||||
// conflict, leaving main's working tree clean.
|
||||
const merge = mergeWorktree(mainDir, branch);
|
||||
if (merge.success) {
|
||||
removeWorktree(mainDir, { dir: wtDir, branch, mainDir });
|
||||
// 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;
|
||||
}
|
||||
@@ -480,3 +541,24 @@ export function finalizeCommittedWorktrees(
|
||||
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;
|
||||
}
|
||||
|
||||
82
tests/progress-multiprd.test.ts
Normal file
82
tests/progress-multiprd.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/// <reference types="bun-types" />
|
||||
import { describe, it, expect, beforeEach } from "bun:test";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { ProgressTracker } from "../src/progress";
|
||||
|
||||
/**
|
||||
* Regression test: two concurrent loops (different PRDs) each run their own
|
||||
* ProgressTracker. Each instance snapshots the whole state at construction;
|
||||
* a save() that writes that stale snapshot verbatim would revert the OTHER
|
||||
* loop's task status changes — tasks wrongly back to "pending" while their
|
||||
* worktrees carry real work, stranding it on the next resume.
|
||||
*/
|
||||
let root: string;
|
||||
|
||||
beforeEach(() => {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), "ralpi-prog-test-"));
|
||||
});
|
||||
|
||||
function prdA(projectDir: string): ProgressTracker {
|
||||
return new ProgressTracker(
|
||||
projectDir,
|
||||
path.join(projectDir, "tasks/a/README.md"),
|
||||
);
|
||||
}
|
||||
function prdB(projectDir: string): ProgressTracker {
|
||||
return new ProgressTracker(
|
||||
projectDir,
|
||||
path.join(projectDir, "tasks/b/README.md"),
|
||||
);
|
||||
}
|
||||
|
||||
/** Read the on-disk progress state; the file is written by the tracker, so
|
||||
* a parse failure is a test bug worth surfacing. */
|
||||
function readState(): Record<string, any> {
|
||||
const raw = fs.readFileSync(
|
||||
path.join(root, ".ralpi", "progress.json"),
|
||||
"utf-8",
|
||||
);
|
||||
try {
|
||||
return JSON.parse(raw) as Record<string, any>;
|
||||
} catch {
|
||||
throw new Error(`malformed progress.json:\n${raw.slice(0, 200)}`);
|
||||
}
|
||||
}
|
||||
|
||||
describe("ProgressTracker multi-PRD save isolation", () => {
|
||||
it("does not clobber another PRD's task status on save", () => {
|
||||
const a = prdA(root);
|
||||
const b = prdB(root);
|
||||
expect(a.getKey()).not.toBe(b.getKey());
|
||||
|
||||
// Loop A marks its task in_progress.
|
||||
a.markInProgress("01");
|
||||
expect(a.getTaskStatus("01")).toBe("in_progress");
|
||||
|
||||
// Loop B (stale snapshot from before A's update) marks ITS task.
|
||||
b.markInProgress("02");
|
||||
|
||||
// The on-disk state must show BOTH updates.
|
||||
const raw = readState();
|
||||
expect(raw.prds[a.getKey()].tasks["01"].status).toBe("in_progress");
|
||||
expect(raw.prds[b.getKey()].tasks["02"].status).toBe("in_progress");
|
||||
});
|
||||
|
||||
it("preserves other PRD completions when this PRD saves", () => {
|
||||
const a = prdA(root);
|
||||
const b = prdB(root);
|
||||
|
||||
a.markCompleted("01", 1000);
|
||||
b.markInProgress("02");
|
||||
|
||||
// A completes another task later — A's save must not revert B.
|
||||
a.markCompleted("03", 500);
|
||||
|
||||
const raw = readState();
|
||||
expect(raw.prds[a.getKey()].tasks["01"].status).toBe("completed");
|
||||
expect(raw.prds[a.getKey()].tasks["03"].status).toBe("completed");
|
||||
expect(raw.prds[b.getKey()].tasks["02"].status).toBe("in_progress");
|
||||
});
|
||||
});
|
||||
87
tests/resume-stats.test.ts
Normal file
87
tests/resume-stats.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/// <reference types="bun-types" />
|
||||
import { describe, it, expect, beforeEach } from "bun:test";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { ProgressTracker } from "../src/progress";
|
||||
import { countPRDResumeStats } from "../src/utils";
|
||||
|
||||
/**
|
||||
* Regression test: the resume-selection prompt under-reported task totals
|
||||
* when multiple loop histories existed. The progress tracker only records
|
||||
* TOUCHED tasks (started/completed/failed) — never-started tasks are absent
|
||||
* from prd.tasks, so a naive Object.keys() count missed them entirely, and
|
||||
* file-checked completions were ignored unless markCompleted had run.
|
||||
* countPRDResumeStats derives the true total from the parsed PRD file and
|
||||
* counts checkbox completions too.
|
||||
*/
|
||||
let root: string;
|
||||
|
||||
beforeEach(() => {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), "ralpi-stats-test-"));
|
||||
});
|
||||
|
||||
const PRD_CONTENT = `# Test PRD
|
||||
|
||||
## Tasks
|
||||
- [ ] Task one
|
||||
- [x] Task two
|
||||
- [ ] Task three
|
||||
- [ ] Task four
|
||||
`;
|
||||
|
||||
function writePRD(rel: string): string {
|
||||
const p = path.join(root, rel);
|
||||
fs.mkdirSync(path.dirname(p), { recursive: true });
|
||||
fs.writeFileSync(p, PRD_CONTENT, "utf-8");
|
||||
return p;
|
||||
}
|
||||
|
||||
describe("countPRDResumeStats", () => {
|
||||
it("reports the full task total from the PRD file, not just touched tasks", () => {
|
||||
const sourcePath = writePRD("tasks/a/README.md");
|
||||
const progress = new ProgressTracker(root, sourcePath);
|
||||
|
||||
// Simple-checkbox format assigns sequential ids 00-03. Only 00
|
||||
// (completed) and 02 (failed) were touched by the loop; 01 is checked
|
||||
// off in the file; 03 was never started.
|
||||
progress.markCompleted("00", 1000);
|
||||
progress.markFailed("02", "boom");
|
||||
|
||||
const stats = countPRDResumeStats(progress.getState(), sourcePath);
|
||||
expect(stats.total).toBe(4); // old code reported 2
|
||||
expect(stats.completed).toBe(2); // 00 via progress + 01 via checkbox
|
||||
expect(stats.failed).toBe(1);
|
||||
});
|
||||
|
||||
it("does not double-count a task that is both progress-completed and file-checked", () => {
|
||||
const sourcePath = writePRD("tasks/b/README.md");
|
||||
const progress = new ProgressTracker(root, sourcePath);
|
||||
|
||||
progress.markCompleted("01", 500); // 01 already [x] in the file
|
||||
|
||||
const stats = countPRDResumeStats(progress.getState(), sourcePath);
|
||||
expect(stats.completed).toBe(1);
|
||||
});
|
||||
|
||||
it("falls back to touched-task counts when the PRD file is missing", () => {
|
||||
const missing = path.join(root, "tasks/gone/README.md");
|
||||
const progress = new ProgressTracker(root, missing);
|
||||
progress.markCompleted("01", 1000);
|
||||
progress.markFailed("02", "nope");
|
||||
|
||||
const stats = countPRDResumeStats(progress.getState(), missing);
|
||||
expect(stats.total).toBe(2);
|
||||
expect(stats.completed).toBe(1);
|
||||
expect(stats.failed).toBe(1);
|
||||
});
|
||||
|
||||
it("reports zero for a never-touched PRD with no file", () => {
|
||||
const missing = path.join(root, "tasks/none/README.md");
|
||||
const progress = new ProgressTracker(root, missing);
|
||||
const stats = countPRDResumeStats(progress.getState(), missing);
|
||||
expect(stats.total).toBe(0);
|
||||
expect(stats.completed).toBe(0);
|
||||
expect(stats.failed).toBe(0);
|
||||
});
|
||||
});
|
||||
265
tests/worktree-resume.test.ts
Normal file
265
tests/worktree-resume.test.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
/// <reference types="bun-types" />
|
||||
import { describe, it, expect, beforeEach } from "bun:test";
|
||||
import { execSync } from "node:child_process";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import {
|
||||
createWorktree,
|
||||
finalizeCommittedWorktrees,
|
||||
mergeWorktree,
|
||||
removeWorktree,
|
||||
worktreeHasPreservableWork,
|
||||
} from "../src/worktree";
|
||||
|
||||
/**
|
||||
* Regression tests for worktree resume/finalize behavior:
|
||||
*
|
||||
* 1. finalizeCommittedWorktrees merges a committed worktree branch even
|
||||
* when the worktree carries UNTRACKED files (previously the dirty check
|
||||
* counted `??` entries, stranding committed code in .ralpi/worktrees/).
|
||||
* 2. finalize works for tasks that are NOT in_progress (pending) — the
|
||||
* stranded-work case after an interrupted resume.
|
||||
* 3. createWorktree reuses an existing worktree under a symlinked project
|
||||
* path (git porcelain emits realpaths; literal path.join must not be
|
||||
* compared verbatim).
|
||||
* 4. worktreeHasPreservableWork keeps failed-task branches alive so a
|
||||
* timeout doesn't destroy commits the agent already made.
|
||||
*/
|
||||
|
||||
const sh = (cmd: string, cwd: string): string => {
|
||||
try {
|
||||
return execSync(cmd, { cwd, encoding: "utf-8" }).trim();
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`git cmd failed in ${cwd}: ${cmd}\n${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const STATE_DIR = ".ralpi";
|
||||
const PRD_KEY = "prd";
|
||||
|
||||
function makeRepo(root: string): void {
|
||||
sh("git init -q -b master .", root);
|
||||
sh("git config user.email t@t.co", root);
|
||||
sh("git config user.name T", root);
|
||||
sh("echo '# Demo' > README.md", root);
|
||||
sh("git add -A && git commit -qm init", root);
|
||||
}
|
||||
|
||||
/** Commit work in a worktree and record the commit message. */
|
||||
function commitInWorktree(wt: { dir: string }, filename: string, msg: string) {
|
||||
sh(`echo '${filename} content' > ${filename}`, wt.dir);
|
||||
sh(`git add -A && git commit -qm '${msg}'`, wt.dir);
|
||||
}
|
||||
|
||||
function masterHasFile(root: string, filename: string): boolean {
|
||||
try {
|
||||
sh(`git show master:${filename}`, root);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
let root: string;
|
||||
|
||||
beforeEach(() => {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), "ralpi-wt-test-"));
|
||||
makeRepo(root);
|
||||
});
|
||||
|
||||
describe("finalizeCommittedWorktrees", () => {
|
||||
it("merges a committed worktree branch even when untracked files exist", () => {
|
||||
const wt = createWorktree(
|
||||
root,
|
||||
STATE_DIR,
|
||||
"01",
|
||||
PRD_KEY,
|
||||
undefined,
|
||||
"task one",
|
||||
)!;
|
||||
commitInWorktree(wt, "work.txt", "task 01 work");
|
||||
// The task agent left a scratch file untracked (like build artifacts).
|
||||
sh("mkdir -p scratch && echo junk > scratch/junk.bin", wt.dir);
|
||||
expect(sh("git status --porcelain", wt.dir)).toContain("??");
|
||||
|
||||
const fin = finalizeCommittedWorktrees(root, STATE_DIR, PRD_KEY, ["01"]);
|
||||
|
||||
expect(fin.finalized).toEqual(["01"]);
|
||||
expect(masterHasFile(root, "work.txt")).toBe(true);
|
||||
});
|
||||
|
||||
it("finalizes tasks that are pending (not in_progress) with committed work", () => {
|
||||
const wt = createWorktree(
|
||||
root,
|
||||
STATE_DIR,
|
||||
"02",
|
||||
PRD_KEY,
|
||||
undefined,
|
||||
"task two",
|
||||
)!;
|
||||
commitInWorktree(wt, "b.txt", "task 02 work");
|
||||
// Simulate a prior interrupted resume: task reset to pending, branch
|
||||
// never merged.
|
||||
const fin = finalizeCommittedWorktrees(root, STATE_DIR, PRD_KEY, ["02"]);
|
||||
expect(fin.finalized).toEqual(["02"]);
|
||||
expect(masterHasFile(root, "b.txt")).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves a worktree with uncommitted TRACKED edits for re-run", () => {
|
||||
const wt = createWorktree(
|
||||
root,
|
||||
STATE_DIR,
|
||||
"03",
|
||||
PRD_KEY,
|
||||
undefined,
|
||||
"task three",
|
||||
)!;
|
||||
commitInWorktree(wt, "c.txt", "task 03 work");
|
||||
// Agent was mid-edit when interrupted: a tracked file modified.
|
||||
sh("echo more >> README.md", wt.dir);
|
||||
|
||||
const fin = finalizeCommittedWorktrees(root, STATE_DIR, PRD_KEY, ["03"]);
|
||||
expect(fin.finalized).toEqual([]);
|
||||
expect(fin.rerun).toEqual(["03"]);
|
||||
expect(masterHasFile(root, "c.txt")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not re-merge an already-merged branch", () => {
|
||||
const wt = createWorktree(
|
||||
root,
|
||||
STATE_DIR,
|
||||
"04",
|
||||
PRD_KEY,
|
||||
undefined,
|
||||
"task four",
|
||||
)!;
|
||||
commitInWorktree(wt, "d.txt", "task 04 work");
|
||||
expect(mergeWorktree(root, wt.branch).success).toBe(true);
|
||||
removeWorktree(root, wt);
|
||||
|
||||
// Re-create a worktree on the same (now-merged) branch tip: nothing
|
||||
// ahead of main → re-run, no spurious merge.
|
||||
const wt2 = createWorktree(
|
||||
root,
|
||||
STATE_DIR,
|
||||
"04",
|
||||
PRD_KEY,
|
||||
undefined,
|
||||
"task four",
|
||||
)!;
|
||||
commitInWorktree(wt2, "e.txt", "task 04 more work");
|
||||
const fin = finalizeCommittedWorktrees(root, STATE_DIR, PRD_KEY, ["04"]);
|
||||
expect(fin.finalized).toEqual(["04"]);
|
||||
expect(masterHasFile(root, "e.txt")).toBe(true);
|
||||
});
|
||||
|
||||
it("reports conflicts and preserves the worktree", () => {
|
||||
const wt = createWorktree(
|
||||
root,
|
||||
STATE_DIR,
|
||||
"05",
|
||||
PRD_KEY,
|
||||
undefined,
|
||||
"task five",
|
||||
)!;
|
||||
// Both sides edit f.txt: master AFTER the worktree exists, so the
|
||||
// branches genuinely diverge and the merge must conflict.
|
||||
sh(
|
||||
"echo master > f.txt && git add -A && git commit -qm 'master f.txt'",
|
||||
root,
|
||||
);
|
||||
sh("echo worktree > f.txt", wt.dir);
|
||||
sh("git add -A && git commit -qm 'task 05 work'", wt.dir);
|
||||
|
||||
const fin = finalizeCommittedWorktrees(root, STATE_DIR, PRD_KEY, ["05"]);
|
||||
expect(fin.finalized).toEqual([]);
|
||||
expect(fin.conflicts["05"]).toBeTruthy();
|
||||
// worktree preserved for manual resolution
|
||||
expect(fs.existsSync(wt.dir)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createWorktree resume reuse under symlinked paths", () => {
|
||||
it("reuses an existing worktree when the project path contains a symlink", () => {
|
||||
// macOS /tmp → /private/tmp style symlink: git porcelain reports the
|
||||
// REAL path, path.join keeps the literal one. Reuse must still match.
|
||||
const realBase = fs.mkdtempSync(path.join(os.tmpdir(), "ralpi-wt-real-"));
|
||||
const link = path.join(realBase, "link");
|
||||
fs.mkdirSync(path.join(realBase, "repo"));
|
||||
fs.symlinkSync(path.join(realBase, "repo"), link);
|
||||
const symRoot = link;
|
||||
|
||||
makeRepo(symRoot);
|
||||
// Sanity: this is genuinely a symlink situation.
|
||||
expect(fs.realpathSync(symRoot)).not.toBe(symRoot);
|
||||
|
||||
const wt = createWorktree(
|
||||
symRoot,
|
||||
STATE_DIR,
|
||||
"01",
|
||||
PRD_KEY,
|
||||
undefined,
|
||||
"task one",
|
||||
)!;
|
||||
commitInWorktree(wt, "a.txt", "task 01 work");
|
||||
|
||||
// Resume: createWorktree again must REUSE the registered worktree
|
||||
// (same dir), not fail and fall through to the main repo.
|
||||
const reused = createWorktree(
|
||||
symRoot,
|
||||
STATE_DIR,
|
||||
"01",
|
||||
PRD_KEY,
|
||||
undefined,
|
||||
"task one",
|
||||
)!;
|
||||
expect(reused.dir).toBe(fs.realpathSync(wt.dir));
|
||||
|
||||
const fin = finalizeCommittedWorktrees(symRoot, STATE_DIR, PRD_KEY, ["01"]);
|
||||
expect(fin.finalized).toEqual(["01"]);
|
||||
expect(masterHasFile(fs.realpathSync(symRoot), "a.txt")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("worktreeHasPreservableWork", () => {
|
||||
it("returns true for a worktree with committed work ahead of main", () => {
|
||||
const wt = createWorktree(
|
||||
root,
|
||||
STATE_DIR,
|
||||
"01",
|
||||
PRD_KEY,
|
||||
undefined,
|
||||
"task one",
|
||||
)!;
|
||||
commitInWorktree(wt, "a.txt", "task 01 work");
|
||||
expect(worktreeHasPreservableWork(root, wt)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for a worktree with uncommitted changes", () => {
|
||||
const wt = createWorktree(
|
||||
root,
|
||||
STATE_DIR,
|
||||
"01",
|
||||
PRD_KEY,
|
||||
undefined,
|
||||
"task one",
|
||||
)!;
|
||||
sh("echo x > junk.txt", wt.dir);
|
||||
expect(worktreeHasPreservableWork(root, wt)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for an empty fresh worktree", () => {
|
||||
const wt = createWorktree(
|
||||
root,
|
||||
STATE_DIR,
|
||||
"01",
|
||||
PRD_KEY,
|
||||
undefined,
|
||||
"task one",
|
||||
)!;
|
||||
expect(worktreeHasPreservableWork(root, wt)).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user