Compare commits

...

24 Commits

Author SHA1 Message Date
6c398eef64 live reporting commit requests, resume prints batching 2026-06-23 13:31:37 -04:00
496d1554be version bump 2026-06-21 13:30:39 -04:00
fe28718911 feat: support for letter padded tasks 2026-06-21 13:29:24 -04:00
3ba5fcb098 fix copyright year 2026-06-19 12:42:47 -04:00
db8859606f handles phased tasks 2026-06-09 13:59:38 -04:00
85123b7755 fixed regressed parsing, tool sanitation 2026-06-08 20:34:16 -04:00
dc3993048e commit clarification 2026-06-04 09:29:00 -04:00
dfa6707a8f commit per task 2026-06-02 15:20:31 -04:00
3892e2a637 bump 2026-06-01 12:58:13 -04:00
8151d19127 more dependancy parsing support 2026-06-01 11:31:33 -04:00
8db135a523 version bump 2026-06-01 08:37:47 -04:00
bac35b8619 more parsing compats 2026-05-31 23:44:34 -04:00
5342a2c69f remove excessive file writes 2026-05-31 19:22:30 -04:00
53bac1976a kick! 2026-05-31 16:48:41 -04:00
9ce89325fd bump 2026-05-31 12:42:50 -04:00
139bf3b3fb remove completed runs from live flow 2026-05-31 12:42:09 -04:00
4d46c001bb version bump 2026-05-31 11:47:15 -04:00
424e2fa885 Add loop-active marker, YAML task file support, and auto-updating PRD checkboxes
- Persist loop-active state for widget re-instantiation after session reload
- Add YAML task file parsing and update support via yaml library
- Auto-update PRD source file checkboxes on task status changes
- Add batchRender callback for real-time parallel widget animation
- Normalize tabs-to-spaces indentation across source files
- Use padStart(2, '0') for ID formatting instead of hardcoded prefix
- Enable parallel execution for single-task DAG batches
2026-05-31 11:44:47 -04:00
30f177b4d9 bump 2026-05-31 09:30:39 -04:00
d2a7dfa5fe disable round-robin for sequential 2026-05-31 09:30:08 -04:00
99b944d10e bump 2026-05-31 09:11:53 -04:00
e10ab719d0 stop manual registration 2026-05-31 09:11:29 -04:00
7c7668cc6b bump 2026-05-31 08:58:15 -04:00
f4af013252 cleanup 2026-05-31 08:57:34 -04:00
18 changed files with 5572 additions and 269 deletions

View File

@@ -26,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
@@ -37,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)

View File

@@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2025 Michael Freno Copyright (c) 2026 Michael Freno
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal

View File

@@ -2,6 +2,10 @@
Execute tasks from task files until done 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
- **Parallel batching**: Independent tasks in each batch can run concurrently - **Parallel batching**: Independent tasks in each batch can run concurrently
@@ -17,17 +21,13 @@ Execute tasks from task files until done using DAG-based dependency resolution w
## Usage ## Usage
``` ```
/ralpi [task-file] # Execute all tasks /ralpi [task-file] # Execute all tasks
/ralpi plan # Alias to /task-manager to plan new tasks /ralpi plan # Alias to /task-manager to plan new tasks
/ralpi resume # Resume paused execution /ralpi resume # Resume paused execution
/ralpi reset [task-file] # Reset progress and .ralpi directory - does not modify PRD /ralpi reset # Reset progress and .ralpi directory - does not modify PRD
``` ```
## Task File Formats ### Highly recommended to use the task-manager prompt for prd construction, it's output pairs perfectly - /task-manager or /ralpi plan
### Highly recommended to use the task-manager prompt for prd construction, it's output pairs perfectly
# Project Title
## Tasks ## Tasks
@@ -54,30 +54,46 @@ tasks:
depends_on: ["01"] depends_on: ["01"]
``` ```
## Task IDs
Task IDs are zero-padded 2-digit strings (`01`, `02`, ...) with an optional
single lowercase letter suffix for sub-tasks inserted between two numbered
steps (e.g. `02b`, `02c`). The parser normalizes `2b``02b`.
```
- [ ] 01 — Setup
- [ ] 02 — Fix bugs
- [ ] 02b — Sub-step of 02 (inserted after the fact)
- [ ] 02c — Another sub-step of 02
- [ ] 03 — Continue
```
Use lettered sub-tasks when you discover mid-stream that a step needs to be
split. They let you preserve sibling numbering (`01`, `02`, `03`, ...) while
adding granularity between two existing steps.
## Dependencies ## Dependencies
### Arrow Notation (recommended): ### Arrow Notation (recommended)
1 -> 2,3,4 1 -> 2,3,4
5 -> 6 5 -> 6
This means: "Task 1 must complete before tasks 2, 3, and 4 can start." This means: "Task 1 must complete before tasks 2, 3, and 4 can start."
### Natural Language: ### Natural Language
13 depends on 17, 18, 19, 20 13 depends on 17, 18, 19, 20
14 depends on 13, 15, 16 14 depends on 13, 15, 16
This means: "Task 13 depends on tasks 17, 18, 19, and 20." This means: "Task 13 depends on tasks 17, 18, 19, and 20."
### Parallel Groups (informational only): ### Parallel Groups (informational only)
1, 2, 3, 4 can be done in parallel 1, 2, 3, 4 can be done in parallel
5, 6, 7, 8 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. Note: These lines are ignored by the parser. Use explicit dependencies to control execution order.
## Configuration ## Configuration
### Task-Level Timeout ### Task-Level Timeout
@@ -91,7 +107,6 @@ 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 ### Config files
| Scope | Path | | Scope | Path |
@@ -113,10 +128,11 @@ prompts:
> tasks, only the first two models are used. The third model is only touched when > 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 > a third concurrent task starts. Freed model slots are reused before new ones
> are allocated. > are allocated.
>
> **Automatic failover**: if a provider/API is unreachable (rate limit, 503, etc.), > **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 > 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. > 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

308
bun.lock Normal file
View 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=="],
}
}

516
index.ts
View File

