Compare commits
31 Commits
ead5d9be3a
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c398eef64 | |||
| 496d1554be | |||
| fe28718911 | |||
| 3ba5fcb098 | |||
| db8859606f | |||
| 85123b7755 | |||
| dc3993048e | |||
| dfa6707a8f | |||
| 3892e2a637 | |||
| 8151d19127 | |||
| 8db135a523 | |||
| bac35b8619 | |||
| 5342a2c69f | |||
| 53bac1976a | |||
| 9ce89325fd | |||
| 139bf3b3fb | |||
| 4d46c001bb | |||
| 424e2fa885 | |||
| 30f177b4d9 | |||
| d2a7dfa5fe | |||
| 99b944d10e | |||
| e10ab719d0 | |||
| 7c7668cc6b | |||
| f4af013252 | |||
| dae33248e3 | |||
| 9f90ed4252 | |||
| 3c01652b90 | |||
| ab1e2eb430 | |||
| d2ef124369 | |||
| 925e37938b | |||
| 8e2e24d0e3 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,4 +1,3 @@
|
||||
node_modules
|
||||
dist
|
||||
.pi-lens
|
||||
package-lock.json
|
||||
|
||||
18
AGENTS.md
18
AGENTS.md
@@ -4,18 +4,17 @@
|
||||
|
||||
A Pi coding agent extension that registers the `/ralpi` slash command. Not a standalone app — it runs inside Pi's extension host.
|
||||
|
||||
## Build
|
||||
## Type checking
|
||||
|
||||
```
|
||||
npm run build # tsc → dist/
|
||||
npm run watch # tsc --watch
|
||||
npm run typecheck # tsc --noEmit
|
||||
```
|
||||
|
||||
No bundler, no linter, no test framework. Plain `tsc` with strict mode.
|
||||
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`. The `tsconfig.json` sets `rootDir: "./"` so `index.ts` compiles to `dist/index.js`.
|
||||
`index.ts` at repo root (not `src/`). Exports a default function receiving `ExtensionAPI`.
|
||||
|
||||
## External dependencies
|
||||
|
||||
@@ -27,7 +26,7 @@ The only real npm dependency is `yaml` (^2.4.0).
|
||||
|
||||
## Source structure
|
||||
|
||||
- `index.ts` — extension entry, command routing, UI registration
|
||||
- `index.ts` — extension entry, command routing, UI registration, reload detection
|
||||
- `src/` — all logic modules:
|
||||
- `parser.ts` — task file parsing (Fio, checkbox, YAML formats)
|
||||
- `dag.ts` — Kahn's algorithm dependency resolution, batch planning
|
||||
@@ -38,12 +37,13 @@ The only real npm dependency is `yaml` (^2.4.0).
|
||||
- `utils.ts` — config loading, progress discovery, `runAgentSession()`
|
||||
- `types.ts` — all interfaces and `DEFAULT_CONFIG`
|
||||
- `widget-batcher.ts` — debounced widget updates for parallel tasks
|
||||
- `constants.ts` — static constants
|
||||
- `skills/ralpi-use.md` — Pi skill definition for task execution
|
||||
- `tasks/` — example ralpi task files (self-modification history)
|
||||
- `prompts/task-manager.md` — Pi prompt for task planning
|
||||
|
||||
## Runtime state
|
||||
|
||||
All runtime state lives in `.ralpi/` (gitignored):
|
||||
All runtime state lives in `.ralpi/` in the **project directory** (not this extension directory):
|
||||
- `.ralpi/progress.json` — execution progress, supports multiple PRDs
|
||||
- `.ralpi/reflections/` — per-task reflection JSON files
|
||||
- `.ralpi/prompts/` — generated prompts (timestamped, for debugging)
|
||||
@@ -55,7 +55,7 @@ Task IDs are zero-padded strings (`"01"`, `"02"`, etc.). The parser prepends `0`
|
||||
|
||||
## Command routing
|
||||
|
||||
`/ralpi` with no args → plan. First token looks like a path (`@path`, `./path`, `.md`, etc.) → run. Otherwise dispatches to subcommand (`run`, `plan`, `status`, `resume`, `next`, `reset`).
|
||||
`/ralpi` with no args → plan. First token looks like a path (`@path`, `./path`, `.md`, etc.) → run. Otherwise dispatches to subcommand (`run`, `plan`, `resume`, `reset`).
|
||||
|
||||
## Config
|
||||
|
||||
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -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.
|
||||
137
README.md
137
README.md
@@ -1,78 +1,36 @@
|
||||
# Ralpi
|
||||
|
||||
Execute tasks from task files using DAG-based dependency resolution with persistent progress tracking.
|
||||
Execute tasks from task files until done using DAG-based dependency resolution with persistent progress tracking.
|
||||
|
||||
```bash
|
||||
pi install npm:@mikefreno/ralpi
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- **DAG-based execution**: Tasks are ordered by dependencies using Kahn's algorithm
|
||||
- **Parallel batching**: Independent tasks in each batch can run concurrently
|
||||
- **Persistent progress**: Execution state saved to `.ralpi/progress.json`
|
||||
- **Reflection system**: Each task produces a reflection for downstream tasks
|
||||
- **Retry with backoff**: Failed tasks retry with exponential backoff
|
||||
- **Multiple formats**: Supports Fio README, simple checkboxes, and YAML
|
||||
- **Chat progress**: Real-time progress messages in Pi chat via `pi.sendMessage`
|
||||
- **Multiple formats**: Supports simple checkboxes, and YAML
|
||||
- **Tool usage tracking**: Detects and reports tool usage (read, write, edit, bash) from task execution
|
||||
- **Git commit capture**: Captures git commit messages and generates summaries per task
|
||||
- **Configurable timeouts**: Task-level timeouts via meta blocks, with global fallback
|
||||
- **Session saving**: Saves full task output for expandable session review
|
||||
- **Resume auto-discovery**: Automatically finds and resumes interrupted execution
|
||||
- **Custom message renderer**: Compact UI labels with expandable details in Pi TUI
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
/ralpi plan [task-file] # Show execution plan
|
||||
/ralpi run [task-file] # Execute all tasks
|
||||
/ralpi status [task-file] # Show current progress
|
||||
/ralpi resume [task-file] # Resume paused execution
|
||||
/ralpi next [task-file] # Execute next batch only
|
||||
/ralpi reset [task-file] # Reset all progress
|
||||
/ralpi [task-file] # Execute all tasks
|
||||
/ralpi plan # Alias to /task-manager to plan new tasks
|
||||
/ralpi resume # Resume paused execution
|
||||
/ralpi reset # Reset progress and .ralpi directory - does not modify PRD
|
||||
```
|
||||
|
||||
## Task File Formats
|
||||
|
||||
### Fio README Format
|
||||
|
||||
```markdown
|
||||
# Project Title
|
||||
### Highly recommended to use the task-manager prompt for prd construction, it's output pairs perfectly - /task-manager or /ralpi plan
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] 01 — Setup project structure -> `tasks/01-setup.md`
|
||||
- [ ] 02 — Implement auth -> `tasks/02-auth.md`
|
||||
- [ ] 03 — Build API -> `tasks/03-api.md`
|
||||
|
||||
## Dependencies
|
||||
|
||||
1 -> 2,3
|
||||
2 -> 3
|
||||
```
|
||||
|
||||
#### Supported Dependency Formats
|
||||
|
||||
The parser supports two dependency declaration styles in the `## Dependencies` section:
|
||||
|
||||
**Arrow Notation** (recommended):
|
||||
```
|
||||
1 -> 2,3,4
|
||||
5 -> 6
|
||||
```
|
||||
This means: "Task 1 must complete before tasks 2, 3, and 4 can start."
|
||||
|
||||
**Natural Language**:
|
||||
```
|
||||
13 depends on 17, 18, 19, 20
|
||||
14 depends on 13, 15, 16
|
||||
```
|
||||
This means: "Task 13 depends on tasks 17, 18, 19, and 20."
|
||||
|
||||
**Parallel Groups** (informational only):
|
||||
```
|
||||
1, 2, 3, 4 can be done in parallel
|
||||
5, 6, 7, 8 can be done in parallel
|
||||
```
|
||||
Note: These lines are ignored by the parser. Use explicit dependencies to control execution order.
|
||||
|
||||
### Simple Checkbox Format
|
||||
|
||||
```markdown
|
||||
@@ -96,17 +54,47 @@ tasks:
|
||||
depends_on: ["01"]
|
||||
```
|
||||
|
||||
## Configuration
|
||||
## Task IDs
|
||||
|
||||
Create `.ralpi/config.yaml`:
|
||||
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`.
|
||||
|
||||
```yaml
|
||||
maxRetries: 3
|
||||
retryDelayMs: 5000
|
||||
timeoutMs: 1800000
|
||||
maxParallel: 3
|
||||
projectContext: "Additional context for all tasks"
|
||||
```
|
||||
- [ ] 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.
|
||||
|
||||
## Dependencies
|
||||
|
||||
### Arrow Notation (recommended)
|
||||
|
||||
1 -> 2,3,4
|
||||
5 -> 6
|
||||
This means: "Task 1 must complete before tasks 2, 3, and 4 can start."
|
||||
|
||||
### Natural Language
|
||||
|
||||
13 depends on 17, 18, 19, 20
|
||||
14 depends on 13, 15, 16
|
||||
|
||||
This means: "Task 13 depends on tasks 17, 18, 19, and 20."
|
||||
|
||||
### Parallel Groups (informational only)
|
||||
|
||||
1, 2, 3, 4 can be done in parallel
|
||||
5, 6, 7, 8 can be done in parallel
|
||||
|
||||
Note: These lines are ignored by the parser. Use explicit dependencies to control execution order.
|
||||
|
||||
## Configuration
|
||||
|
||||
### Task-Level Timeout
|
||||
|
||||
@@ -119,6 +107,33 @@ You can set a timeout for individual tasks using a meta block in the task file:
|
||||
|
||||
Supported formats: `10m` (minutes), `600s` (seconds), `3600000` (milliseconds)
|
||||
|
||||
### Config files
|
||||
|
||||
| Scope | Path |
|
||||
|-------|------|
|
||||
| **Global** | `~/.pi/ralpi/config.yaml` |
|
||||
| **Project** | `./.ralpi/config.yaml` |
|
||||
|
||||
```yaml
|
||||
execution:
|
||||
maxParallel: 3 # ralpi-level concurrency only
|
||||
models: # round-robin in <provider>/<model> format
|
||||
- google/gemini-3.5-flash # 1st and 3rd task in parallel
|
||||
- openai/gpt-5.5 # 2nd task in parallel
|
||||
prompts:
|
||||
projectContext: "Additional context for all tasks"
|
||||
```
|
||||
|
||||
> `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**: this is only used in parallel execution, in sequential mode the
|
||||
> parent pi session's model is used
|
||||
|
||||
## State Files
|
||||
|
||||
- `.ralpi/progress.json` - Execution progress
|
||||
|
||||
308
bun.lock
Normal file
308
bun.lock
Normal file
@@ -0,0 +1,308 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "@mikefreno/ralpi",
|
||||
"dependencies": {
|
||||
"yaml": "^2.4.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.0.0",
|
||||
"bun-types": "^1.3.14",
|
||||
"typescript": "^5.3.0",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@earendil-works/pi-coding-agent": "*",
|
||||
"@earendil-works/pi-tui": "*",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.91.1", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw=="],
|
||||
|
||||
"@aws-crypto/crc32": ["@aws-crypto/crc32@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="],
|
||||
|
||||
"@aws-crypto/sha256-browser": ["@aws-crypto/sha256-browser@5.2.0", "", { "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw=="],
|
||||
|
||||
"@aws-crypto/sha256-js": ["@aws-crypto/sha256-js@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA=="],
|
||||
|
||||
"@aws-crypto/supports-web-crypto": ["@aws-crypto/supports-web-crypto@5.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg=="],
|
||||
|
||||
"@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="],
|
||||
|
||||
"@aws-sdk/client-bedrock-runtime": ["@aws-sdk/client-bedrock-runtime@3.1048.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.11", "@aws-sdk/credential-provider-node": "^3.972.42", "@aws-sdk/eventstream-handler-node": "^3.972.16", "@aws-sdk/middleware-eventstream": "^3.972.12", "@aws-sdk/middleware-websocket": "^3.972.19", "@aws-sdk/token-providers": "3.1048.0", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/fetch-http-handler": "^5.4.2", "@smithy/node-http-handler": "^4.7.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ=="],
|
||||
|
||||
"@aws-sdk/core": ["@aws-sdk/core@3.974.19", "", { "dependencies": { "@aws-sdk/types": "^3.973.12", "@aws-sdk/xml-builder": "^3.972.29", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/core": "^3.24.6", "@smithy/signature-v4": "^5.4.6", "@smithy/types": "^4.14.3", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-SMNfLCU/41xxfFaC5Slwy8V/f1FRhakvyeeMeDeIxqNF0DzhDlXsXnJDELJYke1EtnJbfzfilW7tvulGfxMY6A=="],
|
||||
|
||||
"@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.45", "", { "dependencies": { "@aws-sdk/core": "^3.974.19", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-ZPsnLyrpDRmojKrBbJykASyLLVFkjyD+fWATeSuYgaqablijGOzxPxEKyrwUvNg+bgSQ7PkW2FTu65Xco19Gag=="],
|
||||
|
||||
"@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.47", "", { "dependencies": { "@aws-sdk/core": "^3.974.19", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/node-http-handler": "^4.7.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-1XdgHDIPbARHuzZXM7ouzIbSUZFU9dTi9k+ryMhiZU4QCam4dvwOyUEFjEHNxAZehCYUIOmsSUZ2un6BIgUkWg=="],
|
||||
|
||||
"@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.51", "", { "dependencies": { "@aws-sdk/core": "^3.974.19", "@aws-sdk/credential-provider-env": "^3.972.45", "@aws-sdk/credential-provider-http": "^3.972.47", "@aws-sdk/credential-provider-login": "^3.972.50", "@aws-sdk/credential-provider-process": "^3.972.45", "@aws-sdk/credential-provider-sso": "^3.972.50", "@aws-sdk/credential-provider-web-identity": "^3.972.50", "@aws-sdk/nested-clients": "^3.997.18", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/credential-provider-imds": "^4.3.7", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-f8sRTVyM+9BbzQKPlUP9dVVpgNEu65jFckNAAGzRfCrlaSi5AWUbCKEHIMcIYokv8pWblSKEqHkqKYZtwINnhw=="],
|
||||
|
||||
"@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.50", "", { "dependencies": { "@aws-sdk/core": "^3.974.19", "@aws-sdk/nested-clients": "^3.997.18", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-NHHsKoMhw6UylSU0XDnDc87+IQW8tRBTIe6vnOX12GSIlBDtoce6bSzONleIglCyu8d3H9bmTSfk+sIN5yh3WA=="],
|
||||
|
||||
"@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.53", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.45", "@aws-sdk/credential-provider-http": "^3.972.47", "@aws-sdk/credential-provider-ini": "^3.972.51", "@aws-sdk/credential-provider-process": "^3.972.45", "@aws-sdk/credential-provider-sso": "^3.972.50", "@aws-sdk/credential-provider-web-identity": "^3.972.50", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/credential-provider-imds": "^4.3.7", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-z/JJ8Qvf2GiTn4bw+x8k7wQjxmPpNsiwZ7ls/h1cZHikrSpS0+65lB+lafnXZlxv1lqH4k6rQwh+2UsycC662g=="],
|
||||
|
||||
"@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.45", "", { "dependencies": { "@aws-sdk/core": "^3.974.19", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-QMJXjTGLmHE4Ie03T5H4hHOLfcvMc9DaODO6b5dgte3S8ECf5bBuHUJW4cQREcYZyRkOU8iymqtiBxqF4icxZg=="],
|
||||
|
||||
"@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.50", "", { "dependencies": { "@aws-sdk/core": "^3.974.19", "@aws-sdk/nested-clients": "^3.997.18", "@aws-sdk/token-providers": "3.1064.0", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-pQ9ww4G53gwHlon1NMz25JhaBo13E9Jv+VVgjh39C/yzvby+xhSnEOb+VDYShKNCh1TbttMF/5CFCHkZrIqOcA=="],
|
||||
|
||||
"@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.50", "", { "dependencies": { "@aws-sdk/core": "^3.974.19", "@aws-sdk/nested-clients": "^3.997.18", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-9DbaPaT2aMbz18wtSpq9HVBErjBQwxykqTFgG6n8Bn05GN68mITz+G1869ekYx0mVT/BDjETj5czz/3cPgLwxA=="],
|
||||
|
||||
"@aws-sdk/eventstream-handler-node": ["@aws-sdk/eventstream-handler-node@3.972.21", "", { "dependencies": { "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-mVC0hOmwGJmNFezZ+wM8Sqfap/LjsMavEf2Evl0YWrLAcrdZOEdjnY8nRvgakVViWJSGm2eJxLuPVHGdeV06kA=="],
|
||||
|
||||
"@aws-sdk/middleware-eventstream": ["@aws-sdk/middleware-eventstream@3.972.17", "", { "dependencies": { "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-tdbnXbw73ww62ABWP0G0Z/euvFowEEvAoi/zG4NaZo7HJFpfGho/Z65HyVzkJLT1cMsUregr4pTyxljlarT0wA=="],
|
||||
|
||||
"@aws-sdk/middleware-websocket": ["@aws-sdk/middleware-websocket@3.972.27", "", { "dependencies": { "@aws-sdk/core": "^3.974.19", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/signature-v4": "^5.4.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-V/IgUogQm/NSGlNglDCkREirQXgjyrrq64vPt5qcRTGhEJJwPcUB3RyYE6iZ63WNGp4Ezc+JtVRA4QlSbhDvVQ=="],
|
||||
|
||||
"@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.18", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.19", "@aws-sdk/signature-v4-multi-region": "^3.996.33", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/node-http-handler": "^4.7.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-xBWrodBvW5SHCZV11UZUJG0pSHkLCEREIBoNbff1C1sacOUCmxJnTCPE80sCGLCtqgXg98I2MQJe2z28tcZSsw=="],
|
||||
|
||||
"@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.33", "", { "dependencies": { "@aws-sdk/types": "^3.973.12", "@smithy/signature-v4": "^5.4.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-Hn0RThJEbyOZWV2PV9Z4YD3nitGPxybmyU17dSe9b61WOBcKnqS0WTtM3c1zyZq9WnGiyrfi/i+UBPUk7cM8Ug=="],
|
||||
|
||||
"@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1048.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.11", "@aws-sdk/nested-clients": "^3.997.9", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA=="],
|
||||
|
||||
"@aws-sdk/types": ["@aws-sdk/types@3.973.12", "", { "dependencies": { "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-43ajd1NF0RMgX5k0hxCNUyEdrtFUsb2aHT2QvpktSC/2Eyb2Jr/JPVqdp0XIoaHWikZJq5tNWSLO6kB5q2eMCA=="],
|
||||
|
||||
"@aws-sdk/util-locate-window": ["@aws-sdk/util-locate-window@3.965.7", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-M0D6oIpohdNHjc7udzTHEQyot0+0iuA36jc2I9Hps+f/GtKi2HO/pyijQnCnNcwZqLB5+rtn81z3eZK/GyjAmA=="],
|
||||
|
||||
"@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.29", "", { "dependencies": { "@smithy/types": "^4.14.3", "fast-xml-parser": "5.7.3", "tslib": "^2.6.2" } }, "sha512-fk0niuGFxfi8yIJuMVM4mhwObkiQSuwZFj3tAPrLVx64Pk3BkrEIpqjzHKY4hKoEBUD6Jg/S74Zj9jy+5F3DnQ=="],
|
||||
|
||||
"@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.2.4", "", {}, "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ=="],
|
||||
|
||||
"@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="],
|
||||
|
||||
"@earendil-works/pi-agent-core": ["@earendil-works/pi-agent-core@0.79.0", "", { "dependencies": { "@earendil-works/pi-ai": "^0.79.0", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" } }, "sha512-jQOtYjRGZ7+XC/olw9euLd2V03vkAPO8u0sSnQoLbyOQZz66dEBZrklTESk34Sf3AaeBSua28wjZR48ch1aXJQ=="],
|
||||
|
||||
"@earendil-works/pi-ai": ["@earendil-works/pi-ai@0.79.0", "", { "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", "@google/genai": "1.52.0", "@mistralai/mistralai": "2.2.1", "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", "openai": "6.26.0", "partial-json": "0.1.7", "typebox": "1.1.38" }, "bin": { "pi-ai": "dist/cli.js" } }, "sha512-D/2aDoe9vcCbqAztALQcKkdqXGuaQcqAzLm8LfUhNaorwoIHkwnaAuDVlo+OkF5clpEwS8Z1bk2o8NiSrwEdsA=="],
|
||||
|
||||
"@earendil-works/pi-coding-agent": ["@earendil-works/pi-coding-agent@0.79.0", "", { "dependencies": { "@earendil-works/pi-agent-core": "^0.79.0", "@earendil-works/pi-ai": "^0.79.0", "@earendil-works/pi-tui": "^0.79.0", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", "diff": "8.0.4", "glob": "13.0.6", "highlight.js": "10.7.3", "hosted-git-info": "9.0.3", "ignore": "7.0.5", "jiti": "2.7.0", "minimatch": "10.2.5", "proper-lockfile": "4.1.2", "typebox": "1.1.38", "undici": "8.3.0", "yaml": "2.9.0" }, "optionalDependencies": { "@mariozechner/clipboard": "0.3.9" }, "bin": { "pi": "dist/cli.js" } }, "sha512-pZoXk65vFR3dAzzmPNWEX61aHnT6+BaVhTyFDQAs1DyumaMeWpvzRV9ZrGxqlbVLwhrq+0LnXbaqDAFkhe2+MQ=="],
|
||||
|
||||
"@earendil-works/pi-tui": ["@earendil-works/pi-tui@0.79.0", "", { "dependencies": { "get-east-asian-width": "1.6.0", "marked": "15.0.12" } }, "sha512-qAQWMruW7YKbk2hPcTD4INtXfvIySXifbPQ+mFY5j3J8yf2tfElkh+gGPuBvgPKPT0z9WiAkd7iySCuQq0txuQ=="],
|
||||
|
||||
"@google/genai": ["@google/genai@1.52.0", "", { "dependencies": { "google-auth-library": "^10.3.0", "p-retry": "^4.6.2", "protobufjs": "^7.5.4", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.2" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q=="],
|
||||
|
||||
"@mariozechner/clipboard": ["@mariozechner/clipboard@0.3.9", "", { "optionalDependencies": { "@mariozechner/clipboard-darwin-arm64": "0.3.9", "@mariozechner/clipboard-darwin-universal": "0.3.9", "@mariozechner/clipboard-darwin-x64": "0.3.9", "@mariozechner/clipboard-linux-arm64-gnu": "0.3.9", "@mariozechner/clipboard-linux-arm64-musl": "0.3.9", "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.9", "@mariozechner/clipboard-linux-x64-gnu": "0.3.9", "@mariozechner/clipboard-linux-x64-musl": "0.3.9", "@mariozechner/clipboard-win32-arm64-msvc": "0.3.9", "@mariozechner/clipboard-win32-x64-msvc": "0.3.9" } }, "sha512-ABnA53mdfkGZwOFUdZNv2S0CWGO/EIuPj8Vv9xmBFmSYg/qFc7ihO6q5FcQjvoE67kZpWkEc4AhD6B/os04yuA=="],
|
||||
|
||||
"@mariozechner/clipboard-darwin-arm64": ["@mariozechner/clipboard-darwin-arm64@0.3.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BfgV7vCEWZwJwZJw03r6bP5+tf0iI/ANuQYCxi9RNn7FrWB3yzGuMKCrNLRl6V761vXRdL8+OqZ0wd4TqlsNOQ=="],
|
||||
|
||||
"@mariozechner/clipboard-darwin-universal": ["@mariozechner/clipboard-darwin-universal@0.3.9", "", { "os": "darwin" }, "sha512-BGGR4iA9Z2shAjI65eI5xtyb3LYNlDW9X3gxKxDbqtbnREohsrqznov6zpKoIrsRWpzlYVEdKphS7ksJ0/ndSQ=="],
|
||||
|
||||
"@mariozechner/clipboard-darwin-x64": ["@mariozechner/clipboard-darwin-x64@0.3.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-4kURmCbS6nt8uYhtmWpUcJWyPHfmAr5dTpXD1nO3pIfa+TSQ9DbrGOYCKH+aEFW47XhQ4Vp8ZTszie+wfFvDKg=="],
|
||||
|
||||
"@mariozechner/clipboard-linux-arm64-gnu": ["@mariozechner/clipboard-linux-arm64-gnu@0.3.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-g59OkUGP2DDfCOIKypHeYgv2M55u/cKvXa5dSxFbEJ34XvIQMdcVmpKCkGUro3ZgefXiGVdwguvTMQGpHWzIXw=="],
|
||||
|
||||
"@mariozechner/clipboard-linux-arm64-musl": ["@mariozechner/clipboard-linux-arm64-musl@0.3.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-AGuJdgKsmJdm4Pych7kv3sqe591ERRaAHW3xjLooiFzn8J+PxUyof++7YZrB5Y5tpnTO+K18Og3taj2NpluCRQ=="],
|
||||
|
||||
"@mariozechner/clipboard-linux-riscv64-gnu": ["@mariozechner/clipboard-linux-riscv64-gnu@0.3.9", "", { "os": "linux", "cpu": "none" }, "sha512-DXBEAiuMpk7dhS1a9NzNxVAFi1vaKoPu7rQNgY8LIDLGrK3lnIp3nT10DUum+PKVJoJppIP+NAA8IZe4DMNDPw=="],
|
||||
|
||||
"@mariozechner/clipboard-linux-x64-gnu": ["@mariozechner/clipboard-linux-x64-gnu@0.3.9", "", { "os": "linux", "cpu": "x64" }, "sha512-WORrMLd6EpElEME7JRKfSaY34nW1P5LbdgK5YNCS1ncG2LqmITsSMEJ8nh2mpvxb3TxqbOOKgY7k9eMJYlW9Mw=="],
|
||||
|
||||
"@mariozechner/clipboard-linux-x64-musl": ["@mariozechner/clipboard-linux-x64-musl@0.3.9", "", { "os": "linux", "cpu": "x64" }, "sha512-/DHn+1DrfL6oRaPPWXaOKvonFFrni666fxd+zFqiQEfvBH0tsHVWjq9iqBk0oDp0qaPA72lIMy5BptxISBEhZQ=="],
|
||||
|
||||
"@mariozechner/clipboard-win32-arm64-msvc": ["@mariozechner/clipboard-win32-arm64-msvc@0.3.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-O5FHD3ErkMwMhNzAfu3ggy0ug4z7btZuoQgwwxlzPrwV2bxlD6WDpqBY4NCgICAgZdDKdp+loUEKVAVt8aYnhQ=="],
|
||||
|
||||
"@mariozechner/clipboard-win32-x64-msvc": ["@mariozechner/clipboard-win32-x64-msvc@0.3.9", "", { "os": "win32", "cpu": "x64" }, "sha512-ihQC3EufqEY81vhXBgVBtK4prL+wc62zJsSvxrgz7K1hsdt6OObz6v9p3Rn1OG3GJksTTKMJF0u/guMISHPhSA=="],
|
||||
|
||||
"@mistralai/mistralai": ["@mistralai/mistralai@2.2.1", "", { "dependencies": { "ws": "^8.18.0", "zod": "^3.25.0 || ^4.0.0", "zod-to-json-schema": "^3.25.0" } }, "sha512-uKU8CZmL2RzYKmplsU01hii4p3pe4HqJefpWNRWXm1Tcm0Sm4xXfwSLIy4k7ZCPlbETCGcp69E7hZs+WOJ5itQ=="],
|
||||
|
||||
"@nodable/entities": ["@nodable/entities@2.1.1", "", {}, "sha512-Pig3HxDIoMgjdEH8OCf/dkcTmLFjJRjWuq8jSnklu284/TKOPibSRERmOykiwmyXTtv61mP+44f3GMx0tLAyjg=="],
|
||||
|
||||
"@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/inquire": ["@protobufjs/inquire@1.1.2", "", {}, "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw=="],
|
||||
|
||||
"@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.1", "", {}, "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg=="],
|
||||
|
||||
"@silvia-odwyer/photon-node": ["@silvia-odwyer/photon-node@0.3.4", "", {}, "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA=="],
|
||||
|
||||
"@smithy/core": ["@smithy/core@3.24.6", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-wBXDRup6UU97VKyaiRo8AssnfStPtG0oAAfpq/bC0a1YYau8pM86YB4kM6ccoVi1mS8l/UHbn9oDM+7uozr/ug=="],
|
||||
|
||||
"@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.3.8", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-5cAM+KZC02sTqDt6NaLXyu50M/GNMd1eTzDVR8Lb0BBsVtu7RWHo47VPPEEv1vt3Yub6uzr+M5FHC+GtoT0USg=="],
|
||||
|
||||
"@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.4.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-FEwEYJ1jlBKdhe9TPzfghEi1bP55ZeEImlDkEa62bBBYzUcnB6RUCyuiS2mqKt6ZVjUbBgcNhzfIctH+Hevx9g=="],
|
||||
|
||||
"@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
|
||||
|
||||
"@smithy/node-http-handler": ["@smithy/node-http-handler@4.7.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA=="],
|
||||
|
||||
"@smithy/signature-v4": ["@smithy/signature-v4@5.4.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-Ojg4B6oIDlIr1R86xCDJt1zJWnYa0VINmqdjfe9qxWjdRivHalZ3iSlQgVqYbW0MdpFOC5XfHEWsnbmdnpIILQ=="],
|
||||
|
||||
"@smithy/types": ["@smithy/types@4.14.3", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-YupL0ZWmFtJexUN2cHzkvvF/b9pKrtAIfT1o7/oY/Ppu8IYeZ+lDPM5vZdQJaSeA132dJCqojjGC9NhXeF71VQ=="],
|
||||
|
||||
"@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
|
||||
|
||||
"@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
|
||||
|
||||
"@types/node": ["@types/node@20.19.42", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-5L7SUaFC1RyDraj2yRhyBzHTobyXHmohD100CChNtyPyleoq37Mqab5Gn8XEKI04dfN/oqPdpHk38MgcQWHbZg=="],
|
||||
|
||||
"@types/retry": ["@types/retry@0.12.0", "", {}, "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="],
|
||||
|
||||
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
||||
|
||||
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||
|
||||
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
|
||||
|
||||
"bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="],
|
||||
|
||||
"bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="],
|
||||
|
||||
"brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="],
|
||||
|
||||
"buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
|
||||
|
||||
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="],
|
||||
|
||||
"ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="],
|
||||
|
||||
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
|
||||
|
||||
"fast-xml-builder": ["fast-xml-builder@1.2.0", "", { "dependencies": { "path-expression-matcher": "^1.5.0", "xml-naming": "^0.1.0" } }, "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q=="],
|
||||
|
||||
"fast-xml-parser": ["fast-xml-parser@5.7.3", "", { "dependencies": { "@nodable/entities": "^2.1.0", "fast-xml-builder": "^1.1.7", "path-expression-matcher": "^1.5.0", "strnum": "^2.2.3" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg=="],
|
||||
|
||||
"fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
|
||||
|
||||
"formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="],
|
||||
|
||||
"gaxios": ["gaxios@7.1.5", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-5FZy72Rh8LhtjmvDrKkI+lVhrsQrVKVsItxMoDm5mNQE+xR0WVIIs+jzPSJgBvKVsLi24fZhXJIsNI0bihDzFg=="],
|
||||
|
||||
"gcp-metadata": ["gcp-metadata@8.1.2", "", { "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" } }, "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg=="],
|
||||
|
||||
"get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="],
|
||||
|
||||
"glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="],
|
||||
|
||||
"google-auth-library": ["google-auth-library@10.7.0", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.1.4", "gcp-metadata": "8.1.2", "google-logging-utils": "1.1.3", "jws": "^4.0.0" } }, "sha512-QpTAbNJ36TliZLx3TTtahR8HG0hN9RllL1e3FymOvQSIKK8JmgV58H924ub2wa2DsS3ANjjP1Aw1N+Ramc8hqQ=="],
|
||||
|
||||
"google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="],
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"highlight.js": ["highlight.js@10.7.3", "", {}, "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A=="],
|
||||
|
||||
"hosted-git-info": ["hosted-git-info@9.0.3", "", { "dependencies": { "lru-cache": "^11.1.0" } }, "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg=="],
|
||||
|
||||
"http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
|
||||
|
||||
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
||||
|
||||
"ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
|
||||
|
||||
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
|
||||
|
||||
"json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="],
|
||||
|
||||
"json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="],
|
||||
|
||||
"jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="],
|
||||
|
||||
"jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="],
|
||||
|
||||
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
|
||||
|
||||
"lru-cache": ["lru-cache@11.5.1", "", {}, "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A=="],
|
||||
|
||||
"marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="],
|
||||
|
||||
"minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
|
||||
|
||||
"minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
|
||||
|
||||
"node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
|
||||
|
||||
"openai": ["openai@6.26.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA=="],
|
||||
|
||||
"p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="],
|
||||
|
||||
"partial-json": ["partial-json@0.1.7", "", {}, "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA=="],
|
||||
|
||||
"path-expression-matcher": ["path-expression-matcher@1.5.0", "", {}, "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ=="],
|
||||
|
||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
|
||||
"path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="],
|
||||
|
||||
"proper-lockfile": ["proper-lockfile@4.1.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="],
|
||||
|
||||
"protobufjs": ["protobufjs@7.6.2", "", { "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/inquire": "^1.1.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-N9EiLovGEQOJSPF26Ij7qUGvahfEnq0eeYZ02aigIedkmz1qZSwjnP9SBITHJuF/6MYbIW4HDN8zdYjsjqJKXQ=="],
|
||||
|
||||
"retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="],
|
||||
|
||||
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||
|
||||
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||
|
||||
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||
|
||||
"signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
|
||||
|
||||
"strnum": ["strnum@2.3.0", "", {}, "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q=="],
|
||||
|
||||
"ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"typebox": ["typebox@1.1.38", "", {}, "sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici": ["undici@8.3.0", "", {}, "sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q=="],
|
||||
|
||||
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
"web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
|
||||
|
||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],
|
||||
|
||||
"xml-naming": ["xml-naming@0.1.0", "", {}, "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw=="],
|
||||
|
||||
"yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
|
||||
|
||||
"zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
|
||||
|
||||
"zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
|
||||
|
||||
"@aws-sdk/credential-provider-http/@smithy/node-http-handler": ["@smithy/node-http-handler@4.7.7", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-ZAFvHXrEk6K180EVhmZVg8GU5pUH5BSFqRs27JW3j1qEFx9YyYwWFx17x/MHcjALYimGAji7qEOlF1++be+G5A=="],
|
||||
|
||||
"@aws-sdk/credential-provider-sso/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1064.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.19", "@aws-sdk/nested-clients": "^3.997.18", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-sjI+iA4JtgeckBgKwPQF7KzWillRoNDmtpiM0TRa0syiAKFHKUSf84kPXSO3+gA7aMMSxrcxzOM2oPSecaJvEA=="],
|
||||
|
||||
"@aws-sdk/nested-clients/@smithy/node-http-handler": ["@smithy/node-http-handler@4.7.7", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-ZAFvHXrEk6K180EVhmZVg8GU5pUH5BSFqRs27JW3j1qEFx9YyYwWFx17x/MHcjALYimGAji7qEOlF1++be+G5A=="],
|
||||
|
||||
"p-retry/retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="],
|
||||
}
|
||||
}
|
||||
633
index.ts
633
index.ts
@@ -9,22 +9,29 @@ import { parseTaskFile, updateTaskInFile } from "./src/parser";
|
||||
import {
|
||||
buildExecutionPlan,
|
||||
buildSequentialPlan,
|
||||
formatDependencyChain,
|
||||
formatExecutionPlan,
|
||||
getReadyTasks,
|
||||
} from "./src/dag";
|
||||
import { ProgressTracker } from "./src/progress";
|
||||
import { buildPlanPrompt } from "./src/prompts";
|
||||
import { formatReflections } from "./src/reflection";
|
||||
import { executeBatch, type SendChatMessage } from "./src/executor";
|
||||
import {
|
||||
executeBatch,
|
||||
SPINNER_FRAMES,
|
||||
type SendChatMessage,
|
||||
} from "./src/executor";
|
||||
import {
|
||||
loadConfig,
|
||||
resolveTaskArg,
|
||||
formatProgressStatus,
|
||||
formatAllPRDsStatus,
|
||||
findProgressFile,
|
||||
writeLoopActive,
|
||||
deleteLoopActive,
|
||||
readLoopActive,
|
||||
findRalpiDir,
|
||||
} from "./src/utils";
|
||||
|
||||
const COMMANDS = ["status", "resume", "next", "reset"] as const;
|
||||
const COMMANDS = ["plan", "resume", "reset"] as const;
|
||||
|
||||
type ExecutionMode = "parallel" | "sequential";
|
||||
|
||||
@@ -66,9 +73,10 @@ async function selectExecutionMode(
|
||||
ctx: ExtensionContext,
|
||||
project: import("./src/types").Project,
|
||||
taskFile: string,
|
||||
config: import("./src/types").RalpiConfig,
|
||||
): Promise<ExecutionMode> {
|
||||
const mode = await ctx.ui.select("Execution mode for this run?", [
|
||||
"Parallel (where dependencies allow)",
|
||||
`Parallel (where dependencies allow)[${config.execution.maxParallel} max]`,
|
||||
"Sequential (one at a time)",
|
||||
]);
|
||||
const isParallel = mode?.startsWith("Parallel") ?? false;
|
||||
@@ -117,13 +125,29 @@ async function executePlanBatches(
|
||||
plan: ReturnType<typeof buildPlanByMode>,
|
||||
project: Parameters<typeof buildExecutionPlan>[0],
|
||||
taskFile: string,
|
||||
config: import("./src/types").ralpiConfig,
|
||||
config: import("./src/types").RalpiConfig,
|
||||
progress: ProgressTracker,
|
||||
ctx: ExtensionContext,
|
||||
mode: ExecutionMode,
|
||||
sendChatMessage?: SendChatMessage,
|
||||
projectDir?: string,
|
||||
): Promise<void> {
|
||||
// Write loop-active marker so widgets can be re-instantiated after a reload
|
||||
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(),
|
||||
});
|
||||
}
|
||||
|
||||
// Track failed task IDs across batches to block downstream tasks
|
||||
const failedTaskIds = new Set(progress.getFailedTaskIds());
|
||||
|
||||
try {
|
||||
for (const batch of plan.batches) {
|
||||
if (progress.getState().paused) {
|
||||
ctx.ui.notify(
|
||||
@@ -156,6 +180,49 @@ async function executePlanBatches(
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,6 +287,341 @@ export default function ralpiLoopExtension(pi: ExtensionAPI): void {
|
||||
},
|
||||
);
|
||||
|
||||
// ─── Reload detection: re-instantiate widgets when session reloads ──────
|
||||
//
|
||||
// When the user types /reload while ralpi tasks are executing, the old
|
||||
// ExtensionContext is torn down and widgets (created via ctx.ui.setWidget)
|
||||
// disappear. This handler detects the reload, reads the persisted loop-active
|
||||
// marker and progress.json, and re-creates live-status widgets that show
|
||||
// task progress with spinner animation and tool calls from session files.
|
||||
pi.on("session_start", async (event, ctx) => {
|
||||
if (event.reason !== "reload") return;
|
||||
|
||||
// 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;
|
||||
|
||||
// Load progress state
|
||||
let abortPolling = false;
|
||||
const progressPath = path.join(projectDir, ".ralpi", "progress.json");
|
||||
const sessionsDir = path.join(projectDir, ".ralpi", "sessions");
|
||||
|
||||
// Parse the task file to get task titles
|
||||
const titleMap = new Map<string, string>();
|
||||
try {
|
||||
const project = parseTaskFile(loopState.taskFile);
|
||||
for (const task of project.tasks) {
|
||||
titleMap.set(task.id, task.title);
|
||||
}
|
||||
} catch {
|
||||
// If parsing fails, just use IDs without titles
|
||||
}
|
||||
|
||||
/** Read recent tool calls from a task's session file. */
|
||||
const readRecentToolCalls = (
|
||||
taskId: string,
|
||||
maxLines = 30,
|
||||
): Array<{ name: string; label: string }> => {
|
||||
try {
|
||||
const files = fs
|
||||
.readdirSync(sessionsDir)
|
||||
.filter((f) => f.startsWith(taskId + "-"))
|
||||
.sort();
|
||||
if (files.length === 0) return [];
|
||||
const sessionPath = path.join(sessionsDir, files[files.length - 1]);
|
||||
const content = fs.readFileSync(sessionPath, "utf-8");
|
||||
const lines = content
|
||||
.split("\n")
|
||||
.filter((l) => l.trim())
|
||||
.slice(-maxLines);
|
||||
const calls: Array<{ name: string; label: string }> = [];
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const event = JSON.parse(line);
|
||||
if (event.type === "tool_execution_start") {
|
||||
calls.push({
|
||||
name: event.toolName,
|
||||
label: formatToolLabel(event.toolName, event.args),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Skip malformed lines
|
||||
}
|
||||
}
|
||||
return calls;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
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 formatToolLabel(name: string, args: unknown): string {
|
||||
const a = args as Record<string, unknown> | undefined;
|
||||
if (!a) return name;
|
||||
if (name === "bash")
|
||||
return sanitizeLabel(String(a.command ?? "").slice(0, 70));
|
||||
if (name === "write" || name === "read" || name === "edit")
|
||||
return sanitizeLabel(String(a.path ?? "").slice(0, 60));
|
||||
if (name === "grep")
|
||||
return sanitizeLabel(
|
||||
`${a.pattern ?? "?"} — ${String(a.path ?? "").slice(0, 40)}`,
|
||||
);
|
||||
if (name === "find")
|
||||
return sanitizeLabel(`${a.path ?? "."} — ${a.glob ?? "*"}`);
|
||||
if (name === "ls")
|
||||
return sanitizeLabel(String(a.path ?? ".").slice(0, 60));
|
||||
return name;
|
||||
}
|
||||
|
||||
/** Re-read progress from disk (old tasks still writing to it). */
|
||||
const readTasks = (): Record<string, { status: string }> | null => {
|
||||
try {
|
||||
const raw = fs.readFileSync(progressPath, "utf-8");
|
||||
const parsed = JSON.parse(raw) as Record<string, any>;
|
||||
return parsed.prds?.[loopState.prdKey]?.tasks ?? parsed.tasks ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Early exit: if all tasks already finished during the reload, just clean up
|
||||
const initialTasks = readTasks();
|
||||
if (initialTasks) {
|
||||
const remaining = Object.values(initialTasks).filter(
|
||||
(t) => t.status === "in_progress",
|
||||
).length;
|
||||
if (remaining === 0) {
|
||||
ctx.ui.notify("All ralpi tasks completed during reload.", "info");
|
||||
deleteLoopActive(projectDir);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Show a status notification for the reconnect
|
||||
const taskCount = loopState.taskIds.length;
|
||||
ctx.ui.notify(
|
||||
`Reconnected to running ralpi execution (${taskCount} tasks, ${loopState.mode} mode)`,
|
||||
"info",
|
||||
);
|
||||
|
||||
// Shared state for the widget
|
||||
let tickCount = 0;
|
||||
const MAX_COLLAPSED = 3;
|
||||
|
||||
if (loopState.mode === "parallel") {
|
||||
// ── Parallel mode: single batch widget ──
|
||||
const widgetKey = `ralpi-parallel-reconnect-${Date.now()}`;
|
||||
let widgetTui: { requestRender(): void } | null = null;
|
||||
|
||||
const buildBatchLines = (t: typeof ctx.ui.theme): string[] => {
|
||||
const tasks = readTasks();
|
||||
if (!tasks) return [t.fg("dim", "(waiting for progress...)")];
|
||||
|
||||
const lines: string[] = [];
|
||||
// Only show tasks that have started (in_progress, completed, failed).
|
||||
// Pending/unstarted tasks are noise after a reload.
|
||||
const sortedIds = [...loopState.taskIds].sort().filter((id) => {
|
||||
const info = tasks[id];
|
||||
return info && info.status !== "pending";
|
||||
});
|
||||
|
||||
// If no tasks have started yet, show nothing — polling will pick up
|
||||
// changes within 500ms.
|
||||
if (sortedIds.length === 0) return [t.fg("dim", "(starting tasks...)")];
|
||||
|
||||
for (const id of sortedIds) {
|
||||
const info = tasks[id]!;
|
||||
const title = titleMap.get(id);
|
||||
const header = title ? `${id} · ${title}` : id;
|
||||
|
||||
// Status icon
|
||||
if (info.status === "completed") {
|
||||
lines.push(`${t.fg("success", "✓")} ${header}`);
|
||||
} else if (info.status === "failed") {
|
||||
lines.push(`${t.fg("error", "✗")} ${header}`);
|
||||
} else if (info.status === "in_progress") {
|
||||
const frame = t.fg(
|
||||
"accent",
|
||||
SPINNER_FRAMES[tickCount % SPINNER_FRAMES.length],
|
||||
);
|
||||
lines.push(`${frame} ${header}`);
|
||||
|
||||
// Show recent tool calls for active tasks
|
||||
const toolCalls = readRecentToolCalls(id);
|
||||
if (toolCalls.length > 0) {
|
||||
if (toolCalls.length <= MAX_COLLAPSED) {
|
||||
for (let i = 0; i < toolCalls.length; i++) {
|
||||
const tc = toolCalls[i];
|
||||
const isLast = i === toolCalls.length - 1;
|
||||
const branch = isLast ? " └── " : " ├── ";
|
||||
lines.push(
|
||||
`${branch}${t.fg("accent", `[${tc.name}]`)} ${tc.label}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const shown = toolCalls.slice(-MAX_COLLAPSED);
|
||||
const remaining = toolCalls.length - shown.length;
|
||||
lines.push(t.fg("dim", ` ├── …${remaining} earlier`));
|
||||
for (let i = 0; i < shown.length; i++) {
|
||||
const tc = shown[i];
|
||||
const isLast = i === shown.length - 1;
|
||||
const branch = isLast ? " └── " : " ├── ";
|
||||
lines.push(
|
||||
`${branch}${t.fg("accent", `[${tc.name}]`)} ${tc.label}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
};
|
||||
|
||||
ctx.ui.setWidget(widgetKey, (tui, t) => {
|
||||
widgetTui = tui;
|
||||
return {
|
||||
render: () => buildBatchLines(t),
|
||||
invalidate: () => widgetTui?.requestRender(),
|
||||
};
|
||||
});
|
||||
|
||||
// 100ms tick: advances spinner frame every tick, refreshes
|
||||
// progress + tool calls every 5 ticks (500ms).
|
||||
const tickTimer = setInterval(() => {
|
||||
if (abortPolling) return;
|
||||
tickCount++;
|
||||
widgetTui?.requestRender();
|
||||
|
||||
if (tickCount % 5 === 0) {
|
||||
const tasks = readTasks();
|
||||
if (!tasks) return;
|
||||
const activeCount = Object.values(tasks).filter(
|
||||
(t) => t.status === "in_progress",
|
||||
).length;
|
||||
if (activeCount === 0) {
|
||||
clearInterval(tickTimer);
|
||||
ctx.ui.setWidget(widgetKey, undefined);
|
||||
deleteLoopActive(projectDir);
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
|
||||
// Clean up timer when extension is shut down
|
||||
pi.on("session_shutdown", () => {
|
||||
abortPolling = true;
|
||||
clearInterval(tickTimer);
|
||||
});
|
||||
} else {
|
||||
// ── Sequential mode: per-task widget ──
|
||||
const currentTaskId = loopState.taskIds.find((id) => {
|
||||
const tasks = readTasks();
|
||||
return tasks?.[id]?.status === "in_progress";
|
||||
});
|
||||
|
||||
if (currentTaskId) {
|
||||
const widgetKey = `ralpi-task-${currentTaskId}`;
|
||||
let widgetTui: { requestRender(): void } | null = null;
|
||||
|
||||
const buildLines = (t: typeof ctx.ui.theme): string[] => {
|
||||
const tasks = readTasks();
|
||||
const info = tasks?.[currentTaskId];
|
||||
const title = titleMap.get(currentTaskId);
|
||||
const header = title ? `${currentTaskId} · ${title}` : currentTaskId;
|
||||
const lines: string[] = [];
|
||||
|
||||
if (!info || info.status === "pending") {
|
||||
return [t.fg("dim", "(starting task...)")];
|
||||
}
|
||||
|
||||
if (info.status === "completed") {
|
||||
lines.push(`${t.fg("success", "✓")} ${header}`);
|
||||
} else if (info.status === "failed") {
|
||||
lines.push(`${t.fg("error", "✗")} ${header}`);
|
||||
} else if (info.status === "in_progress") {
|
||||
const frame = t.fg(
|
||||
"accent",
|
||||
SPINNER_FRAMES[tickCount % SPINNER_FRAMES.length],
|
||||
);
|
||||
lines.push(`${frame} ${header}`);
|
||||
|
||||
// Show recent tool calls
|
||||
const toolCalls = readRecentToolCalls(currentTaskId);
|
||||
if (toolCalls.length > 0) {
|
||||
const shown = toolCalls.slice(-MAX_COLLAPSED);
|
||||
const remaining = toolCalls.length - shown.length;
|
||||
if (remaining > 0) {
|
||||
lines.push(t.fg("dim", ` ├── …${remaining} earlier`));
|
||||
}
|
||||
for (let i = 0; i < shown.length; i++) {
|
||||
const tc = shown[i];
|
||||
const isLast = i === shown.length - 1;
|
||||
const branch = isLast ? " └── " : " ├── ";
|
||||
lines.push(
|
||||
`${branch}${t.fg("accent", `[${tc.name}]`)} ${tc.label}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
};
|
||||
|
||||
ctx.ui.setWidget(widgetKey, (tui, t) => {
|
||||
widgetTui = tui;
|
||||
return {
|
||||
render: () => buildLines(t),
|
||||
invalidate: () => widgetTui?.requestRender(),
|
||||
};
|
||||
});
|
||||
|
||||
const tickTimer = setInterval(() => {
|
||||
if (abortPolling) return;
|
||||
tickCount++;
|
||||
widgetTui?.requestRender();
|
||||
|
||||
if (tickCount % 5 === 0) {
|
||||
const tasks = readTasks();
|
||||
if (!tasks) return;
|
||||
const status = tasks[currentTaskId]?.status;
|
||||
if (status !== "in_progress") {
|
||||
clearInterval(tickTimer);
|
||||
// Keep widget visible a moment, then clean up
|
||||
setTimeout(() => {
|
||||
ctx.ui.setWidget(widgetKey, undefined);
|
||||
deleteLoopActive(projectDir);
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
|
||||
pi.on("session_shutdown", () => {
|
||||
abortPolling = true;
|
||||
clearInterval(tickTimer);
|
||||
});
|
||||
} else {
|
||||
// No task actively in progress — show a "resume" hint
|
||||
ctx.ui.notify(
|
||||
"No running task found. Use /ralpi resume to continue execution.",
|
||||
"warning",
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
pi.registerCommand("ralpi", {
|
||||
description:
|
||||
"Execute tasks from a task file using DAG-based dependency resolution",
|
||||
@@ -248,21 +650,37 @@ export default function ralpiLoopExtension(pi: ExtensionAPI): void {
|
||||
return handlePlan(ctx, parts);
|
||||
}
|
||||
if (looksLikePath(parts[0])) {
|
||||
return handleRun(ctx, parts, sendProgress);
|
||||
return handleRun(
|
||||
ctx,
|
||||
parts,
|
||||
sendProgress,
|
||||
ctx.model,
|
||||
pi.getThinkingLevel(),
|
||||
);
|
||||
}
|
||||
|
||||
const command = parts[0];
|
||||
switch (command) {
|
||||
case "run":
|
||||
return handleRun(ctx, parts.slice(1), sendProgress);
|
||||
return handleRun(
|
||||
ctx,
|
||||
parts.slice(1),
|
||||
sendProgress,
|
||||
ctx.model,
|
||||
pi.getThinkingLevel(),
|
||||
);
|
||||
case "plan":
|
||||
return handlePlan(ctx, parts.slice(1));
|
||||
case "status":
|
||||
return handleStatus(ctx, parts.slice(1));
|
||||
pi.sendUserMessage("@task-manager");
|
||||
ctx.ui.notify("Opening Task Manager...", "info");
|
||||
return;
|
||||
case "resume":
|
||||
return handleResume(ctx, parts.slice(1), sendProgress);
|
||||
case "next":
|
||||
return handleNext(ctx, parts.slice(1), sendProgress);
|
||||
return handleResume(
|
||||
ctx,
|
||||
parts.slice(1),
|
||||
sendProgress,
|
||||
ctx.model,
|
||||
pi.getThinkingLevel(),
|
||||
);
|
||||
case "reset":
|
||||
return handleReset(ctx, parts.slice(1));
|
||||
default: {
|
||||
@@ -316,6 +734,8 @@ async function handleRun(
|
||||
ctx: ExtensionContext,
|
||||
args: string[],
|
||||
sendChatMessage?: SendChatMessage,
|
||||
parentModel?: unknown,
|
||||
parentThinkingLevel?: unknown,
|
||||
): Promise<void> {
|
||||
const taskFile = resolveTaskArg(args[0] || "README.md", process.cwd());
|
||||
|
||||
@@ -323,7 +743,13 @@ async function handleRun(
|
||||
// auto-resume instead of starting fresh
|
||||
const existingProgress = findProgressFile(process.cwd(), taskFile);
|
||||
if (existingProgress) {
|
||||
return handleResume(ctx, args.slice(0, 1), sendChatMessage);
|
||||
return handleResume(
|
||||
ctx,
|
||||
args.slice(0, 1),
|
||||
sendChatMessage,
|
||||
parentModel,
|
||||
parentThinkingLevel,
|
||||
);
|
||||
}
|
||||
|
||||
// No existing progress for this task — check for any progress at all
|
||||
@@ -336,7 +762,13 @@ async function handleRun(
|
||||
);
|
||||
|
||||
if (shouldResume?.startsWith("Yes")) {
|
||||
return handleResume(ctx, [], sendChatMessage);
|
||||
return handleResume(
|
||||
ctx,
|
||||
[],
|
||||
sendChatMessage,
|
||||
parentModel,
|
||||
parentThinkingLevel,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -346,21 +778,28 @@ async function handleRun(
|
||||
|
||||
const project = parseTaskFile(taskFile);
|
||||
const config = loadConfig(projectDir);
|
||||
config.model = parentModel ?? ctx.model;
|
||||
config.thinkingLevel = parentThinkingLevel;
|
||||
const progress = new ProgressTracker(projectDir, taskFile);
|
||||
|
||||
// Set initial status
|
||||
ctx.ui.setStatus(
|
||||
"ralpi",
|
||||
`Starting ${project.tasks.length} tasks from ${path.basename(taskFile)}`,
|
||||
);
|
||||
|
||||
const completed = buildCompletedSet(progress, project);
|
||||
const mode = await selectExecutionMode(ctx, project, taskFile);
|
||||
const mode = await selectExecutionMode(ctx, project, taskFile, config);
|
||||
const plan = buildPlanByMode(mode, project, completed);
|
||||
|
||||
// Show execution plan before starting so user can see batch breakdown
|
||||
// Show dependency chain + execution plan before starting
|
||||
const depChain = formatDependencyChain(project);
|
||||
const formattedPlan = formatExecutionPlan(plan);
|
||||
ctx.ui.notify(`${formattedPlan}\n\nStarting ${mode} execution...`, "info");
|
||||
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,
|
||||
@@ -387,46 +826,7 @@ async function handleRun(
|
||||
}
|
||||
|
||||
// ─── /ralpi status ───────────────────────────────────────────────────────────
|
||||
|
||||
async function handleStatus(
|
||||
ctx: ExtensionContext,
|
||||
args: string[],
|
||||
): Promise<void> {
|
||||
if (args[0]) {
|
||||
const taskFile = resolveTaskArg(args[0], process.cwd());
|
||||
const existingProgress = findProgressFile(process.cwd(), taskFile);
|
||||
if (existingProgress) {
|
||||
const projectDir = path.dirname(path.dirname(existingProgress.path));
|
||||
const progress = new ProgressTracker(
|
||||
projectDir,
|
||||
taskFile,
|
||||
existingProgress.prdKey,
|
||||
);
|
||||
ctx.ui.notify(formatProgressStatus(progress.getState()), "info");
|
||||
return;
|
||||
}
|
||||
// No progress yet for this task — parse and show plan instead
|
||||
const project = parseTaskFile(taskFile);
|
||||
ctx.ui.notify(
|
||||
`No progress for ${path.basename(taskFile)}. ${
|
||||
project.tasks.length
|
||||
} tasks found.\nUse /ralpi run ${args[0]} to start.`,
|
||||
"info",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const found = findProgressFile(process.cwd());
|
||||
if (!found) {
|
||||
ctx.ui.notify(
|
||||
"No .ralpi/progress.json found. Start with /ralpi run [task-file]",
|
||||
"warning",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.ui.notify(formatAllPRDsStatus(found.state), "info");
|
||||
}
|
||||
// (removed — use /ralpi plan to invoke @task-manager)
|
||||
|
||||
// ─── /ralpi resume ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -434,6 +834,8 @@ async function handleResume(
|
||||
ctx: ExtensionContext,
|
||||
args: string[],
|
||||
sendChatMessage?: SendChatMessage,
|
||||
parentModel?: unknown,
|
||||
parentThinkingLevel?: unknown,
|
||||
): Promise<void> {
|
||||
let taskFile: string;
|
||||
let projectDir: string;
|
||||
@@ -473,17 +875,30 @@ async function handleResume(
|
||||
);
|
||||
}
|
||||
const config = loadConfig(projectDir);
|
||||
config.model = parentModel ?? ctx.model;
|
||||
config.thinkingLevel = parentThinkingLevel;
|
||||
const progress = new ProgressTracker(projectDir, taskFile, found.prdKey);
|
||||
|
||||
progress.setPaused(false);
|
||||
|
||||
// Set resume status
|
||||
ctx.ui.setStatus("ralpi", `Resuming from ${path.basename(taskFile)}`);
|
||||
|
||||
const completed = buildCompletedSet(progress, project);
|
||||
const mode = await selectExecutionMode(ctx, project, taskFile);
|
||||
const mode = await selectExecutionMode(ctx, project, taskFile, config);
|
||||
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,
|
||||
@@ -500,85 +915,7 @@ async function handleResume(
|
||||
}
|
||||
|
||||
// ─── /ralpi next ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function handleNext(
|
||||
ctx: ExtensionContext,
|
||||
args: string[],
|
||||
sendChatMessage?: SendChatMessage,
|
||||
): Promise<void> {
|
||||
let taskFile: string;
|
||||
let projectDir: string;
|
||||
let found: ReturnType<typeof findProgressFile>;
|
||||
|
||||
if (args[0]) {
|
||||
taskFile = resolveTaskArg(args[0], process.cwd());
|
||||
found = findProgressFile(process.cwd(), taskFile);
|
||||
if (found) {
|
||||
projectDir = path.dirname(path.dirname(found.path));
|
||||
} else {
|
||||
projectDir = process.cwd();
|
||||
}
|
||||
} else {
|
||||
found = findProgressFile(process.cwd());
|
||||
if (!found) {
|
||||
ctx.ui.notify(
|
||||
"No .ralpi/progress.json found. Start with /ralpi run [task-file]",
|
||||
"warning",
|
||||
);
|
||||
return;
|
||||
}
|
||||
taskFile = found.state.prds
|
||||
? Object.values(found.state.prds)[0].sourcePath
|
||||
: found.state.sourcePath;
|
||||
projectDir = path.dirname(path.dirname(found.path));
|
||||
}
|
||||
|
||||
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);
|
||||
const progress = new ProgressTracker(projectDir, taskFile, found?.prdKey);
|
||||
|
||||
const completed = buildCompletedSet(progress, project);
|
||||
const ready = getReadyTasks(project, completed);
|
||||
|
||||
if (ready.length === 0) {
|
||||
ctx.ui.notify(
|
||||
"No tasks ready to execute. All tasks completed or blocked.",
|
||||
"info",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const nextBatch = ready.slice(
|
||||
0,
|
||||
config.execution.maxParallel || ready.length,
|
||||
);
|
||||
|
||||
for (const task of nextBatch) {
|
||||
await executeBatch(
|
||||
[task],
|
||||
project,
|
||||
config,
|
||||
progress,
|
||||
ctx,
|
||||
{ parallel: false },
|
||||
sendChatMessage,
|
||||
projectDir,
|
||||
);
|
||||
updateTaskInFile(taskFile, task.id, progress.getTaskStatus(task.id));
|
||||
}
|
||||
|
||||
ctx.ui.notify(
|
||||
`Executed: ${nextBatch
|
||||
.map((t) => t.id)
|
||||
.join(", ")}\n\n${formatProgressStatus(progress.getState())}`,
|
||||
"info",
|
||||
);
|
||||
}
|
||||
// (removed — use /ralpi run to execute tasks)
|
||||
|
||||
// ─── /ralpi reset ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
46
package.json
46
package.json
@@ -1,33 +1,51 @@
|
||||
{
|
||||
"name": "ralpi-loop",
|
||||
"version": "1.0.0",
|
||||
"description": "Execute tasks from task files using DAG-based dependency resolution with persistent progress tracking",
|
||||
"main": "dist/index.js",
|
||||
"name": "@mikefreno/ralpi",
|
||||
"version": "0.2.5",
|
||||
"description": "Execute tasks from task files/PRD's using DAG-based dependency resolution with persistent progress tracking",
|
||||
"keywords": [
|
||||
"pi-package",
|
||||
"pi-extension",
|
||||
"task-runner",
|
||||
"dag",
|
||||
"task-manager",
|
||||
"ralpi-loop",
|
||||
"ralph-loop",
|
||||
"prd"
|
||||
],
|
||||
"author": "",
|
||||
"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": [
|
||||
"dist/",
|
||||
"index.ts",
|
||||
"src/",
|
||||
"skills/",
|
||||
"prompts/",
|
||||
"index.ts"
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"watch": "tsc --watch",
|
||||
"prepublishOnly": "npm run build"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"prepublishOnly": "tsc --noEmit",
|
||||
"test": "bun test"
|
||||
},
|
||||
"engines": {
|
||||
"bun": ">=1.1.0"
|
||||
},
|
||||
"pi": {
|
||||
"extensions": [
|
||||
"./dist/index.js"
|
||||
"./index.ts"
|
||||
],
|
||||
"skills": [
|
||||
"./skills"
|
||||
],
|
||||
"prompts": [
|
||||
"./prompts"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -37,8 +55,12 @@
|
||||
"@earendil-works/pi-coding-agent": "*",
|
||||
"@earendil-works/pi-tui": "*"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.0.0",
|
||||
"bun-types": "^1.3.14",
|
||||
"typescript": "^5.3.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ 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
|
||||
@@ -22,6 +23,7 @@ You are a Task Manager (@task-manager), an expert at breaking down complex softw
|
||||
## Mandatory Two-Phase Workflow
|
||||
|
||||
### Phase 1: Planning (Approval Required)
|
||||
|
||||
When given a complex feature request:
|
||||
|
||||
1. **Analyze the feature** to identify:
|
||||
@@ -36,21 +38,27 @@ When given a complex feature request:
|
||||
- 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.
|
||||
@@ -67,6 +75,7 @@ Once approved:
|
||||
|
||||
**Feature Index Template** (`tasks/{feature}/README.md`):
|
||||
```
|
||||
|
||||
# {Feature Title}
|
||||
|
||||
Objective: {one-liner}
|
||||
@@ -74,17 +83,22 @@ 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:
|
||||
@@ -95,40 +109,54 @@ meta:
|
||||
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...)
|
||||
- **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
|
||||
|
||||
293
src/dag.ts
293
src/dag.ts
@@ -1,4 +1,38 @@
|
||||
import type { Task, ExecutionBatch, ExecutionPlan, Project } from "./types";
|
||||
import type {
|
||||
Task,
|
||||
ExecutionBatch,
|
||||
ExecutionPlan,
|
||||
Project,
|
||||
ParallelGroup,
|
||||
} from "./types";
|
||||
|
||||
// ─── Blocked Tasks ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Find tasks that are blocked (direct or transitive) due to failed dependencies.
|
||||
* Returns a Set of blocked task IDs.
|
||||
*/
|
||||
export function getBlockedTasks(
|
||||
pendingTasks: Task[],
|
||||
failedTaskIds: Set<string>,
|
||||
): Set<string> {
|
||||
const blocked = new Set<string>();
|
||||
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (const task of pendingTasks) {
|
||||
if (blocked.has(task.id)) continue;
|
||||
const deps = task.dependencies || [];
|
||||
if (deps.some((dep) => failedTaskIds.has(dep) || blocked.has(dep))) {
|
||||
blocked.add(task.id);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return blocked;
|
||||
}
|
||||
|
||||
// ─── Main Entry ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -10,26 +44,46 @@ export function buildExecutionPlan(
|
||||
project: Project,
|
||||
completed: Set<string>,
|
||||
parallelGroup?: number,
|
||||
failedTaskIds: Set<string> = new Set(),
|
||||
): ExecutionPlan {
|
||||
const allTasks = new Map(project.tasks.map((t) => [t.id, t]));
|
||||
// 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),
|
||||
);
|
||||
|
||||
// Filter out already completed tasks
|
||||
const pendingTasks = project.tasks.filter((t) => !completed.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, use group-based batching
|
||||
// If parallel_group is explicitly set (legacy config flag), use group-based batching
|
||||
if (parallelGroup !== undefined) {
|
||||
return {
|
||||
batches: buildParallelGroupBatches(pendingTasks, allTasks, completed),
|
||||
batches: buildParallelGroupBatchesLegacy(pendingTasks, failedTaskIds),
|
||||
totalTasks: pendingTasks.length,
|
||||
skippedTasks: project.tasks.filter((t) => completed.has(t.id)),
|
||||
skippedTasks,
|
||||
};
|
||||
}
|
||||
|
||||
// Use dependency-based Kahn's algorithm
|
||||
return {
|
||||
batches: buildBatches(pendingTasks, allTasks, completed),
|
||||
batches: buildBatches(pendingTasks, failedTaskIds),
|
||||
totalTasks: pendingTasks.length,
|
||||
skippedTasks: project.tasks.filter((t) => completed.has(t.id)),
|
||||
skippedTasks,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -41,9 +95,18 @@ export function buildExecutionPlan(
|
||||
export function buildSequentialPlan(
|
||||
project: Project,
|
||||
completed: Set<string>,
|
||||
failedTaskIds: Set<string> = new Set(),
|
||||
): ExecutionPlan {
|
||||
const pendingTasks = project.tasks.filter((t) => !completed.has(t.id));
|
||||
const batches: ExecutionBatch[] = pendingTasks.map((task, i) => ({
|
||||
|
||||
// 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,
|
||||
}));
|
||||
@@ -51,7 +114,7 @@ export function buildSequentialPlan(
|
||||
return {
|
||||
batches,
|
||||
totalTasks: pendingTasks.length,
|
||||
skippedTasks: project.tasks.filter((t) => completed.has(t.id)),
|
||||
skippedTasks,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -59,12 +122,15 @@ export function buildSequentialPlan(
|
||||
|
||||
function buildBatches(
|
||||
pendingTasks: Task[],
|
||||
allTasks: Map<string, Task>,
|
||||
completed: Set<string>,
|
||||
failedTaskIds: Set<string>,
|
||||
): ExecutionBatch[] {
|
||||
const batches: ExecutionBatch[] = [];
|
||||
const done = new Set(completed);
|
||||
const remaining = new Set(pendingTasks.map((t) => t.id));
|
||||
const done = new Set<string>();
|
||||
const blocked = getBlockedTasks(pendingTasks, failedTaskIds);
|
||||
const pendingSet = new Set(pendingTasks.map((t) => t.id));
|
||||
const remaining = new Set(
|
||||
pendingTasks.filter((t) => !blocked.has(t.id)).map((t) => t.id),
|
||||
);
|
||||
|
||||
while (remaining.size > 0) {
|
||||
// Find tasks whose dependencies are all satisfied
|
||||
@@ -74,7 +140,7 @@ function buildBatches(
|
||||
|
||||
const deps = task.dependencies || [];
|
||||
const depsSatisfied = deps.every(
|
||||
(dep) => done.has(dep) || !allTasks.has(dep),
|
||||
(dep) => done.has(dep) || !pendingSet.has(dep),
|
||||
);
|
||||
|
||||
if (depsSatisfied) {
|
||||
@@ -100,20 +166,81 @@ function buildBatches(
|
||||
return batches;
|
||||
}
|
||||
|
||||
// ─── Parallel Group Batching ─────────────────────────────────────────────────
|
||||
// ─── Group-Aware Batching ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build batches from explicit parallel_group values.
|
||||
* Groups execute in ascending order; tasks within a group run concurrently.
|
||||
* 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 buildParallelGroupBatches(
|
||||
function buildGroupAwareBatches(
|
||||
_project: Project,
|
||||
pendingTasks: Task[],
|
||||
allTasks: Map<string, Task>,
|
||||
completed: Set<string>,
|
||||
failedTaskIds: Set<string>,
|
||||
): ExecutionBatch[] {
|
||||
const blocked = getBlockedTasks(pendingTasks, failedTaskIds);
|
||||
const activeTasks = pendingTasks.filter((t) => !blocked.has(t.id));
|
||||
|
||||
// Standard Kahn's algorithm across ALL tasks — parallel groups are
|
||||
// metadata for display, not scheduling constraints.
|
||||
const pendingSet = new Set(pendingTasks.map((t) => t.id));
|
||||
const done = new Set<string>();
|
||||
const remaining = new Set(activeTasks.map((t) => t.id));
|
||||
const batches: ExecutionBatch[] = [];
|
||||
|
||||
while (remaining.size > 0) {
|
||||
const ready: Task[] = [];
|
||||
for (const task of activeTasks) {
|
||||
if (!remaining.has(task.id)) continue;
|
||||
const deps = task.dependencies || [];
|
||||
const depsSatisfied = deps.every(
|
||||
(dep) => done.has(dep) || !pendingSet.has(dep),
|
||||
);
|
||||
if (depsSatisfied) {
|
||||
ready.push(task);
|
||||
}
|
||||
}
|
||||
|
||||
if (ready.length === 0) {
|
||||
throw new Error(
|
||||
`Dependency cycle detected: ${Array.from(remaining).join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
batches.push({ tasks: ready, batchIndex: batches.length });
|
||||
for (const task of ready) {
|
||||
done.add(task.id);
|
||||
remaining.delete(task.id);
|
||||
}
|
||||
}
|
||||
|
||||
return batches;
|
||||
}
|
||||
|
||||
// ─── Legacy Parallel Group Batching ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Legacy: build batches from explicit parallel_group values only.
|
||||
* Groups execute in ascending order; tasks within a group run concurrently.
|
||||
* Does NOT respect intra-group dependencies.
|
||||
*/
|
||||
function buildParallelGroupBatchesLegacy(
|
||||
pendingTasks: Task[],
|
||||
failedTaskIds: Set<string>,
|
||||
): ExecutionBatch[] {
|
||||
const blocked = getBlockedTasks(pendingTasks, failedTaskIds);
|
||||
const activeTasks = pendingTasks.filter((t) => !blocked.has(t.id));
|
||||
|
||||
const groups = new Map<number, Task[]>();
|
||||
|
||||
for (const task of pendingTasks) {
|
||||
for (const task of activeTasks) {
|
||||
const group = task.parallelGroup ?? 0;
|
||||
if (!groups.has(group)) groups.set(group, []);
|
||||
groups.get(group)!.push(task);
|
||||
@@ -121,7 +248,7 @@ function buildParallelGroupBatches(
|
||||
|
||||
const sortedGroups = Array.from(groups.entries()).sort((a, b) => a[0] - b[0]);
|
||||
|
||||
return sortedGroups.map(([groupNum, tasks], i) => ({
|
||||
return sortedGroups.map(([_groupNum, tasks], i) => ({
|
||||
tasks,
|
||||
batchIndex: i,
|
||||
}));
|
||||
@@ -268,18 +395,125 @@ export function getCriticalPath(project: Project): Task[] {
|
||||
return path;
|
||||
}
|
||||
|
||||
// ─── Format Dependency Chain ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Format the dependency DAG as a tree for display.
|
||||
* Rooted at tasks with no dependencies, showing what depends on what.
|
||||
*/
|
||||
export function formatDependencyChain(project: Project): string {
|
||||
const taskMap = new Map(project.tasks.map((t) => [t.id, t]));
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push("## Dependency Chain");
|
||||
lines.push("");
|
||||
|
||||
if (project.tasks.length === 0) {
|
||||
lines.push("(no tasks)");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// Build reverse dependency map: taskId → [dependent taskIds]
|
||||
const dependents = new Map<string, string[]>();
|
||||
for (const task of project.tasks) {
|
||||
dependents.set(task.id, []);
|
||||
}
|
||||
for (const task of project.tasks) {
|
||||
for (const dep of task.dependencies) {
|
||||
if (dependents.has(dep)) {
|
||||
dependents.get(dep)!.push(task.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Root tasks: those with no dependencies
|
||||
const roots = project.tasks.filter((t) => t.dependencies.length === 0);
|
||||
const rendered = new Set<string>();
|
||||
|
||||
function renderNode(taskId: string, prefix: string, isLast: boolean): void {
|
||||
const task = taskMap.get(taskId);
|
||||
if (!task) return;
|
||||
|
||||
const alreadyRendered = rendered.has(taskId);
|
||||
rendered.add(taskId);
|
||||
|
||||
const connector = prefix ? (isLast ? "└── " : "├── ") : "";
|
||||
|
||||
if (alreadyRendered) {
|
||||
lines.push(`${prefix}${connector}${task.id} · ${task.title}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const deps =
|
||||
task.dependencies.length > 0
|
||||
? ` ← needs ${task.dependencies.join(", ")}`
|
||||
: " (root)";
|
||||
|
||||
lines.push(
|
||||
`${prefix}${connector}${task.id} · ${task.title}${prefix ? "" : deps}`,
|
||||
);
|
||||
|
||||
const children = (dependents.get(taskId) || [])
|
||||
.filter((c) => c !== taskId)
|
||||
.sort();
|
||||
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
const childPrefix = prefix + (isLast ? " " : "│ ");
|
||||
renderNode(children[i], childPrefix, i === children.length - 1);
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < roots.length; i++) {
|
||||
renderNode(roots[i].id, "", i === roots.length - 1);
|
||||
}
|
||||
|
||||
// Tasks not reached from any root (have deps but no root-traversable path)
|
||||
const unreached = project.tasks.filter((t) => !rendered.has(t.id));
|
||||
if (unreached.length > 0) {
|
||||
lines.push("");
|
||||
lines.push("Orphan tasks (dependencies not in task list):");
|
||||
for (const t of unreached) {
|
||||
const deps =
|
||||
t.dependencies.length > 0
|
||||
? ` ← needs ${t.dependencies.join(", ")}`
|
||||
: "";
|
||||
lines.push(` ${t.id} · ${t.title}${deps}`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// ─── Format Execution Plan ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Format the execution plan for display
|
||||
*/
|
||||
export function formatExecutionPlan(plan: ExecutionPlan): string {
|
||||
/**
|
||||
* Format the execution plan for display, optionally with parallel group annotations
|
||||
*/
|
||||
export function formatExecutionPlan(
|
||||
plan: ExecutionPlan,
|
||||
parallelGroups?: ParallelGroup[],
|
||||
): string {
|
||||
const lines: string[] = [];
|
||||
lines.push("## Execution Plan");
|
||||
lines.push("");
|
||||
lines.push(`Total tasks: ${plan.totalTasks}`);
|
||||
lines.push(`Batches: ${plan.batches.length}`);
|
||||
|
||||
// Build a lookup: taskId → group label
|
||||
const groupLabel = new Map<string, string>();
|
||||
if (parallelGroups) {
|
||||
for (const g of parallelGroups) {
|
||||
for (const id of g.taskIds) {
|
||||
if (g.label) {
|
||||
groupLabel.set(id, g.label);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (plan.skippedTasks.length > 0) {
|
||||
lines.push(
|
||||
`Already completed: ${plan.skippedTasks.map((t) => t.id).join(", ")}`,
|
||||
@@ -290,7 +524,14 @@ export function formatExecutionPlan(plan: ExecutionPlan): string {
|
||||
for (const batch of plan.batches) {
|
||||
lines.push(`### Batch ${batch.batchIndex + 1}`);
|
||||
for (const task of batch.tasks) {
|
||||
lines.push(`- ${task.id}: ${task.title}`);
|
||||
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("");
|
||||
}
|
||||
|
||||
778
src/executor.ts
778
src/executor.ts
@@ -1,3 +1,4 @@
|
||||
import { truncateToWidth } from "@earendil-works/pi-tui";
|
||||
import * as path from "node:path";
|
||||
import type { Task, Project, Reflection, ToolUsage } from "./types";
|
||||
import type { RalpiConfig } from "./types";
|
||||
@@ -5,14 +6,17 @@ import type { ProgressTracker } from "./progress";
|
||||
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import { buildTaskPrompt } from "./prompts";
|
||||
import { extractReflection } from "./reflection";
|
||||
import { WidgetBatcher } from "./widget-batcher";
|
||||
import {
|
||||
runAgentSession,
|
||||
writeFileSafe,
|
||||
ensureDir,
|
||||
captureGitCommits,
|
||||
hasUncommittedChanges,
|
||||
getGitStatusPorcelain,
|
||||
getGitDiff,
|
||||
formatDuration,
|
||||
} from "./utils";
|
||||
import { updateTaskInFile } from "./parser";
|
||||
|
||||
/** Optional callback to post a progress message into the chat history. */
|
||||
export type SendChatMessage = (
|
||||
@@ -26,6 +30,110 @@ export interface ToolCallEntry {
|
||||
label: string;
|
||||
}
|
||||
|
||||
// ─── 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<string, number>();
|
||||
|
||||
constructor(models: unknown[]) {
|
||||
this.models = models;
|
||||
this.freeSlots = [];
|
||||
}
|
||||
|
||||
get length(): number {
|
||||
return this.models.length;
|
||||
}
|
||||
|
||||
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<string, ParallelWidgetEntry>;
|
||||
|
||||
// ─── Run Single Task ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -40,7 +148,9 @@ export async function runTask(
|
||||
ctx: ExtensionContext,
|
||||
sendChatMessage?: SendChatMessage,
|
||||
projectDir: string = project.sourceDir,
|
||||
batcher?: WidgetBatcher,
|
||||
parallelState?: ParallelWidgetState,
|
||||
assignedModel?: unknown,
|
||||
batchRender?: () => void,
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
reflection?: Reflection;
|
||||
@@ -48,7 +158,6 @@ export async function runTask(
|
||||
durationMs: number;
|
||||
toolUsage?: ToolUsage;
|
||||
outputPreview?: string;
|
||||
sessionFile?: string;
|
||||
commitMessages?: string[];
|
||||
commitSummary?: string;
|
||||
}> {
|
||||
@@ -62,74 +171,93 @@ export async function runTask(
|
||||
config.prompts.projectContext,
|
||||
);
|
||||
|
||||
// Write prompt to .ralpi/ with timestamp (for debugging)
|
||||
const ralpiDir = path.join(projectDir, ".ralpi");
|
||||
ensureDir(ralpiDir);
|
||||
const promptFile = path.join(ralpiDir, `prompt-${startMs}.md`);
|
||||
writeFileSafe(promptFile, prompt);
|
||||
|
||||
// Footer shows just the task title (no batch prefix)
|
||||
ctx.ui.setStatus("ralpi", task.title);
|
||||
|
||||
const taskHeader = `${task.id} · ${task.title}`;
|
||||
|
||||
// Live progress widget above the editor — animated spinner + tool call tree
|
||||
// Using setWidget instead of setWorkingMessage because the working message area
|
||||
// is only visible during parent agent streaming, not during extension command execution.
|
||||
// Widget key is unique per task so parallel tasks each get their own widget.
|
||||
// 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}`;
|
||||
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||
let frameIndex = 0;
|
||||
const theme = ctx.ui.theme;
|
||||
const MAX_COLLAPSED = 3;
|
||||
|
||||
const toolCalls: ToolCallEntry[] = [];
|
||||
let widgetTui: { requestRender(): void } | null = null;
|
||||
|
||||
const updateWidget = () => {
|
||||
const frame = theme.fg("accent", SPINNER_FRAMES[frameIndex]);
|
||||
const lines = [`${frame} ${taskHeader}`];
|
||||
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;
|
||||
|
||||
if (remaining > 0) {
|
||||
lines.push(theme.fg("dim", ` ├── ${remaining} more`));
|
||||
}
|
||||
|
||||
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 = theme.fg("accent", `[${entry.name}]`);
|
||||
lines.push(`${branch}${tag} ${entry.label}`);
|
||||
const tag = t.fg("accent", `[${entry.name}]`);
|
||||
lines.push(
|
||||
truncateToWidth(`${branch}${tag} ${entry.label}`, effectiveWidth),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (batcher) {
|
||||
batcher.schedule(widgetKey, lines);
|
||||
} else {
|
||||
ctx.ui.setWidget(widgetKey, lines);
|
||||
}
|
||||
return lines;
|
||||
};
|
||||
|
||||
// Smooth spinner animation at 100ms intervals
|
||||
const spinnerTimer = setInterval(() => {
|
||||
frameIndex = (frameIndex + 1) % SPINNER_FRAMES.length;
|
||||
updateWidget();
|
||||
}, 100);
|
||||
ctx.ui.setWidget(widgetKey, (tui, t) => {
|
||||
widgetTui = tui;
|
||||
return {
|
||||
render: (width?: number) => buildLines(t, width),
|
||||
invalidate: () => widgetTui?.requestRender(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Initial display
|
||||
updateWidget();
|
||||
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;
|
||||
|
||||
// Pre-create session file path so events stream to disk (avoids 300+ MB in-memory accumulation)
|
||||
const sessionsDir = path.join(ralpiDir, "sessions");
|
||||
ensureDir(sessionsDir);
|
||||
const sessionFilePath = path.join(sessionsDir, `${task.id}-${startMs}.txt`);
|
||||
|
||||
// Run task asynchronously via Pi SDK — event loop stays responsive
|
||||
const output = await runAgentSession(
|
||||
prompt,
|
||||
@@ -142,32 +270,44 @@ export async function runTask(
|
||||
name: event.toolName,
|
||||
label,
|
||||
});
|
||||
updateWidget();
|
||||
if (isParallel) {
|
||||
const entry = parallelState!.get(task.id);
|
||||
if (entry) {
|
||||
entry.toolCalls.push({ name: event.toolName, label });
|
||||
}
|
||||
batchRender?.();
|
||||
} else {
|
||||
requestRender();
|
||||
}
|
||||
}
|
||||
},
|
||||
undefined, // no abort signal
|
||||
sessionFilePath, // stream events to file
|
||||
assignedModel ?? config.model,
|
||||
config.thinkingLevel,
|
||||
);
|
||||
|
||||
const durationMs = Date.now() - startMs;
|
||||
|
||||
// Clear progress widget and status after task finishes
|
||||
clearInterval(spinnerTimer);
|
||||
if (batcher) {
|
||||
batcher.scheduleRemove(widgetKey);
|
||||
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);
|
||||
}
|
||||
ctx.ui.setStatus("ralpi", undefined);
|
||||
|
||||
if (!output.success) {
|
||||
sendChatMessage?.(`✗ ${taskHeader} — ${output.error}`);
|
||||
ctx.ui.notify(`Task ${task.id} failed: ${output.error}`, "error");
|
||||
// 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,
|
||||
sessionFile: sessionFilePath, // events streamed to file for debugging
|
||||
};
|
||||
}
|
||||
|
||||
@@ -177,13 +317,10 @@ export async function runTask(
|
||||
// Capture git commits made during this task
|
||||
const { commitMessages, commitSummary } = captureGitCommits(projectDir);
|
||||
|
||||
// Session file already written by runAgentSession (events streamed to disk)
|
||||
const sessionFile = sessionFilePath;
|
||||
|
||||
// Build output preview (first 500 chars of agent text)
|
||||
const outputPreview =
|
||||
agentText.length > 500
|
||||
? agentText.slice(0, 500) + "\n... (truncated, see session file)"
|
||||
? agentText.slice(0, 500) + "\n... (truncated)"
|
||||
: agentText;
|
||||
|
||||
// Extract reflection from agent output
|
||||
@@ -199,7 +336,6 @@ export async function runTask(
|
||||
durationMs,
|
||||
toolUsage,
|
||||
outputPreview,
|
||||
sessionFile,
|
||||
commitMessages,
|
||||
commitSummary,
|
||||
};
|
||||
@@ -227,9 +363,43 @@ export async function executeBatch(
|
||||
);
|
||||
}
|
||||
|
||||
// Check if we should run parallel
|
||||
// Set up model round-robin if configured.
|
||||
// Config entries are "<provider>/<model>" 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 <provider>/<model> 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 > 1 && config.execution.maxParallel > 0;
|
||||
options?.parallel && tasks.length > 0 && config.execution.maxParallel > 0;
|
||||
|
||||
if (shouldParallel) {
|
||||
await executeBatchParallel(
|
||||
@@ -240,12 +410,14 @@ export async function executeBatch(
|
||||
ctx,
|
||||
sendChatMessage,
|
||||
projectDir,
|
||||
roundRobin,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Execute sequentially
|
||||
// Execute sequentially (no round-robin — inherit parent model)
|
||||
for (const task of tasks) {
|
||||
try {
|
||||
await executeTask(
|
||||
task,
|
||||
project,
|
||||
@@ -255,6 +427,22 @@ export async function executeBatch(
|
||||
sendChatMessage,
|
||||
projectDir,
|
||||
);
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,15 +457,107 @@ async function executeBatchParallel(
|
||||
ctx: ExtensionContext,
|
||||
sendChatMessage?: SendChatMessage,
|
||||
projectDir?: string,
|
||||
roundRobin?: ModelRoundRobin | null,
|
||||
): Promise<void> {
|
||||
const maxParallel = config.execution.maxParallel;
|
||||
const batcher = new WidgetBatcher(ctx);
|
||||
const results: Array<{ task: Task; result: Promise<any> }> = [];
|
||||
const sharedState: ParallelWidgetState = new Map();
|
||||
|
||||
for (const task of tasks) {
|
||||
results.push({
|
||||
task,
|
||||
result: executeTask(
|
||||
// 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<Promise<void>>();
|
||||
|
||||
/** 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,
|
||||
@@ -285,24 +565,51 @@ async function executeBatchParallel(
|
||||
ctx,
|
||||
sendChatMessage,
|
||||
projectDir,
|
||||
batcher,
|
||||
),
|
||||
sharedState,
|
||||
assignedModel,
|
||||
roundRobin,
|
||||
requestBatchRender,
|
||||
)
|
||||
.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();
|
||||
});
|
||||
|
||||
// Limit concurrency
|
||||
if (results.length >= maxParallel) {
|
||||
const first = results.shift();
|
||||
if (first) await first.result;
|
||||
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);
|
||||
}
|
||||
|
||||
// Wait for remaining tasks
|
||||
for (const { result } of results) {
|
||||
await result;
|
||||
}
|
||||
|
||||
// Flush and stop the batcher after all tasks complete
|
||||
batcher.stop();
|
||||
clearInterval(spinnerTimer);
|
||||
ctx.ui.setWidget(widgetKey, undefined);
|
||||
}
|
||||
|
||||
// ─── Execute Single Task with Retry ──────────────────────────────────────────
|
||||
@@ -315,15 +622,39 @@ async function executeTask(
|
||||
ctx: ExtensionContext,
|
||||
sendChatMessage?: SendChatMessage,
|
||||
projectDir: string = project.sourceDir,
|
||||
batcher?: WidgetBatcher,
|
||||
parallelState?: ParallelWidgetState,
|
||||
assignedModel?: unknown,
|
||||
roundRobin?: ModelRoundRobin | null,
|
||||
batchRender?: () => void,
|
||||
): Promise<void> {
|
||||
const maxRetries = config.execution.maxRetries;
|
||||
let retries = 0;
|
||||
|
||||
// Model failover: when a provider/API is down, cycle through available models.
|
||||
// result.success === false always means an agent-session failure (API error,
|
||||
// provider unreachable, etc.), not a task-work error.
|
||||
const maxModelAttempts = roundRobin ? roundRobin.length : 1;
|
||||
let modelAttempt = 0;
|
||||
let currentModel: unknown = assignedModel ?? config.model;
|
||||
|
||||
while (modelAttempt < maxModelAttempts) {
|
||||
// On subsequent model attempts, advance to the next model.
|
||||
// Uses advance() instead of assign() so we don't get stuck on
|
||||
// the same freed slot when the current model is down.
|
||||
if (modelAttempt > 0 && roundRobin) {
|
||||
currentModel = roundRobin.advance(task.id);
|
||||
}
|
||||
|
||||
let retries = 0;
|
||||
while (retries <= maxRetries) {
|
||||
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(
|
||||
@@ -339,10 +670,183 @@ async function executeTask(
|
||||
ctx,
|
||||
sendChatMessage,
|
||||
projectDir,
|
||||
batcher,
|
||||
parallelState,
|
||||
currentModel,
|
||||
batchRender,
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
// ── Auto-Commit: Trigger follow-up agent session for uncommitted changes ──
|
||||
let finalCommitMessages = result.commitMessages ?? [];
|
||||
let finalCommitSummary = result.commitSummary ?? "";
|
||||
|
||||
try {
|
||||
if (hasUncommittedChanges(projectDir)) {
|
||||
const status = getGitStatusPorcelain(projectDir);
|
||||
const diff = getGitDiff(projectDir);
|
||||
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 <files>`, then create a meaningful git commit.",
|
||||
"Use a descriptive commit message and follow conventional commits format.",
|
||||
"",
|
||||
"### Current Changes (git status --porcelain)",
|
||||
"```text",
|
||||
status || "(no status output)",
|
||||
"```",
|
||||
"",
|
||||
"### Current Tracked Diff (git diff)",
|
||||
"```diff",
|
||||
diff || "(no tracked diff output)",
|
||||
"```",
|
||||
].join("\n");
|
||||
|
||||
// ── Commit widget setup ──
|
||||
const commitWidgetKey = `ralpi-commit-${task.id}`;
|
||||
let commitFrameIndex = 0;
|
||||
const commitToolCalls: ToolCallEntry[] = [];
|
||||
let commitWidgetTui: { requestRender(): void } | null = null;
|
||||
|
||||
const commitHeader = `commit for ${task.id} · ${task.title}`;
|
||||
|
||||
const buildCommitLines = (
|
||||
t: typeof ctx.ui.theme,
|
||||
width?: number,
|
||||
): string[] => {
|
||||
const effectiveWidth = width || 74;
|
||||
const frame = t.fg(
|
||||
"accent",
|
||||
SPINNER_FRAMES[commitFrameIndex % SPINNER_FRAMES.length],
|
||||
);
|
||||
const lines = [
|
||||
truncateToWidth(`~ ${frame} ${commitHeader}`, effectiveWidth),
|
||||
];
|
||||
|
||||
if (commitToolCalls.length > 0) {
|
||||
if (commitToolCalls.length <= MAX_COLLAPSED) {
|
||||
for (let i = 0; i < commitToolCalls.length; i++) {
|
||||
const entry = commitToolCalls[i];
|
||||
const isLast = i === commitToolCalls.length - 1;
|
||||
const branch = isLast ? " └── " : " ├── ";
|
||||
const tag = t.fg("accent", `[${entry.name}]`);
|
||||
lines.push(
|
||||
truncateToWidth(
|
||||
`${branch}${tag} ${entry.label}`,
|
||||
effectiveWidth,
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const shown = commitToolCalls.slice(-MAX_COLLAPSED);
|
||||
const remaining = commitToolCalls.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(commitWidgetKey, (tui, t) => {
|
||||
commitWidgetTui = tui;
|
||||
return {
|
||||
render: (width?: number) => buildCommitLines(t, width),
|
||||
invalidate: () => commitWidgetTui?.requestRender(),
|
||||
};
|
||||
});
|
||||
|
||||
const requestCommitRender = () =>
|
||||
commitWidgetTui?.requestRender();
|
||||
|
||||
const commitSpinnerTimer = setInterval(() => {
|
||||
commitFrameIndex =
|
||||
(commitFrameIndex + 1) % SPINNER_FRAMES.length;
|
||||
requestCommitRender();
|
||||
}, 100);
|
||||
|
||||
// Use a short timeout for the commit session (60s should be enough)
|
||||
const commitTimeout = Math.min(
|
||||
60_000,
|
||||
config.execution.timeoutMs,
|
||||
);
|
||||
|
||||
let commitResult: Awaited<ReturnType<typeof runAgentSession>>;
|
||||
|
||||
try {
|
||||
commitResult = await runAgentSession(
|
||||
commitPrompt,
|
||||
projectDir,
|
||||
commitTimeout,
|
||||
(event) => {
|
||||
if (event.type === "tool_execution_start") {
|
||||
const label = formatToolArg(event.toolName, event.args);
|
||||
commitToolCalls.push({
|
||||
name: event.toolName,
|
||||
label,
|
||||
});
|
||||
requestCommitRender();
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
currentModel,
|
||||
config.thinkingLevel,
|
||||
);
|
||||
} finally {
|
||||
clearInterval(commitSpinnerTimer);
|
||||
ctx.ui.setWidget(commitWidgetKey, undefined);
|
||||
}
|
||||
|
||||
if (commitResult.success) {
|
||||
// Re-capture commits made during this follow-up session
|
||||
const newCommits = captureGitCommits(projectDir);
|
||||
if (newCommits.commitMessages.length > 0) {
|
||||
finalCommitMessages = [
|
||||
...finalCommitMessages,
|
||||
...newCommits.commitMessages,
|
||||
];
|
||||
finalCommitSummary = finalCommitSummary
|
||||
? `${finalCommitSummary}; ${newCommits.commitSummary}`
|
||||
: newCommits.commitSummary;
|
||||
}
|
||||
sendChatMessage?.(`✓ commit for ${task.id} · ${task.title}`, {
|
||||
toolCalls: commitToolCalls,
|
||||
});
|
||||
} else {
|
||||
sendChatMessage?.(
|
||||
`~ commit for ${task.id} · ${task.title} — follow-up commit session failed: ${commitResult.error}`,
|
||||
{ toolCalls: commitToolCalls },
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Don't fail the task if auto-commit fails
|
||||
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);
|
||||
@@ -354,20 +858,38 @@ async function executeTask(
|
||||
result.durationMs,
|
||||
result.reflection,
|
||||
result.toolUsage,
|
||||
result.sessionFile,
|
||||
result.outputPreview,
|
||||
result.commitMessages,
|
||||
result.commitSummary,
|
||||
finalCommitMessages,
|
||||
finalCommitSummary,
|
||||
);
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Task failed, check if we should retry
|
||||
// Agent session failed (provider error).
|
||||
// If we have more models, cycle immediately — don't waste retries.
|
||||
if (roundRobin && modelAttempt < maxModelAttempts - 1) {
|
||||
// Don't release — advance() already handles the transition.
|
||||
// release() would put the slot in freeSlots, then assign()
|
||||
// would pick it right back up, getting stuck on the same model.
|
||||
modelAttempt++;
|
||||
sendChatMessage?.(
|
||||
`~ ${task.id} · ${task.title} — trying model ${modelAttempt + 1}/${maxModelAttempts} (previous: ${result.error})`,
|
||||
);
|
||||
break; // exit retry loop, cycle to next model
|
||||
}
|
||||
|
||||
// No more models — use normal retry logic
|
||||
if (retries < maxRetries) {
|
||||
retries = progress.incrementRetry(task.id);
|
||||
ctx.ui.notify(
|
||||
`Retrying task ${task.id} (${retries}/${maxRetries}): ${result.error}`,
|
||||
"warning",
|
||||
sendChatMessage?.(
|
||||
`~ ${task.id} · ${task.title} — retrying (${retries}/${maxRetries}): ${result.error}`,
|
||||
);
|
||||
|
||||
// Exponential backoff
|
||||
@@ -376,14 +898,49 @@ async function executeTask(
|
||||
} else {
|
||||
// Max retries exceeded
|
||||
progress.markFailed(task.id, result.error || "Unknown error");
|
||||
throw new Error(`Task ${task.id} failed: ${result.error}`);
|
||||
// Don't update PRD — retry exhaustion is transient, not terminal
|
||||
sendChatMessage?.(`✗ ${task.id} · ${task.title} — ${result.error}`);
|
||||
ctx.ui.notify(
|
||||
`Task ${task.id} failed after ${maxRetries} retries: ${
|
||||
result.error || "Unknown error"
|
||||
}`,
|
||||
"error",
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
roundRobin?.release(task.id);
|
||||
batchRender?.();
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
progress.markFailed(task.id, errorMsg);
|
||||
throw error;
|
||||
// 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");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// If we broke out (model cycling), continue the outer loop
|
||||
modelAttempt++;
|
||||
}
|
||||
|
||||
// All models exhausted — release the slot
|
||||
roundRobin?.release(task.id);
|
||||
batchRender?.();
|
||||
progress.markFailed(task.id, "All configured models exhausted");
|
||||
// Don't update PRD — model exhaustion is transient, not terminal
|
||||
sendChatMessage?.(
|
||||
`✗ ${task.id} · ${task.title} — all ${maxModelAttempts} models exhausted`,
|
||||
);
|
||||
ctx.ui.notify(
|
||||
`Task ${task.id} failed: all configured models exhausted`,
|
||||
"error",
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Save Reflection to File ────────────────────────────────────────────────
|
||||
@@ -407,6 +964,20 @@ function sleep(ms: number): Promise<void> {
|
||||
|
||||
// ─── Tool Call Formatting ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
@@ -414,21 +985,20 @@ function formatToolArg(name: string, args: unknown): string {
|
||||
const a = args as Record<string, unknown>;
|
||||
switch (name) {
|
||||
case "bash":
|
||||
return truncateMiddle(String(a.command ?? ""), 70);
|
||||
return sanitizeLabel(truncateMiddle(String(a.command ?? ""), 70));
|
||||
case "write":
|
||||
case "read":
|
||||
return truncateMiddle(String(a.path ?? ""), 60);
|
||||
return sanitizeLabel(truncateMiddle(String(a.path ?? ""), 60));
|
||||
case "edit":
|
||||
return truncateMiddle(String(a.path ?? ""), 60);
|
||||
return sanitizeLabel(truncateMiddle(String(a.path ?? ""), 60));
|
||||
case "grep":
|
||||
return `${a.pattern ?? "?"} — ${truncateMiddle(
|
||||
String(a.path ?? ""),
|
||||
40,
|
||||
)}`;
|
||||
return sanitizeLabel(
|
||||
`${a.pattern ?? "?"} — ${truncateMiddle(String(a.path ?? ""), 40)}`,
|
||||
);
|
||||
case "find":
|
||||
return `${a.path ?? "."} — ${a.glob ?? "*"}`;
|
||||
return sanitizeLabel(`${a.path ?? "."} — ${a.glob ?? "*"}`);
|
||||
case "ls":
|
||||
return truncateMiddle(String(a.path ?? "."), 60);
|
||||
return sanitizeLabel(truncateMiddle(String(a.path ?? "."), 60));
|
||||
default:
|
||||
return name;
|
||||
}
|
||||
|
||||
464
src/parser.ts
464
src/parser.ts
@@ -1,6 +1,20 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import type { Task, Project } from "./types";
|
||||
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 ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -8,6 +22,7 @@ import type { Task, Project } from "./types";
|
||||
* 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: [...])
|
||||
*/
|
||||
@@ -22,7 +37,7 @@ export function parseTaskFile(filePath: string): Project {
|
||||
}
|
||||
|
||||
// Markdown: detect format
|
||||
if (hasDependenciesSection(content)) {
|
||||
if (hasDependenciesSection(content) || hasPhaseHeadings(content)) {
|
||||
return parseFioFormat(content, absolutePath, dir);
|
||||
}
|
||||
return parseSimpleCheckbox(content, absolutePath, dir);
|
||||
@@ -30,8 +45,35 @@ export function parseTaskFile(filePath: string): Project {
|
||||
|
||||
// ─── 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 /^##\s+Dependencies\s*$/m.test(content);
|
||||
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(
|
||||
@@ -42,24 +84,57 @@ function parseFioFormat(
|
||||
const lines = content.split("\n");
|
||||
const tasks: Task[] = [];
|
||||
const dependencies: Record<string, string[]> = {};
|
||||
const parallelGroups: ParallelGroup[] = [];
|
||||
const phases: Phase[] = [];
|
||||
let currentPhase: number | null = null;
|
||||
let currentPhaseTitle = "";
|
||||
let inTasks = false;
|
||||
let inDeps = false;
|
||||
|
||||
for (const line of lines) {
|
||||
if (/^##\s+Tasks\s*$/m.test(line)) {
|
||||
// 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 (/^##\s+Dependencies\s*$/m.test(line)) {
|
||||
|
||||
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 (
|
||||
/^##\s/.test(line) &&
|
||||
!/^##\s+Tasks/.test(line) &&
|
||||
!/^##\s+Dependencies/.test(line)
|
||||
(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;
|
||||
@@ -67,15 +142,17 @@ function parseFioFormat(
|
||||
}
|
||||
|
||||
if (inTasks) {
|
||||
// Match all tasks on a line (supports compact single-line formats)
|
||||
// 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+)\s+[—–:-]\s+(.+?)(?:\s+(?=-+\s+\[)|\s*→\s*`([^`]+)`|$)/g;
|
||||
/-+\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: `0${id}`,
|
||||
id: normalizeTaskId(id),
|
||||
title: title.trim(),
|
||||
description: undefined,
|
||||
file: file || undefined,
|
||||
@@ -83,71 +160,229 @@ function parseFioFormat(
|
||||
dependencies: [],
|
||||
timeoutMs,
|
||||
index: tasks.length,
|
||||
phase: currentPhase ?? undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (inDeps) {
|
||||
// Format 2: Arrow notation with multiple targets
|
||||
// "01 -> 02,03,06 (description)" means 02, 03, 06 depend on 01
|
||||
// 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 arrowMatch = line.match(
|
||||
/^(?:\s*[-*]\s+)?(\d+)\s*->\s*([\d,\s]+?)(?:\s*\(|$)/,
|
||||
);
|
||||
if (arrowMatch) {
|
||||
const [, from, targets] = arrowMatch;
|
||||
const fromId = `0${from}`;
|
||||
const targetIds = targets
|
||||
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) => t)
|
||||
.map((t) => `0${t}`);
|
||||
.filter((t) => /^\d+[a-z]?$/.test(t))
|
||||
.map((t) => normalizeTaskId(t));
|
||||
|
||||
// Each target depends on the source
|
||||
for (const toId of targetIds) {
|
||||
// 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+)\s+depends\s+on\s+([\d,\s]+)/i,
|
||||
/^(?:\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 = `0${taskId}`;
|
||||
const taskIdPadded = normalizeTaskId(taskId);
|
||||
const depIds = depsList
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter((t) => t)
|
||||
.map((t) => `0${t}`);
|
||||
.filter((t) => /^\d+[a-z]?$/.test(t))
|
||||
.map((t) => normalizeTaskId(t));
|
||||
|
||||
if (!dependencies[taskIdPadded]) dependencies[taskIdPadded] = [];
|
||||
dependencies[taskIdPadded].push(...depIds);
|
||||
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+)\s+\[timeout\]\s*=?\s*(\d+)(?:m|min|s|ms)?/i,
|
||||
/^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 === `0${taskId}`);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract exit criteria
|
||||
// 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 exitIdx = lines.findIndex((l) => /^##\s+Exit\s+Criteria/i.test(l));
|
||||
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++) {
|
||||
if (/^##\s/.test(lines[i])) break;
|
||||
// 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());
|
||||
}
|
||||
@@ -164,9 +399,21 @@ function parseFioFormat(
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
@@ -210,16 +457,7 @@ function parseYaml(
|
||||
sourcePath: string,
|
||||
sourceDir: string,
|
||||
): Project {
|
||||
// Lazy-load yaml (may not be installed)
|
||||
let YAML: typeof import("yaml");
|
||||
try {
|
||||
YAML = require("yaml");
|
||||
} catch {
|
||||
throw new Error(
|
||||
"YAML parsing requires the 'yaml' package. Run: npm install yaml",
|
||||
);
|
||||
}
|
||||
|
||||
const YAML = loadYaml();
|
||||
const doc = YAML.parse(content);
|
||||
const tasks: Task[] = [];
|
||||
|
||||
@@ -263,35 +501,119 @@ export function readTaskSpec(taskDir: string, taskFile: string): string {
|
||||
// ─── Task File Updater ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Update task status in the source markdown file
|
||||
* 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 {
|
||||
let content = fs.readFileSync(filePath, "utf-8");
|
||||
const char = statusToChar(status);
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
|
||||
// Try Fio numbered format first
|
||||
const fioPattern = new RegExp(
|
||||
`(^-\\s+\\[)(.)(\\]\\s+${escapeRegex(taskId)}\\s*[—–-])`,
|
||||
"m",
|
||||
);
|
||||
if (fioPattern.test(content)) {
|
||||
content = content.replace(fioPattern, `$1${char}$3`);
|
||||
fs.writeFileSync(filePath, content, "utf-8");
|
||||
// Handle YAML format
|
||||
if (ext === ".yaml" || ext === ".yml") {
|
||||
updateTaskInYaml(filePath, taskId, status);
|
||||
return;
|
||||
}
|
||||
|
||||
// Try simple checkbox format
|
||||
const simplePattern = new RegExp(
|
||||
`(-\\s+\\[)(.)(\\]\\s+${escapeRegex(taskId)})`,
|
||||
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",
|
||||
);
|
||||
if (simplePattern.test(content)) {
|
||||
content = content.replace(simplePattern, `$1${char}$3`);
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -392,6 +714,28 @@ function parseTimeoutFromMeta(
|
||||
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 " ":
|
||||
|
||||
@@ -171,7 +171,6 @@ export class ProgressTracker {
|
||||
durationMs: number,
|
||||
reflection?: Reflection,
|
||||
toolUsage?: ToolUsage,
|
||||
sessionFile?: string,
|
||||
outputPreview?: string,
|
||||
commitMessages?: string[],
|
||||
commitSummary?: string,
|
||||
@@ -183,7 +182,6 @@ export class ProgressTracker {
|
||||
prd.tasks[taskId].durationMs = durationMs;
|
||||
if (reflection) prd.tasks[taskId].reflection = reflection;
|
||||
if (toolUsage) prd.tasks[taskId].toolUsage = toolUsage;
|
||||
if (sessionFile) prd.tasks[taskId].sessionFile = sessionFile;
|
||||
if (outputPreview) prd.tasks[taskId].outputPreview = outputPreview;
|
||||
if (commitMessages) prd.tasks[taskId].commitMessages = commitMessages;
|
||||
if (commitSummary) prd.tasks[taskId].commitSummary = commitSummary;
|
||||
@@ -213,6 +211,14 @@ export class ProgressTracker {
|
||||
.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();
|
||||
|
||||
39
src/types.ts
39
src/types.ts
@@ -27,6 +27,26 @@ export interface Task {
|
||||
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 {
|
||||
@@ -36,6 +56,10 @@ export interface Project {
|
||||
tasks: Task[];
|
||||
/** Explicit dependency map: taskId → [dependency taskIds] */
|
||||
dependencies: Record<string, string[]>;
|
||||
/** Explicit parallel groups from "can be done in parallel" declarations */
|
||||
parallelGroups?: ParallelGroup[];
|
||||
/** Phased sections from ## Phase N headings (in order) */
|
||||
phases?: Phase[];
|
||||
/** Exit criteria (from README ## Exit Criteria section) */
|
||||
exitCriteria?: string[];
|
||||
/** Path to the source task file */
|
||||
@@ -97,8 +121,6 @@ export interface TaskProgressInfo {
|
||||
error?: string;
|
||||
/** Tool usage counts from parsed subprocess output */
|
||||
toolUsage?: ToolUsage;
|
||||
/** Path to session output file */
|
||||
sessionFile?: string;
|
||||
/** Truncated output preview for expanded view */
|
||||
outputPreview?: string;
|
||||
/** Git commit messages from task execution */
|
||||
@@ -153,6 +175,8 @@ export interface RalpiConfig {
|
||||
timeoutMs: number;
|
||||
/** Maximum parallel tasks (0 = unlimited) */
|
||||
maxParallel: number;
|
||||
/** Round-robin model list for parallel tasks (empty = inherit parent model) */
|
||||
models: string[];
|
||||
};
|
||||
prompts: {
|
||||
/** Additional context injected into every task prompt */
|
||||
@@ -160,6 +184,10 @@ export interface RalpiConfig {
|
||||
/** Custom prompt suffix for reflection extraction */
|
||||
reflectionPrompt: 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 = {
|
||||
@@ -168,10 +196,11 @@ export const DEFAULT_CONFIG: RalpiConfig = {
|
||||
reflectionsDir: ".ralpi/reflections",
|
||||
},
|
||||
execution: {
|
||||
maxRetries: 3,
|
||||
retryDelayMs: 5000,
|
||||
timeoutMs: 30 * 60 * 1000, // 30 minutes
|
||||
maxRetries: 0,
|
||||
retryDelayMs: 0,
|
||||
timeoutMs: 0, // 0 = inherit Pi's own defaults (no ralpi-level timeout)
|
||||
maxParallel: 3,
|
||||
models: [],
|
||||
},
|
||||
prompts: {
|
||||
projectContext: "",
|
||||
|
||||
242
src/utils.ts
242
src/utils.ts
@@ -34,6 +34,78 @@ export function writeFileSafe(filePath: string, content: string): void {
|
||||
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.
|
||||
*/
|
||||
export interface LoopActiveState {
|
||||
taskFile: string;
|
||||
mode: "parallel" | "sequential";
|
||||
startedAt: string;
|
||||
taskIds: string[];
|
||||
prdKey: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 ─────────────────────────────────────────────────────
|
||||
@@ -82,32 +154,35 @@ export function findProgressFile(
|
||||
|
||||
// ─── Config ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function parseSimpleYaml(content: string): Record<string, any> {
|
||||
/** 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<string, any> = (() => {
|
||||
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<string, any> = {};
|
||||
const lines = content.split("\n");
|
||||
|
||||
for (const line of lines) {
|
||||
for (const line of content.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
|
||||
const match = trimmed.match(/^([^:]+):\s*(.+)$/);
|
||||
const match = trimmed.match(/^([^:]+):\s*(.*)$/);
|
||||
if (match) {
|
||||
const key = match[1].trim();
|
||||
let value: string | boolean | number = match[2].trim();
|
||||
|
||||
// Parse booleans
|
||||
if (value === "true") value = true;
|
||||
else if (value === "false") value = false;
|
||||
// Parse numbers
|
||||
else if (/^\d+$/.test(value)) value = parseInt(value, 10);
|
||||
else if (/^\d+\.\d+$/.test(value)) value = parseFloat(value);
|
||||
|
||||
result[key] = value;
|
||||
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
|
||||
@@ -129,25 +204,44 @@ function mergeConfig(
|
||||
return result as RalpiConfig;
|
||||
}
|
||||
|
||||
/** Path to the global ralpi config under the user's Pi home directory. */
|
||||
const GLOBAL_CONFIG_PATH = path.join(
|
||||
process.env.HOME || "/tmp",
|
||||
".pi",
|
||||
"ralpi",
|
||||
"config.yaml",
|
||||
);
|
||||
|
||||
/**
|
||||
* Load configuration from .ralpi/config.yaml or return defaults
|
||||
* Load and merge config from global and project sources.
|
||||
*
|
||||
* Precedence (highest wins):
|
||||
* 1. Project-level: `<projectDir>/.ralpi/config.yaml`
|
||||
* 2. Global: `~/.pi/ralpi/config.yaml`
|
||||
* 3. `DEFAULT_CONFIG` in `src/types.ts`
|
||||
*/
|
||||
export function loadConfig(projectDir: string): RalpiConfig {
|
||||
const configPath = path.join(projectDir, ".ralpi", "config.yaml");
|
||||
// Start with defaults
|
||||
const merged: RalpiConfig = { ...DEFAULT_CONFIG };
|
||||
|
||||
// Return defaults silently when config file does not exist
|
||||
if (!fs.existsSync(configPath)) {
|
||||
return { ...DEFAULT_CONFIG };
|
||||
}
|
||||
// Layer 1: global config (~/.pi/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(configPath, "utf-8");
|
||||
// Simple YAML parsing (key: value format)
|
||||
const config = parseSimpleYaml(content);
|
||||
return mergeConfig(DEFAULT_CONFIG, config);
|
||||
const content = fs.readFileSync(filePath, "utf-8");
|
||||
const parsed = parseSimpleYaml(content);
|
||||
Object.assign(acc, mergeConfig(acc, parsed));
|
||||
} catch {
|
||||
// Malformed config — fall back to defaults silently
|
||||
return { ...DEFAULT_CONFIG };
|
||||
// Malformed config — skip silently
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -338,7 +432,8 @@ export async function runAgentSession(
|
||||
timeoutMs: number,
|
||||
onEvent?: (event: AgentSessionEvent) => void,
|
||||
signal?: AbortSignal,
|
||||
sessionFile?: string,
|
||||
model?: unknown,
|
||||
thinkingLevel?: unknown,
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
text: string;
|
||||
@@ -354,17 +449,13 @@ export async function runAgentSession(
|
||||
bash: 0,
|
||||
other: 0,
|
||||
};
|
||||
// Stream events to file instead of accumulating in memory.
|
||||
// Accumulating caused "Invalid string length" crashes when
|
||||
// JSON.stringify(output.events, null, 2) produced 300+ MB strings.
|
||||
const eventStream = sessionFile
|
||||
? fs.createWriteStream(sessionFile, { flags: "a" })
|
||||
: null;
|
||||
|
||||
// Wire timeout via abort signal
|
||||
const timeoutHandle = setTimeout(() => {
|
||||
// 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<ReturnType<typeof createAgentSession>>["session"];
|
||||
@@ -387,6 +478,8 @@ export async function runAgentSession(
|
||||
sessionManager: SessionManager.inMemory(),
|
||||
resourceLoader: loader,
|
||||
tools: ["read", "bash", "edit", "write", "grep", "find", "ls"],
|
||||
model: model as any,
|
||||
thinkingLevel: thinkingLevel as any,
|
||||
});
|
||||
sessionRef.session = result.session;
|
||||
|
||||
@@ -399,10 +492,6 @@ export async function runAgentSession(
|
||||
let stopReason: string | undefined;
|
||||
|
||||
const unsubscribe = result.session.subscribe((event) => {
|
||||
// Stream event to file (avoids accumulating 300+ MB in memory)
|
||||
if (eventStream) {
|
||||
eventStream.write(JSON.stringify(event) + "\n");
|
||||
}
|
||||
onEvent?.(event);
|
||||
|
||||
if (event.type === "message_end") {
|
||||
@@ -437,12 +526,7 @@ export async function runAgentSession(
|
||||
unsubscribe();
|
||||
result.session.dispose();
|
||||
signal?.removeEventListener("abort", abortHandler);
|
||||
clearTimeout(timeoutHandle);
|
||||
|
||||
// Flush and close the event stream before returning
|
||||
if (eventStream) {
|
||||
await new Promise<void>((resolve) => eventStream.end(resolve));
|
||||
}
|
||||
if (timeoutHandle) clearTimeout(timeoutHandle);
|
||||
|
||||
if (errorMessage && !finalText) {
|
||||
return {
|
||||
@@ -460,19 +544,16 @@ export async function runAgentSession(
|
||||
text: finalText.trim(),
|
||||
toolUsage,
|
||||
stopReason,
|
||||
events: [], // streamed to file
|
||||
events: [],
|
||||
};
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutHandle);
|
||||
if (eventStream && !eventStream.destroyed) {
|
||||
eventStream.end();
|
||||
}
|
||||
if (timeoutHandle) clearTimeout(timeoutHandle);
|
||||
return {
|
||||
success: false,
|
||||
text: "",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
toolUsage,
|
||||
events: [], // streamed to file
|
||||
events: [],
|
||||
};
|
||||
} finally {
|
||||
sessionRef.session?.dispose();
|
||||
@@ -498,6 +579,53 @@ function extractAssistantText(content: unknown): string {
|
||||
|
||||
// ─── Git Commit Capture ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Check if there are any uncommitted changes in the git repository.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
674
tests/dag-construction.test.ts
Normal file
674
tests/dag-construction.test.ts
Normal file
@@ -0,0 +1,674 @@
|
||||
/// <reference types="bun-types" />
|
||||
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>): 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"]);
|
||||
});
|
||||
});
|
||||
30
tests/helpers.ts
Normal file
30
tests/helpers.ts
Normal file
@@ -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;
|
||||
}
|
||||
1119
tests/parser-dag.test.ts
Normal file
1119
tests/parser-dag.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
1321
tests/parser-formats.test.ts
Normal file
1321
tests/parser-formats.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
521
tests/parser-phased.test.ts
Normal file
521
tests/parser-phased.test.ts
Normal file
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,18 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "commonjs",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./",
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["index.ts", "src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
|
||||
Reference in New Issue
Block a user