@cspeach/cli 1.1.17 → 1.1.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (225) hide show
  1. package/README.md +3 -4
  2. package/dist/agent/loop.js +143 -25
  3. package/dist/agent/providers/local-provider.js +4 -1
  4. package/dist/agent/repair-partial.js +76 -19
  5. package/dist/agent/tool-dispatch.js +80 -22
  6. package/dist/agent/turn-error-ux.js +77 -26
  7. package/dist/agent/turn-stream.js +25 -3
  8. package/dist/approvals/adt-type.js +27 -0
  9. package/dist/approvals/advisory-prompt.js +48 -0
  10. package/dist/approvals/advisory-render.js +25 -11
  11. package/dist/approvals/approval-prompt.js +74 -24
  12. package/dist/approvals/jwt.js +2 -1
  13. package/dist/approvals/render.js +64 -36
  14. package/dist/approvals/risk-floor.js +53 -2
  15. package/dist/auth/api-key.js +30 -16
  16. package/dist/auth/keychain.js +0 -0
  17. package/dist/auth/me.js +25 -7
  18. package/dist/cli.js +25 -6
  19. package/dist/commands/compact.js +12 -9
  20. package/dist/commands/config-set.js +6 -2
  21. package/dist/commands/config-show.js +7 -5
  22. package/dist/commands/cost.js +41 -34
  23. package/dist/commands/help.js +88 -50
  24. package/dist/commands/login.js +28 -19
  25. package/dist/commands/logout.js +7 -3
  26. package/dist/commands/plan-chain.js +71 -77
  27. package/dist/commands/plan-continue.js +1 -0
  28. package/dist/commands/plan-resume.js +20 -12
  29. package/dist/commands/whoami.js +23 -9
  30. package/dist/config/loader.js +15 -3
  31. package/dist/cost/pricing.js +6 -5
  32. package/dist/doctor/checks/forge-rules.js +13 -3
  33. package/dist/doctor/checks/keychain.js +6 -4
  34. package/dist/doctor/run.js +101 -20
  35. package/dist/index.js +5 -2
  36. package/dist/lib/graceful-exit.js +56 -0
  37. package/dist/lib/piped-prompt.js +65 -0
  38. package/dist/lock-contention.js +2 -2
  39. package/dist/one-shot.js +135 -40
  40. package/dist/projects/answer-blockers.js +6 -1
  41. package/dist/projects/handover-md.js +15 -14
  42. package/dist/projects/plan-run.js +97 -54
  43. package/dist/projects/promote-command.js +11 -2
  44. package/dist/projects/save-command.js +3 -2
  45. package/dist/projects/status.js +2 -1
  46. package/dist/projects/yes-no.js +12 -0
  47. package/dist/renderer/abap-inline.js +18 -14
  48. package/dist/renderer/answer-trim.js +69 -0
  49. package/dist/renderer/banners.js +8 -8
  50. package/dist/renderer/brand-settled.js +19 -0
  51. package/dist/renderer/change-card.js +156 -0
  52. package/dist/renderer/checklist-format.js +130 -0
  53. package/dist/renderer/color-mode.js +45 -0
  54. package/dist/renderer/error-detail.js +104 -0
  55. package/dist/renderer/fence-state.js +46 -0
  56. package/dist/renderer/footer-line.js +96 -0
  57. package/dist/renderer/glyphs.js +25 -0
  58. package/dist/renderer/goodbye.js +38 -0
  59. package/dist/renderer/legacy-palette.js +41 -0
  60. package/dist/renderer/look.js +30 -0
  61. package/dist/renderer/markdown.js +299 -92
  62. package/dist/renderer/notice-log.js +72 -0
  63. package/dist/renderer/notice-shape.js +48 -0
  64. package/dist/renderer/notices.js +40 -8
  65. package/dist/renderer/panel-rows.js +16 -0
  66. package/dist/renderer/pipeline.js +66 -0
  67. package/dist/renderer/progress-chatter.js +23 -17
  68. package/dist/renderer/rendering-mode.js +34 -0
  69. package/dist/renderer/routing-line.js +14 -0
  70. package/dist/renderer/sanitize.js +12 -0
  71. package/dist/renderer/severity.js +2 -7
  72. package/dist/renderer/startup-lines.js +152 -0
  73. package/dist/renderer/status-footer.js +52 -34
  74. package/dist/renderer/steering-echo.js +41 -0
  75. package/dist/renderer/syntax.js +31 -12
  76. package/dist/renderer/tables.js +5 -1
  77. package/dist/renderer/theme.js +44 -0
  78. package/dist/renderer/thinking-heartbeat.js +33 -2
  79. package/dist/renderer/todo-block.js +10 -49
  80. package/dist/renderer/tool-labels.js +238 -0
  81. package/dist/renderer/tool-widget.js +245 -51
  82. package/dist/renderer/trace.js +42 -0
  83. package/dist/renderer/transcript-flow.js +132 -0
  84. package/dist/renderer/tty.js +34 -1
  85. package/dist/renderer/ui-width.js +42 -0
  86. package/dist/renderer/verify-chain.js +4 -2
  87. package/dist/renderer/widget-fallback.js +58 -65
  88. package/dist/repl/bracketed-paste.js +7 -1
  89. package/dist/repl/builtin-commands.js +19 -8
  90. package/dist/repl/credential-handover-gate.js +28 -0
  91. package/dist/repl/early-line-buffer.js +5 -2
  92. package/dist/repl/file-picker.js +54 -12
  93. package/dist/repl/ink-stdin-guard.js +66 -0
  94. package/dist/repl/inquirer-guard.js +59 -16
  95. package/dist/repl/inquirer-theme.js +27 -33
  96. package/dist/repl/paste-marker.js +19 -0
  97. package/dist/repl/plan-turn-end.js +20 -0
  98. package/dist/repl/post-turn-status.js +36 -11
  99. package/dist/repl/reroute-turn.js +21 -0
  100. package/dist/repl/reset-tty-stdin.js +44 -0
  101. package/dist/repl/restore-guard.js +22 -0
  102. package/dist/repl/resume-standalone.js +63 -0
  103. package/dist/repl/rule8-detector.js +11 -3
  104. package/dist/repl/safety-confirm.js +161 -93
  105. package/dist/repl/session-spend-line.js +8 -4
  106. package/dist/repl/themed-prompts.js +19 -0
  107. package/dist/repl/ui-look-command.js +75 -0
  108. package/dist/repl/update-method-preview-hook.js +48 -5
  109. package/dist/repl.js +795 -332
  110. package/dist/rewind/cli.js +3 -2
  111. package/dist/rewind/format.js +14 -9
  112. package/dist/rewind/restore.js +11 -0
  113. package/dist/router/classifier.js +47 -4
  114. package/dist/router/intent-extractor.js +24 -8
  115. package/dist/router/piped-routing.js +23 -0
  116. package/dist/router/resume-routing.js +16 -0
  117. package/dist/router/routing-failure.js +57 -0
  118. package/dist/sap/first-run-choice.js +1 -1
  119. package/dist/sap/onboarding.js +3 -2
  120. package/dist/sap/standalone-onboarding.js +1 -1
  121. package/dist/sap/system-info.js +4 -2
  122. package/dist/sap/unreachable.js +28 -0
  123. package/dist/sap-errors/clean-error-text.js +182 -0
  124. package/dist/session/interrupt-reason.js +39 -0
  125. package/dist/session/recap.js +36 -24
  126. package/dist/session/resume.js +104 -36
  127. package/dist/session/store.js +69 -27
  128. package/dist/session/user-prompt.js +21 -0
  129. package/dist/skill-catalog.js +7 -0
  130. package/dist/skills/bundled-skills.js +76 -69
  131. package/dist/skills/signing-public-key.js +1 -1
  132. package/dist/standards/standards-init.js +1 -1
  133. package/dist/test-helpers/answer-any.js +46 -0
  134. package/dist/tools/approval.js +27 -11
  135. package/dist/tools/ask-question.js +47 -5
  136. package/dist/tools/filesystem/file-write.js +19 -2
  137. package/dist/tools/fiori/fe-extend.js +16 -1
  138. package/dist/tools/fiori/fe-scaffold.js +105 -3
  139. package/dist/tools/fiori/html-escapes.js +28 -0
  140. package/dist/tools/fiori/metadata/audit.js +605 -0
  141. package/dist/tools/fiori/metadata/references.js +104 -0
  142. package/dist/tools/fiori/metadata/types.js +8 -0
  143. package/dist/tools/fiori/preview/app-guard.js +215 -0
  144. package/dist/tools/fiori/preview/env.js +193 -0
  145. package/dist/tools/fiori/preview/readiness.js +127 -0
  146. package/dist/tools/fiori/preview/registry.js +412 -0
  147. package/dist/tools/fiori/preview/start.js +469 -0
  148. package/dist/tools/fiori/samples/loader.js +20 -5
  149. package/dist/tools/fiori/scaffold.js +18 -6
  150. package/dist/tools/fiori/smoke/app-driver-page.js +372 -0
  151. package/dist/tools/fiori/smoke/app-driver.js +100 -0
  152. package/dist/tools/fiori/smoke/assertions.js +161 -24
  153. package/dist/tools/fiori/smoke/audit-columns.js +21 -0
  154. package/dist/tools/fiori/smoke/batch.js +273 -0
  155. package/dist/tools/fiori/smoke/draft-safety.js +241 -0
  156. package/dist/tools/fiori/smoke/draft-smoke.js +473 -0
  157. package/dist/tools/fiori/smoke/driver.js +171 -4
  158. package/dist/tools/fiori/smoke/evidence.js +35 -0
  159. package/dist/tools/fiori/smoke/labels-codes.js +207 -0
  160. package/dist/tools/fiori/smoke/nav-error.js +26 -0
  161. package/dist/tools/fiori/smoke/run-smoke.js +155 -12
  162. package/dist/tools/fiori/tools.js +651 -7
  163. package/dist/tools/fiori/ui5-version.js +16 -0
  164. package/dist/tools/index.js +8 -0
  165. package/dist/tools/local-build.js +6 -0
  166. package/dist/tools/sap-read.js +16 -1
  167. package/dist/tools/sap-write.js +122 -10
  168. package/dist/tools/shell/shell_exec.js +30 -12
  169. package/dist/tools/syntax-state.js +67 -0
  170. package/dist/tools/todo.js +23 -18
  171. package/dist/tools/transport-fit.js +462 -0
  172. package/dist/tools/transport-resolution.js +3 -1
  173. package/dist/tools/transport.js +33 -7
  174. package/dist/tools/verify.js +184 -8
  175. package/dist/ui/app.js +284 -146
  176. package/dist/ui/approval-modal.js +41 -15
  177. package/dist/ui/ask-answer-text.js +17 -0
  178. package/dist/ui/body.js +25 -6
  179. package/dist/ui/card-slot.js +98 -0
  180. package/dist/ui/checklist.js +9 -0
  181. package/dist/ui/coaching-picker-classic.js +8 -5
  182. package/dist/ui/command-palette.js +6 -4
  183. package/dist/ui/confirm-request.js +18 -0
  184. package/dist/ui/context-grid.js +2 -1
  185. package/dist/ui/file-palette.js +6 -4
  186. package/dist/ui/files-card-emitter.js +19 -0
  187. package/dist/ui/footer-line.js +16 -0
  188. package/dist/ui/footer.js +105 -104
  189. package/dist/ui/input-wrap.js +122 -0
  190. package/dist/ui/interrupt.js +103 -0
  191. package/dist/ui/key-burst.js +117 -0
  192. package/dist/ui/line-resolution.js +4 -3
  193. package/dist/ui/live-area.js +71 -0
  194. package/dist/ui/login-banner.js +54 -43
  195. package/dist/ui/rewind-panel.js +76 -24
  196. package/dist/ui/role-props.js +47 -0
  197. package/dist/ui/sap-state-store.js +1 -0
  198. package/dist/ui/session-timeline.js +49 -3
  199. package/dist/ui/skill-picker.js +5 -47
  200. package/dist/ui/text-input.js +128 -24
  201. package/dist/ui/todo-emitter.js +19 -0
  202. package/dist/ui/turn-status-emitter.js +28 -2
  203. package/dist/ui/turn-status.js +16 -19
  204. package/dist/ui/use-card-request.js +44 -0
  205. package/dist/ui/widgets/ask-card.js +521 -0
  206. package/dist/ui/widgets/bar-chart.js +11 -5
  207. package/dist/ui/widgets/coaching-picker.js +50 -55
  208. package/dist/ui/widgets/confirm-card.js +193 -0
  209. package/dist/ui/widgets/dep-graph.js +3 -2
  210. package/dist/ui/widgets/diff-viewer.js +6 -5
  211. package/dist/ui/widgets/files-card.js +97 -0
  212. package/dist/ui/widgets/question-card.js +2 -1
  213. package/dist/ui/widgets/reclaim-raw-stdin.js +24 -0
  214. package/dist/ui/widgets/shortcuts-sheet.js +25 -0
  215. package/dist/ui/widgets/stack-frames.js +2 -1
  216. package/dist/upgrade-check.js +39 -8
  217. package/package.json +11 -2
  218. package/test-harness/fe-draft/app.ts +188 -0
  219. package/test-harness/fe-draft/fe-draft.e2e.test.ts +461 -0
  220. package/test-harness/fe-draft/manifest-enhancer.unit.test.ts +51 -0
  221. package/test-harness/fe-draft/routes.ts +224 -0
  222. package/test-harness/fe-draft/routes.unit.test.ts +45 -0
  223. package/test-harness/fe-draft/server.ts +225 -0
  224. package/test-harness/fe-draft/variants.ts +196 -0
  225. package/vitest.smoke-e2e.config.ts +20 -0
