@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
@@ -14,8 +14,93 @@ import { checkCredits } from './checks/credits.js';
14
14
  import { checkForgeRules } from './checks/forge-rules.js';
15
15
  import { checkSystemRoles } from './checks/system-roles.js';
16
16
  import { checkStandards } from './checks/standards.js';
17
- export async function runDoctor() {
18
- console.log(chalk.cyan.bold('\nCSPeach — doctor\n'));
17
+ import { paint } from '../renderer/theme.js';
18
+ import { getGlyphs } from '../renderer/glyphs.js';
19
+ import { isNoColor } from '../renderer/color-mode.js';
20
+ import { sanitizeControlChars } from '../renderer/sanitize.js';
21
+ import { CLI_VERSION } from '../lib/version.js';
22
+ const NAME_WIDTH = 20;
23
+ /** Check messages can carry server text (HTTP bodies, SAP errors): one line, no control characters. */
24
+ const oneLine = (s) => sanitizeControlChars(s).replace(/\s*\r?\n\s*/g, ' ').trim();
25
+ /** Human name of the terminal CSPeach is running in (for the doctor title). */
26
+ export function terminalName(env = process.env, platform = process.platform) {
27
+ if (env['WT_SESSION'])
28
+ return 'Windows Terminal';
29
+ const tp = env['TERM_PROGRAM'];
30
+ if (tp === 'vscode')
31
+ return 'VS Code terminal';
32
+ if (tp === 'iTerm.app')
33
+ return 'iTerm2';
34
+ if (tp === 'Apple_Terminal')
35
+ return 'Terminal.app';
36
+ if (tp)
37
+ return oneLine(tp);
38
+ if (env['TERM'])
39
+ return oneLine(env['TERM']);
40
+ return platform === 'win32' ? 'Windows console' : 'unknown terminal';
41
+ }
42
+ export function colourLevelName(level, noColor = isNoColor()) {
43
+ if (noColor)
44
+ return 'no colour (NO_COLOR)';
45
+ if (level >= 3)
46
+ return 'truecolor';
47
+ if (level === 2)
48
+ return '256 colours';
49
+ if (level === 1)
50
+ return '16 colours';
51
+ return 'no colour';
52
+ }
53
+ /** 1 when any check failed; a skipped check (C.2 permission skip) is not a failure. */
54
+ export function doctorExitCode(checks) {
55
+ return checks.some((c) => !c.ok && !c.skipped) ? 1 : 0;
56
+ }
57
+ export function formatDoctorTitle(meta) {
58
+ return ['', `${chalk.bold('CSPeach doctor')} ${paint('dim', `${meta.version} · ${meta.terminal} · ${meta.colour}`)}`, ''];
59
+ }
60
+ export function formatDoctorRows(checks) {
61
+ const g = getGlyphs();
62
+ const lines = [];
63
+ let passed = 0;
64
+ let warnings = 0;
65
+ let failed = 0;
66
+ for (const c of checks) {
67
+ let glyph;
68
+ if (c.skipped) {
69
+ glyph = paint('warn', g.attention);
70
+ warnings++;
71
+ }
72
+ else if (c.ok) {
73
+ glyph = paint('ok', g.done);
74
+ passed++;
75
+ }
76
+ else {
77
+ glyph = paint('bad', g.failed);
78
+ failed++;
79
+ }
80
+ lines.push(`${glyph} ${oneLine(c.name).padEnd(NAME_WIDTH)} ${paint('dim', oneLine(c.message))}`);
81
+ if (c.hint && oneLine(c.hint) !== '')
82
+ lines.push(` ${paint('dim', 'Next:')} ${oneLine(c.hint)}`);
83
+ }
84
+ const summary = `${passed} of ${checks.length} checks passed · ${warnings} warning${warnings === 1 ? '' : 's'} · ${failed} failed`;
85
+ lines.push('', paint('dim', summary), '');
86
+ return lines;
87
+ }
88
+ /**
89
+ * Re-review 2 item 3 — the forge-rules probe writes to SAP (create, lock,
90
+ * write, snapshot list, delete); any throw there (lock refused, snapshot
91
+ * store unreadable, connection drop) is a failed row with a readable one-line
92
+ * message, never a crashed doctor run.
93
+ */
94
+ async function safeForgeRules() {
95
+ try {
96
+ return await checkForgeRules();
97
+ }
98
+ catch (err) {
99
+ const msg = String(err?.message ?? err).replace(/\s+/g, ' ').trim().slice(0, 160);
100
+ return { name: 'forge-rules', ok: false, message: `Rule 7/10 probe failed: ${msg}` };
101
+ }
102
+ }
103
+ export async function collectChecks() {
19
104
  const cfg = await loadConfig();
20
105
  const checks = [];
21
106
  checks.push(await checkProxy());
@@ -30,23 +115,19 @@ export async function runDoctor() {
30
115
  checks.push(await checkCert());
31
116
  checks.push(await checkSkill());
32
117
  checks.push(await checkKeychainFallback());
33
- checks.push(await checkForgeRules());
118
+ checks.push(await safeForgeRules());
34
119
  checks.push(await checkStandards());
35
- let failed = 0;
36
- for (const c of checks) {
37
- const icon = c.skipped
38
- ? chalk.yellow('- ')
39
- : c.ok
40
- ? chalk.green('OK ')
41
- : chalk.red('X ');
42
- console.log(` ${icon} ${c.name.padEnd(22)} ${c.message}`);
43
- if (c.hint)
44
- console.log(chalk.gray(` -> ${c.hint}`));
45
- if (!c.ok && !c.skipped)
46
- failed++;
47
- }
48
- console.log(failed === 0
49
- ? chalk.green('\nAll checks passed.\n')
50
- : chalk.yellow(`\n${failed} check(s) failed.\n`));
51
- return failed === 0 ? 0 : 1;
120
+ return checks;
121
+ }
122
+ export async function runDoctor(deps = {}) {
123
+ const print = deps.print ?? ((l) => console.log(l));
124
+ const meta = deps.meta ?? { version: CLI_VERSION, terminal: terminalName(), colour: colourLevelName(chalk.level) };
125
+ // Title first: the checks take seconds (network), and the user should see
126
+ // that doctor is running.
127
+ for (const l of formatDoctorTitle(meta))
128
+ print(l);
129
+ const checks = await (deps.collect ?? collectChecks)();
130
+ for (const l of formatDoctorRows(checks))
131
+ print(l);
132
+ return doctorExitCode(checks);
52
133
  }
package/dist/index.js CHANGED
@@ -1,6 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import { main } from './cli.js';
3
- main(process.argv).then((code) => process.exit(code), (err) => {
3
+ import { gracefulExit } from './lib/graceful-exit.js';
4
+ // Round 7 item 5 — close fetch's sockets before exiting (Windows libuv
5
+ // assertion on process.exit with live undici handles, nodejs/node#56645).
6
+ main(process.argv).then((code) => gracefulExit(code), (err) => {
4
7
  console.error('cspeach: unhandled error:', err);
5
- process.exit(1);
8
+ return gracefulExit(1);
6
9
  });
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Round 7 item 5 — exit without tearing live network handles down under Node.
3
+ *
4
+ * On Windows, process.exit() while fetch (Node's built-in undici) still holds
5
+ * sockets can abort with the libuv assertion
6
+ * `!(handle->flags & UV_HANDLE_CLOSING), src\win\async.c line 76`
7
+ * (known Node bug: nodejs/node#56645, #64322). The matrix saw it once after a
8
+ * routing cancel + /exit. We close fetch's global dispatcher first and give the
9
+ * loop a short, capped chance to settle, then exit as before.
10
+ *
11
+ * Review r7 M-1 — drain with close() (in-flight requests finish) and only
12
+ * destroy() when that does not finish inside the cap. A fire-and-forget fetch
13
+ * that rejects inside the window must not surface as a crash on the way out,
14
+ * so a no-op unhandledRejection guard is installed for the window only.
15
+ */
16
+ /** The global symbol Node's bundled undici stores its dispatcher under. */
17
+ export const UNDICI_DISPATCHER = Symbol.for('undici.globalDispatcher.1');
18
+ function capped(p, ms) {
19
+ let timer;
20
+ return Promise.race([
21
+ p.then(() => true, () => true),
22
+ new Promise((r) => { timer = setTimeout(() => r(false), ms); }),
23
+ ]).finally(() => { if (timer)
24
+ clearTimeout(timer); });
25
+ }
26
+ function call(fn, self) {
27
+ try {
28
+ return Promise.resolve(fn?.call(self));
29
+ }
30
+ catch (e) {
31
+ return Promise.reject(e);
32
+ }
33
+ }
34
+ export async function gracefulExit(code, opts = {}) {
35
+ const exit = opts.exit ?? ((c) => process.exit(c));
36
+ const graceMs = opts.graceMs ?? 250;
37
+ process.exitCode = code;
38
+ const guard = () => { };
39
+ process.on('unhandledRejection', guard);
40
+ try {
41
+ const dispatcher = globalThis[UNDICI_DISPATCHER];
42
+ if (dispatcher) {
43
+ let done = false;
44
+ if (typeof dispatcher.close === 'function')
45
+ done = await capped(call(dispatcher.close, dispatcher), graceMs);
46
+ if (!done && typeof dispatcher.destroy === 'function')
47
+ await capped(call(dispatcher.destroy, dispatcher), graceMs);
48
+ }
49
+ // One more loop turn so socket 'close' callbacks run before the teardown.
50
+ await new Promise((r) => setImmediate(r));
51
+ }
52
+ finally {
53
+ process.off('unhandledRejection', guard);
54
+ }
55
+ exit(code);
56
+ }
@@ -0,0 +1,65 @@
1
+ // A command head: "/name" followed by a space or the end of the line —
2
+ // "/cost", "/abap-explain ZFOO". A path ("/sap/bc/adt/…", "/usr/sap/…") has
3
+ // a second "/" in its first token and is prose (final review B M-3).
4
+ const SCRIPT_LINE = /(^|\n)[ \t]*\/[A-Za-z][\w-]*(?=[ \t]|\r?\n|$)/;
5
+ export function readPipedPrompt(stdin, opts = {}) {
6
+ if (stdin.isTTY)
7
+ return Promise.resolve({ kind: 'repl' });
8
+ const firstMs = opts.firstMs ?? 250;
9
+ const endMs = opts.endMs ?? 1500;
10
+ return new Promise((resolve) => {
11
+ const chunks = [];
12
+ let sawData = false;
13
+ let settled = false;
14
+ let t1;
15
+ let t2;
16
+ const text = () => Buffer.concat(chunks).toString('utf8').replace(/^/, '');
17
+ const done = (r) => {
18
+ if (settled)
19
+ return;
20
+ settled = true;
21
+ if (t1)
22
+ clearTimeout(t1);
23
+ if (t2)
24
+ clearTimeout(t2);
25
+ stdin.off('readable', onReadable);
26
+ stdin.off('end', onEnd);
27
+ resolve(r);
28
+ };
29
+ const giveBack = () => {
30
+ if (settled)
31
+ return;
32
+ if (chunks.length > 0)
33
+ stdin.unshift(Buffer.concat(chunks));
34
+ done({ kind: 'repl' });
35
+ };
36
+ const onEnd = () => {
37
+ const t = text().trim();
38
+ done(t ? { kind: 'prompt', text: t } : { kind: 'repl' });
39
+ };
40
+ const onReadable = () => {
41
+ // EOF with nothing read: leave it unread so the REPL sees the end.
42
+ if (!sawData && stdin.readableLength === 0) {
43
+ done({ kind: 'repl' });
44
+ return;
45
+ }
46
+ let c;
47
+ while ((c = stdin.read()) !== null)
48
+ chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(c));
49
+ if (SCRIPT_LINE.test(text())) {
50
+ giveBack();
51
+ return;
52
+ }
53
+ if (!sawData) {
54
+ sawData = true;
55
+ if (t1)
56
+ clearTimeout(t1);
57
+ t2 = setTimeout(giveBack, endMs);
58
+ }
59
+ };
60
+ stdin.on('readable', onReadable);
61
+ stdin.on('end', onEnd);
62
+ t1 = setTimeout(() => { if (!sawData)
63
+ done({ kind: 'repl' }); }, firstMs);
64
+ });
65
+ }
@@ -1,7 +1,7 @@
1
1
  import { select } from '@inquirer/prompts';
2
- import chalk from 'chalk';
2
+ import { formatAttention } from './renderer/notice-shape.js';
3
3
  export async function handleLockConflict(info) {
4
- console.log(chalk.yellow(`\n⚠ ${info.object} is locked by ${info.user} since ${info.lockTime}.`));
4
+ console.log('\n' + formatAttention({ text: `${info.object} is locked`, fact: `by ${info.user} since ${info.lockTime}` }).trimEnd());
5
5
  return (await select({
6
6
  message: 'How do you want to proceed?',
7
7
  choices: [
package/dist/one-shot.js CHANGED
@@ -1,9 +1,13 @@
1
1
  import chalk from 'chalk';
2
2
  import { loadConfig, resolveLocalBuild, resolveStandalone } from './config/loader.js';
3
+ import { applyColorMode } from './renderer/color-mode.js';
4
+ import { formatAttention, formatInfo } from './renderer/notice-shape.js';
5
+ import { formatRoutingLine } from './renderer/routing-line.js';
3
6
  import { createOfflineAdtClient } from './sap/offline-adt-client.js';
4
7
  import { STANDALONE_ALIAS } from './tools/index.js';
5
8
  import { getStore } from './auth/api-key.js';
6
9
  import { getConnection } from './sap/connection-manager.js';
10
+ import { unreachableCode, unreachableMessage } from './sap/unreachable.js';
7
11
  import { AdtClient } from '@cspeach/sap-client';
8
12
  import { makeSessionId, saveSession } from './session/store.js';
9
13
  import { newSession } from './session/schema.js';
@@ -13,13 +17,23 @@ import { createProviderForMode } from './agent/providers/factory.js';
13
17
  import { assertModeLicensed, CspeachLicenseError } from './agent/providers/license-gate.js';
14
18
  import { runTurn } from './agent/loop.js';
15
19
  import { classifyPrompt, parseShortcut, rejectUnknownSkill } from './router/classifier.js';
16
- import { setRenderingOverride, setHeadlessOverride } from './renderer/tty.js';
20
+ import { routingFailureFromResult, routingFailureOneShotLine } from './router/routing-failure.js';
21
+ import { startThinkingHeartbeat, ROUTING_HEARTBEAT_THRESHOLDS } from './renderer/thinking-heartbeat.js';
22
+ import { setRenderingOverride, setHeadlessOverride, setOneShotOverride } from './renderer/tty.js';
23
+ import { resolveRenderingMode } from './renderer/rendering-mode.js';
24
+ import { setLook, setBackground } from './renderer/look.js';
17
25
  import { alwaysDeclinePreviewHook } from './repl/update-method-preview-hook.js';
18
26
  import { createClassicOutputEmitter } from './renderer/notices.js';
27
+ import { AnswerTrim } from './renderer/answer-trim.js';
28
+ import { writeStdout } from './renderer/transcript-flow.js';
29
+ import { writeAboveSpinner, aboveSpinnerAddsNewline } from './renderer/tool-widget.js';
19
30
  import { todoEmitter } from './ui/todo-emitter.js';
20
31
  import { applyLocalBuildConfig, applyStandaloneFileTools } from './tools/local-build.js';
21
32
  import { getEffectiveWriteMode } from './repl/mode-cycle.js';
22
33
  import { initCredits } from './cost/credits-wire.js';
34
+ import { formatTurnReceipt, turnSpendText } from './renderer/footer-line.js';
35
+ import { snapshotTurnStart, computeTurnFacts } from './repl/post-turn-status.js';
36
+ import { globalCredits } from './cost/credits.js';
23
37
  // Side-effect imports for tool registration.
24
38
  import './tools/sap-read.js';
25
39
  import './tools/sap-write.js';
@@ -52,11 +66,18 @@ import './tools/subagent/monitor_emit.js';
52
66
  import './tools/subagent/schedule_draft_create.js';
53
67
  import './tools/subagent/agent_run.js';
54
68
  export async function runOneShot(prompt, opts) {
69
+ applyColorMode(); // NO_COLOR (piece 2 §1.2)
55
70
  const cfg = await loadConfig();
56
71
  // Standalone (2026-09-16) — run with no SAP system at all: no connection, no
57
72
  // AdtClient, SAP tools hidden. `--no-sap` for this run, or `no_sap = true`.
58
73
  const standalone = resolveStandalone(cfg, opts?.noSap === true);
59
- setRenderingOverride(cfg.ui.rendering);
74
+ // One-shot output is unchanged by the classic retirement: `auto` never
75
+ // mounts Ink for a one-shot run. CSPEACH_UI=classic is honoured here too;
76
+ // the stored-value rewrite happens only on an interactive start.
77
+ setOneShotOverride(true);
78
+ setRenderingOverride(resolveRenderingMode(cfg.ui.rendering).mode);
79
+ setLook(cfg.ui.look);
80
+ setBackground(cfg.ui.background);
60
81
  // model-governance step 2d — fire-and-forget fetch of the served model-config
61
82
  // (managed mode only, never blocks). A turn that starts before it lands uses
62
83
  // the built-ins (fail-safe).
@@ -80,8 +101,10 @@ export async function runOneShot(prompt, opts) {
80
101
  // write_mode stands on its own.
81
102
  const oneShotAlias = standalone ? undefined : (opts?.sapAlias ?? Object.keys(cfg.sap)[0]);
82
103
  const oneShotRole = oneShotAlias !== undefined ? cfg.sap[oneShotAlias]?.role : undefined;
104
+ // Fix round 3 (item 7): every startup notice below goes to STDERR — piped
105
+ // stdout stays the answer only (spec §8).
83
106
  if (getEffectiveWriteMode(cfg.write_mode, oneShotRole) === 'advisory-only') {
84
- console.log(chalk.yellow('⚠ Advisory mode: writes will be proposed, not executed.'));
107
+ console.error(formatAttention({ text: 'Write mode: advisory', fact: 'writes are proposed, not executed' }).trimEnd());
85
108
  }
86
109
  // local_build toggle — force-enable the flag-gated filesystem tools AND
87
110
  // shell_exec BEFORE the (single) turn when persisted-on. Resolve with backward
@@ -91,11 +114,11 @@ export async function runOneShot(prompt, opts) {
91
114
  {
92
115
  const announce = applyLocalBuildConfig(resolveLocalBuild(cfg), process.cwd());
93
116
  if (announce)
94
- console.log(chalk.dim(announce));
117
+ console.error(chalk.dim(announce));
95
118
  // 2026-09-17 — standalone writes files by contract; enable the file tools
96
119
  // without the separate local_build step (see repl.tsx for the live case).
97
120
  else if (standalone)
98
- console.log(chalk.dim(applyStandaloneFileTools(process.cwd())));
121
+ console.error(chalk.dim(applyStandaloneFileTools(process.cwd())));
99
122
  }
100
123
  const store = await getStore();
101
124
  if (!(await store.get())) {
@@ -104,9 +127,9 @@ export async function runOneShot(prompt, opts) {
104
127
  }
105
128
  // Credits (2026-09-16) — resolve the display mode before the turn runs, so
106
129
  // the per-turn cost JSONL records the ledger charge and nothing downstream
107
- // can print a dollar to a managed customer. One-shot prints no cost figure
108
- // of its own today, but the agent loop's cost-log path is shared with the
109
- // REPL and must not diverge. Capped + fail-safe; BYOK/local skip the call.
130
+ // can print a dollar to a managed customer — including the one-line stderr
131
+ // receipt after the turn (piece 2 I3a), and the agent loop's cost-log path,
132
+ // which is shared with the REPL. Capped + fail-safe; BYOK/local skip the call.
110
133
  await initCredits(cfg, async () => (await store.get()) ?? '');
111
134
  const aliases = Object.keys(cfg.sap);
112
135
  let alias;
@@ -117,12 +140,12 @@ export async function runOneShot(prompt, opts) {
117
140
  // filter + dispatch guard.
118
141
  alias = STANDALONE_ALIAS;
119
142
  adt = createOfflineAdtClient();
120
- console.log(chalk.dim('Standalone mode: no SAP system connected — skills work from pasted code. ' +
143
+ console.error(chalk.dim('Standalone mode: no SAP system connected — skills work from pasted code. ' +
121
144
  'Connect later with: cspeach config add-sap'));
122
145
  // One-shot is headless by design (no prompts), so an unanswered standalone
123
146
  // profile can't be filled in here — say how, and carry on with "unknown".
124
147
  if (!cfg.standalone) {
125
- console.log(chalk.dim('Standalone profile not set — run: cspeach config set standalone.platform ...'));
148
+ console.error(chalk.dim('Standalone profile not set — run: cspeach config set standalone.platform ...'));
126
149
  }
127
150
  }
128
151
  else {
@@ -133,9 +156,19 @@ export async function runOneShot(prompt, opts) {
133
156
  // C.1 — --sap-alias flag override
134
157
  alias = opts?.sapAlias ?? aliases[0];
135
158
  if (opts?.sapAlias) {
136
- console.log(chalk.dim(`Using --sap-alias ${opts.sapAlias}`));
159
+ console.error(chalk.dim(`Using --sap-alias ${opts.sapAlias}`));
160
+ }
161
+ let conn;
162
+ try {
163
+ conn = await getConnection(alias, cfg.sap[alias]);
164
+ }
165
+ catch (err) {
166
+ const code = unreachableCode(err);
167
+ if (!code)
168
+ throw err;
169
+ console.error(unreachableMessage(alias, cfg.sap[alias].host, cfg.sap[alias].port, code));
170
+ return 1;
137
171
  }
138
- const conn = await getConnection(alias, cfg.sap[alias]);
139
172
  adt = new AdtClient(conn);
140
173
  }
141
174
  const session = newSession(makeSessionId(), alias, null, resolveModelRole('session_default', cfg));
@@ -155,30 +188,51 @@ export async function runOneShot(prompt, opts) {
155
188
  skill = parsed.skill;
156
189
  }
157
190
  else {
158
- // One-shot has no interactive picker — always uses top-1 and prints the
159
- // confidence band on a dim prelude line. If < 0.6, emit a stderr warning
160
- // with top-3 alternates so the user can rerun with an explicit /skill.
161
- const c = await classifyPrompt(body, {
162
- sap_system: alias,
163
- last_skill: session.last_skill ?? null,
164
- last_object: parsed.object ?? session.last_object ?? null,
165
- });
191
+ // One-shot has no interactive picker — always uses top-1 and names it on
192
+ // a dim stderr line. If < 0.6, add a stderr `!` notice with the top-3
193
+ // candidates so the user can rerun with an explicit /skill.
194
+ // Fix round 3 (item 8) — say we are routing while the classifier runs
195
+ // (stderr; nothing prints when it answers within a second).
196
+ const routingBeat = startThinkingHeartbeat({ thresholds: ROUTING_HEARTBEAT_THRESHOLDS });
197
+ let c;
198
+ try {
199
+ c = await classifyPrompt(body, {
200
+ sap_system: alias,
201
+ last_skill: session.last_skill ?? null,
202
+ last_object: parsed.object ?? session.last_object ?? null,
203
+ });
204
+ }
205
+ catch (err) {
206
+ // Round 4 item 4 — a missing key here was an uncaught `no_api_key`
207
+ // with a stack (and a libuv assertion on exit). One clean line, exit 1.
208
+ if (err instanceof Error && err.message === 'no_api_key') {
209
+ console.error('No API key configured. Run `cspeach` (interactive) first to set one.');
210
+ return 1;
211
+ }
212
+ throw err;
213
+ }
214
+ finally {
215
+ routingBeat.stop();
216
+ }
166
217
  if (c.offline) {
167
218
  // Remote classify failed twice — local-rules fallback fired. Print
168
219
  // to stderr so it doesn't pollute stdout for scripted callers, but
169
220
  // is still visible.
170
- console.error(chalk.dim(`[offline classifier — proxy unreachable: ${c.transport_error ?? 'unknown'}]`));
221
+ // Fix 9 (2026-09-26): same true wording as the REPL — a timeout is not "offline".
222
+ console.error(chalk.dim(routingFailureOneShotLine(routingFailureFromResult(c), c.transport_error)));
171
223
  }
172
- const pct = Math.round(c.confidence * 100);
224
+ // Piece 2 (spec §8/§9) — routing chatter goes to STDERR so piped stdout
225
+ // stays the answer only. Confidence percentages are dropped everywhere.
173
226
  if (c.screenshot)
174
- console.log(chalk.dim(`┄ Screenshot: ${c.screenshot}`));
227
+ process.stderr.write(formatInfo(`Screenshot: ${c.screenshot}`));
228
+ process.stderr.write(formatRoutingLine({ kind: 'using', skill: c.skill }));
175
229
  if (c.confidence < 0.6) {
176
230
  const alternates = [c.skill, ...c.alternates].slice(0, 3).map((s) => `/${s}`).join(', ');
177
- console.error(chalk.yellow(`[classifier] Low confidence (${pct}%). Using /${c.skill}. ` +
178
- `Candidates: ${alternates}. Rerun with an explicit /skill to override.`));
179
- }
180
- else {
181
- console.log(chalk.dim(`┄ Routed to /${c.skill} (${pct}%)`));
231
+ process.stderr.write(formatAttention({
232
+ text: 'Low confidence',
233
+ fact: `candidates: ${alternates}`,
234
+ next: 'rerun with an explicit /skill to override',
235
+ }));
182
236
  }
183
237
  skill = c.skill;
184
238
  }
@@ -201,18 +255,59 @@ export async function runOneShot(prompt, opts) {
201
255
  throw new Error('No API key configured');
202
256
  return key;
203
257
  });
204
- await runTurn({
205
- provider,
206
- userMessage: body,
207
- skill,
208
- // B2 fix (2026-06-12): one-shot runs render tool notices ('warn'/'info'
209
- // — Rule 9 transport overrides, snapshot lines) to stdout instead of
210
- // dropping them. Headless transcripts need these for auditability.
211
- // Task 5 (ux-wave2): todoEmitter wired for the uniform top-level-ctx
212
- // contract — no listener exists in one-shot, so emits are no-ops, but
213
- // "top-level ctxs carry it / subagent ctxs omit it" stays the single
214
- // rule (see ui/todo-emitter.ts).
215
- ctx: { adt, sapAlias: alias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: alwaysDeclinePreviewHook, chunkEmitter: createClassicOutputEmitter(), todoEmitter },
258
+ const turnSnapshot = snapshotTurnStart(session);
259
+ // Round 6 item 4f — stdout is the answer, trimmed: no blank lines before
260
+ // it, exactly one newline after it.
261
+ const answerTrim = new AnswerTrim();
262
+ const answerEmitter = createClassicOutputEmitter((s) => {
263
+ const out = answerTrim.feed(s);
264
+ if (!out)
265
+ return;
266
+ // Review M3 — a painting spinner ends the line itself; tell the trim so
267
+ // its held newline does not become a stray blank line.
268
+ const forced = aboveSpinnerAddsNewline(out);
269
+ writeAboveSpinner(out, writeStdout);
270
+ if (forced)
271
+ answerTrim.noteForcedNewline();
216
272
  });
273
+ // Owner ruling (r3 M11) — stdout is the answer only, everywhere: tool
274
+ // notices (Rule 9 transport overrides, snapshot lines, the write chain)
275
+ // and tool rows go to STDERR in one-shot.
276
+ const noticeEmitter = createClassicOutputEmitter((s) => { process.stderr.write(s); });
277
+ try {
278
+ await runTurn({
279
+ provider,
280
+ userMessage: body,
281
+ skill,
282
+ // Fix round 7 item 2 — runTurn reads params.chunkEmitter (NOT
283
+ // ctx.chunkEmitter) for the answer stream; without this the trim never
284
+ // ran. Liveness stays off stdout (see loop.ts livenessEmitter).
285
+ chunkEmitter: answerEmitter,
286
+ // B2 fix (2026-06-12): one-shot runs render tool notices ('warn'/'info'
287
+ // — Rule 9 transport overrides, snapshot lines) instead of dropping them;
288
+ // headless transcripts need them for auditability. Owner ruling (r3 M11):
289
+ // they go to STDERR (noticeEmitter) — stdout is the answer only.
290
+ // Task 5 (ux-wave2): todoEmitter wired for the uniform top-level-ctx
291
+ // contract — no listener exists in one-shot, so emits are no-ops, but
292
+ // "top-level ctxs carry it / subagent ctxs omit it" stays the single
293
+ // rule (see ui/todo-emitter.ts).
294
+ ctx: { adt, sapAlias: alias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: alwaysDeclinePreviewHook, chunkEmitter: noticeEmitter, todoEmitter },
295
+ });
296
+ }
297
+ finally {
298
+ // Review M2 — even when the turn throws, the answer so far ends with its
299
+ // newline (and any held colour reset) instead of being cut mid-line.
300
+ const answerTail = answerTrim.end();
301
+ if (answerTail)
302
+ writeStdout(answerTail);
303
+ }
304
+ // Piece 2 (spec §8) — the one-line receipt, on STDERR (stdout = the answer).
305
+ const facts = computeTurnFacts(session, turnSnapshot);
306
+ process.stderr.write(formatTurnReceipt({
307
+ skill,
308
+ tokens: facts.turnTokens,
309
+ spend: turnSpendText({ costThisTurn: facts.turnCost, credits: globalCredits.snapshot() }),
310
+ ms: facts.elapsedMs,
311
+ }) + '\n');
217
312
  return 0;
218
313
  }
@@ -1,6 +1,7 @@
1
1
  import { writeFileSync } from 'node:fs';
2
2
  import { readProjectFile } from './status.js';
3
3
  import { validateEnvelope } from './validate.js';
4
+ import { askYesNo } from './yes-no.js';
4
5
  /**
5
6
  * The still-open BLOCKER-severity questions of a spec-gap envelope, in item
6
7
  * order. Returns [] for any non-spec-gap artefact (only spec-gap has gaps with
@@ -81,7 +82,11 @@ export async function offerAnswerBlockers(args) {
81
82
  const blockers = openBlockers(env);
82
83
  if (blockers.length === 0)
83
84
  return null;
84
- const reply = await args.prompt(`Answer the ${blockers.length} unanswered blocker(s) now? [y/N]: `);
85
+ const reply = await askYesNo({
86
+ question: `Answer the ${blockers.length} unanswered blocker(s) now?`,
87
+ confirm: args.confirm,
88
+ prompt: args.prompt,
89
+ });
85
90
  if (!affirmative(reply))
86
91
  return null;
87
92
  const answers = new Map();
@@ -17,32 +17,33 @@ import { basename, dirname, join } from 'node:path';
17
17
  import chalk from 'chalk';
18
18
  import { statusesFromItems, computeNextPhase, isPhaseSatisfied, planCompletionLines, } from './plan-run.js';
19
19
  import { projectFilename } from './filename.js';
20
+ import { UNICODE_GLYPHS } from '../renderer/glyphs.js';
20
21
  import { readProjectFile } from './status.js';
21
22
  /**
22
- * Plain-text mirror of the tracker's auditGlyph wording (plan-run.ts) — same
23
- * words, no chalk. Empty-audit phases render NO segment, matching the
24
- * tracker's legacy-envelope behaviour. The `default` arm is deliberate
25
- * forward-compat: an unknown future PlanAuditState reads "audit not run"
26
- * instead of crashing the projection.
23
+ * Plain-text mirror of the tracker's audit words (plan-run.ts, piece 2 §4) —
24
+ * same words, no chalk, LITERAL unicode glyphs: this is file content, so it
25
+ * never follows a terminal's ASCII fallback. Empty-audit phases render NO
26
+ * segment, matching the tracker's legacy-envelope behaviour. The `default`
27
+ * arm is deliberate forward-compat: an unknown future PlanAuditState reads
28
+ * "audit not run" instead of crashing the projection.
27
29
  */
28
30
  function auditWords(audit) {
29
31
  if (!audit)
30
32
  return null;
31
33
  switch (audit.state) {
32
- case 'passed': return '✔ audited';
34
+ case 'passed': return '✓ audited';
33
35
  case 'warn': {
34
- // Non-blocking tier — passed WITH notes. Plain-text mirror of the
35
- // tracker's warn glyph; count comes from findings.
36
+ // Non-blocking tier — passed WITH notes; count comes from findings.
36
37
  const n = audit.findings?.length ?? 0;
37
- return `⚠ passed with ${n} note${n === 1 ? '' : 's'}`;
38
+ return `! ${n} note${n === 1 ? '' : 's'}`;
38
39
  }
39
- case 'waived': return `✎ waived${audit.waiveCategory ? ` (${audit.waiveCategory})` : ''}`;
40
- case 'failed': return '✖ audit failed';
41
- case 'not_audited_hand_edited': return '✋ hand-edited';
40
+ case 'waived': return `· waived${audit.waiveCategory ? ` (${audit.waiveCategory})` : ''}`;
41
+ case 'failed': return '✗ audit failed';
42
+ case 'not_audited_hand_edited': return '· hand-edited';
42
43
  case 'pending':
43
44
  case 'infra_failed':
44
45
  default:
45
- return '⚠ audit not run';
46
+ return '! audit not run';
46
47
  }
47
48
  }
48
49
  /**
@@ -117,7 +118,7 @@ export function renderPlanHandover(envelope) {
117
118
  // resolves — version-less/newest-version resolution in plan-resume.ts
118
119
  // matches the whole family.
119
120
  const nextAction = allSatisfied
120
- ? planCompletionLines(content.phases).join(' ')
121
+ ? planCompletionLines(content.phases, UNICODE_GLYPHS).join(' ')
121
122
  : `/abap-plan --resume @${projectFilename({
122
123
  title: envelope.title, artefactType: 'plan', id: envelope.id, version: envelope.version,
123
124
  })}`;