@geoqiao/pi-ask 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.
Files changed (59) hide show
  1. package/CHANGELOG.md +194 -0
  2. package/LICENSE +22 -0
  3. package/README.md +282 -0
  4. package/docs/README.md +33 -0
  5. package/docs/configuration.md +406 -0
  6. package/docs/contract.md +309 -0
  7. package/docs/remote-events.md +187 -0
  8. package/package.json +130 -0
  9. package/skills/ask-user/SKILL.md +110 -0
  10. package/src/answer-commands.ts +361 -0
  11. package/src/answer-extraction.ts +354 -0
  12. package/src/ask-payload-store.ts +86 -0
  13. package/src/ask-settings-command.ts +14 -0
  14. package/src/ask-tool-helpers.ts +172 -0
  15. package/src/ask-tool.ts +84 -0
  16. package/src/config/defaults.ts +216 -0
  17. package/src/config/migrate.ts +70 -0
  18. package/src/config/migrations/index.ts +139 -0
  19. package/src/config/migrations/types.ts +10 -0
  20. package/src/config/schema.ts +287 -0
  21. package/src/config/store.ts +227 -0
  22. package/src/constants/keymaps.ts +721 -0
  23. package/src/constants/text.ts +12 -0
  24. package/src/constants/ui.ts +22 -0
  25. package/src/index.ts +30 -0
  26. package/src/math.ts +3 -0
  27. package/src/notifications.ts +119 -0
  28. package/src/remote-ask.ts +563 -0
  29. package/src/result-format.ts +157 -0
  30. package/src/result.ts +23 -0
  31. package/src/schema.ts +74 -0
  32. package/src/state/answers.ts +251 -0
  33. package/src/state/create.ts +18 -0
  34. package/src/state/editor.ts +70 -0
  35. package/src/state/navigation.ts +86 -0
  36. package/src/state/normalize.ts +326 -0
  37. package/src/state/question-type.ts +128 -0
  38. package/src/state/result.ts +263 -0
  39. package/src/state/selectors.ts +135 -0
  40. package/src/state/transitions.ts +330 -0
  41. package/src/state/view.ts +28 -0
  42. package/src/text.ts +98 -0
  43. package/src/types.ts +169 -0
  44. package/src/ui/auto-submit.ts +36 -0
  45. package/src/ui/autocomplete.ts +52 -0
  46. package/src/ui/controller.ts +645 -0
  47. package/src/ui/dismiss-guard.ts +26 -0
  48. package/src/ui/input.ts +160 -0
  49. package/src/ui/render-frame.ts +235 -0
  50. package/src/ui/render-helpers.ts +385 -0
  51. package/src/ui/render-question.ts +288 -0
  52. package/src/ui/render-submit.ts +168 -0
  53. package/src/ui/render-types.ts +33 -0
  54. package/src/ui/render.ts +53 -0
  55. package/src/ui/review-shortcuts.ts +43 -0
  56. package/src/ui/settings-list.ts +461 -0
  57. package/src/ui/show-settings.ts +37 -0
  58. package/src/ui/view-models/question.ts +203 -0
  59. package/src/ui/view-models/review.ts +100 -0