@@ -1,4 +1,4 @@
1
1
  // GENERATED FILE — do not edit by hand.
2
2
  // Build artifact: ed25519 public key for SKILL.md body verification.
3
3
  // Regenerated by `pnpm prebuild` on each release.
4
- export const MANIFEST_PUBLIC_KEYS = ['a7Qj8ibfLTj49kQRTn1DOE/UAExuoBgjhaXxf33+JPY='];
4
+ export const MANIFEST_PUBLIC_KEYS = ['Nexqg3TSUYzVPirLv6h1tbCmpQF7MbAtVDmqoLAie+8=', 'a7Qj8ibfLTj49kQRTn1DOE/UAExuoBgjhaXxf33+JPY='];
@@ -156,7 +156,7 @@ export async function readStandardsSource(src) {
156
156
  return fs.readFile(src, 'utf8');
157
157
  }
158
158
  async function defaultPrompts() {
159
- const { input, select } = await import('@inquirer/prompts');
159
+ const { input, select } = await import('../repl/themed-prompts.js');
160
160
  return {
161
161
  select: (opts) => select({ message: opts.message, choices: opts.choices, default: opts.default }),
162
162
  input: (opts) => input({ message: opts.message, default: opts.default }),
@@ -0,0 +1,46 @@
1
+ // Channel-agnostic answerer for behaviour pins (piece 2, Part 4).
2
+ //
3
+ // Lives under src/ (not test/helpers/ as the plan named it): the pins that
4
+ // use it sit in src/**/__tests__, which tsc type-checks with rootDir 'src',
5
+ // so a helper outside src/ fails `pnpm typecheck` (TS6059).
6
+ //
7
+ // A confirm today goes through askQuestionEmitter (the v1 modal). After its
8
+ // migration it goes through cardSlotEmitter ('confirm'). This helper listens
9
+ // on BOTH and feeds the same logical answers in order, so a pin written before
10
+ // the migration stays valid, unchanged, after it. `null` = Esc / cancelled.
11
+ import { askQuestionEmitter } from '../ui/ask-question-emitter.js';
12
+ import { cardSlotEmitter } from '../ui/card-slot.js';
13
+ export function answerAny(...answers) {
14
+ const seen = [];
15
+ let i = 0;
16
+ const next = () => (i < answers.length ? (answers[i++] ?? null) : null);
17
+ const onAsk = (req) => {
18
+ seen.push({
19
+ id: req.id,
20
+ values: req.choices.map((c) => c.value),
21
+ initial: 0,
22
+ text: `${req.question}\n${req.context ?? ''}`,
23
+ });
24
+ const a = next();
25
+ req.resolve(a === null ? { answer: null, cancelled: true } : { answer: a });
26
+ };
27
+ const onCard = (req) => {
28
+ seen.push({
29
+ id: req.id,
30
+ values: req.choices.map((c) => c.value),
31
+ initial: req.initial ?? 0,
32
+ text: `${req.title}\n${(req.facts ?? []).join('\n')}`,
33
+ });
34
+ const a = next();
35
+ req.resolve(a === null ? { value: null, cancelled: true } : { value: a });
36
+ };
37
+ askQuestionEmitter.on('request', onAsk);
38
+ cardSlotEmitter.on('confirm', onCard);
39
+ return {
40
+ seen,
41
+ dispose: () => {
42
+ askQuestionEmitter.off('request', onAsk);
43
+ cardSlotEmitter.off('confirm', onCard);
44
+ },
45
+ };
46
+ }
@@ -1,7 +1,19 @@
1
1
  import chalk from 'chalk';
2
2
  import { registerTool } from './index.js';
3
- import { isHeadless } from '../renderer/tty.js';
4
- import { effectiveRisk } from '../approvals/risk-floor.js';
3
+ import { isHeadless, shouldUseInk } from '../renderer/tty.js';
4
+ import { effectiveRisk, riskReasonText } from '../approvals/risk-floor.js';
5
+ import { objectsWithSyntaxErrors } from './syntax-state.js';
6
+ import { noteWriteTransport } from '../repl/current-transport.js';
7
+ /**
8
+ * Round 8 item 6 — the footer showed "no transport" while the approved card
9
+ * said "transport S4HK903731". An approval granted for an explicit transport
10
+ * is the transport chosen for this work: the footer shows it (display-only
11
+ * wire — the session transport pointer and write fallback are untouched).
12
+ */
13
+ function noteApprovedTransport(t) {
14
+ if (typeof t === 'string' && /^[A-Z0-9]{3}K\d{6}$/i.test(t.trim()))
15
+ noteWriteTransport(t.trim().toUpperCase());
16
+ }
5
17
  import { mintChangeApproval } from '../approvals/jwt.js';
6
18
  import { canonicalApprovalObject } from '../approvals/canonical.js';
7
19
  import { renderPlanGate, renderPerChangeApprovalV3 } from '../approvals/render.js';
@@ -26,7 +38,7 @@ import { isGuardedRunActive, getCurrentPhaseWrites, notePlanDeviation, PLAN_DEVI
26
38
  * unused — the prompt and render are sapAlias-agnostic.
27
39
  */
28
40
  export async function handleAdvisoryApproval(args, _ctx) {
29
- const render = renderAdvisoryProposal(args);
41
+ const render = renderAdvisoryProposal(args, { interactiveHint: !shouldUseInk() });
30
42
  // B5 (2026-06-11) — headless: the advisory prompt blocks on stdin that
31
43
  // will never answer. Render the proposal (it IS the run's deliverable —
32
44
  // the developer reads the captured output and applies manually), skip
@@ -185,6 +197,7 @@ registerTool({
185
197
  const riskCtx = {
186
198
  sapAlias: ctx.sapAlias,
187
199
  autoApprovePolicy: sapCfg?.auto_approve ?? 'never',
200
+ syntaxErrorObjects: objectsWithSyntaxErrors(),
188
201
  };
189
202
  const changes = args.changes;
190
203
  const declared = args.risk;
@@ -233,18 +246,19 @@ registerTool({
233
246
  for (const c of changes) {
234
247
  // Task A3: mint via the canonical path — the JWT stores the same
235
248
  // object string the write tools' validators will compare against.
236
- ids.push(await mintChangeApproval(c, ctx.session.id));
249
+ ids.push(await mintChangeApproval(c, ctx.session.id, { auto: true }));
237
250
  }
238
251
  // Tell Rule 8 the user has already covered this batch — they
239
252
  // configured auto-approve, so no per-dispatch re-prompts.
240
253
  markPlanGateApproved();
254
+ noteApprovedTransport(args.transport); // round 8 item 6
241
255
  return {
242
256
  content: JSON.stringify({
243
257
  approved: true,
244
258
  auto_approved: true,
245
259
  approval_ids: ids,
246
260
  effective_risk: eff.level,
247
- raised_by: eff.raisedBy,
261
+ raised_by: eff.raisedBy.map(riskReasonText), // round 8 item 4: plain words, never codes
248
262
  }),
249
263
  };
250
264
  }
@@ -266,7 +280,7 @@ registerTool({
266
280
  'No approval IDs were minted — do not attempt any write operations. ' +
267
281
  'Summarise the proposed changes so the user can re-run interactively or switch write_mode to "advisory-only".',
268
282
  effective_risk: eff.level,
269
- raised_by: eff.raisedBy,
283
+ raised_by: eff.raisedBy.map(riskReasonText), // round 8 item 4: plain words, never codes
270
284
  }),
271
285
  is_error: true,
272
286
  };
@@ -278,14 +292,14 @@ registerTool({
278
292
  // gate. Cancel rejects the whole plan.
279
293
  let skipPerChangeGates = false;
280
294
  if (changes.length > 1) {
281
- const mode = await renderPlanGate(args.summary, changes, planGateTransport, eff);
295
+ const mode = await renderPlanGate(args.summary, changes, planGateTransport, eff, ctx.sapAlias);
282
296
  if (mode === 'cancel') {
283
297
  return {
284
298
  content: JSON.stringify({
285
299
  approved: false,
286
300
  reason: 'plan_rejected',
287
301
  effective_risk: eff.level,
288
- raised_by: eff.raisedBy,
302
+ raised_by: eff.raisedBy.map(riskReasonText), // round 8 item 4: plain words, never codes
289
303
  }),
290
304
  };
291
305
  }
@@ -347,7 +361,7 @@ registerTool({
347
361
  approved: false,
348
362
  reason: 'user_cancelled_turn',
349
363
  effective_risk: eff.level,
350
- raised_by: eff.raisedBy,
364
+ raised_by: eff.raisedBy.map(riskReasonText), // round 8 item 4: plain words, never codes
351
365
  }),
352
366
  };
353
367
  }
@@ -364,15 +378,17 @@ registerTool({
364
378
  // Tell Rule 8 the user has explicitly approved (some/all of) this
365
379
  // batch via the plan-gate / per-change flow. Suppresses the redundant
366
380
  // Rule 8 prompt at every subsequent dispatch this turn.
367
- if (approvalIds.length > 0)
381
+ if (approvalIds.length > 0) {
368
382
  markPlanGateApproved();
383
+ noteApprovedTransport(args.transport); // round 8 item 6
384
+ }
369
385
  return {
370
386
  content: JSON.stringify({
371
387
  approved: approvalIds.length > 0,
372
388
  approval_ids: approvalIds,
373
389
  skipped: rejections,
374
390
  effective_risk: eff.level,
375
- raised_by: eff.raisedBy,
391
+ raised_by: eff.raisedBy.map(riskReasonText), // round 8 item 4: plain words, never codes
376
392
  }),
377
393
  };
378
394
  },
@@ -22,7 +22,7 @@
22
22
  * it just got back and can cross-reference the question id.
23
23
  *
24
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
25
+ * ONE call): Ink renders a single <AskCard> (Task 2); classic runs the
26
26
  * questions as a sequential inquirer flow (Task 3). Both return
27
27
  * `{ formId, answers: [{id, answer, custom?}], cancelled? }`.
28
28
  */
@@ -32,8 +32,24 @@ import { input, select, checkbox } from '@inquirer/prompts';
32
32
  import { withInquirer } from '../repl/inquirer-guard.js';
33
33
  import { shouldUseInk, isHeadless } from '../renderer/tty.js';
34
34
  import { askQuestionEmitter } from '../ui/ask-question-emitter.js';
35
+ import { emitTrace, formatAnswerTrace } from '../renderer/trace.js';
36
+ import { truncateToWidth, uiWidth } from '../renderer/ui-width.js';
37
+ import { answerDisplayText } from '../ui/ask-answer-text.js';
35
38
  /** Chip labels in the v2 form are capped at 12 chars (brief-binding). */
36
39
  const HEADER_MAX_CHARS = 12;
40
+ /**
41
+ * Piece 2 / Q1c — scrollback trace labels (spec §3 "Scrollback trace").
42
+ * Ink only: classic inquirer already leaves its own answered line.
43
+ * v1 has no chip header, so the question is cut to a short label; v2 uses
44
+ * each question's header (the chip text the user saw).
45
+ */
46
+ const TRACE_QUESTION_CELLS = 48;
47
+ function v1TraceLabel(question) {
48
+ return truncateToWidth(question, TRACE_QUESTION_CELLS);
49
+ }
50
+ function formTraceLabel(form) {
51
+ return form.questions.map((q) => q.header ?? q.id).join(' · ');
52
+ }
37
53
  /**
38
54
  * UX Wave 2 / Task 1 — validation failure for a v2 `questions` payload.
39
55
  * The handler converts this into an error RESULT (`ask_form_invalid`),
@@ -45,6 +61,17 @@ export class AskFormValidationError extends Error {
45
61
  this.name = 'AskFormValidationError';
46
62
  }
47
63
  }
64
+ /**
65
+ * Fix round 3 (item 9c) — an option that plainly stops the flow ends a form
66
+ * even when the model forgot `ends_form`: asking the next question after
67
+ * "Stop here" was the live defect. Review I1: inferred ONLY from a "Stop" /
68
+ * "Stop here" label. "Cancel" / "Abort" can be real answers (a RAP action
69
+ * named Cancel), so they end the form only with an explicit `ends_form: true`.
70
+ */
71
+ const STOP_LABEL = /^stop( here)?$/i;
72
+ function isStopOption(o) {
73
+ return STOP_LABEL.test(o.label.trim());
74
+ }
48
75
  function normalizeOptions(raw) {
49
76
  if (!Array.isArray(raw))
50
77
  return [];
@@ -54,6 +81,8 @@ function normalizeOptions(raw) {
54
81
  opt.description = String(o.description);
55
82
  if (o.recommended === true)
56
83
  opt.recommended = true;
84
+ if (o.ends_form === true || isStopOption(opt))
85
+ opt.endsForm = true;
57
86
  return opt;
58
87
  });
59
88
  }
@@ -167,7 +196,7 @@ export function pickHeadlessChoices(kind, choices) {
167
196
  }
168
197
  registerTool({
169
198
  name: 'ask_question',
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.",
199
+ 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. Before asking the user to confirm a plan, write the plan out in your message first (objects, order, transport) — the form shows only the question, so a plan you have not written is a plan the user cannot see.",
171
200
  isMutating: false,
172
201
  input_schema: {
173
202
  type: 'object',
@@ -237,6 +266,10 @@ registerTool({
237
266
  type: 'boolean',
238
267
  description: 'Mark the option you would recommend. Auto-picked in headless runs.',
239
268
  },
269
+ ends_form: {
270
+ type: 'boolean',
271
+ description: 'Picking this option ends the form at once (e.g. "Stop here", or a Cancel that means stop asking); the remaining questions come back with answer null. Only a "Stop" / "Stop here" label is treated this way automatically — set this for any other stop option.',
272
+ },
240
273
  },
241
274
  required: ['value', 'label'],
242
275
  },
@@ -300,17 +333,24 @@ registerTool({
300
333
  };
301
334
  }
302
335
  // 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
336
+ // discriminated form request, App routes it to <AskCard> (v1 requests
337
+ // render the same AskCard), and the resolved
305
338
  // AskFormResult comes back here. Cancel (Esc / no listener) mirrors
306
339
  // the v1 cancel contract: empty answers + cancelled: true.
307
340
  if (shouldUseInk()) {
308
341
  const result = await askQuestionEmitter.requestForm(form);
309
342
  if (result.cancelled) {
343
+ emitTrace(formatAnswerTrace(formTraceLabel(form), 'cancelled', uiWidth()));
310
344
  return {
311
345
  content: JSON.stringify({ formId: form.formId, answers: [], cancelled: true }),
312
346
  };
313
347
  }
348
+ for (const a of result.answers) {
349
+ const q = form.questions.find((qq) => qq.id === a.id);
350
+ if (!q || a.answer === null)
351
+ continue;
352
+ emitTrace(formatAnswerTrace(q.header ?? q.id, answerDisplayText(a.answer, q.options, a.custom === true), uiWidth()));
353
+ }
314
354
  return {
315
355
  content: JSON.stringify({ formId: result.formId, answers: result.answers }),
316
356
  };
@@ -322,7 +362,7 @@ registerTool({
322
362
  // the v1 building blocks: select + "(type a custom answer)" escape
323
363
  // for pick-one, checkbox for multiSelect, input for freeText.
324
364
  //
325
- // Result shapes mirror the Ink AskForm EXACTLY (Task-2 contract):
365
+ // Result shapes mirror the Ink AskCard EXACTLY (Task-2 contract):
326
366
  // success → { formId, answers: [{ id, answer, custom? }] } — in
327
367
  // question order; custom:true ONLY for the escape.
328
368
  // Esc anywhere (ExitPromptError) → { formId, answers: [],
@@ -487,8 +527,10 @@ registerTool({
487
527
  choices: choices ?? [],
488
528
  });
489
529
  if (result.cancelled || result.answer === null || result.answer.length === 0) {
530
+ emitTrace(formatAnswerTrace(v1TraceLabel(question), 'cancelled', uiWidth()));
490
531
  return { content: JSON.stringify({ id, kind, answer: null, cancelled: true }) };
491
532
  }
533
+ emitTrace(formatAnswerTrace(v1TraceLabel(question), answerDisplayText(result.answer, kind === 'text' ? [] : (choices ?? [])), uiWidth()));
492
534
  return { content: JSON.stringify({ id, kind, answer: result.answer }) };
493
535
  }
494
536
  // Classic mode — inquirer path (unchanged from v0.6).
@@ -17,7 +17,7 @@
17
17
  *
18
18
  * Flag-gated: invisible to listTools() unless CSPEACH_TOOL_FILE_WRITE=on.
19
19
  */
20
- import { promises as fs } from 'node:fs';
20
+ import { promises as fs, readFileSync } from 'node:fs';
21
21
  import * as path from 'node:path';
22
22
  import { registerTool } from '../index.js';
23
23
  import { resolveSafePath, assertRealPathContained, PathOutsideRootError, DENYLIST_DESCRIPTION, isDenylistedPath, blockedExecutableExtension, } from '../_filesystem-shared.js';
@@ -30,13 +30,30 @@ import { resolveSafePath, assertRealPathContained, PathOutsideRootError, DENYLIS
30
30
  */
31
31
  let guideNoteShown = false;
32
32
  export function __resetGuideNoteForTests() { guideNoteShown = false; }
33
+ /**
34
+ * Dry run 2026-09-25: a CAP app's GUIDE.md was announced as "applying these
35
+ * objects in SAP". A CAP project (package.json depending on @sap/cds) has no
36
+ * SAP objects — its guide is about running and checking the app.
37
+ */
38
+ function isCapProject(cwd) {
39
+ try {
40
+ const pkg = JSON.parse(readFileSync(path.join(cwd, 'package.json'), 'utf8'));
41
+ return Boolean(pkg?.dependencies?.['@sap/cds'] ?? pkg?.devDependencies?.['@sap/cds']);
42
+ }
43
+ catch {
44
+ return false;
45
+ }
46
+ }
33
47
  function noteGuideWritten(relPath, ctx) {
34
48
  if (guideNoteShown)
35
49
  return;
36
50
  if (path.basename(relPath).toLowerCase() !== 'guide.md')
37
51
  return;
38
52
  guideNoteShown = true;
39
- ctx.chunkEmitter?.emit('info', `${relPath} is your step-by-step list for applying these objects in SAP — `
53
+ const what = isCapProject(ctx.cwd)
54
+ ? 'step-by-step list for running and checking this app'
55
+ : 'step-by-step list for applying these objects in SAP';
56
+ ctx.chunkEmitter?.emit('info', `${relPath} is your ${what} — `
40
57
  + 'open it at https://cspeach.dev/guide, or in any text editor. '
41
58
  + 'If a step fails, come back here and say "step N failed" with the error.');
42
59
  }
@@ -36,6 +36,21 @@ function isMissingFeLibError(msg) {
36
36
  * flush the result to disk. Throws NotV4FeAppError when the target is not a V4
37
37
  * FE app; rethrows any other writer error unchanged.
38
38
  */
39
+ /**
40
+ * A custom action must always get a working press handler. fe-fpm-writer reads
41
+ * `eventHandler` at the TOP level of the action config; a flag passed inside
42
+ * `settings` was silently ignored and the action was written with press "" — a
43
+ * button that does nothing (on UI5 1.136 without even an error). So: hoist a
44
+ * `settings.eventHandler` to the top level, and when no handler is given at all,
45
+ * generate one (true = a new ext/<Name> handler module).
46
+ */
47
+ export function withActionHandler(params) {
48
+ const settings = { ...(params.settings ?? {}) };
49
+ const fromSettings = settings.eventHandler;
50
+ delete settings.eventHandler;
51
+ const eventHandler = params.eventHandler ?? fromSettings ?? true;
52
+ return { ...params, settings, eventHandler };
53
+ }
39
54
  export async function feExtend(opts) {
40
55
  // Dynamic import: keep the heavy writer out of the cold-start path (mirrors
41
56
  // the engine's lazy-load convention; only paid when the tool actually runs).
@@ -50,7 +65,7 @@ export async function feExtend(opts) {
50
65
  result = await fpm.generateCustomColumn(opts.basePath, opts.params, editor);
51
66
  break;
52
67
  case 'custom-action':
53
- result = await fpm.generateCustomAction(opts.basePath, opts.params, editor);
68
+ result = await fpm.generateCustomAction(opts.basePath, withActionHandler(opts.params), editor);
54
69
  break;
55
70
  case 'custom-section':
56
71
  result = await fpm.generateCustomSection(opts.basePath, opts.params, editor);
@@ -1,12 +1,108 @@
1
- // cspeach-cli/src/tools/fiori/fe-scaffold.ts
2
- import { generate, TemplateType, OdataVersion } from '@sap-ux/fiori-elements-writer';
3
1
  import { create } from 'mem-fs';
4
2
  import { create as createEditor } from 'mem-fs-editor';
5
3
  import { join } from 'node:path';
6
- const DEFAULT_UI5_VERSION = '1.120.0'; // same rationale as scaffold.ts (avoids the 1.71 blank-page trap)
4
+ import { PINNED_UI5_VERSION } from './ui5-version.js';
5
+ // The ONE pinned, CDN-hosted patch (ui5-version.ts) — never an x.y.0 guess.
6
+ const DEFAULT_UI5_VERSION = PINNED_UI5_VERSION;
7
+ /**
8
+ * F-4: a generated list report loads its rows when it opens. The writers leave
9
+ * the FE default, which on a draft service shows "To start, set the relevant
10
+ * filters and choose Go" and no data — a user sees an empty app and the render
11
+ * smoke's `rows` check has nothing to count.
12
+ * V4 (sap.fe.templates.ListReport targets — LROP, Worklist, ALP): initialLoad "Enabled".
13
+ * V2 (sap.ui.generic.app list pages): dataLoadSettings.loadDataOnAppLaunch "always".
14
+ * Object pages and OVP are untouched.
15
+ */
16
+ /**
17
+ * The V4 `initialLoad` value for the target UI5 version. sap.fe's ListComponent
18
+ * typed it as a BOOLEAN up to 1.84 (S/4HANA 2020) and as
19
+ * sap.fe.core.InitialLoadMode ("Auto" | "Enabled" | "Disabled") from 1.96 —
20
+ * checked against the CDN sources 2026-09-24 (1.85–1.95 are not hosted and not
21
+ * shipped with an S/4 release). The wrong type throws during component creation
22
+ * and the list never renders.
23
+ */
24
+ export function initialLoadSetting(ui5Version) {
25
+ const minor = Number(/^1\.(\d+)/.exec(ui5Version)?.[1] ?? NaN);
26
+ return Number.isFinite(minor) && minor < 96 ? true : 'Enabled';
27
+ }
28
+ /**
29
+ * 2026-09-25: UI5 tooling 4's dev server (`fiori run` for ABAP apps,
30
+ * cds-plugin-ui5 for CAP apps) runs the manifest enhancer, which derives
31
+ * supportedLocales from the i18n files an app ships — only i18n.properties, so
32
+ * [""] — but adds no fallbackLocale, and UI5's default fallback 'en' is then
33
+ * "not contained in the list of supported locales", a console error at every
34
+ * boot. Declaring both on every bundle is the fix the tooling's own warning
35
+ * names ("configure another available fallbackLocale").
36
+ */
37
+ function declareBaseLocale(fs, manifestPath) {
38
+ if (!fs.exists(manifestPath))
39
+ return;
40
+ const manifest = fs.readJSON(manifestPath);
41
+ const locales = { supportedLocales: [''], fallbackLocale: '' };
42
+ const app = manifest?.['sap.app'];
43
+ if (app && typeof app.i18n === 'string')
44
+ app.i18n = { bundleUrl: app.i18n, ...locales };
45
+ else if (app?.i18n && typeof app.i18n === 'object')
46
+ Object.assign(app.i18n, locales);
47
+ const models = manifest?.['sap.ui5']?.models ?? {};
48
+ for (const m of Object.values(models)) {
49
+ if (m?.type === 'sap.ui.model.resource.ResourceModel')
50
+ m.settings = { ...(m.settings ?? {}), ...locales };
51
+ }
52
+ fs.writeJSON(manifestPath, manifest);
53
+ }
54
+ /**
55
+ * Dry run 2026-09-25: under cds-plugin-ui5 the app's ui5 middlewares run inside
56
+ * the CAP server process. fiori-tools-preview loads @sap/cds-dk's own nested
57
+ * @sap/cds — a second CAP copy — and every draft create then crashes the server
58
+ * ("no primary database" → lean-draft "Maximum call stack size exceeded"). The
59
+ * launchpad page is a real file (test/flpSandbox.html), so the preview
60
+ * middleware is not needed there. Exactly that entry goes; anything else
61
+ * unexpected in the writer's ui5.yaml fails loudly instead of shipping a crash.
62
+ */
63
+ export function withoutFioriToolsPreview(yaml) {
64
+ const lines = yaml.split(/\r?\n/);
65
+ const start = lines.findIndex((l) => /^ {4}- name: fiori-tools-preview\s*$/.test(l));
66
+ if (start < 0)
67
+ throw new Error('ui5.yaml has no fiori-tools-preview middleware entry to remove');
68
+ // The entry is every following line indented deeper than its "- name:" line.
69
+ let end = start + 1;
70
+ while (end < lines.length && /^ {5,}\S/.test(lines[end]))
71
+ end++;
72
+ return [...lines.slice(0, start), ...lines.slice(end)].join('\n');
73
+ }
74
+ function loadListsOnOpen(fs, manifestPath, ui5Version) {
75
+ if (!fs.exists(manifestPath))
76
+ return;
77
+ const manifest = fs.readJSON(manifestPath);
78
+ let changed = false;
79
+ const targets = manifest?.['sap.ui5']?.routing?.targets ?? {};
80
+ for (const t of Object.values(targets)) {
81
+ if (t?.name === 'sap.fe.templates.ListReport') {
82
+ t.options ??= {};
83
+ t.options.settings ??= {};
84
+ t.options.settings.initialLoad = initialLoadSetting(ui5Version);
85
+ changed = true;
86
+ }
87
+ }
88
+ const pages = manifest?.['sap.ui.generic.app']?.pages ?? {};
89
+ for (const p of Object.values(pages)) {
90
+ const name = p?.component?.name;
91
+ if (name === 'sap.suite.ui.generic.template.ListReport' || name === 'sap.suite.ui.generic.template.AnalyticalListPage') {
92
+ p.component.settings ??= {};
93
+ p.component.settings.dataLoadSettings = { loadDataOnAppLaunch: 'always' };
94
+ changed = true;
95
+ }
96
+ }
97
+ if (changed)
98
+ fs.writeJSON(manifestPath, manifest);
99
+ }
7
100
  /** Scaffold a Fiori Elements shell and FULLY flush it to disk. Relies on the
8
101
  * service's backend @UI annotations — authors no local annotation.xml. */
9
102
  export async function scaffoldFioriElements(opts) {
103
+ // Loaded on use (fix round 3, item 11e): this writer's module graph alone
104
+ // cost ~2.8 s of every CLI start, Fiori work or not.
105
+ const { generate, TemplateType, OdataVersion } = await import('@sap-ux/fiori-elements-writer');
10
106
  const ui5Version = opts.ui5Version ?? DEFAULT_UI5_VERSION;
11
107
  const version = opts.service.version === '2.0' ? OdataVersion.v2 : OdataVersion.v4;
12
108
  // Per-template settings (spike §3): LROP/Worklist/ALP use
@@ -62,6 +158,12 @@ export async function scaffoldFioriElements(opts) {
62
158
  ui5: { minUI5Version: ui5Version, version: ui5Version },
63
159
  };
64
160
  await generate(opts.basePath, config, fs);
161
+ loadListsOnOpen(fs, join(opts.basePath, 'webapp', 'manifest.json'), ui5Version);
162
+ declareBaseLocale(fs, join(opts.basePath, 'webapp', 'manifest.json'));
163
+ if (opts.backend === 'cap') {
164
+ const ui5YamlPath = join(opts.basePath, 'ui5.yaml');
165
+ fs.write(ui5YamlPath, withoutFioriToolsPreview(fs.read(ui5YamlPath)));
166
+ }
65
167
  if (!opts.localAnnotations) {
66
168
  // @sap-ux writers force-inject a 'localAnnotationsName: annotation' dataSource for
67
169
  // EDMX services even when none was requested, but only write the XML file when
@@ -0,0 +1,28 @@
1
+ // cspeach-cli/src/tools/fiori/html-escapes.ts
2
+ //
3
+ // Dry run 2026-09-25: an app header showed "Suppliers &amp; Quality
4
+ // Certificates". Two sources: the model sent the title already HTML-escaped,
5
+ // and the freestyle writer HTML-escapes the main view title into
6
+ // i18n.properties. i18n texts and titles are plain text — UI5 shows "&amp;"
7
+ // literally — so escapes are decoded, in ONE pass ("&amp;lt;" becomes "&lt;").
8
+ const NAMED = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" };
9
+ export function decodeHtmlEscapes(text) {
10
+ return text.replace(/&(amp|lt|gt|quot|apos|#\d{1,7}|#x[0-9a-fA-F]{1,6});/g, (m, name) => {
11
+ if (name[0] !== '#')
12
+ return NAMED[name] ?? m;
13
+ const code = name[1] === 'x' ? parseInt(name.slice(2), 16) : parseInt(name.slice(1), 10);
14
+ return code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : m;
15
+ });
16
+ }
17
+ /** Decode the VALUE of every `key=value` line of a .properties file; comments and keys untouched. */
18
+ export function decodePropertiesValues(properties) {
19
+ return properties
20
+ .split(/(\r?\n)/)
21
+ .map((part) => {
22
+ const eq = part.indexOf('=');
23
+ if (eq < 0 || /^\s*[#!]/.test(part))
24
+ return part;
25
+ return part.slice(0, eq + 1) + decodeHtmlEscapes(part.slice(eq + 1));
26
+ })
27
+ .join('');
28
+ }