@cspeach/cli 1.1.44 → 1.1.46

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 (60) 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/parallel-write-guard.js +2 -0
  7. package/dist/agent/provider-shape.js +6 -2
  8. package/dist/agent/providers/ai-hub-provider.js +3 -1
  9. package/dist/agent/providers/azure-provider.js +3 -1
  10. package/dist/agent/providers/byok-provider.js +3 -1
  11. package/dist/agent/providers/local-provider.js +3 -1
  12. package/dist/agent/style-state.js +11 -0
  13. package/dist/agent/style.js +59 -0
  14. package/dist/agent/system-prompt.js +24 -3
  15. package/dist/agent/tool-dispatch.js +55 -0
  16. package/dist/approvals/jwt.js +5 -2
  17. package/dist/approvals/op-labels.js +39 -4
  18. package/dist/approvals/render.js +36 -9
  19. package/dist/approvals/risk-floor.js +14 -3
  20. package/dist/cli.js +8 -0
  21. package/dist/commands/config-set.js +59 -1
  22. package/dist/commands/config-show.js +1 -0
  23. package/dist/commands/export-docx.js +66 -0
  24. package/dist/commands/help.js +28 -2
  25. package/dist/config/loader.js +51 -1
  26. package/dist/docx/doc-meta.js +47 -0
  27. package/dist/docx/markdown-to-docx.js +250 -0
  28. package/dist/docx/write-docx.js +54 -0
  29. package/dist/one-shot.js +11 -0
  30. package/dist/projects/document-save.js +62 -0
  31. package/dist/projects/document-skills.js +9 -0
  32. package/dist/renderer/footer-line.js +6 -1
  33. package/dist/renderer/progress-chatter.js +9 -2
  34. package/dist/renderer/startup-lines.js +6 -2
  35. package/dist/renderer/tool-labels.js +5 -2
  36. package/dist/renderer/tool-widget.js +5 -1
  37. package/dist/repl/builtin-commands.js +4 -1
  38. package/dist/repl/chat-command.js +64 -0
  39. package/dist/repl/chat-lane.js +135 -0
  40. package/dist/repl/level-command.js +79 -0
  41. package/dist/repl/style-command.js +67 -0
  42. package/dist/repl.js +187 -105
  43. package/dist/sap/profile-features.js +5 -0
  44. package/dist/session/audit-export.js +50 -2
  45. package/dist/session/queued-step.js +5 -1
  46. package/dist/session/schema.js +1 -0
  47. package/dist/session/store.js +4 -0
  48. package/dist/tools/approval.js +59 -1
  49. package/dist/tools/hand-off.js +86 -0
  50. package/dist/tools/release-texts.js +9 -0
  51. package/dist/tools/sap-read.js +6 -2
  52. package/dist/tools/sap-run.js +163 -0
  53. package/dist/tools/tool-loading.js +11 -7
  54. package/dist/tools/transport.js +248 -22
  55. package/dist/tools/write-mode.js +2 -0
  56. package/dist/ui/approval-modal.js +11 -3
  57. package/dist/ui/sap-state-store.js +1 -0
  58. package/dist/ui/session-timeline.js +1 -1
  59. package/dist/ui/widgets/ask-card.js +2 -1
  60. package/package.json +3 -3
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';
@@ -152,10 +160,12 @@ import { applyLocalBuildConfig, applyStandaloneFileTools } from './tools/local-b
152
160
  import './tools/sap-read.js';
153
161
  import './tools/sap-write.js';
154
162
  import './tools/transport.js';
163
+ import './tools/sap-run.js';
155
164
  import './tools/approval.js';
156
165
  import './tools/snapshot.js';
157
166
  import './tools/ask-question.js';
158
167
  import './tools/dispatch-skill.js';
168
+ import './tools/hand-off.js';
159
169
  import './tools/todo.js';
160
170
  import './tools/filesystem/file-read.js';
161
171
  import './tools/tool-output-read.js';
@@ -210,68 +220,8 @@ function loadHistory() {
210
220
  // and were unreachable.
211
221
  .reverse());
