@pugi/cli 0.1.0-beta.17 → 0.1.0-beta.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/dist/core/diagnostics/probe-runner.js +93 -0
  2. package/dist/core/diagnostics/probes/api.js +46 -0
  3. package/dist/core/diagnostics/probes/auth.js +86 -0
  4. package/dist/core/diagnostics/probes/cli-version.js +127 -0
  5. package/dist/core/diagnostics/probes/config.js +72 -0
  6. package/dist/core/diagnostics/probes/disk.js +81 -0
  7. package/dist/core/diagnostics/probes/git.js +65 -0
  8. package/dist/core/diagnostics/probes/mcp.js +75 -0
  9. package/dist/core/diagnostics/probes/node.js +59 -0
  10. package/dist/core/diagnostics/probes/pnpm.js +36 -0
  11. package/dist/core/diagnostics/probes/session.js +74 -0
  12. package/dist/core/diagnostics/probes/workspace.js +63 -0
  13. package/dist/core/diagnostics/types.js +70 -0
  14. package/dist/core/engine/strip-internal-fields.js +124 -0
  15. package/dist/core/engine/tool-bridge.js +100 -37
  16. package/dist/core/file-cache.js +113 -1
  17. package/dist/core/mcp/client.js +66 -6
  18. package/dist/core/mcp/registry.js +24 -2
  19. package/dist/core/repl/session.js +34 -0
  20. package/dist/core/repl/slash-commands.js +9 -0
  21. package/dist/runtime/cli.js +24 -58
  22. package/dist/runtime/commands/doctor.js +357 -0
  23. package/dist/runtime/commands/mcp.js +290 -3
  24. package/dist/runtime/version.js +1 -1
  25. package/dist/tools/agent-tool.js +18 -4
  26. package/dist/tools/ask-user-question.js +213 -0
  27. package/dist/tools/file-tools.js +57 -14
  28. package/dist/tools/registry.js +7 -0
  29. package/dist/tui/ask-user-question-prompt.js +192 -0
  30. package/dist/tui/conversation-pane.js +68 -7
  31. package/dist/tui/doctor-table.js +31 -0
  32. package/package.json +2 -2
@@ -44,7 +44,7 @@ export function sanitizeSemver(raw) {
44
44
  * during import). When bumping the CLI version BOTH literals must be
45
45
  * updated; the release smoke-test (`pack:smoke`) verifies they agree.
46
46
  */
47
- export const PUGI_CLI_VERSION = sanitizeSemver('0.1.0-beta.17');
47
+ export const PUGI_CLI_VERSION = sanitizeSemver('0.1.0-beta.18');
48
48
  /**
49
49
  * Outbound: the CLI's installed semver. Read at request time by
50
50
  * `version-interceptor.ts` and injected on every `fetch` call.
@@ -58,8 +58,16 @@ import { spawnSubagentWithOutcome } from '../core/subagents/spawn.js';
58
58
  * inverse (forces shared-fs even for roles whose default is `worktree`).
59
59
  *
60
60
  * The role enum mirrors the SDK's SubagentRole — keep both in lockstep.
61
+ *
62
+ * Leak P0 L2 (2026-05-27): `z.strictObject` rejects ANY additional or
63
+ * aliased fields at parse time. Matches the openclaude FileEditTool /
64
+ * FileWriteTool posture (research memo §1.1). The model-facing JSON
65
+ * schema already declares `additionalProperties: false`; the strict
66
+ * Zod variant is defense-in-depth — if the bridge ever bypasses the
67
+ * model-side gate (raw test fixture, internal dispatch), the runtime
68
+ * still refuses unknown keys instead of silently dropping them.
61
69
  */
