@cspeach/cli 0.9.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 (195) hide show
  1. package/README.md +1 -1
  2. package/dist/agent/intent-system-prompt.js +1 -1
  3. package/dist/agent/loop.js +228 -26
  4. package/dist/agent/providers/license-gate.js +44 -0
  5. package/dist/agent/skill-checkpoint.js +1 -1
  6. package/dist/agent/tool-dispatch.js +15 -0
  7. package/dist/approvals/canonical.js +91 -0
  8. package/dist/approvals/jwt.js +39 -2
  9. package/dist/approvals/op-labels.js +124 -0
  10. package/dist/approvals/render.js +42 -36
  11. package/dist/auth/org-anthropic-key.js +25 -0
  12. package/dist/classifier/client.js +18 -3
  13. package/dist/cli.js +15 -0
  14. package/dist/commands/compact.js +28 -2
  15. package/dist/commands/config-set.js +284 -0
  16. package/dist/commands/config-show.js +20 -0
  17. package/dist/commands/export-audit.js +43 -0
  18. package/dist/commands/help.js +5 -0
  19. package/dist/commands/login.js +31 -14
  20. package/dist/commands/plan-audit-evidence.js +266 -0
  21. package/dist/commands/plan-audit.js +692 -0
  22. package/dist/commands/plan-chain.js +671 -0
  23. package/dist/commands/plan-continue.js +179 -0
  24. package/dist/commands/plan-gate.js +154 -0
  25. package/dist/commands/plan-model-tier.js +83 -0
  26. package/dist/commands/plan-resume.js +728 -46
  27. package/dist/config/loader.js +223 -5
  28. package/dist/config/model-defaults.js +14 -0
  29. package/dist/cost/pricing.js +27 -1
  30. package/dist/doctor/checks/_http-probe.js +1 -0
  31. package/dist/doctor/checks/cert.js +14 -3
  32. package/dist/doctor/checks/sap.js +30 -8
  33. package/dist/doctor/checks/system-roles.js +41 -0
  34. package/dist/doctor/checks/zcspeach.js +19 -4
  35. package/dist/doctor/run.js +2 -0
  36. package/dist/models/resolve.js +61 -0
  37. package/dist/models/server-config.js +155 -0
  38. package/dist/one-shot.js +76 -6
  39. package/dist/projects/answer-blockers.js +137 -0
  40. package/dist/projects/extract-cca.js +111 -17
  41. package/dist/projects/extract-modernize.js +4 -2
  42. package/dist/projects/extract-plan.js +184 -37
  43. package/dist/projects/extract-spec-gap.js +34 -7
  44. package/dist/projects/extract-test-coverage.js +4 -2
  45. package/dist/projects/extract-upgrade.js +116 -23
  46. package/dist/projects/handover-md.js +195 -0
  47. package/dist/projects/index.js +5 -2
  48. package/dist/projects/merge-cca.js +292 -0
  49. package/dist/projects/merge-upgrade.js +173 -0
  50. package/dist/projects/migration.js +103 -1
  51. package/dist/projects/output-paths.js +27 -0
  52. package/dist/projects/plan-run.js +285 -27
  53. package/dist/projects/plan-schema.js +136 -3
  54. package/dist/projects/promote-command.js +25 -2
  55. package/dist/projects/promote.js +128 -0
  56. package/dist/projects/run-lease.js +157 -0
  57. package/dist/projects/save-command.js +259 -21
  58. package/dist/projects/status.js +3 -1
  59. package/dist/projects/validate.js +1 -1
  60. package/dist/projects/workspace.js +164 -20
  61. package/dist/renderer/notices.js +64 -0
  62. package/dist/renderer/progress-chatter.js +8 -0
  63. package/dist/renderer/status-footer.js +22 -12
  64. package/dist/renderer/thinking-heartbeat.js +64 -8
  65. package/dist/renderer/todo-block.js +51 -0
  66. package/dist/renderer/tool-widget.js +55 -4
  67. package/dist/renderer/tty.js +43 -4
  68. package/dist/renderer/verify-chain.js +77 -0
  69. package/dist/repl/at-picker.js +60 -7
  70. package/dist/repl/bracketed-paste.js +28 -19
  71. package/dist/repl/builtin-commands.js +42 -0
  72. package/dist/repl/current-transport.js +10 -0
  73. package/dist/repl/early-line-buffer.js +68 -0
  74. package/dist/repl/history.js +86 -0
  75. package/dist/repl/ink-stdin-guard.js +64 -0
  76. package/dist/repl/inquirer-guard.js +70 -5
  77. package/dist/repl/mode-ceiling.js +16 -0
  78. package/dist/repl/mode-cycle.js +104 -0
  79. package/dist/repl/numbered-menu.js +131 -0
  80. package/dist/repl/post-turn-status.js +26 -6
  81. package/dist/repl/rule8-detector.js +17 -2
  82. package/dist/repl/safety-confirm.js +111 -2
  83. package/dist/repl/safety-mode-state.js +19 -3
  84. package/dist/repl/slash-completer.js +5 -0
  85. package/dist/repl/slash-picker.js +10 -15
  86. package/dist/repl.js +1232 -95
  87. package/dist/rewind/candidates.js +194 -0
  88. package/dist/rewind/cli.js +137 -0
  89. package/dist/rewind/format.js +27 -0
  90. package/dist/rewind/restore.js +245 -0
  91. package/dist/router/classifier.js +150 -6
  92. package/dist/sap/capability-matrix.js +20 -0
  93. package/dist/sap/capability-matrix.json +11236 -0
  94. package/dist/sap/capability.js +146 -0
  95. package/dist/sap/connection-manager.js +19 -1
  96. package/dist/sap/onboarding.js +42 -4
  97. package/dist/session/audit-export.js +459 -0
  98. package/dist/session/context-report.js +163 -0
  99. package/dist/session/pending.js +27 -0
  100. package/dist/session/recap.js +160 -0
  101. package/dist/skill-catalog.js +51 -40
  102. package/dist/skills/bundled-skills.js +272 -1
  103. package/dist/skills/promotion-dispatch.js +23 -0
  104. package/dist/tools/_command-shared.js +36 -12
  105. package/dist/tools/_filesystem-shared.js +139 -4
  106. package/dist/tools/_flag.js +25 -0
  107. package/dist/tools/approval.js +177 -26
  108. package/dist/tools/ask-question.js +400 -7
  109. package/dist/tools/capability/tool.js +74 -0
  110. package/dist/tools/dispatch-skill.js +22 -1
  111. package/dist/tools/extend-model/anchored-insert.js +1414 -0
  112. package/dist/tools/extend-model/tool.js +340 -0
  113. package/dist/tools/filesystem/extract-document.js +57 -0
  114. package/dist/tools/filesystem/file-edit.js +12 -2
  115. package/dist/tools/filesystem/file-read.js +2 -2
  116. package/dist/tools/filesystem/file-write.js +11 -2
  117. package/dist/tools/filesystem/glob.js +11 -0
  118. package/dist/tools/filesystem/grep.js +10 -0
  119. package/dist/tools/filesystem/read-document.js +107 -0
  120. package/dist/tools/fiori/apply.js +50 -0
  121. package/dist/tools/fiori/bin.js +3 -0
  122. package/dist/tools/fiori/catalog/index.js +27 -0
  123. package/dist/tools/fiori/catalog/value-help.js +230 -0
  124. package/dist/tools/fiori/catalog/viz-chart.js +177 -0
  125. package/dist/tools/fiori/cli.js +71 -0
  126. package/dist/tools/fiori/deploy-config.js +73 -0
  127. package/dist/tools/fiori/fe-extend.js +76 -0
  128. package/dist/tools/fiori/fe-scaffold.js +71 -0
  129. package/dist/tools/fiori/floorplan-map.js +19 -0
  130. package/dist/tools/fiori/i18n.js +39 -0
  131. package/dist/tools/fiori/manifest.js +70 -0
  132. package/dist/tools/fiori/render.js +77 -0
  133. package/dist/tools/fiori/samples/data/index.json +13602 -0
  134. package/dist/tools/fiori/samples/data/sources.generated.js +808 -0
  135. package/dist/tools/fiori/samples/loader.js +248 -0
  136. package/dist/tools/fiori/samples/search.js +63 -0
  137. package/dist/tools/fiori/samples/types.js +2 -0
  138. package/dist/tools/fiori/scaffold.js +39 -0
  139. package/dist/tools/fiori/smoke/assertions.js +74 -0
  140. package/dist/tools/fiori/smoke/browser.js +52 -0
  141. package/dist/tools/fiori/smoke/driver.js +89 -0
  142. package/dist/tools/fiori/smoke/freestyle-spec.js +317 -0
  143. package/dist/tools/fiori/smoke/run-smoke.js +149 -0
  144. package/dist/tools/fiori/tools.js +681 -0
  145. package/dist/tools/fiori/types.js +1 -0
  146. package/dist/tools/local-build.js +86 -0
  147. package/dist/tools/local-files.js +31 -0
  148. package/dist/tools/project/_merge-shared.js +68 -0
  149. package/dist/tools/project/cca_merge.js +164 -0
  150. package/dist/tools/project/playbook_get.js +1 -1
  151. package/dist/tools/project/upgrade_merge_progress.js +206 -0
  152. package/dist/tools/sap-read.js +132 -20
  153. package/dist/tools/sap-write.js +550 -21
  154. package/dist/tools/shell/shell_exec.js +41 -6
  155. package/dist/tools/snapshot.js +63 -14
  156. package/dist/tools/subagent/agent_run.js +27 -3
  157. package/dist/tools/subagent/background_run.js +17 -1
  158. package/dist/tools/todo.js +144 -0
  159. package/dist/tools/transport-resolution.js +86 -0
  160. package/dist/tools/transport.js +224 -5
  161. package/dist/tools/write-mode.js +4 -0
  162. package/dist/ui/app.js +378 -21
  163. package/dist/ui/approval-modal.js +49 -16
  164. package/dist/ui/ask-question-emitter.js +14 -0
  165. package/dist/ui/body.js +13 -0
  166. package/dist/ui/context-grid.js +108 -0
  167. package/dist/ui/footer.js +120 -27
  168. package/dist/ui/header.js +7 -0
  169. package/dist/ui/line-resolution.js +35 -8
  170. package/dist/ui/rewind-emitter.js +10 -0
  171. package/dist/ui/rewind-panel.js +81 -0
  172. package/dist/ui/sap-state-store.js +1 -0
  173. package/dist/ui/session-timeline.js +1 -0
  174. package/dist/ui/status-line.js +43 -0
  175. package/dist/ui/text-input.js +214 -0
  176. package/dist/ui/todo-emitter.js +25 -0
  177. package/dist/ui/todo-panel.js +64 -0
  178. package/dist/ui/turn-status-emitter.js +50 -4
  179. package/dist/ui/turn-status.js +18 -3
  180. package/dist/ui/widgets/ask-form.js +242 -0
  181. package/dist/ui/widgets/ask-question-modal.js +21 -8
  182. package/package.json +22 -3
  183. package/bench/README.md +0 -78
  184. package/bench/prompts/abap-document-cds.md +0 -44
  185. package/bench/prompts/abap-explain-bdef-handler.md +0 -57
  186. package/bench/prompts/abap-test-method.md +0 -42
  187. package/bench/results/abap-document-cds/claude-haiku-4-5.md +0 -189
  188. package/bench/results/abap-document-cds/claude-opus-4-7.md +0 -120
  189. package/bench/results/abap-document-cds/claude-sonnet-4-6.md +0 -151
  190. package/bench/results/abap-explain-bdef-handler/claude-haiku-4-5.md +0 -112
  191. package/bench/results/abap-explain-bdef-handler/claude-opus-4-7.md +0 -101
  192. package/bench/results/abap-explain-bdef-handler/claude-sonnet-4-6.md +0 -101
  193. package/bench/results/abap-test-method/claude-haiku-4-5.md +0 -186
  194. package/bench/results/abap-test-method/claude-opus-4-7.md +0 -193
  195. package/bench/results/abap-test-method/claude-sonnet-4-6.md +0 -234
