@pugi/cli 0.1.0-beta.16 → 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 (39) hide show
  1. package/dist/commands/jobs-watch.js +201 -0
  2. package/dist/commands/jobs.js +15 -0
  3. package/dist/core/agent-progress/cleanup.js +134 -0
  4. package/dist/core/agent-progress/schema.js +144 -0
  5. package/dist/core/agent-progress/writer.js +101 -0
  6. package/dist/core/diagnostics/probe-runner.js +93 -0
  7. package/dist/core/diagnostics/probes/api.js +46 -0
  8. package/dist/core/diagnostics/probes/auth.js +86 -0
  9. package/dist/core/diagnostics/probes/cli-version.js +127 -0
  10. package/dist/core/diagnostics/probes/config.js +72 -0
  11. package/dist/core/diagnostics/probes/disk.js +81 -0
  12. package/dist/core/diagnostics/probes/git.js +65 -0
  13. package/dist/core/diagnostics/probes/mcp.js +75 -0
  14. package/dist/core/diagnostics/probes/node.js +59 -0
  15. package/dist/core/diagnostics/probes/pnpm.js +36 -0
  16. package/dist/core/diagnostics/probes/session.js +74 -0
  17. package/dist/core/diagnostics/probes/workspace.js +63 -0
  18. package/dist/core/diagnostics/types.js +70 -0
  19. package/dist/core/engine/strip-internal-fields.js +124 -0
  20. package/dist/core/engine/tool-bridge.js +96 -27
  21. package/dist/core/file-cache.js +113 -1
  22. package/dist/core/mcp/client.js +66 -6
  23. package/dist/core/mcp/registry.js +24 -2
  24. package/dist/core/repl/session.js +64 -5
  25. package/dist/core/repl/slash-commands.js +9 -0
  26. package/dist/runtime/cli.js +153 -64
  27. package/dist/runtime/commands/doctor.js +357 -0
  28. package/dist/runtime/commands/mcp.js +290 -3
  29. package/dist/runtime/version.js +1 -1
  30. package/dist/tools/agent-tool.js +18 -4
  31. package/dist/tools/ask-user-question.js +213 -0
  32. package/dist/tools/file-tools.js +85 -14
  33. package/dist/tools/registry.js +7 -0
  34. package/dist/tui/agent-progress-card.js +111 -0
  35. package/dist/tui/ask-user-question-prompt.js +192 -0
  36. package/dist/tui/conversation-pane.js +68 -7
  37. package/dist/tui/doctor-table.js +31 -0
  38. package/dist/tui/tool-stream-pane.js +7 -0
  39. package/package.json +2 -2
@@ -1,11 +1,12 @@
1
1
  import { execFileSync } from 'node:child_process';
2
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
3
3
  import { homedir } from 'node:os';
4
4
  import { isAbsolute, resolve } from 'node:path';
5
5
  import { FileReadCache } from '../../core/file-cache.js';
6
6
  import { openSession } from '../../core/session.js';
7
7
  import { loadSettings } from '../../core/settings.js';
8
- import { loadMcpRegistry } from '../../core/mcp/registry.js';
8
+ import { isAlive } from '../../core/mcp/client.js';
9
+ import { loadMcpRegistry, mcpLogPath } from '../../core/mcp/registry.js';
9
10
  import { listMcpTrust, setMcpTrust } from '../../core/mcp/trust.js';
10
11
  import { createPugiMcpServer, serveStdio } from '../../core/mcp/server.js';
11
12
  import { buildPugiMcpTools } from '../../core/mcp/server-tools.js';
