Compare commits
38 Commits
81e0e8ec1c
...
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 | |||
| ead5d9be3a | |||
| 923f174f3b | |||
| c7ab908bae | |||
| 73d1ee1a47 | |||
| fcc0aa618e | |||
| 919113430a | |||
| e6a8c8bedc |
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
.pi-lens
|
||||
package-lock.json
|
||||
62
AGENTS.md
Normal file
62
AGENTS.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# AGENTS.md
|
||||
|
||||
## What this is
|
||||
|
||||
A Pi coding agent extension that registers the `/ralpi` slash command. Not a standalone app — it runs inside Pi's extension host.
|
||||
|
||||
## Type checking
|
||||
|
||||
```
|
||||
npm run typecheck # tsc --noEmit
|
||||
```
|
||||
|
||||
No build step needed — Pi loads extensions via [jiti](https://github.com/unjs/jiti), which compiles TypeScript at runtime. `index.ts` is the entry point directly.
|
||||
|
||||
## Entry point
|
||||
|
||||
`index.ts` at repo root (not `src/`). Exports a default function receiving `ExtensionAPI`.
|
||||
|
||||
## External dependencies
|
||||
|
||||
The extension imports from Pi SDK packages (not in `package.json` — provided by the host):
|
||||
- `@earendil-works/pi-coding-agent` — `ExtensionAPI`, `ExtensionContext`, `createAgentSession`, etc.
|
||||
- `@earendil-works/pi-tui` — `Box`, `Text` for custom message renderer
|
||||
|
||||
The only real npm dependency is `yaml` (^2.4.0).
|
||||
|
||||
## Source structure
|
||||
|
||||
- `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
|
||||
- `executor.ts` — task execution, retry, parallel/sequential modes
|
||||
- `progress.ts` — `.ralpi/progress.json` state management
|
||||
- `prompts.ts` — prompt generation for spawned agent sessions
|
||||
- `reflection.ts` — reflection extraction from agent output
|
||||
- `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
|
||||
- `prompts/task-manager.md` — Pi prompt for task planning
|
||||
|
||||
## Runtime state
|
||||
|
||||
All runtime state lives in `.ralpi/` in the **project directory** (not this extension directory):
|
||||
- `.ralpi/progress.json` — execution progress, supports multiple PRDs
|
||||
- `.ralpi/reflections/` — per-task reflection JSON files
|
||||
- `.ralpi/prompts/` — generated prompts (timestamped, for debugging)
|
||||
- `.ralpi/sessions/` — full session transcripts
|
||||
|
||||
## Task ID convention
|
||||
|
||||
Task IDs are zero-padded strings (`"01"`, `"02"`, etc.). The parser prepends `0` to parsed digits. Never use raw numeric IDs.
|
||||
|
||||
## Command routing
|
||||
|
||||
`/ralpi` with no args → plan. First token looks like a path (`@path`, `./path`, `.md`, etc.) → run. Otherwise dispatches to subcommand (`run`, `plan`, `resume`, `reset`).
|
||||
|
||||
## Config
|
||||
|
||||
Read from `.ralpi/config.yaml` in project directory. Falls back to `DEFAULT_CONFIG` in `src/types.ts` when file is missing. Config is loaded at `projectDir` level, not extension level.
|
||||
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.
|
||||
131
README.md
131
README.md
@@ -1,46 +1,36 @@
|
||||
# ralph-loop
|
||||
# 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 `.ralph/progress.json`
|
||||
- **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
|
||||
- **Multiple formats**: Supports simple checkboxes, and YAML
|
||||
- **Tool usage tracking**: Detects and reports tool usage (read, write, edit, bash) from task execution
|
||||
- **Configurable timeouts**: Task-level timeouts via meta blocks, with global fallback
|
||||
- **Session saving**: Saves full task output for expandable session review
|
||||
- **Resume auto-discovery**: Automatically finds and resumes interrupted execution
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
/ralph plan [task-file] # Show execution plan
|
||||
/ralph run [task-file] # Execute all tasks
|
||||
/ralph status [task-file] # Show current progress
|
||||
/ralph resume [task-file] # Resume paused execution
|
||||
/ralph next [task-file] # Execute next batch only
|
||||
/ralph 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
|
||||
2 -> 3
|
||||
```
|
||||
|
||||
### Simple Checkbox Format
|
||||
|
||||
```markdown
|
||||
@@ -64,20 +54,89 @@ tasks:
|
||||
depends_on: ["01"]
|
||||
```
|
||||
|
||||
## Task IDs
|
||||
|
||||
Task IDs are zero-padded 2-digit strings (`01`, `02`, ...) with an optional
|
||||
single lowercase letter suffix for sub-tasks inserted between two numbered
|
||||
steps (e.g. `02b`, `02c`). The parser normalizes `2b` → `02b`.
|
||||
|
||||
```
|
||||
- [ ] 01 — Setup
|
||||
- [ ] 02 — Fix bugs
|
||||
- [ ] 02b — Sub-step of 02 (inserted after the fact)
|
||||
- [ ] 02c — Another sub-step of 02
|
||||
- [ ] 03 — Continue
|
||||
```
|
||||
|
||||
Use lettered sub-tasks when you discover mid-stream that a step needs to be
|
||||
split. They let you preserve sibling numbering (`01`, `02`, `03`, ...) while
|
||||
adding granularity between two existing steps.
|
||||
|
||||
## 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
|
||||
|
||||
Create `.ralph/config.yaml`:
|
||||
### Task-Level Timeout
|
||||
|
||||
You can set a timeout for individual tasks using a meta block in the task file:
|
||||
|
||||
```markdown
|
||||
- [ ] 01: Setup project structure
|
||||
timeout: 10m
|
||||
```
|
||||
|
||||
Supported formats: `10m` (minutes), `600s` (seconds), `3600000` (milliseconds)
|
||||
|
||||
### Config files
|
||||
|
||||
| Scope | Path |
|
||||
|-------|------|
|
||||
| **Global** | `~/.pi/ralpi/config.yaml` |
|
||||
| **Project** | `./.ralpi/config.yaml` |
|
||||
|
||||
```yaml
|
||||
maxRetries: 3
|
||||
retryDelayMs: 5000
|
||||
timeoutMs: 1800000
|
||||
maxParallel: 3
|
||||
projectContext: "Additional context for all tasks"
|
||||
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
|
||||
|
||||
- `.ralph/progress.json` - Execution progress
|
||||
- `.ralph/reflections/` - Per-task reflections
|
||||
- `.ralph/prompts/` - Generated prompts
|
||||
- `.ralpi/progress.json` - Execution progress
|
||||
- `.ralpi/reflections/` - Per-task reflections
|
||||
- `.ralpi/prompts/` - Generated prompts
|
||||
- `.ralpi/sessions/` - Full task output for review
|
||||
|
||||
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=="],
|
||||
}
|
||||
}
|
||||
954
index.ts
Normal file
954
index.ts
Normal file
@@ -0,0 +1,954 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import type {
|
||||
ExtensionAPI,
|
||||
ExtensionContext,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
import { Box, Text } from "@earendil-works/pi-tui";
|
||||
import { parseTaskFile, updateTaskInFile } from "./src/parser";
|
||||
import {
|
||||
buildExecutionPlan,
|
||||
buildSequentialPlan,
|
||||
formatDependencyChain,
|
||||
formatExecutionPlan,
|
||||
} from "./src/dag";
|
||||
import { ProgressTracker } from "./src/progress";
|
||||
import { buildPlanPrompt } from "./src/prompts";
|
||||
import { formatReflections } from "./src/reflection";
|
||||
import {
|
||||
executeBatch,
|
||||
SPINNER_FRAMES,
|
||||
type SendChatMessage,
|
||||
} from "./src/executor";
|
||||
import {
|
||||
loadConfig,
|
||||
resolveTaskArg,
|
||||
formatProgressStatus,
|
||||
findProgressFile,
|
||||
writeLoopActive,
|
||||
deleteLoopActive,
|
||||
readLoopActive,
|
||||
findRalpiDir,
|
||||
} from "./src/utils";
|
||||
|
||||
const COMMANDS = ["plan", "resume", "reset"] as const;
|
||||
|
||||
type ExecutionMode = "parallel" | "sequential";
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Detect if a token looks like a file path rather than a subcommand.
|
||||
* Matches: @path, /path, ./path, ../path, path/to/file, path.md, path.yaml
|
||||
*/
|
||||
function looksLikePath(token: string): boolean {
|
||||
return (
|
||||
token.startsWith("@") ||
|
||||
token.startsWith("/") ||
|
||||
token.startsWith("./") ||
|
||||
token.startsWith("../") ||
|
||||
token.includes("/") ||
|
||||
token.endsWith(".md") ||
|
||||
token.endsWith(".yaml") ||
|
||||
token.endsWith(".yml")
|
||||
);
|
||||
}
|
||||
|
||||
/** Build the set of completed tasks from progress tracker and PRD checkboxes. */
|
||||
function buildCompletedSet(
|
||||
progress: ProgressTracker,
|
||||
project: import("./src/types").Project,
|
||||
): Set<string> {
|
||||
const completed = new Set(progress.getCompletedTaskIds());
|
||||
for (const task of project.tasks) {
|
||||
if (task.status === "completed") {
|
||||
completed.add(task.id);
|
||||
}
|
||||
}
|
||||
return completed;
|
||||
}
|
||||
|
||||
/** Prompt user to select an execution mode with dependency validation. */
|
||||
async function selectExecutionMode(
|
||||
ctx: ExtensionContext,
|
||||
project: import("./src/types").Project,
|
||||
taskFile: string,
|
||||
config: import("./src/types").RalpiConfig,
|
||||
): Promise<ExecutionMode> {
|
||||
const mode = await ctx.ui.select("Execution mode for this run?", [
|
||||
`Parallel (where dependencies allow)[${config.execution.maxParallel} max]`,
|
||||
"Sequential (one at a time)",
|
||||
]);
|
||||
const isParallel = mode?.startsWith("Parallel") ?? false;
|
||||
|
||||
if (!isParallel) return "sequential";
|
||||
|
||||
// Validate dependency graph for parallel mode
|
||||
if (Object.keys(project.dependencies).length === 0) {
|
||||
const hasDepsSection = await fs.promises
|
||||
.readFile(taskFile, "utf-8")
|
||||
.then((content) => /^##\s+Dependencies\s*$/m.test(content))
|
||||
.catch(() => false);
|
||||
|
||||
if (hasDepsSection) {
|
||||
const choice = await ctx.ui.select(
|
||||
"Found ## Dependencies section but no valid dependencies were parsed.\n\n" +
|
||||
"This may be due to unsupported format. Parallel mode requires explicit dependencies.\n\n" +
|
||||
"See README.md for supported dependency formats:\n" +
|
||||
"- Arrow notation: `1 -> 2,3,4`\n" +
|
||||
"- Natural language: `13 depends on 17, 18, 19, 20`\n\n" +
|
||||
"Fall back to sequential mode?",
|
||||
["Yes, use sequential", "No, continue with parallel"],
|
||||
);
|
||||
if (choice?.startsWith("Yes")) {
|
||||
return "sequential";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "parallel";
|
||||
}
|
||||
|
||||
/** Build an execution plan based on the selected mode. */
|
||||
function buildPlanByMode(
|
||||
mode: ExecutionMode,
|
||||
project: Parameters<typeof buildExecutionPlan>[0],
|
||||
completed: Set<string>,
|
||||
) {
|
||||
return mode === "parallel"
|
||||
? buildExecutionPlan(project, completed)
|
||||
: buildSequentialPlan(project, completed);
|
||||
}
|
||||
|
||||
/** Run all batches in a plan, updating the task file after each batch. */
|
||||
async function executePlanBatches(
|
||||
plan: ReturnType<typeof buildPlanByMode>,
|
||||
project: Parameters<typeof buildExecutionPlan>[0],
|
||||
taskFile: string,
|
||||
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(
|
||||
"Execution paused. Use /ralpi resume to continue.",
|
||||
"warning",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Array.isArray(batch.tasks)) {
|
||||
throw new Error(
|
||||
`Batch ${
|
||||
batch.batchIndex
|
||||
} has invalid tasks: expected array, got ${typeof batch.tasks}`,
|
||||
);
|
||||
}
|
||||
|
||||
await executeBatch(
|
||||
batch.tasks,
|
||||
project,
|
||||
config,
|
||||
progress,
|
||||
ctx,
|
||||
{ parallel: mode === "parallel" },
|
||||
sendChatMessage,
|
||||
projectDir,
|
||||
);
|
||||
|
||||
for (const task of batch.tasks) {
|
||||
const status = progress.getTaskStatus(task.id);
|
||||
updateTaskInFile(taskFile, task.id, status);
|
||||
}
|
||||
|
||||
// Update failed task IDs after batch completes
|
||||
const newFailed = progress.getFailedTaskIds();
|
||||
for (const id of newFailed) {
|
||||
failedTaskIds.add(id);
|
||||
}
|
||||
|
||||
// In sequential mode, stop after any failure
|
||||
if (mode === "sequential" && failedTaskIds.size > 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
// In parallel mode, rebuild the plan to filter out newly blocked tasks
|
||||
if (mode === "parallel") {
|
||||
// Use buildCompletedSet to include file-based [x] completions
|
||||
// (progress.getCompletedTaskIds() only knows about tasks completed
|
||||
// during THIS execution session — tasks that were already [x] in the
|
||||
// file before the run started would be re-included and re-executed).
|
||||
const completed = buildCompletedSet(progress, project);
|
||||
const newPlan = buildExecutionPlan(
|
||||
project,
|
||||
completed,
|
||||
undefined,
|
||||
failedTaskIds,
|
||||
);
|
||||
|
||||
// Keep processed batches (up to current batch), replace the rest
|
||||
// with the fresh plan — its batchIndex restarts at 0, so filtering
|
||||
// by batchIndex > currentIdx would incorrectly drop the next batch.
|
||||
const processedCount = plan.batches.indexOf(batch) + 1;
|
||||
plan.batches.length = processedCount;
|
||||
plan.batches.push(...newPlan.batches);
|
||||
|
||||
// Skip if nothing remaining
|
||||
if (plan.batches.length === processedCount) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (projectDir) {
|
||||
deleteLoopActive(projectDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Extension Entry ────────────────────────────────────────────────────────
|
||||
|
||||
export default function ralpiLoopExtension(pi: ExtensionAPI): void {
|
||||
// Register custom message renderer for ralpi progress messages.
|
||||
// Renders an expandable tool-call tree: collapsed shows last 3 + "N more",
|
||||
// expanded (Ctrl+O) shows every tool call.
|
||||
pi.registerMessageRenderer(
|
||||
"ralpi-progress",
|
||||
(message, { expanded }, theme) => {
|
||||
const details = message.details as
|
||||
| {
|
||||
phase?: string;
|
||||
toolCalls?: Array<{ name: string; label: string }>;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
const MAX_COLLAPSED = 3;
|
||||
const lines: string[] = [];
|
||||
|
||||
// Header line — e.g. "✓ 05 · billing-subscriptions-trials (2m 14s)"
|
||||
lines.push(String(message.content));
|
||||
|
||||
// Build tool-call tree
|
||||
if (details?.toolCalls && details.toolCalls.length > 0) {
|
||||
const all = details.toolCalls;
|
||||
|
||||
if (expanded) {
|
||||
// Expanded: show ALL tool calls
|
||||
for (let i = 0; i < all.length; i++) {
|
||||
const entry = all[i];
|
||||
const isLast = i === all.length - 1;
|
||||
const branch = isLast ? " └── " : " ├── ";
|
||||
const tag = theme.fg("accent", `[${entry.name}]`);
|
||||
lines.push(`${branch}${tag} ${entry.label}`);
|
||||
}
|
||||
} else {
|
||||
// Collapsed: last N + "X more"
|
||||
const shown = all.slice(-MAX_COLLAPSED);
|
||||
const remaining = all.length - shown.length;
|
||||
|
||||
if (remaining > 0) {
|
||||
lines.push(theme.fg("dim", ` ├── ${remaining} more`));
|
||||
}
|
||||
|
||||
for (let i = 0; i < shown.length; i++) {
|
||||
const entry = shown[i];
|
||||
const isLast = i === shown.length - 1;
|
||||
const branch = isLast ? " └── " : " ├── ";
|
||||
const tag = theme.fg("accent", `[${entry.name}]`);
|
||||
lines.push(`${branch}${tag} ${entry.label}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const text = lines.join("\n");
|
||||
const box = new Box(1, 1, (t) => theme.bg("customMessageBg", t));
|
||||
box.addChild(new Text(text, 0, 0));
|
||||
return box;
|
||||
},
|
||||
);
|
||||
|
||||
// ─── 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",
|
||||
handler: async (args: string, ctx: ExtensionContext) => {
|
||||
const parts = (args || "").trim().split(/\s+/).filter(Boolean);
|
||||
|
||||
// Wraps pi.sendMessage() for posting status to the chat history.
|
||||
// Uses "ralpi-progress" customType with a "progress" phase so the
|
||||
// renderer omits the label prefix entirely (no [INFO] etc.).
|
||||
// Accepts an optional meta object with toolCalls for the expandable view.
|
||||
const sendProgress: SendChatMessage = (
|
||||
content: string,
|
||||
meta?: { toolCalls?: Array<{ name: string; label: string }> },
|
||||
) => {
|
||||
pi.sendMessage({
|
||||
customType: "ralpi-progress",
|
||||
content,
|
||||
display: true,
|
||||
details: { phase: "progress", toolCalls: meta?.toolCalls },
|
||||
});
|
||||
};
|
||||
|
||||
// If no args, show plan. If first token looks like a path (@path, /path, ./path),
|
||||
// route to run so the execution mode prompt fires.
|
||||
if (parts.length === 0) {
|
||||
return handlePlan(ctx, parts);
|
||||
}
|
||||
if (looksLikePath(parts[0])) {
|
||||
return handleRun(
|
||||
ctx,
|
||||
parts,
|
||||
sendProgress,
|
||||
ctx.model,
|
||||
pi.getThinkingLevel(),
|
||||
);
|
||||
}
|
||||
|
||||
const command = parts[0];
|
||||
switch (command) {
|
||||
case "run":
|
||||
return handleRun(
|
||||
ctx,
|
||||
parts.slice(1),
|
||||
sendProgress,
|
||||
ctx.model,
|
||||
pi.getThinkingLevel(),
|
||||
);
|
||||
case "plan":
|
||||
pi.sendUserMessage("@task-manager");
|
||||
ctx.ui.notify("Opening Task Manager...", "info");
|
||||
return;
|
||||
case "resume":
|
||||
return handleResume(
|
||||
ctx,
|
||||
parts.slice(1),
|
||||
sendProgress,
|
||||
ctx.model,
|
||||
pi.getThinkingLevel(),
|
||||
);
|
||||
case "reset":
|
||||
return handleReset(ctx, parts.slice(1));
|
||||
default: {
|
||||
// Auto-discover progress and offer resume
|
||||
const found = findProgressFile(process.cwd());
|
||||
if (found) {
|
||||
ctx.ui.notify(
|
||||
`Unknown command: ${command}\n\nFound existing progress in ${
|
||||
found.path
|
||||
}\nUse /ralpi resume to continue.\n\nAvailable: ${COMMANDS.join(
|
||||
", ",
|
||||
)}`,
|
||||
"warning",
|
||||
);
|
||||
} else {
|
||||
ctx.ui.notify(
|
||||
`Unknown command: ${command}\nAvailable: ${COMMANDS.join(", ")}`,
|
||||
"error",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ─── /ralpi plan ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function handlePlan(
|
||||
ctx: ExtensionContext,
|
||||
args: string[],
|
||||
): Promise<void> {
|
||||
const taskFile = resolveTaskArg(args[0] || "README.md", process.cwd());
|
||||
const project = parseTaskFile(taskFile);
|
||||
if (!Array.isArray(project.tasks)) {
|
||||
throw new Error(
|
||||
`Parsed project from ${taskFile} has invalid tasks: expected array, got ${typeof project.tasks}`,
|
||||
);
|
||||
}
|
||||
|
||||
const planPrompt = buildPlanPrompt(project);
|
||||
const plan = buildExecutionPlan(project, new Set());
|
||||
const formatted = formatExecutionPlan(plan);
|
||||
|
||||
ctx.ui.notify(`${planPrompt}\n\n${formatted}`, "info");
|
||||
}
|
||||
|
||||
// ─── /ralpi run ──────────────────────────────────────────────────────────────
|
||||
|
||||
async function handleRun(
|
||||
ctx: ExtensionContext,
|
||||
args: string[],
|
||||
sendChatMessage?: SendChatMessage,
|
||||
parentModel?: unknown,
|
||||
parentThinkingLevel?: unknown,
|
||||
): Promise<void> {
|
||||
const taskFile = resolveTaskArg(args[0] || "README.md", process.cwd());
|
||||
|
||||
// If targeting a specific task file and there's existing progress for it,
|
||||
// auto-resume instead of starting fresh
|
||||
const existingProgress = findProgressFile(process.cwd(), taskFile);
|
||||
if (existingProgress) {
|
||||
return handleResume(
|
||||
ctx,
|
||||
args.slice(0, 1),
|
||||
sendChatMessage,
|
||||
parentModel,
|
||||
parentThinkingLevel,
|
||||
);
|
||||
}
|
||||
|
||||
// No existing progress for this task — check for any progress at all
|
||||
const found = findProgressFile(process.cwd());
|
||||
if (found && !args[0]) {
|
||||
// Offer to resume instead of starting fresh
|
||||
const shouldResume = await ctx.ui.select(
|
||||
"Found existing ralpi progress. Resume?",
|
||||
["Yes, resume", "No, start fresh"],
|
||||
);
|
||||
|
||||
if (shouldResume?.startsWith("Yes")) {
|
||||
return handleResume(
|
||||
ctx,
|
||||
[],
|
||||
sendChatMessage,
|
||||
parentModel,
|
||||
parentThinkingLevel,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const projectDir = found
|
||||
? path.dirname(path.dirname(found.path))
|
||||
: process.cwd();
|
||||
|
||||
const project = parseTaskFile(taskFile);
|
||||
const config = loadConfig(projectDir);
|
||||
config.model = parentModel ?? ctx.model;
|
||||
config.thinkingLevel = parentThinkingLevel;
|
||||
const progress = new ProgressTracker(projectDir, taskFile);
|
||||
|
||||
const completed = buildCompletedSet(progress, project);
|
||||
const mode = await selectExecutionMode(ctx, project, taskFile, config);
|
||||
const plan = buildPlanByMode(mode, project, completed);
|
||||
|
||||
// Show dependency chain + execution plan before starting
|
||||
const depChain = formatDependencyChain(project);
|
||||
const formattedPlan = formatExecutionPlan(plan);
|
||||
if (mode === "parallel") {
|
||||
ctx.ui.notify(
|
||||
`${depChain}\n\n${formattedPlan}\n\nStarting parallel execution...`,
|
||||
"info",
|
||||
);
|
||||
} else {
|
||||
ctx.ui.notify(
|
||||
`${formattedPlan}\n\nStarting sequential execution...`,
|
||||
"info",
|
||||
);
|
||||
}
|
||||
|
||||
await executePlanBatches(
|
||||
plan,
|
||||
project,
|
||||
taskFile,
|
||||
config,
|
||||
progress,
|
||||
ctx,
|
||||
mode,
|
||||
sendChatMessage,
|
||||
projectDir,
|
||||
);
|
||||
|
||||
const state = progress.getState();
|
||||
const output = formatProgressStatus(state);
|
||||
|
||||
const reflections = progress.getAllReflections();
|
||||
if (reflections.length > 0) {
|
||||
ctx.ui.notify(`${output}\n\n${formatReflections(reflections)}`, "info");
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.ui.notify(output, "info");
|
||||
}
|
||||
|
||||
// ─── /ralpi status ───────────────────────────────────────────────────────────
|
||||
// (removed — use /ralpi plan to invoke @task-manager)
|
||||
|
||||
// ─── /ralpi resume ───────────────────────────────────────────────────────────
|
||||
|
||||
async function handleResume(
|
||||
ctx: ExtensionContext,
|
||||
args: string[],
|
||||
sendChatMessage?: SendChatMessage,
|
||||
parentModel?: unknown,
|
||||
parentThinkingLevel?: unknown,
|
||||
): 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) {
|
||||
ctx.ui.notify(
|
||||
`No existing progress for ${args[0]}. Start with /ralpi run ${args[0]}`,
|
||||
"warning",
|
||||
);
|
||||
return;
|
||||
}
|
||||
projectDir = path.dirname(path.dirname(found.path));
|
||||
} else {
|
||||
found = findProgressFile(process.cwd());
|
||||
if (!found) {
|
||||
ctx.ui.notify(
|
||||
"No .ralpi/progress.json found. Start with /ralpi run [task-file]",
|
||||
"warning",
|
||||
);
|
||||
return;
|
||||
}
|
||||
projectDir = path.dirname(path.dirname(found.path));
|
||||
// For no-arg resume, use the first PRD's source path or legacy sourcePath
|
||||
taskFile = found.state.prds
|
||||
? Object.values(found.state.prds)[0].sourcePath
|
||||
: found.state.sourcePath;
|
||||
}
|
||||
|
||||
const project = parseTaskFile(taskFile);
|
||||
if (!Array.isArray(project.tasks)) {
|
||||
throw new Error(
|
||||
`Parsed project from ${taskFile} has invalid tasks: expected array, got ${typeof project.tasks}`,
|
||||
);
|
||||
}
|
||||
const config = loadConfig(projectDir);
|
||||
config.model = parentModel ?? ctx.model;
|
||||
config.thinkingLevel = parentThinkingLevel;
|
||||
const progress = new ProgressTracker(projectDir, taskFile, found.prdKey);
|
||||
|
||||
progress.setPaused(false);
|
||||
|
||||
const completed = buildCompletedSet(progress, project);
|
||||
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,
|
||||
taskFile,
|
||||
config,
|
||||
progress,
|
||||
ctx,
|
||||
mode,
|
||||
sendChatMessage,
|
||||
projectDir,
|
||||
);
|
||||
|
||||
ctx.ui.notify(formatProgressStatus(progress.getState()), "info");
|
||||
}
|
||||
|
||||
// ─── /ralpi next ─────────────────────────────────────────────────────────────
|
||||
// (removed — use /ralpi run to execute tasks)
|
||||
|
||||
// ─── /ralpi reset ────────────────────────────────────────────────────────────
|
||||
|
||||
async function handleReset(
|
||||
ctx: ExtensionContext,
|
||||
args: string[],
|
||||
): Promise<void> {
|
||||
if (args[0]) {
|
||||
const taskFile = resolveTaskArg(args[0], process.cwd());
|
||||
const found = findProgressFile(process.cwd(), taskFile);
|
||||
const projectDir = found
|
||||
? path.dirname(path.dirname(found.path))
|
||||
: process.cwd();
|
||||
const progress = new ProgressTracker(projectDir, taskFile, found?.prdKey);
|
||||
progress.reset();
|
||||
} else {
|
||||
const found = findProgressFile(process.cwd());
|
||||
if (!found) {
|
||||
ctx.ui.notify(
|
||||
"No .ralpi/progress.json found. Start with /ralpi run [task-file]",
|
||||
"warning",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const projectDir = path.dirname(path.dirname(found.path));
|
||||
const progress = new ProgressTracker(
|
||||
projectDir,
|
||||
found.state.prds
|
||||
? Object.values(found.state.prds)[0].sourcePath
|
||||
: found.state.sourcePath,
|
||||
);
|
||||
progress.reset();
|
||||
}
|
||||
|
||||
ctx.ui.notify("Progress reset. All task statuses cleared.", "info");
|
||||
}
|
||||
61
package.json
61
package.json
@@ -1,17 +1,66 @@
|
||||
{
|
||||
"name": "ralph-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",
|
||||
"ralph-loop",
|
||||
"prd"
|
||||
],
|
||||
"author": "Michael Freno",
|
||||
"license": "MIT",
|
||||
"homepage": "https://github.com/mikefreno/ralpi",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/mikefreno/ralpi.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/mikefreno/ralpi/issues"
|
||||
},
|
||||
"files": [
|
||||
"index.ts",
|
||||
"src/",
|
||||
"skills/",
|
||||
"prompts/",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"watch": "tsc --watch"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"prepublishOnly": "tsc --noEmit",
|
||||
"test": "bun test"
|
||||
},
|
||||
"engines": {
|
||||
"bun": ">=1.1.0"
|
||||
},
|
||||
"pi": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
],
|
||||
"skills": [
|
||||
"./skills"
|
||||
],
|
||||
"prompts": [
|
||||
"./prompts"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"yaml": "^2.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@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"
|
||||
}
|
||||
}
|
||||
|
||||
188
prompts/task-manager.md
Normal file
188
prompts/task-manager.md
Normal file
@@ -0,0 +1,188 @@
|
||||
---
|
||||
name: task-manager
|
||||
description: Breaks down complex features into small, verifiable subtasks
|
||||
tools: read, edit, write, web_search, code_search, fetch_content, get_search_content, mcp, memory, skill, session_search, memory_search, ask_user_question, ctx_execute, ctx_execute_file, ctx_index, ctx_search, ctx_batch_execute
|
||||
systemPromptMode: replace
|
||||
inheritProjectContext: false
|
||||
inheritSkills: false
|
||||
defaultContext: fork
|
||||
---
|
||||
|
||||
# Task Manager (@task-manager)
|
||||
|
||||
Purpose:
|
||||
You are a Task Manager (@task-manager), an expert at breaking down complex software features into small, verifiable subtasks. Your role is to create structured task plans that enable efficient, atomic implementation work.
|
||||
|
||||
## Core Responsibilities
|
||||
|
||||
- Break complex features into atomic tasks
|
||||
- Create structured directories with task files and indexes
|
||||
- Generate clear acceptance criteria and dependency mapping
|
||||
- Follow strict naming conventions and file templates
|
||||
|
||||
## Mandatory Two-Phase Workflow
|
||||
|
||||
### Phase 1: Planning (Approval Required)
|
||||
|
||||
When given a complex feature request:
|
||||
|
||||
1. **Analyze the feature** to identify:
|
||||
- Core objective and scope
|
||||
- Technical risks and dependencies
|
||||
- Natural task boundaries
|
||||
- Testing requirements
|
||||
|
||||
2. **Create a subtask plan** with:
|
||||
- Feature slug (kebab-case)
|
||||
- Clear task sequence and dependencies
|
||||
- Exit criteria for feature completion
|
||||
|
||||
3. **Present plan using this exact format:**```
|
||||
|
||||
## Subtask Plan
|
||||
|
||||
feature: {kebab-case-feature-name}
|
||||
objective: {one-line description}
|
||||
|
||||
tasks:
|
||||
|
||||
- seq: {2-digit}, filename: {seq}-{task-description}.md, title: {clear title}
|
||||
- seq: {2-digit}, filename: {seq}-{task-description}.md, title: {clear title}
|
||||
|
||||
dependencies:
|
||||
|
||||
- {seq} -> {seq} (task dependencies)
|
||||
|
||||
exit_criteria:
|
||||
|
||||
- {specific, measurable completion criteria}
|
||||
|
||||
Approval needed before file creation.
|
||||
|
||||
```
|
||||
|
||||
4. **Wait for explicit approval** before proceeding to Phase 2.
|
||||
|
||||
### Phase 2: File Creation (After Approval)
|
||||
Once approved:
|
||||
|
||||
1. **Create directory structure:**
|
||||
- Base: `tasks/{feature}/`
|
||||
- Create feature README.md index
|
||||
- Create individual task files
|
||||
|
||||
2. **Use these exact templates (Dependencies only if applicable):**
|
||||
|
||||
**Feature Index Template** (`tasks/{feature}/README.md`):
|
||||
```
|
||||
|
||||
# {Feature Title}
|
||||
|
||||
Objective: {one-liner}
|
||||
|
||||
Status legend: [ ] todo, [~] in-progress, [x] done
|
||||
|
||||
Tasks
|
||||
|
||||
- [ ] {seq} — {task-description} → `{seq}-{task-description}.md`
|
||||
|
||||
Dependencies
|
||||
|
||||
- {seq} depends on {seq}
|
||||
|
||||
Exit criteria
|
||||
|
||||
- The feature is complete when {specific criteria}
|
||||
|
||||
```
|
||||
|
||||
**Task File Template** (`{seq}-{task-description}.md`):
|
||||
```
|
||||
|
||||
# {seq}. {Title}
|
||||
|
||||
meta:
|
||||
id: {feature}-{seq}
|
||||
feature: {feature}
|
||||
priority: P2
|
||||
depends_on: [{dependency-ids}]
|
||||
tags: [implementation, tests-required]
|
||||
|
||||
objective:
|
||||
|
||||
- Clear, single outcome for this task
|
||||
|
||||
deliverables:
|
||||
|
||||
- What gets added/changed (files, modules, endpoints)
|
||||
|
||||
steps:
|
||||
|
||||
- Step-by-step actions to complete the task
|
||||
|
||||
tests:
|
||||
|
||||
- Unit: which functions/modules to cover (Arrange–Act–Assert)
|
||||
- Integration/e2e: how to validate behavior
|
||||
|
||||
acceptance_criteria:
|
||||
|
||||
- Observable, binary pass/fail conditions
|
||||
|
||||
validation:
|
||||
|
||||
- Commands or scripts to run and how to verify
|
||||
|
||||
notes:
|
||||
|
||||
- Assumptions, links to relevant docs or design
|
||||
|
||||
```
|
||||
|
||||
3. **Provide creation summary:**
|
||||
```
|
||||
|
||||
## Subtasks Created
|
||||
|
||||
- tasks/{feature}/README.md
|
||||
- tasks/{feature}/{seq}-{task-description}.md
|
||||
|
||||
Next suggested task: {seq} — {title}
|
||||
|
||||
```
|
||||
|
||||
## Strict Conventions
|
||||
- **Naming:** Always use kebab-case for features and task descriptions
|
||||
- **Sequencing:** 2-digits (01, 02, 03...) — optionally a single lowercase letter
|
||||
suffix may be appended to insert a sub-task between two numbered steps without
|
||||
renumbering siblings (e.g. `02b`, `02c` for sub-tasks of `02`). The parser
|
||||
normalizes `2b` → `02b`.
|
||||
- **File pattern:** `{seq}-{task-description}.md`
|
||||
- **Dependencies:** Always map task relationships (if applicable)
|
||||
- **Tests:** Every task must include test requirements
|
||||
- **Acceptance:** Must have binary pass/fail criteria
|
||||
|
||||
## Quality Guidelines
|
||||
- Keep tasks atomic and implementation-ready
|
||||
- Include clear validation steps
|
||||
- Specify exact deliverables (files, functions, endpoints)
|
||||
- Use functional, declarative language
|
||||
- Avoid unnecessary complexity
|
||||
- Ensure each task can be completed independently (given dependencies)
|
||||
|
||||
## Available Tools
|
||||
You have access to: read,edit,write,grep,glob,patch (but NOT bash)
|
||||
You cannot modify: .env files, .key files, .secret files, node_modules, .git
|
||||
|
||||
## Response Instructions
|
||||
- Always follow the two-phase workflow exactly
|
||||
- Use the exact templates and formats provided
|
||||
- Wait for approval after Phase 1
|
||||
- Provide clear, actionable task breakdowns
|
||||
- Include all required metadata and structure
|
||||
|
||||
Break down the complex features into subtasks and create a task plan. Put all tasks in the /tasks/ directory.
|
||||
Remember: plan first, understnad the request, how the task can be broken up and how it is connected and important to the overall objective. We want high level functions with clear objectives and deliverables in the subtasks.
|
||||
|
||||
---
|
||||
User request: $@
|
||||
@@ -1,6 +1,10 @@
|
||||
# ralph-task
|
||||
---
|
||||
description: Executes individual tasks from ralpi task files using DAG-based dependency resolution, with progress tracking and reflection support
|
||||
---
|
||||
|
||||
Execute a single task from a ralph task file.
|
||||
# ralpi-task
|
||||
|
||||
Execute a single task from a ralpi task file.
|
||||
|
||||
## When to Use
|
||||
|
||||
@@ -11,9 +15,9 @@ Execute a single task from a ralph task file.
|
||||
## Usage
|
||||
|
||||
```
|
||||
/ralph run [task-file] # Run all tasks
|
||||
/ralph next [task-file] # Run next batch
|
||||
/ralph status [task-file] # Check progress
|
||||
/ralpi run [task-file] # Run all tasks
|
||||
/ralpi next [task-file] # Run next batch
|
||||
/ralpi status [task-file] # Check progress
|
||||
```
|
||||
|
||||
## Task File Location
|
||||
@@ -1,19 +1,25 @@
|
||||
import type { RalphConfig } from "./types";
|
||||
import { DEFAULT_CONFIG } from "./types";
|
||||
|
||||
export { DEFAULT_CONFIG };
|
||||
|
||||
// CLI
|
||||
export const SLASH_COMMAND = "/ralph";
|
||||
export const COMMANDS = ["run", "plan", "status", "resume", "next", "reset"] as const;
|
||||
export const SLASH_COMMAND = "/ralpi";
|
||||
export const COMMANDS = [
|
||||
"run",
|
||||
"plan",
|
||||
"status",
|
||||
"resume",
|
||||
"next",
|
||||
"reset",
|
||||
] as const;
|
||||
|
||||
// Task file detection
|
||||
export const TASK_FILE_NAMES = [
|
||||
"README.md",
|
||||
"PRD.md",
|
||||
"tasks.md",
|
||||
"tasks.yaml",
|
||||
"tasks.yml",
|
||||
"README.md",
|
||||
"PRD.md",
|
||||
"tasks.md",
|
||||
"tasks.yaml",
|
||||
"tasks.yml",
|
||||
] as const;
|
||||
|
||||
// Reflection parsing
|
||||
|
||||
640
src/dag.ts
640
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 ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -7,30 +41,50 @@ import type { Task, ExecutionBatch, ExecutionPlan, Project } from "./types";
|
||||
* Returns ordered batches of parallelizable tasks.
|
||||
*/
|
||||
export function buildExecutionPlan(
|
||||
project: Project,
|
||||
completed: Set<string>,
|
||||
parallelGroup?: number,
|
||||
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 (parallelGroup !== undefined) {
|
||||
return {
|
||||
batches: buildParallelGroupBatches(pendingTasks, allTasks, completed),
|
||||
totalTasks: pendingTasks.length,
|
||||
skippedTasks: project.tasks.filter(t => completed.has(t.id)),
|
||||
};
|
||||
}
|
||||
// If parallel_group is explicitly set (legacy config flag), use group-based batching
|
||||
if (parallelGroup !== undefined) {
|
||||
return {
|
||||
batches: buildParallelGroupBatchesLegacy(pendingTasks, failedTaskIds),
|
||||
totalTasks: pendingTasks.length,
|
||||
skippedTasks,
|
||||
};
|
||||
}
|
||||
|
||||
// Use dependency-based Kahn's algorithm
|
||||
return {
|
||||
batches: buildBatches(pendingTasks, allTasks, completed),
|
||||
totalTasks: pendingTasks.length,
|
||||
skippedTasks: project.tasks.filter(t => completed.has(t.id)),
|
||||
};
|
||||
// Use dependency-based Kahn's algorithm
|
||||
return {
|
||||
batches: buildBatches(pendingTasks, failedTaskIds),
|
||||
totalTasks: pendingTasks.length,
|
||||
skippedTasks,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Sequential Plan ─────────────────────────────────────────────────────────
|
||||
@@ -39,94 +93,165 @@ export function buildExecutionPlan(
|
||||
* Build a sequential execution plan (one task per batch)
|
||||
*/
|
||||
export function buildSequentialPlan(
|
||||
project: Project,
|
||||
completed: Set<string>,
|
||||
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) => ({
|
||||
tasks: [task],
|
||||
batchIndex: i,
|
||||
}));
|
||||
const pendingTasks = project.tasks.filter((t) => !completed.has(t.id));
|
||||
|
||||
return {
|
||||
batches,
|
||||
totalTasks: pendingTasks.length,
|
||||
skippedTasks: project.tasks.filter(t => completed.has(t.id)),
|
||||
};
|
||||
// Mark tasks with failed dependencies as skipped
|
||||
const blocked = getBlockedTasks(pendingTasks, failedTaskIds);
|
||||
const skippedTasks = project.tasks.filter(
|
||||
(t) => completed.has(t.id) || blocked.has(t.id),
|
||||
);
|
||||
const activeTasks = pendingTasks.filter((t) => !blocked.has(t.id));
|
||||
|
||||
const batches: ExecutionBatch[] = activeTasks.map((task, i) => ({
|
||||
tasks: [task],
|
||||
batchIndex: i,
|
||||
}));
|
||||
|
||||
return {
|
||||
batches,
|
||||
totalTasks: pendingTasks.length,
|
||||
skippedTasks,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Kahn's Algorithm (Dependency-Based Batching) ────────────────────────────
|
||||
|
||||
function buildBatches(
|
||||
pendingTasks: Task[],
|
||||
allTasks: Map<string, Task>,
|
||||
completed: Set<string>,
|
||||
pendingTasks: Task[],
|
||||
failedTaskIds: Set<string>,
|
||||
): ExecutionBatch[] {
|
||||
const batches: ExecutionBatch[] = [];
|
||||
const done = new Set(completed);
|
||||
const remaining = new Set(pendingTasks.map(t => t.id));
|
||||
const batches: ExecutionBatch[] = [];
|
||||
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
|
||||
const ready: Task[] = [];
|
||||
for (const task of pendingTasks) {
|
||||
if (!remaining.has(task.id)) continue;
|
||||
while (remaining.size > 0) {
|
||||
// Find tasks whose dependencies are all satisfied
|
||||
const ready: Task[] = [];
|
||||
for (const task of pendingTasks) {
|
||||
if (!remaining.has(task.id)) continue;
|
||||
|
||||
const deps = task.dependencies || [];
|
||||
const depsSatisfied = deps.every(
|
||||
dep => done.has(dep) || !allTasks.has(dep)
|
||||
);
|
||||
const deps = task.dependencies || [];
|
||||
const depsSatisfied = deps.every(
|
||||
(dep) => done.has(dep) || !pendingSet.has(dep),
|
||||
);
|
||||
|
||||
if (depsSatisfied) {
|
||||
ready.push(task);
|
||||
}
|
||||
}
|
||||
if (depsSatisfied) {
|
||||
ready.push(task);
|
||||
}
|
||||
}
|
||||
|
||||
// Cycle detection: no tasks ready but some remain
|
||||
if (ready.length === 0) {
|
||||
const cycleTasks = Array.from(remaining);
|
||||
throw new Error(
|
||||
`Dependency cycle detected among tasks: ${cycleTasks.join(", ")}`
|
||||
);
|
||||
}
|
||||
// Cycle detection: no tasks ready but some remain
|
||||
if (ready.length === 0) {
|
||||
const cycleTasks = Array.from(remaining);
|
||||
throw new Error(
|
||||
`Dependency cycle detected among tasks: ${cycleTasks.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
batches.push({ tasks: ready, batchIndex: batches.length });
|
||||
for (const task of ready) {
|
||||
done.add(task.id);
|
||||
remaining.delete(task.id);
|
||||
}
|
||||
}
|
||||
batches.push({ tasks: ready, batchIndex: batches.length });
|
||||
for (const task of ready) {
|
||||
done.add(task.id);
|
||||
remaining.delete(task.id);
|
||||
}
|
||||
}
|
||||
|
||||
return batches;
|
||||
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(
|
||||
pendingTasks: Task[],
|
||||
allTasks: Map<string, Task>,
|
||||
completed: Set<string>,
|
||||
function buildGroupAwareBatches(
|
||||
_project: Project,
|
||||
pendingTasks: Task[],
|
||||
failedTaskIds: Set<string>,
|
||||
): ExecutionBatch[] {
|
||||
const groups = new Map<number, Task[]>();
|
||||
const blocked = getBlockedTasks(pendingTasks, failedTaskIds);
|
||||
const activeTasks = pendingTasks.filter((t) => !blocked.has(t.id));
|
||||
|
||||
for (const task of pendingTasks) {
|
||||
const group = task.parallelGroup ?? 0;
|
||||
if (!groups.has(group)) groups.set(group, []);
|
||||
groups.get(group)!.push(task);
|
||||
}
|
||||
// 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[] = [];
|
||||
|
||||
const sortedGroups = Array.from(groups.entries()).sort(
|
||||
(a, b) => a[0] - b[0]
|
||||
);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
return sortedGroups.map(([groupNum, tasks], i) => ({
|
||||
tasks,
|
||||
batchIndex: i,
|
||||
}));
|
||||
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 activeTasks) {
|
||||
const group = task.parallelGroup ?? 0;
|
||||
if (!groups.has(group)) groups.set(group, []);
|
||||
groups.get(group)!.push(task);
|
||||
}
|
||||
|
||||
const sortedGroups = Array.from(groups.entries()).sort((a, b) => a[0] - b[0]);
|
||||
|
||||
return sortedGroups.map(([_groupNum, tasks], i) => ({
|
||||
tasks,
|
||||
batchIndex: i,
|
||||
}));
|
||||
}
|
||||
|
||||
// ─── Cycle Detection ─────────────────────────────────────────────────────────
|
||||
@@ -135,51 +260,51 @@ function buildParallelGroupBatches(
|
||||
* Detect cycles in the task dependency graph
|
||||
*/
|
||||
export function detectCycles(project: Project): string[] {
|
||||
const adj = new Map<string, string[]>();
|
||||
for (const task of project.tasks) {
|
||||
adj.set(task.id, task.dependencies || []);
|
||||
}
|
||||
const adj = new Map<string, string[]>();
|
||||
for (const task of project.tasks) {
|
||||
adj.set(task.id, task.dependencies || []);
|
||||
}
|
||||
|
||||
const WHITE = 0;
|
||||
const GRAY = 1;
|
||||
const BLACK = 2;
|
||||
const color = new Map<string, number>();
|
||||
const WHITE = 0;
|
||||
const GRAY = 1;
|
||||
const BLACK = 2;
|
||||
const color = new Map<string, number>();
|
||||
|
||||
for (const task of project.tasks) {
|
||||
color.set(task.id, WHITE);
|
||||
}
|
||||
for (const task of project.tasks) {
|
||||
color.set(task.id, WHITE);
|
||||
}
|
||||
|
||||
const cycleNodes: string[] = [];
|
||||
const cycleNodes: string[] = [];
|
||||
|
||||
function dfs(node: string): boolean {
|
||||
color.set(node, GRAY);
|
||||
const deps = adj.get(node) || [];
|
||||
function dfs(node: string): boolean {
|
||||
color.set(node, GRAY);
|
||||
const deps = adj.get(node) || [];
|
||||
|
||||
for (const dep of deps) {
|
||||
if (!adj.has(dep)) continue;
|
||||
const depColor = color.get(dep);
|
||||
for (const dep of deps) {
|
||||
if (!adj.has(dep)) continue;
|
||||
const depColor = color.get(dep);
|
||||
|
||||
if (depColor === GRAY) {
|
||||
cycleNodes.push(dep);
|
||||
return true;
|
||||
}
|
||||
if (depColor === WHITE && dfs(dep)) {
|
||||
cycleNodes.push(node);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (depColor === GRAY) {
|
||||
cycleNodes.push(dep);
|
||||
return true;
|
||||
}
|
||||
if (depColor === WHITE && dfs(dep)) {
|
||||
cycleNodes.push(node);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
color.set(node, BLACK);
|
||||
return false;
|
||||
}
|
||||
color.set(node, BLACK);
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const task of project.tasks) {
|
||||
if (color.get(task.id) === WHITE) {
|
||||
dfs(task.id);
|
||||
}
|
||||
}
|
||||
for (const task of project.tasks) {
|
||||
if (color.get(task.id) === WHITE) {
|
||||
dfs(task.id);
|
||||
}
|
||||
}
|
||||
|
||||
return [...new Set(cycleNodes)];
|
||||
return [...new Set(cycleNodes)];
|
||||
}
|
||||
|
||||
// ─── Ready Tasks ─────────────────────────────────────────────────────────────
|
||||
@@ -188,14 +313,14 @@ export function detectCycles(project: Project): string[] {
|
||||
* Get tasks that are ready to execute (all dependencies completed)
|
||||
*/
|
||||
export function getReadyTasks(
|
||||
project: Project,
|
||||
completed: Set<string>,
|
||||
project: Project,
|
||||
completed: Set<string>,
|
||||
): Task[] {
|
||||
return project.tasks.filter(task => {
|
||||
if (completed.has(task.id)) return false;
|
||||
const deps = task.dependencies || [];
|
||||
return deps.every(dep => completed.has(dep));
|
||||
});
|
||||
return project.tasks.filter((task) => {
|
||||
if (completed.has(task.id)) return false;
|
||||
const deps = task.dependencies || [];
|
||||
return deps.every((dep) => completed.has(dep));
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Critical Path ───────────────────────────────────────────────────────────
|
||||
@@ -204,67 +329,159 @@ export function getReadyTasks(
|
||||
* Calculate the critical path (longest path through the DAG)
|
||||
*/
|
||||
export function getCriticalPath(project: Project): Task[] {
|
||||
const taskMap = new Map(project.tasks.map(t => [t.id, t]));
|
||||
const dist = new Map<string, number>();
|
||||
const prev = new Map<string, string | null>();
|
||||
const taskMap = new Map(project.tasks.map((t) => [t.id, t]));
|
||||
const dist = new Map<string, number>();
|
||||
const prev = new Map<string, string | null>();
|
||||
|
||||
// Initialize
|
||||
for (const task of project.tasks) {
|
||||
dist.set(task.id, 1);
|
||||
prev.set(task.id, null);
|
||||
}
|
||||
// Initialize
|
||||
for (const task of project.tasks) {
|
||||
dist.set(task.id, 1);
|
||||
prev.set(task.id, null);
|
||||
}
|
||||
|
||||
// Topological sort
|
||||
const sorted: Task[] = [];
|
||||
const visited = new Set<string>();
|
||||
// Topological sort
|
||||
const sorted: Task[] = [];
|
||||
const visited = new Set<string>();
|
||||
|
||||
function visit(id: string) {
|
||||
if (visited.has(id)) return;
|
||||
visited.add(id);
|
||||
const task = taskMap.get(id);
|
||||
if (!task) return;
|
||||
function visit(id: string) {
|
||||
if (visited.has(id)) return;
|
||||
visited.add(id);
|
||||
const task = taskMap.get(id);
|
||||
if (!task) return;
|
||||
|
||||
for (const dep of task.dependencies || []) {
|
||||
visit(dep);
|
||||
}
|
||||
sorted.push(task);
|
||||
}
|
||||
for (const dep of task.dependencies || []) {
|
||||
visit(dep);
|
||||
}
|
||||
sorted.push(task);
|
||||
}
|
||||
|
||||
for (const task of project.tasks) {
|
||||
visit(task.id);
|
||||
}
|
||||
for (const task of project.tasks) {
|
||||
visit(task.id);
|
||||
}
|
||||
|
||||
// Relax edges
|
||||
for (const task of sorted) {
|
||||
for (const dep of task.dependencies || []) {
|
||||
const depTask = taskMap.get(dep);
|
||||
if (!depTask) continue;
|
||||
// Relax edges
|
||||
for (const task of sorted) {
|
||||
for (const dep of task.dependencies || []) {
|
||||
const depDist = dist.get(dep);
|
||||
if (depDist === undefined) continue;
|
||||
|
||||
const newDist = dist.get(dep) + 1;
|
||||
if (newDist > dist.get(task.id)!) {
|
||||
dist.set(task.id, newDist);
|
||||
prev.set(task.id, dep);
|
||||
}
|
||||
}
|
||||
}
|
||||
const newDist = depDist + 1;
|
||||
const currentDist = dist.get(task.id) ?? 0;
|
||||
if (newDist > currentDist) {
|
||||
dist.set(task.id, newDist);
|
||||
prev.set(task.id, dep);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Trace back from the longest path end
|
||||
let maxTask = project.tasks[0];
|
||||
for (const task of project.tasks) {
|
||||
if (dist.get(task.id) > dist.get(maxTask.id)) {
|
||||
maxTask = task;
|
||||
}
|
||||
}
|
||||
// Trace back from the longest path end
|
||||
let maxTask = project.tasks[0];
|
||||
for (const task of project.tasks) {
|
||||
const taskDist = dist.get(task.id) ?? 0;
|
||||
const maxDist = dist.get(maxTask.id) ?? 0;
|
||||
if (taskDist > maxDist) {
|
||||
maxTask = task;
|
||||
}
|
||||
}
|
||||
|
||||
const path: Task[] = [];
|
||||
let current: string | null = maxTask.id;
|
||||
while (current) {
|
||||
const task = taskMap.get(current);
|
||||
if (task) path.unshift(task);
|
||||
current = prev.get(current) || null;
|
||||
}
|
||||
const path: Task[] = [];
|
||||
let current: string | null = maxTask.id;
|
||||
while (current) {
|
||||
const task = taskMap.get(current);
|
||||
if (task) path.unshift(task);
|
||||
current = prev.get(current) || null;
|
||||
}
|
||||
|
||||
return path;
|
||||
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 ───────────────────────────────────────────────────
|
||||
@@ -272,25 +489,52 @@ export function getCriticalPath(project: Project): Task[] {
|
||||
/**
|
||||
* Format the execution plan for display
|
||||
*/
|
||||
export function formatExecutionPlan(plan: ExecutionPlan): string {
|
||||
const lines: string[] = [];
|
||||
lines.push("## Execution Plan");
|
||||
lines.push("");
|
||||
lines.push(`Total tasks: ${plan.totalTasks}`);
|
||||
lines.push(`Batches: ${plan.batches.length}`);
|
||||
/**
|
||||
* 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}`);
|
||||
|
||||
if (plan.skippedTasks.length > 0) {
|
||||
lines.push(`Already completed: ${plan.skippedTasks.map(t => t.id).join(", ")}`);
|
||||
}
|
||||
lines.push("");
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const batch of plan.batches) {
|
||||
lines.push(`### Batch ${batch.batchIndex + 1}`);
|
||||
for (const task of batch.tasks) {
|
||||
lines.push(`- ${task.id}: ${task.title}`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
if (plan.skippedTasks.length > 0) {
|
||||
lines.push(
|
||||
`Already completed: ${plan.skippedTasks.map((t) => t.id).join(", ")}`,
|
||||
);
|
||||
}
|
||||
lines.push("");
|
||||
|
||||
return lines.join("\n");
|
||||
for (const batch of plan.batches) {
|
||||
lines.push(`### Batch ${batch.batchIndex + 1}`);
|
||||
for (const task of batch.tasks) {
|
||||
const annotation = groupLabel.has(task.id)
|
||||
? ` _(${groupLabel.get(task.id)})_`
|
||||
: "";
|
||||
const deps =
|
||||
task.dependencies.length > 0
|
||||
? ` ← needs ${task.dependencies.join(", ")}`
|
||||
: "";
|
||||
lines.push(`- ${task.id}: ${task.title}${annotation}${deps}`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
1004
src/executor.ts
1004
src/executor.ts
File diff suppressed because it is too large
Load Diff
187
src/index.ts
187
src/index.ts
@@ -1,187 +0,0 @@
|
||||
import * as path from "node:path";
|
||||
import type { ExtensionContext } from "@pi/extension-api";
|
||||
import { parseTaskFile, updateTaskInFile } from "./parser";
|
||||
import { buildExecutionPlan, buildSequentialPlan, formatExecutionPlan, getReadyTasks } from "./dag";
|
||||
import { ProgressTracker } from "./progress";
|
||||
import { buildPlanPrompt } from "./prompts";
|
||||
import { formatReflections } from "./reflection";
|
||||
import { executeBatch } from "./executor";
|
||||
import { loadConfig, resolveTaskArg, formatProgressStatus, getPiPath } from "./utils";
|
||||
import { COMMANDS } from "./constants";
|
||||
|
||||
// ─── Extension Entry ────────────────────────────────────────────────────────
|
||||
|
||||
export function register(context: ExtensionContext) {
|
||||
context.registerSlashCommand({
|
||||
name: "ralph",
|
||||
description: "Execute tasks from a task file using DAG-based dependency resolution",
|
||||
handler: async (args: string[]) => {
|
||||
const [subcommand, ...rest] = args;
|
||||
const command = subcommand || "plan";
|
||||
|
||||
switch (command) {
|
||||
case "run":
|
||||
return handleRun(context, rest);
|
||||
case "plan":
|
||||
return handlePlan(context, rest);
|
||||
case "status":
|
||||
return handleStatus(context, rest);
|
||||
case "resume":
|
||||
return handleResume(context, rest);
|
||||
case "next":
|
||||
return handleNext(context, rest);
|
||||
case "reset":
|
||||
return handleReset(context, rest);
|
||||
default:
|
||||
return `Unknown command: ${command}\nAvailable: ${COMMANDS.join(", ")}`;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ─── /ralph plan ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function handlePlan(context: ExtensionContext, args: string[]): Promise<string> {
|
||||
const taskFile = resolveTaskArg(args[0] || "README.md", process.cwd());
|
||||
const project = parseTaskFile(taskFile);
|
||||
|
||||
// Show plan
|
||||
const planPrompt = buildPlanPrompt(project);
|
||||
const plan = buildExecutionPlan(project, new Set());
|
||||
const formatted = formatExecutionPlan(plan);
|
||||
|
||||
return `${planPrompt}\n\n${formatted}`;
|
||||
}
|
||||
|
||||
// ─── /ralph run ──────────────────────────────────────────────────────────────
|
||||
|
||||
async function handleRun(context: ExtensionContext, args: string[]): Promise<string> {
|
||||
const taskFile = resolveTaskArg(args[0] || "README.md", process.cwd());
|
||||
const project = parseTaskFile(taskFile);
|
||||
const config = loadConfig(process.cwd());
|
||||
const progress = new ProgressTracker(process.cwd(), taskFile);
|
||||
|
||||
// Build execution plan
|
||||
const completed = new Set(progress.getCompletedTaskIds());
|
||||
const plan = buildExecutionPlan(project, completed);
|
||||
|
||||
// Execute batches
|
||||
for (const batch of plan.batches) {
|
||||
// Check if paused
|
||||
if (progress.getState().paused) {
|
||||
return `Execution paused. Use /ralph resume to continue.`;
|
||||
}
|
||||
|
||||
await executeBatch(
|
||||
batch.batchIndex,
|
||||
batch.tasks,
|
||||
project,
|
||||
config,
|
||||
progress,
|
||||
);
|
||||
|
||||
// Update task file
|
||||
for (const task of batch.tasks) {
|
||||
const status = progress.getTaskStatus(task.id);
|
||||
updateTaskInFile(taskFile, task.id, status);
|
||||
}
|
||||
}
|
||||
|
||||
// Final status
|
||||
const state = progress.getState();
|
||||
const output = formatProgressStatus(state);
|
||||
|
||||
// Show reflections
|
||||
const reflections = progress.getAllReflections();
|
||||
if (reflections.length > 0) {
|
||||
return `${output}\n\n${formatReflections(reflections)}`;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
// ─── /ralph status ───────────────────────────────────────────────────────────
|
||||
|
||||
async function handleStatus(context: ExtensionContext, args: string[]): Promise<string> {
|
||||
const taskFile = resolveTaskArg(args[0] || "README.md", process.cwd());
|
||||
const progress = new ProgressTracker(process.cwd(), taskFile);
|
||||
return formatProgressStatus(progress.getState());
|
||||
}
|
||||
|
||||
// ─── /ralph resume ───────────────────────────────────────────────────────────
|
||||
|
||||
async function handleResume(context: ExtensionContext, args: string[]): Promise<string> {
|
||||
const taskFile = resolveTaskArg(args[0] || "README.md", process.cwd());
|
||||
const project = parseTaskFile(taskFile);
|
||||
const config = loadConfig(process.cwd());
|
||||
const progress = new ProgressTracker(process.cwd(), taskFile);
|
||||
|
||||
// Unpause
|
||||
progress.setPaused(false);
|
||||
|
||||
// Get remaining batches
|
||||
const completed = new Set(progress.getCompletedTaskIds());
|
||||
const plan = buildExecutionPlan(project, completed);
|
||||
|
||||
// Execute remaining batches
|
||||
for (const batch of plan.batches) {
|
||||
await executeBatch(
|
||||
batch.batchIndex,
|
||||
batch.tasks,
|
||||
project,
|
||||
config,
|
||||
progress,
|
||||
);
|
||||
|
||||
// Update task file
|
||||
for (const task of batch.tasks) {
|
||||
const status = progress.getTaskStatus(task.id);
|
||||
updateTaskInFile(taskFile, task.id, status);
|
||||
}
|
||||
}
|
||||
|
||||
return formatProgressStatus(progress.getState());
|
||||
}
|
||||
|
||||
// ─── /ralph next ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function handleNext(context: ExtensionContext, args: string[]): Promise<string> {
|
||||
const taskFile = resolveTaskArg(args[0] || "README.md", process.cwd());
|
||||
const project = parseTaskFile(taskFile);
|
||||
const config = loadConfig(process.cwd());
|
||||
const progress = new ProgressTracker(process.cwd(), taskFile);
|
||||
|
||||
const completed = new Set(progress.getCompletedTaskIds());
|
||||
const ready = getReadyTasks(project, completed);
|
||||
|
||||
if (ready.length === 0) {
|
||||
return "No tasks ready to execute. All tasks completed or blocked.";
|
||||
}
|
||||
|
||||
// Execute just the next batch (first ready tasks)
|
||||
const nextBatch = ready.slice(0, config.execution.maxParallel || ready.length);
|
||||
|
||||
for (const task of nextBatch) {
|
||||
await executeBatch(
|
||||
0,
|
||||
[task],
|
||||
project,
|
||||
config,
|
||||
progress,
|
||||
);
|
||||
|
||||
updateTaskInFile(taskFile, task.id, progress.getTaskStatus(task.id));
|
||||
}
|
||||
|
||||
return `Executed: ${nextBatch.map(t => t.id).join(", ")}\n\n${formatProgressStatus(progress.getState())}`;
|
||||
}
|
||||
|
||||
// ─── /ralph reset ────────────────────────────────────────────────────────────
|
||||
|
||||
async function handleReset(context: ExtensionContext, args: string[]): Promise<string> {
|
||||
const taskFile = resolveTaskArg(args[0] || "README.md", process.cwd());
|
||||
const progress = new ProgressTracker(process.cwd(), taskFile);
|
||||
progress.reset();
|
||||
|
||||
return "Progress reset. All task statuses cleared.";
|
||||
}
|
||||
888
src/parser.ts
888
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,162 +22,469 @@ 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: [...])
|
||||
*/
|
||||
export function parseTaskFile(filePath: string): Project {
|
||||
const absolutePath = path.resolve(filePath);
|
||||
const content = fs.readFileSync(absolutePath, "utf-8");
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
const dir = path.dirname(absolutePath);
|
||||
const absolutePath = path.resolve(filePath);
|
||||
const content = fs.readFileSync(absolutePath, "utf-8");
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
const dir = path.dirname(absolutePath);
|
||||
|
||||
if (ext === ".yaml" || ext === ".yml") {
|
||||
return parseYaml(content, absolutePath, dir);
|
||||
}
|
||||
if (ext === ".yaml" || ext === ".yml") {
|
||||
return parseYaml(content, absolutePath, dir);
|
||||
}
|
||||
|
||||
// Markdown: detect format
|
||||
if (hasDependenciesSection(content)) {
|
||||
return parseFioFormat(content, absolutePath, dir);
|
||||
}
|
||||
return parseSimpleCheckbox(content, absolutePath, dir);
|
||||
// Markdown: detect format
|
||||
if (hasDependenciesSection(content) || hasPhaseHeadings(content)) {
|
||||
return parseFioFormat(content, absolutePath, dir);
|
||||
}
|
||||
return parseSimpleCheckbox(content, absolutePath, dir);
|
||||
}
|
||||
|
||||
// ─── Fio Format Parser ───────────────────────────────────────────────────────
|
||||
|
||||
function hasDependenciesSection(content: string): boolean {
|
||||
return /^##\s+Dependencies\s*$/m.test(content);
|
||||
/** 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 parseFioFormat(content: string, sourcePath: string, sourceDir: string): Project {
|
||||
const lines = content.split("\n");
|
||||
const tasks: Task[] = [];
|
||||
const dependencies: Record<string, string[]> = {};
|
||||
let inTasks = false;
|
||||
let inDeps = false;
|
||||
function hasDependenciesSection(content: string): boolean {
|
||||
return DEP_HEADING_RE.test(content);
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
if (/^##\s+Tasks\s*$/m.test(line)) {
|
||||
inTasks = true;
|
||||
inDeps = false;
|
||||
continue;
|
||||
}
|
||||
if (/^##\s+Dependencies\s*$/m.test(line)) {
|
||||
inTasks = false;
|
||||
inDeps = true;
|
||||
continue;
|
||||
}
|
||||
if (/^##\s/.test(line) && !/^##\s+Tasks/.test(line) && !/^##\s+Dependencies/.test(line)) {
|
||||
inTasks = false;
|
||||
inDeps = false;
|
||||
continue;
|
||||
}
|
||||
function hasPhaseHeadings(content: string): boolean {
|
||||
return PHASE_HEADING_RE.test(content) || PHASE_HEADING_PLAIN_RE.test(content);
|
||||
}
|
||||
|
||||
if (inTasks) {
|
||||
const match = line.match(/^-+\s+\[([ ~x!-])\]\s+(\d+)\s+[—–-]\s+(.+?)(?:\s*→\s*`([^`]+)`)?/);
|
||||
if (match) {
|
||||
const [, , id, title, file] = match;
|
||||
tasks.push({
|
||||
id: `0${id}`,
|
||||
title: title.trim(),
|
||||
description: undefined,
|
||||
file: file || undefined,
|
||||
status: charToStatus(match[1]),
|
||||
dependencies: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
function parseFioFormat(
|
||||
content: string,
|
||||
sourcePath: string,
|
||||
sourceDir: string,
|
||||
): Project {
|
||||
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;
|
||||
|
||||
if (inDeps) {
|
||||
const depMatch = line.match(/^(\d+)\s*->\s*(\d+)/);
|
||||
if (depMatch) {
|
||||
const [, from, to] = depMatch;
|
||||
const fromId = `0${from}`;
|
||||
const toId = `0${to}`;
|
||||
if (!dependencies[fromId]) dependencies[fromId] = [];
|
||||
dependencies[fromId].push(toId);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const line of lines) {
|
||||
// Check for phase headings first
|
||||
const phaseMatch =
|
||||
line.match(PHASE_HEADING_RE) || line.match(PHASE_HEADING_PLAIN_RE);
|
||||
if (phaseMatch) {
|
||||
// Save previous phase if exists
|
||||
if (currentPhase !== null) {
|
||||
const phaseTaskIds = tasks
|
||||
.filter((t) => t.phase === currentPhase)
|
||||
.map((t) => t.id);
|
||||
if (phaseTaskIds.length > 0) {
|
||||
phases.push({
|
||||
number: currentPhase,
|
||||
title: currentPhaseTitle,
|
||||
taskIds: phaseTaskIds,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Start new phase
|
||||
currentPhase = parseInt(phaseMatch[1], 10);
|
||||
currentPhaseTitle = phaseMatch[2].trim();
|
||||
inTasks = true;
|
||||
inDeps = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract exit criteria
|
||||
const exitCriteria: string[] = [];
|
||||
const exitIdx = lines.findIndex(l => /^##\s+Exit\s+Criteria/i.test(l));
|
||||
if (exitIdx >= 0) {
|
||||
for (let i = exitIdx + 1; i < lines.length; i++) {
|
||||
if (/^##\s/.test(lines[i])) break;
|
||||
const m = lines[i].match(/^-\s+(.+)$/);
|
||||
if (m) exitCriteria.push(m[1].trim());
|
||||
}
|
||||
}
|
||||
if (TASK_HEADING_RE.test(line)) {
|
||||
inTasks = true;
|
||||
inDeps = false;
|
||||
continue;
|
||||
}
|
||||
if (DEP_HEADING_RE.test(line)) {
|
||||
inTasks = false;
|
||||
inDeps = true;
|
||||
continue;
|
||||
}
|
||||
// Reset state on any other section heading — both ##-style and plain
|
||||
// BUT NOT phase headings (already handled above)
|
||||
if (
|
||||
(ANY_MD_HEADING_RE.test(line) || isPlainSectionHeader(line)) &&
|
||||
!TASK_HEADING_RE.test(line) &&
|
||||
!DEP_HEADING_RE.test(line) &&
|
||||
!PHASE_HEADING_RE.test(line) &&
|
||||
!PHASE_HEADING_PLAIN_RE.test(line)
|
||||
) {
|
||||
inTasks = false;
|
||||
inDeps = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract objective from top-level heading
|
||||
const objectiveMatch = content.match(/^#\s+(.+)$/m);
|
||||
const objective = objectiveMatch ? objectiveMatch[1].trim() : undefined;
|
||||
if (inTasks) {
|
||||
// Match all tasks on a line (supports compact single-line formats).
|
||||
// ID is digits optionally followed by a single lowercase letter
|
||||
// (e.g. "01", "02b", "10c") — see normalizeTaskId for the shape.
|
||||
const taskPattern =
|
||||
/-+\s+\[(.)\]\s+(\d+[a-z]?)\s+[—–:-]\s+(.+?)(?:\s+(?=-+\s+\[)|\s*→\s*`([^`]+)`|$)/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = taskPattern.exec(line)) !== null) {
|
||||
const [, status, id, title, file] = match;
|
||||
const timeoutMs = parseTimeoutFromLine(line);
|
||||
tasks.push({
|
||||
id: normalizeTaskId(id),
|
||||
title: title.trim(),
|
||||
description: undefined,
|
||||
file: file || undefined,
|
||||
status: charToStatus(status),
|
||||
dependencies: [],
|
||||
timeoutMs,
|
||||
index: tasks.length,
|
||||
phase: currentPhase ?? undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { tasks, dependencies, sourcePath, sourceDir, exitCriteria, objective };
|
||||
if (inDeps) {
|
||||
// Arrow notation (supports both -> and unicode \u2192)
|
||||
// "01 -> 02,03,06" means 02, 03, 06 depend on 01
|
||||
// "02 \u2192 08" — single arrow with unicode
|
||||
// "03 \u2192 04 \u2192 05" — chained: 04 depends on 03, 05 depends on 04
|
||||
// "05, 07, 08 \u2192 13" — multi-prereq: 13 depends on 05, 07, 08
|
||||
// Supports optional markdown list prefix: "- 01 -> 02,03,06"
|
||||
const hasArrow = /->/.test(line) || /\u2192/.test(line);
|
||||
if (hasArrow) {
|
||||
// Strip optional list prefix and parenthetical description
|
||||
const cleaned = line
|
||||
.replace(/^(\s*[-*]\s+)?/, "")
|
||||
.replace(/\s*\(.*\)\s*$/, "");
|
||||
|
||||
// Split on arrows to get segments
|
||||
const segments = cleaned
|
||||
.split(/->|\u2192/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (segments.length >= 2) {
|
||||
for (let i = 0; i < segments.length - 1; i++) {
|
||||
// Left segment: source(s) (comma-separated)
|
||||
const fromIds = segments[i]
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter((t) => /^\d+[a-z]?$/.test(t))
|
||||
.map((t) => normalizeTaskId(t));
|
||||
|
||||
// Right segment: target(s) (comma-separated)
|
||||
const toIds = segments[i + 1]
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter((t) => /^\d+[a-z]?$/.test(t))
|
||||
.map((t) => normalizeTaskId(t));
|
||||
|
||||
for (const toId of toIds) {
|
||||
if (!dependencies[toId]) dependencies[toId] = [];
|
||||
for (const fromId of fromIds) {
|
||||
if (!dependencies[toId].includes(fromId)) {
|
||||
dependencies[toId].push(fromId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Format 1: Natural language "X depends on A, B, C"
|
||||
// Supports optional markdown list prefix: "- 13 depends on 17, 18, 19"
|
||||
// Also handles "also depends on": "- 08 also depends on 05, 06"
|
||||
// The dep list char class includes lowercase letters so lettered IDs
|
||||
// (e.g. "02b") don't truncate the capture. Per-id validation is
|
||||
// done by the filter below, so trailing prose can't leak in.
|
||||
const dependsMatch = line.match(
|
||||
/^(?:\s*[-*]\s+)?(\d+[a-z]?)\s+(?:also\s+)?depends\s+on\s+([\d,\s a-z]+)/i,
|
||||
);
|
||||
if (dependsMatch) {
|
||||
const [, taskId, depsList] = dependsMatch;
|
||||
const taskIdPadded = normalizeTaskId(taskId);
|
||||
const depIds = depsList
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter((t) => /^\d+[a-z]?$/.test(t))
|
||||
.map((t) => normalizeTaskId(t));
|
||||
|
||||
if (!dependencies[taskIdPadded]) dependencies[taskIdPadded] = [];
|
||||
for (const depId of depIds) {
|
||||
if (!dependencies[taskIdPadded].includes(depId)) {
|
||||
dependencies[taskIdPadded].push(depId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse meta blocks for task configuration (timeout, etc.)
|
||||
const metaMatch = line.match(
|
||||
/^0?(\d+[a-z]?)\s+\[timeout\]\s*=?\s*(\d+)(?:m|min|s|ms)?/i,
|
||||
);
|
||||
if (metaMatch) {
|
||||
const [, taskId, value, unit] = metaMatch;
|
||||
const task = tasks.find((t) => t.id === normalizeTaskId(taskId));
|
||||
if (task) {
|
||||
task.timeoutMs = parseTimeoutValue(Number(value), unit);
|
||||
}
|
||||
}
|
||||
|
||||
// Format 2: "X, Y, Z can be done in parallel (label)"
|
||||
// "- 01, 02, 03, 04 can be done in parallel (Play Store prep)"
|
||||
const parallelMatch = line.match(
|
||||
/^(?:\s*[-*]\s+)?((?:0?\d+[a-z]?\s*,\s*)*0?\d+[a-z]?)\s+can\s+be\s+done\s+in\s+parallel(?:\s+\(([^)]+)\))?$/i,
|
||||
);
|
||||
if (parallelMatch) {
|
||||
const [, idsStr, label] = parallelMatch;
|
||||
const taskIds = idsStr
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter((t) => /^\d+[a-z]?$/.test(t))
|
||||
.map((t) => normalizeTaskId(t));
|
||||
|
||||
if (taskIds.length > 0) {
|
||||
parallelGroups.push({
|
||||
index: parallelGroups.length,
|
||||
label: label ? label.trim() : undefined,
|
||||
taskIds,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Format 3: "A must be done before B, C" or "A, B must be done before C"
|
||||
// "- 21 must be done before 22, 23, 24 (backend integration foundation)"
|
||||
// "- 02, 03 must be done before 04"
|
||||
const mustBeforeMatch = line.match(
|
||||
/^(?:\s*[-*]\s+)?((?:0?\d+[a-z]?\s*,\s*)*0?\d+[a-z]?)\s+must\s+be\s+done\s+before\s+((?:0?\d+[a-z]?\s*,\s*)*0?\d+[a-z]?)(?:\s+\(([^)]+)\))?$/i,
|
||||
);
|
||||
if (mustBeforeMatch) {
|
||||
const [, fromIdsStr, toIdsStr] = mustBeforeMatch;
|
||||
const fromIds = fromIdsStr
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter((t) => /^\d+[a-z]?$/.test(t))
|
||||
.map((t) => normalizeTaskId(t));
|
||||
const toIds = toIdsStr
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter((t) => /^\d+[a-z]?$/.test(t))
|
||||
.map((t) => normalizeTaskId(t));
|
||||
|
||||
// Each "to" task depends on ALL "from" tasks
|
||||
for (const toId of toIds) {
|
||||
if (!dependencies[toId]) dependencies[toId] = [];
|
||||
for (const fromId of fromIds) {
|
||||
if (!dependencies[toId].includes(fromId)) {
|
||||
dependencies[toId].push(fromId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Format 4: "X, Y, Z depend on A" or "X depends on A, B, C"
|
||||
// "- 22, 23, 24 depend on 21"
|
||||
// "- 05, 06 depend on 02, 03, 04"
|
||||
// "- 08 also depends on 05, 06" ("also" is ignored)
|
||||
// Strip optional "also" before matching
|
||||
const cleanedLine = line.replace(/\balso\b/i, "");
|
||||
const dependOnMatch = cleanedLine.match(
|
||||
/^(?:\s*[-*]\s+)?((?:0?\d+[a-z]?\s*,\s*)*0?\d+[a-z]?)\s+depend(?:s)?\s+on\s+((?:0?\d+[a-z]?\s*,\s*)*0?\d+[a-z]?)(?:\s+\(([^)]+)\))?$/i,
|
||||
);
|
||||
if (dependOnMatch) {
|
||||
const [, fromIdsStr, toIdsStr] = dependOnMatch;
|
||||
const fromIds = fromIdsStr
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter((t) => /^\d+[a-z]?$/.test(t))
|
||||
.map((t) => normalizeTaskId(t));
|
||||
const toIds = toIdsStr
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter((t) => /^\d+[a-z]?$/.test(t))
|
||||
.map((t) => normalizeTaskId(t));
|
||||
|
||||
// Each "from" task depends on ALL "to" tasks
|
||||
for (const fromId of fromIds) {
|
||||
if (!dependencies[fromId]) dependencies[fromId] = [];
|
||||
for (const toId of toIds) {
|
||||
if (!dependencies[fromId].includes(toId)) {
|
||||
dependencies[fromId].push(toId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save final phase if we were in one
|
||||
if (currentPhase !== null) {
|
||||
const phaseTaskIds = tasks
|
||||
.filter((t) => t.phase === currentPhase)
|
||||
.map((t) => t.id);
|
||||
if (phaseTaskIds.length > 0) {
|
||||
phases.push({
|
||||
number: currentPhase,
|
||||
title: currentPhaseTitle,
|
||||
taskIds: phaseTaskIds,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add implicit phase-boundary dependencies
|
||||
// First task of each phase (except phase 1) depends on last task of previous phase
|
||||
if (phases.length > 1) {
|
||||
for (let i = 1; i < phases.length; i++) {
|
||||
const prevPhase = phases[i - 1];
|
||||
const currPhase = phases[i];
|
||||
if (prevPhase.taskIds.length === 0 || currPhase.taskIds.length === 0)
|
||||
continue;
|
||||
|
||||
const lastTaskOfPrevPhase =
|
||||
prevPhase.taskIds[prevPhase.taskIds.length - 1];
|
||||
const firstTaskOfCurrPhase = currPhase.taskIds[0];
|
||||
|
||||
// Add dependency if not already present
|
||||
if (!dependencies[firstTaskOfCurrPhase]) {
|
||||
dependencies[firstTaskOfCurrPhase] = [];
|
||||
}
|
||||
if (!dependencies[firstTaskOfCurrPhase].includes(lastTaskOfPrevPhase)) {
|
||||
dependencies[firstTaskOfCurrPhase].push(lastTaskOfPrevPhase);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract exit criteria — detect both ## Exit Criteria and plain Exit criteria
|
||||
const exitCriteria: string[] = [];
|
||||
const exitCriteriaRe = /^(?:##\s+)?Exit\s+Criteria/i;
|
||||
const exitIdx = lines.findIndex((l) => exitCriteriaRe.test(l));
|
||||
if (exitIdx >= 0) {
|
||||
for (let i = exitIdx + 1; i < lines.length; i++) {
|
||||
// Stop at any new section heading (##-style or plain)
|
||||
if (/^##\s/.test(lines[i]) || isPlainSectionHeader(lines[i])) break;
|
||||
const m = lines[i].match(/^-\s+(.+)$/);
|
||||
if (m) exitCriteria.push(m[1].trim());
|
||||
}
|
||||
}
|
||||
|
||||
// Extract objective from top-level heading
|
||||
const objectiveMatch = content.match(/^#\s+(.+)$/m);
|
||||
const objective = objectiveMatch ? objectiveMatch[1].trim() : undefined;
|
||||
|
||||
// Apply dependencies map to task.dependencies arrays
|
||||
for (const task of tasks) {
|
||||
if (dependencies[task.id]) {
|
||||
task.dependencies = dependencies[task.id];
|
||||
}
|
||||
}
|
||||
|
||||
// Apply parallelGroup to tasks
|
||||
for (const group of parallelGroups) {
|
||||
for (const taskId of group.taskIds) {
|
||||
const task = tasks.find((t) => t.id === taskId);
|
||||
if (task) {
|
||||
task.parallelGroup = group.index;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
tasks,
|
||||
dependencies,
|
||||
parallelGroups: parallelGroups.length > 0 ? parallelGroups : undefined,
|
||||
phases: phases.length > 0 ? phases : undefined,
|
||||
sourcePath,
|
||||
sourceDir,
|
||||
exitCriteria,
|
||||
objective,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Simple Checkbox Parser ──────────────────────────────────────────────────
|
||||
|
||||
function parseSimpleCheckbox(content: string, sourcePath: string, sourceDir: string): Project {
|
||||
const tasks: Task[] = [];
|
||||
const lines = content.split("\n");
|
||||
let idx = 0;
|
||||
function parseSimpleCheckbox(
|
||||
content: string,
|
||||
sourcePath: string,
|
||||
sourceDir: string,
|
||||
): Project {
|
||||
const tasks: Task[] = [];
|
||||
const lines = content.split("\n");
|
||||
let idx = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
const match = line.match(/^-+\s+\[([ ~x!-])\]\s+(.+)$/);
|
||||
if (match) {
|
||||
const [, statusChar, title] = match;
|
||||
const id = `${String(idx).padStart(2, "0")}`;
|
||||
tasks.push({
|
||||
id,
|
||||
title: title.trim(),
|
||||
status: charToStatus(statusChar),
|
||||
dependencies: [],
|
||||
});
|
||||
idx++;
|
||||
}
|
||||
}
|
||||
for (const line of lines) {
|
||||
const match = line.match(/^-+\s+\[(.)\]\s+(.+)$/);
|
||||
if (match) {
|
||||
const [, statusChar, title] = match;
|
||||
const id = `${String(idx).padStart(2, "0")}`;
|
||||
tasks.push({
|
||||
id,
|
||||
title: title.trim(),
|
||||
status: charToStatus(statusChar),
|
||||
dependencies: [],
|
||||
});
|
||||
idx++;
|
||||
}
|
||||
}
|
||||
|
||||
return { tasks, dependencies: {}, sourcePath, sourceDir };
|
||||
return { tasks, dependencies: {}, sourcePath, sourceDir };
|
||||
}
|
||||
|
||||
// ─── YAML Parser ─────────────────────────────────────────────────────────────
|
||||
|
||||
function parseYaml(content: string, 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");
|
||||
}
|
||||
function parseYaml(
|
||||
content: string,
|
||||
sourcePath: string,
|
||||
sourceDir: string,
|
||||
): Project {
|
||||
const YAML = loadYaml();
|
||||
const doc = YAML.parse(content);
|
||||
const tasks: Task[] = [];
|
||||
|
||||
const doc = YAML.parse(content);
|
||||
const tasks: Task[] = [];
|
||||
if (doc.tasks && Array.isArray(doc.tasks)) {
|
||||
doc.tasks.forEach((t: any, idx: number) => {
|
||||
tasks.push({
|
||||
id: t.id || `${String(idx).padStart(2, "0")}`,
|
||||
title: t.title || t.name || `Task ${idx}`,
|
||||
description: t.description,
|
||||
file: t.file,
|
||||
status: (t.status as Task["status"]) || "pending",
|
||||
dependencies: t.depends_on || t.dependencies || [],
|
||||
parallelGroup: t.parallel_group,
|
||||
timeoutMs: parseTimeoutFromMeta(t.timeout),
|
||||
index: idx,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (doc.tasks && Array.isArray(doc.tasks)) {
|
||||
doc.tasks.forEach((t: any, idx: number) => {
|
||||
tasks.push({
|
||||
id: t.id || `${String(idx).padStart(2, "0")}`,
|
||||
title: t.title || t.name || `Task ${idx}`,
|
||||
description: t.description,
|
||||
file: t.file,
|
||||
status: (t.status as Task["status"]) || "pending",
|
||||
dependencies: t.depends_on || t.dependencies || [],
|
||||
parallelGroup: t.parallel_group,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
tasks,
|
||||
dependencies: doc.dependencies || {},
|
||||
sourcePath,
|
||||
sourceDir,
|
||||
exitCriteria: doc.exit_criteria || doc.exitCriteria,
|
||||
objective: doc.objective,
|
||||
};
|
||||
return {
|
||||
tasks,
|
||||
dependencies: doc.dependencies || {},
|
||||
sourcePath,
|
||||
sourceDir,
|
||||
exitCriteria: doc.exit_criteria || doc.exitCriteria,
|
||||
objective: doc.objective,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Task Spec Reader ────────────────────────────────────────────────────────
|
||||
@@ -172,40 +493,128 @@ function parseYaml(content: string, sourcePath: string, sourceDir: string): Proj
|
||||
* Read the detailed task specification from a task file
|
||||
*/
|
||||
export function readTaskSpec(taskDir: string, taskFile: string): string {
|
||||
const fullPath = path.resolve(taskDir, taskFile);
|
||||
if (!fs.existsSync(fullPath)) return "";
|
||||
return fs.readFileSync(fullPath, "utf-8");
|
||||
const fullPath = path.resolve(taskDir, taskFile);
|
||||
if (!fs.existsSync(fullPath)) return "";
|
||||
return fs.readFileSync(fullPath, "utf-8");
|
||||
}
|
||||
|
||||
// ─── Task File Updater ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Update task status in the source 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);
|
||||
export function updateTaskInFile(
|
||||
filePath: string,
|
||||
taskId: string,
|
||||
status: Task["status"],
|
||||
): void {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
|
||||
// Try Fio numbered format first
|
||||
const fioPattern = new RegExp(
|
||||
`(^-\\s+\\[)([ ~x!-])(\\]\\s+${escapeRegex(taskId)}\\s*[—–-])`,
|
||||
"m"
|
||||
);
|
||||
if (fioPattern.test(content)) {
|
||||
content = content.replace(fioPattern, `$1${char}$3`);
|
||||
fs.writeFileSync(filePath, content, "utf-8");
|
||||
return;
|
||||
}
|
||||
// Handle YAML format
|
||||
if (ext === ".yaml" || ext === ".yml") {
|
||||
updateTaskInYaml(filePath, taskId, status);
|
||||
return;
|
||||
}
|
||||
|
||||
// Try simple checkbox format
|
||||
const simplePattern = new RegExp(
|
||||
`(-\\s+\\[)([ ~x!-])(\\]\\s+${escapeRegex(taskId)}`,
|
||||
"m"
|
||||
);
|
||||
if (simplePattern.test(content)) {
|
||||
content = content.replace(simplePattern, `$1${char}$3`);
|
||||
fs.writeFileSync(filePath, content, "utf-8");
|
||||
}
|
||||
let content = fs.readFileSync(filePath, "utf-8");
|
||||
const char = statusToChar(status);
|
||||
|
||||
// Strategy 1: Fio numbered format — match by explicit task ID in the file.
|
||||
// For pure-digit IDs, also try the parsed numeric form (parity with the
|
||||
// pre-lettered behavior). Lettered IDs ("02b", "02c") only have one valid
|
||||
// form — the parseInt fallback would silently drop the letter suffix and
|
||||
// create false-positive partial matches, so we skip it for them.
|
||||
const idPatterns = new Set([escapeRegex(taskId)]);
|
||||
if (!taskId.startsWith("0") && /^\d+$/.test(taskId)) {
|
||||
const rawId = parseInt(taskId, 10).toString();
|
||||
idPatterns.add(escapeRegex(rawId));
|
||||
}
|
||||
|
||||
for (const idPattern of idPatterns) {
|
||||
const fioRegex = new RegExp(
|
||||
`(^-\\s+\\[)(.)(\\]\\s+${idPattern}\\s*[—–:-])`,
|
||||
"m",
|
||||
);
|
||||
const match = content.match(fioRegex);
|
||||
if (match) {
|
||||
content = content.replace(fioRegex, `$1${char}$3`);
|
||||
fs.writeFileSync(filePath, content, "utf-8");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 2: Simple checkbox by position (task IDs are zero-padded indices)
|
||||
const targetIndex = parseInt(taskId, 10);
|
||||
if (!isNaN(targetIndex)) {
|
||||
const lines = content.split("\n");
|
||||
let checkboxIdx = 0;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const m = lines[i].match(/^(\s*-+\s+\[)(.)(\].*)$/);
|
||||
if (m) {
|
||||
if (checkboxIdx === targetIndex) {
|
||||
lines[i] = m[1] + char + m[3];
|
||||
fs.writeFileSync(filePath, lines.join("\n"), "utf-8");
|
||||
return;
|
||||
}
|
||||
checkboxIdx++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update task status in a YAML task file using the yaml library's
|
||||
* Document API, which preserves comments and formatting.
|
||||
*
|
||||
* Matches by explicit `id` field first, then falls back to
|
||||
* position-based matching (for files without explicit IDs).
|
||||
*/
|
||||
function updateTaskInYaml(
|
||||
filePath: string,
|
||||
taskId: string,
|
||||
status: Task["status"],
|
||||
): void {
|
||||
const YAML = loadYaml();
|
||||
const content = fs.readFileSync(filePath, "utf-8");
|
||||
const doc = YAML.parseDocument(content);
|
||||
const tasks = doc.get("tasks");
|
||||
if (!tasks || !YAML.isSeq(tasks)) return;
|
||||
|
||||
// Build alternate ID forms for matching. For lettered IDs ("02b"), the
|
||||
// verbatim form is the only valid pattern — parseInt would drop the suffix.
|
||||
const idVariants: string[] = [taskId];
|
||||
if (/^\d+$/.test(taskId)) {
|
||||
idVariants.push(parseInt(taskId, 10).toString());
|
||||
}
|
||||
|
||||
// Strategy 1: Match by explicit id field
|
||||
for (const item of tasks.items) {
|
||||
if (!YAML.isMap(item)) continue;
|
||||
const idVal = item.get("id");
|
||||
if (idVal === undefined || idVal === null) continue;
|
||||
const idStr = String(idVal);
|
||||
if (idVariants.includes(idStr)) {
|
||||
item.set("status", status);
|
||||
fs.writeFileSync(filePath, String(doc), "utf-8");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 2: Fall back to position-based matching
|
||||
// (for YAML files without explicit id fields)
|
||||
const targetIndex = parseInt(taskId, 10);
|
||||
if (!isNaN(targetIndex) && targetIndex < tasks.items.length) {
|
||||
const item = tasks.items[targetIndex];
|
||||
if (YAML.isMap(item)) {
|
||||
item.set("status", status);
|
||||
fs.writeFileSync(filePath, String(doc), "utf-8");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Auto-Detect Dependencies ────────────────────────────────────────────────
|
||||
@@ -214,60 +623,151 @@ export function updateTaskInFile(filePath: string, taskId: string, status: Task[
|
||||
* Auto-detect dependencies by analyzing task file references
|
||||
*/
|
||||
export function autoDetectDependencies(project: Project): Project {
|
||||
const tasks = project.tasks.map(t => ({ ...t, dependencies: [...t.dependencies] }));
|
||||
const taskMap = new Map(tasks.map(t => [t.id, t]));
|
||||
const taskFiles = new Map(
|
||||
tasks.filter(t => t.file).map(t => [path.resolve(project.sourceDir, t.file!), t])
|
||||
);
|
||||
const tasks = project.tasks.map((t) => ({
|
||||
...t,
|
||||
dependencies: [...t.dependencies],
|
||||
}));
|
||||
const taskFiles = new Map(
|
||||
tasks
|
||||
.filter((t) => t.file)
|
||||
.map((t) => [path.resolve(project.sourceDir, t.file!), t]),
|
||||
);
|
||||
|
||||
for (const [filePath, task] of taskFiles) {
|
||||
if (!fs.existsSync(filePath)) continue;
|
||||
const content = fs.readFileSync(filePath, "utf-8");
|
||||
for (const [filePath, task] of taskFiles) {
|
||||
if (!fs.existsSync(filePath)) continue;
|
||||
const content = fs.readFileSync(filePath, "utf-8");
|
||||
|
||||
// Check if this task's file references another task's file
|
||||
for (const [file, refTask] of taskFiles) {
|
||||
if (refTask.id === task.id) continue;
|
||||
if (content.includes(file) || content.includes(refTask.title)) {
|
||||
if (!task.dependencies.includes(refTask.id)) {
|
||||
task.dependencies.push(refTask.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Check if this task's file references another task's file
|
||||
for (const [file, refTask] of taskFiles) {
|
||||
if (refTask.id === task.id) continue;
|
||||
if (content.includes(file) || content.includes(refTask.title)) {
|
||||
if (!task.dependencies.includes(refTask.id)) {
|
||||
task.dependencies.push(refTask.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const dependencies: Record<string, string[]> = {};
|
||||
for (const task of tasks) {
|
||||
if (task.dependencies.length > 0) {
|
||||
dependencies[task.id] = task.dependencies;
|
||||
}
|
||||
}
|
||||
const dependencies: Record<string, string[]> = {};
|
||||
for (const task of tasks) {
|
||||
if (task.dependencies.length > 0) {
|
||||
dependencies[task.id] = task.dependencies;
|
||||
}
|
||||
}
|
||||
|
||||
return { ...project, tasks, dependencies };
|
||||
return { ...project, tasks, dependencies };
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// ─── Timeout Parsing ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parse timeout from a task line (e.g., "timeout: 15m" or "# timeout=30s")
|
||||
*/
|
||||
function parseTimeoutFromLine(line: string): number | undefined {
|
||||
// Match patterns like "timeout: 15m", "# timeout=30s", "timeout: 5min"
|
||||
const match = line.match(/(?:timeout|timelimit)[\s:=]+(\d+)(?:m|min|s|ms)?/i);
|
||||
if (match) {
|
||||
return parseTimeoutValue(Number(match[1]), match[2]);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a timeout value with unit suffix
|
||||
*/
|
||||
function parseTimeoutValue(value: number, unit?: string): number {
|
||||
const u = (unit || "m").toLowerCase();
|
||||
switch (u) {
|
||||
case "ms":
|
||||
return value;
|
||||
case "s":
|
||||
return value * 1000;
|
||||
case "m":
|
||||
case "min":
|
||||
return value * 60 * 1000;
|
||||
default:
|
||||
return value * 60 * 1000; // default to minutes
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse timeout from YAML meta field (string or number)
|
||||
* Supports: "15m", "30s", "5min", 15 (minutes), 900000 (ms)
|
||||
*/
|
||||
function parseTimeoutFromMeta(
|
||||
timeout: string | number | undefined,
|
||||
): number | undefined {
|
||||
if (timeout === undefined) return undefined;
|
||||
|
||||
if (typeof timeout === "number") {
|
||||
// Assume minutes if < 1000, milliseconds if >= 1000
|
||||
return timeout < 1000 ? timeout * 60 * 1000 : timeout;
|
||||
}
|
||||
|
||||
const match = timeout.match(/^(\d+)(ms|s|m|min)?$/i);
|
||||
if (match) {
|
||||
return parseTimeoutValue(Number(match[1]), match[2]);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a task ID: zero-pad the digit portion to 2 chars, preserve any
|
||||
* single lowercase letter suffix. Idempotent on already-normalized IDs.
|
||||
*
|
||||
* "1" → "01"
|
||||
* "2" → "02"
|
||||
* "2b" → "02b"
|
||||
* "02b" → "02b"
|
||||
* "10" → "10"
|
||||
* "10b" → "10b"
|
||||
*
|
||||
* Pass-through for IDs that don't match the expected shape (defensive — the
|
||||
* upstream regexes restrict matches, but a stray value should not be silently
|
||||
* re-shaped).
|
||||
*/
|
||||
function normalizeTaskId(id: string): string {
|
||||
const match = id.match(/^(\d+)([a-z])?$/);
|
||||
if (!match) return id;
|
||||
const [, digits, letter] = match;
|
||||
return digits.padStart(2, "0") + (letter ?? "");
|
||||
}
|
||||
|
||||
function charToStatus(char: string): Task["status"] {
|
||||
switch (char) {
|
||||
case " ": return "pending";
|
||||
case "~": return "in_progress";
|
||||
case "x": return "completed";
|
||||
case "!": return "failed";
|
||||
case "-": return "skipped";
|
||||
default: return "pending";
|
||||
}
|
||||
switch (char) {
|
||||
case " ":
|
||||
return "pending";
|
||||
case "~":
|
||||
return "in_progress";
|
||||
case "x":
|
||||
return "completed";
|
||||
case "!":
|
||||
return "failed";
|
||||
case "-":
|
||||
return "skipped";
|
||||
default:
|
||||
return "pending";
|
||||
}
|
||||
}
|
||||
|
||||
function statusToChar(status: Task["status"]): string {
|
||||
switch (status) {
|
||||
case "pending": return " ";
|
||||
case "in_progress": return "~";
|
||||
case "completed": return "x";
|
||||
case "failed": return "!";
|
||||
case "skipped": return "-";
|
||||
}
|
||||
switch (status) {
|
||||
case "pending":
|
||||
return " ";
|
||||
case "in_progress":
|
||||
return "~";
|
||||
case "completed":
|
||||
return "x";
|
||||
case "failed":
|
||||
return "!";
|
||||
case "skipped":
|
||||
return "-";
|
||||
}
|
||||
}
|
||||
|
||||
function escapeRegex(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
225
src/progress.ts
225
src/progress.ts
@@ -1,20 +1,42 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import type { ProgressState, Task, Reflection } from "./types";
|
||||
import type {
|
||||
ProgressState,
|
||||
PRDProgress,
|
||||
Task,
|
||||
Reflection,
|
||||
ToolUsage,
|
||||
} from "./types";
|
||||
import { ensureDir } from "./utils";
|
||||
|
||||
/**
|
||||
* Derive a stable PRD key from a source path relative to the project dir.
|
||||
* e.g., "tasks/feature-x/README.md" → "tasks-feature-x-README"
|
||||
*/
|
||||
export function derivePRDKey(projectDir: string, sourcePath: string): string {
|
||||
const rel = path.relative(projectDir, sourcePath);
|
||||
return rel
|
||||
.replace(/[^a-zA-Z0-9_-]/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages persistent progress state for a ralph execution.
|
||||
* State is stored as JSON in .ralph/progress.json
|
||||
* State is stored as JSON in .ralpi/progress.json.
|
||||
* Supports multiple PRDs in progress simultaneously via the `prds` field.
|
||||
* Falls back to legacy flat format for backward compatibility.
|
||||
*/
|
||||
export class ProgressTracker {
|
||||
private statePath: string;
|
||||
private state: ProgressState;
|
||||
private prdKey: string;
|
||||
|
||||
constructor(projectDir: string, sourcePath: string) {
|
||||
const stateDir = path.join(projectDir, ".ralph");
|
||||
constructor(projectDir: string, sourcePath: string, prdKey?: string) {
|
||||
const stateDir = path.join(projectDir, ".ralpi");
|
||||
ensureDir(stateDir);
|
||||
this.statePath = path.join(stateDir, "progress.json");
|
||||
this.prdKey = prdKey ?? derivePRDKey(projectDir, sourcePath);
|
||||
this.state = this.loadOrCreate(sourcePath);
|
||||
}
|
||||
|
||||
@@ -23,13 +45,62 @@ export class ProgressTracker {
|
||||
if (fs.existsSync(this.statePath)) {
|
||||
try {
|
||||
const raw = fs.readFileSync(this.statePath, "utf-8");
|
||||
return JSON.parse(raw) as ProgressState;
|
||||
const parsed = JSON.parse(raw) as ProgressState;
|
||||
|
||||
// Multi-PRD mode: check if we have a PRD entry
|
||||
if (parsed.prds?.[this.prdKey]) {
|
||||
// Found PRD entry — use it, but keep legacy fields for compat
|
||||
return parsed;
|
||||
}
|
||||
|
||||
// Legacy flat mode: check if the source path matches
|
||||
if (path.resolve(parsed.sourcePath) === path.resolve(sourcePathHint)) {
|
||||
// Migrate legacy state to PRD mode
|
||||
parsed.prds = {
|
||||
[this.prdKey]: {
|
||||
sourcePath: parsed.sourcePath,
|
||||
tasks: parsed.tasks,
|
||||
startedAt: parsed.startedAt,
|
||||
lastUpdatedAt: parsed.lastUpdatedAt,
|
||||
paused: parsed.paused,
|
||||
},
|
||||
};
|
||||
return parsed;
|
||||
}
|
||||
|
||||
// Different PRD — create new entry alongside existing ones
|
||||
if (parsed.prds) {
|
||||
parsed.prds[this.prdKey] = this.freshPRD(sourcePathHint);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
// Legacy flat state exists but for a different source — promote it to PRD mode
|
||||
const legacyKey = derivePRDKey(
|
||||
path.dirname(this.statePath),
|
||||
parsed.sourcePath,
|
||||
);
|
||||
parsed.prds = {
|
||||
[legacyKey]: {
|
||||
sourcePath: parsed.sourcePath,
|
||||
tasks: parsed.tasks,
|
||||
startedAt: parsed.startedAt,
|
||||
lastUpdatedAt: parsed.lastUpdatedAt,
|
||||
paused: parsed.paused,
|
||||
},
|
||||
[this.prdKey]: this.freshPRD(sourcePathHint),
|
||||
};
|
||||
return parsed;
|
||||
} catch {
|
||||
// Fall through to create new
|
||||
}
|
||||
}
|
||||
|
||||
return this.freshState(sourcePathHint);
|
||||
}
|
||||
|
||||
private freshPRD(sourcePath: string): PRDProgress {
|
||||
return {
|
||||
sourcePath: sourcePathHint,
|
||||
sourcePath,
|
||||
tasks: {},
|
||||
startedAt: new Date().toISOString(),
|
||||
lastUpdatedAt: new Date().toISOString(),
|
||||
@@ -37,9 +108,47 @@ export class ProgressTracker {
|
||||
};
|
||||
}
|
||||
|
||||
private freshState(sourcePath: string): ProgressState {
|
||||
return {
|
||||
sourcePath,
|
||||
tasks: {},
|
||||
startedAt: new Date().toISOString(),
|
||||
lastUpdatedAt: new Date().toISOString(),
|
||||
paused: false,
|
||||
prds: {
|
||||
[this.prdKey]: {
|
||||
sourcePath,
|
||||
tasks: {},
|
||||
startedAt: new Date().toISOString(),
|
||||
lastUpdatedAt: new Date().toISOString(),
|
||||
paused: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Get the PRD-scoped progress entry */
|
||||
private getPRD(): PRDProgress {
|
||||
if (!this.state.prds) {
|
||||
// Should not happen after loadOrCreate, but guard anyway
|
||||
this.state.prds = { [this.prdKey]: this.freshPRD(this.state.sourcePath) };
|
||||
}
|
||||
if (!this.state.prds[this.prdKey]) {
|
||||
this.state.prds[this.prdKey] = this.freshPRD(this.state.sourcePath);
|
||||
}
|
||||
return this.state.prds[this.prdKey];
|
||||
}
|
||||
|
||||
/** Save current state to disk */
|
||||
save(): void {
|
||||
this.state.lastUpdatedAt = new Date().toISOString();
|
||||
const prd = this.getPRD();
|
||||
prd.lastUpdatedAt = new Date().toISOString();
|
||||
// Sync legacy flat fields with current PRD for backward compat
|
||||
this.state.sourcePath = prd.sourcePath;
|
||||
this.state.tasks = prd.tasks;
|
||||
this.state.startedAt = prd.startedAt;
|
||||
this.state.lastUpdatedAt = prd.lastUpdatedAt;
|
||||
this.state.paused = prd.paused;
|
||||
fs.writeFileSync(
|
||||
this.statePath,
|
||||
JSON.stringify(this.state, null, 2),
|
||||
@@ -49,9 +158,10 @@ export class ProgressTracker {
|
||||
|
||||
/** Mark a task as in progress */
|
||||
markInProgress(taskId: string): void {
|
||||
this.ensureTask(taskId);
|
||||
this.state.tasks[taskId].status = "in_progress";
|
||||
this.state.tasks[taskId].startedAt = new Date().toISOString();
|
||||
const prd = this.getPRD();
|
||||
this.ensureTask(prd, taskId);
|
||||
prd.tasks[taskId].status = "in_progress";
|
||||
prd.tasks[taskId].startedAt = new Date().toISOString();
|
||||
this.save();
|
||||
}
|
||||
|
||||
@@ -60,89 +170,114 @@ export class ProgressTracker {
|
||||
taskId: string,
|
||||
durationMs: number,
|
||||
reflection?: Reflection,
|
||||
toolUsage?: ToolUsage,
|
||||
outputPreview?: string,
|
||||
commitMessages?: string[],
|
||||
commitSummary?: string,
|
||||
): void {
|
||||
this.ensureTask(taskId);
|
||||
this.state.tasks[taskId].status = "completed";
|
||||
this.state.tasks[taskId].completedAt = new Date().toISOString();
|
||||
this.state.tasks[taskId].durationMs = durationMs;
|
||||
if (reflection) {
|
||||
this.state.tasks[taskId].reflection = reflection;
|
||||
}
|
||||
const prd = this.getPRD();
|
||||
this.ensureTask(prd, taskId);
|
||||
prd.tasks[taskId].status = "completed";
|
||||
prd.tasks[taskId].completedAt = new Date().toISOString();
|
||||
prd.tasks[taskId].durationMs = durationMs;
|
||||
if (reflection) prd.tasks[taskId].reflection = reflection;
|
||||
if (toolUsage) prd.tasks[taskId].toolUsage = toolUsage;
|
||||
if (outputPreview) prd.tasks[taskId].outputPreview = outputPreview;
|
||||
if (commitMessages) prd.tasks[taskId].commitMessages = commitMessages;
|
||||
if (commitSummary) prd.tasks[taskId].commitSummary = commitSummary;
|
||||
this.save();
|
||||
}
|
||||
|
||||
/** Mark a task as failed */
|
||||
markFailed(taskId: string, error: string): void {
|
||||
this.ensureTask(taskId);
|
||||
this.state.tasks[taskId].status = "failed";
|
||||
this.state.tasks[taskId].error = error;
|
||||
const prd = this.getPRD();
|
||||
this.ensureTask(prd, taskId);
|
||||
prd.tasks[taskId].status = "failed";
|
||||
prd.tasks[taskId].error = error;
|
||||
this.save();
|
||||
}
|
||||
|
||||
/** Get task status */
|
||||
getTaskStatus(taskId: string): Task["status"] {
|
||||
return this.state.tasks[taskId]?.status ?? "pending";
|
||||
const prd = this.getPRD();
|
||||
return prd.tasks[taskId]?.status ?? "pending";
|
||||
}
|
||||
|
||||
/** Get IDs of all completed tasks */
|
||||
getCompletedTaskIds(): string[] {
|
||||
return Object.entries(this.state.tasks)
|
||||
const prd = this.getPRD();
|
||||
return Object.entries(prd.tasks)
|
||||
.filter(([, info]) => info.status === "completed")
|
||||
.map(([id]) => id);
|
||||
}
|
||||
|
||||
/** Get IDs of all failed tasks */
|
||||
getFailedTaskIds(): string[] {
|
||||
const prd = this.getPRD();
|
||||
return Object.entries(prd.tasks)
|
||||
.filter(([, info]) => info.status === "failed")
|
||||
.map(([id]) => id);
|
||||
}
|
||||
|
||||
/** Get all reflections from completed tasks */
|
||||
getAllReflections(): Reflection[] {
|
||||
const prd = this.getPRD();
|
||||
const reflections: Reflection[] = [];
|
||||
for (const info of Object.values(this.state.tasks)) {
|
||||
if (info.reflection) {
|
||||
reflections.push(info.reflection);
|
||||
}
|
||||
for (const info of Object.values(prd.tasks)) {
|
||||
if (info.reflection) reflections.push(info.reflection);
|
||||
}
|
||||
return reflections;
|
||||
}
|
||||
|
||||
/** Get reflections for specific dependency tasks */
|
||||
getDependencyReflections(depIds: string[]): Reflection[] {
|
||||
const prd = this.getPRD();
|
||||
return depIds
|
||||
.map((id) => this.state.tasks[id]?.reflection)
|
||||
.map((id) => prd.tasks[id]?.reflection)
|
||||
.filter((r): r is Reflection => r !== undefined);
|
||||
}
|
||||
|
||||
/** Increment retry count */
|
||||
incrementRetry(taskId: string): number {
|
||||
this.ensureTask(taskId);
|
||||
this.state.tasks[taskId].retries++;
|
||||
const prd = this.getPRD();
|
||||
this.ensureTask(prd, taskId);
|
||||
prd.tasks[taskId].retries++;
|
||||
this.save();
|
||||
return this.state.tasks[taskId].retries;
|
||||
return prd.tasks[taskId].retries;
|
||||
}
|
||||
|
||||
/** Set paused state */
|
||||
setPaused(paused: boolean): void {
|
||||
this.state.paused = paused;
|
||||
const prd = this.getPRD();
|
||||
prd.paused = paused;
|
||||
this.save();
|
||||
}
|
||||
|
||||
/** Get the raw state (for status display) */
|
||||
getState(): ProgressState {
|
||||
return this.state;
|
||||
/** Get the raw PRD state (for status display) */
|
||||
getState(): PRDProgress {
|
||||
return this.getPRD();
|
||||
}
|
||||
|
||||
/** Reset all progress */
|
||||
/** Get all PRDs (for multi-PRD status display) */
|
||||
getAllPRDs(): Record<string, PRDProgress> {
|
||||
return this.state.prds ?? {};
|
||||
}
|
||||
|
||||
/** Get the PRD key for this tracker */
|
||||
getKey(): string {
|
||||
return this.prdKey;
|
||||
}
|
||||
|
||||
/** Reset all progress for this PRD */
|
||||
reset(): void {
|
||||
this.state = {
|
||||
sourcePath: this.state.sourcePath,
|
||||
tasks: {},
|
||||
startedAt: new Date().toISOString(),
|
||||
lastUpdatedAt: new Date().toISOString(),
|
||||
paused: false,
|
||||
};
|
||||
const prd = this.getPRD();
|
||||
Object.assign(prd, this.freshPRD(prd.sourcePath));
|
||||
this.save();
|
||||
}
|
||||
|
||||
private ensureTask(taskId: string): void {
|
||||
if (!this.state.tasks[taskId]) {
|
||||
this.state.tasks[taskId] = { status: "pending", retries: 0 };
|
||||
private ensureTask(prd: PRDProgress, taskId: string): void {
|
||||
if (!prd.tasks[taskId]) {
|
||||
prd.tasks[taskId] = { status: "pending", retries: 0 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
283
src/types.ts
283
src/types.ts
@@ -1,136 +1,209 @@
|
||||
// ─── Task Model ───────────────────────────────────────────────────────────────
|
||||
|
||||
export type TaskStatus = "pending" | "in_progress" | "completed" | "failed" | "skipped";
|
||||
export type TaskStatus =
|
||||
| "pending"
|
||||
| "in_progress"
|
||||
| "completed"
|
||||
| "failed"
|
||||
| "skipped";
|
||||
export type TaskStatusChar = " " | "~" | "x" | "!" | "-";
|
||||
|
||||
export interface Task {
|
||||
/** Unique task identifier */
|
||||
id: string;
|
||||
/** Task title */
|
||||
title: string;
|
||||
/** Detailed task description */
|
||||
description?: string;
|
||||
/** Path to detailed spec file (relative to sourceDir) */
|
||||
file?: string;
|
||||
/** Current status */
|
||||
status: TaskStatus;
|
||||
/** Task IDs this task depends on */
|
||||
dependencies: string[];
|
||||
/** Explicit parallel group (optional, overrides dependency-based batching) */
|
||||
parallelGroup?: number;
|
||||
/** Unique task identifier */
|
||||
id: string;
|
||||
/** Task title */
|
||||
title: string;
|
||||
/** Detailed task description */
|
||||
description?: string;
|
||||
/** Path to detailed spec file (relative to sourceDir) */
|
||||
file?: string;
|
||||
/** Current status */
|
||||
status: TaskStatus;
|
||||
/** Task IDs this task depends on */
|
||||
dependencies: string[];
|
||||
/** Explicit parallel group (optional, overrides dependency-based batching) */
|
||||
parallelGroup?: number;
|
||||
/** Task-level timeout in milliseconds (parsed from meta block) */
|
||||
timeoutMs?: number;
|
||||
/** Original index in task list for deterministic ordering */
|
||||
index?: number;
|
||||
/** Phase number this task belongs to (1-indexed, from ## Phase N headings) */
|
||||
phase?: number;
|
||||
}
|
||||
|
||||
export interface ParallelGroup {
|
||||
/** Group index (0-based, determines execution order) */
|
||||
index: number;
|
||||
/** Human-readable label for the group (e.g. "Play Store prep") */
|
||||
label?: string;
|
||||
/** Task IDs in this group — all can run concurrently */
|
||||
taskIds: string[];
|
||||
}
|
||||
|
||||
export interface Phase {
|
||||
/** Phase number (1-indexed, matches the heading number) */
|
||||
number: number;
|
||||
/** Phase title (e.g. "Push-to-Talk MVP") */
|
||||
title: string;
|
||||
/** Task IDs in this phase, in order */
|
||||
taskIds: string[];
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
/** Project-level objective / goal */
|
||||
objective?: string;
|
||||
/** All tasks in the project */
|
||||
tasks: Task[];
|
||||
/** Explicit dependency map: taskId → [dependency taskIds] */
|
||||
dependencies: Record<string, string[]>;
|
||||
/** Exit criteria (from README ## Exit Criteria section) */
|
||||
exitCriteria?: string[];
|
||||
/** Path to the source task file */
|
||||
sourcePath: string;
|
||||
/** Directory containing the source file */
|
||||
sourceDir: string;
|
||||
/** Project-level objective / goal */
|
||||
objective?: string;
|
||||
/** All tasks in the 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 */
|
||||
sourcePath: string;
|
||||
/** Directory containing the source file */
|
||||
sourceDir: string;
|
||||
}
|
||||
|
||||
// ─── Execution Plan ───────────────────────────────────────────────────────────
|
||||
|
||||
export interface ExecutionBatch {
|
||||
/** Tasks that can run concurrently in this batch */
|
||||
tasks: Task[];
|
||||
/** Batch number (0-indexed) */
|
||||
batchIndex: number;
|
||||
/** Tasks that can run concurrently in this batch */
|
||||
tasks: Task[];
|
||||
/** Batch number (0-indexed) */
|
||||
batchIndex: number;
|
||||
}
|
||||
|
||||
export interface ExecutionPlan {
|
||||
/** Ordered batches (each batch contains parallelizable tasks) */
|
||||
batches: ExecutionBatch[];
|
||||
/** Total task count */
|
||||
totalTasks: number;
|
||||
/** Tasks skipped (already completed) */
|
||||
skippedTasks: Task[];
|
||||
/** Ordered batches (each batch contains parallelizable tasks) */
|
||||
batches: ExecutionBatch[];
|
||||
/** Total task count */
|
||||
totalTasks: number;
|
||||
/** Tasks skipped (already completed) */
|
||||
skippedTasks: Task[];
|
||||
}
|
||||
|
||||
// ─── Progress Model ───────────────────────────────────────────────────────────
|
||||
|
||||
export interface Reflection {
|
||||
taskId: string;
|
||||
title: string;
|
||||
/** What was accomplished */
|
||||
summary: string;
|
||||
/** Key decisions, patterns, and learnings for downstream tasks */
|
||||
keyLearnings: string[];
|
||||
/** Files created or modified */
|
||||
filesChanged: string[];
|
||||
/** Unresolved issues or caveats */
|
||||
blockers?: string[];
|
||||
/** ISO timestamp */
|
||||
timestamp: string;
|
||||
taskId: string;
|
||||
title: string;
|
||||
/** What was accomplished */
|
||||
summary: string;
|
||||
/** Key decisions, patterns, and learnings for downstream tasks */
|
||||
keyLearnings: string[];
|
||||
/** Files created or modified */
|
||||
filesChanged: string[];
|
||||
/** Unresolved issues or caveats */
|
||||
blockers?: string[];
|
||||
/** ISO timestamp */
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface ToolUsage {
|
||||
read: number;
|
||||
write: number;
|
||||
edit: number;
|
||||
bash: number;
|
||||
other: number;
|
||||
}
|
||||
|
||||
export interface TaskProgressInfo {
|
||||
status: Task["status"];
|
||||
startedAt?: string;
|
||||
completedAt?: string;
|
||||
retries: number;
|
||||
durationMs?: number;
|
||||
reflection?: Reflection;
|
||||
error?: string;
|
||||
/** Tool usage counts from parsed subprocess output */
|
||||
toolUsage?: ToolUsage;
|
||||
/** Truncated output preview for expanded view */
|
||||
outputPreview?: string;
|
||||
/** Git commit messages from task execution */
|
||||
commitMessages?: string[];
|
||||
/** Summary derived from git commits */
|
||||
commitSummary?: string;
|
||||
}
|
||||
|
||||
export interface ProgressState {
|
||||
/** Path to the source task file */
|
||||
sourcePath: string;
|
||||
/** Per-task status tracking */
|
||||
tasks: Record<string, {
|
||||
status: Task["status"];
|
||||
startedAt?: string;
|
||||
completedAt?: string;
|
||||
retries: number;
|
||||
durationMs?: number;
|
||||
reflection?: Reflection;
|
||||
error?: string;
|
||||
}>;
|
||||
/** When execution started */
|
||||
startedAt: string;
|
||||
/** When execution last updated */
|
||||
lastUpdatedAt: string;
|
||||
/** Whether execution is currently paused/stopped */
|
||||
paused: boolean;
|
||||
/** Path to the source task file (legacy single-PRD mode) */
|
||||
sourcePath: string;
|
||||
/** Per-task status tracking (legacy single-PRD mode) */
|
||||
tasks: Record<string, TaskProgressInfo>;
|
||||
/** When execution started (legacy single-PRD mode) */
|
||||
startedAt: string;
|
||||
/** When execution last updated (legacy single-PRD mode) */
|
||||
lastUpdatedAt: string;
|
||||
/** Whether execution is currently paused/stopped (legacy single-PRD mode) */
|
||||
paused: boolean;
|
||||
/** Multiple PRDs tracked simultaneously (keyed by normalized source path) */
|
||||
prds?: Record<string, PRDProgress>;
|
||||
}
|
||||
|
||||
export interface PRDProgress {
|
||||
/** Path to the source task file for this PRD */
|
||||
sourcePath: string;
|
||||
/** Per-task status tracking */
|
||||
tasks: Record<string, TaskProgressInfo>;
|
||||
/** When execution started */
|
||||
startedAt: string;
|
||||
/** When execution last updated */
|
||||
lastUpdatedAt: string;
|
||||
/** Whether execution is currently paused/stopped */
|
||||
paused: boolean;
|
||||
}
|
||||
|
||||
// ─── Configuration ────────────────────────────────────────────────────────────
|
||||
|
||||
export interface RalphConfig {
|
||||
paths: {
|
||||
/** Directory for ralph state files */
|
||||
stateDir: string;
|
||||
/** Directory for per-task reflections */
|
||||
reflectionsDir: string;
|
||||
};
|
||||
execution: {
|
||||
/** Maximum retries per task */
|
||||
maxRetries: number;
|
||||
/** Delay between retries in milliseconds */
|
||||
retryDelayMs: number;
|
||||
/** Task execution timeout in milliseconds */
|
||||
timeoutMs: number;
|
||||
/** Maximum parallel tasks (0 = unlimited) */
|
||||
maxParallel: number;
|
||||
};
|
||||
prompts: {
|
||||
/** Additional context injected into every task prompt */
|
||||
projectContext: string;
|
||||
/** Custom prompt suffix for reflection extraction */
|
||||
reflectionPrompt: string;
|
||||
};
|
||||
export interface RalpiConfig {
|
||||
paths: {
|
||||
/** Directory for ralpi state files */
|
||||
stateDir: string;
|
||||
/** Directory for per-task reflections */
|
||||
reflectionsDir: string;
|
||||
};
|
||||
execution: {
|
||||
/** Maximum retries per task */
|
||||
maxRetries: number;
|
||||
/** Delay between retries in milliseconds */
|
||||
retryDelayMs: number;
|
||||
/** Task execution timeout in milliseconds */
|
||||
timeoutMs: number;
|
||||
/** Maximum parallel tasks (0 = unlimited) */
|
||||
maxParallel: number;
|
||||
/** Round-robin model list for parallel tasks (empty = inherit parent model) */
|
||||
models: string[];
|
||||
};
|
||||
prompts: {
|
||||
/** Additional context injected into every task prompt */
|
||||
projectContext: string;
|
||||
/** 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: RalphConfig = {
|
||||
paths: {
|
||||
stateDir: ".ralph",
|
||||
reflectionsDir: ".ralph/reflections",
|
||||
},
|
||||
execution: {
|
||||
maxRetries: 3,
|
||||
retryDelayMs: 5000,
|
||||
timeoutMs: 30 * 60 * 1000, // 30 minutes
|
||||
maxParallel: 3,
|
||||
},
|
||||
prompts: {
|
||||
projectContext: "",
|
||||
reflectionPrompt: "",
|
||||
},
|
||||
export const DEFAULT_CONFIG: RalpiConfig = {
|
||||
paths: {
|
||||
stateDir: ".ralpi",
|
||||
reflectionsDir: ".ralpi/reflections",
|
||||
},
|
||||
execution: {
|
||||
maxRetries: 0,
|
||||
retryDelayMs: 0,
|
||||
timeoutMs: 0, // 0 = inherit Pi's own defaults (no ralpi-level timeout)
|
||||
maxParallel: 3,
|
||||
models: [],
|
||||
},
|
||||
prompts: {
|
||||
projectContext: "",
|
||||
reflectionPrompt: "",
|
||||
},
|
||||
};
|
||||
|
||||
658
src/utils.ts
658
src/utils.ts
@@ -1,8 +1,19 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import type { RalphConfig, ProgressState, Task } from "./types";
|
||||
import type {
|
||||
RalpiConfig,
|
||||
PRDProgress,
|
||||
ProgressState,
|
||||
ToolUsage,
|
||||
} from "./types";
|
||||
import { DEFAULT_CONFIG } from "./types";
|
||||
import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
|
||||
import {
|
||||
createAgentSession,
|
||||
DefaultResourceLoader,
|
||||
getAgentDir,
|
||||
SessionManager,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
|
||||
// ─── Directory Helpers ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -23,77 +34,163 @@ export function writeFileSafe(filePath: string, content: string): void {
|
||||
fs.writeFileSync(filePath, content, "utf-8");
|
||||
}
|
||||
|
||||
// ─── Command Helpers ─────────────────────────────────────────────────────────
|
||||
// ─── Loop-Active State ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Check if a command exists in PATH
|
||||
* State persisted to disk when a ralpi execution loop is active.
|
||||
* Used to re-instantiate widgets after a session reload.
|
||||
*/
|
||||
export function commandExists(command: string): boolean {
|
||||
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 { execSync } = require("node:child_process");
|
||||
execSync(`which ${command}`, { stdio: "ignore" });
|
||||
return true;
|
||||
const raw = fs.readFileSync(filePath, "utf-8");
|
||||
return JSON.parse(raw) as LoopActiveState;
|
||||
} catch {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path to the pi executable
|
||||
* Delete the loop-active marker.
|
||||
*/
|
||||
export function getPiPath(): string {
|
||||
// Check if PI_PATH environment variable is set
|
||||
const envPath = process.env.PI_PATH;
|
||||
if (envPath && fs.existsSync(envPath)) {
|
||||
return envPath;
|
||||
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 ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Find the nearest .ralpi/progress.json by walking up from the given directory.
|
||||
* For a specific sourcePath, finds the matching PRD entry.
|
||||
*/
|
||||
export function findProgressFile(
|
||||
startDir: string,
|
||||
sourcePath?: string,
|
||||
): { path: string; state: ProgressState; prdKey?: string } | null {
|
||||
let current = path.resolve(startDir);
|
||||
const root = path.parse(current).root;
|
||||
|
||||
while (current !== root) {
|
||||
const candidate = path.join(current, ".ralpi", "progress.json");
|
||||
if (fs.existsSync(candidate)) {
|
||||
try {
|
||||
const raw = fs.readFileSync(candidate, "utf-8");
|
||||
const state = JSON.parse(raw) as ProgressState;
|
||||
|
||||
// If looking for a specific source path, find matching PRD
|
||||
if (sourcePath && state.prds) {
|
||||
const resolvedSource = path.resolve(sourcePath);
|
||||
for (const [key, prd] of Object.entries(state.prds)) {
|
||||
if (path.resolve(prd.sourcePath) === resolvedSource) {
|
||||
return { path: candidate, state, prdKey: key };
|
||||
}
|
||||
}
|
||||
// No matching PRD found, continue walking up
|
||||
current = path.dirname(current);
|
||||
continue;
|
||||
}
|
||||
|
||||
return { path: candidate, state };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
current = path.dirname(current);
|
||||
}
|
||||
|
||||
// Try to find pi in PATH
|
||||
if (commandExists("pi")) {
|
||||
return "pi";
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
"pi executable not found. Set PI_PATH or ensure pi is in PATH.",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Config ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function parseSimpleYaml(content: string): Record<string, any> {
|
||||
const result: Record<string, any> = {};
|
||||
const lines = content.split("\n");
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
|
||||
const match = trimmed.match(/^([^:]+):\s*(.+)$/);
|
||||
if (match) {
|
||||
const key = match[1].trim();
|
||||
let value = 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;
|
||||
}
|
||||
/** 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> = {};
|
||||
for (const line of content.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
const match = trimmed.match(/^([^:]+):\s*(.*)$/);
|
||||
if (match) {
|
||||
const value = match[2].trim();
|
||||
if (value === "true") result[match[1].trim()] = true;
|
||||
else if (value === "false") result[match[1].trim()] = false;
|
||||
else if (/^\d+$/.test(value))
|
||||
result[match[1].trim()] = parseInt(value, 10);
|
||||
else if (/^\d+\.\d+$/.test(value))
|
||||
result[match[1].trim()] = parseFloat(value);
|
||||
else result[match[1].trim()] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
})();
|
||||
|
||||
/**
|
||||
* Deep merge configuration objects
|
||||
*/
|
||||
function mergeConfig(
|
||||
defaults: RalphConfig,
|
||||
defaults: RalpiConfig,
|
||||
overrides: Record<string, any>,
|
||||
): RalphConfig {
|
||||
): RalpiConfig {
|
||||
const result = { ...defaults };
|
||||
|
||||
for (const [key, value] of Object.entries(overrides)) {
|
||||
@@ -104,40 +201,65 @@ function mergeConfig(
|
||||
}
|
||||
}
|
||||
|
||||
return result as RalphConfig;
|
||||
return result as RalpiConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load configuration from .ralph/config.yaml or return defaults
|
||||
*/
|
||||
export function loadConfig(projectDir: string): RalphConfig {
|
||||
const configPath = path.join(projectDir, ".ralph", "config.yaml");
|
||||
/** 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",
|
||||
);
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(configPath, "utf-8");
|
||||
// Simple YAML parsing (key: value format)
|
||||
const config = parseSimpleYaml(content);
|
||||
return mergeConfig(DEFAULT_CONFIG, config);
|
||||
} catch (error) {
|
||||
console.warn("Failed to load .ralph/config.yaml, using defaults:", error);
|
||||
return { ...DEFAULT_CONFIG };
|
||||
/**
|
||||
* 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 {
|
||||
// Start with defaults
|
||||
const merged: RalpiConfig = { ...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(filePath, "utf-8");
|
||||
const parsed = parseSimpleYaml(content);
|
||||
Object.assign(acc, mergeConfig(acc, parsed));
|
||||
} catch {
|
||||
// Malformed config — skip silently
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Task Resolution ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve a task argument to a file path
|
||||
* Resolve a task argument to a file path.
|
||||
* Strips leading `@` (from autocomplete) before resolution.
|
||||
*/
|
||||
export function resolveTaskArg(
|
||||
arg: string,
|
||||
cwd: string,
|
||||
): string {
|
||||
export function resolveTaskArg(arg: string, cwd: string): string {
|
||||
// Strip leading @ from autocomplete
|
||||
const cleanArg = arg.startsWith("@") ? arg.slice(1) : arg;
|
||||
|
||||
const candidates = [
|
||||
path.resolve(cwd, arg),
|
||||
path.resolve(cwd, arg + ".md"),
|
||||
path.resolve(cwd, arg + ".yaml"),
|
||||
path.resolve(cwd, arg + ".yml"),
|
||||
path.resolve(cwd, cleanArg),
|
||||
path.resolve(cwd, cleanArg + ".md"),
|
||||
path.resolve(cwd, cleanArg + ".yaml"),
|
||||
path.resolve(cwd, cleanArg + ".yml"),
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
@@ -145,13 +267,17 @@ export function resolveTaskArg(
|
||||
}
|
||||
|
||||
// Try looking for README.md in the arg directory
|
||||
if (fs.statSync(path.resolve(cwd, arg)).isDirectory()) {
|
||||
const readme = path.resolve(cwd, arg, "README.md");
|
||||
if (fs.existsSync(readme)) return readme;
|
||||
try {
|
||||
if (fs.statSync(path.resolve(cwd, cleanArg)).isDirectory()) {
|
||||
const readme = path.resolve(cwd, cleanArg, "README.md");
|
||||
if (fs.existsSync(readme)) return readme;
|
||||
}
|
||||
} catch {
|
||||
// Directory doesn't exist, fall through to error
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Task file not found: ${arg}\nSearched: ${candidates.join("\n ")}`,
|
||||
`Task file not found: ${cleanArg}\nSearched: ${candidates.join("\n ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -175,33 +301,38 @@ export function formatDuration(ms: number): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Format progress status for display
|
||||
* Format progress status for display. Accepts a single PRDProgress entry.
|
||||
*/
|
||||
export function formatProgressStatus(state: ProgressState): string {
|
||||
export function formatProgressStatus(state: PRDProgress): string {
|
||||
const lines: string[] = [];
|
||||
const tasks = state.tasks;
|
||||
const total = Object.keys(tasks).length;
|
||||
const completed = Object.values(tasks).filter(
|
||||
t => t.status === "completed",
|
||||
(t) => t.status === "completed",
|
||||
).length;
|
||||
const failed = Object.values(tasks).filter(
|
||||
t => t.status === "failed",
|
||||
(t) => t.status === "failed",
|
||||
).length;
|
||||
const inProgress = Object.values(tasks).filter(
|
||||
t => t.status === "in_progress",
|
||||
(t) => t.status === "in_progress",
|
||||
).length;
|
||||
|
||||
lines.push("## Progress");
|
||||
lines.push("");
|
||||
lines.push(`Total: ${total} | Completed: ${completed} | Failed: ${failed} | In Progress: ${inProgress}`);
|
||||
lines.push(
|
||||
`Total: ${total} | Completed: ${completed} | Failed: ${failed} | In Progress: ${inProgress}`,
|
||||
);
|
||||
lines.push("");
|
||||
|
||||
for (const [id, info] of Object.entries(tasks)) {
|
||||
const statusIcon =
|
||||
info.status === "completed" ? "[x]" :
|
||||
info.status === "in_progress" ? "[~]" :
|
||||
info.status === "failed" ? "[!]" :
|
||||
"[ ]";
|
||||
info.status === "completed"
|
||||
? "[x]"
|
||||
: info.status === "in_progress"
|
||||
? "[~]"
|
||||
: info.status === "failed"
|
||||
? "[!]"
|
||||
: "[ ]";
|
||||
|
||||
const duration = info.durationMs
|
||||
? ` (${formatDuration(info.durationMs)})`
|
||||
@@ -222,58 +353,325 @@ export function formatProgressStatus(state: ProgressState): string {
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// ─── Pi Subprocess ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Spawn a pi subprocess with the given prompt file
|
||||
* Format progress status for all PRDs in a ProgressState.
|
||||
*/
|
||||
export function spawnPi(
|
||||
promptFile: string,
|
||||
piPath: string,
|
||||
args?: string[],
|
||||
): { stdout: string; stderr: string; code: number | null } {
|
||||
const spawnArgs = ["--prompt", promptFile, ...(args || [])];
|
||||
export function formatAllPRDsStatus(state: ProgressState): string {
|
||||
const prds = state.prds;
|
||||
if (!prds || Object.keys(prds).length <= 1) {
|
||||
// Single PRD — use simple format
|
||||
const prd = prds
|
||||
? Object.values(prds)[0]
|
||||
: (state as unknown as PRDProgress);
|
||||
return formatProgressStatus(prd);
|
||||
}
|
||||
|
||||
const result = spawnSync(piPath, spawnArgs, {
|
||||
encoding: "utf-8",
|
||||
timeout: 60 * 60 * 1000, // 1 hour
|
||||
maxBuffer: 10 * 1024 * 1024, // 10MB
|
||||
});
|
||||
const lines: string[] = [];
|
||||
lines.push("## Progress (all PRDs)");
|
||||
lines.push("");
|
||||
|
||||
return {
|
||||
stdout: result.stdout || "",
|
||||
stderr: result.stderr || "",
|
||||
code: result.status,
|
||||
};
|
||||
}
|
||||
for (const [key, prd] of Object.entries(prds)) {
|
||||
const tasks = prd.tasks;
|
||||
const total = Object.keys(tasks).length;
|
||||
const completed = Object.values(tasks).filter(
|
||||
(t) => t.status === "completed",
|
||||
).length;
|
||||
const failed = Object.values(tasks).filter(
|
||||
(t) => t.status === "failed",
|
||||
).length;
|
||||
const inProgress = Object.values(tasks).filter(
|
||||
(t) => t.status === "in_progress",
|
||||
).length;
|
||||
|
||||
/**
|
||||
* Extract text content from pi event stream output
|
||||
*/
|
||||
export function extractTextFromEvent(output: string): string {
|
||||
// If output is JSON event stream, extract text fields
|
||||
if (output.startsWith("{") || output.startsWith("data:")) {
|
||||
const lines = output.split("\n");
|
||||
const texts: string[] = [];
|
||||
lines.push(`### ${key}`);
|
||||
lines.push(`Source: ${path.relative(process.cwd(), prd.sourcePath)}`);
|
||||
lines.push(
|
||||
`Total: ${total} | Completed: ${completed} | Failed: ${failed} | In Progress: ${inProgress}`,
|
||||
);
|
||||
lines.push("");
|
||||
|
||||
for (const line of lines) {
|
||||
// Try to parse NDJSON events
|
||||
if (line.startsWith("data: ")) {
|
||||
try {
|
||||
const event = JSON.parse(line.slice(6));
|
||||
if (event.type === "text" && event.text) {
|
||||
texts.push(event.text);
|
||||
}
|
||||
} catch {
|
||||
texts.push(line.slice(6));
|
||||
}
|
||||
} else if (line.trim()) {
|
||||
texts.push(line);
|
||||
for (const [id, info] of Object.entries(tasks)) {
|
||||
const statusIcon =
|
||||
info.status === "completed"
|
||||
? "[x]"
|
||||
: info.status === "in_progress"
|
||||
? "[~]"
|
||||
: info.status === "failed"
|
||||
? "[!]"
|
||||
: "[ ]";
|
||||
|
||||
const duration = info.durationMs
|
||||
? ` (${formatDuration(info.durationMs)})`
|
||||
: "";
|
||||
|
||||
lines.push(`- ${statusIcon} ${id}${duration}`);
|
||||
|
||||
if (info.error) {
|
||||
lines.push(` Error: ${info.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
return texts.join("\n");
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
return output;
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// ─── Async Agent Session ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Run a task prompt through an in-process Pi agent session (async, non-blocking).
|
||||
*
|
||||
* Unlike the old spawnPi() which used spawnSync and froze the TUI,
|
||||
* this uses createAgentSession from the Pi SDK, keeping the event loop
|
||||
* responsive and allowing progress updates during task execution.
|
||||
*/
|
||||
export async function runAgentSession(
|
||||
taskPrompt: string,
|
||||
cwd: string,
|
||||
timeoutMs: number,
|
||||
onEvent?: (event: AgentSessionEvent) => void,
|
||||
signal?: AbortSignal,
|
||||
model?: unknown,
|
||||
thinkingLevel?: unknown,
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
text: string;
|
||||
error?: string;
|
||||
toolUsage: ToolUsage;
|
||||
stopReason?: string;
|
||||
events: AgentSessionEvent[];
|
||||
}> {
|
||||
const toolUsage: ToolUsage = {
|
||||
read: 0,
|
||||
write: 0,
|
||||
edit: 0,
|
||||
bash: 0,
|
||||
other: 0,
|
||||
};
|
||||
// Wire timeout via abort signal (only when set; 0 means inherit Pi's defaults)
|
||||
let timeoutHandle: NodeJS.Timeout | null = null;
|
||||
if (timeoutMs > 0) {
|
||||
timeoutHandle = setTimeout(() => {
|
||||
if (sessionRef?.session) sessionRef.session.agent.abort();
|
||||
}, timeoutMs);
|
||||
}
|
||||
|
||||
const sessionRef: {
|
||||
session?: Awaited<ReturnType<typeof createAgentSession>>["session"];
|
||||
} = {};
|
||||
|
||||
try {
|
||||
const loader = new DefaultResourceLoader({
|
||||
cwd,
|
||||
agentDir: getAgentDir(),
|
||||
noExtensions: true,
|
||||
noSkills: false,
|
||||
noPromptTemplates: true,
|
||||
noThemes: true,
|
||||
noContextFiles: true,
|
||||
});
|
||||
await loader.reload();
|
||||
|
||||
const result = await createAgentSession({
|
||||
cwd,
|
||||
sessionManager: SessionManager.inMemory(),
|
||||
resourceLoader: loader,
|
||||
tools: ["read", "bash", "edit", "write", "grep", "find", "ls"],
|
||||
model: model as any,
|
||||
thinkingLevel: thinkingLevel as any,
|
||||
});
|
||||
sessionRef.session = result.session;
|
||||
|
||||
// Wire external abort signal
|
||||
const abortHandler = () => result.session.agent.abort();
|
||||
signal?.addEventListener("abort", abortHandler, { once: true });
|
||||
|
||||
let finalText = "";
|
||||
let errorMessage: string | undefined;
|
||||
let stopReason: string | undefined;
|
||||
|
||||
const unsubscribe = result.session.subscribe((event) => {
|
||||
onEvent?.(event);
|
||||
|
||||
if (event.type === "message_end") {
|
||||
const message = event.message as {
|
||||
role?: string;
|
||||
content?: unknown;
|
||||
stopReason?: string;
|
||||
errorMessage?: string;
|
||||
};
|
||||
if (message.role !== "assistant") return;
|
||||
if (message.stopReason) stopReason = message.stopReason;
|
||||
if (message.errorMessage) errorMessage = message.errorMessage;
|
||||
const text = extractAssistantText(message.content);
|
||||
if (text) finalText = text;
|
||||
}
|
||||
|
||||
if (event.type === "tool_execution_start") {
|
||||
const name = event.toolName;
|
||||
if (name in toolUsage) {
|
||||
(toolUsage as unknown as Record<string, number>)[name]++;
|
||||
} else {
|
||||
toolUsage.other++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (signal?.aborted) throw new Error("Aborted before prompt");
|
||||
|
||||
await result.session.prompt(taskPrompt);
|
||||
await result.session.agent.waitForIdle();
|
||||
|
||||
unsubscribe();
|
||||
result.session.dispose();
|
||||
signal?.removeEventListener("abort", abortHandler);
|
||||
if (timeoutHandle) clearTimeout(timeoutHandle);
|
||||
|
||||
if (errorMessage && !finalText) {
|
||||
return {
|
||||
success: false,
|
||||
text: "",
|
||||
error: errorMessage,
|
||||
toolUsage,
|
||||
stopReason,
|
||||
events: [], // streamed to file
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
text: finalText.trim(),
|
||||
toolUsage,
|
||||
stopReason,
|
||||
events: [],
|
||||
};
|
||||
} catch (error) {
|
||||
if (timeoutHandle) clearTimeout(timeoutHandle);
|
||||
return {
|
||||
success: false,
|
||||
text: "",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
toolUsage,
|
||||
events: [],
|
||||
};
|
||||
} finally {
|
||||
sessionRef.session?.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract assistant text from message content (text blocks only).
|
||||
*/
|
||||
function extractAssistantText(content: unknown): string {
|
||||
if (typeof content === "string") return content;
|
||||
if (!Array.isArray(content)) return "";
|
||||
return content
|
||||
.filter(
|
||||
(c): c is { type: string; text?: string } =>
|
||||
!!c &&
|
||||
typeof c === "object" &&
|
||||
(c as { type?: string }).type === "text",
|
||||
)
|
||||
.map((c) => (c as { text?: string }).text ?? "")
|
||||
.join("");
|
||||
}
|
||||
|
||||
// ─── Git Commit Capture ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Check if there are any uncommitted changes in the git repository.
|
||||
*/
|
||||
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
|
||||
*/
|
||||
export function captureGitCommits(projectDir: string): {
|
||||
commitMessages: string[];
|
||||
commitSummary: string;
|
||||
} {
|
||||
const { execSync } = require("node:child_process");
|
||||
|
||||
try {
|
||||
// Check if this is a git repo
|
||||
execSync("git rev-parse --git-dir", { cwd: projectDir, stdio: "pipe" });
|
||||
} catch {
|
||||
return { commitMessages: [], commitSummary: "" };
|
||||
}
|
||||
|
||||
const commitMessages: string[] = [];
|
||||
let commitSummary = "";
|
||||
|
||||
try {
|
||||
// Get recent commits (last 5) with short hash and subject
|
||||
const output = execSync("git log --oneline -5 --no-decorate", {
|
||||
cwd: projectDir,
|
||||
encoding: "utf-8",
|
||||
}).trim();
|
||||
|
||||
if (output) {
|
||||
const lines = output.split("\n").filter((l: string) => l.trim());
|
||||
for (const line of lines) {
|
||||
// Format: "abc1234 Commit message"
|
||||
const parts = line.split(" ", 2);
|
||||
if (parts.length >= 2) {
|
||||
commitMessages.push(parts[1]);
|
||||
}
|
||||
}
|
||||
|
||||
// Build summary from commit subjects
|
||||
commitSummary = commitMessages.slice(0, 3).join("; ");
|
||||
if (commitMessages.length > 3) {
|
||||
commitSummary += ` (+${commitMessages.length - 3} more)`;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Git command failed, return empty
|
||||
}
|
||||
|
||||
return { commitMessages, commitSummary };
|
||||
}
|
||||
|
||||
92
src/widget-batcher.ts
Normal file
92
src/widget-batcher.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
/**
|
||||
* Batches widget updates from multiple parallel tasks into a single
|
||||
* render cycle, preventing TUI thrashing when agents update independently.
|
||||
*
|
||||
* Uses microtask debouncing: updates within the same event-loop tick
|
||||
* are coalesced into one flush. No artificial interval — updates hit the
|
||||
* screen as soon as the current tick yields, but never duplicatively.
|
||||
*/
|
||||
export class WidgetBatcher {
|
||||
/** Pending widget updates keyed by widget key. */
|
||||
private pending: Map<string, string[]> = new Map();
|
||||
|
||||
/** Widget keys scheduled for removal. */
|
||||
private pendingRemovals: Set<string> = new Set();
|
||||
|
||||
/** Whether a microtask flush is already queued. */
|
||||
private scheduled = false;
|
||||
|
||||
/** Whether a flush is currently executing (prevents re-entry). */
|
||||
private flushing = false;
|
||||
|
||||
constructor(private ctx: ExtensionContext) {}
|
||||
|
||||
/**
|
||||
* Schedule a widget update. Flushed asynchronously at end of the
|
||||
* current event-loop tick; multiple calls in the same tick coalesce.
|
||||
*/
|
||||
schedule(key: string, lines: string[]): void {
|
||||
this.pending.set(key, lines);
|
||||
this.scheduleFlush();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a widget (e.g., when a task completes).
|
||||
* Flushed asynchronously at end of the current tick.
|
||||
*/
|
||||
scheduleRemove(key: string): void {
|
||||
this.pending.delete(key);
|
||||
this.pendingRemovals.add(key);
|
||||
this.scheduleFlush();
|
||||
}
|
||||
|
||||
/** Synchronously flush all pending updates. */
|
||||
flush(): void {
|
||||
this.doFlush();
|
||||
}
|
||||
|
||||
/** Flush remaining updates then stop scheduling. */
|
||||
stop(): void {
|
||||
this.doFlush();
|
||||
}
|
||||
|
||||
// ── Internal ────────────────────────────────────────────────────────
|
||||
|
||||
private scheduleFlush(): void {
|
||||
if (this.scheduled) return;
|
||||
this.scheduled = true;
|
||||
queueMicrotask(() => {
|
||||
this.scheduled = false;
|
||||
this.doFlush();
|
||||
});
|
||||
}
|
||||
|
||||
private doFlush(): void {
|
||||
if (this.flushing) return;
|
||||
this.flushing = true;
|
||||
|
||||
// Atomically swap — new schedule()/scheduleRemove() calls land on fresh
|
||||
// collections, so the batch we iterate stays immutable and nothing is lost.
|
||||
const toRender = this.pending;
|
||||
const toRemove = this.pendingRemovals;
|
||||
this.pending = new Map();
|
||||
this.pendingRemovals = new Set();
|
||||
|
||||
// Apply removals first
|
||||
for (const key of toRemove) {
|
||||
this.ctx.ui.setWidget(key, undefined);
|
||||
}
|
||||
|
||||
// Sort by key for deterministic, stable ordering across every flush.
|
||||
// Task IDs are zero-padded ("008", "012", "013") so alpha sort = numeric order.
|
||||
const sortedKeys = Array.from(toRender.keys()).sort();
|
||||
for (const key of sortedKeys) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
this.ctx.ui.setWidget(key, toRender.get(key)!);
|
||||
}
|
||||
|
||||
this.flushing = false;
|
||||
}
|
||||
}
|
||||
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,19 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "commonjs",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"include": ["index.ts", "src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user