pi-plan-task 3.0.1 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -134
- package/extensions/build-session.test.ts +14 -14
- package/extensions/build-session.ts +18 -30
- package/extensions/command-surface.test.ts +4 -0
- package/extensions/index.ts +90 -171
- package/extensions/integration.test.ts +27 -28
- package/extensions/task-ui.ts +14 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -26,13 +26,11 @@ Plan → Review → Approve → Execute → Verify → Continue / Stop
|
|
|
26
26
|
- [Task file contract](#task-file-contract)
|
|
27
27
|
- [Durable files](#durable-files)
|
|
28
28
|
- [Lifecycle](#lifecycle)
|
|
29
|
-
- [State
|
|
29
|
+
- [State](#state)
|
|
30
30
|
- [Resume and recovery](#resume-and-recovery)
|
|
31
31
|
- [Configuration](#configuration)
|
|
32
32
|
- [Events](#events)
|
|
33
33
|
- [Optional Plannotator integration](#optional-plannotator-integration)
|
|
34
|
-
- [Upgrading to v3](#upgrading-to-v3)
|
|
35
|
-
- [Legacy command migration](#legacy-command-migration)
|
|
36
34
|
- [Troubleshooting](#troubleshooting)
|
|
37
35
|
- [Development](#development)
|
|
38
36
|
- [Safety notes](#safety-notes)
|
|
@@ -47,14 +45,13 @@ Plan → Review → Approve → Execute → Verify → Continue / Stop
|
|
|
47
45
|
- One task can run here, in a new session, or in a clean session.
|
|
48
46
|
- All remaining tasks can run here or one-per-session.
|
|
49
47
|
- State updates are validated and serialized; multi-file changes use transactional replacement with rollback.
|
|
50
|
-
- State v1 projects are backed up and migrated safely.
|
|
51
48
|
- Optional Plannotator integration provides browser-based plan review.
|
|
52
49
|
|
|
53
50
|
## Requirements
|
|
54
51
|
|
|
55
52
|
- Pi with extension/package support.
|
|
56
53
|
- `ask_user_question` is required at runtime when planning or execution reaches a consequential user decision.
|
|
57
|
-
- Plannotator is optional
|
|
54
|
+
- Plannotator is optional. After a plan is submitted, choose `review` if the CLI is installed.
|
|
58
55
|
|
|
59
56
|
## Installation
|
|
60
57
|
|
|
@@ -82,11 +79,8 @@ Alternatively, add the package path to `packages` in `~/.pi/agent/settings.json`
|
|
|
82
79
|
When the draft is valid, choose one of:
|
|
83
80
|
|
|
84
81
|
```text
|
|
85
|
-
|
|
86
|
-
new approve and execute in a normal new session
|
|
87
|
-
fresh approve and execute in a clean session
|
|
82
|
+
approve lock the submitted plan (then choose here/new/fresh, or run /build later)
|
|
88
83
|
review open optional Plannotator review
|
|
89
|
-
revise edit a new draft with feedback
|
|
90
84
|
reject reject and revise with feedback
|
|
91
85
|
later leave the submitted plan ready for later approval
|
|
92
86
|
```
|
|
@@ -109,7 +103,7 @@ Execution is approval-gated. A missing, changed, or unapproved plan cannot start
|
|
|
109
103
|
The extension registers exactly three user-facing slash commands:
|
|
110
104
|
|
|
111
105
|
```text
|
|
112
|
-
/plan create,
|
|
106
|
+
/plan create, approve, and reject plans
|
|
113
107
|
/build execute one or all tasks
|
|
114
108
|
/tasks inspect and manage tasks
|
|
115
109
|
```
|
|
@@ -122,29 +116,23 @@ The extension registers exactly three user-facing slash commands:
|
|
|
122
116
|
| `/plan <request>` | Plan from a free-text request. |
|
|
123
117
|
| `/plan <file> [notes]` | Use a repository spec file with optional guidance. |
|
|
124
118
|
| `/plan request <text>` | Force text beginning with a reserved subcommand to be treated as a request. |
|
|
125
|
-
| `/plan
|
|
126
|
-
| `/plan approve` | Interactively choose `current`, `new`, `fresh`, `revise`, or `cancel`. |
|
|
127
|
-
| `/plan approve current` | Approve and execute the next task in this session. |
|
|
128
|
-
| `/plan approve new` | Approve and execute the next task in a normal new session. |
|
|
129
|
-
| `/plan approve fresh` | Approve and execute the next task in a clean session without parent conversation context. |
|
|
119
|
+
| `/plan approve` | Approve the submitted plan. Does not start execution. |
|
|
130
120
|
| `/plan reject [feedback]` | Reject the submitted plan and initialize a revision draft. |
|
|
131
|
-
| `/plan revise` | Initialize a revision draft and ask for revision feedback. |
|
|
132
|
-
| `/plan status` | Show lifecycle state, work identity, progress, hash, and current task. |
|
|
133
|
-
| `/plan history` | List immutable submitted-plan snapshots. |
|
|
134
|
-
| `/plan diff` | Compare current submitted files with the newest valid snapshot. |
|
|
135
121
|
|
|
136
122
|
The first argument to `/plan` is reserved when it is one of:
|
|
137
123
|
|
|
138
124
|
```text
|
|
139
|
-
|
|
125
|
+
approve reject request
|
|
140
126
|
```
|
|
141
127
|
|
|
142
|
-
If the actual request starts with
|
|
128
|
+
If the actual request starts with a reserved word, use `/plan request ...`:
|
|
143
129
|
|
|
144
130
|
```text
|
|
145
131
|
/plan request review the login page accessibility
|
|
146
132
|
```
|
|
147
133
|
|
|
134
|
+
`/plan approve` only locks the submitted structure. In a UI session it then asks where the next task should run (`here`, `new`, or `fresh`). In non-UI mode it stops after approval; run `/build` or `/build all` to execute. `/plan reject` works from `ready`, `approved`, `blocked`, or `completed`.
|
|
135
|
+
|
|
148
136
|
#### Draft behavior
|
|
149
137
|
|
|
150
138
|
- New work receives a new `workId` and does not inherit old blocked/task runtime state.
|
|
@@ -158,42 +146,36 @@ If the actual request starts with one of those words, use `/plan request ...`:
|
|
|
158
146
|
| Command | Description |
|
|
159
147
|
| --- | --- |
|
|
160
148
|
| `/build` | Execute one pending task in this session. |
|
|
161
|
-
| `/build new` | Execute one task in a normal new session. |
|
|
162
|
-
| `/build fresh` | Execute one task in a clean session. |
|
|
163
149
|
| `/build all` | Execute all remaining tasks in this session. |
|
|
164
|
-
| `/build all new` | Execute one task per new session and continue automatically. |
|
|
165
|
-
| `/build all fresh` | Start in a clean session, then execute all remaining tasks in that clean session. |
|
|
166
|
-
| `/build all --approval` | Execute here and pause for approval after every verified task. |
|
|
167
|
-
| `/build all new --approval` | Use one new session per task and pause after every verified task. |
|
|
168
|
-
| `/build all fresh --approval` | Start in a clean session and pause after every verified task. |
|
|
169
150
|
|
|
170
|
-
|
|
151
|
+
`--all` is accepted as an alias for `all`. Unknown and duplicate options are rejected.
|
|
152
|
+
|
|
153
|
+
After a single task is verified, a UI session asks:
|
|
171
154
|
|
|
172
155
|
```text
|
|
173
|
-
|
|
156
|
+
Continue in this session
|
|
157
|
+
Continue in a new session
|
|
158
|
+
Stop
|
|
174
159
|
```
|
|
175
160
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
`--approval` applies only to all-task execution. In non-UI modes, per-task approval cannot prompt, so execution stops safely after the current task.
|
|
161
|
+
Choosing a new session persists `continueMode: "all-new"` and opens a replacement session that runs `/build` for the next task. `/build all` continues remaining tasks here without prompting. In non-UI modes, `/build` stops after the current task.
|
|
179
162
|
|
|
180
163
|
#### Session placement
|
|
181
164
|
|
|
165
|
+
Placement is chosen in UI prompts, not as `/build` flags:
|
|
166
|
+
|
|
167
|
+
- `here` continues in the current session.
|
|
182
168
|
- `new` records the current session as parent and opens a replacement session.
|
|
183
|
-
- `fresh` opens a clean session without the planning conversation as parent context.
|
|
184
|
-
-
|
|
185
|
-
- If initial session creation is cancelled, the plan remains approved and can be started again.
|
|
169
|
+
- `fresh` is offered after `/plan approve` and opens a clean session without the planning conversation as parent context.
|
|
170
|
+
- If session creation is cancelled, the plan remains approved and can be started again with `/build`.
|
|
186
171
|
|
|
187
172
|
### `/tasks` — inspection and rework
|
|
188
173
|
|
|
189
174
|
| Command | Description |
|
|
190
175
|
| --- | --- |
|
|
191
|
-
| `/tasks` | Show the canonical checklist
|
|
192
|
-
| `/tasks review [id]` | Show a task, its criteria, unstaged/staged changes, and untracked files. |
|
|
176
|
+
| `/tasks` | Show lifecycle status, work identity, approved hash, current task, and the canonical checklist. |
|
|
193
177
|
| `/tasks rework <id>` | Reopen a verified task and every later task. |
|
|
194
178
|
|
|
195
|
-
`/tasks review` truncates output at approximately 50 KB or 2000 lines. Use `git diff`, `git diff --cached`, and `git status --short` for complete output.
|
|
196
|
-
|
|
197
179
|
Rework is intentionally strict:
|
|
198
180
|
|
|
199
181
|
- The target must exist and already be verified.
|
|
@@ -203,7 +185,6 @@ Rework is intentionally strict:
|
|
|
203
185
|
- Approval is revoked.
|
|
204
186
|
- The plan returns to `ready` and must be approved again.
|
|
205
187
|
|
|
206
|
-
Legacy standalone command names are not aliases. See [Legacy command migration](#legacy-command-migration).
|
|
207
188
|
|
|
208
189
|
## LLM tool: `plan_task`
|
|
209
190
|
|
|
@@ -316,11 +297,11 @@ Do not manually check top-level task items. Only a successful `plan_task verify`
|
|
|
316
297
|
.plan_task/
|
|
317
298
|
├── plan.md # submitted plan; immutable during execution
|
|
318
299
|
├── task.md # canonical queue and completion truth
|
|
319
|
-
├── state.json # validated state
|
|
300
|
+
├── state.json # validated workflow state and task runtime metadata
|
|
320
301
|
├── draft/ # exists only while planning or revising
|
|
321
302
|
│ ├── plan.md
|
|
322
303
|
│ └── task.md
|
|
323
|
-
├── history/ # immutable snapshots
|
|
304
|
+
├── history/ # immutable submitted-plan snapshots
|
|
324
305
|
└── .lock/ # short-lived cross-process workflow lock
|
|
325
306
|
```
|
|
326
307
|
|
|
@@ -378,7 +359,7 @@ pending → implementation-complete → verified
|
|
|
378
359
|
|
|
379
360
|
`unblock` restores the task to whichever active state existed before blocking.
|
|
380
361
|
|
|
381
|
-
## State
|
|
362
|
+
## State
|
|
382
363
|
|
|
383
364
|
`state.json` is validated before use. Unknown versions, malformed statuses, invalid IDs, invalid hashes, invalid dates, verified tasks without evidence, and blocked tasks without reasons fail closed.
|
|
384
365
|
|
|
@@ -392,7 +373,6 @@ Example:
|
|
|
392
373
|
"planningRequest": "Add OAuth login",
|
|
393
374
|
"currentTaskId": 2,
|
|
394
375
|
"continueMode": "all-new",
|
|
395
|
-
"approvalEachTask": true,
|
|
396
376
|
"executionMode": "automatic",
|
|
397
377
|
"draftStructureHash": "0000000000000000000000000000000000000000000000000000000000000000",
|
|
398
378
|
"approvedStructureHash": "1111111111111111111111111111111111111111111111111111111111111111",
|
|
@@ -428,7 +408,7 @@ The extension restores persisted behavior during startup, reload, resume, and fo
|
|
|
428
408
|
| `planning` | Restores Plan mode, draft write boundary, and planning request framing. |
|
|
429
409
|
| `ready` | Leaves the submitted plan ready for review/approval. |
|
|
430
410
|
| `approved` | Leaves the plan approved and ready to build after hash verification. |
|
|
431
|
-
| `executing` | Restores current task, build framing, continuation policy
|
|
411
|
+
| `executing` | Restores current task, build framing, and continuation policy. |
|
|
432
412
|
| `blocked` | Restores blocked state and reasons without reporting completion. |
|
|
433
413
|
| `completed` | Preserves completed history; verified work can be explicitly reopened. |
|
|
434
414
|
|
|
@@ -470,7 +450,7 @@ Shell tools, unknown tools, current submitted files, and implementation tools ar
|
|
|
470
450
|
| `automatic` | The extension starts and coordinates local implementation sessions. |
|
|
471
451
|
| `external` | Approval emits `pi-plan-task:plan-approved`; local `/build` does not execute. |
|
|
472
452
|
|
|
473
|
-
New-session model and thinking behavior follow Pi defaults.
|
|
453
|
+
New-session model and thinking behavior follow Pi defaults.
|
|
474
454
|
|
|
475
455
|
## Events
|
|
476
456
|
|
|
@@ -487,7 +467,7 @@ pi-plan-task:task-blocked
|
|
|
487
467
|
pi-plan-task:execution-finished
|
|
488
468
|
```
|
|
489
469
|
|
|
490
|
-
Event
|
|
470
|
+
Event payloads include workflow metadata such as:
|
|
491
471
|
|
|
492
472
|
```text
|
|
493
473
|
cwd
|
|
@@ -526,99 +506,22 @@ Phase instructions are conversation messages rather than permanent system-prompt
|
|
|
526
506
|
- Build framing includes `plan.md` as untrusted reference context.
|
|
527
507
|
- Current task instructions and safety rules take precedence over plan content.
|
|
528
508
|
- A clean session does not inherit the planning conversation as parent context.
|
|
529
|
-
-
|
|
509
|
+
- An `implementation-complete` task that is resumed for verification receives verification-only framing and must not call `complete` again.
|
|
530
510
|
|
|
531
511
|
## Optional Plannotator integration
|
|
532
512
|
|
|
533
513
|
Plannotator is not a core dependency.
|
|
534
514
|
|
|
535
|
-
If the `plannotator` CLI is installed
|
|
536
|
-
|
|
537
|
-
```text
|
|
538
|
-
/plan review
|
|
539
|
-
```
|
|
540
|
-
|
|
541
|
-
opens the submitted plan for browser review. Approved results are normalized to `/plan approve current`; annotated or rejected results are normalized to `/plan reject <feedback>`.
|
|
515
|
+
If the `plannotator` CLI is installed, choose `review` after a plan is submitted. That opens the submitted plan for browser review. Approved results are normalized to `/plan approve`; annotated or rejected results are normalized to `/plan reject <feedback>`.
|
|
542
516
|
|
|
543
517
|
If Plannotator is unavailable, use:
|
|
544
518
|
|
|
545
519
|
```text
|
|
520
|
+
/tasks
|
|
546
521
|
/plan approve
|
|
547
522
|
/plan reject <feedback>
|
|
548
|
-
/plan diff
|
|
549
|
-
```
|
|
550
|
-
|
|
551
|
-
## Upgrading to v3
|
|
552
|
-
|
|
553
|
-
v3 is a breaking workflow release. The public three-command surface remains `/plan`, `/build`, and `/tasks`, but planning storage, task completion, verification, state validation, and retry behavior are stricter.
|
|
554
|
-
|
|
555
|
-
### State v1 → state v2 migration
|
|
556
|
-
|
|
557
|
-
The package version is v3; the persisted state schema moves from version 1 to version 2. On the first state load, the extension:
|
|
558
|
-
|
|
559
|
-
1. backs up old `state.json` and `task.md` under `.plan_task/history/`;
|
|
560
|
-
2. writes validated state v2;
|
|
561
|
-
3. preserves a task as verified only when it was checked and had non-empty verification evidence;
|
|
562
|
-
4. restores other previously checked tasks to unchecked `implementation-complete` state;
|
|
563
|
-
5. clears approval and requires `/plan approve` before execution.
|
|
564
|
-
|
|
565
|
-
Migrated `implementation-complete` tasks retain their implementation but must run verification again.
|
|
566
|
-
|
|
567
|
-
### Changed planning storage
|
|
568
|
-
|
|
569
|
-
Planning and revision now write only to:
|
|
570
|
-
|
|
571
|
-
```text
|
|
572
|
-
.plan_task/draft/plan.md
|
|
573
|
-
.plan_task/draft/task.md
|
|
574
523
|
```
|
|
575
524
|
|
|
576
|
-
Current submitted files remain unchanged until the draft validates and is transactionally submitted.
|
|
577
|
-
|
|
578
|
-
### Changed completion semantics
|
|
579
|
-
|
|
580
|
-
In v2, `complete` could check a task. In v3:
|
|
581
|
-
|
|
582
|
-
```text
|
|
583
|
-
complete implementation finished, verification pending
|
|
584
|
-
verify verification passed, task checked and eligible to advance
|
|
585
|
-
```
|
|
586
|
-
|
|
587
|
-
### Changed rework semantics
|
|
588
|
-
|
|
589
|
-
`/tasks rework <id>` now reopens the selected verified task and every later task, revokes approval, and requires a new approval before execution.
|
|
590
|
-
|
|
591
|
-
### Removed configuration
|
|
592
|
-
|
|
593
|
-
The unused options below are ignored and should be deleted:
|
|
594
|
-
|
|
595
|
-
```json
|
|
596
|
-
{
|
|
597
|
-
"inheritSessionModel": true,
|
|
598
|
-
"inheritThinkingLevel": true
|
|
599
|
-
}
|
|
600
|
-
```
|
|
601
|
-
|
|
602
|
-
## Legacy command migration
|
|
603
|
-
|
|
604
|
-
If upgrading from a release older than the three-command interface, use these replacements:
|
|
605
|
-
|
|
606
|
-
| Removed | Replacement |
|
|
607
|
-
| --- | --- |
|
|
608
|
-
| `/plan-review` | `/plan review` |
|
|
609
|
-
| `/plan-approve` | `/plan approve` |
|
|
610
|
-
| `/plan-reject` | `/plan reject` |
|
|
611
|
-
| `/plan-status` | `/plan status` |
|
|
612
|
-
| `/plan-history` | `/plan history` |
|
|
613
|
-
| `/plan-diff` | `/plan diff` |
|
|
614
|
-
| `/goal` | `/build all` |
|
|
615
|
-
| `/task-review` | `/tasks review` |
|
|
616
|
-
| `/task-rework` | `/tasks rework` |
|
|
617
|
-
| `/build-next-session` | internal public `/build` handoff |
|
|
618
|
-
| `/goal-next-session` | internal public `/build` handoff |
|
|
619
|
-
|
|
620
|
-
Legacy commands are not registered as compatibility aliases.
|
|
621
|
-
|
|
622
525
|
## Troubleshooting
|
|
623
526
|
|
|
624
527
|
### “Plan files changed after approval”
|
|
@@ -626,8 +529,7 @@ Legacy commands are not registered as compatibility aliases.
|
|
|
626
529
|
The submitted structure no longer matches the approved hash. Inspect the files, then run:
|
|
627
530
|
|
|
628
531
|
```text
|
|
629
|
-
/
|
|
630
|
-
/plan diff
|
|
532
|
+
/tasks
|
|
631
533
|
/plan approve
|
|
632
534
|
```
|
|
633
535
|
|
|
@@ -651,7 +553,7 @@ Run `/build` again when ready.
|
|
|
651
553
|
|
|
652
554
|
### Invalid `state.json`
|
|
653
555
|
|
|
654
|
-
The extension fails closed and will not execute. Inspect `.plan_task/history/` for
|
|
556
|
+
The extension fails closed and will not execute. Inspect `.plan_task/history/` for snapshots. Restore a known-good state or start an explicitly new plan rather than weakening state validation.
|
|
655
557
|
|
|
656
558
|
### Stale `.plan_task/.lock`
|
|
657
559
|
|
|
@@ -662,9 +564,9 @@ Normal operations remove the lock automatically. A lock older than ten minutes i
|
|
|
662
564
|
Use the built-in text workflow:
|
|
663
565
|
|
|
664
566
|
```text
|
|
665
|
-
/
|
|
666
|
-
/plan diff
|
|
567
|
+
/tasks
|
|
667
568
|
/plan approve
|
|
569
|
+
/plan reject <feedback>
|
|
668
570
|
```
|
|
669
571
|
|
|
670
572
|
## Development
|
|
@@ -680,7 +582,7 @@ The test suite includes:
|
|
|
680
582
|
|
|
681
583
|
- parser and Markdown-boundary tests;
|
|
682
584
|
- strict plan validation tests;
|
|
683
|
-
- state schema
|
|
585
|
+
- state schema tests;
|
|
684
586
|
- structure hash tests;
|
|
685
587
|
- concurrent workflow mutation tests;
|
|
686
588
|
- command-surface tests;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { describe, it } from "node:test";
|
|
3
|
-
import {
|
|
3
|
+
import { parseBuildOptions } from "./build-session.ts";
|
|
4
4
|
|
|
5
5
|
function options(input: string) {
|
|
6
6
|
const result = parseBuildOptions(input);
|
|
@@ -11,23 +11,23 @@ function options(input: string) {
|
|
|
11
11
|
|
|
12
12
|
describe("parseBuildOptions", () => {
|
|
13
13
|
it("executes one task here by default", () => {
|
|
14
|
-
assert.deepEqual(options(""), { scope: "one"
|
|
14
|
+
assert.deepEqual(options(""), { scope: "one" });
|
|
15
15
|
});
|
|
16
|
-
it("parses
|
|
17
|
-
assert.
|
|
18
|
-
assert.
|
|
19
|
-
assert.deepEqual(options("all new --approval"), { scope: "all", placement: "new", approval: true, continuation: false });
|
|
16
|
+
it("parses all-task execution", () => {
|
|
17
|
+
assert.deepEqual(options("all"), { scope: "all" });
|
|
18
|
+
assert.deepEqual(options("--all"), { scope: "all" });
|
|
20
19
|
});
|
|
21
|
-
it("rejects
|
|
20
|
+
it("rejects removed placement and approval flags", () => {
|
|
21
|
+
for (const input of ["new", "fresh", "all new", "--approval"]) {
|
|
22
|
+
const result = parseBuildOptions(input);
|
|
23
|
+
assert.equal(result.ok, false);
|
|
24
|
+
if (result.ok) throw new Error("Expected removed build option to fail");
|
|
25
|
+
assert.match(result.error, /^Removed\./);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
it("rejects unknown and duplicate options", () => {
|
|
22
29
|
assert.deepEqual(parseBuildOptions("banana"), { ok: false, error: "Unknown build option: banana" });
|
|
23
|
-
assert.equal(parseBuildOptions("new fresh").ok, false);
|
|
24
30
|
assert.equal(parseBuildOptions("all --all").ok, false);
|
|
25
31
|
assert.equal(parseBuildOptions("continue").ok, false);
|
|
26
|
-
assert.equal(parseBuildOptions("--approval").ok, false);
|
|
27
|
-
});
|
|
28
|
-
it("builds public handoff commands", () => {
|
|
29
|
-
assert.equal(buildHandoffCommand(options("new")), "/build");
|
|
30
|
-
assert.equal(buildHandoffCommand(options("all new --approval")), "/build");
|
|
31
|
-
assert.equal(buildHandoffCommand(options("all fresh --approval")), "/build all --approval");
|
|
32
32
|
});
|
|
33
33
|
});
|
|
@@ -1,42 +1,30 @@
|
|
|
1
|
-
export type BuildPlacement = "here" | "new" | "fresh";
|
|
2
1
|
export type BuildScope = "one" | "all";
|
|
3
2
|
|
|
4
3
|
export interface BuildOptions {
|
|
5
4
|
scope: BuildScope;
|
|
6
|
-
placement: BuildPlacement;
|
|
7
|
-
approval: boolean;
|
|
8
|
-
continuation: boolean;
|
|
9
5
|
}
|
|
10
6
|
|
|
11
7
|
export type BuildParseResult = { ok: true; options: BuildOptions } | { ok: false; error: string };
|
|
12
8
|
|
|
13
|
-
const
|
|
9
|
+
const REMOVED = new Map<string, string>([
|
|
10
|
+
["new", "Removed. After /plan approve or a finished task, choose a new session. /build runs the next task here."],
|
|
11
|
+
["--new", "Removed. After /plan approve or a finished task, choose a new session. /build runs the next task here."],
|
|
12
|
+
["fresh", "Removed. After /plan approve, choose fresh to start a clean session."],
|
|
13
|
+
["--fresh", "Removed. After /plan approve, choose fresh to start a clean session."],
|
|
14
|
+
["approval", "Removed. Use /build for one task or /build all for the rest."],
|
|
15
|
+
["--approval", "Removed. Use /build for one task or /build all for the rest."],
|
|
16
|
+
]);
|
|
14
17
|
|
|
15
18
|
export function parseBuildOptions(args: string): BuildParseResult {
|
|
16
19
|
const tokens = args.trim().split(/\s+/).map((token) => token.toLowerCase()).filter(Boolean);
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
return {
|
|
28
|
-
ok: true,
|
|
29
|
-
options: {
|
|
30
|
-
scope,
|
|
31
|
-
placement: hasFresh ? "fresh" : hasNew ? "new" : "here",
|
|
32
|
-
approval,
|
|
33
|
-
continuation: false,
|
|
34
|
-
},
|
|
35
|
-
};
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export function buildHandoffCommand(options: Pick<BuildOptions, "scope" | "placement" | "approval">): string {
|
|
39
|
-
if (options.scope === "one" || options.placement === "new") return "/build";
|
|
40
|
-
const approvalFlag = options.approval ? " --approval" : "";
|
|
41
|
-
return `/build all${approvalFlag}`;
|
|
20
|
+
if (tokens.length === 0) return { ok: true, options: { scope: "one" } };
|
|
21
|
+
for (const token of tokens) {
|
|
22
|
+
const removed = REMOVED.get(token);
|
|
23
|
+
if (removed) return { ok: false, error: removed };
|
|
24
|
+
}
|
|
25
|
+
const normalized = tokens.map((token) => token.replace(/^--/, ""));
|
|
26
|
+
const unknown = normalized.find((token) => token !== "all");
|
|
27
|
+
if (unknown) return { ok: false, error: `Unknown build option: ${tokens[normalized.indexOf(unknown)]}` };
|
|
28
|
+
if (new Set(normalized).size !== tokens.length) return { ok: false, error: "Duplicate build options are not allowed." };
|
|
29
|
+
return { ok: true, options: { scope: "all" } };
|
|
42
30
|
}
|
|
@@ -7,5 +7,9 @@ describe("public command surface", () => {
|
|
|
7
7
|
const source = await readFile(new URL("./index.ts", import.meta.url), "utf8");
|
|
8
8
|
const commands = [...source.matchAll(/registerCommand\("([^"]+)"/g)].map((match) => match[1]);
|
|
9
9
|
assert.deepEqual(commands, ["plan", "build", "tasks"]);
|
|
10
|
+
assert.match(source, /planSubcommands = \["approve", "reject", "request"\]/);
|
|
11
|
+
assert.match(source, /value: "all"/);
|
|
12
|
+
assert.match(source, /value: "rework"/);
|
|
13
|
+
assert.doesNotMatch(source, /planSubcommands = \[[^\]]*review/);
|
|
10
14
|
});
|
|
11
15
|
});
|
package/extensions/index.ts
CHANGED
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
} from "@earendil-works/pi-coding-agent";
|
|
8
8
|
import { Text } from "@earendil-works/pi-tui";
|
|
9
9
|
import { Type } from "typebox";
|
|
10
|
-
import {
|
|
10
|
+
import { parseBuildOptions } from "./build-session.ts";
|
|
11
11
|
import { ensureDefaultGlobalConfig, isAllowedPlanTool, loadConfig } from "./config.ts";
|
|
12
12
|
import {
|
|
13
13
|
ensurePlanDir,
|
|
@@ -31,10 +31,9 @@ import { markTaskPendingInMarkdown, parseTaskMarkdown } from "./parse.ts";
|
|
|
31
31
|
import { draftPlanFilePath, draftTaskFilePath, isPlanArtifactPath, planFilePath, taskFilePath } from "./paths.ts";
|
|
32
32
|
import { buildPrompt, buildRequest, buildStatus, buildStatusKey, planPrompt, planRequest } from "./prompts.ts";
|
|
33
33
|
import type { PlanState, TaskItem } from "./types.ts";
|
|
34
|
-
import { formatTaskList, TaskListComponent } from "./task-ui.ts";
|
|
34
|
+
import { formatTaskList, formatWorkflowStatus, TaskListComponent } from "./task-ui.ts";
|
|
35
35
|
import { initialState, structureHash, transition, updateTaskState } from "./state.ts";
|
|
36
|
-
import { normalizeReviewResult, validatePlan
|
|
37
|
-
import { listPlanSnapshots } from "./history.ts";
|
|
36
|
+
import { normalizeReviewResult, validatePlan } from "./review.ts";
|
|
38
37
|
import { addTools, removeAddedTools, type ToolDelta } from "./tools.ts";
|
|
39
38
|
import { assertApprovedStructure, blockCurrentTask, completeCurrentTask, reworkFromTask, unblockTask, verifyCurrentTask, classifyQueue } from "./workflow-policy.ts";
|
|
40
39
|
import { commitDraft, initializeDraftWithState, loadStateLocked, mutateWorkflow, readCurrentArtifacts, readDraftArtifacts, saveStateLocked } from "./workflow-store.ts";
|
|
@@ -43,8 +42,15 @@ type Mode = "idle" | "plan" | "build";
|
|
|
43
42
|
|
|
44
43
|
const CONTINUE_THIS = "Continue in this session";
|
|
45
44
|
const CONTINUE_NEW = "Continue in a new session";
|
|
45
|
+
const CONTINUE_STOP = "Stop";
|
|
46
46
|
const BUILD_ONE_HERE_COMMAND = "/build";
|
|
47
|
-
|
|
47
|
+
const REMOVED_PLAN_COMMANDS: Record<string, string> = {
|
|
48
|
+
review: "Removed. After a plan is submitted, choose review if Plannotator is available, or run /plan approve or /plan reject.",
|
|
49
|
+
revise: "Removed. Use /plan reject [feedback] to revise a submitted plan.",
|
|
50
|
+
status: "Removed. Run /tasks to see status, progress, and the approved hash.",
|
|
51
|
+
history: "Removed. Submitted snapshots are in .plan_task/history/.",
|
|
52
|
+
diff: "Removed. Compare the current files with the newest snapshot in .plan_task/history/.",
|
|
53
|
+
} as const;
|
|
48
54
|
function contentKey(content: string): string {
|
|
49
55
|
return createHash("sha256").update(content).digest("hex");
|
|
50
56
|
}
|
|
@@ -93,8 +99,8 @@ export default async function planTaskExtension(pi: ExtensionAPI): Promise<void>
|
|
|
93
99
|
|
|
94
100
|
let mode: Mode = "idle";
|
|
95
101
|
let continueAll = false;
|
|
96
|
-
let approvalEachTask = false;
|
|
97
102
|
let continueNew = false;
|
|
103
|
+
let pendingHandoff: "new" | "fresh" | undefined;
|
|
98
104
|
let currentTaskId: number | undefined;
|
|
99
105
|
let planToolsDelta: ToolDelta | undefined;
|
|
100
106
|
let awaitingChoice = false;
|
|
@@ -173,6 +179,7 @@ export default async function planTaskExtension(pi: ExtensionAPI): Promise<void>
|
|
|
173
179
|
mode = "idle";
|
|
174
180
|
continueAll = false;
|
|
175
181
|
continueNew = false;
|
|
182
|
+
pendingHandoff = undefined;
|
|
176
183
|
currentTaskId = undefined;
|
|
177
184
|
resetFraming();
|
|
178
185
|
restoreTools();
|
|
@@ -235,6 +242,18 @@ export default async function planTaskExtension(pi: ExtensionAPI): Promise<void>
|
|
|
235
242
|
return true;
|
|
236
243
|
}
|
|
237
244
|
|
|
245
|
+
async function queueBuildHandoff(ctx: ExtensionContext, message?: string): Promise<void> {
|
|
246
|
+
mode = "idle";
|
|
247
|
+
currentTaskId = undefined;
|
|
248
|
+
resetFraming();
|
|
249
|
+
updateStatus(ctx);
|
|
250
|
+
pendingHandoff = "new";
|
|
251
|
+
planState = { ...planState, continueMode: "all-new", currentTaskId: undefined };
|
|
252
|
+
if (stateRestorable) await saveStateLocked(ctx.cwd, planState);
|
|
253
|
+
if (message) ctx.ui.notify(message, "info");
|
|
254
|
+
pi.sendUserMessage(BUILD_ONE_HERE_COMMAND, { expandPromptTemplates: true });
|
|
255
|
+
}
|
|
256
|
+
|
|
238
257
|
async function afterBuildSettled(ctx: ExtensionContext): Promise<void> {
|
|
239
258
|
if (mode !== "build" || awaitingChoice || currentTaskId === undefined) return;
|
|
240
259
|
const file = await loadTaskFile(ctx.cwd);
|
|
@@ -251,8 +270,7 @@ export default async function planTaskExtension(pi: ExtensionAPI): Promise<void>
|
|
|
251
270
|
if (continueNew) {
|
|
252
271
|
const decision = classifyQueue(file, planState);
|
|
253
272
|
if (decision.kind === "runnable") {
|
|
254
|
-
|
|
255
|
-
pi.sendUserMessage("/build new", { expandPromptTemplates: true });
|
|
273
|
+
await queueBuildHandoff(ctx);
|
|
256
274
|
return;
|
|
257
275
|
}
|
|
258
276
|
await startNextTask(ctx);
|
|
@@ -285,28 +303,12 @@ export default async function planTaskExtension(pi: ExtensionAPI): Promise<void>
|
|
|
285
303
|
leaveModes(ctx);
|
|
286
304
|
return;
|
|
287
305
|
}
|
|
288
|
-
if (approvalEachTask) {
|
|
289
|
-
if (!ctx.hasUI) {
|
|
290
|
-
ctx.ui.notify("Task complete. Per-task approval requires UI; run /build to continue.", "info");
|
|
291
|
-
leaveModes(ctx);
|
|
292
|
-
return;
|
|
293
|
-
}
|
|
294
|
-
awaitingChoice = true;
|
|
295
|
-
const review = await ctx.ui.select(`Task ${currentTaskId} verified. What next?`, ["Continue to next task", "Stop execution"]);
|
|
296
|
-
awaitingChoice = false;
|
|
297
|
-
if (review !== "Continue to next task") { leaveModes(ctx); return; }
|
|
298
|
-
}
|
|
299
306
|
if (continueAll) {
|
|
300
307
|
await startNextTask(ctx);
|
|
301
308
|
return;
|
|
302
309
|
}
|
|
303
310
|
if (continueNew) {
|
|
304
|
-
|
|
305
|
-
currentTaskId = undefined;
|
|
306
|
-
resetFraming();
|
|
307
|
-
updateStatus(ctx);
|
|
308
|
-
ctx.ui.notify("Task complete. Opening a new session for the next task.", "info");
|
|
309
|
-
pi.sendUserMessage("/build new", { expandPromptTemplates: true });
|
|
311
|
+
await queueBuildHandoff(ctx, "Task complete. Opening a new session for the next task.");
|
|
310
312
|
return;
|
|
311
313
|
}
|
|
312
314
|
if (!ctx.hasUI) {
|
|
@@ -315,24 +317,17 @@ export default async function planTaskExtension(pi: ExtensionAPI): Promise<void>
|
|
|
315
317
|
return;
|
|
316
318
|
}
|
|
317
319
|
awaitingChoice = true;
|
|
318
|
-
const choice = await ctx.ui.select("Task complete. What next?", [CONTINUE_THIS, CONTINUE_NEW]);
|
|
320
|
+
const choice = await ctx.ui.select("Task complete. What next?", [CONTINUE_THIS, CONTINUE_NEW, CONTINUE_STOP]);
|
|
319
321
|
awaitingChoice = false;
|
|
320
322
|
if (choice === CONTINUE_THIS) {
|
|
321
323
|
await startNextTask(ctx);
|
|
322
324
|
return;
|
|
323
325
|
}
|
|
324
326
|
if (choice === CONTINUE_NEW) {
|
|
325
|
-
|
|
326
|
-
currentTaskId = undefined;
|
|
327
|
-
resetFraming();
|
|
328
|
-
updateStatus(ctx);
|
|
329
|
-
pi.sendUserMessage("/build new", { expandPromptTemplates: true });
|
|
327
|
+
await queueBuildHandoff(ctx, "Task complete. Opening a new session for the next task.");
|
|
330
328
|
return;
|
|
331
329
|
}
|
|
332
|
-
|
|
333
|
-
currentTaskId = undefined;
|
|
334
|
-
resetFraming();
|
|
335
|
-
updateStatus(ctx);
|
|
330
|
+
leaveModes(ctx);
|
|
336
331
|
}
|
|
337
332
|
|
|
338
333
|
pi.registerTool({
|
|
@@ -451,37 +446,12 @@ export default async function planTaskExtension(pi: ExtensionAPI): Promise<void>
|
|
|
451
446
|
async function approvePlan(args: string, ctx: ExtensionCommandContext): Promise<void> {
|
|
452
447
|
const persisted = await loadStateLocked(ctx.cwd);
|
|
453
448
|
if (persisted) planState = persisted;
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
const revisionAllowed = choice === "revise" && ["ready", "approved", "blocked", "completed"].includes(planState.status);
|
|
457
|
-
if (planState.status !== "ready" && !revisionAllowed) { ctx.ui.notify(`Plan is not awaiting approval (status: ${planState.status}).`, "warning"); return; }
|
|
458
|
-
if (!["current", "new", "fresh", "revise", "cancel"].includes(choice)) {
|
|
459
|
-
ctx.ui.notify("Usage: /plan approve [current|new|fresh]", "error");
|
|
449
|
+
if (args.trim()) {
|
|
450
|
+
ctx.ui.notify("Usage: /plan approve\nPlacement flags were removed. Approve first, then run /build or choose here/new/fresh when prompted.", "error");
|
|
460
451
|
return;
|
|
461
452
|
}
|
|
453
|
+
if (planState.status !== "ready") { ctx.ui.notify(`Plan is not awaiting approval (status: ${planState.status}).`, "warning"); return; }
|
|
462
454
|
const approvalConfig = await loadConfig(ctx.cwd);
|
|
463
|
-
if (approvalConfig.executionMode === "external" && (choice === "new" || choice === "fresh")) {
|
|
464
|
-
ctx.ui.notify("External execution mode does not start local sessions.", "warning");
|
|
465
|
-
return;
|
|
466
|
-
}
|
|
467
|
-
if (choice === "cancel") {
|
|
468
|
-
planState = transition(planState, "idle");
|
|
469
|
-
await saveStateLocked(ctx.cwd, planState);
|
|
470
|
-
leaveModes(ctx);
|
|
471
|
-
ctx.ui.notify("Plan approval cancelled.", "info");
|
|
472
|
-
return;
|
|
473
|
-
}
|
|
474
|
-
if (choice === "revise") {
|
|
475
|
-
const feedback = ctx.hasUI ? await ctx.ui.input("Revision feedback", "Explain what should change") : "";
|
|
476
|
-
if (!feedback?.trim()) { ctx.ui.notify("Revision cancelled; current plan is unchanged.", "info"); return; }
|
|
477
|
-
const current = await readCurrentArtifacts(ctx.cwd);
|
|
478
|
-
planState = transition(planState, "planning");
|
|
479
|
-
planState = { ...planState, planningRequest: `Revise the current plan: ${feedback.trim()}`, planningBaselineHash: structureHash(current.plan, current.task), approvedStructureHash: undefined, failureReason: feedback.trim() };
|
|
480
|
-
planState = await initializeDraftWithState(ctx.cwd, planState, current);
|
|
481
|
-
await enterPlanMode(ctx);
|
|
482
|
-
pi.sendUserMessage(`Revise the current draft with this feedback:\n${feedback.trim()}`);
|
|
483
|
-
return;
|
|
484
|
-
}
|
|
485
455
|
const current = await readCurrentArtifacts(ctx.cwd);
|
|
486
456
|
const parsed = parseTaskMarkdown(current.task);
|
|
487
457
|
const validation = validatePlan(current.plan, current.task, parsed);
|
|
@@ -490,24 +460,37 @@ export default async function planTaskExtension(pi: ExtensionAPI): Promise<void>
|
|
|
490
460
|
planState = transition(planState, "approved");
|
|
491
461
|
planState = { ...planState, approvedStructureHash, draftStructureHash: approvedStructureHash, executionMode: approvalConfig.executionMode, failureReason: undefined };
|
|
492
462
|
await saveStateLocked(ctx.cwd, planState);
|
|
493
|
-
|
|
494
|
-
|
|
463
|
+
pi.events.emit("pi-plan-task:plan-approved", { version: 1, cwd: ctx.cwd, planPath: planFilePath(ctx.cwd), taskPath: taskFilePath(ctx.cwd), planHash: approvedStructureHash, sessionFile: ctx.sessionManager.getSessionFile() });
|
|
464
|
+
leaveModes(ctx);
|
|
495
465
|
if (approvalConfig.executionMode === "external") {
|
|
496
|
-
leaveModes(ctx);
|
|
497
466
|
ctx.ui.notify("Plan approved. External execution event emitted.", "info");
|
|
498
467
|
return;
|
|
499
468
|
}
|
|
469
|
+
if (!ctx.hasUI) {
|
|
470
|
+
ctx.ui.notify("Plan approved. Run /build or /build all.", "info");
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
awaitingChoice = true;
|
|
474
|
+
const choice = await ctx.ui.select("Plan approved. Where should the next task run?", ["here", "new", "fresh"]);
|
|
475
|
+
awaitingChoice = false;
|
|
500
476
|
if (choice === "new" || choice === "fresh") {
|
|
501
477
|
await startBuildInNewSession(ctx, BUILD_ONE_HERE_COMMAND, choice === "fresh");
|
|
502
478
|
return;
|
|
503
479
|
}
|
|
504
|
-
|
|
480
|
+
if (choice === "here") {
|
|
481
|
+
await beginBuild(ctx, false);
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
ctx.ui.notify("Plan approved. Run /build when you want to execute.", "info");
|
|
505
485
|
}
|
|
506
486
|
|
|
507
487
|
async function rejectPlan(args: string, ctx: ExtensionCommandContext): Promise<void> {
|
|
508
488
|
const persisted = await loadStateLocked(ctx.cwd);
|
|
509
489
|
if (persisted) planState = persisted;
|
|
510
|
-
if (
|
|
490
|
+
if (!["ready", "approved", "blocked", "completed"].includes(planState.status)) {
|
|
491
|
+
ctx.ui.notify(`Plan cannot be rejected from ${planState.status}.`, "warning");
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
511
494
|
const feedback = args.trim() || (ctx.hasUI ? await ctx.ui.input("Plan feedback", "Explain what should change") : "");
|
|
512
495
|
const current = await readCurrentArtifacts(ctx.cwd);
|
|
513
496
|
planState = transition(planState, "planning");
|
|
@@ -519,30 +502,11 @@ export default async function planTaskExtension(pi: ExtensionAPI): Promise<void>
|
|
|
519
502
|
if (feedback) pi.sendUserMessage(`Revise the current draft with this review feedback:\n${feedback}`);
|
|
520
503
|
}
|
|
521
504
|
|
|
522
|
-
async function
|
|
523
|
-
const persisted = await loadStateLocked(ctx.cwd);
|
|
524
|
-
const file = await loadTaskFile(ctx.cwd);
|
|
525
|
-
ctx.ui.notify(`Plan status: ${persisted?.status ?? "idle"}\nApproved hash: ${persisted?.approvedStructureHash ?? "none"}\nWork id: ${persisted?.workId ?? "none"}\nProgress: ${file ? formatProgress(file.tasks) : "0/0"}\nCurrent task: ${persisted?.currentTaskId ?? "none"}`, "info");
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
async function showPlanHistory(ctx: ExtensionCommandContext): Promise<void> {
|
|
529
|
-
const items = await listPlanSnapshots(ctx.cwd);
|
|
530
|
-
ctx.ui.notify(items.length ? items.map((item) => `v${item.version} ${item.createdAt}`).join("\n") : "No plan history.", "info");
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
async function showPlanDiff(ctx: ExtensionCommandContext): Promise<void> {
|
|
534
|
-
const items = await listPlanSnapshots(ctx.cwd);
|
|
535
|
-
if (!items.length) { ctx.ui.notify("No plan history.", "info"); return; }
|
|
536
|
-
const currentPlan = (await readOptionalFile(planFilePath(ctx.cwd))) ?? "";
|
|
537
|
-
const currentTask = (await readOptionalFile(taskFilePath(ctx.cwd))) ?? "";
|
|
538
|
-
ctx.ui.notify(textDiff(`${items.at(-1)!.plan}\n${items.at(-1)!.task}`, `${currentPlan}\n${currentTask}`) || "No changes.", "info");
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
async function reviewPlan(ctx: ExtensionCommandContext): Promise<void> {
|
|
505
|
+
async function reviewPlan(ctx: ExtensionContext): Promise<void> {
|
|
542
506
|
try {
|
|
543
507
|
const result = await pi.exec("plannotator", ["annotate", planFilePath(ctx.cwd), "--markdown", "--json"]);
|
|
544
508
|
const review = normalizeReviewResult(result.stdout.trim());
|
|
545
|
-
if (review?.decision === "approved") { pi.sendUserMessage("/plan approve
|
|
509
|
+
if (review?.decision === "approved") { pi.sendUserMessage("/plan approve", { expandPromptTemplates: true }); return; }
|
|
546
510
|
if (review?.decision === "rejected" && review.feedback) { pi.sendUserMessage(`/plan reject ${review.feedback}`, { expandPromptTemplates: true }); return; }
|
|
547
511
|
if (result.code !== 0) ctx.ui.notify("Plannotator is unavailable or review failed; use /plan approve or /plan reject.", "warning");
|
|
548
512
|
} catch {
|
|
@@ -550,9 +514,9 @@ export default async function planTaskExtension(pi: ExtensionAPI): Promise<void>
|
|
|
550
514
|
}
|
|
551
515
|
}
|
|
552
516
|
|
|
553
|
-
const planSubcommands = ["
|
|
517
|
+
const planSubcommands = ["approve", "reject", "request"];
|
|
554
518
|
pi.registerCommand("plan", {
|
|
555
|
-
description: "Create,
|
|
519
|
+
description: "Create, approve, or reject a plan",
|
|
556
520
|
getArgumentCompletions: (prefix) => {
|
|
557
521
|
const items = planSubcommands.map((name) => ({ value: name, label: name }));
|
|
558
522
|
const filtered = items.filter((item) => item.value.startsWith(prefix.trim().toLowerCase()));
|
|
@@ -563,31 +527,37 @@ export default async function planTaskExtension(pi: ExtensionAPI): Promise<void>
|
|
|
563
527
|
const [rawAction = "", ...rest] = trimmed.split(/\s+/);
|
|
564
528
|
const action = rawAction.toLowerCase();
|
|
565
529
|
const actionArgs = rest.join(" ");
|
|
530
|
+
if (action in REMOVED_PLAN_COMMANDS) {
|
|
531
|
+
ctx.ui.notify(REMOVED_PLAN_COMMANDS[action]!, "error");
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
566
534
|
switch (action) {
|
|
567
|
-
case "review": await reviewPlan(ctx); return;
|
|
568
535
|
case "approve": await approvePlan(actionArgs, ctx); return;
|
|
569
536
|
case "reject": await rejectPlan(actionArgs, ctx); return;
|
|
570
|
-
case "revise": await approvePlan("revise", ctx); return;
|
|
571
|
-
case "status": await showPlanStatus(ctx); return;
|
|
572
|
-
case "history": await showPlanHistory(ctx); return;
|
|
573
|
-
case "diff": await showPlanDiff(ctx); return;
|
|
574
537
|
case "request": await startPlanRequest(actionArgs, ctx); return;
|
|
575
538
|
default: await startPlanRequest(args, ctx);
|
|
576
539
|
}
|
|
577
540
|
},
|
|
578
541
|
});
|
|
579
542
|
|
|
580
|
-
async function beginBuild(
|
|
581
|
-
ctx: ExtensionCommandContext,
|
|
582
|
-
runAll: boolean,
|
|
583
|
-
placement: BuildPlacement = "here",
|
|
584
|
-
options: { chainNew?: boolean; approvalEachTask?: boolean } = {},
|
|
585
|
-
): Promise<void> {
|
|
543
|
+
async function beginBuild(ctx: ExtensionCommandContext, runAll: boolean): Promise<void> {
|
|
586
544
|
const persisted = await loadStateLocked(ctx.cwd);
|
|
587
545
|
if (!persisted) { ctx.ui.notify("No plan state found. Run /plan first.", "error"); return; }
|
|
588
546
|
planState = persisted;
|
|
589
547
|
const executionConfig = await loadConfig(ctx.cwd);
|
|
590
548
|
if (executionConfig.executionMode === "external") { ctx.ui.notify("External execution mode is enabled; waiting for an external executor.", "info"); return; }
|
|
549
|
+
if (pendingHandoff) {
|
|
550
|
+
const clean = pendingHandoff === "fresh";
|
|
551
|
+
pendingHandoff = undefined;
|
|
552
|
+
if (!runAll) {
|
|
553
|
+
const started = await startBuildInNewSession(ctx, BUILD_ONE_HERE_COMMAND, clean);
|
|
554
|
+
if (!started) {
|
|
555
|
+
planState = { ...planState, continueMode: undefined };
|
|
556
|
+
await saveStateLocked(ctx.cwd, planState);
|
|
557
|
+
}
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
591
561
|
if (planState.status !== "approved" && planState.status !== "executing") {
|
|
592
562
|
ctx.ui.notify(`Plan must be approved before execution (status: ${planState.status}). Use /plan approve.`, "error");
|
|
593
563
|
return;
|
|
@@ -609,87 +579,33 @@ export default async function planTaskExtension(pi: ExtensionAPI): Promise<void>
|
|
|
609
579
|
if (queue.kind === "all-complete") { await enterBuildMode(ctx); await startNextTask(ctx); return; }
|
|
610
580
|
if (queue.kind === "all-remaining-blocked") { await enterBuildMode(ctx); await startNextTask(ctx); return; }
|
|
611
581
|
|
|
612
|
-
const
|
|
613
|
-
const persistedAllNew = continuingAllNew && placement === "here" && !runAll;
|
|
614
|
-
const chainNew = options.chainNew === true || persistedAllNew;
|
|
615
|
-
const approval = options.approvalEachTask === true || (continuingAllNew && planState.approvalEachTask === true);
|
|
582
|
+
const chainNew = planState.continueMode === "all-new" && !runAll;
|
|
616
583
|
const continueMode: PlanState["continueMode"] = chainNew ? "all-new" : runAll ? "all-here" : "single";
|
|
617
|
-
|
|
618
|
-
if (placement === "new" || placement === "fresh") {
|
|
619
|
-
const command = buildHandoffCommand({ scope: runAll ? "all" : "one", placement, approval });
|
|
620
|
-
const pendingState: PlanState = { ...planState, continueMode: continuingAllNew || (runAll && placement === "new") ? "all-new" : runAll ? "all-here" : "single", approvalEachTask: approval, currentTaskId: undefined };
|
|
621
|
-
planState = pendingState;
|
|
622
|
-
currentTaskId = undefined;
|
|
623
|
-
await saveStateLocked(ctx.cwd, pendingState);
|
|
624
|
-
const started = await startBuildInNewSession(ctx, command, placement === "fresh");
|
|
625
|
-
if (!started) { planState = { ...pendingState, continueMode: undefined, approvalEachTask: false }; await saveStateLocked(ctx.cwd, planState); }
|
|
626
|
-
return;
|
|
627
|
-
}
|
|
628
584
|
if (planState.status === "approved") planState = transition(planState, "executing");
|
|
629
|
-
planState = { ...planState, currentTaskId: undefined, approvalEachTask:
|
|
585
|
+
planState = { ...planState, currentTaskId: undefined, approvalEachTask: false, continueMode };
|
|
630
586
|
await saveStateLocked(ctx.cwd, planState);
|
|
631
587
|
continueAll = runAll && !chainNew;
|
|
632
|
-
approvalEachTask = approval;
|
|
633
588
|
continueNew = chainNew;
|
|
634
589
|
await enterBuildMode(ctx);
|
|
635
590
|
ctx.ui.notify(chainNew ? "Building the next task; remaining tasks will use new sessions." : runAll ? "Building remaining tasks." : "Building the next task.", "info");
|
|
636
591
|
await startNextTask(ctx);
|
|
637
592
|
}
|
|
638
593
|
|
|
639
|
-
const buildSubcommands = ["all", "new", "fresh", "--approval"];
|
|
640
594
|
pi.registerCommand("build", {
|
|
641
|
-
description: "Execute one task or all remaining tasks
|
|
595
|
+
description: "Execute one task or all remaining tasks in this session",
|
|
642
596
|
getArgumentCompletions: (prefix) => {
|
|
643
597
|
const token = prefix.trim().split(/\s+/).at(-1)?.toLowerCase() ?? "";
|
|
644
|
-
const items =
|
|
598
|
+
const items = [{ value: "all", label: "all" }];
|
|
645
599
|
const filtered = items.filter((item) => item.value.startsWith(token));
|
|
646
600
|
return filtered.length ? filtered : null;
|
|
647
601
|
},
|
|
648
602
|
handler: async (args, ctx) => {
|
|
649
603
|
const parsed = parseBuildOptions(args);
|
|
650
604
|
if (!parsed.ok) { ctx.ui.notify(parsed.error, "error"); return; }
|
|
651
|
-
|
|
652
|
-
await beginBuild(
|
|
653
|
-
ctx,
|
|
654
|
-
options.scope === "all",
|
|
655
|
-
options.placement,
|
|
656
|
-
{
|
|
657
|
-
chainNew: options.scope === "all" && options.placement === "new",
|
|
658
|
-
approvalEachTask: options.scope === "all" && options.approval,
|
|
659
|
-
},
|
|
660
|
-
);
|
|
605
|
+
await beginBuild(ctx, parsed.options.scope === "all");
|
|
661
606
|
},
|
|
662
607
|
});
|
|
663
608
|
|
|
664
|
-
async function reviewTask(args: string, ctx: ExtensionCommandContext): Promise<void> {
|
|
665
|
-
const file = await loadTaskFile(ctx.cwd);
|
|
666
|
-
const requestedId = Number(args.trim());
|
|
667
|
-
const task = Number.isFinite(requestedId) && requestedId > 0
|
|
668
|
-
? file?.tasks.find((item) => item.id === requestedId)
|
|
669
|
-
: currentTaskId === undefined
|
|
670
|
-
? file?.tasks.find((item) => !item.done)
|
|
671
|
-
: file?.tasks.find((item) => item.id === currentTaskId);
|
|
672
|
-
if (!task) { ctx.ui.notify("No matching task to review.", "info"); return; }
|
|
673
|
-
const sections: string[] = [];
|
|
674
|
-
try {
|
|
675
|
-
const [unstaged, staged, status] = await Promise.all([
|
|
676
|
-
pi.exec("git", ["diff", "--", "."]),
|
|
677
|
-
pi.exec("git", ["diff", "--cached", "--", "."]),
|
|
678
|
-
pi.exec("git", ["status", "--short"]),
|
|
679
|
-
]);
|
|
680
|
-
if (unstaged.stdout.trim()) sections.push(`Unstaged:\n${unstaged.stdout}`);
|
|
681
|
-
if (staged.stdout.trim()) sections.push(`Staged:\n${staged.stdout}`);
|
|
682
|
-
const untracked = status.stdout.split(/\r?\n/).filter((line) => line.startsWith("??")).join("\n");
|
|
683
|
-
if (untracked) sections.push(`Untracked:\n${untracked}`);
|
|
684
|
-
} catch { sections.push("(no git diff available)"); }
|
|
685
|
-
const full = `Task ${task.id}: ${task.title}\n\n${task.body}\n\nWorking tree:\n${sections.join("\n\n") || "(clean)"}`;
|
|
686
|
-
const lines = full.split(/\r?\n/);
|
|
687
|
-
let output = lines.slice(0, 2000).join("\n");
|
|
688
|
-
if (Buffer.byteLength(output, "utf8") > 50_000) output = Buffer.from(output, "utf8").subarray(0, 50_000).toString("utf8");
|
|
689
|
-
if (output.length < full.length) output += "\n\n[Output truncated. Use git diff, git diff --cached, and git status --short for full details.]";
|
|
690
|
-
ctx.ui.notify(output, "info");
|
|
691
|
-
}
|
|
692
|
-
|
|
693
609
|
async function reworkTask(args: string, ctx: ExtensionCommandContext): Promise<void> {
|
|
694
610
|
const id = Number(args.trim());
|
|
695
611
|
if (!Number.isInteger(id) || id <= 0) { ctx.ui.notify("Provide a positive task id.", "error"); return; }
|
|
@@ -709,9 +625,9 @@ export default async function planTaskExtension(pi: ExtensionAPI): Promise<void>
|
|
|
709
625
|
}
|
|
710
626
|
|
|
711
627
|
pi.registerCommand("tasks", {
|
|
712
|
-
description: "Show
|
|
628
|
+
description: "Show plan status and tasks, or reopen a task for rework",
|
|
713
629
|
getArgumentCompletions: (prefix) => {
|
|
714
|
-
const items = [
|
|
630
|
+
const items = [{ value: "rework", label: "rework" }];
|
|
715
631
|
const filtered = items.filter((item) => item.value.startsWith(prefix.trim().toLowerCase()));
|
|
716
632
|
return filtered.length ? filtered : null;
|
|
717
633
|
},
|
|
@@ -719,16 +635,18 @@ export default async function planTaskExtension(pi: ExtensionAPI): Promise<void>
|
|
|
719
635
|
const [rawAction = "", ...rest] = args.trim().split(/\s+/);
|
|
720
636
|
const action = rawAction.toLowerCase();
|
|
721
637
|
const actionArgs = rest.join(" ");
|
|
722
|
-
if (action === "review") {
|
|
638
|
+
if (action === "review") { ctx.ui.notify("Removed. Run /tasks for status and the checklist, or use git diff for working-tree details.", "error"); return; }
|
|
723
639
|
if (action === "rework") { await reworkTask(actionArgs, ctx); return; }
|
|
724
|
-
if (action) { ctx.ui.notify("Usage: /tasks [
|
|
640
|
+
if (action) { ctx.ui.notify("Usage: /tasks [rework <id>]", "error"); return; }
|
|
641
|
+
const persisted = await loadStateLocked(ctx.cwd);
|
|
642
|
+
if (persisted) planState = persisted;
|
|
725
643
|
const file = await loadTaskFile(ctx.cwd);
|
|
726
644
|
const tasks = file?.tasks ?? [];
|
|
727
645
|
if (ctx.mode !== "tui") {
|
|
728
|
-
ctx.ui.notify(
|
|
646
|
+
ctx.ui.notify(formatWorkflowStatus(planState, tasks), "info");
|
|
729
647
|
return;
|
|
730
648
|
}
|
|
731
|
-
await ctx.ui.custom<void>((_tui, theme, _kb, done) => new TaskListComponent(tasks, theme, () => done()));
|
|
649
|
+
await ctx.ui.custom<void>((_tui, theme, _kb, done) => new TaskListComponent(tasks, theme, () => done(), planState.status));
|
|
732
650
|
},
|
|
733
651
|
});
|
|
734
652
|
|
|
@@ -736,6 +654,7 @@ export default async function planTaskExtension(pi: ExtensionAPI): Promise<void>
|
|
|
736
654
|
mode = "idle";
|
|
737
655
|
continueAll = false;
|
|
738
656
|
continueNew = false;
|
|
657
|
+
pendingHandoff = undefined;
|
|
739
658
|
currentTaskId = undefined;
|
|
740
659
|
planToolsDelta = undefined;
|
|
741
660
|
awaitingChoice = false;
|
|
@@ -748,7 +667,6 @@ export default async function planTaskExtension(pi: ExtensionAPI): Promise<void>
|
|
|
748
667
|
if (persisted) {
|
|
749
668
|
planState = persisted;
|
|
750
669
|
currentTaskId = persisted.currentTaskId;
|
|
751
|
-
approvalEachTask = persisted.approvalEachTask === true;
|
|
752
670
|
continueAll = persisted.continueMode === "all-here";
|
|
753
671
|
continueNew = persisted.continueMode === "all-new";
|
|
754
672
|
if (["approved", "executing", "blocked"].includes(persisted.status) && persisted.approvedStructureHash) {
|
|
@@ -913,11 +831,12 @@ export default async function planTaskExtension(pi: ExtensionAPI): Promise<void>
|
|
|
913
831
|
}
|
|
914
832
|
awaitingChoice = true;
|
|
915
833
|
const choice = await ctx.ui.select(`Plan ready (${formatProgress(tasks)}). What next?`, [
|
|
916
|
-
"
|
|
834
|
+
"approve", "review", "reject", "later",
|
|
917
835
|
]);
|
|
918
836
|
awaitingChoice = false;
|
|
919
|
-
if (choice === "
|
|
920
|
-
else if (choice === "review"
|
|
837
|
+
if (choice === "approve") pi.sendUserMessage("/plan approve", { expandPromptTemplates: true });
|
|
838
|
+
else if (choice === "review") await reviewPlan(ctx);
|
|
839
|
+
else if (choice === "reject") pi.sendUserMessage("/plan reject", { expandPromptTemplates: true });
|
|
921
840
|
else ctx.ui.notify("Plan is ready. Run /plan approve when you want to execute it.", "info");
|
|
922
841
|
return;
|
|
923
842
|
}
|
|
@@ -114,35 +114,34 @@ describe("extension command integration", () => {
|
|
|
114
114
|
} finally { await h.close(); }
|
|
115
115
|
});
|
|
116
116
|
|
|
117
|
-
it("keeps
|
|
117
|
+
it("rejects removed build placement flags and keeps the plan approved", async () => {
|
|
118
118
|
const h = await harness();
|
|
119
119
|
try {
|
|
120
120
|
await writeWorkflow(h.cwd, "approved");
|
|
121
121
|
await h.commands.get("build").handler("new", h.ctx);
|
|
122
122
|
const state = await loadState(h.cwd);
|
|
123
123
|
assert.equal(state?.status, "approved");
|
|
124
|
-
assert.equal(
|
|
124
|
+
assert.equal(h.notifications.some((message) => message.startsWith("Removed.")), true);
|
|
125
125
|
} finally { await h.close(); }
|
|
126
126
|
});
|
|
127
127
|
|
|
128
|
-
it("hands off
|
|
129
|
-
const
|
|
130
|
-
try {
|
|
131
|
-
await writeWorkflow(allNew.cwd, "approved");
|
|
132
|
-
allNew.setNewSessionCancelled(false);
|
|
133
|
-
await allNew.commands.get("build").handler("all new --approval", allNew.ctx);
|
|
134
|
-
const state = await loadState(allNew.cwd);
|
|
135
|
-
assert.equal(state?.continueMode, "all-new");
|
|
136
|
-
assert.equal(state?.approvalEachTask, true);
|
|
137
|
-
assert.equal(allNew.sent.includes("/build"), true);
|
|
138
|
-
} finally { await allNew.close(); }
|
|
139
|
-
const fresh = await harness();
|
|
128
|
+
it("hands off the next task through a queued /build after Continue in a new session", async () => {
|
|
129
|
+
const h = await harness();
|
|
140
130
|
try {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
131
|
+
const checked = TASK.replace("- [ ] 1. A", "- [x] 1. A");
|
|
132
|
+
let state = await writeWorkflow(h.cwd, "executing", checked);
|
|
133
|
+
state = updateTaskState(state, 1, "verified", { verification: "passed" });
|
|
134
|
+
await saveState(h.cwd, state);
|
|
135
|
+
h.ctx.hasUI = true;
|
|
136
|
+
h.ctx.ui.select = async () => "Continue in a new session";
|
|
137
|
+
h.setNewSessionCancelled(false);
|
|
138
|
+
await h.handlers.get("session_start")({}, h.ctx);
|
|
139
|
+
await h.handlers.get("agent_settled")({}, h.ctx);
|
|
140
|
+
assert.equal(h.sent.includes("/build"), true);
|
|
141
|
+
assert.equal((await loadState(h.cwd))?.continueMode, "all-new");
|
|
142
|
+
await h.commands.get("build").handler("", h.ctx);
|
|
143
|
+
assert.equal(h.sent.filter((message) => message === "/build").length >= 2, true);
|
|
144
|
+
} finally { await h.close(); }
|
|
146
145
|
});
|
|
147
146
|
|
|
148
147
|
it("external mode refuses local build", async () => {
|
|
@@ -167,7 +166,9 @@ describe("extension command integration", () => {
|
|
|
167
166
|
await h.handlers.get("agent_settled")({}, h.ctx);
|
|
168
167
|
assert.equal((await loadState(h.cwd))?.status, "ready");
|
|
169
168
|
assert.equal(await readFile(planFilePath(h.cwd), "utf8"), "# Plan");
|
|
170
|
-
await h.commands.get("plan").handler("approve
|
|
169
|
+
await h.commands.get("plan").handler("approve", h.ctx);
|
|
170
|
+
assert.equal((await loadState(h.cwd))?.status, "approved");
|
|
171
|
+
await h.commands.get("build").handler("", h.ctx);
|
|
171
172
|
assert.equal((await loadState(h.cwd))?.status, "executing");
|
|
172
173
|
assert.equal(h.sent.some((message) => message.includes("Execute planned task 1")), true);
|
|
173
174
|
} finally { await h.close(); }
|
|
@@ -196,23 +197,21 @@ describe("extension command integration", () => {
|
|
|
196
197
|
} finally { await h.close(); }
|
|
197
198
|
});
|
|
198
199
|
|
|
199
|
-
it("supports
|
|
200
|
+
it("supports rejection from approved, removed task review, status on /tasks, and cascading rework", async () => {
|
|
200
201
|
const h = await harness();
|
|
201
202
|
try {
|
|
202
203
|
h.ctx.hasUI = true;
|
|
203
204
|
await writeWorkflow(h.cwd, "approved");
|
|
204
205
|
await h.commands.get("plan").handler("revise", h.ctx);
|
|
205
|
-
assert.equal((await loadState(h.cwd))?.status, "
|
|
206
|
-
assert.equal(h.
|
|
207
|
-
await writeFile(draftPlanFilePath(h.cwd), "# Revised", "utf8");
|
|
208
|
-
await writeFile(draftTaskFilePath(h.cwd), TASK, "utf8");
|
|
209
|
-
await h.handlers.get("agent_settled")({}, h.ctx);
|
|
210
|
-
assert.equal((await loadState(h.cwd))?.status, "ready");
|
|
206
|
+
assert.equal((await loadState(h.cwd))?.status, "approved");
|
|
207
|
+
assert.equal(h.notifications.some((message) => message.startsWith("Removed.")), true);
|
|
211
208
|
await h.commands.get("plan").handler("reject more detail", h.ctx);
|
|
212
209
|
assert.equal((await loadState(h.cwd))?.status, "planning");
|
|
213
210
|
assert.equal(h.sent.some((message) => message.includes("review feedback")), true);
|
|
214
211
|
await h.commands.get("tasks").handler("review 1", h.ctx);
|
|
215
|
-
assert.equal(h.notifications.some((message) => message.includes("
|
|
212
|
+
assert.equal(h.notifications.some((message) => message.includes("Removed.") && message.includes("/tasks")), true);
|
|
213
|
+
await h.commands.get("tasks").handler("", h.ctx);
|
|
214
|
+
assert.equal(h.notifications.some((message) => message.includes("Status:")), true);
|
|
216
215
|
} finally { await h.close(); }
|
|
217
216
|
const rework = await harness();
|
|
218
217
|
try {
|
package/extensions/task-ui.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
|
|
2
2
|
import { formatProgress } from "./files.ts";
|
|
3
|
-
import type { TaskItem } from "./types.ts";
|
|
3
|
+
import type { PlanState, TaskItem } from "./types.ts";
|
|
4
4
|
|
|
5
5
|
export function formatTaskList(tasks: TaskItem[]): string {
|
|
6
6
|
if (tasks.length === 0) return "No tasks found in .plan_task/task.md.";
|
|
@@ -8,15 +8,26 @@ export function formatTaskList(tasks: TaskItem[]): string {
|
|
|
8
8
|
return `Progress ${formatProgress(tasks)}\n${lines.join("\n")}`;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
export function formatWorkflowStatus(state: PlanState | undefined, tasks: TaskItem[]): string {
|
|
12
|
+
return [
|
|
13
|
+
`Status: ${state?.status ?? "idle"}`,
|
|
14
|
+
`Approved hash: ${state?.approvedStructureHash ?? "none"}`,
|
|
15
|
+
`Work id: ${state?.workId ?? "none"}`,
|
|
16
|
+
`Current task: ${state?.currentTaskId ?? "none"}`,
|
|
17
|
+
formatTaskList(tasks),
|
|
18
|
+
].join("\n");
|
|
19
|
+
}
|
|
11
20
|
export class TaskListComponent {
|
|
12
21
|
private readonly tasks: TaskItem[];
|
|
22
|
+
private readonly status: string;
|
|
13
23
|
private readonly theme: { fg: (name: "accent" | "muted" | "dim" | "success" | "text", text: string) => string };
|
|
14
24
|
private readonly onClose: () => void;
|
|
15
25
|
private cachedWidth?: number;
|
|
16
26
|
private cachedLines?: string[];
|
|
17
27
|
|
|
18
|
-
constructor(tasks: TaskItem[], theme: TaskListComponent["theme"], onClose: () => void) {
|
|
28
|
+
constructor(tasks: TaskItem[], theme: TaskListComponent["theme"], onClose: () => void, status = "idle") {
|
|
19
29
|
this.tasks = tasks;
|
|
30
|
+
this.status = status;
|
|
20
31
|
this.theme = theme;
|
|
21
32
|
this.onClose = onClose;
|
|
22
33
|
}
|
|
@@ -28,7 +39,7 @@ export class TaskListComponent {
|
|
|
28
39
|
render(width: number): string[] {
|
|
29
40
|
if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
|
|
30
41
|
const th = this.theme;
|
|
31
|
-
const lines = ["", truncateToWidth(` ${th.fg("accent", "Plan tasks")} ${th.fg("muted", formatProgress(this.tasks))}`, width), ""];
|
|
42
|
+
const lines = ["", truncateToWidth(` ${th.fg("accent", "Plan tasks")} ${th.fg("muted", `${formatProgress(this.tasks)} ${this.status}`)}`, width), ""];
|
|
32
43
|
if (this.tasks.length === 0) lines.push(truncateToWidth(` ${th.fg("dim", "No tasks found. Run /plan first.")}`, width));
|
|
33
44
|
else for (const task of this.tasks) {
|
|
34
45
|
const check = task.done ? th.fg("success", "x") : th.fg("dim", " ");
|