@cspeach/cli 1.0.0 → 1.1.1
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/agent/loop.js +22 -9
- package/dist/approvals/op-labels.js +124 -0
- package/dist/approvals/render.js +42 -36
- package/dist/cli.js +15 -0
- package/dist/commands/compact.js +28 -2
- package/dist/commands/config-set.js +189 -0
- package/dist/commands/config-show.js +20 -0
- package/dist/commands/export-audit.js +43 -0
- package/dist/commands/help.js +5 -0
- package/dist/commands/plan-audit-evidence.js +266 -0
- package/dist/commands/plan-audit.js +692 -0
- package/dist/commands/plan-chain.js +671 -0
- package/dist/commands/plan-continue.js +179 -0
- package/dist/commands/plan-gate.js +154 -0
- package/dist/commands/plan-resume.js +588 -33
- package/dist/config/loader.js +128 -4
- package/dist/config/model-defaults.js +14 -0
- package/dist/cost/pricing.js +27 -1
- package/dist/doctor/checks/system-roles.js +41 -0
- package/dist/doctor/run.js +2 -0
- package/dist/models/resolve.js +61 -0
- package/dist/models/server-config.js +155 -0
- package/dist/one-shot.js +25 -3
- package/dist/projects/extract-cca.js +3 -1
- package/dist/projects/extract-modernize.js +3 -1
- package/dist/projects/extract-plan.js +60 -6
- package/dist/projects/extract-test-coverage.js +3 -1
- package/dist/projects/extract-upgrade.js +3 -1
- package/dist/projects/handover-md.js +195 -0
- package/dist/projects/index.js +1 -1
- package/dist/projects/plan-run.js +137 -13
- package/dist/projects/plan-schema.js +73 -0
- package/dist/projects/run-lease.js +157 -0
- package/dist/projects/save-command.js +26 -15
- package/dist/renderer/status-footer.js +22 -12
- package/dist/renderer/thinking-heartbeat.js +64 -8
- package/dist/renderer/todo-block.js +51 -0
- package/dist/renderer/tool-widget.js +37 -0
- package/dist/repl/bracketed-paste.js +28 -19
- package/dist/repl/builtin-commands.js +5 -0
- package/dist/repl/current-transport.js +10 -0
- package/dist/repl/history.js +86 -0
- package/dist/repl/ink-stdin-guard.js +64 -0
- package/dist/repl/mode-ceiling.js +16 -0
- package/dist/repl/mode-cycle.js +104 -0
- package/dist/repl/post-turn-status.js +24 -4
- package/dist/repl/slash-completer.js +5 -0
- package/dist/repl.js +954 -83
- package/dist/rewind/candidates.js +194 -0
- package/dist/rewind/cli.js +137 -0
- package/dist/rewind/format.js +27 -0
- package/dist/rewind/restore.js +245 -0
- package/dist/session/audit-export.js +459 -0
- package/dist/session/context-report.js +163 -0
- package/dist/session/recap.js +160 -0
- package/dist/skill-catalog.js +9 -3
- package/dist/skills/bundled-skills.js +71 -78
- package/dist/tools/approval.js +115 -7
- package/dist/tools/ask-question.js +304 -3
- package/dist/tools/extend-model/anchored-insert.js +604 -0
- package/dist/tools/extend-model/tool.js +162 -10
- package/dist/tools/fiori/fe-extend.js +76 -0
- package/dist/tools/fiori/fe-scaffold.js +29 -3
- package/dist/tools/fiori/floorplan-map.js +19 -0
- package/dist/tools/fiori/samples/data/index.json +13602 -0
- package/dist/tools/fiori/samples/data/sources.generated.js +808 -0
- package/dist/tools/fiori/samples/loader.js +248 -0
- package/dist/tools/fiori/samples/search.js +63 -0
- package/dist/tools/fiori/samples/types.js +2 -0
- package/dist/tools/fiori/smoke/assertions.js +74 -0
- package/dist/tools/fiori/smoke/browser.js +52 -0
- package/dist/tools/fiori/smoke/driver.js +89 -0
- package/dist/tools/fiori/smoke/freestyle-spec.js +317 -0
- package/dist/tools/fiori/smoke/run-smoke.js +149 -0
- package/dist/tools/fiori/tools.js +328 -3
- package/dist/tools/local-build.js +11 -1
- package/dist/tools/sap-read.js +79 -11
- package/dist/tools/sap-write.js +24 -4
- package/dist/tools/snapshot.js +27 -1
- package/dist/tools/subagent/agent_run.js +27 -3
- package/dist/tools/todo.js +144 -0
- package/dist/ui/app.js +372 -19
- package/dist/ui/approval-modal.js +49 -16
- package/dist/ui/ask-question-emitter.js +14 -0
- package/dist/ui/context-grid.js +108 -0
- package/dist/ui/footer.js +109 -30
- package/dist/ui/header.js +7 -0
- package/dist/ui/line-resolution.js +18 -2
- package/dist/ui/rewind-emitter.js +10 -0
- package/dist/ui/rewind-panel.js +81 -0
- package/dist/ui/sap-state-store.js +1 -0
- package/dist/ui/status-line.js +43 -0
- package/dist/ui/text-input.js +72 -8
- package/dist/ui/todo-emitter.js +25 -0
- package/dist/ui/todo-panel.js +64 -0
- package/dist/ui/turn-status-emitter.js +50 -4
- package/dist/ui/turn-status.js +18 -3
- package/dist/ui/widgets/ask-form.js +242 -0
- package/dist/ui/widgets/ask-question-modal.js +17 -7
- package/package.json +4 -1
package/dist/tools/approval.js
CHANGED
|
@@ -10,6 +10,10 @@ import { maybeShowAutoApproveNag } from '../approvals/approval-prompt.js';
|
|
|
10
10
|
import { renderAdvisoryProposal, } from '../approvals/advisory-render.js';
|
|
11
11
|
import { promptAdvisory } from '../approvals/advisory-prompt.js';
|
|
12
12
|
import { markPlanGateApproved } from '../repl/rule8-detector.js';
|
|
13
|
+
import { getEffectiveWriteMode } from '../repl/mode-cycle.js';
|
|
14
|
+
import { getCurrentTransport } from '../repl/current-transport.js';
|
|
15
|
+
import { displayTransport } from '../approvals/op-labels.js';
|
|
16
|
+
import { isGuardedRunActive, getCurrentPhaseWrites, notePlanDeviation, PLAN_DEVIATION_DETAIL, } from '../commands/plan-gate.js';
|
|
13
17
|
/**
|
|
14
18
|
* Advisory-only replacement for the per-change approval gauntlet.
|
|
15
19
|
*
|
|
@@ -65,6 +69,42 @@ export async function handleAdvisoryApproval(args, _ctx) {
|
|
|
65
69
|
}),
|
|
66
70
|
};
|
|
67
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* §6 escalation exception — the infra base-object TYPE CODES a no-write/design
|
|
74
|
+
* phase (writes:false) may self-create under abap-plan rule 4a. These are the
|
|
75
|
+
* ADT type codes the model puts in `request_approval` `changes[].type`:
|
|
76
|
+
* - DEVC — package (sap_create_object type DEVC)
|
|
77
|
+
* - CTS — transport (sap_transport_create surfaces as {op:'create',type:'CTS'})
|
|
78
|
+
*
|
|
79
|
+
* §6's third member — number range — is intentionally ABSENT: the
|
|
80
|
+
* sap_number_range_intervals tool is a STUB (registered as "do not call", no
|
|
81
|
+
* ADT write), so a number-range op never reaches request_approval. Adding a
|
|
82
|
+
* marker for it would be dead code. Add its type code here if/when that tool
|
|
83
|
+
* ships and starts flowing through the approval gate.
|
|
84
|
+
*
|
|
85
|
+
* DUPLICATION NOTE (deliberate, for a follow-up): the AUDIT side enforces the
|
|
86
|
+
* SAME set in plan-audit.ts (PLAN_AUDIT_PROMPT_CONTRACT §6) — but as PROSE in
|
|
87
|
+
* an LLM prompt ("DEVC (package), transport, or number range"), not code. There
|
|
88
|
+
* is no clean seam to share a predicate because one enforcement layer is a
|
|
89
|
+
* natural-language contract and the other is TypeScript. The two must be kept
|
|
90
|
+
* in sync by hand; this comment and the audit prose both name the same set.
|
|
91
|
+
*/
|
|
92
|
+
const INFRA_BASE_OBJECT_TYPES = new Set(['DEVC', 'CTS']);
|
|
93
|
+
/**
|
|
94
|
+
* TRUE iff EVERY change is a pure infra base-object CREATE — the sanctioned 4a
|
|
95
|
+
* self-create escalation (§6/§7.2). Tightness is the safety property: a single
|
|
96
|
+
* non-infra change (a CLAS/DDLS/… deliverable), any non-create op, or an empty
|
|
97
|
+
* set makes this FALSE, so the writes:false backstop denies it (the correct
|
|
98
|
+
* out-of-lane B3 case). Type is normalized (trim + upper) to match how the
|
|
99
|
+
* model may spell it; op is restricted to 'create' because 4a sanctions
|
|
100
|
+
* self-CREATE only (a transport release / package delete is not an escalation).
|
|
101
|
+
*/
|
|
102
|
+
function isPureInfraEscalation(changes) {
|
|
103
|
+
return (!!changes &&
|
|
104
|
+
changes.length > 0 &&
|
|
105
|
+
changes.every((c) => c.op === 'create' &&
|
|
106
|
+
INFRA_BASE_OBJECT_TYPES.has((c.type ?? '').trim().toUpperCase())));
|
|
107
|
+
}
|
|
68
108
|
registerTool({
|
|
69
109
|
name: 'request_approval',
|
|
70
110
|
description: 'Request user approval for one or more SAP mutations. Returns approval_ids (one per change) to be passed as approval_id on matching mutating tool calls.',
|
|
@@ -83,7 +123,8 @@ registerTool({
|
|
|
83
123
|
op: { type: 'string', enum: ['create', 'modify', 'delete', 'activate', 'release'] },
|
|
84
124
|
object: { type: 'string' },
|
|
85
125
|
type: { type: 'string' },
|
|
86
|
-
diff: { type: 'string', description: '
|
|
126
|
+
diff: { type: 'string', description: 'For a CREATE op this MUST be the exact, complete source that will be written — verbatim, character-for-character, NOT a description, summary, or paraphrase. It is shown to the developer in the approval box as the exact thing they are approving, so a one-line summary here means they approve a write they cannot inspect. For a MODIFY op, the real unified diff of the change (again verbatim, not a description).' },
|
|
127
|
+
package: { type: 'string', description: 'Target package (development class) for the object, if known — shown on the approval box' },
|
|
87
128
|
},
|
|
88
129
|
required: ['op', 'object', 'type'],
|
|
89
130
|
},
|
|
@@ -92,11 +133,52 @@ registerTool({
|
|
|
92
133
|
required: ['summary', 'risk', 'changes'],
|
|
93
134
|
},
|
|
94
135
|
handler: async (args, ctx) => {
|
|
136
|
+
// Task 10 (agentic-flow, 2026-07-03) — deviation backstop, BEFORE the
|
|
137
|
+
// approval flow renders anything (including the advisory prompt, which
|
|
138
|
+
// would hang an unattended chain just the same). In guarded mode the
|
|
139
|
+
// write-phase stops are computed from the plan's DECLARED writes field;
|
|
140
|
+
// a phase that declared writes:false and requests a write approval
|
|
141
|
+
// anyway is a plan deviation: DENY without prompting, raise the flag
|
|
142
|
+
// the post-turn chain (plan-chain.ts) turns into blocked + STOPPED.
|
|
143
|
+
// Same error mechanism as the headless fail-fast below: a structured
|
|
144
|
+
// error RESULT so the model wraps up gracefully instead of writing.
|
|
145
|
+
// Guarded-gated: step mode / outside phases are byte-identical.
|
|
146
|
+
//
|
|
147
|
+
// §6/§7.2 escalation exception (audit redesign, Task 4): a PURE infra
|
|
148
|
+
// base-object create (DEVC / transport) under writes:false is the
|
|
149
|
+
// sanctioned rule-4a self-create. The very act of routing through
|
|
150
|
+
// request_approval IS the user-consent gate, so it must NOT be denied here
|
|
151
|
+
// — fall through to the normal approval flow (which prompts the user, whose
|
|
152
|
+
// approval is the 4a consent). Guarded-mode clients could otherwise never
|
|
153
|
+
// run 4a: this backstop blocks it BEFORE the audit even runs. TIGHT: the
|
|
154
|
+
// exception fires only when EVERY change is infra; a deliverable
|
|
155
|
+
// (CLAS/DDLS/…) or a mixed set still denies (correct out-of-lane B3).
|
|
156
|
+
if (isGuardedRunActive() &&
|
|
157
|
+
getCurrentPhaseWrites() === false &&
|
|
158
|
+
!isPureInfraEscalation(args.changes)) {
|
|
159
|
+
const detail = PLAN_DEVIATION_DETAIL;
|
|
160
|
+
notePlanDeviation(detail);
|
|
161
|
+
console.error(chalk.red('plan deviation: this phase declared writes:false but requested a write approval — ' +
|
|
162
|
+
'the write is denied and the guarded chain will stop.'));
|
|
163
|
+
return {
|
|
164
|
+
content: JSON.stringify({
|
|
165
|
+
error: 'plan_deviation',
|
|
166
|
+
detail,
|
|
167
|
+
}),
|
|
168
|
+
is_error: true,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
95
171
|
let cfg = await loadConfig();
|
|
172
|
+
// Task 2 (ux-wave1) — the approval flow reads the EFFECTIVE write mode:
|
|
173
|
+
// config overlaid by the session override, clamped to the active alias's
|
|
174
|
+
// role ceiling (prd pins advisory-only; qas caps at approval-gated). With
|
|
175
|
+
// no role configured and no session override this is exactly
|
|
176
|
+
// cfg.write_mode — byte-identical behavior.
|
|
177
|
+
const role = cfg.sap[ctx.sapAlias]?.role;
|
|
96
178
|
// Advisory-only short-circuit — never mint approval_ids.
|
|
97
179
|
// The developer applies the change manually in ADT; the AI gets an
|
|
98
180
|
// ai_directive telling it not to attempt any SAP write tools.
|
|
99
|
-
if (cfg.write_mode === 'advisory-only') {
|
|
181
|
+
if (getEffectiveWriteMode(cfg.write_mode, role) === 'advisory-only') {
|
|
100
182
|
return handleAdvisoryApproval(args, { sapAlias: ctx.sapAlias });
|
|
101
183
|
}
|
|
102
184
|
let sapCfg = cfg.sap[ctx.sapAlias];
|
|
@@ -107,10 +189,36 @@ registerTool({
|
|
|
107
189
|
const changes = args.changes;
|
|
108
190
|
const declared = args.risk;
|
|
109
191
|
const eff = effectiveRisk(declared, changes, riskCtx);
|
|
192
|
+
// Transport shown on the approval surfaces — resolved PER OP to mirror
|
|
193
|
+
// what the write tools will actually do (I2, 2026-07-05 review):
|
|
194
|
+
// create-family tools fall back to the session transport, so the box may
|
|
195
|
+
// show it; modify/delete tools do NOT (resolveWriteTransport uses args
|
|
196
|
+
// only + owning-TR override), so those boxes show only an explicit
|
|
197
|
+
// request transport; activate/release take no transport at all. See
|
|
198
|
+
// displayTransport in approvals/op-labels.ts. The plan gate is a batch
|
|
199
|
+
// summary: session fallback applies only when the batch contains a
|
|
200
|
+
// create (the only op it is true for).
|
|
201
|
+
const sessionTransport = getCurrentTransport();
|
|
202
|
+
const planGateTransport = args.transport
|
|
203
|
+
?? (changes.some((c) => c.op === 'create') ? sessionTransport ?? undefined : undefined);
|
|
204
|
+
// Task 6 (agentic-flow, 2026-07-03) — guarded plan chains disable
|
|
205
|
+
// auto-approve entirely: the user's auto_approve consent was given for
|
|
206
|
+
// hand-driven turns, not for phases the harness auto-dispatched. When the
|
|
207
|
+
// flag is active, skip the nag (it only advertises auto-approve) and fall
|
|
208
|
+
// through to the interactive prompts below. Flag off ⇒ this whole block
|
|
209
|
+
// is a no-op and behaviour stays byte-identical (skill-mode compat).
|
|
210
|
+
const guardedRun = isGuardedRunActive();
|
|
211
|
+
// Task 3 (ux-wave1) — qas systems never auto-approve: the role's promise
|
|
212
|
+
// is "no unattended writes", and a pre-configured auto_approve would mint
|
|
213
|
+
// silently. Treat auto_approve as 'never' on a qas alias — composes with
|
|
214
|
+
// the guarded-run skip above, same skip points, ladder order unchanged.
|
|
215
|
+
// (prd needs nothing here: the advisory clamp already prevents minting.)
|
|
216
|
+
const qasNoAutoApprove = role === 'qas';
|
|
110
217
|
// Progressive-disclosure: offer to upgrade never→low on first low-risk
|
|
111
218
|
// encounter. B5: skipped in headless — the nag's raw-mode keypress wait
|
|
112
|
-
// would hang on piped/closed stdin just like any other prompt.
|
|
113
|
-
|
|
219
|
+
// would hang on piped/closed stdin just like any other prompt. Skipped on
|
|
220
|
+
// qas too: the nag only advertises auto-approve, which qas disables.
|
|
221
|
+
if (!isHeadless() && !guardedRun && !qasNoAutoApprove) {
|
|
114
222
|
await maybeShowAutoApproveNag({ sapAlias: ctx.sapAlias, effectiveRisk: eff.level });
|
|
115
223
|
// Refresh cfg so this approval benefits from a just-enabled auto_approve.
|
|
116
224
|
cfg = await loadConfig();
|
|
@@ -118,7 +226,7 @@ registerTool({
|
|
|
118
226
|
riskCtx.autoApprovePolicy = sapCfg?.auto_approve ?? 'never';
|
|
119
227
|
}
|
|
120
228
|
// Auto-approve caps — never auto-approve at high, cap change count per risk level.
|
|
121
|
-
const autoApproveAllowed = ((sapCfg?.auto_approve === 'low' && eff.level === 'low' && changes.length <= 5) ||
|
|
229
|
+
const autoApproveAllowed = !guardedRun && !qasNoAutoApprove && ((sapCfg?.auto_approve === 'low' && eff.level === 'low' && changes.length <= 5) ||
|
|
122
230
|
(sapCfg?.auto_approve === 'medium' && (eff.level === 'low' || eff.level === 'medium') && changes.length <= 2));
|
|
123
231
|
if (autoApproveAllowed) {
|
|
124
232
|
const ids = [];
|
|
@@ -170,7 +278,7 @@ registerTool({
|
|
|
170
278
|
// gate. Cancel rejects the whole plan.
|
|
171
279
|
let skipPerChangeGates = false;
|
|
172
280
|
if (changes.length > 1) {
|
|
173
|
-
const mode = await renderPlanGate(args.summary, changes,
|
|
281
|
+
const mode = await renderPlanGate(args.summary, changes, planGateTransport, eff);
|
|
174
282
|
if (mode === 'cancel') {
|
|
175
283
|
return {
|
|
176
284
|
content: JSON.stringify({
|
|
@@ -223,7 +331,7 @@ registerTool({
|
|
|
223
331
|
outcome = { approved: false, reason: 'sibling_write_skipped' };
|
|
224
332
|
}
|
|
225
333
|
else {
|
|
226
|
-
outcome = await renderPerChangeApprovalV3(c, eff, args.transport);
|
|
334
|
+
outcome = await renderPerChangeApprovalV3(c, eff, displayTransport(c.op, args.transport, sessionTransport));
|
|
227
335
|
}
|
|
228
336
|
if (outcome.approved) {
|
|
229
337
|
if (c.op === 'modify' || c.op === 'create')
|
|
@@ -13,13 +13,18 @@
|
|
|
13
13
|
* structurally reliable: the API validates the input schema, no parsing
|
|
14
14
|
* needed, no streaming ambiguity.
|
|
15
15
|
*
|
|
16
|
-
* Renders:
|
|
16
|
+
* Renders (v1 single-question shape):
|
|
17
17
|
* - kind=text: prompt for free-form answer
|
|
18
18
|
* - kind=choice: arrow-key pick-one via inquirer select
|
|
19
19
|
* - kind=multi: space-to-toggle via inquirer checkbox
|
|
20
20
|
*
|
|
21
21
|
* Returns JSON `{ answer, id, kind, cancelled? }` so the LLM knows what
|
|
22
22
|
* it just got back and can cross-reference the question id.
|
|
23
|
+
*
|
|
24
|
+
* UX Wave 2 — v2 batched `questions` shape (up to 4 related questions in
|
|
25
|
+
* ONE call): Ink renders a single <AskForm> (Task 2); classic runs the
|
|
26
|
+
* questions as a sequential inquirer flow (Task 3). Both return
|
|
27
|
+
* `{ formId, answers: [{id, answer, custom?}], cancelled? }`.
|
|
23
28
|
*/
|
|
24
29
|
import chalk from 'chalk';
|
|
25
30
|
import { registerTool } from './index.js';
|
|
@@ -27,6 +32,92 @@ import { input, select, checkbox } from '@inquirer/prompts';
|
|
|
27
32
|
import { withInquirer } from '../repl/inquirer-guard.js';
|
|
28
33
|
import { shouldUseInk, isHeadless } from '../renderer/tty.js';
|
|
29
34
|
import { askQuestionEmitter } from '../ui/ask-question-emitter.js';
|
|
35
|
+
/** Chip labels in the v2 form are capped at 12 chars (brief-binding). */
|
|
36
|
+
const HEADER_MAX_CHARS = 12;
|
|
37
|
+
/**
|
|
38
|
+
* UX Wave 2 / Task 1 — validation failure for a v2 `questions` payload.
|
|
39
|
+
* The handler converts this into an error RESULT (`ask_form_invalid`),
|
|
40
|
+
* never a throw across the tool boundary.
|
|
41
|
+
*/
|
|
42
|
+
export class AskFormValidationError extends Error {
|
|
43
|
+
constructor(message) {
|
|
44
|
+
super(message);
|
|
45
|
+
this.name = 'AskFormValidationError';
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function normalizeOptions(raw) {
|
|
49
|
+
if (!Array.isArray(raw))
|
|
50
|
+
return [];
|
|
51
|
+
return raw.map((o) => {
|
|
52
|
+
const opt = { value: String(o.value), label: String(o.label) };
|
|
53
|
+
if (o.description != null)
|
|
54
|
+
opt.description = String(o.description);
|
|
55
|
+
if (o.recommended === true)
|
|
56
|
+
opt.recommended = true;
|
|
57
|
+
return opt;
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* UX Wave 2 / Task 1 — normalize EVERY ask_question call (v1 single-question
|
|
62
|
+
* or v2 `questions` batch) into the one internal AskFormRequest shape.
|
|
63
|
+
*
|
|
64
|
+
* v1 mapping: kind=multi → multiSelect; kind=text (or no choices) →
|
|
65
|
+
* freeText with empty options; header defaults to the question id.
|
|
66
|
+
* v2 mapping is 1:1; headers (explicit or defaulted from id) are truncated
|
|
67
|
+
* to 12 chars for the chip row.
|
|
68
|
+
*
|
|
69
|
+
* IMPORTANT: normalization does NOT decide routing. The handler routes
|
|
70
|
+
* v1-shaped ORIGINALS through the untouched v1 code paths (byte-compat by
|
|
71
|
+
* construction); only genuine v2 calls reach the form machinery.
|
|
72
|
+
*
|
|
73
|
+
* Throws AskFormValidationError on an invalid v2 payload (0 or >4
|
|
74
|
+
* questions, entries missing id/question) — the handler converts that to
|
|
75
|
+
* an `ask_form_invalid` error result. Exported for tests.
|
|
76
|
+
*/
|
|
77
|
+
export function normalizeAskInput(args) {
|
|
78
|
+
const header = (q) => String(q.header != null && String(q.header).length > 0 ? q.header : q.id)
|
|
79
|
+
.slice(0, HEADER_MAX_CHARS);
|
|
80
|
+
if (Array.isArray(args?.questions)) {
|
|
81
|
+
const raw = args.questions;
|
|
82
|
+
if (raw.length < 1 || raw.length > 4) {
|
|
83
|
+
throw new AskFormValidationError(`\`questions\` must contain 1 to 4 items (got ${raw.length}). ` +
|
|
84
|
+
'Never ask more than 4 at once — split into a follow-up call instead.');
|
|
85
|
+
}
|
|
86
|
+
const questions = raw.map((q, i) => {
|
|
87
|
+
if (q == null || typeof q !== 'object' || q.id == null || q.question == null) {
|
|
88
|
+
throw new AskFormValidationError(`questions[${i}] is invalid — each entry needs at least { id, question }.`);
|
|
89
|
+
}
|
|
90
|
+
const options = normalizeOptions(q.options);
|
|
91
|
+
return {
|
|
92
|
+
id: String(q.id),
|
|
93
|
+
question: String(q.question),
|
|
94
|
+
context: q.context != null ? String(q.context) : undefined,
|
|
95
|
+
header: header({ id: String(q.id), header: q.header }),
|
|
96
|
+
multiSelect: q.multiSelect === true,
|
|
97
|
+
freeText: options.length === 0,
|
|
98
|
+
options,
|
|
99
|
+
};
|
|
100
|
+
});
|
|
101
|
+
const formId = args.id != null ? String(args.id) : questions.map((q) => q.id).join('+');
|
|
102
|
+
return { formId, questions };
|
|
103
|
+
}
|
|
104
|
+
// v1 single-question shape → one-question form.
|
|
105
|
+
const id = String(args.id);
|
|
106
|
+
const kind = args.kind;
|
|
107
|
+
const options = kind === 'text' ? [] : normalizeOptions(args.choices);
|
|
108
|
+
return {
|
|
109
|
+
formId: id,
|
|
110
|
+
questions: [{
|
|
111
|
+
id,
|
|
112
|
+
question: String(args.question),
|
|
113
|
+
context: args.context != null ? String(args.context) : undefined,
|
|
114
|
+
header: header({ id, header: undefined }),
|
|
115
|
+
multiSelect: kind === 'multi',
|
|
116
|
+
freeText: kind === 'text' || options.length === 0,
|
|
117
|
+
options,
|
|
118
|
+
}],
|
|
119
|
+
};
|
|
120
|
+
}
|
|
30
121
|
/**
|
|
31
122
|
* Label heuristic for rule 2 of pickHeadlessChoices: a string counts as
|
|
32
123
|
* "recommended" only when it contains the word AND that word is not part of
|
|
@@ -76,7 +167,7 @@ export function pickHeadlessChoices(kind, choices) {
|
|
|
76
167
|
}
|
|
77
168
|
registerTool({
|
|
78
169
|
name: 'ask_question',
|
|
79
|
-
description: "Ask the user a clarifying question and get their answer. Use this for ANY user-decision point in a skill conversation — object names, design choices, approval checkpoints, etc. The CLI renders a formatted prompt, waits for the user's answer, and returns it as the tool result. Prefer this over emitting a text question — it is the only reliable mechanism for mid-turn user interaction.",
|
|
170
|
+
description: "Ask the user a clarifying question and get their answer. Use this for ANY user-decision point in a skill conversation — object names, design choices, approval checkpoints, etc. The CLI renders a formatted prompt, waits for the user's answer, and returns it as the tool result. Prefer this over emitting a text question — it is the only reliable mechanism for mid-turn user interaction. When you have MULTIPLE related questions, batch up to 4 into ONE call via `questions` — the user answers them in a single form. Never ask more than 4 at once; never split related questions across calls.",
|
|
80
171
|
isMutating: false,
|
|
81
172
|
input_schema: {
|
|
82
173
|
type: 'object',
|
|
@@ -114,10 +205,220 @@ registerTool({
|
|
|
114
205
|
required: ['value', 'label'],
|
|
115
206
|
},
|
|
116
207
|
},
|
|
208
|
+
questions: {
|
|
209
|
+
type: 'array',
|
|
210
|
+
minItems: 1,
|
|
211
|
+
maxItems: 4,
|
|
212
|
+
description: 'v2 batched form — ask up to 4 related questions in ONE call; the user answers them all in a single form. When `questions` is provided, the single-question fields (question/kind/choices) are ignored; a top-level `id` (if given) names the form. Omit `options` on a question for a free-text answer.',
|
|
213
|
+
items: {
|
|
214
|
+
type: 'object',
|
|
215
|
+
properties: {
|
|
216
|
+
id: { type: 'string', description: 'Short stable identifier for this question.' },
|
|
217
|
+
question: { type: 'string', description: 'The question text. Keep concise — max ~200 chars.' },
|
|
218
|
+
context: { type: 'string', description: 'Optional one-line explanation of why this matters.' },
|
|
219
|
+
header: {
|
|
220
|
+
type: 'string',
|
|
221
|
+
description: 'Optional chip label for the form header row (max 12 chars). Defaults to the question id.',
|
|
222
|
+
},
|
|
223
|
+
multiSelect: {
|
|
224
|
+
type: 'boolean',
|
|
225
|
+
description: 'true = the user may pick several options; false/omitted = pick exactly one.',
|
|
226
|
+
},
|
|
227
|
+
options: {
|
|
228
|
+
type: 'array',
|
|
229
|
+
description: 'Answer options. Omit entirely for a free-text question.',
|
|
230
|
+
items: {
|
|
231
|
+
type: 'object',
|
|
232
|
+
properties: {
|
|
233
|
+
value: { type: 'string', description: 'Machine value returned if the user picks this.' },
|
|
234
|
+
label: { type: 'string', description: 'Human-readable label shown in the form.' },
|
|
235
|
+
description: { type: 'string', description: 'Optional secondary line, rendered dim under the label.' },
|
|
236
|
+
recommended: {
|
|
237
|
+
type: 'boolean',
|
|
238
|
+
description: 'Mark the option you would recommend. Auto-picked in headless runs.',
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
required: ['value', 'label'],
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
},
|
|
245
|
+
required: ['id', 'question'],
|
|
246
|
+
},
|
|
247
|
+
},
|
|
117
248
|
},
|
|
118
|
-
|
|
249
|
+
// v2: `questions` replaces the single-question fields, so nothing can be
|
|
250
|
+
// unconditionally required any more. Single-question calls still need
|
|
251
|
+
// id/question/kind (per their descriptions); batched calls need `questions`.
|
|
252
|
+
required: [],
|
|
119
253
|
},
|
|
120
254
|
handler: async (args, _ctx) => {
|
|
255
|
+
// UX Wave 2 / Task 1 — v2 batched form branch. Routing rule (byte-compat
|
|
256
|
+
// by construction): ONLY calls that actually send a `questions` array
|
|
257
|
+
// enter the form machinery; every v1-shaped original falls through to
|
|
258
|
+
// the untouched v1 code paths below — identical prompts, results, and
|
|
259
|
+
// headless behavior, guaranteed structurally rather than by re-testing.
|
|
260
|
+
if (Array.isArray(args.questions)) {
|
|
261
|
+
let form;
|
|
262
|
+
try {
|
|
263
|
+
form = normalizeAskInput(args);
|
|
264
|
+
}
|
|
265
|
+
catch (err) {
|
|
266
|
+
if (err instanceof AskFormValidationError) {
|
|
267
|
+
return {
|
|
268
|
+
content: JSON.stringify({ error: 'ask_form_invalid', message: err.message }),
|
|
269
|
+
is_error: true,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
throw err;
|
|
273
|
+
}
|
|
274
|
+
// Headless v2 — fully implemented NOW (per-question policy, mirroring
|
|
275
|
+
// the v1 B5 rules): optioned questions auto-pick with the fired rule
|
|
276
|
+
// reported; a free-text question inside a batch becomes a per-question
|
|
277
|
+
// `headless_unanswerable` entry but NEVER fails the whole form.
|
|
278
|
+
if (isHeadless()) {
|
|
279
|
+
const answers = form.questions.map((q) => {
|
|
280
|
+
if (!q.freeText && q.options.length > 0) {
|
|
281
|
+
const { picked, rule } = pickHeadlessChoices(q.multiSelect ? 'multi' : 'choice', q.options);
|
|
282
|
+
const answer = picked.map((c) => c.value).join(',');
|
|
283
|
+
console.error(chalk.dim(`headless: auto-answered '${q.question}' → '${answer}' (${rule})`));
|
|
284
|
+
return { id: q.id, answer, auto_answered: true, auto_answer_rule: rule };
|
|
285
|
+
}
|
|
286
|
+
console.error(chalk.yellow(`headless: cannot answer free-text question '${q.question}' — ` +
|
|
287
|
+
'provide this detail in the prompt or run interactively.'));
|
|
288
|
+
return { id: q.id, answer: null, error: 'headless_unanswerable' };
|
|
289
|
+
});
|
|
290
|
+
return {
|
|
291
|
+
content: JSON.stringify({
|
|
292
|
+
formId: form.formId,
|
|
293
|
+
headless: true,
|
|
294
|
+
answers,
|
|
295
|
+
note: 'Headless run: the user did not actually answer this form. Optioned questions ' +
|
|
296
|
+
'were auto-selected (rule reported per answer); free-text questions are ' +
|
|
297
|
+
'unanswerable (answer: null). Proceed with sensible assumptions and clearly ' +
|
|
298
|
+
'note them in your final output.',
|
|
299
|
+
}),
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
// Interactive v2, INK path — wired by Task 2: requestForm emits the
|
|
303
|
+
// discriminated form request, App routes it to <AskForm> (v1 requests
|
|
304
|
+
// keep rendering the untouched AskQuestionModal), and the resolved
|
|
305
|
+
// AskFormResult comes back here. Cancel (Esc / no listener) mirrors
|
|
306
|
+
// the v1 cancel contract: empty answers + cancelled: true.
|
|
307
|
+
if (shouldUseInk()) {
|
|
308
|
+
const result = await askQuestionEmitter.requestForm(form);
|
|
309
|
+
if (result.cancelled) {
|
|
310
|
+
return {
|
|
311
|
+
content: JSON.stringify({ formId: form.formId, answers: [], cancelled: true }),
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
return {
|
|
315
|
+
content: JSON.stringify({ formId: result.formId, answers: result.answers }),
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
// Interactive v2, CLASSIC path — Task 3 flag-day (this replaces the
|
|
319
|
+
// Task-1 `ask_form_not_wired` stub; Task 2 wired the Ink side).
|
|
320
|
+
// Sequential inquirer prompts, one per question IN FORM ORDER, each
|
|
321
|
+
// under a dim `form i/n · <header>` progress prefix and built from
|
|
322
|
+
// the v1 building blocks: select + "(type a custom answer)" escape
|
|
323
|
+
// for pick-one, checkbox for multiSelect, input for freeText.
|
|
324
|
+
//
|
|
325
|
+
// Result shapes mirror the Ink AskForm EXACTLY (Task-2 contract):
|
|
326
|
+
// success → { formId, answers: [{ id, answer, custom? }] } — in
|
|
327
|
+
// question order; custom:true ONLY for the escape.
|
|
328
|
+
// Esc anywhere (ExitPromptError) → { formId, answers: [],
|
|
329
|
+
// cancelled: true } — the WHOLE form cancels, partial
|
|
330
|
+
// answers are discarded (non-error content, v1 cancel
|
|
331
|
+
// semantics).
|
|
332
|
+
// Empty submissions re-prompt, mirroring the Ink form ignoring
|
|
333
|
+
// empty submits — a committed form answer is never empty.
|
|
334
|
+
{
|
|
335
|
+
const total = form.questions.length;
|
|
336
|
+
const answers = [];
|
|
337
|
+
try {
|
|
338
|
+
for (let i = 0; i < total; i++) {
|
|
339
|
+
const q = form.questions[i];
|
|
340
|
+
console.log('');
|
|
341
|
+
console.log(chalk.dim(`form ${i + 1}/${total} · ${q.header ?? q.id}`));
|
|
342
|
+
console.log(chalk.bold(q.question));
|
|
343
|
+
if (q.context)
|
|
344
|
+
console.log(chalk.dim(q.context));
|
|
345
|
+
console.log('');
|
|
346
|
+
let entry = null;
|
|
347
|
+
while (entry === null) {
|
|
348
|
+
if (!q.freeText && !q.multiSelect && q.options.length > 0) {
|
|
349
|
+
// Pick-one — same select + custom-escape recipe as v1.
|
|
350
|
+
const CUSTOM = '__cspeach_custom__';
|
|
351
|
+
const picked = await withInquirer(() => select({
|
|
352
|
+
message: 'Pick an answer:',
|
|
353
|
+
choices: [
|
|
354
|
+
...q.options.map((c) => ({ value: c.value, name: c.label })),
|
|
355
|
+
{ value: CUSTOM, name: chalk.dim('(type a custom answer)') },
|
|
356
|
+
],
|
|
357
|
+
}));
|
|
358
|
+
if (picked === CUSTOM) {
|
|
359
|
+
const raw = (await withInquirer(() => input({ message: 'Your answer:' }))).trim();
|
|
360
|
+
if (raw.length === 0)
|
|
361
|
+
continue; // empty custom — re-prompt
|
|
362
|
+
entry = { id: q.id, answer: raw, custom: true };
|
|
363
|
+
}
|
|
364
|
+
else {
|
|
365
|
+
entry = { id: q.id, answer: picked };
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
else if (q.multiSelect && q.options.length > 0) {
|
|
369
|
+
const picked = await withInquirer(() => checkbox({
|
|
370
|
+
message: 'Pick one or more (space to toggle, enter to confirm):',
|
|
371
|
+
choices: q.options.map((c) => ({ value: c.value, name: c.label })),
|
|
372
|
+
}));
|
|
373
|
+
if (picked.length === 0)
|
|
374
|
+
continue; // nothing toggled — re-prompt
|
|
375
|
+
entry = { id: q.id, answer: picked.join(',') };
|
|
376
|
+
}
|
|
377
|
+
else {
|
|
378
|
+
// freeText (options empty — including multiSelect without
|
|
379
|
+
// options, which normalization degrades to freeText).
|
|
380
|
+
const raw = (await withInquirer(() => input({ message: 'Your answer:' }))).trim();
|
|
381
|
+
if (raw.length === 0)
|
|
382
|
+
continue;
|
|
383
|
+
entry = { id: q.id, answer: raw };
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
console.log(chalk.dim(` → ${entry.answer}`));
|
|
387
|
+
answers.push(entry);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
catch (err) {
|
|
391
|
+
const name = err?.name;
|
|
392
|
+
if (name === 'ExitPromptError') {
|
|
393
|
+
return {
|
|
394
|
+
content: JSON.stringify({ formId: form.formId, answers: [], cancelled: true }),
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
throw err;
|
|
398
|
+
}
|
|
399
|
+
return { content: JSON.stringify({ formId: form.formId, answers }) };
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
// UX Wave 2 / Task 1 review fix — malformed v1 shape guard. With
|
|
403
|
+
// `required: []` on the schema (needed so `questions`-only calls pass
|
|
404
|
+
// API validation) a call like `{id:'x'}` with no question/kind/questions
|
|
405
|
+
// would reach the v1 paths: headless would embed the literal question
|
|
406
|
+
// "undefined" in its error, and interactive would render a bold prompt
|
|
407
|
+
// reading `undefined` and BLOCK the loop on user input. Reject it here
|
|
408
|
+
// with a structured error result instead. Unreachable for any
|
|
409
|
+
// previously-valid v1 call (id/question/kind used to be schema-required)
|
|
410
|
+
// ⇒ zero byte-compat exposure.
|
|
411
|
+
if (args.id == null || args.question == null || args.kind == null) {
|
|
412
|
+
return {
|
|
413
|
+
content: JSON.stringify({
|
|
414
|
+
error: 'ask_question_invalid',
|
|
415
|
+
message: 'Invalid ask_question call: the single-question shape requires `id`, `question`, ' +
|
|
416
|
+
'and `kind` ("text" | "choice" | "multi"). To ask several related questions at ' +
|
|
417
|
+
'once, use the `questions` array (1-4 items, each { id, question, ... }) instead.',
|
|
418
|
+
}),
|
|
419
|
+
is_error: true,
|
|
420
|
+
};
|
|
421
|
+
}
|
|
121
422
|
const id = String(args.id);
|
|
122
423
|
const question = String(args.question);
|
|
123
424
|
const context = args.context ? String(args.context) : undefined;
|