@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,9 +1,37 @@
1
+ /**
2
+ * file-tools - Pugi CLI file/bash/glob/grep tool surface.
3
+ *
4
+ * Workspace-binding contract (CEO red-alert 2026-05-27 follow-up):
5
+ *
6
+ * Every tool dispatch path threads `ctx.root` from the operator's
7
+ * `process.cwd()` through `EngineTask.workspaceRoot` ->
8
+ * `native-pugi.run()` -> `toolCtx.root` -> here. Tools call
9
+ * `resolveWorkspacePath(ctx.root, path)` for every on-disk operation
10
+ * so a dispatched specialist (e.g. Hiroshi writing tic-tac-toe HTML)
11
+ * produces files in the OPERATOR'S cwd, never in a server-side temp
12
+ * space. The path-security gate refuses traversal (`../etc/passwd`,
13
+ * URL-encoded variants, symlink escapes at the target).
14
+ *
15
+ * Wiring chain:
16
+ * 1. runtime/cli.ts: workspaceRoot = process.cwd()
17
+ * 2. EngineTask.workspaceRoot threads through to native-pugi.run().
18
+ * 3. native-pugi: const root = task.workspaceRoot
19
+ * 4. tool-bridge: passes ctx.root to file-tools / bash.
20
+ * 5. file-tools: resolveWorkspacePath(ctx.root, path).
21
+ *
22
+ * The contract is locked by `test/tools-write-to-workspace.spec.ts`
23
+ * (6 cases covering relative + nested + absolute paths + traversal
24
+ * refusal). If any layer of the chain regressed silently, dispatched
25
+ * files would land in `/tmp` instead of the operator's repo, which
26
+ * is the same failure surface as the menu-mode anti-pattern the
27
+ * sibling commits close.
28
+ */
1
29
  import { spawnSync } from 'node:child_process';
2
- import { existsSync, readFileSync, realpathSync, renameSync, writeFileSync } from 'node:fs';
30
+ import { existsSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync } from 'node:fs';
3
31
  import { dirname, isAbsolute, relative } from 'node:path';
4
32
  import { globSync } from 'node:fs';
5
33
  import { decidePermission } from '../core/permission.js';
6
- import { createReadRecord, hashContent } from '../core/file-cache.js';
34
+ import { StaleReadError, createReadRecord, hashContent, } from '../core/file-cache.js';
7
35
  import { resolveWorkspacePath } from '../core/path-security.js';
8
36
  import { recordFileMutation, recordToolCall, recordToolResult } from '../core/session.js';