@@ -22,6 +23,15 @@ export async function runMcpCommand(args, ctx) {
22
23
  return runMcpFlip(args.slice(1), ctx, 'denied');
23
24
  case 'install':
24
25
  return runMcpInstall(args.slice(1), ctx);
26
+ case 'remove':
27
+ case 'uninstall':
28
+ return runMcpRemove(args.slice(1), ctx);
29
+ case 'doctor':
30
+ return runMcpDoctor(args.slice(1), ctx);
31
+ case 'logs':
32
+ return runMcpLogs(args.slice(1), ctx);
33
+ case 'restart':
34
+ return runMcpRestart(args.slice(1), ctx);
25
35
  case 'serve':
26
36
  return runMcpServe(args.slice(1), ctx);
27
37
  case 'perms':
@@ -32,7 +42,7 @@ export async function runMcpCommand(args, ctx) {
32
42
  ctx.writeOutput({ command: 'mcp', usage: USAGE_LINES }, USAGE_LINES.join('\n'));
33
43
  return;
34
44
  default:
35
- throw new Error(`Unknown sub-command "pugi mcp ${sub}". Try one of: list, trust, deny, install, serve, perms.`);
45
+ throw new Error(`Unknown sub-command "pugi mcp ${sub}". Try one of: list, trust, deny, install, remove, doctor, logs, restart, serve, perms.`);
36
46
  }
37
47
  }
