@cspeach/cli 1.1.18 → 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 (224) 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 +58 -21
  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/standards/standards-init.js +1 -1
  132. package/dist/test-helpers/answer-any.js +46 -0
  133. package/dist/tools/approval.js +27 -11
  134. package/dist/tools/ask-question.js +47 -5
  135. package/dist/tools/filesystem/file-write.js +19 -2
  136. package/dist/tools/fiori/fe-extend.js +16 -1
  137. package/dist/tools/fiori/fe-scaffold.js +105 -3
  138. package/dist/tools/fiori/html-escapes.js +28 -0
  139. package/dist/tools/fiori/metadata/audit.js +605 -0
  140. package/dist/tools/fiori/metadata/references.js +104 -0
  141. package/dist/tools/fiori/metadata/types.js +8 -0
  142. package/dist/tools/fiori/preview/app-guard.js +215 -0
  143. package/dist/tools/fiori/preview/env.js +193 -0
  144. package/dist/tools/fiori/preview/readiness.js +127 -0
  145. package/dist/tools/fiori/preview/registry.js +412 -0
  146. package/dist/tools/fiori/preview/start.js +469 -0
  147. package/dist/tools/fiori/samples/loader.js +20 -5
  148. package/dist/tools/fiori/scaffold.js +18 -6
  149. package/dist/tools/fiori/smoke/app-driver-page.js +372 -0
  150. package/dist/tools/fiori/smoke/app-driver.js +100 -0
  151. package/dist/tools/fiori/smoke/assertions.js +161 -24
  152. package/dist/tools/fiori/smoke/audit-columns.js +21 -0
  153. package/dist/tools/fiori/smoke/batch.js +273 -0
  154. package/dist/tools/fiori/smoke/draft-safety.js +241 -0
  155. package/dist/tools/fiori/smoke/draft-smoke.js +473 -0
  156. package/dist/tools/fiori/smoke/driver.js +171 -4
  157. package/dist/tools/fiori/smoke/evidence.js +35 -0
  158. package/dist/tools/fiori/smoke/labels-codes.js +207 -0
  159. package/dist/tools/fiori/smoke/nav-error.js +26 -0
  160. package/dist/tools/fiori/smoke/run-smoke.js +155 -12
  161. package/dist/tools/fiori/tools.js +651 -7
  162. package/dist/tools/fiori/ui5-version.js +16 -0
  163. package/dist/tools/index.js +8 -0
  164. package/dist/tools/local-build.js +6 -0
  165. package/dist/tools/sap-read.js +16 -1
  166. package/dist/tools/sap-write.js +122 -10
  167. package/dist/tools/shell/shell_exec.js +30 -12
  168. package/dist/tools/syntax-state.js +67 -0
  169. package/dist/tools/todo.js +23 -18
  170. package/dist/tools/transport-fit.js +462 -0
  171. package/dist/tools/transport-resolution.js +3 -1
  172. package/dist/tools/transport.js +33 -7
  173. package/dist/tools/verify.js +184 -8
  174. package/dist/ui/app.js +284 -146
  175. package/dist/ui/approval-modal.js +41 -15
  176. package/dist/ui/ask-answer-text.js +17 -0
  177. package/dist/ui/body.js +25 -6
  178. package/dist/ui/card-slot.js +98 -0
  179. package/dist/ui/checklist.js +9 -0
  180. package/dist/ui/coaching-picker-classic.js +8 -5
  181. package/dist/ui/command-palette.js +6 -4
  182. package/dist/ui/confirm-request.js +18 -0
  183. package/dist/ui/context-grid.js +2 -1
  184. package/dist/ui/file-palette.js +6 -4
  185. package/dist/ui/files-card-emitter.js +19 -0
  186. package/dist/ui/footer-line.js +16 -0
  187. package/dist/ui/footer.js +105 -104
  188. package/dist/ui/input-wrap.js +122 -0
  189. package/dist/ui/interrupt.js +103 -0
  190. package/dist/ui/key-burst.js +117 -0
  191. package/dist/ui/line-resolution.js +4 -3
  192. package/dist/ui/live-area.js +71 -0
  193. package/dist/ui/login-banner.js +54 -43
  194. package/dist/ui/rewind-panel.js +76 -24
  195. package/dist/ui/role-props.js +47 -0
  196. package/dist/ui/sap-state-store.js +1 -0
  197. package/dist/ui/session-timeline.js +49 -3
  198. package/dist/ui/skill-picker.js +5 -47
  199. package/dist/ui/text-input.js +128 -24
  200. package/dist/ui/todo-emitter.js +19 -0
  201. package/dist/ui/turn-status-emitter.js +28 -2
  202. package/dist/ui/turn-status.js +16 -19
  203. package/dist/ui/use-card-request.js +44 -0
  204. package/dist/ui/widgets/ask-card.js +521 -0
  205. package/dist/ui/widgets/bar-chart.js +11 -5
  206. package/dist/ui/widgets/coaching-picker.js +50 -55
  207. package/dist/ui/widgets/confirm-card.js +193 -0
  208. package/dist/ui/widgets/dep-graph.js +3 -2
  209. package/dist/ui/widgets/diff-viewer.js +6 -5
  210. package/dist/ui/widgets/files-card.js +97 -0
  211. package/dist/ui/widgets/question-card.js +2 -1
  212. package/dist/ui/widgets/reclaim-raw-stdin.js +24 -0
  213. package/dist/ui/widgets/shortcuts-sheet.js +25 -0
  214. package/dist/ui/widgets/stack-frames.js +2 -1
  215. package/dist/upgrade-check.js +39 -8
  216. package/package.json +11 -2
  217. package/test-harness/fe-draft/app.ts +188 -0
  218. package/test-harness/fe-draft/fe-draft.e2e.test.ts +461 -0
  219. package/test-harness/fe-draft/manifest-enhancer.unit.test.ts +51 -0
  220. package/test-harness/fe-draft/routes.ts +224 -0
  221. package/test-harness/fe-draft/routes.unit.test.ts +45 -0
  222. package/test-harness/fe-draft/server.ts +225 -0
  223. package/test-harness/fe-draft/variants.ts +196 -0
  224. package/vitest.smoke-e2e.config.ts +20 -0
