pi-plan-task 1.0.1 → 1.1.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 +57 -20
- package/extensions/build-session.test.ts +35 -0
- package/extensions/build-session.ts +15 -0
- package/extensions/index.ts +80 -60
- package/extensions/prompts.test.ts +4 -0
- package/extensions/prompts.ts +2 -2
- package/package.json +4 -3
- package/extensions/ask-question.test.ts +0 -91
- package/extensions/ask-question.ts +0 -145
package/README.md
CHANGED
|
@@ -1,19 +1,45 @@
|
|
|
1
1
|
# pi-plan-task
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/pi-plan-task)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
|
|
3
6
|
One Pi package: `/plan`, `/build`, `/goal`, and `/tasks`. Progress lives on disk, so a new session or a Pi restart can continue from the next unfinished task.
|
|
4
7
|
|
|
8
|
+
Structured questions use [`@juicesharp/rpiv-ask-user-question`](https://pi.dev/packages/@juicesharp/rpiv-ask-user-question). This package does not register `ask_user_question`.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pi install npm:@juicesharp/rpiv-ask-user-question
|
|
14
|
+
pi install npm:pi-plan-task
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Restart Pi or run `/reload`. If juicesharp is missing, this package warns on session start.
|
|
18
|
+
|
|
19
|
+
Local checkout (development):
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pi install /absolute/path/to/pi-plan-task
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Or add the source to `packages` in `~/.pi/agent/settings.json`.
|
|
26
|
+
|
|
5
27
|
## Commands
|
|
6
28
|
|
|
7
29
|
| Command | What it does |
|
|
8
30
|
| --- | --- |
|
|
9
31
|
| `/plan [file or goal]` | Read-only planning. Writes `.plan_task/plan.md` and `.plan_task/task.md`. |
|
|
10
|
-
| `/build` | Execute the next unfinished task, then ask whether to continue here or in a new session. |
|
|
11
|
-
| `/
|
|
32
|
+
| `/build` | Execute the next unfinished task in this session, then ask whether to continue here or in a new session. |
|
|
33
|
+
| `/build new` | Same as `/build`, but start that task in a new session. |
|
|
34
|
+
| `/goal` | Execute remaining tasks until `task.md` is complete. No session prompts. All in this session. |
|
|
35
|
+
| `/goal new` | Same as `/goal`, but each remaining task starts in a new session. |
|
|
12
36
|
| `/tasks` | Show the current task list and progress. |
|
|
13
37
|
|
|
14
|
-
Restarting Pi does not auto-start work. Run `/build` again.
|
|
38
|
+
Restarting Pi does not auto-start work. Run `/build` or `/goal` again.
|
|
15
39
|
|
|
16
|
-
`/
|
|
40
|
+
Session prompts only happen after a `/build` task finishes. `/goal` and `/goal new` never ask. Those prompts use this package's `select` dialog, not `ask_user_question`.
|
|
41
|
+
|
|
42
|
+
### `/plan` arguments
|
|
17
43
|
|
|
18
44
|
```
|
|
19
45
|
/plan
|
|
@@ -25,23 +51,38 @@ Restarting Pi does not auto-start work. Run `/build` again.
|
|
|
25
51
|
|
|
26
52
|
If the first argument is an existing file — or looks like a path such as `./spec.md`, `notes.txt`, or `@spec.md` — that file is the spec. Anything after it is extra planning guidance. Missing explicit paths are rejected instead of being treated as a prompt.
|
|
27
53
|
|
|
54
|
+
### `/build` and `/goal`
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
/build
|
|
58
|
+
/build new
|
|
59
|
+
/goal
|
|
60
|
+
/goal new
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`new` and `--new` are equivalent.
|
|
64
|
+
|
|
65
|
+
- `/build` runs one task here. After it is marked complete, Pi asks whether to continue in this session or a new session.
|
|
66
|
+
- `/build new` opens a new session for that one task, then asks the same question when it finishes.
|
|
67
|
+
- `/goal` runs every remaining task in this session.
|
|
68
|
+
- `/goal new` opens a new session for the next unfinished task, then another new session after each completed task, until the list is done.
|
|
69
|
+
|
|
28
70
|
## Planning method
|
|
29
71
|
|
|
30
72
|
`/plan` uses `extensions/planning-and-task-breakdown.md` as the planning prompt.
|
|
31
73
|
|
|
32
|
-
That file keeps the same section headings as `planning-and-task-breakdown.md`, so later methodology edits can be copied section-by-section from that source. Plan-task only changes output paths and the checklist form `/build`
|
|
74
|
+
That file keeps the same section headings as `planning-and-task-breakdown.md`, so later methodology edits can be copied section-by-section from that source. Plan-task only changes output paths and the checklist form `/build` and `/goal` need:
|
|
33
75
|
|
|
34
76
|
- `.plan_task/plan.md` and `.plan_task/task.md`
|
|
35
77
|
- checklist lines in the form `- [ ] N. Title`
|
|
36
78
|
|
|
37
79
|
This package does not install or load a skill. It also does not modify Pi's system prompt.
|
|
38
80
|
|
|
39
|
-
|
|
40
81
|
## Prompt injection
|
|
41
82
|
|
|
42
83
|
Phase instructions are conversation messages, not system-prompt patches:
|
|
43
84
|
|
|
44
|
-
- `/plan
|
|
85
|
+
- `/plan`, `/build`, and `/goal` send a short visible user message to start the turn.
|
|
45
86
|
- The planning method or current-task instructions are injected once, as a hidden message, when that phase or task starts.
|
|
46
87
|
- Later turns in the same phase inject nothing unless the checklist actually changed; then a small build-status message is appended.
|
|
47
88
|
- `context` keeps only the newest framing for the current phase. Stale plan/build messages are dropped from the model context. While idle, every injected message is filtered out.
|
|
@@ -50,13 +91,13 @@ Session history still stores the injected messages. Filtering is non-destructive
|
|
|
50
91
|
|
|
51
92
|
## Ask user
|
|
52
93
|
|
|
53
|
-
`ask_user_question`
|
|
94
|
+
`/plan`, `/build`, and `/goal` keep juicesharp's `ask_user_question` available and tell the model to call it for consequential choices the repo cannot answer:
|
|
54
95
|
|
|
55
|
-
- 2-4
|
|
56
|
-
-
|
|
57
|
-
-
|
|
96
|
+
- 1-4 questions per call, each with a short `header` and 2-4 described options
|
|
97
|
+
- recommended option first, with `(Recommended)` on the label
|
|
98
|
+
- do not author `Other` or `Type something.` — juicesharp appends a custom-answer row
|
|
58
99
|
|
|
59
|
-
|
|
100
|
+
Do not also register another `ask_user_question` tool. The names collide and the schemas differ.
|
|
60
101
|
|
|
61
102
|
## Files
|
|
62
103
|
|
|
@@ -74,7 +115,7 @@ Created in the current project:
|
|
|
74
115
|
- [ ] 2. Add login UI
|
|
75
116
|
```
|
|
76
117
|
|
|
77
|
-
`/build`
|
|
118
|
+
`/build` and `/goal` resume from the first unchecked item.
|
|
78
119
|
|
|
79
120
|
## Config
|
|
80
121
|
|
|
@@ -89,12 +130,8 @@ Allowed `/plan` tools:
|
|
|
89
130
|
}
|
|
90
131
|
```
|
|
91
132
|
|
|
92
|
-
During `/plan`, `write` and `edit` stay available but can only touch the two plan files. Bash is limited to read-only commands.
|
|
93
|
-
|
|
94
|
-
## Install
|
|
133
|
+
During `/plan`, `write` and `edit` stay available but can only touch the two plan files. Bash is limited to read-only commands. `plan_task` and `ask_user_question` stay available in every mode.
|
|
95
134
|
|
|
96
|
-
|
|
97
|
-
pi install D:/work/tools/pi-plan-task
|
|
98
|
-
```
|
|
135
|
+
## License
|
|
99
136
|
|
|
100
|
-
|
|
137
|
+
MIT
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { parseBuildPlacement, parseGoalMode } from "./build-session.ts";
|
|
4
|
+
|
|
5
|
+
describe("parseBuildPlacement", () => {
|
|
6
|
+
it("stays in this session by default", () => {
|
|
7
|
+
assert.equal(parseBuildPlacement(""), "here");
|
|
8
|
+
assert.equal(parseBuildPlacement(" "), "here");
|
|
9
|
+
assert.equal(parseBuildPlacement("unknown"), "here");
|
|
10
|
+
assert.equal(parseBuildPlacement("here"), "here");
|
|
11
|
+
assert.equal(parseBuildPlacement(" --here extra"), "here");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("accepts new aliases", () => {
|
|
15
|
+
assert.equal(parseBuildPlacement("new"), "new");
|
|
16
|
+
assert.equal(parseBuildPlacement("NEW"), "new");
|
|
17
|
+
assert.equal(parseBuildPlacement("--new"), "new");
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe("parseGoalMode", () => {
|
|
22
|
+
it("runs remaining tasks in this session by default", () => {
|
|
23
|
+
assert.equal(parseGoalMode(""), "all-here");
|
|
24
|
+
assert.equal(parseGoalMode(" "), "all-here");
|
|
25
|
+
assert.equal(parseGoalMode("unknown"), "all-here");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("accepts new and continue aliases", () => {
|
|
29
|
+
assert.equal(parseGoalMode("new"), "all-new");
|
|
30
|
+
assert.equal(parseGoalMode(" --new extra"), "all-new");
|
|
31
|
+
assert.equal(parseGoalMode("NEW"), "all-new");
|
|
32
|
+
assert.equal(parseGoalMode("continue"), "chain-here");
|
|
33
|
+
assert.equal(parseGoalMode("--continue"), "chain-here");
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export type BuildPlacement = "here" | "new";
|
|
2
|
+
export type GoalMode = "all-here" | "all-new" | "chain-here";
|
|
3
|
+
|
|
4
|
+
export function parseBuildPlacement(args: string): BuildPlacement {
|
|
5
|
+
const token = args.trim().split(/\s+/)[0]?.toLowerCase() ?? "";
|
|
6
|
+
if (token === "new" || token === "--new") return "new";
|
|
7
|
+
return "here";
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function parseGoalMode(args: string): GoalMode {
|
|
11
|
+
const token = args.trim().split(/\s+/)[0]?.toLowerCase() ?? "";
|
|
12
|
+
if (token === "new" || token === "--new") return "all-new";
|
|
13
|
+
if (token === "continue" || token === "--continue") return "chain-here";
|
|
14
|
+
return "all-here";
|
|
15
|
+
}
|
package/extensions/index.ts
CHANGED
|
@@ -1,17 +1,14 @@
|
|
|
1
1
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
2
2
|
import {
|
|
3
3
|
type ExtensionAPI,
|
|
4
|
+
type ExtensionCommandContext,
|
|
4
5
|
type ExtensionContext,
|
|
5
6
|
isToolCallEventType,
|
|
6
7
|
} from "@earendil-works/pi-coding-agent";
|
|
7
8
|
import { matchesKey, Text, truncateToWidth } from "@earendil-works/pi-tui";
|
|
8
9
|
import { Type } from "typebox";
|
|
9
10
|
import { isSafePlanCommand } from "./bash-guard.ts";
|
|
10
|
-
import {
|
|
11
|
-
ASK_QUESTION_GUIDELINES,
|
|
12
|
-
ASK_USER_QUESTION_TOOL,
|
|
13
|
-
executeAskQuestion,
|
|
14
|
-
} from "./ask-question.ts";
|
|
11
|
+
import { parseBuildPlacement, parseGoalMode, type BuildPlacement } from "./build-session.ts";
|
|
15
12
|
import { ensureDefaultGlobalConfig, loadConfig } from "./config.ts";
|
|
16
13
|
import {
|
|
17
14
|
ensurePlanDir,
|
|
@@ -40,8 +37,23 @@ type Mode = "idle" | "plan" | "build";
|
|
|
40
37
|
|
|
41
38
|
const CONTINUE_THIS = "Continue in this session";
|
|
42
39
|
const CONTINUE_NEW = "Continue in a new session";
|
|
40
|
+
const BUILD_HERE_COMMAND = "/build here";
|
|
41
|
+
const GOAL_CONTINUE_COMMAND = "/goal continue";
|
|
42
|
+
|
|
43
|
+
async function startBuildInNewSession(ctx: ExtensionCommandContext, command = BUILD_HERE_COMMAND): Promise<void> {
|
|
44
|
+
const parentSession = ctx.sessionManager.getSessionFile();
|
|
45
|
+
const result = await ctx.newSession({
|
|
46
|
+
parentSession,
|
|
47
|
+
withSession: async (nextCtx) => {
|
|
48
|
+
await nextCtx.sendUserMessage(command, { expandPromptTemplates: true });
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
if (result.cancelled) {
|
|
52
|
+
ctx.ui.notify("New session cancelled.", "info");
|
|
53
|
+
}
|
|
54
|
+
}
|
|
43
55
|
|
|
44
|
-
const ALWAYS_ON_TOOLS = ["plan_task",
|
|
56
|
+
const ALWAYS_ON_TOOLS = ["plan_task", "ask_user_question"] as const;
|
|
45
57
|
|
|
46
58
|
function unique(names: string[]): string[] {
|
|
47
59
|
return [...new Set(names)];
|
|
@@ -119,6 +131,7 @@ export default async function planTaskExtension(pi: ExtensionAPI): Promise<void>
|
|
|
119
131
|
|
|
120
132
|
let mode: Mode = "idle";
|
|
121
133
|
let continueAll = false;
|
|
134
|
+
let continueNew = false;
|
|
122
135
|
let currentTaskId: number | undefined;
|
|
123
136
|
let toolsBeforePlan: string[] | undefined;
|
|
124
137
|
let awaitingChoice = false;
|
|
@@ -174,6 +187,7 @@ export default async function planTaskExtension(pi: ExtensionAPI): Promise<void>
|
|
|
174
187
|
async function enterPlanMode(ctx: ExtensionContext): Promise<void> {
|
|
175
188
|
mode = "plan";
|
|
176
189
|
continueAll = false;
|
|
190
|
+
continueNew = false;
|
|
177
191
|
currentTaskId = undefined;
|
|
178
192
|
planReadyNotified = false;
|
|
179
193
|
resetFraming();
|
|
@@ -193,6 +207,7 @@ export default async function planTaskExtension(pi: ExtensionAPI): Promise<void>
|
|
|
193
207
|
function leaveModes(ctx: ExtensionContext): void {
|
|
194
208
|
mode = "idle";
|
|
195
209
|
continueAll = false;
|
|
210
|
+
continueNew = false;
|
|
196
211
|
currentTaskId = undefined;
|
|
197
212
|
resetFraming();
|
|
198
213
|
restoreTools();
|
|
@@ -244,6 +259,15 @@ export default async function planTaskExtension(pi: ExtensionAPI): Promise<void>
|
|
|
244
259
|
await startNextTask(ctx);
|
|
245
260
|
return;
|
|
246
261
|
}
|
|
262
|
+
if (continueNew) {
|
|
263
|
+
mode = "idle";
|
|
264
|
+
currentTaskId = undefined;
|
|
265
|
+
resetFraming();
|
|
266
|
+
updateStatus(ctx);
|
|
267
|
+
ctx.ui.notify("Task complete. Opening a new session for the next task.", "info");
|
|
268
|
+
pi.sendUserMessage("/goal-next-session", { expandPromptTemplates: true });
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
247
271
|
if (!ctx.hasUI) {
|
|
248
272
|
ctx.ui.notify("Task complete. Run /build for the next task.", "info");
|
|
249
273
|
mode = "idle";
|
|
@@ -317,43 +341,6 @@ export default async function planTaskExtension(pi: ExtensionAPI): Promise<void>
|
|
|
317
341
|
},
|
|
318
342
|
});
|
|
319
343
|
|
|
320
|
-
pi.registerTool({
|
|
321
|
-
name: ASK_USER_QUESTION_TOOL,
|
|
322
|
-
label: "Ask User Question",
|
|
323
|
-
description:
|
|
324
|
-
"Ask user a clarifying question with selectable options, a recommended default, and optional free-form input. Works in any mode.",
|
|
325
|
-
promptSnippet:
|
|
326
|
-
"Ask user a clarifying question with 2-4 options and a recommended default; works in any mode",
|
|
327
|
-
promptGuidelines: ASK_QUESTION_GUIDELINES,
|
|
328
|
-
parameters: Type.Object({
|
|
329
|
-
question: Type.String({ description: "The clarifying question to ask" }),
|
|
330
|
-
options: Type.Array(
|
|
331
|
-
Type.Object({
|
|
332
|
-
label: Type.String({ description: "Option label" }),
|
|
333
|
-
description: Type.Optional(Type.String({ description: "Optional explanation" })),
|
|
334
|
-
}),
|
|
335
|
-
{ description: "Options to choose from (2-4 required)", minItems: 2, maxItems: 4 },
|
|
336
|
-
),
|
|
337
|
-
recommended: Type.Optional(
|
|
338
|
-
Type.String({
|
|
339
|
-
description:
|
|
340
|
-
"Label of the recommended option (must match one option label). It is shown with a ★ marker.",
|
|
341
|
-
}),
|
|
342
|
-
),
|
|
343
|
-
allowOther: Type.Optional(Type.Boolean({ description: "Allow free-form user answer; default true" })),
|
|
344
|
-
}),
|
|
345
|
-
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
346
|
-
return executeAskQuestion(params, ctx);
|
|
347
|
-
},
|
|
348
|
-
renderCall(args, theme) {
|
|
349
|
-
const question = typeof args.question === "string" ? args.question : "";
|
|
350
|
-
return new Text(
|
|
351
|
-
theme.fg("toolTitle", theme.bold("ask_user_question ")) + theme.fg("muted", question),
|
|
352
|
-
0,
|
|
353
|
-
0,
|
|
354
|
-
);
|
|
355
|
-
},
|
|
356
|
-
});
|
|
357
344
|
pi.registerCommand("plan", {
|
|
358
345
|
description: "Write .plan_task/plan.md and .plan_task/task.md from a file path or prompt",
|
|
359
346
|
handler: async (args, ctx) => {
|
|
@@ -384,7 +371,14 @@ export default async function planTaskExtension(pi: ExtensionAPI): Promise<void>
|
|
|
384
371
|
},
|
|
385
372
|
});
|
|
386
373
|
|
|
387
|
-
async function beginBuild(
|
|
374
|
+
async function beginBuild(
|
|
375
|
+
ctx: ExtensionCommandContext,
|
|
376
|
+
runAll: boolean,
|
|
377
|
+
placement: BuildPlacement = "here",
|
|
378
|
+
options: { chainNew?: boolean; forkCommand?: string } = {},
|
|
379
|
+
): Promise<void> {
|
|
380
|
+
const chainNew = options.chainNew === true;
|
|
381
|
+
const forkCommand = options.forkCommand ?? BUILD_HERE_COMMAND;
|
|
388
382
|
const file = await loadTaskFile(ctx.cwd);
|
|
389
383
|
if (!file) {
|
|
390
384
|
ctx.ui.notify("No plan found. Run /plan first.", "error");
|
|
@@ -398,22 +392,43 @@ export default async function planTaskExtension(pi: ExtensionAPI): Promise<void>
|
|
|
398
392
|
ctx.ui.notify("All tasks are complete.", "info");
|
|
399
393
|
return;
|
|
400
394
|
}
|
|
395
|
+
if (!runAll && !chainNew && placement === "new") {
|
|
396
|
+
await startBuildInNewSession(ctx, forkCommand);
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
401
399
|
continueAll = runAll;
|
|
400
|
+
continueNew = chainNew;
|
|
402
401
|
await enterBuildMode(ctx);
|
|
403
|
-
ctx.ui.notify(
|
|
402
|
+
ctx.ui.notify(
|
|
403
|
+
runAll
|
|
404
|
+
? "Building remaining tasks."
|
|
405
|
+
: chainNew
|
|
406
|
+
? "Building the next task. Remaining tasks will each start in a new session."
|
|
407
|
+
: "Building the next task.",
|
|
408
|
+
"info",
|
|
409
|
+
);
|
|
404
410
|
await startNextTask(ctx);
|
|
405
411
|
}
|
|
406
412
|
|
|
407
413
|
pi.registerCommand("build", {
|
|
408
|
-
description: "Execute the next planned task",
|
|
409
|
-
handler: async (
|
|
410
|
-
await beginBuild(ctx, false);
|
|
414
|
+
description: "Execute the next planned task in this session. Use /build new to start it in a new session",
|
|
415
|
+
handler: async (args, ctx) => {
|
|
416
|
+
await beginBuild(ctx, false, parseBuildPlacement(args));
|
|
411
417
|
},
|
|
412
418
|
});
|
|
413
419
|
|
|
414
420
|
pi.registerCommand("goal", {
|
|
415
|
-
description: "Execute remaining planned tasks until
|
|
416
|
-
handler: async (
|
|
421
|
+
description: "Execute remaining planned tasks until complete. Use /goal new for one new session per task",
|
|
422
|
+
handler: async (args, ctx) => {
|
|
423
|
+
const goalMode = parseGoalMode(args);
|
|
424
|
+
if (goalMode === "all-new") {
|
|
425
|
+
await beginBuild(ctx, false, "new", { forkCommand: GOAL_CONTINUE_COMMAND });
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
if (goalMode === "chain-here") {
|
|
429
|
+
await beginBuild(ctx, false, "here", { chainNew: true });
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
417
432
|
await beginBuild(ctx, true);
|
|
418
433
|
},
|
|
419
434
|
});
|
|
@@ -434,22 +449,21 @@ export default async function planTaskExtension(pi: ExtensionAPI): Promise<void>
|
|
|
434
449
|
pi.registerCommand("build-next-session", {
|
|
435
450
|
description: "Continue the next planned task in a new session",
|
|
436
451
|
handler: async (_args, ctx) => {
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
ctx.ui.notify("New session cancelled.", "info");
|
|
446
|
-
}
|
|
452
|
+
await startBuildInNewSession(ctx);
|
|
453
|
+
},
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
pi.registerCommand("goal-next-session", {
|
|
457
|
+
description: "Continue the next planned task in a new session, then keep chaining new sessions",
|
|
458
|
+
handler: async (_args, ctx) => {
|
|
459
|
+
await startBuildInNewSession(ctx, GOAL_CONTINUE_COMMAND);
|
|
447
460
|
},
|
|
448
461
|
});
|
|
449
462
|
|
|
450
463
|
pi.on("session_start", async (_event, ctx) => {
|
|
451
464
|
mode = "idle";
|
|
452
465
|
continueAll = false;
|
|
466
|
+
continueNew = false;
|
|
453
467
|
currentTaskId = undefined;
|
|
454
468
|
toolsBeforePlan = undefined;
|
|
455
469
|
awaitingChoice = false;
|
|
@@ -457,6 +471,12 @@ export default async function planTaskExtension(pi: ExtensionAPI): Promise<void>
|
|
|
457
471
|
planSource = EMPTY_PLAN_SOURCE;
|
|
458
472
|
resetFraming();
|
|
459
473
|
pi.setActiveTools(withAlwaysOnTools(pi.getActiveTools()));
|
|
474
|
+
if (!pi.getAllTools().some((tool) => tool.name === "ask_user_question")) {
|
|
475
|
+
ctx.ui.notify(
|
|
476
|
+
"ask_user_question is missing. Install npm:@juicesharp/rpiv-ask-user-question.",
|
|
477
|
+
"warning",
|
|
478
|
+
);
|
|
479
|
+
}
|
|
460
480
|
updateStatus(ctx);
|
|
461
481
|
});
|
|
462
482
|
|
|
@@ -19,6 +19,8 @@ describe("planPrompt", () => {
|
|
|
19
19
|
assert.doesNotMatch(prompt, /Planning skill/);
|
|
20
20
|
assert.doesNotMatch(prompt, /tasks\/todo\.md/);
|
|
21
21
|
assert.match(prompt, /ask_user_question/);
|
|
22
|
+
assert.match(prompt, /questions/);
|
|
23
|
+
assert.match(prompt, /\(Recommended\)/);
|
|
22
24
|
});
|
|
23
25
|
|
|
24
26
|
it("inlines a spec file and extra notes", () => {
|
|
@@ -51,6 +53,8 @@ describe("buildPrompt", () => {
|
|
|
51
53
|
assert.match(prompt, /Form submits/);
|
|
52
54
|
assert.match(prompt, /acceptance criteria/);
|
|
53
55
|
assert.match(prompt, /ask_user_question/);
|
|
56
|
+
assert.match(prompt, /questions/);
|
|
57
|
+
assert.match(prompt, /\(Recommended\)/);
|
|
54
58
|
assert.match(prompt, /Do not start the next task/);
|
|
55
59
|
});
|
|
56
60
|
});
|
package/extensions/prompts.ts
CHANGED
|
@@ -35,7 +35,7 @@ Runtime rules:
|
|
|
35
35
|
- Follow the planning method below for process, task sizing, templates, and verification.
|
|
36
36
|
- The checklist lines in \`.plan_task/task.md\` must stay in the exact form \`- [ ] N. Title\` so later sessions can resume.
|
|
37
37
|
- If a spec file is provided, treat it as the primary requirements.
|
|
38
|
-
- If a consequential, user-answerable decision remains, call \`ask_user_question\` with 2-4 options
|
|
38
|
+
- If a consequential, user-answerable decision remains, call \`ask_user_question\` with a \`questions\` array (1-4 questions). Each question needs a short \`header\` and 2-4 options with labels and descriptions. Put the recommended option first and append "(Recommended)" to its label. Do not author "Other" or "Type something." labels. Batch every blocking decision into one call. Do not leave blocking decisions as open questions in the plan.
|
|
39
39
|
- When both files are written, stop and wait for /build.
|
|
40
40
|
|
|
41
41
|
${loadPlanningMethod()}`;
|
|
@@ -64,7 +64,7 @@ Rules:
|
|
|
64
64
|
- Leave the system in a working state when the task ends.
|
|
65
65
|
- Verify against this task's acceptance criteria and verification steps before marking it done.
|
|
66
66
|
- Do not mark the task complete if acceptance criteria are unmet or verification failed.
|
|
67
|
-
- If a consequential decision is still ambiguous, call \`ask_user_question\` instead of guessing.
|
|
67
|
+
- If a consequential decision is still ambiguous, call \`ask_user_question\` instead of guessing. Use a \`questions\` array with a short \`header\` and 2-4 described options. Put the recommended option first and append "(Recommended)" to its label. Do not author "Other" or "Type something." labels. Group related questions into one call.
|
|
68
68
|
- When the task is done, call the plan_task tool with action "complete" and this task id, and keep the matching checklist box checked in \`.plan_task/task.md\`.
|
|
69
69
|
- ${stopRule}`;
|
|
70
70
|
}
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-plan-task",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"license": "MIT",
|
|
5
|
-
"description": "Pi package: /plan, /build, /goal, /tasks.",
|
|
6
|
-
"keywords": ["pi-package"],
|
|
5
|
+
"description": "Pi package: /plan, /build, /goal, and /tasks.",
|
|
6
|
+
"keywords": ["pi-package", "pi-extension", "plan", "build"],
|
|
7
7
|
"files": ["extensions", "README.md"],
|
|
8
8
|
"type": "module",
|
|
9
9
|
"pi": {
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
"@earendil-works/pi-ai": "*",
|
|
14
14
|
"@earendil-works/pi-coding-agent": "*",
|
|
15
15
|
"@earendil-works/pi-tui": "*",
|
|
16
|
+
"@juicesharp/rpiv-ask-user-question": ">=2.7.0",
|
|
16
17
|
"typebox": "*"
|
|
17
18
|
}
|
|
18
19
|
}
|
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { describe, it } from "node:test";
|
|
3
|
-
import {
|
|
4
|
-
OTHER_LABEL,
|
|
5
|
-
questionDisplayLabels,
|
|
6
|
-
validateQuestionParams,
|
|
7
|
-
} from "./ask-question.ts";
|
|
8
|
-
|
|
9
|
-
const options = [
|
|
10
|
-
{ label: "SQLite", description: "Local file" },
|
|
11
|
-
{ label: "Postgres", description: "Shared DB" },
|
|
12
|
-
];
|
|
13
|
-
|
|
14
|
-
describe("validateQuestionParams", () => {
|
|
15
|
-
it("accepts 2-4 unique labels and a matching recommended option", () => {
|
|
16
|
-
const result = validateQuestionParams({
|
|
17
|
-
question: "Which database?",
|
|
18
|
-
options,
|
|
19
|
-
recommended: "postgres",
|
|
20
|
-
});
|
|
21
|
-
assert.equal(result.recommendedIndex, 1);
|
|
22
|
-
assert.equal(result.options.length, 2);
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
it("rejects blank, duplicate, Other-conflicting, and unmatched recommended labels", () => {
|
|
26
|
-
assert.throws(() => validateQuestionParams({ question: "Q", options: [{ label: "A" }, { label: " " }] }), /non-blank/);
|
|
27
|
-
assert.throws(
|
|
28
|
-
() => validateQuestionParams({ question: "Q", options: [{ label: "A" }, { label: "A" }] }),
|
|
29
|
-
/unique/,
|
|
30
|
-
);
|
|
31
|
-
assert.throws(
|
|
32
|
-
() => validateQuestionParams({ question: "Q", options: [{ label: "A" }, { label: "Other path" }] }),
|
|
33
|
-
/Other/,
|
|
34
|
-
);
|
|
35
|
-
assert.throws(
|
|
36
|
-
() => validateQuestionParams({ question: "Q", options, recommended: "Redis" }),
|
|
37
|
-
/recommended/,
|
|
38
|
-
);
|
|
39
|
-
});
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
describe("questionDisplayLabels", () => {
|
|
43
|
-
it("marks the recommended option with a star", () => {
|
|
44
|
-
assert.deepEqual(questionDisplayLabels(options, 1), [
|
|
45
|
-
"SQLite — Local file",
|
|
46
|
-
"★ Postgres — Shared DB",
|
|
47
|
-
]);
|
|
48
|
-
assert.equal(OTHER_LABEL, "Other / type my answer");
|
|
49
|
-
});
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
describe("executeAskQuestion", () => {
|
|
53
|
-
it("asks in chat when no UI is available", async () => {
|
|
54
|
-
const { executeAskQuestion } = await import("./ask-question.ts");
|
|
55
|
-
const result = await executeAskQuestion(
|
|
56
|
-
{ question: "Which database?", options },
|
|
57
|
-
{ hasUI: false } as never,
|
|
58
|
-
);
|
|
59
|
-
assert.match(result.content[0]?.text ?? "", /UI is not available/);
|
|
60
|
-
assert.equal(result.details.answer, null);
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
it("selects the starred option and accepts a free-form Other answer", async () => {
|
|
64
|
-
const { executeAskQuestion, OTHER_LABEL } = await import("./ask-question.ts");
|
|
65
|
-
const selected = await executeAskQuestion(
|
|
66
|
-
{ question: "Which database?", options, recommended: "Postgres" },
|
|
67
|
-
{
|
|
68
|
-
hasUI: true,
|
|
69
|
-
ui: {
|
|
70
|
-
select: async (_question: string, labels: string[]) => labels.find((label) => label.startsWith("★")),
|
|
71
|
-
editor: async () => undefined,
|
|
72
|
-
},
|
|
73
|
-
} as never,
|
|
74
|
-
);
|
|
75
|
-
assert.equal(selected.details.answer, "Postgres");
|
|
76
|
-
assert.equal(selected.details.wasCustom, false);
|
|
77
|
-
|
|
78
|
-
const custom = await executeAskQuestion(
|
|
79
|
-
{ question: "Which database?", options },
|
|
80
|
-
{
|
|
81
|
-
hasUI: true,
|
|
82
|
-
ui: {
|
|
83
|
-
select: async () => OTHER_LABEL,
|
|
84
|
-
editor: async () => " Redis ",
|
|
85
|
-
},
|
|
86
|
-
} as never,
|
|
87
|
-
);
|
|
88
|
-
assert.equal(custom.details.answer, "Redis");
|
|
89
|
-
assert.equal(custom.details.wasCustom, true);
|
|
90
|
-
});
|
|
91
|
-
});
|
|
@@ -1,145 +0,0 @@
|
|
|
1
|
-
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
|
|
3
|
-
export const ASK_USER_QUESTION_TOOL = "ask_user_question";
|
|
4
|
-
export const OTHER_LABEL = "Other / type my answer";
|
|
5
|
-
|
|
6
|
-
export interface QuestionOption {
|
|
7
|
-
label: string;
|
|
8
|
-
description?: string;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export interface QuestionParams {
|
|
12
|
-
question: string;
|
|
13
|
-
options: QuestionOption[];
|
|
14
|
-
recommended?: string;
|
|
15
|
-
allowOther?: boolean;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export const ASK_QUESTION_GUIDELINES = [
|
|
19
|
-
"Use only when repo research leaves a consequential ambiguity.",
|
|
20
|
-
"Prefer 2-4 concrete options. Use short labels.",
|
|
21
|
-
"Don't ask what's discoverable from repo.",
|
|
22
|
-
"Respect user's stated preference.",
|
|
23
|
-
"Provide a recommended option when one choice is clearly preferable.",
|
|
24
|
-
];
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
export function validateQuestionParams(params: QuestionParams): {
|
|
28
|
-
options: QuestionOption[];
|
|
29
|
-
recommendedIndex: number | null;
|
|
30
|
-
} {
|
|
31
|
-
const question = params.question?.trim() ?? "";
|
|
32
|
-
if (!question) throw new Error("question must be non-blank.");
|
|
33
|
-
const options = params.options ?? [];
|
|
34
|
-
if (options.length < 2 || options.length > 4) {
|
|
35
|
-
throw new Error("Provide 2-4 options.");
|
|
36
|
-
}
|
|
37
|
-
const labels = options.map((option) => option.label.trim());
|
|
38
|
-
if (labels.some((label) => !label)) {
|
|
39
|
-
throw new Error("Each option must have a non-blank label.");
|
|
40
|
-
}
|
|
41
|
-
if (new Set(labels).size !== labels.length) {
|
|
42
|
-
throw new Error("Option labels must be unique.");
|
|
43
|
-
}
|
|
44
|
-
if (labels.some((label) => label.toLowerCase() === "other" || label.toLowerCase().startsWith("other "))) {
|
|
45
|
-
throw new Error('Option labels cannot conflict with the "Other" label.');
|
|
46
|
-
}
|
|
47
|
-
let recommendedIndex: number | null = null;
|
|
48
|
-
if (params.recommended) {
|
|
49
|
-
const recommended = params.recommended.trim();
|
|
50
|
-
const matchIdx = labels.findIndex((label) => label.toLowerCase() === recommended.toLowerCase());
|
|
51
|
-
if (matchIdx === -1) {
|
|
52
|
-
throw new Error("recommended must match one of the option labels.");
|
|
53
|
-
}
|
|
54
|
-
recommendedIndex = matchIdx;
|
|
55
|
-
}
|
|
56
|
-
return { options, recommendedIndex };
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export function questionDisplayLabels(
|
|
60
|
-
options: QuestionOption[],
|
|
61
|
-
recommendedIndex: number | null,
|
|
62
|
-
): string[] {
|
|
63
|
-
return options.map((option, index) => {
|
|
64
|
-
const star = recommendedIndex !== null && index === recommendedIndex && options.length > 1 ? "★ " : "";
|
|
65
|
-
return option.description ? `${star}${option.label} — ${option.description}` : `${star}${option.label}`;
|
|
66
|
-
});
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function textResult(
|
|
70
|
-
text: string,
|
|
71
|
-
details: Record<string, unknown>,
|
|
72
|
-
): { content: [{ type: "text"; text: string }]; details: Record<string, unknown> } {
|
|
73
|
-
return { content: [{ type: "text", text }], details };
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
export async function executeAskQuestion(
|
|
77
|
-
params: unknown,
|
|
78
|
-
ctx: ExtensionContext,
|
|
79
|
-
): Promise<{ content: [{ type: "text"; text: string }]; details: Record<string, unknown> }> {
|
|
80
|
-
const typed = params as QuestionParams;
|
|
81
|
-
let options: QuestionOption[];
|
|
82
|
-
let recommendedIndex: number | null;
|
|
83
|
-
try {
|
|
84
|
-
({ options, recommendedIndex } = validateQuestionParams(typed));
|
|
85
|
-
} catch (error) {
|
|
86
|
-
return textResult(error instanceof Error ? error.message : String(error), {
|
|
87
|
-
question: typed.question,
|
|
88
|
-
answer: null,
|
|
89
|
-
cancelled: false,
|
|
90
|
-
wasCustom: false,
|
|
91
|
-
});
|
|
92
|
-
}
|
|
93
|
-
if (!ctx.hasUI) {
|
|
94
|
-
return textResult("UI is not available. Ask this question directly in chat and wait for the user's answer.", {
|
|
95
|
-
question: typed.question,
|
|
96
|
-
options,
|
|
97
|
-
answer: null,
|
|
98
|
-
wasCustom: false,
|
|
99
|
-
cancelled: false,
|
|
100
|
-
});
|
|
101
|
-
}
|
|
102
|
-
const allowOther = typed.allowOther !== false;
|
|
103
|
-
const displayLabels = questionDisplayLabels(options, recommendedIndex);
|
|
104
|
-
const choice = await ctx.ui.select(typed.question, allowOther ? [...displayLabels, OTHER_LABEL] : displayLabels);
|
|
105
|
-
if (!choice) {
|
|
106
|
-
return textResult("User cancelled the question.", {
|
|
107
|
-
question: typed.question,
|
|
108
|
-
options,
|
|
109
|
-
answer: null,
|
|
110
|
-
cancelled: true,
|
|
111
|
-
wasCustom: false,
|
|
112
|
-
});
|
|
113
|
-
}
|
|
114
|
-
if (choice === OTHER_LABEL) {
|
|
115
|
-
const answer = (await ctx.ui.editor("Your answer", ""))?.trim();
|
|
116
|
-
if (!answer) {
|
|
117
|
-
return textResult("User cancelled the question.", {
|
|
118
|
-
question: typed.question,
|
|
119
|
-
options,
|
|
120
|
-
answer: null,
|
|
121
|
-
cancelled: true,
|
|
122
|
-
wasCustom: false,
|
|
123
|
-
});
|
|
124
|
-
}
|
|
125
|
-
return textResult(`User wrote: ${answer}`, {
|
|
126
|
-
question: typed.question,
|
|
127
|
-
options,
|
|
128
|
-
answer,
|
|
129
|
-
wasCustom: true,
|
|
130
|
-
cancelled: false,
|
|
131
|
-
});
|
|
132
|
-
}
|
|
133
|
-
const selectedIndex = displayLabels.indexOf(choice);
|
|
134
|
-
const selected = options[selectedIndex];
|
|
135
|
-
const answer = selected?.label ?? choice;
|
|
136
|
-
return textResult(`User selected: ${answer}`, {
|
|
137
|
-
question: typed.question,
|
|
138
|
-
options,
|
|
139
|
-
answer,
|
|
140
|
-
selectedIndex,
|
|
141
|
-
recommendedIndex,
|
|
142
|
-
wasCustom: false,
|
|
143
|
-
cancelled: false,
|
|
144
|
-
});
|
|
145
|
-
}
|