@cspeach/cli 1.1.17 → 1.1.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (225) hide show
  1. package/README.md +3 -4
  2. package/dist/agent/loop.js +143 -25
  3. package/dist/agent/providers/local-provider.js +4 -1
  4. package/dist/agent/repair-partial.js +76 -19
  5. package/dist/agent/tool-dispatch.js +80 -22
  6. package/dist/agent/turn-error-ux.js +77 -26
  7. package/dist/agent/turn-stream.js +25 -3
  8. package/dist/approvals/adt-type.js +27 -0
  9. package/dist/approvals/advisory-prompt.js +48 -0
  10. package/dist/approvals/advisory-render.js +25 -11
  11. package/dist/approvals/approval-prompt.js +74 -24
  12. package/dist/approvals/jwt.js +2 -1
  13. package/dist/approvals/render.js +64 -36
  14. package/dist/approvals/risk-floor.js +53 -2
  15. package/dist/auth/api-key.js +30 -16
  16. package/dist/auth/keychain.js +0 -0
  17. package/dist/auth/me.js +25 -7
  18. package/dist/cli.js +25 -6
  19. package/dist/commands/compact.js +12 -9
  20. package/dist/commands/config-set.js +6 -2
  21. package/dist/commands/config-show.js +7 -5
  22. package/dist/commands/cost.js +41 -34
  23. package/dist/commands/help.js +88 -50
  24. package/dist/commands/login.js +28 -19
  25. package/dist/commands/logout.js +7 -3
  26. package/dist/commands/plan-chain.js +71 -77
  27. package/dist/commands/plan-continue.js +1 -0
  28. package/dist/commands/plan-resume.js +20 -12
  29. package/dist/commands/whoami.js +23 -9
  30. package/dist/config/loader.js +15 -3
  31. package/dist/cost/pricing.js +6 -5
  32. package/dist/doctor/checks/forge-rules.js +13 -3
  33. package/dist/doctor/checks/keychain.js +6 -4
  34. package/dist/doctor/run.js +101 -20
  35. package/dist/index.js +5 -2
  36. package/dist/lib/graceful-exit.js +56 -0
  37. package/dist/lib/piped-prompt.js +65 -0
  38. package/dist/lock-contention.js +2 -2
  39. package/dist/one-shot.js +135 -40
  40. package/dist/projects/answer-blockers.js +6 -1
  41. package/dist/projects/handover-md.js +15 -14
  42. package/dist/projects/plan-run.js +97 -54
  43. package/dist/projects/promote-command.js +11 -2
  44. package/dist/projects/save-command.js +3 -2
  45. package/dist/projects/status.js +2 -1
  46. package/dist/projects/yes-no.js +12 -0
  47. package/dist/renderer/abap-inline.js +18 -14
  48. package/dist/renderer/answer-trim.js +69 -0
  49. package/dist/renderer/banners.js +8 -8
  50. package/dist/renderer/brand-settled.js +19 -0
  51. package/dist/renderer/change-card.js +156 -0
  52. package/dist/renderer/checklist-format.js +130 -0
  53. package/dist/renderer/color-mode.js +45 -0
  54. package/dist/renderer/error-detail.js +104 -0
  55. package/dist/renderer/fence-state.js +46 -0
  56. package/dist/renderer/footer-line.js +96 -0
  57. package/dist/renderer/glyphs.js +25 -0
  58. package/dist/renderer/goodbye.js +38 -0
  59. package/dist/renderer/legacy-palette.js +41 -0
  60. package/dist/renderer/look.js +30 -0
  61. package/dist/renderer/markdown.js +299 -92
  62. package/dist/renderer/notice-log.js +72 -0
  63. package/dist/renderer/notice-shape.js +48 -0
  64. package/dist/renderer/notices.js +40 -8
  65. package/dist/renderer/panel-rows.js +16 -0
  66. package/dist/renderer/pipeline.js +66 -0
  67. package/dist/renderer/progress-chatter.js +23 -17
  68. package/dist/renderer/rendering-mode.js +34 -0
  69. package/dist/renderer/routing-line.js +14 -0
  70. package/dist/renderer/sanitize.js +12 -0
  71. package/dist/renderer/severity.js +2 -7
  72. package/dist/renderer/startup-lines.js +152 -0
  73. package/dist/renderer/status-footer.js +52 -34
  74. package/dist/renderer/steering-echo.js +41 -0
  75. package/dist/renderer/syntax.js +31 -12
  76. package/dist/renderer/tables.js +5 -1
  77. package/dist/renderer/theme.js +44 -0
  78. package/dist/renderer/thinking-heartbeat.js +33 -2
  79. package/dist/renderer/todo-block.js +10 -49
  80. package/dist/renderer/tool-labels.js +238 -0
  81. package/dist/renderer/tool-widget.js +245 -51
  82. package/dist/renderer/trace.js +42 -0
  83. package/dist/renderer/transcript-flow.js +132 -0
  84. package/dist/renderer/tty.js +34 -1
  85. package/dist/renderer/ui-width.js +42 -0
  86. package/dist/renderer/verify-chain.js +4 -2
  87. package/dist/renderer/widget-fallback.js +58 -65
  88. package/dist/repl/bracketed-paste.js +7 -1
  89. package/dist/repl/builtin-commands.js +19 -8
  90. package/dist/repl/credential-handover-gate.js +28 -0
  91. package/dist/repl/early-line-buffer.js +5 -2
  92. package/dist/repl/file-picker.js +54 -12
  93. package/dist/repl/ink-stdin-guard.js +66 -0
  94. package/dist/repl/inquirer-guard.js +59 -16
  95. package/dist/repl/inquirer-theme.js +27 -33
  96. package/dist/repl/paste-marker.js +19 -0
  97. package/dist/repl/plan-turn-end.js +20 -0
  98. package/dist/repl/post-turn-status.js +36 -11
  99. package/dist/repl/reroute-turn.js +21 -0
  100. package/dist/repl/reset-tty-stdin.js +44 -0
  101. package/dist/repl/restore-guard.js +22 -0
  102. package/dist/repl/resume-standalone.js +63 -0
  103. package/dist/repl/rule8-detector.js +11 -3
  104. package/dist/repl/safety-confirm.js +161 -93
  105. package/dist/repl/session-spend-line.js +8 -4
  106. package/dist/repl/themed-prompts.js +19 -0
  107. package/dist/repl/ui-look-command.js +75 -0
  108. package/dist/repl/update-method-preview-hook.js +48 -5
  109. package/dist/repl.js +795 -332
  110. package/dist/rewind/cli.js +3 -2
  111. package/dist/rewind/format.js +14 -9
  112. package/dist/rewind/restore.js +11 -0
  113. package/dist/router/classifier.js +47 -4
  114. package/dist/router/intent-extractor.js +24 -8
  115. package/dist/router/piped-routing.js +23 -0
  116. package/dist/router/resume-routing.js +16 -0
  117. package/dist/router/routing-failure.js +57 -0
  118. package/dist/sap/first-run-choice.js +1 -1
  119. package/dist/sap/onboarding.js +3 -2
  120. package/dist/sap/standalone-onboarding.js +1 -1
  121. package/dist/sap/system-info.js +4 -2
  122. package/dist/sap/unreachable.js +28 -0
  123. package/dist/sap-errors/clean-error-text.js +182 -0
  124. package/dist/session/interrupt-reason.js +39 -0
  125. package/dist/session/recap.js +36 -24
  126. package/dist/session/resume.js +104 -36
  127. package/dist/session/store.js +69 -27
  128. package/dist/session/user-prompt.js +21 -0
  129. package/dist/skill-catalog.js +7 -0
  130. package/dist/skills/bundled-skills.js +76 -69
  131. package/dist/skills/signing-public-key.js +1 -1
  132. package/dist/standards/standards-init.js +1 -1
  133. package/dist/test-helpers/answer-any.js +46 -0
  134. package/dist/tools/approval.js +27 -11
  135. package/dist/tools/ask-question.js +47 -5
  136. package/dist/tools/filesystem/file-write.js +19 -2
  137. package/dist/tools/fiori/fe-extend.js +16 -1
  138. package/dist/tools/fiori/fe-scaffold.js +105 -3
  139. package/dist/tools/fiori/html-escapes.js +28 -0
  140. package/dist/tools/fiori/metadata/audit.js +605 -0
  141. package/dist/tools/fiori/metadata/references.js +104 -0
  142. package/dist/tools/fiori/metadata/types.js +8 -0
  143. package/dist/tools/fiori/preview/app-guard.js +215 -0
  144. package/dist/tools/fiori/preview/env.js +193 -0
  145. package/dist/tools/fiori/preview/readiness.js +127 -0
  146. package/dist/tools/fiori/preview/registry.js +412 -0
  147. package/dist/tools/fiori/preview/start.js +469 -0
  148. package/dist/tools/fiori/samples/loader.js +20 -5
  149. package/dist/tools/fiori/scaffold.js +18 -6
  150. package/dist/tools/fiori/smoke/app-driver-page.js +372 -0
  151. package/dist/tools/fiori/smoke/app-driver.js +100 -0
  152. package/dist/tools/fiori/smoke/assertions.js +161 -24
  153. package/dist/tools/fiori/smoke/audit-columns.js +21 -0
  154. package/dist/tools/fiori/smoke/batch.js +273 -0
  155. package/dist/tools/fiori/smoke/draft-safety.js +241 -0
  156. package/dist/tools/fiori/smoke/draft-smoke.js +473 -0
  157. package/dist/tools/fiori/smoke/driver.js +171 -4
  158. package/dist/tools/fiori/smoke/evidence.js +35 -0
  159. package/dist/tools/fiori/smoke/labels-codes.js +207 -0
  160. package/dist/tools/fiori/smoke/nav-error.js +26 -0
  161. package/dist/tools/fiori/smoke/run-smoke.js +155 -12
  162. package/dist/tools/fiori/tools.js +651 -7
  163. package/dist/tools/fiori/ui5-version.js +16 -0
  164. package/dist/tools/index.js +8 -0
  165. package/dist/tools/local-build.js +6 -0
  166. package/dist/tools/sap-read.js +16 -1
  167. package/dist/tools/sap-write.js +122 -10
  168. package/dist/tools/shell/shell_exec.js +30 -12
  169. package/dist/tools/syntax-state.js +67 -0
  170. package/dist/tools/todo.js +23 -18
  171. package/dist/tools/transport-fit.js +462 -0
  172. package/dist/tools/transport-resolution.js +3 -1
  173. package/dist/tools/transport.js +33 -7
  174. package/dist/tools/verify.js +184 -8
  175. package/dist/ui/app.js +284 -146
  176. package/dist/ui/approval-modal.js +41 -15
  177. package/dist/ui/ask-answer-text.js +17 -0
  178. package/dist/ui/body.js +25 -6
  179. package/dist/ui/card-slot.js +98 -0
  180. package/dist/ui/checklist.js +9 -0
  181. package/dist/ui/coaching-picker-classic.js +8 -5
  182. package/dist/ui/command-palette.js +6 -4
  183. package/dist/ui/confirm-request.js +18 -0
  184. package/dist/ui/context-grid.js +2 -1
  185. package/dist/ui/file-palette.js +6 -4
  186. package/dist/ui/files-card-emitter.js +19 -0
  187. package/dist/ui/footer-line.js +16 -0
  188. package/dist/ui/footer.js +105 -104
  189. package/dist/ui/input-wrap.js +122 -0
  190. package/dist/ui/interrupt.js +103 -0
  191. package/dist/ui/key-burst.js +117 -0
  192. package/dist/ui/line-resolution.js +4 -3
  193. package/dist/ui/live-area.js +71 -0
  194. package/dist/ui/login-banner.js +54 -43
  195. package/dist/ui/rewind-panel.js +76 -24
  196. package/dist/ui/role-props.js +47 -0
  197. package/dist/ui/sap-state-store.js +1 -0
  198. package/dist/ui/session-timeline.js +49 -3
  199. package/dist/ui/skill-picker.js +5 -47
  200. package/dist/ui/text-input.js +128 -24
  201. package/dist/ui/todo-emitter.js +19 -0
  202. package/dist/ui/turn-status-emitter.js +28 -2
  203. package/dist/ui/turn-status.js +16 -19
  204. package/dist/ui/use-card-request.js +44 -0
  205. package/dist/ui/widgets/ask-card.js +521 -0
  206. package/dist/ui/widgets/bar-chart.js +11 -5
  207. package/dist/ui/widgets/coaching-picker.js +50 -55
  208. package/dist/ui/widgets/confirm-card.js +193 -0
  209. package/dist/ui/widgets/dep-graph.js +3 -2
  210. package/dist/ui/widgets/diff-viewer.js +6 -5
  211. package/dist/ui/widgets/files-card.js +97 -0
  212. package/dist/ui/widgets/question-card.js +2 -1
  213. package/dist/ui/widgets/reclaim-raw-stdin.js +24 -0
  214. package/dist/ui/widgets/shortcuts-sheet.js +25 -0
  215. package/dist/ui/widgets/stack-frames.js +2 -1
  216. package/dist/upgrade-check.js +39 -8
  217. package/package.json +11 -2
  218. package/test-harness/fe-draft/app.ts +188 -0
  219. package/test-harness/fe-draft/fe-draft.e2e.test.ts +461 -0
  220. package/test-harness/fe-draft/manifest-enhancer.unit.test.ts +51 -0
  221. package/test-harness/fe-draft/routes.ts +224 -0
  222. package/test-harness/fe-draft/routes.unit.test.ts +45 -0
  223. package/test-harness/fe-draft/server.ts +225 -0
  224. package/test-harness/fe-draft/variants.ts +196 -0
  225. package/vitest.smoke-e2e.config.ts +20 -0