@@ -6,6 +6,12 @@
6
6
  // picker, the tracker renderer, and the revision builder unit-testable
7
7
  // without touching disk.
8
8
  import chalk from 'chalk';
9
+ import stringWidth from 'string-width';
10
+ import { getGlyphs } from '../renderer/glyphs.js';
11
+ import { paint } from '../renderer/theme.js';
12
+ import { countLabel } from '../renderer/checklist-format.js';
13
+ import { sanitizeControlChars } from '../renderer/sanitize.js';
14
+ import { truncateToWidth, uiWidth } from '../renderer/ui-width.js';
9
15
  /** Statuses a dead session may have left behind — resumable, picked before fresh `todo`s. */
10
16
  const IN_PROGRESS = ['designing', 'building', 'verifying'];
11
17
  /**
@@ -29,8 +35,8 @@ export function isAuditResolved(audit) {
29
35
  // against the work — and the phase already passed its own exit gate
30
36
  // (activation + ATC) — so walling verified work on an infra outage is wrong.
31
37
  // It resolves like a warn: dependants unlock, the chain continues, and the
32
- // "not verified" fact is surfaced (loud chain note + the ⚠ audit-not-run
33
- // glyph) and recorded (state stays infra_failed; NOT laundered to waived).
38
+ // "not verified" fact is surfaced (loud chain note + the `! audit not run`
39
+ // tag) and recorded (state stays infra_failed; NOT laundered to waived).
34
40
  return audit.state === 'passed' || audit.state === 'waived'
35
41
  || audit.state === 'warn' || audit.state === 'infra_failed';
36
42
  }
@@ -85,13 +91,14 @@ export function computeNextPhase(phases, statuses) {
85
91
  }
86
92
  /**
87
93
  * Plan-progress board printed before and after every resume turn (and by
88
- * `--status` for plan envelopes). Plain text — flows through chunkEmitter
89
- * in Ink mode and console.log in classic mode alike.
94
+ * `--status` for plan envelopes). Flows through chunkEmitter in Ink mode and
95
+ * console.log in classic mode alike.
90
96
  *
91
- * ─ Plan: HR Dayforce Extract (v3) ── 2/7 validated ─
92
- * ✔ c1.types abap-data-model → ZDOM_DAYF_STATUS (S4HK903412)
93
- * ▶ c1.orchestration abap-generate ← THIS SESSION
94
- * ○ c1.integration abap-generate (needs c1.orchestration)
97
+ * HR Dayforce Extract (v3) · 2 of 7 done
98
+ *
99
+ * ✓ Types — ZDOM_DAYF_STATUS ✓ audited
100
+ * ◼ Tables — Reason checktable + text table active
101
+ * ☐ Service — Service binding published
95
102
  */
96
103
  // Human phase labels — a phase is named for the KIND of work it does, so a
