@cspeach/cli 1.1.18 → 1.1.20

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 (252) hide show
  1. package/README.md +3 -4
  2. package/dist/agent/anthropic-provider.js +30 -10
  3. package/dist/agent/cache-keepalive.js +162 -0
  4. package/dist/agent/cold-prune.js +116 -0
  5. package/dist/agent/loop.js +804 -159
  6. package/dist/agent/provider-shape.js +263 -0
  7. package/dist/agent/providers/ai-hub-provider.js +17 -2
  8. package/dist/agent/providers/byok-provider.js +33 -3
  9. package/dist/agent/providers/local-provider.js +12 -2
  10. package/dist/agent/repair-partial.js +125 -7
  11. package/dist/agent/summarise-via-provider.js +6 -1
  12. package/dist/agent/system-prompt.js +38 -0
  13. package/dist/agent/tool-dispatch.js +66 -21
  14. package/dist/agent/tool-loading-pin.js +100 -0
  15. package/dist/agent/turn-error-ux.js +77 -26
  16. package/dist/agent/turn-stream.js +25 -3
  17. package/dist/approvals/adt-type.js +27 -0
  18. package/dist/approvals/advisory-prompt.js +48 -0
  19. package/dist/approvals/advisory-render.js +25 -11
  20. package/dist/approvals/approval-prompt.js +74 -24
  21. package/dist/approvals/jwt.js +2 -1
  22. package/dist/approvals/render.js +64 -36
  23. package/dist/approvals/risk-floor.js +53 -2
  24. package/dist/auth/api-key.js +30 -16
  25. package/dist/auth/keychain.js +0 -0
  26. package/dist/auth/me.js +25 -7
  27. package/dist/cli.js +36 -6
  28. package/dist/commands/auto-compact.js +33 -16
  29. package/dist/commands/compact.js +49 -11
  30. package/dist/commands/config-set.js +16 -3
  31. package/dist/commands/config-show.js +18 -5
  32. package/dist/commands/cost.js +54 -35
  33. package/dist/commands/help.js +88 -50
  34. package/dist/commands/login.js +28 -19
  35. package/dist/commands/logout.js +7 -3
  36. package/dist/commands/plan-audit.js +1 -0
  37. package/dist/commands/plan-chain.js +71 -77
  38. package/dist/commands/plan-continue.js +1 -0
  39. package/dist/commands/plan-resume.js +20 -12
  40. package/dist/commands/whoami.js +23 -9
  41. package/dist/config/loader.js +70 -5
  42. package/dist/cost/cost-log.js +62 -2
  43. package/dist/cost/pricing.js +11 -6
  44. package/dist/doctor/checks/forge-rules.js +13 -3
  45. package/dist/doctor/checks/keychain.js +6 -4
  46. package/dist/doctor/run.js +101 -20
  47. package/dist/index.js +5 -2
  48. package/dist/lib/graceful-exit.js +56 -0
  49. package/dist/lib/piped-prompt.js +65 -0
  50. package/dist/lib/spill-labels.js +13 -0
  51. package/dist/lock-contention.js +2 -2
  52. package/dist/models/resolve.js +93 -2
  53. package/dist/models/server-config.js +158 -3
  54. package/dist/one-shot.js +150 -45
  55. package/dist/projects/answer-blockers.js +6 -1
  56. package/dist/projects/handover-md.js +15 -14
  57. package/dist/projects/image-attachments.js +15 -2
  58. package/dist/projects/plan-run.js +97 -54
  59. package/dist/projects/promote-command.js +11 -2
  60. package/dist/projects/save-command.js +3 -2
  61. package/dist/projects/status.js +2 -1
  62. package/dist/projects/yes-no.js +12 -0
  63. package/dist/renderer/abap-inline.js +18 -14
  64. package/dist/renderer/answer-trim.js +69 -0
  65. package/dist/renderer/banners.js +8 -8
  66. package/dist/renderer/brand-settled.js +19 -0
  67. package/dist/renderer/change-card.js +156 -0
  68. package/dist/renderer/checklist-format.js +130 -0
  69. package/dist/renderer/color-mode.js +45 -0
  70. package/dist/renderer/error-detail.js +104 -0
  71. package/dist/renderer/fence-state.js +46 -0
  72. package/dist/renderer/footer-line.js +100 -0
  73. package/dist/renderer/glyphs.js +25 -0
  74. package/dist/renderer/goodbye.js +38 -0
  75. package/dist/renderer/legacy-palette.js +41 -0
  76. package/dist/renderer/look.js +30 -0
  77. package/dist/renderer/markdown.js +299 -92
  78. package/dist/renderer/notice-log.js +72 -0
  79. package/dist/renderer/notice-shape.js +48 -0
  80. package/dist/renderer/notices.js +40 -8
  81. package/dist/renderer/panel-rows.js +16 -0
  82. package/dist/renderer/pipeline.js +66 -0
  83. package/dist/renderer/progress-chatter.js +23 -17
  84. package/dist/renderer/rendering-mode.js +34 -0
  85. package/dist/renderer/routing-line.js +14 -0
  86. package/dist/renderer/sanitize.js +12 -0
  87. package/dist/renderer/severity.js +2 -7
  88. package/dist/renderer/startup-lines.js +154 -0
  89. package/dist/renderer/status-footer.js +52 -34
  90. package/dist/renderer/steering-echo.js +41 -0
  91. package/dist/renderer/syntax.js +31 -12
  92. package/dist/renderer/tables.js +5 -1
  93. package/dist/renderer/theme.js +44 -0
  94. package/dist/renderer/thinking-heartbeat.js +33 -2
  95. package/dist/renderer/todo-block.js +10 -49
  96. package/dist/renderer/tool-labels.js +269 -0
  97. package/dist/renderer/tool-widget.js +245 -51
  98. package/dist/renderer/trace.js +42 -0
  99. package/dist/renderer/transcript-flow.js +132 -0
  100. package/dist/renderer/tty.js +34 -1
  101. package/dist/renderer/ui-width.js +55 -0
  102. package/dist/renderer/verify-chain.js +4 -2
  103. package/dist/renderer/widget-fallback.js +58 -65
  104. package/dist/repl/bracketed-paste.js +7 -1
  105. package/dist/repl/builtin-commands.js +19 -8
  106. package/dist/repl/credential-handover-gate.js +28 -0
  107. package/dist/repl/current-transport.js +13 -0
  108. package/dist/repl/early-line-buffer.js +5 -2
  109. package/dist/repl/file-picker.js +54 -12
  110. package/dist/repl/ink-stdin-guard.js +66 -0
  111. package/dist/repl/inquirer-guard.js +59 -16
  112. package/dist/repl/inquirer-theme.js +27 -33
  113. package/dist/repl/paste-marker.js +19 -0
  114. package/dist/repl/plan-turn-end.js +20 -0
  115. package/dist/repl/post-turn-status.js +43 -11
  116. package/dist/repl/reroute-turn.js +21 -0
  117. package/dist/repl/reset-tty-stdin.js +44 -0
  118. package/dist/repl/restore-guard.js +22 -0
  119. package/dist/repl/resume-standalone.js +63 -0
  120. package/dist/repl/rule8-detector.js +11 -3
  121. package/dist/repl/safety-confirm.js +161 -93
  122. package/dist/repl/session-spend-line.js +8 -4
  123. package/dist/repl/themed-prompts.js +19 -0
  124. package/dist/repl/ui-look-command.js +75 -0
  125. package/dist/repl/update-method-preview-hook.js +48 -5
  126. package/dist/repl.js +863 -339
  127. package/dist/rewind/cli.js +3 -2
  128. package/dist/rewind/format.js +14 -9
  129. package/dist/rewind/restore.js +11 -0
  130. package/dist/router/classifier.js +47 -4
  131. package/dist/router/intent-extractor.js +24 -8
  132. package/dist/router/piped-routing.js +23 -0
  133. package/dist/router/resume-routing.js +16 -0
  134. package/dist/router/routing-failure.js +57 -0
  135. package/dist/sap/first-run-choice.js +1 -1
  136. package/dist/sap/onboarding.js +3 -2
  137. package/dist/sap/standalone-onboarding.js +1 -1
  138. package/dist/sap/system-info.js +4 -2
  139. package/dist/sap/unreachable.js +28 -0
  140. package/dist/sap-errors/clean-error-text.js +182 -0
  141. package/dist/session/interrupt-reason.js +39 -0
  142. package/dist/session/recap.js +36 -24
  143. package/dist/session/repin-model.js +18 -0
  144. package/dist/session/resume.js +104 -36
  145. package/dist/session/store.js +84 -28
  146. package/dist/session/user-prompt.js +21 -0
  147. package/dist/skill-catalog.js +7 -0
  148. package/dist/skills/bundled-skills.js +76 -69
  149. package/dist/skills/preamble.js +75 -0
  150. package/dist/skills/source-manifest.js +11 -1
  151. package/dist/standards/standards-init.js +1 -1
  152. package/dist/test-helpers/answer-any.js +46 -0
  153. package/dist/tools/approval.js +27 -11
  154. package/dist/tools/ask-question.js +47 -5
  155. package/dist/tools/filesystem/file-read.js +11 -1
  156. package/dist/tools/filesystem/file-write.js +19 -2
  157. package/dist/tools/fiori/fe-extend.js +16 -1
  158. package/dist/tools/fiori/fe-scaffold.js +105 -3
  159. package/dist/tools/fiori/html-escapes.js +28 -0
  160. package/dist/tools/fiori/metadata/audit.js +605 -0
  161. package/dist/tools/fiori/metadata/references.js +104 -0
  162. package/dist/tools/fiori/metadata/types.js +8 -0
  163. package/dist/tools/fiori/preview/app-guard.js +215 -0
  164. package/dist/tools/fiori/preview/env.js +193 -0
  165. package/dist/tools/fiori/preview/readiness.js +127 -0
  166. package/dist/tools/fiori/preview/registry.js +412 -0
  167. package/dist/tools/fiori/preview/start.js +469 -0
  168. package/dist/tools/fiori/samples/loader.js +20 -5
  169. package/dist/tools/fiori/scaffold.js +18 -6
  170. package/dist/tools/fiori/smoke/app-driver-page.js +372 -0
  171. package/dist/tools/fiori/smoke/app-driver.js +100 -0
  172. package/dist/tools/fiori/smoke/assertions.js +161 -24
  173. package/dist/tools/fiori/smoke/audit-columns.js +21 -0
  174. package/dist/tools/fiori/smoke/batch.js +273 -0
  175. package/dist/tools/fiori/smoke/draft-safety.js +241 -0
  176. package/dist/tools/fiori/smoke/draft-smoke.js +473 -0
  177. package/dist/tools/fiori/smoke/driver.js +171 -4
  178. package/dist/tools/fiori/smoke/evidence.js +35 -0
  179. package/dist/tools/fiori/smoke/labels-codes.js +207 -0
  180. package/dist/tools/fiori/smoke/nav-error.js +26 -0
  181. package/dist/tools/fiori/smoke/run-smoke.js +155 -12
  182. package/dist/tools/fiori/tools.js +651 -7
  183. package/dist/tools/fiori/ui5-version.js +16 -0
  184. package/dist/tools/index.js +8 -0
  185. package/dist/tools/local-build.js +6 -0
  186. package/dist/tools/result-spill.js +238 -0
  187. package/dist/tools/sap-read.js +74 -15
  188. package/dist/tools/sap-write.js +122 -10
  189. package/dist/tools/shell/shell_exec.js +39 -12
  190. package/dist/tools/subagent/adt-serial.js +33 -0
  191. package/dist/tools/subagent/agent_run.js +2 -0
  192. package/dist/tools/subagent/read_agent.js +178 -0
  193. package/dist/tools/subagent/reader-prompt.js +48 -0
  194. package/dist/tools/syntax-state.js +67 -0
  195. package/dist/tools/todo.js +26 -19
  196. package/dist/tools/tool-loading.js +255 -0
  197. package/dist/tools/tool-output-read.js +117 -0
  198. package/dist/tools/transport-fit.js +462 -0
  199. package/dist/tools/transport-resolution.js +3 -1
  200. package/dist/tools/transport.js +39 -8
  201. package/dist/tools/verify.js +184 -8
  202. package/dist/ui/app.js +284 -146
  203. package/dist/ui/approval-modal.js +41 -15
  204. package/dist/ui/ask-answer-text.js +17 -0
  205. package/dist/ui/body.js +25 -6
  206. package/dist/ui/card-slot.js +98 -0
  207. package/dist/ui/checklist.js +9 -0
  208. package/dist/ui/coaching-picker-classic.js +8 -5
  209. package/dist/ui/command-palette.js +6 -4
  210. package/dist/ui/confirm-request.js +18 -0
  211. package/dist/ui/context-grid.js +2 -1
  212. package/dist/ui/file-palette.js +6 -4
  213. package/dist/ui/files-card-emitter.js +19 -0
  214. package/dist/ui/footer-line.js +16 -0
  215. package/dist/ui/footer.js +105 -104
  216. package/dist/ui/input-wrap.js +122 -0
  217. package/dist/ui/interrupt.js +103 -0
  218. package/dist/ui/key-burst.js +117 -0
  219. package/dist/ui/line-resolution.js +4 -3
  220. package/dist/ui/live-area.js +71 -0
  221. package/dist/ui/login-banner.js +54 -43
  222. package/dist/ui/rewind-panel.js +76 -24
  223. package/dist/ui/role-props.js +47 -0
  224. package/dist/ui/sap-state-store.js +2 -1
  225. package/dist/ui/session-timeline.js +49 -3
  226. package/dist/ui/skill-picker.js +5 -47
  227. package/dist/ui/text-input.js +128 -24
  228. package/dist/ui/todo-emitter.js +19 -0
  229. package/dist/ui/turn-status-emitter.js +65 -2
  230. package/dist/ui/turn-status.js +16 -19
  231. package/dist/ui/use-card-request.js +44 -0
  232. package/dist/ui/widgets/ask-card.js +521 -0
  233. package/dist/ui/widgets/bar-chart.js +11 -5
  234. package/dist/ui/widgets/coaching-picker.js +50 -55
  235. package/dist/ui/widgets/confirm-card.js +193 -0
  236. package/dist/ui/widgets/dep-graph.js +3 -2
  237. package/dist/ui/widgets/diff-viewer.js +6 -5
  238. package/dist/ui/widgets/files-card.js +97 -0
  239. package/dist/ui/widgets/question-card.js +2 -1
  240. package/dist/ui/widgets/reclaim-raw-stdin.js +24 -0
  241. package/dist/ui/widgets/shortcuts-sheet.js +25 -0
  242. package/dist/ui/widgets/stack-frames.js +2 -1
  243. package/dist/upgrade-check.js +39 -8
  244. package/package.json +12 -2
  245. package/test-harness/fe-draft/app.ts +188 -0
  246. package/test-harness/fe-draft/fe-draft.e2e.test.ts +461 -0
  247. package/test-harness/fe-draft/manifest-enhancer.unit.test.ts +51 -0
  248. package/test-harness/fe-draft/routes.ts +224 -0
  249. package/test-harness/fe-draft/routes.unit.test.ts +45 -0
  250. package/test-harness/fe-draft/server.ts +225 -0
  251. package/test-harness/fe-draft/variants.ts +196 -0
  252. package/vitest.smoke-e2e.config.ts +20 -0