@@ -3,6 +3,7 @@ import { getTool, isStandalone } from '../tools/index.js';
3
3
  import { NOT_CONNECTED_MESSAGE } from '../sap/offline-adt-client.js';
4
4
  import { presentSafetyConfirmation } from '../repl/safety-confirm.js';
5
5
  import { shouldGateRule8, recordWriteOp, renderPlanSummary, getWriteOpsThisTurn, } from '../repl/rule8-detector.js';
6
+ import { isCredentialHandoverApproved, markCredentialHandoverApproved, } from '../repl/credential-handover-gate.js';
6
7
  export async function dispatchTool(name, args, ctx) {
7
8
  const tool = getTool(name);
8
9
  if (!tool) {
@@ -23,6 +24,37 @@ export async function dispatchTool(name, args, ctx) {
23
24
  is_error: true,
24
25
  };
25
26
  }
27
+ // L1-6 R1-6 (owner decision 2026-09-21) — a call that hands the stored SAP
28
+ // login to a local child process ALWAYS asks a human, once per session per
29
+ // alias, even as the first mutating call of a turn (where Rule 8 below stays
30
+ // silent). Headless runs are declined, never auto-approved.
31
+ // The hook may be async (it reads the app's files). A hook that throws is
32
+ // doubt — never let the call through unprompted: fail the call instead.
33
+ let handover = null;
34
+ try {
35
+ handover = (await tool.credentialHandover?.(args, ctx)) ?? null;
36
+ }
37
+ catch (err) {
38
+ return {
39
+ content: JSON.stringify({ error: 'credential_handover_check_failed', reason: String(err) }),
40
+ is_error: true,
41
+ };
42
+ }
43
+ const sessionId = ctx.session?.id;
44
+ if (handover && !isCredentialHandoverApproved(sessionId, handover.alias)) {
45
+ const r = await presentSafetyConfirmation({
46
+ rule: 'CREDENTIAL_HANDOVER',
47
+ op: tool.name,
48
+ object: { type: 'SAP login', name: handover.alias },
49
+ what_will_happen: handover.summary,
50
+ rollback_available: false, // a handed-over secret cannot be taken back
51
+ transport: '',
52
+ args,
53
+ });
54
+ if (!r.confirmed)
55
+ return declined(r);
56
+ markCredentialHandoverApproved(sessionId, handover.alias);
57
+ }
26
58
  // Rule 8 — no batch without plan. 2nd+ mutating call this turn requires
27
59
  // explicit confirmation. Fires regardless of /safety-mode toggle (batch
28
60
  // detection is unconditional per Plan 3 §6.6).
@@ -31,7 +63,12 @@ export async function dispatchTool(name, args, ctx) {
31
63
  // GUIDE.md) — local scratch, not SAP objects. Rule 8 protects SAP batches;
32
64
  // applying it here made the 2nd+ file (GUIDE.md) hit the batch card and,
33
65
  // headless, get refused. Exempt from the gate AND from the batch count.
34
- const scratchWrite = isScratchOutputWrite(tool, args, ctx.cwd);
66
+ // Local project work (owner decision 2026-09-25, CAP spike F2): the same
67
+ // reasoning for ALL local work — file edits (the filesystem tools refuse any
68
+ // path outside the project) and the safelisted local build commands. One-shot
69
+ // refused the 2nd edit of a CAP schema and left it half-changed. SAP writes
70
+ // are gated exactly as before.
71
+ const scratchWrite = isLocalProjectWork(tool, args) || isScratchOutputWrite(tool, args, ctx.cwd);
35
72
  if (tool.isMutating && !scratchWrite && shouldGateRule8()) {
36
73
  const r = await presentSafetyConfirmation({
37
74
  rule: 'RULE_8_BATCH_PLAN',
@@ -44,28 +81,10 @@ export async function dispatchTool(name, args, ctx) {
44
81
  rollback_available: true, // snapshots taken per-write
45
82
  transport: typeof args?.transport === 'string' ? args.transport : '',
46
83
  batch_count: getWriteOpsThisTurn().length + 1,
84
+ args,
47
85
  });
48
- if (!r.confirmed) {
49
- // B5 — discriminate WHO declined. A headless fail-fast (nobody ever
50
- // saw the card) must not masquerade as a user decision: the model
51
- // (and any transcript reader) reacts differently to "the user said
52
- // no" vs "no user was available to say yes". Uniform `headless: true`
53
- // marker matches the other headless result payloads.
54
- if (r.headless) {
55
- return {
56
- content: JSON.stringify({
57
- error: 'headless_safety_decline',
58
- headless: true,
59
- reason: r.reason,
60
- }),
61
- is_error: true,
62
- };
63
- }
64
- return {
65
- content: JSON.stringify({ error: 'cancelled_by_user', reason: r.reason }),
66
- is_error: true,
67
- };
68
- }
86
+ if (!r.confirmed)
87
+ return declined(r);
69
88
  }
70
89
  try {
71
90
  const result = await tool.handler(args, ctx);
@@ -83,6 +102,45 @@ export async function dispatchTool(name, args, ctx) {
83
102
  return { content: JSON.stringify({ error: String(err) }), is_error: true };
84
103
  }
85
104
  }
105
+ /**
106
+ * B5 — discriminate WHO declined a safety card. A headless fail-fast (nobody
107
+ * ever saw the card) must not masquerade as a user decision: the model (and
108
+ * any transcript reader) reacts differently to "the user said no" vs "no user
109
+ * was available to say yes". Uniform `headless: true` marker matches the
110
+ * other headless result payloads.
111
+ */
112
+ function declined(r) {
113
+ if (r.headless) {
114
+ return {
115
+ content: JSON.stringify({
116
+ error: 'headless_safety_decline',
117
+ headless: true,
118
+ reason: r.reason,
119
+ }),
120
+ is_error: true,
121
+ };
122
+ }
123
+ return {
124
+ content: JSON.stringify({ error: 'cancelled_by_user', reason: r.reason }),
125
+ is_error: true,
126
+ };
127
+ }
128
+ /**
129
+ * True for local project work: the filesystem tools (confined to the project
130
+ * folder by resolveSafePath) and shell_exec (a hardcoded safelist of local build
131
+ * and VCS commands run in the project). Neither touches an SAP object, which is
132
+ * what Rule 8 protects — EXCEPT a deploy or publish: `npm run deploy` /
133
+ * `ui5 deploy` / `fiori deploy` upload to the ABAP repository, so any shell
134
+ * command with a deploy/publish argument still counts (deliberately broad).
135
+ */
136
+ export function isLocalProjectWork(tool, args) {
137
+ if (tool.category === 'filesystem')
138
+ return true;
139
+ if (tool.category !== 'shell')
140
+ return false;
141
+ const argv = [args?.command, ...(Array.isArray(args?.args) ? args.args : [])].map((a) => String(a ?? ''));
142
+ return !argv.some((a) => /deploy|publish/i.test(a));
143
+ }
86
144
  /**
87
145
  * True for a mutating filesystem tool whose target sits under the project's
88
146
  * own ./cspeach-out/ (cwd). Accepts `cspeach-out/…`, `./cspeach-out/…`, the
@@ -5,8 +5,8 @@
5
5
  * everything. The new behaviour:
6
6
  *
7
7
  * - Writes the full error (message + stack) to ~/.cspeach/logs/<sessionId>-<ts>.log
8
- * - Prints a clean 4-6 line block to stderr with: cause, session id,
9
- * stream path, log path, resume hint.
8
+ * - Prints the piece 2 failure shape to stderr: cause, session id,
9
+ * stream path, log path and resume facts as dim detail, then one Next: line.
10
10
  * - Never throws — best-effort. The CLI returns to the prompt cleanly so
11
11
  * the user can either resume or type a new prompt.
12
12
  *
@@ -14,7 +14,9 @@
14
14
  * pushed any partial assistant content onto session.messages and saved
15
15
  * the session before the throw bubbled here. Resume picks up that state.
16
16
  */
17
- import chalk from 'chalk';
17
+ import { formatFailure, formatInfo } from '../renderer/notice-shape.js';
18
+ import { paint } from '../renderer/theme.js';
19
+ import { getLook } from '../renderer/look.js';
18
20
  import { promises as fs } from 'node:fs';
19
21
  import * as os from 'node:os';
20
22
  import * as path from 'node:path';
@@ -48,7 +50,37 @@ export function describeError(err) {
48
50
  return 'network timeout';
49
51
  if (msg.toLowerCase().includes('aborterror'))
50
52
  return 'request aborted';
51
- return msg.slice(0, 200);
53
+ const api = describeApiError(err, msg);
54
+ if (api)
55
+ return api;
56
+ return msg.replace(/\s+/g, ' ').trim().slice(0, 200);
57
+ }
58
+ /**
59
+ * 2026-09-27 — an upstream API error arrives as `Error: 400 {"type":"error",
60
+ * "error":{…,"message":"…"},"request_id":"…"}`. Show ONE readable line
61
+ * (`service error 400: <message>`), never the raw JSON body.
62
+ */
63
+ function describeApiError(err, msg) {
64
+ const brace = msg.indexOf('{');
65
+ if (brace < 0)
66
+ return null;
67
+ const end = msg.lastIndexOf('}');
68
+ let body;
69
+ try {
70
+ body = JSON.parse(msg.slice(brace, end + 1));
71
+ }
72
+ catch {
73
+ return null;
74
+ }
75
+ if (!body || typeof body !== 'object')
76
+ return null;
77
+ const head = msg.slice(0, brace).match(/(\d{3})\s*$/);
78
+ const statusRaw = head?.[1] ?? err?.status;
79
+ const status = statusRaw != null && /^\d{3}$/.test(String(statusRaw)) ? String(statusRaw) : null;
80
+ const inner = body?.error?.message ?? body?.message;
81
+ const text = typeof inner === 'string' ? inner.replace(/\s+/g, ' ').trim() : '';
82
+ const label = status ? `service error ${status}` : 'service error';
83
+ return (text ? `${label}: ${text}` : label).slice(0, 200);
52
84
  }
53
85
  /**
54
86
  * Write a full diagnostic log to ~/.cspeach/logs/<sessionId>-<ts>.log.
@@ -96,38 +128,57 @@ async function writeErrorLog(sessionId, err) {
96
128
  return null;
97
129
  }
98
130
  }
131
+ /** Print a multi-line block through the line-based `print` sink. */
132
+ function printBlock(print, block) {
133
+ for (const l of block.replace(/\n$/, '').split('\n'))
134
+ print(l);
135
+ }
99
136
  /**
100
- * Render the user-facing turn-error block. Writes a log file (best-effort)
101
- * and prints a 4-6 line diagnostic that names the cause + the recovery path.
137
+ * Render the user-facing turn-error block (piece 2 §9 failure shape). Writes a
138
+ * log file (best-effort) and prints the cause plus the Session, Stream, Log and
139
+ * Resume facts as dim detail, ending with ONE Next: line — "type continue"
140
+ * inside a live session, `cspeach --resume <id>` otherwise.
102
141
  */
103
142
  export async function renderTurnError(input) {
104
143
  const print = input.print ?? ((line) => process.stderr.write(line + '\n'));
144
+ const id = input.session.id;
145
+ const resumeRows = [
146
+ `Resume: cspeach --resume ${id}`,
147
+ ` cspeach --resume (no arg → pick from a list)`,
148
+ ];
105
149
  print('');
106
150
  if (input.userInterrupted) {
107
- // User pressed Ctrl+C / Esc — frame as a deliberate cancellation,
108
- // not a crash. Skip the log file (no diagnostic value).
109
- print(chalk.yellow(`✋ Turn cancelled.`));
110
- print(chalk.gray(` Session: ${input.session.id}`));
111
- print(chalk.gray(` Resume: cspeach --resume ${input.session.id}`));
112
- print(chalk.gray(` cspeach --resume (no arg → pick from a list)`));
151
+ // User pressed Ctrl+C / Esc — a deliberate cancellation, not a crash.
152
+ // Skip the log file (no diagnostic value). Round 6 item 4d — the panels
153
+ // shape: ONE notice line. Review M4 — the legacy look keeps its rows.
154
+ if (getLook() === 'panels') {
155
+ printBlock(print, formatInfo(input.inSession
156
+ ? 'Turn cancelled · your work so far is kept · type continue to pick up'
157
+ : `Turn cancelled · resume: cspeach --resume ${id}`));
158
+ }
159
+ else {
160
+ printBlock(print, formatInfo('Turn cancelled'));
161
+ for (const r of [`Session: ${id}`, ...resumeRows])
162
+ print(` ${paint('dim', r)}`);
163
+ }
113
164
  print('');
114
165
  return;
115
166
  }
116
167
  const reason = describeError(input.err);
117
- const logPath = await writeErrorLog(input.session.id, input.err);
118
- print(chalk.red(`⚠ Turn interrupted: ${reason}`));
119
- if (input.inSession) {
120
- print(chalk.yellow(` Your work so far is saved. Type continue to pick up where it stopped.`));
121
- }
122
- print(chalk.gray(` Session: ${input.session.id}`));
123
- if (input.streamFilePath) {
124
- print(chalk.gray(` Stream: ${input.streamFilePath}`));
125
- }
126
- if (logPath) {
127
- print(chalk.gray(` Log: ${logPath}`));
128
- }
129
- print(chalk.gray(` Resume: cspeach --resume ${input.session.id}`));
130
- print(chalk.gray(` cspeach --resume (no arg → pick from a list)`));
168
+ const logPath = await writeErrorLog(id, input.err);
169
+ const detail = [reason, `Session: ${id}`];
170
+ if (input.streamFilePath)
171
+ detail.push(`Stream: ${input.streamFilePath}`);
172
+ if (logPath)
173
+ detail.push(`Log: ${logPath}`);
174
+ detail.push(...resumeRows);
175
+ printBlock(print, formatFailure({
176
+ what: 'Turn interrupted',
177
+ detail,
178
+ next: input.inSession
179
+ ? 'type continue to pick up where it stopped (your work so far is saved)'
180
+ : `cspeach --resume ${id}`,
181
+ }));
131
182
  print('');
132
183
  }
133
184
  /** Re-export for callers that want to compute a stream path independently. */
@@ -29,6 +29,17 @@ export function streamsRoot() {
29
29
  export class TurnStreamWriter {
30
30
  filePath;
31
31
  headerWritten = false;
32
+ /**
33
+ * Fix round 3 (item 11a) — every write runs after the previous one. The
34
+ * loop calls append() per text delta WITHOUT awaiting, so unserialised
35
+ * appendFile calls interleaved on disk (a scrambled log) and two racing
36
+ * first appends could each write the header.
37
+ */
38
+ chain = Promise.resolve();
39
+ enqueue(work) {
40
+ this.chain = this.chain.then(work, work);
41
+ return this.chain;
42
+ }
32
43
  constructor(sessionId, startedAt = new Date()) {
33
44
  // ISO timestamp with filename-safe punctuation.
34
45
  const stamp = startedAt.toISOString().replace(/[:.]/g, '-');
@@ -43,9 +54,12 @@ export class TurnStreamWriter {
43
54
  * file-IO errors so a write hiccup never kills a working turn. Lazily writes
44
55
  * a small header on the first append so an unused turn produces no file.
45
56
  */
46
- async append(text) {
57
+ append(text) {
47
58
  if (!text || text.length === 0)
48
- return;
59
+ return Promise.resolve();
60
+ return this.enqueue(() => this.write(text));
61
+ }
62
+ async write(text) {
49
63
  try {
50
64
  if (!this.headerWritten) {
51
65
  await fs.mkdir(path.dirname(this.filePath), { recursive: true });
@@ -63,7 +77,15 @@ export class TurnStreamWriter {
63
77
  * Close the stream with an optional footer (token totals, duration, end
64
78
  * status). Idempotent — safe to call multiple times.
65
79
  */
66
- async close(footer) {
80
+ close(footer) {
81
+ return this.enqueue(() => this.writeFooter(footer));
82
+ }
83
+ async writeFooter(footer) {
84
+ // Round 6 item 4e — a turn cut before it streamed any text (a cancel
85
+ // while thinking, /exit mid-turn) still gets a log with its footer, so
86
+ // the goodbye can point at it. A clean turn with no text writes nothing.
87
+ if (!this.headerWritten && footer)
88
+ await this.write('');
67
89
  if (!this.headerWritten)
68
90
  return;
69
91
  try {
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Re-review item 3 — one normaliser for object types, shared by the syntax
3
+ * state (tools/syntax-state.ts) and the 8e risk-floor backstop. Approvals
4
+ * carry whatever type word the model chose ("CLASS", "Program", "PROG/P");
5
+ * the syntax state records the tool's ADT code. Unknown words return
6
+ * undefined so the caller can fall back to a name-only match.
7
+ */
8
+ const ALIASES = {
9
+ CLASS: 'CLAS', PROGRAM: 'PROG', REPORT: 'PROG', INTERFACE: 'INTF',
10
+ 'FUNCTION GROUP': 'FUGR', FUNCTIONGROUP: 'FUGR', 'FUNCTION MODULE': 'FUNC', FUNCTION: 'FUNC',
11
+ TABLE: 'TABL', STRUCTURE: 'TABL', 'DATA ELEMENT': 'DTEL', DOMAIN: 'DOMA', INCLUDE: 'PROG',
12
+ 'CDS VIEW': 'DDLS', CDS: 'DDLS', 'CDS VIEW ENTITY': 'DDLS', 'BEHAVIOR DEFINITION': 'BDEF', 'BEHAVIOUR DEFINITION': 'BDEF',
13
+ 'SERVICE DEFINITION': 'SRVD', 'SERVICE BINDING': 'SRVB', 'METADATA EXTENSION': 'DDLX', 'ACCESS CONTROL': 'DCLS',
14
+ 'MESSAGE CLASS': 'MSAG', PACKAGE: 'DEVC',
15
+ };
16
+ const CODES = new Set([
17
+ 'CLAS', 'PROG', 'INTF', 'FUGR', 'FUNC', 'TABL', 'DTEL', 'DOMA', 'DDLS', 'BDEF', 'SRVD', 'SRVB',
18
+ 'DDLX', 'DCLS', 'MSAG', 'DEVC', 'ENHO', 'XSLT', 'TTYP', 'VIEW', 'SHLP', 'NROB',
19
+ ]);
20
+ /** ADT code (CLAS, PROG, …) for a type word, or undefined when unknown. */
21
+ export function normaliseAdtType(type) {
22
+ const t = (type ?? '').trim().toUpperCase().replace(/\s+/g, ' ');
23
+ const head = t.split('/')[0].trim();
24
+ if (CODES.has(head))
25
+ return head;
26
+ return ALIASES[t] ?? ALIASES[head];
27
+ }
@@ -1,7 +1,55 @@
1
1
  import clipboardy from 'clipboardy';
2
2
  import readline from 'node:readline/promises';
3
3
  import { stdin as input, stdout as output } from 'node:process';
4
+ import { shouldUseInk } from '../renderer/tty.js';
5
+ import { emitTrace, formatDecisionTrace } from '../renderer/trace.js';
6
+ import { paint } from '../renderer/theme.js';
7
+ import { getGlyphs } from '../renderer/glyphs.js';
8
+ import { askConfirm } from '../ui/confirm-request.js';
9
+ const ADVISORY_LABEL = { applied: 'I applied it', skipped: 'Skip', aborted: 'Stop' };
10
+ /**
11
+ * Piece 2 §6.2 — Ink: the proposal goes to scrollback once; a confirm card
12
+ * holds the choices. `c` copies, shows "· Copied" and asks again (not a
13
+ * decision, as today). y/c/s/n and the words yes/skip/abort still work.
14
+ * Esc / no listener → 'aborted': stop, nothing is written. The cursor starts
15
+ * on Copy, so a bare Enter decides nothing (as a bare Enter does today).
16
+ */
17
+ async function promptAdvisoryInk(args) {
18
+ emitTrace(args.displayText);
19
+ for (;;) {
20
+ const res = await askConfirm({
21
+ id: 'advisory-proposal',
22
+ tag: 'ADVISORY',
23
+ title: 'Did you apply this change in ADT or SE80?',
24
+ facts: ['The proposal is shown above.', 'Nothing is written to SAP from here.'],
25
+ choices: [
26
+ { value: 'applied', label: ADVISORY_LABEL.applied },
27
+ { value: 'copy', label: 'Copy to clipboard' },
28
+ { value: 'skipped', label: ADVISORY_LABEL.skipped },
29
+ { value: 'aborted', label: ADVISORY_LABEL.aborted },
30
+ ],
31
+ initial: 1,
32
+ aliases: {
33
+ y: 'applied', yes: 'applied', applied: 'applied',
34
+ c: 'copy', copy: 'copy',
35
+ s: 'skipped', skip: 'skipped', skipped: 'skipped',
36
+ n: 'aborted', no: 'aborted', abort: 'aborted', aborted: 'aborted',
37
+ },
38
+ footnote: 'Advisory mode',
39
+ }, { trace: false });
40
+ if (res.value === 'copy') {
41
+ await clipboardy.write(args.clipboardText);
42
+ emitTrace(paint('dim', `${getGlyphs().info} Copied`));
43
+ continue;
44
+ }
45
+ const outcome = res.value === 'applied' ? 'applied' : res.value === 'skipped' ? 'skipped' : 'aborted';
46
+ emitTrace(formatDecisionTrace('Advisory proposal', ADVISORY_LABEL[outcome]));
47
+ return outcome;
48
+ }
49
+ }
4
50
  export async function promptAdvisory(args) {
51
+ if (shouldUseInk())
52
+ return promptAdvisoryInk(args);
5
53
  output.write(args.displayText + '\n');
6
54
  const rl = readline.createInterface({ input, output });
7
55
  try {
@@ -1,12 +1,25 @@
1
1
  import chalk from 'chalk';
2
- export function renderAdvisoryProposal(args) {
2
+ import { paint } from '../renderer/theme.js';
3
+ import { uiWidth } from '../renderer/ui-width.js';
4
+ import { sanitizeControlChars as clean } from '../renderer/sanitize.js';
5
+ const ADVISORY_TITLE = ' ADVISORY MODE — proposed change (no SAP write will occur) ';
6
+ export const ADVISORY_KEY_HINT = 'Press c to copy to clipboard, y when applied, s to skip, n to abort.';
7
+ export function renderAdvisoryProposal(args, opts = {}) {
8
+ // Model text is shown with control characters made harmless (it must not
9
+ // drive the terminal); the clipboard copy keeps it verbatim.
3
10
  const displayParts = [];
4
11
  const clipboardParts = [];
5
- displayParts.push(chalk.yellow.bold('╔══ ADVISORY MODE — proposed change (no SAP write will occur) ══╗'));
6
- displayParts.push(chalk.bold(`Summary: ${args.summary}`));
12
+ // Borders line up: top and bottom share one width (§1.4 rule, widened
13
+ // only if the title itself would not fit).
14
+ const w = Math.max(uiWidth(), ADVISORY_TITLE.length + 6);
15
+ const top = '╔══' + ADVISORY_TITLE + '═'.repeat(w - 4 - ADVISORY_TITLE.length) + '╗';
16
+ const bottom = '╚' + '═'.repeat(w - 2) + '╝';
17
+ const rule = paint('dim', '─'.repeat(w));
18
+ displayParts.push(chalk.bold(paint('warn', top)));
19
+ displayParts.push(chalk.bold(`Summary: ${clean(args.summary)}`));
7
20
  displayParts.push(`Risk: ${args.risk}`);
8
21
  if (args.transport)
9
- displayParts.push(`Target transport: ${args.transport}`);
22
+ displayParts.push(`Target transport: ${clean(args.transport)}`);
10
23
  displayParts.push('');
11
24
  clipboardParts.push(`# ${args.summary}`);
12
25
  if (args.transport)
@@ -14,21 +27,22 @@ export function renderAdvisoryProposal(args) {
14
27
  clipboardParts.push('');
15
28
  args.changes.forEach((change, idx) => {
16
29
  const header = `Change ${idx + 1}: ${change.op.toUpperCase()} ${change.type} ${change.object}`;
17
- displayParts.push(chalk.cyan(header));
30
+ displayParts.push(paint('sap', clean(header)));
18
31
  clipboardParts.push(`## ${header}`);
19
32
  if (change.diff) {
20
- displayParts.push(chalk.gray('─'.repeat(64)));
21
- displayParts.push(change.diff);
22
- displayParts.push(chalk.gray('─'.repeat(64)));
33
+ displayParts.push(rule);
34
+ displayParts.push(clean(change.diff));
35
+ displayParts.push(rule);
23
36
  clipboardParts.push('');
24
37
  clipboardParts.push(change.diff);
25
38
  clipboardParts.push('');
26
39
  }
27
40
  });
28
41
  displayParts.push('');
29
- displayParts.push(chalk.yellow('Next step: open this object in ADT or SE80, apply the change, save and activate.'));
30
- displayParts.push(chalk.gray('Press c to copy to clipboard, y when applied, s to skip, n to abort.'));
31
- displayParts.push(chalk.yellow.bold('╚══════════════════════════════════════════════════════════════════╝'));
42
+ displayParts.push(paint('warn', 'Next step: open this object in ADT or SE80, apply the change, save and activate.'));
43
+ if (opts.interactiveHint !== false)
44
+ displayParts.push(paint('dim', ADVISORY_KEY_HINT));
45
+ displayParts.push(chalk.bold(paint('warn', bottom)));
32
46
  clipboardParts.push('');
33
47
  clipboardParts.push('# This is an advisory proposal. Apply manually in ADT/SE80.');
34
48
  return {
@@ -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) {