@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
@@ -1,10 +1,19 @@
1
1
  import chalk from 'chalk';
2
+ import stringWidth from 'string-width';
2
3
  import { loadConfig, saveConfig } from '../config/loader.js';
3
- const BOX_WIDTH = 62;
4
- const TOP = '┌' + '─'.repeat(BOX_WIDTH) + '┐';
5
- const BOTTOM = '└' + '─'.repeat(BOX_WIDTH) + '┘';
6
- const pad = (s) => '│ ' + s.padEnd(BOX_WIDTH - 2) + '│';
7
- const blank = '│' + ' '.repeat(BOX_WIDTH) + '│';
4
+ import { shouldUseInk } from '../renderer/tty.js';
5
+ import { uiWidth } from '../renderer/ui-width.js';
6
+ import { paint } from '../renderer/theme.js';
7
+ import { emitTrace } from '../renderer/trace.js';
8
+ import { askConfirm } from '../ui/confirm-request.js';
9
+ // Classic box: width from the §1.4 rule (was a fixed 62); padding counts
10
+ // VISIBLE cells (string-width ignores ANSI) so coloured rows line up with
11
+ // plain ones. (strip-ansi is a devDependency only — not usable here.)
12
+ const boxWidth = () => uiWidth() - 2;
13
+ const top = () => '┌' + '─'.repeat(boxWidth()) + '┐';
14
+ const bottom = () => '└' + '─'.repeat(boxWidth()) + '┘';
15
+ const pad = (s) => '│ ' + s + ' '.repeat(Math.max(0, boxWidth() - 2 - stringWidth(s))) + '│';
16
+ const blank = () => '│' + ' '.repeat(boxWidth()) + '│';
8
17
  /**
9
18
  * Capture a single keypress for the y/n/d answer. Raw-mode readline so the
10
19
  * hand-drawn box stays the only visible affordance.
@@ -43,6 +52,31 @@ async function promptNagChoice() {
43
52
  process.stdin.on('data', onData);
44
53
  });
45
54
  }
55
+ /**
56
+ * Piece 2 §6.1 — under Ink the nag is a confirm card in the card slot.
57
+ * Raw stdin reads while Ink is mounted steal or double keystrokes.
58
+ * y / n / d stay as keys; Esc (or no listener) is 'n', like Ctrl+C today.
59
+ * Cursor starts on "No": the one choice that writes nothing.
60
+ */
61
+ async function promptNagChoiceInk(sapAlias) {
62
+ const res = await askConfirm({
63
+ id: 'auto-approve-nag',
64
+ tag: 'QUESTION',
65
+ title: `Auto-approve low-risk changes on ${sapAlias}?`,
66
+ facts: [
67
+ 'This change was classified as low risk.',
68
+ `Today every change on ${sapAlias} asks you first.`,
69
+ ],
70
+ choices: [
71
+ { value: 'y', label: 'Yes, auto-approve low-risk changes', hint: 'DEV systems only' },
72
+ { value: 'n', label: 'No, keep asking every time' },
73
+ { value: 'd', label: `Don't ask me this again on ${sapAlias}` },
74
+ ],
75
+ initial: 1,
76
+ aliases: { y: 'y', n: 'n', d: 'd' },
77
+ });
78
+ return res.value === 'y' || res.value === 'd' ? res.value : 'n';
79
+ }
46
80
  /**
47
81
  * Called just before `renderPerChangeApproval` when the current change
48
82
  * is low-risk on a `never` system and the nag has not been dismissed.
@@ -61,29 +95,42 @@ export async function maybeShowAutoApproveNag(ctx) {
61
95
  return;
62
96
  if (sapCfg.nag_dismissed ?? false)
63
97
  return;
64
- // Draw the box. This is the ONLY visible UI — keypress answers it directly.
65
- console.log('');
66
- console.log(TOP);
67
- console.log(blank);
68
- console.log(pad(chalk.yellow('This change was classified as LOW risk.')));
69
- console.log(blank);
70
- console.log(pad(`Your current policy on ${ctx.sapAlias} prompts on everything.`));
71
- console.log(pad('Want to auto-approve low-risk changes on this system?'));
72
- console.log(blank);
73
- console.log(pad(chalk.green('[y] yes') + ' (safer for DEV systems only)'));
74
- console.log(pad(chalk.dim('[n] no ') + ' (keep prompting every time)'));
75
- console.log(pad(chalk.dim('[d] don\'t ask me this again (on ' + ctx.sapAlias + ')')));
76
- console.log(blank);
77
- console.log(BOTTOM);
78
- process.stdout.write(chalk.dim('Press y / n / d: '));
79
- const choice = await promptNagChoice();
98
+ const ink = shouldUseInk();
99
+ let choice;
100
+ if (ink) {
101
+ choice = await promptNagChoiceInk(ctx.sapAlias);
102
+ }
103
+ else {
104
+ // Classic: the box is the UI — keypress answers it directly.
105
+ console.log('');
106
+ console.log(top());
107
+ console.log(blank());
108
+ console.log(pad(chalk.yellow('This change was classified as LOW risk.')));
109
+ console.log(blank());
110
+ console.log(pad(`Your current policy on ${ctx.sapAlias} prompts on everything.`));
111
+ console.log(pad('Want to auto-approve low-risk changes on this system?'));
112
+ console.log(blank());
113
+ console.log(pad(chalk.green('[y] yes') + ' (safer for DEV systems only)'));
114
+ console.log(pad(chalk.dim('[n] no ') + ' (keep prompting every time)'));
115
+ console.log(pad(chalk.dim('[d] don\'t ask me this again (on ' + ctx.sapAlias + ')')));
116
+ console.log(blank());
117
+ console.log(bottom());
118
+ process.stdout.write(chalk.dim('Press y / n / d: '));
119
+ choice = await promptNagChoice();
120
+ }
80
121
  if (choice === 'y') {
81
122
  const fresh = await loadConfig(); // re-read to avoid stale write
82
123
  if (fresh.sap[ctx.sapAlias]) {
83
124
  fresh.sap[ctx.sapAlias].auto_approve = 'low';
84
125
  await saveConfig(fresh);
85
- console.log(chalk.green(`Auto-approve set to "low" for ${ctx.sapAlias}.`));
86
- console.log(chalk.gray('Change with: cspeach config set sap.' + ctx.sapAlias + '.auto_approve never\n'));
126
+ if (ink) {
127
+ emitTrace(paint('ok', `Auto-approve set to "low" for ${ctx.sapAlias}.`));
128
+ emitTrace(paint('dim', `Change with: cspeach config set sap.${ctx.sapAlias}.auto_approve never`));
129
+ }
130
+ else {
131
+ console.log(chalk.green(`Auto-approve set to "low" for ${ctx.sapAlias}.`));
132
+ console.log(chalk.gray('Change with: cspeach config set sap.' + ctx.sapAlias + '.auto_approve never\n'));
133
+ }
87
134
  }
88
135
  }
89
136
  else if (choice === 'd') {
@@ -93,7 +140,10 @@ export async function maybeShowAutoApproveNag(ctx) {
93
140
  if (fresh.sap[ctx.sapAlias]) {
94
141
  fresh.sap[ctx.sapAlias].nag_dismissed = true;
95
142
  await saveConfig(fresh);
96
- console.log(chalk.gray(`Noted. This prompt will not appear again on ${ctx.sapAlias}.\n`));
143
+ if (ink)
144
+ emitTrace(paint('dim', `Noted. This prompt will not appear again on ${ctx.sapAlias}.`));
145
+ else
146
+ console.log(chalk.gray(`Noted. This prompt will not appear again on ${ctx.sapAlias}.\n`));
97
147
  }
98
148
  }
99
149
  // 'n' → no config change, fall through to per-change approval normally.
@@ -55,12 +55,13 @@ export async function mintApprovalId(payload) {
55
55
  * through this function instead of calling mintApprovalId with a free-text
56
56
  * object string.
57
57
  */