@@ -13,23 +13,161 @@
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';
26
31
  import { input, select, checkbox } from '@inquirer/prompts';
27
32
  import { withInquirer } from '../repl/inquirer-guard.js';
28
- import { shouldUseInk } from '../renderer/tty.js';
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
+ }
121
+ /**
122
+ * Label heuristic for rule 2 of pickHeadlessChoices: a string counts as
123
+ * "recommended" only when it contains the word AND that word is not part of
124
+ * a negation — skills write both "RAP (recommended)" and "SEGW (not
125
+ * recommended)" and the latter must NEVER win the auto-pick.
126
+ */
127
+ function labelSaysRecommended(s) {
128
+ return /\brecommended\b/i.test(s) && !/\b(?:not|non|never)[\s-]+recommended\b/i.test(s);
129
+ }
130
+ /**
131
+ * B5 (2026-06-11) — headless auto-pick rule for choice/multi questions.
132
+ *
133
+ * Rule order (the fired rule is reported so logs and the tool result are
134
+ * honest about WHY an option was picked):
135
+ * 1. `recommended: true` on one or more choices → those choices
136
+ * (rule: 'recommended').
137
+ * 2. No explicit flag, but a label/value contains the word "recommended"
138
+ * (skills frequently write "RAP (recommended)") → those choices
139
+ * (rule: 'recommended-label'). Negated forms ("not recommended",
140
+ * "non-recommended", "never recommended") do NOT match.
141
+ * 3. Nothing marked → the FIRST option (rule: 'first-option').
142
+ *
143
+ * For kind=choice only the first match is taken; for kind=multi the whole
144
+ * recommended set is taken (or the first option when nothing is marked).
145
+ *
146
+ * Contract: `choices` must be non-empty — the handler guards with
147
+ * `choices.length > 0` before calling (an empty array means there is nothing
148
+ * to pick and the headless path returns the free-text error instead). An
149
+ * empty array here is a programming error, so this throws rather than
150
+ * returning `[undefined]`.
151
+ *
152
+ * Exported for unit testing.
153
+ */
154
+ export function pickHeadlessChoices(kind, choices) {
155
+ if (choices.length === 0) {
156
+ throw new Error('pickHeadlessChoices requires at least one choice — callers must guard (see ask_question handler)');
157
+ }
158
+ const flagged = choices.filter((c) => c.recommended === true);
159
+ if (flagged.length > 0) {
160
+ return { picked: kind === 'multi' ? flagged : [flagged[0]], rule: 'recommended' };
161
+ }
162
+ const labelled = choices.filter((c) => labelSaysRecommended(c.label) || labelSaysRecommended(c.value));
163
+ if (labelled.length > 0) {
164
+ return { picked: kind === 'multi' ? labelled : [labelled[0]], rule: 'recommended-label' };
165
+ }
166
+ return { picked: [choices[0]], rule: 'first-option' };
167
+ }
30
168
  registerTool({
31
169
  name: 'ask_question',
32
- 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.",
33
171
  isMutating: false,
34
172
  input_schema: {
35
173
  type: 'object',
@@ -59,21 +197,276 @@ registerTool({
59
197
  properties: {
60
198
  value: { type: 'string', description: 'Machine value returned if the user picks this.' },
61
199
  label: { type: 'string', description: 'Human-readable label shown in the picker.' },
200
+ recommended: {
201
+ type: 'boolean',
202
+ description: 'Mark the option you would recommend. In headless (non-interactive) runs this option is auto-picked; in interactive runs it is informational only.',
203
+ },
62
204
  },
63
205
  required: ['value', 'label'],
64
206
  },
65
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
+ },
66
248
  },
67
- 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: [],
68
253
  },
69
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
+ }
70
422
  const id = String(args.id);
