@pify/ask-question 0.2.0 → 0.3.1

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,21 +1,62 @@
1
1
  # @pify/ask-question
2
2
 
3
- Let the model ask instead of guessing — a Claude Code `AskUserQuestion`-shaped tool for [pi](https://github.com/earendil-works/pi): 1-4 structured questions, written-out options with trade-offs, multi-select, and an "Other…" free-text path.
3
+ Let the model ask instead of guessing — a structured question tool for [pi](https://github.com/earendil-works/pi). Up to four questions per batch, written-out options with their trade-offs, multi-select, and a free-text path for the answer you didn't offer.
4
4
 
5
5
  Part of the [Pify suite](https://github.com/pifydev). Install with [`pify install ask-question`](https://github.com/pifydev/cli) or `pi install npm:@pify/ask-question`.
6
6
 
7
- ## What it does
7
+ ## Why
8
8
 
9
- - **`ask_question`** the agent batches up to 4 questions, each with up to 4 options (`label` + `description` trade-offs, recommendation marked "(Recommended)" and listed first), optional `multiSelect`, and free-text via "Other…".
10
- - **Built entirely on pi's built-in dialogs** (`select`/`input`) — no custom TUI overlay, so it works identically in the terminal and RPC/GUI hosts and can't break with pi UI changes. Multi-select is a checkbox toggle loop with `✓ Done`.
11
- - **Discipline encoded in the tool description** (zhushanwen's three conditions): only when 2+ reasonable approaches exist, context is already gathered, and a wrong pick means rework. Never for permissions or things the agent can look up.
12
- - **Declining is an answer**: Esc cleanly reports "the user declined" for the rest of the batch — no error, no re-asking. Headless runs get the full questionnaire back — every question with its options — plus "proceed with your best judgment and say which option you assumed", so the decision stays in the CI transcript instead of vanishing (asking is advisory, unlike the fail-closed safety gates).
13
- - **Rows the user can actually pick** (v0.2): two options sharing a label, or one labelled `Other…`, used to render as indistinguishable rows where the second was unselectable. Duplicates are now suffixed, reserved labels renamed, and every pick resolves by its position in the dialog rather than by its text.
14
- - Structured results return to the model as both readable text and `details.answers`.
9
+ An agent that will not ask has only one way to handle ambiguity: pick something and keep going. That is fine when the choices are equivalent and expensive when they are not the wrong guess is discovered after the work has been built on top of it. The cost of asking is one dialog; the cost of guessing wrong is the rework.
15
10
 
16
- ## Why no fancy overlay?
11
+ The opposite failure is just as real. An agent that asks about everything turns delegation into an interview, and the questions it asks are usually ones it could have answered by reading a file. So the tool's description spends most of its words on when *not* to use it.
17
12
 
18
- The three big prior arts (6-16k lines each) all build custom TUI overlays — tabbed questionnaires, split-pane previews, searchable lists. They're impressive and fragile. `@henryqw/pi-ask-question` proved 256 lines of built-in dialogs covers the core; this package takes that floor and adds the CC schema, multi-select, and discipline. The overlay experience can return as v0.2 if demand appears.
13
+ ## The tool
14
+
15
+ ### `ask_question`
16
+
17
+ | Parameter | Type | Notes |
18
+ |---|---|---|
19
+ | `questions` | array, 1–4 | Asked in order, each as its own dialog |
20
+ | `questions[].question` | string | The complete question, ending in a question mark |
21
+ | `questions[].options` | array, up to 4 | Omit for a pure free-text prompt |
22
+ | `questions[].options[].label` | string | The choice itself, 1–6 words |
23
+ | `questions[].options[].description` | string, optional | What this choice costs or implies |
24
+ | `questions[].multiSelect` | boolean, optional | Checkbox toggles ending in `✓ Done`, instead of a single pick |
25
+ | `questions[].allowOther` | boolean, optional | Free-text `Other…` path; on by default |
26
+
27
+ A recommendation is expressed by marking the label `(Recommended)` and listing it first.
28
+
29
+ Answers return both as readable text and as `details.answers`, so anything reading the tool result does not have to parse prose.
30
+
31
+ ### When it should fire
32
+
33
+ The tool description holds the agent to three conditions at once:
34
+
35
+ - two or more reasonable approaches genuinely exist,
36
+ - the context needed to choose has already been gathered,
37
+ - and picking wrong means rework rather than a small correction.
38
+
39
+ It is explicitly not for permission ("shall I edit this file?") and not for anything the agent could look up.
40
+
41
+ ## Behaviour
42
+
43
+ - **Built on pi's own dialogs.** `select` and `input`, nothing custom. It therefore behaves identically in the terminal and in RPC or GUI hosts, and cannot break when pi's UI changes. Multi-select is a toggle loop over the same primitive.
44
+ - **Declining is an answer.** Esc reports *the user declined* for the rest of the batch — no error, no re-asking, no second dialog fighting for your attention.
45
+ - **Headless runs get the questionnaire back.** With no UI available the tool returns every question and option as text, plus an instruction to proceed on best judgment and say which option was assumed. The decision then lives in the CI transcript instead of vanishing. Asking is advisory, unlike this suite's fail-closed safety gates: a question that cannot be asked must never stop the run.
46
+ - **Rows you can actually pick.** Two options sharing a label, or one already called `Other…`, used to render as indistinguishable rows where the second could not be selected. Duplicates are suffixed, reserved labels renamed, and every answer resolves by its position in the dialog rather than by its text.
47
+ - **The decisions stay on the record.** Every questionnaire is appended to the session as its own entry. Forks and `/reload` keep their own history, because the entries live on the branch rather than in memory.
48
+
49
+ ## Command
50
+
51
+ `/ask` — show the last questionnaire and what you chose or declined. `/ask all` prints the whole history for this branch.
52
+
53
+ ## Design notes
54
+
55
+ There is no custom TUI overlay, and that is deliberate. An overlay means owning a rendering surface: it has to be re-tested against every pi UI change, it does not exist at all in RPC and GUI hosts, and it fails in the one place a question matters most — when something has already gone sideways. Built-in dialogs work everywhere pi works.
56
+
57
+ ## Where this sits in the suite
58
+
59
+ This is the question a *top-level* agent asks you. A child agent spawned by [`@pify/subagent`](https://github.com/pifydev/subagent) reaches you through its own `ask_supervisor` tool instead, because the parent is blocked inside the tool call that spawned the child and could not answer anyway.
19
60
 
20
61
  ## License
21
62
 
@@ -20,12 +20,16 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
20
20
  import { Type } from "typebox";
21
21
 
22
22
  import {
23
+ ASK_STATE,
23
24
  DONE_LABEL,
24
25
  OTHER_LABEL,
25
26
  formatAnswers,
26
27
  headlessText,
28
+ parseAskRoute,
27
29
  parseSingleRow,
28
30
  parseToggleRow,
31
+ replayRounds,
32
+ routeText,
29
33
  singleRows,
30
34
  toggleRows,
31
35
  validateQuestions,
@@ -132,6 +136,10 @@ export default function askQuestion(pi: ExtensionAPI) {
132
136
  }
133
137
  }
134
138
 
139
+ // Record the round so /ask can show it later, and a fork keeps its own
140
+ // history; appended per round, never overwritten.
141
+ pi.appendEntry(ASK_STATE, { timestamp: Date.now(), answers });
142
+
135
143
  const text = [
136
144
  formatAnswers(answers),
137
145
  ...(result.warnings.length > 0 ? [`Warnings: ${result.warnings.join("; ")}`] : []),
@@ -139,4 +147,13 @@ export default function askQuestion(pi: ExtensionAPI) {
139
147
  return { content: [{ type: "text", text }], details: { answers } };
140
148
  },
141
149
  });
150
+
151
+ pi.registerCommand("ask", {
152
+ description: "Show what the agent asked you and how you answered: /ask [last | all]",
153
+ handler: async (args, ctx) => {
154
+ if (!ctx.hasUI) return;
155
+ const rounds = replayRounds(ctx.sessionManager.getBranch() as never);
156
+ ctx.ui.notify(routeText(parseAskRoute(args ?? ""), rounds), "info");
157
+ },
158
+ });
142
159
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pify/ask-question",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Let the model ask instead of guessing: CC AskUserQuestion-shaped tool on built-in dialogs - 1-4 questions, multi-select, Other free-text, works in TUI and RPC",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -58,7 +58,7 @@
58
58
  }
59
59
  },
60
60
  "devDependencies": {
61
- "@earendil-works/pi-coding-agent": "^0.84.4",
61
+ "@earendil-works/pi-coding-agent": "^0.85.1",
62
62
  "@types/node": "^22.10.2",
63
63
  "typebox": "^1.1.38",
64
64
  "typescript": "^5.7.2"
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: ask-question
3
- description: Use when a decision is genuinely ambiguous after gathering context and picking wrong means rework - explains the ask_question discipline and how to write good options
3
+ description: Use when a decision is genuinely ambiguous after gathering context and picking wrong means rework
4
4
  ---
5
5
 
6
6
  # Asking the user
package/src/ask.ts CHANGED
@@ -184,6 +184,80 @@ export function headlessText(questions: AskQuestion[]): string {
184
184
  ].join("\n");
185
185
  }
186
186
 
187
+ export const ASK_STATE = "ask-question-round";
188
+
189
+ export interface AskRound {
190
+ timestamp: number;
191
+ answers: AskAnswer[];
192
+ }
193
+
194
+ export interface BranchEntryLike {
195
+ type?: string;
196
+ customType?: string;
197
+ data?: unknown;
198
+ [key: string]: unknown;
199
+ }
200
+
201
+ /**
202
+ * Every questionnaire is appended as its own entry (not a last-wins
203
+ * snapshot): the point of the record is the sequence of decisions, and an
204
+ * earlier answer stays true after a later one is given.
205
+ */
206
+ export function replayRounds(entries: BranchEntryLike[]): AskRound[] {
207
+ const rounds: AskRound[] = [];
208
+ for (const entry of entries) {
209
+ if (entry.type !== "custom" || entry.customType !== ASK_STATE) continue;
210
+ const data = entry.data;
211
+ if (!isRecord(data) || !Array.isArray(data.answers)) continue;
212
+ rounds.push({
213
+ timestamp: typeof data.timestamp === "number" ? data.timestamp : 0,
214
+ answers: data.answers as AskAnswer[],
215
+ });
216
+ }
217
+ return rounds;
218
+ }
219
+
220
+ export type AskRoute = { kind: "last" } | { kind: "all" } | { kind: "help" } | { kind: "unknown"; input: string };
221
+
222
+ export const ASK_USAGE = "Usage: /ask [last | all]";
223
+
224
+ export function parseAskRoute(raw: string): AskRoute {
225
+ const text = (raw ?? "").trim().toLowerCase();
226
+ if (!text || text === "last") return { kind: "last" };
227
+ if (text === "all" || text === "history") return { kind: "all" };
228
+ if (text === "help" || text === "?") return { kind: "help" };
229
+ return { kind: "unknown", input: text };
230
+ }
231
+
232
+ function stamp(timestamp: number): string {
233
+ if (!timestamp) return "";
234
+ const d = new Date(timestamp);
235
+ const pad = (n: number) => String(n).padStart(2, "0");
236
+ return `${pad(d.getHours())}:${pad(d.getMinutes())} `;
237
+ }
238
+
239
+ /** What /ask prints. */
240
+ export function routeText(route: AskRoute, rounds: AskRound[]): string {
241
+ switch (route.kind) {
242
+ case "help":
243
+ return ASK_USAGE;
244
+ case "unknown":
245
+ return `Unknown route "${route.input}". ${ASK_USAGE}`;
246
+ case "last": {
247
+ const last = rounds[rounds.length - 1];
248
+ return last
249
+ ? `${stamp(last.timestamp)}last questionnaire\n${formatAnswers(last.answers)}`
250
+ : "No questions have been asked in this session.";
251
+ }
252
+ case "all":
253
+ return rounds.length === 0
254
+ ? "No questions have been asked in this session."
255
+ : rounds
256
+ .map((round, i) => `#${i + 1} ${stamp(round.timestamp)}\n${formatAnswers(round.answers)}`)
257
+ .join("\n\n");
258
+ }
259
+ }
260
+
187
261
  /** Text block the model receives. */
188
262
  export function formatAnswers(answers: AskAnswer[]): string {
189
263
  return answers