@@ -0,0 +1,110 @@
1
+ ---
2
+ name: ask-user
3
+ description: "Use ask_user as a decision, research, and requirements gate before ambiguous or high-stakes choices."
4
+ metadata:
5
+ short-description: Decision, research, and requirements gate
6
+ ---
7
+
8
+ # Ask User decision/research gate
9
+
10
+ Use this skill to force explicit user alignment before consequential decisions, preference-sensitive planning, research scoping, or requirements gathering.
11
+
12
+ This skill is for decision control, research scoping, requirements gathering, and preference-sensitive planning, not general chat.
13
+
14
+ ## Trigger
15
+
16
+ Classify the next step as one of:
17
+
18
+ - `high_stakes`
19
+ - `ambiguous`
20
+ - `both`
21
+ - `clear`
22
+
23
+ Use `ask_user` when the next step is ambiguous, preference-sensitive, or high-stakes and the user has not already made the decision explicitly.
24
+
25
+ Use `ask_user` for any domain where user input changes the plan, recommendation, research direction, output format, criteria, constraints, or next action.
26
+
27
+ Also use `ask_user` when the user asks to gather requirements, interview them, ask questions, scope research, plan work, compare options, or answer a set of open product/design/architecture/research questions. Do not respond with a plain-text questionnaire unless the user explicitly asks for a checklist or written questionnaire.
28
+
29
+ ### Treat as `high_stakes` when the next step changes:
30
+
31
+ - architecture, schema, API contract, deployment, or security posture
32
+ - production-facing behavior in a costly-to-undo way
33
+ - large refactors, migrations, or destructive edits
34
+ - legal, financial, medical, career, hiring, vendor, purchasing, travel, or other costly-to-reverse decisions
35
+ - public-facing claims, sensitive communications, or consequential recommendations
36
+
37
+ ### Treat as `ambiguous` when:
38
+
39
+ - requirements, goals, constraints, evaluation criteria, or success criteria are missing/conflicting
40
+ - multiple valid options exist and the trade-off is preference-sensitive
41
+ - research scope, audience, budget, timeline, risk tolerance, or output format is unclear
42
+ - you would otherwise make a material assumption
43
+
44
+ ## Handshake (required)
45
+
46
+ 1. Gather evidence first from code/docs/tools.
47
+ 2. Summarize neutral context (current state, constraints, trade-offs, recommendation).
48
+ 3. Ask one focused `ask_user` decision question, or bundle 2-5 closely related questions when the user is explicitly in requirement-gathering/interview mode.
49
+ 4. Restate the user decision and proceed explicitly with it.
50
+ 5. Re-open only for materially new ambiguity.
51
+
52
+ ## Question spew prevention
53
+
54
+ Before sending any assistant response that contains 2+ substantive questions for the user, stop and decide whether those questions should be interactive.
55
+
56
+ Use `ask_user` instead of prose when:
57
+
58
+ - the questions are meant to collect requirements, goals, constraints, preferences, scope, priorities, criteria, or missing context
59
+ - answers will materially change the next artifact, recommendation, research direction, plan, implementation, architecture, schema, UX, stack choice, or decision criteria
60
+ - the user previously corrected you with phrases like "ask those questions", "ask interactively", or "use ask_user"
61
+
62
+ Plain-text questions are acceptable only when:
63
+
64
+ - the user asked for a written checklist/list of open questions
65
+ - the questions are rhetorical or purely explanatory
66
+ - there is exactly one small factual clarification and an interactive flow would be heavier than needed
67
+
68
+ If there are too many questions, group them into the smallest coherent `ask_user` batches and ask the highest-impact batch first.
69
+
70
+ ## Question budget and escalation
71
+
72
+ - Max 1 `ask_user` call per decision boundary in normal cases.
73
+ - Max 2 calls for the same boundary if first answer is unclear/cancelled.
74
+ - Never re-ask the same trade-off without new evidence.
75
+
76
+ Attempt 2 (only if needed) must be narrower and include:
77
+
78
+ - `Proceed with recommended option`
79
+ - `Choose another option`
80
+ - `Stop for now`
81
+
82
+ After attempt 2:
83
+
84
+ - for `high_stakes` or `both`: stop as blocked until explicit decision
85
+ - for `ambiguous` only: if user delegates ("your call"), proceed with the most reversible default and state assumptions
86
+
87
+ ## ask_user payload quality
88
+
89
+ - Ask one concrete decision at a time.
90
+ - Provide clear, distinct options. Do not add filler options.
91
+ - Choose question type from semantics: `single` means one answer is expected, `multi` means multiple answers could reasonably be selected, and `preview` means options need preview-pane detail with non-empty preview text.
92
+ - Avoid defaulting mechanically; infer from whether options are mutually exclusive, can coexist, or need preview-pane detail.
93
+ - Keep option labels short and outcome-oriented.
94
+ - Include trade-off descriptions when non-obvious.
95
+ - For research/planning, ask about goals, constraints, evaluation criteria, audience, budget, timeline, risk tolerance, and desired output only when they materially affect the result.
96
+ - Prefer non-`preview` questions when a free-form answer may be useful, since those include an internal `Type your own` option.
97
+
98
+ ## Guardrails
99
+
100
+ - Do not ask before reading available context.
101
+ - Do not use for trivial formatting/style micro-decisions.
102
+ - Do not continue implementation after unclear high-stakes answers.
103
+
104
+ ## Conflict rule
105
+
106
+ If this skill conflicts with implementation behavior or tests, the project contract wins:
107
+
108
+ 1. `docs/contract.md`
109
+ 2. `tests/*.test.ts`
110
+ 3. this skill
@@ -0,0 +1,361 @@
1
+ import {
2
+ BorderedLoader,
3
+ type ExtensionAPI,
4
+ type ExtensionCommandContext,
5
+ type ExtensionContext,
6
+ } from "@earendil-works/pi-coding-agent";
7
+ import {
8
+ extractAskParams,
9
+ selectExtractionModel,
10
+ } from "./answer-extraction.ts";
11
+ import {
12
+ type AskPayloadSource,
13
+ appendAskPayload,
14
+ findLatestPayloadInCurrentBranch,
15
+ } from "./ask-payload-store.ts";
16
+ import {
17
+ invalidPayloadResponse,
18
+ successfulResponse,
19
+ validateParams,
20
+ } from "./ask-tool-helpers.ts";
21
+ import { getAskConfigStore } from "./config/store.ts";
22
+ import type { RemoteAskRuntime, RemoteAskSource } from "./remote-ask.ts";
23
+ import type { AskParams, AskResult } from "./types.ts";
24
+ import { runAskFlow } from "./ui/controller.ts";
25
+
26
+ interface AssistantTextSource {
27
+ entryId: string;
28
+ text: string;
29
+ }
30
+
31
+ type ExtractionSelection = Exclude<
32
+ Awaited<ReturnType<typeof selectExtractionModel>>,
33
+ { error: string }
34
+ >;
35
+
36
+ type ExtractionUiResult =
37
+ | { cancelled: true }
38
+ | { error: string }
39
+ | { params: AskParams };
40
+
41
+ export function registerAnswerCommands(
42
+ pi: ExtensionAPI,
43
+ remoteAsk?: RemoteAskRuntime
44
+ ): void {
45
+ pi.registerCommand("answer", {
46
+ description:
47
+ "Extract questions from the latest assistant message into an ask form",
48
+ handler: async (_args, ctx) => runAnswerCommand(pi, ctx, remoteAsk),
49
+ });
50
+
51
+ pi.registerCommand("answer:again", {
52
+ description: "Reopen the previous /answer form on this branch",
53
+ handler: async (_args, ctx) =>
54
+ runReplayCommand(pi, ctx, {
55
+ missingMessage:
56
+ "No previous /answer form found on this branch; use /answer first.",
57
+ noticePrefix: "Reopening previous /answer form on this branch",
58
+ remoteSource: "answer:again",
59
+ source: "answer-extraction",
60
+ remoteAsk,
61
+ }),
62
+ });
63
+
64
+ pi.registerCommand("ask:replay", {
65
+ description: "Replay the previous ask_user form on this branch",
66
+ handler: async (_args, ctx) =>
67
+ runReplayCommand(pi, ctx, {
68
+ missingMessage: "No previous ask_user form found on this branch.",
69
+ noticePrefix: "Replaying previous ask_user form on this branch",
70
+ remoteSource: "ask:replay",
71
+ source: "tool",
72
+ remoteAsk,
73
+ }),
74
+ });
75
+ }
76
+
77
+ async function runAnswerCommand(
78
+ pi: ExtensionAPI,
79
+ ctx: ExtensionCommandContext,
80
+ remoteAsk?: RemoteAskRuntime
81
+ ): Promise<void> {
82
+ if (ctx.mode !== "tui") {
83
+ ctx.ui.notify("/answer requires interactive TUI mode.", "error");
84
+ return;
85
+ }
86
+
87
+ const assistant = findLatestAssistantText(ctx);
88
+ if ("error" in assistant) {
89
+ ctx.ui.notify(assistant.error, "error");
90
+ return;
91
+ }
92
+
93
+ const config = await getAskConfigStore().getConfig();
94
+ const selected = await selectExtractionModel(
95
+ ctx,
96
+ config.answer.extractionModels
97
+ );
98
+ if ("error" in selected) {
99
+ ctx.ui.notify(selected.error, "error");
100
+ return;
101
+ }
102
+ if (selected.usedFallback) {
103
+ ctx.ui.notify(
104
+ `Configured extraction models unavailable; using current chat model ${selected.model.provider}/${selected.model.id}.`,
105
+ "info"
106
+ );
107
+ }
108
+
109
+ const params = await extractAndValidateAnswerParams(
110
+ ctx,
111
+ assistant,
112
+ selected,
113
+ config
114
+ );
115
+ if (!params) {
116
+ return;
117
+ }
118
+
119
+ appendAskPayload(pi, {
120
+ params,
121
+ source: "answer-extraction",
122
+ sourceEntryId: assistant.entryId,
123
+ });
124
+
125
+ await runAskAndSendSubmittedResult(pi, ctx, params, {
126
+ allowFreeform: true,
127
+ remoteAsk,
128
+ remoteSource: "answer",
129
+ });
130
+ }
131
+
132
+ async function extractAndValidateAnswerParams(
133
+ ctx: ExtensionCommandContext,
134
+ assistant: AssistantTextSource,
135
+ selected: ExtractionSelection,
136
+ config: Awaited<ReturnType<ReturnType<typeof getAskConfigStore>["getConfig"]>>
137
+ ): Promise<AskParams | undefined> {
138
+ const extraction = await withHiddenWorkingRow(ctx, () =>
139
+ runExtractionUi(ctx, assistant, selected, config)
140
+ );
141
+ if ("cancelled" in extraction) {
142
+ ctx.ui.notify("Question extraction cancelled.", "info");
143
+ return;
144
+ }
145
+ if ("error" in extraction) {
146
+ ctx.ui.notify(formatExtractionError(extraction.error), "error");
147
+ return;
148
+ }
149
+ if (!isAskParamsCandidate(extraction.params)) {
150
+ ctx.ui.notify("Question extraction returned an invalid ask form.", "error");
151
+ return;
152
+ }
153
+ if (extraction.params.questions.length === 0) {
154
+ ctx.ui.notify(
155
+ "No questions found in the latest assistant message.",
156
+ "info"
157
+ );
158
+ return;
159
+ }
160
+ const validation = validateParams(extraction.params, { allowFreeform: true });
161
+ if (!validation.ok) {
162
+ const response = invalidPayloadResponse(
163
+ extraction.params,
164
+ validation.issues
165
+ );
166
+ ctx.ui.notify(response.content[0].text, "error");
167
+ return;
168
+ }
169
+ return extraction.params;
170
+ }
171
+
172
+ function runExtractionUi(
173
+ ctx: ExtensionCommandContext,
174
+ assistant: AssistantTextSource,
175
+ selected: ExtractionSelection,
176
+ config: Awaited<ReturnType<ReturnType<typeof getAskConfigStore>["getConfig"]>>
177
+ ): Promise<ExtractionUiResult> {
178
+ return ctx.ui.custom<ExtractionUiResult>((tui, theme, _keybindings, done) => {
179
+ let completed = false;
180
+ const doneOnce = (result: ExtractionUiResult) => {
181
+ if (completed) {
182
+ return;
183
+ }
184
+ completed = true;
185
+ done(result);
186
+ };
187
+ const loader = new BorderedLoader(
188
+ tui,
189
+ theme,
190
+ `Extracting questions using ${selected.model.provider}/${selected.model.id}...`
191
+ );
192
+ loader.onAbort = () => doneOnce({ cancelled: true });
193
+ extractAskParams({
194
+ assistantText: assistant.text,
195
+ auth: selected.auth,
196
+ model: selected.model,
197
+ onRetry: (attempt, maxRetries) => {
198
+ ctx.ui.notify(
199
+ `Retrying extraction JSON repair (${attempt}/${maxRetries})...`,
200
+ "info"
201
+ );
202
+ },
203
+ retries: config.answer.extractionRetries,
204
+ signal: loader.signal,
205
+ timeoutMs: config.answer.extractionTimeoutMs,
206
+ })
207
+ .then((params) => doneOnce({ params }))
208
+ .catch((error) => doneOnce({ error: formatErrorMessage(error) }));
209
+ return loader;
210
+ });
211
+ }
212
+
213
+ function formatErrorMessage(error: unknown): string {
214
+ if (error instanceof Error) {
215
+ return error.name ? `${error.name}: ${error.message}` : error.message;
216
+ }
217
+ return String(error);
218
+ }
219
+
220
+ function isAskParamsCandidate(value: unknown): value is AskParams {
221
+ return (
222
+ !!value &&
223
+ typeof value === "object" &&
224
+ Array.isArray((value as { questions?: unknown }).questions)
225
+ );
226
+ }
227
+
228
+ function formatExtractionError(error: string): string {
229
+ if (error.includes("valid JSON")) {
230
+ return error;
231
+ }
232
+ if (error.includes("cancelled")) {
233
+ return "Question extraction cancelled.";
234
+ }
235
+ if (
236
+ error.includes("aborted") ||
237
+ error.includes("AbortError") ||
238
+ error.includes("timed out")
239
+ ) {
240
+ return "Question extraction timed out. Try again or configure a faster extraction model.";
241
+ }
242
+ return error;
243
+ }
244
+
245
+ async function runReplayCommand(
246
+ pi: ExtensionAPI,
247
+ ctx: ExtensionCommandContext,
248
+ options: {
249
+ missingMessage: string;
250
+ noticePrefix: string;
251
+ remoteAsk?: RemoteAskRuntime;
252
+ remoteSource: RemoteAskSource;
253
+ source: AskPayloadSource;
254
+ }
255
+ ): Promise<void> {
256
+ if (ctx.mode !== "tui") {
257
+ ctx.ui.notify("Ask replay requires interactive TUI mode.", "error");
258
+ return;
259
+ }
260
+ const lookup = findLatestPayloadInCurrentBranch(ctx, options.source);
261
+ if (!lookup.data) {
262
+ ctx.ui.notify(
263
+ lookup.invalidMatchFound
264
+ ? "Previous form exists on this branch but is no longer compatible."
265
+ : options.missingMessage,
266
+ "info"
267
+ );
268
+ return;
269
+ }
270
+ ctx.ui.notify(
271
+ `${options.noticePrefix}: ${lookup.data.params.questions.length} question(s).`,
272
+ "info"
273
+ );
274
+ await runAskAndSendSubmittedResult(pi, ctx, lookup.data.params, {
275
+ allowFreeform: options.source === "answer-extraction",
276
+ remoteAsk: options.remoteAsk,
277
+ remoteSource: options.remoteSource,
278
+ });
279
+ }
280
+
281
+ async function runAskAndSendSubmittedResult(
282
+ pi: Pick<ExtensionAPI, "sendUserMessage">,
283
+ ctx: ExtensionContext,
284
+ params: AskParams,
285
+ options: {
286
+ allowFreeform: boolean;
287
+ remoteAsk?: RemoteAskRuntime;
288
+ remoteSource: RemoteAskSource;
289
+ }
290
+ ): Promise<void> {
291
+ const result = await withHiddenWorkingRow(ctx, () =>
292
+ runAskFlow(ctx, params, {
293
+ allowFreeform: options.allowFreeform,
294
+ remote: options.remoteAsk
295
+ ? { runtime: options.remoteAsk, source: options.remoteSource }
296
+ : undefined,
297
+ })
298
+ );
299
+ if (result.cancelled) {
300
+ ctx.ui.notify("Ask form cancelled.", "info");
301
+ return;
302
+ }
303
+ sendAskResult(pi, result, ctx);
304
+ }
305
+
306
+ async function withHiddenWorkingRow<T>(
307
+ ctx: ExtensionContext,
308
+ run: () => Promise<T>
309
+ ): Promise<T> {
310
+ ctx.ui.setWorkingVisible(false);
311
+ try {
312
+ return await run();
313
+ } finally {
314
+ ctx.ui.setWorkingVisible(true);
315
+ }
316
+ }
317
+
318
+ function sendAskResult(
319
+ pi: Pick<ExtensionAPI, "sendUserMessage">,
320
+ result: AskResult,
321
+ ctx: Pick<ExtensionContext, "isIdle">
322
+ ): void {
323
+ const response = successfulResponse(result);
324
+ const text = response.content[0];
325
+ pi.sendUserMessage(
326
+ text.text,
327
+ ctx.isIdle() ? undefined : { deliverAs: "followUp" }
328
+ );
329
+ }
330
+
331
+ function findLatestAssistantText(
332
+ ctx: Pick<ExtensionContext, "sessionManager">
333
+ ): AssistantTextSource | { error: string } {
334
+ const branch = ctx.sessionManager.getBranch();
335
+ for (let index = branch.length - 1; index >= 0; index--) {
336
+ const entry = branch[index];
337
+ if (entry.type !== "message") {
338
+ continue;
339
+ }
340
+ const message = entry.message;
341
+ if (!("role" in message) || message.role !== "assistant") {
342
+ continue;
343
+ }
344
+ if (message.stopReason !== "stop") {
345
+ return {
346
+ error: `Latest assistant message is incomplete (${message.stopReason}); wait for it to finish, then run /answer again.`,
347
+ };
348
+ }
349
+ const text = message.content
350
+ .filter(
351
+ (part): part is { text: string; type: "text" } => part.type === "text"
352
+ )
353
+ .map((part) => part.text)
354
+ .join("\n")
355
+ .trim();
356
+ if (text) {
357
+ return { entryId: entry.id, text };
358
+ }
359
+ }
360
+ return { error: "No assistant message found to extract questions from." };
361
+ }