71
423
  const question = String(args.question);
72
424
  const context = args.context ? String(args.context) : undefined;
73
425
  const kind = args.kind;
74
- const choices = Array.isArray(args.choices)
75
- ? args.choices
76
- : [];
426
+ const choices = Array.isArray(args.choices) ? args.choices : [];
427
+ // B5 (2026-06-11) — headless answer policy (defects D1/D1b). In a
428
+ // headless run (one-shot / piped stdin) NO prompt may ever block on
429
+ // stdin: battery S2 Pass A stalled forever on exactly this path.
430
+ // - choice/multi WITH options: auto-pick (recommended > recommended-
431
+ // label > first option), log to stderr, and tell the model via
432
+ // `auto_answered: true` that the user did NOT really answer.
433
+ // - free text (or choice/multi without options): no auto-answer is
434
+ // possible — return an error RESULT to the model (not a hard exit)
435
+ // so it can finish gracefully with documented assumptions, the way
436
+ // S2 Pass B completed when answers were supplied up front.
437
+ if (isHeadless()) {
438
+ if ((kind === 'choice' || kind === 'multi') && choices.length > 0) {
439
+ const { picked, rule } = pickHeadlessChoices(kind, choices);
440
+ const answer = picked.map((c) => c.value).join(',');
441
+ console.error(chalk.dim(`headless: auto-answered '${question}' → '${answer}' (${rule})`));
442
+ return {
443
+ content: JSON.stringify({
444
+ id,
445
+ kind,
446
+ answer,
447
+ headless: true,
448
+ auto_answered: true,
449
+ auto_answer_rule: rule,
450
+ note: 'Headless run: the user did not actually answer — this option was auto-selected ' +
451
+ `(${rule}). Proceed with it and clearly note the assumption in your final output.`,
452
+ }),
453
+ };
454
+ }
455
+ console.error(chalk.yellow(`headless: cannot answer free-text question '${question}' — ` +
456
+ 'provide this detail in the prompt or run interactively.'));
457
+ return {
458
+ content: JSON.stringify({
459
+ id,
460
+ kind,
461
+ headless: true,
462
+ error: 'headless_unanswerable',
463
+ message: `Headless run: no user is available to answer this free-text question: "${question}". ` +
464
+ 'Either proceed with a sensible assumption and CLEARLY document it in your final output, ' +
465
+ 'or finish by telling the user to include this detail in the prompt or run interactively.',
466
+ }),
467
+ is_error: true,
468
+ };
469
+ }
77
470
  // 2026-05-01 redesign: when the LLM provides choices, use a real