38
48
  const USAGE_LINES = [
@@ -44,6 +54,15 @@ const USAGE_LINES = [
44
54
  ' install <name> <command...> Add a server to .pugi/mcp.json (workspace scope).',
45
55
  ' <command> must be an absolute path OR a binary',
46
56
  ' resolvable via `which` on the operator PATH.',
57
+ ' remove <name> Remove a server from .pugi/mcp.json (workspace scope).',
58
+ ' Trust ledger entry is preserved — re-install reuses it.',
59
+ ' doctor [--connect] Print per-server health (handshake, tool count, last error).',
60
+ ' --connect actually spawns the children (slow, ~5s/server).',
61
+ ' Without --connect, reports the declared state only.',
62
+ ' logs <name> [--tail N] Tail the per-server log file at .pugi/logs/mcp-<name>.log.',
63
+ ' Default tail is 40 lines.',
64
+ ' restart <name> Bounce a server: tear down the connection, reload the',
65
+ ' config, re-handshake. Surfaces the new tool count.',
47
66
  ' serve [options] Run Pugi as an MCP server',
48
67
  ' --http :<port> HTTP+SSE transport (default: stdio)',
49
68
  ' --host <ip> HTTP bind host (default: 127.0.0.1)',
@@ -219,6 +238,274 @@ function containsShellMetachar(value) {
219
238
  // a shell pipeline.
220
239
  return /[;|&`$<>(){}\n\r]/.test(value);
221
240
  }
241
+ /* ---------- remove ---------------------------------------------------- */
242
+ async function runMcpRemove(args, ctx) {
243
+ const name = args[0];
244
+ if (!name) {
245
+ throw new Error('Usage: pugi mcp remove <name>');
246
+ }
247
+ if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
248
+ throw new Error(`pugi mcp remove: server name "${name}" must be [a-zA-Z0-9_-]+`);
249
+ }
250
+ const mcpJsonPath = resolve(ctx.workspaceRoot, '.pugi/mcp.json');
251
+ if (!existsSync(mcpJsonPath)) {
252
+ throw new Error(`pugi mcp remove: no .pugi/mcp.json at ${mcpJsonPath}. Nothing to remove.`);
253
+ }
254
+ let existing;
255
+ try {
256
+ const raw = readFileSync(mcpJsonPath, 'utf8');
257
+ if (raw.trim().length === 0) {
258
+ existing = { servers: {} };
259
+ }
260
+ else {
261
+ const parsed = JSON.parse(raw);
262
+ existing = { servers: parsed.servers ?? {} };
263
+ }
264
+ }
265
+ catch (error) {
266
+ throw new Error(`pugi mcp remove: cannot parse .pugi/mcp.json: ${error.message}. ` +
267
+ `Fix the file by hand or delete it and re-run.`);
268
+ }
269
+ if (!existing.servers[name]) {
270
+ throw new Error(`pugi mcp remove: server "${name}" not declared in ${mcpJsonPath}. ` +
271
+ `Run \`pugi mcp list\` to see declared servers.`);
272
+ }
273
+ // Preserve the trust ledger entry on purpose — a re-install of the same
274
+ // server name should land back at its old trust state so the operator
275
+ // does not have to re-approve it. To wipe trust as well, run
276
+ // `pugi mcp deny <name>` (or edit ~/.pugi/trust-mcp.json by hand).
277
+ delete existing.servers[name];
278
+ writeFileSync(mcpJsonPath, `${JSON.stringify(existing, null, 2)}\n`, { mode: 0o600 });
279
+ ctx.writeOutput({
280
+ command: 'mcp.remove',
281
+ name,
282
+ configPath: mcpJsonPath,
283
+ remaining: Object.keys(existing.servers),
284
+ }, [
285
+ `Removed MCP server "${name}" from ${mcpJsonPath}.`,
286
+ `Trust ledger entry preserved — re-install reuses it.`,
287
+ `Remaining servers: ${Object.keys(existing.servers).length === 0 ? '(none)' : Object.keys(existing.servers).join(', ')}.`,
288
+ ].join('\n'));
289
+ }
290
+ /* ---------- doctor ---------------------------------------------------- */
291
+ async function runMcpDoctor(args, ctx) {
292
+ // `--connect` forces the doctor to actually spawn children + handshake.
293
+ // Default behaviour is dry-run (config + trust ledger only) so a routine
294
+ // `pugi doctor` does not block on a misbehaving server's 5s timeout per
295
+ // entry. Operators investigating an outage pass `--connect` explicitly.
296
+ const wantsConnect = args.includes('--connect');
297
+ const registry = await loadMcpRegistry(ctx.workspaceRoot, { connect: wantsConnect });
298
+ const ledger = await listMcpTrust();
299
+ const rows = [];
300
+ for (const state of registry.servers.values()) {
301
+ const conn = state.connection;
302
+ let handshake;
303
+ if (!wantsConnect) {
304
+ handshake = 'not-attempted';
305
+ }
306
+ else if (state.lastError) {
307
+ handshake = 'failed';
308
+ }
309
+ else if (conn && isAlive(conn)) {
310
+ handshake = 'ok';
311
+ }
312
+ else {
313
+ handshake = 'failed';
314
+ }
315
+ rows.push({
316
+ name: state.name,
317
+ trust: state.trust,
318
+ handshake,
319
+ pid: conn?.child.pid ?? null,
320
+ tools: state.surfacedTools.length,
321
+ uptimeMs: conn ? Date.now() - conn.startedAt : null,
322
+ lastError: state.lastError ?? null,
323
+ logFile: mcpLogPath(ctx.workspaceRoot, state.name),
324
+ });
325
+ }
326
+ rows.sort((a, b) => a.name.localeCompare(b.name));
327
+ await registry.shutdown();
328
+ if (rows.length === 0) {
329
+ ctx.writeOutput({ command: 'mcp.doctor', rows, ledger, connectAttempted: wantsConnect }, 'No MCP servers declared. Add one with `pugi mcp install <name> <command...>`.');
330
+ return;
331
+ }
332
+ const headerLines = [
333
+ `MCP doctor (${wantsConnect ? 'live handshake' : 'declared state only — pass --connect for live probe'}):`,
334
+ '',
335
+ ` ${'NAME'.padEnd(20)} ${'TRUST'.padEnd(8)} ${'HANDSHAKE'.padEnd(14)} ${'TOOLS'.padEnd(6)} ${'PID'.padEnd(7)} NOTE`,
336
+ ];
337
+ for (const row of rows) {
338
+ const note = row.lastError
339
+ ? `error: ${truncate(row.lastError, 60)}`
340
+ : row.handshake === 'ok'
341
+ ? `uptime ${formatUptime(row.uptimeMs ?? 0)}`
342
+ : row.handshake === 'failed'
343
+ ? 'see log file'
344
+ : '';
345
+ headerLines.push(` ${row.name.padEnd(20)} ${row.trust.padEnd(8)} ${row.handshake.padEnd(14)} ${String(row.tools).padEnd(6)} ${String(row.pid ?? '-').padEnd(7)} ${note}`);
346
+ }
347
+ headerLines.push('', `Log dir: ${resolve(ctx.workspaceRoot, '.pugi/logs')}`);
348
+ if (!wantsConnect) {
349
+ headerLines.push('Hint: pass --connect to actually spawn the children (slow, ~5s budget/server).');
350
+ }
351
+ ctx.writeOutput({ command: 'mcp.doctor', rows, ledger, connectAttempted: wantsConnect }, headerLines.join('\n'));
352
+ }
353
+ function truncate(value, max) {
354
+ if (value.length <= max)
355
+ return value;
356
+ return `${value.slice(0, max - 1)}…`;
357
+ }
358
+ function formatUptime(ms) {
359
+ if (ms < 1000)
360
+ return `${ms}ms`;
361
+ const sec = Math.floor(ms / 1000);
362
+ if (sec < 60)
363
+ return `${sec}s`;
364
+ const min = Math.floor(sec / 60);
365
+ if (min < 60)
366
+ return `${min}m${sec % 60}s`;
367
+ const hr = Math.floor(min / 60);
368
+ return `${hr}h${min % 60}m`;
369
+ }
370
+ /* ---------- logs ------------------------------------------------------ */
371
+ async function runMcpLogs(args, ctx) {
372
+ const name = args[0];
373
+ if (!name || name.startsWith('--')) {
374
+ throw new Error('Usage: pugi mcp logs <name> [--tail N]');
375
+ }
376
+ if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
377
+ throw new Error(`pugi mcp logs: server name "${name}" must be [a-zA-Z0-9_-]+`);
378
+ }
379
+ // `--tail N` and `--tail=N` both supported. Default 40 lines — matches
380
+ // typical `tail -n 40` muscle memory.
381
+ let tail = 40;
382
+ for (let i = 1; i < args.length; i += 1) {
383
+ const arg = args[i] ?? '';
384
+ if (arg === '--tail') {
385
+ const next = args[i + 1];
386
+ if (!next)
387
+ throw new Error('pugi mcp logs: --tail requires a value');
388
+ const n = Number.parseInt(next, 10);
389
+ if (!Number.isInteger(n) || n <= 0) {
390
+ throw new Error(`pugi mcp logs: --tail must be a positive integer (got "${next}")`);
391
+ }
392
+ tail = n;
393
+ i += 1;
394
+ }
395
+ else if (arg.startsWith('--tail=')) {
396
+ const n = Number.parseInt(arg.slice('--tail='.length), 10);
397
+ if (!Number.isInteger(n) || n <= 0) {
398
+ throw new Error(`pugi mcp logs: --tail must be a positive integer (got "${arg}")`);
399
+ }
400
+ tail = n;
401
+ }
402
+ else {
403
+ throw new Error(`pugi mcp logs: unknown flag "${arg}"`);
404
+ }
405
+ }
406
+ const path = mcpLogPath(ctx.workspaceRoot, name);
407
+ if (!existsSync(path)) {
408
+ ctx.writeOutput({ command: 'mcp.logs', name, path, tail, lines: [] }, `No log file at ${path}. The server has not produced stderr output yet (or has never been started).`);
409
+ return;
410
+ }
411
+ let raw;
412
+ try {
413
+ raw = readFileSync(path, 'utf8');
414
+ }
415
+ catch (error) {
416
+ throw new Error(`pugi mcp logs: cannot read ${path}: ${error.message}.`);
417
+ }
418
+ const allLines = raw.split('\n');
419
+ // `split('\n')` of a trailing-newline file yields an empty last element.
420
+ // Drop it so the displayed tail matches `wc -l` expectations.
421
+ if (allLines.length > 0 && allLines[allLines.length - 1] === '') {
422
+ allLines.pop();
423
+ }
424
+ const tailed = allLines.slice(Math.max(0, allLines.length - tail));
425
+ const sizeBytes = (() => {
426
+ try {
427
+ return statSync(path).size;
428
+ }
429
+ catch {
430
+ return 0;
431
+ }
432
+ })();
433
+ ctx.writeOutput({
434
+ command: 'mcp.logs',
435
+ name,
436
+ path,
437
+ tail,
438
+ totalLines: allLines.length,
439
+ sizeBytes,
440
+ lines: tailed,
441
+ }, [
442
+ `pugi mcp logs ${name} (${path}, ${sizeBytes} bytes, ${allLines.length} total lines, showing last ${Math.min(tail, allLines.length)}):`,
443
+ ...tailed,
444
+ ].join('\n'));
445
+ }
446
+ /* ---------- restart --------------------------------------------------- */
447
+ async function runMcpRestart(args, ctx) {
448
+ const name = args[0];
449
+ if (!name) {
450
+ throw new Error('Usage: pugi mcp restart <name>');
451
+ }
452
+ if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
453
+ throw new Error(`pugi mcp restart: server name "${name}" must be [a-zA-Z0-9_-]+`);
454
+ }
455
+ // `pugi mcp restart` is stateless from the CLI's perspective — the CLI
456
+ // process has no long-lived MCP registry of its own (the REPL owns it
457
+ // and tears it down on exit). What we DO here is: load the config,
458
+ // refuse if the server is not declared or not trusted, then probe-spawn
459
+ // the server with the live handshake. This proves it is reachable +
460
+ // surfaces the tool count for the operator. The REPL picks up the
461
+ // change on its next `loadMcpRegistry` cycle.
462
+ const registry = await loadMcpRegistry(ctx.workspaceRoot, { connect: false });
463
+ const state = registry.servers.get(name);
464
+ if (!state) {
465
+ await registry.shutdown();
466
+ throw new Error(`pugi mcp restart: server "${name}" not declared. Run \`pugi mcp list\` to see declared servers.`);
467
+ }
468
+ if (state.trust !== 'trusted') {
469
+ await registry.shutdown();
470
+ throw new Error(`pugi mcp restart: server "${name}" trust state is "${state.trust}". ` +
471
+ `Run \`pugi mcp trust ${name}\` first.`);
472
+ }
473
+ await registry.shutdown();
474
+ // Re-load WITH connect=true but scoped to a single-server probe via
475
+ // handshakeTimeoutMs (5s default keeps the CLI snappy). We use the same
476
+ // loadMcpRegistry path so log routing + error capture stay consistent.
477
+ const probe = await loadMcpRegistry(ctx.workspaceRoot, { connect: true });
478
+ const probed = probe.servers.get(name);
479
+ const lastError = probed?.lastError ?? null;
480
+ const toolCount = probed?.surfacedTools.length ?? 0;
481
+ const pid = probed?.connection?.child.pid ?? null;
482
+ await probe.shutdown();
483
+ if (lastError) {
484
+ ctx.writeOutput({
485
+ command: 'mcp.restart',
486
+ name,
487
+ ok: false,
488
+ error: lastError,
489
+ logFile: mcpLogPath(ctx.workspaceRoot, name),
490
+ }, [
491
+ `pugi mcp restart ${name}: FAILED`,
492
+ ` error: ${lastError}`,
493
+ ` log file: ${mcpLogPath(ctx.workspaceRoot, name)}`,
494
+ ].join('\n'));
495
+ return;
496
+ }
497
+ ctx.writeOutput({
498
+ command: 'mcp.restart',
499
+ name,
500
+ ok: true,
501
+ pid,
502
+ surfacedTools: toolCount,
503
+ }, [
504
+ `pugi mcp restart ${name}: OK`,
505
+ ` pid: ${pid ?? '-'}`,
506
+ ` surfaced tools: ${toolCount}`,
507
+ ].join('\n'));
508
+ }
222
509
  async function runMcpServe(args, ctx) {
223
510
  const flags = parseServeFlags(args);
224
511
  const session = openSession(ctx.workspaceRoot);
@@ -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.16');
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