@@ -9,17 +9,26 @@ import { parseTaskFile, updateTaskInFile } from "./src/parser";
import { import {
buildExecutionPlan, buildExecutionPlan,
buildSequentialPlan, buildSequentialPlan,
formatDependencyChain,
formatExecutionPlan, formatExecutionPlan,
} from "./src/dag"; } from "./src/dag";
import { ProgressTracker } from "./src/progress"; import { ProgressTracker } from "./src/progress";
import { buildPlanPrompt } from "./src/prompts"; import { buildPlanPrompt } from "./src/prompts";
import { formatReflections } from "./src/reflection"; import { formatReflections } from "./src/reflection";
import { executeBatch, type SendChatMessage } from "./src/executor"; import {
executeBatch,
SPINNER_FRAMES,
type SendChatMessage,
} from "./src/executor";
import { import {
loadConfig, loadConfig,
resolveTaskArg, resolveTaskArg,
formatProgressStatus, formatProgressStatus,
findProgressFile, findProgressFile,
writeLoopActive,
deleteLoopActive,
readLoopActive,
findRalpiDir,
} from "./src/utils"; } from "./src/utils";
const COMMANDS = ["plan", "resume", "reset"] as const; const COMMANDS = ["plan", "resume", "reset"] as const;
@@ -123,77 +132,96 @@ async function executePlanBatches(
sendChatMessage?: SendChatMessage, sendChatMessage?: SendChatMessage,
projectDir?: string, projectDir?: string,
): Promise<void> { ): Promise<void> {
// Write loop-active marker so widgets can be re-instantiated after a reload
if (projectDir) {
const allTaskIds = plan.batches.flatMap((b) => b.tasks.map((t) => t.id));
writeLoopActive(projectDir, {
taskFile,
mode,
startedAt: new Date().toISOString(),
taskIds: allTaskIds,
prdKey: progress.getKey(),
});
}
// Track failed task IDs across batches to block downstream tasks // Track failed task IDs across batches to block downstream tasks
const failedTaskIds = new Set(progress.getFailedTaskIds()); const failedTaskIds = new Set(progress.getFailedTaskIds());
for (const batch of plan.batches) { try {
if (progress.getState().paused) { for (const batch of plan.batches) {
ctx.ui.notify( if (progress.getState().paused) {
"Execution paused. Use /ralpi resume to continue.", ctx.ui.notify(
"warning", "Execution paused. Use /ralpi resume to continue.",
); "warning",
return; );
} return;
}
if (!Array.isArray(batch.tasks)) { if (!Array.isArray(batch.tasks)) {
throw new Error( throw new Error(
`Batch ${ `Batch ${
batch.batchIndex batch.batchIndex
} has invalid tasks: expected array, got ${typeof batch.tasks}`, } has invalid tasks: expected array, got ${typeof batch.tasks}`,
); );
} }
await executeBatch( await executeBatch(
batch.tasks, batch.tasks,
project,
config,
progress,
ctx,
{ parallel: mode === "parallel" },
sendChatMessage,
projectDir,
);
for (const task of batch.tasks) {
const status = progress.getTaskStatus(task.id);
updateTaskInFile(taskFile, task.id, status);
}
// Update failed task IDs after batch completes
const newFailed = progress.getFailedTaskIds();
for (const id of newFailed) {
failedTaskIds.add(id);
}
// In sequential mode, stop after any failure
if (mode === "sequential" && failedTaskIds.size > 0) {
break;
}
// In parallel mode, rebuild the plan to filter out newly blocked tasks
if (mode === "parallel") {
const completed = new Set(progress.getCompletedTaskIds());
const newPlan = buildExecutionPlan(
project, project,
completed, config,
undefined, progress,
failedTaskIds, ctx,
{ parallel: mode === "parallel" },
sendChatMessage,
projectDir,
); );
// Replace remaining batches with filtered ones for (const task of batch.tasks) {
const currentIdx = plan.batches.indexOf(batch); const status = progress.getTaskStatus(task.id);
const remainingBatches = newPlan.batches.filter( updateTaskInFile(taskFile, task.id, status);
(b) => b.batchIndex > currentIdx, }
);
// Update the plan's batches in-place // Update failed task IDs after batch completes
plan.batches.length = 0; const newFailed = progress.getFailedTaskIds();
plan.batches.push(...remainingBatches); for (const id of newFailed) {
failedTaskIds.add(id);
}
// Skip empty batches // In sequential mode, stop after any failure
if (remainingBatches.length === 0) { if (mode === "sequential" && failedTaskIds.size > 0) {
break; break;
} }
// In parallel mode, rebuild the plan to filter out newly blocked tasks
if (mode === "parallel") {
// Use buildCompletedSet to include file-based [x] completions
// (progress.getCompletedTaskIds() only knows about tasks completed
// during THIS execution session — tasks that were already [x] in the
// file before the run started would be re-included and re-executed).
const completed = buildCompletedSet(progress, project);
const newPlan = buildExecutionPlan(
project,
completed,
undefined,
failedTaskIds,
);
// Keep processed batches (up to current batch), replace the rest
// with the fresh plan — its batchIndex restarts at 0, so filtering
// by batchIndex > currentIdx would incorrectly drop the next batch.
const processedCount = plan.batches.indexOf(batch) + 1;
plan.batches.length = processedCount;
plan.batches.push(...newPlan.batches);
// Skip if nothing remaining
if (plan.batches.length === processedCount) {
break;
}
}
}
} finally {
if (projectDir) {
deleteLoopActive(projectDir);
} }
} }
} }
@@ -259,14 +287,339 @@ export default function ralpiLoopExtension(pi: ExtensionAPI): void {
}, },
); );
// Register the extension's prompts/ directory so Pi discovers @task-manager // ─── Reload detection: re-instantiate widgets when session reloads ──────
pi.on("resources_discover", async (_event, _ctx) => { //
const promptsDir = fs.existsSync(path.resolve(__dirname, "prompts")) // When the user types /reload while ralpi tasks are executing, the old
? path.resolve(__dirname, "prompts") // ExtensionContext is torn down and widgets (created via ctx.ui.setWidget)
: path.resolve(__dirname, "..", "prompts"); // disappear. This handler detects the reload, reads the persisted loop-active
return { // marker and progress.json, and re-creates live-status widgets that show
promptPaths: [promptsDir], // task progress with spinner animation and tool calls from session files.
pi.on("session_start", async (event, ctx) => {
if (event.reason !== "reload") return;
// Find the ralpi project directory
const projectDir = findRalpiDir(ctx.cwd);
if (!projectDir) return;
// Check if a task execution loop was active before the reload
const loopState = readLoopActive(projectDir);
if (!loopState) return;
// Load progress state
let abortPolling = false;
const progressPath = path.join(projectDir, ".ralpi", "progress.json");
const sessionsDir = path.join(projectDir, ".ralpi", "sessions");
// Parse the task file to get task titles
const titleMap = new Map<string, string>();
try {
const project = parseTaskFile(loopState.taskFile);
for (const task of project.tasks) {
titleMap.set(task.id, task.title);
}
} catch {
// If parsing fails, just use IDs without titles
}
/** Read recent tool calls from a task's session file. */
const readRecentToolCalls = (
taskId: string,
maxLines = 30,
): Array<{ name: string; label: string }> => {
try {
const files = fs
.readdirSync(sessionsDir)
.filter((f) => f.startsWith(taskId + "-"))
.sort();
if (files.length === 0) return [];
const sessionPath = path.join(sessionsDir, files[files.length - 1]);
const content = fs.readFileSync(sessionPath, "utf-8");
const lines = content
.split("\n")
.filter((l) => l.trim())
.slice(-maxLines);
const calls: Array<{ name: string; label: string }> = [];
for (const line of lines) {
try {
const event = JSON.parse(line);
if (event.type === "tool_execution_start") {
calls.push({
name: event.toolName,
label: formatToolLabel(event.toolName, event.args),
});
}
} catch {
// Skip malformed lines
}
}
return calls;
} catch {
return [];
}
}; };
/**
* Strip control characters and newlines from a display label so it
* does not break TUI layout (tree branches, text width calculation).
*/
function sanitizeLabel(s: string): string {
return s
.replace(/\r?\n/g, " ")
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "")
.trim();
}
/** Format a tool call argument into a short label. */
function formatToolLabel(name: string, args: unknown): string {
const a = args as Record<string, unknown> | undefined;
if (!a) return name;
if (name === "bash")
return sanitizeLabel(String(a.command ?? "").slice(0, 70));
if (name === "write" || name === "read" || name === "edit")
return sanitizeLabel(String(a.path ?? "").slice(0, 60));
if (name === "grep")
return sanitizeLabel(
`${a.pattern ?? "?"}${String(a.path ?? "").slice(0, 40)}`,
);
if (name === "find")
return sanitizeLabel(`${a.path ?? "."}${a.glob ?? "*"}`);
if (name === "ls")
return sanitizeLabel(String(a.path ?? ".").slice(0, 60));
return name;
}
/** Re-read progress from disk (old tasks still writing to it). */
const readTasks = (): Record<string, { status: string }> | null => {
try {
const raw = fs.readFileSync(progressPath, "utf-8");
const parsed = JSON.parse(raw) as Record<string, any>;
return parsed.prds?.[loopState.prdKey]?.tasks ?? parsed.tasks ?? null;
} catch {
return null;
}
};
// Early exit: if all tasks already finished during the reload, just clean up
const initialTasks = readTasks();
if (initialTasks) {
const remaining = Object.values(initialTasks).filter(
(t) => t.status === "in_progress",
).length;
if (remaining === 0) {
ctx.ui.notify("All ralpi tasks completed during reload.", "info");
deleteLoopActive(projectDir);
return;
}
}
// Show a status notification for the reconnect
const taskCount = loopState.taskIds.length;
ctx.ui.notify(
`Reconnected to running ralpi execution (${taskCount} tasks, ${loopState.mode} mode)`,
"info",
);
// Shared state for the widget
let tickCount = 0;
const MAX_COLLAPSED = 3;
if (loopState.mode === "parallel") {
// ── Parallel mode: single batch widget ──
const widgetKey = `ralpi-parallel-reconnect-${Date.now()}`;
let widgetTui: { requestRender(): void } | null = null;
const buildBatchLines = (t: typeof ctx.ui.theme): string[] => {
const tasks = readTasks();
if (!tasks) return [t.fg("dim", "(waiting for progress...)")];
const lines: string[] = [];
// Only show tasks that have started (in_progress, completed, failed).
// Pending/unstarted tasks are noise after a reload.
const sortedIds = [...loopState.taskIds].sort().filter((id) => {
const info = tasks[id];
return info && info.status !== "pending";
});
// If no tasks have started yet, show nothing — polling will pick up
// changes within 500ms.
if (sortedIds.length === 0) return [t.fg("dim", "(starting tasks...)")];
for (const id of sortedIds) {
const info = tasks[id]!;
const title = titleMap.get(id);
const header = title ? `${id} · ${title}` : id;
// Status icon
if (info.status === "completed") {
lines.push(`${t.fg("success", "✓")} ${header}`);
} else if (info.status === "failed") {
lines.push(`${t.fg("error", "✗")} ${header}`);
} else if (info.status === "in_progress") {
const frame = t.fg(
"accent",
SPINNER_FRAMES[tickCount % SPINNER_FRAMES.length],
);
lines.push(`${frame} ${header}`);
// Show recent tool calls for active tasks
const toolCalls = readRecentToolCalls(id);
if (toolCalls.length > 0) {
if (toolCalls.length <= MAX_COLLAPSED) {
for (let i = 0; i < toolCalls.length; i++) {
const tc = toolCalls[i];
const isLast = i === toolCalls.length - 1;
const branch = isLast ? " └── " : " ├── ";
lines.push(
`${branch}${t.fg("accent", `[${tc.name}]`)} ${tc.label}`,
);
}
} else {
const shown = toolCalls.slice(-MAX_COLLAPSED);
const remaining = toolCalls.length - shown.length;
lines.push(t.fg("dim", ` ├── …${remaining} earlier`));
for (let i = 0; i < shown.length; i++) {
const tc = shown[i];
const isLast = i === shown.length - 1;
const branch = isLast ? " └── " : " ├── ";
lines.push(
`${branch}${t.fg("accent", `[${tc.name}]`)} ${tc.label}`,
);
}
}
}
}
}
return lines;
};
ctx.ui.setWidget(widgetKey, (tui, t) => {
widgetTui = tui;
return {
render: () => buildBatchLines(t),
invalidate: () => widgetTui?.requestRender(),
};
});
// 100ms tick: advances spinner frame every tick, refreshes
// progress + tool calls every 5 ticks (500ms).
const tickTimer = setInterval(() => {
if (abortPolling) return;
tickCount++;
widgetTui?.requestRender();
if (tickCount % 5 === 0) {
const tasks = readTasks();
if (!tasks) return;
const activeCount = Object.values(tasks).filter(
(t) => t.status === "in_progress",
).length;
if (activeCount === 0) {
clearInterval(tickTimer);
ctx.ui.setWidget(widgetKey, undefined);
deleteLoopActive(projectDir);
}
}
}, 100);
// Clean up timer when extension is shut down
pi.on("session_shutdown", () => {
abortPolling = true;
clearInterval(tickTimer);
});
} else {
// ── Sequential mode: per-task widget ──
const currentTaskId = loopState.taskIds.find((id) => {
const tasks = readTasks();
return tasks?.[id]?.status === "in_progress";
});
if (currentTaskId) {
const widgetKey = `ralpi-task-${currentTaskId}`;
let widgetTui: { requestRender(): void } | null = null;
const buildLines = (t: typeof ctx.ui.theme): string[] => {
const tasks = readTasks();
const info = tasks?.[currentTaskId];
const title = titleMap.get(currentTaskId);
const header = title ? `${currentTaskId} · ${title}` : currentTaskId;
const lines: string[] = [];
if (!info || info.status === "pending") {
return [t.fg("dim", "(starting task...)")];
}
if (info.status === "completed") {
lines.push(`${t.fg("success", "✓")} ${header}`);
} else if (info.status === "failed") {
lines.push(`${t.fg("error", "✗")} ${header}`);
} else if (info.status === "in_progress") {
const frame = t.fg(
"accent",
SPINNER_FRAMES[tickCount % SPINNER_FRAMES.length],
);
lines.push(`${frame} ${header}`);
// Show recent tool calls
const toolCalls = readRecentToolCalls(currentTaskId);
if (toolCalls.length > 0) {
const shown = toolCalls.slice(-MAX_COLLAPSED);
const remaining = toolCalls.length - shown.length;
if (remaining > 0) {
lines.push(t.fg("dim", ` ├── …${remaining} earlier`));
}
for (let i = 0; i < shown.length; i++) {
const tc = shown[i];
const isLast = i === shown.length - 1;
const branch = isLast ? " └── " : " ├── ";
lines.push(
`${branch}${t.fg("accent", `[${tc.name}]`)} ${tc.label}`,
);
}
}
}
return lines;
};
ctx.ui.setWidget(widgetKey, (tui, t) => {
widgetTui = tui;
return {
render: () => buildLines(t),
invalidate: () => widgetTui?.requestRender(),
};
});
const tickTimer = setInterval(() => {
if (abortPolling) return;
tickCount++;
widgetTui?.requestRender();
if (tickCount % 5 === 0) {
const tasks = readTasks();
if (!tasks) return;
const status = tasks[currentTaskId]?.status;
if (status !== "in_progress") {
clearInterval(tickTimer);
// Keep widget visible a moment, then clean up
setTimeout(() => {
ctx.ui.setWidget(widgetKey, undefined);
deleteLoopActive(projectDir);
}, 3000);
}
}
}, 100);
pi.on("session_shutdown", () => {
abortPolling = true;
clearInterval(tickTimer);
});
} else {
// No task actively in progress — show a "resume" hint
ctx.ui.notify(
"No running task found. Use /ralpi resume to continue execution.",
"warning",
);
}
}
}); });
pi.registerCommand("ralpi", { pi.registerCommand("ralpi", {
@@ -433,9 +786,20 @@ async function handleRun(
const mode = await selectExecutionMode(ctx, project, taskFile, config); const mode = await selectExecutionMode(ctx, project, taskFile, config);
const plan = buildPlanByMode(mode, project, completed); const plan = buildPlanByMode(mode, project, completed);
// Show execution plan before starting so user can see batch breakdown // Show dependency chain + execution plan before starting
const depChain = formatDependencyChain(project);
const formattedPlan = formatExecutionPlan(plan); const formattedPlan = formatExecutionPlan(plan);
ctx.ui.notify(`${formattedPlan}\n\nStarting ${mode} execution...`, "info"); if (mode === "parallel") {
ctx.ui.notify(
`${depChain}\n\n${formattedPlan}\n\nStarting parallel execution...`,
"info",
);
} else {
ctx.ui.notify(
`${formattedPlan}\n\nStarting sequential execution...`,
"info",
);
}
await executePlanBatches( await executePlanBatches(
plan, plan,
@@ -521,6 +885,20 @@ async function handleResume(
const mode = await selectExecutionMode(ctx, project, taskFile, config); const mode = await selectExecutionMode(ctx, project, taskFile, config);
const plan = buildPlanByMode(mode, project, completed); const plan = buildPlanByMode(mode, project, completed);
// Print remaining batches before executing
const formattedPlan = formatExecutionPlan(plan);
if (mode === "parallel") {
ctx.ui.notify(
`${formattedPlan}\n\nResuming parallel execution...`,
"info",
);
} else {
ctx.ui.notify(
`${formattedPlan}\n\nResuming sequential execution...`,
"info",
);
}
await executePlanBatches( await executePlanBatches(
plan, plan,
project, project,

View File

@@ -1,6 +1,6 @@
{ {
"name": "ralpi", "name": "@mikefreno/ralpi",
"version": "0.1.0", "version": "0.2.5",
"description": "Execute tasks from task files/PRD's 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",
"keywords": [ "keywords": [
"pi-package", "pi-package",
@@ -31,7 +31,8 @@
], ],
"scripts": { "scripts": {
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"prepublishOnly": "tsc --noEmit" "prepublishOnly": "tsc --noEmit",
"test": "bun test"
}, },
"engines": { "engines": {
"bun": ">=1.1.0" "bun": ">=1.1.0"
@@ -59,6 +60,7 @@
}, },
"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"
} }
} }

View File

@@ -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 (ArrangeActAssert) - Unit: which functions/modules to cover (ArrangeActAssert)
- 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

View File

@@ -1,4 +1,10 @@
import type { Task, ExecutionBatch, ExecutionPlan, Project } from "./types"; import type {
Task,
ExecutionBatch,
ExecutionPlan,
Project,
ParallelGroup,
} from "./types";
// ─── Blocked Tasks ─────────────────────────────────────────────────────────── // ─── Blocked Tasks ───────────────────────────────────────────────────────────
@@ -18,7 +24,7 @@ export function getBlockedTasks(
for (const task of pendingTasks) { for (const task of pendingTasks) {
if (blocked.has(task.id)) continue; if (blocked.has(task.id)) continue;
const deps = task.dependencies || []; const deps = task.dependencies || [];
if (deps.some((dep) => failedTaskIds.has(dep))) { if (deps.some((dep) => failedTaskIds.has(dep) || blocked.has(dep))) {
blocked.add(task.id); blocked.add(task.id);
changed = true; changed = true;
} }
@@ -40,15 +46,36 @@ export function buildExecutionPlan(
parallelGroup?: number, parallelGroup?: number,
failedTaskIds: Set<string> = new Set(), failedTaskIds: Set<string> = new Set(),
): ExecutionPlan { ): ExecutionPlan {
// Filter out already completed tasks // Filter out already completed AND failed tasks
const pendingTasks = project.tasks.filter((t) => !completed.has(t.id)); // 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),
);
// If parallel_group is explicitly set, use group-based batching // With explicitly declared parallel groups, all groups are independent.
// Since there are no cross-group dependencies by definition, standard
// Kahn's algorithm produces the correct plan — tasks ready in any group
// appear in the same batch, and intra-group dependencies (e.g. "21 must
// be done before 22, 23, 24") are respected automatically.
// The parallel groups are preserved as metadata for display/documentation.
if (project.parallelGroups && project.parallelGroups.length > 0) {
return {
batches: buildGroupAwareBatches(project, pendingTasks, failedTaskIds),
totalTasks: pendingTasks.length,
skippedTasks,
};
}
// If parallel_group is explicitly set (legacy config flag), use group-based batching
if (parallelGroup !== undefined) { if (parallelGroup !== undefined) {
return { return {
batches: buildParallelGroupBatches(pendingTasks, failedTaskIds), batches: buildParallelGroupBatchesLegacy(pendingTasks, failedTaskIds),
totalTasks: pendingTasks.length, totalTasks: pendingTasks.length,
skippedTasks: project.tasks.filter((t) => completed.has(t.id)), skippedTasks,
}; };
} }
@@ -56,7 +83,7 @@ export function buildExecutionPlan(
return { return {
batches: buildBatches(pendingTasks, failedTaskIds), batches: buildBatches(pendingTasks, failedTaskIds),
totalTasks: pendingTasks.length, totalTasks: pendingTasks.length,
skippedTasks: project.tasks.filter((t) => completed.has(t.id)), skippedTasks,
}; };
} }
@@ -139,13 +166,72 @@ 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[],
failedTaskIds: Set<string>,
): ExecutionBatch[] {
const blocked = getBlockedTasks(pendingTasks, failedTaskIds);
const activeTasks = pendingTasks.filter((t) => !blocked.has(t.id));
// Standard Kahn's algorithm across ALL tasks — parallel groups are
// metadata for display, not scheduling constraints.
const pendingSet = new Set(pendingTasks.map((t) => t.id));
const done = new Set<string>();
const remaining = new Set(activeTasks.map((t) => t.id));
const batches: ExecutionBatch[] = [];
while (remaining.size > 0) {
const ready: Task[] = [];
for (const task of activeTasks) {
if (!remaining.has(task.id)) continue;
const deps = task.dependencies || [];
const depsSatisfied = deps.every(
(dep) => done.has(dep) || !pendingSet.has(dep),
);
if (depsSatisfied) {
ready.push(task);
}
}
if (ready.length === 0) {
throw new Error(
`Dependency cycle detected: ${Array.from(remaining).join(", ")}`,
);
}
batches.push({ tasks: ready, batchIndex: batches.length });
for (const task of ready) {
done.add(task.id);
remaining.delete(task.id);
}
}
return batches;
}
// ─── Legacy Parallel Group Batching ─────────────────────────────────────────
/**
* Legacy: build batches from explicit parallel_group values only.
* Groups execute in ascending order; tasks within a group run concurrently.
* Does NOT respect intra-group dependencies.
*/
function buildParallelGroupBatchesLegacy(
pendingTasks: Task[], pendingTasks: Task[],
failedTaskIds: Set<string>, failedTaskIds: Set<string>,
): ExecutionBatch[] { ): ExecutionBatch[] {
@@ -309,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(", ")}`,
@@ -331,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("");
} }

View File

@@ -11,8 +11,12 @@ import {
writeFileSafe, writeFileSafe,
ensureDir, ensureDir,
captureGitCommits, captureGitCommits,
hasUncommittedChanges,
getGitStatusPorcelain,
getGitDiff,
formatDuration, formatDuration,
} from "./utils"; } from "./utils";
import { updateTaskInFile } from "./parser";
/** Optional callback to post a progress message into the chat history. */ /** Optional callback to post a progress message into the chat history. */
export type SendChatMessage = ( export type SendChatMessage = (
@@ -33,7 +37,18 @@ export interface ToolCallEntry {
* messages rendered by registerMessageRenderer). */ * messages rendered by registerMessageRenderer). */
const MAX_COLLAPSED = 3; const MAX_COLLAPSED = 3;
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; export const SPINNER_FRAMES = [
"⠋",
"⠙",
"⠹",
"⠸",
"⠼",
"⠴",
"⠦",
"⠧",
"⠇",
"⠏",
];
// ─── Model Round-Robin ───────────────────────────────────────────────────── // ─── Model Round-Robin ─────────────────────────────────────────────────────
@@ -135,6 +150,7 @@ export async function runTask(
projectDir: string = project.sourceDir, projectDir: string = project.sourceDir,
parallelState?: ParallelWidgetState, parallelState?: ParallelWidgetState,
assignedModel?: unknown, assignedModel?: unknown,
batchRender?: () => void,
): Promise<{ ): Promise<{
success: boolean; success: boolean;
reflection?: Reflection; reflection?: Reflection;
@@ -142,7 +158,6 @@ export async function runTask(
durationMs: number; durationMs: number;
toolUsage?: ToolUsage; toolUsage?: ToolUsage;
outputPreview?: string; outputPreview?: string;
sessionFile?: string;
commitMessages?: string[]; commitMessages?: string[];
commitSummary?: string; commitSummary?: string;
}> { }> {
@@ -156,12 +171,6 @@ export async function runTask(
config.prompts.projectContext, config.prompts.projectContext,
); );
// Write prompt to .ralpi/ with timestamp (for debugging)
const ralpiDir = path.join(projectDir, ".ralpi");
ensureDir(ralpiDir);
const promptFile = path.join(ralpiDir, `prompt-${startMs}.md`);
writeFileSafe(promptFile, prompt);
const taskHeader = `${task.id} · ${task.title}`; const taskHeader = `${task.id} · ${task.title}`;
// When running in parallel, all tasks share a single widget so ordering // When running in parallel, all tasks share a single widget so ordering
@@ -249,11 +258,6 @@ export async function runTask(
// Use task-level timeout if set, otherwise fall back to config // Use task-level timeout if set, otherwise fall back to config
const timeoutMs = task.timeoutMs ?? config.execution.timeoutMs; const timeoutMs = task.timeoutMs ?? config.execution.timeoutMs;
// Pre-create session file path so events stream to disk (avoids 300+ MB in-memory accumulation)
const sessionsDir = path.join(ralpiDir, "sessions");
ensureDir(sessionsDir);
const sessionFilePath = path.join(sessionsDir, `${task.id}-${startMs}.txt`);
// Run task asynchronously via Pi SDK — event loop stays responsive // Run task asynchronously via Pi SDK — event loop stays responsive
const output = await runAgentSession( const output = await runAgentSession(
prompt, prompt,
@@ -271,12 +275,13 @@ export async function runTask(
if (entry) { if (entry) {
entry.toolCalls.push({ name: event.toolName, label }); entry.toolCalls.push({ name: event.toolName, label });
} }
batchRender?.();
} else {
requestRender();
} }
requestRender();
} }
}, },
undefined, // no abort signal undefined, // no abort signal
sessionFilePath, // stream events to file
assignedModel ?? config.model, assignedModel ?? config.model,
config.thinkingLevel, config.thinkingLevel,
); );
@@ -291,6 +296,7 @@ export async function runTask(
entry.done = true; entry.done = true;
entry.success = output.success; entry.success = output.success;
} }
batchRender?.();
} else { } else {
ctx.ui.setWidget(widgetKey, undefined); ctx.ui.setWidget(widgetKey, undefined);
} }
@@ -302,7 +308,6 @@ export async function runTask(
success: false, success: false,
error: output.error, error: output.error,
durationMs, durationMs,
sessionFile: sessionFilePath, // events streamed to file for debugging
}; };
} }
@@ -312,13 +317,10 @@ export async function runTask(
// Capture git commits made during this task // Capture git commits made during this task
const { commitMessages, commitSummary } = captureGitCommits(projectDir); const { commitMessages, commitSummary } = captureGitCommits(projectDir);
// Session file already written by runAgentSession (events streamed to disk)
const sessionFile = sessionFilePath;
// Build output preview (first 500 chars of agent text) // Build output preview (first 500 chars of agent text)
const outputPreview = const outputPreview =
agentText.length > 500 agentText.length > 500
? agentText.slice(0, 500) + "\n... (truncated, see session file)" ? agentText.slice(0, 500) + "\n... (truncated)"
: agentText; : agentText;
// Extract reflection from agent output // Extract reflection from agent output
@@ -334,7 +336,6 @@ export async function runTask(
durationMs, durationMs,
toolUsage, toolUsage,
outputPreview, outputPreview,
sessionFile,
commitMessages, commitMessages,
commitSummary, commitSummary,
}; };
@@ -393,9 +394,12 @@ export async function executeBatch(
} }
} }
// Check if we should run parallel // Check if we should run parallel.
// Use the parallel path whenever the user selected parallel mode,
// even for single-task batches produced by DAG dependency chains.
// Only sequential mode should inherit the parent session model.
const shouldParallel = const shouldParallel =
options?.parallel && tasks.length > 1 && config.execution.maxParallel > 0; options?.parallel && tasks.length > 0 && config.execution.maxParallel > 0;
if (shouldParallel) { if (shouldParallel) {
await executeBatchParallel( await executeBatchParallel(
@@ -411,10 +415,9 @@ export async function executeBatch(
return; return;
} }
// Execute sequentially // Execute sequentially (no round-robin — inherit parent model)
for (const task of tasks) { for (const task of tasks) {
try { try {
const model = roundRobin?.assign(task.id);
await executeTask( await executeTask(
task, task,
project, project,
@@ -423,16 +426,19 @@ export async function executeBatch(
ctx, ctx,
sendChatMessage, sendChatMessage,
projectDir, projectDir,
undefined,
model,
roundRobin,
); );
} catch (error) { } catch (error) {
// Task failed — stop the batch. Dependent tasks are blocked by // Task failed — stop the batch. Dependent tasks are blocked by
// the DAG layer (getBlockedTasks) so they won't appear in this batch. // the DAG layer (getBlockedTasks) so they won't appear in this batch.
roundRobin?.release(task.id);
const errorMsg = error instanceof Error ? error.message : String(error); const errorMsg = error instanceof Error ? error.message : String(error);
progress.markFailed(task.id, errorMsg); progress.markFailed(task.id, errorMsg);
// Auto-update the PRD source file checkbox
try {
updateTaskInFile(project.sourcePath, task.id, "failed");
} catch {
// Best-effort
}
sendChatMessage?.(`${task.id} · ${task.title}${errorMsg}`); sendChatMessage?.(`${task.id} · ${task.title}${errorMsg}`);
ctx.ui.notify(`Task ${task.id} failed: ${errorMsg}`, "error"); ctx.ui.notify(`Task ${task.id} failed: ${errorMsg}`, "error");
break; break;
@@ -479,7 +485,9 @@ async function executeBatchParallel(
truncateToWidth(`${frame} ${entry.taskHeader}`, effectiveWidth), truncateToWidth(`${frame} ${entry.taskHeader}`, effectiveWidth),
); );
if (entry.toolCalls.length > 0) { // Only show tool calls for in-progress tasks; completed/failed
// tasks already have their tool-call tree in the chat history message.
if (!entry.done && entry.toolCalls.length > 0) {
if (entry.toolCalls.length <= MAX_COLLAPSED) { if (entry.toolCalls.length <= MAX_COLLAPSED) {
for (let i = 0; i < entry.toolCalls.length; i++) { for (let i = 0; i < entry.toolCalls.length; i++) {
const tc = entry.toolCalls[i]; const tc = entry.toolCalls[i];
@@ -522,23 +530,34 @@ async function executeBatchParallel(
}; };
}); });
// Single spinner timer drives all tasks in the batch // Batch-render trigger: re-render on spinner ticks AND content changes.
// Spinner animation requires requestRender() on every tick; without it,
// spinner frames advance in memory but the display never updates.
const requestBatchRender = () => widgetTui?.requestRender();
const spinnerTimer = setInterval(() => { const spinnerTimer = setInterval(() => {
for (const entry of sharedState.values()) { for (const entry of sharedState.values()) {
if (!entry.done) { if (!entry.done) {
entry.frameIndex = (entry.frameIndex + 1) % SPINNER_FRAMES.length; entry.frameIndex = (entry.frameIndex + 1) % SPINNER_FRAMES.length;
} }
} }
widgetTui?.requestRender(); requestBatchRender();
}, 100); }, 100);
const results: Array<{ task: Task; result: Promise<any> }> = []; // Semaphore-based concurrency control:
// Start up to maxParallel tasks immediately. When ANY task completes,
// start the next pending task. This ensures slots fill as soon as they
// open, instead of blocking on the oldest task (FIFO pattern).
const pending = [...tasks];
const running = new Set<Promise<void>>();
for (const task of tasks) { /** Start the next pending task if a slot is available. */
const assignedModel = roundRobin?.assign(task.id); const kick = (): void => {
results.push({ while (running.size < maxParallel && pending.length > 0) {
task, const task = pending.shift()!;
result: executeTask( const assignedModel = roundRobin?.assign(task.id);
const p = executeTask(
task, task,
project, project,
config, config,
@@ -549,28 +568,44 @@ async function executeBatchParallel(
sharedState, sharedState,
assignedModel, assignedModel,
roundRobin, roundRobin,
).catch((error) => { requestBatchRender,
// Safety net: one task failure should never crash the batch. )
// executeTask already marks failed and notifies, but catch as .catch((error) => {
// a last resort so the error doesn't propagate and crash pi. // Safety net: one task failure should never crash the batch.
roundRobin?.release(task.id); // executeTask already marks failed and notifies, but catch as
const errorMsg = error instanceof Error ? error.message : String(error); // a last resort so the error doesn't propagate and crash pi.
progress.markFailed(task.id, errorMsg); roundRobin?.release(task.id);
sendChatMessage?.(`${task.id} · ${task.title}${errorMsg}`); requestBatchRender();
ctx.ui.notify(`Task ${task.id} failed: ${errorMsg}`, "error"); const errorMsg =
}), error instanceof Error ? error.message : String(error);
}); progress.markFailed(task.id, errorMsg);
// Auto-update the PRD source file checkbox
try {
updateTaskInFile(project.sourcePath, task.id, "failed");
} catch {
// Best-effort
}
sendChatMessage?.(`${task.id} · ${task.title}${errorMsg}`);
ctx.ui.notify(`Task ${task.id} failed: ${errorMsg}`, "error");
})
.finally(() => {
// Remove from running set and start next pending task
running.delete(p);
requestBatchRender();
kick();
});
// Limit concurrency running.add(p);
if (results.length >= maxParallel) {
const first = results.shift();
if (first) await first.result;
} }
} };
// Wait for remaining tasks // Kick off initial batch of tasks (up to maxParallel)
for (const { result } of results) { kick();
await result;
// Wait for all tasks to complete (kick() adds new promises to `running`
// when completed tasks free up slots, so we iterate until the set is empty).
while (running.size > 0) {
await Promise.race(running);
} }
clearInterval(spinnerTimer); clearInterval(spinnerTimer);
@@ -590,6 +625,7 @@ async function executeTask(
parallelState?: ParallelWidgetState, parallelState?: ParallelWidgetState,
assignedModel?: unknown, assignedModel?: unknown,
roundRobin?: ModelRoundRobin | null, roundRobin?: ModelRoundRobin | null,
batchRender?: () => void,
): Promise<void> { ): Promise<void> {
const maxRetries = config.execution.maxRetries; const maxRetries = config.execution.maxRetries;
@@ -613,6 +649,12 @@ async function executeTask(
try { try {
// Mark as in progress // Mark as in progress
progress.markInProgress(task.id); progress.markInProgress(task.id);
// Auto-update the PRD source file checkbox
try {
updateTaskInFile(project.sourcePath, task.id, "in_progress");
} catch {
// Best-effort: don't fail the task over a checkbox update
}
// Get dependency reflections // Get dependency reflections
const depReflections = progress.getDependencyReflections( const depReflections = progress.getDependencyReflections(
@@ -630,9 +672,181 @@ async function executeTask(
projectDir, projectDir,
parallelState, parallelState,
currentModel, currentModel,
batchRender,
); );
if (result.success) { if (result.success) {
// ── Auto-Commit: Trigger follow-up agent session for uncommitted changes ──
let finalCommitMessages = result.commitMessages ?? [];
let finalCommitSummary = result.commitSummary ?? "";
try {
if (hasUncommittedChanges(projectDir)) {
const status = getGitStatusPorcelain(projectDir);
const diff = getGitDiff(projectDir);
const commitPrompt = [
`## Auto-Commit for Task ${task.id}: ${task.title}`,
"",
"The previous task is complete. There are uncommitted changes in the repository.",
"",
"Only commit changes you made while completing this task. Do not commit pre-existing changes, changes from other work, or files unrelated to this task.",
"Review the git status and diff below to identify which changes are from your work, and stage only those files.",
"",
"Stage only the files relevant to this task with `git add <files>`, then create a meaningful git commit.",
"Use a descriptive commit message and follow conventional commits format.",
"",
"### Current Changes (git status --porcelain)",
"```text",
status || "(no status output)",
"```",
"",
"### Current Tracked Diff (git diff)",
"```diff",
diff || "(no tracked diff output)",
"```",
].join("\n");
// ── Commit widget setup ──
const commitWidgetKey = `ralpi-commit-${task.id}`;
let commitFrameIndex = 0;
const commitToolCalls: ToolCallEntry[] = [];
let commitWidgetTui: { requestRender(): void } | null = null;
const commitHeader = `commit for ${task.id} · ${task.title}`;
const buildCommitLines = (
t: typeof ctx.ui.theme,
width?: number,
): string[] => {
const effectiveWidth = width || 74;
const frame = t.fg(
"accent",
SPINNER_FRAMES[commitFrameIndex % SPINNER_FRAMES.length],
);
const lines = [
truncateToWidth(`~ ${frame} ${commitHeader}`, effectiveWidth),
];
if (commitToolCalls.length > 0) {
if (commitToolCalls.length <= MAX_COLLAPSED) {
for (let i = 0; i < commitToolCalls.length; i++) {
const entry = commitToolCalls[i];
const isLast = i === commitToolCalls.length - 1;
const branch = isLast ? " └── " : " ├── ";
const tag = t.fg("accent", `[${entry.name}]`);
lines.push(
truncateToWidth(
`${branch}${tag} ${entry.label}`,
effectiveWidth,
),
);
}
} else {
const shown = commitToolCalls.slice(-MAX_COLLAPSED);
const remaining = commitToolCalls.length - shown.length;
lines.push(
truncateToWidth(
t.fg("dim", ` ├── …${remaining} earlier`),
effectiveWidth,
),
);
for (let i = 0; i < shown.length; i++) {
const entry = shown[i];
const isLast = i === shown.length - 1;
const branch = isLast ? " └── " : " ├── ";
const tag = t.fg("accent", `[${entry.name}]`);
lines.push(
truncateToWidth(
`${branch}${tag} ${entry.label}`,
effectiveWidth,
),
);
}
}
}
return lines;
};
ctx.ui.setWidget(commitWidgetKey, (tui, t) => {
commitWidgetTui = tui;
return {
render: (width?: number) => buildCommitLines(t, width),
invalidate: () => commitWidgetTui?.requestRender(),
};
});
const requestCommitRender = () =>
commitWidgetTui?.requestRender();
const commitSpinnerTimer = setInterval(() => {
commitFrameIndex =
(commitFrameIndex + 1) % SPINNER_FRAMES.length;
requestCommitRender();
}, 100);
// Use a short timeout for the commit session (60s should be enough)
const commitTimeout = Math.min(
60_000,
config.execution.timeoutMs,
);
let commitResult: Awaited<ReturnType<typeof runAgentSession>>;
try {
commitResult = await runAgentSession(
commitPrompt,
projectDir,
commitTimeout,
(event) => {
if (event.type === "tool_execution_start") {
const label = formatToolArg(event.toolName, event.args);
commitToolCalls.push({
name: event.toolName,
label,
});
requestCommitRender();
}
},
undefined,
currentModel,
config.thinkingLevel,
);
} finally {
clearInterval(commitSpinnerTimer);
ctx.ui.setWidget(commitWidgetKey, undefined);
}
if (commitResult.success) {
// Re-capture commits made during this follow-up session
const newCommits = captureGitCommits(projectDir);
if (newCommits.commitMessages.length > 0) {
finalCommitMessages = [
...finalCommitMessages,
...newCommits.commitMessages,
];
finalCommitSummary = finalCommitSummary
? `${finalCommitSummary}; ${newCommits.commitSummary}`
: newCommits.commitSummary;
}
sendChatMessage?.(`✓ commit for ${task.id} · ${task.title}`, {
toolCalls: commitToolCalls,
});
} else {
sendChatMessage?.(
`~ commit for ${task.id} · ${task.title} — follow-up commit session failed: ${commitResult.error}`,
{ toolCalls: commitToolCalls },
);
}
}
} catch (error) {
// Don't fail the task if auto-commit fails
sendChatMessage?.(
`~ commit for ${task.id} · ${task.title} — auto-commit error: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
// Save reflection // Save reflection
if (result.reflection) { if (result.reflection) {
saveReflectionToFile(projectDir, config, result.reflection); saveReflectionToFile(projectDir, config, result.reflection);
@@ -644,11 +858,16 @@ async function executeTask(
result.durationMs, result.durationMs,
result.reflection, result.reflection,
result.toolUsage, result.toolUsage,
result.sessionFile,
result.outputPreview, result.outputPreview,
result.commitMessages, finalCommitMessages,
result.commitSummary, finalCommitSummary,
); );
// Auto-update the PRD source file checkbox
try {
updateTaskInFile(project.sourcePath, task.id, "completed");
} catch {
// Best-effort: don't fail the task over a checkbox update
}
roundRobin?.release(task.id); roundRobin?.release(task.id);
return; return;
} }
@@ -679,6 +898,7 @@ async function executeTask(
} else { } else {
// Max retries exceeded // Max retries exceeded
progress.markFailed(task.id, result.error || "Unknown error"); progress.markFailed(task.id, result.error || "Unknown error");
// Don't update PRD — retry exhaustion is transient, not terminal
sendChatMessage?.(`${task.id} · ${task.title}${result.error}`); sendChatMessage?.(`${task.id} · ${task.title}${result.error}`);
ctx.ui.notify( ctx.ui.notify(
`Task ${task.id} failed after ${maxRetries} retries: ${ `Task ${task.id} failed after ${maxRetries} retries: ${
@@ -690,8 +910,15 @@ async function executeTask(
} }
} catch (error) { } catch (error) {
roundRobin?.release(task.id); roundRobin?.release(task.id);
batchRender?.();
const errorMsg = error instanceof Error ? error.message : String(error); const errorMsg = error instanceof Error ? error.message : String(error);
progress.markFailed(task.id, errorMsg); progress.markFailed(task.id, errorMsg);
// Auto-update the PRD source file checkbox
try {
updateTaskInFile(project.sourcePath, task.id, "failed");
} catch {
// Best-effort
}
sendChatMessage?.(`${task.id} · ${task.title}${errorMsg}`); sendChatMessage?.(`${task.id} · ${task.title}${errorMsg}`);
ctx.ui.notify(`Task ${task.id} failed: ${errorMsg}`, "error"); ctx.ui.notify(`Task ${task.id} failed: ${errorMsg}`, "error");
return; return;
@@ -704,7 +931,9 @@ async function executeTask(
// All models exhausted — release the slot // All models exhausted — release the slot
roundRobin?.release(task.id); roundRobin?.release(task.id);
batchRender?.();
progress.markFailed(task.id, "All configured models exhausted"); progress.markFailed(task.id, "All configured models exhausted");
// Don't update PRD — model exhaustion is transient, not terminal
sendChatMessage?.( sendChatMessage?.(
`${task.id} · ${task.title} — all ${maxModelAttempts} models exhausted`, `${task.id} · ${task.title} — all ${maxModelAttempts} models exhausted`,
); );
@@ -735,6 +964,20 @@ function sleep(ms: number): Promise<void> {
// ─── Tool Call Formatting ──────────────────────────────────────────────── // ─── Tool Call Formatting ────────────────────────────────────────────────
/**
* Strip control characters and newlines from a display label so it
* does not break TUI layout (tree branches, text width calculation).
*/
function sanitizeLabel(s: string): string {
// Replace newlines/carriage returns with spaces (multi-line commands
// must fit on a single tree-branch line), then strip ASCII control
// characters except \t (which is harmless) and keep printable chars.
return s
.replace(/\r?\n/g, " ")
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "")
.trim();
}
/** /**
* Format a tool call argument into a short label. * Format a tool call argument into a short label.
*/ */
@@ -742,21 +985,20 @@ function formatToolArg(name: string, args: unknown): string {
const a = args as Record<string, unknown>; const a = args as Record<string, unknown>;
switch (name) { switch (name) {
case "bash": case "bash":
return truncateMiddle(String(a.command ?? ""), 70); return sanitizeLabel(truncateMiddle(String(a.command ?? ""), 70));
case "write": case "write":
case "read": case "read":
return truncateMiddle(String(a.path ?? ""), 60); return sanitizeLabel(truncateMiddle(String(a.path ?? ""), 60));
case "edit": case "edit":
return truncateMiddle(String(a.path ?? ""), 60); return sanitizeLabel(truncateMiddle(String(a.path ?? ""), 60));
case "grep": case "grep":
return `${a.pattern ?? "?"}${truncateMiddle( return sanitizeLabel(
String(a.path ?? ""), `${a.pattern ?? "?"}${truncateMiddle(String(a.path ?? ""), 40)}`,
40, );
)}`;
case "find": case "find":
return `${a.path ?? "."}${a.glob ?? "*"}`; return sanitizeLabel(`${a.path ?? "."}${a.glob ?? "*"}`);
case "ls": case "ls":
return truncateMiddle(String(a.path ?? "."), 60); return sanitizeLabel(truncateMiddle(String(a.path ?? "."), 60));
default: default:
return name; return name;
} }

View File

@@ -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 " ":

View File

@@ -171,7 +171,6 @@ export class ProgressTracker {
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,
@@ -183,7 +182,6 @@ export class ProgressTracker {
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 (sessionFile) prd.tasks[taskId].sessionFile = sessionFile;
if (outputPreview) prd.tasks[taskId].outputPreview = outputPreview; if (outputPreview) prd.tasks[taskId].outputPreview = outputPreview;
if (commitMessages) prd.tasks[taskId].commitMessages = commitMessages; if (commitMessages) prd.tasks[taskId].commitMessages = commitMessages;
if (commitSummary) prd.tasks[taskId].commitSummary = commitSummary; if (commitSummary) prd.tasks[taskId].commitSummary = commitSummary;

View File

@@ -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 */

View File

@@ -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 ─────────────────────────────────────────────────────
@@ -360,7 +432,6 @@ export async function runAgentSession(
timeoutMs: number, timeoutMs: number,
onEvent?: (event: AgentSessionEvent) => void, onEvent?: (event: AgentSessionEvent) => void,
signal?: AbortSignal, signal?: AbortSignal,
sessionFile?: string,
model?: unknown, model?: unknown,
thinkingLevel?: unknown, thinkingLevel?: unknown,
): Promise<{ ): Promise<{
@@ -378,13 +449,6 @@ export async function runAgentSession(
bash: 0, bash: 0,
other: 0, other: 0,
}; };
// Stream events to file instead of accumulating in memory.
// Accumulating caused "Invalid string length" crashes when
// JSON.stringify(output.events, null, 2) produced 300+ MB strings.
const eventStream = sessionFile
? fs.createWriteStream(sessionFile, { flags: "a" })
: null;
// Wire timeout via abort signal (only when set; 0 means inherit Pi's defaults) // Wire timeout via abort signal (only when set; 0 means inherit Pi's defaults)
let timeoutHandle: NodeJS.Timeout | null = null; let timeoutHandle: NodeJS.Timeout | null = null;
if (timeoutMs > 0) { if (timeoutMs > 0) {
@@ -428,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") {
@@ -468,11 +528,6 @@ export async function runAgentSession(
signal?.removeEventListener("abort", abortHandler); signal?.removeEventListener("abort", abortHandler);
if (timeoutHandle) 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 {
success: false, success: false,
@@ -489,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) {
if (timeoutHandle) 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();
@@ -527,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

View 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
View 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

File diff suppressed because it is too large Load Diff

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
View 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();
}
});
});
});