@cspeach/cli 1.1.45 → 1.1.47

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 (46) hide show
  1. package/dist/agent/chat-prompt.js +47 -0
  2. package/dist/agent/chat-unavailable.js +9 -0
  3. package/dist/agent/level-state.js +11 -0
  4. package/dist/agent/level.js +60 -0
  5. package/dist/agent/loop.js +110 -2
  6. package/dist/agent/provider-shape.js +6 -2
  7. package/dist/agent/providers/ai-hub-provider.js +3 -1
  8. package/dist/agent/providers/azure-provider.js +3 -1
  9. package/dist/agent/providers/byok-provider.js +3 -1
  10. package/dist/agent/providers/local-provider.js +3 -1
  11. package/dist/agent/style-state.js +11 -0
  12. package/dist/agent/style.js +59 -0
  13. package/dist/agent/system-prompt.js +24 -3
  14. package/dist/agent/tool-dispatch.js +55 -0
  15. package/dist/cli.js +8 -0
  16. package/dist/commands/config-set.js +59 -1
  17. package/dist/commands/config-show.js +1 -0
  18. package/dist/commands/export-docx.js +66 -0
  19. package/dist/commands/help.js +28 -2
  20. package/dist/config/loader.js +51 -1
  21. package/dist/docx/doc-meta.js +47 -0
  22. package/dist/docx/markdown-to-docx.js +250 -0
  23. package/dist/docx/write-docx.js +54 -0
  24. package/dist/one-shot.js +10 -0
  25. package/dist/projects/document-save.js +62 -0
  26. package/dist/projects/document-skills.js +9 -0
  27. package/dist/renderer/footer-line.js +6 -1
  28. package/dist/renderer/goodbye.js +4 -1
  29. package/dist/renderer/startup-lines.js +6 -2
  30. package/dist/renderer/tool-labels.js +2 -1
  31. package/dist/renderer/tool-widget.js +5 -1
  32. package/dist/repl/builtin-commands.js +4 -1
  33. package/dist/repl/chat-command.js +64 -0
  34. package/dist/repl/chat-lane.js +135 -0
  35. package/dist/repl/level-command.js +79 -0
  36. package/dist/repl/style-command.js +67 -0
  37. package/dist/repl.js +186 -105
  38. package/dist/session/queued-step.js +5 -1
  39. package/dist/session/schema.js +1 -0
  40. package/dist/session/store.js +4 -0
  41. package/dist/tools/hand-off.js +86 -0
  42. package/dist/tools/tool-loading.js +3 -0
  43. package/dist/ui/sap-state-store.js +1 -0
  44. package/dist/ui/widgets/ask-card.js +2 -1
  45. package/package.json +2 -2
  46. package/.cspeach/project.json +0 -4
package/dist/repl.js CHANGED
@@ -16,6 +16,10 @@ import { formatPanel } from './renderer/panel-rows.js';
16
16
  import { sanitizeControlChars } from './renderer/sanitize.js';
17
17
  import { formatRoutingLine } from './renderer/routing-line.js';
18
18
  import { handleUiLook, handleUiRendering } from './repl/ui-look-command.js';
19
+ import { handleStyleCommand, pickStyleClassic, pickStyleInk } from './repl/style-command.js';
20
+ import { handleLevelCommand, pickLevelClassic, pickLevelInk } from './repl/level-command.js';
21
+ import { handleChatCommand } from './repl/chat-command.js';
22
+ import { ChatUnavailableError } from './agent/chat-unavailable.js';
19
23
  import { CLI_VERSION } from './lib/version.js';
20
24
  import { createOfflineAdtClient } from './sap/offline-adt-client.js';
21
25
  import { STANDALONE_ALIAS } from './tools/index.js';
@@ -23,7 +27,11 @@ import { resumeStandaloneDecision, applySystemSwitch } from './repl/resume-stand
23
27
  import { clearAppFlow } from './lane/app-flow.js';
24
28
  import { clearJourneyFlow } from './journey/flow.js';
25
29
  import { createDispatchSlot, queuedStepView } from './session/queued-step.js';
26
- import { applyResumedSkill, isShortFollowUp } from './router/resume-routing.js';
30
+ import { applyResumedSkill } from './router/resume-routing.js';
31
+ import { ACK_PHRASES, wantsContinuation, isConversationalAck, decideLane, turnStatusLabel, applyLaneTools, chatFallbackOrigin } from './repl/chat-lane.js';
32
+ import { CHAT_SKILL } from './agent/chat-prompt.js';
33
+ import { setAssistantStyle } from './agent/style-state.js';
34
+ import { setAssistantLevel } from './agent/level-state.js';
27
35
  import { readableInterruptReason, isUserStopReason } from './session/interrupt-reason.js';
28
36
  import { getStore } from './auth/api-key.js';
29
37
  import { readAuthFile } from './auth/auth-file.js';
@@ -157,6 +165,7 @@ import './tools/approval.js';
157
165
  import './tools/snapshot.js';
158
166
  import './tools/ask-question.js';
159
167
  import './tools/dispatch-skill.js';
168
+ import './tools/hand-off.js';
160
169
  import './tools/todo.js';
161
170
  import './tools/filesystem/file-read.js';
162
171
  import './tools/tool-output-read.js';
@@ -211,68 +220,8 @@ function loadHistory() {
211
220
  // and were unreachable.
212
221
  .reverse());
213
222
  }
