@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
@@ -1,13 +1,19 @@
1
1
  import { select, input } from '@inquirer/prompts';
2
2
  import chalk from 'chalk';
3
+ import { riskReasonText, shownRiskReasons } from './risk-floor.js';
3
4
  import { verbLine, viewAffordance, toSourceLines } from './op-labels.js';
4
5
  import { shouldUseInk } from '../renderer/tty.js';
5
6
  import { approvalEmitter } from '../ui/approval-emitter.js';
6
- import { askQuestionEmitter } from '../ui/ask-question-emitter.js';
7
7
  import { withInquirer } from '../repl/inquirer-guard.js';
8
8
  import { inquirerTheme } from '../repl/inquirer-theme.js';
9
- import { PEACH } from '../renderer/banners.js';
10
- const peach = (s) => chalk.hex(PEACH)(s);
9
+ import { askConfirm } from '../ui/confirm-request.js';
10
+ import { emitTrace } from '../renderer/trace.js';
11
+ import { getGlyphs } from '../renderer/glyphs.js';
12
+ import { paint } from '../renderer/theme.js';
13
+ import { sanitizeControlChars } from '../renderer/sanitize.js';
14
+ import { withSpinnerSuspended } from '../renderer/tool-widget.js';
15
+ // Piece 2 §1.1 — the brand accent comes from the theme role, not a legacy hex.
16
+ const peach = (s) => paint('brand', s);
11
17
  /**
12
18
  * Risk tag. 2026-05-01: yellow used to fire on `medium` too, which
13
19
  * meant most plans had a yellow tag — defeating yellow's purpose as
@@ -54,7 +60,7 @@ function colorizeDiff(diff) {
54
60
  * per-change prompts that were the main "so much confusion" complaint.
55
61
  *
56
62
  * Color discipline:
57
- * - Peach (PEACH) for the brand-bearing "Plan" header.
63
+ * - The brand role for the brand-bearing "Plan" header.
58
64
  * - Default text for the summary, change list. No cyan, no bold-on-bold.
59
65
  * - Yellow tag for medium risk, red for high. Low is dim — no shouting.
60
66
  */
@@ -80,57 +86,79 @@ function collapseImplicitActivates(changes) {
80
86
  return !writeKeys.has(`${c.type}:${c.object}`);
81
87
  });
82
88
  }
