commit a9757c6fce9f8dd825b8cf5fb587c5849ce7bd62 Author: Michael Freno Date: Mon Aug 10 09:46:09 2026 -0400 initial import: @mikefreno/omp-ralpi (omp port) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7b422a4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules +.pi-lens +package-lock.json diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e8d8b13 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,149 @@ +# AGENTS.md + +## What this is + +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 + +``` +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 + +`index.ts` at repo root (not `src/`). Exports a default function receiving `ExtensionAPI`. + +## External dependencies + +The extension imports from Pi SDK packages (not in `package.json` — provided by the host): + +- `@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). 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 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/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/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 + - `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 +- `prompts/task-manager.md` — Pi prompt for task planning + +## Runtime state + +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//` — full review output JSON (only when + `saveReviews` is on) +- `.ralpi/prompts/` — generated prompts (timestamped, for debugging) +- `.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.) 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` — 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`), 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` / `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 + failover to the next model per task (only after exhausting same-model + retries, see `maxSameModelAttempts`) +- `maxSameModelAttempts` — max attempts on the SAME model before cycling to + the next model on failure (default 5, matching pi's normal retry count). + Applies to task execution, commit/review follow-up sessions, and + review-fix re-execution alike +- `implModel` / `commitModel` / `reviewModel` — `/` strings + resolved via `resolveModelSpec` in `utils.ts` +- `prompts.reviewFocus` — per-review custom focus/instructions, injected as a + `## Custom Review Focus` section in review prompts +- `review.extraIgnorePatterns` — extra noise-filter exclusion regexes (file + paths) merged into the default rules +- `review.ignorePaths` — pathspec allowlist keeping matching files in review + scope even when a default noise rule would exclude them +- `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`) +- `timeoutMs` — per-task execution timeout +- `prompts.projectContext` / `prompts.reflectionPrompt` — prompt-level settings diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8fa6dd9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Michael Freno + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b14edd9 --- /dev/null +++ b/README.md @@ -0,0 +1,304 @@ +# Ralpi + +Execute tasks from task files until done using DAG-based dependency resolution with persistent progress tracking. + +```bash +# omp auto-discovers extensions in ~/.omp/agent/extensions// +git clone ~/.omp/agent/extensions/ralpi/ +``` + +## Features + +- **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 +- **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] # 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 +``` + +`/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 +- [ ] 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 + +```yaml +objective: Build a web application +tasks: + - id: "01" + title: Setup project structure + file: tasks/01-setup.md + dependencies: [] + - id: "02" + title: Implement auth + file: tasks/02-auth.md + depends_on: ["01"] +``` + +## Task IDs + +Task IDs are zero-padded 2-digit strings (`01`, `02`, ...) with an optional +single lowercase letter suffix for sub-tasks inserted between two numbered +steps (e.g. `02b`, `02c`). The parser normalizes `2b` → `02b`. + +``` +- [ ] 01 — Setup +- [ ] 02 — Fix bugs +- [ ] 02b — Sub-step of 02 (inserted after the fact) +- [ ] 02c — Another sub-step of 02 +- [ ] 03 — Continue +``` + +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 +``` + +"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 +``` + +"Task 13 depends on tasks 17, 18, 19, and 20." `also depends on` is accepted. + +### "must be done before" + +``` +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 +``` + +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 + +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 +- [ ] 02 — Implement auth # timeout=30s +``` + +```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 + +| Scope | Path | +|-------|------| +| **Global** | `~/.omp/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 (0 = unlimited) + models: # round-robin for parallel tasks, / + - 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 + chatStyle: compact # "compact" (default) | "verbose" — per-event tool-call stream +prompts: + projectContext: "Additional context for all tasks" + reflectionPrompt: "" # custom suffix for reflection extraction + reviewFocus: "" # per-review custom focus/instructions (e.g. "check security only") +review: + extraIgnorePatterns: [] # extra noise-filter exclusion regexes (merged into the default rules) + ignorePaths: [] # pathspec allowlist — files matching these stay in review scope +``` + +Review prompts (committed + uncommitted) run the diff through a noise filter +before inlining: lockfiles, minified/generated assets, source maps, +snapshots, build output, `node_modules`/`vendor`, and binary/media files are +excluded by default. The prompt gets a per-file `+/−` summary table, an +`### Excluded Files (n)` section listing what was filtered (path, counts, +reason), and — when a diff is oversized or touches >20 files — a +file-list + "use `read`" instruction instead of a byte-truncated diff. +`prompts.reviewFocus` injects a `### Custom Review Focus` section into each +review prompt. `review.extraIgnorePatterns` adds exclusion regexes (matched +against file paths), and `review.ignorePaths` is a pathspec allowlist that +keeps matching files in review scope even when a default rule would exclude +them. + +> `execution.models` uses slot-aware round-robin: with 3 models and 2 concurrent +> tasks, only the first two models are used. The third model is only touched when +> a third concurrent task starts. Freed model slots are reused before new ones +> are allocated. +> **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**: 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. + +> `execution.chatStyle` controls how sub-agent tool calls appear in the chat during task execution: +> - **compact** (default): a single completion message per task with an expandable tool-call tree (collapsed shows the last 3 calls, expanded via Ctrl+O shows all). +> - **verbose**: each tool event is streamed live as its own chat line (`[01 · task-name] → bash ...` / `← (ok)`), like piolium/pygienium's per-event stream. + +#### Auto-review and Auto-commit + +At loop startup the review question is asked FIRST. When `autoReview` is +enabled, commit is **mandated** — after task execution, changes are +committed (via a commit agent session when the task agent didn't +self-commit), then the complete task diff (`baseRef..HEAD`) is reviewed +against the task description. On a `fail` verdict the task is +re-executed with the review feedback injected into the prompt (looping +until the review passes or `maxReviewRetries` is exhausted). After +re-execution, changes are committed again and the full diff is +re-reviewed with the same base ref so the reviewer sees the complete +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. With `autoReview` on, the user is +also asked whether to persist full review output to +`.ralpi/reviews//.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 `/` 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 +(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 (supports multiple PRDs) +.ralpi/loop-active.json # Active-loop marker used for auto-resume after a reload +.ralpi/reflections/ # Per-task reflections +.ralpi/reviews// # Full review output (when saveReviews is on) +.ralpi/prompts/ # Generated prompts (timestamped, for debugging) +.ralpi/config.yaml # Project-level config (optional) +``` + +Every `/ralpi run`, `/ralpi resume`, and `/ralpi reset` (plus the auto-resume +on session reload) ensures `.ralpi/` is present in the project's `.gitignore`, +so ralpi's own artifacts never show up as untracked/staged files in the user's +repo. Opt out per command with `--no-gitignore` (e.g. `/ralpi-run README.md +--no-gitignore`). diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..075467b --- /dev/null +++ b/bun.lock @@ -0,0 +1,412 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "@mikefreno/ralpi", + "dependencies": { + "yaml": "^2.4.0", + }, + "devDependencies": { + "@oh-my-pi/pi-coding-agent": "17.2.12", + "@oh-my-pi/pi-tui": "17.2.12", + "@types/node": "^20.0.0", + "bun-types": "^1.3.14", + "typescript": "^5.3.0", + }, + }, + }, + "packages": { + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/parser": ["@babel/parser@7.29.8", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA=="], + + "@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], + + "@bufbuild/protobuf": ["@bufbuild/protobuf@2.13.0", "", {}, "sha512-acq7c49vxfm1ggJ95P70TX7ABDM0vxr1SYD3BB0o0jnBLB4OAqeHyKuN+cD3w80gXEDQ2zxHpR6CUeA+O/aU9g=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], + + "@huggingface/jinja": ["@huggingface/jinja@0.5.9", "", {}, "sha512-uWTG+l3VJRsl7EXxYizuL3P+cCPoc3cRqbWWRcQN0FhejRfbdq0RNhCmbY/YDtnTcz9icdLYuLDjsnz4d8JMuw=="], + + "@huggingface/tokenizers": ["@huggingface/tokenizers@0.1.3", "", {}, "sha512-8rF/RRT10u+kn7YuUbUg0OF30K8rjTc78aHpxT+qJ1uWSqxT1MHi8+9ltwYfkFYJzT/oS+qw3JVfHtNMGAdqyA=="], + + "@huggingface/transformers": ["@huggingface/transformers@4.2.0", "", { "dependencies": { "@huggingface/jinja": "^0.5.6", "@huggingface/tokenizers": "^0.1.3", "onnxruntime-node": "1.24.3", "onnxruntime-web": "1.26.0-dev.20260416-b7804b056c", "sharp": "^0.34.5" } }, "sha512-8BRCoBMH0XsWaEIamuR0LrJGAfftgHAfb2Vrffy0VKlSAE/MnUJ5/h/zTfEP3fDIft+nk7TqB8xXEyABGitBjQ=="], + + "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], + + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], + + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], + + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], + + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], + + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="], + + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="], + + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="], + + "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="], + + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="], + + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="], + + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="], + + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="], + + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="], + + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="], + + "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="], + + "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="], + + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="], + + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="], + + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="], + + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="], + + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="], + + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], + + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="], + + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@kurkle/color": ["@kurkle/color@0.3.4", "", {}, "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="], + + "@oh-my-pi/hashline": ["@oh-my-pi/hashline@17.2.12", "", { "dependencies": { "@oh-my-pi/pi-natives": "17.2.12", "@oh-my-pi/pi-utils": "17.2.12" } }, "sha512-OdqMAojK8iUZynyNAP2VnbB+EHOHM7ISDSM3f9xHh/sDNSPDi15Xwd53AIdJy+EhYoCyglDIqO9/seHjA6veQQ=="], + + "@oh-my-pi/omp-stats": ["@oh-my-pi/omp-stats@17.2.12", "", { "dependencies": { "@oh-my-pi/pi-ai": "17.2.12", "@oh-my-pi/pi-catalog": "17.2.12", "@oh-my-pi/pi-utils": "17.2.12", "@tailwindcss/node": "^4.3.2", "chart.js": "^4.5.1", "lucide-react": "^1.24.0", "react": "19.2.7", "react-chartjs-2": "^5.3.1", "react-dom": "19.2.7", "tailwindcss": "^4.3.2" }, "bin": { "omp-stats": "src/index.ts" } }, "sha512-BOyDNq8Hj/CJVc6Wntdo+1yaG/b8CqLcxsBnHefLoC7vO96YoFjt4S7GOpp3cd+ysiuxHnmrWn30kEtwR9fhjQ=="], + + "@oh-my-pi/omptype": ["@oh-my-pi/omptype@17.2.12", "", {}, "sha512-Y27VMnPbcUEOpK1DgYuvNIaVnGxEmOcEP2bYPfuAWkx2vc4PKXL0NCRzGLwYMjhwjjd9O/gn8lywZfxDCBytjA=="], + + "@oh-my-pi/pi-agent-core": ["@oh-my-pi/pi-agent-core@17.2.12", "", { "dependencies": { "@oh-my-pi/pi-ai": "17.2.12", "@oh-my-pi/pi-catalog": "17.2.12", "@oh-my-pi/pi-natives": "17.2.12", "@oh-my-pi/pi-utils": "17.2.12", "@oh-my-pi/pi-wire": "17.2.12", "@oh-my-pi/snapcompact": "17.2.12", "@opentelemetry/api": "^1.9.1" } }, "sha512-VIJnZCQyshiZoiWpvhoNVHhUQmptLG41h7XPg2IZfEdDNdDpV26JuZdDMgBr0hIz1L/RfTq8MiRcRuAcasvkOg=="], + + "@oh-my-pi/pi-ai": ["@oh-my-pi/pi-ai@17.2.12", "", { "dependencies": { "@bufbuild/protobuf": "^2.12.1", "@oh-my-pi/omptype": "17.2.12", "@oh-my-pi/pi-catalog": "17.2.12", "@oh-my-pi/pi-utils": "17.2.12", "@oh-my-pi/pi-wire": "17.2.12" } }, "sha512-S6LswgyLgQdE5zXADf6NGwrYn1OCYp1WiKxyN6f/1RRv68vbd3y4YSoidzeOYs3kPS/cIiv3dw3LHaCrwpam/Q=="], + + "@oh-my-pi/pi-catalog": ["@oh-my-pi/pi-catalog@17.2.12", "", { "dependencies": { "@bufbuild/protobuf": "^2.12.1", "@oh-my-pi/omptype": "17.2.12", "@oh-my-pi/pi-utils": "17.2.12" } }, "sha512-6VycCR0ShSzbVfOa7cdeqVtxHL2IPpJXFePKaQPhQ8bOTiyE/TaUm1uIa1i4doqPTz/k6vUEs+a57cZwoUP97A=="], + + "@oh-my-pi/pi-coding-agent": ["@oh-my-pi/pi-coding-agent@17.2.12", "", { "dependencies": { "@babel/parser": "^7.29.7", "@oh-my-pi/hashline": "17.2.12", "@oh-my-pi/omp-stats": "17.2.12", "@oh-my-pi/omptype": "17.2.12", "@oh-my-pi/pi-agent-core": "17.2.12", "@oh-my-pi/pi-ai": "17.2.12", "@oh-my-pi/pi-catalog": "17.2.12", "@oh-my-pi/pi-mnemopi": "17.2.12", "@oh-my-pi/pi-natives": "17.2.12", "@oh-my-pi/pi-tui": "17.2.12", "@oh-my-pi/pi-utils": "17.2.12", "@oh-my-pi/pi-wire": "17.2.12", "@oh-my-pi/snapcompact": "17.2.12", "@opentelemetry/api": "^1.9.1", "@opentelemetry/api-logs": "^0.220.0", "@opentelemetry/context-async-hooks": "^2.9.0", "@opentelemetry/exporter-logs-otlp-proto": "^0.220.0", "@opentelemetry/exporter-metrics-otlp-proto": "^0.220.0", "@opentelemetry/exporter-trace-otlp-proto": "^0.220.0", "@opentelemetry/resources": "^2.9.0", "@opentelemetry/sdk-logs": "^0.220.0", "@opentelemetry/sdk-metrics": "^2.9.0", "@opentelemetry/sdk-trace-base": "^2.9.0", "@opentelemetry/sdk-trace-node": "^2.9.0", "mupdf": "^1.28.0", "puppeteer-core": "25.3.0" }, "optionalDependencies": { "@huggingface/transformers": "^4.2.0", "sherpa-onnx-node": "1.13.2" }, "bin": { "omp": "dist/cli.js" } }, "sha512-+q+W4fyNQQ7xAKiN0mmOisWDDtKO0R/ZctTSsKqR4ulN3K1zfQ9HwiTxtg7HJHn5fwCy+X3BmUG72FatNUN8IA=="], + + "@oh-my-pi/pi-mnemopi": ["@oh-my-pi/pi-mnemopi@17.2.12", "", { "dependencies": { "@oh-my-pi/pi-ai": "17.2.12", "@oh-my-pi/pi-catalog": "17.2.12", "@oh-my-pi/pi-natives": "17.2.12", "@oh-my-pi/pi-utils": "17.2.12" }, "peerDependencies": { "fastembed": "2.1.0", "onnxruntime-node": "1.21.0" }, "optionalPeers": ["fastembed", "onnxruntime-node"], "bin": { "mnemopi": "src/cli.ts" } }, "sha512-lKjEexuFC/piaNnb3MzJKu8rolXwkjfv0M90UBUiNPFxKbpKHWU5np5aUAOYkT+fwKoQPQ7Tr95mJx0l32S0OQ=="], + + "@oh-my-pi/pi-natives": ["@oh-my-pi/pi-natives@17.2.12", "", { "optionalDependencies": { "@oh-my-pi/pi-natives-darwin-arm64": "17.2.12", "@oh-my-pi/pi-natives-darwin-x64": "17.2.12", "@oh-my-pi/pi-natives-linux-arm64": "17.2.12", "@oh-my-pi/pi-natives-linux-x64": "17.2.12", "@oh-my-pi/pi-natives-win32-x64": "17.2.12" } }, "sha512-MVZq0UrrA7mk6uMKrgjnAhfrDj+58yEuu0VeVd3JuvneMjcX1duIzOdyqRG13S+/XzGOvGkk164dM6D/CwxteQ=="], + + "@oh-my-pi/pi-natives-darwin-arm64": ["@oh-my-pi/pi-natives-darwin-arm64@17.2.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5iullmWEWmGRjwgo7Kw6Bub3KOXahkUX3jZfw65bM9LjFRceSwoLTt6Yh0RnXXP8hRzZnhU3b/o73peWBlpLzw=="], + + "@oh-my-pi/pi-natives-darwin-x64": ["@oh-my-pi/pi-natives-darwin-x64@17.2.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-qNc2q02d5etsOdxmO4CNwCcEvgfWS9Ya/fuq2hDKJgp3BFeKQCzYDap0HhC/qkAfj2vpjsTyKc/PsPMMMLGytg=="], + + "@oh-my-pi/pi-natives-linux-arm64": ["@oh-my-pi/pi-natives-linux-arm64@17.2.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-HTc6efuPNoYSzHu7Zk4gl3RtQzWWprVA4x0ep0gq5B7mBS6OwgnbH5otLrXSFeQ2UgRyV/nddz1O02ek4Vg8MA=="], + + "@oh-my-pi/pi-natives-linux-x64": ["@oh-my-pi/pi-natives-linux-x64@17.2.12", "", { "os": "linux", "cpu": "x64" }, "sha512-jg9xzfqpcBDGRVnW+98z/eQPIfcnOYujd1bLW0PnJh6A0ykSqjCvt9PpN0wrlqr70WazOOURqoodN2eibIyOOQ=="], + + "@oh-my-pi/pi-natives-win32-x64": ["@oh-my-pi/pi-natives-win32-x64@17.2.12", "", { "os": "win32", "cpu": "x64" }, "sha512-Jb/EF7Ug7SX/YYJofgKR5Kvw0I+AqGpEfmI5inIsvFzygqlA4QgMETbAR1s08sK+AYZqPqRorBpbGNas85XFig=="], + + "@oh-my-pi/pi-tui": ["@oh-my-pi/pi-tui@17.2.12", "", { "dependencies": { "@oh-my-pi/pi-natives": "17.2.12", "@oh-my-pi/pi-utils": "17.2.12" } }, "sha512-X4IKQG3alzw3ogUewxoPBaZImReEBF7P2Xu5xN5E47lfMHR0eNJ6cQnQmk7sHjfNEAclKCJDYqwzgOfRdh9GEA=="], + + "@oh-my-pi/pi-utils": ["@oh-my-pi/pi-utils@17.2.12", "", { "dependencies": { "@oh-my-pi/pi-natives": "17.2.12" } }, "sha512-iYNV2y6RW9tzYaBa7hX5pzb23+zzNLF/Bs4bK7LAO2Qc7OZl5gw1lp9YOUX4/VZOO7A20KrXnZNwZVCnpPKy1A=="], + + "@oh-my-pi/pi-wire": ["@oh-my-pi/pi-wire@17.2.12", "", {}, "sha512-UdQ0VP3gExd+jXgy8epCSZ+PTW9pkFu8FoomywmqEPj/JKjt1SqZhIiN8N51HkQCC5WJryT5AedzL/Qr6OID8w=="], + + "@oh-my-pi/snapcompact": ["@oh-my-pi/snapcompact@17.2.12", "", { "dependencies": { "@oh-my-pi/pi-ai": "17.2.12", "@oh-my-pi/pi-natives": "17.2.12", "@oh-my-pi/pi-utils": "17.2.12", "@oh-my-pi/pi-wire": "17.2.12" } }, "sha512-kWYY/7tgIAnXAiYogC6K1GLU2G26QXTr2vDJGq1mxCMk3/GixoNzrFwzK8w6NmKYdQpO/Sa1SRw9sypYT+v1Bw=="], + + "@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="], + + "@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.220.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CmVa4ImJ+ynfrPMNaAXHET6Bhb44SwzmfyVJFq9ni2jgXJR/l7C6gfVFddNmHP+ZOkP9cf4f9DBe68qVLTHc9w=="], + + "@opentelemetry/context-async-hooks": ["@opentelemetry/context-async-hooks@2.10.0", "", { "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-bvyMcgLEkozzSzpEEEo1OMoeQ97bxj6Qs2uN3mPrSdDvObMI1myffD/BPqcLlzZO9//d1SqQA/WPw7Cz2AiqhA=="], + + "@opentelemetry/core": ["@opentelemetry/core@2.9.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-m2nckMT80NnmjTYSPjJQObBJ+8dgkoajEOUbznL8AHZ3T3yHRk2P7gI1PhEBc1+lOnrYE9UWrWHqJDsmqjmNbw=="], + + "@opentelemetry/exporter-logs-otlp-proto": ["@opentelemetry/exporter-logs-otlp-proto@0.220.0", "", { "dependencies": { "@opentelemetry/otlp-exporter-base": "0.220.0", "@opentelemetry/otlp-transformer": "0.220.0", "@opentelemetry/sdk-logs": "0.220.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-8LZAxdJ0ENDAFwr4j0oY35mHBltiSzvlhdQAPGiC7p9VnxtuSq4SW1gfBAdW6t6hiQG6OwUl8w7KHaOdJPKHWg=="], + + "@opentelemetry/exporter-metrics-otlp-http": ["@opentelemetry/exporter-metrics-otlp-http@0.220.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/otlp-exporter-base": "0.220.0", "@opentelemetry/otlp-transformer": "0.220.0", "@opentelemetry/resources": "2.9.0", "@opentelemetry/sdk-metrics": "2.9.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-Yqt3RBw/bRVncaE9qIIhk4WfjbAQqXuP9FgAaU+IKPndnLEp/cUqZlSC324+bpmduRz7DoTjig8Ub0PeILWXUA=="], + + "@opentelemetry/exporter-metrics-otlp-proto": ["@opentelemetry/exporter-metrics-otlp-proto@0.220.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/exporter-metrics-otlp-http": "0.220.0", "@opentelemetry/otlp-exporter-base": "0.220.0", "@opentelemetry/otlp-transformer": "0.220.0", "@opentelemetry/resources": "2.9.0", "@opentelemetry/sdk-metrics": "2.9.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-lyO+IQBdSvqHN/ZOW/OzrSWemtfD+HgWngn+HBNLhjy0YrCQQTz0OE/kSekH2Pl340dn9DWzhqHdz5Eftr+HLA=="], + + "@opentelemetry/exporter-trace-otlp-proto": ["@opentelemetry/exporter-trace-otlp-proto@0.220.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/otlp-exporter-base": "0.220.0", "@opentelemetry/otlp-transformer": "0.220.0", "@opentelemetry/resources": "2.9.0", "@opentelemetry/sdk-trace": "2.9.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-voTAD8XgJxlK7zLkXh8EzMB09zrQr3tyY/BsnDTlDiQU/UdK58MZ63A3mUjdEDrxMjCVmBHU3WQJhRmQe+Dvzg=="], + + "@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.220.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/otlp-transformer": "0.220.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CXYo8UD5Mn9YbgebO2EL4wejtA+gxLmLiu6HCk2KH2BR7XhFN6/6p1UlCb23DYCjeYkndevLHuejCCN1yx4+OQ=="], + + "@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.220.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.220.0", "@opentelemetry/core": "2.9.0", "@opentelemetry/resources": "2.9.0", "@opentelemetry/sdk-logs": "0.220.0", "@opentelemetry/sdk-metrics": "2.9.0", "@opentelemetry/sdk-trace": "2.9.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-lXGrv7KXZ0gNH9SVNUaa6vv6phVYGvJxfXAlMbzbakiXru75f5MZl8Z7oqiMMQD77riVHJCFlQvbZs/VVN2/4A=="], + + "@opentelemetry/resources": ["@opentelemetry/resources@2.10.0", "", { "dependencies": { "@opentelemetry/core": "2.10.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-q6MMm2zhggzsHVNbabYwut+a6nbuQQe3URUoxaojM/8K1IBfwwPzvxIjNi2/lI1TFe+fMHMW9MWhrtDLEXEnkA=="], + + "@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.220.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.220.0", "@opentelemetry/core": "2.9.0", "@opentelemetry/resources": "2.9.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-WywcTkQtv2iNmt+6y5Kcd4rzvx9bLVsBa2Nwcmg01IUaBTkTow3W4d9KE5vNBpEDtb9tp21WcRBY/lANRrApYA=="], + + "@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.10.0", "", { "dependencies": { "@opentelemetry/core": "2.10.0", "@opentelemetry/resources": "2.10.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-t6r1VSvXNtSDnPXU1FbZeetJb7yyovHmgu0wRSoftxtE0g2rSNhQZQUy69sRUCL+iioJpX8SN/S6wq6ZtvLySQ=="], + + "@opentelemetry/sdk-trace": ["@opentelemetry/sdk-trace@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/resources": "2.9.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-sGA19HvtrrSKYsseHphluH6j3p6Xa3fqc7c7y8f/7mYWejc1lyDFcpSdD1kYa50HCLUeEo4zA5bW0pniaPszuw=="], + + "@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.10.0", "", { "dependencies": { "@opentelemetry/core": "2.10.0", "@opentelemetry/resources": "2.10.0", "@opentelemetry/sdk-trace": "2.10.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-GuYQQT7QD2EeO8lcZLRQzcbOyhqAzL+6WWTKTU9mSUBYBazkEDl+VrQcXQhbB08OWM9anD1aHleVadzulpOaUQ=="], + + "@opentelemetry/sdk-trace-node": ["@opentelemetry/sdk-trace-node@2.10.0", "", { "dependencies": { "@opentelemetry/context-async-hooks": "2.10.0", "@opentelemetry/core": "2.10.0", "@opentelemetry/sdk-trace-base": "2.10.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-GZK/G6oZyBLGlH1pUgeDch7D91KoHd2uotUGIkWCPi9GI5T9X0p4L7nNAMDR1BQjkRYoDqo+ddfVx9t5Uhys+Q=="], + + "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="], + + "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], + + "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], + + "@protobufjs/codegen": ["@protobufjs/codegen@2.0.5", "", {}, "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g=="], + + "@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.1", "", {}, "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg=="], + + "@protobufjs/fetch": ["@protobufjs/fetch@1.1.1", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1" } }, "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw=="], + + "@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="], + + "@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="], + + "@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="], + + "@protobufjs/utf8": ["@protobufjs/utf8@1.1.2", "", {}, "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug=="], + + "@puppeteer/browsers": ["@puppeteer/browsers@3.0.6", "", { "dependencies": { "modern-tar": "^0.7.6", "yargs": "^18.0.0" }, "peerDependencies": { "proxy-agent": ">=8.0.1", "yauzl": "^2.10.0 || ^3.4.0" }, "optionalPeers": ["proxy-agent", "yauzl"], "bin": { "browsers": "lib/main-cli.js" } }, "sha512-B/gKoqlFkzhvzsI6jo9K1cZz9o5ypviVv/xu8CwA4grZzyVwN+XfkT+tu8T1zrauuEXv6VhS2oGX+6NL95WcKA=="], + + "@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="], + + "@types/node": ["@types/node@20.19.43", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA=="], + + "adm-zip": ["adm-zip@0.5.18", "", {}, "sha512-ufJnssQGbxzLNS1Ho9bCtX4rQKCCvoVuDLHoJyc3F9dOGDB4BkWs2Ci0kv53lqocAEQ/Cbi+I2XCsNYGqVYqng=="], + + "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + + "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="], + + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + + "chart.js": ["chart.js@4.5.1", "", { "dependencies": { "@kurkle/color": "^0.3.0" } }, "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw=="], + + "chromium-bidi": ["chromium-bidi@16.0.1", "", { "dependencies": { "mitt": "^3.0.1", "zod": "^3.24.1" }, "peerDependencies": { "devtools-protocol": "*" } }, "sha512-J63PGu/9PpeCwLIcKYyzWP6yaVL5pxuBc0shlYCYM8BaAkmlwiQboXO1iNbOgSDbVklEyYFfNEcHD8oOAWacUA=="], + + "cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="], + + "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], + + "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "detect-node": ["detect-node@2.1.0", "", {}, "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g=="], + + "devtools-protocol": ["devtools-protocol@0.0.1638949", "", {}, "sha512-mXwg4Fqnv0WR4iuAT/gYUmctNkjILwXFHyZ+m7Ty1dfr0ezZt2U3gnrrJTfRobJTHoXf+IbuFvFITzLrLFjwJA=="], + + "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + + "enhanced-resolve": ["enhanced-resolve@5.24.5", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es6-error": ["es6-error@4.1.1", "", {}, "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + + "flatbuffers": ["flatbuffers@25.9.23", "", {}, "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ=="], + + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + + "get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="], + + "global-agent": ["global-agent@3.0.0", "", { "dependencies": { "boolean": "^3.0.1", "es6-error": "^4.1.1", "matcher": "^3.0.0", "roarr": "^2.15.3", "semver": "^7.3.2", "serialize-error": "^7.0.1" } }, "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q=="], + + "globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "guid-typescript": ["guid-typescript@1.0.9", "", {}, "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ=="], + + "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], + + "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], + + "json-stringify-safe": ["json-stringify-safe@5.0.1", "", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="], + + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + + "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], + + "lucide-react": ["lucide-react@1.31.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-G8u2eEtoHUnUa9f8lbvqDhCiORMnYLdUEo06EEG9MQvHQrInKcX3Pa2TH39MM5qyzRcWETxB0+aOwAPI1g1kEg=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "matcher": ["matcher@3.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng=="], + + "mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="], + + "modern-tar": ["modern-tar@0.7.7", "", {}, "sha512-t9VmxaqrmANnEOBhpSDI6HD192Ge48k8vmWqQQL7hSFEqHEYwZbbsu49+aKLWZeRvFs3j1pMhXOqqF4kPlvjkQ=="], + + "mupdf": ["mupdf@1.28.0", "", {}, "sha512-ACUnbpECaQ5JLq04pwd89lS+0IGMest5qL5tb08g9TAR7bDtfqflHEkb2Xm3o4rvC/szguLiV+WEbW9kstj8Sg=="], + + "object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="], + + "onnxruntime-common": ["onnxruntime-common@1.24.3", "", {}, "sha512-GeuPZO6U/LBJXvwdaqHbuUmoXiEdeCjWi/EG7Y1HNnDwJYuk6WUbNXpF6luSUY8yASul3cmUlLGrCCL1ZgVXqA=="], + + "onnxruntime-node": ["onnxruntime-node@1.24.3", "", { "dependencies": { "adm-zip": "^0.5.16", "global-agent": "^3.0.0", "onnxruntime-common": "1.24.3" }, "os": [ "linux", "win32", "darwin", ] }, "sha512-JH7+czbc8ALA819vlTgcV+Q214/+VjGeBHDjX81+ZCD0PCVCIFGFNtT0V4sXG/1JXypKPgScQcB3ij/hk3YnTg=="], + + "onnxruntime-web": ["onnxruntime-web@1.26.0-dev.20260416-b7804b056c", "", { "dependencies": { "flatbuffers": "^25.1.24", "guid-typescript": "^1.0.9", "long": "^5.2.3", "onnxruntime-common": "1.24.0-dev.20251116-b39e144322", "platform": "^1.3.6", "protobufjs": "^7.2.4" } }, "sha512-MD6Ss4GSpQBo6zqoJzyT9LRbKYs7x/JVN23FT24EcEvlqF4VuzPOeH6X38orZPKHQDbprn7K+SBpu0/mj2CQiw=="], + + "platform": ["platform@1.3.6", "", {}, "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg=="], + + "protobufjs": ["protobufjs@7.6.5", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw=="], + + "puppeteer-core": ["puppeteer-core@25.3.0", "", { "dependencies": { "@puppeteer/browsers": "3.0.6", "chromium-bidi": "16.0.1", "devtools-protocol": "0.0.1638949", "typed-query-selector": "^2.12.2", "webdriver-bidi-protocol": "0.4.2", "ws": "^8.21.0" } }, "sha512-fm+wpUr2oigH1PXZvwgATrM2tYWHMDG8ASzTEe9uukCye4X5Ldx1K5BTHPFKITrIWvQQAQ256d1NpbEveBcKjA=="], + + "react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="], + + "react-chartjs-2": ["react-chartjs-2@5.3.1", "", { "peerDependencies": { "chart.js": "^4.1.1", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-h5IPXKg9EXpjoBzUfyWJvllMjG2mQ4EiuHQFhms/AjUm0XSZHhyRy2xVmLXHKrtcdrPO4mnGqRtYoD0vp95A0A=="], + + "react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="], + + "roarr": ["roarr@2.15.4", "", { "dependencies": { "boolean": "^3.0.1", "detect-node": "^2.0.4", "globalthis": "^1.0.1", "json-stringify-safe": "^5.0.1", "semver-compare": "^1.0.0", "sprintf-js": "^1.1.2" } }, "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A=="], + + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + + "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + + "semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="], + + "serialize-error": ["serialize-error@7.0.1", "", { "dependencies": { "type-fest": "^0.13.1" } }, "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw=="], + + "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], + + "sherpa-onnx-darwin-arm64": ["sherpa-onnx-darwin-arm64@1.13.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QcYKzyrTzGSx6aKCD6hUODgRS1LetqfG57Z/+i5LCyfMlrgCvDc1lRcl9cdB+TozBsLha9QwLTlI0vmDcf5JKg=="], + + "sherpa-onnx-darwin-x64": ["sherpa-onnx-darwin-x64@1.13.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-6RGeis9K9gV/UQWOgd6Rf3iqXr2/YsBQswxHaCR4hrYkHfEIpHMfFmRWLt6nJJCOWgYW2xFxEd9yzjrafAV/Pw=="], + + "sherpa-onnx-linux-arm64": ["sherpa-onnx-linux-arm64@1.13.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-RMjMRqT82BgTXypNNGmLe6ZFYhc3WEvnAGl3DdkK7qB/kuXwkL3iHhV31wAecbnWPsnEpUoD+8cFovWSBzsCuw=="], + + "sherpa-onnx-linux-x64": ["sherpa-onnx-linux-x64@1.13.4", "", { "os": "linux", "cpu": "x64" }, "sha512-WZh5NCkGPFHHpYSd78iN4OnmxQeSTGyt9uZskH+im/NFHQ7elQ7B0sLzCMeRpvJxiIKvd9C6WxIJ4hYaxClfsQ=="], + + "sherpa-onnx-node": ["sherpa-onnx-node@1.13.2", "", { "optionalDependencies": { "sherpa-onnx-darwin-arm64": "^1.13.2", "sherpa-onnx-darwin-x64": "^1.13.2", "sherpa-onnx-linux-arm64": "^1.13.2", "sherpa-onnx-linux-x64": "^1.13.2", "sherpa-onnx-win-ia32": "^1.13.2", "sherpa-onnx-win-x64": "^1.13.2" } }, "sha512-uIH6SA5Or4pb8HlCYWB3K54XkMtzdef4/tkw1amtIf8GB1tt6hQLpur9p2jSFNfTYRyzZ8XrXofxefXQ0A7EUA=="], + + "sherpa-onnx-win-ia32": ["sherpa-onnx-win-ia32@1.13.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-/JbPjldrfNv+t+uIS3MlkuhfIf5l3FHUGkRC2oRXgjRqOaVmEyP3vLlQ7dTa4J7raG5oB8c3GoPjuSWSqT9GOQ=="], + + "sherpa-onnx-win-x64": ["sherpa-onnx-win-x64@1.13.4", "", { "os": "win32", "cpu": "x64" }, "sha512-R0PWby1VxC14TDZPq7GcfSyXSY6SAFO8Y4JwdCdqouFmeXkZ1L7Is9m98C9KxQ0dN7ZtDzhAmE/43FUs/elXRQ=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="], + + "string-width": ["string-width@8.2.2", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg=="], + + "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + + "tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="], + + "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="], + + "typed-query-selector": ["typed-query-selector@2.12.2", "", {}, "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "webdriver-bidi-protocol": ["webdriver-bidi-protocol@0.4.2", "", {}, "sha512-VSV+fzfChirL3e7jay2yUC7B4HQCGtEWEg/MSSQbK+qWbqeGlRLlXTzPpYr3XGUvbpDHumWZBJxgesg4N7dbtA=="], + + "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], + + "ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="], + + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], + + "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + + "yargs": ["yargs@18.1.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^8.2.1", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg=="], + + "yargs-parser": ["yargs-parser@22.0.0", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="], + + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@opentelemetry/exporter-metrics-otlp-http/@opentelemetry/resources": ["@opentelemetry/resources@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg=="], + + "@opentelemetry/exporter-metrics-otlp-http/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/resources": "2.9.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-Xx8RGS4H5XEBl01WuCreMIpiah9cCXMbSkeuIePPdD2cUpq/vUzYmj8E/MK1OsbOc93FuAD4jfn2WOacKwLn7Q=="], + + "@opentelemetry/exporter-metrics-otlp-proto/@opentelemetry/resources": ["@opentelemetry/resources@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg=="], + + "@opentelemetry/exporter-metrics-otlp-proto/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/resources": "2.9.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-Xx8RGS4H5XEBl01WuCreMIpiah9cCXMbSkeuIePPdD2cUpq/vUzYmj8E/MK1OsbOc93FuAD4jfn2WOacKwLn7Q=="], + + "@opentelemetry/exporter-trace-otlp-proto/@opentelemetry/resources": ["@opentelemetry/resources@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg=="], + + "@opentelemetry/otlp-transformer/@opentelemetry/resources": ["@opentelemetry/resources@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg=="], + + "@opentelemetry/otlp-transformer/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/resources": "2.9.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-Xx8RGS4H5XEBl01WuCreMIpiah9cCXMbSkeuIePPdD2cUpq/vUzYmj8E/MK1OsbOc93FuAD4jfn2WOacKwLn7Q=="], + + "@opentelemetry/resources/@opentelemetry/core": ["@opentelemetry/core@2.10.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-/wNZ8twnEQQA4HoHu22+vcsdru6pWPWxW+7w+FlxT6Id7PE/WIbZmVKkte+PF72e0F2dnImFeHD2syyE1Mw6MQ=="], + + "@opentelemetry/sdk-logs/@opentelemetry/resources": ["@opentelemetry/resources@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg=="], + + "@opentelemetry/sdk-metrics/@opentelemetry/core": ["@opentelemetry/core@2.10.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-/wNZ8twnEQQA4HoHu22+vcsdru6pWPWxW+7w+FlxT6Id7PE/WIbZmVKkte+PF72e0F2dnImFeHD2syyE1Mw6MQ=="], + + "@opentelemetry/sdk-trace/@opentelemetry/resources": ["@opentelemetry/resources@2.9.0", "", { "dependencies": { "@opentelemetry/core": "2.9.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg=="], + + "@opentelemetry/sdk-trace-base/@opentelemetry/core": ["@opentelemetry/core@2.10.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-/wNZ8twnEQQA4HoHu22+vcsdru6pWPWxW+7w+FlxT6Id7PE/WIbZmVKkte+PF72e0F2dnImFeHD2syyE1Mw6MQ=="], + + "@opentelemetry/sdk-trace-base/@opentelemetry/sdk-trace": ["@opentelemetry/sdk-trace@2.10.0", "", { "dependencies": { "@opentelemetry/core": "2.10.0", "@opentelemetry/resources": "2.10.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-MfQGq3GRmTh5fM/y+OjaO0vj6+luCB1XO2gfXCalKCfgKw0eHL++sm75DNweC6ohlp+aFvACqeE0fYayqdRaoQ=="], + + "@opentelemetry/sdk-trace-node/@opentelemetry/core": ["@opentelemetry/core@2.10.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-/wNZ8twnEQQA4HoHu22+vcsdru6pWPWxW+7w+FlxT6Id7PE/WIbZmVKkte+PF72e0F2dnImFeHD2syyE1Mw6MQ=="], + + "cliui/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + + "onnxruntime-web/onnxruntime-common": ["onnxruntime-common@1.24.0-dev.20251116-b39e144322", "", {}, "sha512-BOoomdHYmNRL5r4iQ4bMvsl2t0/hzVQ3OM3PHD0gxeXu1PmggqBv3puZicEUVOA3AtHHYmqZtjMj9FOfGrATTw=="], + + "wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + } +} diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..d267575 --- /dev/null +++ b/index.ts @@ -0,0 +1,1603 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { + ExtensionAPI, + ExtensionContext, + SessionStartEvent, +} from "@oh-my-pi/pi-coding-agent"; +import { Box, Text } from "@oh-my-pi/pi-tui"; +import { parseTaskFile, updateTaskInFile } from "./src/parser"; +import { + buildExecutionPlan, + buildSequentialPlan, + formatDependencyChain, + formatExecutionPlan, +} from "./src/dag"; +import { ProgressTracker } from "./src/progress"; +import { buildPlanPrompt } from "./src/prompts"; +import { loadTaskManagerPrompt } from "./src/task-manager-prompt"; +import { formatReflections } from "./src/reflection"; +import { verdictGlyph, verdictSummary, formatFindings } from "./src/review"; +import type { ReviewResult } from "./src/types"; +import { executeBatch, type SendChatMessage, setStreamForwarder } from "./src/executor"; +import { + cleanupStaleWorktrees, + finalizeCommittedWorktrees, + abortMerge, +} from "./src/worktree"; +import { + loadConfig, + resolveTaskArg, + formatProgressStatus, + findProgressFile, + writeLoopActive, + deleteLoopActive, + readLoopActive, + findRalpiDir, + ensureRalpiIgnored, + listPRDsSorted, + countPRDResumeStats, + formatDuration, +} from "./src/utils"; + +type ExecutionMode = "parallel" | "sequential"; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** + * Split a `--no-gitignore` opt-out out of the command args (in place). The + * flag controls whether `/ralpi run|resume|reset` auto-adds `.ralpi/` to the + * project's `.gitignore` — it defaults to on so ralpi's own artifacts never + * end up staged in the user's repo. + */ +function stripNoGitignore(args: string[]): boolean { + const i = args.indexOf("--no-gitignore"); + if (i === -1) return false; + args.splice(i, 1); + return true; +} + +/** + * Ensure `.ralpi/` is gitignored in the project (unless opted out), and + * notify once when the guard actually appended the entry. + */ +function ensureIgnoredNote( + projectDir: string, + ctx: ExtensionContext, + noGitignore = false, +): void { + if (noGitignore) return; + if (ensureRalpiIgnored(projectDir)) { + ctx.ui.notify( + "· .ralpi/ added to .gitignore (opt out with --no-gitignore)", + "info", + ); + } +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** + * Detect if a token looks like a file path rather than a subcommand. + * Matches: @path, /path, ./path, ../path, path/to/file, path.md, path.yaml + */ +function looksLikePath(token: string): boolean { + return ( + token.startsWith("@") || + token.startsWith("/") || + token.startsWith("./") || + token.startsWith("../") || + token.includes("/") || + token.endsWith(".md") || + token.endsWith(".yaml") || + token.endsWith(".yml") + ); +} + +/** Build the set of completed tasks from progress tracker and PRD checkboxes. */ +function buildCompletedSet( + progress: ProgressTracker, + project: import("./src/types").Project, +): Set { + const completed = new Set(progress.getCompletedTaskIds()); + for (const task of project.tasks) { + if (task.status === "completed") { + completed.add(task.id); + } + } + return completed; +} + +/** Prompt user to select an execution mode with dependency validation. */ +async function selectExecutionMode( + ctx: ExtensionContext, + project: import("./src/types").Project, + taskFile: string, + config: import("./src/types").RalpiConfig, +): Promise { + const mode = await ctx.ui.select("Execution mode for this run?", [ + `Parallel (where dependencies allow)[${config.execution.maxParallel} max]`, + "Sequential (one at a time)", + ]); + const isParallel = mode?.startsWith("Parallel") ?? false; + + if (!isParallel) return "sequential"; + + // Validate dependency graph for parallel mode + if (Object.keys(project.dependencies).length === 0) { + const hasDepsSection = await fs.promises + .readFile(taskFile, "utf-8") + .then((content) => /^##\s+Dependencies\s*$/m.test(content)) + .catch(() => false); + + if (hasDepsSection) { + const choice = await ctx.ui.select( + "Found ## Dependencies section but no valid dependencies were parsed.\n\n" + + "This may be due to unsupported format. Parallel mode requires explicit dependencies.\n\n" + + "See README.md for supported dependency formats:\n" + + "- Arrow notation: `1 -> 2,3,4`\n" + + "- Natural language: `13 depends on 17, 18, 19, 20`\n\n" + + "Fall back to sequential mode?", + ["Yes, use sequential", "No, continue with parallel"], + ); + if (choice?.startsWith("Yes")) { + return "sequential"; + } + } + } + + return "parallel"; +} + +/** Build an execution plan based on the selected mode. */ +function buildPlanByMode( + mode: ExecutionMode, + project: Parameters[0], + completed: Set, +) { + return mode === "parallel" + ? buildExecutionPlan(project, completed) + : buildSequentialPlan(project, completed); +} + +/** + * Prompt the user to select auto-review and auto-commit options for this loop. + * Reviews are asked about FIRST. When autoReview is on, commit is always + * mandated (it happens before review) — so autoCommit is forced true and not + * asked about. When autoReview is off, autoCommit is asked as a stand-alone + * toggle. Fields explicitly set in the config YAML are skipped (no prompt). + * Returns the selected options (or config defaults if cancelled). + */ +async function selectLoopOptions( + ctx: ExtensionContext, + config: import("./src/types").RalpiConfig, +): Promise<{ autoCommit: boolean; autoReview: boolean; saveReviews: boolean }> { + const explicit = config.execution.explicitKeys; + + // ── 1. Auto-review (asked FIRST) ── + // When enabled, a commit is mandated before review (the task agent's + // changes are committed, then the complete diff is reviewed). On 'fail' + // the task is re-executed with review feedback (looping until pass or + // maxReviewRetries exhausted). On pass the worktree merges. + let autoReview: boolean; + if (explicit?.has("autoReview")) { + autoReview = config.execution.autoReview; + } else { + const reviewChoice = await ctx.ui.select("Auto-review after each task?", [ + "Yes — review the task commit and loop on failures (re-execute until pass)", + "No — skip review", + ]); + autoReview = reviewChoice + ? reviewChoice.startsWith("Yes") + : config.execution.autoReview; + } + + // ── 2. Save full review output to disk (only when review is enabled) ── + let saveReviews = false; + if (autoReview) { + if (explicit?.has("saveReviews")) { + saveReviews = config.execution.saveReviews; + } else { + const saveChoice = await ctx.ui.select( + "Save full review output to disk? (recommended — enables review feedback recovery when resuming interrupted loops)", + [ + "Yes — write each review to .ralpi/reviews//.json", + "No — keep reviews in-chat only", + ], + ); + saveReviews = saveChoice + ? saveChoice.startsWith("Yes") + : config.execution.saveReviews; + } + } + + // ── 3. Auto-commit ── + // When autoReview is on, commit is always mandated (it happens before the + // review). autoCommit is forced true and not asked about. When review is + // disabled, autoCommit is asked as a stand-alone "commit per task" toggle. + let autoCommit: boolean; + if (autoReview) { + autoCommit = true; // mandated by the review-gated flow + } else if (explicit?.has("autoCommit")) { + autoCommit = config.execution.autoCommit; + } else { + const commitChoice = await ctx.ui.select("Auto-commit after each task?", [ + "Yes — stage and commit changes automatically", + "No — skip auto-commit", + ]); + autoCommit = commitChoice + ? commitChoice.startsWith("Yes") + : config.execution.autoCommit; + } + + return { autoCommit, autoReview, saveReviews }; +} + +/** + * When multiple PRD loops have progress, prompt the user to select which one + * to act on. Returns the selected PRD key and sourcePath. + * If only one PRD exists, returns it without prompting. + * Returns null if no PRDs exist. + */ +async function selectPRD( + ctx: ExtensionContext, + found: NonNullable>, + prompt: string, +): Promise<{ prdKey: string; sourcePath: string } | null> { + const prds = listPRDsSorted(found.state); + if (prds.length === 0) return null; + if (prds.length === 1) { + return { prdKey: prds[0].key, sourcePath: prds[0].prd.sourcePath }; + } + + // Multiple PRDs — show selection sorted by most recent first + const options = prds.map((entry) => { + // Total/completed must come from the parsed PRD file, not just the + // progress map: the tracker only records TOUCHED tasks (started/ + // completed/failed), so never-started tasks would be silently missing + // from a naive Object.keys() count and the totals would under-report. + const { total, completed, failed } = countPRDResumeStats( + entry.prd, + entry.prd.sourcePath, + ); + const relPath = path.relative(ctx.cwd, entry.prd.sourcePath); + const updated = new Date(entry.prd.lastUpdatedAt).toLocaleString(); + return `${relPath} — ${completed}/${total} done${failed ? `, ${failed} failed` : ""} · ${updated}`; + }); + + const selected = await ctx.ui.select(prompt, options); + if (!selected) return null; + + const idx = options.indexOf(selected); + if (idx === -1) return null; + return { + prdKey: prds[idx].key, + sourcePath: prds[idx].prd.sourcePath, + }; +} + +/** Run all batches in a plan, updating the task file after each batch. */ +async function executePlanBatches( + plan: ReturnType, + project: Parameters[0], + taskFile: string, + config: import("./src/types").RalpiConfig, + progress: ProgressTracker, + ctx: ExtensionContext, + mode: ExecutionMode, + sendChatMessage?: SendChatMessage, + projectDir?: string, + isResume?: boolean, +): Promise { + // Refresh the model registry so the host reloads models.json before we + // resolve the round-robin model pool. The registry snapshot is captured at + // host startup and only reloaded here; a long-running host would otherwise + // skip providers added to models.json after it booted (e.g. "strix"). + // Best-effort: a failed refresh shouldn't block execution — the pool just + // resolves against the existing snapshot. + try { + await ctx.modelRegistry?.refresh(); + } catch (error) { + ctx.ui.notify( + `ralpi: model registry refresh failed — continuing with existing snapshot: ${ + error instanceof Error ? error.message : String(error) + }`, + "warning", + ); + } + + // Write loop-active marker so a session reload can detect an interrupted + // loop and resume it (in-process agent sessions die on reload — the marker + // + progress.json in_progress tasks are the signal to re-run them). + if (projectDir) { + const allTaskIds = plan.batches.flatMap((b) => b.tasks.map((t) => t.id)); + writeLoopActive(projectDir, { + taskFile, + mode, + startedAt: new Date().toISOString(), + taskIds: allTaskIds, + prdKey: progress.getKey(), + autoCommit: config.execution.autoCommit, + autoReview: config.execution.autoReview, + saveReviews: config.execution.saveReviews, + }); + + // Clean up stale worktrees from interrupted runs before starting. + // On resume this MUST be skipped: an interrupted in-progress task's + // worktree still carries its committed branch, which createWorktree() + // reuses to continue the task rather than restarting from scratch. + // The stale-worktree sweep only runs for fresh loops so concurrent + // loops (other PRDs) are still scoped out via the prdKey filter above. + if (!isResume && config.execution.worktrees !== "never" && projectDir) { + const removed = cleanupStaleWorktrees( + projectDir, + config.paths.stateDir, + progress.getKey(), + ); + if (removed.length > 0) { + ctx.ui.notify( + `Cleaned up ${removed.length} stale worktree(s) from previous run.`, + "info", + ); + } + } + } + + // Track failed task IDs across batches to block downstream tasks + const failedTaskIds = new Set(progress.getFailedTaskIds()); + + // Loop-level execution timeout: stop starting new batches once elapsed. + // In-progress tasks finish naturally; we just skip remaining batches. + const loopStart = Date.now(); + const loopTimeoutMs = config.execution.loopTimeoutMs; + let loopTimedOut = false; + + try { + for (const batch of plan.batches) { + // Check loop timeout before starting a new batch + if (loopTimeoutMs > 0 && Date.now() - loopStart > loopTimeoutMs) { + loopTimedOut = true; + break; + } + + if (progress.getState().paused) { + ctx.ui.notify( + "Execution paused. Use /ralpi resume to continue.", + "warning", + ); + return; + } + + if (!Array.isArray(batch.tasks)) { + throw new Error( + `Batch ${ + batch.batchIndex + } has invalid tasks: expected array, got ${typeof batch.tasks}`, + ); + } + + await executeBatch( + batch.tasks, + project, + config, + progress, + ctx, + { parallel: mode === "parallel" }, + sendChatMessage, + projectDir, + ); + + for (const task of batch.tasks) { + const status = progress.getTaskStatus(task.id); + updateTaskInFile(taskFile, task.id, status); + } + + // Update failed task IDs after batch completes + const newFailed = progress.getFailedTaskIds(); + for (const id of newFailed) { + failedTaskIds.add(id); + } + + // In sequential mode, stop after any failure + if (mode === "sequential" && failedTaskIds.size > 0) { + break; + } + + // In parallel mode, rebuild the plan to filter out newly blocked tasks + if (mode === "parallel") { + // Use buildCompletedSet to include file-based [x] completions + // (progress.getCompletedTaskIds() only knows about tasks completed + // during THIS execution session — tasks that were already [x] in the + // file before the run started would be re-included and re-executed). + const completed = buildCompletedSet(progress, project); + const newPlan = buildExecutionPlan( + project, + completed, + undefined, + failedTaskIds, + ); + + // Keep processed batches (up to current batch), replace the rest + // with the fresh plan — its batchIndex restarts at 0, so filtering + // by batchIndex > currentIdx would incorrectly drop the next batch. + const processedCount = plan.batches.indexOf(batch) + 1; + plan.batches.length = processedCount; + plan.batches.push(...newPlan.batches); + + // Skip if nothing remaining + if (plan.batches.length === processedCount) { + break; + } + } + } + } finally { + if (projectDir) { + deleteLoopActive(projectDir); + } + if (loopTimedOut) { + const elapsed = formatDuration(Date.now() - loopStart); + ctx.ui.notify( + `Loop execution timeout reached (${elapsed}). Remaining tasks skipped. Use /ralpi resume to continue.`, + "warning", + ); + } + } +} + +// ─── Shared Helpers ───────────────────────────────────────────────────────── + +/** + * Build a sendProgress closure that posts ralpi progress messages into the + * chat history for the expandable tool-call-tree renderer. + * + * Used by every registered command so they share one rendering path. + */ +function makeSendProgress(pi: ExtensionAPI): SendChatMessage { + return (content, meta) => { + pi.sendMessage({ + customType: "ralpi-progress", + content, + display: true, + details: { + phase: "progress", + toolCalls: meta?.toolCalls, + reviewText: meta?.reviewText, + reviewPath: meta?.reviewPath, + reviewResult: meta?.reviewResult, + }, + }); + }; +} + +/** Pick the one useful argument from a tool-call's args (path/command/…). */ +function summarizeArgs(args: unknown): string { + if (!args || typeof args !== "object") return ""; + const obj = args as Record; + const pickKey = ["file_path", "path", "command", "pattern", "query", "url"].find( + (k) => typeof obj[k] === "string", + ); + if (pickKey) { + const value = String(obj[pickKey]); + return value.length > 120 ? `${value.slice(0, 117)}…` : value; + } + const json = JSON.stringify(obj); + return json.length > 120 ? `${json.slice(0, 117)}…` : json; +} + +/** Collapse a tool result down to a single short line. */ +function summarizeToolResult(result: unknown): string { + if (result == null) return ""; + if (typeof result === "string") return result; + if (typeof result === "number" || typeof result === "boolean") + return String(result); + if (Array.isArray(result)) { + return result + .map((item) => { + if (typeof item === "string") return item; + if ( + item && + typeof item === "object" && + "text" in (item as Record) + ) + return String((item as { text?: unknown }).text ?? ""); + return JSON.stringify(item); + }) + .join("\n"); + } + if (typeof result !== "object") return ""; + const obj = result as Record; + if (Array.isArray(obj.content)) { + const unwrapped = summarizeToolResult(obj.content); + if (unwrapped) return unwrapped; + } + const preferKey = ["stdout", "output", "text", "content", "result"].find( + (k) => typeof obj[k] === "string" && (obj[k] as string).length > 0, + ); + if (preferKey) return obj[preferKey] as string; + try { + return JSON.stringify(obj); + } catch { + return ""; + } +} + +/** Collapse whitespace and cap a line at `max` chars with an ellipsis. */ +function compactLine(text: string, max: number): string { + const collapsed = text.replace(/\s+/g, " ").trim(); + if (collapsed.length <= max) return collapsed; + return `${collapsed.slice(0, max - 1)}…`; +} + +/** Extract joined text from an assistant message's content blocks. */ +function extractAssistantTextFromContent(content: unknown): string { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + return content + .flatMap((c) => + c && typeof c === "object" && + (c as { type?: string }).type === "text" + ? [(c as { text?: string }).text ?? ""] + : [], + ) + .join(""); +} + +/** + * Build a stream-event forwarder that posts ralpi-stream messages (one chat + * line per tool start/end and assistant turn) into the chat. Only used when + * execution.chatStyle is "verbose". + */ +function makeStreamForwarder(pi: ExtensionAPI): (phase: string, event: import("@oh-my-pi/pi-coding-agent").AgentSessionEvent) => void { + const send = (details: { + kind: "tool-start" | "tool-end" | "tool-error" | "assistant"; + phase: string; + toolName?: string; + body?: string; + }, fallback: string) => { + pi.sendMessage({ + customType: "ralpi-stream", + content: fallback, + display: true, + details, + }); + }; + + return (phase: string, event: import("@oh-my-pi/pi-coding-agent").AgentSessionEvent) => { + switch (event.type) { + case "tool_execution_start": { + const body = summarizeArgs(event.args); + send({ kind: "tool-start", phase, toolName: event.toolName, body }, `[${phase}] → ${event.toolName}${body ? ` ${body}` : ""}`); + return; + } + case "tool_execution_end": { + const body = compactLine(summarizeToolResult(event.result), 200); + const kind = event.isError ? "tool-error" : "tool-end"; + const marker = event.isError ? "✗" : "←"; + send({ kind, phase, toolName: event.toolName, body }, `[${phase}] ${marker} ${event.toolName}${body ? ` ${body}` : ""}`); + return; + } + case "message_end": { + const message = event.message as { role?: string; content?: unknown }; + if (message.role !== "assistant") return; + const text = extractAssistantTextFromContent(message.content).trim(); + if (!text) return; + const head = compactLine(text, 240); + send({ kind: "assistant", phase, body: head }, `[${phase}] ${head}`); + return; + } + } + }; +} + +// ─── Extension Entry ──────────────────────────────────────────────────────── + +export default function ralpiLoopExtension(pi: ExtensionAPI): void { + // Wire the verbose stream forwarder — posts each tool event as its own + // chat message via the ralpi-stream renderer. Enabled per-run by + // `execution.chatStyle: verbose` in the config YAML. + setStreamForwarder(makeStreamForwarder(pi)); + + // Register custom message renderer for ralpi progress messages. + // Renders an expandable tool-call tree: collapsed shows last 3 + "N more", + // expanded (Ctrl+O) shows every tool call. + pi.registerMessageRenderer( + "ralpi-progress", + (message, { expanded }, theme) => { + const details = message.details as + | { + phase?: string; + toolCalls?: Array<{ name: string; label: string }>; + reviewText?: string; + reviewPath?: string; + reviewResult?: ReviewResult; + } + | undefined; + + const MAX_COLLAPSED = 3; + const lines: string[] = []; + + // Header line — e.g. "✓ 05 · billing-subscriptions-trials (2m 14s)" + lines.push(String(message.content)); + + // Structured review: when we have a ReviewResult, render verdict + + // findings tree. In expanded mode show findings detail; collapsed + // shows the verdict summary + a hint to expand. + const hasReview = !!details?.reviewText || !!details?.reviewResult; + if (details?.reviewResult) { + const rv = details.reviewResult; + const glyph = verdictGlyph(rv.verdict); + const summary = verdictSummary(rv); + if (expanded) { + // Show verdict, summary, then findings tree, then raw text. + lines.push(` ${glyph} VERDICT: ${rv.verdict.toUpperCase()}`); + lines.push(` ${rv.summary}`); + if (rv.findings.length > 0) { + lines.push(` ${formatFindings(rv)}`); + } + if (details.reviewText) { + const body = details.reviewText.split("\n"); + for (const line of body) { + lines.push(` ${line}`); + } + } + } else { + const hint = details.reviewPath + ? `press Ctrl+O for full review · saved to ${details.reviewPath}` + : "press Ctrl+O for full review"; + lines.push(theme.fg("dim", ` ├── ${glyph} ${summary} · ${hint}`)); + } + } else if (hasReview && expanded && details!.reviewText) { + const body = details!.reviewText.split("\n"); + for (const line of body) { + lines.push(` ${line}`); + } + } else if (hasReview && !expanded) { + const hint = details?.reviewPath + ? `press Ctrl+O for full review · saved to ${details.reviewPath}` + : "press Ctrl+O for full review"; + lines.push(theme.fg("dim", ` ├── ${hint}`)); + } + + // Build tool-call tree + if (details?.toolCalls && details.toolCalls.length > 0) { + const all = details.toolCalls; + + if (expanded) { + // Expanded: show ALL tool calls + for (let i = 0; i < all.length; i++) { + const entry = all[i]; + const isLast = i === all.length - 1; + const branch = isLast ? " └── " : " ├── "; + const tag = theme.fg("accent", `[${entry.name}]`); + lines.push(`${branch}${tag} ${entry.label}`); + } + } else { + // Collapsed: last N + "X more" + const shown = all.slice(-MAX_COLLAPSED); + const remaining = all.length - shown.length; + + if (remaining > 0) { + lines.push(theme.fg("dim", ` ├── ${remaining} more`)); + } + + for (let i = 0; i < shown.length; i++) { + const entry = shown[i]; + const isLast = i === shown.length - 1; + const branch = isLast ? " └── " : " ├── "; + const tag = theme.fg("accent", `[${entry.name}]`); + lines.push(`${branch}${tag} ${entry.label}`); + } + } + } + + const text = lines.join("\n"); + const box = new Box(1, 1, (t) => theme.bg("customMessageBg", t)); + box.addChild(new Text(text, 0, 0)); + return box; + }, + ); + + // ─── Verbose tool-event stream renderer ───────────────────────────── + // + // When execution.chatStyle is "verbose", each tool_execution_start/end and + // assistant turn is posted as its own chat message — the piolium/pygienium + // per-event stream. When "compact" (default), only the completion message + // with its expandable tool-call tree shows (the existing ralpi-progress + // renderer above). + + type StreamLineKind = "tool-start" | "tool-end" | "tool-error" | "assistant"; + + interface StreamLineDetails { + kind: StreamLineKind; + phase: string; + toolName?: string; + body?: string; + } + + pi.registerMessageRenderer( + "ralpi-stream", + (message, _options, theme) => { + const details = message.details; + if (!details || typeof details !== "object") { + const fallback = + typeof message.content === "string" ? message.content : ""; + return new Text(theme.fg("muted", fallback), 0, 0); + } + const { kind, phase, toolName, body } = details; + const phaseTag = theme.fg("accent", `[${phase}]`); + const indent = " ".repeat(phase.length + 3); + let line: string; + switch (kind) { + case "tool-start": { + const arrow = theme.fg("muted", "→"); + const name = theme.fg("toolTitle", theme.bold(toolName ?? "")); + const args = body ? ` ${theme.fg("muted", body)}` : ""; + line = `${phaseTag} ${arrow} ${name}${args}`; + break; + } + case "tool-end": { + const arrow = theme.fg("success", "←"); + const result = body + ? ` ${theme.fg("dim", body)}` + : ` ${theme.fg("dim", "(ok)")}`; + line = `${indent}${arrow}${result}`; + break; + } + case "tool-error": { + const marker = theme.fg("error", "✗"); + const result = body + ? ` ${theme.fg("error", body)}` + : ` ${theme.fg("error", "failed")}`; + line = `${indent}${marker}${result}`; + break; + } + case "assistant": + line = `${phaseTag} ${theme.fg("muted", body ?? "")}`; + break; + default: + line = + typeof message.content === "string" + ? theme.fg("muted", message.content) + : ""; + } + return new Text(line, 0, 0); + }, + ); + + // ─── Reload detection: resume interrupted loops when session reloads ── + // + // ralpi runs task agent sessions in-process (createAgentSession), so they + // do NOT survive a /reload. When the new session starts, this handler + // reads the persisted loop-active marker + progress.json: if any task is + // still `in_progress`, the loop was interrupted mid-task and we resume it + // (resetting those tasks to pending so the DAG re-schedules them), using + // the mode + loop options snapshotted in loop-active.json so the resume is + // non-interactive. + pi.on("session_start", async (event: SessionStartEvent, ctx) => { + // omp's SessionStartEvent has no reason/reload field; the in_progress-task + // check below already scopes recovery to genuinely interrupted loops (a + // completed loop has no in_progress tasks), so recovery runs on any start + // where a stalled loop marker exists. + + // Find the ralpi project directory + const projectDir = findRalpiDir(ctx.cwd); + if (!projectDir) return; + + // Check if a task execution loop was active before the reload + const loopState = readLoopActive(projectDir); + if (!loopState) return; + + // The auto-resume path has no CLI flag, so the gitignore guard is + // always on: keep `.ralpi/` out of the user's repo on reload too. + ensureRalpiIgnored(projectDir); + + // Load progress state + const progressPath = path.join(projectDir, ".ralpi", "progress.json"); + + /** Re-read progress from disk. */ + const readTasks = (): Record | null => { + try { + const raw = fs.readFileSync(progressPath, "utf-8"); + const parsed = JSON.parse(raw) as Record; + return parsed.prds?.[loopState.prdKey]?.tasks ?? parsed.tasks ?? null; + } catch { + return null; + } + }; + + // ralpi agent sessions run in-process (createAgentSession), so they do + // NOT survive a session reload. Any task left `in_progress` is therefore + // stalled — its agent died with the previous session. Detect that state + // and actively resume the loop instead of passively polling (which would + // spin forever waiting for a dead task to complete). + const initialTasks = readTasks(); + if (initialTasks) { + const inProgressIds = Object.entries(initialTasks).flatMap(([id, t]) => + t.status === "in_progress" ? [id] : [], + ); + + // Build the sendProgress wrapper so resumed task messages render the + // same expandable tool-call tree as an interactive run. Defined before + // the finalize path below so it can report self-healed merges. + const sendProgress: SendChatMessage = ( + content: string, + meta?: { + toolCalls?: Array<{ name: string; label: string }>; + reviewText?: string; + reviewPath?: string; + reviewResult?: ReviewResult; + }, + ) => { + pi.sendMessage({ + customType: "ralpi-progress", + content, + display: true, + details: { + phase: "progress", + toolCalls: meta?.toolCalls, + reviewText: meta?.reviewText, + reviewPath: meta?.reviewPath, + reviewResult: meta?.reviewResult, + }, + }); + }; + + if (inProgressIds.length === 0) { + // Nothing was mid-flight — the loop either finished cleanly between + // the reload landing and this handler running, or was stopped + // between tasks. Either way, committed worktree branches from an + // interrupted loop may still be unmerged (e.g. a prior resume + // attempt reset tasks to pending before it was itself interrupted). + // Finalize those first so committed code lands in the workspace, + // persist the state to progress.json, update the PRD file, THEN + // clean up the stale marker. + try { + const config = loadConfig(projectDir); + // Clear any half-done merge left by an interrupted + // conflict-resolution session (it would block every merge below). + abortMerge(projectDir); + const allIds = Object.entries(initialTasks).flatMap(([id, t]) => + t.status !== "failed" && t.status !== "pending" ? [id] : [], + ); + const fin = finalizeCommittedWorktrees( + projectDir, + config.paths.stateDir, + loopState.prdKey, + allIds, + ); + // Persist finalized tasks to progress.json + PRD file so the + // state is correct for subsequent /ralpi resume calls. + const stateDir = config.paths.stateDir; + const progressPath = path.join(projectDir, stateDir, "progress.json"); + // Batch-update progress.json and PRD file for all finalized tasks + if (fin.finalized.length > 0) { + const progressRaw = fs.existsSync(progressPath) + ? JSON.parse(fs.readFileSync(progressPath, "utf-8")) + : null; + for (const id of fin.finalized) { + sendProgress?.( + `✓ ${id} — finalized on resume (committed branch merged into main)`, + ); + if (progressRaw) { + const tasks = + progressRaw.prds?.[loopState.prdKey]?.tasks ?? + progressRaw.tasks; + if (tasks && tasks[id]) { + tasks[id].status = "completed"; + tasks[id].completedAt = new Date().toISOString(); + } + } + try { + const prdPath = loopState.taskFile; + if (fs.existsSync(prdPath)) { + updateTaskInFile(prdPath, id, "completed"); + } + } catch { + // Best-effort + } + } + if (progressRaw) { + fs.writeFileSync( + progressPath, + JSON.stringify(progressRaw, null, 2), + "utf-8", + ); + } + } + // ── Handle conflicted tasks ── + // Same logic as resumeLoop: reset to pending so the DAG can + // re-schedule them, keep the worktree for in-place re-run. + const conflictIds = Object.keys(fin.conflicts); + if (conflictIds.length > 0) { + const detail = conflictIds + .map((id) => `${id}: ${fin.conflicts[id].slice(0, 3).join(", ")}`) + .join("; "); + // Batch-reset all conflicted tasks to pending, then write once + const progressRaw = fs.existsSync(progressPath) + ? JSON.parse(fs.readFileSync(progressPath, "utf-8")) + : null; + for (const id of conflictIds) { + if (progressRaw) { + const tasks = + progressRaw.prds?.[loopState.prdKey]?.tasks ?? + progressRaw.tasks; + if (tasks && tasks[id]) { + tasks[id].status = "pending"; + delete tasks[id].startedAt; + delete tasks[id].error; + } + } + try { + const prdPath = loopState.taskFile; + if (fs.existsSync(prdPath)) { + updateTaskInFile(prdPath, id, "pending"); + } + } catch { + // Best-effort + } + } + if (progressRaw) { + fs.writeFileSync( + progressPath, + JSON.stringify(progressRaw, null, 2), + "utf-8", + ); + } + ctx.ui.notify( + `Reset ${conflictIds.length} conflicted task(s) to pending for re-execution (${detail})`, + "info", + ); + } + } catch { + // Best-effort — the marker is removed either way; the worktrees + // stay on disk for a manual /ralpi-resume. + } + ctx.ui.notify( + "ralpi loop has no in-progress task to resume — marking complete.", + "info", + ); + deleteLoopActive(projectDir); + return; + } + + const taskCount = loopState.taskIds.length; + ctx.ui.notify( + `ralpi loop was interrupted by reload with ${inProgressIds.length} in-progress task(s). ` + + `Resuming execution (${taskCount} tasks, ${loopState.mode} mode)...`, + "info", + ); + + // Load config from the project directory so model + thinking level + // resolve the same way the interactive command handler does. + const config = loadConfig(projectDir); + + try { + await resumeLoop( + ctx, + loopState.taskFile, + projectDir, + loopState.prdKey, + sendProgress, + config.model ?? ctx.model, + pi.getThinkingLevel(), + { + mode: loopState.mode, + autoCommit: loopState.autoCommit ?? config.execution.autoCommit, + autoReview: loopState.autoReview ?? config.execution.autoReview, + saveReviews: loopState.saveReviews ?? config.execution.saveReviews, + skipFinalStatus: false, + }, + ); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + ctx.ui.notify(`ralpi auto-resume failed: ${msg}`, "error"); + // Leave loop-active.json in place so the user can retry via + // /ralpi resume after addressing the underlying error. + } + return; + } + }); + + pi.registerCommand("ralpi", { + description: + "Execute tasks from a task file using DAG-based dependency resolution", + handler: async (args: string, ctx: ExtensionContext) => { + const parts = (args || "").trim().split(/\s+/).filter(Boolean); + const sendProgress = makeSendProgress(pi); + + // If no args, show plan. If first token looks like a path (@path, /path, ./path), + // route to run so the execution mode prompt fires. + if (parts.length === 0) { + return handlePlan(ctx, parts); + } + if (looksLikePath(parts[0])) { + return handleRun( + ctx, + parts, + sendProgress, + ctx.model, + pi.getThinkingLevel(), + ); + } + + // Subcommands (run/plan/resume/reset) are handled by the dash commands + // below — /ralpi only dispatches no-args → plan and path → run. + ctx.ui.notify( + `Unknown: ${parts[0]}. Use /ralpi-run, /ralpi-plan, /ralpi-resume, or /ralpi-reset`, + "error", + ); + }, + }); + + // ─── Dedicated subcommands (dash namespace) ────────────────────────── + // + // Each subcommand is registered as its own top-level Pi command so the + // slash-menu autocompletes it directly (`/ralpi-run`, `/ralpi-resume`, …) + // instead of requiring the user to type `/ralpi ` and rely on + // raw-string dispatch. `/ralpi` above remains as a back-compat dispatcher. + pi.registerCommand("ralpi-run", { + description: "Run tasks from a task file (DAG-based execution)", + handler: async (args: string, ctx: ExtensionContext) => { + const parts = (args || "").trim().split(/\s+/).filter(Boolean); + return handleRun( + ctx, + parts, + makeSendProgress(pi), + ctx.model, + pi.getThinkingLevel(), + ); + }, + }); + + const extensionDir = path.dirname(fileURLToPath(import.meta.url)); + + pi.registerCommand("ralpi-plan", { + description: "Open the Task Manager to plan a ralpi run", + handler: async (args: string, ctx: ExtensionContext) => { + // pi.sendUserMessage() sends with expandPromptTemplates: false, so it + // would NOT expand `/task-manager` — and `@task-manager` is an + // @-mention, not a template invocation. Load the bundled template, + // strip frontmatter, substitute $@ args ourselves, and send the + // expanded body directly. + const body = loadTaskManagerPrompt(extensionDir, args ?? ""); + pi.sendUserMessage(body); + ctx.ui.notify("Opening Task Manager...", "info"); + }, + }); + + pi.registerCommand("ralpi-resume", { + description: "Resume an interrupted ralpi loop from persisted progress", + handler: async (args: string, ctx: ExtensionContext) => { + const parts = (args || "").trim().split(/\s+/).filter(Boolean); + return handleResume( + ctx, + parts, + makeSendProgress(pi), + ctx.model, + pi.getThinkingLevel(), + ); + }, + }); + + pi.registerCommand("ralpi-reset", { + description: "Reset ralpi progress for a task file", + handler: async (args: string, ctx: ExtensionContext) => { + const parts = (args || "").trim().split(/\s+/).filter(Boolean); + return handleReset(ctx, parts); + }, + }); +} + +// ─── /ralpi plan ───────────────────────────────────────────────────────────── + +async function handlePlan( + ctx: ExtensionContext, + args: string[], +): Promise { + const taskFile = resolveTaskArg(args[0] || "README.md", ctx.cwd); + const project = parseTaskFile(taskFile); + if (!Array.isArray(project.tasks)) { + throw new Error( + `Parsed project from ${taskFile} has invalid tasks: expected array, got ${typeof project.tasks}`, + ); + } + + const planPrompt = buildPlanPrompt(project); + const plan = buildExecutionPlan(project, new Set()); + const formatted = formatExecutionPlan(plan); + + ctx.ui.notify(`${planPrompt}\n\n${formatted}`, "info"); +} + +// ─── /ralpi run ────────────────────────────────────────────────────────────── + +async function handleRun( + ctx: ExtensionContext, + args: string[], + sendChatMessage?: SendChatMessage, + parentModel?: unknown, + parentThinkingLevel?: unknown, +): Promise { + const noGitignore = stripNoGitignore(args); + const taskFile = resolveTaskArg(args[0] || "README.md", ctx.cwd); + + // If targeting a specific task file and there's existing progress for it, + // auto-resume instead of starting fresh + const existingProgress = findProgressFile(ctx.cwd, taskFile); + if (existingProgress) { + return handleResume( + ctx, + args.slice(0, 1), + sendChatMessage, + parentModel, + parentThinkingLevel, + ); + } + + // No existing progress for this task — check for any progress at all + const found = findProgressFile(ctx.cwd); + if (found && !args[0]) { + // Offer to resume instead of starting fresh + const shouldResume = await ctx.ui.select( + "Found existing ralpi progress. Resume?", + ["Yes, resume", "No, start fresh"], + ); + + if (shouldResume?.startsWith("Yes")) { + return handleResume( + ctx, + [], + sendChatMessage, + parentModel, + parentThinkingLevel, + ); + } + } + + const projectDir = found ? path.dirname(path.dirname(found.path)) : ctx.cwd; + ensureIgnoredNote(projectDir, ctx, noGitignore); + + const project = parseTaskFile(taskFile); + const config = loadConfig(projectDir); + config.model = parentModel ?? ctx.model; + config.thinkingLevel = parentThinkingLevel; + const progress = new ProgressTracker(projectDir, taskFile); + + const completed = buildCompletedSet(progress, project); + const mode = await selectExecutionMode(ctx, project, taskFile, config); + const { autoCommit, autoReview, saveReviews } = await selectLoopOptions( + ctx, + config, + ); + config.execution.autoCommit = autoCommit; + config.execution.autoReview = autoReview; + config.execution.saveReviews = saveReviews; + const plan = buildPlanByMode(mode, project, completed); + + // Show dependency chain + execution plan before starting + const depChain = formatDependencyChain(project); + const formattedPlan = formatExecutionPlan(plan); + if (mode === "parallel") { + ctx.ui.notify( + `${depChain}\n\n${formattedPlan}\n\nStarting parallel execution...`, + "info", + ); + } else { + ctx.ui.notify( + `${formattedPlan}\n\nStarting sequential execution...`, + "info", + ); + } + + await executePlanBatches( + plan, + project, + taskFile, + config, + progress, + ctx, + mode, + sendChatMessage, + projectDir, + ); + + const state = progress.getState(); + const output = formatProgressStatus(state); + + const reflections = progress.getAllReflections(); + if (reflections.length > 0) { + ctx.ui.notify(`${output}\n\n${formatReflections(reflections)}`, "info"); + return; + } + + ctx.ui.notify(output, "info"); +} + +// ─── /ralpi status ─────────────────────────────────────────────────────────── +// (removed — use /ralpi plan to invoke @task-manager) + +// ─── /ralpi resume ─────────────────────────────────────────────────────────── + +/** + * Resume core: given a resolved task file, project dir, and PRD key, + * build the remaining plan and execute it. Used by both the explicit + * `/ralpi resume` command and the auto-resume on session reload. + * + * `mode` and loop options (`autoCommit`/`autoReview`/`saveReviews`) may be + * passed to skip interactive prompts — this is how a reload resumes + * non-interactively using the snapshot stored in loop-active.json. + * When omitted, the user is prompted as usual. + */ +async function resumeLoop( + ctx: ExtensionContext, + taskFile: string, + projectDir: string, + prdKey: string | undefined, + sendChatMessage: SendChatMessage | undefined, + parentModel: unknown, + parentThinkingLevel: unknown, + options?: { + mode?: ExecutionMode; + autoCommit?: boolean; + autoReview?: boolean; + saveReviews?: boolean; + skipFinalStatus?: boolean; + }, +): Promise { + const project = parseTaskFile(taskFile); + if (!Array.isArray(project.tasks)) { + throw new Error( + `Parsed project from ${taskFile} has invalid tasks: expected array, got ${typeof project.tasks}`, + ); + } + const config = loadConfig(projectDir); + config.model = parentModel ?? ctx.model; + config.thinkingLevel = parentThinkingLevel; + const progress = new ProgressTracker(projectDir, taskFile, prdKey); + + progress.setPaused(false); + + // ── Self-heal: finalize tasks that finished but were never merged ── + // + // A review-gated task whose agent committed + reviewed successfully still + // needs a final merge into main + worktree removal to be "done". If the + // loop was interrupted between that commit and the merge, the task is left + // with a committed worktree branch. Resuming without finalizing would + // wastefully re-run finished work — or worse, strand the committed code in + // `.ralpi/worktrees/` forever. + // + // finalizeCommittedWorktrees runs over EVERY non-failed task, not just + // `in_progress` ones: a prior interrupted resume can reset tasks to + // `pending` while their worktree branch still holds committed work that + // was never merged. Only scanning in_progress tasks would silently leave + // that code out of the workspace on every resume. + // + // `pending` tasks (never started) are excluded — they never had worktrees + // created, so finalize always puts them in `rerun`, which is wasted work. + // Failed tasks keep their worktrees for inspection/re-run and are also + // deliberately excluded. + const prdKeyForFinalize = progress.getKey(); + // Clear any half-done merge left in the main repo by an interrupted + // conflict-resolution session — it would block every merge below + // (`git merge` refuses while a merge is already in progress). No-op when + // the repo isn't mid-merge. + abortMerge(projectDir); + const finalizeCandidateIds = Object.entries( + progress.getState().tasks, + ).flatMap(([id, t]) => + t.status !== "failed" && t.status !== "pending" ? [id] : [], + ); + if (finalizeCandidateIds.length > 0) { + const fin = finalizeCommittedWorktrees( + projectDir, + config.paths.stateDir, + prdKeyForFinalize, + finalizeCandidateIds, + ); + for (const id of fin.finalized) { + progress.markCompleted(id, 0); + try { + updateTaskInFile(taskFile, id, "completed"); + } catch { + // Best-effort — progress.json is the source of truth for scheduling. + } + sendChatMessage?.( + `✓ ${id} — finalized on resume (committed branch merged into main)`, + ); + } + // ── Handle conflicted tasks ── + // + // Tasks whose committed branch could not be auto-merged (git conflicts) + // must be reset to `pending` so the DAG re-schedules them. The worktree + // is preserved — the agent re-runs in-place in the existing worktree via + // createWorktree's reuse logic. If the agent's re-run changes make the + // merge succeed on the next attempt, the loop continues normally. If the + // merge fails again, `executeBatch`'s batch-level conflict resolution + // (`resolveConflictsSession`) handles the conflict markers properly. + const conflictIds = Object.keys(fin.conflicts); + if (conflictIds.length > 0) { + const detail = conflictIds + .map((id) => `${id}: ${fin.conflicts[id].slice(0, 3).join(", ")}`) + .join("; "); + // Batch-reset all conflicted tasks to pending, then save once. + // Directly mutate the progress state (there's no markPending method + // on ProgressTracker — markFailed would leave it as 'failed' which the + // DAG excludes). The worktree is preserved so createWorktree reuses + // it and the agent re-runs in-place. + const tasks = progress.getState().tasks; + for (const id of conflictIds) { + if (tasks[id]) { + tasks[id].status = "pending"; + delete tasks[id].startedAt; + delete tasks[id].error; + } + try { + updateTaskInFile(taskFile, id, "pending"); + } catch { + // Best-effort + } + } + progress.save(); + sendChatMessage?.( + `⚠ ${conflictIds.join( + ", ", + )} — merge conflict on resume-finalize; reset to pending for re-execution (${detail})`, + ); + ctx.ui.notify( + `Reset ${conflictIds.length} conflicted task(s) to pending for re-execution`, + "info", + ); + } + } + + // Any task still `in_progress` (those NOT finalized above) died with the + // previous session (ralpi runs agents in-process). Reset them to `pending` + // so the DAG re-schedules them cleanly. Without this they'd still be + // re-run (they're not in the completed set), but the progress.json would + // carry a stale in_progress state during the rebuild window. + const resetIds = progress.resetInProgressToPending(); + if (resetIds.length > 0) { + // Keep the source-file checkboxes in sync so a later parse sees these + // tasks as `pending` rather than `in_progress`. + for (const id of resetIds) { + try { + updateTaskInFile(taskFile, id, "pending"); + } catch { + // Best-effort — progress.json is the source of truth for scheduling. + } + } + ctx.ui.notify( + `Reset stalled in-progress task(s) to pending: ${resetIds.join(", ")}`, + "info", + ); + } + + const completed = buildCompletedSet(progress, project); + const mode = + options?.mode ?? + (await selectExecutionMode(ctx, project, taskFile, config)); + + let autoCommit: boolean; + let autoReview: boolean; + let saveReviews: boolean; + if ( + options?.autoCommit !== undefined && + options?.autoReview !== undefined && + options?.saveReviews !== undefined + ) { + autoCommit = options.autoCommit; + autoReview = options.autoReview; + saveReviews = options.saveReviews; + } else { + const opt = await selectLoopOptions(ctx, config); + autoCommit = opt.autoCommit; + autoReview = opt.autoReview; + saveReviews = opt.saveReviews; + } + config.execution.autoCommit = autoCommit; + config.execution.autoReview = autoReview; + config.execution.saveReviews = saveReviews; + const plan = buildPlanByMode(mode, project, completed); + + // Print remaining batches before executing + const formattedPlan = formatExecutionPlan(plan); + if (mode === "parallel") { + ctx.ui.notify(`${formattedPlan}\n\nResuming parallel execution...`, "info"); + } else { + ctx.ui.notify( + `${formattedPlan}\n\nResuming sequential execution...`, + "info", + ); + } + + await executePlanBatches( + plan, + project, + taskFile, + config, + progress, + ctx, + mode, + sendChatMessage, + projectDir, + true, // isResume — preserve in-progress worktrees, continue them + ); + + if (!options?.skipFinalStatus) { + ctx.ui.notify(formatProgressStatus(progress.getState()), "info"); + } +} + +async function handleResume( + ctx: ExtensionContext, + args: string[], + sendChatMessage?: SendChatMessage, + parentModel?: unknown, + parentThinkingLevel?: unknown, +): Promise { + const noGitignore = stripNoGitignore(args); + let taskFile: string; + let projectDir: string; + let prdKey: string | undefined; + + if (args[0]) { + taskFile = resolveTaskArg(args[0], ctx.cwd); + const found = findProgressFile(ctx.cwd, taskFile); + if (!found) { + ctx.ui.notify( + `No existing progress for ${args[0]}. Start with /ralpi run ${args[0]}`, + "warning", + ); + return; + } + projectDir = path.dirname(path.dirname(found.path)); + prdKey = found.prdKey; + } else { + const found = findProgressFile(ctx.cwd); + if (!found) { + ctx.ui.notify( + "No .ralpi/progress.json found. Start with /ralpi run [task-file]", + "warning", + ); + return; + } + projectDir = path.dirname(path.dirname(found.path)); + + // When no specific task file is given, let the user select which loop + // to resume from multiple PRDs (sorted by most recent first). + const selected = await selectPRD( + ctx, + found, + "Multiple loops found. Which to resume?", + ); + if (!selected) { + ctx.ui.notify("Resume cancelled.", "info"); + return; + } + taskFile = selected.sourcePath; + prdKey = selected.prdKey; + } + + // Reuse the loop snapshot (mode + autoCommit/autoReview/saveReviews) + // persisted when the loop started, so an interrupted loop resumes + // non-interactively — matching the auto-resume-on-reload path. Only fall + // back to interactive prompts when no snapshot is present. + const snapshot = readLoopActive(projectDir); + const loopOpts = (() => { + if ( + snapshot && + snapshot.prdKey === prdKey && + snapshot.mode && + snapshot.autoCommit !== undefined && + snapshot.autoReview !== undefined && + snapshot.saveReviews !== undefined + ) { + return { + mode: snapshot.mode as ExecutionMode, + autoCommit: snapshot.autoCommit, + autoReview: snapshot.autoReview, + saveReviews: snapshot.saveReviews, + }; + } + return undefined; + })(); + + ensureIgnoredNote(projectDir, ctx, noGitignore); + + await resumeLoop( + ctx, + taskFile, + projectDir, + prdKey, + sendChatMessage, + parentModel, + parentThinkingLevel, + loopOpts, + ); +} + +// ─── /ralpi next ───────────────────────────────────────────────────────────── +// (removed — use /ralpi run to execute tasks) + +// ─── /ralpi reset ──────────────────────────────────────────────────────────── + +async function handleReset( + ctx: ExtensionContext, + args: string[], +): Promise { + const noGitignore = stripNoGitignore(args); + let sourcePath: string; + let prdKey: string | undefined; + let progress: ProgressTracker; + + if (args[0]) { + const taskFile = resolveTaskArg(args[0], ctx.cwd); + const found = findProgressFile(ctx.cwd, taskFile); + const projectDir = found ? path.dirname(path.dirname(found.path)) : ctx.cwd; + ensureIgnoredNote(projectDir, ctx, noGitignore); + sourcePath = taskFile; + prdKey = found?.prdKey; + progress = new ProgressTracker(projectDir, taskFile, prdKey); + } else { + const found = findProgressFile(ctx.cwd); + if (!found) { + ctx.ui.notify( + "No .ralpi/progress.json found. Start with /ralpi run [task-file]", + "warning", + ); + return; + } + const projectDir = path.dirname(path.dirname(found.path)); + + ensureIgnoredNote(projectDir, ctx, noGitignore); + + // Multiple loops may have progress — let the user select which one to + // reset (sorted by most recent first), same as resume. + const selected = await selectPRD( + ctx, + found, + "Multiple loops found. Which to reset?", + ); + if (!selected) { + ctx.ui.notify("Reset cancelled.", "info"); + return; + } + sourcePath = selected.sourcePath; + prdKey = selected.prdKey; + progress = new ProgressTracker(projectDir, sourcePath, prdKey); + } + + // Ask whether to also clear the progress markers (checkboxes/status) in the + // source PRD/README file itself, not just .ralpi/progress.json. + const taskIds = Object.keys(progress.getState().tasks); + if (taskIds.length > 0) { + const choice = await ctx.ui.select( + `Also reset progress markers in the source file (${path.basename( + sourcePath, + )})?`, + [ + "Yes — clear checkboxes/status markers in the source PRD/README", + "No — only reset .ralpi/progress.json", + ], + ); + if (choice === undefined) { + ctx.ui.notify("Reset cancelled.", "info"); + return; + } + if (choice.startsWith("Yes")) { + progress.reset(); + for (const id of taskIds) { + updateTaskInFile(sourcePath, id, "pending"); + } + ctx.ui.notify( + `Progress reset — cleared ${taskIds.length} task marker(s) in ${path.basename(sourcePath)}.`, + "info", + ); + return; + } + } + + progress.reset(); + ctx.ui.notify("Progress reset. All task statuses cleared.", "info"); +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..2957e8e --- /dev/null +++ b/package.json @@ -0,0 +1,56 @@ +{ + "name": "@mikefreno/omp-ralpi", + "version": "0.5.0", + "description": "Execute tasks from task files/PRD's using DAG-based dependency resolution with persistent progress tracking", + "keywords": [ + "omp", + "omp-extension", + "task-runner", + "dag", + "task-manager", + "ralph-loop", + "prd" + ], + "author": "Michael Freno", + "license": "MIT", + "homepage": "https://github.com/mikefreno/ralpi", + "repository": { + "type": "git", + "url": "git+https://github.com/mikefreno/ralpi.git" + }, + "bugs": { + "url": "https://github.com/mikefreno/ralpi/issues" + }, + "files": [ + "index.ts", + "src/", + "prompts/", + "README.md", + "LICENSE" + ], + "scripts": { + "typecheck": "tsc --noEmit", + "test": "bun test" + }, + "engines": { + "bun": ">=1.3.14" + }, + "omp": { + "extensions": [ + "./index.ts" + ] + }, + "dependencies": { + "yaml": "^2.4.0" + }, + "publishConfig": { + "access": "public" + }, + "devDependencies": { + "@oh-my-pi/pi-coding-agent": "17.2.12", + "@oh-my-pi/pi-tui": "17.2.12", + "@types/node": "^20.0.0", + "bun-types": "^1.3.14", + "typescript": "^5.3.0" + } +} diff --git a/prompts/task-manager.md b/prompts/task-manager.md new file mode 100644 index 0000000..84c4190 --- /dev/null +++ b/prompts/task-manager.md @@ -0,0 +1,188 @@ +--- +name: task-manager +description: Breaks down complex features into small, verifiable subtasks +tools: read, edit, write, web_search, code_search, fetch_content, get_search_content, mcp, memory, skill, session_search, memory_search, ask_user_question, ctx_execute, ctx_execute_file, ctx_index, ctx_search, ctx_batch_execute +systemPromptMode: replace +inheritProjectContext: false +inheritSkills: false +defaultContext: fork +--- + +# Task Manager (@task-manager) + +Purpose: +You are a Task Manager (@task-manager), an expert at breaking down complex software features into small, verifiable subtasks. Your role is to create structured task plans that enable efficient, atomic implementation work. + +## Core Responsibilities + +- Break complex features into atomic tasks +- Create structured directories with task files and indexes +- Generate clear acceptance criteria and dependency mapping +- Follow strict naming conventions and file templates + +## Mandatory Two-Phase Workflow + +### Phase 1: Planning (Approval Required) + +When given a complex feature request: + +1. **Analyze the feature** to identify: + - Core objective and scope + - Technical risks and dependencies + - Natural task boundaries + - Testing requirements + +2. **Create a subtask plan** with: + - Feature slug (kebab-case) + - Clear task sequence and dependencies + - Exit criteria for feature completion + +3. **Present plan using this exact format:**``` + +## Subtask Plan + +feature: {kebab-case-feature-name} +objective: {one-line description} + +tasks: + +- seq: {2-digit}, filename: {seq}-{task-description}.md, title: {clear title} +- seq: {2-digit}, filename: {seq}-{task-description}.md, title: {clear title} + +dependencies: + +- {seq} -> {seq} (task dependencies) + +exit_criteria: + +- {specific, measurable completion criteria} + +Approval needed before file creation. + +``` + +4. **Wait for explicit approval** before proceeding to Phase 2. + +### Phase 2: File Creation (After Approval) +Once approved: + +1. **Create directory structure:** + - Base: `tasks/{feature}/` + - Create feature README.md index + - Create individual task files + +2. **Use these exact templates (Dependencies only if applicable):** + +**Feature Index Template** (`tasks/{feature}/README.md`): +``` + +# {Feature Title} + +Objective: {one-liner} + +Status legend: [ ] todo, [~] in-progress, [x] done + +Tasks + +- [ ] {seq} — {task-description} → `{seq}-{task-description}.md` + +Dependencies + +- {seq} depends on {seq} + +Exit criteria + +- The feature is complete when {specific criteria} + +``` + +**Task File Template** (`{seq}-{task-description}.md`): +``` + +# {seq}. {Title} + +meta: + id: {feature}-{seq} + feature: {feature} + priority: P2 + depends_on: [{dependency-ids}] + tags: [implementation, tests-required] + +objective: + +- Clear, single outcome for this task + +deliverables: + +- What gets added/changed (files, modules, endpoints) + +steps: + +- Step-by-step actions to complete the task + +tests: + +- Unit: which functions/modules to cover (Arrange–Act–Assert) +- Integration/e2e: how to validate behavior + +acceptance_criteria: + +- Observable, binary pass/fail conditions + +validation: + +- Commands or scripts to run and how to verify + +notes: + +- Assumptions, links to relevant docs or design + +``` + +3. **Provide creation summary:** +``` + +## Subtasks Created + +- tasks/{feature}/README.md +- tasks/{feature}/{seq}-{task-description}.md + +Next suggested task: {seq} — {title} + +``` + +## Strict Conventions +- **Naming:** Always use kebab-case for features and task descriptions +- **Sequencing:** 2-digits (01, 02, 03...) — optionally a single lowercase letter + suffix may be appended to insert a sub-task between two numbered steps without + renumbering siblings (e.g. `02b`, `02c` for sub-tasks of `02`). The parser + normalizes `2b` → `02b`. +- **File pattern:** `{seq}-{task-description}.md` +- **Dependencies:** Always map task relationships (if applicable) +- **Tests:** Every task must include test requirements +- **Acceptance:** Must have binary pass/fail criteria + +## Quality Guidelines +- Keep tasks atomic and implementation-ready +- Include clear validation steps +- Specify exact deliverables (files, functions, endpoints) +- Use functional, declarative language +- Avoid unnecessary complexity +- Ensure each task can be completed independently (given dependencies) + +## Available Tools +You have access to: read,edit,write,grep,glob,patch (but NOT bash) +You cannot modify: .env files, .key files, .secret files, node_modules, .git + +## Response Instructions +- Always follow the two-phase workflow exactly +- Use the exact templates and formats provided +- Wait for approval after Phase 1 +- Provide clear, actionable task breakdowns +- Include all required metadata and structure + +Break down the complex features into subtasks and create a task plan. Put all tasks in the /tasks/ directory. +Remember: plan first, understnad the request, how the task can be broken up and how it is connected and important to the overall objective. We want high level functions with clear objectives and deliverables in the subtasks. + +--- +User request: $@ diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..b7c5d9e --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,35 @@ +import { DEFAULT_CONFIG } from "./types"; + +export { DEFAULT_CONFIG }; + +// CLI +export const SLASH_COMMAND = "/ralpi"; +export const COMMANDS = [ + "run", + "plan", + "status", + "resume", + "next", + "reset", +] as const; + +// Task file detection +export const TASK_FILE_NAMES = [ + "README.md", + "PRD.md", + "tasks.md", + "tasks.yaml", + "tasks.yml", +] as const; + +// Reflection parsing +export const REFLECTION_HEADER = "## REFLECTION"; +export const REFLECTION_PATTERN = /##\s*REFLECTION\s*\n([\s\S]*?)(?=\n```|$)/i; + +// Review verdict parsing +export const REVIEW_HEADER = "## REVIEW VERDICT"; +export const REVIEW_PATTERN = + /##\s*REVIEW\s+VERDICT\s*\n([\s\S]*?)(?=\n```|$)/i; + +// Pi subprocess +export const DEFAULT_PI_ARGS = ["--no-stream"] as const; diff --git a/src/dag.ts b/src/dag.ts new file mode 100644 index 0000000..535abd5 --- /dev/null +++ b/src/dag.ts @@ -0,0 +1,540 @@ +import type { + Task, + ExecutionBatch, + ExecutionPlan, + Project, + ParallelGroup, +} from "./types"; + +// ─── Blocked Tasks ─────────────────────────────────────────────────────────── + +/** + * Find tasks that are blocked (direct or transitive) due to failed dependencies. + * Returns a Set of blocked task IDs. + */ +export function getBlockedTasks( + pendingTasks: Task[], + failedTaskIds: Set, +): Set { + const blocked = new Set(); + + let changed = true; + while (changed) { + changed = false; + for (const task of pendingTasks) { + if (blocked.has(task.id)) continue; + const deps = task.dependencies || []; + if (deps.some((dep) => failedTaskIds.has(dep) || blocked.has(dep))) { + blocked.add(task.id); + changed = true; + } + } + } + + return blocked; +} + +// ─── Main Entry ────────────────────────────────────────────────────────────── + +/** + * Build an execution plan from project tasks using DAG analysis. + * Returns ordered batches of parallelizable tasks. + */ +export function buildExecutionPlan( + project: Project, + completed: Set, + parallelGroup?: number, + failedTaskIds: Set = new Set(), +): ExecutionPlan { + // Filter out already completed AND failed tasks + // Failed tasks should not be re-scheduled — they're only re-attempted + // via the retry mechanism inside executeTask, not via the DAG. + const pendingTasks = project.tasks.filter( + (t) => !completed.has(t.id) && !failedTaskIds.has(t.id), + ); + const skippedTasks = project.tasks.filter( + (t) => completed.has(t.id) || failedTaskIds.has(t.id), + ); + + // With explicitly declared parallel groups, all groups are independent. + // Since there are no cross-group dependencies by definition, standard + // Kahn's algorithm produces the correct plan — tasks ready in any group + // appear in the same batch, and intra-group dependencies (e.g. "21 must + // be done before 22, 23, 24") are respected automatically. + // The parallel groups are preserved as metadata for display/documentation. + if (project.parallelGroups && project.parallelGroups.length > 0) { + return { + batches: buildGroupAwareBatches(project, pendingTasks, failedTaskIds), + totalTasks: pendingTasks.length, + skippedTasks, + }; + } + + // If parallel_group is explicitly set (legacy config flag), use group-based batching + if (parallelGroup !== undefined) { + return { + batches: buildParallelGroupBatchesLegacy(pendingTasks, failedTaskIds), + totalTasks: pendingTasks.length, + skippedTasks, + }; + } + + // Use dependency-based Kahn's algorithm + return { + batches: buildBatches(pendingTasks, failedTaskIds), + totalTasks: pendingTasks.length, + skippedTasks, + }; +} + +// ─── Sequential Plan ───────────────────────────────────────────────────────── + +/** + * Build a sequential execution plan (one task per batch) + */ +export function buildSequentialPlan( + project: Project, + completed: Set, + failedTaskIds: Set = new Set(), +): ExecutionPlan { + const pendingTasks = project.tasks.filter((t) => !completed.has(t.id)); + + // Mark tasks with failed dependencies as skipped + const blocked = getBlockedTasks(pendingTasks, failedTaskIds); + const skippedTasks = project.tasks.filter( + (t) => completed.has(t.id) || blocked.has(t.id), + ); + const activeTasks = pendingTasks.filter((t) => !blocked.has(t.id)); + + const batches: ExecutionBatch[] = activeTasks.map((task, i) => ({ + tasks: [task], + batchIndex: i, + })); + + return { + batches, + totalTasks: pendingTasks.length, + skippedTasks, + }; +} + +// ─── Kahn's Algorithm (Dependency-Based Batching) ──────────────────────────── + +function buildBatches( + pendingTasks: Task[], + failedTaskIds: Set, +): ExecutionBatch[] { + const batches: ExecutionBatch[] = []; + const done = new Set(); + const blocked = getBlockedTasks(pendingTasks, failedTaskIds); + const pendingSet = new Set(pendingTasks.map((t) => t.id)); + const remaining = new Set( + pendingTasks.filter((t) => !blocked.has(t.id)).map((t) => t.id), + ); + + while (remaining.size > 0) { + // Find tasks whose dependencies are all satisfied + const ready: Task[] = []; + for (const task of pendingTasks) { + if (!remaining.has(task.id)) continue; + + const deps = task.dependencies || []; + const depsSatisfied = deps.every( + (dep) => done.has(dep) || !pendingSet.has(dep), + ); + + if (depsSatisfied) { + ready.push(task); + } + } + + // Cycle detection: no tasks ready but some remain + if (ready.length === 0) { + const cycleTasks = Array.from(remaining); + throw new Error( + `Dependency cycle detected among tasks: ${cycleTasks.join(", ")}`, + ); + } + + batches.push({ tasks: ready, batchIndex: batches.length }); + for (const task of ready) { + done.add(task.id); + remaining.delete(task.id); + } + } + + return batches; +} + +// ─── Group-Aware Batching ──────────────────────────────────────────────────── + +/** + * Build batches respecting both explicit parallel groups and intra-group + * dependencies. Since parallel group declarations imply no cross-group + * dependencies, all tasks whose dependencies are satisfied — across any + * group — can run concurrently in the same batch. This means groups + * "proceed independently" as the user specified: tasks from different + * groups can appear in the same batch when ready. + * + * Intra-group dependencies (e.g., "21 must be done before 22, 23, 24") + * are handled by Kahn's algorithm: if 21 has deps satisfied but 22 doesn't, + * only 21 appears in the current batch. + */ +function buildGroupAwareBatches( + _project: Project, + pendingTasks: Task[], + failedTaskIds: Set, +): ExecutionBatch[] { + const blocked = getBlockedTasks(pendingTasks, failedTaskIds); + const activeTasks = pendingTasks.filter((t) => !blocked.has(t.id)); + + // Standard Kahn's algorithm across ALL tasks — parallel groups are + // metadata for display, not scheduling constraints. + const pendingSet = new Set(pendingTasks.map((t) => t.id)); + const done = new Set(); + const remaining = new Set(activeTasks.map((t) => t.id)); + const batches: ExecutionBatch[] = []; + + while (remaining.size > 0) { + const ready: Task[] = []; + for (const task of activeTasks) { + if (!remaining.has(task.id)) continue; + const deps = task.dependencies || []; + const depsSatisfied = deps.every( + (dep) => done.has(dep) || !pendingSet.has(dep), + ); + if (depsSatisfied) { + ready.push(task); + } + } + + if (ready.length === 0) { + throw new Error( + `Dependency cycle detected: ${Array.from(remaining).join(", ")}`, + ); + } + + batches.push({ tasks: ready, batchIndex: batches.length }); + for (const task of ready) { + done.add(task.id); + remaining.delete(task.id); + } + } + + return batches; +} + +// ─── Legacy Parallel Group Batching ───────────────────────────────────────── + +/** + * Legacy: build batches from explicit parallel_group values only. + * Groups execute in ascending order; tasks within a group run concurrently. + * Does NOT respect intra-group dependencies. + */ +function buildParallelGroupBatchesLegacy( + pendingTasks: Task[], + failedTaskIds: Set, +): ExecutionBatch[] { + const blocked = getBlockedTasks(pendingTasks, failedTaskIds); + const activeTasks = pendingTasks.filter((t) => !blocked.has(t.id)); + + const groups = new Map(); + + for (const task of activeTasks) { + const group = task.parallelGroup ?? 0; + if (!groups.has(group)) groups.set(group, []); + groups.get(group)!.push(task); + } + + const sortedGroups = Array.from(groups.entries()).sort((a, b) => a[0] - b[0]); + + return sortedGroups.map(([_groupNum, tasks], i) => ({ + tasks, + batchIndex: i, + })); +} + +// ─── Cycle Detection ───────────────────────────────────────────────────────── + +/** + * Detect cycles in the task dependency graph + */ +export function detectCycles(project: Project): string[] { + const adj = new Map(); + for (const task of project.tasks) { + adj.set(task.id, task.dependencies || []); + } + + const WHITE = 0; + const GRAY = 1; + const BLACK = 2; + const color = new Map(); + + for (const task of project.tasks) { + color.set(task.id, WHITE); + } + + const cycleNodes: string[] = []; + + function dfs(node: string): boolean { + color.set(node, GRAY); + const deps = adj.get(node) || []; + + for (const dep of deps) { + if (!adj.has(dep)) continue; + const depColor = color.get(dep); + + if (depColor === GRAY) { + cycleNodes.push(dep); + return true; + } + if (depColor === WHITE && dfs(dep)) { + cycleNodes.push(node); + return true; + } + } + + color.set(node, BLACK); + return false; + } + + for (const task of project.tasks) { + if (color.get(task.id) === WHITE) { + dfs(task.id); + } + } + + return [...new Set(cycleNodes)]; +} + +// ─── Ready Tasks ───────────────────────────────────────────────────────────── + +/** + * Get tasks that are ready to execute (all dependencies completed) + */ +export function getReadyTasks( + project: Project, + completed: Set, +): Task[] { + return project.tasks.filter((task) => { + if (completed.has(task.id)) return false; + const deps = task.dependencies || []; + return deps.every((dep) => completed.has(dep)); + }); +} + +// ─── Critical Path ─────────────────────────────────────────────────────────── + +/** + * Calculate the critical path (longest path through the DAG) + */ +export function getCriticalPath(project: Project): Task[] { + const taskMap = new Map(project.tasks.map((t) => [t.id, t])); + const dist = new Map(); + const prev = new Map(); + + // Initialize + for (const task of project.tasks) { + dist.set(task.id, 1); + prev.set(task.id, null); + } + + // Topological sort + const sorted: Task[] = []; + const visited = new Set(); + + function visit(id: string) { + if (visited.has(id)) return; + visited.add(id); + const task = taskMap.get(id); + if (!task) return; + + for (const dep of task.dependencies || []) { + visit(dep); + } + sorted.push(task); + } + + for (const task of project.tasks) { + visit(task.id); + } + + // Relax edges + for (const task of sorted) { + for (const dep of task.dependencies || []) { + const depDist = dist.get(dep); + if (depDist === undefined) continue; + + const newDist = depDist + 1; + const currentDist = dist.get(task.id) ?? 0; + if (newDist > currentDist) { + dist.set(task.id, newDist); + prev.set(task.id, dep); + } + } + } + + // Trace back from the longest path end + let maxTask = project.tasks[0]; + for (const task of project.tasks) { + const taskDist = dist.get(task.id) ?? 0; + const maxDist = dist.get(maxTask.id) ?? 0; + if (taskDist > maxDist) { + maxTask = task; + } + } + + const path: Task[] = []; + let current: string | null = maxTask.id; + while (current) { + const task = taskMap.get(current); + if (task) path.unshift(task); + current = prev.get(current) || null; + } + + return path; +} + +// ─── Format Dependency Chain ───────────────────────────────────────────────── + +/** + * Format the dependency DAG as a tree for display. + * Rooted at tasks with no dependencies, showing what depends on what. + */ +export function formatDependencyChain(project: Project): string { + const taskMap = new Map(project.tasks.map((t) => [t.id, t])); + const lines: string[] = []; + + lines.push("## Dependency Chain"); + lines.push(""); + + if (project.tasks.length === 0) { + lines.push("(no tasks)"); + return lines.join("\n"); + } + + // Build reverse dependency map: taskId → [dependent taskIds] + const dependents = new Map(); + for (const task of project.tasks) { + dependents.set(task.id, []); + } + for (const task of project.tasks) { + for (const dep of task.dependencies) { + if (dependents.has(dep)) { + dependents.get(dep)!.push(task.id); + } + } + } + + // Root tasks: those with no dependencies + const roots = project.tasks.filter((t) => t.dependencies.length === 0); + const rendered = new Set(); + + function renderNode(taskId: string, prefix: string, isLast: boolean): void { + const task = taskMap.get(taskId); + if (!task) return; + + const alreadyRendered = rendered.has(taskId); + rendered.add(taskId); + + const connector = prefix ? (isLast ? "└── " : "├── ") : ""; + + if (alreadyRendered) { + lines.push(`${prefix}${connector}${task.id} · ${task.title}`); + return; + } + + const deps = + task.dependencies.length > 0 + ? ` ← needs ${task.dependencies.join(", ")}` + : " (root)"; + + lines.push( + `${prefix}${connector}${task.id} · ${task.title}${prefix ? "" : deps}`, + ); + + const children = (dependents.get(taskId) || []) + .filter((c) => c !== taskId) + .sort(); + + for (let i = 0; i < children.length; i++) { + const childPrefix = prefix + (isLast ? " " : "│ "); + renderNode(children[i], childPrefix, i === children.length - 1); + } + } + + for (let i = 0; i < roots.length; i++) { + renderNode(roots[i].id, "", i === roots.length - 1); + } + + // Tasks not reached from any root (have deps but no root-traversable path) + const unreached = project.tasks.filter((t) => !rendered.has(t.id)); + if (unreached.length > 0) { + lines.push(""); + lines.push("Orphan tasks (dependencies not in task list):"); + for (const t of unreached) { + const deps = + t.dependencies.length > 0 + ? ` ← needs ${t.dependencies.join(", ")}` + : ""; + lines.push(` ${t.id} · ${t.title}${deps}`); + } + } + + return lines.join("\n"); +} + +// ─── Format Execution Plan ─────────────────────────────────────────────────── + +/** + * Format the execution plan for display + */ +/** + * Format the execution plan for display, optionally with parallel group annotations + */ +export function formatExecutionPlan( + plan: ExecutionPlan, + parallelGroups?: ParallelGroup[], +): string { + const lines: string[] = []; + lines.push("## Execution Plan"); + lines.push(""); + lines.push(`Total tasks: ${plan.totalTasks}`); + lines.push(`Batches: ${plan.batches.length}`); + + // Build a lookup: taskId → group label + const groupLabel = new Map(); + if (parallelGroups) { + for (const g of parallelGroups) { + for (const id of g.taskIds) { + if (g.label) { + groupLabel.set(id, g.label); + } + } + } + } + + if (plan.skippedTasks.length > 0) { + lines.push( + `Already completed: ${plan.skippedTasks.map((t) => t.id).join(", ")}`, + ); + } + lines.push(""); + + for (const batch of plan.batches) { + lines.push(`### Batch ${batch.batchIndex + 1}`); + for (const task of batch.tasks) { + const annotation = groupLabel.has(task.id) + ? ` _(${groupLabel.get(task.id)})_` + : ""; + const deps = + task.dependencies.length > 0 + ? ` ← needs ${task.dependencies.join(", ")}` + : ""; + lines.push(`- ${task.id}: ${task.title}${annotation}${deps}`); + } + lines.push(""); + } + + return lines.join("\n"); +} diff --git a/src/diff.ts b/src/diff.ts new file mode 100644 index 0000000..3d26feb --- /dev/null +++ b/src/diff.ts @@ -0,0 +1,274 @@ +/** + * Reusable unified-diff engine: parses a diff into per-file +/− stats and + * filters out noise files (locks, build output, vendor, generated, media + * binaries) so review prompts feed the model only clean, review-relevant + * changes. + * + * Ported from @piex-dev/review's `EXCLUDED_PATTERNS` + `parseDiff` (MIT). + * Kept the excluded-files-not-totaled behavior that fixed the upstream + * double-count bug. + */ + +// ─── Types ────────────────────────────────────────────────────────────────── + +/** Per-file diff stats. */ +export interface FileDiff { + /** File path as it appears in the diff (`a/` path). */ + path: string; + /** Number of added lines (excluding the `+++` header). */ + linesAdded: number; + /** Number of removed lines (excluding the `---` header). */ + linesRemoved: number; + /** File extension (empty when the path has none). */ + ext: string; +} + +/** An excluded (noise) file with the reason it was filtered. */ +export interface ExcludedFile extends FileDiff { + /** Why the file was excluded (e.g. "lockfile"). */ + reason: string; +} + +/** Result of parsing a unified diff. */ +export interface DiffSummary { + /** Files kept in scope (review-relevant). */ + files: FileDiff[]; + /** Files filtered out as noise. */ + excluded: ExcludedFile[]; + /** Sum of added lines over included files only. */ + totalAdded: number; + /** Sum of removed lines over included files only. */ + totalRemoved: number; +} + +/** Caller-supplied overrides for the noise filter. */ +export interface DiffOptions { + /** Additional exclusion regexes merged into EXCLUDED_PATTERNS. */ + extraPatterns?: RegExp[]; + /** Pathspec allowlist — files matching these stay in scope even if a + * default rule would exclude them. */ + ignorePaths?: string[]; +} + +// ─── Noise-Filter Rules ───────────────────────────────────────────────────── + +/** Default noise-exclusion rules, ported from @piex-dev/review (MIT). + * Each entry is a regex tested against the file path plus a human-readable + * reason surfaced in the "Excluded Files" prompt section. */ +export const EXCLUDED_PATTERNS: { pattern: RegExp; reason: string }[] = [ + // Lockfiles + { pattern: /(^|\/)package-lock\.json$/i, reason: "lockfile" }, + { pattern: /(^|\/)yarn\.lock$/i, reason: "lockfile" }, + { pattern: /(^|\/)pnpm-lock\.yaml$/i, reason: "lockfile" }, + { pattern: /(^|\/)Cargo\.lock$/i, reason: "lockfile" }, + { pattern: /(^|\/)Gemfile\.lock$/i, reason: "lockfile" }, + { pattern: /\.lock$/i, reason: "lockfile" }, + // Minified assets + { pattern: /\.min\.(js|css)$/i, reason: "minified asset" }, + // Generated / tooling output + { pattern: /\.generated\./i, reason: "generated file" }, + { pattern: /\.snap$/i, reason: "snapshot" }, + { pattern: /\.map$/i, reason: "source map" }, + // Build output directories + { pattern: /(^|\/)(dist|build|out|coverage)\//i, reason: "build output" }, + // Dependency trees + { pattern: /(^|\/)node_modules\//i, reason: "dependency" }, + { pattern: /(^|\/)vendor\//i, reason: "vendored dependency" }, + // Image / font / binary extensions + { + pattern: + /\.(png|jpe?g|gif|webp|svg|ico|avif|bmp|woff2?|ttf|otf|eot|pdf|zip|tar|gz|mp[34]|wav|ogg|flac|wasm|bin|exe|dll|so|a|o|class|jar|pyc)$/i, + reason: "binary/media asset", + }, +]; + +/** + * Return the exclusion reason for a file path, or undefined when the file is + * review-relevant. Extra caller-supplied patterns are merged into the default + * rule set. + */ +export function isExcluded( + fp: string, + extraPatterns?: RegExp[], +): string | undefined { + for (const rule of EXCLUDED_PATTERNS) { + if (rule.pattern.test(fp)) return rule.reason; + } + if (extraPatterns) { + for (const p of extraPatterns) { + if (p.test(fp)) return "extra ignore pattern"; + } + } + return undefined; +} + +/** + * Safely compile user-supplied regex strings into RegExp objects. Invalid + * patterns (that don't compile) are skipped so a bad config value never + * crashes review prompt building. + */ +export function compileIgnorePatterns(patterns: string[]): RegExp[] { + const out: RegExp[] = []; + for (const p of patterns) { + if (!p) continue; + try { + out.push(new RegExp(p)); + } catch { + // Skip malformed patterns silently + } + } + return out; +} + +// ─── Chunking + Counting Helpers ──────────────────────────────────────────── + +/** Split a raw diff into per-file chunks, each starting at a `diff --git` + * line. The leading non-diff preamble (e.g. a `--stat` block) is dropped — + * per-file stats are derived from the patch chunks themselves. */ +function chunkDiff(raw: string): string[] { + if (!raw) return []; + const lines = raw.split("\n"); + const chunks: string[] = []; + let current: string[] = []; + let started = false; + for (const line of lines) { + if (line.startsWith("diff --git ")) { + if (started && current.length > 0) chunks.push(current.join("\n")); + current = [line]; + started = true; + } else if (started) { + current.push(line); + } + } + if (started && current.length > 0) chunks.push(current.join("\n")); + return chunks; +} + +/** Parse the `a/` from a `diff --git a/… b/…` header. Returns null for + * malformed chunks that lack the a/… b/… header (guarded, never crashes). */ +function chunkPath(chunk: string): string | null { + const m = chunk.match(/^diff --git a\/(.+?) b\//); + return m ? m[1] : null; +} + +/** Count added/removed lines in a chunk, excluding the `+++`/`---` headers. */ +function countLines(chunk: string): { added: number; removed: number } { + let added = 0; + let removed = 0; + for (const line of chunk.split("\n")) { + if (line.startsWith("+") && !line.startsWith("+++")) added++; + else if (line.startsWith("-") && !line.startsWith("---")) removed++; + } + return { added, removed }; +} + +/** Extract the file extension from a path (no ext → empty string). */ +function getExt(fp: string): string { + const base = fp.split("/").pop() ?? ""; + const idx = base.lastIndexOf("."); + return idx > 0 ? base.slice(idx + 1) : ""; +} + +/** Convert a git pathspec glob into a regex (supports `*`, `**`, `?`). */ +function globToRegExp(glob: string): RegExp { + let re = ""; + for (let i = 0; i < glob.length; i++) { + const c = glob[i]; + if (c === "*") { + if (glob[i + 1] === "*") { + re += ".*"; + i++; + } else { + re += "[^/]*"; + } + } else if (c === "?") { + re += "[^/]"; + } else if (c === ".") { + re += "\\."; + } else { + re += c; + } + } + return new RegExp(`^${re}$`); +} + +/** Whether a file path matches a pathspec allowlist entry. */ +function matchesPathspec(pathspec: string, fp: string): boolean { + const ps = pathspec.trim(); + if (!ps) return false; + // Directory prefix: "tests/" or a bare dir name matches everything under it. + if (ps.endsWith("/") && fp.startsWith(ps)) return true; + if (ps.includes("*") || ps.includes("?")) return globToRegExp(ps).test(fp); + // Plain path — exact file or prefix directory. + if (fp === ps) return true; + if (fp.startsWith(ps + "/")) return true; + return false; +} + +/** Decide whether a file path is kept in scope or noise-excluded. */ +function classify( + path: string, + opts?: DiffOptions, +): { kept: boolean; reason?: string } { + const reason = isExcluded(path, opts?.extraPatterns); + if (reason === undefined) return { kept: true }; + // Excluded by a rule, but an ignorePaths allowlist can keep it in scope. + const keptByPathspec = + opts?.ignorePaths?.some((ps) => matchesPathspec(ps, path)) ?? false; + return keptByPathspec ? { kept: true } : { kept: false, reason }; +} + +// ─── Public API ───────────────────────────────────────────────────────────── + +/** + * Parse a unified diff into per-file +/− stats, splitting excluded (noise) + * files from included files. Totals are summed over included files only. + * Malformed chunks (no a/… b/… header) are skipped without crashing. + */ +export function parseDiff(raw: string, opts?: DiffOptions): DiffSummary { + const files: FileDiff[] = []; + const excluded: ExcludedFile[] = []; + let totalAdded = 0; + let totalRemoved = 0; + + for (const chunk of chunkDiff(raw)) { + if (!chunk) continue; + const path = chunkPath(chunk); + if (path === null) continue; // malformed chunk — skip + const { added, removed } = countLines(chunk); + const base: FileDiff = { + path, + linesAdded: added, + linesRemoved: removed, + ext: getExt(path), + }; + const decision = classify(path, opts); + if (decision.kept) { + files.push(base); + totalAdded += added; + totalRemoved += removed; + } else if (decision.reason) { + excluded.push({ ...base, reason: decision.reason }); + } + } + + return { files, excluded, totalAdded, totalRemoved }; +} + +/** + * Return the diff re-emitted with excluded (noise) file chunks removed, so an + * inlined review diff never contains filtered content. The stat preamble is + * dropped — the per-file summary table carries that information. Empty string + * when every changed file is noise. + */ +export function filterNoise(raw: string, opts?: DiffOptions): string { + const kept: string[] = []; + for (const chunk of chunkDiff(raw)) { + if (!chunk) continue; + const path = chunkPath(chunk); + if (path === null) continue; + const decision = classify(path, opts); + if (decision.kept) kept.push(chunk); + } + return kept.join("\n"); +} diff --git a/src/executor.ts b/src/executor.ts new file mode 100644 index 0000000..3232b8e --- /dev/null +++ b/src/executor.ts @@ -0,0 +1,1918 @@ +import { truncateToWidth } from "@oh-my-pi/pi-tui"; +import * as path from "node:path"; +import type { + Task, + Project, + Reflection, + ToolUsage, + ReviewResult, +} from "./types"; +import type { RalpiConfig } from "./types"; +import type { ProgressTracker } from "./progress"; +import type { + ExtensionContext, + AgentSessionEvent, +} from "@oh-my-pi/pi-coding-agent"; +import { + buildTaskPrompt, + buildReviewPrompt, + buildConflictResolutionPrompt, + MAX_DIFF_BYTES, +} from "./prompts"; +import { compileIgnorePatterns } from "./diff"; +import { extractReflection } from "./reflection"; +import { + extractReview, + saveReviewToFile as saveReviewJson, + loadReview as loadReviewJson, + verdictGlyph, + verdictSummary, +} from "./review"; +import { + createWorktree, + mergeWorktree, + removeWorktree, + reattemptMerge, + abortMerge, + hasMergeConflicts, + completeMerge, + worktreeHasPreservableWork, + type WorktreeHandle, + type MergeResult, +} from "./worktree"; +import { + runAgentSession, + writeFileSafe, + ensureDir, + captureGitCommits, + captureGitHead, + canComputeRange, + getCommitRangeDiff, + hasUncommittedChanges, + getGitStatusPorcelain, + getGitDiff, + resolveModelSpec, + formatDuration, +} from "./utils"; +import { updateTaskInFile } from "./parser"; + +// ─── Stream Forwarder (verbose chat style) ──────────────────────────────────── + +/** + * Module-level callback for verbose per-event chat streaming. Set by + * `index.ts` at extension startup via {@link setStreamForwarder} when the + * config's `execution.chatStyle` is "verbose". `runTask`'s event callback + * checks this and forwards each tool_execution_start/end + message_end as + * its own chat message — the piolium/pygienium per-event stream. When null + * (compact mode, the default), only the completion message with its + * expandable tool-call tree shows. + */ +let _streamForwarder: + | ((phase: string, event: AgentSessionEvent) => void) + | null = null; + +/** Register the verbose stream forwarder (called by index.ts at startup). */ +export function setStreamForwarder( + fn: ((phase: string, event: AgentSessionEvent) => void) | null, +): void { + _streamForwarder = fn; +} + +/** Optional callback to post a progress message into the chat history. */ +export type SendChatMessage = ( + content: string, + /** Extra data passed to the message renderer for the expanded view. */ + meta?: { + toolCalls?: ToolCallEntry[]; + /** Full review body for review messages — renderer shows it in the + * expanded (Ctrl+O) view so long reviews aren't lost to truncation. */ + reviewText?: string; + /** Saved file path when the review has been persisted to disk. */ + reviewPath?: string; + /** Structured review result (when extractReview succeeded). */ + reviewResult?: ReviewResult; + }, +) => void; + +export interface ToolCallEntry { + name: string; + label: string; +} + +/** A merge conflict deferred from executeTask to batch-level resolution. */ +export interface BatchConflict { + task: Task; + worktree: WorktreeHandle; + mergeResult: MergeResult; + /** The task's run result (reflection, commits, etc.) from executeTask. */ + result: { + reflection?: Reflection; + toolUsage?: ToolUsage; + outputPreview?: string; + commitMessages?: string[]; + commitSummary?: string; + durationMs: number; + }; +} + +// ─── Widget Expand/Collapse ─────────────────────────────────────────────── + +/** Max tool calls shown in a live widget before truncating. Widgets don't + * support message-style Ctrl+O expansion (that's only for chat-history + * messages rendered by registerMessageRenderer). */ +const MAX_COLLAPSED = 3; + +export const SPINNER_FRAMES = [ + "⠋", + "⠙", + "⠹", + "⠸", + "⠼", + "⠴", + "⠦", + "⠧", + "⠇", + "⠏", +]; + +// ─── Model Round-Robin ───────────────────────────────────────────────────── + +/** + * Round-robin model assignment with slot reuse. + * + * With models [A, B, C] and 2 concurrent tasks, only A and B are used. + * Model C is only touched when a third concurrent task starts. + * Freed slots are reused before new slots are allocated. + */ +class ModelRoundRobin { + private models: unknown[]; + private freeSlots: number[]; + private nextIndex = 0; + private assignments = new Map(); + + constructor(models: unknown[]) { + this.models = models; + this.freeSlots = []; + } + + get length(): number { + return this.models.length; + } + + /** All resolved models in the pool (for follow-up session failover). */ + get allModels(): unknown[] { + return this.models; + } + + assign(taskId: string): unknown { + let index: number; + if (this.freeSlots.length > 0) { + // Reuse a freed model slot first + index = this.freeSlots.shift()!; + } else if (this.nextIndex < this.models.length) { + // Allocate a new slot + index = this.nextIndex++; + } else { + // All models in use — wrap around + index = this.nextIndex % this.models.length; + this.nextIndex++; + } + this.assignments.set(taskId, index); + return this.models[index]; + } + + release(taskId: string): void { + const index = this.assignments.get(taskId); + if (index !== undefined) { + this.freeSlots.push(index); + this.freeSlots.sort((a, b) => a - b); + this.assignments.delete(taskId); + } + } + + /** + * Advance a task to the next model slot without going through freed slots. + * Used for model failover — when the current model is down, skip to the + * next one instead of re-assigning the same freed index. + */ + advance(taskId: string): unknown { + const currentIndex = this.assignments.get(taskId); + if (currentIndex === undefined) { + // No current assignment — fresh assign (fallback, shouldn't happen) + return this.assign(taskId); + } + // If this index was freed (e.g. from an earlier release call that raced), + // remove it from freeSlots so it's not handed out to another task. + const freeIdx = this.freeSlots.indexOf(currentIndex); + if (freeIdx !== -1) this.freeSlots.splice(freeIdx, 1); + // Advance to the next index (circular) + const nextIndex = (currentIndex + 1) % this.models.length; + this.assignments.set(taskId, nextIndex); + return this.models[nextIndex]; + } +} + +/** Shared state for parallel-batch widget. Each running task writes its + * tool calls and spinner frame; the batch widget reads them in task-ID order. */ +interface ParallelWidgetEntry { + taskHeader: string; + frameIndex: number; + done: boolean; + success: boolean; + toolCalls: ToolCallEntry[]; +} + +type ParallelWidgetState = Map; + +// ─── Run Single Task ──────────────────────────────────────────────────────── + +/** + * Execute a single task by spawning an async Pi agent session. + * Non-blocking — the TUI remains responsive throughout. + */ +export async function runTask( + task: Task, + project: Project, + config: RalpiConfig, + depReflections: Reflection[], + ctx: ExtensionContext, + sendChatMessage?: SendChatMessage, + projectDir: string = project.sourceDir, + parallelState?: ParallelWidgetState, + assignedModel?: unknown, + batchRender?: () => void, + /** Review feedback from a rejected review — injected when re-executing + * a task in review-gated mode so the agent knows what to fix. */ + reviewFeedback?: ReviewResult, +): Promise<{ + success: boolean; + reflection?: Reflection; + error?: string; + durationMs: number; + toolUsage?: ToolUsage; + outputPreview?: string; + commitMessages?: string[]; + commitSummary?: string; +}> { + const startMs = Date.now(); + + // Build prompt + const prompt = buildTaskPrompt( + task, + project, + depReflections, + config.prompts.projectContext, + reviewFeedback, + ); + + const taskHeader = `${task.id} · ${task.title}`; + + // When running in parallel, all tasks share a single widget so ordering + // is deterministic (sorted by task ID). In sequential mode each task gets + // its own widget. + const isParallel = !!parallelState; + const widgetKey = `ralpi-task-${task.id}`; + let frameIndex = 0; + const toolCalls: ToolCallEntry[] = []; + let widgetTui: { requestRender(): void } | null = null; + + if (isParallel) { + parallelState!.set(task.id, { + taskHeader, + frameIndex: 0, + done: false, + success: false, + toolCalls: [], + }); + } else { + // Build widget lines from current state. Live widgets can't expand/collapse + // like chat messages, so we always truncate to MAX_COLLAPSED recent calls. + const truncateWidth = 74; // Account for widget container padding + const buildLines = (t: typeof ctx.ui.theme, width?: number): string[] => { + const effectiveWidth = width + ? Math.min(width, truncateWidth) + : truncateWidth; + const frame = t.fg("accent", SPINNER_FRAMES[frameIndex]); + const lines = [truncateToWidth(`${frame} ${taskHeader}`, effectiveWidth)]; + + if (toolCalls.length > 0) { + if (toolCalls.length <= MAX_COLLAPSED) { + for (let i = 0; i < toolCalls.length; i++) { + const entry = toolCalls[i]; + const isLast = i === toolCalls.length - 1; + const branch = isLast ? " └── " : " ├── "; + const tag = t.fg("accent", `[${entry.name}]`); + lines.push( + truncateToWidth(`${branch}${tag} ${entry.label}`, effectiveWidth), + ); + } + } else { + const shown = toolCalls.slice(-MAX_COLLAPSED); + const remaining = toolCalls.length - shown.length; + lines.push( + truncateToWidth( + t.fg("dim", ` ├── …${remaining} earlier`), + effectiveWidth, + ), + ); + for (let i = 0; i < shown.length; i++) { + const entry = shown[i]; + const isLast = i === shown.length - 1; + const branch = isLast ? " └── " : " ├── "; + const tag = t.fg("accent", `[${entry.name}]`); + lines.push( + truncateToWidth(`${branch}${tag} ${entry.label}`, effectiveWidth), + ); + } + } + } + return lines; + }; + + ctx.ui.setWidget(widgetKey, (tui, t) => { + widgetTui = tui; + return { + render: (width?: number) => buildLines(t, width), + invalidate: () => widgetTui?.requestRender(), + }; + }); + } + + const requestRender = () => widgetTui?.requestRender(); + + // Spinner animation (sequential only — parallel uses a single batch timer) + let spinnerTimer: NodeJS.Timeout | undefined; + if (!isParallel) { + spinnerTimer = setInterval(() => { + frameIndex = (frameIndex + 1) % SPINNER_FRAMES.length; + requestRender(); + }, 100); + } + + // Use task-level timeout if set, otherwise fall back to config + const timeoutMs = task.timeoutMs ?? config.execution.timeoutMs; + + // Run task asynchronously via Pi SDK — event loop stays responsive + const output = await runAgentSession( + prompt, + projectDir, + timeoutMs, + (event) => { + // Forward to the verbose stream when enabled. + if (_streamForwarder && config.execution.chatStyle === "verbose") { + _streamForwarder(`${task.id} · ${task.title}`, event); + } + if (event.type === "tool_execution_start") { + const label = formatToolArg(event.toolName, event.args); + toolCalls.push({ + name: event.toolName, + label, + }); + if (isParallel) { + const entry = parallelState!.get(task.id); + if (entry) { + entry.toolCalls.push({ name: event.toolName, label }); + } + batchRender?.(); + } else { + requestRender(); + } + } + }, + undefined, // no abort signal + assignedModel ?? config.model, + config.thinkingLevel, + false, // noSkills — task sessions need skills + ctx.modelRegistry, + ); + + const durationMs = Date.now() - startMs; + + // Clear progress widget and status after task finishes + if (spinnerTimer) clearInterval(spinnerTimer); + if (isParallel) { + const entry = parallelState!.get(task.id); + if (entry) { + entry.done = true; + entry.success = output.success; + } + batchRender?.(); + } else { + ctx.ui.setWidget(widgetKey, undefined); + } + + if (!output.success) { + // Failure reporting is handled by the caller (executeTask) to avoid + // duplicate messages when model failover or retry cycling is active. + return { + success: false, + error: output.error, + durationMs, + }; + } + + const agentText = output.text; + const toolUsage = output.toolUsage; + + // Capture git commits made during this task + const { commitMessages, commitSummary } = captureGitCommits(projectDir); + + // Build output preview (first 500 chars of agent text) + const outputPreview = + agentText.length > 500 + ? agentText.slice(0, 500) + "\n... (truncated)" + : agentText; + + // Extract reflection from agent output + const reflection = extractReflection(agentText, task.id, task.title); + + // Post completion chat message — header only, renderer builds the expandable tree + const dur = formatDuration(durationMs); + sendChatMessage?.(`✓ ${taskHeader} (${dur})`, { toolCalls }); + + return { + success: true, + reflection: reflection ?? undefined, + durationMs, + toolUsage, + outputPreview, + commitMessages, + commitSummary, + }; +} + +// ─── Execute Batch ─────────────────────────────────────────────────────────── + +/** + * Execute a batch of tasks (sequentially or in parallel) + */ +// ─── Worktree Decision ────────────────────────────────────────────────────── + +/** Determine if worktree isolation should be used based on config + mode. */ +function shouldUseWorktrees(config: RalpiConfig, isParallel: boolean): boolean { + switch (config.execution.worktrees) { + case "always": + return true; + case "parallel": + return isParallel; + default: + return false; // "never" + } +} + +export async function executeBatch( + tasks: Task[], + project: Project, + config: RalpiConfig, + progress: ProgressTracker, + ctx: ExtensionContext, + options?: { parallel?: boolean }, + sendChatMessage?: SendChatMessage, + projectDir?: string, +): Promise { + // Defensive: ensure tasks is an iterable array + if (!Array.isArray(tasks)) { + throw new Error( + `executeBatch received invalid tasks: expected array, got ${typeof tasks}`, + ); + } + + // Set up model round-robin if configured. + // Config entries are "/" strings — resolve via modelRegistry. + let roundRobin: ModelRoundRobin | null = null; + if (config.execution.models.length > 0) { + const resolvedModels: unknown[] = []; + for (const entry of config.execution.models) { + const slashIdx = entry.indexOf("/"); + if (slashIdx === -1) { + ctx.ui.notify( + `ralpi config: skipping model "${entry}" — expected / format`, + "warning", + ); + continue; + } + const provider = entry.slice(0, slashIdx); + const modelId = entry.slice(slashIdx + 1); + const resolved = ctx.modelRegistry?.find(provider, modelId); + if (resolved) { + resolvedModels.push(resolved); + } else { + ctx.ui.notify( + `ralpi config: model "${entry}" not found in registry — skipping`, + "warning", + ); + } + } + if (resolvedModels.length > 0) { + roundRobin = new ModelRoundRobin(resolvedModels); + } + } + + // Check if we should run parallel. + // Use the parallel path whenever the user selected parallel mode, + // even for single-task batches produced by DAG dependency chains. + // Only sequential mode should inherit the parent session model. + const shouldParallel = + options?.parallel && tasks.length > 0 && config.execution.maxParallel > 0; + + const useWorktree = shouldUseWorktrees(config, !!shouldParallel); + + const conflicts: BatchConflict[] = []; + + if (shouldParallel) { + await executeBatchParallel( + tasks, + project, + config, + progress, + ctx, + sendChatMessage, + projectDir, + roundRobin, + useWorktree, + conflicts, + ); + } else { + // Execute sequentially (no round-robin — inherit parent model) + for (const task of tasks) { + try { + await executeTask( + task, + project, + config, + progress, + ctx, + sendChatMessage, + projectDir, + undefined, // parallelState + undefined, // assignedModel + undefined, // roundRobin + undefined, // batchRender + useWorktree, + conflicts, + ); + } catch (error) { + // Task failed — stop the batch. Dependent tasks are blocked by + // the DAG layer (getBlockedTasks) so they won't appear in this batch. + + const errorMsg = error instanceof Error ? error.message : String(error); + progress.markFailed(task.id, errorMsg); + // Auto-update the PRD source file checkbox + try { + updateTaskInFile(project.sourcePath, task.id, "failed"); + } catch { + // Best-effort + } + sendChatMessage?.(`✗ ${task.id} · ${task.title} — ${errorMsg}`); + ctx.ui.notify(`Task ${task.id} failed: ${errorMsg}`, "error"); + break; + } + } + } + + // ── Batch-level conflict resolution ── + // After all tasks in the batch finish, resolve any deferred merge conflicts + // by spawning resolution agent sessions. This doesn't block parallel slots. + if (conflicts.length > 0) { + ctx.ui.notify( + `Resolving ${conflicts.length} merge conflict(s) from batch...`, + "info", + ); + const dir = projectDir ?? project.sourceDir; + for (const c of conflicts) { + await resolveConflictsSession( + ctx, + config, + c.task, + project, + dir, + c.worktree, + config.model, + roundRobin, + progress, + sendChatMessage, + ); + } + } +} + +/** + * Execute tasks in parallel using child processes + */ +async function executeBatchParallel( + tasks: Task[], + project: Project, + config: RalpiConfig, + progress: ProgressTracker, + ctx: ExtensionContext, + sendChatMessage?: SendChatMessage, + projectDir?: string, + roundRobin?: ModelRoundRobin | null, + useWorktree?: boolean, + conflicts?: BatchConflict[], +): Promise { + const maxParallel = config.execution.maxParallel; + const sharedState: ParallelWidgetState = new Map(); + + // Register a single batch widget that renders ALL parallel tasks in ID order. + const widgetKey = `ralpi-parallel-${Date.now()}`; + let widgetTui: { requestRender(): void } | null = null; + + const buildBatchLines = ( + t: typeof ctx.ui.theme, + width?: number, + ): string[] => { + const effectiveWidth = width || 74; + const lines: string[] = []; + const sortedIds = Array.from(sharedState.keys()).sort(); + + for (const id of sortedIds) { + const entry = sharedState.get(id)!; + const frame = entry.done + ? entry.success + ? "✓" + : "✗" + : t.fg("accent", SPINNER_FRAMES[entry.frameIndex]); + lines.push( + truncateToWidth(`${frame} ${entry.taskHeader}`, effectiveWidth), + ); + + // Only show tool calls for in-progress tasks; completed/failed + // tasks already have their tool-call tree in the chat history message. + if (!entry.done && entry.toolCalls.length > 0) { + if (entry.toolCalls.length <= MAX_COLLAPSED) { + for (let i = 0; i < entry.toolCalls.length; i++) { + const tc = entry.toolCalls[i]; + const isLast = i === entry.toolCalls.length - 1; + const branch = isLast ? " └── " : " ├── "; + const tag = t.fg("accent", `[${tc.name}]`); + lines.push( + truncateToWidth(`${branch}${tag} ${tc.label}`, effectiveWidth), + ); + } + } else { + const shown = entry.toolCalls.slice(-MAX_COLLAPSED); + const remaining = entry.toolCalls.length - shown.length; + lines.push( + truncateToWidth( + t.fg("dim", ` ├── …${remaining} earlier`), + effectiveWidth, + ), + ); + for (let i = 0; i < shown.length; i++) { + const tc = shown[i]; + const isLast = i === shown.length - 1; + const branch = isLast ? " └── " : " ├── "; + const tag = t.fg("accent", `[${tc.name}]`); + lines.push( + truncateToWidth(`${branch}${tag} ${tc.label}`, effectiveWidth), + ); + } + } + } + } + return lines; + }; + + ctx.ui.setWidget(widgetKey, (tui, t) => { + widgetTui = tui; + return { + render: (width?: number) => buildBatchLines(t, width), + invalidate: () => widgetTui?.requestRender(), + }; + }); + + // Batch-render trigger: re-render on spinner ticks AND content changes. + // Spinner animation requires requestRender() on every tick; without it, + // spinner frames advance in memory but the display never updates. + const requestBatchRender = () => widgetTui?.requestRender(); + + const spinnerTimer = setInterval(() => { + for (const entry of sharedState.values()) { + if (!entry.done) { + entry.frameIndex = (entry.frameIndex + 1) % SPINNER_FRAMES.length; + } + } + requestBatchRender(); + }, 100); + + // Semaphore-based concurrency control: + // Start up to maxParallel tasks immediately. When ANY task completes, + // start the next pending task. This ensures slots fill as soon as they + // open, instead of blocking on the oldest task (FIFO pattern). + const pending = [...tasks]; + const running = new Set>(); + + /** Start the next pending task if a slot is available. */ + const kick = (): void => { + while (running.size < maxParallel && pending.length > 0) { + const task = pending.shift()!; + const assignedModel = roundRobin?.assign(task.id); + + const p = executeTask( + task, + project, + config, + progress, + ctx, + sendChatMessage, + projectDir, + sharedState, + assignedModel, + roundRobin, + requestBatchRender, + useWorktree, + conflicts, + ) + .catch((error) => { + // Safety net: one task failure should never crash the batch. + // executeTask already marks failed and notifies, but catch as + // a last resort so the error doesn't propagate and crash pi. + roundRobin?.release(task.id); + requestBatchRender(); + const errorMsg = + error instanceof Error ? error.message : String(error); + progress.markFailed(task.id, errorMsg); + // Auto-update the PRD source file checkbox + try { + updateTaskInFile(project.sourcePath, task.id, "failed"); + } catch { + // Best-effort + } + sendChatMessage?.(`✗ ${task.id} · ${task.title} — ${errorMsg}`); + ctx.ui.notify(`Task ${task.id} failed: ${errorMsg}`, "error"); + }) + .finally(() => { + // Remove from running set and start next pending task + running.delete(p); + requestBatchRender(); + kick(); + }); + + running.add(p); + } + }; + + // Kick off initial batch of tasks (up to maxParallel) + kick(); + + // Wait for all tasks to complete (kick() adds new promises to `running` + // when completed tasks free up slots, so we iterate until the set is empty). + while (running.size > 0) { + await Promise.race(running); + } + + clearInterval(spinnerTimer); + ctx.ui.setWidget(widgetKey, undefined); +} + +// ─── Execute Single Task with Retry ────────────────────────────────────────── + +async function executeTask( + task: Task, + project: Project, + config: RalpiConfig, + progress: ProgressTracker, + ctx: ExtensionContext, + sendChatMessage?: SendChatMessage, + projectDir: string = project.sourceDir, + parallelState?: ParallelWidgetState, + assignedModel?: unknown, + roundRobin?: ModelRoundRobin | null, + batchRender?: () => void, + useWorktree?: boolean, + conflicts?: BatchConflict[], +): Promise { + // Model failover: when a provider/API is down, cycle through available models. + // The agent's built-in retry handles transient HTTP errors with exponential + // backoff WITHIN a single prompt. Ralpi adds two layers on + // top: (1) reattempt the SAME model up to `maxSameModelAttempts` times — a + // sustained provider hiccup can exhaust pi's in-call retries mid-session, + // and flapping to a different model on the first hard failure throws away + // model-specific context; (2) once same-model retries are exhausted, cycle + // to the next model in the round-robin pool. + const maxModelAttempts = roundRobin ? roundRobin.length : 1; + const maxSameModelAttempts = Math.max( + 1, + config.execution.maxSameModelAttempts, + ); + let modelAttempt = 0; + let sameModelAttempt = 0; + // Resolve implModel from config (used in sequential mode when no round-robin assignment). + // In parallel mode, the round-robin assignedModel takes precedence. + const implModel = resolveModelSpec( + ctx.modelRegistry as { find(p: string, m: string): unknown } | undefined, + config.execution.implModel, + (msg) => ctx.ui.notify(msg, "warning"), + ); + let currentModel: unknown = assignedModel ?? implModel ?? config.model; + + // ── Worktree isolation ── + // When enabled, the task runs in a separate git worktree so parallel tasks + // can't stomp each other's files, and review/commit see a clean single-task + // diff. `worktreeDir` is used for agent cwd + git ops; `projectDir` stays as + // the main repo dir for state saves (reflections, reviews, progress.json). + const wt = useWorktree + ? createWorktree( + projectDir, + config.paths.stateDir, + task.id, + progress.getKey(), + undefined, + task.title, + ) + : null; + const worktreeDir = wt?.dir ?? projectDir; + + while (modelAttempt < maxModelAttempts) { + // Model advancement happens in the cycling branch below (not here) so a + // same-model retry `continue` doesn't re-advance and accidentally swap + // models mid-retry. The first model uses `currentModel` set above. + + try { + // Mark as in progress + progress.markInProgress(task.id); + // Auto-update the PRD source file checkbox + try { + updateTaskInFile(project.sourcePath, task.id, "in_progress"); + } catch { + // Best-effort: don't fail the task over a checkbox update + } + + // Get dependency reflections + const depReflections = progress.getDependencyReflections( + task.dependencies || [], + ); + + // Capture base HEAD before execution so the review-gated loop can diff + // the complete task output (baseRef..HEAD) across execution + fix attempts. + const baseRef = config.execution.autoReview + ? captureGitHead(worktreeDir) + : undefined; + + // Load a prior review from disk when resuming an interrupted loop. + // If the previous run's review rejected the task (verdict 'fail') and the + // re-execution was lost to a crash/connection error, the findings would + // otherwise be orphaned. Injecting them here gives the fresh run the + // reviewer's feedback so it doesn't reintroduce the same blockers. + let priorReview: ReviewResult | undefined; + if (config.execution.autoReview) { + const loaded = loadReviewJson( + projectDir, + config.paths.reviewsDir, + task.id, + progress.getKey(), + ); + if (loaded && loaded.verdict === "fail") { + priorReview = loaded; + sendChatMessage?.( + `↻ ${task.id} · ${task.title} — resuming with prior review feedback (${loaded.findings.length} findings)`, + ); + } + } + + // Run the task + const result = await runTask( + task, + project, + config, + depReflections, + ctx, + sendChatMessage, + worktreeDir, + parallelState, + currentModel, + batchRender, + priorReview, + ); + + if (result.success) { + let finalCommitMessages = result.commitMessages ?? []; + let finalCommitSummary = result.commitSummary ?? ""; + let finalReview: ReviewResult | undefined; + let reviewRetries = 0; + + if (config.execution.autoReview) { + // ── Review-gated loop: commit → review → re-execute on fail → merge on pass ── + // The commit is mandated — when the task agent didn't self-commit, a + // commit session handles it. Then the COMPLETE task diff (baseRef..HEAD) + // is reviewed. On 'fail' the task is re-executed with the review feedback + // injected (up to maxReviewRetries); after re-execution changes are + // committed again and the full diff is re-reviewed with the SAME baseRef + // so the reviewer sees the complete state, not just incremental fixes. + // On pass the changes are already committed — the worktree merges next. + const maxRetries = config.execution.maxReviewRetries; + let attempt = 0; + + try { + // ── Ensure committed (commit session fallback) ── + // If the task agent didn't self-commit, a commit session handles it. + if (hasUncommittedChanges(worktreeDir)) { + const commitResult = await runCommitSession( + ctx, + config, + task, + worktreeDir, + currentModel, + roundRobin, + sendChatMessage, + ); + if (commitResult.success) { + finalCommitMessages = [ + ...finalCommitMessages, + ...commitResult.commitMessages, + ]; + finalCommitSummary = finalCommitSummary + ? `${finalCommitSummary}; ${commitResult.commitSummary}` + : commitResult.commitSummary; + } + } + + // ── Review loop ── + // baseRef was captured before runTask (above). Each review iteration + // diffs the range baseRef..HEAD — the complete task output including + // all fix attempts. On re-execution the same baseRef is reused. + // A FAILED range computation (broken/stale base ref, git error) is + // logged as a distinct warning and is never treated as a clean, + // verified task — only a GENUINE "no changes" skips review. + while (true) { + if (!baseRef) { + sendChatMessage?.( + `~ review for ${task.id} · ${task.title} — diff could not be computed (could not capture base ref before execution)`, + ); + break; + } + // Cheap guard mirroring canCompareToBase: if the captured base ref no + // longer resolves (stale/broken worktree ref), warn explicitly and + // never treat the task as review-verified. + if (!canComputeRange(worktreeDir, baseRef)) { + sendChatMessage?.( + `~ review for ${task.id} · ${task.title} — diff could not be computed (base ref ${baseRef} no longer resolves)`, + ); + break; + } + const rangeDiff = getCommitRangeDiff(worktreeDir, baseRef); + if (rangeDiff.kind === "error") { + sendChatMessage?.( + `~ review for ${task.id} · ${task.title} — diff could not be computed (${rangeDiff.error})`, + ); + break; + } + if (rangeDiff.kind === "no-changes") { + sendChatMessage?.( + `~ review for ${task.id} · ${task.title} — skipping review (no changes found between base and HEAD)`, + ); + break; + } + const reviewInfo = rangeDiff; + + const reviewPrompt = buildReviewPrompt( + task, + project, + reviewInfo.hash, + reviewInfo.subject, + reviewInfo.diff, + { + projectContext: config.prompts.projectContext, + focus: config.prompts.reviewFocus, + diffOptions: { + extraPatterns: compileIgnorePatterns( + config.review.extraIgnorePatterns, + ), + ignorePaths: config.review.ignorePaths, + }, + }, + ); + + const reviewModel = resolveFollowUpModel( + ctx, + config.execution.reviewModel, + currentModel, + ); + const reviewModels = buildFailoverModels(reviewModel, roundRobin); + + const { result: reviewResult, toolCalls: reviewToolCalls } = + await runFollowUpSession( + ctx, + config, + reviewPrompt, + worktreeDir, + `review for ${task.id} · ${task.title}${ + attempt > 0 ? ` (attempt ${attempt + 1})` : "" + }`, + `review-${task.id}`, + config.execution.reviewTimeoutMs, + reviewModels, + ); + + if (!reviewResult.success) { + sendChatMessage?.( + `~ review for ${task.id} · ${task.title} — review session failed: ${reviewResult.error}`, + { toolCalls: reviewToolCalls }, + ); + break; // proceed with what we have (changes already committed) + } + + const reviewText = reviewResult.text.trim(); + const review = extractReview( + reviewText, + task.id, + reviewInfo.hash, + ); + finalReview = review ?? undefined; + + // Persist structured review JSON when opted in. + let reviewPath: string | undefined; + if (review && config.execution.saveReviews) { + reviewPath = saveReviewJson( + projectDir, + config.paths.reviewsDir, + review, + progress.getKey(), + ); + } + + if ( + review && + (review.verdict === "pass" || review.verdict === "warn") + ) { + // Review passed — all changes are committed; merge will follow. + const label = `${verdictGlyph(review.verdict)} ${verdictSummary(review)}`; + const savedHint = reviewPath ? ` · saved to ${reviewPath}` : ""; + sendChatMessage?.( + `⚑ review for ${task.id} · ${task.title} — ${label}${savedHint}`, + { + toolCalls: reviewToolCalls, + reviewText, + reviewPath, + reviewResult: review, + }, + ); + break; // good to merge + } + + // Review rejected (fail) or verdict not parsed. + if (review) { + sendChatMessage?.( + `⚑ review for ${task.id} · ${task.title} — ${verdictGlyph(review.verdict)} ${verdictSummary(review)}`, + { + toolCalls: reviewToolCalls, + reviewText, + reviewPath, + reviewResult: review, + }, + ); + } else { + const lines = reviewText.split("\n").filter((l) => l.trim()); + const tail = lines.slice(-3).join("\n"); + const savedHint = reviewPath ? ` · saved to ${reviewPath}` : ""; + sendChatMessage?.( + `⚑ review for ${task.id} · ${task.title} — verdict not found${savedHint}\n${tail}`, + { toolCalls: reviewToolCalls, reviewText, reviewPath }, + ); + } + + if (attempt >= maxRetries) { + // Retries exhausted — changes are already committed; proceed. + if (config.execution.reviewBlockOnFail) { + sendChatMessage?.( + `✗ ${task.id} · ${task.title} — review rejected after ${maxRetries} retr${maxRetries === 1 ? "y" : "ies"} (reviewBlockOnFail)`, + ); + progress.markFailed( + task.id, + `Review rejected after ${maxRetries} re-execution attempt(s)`, + ); + try { + updateTaskInFile(project.sourcePath, task.id, "failed"); + } catch { + // Best-effort + } + roundRobin?.release(task.id); + return; + } + sendChatMessage?.( + `~ review for ${task.id} · ${task.title} — max retries (${maxRetries}) exhausted, proceeding with current state`, + ); + break; // changes already committed — merge proceeds + } + + attempt++; + reviewRetries++; + sendChatMessage?.( + `↻ review for ${task.id} · ${task.title} — verdict ${review?.verdict ?? "unknown"}, re-executing with feedback (${attempt}/${maxRetries})...`, + ); + + // Re-execute the task with review feedback injected, cycling + // through failover models on connection errors so a flaky + // provider doesn't waste the review-fix attempt. + const fixModels = buildFailoverModels(currentModel, roundRobin); + let fixResult: Awaited> | undefined; + for ( + let fixAttempt = 0; + fixAttempt < fixModels.length; + fixAttempt++ + ) { + const fixModel = fixModels[fixAttempt]; + let fixSameAttempt = 0; + // Reattempt on the same model before cycling, matching the main + // task loop's behavior. + for (;;) { + fixResult = await runTask( + task, + project, + config, + depReflections, + ctx, + sendChatMessage, + worktreeDir, + parallelState, + fixModel, + batchRender, + review ?? undefined, + ); + if (fixResult.success) break; + if (fixSameAttempt < maxSameModelAttempts - 1) { + fixSameAttempt++; + sendChatMessage?.( + `~ re-execution for ${task.id} · ${task.title} — reattempting model ${fixAttempt + 1}/${fixModels.length} (${fixSameAttempt + 1}/${maxSameModelAttempts}, previous: ${fixResult.error})`, + ); + continue; + } + break; // same-model retries exhausted + } + if (fixResult.success) break; + // Connection/error failover — try the next model. + if (fixAttempt < fixModels.length - 1) { + sendChatMessage?.( + `~ re-execution for ${task.id} · ${task.title} — cycling to model ${fixAttempt + 2}/${fixModels.length} (previous: ${fixResult.error})`, + ); + } + } + + if (!fixResult || !fixResult.success) { + sendChatMessage?.( + `~ re-execution for ${task.id} · ${task.title} failed: ${fixResult?.error}`, + ); + break; // proceed with what we have + } + + // Merge commit messages from the fix attempt. + finalCommitMessages = [ + ...finalCommitMessages, + ...(fixResult.commitMessages ?? []), + ]; + finalCommitSummary = finalCommitSummary + ? `${finalCommitSummary}; ${fixResult.commitSummary ?? ""}` + : (fixResult.commitSummary ?? ""); + + // Ensure committed after re-execution (same commit fallback). + if (hasUncommittedChanges(worktreeDir)) { + const commitResult = await runCommitSession( + ctx, + config, + task, + worktreeDir, + currentModel, + roundRobin, + sendChatMessage, + ); + if (commitResult.success) { + finalCommitMessages = [ + ...finalCommitMessages, + ...commitResult.commitMessages, + ]; + finalCommitSummary = finalCommitSummary + ? `${finalCommitSummary}; ${commitResult.commitSummary}` + : commitResult.commitSummary; + } + } + // Loop back to review with the same baseRef — the reviewer sees the + // complete diff (original work + fixes), not just incremental changes. + } + } catch (error) { + sendChatMessage?.( + `~ review/commit for ${task.id} · ${task.title} — error: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } else if (config.execution.autoCommit) { + // ── Commit only (no review) ── + try { + if (hasUncommittedChanges(worktreeDir)) { + const commitResult = await runCommitSession( + ctx, + config, + task, + worktreeDir, + currentModel, + roundRobin, + sendChatMessage, + ); + if (commitResult.success) { + finalCommitMessages = [ + ...finalCommitMessages, + ...commitResult.commitMessages, + ]; + finalCommitSummary = finalCommitSummary + ? `${finalCommitSummary}; ${commitResult.commitSummary}` + : commitResult.commitSummary; + } + } + } catch (error) { + sendChatMessage?.( + `~ commit for ${task.id} · ${task.title} — auto-commit error: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + + // Save reflection + if (result.reflection) { + saveReflectionToFile( + projectDir, + config, + result.reflection, + progress.getKey(), + ); + } + + // ── Merge worktree back to main ── + // After the commit lands on the worktree branch, merge it into the + // main repo so downstream tasks see the changes. On conflict, the + // conflict is deferred to batch-level resolution (the caller collects + // conflicted worktrees and spawns resolution sessions after the batch). + if (wt) { + const mergeResult = mergeWorktree(projectDir, wt.branch); + if (!mergeResult.success) { + sendChatMessage?.( + `⚠ ${task.id} · ${task.title} — merge conflict, deferring to batch resolution\n ${mergeResult.message}`, + ); + // Defer conflict resolution to the batch level. + if (conflicts) { + conflicts.push({ + task, + worktree: wt, + mergeResult, + result: { + reflection: result.reflection, + toolUsage: result.toolUsage, + outputPreview: result.outputPreview, + commitMessages: finalCommitMessages, + commitSummary: finalCommitSummary, + durationMs: result.durationMs, + }, + }); + } else { + // No conflict collector — mark failed as fallback. + progress.markFailed(task.id, mergeResult.message); + try { + updateTaskInFile(project.sourcePath, task.id, "failed"); + } catch { + // Best-effort + } + } + roundRobin?.release(task.id); + return; + } + // Merge succeeded — clean up the worktree. + removeWorktree(projectDir, wt); + sendChatMessage?.(`✓ merged worktree for ${task.id} into main`); + } + + // Mark completed with all metadata + progress.markCompleted( + task.id, + result.durationMs, + result.reflection, + result.toolUsage, + result.outputPreview, + finalCommitMessages, + finalCommitSummary, + finalReview, + reviewRetries, + ); + // Auto-update the PRD source file checkbox + try { + updateTaskInFile(project.sourcePath, task.id, "completed"); + } catch { + // Best-effort: don't fail the task over a checkbox update + } + roundRobin?.release(task.id); + return; + } + + // Agent session failed (provider error). + // Pi's built-in in-call retry already exhausted for this attempt. + // Reattempt on the SAME model a few more times before cycling — a + // transient outage can outlast pi's per-prompt backoff window. + sameModelAttempt++; + if (sameModelAttempt < maxSameModelAttempts) { + sendChatMessage?.( + `~ ${task.id} · ${task.title} — reattempting model ${modelAttempt + 1}/${maxModelAttempts} (${sameModelAttempt + 1}/${maxSameModelAttempts}, previous: ${result.error})`, + ); + continue; // same model, fresh session + } + + // Same-model retries exhausted — cycle to the next model (if any). + if (roundRobin && modelAttempt < maxModelAttempts - 1) { + modelAttempt++; + sameModelAttempt = 0; + currentModel = roundRobin.advance(task.id); + sendChatMessage?.( + `~ ${task.id} · ${task.title} — cycling to model ${modelAttempt + 1}/${maxModelAttempts} (previous: ${result.error})`, + ); + continue; // next model in the outer while loop + } + + // All models exhausted. + progress.markFailed(task.id, result.error || "Unknown error"); + try { + updateTaskInFile(project.sourcePath, task.id, "failed"); + } catch { + // Best-effort + } + sendChatMessage?.(`✗ ${task.id} · ${task.title} — ${result.error}`); + ctx.ui.notify( + `Task ${task.id} failed across ${maxModelAttempts} models: ${ + result.error || "Unknown error" + }`, + "error", + ); + cleanupFailedWorktree(projectDir, wt, task, sendChatMessage); + roundRobin?.release(task.id); + return; + } catch (error) { + roundRobin?.release(task.id); + batchRender?.(); + const errorMsg = error instanceof Error ? error.message : String(error); + progress.markFailed(task.id, errorMsg); + // Auto-update the PRD source file checkbox + try { + updateTaskInFile(project.sourcePath, task.id, "failed"); + } catch { + // Best-effort + } + sendChatMessage?.(`✗ ${task.id} · ${task.title} — ${errorMsg}`); + ctx.ui.notify(`Task ${task.id} failed: ${errorMsg}`, "error"); + cleanupFailedWorktree(projectDir, wt, task, sendChatMessage); + return; + } + } + + // All models exhausted — release the slot + roundRobin?.release(task.id); + batchRender?.(); + progress.markFailed(task.id, "All configured models exhausted"); + sendChatMessage?.( + `✗ ${task.id} · ${task.title} — all ${maxModelAttempts} models exhausted`, + ); + ctx.ui.notify( + `Task ${task.id} failed: all configured models exhausted`, + "error", + ); + 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, + reflection: Reflection, + prdKey: string, +): void { + const reflectionsDir = path.join( + sourceDir, + config.paths.reflectionsDir, + prdKey, + ); + ensureDir(reflectionsDir); + const filePath = path.join(reflectionsDir, `${reflection.taskId}.json`); + writeFileSafe(filePath, JSON.stringify(reflection, null, 2)); +} + +// ─── Follow-Up Sessions (Commit / Review) ───────────────────────────────────── + +/** + * Run a follow-up agent session (commit, review, etc.) with a live spinner + * widget. Handles widget setup, spinner animation, session execution, and + * cleanup. Cycles through `models` on connection failure so a flaky provider + * doesn't kill the commit/review step. Returns the session result and + * captured tool calls. + */ +async function runFollowUpSession( + ctx: ExtensionContext, + config: RalpiConfig, + prompt: string, + projectDir: string, + header: string, + widgetKeySuffix: string, + timeoutMs: number, + models: unknown[], +): Promise<{ + result: Awaited>; + toolCalls: ToolCallEntry[]; +}> { + const toolCalls: ToolCallEntry[] = []; + let frameIndex = 0; + let widgetTui: { requestRender(): void } | null = null; + const widgetKey = `ralpi-${widgetKeySuffix}-${Date.now()}`; + + const truncateWidth = 74; + + const buildLines = (t: typeof ctx.ui.theme, width?: number): string[] => { + const effectiveWidth = width + ? Math.min(width, truncateWidth) + : truncateWidth; + const frame = t.fg( + "accent", + SPINNER_FRAMES[frameIndex % SPINNER_FRAMES.length], + ); + const lines = [truncateToWidth(`~ ${frame} ${header}`, effectiveWidth)]; + + if (toolCalls.length > 0) { + if (toolCalls.length <= MAX_COLLAPSED) { + for (let i = 0; i < toolCalls.length; i++) { + const entry = toolCalls[i]; + const isLast = i === toolCalls.length - 1; + const branch = isLast ? " └── " : " ├── "; + const tag = t.fg("accent", `[${entry.name}]`); + lines.push( + truncateToWidth(`${branch}${tag} ${entry.label}`, effectiveWidth), + ); + } + } else { + const shown = toolCalls.slice(-MAX_COLLAPSED); + const remaining = toolCalls.length - shown.length; + lines.push( + truncateToWidth( + t.fg("dim", ` ├── …${remaining} earlier`), + effectiveWidth, + ), + ); + for (let i = 0; i < shown.length; i++) { + const entry = shown[i]; + const isLast = i === shown.length - 1; + const branch = isLast ? " └── " : " ├── "; + const tag = t.fg("accent", `[${entry.name}]`); + lines.push( + truncateToWidth(`${branch}${tag} ${entry.label}`, effectiveWidth), + ); + } + } + } + return lines; + }; + + ctx.ui.setWidget(widgetKey, (tui, t) => { + widgetTui = tui; + return { + render: (width?: number) => buildLines(t, width), + invalidate: () => widgetTui?.requestRender(), + }; + }); + + const requestRender = () => widgetTui?.requestRender(); + + const spinnerTimer = setInterval(() => { + frameIndex = (frameIndex + 1) % SPINNER_FRAMES.length; + requestRender(); + }, 100); + + let result: Awaited> | undefined; + const maxSameModelAttempts = Math.max( + 1, + config.execution.maxSameModelAttempts, + ); + try { + for (let attempt = 0; attempt < models.length; attempt++) { + const model = models[attempt]; + // Reattempt on the same model before cycling — matches the main task + // loop. Clear partial tool calls between failed attempts so the + // widget reflects only the successful (or final) attempt. + for (let same = 0; same < maxSameModelAttempts; same++) { + result = await runAgentSession( + prompt, + projectDir, + timeoutMs, + (event) => { + if (event.type === "tool_execution_start") { + const label = formatToolArg(event.toolName, event.args); + toolCalls.push({ name: event.toolName, label }); + requestRender(); + } + }, + undefined, + model, + config.thinkingLevel, + false, // noSkills=false — follow-up sessions load skills too + ctx.modelRegistry, + ); + + if (result.success) break; + if (same < maxSameModelAttempts - 1) { + toolCalls.length = 0; + requestRender(); + } + } + + if (result!.success) break; + + // If there's a next model to try, cycle; otherwise give up. + if (attempt < models.length - 1) { + // Clear partial tool calls from the failed attempt so the widget + // reflects only the successful (or final) attempt. + toolCalls.length = 0; + requestRender(); + } + } + } finally { + clearInterval(spinnerTimer); + ctx.ui.setWidget(widgetKey, undefined); + } + + // result is always set — the loop runs at least once (models.length >= 1) + return { result: result!, toolCalls }; +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** + * Build a model failover list for a follow-up session. + * + * The primary model goes first; the remaining models from the round-robin + * pool are appended (deduped) so a flaky provider doesn't kill the commit + * or review step. When there's no round-robin (sequential mode), the + * primary model is returned as a single-element list. + */ +function buildFailoverModels( + primary: unknown, + roundRobin: ModelRoundRobin | null | undefined, +): unknown[] { + const models: unknown[] = [primary]; + if (roundRobin) { + for (const m of roundRobin.allModels) { + if (m !== primary) models.push(m); + } + } + return models; +} + +// ─── Tool Call Formatting ──────────────────────────────────────────────── + +/** + * Shorthand type for the model registry's find() shape. + */ +type ModelRegistryLike = { find(p: string, m: string): unknown }; + +/** + * Resolve a model spec for a follow-up session (commit/review), falling back + * to `currentModel` when the config field is blank or the registry can't + * resolve it. Warns via `ctx.ui.notify` on resolution failure. + */ +function resolveFollowUpModel( + ctx: ExtensionContext, + spec: string, + currentModel: unknown, +): unknown { + return ( + resolveModelSpec( + ctx.modelRegistry as ModelRegistryLike | undefined, + spec, + (msg) => ctx.ui.notify(msg, "warning"), + ) ?? currentModel + ); +} + +/** + * Run the auto-commit follow-up agent session. + * Returns the commit messages, summary, tool calls, and success flag. + */ +async function runCommitSession( + ctx: ExtensionContext, + config: RalpiConfig, + task: Task, + projectDir: string, + currentModel: unknown, + roundRobin: ModelRoundRobin | null | undefined, + sendChatMessage?: SendChatMessage, +): Promise<{ + commitMessages: string[]; + commitSummary: string; + toolCalls: ToolCallEntry[]; + success: boolean; +}> { + const status = getGitStatusPorcelain(projectDir); + let diff = getGitDiff(projectDir); + let diffNote = ""; + if (diff.length > MAX_DIFF_BYTES) { + diffNote = + "\n\n... (diff truncated: omitted " + + (diff.length - MAX_DIFF_BYTES).toLocaleString() + + " bytes; run `git diff` to view the full diff)"; + diff = diff.slice(0, MAX_DIFF_BYTES); + } + const commitPrompt = [ + `## Auto-Commit for Task ${task.id}: ${task.title}`, + "", + "The previous task is complete. There are uncommitted changes in the repository.", + "", + "Only commit changes you made while completing this task. Do not commit pre-existing changes, changes from other work, or files unrelated to this task.", + "Review the git status and diff below to identify which changes are from your work, and stage only those files.", + "", + "Stage only the files relevant to this task with `git add `, then create a meaningful git commit.", + "Use a descriptive commit message and follow conventional commits format.", + "Do NOT include the task number, task ID, or any ralpi task reference in the commit message. The commit message must describe only the work done — never mention the task ID (e.g. `task 03`, `#3`, etc.).", + "", + "### Current Changes (git status --porcelain)", + "```text", + status || "(no status output)", + "```", + "", + "### Current Tracked Diff (git diff)", + "```diff", + diff || "(no tracked diff output)", + diffNote, + "```", + ].join("\n"); + + const commitModel = resolveFollowUpModel( + ctx, + config.execution.commitModel, + currentModel, + ); + const commitModels = buildFailoverModels(commitModel, roundRobin); + + const { result: commitResult, toolCalls: commitToolCalls } = + await runFollowUpSession( + ctx, + config, + commitPrompt, + projectDir, + `commit for ${task.id} · ${task.title}`, + `commit-${task.id}`, + config.execution.commitTimeoutMs, + commitModels, + ); + + if (commitResult.success) { + const newCommits = captureGitCommits(projectDir); + const commitMessages = + newCommits.commitMessages.length > 0 ? newCommits.commitMessages : []; + const commitSummary = newCommits.commitSummary || ""; + sendChatMessage?.(`✓ commit for ${task.id} · ${task.title}`, { + toolCalls: commitToolCalls, + }); + return { + commitMessages, + commitSummary, + toolCalls: commitToolCalls, + success: true, + }; + } + + sendChatMessage?.( + `~ commit for ${task.id} · ${task.title} — follow-up commit session failed: ${commitResult.error}`, + { toolCalls: commitToolCalls }, + ); + return { + commitMessages: [], + commitSummary: "", + toolCalls: commitToolCalls, + success: false, + }; +} + +// ─── Batch Conflict Resolution ─────────────────────────────────────────────── + +/** + * Resolve a merge conflict by spawning an agent session in the main repo. + * + * The merge was already attempted (and aborted) by `mergeWorktree` during + * `executeTask`. This function re-attempts the merge to recreate the conflict + * state, spawns an agent to resolve all conflict markers, stage, and commit, + * then verifies completion. On success, the worktree is cleaned up and the + * task is marked completed. On failure, the merge is aborted and the task + * is marked failed (the worktree is retained for inspection). + * + * Runs after all tasks in a batch finish, so parallel task slots aren't + * blocked waiting for conflict resolution. + */ +async function resolveConflictsSession( + ctx: ExtensionContext, + config: RalpiConfig, + task: Task, + project: Project, + projectDir: string, + worktree: WorktreeHandle, + currentModel: unknown, + roundRobin: ModelRoundRobin | null | undefined, + progress: ProgressTracker, + sendChatMessage?: SendChatMessage, +): Promise { + const { branch } = worktree; + + // Re-attempt the merge to recreate the conflict state in the main repo. + const attempt = reattemptMerge(projectDir, branch); + if (attempt.clean) { + // No conflicts on re-attempt — complete the merge directly. + if (completeMerge(projectDir)) { + sendChatMessage?.( + `✓ conflicts auto-resolved for ${task.id} · ${task.title}`, + ); + removeWorktree(projectDir, worktree); + progress.markCompleted( + task.id, + 0, // duration already tracked in executeTask + undefined, + undefined, + undefined, + [], + "", + undefined, + 0, + ); + try { + updateTaskInFile(project.sourcePath, task.id, "completed"); + } catch { + // Best-effort + } + return; + } + // completeMerge failed — fall through to mark failed. + abortMerge(projectDir); + progress.markFailed(task.id, `Failed to complete merge of ${branch}`); + try { + updateTaskInFile(project.sourcePath, task.id, "failed"); + } catch { + // Best-effort + } + return; + } + + // Conflicts exist — spawn a resolution agent session. + const prompt = buildConflictResolutionPrompt( + task, + project, + attempt.conflicts, + branch, + config.prompts.projectContext, + ); + + const commitModel = resolveFollowUpModel( + ctx, + config.execution.commitModel, + currentModel, + ); + const models = buildFailoverModels(commitModel, roundRobin); + + sendChatMessage?.( + `⚑ resolving ${attempt.conflicts.length} conflict(s) for ${task.id} · ${task.title}...`, + ); + + const { result, toolCalls } = await runFollowUpSession( + ctx, + config, + prompt, + projectDir, + `resolve conflicts for ${task.id}`, + `resolve-${task.id}`, + config.execution.commitTimeoutMs, + models, + ); + + if (!result.success) { + sendChatMessage?.( + `~ conflict resolution for ${task.id} · ${task.title} — session failed: ${result.error}`, + { toolCalls }, + ); + abortMerge(projectDir); + progress.markFailed( + task.id, + `Conflict resolution session failed: ${result.error}`, + ); + try { + updateTaskInFile(project.sourcePath, task.id, "failed"); + } catch { + // Best-effort + } + return; + } + + // Check if the agent actually resolved all conflicts and committed. + if (hasMergeConflicts(projectDir)) { + // Agent didn't resolve everything — abort and fail. + sendChatMessage?.( + `✗ ${task.id} · ${task.title} — conflict resolution incomplete, ${attempt.conflicts.length} file(s) still conflicted`, + { toolCalls }, + ); + abortMerge(projectDir); + progress.markFailed( + task.id, + `Conflict resolution incomplete — unresolved conflicts remaining`, + ); + try { + updateTaskInFile(project.sourcePath, task.id, "failed"); + } catch { + // Best-effort + } + return; + } + + // Success — conflicts resolved and merge committed. + sendChatMessage?.(`✓ conflicts resolved for ${task.id} · ${task.title}`, { + toolCalls, + }); + removeWorktree(projectDir, worktree); + progress.markCompleted( + task.id, + 0, + undefined, + undefined, + undefined, + [], + "", + undefined, + 0, + ); + try { + updateTaskInFile(project.sourcePath, task.id, "completed"); + } catch { + // Best-effort + } +} + +/** + * Strip control characters and newlines from a display label so it + * does not break TUI layout (tree branches, text width calculation). + */ +function sanitizeLabel(s: string): string { + // Replace newlines/carriage returns with spaces (multi-line commands + // must fit on a single tree-branch line), then strip ASCII control + // characters except \t (which is harmless) and keep printable chars. + return s + .replace(/\r?\n/g, " ") + .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "") + .trim(); +} + +/** + * Format a tool call argument into a short label. + */ +function formatToolArg(name: string, args: unknown): string { + const a = args as Record; + switch (name) { + case "bash": + return sanitizeLabel(truncateMiddle(String(a.command ?? ""), 70)); + case "write": + case "read": + return sanitizeLabel(truncateMiddle(String(a.path ?? ""), 60)); + case "edit": + return sanitizeLabel(truncateMiddle(String(a.path ?? ""), 60)); + case "grep": + return sanitizeLabel( + `${a.pattern ?? "?"} — ${truncateMiddle(String(a.path ?? ""), 40)}`, + ); + case "glob": + return sanitizeLabel(`${a.path ?? "."} — ${a.glob ?? "*"}`); + default: + return name; + } +} + +/** + * Truncate a long string in the middle, keeping start and end visible. + */ +function truncateMiddle(s: string, maxLen: number): string { + if (s.length <= maxLen) return s; + const half = Math.floor((maxLen - 3) / 2); + return s.slice(0, half) + "…" + s.slice(s.length - half); +} diff --git a/src/parser.ts b/src/parser.ts new file mode 100644 index 0000000..83e7e7b --- /dev/null +++ b/src/parser.ts @@ -0,0 +1,773 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import type { Task, Project, ParallelGroup, Phase } from "./types"; + +// Lazy-loaded yaml package +let YAML_module: typeof import("yaml") | undefined; +function loadYaml(): typeof import("yaml") { + if (YAML_module) return YAML_module; + try { + YAML_module = require("yaml"); + } catch { + throw new Error( + "YAML parsing requires the 'yaml' package. Run: npm install yaml", + ); + } + return YAML_module!; +} + +// ─── Main Entry ────────────────────────────────────────────────────────────── + +/** + * Parse a task file (markdown or YAML) into a Project structure. + * Supports: + * - Fio README format (numbered tasks with dependency graph) + * - Phased format (## Phase N — Title sections with tasks and dependencies) + * - Simple checkbox format (- [ ] task) + * - YAML format (tasks: [...]) + */ +export function parseTaskFile(filePath: string): Project { + const absolutePath = path.resolve(filePath); + const content = fs.readFileSync(absolutePath, "utf-8"); + const ext = path.extname(filePath).toLowerCase(); + const dir = path.dirname(absolutePath); + + if (ext === ".yaml" || ext === ".yml") { + return parseYaml(content, absolutePath, dir); + } + + // Markdown: detect format + if (hasDependenciesSection(content) || hasPhaseHeadings(content)) { + return parseFioFormat(content, absolutePath, dir); + } + return parseSimpleCheckbox(content, absolutePath, dir); +} + +// ─── Fio Format Parser ─────────────────────────────────────────────────────── + +/** Match both markdown heading (## Dependencies) and plain heading (Dependencies). */ +const DEP_HEADING_RE = /^(?:##\s+)?Dependencies\s*$/m; +/** Match both markdown heading (## Tasks) and plain heading (Tasks). */ +const TASK_HEADING_RE = /^(?:##\s+)?Tasks\s*$/m; +/** Match other markdown headings (## Something). */ +const ANY_MD_HEADING_RE = /^##\s/; +/** Match phase headings: ## Phase 1 — Push-to-Talk MVP */ +const PHASE_HEADING_RE = /^\s*##\s+Phase\s+(\d+)\s*[—–:-]\s*(.+)$/i; +/** Detect plain phase headings too: Phase 1 — Title (no ##) */ +const PHASE_HEADING_PLAIN_RE = /^Phase\s+(\d+)\s*[—–:-]\s*(.+)$/i; +/** + * Detect a plain (non-markdown) section heading like "Exit criteria". + * A plain heading must: + * - Start with a letter + * - Contain only letters and spaces + * - Have no colons (avoids matching "Objective:" and "Status legend:") + * - Not be a task/dep line (doesn't start with "-") + */ +function isPlainSectionHeader(line: string): boolean { + const trimmed = line.trim(); + return trimmed.length > 0 && /^[A-Za-z][A-Za-z\s]*$/.test(trimmed); +} + +function hasDependenciesSection(content: string): boolean { + return DEP_HEADING_RE.test(content); +} + +function hasPhaseHeadings(content: string): boolean { + return PHASE_HEADING_RE.test(content) || PHASE_HEADING_PLAIN_RE.test(content); +} + +function parseFioFormat( + content: string, + sourcePath: string, + sourceDir: string, +): Project { + const lines = content.split("\n"); + const tasks: Task[] = []; + const dependencies: Record = {}; + const parallelGroups: ParallelGroup[] = []; + const phases: Phase[] = []; + let currentPhase: number | null = null; + let currentPhaseTitle = ""; + let inTasks = false; + let inDeps = false; + + for (const line of lines) { + // Check for phase headings first + const phaseMatch = + line.match(PHASE_HEADING_RE) || line.match(PHASE_HEADING_PLAIN_RE); + if (phaseMatch) { + // Save previous phase if exists + if (currentPhase !== null) { + const phaseTaskIds = tasks + .filter((t) => t.phase === currentPhase) + .map((t) => t.id); + if (phaseTaskIds.length > 0) { + phases.push({ + number: currentPhase, + title: currentPhaseTitle, + taskIds: phaseTaskIds, + }); + } + } + // Start new phase + currentPhase = parseInt(phaseMatch[1], 10); + currentPhaseTitle = phaseMatch[2].trim(); + inTasks = true; + inDeps = false; + continue; + } + + if (TASK_HEADING_RE.test(line)) { + inTasks = true; + inDeps = false; + continue; + } + if (DEP_HEADING_RE.test(line)) { + inTasks = false; + inDeps = true; + continue; + } + // Reset state on any other section heading — both ##-style and plain + // BUT NOT phase headings (already handled above) + if ( + (ANY_MD_HEADING_RE.test(line) || isPlainSectionHeader(line)) && + !TASK_HEADING_RE.test(line) && + !DEP_HEADING_RE.test(line) && + !PHASE_HEADING_RE.test(line) && + !PHASE_HEADING_PLAIN_RE.test(line) + ) { + inTasks = false; + inDeps = false; + continue; + } + + if (inTasks) { + // Match all tasks on a line (supports compact single-line formats). + // ID is digits optionally followed by a single lowercase letter + // (e.g. "01", "02b", "10c") — see normalizeTaskId for the shape. + const taskPattern = + /-+\s+\[(.)\]\s+(\d+[a-z]?)\s+[—–:-]\s+(.+?)(?:\s+(?=-+\s+\[)|\s*→\s*`([^`]+)`|$)/g; + let match: RegExpExecArray | null; + while ((match = taskPattern.exec(line)) !== null) { + const [, status, id, title, file] = match; + const timeoutMs = parseTimeoutFromLine(line); + tasks.push({ + id: normalizeTaskId(id), + title: title.trim(), + description: undefined, + file: file || undefined, + status: charToStatus(status), + dependencies: [], + timeoutMs, + index: tasks.length, + phase: currentPhase ?? undefined, + }); + } + } + + if (inDeps) { + // Arrow notation (supports both -> and unicode \u2192) + // "01 -> 02,03,06" means 02, 03, 06 depend on 01 + // "02 \u2192 08" — single arrow with unicode + // "03 \u2192 04 \u2192 05" — chained: 04 depends on 03, 05 depends on 04 + // "05, 07, 08 \u2192 13" — multi-prereq: 13 depends on 05, 07, 08 + // Supports optional markdown list prefix: "- 01 -> 02,03,06" + const hasArrow = /->/.test(line) || /\u2192/.test(line); + if (hasArrow) { + // Strip optional list prefix and parenthetical description + const cleaned = line + .replace(/^(\s*[-*]\s+)?/, "") + .replace(/\s*\(.*\)\s*$/, ""); + + // Split on arrows to get segments + const segments = cleaned + .split(/->|\u2192/) + .map((s) => s.trim()) + .filter(Boolean); + + if (segments.length >= 2) { + for (let i = 0; i < segments.length - 1; i++) { + // Left segment: source(s) (comma-separated) + const fromIds = segments[i] + .split(",") + .map((t) => t.trim()) + .filter((t) => /^\d+[a-z]?$/.test(t)) + .map((t) => normalizeTaskId(t)); + + // Right segment: target(s) (comma-separated) + const toIds = segments[i + 1] + .split(",") + .map((t) => t.trim()) + .filter((t) => /^\d+[a-z]?$/.test(t)) + .map((t) => normalizeTaskId(t)); + + for (const toId of toIds) { + if (!dependencies[toId]) dependencies[toId] = []; + for (const fromId of fromIds) { + if (!dependencies[toId].includes(fromId)) { + dependencies[toId].push(fromId); + } + } + } + } + } + } + + // Format 1: Natural language "X depends on A, B, C" + // Supports optional markdown list prefix: "- 13 depends on 17, 18, 19" + // Also handles "also depends on": "- 08 also depends on 05, 06" + // The dep list char class includes lowercase letters so lettered IDs + // (e.g. "02b") don't truncate the capture. Per-id validation is + // done by the filter below, so trailing prose can't leak in. + const dependsMatch = line.match( + /^(?:\s*[-*]\s+)?(\d+[a-z]?)\s+(?:also\s+)?depends\s+on\s+([\d,\s a-z]+)/i, + ); + if (dependsMatch) { + const [, taskId, depsList] = dependsMatch; + const taskIdPadded = normalizeTaskId(taskId); + const depIds = depsList + .split(",") + .map((t) => t.trim()) + .filter((t) => /^\d+[a-z]?$/.test(t)) + .map((t) => normalizeTaskId(t)); + + if (!dependencies[taskIdPadded]) dependencies[taskIdPadded] = []; + for (const depId of depIds) { + if (!dependencies[taskIdPadded].includes(depId)) { + dependencies[taskIdPadded].push(depId); + } + } + } + + // Parse meta blocks for task configuration (timeout, etc.) + const metaMatch = line.match( + /^0?(\d+[a-z]?)\s+\[timeout\]\s*=?\s*(\d+)(?:m|min|s|ms)?/i, + ); + if (metaMatch) { + const [, taskId, value, unit] = metaMatch; + const task = tasks.find((t) => t.id === normalizeTaskId(taskId)); + if (task) { + task.timeoutMs = parseTimeoutValue(Number(value), unit); + } + } + + // Format 2: "X, Y, Z can be done in parallel (label)" + // "- 01, 02, 03, 04 can be done in parallel (Play Store prep)" + const parallelMatch = line.match( + /^(?:\s*[-*]\s+)?((?:0?\d+[a-z]?\s*,\s*)*0?\d+[a-z]?)\s+can\s+be\s+done\s+in\s+parallel(?:\s+\(([^)]+)\))?$/i, + ); + if (parallelMatch) { + const [, idsStr, label] = parallelMatch; + const taskIds = idsStr + .split(",") + .map((t) => t.trim()) + .filter((t) => /^\d+[a-z]?$/.test(t)) + .map((t) => normalizeTaskId(t)); + + if (taskIds.length > 0) { + parallelGroups.push({ + index: parallelGroups.length, + label: label ? label.trim() : undefined, + taskIds, + }); + } + } + + // Format 3: "A must be done before B, C" or "A, B must be done before C" + // "- 21 must be done before 22, 23, 24 (backend integration foundation)" + // "- 02, 03 must be done before 04" + const mustBeforeMatch = line.match( + /^(?:\s*[-*]\s+)?((?:0?\d+[a-z]?\s*,\s*)*0?\d+[a-z]?)\s+must\s+be\s+done\s+before\s+((?:0?\d+[a-z]?\s*,\s*)*0?\d+[a-z]?)(?:\s+\(([^)]+)\))?$/i, + ); + if (mustBeforeMatch) { + const [, fromIdsStr, toIdsStr] = mustBeforeMatch; + const fromIds = fromIdsStr + .split(",") + .map((t) => t.trim()) + .filter((t) => /^\d+[a-z]?$/.test(t)) + .map((t) => normalizeTaskId(t)); + const toIds = toIdsStr + .split(",") + .map((t) => t.trim()) + .filter((t) => /^\d+[a-z]?$/.test(t)) + .map((t) => normalizeTaskId(t)); + + // Each "to" task depends on ALL "from" tasks + for (const toId of toIds) { + if (!dependencies[toId]) dependencies[toId] = []; + for (const fromId of fromIds) { + if (!dependencies[toId].includes(fromId)) { + dependencies[toId].push(fromId); + } + } + } + } + + // Format 4: "X, Y, Z depend on A" or "X depends on A, B, C" + // "- 22, 23, 24 depend on 21" + // "- 05, 06 depend on 02, 03, 04" + // "- 08 also depends on 05, 06" ("also" is ignored) + // Strip optional "also" before matching + const cleanedLine = line.replace(/\balso\b/i, ""); + const dependOnMatch = cleanedLine.match( + /^(?:\s*[-*]\s+)?((?:0?\d+[a-z]?\s*,\s*)*0?\d+[a-z]?)\s+depend(?:s)?\s+on\s+((?:0?\d+[a-z]?\s*,\s*)*0?\d+[a-z]?)(?:\s+\(([^)]+)\))?$/i, + ); + if (dependOnMatch) { + const [, fromIdsStr, toIdsStr] = dependOnMatch; + const fromIds = fromIdsStr + .split(",") + .map((t) => t.trim()) + .filter((t) => /^\d+[a-z]?$/.test(t)) + .map((t) => normalizeTaskId(t)); + const toIds = toIdsStr + .split(",") + .map((t) => t.trim()) + .filter((t) => /^\d+[a-z]?$/.test(t)) + .map((t) => normalizeTaskId(t)); + + // Each "from" task depends on ALL "to" tasks + for (const fromId of fromIds) { + if (!dependencies[fromId]) dependencies[fromId] = []; + for (const toId of toIds) { + if (!dependencies[fromId].includes(toId)) { + dependencies[fromId].push(toId); + } + } + } + } + } + } + + // Save final phase if we were in one + if (currentPhase !== null) { + const phaseTaskIds = tasks + .filter((t) => t.phase === currentPhase) + .map((t) => t.id); + if (phaseTaskIds.length > 0) { + phases.push({ + number: currentPhase, + title: currentPhaseTitle, + taskIds: phaseTaskIds, + }); + } + } + + // Add implicit phase-boundary dependencies + // First task of each phase (except phase 1) depends on last task of previous phase + if (phases.length > 1) { + for (let i = 1; i < phases.length; i++) { + const prevPhase = phases[i - 1]; + const currPhase = phases[i]; + if (prevPhase.taskIds.length === 0 || currPhase.taskIds.length === 0) + continue; + + const lastTaskOfPrevPhase = + prevPhase.taskIds[prevPhase.taskIds.length - 1]; + const firstTaskOfCurrPhase = currPhase.taskIds[0]; + + // Add dependency if not already present + if (!dependencies[firstTaskOfCurrPhase]) { + dependencies[firstTaskOfCurrPhase] = []; + } + if (!dependencies[firstTaskOfCurrPhase].includes(lastTaskOfPrevPhase)) { + dependencies[firstTaskOfCurrPhase].push(lastTaskOfPrevPhase); + } + } + } + + // Extract exit criteria — detect both ## Exit Criteria and plain Exit criteria + const exitCriteria: string[] = []; + const exitCriteriaRe = /^(?:##\s+)?Exit\s+Criteria/i; + const exitIdx = lines.findIndex((l) => exitCriteriaRe.test(l)); + if (exitIdx >= 0) { + for (let i = exitIdx + 1; i < lines.length; i++) { + // Stop at any new section heading (##-style or plain) + if (/^##\s/.test(lines[i]) || isPlainSectionHeader(lines[i])) break; + const m = lines[i].match(/^-\s+(.+)$/); + if (m) exitCriteria.push(m[1].trim()); + } + } + + // Extract objective from top-level heading + const objectiveMatch = content.match(/^#\s+(.+)$/m); + const objective = objectiveMatch ? objectiveMatch[1].trim() : undefined; + + // Apply dependencies map to task.dependencies arrays + for (const task of tasks) { + if (dependencies[task.id]) { + task.dependencies = dependencies[task.id]; + } + } + + // Apply parallelGroup to tasks + for (const group of parallelGroups) { + for (const taskId of group.taskIds) { + const task = tasks.find((t) => t.id === taskId); + if (task) { + task.parallelGroup = group.index; + } + } + } + + return { + tasks, + dependencies, + parallelGroups: parallelGroups.length > 0 ? parallelGroups : undefined, + phases: phases.length > 0 ? phases : undefined, + sourcePath, + sourceDir, + exitCriteria, + objective, + }; +} + +// ─── Simple Checkbox Parser ────────────────────────────────────────────────── + +function parseSimpleCheckbox( + content: string, + sourcePath: string, + sourceDir: string, +): Project { + const tasks: Task[] = []; + const lines = content.split("\n"); + let idx = 0; + + for (const line of lines) { + const match = line.match(/^-+\s+\[(.)\]\s+(.+)$/); + if (match) { + const [, statusChar, title] = match; + const id = `${String(idx).padStart(2, "0")}`; + tasks.push({ + id, + title: title.trim(), + status: charToStatus(statusChar), + dependencies: [], + }); + idx++; + } + } + + return { tasks, dependencies: {}, sourcePath, sourceDir }; +} + +// ─── YAML Parser ───────────────────────────────────────────────────────────── + +function parseYaml( + content: string, + sourcePath: string, + sourceDir: string, +): Project { + const YAML = loadYaml(); + const doc = YAML.parse(content); + const tasks: Task[] = []; + + if (doc.tasks && Array.isArray(doc.tasks)) { + doc.tasks.forEach((t: any, idx: number) => { + tasks.push({ + id: t.id || `${String(idx).padStart(2, "0")}`, + title: t.title || t.name || `Task ${idx}`, + description: t.description, + file: t.file, + status: (t.status as Task["status"]) || "pending", + dependencies: t.depends_on || t.dependencies || [], + parallelGroup: t.parallel_group, + timeoutMs: parseTimeoutFromMeta(t.timeout), + index: idx, + }); + }); + } + + return { + tasks, + dependencies: doc.dependencies || {}, + sourcePath, + sourceDir, + exitCriteria: doc.exit_criteria || doc.exitCriteria, + objective: doc.objective, + }; +} + +// ─── Task Spec Reader ──────────────────────────────────────────────────────── + +/** + * Read the detailed task specification from a task file + */ +export function readTaskSpec(taskDir: string, taskFile: string): string { + const fullPath = path.resolve(taskDir, taskFile); + if (!fs.existsSync(fullPath)) return ""; + return fs.readFileSync(fullPath, "utf-8"); +} + +// ─── Task File Updater ─────────────────────────────────────────────────────── + +/** + * Update task status in the source file (markdown or YAML). + * + * Handles three formats: + * 1. Fio numbered format: `- [ ] 01 – Title` — matches by task number in the file + * 2. Simple checkbox: `- [ ] Title` — matches by checkbox position (index) + * 3. YAML: uses `yaml` library to parse, update, and stringify + */ +export function updateTaskInFile( + filePath: string, + taskId: string, + status: Task["status"], +): void { + const ext = path.extname(filePath).toLowerCase(); + + // Handle YAML format + if (ext === ".yaml" || ext === ".yml") { + updateTaskInYaml(filePath, taskId, status); + return; + } + + let content = fs.readFileSync(filePath, "utf-8"); + const char = statusToChar(status); + + // Strategy 1: Fio numbered format — match by explicit task ID in the file. + // For pure-digit IDs, also try the parsed numeric form (parity with the + // pre-lettered behavior). Lettered IDs ("02b", "02c") only have one valid + // form — the parseInt fallback would silently drop the letter suffix and + // create false-positive partial matches, so we skip it for them. + const idPatterns = new Set([escapeRegex(taskId)]); + if (!taskId.startsWith("0") && /^\d+$/.test(taskId)) { + const rawId = parseInt(taskId, 10).toString(); + idPatterns.add(escapeRegex(rawId)); + } + + for (const idPattern of idPatterns) { + const fioRegex = new RegExp( + `(^-\\s+\\[)(.)(\\]\\s+${idPattern}\\s*[—–:-])`, + "m", + ); + const match = content.match(fioRegex); + if (match) { + content = content.replace(fioRegex, `$1${char}$3`); + fs.writeFileSync(filePath, content, "utf-8"); + return; + } + } + + // Strategy 2: Simple checkbox by position (task IDs are zero-padded indices) + const targetIndex = parseInt(taskId, 10); + if (!isNaN(targetIndex)) { + const lines = content.split("\n"); + let checkboxIdx = 0; + for (let i = 0; i < lines.length; i++) { + const m = lines[i].match(/^(\s*-+\s+\[)(.)(\].*)$/); + if (m) { + if (checkboxIdx === targetIndex) { + lines[i] = m[1] + char + m[3]; + fs.writeFileSync(filePath, lines.join("\n"), "utf-8"); + return; + } + checkboxIdx++; + } + } + } +} + +/** + * Update task status in a YAML task file using the yaml library's + * Document API, which preserves comments and formatting. + * + * Matches by explicit `id` field first, then falls back to + * position-based matching (for files without explicit IDs). + */ +function updateTaskInYaml( + filePath: string, + taskId: string, + status: Task["status"], +): void { + const YAML = loadYaml(); + const content = fs.readFileSync(filePath, "utf-8"); + const doc = YAML.parseDocument(content); + const tasks = doc.get("tasks"); + if (!tasks || !YAML.isSeq(tasks)) return; + + // Build alternate ID forms for matching. For lettered IDs ("02b"), the + // verbatim form is the only valid pattern — parseInt would drop the suffix. + const idVariants: string[] = [taskId]; + if (/^\d+$/.test(taskId)) { + idVariants.push(parseInt(taskId, 10).toString()); + } + + // Strategy 1: Match by explicit id field + for (const item of tasks.items) { + if (!YAML.isMap(item)) continue; + const idVal = item.get("id"); + if (idVal === undefined || idVal === null) continue; + const idStr = String(idVal); + if (idVariants.includes(idStr)) { + item.set("status", status); + fs.writeFileSync(filePath, String(doc), "utf-8"); + return; + } + } + + // Strategy 2: Fall back to position-based matching + // (for YAML files without explicit id fields) + const targetIndex = parseInt(taskId, 10); + if (!isNaN(targetIndex) && targetIndex < tasks.items.length) { + const item = tasks.items[targetIndex]; + if (YAML.isMap(item)) { + item.set("status", status); + fs.writeFileSync(filePath, String(doc), "utf-8"); + } + } +} + +// ─── Auto-Detect Dependencies ──────────────────────────────────────────────── + +/** + * Auto-detect dependencies by analyzing task file references + */ +export function autoDetectDependencies(project: Project): Project { + const tasks = project.tasks.map((t) => ({ + ...t, + dependencies: [...t.dependencies], + })); + const taskFiles = new Map( + tasks + .filter((t) => t.file) + .map((t) => [path.resolve(project.sourceDir, t.file!), t]), + ); + + for (const [filePath, task] of taskFiles) { + if (!fs.existsSync(filePath)) continue; + const content = fs.readFileSync(filePath, "utf-8"); + + // Check if this task's file references another task's file + for (const [file, refTask] of taskFiles) { + if (refTask.id === task.id) continue; + if (content.includes(file) || content.includes(refTask.title)) { + if (!task.dependencies.includes(refTask.id)) { + task.dependencies.push(refTask.id); + } + } + } + } + + const dependencies: Record = {}; + for (const task of tasks) { + if (task.dependencies.length > 0) { + dependencies[task.id] = task.dependencies; + } + } + + return { ...project, tasks, dependencies }; +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +// ─── Timeout Parsing ──────────────────────────────────────────────────────── + +/** + * Parse timeout from a task line (e.g., "timeout: 15m" or "# timeout=30s") + */ +function parseTimeoutFromLine(line: string): number | undefined { + // Match patterns like "timeout: 15m", "# timeout=30s", "timeout: 5min" + const match = line.match(/(?:timeout|timelimit)[\s:=]+(\d+)(?:m|min|s|ms)?/i); + if (match) { + return parseTimeoutValue(Number(match[1]), match[2]); + } + return undefined; +} + +/** + * Parse a timeout value with unit suffix + */ +function parseTimeoutValue(value: number, unit?: string): number { + const u = (unit || "m").toLowerCase(); + switch (u) { + case "ms": + return value; + case "s": + return value * 1000; + case "m": + case "min": + return value * 60 * 1000; + default: + return value * 60 * 1000; // default to minutes + } +} + +/** + * Parse timeout from YAML meta field (string or number) + * Supports: "15m", "30s", "5min", 15 (minutes), 900000 (ms) + */ +function parseTimeoutFromMeta( + timeout: string | number | undefined, +): number | undefined { + if (timeout === undefined) return undefined; + + if (typeof timeout === "number") { + // Assume minutes if < 1000, milliseconds if >= 1000 + return timeout < 1000 ? timeout * 60 * 1000 : timeout; + } + + const match = timeout.match(/^(\d+)(ms|s|m|min)?$/i); + if (match) { + return parseTimeoutValue(Number(match[1]), match[2]); + } + + return undefined; +} + +/** + * Normalize a task ID: zero-pad the digit portion to 2 chars, preserve any + * single lowercase letter suffix. Idempotent on already-normalized IDs. + * + * "1" → "01" + * "2" → "02" + * "2b" → "02b" + * "02b" → "02b" + * "10" → "10" + * "10b" → "10b" + * + * Pass-through for IDs that don't match the expected shape (defensive — the + * upstream regexes restrict matches, but a stray value should not be silently + * re-shaped). + */ +function normalizeTaskId(id: string): string { + const match = id.match(/^(\d+)([a-z])?$/); + if (!match) return id; + const [, digits, letter] = match; + return digits.padStart(2, "0") + (letter ?? ""); +} + +function charToStatus(char: string): Task["status"] { + switch (char) { + case " ": + return "pending"; + case "~": + return "in_progress"; + case "x": + return "completed"; + case "!": + return "failed"; + case "-": + return "skipped"; + default: + return "pending"; + } +} + +function statusToChar(status: Task["status"]): string { + switch (status) { + case "pending": + return " "; + case "in_progress": + return "~"; + case "completed": + return "x"; + case "failed": + return "!"; + case "skipped": + return "-"; + } +} + +function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} diff --git a/src/progress.ts b/src/progress.ts new file mode 100644 index 0000000..804fc6d --- /dev/null +++ b/src/progress.ts @@ -0,0 +1,327 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import type { + ProgressState, + PRDProgress, + Task, + Reflection, + ToolUsage, + ReviewResult, +} from "./types"; +import { ensureDir } from "./utils"; + +/** + * Derive a stable PRD key from a source path relative to the project dir. + * e.g., "tasks/feature-x/README.md" → "tasks-feature-x-README" + */ +export function derivePRDKey(projectDir: string, sourcePath: string): string { + const rel = path.relative(projectDir, sourcePath); + return rel + .replace(/[^a-zA-Z0-9_-]/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); +} + +/** + * Manages persistent progress state for a ralph execution. + * State is stored as JSON in .ralpi/progress.json. + * Supports multiple PRDs in progress simultaneously via the `prds` field. + * Falls back to legacy flat format for backward compatibility. + */ +export class ProgressTracker { + private statePath: string; + private state: ProgressState; + private prdKey: string; + + constructor(projectDir: string, sourcePath: string, prdKey?: string) { + const stateDir = path.join(projectDir, ".ralpi"); + ensureDir(stateDir); + this.statePath = path.join(stateDir, "progress.json"); + this.prdKey = prdKey ?? derivePRDKey(projectDir, sourcePath); + this.state = this.loadOrCreate(sourcePath); + } + + /** Load existing state or create a fresh one */ + private loadOrCreate(sourcePathHint: string): ProgressState { + if (fs.existsSync(this.statePath)) { + try { + const raw = fs.readFileSync(this.statePath, "utf-8"); + const parsed = JSON.parse(raw) as ProgressState; + + // Multi-PRD mode: check if we have a PRD entry + if (parsed.prds?.[this.prdKey]) { + // Found PRD entry — use it, but keep legacy fields for compat + return parsed; + } + + // Legacy flat mode: check if the source path matches + if (path.resolve(parsed.sourcePath) === path.resolve(sourcePathHint)) { + // Migrate legacy state to PRD mode + parsed.prds = { + [this.prdKey]: { + sourcePath: parsed.sourcePath, + tasks: parsed.tasks, + startedAt: parsed.startedAt, + lastUpdatedAt: parsed.lastUpdatedAt, + paused: parsed.paused, + }, + }; + return parsed; + } + + // Different PRD — create new entry alongside existing ones + if (parsed.prds) { + parsed.prds[this.prdKey] = this.freshPRD(sourcePathHint); + return parsed; + } + + // Legacy flat state exists but for a different source — promote it to PRD mode + const legacyKey = derivePRDKey( + path.dirname(this.statePath), + parsed.sourcePath, + ); + parsed.prds = { + [legacyKey]: { + sourcePath: parsed.sourcePath, + tasks: parsed.tasks, + startedAt: parsed.startedAt, + lastUpdatedAt: parsed.lastUpdatedAt, + paused: parsed.paused, + }, + [this.prdKey]: this.freshPRD(sourcePathHint), + }; + return parsed; + } catch { + // Fall through to create new + } + } + + return this.freshState(sourcePathHint); + } + + private freshPRD(sourcePath: string): PRDProgress { + return { + sourcePath, + tasks: {}, + startedAt: new Date().toISOString(), + lastUpdatedAt: new Date().toISOString(), + paused: false, + }; + } + + private freshState(sourcePath: string): ProgressState { + return { + sourcePath, + tasks: {}, + startedAt: new Date().toISOString(), + lastUpdatedAt: new Date().toISOString(), + paused: false, + prds: { + [this.prdKey]: { + sourcePath, + tasks: {}, + startedAt: new Date().toISOString(), + lastUpdatedAt: new Date().toISOString(), + paused: false, + }, + }, + }; + } + + /** Get the PRD-scoped progress entry */ + private getPRD(): PRDProgress { + if (!this.state.prds) { + // Should not happen after loadOrCreate, but guard anyway + this.state.prds = { [this.prdKey]: this.freshPRD(this.state.sourcePath) }; + } + if (!this.state.prds[this.prdKey]) { + this.state.prds[this.prdKey] = this.freshPRD(this.state.sourcePath); + } + return this.state.prds[this.prdKey]; + } + + /** Save current state to disk */ + save(): void { + // Merge into the freshest on-disk state instead of writing the + // construction-time snapshot verbatim. Each ProgressTracker instance + // (one per PRD loop) snapshots the WHOLE state at construction; when + // two loops run concurrently in one project, saving a stale snapshot + // would silently revert the OTHER loop's task status changes — tasks + // get wrongly written back to "pending" while their worktrees carry + // real work, stranding it on the next resume. + let disk: ProgressState | null = null; + try { + if (fs.existsSync(this.statePath)) { + const raw = fs.readFileSync(this.statePath, "utf-8"); + disk = JSON.parse(raw) as ProgressState; + } + } catch { + disk = null; + } + if (disk && disk.prds) { + // Keep THIS tracker's in-memory PRD (its own tasks are the source + // of truth — all status mutations happened on it), but adopt the + // on-disk entries for OTHER PRDs instead of writing the stale + // construction-time snapshot over them. + const mine = this.getPRD(); + this.state = disk; + this.state.prds ??= {}; + this.state.prds[this.prdKey] = mine; + } + + const prd = this.getPRD(); + prd.lastUpdatedAt = new Date().toISOString(); + // Sync legacy flat fields with current PRD for backward compat + this.state.sourcePath = prd.sourcePath; + this.state.tasks = prd.tasks; + this.state.startedAt = prd.startedAt; + this.state.lastUpdatedAt = prd.lastUpdatedAt; + this.state.paused = prd.paused; + fs.writeFileSync( + this.statePath, + JSON.stringify(this.state, null, 2), + "utf-8", + ); + } + + /** Mark a task as in progress */ + markInProgress(taskId: string): void { + const prd = this.getPRD(); + this.ensureTask(prd, taskId); + prd.tasks[taskId].status = "in_progress"; + prd.tasks[taskId].startedAt = new Date().toISOString(); + this.save(); + } + + /** Mark a task as completed */ + markCompleted( + taskId: string, + durationMs: number, + reflection?: Reflection, + toolUsage?: ToolUsage, + outputPreview?: string, + commitMessages?: string[], + commitSummary?: string, + review?: ReviewResult, + reviewRetries?: number, + ): void { + const prd = this.getPRD(); + this.ensureTask(prd, taskId); + prd.tasks[taskId].status = "completed"; + prd.tasks[taskId].completedAt = new Date().toISOString(); + prd.tasks[taskId].durationMs = durationMs; + if (reflection) prd.tasks[taskId].reflection = reflection; + if (toolUsage) prd.tasks[taskId].toolUsage = toolUsage; + if (outputPreview) prd.tasks[taskId].outputPreview = outputPreview; + if (commitMessages) prd.tasks[taskId].commitMessages = commitMessages; + if (commitSummary) prd.tasks[taskId].commitSummary = commitSummary; + if (review) prd.tasks[taskId].review = review; + if (reviewRetries !== undefined) + prd.tasks[taskId].reviewRetries = reviewRetries; + this.save(); + } + + /** Mark a task as failed */ + markFailed(taskId: string, error: string): void { + const prd = this.getPRD(); + this.ensureTask(prd, taskId); + prd.tasks[taskId].status = "failed"; + prd.tasks[taskId].error = error; + this.save(); + } + + /** Get task status */ + getTaskStatus(taskId: string): Task["status"] { + const prd = this.getPRD(); + return prd.tasks[taskId]?.status ?? "pending"; + } + + /** Get IDs of all completed tasks */ + getCompletedTaskIds(): string[] { + const prd = this.getPRD(); + return Object.entries(prd.tasks) + .filter(([, info]) => info.status === "completed") + .map(([id]) => id); + } + + /** Get IDs of all failed tasks */ + getFailedTaskIds(): string[] { + const prd = this.getPRD(); + return Object.entries(prd.tasks) + .filter(([, info]) => info.status === "failed") + .map(([id]) => id); + } + + /** Get all reflections from completed tasks */ + getAllReflections(): Reflection[] { + const prd = this.getPRD(); + const reflections: Reflection[] = []; + for (const info of Object.values(prd.tasks)) { + if (info.reflection) reflections.push(info.reflection); + } + return reflections; + } + + /** Get reflections for specific dependency tasks */ + getDependencyReflections(depIds: string[]): Reflection[] { + const prd = this.getPRD(); + return depIds + .map((id) => prd.tasks[id]?.reflection) + .filter((r): r is Reflection => r !== undefined); + } + + /** Set paused state */ + setPaused(paused: boolean): void { + const prd = this.getPRD(); + prd.paused = paused; + this.save(); + } + + /** Reset all `in_progress` tasks back to `pending`. + * + * Used after a session reload: in-process agent sessions die with the + * parent session, so any task left `in_progress` is actually stalled. + * Resetting ensures the DAG re-schedules it on the next resume. Returns + * the IDs that were reset. */ + resetInProgressToPending(): string[] { + const prd = this.getPRD(); + const reset: string[] = []; + for (const [id, info] of Object.entries(prd.tasks)) { + if (info.status === "in_progress") { + info.status = "pending"; + delete info.startedAt; + reset.push(id); + } + } + if (reset.length > 0) this.save(); + return reset; + } + + /** Get the raw PRD state (for status display) */ + getState(): PRDProgress { + return this.getPRD(); + } + + /** Get all PRDs (for multi-PRD status display) */ + getAllPRDs(): Record { + return this.state.prds ?? {}; + } + + /** Get the PRD key for this tracker */ + getKey(): string { + return this.prdKey; + } + + /** Reset all progress for this PRD */ + reset(): void { + const prd = this.getPRD(); + Object.assign(prd, this.freshPRD(prd.sourcePath)); + this.save(); + } + + private ensureTask(prd: PRDProgress, taskId: string): void { + if (!prd.tasks[taskId]) { + prd.tasks[taskId] = { status: "pending" }; + } + } +} diff --git a/src/prompts.ts b/src/prompts.ts new file mode 100644 index 0000000..79088a8 --- /dev/null +++ b/src/prompts.ts @@ -0,0 +1,642 @@ +import type { Task, Project, Reflection, ReviewResult } from "./types"; +import { readTaskSpec } from "./parser"; +import { + parseDiff, + filterNoise, + type DiffSummary, + type DiffOptions, +} from "./diff"; + +/** Maximum bytes of an inlined review diff before we stop inlining it and + * instead list the changed files + tell the model to `read` them. + * Diffs larger than this are never byte-truncated into a review prompt — + * truncation loses the middle of a large diff, so the file-list + read + * instruction is strictly better. + * + * ~50 KB ≈ 12.5K tokens — comfortably fits even on models with a 128K + * context window once system-prompt overhead is accounted for. */ +export const MAX_DIFF_BYTES = 50_000; + +/** Max included files before an oversized diff is replaced by a read + * instruction rather than inlined. */ +const MAX_REVIEW_FILES = 20; + +/** Optional knobs for the review prompt builders. */ +export interface ReviewPromptOptions { + /** Extra context injected into the prompt (config.prompts.projectContext). */ + projectContext?: string; + /** Per-review custom focus/instructions (config.prompts.reviewFocus). */ + focus?: string; + /** Noise-filter overrides (config.review.*). */ + diffOptions?: DiffOptions; +} + +// ─── Task Prompt ───────────────────────────────────────────────────────────── + +/** + * Build the prompt for a single task execution. + * Injects task details, dependency reflections, and project context. + */ +export function buildTaskPrompt( + task: Task, + project: Project, + depReflections: Reflection[], + projectContext?: string, + /** Review feedback from a rejected review — injected when re-executing + * a task in review-gated mode so the agent knows what to fix. */ + reviewFeedback?: ReviewResult, +): string { + const parts: string[] = []; + + // ── Header ── + + parts.push(`# Task ${task.id}: ${task.title}`); + parts.push(""); + + // ── Project Objective ── + + if (project.objective) { + parts.push("## Project Objective"); + parts.push(project.objective); + parts.push(""); + } + + // ── Exit Criteria ── + + if (project.exitCriteria && project.exitCriteria.length > 0) { + parts.push("## Exit Criteria"); + for (const criterion of project.exitCriteria) { + parts.push(`- ${criterion}`); + } + parts.push(""); + } + + // ── Task Description ── + + if (task.description) { + parts.push("## Description"); + parts.push(task.description); + parts.push(""); + } + + // ── Task Specification ── + + if (task.file) { + const spec = readTaskSpec(project.sourceDir, task.file); + if (spec) { + parts.push("## Task Specification"); + parts.push(`Full details from \`${task.file}\`:`); + parts.push(""); + parts.push(spec); + parts.push(""); + } + } + + // ── Dependencies ── + + if (task.dependencies && task.dependencies.length > 0) { + parts.push("## Dependencies"); + parts.push(`This task depends on: ${task.dependencies.join(", ")}`); + parts.push(""); + } + + // ── Dependency Reflections ── + + if (depReflections.length > 0) { + parts.push("## Completed Dependency Reflections"); + parts.push( + "The following tasks have been completed. Use their reflections for context:", + ); + parts.push(""); + + for (const ref of depReflections) { + parts.push(`### Task ${ref.taskId}: ${ref.title}`); + parts.push(`**Summary:** ${ref.summary}`); + + if (ref.keyLearnings && ref.keyLearnings.length > 0) { + parts.push("**Key Learnings:**"); + for (const learning of ref.keyLearnings) { + parts.push(`- ${learning}`); + } + } + + if (ref.filesChanged && ref.filesChanged.length > 0) { + parts.push(`**Files Changed:** ${ref.filesChanged.join(", ")}`); + } + + if (ref.blockers && ref.blockers.length > 0) { + parts.push(`**Known Issues:** ${ref.blockers.join("; ")}`); + } + + parts.push(""); + } + } + + // ── Project Context ── + + if (projectContext) { + parts.push("## Additional Context"); + parts.push(projectContext); + parts.push(""); + } + + // ── Previous Review Feedback (re-execution only) ── + + if (reviewFeedback) { + parts.push("## Previous Review Feedback — FIX REQUIRED"); + parts.push( + "A review agent examined your previous attempt and rejected it.", + ); + parts.push(`Verdict: **${reviewFeedback.verdict.toUpperCase()}**`); + parts.push(`Summary: ${reviewFeedback.summary}`); + parts.push(""); + if (reviewFeedback.findings.length > 0) { + parts.push("You MUST address these findings:"); + for (const finding of reviewFeedback.findings) { + const loc = finding.file + ? finding.line + ? ` (${finding.file}:${finding.line})` + : ` (${finding.file})` + : ""; + parts.push(`- [${finding.severity}]${loc} ${finding.message}`); + } + parts.push(""); + } + parts.push("Fix every issue above. Do not re-introduce the same problems."); + parts.push(""); + } + + // ── Reflection Instructions ── + + parts.push("## REFLECTION (REQUIRED)"); + parts.push( + "When the task is COMPLETE, end your response with a reflection section.", + ); + parts.push("Use EXACTLY this format at the END of your response:"); + parts.push(""); + parts.push("```"); + parts.push("## REFLECTION"); + parts.push("SUMMARY: [1-2 sentence description of what was accomplished]"); + parts.push("FILES: [comma-separated list of files created or modified]"); + parts.push("LEARNINGS:"); + parts.push("- [key decision, pattern, or architectural choice]"); + parts.push("- [important API or interface details]"); + parts.push("- [anything downstream tasks need to know]"); + parts.push("BLOCKERS: [any unresolved issues, or 'none']"); + parts.push("```"); + parts.push(""); + parts.push( + "Also use the `memory` tool to save important learnings that will", + ); + parts.push( + "be useful across future sessions (architecture decisions, API patterns, etc.)", + ); + + return parts.join("\n"); +} + +// ─── Review Prompt ─────────────────────────────────────────────────────────── + +/** + * Build the prompt for the auto-review agent. + * Includes the task description and the latest commit diff so the reviewer + * can assess whether the commit fulfills the task requirements. + */ +export function buildReviewPrompt( + task: Task, + project: Project, + commitHash: string, + commitSubject: string, + commitDiff: string, + opts: ReviewPromptOptions = {}, +): string { + const parts: string[] = []; + + parts.push(`# Code Review: Task ${task.id}: ${task.title}`); + parts.push(""); + + // ── Task Description ── + + parts.push("## Task Description"); + if (task.description) { + parts.push(task.description); + } else { + parts.push(task.title); + } + parts.push(""); + + // ── Task Specification ── + + if (task.file) { + const spec = readTaskSpec(project.sourceDir, task.file); + if (spec) { + parts.push("## Task Specification"); + parts.push(`Full details from \`${task.file}\`:`); + parts.push(""); + parts.push(spec); + parts.push(""); + } + } + + // ── Commit Under Review ── + + parts.push("## Commit Under Review"); + parts.push(`Commit: ${commitHash} — ${commitSubject}`); + parts.push(""); + + // ── Changed-Files Summary + Exclusions (noise-filtered scope) ── + + const summary = parseDiff(commitDiff, opts.diffOptions); + const filtered = filterNoise(commitDiff, opts.diffOptions); + parts.push(buildFileSummaryTable(summary)); + const excluded = renderExcludedFiles(summary); + if (excluded) parts.push(excluded); + parts.push(""); + + // ── Diff (inline, or file-list + read instruction when oversized) ── + + parts.push(renderDiffSection(summary, filtered, "### Diff")); + parts.push(""); + + // ── Custom Review Focus ── + + if (opts.focus) { + parts.push("## Custom Review Focus"); + parts.push(opts.focus); + parts.push(""); + } + + // ── Project Context ── + + if (opts.projectContext) { + parts.push("## Additional Context"); + parts.push(opts.projectContext); + parts.push(""); + } + + // ── Review Instructions ── + + parts.push("## Review Instructions"); + parts.push( + "Review the changes above against the task description. Check for:", + ); + parts.push(...reviewInstructions()); + parts.push(""); + parts.push( + "Provide a concise review with any issues found. Your free-form prose", + ); + parts.push("precedes the structured verdict block below."); + parts.push(...reviewVerdictBlock()); + + return parts.join("\n"); +} + +// ─── Uncommitted-Changes Review Prompt ────────────────────────────────────── + +/** + * Build a review prompt for uncommitted working-tree changes (pre-commit). + * Used in review-gated mode: the review runs BEFORE committing so a rejected + * review triggers a re-execution instead of a bad commit. + */ +export function buildReviewPromptUncommitted( + task: Task, + project: Project, + status: string, + diff: string, + opts: ReviewPromptOptions = {}, +): string { + const parts: string[] = []; + + parts.push(`# Code Review (pre-commit): Task ${task.id}: ${task.title}`); + parts.push(""); + + // ── Task Description ── + + parts.push("## Task Description"); + if (task.description) { + parts.push(task.description); + } else { + parts.push(task.title); + } + parts.push(""); + + // ── Task Specification ── + + if (task.file) { + const spec = readTaskSpec(project.sourceDir, task.file); + if (spec) { + parts.push("## Task Specification"); + parts.push(`Full details from \`${task.file}\`:`); + parts.push(""); + parts.push(spec); + parts.push(""); + } + } + + // ── Uncommitted Changes Under Review ── + + parts.push("## Uncommitted Changes Under Review"); + parts.push( + "Review the working-tree changes below against the task description.", + ); + parts.push(""); + parts.push("### Current Changes (git status --porcelain)"); + parts.push("```text"); + parts.push(status || "(no status output)"); + parts.push("```"); + parts.push(""); + + // ── Changed-Files Summary + Exclusions (noise-filtered scope) ── + + const summary = parseDiff(diff, opts.diffOptions); + const filtered = filterNoise(diff, opts.diffOptions); + parts.push(buildFileSummaryTable(summary)); + const excluded = renderExcludedFiles(summary); + if (excluded) parts.push(excluded); + parts.push(""); + + // ── Diff (inline, or file-list + read instruction when oversized) ── + + parts.push( + renderDiffSection(summary, filtered, "### Current Tracked Diff (git diff)"), + ); + parts.push(""); + + // ── Custom Review Focus ── + + if (opts.focus) { + parts.push("## Custom Review Focus"); + parts.push(opts.focus); + parts.push(""); + } + + // ── Project Context ── + + if (opts.projectContext) { + parts.push("## Additional Context"); + parts.push(opts.projectContext); + parts.push(""); + } + + // ── Review Instructions ── + + parts.push("## Review Instructions"); + parts.push( + "Review the uncommitted changes above against the task description. Check for:", + ); + parts.push(...reviewInstructions()); + parts.push(""); + parts.push( + "Provide a concise review with any issues found. Your free-form prose", + ); + parts.push("precedes the structured verdict block below."); + parts.push(...reviewVerdictBlock()); + + return parts.join("\n"); +} + +// ─── Shared Review Prompt Helpers ─────────────────────────────────────────── + +/** Whether an oversized/wide diff should be replaced by a file-list + read + * instruction instead of being inlined. Thresholds: cleaned diff over + * MAX_DIFF_BYTES, or more than MAX_REVIEW_FILES included files. */ +function shouldSkipInline(summary: DiffSummary, filteredLength: number): boolean { + return ( + filteredLength > MAX_DIFF_BYTES || summary.files.length > MAX_REVIEW_FILES + ); +} + +/** + * Render a per-file +/− summary Markdown table (with type column and a total + * line) from a parsed diff. Handles the empty/all-noise diff gracefully — an + * empty table with zero totals, no crash. + */ +function buildFileSummaryTable(summary: DiffSummary): string { + const lines: string[] = []; + lines.push("### Changed Files"); + lines.push(""); + lines.push("| File | +/− | Type |"); + lines.push("|------|-----|------|"); + if (summary.files.length === 0) { + lines.push("| _(no included changes)_ | — | — |"); + } else { + for (const f of summary.files) { + lines.push( + `| \`${f.path}\` | +${f.linesAdded}/-${f.linesRemoved} | ${f.ext || "—"} |`, + ); + } + } + lines.push(`| **Total** | **+${summary.totalAdded}/-${summary.totalRemoved}** | |`); + return lines.join("\n"); +} + +/** + * Render the `### Excluded Files (n)` bullet list (path, +/− counts, reason). + * Returns an empty string when there are no exclusions so callers omit the + * section entirely (no empty heading). + */ +function renderExcludedFiles(summary: DiffSummary): string { + if (summary.excluded.length === 0) return ""; + const lines: string[] = []; + lines.push(`### Excluded Files (${summary.excluded.length})`); + lines.push(""); + for (const f of summary.excluded) { + lines.push( + `- \`${f.path}\` (+${f.linesAdded}/-${f.linesRemoved}) — ${f.reason}`, + ); + } + return lines.join("\n"); +} + +/** + * Render the diff section of a review prompt. Under the threshold, inline the + * noise-filtered diff. Over the threshold (size or file count), emit a + * file-list + read-instruction notice and never byte-truncate the diff. + */ +function renderDiffSection( + summary: DiffSummary, + filtered: string, + heading: string, +): string { + if (shouldSkipInline(summary, filtered.length)) { + return `${heading} — _Diff too large (${filtered.length.toLocaleString()} chars, ${summary.files.length} files). Use \`read\` to inspect the changed files._`; + } + const lines: string[] = []; + lines.push(heading); + lines.push("```diff"); + lines.push(filtered || "(no included changes)"); + lines.push("```"); + return lines.join("\n"); +} + +function reviewInstructions(): string[] { + return [ + "- **Correctness**: Does the implementation fulfill the task requirements?", + "- **Completeness**: Are all aspects of the task addressed?", + "- **Code quality**: Are there obvious bugs, anti-patterns, or issues?", + "- **Missing changes**: Are there files that should have been modified but weren't?", + ]; +} + +function reviewVerdictBlock(): string[] { + return [ + "## REVIEW VERDICT (REQUIRED)", + "End your response with a verdict block in EXACTLY this format:", + "", + "```", + "## REVIEW VERDICT", + "VERDICT: [pass | warn | fail]", + "SUMMARY: [1-2 sentence overall assessment]", + "FINDINGS:", + "- [blocker] file:line description (use severity: blocker|warning|nit|info; `critical` is accepted as a blocker synonym)", + "- [warning] file:line description", + "```", + "", + "Verdict guidance:", + "- **pass**: the implementation fully satisfies the task requirements; no", + " action needed. Use an empty FINDINGS section (just the header).", + "- **warn**: the implementation is acceptable but has minor issues worth fixing", + " in a follow-up; not blocking.", + "- **fail**: the implementation does not satisfy the task, or has serious bugs", + " that must be fixed before proceeding.", + "", + "Each FINDINGS line uses the form `- [severity] [file:line] message`.", + "The `file:line` part is optional. Severity must be one of:", + "`blocker`, `warning`, `nit`, `info`. The `critical` token is accepted", + "and treated as `blocker`.", + ]; +} + +/** + * Build the prompt for a dry-run / plan display + */ +export function buildPlanPrompt(project: Project): string { + const lines: string[] = []; + + lines.push("# Project Plan"); + lines.push(""); + + if (project.objective) { + lines.push("## Objective"); + lines.push(project.objective); + lines.push(""); + } + + lines.push("## Tasks"); + for (const task of project.tasks) { + const deps = + task.dependencies.length > 0 + ? ` (depends on: ${task.dependencies.join(", ")})` + : ""; + lines.push(`- [ ] ${task.id}: ${task.title}${deps}`); + } + lines.push(""); + + if (project.exitCriteria && project.exitCriteria.length > 0) { + lines.push("## Exit Criteria"); + for (const criterion of project.exitCriteria) { + lines.push(`- ${criterion}`); + } + lines.push(""); + } + + return lines.join("\n"); +} + +// ─── Conflict Resolution Prompt ───────────────────────────────────────────── + +/** + * Build the prompt for a conflict-resolution agent session. + * + * The main repo is in a merge-conflict state (from `reattemptMerge`). The + * agent must resolve all conflict markers in the conflicted files, stage the + * resolved files, and commit to complete the merge. + */ +export function buildConflictResolutionPrompt( + task: Task, + project: Project, + conflicts: string[], + branch: string, + projectContext?: string, +): string { + const parts: string[] = []; + + parts.push(`# Merge Conflict Resolution: Task ${task.id}: ${task.title}`); + parts.push(""); + parts.push( + `A merge of branch \`${branch}\` into the current branch produced conflicts.`, + ); + parts.push("You must resolve all conflicts and complete the merge."); + parts.push(""); + + // ── Task Context ── + + parts.push("## Task Description"); + if (task.description) { + parts.push(task.description); + } else { + parts.push(task.title); + } + parts.push(""); + + // ── Task Specification ── + + if (task.file) { + const spec = readTaskSpec(project.sourceDir, task.file); + if (spec) { + parts.push("## Task Specification"); + parts.push(`Full details from \`${task.file}\`:`); + parts.push(""); + parts.push(spec); + parts.push(""); + } + } + + // ── Conflicted Files ── + + parts.push("## Conflicted Files"); + parts.push( + "The following files have unresolved merge conflicts (conflict markers `<<<<<<<`, `=======`, `>>>>>>>`):", + ); + parts.push(""); + for (const f of conflicts) { + parts.push(`- \`${f}\``); + } + parts.push(""); + + // ── Project Context ── + + if (projectContext) { + parts.push("## Additional Context"); + parts.push(projectContext); + parts.push(""); + } + + // ── Resolution Instructions ── + + parts.push("## Resolution Instructions"); + parts.push( + "1. Read each conflicted file to understand both sides of the conflict.", + ); + parts.push( + "2. Edit each file to remove all conflict markers (`<<<<<<<`, `=======`, `>>>>>>>`).", + ); + parts.push( + " Keep the correct changes from both sides — do NOT blindly pick one side.", + ); + parts.push( + " The goal is a correct union of both the task's changes and the main branch.", + ); + parts.push( + "3. After resolving all conflicts, stage the resolved files with `git add `.", + ); + parts.push( + "4. Complete the merge with `git commit` — use the default merge message.", + ); + parts.push(""); + parts.push( + "Resolve ALL conflicts. Do NOT abort the merge. Do NOT leave any conflict markers.", + ); + + return parts.join("\n"); +} diff --git a/src/reflection.ts b/src/reflection.ts new file mode 100644 index 0000000..e715f94 --- /dev/null +++ b/src/reflection.ts @@ -0,0 +1,128 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import type { Reflection } from "./types"; +import { REFLECTION_PATTERN } from "./constants"; +import { ensureDir, writeFileSafe } from "./utils"; + +// ─── Extract Reflection ────────────────────────────────────────────────────── + +/** + * Extract a reflection block from pi's output text + */ +export function extractReflection( + output: string, + taskId: string, + title: string, +): Reflection | null { + const match = output.match(REFLECTION_PATTERN); + if (!match) return null; + + const block = match[1]; + const summary = extractField(block, "SUMMARY"); + const files = extractField(block, "FILES"); + const learnings = extractList(block, "LEARNINGS"); + const blockersRaw = extractField(block, "BLOCKERS"); + + const blockers = + blockersRaw && blockersRaw.toLowerCase() !== "none" + ? blockersRaw.split(",").map(b => b.trim()).filter(Boolean) + : undefined; + + return { + taskId, + title, + summary: summary || "Task completed", + keyLearnings: learnings || [], + filesChanged: files + ? files.split(",").map(f => f.trim()).filter(Boolean) + : [], + blockers, + timestamp: new Date().toISOString(), + }; +} + +function extractField(block: string, field: string): string | null { + const regex = new RegExp(`${field}:\\s*(.+?)$`, "im"); + const match = block.match(regex); + return match ? match[1].trim() : null; +} + +function extractList(block: string, field: string): string[] | null { + const regex = new RegExp(`${field}:\\s*\\n((?:- .+\\n?)+)`, "im"); + const match = block.match(regex); + if (!match) return null; + return match[1] + .split("\n") + .map(l => l.replace(/^-\\s*/, "").trim()) + .filter(Boolean); +} + +// ─── Save / Load Reflections ──────────────────────────────────────────────── + +/** + * Save a reflection to a file + */ +export function saveReflection( + reflectionsDir: string, + reflection: Reflection, +): void { + ensureDir(reflectionsDir); + const filePath = path.join( + reflectionsDir, + `${reflection.taskId}.json`, + ); + writeFileSafe(filePath, JSON.stringify(reflection, null, 2)); +} + +/** + * Load a reflection from a file + */ +export function loadReflection( + reflectionsDir: string, + taskId: string, +): Reflection | null { + const filePath = path.join(reflectionsDir, `${taskId}.json`); + if (!fs.existsSync(filePath)) return null; + try { + return JSON.parse(fs.readFileSync(filePath, "utf-8")) as Reflection; + } catch { + return null; + } +} + +// ─── Format Reflections ────────────────────────────────────────────────────── + +/** + * Format reflections for display + */ +export function formatReflections(reflections: Reflection[]): string { + if (reflections.length === 0) return "No reflections yet."; + + const lines: string[] = []; + lines.push("## Task Reflections"); + lines.push(""); + + for (const ref of reflections) { + lines.push(`### ${ref.taskId}: ${ref.title}`); + lines.push(`Summary: ${ref.summary}`); + + if (ref.keyLearnings.length > 0) { + lines.push("Learnings:"); + for (const l of ref.keyLearnings) { + lines.push(` - ${l}`); + } + } + + if (ref.filesChanged.length > 0) { + lines.push(`Files: ${ref.filesChanged.join(", ")}`); + } + + if (ref.blockers && ref.blockers.length > 0) { + lines.push(`Blockers: ${ref.blockers.join("; ")}`); + } + + lines.push(""); + } + + return lines.join("\n"); +} diff --git a/src/review.ts b/src/review.ts new file mode 100644 index 0000000..2f45d20 --- /dev/null +++ b/src/review.ts @@ -0,0 +1,214 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import type { ReviewResult, ReviewFinding, ReviewVerdict } from "./types"; +import { REVIEW_PATTERN } from "./constants"; +import { ensureDir, writeFileSafe } from "./utils"; + +// ─── Extract Structured Review ────────────────────────────────────────────── + +/** + * Extract a structured review verdict from the review agent's output text. + * Mirrors extractReflection() — parses a `## REVIEW VERDICT` block emitted at + * the end of the response. + * + * The raw text is preserved on the ReviewResult so the expanded (Ctrl+O) view + * can still render the full free-form prose. Returns null when no verdict + * block is found (caller falls back to free-form text handling). + */ +export function extractReview( + output: string, + taskId: string, + commitHash: string, +): ReviewResult | null { + const match = output.match(REVIEW_PATTERN); + if (!match) return null; + + const block = match[1]; + const verdict = extractVerdict(block); + if (!verdict) return null; // verdict is the one required field + + const summary = extractField(block, "SUMMARY") ?? ""; + const findings = extractFindings(block); + + return { + taskId, + verdict, + summary: summary || verdictLabel(verdict), + findings, + commitHash, + rawText: output.trim(), + timestamp: new Date().toISOString(), + }; +} + +function extractVerdict(block: string): ReviewVerdict | null { + const raw = extractField(block, "VERDICT"); + if (!raw) return null; + const v = raw.toLowerCase().trim(); + if (v === "pass" || v === "warn" || v === "fail") return v; + // Tolerate common synonyms + if (v === "warning" || v === "minor") return "warn"; + if (v === "fail" || v === "failing" || v === "blocker") return "fail"; + if (v === "ok" || v === "passing" || v === "approve") return "pass"; + return null; +} + +// Allowlisted static regexes — `field` is always a known literal, but we use +// a static map rather than string interpolation so there's no dynamic regex +// construction at all (`new RegExp` from a variable trips ReDoS linters). +const FIELD_PATTERNS: Record = { + VERDICT: /VERDICT:\s*(.+?)$/im, + SUMMARY: /SUMMARY:\s*(.+?)$/im, +}; + +function extractField(block: string, field: string): string | null { + const regex = FIELD_PATTERNS[field.toUpperCase()]; + if (!regex) return null; + const match = block.match(regex); + return match ? match[1].trim() : null; +} + +/** + * Parse FINDINGS: lines into structured ReviewFinding objects. + * Each finding line is expected as: + * - [severity] [file:line] message + * where severity is one of blocker|warning|nit|info. + * Falls back gracefully — an unparseable line becomes an info-severity + * finding with the raw line as the message. + */ +function extractFindings(block: string): ReviewFinding[] { + // Match the FINDINGS: header, then capture all following bullet lines. + const regex = /FINDINGS:\s*\n((?:[-*]\s+.+\n?)+)/i; + const match = block.match(regex); + if (!match) return []; + + const lines = match[1] + .split("\n") + .map((l) => l.replace(/^[-*]\s*/, "").trim()) + .filter(Boolean); + + const findings: ReviewFinding[] = []; + // `critical` is accepted and normalized to ralpi's `blocker` severity, + // providing parity with @piex-dev/review's critical/warning/info grading. + const severityRe = /^\[(blocker|critical|warning|warn|nit|info)\]\s*(.*)$/i; + + for (const line of lines) { + const sm = line.match(severityRe); + if (sm) { + let sev = sm[1].toLowerCase(); + if (sev === "warn") sev = "warning"; + else if (sev === "critical") sev = "blocker"; + const rest = sm[2].trim(); + const { file, line: lineNum, message } = parseFileRef(rest); + findings.push({ + severity: sev as ReviewFinding["severity"], + file, + line: lineNum, + message, + }); + } else { + // No severity bracket — treat as info + const { file, line: lineNum, message } = parseFileRef(line); + findings.push({ severity: "info", file, line: lineNum, message }); + } + } + + return findings; +} + +/** Parse an optional `file:line` prefix from a finding message. */ +function parseFileRef(rest: string): { + file?: string; + line?: number; + message: string; +} { + const m = rest.match(/^([\w./-]+):(\d+)\s*[-—]?\s*(.*)$/); + if (m) { + return { file: m[1], line: Number(m[2]), message: m[3].trim() || rest }; + } + return { message: rest }; +} + +function verdictLabel(v: ReviewVerdict): string { + switch (v) { + case "pass": + return "Commit satisfies the task requirements."; + case "warn": + return "Commit passes with minor issues worth addressing."; + case "fail": + return "Commit does not satisfy the task requirements."; + } +} + +// ─── Save / Load Structured Reviews ───────────────────────────────────────── + +/** + * Save a structured review as JSON alongside (or instead of) the markdown + * body. Mirrors saveReflectionToFile's per-loop layout so a repo can hold + * many loops without collisions: + * .ralpi/reviews//.json + */ +export function saveReviewToFile( + sourceDir: string, + reviewsDir: string, + review: ReviewResult, + prdKey: string, +): string { + const dir = path.join(sourceDir, reviewsDir, prdKey); + ensureDir(dir); + const filePath = path.join(dir, `${review.taskId}.json`); + writeFileSafe(filePath, JSON.stringify(review, null, 2)); + return filePath; +} + +/** + * Load a structured review from disk. + */ +export function loadReview( + sourceDir: string, + reviewsDir: string, + taskId: string, + prdKey: string, +): ReviewResult | null { + const filePath = path.join(sourceDir, reviewsDir, prdKey, `${taskId}.json`); + if (!fs.existsSync(filePath)) return null; + try { + return JSON.parse(fs.readFileSync(filePath, "utf-8")) as ReviewResult; + } catch { + return null; + } +} + +// ─── Formatting ────────────────────────────────────────────────────────────── + +/** Verdict glyph for compact display in chat headers / widgets. */ +export function verdictGlyph(v: ReviewVerdict): string { + switch (v) { + case "pass": + return "✓"; + case "warn": + return "⚠"; + case "fail": + return "✗"; + } +} + +/** Short label: "PASS · 0 findings", "WARN · 2 findings", "FAIL · 3 findings" */ +export function verdictSummary(review: ReviewResult): string { + const n = review.findings.length; + const noun = n === 1 ? "finding" : "findings"; + return `${review.verdict.toUpperCase()} · ${n} ${noun}`; +} + +/** + * Format findings as an indented markdown tree for the expanded view. + */ +export function formatFindings(review: ReviewResult): string { + if (review.findings.length === 0) return "(no findings)"; + const lines: string[] = []; + for (const f of review.findings) { + const loc = f.file ? (f.line ? `${f.file}:${f.line}` : f.file) : ""; + lines.push(` - [${f.severity}]${loc ? ` ${loc}` : ""} — ${f.message}`); + } + return lines.join("\n"); +} diff --git a/src/task-manager-prompt.ts b/src/task-manager-prompt.ts new file mode 100644 index 0000000..b1ff355 --- /dev/null +++ b/src/task-manager-prompt.ts @@ -0,0 +1,112 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; + +const TEMPLATE_REL = path.join("prompts", "task-manager.md"); + +/** + * Strip leading YAML frontmatter (--- delimited) from template content. + * Local port of the helper omp does not export from the package root. + */ +function stripFrontmatter(content: string): string { + const m = /^---\r?\n[\s\S]*?\r?\n---\r?\n/.exec(content); + return m ? content.slice(m[0].length) : content; +} + +/** + * Parse command arguments respecting quoted strings (bash-style). + * Ported from pi's core/prompt-templates.js so the task-manager template + * receives the same arg-splitting a real `/task-manager` invocation would. + */ +function parseCommandArgs(argsString: string): string[] { + const args: string[] = []; + let current = ""; + let inQuote: string | null = null; + for (let i = 0; i < argsString.length; i++) { + const char = argsString[i]; + if (inQuote) { + if (char === inQuote) { + inQuote = null; + } else { + current += char; + } + } else if (char === '"' || char === "'") { + inQuote = char; + } else if (/\s/.test(char)) { + if (current) { + args.push(current); + current = ""; + } + } else { + current += char; + } + } + if (current) args.push(current); + return args; +} + +/** + * Substitute argument placeholders in template content. + * Faithful port of pi's substituteArgs (core/prompt-templates.js): + * - $1, $2, ... positional args + * - $@ / $ARGUMENTS all args joined + * - ${N:-default} positional N with default when missing/empty + * - ${@:-default} all args with default when empty + * - ${@:N} / ${@:N:L} bash-style slicing + * + * Replacement runs once over the template only; argument/default values + * containing patterns like $1 or $@ are NOT recursively substituted. + */ +function substituteArgs(content: string, args: string[]): string { + const allArgs = args.join(" "); + return content.replace( + /\$\{(\d+|ARGUMENTS|@):-([^}]*)\}|\$\{@:(\d+)(?::(\d+))?\}|\$(ARGUMENTS|@|\d+)/g, + (_match, defaultTarget, defaultValue, sliceStart, sliceLength, simple) => { + if (defaultTarget) { + const value = + defaultTarget === "@" || defaultTarget === "ARGUMENTS" + ? allArgs + : args[parseInt(defaultTarget, 10) - 1]; + return value ? value : defaultValue; + } + if (sliceStart) { + let start = parseInt(sliceStart, 10) - 1; // 1-indexed → 0-indexed + if (start < 0) start = 0; + if (sliceLength) { + const length = parseInt(sliceLength, 10); + return args.slice(start, start + length).join(" "); + } + return args.slice(start).join(" "); + } + if (simple === "ARGUMENTS" || simple === "@") { + return allArgs; + } + const index = parseInt(simple, 10) - 1; + return args[index] ?? ""; + }, + ); +} + +/** + * Load and expand the task-manager prompt template bundled with the extension. + * + * `pi.sendUserMessage()` sends with `expandPromptTemplates: false`, so it will + * NOT expand a `/task-manager` invocation — and `@task-manager` is an + * @-mention, not a template invocation anyway. We therefore read the + * template ourselves, strip its frontmatter, substitute args ($@ etc.), and + * return the fully-expanded prompt body ready to send as a user message. + * + * @param extensionDir Absolute path to the extension root (where index.ts + * lives), used to locate `prompts/task-manager.md`. + * @param argsString Raw argument string from the slash command (may be ""). + * @throws if the template file is missing or unreadable. + */ +export function loadTaskManagerPrompt( + extensionDir: string, + argsString: string, +): string { + const templatePath = path.join(extensionDir, TEMPLATE_REL); + const raw = fs.readFileSync(templatePath, "utf-8"); + const body = stripFrontmatter(raw); + const args = parseCommandArgs(argsString); + return substituteArgs(body, args).trim(); +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..ffa93ba --- /dev/null +++ b/src/types.ts @@ -0,0 +1,330 @@ +// ─── Task Model ─────────────────────────────────────────────────────────────── + +export type TaskStatus = + | "pending" + | "in_progress" + | "completed" + | "failed" + | "skipped"; +export type TaskStatusChar = " " | "~" | "x" | "!" | "-"; + +export interface Task { + /** Unique task identifier */ + id: string; + /** Task title */ + title: string; + /** Detailed task description */ + description?: string; + /** Path to detailed spec file (relative to sourceDir) */ + file?: string; + /** Current status */ + status: TaskStatus; + /** Task IDs this task depends on */ + dependencies: string[]; + /** Explicit parallel group (optional, overrides dependency-based batching) */ + parallelGroup?: number; + /** Task-level timeout in milliseconds (parsed from meta block) */ + timeoutMs?: number; + /** Original index in task list for deterministic ordering */ + index?: number; + /** Phase number this task belongs to (1-indexed, from ## Phase N headings) */ + phase?: number; +} + +export interface ParallelGroup { + /** Group index (0-based, determines execution order) */ + index: number; + /** Human-readable label for the group (e.g. "Play Store prep") */ + label?: string; + /** Task IDs in this group — all can run concurrently */ + taskIds: string[]; +} + +export interface Phase { + /** Phase number (1-indexed, matches the heading number) */ + number: number; + /** Phase title (e.g. "Push-to-Talk MVP") */ + title: string; + /** Task IDs in this phase, in order */ + taskIds: string[]; +} + +export interface Project { + /** Project-level objective / goal */ + objective?: string; + /** All tasks in the project */ + tasks: Task[]; + /** Explicit dependency map: taskId → [dependency taskIds] */ + dependencies: Record; + /** Explicit parallel groups from "can be done in parallel" declarations */ + parallelGroups?: ParallelGroup[]; + /** Phased sections from ## Phase N headings (in order) */ + phases?: Phase[]; + /** Exit criteria (from README ## Exit Criteria section) */ + exitCriteria?: string[]; + /** Path to the source task file */ + sourcePath: string; + /** Directory containing the source file */ + sourceDir: string; +} + +// ─── Execution Plan ─────────────────────────────────────────────────────────── + +export interface ExecutionBatch { + /** Tasks that can run concurrently in this batch */ + tasks: Task[]; + /** Batch number (0-indexed) */ + batchIndex: number; +} + +export interface ExecutionPlan { + /** Ordered batches (each batch contains parallelizable tasks) */ + batches: ExecutionBatch[]; + /** Total task count */ + totalTasks: number; + /** Tasks skipped (already completed) */ + skippedTasks: Task[]; +} + +// ─── Progress Model ─────────────────────────────────────────────────────────── + +export interface Reflection { + taskId: string; + title: string; + /** What was accomplished */ + summary: string; + /** Key decisions, patterns, and learnings for downstream tasks */ + keyLearnings: string[]; + /** Files created or modified */ + filesChanged: string[]; + /** Unresolved issues or caveats */ + blockers?: string[]; + /** ISO timestamp */ + timestamp: string; +} + +// ─── Review Model ──────────────────────────────────────────────────────────── + +export type ReviewVerdict = "pass" | "warn" | "fail"; + +export interface ReviewFinding { + /** Severity of the finding */ + severity: "blocker" | "warning" | "nit" | "info"; + /** File path if applicable */ + file?: string; + /** Line number if applicable */ + line?: number; + /** Description of the issue */ + message: string; +} + +export interface ReviewResult { + taskId: string; + /** Overall verdict */ + verdict: ReviewVerdict; + /** 1-2 sentence overall assessment */ + summary: string; + /** Structured findings (empty when verdict is "pass") */ + findings: ReviewFinding[]; + /** Commit hash the review was performed against */ + commitHash: string; + /** Full free-form review text (preserved for display) */ + rawText: string; + /** ISO timestamp */ + timestamp: string; +} + +export interface ToolUsage { + read: number; + write: number; + edit: number; + bash: number; + other: number; +} + +export interface TaskProgressInfo { + status: Task["status"]; + startedAt?: string; + completedAt?: string; + durationMs?: number; + reflection?: Reflection; + /** Structured review result (when autoReview is enabled) */ + review?: ReviewResult; + error?: string; + /** Tool usage counts from parsed subprocess output */ + toolUsage?: ToolUsage; + /** Truncated output preview for expanded view */ + outputPreview?: string; + /** Git commit messages from task execution */ + commitMessages?: string[]; + /** Summary derived from git commits */ + commitSummary?: string; + /** Number of review-fix re-execution attempts made (review-gated mode) */ + reviewRetries?: number; +} + +export interface ProgressState { + /** Path to the source task file (legacy single-PRD mode) */ + sourcePath: string; + /** Per-task status tracking (legacy single-PRD mode) */ + tasks: Record; + /** When execution started (legacy single-PRD mode) */ + startedAt: string; + /** When execution last updated (legacy single-PRD mode) */ + lastUpdatedAt: string; + /** Whether execution is currently paused/stopped (legacy single-PRD mode) */ + paused: boolean; + /** Multiple PRDs tracked simultaneously (keyed by normalized source path) */ + prds?: Record; +} + +export interface PRDProgress { + /** Path to the source task file for this PRD */ + sourcePath: string; + /** Per-task status tracking */ + tasks: Record; + /** When execution started */ + startedAt: string; + /** When execution last updated */ + lastUpdatedAt: string; + /** Whether execution is currently paused/stopped */ + paused: boolean; +} + +// ─── Configuration ──────────────────────────────────────────────────────────── + +export interface RalpiConfig { + paths: { + /** Directory for ralpi state files */ + stateDir: string; + /** Directory for per-task reflections */ + reflectionsDir: string; + /** Directory for per-loop review output (mirrors reflectionsDir) */ + reviewsDir: string; + }; + execution: { + /** Task execution timeout in milliseconds */ + timeoutMs: number; + /** Maximum parallel tasks (0 = unlimited) */ + maxParallel: number; + /** Round-robin model list for parallel tasks (empty = inherit parent model) */ + models: string[]; + /** Spawn a follow-up agent to commit changes after each task completes */ + autoCommit: boolean; + /** Spawn a review agent to review the task's committed changes against + * the task description. When autoReview is on, commit is mandated: + * changes are committed (via commit session fallback when the agent + * didn't self-commit), then the COMPLETE diff (baseRef..HEAD) is + * reviewed. On 'fail' the task is re-executed with feedback (loops + * until pass or maxReviewRetries). On pass the worktree merges. + * When autoReview is off, autoCommit controls standalone commit. */ + autoReview: boolean; + /** Persist the full review output to `.ralpi/reviews/.md`. + * Only active when autoReview is true and the user opts in at loop start. */ + saveReviews: boolean; + /** Keys under `execution:` explicitly present in a loaded config YAML. + * Used to skip interactive prompts for fields the user already set. */ + explicitKeys?: Set; + /** Model for commit sessions in / format (empty = inherit task model) */ + commitModel: string; + /** Model for review sessions in / format (empty = inherit task model) */ + reviewModel: string; + /** Model for task implementation in / format (empty = inherit parent model; only used in sequential mode when models is empty) */ + implModel: string; + /** Timeout for auto-commit agent sessions in milliseconds */ + commitTimeoutMs: number; + /** Timeout for auto-review agent sessions in milliseconds */ + reviewTimeoutMs: number; + /** Max review-fix re-execution attempts before giving up (0 = no retries; + * review runs once, reject = stop). Active whenever autoReview is + * enabled. On exhaustion the task proceeds with its committed changes + * (the worktree merges) unless reviewBlockOnFail is set. */ + maxReviewRetries: number; + /** When true, a 'fail' review verdict after exhausting maxReviewRetries + * marks the task as failed instead of proceeding with its committed + * changes (the worktree does not merge). */ + reviewBlockOnFail: boolean; + /** Maximum total duration for the entire loop execution in milliseconds (0 = no limit). Checked between batches — in-progress tasks finish naturally. */ + loopTimeoutMs: number; + /** Max attempts on the SAME model before cycling to the next model on + * failure. Pi retries transient HTTP errors within a single prompt, + * but a sustained provider hiccup can still exhaust those in-call + * retries mid-session. Re-running the whole session a few times on + * the same model avoids flapping to a different model (and losing + * model-specific context) on the first hard failure. Applies to task + * execution, commit/review follow-up sessions, and review-fix + * re-execution alike. After this many attempts on one model, ralpi + * advances to the next model in the round-robin pool. */ + maxSameModelAttempts: number; + /** Isolate each task in a separate git worktree so parallel tasks can't + * stomp each other's files, and review/commit see a clean single-task diff. + * - "never": all tasks run in the shared working tree (default, backward compat) + * - "parallel": only when maxParallel > 1 and mode is parallel + * - "always": every task gets its own worktree */ + worktrees: "always" | "parallel" | "never"; + /** Chat rendering style for tool calls during task execution. + * - "compact": single completion message per task with an expandable + * tool-call tree (collapsed shows last 3, expanded shows all). + * - "verbose": per-event stream — each tool start/end and assistant + * turn is its own chat line (piolium/pygienium-style). */ + chatStyle: "compact" | "verbose"; + }; + prompts: { + /** Additional context injected into every task prompt */ + projectContext: string; + /** Custom prompt suffix for reflection extraction */ + reflectionPrompt: string; + /** Per-review custom focus/instructions (e.g. "check security only"). + * Injected as a `### Custom Review Focus` section in committed and + * uncommitted review prompts when non-empty. */ + reviewFocus: string; + }; + review: { + /** Extra noise-filter exclusion regexes (strings compiled to RegExp), + * merged into EXCLUDED_PATTERNS for review diffs. */ + extraIgnorePatterns: string[]; + /** Pathspec allowlist — files matching these stay in scope even when a + * default noise rule would exclude them. */ + ignorePaths: string[]; + }; + /** Parent session model to inherit in child agent sessions */ + model?: unknown; + /** Parent session thinking level to inherit in child agent sessions */ + thinkingLevel?: unknown; +} + +export const DEFAULT_CONFIG: RalpiConfig = { + paths: { + stateDir: ".ralpi", + reflectionsDir: ".ralpi/reflections", + reviewsDir: ".ralpi/reviews", + }, + execution: { + timeoutMs: 0, // 0 = inherit Pi's own defaults (no ralpi-level timeout) + maxParallel: 3, + models: [], + autoCommit: true, + autoReview: false, + saveReviews: false, + commitModel: "", + reviewModel: "", + implModel: "", + commitTimeoutMs: 0, // 0 = inherit Pi's own defaults (no ralpi-level timeout) + reviewTimeoutMs: 0, // 0 = inherit Pi's own defaults (no ralpi-level timeout) + maxReviewRetries: 2, // 2 re-execution attempts on review rejection before giving up + reviewBlockOnFail: false, // false = commit anyway after retries exhausted + loopTimeoutMs: 0, // 0 = no limit + worktrees: "parallel", // worktree isolation for parallel tasks by default + maxSameModelAttempts: 5, // retry the same model up to 5 times before cycling to the next + chatStyle: "compact", // compact = completion message with tool-call tree; verbose = per-event stream + }, + prompts: { + projectContext: "", + reflectionPrompt: "", + reviewFocus: "", + }, + review: { + extraIgnorePatterns: [], + ignorePaths: [], + }, +}; diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..cefa34b --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,1045 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import type { + RalpiConfig, + PRDProgress, + ProgressState, + ToolUsage, +} from "./types"; +import { DEFAULT_CONFIG } from "./types"; +import { parseTaskFile } from "./parser"; +import type { AgentSessionEvent } from "@oh-my-pi/pi-coding-agent"; +import type { ModelRegistry } from "@oh-my-pi/pi-coding-agent"; +import { + AgentRegistry, + createAgentSession, + getAgentDir, + SessionManager, + Settings, +} from "@oh-my-pi/pi-coding-agent"; + +// ─── Directory Helpers ─────────────────────────────────────────────────────── + +/** + * Ensure a directory exists, creating it recursively if needed + */ +export function ensureDir(dirPath: string): void { + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }); + } +} + +/** + * Write file content, creating parent directories if needed + */ +export function writeFileSafe(filePath: string, content: string): void { + ensureDir(path.dirname(filePath)); + fs.writeFileSync(filePath, content, "utf-8"); +} + +// ─── Loop-Active State ────────────────────────────────────────────────────── + +/** + * State persisted to disk when a ralpi execution loop is active. + * Used to re-instantiate widgets after a session reload, and to resume + * the loop non-interactively when a reload interrupts in-progress tasks. + */ +export interface LoopActiveState { + taskFile: string; + mode: "parallel" | "sequential"; + startedAt: string; + taskIds: string[]; + prdKey: string; + /** Loop option snapshot at loop start, so a reload can resume without + * re-prompting the user. */ + autoCommit?: boolean; + autoReview?: boolean; + saveReviews?: boolean; +} + +/** + * Path (relative to projectDir) where the loop-active marker is stored. + */ +const LOOP_ACTIVE_FILE = ".ralpi/loop-active.json"; + +/** + * Write the loop-active marker, indicating an execution loop is running. + */ +export function writeLoopActive( + projectDir: string, + state: LoopActiveState, +): void { + writeFileSafe( + path.join(projectDir, LOOP_ACTIVE_FILE), + JSON.stringify(state, null, 2), + ); +} + +/** + * Read the loop-active marker, if present. + */ +export function readLoopActive(projectDir: string): LoopActiveState | null { + const filePath = path.join(projectDir, LOOP_ACTIVE_FILE); + try { + const raw = fs.readFileSync(filePath, "utf-8"); + return JSON.parse(raw) as LoopActiveState; + } catch { + return null; + } +} + +/** + * Delete the loop-active marker. + */ +export function deleteLoopActive(projectDir: string): void { + const filePath = path.join(projectDir, LOOP_ACTIVE_FILE); + try { + fs.unlinkSync(filePath); + } catch { + // Ignore if already gone + } +} + +// ─── Git Hygiene ──────────────────────────────────────────────────────────── + +const ralpiIgnoreMemo = new Set(); + +/** + * Ensure `.ralpi/` is excluded from the project's `.gitignore` so ralpi's own + * run-state, worktrees, and reviews never show up as tracked/untracked files + * in the user's repo. + * + * Memoized per project dir; only acts inside a git work tree (`.git` may be a + * directory or, in linked worktrees, a file). Creates or appends `.ralpi/` to + * `.gitignore`, best-effort: any failure returns `false` (never throws). + * + * @returns true when the ignore entry was newly added, false otherwise. + */ +export function ensureRalpiIgnored(projectDir: string): boolean { + if (ralpiIgnoreMemo.has(projectDir)) return false; + ralpiIgnoreMemo.add(projectDir); + try { + // Only act inside a git work tree (works for worktrees too: .git is a file). + fs.statSync(path.join(projectDir, ".git")); + const ignorePath = path.join(projectDir, ".gitignore"); + const marker = ".ralpi/"; + let content: string; + try { + content = fs.readFileSync(ignorePath, "utf8"); + } catch { + fs.writeFileSync(ignorePath, `${marker}\n`, "utf8"); + return true; + } + if (content.split(/\r?\n/).some((l) => l.trim() === marker)) return false; + const prefix = content.endsWith("\n") ? "" : "\n"; + fs.appendFileSync( + ignorePath, + `${prefix}# ralpi run-state, worktrees, and reviews\n${marker}\n`, + "utf8", + ); + return true; + } catch { + return false; // not a git work tree, or a best-effort write failed + } +} + +/** + * Discover the project directory by walking up to find `.ralpi/`. + */ +export function findRalpiDir(startDir: string): string | null { + let current = path.resolve(startDir); + const root = path.parse(current).root; + while (current !== root) { + if (fs.existsSync(path.join(current, ".ralpi"))) { + return current; + } + current = path.dirname(current); + } + return null; +} + +// ─── Async Agent Session ──────────────────────────────────────────────────── + +// ─── Progress Discovery ───────────────────────────────────────────────────── + +/** + * Find the nearest .ralpi/progress.json by walking up from the given directory. + * For a specific sourcePath, finds the matching PRD entry. + */ +export function findProgressFile( + startDir: string, + sourcePath?: string, +): { path: string; state: ProgressState; prdKey?: string } | null { + let current = path.resolve(startDir); + const root = path.parse(current).root; + + while (current !== root) { + const candidate = path.join(current, ".ralpi", "progress.json"); + if (fs.existsSync(candidate)) { + try { + const raw = fs.readFileSync(candidate, "utf-8"); + const state = JSON.parse(raw) as ProgressState; + + // If looking for a specific source path, find matching PRD + if (sourcePath && state.prds) { + const resolvedSource = path.resolve(sourcePath); + for (const [key, prd] of Object.entries(state.prds)) { + if (path.resolve(prd.sourcePath) === resolvedSource) { + return { path: candidate, state, prdKey: key }; + } + } + // No matching PRD found, continue walking up + current = path.dirname(current); + continue; + } + + return { path: candidate, state }; + } catch { + return null; + } + } + current = path.dirname(current); + } + + return null; +} + +/** + * List all PRDs from a ProgressState, sorted by lastUpdatedAt descending + * (most recent first). Used by resume to offer a selection when multiple + * loops have progress simultaneously. + */ +export function listPRDsSorted( + state: ProgressState, +): Array<{ key: string; prd: PRDProgress }> { + const entries: Array<{ key: string; prd: PRDProgress }> = []; + + if (state.prds) { + for (const [key, prd] of Object.entries(state.prds)) { + entries.push({ key, prd }); + } + } else { + // Legacy flat mode — single PRD + entries.push({ + key: "legacy", + prd: { + sourcePath: state.sourcePath, + tasks: state.tasks, + startedAt: state.startedAt, + lastUpdatedAt: state.lastUpdatedAt, + paused: state.paused, + }, + }); + } + + entries.sort((a, b) => { + return ( + new Date(b.prd.lastUpdatedAt).getTime() - + new Date(a.prd.lastUpdatedAt).getTime() + ); + }); + + return entries; +} + +export interface PRDResumeSummary { + total: number; + completed: number; + failed: number; +} + +/** + * Count tasks for the resume-selection display. + * + * The progress tracker only records tasks that were TOUCHED (started, + * completed, or failed) — never-started tasks are absent from `prd.tasks`, + * so a naive Object.keys(prd.tasks).length under-reports the real total. + * The true total comes from parsing the PRD source file. Completed counts + * both progress-marked completions and PRD checkbox completions (a task + * checked off in the file is done even if the loop was interrupted before + * markCompleted), deduped by task id. Falls back to touched-task counts + * when the source file is missing or unparseable. + */ +export function countPRDResumeStats( + prd: PRDProgress, + sourcePath: string, +): PRDResumeSummary { + const touched = Object.entries(prd.tasks); + const failed = touched.filter(([, t]) => t.status === "failed").length; + const completedIds = new Set( + touched.filter(([, t]) => t.status === "completed").map(([id]) => id), + ); + + let total: number; + try { + const project = parseTaskFile(sourcePath); + total = project.tasks.length; + for (const task of project.tasks) { + if (task.status === "completed") completedIds.add(task.id); + } + } catch { + // PRD file missing/unparseable — fall back to touched-task counts + total = touched.length; + } + + return { total, completed: completedIds.size, failed }; +} + +// ─── Model Resolution ─────────────────────────────────────────────────────── + +/** + * Resolve a "/" spec string via the model registry. + * Returns undefined if spec is empty, malformed, or not found. + */ +export function resolveModelSpec( + modelRegistry: + | { find(provider: string, modelId: string): unknown } + | undefined, + spec: string, + onWarning?: (msg: string) => void, +): unknown | undefined { + if (!spec) return undefined; + const slashIdx = spec.indexOf("/"); + if (slashIdx === -1) { + onWarning?.( + `ralpi config: skipping model "${spec}" — expected / format`, + ); + return undefined; + } + const provider = spec.slice(0, slashIdx); + const modelId = spec.slice(slashIdx + 1); + return modelRegistry?.find(provider, modelId); +} + +// ─── Config ────────────────────────────────────────────────────────────────── + +/** Try to use the `yaml` package (real dependency in package.json). + * Falls back to a flat key:value parser when unavailable. */ +const parseSimpleYaml: (content: string) => Record = (() => { + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { parse } = require("yaml"); + return (content: string) => parse(content) ?? {}; + } catch { + return (content: string) => { + const result: Record = {}; + for (const line of content.split("\n")) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const match = trimmed.match(/^([^:]+):\s*(.*)$/); + if (match) { + const value = match[2].trim(); + if (value === "true") result[match[1].trim()] = true; + else if (value === "false") result[match[1].trim()] = false; + else if (/^\d+$/.test(value)) + result[match[1].trim()] = parseInt(value, 10); + else if (/^\d+\.\d+$/.test(value)) + result[match[1].trim()] = parseFloat(value); + else result[match[1].trim()] = value; + } + } + return result; + }; + } +})(); + +/** + * Deep merge configuration objects + */ +function mergeConfig( + defaults: RalpiConfig, + overrides: Record, +): RalpiConfig { + const result = { ...defaults }; + + for (const [key, value] of Object.entries(overrides)) { + if (typeof value === "object" && value !== null && !Array.isArray(value)) { + (result as any)[key] = { ...(defaults as any)[key], ...value }; + } else { + (result as any)[key] = value; + } + } + + return result as RalpiConfig; +} + +/** Path to the global ralpi config under the user's omp home directory. */ +const GLOBAL_CONFIG_PATH = path.join( + process.env.HOME || "/tmp", + ".omp", + "ralpi", + "config.yaml", +); + +/** + * Load and merge config from global and project sources. + * + * Precedence (highest wins): + * 1. Project-level: `/.ralpi/config.yaml` + * 2. Global: `~/.omp/ralpi/config.yaml` + * 3. `DEFAULT_CONFIG` in `src/types.ts` + */ +export function loadConfig(projectDir: string): RalpiConfig { + // Start with defaults + const merged: RalpiConfig = { ...DEFAULT_CONFIG }; + + // Layer 1: global config (~/.omp/ralpi/config.yaml) + tryLoadConfigFile(GLOBAL_CONFIG_PATH, merged); + + // Layer 2: project config (.ralpi/config.yaml) — overrides global + tryLoadConfigFile(path.join(projectDir, ".ralpi", "config.yaml"), merged); + + return merged; + + /** Attempt to load a single config file and merge into `acc` in place. */ + function tryLoadConfigFile(filePath: string, acc: RalpiConfig): void { + if (!fs.existsSync(filePath)) return; + try { + const content = fs.readFileSync(filePath, "utf-8"); + const parsed = parseSimpleYaml(content); + Object.assign(acc, mergeConfig(acc, parsed)); + // Track which execution keys were explicitly set in this YAML so the + // loop-startup prompts can be skipped for fields the user already set. + const exec = parsed?.execution; + if (exec && typeof exec === "object" && !Array.isArray(exec)) { + acc.execution.explicitKeys ??= new Set(); + for (const key of Object.keys(exec)) { + acc.execution.explicitKeys.add(key); + } + } + } catch { + // Malformed config — skip silently + } + } +} + +// ─── Task Resolution ───────────────────────────────────────────────────────── + +/** + * Resolve a task argument to a file path. + * Strips leading `@` (from autocomplete) before resolution. + */ +export function resolveTaskArg(arg: string, cwd: string): string { + // Strip leading @ from autocomplete + const cleanArg = arg.startsWith("@") ? arg.slice(1) : arg; + + const candidates = [ + path.resolve(cwd, cleanArg), + path.resolve(cwd, cleanArg + ".md"), + path.resolve(cwd, cleanArg + ".yaml"), + path.resolve(cwd, cleanArg + ".yml"), + ]; + + for (const candidate of candidates) { + if (fs.existsSync(candidate)) return candidate; + } + + // Try looking for README.md in the arg directory + try { + if (fs.statSync(path.resolve(cwd, cleanArg)).isDirectory()) { + const readme = path.resolve(cwd, cleanArg, "README.md"); + if (fs.existsSync(readme)) return readme; + } + } catch { + // Directory doesn't exist, fall through to error + } + + throw new Error( + `Task file not found: ${cleanArg}\nSearched: ${candidates.join("\n ")}`, + ); +} + +// ─── Formatting ────────────────────────────────────────────────────────────── + +/** + * Format duration in milliseconds to human-readable string + */ +export function formatDuration(ms: number): string { + const seconds = Math.floor(ms / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + + if (hours > 0) { + return `${hours}h ${minutes % 60}m`; + } + if (minutes > 0) { + return `${minutes}m ${seconds % 60}s`; + } + return `${seconds}s`; +} + +/** + * Format progress status for display. Accepts a single PRDProgress entry. + */ +export function formatProgressStatus(state: PRDProgress): string { + const lines: string[] = []; + const tasks = state.tasks; + const total = Object.keys(tasks).length; + const completed = Object.values(tasks).filter( + (t) => t.status === "completed", + ).length; + const failed = Object.values(tasks).filter( + (t) => t.status === "failed", + ).length; + const inProgress = Object.values(tasks).filter( + (t) => t.status === "in_progress", + ).length; + + lines.push("## Progress"); + lines.push(""); + lines.push( + `Total: ${total} | Completed: ${completed} | Failed: ${failed} | In Progress: ${inProgress}`, + ); + lines.push(""); + + for (const [id, info] of Object.entries(tasks)) { + const statusIcon = + info.status === "completed" + ? "[x]" + : info.status === "in_progress" + ? "[~]" + : info.status === "failed" + ? "[!]" + : "[ ]"; + + const duration = info.durationMs + ? ` (${formatDuration(info.durationMs)})` + : ""; + + lines.push(`- ${statusIcon} ${id}${duration}`); + + if (info.error) { + lines.push(` Error: ${info.error}`); + } + } + + lines.push(""); + lines.push(`Started: ${state.startedAt}`); + lines.push(`Updated: ${state.lastUpdatedAt}`); + lines.push(`Paused: ${state.paused ? "yes" : "no"}`); + + return lines.join("\n"); +} + +/** + * Format progress status for all PRDs in a ProgressState. + */ +export function formatAllPRDsStatus(state: ProgressState): string { + const prds = state.prds; + if (!prds || Object.keys(prds).length <= 1) { + // Single PRD — use simple format + const prd = prds + ? Object.values(prds)[0] + : (state as unknown as PRDProgress); + return formatProgressStatus(prd); + } + + const lines: string[] = []; + lines.push("## Progress (all PRDs)"); + lines.push(""); + + for (const [key, prd] of Object.entries(prds)) { + const tasks = prd.tasks; + const total = Object.keys(tasks).length; + const completed = Object.values(tasks).filter( + (t) => t.status === "completed", + ).length; + const failed = Object.values(tasks).filter( + (t) => t.status === "failed", + ).length; + const inProgress = Object.values(tasks).filter( + (t) => t.status === "in_progress", + ).length; + + lines.push(`### ${key}`); + lines.push(`Source: ${path.relative(process.cwd(), prd.sourcePath)}`); + lines.push( + `Total: ${total} | Completed: ${completed} | Failed: ${failed} | In Progress: ${inProgress}`, + ); + lines.push(""); + + for (const [id, info] of Object.entries(tasks)) { + const statusIcon = + info.status === "completed" + ? "[x]" + : info.status === "in_progress" + ? "[~]" + : info.status === "failed" + ? "[!]" + : "[ ]"; + + const duration = info.durationMs + ? ` (${formatDuration(info.durationMs)})` + : ""; + + lines.push(`- ${statusIcon} ${id}${duration}`); + + if (info.error) { + lines.push(` Error: ${info.error}`); + } + } + + lines.push(""); + } + + return lines.join("\n"); +} + +// ─── Async Agent Session ──────────────────────────────────────────────────── + +/** + * Run a task prompt through an in-process Pi agent session (async, non-blocking). + * + * Unlike the old spawnPi() which used spawnSync and froze the TUI, + * this uses createAgentSession from the Pi SDK, keeping the event loop + * responsive and allowing progress updates during task execution. + */ +export async function runAgentSession( + taskPrompt: string, + cwd: string, + timeoutMs: number, + onEvent?: (event: AgentSessionEvent) => void, + signal?: AbortSignal, + model?: unknown, + thinkingLevel?: unknown, + /** When true, skip loading the skills catalog for this session. Used by + * focused follow-up sessions (commit/review) that don't need skills — + * keeps the context lean and avoids dragging in unrelated overhead. */ + noSkills = false, + /** Parent session's model registry. Must be passed so extension-registered + * providers (e.g., neuralwatt with its streamSimple wrapper for 429 + * rate-limit normalization) are available. When omitted, the SDK creates + * a fresh registry from models.json only — extension providers are lost. */ + modelRegistry?: ModelRegistry, +): Promise<{ + success: boolean; + text: string; + error?: string; + toolUsage: ToolUsage; + stopReason?: string; + events: AgentSessionEvent[]; +}> { + const toolUsage: ToolUsage = { + read: 0, + write: 0, + edit: 0, + bash: 0, + other: 0, + }; + // Wire timeout via abort signal (only when set; 0 means inherit Pi's defaults) + let timeoutHandle: NodeJS.Timeout | null = null; + if (timeoutMs > 0) { + timeoutHandle = setTimeout(() => { + if (sessionRef?.session) sessionRef.session.agent.abort(); + }, timeoutMs); + } + + const sessionRef: { + session?: Awaited>["session"]; + } = {}; + + try { + // Loop sessions load the full normal omp context: extensions (so all + // extension-provided tools register) and project context (AGENTS.md). + const result = await createAgentSession({ + cwd, + sessionManager: SessionManager.inMemory(cwd), + settingsManager: Settings.init({ cwd, agentDir: getAgentDir() }), + // Loop sessions intentionally load extensions (no disableExtensionDiscovery), + // plus skills and project context via default discovery. + skills: noSkills ? [] : undefined, + promptTemplates: [], + // No `tools` allowlist: matches a normal omp session's tool set. + model: model as any, + thinkingLevel: thinkingLevel as any, + modelRegistry, + agentRegistry: new AgentRegistry(), + }); + sessionRef.session = result.session; + + // Wire external abort signal + const abortHandler = () => result.session.agent.abort(); + signal?.addEventListener("abort", abortHandler, { once: true }); + + let finalText = ""; + let errorMessage: string | undefined; + let stopReason: string | undefined; + + const unsubscribe = result.session.subscribe((event) => { + onEvent?.(event); + + if (event.type === "message_end") { + const message = event.message as { + role?: string; + content?: unknown; + stopReason?: string; + errorMessage?: string; + }; + if (message.role !== "assistant") return; + if (message.stopReason) stopReason = message.stopReason; + if (message.errorMessage) errorMessage = message.errorMessage; + const text = extractAssistantText(message.content); + if (text) finalText = text; + } + + if (event.type === "tool_execution_start") { + const name = event.toolName; + if (name in toolUsage) { + (toolUsage as unknown as Record)[name]++; + } else { + toolUsage.other++; + } + } + }); + + if (signal?.aborted) throw new Error("Aborted before prompt"); + + await result.session.prompt(taskPrompt); + await result.session.agent.waitForIdle(); + + unsubscribe(); + result.session.dispose(); + signal?.removeEventListener("abort", abortHandler); + if (timeoutHandle) clearTimeout(timeoutHandle); + + if (errorMessage && !finalText) { + return { + success: false, + text: "", + error: errorMessage, + toolUsage, + stopReason, + events: [], // streamed to file + }; + } + + return { + success: true, + text: finalText.trim(), + toolUsage, + stopReason, + events: [], + }; + } catch (error) { + if (timeoutHandle) clearTimeout(timeoutHandle); + return { + success: false, + text: "", + error: error instanceof Error ? error.message : String(error), + toolUsage, + events: [], + }; + } finally { + sessionRef.session?.dispose(); + } +} + +/** + * Extract assistant text from message content (text blocks only). + */ +function extractAssistantText(content: unknown): string { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + return content + .filter( + (c): c is { type: string; text?: string } => + !!c && + typeof c === "object" && + (c as { type?: string }).type === "text", + ) + .map((c) => (c as { text?: string }).text ?? "") + .join(""); +} + +// ─── Git Commit Capture ────────────────────────────────────────────────────── + +/** + * Check if there are any uncommitted changes in the git repository. + * Includes untracked files — a new file created by a task agent is work + * that still needs committing. + */ +export function hasUncommittedChanges(projectDir: string): boolean { + const { execSync } = require("node:child_process"); + try { + const output = execSync("git status --porcelain", { + cwd: projectDir, + encoding: "utf-8", + }).trim(); + return output.length > 0; + } catch { + return false; + } +} + +/** + * Check for uncommitted changes to TRACKED files only, ignoring untracked + * (`??`) entries. + * + * Untracked files never block a merge, so a worktree whose task work is + * fully committed is "done" even when it carries stray untracked files + * (scratch files, build artifacts, files created but deliberately left out + * of the commit). Resume-finalize uses this to decide whether a task's + * committed branch should be merged into main: counting `??` entries there + * would strand committed code in `.ralpi/worktrees/` forever. + */ +export function hasTrackedUncommittedChanges(projectDir: string): boolean { + const { execSync } = require("node:child_process"); + try { + const output = execSync("git status --porcelain", { + cwd: projectDir, + encoding: "utf-8", + }).trim(); + return output + .split("\n") + .some((line: string) => line.length > 0 && !line.startsWith("??")); + } catch { + return false; + } +} + +/** + * Get the current git status in porcelain format. + * Includes untracked files, which `git diff` alone would miss. + */ +export function getGitStatusPorcelain(projectDir: string): string { + const { execSync } = require("node:child_process"); + try { + return execSync("git status --porcelain", { + cwd: projectDir, + encoding: "utf-8", + }).trim(); + } catch { + return ""; + } +} + +/** + * Get the current git diff for tracked uncommitted changes. + */ +export function getGitDiff(projectDir: string): string { + const { execSync } = require("node:child_process"); + try { + return execSync("git diff", { + cwd: projectDir, + encoding: "utf-8", + }).trim(); + } catch { + return ""; + } +} + +/** + * Capture recent git commits made during task execution + * Returns commit messages and a summary string + */ +export function captureGitCommits(projectDir: string): { + commitMessages: string[]; + commitSummary: string; +} { + const { execSync } = require("node:child_process"); + + try { + // Check if this is a git repo + execSync("git rev-parse --git-dir", { cwd: projectDir, stdio: "pipe" }); + } catch { + return { commitMessages: [], commitSummary: "" }; + } + + const commitMessages: string[] = []; + let commitSummary = ""; + + try { + // Get recent commits (last 5) with short hash and subject + const output = execSync("git log --oneline -5 --no-decorate", { + cwd: projectDir, + encoding: "utf-8", + }).trim(); + + if (output) { + const lines = output.split("\n").filter((l: string) => l.trim()); + for (const line of lines) { + // Format: "abc1234 Commit message" + const parts = line.split(" ", 2); + if (parts.length >= 2) { + commitMessages.push(parts[1]); + } + } + + // Build summary from commit subjects + commitSummary = commitMessages.slice(0, 3).join("; "); + if (commitMessages.length > 3) { + commitSummary += ` (+${commitMessages.length - 3} more)`; + } + } + } catch { + // Git command failed, return empty + } + + return { commitMessages, commitSummary }; +} + +/** + * Get the diff of the latest commit (HEAD). + * Returns the short hash, subject, and full diff (stat + patch). + * Used by the auto-review agent to review a commit against the task. + */ +export function getLatestCommitDiff( + projectDir: string, +): { hash: string; subject: string; diff: string } | null { + const { execSync } = require("node:child_process"); + + try { + execSync("git rev-parse --git-dir", { cwd: projectDir, stdio: "pipe" }); + } catch { + return null; + } + + try { + const hash = execSync("git rev-parse --short HEAD", { + cwd: projectDir, + encoding: "utf-8", + }).trim(); + + const subject = execSync("git log -1 --format=%s", { + cwd: projectDir, + encoding: "utf-8", + }).trim(); + + // Full diff of the latest commit: stat overview + patch. + // maxBuffer set high — the prompt builder truncates to MAX_DIFF_BYTES. + const diff = execSync("git show HEAD --stat --patch", { + cwd: projectDir, + encoding: "utf-8", + maxBuffer: 10 * 1024 * 1024, + }).trim(); + + return { hash, subject, diff }; + } catch { + return null; + } +} + +/** + * Capture the current HEAD commit SHA. Returns the full 40-char SHA, or + * undefined when not a git repo / git unavailable. Used to snapshot the + * worktree HEAD before a task runs so the review can diff the complete task + * output (baseRef..HEAD) — including any commits the task agent makes. + */ +export function captureGitHead(projectDir: string): string | undefined { + const { execSync } = require("node:child_process"); + try { + const sha = execSync("git rev-parse HEAD", { + cwd: projectDir, + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + }).trim(); + // Guard against injection — only accept hex SHAs. + return /^[0-9a-f]{7,40}$/i.test(sha) ? sha : undefined; + } catch { + return undefined; + } +} + +/** + * Get the diff from `baseRef` to HEAD — the complete set of committed changes + * made since the base reference. Used by the review-gated loop so the reviewer + * sees the full task diff (all commits, not just the latest) across execution + * attempts and re-execution fixes. `baseRef` must be a validated hex SHA from + * captureGitHead(). + * + * Returns a tri-state so the review loop can tell a FAILED range computation + * (invalid/stale base ref, git error) apart from a GENUINELY EMPTY range — a + * broken base must never be silently treated as a clean, verified task. + */ +export type CommitRangeDiffResult = + | { kind: "ok"; hash: string; subject: string; diff: string } + | { kind: "no-changes" } + | { kind: "error"; error: string }; + +/** + * Whether the `baseRef..HEAD` range can be computed — i.e. the base ref is a + * resolvable commit in this repo (mirrors @piex-dev/review's canCompareToBase). + * Only validated hex SHAs are passed to the shell. + */ +export function canComputeRange(projectDir: string, baseRef: string): boolean { + const { execSync } = require("node:child_process"); + if (!/^[0-9a-f]{7,40}$/i.test(baseRef)) return false; + try { + // git cat-file -e truly verifies the object EXISTS (rev-parse --verify + // accepts any 40-hex SHA even if it was never created), so a stale/broken + // base ref is caught here rather than silently treated as no-changes. + execSync(`git cat-file -e ${baseRef}`, { + cwd: projectDir, + stdio: "pipe", + }); + return true; + } catch { + return false; + } +} + +export function getCommitRangeDiff( + projectDir: string, + baseRef: string, +): CommitRangeDiffResult { + const { execSync } = require("node:child_process"); + + // Only pass validated hex SHAs to the shell. + if (!/^[0-9a-f]{7,40}$/i.test(baseRef)) { + return { kind: "error", error: "invalid or stale base ref" }; + } + + try { + execSync("git rev-parse --git-dir", { + cwd: projectDir, + stdio: "pipe", + }); + } catch { + return { kind: "error", error: "not a git repository" }; + } + + // Verify the base ref resolves before diffing — a stale/unfetched ref is a + // computation failure, not a clean "no changes" signal. git cat-file -e + // checks the object genuinely exists (rev-parse --verify would accept any + // 40-hex SHA even if it was never created). + try { + execSync(`git cat-file -e ${baseRef}`, { + cwd: projectDir, + stdio: "pipe", + }); + } catch { + return { kind: "error", error: `base ref ${baseRef} cannot be resolved` }; + } + + try { + const hash = execSync("git rev-parse --short HEAD", { + cwd: projectDir, + encoding: "utf-8", + }).trim(); + + const subject = execSync("git log -1 --format=%s", { + cwd: projectDir, + encoding: "utf-8", + }).trim(); + + // Diff from baseRef to HEAD — shows all committed changes made since + // the snapshot. Includes stat overview + full patch. + // + // maxBuffer is set high (10 MB) so larger tasks don't cause execSync to + // throw. The review prompt builder filters noise and inlines only under + // MAX_DIFF_BYTES, so the full diff in memory is fine. + const diff = execSync(`git diff ${baseRef} HEAD --stat --patch`, { + cwd: projectDir, + encoding: "utf-8", + maxBuffer: 10 * 1024 * 1024, + }).trim(); + + if (!diff) return { kind: "no-changes" }; // genuinely no changes since baseRef + return { kind: "ok", hash, subject, diff }; + } catch (error) { + return { + kind: "error", + error: error instanceof Error ? error.message : String(error), + }; + } +} diff --git a/src/widget-batcher.ts b/src/widget-batcher.ts new file mode 100644 index 0000000..40e99f9 --- /dev/null +++ b/src/widget-batcher.ts @@ -0,0 +1,92 @@ +import type { ExtensionContext } from "@oh-my-pi/pi-coding-agent"; + +/** + * Batches widget updates from multiple parallel tasks into a single + * render cycle, preventing TUI thrashing when agents update independently. + * + * Uses microtask debouncing: updates within the same event-loop tick + * are coalesced into one flush. No artificial interval — updates hit the + * screen as soon as the current tick yields, but never duplicatively. + */ +export class WidgetBatcher { + /** Pending widget updates keyed by widget key. */ + private pending: Map = new Map(); + + /** Widget keys scheduled for removal. */ + private pendingRemovals: Set = new Set(); + + /** Whether a microtask flush is already queued. */ + private scheduled = false; + + /** Whether a flush is currently executing (prevents re-entry). */ + private flushing = false; + + constructor(private ctx: ExtensionContext) {} + + /** + * Schedule a widget update. Flushed asynchronously at end of the + * current event-loop tick; multiple calls in the same tick coalesce. + */ + schedule(key: string, lines: string[]): void { + this.pending.set(key, lines); + this.scheduleFlush(); + } + + /** + * Remove a widget (e.g., when a task completes). + * Flushed asynchronously at end of the current tick. + */ + scheduleRemove(key: string): void { + this.pending.delete(key); + this.pendingRemovals.add(key); + this.scheduleFlush(); + } + + /** Synchronously flush all pending updates. */ + flush(): void { + this.doFlush(); + } + + /** Flush remaining updates then stop scheduling. */ + stop(): void { + this.doFlush(); + } + + // ── Internal ──────────────────────────────────────────────────────── + + private scheduleFlush(): void { + if (this.scheduled) return; + this.scheduled = true; + queueMicrotask(() => { + this.scheduled = false; + this.doFlush(); + }); + } + + private doFlush(): void { + if (this.flushing) return; + this.flushing = true; + + // Atomically swap — new schedule()/scheduleRemove() calls land on fresh + // collections, so the batch we iterate stays immutable and nothing is lost. + const toRender = this.pending; + const toRemove = this.pendingRemovals; + this.pending = new Map(); + this.pendingRemovals = new Set(); + + // Apply removals first + for (const key of toRemove) { + this.ctx.ui.setWidget(key, undefined); + } + + // Sort by key for deterministic, stable ordering across every flush. + // Task IDs are zero-padded ("008", "012", "013") so alpha sort = numeric order. + const sortedKeys = Array.from(toRender.keys()).sort(); + for (const key of sortedKeys) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.ctx.ui.setWidget(key, toRender.get(key)!); + } + + this.flushing = false; + } +} diff --git a/src/worktree.ts b/src/worktree.ts new file mode 100644 index 0000000..d17dd11 --- /dev/null +++ b/src/worktree.ts @@ -0,0 +1,564 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { + ensureDir, + hasUncommittedChanges, + hasTrackedUncommittedChanges, +} from "./utils"; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export interface WorktreeHandle { + /** Absolute path to the worktree working directory. */ + dir: string; + /** Branch name: slugified task title, or `ralpi//` as a fallback. */ + branch: string; + /** Main repo directory (where the primary working tree lives). */ + mainDir: string; +} + +export interface MergeResult { + success: boolean; + /** File paths that conflicted (empty when merge succeeds). */ + conflicts: string[]; + /** Human-readable status message. */ + message: string; +} + +// ─── Git Helpers ───────────────────────────────────────────────────────────── + +/** Run a git command, returning trimmed stdout. Returns null on failure. */ +function git(args: string, cwd: string): string | null { + const { execSync } = require("node:child_process") as { + execSync: (cmd: string, opts: object) => string; + }; + try { + return execSync(`git ${args}`, { + cwd, + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + }).trim(); + } catch { + return null; + } +} + +/** Run a git command that may fail; returns { ok, stdout, stderr }. */ +function gitRaw( + args: string, + cwd: string, +): { ok: boolean; stdout: string; stderr: string } { + const { execSync } = require("node:child_process") as { + execSync: (cmd: string, opts: object) => string; + }; + try { + const stdout = execSync(`git ${args}`, { + cwd, + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + }); + return { ok: true, stdout: stdout.trim(), stderr: "" }; + } catch (err: unknown) { + const e = err as { + stdout?: string; + stderr?: string; + message?: string; + }; + return { + ok: false, + stdout: (e.stdout ?? "").toString().trim(), + stderr: (e.stderr ?? "").toString().trim(), + }; + } +} + +/** Check if a directory is inside a git repository. */ +export function isGitRepo(dir: string): boolean { + return git("rev-parse --git-dir", dir) !== null; +} + +/** Get the current HEAD commit hash of a directory. */ +export function getGitHead(dir: string): string | null { + return git("rev-parse HEAD", dir); +} + +/** Get the current branch name of a directory. */ +export function getCurrentBranch(dir: string): string | null { + return git("rev-parse --abbrev-ref HEAD", dir); +} + +/** + * Canonicalize a directory path, resolving symlinks. + * + * `git worktree list --porcelain` emits REAL paths (symlinks resolved, + * e.g. `/private/tmp/...` for `/tmp/...` on macOS), while `path.join` on a + * caller-supplied path keeps the literal spelling. Comparing the two + * verbatim silently fails — resume then can't see an existing worktree, + * `createWorktree` falls through to a fresh `worktree add` that fails + * because the directory already exists, returns null, and the task agent + * ends up running in the MAIN repo with no worktree merge at all. + * + * All worktree path computation and porcelain comparisons go through this + * so literal vs real paths can never diverge. + */ +function canonicalDir(dir: string): string { + try { + return fs.realpathSync(dir); + } catch { + return path.resolve(dir); + } +} + +// ─── Worktree Lifecycle ────────────────────────────────────────────────────── + +/** + * Path to the worktree directory for a given task. + * Lives inside `.ralpi/worktrees//` in the main repo so all + * ralpi state stays co-located and multiple loops (different PRDs) can run + * concurrently without colliding on shared task IDs. The directory itself + * is untracked git metadata (registered in `.git/worktrees/`), so it won't + * pollute `git status` in the main working tree. + */ +export function worktreePath( + mainDir: string, + stateDir: string, + prdKey: string, + taskId: string, +): string { + return path.join(mainDir, stateDir, "worktrees", prdKey, taskId); +} + +/** + * Normalise a task ID into a valid git branch suffix. + * Zero-padded IDs like "01" are already valid; this ensures any stray + * characters are replaced. + */ +function safeBranchSuffix(taskId: string): string { + return taskId.replace(/[^a-zA-Z0-9_-]/g, "-"); +} + +/** + * Sanitise a free-form task title into a git-branch-safe slug. + * + * Lowercases, replaces runs of non-alphanumeric characters with single + * hyphens, trims leading/trailing hyphens, and caps the length so the + * branch name stays readable and within reasonable git limits. + * + * Returns an empty string when the title produces no usable slug. + */ +function slugifyTitle(title: string): string { + return title + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 60); +} + +/** + * Create a git worktree for a task. + * + * The worktree is created at `/.ralpi/worktrees//` + * on a new branch. When `taskTitle` is provided the branch name is the slugified title + * alone (e.g. `fix-plans-tab-grammar-casing-icons`); otherwise it falls back + * to `ralpi//`. Based at `baseRef` (defaults to the current + * HEAD of `mainDir`). + * + * The worktree directory always uses the bare `taskId` for a stable path; + * stale-worktree cleanup identifies ralpi worktrees by that path, not by + * branch name, so descriptive branch names are safe. + * + * Returns null if `mainDir` is not a git repo or the worktree creation fails. + */ +export function createWorktree( + mainDir: string, + stateDir: string, + taskId: string, + prdKey: string, + baseRef?: string, + taskTitle?: string, +): WorktreeHandle | null { + // Canonicalize FIRST: every path below (worktree dir, porcelain + // comparisons, branch refs) must share one spelling of the repo path. + mainDir = canonicalDir(mainDir); + if (!isGitRepo(mainDir)) return null; + + const safeId = safeBranchSuffix(taskId); + const slug = taskTitle ? slugifyTitle(taskTitle) : ""; + const branch = slug || `ralpi/${prdKey}/${safeId}`; + const wtDir = worktreePath(mainDir, stateDir, prdKey, taskId); + + // Prune metadata for worktree directories that no longer exist on disk + // (e.g. from a crashed previous run that left stale `.git/worktrees/` entries). + git("worktree prune", mainDir); + + // ── Reuse an already-registered worktree (resume) ── + // A resumed loop skips `cleanupStaleWorktrees`, so the interrupted task's + // worktree — and the branch carrying its committed work — survives. Reuse + // it instead of destroying and recreating from the base ref; otherwise the + // prior session's commits are lost and the task restarts from scratch. + const existing = git(`worktree list --porcelain`, mainDir); + if (existing && existing.includes(`worktree ${wtDir}`)) { + // The worktree is registered — sanity-check it's a valid checkout. + if (getGitHead(wtDir)) { + // Return the branch the worktree is ACTUALLY checked out on, NOT the + // slug recomputed from the (possibly changed) task title. Mismatch + // happens routinely on resume: the task agent may have created its + // own feature branch (e.g. `proctored-exam-delivery-mode-10-exam-...`) + // once it saw the convention in the git log, the title may have been + // edited between runs, or an older ralpi version used a different + // naming scheme. Returning the slug here makes `git merge ` + // fail with "not something we can merge" because no such ref exists — + // exactly the spurious merge-conflict we see on resumes. + const actual = getCurrentBranch(wtDir); + if (actual && actual !== "HEAD" && actual !== "detached") { + return { dir: wtDir, branch: actual, mainDir }; + } + // Detached-HEAD worktree (e.g. left by a prior `--detach` fallback). + // The slug ref doesn't exist as a branch — create one matching the + // slug from the worktree's current HEAD so the merge step resolves. + git(`branch "${branch}" HEAD`, mainDir); + return { dir: wtDir, branch, mainDir }; + } + // Registered but broken (dir gone / checkout corrupt) — drop its + // metadata and fall through to fresh creation below. + git(`worktree remove --force "${wtDir}"`, mainDir); + } + + // Fresh creation. + const ref = baseRef ?? getGitHead(mainDir); + if (!ref) return null; + + // Ensure the parent directory exists so `git worktree add` can create + // the worktree directory inside it. + ensureDir(path.dirname(wtDir)); + + // Delete a stale branch if it exists from a previous run so `-b` doesn't + // fail on the new worktree. + git(`branch -D "${branch}"`, mainDir); + + const result = gitRaw( + `worktree add -b "${branch}" "${wtDir}" "${ref}"`, + mainDir, + ); + if (!result.ok) { + // Fall back to detached HEAD worktree if branch creation fails + // (e.g. the branch name somehow conflicts). + const fallback = gitRaw( + `worktree add --detach "${wtDir}" "${ref}"`, + mainDir, + ); + if (!fallback.ok) return null; + } + + return { dir: wtDir, branch, mainDir }; +} + +/** + * Merge a worktree's branch back into the current branch of the main repo. + * + * Uses `--no-ff` to always create a merge commit, preserving the task + * branch's history. On conflict, the merge is aborted and the conflicts + * are returned so the caller can mark the task as failed. + */ +export function mergeWorktree(mainDir: string, branch: string): MergeResult { + // Attempt the merge. + const result = gitRaw(`merge --no-ff --no-edit "${branch}"`, mainDir); + + if (result.ok) { + return { + success: true, + conflicts: [], + message: `Merged ${branch} into ${getCurrentBranch(mainDir) ?? "HEAD"}`, + }; + } + + // Merge failed — likely conflicts. Collect the list of conflicting files. + const status = git("diff --name-only --diff-filter=U", mainDir) ?? ""; + const conflicts = status + .split("\n") + .map((l) => l.trim()) + .filter(Boolean); + + // Abort the merge so the main repo's working tree is left clean. + git("merge --abort", mainDir); + + return { + success: false, + conflicts, + message: + conflicts.length > 0 + ? `Merge conflicts in: ${conflicts.join(", ")}` + : `Merge of ${branch} failed: ${result.stderr || result.stdout}`, + }; +} + +/** + * Re-attempt a merge WITHOUT aborting on conflict. + * + * Unlike `mergeWorktree`, this leaves the main repo in a merge-conflict + * state so a conflict-resolution agent can see the conflict markers in the + * working tree and resolve them manually. The caller is responsible for + * committing the resolved merge or aborting it. + * + * Returns: + * - `clean: true` → merge succeeded (nothing staged to commit yet; the + * caller should `git commit` or `git merge --abort` to finalise) + * - `clean: false` → conflicts; working tree has conflict markers + */ +export function reattemptMerge( + mainDir: string, + branch: string, +): { clean: boolean; conflicts: string[] } { + // Use --no-commit so even a clean merge doesn't auto-commit — the caller + // controls when the merge commit lands. + const result = gitRaw(`merge --no-ff --no-commit "${branch}"`, mainDir); + + if (result.ok) { + return { clean: true, conflicts: [] }; + } + + // Merge produced conflicts — collect them but DO NOT abort. + const status = git("diff --name-only --diff-filter=U", mainDir) ?? ""; + const conflicts = status + .split("\n") + .map((l) => l.trim()) + .filter(Boolean); + return { clean: false, conflicts }; +} + +/** Abort an in-progress merge in the main repo. */ +export function abortMerge(mainDir: string): void { + git("merge --abort", mainDir); +} + +/** Check if there are unmerged paths (conflicts) in the working tree. */ +export function hasMergeConflicts(mainDir: string): boolean { + const status = git("diff --name-only --diff-filter=U", mainDir) ?? ""; + return status.trim().length > 0; +} + +/** Complete the in-progress merge by committing. Returns true on success. */ +export function completeMerge(mainDir: string): boolean { + const result = gitRaw("commit --no-edit", mainDir); + return result.ok; +} + +/** + * Remove a worktree and delete its branch. + * + * Called after a successful merge to clean up. Safe to call even if the + * worktree or branch no longer exists. + */ +export function removeWorktree(mainDir: string, wt: WorktreeHandle): void { + git(`worktree remove --force "${wt.dir}"`, mainDir); + git(`branch -D "${wt.branch}"`, mainDir); + git("worktree prune", mainDir); +} + +/** + * Clean up stale worktrees from interrupted runs. + * + * Identifies ralpi-owned worktrees by their path living under + * `//worktrees/` and removes them. Called at the start + * of a loop to ensure a clean slate. Returns the list of removed worktree + * directories. + * + * When `prdKey` is provided, cleanup is scoped to + * `//worktrees//` so that worktrees belonging to + * other concurrently running loops (different PRDs) are left untouched. + * When omitted, all ralpi-managed worktrees are cleaned. + */ +export function cleanupStaleWorktrees( + mainDir: string, + stateDir: string, + prdKey?: string, +): string[] { + const removed: string[] = []; + + // Prune metadata for worktree directories that no longer exist on disk. + git("worktree prune", mainDir); + + const list = git("worktree list --porcelain", mainDir); + if (!list) return removed; + + // Worktrees we manage live under //worktrees/. + // When a prdKey is given, narrow to that PRD's subdir so concurrent + // loops (other PRDs) are not disturbed. + const managedRoot = path.resolve( + canonicalDir(mainDir), + stateDir, + "worktrees", + ...(prdKey ? [prdKey] : []), + ); + + // Parse worktree list: each entry is `worktree ` followed by metadata. + const wtLines = list + .split("\n") + .filter((l) => l.startsWith("worktree ")) + .map((l) => l.slice("worktree ".length).trim()); + + for (const wtDir of wtLines) { + // Skip the main working tree (always first in the list). + if (path.resolve(wtDir) === path.resolve(mainDir)) continue; + + // Only touch worktrees that live under the ralpi worktrees directory. + const resolved = path.resolve(wtDir); + if ( + resolved !== managedRoot && + !resolved.startsWith(managedRoot + path.sep) + ) + continue; + + // Remove the worktree and its branch. + git(`worktree remove --force "${wtDir}"`, mainDir); + const branch = git(`rev-parse --abbrev-ref HEAD`, wtDir); + if (branch && branch !== "HEAD" && branch !== "detached") { + git(`branch -D "${branch}"`, mainDir); + } + removed.push(wtDir); + } + + git("worktree prune", mainDir); + return removed; +} + +/** Result of attempting to finalize a single in-progress worktree on resume. */ +export interface FinalizeResult { + /** Task IDs whose committed branch was merged into main and cleaned up. */ + finalized: string[]; + /** Task IDs left to re-run (no worktree, dirty tree, nothing committed, + * or merge conflict — work is preserved for re-execution). */ + rerun: string[]; + /** Task IDs that hit a merge conflict; their committed branch + worktree + * are left intact for manual resolution. Excluded from `rerun` so the + * scheduler does not blindly re-execute conflicting work. */ + conflicts: Record; +} + +/** + * Finalize worktrees that already hold committed, clean work that was never + * merged into main (typically because the loop was interrupted between the + * task commit and the merge/finalize step). + * + * For each task ID: + * - If no worktree exists / is registered → re-run (fresh worktree later). + * - If the worktree has uncommitted edits to TRACKED files (e.g. an + * interrupted agent mid-edit) → re-run, preserving the worktree so + * `createWorktree` reuses it and the agent continues where it left off. + * Untracked files are ignored here — they never block a merge, and a + * worktree whose task work is fully committed is "done" even if it + * carries stray untracked files. Counting `??` entries would strand the + * committed branch in `.ralpi/worktrees/` forever on every resume. + * - If the worktree has no commits ahead of main → re-run. + * - If the worktree has ≥1 commit ahead of main → merge the branch into + * main (`--no-ff`) and report finalized. Fully clean worktrees are then + * removed; worktrees that also carry untracked files are kept so that + * (possibly meaningful) uncommitted files aren't destroyed — the next + * fresh-loop sweep cleans them up. On merge conflict the merge is + * aborted (main left clean), the worktree is preserved, and the task is + * reported in `conflicts`. + * + * This is the self-healing path for an interrupted review-gated loop: + * tasks that finished (commit + review already saved) but never got their + * merge are completed here, so `/ralpi-resume` does not wastefully re-run + * finished work. + */ +export function finalizeCommittedWorktrees( + mainDir: string, + stateDir: string, + prdKey: string, + taskIds: string[], +): FinalizeResult { + mainDir = canonicalDir(mainDir); + const result: FinalizeResult = { finalized: [], rerun: [], conflicts: {} }; + + const mainHead = getGitHead(mainDir); + + for (const taskId of taskIds) { + const wtDir = worktreePath(mainDir, stateDir, prdKey, taskId); + + // No worktree directory on disk → nothing to finalize. + if (!fs.existsSync(wtDir)) { + result.rerun.push(taskId); + continue; + } + + // Confirm the worktree is actually registered with git (not a leftover + // dir from a half-cleaned-up run). If registered but broken, drop its + // metadata so a fresh worktree can be created on re-run. + const list = git("worktree list --porcelain", mainDir) ?? ""; + if (!list.includes(`worktree ${wtDir}`)) { + result.rerun.push(taskId); + continue; + } + + // Broken checkout → re-run (createWorktree will recreate it). + const branch = getCurrentBranch(wtDir); + if (!branch || branch === "HEAD" || branch === "detached") { + result.rerun.push(taskId); + continue; + } + + // Uncommitted edits to TRACKED files (an interrupted agent mid-edit) → + // re-run, keeping the worktree so the agent resumes in place. Untracked + // files alone do NOT count as dirty here (see doc comment above). + if (hasTrackedUncommittedChanges(wtDir)) { + result.rerun.push(taskId); + continue; + } + + // No commits ahead of main → nothing to merge. + const aheadStr = + mainHead !== null + ? git(`rev-list --count ${mainHead}..HEAD`, wtDir) + : null; + const ahead = aheadStr !== null ? parseInt(aheadStr, 10) : 0; + if (Number.isNaN(ahead) || ahead <= 0) { + result.rerun.push(taskId); + continue; + } + + // Committed + no tracked edits → finalize. mergeWorktree aborts on + // conflict, leaving main's working tree clean. + const merge = mergeWorktree(mainDir, branch); + if (merge.success) { + // Remove the worktree only when it's fully clean. If it still carries + // untracked files, keep it so that uncommitted work isn't destroyed + // (the branch is merged; the leftover worktree is swept by the next + // fresh-loop cleanup). + if (!hasUncommittedChanges(wtDir)) { + removeWorktree(mainDir, { dir: wtDir, branch, mainDir }); + } + result.finalized.push(taskId); + continue; + } + + // Conflict — preserve the worktree for manual resolution and report. + result.conflicts[taskId] = merge.conflicts; + } + + git("worktree prune", mainDir); + return result; +} + +/** + * Whether a worktree still holds work worth preserving (committed commits + * ahead of main, or uncommitted changes). Used by the task-failure path so a + * failed/timeout agent's partial output isn't force-deleted with the + * worktree. + */ +export function worktreeHasPreservableWork( + mainDir: string, + wt: WorktreeHandle, +): boolean { + mainDir = canonicalDir(mainDir); + // Any uncommitted changes (tracked edits or untracked files) count — the + // agent may have been mid-write when it failed. + if (hasUncommittedChanges(wt.dir)) return true; + const mainHead = getGitHead(mainDir); + if (!mainHead) return true; + const aheadStr = git(`rev-list --count ${mainHead}..${wt.branch}`, mainDir); + const ahead = aheadStr !== null ? parseInt(aheadStr, 10) : 0; + return !Number.isNaN(ahead) && ahead > 0; +} diff --git a/tests/commit-range-diff.test.ts b/tests/commit-range-diff.test.ts new file mode 100644 index 0000000..990d2dd --- /dev/null +++ b/tests/commit-range-diff.test.ts @@ -0,0 +1,82 @@ +/** + * Tests for the tri-state commit-range diff (src/utils.ts getCommitRangeDiff): + * a FAILED range computation (invalid/stale base ref, git error) must be a + * distinct `error` signal, never collapsed into a clean `no-changes` — a + * broken base ref must never be silently treated as a verified task. + * + * Uses a real throwaway git repo so the shell-out behavior is exercised. + */ + +import { describe, test, expect, beforeAll, afterAll } from "bun:test"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as os from "node:os"; +import { execSync } from "node:child_process"; +import { getCommitRangeDiff } from "../src/utils"; + +let repoDir: string; + +function sh(cmd: string, cwd: string) { + execSync(cmd, { cwd, stdio: "pipe" }); +} + +beforeAll(() => { + repoDir = fs.mkdtempSync(path.join(os.tmpdir(), "ralpi-crd-")); + sh("git init -q", repoDir); + sh("git config user.email test@example.com", repoDir); + sh("git config user.name test", repoDir); + fs.writeFileSync(path.join(repoDir, "a.ts"), "one\n", "utf-8"); + sh("git add -A", repoDir); + sh("git commit -q -m init", repoDir); +}); + +afterAll(() => { + fs.rmSync(repoDir, { recursive: true, force: true }); +}); + +describe("getCommitRangeDiff tri-state", () => { + test("ok: a resolvable base with committed changes yields the diff", () => { + fs.writeFileSync(path.join(repoDir, "a.ts"), "one\ntwo\n", "utf-8"); + sh("git add -A", repoDir); + sh("git commit -q -m change", repoDir); + + const base = execSync("git rev-parse HEAD~1", { + cwd: repoDir, + encoding: "utf-8", + }).trim(); + + const result = getCommitRangeDiff(repoDir, base); + expect(result.kind).toBe("ok"); + if (result.kind === "ok") { + expect(result.diff).toContain("a.ts"); + expect(result.hash.length).toBeGreaterThan(0); + } + }); + + test("error: a fake/unresolvable base ref yields the failure signal, not no-changes", () => { + // 40 hex chars that never existed in this repo. + const fake = "ffffffffffffffffffffffffffffffffffffffff"; + const result = getCommitRangeDiff(repoDir, fake); + expect(result.kind).toBe("error"); + if (result.kind === "error") { + expect(result.error).toContain("cannot be resolved"); + } + }); + + test("error: a non-hex base ref is rejected before reaching the shell", () => { + const result = getCommitRangeDiff(repoDir, "HEAD~1; rm -rf /"); + expect(result.kind).toBe("error"); + if (result.kind === "error") { + expect(result.error).toContain("invalid or stale base ref"); + } + }); + + test("no-changes: an empty range (base == HEAD) yields the no-changes signal", () => { + const head = execSync("git rev-parse HEAD", { + cwd: repoDir, + encoding: "utf-8", + }).trim(); + const result = getCommitRangeDiff(repoDir, head); + expect(result.kind).toBe("no-changes"); + }); +}); diff --git a/tests/dag-construction.test.ts b/tests/dag-construction.test.ts new file mode 100644 index 0000000..7c8b4e8 --- /dev/null +++ b/tests/dag-construction.test.ts @@ -0,0 +1,674 @@ +/// +import { describe, it, expect } from "bun:test"; +import type { Project, Task } from "../src/types"; +import { + buildExecutionPlan, + buildSequentialPlan, + getBlockedTasks, + detectCycles, + getCriticalPath, + formatDependencyChain, + formatExecutionPlan, +} from "../src/dag"; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function makeProject(overrides?: Partial): Project { + return { + tasks: [], + dependencies: {}, + sourcePath: "/tmp/test.md", + sourceDir: "/tmp", + ...overrides, + }; +} + +function task( + id: string, + dependencies: string[] = [], + status: Task["status"] = "pending", + parallelGroup?: number, +): Task { + return { id, title: `Task ${id}`, status, dependencies, parallelGroup }; +} + +function tasksFrom(...args: Task[]): Task[] { + return args; +} + +// ─── Basic DAG Construction ────────────────────────────────────────────────── + +describe("buildExecutionPlan (Kahn's algorithm)", () => { + it("handles empty task list", () => { + const project = makeProject({ tasks: [] }); + const plan = buildExecutionPlan(project, new Set()); + expect(plan.batches).toEqual([]); + expect(plan.totalTasks).toBe(0); + }); + + it("puts all root tasks in batch 0", () => { + const project = makeProject({ + tasks: tasksFrom(task("01"), task("02"), task("03")), + }); + const plan = buildExecutionPlan(project, new Set()); + expect(plan.batches).toHaveLength(1); + expect(plan.batches[0].tasks.map((t) => t.id).sort()).toEqual([ + "01", + "02", + "03", + ]); + }); + + it("builds correct linear dependency chain", () => { + const project = makeProject({ + tasks: tasksFrom( + task("01"), + task("02", ["01"]), + task("03", ["02"]), + task("04", ["03"]), + ), + }); + const plan = buildExecutionPlan(project, new Set()); + expect(plan.batches).toHaveLength(4); + expect(plan.batches[0].tasks.map((t) => t.id)).toEqual(["01"]); + expect(plan.batches[1].tasks.map((t) => t.id)).toEqual(["02"]); + expect(plan.batches[2].tasks.map((t) => t.id)).toEqual(["03"]); + expect(plan.batches[3].tasks.map((t) => t.id)).toEqual(["04"]); + }); + + it("groups parallelizable tasks in the same batch", () => { + // Diamond: 01 -> 02, 03 -> 04 + const project = makeProject({ + tasks: tasksFrom( + task("01"), + task("02", ["01"]), + task("03", ["01"]), + task("04", ["02", "03"]), + ), + }); + const plan = buildExecutionPlan(project, new Set()); + // Batch 0: [01], Batch 1: [02, 03], Batch 2: [04] + expect(plan.batches).toHaveLength(3); + expect(plan.batches[0].tasks.map((t) => t.id)).toEqual(["01"]); + expect(plan.batches[1].tasks.map((t) => t.id).sort()).toEqual(["02", "03"]); + expect(plan.batches[2].tasks.map((t) => t.id)).toEqual(["04"]); + }); + + it("assigns correct batchIndex values", () => { + const project = makeProject({ + tasks: tasksFrom( + task("01"), + task("02", ["01"]), + task("03", ["01"]), + task("04", ["02", "03"]), + ), + }); + const plan = buildExecutionPlan(project, new Set()); + expect(plan.batches[0].batchIndex).toBe(0); + expect(plan.batches[1].batchIndex).toBe(1); + expect(plan.batches[2].batchIndex).toBe(2); + }); + + it("skips completed tasks and includes them in skippedTasks", () => { + const project = makeProject({ + tasks: tasksFrom( + task("01", [], "completed"), + task("02", ["01"]), + task("03", ["02"]), + ), + }); + const plan = buildExecutionPlan(project, new Set(["01"])); + expect(plan.totalTasks).toBe(2); + expect(plan.skippedTasks.map((t) => t.id)).toEqual(["01"]); + expect(plan.batches).toHaveLength(2); + expect(plan.batches[0].tasks.map((t) => t.id)).toEqual(["02"]); + expect(plan.batches[1].tasks.map((t) => t.id)).toEqual(["03"]); + }); + + it("throws on dependency cycle", () => { + const project = makeProject({ + tasks: tasksFrom( + task("01", ["03"]), + task("02", ["01"]), + task("03", ["02"]), + ), + }); + expect(() => buildExecutionPlan(project, new Set())).toThrow( + /dependency cycle/i, + ); + }); + + it("blocks tasks that depend on failed tasks", () => { + const project = makeProject({ + tasks: tasksFrom( + task("01"), + task("02", ["01"]), + task("03", ["02"]), + task("04", ["03"]), + ), + }); + const plan = buildExecutionPlan( + project, + new Set(), + undefined, + new Set(["01"]), + ); + // 01 is excluded from pending (failed). 02, 03, 04 are pending but + // transitively blocked — they don't appear in batches. + expect(plan.skippedTasks.map((t) => t.id)).toEqual(["01"]); + expect(plan.totalTasks).toBe(3); // 02, 03, 04 are pending but blocked + expect(plan.batches).toHaveLength(0); + }); + + it("blocks immediate dependents when task fails", () => { + const project = makeProject({ + tasks: tasksFrom( + task("01"), + task("02", ["01"]), + task("03", ["01"]), + task("04"), // independent + ), + }); + const plan = buildExecutionPlan( + project, + new Set(), + undefined, + new Set(["01"]), + ); + // 01 is excluded from pending (failed). 02, 03 are pending but blocked + // (depend on 01). 04 is independent and ready. + expect(plan.skippedTasks.map((t) => t.id)).toEqual(["01"]); + expect(plan.batches[0].tasks.map((t) => t.id)).toEqual(["04"]); + }); +}); + +// ─── Complex DAGs ─────────────────────────────────────────────────────────── + +describe("Complex DAG batching", () => { + it("builds the OAuth PRD example correctly", () => { + const project = makeProject({ + tasks: tasksFrom( + task("01"), + task("02", ["01"]), + task("03", ["02"]), + task("04", ["01"]), + task("05", ["03", "04"]), + task("06", ["03", "04"]), + task("07", ["03"]), + task("08", ["05", "06", "07"]), + ), + }); + const plan = buildExecutionPlan(project, new Set()); + // Expected batches: [01], [02,04], [03], [05,06,07], [08] + expect(plan.batches).toHaveLength(5); + expect(plan.batches[0].tasks.map((t) => t.id)).toEqual(["01"]); + expect(plan.batches[1].tasks.map((t) => t.id).sort()).toEqual(["02", "04"]); + expect(plan.batches[2].tasks.map((t) => t.id)).toEqual(["03"]); + expect(plan.batches[3].tasks.map((t) => t.id).sort()).toEqual([ + "05", + "06", + "07", + ]); + expect(plan.batches[4].tasks.map((t) => t.id)).toEqual(["08"]); + }); + + it("builds the Design Token PRD example correctly", () => { + const project = makeProject({ + tasks: tasksFrom( + task("01"), + task("02", ["01"]), + task("03", ["01"]), + task("04", ["02", "03"]), + task("05", ["04", "01"]), + ), + }); + const plan = buildExecutionPlan(project, new Set()); + // Expected batches: [01], [02,03], [04], [05] + expect(plan.batches).toHaveLength(4); + expect(plan.batches[0].tasks.map((t) => t.id)).toEqual(["01"]); + expect(plan.batches[1].tasks.map((t) => t.id).sort()).toEqual(["02", "03"]); + expect(plan.batches[2].tasks.map((t) => t.id)).toEqual(["04"]); + expect(plan.batches[3].tasks.map((t) => t.id)).toEqual(["05"]); + }); + + it("handles a 3-tier diamond", () => { + // 01 + // / \ + // 02 03 + // / \ / \ + // 04 05 06 + // \ | / + // 07 + const project = makeProject({ + tasks: tasksFrom( + task("01"), + task("02", ["01"]), + task("03", ["01"]), + task("04", ["02"]), + task("05", ["02", "03"]), + task("06", ["03"]), + task("07", ["04", "05", "06"]), + ), + }); + const plan = buildExecutionPlan(project, new Set()); + expect(plan.batches).toHaveLength(4); + expect(plan.batches[0].tasks.map((t) => t.id)).toEqual(["01"]); + expect(plan.batches[1].tasks.map((t) => t.id).sort()).toEqual(["02", "03"]); + expect(plan.batches[2].tasks.map((t) => t.id).sort()).toEqual([ + "04", + "05", + "06", + ]); + expect(plan.batches[3].tasks.map((t) => t.id)).toEqual(["07"]); + }); + + it("handles a wide fan-out with delayed convergence", () => { + // 01 -> 02,03,04,05,06 + // 02,03 -> 07 + // 04,05 -> 08 + // 06 -> 09 + // 07,08,09 -> 10 + const project = makeProject({ + tasks: tasksFrom( + task("01"), + task("02", ["01"]), + task("03", ["01"]), + task("04", ["01"]), + task("05", ["01"]), + task("06", ["01"]), + task("07", ["02", "03"]), + task("08", ["04", "05"]), + task("09", ["06"]), + task("10", ["07", "08", "09"]), + ), + }); + const plan = buildExecutionPlan(project, new Set()); + expect(plan.batches).toHaveLength(4); + expect(plan.batches[0].tasks.map((t) => t.id)).toEqual(["01"]); + expect(plan.batches[1].tasks.map((t) => t.id).sort()).toEqual([ + "02", + "03", + "04", + "05", + "06", + ]); + expect(plan.batches[2].tasks.map((t) => t.id).sort()).toEqual([ + "07", + "08", + "09", + ]); + expect(plan.batches[3].tasks.map((t) => t.id)).toEqual(["10"]); + }); + + it("handles multiple independent subgraphs", () => { + // Two completely independent chains: + // Chain A: 01 -> 02 -> 03 + // Chain B: 04 -> 05 + const project = makeProject({ + tasks: tasksFrom( + task("01"), + task("02", ["01"]), + task("03", ["02"]), + task("04"), + task("05", ["04"]), + ), + }); + const plan = buildExecutionPlan(project, new Set()); + // Batch 0: [01, 04] (both roots) + // Batch 1: [02, 05] + // Batch 2: [03] + expect(plan.batches[0].tasks.map((t) => t.id).sort()).toEqual(["01", "04"]); + expect(plan.batches[1].tasks.map((t) => t.id).sort()).toEqual(["02", "05"]); + expect(plan.batches[2].tasks.map((t) => t.id)).toEqual(["03"]); + }); + + it("batches tasks respecting fan-in convergence", () => { + // 01 -> 03, 02 -> 03 (03 depends on both 01 AND 02) + const project = makeProject({ + tasks: tasksFrom(task("01"), task("02"), task("03", ["01", "02"])), + }); + const plan = buildExecutionPlan(project, new Set()); + expect(plan.batches[0].tasks.map((t) => t.id).sort()).toEqual(["01", "02"]); + expect(plan.batches[1].tasks.map((t) => t.id)).toEqual(["03"]); + }); +}); + +// ─── Sequential Plan ───────────────────────────────────────────────────────── + +describe("buildSequentialPlan", () => { + it("puts each task in its own batch", () => { + const project = makeProject({ + tasks: tasksFrom(task("01"), task("02", ["01"]), task("03", ["01"])), + }); + const plan = buildSequentialPlan(project, new Set()); + expect(plan.batches).toHaveLength(3); + plan.batches.forEach((b, i) => { + expect(b.tasks).toHaveLength(1); + expect(b.batchIndex).toBe(i); + }); + }); + + it("skips completed tasks and blocks transitively failed tasks", () => { + const project = makeProject({ + tasks: tasksFrom( + task("01"), + task("02", ["01"]), + task("03", ["02"]), + task("04"), + ), + }); + const plan = buildSequentialPlan(project, new Set(["01"]), new Set(["01"])); + // 01 failed => 02, 03 blocked. 04 independent, runs. + expect(plan.skippedTasks.map((t) => t.id).sort()).toEqual([ + "01", + "02", + "03", + ]); + expect(plan.totalTasks).toBe(3); + }); + + it("maintains task order in sequential batches", () => { + const project = makeProject({ + tasks: tasksFrom(task("01"), task("02", ["01"]), task("03", ["01"])), + }); + const plan = buildSequentialPlan(project, new Set()); + expect(plan.batches.map((b) => b.tasks[0].id)).toEqual(["01", "02", "03"]); + }); +}); + +// ─── getBlockedTasks ───────────────────────────────────────────────────────── + +describe("getBlockedTasks", () => { + it("returns direct dependents of failed tasks", () => { + const pending = tasksFrom(task("01"), task("02", ["01"]), task("03")); + const blocked = getBlockedTasks(pending, new Set(["01"])); + expect([...blocked]).toEqual(["02"]); + }); + + it("returns transitive dependents (chain reaction)", () => { + const pending = tasksFrom( + task("01"), + task("02", ["01"]), + task("03", ["02"]), + task("04", ["03"]), + ); + const blocked = getBlockedTasks(pending, new Set(["01"])); + expect([...blocked].sort()).toEqual(["02", "03", "04"]); + }); + + it("does not affect tasks in separate subgraphs", () => { + const pending = tasksFrom( + task("01"), + task("02", ["01"]), + task("10"), + task("11", ["10"]), + ); + const blocked = getBlockedTasks(pending, new Set(["01"])); + expect([...blocked].sort()).toEqual(["02"]); + }); + + it("returns empty set when no tasks depend on failed tasks", () => { + const pending = tasksFrom(task("01"), task("02"), task("03")); + const blocked = getBlockedTasks(pending, new Set(["99"])); + expect(blocked.size).toBe(0); + }); +}); + +// ─── detectCycles ──────────────────────────────────────────────────────────── + +describe("detectCycles", () => { + it("returns empty for acyclic graph", () => { + const project = makeProject({ + tasks: tasksFrom(task("01"), task("02", ["01"]), task("03", ["02"])), + }); + expect(detectCycles(project)).toEqual([]); + }); + + it("detects a 3-node cycle", () => { + const project = makeProject({ + tasks: tasksFrom( + task("01", ["03"]), + task("02", ["01"]), + task("03", ["02"]), + ), + }); + const cycles = detectCycles(project); + expect(cycles.length).toBeGreaterThan(0); + }); + + it("detects a self-loop", () => { + const project = makeProject({ + tasks: tasksFrom(task("01", ["01"])), + }); + const cycles = detectCycles(project); + expect(cycles.length).toBeGreaterThan(0); + }); + + it("detects cycle in disconnected subgraph", () => { + const project = makeProject({ + tasks: tasksFrom( + task("01"), // isolated + task("02", ["03"]), + task("03", ["02"]), // cycle + ), + }); + const cycles = detectCycles(project); + expect(cycles.length).toBeGreaterThan(0); + }); + + it("returns empty for graph with only diamond patterns", () => { + const project = makeProject({ + tasks: tasksFrom( + task("01"), + task("02", ["01"]), + task("03", ["01"]), + task("04", ["02", "03"]), + ), + }); + expect(detectCycles(project)).toEqual([]); + }); +}); + +// ─── getCriticalPath ───────────────────────────────────────────────────────── + +describe("getCriticalPath", () => { + it("returns the longest path through the DAG", () => { + // 01 -> 02 -> 03 -> 04 (long = 4) + // 01 -> 05 -> 04 (short = 3) + const project = makeProject({ + tasks: tasksFrom( + task("01"), + task("02", ["01"]), + task("03", ["02"]), + task("04", ["03", "05"]), + task("05", ["01"]), + ), + }); + const path = getCriticalPath(project); + expect(path.length).toBe(4); + expect(path[0].id).toBe("01"); + expect(path[path.length - 1].id).toBe("04"); + }); + + it("returns single-node path for roots", () => { + const project = makeProject({ + tasks: tasksFrom(task("01"), task("02"), task("03")), + }); + const path = getCriticalPath(project); + expect(path.length).toBe(1); + }); + + it("handles complex branching by picking the longest chain", () => { + // 01 -> 02 -> 03 -> 04 -> 05 (long = 5) + // 01 -> 06 -> 05 (short = 3) + const project = makeProject({ + tasks: tasksFrom( + task("01"), + task("02", ["01"]), + task("03", ["02"]), + task("04", ["03"]), + task("05", ["04", "06"]), + task("06", ["01"]), + ), + }); + const path = getCriticalPath(project); + // Should pick 01 -> 02 -> 03 -> 04 -> 05 (length 5) + expect(path.length).toBe(5); + expect(path.map((t) => t.id)).toEqual(["01", "02", "03", "04", "05"]); + }); +}); + +// ─── formatDependencyChain ─────────────────────────────────────────────────── + +describe("formatDependencyChain", () => { + it("renders a simple tree", () => { + const project = makeProject({ + tasks: tasksFrom(task("01"), task("02", ["01"])), + }); + const formatted = formatDependencyChain(project); + expect(formatted).toContain("01"); + expect(formatted).toContain("02"); + }); + + it("mentions root tasks", () => { + const project = makeProject({ + tasks: tasksFrom(task("01"), task("02")), + }); + const formatted = formatDependencyChain(project); + expect(formatted).toMatch(/01.*root|root.*01/i); + }); + + it("handles empty task list", () => { + const project = makeProject({ tasks: [] }); + const formatted = formatDependencyChain(project); + expect(formatted).toContain("no tasks"); + }); + + it("shows orphan tasks when dependencies reference non-existent IDs", () => { + const project = makeProject({ + tasks: tasksFrom(task("01", ["99"])), + }); + const formatted = formatDependencyChain(project); + expect(formatted).toMatch(/orphan|unreached/i); + }); +}); + +// ─── formatExecutionPlan ───────────────────────────────────────────────────── + +describe("formatExecutionPlan", () => { + it("displays task counts and batches", () => { + const project = makeProject({ + tasks: tasksFrom(task("01"), task("02", ["01"])), + }); + const plan = buildExecutionPlan(project, new Set()); + const formatted = formatExecutionPlan(plan); + expect(formatted).toContain("Total tasks"); + expect(formatted).toContain("Batches"); + expect(formatted).toContain("01"); + expect(formatted).toContain("02"); + }); + + it("shows skipped tasks", () => { + const project = makeProject({ + tasks: tasksFrom(task("01", [], "completed"), task("02", ["01"])), + }); + const plan = buildExecutionPlan(project, new Set(["01"])); + const formatted = formatExecutionPlan(plan); + expect(formatted).toContain("completed"); + }); + + it("shows parallel group annotations when provided", () => { + const project = makeProject({ + tasks: tasksFrom(task("01"), task("02", ["01"]), task("03", ["01"])), + parallelGroups: [{ index: 0, label: "UI sprint", taskIds: ["02", "03"] }], + }); + const plan = buildExecutionPlan(project, new Set()); + const formatted = formatExecutionPlan(plan, project.parallelGroups); + expect(formatted).toContain("UI sprint"); + }); +}); + +// ─── Group-Aware Batching ──────────────────────────────────────────────────── + +describe("Parallel group batching", () => { + it("builds batches when parallel groups are defined", () => { + const project = makeProject({ + tasks: tasksFrom( + task("01"), + task("02", ["01"]), + task("03", ["01"]), + task("04", ["02", "03"]), + ), + parallelGroups: [ + { index: 0, label: "Frontend", taskIds: ["01", "02", "03", "04"] }, + ], + }); + // Should route through buildGroupAwareBatches + const plan = buildExecutionPlan(project, new Set()); + expect(plan.batches.length).toBeGreaterThan(0); + }); + + it("respects intra-group dependencies in parallel groups", () => { + // Tasks: 01 -> 02, 01 -> 03, 02 -> 04, 03 -> 04 + // With parallel groups, there are no cross-group dependencies by definition. + // Intra-group deps are respected by Kahn's algorithm. + const project = makeProject({ + tasks: tasksFrom( + task("01"), + task("02", ["01"]), + task("03", ["01"]), + task("04", ["02", "03"]), + ), + parallelGroups: [ + { index: 0, label: "All", taskIds: ["01", "02", "03", "04"] }, + ], + }); + const plan = buildExecutionPlan(project, new Set()); + // Batch 0: [01], Batch 1: [02, 03], Batch 2: [04] + expect(plan.batches[0].tasks.map((t) => t.id)).toEqual(["01"]); + expect(plan.batches[1].tasks.map((t) => t.id).sort()).toEqual(["02", "03"]); + expect(plan.batches[2].tasks.map((t) => t.id)).toEqual(["04"]); + }); +}); + +// ─── Real-World Scenario: Resume with completed tasks ─────────────────────── + +describe("Real-world resume scenarios", () => { + it("buildExecutionPlan correctly excludes file-based [x] completions", () => { + // Design Token PRD resume: 01,02,03 [x] in file, 04 [~], 05 [ ] + const project = makeProject({ + tasks: tasksFrom( + task("01", [], "completed"), + task("02", ["01"], "completed"), + task("03", ["01"], "completed"), + task("04", ["02", "03"], "in_progress"), + task("05", ["04", "01"], "pending"), + ), + }); + // buildCompletedSet in index.ts produces {01, 02, 03} from file + progress + // This simulates what happens after buildCompletedSet is called + const completedFromFile = new Set( + project.tasks.filter((t) => t.status === "completed").map((t) => t.id), + ); + const plan = buildExecutionPlan(project, completedFromFile); + + // Only 04 and 05 should be pending + expect(plan.totalTasks).toBe(2); + expect(plan.batches).toHaveLength(2); + expect(plan.batches[0].tasks.map((t) => t.id)).toEqual(["04"]); + expect(plan.batches[1].tasks.map((t) => t.id)).toEqual(["05"]); + }); + + it("skipsTasks includes both progress-completed and file-completed tasks", () => { + const project = makeProject({ + tasks: tasksFrom( + task("01", [], "completed"), + task("02", ["01"], "pending"), + ), + }); + // Simulate: 01 completed in file AND in progress + const plan = buildExecutionPlan(project, new Set(["01"])); + expect(plan.skippedTasks.map((t) => t.id)).toEqual(["01"]); + expect(plan.batches[0].tasks.map((t) => t.id)).toEqual(["02"]); + }); +}); diff --git a/tests/diff.test.ts b/tests/diff.test.ts new file mode 100644 index 0000000..1da2ee2 --- /dev/null +++ b/tests/diff.test.ts @@ -0,0 +1,237 @@ +/** + * Tests for the noise-filtered diff engine (src/diff.ts). + * Covers: per-file +/− parsing, excluded-file split, totals excluding noise, + * malformed-chunk guard, isExcluded reasons, and configurable overrides. + */ + +import { describe, test, expect } from "bun:test"; +import { + parseDiff, + filterNoise, + isExcluded, + compileIgnorePatterns, + EXCLUDED_PATTERNS, +} from "../src/diff"; + +/** A synthetic unified diff mixing code, a lockfile, a minified file, and a binary. */ +const SYNTH_DIFF = [ + "diff --git a/src/index.ts b/src/index.ts", + "index 111..222 100644", + "--- a/src/index.ts", + "+++ b/src/index.ts", + "@@ -1,2 +1,4 @@", + ' import { foo } from "./foo";', + "+export const baz = 1;", + "+export const qux = 2;", + "-foo();", + "+bar();", + "", + "diff --git a/package-lock.json b/package-lock.json", + "index 000..111 100644", + "--- a/package-lock.json", + "+++ b/package-lock.json", + "@@ -0,0 +1,3 @@", + "+{", + '+ "name": "x"', + "+}", + "", + "diff --git a/dist/foo.min.js b/dist/foo.min.js", + "index 111..222 100644", + "--- a/dist/foo.min.js", + "+++ b/dist/foo.min.js", + "@@ -1 +1 @@", + "-var a=1;", + "+var a=2;", + "", + "diff --git a/assets/logo.png b/assets/logo.png", + "index 111..222 100644", + "Binary files differ", +].join("\n"); + +describe("parseDiff", () => { + test("splits included vs excluded files and totals only included", () => { + const summary = parseDiff(SYNTH_DIFF); + + // Included: only src/index.ts (code). Lockfile, minified, binary excluded. + expect(summary.files).toHaveLength(1); + expect(summary.files[0]).toEqual({ + path: "src/index.ts", + linesAdded: 3, + linesRemoved: 1, + ext: "ts", + }); + + expect(summary.excluded).toHaveLength(3); + const byPath = new Map( + summary.excluded.map((f) => [f.path, f]), + ); + expect(byPath.get("package-lock.json")).toMatchObject({ + linesAdded: 3, + linesRemoved: 0, + reason: "lockfile", + }); + expect(byPath.get("dist/foo.min.js")).toMatchObject({ + linesAdded: 1, + linesRemoved: 1, + reason: "minified asset", + }); + expect(byPath.get("assets/logo.png")).toMatchObject({ + linesAdded: 0, + linesRemoved: 0, + reason: "binary/media asset", + }); + + // Totals exclude the noise files. + expect(summary.totalAdded).toBe(3); + expect(summary.totalRemoved).toBe(1); + }); + + test("returns empty summary for an empty diff", () => { + const summary = parseDiff(""); + expect(summary.files).toHaveLength(0); + expect(summary.excluded).toHaveLength(0); + expect(summary.totalAdded).toBe(0); + expect(summary.totalRemoved).toBe(0); + }); + + test("skips malformed chunks without a/… b/ header without crashing", () => { + const malformed = + "diff --git weird-line\nindex 111..222\n--- a/x\n+++ b/x\n+x\n" + + "\n" + + "diff --git a/src/ok.ts b/src/ok.ts\n--- a/src/ok.ts\n+++ b/src/ok.ts\n+ok\n"; + const summary = parseDiff(malformed); + // Only the well-formed chunk is counted. + expect(summary.files).toHaveLength(1); + expect(summary.files[0].path).toBe("src/ok.ts"); + expect(summary.totalAdded).toBe(1); + }); + + test("does not count +++/--- header lines as additions/removals", () => { + const diff = [ + "diff --git a/src/a.ts b/src/a.ts", + "--- a/src/a.ts", + "+++ b/src/a.ts", + "@@ -0,0 +1,2 @@", + "+one", + "+two", + ].join("\n"); + const summary = parseDiff(diff); + expect(summary.files[0].linesAdded).toBe(2); + expect(summary.files[0].linesRemoved).toBe(0); + }); +}); + +describe("isExcluded", () => { + test("returns the right reason per pattern", () => { + expect(isExcluded("package-lock.json")).toBe("lockfile"); + expect(isExcluded("yarn.lock")).toBe("lockfile"); + expect(isExcluded("src/app.min.js")).toBe("minified asset"); + expect(isExcluded("src/styles.min.css")).toBe("minified asset"); + expect(isExcluded("build/out.js")).toBe("build output"); + expect(isExcluded("node_modules/foo/index.js")).toBe("dependency"); + expect(isExcluded("vendor/lib.bundle.js")).toBe("vendored dependency"); + expect(isExcluded("assets/icon.svg")).toBe("binary/media asset"); + expect(isExcluded("src/api.generated.ts")).toBe("generated file"); + expect(isExcluded("test/__snapshots__/x.snap")).toBe("snapshot"); + expect(isExcluded("dist/x.js.map")).toBe("source map"); + }); + + test("returns undefined for review-relevant files", () => { + expect(isExcluded("src/foo.ts")).toBeUndefined(); + expect(isExcluded("src/index.ts")).toBeUndefined(); + }); + + test("merges caller-supplied extra patterns", () => { + expect(isExcluded("src/data.foo", [/\.foo$/])).toBe("extra ignore pattern"); + expect(isExcluded("src/data.foo")).toBeUndefined(); + }); + + test("EXCLUDED_PATTERNS covers lockfiles, min, generated, snap, map, build, vendor, binaries", () => { + for (const pat of [ + "package-lock.json", + "src/app.min.js", + "src/thing.generated.ts", + "x.snap", + "x.js.map", + "dist/bundle.js", + "node_modules/a/b.js", + "vendor/x", + "a.png", + "f.woff2", + ]) { + const hit = EXCLUDED_PATTERNS.some((r) => r.pattern.test(pat)); + expect(hit, `${pat} should be covered by a default rule`).toBe(true); + } + }); +}); + +describe("filterNoise", () => { + test("re-emits only included-file chunks", () => { + const filtered = filterNoise(SYNTH_DIFF); + expect(filtered).toContain("diff --git a/src/index.ts"); + expect(filtered).not.toContain("package-lock.json"); + expect(filtered).not.toContain("foo.min.js"); + expect(filtered).not.toContain("logo.png"); + }); + + test("returns empty when every file is noise", () => { + const onlyNoise = [ + "diff --git a/package-lock.json b/package-lock.json", + "--- a/package-lock.json", + "+++ b/package-lock.json", + "+x", + ].join("\n"); + expect(filterNoise(onlyNoise)).toBe(""); + }); +}); + +describe("configurable noise rules", () => { + test("extraPatterns excludes a matching file from the review diff", () => { + const diff = [ + "diff --git a/src/foo.ts b/src/foo.ts", + "--- a/src/foo.ts", + "+++ b/src/foo.ts", + "+keep", + "diff --git a/src/data.foo b/src/data.foo", + "--- a/src/data.foo", + "+++ b/src/data.foo", + "+drop", + ].join("\n"); + const opts = { extraPatterns: compileIgnorePatterns(["\\.foo$"]) }; + const summary = parseDiff(diff, opts); + expect(summary.files.map((f) => f.path)).toEqual(["src/foo.ts"]); + expect(summary.excluded.map((f) => [f.path, f.reason])).toEqual([ + ["src/data.foo", "extra ignore pattern"], + ]); + expect(filterNoise(diff, opts)).not.toContain("data.foo"); + }); + + test("ignorePaths keeps an excluded-by-default file in scope", () => { + const diff = [ + "diff --git a/package-lock.json b/package-lock.json", + "--- a/package-lock.json", + "+++ b/package-lock.json", + "+a", + "+b", + "+c", + ].join("\n"); + const opts = { ignorePaths: ["package-lock.json"] }; + const summary = parseDiff(diff, opts); + expect(summary.files).toHaveLength(1); + expect(summary.files[0].path).toBe("package-lock.json"); + expect(summary.excluded).toHaveLength(0); + expect(summary.totalAdded).toBe(3); + expect(filterNoise(diff, opts)).toContain("package-lock.json"); + }); + + test("default behavior unchanged when overrides are unset", () => { + const summary = parseDiff(SYNTH_DIFF); + expect(summary.files[0].path).toBe("src/index.ts"); + expect(summary.totalAdded).toBe(3); + }); + + test("compileIgnorePatterns skips invalid regexes", () => { + const compiled = compileIgnorePatterns(["\\.foo$", "(", "ok$"]); + expect(compiled.length).toBe(2); + }); +}); diff --git a/tests/gitignore-hygiene.test.ts b/tests/gitignore-hygiene.test.ts new file mode 100644 index 0000000..a113a4f --- /dev/null +++ b/tests/gitignore-hygiene.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "bun:test"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { tempDir } from "./helpers"; +import { ensureRalpiIgnored } from "../src/utils"; + +// ─── Gitignore hygiene: ensureRalpiIgnored ────────────────────────────────── + +describe("ensureRalpiIgnored", () => { + it("creates .gitignore with .ralpi/ when absent in a git work tree", () => { + const { dir, cleanup } = tempDir(); + try { + fs.mkdirSync(path.join(dir, ".git")); + expect(ensureRalpiIgnored(dir)).toBe(true); + const content = fs.readFileSync(path.join(dir, ".gitignore"), "utf8"); + expect(content).toContain(".ralpi/"); + } finally { + cleanup(); + } + }); + + it("appends .ralpi/ to an existing .gitignore without the marker", () => { + const { dir, cleanup } = tempDir(); + try { + fs.mkdirSync(path.join(dir, ".git")); + fs.writeFileSync( + path.join(dir, ".gitignore"), + "node_modules/\n*.log\n", + "utf8", + ); + expect(ensureRalpiIgnored(dir)).toBe(true); + const content = fs.readFileSync(path.join(dir, ".gitignore"), "utf8"); + expect(content).toContain("node_modules/"); + expect(content).toContain(".ralpi/"); + } finally { + cleanup(); + } + }); + + it("leaves a .gitignore with the marker untouched", () => { + const { dir, cleanup } = tempDir(); + try { + fs.mkdirSync(path.join(dir, ".git")); + fs.writeFileSync(path.join(dir, ".gitignore"), ".ralpi/\n", "utf8"); + expect(ensureRalpiIgnored(dir)).toBe(false); + expect(fs.readFileSync(path.join(dir, ".gitignore"), "utf8")).toBe( + ".ralpi/\n", + ); + } finally { + cleanup(); + } + }); + + it("is a no-op outside a git work tree", () => { + const { dir, cleanup } = tempDir(); + try { + expect(ensureRalpiIgnored(dir)).toBe(false); + expect(fs.existsSync(path.join(dir, ".gitignore"))).toBe(false); + } finally { + cleanup(); + } + }); + + it("is memoized per project dir", () => { + const { dir, cleanup } = tempDir(); + try { + fs.mkdirSync(path.join(dir, ".git")); + expect(ensureRalpiIgnored(dir)).toBe(true); + // Second call: same dir already handled → no further work. + expect(ensureRalpiIgnored(dir)).toBe(false); + fs.writeFileSync(path.join(dir, ".gitignore"), "old\n", "utf8"); + expect(ensureRalpiIgnored(dir)).toBe(false); + expect(fs.readFileSync(path.join(dir, ".gitignore"), "utf8")).toBe( + "old\n", + ); + } finally { + cleanup(); + } + }); + + it("works when .git is a file (linked git worktree)", () => { + const { dir, cleanup } = tempDir(); + try { + fs.writeFileSync( + path.join(dir, ".git"), + "gitdir: /some/shared/repo\n", + "utf8", + ); + expect(ensureRalpiIgnored(dir)).toBe(true); + const content = fs.readFileSync(path.join(dir, ".gitignore"), "utf8"); + expect(content).toContain(".ralpi/"); + } finally { + cleanup(); + } + }); +}); \ No newline at end of file diff --git a/tests/helpers.ts b/tests/helpers.ts new file mode 100644 index 0000000..03c11cb --- /dev/null +++ b/tests/helpers.ts @@ -0,0 +1,30 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as os from "node:os"; + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +/** + * Create a temporary directory for test files. + * Returns the path and a cleanup function. + */ +export function tempDir(): { dir: string; cleanup: () => void } { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ralpi-test-")); + return { + dir, + cleanup: () => fs.rmSync(dir, { recursive: true, force: true }), + }; +} + +/** + * Write content to a temp markdown file and return its path. + */ +export function writeTaskFile( + dir: string, + name: string, + content: string, +): string { + const filePath = path.join(dir, name); + fs.writeFileSync(filePath, content, "utf-8"); + return filePath; +} diff --git a/tests/parser-dag.test.ts b/tests/parser-dag.test.ts new file mode 100644 index 0000000..63603e8 --- /dev/null +++ b/tests/parser-dag.test.ts @@ -0,0 +1,1119 @@ +/** + * Comprehensive tests for ralpi's parser (dependency formats) and DAG construction. + * + * Run: bun test tests/parser-dag.test.ts + * + * Covers all supported dependency declaration formats: + * - Arrow notation (->, →) — single, multi-target, multi-source, chained + * - Natural language "depends on" / "depend on" / "also depends on" + * - "must be done before" — single→multi, multi→single, multi→multi + * - "can be done in parallel" — with and without labels + * - Mixed formats in one file + * - DAG construction (Kahn's algorithm) — batching, cycle detection, critical path + * - buildCompletedSet integration + * - Blocked tasks (transitive) + * - Edge cases, negative tests + */ +import { describe, test, expect } from "bun:test"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { parseTaskFile } from "../src/parser"; +import { + buildExecutionPlan, + buildSequentialPlan, + detectCycles, + getBlockedTasks, + getCriticalPath, + getReadyTasks, +} from "../src/dag"; +import type { Task, Project, ExecutionPlan } from "../src/types"; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** Parse a markdown string as if it were a task file, returning the Project. */ +function parseMD(content: string, name = "test-prd.md"): Project { + const dir = fs.mkdtempSync("/tmp/ralpi-test-"); + const filePath = path.join(dir, name); + fs.writeFileSync(filePath, content, "utf-8"); + try { + return parseTaskFile(filePath); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +/** Build an execution plan from a Project, marking specified IDs as completed. */ +function plan( + project: Project, + completedIds: string[] = [], + failedIds: string[] = [], +): ExecutionPlan { + return buildExecutionPlan( + project, + new Set(completedIds), + undefined, + new Set(failedIds), + ); +} + +/** Extract batch IDs for easy assertion: [[id1,id2], [id3], ...] */ +function batchIds(plan: ExecutionPlan): string[][] { + return plan.batches.map((b) => b.tasks.map((t) => t.id).sort()); +} + +/** Find a task by ID in a Project. */ +function findTask(project: Project, id: string): Task { + const t = project.tasks.find((t) => t.id === id); + if (!t) throw new Error(`Task ${id} not found`); + return t; +} + +// ───────────────────────────────────────────────────────────────────────────── +// ARROW NOTATION +// ───────────────────────────────────────────────────────────────────────────── + +describe("Arrow notation (->)", () => { + test("single arrow: 01 -> 02", () => { + const md = `# Test +## Tasks +- [ ] 01 — task-a +- [ ] 02 — task-b +## Dependencies +01 -> 02`; + const project = parseMD(md); + expect(findTask(project, "01").dependencies).toEqual([]); + expect(findTask(project, "02").dependencies).toEqual(["01"]); + }); + + test("multi-target: 01 -> 02,03,06", () => { + const md = `# Test +## Tasks +- [ ] 01 — task-a +- [ ] 02 — task-b +- [ ] 03 — task-c +- [ ] 06 — task-f +## Dependencies +01 -> 02,03,06`; + const project = parseMD(md); + expect(findTask(project, "01").dependencies).toEqual([]); + expect(findTask(project, "02").dependencies).toEqual(["01"]); + expect(findTask(project, "03").dependencies).toEqual(["01"]); + expect(findTask(project, "06").dependencies).toEqual(["01"]); + }); + + test("multi-source: 05,07,08 -> 13", () => { + const md = `# Test +## Tasks +- [ ] 05 — task-e +- [ ] 07 — task-g +- [ ] 08 — task-h +- [ ] 13 — task-m +## Dependencies +05, 07, 08 -> 13`; + const project = parseMD(md); + expect(findTask(project, "13").dependencies).toEqual(["05", "07", "08"]); + }); + + test("chained: 03 -> 04 -> 05", () => { + const md = `# Test +## Tasks +- [ ] 03 — task-c +- [ ] 04 — task-d +- [ ] 05 — task-e +## Dependencies +03 -> 04 -> 05`; + const project = parseMD(md); + expect(findTask(project, "04").dependencies).toEqual(["03"]); + expect(findTask(project, "05").dependencies).toEqual(["04"]); + }); + + test("with markdown list prefix: - 01 -> 02,03", () => { + const md = `# Test +## Tasks +- [ ] 01 — task-a +- [ ] 02 — task-b +- [ ] 03 — task-c +## Dependencies +- 01 -> 02,03`; + const project = parseMD(md); + expect(findTask(project, "02").dependencies).toEqual(["01"]); + expect(findTask(project, "03").dependencies).toEqual(["01"]); + }); + + test("unicode arrow (→): 01 → 02", () => { + const md = `# Test +## Tasks +- [ ] 01 — task-a +- [ ] 02 — task-b +## Dependencies +01 → 02`; + const project = parseMD(md); + expect(findTask(project, "02").dependencies).toEqual(["01"]); + }); + + test("chained with unicode arrows: A → B → C", () => { + const md = `# Test +## Tasks +- [ ] 01 — setup +- [ ] 02 — build +- [ ] 03 — deploy +## Dependencies +01 → 02 → 03`; + const project = parseMD(md); + expect(findTask(project, "02").dependencies).toEqual(["01"]); + expect(findTask(project, "03").dependencies).toEqual(["02"]); + }); + + test("multi-source multi-target: 01,02 -> 03,04", () => { + const md = `# Test +## Tasks +- [ ] 01 — a +- [ ] 02 — b +- [ ] 03 — c +- [ ] 04 — d +## Dependencies +01, 02 -> 03, 04`; + const project = parseMD(md); + expect(findTask(project, "03").dependencies).toEqual(["01", "02"]); + expect(findTask(project, "04").dependencies).toEqual(["01", "02"]); + }); + + test("unpadded task IDs still pad to 2 digits", () => { + const md = `# Test +## Tasks +- [ ] 1 — task-a +- [ ] 2 — task-b +- [ ] 3 — task-c +## Dependencies +1 -> 2, 3`; + const project = parseMD(md); + expect(findTask(project, "01").dependencies).toEqual([]); + expect(findTask(project, "02").dependencies).toEqual(["01"]); + expect(findTask(project, "03").dependencies).toEqual(["01"]); + }); + + test("arrow with parenthetical comment is stripped", () => { + const md = `# Test +## Tasks +- [ ] 01 — a +- [ ] 02 — b +- [ ] 03 — c +## Dependencies +- 01 -> 02, 03 (core dependency)`; + const project = parseMD(md); + expect(findTask(project, "02").dependencies).toEqual(["01"]); + expect(findTask(project, "03").dependencies).toEqual(["01"]); + }); + + test("non-numeric text after arrow is ignored", () => { + const md = `# Test +## Tasks +- [ ] 01 — a +- [ ] 02 — b +## Dependencies +01 -> some-text-here`; + const project = parseMD(md); + expect(findTask(project, "02").dependencies).toEqual([]); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// NATURAL LANGUAGE "depends on" +// ───────────────────────────────────────────────────────────────────────────── + +describe('Natural language "depends on"', () => { + test("single task depends on single", () => { + const md = `# Test +## Tasks +- [ ] 01 — a +- [ ] 02 — b +## Dependencies +02 depends on 01`; + const project = parseMD(md); + expect(findTask(project, "02").dependencies).toEqual(["01"]); + }); + + test("task depends on multiple: 13 depends on 17, 18, 19, 20", () => { + const md = `# Test +## Tasks +- [ ] 13 — task-m +- [ ] 17 — task-q +- [ ] 18 — task-r +- [ ] 19 — task-s +- [ ] 20 — task-t +## Dependencies +13 depends on 17, 18, 19, 20`; + const project = parseMD(md); + expect(findTask(project, "13").dependencies).toEqual([ + "17", + "18", + "19", + "20", + ]); + }); + + test('multiple tasks depend on one: "depend on" (plural)', () => { + const md = `# Test +## Tasks +- [ ] 02 — b +- [ ] 03 — c +- [ ] 04 — d +- [ ] 05 — e +## Dependencies +04, 05 depend on 02, 03`; + const project = parseMD(md); + expect(findTask(project, "04").dependencies).toEqual(["02", "03"]); + expect(findTask(project, "05").dependencies).toEqual(["02", "03"]); + }); + + test("also depends on", () => { + const md = `# Test +## Tasks +- [ ] 05 — e +- [ ] 06 — f +- [ ] 08 — h +## Dependencies +08 also depends on 05, 06`; + const project = parseMD(md); + expect(findTask(project, "08").dependencies).toEqual(["05", "06"]); + }); + + test("with markdown list prefix", () => { + const md = `# Test +## Tasks +- [ ] 13 — m +- [ ] 17 — q +- [ ] 18 — r +- [ ] 19 — s +## Dependencies +- 13 depends on 17, 18, 19`; + const project = parseMD(md); + expect(findTask(project, "13").dependencies).toEqual(["17", "18", "19"]); + }); + + test("with parenthetical description", () => { + const md = `# Test +## Tasks +- [ ] 21 — setup-db +- [ ] 22 — write-queries +- [ ] 23 — build-api +## Dependencies +- 22 depends on 21 (database schema must exist) +- 23 depends on 22 (API builds on queries)`; + const project = parseMD(md); + expect(findTask(project, "22").dependencies).toEqual(["21"]); + expect(findTask(project, "23").dependencies).toEqual(["22"]); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// "must be done before" +// ───────────────────────────────────────────────────────────────────────────── + +describe('"must be done before"', () => { + test("single must be done before multiple", () => { + const md = `# Test +## Tasks +- [ ] 21 — backend-foundation +- [ ] 22 — api-endpoints +- [ ] 23 — database-migrations +- [ ] 24 — integration-tests +## Dependencies +21 must be done before 22, 23, 24`; + const project = parseMD(md); + expect(findTask(project, "21").dependencies).toEqual([]); + expect(findTask(project, "22").dependencies).toEqual(["21"]); + expect(findTask(project, "23").dependencies).toEqual(["21"]); + expect(findTask(project, "24").dependencies).toEqual(["21"]); + }); + + test("multiple must be done before single", () => { + const md = `# Test +## Tasks +- [ ] 02 — design +- [ ] 03 — review +- [ ] 04 — implement +## Dependencies +02, 03 must be done before 04`; + const project = parseMD(md); + expect(findTask(project, "04").dependencies).toEqual(["02", "03"]); + }); + + test("with markdown list prefix and parenthetical", () => { + const md = `# Test +## Tasks +- [ ] 21 — backend +- [ ] 22 — api +- [ ] 23 — db +- [ ] 24 — tests +## Dependencies +- 21 must be done before 22, 23, 24 (backend integration foundation)`; + const project = parseMD(md); + expect(findTask(project, "22").dependencies).toEqual(["21"]); + expect(findTask(project, "23").dependencies).toEqual(["21"]); + expect(findTask(project, "24").dependencies).toEqual(["21"]); + }); + + test("multi must be done before multi", () => { + const md = `# Test +## Tasks +- [ ] 01 — env +- [ ] 02 — config +- [ ] 03 — api-v1 +- [ ] 04 — api-v2 +## Dependencies +01, 02 must be done before 03, 04`; + const project = parseMD(md); + expect(findTask(project, "03").dependencies).toEqual(["01", "02"]); + expect(findTask(project, "04").dependencies).toEqual(["01", "02"]); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// "can be done in parallel" +// ───────────────────────────────────────────────────────────────────────────── + +describe('"can be done in parallel"', () => { + test("basic parallel group without label", () => { + const md = `# Test +## Tasks +- [ ] 02 — design +- [ ] 03 — auth +- [ ] 04 — storage +## Dependencies +02, 03, 04 can be done in parallel`; + const project = parseMD(md); + expect(project.parallelGroups).toBeDefined(); + expect(project.parallelGroups!.length).toBe(1); + expect(project.parallelGroups![0].taskIds.sort()).toEqual([ + "02", + "03", + "04", + ]); + expect(project.parallelGroups![0].label).toBeUndefined(); + }); + + test("parallel group with label", () => { + const md = `# Test +## Tasks +- [ ] 01 — a +- [ ] 02 — b +- [ ] 03 — c +- [ ] 04 — d +## Dependencies +01, 02, 03, 04 can be done in parallel (Play Store prep)`; + const project = parseMD(md); + expect(project.parallelGroups![0].label).toBe("Play Store prep"); + expect(project.parallelGroups![0].taskIds.sort()).toEqual([ + "01", + "02", + "03", + "04", + ]); + }); + + test("parallel group sets parallelGroup field on tasks", () => { + const md = `# Test +## Tasks +- [ ] 01 — a +- [ ] 02 — b +- [ ] 03 — c +## Dependencies +01, 02, 03 can be done in parallel`; + const project = parseMD(md); + expect(findTask(project, "01").parallelGroup).toBe(0); + expect(findTask(project, "02").parallelGroup).toBe(0); + expect(findTask(project, "03").parallelGroup).toBe(0); + }); + + test("multiple parallel groups with labels", () => { + const md = `# Test +## Tasks +- [ ] 01 — a +- [ ] 02 — b +- [ ] 03 — c +- [ ] 04 — d +- [ ] 05 — e +- [ ] 06 — f +## Dependencies +01, 02 can be done in parallel (frontend) +03, 04, 05 can be done in parallel (backend) +06 depends on 01, 03`; + const project = parseMD(md); + expect(project.parallelGroups!.length).toBe(2); + expect(project.parallelGroups![0].label).toBe("frontend"); + expect(project.parallelGroups![1].label).toBe("backend"); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// MIXED FORMATS in one file +// ───────────────────────────────────────────────────────────────────────────── + +describe("Mixed dependency formats", () => { + test("arrows + depends-on + must-before in one file", () => { + const md = `# iOS OAuth Sign-In + +Objective: Add Google and Apple OAuth sign-in options + +Status legend: [ ] todo, [~] in-progress, [x] done + +## Tasks +- [~] 01 — oauth-flow-research +- [~] 02 — clerkapi-oauth-methods +- [ ] 03 — authservice-oauth-methods +- [ ] 04 — oauth-button-component +- [ ] 05 — update-signin-view +- [ ] 06 — update-signup-view +- [ ] 07 — session-handling +- [ ] 08 — integration-tests + +## Dependencies +- 02 depends on 01 +- 03 depends on 02 +- 01 -> 04 +- 05 must be done before 06 +- 07 depends on 03 +- 08 depends on 05, 06, 07`; + const project = parseMD(md); + expect(findTask(project, "02").dependencies).toEqual(["01"]); + expect(findTask(project, "03").dependencies).toEqual(["02"]); + expect(findTask(project, "04").dependencies).toEqual(["01"]); + expect(findTask(project, "06").dependencies).toEqual(["05"]); + expect(findTask(project, "07").dependencies).toEqual(["03"]); + expect(findTask(project, "08").dependencies).toEqual(["05", "06", "07"]); + }); + + test("parallel groups mixed with dependencies", () => { + const md = `# Full Project + +## Tasks +- [ ] 01 — env-setup +- [ ] 02 — db-schema +- [ ] 03 — api-core +- [ ] 04 — frontend-shell +- [ ] 05 — auth-module +- [ ] 06 — user-dashboard +- [ ] 07 — admin-panel +- [ ] 08 — integration-tests +- [ ] 09 — deploy + +## Dependencies +01 -> 02, 03 +02 -> 05 +03 -> 05 +04 -> 06, 07 +05 -> 06, 07 +06, 07 can be done in parallel (user-facing work) +08 must be done before 09 +05 depends on 02, 03 +06 depends on 04, 05`; + const project = parseMD(md); + expect(findTask(project, "02").dependencies).toEqual(["01"]); + expect(findTask(project, "03").dependencies).toEqual(["01"]); + expect(findTask(project, "05").dependencies).toEqual(["02", "03"]); + expect(findTask(project, "06").dependencies).toEqual(["04", "05"]); + expect(findTask(project, "07").dependencies.sort()).toEqual(["04", "05"]); + expect(findTask(project, "09").dependencies).toEqual(["08"]); + expect(project.parallelGroups).toBeDefined(); + expect(project.parallelGroups!.length).toBe(1); + expect(project.parallelGroups![0].taskIds.sort()).toEqual(["06", "07"]); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// DAG CONSTRUCTION (Kahn's Algorithm) +// ───────────────────────────────────────────────────────────────────────────── + +describe("DAG construction (Kahn's algorithm)", () => { + test("simple linear chain produces sequential batches", () => { + const md = `# Test +## Tasks +- [ ] 01 — a +- [ ] 02 — b +- [ ] 03 — c +## Dependencies +01 -> 02 -> 03`; + const p = plan(parseMD(md)); + expect(batchIds(p)).toEqual([["01"], ["02"], ["03"]]); + }); + + test("diamond dependency produces 3 batches", () => { + const md = `# Test +## Tasks +- [ ] 01 — setup +- [ ] 02 — frontend +- [ ] 03 — backend +- [ ] 04 — integration +## Dependencies +01 -> 02, 03 +02 -> 04 +03 -> 04`; + const p = plan(parseMD(md)); + // 01 first, then 02+03 in parallel, then 04 + expect(batchIds(p)).toEqual([["01"], ["02", "03"], ["04"]]); + }); + + test("fan-out: one task gates many", () => { + const md = `# Test +## Tasks +- [ ] 01 — foundation +- [ ] 02 — feature-a +- [ ] 03 — feature-b +- [ ] 04 — feature-c +## Dependencies +01 -> 02, 03, 04`; + const p = plan(parseMD(md)); + expect(batchIds(p)).toEqual([["01"], ["02", "03", "04"]]); + }); + + test("fan-in: many converge on one", () => { + const md = `# Test +## Tasks +- [ ] 01 — data +- [ ] 02 — ui +- [ ] 03 — api +- [ ] 04 — integration +## Dependencies +01, 02, 03 -> 04`; + const p = plan(parseMD(md)); + expect(batchIds(p)).toEqual([["01", "02", "03"], ["04"]]); + }); + + test("complex DAG with multiple dependency chains", () => { + // 01 + // / \ + // 02 03 + // | | + // 04 05 + // \ / + // 06 + // | + // 07 + const md = `# Test +## Tasks +- [ ] 01 — a +- [ ] 02 — b +- [ ] 03 — c +- [ ] 04 — d +- [ ] 05 — e +- [ ] 06 — f +- [ ] 07 — g +## Dependencies +01 -> 02, 03 +02 -> 04 +03 -> 05 +04, 05 -> 06 +06 -> 07`; + const p = plan(parseMD(md)); + expect(batchIds(p)).toEqual([ + ["01"], + ["02", "03"], + ["04", "05"], + ["06"], + ["07"], + ]); + }); + + test("no dependencies = all in one batch", () => { + // Content without ## Dependencies — uses simple checkbox parsing + // Simple checkbox assigns auto-incrementing IDs starting from "00" + const project = parseMD(`# Test\n- [ ] 01 — a\n- [ ] 02 — b\n- [ ] 03 — c`); + const p = plan(project); + // Simple checkbox: no dependencies, so all pending = all ready = one batch + expect(batchIds(p)).toEqual([["00", "01", "02"]]); + }); + + test("completed tasks are excluded from batches", () => { + const md = `# Test +## Tasks +- [x] 01 — setup (done) +- [ ] 02 — build +- [ ] 03 — test +## Dependencies +01 -> 02 -> 03`; + const project = parseMD(md); + // 01 is [x] in file, buildCompletedSet would include it + const p = buildExecutionPlan( + project, + new Set(["01"]), // completed + ); + expect(batchIds(p)).toEqual([["02"], ["03"]]); + expect(p.totalTasks).toBe(2); + }); + + test("all completed = empty plan", () => { + const md = `# Test +## Tasks +- [x] 01 — a +- [x] 02 — b +## Dependencies +01 -> 02`; + const project = parseMD(md); + const p = buildExecutionPlan(project, new Set(["01", "02"])); + expect(batchIds(p)).toEqual([]); + expect(p.totalTasks).toBe(0); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// SEQUENTIAL PLAN +// ───────────────────────────────────────────────────────────────────────────── + +describe("Sequential plan", () => { + test("each batch has exactly one task", () => { + const md = `# Test +## Tasks +- [ ] 01 — a +- [ ] 02 — b +- [ ] 03 — c +## Dependencies +01 -> 02 -> 03`; + const project = parseMD(md); + const p = buildSequentialPlan(project, new Set()); + expect(p.batches.length).toBe(3); + for (const b of p.batches) { + expect(b.tasks.length).toBe(1); + } + }); + + test("sequential plan respects dependency order", () => { + const md = `# Test +## Tasks +- [ ] 01 — a +- [ ] 02 — b +- [ ] 03 — c +## Dependencies +01 -> 02 -> 03`; + const project = parseMD(md); + const p = buildSequentialPlan(project, new Set()); + expect(p.batches[0].tasks[0].id).toBe("01"); + expect(p.batches[1].tasks[0].id).toBe("02"); + expect(p.batches[2].tasks[0].id).toBe("03"); + }); + + test("sequential excludes completed tasks", () => { + const md = `# Test +## Tasks +- [ ] 01 — a +- [ ] 02 — b +- [ ] 03 — c +## Dependencies +01 -> 02 -> 03`; + const project = parseMD(md); + const p = buildSequentialPlan(project, new Set(["01"])); + expect(p.batches.length).toBe(2); + expect(p.batches[0].tasks[0].id).toBe("02"); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// CYCLE DETECTION +// ───────────────────────────────────────────────────────────────────────────── + +describe("Cycle detection", () => { + test("no cycle = empty array", () => { + const md = `# Test +## Tasks +- [ ] 01 — a +- [ ] 02 — b +## Dependencies +01 -> 02`; + const project = parseMD(md); + expect(detectCycles(project)).toEqual([]); + }); + + test("direct cycle: A -> B -> A", () => { + const md = `# Test +## Tasks +- [ ] 01 — a +- [ ] 02 — b +## Dependencies +01 -> 02 +02 -> 01`; + const project = parseMD(md); + const cycles = detectCycles(project); + expect(cycles.length).toBeGreaterThan(0); + }); + + test("indirect cycle: A -> B -> C -> A", () => { + const md = `# Test +## Tasks +- [ ] 01 — a +- [ ] 02 — b +- [ ] 03 — c +## Dependencies +01 -> 02 +02 -> 03 +03 -> 01`; + const project = parseMD(md); + const cycles = detectCycles(project); + expect(cycles.length).toBeGreaterThan(0); + }); + + test("self-loop: A -> A", () => { + const md = `# Test +## Tasks +- [ ] 01 — a +## Dependencies +01 -> 01`; + const project = parseMD(md); + const cycles = detectCycles(project); + expect(cycles.length).toBeGreaterThan(0); + }); + + test("buildExecutionPlan throws on cycle", () => { + const md = `# Test +## Tasks +- [ ] 01 — a +- [ ] 02 — b +## Dependencies +01 -> 02 +02 -> 01`; + const project = parseMD(md); + expect(() => buildExecutionPlan(project, new Set())).toThrow(/cycle/i); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// BLOCKED TASKS +// ───────────────────────────────────────────────────────────────────────────── + +describe("Blocked tasks", () => { + test("direct dependent blocked", () => { + const tasks: Task[] = [ + { id: "01", title: "a", status: "pending", dependencies: [] }, + { id: "02", title: "b", status: "pending", dependencies: ["01"] }, + { id: "03", title: "c", status: "pending", dependencies: ["02"] }, + ]; + const blocked = getBlockedTasks(tasks, new Set(["01"])); + expect(blocked.has("02")).toBe(true); + }); + + test("transitive blocking", () => { + const tasks: Task[] = [ + { id: "01", title: "a", status: "pending", dependencies: [] }, + { id: "02", title: "b", status: "pending", dependencies: ["01"] }, + { id: "03", title: "c", status: "pending", dependencies: ["02"] }, + { id: "04", title: "d", status: "pending", dependencies: [] }, + ]; + const blocked = getBlockedTasks(tasks, new Set(["01"])); + expect(blocked.has("02")).toBe(true); + expect(blocked.has("03")).toBe(true); + expect(blocked.has("04")).toBe(false); // independent + }); + + test("no failed = nothing blocked", () => { + const tasks: Task[] = [ + { id: "01", title: "a", status: "pending", dependencies: [] }, + { id: "02", title: "b", status: "pending", dependencies: ["01"] }, + ]; + const blocked = getBlockedTasks(tasks, new Set()); + expect(blocked.size).toBe(0); + }); + + test("buildExecutionPlan excludes blocked tasks from batches", () => { + const md = `# Test +## Tasks +- [ ] 01 — a +- [ ] 02 — b +- [ ] 03 — c +## Dependencies +01 -> 02 -> 03`; + const project = parseMD(md); + const p = buildExecutionPlan( + project, + new Set(), + undefined, + new Set(["01"]), + ); + // 02 and 03 are blocked because 01 failed. 01 is excluded (failed). + // 02 and 03 remain pending but don't appear in batches. + expect(batchIds(p)).toEqual([]); + expect(p.totalTasks).toBe(2); // 02, 03 are pending (blocked) + }); + + test("blocked with diamond: failing root blocks everything downstream", () => { + const md = `# Test +## Tasks +- [ ] 01 — setup +- [ ] 02 — frontend +- [ ] 03 — backend +- [ ] 04 — integration +## Dependencies +01 -> 02, 03 +02 -> 04 +03 -> 04`; + const project = parseMD(md); + const p = buildExecutionPlan( + project, + new Set(), + undefined, + new Set(["01"]), + ); + // All tasks are blocked since 01 failed + expect(batchIds(p)).toEqual([]); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// CRITICAL PATH +// ───────────────────────────────────────────────────────────────────────────── + +describe("Critical path", () => { + test("linear chain critical path is the whole chain", () => { + const md = `# Test +## Tasks +- [ ] 01 — a +- [ ] 02 — b +- [ ] 03 — c +## Dependencies +01 -> 02 -> 03`; + const project = parseMD(md); + const cp = getCriticalPath(project); + expect(cp.map((t) => t.id)).toEqual(["01", "02", "03"]); + }); + + test("diamond: critical path is the longer chain", () => { + const md = `# Test +## Tasks +- [ ] 01 — setup +- [ ] 02 — short +- [ ] 03 — long +- [ ] 04 — end +## Dependencies +01 -> 02 -> 04 +01 -> 03 -> 04`; + // Both chains are length 3, so either is valid + const project = parseMD(md); + const cp = getCriticalPath(project); + expect(cp.length).toBe(3); + expect(cp[0].id).toBe("01"); + }); + + test("fan-out: critical path covers the longest depth", () => { + const md = `# Test +## Tasks +- [ ] 01 — root +- [ ] 02 — a +- [ ] 03 — b +- [ ] 04 — c +- [ ] 05 — d +## Dependencies +01 -> 02, 03, 04 +04 -> 05`; + const project = parseMD(md); + const cp = getCriticalPath(project); + // Critical path: 01 -> 04 -> 05 + expect(cp.map((t) => t.id)).toEqual(["01", "04", "05"]); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// GET READY TASKS +// ───────────────────────────────────────────────────────────────────────────── + +describe("getReadyTasks", () => { + test("root tasks are ready when nothing completed", () => { + const md = `# Test +## Tasks +- [ ] 01 — a +- [ ] 02 — b +- [ ] 03 — c +## Dependencies +01 -> 02 -> 03`; + const project = parseMD(md); + const ready = getReadyTasks(project, new Set()); + expect(ready.map((t) => t.id)).toEqual(["01"]); + }); + + test("task becomes ready when all deps completed", () => { + const md = `# Test +## Tasks +- [ ] 01 — a +- [ ] 02 — b +- [ ] 03 — c +## Dependencies +01 -> 02 -> 03`; + const project = parseMD(md); + const ready = getReadyTasks(project, new Set(["01"])); + expect(ready.map((t) => t.id)).toEqual(["02"]); + }); + + test("all independent tasks are ready", () => { + const project = parseMD(`# Test\n- [ ] 01 — a\n- [ ] 02 — b\n- [ ] 03 — c`); + const ready = getReadyTasks(project, new Set()); + expect(ready.length).toBe(3); + }); + + test("completed task is not ready", () => { + const md = `# Test +## Tasks +- [ ] 01 — a +- [ ] 02 — b +## Dependencies +01 -> 02`; + const project = parseMD(md); + const ready = getReadyTasks(project, new Set(["01"])); + expect(ready.map((t) => t.id)).toEqual(["02"]); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// EDGE CASES & NEGATIVE TESTS +// ───────────────────────────────────────────────────────────────────────────── + +describe("Edge cases", () => { + test("empty dependencies section produces no deps", () => { + const md = `# Test +## Tasks +- [ ] 01 — a +- [ ] 02 — b +## Dependencies`; + const project = parseMD(md); + expect(findTask(project, "01").dependencies).toEqual([]); + expect(findTask(project, "02").dependencies).toEqual([]); + }); + + test("no dependencies section triggers simple checkbox parser", () => { + const md = `# Simple List +- [ ] 01 — task-a +- [ ] 02 — task-b +- [ ] 03 — task-c`; + const project = parseMD(md); + expect(project.tasks.length).toBe(3); + expect(project.dependencies).toEqual({}); + }); + + test("simple checkbox parser assigns sequential zero-padded IDs", () => { + const md = `- [ ] Do something +- [ ] Do something else`; + const project = parseMD(md); + expect(project.tasks[0].id).toBe("00"); + expect(project.tasks[1].id).toBe("01"); + }); + + test("line with non-dep content in ## Dependencies is ignored", () => { + const md = `# Test +## Tasks +- [ ] 01 — a +- [ ] 02 — b +## Dependencies +Some explanatory text that isn't a valid dependency format. +- 01 -> 02`; + const project = parseMD(md); + expect(findTask(project, "02").dependencies).toEqual(["01"]); + }); + + test("comment after 'can be done in parallel' doesn't break parsing", () => { + const md = `# Test +## Tasks +- [ ] 01 — a +- [ ] 02 — b +- [ ] 03 — c +## Dependencies +01, 02, 03 can be done in parallel +01 -> 02 +# some comment`; + const project = parseMD(md); + expect(project.parallelGroups).toBeDefined(); + expect(findTask(project, "02").dependencies).toEqual(["01"]); + }); + + test("exit criteria are extracted", () => { + const md = `# Test +## Tasks +- [ ] 01 — a +## Dependencies +## Exit Criteria +- All tests pass +- Code reviewed +- Deployed to staging`; + const project = parseMD(md); + expect(project.exitCriteria).toEqual([ + "All tests pass", + "Code reviewed", + "Deployed to staging", + ]); + }); + + test("objective extracted from top heading", () => { + const md = `# iOS OAuth Sign-In + +Objective: Add Google and Apple OAuth sign-in options + +## Tasks +- [ ] 01 — research +## Dependencies`; + const project = parseMD(md); + expect(project.objective).toBe("iOS OAuth Sign-In"); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// SECTIONS SEPARATION — content between ## sections is ignored +// ───────────────────────────────────────────────────────────────────────────── + +describe("Section separation", () => { + test("content between ## sections doesn't leak into tasks/deps", () => { + const md = `# Test +## Tasks +- [ ] 01 — a + +Some stray text here that isn't a task + +- [ ] 02 — b + +## Dependencies +01 -> 02 + +Extra text in deps should be ignored`; + const project = parseMD(md); + expect(project.tasks.length).toBe(2); + expect(findTask(project, "02").dependencies).toEqual(["01"]); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// REAL-WORLD SCENARIO: Full PRD with complex deps +// ───────────────────────────────────────────────────────────────────────────── + +describe("Real-world PRD scenario", () => { + test("design token integration with must-before + depends-on + arrows", () => { + const md = `# iOS Design Token Integration + +Objective: Replace hardcoded color/spacing values with centralized design tokens + +## Tasks +- [x] 01 — migrate-domain-colors +- [x] 02 — replace-corner-radius +- [x] 03 — replace-system-color-leakage +- [~] 04 — replace-raw-spacing-values +- [ ] 05 — replace-border-token-props +- [ ] 06 — replace-font-token-props +- [ ] 07 — fix-component-color-variants +- [ ] 08 — fix-dark-mode-utility-class + +## Dependencies +01 -> 02, 03 +02 -> 04 +03 -> 05 +04, 05 -> 06, 07, 08 +06 depends on 07`; + const project = parseMD(md); + expect(findTask(project, "02").dependencies).toEqual(["01"]); + expect(findTask(project, "03").dependencies).toEqual(["01"]); + expect(findTask(project, "04").dependencies).toEqual(["02"]); + expect(findTask(project, "05").dependencies).toEqual(["03"]); + expect(findTask(project, "06").dependencies).toEqual(["04", "05", "07"]); + expect(findTask(project, "07").dependencies).toEqual(["04", "05"]); + expect(findTask(project, "08").dependencies).toEqual(["04", "05"]); + + // Plan with 01,02,03 completed + const p = buildExecutionPlan(project, new Set(["01", "02", "03"])); + // Batch 1: 04, 05 (both deps satisfied) + // Batch 2: 07 (depends on 04,05) + // Batch 3: 06 (depends on 04,05,07), 08 (depends on 04,05) + expect(batchIds(p)).toEqual([["04", "05"], ["07", "08"], ["06"]]); + }); +}); diff --git a/tests/parser-formats.test.ts b/tests/parser-formats.test.ts new file mode 100644 index 0000000..ad05634 --- /dev/null +++ b/tests/parser-formats.test.ts @@ -0,0 +1,1321 @@ +/// +import { describe, it, expect } from "bun:test"; +import { parseTaskFile } from "../src/parser"; +import { tempDir, writeTaskFile } from "./helpers"; + +// ─── Helper ────────────────────────────────────────────────────────────────── + +/** Parse a task file from an inline template literal. */ +function parse(content: string) { + const { dir, cleanup } = tempDir(); + try { + const filePath = writeTaskFile(dir, "README.md", content); + return { project: parseTaskFile(filePath), cleanup }; + } catch (e) { + cleanup(); + throw e; + } +} + +/** Assert that task with `id` has the exact set of dependency IDs. */ +function expectDeps(content: string, id: string, expectedDeps: string[]) { + const { project, cleanup } = parse(content); + try { + const task = project.tasks.find((t) => t.id === id); + if (!task) throw new Error(`Task ${id} not found`); + expect(task.dependencies.sort()).toEqual([...expectedDeps].sort()); + } finally { + cleanup(); + } +} + +// ─── Helpers for constructing header + task table ──────────────────────────── + +const FIO_HEADER = `# Test Project + +## Tasks`; + +const FIO_FOOTER = `## Dependencies`; + +// ─── Arrow Notation Tests ──────────────────────────────────────────────────── + +describe("Arrow notation (`->`)", () => { + it("parses basic single dependency", () => { + const content = `${FIO_HEADER} +- [ ] 01 — Task one +- [ ] 02 — Task two + +${FIO_FOOTER} +- 01 -> 02 +`; + const { project, cleanup } = parse(content); + try { + expect(project.tasks).toHaveLength(2); + const t1 = project.tasks.find((t) => t.id === "01")!; + const t2 = project.tasks.find((t) => t.id === "02")!; + expect(t1.dependencies).toEqual([]); + expect(t2.dependencies).toEqual(["01"]); + } finally { + cleanup(); + } + }); + + it("parses multi-target arrows (one source, many targets)", () => { + expectDeps( + `${FIO_HEADER} +- [ ] 01 — Task one +- [ ] 02 — Task two +- [ ] 03 — Task three + +${FIO_FOOTER} +- 01 -> 02, 03 +`, + "02", + ["01"], + ); + }); + + it("parses chained arrows (A -> B -> C)", () => { + const content = `${FIO_HEADER} +- [ ] 01 — Task one +- [ ] 02 — Task two +- [ ] 03 — Task three + +${FIO_FOOTER} +- 01 -> 02 -> 03 +`; + const { project, cleanup } = parse(content); + try { + expect(project.tasks.find((t) => t.id === "02")!.dependencies).toEqual([ + "01", + ]); + expect(project.tasks.find((t) => t.id === "03")!.dependencies).toEqual([ + "02", + ]); + } finally { + cleanup(); + } + }); + + it("parses chained arrows with multi-target forks", () => { + const content = `${FIO_HEADER} +- [ ] 01 — Task one +- [ ] 02 — Task two +- [ ] 03 — Task three +- [ ] 04 — Task four + +${FIO_FOOTER} +- 01 -> 02, 03 -> 04 +`; + const { project, cleanup } = parse(content); + try { + // 01 -> 02,03: 02 and 03 depend on 01 + expect(project.tasks.find((t) => t.id === "02")!.dependencies).toEqual([ + "01", + ]); + expect(project.tasks.find((t) => t.id === "03")!.dependencies).toEqual([ + "01", + ]); + // 02, 03 -> 04: 04 depends on BOTH 02 and 03 (chained multi-target fork) + expect( + project.tasks.find((t) => t.id === "04")!.dependencies.sort(), + ).toEqual(["02", "03"]); + } finally { + cleanup(); + } + }); + + it("parses unicode arrow (→)", () => { + const content = `${FIO_HEADER} +- [ ] 01 — Task one +- [ ] 02 — Task two + +${FIO_FOOTER} +- 01 → 02 +`; + const { project, cleanup } = parse(content); + try { + expect(project.tasks.find((t) => t.id === "02")!.dependencies).toEqual([ + "01", + ]); + } finally { + cleanup(); + } + }); + + it("parses arrows with parenthetical descriptions", () => { + expectDeps( + `${FIO_HEADER} +- [ ] 01 — Domain colors +- [ ] 02 — Corner radius +- [ ] 03 — Color leakage +- [ ] 04 — Raw spacing + +${FIO_FOOTER} +- 01 -> 02 (SemanticColors tokens must exist before views consume them) +- 01 -> 03 (Color tokens needed for system color replacement) +- 02 -> 04 (independent — sequential for clean git history) +- 03 -> 04 (independent — sequential for clean git history) +`, + "02", + ["01"], + ); + }); + + it("parses multi-source, multi-target arrows", () => { + const content = `${FIO_HEADER} +- [ ] 01 — Research +- [ ] 02 — API design +- [ ] 03 — Implementation +- [ ] 04 — Review +- [ ] 05 — Merge + +${FIO_FOOTER} +- 01, 02, 03 -> 04 -> 05 +`; + const { project, cleanup } = parse(content); + try { + // 01,02,03 -> 04: 04 depends on 01,02,03 + expect( + project.tasks.find((t) => t.id === "04")!.dependencies.sort(), + ).toEqual(["01", "02", "03"]); + // 04 -> 05: 05 depends on 04 + expect(project.tasks.find((t) => t.id === "05")!.dependencies).toEqual([ + "04", + ]); + } finally { + cleanup(); + } + }); +}); + +// ─── "depends on" Format Tests ─────────────────────────────────────────────── + +describe('"depends on" format', () => { + it("parses basic 'X depends on Y'", () => { + expectDeps( + `${FIO_HEADER} +- [ ] 01 — OAuth research +- [ ] 02 — Clerk API + +${FIO_FOOTER} +- 02 depends on 01 +`, + "02", + ["01"], + ); + }); + + it("parses 'X depends on Y, Z' (multi-dependency)", () => { + expectDeps( + `${FIO_HEADER} +- [ ] 01 — Sign-in design +- [ ] 02 — Sign-up design +- [ ] 03 — OAuth buttons +- [ ] 04 — Reuse buttons + +${FIO_FOOTER} +- 04 depends on 01, 02, 03 +`, + "04", + ["01", "02", "03"], + ); + }); + + it("parses 'X also depends on Y'", () => { + const content = `${FIO_HEADER} +- [ ] 01 — Foundation +- [ ] 02 — Feature A +- [ ] 03 — Feature B +- [ ] 04 — Integration + +${FIO_FOOTER} +- 04 depends on 02, 03 +- 04 also depends on 01 +`; + const { project, cleanup } = parse(content); + try { + expect( + project.tasks.find((t) => t.id === "04")!.dependencies.sort(), + ).toEqual(["01", "02", "03"]); + } finally { + cleanup(); + } + }); + + it("parses many depends-on lines forming a full DAG", () => { + const content = `${FIO_HEADER} +- [ ] 01 — OAuth research +- [ ] 02 — Clerk API methods +- [ ] 03 — AuthService methods +- [ ] 04 — OAuth button +- [ ] 05 — Update sign-in +- [ ] 06 — Update sign-up +- [ ] 07 — Callback handler +- [ ] 08 — Integration tests + +${FIO_FOOTER} +- 02 depends on 01 +- 03 depends on 02 +- 04 depends on 01 +- 05 depends on 03, 04 +- 06 depends on 03, 04 +- 07 depends on 03 +- 08 depends on 05, 06, 07 +`; + const { project, cleanup } = parse(content); + try { + expect(project.tasks.find((t) => t.id === "01")!.dependencies).toEqual( + [], + ); + expect(project.tasks.find((t) => t.id === "02")!.dependencies).toEqual([ + "01", + ]); + expect(project.tasks.find((t) => t.id === "03")!.dependencies).toEqual([ + "02", + ]); + expect(project.tasks.find((t) => t.id === "04")!.dependencies).toEqual([ + "01", + ]); + expect( + project.tasks.find((t) => t.id === "05")!.dependencies.sort(), + ).toEqual(["03", "04"]); + expect( + project.tasks.find((t) => t.id === "06")!.dependencies.sort(), + ).toEqual(["03", "04"]); + expect(project.tasks.find((t) => t.id === "07")!.dependencies).toEqual([ + "03", + ]); + expect( + project.tasks.find((t) => t.id === "08")!.dependencies.sort(), + ).toEqual(["05", "06", "07"]); + } finally { + cleanup(); + } + }); +}); + +// ─── "depend on" (Plural) Format Tests ─────────────────────────────────────── + +describe('"depend on" (plural) format', () => { + it("parses 'X, Y depend on Z'", () => { + const content = `${FIO_HEADER} +- [ ] 01 — Foundation +- [ ] 02 — Feature A +- [ ] 03 — Feature B + +${FIO_FOOTER} +- 02, 03 depend on 01 +`; + const { project, cleanup } = parse(content); + try { + expect(project.tasks.find((t) => t.id === "02")!.dependencies).toEqual([ + "01", + ]); + expect(project.tasks.find((t) => t.id === "03")!.dependencies).toEqual([ + "01", + ]); + } finally { + cleanup(); + } + }); + + it("parses 'X, Y depend on Z, W' (multi-source, multi-dependency)", () => { + const content = `${FIO_HEADER} +- [ ] 01 — Foundation +- [ ] 02 — API +- [ ] 03 — Feature A +- [ ] 04 — Feature B +- [ ] 05 — Integration + +${FIO_FOOTER} +- 03, 04, 05 depend on 01, 02 +`; + const { project, cleanup } = parse(content); + try { + expect( + project.tasks.find((t) => t.id === "03")!.dependencies.sort(), + ).toEqual(["01", "02"]); + expect( + project.tasks.find((t) => t.id === "04")!.dependencies.sort(), + ).toEqual(["01", "02"]); + expect( + project.tasks.find((t) => t.id === "05")!.dependencies.sort(), + ).toEqual(["01", "02"]); + } finally { + cleanup(); + } + }); + + it("handles mixed singular/plural across different lines", () => { + const content = `${FIO_HEADER} +- [ ] 01 — Research +- [ ] 02 — API design +- [ ] 03 — Implementation +- [ ] 04 — Tests + +${FIO_FOOTER} +- 02 depends on 01 +- 03, 04 depend on 02 +`; + const { project, cleanup } = parse(content); + try { + expect(project.tasks.find((t) => t.id === "02")!.dependencies).toEqual([ + "01", + ]); + expect(project.tasks.find((t) => t.id === "03")!.dependencies).toEqual([ + "02", + ]); + expect(project.tasks.find((t) => t.id === "04")!.dependencies).toEqual([ + "02", + ]); + } finally { + cleanup(); + } + }); +}); + +// ─── "must be done before" Format Tests ───────────────────────────────────── + +describe('"must be done before" format', () => { + it("parses 'X must be done before Y'", () => { + expectDeps( + `${FIO_HEADER} +- [ ] 01 — Setup +- [ ] 02 — Build + +${FIO_FOOTER} +- 01 must be done before 02 +`, + "02", + ["01"], + ); + }); + + it("parses 'X must be done before Y, Z' (multi-target)", () => { + const content = `${FIO_HEADER} +- [ ] 01 — Foundation +- [ ] 02 — Feature A +- [ ] 03 — Feature B + +${FIO_FOOTER} +- 01 must be done before 02, 03 +`; + const { project, cleanup } = parse(content); + try { + expect(project.tasks.find((t) => t.id === "02")!.dependencies).toEqual([ + "01", + ]); + expect(project.tasks.find((t) => t.id === "03")!.dependencies).toEqual([ + "01", + ]); + } finally { + cleanup(); + } + }); + + it("parses 'X, Y must be done before Z' (multi-source)", () => { + expectDeps( + `${FIO_HEADER} +- [ ] 01 — Auth +- [ ] 02 — Billing +- [ ] 03 — Dashboard + +${FIO_FOOTER} +- 01, 02 must be done before 03 +`, + "03", + ["01", "02"], + ); + }); + + it("parses 'must be done before' with parenthetical labels", () => { + expectDeps( + `${FIO_HEADER} +- [ ] 21 — Backend integration +- [ ] 22 — API routes +- [ ] 23 — Database schema +- [ ] 24 — Frontend components + +${FIO_FOOTER} +- 21 must be done before 22, 23, 24 (backend integration foundation) +`, + "22", + ["21"], + ); + }); +}); + +// ─── Parallel Groups Format Tests ──────────────────────────────────────────── + +describe("Parallel groups format", () => { + it("parses 'X, Y can be done in parallel'", () => { + const content = `${FIO_HEADER} +- [ ] 01 — Research +- [ ] 02 — API +- [ ] 03 — UI +- [ ] 04 — Tests + +${FIO_FOOTER} +- 01, 02, 03, 04 can be done in parallel +`; + const { project, cleanup } = parse(content); + try { + expect(project.parallelGroups).toBeDefined(); + expect(project.parallelGroups!).toHaveLength(1); + expect(project.parallelGroups![0].taskIds.sort()).toEqual([ + "01", + "02", + "03", + "04", + ]); + } finally { + cleanup(); + } + }); + + it("parses parallel groups with labels", () => { + const content = `${FIO_HEADER} +- [ ] 01 — Play Store listing +- [ ] 02 — Screenshots +- [ ] 03 — Privacy policy +- [ ] 04 — Rating prompts + +${FIO_FOOTER} +- 01, 02, 03, 04 can be done in parallel (Play Store prep) +`; + const { project, cleanup } = parse(content); + try { + expect(project.parallelGroups).toBeDefined(); + expect(project.parallelGroups![0].label).toBe("Play Store prep"); + } finally { + cleanup(); + } + }); + + it("assigns parallelGroup index to tasks", () => { + const content = `${FIO_HEADER} +- [ ] 01 — Research +- [ ] 02 — API +- [ ] 03 — UI + +${FIO_FOOTER} +- 01, 02, 03 can be done in parallel +`; + const { project, cleanup } = parse(content); + try { + for (const t of project.tasks) { + expect(t.parallelGroup).toBe(0); + } + } finally { + cleanup(); + } + }); +}); + +// ─── YAML Format Tests ─────────────────────────────────────────────────────── + +describe("YAML task file format", () => { + function parseYaml(content: string) { + const { dir, cleanup } = tempDir(); + const filePath = writeTaskFile(dir, "tasks.yaml", content); + return { project: parseTaskFile(filePath), cleanup }; + } + + it("parses basic YAML tasks", () => { + const content = `tasks: + - id: "01" + title: Research OAuth flows + status: pending + - id: "02" + title: Implement Clerk API methods + status: pending + depends_on: ["01"] +`; + const { project, cleanup } = parseYaml(content); + try { + expect(project.tasks).toHaveLength(2); + const t2 = project.tasks.find((t) => t.id === "02")!; + expect(t2.dependencies).toEqual(["01"]); + } finally { + cleanup(); + } + }); + + it("parses YAML with dependencies (dependencies key)", () => { + const content = `tasks: + - id: "01" + title: Foundation + - id: "02" + title: Feature A + dependencies: ["01"] + - id: "03" + title: Feature B + dependencies: ["01"] + - id: "04" + title: Integration + dependencies: ["02", "03"] +`; + const { project, cleanup } = parseYaml(content); + try { + expect( + project.tasks.find((t) => t.id === "04")!.dependencies.sort(), + ).toEqual(["02", "03"]); + } finally { + cleanup(); + } + }); + + it("parses YAML with exit criteria and objective", () => { + const content = `objective: Complete OAuth integration +exit_criteria: + - Users can sign in with Google + - Users can sign in with Apple +tasks: + - id: "01" + title: Research +`; + const { project, cleanup } = parseYaml(content); + try { + expect(project.objective).toBe("Complete OAuth integration"); + expect(project.exitCriteria).toEqual([ + "Users can sign in with Google", + "Users can sign in with Apple", + ]); + } finally { + cleanup(); + } + }); +}); + +// ─── Mixed Format Tests ────────────────────────────────────────────────────── + +describe("Mixed format files", () => { + it("handles arrow + depends-on arrows mixed in same file", () => { + const content = `${FIO_HEADER} +- [ ] 01 — Research +- [ ] 02 — Design +- [ ] 03 — Implement +- [ ] 04 — Test +- [ ] 05 — Deploy + +${FIO_FOOTER} +- 02 depends on 01 +- 03 -> 04 +- 04 -> 05 +`; + const { project, cleanup } = parse(content); + try { + expect(project.tasks.find((t) => t.id === "02")!.dependencies).toEqual([ + "01", + ]); + expect(project.tasks.find((t) => t.id === "03")!.dependencies).toEqual( + [], + ); + expect(project.tasks.find((t) => t.id === "04")!.dependencies).toEqual([ + "03", + ]); + expect(project.tasks.find((t) => t.id === "05")!.dependencies).toEqual([ + "04", + ]); + } finally { + cleanup(); + } + }); + + it("handles must-be-done-before + depends-on mixed", () => { + const content = `${FIO_HEADER} +- [ ] 10 — Scaffold +- [ ] 11 — Backend +- [ ] 12 — Frontend +- [ ] 13 — Auth +- [ ] 14 — Deploy + +${FIO_FOOTER} +- 10 must be done before 11, 12 +- 13 depends on 11, 12 +- 14 depends on 13 +`; + const { project, cleanup } = parse(content); + try { + expect(project.tasks.find((t) => t.id === "11")!.dependencies).toEqual([ + "10", + ]); + expect(project.tasks.find((t) => t.id === "12")!.dependencies).toEqual([ + "10", + ]); + expect( + project.tasks.find((t) => t.id === "13")!.dependencies.sort(), + ).toEqual(["11", "12"]); + expect(project.tasks.find((t) => t.id === "14")!.dependencies).toEqual([ + "13", + ]); + } finally { + cleanup(); + } + }); +}); + +// ─── Simple Checkbox Format (Fallback) Tests ───────────────────────────────── + +describe("Simple checkbox format (fallback)", () => { + it("parses simple checkboxes when no ## Dependencies section", () => { + const content = `# Todo +- [ ] Buy groceries +- [x] Walk the dog +- [~] Do laundry +- [!] Fix bug +`; + const { project, cleanup } = parse(content); + try { + expect(project.tasks).toHaveLength(4); + expect(project.tasks[0].status).toBe("pending"); + expect(project.tasks[1].status).toBe("completed"); + expect(project.tasks[2].status).toBe("in_progress"); + expect(project.tasks[3].status).toBe("failed"); + expect(project.dependencies).toEqual({}); + } finally { + cleanup(); + } + }); +}); + +// ─── Edge Cases ────────────────────────────────────────────────────────────── + +describe("Edge cases", () => { + it("parses a file with no dependencies section", () => { + const content = `${FIO_HEADER} +- [ ] 01 — Solo task +`; + const { project, cleanup } = parse(content); + try { + expect(project.tasks).toHaveLength(1); + expect(project.tasks[0].dependencies).toEqual([]); + } finally { + cleanup(); + } + }); + + it("parses a file with mixed task status characters", () => { + const content = `${FIO_HEADER} +- [ ] 01 — Pending +- [~] 02 — In progress +- [x] 03 — Completed +- [!] 04 — Failed +- [-] 05 — Skipped + +${FIO_FOOTER} +`; + const { project, cleanup } = parse(content); + try { + expect(project.tasks.find((t) => t.id === "01")!.status).toBe("pending"); + expect(project.tasks.find((t) => t.id === "02")!.status).toBe( + "in_progress", + ); + expect(project.tasks.find((t) => t.id === "03")!.status).toBe( + "completed", + ); + expect(project.tasks.find((t) => t.id === "04")!.status).toBe("failed"); + expect(project.tasks.find((t) => t.id === "05")!.status).toBe("skipped"); + } finally { + cleanup(); + } + }); + + it("preserves exit criteria content", () => { + const content = `${FIO_HEADER} +- [ ] 01 — Task + +${FIO_FOOTER} + +## Exit Criteria +- Users can sign in with Google +- All tests pass +- No regressions +`; + const { project, cleanup } = parse(content); + try { + expect(project.exitCriteria).toBeDefined(); + expect(project.exitCriteria).toHaveLength(3); + expect(project.exitCriteria![0]).toBe("Users can sign in with Google"); + } finally { + cleanup(); + } + }); + + it("extracts the objective from the H1 heading", () => { + const content = `# iOS OAuth Sign-In + +Objective: Add Google and Apple OAuth + +## Tasks +- [ ] 01 — Research + +## Dependencies +`; + const { project, cleanup } = parse(content); + try { + expect(project.objective).toBe("iOS OAuth Sign-In"); + } finally { + cleanup(); + } + }); + + it("does not confuse 'depends on' inside a parenthetical comment", () => { + expectDeps( + `${FIO_HEADER} +- [ ] 01 — Setup +- [ ] 02 — Feature + +${FIO_FOOTER} +- 01 -> 02 (this depends on the setup being complete) +`, + "02", + ["01"], + ); + }); +}); + +// ─── Complex / Large DAG Tests ─────────────────────────────────────────────── + +describe("Complex dependency scenarios", () => { + it("parses a 20-task diamond with multiple layers", () => { + // Diamond: 01 feeds two middle layers which converge + const lines: string[] = [`${FIO_HEADER}`]; + for (let i = 1; i <= 20; i++) { + lines.push(`- [ ] ${String(i).padStart(2, "0")} — Task ${i}`); + } + lines.push("", `${FIO_FOOTER}`); + + // 01 -> 02..10 (left chain) and 01 -> 11..19 (right chain) + // 10 -> 20, 19 -> 20 + const leftIds = Array.from({ length: 9 }, (_, i) => + String(i + 2).padStart(2, "0"), + ); // 02-10 + const rightIds = Array.from({ length: 9 }, (_, i) => + String(i + 11).padStart(2, "0"), + ); // 11-19 + lines.push(`- 01 -> ${leftIds.join(", ")}`); + lines.push(`- 01 -> ${rightIds.join(", ")}`); + lines.push(`- 10, 19 -> 20`); + + const content = lines.join("\n"); + const { project, cleanup } = parse(content); + try { + expect(project.tasks).toHaveLength(20); + // All left-branch tasks depend on 01 + for (const id of leftIds) { + expect(project.tasks.find((t) => t.id === id)!.dependencies).toEqual([ + "01", + ]); + } + // All right-branch tasks depend on 01 + for (const id of rightIds) { + expect(project.tasks.find((t) => t.id === id)!.dependencies).toEqual([ + "01", + ]); + } + // Task 20 depends on 10 and 19 + expect( + project.tasks.find((t) => t.id === "20")!.dependencies.sort(), + ).toEqual(["10", "19"]); + } finally { + cleanup(); + } + }); + + it("parses a multi-level fan-out/fan-in DAG", () => { + const content = `${FIO_HEADER} +- [ ] 01 — Foundation +- [ ] 02 — Module A +- [ ] 03 — Module B +- [ ] 04 — Module C +- [ ] 05 — Component A1 +- [ ] 06 — Component A2 +- [ ] 07 — Component B1 +- [ ] 08 — Component B2 +- [ ] 09 — Component C1 +- [ ] 10 — Integration A +- [ ] 11 — Integration B +- [ ] 12 — Integration C +- [ ] 13 — System test +- [ ] 14 — Deploy + +${FIO_FOOTER} +- 01 -> 02, 03, 04 +- 02 -> 05, 06 +- 03 -> 07, 08 +- 04 -> 09 +- 05, 06 -> 10 +- 07, 08 -> 11 +- 09 -> 12 +- 10, 11, 12 -> 13 +- 13 -> 14 +`; + const { project, cleanup } = parse(content); + try { + expect( + project.tasks.find((t) => t.id === "10")!.dependencies.sort(), + ).toEqual(["05", "06"]); + expect( + project.tasks.find((t) => t.id === "11")!.dependencies.sort(), + ).toEqual(["07", "08"]); + expect(project.tasks.find((t) => t.id === "12")!.dependencies).toEqual([ + "09", + ]); + expect( + project.tasks.find((t) => t.id === "13")!.dependencies.sort(), + ).toEqual(["10", "11", "12"]); + expect(project.tasks.find((t) => t.id === "14")!.dependencies).toEqual([ + "13", + ]); + } finally { + cleanup(); + } + }); + + it("parses all formats mixed into one complex file", () => { + const content = `${FIO_HEADER} +- [ ] 01 — Config setup +- [ ] 02 — Database schema +- [ ] 03 — API routes +- [ ] 04 — Auth middleware +- [ ] 05 — Frontend shell +- [ ] 06 — User model +- [ ] 07 — Login page +- [ ] 08 — Dashboard +- [ ] 09 — Tests +- [ ] 10 — Deploy + +${FIO_FOOTER} +- 01 -> 02, 03, 04 (foundational layers) +- 05, 06 depend on 02, 03 +- 07 depends on 04, 06 +- 08 must be done before 09 +- 06, 07, 08 can be done in parallel (UI sprint) +- 09 -> 10 (quality gate before deploy) +`; + const { project, cleanup } = parse(content); + try { + // Arrow + expect(project.tasks.find((t) => t.id === "02")!.dependencies).toEqual([ + "01", + ]); + expect(project.tasks.find((t) => t.id === "03")!.dependencies).toEqual([ + "01", + ]); + expect(project.tasks.find((t) => t.id === "04")!.dependencies).toEqual([ + "01", + ]); + // depends on (plural) + expect( + project.tasks.find((t) => t.id === "05")!.dependencies.sort(), + ).toEqual(["02", "03"]); + expect( + project.tasks.find((t) => t.id === "06")!.dependencies.sort(), + ).toEqual(["02", "03"]); + // depends on (singular) + expect( + project.tasks.find((t) => t.id === "07")!.dependencies.sort(), + ).toEqual(["04", "06"]); + // must be done before + expect(project.tasks.find((t) => t.id === "09")!.dependencies).toEqual([ + "08", + ]); + // arrow again + expect(project.tasks.find((t) => t.id === "10")!.dependencies).toEqual([ + "09", + ]); + // parallel groups + expect(project.parallelGroups).toBeDefined(); + const uiSprint = project.parallelGroups!.find( + (g) => g.label === "UI sprint", + ); + expect(uiSprint).toBeDefined(); + expect(uiSprint!.taskIds.sort()).toEqual(["06", "07", "08"]); + } finally { + cleanup(); + } + }); +}); + +// ─── Plain Section Headings (without ##) ────────────────────────────────── + +describe("Plain section headings (no ##)", () => { + it("parses plain 'Tasks' and 'Dependencies' headings (the OAuth PRD format)", () => { + const content = `# iOS OAuth Sign-In\n\nObjective: Add Google and Apple OAuth sign-in options\n\nStatus legend: [ ] todo, [~] in-progress, [x] done\n\nTasks\n- [~] 01 — oauth-flow-research\n- [~] 02 — clerkapi-oauth-methods\n- [ ] 03 — authservice-oauth-methods\n- [ ] 04 — oauth-button-component\n- [ ] 05 — update-signin-view\n- [ ] 06 — update-signup-view\n- [ ] 07 — oauth-callback-handler\n- [ ] 08 — oauth-integration-tests\n\nDependencies\n- 02 depends on 01\n- 03 depends on 02\n- 04 depends on 01\n- 05 depends on 03, 04\n- 06 depends on 03, 04\n- 07 depends on 03\n- 08 depends on 05, 06, 07\n\nExit criteria\n- Users can sign in with Google account\n- Users can sign in with Apple account\n`; + const { project, cleanup } = parse(content); + try { + expect(project.tasks).toHaveLength(8); + expect(project.tasks.find((t) => t.id === "01")!.dependencies).toEqual( + [], + ); + expect(project.tasks.find((t) => t.id === "02")!.dependencies).toEqual([ + "01", + ]); + expect(project.tasks.find((t) => t.id === "03")!.dependencies).toEqual([ + "02", + ]); + expect(project.tasks.find((t) => t.id === "04")!.dependencies).toEqual([ + "01", + ]); + expect( + project.tasks.find((t) => t.id === "05")!.dependencies.sort(), + ).toEqual(["03", "04"]); + expect( + project.tasks.find((t) => t.id === "06")!.dependencies.sort(), + ).toEqual(["03", "04"]); + expect(project.tasks.find((t) => t.id === "07")!.dependencies).toEqual([ + "03", + ]); + expect( + project.tasks.find((t) => t.id === "08")!.dependencies.sort(), + ).toEqual(["05", "06", "07"]); + expect(project.exitCriteria).toBeDefined(); + expect(project.exitCriteria).toHaveLength(2); + expect(project.objective).toBe("iOS OAuth Sign-In"); + } finally { + cleanup(); + } + }); + + it("parses plain headings with arrow notation deps", () => { + const content = `# Design Token Integration\n\nTasks\n- [x] 01 — Migrate domain colors\n- [x] 02 — Replace corner radius\n- [x] 03 — Replace color leakage\n- [~] 04 — Replace raw spacing\n- [ ] 05 — Increase component adoption\n\nDependencies\n- 01 -> 02 (SemanticColors tokens must exist before views consume them)\n- 01 -> 03 (SemanticColors tokens must exist before views consume them)\n- 02 -> 04 (independent for clean git history)\n- 03 -> 04 (independent for clean git history)\n- 04 -> 05 (spacing consistency before component adoption)\n- 01 -> 05 (SemanticColors tokens before component-level adoption)\n\nExit criteria\n- Zero Color.systemGroupedBackground remain\n`; + const { project, cleanup } = parse(content); + try { + expect(project.tasks).toHaveLength(5); + expect(project.tasks.find((t) => t.id === "02")!.dependencies).toEqual([ + "01", + ]); + expect(project.tasks.find((t) => t.id === "03")!.dependencies).toEqual([ + "01", + ]); + expect( + project.tasks.find((t) => t.id === "04")!.dependencies.sort(), + ).toEqual(["02", "03"]); + expect( + project.tasks.find((t) => t.id === "05")!.dependencies.sort(), + ).toEqual(["01", "04"]); + } finally { + cleanup(); + } + }); + + it("ignores 'Status legend' line (has colon — not a section break)", () => { + const content = `# Test\n\nStatus legend: [ ] todo, [~] in-progress, [x] done\n\nTasks\n- [ ] 01 — First\n- [~] 02 — Second\n\nDependencies\n- 02 depends on 01\n`; + const { project, cleanup } = parse(content); + try { + expect(project.tasks).toHaveLength(2); + expect(project.tasks.find((t) => t.id === "02")!.dependencies).toEqual([ + "01", + ]); + } finally { + cleanup(); + } + }); + + it("ignores 'Objective:' line (has colon — not a section break)", () => { + const content = `# Test\n\nObjective: Add Google and Apple OAuth\n\nTasks\n- [ ] 01 — Research\n- [ ] 02 — Implement\n\nDependencies\n- 02 depends on 01\n`; + const { project, cleanup } = parse(content); + try { + expect(project.tasks).toHaveLength(2); + expect(project.tasks.find((t) => t.id === "02")!.dependencies).toEqual([ + "01", + ]); + expect(project.objective).toBe("Test"); + } finally { + cleanup(); + } + }); +}); + +// ─── Lettered Step IDs (e.g. 02b, 02c) ──────────────────────────────────── + +describe("Lettered step IDs (e.g. 02b, 02c)", () => { + it("parses task lines with a single lowercase letter suffix", () => { + const content = `${FIO_HEADER} +- [~] 01 — Revert terminology +- [ ] 02 — Fix lesson generation +- [ ] 02b — Create lesson sequence adapter +- [ ] 02c — Add tap to show translation +- [ ] 03 — Restore web curriculum overview + +${FIO_FOOTER} +- 02 -> 02b +- 02 -> 02c +`; + const { project, cleanup } = parse(content); + try { + expect(project.tasks).toHaveLength(5); + expect(project.tasks.map((t) => t.id)).toEqual([ + "01", + "02", + "02b", + "02c", + "03", + ]); + } finally { + cleanup(); + } + }); + + it("normalizes unpadded lettered IDs (2b -> 02b)", () => { + const content = `${FIO_HEADER} +- [ ] 2 — First +- [ ] 2b — Sub-step +- [ ] 2c — Another sub-step +- [ ] 3 — Third + +${FIO_FOOTER} +`; + const { project, cleanup } = parse(content); + try { + const ids = project.tasks.map((t) => t.id); + expect(ids).toEqual(["02", "02b", "02c", "03"]); + } finally { + cleanup(); + } + }); + + it("preserves lettered IDs in natural-language depends-on", () => { + const content = `${FIO_HEADER} +- [ ] 02 — Fix bugs +- [ ] 02b — Adapter +- [ ] 02c — Translation +- [ ] 04 — Restore unit detail + +${FIO_FOOTER} +- 02b depends on 02 +- 02c depends on 02 +- 04 depends on 01, 02b, 03 +`; + const { project, cleanup } = parse(content); + try { + expect(project.tasks.find((t) => t.id === "02b")!.dependencies).toEqual([ + "02", + ]); + expect(project.tasks.find((t) => t.id === "02c")!.dependencies).toEqual([ + "02", + ]); + expect( + project.tasks.find((t) => t.id === "04")!.dependencies.sort(), + ).toEqual(["01", "02b", "03"]); + } finally { + cleanup(); + } + }); + + it("handles 'also depends on' with lettered IDs", () => { + const content = `${FIO_HEADER} +- [ ] 02 — First +- [ ] 02b — Sub + +${FIO_FOOTER} +- 02b also depends on 02 +`; + const { project, cleanup } = parse(content); + try { + expect(project.tasks.find((t) => t.id === "02b")!.dependencies).toEqual([ + "02", + ]); + } finally { + cleanup(); + } + }); + + it("handles arrow notation with lettered targets", () => { + const content = `${FIO_HEADER} +- [ ] 02 — Source +- [ ] 02b — Target b +- [ ] 02c — Target c +- [ ] 03 — End + +${FIO_FOOTER} +- 02 -> 02b, 02c +- 02b, 02c -> 03 +`; + const { project, cleanup } = parse(content); + try { + expect(project.tasks.find((t) => t.id === "02b")!.dependencies).toEqual([ + "02", + ]); + expect(project.tasks.find((t) => t.id === "02c")!.dependencies).toEqual([ + "02", + ]); + expect( + project.tasks.find((t) => t.id === "03")!.dependencies.sort(), + ).toEqual(["02b", "02c"]); + } finally { + cleanup(); + } + }); + + it("handles 'must be done before' with lettered IDs", () => { + const content = `${FIO_HEADER} +- [ ] 02 — Before +- [ ] 02b — Sub b +- [ ] 02c — Sub c +- [ ] 03 — After + +${FIO_FOOTER} +- 02 must be done before 02b, 02c +- 02b must be done before 03 +`; + const { project, cleanup } = parse(content); + try { + expect(project.tasks.find((t) => t.id === "02b")!.dependencies).toEqual([ + "02", + ]); + expect(project.tasks.find((t) => t.id === "02c")!.dependencies).toEqual([ + "02", + ]); + expect(project.tasks.find((t) => t.id === "03")!.dependencies).toEqual([ + "02b", + ]); + } finally { + cleanup(); + } + }); + + it("handles 'depend on' with lettered IDs", () => { + const content = `${FIO_HEADER} +- [ ] 02 — Foundation +- [ ] 03 — Foundation +- [ ] 02b — Needs both + +${FIO_FOOTER} +- 02b depends on 02, 03 +`; + const { project, cleanup } = parse(content); + try { + expect( + project.tasks.find((t) => t.id === "02b")!.dependencies.sort(), + ).toEqual(["02", "03"]); + } finally { + cleanup(); + } + }); + + it("handles 'can be done in parallel' with lettered IDs", () => { + const content = `${FIO_HEADER} +- [ ] 02b — Adapter +- [ ] 02c — Translation +- [ ] 05 — Final + +${FIO_FOOTER} +- 02b, 02c can be done in parallel (post-fix polish) +`; + const { project, cleanup } = parse(content); + try { + expect(project.parallelGroups).toBeDefined(); + const group = project.parallelGroups![0]; + expect(group.taskIds.sort()).toEqual(["02b", "02c"]); + expect(group.label).toBe("post-fix polish"); + } finally { + cleanup(); + } + }); + + it("parses timeout meta block for a lettered ID", () => { + // Timeout meta blocks live in the Dependencies section, not inline + // with the task. Format: "02b [timeout] = 15m" (no list prefix). + const content = `${FIO_HEADER} +- [ ] 02b — Adapter + +${FIO_FOOTER} +02b [timeout] = 15m +`; + const { project, cleanup } = parse(content); + try { + const task = project.tasks.find((t) => t.id === "02b")!; + expect(task.timeoutMs).toBe(15 * 60 * 1000); + } finally { + cleanup(); + } + }); + + it("parses the full Linear UI Reintegration example", () => { + const content = `# Linear UI Reintegration + +Objective: Reintegrate the original linear unit progression UI while retaining the pool-based v2 backend, hiding all pool internals from the user. Also fix critical lesson generation bugs (word repetition, introduction dedup, learning-pool cap) and add tap-to-show-translation. + +Status legend: [ ] todo, [~] in-progress, [x] done + +Tasks + +- [~] 01 — revert-terminology-and-url-scheme +- [ ] 02 — fix-lesson-generation-bugs +- [ ] 02b — create-lesson-sequence-adapter +- [ ] 02c — add-tap-to-show-translation +- [ ] 03 — restore-web-curriculum-overview +- [ ] 04 — restore-web-linear-path-unit-detail +- [ ] 05 — wire-web-lesson-player-to-v2 +- [ ] 06 — remove-pool-exposing-web-ui +- [ ] 07 — restore-ios-curriculum-linear-view +- [ ] 08 — bridge-ios-lesson-flow-to-v2 +- [ ] 09 — remove-pool-exposing-ios-ui +- [ ] 10 — e2e-testing-and-validation + +Dependencies + +- 02b depends on 02 +- 04 depends on 01, 02b, 03 +- 05 depends on 02b, 02c, 04 +- 06 depends on 03, 04, 05 +- 08 depends on 02b, 02c, 07 +- 09 depends on 07, 08 +- 10 depends on 05, 06, 08, 09 +`; + const { project, cleanup } = parse(content); + try { + expect(project.tasks).toHaveLength(12); + expect(project.objective).toBe("Linear UI Reintegration"); + + // Lettered IDs land in the right slot + const t2b = project.tasks.find((t) => t.id === "02b")!; + const t2c = project.tasks.find((t) => t.id === "02c")!; + expect(t2b.title).toBe("create-lesson-sequence-adapter"); + expect(t2c.title).toBe("add-tap-to-show-translation"); + + // 02b depends on 02 + expect(t2b.dependencies).toEqual(["02"]); + expect(t2c.dependencies).toEqual([]); + + // 04 depends on 01, 02b, 03 + expect( + project.tasks.find((t) => t.id === "04")!.dependencies.sort(), + ).toEqual(["01", "02b", "03"]); + + // 05 depends on 02b, 02c, 04 + expect( + project.tasks.find((t) => t.id === "05")!.dependencies.sort(), + ).toEqual(["02b", "02c", "04"]); + + // 10 depends on 05, 06, 08, 09 + expect( + project.tasks.find((t) => t.id === "10")!.dependencies.sort(), + ).toEqual(["05", "06", "08", "09"]); + } finally { + cleanup(); + } + }); + + it("preserves 02b in task title normalization (id 02b vs 02)", () => { + const content = `${FIO_HEADER} +- [ ] 02 — Step two +- [ ] 02b — Step two-b +- [ ] 02c — Step two-c + +${FIO_FOOTER} +`; + const { project, cleanup } = parse(content); + try { + const ids = project.tasks.map((t) => t.id); + expect(ids).toEqual(["02", "02b", "02c"]); + // All three are distinct + expect(new Set(ids).size).toBe(3); + } finally { + cleanup(); + } + }); +}); diff --git a/tests/parser-phased.test.ts b/tests/parser-phased.test.ts new file mode 100644 index 0000000..5843b25 --- /dev/null +++ b/tests/parser-phased.test.ts @@ -0,0 +1,521 @@ +/** + * Tests for phased task format parsing + * Covers: phase detection, task parsing, phase boundaries, implicit dependencies + */ + +import { describe, test, expect } from "bun:test"; +import { parseTaskFile } from "../src/parser"; +import type { Task } from "../src/types"; +import { tempDir, writeTaskFile } from "./helpers"; + +/** Parse a task file from an inline template literal. */ +function parse(content: string) { + const { dir, cleanup } = tempDir(); + try { + const filePath = writeTaskFile(dir, "README.md", content); + return { project: parseTaskFile(filePath), cleanup }; + } catch (e) { + cleanup(); + throw e; + } +} + +describe("Phased task format", () => { + describe("Phase detection", () => { + test("detects phased format with markdown headings", () => { + const content = `# Voice Conversation + +## Phase 1 - MVP +- [ ] 01 - Build voice pipeline +- [ ] 02 - Add audio playback + +## Phase 2 - Streaming +- [ ] 03 - WebSocket channel +- [ ] 04 - Streaming STT + +## Dependencies +- 02 depends on 01 +`; + const { project, cleanup } = parse(content); + try { + expect(project.phases).toBeDefined(); + expect(project.phases?.length).toBe(2); + expect(project.phases?.[0].number).toBe(1); + expect(project.phases?.[0].title).toBe("MVP"); + expect(project.phases?.[1].number).toBe(2); + expect(project.phases?.[1].title).toBe("Streaming"); + } finally { + cleanup(); + } + }); + + test("detects phased format with plain headings", () => { + const content = `# Voice Conversation + +Phase 1 - MVP +- [ ] 01 - Build voice pipeline +- [ ] 02 - Add audio playback + +Phase 2 - Streaming +- [ ] 03 - WebSocket channel + +## Dependencies +`; + const { project, cleanup } = parse(content); + try { + expect(project.phases).toBeDefined(); + expect(project.phases?.length).toBe(2); + } finally { + cleanup(); + } + }); + + test("supports various separators in phase headings", () => { + const variants = [ + "## Phase 1 - MVP", + "## Phase 1 - MVP", + "## Phase 1 - MVP", + "## Phase 1: MVP", + "## Phase 1 - MVP", // multiple spaces + ]; + + for (const heading of variants) { + const content = `# Test + +${heading} +- [ ] 01 - Task + +## Dependencies +`; + const { project, cleanup } = parse(content); + try { + expect(project.phases).toBeDefined(); + expect(project.phases?.length).toBe(1); + } finally { + cleanup(); + } + } + }); + + test("handles phase headings with extra whitespace", () => { + const content = `# Test + + ## Phase 1 - MVP +- [ ] 01 - Task + +## Dependencies +`; + const { project, cleanup } = parse(content); + try { + expect(project.phases).toBeDefined(); + expect(project.phases?.length).toBe(1); + } finally { + cleanup(); + } + }); + }); + + describe("Task parsing within phases", () => { + test("assigns phase number to tasks", () => { + const content = `# Test + +## Phase 1 - MVP +- [ ] 01 - Task A +- [ ] 02 - Task B + +## Phase 2 - Enhancement +- [ ] 03 - Task C +- [ ] 04 - Task D + +## Dependencies +`; + const { project, cleanup } = parse(content); + try { + expect(project.tasks[0].id).toBe("01"); + expect(project.tasks[0].phase).toBe(1); + expect(project.tasks[1].id).toBe("02"); + expect(project.tasks[1].phase).toBe(1); + expect(project.tasks[2].id).toBe("03"); + expect(project.tasks[2].phase).toBe(2); + expect(project.tasks[3].id).toBe("04"); + expect(project.tasks[3].phase).toBe(2); + } finally { + cleanup(); + } + }); + + test("tracks task IDs in each phase", () => { + const content = `# Test + +## Phase 1 - Foundation +- [ ] 01 - Setup +- [ ] 02 - Config + +## Phase 2 - Implementation +- [ ] 03 - Feature A +- [ ] 04 - Feature B +- [ ] 05 - Feature C + +## Dependencies +`; + const { project, cleanup } = parse(content); + try { + expect(project.phases?.[0].taskIds).toEqual(["01", "02"]); + expect(project.phases?.[1].taskIds).toEqual(["03", "04", "05"]); + } finally { + cleanup(); + } + }); + + test("handles tasks with different statuses in phases", () => { + const content = `# Test + +## Phase 1 - MVP +- [x] 01 - Done task +- [ ] 02 - Pending task +- [~] 03 - In progress + +## Phase 2 - Next +- [ ] 04 - Future task + +## Dependencies +`; + const { project, cleanup } = parse(content); + try { + expect(project.tasks[0].status).toBe("completed"); + expect(project.tasks[1].status).toBe("pending"); + expect(project.tasks[2].status).toBe("in_progress"); + expect(project.tasks[3].status).toBe("pending"); + + expect(project.phases?.[0].taskIds).toEqual(["01", "02", "03"]); + expect(project.phases?.[1].taskIds).toEqual(["04"]); + } finally { + cleanup(); + } + }); + + test("handles empty phases", () => { + const content = `# Test + +## Phase 1 - Empty + +## Phase 2 - Has tasks +- [ ] 01 - Task + +## Dependencies +`; + const { project, cleanup } = parse(content); + try { + expect(project.phases?.length).toBe(1); + expect(project.phases?.[0].number).toBe(2); + expect(project.phases?.[0].taskIds).toEqual(["01"]); + } finally { + cleanup(); + } + }); + }); + + describe("Implicit phase-boundary dependencies", () => { + test("adds dependency from first task of phase 2 to last task of phase 1", () => { + const content = `# Test + +## Phase 1 - MVP +- [ ] 01 - Setup +- [ ] 02 - Build + +## Phase 2 - Enhancement +- [ ] 03 - Feature +- [ ] 04 - Test + +## Dependencies +`; + const { project, cleanup } = parse(content); + try { + // Task 03 should depend on task 02 (implicit phase boundary) + expect( + project.tasks.find((t: Task) => t.id === "03")?.dependencies, + ).toContain("02"); + } finally { + cleanup(); + } + }); + + test("adds dependencies across multiple phases", () => { + const content = `# Test + +## Phase 1 - Foundation +- [ ] 01 - Setup + +## Phase 2 - Core +- [ ] 02 - Build +- [ ] 03 - Test + +## Phase 3 — Polish +- [ ] 04 — Refine +- [ ] 05 — Release + +## Dependencies +`; + const { project, cleanup } = parse(content); + try { + // Task 02 depends on task 01 (phase 1 → 2 boundary) + expect( + project.tasks.find((t: Task) => t.id === "02")?.dependencies, + ).toContain("01"); + + // Task 04 depends on task 03 (phase 2 → 3 boundary) + expect( + project.tasks.find((t: Task) => t.id === "04")?.dependencies, + ).toContain("03"); + } finally { + cleanup(); + } + }); + + test("does not duplicate explicit dependencies", () => { + const content = `# Test + +## Phase 1 - MVP +- [ ] 01 - Setup +- [ ] 02 - Build + +## Phase 2 — Enhancement +- [ ] 03 — Feature + +## Dependencies +- 03 depends on 02 +`; + const { project, cleanup } = parse(content); + try { + const task03 = project.tasks.find((t: Task) => t.id === "03"); + const depCount = task03?.dependencies.filter( + (d: string) => d === "02", + ).length; + expect(depCount).toBe(1); // Should not duplicate + } finally { + cleanup(); + } + }); + + test("handles single phase (no boundaries)", () => { + const content = `# Test + +## Phase 1 - All tasks +- [ ] 01 - Task A +- [ ] 02 - Task B + +## Dependencies +`; + const { project, cleanup } = parse(content); + try { + // No implicit dependencies should be added + expect(project.tasks[0].dependencies).toEqual([]); + expect(project.tasks[1].dependencies).toEqual([]); + } finally { + cleanup(); + } + }); + + test("works alongside explicit dependencies", () => { + const content = `# Test + +## Phase 1 - MVP +- [ ] 01 - Setup +- [ ] 02 - Build + +## Phase 2 - Enhancement +- [ ] 03 - Feature A +- [ ] 04 - Feature B + +## Dependencies +- 04 depends on 03 +`; + const { project, cleanup } = parse(content); + try { + // Task 03 has implicit dependency on task 02 + expect( + project.tasks.find((t: Task) => t.id === "03")?.dependencies, + ).toContain("02"); + + // Task 04 has explicit dependency on task 03 + expect( + project.tasks.find((t: Task) => t.id === "04")?.dependencies, + ).toContain("03"); + + // Task 04 should NOT have implicit dependency on task 02 + expect( + project.tasks.find((t: Task) => t.id === "04")?.dependencies, + ).not.toContain("02"); + } finally { + cleanup(); + } + }); + }); + + describe("Mixed formats", () => { + test("phased format with arrow dependencies", () => { + const content = `# Test + +## Phase 1 - Setup +- [ ] 01 - Initialize +- [ ] 02 - Configure + +## Phase 2 - Build +- [ ] 03 - Compile +- [ ] 04 - Bundle + +## Dependencies +- 01 → 02 +- 03 → 04 +`; + const { project, cleanup } = parse(content); + try { + expect(project.phases?.length).toBe(2); + expect( + project.tasks.find((t: Task) => t.id === "02")?.dependencies, + ).toContain("01"); + expect( + project.tasks.find((t: Task) => t.id === "04")?.dependencies, + ).toContain("03"); + expect( + project.tasks.find((t: Task) => t.id === "03")?.dependencies, + ).toContain("02"); + } finally { + cleanup(); + } + }); + + test("phased format with parallel groups", () => { + const content = `# Test + +## Phase 1 - MVP +- [ ] 01 - Setup +- [ ] 02 - Build + +## Phase 2 - Enhancement +- [ ] 03 - Feature +- [ ] 04 - Test + +## Dependencies +- 01, 02 can be done in parallel +- 03, 04 can be done in parallel +`; + const { project, cleanup } = parse(content); + try { + expect(project.phases?.length).toBe(2); + expect(project.parallelGroups?.length).toBe(2); + expect( + project.tasks.find((t: Task) => t.id === "03")?.dependencies, + ).toContain("02"); + } finally { + cleanup(); + } + }); + + test("phased format with exit criteria", () => { + const content = `# Test + +## Phase 1 - MVP +- [ ] 01 - Build +- [ ] 02 - Test + +## Phase 2 - Release +- [ ] 03 - Deploy + +## Dependencies + +## Exit Criteria +- All tests pass +- Deployment successful +`; + const { project, cleanup } = parse(content); + try { + expect(project.phases?.length).toBe(2); + expect(project.exitCriteria?.length).toBe(2); + } finally { + cleanup(); + } + }); + }); + + describe("Real-world example", () => { + test("parses voice conversation PRD correctly", () => { + const content = `# Voice Conversation + +Objective: Add full voice conversation capability + +## Phase 1 - Push-to-Talk MVP +- [ ] 01 - Build voice pipeline orchestrator → \`01-voice-pipeline-orchestrator.md\` +- [ ] 02 - Build auto-playback audio module → \`02-auto-playback-audio-module.md\` +- [ ] 03 - Wire voice mode toggle into chat UI → \`03-voice-mode-toggle-ui.md\` +- [ ] 04 - End-to-end push-to-talk integration test → \`04-push-to-talk-integration-test.md\` + +## Phase 2 - Streaming & Real-Time +- [ ] 05 - Build WebSocket voice channel → \`05-websocket-voice-channel.md\` +- [ ] 06 - Implement streaming STT pipeline → \`06-streaming-stt-pipeline.md\` +- [ ] 07 - Implement streaming TTS pipeline → \`07-streaming-tts-pipeline.md\` + +## Phase 3 - Optimization & Hardening +- [ ] 08 - Model quantization and VRAM budget manager → \`08-model-quantization.md\` +- [ ] 09 - Latency profiling and pipeline optimization → \`09-latency-profiling.md\` + +## Dependencies +- 02 depends on 01 +- 03 depends on 01, 02 +- 04 depends on 03 +- 06 depends on 05 +- 07 depends on 05 +- 09 depends on 08 + +## Exit Criteria +- Users can hold multi-turn voice conversations +- Total round-trip latency under 3s +`; + const { project, cleanup } = parse(content); + try { + // Verify phases + expect(project.phases?.length).toBe(3); + expect(project.phases?.[0].title).toBe("Push-to-Talk MVP"); + expect(project.phases?.[1].title).toBe("Streaming & Real-Time"); + expect(project.phases?.[2].title).toBe("Optimization & Hardening"); + + // Verify task phases + expect(project.tasks[0].phase).toBe(1); + expect(project.tasks[4].phase).toBe(2); + expect(project.tasks[7].phase).toBe(3); + + // Verify phase boundaries + // Task 05 (first in phase 2) depends on task 04 (last in phase 1) + expect( + project.tasks.find((t: Task) => t.id === "05")?.dependencies, + ).toContain("04"); + + // Task 08 (first in phase 3) depends on task 07 (last in phase 2) + expect( + project.tasks.find((t: Task) => t.id === "08")?.dependencies, + ).toContain("07"); + + // Verify explicit dependencies still work + expect( + project.tasks.find((t: Task) => t.id === "02")?.dependencies, + ).toContain("01"); + expect( + project.tasks.find((t: Task) => t.id === "03")?.dependencies, + ).toContain("01"); + expect( + project.tasks.find((t: Task) => t.id === "03")?.dependencies, + ).toContain("02"); + + // Verify task files + expect(project.tasks[0].file).toBe("01-voice-pipeline-orchestrator.md"); + expect(project.tasks[1].file).toBe("02-auto-playback-audio-module.md"); + + // Verify exit criteria + expect(project.exitCriteria?.length).toBe(2); + expect(project.objective).toBe("Voice Conversation"); + } finally { + cleanup(); + } + }); + }); +}); diff --git a/tests/progress-multiprd.test.ts b/tests/progress-multiprd.test.ts new file mode 100644 index 0000000..5c0e529 --- /dev/null +++ b/tests/progress-multiprd.test.ts @@ -0,0 +1,82 @@ +/// +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 { + const raw = fs.readFileSync( + path.join(root, ".ralpi", "progress.json"), + "utf-8", + ); + try { + return JSON.parse(raw) as Record; + } 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"); + }); +}); diff --git a/tests/resume-stats.test.ts b/tests/resume-stats.test.ts new file mode 100644 index 0000000..a85aa3a --- /dev/null +++ b/tests/resume-stats.test.ts @@ -0,0 +1,87 @@ +/// +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); + }); +}); diff --git a/tests/review-prompt.test.ts b/tests/review-prompt.test.ts new file mode 100644 index 0000000..63aa1ab --- /dev/null +++ b/tests/review-prompt.test.ts @@ -0,0 +1,303 @@ +/** + * Tests for the review prompt builders (src/prompts.ts). + * Covers: per-file summary table, excluded-files section, oversized-diff + * read-instruction (never byte-truncates), custom review focus, and the + * configurable noise-filter overrides surfacing in the prompt. + */ + +import { describe, test, expect } from "bun:test"; +import { + buildReviewPrompt, + buildReviewPromptUncommitted, +} from "../src/prompts"; +import { compileIgnorePatterns } from "../src/diff"; +import type { Task, Project } from "../src/types"; + +const task: Task = { + id: "01", + title: "Implement auth", + description: "Add a login flow", + status: "completed", + dependencies: [], +}; + +const project: Project = { + objective: "Build the app", + sourcePath: "README.md", + sourceDir: "/tmp", + tasks: [task], + dependencies: {}, +}; + +/** A diff mixing one code file plus lockfile/minified/binary noise. */ +const MIXED_DIFF = [ + "diff --git a/src/auth.ts b/src/auth.ts", + "index 111..222 100644", + "--- a/src/auth.ts", + "+++ b/src/auth.ts", + "@@ -1,3 +1,5 @@", + ' import { hash } from "./hash";', + "+export function login() {", + "+ return hash(secret);", + "- return legacy();", + "+}", + "", + "diff --git a/package-lock.json b/package-lock.json", + "index 000..111 100644", + "--- a/package-lock.json", + "+++ b/package-lock.json", + "@@ -0,0 +1,3 @@", + "+{", + '+ "name": "x"', + "+}", + "", + "diff --git a/assets/logo.png b/assets/logo.png", + "index 111..222 100644", + "Binary files differ", + "", + "diff --git a/dist/app.min.js b/dist/app.min.js", + "index 111..222 100644", + "--- a/dist/app.min.js", + "+++ b/dist/app.min.js", + "@@ -1 +1 @@", + "-var a=1;", + "+var a=2;", +].join("\n"); + +function manyFileDiff(n: number): string { + const chunks: string[] = []; + for (let i = 0; i < n; i++) { + chunks.push( + `diff --git a/src/f${String(i).padStart(2, "0")}.ts b/src/f${String(i).padStart(2, "0")}.ts`, + "--- a/src/f.ts", + "+++ b/src/f.ts", + `+line ${i}`, + ); + } + return chunks.join("\n"); +} + +describe("buildReviewPrompt", () => { + test("emits a per-file +/− summary table with totals, excluding noise", () => { + const prompt = buildReviewPrompt( + task, + project, + "abc1234", + "feat: auth", + MIXED_DIFF, + ); + + expect(prompt).toContain("### Changed Files"); + expect(prompt).toContain("| `src/auth.ts` | +3/-1 | ts |"); + expect(prompt).toContain("| **Total** | **+3/-1** | |"); + }); + + test("surfaces an excluded-files section with path, counts, and reason", () => { + const prompt = buildReviewPrompt( + task, + project, + "abc1234", + "feat: auth", + MIXED_DIFF, + ); + + expect(prompt).toContain("### Excluded Files (3)"); + expect(prompt).toContain("- `package-lock.json` (+3/-0) — lockfile"); + expect(prompt).toContain( + "- `assets/logo.png` (+0/-0) — binary/media asset", + ); + expect(prompt).toContain("- `dist/app.min.js` (+1/-1) — minified asset"); + }); + + test("never inlines excluded (noise) chunks into the diff block", () => { + const prompt = buildReviewPrompt( + task, + project, + "abc1234", + "feat: auth", + MIXED_DIFF, + ); + + // The noise chunks themselves are never inlined — only the excluded-files + // section names them (as `- path (+x/-y) — reason`, no `diff --git` header). + expect(prompt).not.toContain("diff --git a/package-lock.json"); + expect(prompt).not.toContain("diff --git a/assets/logo.png"); + expect(prompt).not.toContain("diff --git a/dist/app.min.js"); + // The cleaned diff block is present with the code file. + expect(prompt).toContain("```diff"); + expect(prompt).toContain("diff --git a/src/auth.ts"); + }); + + test("omits the excluded section entirely when nothing is excluded", () => { + const clean = [ + "diff --git a/src/auth.ts b/src/auth.ts", + "--- a/src/auth.ts", + "+++ b/src/auth.ts", + "+export const x = 1;", + ].join("\n"); + const prompt = buildReviewPrompt( + task, + project, + "abc1234", + "feat: auth", + clean, + ); + expect(prompt).not.toContain("### Excluded Files"); + expect(prompt).toContain("| `src/auth.ts` | +1/-0 | ts |"); + }); + + test("switches to a file-list + read instruction for >20 files, no truncation", () => { + const prompt = buildReviewPrompt( + task, + project, + "abc1234", + "feat: many", + manyFileDiff(21), + ); + + expect(prompt).toContain("Diff too large"); + expect(prompt).toContain("Use `read` to inspect the changed files"); + // No byte-truncated inline diff for oversized inputs. + expect(prompt).not.toContain("```diff"); + }); + + test("switches to a file-list + read instruction for a >50KB diff, no truncation", () => { + // One file but a huge cleaned diff — crosses MAX_DIFF_BYTES (50_000). + const huge = [ + "diff --git a/src/auth.ts b/src/auth.ts", + "--- a/src/auth.ts", + "+++ b/src/auth.ts", + ...Array.from( + { length: 26000 }, + () => "+padding line to blow past the size threshold", + ), + ].join("\n"); + const prompt = buildReviewPrompt( + task, + project, + "abc1234", + "feat: auth", + huge, + ); + + expect(prompt).toContain("Diff too large"); + expect(prompt).toContain("Use `read` to inspect the changed files"); + expect(prompt).toContain("src/auth.ts"); + // No byte-truncated inline diff for the oversized input. + expect(prompt).not.toContain("```diff"); + }); + + test("a small diff over the file-count branch still inlines under size threshold", () => { + // 5 files, small diff — under MAX_REVIEW_FILES and MAX_DIFF_BYTES → inlined. + const prompt = buildReviewPrompt( + task, + project, + "abc1234", + "feat: small", + manyFileDiff(5), + ); + expect(prompt).toContain("```diff"); + expect(prompt).not.toContain("Diff too large"); + }); + + test("inlines a small diff normally (no read-instruction)", () => { + const prompt = buildReviewPrompt( + task, + project, + "abc1234", + "feat: auth", + MIXED_DIFF, + ); + expect(prompt).not.toContain("Diff too large"); + }); + + test("emits a Custom Review Focus section only when focus is set", () => { + const withFocus = buildReviewPrompt( + task, + project, + "abc1234", + "feat: auth", + MIXED_DIFF, + { focus: "check security only" }, + ); + expect(withFocus).toContain("## Custom Review Focus"); + expect(withFocus).toContain("check security only"); + + const withoutFocus = buildReviewPrompt( + task, + project, + "abc1234", + "feat: auth", + MIXED_DIFF, + ); + expect(withoutFocus).not.toContain("## Custom Review Focus"); + }); + + test("surfaces extra ignore patterns and ignorePaths overrides in the prompt", () => { + const diff = [ + "diff --git a/src/keep.ts b/src/keep.ts", + "--- a/src/keep.ts", + "+++ b/src/keep.ts", + "+keep", + "diff --git a/package-lock.json b/package-lock.json", + "--- a/package-lock.json", + "+++ b/package-lock.json", + "+a", + "+b", + "+c", + "+d", + ].join("\n"); + + // ignorePaths keeps the lockfile in scope → it shows in the table, + // and no excluded section is emitted. + const kept = buildReviewPrompt(task, project, "abc1234", "x", diff, { + diffOptions: { ignorePaths: ["package-lock.json"] }, + }); + expect(kept).toContain("| `package-lock.json` | +4/-0 | json |"); + expect(kept).not.toContain("### Excluded Files"); + + // Without ignorePaths, the lockfile is excluded. + const excluded = buildReviewPrompt(task, project, "abc1234", "x", diff); + expect(excluded).not.toContain("| `package-lock.json` |"); + expect(excluded).toContain("### Excluded Files (1)"); + + // extraPatterns drops a matching file from scope. + const dropped = buildReviewPrompt(task, project, "abc1234", "x", diff, { + diffOptions: { + extraPatterns: compileIgnorePatterns(["\\.ts$"]), + ignorePaths: [], + }, + }); + expect(dropped).not.toContain("| `src/keep.ts` |"); + expect(dropped).toContain("### Excluded Files (2)"); + }); +}); + +describe("buildReviewPromptUncommitted", () => { + test("emits summary table, excluded section, and cleaned diff", () => { + const prompt = buildReviewPromptUncommitted( + task, + project, + "M src/auth.ts", + MIXED_DIFF, + ); + + expect(prompt).toContain("### Changed Files"); + expect(prompt).toContain("| `src/auth.ts` | +3/-1 | ts |"); + expect(prompt).toContain("### Excluded Files (3)"); + expect(prompt).not.toContain("diff --git a/package-lock.json"); + expect(prompt).toContain("### Current Tracked Diff (git diff)"); + }); + + test("supports custom focus", () => { + const prompt = buildReviewPromptUncommitted( + task, + project, + "M src/auth.ts", + MIXED_DIFF, + { focus: "review performance" }, + ); + expect(prompt).toContain("## Custom Review Focus"); + expect(prompt).toContain("review performance"); + }); +}); diff --git a/tests/review-severity.test.ts b/tests/review-severity.test.ts new file mode 100644 index 0000000..0621531 --- /dev/null +++ b/tests/review-severity.test.ts @@ -0,0 +1,53 @@ +/** + * Tests for the severity taxonomy alignment in review verdict parsing + * (src/review.ts): the `critical` token is accepted and normalized to + * ralpi's `blocker` severity, mirroring @piex-dev/review's grading. + */ + +import { describe, test, expect } from "bun:test"; +import { extractReview } from "../src/review"; + +/** Build a full review-agent output ending in a REVIEW VERDICT block. */ +function reviewOutput(findings: string[]): string { + return [ + "Prose: looks mostly fine, a few issues to fix.", + "## REVIEW VERDICT", + "VERDICT: fail", + "SUMMARY: Needs fixes.", + "FINDINGS:", + ...findings, + ].join("\n"); +} + +describe("extractReview severity normalization", () => { + test("maps critical → blocker, keeps warning/nit/info", () => { + const out = reviewOutput([ + "- [critical] src/auth.ts:12 hardcoded secret", + "- [warning] src/auth.ts:30 unused import", + "- [nit] src/auth.ts:5 style", + "- [info] src/auth.ts:1 note", + ]); + const review = extractReview(out, "01", "abc1234"); + expect(review).not.toBeNull(); + const severities = review!.findings.map((f) => f.severity); + expect(severities).toEqual(["blocker", "warning", "nit", "info"]); + }); + + test("normalizes the warn synonym to warning", () => { + const out = reviewOutput(["- [warn] src/a.ts:2 thing"]); + const review = extractReview(out, "01", "abc1234"); + expect(review!.findings[0].severity).toBe("warning"); + }); + + test("uppercase CRITICAL token also maps to blocker", () => { + const out = reviewOutput(["- [CRITICAL] src/a.ts:2 thing"]); + const review = extractReview(out, "01", "abc1234"); + expect(review!.findings[0].severity).toBe("blocker"); + }); + + test("findings without a severity are still parsed", () => { + const out = reviewOutput(["- src/a.ts:2 plain line"]); + const review = extractReview(out, "01", "abc1234"); + expect(review!.findings[0].severity).toBe("info"); + }); +}); diff --git a/tests/worktree-resume.test.ts b/tests/worktree-resume.test.ts new file mode 100644 index 0000000..b55d62c --- /dev/null +++ b/tests/worktree-resume.test.ts @@ -0,0 +1,265 @@ +/// +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); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..f842c64 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "noEmit": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true + }, + "include": ["index.ts", "src/**/*"], + "exclude": ["node_modules", "dist"] +}