@@ -0,0 +1,75 @@
1
+ /**
2
+ * CLI copy of cspeach-proxy/src/skills/preamble.ts FORGE_PRINCIPLES_PREAMBLE.
3
+ *
4
+ * Task 5 (2026-09-27, ruling F3): BYOK, AI-hub and local build the system
5
+ * prompt client-side, so they need the same preamble the proxy prepends for
6
+ * managed turns. src/skills/__tests__/preamble-pin.test.ts asserts the two
7
+ * template literals stay byte-equal — edit both files together.
8
+ */
9
+ export const FORGE_PRINCIPLES_PREAMBLE = `# CSPeach Principles
10
+
11
+ You are operating inside CSPeach, an AI-assisted ABAP development tool with
12
+ direct access to the user's SAP system via ADT. Follow these principles on
13
+ every turn. The active skill may specialize your behavior; these principles
14
+ take precedence if a skill's instruction conflicts with them.
15
+
16
+ ## Correctness over speed
17
+ - Read before writing. Use sap_get_source and sap_object_structure to ground
18
+ your understanding in the real code before proposing changes.
19
+ - Never hallucinate method names, field names, parameter types, or table
20
+ structures. If you're not sure, look it up.
21
+ - When modifying method logic without changing the class definition
22
+ (no new/removed methods, no signature changes), use sap_update_method
23
+ per method. Do NOT rewrite the full class with sap_set_source.
24
+ - Batch independent reads in one response.
25
+ - When a result says it was saved to a file, read the slice you need with
26
+ tool_output_read(path, offset, limit); do not re-run the command.
27
+
28
+ ## Heavy reading
29
+ - Send more than two source reads, any where-used scan, ATC-finding triage or
30
+ a package inventory to a reader: call read_agent with a precise brief (what
31
+ to read, what to report) and work from its report. Do not read those sources
32
+ yourself when the turn will keep working afterwards.
33
+ - Do not use a reader for one or two reads, a small edit, or to check your own
34
+ work. Never more than three readers for one task unless the user asks. Send
35
+ independent readers in one response so they run at the same time.
36
+
37
+ ## Safety gates are mandatory
38
+ - Mutating tools (sap_set_source, sap_update_method, sap_delete_object,
39
+ sap_activate, sap_create_object, sap_transport_*, sap_message_maintain,
40
+ sap_number_range_intervals, sap_service_binding_publish) require a valid
41
+ approval_id from request_approval. You cannot bypass this.
42
+ - Snapshots are taken automatically before every write — do not ask the
43
+ user to confirm snapshots.
44
+ - After every write, syntax is checked and activation is verified. You
45
+ will see results as tool_results. Do not activate if syntax errors exist.
46
+
47
+ ## Plan before batch
48
+ - If you will modify more than one object, call request_approval with all
49
+ changes in a single changes[] array first. The user sees a holistic plan
50
+ before per-change prompts.
51
+ - If a plan is rejected, ask the user what to change. Do not silently
52
+ revise and re-submit.
53
+
54
+ ## Transport discipline
55
+ - All writes go into the active session transport. Do not ask the user
56
+ for a transport number per change.
57
+ - Never use \$TMP unless the user explicitly requests local-only work.
58
+
59
+ ## Escalate, don't fail silently
60
+ - If a tool returns an error you don't know how to recover from, surface
61
+ the error to the user with a clear explanation and ask how to proceed.
62
+ - If ATC or syntax errors block activation, fix them or ask — never
63
+ suppress or hide.
64
+ - Stop and ask the user before a 4th attempt on the same operation. Do not keep retrying silently.
65
+
66
+ ## Ask when unsure
67
+ - For ambiguous requests, ask one clarifying question before acting.
68
+ - For destructive operations on production-namespaced objects, confirm
69
+ intent in text even if approval_id is granted.
70
+
71
+ ## One skill per turn
72
+ - If the user's request is better served by a different skill, call
73
+ switch_skill with a clear reason. Do not try to do the work of another
74
+ skill yourself.
75
+ `;
@@ -2,6 +2,11 @@ import { ManifestClient } from './manifest-client.js';
2
2
  export function createManifestSkillSource(manifestUrl) {
3
3
  const client = new ManifestClient(manifestUrl);
4
4
  let cachedManifest = null;
5
+ // Task 5 (2026-09-27) — BYOK / AI-hub fetch the skill body on every model
6
+ // call (the system prompt is built client-side). Cache the sha-verified body
7
+ // for the source's lifetime, like the manifest, so each tool-loop round does
8
+ // not hit the network. Failures are not cached.
9
+ const bodyCache = new Map();
5
10
  async function getManifest() {
6
11
  if (!cachedManifest)
7
12
  cachedManifest = await client.fetchAndVerifyManifest();
@@ -13,7 +18,12 @@ export function createManifestSkillSource(manifestUrl) {
13
18
  const entry = m.skills.find(s => s.name === name);
14
19
  if (!entry)
15
20
  throw new Error(`skill '${name}' not found in manifest (available: ${m.skills.map(s => s.name).join(', ')})`);
16
- return client.fetchSkillBody(name, entry.body_url, { expectedSha256: entry.sha256 });
21
+ const cached = bodyCache.get(name);
22
+ if (cached && cached.sha256 === entry.sha256)
23
+ return cached;
24
+ const body = await client.fetchSkillBody(name, entry.body_url, { expectedSha256: entry.sha256 });
25
+ bodyCache.set(name, body);
26
+ return body;
17
27
  },
18
28
  async listAvailableSkills() {
19
29
  const m = await getManifest();
@@ -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).
@@ -11,6 +11,8 @@
11
11
  import { promises as fs } from 'node:fs';
12
12
  import { registerTool } from '../index.js';
13
13
  import { resolveSafePath, assertRealPathContained, PathOutsideRootError, DENYLIST_DESCRIPTION, isDenylistedPath } from '../_filesystem-shared.js';
14
+ import { spillToolResult, isUnderSpillRoot } from '../result-spill.js';
15
+ import { basename } from 'node:path';
14
16
  const MAX_LINES = 2000;
15
17
  export async function fileReadHandler(args, ctx) {
16
18
  // Validate offset and limit BEFORE clamping
@@ -68,7 +70,15 @@ export async function fileReadHandler(args, ctx) {
68
70
  const trailer = truncated
69
71
  ? `\n... (truncated, ${allLines.length - (offset + window.length)} more lines — re-read with offset=${offset + window.length})`
70
72
  : '';
71
- return { content: numbered + trailer };
73
+ // Task 17 (spec D12) — a window above 8 000 chars is saved to the session
74
+ // spill file and previewed; today's trailer stays last, byte-identical. A
75
+ // file already under the spill root is a slice already: never re-spilled.
76
+ // The spill keeps the FILE's line numbers (a `.n.txt` spill, which
77
+ // tool_output_read shows unnumbered-again) — one numbering space.
78
+ const body = isUnderSpillRoot(realAbs)
79
+ ? numbered
80
+ : spillToolResult({ content: numbered }, ctx, 'file', { numbered: true, label: basename(realAbs) }).content;
81
+ return { content: body + trailer };
72
82
  }
73
83
  registerTool({
74
84
  name: 'file_read',
@@ -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
+ }