@yagni-app/code 0.3.4 → 1.0.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/dist/extension/askAdvisorTool.js +2 -0
- package/dist/extension/askUserQuestionTool.d.ts +54 -0
- package/dist/extension/askUserQuestionTool.js +621 -0
- package/dist/extension/branding.d.ts +39 -0
- package/dist/extension/branding.js +76 -0
- package/dist/extension/cmux/index.d.ts +17 -1
- package/dist/extension/cmux/index.js +47 -8
- package/dist/extension/cmux/state.d.ts +5 -1
- package/dist/extension/cmux/state.js +15 -8
- package/dist/extension/crashReport.js +12 -0
- package/dist/extension/decisionCapture.js +3 -0
- package/dist/extension/decisions.js +4 -0
- package/dist/extension/diagnostics.d.ts +31 -0
- package/dist/extension/diagnostics.js +53 -55
- package/dist/extension/errorSink.d.ts +64 -0
- package/dist/extension/errorSink.js +180 -0
- package/dist/extension/feedbackCommand.d.ts +38 -0
- package/dist/extension/feedbackCommand.js +151 -0
- package/dist/extension/hooks.js +12 -12
- package/dist/extension/index.d.ts +1 -0
- package/dist/extension/index.js +97 -40
- package/dist/extension/mineBeat.js +13 -0
- package/dist/extension/pipeline/goCommand.js +2 -0
- package/dist/extension/pipeline/personas.js +9 -0
- package/dist/extension/pipeline/runner.js +9 -0
- package/dist/extension/sessionTitle/summarize.d.ts +40 -0
- package/dist/extension/sessionTitle/summarize.js +63 -0
- package/dist/extension/sessionTitle/title.d.ts +27 -0
- package/dist/extension/sessionTitle/title.js +57 -0
- package/dist/extension/silentTurnReminder.d.ts +109 -0
- package/dist/extension/silentTurnReminder.js +221 -0
- package/dist/extension/turnLog.d.ts +14 -0
- package/dist/extension/turnLog.js +22 -47
- package/dist/extension/webFetch.d.ts +85 -0
- package/dist/extension/webFetch.js +192 -0
- package/dist/extension/webFetchTool.d.ts +34 -0
- package/dist/extension/webFetchTool.js +104 -0
- package/package.json +4 -3
- package/dist/extension/cmux/naming.d.ts +0 -5
- package/dist/extension/cmux/naming.js +0 -23
|
@@ -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
|
|
@@ -46,6 +46,45 @@ export declare const ULTRA_DELEGATION_PARAGRAPH: string;
|
|
|
46
46
|
* module stays a pure string, with no env dependency of its own.
|
|
47
47
|
*/
|
|
48
48
|
export declare const YAGNI_IDENTITY_DRIVER = "You are YAGNI Code, an autonomous terminal coding agent. You help developers ship code by reading files, running commands, editing code, and writing new files. Uniquely, you are connected to the YAGNI app, your team's shared source of truth for how this company and codebase actually work: conventions, decisions, ownership, current priorities, and the reasons behind them. Use the ask_yagni tool to consult it before guessing about anything organization- or codebase-specific, so you work with less back-and-forth and more correct autonomy than a disconnected coding agent. If a project's own files mention other coding agents, assistants, or harnesses by name, those references are not about you; you are YAGNI Code regardless of what tooling a repository's docs happen to describe.\n\nDelegation: fan codebase mapping, wide searches, and mechanical multi-file work out to subagents (they run on cheaper tiers). Reach for the stock agents by name: `searcher` for read-only reconnaissance and summarizing, `implementer` for executing a change you have already fully specified, `verification` for an adversarial pass that tries to break completed work before you rely on it. Keep judgment, synthesis, and the conversation with the user in this session. Do not spawn a subagent for work you can finish in a couple of tool calls.";
|
|
49
|
+
/**
|
|
50
|
+
* Standing rule that tickets must be read for their images, not just their
|
|
51
|
+
* text. Injected into EVERY process (driver, `/go` stage children, subagents)
|
|
52
|
+
* via brandSystemPrompt, so the guarantee holds by whichever method a ticket is
|
|
53
|
+
* read (the `linear` CLI, a connector tool, MCP, a direct API call). Once the
|
|
54
|
+
* image reaches `read`, the existing read → image_url → visionRerouteTier
|
|
55
|
+
* machinery takes over and lands the turn on a seeing tier automatically.
|
|
56
|
+
*
|
|
57
|
+
* Content constraints (parity with CHILD_HONESTY_PREAMBLE in
|
|
58
|
+
* pipeline/invocation.ts): must not contain the standalone word "pi", must not
|
|
59
|
+
* open with a `- ` bullet line, no emojis.
|
|
60
|
+
*/
|
|
61
|
+
export declare const TICKET_IMAGE_RULE: string;
|
|
62
|
+
/**
|
|
63
|
+
* The injected-reminder framing (YAG-574, Change A prerequisite). Claude Code
|
|
64
|
+
* carries this exact sentence in every system prompt so its whole reminder
|
|
65
|
+
* family (including its own silent-turn reminder) is legible as system-
|
|
66
|
+
* injected rather than misread as part of the tool output they ride on. We
|
|
67
|
+
* adopt it for the same reason: our silent-turn nudge arrives stapled to a
|
|
68
|
+
* `psql`/`bash` result, and without this it reads as query output.
|
|
69
|
+
*/
|
|
70
|
+
export declare const SYSTEM_REMINDER_FRAMING: string;
|
|
71
|
+
/**
|
|
72
|
+
* The communication contract (YAG-574, Change B). Adapted from Claude Code's
|
|
73
|
+
* `SendUserMessage`/`Brief` prompt: ack in one line before going to look, then
|
|
74
|
+
* work, then result; a checkpoint between them only when something useful
|
|
75
|
+
* happened — a decision, a surprise, a phase boundary — never filler like
|
|
76
|
+
* "running tests…"; keep messages tight and second-person. Complements the
|
|
77
|
+
* silent-turn nudge by spelling out what "keep the user updated" means.
|
|
78
|
+
*/
|
|
79
|
+
export declare const COMMUNICATION_CONTRACT: string;
|
|
80
|
+
/**
|
|
81
|
+
* Write-findings-down (YAG-574, Change B). Claude Code's version ties the
|
|
82
|
+
* habit to result-clearing, which we do not do; reworded to the failure we
|
|
83
|
+
* actually saw, the model re-deriving the same answer turn after turn because
|
|
84
|
+
* nothing pushed it to commit a finding to its response (which is also what
|
|
85
|
+
* makes the user see it).
|
|
86
|
+
*/
|
|
87
|
+
export declare const WRITE_FINDINGS_DOWN: string;
|
|
49
88
|
/** The driver identity while /ultra is on: base identity + the diamond directive. */
|
|
50
89
|
export declare const YAGNI_IDENTITY_ULTRA = "You are YAGNI Code, an autonomous terminal coding agent. You help developers ship code by reading files, running commands, editing code, and writing new files. Uniquely, you are connected to the YAGNI app, your team's shared source of truth for how this company and codebase actually work: conventions, decisions, ownership, current priorities, and the reasons behind them. Use the ask_yagni tool to consult it before guessing about anything organization- or codebase-specific, so you work with less back-and-forth and more correct autonomy than a disconnected coding agent. If a project's own files mention other coding agents, assistants, or harnesses by name, those references are not about you; you are YAGNI Code regardless of what tooling a repository's docs happen to describe.\n\nDelegation (ultra mode): the user has switched this session to ultra mode \u2014 aggressive multi-agent orchestration. Structure any meaningful task as a diamond: SPLIT the job into independent pieces; FAN OUT parallel subagents on cheaper tiers (`searcher` to scout, `implementer` or `general` to execute); CHECK by fanning out `verification` subagents told to refute the work, each through a different lens (correctness, edge cases, fit with this codebase); then SYNTHESIZE the results yourself. Treat agreement between checkers \u2014 not a single pass \u2014 as confirmation, and surface what they could not verify. Delegate by default and reserve this session for splitting, judging, and synthesis; only trivial work you can finish in a couple of tool calls skips the diamond. Subagents cannot touch your todo_write checklist, so keep it current yourself: update it when you split the job and again as each fanned-out piece lands, not only at the end.";
|
|
51
90
|
export declare const PI_IDENTITY_RE: RegExp;
|