@yandy0725/pi-ask-user 0.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/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@yandy0725/pi-ask-user",
3
+ "publishConfig": {
4
+ "access": "public"
5
+ },
6
+ "version": "0.1.0",
7
+ "description": "Interactive ask_user tool for pi-coding-agent with searchable split-pane selection UI, multi-select, and freeform input",
8
+ "license": "MIT",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "https://github.com/yandy/pi-packages",
12
+ "directory": "pi-ask-user"
13
+ },
14
+ "type": "module",
15
+ "keywords": [
16
+ "pi-package"
17
+ ],
18
+ "files": [
19
+ "index.ts",
20
+ "src/",
21
+ "skills/"
22
+ ],
23
+ "scripts": {
24
+ "test": "vitest run",
25
+ "test:watch": "vitest",
26
+ "typecheck": "tsc --noEmit",
27
+ "lint": "biome lint .",
28
+ "format": "biome format --write .",
29
+ "check": "biome check ."
30
+ },
31
+ "pi": {
32
+ "extensions": [
33
+ "./index.ts"
34
+ ],
35
+ "skills": [
36
+ "./skills"
37
+ ]
38
+ },
39
+ "peerDependencies": {
40
+ "@earendil-works/pi-coding-agent": ">=0.80.2",
41
+ "@earendil-works/pi-tui": ">=0.80.2",
42
+ "@sinclair/typebox": "*"
43
+ },
44
+ "devDependencies": {
45
+ "@biomejs/biome": "^2.5.0",
46
+ "@types/node": "^22.0.0",
47
+ "typescript": "~5.7.0",
48
+ "vitest": "^3.0.0"
49
+ }
50
+ }
@@ -0,0 +1,162 @@
1
+ ---
2
+ name: ask-user
3
+ description: "You MUST use this before high-stakes architectural decisions, irreversible changes, or when requirements are ambiguous. Runs a decision handshake with the ask_user tool: summarize context, present structured options, collect explicit user choice, then proceed."
4
+ metadata:
5
+ short-description: Decision gate for ambiguity and high-stakes choices
6
+ ---
7
+
8
+ # Ask User Decision Gate
9
+
10
+ Use this skill to force explicit user alignment before consequential decisions.
11
+
12
+ This skill is about **decision control**, not general chit-chat.
13
+
14
+ ## Non-negotiable rule
15
+
16
+ Invoke `ask_user` before proceeding when **any** of the following is true:
17
+
18
+ 1. The next step changes architecture, schema, API contracts, deployment strategy, or security posture.
19
+ 2. The work is costly to undo (large refactor, migration, destructive edit, production-facing behavior change).
20
+ 3. Requirements, constraints, or success criteria are unclear, conflicting, or missing.
21
+ 4. Multiple valid options exist and the trade-off is preference-dependent.
22
+ 5. You are about to assume something that can materially change implementation.
23
+
24
+ Do **not** skip this gate unless the user has already provided a clear, explicit decision for the exact trade-off.
25
+
26
+ ## Agent Protocol Handshake (required)
27
+
28
+ Follow this handshake in order.
29
+
30
+ ### 1) Detect boundary
31
+ Classify the current step as:
32
+ - `high_stakes`
33
+ - `ambiguous`
34
+ - `both`
35
+ - `clear` (no gate needed)
36
+
37
+ If classification is not `clear`, continue.
38
+
39
+ ### 2) Gather evidence first
40
+ Before asking, gather context from available tools (`read`, `bash`, `exa`, `ref`, etc.).
41
+ Do not ask the user to decide blind.
42
+
43
+ ### 3) Synthesize context
44
+ Prepare a short neutral summary (3-7 bullets or short paragraph) covering:
45
+ - current state
46
+ - key constraints
47
+ - trade-offs
48
+ - recommendation (if any)
49
+
50
+ ### 4) Ask one focused question
51
+ Call `ask_user` with one decision at a time:
52
+ - `question`: concrete decision prompt
53
+ - `context`: synthesized summary
54
+ - `options`: 2-5 clear choices when possible
55
+ - `allowMultiple`: `false` unless independent selections are genuinely needed
56
+ - `allowFreeform`: usually `true`
57
+ - `displayMode` *(optional)*: `"overlay"` (default) or `"inline"`. Use `"inline"` when preceding assistant context (summary, trade-offs, recommendation) is essential to the decision and should remain visible — overlays cover the conversation underneath. The user may set a personal default via the `PI_ASK_USER_DISPLAY_MODE` environment variable; only pass this when you intentionally want to override it for one call.
58
+ ### 5) Commit the decision
59
+ After response:
60
+ - restate the decision in plain language
61
+ - state what will be done next
62
+ - proceed with implementation
63
+
64
+ ### 6) Re-open only on new ambiguity
65
+ Ask again only if materially new uncertainty appears.
66
+ Avoid repetitive confirmation loops.
67
+
68
+ ## Anti-overasking guardrails (required)
69
+
70
+ Apply a strict question budget per decision boundary:
71
+
72
+ - **Max 1** `ask_user` call per decision boundary in normal cases.
73
+ - **Max 2** `ask_user` calls for the same boundary when first response is unclear/cancelled.
74
+ - Never ask the same trade-off again without new evidence.
75
+
76
+ Escalation ladder:
77
+
78
+ 1. **Attempt 1:** structured options + concise context.
79
+ 2. **Attempt 2 (only if needed):** narrower question with agent recommendation and explicit choices:
80
+ - `Proceed with recommended option`
81
+ - `Choose another option` (freeform)
82
+ - `Stop for now`
83
+
84
+ After attempt 2:
85
+
86
+ - If boundary is `high_stakes` or `both`: **stop and mark blocked**. Do not keep asking.
87
+ - If boundary is `ambiguous` only and user says “your call” or equivalent: proceed with the most reversible default and state assumptions explicitly.
88
+
89
+ ## `ask_user` payload quality standard
90
+
91
+ ### Question quality
92
+ Use:
93
+ - “Which option should we adopt for X?”
94
+ - “Do you want A (fast) or B (safer) for Y?”
95
+
96
+ Avoid:
97
+ - broad/open prompts with no decision boundary
98
+ - multiple unrelated decisions in one question
99
+ - questions that should be answered by reading code/docs first
100
+
101
+ ### Option quality
102
+ Options must be:
103
+ - mutually understandable
104
+ - short and outcome-oriented
105
+ - explicit on trade-offs
106
+
107
+ Good options include a short description when trade-offs are non-obvious.
108
+
109
+ ## Recommended patterns
110
+
111
+ ### Single-select architecture decision
112
+
113
+ ```json
114
+ {
115
+ "question": "Which caching strategy should we use for the first release?",
116
+ "context": "Current API has p95 latency issues. Redis is fastest but adds infra complexity; in-memory cache is simpler but not shared across instances.",
117
+ "options": [
118
+ { "title": "In-memory cache", "description": "Simpler rollout, weaker horizontal consistency" },
119
+ { "title": "Redis cache", "description": "Better consistency and scalability, more ops overhead" }
120
+ ],
121
+ "allowMultiple": false,
122
+ "allowFreeform": true
123
+ }
124
+ ```
125
+
126
+ ### Multi-select when decisions are independent
127
+
128
+ ```json
129
+ {
130
+ "question": "Select the first-wave hardening items to implement now.",
131
+ "context": "We can ship quickly with baseline controls, then add targeted hardening. Budget is limited to 1-2 days.",
132
+ "options": [
133
+ "Rate limiting",
134
+ "Audit logging",
135
+ "Input schema validation",
136
+ "Secrets rotation"
137
+ ],
138
+ "allowMultiple": true,
139
+ "allowFreeform": true
140
+ }
141
+ ```
142
+
143
+ ## Anti-patterns
144
+
145
+ - Asking `ask_user` without first gathering context
146
+ - Using it for trivial formatting choices
147
+ - Forcing options when freeform is clearly better
148
+ - Asking the same question repeatedly without new information
149
+ - Proceeding with high-stakes implementation after unclear/cancelled answer
150
+
151
+ ## If user cancels or answer is unclear
152
+
153
+ Pause execution and explain what is blocked.
154
+ Use at most one narrower follow-up `ask_user` question (attempt 2).
155
+ After that, do not continue asking in a loop:
156
+ - for high-stakes decisions: remain blocked until explicit decision
157
+ - for ambiguity-only decisions: proceed only if user delegated the choice ("your call")
158
+
159
+ ## Additional reference
160
+
161
+ For full trigger matrix, UX conventions, and extension interaction details, read:
162
+ - `references/ask-user-skill-extension-spec.md`
@@ -0,0 +1,96 @@
1
+ # Ask User Skill × Extension Interaction Spec
2
+
3
+ ## Purpose
4
+
5
+ This document defines a minimal decision-gating protocol for using the `ask-user` skill with the `ask_user` tool.
6
+
7
+ Goal: require explicit user decisions at high-impact or ambiguous boundaries before implementation continues.
8
+
9
+ ---
10
+
11
+ ## 1) Trigger Matrix (When to Call `ask_user`)
12
+
13
+ | Scenario | Must Ask? | Why |
14
+ |---|---:|---|
15
+ | Architecture trade-off (e.g., queue vs cron, SQL vs KV) | Yes | Preference-sensitive, high blast radius |
16
+ | Data schema / migration path selection | Yes | Costly to reverse |
17
+ | Security/compliance posture trade-off | Yes | Risk ownership is human |
18
+ | Requirements conflict or ambiguity | Yes | Need explicit intent |
19
+ | Non-trivial scope cut/prioritization | Yes | Product decision, not purely technical |
20
+ | Purely local refactor with identical behavior | Usually no | No policy-level decision |
21
+ | Formatting-only edits | No | Trivial |
22
+ | User already gave explicit choice for exact trade-off | No (unless new ambiguity) | Decision already captured |
23
+
24
+ ---
25
+
26
+ ## 2) Decision Handshake
27
+
28
+ Use this protocol whenever the trigger matrix says to ask.
29
+
30
+ 1. **Detect boundary**
31
+ - classify as `high_stakes`, `ambiguous`, `both`, or `clear`
32
+ 2. **Gather evidence**
33
+ - read code/docs/logs first; do not ask blindly
34
+ 3. **Summarize context**
35
+ - prepare concise trade-off context (3–7 bullets or short paragraph)
36
+ 4. **Ask one focused question**
37
+ - call `ask_user` for one decision at a time
38
+ 5. **Commit and proceed**
39
+ - restate chosen option and implement accordingly
40
+
41
+ ### Retry/cancel policy
42
+
43
+ - Max **2** `ask_user` attempts for the same decision boundary.
44
+ - Attempt 1: normal structured question.
45
+ - Attempt 2: narrower question with recommendation and explicit options.
46
+ - After attempt 2:
47
+ - `high_stakes` / `both`: stop and report blocked.
48
+ - `ambiguous` only: proceed only if user delegates (e.g., “your call”), using the most reversible default.
49
+
50
+ ---
51
+
52
+ ## 3) Example Payloads
53
+
54
+ ### Architecture decision
55
+
56
+ ```json
57
+ {
58
+ "question": "Which implementation path should we use for v1?",
59
+ "context": "Path A is faster to ship but less extensible. Path B takes longer but supports plugin-style growth. Existing deadline is 2 weeks.",
60
+ "options": [
61
+ { "title": "Path A (ship fast)", "description": "Lowest scope, revisit architecture later" },
62
+ { "title": "Path B (extensible)", "description": "Higher initial effort, cleaner long-term composition" }
63
+ ],
64
+ "allowMultiple": false,
65
+ "allowFreeform": true
66
+ }
67
+ ```
68
+
69
+ ### Display mode (optional)
70
+
71
+ The `ask_user` tool accepts an optional `displayMode` parameter:
72
+
73
+ - `"overlay"` *(default)*: centered modal; covers the conversation underneath.
74
+ - `"inline"`: rendered in the conversation flow; preceding messages stay visible.
75
+
76
+ Guidance:
77
+
78
+ - Omit `displayMode` to respect the user's configured preference (`PI_ASK_USER_DISPLAY_MODE` environment variable).
79
+ - Pass `"inline"` only when the immediately preceding assistant message (summary, trade-offs, recommendation) is the primary context for the decision and must remain visible.
80
+ - Pass `"overlay"` only to explicitly force the modal style (rare).
81
+
82
+ ### Requirement-priority decision
83
+
84
+ ```json
85
+ {
86
+ "question": "Which requirement should be prioritized first?",
87
+ "context": "Current request mixes performance tuning and UI redesign. Doing both now risks delaying delivery.",
88
+ "options": [
89
+ "Performance first",
90
+ "UI redesign first",
91
+ "Do a minimal pass on both"
92
+ ],
93
+ "allowMultiple": false,
94
+ "allowFreeform": true
95
+ }
96
+ ```
@@ -0,0 +1,208 @@
1
+ export interface QuestionOption {
2
+ title: string;
3
+ description?: string;
4
+ }
5
+
6
+ export interface AnnotatedRow {
7
+ line: string;
8
+ selected: boolean;
9
+ }
10
+
11
+ export interface RenderSingleSelectRowsParams {
12
+ options: QuestionOption[];
13
+ selectedIndex: number;
14
+ width: number;
15
+ allowFreeform: boolean;
16
+ allowComment?: boolean;
17
+ commentEnabled?: boolean;
18
+ maxRows?: number;
19
+ hideDescriptions?: boolean;
20
+ }
21
+
22
+ function wrapText(text: string, width: number): string[] {
23
+ const normalized = text.replace(/\s+/g, " ").trim();
24
+ if (!normalized) return [""];
25
+ if (width <= 1) return normalized.split("");
26
+
27
+ const words = normalized.split(" ");
28
+ const lines: string[] = [];
29
+ let current = "";
30
+
31
+ for (const word of words) {
32
+ if (!current) {
33
+ if (word.length <= width) {
34
+ current = word;
35
+ } else {
36
+ for (let i = 0; i < word.length; i += width) {
37
+ lines.push(word.slice(i, i + width));
38
+ }
39
+ }
40
+ continue;
41
+ }
42
+
43
+ const candidate = `${current} ${word}`;
44
+ if (candidate.length <= width) {
45
+ current = candidate;
46
+ continue;
47
+ }
48
+
49
+ lines.push(current);
50
+ if (word.length <= width) {
51
+ current = word;
52
+ } else {
53
+ current = "";
54
+ for (let i = 0; i < word.length; i += width) {
55
+ const chunk = word.slice(i, i + width);
56
+ if (chunk.length === width || i + width < word.length) lines.push(chunk);
57
+ else current = chunk;
58
+ }
59
+ }
60
+ }
61
+
62
+ if (current) lines.push(current);
63
+ return lines;
64
+ }
65
+
66
+ function padLine(prefix: string, content: string): string {
67
+ return `${prefix}${content}`.trimEnd();
68
+ }
69
+
70
+ interface ItemBlock {
71
+ itemIndex: number;
72
+ lines: string[];
73
+ }
74
+
75
+ type ListItem =
76
+ | { type: "option"; option: QuestionOption }
77
+ | { type: "comment-toggle"; option: QuestionOption }
78
+ | { type: "freeform"; option: QuestionOption };
79
+
80
+ function buildItemBlocks(
81
+ options: QuestionOption[],
82
+ width: number,
83
+ allowFreeform: boolean,
84
+ allowComment: boolean,
85
+ commentEnabled: boolean,
86
+ selectedIndex: number,
87
+ hideDescriptions = false,
88
+ ): ItemBlock[] {
89
+ const normalizedWidth = Math.max(12, width);
90
+ const freeformLabel = "Type something. — Enter a custom response";
91
+ const commentToggleLabel = `${commentEnabled ? "[✓]" : "[ ]"} Add extra context after selection`;
92
+ const allItems: ListItem[] = options.map((option) => ({ type: "option", option }));
93
+ if (allowComment) {
94
+ allItems.push({ type: "comment-toggle", option: { title: commentToggleLabel } });
95
+ }
96
+ if (allowFreeform) {
97
+ allItems.push({ type: "freeform", option: { title: freeformLabel } });
98
+ }
99
+
100
+ return allItems.map((item, itemIndex) => {
101
+ const pointer = itemIndex === selectedIndex ? "→" : " ";
102
+ const lines: string[] = [];
103
+
104
+ if (item.type === "comment-toggle" || item.type === "freeform") {
105
+ const prefix = `${pointer} `;
106
+ const wrapped = wrapText(item.option.title, Math.max(8, normalizedWidth - prefix.length));
107
+ wrapped.forEach((line, lineIndex) => {
108
+ lines.push(padLine(lineIndex === 0 ? prefix : " ".repeat(prefix.length), line));
109
+ });
110
+ return { itemIndex, lines };
111
+ }
112
+
113
+ const numberPrefix = `${pointer} ${itemIndex + 1}. `;
114
+ const continuationPrefix = " ".repeat(numberPrefix.length);
115
+ const titleLines = wrapText(item.option.title, Math.max(8, normalizedWidth - numberPrefix.length));
116
+ titleLines.forEach((line, lineIndex) => {
117
+ lines.push(padLine(lineIndex === 0 ? numberPrefix : continuationPrefix, line));
118
+ });
119
+
120
+ if (item.option.description && !hideDescriptions) {
121
+ const descriptionPrefix = " ";
122
+ const descriptionLines = wrapText(item.option.description, Math.max(8, normalizedWidth - descriptionPrefix.length));
123
+ descriptionLines.forEach((line) => {
124
+ lines.push(padLine(descriptionPrefix, line));
125
+ });
126
+ }
127
+
128
+ return { itemIndex, lines };
129
+ });
130
+ }
131
+
132
+ function flatten(blocks: ItemBlock[], selectedIndex: number): AnnotatedRow[] {
133
+ return blocks.flatMap((block) =>
134
+ block.lines.map((line) => ({
135
+ line,
136
+ selected: block.itemIndex === selectedIndex,
137
+ })),
138
+ );
139
+ }
140
+
141
+ export function renderSingleSelectRows({
142
+ options,
143
+ selectedIndex,
144
+ width,
145
+ allowFreeform,
146
+ allowComment = false,
147
+ commentEnabled = false,
148
+ maxRows,
149
+ hideDescriptions,
150
+ }: RenderSingleSelectRowsParams): AnnotatedRow[] {
151
+ const itemCount = options.length + (allowComment ? 1 : 0) + (allowFreeform ? 1 : 0);
152
+ const blocks = buildItemBlocks(
153
+ options,
154
+ width,
155
+ allowFreeform,
156
+ allowComment,
157
+ commentEnabled,
158
+ selectedIndex,
159
+ hideDescriptions,
160
+ );
161
+ const allRows = flatten(blocks, selectedIndex);
162
+
163
+ if (!Number.isFinite(maxRows) || !maxRows || maxRows <= 0 || allRows.length <= maxRows) {
164
+ return allRows;
165
+ }
166
+
167
+ const safeMaxRows = Math.max(1, Math.floor(maxRows));
168
+ const selectedBlock = blocks[selectedIndex] ?? blocks[0];
169
+ if (!selectedBlock) return [];
170
+
171
+ const indicator = ` (${selectedIndex + 1}/${itemCount})`;
172
+ const availableRows = safeMaxRows > 1 ? safeMaxRows - 1 : 1;
173
+
174
+ if (selectedBlock.lines.length >= availableRows) {
175
+ const visible = selectedBlock.lines.slice(0, availableRows).map((line) => ({
176
+ line,
177
+ selected: true,
178
+ }));
179
+ if (safeMaxRows > 1) visible.push({ line: indicator, selected: false });
180
+ return visible.slice(0, safeMaxRows);
181
+ }
182
+
183
+ let start = selectedIndex;
184
+ let end = selectedIndex + 1;
185
+ let usedRows = selectedBlock.lines.length;
186
+
187
+ while (true) {
188
+ const nextCanFit = end < blocks.length && usedRows + blocks[end]?.lines.length <= availableRows;
189
+ if (nextCanFit) {
190
+ usedRows += blocks[end]?.lines.length;
191
+ end += 1;
192
+ continue;
193
+ }
194
+
195
+ const prevCanFit = start > 0 && usedRows + blocks[start - 1]?.lines.length <= availableRows;
196
+ if (prevCanFit) {
197
+ start -= 1;
198
+ usedRows += blocks[start]?.lines.length;
199
+ continue;
200
+ }
201
+
202
+ break;
203
+ }
204
+
205
+ const visible = flatten(blocks.slice(start, end), selectedIndex);
206
+ visible.push({ line: indicator, selected: false });
207
+ return visible.slice(0, safeMaxRows);
208
+ }