@yagni-app/code-staging 0.3.5-staging.1160.1 → 0.3.5-staging.1165.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/dist/extension/askUserQuestionTool.d.ts +54 -0
- package/dist/extension/askUserQuestionTool.js +621 -0
- package/dist/extension/diagnostics.d.ts +28 -0
- package/dist/extension/diagnostics.js +36 -0
- package/dist/extension/index.d.ts +1 -0
- package/dist/extension/index.js +15 -0
- package/package.json +2 -2
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ask_user_question — structured human questions.
|
|
3
|
+
*
|
|
4
|
+
* Adapts Claude Code's `AskUserQuestion` tool into yagni-code: when the model
|
|
5
|
+
* genuinely needs a human (an architectural fork, a product-intent call with
|
|
6
|
+
* no recorded answer), it poses a closed question — 2-4 mutually exclusive
|
|
7
|
+
* options, each with a description explaining the tradeoff and an optional
|
|
8
|
+
* preview — and gets back a clean machine-readable answer instead of parsing
|
|
9
|
+
* free text.
|
|
10
|
+
*
|
|
11
|
+
* Rendering is via `ctx.ui.custom` (not the bare `ctx.ui.select` used for the
|
|
12
|
+
* permission "don't ask again" flow) so each option can show its description
|
|
13
|
+
* and, when any option carries a preview, a two-column layout surfaces the
|
|
14
|
+
* focused option's preview on the right. "Other" is a trailing editable row
|
|
15
|
+
* (always last, no label) whose text is the answer on Enter — no mode switch.
|
|
16
|
+
*
|
|
17
|
+
* No backend changes; no new dependencies. Only pi's TUI primitives. When
|
|
18
|
+
* there is no interactive UI (print / headless), execute() returns a short
|
|
19
|
+
* message so the tool never hangs waiting for a keyboard.
|
|
20
|
+
*/
|
|
21
|
+
import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
22
|
+
import { Type } from "typebox";
|
|
23
|
+
declare const parameters: Type.TObject<{
|
|
24
|
+
questions: Type.TArray<Type.TObject<{
|
|
25
|
+
question: Type.TString;
|
|
26
|
+
header: Type.TString;
|
|
27
|
+
options: Type.TArray<Type.TObject<{
|
|
28
|
+
label: Type.TString;
|
|
29
|
+
description: Type.TString;
|
|
30
|
+
preview: Type.TOptional<Type.TString>;
|
|
31
|
+
}>>;
|
|
32
|
+
multiSelect: Type.TOptional<Type.TBoolean>;
|
|
33
|
+
}>>;
|
|
34
|
+
}>;
|
|
35
|
+
type QuestionOption = {
|
|
36
|
+
label: string;
|
|
37
|
+
description: string;
|
|
38
|
+
preview?: string;
|
|
39
|
+
};
|
|
40
|
+
type Question = {
|
|
41
|
+
question: string;
|
|
42
|
+
header: string;
|
|
43
|
+
options: QuestionOption[];
|
|
44
|
+
multiSelect?: boolean;
|
|
45
|
+
};
|
|
46
|
+
/** The structured answer bag stored on the tool result. */
|
|
47
|
+
type AskDetails = {
|
|
48
|
+
questions: Question[];
|
|
49
|
+
answers: Record<string, string>;
|
|
50
|
+
};
|
|
51
|
+
/** Build the ask_user_question tool. Register it unconditionally (like ask_advisor). */
|
|
52
|
+
export declare function makeAskUserQuestionTool(): ToolDefinition<typeof parameters, AskDetails>;
|
|
53
|
+
export {};
|
|
54
|
+
//# sourceMappingURL=askUserQuestionTool.d.ts.map
|
|
@@ -0,0 +1,621 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ask_user_question — structured human questions.
|
|
3
|
+
*
|
|
4
|
+
* Adapts Claude Code's `AskUserQuestion` tool into yagni-code: when the model
|
|
5
|
+
* genuinely needs a human (an architectural fork, a product-intent call with
|
|
6
|
+
* no recorded answer), it poses a closed question — 2-4 mutually exclusive
|
|
7
|
+
* options, each with a description explaining the tradeoff and an optional
|
|
8
|
+
* preview — and gets back a clean machine-readable answer instead of parsing
|
|
9
|
+
* free text.
|
|
10
|
+
*
|
|
11
|
+
* Rendering is via `ctx.ui.custom` (not the bare `ctx.ui.select` used for the
|
|
12
|
+
* permission "don't ask again" flow) so each option can show its description
|
|
13
|
+
* and, when any option carries a preview, a two-column layout surfaces the
|
|
14
|
+
* focused option's preview on the right. "Other" is a trailing editable row
|
|
15
|
+
* (always last, no label) whose text is the answer on Enter — no mode switch.
|
|
16
|
+
*
|
|
17
|
+
* No backend changes; no new dependencies. Only pi's TUI primitives. When
|
|
18
|
+
* there is no interactive UI (print / headless), execute() returns a short
|
|
19
|
+
* message so the tool never hangs waiting for a keyboard.
|
|
20
|
+
*/
|
|
21
|
+
import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
22
|
+
import { Type } from "typebox";
|
|
23
|
+
import { logAskQuestion } from "./diagnostics.js";
|
|
24
|
+
const TOOL_NAME = "ask_user_question";
|
|
25
|
+
/** Claude mirrors this as ASK_USER_QUESTION_TOOL_CHIP_WIDTH. */
|
|
26
|
+
const CHIP_WIDTH = 12;
|
|
27
|
+
/** Same fail-closed ceiling as the permission-gate ask. */
|
|
28
|
+
const ASK_TIMEOUT_MS = 120_000;
|
|
29
|
+
/** Sentinel value representing the "Other" row in the multi-select toggle set. */
|
|
30
|
+
const OTHER_KEY = "__other__";
|
|
31
|
+
/** Label on the multi-select submit row (mirrors Claude's Submit button). */
|
|
32
|
+
const SUBMIT_LABEL = "Submit";
|
|
33
|
+
const optionParameters = Type.Object({
|
|
34
|
+
label: Type.String({
|
|
35
|
+
description: "The display text for this option that the user sees and selects. Concise (1-5 words) and clearly describes the choice.",
|
|
36
|
+
}),
|
|
37
|
+
description: Type.String({
|
|
38
|
+
description: "Explanation of what this option means or what happens if chosen. Provides context about the trade-off.",
|
|
39
|
+
}),
|
|
40
|
+
preview: Type.Optional(Type.String({
|
|
41
|
+
description: "Optional concrete content rendered when this option is focused — a mockup, code snippet, diagram, or config example the user needs to compare. Only used for single-select questions.",
|
|
42
|
+
})),
|
|
43
|
+
});
|
|
44
|
+
const questionParameters = Type.Object({
|
|
45
|
+
question: Type.String({
|
|
46
|
+
description: 'The complete question. Clear, specific, and end with a question mark. Example: "Which library should we use for date formatting?" If multiSelect is true, phrase it accordingly.',
|
|
47
|
+
}),
|
|
48
|
+
header: Type.String({
|
|
49
|
+
maxLength: CHIP_WIDTH,
|
|
50
|
+
description: `Very short label shown as a chip/tag (max ${CHIP_WIDTH} chars). Examples: "Auth method", "Approach".`,
|
|
51
|
+
}),
|
|
52
|
+
options: Type.Array(optionParameters, { minItems: 2, maxItems: 4 }),
|
|
53
|
+
multiSelect: Type.Optional(Type.Boolean({
|
|
54
|
+
default: false,
|
|
55
|
+
description: "Set to true to allow the user to select multiple options instead of just one. Use when choices are not mutually exclusive.",
|
|
56
|
+
})),
|
|
57
|
+
});
|
|
58
|
+
const parameters = Type.Object({
|
|
59
|
+
questions: Type.Array(questionParameters, { minItems: 1, maxItems: 4 }),
|
|
60
|
+
});
|
|
61
|
+
const PROMPT_SNIPPET = "ask_user_question: pose a structured multiple-choice question (2-4 options with tradeoffs) to the user and get back a clean machine-readable answer.";
|
|
62
|
+
const PROMPT_GUIDELINES = [
|
|
63
|
+
"Use ask_user_question when you genuinely need a human decision during execution — an architectural fork, a product-intent call, or a choice between approaches — and the answer is not already recorded (check ask_yagni first).",
|
|
64
|
+
"Enumerate 2-4 distinct, mutually exclusive options, each with a description explaining its tradeoff. Do not ask a vague open question.",
|
|
65
|
+
"Never add an 'Other' option yourself — the UI always offers a trailing free-text row automatically.",
|
|
66
|
+
"If you recommend a specific option, make it the first one and append '(Recommended)' to its label.",
|
|
67
|
+
"Do not use this tool for feedback like 'does the plan look good?' — ask a real choice with real options.",
|
|
68
|
+
];
|
|
69
|
+
/**
|
|
70
|
+
* Whether any option in the question has a preview. Drives the two-column
|
|
71
|
+
* layout (Claude switches to side-by-side only when previews exist).
|
|
72
|
+
*/
|
|
73
|
+
function hasPreview(q) {
|
|
74
|
+
return q.options.some((o) => o.preview !== undefined);
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Clamp a rendered line to the terminal width. pi hard-crashes on lines wider
|
|
78
|
+
* than the terminal; every line we emit must be within bounds.
|
|
79
|
+
*/
|
|
80
|
+
function clampLine(line, width) {
|
|
81
|
+
const safeWidth = Math.max(0, width);
|
|
82
|
+
return visibleWidth(line) > safeWidth ? truncateToWidth(line, safeWidth, "") : line;
|
|
83
|
+
}
|
|
84
|
+
/** A harmless Component returned when the dialog has already settled. */
|
|
85
|
+
const NOOP_COMPONENT = {
|
|
86
|
+
invalidate() { },
|
|
87
|
+
render() {
|
|
88
|
+
return [];
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
/** Strip raw control/escape sequences so pasted content can't corrupt the TUI. */
|
|
92
|
+
function sanitize(text) {
|
|
93
|
+
// Keep only printable chars, newline, and tab-ish; drop C0/C1 controls and
|
|
94
|
+
// ANSI/OSC escape sequences.
|
|
95
|
+
return text
|
|
96
|
+
.replace(/(?:\[[0-?]*[ -\/]*[@-~]|\][^\u0007]*(?:\u0007|\u001b\\))|[@-_]/g, "")
|
|
97
|
+
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "");
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Interactive selector for ONE question, rendered via `ctx.ui.custom`.
|
|
101
|
+
*
|
|
102
|
+
* Follows Claude Code's layout exactly:
|
|
103
|
+
* - No previews → one-column with numbered options (1., 2., ...) and description
|
|
104
|
+
* indented under the label text. The "Other" row is an inline input field.
|
|
105
|
+
* - With previews → two-column: options left, focused option's preview right.
|
|
106
|
+
* No "Other" row (Claude omits it; we keep it for parity with the tool spec).
|
|
107
|
+
*/
|
|
108
|
+
class QuestionComponent {
|
|
109
|
+
q;
|
|
110
|
+
qIndex;
|
|
111
|
+
theme;
|
|
112
|
+
keybindings;
|
|
113
|
+
onFinish;
|
|
114
|
+
selectedIndex = 0;
|
|
115
|
+
toggled = new Set();
|
|
116
|
+
closed = false;
|
|
117
|
+
otherValue = "";
|
|
118
|
+
constructor(q, qIndex, theme, keybindings, onFinish) {
|
|
119
|
+
this.q = q;
|
|
120
|
+
this.qIndex = qIndex;
|
|
121
|
+
this.theme = theme;
|
|
122
|
+
this.keybindings = keybindings;
|
|
123
|
+
this.onFinish = onFinish;
|
|
124
|
+
}
|
|
125
|
+
/** Single exit: mark closed so no post-resolution keystroke is processed, then resolve. */
|
|
126
|
+
finish(r) {
|
|
127
|
+
this.closed = true;
|
|
128
|
+
this.onFinish(r);
|
|
129
|
+
}
|
|
130
|
+
get multi() {
|
|
131
|
+
return this.q.multiSelect === true;
|
|
132
|
+
}
|
|
133
|
+
/** Index of the trailing "Other" editable row. */
|
|
134
|
+
get otherIndex() {
|
|
135
|
+
return this.q.options.length;
|
|
136
|
+
}
|
|
137
|
+
/** Index of the multi-select Submit row (after "Other"). */
|
|
138
|
+
get submitIndex() {
|
|
139
|
+
return this.q.options.length + 1;
|
|
140
|
+
}
|
|
141
|
+
/** Largest navigable cursor index. */
|
|
142
|
+
get maxIndex() {
|
|
143
|
+
return this.multi ? this.submitIndex : this.otherIndex;
|
|
144
|
+
}
|
|
145
|
+
focusedOption() {
|
|
146
|
+
return this.q.options[this.selectedIndex];
|
|
147
|
+
}
|
|
148
|
+
/** Whether the "Other" row counts as selected (multi-select auto-check rule). */
|
|
149
|
+
otherSelected() {
|
|
150
|
+
return this.multi && this.toggled.has(OTHER_KEY);
|
|
151
|
+
}
|
|
152
|
+
/** Add/remove a value from the multi-select toggle set. */
|
|
153
|
+
toggleValue(v) {
|
|
154
|
+
if (this.toggled.has(v))
|
|
155
|
+
this.toggled.delete(v);
|
|
156
|
+
else
|
|
157
|
+
this.toggled.add(v);
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Multi-select rule mirroring Claude's `updateInputValue`: typing non-empty
|
|
161
|
+
* text auto-checks "Other"; emptying it back out un-checks it. Call on every
|
|
162
|
+
* edit to `otherValue`.
|
|
163
|
+
*/
|
|
164
|
+
syncOtherToggle() {
|
|
165
|
+
if (this.otherValue.trim() !== "")
|
|
166
|
+
this.toggled.add(OTHER_KEY);
|
|
167
|
+
else
|
|
168
|
+
this.toggled.delete(OTHER_KEY);
|
|
169
|
+
logAskQuestion({ event: "other_toggle", checked: this.toggled.has(OTHER_KEY), otherLen: this.otherValue.length, qIndex: this.qIndex });
|
|
170
|
+
}
|
|
171
|
+
invalidate() {
|
|
172
|
+
// No cached render state; a full render is cheap.
|
|
173
|
+
}
|
|
174
|
+
render(width) {
|
|
175
|
+
const t = this.theme;
|
|
176
|
+
const safeWidth = Math.max(0, width);
|
|
177
|
+
const lines = [];
|
|
178
|
+
// Top border.
|
|
179
|
+
lines.push(t.fg("borderMuted", "─".repeat(safeWidth)));
|
|
180
|
+
// Header chip (inverse video, mirroring Claude's current-tab in the nav bar).
|
|
181
|
+
if (this.q.header) {
|
|
182
|
+
const chip = ` ${this.q.header} `;
|
|
183
|
+
lines.push(clampLine(t.bg("selectedBg", t.fg("accent", chip)), safeWidth));
|
|
184
|
+
lines.push("");
|
|
185
|
+
}
|
|
186
|
+
const heading = t.bold(this.q.question);
|
|
187
|
+
lines.push(...wrapTextWithAnsi(heading, Math.max(1, safeWidth - 2)).map((l) => clampLine(l, safeWidth)));
|
|
188
|
+
lines.push("");
|
|
189
|
+
if (hasPreview(this.q) && !this.multi) {
|
|
190
|
+
this.renderTwoColumn(lines, width, t);
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
this.renderOneColumn(lines, width, t);
|
|
194
|
+
}
|
|
195
|
+
// Help line (mirrors Claude's footer guidance; no "Chat about this" row).
|
|
196
|
+
lines.push("");
|
|
197
|
+
const help = this.multi
|
|
198
|
+
? "Enter to toggle · ↑/↓ to navigate · Enter on Submit to commit · Esc to cancel"
|
|
199
|
+
: "Enter to select · ↑/↓ to navigate · Esc to cancel";
|
|
200
|
+
lines.push(clampLine(t.fg("dim", ` ${help}`), safeWidth));
|
|
201
|
+
// Bottom border.
|
|
202
|
+
lines.push(t.fg("borderMuted", "─".repeat(safeWidth)));
|
|
203
|
+
return lines;
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* One-column layout (no previews). Matches Claude Code's `compact-vertical`:
|
|
207
|
+
* each option is `index. label`, description on the next line indented under
|
|
208
|
+
* the label text (not under the number).
|
|
209
|
+
*/
|
|
210
|
+
renderOneColumn(lines, width, t) {
|
|
211
|
+
const maxIndexWidth = (this.q.options.length + 1).toString().length;
|
|
212
|
+
const indexPad = maxIndexWidth + 2; // Claude uses padEnd(maxIndexWidth + 2)
|
|
213
|
+
const descIndent = " ".repeat(indexPad + 2); // align under label text
|
|
214
|
+
this.q.options.forEach((opt, i) => {
|
|
215
|
+
const isSelected = i === this.selectedIndex;
|
|
216
|
+
const marker = this.multi
|
|
217
|
+
? this.toggled.has(opt.label)
|
|
218
|
+
? t.fg("accent", "[x]")
|
|
219
|
+
: "[ ]"
|
|
220
|
+
: isSelected
|
|
221
|
+
? t.fg("accent", "❯")
|
|
222
|
+
: " ";
|
|
223
|
+
const indexText = `${i + 1}.`.padEnd(indexPad);
|
|
224
|
+
const label = isSelected ? t.fg("accent", opt.label) : opt.label;
|
|
225
|
+
lines.push(clampLine(`${marker} ${indexText}${label}`, width));
|
|
226
|
+
if (opt.description) {
|
|
227
|
+
lines.push(clampLine(t.fg("muted", descIndent + opt.description), width));
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
lines.push("");
|
|
231
|
+
this.renderOtherRow(lines, width, t, indexPad);
|
|
232
|
+
// Multi-select Submit row (after "Other"), mirroring Claude's button.
|
|
233
|
+
if (this.multi) {
|
|
234
|
+
lines.push("");
|
|
235
|
+
this.renderSubmitRow(lines, width, t);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
/** The multi-select Submit row: selected → commits; Escape row is omitted. */
|
|
239
|
+
renderSubmitRow(lines, width, t) {
|
|
240
|
+
const isSelected = this.selectedIndex === this.submitIndex;
|
|
241
|
+
const marker = isSelected ? t.fg("accent", "❯") : " ";
|
|
242
|
+
const label = isSelected ? t.fg("accent", t.bold(SUBMIT_LABEL)) : t.bold(SUBMIT_LABEL);
|
|
243
|
+
lines.push(clampLine(` ${marker} ${label}`, width));
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Two-column layout (with previews). Matches Claude Code's `PreviewQuestionView`:
|
|
247
|
+
* left panel is a vertical numbered list, right panel shows the focused
|
|
248
|
+
* option's preview box + description.
|
|
249
|
+
*/
|
|
250
|
+
renderTwoColumn(lines, width, t) {
|
|
251
|
+
const LEFT_PANEL_WIDTH = 30;
|
|
252
|
+
const GAP = 4;
|
|
253
|
+
const rightWidth = Math.max(20, width - LEFT_PANEL_WIDTH - GAP);
|
|
254
|
+
const maxIndexWidth = (this.q.options.length + 1).toString().length;
|
|
255
|
+
const indexPad = maxIndexWidth + 2;
|
|
256
|
+
// Build left panel lines: options list.
|
|
257
|
+
const leftLines = [];
|
|
258
|
+
this.q.options.forEach((opt, i) => {
|
|
259
|
+
const isSelected = i === this.selectedIndex;
|
|
260
|
+
const marker = isSelected ? t.fg("accent", "❯") : " ";
|
|
261
|
+
const indexText = `${i + 1}.`.padEnd(indexPad);
|
|
262
|
+
const label = isSelected ? t.fg("accent", opt.label) : opt.label;
|
|
263
|
+
leftLines.push(clampLine(`${marker} ${indexText}${label}`, LEFT_PANEL_WIDTH));
|
|
264
|
+
});
|
|
265
|
+
// Build right panel lines: preview box + description for focused option.
|
|
266
|
+
const rightLines = [];
|
|
267
|
+
const focused = this.focusedOption();
|
|
268
|
+
if (focused?.preview) {
|
|
269
|
+
rightLines.push(t.fg("mdCodeBlockBorder", "┌" + "─".repeat(rightWidth) + "┐"));
|
|
270
|
+
for (const raw of focused.preview.split("\n")) {
|
|
271
|
+
for (const wrapped of wrapTextWithAnsi(raw, rightWidth - 2)) {
|
|
272
|
+
rightLines.push(t.fg("mdCodeBlock", "│ " + wrapped + " ".repeat(Math.max(0, rightWidth - 2 - visibleWidth(wrapped))) + " │"));
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
rightLines.push(t.fg("mdCodeBlockBorder", "└" + "─".repeat(rightWidth) + "┘"));
|
|
276
|
+
}
|
|
277
|
+
if (focused?.description) {
|
|
278
|
+
if (rightLines.length)
|
|
279
|
+
rightLines.push("");
|
|
280
|
+
rightLines.push(...wrapTextWithAnsi(t.fg("muted", focused.description), rightWidth));
|
|
281
|
+
}
|
|
282
|
+
// Merge left + right into side-by-side lines.
|
|
283
|
+
const maxRows = Math.max(leftLines.length, rightLines.length);
|
|
284
|
+
for (let r = 0; r < maxRows; r++) {
|
|
285
|
+
const left = leftLines[r] ?? "";
|
|
286
|
+
const right = rightLines[r] ?? "";
|
|
287
|
+
const leftPadded = left + " ".repeat(Math.max(0, LEFT_PANEL_WIDTH - visibleWidth(left)));
|
|
288
|
+
lines.push(clampLine(leftPadded + " ".repeat(GAP) + right, width));
|
|
289
|
+
}
|
|
290
|
+
// The "Other" row spans the full width (below the two-column area).
|
|
291
|
+
lines.push("");
|
|
292
|
+
this.renderOtherRow(lines, width, t, indexPad);
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* The trailing "Other" editable row. Always last, full width.
|
|
296
|
+
* Claude renders this as a proper input field inline in the list.
|
|
297
|
+
*/
|
|
298
|
+
renderOtherRow(lines, width, t, indexPad) {
|
|
299
|
+
const isSelected = this.selectedIndex === this.otherIndex;
|
|
300
|
+
// Multi-select: a checkbox `[ ]`/`[x]`; single-select: a `❯` focus marker.
|
|
301
|
+
const isChecked = this.multi ? this.otherSelected() : isSelected;
|
|
302
|
+
const marker = this.multi
|
|
303
|
+
? isChecked
|
|
304
|
+
? t.fg("accent", "[x]")
|
|
305
|
+
: "[ ]"
|
|
306
|
+
: isSelected
|
|
307
|
+
? t.fg("accent", "❯")
|
|
308
|
+
: " ";
|
|
309
|
+
const indexText = `${this.otherIndex + 1}.`.padEnd(indexPad);
|
|
310
|
+
if (isSelected) {
|
|
311
|
+
// Inline input: show current text + inverse-video cursor at end.
|
|
312
|
+
const cursor = "\x1b[7m \x1b[27m"; // space with reverse video = visible cursor
|
|
313
|
+
const text = this.otherValue || " ";
|
|
314
|
+
lines.push(clampLine(`${marker} ${indexText}${text}${cursor}`, width));
|
|
315
|
+
}
|
|
316
|
+
else {
|
|
317
|
+
// Not focused: show placeholder or current text in dim.
|
|
318
|
+
const display = this.otherValue || "(type a custom answer)";
|
|
319
|
+
lines.push(clampLine(`${marker} ${indexText}${t.fg("dim", display)}`, width));
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
handleInput(data) {
|
|
323
|
+
if (this.closed)
|
|
324
|
+
return;
|
|
325
|
+
if (typeof data !== "string") {
|
|
326
|
+
logAskQuestion({ event: "key", detail: "non-string input dropped", qIndex: this.qIndex });
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
const kb = this.keybindings;
|
|
330
|
+
const onOther = this.selectedIndex === this.otherIndex;
|
|
331
|
+
logAskQuestion({
|
|
332
|
+
event: "key",
|
|
333
|
+
key: JSON.stringify(data),
|
|
334
|
+
selectedIndex: this.selectedIndex,
|
|
335
|
+
otherIndex: this.otherIndex,
|
|
336
|
+
otherLen: this.otherValue.length,
|
|
337
|
+
qIndex: this.qIndex,
|
|
338
|
+
detail: onOther ? "key routed to other input" : "key routed to list nav",
|
|
339
|
+
});
|
|
340
|
+
if (onOther) {
|
|
341
|
+
this.handleOtherInput(data, kb);
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
if (kb.matches(data, "tui.select.up")) {
|
|
345
|
+
this.selectedIndex = this.selectedIndex === 0 ? this.maxIndex : this.selectedIndex - 1;
|
|
346
|
+
logAskQuestion({ event: "navigate", detail: "up", selectedIndex: this.selectedIndex, qIndex: this.qIndex });
|
|
347
|
+
}
|
|
348
|
+
else if (kb.matches(data, "tui.select.down")) {
|
|
349
|
+
this.selectedIndex = this.selectedIndex >= this.maxIndex ? 0 : this.selectedIndex + 1;
|
|
350
|
+
logAskQuestion({ event: "navigate", detail: "down", selectedIndex: this.selectedIndex, qIndex: this.qIndex });
|
|
351
|
+
}
|
|
352
|
+
else if (kb.matches(data, "tui.select.confirm") || data === "\n") {
|
|
353
|
+
logAskQuestion({ event: "navigate", detail: "enter -> confirmCurrent", selectedIndex: this.selectedIndex, multi: this.multi, qIndex: this.qIndex });
|
|
354
|
+
this.confirmCurrent();
|
|
355
|
+
}
|
|
356
|
+
else if (data === " " && this.multi) {
|
|
357
|
+
// Space toggles a labeled option only (matches Claude; on "Other" space
|
|
358
|
+
// is routed to the input to type a space).
|
|
359
|
+
if (this.selectedIndex < this.q.options.length) {
|
|
360
|
+
this.toggleCurrent();
|
|
361
|
+
logAskQuestion({ event: "navigate", detail: "space toggled option", selectedIndex: this.selectedIndex, multi: true, qIndex: this.qIndex });
|
|
362
|
+
}
|
|
363
|
+
else {
|
|
364
|
+
logAskQuestion({ event: "navigate", detail: "space on submit/other ignored", selectedIndex: this.selectedIndex, qIndex: this.qIndex });
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
else if (kb.matches(data, "tui.select.cancel")) {
|
|
368
|
+
logAskQuestion({ event: "navigate", detail: "escape -> finish cancelled", qIndex: this.qIndex });
|
|
369
|
+
this.finish({ status: "cancelled" });
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
/** Input handling while the "Other" row is focused. */
|
|
373
|
+
handleOtherInput(data, kb) {
|
|
374
|
+
// Backspace deletes the last char (and re-syncs the auto-check).
|
|
375
|
+
if (kb.matches(data, "tui.editor.deleteCharBackward") || data === "\x7f" || data === "\b") {
|
|
376
|
+
this.otherValue = this.otherValue.slice(0, -1);
|
|
377
|
+
if (this.multi)
|
|
378
|
+
this.syncOtherToggle();
|
|
379
|
+
logAskQuestion({ event: "other_key", detail: "backspace", otherLen: this.otherValue.length, qIndex: this.qIndex });
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
// Escape cancels the whole tool (same as anywhere else in the list).
|
|
383
|
+
if (kb.matches(data, "tui.select.cancel")) {
|
|
384
|
+
logAskQuestion({ event: "other_key", detail: "escape -> finish cancelled", qIndex: this.qIndex });
|
|
385
|
+
this.finish({ status: "cancelled" });
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
// Up arrow moves focus back to the options list (same as anywhere else).
|
|
389
|
+
if (kb.matches(data, "tui.select.up")) {
|
|
390
|
+
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
|
|
391
|
+
logAskQuestion({ event: "other_key", detail: "up arrow -> moved to options", selectedIndex: this.selectedIndex, qIndex: this.qIndex });
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
// Down arrow: single-select no-op (Other is last); multi-select moves to Submit.
|
|
395
|
+
if (kb.matches(data, "tui.select.down")) {
|
|
396
|
+
if (this.multi) {
|
|
397
|
+
this.selectedIndex = this.submitIndex;
|
|
398
|
+
logAskQuestion({ event: "other_key", detail: "down arrow -> submit row", selectedIndex: this.selectedIndex, qIndex: this.qIndex });
|
|
399
|
+
}
|
|
400
|
+
else {
|
|
401
|
+
logAskQuestion({ event: "other_key", detail: "down arrow -> noop", qIndex: this.qIndex });
|
|
402
|
+
}
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
// Enter: single-select submits the text; multi-select toggles the "Other"
|
|
406
|
+
// checked state (submit only happens via the Submit row).
|
|
407
|
+
if (kb.matches(data, "tui.select.confirm") || data === "\n") {
|
|
408
|
+
if (this.multi) {
|
|
409
|
+
this.toggleValue(OTHER_KEY);
|
|
410
|
+
logAskQuestion({ event: "other_key", detail: "enter -> toggle other", checked: this.toggled.has(OTHER_KEY), otherLen: this.otherValue.length, qIndex: this.qIndex });
|
|
411
|
+
}
|
|
412
|
+
else {
|
|
413
|
+
logAskQuestion({ event: "other_key", detail: "enter -> submit other", otherLen: this.otherValue.length, qIndex: this.qIndex });
|
|
414
|
+
this.finish({ status: "other", text: this.otherValue.trim() });
|
|
415
|
+
}
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
// Any printable run (single codepoint or multi-byte pasted text): append
|
|
419
|
+
// after stripping controls. In multi-select, typing auto-checks "Other".
|
|
420
|
+
if (data.length > 0 && !data.startsWith("\x1b")) {
|
|
421
|
+
const cleaned = sanitize(data);
|
|
422
|
+
if (cleaned.length > 0) {
|
|
423
|
+
this.otherValue += cleaned;
|
|
424
|
+
if (this.multi)
|
|
425
|
+
this.syncOtherToggle();
|
|
426
|
+
logAskQuestion({ event: "other_key", detail: "printable appended", otherLen: this.otherValue.length, qIndex: this.qIndex });
|
|
427
|
+
}
|
|
428
|
+
else {
|
|
429
|
+
logAskQuestion({ event: "other_key", detail: "input sanitized to empty", key: JSON.stringify(data), qIndex: this.qIndex });
|
|
430
|
+
}
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
logAskQuestion({ event: "other_key", detail: "unhandled key", key: JSON.stringify(data), qIndex: this.qIndex });
|
|
434
|
+
}
|
|
435
|
+
/** Toggle the option under the cursor (multi-select). Submit/Other handled separately. */
|
|
436
|
+
toggleCurrent() {
|
|
437
|
+
if (this.selectedIndex < this.q.options.length) {
|
|
438
|
+
this.toggleValue(this.q.options[this.selectedIndex].label);
|
|
439
|
+
}
|
|
440
|
+
else if (this.selectedIndex === this.otherIndex) {
|
|
441
|
+
this.toggleValue(OTHER_KEY);
|
|
442
|
+
}
|
|
443
|
+
// submitIndex → no-op (handled in confirmCurrent).
|
|
444
|
+
}
|
|
445
|
+
confirmCurrent() {
|
|
446
|
+
if (this.multi) {
|
|
447
|
+
// Submit row commits the selection.
|
|
448
|
+
if (this.selectedIndex === this.submitIndex) {
|
|
449
|
+
this.finishMulti();
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
// "Other" → toggle its checked state (submit only via the Submit row).
|
|
453
|
+
if (this.selectedIndex === this.otherIndex) {
|
|
454
|
+
this.toggleValue(OTHER_KEY);
|
|
455
|
+
logAskQuestion({ event: "navigate", detail: "enter -> toggle other", checked: this.toggled.has(OTHER_KEY), qIndex: this.qIndex });
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
// Labeled option → toggle on/off, dialog stays open.
|
|
459
|
+
this.toggleCurrent();
|
|
460
|
+
logAskQuestion({ event: "navigate", detail: "enter -> toggled option", selectedIndex: this.selectedIndex, multi: true, qIndex: this.qIndex });
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
// Single-select: Enter selects and closes.
|
|
464
|
+
if (this.selectedIndex < this.q.options.length) {
|
|
465
|
+
logAskQuestion({ event: "finish", detail: "selected a labeled option", selectedIndex: this.selectedIndex, status: "selected", qIndex: this.qIndex });
|
|
466
|
+
this.finish({ status: "selected", values: [this.q.options[this.selectedIndex].label] });
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
// Unreachable (Other routes to handleOtherInput); guard defensively.
|
|
470
|
+
logAskQuestion({ event: "finish", detail: "confirmCurrent on Other (unexpected)", selectedIndex: this.selectedIndex, qIndex: this.qIndex });
|
|
471
|
+
this.handleOtherInput("\n", this.keybindings);
|
|
472
|
+
}
|
|
473
|
+
/** Commit a multiSelect: toggled labels + any checked "Other" text, comma-joined. */
|
|
474
|
+
finishMulti() {
|
|
475
|
+
const values = [];
|
|
476
|
+
for (const o of this.q.options)
|
|
477
|
+
if (this.toggled.has(o.label))
|
|
478
|
+
values.push(o.label);
|
|
479
|
+
if (this.toggled.has(OTHER_KEY) && this.otherValue.trim() !== "")
|
|
480
|
+
values.push(this.otherValue.trim());
|
|
481
|
+
logAskQuestion({ event: "finish", detail: "multiselect submitted", status: "selected", qIndex: this.qIndex, otherLen: this.otherValue.length });
|
|
482
|
+
this.finish({ status: "other", text: values.join(", ") });
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* Drive one interactive question and resolve it to a machine-readable answer.
|
|
487
|
+
*/
|
|
488
|
+
async function askOne(ctx, q, qIndex) {
|
|
489
|
+
if (!ctx.hasUI) {
|
|
490
|
+
return { answer: undefined, cancelled: false };
|
|
491
|
+
}
|
|
492
|
+
const resolution = await new Promise((resolve) => {
|
|
493
|
+
let settled = false;
|
|
494
|
+
let close;
|
|
495
|
+
const settle = (r) => {
|
|
496
|
+
if (settled)
|
|
497
|
+
return;
|
|
498
|
+
settled = true;
|
|
499
|
+
ctx.signal?.removeEventListener("abort", onAbort);
|
|
500
|
+
close?.(r); // also closes the overlay (resolves the .custom promise)
|
|
501
|
+
resolve(r);
|
|
502
|
+
};
|
|
503
|
+
const onAbort = () => settle({ status: "cancelled" });
|
|
504
|
+
if (ctx.signal?.aborted) {
|
|
505
|
+
resolve({ status: "cancelled" });
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
ctx.signal?.addEventListener("abort", onAbort, { once: true });
|
|
509
|
+
// Best-effort timeout, mirroring the permission-gate ask fail-closed.
|
|
510
|
+
const timer = setTimeout(() => settle({ status: "cancelled" }), ASK_TIMEOUT_MS);
|
|
511
|
+
void ctx.ui
|
|
512
|
+
.custom((_tui, theme, keybindings, done) => {
|
|
513
|
+
// If the abort signal or timeout already settled before this factory
|
|
514
|
+
// ran (a microtask-sized race), dismiss immediately rather than mount
|
|
515
|
+
// an overlay that nothing will ever close.
|
|
516
|
+
if (settled) {
|
|
517
|
+
done({ status: "cancelled" });
|
|
518
|
+
return NOOP_COMPONENT;
|
|
519
|
+
}
|
|
520
|
+
close = done;
|
|
521
|
+
return new QuestionComponent(q, qIndex, theme, keybindings, settle);
|
|
522
|
+
})
|
|
523
|
+
.then(() => {
|
|
524
|
+
clearTimeout(timer);
|
|
525
|
+
})
|
|
526
|
+
.catch((err) => {
|
|
527
|
+
logAskQuestion({
|
|
528
|
+
event: "error",
|
|
529
|
+
detail: err instanceof Error ? `ui.custom threw: ${err.message}` : "ui.custom rejected",
|
|
530
|
+
qIndex,
|
|
531
|
+
});
|
|
532
|
+
settle({ status: "cancelled" });
|
|
533
|
+
});
|
|
534
|
+
});
|
|
535
|
+
if (resolution.status === "cancelled") {
|
|
536
|
+
logAskQuestion({ event: "resolved", status: "cancelled", qIndex });
|
|
537
|
+
return { answer: undefined, cancelled: true };
|
|
538
|
+
}
|
|
539
|
+
if (resolution.status === "other") {
|
|
540
|
+
logAskQuestion({ event: "resolved", status: "other", otherLen: resolution.text.length, qIndex });
|
|
541
|
+
return { answer: resolution.text, cancelled: false };
|
|
542
|
+
}
|
|
543
|
+
// multiSelect: comma-joined answers (Claude's convention).
|
|
544
|
+
logAskQuestion({ event: "resolved", status: "selected", detail: `values:${resolution.values.length}`, qIndex });
|
|
545
|
+
return { answer: resolution.values.join(", "), cancelled: false };
|
|
546
|
+
}
|
|
547
|
+
function buildTool() {
|
|
548
|
+
return {
|
|
549
|
+
name: TOOL_NAME,
|
|
550
|
+
label: "Ask user a question",
|
|
551
|
+
description: "Pose a structured multiple-choice question to the user during execution and get back a clean machine-readable answer. " +
|
|
552
|
+
"Use when you genuinely need a human decision (an architectural fork, a product-intent call with no recorded answer, or a choice between approaches)." +
|
|
553
|
+
"Each question has 2-4 mutually exclusive options, each with a description of its tradeoff and an optional preview.",
|
|
554
|
+
promptSnippet: PROMPT_SNIPPET,
|
|
555
|
+
promptGuidelines: PROMPT_GUIDELINES,
|
|
556
|
+
parameters,
|
|
557
|
+
async execute(_toolCallId, params, _signal, onUpdate, ctx) {
|
|
558
|
+
onUpdate?.({ content: [{ type: "text", text: "Asking you…" }], details: { questions: [], answers: {} } });
|
|
559
|
+
const questions = params.questions;
|
|
560
|
+
// Soft uniqueness guard (Claude enforces it with a zod refine).
|
|
561
|
+
const seenQuestions = questions.map((q) => q.question);
|
|
562
|
+
if (new Set(seenQuestions).size !== seenQuestions.length) {
|
|
563
|
+
const text = "ask_user_question: question texts must be unique. Re-ask with distinct questions.";
|
|
564
|
+
return { content: [{ type: "text", text }], details: { questions, answers: {} } };
|
|
565
|
+
}
|
|
566
|
+
for (const q of questions) {
|
|
567
|
+
const labels = q.options.map((o) => o.label);
|
|
568
|
+
if (new Set(labels).size !== labels.length) {
|
|
569
|
+
const text = `ask_user_question: option labels for "${q.question}" must be unique within that question. Re-ask with distinct labels.`;
|
|
570
|
+
return { content: [{ type: "text", text }], details: { questions, answers: {} } };
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
if (!ctx.hasUI) {
|
|
574
|
+
const text = "ask_user_question: this session has no interactive UI, so the question cannot be answered. " +
|
|
575
|
+
"Proceed with a reasonable default and note that you did; if the decision is critical, surface it to the user outside the session.";
|
|
576
|
+
return { content: [{ type: "text", text }], details: { questions, answers: {} } };
|
|
577
|
+
}
|
|
578
|
+
const answers = {};
|
|
579
|
+
let declined = false;
|
|
580
|
+
try {
|
|
581
|
+
for (let i = 0; i < questions.length; i++) {
|
|
582
|
+
const res = await askOne(ctx, questions[i], i);
|
|
583
|
+
if (res.cancelled) {
|
|
584
|
+
declined = true;
|
|
585
|
+
break;
|
|
586
|
+
}
|
|
587
|
+
if (res.answer !== undefined && res.answer !== "") {
|
|
588
|
+
answers[questions[i].question] = res.answer;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
catch (err) {
|
|
593
|
+
logAskQuestion({ event: "error", detail: err instanceof Error ? `execute loop: ${err.message}` : "execute loop threw" });
|
|
594
|
+
return {
|
|
595
|
+
content: [
|
|
596
|
+
{
|
|
597
|
+
type: "text",
|
|
598
|
+
text: "ask_user_question: an unexpected error interrupted the dialog. " +
|
|
599
|
+
"Continue without the answer, state the assumption you are proceeding on, and flag it for later confirmation.",
|
|
600
|
+
},
|
|
601
|
+
],
|
|
602
|
+
details: { questions, answers },
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
if (declined) {
|
|
606
|
+
const text = "The user declined to answer the question(s). Continue without the answer, state the assumption you are proceeding on, and flag it for later confirmation.";
|
|
607
|
+
return { content: [{ type: "text", text }], details: { questions, answers } };
|
|
608
|
+
}
|
|
609
|
+
const answersText = Object.entries(answers)
|
|
610
|
+
.map(([q, a]) => `"${q}"="${a}"`)
|
|
611
|
+
.join(", ");
|
|
612
|
+
const text = `User has answered your questions: ${answersText}. You can now continue with the user's answers in mind.`;
|
|
613
|
+
return { content: [{ type: "text", text }], details: { questions, answers } };
|
|
614
|
+
},
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
/** Build the ask_user_question tool. Register it unconditionally (like ask_advisor). */
|
|
618
|
+
export function makeAskUserQuestionTool() {
|
|
619
|
+
return buildTool();
|
|
620
|
+
}
|
|
621
|
+
//# sourceMappingURL=askUserQuestionTool.js.map
|
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
*/
|
|
15
15
|
export declare function _setDiagnosticsHomeForTest(dir: string | null): void;
|
|
16
16
|
export declare function diagnosticsLogPath(): string;
|
|
17
|
+
/** Dedicated log for the ask_user_question interactive tool's state machine. */
|
|
18
|
+
export declare function askQuestionLogPath(): string;
|
|
17
19
|
/** Layer A: verbose mode. `YAGNI_DEBUG=1` (or "true") turns on extra detail. */
|
|
18
20
|
export declare function isDebug(env?: NodeJS.ProcessEnv): boolean;
|
|
19
21
|
export interface ImagePasteEvent {
|
|
@@ -34,6 +36,32 @@ export interface ImagePasteEvent {
|
|
|
34
36
|
* prompt. `detail` is included only when YAGNI_DEBUG is on.
|
|
35
37
|
*/
|
|
36
38
|
export declare function logImagePaste(ev: ImagePasteEvent): void;
|
|
39
|
+
export interface AskQuestionEvent {
|
|
40
|
+
/** Stable event name: "key" | "finish" | "render" | "navigate" | "other_key". */
|
|
41
|
+
event: string;
|
|
42
|
+
/** Human-readable description; NEVER user-typed text or question content. */
|
|
43
|
+
detail?: string;
|
|
44
|
+
selectedIndex?: number;
|
|
45
|
+
otherIndex?: number;
|
|
46
|
+
otherLen?: number;
|
|
47
|
+
/** Raw key bytes, escaped (only for debug). */
|
|
48
|
+
key?: string;
|
|
49
|
+
/** Resolution status when the tool finishes: selected | other | cancelled. */
|
|
50
|
+
status?: string;
|
|
51
|
+
/** Whether the question is multiSelect at the time of the event. */
|
|
52
|
+
multi?: boolean;
|
|
53
|
+
/** 0-based index of the question within this tool call (multi-question runs). */
|
|
54
|
+
qIndex?: number;
|
|
55
|
+
/** Whether the "Other" row auto-check is on (multi-select). */
|
|
56
|
+
checked?: boolean;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Append one sanitized ask-user-question event to `ask-question.log`.
|
|
60
|
+
* Fail-soft and hermetically gated under `node --test` exactly like
|
|
61
|
+
* `logImagePaste`. Never logs user-typed text, question text, or option labels
|
|
62
|
+
* — only indices, lengths, key bytes (escaped), and resolution status.
|
|
63
|
+
*/
|
|
64
|
+
export declare function logAskQuestion(ev: AskQuestionEvent): void;
|
|
37
65
|
/** Read the most recent log content (for a user-triggered report). */
|
|
38
66
|
export declare function readRecentDiagnostics(maxBytes?: number): string;
|
|
39
67
|
/** List existing diagnostic log files (active + rotations), for a report. */
|
|
@@ -26,6 +26,10 @@ function yagniCodeHome() {
|
|
|
26
26
|
export function diagnosticsLogPath() {
|
|
27
27
|
return join(yagniCodeHome(), "logs", "image-paste.log");
|
|
28
28
|
}
|
|
29
|
+
/** Dedicated log for the ask_user_question interactive tool's state machine. */
|
|
30
|
+
export function askQuestionLogPath() {
|
|
31
|
+
return join(yagniCodeHome(), "logs", "ask-question.log");
|
|
32
|
+
}
|
|
29
33
|
const MAX_LOG_BYTES = 256 * 1024; // rotate the active file past this
|
|
30
34
|
const KEEP_ROTATIONS = 2; // keep image-paste.log.1 and .2 alongside the active file
|
|
31
35
|
/** Layer A: verbose mode. `YAGNI_DEBUG=1` (or "true") turns on extra detail. */
|
|
@@ -84,6 +88,38 @@ function rotateIfNeeded(path) {
|
|
|
84
88
|
/* rotation is best-effort */
|
|
85
89
|
}
|
|
86
90
|
}
|
|
91
|
+
/**
|
|
92
|
+
* Append one sanitized ask-user-question event to `ask-question.log`.
|
|
93
|
+
* Fail-soft and hermetically gated under `node --test` exactly like
|
|
94
|
+
* `logImagePaste`. Never logs user-typed text, question text, or option labels
|
|
95
|
+
* — only indices, lengths, key bytes (escaped), and resolution status.
|
|
96
|
+
*/
|
|
97
|
+
export function logAskQuestion(ev) {
|
|
98
|
+
try {
|
|
99
|
+
if (process.env.NODE_TEST_CONTEXT && homeOverride === null)
|
|
100
|
+
return;
|
|
101
|
+
const line = {
|
|
102
|
+
ts: new Date().toISOString(),
|
|
103
|
+
event: ev.event,
|
|
104
|
+
...(ev.selectedIndex !== undefined ? { selectedIndex: ev.selectedIndex } : {}),
|
|
105
|
+
...(ev.otherIndex !== undefined ? { otherIndex: ev.otherIndex } : {}),
|
|
106
|
+
...(ev.otherLen !== undefined ? { otherLen: ev.otherLen } : {}),
|
|
107
|
+
...(ev.status !== undefined ? { status: ev.status } : {}),
|
|
108
|
+
...(ev.multi !== undefined ? { multi: ev.multi } : {}),
|
|
109
|
+
...(ev.qIndex !== undefined ? { qIndex: ev.qIndex } : {}),
|
|
110
|
+
...(ev.checked !== undefined ? { checked: ev.checked } : {}),
|
|
111
|
+
...(ev.key !== undefined ? { key: ev.key } : {}),
|
|
112
|
+
...(ev.detail !== undefined && isDebug() ? { detail: ev.detail } : {}),
|
|
113
|
+
};
|
|
114
|
+
const path = askQuestionLogPath();
|
|
115
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
116
|
+
rotateIfNeeded(path);
|
|
117
|
+
appendFileSync(path, JSON.stringify(line) + "\n", "utf8");
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
/* logging must never throw into the tool */
|
|
121
|
+
}
|
|
122
|
+
}
|
|
87
123
|
/** Read the most recent log content (for a user-triggered report). */
|
|
88
124
|
export function readRecentDiagnostics(maxBytes = 64 * 1024) {
|
|
89
125
|
try {
|
|
@@ -120,6 +120,7 @@ export default function (pi: ExtensionAPI): Promise<void>;
|
|
|
120
120
|
export { makeAskYagniTool } from "./askYagniTool.js";
|
|
121
121
|
export { makeFileTicketTool, makeUpdateTicketStatusTool } from "./ticketTools.js";
|
|
122
122
|
export { makeAskAdvisorTool, registerAdviseCommand } from "./askAdvisorTool.js";
|
|
123
|
+
export { makeAskUserQuestionTool } from "./askUserQuestionTool.js";
|
|
123
124
|
export { ADVISOR_TIER, DEFAULT_ADVISOR_LIMITS, decideConsult, formatAdvisorSubtotal, makeAdvisorState, } from "./advisor.js";
|
|
124
125
|
export { DEFAULT_GUARDIAN_LIMITS, GUARDIAN_MODEL_TIER, formatGuardianSubtotal, makeGuardianState, resolveGuardianLimits, reviewCommand, deriveGuardianTimeoutMs, } from "./permission/guardian.js";
|
|
125
126
|
export type { GuardianOutcome, GuardianVerdict, GuardianState, GuardianStateHandle, GuardianLimits, ReviewResult, ReviewCommandDeps, } from "./permission/guardian.js";
|
package/dist/extension/index.js
CHANGED
|
@@ -7,6 +7,7 @@ import { appendGrant, loadGrants, resolveRepoKey, storagePrefix } from "./permis
|
|
|
7
7
|
import { redactCommand } from "./redact.js";
|
|
8
8
|
import { formatGuardianSubtotal, GUARDIAN_MODEL_TIER, makeGuardianState, resolveGuardianLimits, reviewCommand, deriveGuardianTimeoutMs } from "./permission/guardian.js";
|
|
9
9
|
import { makeAskAdvisorTool, registerAdviseCommand } from "./askAdvisorTool.js";
|
|
10
|
+
import { makeAskUserQuestionTool } from "./askUserQuestionTool.js";
|
|
10
11
|
import { makeAskYagniTool } from "./askYagniTool.js";
|
|
11
12
|
import { makeWebFetchTool } from "./webFetchTool.js";
|
|
12
13
|
import { makeFileTicketTool, makeUpdateTicketStatusTool } from "./ticketTools.js";
|
|
@@ -181,6 +182,9 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
181
182
|
// /advise runs the SAME tool, sharing the state handle, so a manual consult
|
|
182
183
|
// draws on the same cap rather than opening a side channel around it.
|
|
183
184
|
registerAdviseCommand(pi, askAdvisorTool);
|
|
185
|
+
// Structured human questions: the model poses a closed 2-4-option question
|
|
186
|
+
// and gets a clean machine-readable answer via ctx.ui.custom.
|
|
187
|
+
pi.registerTool(makeAskUserQuestionTool());
|
|
184
188
|
// The differentiated business-grounded tools (loop bricks): review a change
|
|
185
189
|
// for business fit, rank the next work by business priority, and record the
|
|
186
190
|
// engineering rationale back onto the work-item.
|
|
@@ -763,6 +767,16 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
763
767
|
// and a "Pi can explain its own features…" line) with a YAGNI Code masthead,
|
|
764
768
|
// set the terminal title, and add a footer brand mark. TUI only.
|
|
765
769
|
pi.on("session_start", async (event, ctx) => {
|
|
770
|
+
// Surface Pi's native find/grep/ls on the driver. These are Pi's
|
|
771
|
+
// equivalents of Claude Code's Glob/Grep/LS; the /go stages and subagents
|
|
772
|
+
// already pass them explicitly, but the driver defaults to read/bash/edit/write
|
|
773
|
+
// and otherwise reaches for `bash` find/grep/ls.
|
|
774
|
+
try {
|
|
775
|
+
pi.setActiveTools([...pi.getActiveTools(), "grep", "find", "ls"]);
|
|
776
|
+
}
|
|
777
|
+
catch {
|
|
778
|
+
// Tool enrichment must never break session start.
|
|
779
|
+
}
|
|
766
780
|
ctx.ui?.setTitle(BRAND_NAME);
|
|
767
781
|
if (ctx.mode === "tui") {
|
|
768
782
|
ctx.ui?.setStatus?.("brand", BRAND_NAME);
|
|
@@ -906,6 +920,7 @@ export default async function (pi) {
|
|
|
906
920
|
export { makeAskYagniTool } from "./askYagniTool.js";
|
|
907
921
|
export { makeFileTicketTool, makeUpdateTicketStatusTool } from "./ticketTools.js";
|
|
908
922
|
export { makeAskAdvisorTool, registerAdviseCommand } from "./askAdvisorTool.js";
|
|
923
|
+
export { makeAskUserQuestionTool } from "./askUserQuestionTool.js";
|
|
909
924
|
export { ADVISOR_TIER, DEFAULT_ADVISOR_LIMITS, decideConsult, formatAdvisorSubtotal, makeAdvisorState, } from "./advisor.js";
|
|
910
925
|
export { DEFAULT_GUARDIAN_LIMITS, GUARDIAN_MODEL_TIER, formatGuardianSubtotal, makeGuardianState, resolveGuardianLimits, reviewCommand, deriveGuardianTimeoutMs, } from "./permission/guardian.js";
|
|
911
926
|
export { makeReviewBusinessMatchTool } from "./reviewTool.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yagni-app/code-staging",
|
|
3
|
-
"version": "0.3.5-staging.
|
|
3
|
+
"version": "0.3.5-staging.1165.1",
|
|
4
4
|
"description": "YAGNI Code: a terminal coding agent that already knows your company. One YAGNI login routes the model and grounds the agent in your team's context.",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE.md",
|
|
6
6
|
"author": "YAGNI, Inc. <jack@yagni.app> (https://yagni.app)",
|
|
@@ -40,5 +40,5 @@
|
|
|
40
40
|
"turndown": "^7.2.4",
|
|
41
41
|
"typebox": "^1.3.15"
|
|
42
42
|
},
|
|
43
|
-
"yagniSourceSha": "
|
|
43
|
+
"yagniSourceSha": "09b1377d109fda746976e90031aa0641e24e79b3"
|
|
44
44
|
}
|