78
471
  // arrow-key picker (select / checkbox) instead of free-text input.
79
472
  // The previous "always free-text, smart-resolve digits" pattern produced
@@ -0,0 +1,74 @@
1
+ /**
2
+ * `system_capability` — the data-driven capability gate behind Pillar A.
3
+ *
4
+ * Given a capability id (cds.viewEntity | rap.managed | abap.filterExpr |
5
+ * abap.inlineData) or a raw ABAP feature name, returns a yes/no/with-fallback
6
+ * verdict for the CONNECTED SAP release, read out of the committed ABAP
7
+ * feature-matrix snapshot. Skills call this instead of hardcoding version
8
+ * prose like "FILTER needs 7.50".
9
+ *
10
+ * Read-only: it consults system-info (which caches/queries CVERS once) and a
11
+ * static snapshot — it never writes to SAP. Flag-gated with the other
12
+ * local-build tools (rides the `local_build` switch via LOCAL_BUILD_TOOLS).
13
+ *
14
+ * See docs/superpowers/specs/2026-06-24-revision-aware-cspeach-design.md.
15
+ */
16
+ import { registerTool } from '../index.js';
17
+ import { getSapSystemInfo } from '../../sap/system-info.js';
18
+ import { loadCapabilityMatrix } from '../../sap/capability-matrix.js';
19
+ import { resolveCapability, CAPABILITY_REGISTRY, } from '../../sap/capability.js';
20
+ function isRegistryId(s) {
21
+ return Object.prototype.hasOwnProperty.call(CAPABILITY_REGISTRY, s);
22
+ }
23
+ async function systemCapabilityHandler(args, ctx) {
24
+ const capability = args?.capability;
25
+ if (typeof capability !== 'string' || capability.trim() === '') {
26
+ return { content: 'error: capability (non-empty string) is required', is_error: true };
27
+ }
28
+ const info = await getSapSystemInfo(ctx.adt, ctx.sapAlias);
29
+ if (!info) {
30
+ // Release undetectable. Build a minimal verdict-like JSON: with-fallback
31
+ // when the caller asked for a registry id that HAS a fallback, else no.
32
+ const fallback = isRegistryId(capability)
33
+ ? CAPABILITY_REGISTRY[capability].fallback || undefined
34
+ : undefined;
35
+ const feature = isRegistryId(capability) ? CAPABILITY_REGISTRY[capability].feature : capability;
36
+ const verdict = {
37
+ capability,
38
+ feature,
39
+ status: fallback ? 'with-fallback' : 'no',
40
+ reason: 'SAP release could not be detected — verify the system manually before relying on this feature',
41
+ release: 'unknown',
42
+ column: null,
43
+ source: 'matrix-snapshot',
44
+ ...(fallback ? { fallback } : {}),
45
+ };
46
+ return { content: JSON.stringify(verdict) };
47
+ }
48
+ const verdict = resolveCapability(info, loadCapabilityMatrix(), capability);
49
+ return { content: JSON.stringify(verdict) };
50
+ }
51
+ registerTool({
52
+ name: 'system_capability',
53
+ description: 'Returns yes / no / with-fallback for whether the CONNECTED SAP release supports a given ABAP '
54
+ + 'capability, read out of the committed ABAP feature-matrix snapshot — so skills stop hardcoding '
55
+ + 'version prose like "FILTER needs 7.50". Pass a capability gate id (cds.viewEntity | rap.managed | '
56
+ + 'abap.filterExpr | abap.inlineData) OR a raw ABAP feature name from the matrix. The verdict carries '
57
+ + 'the feature consulted, the matrix column used, the reason, and (only for with-fallback) a concrete '
58
+ + 'fallback instruction. Read-only — never writes to SAP.',
59
+ isMutating: false,
60
+ category: 'sap',
61
+ flagGated: true,
62
+ input_schema: {
63
+ type: 'object',
64
+ properties: {
65
+ capability: {
66
+ type: 'string',
67
+ description: 'A capability gate id (cds.viewEntity | rap.managed | abap.filterExpr | abap.inlineData) '
68
+ + 'OR a raw ABAP feature name from the matrix.',
69
+ },
70
+ },
71
+ required: ['capability'],
72
+ },
73
+ handler: systemCapabilityHandler,
74
+ });
@@ -22,11 +22,20 @@
22
22
  *