83
- export async function renderPlanGate(summary, changes, transport, effectiveRisk) {
89
+ export async function renderPlanGate(summary, changes, transport, effectiveRisk, system) {
84
90
  const visibleChanges = collapseImplicitActivates(changes);
85
91
  const visibleCount = visibleChanges.length;
86
92
  const noun = visibleCount === 1 ? 'change' : 'changes';
87
- // Ink path (default): route the gate through the ask_question choice modal so
88
- // it renders INSIDE the React frame. The prior inquirer select drew raw to
89
- // stdout while Ink owned the terminal — it toggled raw mode out from under
90
- // Ink and competed with the Footer for keystrokes (the "turn in progress —
91
- // input kept in box" confusion, and dead arrow keys on the modal that
92
- // followed). One input system = no handoff, no competition. Mirrors
93
- // renderPerChangeApprovalV3, which already went Ink-native via approvalEmitter.
93
+ // Ink path (piece 2 §5): the plan gate is an APPROVAL confirm card in the
94
+ // card slot. Same three values, same order, cursor on Apply all; anything
95
+ // else (Esc, no listener) is 'cancel'. Digits pick on an APPROVAL card
96
+ // (fix round 3), but typed-ahead keys are dropped (ConfirmCard), so a `1`
97
+ // or Enter typed before the card opened can't apply a plan. Long plans
98
+ // print in full to scrollback; the card keeps 4 (controller ruling, C1 fix
99
+ // round 1: 8 steps broke the spec §2 height cap — rows − 2 — on a 24-row
100
+ // terminal).
94
101
  if (shouldUseInk()) {
95
- const ctx = [
102
+ const MAX_STEPS = 4;
103
+ const packages = [...new Set(visibleChanges.map((c) => c.package).filter((p) => !!p))];
104
+ const facts = [
96
105
  summary,
97
- [`${visibleCount} ${noun}`, riskTag(effectiveRisk.level), transport ? `transport ${transport}` : '']
98
- .filter(Boolean).join(' · '),
106
+ [
107
+ `${visibleCount} ${noun}`,
108
+ transport ? `transport ${transport}` : 'no transport',
109
+ ...(packages.length > 0 ? [`package ${packages.join(', ')}`] : []),
110
+ `${effectiveRisk.level} risk`,
111
+ ].join(' · '),
99
112
  ];
100
- if (effectiveRisk.raisedBy.length > 0) {
101
- ctx.push(`(raised from low: ${effectiveRisk.raisedBy.join(', ')})`);
113
+ const shownReasons = shownRiskReasons(effectiveRisk.raisedBy);
114
+ if (shownReasons.length > 0) {
115
+ facts.push(`Risk raised to ${effectiveRisk.level}: ${shownReasons.map(riskReasonText).join('; ')}`);
102
116
  }
103
- // Numbered, operation-aware verb rows ("1. Create table ZMNT_NOTE") —
104
- // no cryptic op-code glyph. The Ink modal renders `context` itself, so
105
- // no chalk here.
106
- visibleChanges.forEach((c, i) => ctx.push(` ${i + 1}. ${verbLine(c)}`));
107
- const result = await askQuestionEmitter.request({
117
+ const steps = visibleChanges.map((c, i) => `${i + 1}. ${verbLine(c)}`);
118
+ if (steps.length > MAX_STEPS) {
119
+ // Object names are model-supplied: neutralise control characters
120
+ // before they reach scrollback (the card sanitises its own rows).
121
+ emitTrace(sanitizeControlChars(['Plan', ...steps.map((s) => ` ${s}`)].join('\n')));
122
+ facts.push(...steps.slice(0, MAX_STEPS), `${getGlyphs().more} ${steps.length - MAX_STEPS} more steps, listed above`);
123
+ }
124
+ else {
125
+ facts.push(...steps);
126
+ }
127
+ const res = await askConfirm({
108
128
  id: 'plan-gate',
109
- question: 'Apply this plan?',
110
- // Strip chalk ANSI from riskTag — the Ink modal renders `context`
111
- // itself (dimColor), so embedded escape codes would show as raw text.
112
- context: ctx.join('\n').replace(/\x1b\[[0-9;]*m/g, ''),
113
- kind: 'choice',
129
+ tag: 'APPROVAL',
130
+ title: system ? `Apply this plan on ${system}?` : 'Apply this plan?',
131
+ facts,
114
132
  choices: [
115
133
  { value: 'apply_all', label: `Apply all ${visibleCount} ${noun}` },
116
- { value: 'review', label: 'Review each change individually' },
134
+ { value: 'review', label: 'Review each change one by one' },
117
135
  { value: 'cancel', label: 'Cancel' },
118
136
  ],
119
- });
120
- if (result.answer === 'apply_all' || result.answer === 'review' || result.answer === 'cancel') {
121
- return result.answer;
137
+ initial: 0,
138
+ footnote: 'Forge Rule 8',
139
+ }, { traceLabel: 'Plan' });
140
+ if (res.value === 'apply_all' || res.value === 'review' || res.value === 'cancel') {
141
+ return res.value;
122
142
  }
123
- return 'cancel'; // Esc / no answer → cancel the plan (safe default)
143
+ return 'cancel'; // Esc / no listener / unknown → cancel the plan (safe default)
124
144
  }
125
145
  // Non-Ink (one-shot / non-TTY / classic): the original inquirer flow.
146
+ // Review M1: request_approval runs under a live tool spinner — keep it
147
+ // suspended for the whole preamble + prompt so no stale frame is left.
148
+ return withSpinnerSuspended(() => classicPlanGate(summary, visibleChanges, transport, effectiveRisk));
149
+ }
150
+ async function classicPlanGate(summary, visibleChanges, transport, effectiveRisk) {
151
+ const visibleCount = visibleChanges.length;
152
+ const noun = visibleCount === 1 ? 'change' : 'changes';
126
153
  console.log('');
127
154
  console.log(`${peach('Plan')} ${summary}`);
128
155
  const meta = [`${visibleCount} ${noun}`, riskTag(effectiveRisk.level)];
129
156
  if (transport)
130
157
  meta.push(`transport ${transport}`);
131
158
  console.log(` ${chalk.dim(meta.join(' · '))}`);
132
- if (effectiveRisk.raisedBy.length > 0) {
133
- console.log(` ${chalk.dim(`(raised from low: ${effectiveRisk.raisedBy.join(', ')})`)}`);
159
+ const shownReasons = shownRiskReasons(effectiveRisk.raisedBy);
160
+ if (shownReasons.length > 0) {
161
+ console.log(` ${chalk.dim(`(risk raised: ${shownReasons.map(riskReasonText).join('; ')})`)}`);
134
162
  }
135
163
  visibleChanges.forEach((c, i) => {
136
164
  console.log(` ${chalk.dim(`${i + 1}.`)} ${verbLine(c)}`);
@@ -183,7 +211,7 @@ export async function renderPerChangeApproval(change, effectiveRisk, transport)
183
211
  //
184
212
  // I3 (2026-07-05 review): a 'v' affordance (create/delete View source) is
185
213
  // NOT a diff — the payload is plain/all-adds source. Routing it through the
186
- // diff printer fired the "⚠ Model did not provide a unified diff" warning
214
+ // diff printer fired the "Model did not provide a unified diff" warning
187
215
  // under a View source label. Print numbered plain source instead — never
188
216
  // the diff path or its warning. (The affordance is only offered when
189
217
  // change.diff is present, so the fallback line is belt-and-braces.)
@@ -214,7 +242,7 @@ export async function renderPerChangeApproval(change, effectiveRisk, transport)
214
242
  console.log('\n' + colorizeDiff(change.diff) + '\n');
215
243
  }
216
244
  else {
217
- console.log('\n' + chalk.yellow('⚠ Model did not provide a unified diff — only a description:'));
245
+ console.log('\n' + chalk.yellow(`${getGlyphs().attention} Model did not provide a unified diff — only a description:`));
218
246
  console.log(chalk.dim(change.diff) + '\n');
219
247
  }
220
248
  }
@@ -10,10 +10,33 @@ export function effectiveRisk(declared, changes, ctx) {
10
10
  };
11
11
  if (ctx.autoApprovePolicy === 'never')
12
12
  raise('high', 'non_prod_override');
13
+ // Round 7 item 8e — never auto-approve anything on an object whose last
14
+ // syntax check found errors (high is never auto-approved).
15
+ if (ctx.syntaxErrorObjects && ctx.syntaxErrorObjects.length > 0) {
16
+ // Keys are `TYPE:NAME` (tools/syntax-state.ts syntaxKey). Re-review 2
17
+ // item 1: the floor matches on the NAME alone — a type word can be
18
+ // ambiguous (VIEW, FUNC, TABLE vs STRUCTURE…) or unknown, so any recorded
19
+ // entry with that name raises. Over-raise, never under-raise: the cost is
20
+ // a card the user would otherwise not see.
21
+ const badNames = new Set(ctx.syntaxErrorObjects.map((k) => k.slice(k.lastIndexOf(':') + 1).toUpperCase()));
22
+ const hit = (c) => badNames.has(c.object.trim().split(/\s+/)[0].toUpperCase());
23
+ if (changes.some(hit)) {
24
+ raise('high', 'syntax_errors');
25
+ // Final review A M-4 — the reason is always shown, even when the floor
26
+ // was already high (policy never, or declared high).
27
+ if (!raisedBy.includes('syntax_errors'))
28
+ raisedBy.push('syntax_errors');
29
+ }
30
+ }
13
31
  if (changes.some((c) => c.op === 'delete'))
14
32
  raise('medium', 'delete_op');
15
- if (changes.some((c) => c.op === 'release' || c.op === 'activate'))
16
- raise('medium', 'shared_transport');
33
+ // Round 4 item 8a — these were both labelled 'shared_transport', so a plan
34
+ // on a transport created seconds earlier read "raised … by shared_transport".
35
+ // Each reason now says what actually raised it.
36
+ if (changes.some((c) => c.op === 'release'))
37
+ raise('medium', 'transport_release');
38
+ if (changes.some((c) => c.op === 'activate'))
39
+ raise('medium', 'activation');
17
40
  if (ctx.recentAtcFailure && changes.some((c) => ctx.recentAtcFailure.includes(c.object)))
18
41
  raise('medium', 'atc_failed_24h');
19
42
  if ((ctx.transportRecentDevelopers ?? 0) >= 3)
@@ -24,3 +47,31 @@ export function effectiveRisk(declared, changes, ctx) {
24
47
  raise('medium', 'prod_namespace');
25
48
  return { level: floor, raisedBy };
26
49
  }
50
+ /** Plain words for each risk reason code — the codes stay in audit data only. */
51
+ const RISK_REASON_TEXT = {
52
+ non_prod_override: 'auto-approve is set to never on this system (a setting, not a risk)',
53
+ delete_op: 'the plan deletes an object',
54
+ transport_release: 'the plan releases a transport',
55
+ activation: 'activation makes the change live on the system',
56
+ atc_failed_24h: 'ATC failed on this object in the last 24 hours',
57
+ shared_transport: 'the transport is shared by 3 or more developers',
58
+ escalated_in_session: 'more than 5 changes in one plan',
59
+ prod_namespace: 'the object is in a production namespace',
60
+ syntax_errors: 'the last syntax check of this object found errors',
61
+ };
62
+ /** Round 8 item 4 — every code the floor can raise (each has plain words above). */
63
+ export const RISK_REASON_CODES = Object.keys(RISK_REASON_TEXT);
64
+ /** Round 4 item 8a — never show an internal reason code raw. */
65
+ export function riskReasonText(code) {
66
+ return RISK_REASON_TEXT[code] ?? code.replace(/_/g, ' ');
67
+ }
68
+ /**
69
+ * Round 7 item 8b — the reasons a user is SHOWN. `non_prod_override` (the
70
+ * alias's auto_approve is 'never') raises the floor so nothing auto-approves,
71
+ * but it is a setting, not a risk: the card read "Risk raised to high:
72
+ * auto-approve is off for this system". It stays in audit data only.
73
+ */
74
+ const NOT_A_RISK_REASON = new Set(['non_prod_override']);
75
+ export function shownRiskReasons(raisedBy) {
76
+ return raisedBy.filter((c) => !NOT_A_RISK_REASON.has(c));
77
+ }
@@ -1,17 +1,29 @@
1
- import keychain from './keychain.js';
1
+ import keychain, { newProbeAccount } from './keychain.js';
2
2
  const SERVICE = 'cspeach';
3
3
  const ACCOUNT = 'api-key';
4
4
  class KeychainStore {
5
+ /** One probe per store: repeat checks (repl.tsx) reuse the first answer. */
6
+ available;
5
7
  async get() { return keychain.getPassword(SERVICE, ACCOUNT); }
6
8
  async set(key) { await keychain.setPassword(SERVICE, ACCOUNT, key); }
7
9
  async delete() { await keychain.deletePassword(SERVICE, ACCOUNT); }
8
- async isKeychainAvailable() {
10
+ isKeychainAvailable() {
11
+ this.available ??= this.probe();
12
+ return this.available;
13
+ }
14
+ async probe() {
9
15
  try {
16
+ // Round 4 item 7 — a per-process account: a fixed 'probe' entry was
17
+ // deleted by a second cspeach starting at the same time.
18
+ const account = newProbeAccount();
10
19
  const probe = `probe-${Date.now()}`;
11
- await keychain.setPassword(SERVICE, 'probe', probe);
12
- const ok = (await keychain.getPassword(SERVICE, 'probe')) === probe;
13
- await keychain.deletePassword(SERVICE, 'probe');
14
- return ok;
20
+ try {
21
+ await keychain.setPassword(SERVICE, account, probe);
22
+ return (await keychain.getPassword(SERVICE, account)) === probe;
23
+ }
24
+ finally {
25
+ await keychain.deletePassword(SERVICE, account).catch(() => false);
26
+ }
15
27
  }
16
28
  catch {
17
29
  return false;
@@ -25,16 +37,18 @@ class EphemeralStore {
25
37
  async delete() { this.cached = null; }
26
38
  async isKeychainAvailable() { return false; }
27
39
  }
40
+ /**
41
+ * Round 4 item 4 — memoise the PROMISE, not the result. The startup upgrade
42
+ * check and runOneShot call getStore() concurrently; with a memoised result
43
+ * both ran a probe, and a probe that lost the race REPLACED the shared store
44
+ * with the in-memory EphemeralStore — the next get() found no key
45
+ * ("No API key configured" / uncaught no_api_key on `cspeach … < /dev/null`).
46
+ */
28
47
  let store;
29
- export async function getStore() {
30
- if (store)
31
- return store;
32
- const ktr = new KeychainStore();
33
- if (await ktr.isKeychainAvailable()) {
34
- store = ktr;
35
- }
36
- else {
37
- store = new EphemeralStore();
38
- }
48
+ export function getStore() {
49
+ store ??= (async () => {
50
+ const ktr = new KeychainStore();
51
+ return (await ktr.isKeychainAvailable()) ? ktr : new EphemeralStore();
52
+ })();
39
53
  return store;
40
54
  }
Binary file
package/dist/auth/me.js CHANGED
@@ -2,18 +2,36 @@
2
2
  * Fetch /v1/me — customer profile + entitlements + usage.
3
3
  * Result envelope so the REPL doesn't need try/catch.
4
4
  */
5
+ /** Default /v1/me budget. The panels brand line waits for this call (D5), so a
6
+ * hung proxy must not mean a blank screen. */
7
+ export const FETCH_ME_TIMEOUT_MS = 5000;
5
8
  export async function fetchMe(opts) {
6
9
  const url = `${opts.proxyUrl.replace(/\/$/, '')}/v1/me`;
10
+ const timeoutMs = opts.timeoutMs ?? FETCH_ME_TIMEOUT_MS;
11
+ const signal = AbortSignal.timeout(timeoutMs);
12
+ // Race the signal too, so even a fetch that ignores it cannot hang startup.
13
+ // A timeout is a status-0 failure (the failed-refresh path), never a 401.
14
+ const timedOut = new Promise((_, reject) => {
15
+ signal.addEventListener('abort', () => reject(new Error(`timed out after ${timeoutMs} ms`)), { once: true });
16
+ });
17
+ timedOut.catch(() => { });
7
18
  try {
8
- const r = await fetch(url, { headers: { Authorization: `Bearer ${opts.apiKey}` } });
9
- if (!r.ok) {
10
- const body = await r.json().catch(() => ({}));
11
- return { ok: false, status: r.status, error: body.error ?? 'unknown' };
12
- }
13
- const profile = await r.json();
14
- return { ok: true, profile };
19
+ return await Promise.race([
20
+ (async () => {
21
+ const r = await fetch(url, { headers: { Authorization: `Bearer ${opts.apiKey}` }, signal });
22
+ if (!r.ok) {
23
+ const body = await r.json().catch(() => ({}));
24
+ return { ok: false, status: r.status, error: body.error ?? 'unknown' };
25
+ }
26
+ const profile = await r.json();
27
+ return { ok: true, profile };
28
+ })(),
29
+ timedOut,
30
+ ]);
15
31
  }
16
32
  catch (err) {
33
+ if (signal.aborted)
34
+ return { ok: false, status: 0, error: `timed out after ${timeoutMs} ms` };
17
35
  return { ok: false, status: 0, error: err instanceof Error ? err.message : String(err) };
18
36
  }
19
37
  }
package/dist/cli.js CHANGED
@@ -4,12 +4,16 @@ import { runAddSapWizard } from './sap/onboarding.js';
4
4
  import { runGcOnStartup } from './session/gc.js';
5
5
  import { checkForUpgrade } from './upgrade-check.js';
6
6
  import { extractSapAliasFlag, extractNoSapFlag } from './cli-args.js';
7
+ import { applyColorMode } from './renderer/color-mode.js';
7
8
  /**
8
9
  * The verbs that make `cspeach project …` a mistyped team command rather than
9
10
  * a prompt somebody meant to send. See the check in `main`.
10
11
  */
11
12
  const TEAM_SUBCOMMANDS = new Set(['init', 'join', 'lane', 'refresh', 'status', 'outbox', 'rule', 'leave']);
12
13
  export async function main(argv) {
14
+ // NO_COLOR (piece 2 §1.2) — before ANY output, for every command (REPL,
15
+ // one-shot, doctor, login, help, config …).
16
+ applyColorMode();
13
17
  runGcOnStartup().catch(() => { });
14
18
  checkForUpgrade().catch(() => { });
15
19
  let args = argv.slice(2);
@@ -36,12 +40,9 @@ export async function main(argv) {
36
40
  const { runUpgrade } = await import('./upgrade.js');
37
41
  return runUpgrade();
38
42
  }
39
- if (args[0] === '--resume') {
40
- const { runResume } = await import('./session/resume.js');
41
- return runResume(args[1]);
42
- }
43
- // C.1 — extract --sap-alias <name> early so it is available for REPL and
44
- // one-shot paths. Validation requires loading config to know available aliases.
43
+ // C.1 — extract --sap-alias <name> early so it is available for REPL,
44
+ // one-shot and resume paths (round 6 review C1: it used to be parsed after
45
+ // the --resume branch, so a resume never saw it). Validation requires loading config to know available aliases.
45
46
  let sapAlias;
46
47
  if (args.includes('--sap-alias')) {
47
48
  const { loadConfig } = await import('./config/loader.js');
@@ -55,6 +56,15 @@ export async function main(argv) {
55
56
  sapAlias = parsed.sapAlias;
56
57
  args = parsed.rest;
57
58
  }
59
+ if (args[0] === '--resume') {
60
+ // Final batch 5 — `--resume --one-shot "…"`: a flag is never a session id.
61
+ if (args[1]?.startsWith('-')) {
62
+ console.error(chalk.red(`error: --resume takes a session id, not '${args[1]}'. Usage: cspeach --resume [<id>] (no id → pick from a list)`));
63
+ return 1;
64
+ }
65
+ const { runResume } = await import('./session/resume.js');
66
+ return runResume(args[1], { sapAlias, noSap });
67
+ }
58
68
  // Standalone profile wizard — re-runnable any time. Checked BEFORE the plain
59
69
  // `configure` branch so `configure --standalone` isn't swallowed by it.
60
70
  // Asks what system the code will land on, then the standards question.
@@ -203,6 +213,15 @@ export async function main(argv) {
203
213
  const { runOneShot } = await import('./one-shot.js');
204
214
  return runOneShot(args.join(' '), { sapAlias, noSap });
205
215
  }
216
+ // Round 8 item 3 — `echo "prompt" | cspeach`: a piped stdin carrying one
217
+ // prompt runs as --one-shot (stdout = the answer only). Scripted pipes
218
+ // (slash lines), empty or live stdin stay with the REPL, nothing consumed.
219
+ const { readPipedPrompt } = await import('./lib/piped-prompt.js');
220
+ const piped = await readPipedPrompt(process.stdin);
221
+ if (piped.kind === 'prompt') {
222
+ const { runOneShot } = await import('./one-shot.js');
223
+ return runOneShot(piped.text, { sapAlias, noSap });
224
+ }
206
225
  // No args → REPL.
207
226
  const { runRepl } = await import('./repl.js');
208
227
  return runRepl({ sapAlias, noSap });
@@ -26,8 +26,11 @@
26
26
  */
27
27
  import fs from 'node:fs/promises';
28
28
  import path from 'node:path';
29
- import chalk from 'chalk';
30
29
  import { sessionsDir } from '../config/paths.js';
30
+ import { panelHeading, panelRow } from '../renderer/panel-rows.js';
31
+ import { formatInfo } from '../renderer/notice-shape.js';
32
+ import { paint } from '../renderer/theme.js';
33
+ import { getGlyphs } from '../renderer/glyphs.js';
31
34
  import { saveSession } from '../session/store.js';
32
35
  import { isUserPrompt } from '../session/user-prompt.js';
33
36
  /** Keep this many recent user-prompt anchored turns verbatim. */
@@ -277,18 +280,18 @@ export function formatCompactResult(result,
277
280
  /** Optional — falls back to the legacy constant for back-compat. */
278
281
  keepRecentTurns = KEEP_RECENT_TURNS) {
279
282
  if (!result.performed) {
280
- return chalk.dim(`/compact: ${result.reason ?? 'nothing to do'}`);
283
+ return formatInfo(`/compact: ${result.reason ?? 'nothing to do'}`).trimEnd();
281
284
  }
282
285
  const lines = [
283
286
  '',
284
- chalk.bold('/compact complete'),
285
- chalk.dim(` dropped: ${result.droppedTurns} messages`),
286
- chalk.dim(` kept: ${result.keptTurns} messages (most recent ${keepRecentTurns} user turns intact)`),
287
- chalk.dim(` summary: ~${result.summaryTokens?.toLocaleString()} tokens`),
288
- chalk.dim(` backup: ${result.backupPath}`),
287
+ panelHeading('Compacted', 'older turns summarised'),
288
+ panelRow('Dropped', `${result.droppedTurns} messages`),
289
+ panelRow('Kept', `${result.keptTurns} messages (most recent ${keepRecentTurns} user turns intact)`),
290
+ panelRow('Summary', `~${result.summaryTokens?.toLocaleString()} tokens`),
291
+ panelRow('Backup', `${result.backupPath}`),
289
292
  '',
290
- chalk.green('Next turn\'s context is now roughly summary-size instead of full history.'),
291
- chalk.dim('Heads-up: the very next turn rebuilds the prompt cache (one-off cost), then turns after that are cheap again.'),
293
+ `${paint('ok', getGlyphs().done)} The next turn's context is roughly summary-size instead of the full history.`,
294
+ formatInfo('The very next turn rebuilds the prompt cache once; turns after that are cheap again.').trimEnd(),
292
295
  '',
293
296
  ];
294
297
  return lines.join('\n');
@@ -1,4 +1,5 @@
1
1
  import chalk from 'chalk';
2
+ import { formatAttention } from '../renderer/notice-shape.js';
2
3
  import { loadConfig, saveConfig, isValidWriteMode } from '../config/loader.js';
3
4
  import { STANDALONE_PLATFORMS, STANDALONE_DATABASES, STANDALONE_DEPLOYMENTS, STANDALONE_IDES, isStandalonePlatform, deriveDatabase, deriveDeployment, defaultAbapCloudOnly, defaultRelease, } from '../sap/standalone-profile.js';
4
5
  /**
@@ -350,8 +351,11 @@ export async function runConfigSet(args) {
350
351
  }
351
352
  cfg.sap[alias].insecure_skip_tls_verify = b;
352
353
  if (b) {
353
- console.log(chalk.yellow(` ⚠ TLS verification will be DISABLED for ${alias} — dev only, NEVER use in production. ` +
354
- `Prefer: cspeach config set sap.${alias}.ca_cert_path <path-to-corporate-CA>`));
354
+ console.log(formatAttention({
355
+ text: `TLS verification is off for ${alias}`,
356
+ fact: 'dev only, never in production',
357
+ next: `cspeach config set sap.${alias}.ca_cert_path <path-to-corporate-CA>`,
358
+ }).trimEnd());
355
359
  }
356
360
  await saveConfig(cfg);
357
361
  console.log(chalk.green(`Set ${key} = ${b}`));
@@ -1,5 +1,7 @@
1
1
  import chalk from 'chalk';
2
2
  import { loadConfig } from '../config/loader.js';
3
+ import { paint } from '../renderer/theme.js';
4
+ import { formatAttention } from '../renderer/notice-shape.js';
3
5
  /**
4
6
  * Dotted paths of keys that must never appear in plaintext config output.
5
7
  * SAP passwords live in the keychain and should never land in config.toml,
@@ -57,7 +59,7 @@ function printLlmMissingHints(llm) {
57
59
  for (const key of required) {
58
60
  const value = llm[key];
59
61
  if (value === undefined || value === null || value === '') {
60
- console.log(`${key} = ${chalk.red('<not set>')}`);
62
+ console.log(`${key} = ${paint('bad', '<not set>')}`);
61
63
  }
62
64
  }
63
65
  }
@@ -83,14 +85,14 @@ function printConfigAsToml(cfg, prefix = '') {
83
85
  // the implication (writes proposed, never executed) without having to
84
86
  // read the docs.
85
87
  if (fullKey === 'write_mode' && v === 'advisory-only') {
86
- console.log(chalk.yellow(' → Advisory mode: writes will be proposed, never executed.'));
88
+ console.log(' ' + formatAttention({ text: 'Advisory mode', fact: 'writes are proposed, never executed' }).trimEnd());
87
89
  }
88
90
  }
89
91
  }
90
92
  for (const [k, v] of tables) {
91
93
  const fullKey = prefix ? `${prefix}.${k}` : k;
92
94
  console.log('');
93
- console.log(chalk.cyan(`[${fullKey}]`));
95
+ console.log(chalk.bold(`[${fullKey}]`));
94
96
  printConfigAsToml(v, fullKey);
95
97
  // Mode-aware extras for the `[llm]` section — print required keys that
96
98
  // are empty as `<not set>` so a user who ran `cspeach config set llm.mode
@@ -158,7 +160,7 @@ export async function runConfigShow(args) {
158
160
  if (typeof resolved === 'object' && resolved !== null) {
159
161
  // Sub-object (e.g. sap.S4H-DEV) — print as TOML section
160
162
  console.log('');
161
- console.log(chalk.cyan(`[${keyPath}]`));
163
+ console.log(chalk.bold(`[${keyPath}]`));
162
164
  for (const [k, v] of Object.entries(resolved)) {
163
165
  const subKey = `${keyPath}.${k}`;
164
166
  if (REDACTED_KEYS.has(subKey)) {
@@ -173,7 +175,7 @@ export async function runConfigShow(args) {
173
175
  else {
174
176
  console.log(String(resolved));
175
177
  if (keyPath === 'write_mode' && resolved === 'advisory-only') {
176
- console.log(chalk.yellow(' → Advisory mode: writes will be proposed, never executed.'));
178
+ console.log(' ' + formatAttention({ text: 'Advisory mode', fact: 'writes are proposed, never executed' }).trimEnd());
177
179
  }
178
180
  }
179
181
  }