9
37
  /**
@@ -19,6 +47,11 @@ export class OperatorAbortedError extends Error {
19
47
  this.name = 'OperatorAbortedError';
20
48
  }
21
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';
22
55
  /**
23
56
  * α6.9 WriteGate: refuse the tool dispatch when the active
24
57
  * cancellation token has aborted. Idempotent (the token's `isAborted`
@@ -124,10 +157,37 @@ export function writeTool(ctx, path, content) {
124
157
  throw error;
125
158
  }
126
159
  const existed = existsSync(resolved);
127
- 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
+ }
128
184
  const tmp = `${resolved}.pugi-tmp-${Date.now()}`;
129
185
  writeFileSync(tmp, content, { encoding: 'utf8', mode: 0o600 });
130
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'));
131
191
  recordFileMutation(ctx.session, {
132
192
  toolCallId,
133
193
  path,
@@ -154,10 +214,6 @@ export function editTool(ctx, path, oldString, newString) {
154
214
  recordToolResult(ctx.session, toolCallId, 'error', reason);
155
215
  throw new Error(reason);
156
216
  }
157
- const readRecord = ctx.readCache.get(ctx.root, path);
158
- if (!readRecord) {
159
- throw new Error(`Cannot edit ${path}: file must be read first`);
160
- }
161
217
  let resolved;
162
218
  try {
163
219
  resolved = permissionGatedResolve(ctx, path, 'edit', 'edit');
@@ -167,16 +223,31 @@ export function editTool(ctx, path, oldString, newString) {
167
223
  recordToolResult(ctx.session, toolCallId, 'error', reason);
168
224
  throw error;
169
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.
170
231
  const before = readFileSync(resolved, 'utf8');
171
- const currentHash = hashContent(before);
172
- if (currentHash !== readRecord.sha256) {
173
- 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);
174
238
  }
239
+ const currentHash = hashContent(before);
175
240
  const matches = before.split(oldString).length - 1;
176
- if (matches === 0)
177
- throw new Error(`Cannot edit ${path}: oldString not found`);
178
- if (matches > 1)
179
- 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
+ }
180
251
  const after = before.replace(oldString, newString);
181
252
  const tmp = `${resolved}.pugi-tmp-${Date.now()}`;
182
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,111 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ /** Width of the progress bar in display cells. Tuned to fit comfortably
4
+ * inside an 80-col terminal alongside the percent label. */
5
+ export const PROGRESS_BAR_WIDTH = 24;
6
+ /** Max milestone rows the card renders before collapsing to the footer
7
+ * summary. Matches the CC `/compact` cutoff. */
8
+ export const MAX_VISIBLE_MILESTONES = 5;
9
+ const STATUS_GLYPH = {
10
+ done: '◼',
11
+ active: '▸',
12
+ pending: '◻',
13
+ };
14
+ const STATUS_COLOR = {
15
+ done: 'green',
16
+ active: 'yellow',
17
+ pending: 'gray',
18
+ };
19
+ const HEADER_DOT_COLOR = {
20
+ running: 'cyan',
21
+ completed: 'green',
22
+ failed: 'red',
23
+ };
24
+ /**
25
+ * Build the unicode progress bar. Exported для тесты — guarantees the
26
+ * filled/empty counts match the percent under all rounding edges.
27
+ */
28
+ export function renderProgressBarCells(percent, width = PROGRESS_BAR_WIDTH) {
29
+ const safePercent = Math.max(0, Math.min(100, percent));
30
+ const cells = Math.round((safePercent / 100) * width);
31
+ const clamped = Math.max(0, Math.min(width, cells));
32
+ return {
33
+ filled: '▰'.repeat(clamped),
34
+ empty: '▱'.repeat(width - clamped),
35
+ cells: clamped,
36
+ };
37
+ }
38
+ /**
39
+ * Format milliseconds as the CC-style `Hh Mm Ss` / `Mm Ss` / `Ss` label.
40
+ * Mirrors the rule used by status-bar elapsed slot.
41
+ */
42
+ export function formatElapsed(ms) {
43
+ const total = Math.max(0, Math.floor(ms / 1000));
44
+ const h = Math.floor(total / 3600);
45
+ const m = Math.floor((total % 3600) / 60);
46
+ const s = total % 60;
47
+ if (h > 0)
48
+ return `${h}h ${m}m ${s}s`;
49
+ if (m > 0)
50
+ return `${m}m ${s}s`;
51
+ return `${s}s`;
52
+ }
53
+ /**
54
+ * Format a raw token count as `21.7k` / `3.4M` / `812`. Mirrors the
55
+ * formatter in `core/repl/model-pricing.ts` so both surfaces stay
56
+ * visually consistent without coupling.
57
+ */
58
+ export function formatTokenCount(n) {
59
+ if (n === undefined)
60
+ return undefined;
61
+ if (n < 1_000)
62
+ return `${n}`;
63
+ if (n < 1_000_000) {
64
+ const k = n / 1_000;
65
+ return `${k >= 10 ? k.toFixed(1).replace(/\.0$/, '') : k.toFixed(1)}k`;
66
+ }
67
+ const m = n / 1_000_000;
68
+ return `${m >= 10 ? m.toFixed(1).replace(/\.0$/, '') : m.toFixed(1)}M`;
69
+ }
70
+ /**
71
+ * Compute the "… +N pending, M completed" footer counts. When the
72
+ * agent supplied rollups they win; otherwise we derive from the
73
+ * milestone array.
74
+ */
75
+ export function computeFooterCounts(milestones, visibleCount, rollup) {
76
+ const pending = rollup.pendingCount
77
+ ?? milestones.filter((m) => m.status === 'pending').length;
78
+ const completed = rollup.completedCount
79
+ ?? milestones.filter((m) => m.status === 'done').length;
80
+ const hidden = Math.max(0, milestones.length - visibleCount);
81
+ return { pending, completed, hidden };
82
+ }
83
+ function MilestoneRow({ milestone }) {
84
+ const glyph = STATUS_GLYPH[milestone.status];
85
+ const color = STATUS_COLOR[milestone.status];
86
+ // Truncate to 64 chars so a verbose label can't wrap and break the
87
+ // grid layout in the watcher.
88
+ const label = milestone.label.length > 64
89
+ ? `${milestone.label.slice(0, 63)}…`
90
+ : milestone.label;
91
+ return (_jsxs(Box, { children: [_jsx(Text, { children: ' ' }), _jsx(Text, { color: color, children: glyph }), _jsx(Text, { children: " " }), _jsx(Text, { color: color === 'gray' ? 'gray' : undefined, dimColor: milestone.status === 'pending', children: label })] }));
92
+ }
93
+ export function AgentProgressCard({ progress, nowEpochMs, }) {
94
+ // Re-derive elapsed from the wall clock when the parent supplied it;
95
+ // this is what makes the card tick once a second without the writer
96
+ // re-emitting JSON every tick.
97
+ const elapsed = nowEpochMs !== undefined
98
+ ? Math.max(progress.elapsedMs, nowEpochMs - Date.parse(progress.startedAt))
99
+ : progress.elapsedMs;
100
+ const bar = renderProgressBarCells(progress.percentComplete);
101
+ const percentLabel = `${Math.round(Math.max(0, Math.min(100, progress.percentComplete)))}%`;
102
+ const tokensLabel = formatTokenCount(progress.tokensUsed);
103
+ const dotColor = HEADER_DOT_COLOR[progress.status];
104
+ const visibleMilestones = progress.milestones.slice(0, MAX_VISIBLE_MILESTONES);
105
+ const footer = computeFooterCounts(progress.milestones, visibleMilestones.length, { pendingCount: progress.pendingCount, completedCount: progress.completedCount });
106
+ // CC compact pattern: header has a leading `· ` glyph + the task label.
107
+ // We append `…` only while running (matches CC's "Compacting…" verb form).
108
+ const headerVerb = progress.status === 'running' ? '…' : '';
109
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 1, paddingY: 0, marginBottom: 1, children: [_jsxs(Box, { children: [_jsx(Text, { color: dotColor, children: '· ' }), _jsx(Text, { bold: true, children: progress.agentType }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [progress.task, headerVerb] }), _jsxs(Text, { dimColor: true, children: [' (', formatElapsed(elapsed), tokensLabel ? ` · ↑ ${tokensLabel} tokens` : '', ')'] })] }), _jsxs(Box, { children: [_jsx(Text, { children: ' ' }), _jsx(Text, { color: "cyan", children: bar.filled }), _jsx(Text, { dimColor: true, children: bar.empty }), _jsxs(Text, { children: [' ', percentLabel] })] }), progress.stepDescription ? (_jsxs(Box, { children: [_jsx(Text, { children: ' ' }), _jsxs(Text, { dimColor: true, children: ["step ", progress.currentStep, "/", progress.totalSteps, ": ", progress.stepDescription] })] })) : null, visibleMilestones.length > 0 ? (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { children: ' ' }), _jsx(Text, { dimColor: true, children: "\u23BF" })] }), visibleMilestones.map((m, i) => (_jsx(MilestoneRow, { milestone: m }, `${m.label}-${i}`))), footer.hidden > 0 ? (_jsxs(Box, { children: [_jsx(Text, { children: ' ' }), _jsxs(Text, { dimColor: true, children: ["\u2026 +", footer.pending, " pending, ", footer.completed, " completed"] })] })) : null] })) : null] }));
110
+ }
111
+ //# sourceMappingURL=agent-progress-card.js.map
@@ -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
@@ -43,16 +43,77 @@ function ConversationRow({ row, personaNames, }) {
43
43
  const slug = row.personaSlug ?? '';
44
44
  const color = HUE_COLOR_BY_SLUG[slug] ?? 'white';
45
45
  const displayName = personaNames?.get(slug) ?? slug;
46
- // α6.12: persona bodies travel through MarkdownRender so code
47
- // fences, headings, and inline accents land correctly. A row that
48
- // carries no Markdown syntax renders as plain text under the same
49
- // path (the parser falls through to a single paragraph span), so
50
- // there is no regression for the simple "Mira shipped." baseline.
51
- const containsMarkdown = looksLikeMarkdown(row.text);
52
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: color, bold: true, children: `▸ ${displayName} ` }), containsMarkdown ? null : _jsx(Text, { children: row.text })] }), containsMarkdown ? (_jsx(Box, { marginLeft: 2, children: _jsx(MarkdownRender, { source: row.text }) })) : null] }));
46
+ // CEO live dogfood 2026-05-27: a body that started with a
47
+ // Cyrillic letter (e.g. "Принял.") rendered as "PugiПринял." on
48
+ // the same line because the persona label sat at zero margin and
49
+ // the body fell flush behind it. The label is bold-coloured chrome,
50
+ // not text the operator is meant to read as a sentence stem.
51
+ // Split body to its own row and indent two columns so the
52
+ // transcript reads:
53
+ //
54
+ // ▸ Pugi
55
+ // Принял.
56
+ //
57
+ // matching the Claude Code / Codex / Gemini baseline visually +
58
+ // preventing the "PugiПринял" glue regardless of the body's
59
+ // leading character.
60
+ //
61
+ // The render also strips a leading identity-intro phrase as a
62
+ // defense-in-depth complement to the backend output gate — when
63
+ // turnIndex > 1, the operator must NOT see "Я Pugi - твой
64
+ // инженерный напарник." opening every reply. This is belt-and-
65
+ // braces; the prompt + output-gate already block it upstream.
66
+ const stripped = stripLeadingIdentityIntro(row.text);
67
+ const containsMarkdown = looksLikeMarkdown(stripped);
68
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { children: _jsx(Text, { color: color, bold: true, children: `▸ ${displayName}` }) }), containsMarkdown ? (_jsx(Box, { marginLeft: 2, children: _jsx(MarkdownRender, { source: stripped }) })) : (_jsx(Box, { marginLeft: 2, children: _jsx(Text, { children: stripped }) }))] }));
53
69
  }