62
- export const agentToolArgsSchema = z.object({
70
+ export const agentToolArgsSchema = z.strictObject({
63
71
  role: z.enum([
64
72
  'orchestrator',
65
73
  'architect',
@@ -70,12 +78,18 @@ export const agentToolArgsSchema = z.object({
70
78
  'release',
71
79
  'devops',
72
80
  'design_qa',
73
- ]),
81
+ ]).describe('SubagentRole — selects persona + isolation tier.'),
74
82
  brief: z
75
83
  .string()
76
84
  .min(1, 'brief must not be empty')
77
- .max(8000, 'brief must be ≤ 8000 chars'),
78
- isolation: z.enum(['worktree', 'shared_fs', 'auto']).optional(),
85
+ .max(8000, 'brief must be ≤ 8000 chars')
86
+ .describe('One-paragraph task description forwarded to the child as the user prompt. '
87
+ + 'Be concrete: include filenames, expected behavior, and acceptance criteria.'),
88
+ isolation: z
89
+ .enum(['worktree', 'shared_fs', 'auto'])
90
+ .optional()
91
+ .describe('Optional override. `worktree` forces a scratch git worktree for write isolation; '
92
+ + '`shared_fs` forces same-tree execution; `auto` (default) defers to the role tier.'),
79
93
  });
80
94
  /**
81
95
  * Dispatch a subagent via the `agent` tool. Returns the JSON envelope
@@ -0,0 +1,213 @@
1
+ /**
2
+ * AskUserQuestion structured tool — leak L5 (research memo §2.5).
3
+ *
4
+ * Mirrors openclaude's `src/tools/AskUserQuestionTool/prompt.ts`
5
+ * pattern: clarifying questions go through a structured multi-choice
6
+ * tool, NOT free-text prose. The model dispatches the tool with a
7
+ * `question` + a `header` chip + 2-4 `options` (each with `label` +
8
+ * `description`). The UI renders the modal, auto-appends an "Other"
9
+ * fallback for custom text, and surfaces the operator's pick back to
10
+ * the model as a tool_result frame.
11
+ *
12
+ * Why P0 leverage: the structured form forecloses Pugi's recurring
13
+ * "agent rambles instead of dispatching" failure mode at the schema
14
+ * level. When the model is uncertain, the cheapest legal output is
15
+ * `ask_user_question` — not a prose menu, not a fake "Шипану через
16
+ * 8 минут" dispatch promise.
17
+ *
18
+ * Relationship to ask-user.ts (β1 T2):
19
+ * - ask-user.ts is the LEGACY string-array form (`options: string[]`).
20
+ * Kept for back-compat; the existing prompt envelope `<pugi-ask>`
21
+ * and the persona prompts still emit that grammar.
22
+ * - ask-user-question.ts is the STRUCTURED form layered on top. It
23
+ * normalises a {label, description} option into the legacy string
24
+ * before delegating to `askUser`, so the Ink modal does not need
25
+ * two render paths and the abort/timeout race logic is shared.
26
+ *
27
+ * Hard rules (enforced by Zod):
28
+ * - question: 5-500 chars, must end with "?". Plain English.
29
+ * - header: 2-12 chars (short chip label, e.g. "Auth method").
30
+ * - options: 2-4 strict (no more, no less). Mutually exclusive.
31
+ * UI auto-adds "Other" — the model NEVER emits it.
32
+ * - multiSelect: default false.
33
+ */
34
+ import { z } from 'zod';
35
+ import { askUser } from './ask-user.js';
36
+ /** Cap matches the Ink modal layout: 12 chars fits the header chip. */
37
+ export const ASK_USER_QUESTION_HEADER_MIN = 2;
38
+ export const ASK_USER_QUESTION_HEADER_MAX = 12;
39
+ /** Question must be a real question (ends with ?). 5-500 chars. */
40
+ export const ASK_USER_QUESTION_MIN = 5;
41
+ export const ASK_USER_QUESTION_MAX = 500;
42
+ /** Each option label: 2-40 chars (1-5 words). */
43
+ export const ASK_USER_QUESTION_OPTION_LABEL_MIN = 2;
44
+ export const ASK_USER_QUESTION_OPTION_LABEL_MAX = 40;
45
+ /** Each option description: 10-200 chars (one short sentence). */
46
+ export const ASK_USER_QUESTION_OPTION_DESC_MIN = 10;
47
+ export const ASK_USER_QUESTION_OPTION_DESC_MAX = 200;
48
+ /** Option count: 2-4 strict. UI adds "Other" automatically. */
49
+ export const ASK_USER_QUESTION_OPTIONS_MIN = 2;
50
+ export const ASK_USER_QUESTION_OPTIONS_MAX = 4;
51
+ /**
52
+ * Structured option. `label` is the display text; `description` is the
53
+ * implication line shown dim below it. Both are required — the model
54
+ * cannot ship a label-only option (forces it to think about why each
55
+ * choice exists).
56
+ */
57
+ export const askUserQuestionOptionSchema = z.strictObject({
58
+ label: z
59
+ .string()
60
+ .min(ASK_USER_QUESTION_OPTION_LABEL_MIN)
61
+ .max(ASK_USER_QUESTION_OPTION_LABEL_MAX)
62
+ .describe('Display text. Concise (1-5 words).'),
63
+ description: z
64
+ .string()
65
+ .min(ASK_USER_QUESTION_OPTION_DESC_MIN)
66
+ .max(ASK_USER_QUESTION_OPTION_DESC_MAX)
67
+ .describe('What this option means / implications.'),
68
+ });
69
+ export const askUserQuestionSchema = z.strictObject({
70
+ question: z
71
+ .string()
72
+ .min(ASK_USER_QUESTION_MIN)
73
+ .max(ASK_USER_QUESTION_MAX)
74
+ .refine((q) => q.trim().endsWith('?'), {
75
+ message: 'question must end with "?"',
76
+ })
77
+ .describe('The complete question. Must end with "?". Plain English, no jargon.'),
78
+ header: z
79
+ .string()
80
+ .min(ASK_USER_QUESTION_HEADER_MIN)
81
+ .max(ASK_USER_QUESTION_HEADER_MAX)
82
+ .describe('Short chip label (max 12 chars). E.g. "Auth method".'),
83
+ options: z
84
+ .array(askUserQuestionOptionSchema)
85
+ .min(ASK_USER_QUESTION_OPTIONS_MIN)
86
+ .max(ASK_USER_QUESTION_OPTIONS_MAX)
87
+ .describe('2-4 mutually-exclusive options. NEVER add "Other" — UI auto-adds.'),
88
+ multiSelect: z
89
+ .boolean()
90
+ .optional()
91
+ .default(false)
92
+ .describe('Allow multiple selections. Default false.'),
93
+ });
94
+ /**
95
+ * Dispatch the structured tool: validate args via Zod, then route
96
+ * through the shared `askUser` primitive so abort/timeout/non-TTY
97
+ * envelope behaviour is identical to the legacy form.
98
+ *
99
+ * The bridge surface is the same `AskUserBridge` signature — the
100
+ * structured form just gives the Ink modal richer metadata to render
101
+ * (header chip + per-option description). The bridge sees the legacy
102
+ * `{question, options: string[]}` shape because all production bridges
103
+ * (Ink modal + non-TTY envelope emitter) already consume that shape.
104
+ * Per-option descriptions and the header chip are surfaced separately
105
+ * via `enrich` — the modal layer reads them off the dispatched payload
106
+ * stash, NOT off the bridge input, so structured callers can layer on
107
+ * top of the legacy interface without touching the modal contract.
108
+ *
109
+ * Return contract:
110
+ * - Interactive + bridge present + operator picks N options →
111
+ * `[ask_user_question:answered] <labels joined by ", ">`.
112
+ * - Interactive + bridge present + operator cancels →
113
+ * `[ask_user_question:cancelled]`.
114
+ * - Interactive + bridge present + timeout →
115
+ * `[ask_user_question:timeout]`.
116
+ * - Non-TTY or no bridge → `[user_input_required]<json>[/...]`
117
+ * envelope identical to the legacy form. Includes `header` +
118
+ * structured options so a scripted caller can parse the full shape.
119
+ */
120
+ export async function dispatchAskUserQuestion(ctx, rawArgs) {
121
+ const parsed = askUserQuestionSchema.parse(rawArgs);
122
+ // Schema-level guard against the "Other" leak: the prompt rules tell
123
+ // the model NEVER to include "Other" in `options`, but we still reject
124
+ // it defensively in case a future model misreads the spec. The Ink
125
+ // modal auto-appends "Other" itself; a model-supplied duplicate would
126
+ // render two "Other" rows.
127
+ for (const opt of parsed.options) {
128
+ const trimmed = opt.label.trim().toLowerCase();
129
+ if (trimmed === 'other' || trimmed === 'другое') {
130
+ throw new Error('ask_user_question: do NOT include "Other" in options — UI auto-adds it');
131
+ }
132
+ }
133
+ const legacyOptions = parsed.options.map((opt) => opt.label);
134
+ const result = await askUser(ctx, {
135
+ question: parsed.question,
136
+ options: legacyOptions,
137
+ multiSelect: parsed.multiSelect ?? false,
138
+ });
139
+ if (result.answers && result.answers.length > 0) {
140
+ return {
141
+ answers: result.answers,
142
+ envelope: `[ask_user_question:answered] ${result.answers.join(', ')}`,
143
+ };
144
+ }
145
+ // Non-TTY / cancelled / timeout. Re-wrap the envelope so callers can
146
+ // grep for the structured tool name even when the underlying primitive
147
+ // surfaced its legacy `[user_input_required]` envelope.
148
+ if (result.envelope.includes('"reason":"timeout"')) {
149
+ return { envelope: '[ask_user_question:timeout]' };
150
+ }
151
+ if (result.envelope.includes('"reason":"cancelled"')) {
152
+ return { envelope: '[ask_user_question:cancelled]' };
153
+ }
154
+ // Default to the legacy envelope verbatim — it is still
155
+ // grep-friendly and includes the structured payload above.
156
+ return { envelope: result.envelope };
157
+ }
158
+ /**
159
+ * JSON-Schema fragment surfaced to the model via the tool-bridge
160
+ * `parameters` field. Mirrors the Zod schema 1:1 — kept hand-written
161
+ * because the runtime engine wires OpenAI-compatible JSON Schema and
162
+ * the Zod-to-JSON-Schema converter pulls in a transitive dep we have
163
+ * not greenlit. If the Zod schema above changes, mirror the change here.
164
+ */
165
+ export const askUserQuestionJsonSchema = {
166
+ type: 'object',
167
+ additionalProperties: false,
168
+ required: ['question', 'header', 'options'],
169
+ properties: {
170
+ question: {
171
+ type: 'string',
172
+ minLength: ASK_USER_QUESTION_MIN,
173
+ maxLength: ASK_USER_QUESTION_MAX,
174
+ description: 'The complete question. Must end with "?". Plain English, no jargon.',
175
+ },
176
+ header: {
177
+ type: 'string',
178
+ minLength: ASK_USER_QUESTION_HEADER_MIN,
179
+ maxLength: ASK_USER_QUESTION_HEADER_MAX,
180
+ description: 'Short chip label (max 12 chars). E.g. "Auth method".',
181
+ },
182
+ options: {
183
+ type: 'array',
184
+ minItems: ASK_USER_QUESTION_OPTIONS_MIN,
185
+ maxItems: ASK_USER_QUESTION_OPTIONS_MAX,
186
+ items: {
187
+ type: 'object',
188
+ additionalProperties: false,
189
+ required: ['label', 'description'],
190
+ properties: {
191
+ label: {
192
+ type: 'string',
193
+ minLength: ASK_USER_QUESTION_OPTION_LABEL_MIN,
194
+ maxLength: ASK_USER_QUESTION_OPTION_LABEL_MAX,
195
+ description: 'Display text. Concise (1-5 words).',
196
+ },
197
+ description: {
198
+ type: 'string',
199
+ minLength: ASK_USER_QUESTION_OPTION_DESC_MIN,
200
+ maxLength: ASK_USER_QUESTION_OPTION_DESC_MAX,
201
+ description: 'What this option means / implications.',
202
+ },
203
+ },
204
+ },
205
+ description: '2-4 mutually-exclusive options. NEVER add "Other" — UI auto-adds.',
206
+ },
207
+ multiSelect: {
208
+ type: 'boolean',
209
+ description: 'Allow multiple selections. Default false.',
210
+ },
211
+ },
212
+ };
213
+ //# sourceMappingURL=ask-user-question.js.map
@@ -27,11 +27,11 @@
27
27
  * sibling commits close.
28
28
  */
29
29
  import { spawnSync } from 'node:child_process';
30
- import { existsSync, readFileSync, realpathSync, renameSync, writeFileSync } from 'node:fs';
30
+ import { existsSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync } from 'node:fs';
31
31
  import { dirname, isAbsolute, relative } from 'node:path';
32
32
  import { globSync } from 'node:fs';
33
33
  import { decidePermission } from '../core/permission.js';
34
- import { createReadRecord, hashContent } from '../core/file-cache.js';
34
+ import { StaleReadError, createReadRecord, hashContent, } from '../core/file-cache.js';
35
35
  import { resolveWorkspacePath } from '../core/path-security.js';
36
36
  import { recordFileMutation, recordToolCall, recordToolResult } from '../core/session.js';
37
37
  /**
@@ -47,6 +47,11 @@ export class OperatorAbortedError extends Error {
47
47
  this.name = 'OperatorAbortedError';
48
48
  }
49
49
  }
50
+ // Re-export StaleReadError so tool-bridge / test consumers can import
51
+ // the typed error from a single file-tools surface alongside
52
+ // OperatorAbortedError. Same shape as the existing OperatorAbortedError
53
+ // re-surface pattern.
54
+ export { StaleReadError } from '../core/file-cache.js';
50
55
  /**
51
56
  * α6.9 WriteGate: refuse the tool dispatch when the active
52
57
  * cancellation token has aborted. Idempotent (the token's `isAborted`
@@ -152,10 +157,37 @@ export function writeTool(ctx, path, content) {
152
157
  throw error;
153
158
  }
154
159
  const existed = existsSync(resolved);
155
- const before = existed ? readFileSync(resolved, 'utf8') : undefined;
160
+ // Leak L1 stale-read gate for writeTool's update-existing path. The
161
+ // model uses writeTool for two distinct intents:
162
+ //
163
+ // - create-new: path does not exist on disk. There is no prior
164
+ // read to validate against; skip the gate. This is the
165
+ // intentional escape hatch the leak spec also calls out.
166
+ // - overwrite-existing: path exists. Without the gate the model
167
+ // could blind-clobber an externally-modified file, losing the
168
+ // concurrent change silently. Force the model to re-read first.
169
+ //
170
+ // We deliberately apply the SAME stale-validation primitive editTool
171
+ // uses so the two write surfaces stay symmetric and a future fix to
172
+ // either one cannot accidentally weaken the other.
173
+ let before;
174
+ if (existed) {
175
+ before = readFileSync(resolved, 'utf8');
176
+ const currentStat = statSync(resolved);
177
+ const validation = ctx.readCache.validate(ctx.root, path, currentStat.mtimeMs, before);
178
+ if (validation.stale) {
179
+ const reason = `stale_read: write ${path} refused — ${validation.detail}`;
180
+ recordToolResult(ctx.session, toolCallId, 'error', reason);
181
+ throw new StaleReadError(path, validation.reason, validation.detail);
182
+ }
183
+ }
156
184
  const tmp = `${resolved}.pugi-tmp-${Date.now()}`;
157
185
  writeFileSync(tmp, content, { encoding: 'utf8', mode: 0o600 });
158
186
  renameSync(tmp, resolved);
187
+ // Refresh the cache with the post-write content so the model can
188
+ // chain a follow-up read+edit on the same file without an extra
189
+ // round-trip. Same pattern editTool uses below.
190
+ ctx.readCache.set(createReadRecord(ctx.root, path, content, 'read_tool'));
159
191
  recordFileMutation(ctx.session, {
160
192
  toolCallId,
161
193
  path,
@@ -182,10 +214,6 @@ export function editTool(ctx, path, oldString, newString) {
182
214
  recordToolResult(ctx.session, toolCallId, 'error', reason);
183
215
  throw new Error(reason);
184
216
  }
185
- const readRecord = ctx.readCache.get(ctx.root, path);
186
- if (!readRecord) {
187
- throw new Error(`Cannot edit ${path}: file must be read first`);
188
- }
189
217
  let resolved;
190
218
  try {
191
219
  resolved = permissionGatedResolve(ctx, path, 'edit', 'edit');
@@ -195,16 +223,31 @@ export function editTool(ctx, path, oldString, newString) {
195
223
  recordToolResult(ctx.session, toolCallId, 'error', reason);
196
224
  throw error;
197
225
  }
226
+ // Leak L1 stale-read gate. Validate the model's read-time view of
227
+ // the file against the on-disk state BEFORE applying the mutation.
228
+ // We read disk content once and feed it to the validator so a single
229
+ // syscall covers both the gate decision AND the oldString/newString
230
+ // replacement below.
198
231
  const before = readFileSync(resolved, 'utf8');
199
- const currentHash = hashContent(before);
200
- if (currentHash !== readRecord.sha256) {
201
- throw new Error(`Cannot edit ${path}: file changed since last read`);
232
+ const currentStat = statSync(resolved);
233
+ const validation = ctx.readCache.validate(ctx.root, path, currentStat.mtimeMs, before);
234
+ if (validation.stale) {
235
+ const reason = `stale_read: edit ${path} refused — ${validation.detail}`;
236
+ recordToolResult(ctx.session, toolCallId, 'error', reason);
237
+ throw new StaleReadError(path, validation.reason, validation.detail);
202
238
  }
239
+ const currentHash = hashContent(before);
203
240
  const matches = before.split(oldString).length - 1;
204
- if (matches === 0)
205
- throw new Error(`Cannot edit ${path}: oldString not found`);
206
- if (matches > 1)
207
- throw new Error(`Cannot edit ${path}: oldString is not unique`);
241
+ if (matches === 0) {
242
+ const reason = `Cannot edit ${path}: oldString not found`;
243
+ recordToolResult(ctx.session, toolCallId, 'error', reason);
244
+ throw new Error(reason);
245
+ }
246
+ if (matches > 1) {
247
+ const reason = `Cannot edit ${path}: oldString is not unique`;
248
+ recordToolResult(ctx.session, toolCallId, 'error', reason);
249
+ throw new Error(reason);
250
+ }
208
251
  const after = before.replace(oldString, newString);
209
252
  const tmp = `${resolved}.pugi-tmp-${Date.now()}`;
210
253
  writeFileSync(tmp, after, { encoding: 'utf8', mode: 0o600 });
@@ -3,6 +3,13 @@ const registry = [
3
3
  // gate as Layer A/B/C, so the risk class matches `edit`/`write`
4
4
  // (medium — writes inside the workspace, never to protected files).
5
5
  { name: 'apply_patch', permission: 'edit', risk: 'medium', concurrencySafe: false, m1: true },
6
+ // Leak L5 (2026-05-27): structured multi-choice clarifier tool. Risk =
7
+ // low because the dispatch is a pure UI surface — no file writes, no
8
+ // shell, no network. Permission = none (no workspace access required).
9
+ // concurrencySafe = true because the prompt-budget gate runs in the
10
+ // engine loop, not via tool-side mutex (one prompt per turn is enforced
11
+ // by the persona system prompt + the engine's tool_calls budget).
12
+ { name: 'ask_user_question', permission: 'none', risk: 'low', concurrencySafe: true, m1: true },
6
13
  { name: 'bash', permission: 'bash', risk: 'high', concurrencySafe: false, m1: true },
7
14
  { name: 'edit', permission: 'edit', risk: 'medium', concurrencySafe: false, m1: true },
8
15
  { name: 'glob', permission: 'read', risk: 'low', concurrencySafe: true, m1: true },
@@ -0,0 +1,192 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * AskUserQuestionPrompt — Ink modal for the structured tool grammar
4
+ * (leak L5, openclaude pattern).
5
+ *
6
+ * Renders the four required elements of an openclaude-style clarifier:
7
+ * 1. A short "header" chip at the top (e.g. "Auth method"). Max 12
8
+ * chars by schema, fits one line at the standard 80-col REPL width.
9
+ * 2. The full question prose (must end "?"). Word-wrapped by Ink.
10
+ * 3. The 2-4 options as a selectable list with j/k navigation. Each
11
+ * option shows the `label` (bright) + `description` (dim).
12
+ * 4. An auto-appended "Other" row that the operator can pick to type
13
+ * a custom answer. The model NEVER emits this — the UI owns it.
14
+ *
15
+ * Multi-select mode: when `multiSelect=true`, space toggles the
16
+ * current row, Enter submits the toggled set. Selected rows are
17
+ * marked with a leading checkbox glyph. Single-select mode: Enter
18
+ * commits the highlighted row immediately.
19
+ *
20
+ * Resolver contract: `onResolve` receives either an `answers: string[]`
21
+ * (one or more picked labels), a `customInput: string` (Other path),
22
+ * or `cancelled: true` (Esc). Mirrors AskModal so the REPL wiring
23
+ * stays uniform.
24
+ *
25
+ * Brand voice gate: ASCII glyphs only. No em-dashes, no banned brand
26
+ * words. The copy is power-word neutral so a localised variant lands
27
+ * cleanly later.
28
+ */
29
+ import { useState } from 'react';
30
+ import { Box, Text, useInput } from 'ink';
31
+ export function AskUserQuestionPrompt(props) {
32
+ const multiSelect = props.multiSelect === true;
33
+ const [mode, setMode] = useState('pick');
34
+ const [cursor, setCursor] = useState(0);
35
+ // Used in multi-select mode: indices in `options` that the operator
36
+ // has toggled. Order preserved so the resolved answer list reflects
37
+ // selection order (the model's downstream reasoning often weights
38
+ // earlier picks higher — preserving order is cheap).
39
+ const [picked, setPicked] = useState([]);
40
+ const [buffer, setBuffer] = useState('');
41
+ const otherIndex = props.options.length; // 0-indexed slot for "Other"
42
+ const totalRows = props.options.length + 1; // options + Other
43
+ useInput((input, key) => {
44
+ // Esc cancels the modal in either mode.
45
+ if (key.escape) {
46
+ props.onResolve({ cancelled: true });
47
+ return;
48
+ }
49
+ if (mode === 'pick') {
50
+ // Numeric hotkeys: 1..N selects the matching option directly
51
+ // (single-select), or toggles it (multi-select). Convenience
52
+ // shortcut so a keyboard-only user does not need to j/k walk
53
+ // through 4 rows. Out-of-range keys fall through.
54
+ const numeric = Number.parseInt(input, 10);
55
+ if (!Number.isNaN(numeric) && numeric >= 1 && numeric <= totalRows) {
56
+ const row = numeric - 1;
57
+ if (row === otherIndex) {
58
+ setMode('custom');
59
+ setBuffer('');
60
+ return;
61
+ }
62
+ if (multiSelect) {
63
+ togglePick(row);
64
+ return;
65
+ }
66
+ commitSinglePick(row);
67
+ return;
68
+ }
69
+ // Vim-style navigation: j down, k up. Arrow keys also work.
70
+ if (input === 'j' || key.downArrow) {
71
+ setCursor((c) => (c + 1) % totalRows);
72
+ return;
73
+ }
74
+ if (input === 'k' || key.upArrow) {
75
+ setCursor((c) => (c - 1 + totalRows) % totalRows);
76
+ return;
77
+ }
78
+ // 'o' hotkey: jump straight to Other (mirrors AskModal).
79
+ if (input === 'o' || input === 'O') {
80
+ setMode('custom');
81
+ setBuffer('');
82
+ return;
83
+ }
84
+ if (key.return) {
85
+ if (cursor === otherIndex) {
86
+ setMode('custom');
87
+ setBuffer('');
88
+ return;
89
+ }
90
+ if (multiSelect) {
91
+ // Enter in multi-select mode COMMITS the toggled set. If the
92
+ // current row is not yet toggled, fold it in first so the
93
+ // operator does not have to press space+enter for a single pick.
94
+ const finalPicks = picked.includes(cursor) ? picked : [...picked, cursor];
95
+ if (finalPicks.length === 0) {
96
+ // No picks + Enter = ignore; the footer hint nudges them.
97
+ return;
98
+ }
99
+ const answers = finalPicks.map((i) => props.options[i].label);
100
+ props.onResolve({ answers, cancelled: false });
101
+ return;
102
+ }
103
+ commitSinglePick(cursor);
104
+ return;
105
+ }
106
+ // Space toggles the current row in multi-select mode. Ignored in
107
+ // single-select (a single space could otherwise leak into a buffer).
108
+ if (multiSelect && input === ' ') {
109
+ togglePick(cursor);
110
+ return;
111
+ }
112
+ return;
113
+ }
114
+ // Custom-input mode: line editor.
115
+ if (key.return) {
116
+ if (buffer.trim().length === 0) {
117
+ // Empty buffer + Enter = bounce back to pick mode (mirrors AskModal).
118
+ setMode('pick');
119
+ setBuffer('');
120
+ return;
121
+ }
122
+ props.onResolve({
123
+ customInput: buffer.trim(),
124
+ cancelled: false,
125
+ });
126
+ return;
127
+ }
128
+ if (key.backspace || key.delete) {
129
+ setBuffer((prev) => prev.slice(0, -1));
130
+ return;
131
+ }
132
+ if (input && !key.meta && !key.ctrl) {
133
+ setBuffer((prev) => prev + input);
134
+ }
135
+ }, { isActive: props.inert !== true });
136
+ function togglePick(row) {
137
+ setPicked((prev) => prev.includes(row) ? prev.filter((i) => i !== row) : [...prev, row]);
138
+ }
139
+ function commitSinglePick(row) {
140
+ if (row < 0 || row >= props.options.length)
141
+ return;
142
+ const opt = props.options[row];
143
+ if (!opt)
144
+ return;
145
+ props.onResolve({ answers: [opt.label], cancelled: false });
146
+ }
147
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "yellow", children: '? ' }), _jsx(Text, { inverse: true, bold: true, color: "yellow", children: ` ${props.header} ` }), _jsx(Text, { bold: true, children: ' Need your call before I continue' })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { children: props.question }) }), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [props.options.map((opt, idx) => {
148
+ const isCursor = mode === 'pick' && cursor === idx;
149
+ const isPicked = multiSelect && picked.includes(idx);
150
+ const marker = multiSelect ? (isPicked ? '[x]' : '[ ]') : `${idx + 1}.`;
151
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: isCursor ? 'cyan' : '#3da9fc', bold: true, children: isCursor ? '> ' : ' ' }), _jsx(Text, { color: "#3da9fc", bold: true, children: `${marker} ` }), _jsx(Text, { bold: isCursor, children: opt.label })] }), _jsx(Box, { marginLeft: multiSelect ? 7 : 5, children: _jsx(Text, { dimColor: true, children: opt.description }) })] }, `${idx}-${opt.label}`));
152
+ }), _jsxs(Box, { children: [_jsx(Text, { color: mode === 'pick' && cursor === otherIndex ? 'cyan' : '#3da9fc', bold: true, children: mode === 'pick' && cursor === otherIndex ? '> ' : ' ' }), _jsx(Text, { color: "#3da9fc", bold: true, children: `${multiSelect ? '[*]' : `${totalRows}.`} ` }), _jsx(Text, { dimColor: true, children: 'Other (type a custom answer)' })] })] }), mode === 'pick' ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: multiSelect
153
+ ? `j/k navigate. Space toggles. Enter commits. Esc cancels.`
154
+ : `1-${totalRows} or j/k navigate. Enter commits. Esc cancels.` }) })) : (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { children: [_jsx(Text, { color: "#3da9fc", children: '> ' }), _jsx(Text, { children: buffer }), _jsx(Text, { inverse: true, children: ' ' })] }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: 'Type your custom answer. Enter submits. Esc cancels.' }) })] }))] }));
155
+ }
156
+ /**
157
+ * Encode the prompt verdict into the literal turn-injection string. The
158
+ * persona prompt teaches the model to recognise this prefix; mirrors
159
+ * AskModal's `encodeAskVerdict` for consistency.
160
+ *
161
+ * Examples:
162
+ * { answers: ['Vercel'] } → "[ASK-USER-QUESTION:answered] Vercel"
163
+ * { answers: ['a', 'b'] } → "[ASK-USER-QUESTION:answered] a, b"
164
+ * { customInput: 'gcp' } → "[ASK-USER-QUESTION:other] gcp"
165
+ * { cancelled: true } → "[ASK-USER-QUESTION:cancelled]"
166
+ */
167
+ export function encodeAskUserQuestionVerdict(verdict) {
168
+ if (verdict.cancelled)
169
+ return '[ASK-USER-QUESTION:cancelled]';
170
+ if (verdict.answers && verdict.answers.length > 0) {
171
+ return `[ASK-USER-QUESTION:answered] ${verdict.answers.join(', ')}`;
172
+ }
173
+ if (verdict.customInput && verdict.customInput.trim().length > 0) {
174
+ // Strip any forged verdict header (mirrors AskModal sanitiser).
175
+ const cleaned = sanitiseVerdictText(verdict.customInput);
176
+ if (cleaned.length > 0) {
177
+ return `[ASK-USER-QUESTION:other] ${cleaned}`;
178
+ }
179
+ }
180
+ return '[ASK-USER-QUESTION:cancelled]';
181
+ }
182
+ function sanitiseVerdictText(raw) {
183
+ let cleaned = raw;
184
+ for (let i = 0; i < raw.length + 4; i += 1) {
185
+ const stripped = cleaned.replace(/^\s*\[(?:ASK-RESPONSE|PLAN-VERDICT|ASK-USER-QUESTION):[^\]]*\]\s*/u, '');
186
+ if (stripped === cleaned)
187
+ break;
188
+ cleaned = stripped;
189
+ }
190
+ return cleaned.trim();
191
+ }
192
+ //# sourceMappingURL=ask-user-question-prompt.js.map