214
- /** Short user inputs that almost always mean "continue what we were doing"
215
- * rather than "start a new task." When the previous turn left a skill
216
- * in session.skill, an ack-only follow-up should route there instead of
217
- * re-classifying as a fresh prompt (which produces nonsense like "yes"
218
- * → /abap-explain at 35% confidence). */
219
- const ACK_PHRASES = new Set([
220
- 'yes', 'y', 'yeah', 'yep', 'yup', 'sure',
221
- 'ok', 'okay', 'k', 'kk',
222
- 'proceed', 'continue', 'go', 'go ahead', 'do it', 'do that', 'go on',
223
- 'no', 'n', 'nope', 'nah',
224
- 'stop', 'cancel', 'abort', 'never mind', 'nm',
225
- 'approve', 'approved', 'accept', 'accepted',
226
- 'decline', 'denied', 'reject', 'rejected',
227
- 'skip', 'next',
228
- ]);
229
- /** Conversational acknowledgements that mean "I'm done with the previous
230
- * turn, no further work needed." Distinct from ACK_PHRASES (which
231
- * largely mean "yes/no to a question"). For these we don't classify,
232
- * don't call any model — just print a friendly ack and return. The user
233
- * isn't asking us to do more work; they're closing the prior thread.
234
- *
235
- * 2026-05-17 — Laeeq hit "thank you" being classified and run through
236
- * /abap-explain, producing another huge code explanation. THIS is the
237
- * fix for that. */
238
- const CONVERSATIONAL_ACKS = new Set([
239
- 'thanks', 'thank you', 'thank u', 'thx', 'tx', 'ty', 'ta',
240
- 'cool', 'nice', 'great', 'awesome', 'perfect',
241
- 'got it', 'gotit', 'understood',
242
- 'cheers',
243
- // Common ackowledgements that follow a long answer.
244
- 'makes sense',
245
- ]);
246
- /**
247
- * True when `trimmed` is one of CONVERSATIONAL_ACKS. We treat these as
248
- * "user closes the previous thread, no further work" — print a friendly
249
- * acknowledgement and return without classifying or calling any model.
250
- */
251
- function isConversationalAck(trimmed) {
252
- const normalized = trimmed.toLowerCase().trim().replace(/[.!?,]+$/, '');
253
- return CONVERSATIONAL_ACKS.has(normalized);
254
- }
255
- /**
256
- * Returns true when `trimmed` is a short acknowledgement-style follow-up
257
- * AND there is a previous skill to continue. Treats both bare acks and
258
- * short ack-prefixed phrases ("yes please", "no thanks") the same way.
259
- * Strips trailing punctuation so "yes." and "yes!" still register.
260
- */
261
- function isAckContinuation(trimmed, sessionSkill) {
262
- if (!sessionSkill)
263
- return false;
264
- const normalized = trimmed.toLowerCase().trim().replace(/[.!?,]+$/, '');
265
- if (ACK_PHRASES.has(normalized))
266
- return true;
267
- // "yes please", "go ahead and do it", "no thanks" — first word is an ack
268
- // and the whole phrase is short enough to be a follow-up not a new task.
269
- if (normalized.length <= 30) {
270
- const firstWord = normalized.split(/\s+/)[0] ?? '';
271
- if (ACK_PHRASES.has(firstWord))
272
- return true;
273
- }
274
- return false;
275
- }
223
+ // CONVERSATIONAL_ACKS / isConversationalAck live in repl/chat-lane.ts (moved
224
+ // unchanged — the chat-lane decision reads the same set).
276
225
  /**
277
226
  * Returns true when `trimmed` is a bare ack ("y", "n", "ok", "cancel", ...)
278
227
  * with no surrounding context — regardless of whether a session.skill exists.
@@ -538,6 +487,15 @@ export async function runRepl(opts) {
538
487
  }
539
488
  let cfg = await loadConfig();
540
489
  setLook(cfg.ui.look);
490
+ // Chat lane (spec 2026-10-17 D1/D2/D5/D9) — the process lane and talking
491
+ // style. `laneSetting` is a `let`: Task 14's `/chat on|off` and the D11
492
+ // fallback (Task 15) reassign it, and decideLane reads it on every message.
493
+ // applyLaneTools is the one place the hand_off flag is enabled (interactive
494
+ // chat lane only — piped stdin keeps today's tools array and router).
495
+ let laneSetting = cfg.assistant.lane;
496
+ applyLaneTools(laneSetting, !!process.stdin.isTTY);
497
+ setAssistantStyle(cfg.assistant.style);
498
+ setAssistantLevel(cfg.assistant.level);
541
499
  // Apply the rendering mode BEFORE any shouldUseInk() call. This is the
542
500
  // interactive entry point, so it is never one-shot whatever argv holds. A
543
501
  // stored `classic` is retiring: read as auto, rewritten once, one dim notice
@@ -820,6 +778,8 @@ export async function runRepl(opts) {
820
778
  user: standalone ? '' : cfg.sap[selectedAlias].username,
821
779
  writeMode: startupWriteMode,
822
780
  standalone,
781
+ // Chat lane is interactive-only (applyLaneTools); piped stdin never chats.
782
+ chatLane: laneSetting === 'chat' && !!process.stdin.isTTY,
823
783
  });
824
784
  // v0.6 — when resuming, adopt the on-disk SessionState verbatim. This
825
785
  // preserves messages[], usage, last_skill, awaitingSkillAnswer, and the
@@ -931,6 +891,7 @@ export async function runRepl(opts) {
931
891
  tools: localTools,
932
892
  writeMode: startupWriteMode,
933
893
  cwd: process.cwd(),
894
+ chat: laneSetting === 'chat' && !!process.stdin.isTTY,
934
895
  };
935
896
  if (opts?.resumedSession) {
936
897
  const turnCount = session.messages.filter((m) => m.role === 'user' && typeof m.content === 'string').length;
@@ -979,7 +940,7 @@ export async function runRepl(opts) {
979
940
  }
980
941
  }
981
942
  else if (getLook() === 'panels') {
982
- process.stdout.write(panelsStartTail({ status: panelsStatus, third: panelsHintLine(localTools), notices: startupNotices }));
943
+ process.stdout.write(panelsStartTail({ status: panelsStatus, third: panelsHintLine(localTools, laneSetting), notices: startupNotices }));
983
944
  }
984
945
  else {
985
946
  console.log(chalk.gray(`\nSession ${session.id} on ${selectedAlias}. Type /exit to quit.\n`));
@@ -1166,7 +1127,7 @@ export async function runRepl(opts) {
1166
1127
  if (trimmed === '/help' || trimmed === '/skills') {
1167
1128
  // Phase D5 — route through chunkEmitter so help lands in Body,
1168
1129
  // not above the Ink frame (which would scroll off + corrupt).
1169
- printHelp((line) => chunkEmitter.emit('chunk', line + '\n'));
1130
+ printHelp((line) => chunkEmitter.emit('chunk', line + '\n'), { lane: laneSetting });
1170
1131
  return;
1171
1132
  }
1172
1133
  // /usage (2026-10-09) — this month in credits, from the proxy's ledger.
@@ -1190,8 +1151,14 @@ export async function runRepl(opts) {
1190
1151
  if (trimmed === '/export' || trimmed.startsWith('/export ')) {
1191
1152
  const { parseExportArgs, runExportAuditCommand } = await import('./commands/export-audit.js');
1192
1153
  const { sub, filename } = parseExportArgs(trimmed.slice('/export'.length));
1154
+ // Word export (2026-10-19): /export docx <file>.md, same body as `cspeach export`.
1155
+ if (sub === 'docx') {
1156
+ const { docxFileFromReplArgs, runExportDocxCommand } = await import('./commands/export-docx.js');
1157
+ await runExportDocxCommand({ file: docxFileFromReplArgs(trimmed.slice('/export'.length)), docx: true, system: session.sap_system, emit: (l) => chunkEmitter.emit('chunk', l + '\n') });
1158
+ return;
1159
+ }
1193
1160
  if (sub !== 'audit') {
1194
- chunkEmitter.emit('chunk', chalk.dim('\nUsage: /export audit [filename]\n'));
1161
+ chunkEmitter.emit('chunk', chalk.dim('\nUsage: /export audit [filename] · /export docx <file>.md\n'));
1195
1162
  return;
1196
1163
  }
1197
1164
  await runExportAuditCommand({
@@ -1318,6 +1285,24 @@ export async function runRepl(opts) {
1318
1285
  await handleUiRendering(arg, cfg, saveConfig, emit, shouldUseInk() ? 'ink' : 'classic');
1319
1286
  return;
1320
1287
  }
1288
+ // /style [value] — talking style (spec 2026-10-17 D9); saved like /ui look.
1289
+ if (trimmed === '/style' || trimmed.startsWith('/style ')) {
1290
+ if (!cfg.assistant)
1291
+ cfg.assistant = { ...DEFAULT_CONFIG.assistant };
1292
+ const emit = (s) => chunkEmitter.emit('chunk', s + '\n');
1293
+ await handleStyleCommand(trimmed.slice('/style'.length), cfg, saveConfig, emit, pickStyleInk);
1294
+ return;
1295
+ }
1296
+ // /level [value] — who CSPeach is talking to (spec 2026-10-17 §10 D13); saved like /style.
1297
+ // The D15 tip reads the write mode in force now (a /mode change this session counts).
1298
+ if (trimmed === '/level' || trimmed.startsWith('/level ')) {
1299
+ if (!cfg.assistant)
1300
+ cfg.assistant = { ...DEFAULT_CONFIG.assistant };
1301
+ const emit = (s) => chunkEmitter.emit('chunk', s + '\n');
1302
+ const writeModeNow = getEffectiveWriteMode(cfg.write_mode, cfg.sap[selectedAlias]?.role);
1303
+ await handleLevelCommand(trimmed.slice('/level'.length), cfg, saveConfig, emit, pickLevelInk, writeModeNow);
1304
+ return;
1305
+ }
1321
1306
  // /reroute — re-dispatch session.lastUserPrompt to a different skill
1322
1307
  if (trimmed === '/reroute' || trimmed.startsWith('/reroute ')) {
1323
1308
  session.awaitingSkillAnswer = false;
@@ -1385,7 +1370,19 @@ export async function runRepl(opts) {
1385
1370
  todoEmitter.emit('update', []);
1386
1371
  clearAppFlow(session); // Task 7 — /new ends an /abap-app flow
1387
1372
  clearJourneyFlow(session); // Task 3 — and a clean-core journey
1388
- chunkEmitter.emit('chunk', '\n' + formatInfo('Fresh start · the next prompt picks a skill again'));
1373
+ chunkEmitter.emit('chunk', '\n' + (laneSetting === 'chat' ? formatInfo('Fresh start.') : formatInfo('Fresh start · the next prompt picks a skill again')));
1374
+ return;
1375
+ }
1376
+ // /chat [on|off] (spec 2026-10-17 D6, owner amendment) — leave a workflow,
1377
+ // or switch the lane now and save it; the next message reads the new lane.
1378
+ if (trimmed === '/chat' || trimmed.startsWith('/chat ')) {
1379
+ if (!cfg.assistant)
1380
+ cfg.assistant = { ...DEFAULT_CONFIG.assistant };
1381
+ laneSetting = await handleChatCommand(trimmed.slice('/chat'.length), {
1382
+ lane: laneSetting, cfg, save: saveConfig, session, interactive: !!process.stdin.isTTY,
1383
+ emit: (s) => chunkEmitter.emit('chunk', '\n' + s + '\n'),
1384
+ });
1385
+ sapStateStore.update({ chatLane: laneSetting === 'chat' });
1389
1386
  return;
1390
1387
  }
1391
1388
  // /transport — session current-transport state for Rule 9 (transport isolation)
@@ -1520,7 +1517,22 @@ export async function runRepl(opts) {
1520
1517
  const previousObject = session.last_object ?? null;
1521
1518
  const shortcutSkill = parsed.skill;
1522
1519
  let skill = shortcutSkill;
1523
- if (!skill) {
1520
+ // Chat lane (D1/D6/D-G) — decided here, once, before today's routing
1521
+ // block. Skills lane ⇒ always `explicit` or `route` (block unchanged).
1522
+ const laneDecision = decideLane({
1523
+ laneSetting, interactive: !!process.stdin.isTTY, explicitSkill: shortcutSkill, trimmed,
1524
+ sessionSkill: session.skill, awaitingSkillAnswer: session.awaitingSkillAnswer, resumedSkill,
1525
+ });
1526
+ if (laneDecision.kind === 'chat') {
1527
+ // M1 — just talk: no router, no routing line, no picker.
1528
+ skill = CHAT_SKILL;
1529
+ }
1530
+ else if (laneDecision.kind === 'continue') {
1531
+ // D6 — the workflow keeps the turn; the one existing line says so.
1532
+ chunkEmitter.emit('chunk', formatRoutingLine({ kind: 'continuing', skill: laneDecision.skill }));
1533
+ skill = laneDecision.skill;
1534
+ }
1535
+ else if (!skill) {
1524
1536
  // v0.3.1 — if the last skill turn ended with a question, route the
1525
1537
  // user's reply back to the same skill instead of reclassifying. This
1526
1538
  // stops short answers like "1" or "S/4 on-prem" from being treated
@@ -1541,9 +1553,10 @@ export async function runRepl(opts) {
1541
1553
  // Round 6 item 4c / review I2 — after a resume, a short follow-up
1542
1554
  // ("continue", a number) stays in the session's skill; anything else
1543
1555
  // is classified (applyResumedSkill below).
1544
- if ((session.awaitingSkillAnswer || (resumedSkill !== null && isShortFollowUp(trimmed)) || isAckContinuation(trimmed, session.skill)) && session.skill) {
1545
- chunkEmitter.emit('chunk', formatRoutingLine({ kind: 'continuing', skill: session.skill }));
1546
- skill = session.skill;
1556
+ const continuedSkill = wantsContinuation({ trimmed, sessionSkill: session.skill, awaitingSkillAnswer: session.awaitingSkillAnswer, resumedSkill });
1557
+ if (continuedSkill) {
1558
+ chunkEmitter.emit('chunk', formatRoutingLine({ kind: 'continuing', skill: continuedSkill }));
1559
+ skill = continuedSkill;
1547
1560
  }
1548
1561
  else if (isBareAck(trimmed)) {
1549
1562
  // Bare "y" / "n" / "cancel" with no skill to continue — typing this
@@ -1969,7 +1982,7 @@ export async function runRepl(opts) {
1969
1982
  // loop.ts) so the try/finally GUARANTEES the row clears even when the
1970
1983
  // turn throws; loop.ts only narrates activity changes in between.
1971
1984
  // (turnStatusEmitter is the module-level singleton import — BUG-2b.)
1972
- turnStatusEmitter.start(`/${skill}`);
1985
+ turnStatusEmitter.start(turnStatusLabel(skill));
1973
1986
  try {
1974
1987
  // Task 6 (review M-2) — guarded chain start: announce and disable
1975
1988
  // auto_approve for this phase's turn (request_approval consults the
@@ -2081,20 +2094,33 @@ export async function runRepl(opts) {
2081
2094
  });
2082
2095
  }
2083
2096
  catch (err) {
2084
- // 2026-09-24 — print the turn-error block INTO the conversation (it
2085
- // stays on screen) instead of the overlay box that vanished after 5 s
2086
- // — a customer's dropped turn left no trace of what happened or what
2087
- // to do. Same block classic mode prints; in-session it leads with
2088
- // "type continue" (the partial turn is already saved).
2089
- const { renderTurnError } = await import('./agent/turn-error-ux.js');
2090
- turnCancelledByUser = currentTurnAbort?.signal.aborted ?? false;
2091
- await renderTurnError({
2092
- err,
2093
- session,
2094
- userInterrupted: turnCancelledByUser,
2095
- inSession: true,
2096
- print: (line) => chunkEmitter.emit('chunk', line + '\n'),
2097
- });
2097
+ if (err instanceof ChatUnavailableError) {
2098
+ // D11 — this server has no chat lane (404/403 for _chat; the loop
2099
+ // already withdrew the turn). Switch this PROCESS to skill routing
2100
+ // (config is not saved) and re-run the same message once through
2101
+ // today's router; the queued step shows the one notice line in
2102
+ // place of a second echo. The re-run decides `route`, so it can
2103
+ // never throw ChatUnavailableError again.
2104
+ laneSetting = 'skills';
2105
+ sapStateStore.update({ chatLane: false });
2106
+ pendingDispatchAccessor.set(trimmed, chatFallbackOrigin());
2107
+ }
2108
+ else {
2109
+ // 2026-09-24 — print the turn-error block INTO the conversation (it
2110
+ // stays on screen) instead of the overlay box that vanished after 5 s
2111
+ // — a customer's dropped turn left no trace of what happened or what
2112
+ // to do. Same block classic mode prints; in-session it leads with
2113
+ // "type continue" (the partial turn is already saved).
2114
+ const { renderTurnError } = await import('./agent/turn-error-ux.js');
2115
+ turnCancelledByUser = currentTurnAbort?.signal.aborted ?? false;
2116
+ await renderTurnError({
2117
+ err,
2118
+ session,
2119
+ userInterrupted: turnCancelledByUser,
2120
+ inSession: true,
2121
+ print: (line) => chunkEmitter.emit('chunk', line + '\n'),
2122
+ });
2123
+ }
2098
2124
  }
2099
2125
  finally {
2100
2126
  turnStatusEmitter.stop();
@@ -3134,7 +3160,7 @@ export async function runRepl(opts) {
3134
3160
  }
3135
3161
  // /help and /skills show the skill catalog flat-list.
3136
3162
  if (trimmed === '/help' || trimmed === '/skills') {
3137
- printHelp();
3163
+ printHelp(undefined, { lane: laneSetting });
3138
3164
  continue;
3139
3165
  }
3140
3166
  // /usage (2026-10-09) — classic mirror of the Ink handler above.
@@ -3156,8 +3182,14 @@ export async function runRepl(opts) {
3156
3182
  if (trimmed === '/export' || trimmed.startsWith('/export ')) {
3157
3183
  const { parseExportArgs, runExportAuditCommand } = await import('./commands/export-audit.js');
3158
3184
  const { sub, filename } = parseExportArgs(trimmed.slice('/export'.length));
3185
+ // Word export (2026-10-19): /export docx <file>.md, same body as `cspeach export`.
3186
+ if (sub === 'docx') {
3187
+ const { docxFileFromReplArgs, runExportDocxCommand } = await import('./commands/export-docx.js');
3188
+ await runExportDocxCommand({ file: docxFileFromReplArgs(trimmed.slice('/export'.length)), docx: true, system: session.sap_system, emit: (l) => console.log(l) });
3189
+ continue;
3190
+ }
3159
3191
  if (sub !== 'audit') {
3160
- console.log(chalk.dim('Usage: /export audit [filename]'));
3192
+ console.log(chalk.dim('Usage: /export audit [filename] · /export docx <file>.md'));
3161
3193
  continue;
3162
3194
  }
3163
3195
  await runExportAuditCommand({
@@ -3291,6 +3323,21 @@ export async function runRepl(opts) {
3291
3323
  await handleUiRendering(arg, cfg, saveConfig, (s) => console.log(s), shouldUseInk() ? 'ink' : 'classic');
3292
3324
  continue;
3293
3325
  }
3326
+ // /style [value] — talking style (spec 2026-10-17 D9); saved like /ui look.
3327
+ if (trimmed === '/style' || trimmed.startsWith('/style ')) {
3328
+ if (!cfg.assistant)
3329
+ cfg.assistant = { ...DEFAULT_CONFIG.assistant };
3330
+ await handleStyleCommand(trimmed.slice('/style'.length), cfg, saveConfig, (s) => console.log(s), pickStyleClassic);
3331
+ continue;
3332
+ }
3333
+ // /level [value] — who CSPeach is talking to (spec 2026-10-17 §10 D13); saved like /style.
3334
+ if (trimmed === '/level' || trimmed.startsWith('/level ')) {
3335
+ if (!cfg.assistant)
3336
+ cfg.assistant = { ...DEFAULT_CONFIG.assistant };
3337
+ const writeModeNow = getEffectiveWriteMode(cfg.write_mode, cfg.sap[selectedAlias]?.role);
3338
+ await handleLevelCommand(trimmed.slice('/level'.length), cfg, saveConfig, (s) => console.log(s), pickLevelClassic, writeModeNow);
3339
+ continue;
3340
+ }
3294
3341
  // /reroute — re-dispatch session.lastUserPrompt to a different skill
3295
3342
  if (trimmed === '/reroute' || trimmed.startsWith('/reroute ')) {
3296
3343
  session.awaitingSkillAnswer = false;
@@ -3339,7 +3386,18 @@ export async function runRepl(opts) {
3339
3386
  session.todos = []; // P2 — /new clears the plan (spec §4)
3340
3387
  clearAppFlow(session); // Task 7 — /new ends an /abap-app flow
3341
3388
  clearJourneyFlow(session); // Task 3 — and a clean-core journey
3342
- console.log(formatInfo('Fresh start · the next prompt picks a skill again').trimEnd());
3389
+ console.log((laneSetting === 'chat' ? formatInfo('Fresh start.') : formatInfo('Fresh start · the next prompt picks a skill again')).trimEnd());
3390
+ continue;
3391
+ }
3392
+ // /chat [on|off] — classic mirror of the Ink handler above.
3393
+ if (trimmed === '/chat' || trimmed.startsWith('/chat ')) {
3394
+ if (!cfg.assistant)
3395
+ cfg.assistant = { ...DEFAULT_CONFIG.assistant };
3396
+ laneSetting = await handleChatCommand(trimmed.slice('/chat'.length), {
3397
+ lane: laneSetting, cfg, save: saveConfig, session, interactive: !!process.stdin.isTTY,
3398
+ emit: (s) => console.log(s),
3399
+ });
3400
+ sapStateStore.update({ chatLane: laneSetting === 'chat' });
3343
3401
  continue;
3344
3402
  }
3345
3403
  // /transport — session current-transport state for Rule 9 (transport isolation)
@@ -3583,7 +3641,20 @@ export async function runRepl(opts) {
3583
3641
  const previousSkill = session.skill; // snapshot before this turn dispatches
3584
3642
  const previousObject = session.last_object ?? null; // carries from two turns ago
3585
3643
  let skill = parsed.skill;
3586
- if (!skill) {
3644
+ // Chat lane (D1/D6/D-G) — classic mirror of the Ink decision. Piped stdin
3645
+ // is a scripting surface and keeps today's router (interactive: false).
3646
+ const laneDecision = decideLane({
3647
+ laneSetting, interactive: !pipedStdin, explicitSkill: parsed.skill, trimmed,
3648
+ sessionSkill: session.skill, awaitingSkillAnswer: session.awaitingSkillAnswer, resumedSkill,
3649
+ });
3650
+ if (laneDecision.kind === 'chat') {
3651
+ skill = CHAT_SKILL;
3652
+ }
3653
+ else if (laneDecision.kind === 'continue') {
3654
+ console.log(formatRoutingLine({ kind: 'continuing', skill: laneDecision.skill }).trimEnd());
3655
+ skill = laneDecision.skill;
3656
+ }
3657
+ else if (!skill) {
3587
3658
  // v0.3.1 — if the last skill turn ended with a question, route the
3588
3659
  // user's reply back to the same skill instead of reclassifying. This
3589
3660
  // stops short answers like "1" or "S/4 on-prem" from being treated
@@ -3598,9 +3669,10 @@ export async function runRepl(opts) {
3598
3669
  continue;
3599
3670
  }
3600
3671
  // Round 6 item 4c / review I2 — see the Ink path.
3601
- if ((session.awaitingSkillAnswer || (resumedSkill !== null && isShortFollowUp(trimmed)) || isAckContinuation(trimmed, session.skill)) && session.skill) {
3602
- console.log(formatRoutingLine({ kind: 'continuing', skill: session.skill }).trimEnd());
3603
- skill = session.skill;
3672
+ const continuedSkill = wantsContinuation({ trimmed, sessionSkill: session.skill, awaitingSkillAnswer: session.awaitingSkillAnswer, resumedSkill });
3673
+ if (continuedSkill) {
3674
+ console.log(formatRoutingLine({ kind: 'continuing', skill: continuedSkill }).trimEnd());
3675
+ skill = continuedSkill;
3604
3676
  }
3605
3677
  else if (isBareAck(trimmed)) {
3606
3678
  // Bare "y" / "n" / "cancel" with no skill to continue — almost always
@@ -4093,18 +4165,27 @@ export async function runRepl(opts) {
4093
4165
  });
4094
4166
  }
4095
4167
  catch (err) {
4096
- // v0.6 — graceful turn-error UX. The loop's try/catch already
4097
- // persisted any partial assistant content to session.messages and
4098
- // wrote the streamed prose to ~/.cspeach/streams/. Here we only
4099
- // surface a clean 4–6 line diagnostic + write a full log so the
4100
- // user can resume cleanly. See agent/turn-error-ux.ts for details.
4101
- await renderTurnError({
4102
- err,
4103
- session,
4104
- userInterrupted: interruptedByUser,
4105
- inSession: true,
4106
- print: (line) => console.error(line),
4107
- });
4168
+ if (err instanceof ChatUnavailableError) {
4169
+ // D11 — classic mirror of the Ink catch: process lane only, no save;
4170
+ // the loop-top slot consumer re-runs the message once.
4171
+ laneSetting = 'skills';
4172
+ sapStateStore.update({ chatLane: false });
4173
+ pendingDispatchAccessor.set(trimmed, chatFallbackOrigin());
4174
+ }
4175
+ else {
4176
+ // v0.6 — graceful turn-error UX. The loop's try/catch already
4177
+ // persisted any partial assistant content to session.messages and
4178
+ // wrote the streamed prose to ~/.cspeach/streams/. Here we only
4179
+ // surface a clean 4–6 line diagnostic + write a full log so the
4180
+ // user can resume cleanly. See agent/turn-error-ux.ts for details.
4181
+ await renderTurnError({
4182
+ err,
4183
+ session,
4184
+ userInterrupted: interruptedByUser,
4185
+ inSession: true,
4186
+ print: (line) => console.error(line),
4187
+ });
4188
+ }
4108
4189
  }
4109
4190
  finally {
4110
4191
  rl.removeListener('SIGINT', onSigint);
@@ -13,6 +13,7 @@
13
13
  // the exact command was written by the model, so it shows as "Auto-routed: …",
14
14
  // never as typed. An untagged command keeps the old echo.
15
15
  import { shownUpTo } from '../register/atc-file.js';
16
+ import { paint } from '../renderer/theme.js';
16
17
  export function createDispatchSlot() {
17
18
  let cmd = null;
18
19
  let origin = null;
@@ -41,5 +42,8 @@ export function queuedStepView(origin) {
41
42
  if (!origin)
42
43
  return { echo: true };
43
44
  const line = origin.line ? shownUpTo(origin.line, 160).trim() : '';
44
- return { echo: false, line: line || null };
45
+ if (!line)
46
+ return { echo: false, line: null };
47
+ // D11 — the chat-fallback line is dim; painted after shownUpTo flattened it.
48
+ return { echo: false, line: origin.by === 'chat_fallback' ? paint('dim', line) : line };
45
49
  }
@@ -19,5 +19,6 @@ export function newSession(id, sapSystem, skill, model, project) {
19
19
  lastUserPrompt: null,
20
20
  awaitingSkillAnswer: false,
21
21
  agentMode: 'skill',
22
+ lane: 'skill',
22
23
  };
23
24
  }
@@ -72,6 +72,10 @@ function validateSchema(parsed) {
72
72
  // Phase 4 chunk 4A: agentMode defaults to 'skill' for sessions written
73
73
  // before this chunk landed. Mirrors the lastUserPrompt back-compat above.
74
74
  parsed.agentMode ??= 'skill';
75
+ // Chat lane (2026-10-17 D7): older files have no lane — they ran skill turns.
76
+ // Anything but 'chat' (missing or hand-edited) loads as 'skill', the safe default.
77
+ if (parsed.lane !== 'chat')
78
+ parsed.lane = 'skill';
75
79
  // 2026-10-08: sessions are listed per folder. Older files have no folder.
76
80
  parsed.project_dir ??= null;
77
81
  parsed.project_id ??= null;
@@ -0,0 +1,86 @@
1
+ /**
2
+ * hand_off (chat lane, spec 2026-10-17 D5) — the chat's one way into a guided
3
+ * workflow. Queues `/<skill> <request>` through the existing pendingDispatch
4
+ * slot (shown as one line "→ /abap-x", never as the person's own line); the
5
+ * chat turn then ends and the workflow runs with its own plan + approvals.
6
+ * No extra consent card (D5): the workflow asks before any write.
7
+ * Refused outside the chat lane; dispatch_skill stays the skills' own switch.
8
+ *
9
+ * Flag-gated: only a chat-lane process enables it (Task 11), so a skills-lane
10
+ * session never lists it and its tools array stays byte-identical to today.
11
+ */
12
+ import { registerTool } from './index.js';
13
+ import { isPlanResumeCommand } from '../commands/plan-resume.js';
14
+ import { SKILL_CATALOG, resolveSkillAlias } from '../skill-catalog.js';
15
+ /** Longest request hand_off queues (after whitespace is collapsed). */
16
+ export const HAND_OFF_REQUEST_MAX = 500;
17
+ export const HAND_OFF_LINE = (skill) => `→ /${skill}`;
18
+ export const buildHandOffCommand = (skill, request) => `/${skill} ${request}`;
19
+ const CATALOG_NAMES = new Set(SKILL_CATALOG.map((s) => s.name));
20
+ /**
21
+ * A workflow name as the model wrote it → its catalog name, or null.
22
+ * Strips one leading `/`, lowercases, resolves a retired alias, and accepts a
23
+ * bare name ("refactor" → "abap-refactor"). Never `_chat` (not in the catalog).
24
+ */
25
+ export function normaliseSkill(raw) {
26
+ if (typeof raw !== 'string')
27
+ return null;
28
+ const bare = raw.trim().replace(/^\//, '').toLowerCase();
29
+ if (!bare)
30
+ return null;
31
+ for (const candidate of [bare, `abap-${bare}`]) {
32
+ const resolved = resolveSkillAlias(candidate);
33
+ if (CATALOG_NAMES.has(resolved))
34
+ return resolved;
35
+ }
36
+ return null;
37
+ }
38
+ registerTool({
39
+ name: 'hand_off',
40
+ description: 'Hand this conversation to a CSPeach guided workflow when the developer wants a change made in SAP or a complete deliverable (a full review, a handover document, an upgrade scan). The workflow shows its plan and asks before it writes, so do not ask "shall I hand off?" first — say in one line what you would change, then call this. skill: the workflow name (e.g. "abap-refactor", "abap-generate", "abap-transport", "abap-review"). request: what to do, one clear sentence with object names. After this returns, end the turn.',
41
+ isMutating: false,
42
+ flagGated: true,
43
+ category: 'session',
44
+ input_schema: {
45
+ type: 'object',
46
+ properties: {
47
+ skill: { type: 'string', description: 'Workflow name without the slash, e.g. "abap-refactor".' },
48
+ request: { type: 'string', description: 'What the workflow should do, with object names. One sentence, at most 500 characters, no flags.' },
49
+ },
50
+ required: ['skill', 'request'],
51
+ },
52
+ handler: async (args, ctx) => {
53
+ const bad = (error, reason) => ({ is_error: true, content: JSON.stringify({ error, reason }) });
54
+ if (ctx.session?.lane !== 'chat') {
55
+ return bad('HAND_OFF_OUTSIDE_CHAT', 'hand_off is only for the chat; in a workflow, continue with the workflow (dispatch_skill switches workflows after the person agreed).');
56
+ }
57
+ const skill = normaliseSkill(args.skill);
58
+ if (!skill) {
59
+ return bad('HAND_OFF_UNKNOWN_SKILL', `unknown workflow "${String(args.skill ?? '')}". Use one of: ${SKILL_CATALOG.map((s) => s.name).join(', ')}`);
60
+ }
61
+ const request = typeof args.request === 'string' ? args.request.trim().replace(/\s+/g, ' ') : '';
62
+ if (!request)
63
+ return bad('HAND_OFF_INVALID', 'request must say what the workflow should do');
64
+ if (request.length > HAND_OFF_REQUEST_MAX) {
65
+ return bad('HAND_OFF_INVALID', `request is too long (${request.length} chars; at most ${HAND_OFF_REQUEST_MAX}). Say it in one short sentence with the object names.`);
66
+ }
67
+ const command = buildHandOffCommand(skill, request);
68
+ if (isPlanResumeCommand(command)) {
69
+ return bad('PLAN_RESUME_HARNESS_OWNED', 'Plan continuation is owned by CSPeach; never hand off "/abap-plan --resume".');
70
+ }
71
+ if (request.startsWith('--')) {
72
+ return bad('HAND_OFF_INVALID', 'request must start with what to do, not with flags (--…). Say it in one plain sentence with the object names.');
73
+ }
74
+ if (!ctx.pendingDispatch) {
75
+ return bad('DISPATCH_UNSUPPORTED', 'no dispatch slot in this context (one-shot or test); tell the developer which /abap-… command to run instead');
76
+ }
77
+ ctx.pendingDispatch.set(command, { by: 'hand_off', line: HAND_OFF_LINE(skill) });
78
+ return {
79
+ content: JSON.stringify({
80
+ ok: true,
81
+ queued: command,
82
+ note: 'The workflow runs next and asks before it writes. End the turn now with at most one short line; do not print the command.',
83
+ }),
84
+ };
85
+ },
86
+ });
@@ -86,6 +86,9 @@ export const SKILL_STARTERS = Object.freeze({
86
86
  // Selection texts (2026-10-04): a new PARAMETERS / SELECT-OPTIONS gets its text in the same change.
87
87
  'abap-refactor': ['sap_get_text_elements', 'sap_set_text_elements'],
88
88
  'abap-generate': ['sap_get_text_elements', 'sap_set_text_elements', 'sap_run'],
89
+ // Chat lane (D5): the hand-off tool is loaded on chat turns; flag-gated, so a skills-lane session never lists it.
90
+ // `_chat` is the one SKILL_STARTERS key that is not a catalog skill (pinned in tool-loading.test.ts).
91
+ '_chat': ['hand_off'],
89
92
  });
90
93
  /** The starter tools for a skill; unknown skill (or none) ⇒ []. */
91
94
  export function startersForSkill(skill) {
@@ -20,6 +20,7 @@ const DEFAULT_STATE = {
20
20
  creditsMode: false, creditsSessionTotal: 0, creditsBalance: null,
21
21
  creditsUnitLabel: 'credits', creditsPending: false,
22
22
  standalone: false,
23
+ chatLane: false,
23
24
  };
24
25
  /**
25
26
  * Rate limiter: calls `fn` at most once every `minIntervalMs`. Trailing-only:
@@ -178,6 +178,7 @@ export function toCardQuestions(request) {
178
178
  options: freeText ? [] : request.choices,
179
179
  otherRow: false,
180
180
  defaultValue: request.defaultValue,
181
+ cursorValue: request.cursorValue,
181
182
  keys: request.keys,
182
183
  }];
183
184
  }
@@ -323,7 +324,7 @@ export function AskCard({ request, width, showDescriptions, maxRows, onResolveV1
323
324
  // Focus slots: 0..n-1 = questions; n = the Review step (2+ questions,
324
325
  // reachable only once every question has an answer).
325
326
  const [focus, setFocus] = useState(0);
326
- const [cursors, setCursors] = useState(() => questions.map((qq) => Math.max(0, qq.options.findIndex((o) => o.value === qq.defaultValue))));
327
+ const [cursors, setCursors] = useState(() => questions.map((qq) => Math.max(0, qq.options.findIndex((o) => o.value === (qq.defaultValue ?? qq.cursorValue)))));
327
328
  const [ticks, setTicks] = useState(() => questions.map(() => new Set()));
328
329
  const [drafts, setDrafts] = useState(() => questions.map(() => ''));
329
330
  const [answers, setAnswers] = useState(() => questions.map(() => null));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cspeach/cli",
3
- "version": "1.1.45",
3
+ "version": "1.1.47",
4
4
  "description": "CSPeach — AI-assisted ABAP development CLI. Direct SAP access, signed skills, safety gates.",
5
5
  "keywords": [
6
6
  "abap",
@@ -74,6 +74,7 @@
74
74
  "clipboardy": "^5.3.1",
75
75
  "cross-spawn": "^7.0.6",
76
76
  "diff": "^9.0.0",
77
+ "docx": "^9.0.2",
77
78
  "ejs": "3.1.10",
78
79
  "fuse.js": "^7.0.0",
79
80
  "ink": "^5.0.0",
@@ -104,7 +105,6 @@
104
105
  "@types/turndown": "^5.0.5",
105
106
  "@ui5/builder": "4.3.2",
106
107
  "@ui5/fs": "4.0.6",
107
- "docx": "^9.0.2",
108
108
  "husky": "^9.1.7",
109
109
  "ink-testing-library": "^4.0.0",
110
110
  "pdf-lib": "^1.17.1",