@pugi/cli 0.1.0-beta.17 → 0.1.0-beta.19
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/core/compact/auto-trigger.js +96 -0
- package/dist/core/compact/buffer-rewriter.js +115 -0
- package/dist/core/compact/summarizer.js +196 -0
- package/dist/core/compact/token-counter.js +108 -0
- package/dist/core/denial-tracking/index.js +8 -0
- package/dist/core/denial-tracking/state.js +264 -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/denial-tracking.js +57 -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/status-snapshot.js +442 -0
- package/dist/core/diagnostics/probes/workspace.js +63 -0
- package/dist/core/diagnostics/types.js +70 -0
- package/dist/core/engine/native-pugi.js +20 -0
- package/dist/core/engine/strip-internal-fields.js +124 -0
- package/dist/core/engine/tool-bridge.js +251 -49
- 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/permissions/gate.js +187 -0
- package/dist/core/permissions/index.js +18 -0
- package/dist/core/permissions/mode.js +102 -0
- package/dist/core/permissions/state.js +160 -0
- package/dist/core/permissions/tool-class.js +93 -0
- package/dist/core/repl/session.js +261 -9
- package/dist/core/repl/slash-commands.js +67 -4
- package/dist/runtime/cli.js +153 -58
- package/dist/runtime/commands/compact.js +296 -0
- package/dist/runtime/commands/doctor.js +369 -0
- package/dist/runtime/commands/mcp.js +290 -3
- package/dist/runtime/commands/permissions.js +87 -0
- package/dist/runtime/commands/status.js +178 -0
- 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 +57 -14
- package/dist/tools/registry.js +7 -0
- package/dist/tui/ask-user-question-prompt.js +192 -0
- package/dist/tui/compact-banner.js +54 -0
- package/dist/tui/conversation-pane.js +68 -7
- package/dist/tui/doctor-table.js +31 -0
- package/dist/tui/status-table.js +7 -0
- package/package.json +2 -2
|
@@ -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
|
package/dist/tools/file-tools.js
CHANGED
|
@@ -27,11 +27,11 @@
|
|
|
27
27
|
* sibling commits close.
|
|
28
28
|
*/
|
|
29
29
|
import { spawnSync } from 'node:child_process';
|
|
30
|
-
import { existsSync, readFileSync, realpathSync, renameSync, writeFileSync } from 'node:fs';
|
|
30
|
+
import { existsSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync } from 'node:fs';
|
|
31
31
|
import { dirname, isAbsolute, relative } from 'node:path';
|
|
32
32
|
import { globSync } from 'node:fs';
|
|
33
33
|
import { decidePermission } from '../core/permission.js';
|
|
34
|
-
import { createReadRecord, hashContent } from '../core/file-cache.js';
|
|
34
|
+
import { StaleReadError, createReadRecord, hashContent, } from '../core/file-cache.js';
|
|
35
35
|
import { resolveWorkspacePath } from '../core/path-security.js';
|
|
36
36
|
import { recordFileMutation, recordToolCall, recordToolResult } from '../core/session.js';
|
|
37
37
|
/**
|
|
@@ -47,6 +47,11 @@ export class OperatorAbortedError extends Error {
|
|
|
47
47
|
this.name = 'OperatorAbortedError';
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
|
+
// Re-export StaleReadError so tool-bridge / test consumers can import
|
|
51
|
+
// the typed error from a single file-tools surface alongside
|
|
52
|
+
// OperatorAbortedError. Same shape as the existing OperatorAbortedError
|
|
53
|
+
// re-surface pattern.
|
|
54
|
+
export { StaleReadError } from '../core/file-cache.js';
|
|
50
55
|
/**
|
|
51
56
|
* α6.9 WriteGate: refuse the tool dispatch when the active
|
|
52
57
|
* cancellation token has aborted. Idempotent (the token's `isAborted`
|
|
@@ -152,10 +157,37 @@ export function writeTool(ctx, path, content) {
|
|
|
152
157
|
throw error;
|
|
153
158
|
}
|
|
154
159
|
const existed = existsSync(resolved);
|
|
155
|
-
|
|
160
|
+
// Leak L1 stale-read gate for writeTool's update-existing path. The
|
|
161
|
+
// model uses writeTool for two distinct intents:
|
|
162
|
+
//
|
|
163
|
+
// - create-new: path does not exist on disk. There is no prior
|
|
164
|
+
// read to validate against; skip the gate. This is the
|
|
165
|
+
// intentional escape hatch the leak spec also calls out.
|
|
166
|
+
// - overwrite-existing: path exists. Without the gate the model
|
|
167
|
+
// could blind-clobber an externally-modified file, losing the
|
|
168
|
+
// concurrent change silently. Force the model to re-read first.
|
|
169
|
+
//
|
|
170
|
+
// We deliberately apply the SAME stale-validation primitive editTool
|
|
171
|
+
// uses so the two write surfaces stay symmetric and a future fix to
|
|
172
|
+
// either one cannot accidentally weaken the other.
|
|
173
|
+
let before;
|
|
174
|
+
if (existed) {
|
|
175
|
+
before = readFileSync(resolved, 'utf8');
|
|
176
|
+
const currentStat = statSync(resolved);
|
|
177
|
+
const validation = ctx.readCache.validate(ctx.root, path, currentStat.mtimeMs, before);
|
|
178
|
+
if (validation.stale) {
|
|
179
|
+
const reason = `stale_read: write ${path} refused — ${validation.detail}`;
|
|
180
|
+
recordToolResult(ctx.session, toolCallId, 'error', reason);
|
|
181
|
+
throw new StaleReadError(path, validation.reason, validation.detail);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
156
184
|
const tmp = `${resolved}.pugi-tmp-${Date.now()}`;
|
|
157
185
|
writeFileSync(tmp, content, { encoding: 'utf8', mode: 0o600 });
|
|
158
186
|
renameSync(tmp, resolved);
|
|
187
|
+
// Refresh the cache with the post-write content so the model can
|
|
188
|
+
// chain a follow-up read+edit on the same file without an extra
|
|
189
|
+
// round-trip. Same pattern editTool uses below.
|
|
190
|
+
ctx.readCache.set(createReadRecord(ctx.root, path, content, 'read_tool'));
|
|
159
191
|
recordFileMutation(ctx.session, {
|
|
160
192
|
toolCallId,
|
|
161
193
|
path,
|
|
@@ -182,10 +214,6 @@ export function editTool(ctx, path, oldString, newString) {
|
|
|
182
214
|
recordToolResult(ctx.session, toolCallId, 'error', reason);
|
|
183
215
|
throw new Error(reason);
|
|
184
216
|
}
|
|
185
|
-
const readRecord = ctx.readCache.get(ctx.root, path);
|
|
186
|
-
if (!readRecord) {
|
|
187
|
-
throw new Error(`Cannot edit ${path}: file must be read first`);
|
|
188
|
-
}
|
|
189
217
|
let resolved;
|
|
190
218
|
try {
|
|
191
219
|
resolved = permissionGatedResolve(ctx, path, 'edit', 'edit');
|
|
@@ -195,16 +223,31 @@ export function editTool(ctx, path, oldString, newString) {
|
|
|
195
223
|
recordToolResult(ctx.session, toolCallId, 'error', reason);
|
|
196
224
|
throw error;
|
|
197
225
|
}
|
|
226
|
+
// Leak L1 stale-read gate. Validate the model's read-time view of
|
|
227
|
+
// the file against the on-disk state BEFORE applying the mutation.
|
|
228
|
+
// We read disk content once and feed it to the validator so a single
|
|
229
|
+
// syscall covers both the gate decision AND the oldString/newString
|
|
230
|
+
// replacement below.
|
|
198
231
|
const before = readFileSync(resolved, 'utf8');
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
232
|
+
const currentStat = statSync(resolved);
|
|
233
|
+
const validation = ctx.readCache.validate(ctx.root, path, currentStat.mtimeMs, before);
|
|
234
|
+
if (validation.stale) {
|
|
235
|
+
const reason = `stale_read: edit ${path} refused — ${validation.detail}`;
|
|
236
|
+
recordToolResult(ctx.session, toolCallId, 'error', reason);
|
|
237
|
+
throw new StaleReadError(path, validation.reason, validation.detail);
|
|
202
238
|
}
|
|
239
|
+
const currentHash = hashContent(before);
|
|
203
240
|
const matches = before.split(oldString).length - 1;
|
|
204
|
-
if (matches === 0)
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
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
|
+
}
|
|
208
251
|
const after = before.replace(oldString, newString);
|
|
209
252
|
const tmp = `${resolved}.pugi-tmp-${Date.now()}`;
|
|
210
253
|
writeFileSync(tmp, after, { encoding: 'utf8', mode: 0o600 });
|
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,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
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* Visible boundary marker for the conversation pane.
|
|
4
|
+
*
|
|
5
|
+
* Rendered inline in the transcript when the replay walker hits a
|
|
6
|
+
* `compaction` event. Conveys three facts at a glance:
|
|
7
|
+
*
|
|
8
|
+
* 1. A compaction happened (separator line).
|
|
9
|
+
* 2. How many turns were folded into the summary.
|
|
10
|
+
* 3. Whether it was manual or auto-triggered.
|
|
11
|
+
*
|
|
12
|
+
* The separator line uses U+2500 (BOX DRAWINGS LIGHT HORIZONTAL) so
|
|
13
|
+
* the visual weight matches the rest of the Ink chrome. Dim ink-color
|
|
14
|
+
* `gray` keeps the marker subdued — it is a navigation aid, not the
|
|
15
|
+
* conversation itself.
|
|
16
|
+
*
|
|
17
|
+
* Width-aware: when stdout columns are known we render the dashes to
|
|
18
|
+
* fill the line minus the centred label; on unknown width we fall
|
|
19
|
+
* back to a fixed 64-dash pad. The fallback is wide enough for any
|
|
20
|
+
* realistic terminal and narrow enough to not wrap on small ones.
|
|
21
|
+
*/
|
|
22
|
+
import { Box, Text } from 'ink';
|
|
23
|
+
const FALLBACK_COLUMNS = 80;
|
|
24
|
+
/**
|
|
25
|
+
* Render the boundary line. The wrapping `<Box>` keeps the marker on
|
|
26
|
+
* its own row even when the surrounding flex container packs siblings.
|
|
27
|
+
*/
|
|
28
|
+
export function CompactBanner(props) {
|
|
29
|
+
const columns = props.columns && props.columns > 20 ? props.columns : FALLBACK_COLUMNS;
|
|
30
|
+
const label = buildLabel(props);
|
|
31
|
+
const dashesEach = Math.max(3, Math.floor((columns - label.length - 2) / 2));
|
|
32
|
+
const dashes = '─'.repeat(dashesEach);
|
|
33
|
+
return (_jsx(Box, { children: _jsx(Text, { color: "gray", children: `${dashes} ${label} ${dashes}` }) }));
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Build the centred label. Examples:
|
|
37
|
+
* "context compacted (12 turns → 1 summary, auto)"
|
|
38
|
+
* "context compacted (4 turns → 1 summary, manual · ~3.2k tokens)"
|
|
39
|
+
*/
|
|
40
|
+
export function buildLabel(props) {
|
|
41
|
+
const trigger = props.trigger === 'auto' ? 'auto' : 'manual';
|
|
42
|
+
const turns = `${props.turnsBefore} ${props.turnsBefore === 1 ? 'turn' : 'turns'}`;
|
|
43
|
+
const tokens = props.summaryTokenCount && props.summaryTokenCount > 0
|
|
44
|
+
? ` · ~${formatTokens(props.summaryTokenCount)} tokens`
|
|
45
|
+
: '';
|
|
46
|
+
return `context compacted (${turns} → 1 summary, ${trigger}${tokens})`;
|
|
47
|
+
}
|
|
48
|
+
/** Format token counts like 1234 → 1.2k, 950 → 950. */
|
|
49
|
+
function formatTokens(n) {
|
|
50
|
+
if (n < 1000)
|
|
51
|
+
return `${n}`;
|
|
52
|
+
return `${(n / 1000).toFixed(1)}k`;
|
|
53
|
+
}
|
|
54
|
+
//# sourceMappingURL=compact-banner.js.map
|