212
222
  }
213
- /** Short user inputs that almost always mean "continue what we were doing"
214
- * rather than "start a new task." When the previous turn left a skill
215
- * in session.skill, an ack-only follow-up should route there instead of
216
- * re-classifying as a fresh prompt (which produces nonsense like "yes"
217
- * → /abap-explain at 35% confidence). */
218
- const ACK_PHRASES = new Set([
219
- 'yes', 'y', 'yeah', 'yep', 'yup', 'sure',
220
- 'ok', 'okay', 'k', 'kk',
221
- 'proceed', 'continue', 'go', 'go ahead', 'do it', 'do that', 'go on',
222
- 'no', 'n', 'nope', 'nah',
223
- 'stop', 'cancel', 'abort', 'never mind', 'nm',
224
- 'approve', 'approved', 'accept', 'accepted',
225
- 'decline', 'denied', 'reject', 'rejected',
226
- 'skip', 'next',
227
- ]);
228
- /** Conversational acknowledgements that mean "I'm done with the previous
229
- * turn, no further work needed." Distinct from ACK_PHRASES (which
230
- * largely mean "yes/no to a question"). For these we don't classify,
231
- * don't call any model — just print a friendly ack and return. The user
232
- * isn't asking us to do more work; they're closing the prior thread.
233
- *
234
- * 2026-05-17 — Laeeq hit "thank you" being classified and run through
235
- * /abap-explain, producing another huge code explanation. THIS is the
236
- * fix for that. */
237
- const CONVERSATIONAL_ACKS = new Set([
238
- 'thanks', 'thank you', 'thank u', 'thx', 'tx', 'ty', 'ta',
239
- 'cool', 'nice', 'great', 'awesome', 'perfect',
240
- 'got it', 'gotit', 'understood',
241
- 'cheers',
242
- // Common ackowledgements that follow a long answer.
243
- 'makes sense',
244
- ]);
245
- /**
246
- * True when `trimmed` is one of CONVERSATIONAL_ACKS. We treat these as
247
- * "user closes the previous thread, no further work" — print a friendly
248
- * acknowledgement and return without classifying or calling any model.
249
- */
250
- function isConversationalAck(trimmed) {
251
- const normalized = trimmed.toLowerCase().trim().replace(/[.!?,]+$/, '');
252
- return CONVERSATIONAL_ACKS.has(normalized);
253
- }
254
- /**
255
- * Returns true when `trimmed` is a short acknowledgement-style follow-up
256
- * AND there is a previous skill to continue. Treats both bare acks and
257
- * short ack-prefixed phrases ("yes please", "no thanks") the same way.
258
- * Strips trailing punctuation so "yes." and "yes!" still register.
259
- */
260
- function isAckContinuation(trimmed, sessionSkill) {
261
- if (!sessionSkill)
262
- return false;
263
- const normalized = trimmed.toLowerCase().trim().replace(/[.!?,]+$/, '');
264
- if (ACK_PHRASES.has(normalized))
265
- return true;
266
- // "yes please", "go ahead and do it", "no thanks" — first word is an ack
267
- // and the whole phrase is short enough to be a follow-up not a new task.
268
- if (normalized.length <= 30) {
269
- const firstWord = normalized.split(/\s+/)[0] ?? '';
270
- if (ACK_PHRASES.has(firstWord))
271
- return true;
272
- }
273
- return false;
274
- }
223
+ // CONVERSATIONAL_ACKS / isConversationalAck live in repl/chat-lane.ts (moved
224
+ // unchanged — the chat-lane decision reads the same set).
275
225
  /**
276
226
  * Returns true when `trimmed` is a bare ack ("y", "n", "ok", "cancel", ...)
277
227
  * with no surrounding context — regardless of whether a session.skill exists.
@@ -537,6 +487,15 @@ export async function runRepl(opts) {
537
487
  }
538
488
  let cfg = await loadConfig();
539
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);
540
499
  // Apply the rendering mode BEFORE any shouldUseInk() call. This is the
541
500
  // interactive entry point, so it is never one-shot whatever argv holds. A
542
501
  // stored `classic` is retiring: read as auto, rewritten once, one dim notice
@@ -819,6 +778,8 @@ export async function runRepl(opts) {
819
778
  user: standalone ? '' : cfg.sap[selectedAlias].username,
820
779
  writeMode: startupWriteMode,
821
780
  standalone,
781
+ // Chat lane is interactive-only (applyLaneTools); piped stdin never chats.
782
+ chatLane: laneSetting === 'chat' && !!process.stdin.isTTY,
822
783
  });
823
784
  // v0.6 — when resuming, adopt the on-disk SessionState verbatim. This
824
785
  // preserves messages[], usage, last_skill, awaitingSkillAnswer, and the
@@ -930,6 +891,7 @@ export async function runRepl(opts) {
930
891
  tools: localTools,
931
892
  writeMode: startupWriteMode,
932
893
  cwd: process.cwd(),
894
+ chat: laneSetting === 'chat' && !!process.stdin.isTTY,
933
895
  };
934
896
  if (opts?.resumedSession) {
935
897
  const turnCount = session.messages.filter((m) => m.role === 'user' && typeof m.content === 'string').length;
@@ -978,7 +940,7 @@ export async function runRepl(opts) {
978
940
  }
979
941
  }
980
942
  else if (getLook() === 'panels') {
981
- process.stdout.write(panelsStartTail({ status: panelsStatus, third: panelsHintLine(localTools), notices: startupNotices }));
943
+ process.stdout.write(panelsStartTail({ status: panelsStatus, third: panelsHintLine(localTools, laneSetting), notices: startupNotices }));
982
944
  }
983
945
  else {
984
946
  console.log(chalk.gray(`\nSession ${session.id} on ${selectedAlias}. Type /exit to quit.\n`));
@@ -1165,7 +1127,7 @@ export async function runRepl(opts) {
1165
1127
  if (trimmed === '/help' || trimmed === '/skills') {
1166
1128
  // Phase D5 — route through chunkEmitter so help lands in Body,
1167
1129
  // not above the Ink frame (which would scroll off + corrupt).
1168
- printHelp((line) => chunkEmitter.emit('chunk', line + '\n'));
1130
+ printHelp((line) => chunkEmitter.emit('chunk', line + '\n'), { lane: laneSetting });
1169
1131
  return;
1170
1132
  }
1171
1133
  // /usage (2026-10-09) — this month in credits, from the proxy's ledger.
@@ -1189,8 +1151,14 @@ export async function runRepl(opts) {
1189
1151
  if (trimmed === '/export' || trimmed.startsWith('/export ')) {
1190
1152
  const { parseExportArgs, runExportAuditCommand } = await import('./commands/export-audit.js');
1191
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
+ }
1192
1160
  if (sub !== 'audit') {
1193
- 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'));
1194
1162
  return;
1195
1163
  }
1196
1164
  await runExportAuditCommand({
@@ -1317,6 +1285,24 @@ export async function runRepl(opts) {
1317
1285
  await handleUiRendering(arg, cfg, saveConfig, emit, shouldUseInk() ? 'ink' : 'classic');
1318
1286
  return;
1319
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
+ }
1320
1306
  // /reroute — re-dispatch session.lastUserPrompt to a different skill
1321
1307
  if (trimmed === '/reroute' || trimmed.startsWith('/reroute ')) {
1322
1308
  session.awaitingSkillAnswer = false;
@@ -1384,7 +1370,19 @@ export async function runRepl(opts) {
1384
1370
  todoEmitter.emit('update', []);
1385
1371
  clearAppFlow(session); // Task 7 — /new ends an /abap-app flow
1386
1372
  clearJourneyFlow(session); // Task 3 — and a clean-core journey
1387
- 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' });
1388
1386
  return;
1389
1387
  }
1390
1388
  // /transport — session current-transport state for Rule 9 (transport isolation)
@@ -1519,7 +1517,22 @@ export async function runRepl(opts) {
1519
1517
  const previousObject = session.last_object ?? null;
1520
1518
  const shortcutSkill = parsed.skill;
1521
1519
  let skill = shortcutSkill;
1522
- 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) {
1523
1536
  // v0.3.1 — if the last skill turn ended with a question, route the
1524
1537
  // user's reply back to the same skill instead of reclassifying. This
1525
1538
  // stops short answers like "1" or "S/4 on-prem" from being treated
@@ -1540,9 +1553,10 @@ export async function runRepl(opts) {
1540
1553
  // Round 6 item 4c / review I2 — after a resume, a short follow-up
1541
1554
  // ("continue", a number) stays in the session's skill; anything else
1542
1555
  // is classified (applyResumedSkill below).
1543
- if ((session.awaitingSkillAnswer || (resumedSkill !== null && isShortFollowUp(trimmed)) || isAckContinuation(trimmed, session.skill)) && session.skill) {
1544
- chunkEmitter.emit('chunk', formatRoutingLine({ kind: 'continuing', skill: session.skill }));
1545
- 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;
1546
1560
  }
1547
1561
  else if (isBareAck(trimmed)) {
1548
1562
  // Bare "y" / "n" / "cancel" with no skill to continue — typing this
@@ -1968,7 +1982,7 @@ export async function runRepl(opts) {
1968
1982
  // loop.ts) so the try/finally GUARANTEES the row clears even when the
1969
1983
  // turn throws; loop.ts only narrates activity changes in between.
1970
1984
  // (turnStatusEmitter is the module-level singleton import — BUG-2b.)
1971
- turnStatusEmitter.start(`/${skill}`);
1985
+ turnStatusEmitter.start(turnStatusLabel(skill));
1972
1986
  try {
1973
1987
  // Task 6 (review M-2) — guarded chain start: announce and disable
1974
1988
  // auto_approve for this phase's turn (request_approval consults the
@@ -2080,20 +2094,33 @@ export async function runRepl(opts) {
2080
2094
  });
2081
2095
  }
2082
2096
  catch (err) {
2083
- // 2026-09-24 — print the turn-error block INTO the conversation (it
2084
- // stays on screen) instead of the overlay box that vanished after 5 s
2085
- // — a customer's dropped turn left no trace of what happened or what
2086
- // to do. Same block classic mode prints; in-session it leads with
2087
- // "type continue" (the partial turn is already saved).
2088
- const { renderTurnError } = await import('./agent/turn-error-ux.js');
2089
- turnCancelledByUser = currentTurnAbort?.signal.aborted ?? false;
2090
- await renderTurnError({
2091
- err,
2092
- session,
2093
- userInterrupted: turnCancelledByUser,
2094
- inSession: true,
2095
- print: (line) => chunkEmitter.emit('chunk', line + '\n'),
2096
- });
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
+ }
2097
2124
  }
2098
2125
  finally {
2099
2126
  turnStatusEmitter.stop();
@@ -3133,7 +3160,7 @@ export async function runRepl(opts) {
3133
3160
  }
3134
3161
  // /help and /skills show the skill catalog flat-list.
3135
3162
  if (trimmed === '/help' || trimmed === '/skills') {
3136
- printHelp();
3163
+ printHelp(undefined, { lane: laneSetting });
3137
3164
  continue;
3138
3165
  }
3139
3166
  // /usage (2026-10-09) — classic mirror of the Ink handler above.
@@ -3155,8 +3182,14 @@ export async function runRepl(opts) {
3155
3182
  if (trimmed === '/export' || trimmed.startsWith('/export ')) {
3156
3183
  const { parseExportArgs, runExportAuditCommand } = await import('./commands/export-audit.js');
3157
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
+ }
3158
3191
  if (sub !== 'audit') {
3159
- console.log(chalk.dim('Usage: /export audit [filename]'));
3192
+ console.log(chalk.dim('Usage: /export audit [filename] · /export docx <file>.md'));
3160
3193
  continue;
3161
3194
  }
3162
3195
  await runExportAuditCommand({
@@ -3290,6 +3323,21 @@ export async function runRepl(opts) {
3290
3323
  await handleUiRendering(arg, cfg, saveConfig, (s) => console.log(s), shouldUseInk() ? 'ink' : 'classic');
3291
3324
  continue;
3292
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
+ }
3293
3341
  // /reroute — re-dispatch session.lastUserPrompt to a different skill
3294
3342
  if (trimmed === '/reroute' || trimmed.startsWith('/reroute ')) {
3295
3343
  session.awaitingSkillAnswer = false;
@@ -3338,7 +3386,18 @@ export async function runRepl(opts) {
3338
3386
  session.todos = []; // P2 — /new clears the plan (spec §4)
3339
3387
  clearAppFlow(session); // Task 7 — /new ends an /abap-app flow
3340
3388
  clearJourneyFlow(session); // Task 3 — and a clean-core journey
3341
- 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' });
3342
3401
  continue;
3343
3402
  }
3344
3403
  // /transport — session current-transport state for Rule 9 (transport isolation)
@@ -3582,7 +3641,20 @@ export async function runRepl(opts) {
3582
3641
  const previousSkill = session.skill; // snapshot before this turn dispatches
3583
3642
  const previousObject = session.last_object ?? null; // carries from two turns ago
3584
3643
  let skill = parsed.skill;
3585
- 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) {
3586
3658
  // v0.3.1 — if the last skill turn ended with a question, route the
3587
3659
  // user's reply back to the same skill instead of reclassifying. This
3588
3660
  // stops short answers like "1" or "S/4 on-prem" from being treated
@@ -3597,9 +3669,10 @@ export async function runRepl(opts) {
3597
3669
  continue;
3598
3670
  }
3599
3671
  // Round 6 item 4c / review I2 — see the Ink path.
3600
- if ((session.awaitingSkillAnswer || (resumedSkill !== null && isShortFollowUp(trimmed)) || isAckContinuation(trimmed, session.skill)) && session.skill) {
3601
- console.log(formatRoutingLine({ kind: 'continuing', skill: session.skill }).trimEnd());
3602
- 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;
3603
3676
  }
3604
3677
  else if (isBareAck(trimmed)) {
3605
3678
  // Bare "y" / "n" / "cancel" with no skill to continue — almost always
@@ -4092,18 +4165,27 @@ export async function runRepl(opts) {
4092
4165
  });
4093
4166
  }
4094
4167
  catch (err) {
4095
- // v0.6 — graceful turn-error UX. The loop's try/catch already
4096
- // persisted any partial assistant content to session.messages and
4097
- // wrote the streamed prose to ~/.cspeach/streams/. Here we only
4098
- // surface a clean 4–6 line diagnostic + write a full log so the
4099
- // user can resume cleanly. See agent/turn-error-ux.ts for details.
4100
- await renderTurnError({
4101
- err,
4102
- session,
4103
- userInterrupted: interruptedByUser,
4104
- inSession: true,
4105
- print: (line) => console.error(line),
4106
- });
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
+ }
4107
4189
  }
4108
4190
  finally {
4109
4191
  rl.removeListener('SIGINT', onSigint);
@@ -35,6 +35,11 @@ export const FEATURES = Object.freeze({
35
35
  'fugr.modules': { label: 'Function modules and function-group includes', collection: `${A}/functions/groups`, provenOnOurBox: true },
36
36
  'prog.includes': { label: 'Program includes', collection: `${A}/programs/includes`, provenOnOurBox: true },
37
37
  'ddic.tabletypes': { label: 'Table types', collection: `${A}/ddic/tabletypes`, provenOnOurBox: true },
38
+ // Part 3 (D15): no `since` (no cited release). The collections and the reads are
39
+ // proven on our box; the release is not, and these rows are about the collection.
40
+ 'run.classrun': { label: 'Run a class (console)', collection: `${A}/oo/classrun`, provenOnOurBox: true },
41
+ 'run.programrun': { label: 'Run a program', collection: `${A}/programs/programrun`, provenOnOurBox: true },
42
+ 'cts.requests': { label: 'Transport requests (read, release)', collection: `${A}/cts/transportrequests`, provenOnOurBox: true },
38
43
  });
39
44
  export const FEATURE_IDS = Object.freeze(Object.keys(FEATURES));
40
45
  export function isFeatureId(s) {
@@ -62,6 +62,8 @@ const WRITE_TOOLS = new Set([
62
62
  // Final review I2 — SAP's RAP generator (tools/rap/generate-objects.ts) creates
63
63
  // the skeleton objects in one call; its row names them.
64
64
  'sap_generate_objects',
65
+ // Part 3 — a run executes code on the SAP system under an approval: it is audited.
66
+ 'sap_run',
65
67
  ]);
66
68
  /**
67
69
  * Non-mutating tools that are interaction/plumbing, not object reads — excluded
@@ -156,6 +158,29 @@ function approvalCell(rec) {
156
158
  return `declined ✗${risk}`;
157
159
  }
158
160
  // ── Object / snapshot extraction ──────────────────────────────────────────
161
+ /**
162
+ * The result as JSON, or its first line as JSON (a run's facts line), or the
163
+ * top-level fields a spilled preview still shows ("status": "stopped" comes
164
+ * before steps[] in the release envelope). {} when none of these read.
165
+ */
166
+ function parseResultHead(result) {
167
+ const text = result ?? '';
168
+ try {
169
+ return asRecord(JSON.parse(text));
170
+ }
171
+ catch { /* next */ }
172
+ try {
173
+ return asRecord(JSON.parse(text.split('\n', 1)[0] ?? ''));
174
+ }
175
+ catch { /* next */ }
176
+ const top = (k) => new RegExp(`^ {2}"${k}": "([^"]+)"`, 'm').exec(text)?.[1];
177
+ const status = top('status');
178
+ if (!status)
179
+ return {};
180
+ const at = top('stopped_at');
181
+ const verdict = top('stop_verdict');
182
+ return { status, ...(at ? { stopped_at: at } : {}), ...(verdict ? { stop_verdict: verdict } : {}) };
183
+ }
159
184
  function writeObject(tool, args, result) {
160
185
  switch (tool) {
161
186
  case 'sap_transport_create': {
@@ -180,8 +205,31 @@ function writeObject(tool, args, result) {
180
205
  // Last resort: name the operation, not the raw description.
181
206
  return 'transport';
182
207
  }
183
- case 'sap_transport_release':
184
- return str(args.transport) ?? '—';
208
+ case 'sap_transport_release': {
209
+ // Part 3 (D17): the row carries SAP's verdict; a stop names its step.
210
+ const tr = str(args.transport) ?? '—';
211
+ const r = parseResultHead(result);
212
+ const status = str(r.status);
213
+ if (!status)
214
+ return tr;
215
+ if (status === 'stopped') {
216
+ const last = Array.isArray(r.steps) && r.steps.length > 0 ? asRecord(r.steps[r.steps.length - 1]) : {};
217
+ const at = str(r.stopped_at) ?? str(last.number);
218
+ const verdict = str(r.stop_verdict) ?? str(last.verdict);
219
+ if (at && verdict)
220
+ return `${tr} · stopped at ${at}: ${verdict}`;
221
+ }
222
+ return `${tr} · ${status}`;
223
+ }
224
+ case 'sap_run': {
225
+ // Part 3: `CLAS ZCL_X · ran` or the error code (the facts line is line 1 of a ran result).
226
+ const r = parseResultHead(result);
227
+ const what = [str(args.type), str(args.name)].filter(Boolean).join(' ') || '—';
228
+ if (r.ran === true)
229
+ return `${what} · ran`;
230
+ const err = str(r.error);
231
+ return err ? `${what} · ${err}` : what;
232
+ }
185
233
  case 'sap_update_method': {
186
234
  const cls = str(args.class_name) ?? '—';
187
235
  const m = str(args.method_name);
@@ -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;