58
- export async function mintChangeApproval(change, sessionId) {
58
+ export async function mintChangeApproval(change, sessionId, opts = {}) {
59
59
  return mintApprovalId({
60
60
  object: canonicalApprovalObject(change.object),
61
61
  type: change.type,
62
62
  op: change.op,
63
63
  session_id: sessionId,
64
+ ...(opts.auto ? { auto: true } : {}),
64
65
  });
65
66
  }
66
67
  export async function verifyAndSpendApprovalId(jwt, expectedObject, expectedOp) {
@@ -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.
@@ -109,6 +119,17 @@ export async function main(argv) {
109
119
  }
110
120
  if (args[0] === 'config' && args[1] === 'show') {
111
121
  const { runConfigShow } = await import('./commands/config-show.js');
122
+ if (args.length === 2) {
123
+ // Task 12 — the full dump ends with "# model set by your account admin"
124
+ // when /v1/me/model-config says the admin's model is enforced. Capped at
125
+ // 1.5 s and fail-open: offline / signed out ⇒ the output is unchanged.
126
+ const [{ loadConfig }, { getStore }, { loadCustomerModelConfig }] = await Promise.all([
127
+ import('./config/loader.js'),
128
+ import('./auth/api-key.js'),
129
+ import('./models/server-config.js'),
130
+ ]);
131
+ await loadCustomerModelConfig(await loadConfig(), async () => (await (await getStore()).get()) ?? '', { timeoutMs: 1500 }).catch(() => { });
132
+ }
112
133
  await runConfigShow(args.slice(2));
113
134
  return 0;
114
135
  }
@@ -203,6 +224,15 @@ export async function main(argv) {
203
224
  const { runOneShot } = await import('./one-shot.js');
204
225
  return runOneShot(args.join(' '), { sapAlias, noSap });
205
226
  }
227
+ // Round 8 item 3 — `echo "prompt" | cspeach`: a piped stdin carrying one
228
+ // prompt runs as --one-shot (stdout = the answer only). Scripted pipes
229
+ // (slash lines), empty or live stdin stay with the REPL, nothing consumed.
230
+ const { readPipedPrompt } = await import('./lib/piped-prompt.js');
231
+ const piped = await readPipedPrompt(process.stdin);
232
+ if (piped.kind === 'prompt') {
233
+ const { runOneShot } = await import('./one-shot.js');
234
+ return runOneShot(piped.text, { sapAlias, noSap });
235
+ }
206
236
  // No args → REPL.
207
237
  const { runRepl } = await import('./repl.js');
208
238
  return runRepl({ sapAlias, noSap });