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 CHANGED
@@ -1,19 +1,45 @@
1
1
  # pi-plan-task
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/pi-plan-task.svg)](https://www.npmjs.com/package/pi-plan-task)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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
- | `/goal` | Execute remaining tasks until `task.md` is complete. No session prompts. |
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
- `/plan` accepts a spec file, a prompt, or a file plus extra notes:
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` needs:
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` and `/build` send a short visible user message to start the turn.
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` is available in every mode, not just `/plan`. Use it for consequential choices the repo cannot answer:
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 selectable options
56
- - optional recommended default, shown with
57
- - free-form Other path (on by default; set `allowOther: false` to hide it)
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
- In the TUI, pick an option or choose **Other / type my answer**. Without a UI, the tool asks the agent to pose the question in chat.
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` resumes from the first unchecked item.
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
- ```bash
97
- pi install D:/work/tools/pi-plan-task
98
- ```
135
+ ## License
99
136
 
100
- Or add the path to `packages` in `~/.pi/agent/settings.json`, then restart Pi or run `/reload`.
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
+ }
@@ -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", ASK_USER_QUESTION_TOOL] as const;
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(ctx: ExtensionContext, runAll: boolean): Promise<void> {
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(runAll ? "Building remaining tasks." : "Building the next task.", "info");
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 (_args, ctx) => {
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 the list is complete",
416
- handler: async (_args, ctx) => {
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
- const parentSession = ctx.sessionManager.getSessionFile();
438
- const result = await ctx.newSession({
439
- parentSession,
440
- withSession: async (nextCtx) => {
441
- await nextCtx.sendUserMessage("/build", { expandPromptTemplates: true });
442
- },
443
- });
444
- if (result.cancelled) {
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
  });
@@ -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, a recommended default, and an Other path. Do not leave blocking decisions as open questions in the plan.
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.1",
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
- }