97
104
  // non-ABAP reader can follow the plan. The exit-gate / produced objects carry
@@ -140,89 +147,120 @@ function phaseDetail(p, s) {
140
147
  return shortGate(p.exitGate);
141
148
  }
142
149
  /**
143
- * Auditor-verdict glyph rendered after the phase status (2026-07-03,
144
- * agentic-flow). Empty string when the phase carries no audit — legacy
145
- * envelopes must render byte-identical to the pre-audit tracker.
150
+ * Audit tag after the phase status (spec §4 words): `✓ audited`, `! N notes`,
151
+ * `✗ audit failed`, `· hand-edited`, `· waived`, `! audit not run`. Empty
152
+ * string when the phase carries no audit — legacy envelopes render no tag.
146
153
  */
147
- function auditGlyph(audit) {
154
+ function auditTag(audit) {
148
155
  if (!audit)
149
156
  return '';
157
+ const g = getGlyphs();
150
158
  switch (audit.state) {
151
- case 'passed': return ` ${chalk.green('✔ audited')}`;
159
+ case 'passed': return ` ${paint('ok', `${g.done} audited`)}`;
152
160
  case 'warn': {
153
161
  // Non-blocking tier: validated-WITH-NOTES. Distinct from a clean pass and
154
162
  // from the "not run" states — it ran, passed, and carries N notes.
155
163
  const n = audit.findings?.length ?? 0;
156
- return ` ${chalk.yellow(`⚠ ${n} note${n === 1 ? '' : 's'}`)}`;
164
+ return ` ${paint('warn', `${g.attention} ${n} note${n === 1 ? '' : 's'}`)}`;
165
+ }
166
+ case 'waived': {
167
+ const cat = audit.waiveCategory ? ` (${sanitizeControlChars(audit.waiveCategory)})` : '';
168
+ return ` ${paint('dim', `${g.info} waived${cat}`)}`;
157
169
  }
158
- case 'waived': return ` ${chalk.yellow(`✎ waived${audit.waiveCategory ? ` (${audit.waiveCategory})` : ''}`)}`;
159
- case 'failed': return ` ${chalk.red('✖ audit failed')}`;
160
- case 'not_audited_hand_edited': return ` ${chalk.yellow('✋ hand-edited')}`;
170
+ case 'failed': return ` ${paint('bad', `${g.failed} audit failed`)}`;
171
+ case 'not_audited_hand_edited': return ` ${paint('dim', `${g.info} hand-edited`)}`;
161
172
  case 'pending':
162
173
  case 'infra_failed':
163
174
  default:
164
- // `default` is deliberate forward-compat, not a missed case: an unknown
165
- // future PlanAuditState renders "⚠ audit not run" instead of crashing.
166
- return ` ${chalk.yellow('⚠ audit not run')}`;
175
+ // Deliberate forward-compat: an unknown future state reads "audit not run".
176
+ return ` ${paint('warn', `${g.attention} audit not run`)}`;
167
177
  }
168
178
  }
169
179
  /**
170
- * Plan-progress board — a Claude-Code-style checkbox task list. Each phase is
171
- * named for the work it does (not its mechanical id), so the reader can follow
172
- * along; ☒ = done (dim), ☐ = to do, the active phase is bold with a `◀ now`
173
- * marker, blocked is red. Flows through chunkEmitter (Ink) and console.log
174
- * (classic) alike — chalk degrades to plain text on a non-colour stream.
180
+ * Pre-cut a row's title and detail (plain text, before painting) so both fit
181
+ * in `room` cells. The title has priority; the detail, with its separator,
182
+ * gets what is left and is dropped when too little room remains.
183
+ */
184
+ function fitRow(title, detail, sep, room) {
185
+ const t = truncateToWidth(title, room);
186
+ const rest = room - stringWidth(t);
187
+ const full = detail ? `${sep}${detail}` : '';
188
+ const tail = full && rest > stringWidth(sep) + 1 ? truncateToWidth(full, rest) : '';
189
+ return { title: t, tail };
190
+ }
191
+ /**
192
+ * Plan-progress board (spec §4) — printed once per phase event to scrollback,
193
+ * never a live element. Same glyphs and "N of M" count as the live checklist:
194
+ * ✓ done (dim) · ◼ current (SAP blue, bold) · ☐ to do · ✗ blocked (bad).
195
+ * Titles and details come from plan files / the model: control characters are
196
+ * neutralised and every row is pre-cut to the UI width before painting.
197
+ * Flows through chunkEmitter (Ink) and console.log (classic) alike.
175
198
  */
176
199
  export function renderPlanTracker(a) {
177
- const PEACH = '#F5A623';
200
+ const g = getGlyphs();
201
+ const width = uiWidth();
178
202
  const lines = [];
179
203
  const total = a.content.phases.length;
180
204
  // Waived phases count as done in the header tally — the DAG treats them as
181
- // satisfied; the per-line "✓* (waived)" marker carries the distinction.
182
- // Audit-aware (2026-07-03): a validated-but-audit-failed phase must NOT
183
- // count as done, or the header would say "N/N done" while the completion
184
- // check refuses to complete — the tally uses the same predicate as the DAG.
205
+ // satisfied. Audit-aware: a validated-but-audit-failed phase must NOT count,
206
+ // or the header would say "N of N done" while the completion check refuses
207
+ // to complete — the tally uses the same predicate as the DAG (pinned by
208
+ // plan-tracker-pin).
185
209
  const validated = a.content.phases.filter((p) => isPhaseSatisfied(a.statuses[p.id], p.audit)).length;
186
210
  if (a.includeHeader !== false) {
187
- lines.push(chalk.dim(`─ ${a.title} (v${a.version}) · ${validated}/${total} done ─`));
211
+ const tail = ` (v${a.version}) · ${countLabel(validated, total)} done`;
212
+ const head = truncateToWidth(sanitizeControlChars(a.title), Math.max(1, width - stringWidth(tail)));
213
+ lines.push(chalk.bold(head) + paint('dim', tail));
188
214
  lines.push('');
189
215
  }
190
216
  for (const p of a.content.phases) {
191
217
  const s = a.statuses[p.id] ?? 'todo';
192
218
  const isCurrent = a.currentId != null && p.id === a.currentId;
193
- const title = phaseTitle(p);
194
- const detail = phaseDetail(p, s);
195
- const dash = detail ? ` — ${detail}` : '';
196
- // Auditor verdict after the status — '' on legacy phases (no audit field),
197
- // so pre-audit envelopes render byte-identical.
198
- const audit = auditGlyph(p.audit);
219
+ const title = sanitizeControlChars(phaseTitle(p));
220
+ const detail = sanitizeControlChars(phaseDetail(p, s));
221
+ // Auditor verdict after the status — '' on legacy phases (no audit field).
222
+ const audit = auditTag(p.audit);
223
+ const auditW = stringWidth(audit);
199
224
  if (s === 'validated') {
200
- lines.push(chalk.dim(` ☒ ${title}${dash}`) + audit);
225
+ const lead = ` ${g.done} `;
226
+ const r = fitRow(title, detail, ' — ', width - stringWidth(lead) - auditW);
227
+ lines.push(paint('dim', `${lead}${r.title}${r.tail}`) + audit);
201
228
  }
202
229
  else if (s === 'validated-with-waiver') {
203
- // C1 (D30 waiver) — done-but-waived: the exit gate was never met; the
204
- // user accepted it anyway. Distinct marker so a tracker reader can tell
205
- // a verified phase from a waived one at a glance.
206
- lines.push(chalk.dim(` ☒ ${title}${dash}`) + ` ${chalk.yellow('✓* (waived)')}` + audit);
230
+ // Done-but-waived: the exit gate was never met; the user accepted it.
231
+ const lead = ` ${g.done} `;
232
+ const mark = `${g.done}* (waived)`;
233
+ const r = fitRow(title, detail, ' — ', width - stringWidth(lead) - 1 - stringWidth(mark) - auditW);
234
+ lines.push(paint('dim', `${lead}${r.title}${r.tail}`) + ` ${paint('warn', mark)}` + audit);
207
235
  }
208
236
  else if (s === 'blocked') {
209
- lines.push(` ${chalk.red('☐')} ${chalk.red(title)} ${chalk.red('— blocked')}${detail ? chalk.dim(` · ${detail}`) : ''}${audit}`);
237
+ const lead = ` ${g.failed} `;
238
+ const mark = ' — blocked';
239
+ const r = fitRow(title, detail, ' · ', width - stringWidth(lead) - stringWidth(mark) - auditW);
240
+ lines.push(` ${paint('bad', `${g.failed} ${r.title}${mark}`)}${r.tail ? paint('dim', r.tail) : ''}${audit}`);
210
241
  }
211
242
  else if (isCurrent) {
212
- lines.push(` ${chalk.hex(PEACH)('☐')} ${chalk.bold(title)}${dash}${audit} ${chalk.hex(PEACH).bold('◀ now')}`);
243
+ const lead = ` ${g.current} `;
244
+ const r = fitRow(title, detail, ' — ', width - stringWidth(lead) - auditW);
245
+ lines.push(` ${chalk.bold(paint('sap', `${g.current} ${r.title}`))}${paint('dim', r.tail)}${audit}`);
213
246
  }
214
247
  else if (IN_PROGRESS.includes(s)) {
215
- lines.push(` ${chalk.hex(PEACH)('☐')} ${chalk.bold(title)}${dash}${audit} ${chalk.dim(`(${s})`)}`);
248
+ const lead = ` ${g.pending} `;
249
+ const note = ` (${s})`;
250
+ const r = fitRow(title, detail, ' — ', width - stringWidth(lead) - stringWidth(note) - auditW);
251
+ lines.push(` ${g.pending} ${chalk.bold(r.title)}${paint('dim', r.tail)}${audit}${paint('dim', note)}`);
216
252
  }
217
253
  else {
218
- lines.push(` ☐ ${title}${chalk.dim(dash)}${audit}`);
254
+ const lead = ` ${g.pending} `;
255
+ const r = fitRow(title, detail, ' — ', width - stringWidth(lead) - auditW);
256
+ lines.push(` ${paint('dim', g.pending)} ${r.title}${paint('dim', r.tail)}${audit}`);
219
257
  }
220
258
  }
221
259
  // Off-switch footer — honesty: state plainly that this run's phases are not
222
260
  // being audited. Rendered only when the caller threads auditsOff (in-memory).
223
261
  if (a.auditsOff) {
224
262
  lines.push('');
225
- lines.push(chalk.dim(' audits: off'));
263
+ lines.push(paint('dim', ' audits: off'));
226
264
  }
227
265
  return lines.join('\n');
228
266
  }
@@ -272,7 +310,10 @@ export function findServiceBinding(phases) {
272
310
  * /abap-fiori-build — with the EXACT binding name when resolvable. A plan
273
311
  * that already built its UI, or never built a service, gets no chain line.
274
312
  */
275
- export function planCompletionLines(phases) {
313
+ export function planCompletionLines(phases,
314
+ // Terminal callers follow the terminal's glyph set; file writers (the
315
+ // handover markdown) pass UNICODE_GLYPHS so file content never varies.
316
+ g = getGlyphs()) {
276
317
  const lines = ['Plan complete — every phase validated.'];
277
318
  // Confidence-tiered redesign (2026-07-08): a completed plan that carried
278
319
  // non-blocking warn verdicts surfaces them in the FINAL summary — a warned
@@ -283,9 +324,10 @@ export function planCompletionLines(phases) {
283
324
  if (p.audit?.state !== 'warn')
284
325
  continue;
285
326
  const notes = p.audit.findings ?? [];
327
+ // Findings are model-written text: sanitise before they reach a terminal.
286
328
  lines.push(notes.length > 0
287
- ? `⚠ ${p.id} passed with ${notes.length} note${notes.length === 1 ? '' : 's'}: ${notes.join('; ')}.`
288
- : `⚠ ${p.id} passed with notes.`);
329
+ ? `${g.attention} ${p.id} passed with ${notes.length} note${notes.length === 1 ? '' : 's'}: ${sanitizeControlChars(notes.join('; '))}.`
330
+ : `${g.attention} ${p.id} passed with notes.`);
289
331
  }
290
332
  // §10 Q2 (audit-confidence redesign): a waived phase surfaces its light
291
333
  // category + reason in the FINAL summary — an "all validated" plan never
@@ -295,9 +337,10 @@ export function planCompletionLines(phases) {
295
337
  for (const p of phases) {
296
338
  if (p.audit?.state !== 'waived')
297
339
  continue;
298
- const cat = p.audit.waiveCategory ? ` (${p.audit.waiveCategory})` : '';
299
- const reason = p.audit.waivedReason ? `: ${p.audit.waivedReason}` : '';
300
- lines.push(`✎ ${p.id} waived${cat}${reason}.`);
340
+ // Same "· waived" tag as the board (auditTag); the reason is user-typed text.
341
+ const cat = p.audit.waiveCategory ? ` (${sanitizeControlChars(p.audit.waiveCategory)})` : '';
342
+ const reason = p.audit.waivedReason ? `: ${sanitizeControlChars(p.audit.waivedReason)}` : '';
343
+ lines.push(`${g.info} ${p.id} waived${cat}${reason}.`);
301
344
  }
302
345
  // audit-infra-continue (2026-07-09): a phase whose audit could NOT RUN
303
346
  // surfaces in the FINAL summary as a DISTINCT "unverified" line — an "all
@@ -308,7 +351,7 @@ export function planCompletionLines(phases) {
308
351
  for (const p of phases) {
309
352
  if (p.audit?.state !== 'infra_failed')
310
353
  continue;
311
- lines.push(`⚠ ${p.id} was NOT audit-verified (audit infrastructure failed — its own exit gate, activation + ATC, passed).`);
354
+ lines.push(`${g.attention} ${p.id} was NOT audit-verified (audit infrastructure failed — its own exit gate, activation + ATC, passed).`);
312
355
  }
313
356
  const hasUi = phases.some((p) => p.layer === 'ui' || p.delegateTo === 'abap-fiori-build');
314
357
  const hasService = phases.some((p) => p.layer === 'service');
@@ -1,6 +1,7 @@
1
1
  import { readProjectFile } from './status.js';
2
2
  import { buildPromotedFromSnapshot, buildReviewDecisionsBlock, buildDetailPathHint, validatePromotionSource } from './promote.js';
3
3
  import { resolveAtTokenAsync, formatProjectFileList } from './workspace.js';
4
+ import { askYesNo } from './yes-no.js';
4
5
  /**
5
6
  * Per-skill predecessor map. v0.7 admits multi-source via array values —
6
7
  * e.g. /abap-upgrade-fix accepts BOTH upgrade-scan (the existing chain
@@ -79,14 +80,22 @@ export async function runPromoteCommand(args) {
79
80
  }
80
81
  for (const w of validation.warnings) {
81
82
  if (w.kind === 'ZERO_ANSWERED') {
82
- const reply = await args.prompt(`Source has no answered items (${w.count} still open). Continue anyway? [y/N]: `);
83
+ const reply = await askYesNo({
84
+ question: `Source has no answered items (${w.count} still open). Continue anyway?`,
85
+ confirm: args.confirm,
86
+ prompt: args.prompt,
87
+ });
83
88
  if (!affirmative(reply))
84
89
  return null;
85
90
  }
86
91
  else if (w.kind === 'PARTIAL_OPEN') {
87
92
  const sample = w.openIds.slice(0, 5).join(', ');
88
93
  const more = w.openIds.length > 5 ? `, ... ${w.openIds.length - 5} more` : '';
89
- const reply = await args.prompt(`${w.openIds.length} item(s) still open (${sample}${more}). Promote anyway? [y/N]: `);
94
+ const reply = await askYesNo({
95
+ question: `${w.openIds.length} item(s) still open (${sample}${more}). Promote anyway?`,
96
+ confirm: args.confirm,
97
+ prompt: args.prompt,
98
+ });
90
99
  if (!affirmative(reply))
91
100
  return null;
92
101
  }
@@ -17,6 +17,7 @@ import { saveProject } from './save.js';
17
17
  import { renderEmailTemplate } from './email-template.js';
18
18
  import { writePlanHandover } from './handover-md.js';
19
19
  import { ensureWorkspace, resolveWorkspacePath } from './workspace.js';
20
+ import { askYesNo, isYes } from './yes-no.js';
20
21
  const SKILL_REGISTRY = {
21
22
  'abap-spec-gap': {
22
23
  artefactType: 'spec-gap',
@@ -317,8 +318,8 @@ export async function runSaveCommand(args) {
317
318
  // question added here would silently receive 'y' too. If you need another
318
319
  // prompt, give maybeOfferSave's headless prompt a question-aware answer
319
320
  // first.
320
- const answer = (await args.prompt('Save as project file? [y/N]: ')).trim().toLowerCase();
321
- if (answer !== 'y' && answer !== 'yes')
321
+ const answer = await askYesNo({ question: 'Save as project file?', confirm: args.confirm, prompt: args.prompt });
322
+ if (!isYes(answer))
322
323
  return null;
323
324
  let env;
324
325
  if (planExtract && prior) {
@@ -2,6 +2,7 @@ import { readFileSync } from 'node:fs';
2
2
  import { validateEnvelope } from './validate.js';
3
3
  import { canonicalSha256 } from './canonicalize.js';
4
4
  import { statusesFromItems, computeNextPhase, renderPlanTracker } from './plan-run.js';
5
+ import { getGlyphs } from '../renderer/glyphs.js';
5
6
  export function readProjectFile(path) {
6
7
  const raw = readFileSync(path, 'utf8');
7
8
  let parsed;
@@ -167,7 +168,7 @@ export async function renderMultiFileStatus(envs) {
167
168
  const distinct = new Set(shas);
168
169
  if (distinct.size > 1) {
169
170
  const lines = [];
170
- lines.push(`⚠ Two v${firstVer} files received from same source, with conflicting changes.`);
171
+ lines.push(`${getGlyphs().attention} Two v${firstVer} files received from same source, with conflicting changes.`);
171
172
  for (let i = 0; i < envs.length; i++) {
172
173
  const e = envs[i];
173
174
  const items = e.interaction.items;
@@ -0,0 +1,12 @@
1
+ export async function askYesNo(a) {
2
+ if (a.confirm)
3
+ return a.confirm(a.question, { default: 'n' });
4
+ if (!a.prompt)
5
+ return '';
6
+ return (await a.prompt(`${a.question} [y/N]: `)).trim().toLowerCase();
7
+ }
8
+ /** Today's decision rule at all six sites. */
9
+ export function isYes(reply) {
10
+ const v = reply.trim().toLowerCase();
11
+ return v === 'y' || v === 'yes';
12
+ }
@@ -36,6 +36,22 @@ const ABAP_KEYWORDS = new Set([
36
36
  // Now accepts any alphanumeric/_/- lang slug and makes it available as m[1].
37
37
  const OPEN_FENCE_RE = /^```([a-z0-9_-]*)\s*$/i;
38
38
  const CLOSE_FENCE_RE = /^```\s*$/;
39
+ /** Highlight one fenced block body for the languages CSPeach owns
40
+ * (abap, cds, bdef, ddl). undefined = not ours (caller keeps its default). */
41
+ export function highlightFencedBody(body, lang) {
42
+ // Only the first word of the info string names the language
43
+ // (```abap linenums → abap).
44
+ const l = (lang.trim().split(/\s+/)[0] ?? '').toLowerCase();
45
+ // Preserve v0.1.1 regex-based highlighter for ABAP — it is stable,
46
+ // has been tested in production, and avoids a second runtime
47
+ // dependency for the hottest fenced-block type.
48
+ if (l === 'abap')
49
+ return highlightAbapBlock(body);
50
+ // cli-highlight grammars via the Task 5 dispatcher.
51
+ if (l === 'cds' || l === 'bdef' || l === 'ddl')
52
+ return highlightCodeBlock(body, l);
53
+ return undefined;
54
+ }
39
55
  /** Whole-text pass. Splits by fenced blocks; routes each block by lang. */
40
56
  export function applyCodeHighlight(text) {
41
57
  const lines = text.split('\n');
@@ -59,20 +75,8 @@ export function applyCodeHighlight(text) {
59
75
  // inFence === true
60
76
  if (CLOSE_FENCE_RE.test(line)) {
61
77
  const body = blockLines.join('\n');
62
- if (lang === 'abap') {
63
- // Preserve v0.1.1 regex-based highlighter for ABAP — it is stable,
64
- // has been tested in production, and avoids a second runtime
65
- // dependency for the hottest fenced-block type.
66
- out.push(highlightAbapBlock(body));
67
- }
68
- else if (lang === 'cds' || lang === 'bdef' || lang === 'ddl') {
69
- // cli-highlight grammars via the Task 5 dispatcher.
70
- out.push(highlightCodeBlock(body, lang));
71
- }
72
- else {
73
- // Unknown / empty lang → pass through unchanged.
74
- out.push(body);
75
- }
78
+ // Unknown / empty lang → pass through unchanged.
79
+ out.push(highlightFencedBody(body, lang) ?? body);
76
80
  out.push(line);
77
81
  inFence = false;
78
82
  lang = '';
@@ -0,0 +1,69 @@
1
+ // src/renderer/answer-trim.ts
2
+ //
3
+ // Round 6 item 4f — one-shot stdout is the answer only: no blank lines before
4
+ // it, exactly one newline after it. Newlines (and colour codes next to them)
5
+ // are held until visible text proves they are inside the answer; leading ones
6
+ // are dropped, trailing ones are dropped at end().
7
+ // eslint-disable-next-line no-control-regex
8
+ const ESC_SEQ = /^\x1b(?:\[[0-9;?]*[ -/]*[@-~]|\][^\x07]*\x07)/;
9
+ export class AnswerTrim {
10
+ started = false;
11
+ /** Held newlines + escapes, in order. */
12
+ held = '';
13
+ /**
14
+ * Round 6 review M3 — a newline the caller already wrote on its own
15
+ * (writeAboveSpinner ends a line before repainting the spinner). The next
16
+ * held newline, or the final one, is covered by it.
17
+ */
18
+ forced = false;
19
+ /** The caller wrote a newline itself after the last output. */
20
+ noteForcedNewline() {
21
+ if (this.started)
22
+ this.forced = true;
23
+ }
24
+ flushHeld() {
25
+ let h = this.held;
26
+ if (this.forced) {
27
+ h = h.replace(/\r?\n/, '');
28
+ this.forced = false;
29
+ }
30
+ return h;
31
+ }
32
+ feed(s) {
33
+ let out = '';
34
+ let i = 0;
35
+ while (i < s.length) {
36
+ const ch = s[i];
37
+ if (ch === '\x1b') {
38
+ const m = ESC_SEQ.exec(s.slice(i));
39
+ if (m) {
40
+ this.held += m[0];
41
+ i += m[0].length;
42
+ continue;
43
+ }
44
+ }
45
+ if (ch === '\n' || ch === '\r') {
46
+ this.held += ch;
47
+ }
48
+ else {
49
+ // Before the answer starts, held newlines are dropped (escapes kept).
50
+ out += this.started ? this.flushHeld() : this.held.replace(/[\r\n]/g, '');
51
+ this.held = '';
52
+ this.started = true;
53
+ out += ch;
54
+ }
55
+ i++;
56
+ }
57
+ return out;
58
+ }
59
+ /** What to write when the answer is done: held escapes + one newline (nothing if no answer). */
60
+ end() {
61
+ const escapes = this.held.replace(/[\r\n]/g, '');
62
+ this.held = '';
63
+ if (this.forced) {
64
+ this.forced = false;
65
+ return escapes;
66
+ }
67
+ return this.started ? `${escapes}\n` : escapes;
68
+ }
69
+ }
@@ -1,10 +1,8 @@
1
1
  import chalk from 'chalk';
2
2
  import { CLI_VERSION } from '../lib/version.js';
3
- /** CSPeach brand colour — used for the brand-name text in the startup line,
4
- * the tool-dispatch bullet (⏺), and the result continuation glyph (⎿).
5
- * Single source of truth so a future rebrand is one find-replace.
6
- * The startup banner uses the 🍑 emoji directly (no tint) for identity. */
7
- export const PEACH = '#FFCBA4';
3
+ // Legacy brand colour — frozen in legacy-palette.ts (piece 2 §1.1).
4
+ import { LEGACY_PEACH as PEACH } from './legacy-palette.js';
5
+ import { formatAttention } from './notice-shape.js';
8
6
  /**
9
7
  * Minimal startup line — terse, single bullet, three lines max. Modeled on
10
8
  * Claude Code's `✻ Welcome to Claude Code v1.0.x` pattern but with the
@@ -33,8 +31,10 @@ export function printStartupBanner() {
33
31
  console.log(` ${chalk.gray('ABAP + AI + safety gates')} ${chalk.dim('·')} ${chalk.gray('by Cremencing Solutions')}`);
34
32
  console.log('');
35
33
  }
34
+ /** Piece 2 §10: the keychain fallback is ONE "!" line (was three). */
35
+ export function keychainFallbackNotice() {
36
+ return formatAttention({ text: 'OS keychain unavailable', fact: 'the API key and SAP passwords are asked each session' });
37
+ }
36
38
  export function printKeytarFallbackBanner() {
37
- console.log(chalk.yellow('⚠ OS keychain unavailable on this system.'));
38
- console.log(chalk.yellow(' API key + SAP passwords will be prompted per session.'));
39
- console.log(chalk.yellow(' Run `cspeach doctor` for remediation hints.\n'));
39
+ process.stdout.write(keychainFallbackNotice());
40
40
  }
@@ -0,0 +1,19 @@
1
+ // src/renderer/brand-settled.ts
2
+ //
3
+ // The early panels brand line (startup-lines.ts startEarlyBrand) is written
4
+ // without its newline while the sign-in check runs. Anything that prints on
5
+ // its own schedule during startup (the fire-and-forget upgrade check) awaits
6
+ // brandSettled() first, so it can never land in the middle of that line.
7
+ // Resolved by default: with no early brand line open there is nothing to wait for.
8
+ // Kept dependency-free: cli.ts reaches it statically through upgrade-check.ts.
9
+ let settled = Promise.resolve();
10
+ let release;
11
+ export function brandSettled() {
12
+ return settled;
13
+ }
14
+ /** Marks the brand line open; returns the function that settles it (idempotent). */
15
+ export function holdBrand() {
16
+ settled = new Promise((r) => { release = r; });
17
+ const mine = release;
18
+ return () => { mine(); };
19
+ }