@cspeach/cli 1.1.51 → 1.1.53

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 (112) hide show
  1. package/dist/agent/chat-prompt.js +4 -1
  2. package/dist/agent/conversation-prompt.js +31 -13
  3. package/dist/agent/inline-skill-block.js +341 -0
  4. package/dist/agent/loop.js +304 -49
  5. package/dist/agent/maybe-build-project-context.js +11 -2
  6. package/dist/agent/provider-shape.js +8 -4
  7. package/dist/agent/providers/ai-hub-provider.js +2 -2
  8. package/dist/agent/providers/azure-provider.js +2 -2
  9. package/dist/agent/providers/byok-provider.js +2 -2
  10. package/dist/agent/providers/local-provider.js +2 -2
  11. package/dist/agent/repair-partial.js +21 -0
  12. package/dist/agent/skill-block-key.js +134 -0
  13. package/dist/agent/steering-queue.js +50 -14
  14. package/dist/agent/system-prompt.js +22 -0
  15. package/dist/agent/tool-dispatch.js +25 -9
  16. package/dist/agent/turn-watchdog.js +1 -1
  17. package/dist/approvals/advisory-render.js +6 -3
  18. package/dist/approvals/approval-prompt.js +9 -4
  19. package/dist/approvals/op-labels.js +20 -3
  20. package/dist/approvals/plan-grant.js +8 -0
  21. package/dist/approvals/render.js +22 -10
  22. package/dist/approvals/risk-floor.js +32 -4
  23. package/dist/approvals/visible-changes.js +50 -0
  24. package/dist/cli.js +10 -2
  25. package/dist/commands/compact.js +6 -1
  26. package/dist/commands/config-set.js +13 -0
  27. package/dist/commands/config-show.js +16 -4
  28. package/dist/commands/export-conversation.js +7 -4
  29. package/dist/commands/finish-check-guard.js +124 -0
  30. package/dist/commands/plan-chain.js +2 -2
  31. package/dist/commands/plan-reground.js +27 -4
  32. package/dist/commands/plan-resume.js +113 -3
  33. package/dist/commands/plan-startup.js +3 -3
  34. package/dist/config/loader.js +5 -1
  35. package/dist/cost/cost-log.js +6 -0
  36. package/dist/cost/system-block.js +6 -0
  37. package/dist/docx/doc-meta.js +1 -0
  38. package/dist/docx/markdown-to-docx.js +2 -0
  39. package/dist/lane/app-chain.js +13 -6
  40. package/dist/models/server-config.js +1 -1
  41. package/dist/one-shot.js +1 -0
  42. package/dist/project-context/conventions.js +8 -1
  43. package/dist/project-context/index-files.js +97 -34
  44. package/dist/project-context/index.js +1 -1
  45. package/dist/project-context/render.js +38 -4
  46. package/dist/projects/answer-blockers.js +107 -16
  47. package/dist/projects/blocker-answers-note.js +89 -0
  48. package/dist/projects/change-options.js +70 -0
  49. package/dist/projects/document-save.js +86 -0
  50. package/dist/projects/extract-design.js +4 -0
  51. package/dist/projects/extract-estimate.js +4 -0
  52. package/dist/projects/extract-spec-gap.js +8 -0
  53. package/dist/projects/index.js +1 -1
  54. package/dist/projects/plan-objects.js +24 -0
  55. package/dist/projects/plan-run.js +12 -2
  56. package/dist/projects/save-command.js +50 -18
  57. package/dist/projects/yes-no.js +5 -1
  58. package/dist/renderer/change-card.js +17 -1
  59. package/dist/renderer/html-entities.js +25 -0
  60. package/dist/renderer/machine-blocks.js +54 -7
  61. package/dist/renderer/steering-echo.js +53 -8
  62. package/dist/renderer/tool-labels.js +5 -1
  63. package/dist/renderer/tool-widget.js +20 -5
  64. package/dist/repl/chat-lane.js +15 -3
  65. package/dist/repl/inline-mode.js +51 -0
  66. package/dist/repl/leftover.js +90 -0
  67. package/dist/repl/reroute-turn.js +7 -1
  68. package/dist/repl/turn-stop.js +64 -0
  69. package/dist/repl.js +155 -24
  70. package/dist/router/classifier.js +1 -1
  71. package/dist/sap/reachability-state.js +7 -0
  72. package/dist/sap/unreachable.js +2 -2
  73. package/dist/session/fresh-start.js +4 -0
  74. package/dist/session/queued-step.js +3 -1
  75. package/dist/session/user-prompt.js +12 -1
  76. package/dist/skills/manifest-client.js +3 -2
  77. package/dist/skills/preamble.js +6 -4
  78. package/dist/skills/source-authed.js +3 -2
  79. package/dist/tools/approval.js +49 -8
  80. package/dist/tools/dispatch-skill.js +1 -1
  81. package/dist/tools/hand-off.js +49 -7
  82. package/dist/tools/lane/app-lane-resolve.js +9 -0
  83. package/dist/tools/method-only-change.js +241 -0
  84. package/dist/tools/project/spec_answers_record.js +190 -0
  85. package/dist/tools/rap/skeleton-resolve.js +4 -0
  86. package/dist/tools/sap-write.js +78 -3
  87. package/dist/tools/snapshot.js +7 -2
  88. package/dist/tools/subagent/agent_run.js +14 -0
  89. package/dist/tools/tool-loading.js +18 -8
  90. package/dist/tools/transport-fit-check.js +70 -0
  91. package/dist/tools/transport.js +17 -8
  92. package/dist/tools/write-pipeline.js +46 -5
  93. package/dist/ui/app.js +112 -13
  94. package/dist/ui/approval-modal.js +13 -7
  95. package/dist/ui/ascii-chars.js +21 -3
  96. package/dist/ui/footer.js +128 -7
  97. package/dist/ui/interrupt.js +35 -1
  98. package/dist/ui/live-area.js +21 -4
  99. package/dist/ui/queue-box-emitter.js +14 -0
  100. package/dist/ui/rewind-panel.js +2 -1
  101. package/dist/ui/session-timeline.js +2 -1
  102. package/dist/ui/text-input.js +31 -2
  103. package/dist/ui/widgets/ask-card.js +171 -29
  104. package/dist/ui/widgets/bar-chart.js +2 -1
  105. package/dist/ui/widgets/confirm-card.js +2 -1
  106. package/dist/ui/widgets/dep-graph.js +2 -1
  107. package/dist/ui/widgets/diff-viewer.js +2 -1
  108. package/dist/ui/widgets/question-card.js +2 -1
  109. package/dist/ui/widgets/shortcuts-sheet.js +2 -2
  110. package/dist/ui/widgets/stack-frames.js +2 -1
  111. package/dist/util/connect-retry.js +65 -0
  112. package/package.json +2 -2