54
70
  }
55
71
  }
72
+ /**
73
+ * Strip a leading identity-intro line ("I'm Pugi — your engineering
74
+ * copilot..." / "Я Pugi — твой инженерный напарник...") from the
75
+ * transcript body as a defense-in-depth complement to the backend
76
+ * output gate. The prompt + gate already block this upstream; the
77
+ * render guards against the rare case where a legacy model release
78
+ * sneaks the intro past both layers.
79
+ *
80
+ * The strip is intentionally conservative — we only remove the
81
+ * canonical phrasings the prompt teaches Mira to emit. Any other
82
+ * prose containing the word "Pugi" passes through unchanged.
83
+ *
84
+ * Exported for spec.
85
+ */
86
+ export function stripLeadingIdentityIntro(text) {
87
+ if (!text || text.length === 0)
88
+ return text;
89
+ // Try each canonical intro shape in turn (RU + EN). We strip only
90
+ // the LEADING occurrence; a mid-body mention is intentional citation
91
+ // (e.g. "the Pugi codename..." in an explainer turn).
92
+ const introPatterns = [
93
+ // RU canonical — both masculine ("напарник/напарника/напарнику") and
94
+ // feminine ("напарница/напарницей/напарнице") declensions land on this
95
+ // strip. The prior pattern `напарни[ккк][аеу]?` was a character-class
96
+ // typo (three `к`s collapse to one) and accepted no feminine form, so
97
+ // a Mira reply opening "Я Pugi — твоя инженерная напарница…" leaked
98
+ // past the strip. P1 reviewer fix PR #540 (2026-05-27).
99
+ /^(?:Я\s+Pugi\s*[—–-]\s*тв(?:ой|ё|оя)\s+инженерн[аыяий][хйеомя]*\s+напарни(?:к[аеу]?|ц(?:ей|а|ы|е|у))[.,!]?\s*)/u,
100
+ /^(?:Я\s+Pugi\s*[—–-]\s*координатор[.,!]?\s*)/u,
101
+ // EN canonical
102
+ /^(?:I'?m\s+Pugi\s*[—–-]\s*your\s+engineering\s+copilot[.,!]?\s*)/iu,
103
+ /^(?:Pugi\s+here[.,!]?\s*)/iu,
104
+ /^(?:This\s+is\s+Pugi[.,!]?\s*)/iu,
105
+ ];
106
+ let out = text;
107
+ for (const re of introPatterns) {
108
+ out = out.replace(re, '');
109
+ }
110
+ // If the body had ONLY the intro phrase, return the original — never
111
+ // hand the operator an empty bubble. The output gate would have
112
+ // logged the verbosity hit; the operator sees the unmodified text.
113
+ if (out.trim().length === 0)
114
+ return text;
115
+ return out;
116
+ }
56
117
  /**
57
118
  * Cheap heuristic for "this transcript row will benefit from Markdown
58
119
  * rendering". We only pay the parser cost when the row plausibly
@@ -0,0 +1,31 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ /** Brand-voice palette for status cells. Mirrors the table chrome
4
+ * used by `<AgentProgressCard>` so the operator's eye trains on a
5
+ * consistent OK/WARN/ERROR/SKIPPED colour grammar. */
6
+ const STATUS_COLOR = {
7
+ ok: 'green',
8
+ warn: 'yellow',
9
+ error: 'red',
10
+ skipped: 'gray',
11
+ };
12
+ const OVERALL_COLOR = {
13
+ healthy: 'green',
14
+ warning: 'yellow',
15
+ error: 'red',
16
+ };
17
+ export function DoctorTable({ envelope }) {
18
+ const nameWidth = Math.max('NAME'.length, ...envelope.probes.map((row) => row.name.length));
19
+ const statusWidth = Math.max('STATUS'.length, ...envelope.probes.map((row) => row.status.length));
20
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, children: "Pugi Doctor" }) }), envelope.probes.map((row) => (_jsx(DoctorRow, { row: row, nameWidth: nameWidth, statusWidth: statusWidth }, row.name))), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { children: [envelope.counts.error, " error(s), ", envelope.counts.warn, " warning(s), ", envelope.counts.ok, " ok,", ' ', envelope.counts.skipped, " skipped. Overall:", ' ', _jsx(Text, { color: OVERALL_COLOR[envelope.overall], bold: true, children: envelope.overall.toUpperCase() })] }) }), _jsx(Box, { children: _jsxs(Text, { dimColor: true, children: ["CLI ", envelope.meta.cliVersion, " Node ", envelope.meta.nodeVersion, " cwd ", envelope.meta.cwd] }) })] }));
21
+ }
22
+ function DoctorRow({ row, nameWidth, statusWidth }) {
23
+ const namePart = row.name.padEnd(nameWidth, ' ');
24
+ const statusPart = row.status.toUpperCase().padEnd(statusWidth, ' ');
25
+ const latencyPart = typeof row.latencyMs === 'number' ? ` (${row.latencyMs}ms)` : '';
26
+ const showRemediation = typeof row.remediation === 'string' &&
27
+ row.remediation.length > 0 &&
28
+ (row.status === 'warn' || row.status === 'error');
29
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsxs(Text, { children: [namePart, " "] }), _jsx(Text, { color: STATUS_COLOR[row.status], bold: true, children: statusPart }), _jsxs(Text, { children: [' ', row.detail, latencyPart] })] }), showRemediation && (_jsx(Box, { marginLeft: nameWidth + statusWidth + 4, children: _jsxs(Text, { dimColor: true, children: ["\u2192 ", row.remediation] }) }))] }));
30
+ }
31
+ //# sourceMappingURL=doctor-table.js.map
@@ -64,6 +64,13 @@ function toolDisplayName(tool) {
64
64
  switch (tool) {
65
65
  case 'read':
66
66
  return 'Read';
67
+ case 'write':
68
+ // 2026-05-27 — Write is the most operator-visible tool for the
69
+ // codegen-dispatch surface (Hiroshi writing index.html / style.css
70
+ // / script.js for a tic-tac-toe brief). Add the display name so
71
+ // the tool stream pane renders ✓ Write(index.html) instead of an
72
+ // unlabeled placeholder. Mirrors the Claude Code Write rendering.
73
+ return 'Write';
67
74
  case 'edit':
68
75
  return 'Edit';
69
76
  case 'bash':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pugi/cli",
3
- "version": "0.1.0-beta.16",
3
+ "version": "0.1.0-beta.18",
4
4
  "description": "Pugi CLI - terminal-native software execution system",
5
5
  "homepage": "https://pugi.io",
6
6
  "repository": {
@@ -54,7 +54,7 @@
54
54
  "undici": "^8.3.0",
55
55
  "zod": "^3.23.0",
56
56
  "@pugi/personas": "0.1.2",
57
- "@pugi/sdk": "0.1.0-beta.16"
57
+ "@pugi/sdk": "0.1.0-beta.18"
58
58
  },
59
59
  "devDependencies": {
60
60
  "@types/node": "^22.0.0",