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 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 v2](#state-v2)
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 and only needed for `/plan review`.
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
- current approve and execute in this session
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, review, and approve plans
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 review` | Open the submitted `plan.md` in optional Plannotator. |
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
- review approve reject revise status history diff request
125
+ approve reject request
140
126
  ```
141
127
 
142
- If the actual request starts with one of those words, use `/plan request ...`:
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
- Long-form aliases are accepted:
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
- --all --new --fresh --approval
156
+ Continue in this session
157
+ Continue in a new session
158
+ Stop
174
159
  ```
175
160
 
176
- Unknown, duplicate, and conflicting options are rejected. For example, `/build new fresh` is invalid.
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
- - `/build all new` persists its continuation policy, so reloads and subsequent session handoffs resume one task per session.
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 and progress. |
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 v2 and task runtime metadata
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 and migration backups
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 v2
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, and task approval mode. |
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. The old unused `inheritSessionModel` and `inheritThinkingLevel` options were removed in v3.
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 payload version remains `1` and includes workflow metadata such as:
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
- - A migrated `implementation-complete` task receives verification-only framing and must not call `complete` again.
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
- /plan status
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 migration backups. Restore a known-good state or start an explicitly new plan rather than weakening state validation.
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
- /plan status
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 and v1 migration tests;
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 { buildHandoffCommand, parseBuildOptions } from "./build-session.ts";
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", placement: "here", approval: false, continuation: false });
14
+ assert.deepEqual(options(""), { scope: "one" });
15
15
  });
16
- it("parses placement and all-task execution", () => {
17
- assert.equal(options("new").placement, "new");
18
- assert.equal(options("--fresh").placement, "fresh");
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 unknown, duplicate, and conflicting options", () => {
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 ALLOWED = new Set(["all", "--all", "new", "--new", "fresh", "--fresh", "approval", "--approval"]);
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
- const unknown = tokens.find((token) => !ALLOWED.has(token));
18
- if (unknown) return { ok: false, error: `Unknown build option: ${unknown}` };
19
- const hasNew = tokens.includes("new") || tokens.includes("--new");
20
- const hasFresh = tokens.includes("fresh") || tokens.includes("--fresh");
21
- if (hasNew && hasFresh) return { ok: false, error: "new and fresh cannot be combined." };
22
- const unique = new Set(tokens.map((token) => token.replace(/^--/, "")));
23
- if (unique.size !== tokens.length) return { ok: false, error: "Duplicate build options are not allowed." };
24
- const scope: BuildScope = tokens.includes("all") || tokens.includes("--all") ? "all" : "one";
25
- const approval = tokens.includes("approval") || tokens.includes("--approval");
26
- if (approval && scope !== "all") return { ok: false, error: "approval requires all-task execution." };
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
  });
@@ -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 { buildHandoffCommand, parseBuildOptions, type BuildPlacement } from "./build-session.ts";
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, textDiff } from "./review.ts";
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
- mode = "idle"; currentTaskId = undefined; resetFraming(); updateStatus(ctx);
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
- mode = "idle";
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
- mode = "idle";
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
- mode = "idle";
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
- const selected = args.trim() || (ctx.hasUI ? ((await ctx.ui.select("Approve plan: choose next action", ["current", "new", "fresh", "revise", "cancel"])) ?? "cancel") : "current");
455
- const choice = selected.toLowerCase();
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
- const payload = { version: 1, cwd: ctx.cwd, planPath: planFilePath(ctx.cwd), taskPath: taskFilePath(ctx.cwd), planHash: approvedStructureHash, sessionFile: ctx.sessionManager.getSessionFile() };
494
- pi.events.emit("pi-plan-task:plan-approved", payload);
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
- await beginBuild(ctx, false, "here");
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 (planState.status !== "ready") { ctx.ui.notify(`Plan is not awaiting review (status: ${planState.status}).`, "warning"); return; }
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 showPlanStatus(ctx: ExtensionCommandContext): Promise<void> {
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 current", { expandPromptTemplates: true }); return; }
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 = ["review", "approve", "reject", "revise", "status", "history", "diff", "request"];
517
+ const planSubcommands = ["approve", "reject", "request"];
554
518
  pi.registerCommand("plan", {
555
- description: "Create, review, approve, reject, or inspect a plan",
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 continuingAllNew = planState.continueMode === "all-new";
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: approval, continueMode };
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, here or in a new session",
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 = buildSubcommands.map((name) => ({ value: name, label: name }));
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
- const options = parsed.options;
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 tasks, review a task, or reopen a task for rework",
628
+ description: "Show plan status and tasks, or reopen a task for rework",
713
629
  getArgumentCompletions: (prefix) => {
714
- const items = ["review", "rework"].map((name) => ({ value: name, label: name }));
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") { await reviewTask(actionArgs, ctx); return; }
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 [review [id]|rework <id>]", "error"); return; }
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(formatTaskList(tasks), "info");
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
- "current", "new", "fresh", "review", "revise", "reject", "later",
834
+ "approve", "review", "reject", "later",
917
835
  ]);
918
836
  awaitingChoice = false;
919
- if (choice === "current" || choice === "new" || choice === "fresh") pi.sendUserMessage(`/plan approve ${choice}`, { expandPromptTemplates: true });
920
- else if (choice === "review" || choice === "revise" || choice === "reject") pi.sendUserMessage(`/plan ${choice}`, { expandPromptTemplates: true });
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 initial new-session cancellation approved", async () => {
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(state?.continueMode, undefined);
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 all-new approval and all-fresh through public build commands", async () => {
129
- const allNew = await harness();
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
- await writeWorkflow(fresh.cwd, "approved");
142
- fresh.setNewSessionCancelled(false);
143
- await fresh.commands.get("build").handler("all fresh", fresh.ctx);
144
- assert.equal(fresh.sent.includes("/build all"), true);
145
- } finally { await fresh.close(); }
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 current", h.ctx);
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 revision, rejection, task review, and successful cascading rework", async () => {
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, "planning");
206
- assert.equal(h.sent.some((message) => message.includes("Revise the current draft")), true);
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("Task 1: A")), true);
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 {
@@ -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", " ");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-plan-task",
3
- "version": "3.0.1",
3
+ "version": "4.0.0",
4
4
  "license": "MIT",
5
5
  "description": "Pi package: /plan, /build, and /tasks with approved, resumable plan execution.",
6
6
  "keywords": [