@cspeach/cli 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/dist/agent/loop.js +22 -9
  2. package/dist/approvals/op-labels.js +124 -0
  3. package/dist/approvals/render.js +42 -36
  4. package/dist/cli.js +15 -0
  5. package/dist/commands/compact.js +28 -2
  6. package/dist/commands/config-set.js +189 -0
  7. package/dist/commands/config-show.js +20 -0
  8. package/dist/commands/export-audit.js +43 -0
  9. package/dist/commands/help.js +5 -0
  10. package/dist/commands/plan-audit-evidence.js +266 -0
  11. package/dist/commands/plan-audit.js +692 -0
  12. package/dist/commands/plan-chain.js +671 -0
  13. package/dist/commands/plan-continue.js +179 -0
  14. package/dist/commands/plan-gate.js +154 -0
  15. package/dist/commands/plan-resume.js +588 -33
  16. package/dist/config/loader.js +128 -4
  17. package/dist/config/model-defaults.js +14 -0
  18. package/dist/cost/pricing.js +27 -1
  19. package/dist/doctor/checks/system-roles.js +41 -0
  20. package/dist/doctor/run.js +2 -0
  21. package/dist/models/resolve.js +61 -0
  22. package/dist/models/server-config.js +155 -0
  23. package/dist/one-shot.js +25 -3
  24. package/dist/projects/extract-cca.js +3 -1
  25. package/dist/projects/extract-modernize.js +3 -1
  26. package/dist/projects/extract-plan.js +60 -6
  27. package/dist/projects/extract-test-coverage.js +3 -1
  28. package/dist/projects/extract-upgrade.js +3 -1
  29. package/dist/projects/handover-md.js +195 -0
  30. package/dist/projects/index.js +1 -1
  31. package/dist/projects/plan-run.js +137 -13
  32. package/dist/projects/plan-schema.js +73 -0
  33. package/dist/projects/run-lease.js +157 -0
  34. package/dist/projects/save-command.js +26 -15
  35. package/dist/renderer/status-footer.js +22 -12
  36. package/dist/renderer/thinking-heartbeat.js +64 -8
  37. package/dist/renderer/todo-block.js +51 -0
  38. package/dist/renderer/tool-widget.js +37 -0
  39. package/dist/repl/bracketed-paste.js +28 -19
  40. package/dist/repl/builtin-commands.js +5 -0
  41. package/dist/repl/current-transport.js +10 -0
  42. package/dist/repl/history.js +86 -0
  43. package/dist/repl/ink-stdin-guard.js +64 -0
  44. package/dist/repl/mode-ceiling.js +16 -0
  45. package/dist/repl/mode-cycle.js +104 -0
  46. package/dist/repl/post-turn-status.js +24 -4
  47. package/dist/repl/slash-completer.js +5 -0
  48. package/dist/repl.js +954 -83
  49. package/dist/rewind/candidates.js +194 -0
  50. package/dist/rewind/cli.js +137 -0
  51. package/dist/rewind/format.js +27 -0
  52. package/dist/rewind/restore.js +245 -0
  53. package/dist/session/audit-export.js +459 -0
  54. package/dist/session/context-report.js +163 -0
  55. package/dist/session/recap.js +160 -0
  56. package/dist/skill-catalog.js +9 -3
  57. package/dist/skills/bundled-skills.js +59 -66
  58. package/dist/tools/approval.js +115 -7
  59. package/dist/tools/ask-question.js +304 -3
  60. package/dist/tools/extend-model/anchored-insert.js +604 -0
  61. package/dist/tools/extend-model/tool.js +162 -10
  62. package/dist/tools/fiori/fe-extend.js +76 -0
  63. package/dist/tools/fiori/fe-scaffold.js +29 -3
  64. package/dist/tools/fiori/floorplan-map.js +19 -0
  65. package/dist/tools/fiori/samples/data/index.json +13602 -0
  66. package/dist/tools/fiori/samples/data/sources.generated.js +808 -0
  67. package/dist/tools/fiori/samples/loader.js +248 -0
  68. package/dist/tools/fiori/samples/search.js +63 -0
  69. package/dist/tools/fiori/samples/types.js +2 -0
  70. package/dist/tools/fiori/smoke/assertions.js +74 -0
  71. package/dist/tools/fiori/smoke/browser.js +52 -0
  72. package/dist/tools/fiori/smoke/driver.js +89 -0
  73. package/dist/tools/fiori/smoke/freestyle-spec.js +317 -0
  74. package/dist/tools/fiori/smoke/run-smoke.js +149 -0
  75. package/dist/tools/fiori/tools.js +328 -3
  76. package/dist/tools/local-build.js +11 -1
  77. package/dist/tools/sap-read.js +79 -11
  78. package/dist/tools/sap-write.js +24 -4
  79. package/dist/tools/snapshot.js +27 -1
  80. package/dist/tools/subagent/agent_run.js +27 -3
  81. package/dist/tools/todo.js +144 -0
  82. package/dist/ui/app.js +372 -19
  83. package/dist/ui/approval-modal.js +49 -16
  84. package/dist/ui/ask-question-emitter.js +14 -0
  85. package/dist/ui/context-grid.js +108 -0
  86. package/dist/ui/footer.js +109 -30
  87. package/dist/ui/header.js +7 -0
  88. package/dist/ui/line-resolution.js +18 -2
  89. package/dist/ui/rewind-emitter.js +10 -0
  90. package/dist/ui/rewind-panel.js +81 -0
  91. package/dist/ui/sap-state-store.js +1 -0
  92. package/dist/ui/status-line.js +43 -0
  93. package/dist/ui/text-input.js +72 -8
  94. package/dist/ui/todo-emitter.js +25 -0
  95. package/dist/ui/todo-panel.js +64 -0
  96. package/dist/ui/turn-status-emitter.js +50 -4
  97. package/dist/ui/turn-status.js +18 -3
  98. package/dist/ui/widgets/ask-form.js +242 -0
  99. package/dist/ui/widgets/ask-question-modal.js +17 -7
  100. package/package.json +4 -1
@@ -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: 'Unified diff for modify/create ops (optional)' },
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
- if (!isHeadless()) {
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, args.transport, eff);
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
- required: ['id', 'question', 'kind'],
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;