@@ -13,7 +13,13 @@ export async function runRerouteTurn(p) {
13
13
  await p.run();
14
14
  }
15
15
  catch (err) {
16
- await renderTurnError({ err, session: p.session, userInterrupted: p.signal?.aborted ?? false, inSession: true, print: p.print });
16
+ const stopped = p.signal?.aborted ?? false;
17
+ const line = stopped ? p.stopLine?.() ?? null : null;
18
+ if (line !== null)
19
+ p.print(line);
20
+ else
21
+ await renderTurnError({ err, session: p.session, userInterrupted: stopped, inSession: true, print: p.print });
22
+ p.onError?.();
17
23
  }
18
24
  finally {
19
25
  p.onFinally();
@@ -0,0 +1,64 @@
1
+ /**
2
+ * LT-15 Task 5b — how the current dispatch was stopped (Ink REPL only;
3
+ * classic, headless and one-shot have no Esc brake).
4
+ *
5
+ * Per dispatch (plan review I4): set by the presses, read by autoDispatch
6
+ * (a queued hop / lane plan / switch is not started after ANY stop) and by
7
+ * the leftover runner's settle, then reset in handleSubmit's finally right
8
+ * after that settle, and at a restore's end. Never reset at controller
9
+ * creation, so a stale stop can never touch the next hop or turn.
10
+ *
11
+ * `press` is the Esc brake. It reuses what Ctrl+C already does — the SAME
12
+ * `currentTurnAbort.abort()` (deps.abortTurn) or, in the post-turn window,
13
+ * the SAME pending cancel (deps.cancelPending) — and never touches Ctrl+C's
14
+ * press count (review C1): after an Esc, one Ctrl+C is still Ctrl+C's FIRST
15
+ * press, never the force-exit.
16
+ */
17
+ import { steeringSnapshot } from '../agent/steering-queue.js';
18
+ import { decideEscape, brakeLine, brakeEndLine } from '../ui/interrupt.js';
19
+ export function createTurnStop(deps) {
20
+ let braked = false;
21
+ let ctrlC = false;
22
+ const waiting = deps.waiting ?? (() => steeringSnapshot().length);
23
+ const decide = (ui) => decideEscape({
24
+ ...ui,
25
+ turnActive: deps.turnActive(),
26
+ busy: deps.busy(),
27
+ restoreActive: deps.restoreActive(),
28
+ phase: deps.phase(),
29
+ canChain: deps.canChain(),
30
+ alreadyStopped: braked || ctrlC || deps.exitRequested(),
31
+ });
32
+ const brake = () => {
33
+ braked = true;
34
+ deps.print(brakeLine(waiting() > 0));
35
+ deps.abortTurn();
36
+ };
37
+ return {
38
+ stopFromCard() {
39
+ // The card itself is the overlay that owned the key: decide as if
40
+ // nothing else were open. Only a running turn is stopped.
41
+ if (decide({ overlayOpen: false, bufferCount: 0, fuzzyOpen: false }) !== 'brake')
42
+ return 'none';
43
+ brake();
44
+ return 'brake';
45
+ },
46
+ press(ui) {
47
+ const action = decide(ui);
48
+ if (action === 'brake') {
49
+ brake();
50
+ }
51
+ else if (action === 'cancel-pending') {
52
+ braked = true;
53
+ deps.cancelPending();
54
+ }
55
+ return action;
56
+ },
57
+ noteCtrlCStop() { ctrlC = true; },
58
+ get braked() { return braked; },
59
+ get stopped() { return braked || ctrlC; },
60
+ get brakedOnly() { return braked && !ctrlC; },
61
+ endLine() { return brakeEndLine(waiting() > 0); },
62
+ reset() { braked = false; ctrlC = false; },
63
+ };
64
+ }
package/dist/repl.js CHANGED
@@ -30,6 +30,7 @@ import { createDispatchSlot, queuedStepView } from './session/queued-step.js';
30
30
  import { continueSkillFor } from './repl/continue-skill.js';
31
31
  import { applyResumedSkill } from './router/resume-routing.js';
32
32
  import { ACK_PHRASES, wantsContinuation, isConversationalAck, decideLane, turnStatusLabel, applyLaneTools, chatFallbackOrigin } from './repl/chat-lane.js';
33
+ import { inlineActive, inlineServed, isChatLaneProcess } from './repl/inline-mode.js';
33
34
  import { CHAT_SKILL } from './agent/chat-prompt.js';
34
35
  import { setAssistantStyle } from './agent/style-state.js';
35
36
  import { setAssistantLevel } from './agent/level-state.js';
@@ -45,7 +46,7 @@ import { startSkillsCacheWarmup } from './skills/source-authed.js';
45
46
  import { formatLoginBanner, signInFragment } from './ui/login-banner.js';
46
47
  import { printStartupBanner, printKeytarFallbackBanner, keychainFallbackNotice } from './renderer/banners.js';
47
48
  import { paint } from './renderer/theme.js';
48
- import { steeringEchoLine, classifyMidTurnLine, midTurnSlashHint } from './renderer/steering-echo.js';
49
+ import { classifyMidTurnLine, midTurnSlashHint } from './renderer/steering-echo.js';
49
50
  import { runAddSapWizard } from './sap/onboarding.js';
50
51
  import { getConnection } from './sap/connection-manager.js';
51
52
  import { unreachableCode, unreachableMessage } from './sap/unreachable.js';
@@ -154,6 +155,9 @@ import { getEffectiveWriteMode, runModeCommand } from './repl/mode-cycle.js';
154
155
  import { maybeExpandNumberSelection, lastAssistantText } from './repl/numbered-menu.js';
155
156
  import { attachEarlyLineBuffer, noopEarlyLineBuffer } from './repl/early-line-buffer.js';
156
157
  import { createRestoreGuard } from './repl/restore-guard.js';
158
+ import { createLeftoverRunner, takeUnsentReport, parseSubmitted } from './repl/leftover.js';
159
+ import { createTurnStop } from './repl/turn-stop.js';
160
+ import { requestQueueToBox } from './ui/queue-box-emitter.js';
157
161
  import { gracefulExit } from './lib/graceful-exit.js';
158
162
  import { endSapSessionAfterTurn } from './sap/session-end.js';
159
163
  import { snapshotTurnStart, pushPostTurnStatus, seedFooterSpend } from './repl/post-turn-status.js';
@@ -201,6 +205,7 @@ import './tools/project/team_findings.js';
201
205
  import './tools/project/team_estate.js';
202
206
  import './tools/project/rules_propose.js';
203
207
  import './tools/project/team_lane_move.js';
208
+ import './tools/project/spec_answers_record.js';
204
209
  import './tools/subagent/_background-shared.js';
205
210
  import './tools/subagent/background_run.js';
206
211
  import './tools/subagent/monitor_emit.js';
@@ -1079,13 +1084,47 @@ export async function runRepl(opts) {
1079
1084
  : pendingCancelLine(phase, exiting, queuedWorkLabel(pendingDispatchAccessor.get()));
1080
1085
  chunkEmitter.emit('chunk', '\n' + formatInfo(line));
1081
1086
  };
1087
+ // LT-15 Feature 4 — messages still waiting when a dispatch ends: run them
1088
+ // as the next turn (normal end), put them back in the box (Ctrl+C, an
1089
+ // error), let a queued hop read them first, or report them at exit.
1090
+ // Flags are per dispatch: set below, read + reset by settle() in
1091
+ // handleSubmit's finally and at a restore's end (plan review I4).
1092
+ const leftover = createLeftoverRunner({
1093
+ isBusy: () => turnStatusEmitter.isBusy(),
1094
+ isDone: () => done,
1095
+ // F4 — prose: never parsed as a slash command or @-shortcut; it goes
1096
+ // through routing like a fresh prompt (review M5), as the person's turn.
1097
+ submit: (text) => { void handleSubmit(text, { prose: true }); },
1098
+ toBox: () => { requestQueueToBox(); },
1099
+ print: (line) => chunkEmitter.emit('chunk', '\n' + line),
1100
+ schedule: (fn) => { setImmediate(fn); },
1101
+ });
1102
+ // LT-15 Task 5b — how this dispatch was stopped (Esc brake or Ctrl+C).
1103
+ // Per dispatch: read by autoDispatch (nothing queued starts after a stop)
1104
+ // and by leftover.settle, reset right after that settle and at a
1105
+ // restore's end (plan review I4). The brake reuses Ctrl+C's own abort and
1106
+ // pending cancel and never touches interruptCount (review C1).
1107
+ const turnStop = createTurnStop({
1108
+ turnActive: () => currentTurnAbort !== null,
1109
+ busy: () => turnStatusEmitter.isBusy(),
1110
+ restoreActive: () => restoreGuard.active,
1111
+ phase: () => cancelPhase(),
1112
+ canChain: () => canChainNow(),
1113
+ exitRequested: () => exitAfterTurn,
1114
+ abortTurn: () => { currentTurnAbort?.abort(); },
1115
+ cancelPending: () => { announcePendingCancel(false); },
1116
+ print: (line) => chunkEmitter.emit('chunk', '\n' + formatInfo(line)),
1117
+ });
1082
1118
  /** Auto-dispatch of the next plan phase, unless a post-turn cancel stopped it. */
1083
1119
  // Task 13 fix round — `planHop` travels WITH the command into handleSubmit,
1084
1120
  // so a refused submit can never leave a stale hop mark behind.
1085
1121
  const autoDispatch = (cmd, planHop = false, queued = null) => {
1086
1122
  // Round 4 item 9b — the exit is already under way: start nothing new.
1087
1123
  // Final review B-I2 — `/exit` typed mid-turn (exitAfterTurn) stops it too.
1088
- const decision = autoDispatchDecision({ done, exitRequested: exitAfterTurn, cancelPending: pendingCancel.pending }, cmd);
1124
+ // LT-15 Task 5b (D4, 5a re-review) — nothing queued starts after a stop
1125
+ // of this dispatch (Esc or Ctrl+C); the D11 chat fallback still re-runs
1126
+ // the person's own message (review M6).
1127
+ const decision = autoDispatchDecision({ done, exitRequested: exitAfterTurn, cancelPending: pendingCancel.pending, stopped: turnStop.stopped, chatFallback: queued?.by === 'chat_fallback' }, cmd);
1089
1128
  if (decision.kind === 'skip')
1090
1129
  return;
1091
1130
  if (decision.kind === 'not-started') {
@@ -1098,6 +1137,8 @@ export async function runRepl(opts) {
1098
1137
  const notice = queued ? null : autoRoutedNotice(cmd);
1099
1138
  if (notice)
1100
1139
  chunkEmitter.emit('chunk', formatInfo(notice));
1140
+ // LT-15 D3 — the hop goes first; a waiting message is read at its round 0.
1141
+ leftover.noteDispatchScheduled();
1101
1142
  setImmediate(() => { void handleSubmit(cmd, { planHop, queued }); });
1102
1143
  };
1103
1144
  // BUG-2b — the actual dispatch. Wrapped by handleSubmit (below) which
@@ -1108,6 +1149,9 @@ export async function runRepl(opts) {
1108
1149
  const trimmed = line.trim();
1109
1150
  if (!trimmed)
1110
1151
  return;
1152
+ // LT-15 F4 — a replayed waiting message is prose, also when the D11
1153
+ // chat fallback re-runs it (the flag rides on the fallback origin).
1154
+ const prose = submitOpts?.prose === true || submitOpts?.queued?.prose === true;
1111
1155
  // Round 6 review I2 — the resumed skill is offered to the FIRST submit
1112
1156
  // only (slash commands included), then cleared.
1113
1157
  const resumedSkill = resumeKeepsSkill ? session.skill ?? null : null;
@@ -1116,7 +1160,10 @@ export async function runRepl(opts) {
1116
1160
  // the Footer has already persisted this submission (flattened), so
1117
1161
  // the consecutive-dup guard makes this a no-op instead of the old
1118
1162
  // double append.
1119
- appendHistoryEntry(trimmed);
1163
+ // LT-15 F4 — a replayed waiting message is already in history (the
1164
+ // Footer stored each one when it was typed).
1165
+ if (!prose)
1166
+ appendHistoryEntry(trimmed);
1120
1167
  // Phase A** (2026-05-16) — echo the user's prompt into Body so it
1121
1168
  // appears in the conversation history. Plus a dim divider above
1122
1169
  // the echo so consecutive turns have a clear visual break (closes
@@ -1362,7 +1409,8 @@ export async function runRepl(opts) {
1362
1409
  provider,
1363
1410
  userMessage: rr.prompt,
1364
1411
  skill: rr.skill,
1365
- ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: inkPreview, chunkEmitter, todoEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor },
1412
+ ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: inkPreview, chunkEmitter, todoEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor, inlineSkills: inlineActive({ skills: cfg.assistant.skills, lane: laneSetting, interactive: !!process.stdin.isTTY, served: inlineServed(cfg.llm?.mode) }), chatLaneProcess: isChatLaneProcess(laneSetting, !!process.stdin.isTTY), turnBy: lastTurnBy },
1413
+ ...(signedInAs ? { operator: signedInAs } : {}), // change options: Prepared for
1366
1414
  chunkEmitter,
1367
1415
  pushSpend: true, // F-L2-2 — top-level REPL turn: live statusline spend
1368
1416
  autoCompact: true, // Task 19 — top-level REPL turn: auto-compact gate at turn end
@@ -1371,6 +1419,11 @@ export async function runRepl(opts) {
1371
1419
  session,
1372
1420
  print: (line) => chunkEmitter.emit('chunk', line + '\n'),
1373
1421
  signal: rerouteAbort.signal,
1422
+ // LT-15 review I5 — an error end puts waiting text back in the box.
1423
+ onError: () => { if (!rerouteAbort.signal.aborted)
1424
+ leftover.noteError(); },
1425
+ // LT-15 Task 5b — an Esc brake prints one dim line, not the block.
1426
+ stopLine: () => (turnStop.brakedOnly ? formatInfo(turnStop.endLine()).trimEnd() : null),
1374
1427
  onFinally: () => {
1375
1428
  turnStatusEmitter.stop();
1376
1429
  currentTurnAbort = null;
@@ -1393,6 +1446,9 @@ export async function runRepl(opts) {
1393
1446
  todoEmitter.emit('update', []);
1394
1447
  clearAppFlow(session); // Task 7 — /new ends an /abap-app flow
1395
1448
  clearJourneyFlow(session); // Task 3 — and a clean-core journey
1449
+ // LT-15 Task 4 — a message still waiting belongs to the old
1450
+ // conversation: back into the box (or reported), never carried over.
1451
+ leftover.toBoxOrReport();
1396
1452
  // 2026-10-22 — and the conversation history: the next prompt sends none of it.
1397
1453
  startFreshConversation(session);
1398
1454
  idleKeepalive.disarm(); // no idle cache ping for the old history
@@ -1465,7 +1521,9 @@ export async function runRepl(opts) {
1465
1521
  // an item in a 2+-item list in the LAST assistant message.
1466
1522
  let effectiveInput = trimmed;
1467
1523
  {
1468
- const expansion = maybeExpandNumberSelection(trimmed, lastAssistantText(session.messages));
1524
+ // LT-15 Task 4 fix round 1 — never for a replayed waiting message: a
1525
+ // "2" typed mid-turn did not answer the menu of the turn that just ended.
1526
+ const expansion = prose ? null : maybeExpandNumberSelection(trimmed, lastAssistantText(session.messages));
1469
1527
  if (expansion) {
1470
1528
  chunkEmitter.emit('chunk', chalk.dim(`${expansion.echo}\n`));
1471
1529
  effectiveInput = expansion.text;
@@ -1490,7 +1548,9 @@ export async function runRepl(opts) {
1490
1548
  return;
1491
1549
  }
1492
1550
  effectiveInput = door.input;
1493
- const parsed = parseShortcut(effectiveInput);
1551
+ // LT-15 F4 — a replayed waiting message is prose: never a slash command
1552
+ // or an @-shortcut (`/sap/bc/adt/x is slow` reaches the model as text).
1553
+ const parsed = parseSubmitted(effectiveInput, prose);
1494
1554
  // D35 (2026-06-11) — explicit slash naming a skill that isn't in the
1495
1555
  // catalog: print the notice ("did you mean" / builtin-with-args) and
1496
1556
  // end the turn. Slash input must NEVER fall through to the classifier
@@ -1580,7 +1640,7 @@ export async function runRepl(opts) {
1580
1640
  // Round 6 item 4c / review I2 — after a resume, a short follow-up
1581
1641
  // ("continue", a number) stays in the session's skill; anything else
1582
1642
  // is classified (applyResumedSkill below).
1583
- const continuedSkill = wantsContinuation({ trimmed, sessionSkill: session.skill, awaitingSkillAnswer: session.awaitingSkillAnswer, resumedSkill });
1643
+ const continuedSkill = wantsContinuation({ trimmed, sessionSkill: session.skill, awaitingSkillAnswer: session.awaitingSkillAnswer, resumedSkill, lastChangeOptions: session.lastChangeOptions ?? null });
1584
1644
  if (continuedSkill) {
1585
1645
  chunkEmitter.emit('chunk', formatRoutingLine({ kind: 'continuing', skill: continuedSkill }));
1586
1646
  skill = continuedSkill;
@@ -1611,7 +1671,7 @@ export async function runRepl(opts) {
1611
1671
  sap_system: selectedAlias,
1612
1672
  last_skill: session.last_skill ?? null,
1613
1673
  last_object: extractedObject ?? session.last_object ?? null,
1614
- }, { signal: routingCtl.signal });
1674
+ }, { signal: routingCtl.signal, prose });
1615
1675
  }
1616
1676
  catch (err) {
1617
1677
  if (!routingCtl.signal.aborted) {
@@ -2056,7 +2116,9 @@ export async function runRepl(opts) {
2056
2116
  provider,
2057
2117
  userMessage: prompt,
2058
2118
  skill: skill,
2059
- ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: inkPreview, chunkEmitter, todoEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor },
2119
+ // Stage 1A — inline is decided here, at every submit, from today's inputs (never cached).
2120
+ ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: inkPreview, chunkEmitter, todoEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor, inlineSkills: inlineActive({ skills: cfg.assistant.skills, lane: laneSetting, interactive: !!process.stdin.isTTY, served: inlineServed(cfg.llm?.mode) }), chatLaneProcess: isChatLaneProcess(laneSetting, !!process.stdin.isTTY), turnBy: lastTurnBy },
2121
+ ...(signedInAs ? { operator: signedInAs } : {}), // change options: Prepared for
2060
2122
  chunkEmitter,
2061
2123
  pushSpend: true, // F-L2-2 — top-level REPL turn: live statusline spend
2062
2124
  explicitSlash: parsed.skill !== undefined, // Task 7 R11 — typed or queued slash command
@@ -2134,7 +2196,7 @@ export async function runRepl(opts) {
2134
2196
  // never throw ChatUnavailableError again.
2135
2197
  laneSetting = 'skills';
2136
2198
  sapStateStore.update({ chatLane: false });
2137
- pendingDispatchAccessor.set(trimmed, chatFallbackOrigin());
2199
+ pendingDispatchAccessor.set(trimmed, chatFallbackOrigin(prose));
2138
2200
  }
2139
2201
  else {
2140
2202
  // 2026-09-24 — print the turn-error block INTO the conversation (it
@@ -2144,13 +2206,25 @@ export async function runRepl(opts) {
2144
2206
  // "type continue" (the partial turn is already saved).
2145
2207
  const { renderTurnError } = await import('./agent/turn-error-ux.js');
2146
2208
  turnCancelledByUser = currentTurnAbort?.signal.aborted ?? false;
2147
- await renderTurnError({
2148
- err,
2149
- session,
2150
- userInterrupted: turnCancelledByUser,
2151
- inSession: true,
2152
- print: (line) => chunkEmitter.emit('chunk', line + '\n'),
2153
- });
2209
+ // LT-15 review I5 — an error end (not a stop): waiting text goes back
2210
+ // into the box, nothing runs by itself.
2211
+ if (!turnCancelledByUser)
2212
+ leftover.noteError();
2213
+ if (turnCancelledByUser && turnStop.brakedOnly) {
2214
+ // LT-15 Task 5b — an Esc brake: one dim line in place of the
2215
+ // "type continue" block. The loop already saved the turn as
2216
+ // interrupted, and a journey stays paused.
2217
+ chunkEmitter.emit('chunk', '\n' + formatInfo(turnStop.endLine()));
2218
+ }
2219
+ else {
2220
+ await renderTurnError({
2221
+ err,
2222
+ session,
2223
+ userInterrupted: turnCancelledByUser,
2224
+ inSession: true,
2225
+ print: (line) => chunkEmitter.emit('chunk', line + '\n'),
2226
+ });
2227
+ }
2154
2228
  }
2155
2229
  }
2156
2230
  finally {
@@ -2276,6 +2350,8 @@ export async function runRepl(opts) {
2276
2350
  tokensUsed: 0,
2277
2351
  model: session.model,
2278
2352
  emit: (line) => chunkEmitter.emit('chunk', line + '\n'),
2353
+ ...(signedInAs ? { operator: signedInAs } : {}),
2354
+ sessionId: session.id,
2279
2355
  });
2280
2356
  }
2281
2357
  else if (r.kind === 'none' && !(await import('./sap/reachability-state.js')).isSapUnreachableNoted()) {
@@ -2443,21 +2519,23 @@ export async function runRepl(opts) {
2443
2519
  chunkEmitter.emit('chunk', midTurnSlashHint(kind.command));
2444
2520
  return;
2445
2521
  }
2446
- if (t.length > 0) {
2522
+ // LT-15 Feature 1 (D5) — no scrollback receipt; the App's queue row shows it.
2523
+ if (t.length > 0)
2447
2524
  enqueueSteering(t);
2448
- chunkEmitter.emit('chunk', steeringEchoLine(t));
2449
- }
2450
2525
  return;
2451
2526
  }
2452
2527
  idleKeepalive.disarm(); // Task 15 — a submit ends the idle period
2453
2528
  turnStatusEmitter.reserve();
2454
2529
  postTurn = false;
2455
2530
  postTurnMayChain = false;
2531
+ // LT-15 review I5 — a dispatch that threw ended by an error.
2532
+ let threw = true;
2456
2533
  try {
2457
2534
  // Round 4 item 9b — the exit path waits (capped) for this to finish.
2458
2535
  const run = dispatchSubmit(line, submitOpts);
2459
2536
  dispatchTail = run;
2460
2537
  await run;
2538
+ threw = false;
2461
2539
  }
2462
2540
  finally {
2463
2541
  turnStatusEmitter.release();
@@ -2479,6 +2557,14 @@ export async function runRepl(opts) {
2479
2557
  // the submit ended in an exit (merge: UI round-3 /exit mid-turn).
2480
2558
  if (!done)
2481
2559
  idleKeepalive.armAfterTurn(session);
2560
+ // LT-15 Feature 4 — after release(): messages still waiting run as the
2561
+ // next turn (on the next tick, drained there), go back into the box,
2562
+ // wait for a queued hop, or are left for the exit report.
2563
+ if (threw)
2564
+ leftover.noteError();
2565
+ leftover.settle({ exitRequested: exitAfterTurn, braked: turnStop.braked });
2566
+ // LT-15 Task 5b (I4) — the stop belongs to this dispatch only.
2567
+ turnStop.reset();
2482
2568
  }
2483
2569
  };
2484
2570
  // Fix round 3 (item 5) — `/exit` / `/quit` typed while a turn runs:
@@ -2495,6 +2581,9 @@ export async function runRepl(opts) {
2495
2581
  cancelAnnouncedPhase = null;
2496
2582
  pendingCancel.clear();
2497
2583
  interruptCount = 0;
2584
+ // LT-15 Feature 4 — a restore's end decides like a normal turn end.
2585
+ leftover.settle({ exitRequested: exitAfterTurn, braked: turnStop.braked });
2586
+ turnStop.reset();
2498
2587
  },
2499
2588
  exit: () => { done = true; if (resolveDone)
2500
2589
  resolveDone(); },
@@ -2532,6 +2621,10 @@ export async function runRepl(opts) {
2532
2621
  let idleInterruptAt = 0;
2533
2622
  const onInterrupt = () => {
2534
2623
  const now = Date.now();
2624
+ // LT-15 D1 — Ctrl+C during a dispatch: waiting messages go back into the
2625
+ // box at this dispatch's end; nothing runs by itself.
2626
+ if (turnStatusEmitter.isBusy())
2627
+ leftover.noteCtrlC();
2535
2628
  const action = decideInterrupt({
2536
2629
  turnActive: currentTurnAbort !== null,
2537
2630
  // Review I3 — routing / prep in flight without a controller yet is
@@ -2545,18 +2638,30 @@ export async function runRepl(opts) {
2545
2638
  });
2546
2639
  if (action === 'abort-turn') {
2547
2640
  interruptCount++;
2548
- chunkEmitter.emit('chunk', '\n' + formatInfo('Stopping the turn · saving the work done so far'));
2641
+ turnStop.noteCtrlCStop(); // LT-15 Task 5b — nothing queued starts after it
2642
+ // After an Esc the stop line is already on screen: not repeated (C1).
2643
+ if (!turnStop.braked)
2644
+ chunkEmitter.emit('chunk', '\n' + formatInfo('Stopping the turn · saving the work done so far'));
2549
2645
  currentTurnAbort.abort();
2646
+ return 'stopped'; // LT-15 D1 — App puts the waiting text back in the box now
2550
2647
  }
2551
2648
  else if (action === 'cancel-pending') {
2552
2649
  interruptCount++;
2650
+ turnStop.noteCtrlCStop();
2553
2651
  announcePendingCancel(false);
2652
+ return 'stopped';
2554
2653
  }
2555
2654
  else if (action === 'force-exit') {
2556
2655
  // Second Ctrl+C during the same turn — give up and exit.
2557
2656
  chunkEmitter.emit('chunk', '\n' + formatInfo('Exiting · second Ctrl+C'));
2558
2657
  unmount();
2559
2658
  process.stdout.write('\x1b[?25h'); // restore cursor
2659
+ // LT-15 Feature 4 — say how many waiting messages were never sent.
2660
+ {
2661
+ const unsent = takeUnsentReport();
2662
+ if (unsent)
2663
+ console.log(unsent.trimEnd());
2664
+ }
2560
2665
  printGoodbye(session);
2561
2666
  // Final review B M-2 — through gracefulExit (fetch drain, capped).
2562
2667
  // Re-review item 3 — mark the session done first, so nothing queued
@@ -2576,6 +2681,18 @@ export async function runRepl(opts) {
2576
2681
  resolveDone();
2577
2682
  }
2578
2683
  };
2684
+ // LT-15 Task 5b — Esc while working (the App calls this only while busy
2685
+ // and when no card, held draft or Ctrl+R search owns the Esc). The brake
2686
+ // decides with decideEscape and reuses Ctrl+C's own stop.
2687
+ const onBrake = (ui) => {
2688
+ turnStop.press(ui);
2689
+ };
2690
+ // LT-15 Revision 2 — Esc / [c] on the approval card (or Esc / Cancel on
2691
+ // the plan gate card) is the same safe stop as the Esc brake. The card
2692
+ // still answers cancel-turn, so nothing from it is written or minted.
2693
+ const onCardStop = () => {
2694
+ turnStop.stopFromCard();
2695
+ };
2579
2696
  // Wave1 live-fix (Bug 2) — bracketed paste for the Ink path. Windows
2580
2697
  // Terminal delivers pasted newlines as `\r`, and a `\r`-only chunk
2581
2698
  // parses as key.return (ink parse-keypress.js:145-148) → every pasted
@@ -2640,6 +2757,8 @@ export async function runRepl(opts) {
2640
2757
  sapAlias: selectedAlias,
2641
2758
  onInterrupt,
2642
2759
  onExitRequest,
2760
+ onBrake,
2761
+ onCardStop,
2643
2762
  }), {
2644
2763
  // Bug 2 (wave1 live-fix) — Ink reads from the paste guard, not
2645
2764
  // process.stdin directly. The guard satisfies Ink's full stdin
@@ -2718,6 +2837,13 @@ export async function runRepl(opts) {
2718
2837
  if (exitTail === 'timeout') {
2719
2838
  console.log(formatAttention({ text: 'Post-turn work did not finish', fact: 'the session file may be incomplete' }).trimEnd());
2720
2839
  }
2840
+ // LT-15 Feature 4 — `/exit` (or the idle exit) with messages still waiting:
2841
+ // they are not sent; say how many.
2842
+ {
2843
+ const unsent = takeUnsentReport();
2844
+ if (unsent)
2845
+ console.log(unsent.trimEnd());
2846
+ }
2721
2847
  printGoodbye(session);
2722
2848
  return 0;
2723
2849
  }
@@ -3404,7 +3530,8 @@ export async function runRepl(opts) {
3404
3530
  provider,
3405
3531
  userMessage: rr.prompt,
3406
3532
  skill: rr.skill,
3407
- ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: replPreviewHook, chunkEmitter: classicOutputEmitter, todoEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor },
3533
+ ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: replPreviewHook, chunkEmitter: classicOutputEmitter, todoEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor, inlineSkills: inlineActive({ skills: cfg.assistant.skills, lane: laneSetting, interactive: !pipedStdin, served: inlineServed(cfg.llm?.mode) }), chatLaneProcess: isChatLaneProcess(laneSetting, !pipedStdin), turnBy: lastTurnBy },
3534
+ ...(signedInAs ? { operator: signedInAs } : {}), // change options: Prepared for
3408
3535
  pushSpend: true, // F-L2-2 — top-level REPL turn: live statusline spend
3409
3536
  autoCompact: true, // Task 19 — top-level REPL turn: auto-compact gate at turn end
3410
3537
  }),
@@ -3712,7 +3839,7 @@ export async function runRepl(opts) {
3712
3839
  continue;
3713
3840
  }
3714
3841
  // Round 6 item 4c / review I2 — see the Ink path.
3715
- const continuedSkill = wantsContinuation({ trimmed, sessionSkill: session.skill, awaitingSkillAnswer: session.awaitingSkillAnswer, resumedSkill });
3842
+ const continuedSkill = wantsContinuation({ trimmed, sessionSkill: session.skill, awaitingSkillAnswer: session.awaitingSkillAnswer, resumedSkill, lastChangeOptions: session.lastChangeOptions ?? null });
3716
3843
  if (continuedSkill) {
3717
3844
  console.log(formatRoutingLine({ kind: 'continuing', skill: continuedSkill }).trimEnd());
3718
3845
  skill = continuedSkill;
@@ -4146,7 +4273,9 @@ export async function runRepl(opts) {
4146
4273
  provider,
4147
4274
  userMessage: prompt,
4148
4275
  skill: skill,
4149
- ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: replPreviewHook, chunkEmitter: classicOutputEmitter, todoEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor },
4276
+ // Stage 1A — inline is decided here, at every submit, from today's inputs (never cached).
4277
+ ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: replPreviewHook, chunkEmitter: classicOutputEmitter, todoEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor, inlineSkills: inlineActive({ skills: cfg.assistant.skills, lane: laneSetting, interactive: !pipedStdin, served: inlineServed(cfg.llm?.mode) }), chatLaneProcess: isChatLaneProcess(laneSetting, !pipedStdin), turnBy: lastTurnBy },
4278
+ ...(signedInAs ? { operator: signedInAs } : {}), // change options: Prepared for
4150
4279
  pushSpend: true, // F-L2-2 — top-level REPL turn: live statusline spend
4151
4280
  explicitSlash: parsed.skill !== undefined, // Task 7 R11 — typed or queued slash command
4152
4281
  signal: turnAbort.signal,
@@ -4340,6 +4469,8 @@ export async function runRepl(opts) {
4340
4469
  tokensUsed: 0,
4341
4470
  model: session.model,
4342
4471
  emit: (line) => console.log(line),
4472
+ ...(signedInAs ? { operator: signedInAs } : {}),
4473
+ sessionId: session.id,
4343
4474
  });
4344
4475
  }
4345
4476
  else if (r.kind === 'none' && !(await import('./sap/reachability-state.js')).isSapUnreachableNoted()) {
@@ -157,7 +157,7 @@ export async function classifyPrompt(prompt, context, opts = {}) {
157
157
  // before classification; this guard is the backstop that turns any
158
158
  // future regression into a loud error instead of a silent misroute.
159
159
  // Fires BEFORE config/key loading so no network call is ever made.
160
- if (prompt.trimStart().startsWith('/')) {
160
+ if (!opts.prose && prompt.trimStart().startsWith('/')) {
161
161
  throw new Error('slash command reached the classifier — explicit /commands must be routed before classification (CLI bug, please report)');
162
162
  }
163
163
  const cfg = await loadConfig();
@@ -7,6 +7,13 @@ let unreachable = null;
7
7
  const CODE_RE = /\b(ETIMEDOUT|ECONNREFUSED|ENOTFOUND|EHOSTUNREACH|ENETUNREACH|ECONNRESET|EAI_AGAIN)\b/;
8
8
  export const SAP_UNREACHABLE_DIRECTIVE = 'SAP is not reachable from this machine right now. Stop here: do not retry SAP tools and do not write the plan manifest. '
9
9
  + 'End the turn with one short sentence. CSPeach pauses the build and runs this step again when the network is back.';
10
+ /**
11
+ * LT-14 (1A fix wave) — outside a plan step, the note that rides on a SAP
12
+ * tool error when SAP did not answer. Before, the model got the bare error
13
+ * and invented network advice for the person. No pause, no flag.
14
+ */
15
+ export const SAP_UNREACHABLE_NOTE = 'SAP did not answer from this machine. Do not guess why or give the person network advice. '
16
+ + "After three such failures in a row, stop and ask; offer 'SAP is reachable again; try once more' and 'Stop for now; I'll pick up later'.";
10
17
  export function noteSapUnreachable(code) {
11
18
  unreachable = code;
12
19
  }
@@ -26,6 +26,6 @@ export function unreachableMessage(alias, host, port, code) {
26
26
  return (
27
27
  // 2026-10-04: never assume a VPN — many users sit on the company network.
28
28
  `Can't reach SAP system ${alias} (${host}:${port}) from this machine — ${code}. ` +
29
- 'Check network access to the SAP system (company network or VPN), ' +
30
- 'or run with --no-sap for work that does not need SAP.');
29
+ 'Check network access to the SAP system. ' +
30
+ 'For work that does not need SAP, run with --no-sap.');
31
31
  }
@@ -10,6 +10,10 @@ export function startFreshConversation(session) {
10
10
  // Word export (final-review m-4): the once-per-object save offers and the
11
11
  // export records describe the cleared conversation; they go with it.
12
12
  clearConversationRecords(session);
13
+ // Change options (1.1.53): the last document goes too; a level reply then routes as today.
14
+ session.lastChangeOptions = null;
15
+ // Task 16: a pending blocker-form note describes the cleared conversation.
16
+ delete session.blockerAnswersNote;
13
17
  }
14
18
  /** The one line /new prints. */
15
19
  export function freshStartText(lane) {
@@ -45,5 +45,7 @@ export function queuedStepView(origin) {
45
45
  if (!line)
46
46
  return { echo: false, line: null };
47
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 };
48
+ // LT-4 (1A fix wave, owner ruling): the hand-off arrow line is dim too, kept in scrollback.
49
+ const dim = origin.by === 'chat_fallback' || origin.by === 'hand_off';
50
+ return { echo: false, line: dim ? paint('dim', line) : line };
49
51
  }
@@ -26,8 +26,19 @@ export function isUserPrompt(m) {
26
26
  * (`<session_context>` from the intent extractor / SAP system info,
27
27
  * `<project_context>` from CSPEACH_PROJECT_CONTEXT). The user never typed
28
28
  * them, so a prompt preview must never show them (fix round 3, item 2).
29
+ * Stage 1A (M5) — `<skill>`, the playbook block an inline turn appends. The
30
+ * order matters: `<session_context>` first, so the bare `<skill>` word in a
31
+ * `<playbook>` line goes with the block it sits in — `skill` stays last.
32
+ * Shared with the conversation export (commands/export-conversation.ts).
29
33
  */
30
- const INJECTED_CONTEXT_TAGS = ['session_context', 'project_context'];
34
+ export const INJECTED_CONTEXT_TAGS = [
35
+ 'session_context', 'project_context',
36
+ // The /export docx conversation list (main, 2026-10-23): one list for both readers.
37
+ 'journey', 'lane_decision', 'system_switch', 'sap_system', 'sap_connection',
38
+ // Task 16 (F-M): the blocker form's note (it rides inside <session_context>).
39
+ 'blocker_answers',
40
+ 'skill',
41
+ ];
31
42
  /**
32
43
  * One-line preview of what the user typed: injected context blocks removed,
33
44
  * `<attached file="x">…</attached>` expansions shown as the original `@x`
@@ -4,6 +4,7 @@ import { sha512 } from '@noble/hashes/sha512';
4
4
  import { bytesToHex, concatBytes } from '@noble/hashes/utils';
5
5
  import { MANIFEST_PUBLIC_KEYS } from './signing-public-key.js';
6
6
  import { canonicalise } from './canonical.js';
7
+ import { fetchWithConnectRetry } from '../util/connect-retry.js';
7
8
  // @noble/ed25519 v2 requires an ed25519 sha512 hook.
8
9
  ed.etc.sha512Sync = (...m) => sha512(concatBytes(...m));
9
10
  /**
@@ -39,7 +40,7 @@ export class ManifestClient {
39
40
  async fetchManifest() {
40
41
  let r;
41
42
  try {
42
- r = await fetch(this.manifestUrl, { signal: AbortSignal.timeout(10_000) });
43
+ r = await fetchWithConnectRetry(this.manifestUrl, () => ({ signal: AbortSignal.timeout(10_000) }));
43
44
  }
44
45
  catch (e) {
45
46
  throw new ManifestUnavailableError(`manifest fetch failed (${this.manifestUrl}): ${e.message}`);
@@ -99,7 +100,7 @@ export class ManifestClient {
99
100
  async fetchSkillBody(name, bodyUrl, opts) {
100
101
  let r;
101
102
  try {
102
- r = await fetch(bodyUrl, { signal: AbortSignal.timeout(10_000) });
103
+ r = await fetchWithConnectRetry(bodyUrl, () => ({ signal: AbortSignal.timeout(10_000) }));
103
104
  }
104
105
  catch (e) {
105
106
  throw new Error(`skill body fetch failed (${bodyUrl}): ${e.message}`);
@@ -52,8 +52,10 @@ take precedence if a skill's instruction conflicts with them.
52
52
  revise and re-submit.
53
53
 
54
54
  ## Transport discipline
55
- - All writes go into the active session transport. Do not ask the user
56
- for a transport number per change.
55
+ - All writes go into the session transport once one is set. Do not ask
56
+ the user for a transport number per change. When a create says a
57
+ transport is needed, ask the user once: a new session transport
58
+ (recommended) or one of the open requests for the package.
57
59
  - Never use \$TMP unless the user explicitly requests local-only work.
58
60
 
59
61
  ## Escalate, don't fail silently
@@ -70,6 +72,6 @@ take precedence if a skill's instruction conflicts with them.
70
72
 
71
73
  ## One skill per turn
72
74
  - If the user's request is better served by a different skill, call
73
- switch_skill with a clear reason. Do not try to do the work of another
74
- skill yourself.
75
+ dispatch_skill after the person has picked the command. Do not try to do
76
+ the work of another skill yourself.
75
77
  `;