23
23
  * The harness clears pendingDispatch at the start of every consume cycle,
24
24
  * so a stale dispatch from a prior turn never re-fires.
25
+ *
26
+ * A1 (2026-06-10, defect D23): `/abap-plan --resume …` is REJECTED here.
27
+ * Plan auto-run is harness-owned — commands/plan-resume.ts builds the
28
+ * resume command from the exact path it just saved and queues it itself
29
+ * (offerNextPhaseAutoRun). When the model authored this text it once
30
+ * hallucinated a wrong base token ("@…-plan-c1" → "no file matching")
31
+ * while the harness had just printed the real saved path. The tool stays
32
+ * for every other dispatch (e.g. /abap-generate → /abap-rap switch).
25
33
  */
26
34
  import { registerTool } from './index.js';
35
+ import { isPlanResumeCommand } from '../commands/plan-resume.js';
27
36
  registerTool({
28
37
  name: 'dispatch_skill',
29
- description: 'Queue the next user submission to a slash command the user already explicitly approved via an inquirer picker. Use this in place of telling the user "Type X at the next prompt" — re-typing what they already picked is friction. Only call this AFTER an explicit user consent (ask_question pick, picker confirmation, etc.); never from prose or a one-line suggestion. The command must start with a known slash route (e.g. "/abap-rap --from @<file>"). After this tool returns, end the turn — do not also print a continuation hint.',
38
+ description: 'Queue the next user submission to a slash command the user already explicitly approved via an inquirer picker. Use this in place of telling the user "Type X at the next prompt" — re-typing what they already picked is friction. Only call this AFTER an explicit user consent (ask_question pick, picker confirmation, etc.); never from prose or a one-line suggestion. The command must start with a known slash route (e.g. "/abap-rap --from @<file>"). NEVER dispatch "/abap-plan --resume" — plan continuation is harness-owned: the CLI offers and dispatches the next phase itself after the manifest is persisted. After this tool returns, end the turn — do not also print a continuation hint.',
30
39
  isMutating: false,
31
40
  input_schema: {
32
41
  type: 'object',
@@ -58,6 +67,18 @@ registerTool({
58
67
  }),
59
68
  };
60
69
  }
70
+ // A1 (defect D23) — plan auto-run is harness-owned. The CLI builds the
71
+ // resume command from the exact saved envelope path and asks the user
72
+ // itself; a model-authored variant risks a hallucinated file token.
73
+ if (isPlanResumeCommand(command)) {
74
+ return {
75
+ is_error: true,
76
+ content: JSON.stringify({
77
+ error: 'PLAN_RESUME_HARNESS_OWNED',
78
+ reason: 'Do not dispatch /abap-plan --resume. After your plan manifest is persisted, the CLI itself offers the user the next phase and dispatches the resume with the exact saved file in a fresh bounded context. Simply end the turn now.',
79
+ }),
80
+ };
81
+ }
61
82
  if (!ctx.pendingDispatch) {
62
83
  // Non-REPL path (one-shot, tests). Tell the model the dispatch
63
84
  // can't take effect here so it falls back to a continuation hint.