Compare commits
31 Commits
ead5d9be3a
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c398eef64 | |||
| 496d1554be | |||
| fe28718911 | |||
| 3ba5fcb098 | |||
| db8859606f | |||
| 85123b7755 | |||
| dc3993048e | |||
| dfa6707a8f | |||
| 3892e2a637 | |||
| 8151d19127 | |||
| 8db135a523 | |||
| bac35b8619 | |||
| 5342a2c69f | |||
| 53bac1976a | |||
| 9ce89325fd | |||
| 139bf3b3fb | |||
| 4d46c001bb | |||
| 424e2fa885 | |||
| 30f177b4d9 | |||
| d2a7dfa5fe | |||
| 99b944d10e | |||
| e10ab719d0 | |||
| 7c7668cc6b | |||
| f4af013252 | |||
| dae33248e3 | |||
| 9f90ed4252 | |||
| 3c01652b90 | |||
| ab1e2eb430 | |||
| d2ef124369 | |||
| 925e37938b | |||
| 8e2e24d0e3 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,4 +1,3 @@
|
|||||||
node_modules
|
node_modules
|
||||||
dist
|
|
||||||
.pi-lens
|
.pi-lens
|
||||||
package-lock.json
|
package-lock.json
|
||||||
|
|||||||
18
AGENTS.md
18
AGENTS.md
@@ -4,18 +4,17 @@
|
|||||||
|
|
||||||
A Pi coding agent extension that registers the `/ralpi` slash command. Not a standalone app — it runs inside Pi's extension host.
|
A Pi coding agent extension that registers the `/ralpi` slash command. Not a standalone app — it runs inside Pi's extension host.
|
||||||
|
|
||||||
## Build
|
## Type checking
|
||||||
|
|
||||||
```
|
```
|
||||||
npm run build # tsc → dist/
|
npm run typecheck # tsc --noEmit
|
||||||
npm run watch # tsc --watch
|
|
||||||
```
|
```
|
||||||
|
|
||||||
No bundler, no linter, no test framework. Plain `tsc` with strict mode.
|
No build step needed — Pi loads extensions via [jiti](https://github.com/unjs/jiti), which compiles TypeScript at runtime. `index.ts` is the entry point directly.
|
||||||
|
|
||||||
## Entry point
|
## Entry point
|
||||||
|
|
||||||
`index.ts` at repo root (not `src/`). Exports a default function receiving `ExtensionAPI`. The `tsconfig.json` sets `rootDir: "./"` so `index.ts` compiles to `dist/index.js`.
|
`index.ts` at repo root (not `src/`). Exports a default function receiving `ExtensionAPI`.
|
||||||
|
|
||||||
## External dependencies
|
## External dependencies
|
||||||
|
|
||||||
@@ -27,7 +26,7 @@ The only real npm dependency is `yaml` (^2.4.0).
|
|||||||
|
|
||||||
## Source structure
|
## Source structure
|
||||||
|
|
||||||
- `index.ts` — extension entry, command routing, UI registration
|
- `index.ts` — extension entry, command routing, UI registration, reload detection
|
||||||
- `src/` — all logic modules:
|
- `src/` — all logic modules:
|
||||||
- `parser.ts` — task file parsing (Fio, checkbox, YAML formats)
|
- `parser.ts` — task file parsing (Fio, checkbox, YAML formats)
|
||||||
- `dag.ts` — Kahn's algorithm dependency resolution, batch planning
|
- `dag.ts` — Kahn's algorithm dependency resolution, batch planning
|
||||||
@@ -38,12 +37,13 @@ The only real npm dependency is `yaml` (^2.4.0).
|
|||||||
- `utils.ts` — config loading, progress discovery, `runAgentSession()`
|
- `utils.ts` — config loading, progress discovery, `runAgentSession()`
|
||||||
- `types.ts` — all interfaces and `DEFAULT_CONFIG`
|
- `types.ts` — all interfaces and `DEFAULT_CONFIG`
|
||||||
- `widget-batcher.ts` — debounced widget updates for parallel tasks
|
- `widget-batcher.ts` — debounced widget updates for parallel tasks
|
||||||
|
- `constants.ts` — static constants
|
||||||
- `skills/ralpi-use.md` — Pi skill definition for task execution
|
- `skills/ralpi-use.md` — Pi skill definition for task execution
|
||||||
- `tasks/` — example ralpi task files (self-modification history)
|
- `prompts/task-manager.md` — Pi prompt for task planning
|
||||||
|
|
||||||
## Runtime state
|
## Runtime state
|
||||||
|
|
||||||
All runtime state lives in `.ralpi/` (gitignored):
|
All runtime state lives in `.ralpi/` in the **project directory** (not this extension directory):
|
||||||
- `.ralpi/progress.json` — execution progress, supports multiple PRDs
|
- `.ralpi/progress.json` — execution progress, supports multiple PRDs
|
||||||
- `.ralpi/reflections/` — per-task reflection JSON files
|
- `.ralpi/reflections/` — per-task reflection JSON files
|
||||||
- `.ralpi/prompts/` — generated prompts (timestamped, for debugging)
|
- `.ralpi/prompts/` — generated prompts (timestamped, for debugging)
|
||||||
@@ -55,7 +55,7 @@ Task IDs are zero-padded strings (`"01"`, `"02"`, etc.). The parser prepends `0`
|
|||||||
|
|
||||||
## Command routing
|
## Command routing
|
||||||
|
|
||||||
`/ralpi` with no args → plan. First token looks like a path (`@path`, `./path`, `.md`, etc.) → run. Otherwise dispatches to subcommand (`run`, `plan`, `status`, `resume`, `next`, `reset`).
|
`/ralpi` with no args → plan. First token looks like a path (`@path`, `./path`, `.md`, etc.) → run. Otherwise dispatches to subcommand (`run`, `plan`, `resume`, `reset`).
|
||||||
|
|
||||||
## Config
|
## Config
|
||||||
|
|
||||||
|
|||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Michael Freno
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
137
README.md
137
README.md
@@ -1,78 +1,36 @@
|
|||||||
# Ralpi
|
# 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
|
## Features
|
||||||
|
|
||||||
- **DAG-based execution**: Tasks are ordered by dependencies using Kahn's algorithm
|
|
||||||
- **Parallel batching**: Independent tasks in each batch can run concurrently
|
- **Parallel batching**: Independent tasks in each batch can run concurrently
|
||||||
- **Persistent progress**: Execution state saved to `.ralpi/progress.json`
|
- **Persistent progress**: Execution state saved to `.ralpi/progress.json`
|
||||||
- **Reflection system**: Each task produces a reflection for downstream tasks
|
- **Reflection system**: Each task produces a reflection for downstream tasks
|
||||||
- **Retry with backoff**: Failed tasks retry with exponential backoff
|
- **Retry with backoff**: Failed tasks retry with exponential backoff
|
||||||
- **Multiple formats**: Supports Fio README, simple checkboxes, and YAML
|
- **Multiple formats**: Supports simple checkboxes, and YAML
|
||||||
- **Chat progress**: Real-time progress messages in Pi chat via `pi.sendMessage`
|
|
||||||
- **Tool usage tracking**: Detects and reports tool usage (read, write, edit, bash) from task execution
|
- **Tool usage tracking**: Detects and reports tool usage (read, write, edit, bash) from task execution
|
||||||
- **Git commit capture**: Captures git commit messages and generates summaries per task
|
|
||||||
- **Configurable timeouts**: Task-level timeouts via meta blocks, with global fallback
|
- **Configurable timeouts**: Task-level timeouts via meta blocks, with global fallback
|
||||||
- **Session saving**: Saves full task output for expandable session review
|
- **Session saving**: Saves full task output for expandable session review
|
||||||
- **Resume auto-discovery**: Automatically finds and resumes interrupted execution
|
- **Resume auto-discovery**: Automatically finds and resumes interrupted execution
|
||||||
- **Custom message renderer**: Compact UI labels with expandable details in Pi TUI
|
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```
|
```
|
||||||
/ralpi plan [task-file] # Show execution plan
|
/ralpi [task-file] # Execute all tasks
|
||||||
/ralpi run [task-file] # Execute all tasks
|
/ralpi plan # Alias to /task-manager to plan new tasks
|
||||||
/ralpi status [task-file] # Show current progress
|
/ralpi resume # Resume paused execution
|
||||||
/ralpi resume [task-file] # Resume paused execution
|
/ralpi reset # Reset progress and .ralpi directory - does not modify PRD
|
||||||
/ralpi next [task-file] # Execute next batch only
|
|
||||||
/ralpi reset [task-file] # Reset all progress
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Task File Formats
|
### Highly recommended to use the task-manager prompt for prd construction, it's output pairs perfectly - /task-manager or /ralpi plan
|
||||||
|
|
||||||
### Fio README Format
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
# Project Title
|
|
||||||
|
|
||||||
## Tasks
|
## Tasks
|
||||||
|
|
||||||
- [ ] 01 — Setup project structure -> `tasks/01-setup.md`
|
|
||||||
- [ ] 02 — Implement auth -> `tasks/02-auth.md`
|
|
||||||
- [ ] 03 — Build API -> `tasks/03-api.md`
|
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
|
|
||||||
1 -> 2,3
|
|
||||||
2 -> 3
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Supported Dependency Formats
|
|
||||||
|
|
||||||
The parser supports two dependency declaration styles in the `## Dependencies` section:
|
|
||||||
|
|
||||||
**Arrow Notation** (recommended):
|
|
||||||
```
|
|
||||||
1 -> 2,3,4
|
|
||||||
5 -> 6
|
|
||||||
```
|
|
||||||
This means: "Task 1 must complete before tasks 2, 3, and 4 can start."
|
|
||||||
|
|
||||||
**Natural Language**:
|
|
||||||
```
|
|
||||||
13 depends on 17, 18, 19, 20
|
|
||||||
14 depends on 13, 15, 16
|
|
||||||
```
|
|
||||||
This means: "Task 13 depends on tasks 17, 18, 19, and 20."
|
|
||||||
|
|
||||||
**Parallel Groups** (informational only):
|
|
||||||
```
|
|
||||||
1, 2, 3, 4 can be done in parallel
|
|
||||||
5, 6, 7, 8 can be done in parallel
|
|
||||||
```
|
|
||||||
Note: These lines are ignored by the parser. Use explicit dependencies to control execution order.
|
|
||||||
|
|
||||||
### Simple Checkbox Format
|
### Simple Checkbox Format
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
@@ -96,17 +54,47 @@ tasks:
|
|||||||
depends_on: ["01"]
|
depends_on: ["01"]
|
||||||
```
|
```
|
||||||
|
|
||||||
## Configuration
|
## Task IDs
|
||||||
|
|
||||||
Create `.ralpi/config.yaml`:
|
Task IDs are zero-padded 2-digit strings (`01`, `02`, ...) with an optional
|
||||||
|
single lowercase letter suffix for sub-tasks inserted between two numbered
|
||||||
|
steps (e.g. `02b`, `02c`). The parser normalizes `2b` → `02b`.
|
||||||
|
|
||||||
```yaml
|
|
||||||
maxRetries: 3
|
|
||||||
retryDelayMs: 5000
|
|
||||||
timeoutMs: 1800000
|
|
||||||
maxParallel: 3
|
|
||||||
projectContext: "Additional context for all tasks"
|
|
||||||
```
|
```
|
||||||
|
- [ ] 01 — Setup
|
||||||
|
- [ ] 02 — Fix bugs
|
||||||
|
- [ ] 02b — Sub-step of 02 (inserted after the fact)
|
||||||
|
- [ ] 02c — Another sub-step of 02
|
||||||
|
- [ ] 03 — Continue
|
||||||
|
```
|
||||||
|
|
||||||
|
Use lettered sub-tasks when you discover mid-stream that a step needs to be
|
||||||
|
split. They let you preserve sibling numbering (`01`, `02`, `03`, ...) while
|
||||||
|
adding granularity between two existing steps.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
### Arrow Notation (recommended)
|
||||||
|
|
||||||
|
1 -> 2,3,4
|
||||||
|
5 -> 6
|
||||||
|
This means: "Task 1 must complete before tasks 2, 3, and 4 can start."
|
||||||
|
|
||||||
|
### Natural Language
|
||||||
|
|
||||||
|
13 depends on 17, 18, 19, 20
|
||||||
|
14 depends on 13, 15, 16
|
||||||
|
|
||||||
|
This means: "Task 13 depends on tasks 17, 18, 19, and 20."
|
||||||
|
|
||||||
|
### Parallel Groups (informational only)
|
||||||
|
|
||||||
|
1, 2, 3, 4 can be done in parallel
|
||||||
|
5, 6, 7, 8 can be done in parallel
|
||||||
|
|
||||||
|
Note: These lines are ignored by the parser. Use explicit dependencies to control execution order.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
### Task-Level Timeout
|
### Task-Level Timeout
|
||||||
|
|
||||||
@@ -119,6 +107,33 @@ You can set a timeout for individual tasks using a meta block in the task file:
|
|||||||
|
|
||||||
Supported formats: `10m` (minutes), `600s` (seconds), `3600000` (milliseconds)
|
Supported formats: `10m` (minutes), `600s` (seconds), `3600000` (milliseconds)
|
||||||
|
|
||||||
|
### Config files
|
||||||
|
|
||||||
|
| Scope | Path |
|
||||||
|
|-------|------|
|
||||||
|
| **Global** | `~/.pi/ralpi/config.yaml` |
|
||||||
|
| **Project** | `./.ralpi/config.yaml` |
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
execution:
|
||||||
|
maxParallel: 3 # ralpi-level concurrency only
|
||||||
|
models: # round-robin in <provider>/<model> format
|
||||||
|
- google/gemini-3.5-flash # 1st and 3rd task in parallel
|
||||||
|
- openai/gpt-5.5 # 2nd task in parallel
|
||||||
|
prompts:
|
||||||
|
projectContext: "Additional context for all tasks"
|
||||||
|
```
|
||||||
|
|
||||||
|
> `execution.models` uses slot-aware round-robin: with 3 models and 2 concurrent
|
||||||
|
> tasks, only the first two models are used. The third model is only touched when
|
||||||
|
> a third concurrent task starts. Freed model slots are reused before new ones
|
||||||
|
> are allocated.
|
||||||
|
> **Automatic failover**: if a provider/API is unreachable (rate limit, 503, etc.),
|
||||||
|
> the task automatically cycles to the next model in the list without counting it
|
||||||
|
> as a task failure. Each model is tried once before the task is marked as failed.
|
||||||
|
> **NOTE**: this is only used in parallel execution, in sequential mode the
|
||||||
|
> parent pi session's model is used
|
||||||
|
|
||||||
## State Files
|
## State Files
|
||||||
|
|
||||||
- `.ralpi/progress.json` - Execution progress
|
- `.ralpi/progress.json` - Execution progress
|
||||||
|
|||||||
308
bun.lock
Normal file
308
bun.lock
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"configVersion": 1,
|
||||||
|
"workspaces": {
|
||||||
|
"": {
|
||||||
|
"name": "@mikefreno/ralpi",
|
||||||
|
"dependencies": {
|
||||||
|
"yaml": "^2.4.0",
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.0.0",
|
||||||
|
"bun-types": "^1.3.14",
|
||||||
|
"typescript": "^5.3.0",
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@earendil-works/pi-coding-agent": "*",
|
||||||
|
"@earendil-works/pi-tui": "*",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.91.1", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw=="],
|
||||||
|
|
||||||
|
"@aws-crypto/crc32": ["@aws-crypto/crc32@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="],
|
||||||
|
|
||||||
|
"@aws-crypto/sha256-browser": ["@aws-crypto/sha256-browser@5.2.0", "", { "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw=="],
|
||||||
|
|
||||||
|
"@aws-crypto/sha256-js": ["@aws-crypto/sha256-js@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA=="],
|
||||||
|
|
||||||
|
"@aws-crypto/supports-web-crypto": ["@aws-crypto/supports-web-crypto@5.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg=="],
|
||||||
|
|
||||||
|
"@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="],
|
||||||
|
|
||||||
|
"@aws-sdk/client-bedrock-runtime": ["@aws-sdk/client-bedrock-runtime@3.1048.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.11", "@aws-sdk/credential-provider-node": "^3.972.42", "@aws-sdk/eventstream-handler-node": "^3.972.16", "@aws-sdk/middleware-eventstream": "^3.972.12", "@aws-sdk/middleware-websocket": "^3.972.19", "@aws-sdk/token-providers": "3.1048.0", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/fetch-http-handler": "^5.4.2", "@smithy/node-http-handler": "^4.7.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ=="],
|
||||||
|
|
||||||
|
"@aws-sdk/core": ["@aws-sdk/core@3.974.19", "", { "dependencies": { "@aws-sdk/types": "^3.973.12", "@aws-sdk/xml-builder": "^3.972.29", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/core": "^3.24.6", "@smithy/signature-v4": "^5.4.6", "@smithy/types": "^4.14.3", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-SMNfLCU/41xxfFaC5Slwy8V/f1FRhakvyeeMeDeIxqNF0DzhDlXsXnJDELJYke1EtnJbfzfilW7tvulGfxMY6A=="],
|
||||||
|
|
||||||
|
"@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.45", "", { "dependencies": { "@aws-sdk/core": "^3.974.19", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-ZPsnLyrpDRmojKrBbJykASyLLVFkjyD+fWATeSuYgaqablijGOzxPxEKyrwUvNg+bgSQ7PkW2FTu65Xco19Gag=="],
|
||||||
|
|
||||||
|
"@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.47", "", { "dependencies": { "@aws-sdk/core": "^3.974.19", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/node-http-handler": "^4.7.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-1XdgHDIPbARHuzZXM7ouzIbSUZFU9dTi9k+ryMhiZU4QCam4dvwOyUEFjEHNxAZehCYUIOmsSUZ2un6BIgUkWg=="],
|
||||||
|
|
||||||
|
"@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.51", "", { "dependencies": { "@aws-sdk/core": "^3.974.19", "@aws-sdk/credential-provider-env": "^3.972.45", "@aws-sdk/credential-provider-http": "^3.972.47", "@aws-sdk/credential-provider-login": "^3.972.50", "@aws-sdk/credential-provider-process": "^3.972.45", "@aws-sdk/credential-provider-sso": "^3.972.50", "@aws-sdk/credential-provider-web-identity": "^3.972.50", "@aws-sdk/nested-clients": "^3.997.18", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/credential-provider-imds": "^4.3.7", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-f8sRTVyM+9BbzQKPlUP9dVVpgNEu65jFckNAAGzRfCrlaSi5AWUbCKEHIMcIYokv8pWblSKEqHkqKYZtwINnhw=="],
|
||||||
|
|
||||||
|
"@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.50", "", { "dependencies": { "@aws-sdk/core": "^3.974.19", "@aws-sdk/nested-clients": "^3.997.18", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-NHHsKoMhw6UylSU0XDnDc87+IQW8tRBTIe6vnOX12GSIlBDtoce6bSzONleIglCyu8d3H9bmTSfk+sIN5yh3WA=="],
|
||||||
|
|
||||||
|
"@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.53", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.45", "@aws-sdk/credential-provider-http": "^3.972.47", "@aws-sdk/credential-provider-ini": "^3.972.51", "@aws-sdk/credential-provider-process": "^3.972.45", "@aws-sdk/credential-provider-sso": "^3.972.50", "@aws-sdk/credential-provider-web-identity": "^3.972.50", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/credential-provider-imds": "^4.3.7", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-z/JJ8Qvf2GiTn4bw+x8k7wQjxmPpNsiwZ7ls/h1cZHikrSpS0+65lB+lafnXZlxv1lqH4k6rQwh+2UsycC662g=="],
|
||||||
|
|
||||||
|
"@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.45", "", { "dependencies": { "@aws-sdk/core": "^3.974.19", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-QMJXjTGLmHE4Ie03T5H4hHOLfcvMc9DaODO6b5dgte3S8ECf5bBuHUJW4cQREcYZyRkOU8iymqtiBxqF4icxZg=="],
|
||||||
|
|
||||||
|
"@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.50", "", { "dependencies": { "@aws-sdk/core": "^3.974.19", "@aws-sdk/nested-clients": "^3.997.18", "@aws-sdk/token-providers": "3.1064.0", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-pQ9ww4G53gwHlon1NMz25JhaBo13E9Jv+VVgjh39C/yzvby+xhSnEOb+VDYShKNCh1TbttMF/5CFCHkZrIqOcA=="],
|
||||||
|
|
||||||
|
"@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.50", "", { "dependencies": { "@aws-sdk/core": "^3.974.19", "@aws-sdk/nested-clients": "^3.997.18", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-9DbaPaT2aMbz18wtSpq9HVBErjBQwxykqTFgG6n8Bn05GN68mITz+G1869ekYx0mVT/BDjETj5czz/3cPgLwxA=="],
|
||||||
|
|
||||||
|
"@aws-sdk/eventstream-handler-node": ["@aws-sdk/eventstream-handler-node@3.972.21", "", { "dependencies": { "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-mVC0hOmwGJmNFezZ+wM8Sqfap/LjsMavEf2Evl0YWrLAcrdZOEdjnY8nRvgakVViWJSGm2eJxLuPVHGdeV06kA=="],
|
||||||
|
|
||||||
|
"@aws-sdk/middleware-eventstream": ["@aws-sdk/middleware-eventstream@3.972.17", "", { "dependencies": { "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-tdbnXbw73ww62ABWP0G0Z/euvFowEEvAoi/zG4NaZo7HJFpfGho/Z65HyVzkJLT1cMsUregr4pTyxljlarT0wA=="],
|
||||||
|
|
||||||
|
"@aws-sdk/middleware-websocket": ["@aws-sdk/middleware-websocket@3.972.27", "", { "dependencies": { "@aws-sdk/core": "^3.974.19", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/signature-v4": "^5.4.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-V/IgUogQm/NSGlNglDCkREirQXgjyrrq64vPt5qcRTGhEJJwPcUB3RyYE6iZ63WNGp4Ezc+JtVRA4QlSbhDvVQ=="],
|
||||||
|
|
||||||
|
"@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.18", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.19", "@aws-sdk/signature-v4-multi-region": "^3.996.33", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/node-http-handler": "^4.7.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-xBWrodBvW5SHCZV11UZUJG0pSHkLCEREIBoNbff1C1sacOUCmxJnTCPE80sCGLCtqgXg98I2MQJe2z28tcZSsw=="],
|
||||||
|
|
||||||
|
"@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.33", "", { "dependencies": { "@aws-sdk/types": "^3.973.12", "@smithy/signature-v4": "^5.4.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-Hn0RThJEbyOZWV2PV9Z4YD3nitGPxybmyU17dSe9b61WOBcKnqS0WTtM3c1zyZq9WnGiyrfi/i+UBPUk7cM8Ug=="],
|
||||||
|
|
||||||
|
"@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1048.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.11", "@aws-sdk/nested-clients": "^3.997.9", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA=="],
|
||||||
|
|
||||||
|
"@aws-sdk/types": ["@aws-sdk/types@3.973.12", "", { "dependencies": { "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-43ajd1NF0RMgX5k0hxCNUyEdrtFUsb2aHT2QvpktSC/2Eyb2Jr/JPVqdp0XIoaHWikZJq5tNWSLO6kB5q2eMCA=="],
|
||||||
|
|
||||||
|
"@aws-sdk/util-locate-window": ["@aws-sdk/util-locate-window@3.965.7", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-M0D6oIpohdNHjc7udzTHEQyot0+0iuA36jc2I9Hps+f/GtKi2HO/pyijQnCnNcwZqLB5+rtn81z3eZK/GyjAmA=="],
|
||||||
|
|
||||||
|
"@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.29", "", { "dependencies": { "@smithy/types": "^4.14.3", "fast-xml-parser": "5.7.3", "tslib": "^2.6.2" } }, "sha512-fk0niuGFxfi8yIJuMVM4mhwObkiQSuwZFj3tAPrLVx64Pk3BkrEIpqjzHKY4hKoEBUD6Jg/S74Zj9jy+5F3DnQ=="],
|
||||||
|
|
||||||
|
"@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.2.4", "", {}, "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ=="],
|
||||||
|
|
||||||
|
"@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="],
|
||||||
|
|
||||||
|
"@earendil-works/pi-agent-core": ["@earendil-works/pi-agent-core@0.79.0", "", { "dependencies": { "@earendil-works/pi-ai": "^0.79.0", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" } }, "sha512-jQOtYjRGZ7+XC/olw9euLd2V03vkAPO8u0sSnQoLbyOQZz66dEBZrklTESk34Sf3AaeBSua28wjZR48ch1aXJQ=="],
|
||||||
|
|
||||||
|
"@earendil-works/pi-ai": ["@earendil-works/pi-ai@0.79.0", "", { "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", "@google/genai": "1.52.0", "@mistralai/mistralai": "2.2.1", "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", "openai": "6.26.0", "partial-json": "0.1.7", "typebox": "1.1.38" }, "bin": { "pi-ai": "dist/cli.js" } }, "sha512-D/2aDoe9vcCbqAztALQcKkdqXGuaQcqAzLm8LfUhNaorwoIHkwnaAuDVlo+OkF5clpEwS8Z1bk2o8NiSrwEdsA=="],
|
||||||
|
|
||||||
|
"@earendil-works/pi-coding-agent": ["@earendil-works/pi-coding-agent@0.79.0", "", { "dependencies": { "@earendil-works/pi-agent-core": "^0.79.0", "@earendil-works/pi-ai": "^0.79.0", "@earendil-works/pi-tui": "^0.79.0", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", "diff": "8.0.4", "glob": "13.0.6", "highlight.js": "10.7.3", "hosted-git-info": "9.0.3", "ignore": "7.0.5", "jiti": "2.7.0", "minimatch": "10.2.5", "proper-lockfile": "4.1.2", "typebox": "1.1.38", "undici": "8.3.0", "yaml": "2.9.0" }, "optionalDependencies": { "@mariozechner/clipboard": "0.3.9" }, "bin": { "pi": "dist/cli.js" } }, "sha512-pZoXk65vFR3dAzzmPNWEX61aHnT6+BaVhTyFDQAs1DyumaMeWpvzRV9ZrGxqlbVLwhrq+0LnXbaqDAFkhe2+MQ=="],
|
||||||
|
|
||||||
|
"@earendil-works/pi-tui": ["@earendil-works/pi-tui@0.79.0", "", { "dependencies": { "get-east-asian-width": "1.6.0", "marked": "15.0.12" } }, "sha512-qAQWMruW7YKbk2hPcTD4INtXfvIySXifbPQ+mFY5j3J8yf2tfElkh+gGPuBvgPKPT0z9WiAkd7iySCuQq0txuQ=="],
|
||||||
|
|
||||||
|
"@google/genai": ["@google/genai@1.52.0", "", { "dependencies": { "google-auth-library": "^10.3.0", "p-retry": "^4.6.2", "protobufjs": "^7.5.4", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.2" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q=="],
|
||||||
|
|
||||||
|
"@mariozechner/clipboard": ["@mariozechner/clipboard@0.3.9", "", { "optionalDependencies": { "@mariozechner/clipboard-darwin-arm64": "0.3.9", "@mariozechner/clipboard-darwin-universal": "0.3.9", "@mariozechner/clipboard-darwin-x64": "0.3.9", "@mariozechner/clipboard-linux-arm64-gnu": "0.3.9", "@mariozechner/clipboard-linux-arm64-musl": "0.3.9", "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.9", "@mariozechner/clipboard-linux-x64-gnu": "0.3.9", "@mariozechner/clipboard-linux-x64-musl": "0.3.9", "@mariozechner/clipboard-win32-arm64-msvc": "0.3.9", "@mariozechner/clipboard-win32-x64-msvc": "0.3.9" } }, "sha512-ABnA53mdfkGZwOFUdZNv2S0CWGO/EIuPj8Vv9xmBFmSYg/qFc7ihO6q5FcQjvoE67kZpWkEc4AhD6B/os04yuA=="],
|
||||||
|
|
||||||
|
"@mariozechner/clipboard-darwin-arm64": ["@mariozechner/clipboard-darwin-arm64@0.3.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BfgV7vCEWZwJwZJw03r6bP5+tf0iI/ANuQYCxi9RNn7FrWB3yzGuMKCrNLRl6V761vXRdL8+OqZ0wd4TqlsNOQ=="],
|
||||||
|
|
||||||
|
"@mariozechner/clipboard-darwin-universal": ["@mariozechner/clipboard-darwin-universal@0.3.9", "", { "os": "darwin" }, "sha512-BGGR4iA9Z2shAjI65eI5xtyb3LYNlDW9X3gxKxDbqtbnREohsrqznov6zpKoIrsRWpzlYVEdKphS7ksJ0/ndSQ=="],
|
||||||
|
|
||||||
|
"@mariozechner/clipboard-darwin-x64": ["@mariozechner/clipboard-darwin-x64@0.3.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-4kURmCbS6nt8uYhtmWpUcJWyPHfmAr5dTpXD1nO3pIfa+TSQ9DbrGOYCKH+aEFW47XhQ4Vp8ZTszie+wfFvDKg=="],
|
||||||
|
|
||||||
|
"@mariozechner/clipboard-linux-arm64-gnu": ["@mariozechner/clipboard-linux-arm64-gnu@0.3.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-g59OkUGP2DDfCOIKypHeYgv2M55u/cKvXa5dSxFbEJ34XvIQMdcVmpKCkGUro3ZgefXiGVdwguvTMQGpHWzIXw=="],
|
||||||
|
|
||||||
|
"@mariozechner/clipboard-linux-arm64-musl": ["@mariozechner/clipboard-linux-arm64-musl@0.3.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-AGuJdgKsmJdm4Pych7kv3sqe591ERRaAHW3xjLooiFzn8J+PxUyof++7YZrB5Y5tpnTO+K18Og3taj2NpluCRQ=="],
|
||||||
|
|
||||||
|
"@mariozechner/clipboard-linux-riscv64-gnu": ["@mariozechner/clipboard-linux-riscv64-gnu@0.3.9", "", { "os": "linux", "cpu": "none" }, "sha512-DXBEAiuMpk7dhS1a9NzNxVAFi1vaKoPu7rQNgY8LIDLGrK3lnIp3nT10DUum+PKVJoJppIP+NAA8IZe4DMNDPw=="],
|
||||||
|
|
||||||
|
"@mariozechner/clipboard-linux-x64-gnu": ["@mariozechner/clipboard-linux-x64-gnu@0.3.9", "", { "os": "linux", "cpu": "x64" }, "sha512-WORrMLd6EpElEME7JRKfSaY34nW1P5LbdgK5YNCS1ncG2LqmITsSMEJ8nh2mpvxb3TxqbOOKgY7k9eMJYlW9Mw=="],
|
||||||
|
|
||||||
|
"@mariozechner/clipboard-linux-x64-musl": ["@mariozechner/clipboard-linux-x64-musl@0.3.9", "", { "os": "linux", "cpu": "x64" }, "sha512-/DHn+1DrfL6oRaPPWXaOKvonFFrni666fxd+zFqiQEfvBH0tsHVWjq9iqBk0oDp0qaPA72lIMy5BptxISBEhZQ=="],
|
||||||
|
|
||||||
|
"@mariozechner/clipboard-win32-arm64-msvc": ["@mariozechner/clipboard-win32-arm64-msvc@0.3.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-O5FHD3ErkMwMhNzAfu3ggy0ug4z7btZuoQgwwxlzPrwV2bxlD6WDpqBY4NCgICAgZdDKdp+loUEKVAVt8aYnhQ=="],
|
||||||
|
|
||||||
|
"@mariozechner/clipboard-win32-x64-msvc": ["@mariozechner/clipboard-win32-x64-msvc@0.3.9", "", { "os": "win32", "cpu": "x64" }, "sha512-ihQC3EufqEY81vhXBgVBtK4prL+wc62zJsSvxrgz7K1hsdt6OObz6v9p3Rn1OG3GJksTTKMJF0u/guMISHPhSA=="],
|
||||||
|
|
||||||
|
"@mistralai/mistralai": ["@mistralai/mistralai@2.2.1", "", { "dependencies": { "ws": "^8.18.0", "zod": "^3.25.0 || ^4.0.0", "zod-to-json-schema": "^3.25.0" } }, "sha512-uKU8CZmL2RzYKmplsU01hii4p3pe4HqJefpWNRWXm1Tcm0Sm4xXfwSLIy4k7ZCPlbETCGcp69E7hZs+WOJ5itQ=="],
|
||||||
|
|
||||||
|
"@nodable/entities": ["@nodable/entities@2.1.1", "", {}, "sha512-Pig3HxDIoMgjdEH8OCf/dkcTmLFjJRjWuq8jSnklu284/TKOPibSRERmOykiwmyXTtv61mP+44f3GMx0tLAyjg=="],
|
||||||
|
|
||||||
|
"@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
|
||||||
|
|
||||||
|
"@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="],
|
||||||
|
|
||||||
|
"@protobufjs/codegen": ["@protobufjs/codegen@2.0.5", "", {}, "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g=="],
|
||||||
|
|
||||||
|
"@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.1", "", {}, "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg=="],
|
||||||
|
|
||||||
|
"@protobufjs/fetch": ["@protobufjs/fetch@1.1.1", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1" } }, "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw=="],
|
||||||
|
|
||||||
|
"@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="],
|
||||||
|
|
||||||
|
"@protobufjs/inquire": ["@protobufjs/inquire@1.1.2", "", {}, "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw=="],
|
||||||
|
|
||||||
|
"@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="],
|
||||||
|
|
||||||
|
"@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="],
|
||||||
|
|
||||||
|
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.1", "", {}, "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg=="],
|
||||||
|
|
||||||
|
"@silvia-odwyer/photon-node": ["@silvia-odwyer/photon-node@0.3.4", "", {}, "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA=="],
|
||||||
|
|
||||||
|
"@smithy/core": ["@smithy/core@3.24.6", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-wBXDRup6UU97VKyaiRo8AssnfStPtG0oAAfpq/bC0a1YYau8pM86YB4kM6ccoVi1mS8l/UHbn9oDM+7uozr/ug=="],
|
||||||
|
|
||||||
|
"@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.3.8", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-5cAM+KZC02sTqDt6NaLXyu50M/GNMd1eTzDVR8Lb0BBsVtu7RWHo47VPPEEv1vt3Yub6uzr+M5FHC+GtoT0USg=="],
|
||||||
|
|
||||||
|
"@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.4.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-FEwEYJ1jlBKdhe9TPzfghEi1bP55ZeEImlDkEa62bBBYzUcnB6RUCyuiS2mqKt6ZVjUbBgcNhzfIctH+Hevx9g=="],
|
||||||
|
|
||||||
|
"@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
|
||||||
|
|
||||||
|
"@smithy/node-http-handler": ["@smithy/node-http-handler@4.7.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA=="],
|
||||||
|
|
||||||
|
"@smithy/signature-v4": ["@smithy/signature-v4@5.4.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-Ojg4B6oIDlIr1R86xCDJt1zJWnYa0VINmqdjfe9qxWjdRivHalZ3iSlQgVqYbW0MdpFOC5XfHEWsnbmdnpIILQ=="],
|
||||||
|
|
||||||
|
"@smithy/types": ["@smithy/types@4.14.3", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-YupL0ZWmFtJexUN2cHzkvvF/b9pKrtAIfT1o7/oY/Ppu8IYeZ+lDPM5vZdQJaSeA132dJCqojjGC9NhXeF71VQ=="],
|
||||||
|
|
||||||
|
"@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
|
||||||
|
|
||||||
|
"@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
|
||||||
|
|
||||||
|
"@types/node": ["@types/node@20.19.42", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-5L7SUaFC1RyDraj2yRhyBzHTobyXHmohD100CChNtyPyleoq37Mqab5Gn8XEKI04dfN/oqPdpHk38MgcQWHbZg=="],
|
||||||
|
|
||||||
|
"@types/retry": ["@types/retry@0.12.0", "", {}, "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="],
|
||||||
|
|
||||||
|
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
||||||
|
|
||||||
|
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||||
|
|
||||||
|
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
|
||||||
|
|
||||||
|
"bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="],
|
||||||
|
|
||||||
|
"bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="],
|
||||||
|
|
||||||
|
"brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="],
|
||||||
|
|
||||||
|
"buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
|
||||||
|
|
||||||
|
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
|
||||||
|
|
||||||
|
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
||||||
|
|
||||||
|
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||||
|
|
||||||
|
"data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
|
||||||
|
|
||||||
|
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||||
|
|
||||||
|
"diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="],
|
||||||
|
|
||||||
|
"ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="],
|
||||||
|
|
||||||
|
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
|
||||||
|
|
||||||
|
"fast-xml-builder": ["fast-xml-builder@1.2.0", "", { "dependencies": { "path-expression-matcher": "^1.5.0", "xml-naming": "^0.1.0" } }, "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q=="],
|
||||||
|
|
||||||
|
"fast-xml-parser": ["fast-xml-parser@5.7.3", "", { "dependencies": { "@nodable/entities": "^2.1.0", "fast-xml-builder": "^1.1.7", "path-expression-matcher": "^1.5.0", "strnum": "^2.2.3" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg=="],
|
||||||
|
|
||||||
|
"fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
|
||||||
|
|
||||||
|
"formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="],
|
||||||
|
|
||||||
|
"gaxios": ["gaxios@7.1.5", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-5FZy72Rh8LhtjmvDrKkI+lVhrsQrVKVsItxMoDm5mNQE+xR0WVIIs+jzPSJgBvKVsLi24fZhXJIsNI0bihDzFg=="],
|
||||||
|
|
||||||
|
"gcp-metadata": ["gcp-metadata@8.1.2", "", { "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" } }, "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg=="],
|
||||||
|
|
||||||
|
"get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="],
|
||||||
|
|
||||||
|
"glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="],
|
||||||
|
|
||||||
|
"google-auth-library": ["google-auth-library@10.7.0", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.1.4", "gcp-metadata": "8.1.2", "google-logging-utils": "1.1.3", "jws": "^4.0.0" } }, "sha512-QpTAbNJ36TliZLx3TTtahR8HG0hN9RllL1e3FymOvQSIKK8JmgV58H924ub2wa2DsS3ANjjP1Aw1N+Ramc8hqQ=="],
|
||||||
|
|
||||||
|
"google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="],
|
||||||
|
|
||||||
|
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||||
|
|
||||||
|
"highlight.js": ["highlight.js@10.7.3", "", {}, "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A=="],
|
||||||
|
|
||||||
|
"hosted-git-info": ["hosted-git-info@9.0.3", "", { "dependencies": { "lru-cache": "^11.1.0" } }, "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg=="],
|
||||||
|
|
||||||
|
"http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
|
||||||
|
|
||||||
|
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
||||||
|
|
||||||
|
"ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
|
||||||
|
|
||||||
|
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||||
|
|
||||||
|
"jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
|
||||||
|
|
||||||
|
"json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="],
|
||||||
|
|
||||||
|
"json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="],
|
||||||
|
|
||||||
|
"jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="],
|
||||||
|
|
||||||
|
"jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="],
|
||||||
|
|
||||||
|
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
|
||||||
|
|
||||||
|
"lru-cache": ["lru-cache@11.5.1", "", {}, "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A=="],
|
||||||
|
|
||||||
|
"marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="],
|
||||||
|
|
||||||
|
"minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
|
||||||
|
|
||||||
|
"minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
|
||||||
|
|
||||||
|
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||||
|
|
||||||
|
"node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
|
||||||
|
|
||||||
|
"node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
|
||||||
|
|
||||||
|
"openai": ["openai@6.26.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA=="],
|
||||||
|
|
||||||
|
"p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="],
|
||||||
|
|
||||||
|
"partial-json": ["partial-json@0.1.7", "", {}, "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA=="],
|
||||||
|
|
||||||
|
"path-expression-matcher": ["path-expression-matcher@1.5.0", "", {}, "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ=="],
|
||||||
|
|
||||||
|
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||||
|
|
||||||
|
"path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="],
|
||||||
|
|
||||||
|
"proper-lockfile": ["proper-lockfile@4.1.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="],
|
||||||
|
|
||||||
|
"protobufjs": ["protobufjs@7.6.2", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-N9EiLovGEQOJSPF26Ij7qUGvahfEnq0eeYZ02aigIedkmz1qZSwjnP9SBITHJuF/6MYbIW4HDN8zdYjsjqJKXQ=="],
|
||||||
|
|
||||||
|
"retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="],
|
||||||
|
|
||||||
|
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||||
|
|
||||||
|
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||||
|
|
||||||
|
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||||
|
|
||||||
|
"signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
|
||||||
|
|
||||||
|
"strnum": ["strnum@2.3.0", "", {}, "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q=="],
|
||||||
|
|
||||||
|
"ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="],
|
||||||
|
|
||||||
|
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||||
|
|
||||||
|
"typebox": ["typebox@1.1.38", "", {}, "sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA=="],
|
||||||
|
|
||||||
|
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||||
|
|
||||||
|
"undici": ["undici@8.3.0", "", {}, "sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q=="],
|
||||||
|
|
||||||
|
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||||
|
|
||||||
|
"web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
|
||||||
|
|
||||||
|
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||||
|
|
||||||
|
"ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],
|
||||||
|
|
||||||
|
"xml-naming": ["xml-naming@0.1.0", "", {}, "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw=="],
|
||||||
|
|
||||||
|
"yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
|
||||||
|
|
||||||
|
"zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
|
||||||
|
|
||||||
|
"zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
|
||||||
|
|
||||||
|
"@aws-sdk/credential-provider-http/@smithy/node-http-handler": ["@smithy/node-http-handler@4.7.7", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-ZAFvHXrEk6K180EVhmZVg8GU5pUH5BSFqRs27JW3j1qEFx9YyYwWFx17x/MHcjALYimGAji7qEOlF1++be+G5A=="],
|
||||||
|
|
||||||
|
"@aws-sdk/credential-provider-sso/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1064.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.19", "@aws-sdk/nested-clients": "^3.997.18", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-sjI+iA4JtgeckBgKwPQF7KzWillRoNDmtpiM0TRa0syiAKFHKUSf84kPXSO3+gA7aMMSxrcxzOM2oPSecaJvEA=="],
|
||||||
|
|
||||||
|
"@aws-sdk/nested-clients/@smithy/node-http-handler": ["@smithy/node-http-handler@4.7.7", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-ZAFvHXrEk6K180EVhmZVg8GU5pUH5BSFqRs27JW3j1qEFx9YyYwWFx17x/MHcjALYimGAji7qEOlF1++be+G5A=="],
|
||||||
|
|
||||||
|
"p-retry/retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
46
package.json
46
package.json
@@ -1,33 +1,51 @@
|
|||||||
{
|
{
|
||||||
"name": "ralpi-loop",
|
"name": "@mikefreno/ralpi",
|
||||||
"version": "1.0.0",
|
"version": "0.2.5",
|
||||||
"description": "Execute tasks from task files using DAG-based dependency resolution with persistent progress tracking",
|
"description": "Execute tasks from task files/PRD's using DAG-based dependency resolution with persistent progress tracking",
|
||||||
"main": "dist/index.js",
|
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"pi-package",
|
"pi-package",
|
||||||
"pi-extension",
|
"pi-extension",
|
||||||
"task-runner",
|
"task-runner",
|
||||||
"dag",
|
"dag",
|
||||||
"task-manager",
|
"task-manager",
|
||||||
"ralpi-loop",
|
"ralph-loop",
|
||||||
"prd"
|
"prd"
|
||||||
],
|
],
|
||||||
"author": "",
|
"author": "Michael Freno",
|
||||||
"license": "MIT",
|
"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": [
|
"files": [
|
||||||
"dist/",
|
"index.ts",
|
||||||
|
"src/",
|
||||||
"skills/",
|
"skills/",
|
||||||
"prompts/",
|
"prompts/",
|
||||||
"index.ts"
|
"README.md",
|
||||||
|
"LICENSE"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"typecheck": "tsc --noEmit",
|
||||||
"watch": "tsc --watch",
|
"prepublishOnly": "tsc --noEmit",
|
||||||
"prepublishOnly": "npm run build"
|
"test": "bun test"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"bun": ">=1.1.0"
|
||||||
},
|
},
|
||||||
"pi": {
|
"pi": {
|
||||||
"extensions": [
|
"extensions": [
|
||||||
"./dist/index.js"
|
"./index.ts"
|
||||||
|
],
|
||||||
|
"skills": [
|
||||||
|
"./skills"
|
||||||
|
],
|
||||||
|
"prompts": [
|
||||||
|
"./prompts"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -37,8 +55,12 @@
|
|||||||
"@earendil-works/pi-coding-agent": "*",
|
"@earendil-works/pi-coding-agent": "*",
|
||||||
"@earendil-works/pi-tui": "*"
|
"@earendil-works/pi-tui": "*"
|
||||||
},
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20.0.0",
|
"@types/node": "^20.0.0",
|
||||||
|
"bun-types": "^1.3.14",
|
||||||
"typescript": "^5.3.0"
|
"typescript": "^5.3.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ Purpose:
|
|||||||
You are a Task Manager (@task-manager), an expert at breaking down complex software features into small, verifiable subtasks. Your role is to create structured task plans that enable efficient, atomic implementation work.
|
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
|
## Core Responsibilities
|
||||||
|
|
||||||
- Break complex features into atomic tasks
|
- Break complex features into atomic tasks
|
||||||
- Create structured directories with task files and indexes
|
- Create structured directories with task files and indexes
|
||||||
- Generate clear acceptance criteria and dependency mapping
|
- Generate clear acceptance criteria and dependency mapping
|
||||||
@@ -22,6 +23,7 @@ You are a Task Manager (@task-manager), an expert at breaking down complex softw
|
|||||||
## Mandatory Two-Phase Workflow
|
## Mandatory Two-Phase Workflow
|
||||||
|
|
||||||
### Phase 1: Planning (Approval Required)
|
### Phase 1: Planning (Approval Required)
|
||||||
|
|
||||||
When given a complex feature request:
|
When given a complex feature request:
|
||||||
|
|
||||||
1. **Analyze the feature** to identify:
|
1. **Analyze the feature** to identify:
|
||||||
@@ -36,21 +38,27 @@ When given a complex feature request:
|
|||||||
- Exit criteria for feature completion
|
- Exit criteria for feature completion
|
||||||
|
|
||||||
3. **Present plan using this exact format:**```
|
3. **Present plan using this exact format:**```
|
||||||
|
|
||||||
## Subtask Plan
|
## Subtask Plan
|
||||||
|
|
||||||
feature: {kebab-case-feature-name}
|
feature: {kebab-case-feature-name}
|
||||||
objective: {one-line description}
|
objective: {one-line description}
|
||||||
|
|
||||||
tasks:
|
tasks:
|
||||||
|
|
||||||
- seq: {2-digit}, filename: {seq}-{task-description}.md, title: {clear title}
|
- seq: {2-digit}, filename: {seq}-{task-description}.md, title: {clear title}
|
||||||
- seq: {2-digit}, filename: {seq}-{task-description}.md, title: {clear title}
|
- seq: {2-digit}, filename: {seq}-{task-description}.md, title: {clear title}
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
||||||
- {seq} -> {seq} (task dependencies)
|
- {seq} -> {seq} (task dependencies)
|
||||||
|
|
||||||
exit_criteria:
|
exit_criteria:
|
||||||
|
|
||||||
- {specific, measurable completion criteria}
|
- {specific, measurable completion criteria}
|
||||||
|
|
||||||
Approval needed before file creation.
|
Approval needed before file creation.
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
4. **Wait for explicit approval** before proceeding to Phase 2.
|
4. **Wait for explicit approval** before proceeding to Phase 2.
|
||||||
@@ -67,6 +75,7 @@ Once approved:
|
|||||||
|
|
||||||
**Feature Index Template** (`tasks/{feature}/README.md`):
|
**Feature Index Template** (`tasks/{feature}/README.md`):
|
||||||
```
|
```
|
||||||
|
|
||||||
# {Feature Title}
|
# {Feature Title}
|
||||||
|
|
||||||
Objective: {one-liner}
|
Objective: {one-liner}
|
||||||
@@ -74,17 +83,22 @@ Objective: {one-liner}
|
|||||||
Status legend: [ ] todo, [~] in-progress, [x] done
|
Status legend: [ ] todo, [~] in-progress, [x] done
|
||||||
|
|
||||||
Tasks
|
Tasks
|
||||||
|
|
||||||
- [ ] {seq} — {task-description} → `{seq}-{task-description}.md`
|
- [ ] {seq} — {task-description} → `{seq}-{task-description}.md`
|
||||||
|
|
||||||
Dependencies
|
Dependencies
|
||||||
|
|
||||||
- {seq} depends on {seq}
|
- {seq} depends on {seq}
|
||||||
|
|
||||||
Exit criteria
|
Exit criteria
|
||||||
|
|
||||||
- The feature is complete when {specific criteria}
|
- The feature is complete when {specific criteria}
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Task File Template** (`{seq}-{task-description}.md`):
|
**Task File Template** (`{seq}-{task-description}.md`):
|
||||||
```
|
```
|
||||||
|
|
||||||
# {seq}. {Title}
|
# {seq}. {Title}
|
||||||
|
|
||||||
meta:
|
meta:
|
||||||
@@ -95,40 +109,54 @@ meta:
|
|||||||
tags: [implementation, tests-required]
|
tags: [implementation, tests-required]
|
||||||
|
|
||||||
objective:
|
objective:
|
||||||
|
|
||||||
- Clear, single outcome for this task
|
- Clear, single outcome for this task
|
||||||
|
|
||||||
deliverables:
|
deliverables:
|
||||||
|
|
||||||
- What gets added/changed (files, modules, endpoints)
|
- What gets added/changed (files, modules, endpoints)
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
|
||||||
- Step-by-step actions to complete the task
|
- Step-by-step actions to complete the task
|
||||||
|
|
||||||
tests:
|
tests:
|
||||||
|
|
||||||
- Unit: which functions/modules to cover (Arrange–Act–Assert)
|
- Unit: which functions/modules to cover (Arrange–Act–Assert)
|
||||||
- Integration/e2e: how to validate behavior
|
- Integration/e2e: how to validate behavior
|
||||||
|
|
||||||
acceptance_criteria:
|
acceptance_criteria:
|
||||||
|
|
||||||
- Observable, binary pass/fail conditions
|
- Observable, binary pass/fail conditions
|
||||||
|
|
||||||
validation:
|
validation:
|
||||||
|
|
||||||
- Commands or scripts to run and how to verify
|
- Commands or scripts to run and how to verify
|
||||||
|
|
||||||
notes:
|
notes:
|
||||||
|
|
||||||
- Assumptions, links to relevant docs or design
|
- Assumptions, links to relevant docs or design
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Provide creation summary:**
|
3. **Provide creation summary:**
|
||||||
```
|
```
|
||||||
|
|
||||||
## Subtasks Created
|
## Subtasks Created
|
||||||
|
|
||||||
- tasks/{feature}/README.md
|
- tasks/{feature}/README.md
|
||||||
- tasks/{feature}/{seq}-{task-description}.md
|
- tasks/{feature}/{seq}-{task-description}.md
|
||||||
|
|
||||||
Next suggested task: {seq} — {title}
|
Next suggested task: {seq} — {title}
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Strict Conventions
|
## Strict Conventions
|
||||||
- **Naming:** Always use kebab-case for features and task descriptions
|
- **Naming:** Always use kebab-case for features and task descriptions
|
||||||
- **Sequencing:** 2-digits (01, 02, 03...)
|
- **Sequencing:** 2-digits (01, 02, 03...) — optionally a single lowercase letter
|
||||||
|
suffix may be appended to insert a sub-task between two numbered steps without
|
||||||
|
renumbering siblings (e.g. `02b`, `02c` for sub-tasks of `02`). The parser
|
||||||
|
normalizes `2b` → `02b`.
|
||||||
- **File pattern:** `{seq}-{task-description}.md`
|
- **File pattern:** `{seq}-{task-description}.md`
|
||||||
- **Dependencies:** Always map task relationships (if applicable)
|
- **Dependencies:** Always map task relationships (if applicable)
|
||||||
- **Tests:** Every task must include test requirements
|
- **Tests:** Every task must include test requirements
|
||||||
|
|||||||
293
src/dag.ts
293
src/dag.ts
@@ -1,4 +1,38 @@
|
|||||||
import type { Task, ExecutionBatch, ExecutionPlan, Project } from "./types";
|
import type {
|
||||||
|
Task,
|
||||||
|
ExecutionBatch,
|
||||||
|
ExecutionPlan,
|
||||||
|
Project,
|
||||||
|
ParallelGroup,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
// ─── Blocked Tasks ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find tasks that are blocked (direct or transitive) due to failed dependencies.
|
||||||
|
* Returns a Set of blocked task IDs.
|
||||||
|
*/
|
||||||
|
export function getBlockedTasks(
|
||||||
|
pendingTasks: Task[],
|
||||||
|
failedTaskIds: Set<string>,
|
||||||
|
): Set<string> {
|
||||||
|
const blocked = new Set<string>();
|
||||||
|
|
||||||
|
let changed = true;
|
||||||
|
while (changed) {
|
||||||
|
changed = false;
|
||||||
|
for (const task of pendingTasks) {
|
||||||
|
if (blocked.has(task.id)) continue;
|
||||||
|
const deps = task.dependencies || [];
|
||||||
|
if (deps.some((dep) => failedTaskIds.has(dep) || blocked.has(dep))) {
|
||||||
|
blocked.add(task.id);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return blocked;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Main Entry ──────────────────────────────────────────────────────────────
|
// ─── Main Entry ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -10,26 +44,46 @@ export function buildExecutionPlan(
|
|||||||
project: Project,
|
project: Project,
|
||||||
completed: Set<string>,
|
completed: Set<string>,
|
||||||
parallelGroup?: number,
|
parallelGroup?: number,
|
||||||
|
failedTaskIds: Set<string> = new Set(),
|
||||||
): ExecutionPlan {
|
): 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
|
// With explicitly declared parallel groups, all groups are independent.
|
||||||
const pendingTasks = project.tasks.filter((t) => !completed.has(t.id));
|
// Since there are no cross-group dependencies by definition, standard
|
||||||
|
// Kahn's algorithm produces the correct plan — tasks ready in any group
|
||||||
|
// appear in the same batch, and intra-group dependencies (e.g. "21 must
|
||||||
|
// be done before 22, 23, 24") are respected automatically.
|
||||||
|
// The parallel groups are preserved as metadata for display/documentation.
|
||||||
|
if (project.parallelGroups && project.parallelGroups.length > 0) {
|
||||||
|
return {
|
||||||
|
batches: buildGroupAwareBatches(project, pendingTasks, failedTaskIds),
|
||||||
|
totalTasks: pendingTasks.length,
|
||||||
|
skippedTasks,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// If parallel_group is explicitly set, use group-based batching
|
// If parallel_group is explicitly set (legacy config flag), use group-based batching
|
||||||
if (parallelGroup !== undefined) {
|
if (parallelGroup !== undefined) {
|
||||||
return {
|
return {
|
||||||
batches: buildParallelGroupBatches(pendingTasks, allTasks, completed),
|
batches: buildParallelGroupBatchesLegacy(pendingTasks, failedTaskIds),
|
||||||
totalTasks: pendingTasks.length,
|
totalTasks: pendingTasks.length,
|
||||||
skippedTasks: project.tasks.filter((t) => completed.has(t.id)),
|
skippedTasks,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use dependency-based Kahn's algorithm
|
// Use dependency-based Kahn's algorithm
|
||||||
return {
|
return {
|
||||||
batches: buildBatches(pendingTasks, allTasks, completed),
|
batches: buildBatches(pendingTasks, failedTaskIds),
|
||||||
totalTasks: pendingTasks.length,
|
totalTasks: pendingTasks.length,
|
||||||
skippedTasks: project.tasks.filter((t) => completed.has(t.id)),
|
skippedTasks,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,9 +95,18 @@ export function buildExecutionPlan(
|
|||||||
export function buildSequentialPlan(
|
export function buildSequentialPlan(
|
||||||
project: Project,
|
project: Project,
|
||||||
completed: Set<string>,
|
completed: Set<string>,
|
||||||
|
failedTaskIds: Set<string> = new Set(),
|
||||||
): ExecutionPlan {
|
): ExecutionPlan {
|
||||||
const pendingTasks = project.tasks.filter((t) => !completed.has(t.id));
|
const pendingTasks = project.tasks.filter((t) => !completed.has(t.id));
|
||||||
const batches: ExecutionBatch[] = pendingTasks.map((task, i) => ({
|
|
||||||
|
// Mark tasks with failed dependencies as skipped
|
||||||
|
const blocked = getBlockedTasks(pendingTasks, failedTaskIds);
|
||||||
|
const skippedTasks = project.tasks.filter(
|
||||||
|
(t) => completed.has(t.id) || blocked.has(t.id),
|
||||||
|
);
|
||||||
|
const activeTasks = pendingTasks.filter((t) => !blocked.has(t.id));
|
||||||
|
|
||||||
|
const batches: ExecutionBatch[] = activeTasks.map((task, i) => ({
|
||||||
tasks: [task],
|
tasks: [task],
|
||||||
batchIndex: i,
|
batchIndex: i,
|
||||||
}));
|
}));
|
||||||
@@ -51,7 +114,7 @@ export function buildSequentialPlan(
|
|||||||
return {
|
return {
|
||||||
batches,
|
batches,
|
||||||
totalTasks: pendingTasks.length,
|
totalTasks: pendingTasks.length,
|
||||||
skippedTasks: project.tasks.filter((t) => completed.has(t.id)),
|
skippedTasks,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,12 +122,15 @@ export function buildSequentialPlan(
|
|||||||
|
|
||||||
function buildBatches(
|
function buildBatches(
|
||||||
pendingTasks: Task[],
|
pendingTasks: Task[],
|
||||||
allTasks: Map<string, Task>,
|
failedTaskIds: Set<string>,
|
||||||
completed: Set<string>,
|
|
||||||
): ExecutionBatch[] {
|
): ExecutionBatch[] {
|
||||||
const batches: ExecutionBatch[] = [];
|
const batches: ExecutionBatch[] = [];
|
||||||
const done = new Set(completed);
|
const done = new Set<string>();
|
||||||
const remaining = new Set(pendingTasks.map((t) => t.id));
|
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) {
|
while (remaining.size > 0) {
|
||||||
// Find tasks whose dependencies are all satisfied
|
// Find tasks whose dependencies are all satisfied
|
||||||
@@ -74,7 +140,7 @@ function buildBatches(
|
|||||||
|
|
||||||
const deps = task.dependencies || [];
|
const deps = task.dependencies || [];
|
||||||
const depsSatisfied = deps.every(
|
const depsSatisfied = deps.every(
|
||||||
(dep) => done.has(dep) || !allTasks.has(dep),
|
(dep) => done.has(dep) || !pendingSet.has(dep),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (depsSatisfied) {
|
if (depsSatisfied) {
|
||||||
@@ -100,20 +166,81 @@ function buildBatches(
|
|||||||
return batches;
|
return batches;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Parallel Group Batching ─────────────────────────────────────────────────
|
// ─── Group-Aware Batching ────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build batches from explicit parallel_group values.
|
* Build batches respecting both explicit parallel groups and intra-group
|
||||||
* Groups execute in ascending order; tasks within a group run concurrently.
|
* dependencies. Since parallel group declarations imply no cross-group
|
||||||
|
* dependencies, all tasks whose dependencies are satisfied — across any
|
||||||
|
* group — can run concurrently in the same batch. This means groups
|
||||||
|
* "proceed independently" as the user specified: tasks from different
|
||||||
|
* groups can appear in the same batch when ready.
|
||||||
|
*
|
||||||
|
* Intra-group dependencies (e.g., "21 must be done before 22, 23, 24")
|
||||||
|
* are handled by Kahn's algorithm: if 21 has deps satisfied but 22 doesn't,
|
||||||
|
* only 21 appears in the current batch.
|
||||||
*/
|
*/
|
||||||
function buildParallelGroupBatches(
|
function buildGroupAwareBatches(
|
||||||
|
_project: Project,
|
||||||
pendingTasks: Task[],
|
pendingTasks: Task[],
|
||||||
allTasks: Map<string, Task>,
|
failedTaskIds: Set<string>,
|
||||||
completed: Set<string>,
|
|
||||||
): ExecutionBatch[] {
|
): ExecutionBatch[] {
|
||||||
|
const blocked = getBlockedTasks(pendingTasks, failedTaskIds);
|
||||||
|
const activeTasks = pendingTasks.filter((t) => !blocked.has(t.id));
|
||||||
|
|
||||||
|
// Standard Kahn's algorithm across ALL tasks — parallel groups are
|
||||||
|
// metadata for display, not scheduling constraints.
|
||||||
|
const pendingSet = new Set(pendingTasks.map((t) => t.id));
|
||||||
|
const done = new Set<string>();
|
||||||
|
const remaining = new Set(activeTasks.map((t) => t.id));
|
||||||
|
const batches: ExecutionBatch[] = [];
|
||||||
|
|
||||||
|
while (remaining.size > 0) {
|
||||||
|
const ready: Task[] = [];
|
||||||
|
for (const task of activeTasks) {
|
||||||
|
if (!remaining.has(task.id)) continue;
|
||||||
|
const deps = task.dependencies || [];
|
||||||
|
const depsSatisfied = deps.every(
|
||||||
|
(dep) => done.has(dep) || !pendingSet.has(dep),
|
||||||
|
);
|
||||||
|
if (depsSatisfied) {
|
||||||
|
ready.push(task);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ready.length === 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Dependency cycle detected: ${Array.from(remaining).join(", ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
batches.push({ tasks: ready, batchIndex: batches.length });
|
||||||
|
for (const task of ready) {
|
||||||
|
done.add(task.id);
|
||||||
|
remaining.delete(task.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return batches;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Legacy Parallel Group Batching ─────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy: build batches from explicit parallel_group values only.
|
||||||
|
* Groups execute in ascending order; tasks within a group run concurrently.
|
||||||
|
* Does NOT respect intra-group dependencies.
|
||||||
|
*/
|
||||||
|
function buildParallelGroupBatchesLegacy(
|
||||||
|
pendingTasks: Task[],
|
||||||
|
failedTaskIds: Set<string>,
|
||||||
|
): ExecutionBatch[] {
|
||||||
|
const blocked = getBlockedTasks(pendingTasks, failedTaskIds);
|
||||||
|
const activeTasks = pendingTasks.filter((t) => !blocked.has(t.id));
|
||||||
|
|
||||||
const groups = new Map<number, Task[]>();
|
const groups = new Map<number, Task[]>();
|
||||||
|
|
||||||
for (const task of pendingTasks) {
|
for (const task of activeTasks) {
|
||||||
const group = task.parallelGroup ?? 0;
|
const group = task.parallelGroup ?? 0;
|
||||||
if (!groups.has(group)) groups.set(group, []);
|
if (!groups.has(group)) groups.set(group, []);
|
||||||
groups.get(group)!.push(task);
|
groups.get(group)!.push(task);
|
||||||
@@ -121,7 +248,7 @@ function buildParallelGroupBatches(
|
|||||||
|
|
||||||
const sortedGroups = Array.from(groups.entries()).sort((a, b) => a[0] - b[0]);
|
const sortedGroups = Array.from(groups.entries()).sort((a, b) => a[0] - b[0]);
|
||||||
|
|
||||||
return sortedGroups.map(([groupNum, tasks], i) => ({
|
return sortedGroups.map(([_groupNum, tasks], i) => ({
|
||||||
tasks,
|
tasks,
|
||||||
batchIndex: i,
|
batchIndex: i,
|
||||||
}));
|
}));
|
||||||
@@ -268,18 +395,125 @@ export function getCriticalPath(project: Project): Task[] {
|
|||||||
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 ───────────────────────────────────────────────────
|
// ─── Format Execution Plan ───────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format the execution plan for display
|
* Format the execution plan for display
|
||||||
*/
|
*/
|
||||||
export function formatExecutionPlan(plan: ExecutionPlan): string {
|
/**
|
||||||
|
* Format the execution plan for display, optionally with parallel group annotations
|
||||||
|
*/
|
||||||
|
export function formatExecutionPlan(
|
||||||
|
plan: ExecutionPlan,
|
||||||
|
parallelGroups?: ParallelGroup[],
|
||||||
|
): string {
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
lines.push("## Execution Plan");
|
lines.push("## Execution Plan");
|
||||||
lines.push("");
|
lines.push("");
|
||||||
lines.push(`Total tasks: ${plan.totalTasks}`);
|
lines.push(`Total tasks: ${plan.totalTasks}`);
|
||||||
lines.push(`Batches: ${plan.batches.length}`);
|
lines.push(`Batches: ${plan.batches.length}`);
|
||||||
|
|
||||||
|
// Build a lookup: taskId → group label
|
||||||
|
const groupLabel = new Map<string, string>();
|
||||||
|
if (parallelGroups) {
|
||||||
|
for (const g of parallelGroups) {
|
||||||
|
for (const id of g.taskIds) {
|
||||||
|
if (g.label) {
|
||||||
|
groupLabel.set(id, g.label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (plan.skippedTasks.length > 0) {
|
if (plan.skippedTasks.length > 0) {
|
||||||
lines.push(
|
lines.push(
|
||||||
`Already completed: ${plan.skippedTasks.map((t) => t.id).join(", ")}`,
|
`Already completed: ${plan.skippedTasks.map((t) => t.id).join(", ")}`,
|
||||||
@@ -290,7 +524,14 @@ export function formatExecutionPlan(plan: ExecutionPlan): string {
|
|||||||
for (const batch of plan.batches) {
|
for (const batch of plan.batches) {
|
||||||
lines.push(`### Batch ${batch.batchIndex + 1}`);
|
lines.push(`### Batch ${batch.batchIndex + 1}`);
|
||||||
for (const task of batch.tasks) {
|
for (const task of batch.tasks) {
|
||||||
lines.push(`- ${task.id}: ${task.title}`);
|
const annotation = groupLabel.has(task.id)
|
||||||
|
? ` _(${groupLabel.get(task.id)})_`
|
||||||
|
: "";
|
||||||
|
const deps =
|
||||||
|
task.dependencies.length > 0
|
||||||
|
? ` ← needs ${task.dependencies.join(", ")}`
|
||||||
|
: "";
|
||||||
|
lines.push(`- ${task.id}: ${task.title}${annotation}${deps}`);
|
||||||
}
|
}
|
||||||
lines.push("");
|
lines.push("");
|
||||||
}
|
}
|
||||||
|
|||||||
1244
src/executor.ts
1244
src/executor.ts
File diff suppressed because it is too large
Load Diff
478
src/parser.ts
478
src/parser.ts
@@ -1,6 +1,20 @@
|
|||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import type { 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 ──────────────────────────────────────────────────────────────
|
// ─── Main Entry ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -8,6 +22,7 @@ import type { Task, Project } from "./types";
|
|||||||
* Parse a task file (markdown or YAML) into a Project structure.
|
* Parse a task file (markdown or YAML) into a Project structure.
|
||||||
* Supports:
|
* Supports:
|
||||||
* - Fio README format (numbered tasks with dependency graph)
|
* - Fio README format (numbered tasks with dependency graph)
|
||||||
|
* - Phased format (## Phase N — Title sections with tasks and dependencies)
|
||||||
* - Simple checkbox format (- [ ] task)
|
* - Simple checkbox format (- [ ] task)
|
||||||
* - YAML format (tasks: [...])
|
* - YAML format (tasks: [...])
|
||||||
*/
|
*/
|
||||||
@@ -22,7 +37,7 @@ export function parseTaskFile(filePath: string): Project {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Markdown: detect format
|
// Markdown: detect format
|
||||||
if (hasDependenciesSection(content)) {
|
if (hasDependenciesSection(content) || hasPhaseHeadings(content)) {
|
||||||
return parseFioFormat(content, absolutePath, dir);
|
return parseFioFormat(content, absolutePath, dir);
|
||||||
}
|
}
|
||||||
return parseSimpleCheckbox(content, absolutePath, dir);
|
return parseSimpleCheckbox(content, absolutePath, dir);
|
||||||
@@ -30,8 +45,35 @@ export function parseTaskFile(filePath: string): Project {
|
|||||||
|
|
||||||
// ─── Fio Format Parser ───────────────────────────────────────────────────────
|
// ─── Fio Format Parser ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Match both markdown heading (## Dependencies) and plain heading (Dependencies). */
|
||||||
|
const DEP_HEADING_RE = /^(?:##\s+)?Dependencies\s*$/m;
|
||||||
|
/** Match both markdown heading (## Tasks) and plain heading (Tasks). */
|
||||||
|
const TASK_HEADING_RE = /^(?:##\s+)?Tasks\s*$/m;
|
||||||
|
/** Match other markdown headings (## Something). */
|
||||||
|
const ANY_MD_HEADING_RE = /^##\s/;
|
||||||
|
/** Match phase headings: ## Phase 1 — Push-to-Talk MVP */
|
||||||
|
const PHASE_HEADING_RE = /^\s*##\s+Phase\s+(\d+)\s*[—–:-]\s*(.+)$/i;
|
||||||
|
/** Detect plain phase headings too: Phase 1 — Title (no ##) */
|
||||||
|
const PHASE_HEADING_PLAIN_RE = /^Phase\s+(\d+)\s*[—–:-]\s*(.+)$/i;
|
||||||
|
/**
|
||||||
|
* Detect a plain (non-markdown) section heading like "Exit criteria".
|
||||||
|
* A plain heading must:
|
||||||
|
* - Start with a letter
|
||||||
|
* - Contain only letters and spaces
|
||||||
|
* - Have no colons (avoids matching "Objective:" and "Status legend:")
|
||||||
|
* - Not be a task/dep line (doesn't start with "-")
|
||||||
|
*/
|
||||||
|
function isPlainSectionHeader(line: string): boolean {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
return trimmed.length > 0 && /^[A-Za-z][A-Za-z\s]*$/.test(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
function hasDependenciesSection(content: string): boolean {
|
function hasDependenciesSection(content: string): boolean {
|
||||||
return /^##\s+Dependencies\s*$/m.test(content);
|
return DEP_HEADING_RE.test(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasPhaseHeadings(content: string): boolean {
|
||||||
|
return PHASE_HEADING_RE.test(content) || PHASE_HEADING_PLAIN_RE.test(content);
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseFioFormat(
|
function parseFioFormat(
|
||||||
@@ -42,24 +84,57 @@ function parseFioFormat(
|
|||||||
const lines = content.split("\n");
|
const lines = content.split("\n");
|
||||||
const tasks: Task[] = [];
|
const tasks: Task[] = [];
|
||||||
const dependencies: Record<string, string[]> = {};
|
const dependencies: Record<string, string[]> = {};
|
||||||
|
const parallelGroups: ParallelGroup[] = [];
|
||||||
|
const phases: Phase[] = [];
|
||||||
|
let currentPhase: number | null = null;
|
||||||
|
let currentPhaseTitle = "";
|
||||||
let inTasks = false;
|
let inTasks = false;
|
||||||
let inDeps = false;
|
let inDeps = false;
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (/^##\s+Tasks\s*$/m.test(line)) {
|
// Check for phase headings first
|
||||||
|
const phaseMatch =
|
||||||
|
line.match(PHASE_HEADING_RE) || line.match(PHASE_HEADING_PLAIN_RE);
|
||||||
|
if (phaseMatch) {
|
||||||
|
// Save previous phase if exists
|
||||||
|
if (currentPhase !== null) {
|
||||||
|
const phaseTaskIds = tasks
|
||||||
|
.filter((t) => t.phase === currentPhase)
|
||||||
|
.map((t) => t.id);
|
||||||
|
if (phaseTaskIds.length > 0) {
|
||||||
|
phases.push({
|
||||||
|
number: currentPhase,
|
||||||
|
title: currentPhaseTitle,
|
||||||
|
taskIds: phaseTaskIds,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Start new phase
|
||||||
|
currentPhase = parseInt(phaseMatch[1], 10);
|
||||||
|
currentPhaseTitle = phaseMatch[2].trim();
|
||||||
inTasks = true;
|
inTasks = true;
|
||||||
inDeps = false;
|
inDeps = false;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (/^##\s+Dependencies\s*$/m.test(line)) {
|
|
||||||
|
if (TASK_HEADING_RE.test(line)) {
|
||||||
|
inTasks = true;
|
||||||
|
inDeps = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (DEP_HEADING_RE.test(line)) {
|
||||||
inTasks = false;
|
inTasks = false;
|
||||||
inDeps = true;
|
inDeps = true;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// Reset state on any other section heading — both ##-style and plain
|
||||||
|
// BUT NOT phase headings (already handled above)
|
||||||
if (
|
if (
|
||||||
/^##\s/.test(line) &&
|
(ANY_MD_HEADING_RE.test(line) || isPlainSectionHeader(line)) &&
|
||||||
!/^##\s+Tasks/.test(line) &&
|
!TASK_HEADING_RE.test(line) &&
|
||||||
!/^##\s+Dependencies/.test(line)
|
!DEP_HEADING_RE.test(line) &&
|
||||||
|
!PHASE_HEADING_RE.test(line) &&
|
||||||
|
!PHASE_HEADING_PLAIN_RE.test(line)
|
||||||
) {
|
) {
|
||||||
inTasks = false;
|
inTasks = false;
|
||||||
inDeps = false;
|
inDeps = false;
|
||||||
@@ -67,15 +142,17 @@ function parseFioFormat(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (inTasks) {
|
if (inTasks) {
|
||||||
// Match all tasks on a line (supports compact single-line formats)
|
// Match all tasks on a line (supports compact single-line formats).
|
||||||
|
// ID is digits optionally followed by a single lowercase letter
|
||||||
|
// (e.g. "01", "02b", "10c") — see normalizeTaskId for the shape.
|
||||||
const taskPattern =
|
const taskPattern =
|
||||||
/-+\s+\[(.)\]\s+(\d+)\s+[—–:-]\s+(.+?)(?:\s+(?=-+\s+\[)|\s*→\s*`([^`]+)`|$)/g;
|
/-+\s+\[(.)\]\s+(\d+[a-z]?)\s+[—–:-]\s+(.+?)(?:\s+(?=-+\s+\[)|\s*→\s*`([^`]+)`|$)/g;
|
||||||
let match: RegExpExecArray | null;
|
let match: RegExpExecArray | null;
|
||||||
while ((match = taskPattern.exec(line)) !== null) {
|
while ((match = taskPattern.exec(line)) !== null) {
|
||||||
const [, status, id, title, file] = match;
|
const [, status, id, title, file] = match;
|
||||||
const timeoutMs = parseTimeoutFromLine(line);
|
const timeoutMs = parseTimeoutFromLine(line);
|
||||||
tasks.push({
|
tasks.push({
|
||||||
id: `0${id}`,
|
id: normalizeTaskId(id),
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
description: undefined,
|
description: undefined,
|
||||||
file: file || undefined,
|
file: file || undefined,
|
||||||
@@ -83,71 +160,229 @@ function parseFioFormat(
|
|||||||
dependencies: [],
|
dependencies: [],
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
index: tasks.length,
|
index: tasks.length,
|
||||||
|
phase: currentPhase ?? undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (inDeps) {
|
if (inDeps) {
|
||||||
// Format 2: Arrow notation with multiple targets
|
// Arrow notation (supports both -> and unicode \u2192)
|
||||||
// "01 -> 02,03,06 (description)" means 02, 03, 06 depend on 01
|
// "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"
|
// Supports optional markdown list prefix: "- 01 -> 02,03,06"
|
||||||
const arrowMatch = line.match(
|
const hasArrow = /->/.test(line) || /\u2192/.test(line);
|
||||||
/^(?:\s*[-*]\s+)?(\d+)\s*->\s*([\d,\s]+?)(?:\s*\(|$)/,
|
if (hasArrow) {
|
||||||
);
|
// Strip optional list prefix and parenthetical description
|
||||||
if (arrowMatch) {
|
const cleaned = line
|
||||||
const [, from, targets] = arrowMatch;
|
.replace(/^(\s*[-*]\s+)?/, "")
|
||||||
const fromId = `0${from}`;
|
.replace(/\s*\(.*\)\s*$/, "");
|
||||||
const targetIds = targets
|
|
||||||
.split(",")
|
|
||||||
.map((t) => t.trim())
|
|
||||||
.filter((t) => t)
|
|
||||||
.map((t) => `0${t}`);
|
|
||||||
|
|
||||||
// Each target depends on the source
|
// Split on arrows to get segments
|
||||||
for (const toId of targetIds) {
|
const segments = cleaned
|
||||||
if (!dependencies[toId]) dependencies[toId] = [];
|
.split(/->|\u2192/)
|
||||||
dependencies[toId].push(fromId);
|
.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"
|
// Format 1: Natural language "X depends on A, B, C"
|
||||||
// Supports optional markdown list prefix: "- 13 depends on 17, 18, 19"
|
// 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(
|
const dependsMatch = line.match(
|
||||||
/^(?:\s*[-*]\s+)?(\d+)\s+depends\s+on\s+([\d,\s]+)/i,
|
/^(?:\s*[-*]\s+)?(\d+[a-z]?)\s+(?:also\s+)?depends\s+on\s+([\d,\s a-z]+)/i,
|
||||||
);
|
);
|
||||||
if (dependsMatch) {
|
if (dependsMatch) {
|
||||||
const [, taskId, depsList] = dependsMatch;
|
const [, taskId, depsList] = dependsMatch;
|
||||||
const taskIdPadded = `0${taskId}`;
|
const taskIdPadded = normalizeTaskId(taskId);
|
||||||
const depIds = depsList
|
const depIds = depsList
|
||||||
.split(",")
|
.split(",")
|
||||||
.map((t) => t.trim())
|
.map((t) => t.trim())
|
||||||
.filter((t) => t)
|
.filter((t) => /^\d+[a-z]?$/.test(t))
|
||||||
.map((t) => `0${t}`);
|
.map((t) => normalizeTaskId(t));
|
||||||
|
|
||||||
if (!dependencies[taskIdPadded]) dependencies[taskIdPadded] = [];
|
if (!dependencies[taskIdPadded]) dependencies[taskIdPadded] = [];
|
||||||
dependencies[taskIdPadded].push(...depIds);
|
for (const depId of depIds) {
|
||||||
|
if (!dependencies[taskIdPadded].includes(depId)) {
|
||||||
|
dependencies[taskIdPadded].push(depId);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse meta blocks for task configuration (timeout, etc.)
|
// Parse meta blocks for task configuration (timeout, etc.)
|
||||||
const metaMatch = line.match(
|
const metaMatch = line.match(
|
||||||
/^0?(\d+)\s+\[timeout\]\s*=?\s*(\d+)(?:m|min|s|ms)?/i,
|
/^0?(\d+[a-z]?)\s+\[timeout\]\s*=?\s*(\d+)(?:m|min|s|ms)?/i,
|
||||||
);
|
);
|
||||||
if (metaMatch) {
|
if (metaMatch) {
|
||||||
const [, taskId, value, unit] = metaMatch;
|
const [, taskId, value, unit] = metaMatch;
|
||||||
const task = tasks.find((t) => t.id === `0${taskId}`);
|
const task = tasks.find((t) => t.id === normalizeTaskId(taskId));
|
||||||
if (task) {
|
if (task) {
|
||||||
task.timeoutMs = parseTimeoutValue(Number(value), unit);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract exit criteria
|
// 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 exitCriteria: string[] = [];
|
||||||
const exitIdx = lines.findIndex((l) => /^##\s+Exit\s+Criteria/i.test(l));
|
const exitCriteriaRe = /^(?:##\s+)?Exit\s+Criteria/i;
|
||||||
|
const exitIdx = lines.findIndex((l) => exitCriteriaRe.test(l));
|
||||||
if (exitIdx >= 0) {
|
if (exitIdx >= 0) {
|
||||||
for (let i = exitIdx + 1; i < lines.length; i++) {
|
for (let i = exitIdx + 1; i < lines.length; i++) {
|
||||||
if (/^##\s/.test(lines[i])) break;
|
// Stop at any new section heading (##-style or plain)
|
||||||
|
if (/^##\s/.test(lines[i]) || isPlainSectionHeader(lines[i])) break;
|
||||||
const m = lines[i].match(/^-\s+(.+)$/);
|
const m = lines[i].match(/^-\s+(.+)$/);
|
||||||
if (m) exitCriteria.push(m[1].trim());
|
if (m) exitCriteria.push(m[1].trim());
|
||||||
}
|
}
|
||||||
@@ -164,9 +399,21 @@ function parseFioFormat(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply parallelGroup to tasks
|
||||||
|
for (const group of parallelGroups) {
|
||||||
|
for (const taskId of group.taskIds) {
|
||||||
|
const task = tasks.find((t) => t.id === taskId);
|
||||||
|
if (task) {
|
||||||
|
task.parallelGroup = group.index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tasks,
|
tasks,
|
||||||
dependencies,
|
dependencies,
|
||||||
|
parallelGroups: parallelGroups.length > 0 ? parallelGroups : undefined,
|
||||||
|
phases: phases.length > 0 ? phases : undefined,
|
||||||
sourcePath,
|
sourcePath,
|
||||||
sourceDir,
|
sourceDir,
|
||||||
exitCriteria,
|
exitCriteria,
|
||||||
@@ -210,16 +457,7 @@ function parseYaml(
|
|||||||
sourcePath: string,
|
sourcePath: string,
|
||||||
sourceDir: string,
|
sourceDir: string,
|
||||||
): Project {
|
): Project {
|
||||||
// Lazy-load yaml (may not be installed)
|
const YAML = loadYaml();
|
||||||
let YAML: typeof import("yaml");
|
|
||||||
try {
|
|
||||||
YAML = require("yaml");
|
|
||||||
} catch {
|
|
||||||
throw new Error(
|
|
||||||
"YAML parsing requires the 'yaml' package. Run: npm install yaml",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const doc = YAML.parse(content);
|
const doc = YAML.parse(content);
|
||||||
const tasks: Task[] = [];
|
const tasks: Task[] = [];
|
||||||
|
|
||||||
@@ -263,35 +501,119 @@ export function readTaskSpec(taskDir: string, taskFile: string): string {
|
|||||||
// ─── Task File Updater ───────────────────────────────────────────────────────
|
// ─── 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(
|
export function updateTaskInFile(
|
||||||
filePath: string,
|
filePath: string,
|
||||||
taskId: string,
|
taskId: string,
|
||||||
status: Task["status"],
|
status: Task["status"],
|
||||||
): void {
|
): void {
|
||||||
let content = fs.readFileSync(filePath, "utf-8");
|
const ext = path.extname(filePath).toLowerCase();
|
||||||
const char = statusToChar(status);
|
|
||||||
|
|
||||||
// Try Fio numbered format first
|
// Handle YAML format
|
||||||
const fioPattern = new RegExp(
|
if (ext === ".yaml" || ext === ".yml") {
|
||||||
`(^-\\s+\\[)(.)(\\]\\s+${escapeRegex(taskId)}\\s*[—–-])`,
|
updateTaskInYaml(filePath, taskId, status);
|
||||||
"m",
|
|
||||||
);
|
|
||||||
if (fioPattern.test(content)) {
|
|
||||||
content = content.replace(fioPattern, `$1${char}$3`);
|
|
||||||
fs.writeFileSync(filePath, content, "utf-8");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try simple checkbox format
|
let content = fs.readFileSync(filePath, "utf-8");
|
||||||
const simplePattern = new RegExp(
|
const char = statusToChar(status);
|
||||||
`(-\\s+\\[)(.)(\\]\\s+${escapeRegex(taskId)})`,
|
|
||||||
"m",
|
// 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
|
||||||
if (simplePattern.test(content)) {
|
// pre-lettered behavior). Lettered IDs ("02b", "02c") only have one valid
|
||||||
content = content.replace(simplePattern, `$1${char}$3`);
|
// form — the parseInt fallback would silently drop the letter suffix and
|
||||||
fs.writeFileSync(filePath, content, "utf-8");
|
// 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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -392,6 +714,28 @@ function parseTimeoutFromMeta(
|
|||||||
return undefined;
|
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"] {
|
function charToStatus(char: string): Task["status"] {
|
||||||
switch (char) {
|
switch (char) {
|
||||||
case " ":
|
case " ":
|
||||||
|
|||||||
468
src/progress.ts
468
src/progress.ts
@@ -1,11 +1,11 @@
|
|||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import type {
|
import type {
|
||||||
ProgressState,
|
ProgressState,
|
||||||
PRDProgress,
|
PRDProgress,
|
||||||
Task,
|
Task,
|
||||||
Reflection,
|
Reflection,
|
||||||
ToolUsage,
|
ToolUsage,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { ensureDir } from "./utils";
|
import { ensureDir } from "./utils";
|
||||||
|
|
||||||
@@ -14,11 +14,11 @@ import { ensureDir } from "./utils";
|
|||||||
* e.g., "tasks/feature-x/README.md" → "tasks-feature-x-README"
|
* e.g., "tasks/feature-x/README.md" → "tasks-feature-x-README"
|
||||||
*/
|
*/
|
||||||
export function derivePRDKey(projectDir: string, sourcePath: string): string {
|
export function derivePRDKey(projectDir: string, sourcePath: string): string {
|
||||||
const rel = path.relative(projectDir, sourcePath);
|
const rel = path.relative(projectDir, sourcePath);
|
||||||
return rel
|
return rel
|
||||||
.replace(/[^a-zA-Z0-9_-]/g, "-")
|
.replace(/[^a-zA-Z0-9_-]/g, "-")
|
||||||
.replace(/-+/g, "-")
|
.replace(/-+/g, "-")
|
||||||
.replace(/^-|-$/g, "");
|
.replace(/^-|-$/g, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -28,250 +28,256 @@ export function derivePRDKey(projectDir: string, sourcePath: string): string {
|
|||||||
* Falls back to legacy flat format for backward compatibility.
|
* Falls back to legacy flat format for backward compatibility.
|
||||||
*/
|
*/
|
||||||
export class ProgressTracker {
|
export class ProgressTracker {
|
||||||
private statePath: string;
|
private statePath: string;
|
||||||
private state: ProgressState;
|
private state: ProgressState;
|
||||||
private prdKey: string;
|
private prdKey: string;
|
||||||
|
|
||||||
constructor(projectDir: string, sourcePath: string, prdKey?: string) {
|
constructor(projectDir: string, sourcePath: string, prdKey?: string) {
|
||||||
const stateDir = path.join(projectDir, ".ralpi");
|
const stateDir = path.join(projectDir, ".ralpi");
|
||||||
ensureDir(stateDir);
|
ensureDir(stateDir);
|
||||||
this.statePath = path.join(stateDir, "progress.json");
|
this.statePath = path.join(stateDir, "progress.json");
|
||||||
this.prdKey = prdKey ?? derivePRDKey(projectDir, sourcePath);
|
this.prdKey = prdKey ?? derivePRDKey(projectDir, sourcePath);
|
||||||
this.state = this.loadOrCreate(sourcePath);
|
this.state = this.loadOrCreate(sourcePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Load existing state or create a fresh one */
|
/** Load existing state or create a fresh one */
|
||||||
private loadOrCreate(sourcePathHint: string): ProgressState {
|
private loadOrCreate(sourcePathHint: string): ProgressState {
|
||||||
if (fs.existsSync(this.statePath)) {
|
if (fs.existsSync(this.statePath)) {
|
||||||
try {
|
try {
|
||||||
const raw = fs.readFileSync(this.statePath, "utf-8");
|
const raw = fs.readFileSync(this.statePath, "utf-8");
|
||||||
const parsed = JSON.parse(raw) as ProgressState;
|
const parsed = JSON.parse(raw) as ProgressState;
|
||||||
|
|
||||||
// Multi-PRD mode: check if we have a PRD entry
|
// Multi-PRD mode: check if we have a PRD entry
|
||||||
if (parsed.prds?.[this.prdKey]) {
|
if (parsed.prds?.[this.prdKey]) {
|
||||||
// Found PRD entry — use it, but keep legacy fields for compat
|
// Found PRD entry — use it, but keep legacy fields for compat
|
||||||
return parsed;
|
return parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Legacy flat mode: check if the source path matches
|
// Legacy flat mode: check if the source path matches
|
||||||
if (path.resolve(parsed.sourcePath) === path.resolve(sourcePathHint)) {
|
if (path.resolve(parsed.sourcePath) === path.resolve(sourcePathHint)) {
|
||||||
// Migrate legacy state to PRD mode
|
// Migrate legacy state to PRD mode
|
||||||
parsed.prds = {
|
parsed.prds = {
|
||||||
[this.prdKey]: {
|
[this.prdKey]: {
|
||||||
sourcePath: parsed.sourcePath,
|
sourcePath: parsed.sourcePath,
|
||||||
tasks: parsed.tasks,
|
tasks: parsed.tasks,
|
||||||
startedAt: parsed.startedAt,
|
startedAt: parsed.startedAt,
|
||||||
lastUpdatedAt: parsed.lastUpdatedAt,
|
lastUpdatedAt: parsed.lastUpdatedAt,
|
||||||
paused: parsed.paused,
|
paused: parsed.paused,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
return parsed;
|
return parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Different PRD — create new entry alongside existing ones
|
// Different PRD — create new entry alongside existing ones
|
||||||
if (parsed.prds) {
|
if (parsed.prds) {
|
||||||
parsed.prds[this.prdKey] = this.freshPRD(sourcePathHint);
|
parsed.prds[this.prdKey] = this.freshPRD(sourcePathHint);
|
||||||
return parsed;
|
return parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Legacy flat state exists but for a different source — promote it to PRD mode
|
// Legacy flat state exists but for a different source — promote it to PRD mode
|
||||||
const legacyKey = derivePRDKey(
|
const legacyKey = derivePRDKey(
|
||||||
path.dirname(this.statePath),
|
path.dirname(this.statePath),
|
||||||
parsed.sourcePath,
|
parsed.sourcePath,
|
||||||
);
|
);
|
||||||
parsed.prds = {
|
parsed.prds = {
|
||||||
[legacyKey]: {
|
[legacyKey]: {
|
||||||
sourcePath: parsed.sourcePath,
|
sourcePath: parsed.sourcePath,
|
||||||
tasks: parsed.tasks,
|
tasks: parsed.tasks,
|
||||||
startedAt: parsed.startedAt,
|
startedAt: parsed.startedAt,
|
||||||
lastUpdatedAt: parsed.lastUpdatedAt,
|
lastUpdatedAt: parsed.lastUpdatedAt,
|
||||||
paused: parsed.paused,
|
paused: parsed.paused,
|
||||||
},
|
},
|
||||||
[this.prdKey]: this.freshPRD(sourcePathHint),
|
[this.prdKey]: this.freshPRD(sourcePathHint),
|
||||||
};
|
};
|
||||||
return parsed;
|
return parsed;
|
||||||
} catch {
|
} catch {
|
||||||
// Fall through to create new
|
// Fall through to create new
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.freshState(sourcePathHint);
|
return this.freshState(sourcePathHint);
|
||||||
}
|
}
|
||||||
|
|
||||||
private freshPRD(sourcePath: string): PRDProgress {
|
private freshPRD(sourcePath: string): PRDProgress {
|
||||||
return {
|
return {
|
||||||
sourcePath,
|
sourcePath,
|
||||||
tasks: {},
|
tasks: {},
|
||||||
startedAt: new Date().toISOString(),
|
startedAt: new Date().toISOString(),
|
||||||
lastUpdatedAt: new Date().toISOString(),
|
lastUpdatedAt: new Date().toISOString(),
|
||||||
paused: false,
|
paused: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private freshState(sourcePath: string): ProgressState {
|
private freshState(sourcePath: string): ProgressState {
|
||||||
return {
|
return {
|
||||||
sourcePath,
|
sourcePath,
|
||||||
tasks: {},
|
tasks: {},
|
||||||
startedAt: new Date().toISOString(),
|
startedAt: new Date().toISOString(),
|
||||||
lastUpdatedAt: new Date().toISOString(),
|
lastUpdatedAt: new Date().toISOString(),
|
||||||
paused: false,
|
paused: false,
|
||||||
prds: {
|
prds: {
|
||||||
[this.prdKey]: {
|
[this.prdKey]: {
|
||||||
sourcePath,
|
sourcePath,
|
||||||
tasks: {},
|
tasks: {},
|
||||||
startedAt: new Date().toISOString(),
|
startedAt: new Date().toISOString(),
|
||||||
lastUpdatedAt: new Date().toISOString(),
|
lastUpdatedAt: new Date().toISOString(),
|
||||||
paused: false,
|
paused: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get the PRD-scoped progress entry */
|
/** Get the PRD-scoped progress entry */
|
||||||
private getPRD(): PRDProgress {
|
private getPRD(): PRDProgress {
|
||||||
if (!this.state.prds) {
|
if (!this.state.prds) {
|
||||||
// Should not happen after loadOrCreate, but guard anyway
|
// Should not happen after loadOrCreate, but guard anyway
|
||||||
this.state.prds = { [this.prdKey]: this.freshPRD(this.state.sourcePath) };
|
this.state.prds = { [this.prdKey]: this.freshPRD(this.state.sourcePath) };
|
||||||
}
|
}
|
||||||
if (!this.state.prds[this.prdKey]) {
|
if (!this.state.prds[this.prdKey]) {
|
||||||
this.state.prds[this.prdKey] = this.freshPRD(this.state.sourcePath);
|
this.state.prds[this.prdKey] = this.freshPRD(this.state.sourcePath);
|
||||||
}
|
}
|
||||||
return this.state.prds[this.prdKey];
|
return this.state.prds[this.prdKey];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Save current state to disk */
|
/** Save current state to disk */
|
||||||
save(): void {
|
save(): void {
|
||||||
const prd = this.getPRD();
|
const prd = this.getPRD();
|
||||||
prd.lastUpdatedAt = new Date().toISOString();
|
prd.lastUpdatedAt = new Date().toISOString();
|
||||||
// Sync legacy flat fields with current PRD for backward compat
|
// Sync legacy flat fields with current PRD for backward compat
|
||||||
this.state.sourcePath = prd.sourcePath;
|
this.state.sourcePath = prd.sourcePath;
|
||||||
this.state.tasks = prd.tasks;
|
this.state.tasks = prd.tasks;
|
||||||
this.state.startedAt = prd.startedAt;
|
this.state.startedAt = prd.startedAt;
|
||||||
this.state.lastUpdatedAt = prd.lastUpdatedAt;
|
this.state.lastUpdatedAt = prd.lastUpdatedAt;
|
||||||
this.state.paused = prd.paused;
|
this.state.paused = prd.paused;
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
this.statePath,
|
this.statePath,
|
||||||
JSON.stringify(this.state, null, 2),
|
JSON.stringify(this.state, null, 2),
|
||||||
"utf-8",
|
"utf-8",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Mark a task as in progress */
|
/** Mark a task as in progress */
|
||||||
markInProgress(taskId: string): void {
|
markInProgress(taskId: string): void {
|
||||||
const prd = this.getPRD();
|
const prd = this.getPRD();
|
||||||
this.ensureTask(prd, taskId);
|
this.ensureTask(prd, taskId);
|
||||||
prd.tasks[taskId].status = "in_progress";
|
prd.tasks[taskId].status = "in_progress";
|
||||||
prd.tasks[taskId].startedAt = new Date().toISOString();
|
prd.tasks[taskId].startedAt = new Date().toISOString();
|
||||||
this.save();
|
this.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Mark a task as completed */
|
/** Mark a task as completed */
|
||||||
markCompleted(
|
markCompleted(
|
||||||
taskId: string,
|
taskId: string,
|
||||||
durationMs: number,
|
durationMs: number,
|
||||||
reflection?: Reflection,
|
reflection?: Reflection,
|
||||||
toolUsage?: ToolUsage,
|
toolUsage?: ToolUsage,
|
||||||
sessionFile?: string,
|
outputPreview?: string,
|
||||||
outputPreview?: string,
|
commitMessages?: string[],
|
||||||
commitMessages?: string[],
|
commitSummary?: string,
|
||||||
commitSummary?: string,
|
): void {
|
||||||
): void {
|
const prd = this.getPRD();
|
||||||
const prd = this.getPRD();
|
this.ensureTask(prd, taskId);
|
||||||
this.ensureTask(prd, taskId);
|
prd.tasks[taskId].status = "completed";
|
||||||
prd.tasks[taskId].status = "completed";
|
prd.tasks[taskId].completedAt = new Date().toISOString();
|
||||||
prd.tasks[taskId].completedAt = new Date().toISOString();
|
prd.tasks[taskId].durationMs = durationMs;
|
||||||
prd.tasks[taskId].durationMs = durationMs;
|
if (reflection) prd.tasks[taskId].reflection = reflection;
|
||||||
if (reflection) prd.tasks[taskId].reflection = reflection;
|
if (toolUsage) prd.tasks[taskId].toolUsage = toolUsage;
|
||||||
if (toolUsage) prd.tasks[taskId].toolUsage = toolUsage;
|
if (outputPreview) prd.tasks[taskId].outputPreview = outputPreview;
|
||||||
if (sessionFile) prd.tasks[taskId].sessionFile = sessionFile;
|
if (commitMessages) prd.tasks[taskId].commitMessages = commitMessages;
|
||||||
if (outputPreview) prd.tasks[taskId].outputPreview = outputPreview;
|
if (commitSummary) prd.tasks[taskId].commitSummary = commitSummary;
|
||||||
if (commitMessages) prd.tasks[taskId].commitMessages = commitMessages;
|
this.save();
|
||||||
if (commitSummary) prd.tasks[taskId].commitSummary = commitSummary;
|
}
|
||||||
this.save();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Mark a task as failed */
|
/** Mark a task as failed */
|
||||||
markFailed(taskId: string, error: string): void {
|
markFailed(taskId: string, error: string): void {
|
||||||
const prd = this.getPRD();
|
const prd = this.getPRD();
|
||||||
this.ensureTask(prd, taskId);
|
this.ensureTask(prd, taskId);
|
||||||
prd.tasks[taskId].status = "failed";
|
prd.tasks[taskId].status = "failed";
|
||||||
prd.tasks[taskId].error = error;
|
prd.tasks[taskId].error = error;
|
||||||
this.save();
|
this.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get task status */
|
/** Get task status */
|
||||||
getTaskStatus(taskId: string): Task["status"] {
|
getTaskStatus(taskId: string): Task["status"] {
|
||||||
const prd = this.getPRD();
|
const prd = this.getPRD();
|
||||||
return prd.tasks[taskId]?.status ?? "pending";
|
return prd.tasks[taskId]?.status ?? "pending";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get IDs of all completed tasks */
|
/** Get IDs of all completed tasks */
|
||||||
getCompletedTaskIds(): string[] {
|
getCompletedTaskIds(): string[] {
|
||||||
const prd = this.getPRD();
|
const prd = this.getPRD();
|
||||||
return Object.entries(prd.tasks)
|
return Object.entries(prd.tasks)
|
||||||
.filter(([, info]) => info.status === "completed")
|
.filter(([, info]) => info.status === "completed")
|
||||||
.map(([id]) => id);
|
.map(([id]) => id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get all reflections from completed tasks */
|
/** Get IDs of all failed tasks */
|
||||||
getAllReflections(): Reflection[] {
|
getFailedTaskIds(): string[] {
|
||||||
const prd = this.getPRD();
|
const prd = this.getPRD();
|
||||||
const reflections: Reflection[] = [];
|
return Object.entries(prd.tasks)
|
||||||
for (const info of Object.values(prd.tasks)) {
|
.filter(([, info]) => info.status === "failed")
|
||||||
if (info.reflection) reflections.push(info.reflection);
|
.map(([id]) => id);
|
||||||
}
|
}
|
||||||
return reflections;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get reflections for specific dependency tasks */
|
/** Get all reflections from completed tasks */
|
||||||
getDependencyReflections(depIds: string[]): Reflection[] {
|
getAllReflections(): Reflection[] {
|
||||||
const prd = this.getPRD();
|
const prd = this.getPRD();
|
||||||
return depIds
|
const reflections: Reflection[] = [];
|
||||||
.map((id) => prd.tasks[id]?.reflection)
|
for (const info of Object.values(prd.tasks)) {
|
||||||
.filter((r): r is Reflection => r !== undefined);
|
if (info.reflection) reflections.push(info.reflection);
|
||||||
}
|
}
|
||||||
|
return reflections;
|
||||||
|
}
|
||||||
|
|
||||||
/** Increment retry count */
|
/** Get reflections for specific dependency tasks */
|
||||||
incrementRetry(taskId: string): number {
|
getDependencyReflections(depIds: string[]): Reflection[] {
|
||||||
const prd = this.getPRD();
|
const prd = this.getPRD();
|
||||||
this.ensureTask(prd, taskId);
|
return depIds
|
||||||
prd.tasks[taskId].retries++;
|
.map((id) => prd.tasks[id]?.reflection)
|
||||||
this.save();
|
.filter((r): r is Reflection => r !== undefined);
|
||||||
return prd.tasks[taskId].retries;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/** Set paused state */
|
/** Increment retry count */
|
||||||
setPaused(paused: boolean): void {
|
incrementRetry(taskId: string): number {
|
||||||
const prd = this.getPRD();
|
const prd = this.getPRD();
|
||||||
prd.paused = paused;
|
this.ensureTask(prd, taskId);
|
||||||
this.save();
|
prd.tasks[taskId].retries++;
|
||||||
}
|
this.save();
|
||||||
|
return prd.tasks[taskId].retries;
|
||||||
|
}
|
||||||
|
|
||||||
/** Get the raw PRD state (for status display) */
|
/** Set paused state */
|
||||||
getState(): PRDProgress {
|
setPaused(paused: boolean): void {
|
||||||
return this.getPRD();
|
const prd = this.getPRD();
|
||||||
}
|
prd.paused = paused;
|
||||||
|
this.save();
|
||||||
|
}
|
||||||
|
|
||||||
/** Get all PRDs (for multi-PRD status display) */
|
/** Get the raw PRD state (for status display) */
|
||||||
getAllPRDs(): Record<string, PRDProgress> {
|
getState(): PRDProgress {
|
||||||
return this.state.prds ?? {};
|
return this.getPRD();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get the PRD key for this tracker */
|
/** Get all PRDs (for multi-PRD status display) */
|
||||||
getKey(): string {
|
getAllPRDs(): Record<string, PRDProgress> {
|
||||||
return this.prdKey;
|
return this.state.prds ?? {};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reset all progress for this PRD */
|
/** Get the PRD key for this tracker */
|
||||||
reset(): void {
|
getKey(): string {
|
||||||
const prd = this.getPRD();
|
return this.prdKey;
|
||||||
Object.assign(prd, this.freshPRD(prd.sourcePath));
|
}
|
||||||
this.save();
|
|
||||||
}
|
|
||||||
|
|
||||||
private ensureTask(prd: PRDProgress, taskId: string): void {
|
/** Reset all progress for this PRD */
|
||||||
if (!prd.tasks[taskId]) {
|
reset(): void {
|
||||||
prd.tasks[taskId] = { status: "pending", retries: 0 };
|
const prd = this.getPRD();
|
||||||
}
|
Object.assign(prd, this.freshPRD(prd.sourcePath));
|
||||||
}
|
this.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureTask(prd: PRDProgress, taskId: string): void {
|
||||||
|
if (!prd.tasks[taskId]) {
|
||||||
|
prd.tasks[taskId] = { status: "pending", retries: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
39
src/types.ts
39
src/types.ts
@@ -27,6 +27,26 @@ export interface Task {
|
|||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
/** Original index in task list for deterministic ordering */
|
/** Original index in task list for deterministic ordering */
|
||||||
index?: number;
|
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 {
|
export interface Project {
|
||||||
@@ -36,6 +56,10 @@ export interface Project {
|
|||||||
tasks: Task[];
|
tasks: Task[];
|
||||||
/** Explicit dependency map: taskId → [dependency taskIds] */
|
/** Explicit dependency map: taskId → [dependency taskIds] */
|
||||||
dependencies: Record<string, string[]>;
|
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) */
|
/** Exit criteria (from README ## Exit Criteria section) */
|
||||||
exitCriteria?: string[];
|
exitCriteria?: string[];
|
||||||
/** Path to the source task file */
|
/** Path to the source task file */
|
||||||
@@ -97,8 +121,6 @@ export interface TaskProgressInfo {
|
|||||||
error?: string;
|
error?: string;
|
||||||
/** Tool usage counts from parsed subprocess output */
|
/** Tool usage counts from parsed subprocess output */
|
||||||
toolUsage?: ToolUsage;
|
toolUsage?: ToolUsage;
|
||||||
/** Path to session output file */
|
|
||||||
sessionFile?: string;
|
|
||||||
/** Truncated output preview for expanded view */
|
/** Truncated output preview for expanded view */
|
||||||
outputPreview?: string;
|
outputPreview?: string;
|
||||||
/** Git commit messages from task execution */
|
/** Git commit messages from task execution */
|
||||||
@@ -153,6 +175,8 @@ export interface RalpiConfig {
|
|||||||
timeoutMs: number;
|
timeoutMs: number;
|
||||||
/** Maximum parallel tasks (0 = unlimited) */
|
/** Maximum parallel tasks (0 = unlimited) */
|
||||||
maxParallel: number;
|
maxParallel: number;
|
||||||
|
/** Round-robin model list for parallel tasks (empty = inherit parent model) */
|
||||||
|
models: string[];
|
||||||
};
|
};
|
||||||
prompts: {
|
prompts: {
|
||||||
/** Additional context injected into every task prompt */
|
/** Additional context injected into every task prompt */
|
||||||
@@ -160,6 +184,10 @@ export interface RalpiConfig {
|
|||||||
/** Custom prompt suffix for reflection extraction */
|
/** Custom prompt suffix for reflection extraction */
|
||||||
reflectionPrompt: string;
|
reflectionPrompt: string;
|
||||||
};
|
};
|
||||||
|
/** Parent session model to inherit in child agent sessions */
|
||||||
|
model?: unknown;
|
||||||
|
/** Parent session thinking level to inherit in child agent sessions */
|
||||||
|
thinkingLevel?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_CONFIG: RalpiConfig = {
|
export const DEFAULT_CONFIG: RalpiConfig = {
|
||||||
@@ -168,10 +196,11 @@ export const DEFAULT_CONFIG: RalpiConfig = {
|
|||||||
reflectionsDir: ".ralpi/reflections",
|
reflectionsDir: ".ralpi/reflections",
|
||||||
},
|
},
|
||||||
execution: {
|
execution: {
|
||||||
maxRetries: 3,
|
maxRetries: 0,
|
||||||
retryDelayMs: 5000,
|
retryDelayMs: 0,
|
||||||
timeoutMs: 30 * 60 * 1000, // 30 minutes
|
timeoutMs: 0, // 0 = inherit Pi's own defaults (no ralpi-level timeout)
|
||||||
maxParallel: 3,
|
maxParallel: 3,
|
||||||
|
models: [],
|
||||||
},
|
},
|
||||||
prompts: {
|
prompts: {
|
||||||
projectContext: "",
|
projectContext: "",
|
||||||
|
|||||||
262
src/utils.ts
262
src/utils.ts
@@ -34,6 +34,78 @@ export function writeFileSafe(filePath: string, content: string): void {
|
|||||||
fs.writeFileSync(filePath, content, "utf-8");
|
fs.writeFileSync(filePath, content, "utf-8");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Loop-Active State ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* State persisted to disk when a ralpi execution loop is active.
|
||||||
|
* Used to re-instantiate widgets after a session reload.
|
||||||
|
*/
|
||||||
|
export interface LoopActiveState {
|
||||||
|
taskFile: string;
|
||||||
|
mode: "parallel" | "sequential";
|
||||||
|
startedAt: string;
|
||||||
|
taskIds: string[];
|
||||||
|
prdKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Path (relative to projectDir) where the loop-active marker is stored.
|
||||||
|
*/
|
||||||
|
const LOOP_ACTIVE_FILE = ".ralpi/loop-active.json";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write the loop-active marker, indicating an execution loop is running.
|
||||||
|
*/
|
||||||
|
export function writeLoopActive(
|
||||||
|
projectDir: string,
|
||||||
|
state: LoopActiveState,
|
||||||
|
): void {
|
||||||
|
writeFileSafe(
|
||||||
|
path.join(projectDir, LOOP_ACTIVE_FILE),
|
||||||
|
JSON.stringify(state, null, 2),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the loop-active marker, if present.
|
||||||
|
*/
|
||||||
|
export function readLoopActive(projectDir: string): LoopActiveState | null {
|
||||||
|
const filePath = path.join(projectDir, LOOP_ACTIVE_FILE);
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(filePath, "utf-8");
|
||||||
|
return JSON.parse(raw) as LoopActiveState;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete the loop-active marker.
|
||||||
|
*/
|
||||||
|
export function deleteLoopActive(projectDir: string): void {
|
||||||
|
const filePath = path.join(projectDir, LOOP_ACTIVE_FILE);
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(filePath);
|
||||||
|
} catch {
|
||||||
|
// Ignore if already gone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discover the project directory by walking up to find `.ralpi/`.
|
||||||
|
*/
|
||||||
|
export function findRalpiDir(startDir: string): string | null {
|
||||||
|
let current = path.resolve(startDir);
|
||||||
|
const root = path.parse(current).root;
|
||||||
|
while (current !== root) {
|
||||||
|
if (fs.existsSync(path.join(current, ".ralpi"))) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
current = path.dirname(current);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Async Agent Session ────────────────────────────────────────────────────
|
// ─── Async Agent Session ────────────────────────────────────────────────────
|
||||||
|
|
||||||
// ─── Progress Discovery ─────────────────────────────────────────────────────
|
// ─── Progress Discovery ─────────────────────────────────────────────────────
|
||||||
@@ -82,32 +154,35 @@ export function findProgressFile(
|
|||||||
|
|
||||||
// ─── Config ──────────────────────────────────────────────────────────────────
|
// ─── Config ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function parseSimpleYaml(content: string): Record<string, any> {
|
/** Try to use the `yaml` package (real dependency in package.json).
|
||||||
const result: Record<string, any> = {};
|
* Falls back to a flat key:value parser when unavailable. */
|
||||||
const lines = content.split("\n");
|
const parseSimpleYaml: (content: string) => Record<string, any> = (() => {
|
||||||
|
try {
|
||||||
for (const line of lines) {
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
const trimmed = line.trim();
|
const { parse } = require("yaml");
|
||||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
return (content: string) => parse(content) ?? {};
|
||||||
|
} catch {
|
||||||
const match = trimmed.match(/^([^:]+):\s*(.+)$/);
|
return (content: string) => {
|
||||||
if (match) {
|
const result: Record<string, any> = {};
|
||||||
const key = match[1].trim();
|
for (const line of content.split("\n")) {
|
||||||
let value: string | boolean | number = match[2].trim();
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||||
// Parse booleans
|
const match = trimmed.match(/^([^:]+):\s*(.*)$/);
|
||||||
if (value === "true") value = true;
|
if (match) {
|
||||||
else if (value === "false") value = false;
|
const value = match[2].trim();
|
||||||
// Parse numbers
|
if (value === "true") result[match[1].trim()] = true;
|
||||||
else if (/^\d+$/.test(value)) value = parseInt(value, 10);
|
else if (value === "false") result[match[1].trim()] = false;
|
||||||
else if (/^\d+\.\d+$/.test(value)) value = parseFloat(value);
|
else if (/^\d+$/.test(value))
|
||||||
|
result[match[1].trim()] = parseInt(value, 10);
|
||||||
result[key] = value;
|
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
|
* Deep merge configuration objects
|
||||||
@@ -129,25 +204,44 @@ function mergeConfig(
|
|||||||
return result as RalpiConfig;
|
return result as RalpiConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Path to the global ralpi config under the user's Pi home directory. */
|
||||||
|
const GLOBAL_CONFIG_PATH = path.join(
|
||||||
|
process.env.HOME || "/tmp",
|
||||||
|
".pi",
|
||||||
|
"ralpi",
|
||||||
|
"config.yaml",
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load configuration from .ralpi/config.yaml or return defaults
|
* Load and merge config from global and project sources.
|
||||||
|
*
|
||||||
|
* Precedence (highest wins):
|
||||||
|
* 1. Project-level: `<projectDir>/.ralpi/config.yaml`
|
||||||
|
* 2. Global: `~/.pi/ralpi/config.yaml`
|
||||||
|
* 3. `DEFAULT_CONFIG` in `src/types.ts`
|
||||||
*/
|
*/
|
||||||
export function loadConfig(projectDir: string): RalpiConfig {
|
export function loadConfig(projectDir: string): RalpiConfig {
|
||||||
const configPath = path.join(projectDir, ".ralpi", "config.yaml");
|
// Start with defaults
|
||||||
|
const merged: RalpiConfig = { ...DEFAULT_CONFIG };
|
||||||
|
|
||||||
// Return defaults silently when config file does not exist
|
// Layer 1: global config (~/.pi/ralpi/config.yaml)
|
||||||
if (!fs.existsSync(configPath)) {
|
tryLoadConfigFile(GLOBAL_CONFIG_PATH, merged);
|
||||||
return { ...DEFAULT_CONFIG };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
// Layer 2: project config (.ralpi/config.yaml) — overrides global
|
||||||
const content = fs.readFileSync(configPath, "utf-8");
|
tryLoadConfigFile(path.join(projectDir, ".ralpi", "config.yaml"), merged);
|
||||||
// Simple YAML parsing (key: value format)
|
|
||||||
const config = parseSimpleYaml(content);
|
return merged;
|
||||||
return mergeConfig(DEFAULT_CONFIG, config);
|
|
||||||
} catch {
|
/** Attempt to load a single config file and merge into `acc` in place. */
|
||||||
// Malformed config — fall back to defaults silently
|
function tryLoadConfigFile(filePath: string, acc: RalpiConfig): void {
|
||||||
return { ...DEFAULT_CONFIG };
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,7 +432,8 @@ export async function runAgentSession(
|
|||||||
timeoutMs: number,
|
timeoutMs: number,
|
||||||
onEvent?: (event: AgentSessionEvent) => void,
|
onEvent?: (event: AgentSessionEvent) => void,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
sessionFile?: string,
|
model?: unknown,
|
||||||
|
thinkingLevel?: unknown,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
text: string;
|
text: string;
|
||||||
@@ -354,17 +449,13 @@ export async function runAgentSession(
|
|||||||
bash: 0,
|
bash: 0,
|
||||||
other: 0,
|
other: 0,
|
||||||
};
|
};
|
||||||
// Stream events to file instead of accumulating in memory.
|
// Wire timeout via abort signal (only when set; 0 means inherit Pi's defaults)
|
||||||
// Accumulating caused "Invalid string length" crashes when
|
let timeoutHandle: NodeJS.Timeout | null = null;
|
||||||
// JSON.stringify(output.events, null, 2) produced 300+ MB strings.
|
if (timeoutMs > 0) {
|
||||||
const eventStream = sessionFile
|
timeoutHandle = setTimeout(() => {
|
||||||
? fs.createWriteStream(sessionFile, { flags: "a" })
|
if (sessionRef?.session) sessionRef.session.agent.abort();
|
||||||
: null;
|
}, timeoutMs);
|
||||||
|
}
|
||||||
// Wire timeout via abort signal
|
|
||||||
const timeoutHandle = setTimeout(() => {
|
|
||||||
if (sessionRef?.session) sessionRef.session.agent.abort();
|
|
||||||
}, timeoutMs);
|
|
||||||
|
|
||||||
const sessionRef: {
|
const sessionRef: {
|
||||||
session?: Awaited<ReturnType<typeof createAgentSession>>["session"];
|
session?: Awaited<ReturnType<typeof createAgentSession>>["session"];
|
||||||
@@ -387,6 +478,8 @@ export async function runAgentSession(
|
|||||||
sessionManager: SessionManager.inMemory(),
|
sessionManager: SessionManager.inMemory(),
|
||||||
resourceLoader: loader,
|
resourceLoader: loader,
|
||||||
tools: ["read", "bash", "edit", "write", "grep", "find", "ls"],
|
tools: ["read", "bash", "edit", "write", "grep", "find", "ls"],
|
||||||
|
model: model as any,
|
||||||
|
thinkingLevel: thinkingLevel as any,
|
||||||
});
|
});
|
||||||
sessionRef.session = result.session;
|
sessionRef.session = result.session;
|
||||||
|
|
||||||
@@ -399,10 +492,6 @@ export async function runAgentSession(
|
|||||||
let stopReason: string | undefined;
|
let stopReason: string | undefined;
|
||||||
|
|
||||||
const unsubscribe = result.session.subscribe((event) => {
|
const unsubscribe = result.session.subscribe((event) => {
|
||||||
// Stream event to file (avoids accumulating 300+ MB in memory)
|
|
||||||
if (eventStream) {
|
|
||||||
eventStream.write(JSON.stringify(event) + "\n");
|
|
||||||
}
|
|
||||||
onEvent?.(event);
|
onEvent?.(event);
|
||||||
|
|
||||||
if (event.type === "message_end") {
|
if (event.type === "message_end") {
|
||||||
@@ -437,12 +526,7 @@ export async function runAgentSession(
|
|||||||
unsubscribe();
|
unsubscribe();
|
||||||
result.session.dispose();
|
result.session.dispose();
|
||||||
signal?.removeEventListener("abort", abortHandler);
|
signal?.removeEventListener("abort", abortHandler);
|
||||||
clearTimeout(timeoutHandle);
|
if (timeoutHandle) clearTimeout(timeoutHandle);
|
||||||
|
|
||||||
// Flush and close the event stream before returning
|
|
||||||
if (eventStream) {
|
|
||||||
await new Promise<void>((resolve) => eventStream.end(resolve));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (errorMessage && !finalText) {
|
if (errorMessage && !finalText) {
|
||||||
return {
|
return {
|
||||||
@@ -460,19 +544,16 @@ export async function runAgentSession(
|
|||||||
text: finalText.trim(),
|
text: finalText.trim(),
|
||||||
toolUsage,
|
toolUsage,
|
||||||
stopReason,
|
stopReason,
|
||||||
events: [], // streamed to file
|
events: [],
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
clearTimeout(timeoutHandle);
|
if (timeoutHandle) clearTimeout(timeoutHandle);
|
||||||
if (eventStream && !eventStream.destroyed) {
|
|
||||||
eventStream.end();
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
text: "",
|
text: "",
|
||||||
error: error instanceof Error ? error.message : String(error),
|
error: error instanceof Error ? error.message : String(error),
|
||||||
toolUsage,
|
toolUsage,
|
||||||
events: [], // streamed to file
|
events: [],
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
sessionRef.session?.dispose();
|
sessionRef.session?.dispose();
|
||||||
@@ -498,6 +579,53 @@ function extractAssistantText(content: unknown): string {
|
|||||||
|
|
||||||
// ─── Git Commit Capture ──────────────────────────────────────────────────────
|
// ─── 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
|
* Capture recent git commits made during task execution
|
||||||
* Returns commit messages and a summary string
|
* Returns commit messages and a summary string
|
||||||
|
|||||||
674
tests/dag-construction.test.ts
Normal file
674
tests/dag-construction.test.ts
Normal file
@@ -0,0 +1,674 @@
|
|||||||
|
/// <reference types="bun-types" />
|
||||||
|
import { describe, it, expect } from "bun:test";
|
||||||
|
import type { Project, Task } from "../src/types";
|
||||||
|
import {
|
||||||
|
buildExecutionPlan,
|
||||||
|
buildSequentialPlan,
|
||||||
|
getBlockedTasks,
|
||||||
|
detectCycles,
|
||||||
|
getCriticalPath,
|
||||||
|
formatDependencyChain,
|
||||||
|
formatExecutionPlan,
|
||||||
|
} from "../src/dag";
|
||||||
|
|
||||||
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function makeProject(overrides?: Partial<Project>): Project {
|
||||||
|
return {
|
||||||
|
tasks: [],
|
||||||
|
dependencies: {},
|
||||||
|
sourcePath: "/tmp/test.md",
|
||||||
|
sourceDir: "/tmp",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function task(
|
||||||
|
id: string,
|
||||||
|
dependencies: string[] = [],
|
||||||
|
status: Task["status"] = "pending",
|
||||||
|
parallelGroup?: number,
|
||||||
|
): Task {
|
||||||
|
return { id, title: `Task ${id}`, status, dependencies, parallelGroup };
|
||||||
|
}
|
||||||
|
|
||||||
|
function tasksFrom(...args: Task[]): Task[] {
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Basic DAG Construction ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("buildExecutionPlan (Kahn's algorithm)", () => {
|
||||||
|
it("handles empty task list", () => {
|
||||||
|
const project = makeProject({ tasks: [] });
|
||||||
|
const plan = buildExecutionPlan(project, new Set());
|
||||||
|
expect(plan.batches).toEqual([]);
|
||||||
|
expect(plan.totalTasks).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts all root tasks in batch 0", () => {
|
||||||
|
const project = makeProject({
|
||||||
|
tasks: tasksFrom(task("01"), task("02"), task("03")),
|
||||||
|
});
|
||||||
|
const plan = buildExecutionPlan(project, new Set());
|
||||||
|
expect(plan.batches).toHaveLength(1);
|
||||||
|
expect(plan.batches[0].tasks.map((t) => t.id).sort()).toEqual([
|
||||||
|
"01",
|
||||||
|
"02",
|
||||||
|
"03",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds correct linear dependency chain", () => {
|
||||||
|
const project = makeProject({
|
||||||
|
tasks: tasksFrom(
|
||||||
|
task("01"),
|
||||||
|
task("02", ["01"]),
|
||||||
|
task("03", ["02"]),
|
||||||
|
task("04", ["03"]),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
const plan = buildExecutionPlan(project, new Set());
|
||||||
|
expect(plan.batches).toHaveLength(4);
|
||||||
|
expect(plan.batches[0].tasks.map((t) => t.id)).toEqual(["01"]);
|
||||||
|
expect(plan.batches[1].tasks.map((t) => t.id)).toEqual(["02"]);
|
||||||
|
expect(plan.batches[2].tasks.map((t) => t.id)).toEqual(["03"]);
|
||||||
|
expect(plan.batches[3].tasks.map((t) => t.id)).toEqual(["04"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("groups parallelizable tasks in the same batch", () => {
|
||||||
|
// Diamond: 01 -> 02, 03 -> 04
|
||||||
|
const project = makeProject({
|
||||||
|
tasks: tasksFrom(
|
||||||
|
task("01"),
|
||||||
|
task("02", ["01"]),
|
||||||
|
task("03", ["01"]),
|
||||||
|
task("04", ["02", "03"]),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
const plan = buildExecutionPlan(project, new Set());
|
||||||
|
// Batch 0: [01], Batch 1: [02, 03], Batch 2: [04]
|
||||||
|
expect(plan.batches).toHaveLength(3);
|
||||||
|
expect(plan.batches[0].tasks.map((t) => t.id)).toEqual(["01"]);
|
||||||
|
expect(plan.batches[1].tasks.map((t) => t.id).sort()).toEqual(["02", "03"]);
|
||||||
|
expect(plan.batches[2].tasks.map((t) => t.id)).toEqual(["04"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("assigns correct batchIndex values", () => {
|
||||||
|
const project = makeProject({
|
||||||
|
tasks: tasksFrom(
|
||||||
|
task("01"),
|
||||||
|
task("02", ["01"]),
|
||||||
|
task("03", ["01"]),
|
||||||
|
task("04", ["02", "03"]),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
const plan = buildExecutionPlan(project, new Set());
|
||||||
|
expect(plan.batches[0].batchIndex).toBe(0);
|
||||||
|
expect(plan.batches[1].batchIndex).toBe(1);
|
||||||
|
expect(plan.batches[2].batchIndex).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips completed tasks and includes them in skippedTasks", () => {
|
||||||
|
const project = makeProject({
|
||||||
|
tasks: tasksFrom(
|
||||||
|
task("01", [], "completed"),
|
||||||
|
task("02", ["01"]),
|
||||||
|
task("03", ["02"]),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
const plan = buildExecutionPlan(project, new Set(["01"]));
|
||||||
|
expect(plan.totalTasks).toBe(2);
|
||||||
|
expect(plan.skippedTasks.map((t) => t.id)).toEqual(["01"]);
|
||||||
|
expect(plan.batches).toHaveLength(2);
|
||||||
|
expect(plan.batches[0].tasks.map((t) => t.id)).toEqual(["02"]);
|
||||||
|
expect(plan.batches[1].tasks.map((t) => t.id)).toEqual(["03"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws on dependency cycle", () => {
|
||||||
|
const project = makeProject({
|
||||||
|
tasks: tasksFrom(
|
||||||
|
task("01", ["03"]),
|
||||||
|
task("02", ["01"]),
|
||||||
|
task("03", ["02"]),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
expect(() => buildExecutionPlan(project, new Set())).toThrow(
|
||||||
|
/dependency cycle/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks tasks that depend on failed tasks", () => {
|
||||||
|
const project = makeProject({
|
||||||
|
tasks: tasksFrom(
|
||||||
|
task("01"),
|
||||||
|
task("02", ["01"]),
|
||||||
|
task("03", ["02"]),
|
||||||
|
task("04", ["03"]),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
const plan = buildExecutionPlan(
|
||||||
|
project,
|
||||||
|
new Set(),
|
||||||
|
undefined,
|
||||||
|
new Set(["01"]),
|
||||||
|
);
|
||||||
|
// 01 is excluded from pending (failed). 02, 03, 04 are pending but
|
||||||
|
// transitively blocked — they don't appear in batches.
|
||||||
|
expect(plan.skippedTasks.map((t) => t.id)).toEqual(["01"]);
|
||||||
|
expect(plan.totalTasks).toBe(3); // 02, 03, 04 are pending but blocked
|
||||||
|
expect(plan.batches).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks immediate dependents when task fails", () => {
|
||||||
|
const project = makeProject({
|
||||||
|
tasks: tasksFrom(
|
||||||
|
task("01"),
|
||||||
|
task("02", ["01"]),
|
||||||
|
task("03", ["01"]),
|
||||||
|
task("04"), // independent
|
||||||
|
),
|
||||||
|
});
|
||||||
|
const plan = buildExecutionPlan(
|
||||||
|
project,
|
||||||
|
new Set(),
|
||||||
|
undefined,
|
||||||
|
new Set(["01"]),
|
||||||
|
);
|
||||||
|
// 01 is excluded from pending (failed). 02, 03 are pending but blocked
|
||||||
|
// (depend on 01). 04 is independent and ready.
|
||||||
|
expect(plan.skippedTasks.map((t) => t.id)).toEqual(["01"]);
|
||||||
|
expect(plan.batches[0].tasks.map((t) => t.id)).toEqual(["04"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Complex DAGs ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("Complex DAG batching", () => {
|
||||||
|
it("builds the OAuth PRD example correctly", () => {
|
||||||
|
const project = makeProject({
|
||||||
|
tasks: tasksFrom(
|
||||||
|
task("01"),
|
||||||
|
task("02", ["01"]),
|
||||||
|
task("03", ["02"]),
|
||||||
|
task("04", ["01"]),
|
||||||
|
task("05", ["03", "04"]),
|
||||||
|
task("06", ["03", "04"]),
|
||||||
|
task("07", ["03"]),
|
||||||
|
task("08", ["05", "06", "07"]),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
const plan = buildExecutionPlan(project, new Set());
|
||||||
|
// Expected batches: [01], [02,04], [03], [05,06,07], [08]
|
||||||
|
expect(plan.batches).toHaveLength(5);
|
||||||
|
expect(plan.batches[0].tasks.map((t) => t.id)).toEqual(["01"]);
|
||||||
|
expect(plan.batches[1].tasks.map((t) => t.id).sort()).toEqual(["02", "04"]);
|
||||||
|
expect(plan.batches[2].tasks.map((t) => t.id)).toEqual(["03"]);
|
||||||
|
expect(plan.batches[3].tasks.map((t) => t.id).sort()).toEqual([
|
||||||
|
"05",
|
||||||
|
"06",
|
||||||
|
"07",
|
||||||
|
]);
|
||||||
|
expect(plan.batches[4].tasks.map((t) => t.id)).toEqual(["08"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds the Design Token PRD example correctly", () => {
|
||||||
|
const project = makeProject({
|
||||||
|
tasks: tasksFrom(
|
||||||
|
task("01"),
|
||||||
|
task("02", ["01"]),
|
||||||
|
task("03", ["01"]),
|
||||||
|
task("04", ["02", "03"]),
|
||||||
|
task("05", ["04", "01"]),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
const plan = buildExecutionPlan(project, new Set());
|
||||||
|
// Expected batches: [01], [02,03], [04], [05]
|
||||||
|
expect(plan.batches).toHaveLength(4);
|
||||||
|
expect(plan.batches[0].tasks.map((t) => t.id)).toEqual(["01"]);
|
||||||
|
expect(plan.batches[1].tasks.map((t) => t.id).sort()).toEqual(["02", "03"]);
|
||||||
|
expect(plan.batches[2].tasks.map((t) => t.id)).toEqual(["04"]);
|
||||||
|
expect(plan.batches[3].tasks.map((t) => t.id)).toEqual(["05"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles a 3-tier diamond", () => {
|
||||||
|
// 01
|
||||||
|
// / \
|
||||||
|
// 02 03
|
||||||
|
// / \ / \
|
||||||
|
// 04 05 06
|
||||||
|
// \ | /
|
||||||
|
// 07
|
||||||
|
const project = makeProject({
|
||||||
|
tasks: tasksFrom(
|
||||||
|
task("01"),
|
||||||
|
task("02", ["01"]),
|
||||||
|
task("03", ["01"]),
|
||||||
|
task("04", ["02"]),
|
||||||
|
task("05", ["02", "03"]),
|
||||||
|
task("06", ["03"]),
|
||||||
|
task("07", ["04", "05", "06"]),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
const plan = buildExecutionPlan(project, new Set());
|
||||||
|
expect(plan.batches).toHaveLength(4);
|
||||||
|
expect(plan.batches[0].tasks.map((t) => t.id)).toEqual(["01"]);
|
||||||
|
expect(plan.batches[1].tasks.map((t) => t.id).sort()).toEqual(["02", "03"]);
|
||||||
|
expect(plan.batches[2].tasks.map((t) => t.id).sort()).toEqual([
|
||||||
|
"04",
|
||||||
|
"05",
|
||||||
|
"06",
|
||||||
|
]);
|
||||||
|
expect(plan.batches[3].tasks.map((t) => t.id)).toEqual(["07"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles a wide fan-out with delayed convergence", () => {
|
||||||
|
// 01 -> 02,03,04,05,06
|
||||||
|
// 02,03 -> 07
|
||||||
|
// 04,05 -> 08
|
||||||
|
// 06 -> 09
|
||||||
|
// 07,08,09 -> 10
|
||||||
|
const project = makeProject({
|
||||||
|
tasks: tasksFrom(
|
||||||
|
task("01"),
|
||||||
|
task("02", ["01"]),
|
||||||
|
task("03", ["01"]),
|
||||||
|
task("04", ["01"]),
|
||||||
|
task("05", ["01"]),
|
||||||
|
task("06", ["01"]),
|
||||||
|
task("07", ["02", "03"]),
|
||||||
|
task("08", ["04", "05"]),
|
||||||
|
task("09", ["06"]),
|
||||||
|
task("10", ["07", "08", "09"]),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
const plan = buildExecutionPlan(project, new Set());
|
||||||
|
expect(plan.batches).toHaveLength(4);
|
||||||
|
expect(plan.batches[0].tasks.map((t) => t.id)).toEqual(["01"]);
|
||||||
|
expect(plan.batches[1].tasks.map((t) => t.id).sort()).toEqual([
|
||||||
|
"02",
|
||||||
|
"03",
|
||||||
|
"04",
|
||||||
|
"05",
|
||||||
|
"06",
|
||||||
|
]);
|
||||||
|
expect(plan.batches[2].tasks.map((t) => t.id).sort()).toEqual([
|
||||||
|
"07",
|
||||||
|
"08",
|
||||||
|
"09",
|
||||||
|
]);
|
||||||
|
expect(plan.batches[3].tasks.map((t) => t.id)).toEqual(["10"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles multiple independent subgraphs", () => {
|
||||||
|
// Two completely independent chains:
|
||||||
|
// Chain A: 01 -> 02 -> 03
|
||||||
|
// Chain B: 04 -> 05
|
||||||
|
const project = makeProject({
|
||||||
|
tasks: tasksFrom(
|
||||||
|
task("01"),
|
||||||
|
task("02", ["01"]),
|
||||||
|
task("03", ["02"]),
|
||||||
|
task("04"),
|
||||||
|
task("05", ["04"]),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
const plan = buildExecutionPlan(project, new Set());
|
||||||
|
// Batch 0: [01, 04] (both roots)
|
||||||
|
// Batch 1: [02, 05]
|
||||||
|
// Batch 2: [03]
|
||||||
|
expect(plan.batches[0].tasks.map((t) => t.id).sort()).toEqual(["01", "04"]);
|
||||||
|
expect(plan.batches[1].tasks.map((t) => t.id).sort()).toEqual(["02", "05"]);
|
||||||
|
expect(plan.batches[2].tasks.map((t) => t.id)).toEqual(["03"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("batches tasks respecting fan-in convergence", () => {
|
||||||
|
// 01 -> 03, 02 -> 03 (03 depends on both 01 AND 02)
|
||||||
|
const project = makeProject({
|
||||||
|
tasks: tasksFrom(task("01"), task("02"), task("03", ["01", "02"])),
|
||||||
|
});
|
||||||
|
const plan = buildExecutionPlan(project, new Set());
|
||||||
|
expect(plan.batches[0].tasks.map((t) => t.id).sort()).toEqual(["01", "02"]);
|
||||||
|
expect(plan.batches[1].tasks.map((t) => t.id)).toEqual(["03"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Sequential Plan ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("buildSequentialPlan", () => {
|
||||||
|
it("puts each task in its own batch", () => {
|
||||||
|
const project = makeProject({
|
||||||
|
tasks: tasksFrom(task("01"), task("02", ["01"]), task("03", ["01"])),
|
||||||
|
});
|
||||||
|
const plan = buildSequentialPlan(project, new Set());
|
||||||
|
expect(plan.batches).toHaveLength(3);
|
||||||
|
plan.batches.forEach((b, i) => {
|
||||||
|
expect(b.tasks).toHaveLength(1);
|
||||||
|
expect(b.batchIndex).toBe(i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips completed tasks and blocks transitively failed tasks", () => {
|
||||||
|
const project = makeProject({
|
||||||
|
tasks: tasksFrom(
|
||||||
|
task("01"),
|
||||||
|
task("02", ["01"]),
|
||||||
|
task("03", ["02"]),
|
||||||
|
task("04"),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
const plan = buildSequentialPlan(project, new Set(["01"]), new Set(["01"]));
|
||||||
|
// 01 failed => 02, 03 blocked. 04 independent, runs.
|
||||||
|
expect(plan.skippedTasks.map((t) => t.id).sort()).toEqual([
|
||||||
|
"01",
|
||||||
|
"02",
|
||||||
|
"03",
|
||||||
|
]);
|
||||||
|
expect(plan.totalTasks).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maintains task order in sequential batches", () => {
|
||||||
|
const project = makeProject({
|
||||||
|
tasks: tasksFrom(task("01"), task("02", ["01"]), task("03", ["01"])),
|
||||||
|
});
|
||||||
|
const plan = buildSequentialPlan(project, new Set());
|
||||||
|
expect(plan.batches.map((b) => b.tasks[0].id)).toEqual(["01", "02", "03"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── getBlockedTasks ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("getBlockedTasks", () => {
|
||||||
|
it("returns direct dependents of failed tasks", () => {
|
||||||
|
const pending = tasksFrom(task("01"), task("02", ["01"]), task("03"));
|
||||||
|
const blocked = getBlockedTasks(pending, new Set(["01"]));
|
||||||
|
expect([...blocked]).toEqual(["02"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns transitive dependents (chain reaction)", () => {
|
||||||
|
const pending = tasksFrom(
|
||||||
|
task("01"),
|
||||||
|
task("02", ["01"]),
|
||||||
|
task("03", ["02"]),
|
||||||
|
task("04", ["03"]),
|
||||||
|
);
|
||||||
|
const blocked = getBlockedTasks(pending, new Set(["01"]));
|
||||||
|
expect([...blocked].sort()).toEqual(["02", "03", "04"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not affect tasks in separate subgraphs", () => {
|
||||||
|
const pending = tasksFrom(
|
||||||
|
task("01"),
|
||||||
|
task("02", ["01"]),
|
||||||
|
task("10"),
|
||||||
|
task("11", ["10"]),
|
||||||
|
);
|
||||||
|
const blocked = getBlockedTasks(pending, new Set(["01"]));
|
||||||
|
expect([...blocked].sort()).toEqual(["02"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty set when no tasks depend on failed tasks", () => {
|
||||||
|
const pending = tasksFrom(task("01"), task("02"), task("03"));
|
||||||
|
const blocked = getBlockedTasks(pending, new Set(["99"]));
|
||||||
|
expect(blocked.size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── detectCycles ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("detectCycles", () => {
|
||||||
|
it("returns empty for acyclic graph", () => {
|
||||||
|
const project = makeProject({
|
||||||
|
tasks: tasksFrom(task("01"), task("02", ["01"]), task("03", ["02"])),
|
||||||
|
});
|
||||||
|
expect(detectCycles(project)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects a 3-node cycle", () => {
|
||||||
|
const project = makeProject({
|
||||||
|
tasks: tasksFrom(
|
||||||
|
task("01", ["03"]),
|
||||||
|
task("02", ["01"]),
|
||||||
|
task("03", ["02"]),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
const cycles = detectCycles(project);
|
||||||
|
expect(cycles.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects a self-loop", () => {
|
||||||
|
const project = makeProject({
|
||||||
|
tasks: tasksFrom(task("01", ["01"])),
|
||||||
|
});
|
||||||
|
const cycles = detectCycles(project);
|
||||||
|
expect(cycles.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects cycle in disconnected subgraph", () => {
|
||||||
|
const project = makeProject({
|
||||||
|
tasks: tasksFrom(
|
||||||
|
task("01"), // isolated
|
||||||
|
task("02", ["03"]),
|
||||||
|
task("03", ["02"]), // cycle
|
||||||
|
),
|
||||||
|
});
|
||||||
|
const cycles = detectCycles(project);
|
||||||
|
expect(cycles.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty for graph with only diamond patterns", () => {
|
||||||
|
const project = makeProject({
|
||||||
|
tasks: tasksFrom(
|
||||||
|
task("01"),
|
||||||
|
task("02", ["01"]),
|
||||||
|
task("03", ["01"]),
|
||||||
|
task("04", ["02", "03"]),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
expect(detectCycles(project)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── getCriticalPath ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("getCriticalPath", () => {
|
||||||
|
it("returns the longest path through the DAG", () => {
|
||||||
|
// 01 -> 02 -> 03 -> 04 (long = 4)
|
||||||
|
// 01 -> 05 -> 04 (short = 3)
|
||||||
|
const project = makeProject({
|
||||||
|
tasks: tasksFrom(
|
||||||
|
task("01"),
|
||||||
|
task("02", ["01"]),
|
||||||
|
task("03", ["02"]),
|
||||||
|
task("04", ["03", "05"]),
|
||||||
|
task("05", ["01"]),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
const path = getCriticalPath(project);
|
||||||
|
expect(path.length).toBe(4);
|
||||||
|
expect(path[0].id).toBe("01");
|
||||||
|
expect(path[path.length - 1].id).toBe("04");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns single-node path for roots", () => {
|
||||||
|
const project = makeProject({
|
||||||
|
tasks: tasksFrom(task("01"), task("02"), task("03")),
|
||||||
|
});
|
||||||
|
const path = getCriticalPath(project);
|
||||||
|
expect(path.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles complex branching by picking the longest chain", () => {
|
||||||
|
// 01 -> 02 -> 03 -> 04 -> 05 (long = 5)
|
||||||
|
// 01 -> 06 -> 05 (short = 3)
|
||||||
|
const project = makeProject({
|
||||||
|
tasks: tasksFrom(
|
||||||
|
task("01"),
|
||||||
|
task("02", ["01"]),
|
||||||
|
task("03", ["02"]),
|
||||||
|
task("04", ["03"]),
|
||||||
|
task("05", ["04", "06"]),
|
||||||
|
task("06", ["01"]),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
const path = getCriticalPath(project);
|
||||||
|
// Should pick 01 -> 02 -> 03 -> 04 -> 05 (length 5)
|
||||||
|
expect(path.length).toBe(5);
|
||||||
|
expect(path.map((t) => t.id)).toEqual(["01", "02", "03", "04", "05"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── formatDependencyChain ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("formatDependencyChain", () => {
|
||||||
|
it("renders a simple tree", () => {
|
||||||
|
const project = makeProject({
|
||||||
|
tasks: tasksFrom(task("01"), task("02", ["01"])),
|
||||||
|
});
|
||||||
|
const formatted = formatDependencyChain(project);
|
||||||
|
expect(formatted).toContain("01");
|
||||||
|
expect(formatted).toContain("02");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mentions root tasks", () => {
|
||||||
|
const project = makeProject({
|
||||||
|
tasks: tasksFrom(task("01"), task("02")),
|
||||||
|
});
|
||||||
|
const formatted = formatDependencyChain(project);
|
||||||
|
expect(formatted).toMatch(/01.*root|root.*01/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles empty task list", () => {
|
||||||
|
const project = makeProject({ tasks: [] });
|
||||||
|
const formatted = formatDependencyChain(project);
|
||||||
|
expect(formatted).toContain("no tasks");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows orphan tasks when dependencies reference non-existent IDs", () => {
|
||||||
|
const project = makeProject({
|
||||||
|
tasks: tasksFrom(task("01", ["99"])),
|
||||||
|
});
|
||||||
|
const formatted = formatDependencyChain(project);
|
||||||
|
expect(formatted).toMatch(/orphan|unreached/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── formatExecutionPlan ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("formatExecutionPlan", () => {
|
||||||
|
it("displays task counts and batches", () => {
|
||||||
|
const project = makeProject({
|
||||||
|
tasks: tasksFrom(task("01"), task("02", ["01"])),
|
||||||
|
});
|
||||||
|
const plan = buildExecutionPlan(project, new Set());
|
||||||
|
const formatted = formatExecutionPlan(plan);
|
||||||
|
expect(formatted).toContain("Total tasks");
|
||||||
|
expect(formatted).toContain("Batches");
|
||||||
|
expect(formatted).toContain("01");
|
||||||
|
expect(formatted).toContain("02");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows skipped tasks", () => {
|
||||||
|
const project = makeProject({
|
||||||
|
tasks: tasksFrom(task("01", [], "completed"), task("02", ["01"])),
|
||||||
|
});
|
||||||
|
const plan = buildExecutionPlan(project, new Set(["01"]));
|
||||||
|
const formatted = formatExecutionPlan(plan);
|
||||||
|
expect(formatted).toContain("completed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows parallel group annotations when provided", () => {
|
||||||
|
const project = makeProject({
|
||||||
|
tasks: tasksFrom(task("01"), task("02", ["01"]), task("03", ["01"])),
|
||||||
|
parallelGroups: [{ index: 0, label: "UI sprint", taskIds: ["02", "03"] }],
|
||||||
|
});
|
||||||
|
const plan = buildExecutionPlan(project, new Set());
|
||||||
|
const formatted = formatExecutionPlan(plan, project.parallelGroups);
|
||||||
|
expect(formatted).toContain("UI sprint");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Group-Aware Batching ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("Parallel group batching", () => {
|
||||||
|
it("builds batches when parallel groups are defined", () => {
|
||||||
|
const project = makeProject({
|
||||||
|
tasks: tasksFrom(
|
||||||
|
task("01"),
|
||||||
|
task("02", ["01"]),
|
||||||
|
task("03", ["01"]),
|
||||||
|
task("04", ["02", "03"]),
|
||||||
|
),
|
||||||
|
parallelGroups: [
|
||||||
|
{ index: 0, label: "Frontend", taskIds: ["01", "02", "03", "04"] },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
// Should route through buildGroupAwareBatches
|
||||||
|
const plan = buildExecutionPlan(project, new Set());
|
||||||
|
expect(plan.batches.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("respects intra-group dependencies in parallel groups", () => {
|
||||||
|
// Tasks: 01 -> 02, 01 -> 03, 02 -> 04, 03 -> 04
|
||||||
|
// With parallel groups, there are no cross-group dependencies by definition.
|
||||||
|
// Intra-group deps are respected by Kahn's algorithm.
|
||||||
|
const project = makeProject({
|
||||||
|
tasks: tasksFrom(
|
||||||
|
task("01"),
|
||||||
|
task("02", ["01"]),
|
||||||
|
task("03", ["01"]),
|
||||||
|
task("04", ["02", "03"]),
|
||||||
|
),
|
||||||
|
parallelGroups: [
|
||||||
|
{ index: 0, label: "All", taskIds: ["01", "02", "03", "04"] },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const plan = buildExecutionPlan(project, new Set());
|
||||||
|
// Batch 0: [01], Batch 1: [02, 03], Batch 2: [04]
|
||||||
|
expect(plan.batches[0].tasks.map((t) => t.id)).toEqual(["01"]);
|
||||||
|
expect(plan.batches[1].tasks.map((t) => t.id).sort()).toEqual(["02", "03"]);
|
||||||
|
expect(plan.batches[2].tasks.map((t) => t.id)).toEqual(["04"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Real-World Scenario: Resume with completed tasks ───────────────────────
|
||||||
|
|
||||||
|
describe("Real-world resume scenarios", () => {
|
||||||
|
it("buildExecutionPlan correctly excludes file-based [x] completions", () => {
|
||||||
|
// Design Token PRD resume: 01,02,03 [x] in file, 04 [~], 05 [ ]
|
||||||
|
const project = makeProject({
|
||||||
|
tasks: tasksFrom(
|
||||||
|
task("01", [], "completed"),
|
||||||
|
task("02", ["01"], "completed"),
|
||||||
|
task("03", ["01"], "completed"),
|
||||||
|
task("04", ["02", "03"], "in_progress"),
|
||||||
|
task("05", ["04", "01"], "pending"),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
// buildCompletedSet in index.ts produces {01, 02, 03} from file + progress
|
||||||
|
// This simulates what happens after buildCompletedSet is called
|
||||||
|
const completedFromFile = new Set(
|
||||||
|
project.tasks.filter((t) => t.status === "completed").map((t) => t.id),
|
||||||
|
);
|
||||||
|
const plan = buildExecutionPlan(project, completedFromFile);
|
||||||
|
|
||||||
|
// Only 04 and 05 should be pending
|
||||||
|
expect(plan.totalTasks).toBe(2);
|
||||||
|
expect(plan.batches).toHaveLength(2);
|
||||||
|
expect(plan.batches[0].tasks.map((t) => t.id)).toEqual(["04"]);
|
||||||
|
expect(plan.batches[1].tasks.map((t) => t.id)).toEqual(["05"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skipsTasks includes both progress-completed and file-completed tasks", () => {
|
||||||
|
const project = makeProject({
|
||||||
|
tasks: tasksFrom(
|
||||||
|
task("01", [], "completed"),
|
||||||
|
task("02", ["01"], "pending"),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
// Simulate: 01 completed in file AND in progress
|
||||||
|
const plan = buildExecutionPlan(project, new Set(["01"]));
|
||||||
|
expect(plan.skippedTasks.map((t) => t.id)).toEqual(["01"]);
|
||||||
|
expect(plan.batches[0].tasks.map((t) => t.id)).toEqual(["02"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
30
tests/helpers.ts
Normal file
30
tests/helpers.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import * as fs from "node:fs";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import * as os from "node:os";
|
||||||
|
|
||||||
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a temporary directory for test files.
|
||||||
|
* Returns the path and a cleanup function.
|
||||||
|
*/
|
||||||
|
export function tempDir(): { dir: string; cleanup: () => void } {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ralpi-test-"));
|
||||||
|
return {
|
||||||
|
dir,
|
||||||
|
cleanup: () => fs.rmSync(dir, { recursive: true, force: true }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write content to a temp markdown file and return its path.
|
||||||
|
*/
|
||||||
|
export function writeTaskFile(
|
||||||
|
dir: string,
|
||||||
|
name: string,
|
||||||
|
content: string,
|
||||||
|
): string {
|
||||||
|
const filePath = path.join(dir, name);
|
||||||
|
fs.writeFileSync(filePath, content, "utf-8");
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
1119
tests/parser-dag.test.ts
Normal file
1119
tests/parser-dag.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
1321
tests/parser-formats.test.ts
Normal file
1321
tests/parser-formats.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
521
tests/parser-phased.test.ts
Normal file
521
tests/parser-phased.test.ts
Normal file
@@ -0,0 +1,521 @@
|
|||||||
|
/**
|
||||||
|
* Tests for phased task format parsing
|
||||||
|
* Covers: phase detection, task parsing, phase boundaries, implicit dependencies
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, test, expect } from "bun:test";
|
||||||
|
import { parseTaskFile } from "../src/parser";
|
||||||
|
import type { Task } from "../src/types";
|
||||||
|
import { tempDir, writeTaskFile } from "./helpers";
|
||||||
|
|
||||||
|
/** Parse a task file from an inline template literal. */
|
||||||
|
function parse(content: string) {
|
||||||
|
const { dir, cleanup } = tempDir();
|
||||||
|
try {
|
||||||
|
const filePath = writeTaskFile(dir, "README.md", content);
|
||||||
|
return { project: parseTaskFile(filePath), cleanup };
|
||||||
|
} catch (e) {
|
||||||
|
cleanup();
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Phased task format", () => {
|
||||||
|
describe("Phase detection", () => {
|
||||||
|
test("detects phased format with markdown headings", () => {
|
||||||
|
const content = `# Voice Conversation
|
||||||
|
|
||||||
|
## Phase 1 - MVP
|
||||||
|
- [ ] 01 - Build voice pipeline
|
||||||
|
- [ ] 02 - Add audio playback
|
||||||
|
|
||||||
|
## Phase 2 - Streaming
|
||||||
|
- [ ] 03 - WebSocket channel
|
||||||
|
- [ ] 04 - Streaming STT
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
- 02 depends on 01
|
||||||
|
`;
|
||||||
|
const { project, cleanup } = parse(content);
|
||||||
|
try {
|
||||||
|
expect(project.phases).toBeDefined();
|
||||||
|
expect(project.phases?.length).toBe(2);
|
||||||
|
expect(project.phases?.[0].number).toBe(1);
|
||||||
|
expect(project.phases?.[0].title).toBe("MVP");
|
||||||
|
expect(project.phases?.[1].number).toBe(2);
|
||||||
|
expect(project.phases?.[1].title).toBe("Streaming");
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("detects phased format with plain headings", () => {
|
||||||
|
const content = `# Voice Conversation
|
||||||
|
|
||||||
|
Phase 1 - MVP
|
||||||
|
- [ ] 01 - Build voice pipeline
|
||||||
|
- [ ] 02 - Add audio playback
|
||||||
|
|
||||||
|
Phase 2 - Streaming
|
||||||
|
- [ ] 03 - WebSocket channel
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
`;
|
||||||
|
const { project, cleanup } = parse(content);
|
||||||
|
try {
|
||||||
|
expect(project.phases).toBeDefined();
|
||||||
|
expect(project.phases?.length).toBe(2);
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("supports various separators in phase headings", () => {
|
||||||
|
const variants = [
|
||||||
|
"## Phase 1 - MVP",
|
||||||
|
"## Phase 1 - MVP",
|
||||||
|
"## Phase 1 - MVP",
|
||||||
|
"## Phase 1: MVP",
|
||||||
|
"## Phase 1 - MVP", // multiple spaces
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const heading of variants) {
|
||||||
|
const content = `# Test
|
||||||
|
|
||||||
|
${heading}
|
||||||
|
- [ ] 01 - Task
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
`;
|
||||||
|
const { project, cleanup } = parse(content);
|
||||||
|
try {
|
||||||
|
expect(project.phases).toBeDefined();
|
||||||
|
expect(project.phases?.length).toBe(1);
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles phase headings with extra whitespace", () => {
|
||||||
|
const content = `# Test
|
||||||
|
|
||||||
|
## Phase 1 - MVP
|
||||||
|
- [ ] 01 - Task
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
`;
|
||||||
|
const { project, cleanup } = parse(content);
|
||||||
|
try {
|
||||||
|
expect(project.phases).toBeDefined();
|
||||||
|
expect(project.phases?.length).toBe(1);
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Task parsing within phases", () => {
|
||||||
|
test("assigns phase number to tasks", () => {
|
||||||
|
const content = `# Test
|
||||||
|
|
||||||
|
## Phase 1 - MVP
|
||||||
|
- [ ] 01 - Task A
|
||||||
|
- [ ] 02 - Task B
|
||||||
|
|
||||||
|
## Phase 2 - Enhancement
|
||||||
|
- [ ] 03 - Task C
|
||||||
|
- [ ] 04 - Task D
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
`;
|
||||||
|
const { project, cleanup } = parse(content);
|
||||||
|
try {
|
||||||
|
expect(project.tasks[0].id).toBe("01");
|
||||||
|
expect(project.tasks[0].phase).toBe(1);
|
||||||
|
expect(project.tasks[1].id).toBe("02");
|
||||||
|
expect(project.tasks[1].phase).toBe(1);
|
||||||
|
expect(project.tasks[2].id).toBe("03");
|
||||||
|
expect(project.tasks[2].phase).toBe(2);
|
||||||
|
expect(project.tasks[3].id).toBe("04");
|
||||||
|
expect(project.tasks[3].phase).toBe(2);
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tracks task IDs in each phase", () => {
|
||||||
|
const content = `# Test
|
||||||
|
|
||||||
|
## Phase 1 - Foundation
|
||||||
|
- [ ] 01 - Setup
|
||||||
|
- [ ] 02 - Config
|
||||||
|
|
||||||
|
## Phase 2 - Implementation
|
||||||
|
- [ ] 03 - Feature A
|
||||||
|
- [ ] 04 - Feature B
|
||||||
|
- [ ] 05 - Feature C
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
`;
|
||||||
|
const { project, cleanup } = parse(content);
|
||||||
|
try {
|
||||||
|
expect(project.phases?.[0].taskIds).toEqual(["01", "02"]);
|
||||||
|
expect(project.phases?.[1].taskIds).toEqual(["03", "04", "05"]);
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles tasks with different statuses in phases", () => {
|
||||||
|
const content = `# Test
|
||||||
|
|
||||||
|
## Phase 1 - MVP
|
||||||
|
- [x] 01 - Done task
|
||||||
|
- [ ] 02 - Pending task
|
||||||
|
- [~] 03 - In progress
|
||||||
|
|
||||||
|
## Phase 2 - Next
|
||||||
|
- [ ] 04 - Future task
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
`;
|
||||||
|
const { project, cleanup } = parse(content);
|
||||||
|
try {
|
||||||
|
expect(project.tasks[0].status).toBe("completed");
|
||||||
|
expect(project.tasks[1].status).toBe("pending");
|
||||||
|
expect(project.tasks[2].status).toBe("in_progress");
|
||||||
|
expect(project.tasks[3].status).toBe("pending");
|
||||||
|
|
||||||
|
expect(project.phases?.[0].taskIds).toEqual(["01", "02", "03"]);
|
||||||
|
expect(project.phases?.[1].taskIds).toEqual(["04"]);
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles empty phases", () => {
|
||||||
|
const content = `# Test
|
||||||
|
|
||||||
|
## Phase 1 - Empty
|
||||||
|
|
||||||
|
## Phase 2 - Has tasks
|
||||||
|
- [ ] 01 - Task
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
`;
|
||||||
|
const { project, cleanup } = parse(content);
|
||||||
|
try {
|
||||||
|
expect(project.phases?.length).toBe(1);
|
||||||
|
expect(project.phases?.[0].number).toBe(2);
|
||||||
|
expect(project.phases?.[0].taskIds).toEqual(["01"]);
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Implicit phase-boundary dependencies", () => {
|
||||||
|
test("adds dependency from first task of phase 2 to last task of phase 1", () => {
|
||||||
|
const content = `# Test
|
||||||
|
|
||||||
|
## Phase 1 - MVP
|
||||||
|
- [ ] 01 - Setup
|
||||||
|
- [ ] 02 - Build
|
||||||
|
|
||||||
|
## Phase 2 - Enhancement
|
||||||
|
- [ ] 03 - Feature
|
||||||
|
- [ ] 04 - Test
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
`;
|
||||||
|
const { project, cleanup } = parse(content);
|
||||||
|
try {
|
||||||
|
// Task 03 should depend on task 02 (implicit phase boundary)
|
||||||
|
expect(
|
||||||
|
project.tasks.find((t: Task) => t.id === "03")?.dependencies,
|
||||||
|
).toContain("02");
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("adds dependencies across multiple phases", () => {
|
||||||
|
const content = `# Test
|
||||||
|
|
||||||
|
## Phase 1 - Foundation
|
||||||
|
- [ ] 01 - Setup
|
||||||
|
|
||||||
|
## Phase 2 - Core
|
||||||
|
- [ ] 02 - Build
|
||||||
|
- [ ] 03 - Test
|
||||||
|
|
||||||
|
## Phase 3 — Polish
|
||||||
|
- [ ] 04 — Refine
|
||||||
|
- [ ] 05 — Release
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
`;
|
||||||
|
const { project, cleanup } = parse(content);
|
||||||
|
try {
|
||||||
|
// Task 02 depends on task 01 (phase 1 → 2 boundary)
|
||||||
|
expect(
|
||||||
|
project.tasks.find((t: Task) => t.id === "02")?.dependencies,
|
||||||
|
).toContain("01");
|
||||||
|
|
||||||
|
// Task 04 depends on task 03 (phase 2 → 3 boundary)
|
||||||
|
expect(
|
||||||
|
project.tasks.find((t: Task) => t.id === "04")?.dependencies,
|
||||||
|
).toContain("03");
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not duplicate explicit dependencies", () => {
|
||||||
|
const content = `# Test
|
||||||
|
|
||||||
|
## Phase 1 - MVP
|
||||||
|
- [ ] 01 - Setup
|
||||||
|
- [ ] 02 - Build
|
||||||
|
|
||||||
|
## Phase 2 — Enhancement
|
||||||
|
- [ ] 03 — Feature
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
- 03 depends on 02
|
||||||
|
`;
|
||||||
|
const { project, cleanup } = parse(content);
|
||||||
|
try {
|
||||||
|
const task03 = project.tasks.find((t: Task) => t.id === "03");
|
||||||
|
const depCount = task03?.dependencies.filter(
|
||||||
|
(d: string) => d === "02",
|
||||||
|
).length;
|
||||||
|
expect(depCount).toBe(1); // Should not duplicate
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles single phase (no boundaries)", () => {
|
||||||
|
const content = `# Test
|
||||||
|
|
||||||
|
## Phase 1 - All tasks
|
||||||
|
- [ ] 01 - Task A
|
||||||
|
- [ ] 02 - Task B
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
`;
|
||||||
|
const { project, cleanup } = parse(content);
|
||||||
|
try {
|
||||||
|
// No implicit dependencies should be added
|
||||||
|
expect(project.tasks[0].dependencies).toEqual([]);
|
||||||
|
expect(project.tasks[1].dependencies).toEqual([]);
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("works alongside explicit dependencies", () => {
|
||||||
|
const content = `# Test
|
||||||
|
|
||||||
|
## Phase 1 - MVP
|
||||||
|
- [ ] 01 - Setup
|
||||||
|
- [ ] 02 - Build
|
||||||
|
|
||||||
|
## Phase 2 - Enhancement
|
||||||
|
- [ ] 03 - Feature A
|
||||||
|
- [ ] 04 - Feature B
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
- 04 depends on 03
|
||||||
|
`;
|
||||||
|
const { project, cleanup } = parse(content);
|
||||||
|
try {
|
||||||
|
// Task 03 has implicit dependency on task 02
|
||||||
|
expect(
|
||||||
|
project.tasks.find((t: Task) => t.id === "03")?.dependencies,
|
||||||
|
).toContain("02");
|
||||||
|
|
||||||
|
// Task 04 has explicit dependency on task 03
|
||||||
|
expect(
|
||||||
|
project.tasks.find((t: Task) => t.id === "04")?.dependencies,
|
||||||
|
).toContain("03");
|
||||||
|
|
||||||
|
// Task 04 should NOT have implicit dependency on task 02
|
||||||
|
expect(
|
||||||
|
project.tasks.find((t: Task) => t.id === "04")?.dependencies,
|
||||||
|
).not.toContain("02");
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Mixed formats", () => {
|
||||||
|
test("phased format with arrow dependencies", () => {
|
||||||
|
const content = `# Test
|
||||||
|
|
||||||
|
## Phase 1 - Setup
|
||||||
|
- [ ] 01 - Initialize
|
||||||
|
- [ ] 02 - Configure
|
||||||
|
|
||||||
|
## Phase 2 - Build
|
||||||
|
- [ ] 03 - Compile
|
||||||
|
- [ ] 04 - Bundle
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
- 01 → 02
|
||||||
|
- 03 → 04
|
||||||
|
`;
|
||||||
|
const { project, cleanup } = parse(content);
|
||||||
|
try {
|
||||||
|
expect(project.phases?.length).toBe(2);
|
||||||
|
expect(
|
||||||
|
project.tasks.find((t: Task) => t.id === "02")?.dependencies,
|
||||||
|
).toContain("01");
|
||||||
|
expect(
|
||||||
|
project.tasks.find((t: Task) => t.id === "04")?.dependencies,
|
||||||
|
).toContain("03");
|
||||||
|
expect(
|
||||||
|
project.tasks.find((t: Task) => t.id === "03")?.dependencies,
|
||||||
|
).toContain("02");
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("phased format with parallel groups", () => {
|
||||||
|
const content = `# Test
|
||||||
|
|
||||||
|
## Phase 1 - MVP
|
||||||
|
- [ ] 01 - Setup
|
||||||
|
- [ ] 02 - Build
|
||||||
|
|
||||||
|
## Phase 2 - Enhancement
|
||||||
|
- [ ] 03 - Feature
|
||||||
|
- [ ] 04 - Test
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
- 01, 02 can be done in parallel
|
||||||
|
- 03, 04 can be done in parallel
|
||||||
|
`;
|
||||||
|
const { project, cleanup } = parse(content);
|
||||||
|
try {
|
||||||
|
expect(project.phases?.length).toBe(2);
|
||||||
|
expect(project.parallelGroups?.length).toBe(2);
|
||||||
|
expect(
|
||||||
|
project.tasks.find((t: Task) => t.id === "03")?.dependencies,
|
||||||
|
).toContain("02");
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("phased format with exit criteria", () => {
|
||||||
|
const content = `# Test
|
||||||
|
|
||||||
|
## Phase 1 - MVP
|
||||||
|
- [ ] 01 - Build
|
||||||
|
- [ ] 02 - Test
|
||||||
|
|
||||||
|
## Phase 2 - Release
|
||||||
|
- [ ] 03 - Deploy
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
## Exit Criteria
|
||||||
|
- All tests pass
|
||||||
|
- Deployment successful
|
||||||
|
`;
|
||||||
|
const { project, cleanup } = parse(content);
|
||||||
|
try {
|
||||||
|
expect(project.phases?.length).toBe(2);
|
||||||
|
expect(project.exitCriteria?.length).toBe(2);
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Real-world example", () => {
|
||||||
|
test("parses voice conversation PRD correctly", () => {
|
||||||
|
const content = `# Voice Conversation
|
||||||
|
|
||||||
|
Objective: Add full voice conversation capability
|
||||||
|
|
||||||
|
## Phase 1 - Push-to-Talk MVP
|
||||||
|
- [ ] 01 - Build voice pipeline orchestrator → \`01-voice-pipeline-orchestrator.md\`
|
||||||
|
- [ ] 02 - Build auto-playback audio module → \`02-auto-playback-audio-module.md\`
|
||||||
|
- [ ] 03 - Wire voice mode toggle into chat UI → \`03-voice-mode-toggle-ui.md\`
|
||||||
|
- [ ] 04 - End-to-end push-to-talk integration test → \`04-push-to-talk-integration-test.md\`
|
||||||
|
|
||||||
|
## Phase 2 - Streaming & Real-Time
|
||||||
|
- [ ] 05 - Build WebSocket voice channel → \`05-websocket-voice-channel.md\`
|
||||||
|
- [ ] 06 - Implement streaming STT pipeline → \`06-streaming-stt-pipeline.md\`
|
||||||
|
- [ ] 07 - Implement streaming TTS pipeline → \`07-streaming-tts-pipeline.md\`
|
||||||
|
|
||||||
|
## Phase 3 - Optimization & Hardening
|
||||||
|
- [ ] 08 - Model quantization and VRAM budget manager → \`08-model-quantization.md\`
|
||||||
|
- [ ] 09 - Latency profiling and pipeline optimization → \`09-latency-profiling.md\`
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
- 02 depends on 01
|
||||||
|
- 03 depends on 01, 02
|
||||||
|
- 04 depends on 03
|
||||||
|
- 06 depends on 05
|
||||||
|
- 07 depends on 05
|
||||||
|
- 09 depends on 08
|
||||||
|
|
||||||
|
## Exit Criteria
|
||||||
|
- Users can hold multi-turn voice conversations
|
||||||
|
- Total round-trip latency under 3s
|
||||||
|
`;
|
||||||
|
const { project, cleanup } = parse(content);
|
||||||
|
try {
|
||||||
|
// Verify phases
|
||||||
|
expect(project.phases?.length).toBe(3);
|
||||||
|
expect(project.phases?.[0].title).toBe("Push-to-Talk MVP");
|
||||||
|
expect(project.phases?.[1].title).toBe("Streaming & Real-Time");
|
||||||
|
expect(project.phases?.[2].title).toBe("Optimization & Hardening");
|
||||||
|
|
||||||
|
// Verify task phases
|
||||||
|
expect(project.tasks[0].phase).toBe(1);
|
||||||
|
expect(project.tasks[4].phase).toBe(2);
|
||||||
|
expect(project.tasks[7].phase).toBe(3);
|
||||||
|
|
||||||
|
// Verify phase boundaries
|
||||||
|
// Task 05 (first in phase 2) depends on task 04 (last in phase 1)
|
||||||
|
expect(
|
||||||
|
project.tasks.find((t: Task) => t.id === "05")?.dependencies,
|
||||||
|
).toContain("04");
|
||||||
|
|
||||||
|
// Task 08 (first in phase 3) depends on task 07 (last in phase 2)
|
||||||
|
expect(
|
||||||
|
project.tasks.find((t: Task) => t.id === "08")?.dependencies,
|
||||||
|
).toContain("07");
|
||||||
|
|
||||||
|
// Verify explicit dependencies still work
|
||||||
|
expect(
|
||||||
|
project.tasks.find((t: Task) => t.id === "02")?.dependencies,
|
||||||
|
).toContain("01");
|
||||||
|
expect(
|
||||||
|
project.tasks.find((t: Task) => t.id === "03")?.dependencies,
|
||||||
|
).toContain("01");
|
||||||
|
expect(
|
||||||
|
project.tasks.find((t: Task) => t.id === "03")?.dependencies,
|
||||||
|
).toContain("02");
|
||||||
|
|
||||||
|
// Verify task files
|
||||||
|
expect(project.tasks[0].file).toBe("01-voice-pipeline-orchestrator.md");
|
||||||
|
expect(project.tasks[1].file).toBe("02-auto-playback-audio-module.md");
|
||||||
|
|
||||||
|
// Verify exit criteria
|
||||||
|
expect(project.exitCriteria?.length).toBe(2);
|
||||||
|
expect(project.objective).toBe("Voice Conversation");
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,18 +1,15 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"module": "commonjs",
|
"module": "ES2022",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
"lib": ["ES2022"],
|
"lib": ["ES2022"],
|
||||||
"outDir": "./dist",
|
"noEmit": true,
|
||||||
"rootDir": "./",
|
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true
|
||||||
"declaration": true,
|
|
||||||
"declarationMap": true,
|
|
||||||
"sourceMap": true
|
|
||||||
},
|
},
|
||||||
"include": ["index.ts", "src/**/*"],
|
"include": ["index.ts", "src/**/*"],
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": ["node_modules", "dist"]
|
||||||
|
|||||||
Reference in New Issue
Block a user