@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.
- package/dist/commands/jobs-watch.js +201 -0
- package/dist/commands/jobs.js +15 -0
- package/dist/core/agent-progress/cleanup.js +134 -0
- package/dist/core/agent-progress/schema.js +144 -0
- package/dist/core/agent-progress/writer.js +101 -0
- package/dist/core/diagnostics/probe-runner.js +93 -0
- package/dist/core/diagnostics/probes/api.js +46 -0
- package/dist/core/diagnostics/probes/auth.js +86 -0
- package/dist/core/diagnostics/probes/cli-version.js +127 -0
- package/dist/core/diagnostics/probes/config.js +72 -0
- package/dist/core/diagnostics/probes/disk.js +81 -0
- package/dist/core/diagnostics/probes/git.js +65 -0
- package/dist/core/diagnostics/probes/mcp.js +75 -0
- package/dist/core/diagnostics/probes/node.js +59 -0
- package/dist/core/diagnostics/probes/pnpm.js +36 -0
- package/dist/core/diagnostics/probes/session.js +74 -0
- package/dist/core/diagnostics/probes/workspace.js +63 -0
- package/dist/core/diagnostics/types.js +70 -0
- package/dist/core/engine/strip-internal-fields.js +124 -0
- package/dist/core/engine/tool-bridge.js +96 -27
- package/dist/core/file-cache.js +113 -1
- package/dist/core/mcp/client.js +66 -6
- package/dist/core/mcp/registry.js +24 -2
- package/dist/core/repl/session.js +64 -5
- package/dist/core/repl/slash-commands.js +9 -0
- package/dist/runtime/cli.js +153 -64
- package/dist/runtime/commands/doctor.js +357 -0
- package/dist/runtime/commands/mcp.js +290 -3
- package/dist/runtime/version.js +1 -1
- package/dist/tools/agent-tool.js +18 -4
- package/dist/tools/ask-user-question.js +213 -0
- package/dist/tools/file-tools.js +85 -14
- package/dist/tools/registry.js +7 -0
- package/dist/tui/agent-progress-card.js +111 -0
- package/dist/tui/ask-user-question-prompt.js +192 -0
- package/dist/tui/conversation-pane.js +68 -7
- package/dist/tui/doctor-table.js +31 -0
- package/dist/tui/tool-stream-pane.js +7 -0
- package/package.json +2 -2
package/dist/tools/file-tools.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
throw new Error(
|
|
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 });
|
package/dist/tools/registry.js
CHANGED
|
@@ -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
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
|
|
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.
|
|
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.
|
|
57
|
+
"@pugi/sdk": "0.1.0-beta.18"
|
|
58
58
|
},
|
|
59
59
|
"devDependencies": {
|
|
60
60
|
"@types/node": "^22.0.0",
|