@cspeach/cli 0.9.0 → 1.0.0

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 (131) hide show
  1. package/README.md +1 -1
  2. package/dist/agent/intent-system-prompt.js +1 -1
  3. package/dist/agent/loop.js +209 -20
  4. package/dist/agent/providers/license-gate.js +44 -0
  5. package/dist/agent/skill-checkpoint.js +1 -1
  6. package/dist/agent/tool-dispatch.js +15 -0
  7. package/dist/approvals/canonical.js +91 -0
  8. package/dist/approvals/jwt.js +39 -2
  9. package/dist/auth/org-anthropic-key.js +25 -0
  10. package/dist/classifier/client.js +18 -3
  11. package/dist/commands/config-set.js +95 -0
  12. package/dist/commands/login.js +31 -14
  13. package/dist/commands/plan-model-tier.js +83 -0
  14. package/dist/commands/plan-resume.js +148 -21
  15. package/dist/config/loader.js +95 -1
  16. package/dist/doctor/checks/_http-probe.js +1 -0
  17. package/dist/doctor/checks/cert.js +14 -3
  18. package/dist/doctor/checks/sap.js +30 -8
  19. package/dist/doctor/checks/zcspeach.js +19 -4
  20. package/dist/one-shot.js +52 -4
  21. package/dist/projects/answer-blockers.js +137 -0
  22. package/dist/projects/extract-cca.js +108 -16
  23. package/dist/projects/extract-modernize.js +1 -1
  24. package/dist/projects/extract-plan.js +130 -37
  25. package/dist/projects/extract-spec-gap.js +34 -7
  26. package/dist/projects/extract-test-coverage.js +1 -1
  27. package/dist/projects/extract-upgrade.js +113 -22
  28. package/dist/projects/index.js +5 -2
  29. package/dist/projects/merge-cca.js +292 -0
  30. package/dist/projects/merge-upgrade.js +173 -0
  31. package/dist/projects/migration.js +103 -1
  32. package/dist/projects/output-paths.js +27 -0
  33. package/dist/projects/plan-run.js +159 -25
  34. package/dist/projects/plan-schema.js +63 -3
  35. package/dist/projects/promote-command.js +25 -2
  36. package/dist/projects/promote.js +128 -0
  37. package/dist/projects/save-command.js +247 -20
  38. package/dist/projects/status.js +3 -1
  39. package/dist/projects/validate.js +1 -1
  40. package/dist/projects/workspace.js +164 -20
  41. package/dist/renderer/notices.js +64 -0
  42. package/dist/renderer/progress-chatter.js +8 -0
  43. package/dist/renderer/tool-widget.js +18 -4
  44. package/dist/renderer/tty.js +43 -4
  45. package/dist/renderer/verify-chain.js +77 -0
  46. package/dist/repl/at-picker.js +60 -7
  47. package/dist/repl/builtin-commands.js +37 -0
  48. package/dist/repl/early-line-buffer.js +68 -0
  49. package/dist/repl/inquirer-guard.js +70 -5
  50. package/dist/repl/numbered-menu.js +131 -0
  51. package/dist/repl/post-turn-status.js +2 -2
  52. package/dist/repl/rule8-detector.js +17 -2
  53. package/dist/repl/safety-confirm.js +111 -2
  54. package/dist/repl/safety-mode-state.js +19 -3
  55. package/dist/repl/slash-picker.js +10 -15
  56. package/dist/repl.js +301 -35
  57. package/dist/router/classifier.js +150 -6
  58. package/dist/sap/capability-matrix.js +20 -0
  59. package/dist/sap/capability-matrix.json +11236 -0
  60. package/dist/sap/capability.js +146 -0
  61. package/dist/sap/connection-manager.js +19 -1
  62. package/dist/sap/onboarding.js +42 -4
  63. package/dist/session/pending.js +27 -0
  64. package/dist/skill-catalog.js +48 -43
  65. package/dist/skills/bundled-skills.js +279 -1
  66. package/dist/skills/promotion-dispatch.js +23 -0
  67. package/dist/tools/_command-shared.js +36 -12
  68. package/dist/tools/_filesystem-shared.js +139 -4
  69. package/dist/tools/_flag.js +25 -0
  70. package/dist/tools/approval.js +64 -21
  71. package/dist/tools/ask-question.js +96 -4
  72. package/dist/tools/capability/tool.js +74 -0
  73. package/dist/tools/dispatch-skill.js +22 -1
  74. package/dist/tools/extend-model/anchored-insert.js +810 -0
  75. package/dist/tools/extend-model/tool.js +188 -0
  76. package/dist/tools/filesystem/extract-document.js +57 -0
  77. package/dist/tools/filesystem/file-edit.js +12 -2
  78. package/dist/tools/filesystem/file-read.js +2 -2
  79. package/dist/tools/filesystem/file-write.js +11 -2
  80. package/dist/tools/filesystem/glob.js +11 -0
  81. package/dist/tools/filesystem/grep.js +10 -0
  82. package/dist/tools/filesystem/read-document.js +107 -0
  83. package/dist/tools/fiori/apply.js +50 -0
  84. package/dist/tools/fiori/bin.js +3 -0
  85. package/dist/tools/fiori/catalog/index.js +27 -0
  86. package/dist/tools/fiori/catalog/value-help.js +230 -0
  87. package/dist/tools/fiori/catalog/viz-chart.js +177 -0
  88. package/dist/tools/fiori/cli.js +71 -0
  89. package/dist/tools/fiori/deploy-config.js +73 -0
  90. package/dist/tools/fiori/fe-scaffold.js +45 -0
  91. package/dist/tools/fiori/i18n.js +39 -0
  92. package/dist/tools/fiori/manifest.js +70 -0
  93. package/dist/tools/fiori/render.js +77 -0
  94. package/dist/tools/fiori/scaffold.js +39 -0
  95. package/dist/tools/fiori/tools.js +356 -0
  96. package/dist/tools/fiori/types.js +1 -0
  97. package/dist/tools/local-build.js +76 -0
  98. package/dist/tools/local-files.js +31 -0
  99. package/dist/tools/project/_merge-shared.js +68 -0
  100. package/dist/tools/project/cca_merge.js +164 -0
  101. package/dist/tools/project/playbook_get.js +1 -1
  102. package/dist/tools/project/upgrade_merge_progress.js +206 -0
  103. package/dist/tools/sap-read.js +53 -9
  104. package/dist/tools/sap-write.js +530 -21
  105. package/dist/tools/shell/shell_exec.js +41 -6
  106. package/dist/tools/snapshot.js +37 -14
  107. package/dist/tools/subagent/background_run.js +17 -1
  108. package/dist/tools/transport-resolution.js +86 -0
  109. package/dist/tools/transport.js +224 -5
  110. package/dist/tools/write-mode.js +4 -0
  111. package/dist/ui/app.js +6 -2
  112. package/dist/ui/body.js +13 -0
  113. package/dist/ui/footer.js +20 -6
  114. package/dist/ui/line-resolution.js +17 -6
  115. package/dist/ui/session-timeline.js +1 -0
  116. package/dist/ui/text-input.js +150 -0
  117. package/dist/ui/widgets/ask-question-modal.js +4 -1
  118. package/package.json +19 -3
  119. package/bench/README.md +0 -78
  120. package/bench/prompts/abap-document-cds.md +0 -44
  121. package/bench/prompts/abap-explain-bdef-handler.md +0 -57
  122. package/bench/prompts/abap-test-method.md +0 -42
  123. package/bench/results/abap-document-cds/claude-haiku-4-5.md +0 -189
  124. package/bench/results/abap-document-cds/claude-opus-4-7.md +0 -120
  125. package/bench/results/abap-document-cds/claude-sonnet-4-6.md +0 -151
  126. package/bench/results/abap-explain-bdef-handler/claude-haiku-4-5.md +0 -112
  127. package/bench/results/abap-explain-bdef-handler/claude-opus-4-7.md +0 -101
  128. package/bench/results/abap-explain-bdef-handler/claude-sonnet-4-6.md +0 -101
  129. package/bench/results/abap-test-method/claude-haiku-4-5.md +0 -186
  130. package/bench/results/abap-test-method/claude-opus-4-7.md +0 -193
  131. package/bench/results/abap-test-method/claude-sonnet-4-6.md +0 -234
package/dist/repl.js CHANGED
@@ -10,7 +10,7 @@ import * as fs from 'node:fs';
10
10
  import * as path from 'node:path';
11
11
  import * as os from 'node:os';
12
12
  import { select, password as promptPassword } from '@inquirer/prompts';
13
- import { loadConfig } from './config/loader.js';
13
+ import { loadConfig, resolveLocalBuild } from './config/loader.js';
14
14
  import { getStore } from './auth/api-key.js';
15
15
  import { readAuthFile } from './auth/auth-file.js';
16
16
  import { fetchMe } from './auth/me.js';
@@ -23,21 +23,22 @@ import { makeSessionId, saveSession } from './session/store.js';
23
23
  import { newSession } from './session/schema.js';
24
24
  import { computeCost, formatCost } from './cost/pricing.js';
25
25
  import { createProviderForMode } from './agent/providers/factory.js';
26
+ import { assertModeLicensed, CspeachLicenseError } from './agent/providers/license-gate.js';
26
27
  import { runTurn } from './agent/loop.js';
27
28
  import { renderTurnError } from './agent/turn-error-ux.js';
28
- import { classifyPrompt, parseShortcut } from './router/classifier.js';
29
+ import { classifyPrompt, parseShortcut, rejectUnknownSkill } from './router/classifier.js';
29
30
  import { decideRouting } from './router/routing-decision.js';
30
31
  import { coachingPickerClassic } from './ui/coaching-picker-classic.js';
31
32
  import { coachingPickerEmitter } from './ui/coaching-picker-emitter.js';
32
33
  import { globalStore as sapStateStore } from './ui/sap-state-store.js';
33
34
  import { PICK_CANCELLED } from './ui/skill-picker.js';
34
35
  import { registerReadline, withInquirer } from './repl/inquirer-guard.js';
35
- import { maybePromptWorkspaceMigration } from './projects/migration.js';
36
+ import { maybePromptWorkspaceMigration, maybeOfferAbapforgeMigration } from './projects/migration.js';
37
+ import { findProjectRoot } from './projects/workspace.js';
36
38
  import { slashCompleter } from './repl/slash-completer.js';
37
39
  import { openSlashPicker, isExactSlashMatch } from './repl/slash-picker.js';
38
40
  import { openAtPicker, pendingAtTokenPrefix } from './repl/at-picker.js';
39
41
  import { atCompleter } from './repl/at-completer.js';
40
- import { isPlanResumeCommand } from './commands/plan-resume.js';
41
42
  /**
42
43
  * Pre-fill the readline buffer with `text`, cursor at end, and trigger
43
44
  * a redraw. Bypasses rl.write() — which simulates per-character
@@ -61,14 +62,19 @@ function prefillReadline(rl, text) {
61
62
  import { BracketedPasteDecoder, enableBracketedPaste, disableBracketedPaste, } from './repl/bracketed-paste.js';
62
63
  import { printHelp } from './commands/help.js';
63
64
  import { shouldUseInk, setRenderingOverride } from './renderer/tty.js';
65
+ import { createClassicOutputEmitter } from './renderer/notices.js';
64
66
  import { saveConfig } from './config/loader.js';
65
67
  import { EventEmitter } from 'node:events';
66
68
  import { parseAndValidateReroute } from './commands/reroute.js';
67
69
  import { SKILL_CATALOG } from './skill-catalog.js';
68
70
  import { replPreviewHook } from './repl/update-method-preview-hook.js';
69
71
  import { getCurrentTransport, handleTransportCommand } from './repl/current-transport.js';
72
+ import { matchSafetyModeCommand } from './repl/safety-mode-state.js';
73
+ import { maybeExpandNumberSelection, lastAssistantText } from './repl/numbered-menu.js';
74
+ import { attachEarlyLineBuffer, noopEarlyLineBuffer } from './repl/early-line-buffer.js';
70
75
  import { snapshotTurnStart, pushPostTurnStatus } from './repl/post-turn-status.js';
71
76
  import { looksLikeCspeachShellCommand, CSPEACH_SHELL_NUDGE } from './repl/cspeach-shell-detect.js';
77
+ import { applyLocalBuildConfig } from './tools/local-build.js';
72
78
  // Side-effect imports so tool registrations happen before listTools() is called.
73
79
  import './tools/sap-read.js';
74
80
  import './tools/sap-write.js';
@@ -78,16 +84,22 @@ import './tools/snapshot.js';
78
84
  import './tools/ask-question.js';
79
85
  import './tools/dispatch-skill.js';
80
86
  import './tools/filesystem/file-read.js';
87
+ import './tools/filesystem/read-document.js';
81
88
  import './tools/filesystem/file-edit.js';
82
89
  import './tools/filesystem/file-write.js';
83
90
  import './tools/filesystem/glob.js';
84
91
  import './tools/filesystem/grep.js';
85
92
  import './tools/shell/shell_exec.js';
93
+ import './tools/fiori/tools.js';
94
+ import './tools/extend-model/tool.js';
95
+ import './tools/capability/tool.js';
86
96
  import './tools/web/web_fetch.js';
87
97
  import './tools/web/web_search.js';
88
98
  import './tools/project/project_context_get.js';
89
99
  import './tools/project/playbook_get.js';
90
100
  import './tools/project/convention_get.js';
101
+ import './tools/project/cca_merge.js';
102
+ import './tools/project/upgrade_merge_progress.js';
91
103
  import './tools/subagent/_background-shared.js';
92
104
  import './tools/subagent/background_run.js';
93
105
  import './tools/subagent/monitor_emit.js';
@@ -253,7 +265,7 @@ function isBareAck(trimmed) {
253
265
  * instead of having to arrow to the right item.
254
266
  */
255
267
  const PICKER_FALLBACK_SKILLS = [
256
- 'abap-explain', 'abap-review', 'abap-document', 'abap-rap', 'abap-radar',
268
+ 'abap-explain', 'abap-review', 'abap-handover', 'abap-rap', 'abap-impact',
257
269
  ];
258
270
  const MIN_PICKER_OPTIONS = 3;
259
271
  function buildCoachingParams(result, prompt, preSelect, lastSkill) {
@@ -377,6 +389,18 @@ export async function runRepl(opts) {
377
389
  else {
378
390
  console.log(chalk.gray(`Write mode: ${cfg.write_mode}\n`));
379
391
  }
392
+ // local_build toggle — when persisted-on, force-enable the flag-gated
393
+ // filesystem tools AND shell_exec BEFORE the first listTools()/turn (listTools
394
+ // reads flag state live per turn). Resolve with backward compat: a stale
395
+ // legacy `local_files = on` still applies via resolveLocalBuild. Announce
396
+ // visibly: the toggle is persisted, not re-consented each session, so the user
397
+ // must see that local file access + command execution are live this session.
398
+ // Printed to stdout here (before any Ink frame mounts).
399
+ {
400
+ const announce = applyLocalBuildConfig(resolveLocalBuild(cfg), process.cwd());
401
+ if (announce)
402
+ console.log(chalk.dim(announce + '\n'));
403
+ }
380
404
  const aliases = Object.keys(cfg.sap);
381
405
  // C.1 — --sap-alias flag override skips the interactive picker
382
406
  let selectedAlias;
@@ -416,6 +440,19 @@ export async function runRepl(opts) {
416
440
  if (!opts?.resumedSession) {
417
441
  await saveSession(session);
418
442
  }
443
+ // License gate: non-managed modes (byok/local/ai-hub) bypass our proxy for
444
+ // inference, so without this anyone who never logged in could use the bundled
445
+ // skills untracked. Require a valid CSPeach login before the REPL starts.
446
+ try {
447
+ await assertModeLicensed(cfg.llm.mode, async () => (await store.get()) ?? '');
448
+ }
449
+ catch (e) {
450
+ if (e instanceof CspeachLicenseError) {
451
+ console.error(chalk.yellow(`\n${e.message}\n`));
452
+ process.exit(1);
453
+ }
454
+ throw e;
455
+ }
419
456
  const provider = createProviderForMode(cfg, async () => {
420
457
  const key = await store.get();
421
458
  if (!key)
@@ -672,7 +709,40 @@ export async function runRepl(opts) {
672
709
  chunkEmitter.emit('chunk', chalk.cyan(`\n${msg}\n`));
673
710
  return;
674
711
  }
675
- const parsed = parseShortcut(trimmed);
712
+ // /safety-mode on|off — structured Forge Rule confirmation cards.
713
+ // UX-5 #1 (2026-06-12): handleSafetyModeCommand existed (unit-tested)
714
+ // but no REPL path ever routed to it — the toggle was unreachable at
715
+ // runtime. Mirrors the /transport dispatch shape above.
716
+ {
717
+ const msg = matchSafetyModeCommand(trimmed);
718
+ if (msg !== null) {
719
+ chunkEmitter.emit('chunk', chalk.cyan(`\n${msg}\n`));
720
+ return;
721
+ }
722
+ }
723
+ // UX-5 #2 (2026-06-12) — numbered-menu expansion. When the model
724
+ // ended the previous turn with a plain-text numbered menu and the
725
+ // user types a bare `2`, expand the input to that item's text (with
726
+ // a dim echo) so the turn carries real intent instead of a lone
727
+ // digit. Conservative: fires only on pure 1–2 digit input matching
728
+ // an item in a 2+-item list in the LAST assistant message.
729
+ let effectiveInput = trimmed;
730
+ {
731
+ const expansion = maybeExpandNumberSelection(trimmed, lastAssistantText(session.messages));
732
+ if (expansion) {
733
+ chunkEmitter.emit('chunk', chalk.dim(`${expansion.echo}\n`));
734
+ effectiveInput = expansion.text;
735
+ }
736
+ }
737
+ const parsed = parseShortcut(effectiveInput);
738
+ // D35 (2026-06-11) — explicit slash naming a skill that isn't in the
739
+ // catalog: print the notice ("did you mean" / builtin-with-args) and
740
+ // end the turn. Slash input must NEVER fall through to the classifier
741
+ // (classifyPrompt asserts on it) and must never dispatch to a
742
+ // nonexistent skill. Shared gate: rejectUnknownSkill in classifier.ts.
743
+ if (rejectUnknownSkill(parsed, (m) => chunkEmitter.emit('chunk', chalk.yellow(`\n${m}\n`)))) {
744
+ return;
745
+ }
676
746
  // Record the prose body (post-shortcut stripping) for /reroute. Using
677
747
  // `parsed.prompt` rather than `trimmed` means:
678
748
  // - plain prose: `parsed.prompt === trimmed`, stored as-is
@@ -806,6 +876,8 @@ export async function runRepl(opts) {
806
876
  // persists the version-N+1 REVISION after the turn.
807
877
  let planResume = null;
808
878
  let planResumeMsgStart = 0;
879
+ // A4 — set only when plan model tiering picks Sonnet for this phase.
880
+ let planModelOverride;
809
881
  if (skill === 'abap-plan' && /(^|\s)--resume(\s|$)/.test(prompt)) {
810
882
  try {
811
883
  const { preparePlanResume } = await import('./commands/plan-resume.js');
@@ -823,13 +895,45 @@ export async function runRepl(opts) {
823
895
  session.last_object = extractedObject ?? previousObject ?? null;
824
896
  return;
825
897
  }
898
+ // A1 (defect D23) — true bounded phases: EVERY plan-resume turn
899
+ // (harness-dispatched or manually typed) starts from reset session
900
+ // messages. The envelope + bounded prompt carry all needed state;
901
+ // prior transcript is pure context cost (a 6-phase run once
902
+ // snowballed to a 3.24M-token cache read per call).
903
+ {
904
+ const { resetContextForPlanResume } = await import('./commands/plan-resume.js');
905
+ if (resetContextForPlanResume(session)) {
906
+ chunkEmitter.emit('chunk', chalk.dim('↪ Plan phase starts in a fresh bounded context — prior transcript cleared\n'));
907
+ }
908
+ }
826
909
  planResumeMsgStart = session.messages.length;
827
910
  prompt = planResume.llmPrompt;
911
+ // A4 (2026-06-11) — flag-gated per-phase model tiering: design/test/
912
+ // document-shaped phases (structural signal: the phase's delegateTo
913
+ // skill) run on Sonnet; everything else — and the flag-off default —
914
+ // stays on cfg.default_model. The dim notice keeps cost attribution
915
+ // auditable next to the cost-log line that records the same model.
916
+ {
917
+ const { isPlanModelTieringEnabled, selectPlanPhaseModel } = await import('./commands/plan-model-tier.js');
918
+ const choice = selectPlanPhaseModel({
919
+ enabled: isPlanModelTieringEnabled(cfg),
920
+ phaseId: planResume.nextPhaseId,
921
+ delegateTo: planResume.nextDelegateTo,
922
+ defaultModel: cfg.default_model,
923
+ });
924
+ if (choice.model) {
925
+ planModelOverride = choice.model;
926
+ chunkEmitter.emit('chunk', chalk.dim(`${choice.notice}\n`));
927
+ }
928
+ }
828
929
  }
829
930
  // 2026-06-08 — manifest-on-interrupt. Set true once the mid-turn
830
931
  // onPlanManifest hook has persisted the revision, so the post-turn
831
932
  // finishPlanResume below doesn't write it a second time.
832
933
  let planSavedEarly = false;
934
+ // A1 — outcome of the persisted revision (exact saved path + the
935
+ // harness-built resume command). Drives the post-turn next-phase offer.
936
+ let planOutcome = null;
833
937
  const planResumeForHook = planResume;
834
938
  // Phase A** wiring fix (2026-05-16) — snapshot pre-turn counters so
835
939
  // pushPostTurnStatus can compute per-turn deltas. The classic-mode
@@ -858,13 +962,15 @@ export async function runRepl(opts) {
858
962
  chunkEmitter,
859
963
  signal: currentTurnAbort.signal,
860
964
  suppressSaveHook: planResume !== null,
965
+ // A4 — undefined on every non-plan turn (and when tiering is off).
966
+ modelOverride: planModelOverride,
861
967
  // Persist the plan revision the instant its manifest is finalised
862
968
  // (before the continuation modal) so a Ctrl+C can't lose the phase.
863
969
  onPlanManifest: planResumeForHook
864
970
  ? async (assistantText) => {
865
971
  try {
866
972
  const { finishPlanResume } = await import('./commands/plan-resume.js');
867
- await finishPlanResume({
973
+ planOutcome = await finishPlanResume({
868
974
  prepared: planResumeForHook,
869
975
  messages: session.messages,
870
976
  messagesStart: planResumeMsgStart,
@@ -903,7 +1009,7 @@ export async function runRepl(opts) {
903
1009
  if (planResume && !planSavedEarly) {
904
1010
  try {
905
1011
  const { finishPlanResume } = await import('./commands/plan-resume.js');
906
- await finishPlanResume({
1012
+ planOutcome = await finishPlanResume({
907
1013
  prepared: planResume,
908
1014
  messages: session.messages,
909
1015
  messagesStart: planResumeMsgStart,
@@ -914,29 +1020,53 @@ export async function runRepl(opts) {
914
1020
  chunkEmitter.emit('chunk', chalk.red(`\n[plan] revision save failed: ${err.message}\n`));
915
1021
  }
916
1022
  }
917
- pushPostTurnStatus({ session, selectedAlias, snapshot: turnSnapshot, chunkEmitter });
1023
+ // A1 (defect D23) — harness-owned continuation. The model's turn ended
1024
+ // at the manifest; if the persisted revision has a next eligible phase,
1025
+ // the HARNESS asks for consent (Rule 8) and queues the exact
1026
+ // `/abap-plan --resume @<saved-file>` it just built — the model never
1027
+ // authors this command. The pendingDispatch consumer below re-enters
1028
+ // it, and the resume turn itself resets session messages.
1029
+ if (planResume && planOutcome?.nextPhaseId) {
1030
+ try {
1031
+ const { offerNextPhaseAutoRun } = await import('./commands/plan-resume.js');
1032
+ const { inkAwarePrompt } = await import('./agent/loop.js');
1033
+ await offerNextPhaseAutoRun({
1034
+ outcome: planOutcome,
1035
+ prompt: inkAwarePrompt,
1036
+ queueDispatch: (cmd) => pendingDispatchAccessor.set(cmd),
1037
+ log: (...lines) => chunkEmitter.emit('chunk', lines.join('\n') + '\n'),
1038
+ });
1039
+ }
1040
+ catch (err) {
1041
+ chunkEmitter.emit('chunk', chalk.red(`\n[plan] next-phase offer failed: ${err.message}\n`));
1042
+ }
1043
+ }
1044
+ // A4 — turn-cost line must price at the model that actually ran the
1045
+ // turn (planModelOverride on tiered plan phases), not session.model.
1046
+ pushPostTurnStatus({
1047
+ session,
1048
+ selectedAlias,
1049
+ snapshot: turnSnapshot,
1050
+ chunkEmitter,
1051
+ turnModel: planModelOverride ?? session.model,
1052
+ });
918
1053
  session.last_skill = previousSkill ?? null;
919
1054
  session.last_object = extractedObject ?? previousObject ?? null;
920
1055
  // 2026-05-08: post-turn auto-dispatch. The dispatch_skill tool may
921
1056
  // have queued a slash command (typically after the user picked
922
- // "Switch to /abap-X" via ask_question). Consume the slot and
923
- // re-enter handleSubmit with that command — no user keypress.
924
- // The user's consent was the picker pick.
1057
+ // "Switch to /abap-X" via ask_question), or — A1 — the harness
1058
+ // next-phase offer above queued the plan-resume it built itself.
1059
+ // Consume the slot and re-enter handleSubmit with that command —
1060
+ // no user keypress. The user's consent was the picker pick / y answer.
925
1061
  const dispatched = pendingDispatchAccessor.get();
926
1062
  if (dispatched) {
927
1063
  pendingDispatchAccessor.set(null);
928
- // Auto-fire next phase: a plan resume rebuilds its bounded prompt from
929
- // the envelope, so clear the finished phase's transcript first — the
930
- // next phase then costs exactly what a manual fresh resume costs.
931
- // (The phase is already saved via manifest-on-interrupt, so nothing is
932
- // lost; prior prose is mirrored to ~/.cspeach/streams/.)
933
- if (isPlanResumeCommand(dispatched)) {
934
- session.messages = [];
935
- chunkEmitter.emit('chunk', chalk.dim('↪ Auto-running next phase in fresh context\n'));
936
- }
937
- else {
938
- chunkEmitter.emit('chunk', chalk.dim(`↪ Auto-routed: ${dispatched}\n`));
939
- }
1064
+ // No context clear here: resetContextForPlanResume (called in the
1065
+ // plan-resume prepare branch of handleSubmit) is the single owner of
1066
+ // the transcript reset — it runs for typed AND dispatched resumes,
1067
+ // and only AFTER preparePlanResume succeeds, so a failed prepare
1068
+ // never destroys the session context.
1069
+ chunkEmitter.emit('chunk', chalk.dim(`↪ Auto-routed: ${dispatched}\n`));
940
1070
  // Schedule on next tick so the current handleSubmit's promise
941
1071
  // resolves cleanly before the next one starts. Recursion via
942
1072
  // setImmediate keeps the call stack flat across long chains.
@@ -1038,6 +1168,25 @@ export async function runRepl(opts) {
1038
1168
  // Disable bracketed paste on shutdown so the user's shell prompt after
1039
1169
  // CSPeach exits doesn't keep receiving wrapped pastes.
1040
1170
  process.on('exit', () => disableBracketedPaste());
1171
+ // UX-5 #5 (2026-06-12) — piped-stdin support. Lines piped into the REPL
1172
+ // (`"/cost`n/exit" | cspeach`) arrive BEFORE the loop's first rl.question
1173
+ // is pending; readline emits them as 'line' EVENTS that nobody consumed,
1174
+ // so every line was silently dropped and EOF hard-exited the process.
1175
+ // The buffer queues those early lines (with a BOM strip on the first —
1176
+ // PowerShell pipes inject one); the loop below drains the queue before
1177
+ // calling rl.question, and EOF only ends the session after the queue is
1178
+ // empty. Attached ONLY for non-TTY stdin: interactive type-ahead during
1179
+ // a turn keeps its existing semantics.
1180
+ const pipedStdin = !process.stdin.isTTY;
1181
+ const earlyLines = pipedStdin ? attachEarlyLineBuffer(rl) : noopEarlyLineBuffer();
1182
+ // B2 fix (2026-06-12) — classic-mode notice channel. Tool handlers emit
1183
+ // 'warn'/'info' (and pre-formatted 'chunk' lines like the write verify
1184
+ // chain) on ctx.chunkEmitter; in classic mode that slot used to be
1185
+ // undefined, so every Rule 9 transport notice was silently dropped.
1186
+ // This emitter renders them straight to stdout. It is intentionally NOT
1187
+ // passed as RunTurnParams.chunkEmitter — the loop's render functions
1188
+ // keep their direct-stdout classic path (spinner stays enabled).
1189
+ const classicOutputEmitter = createClassicOutputEmitter();
1041
1190
  // Prompt string: 'CSPeach ' + '›' in peach colour
1042
1191
  const promptStr = 'CSPeach ' + chalk.hex(PEACH)('›') + ' ';
1043
1192
  // When we dispatch an inquirer sub-prompt (coaching picker, pickSkill),
@@ -1084,6 +1233,11 @@ export async function runRepl(opts) {
1084
1233
  return;
1085
1234
  if (turnInProgress)
1086
1235
  return; // turn handler owns the abort + UX
1236
+ // UX-5 #5 — piped stdin: EOF must NOT hard-exit while queued lines are
1237
+ // still unprocessed (or while a builtin like /cost is mid-flight). The
1238
+ // loop drains the queue and owns shutdown via earlyLines.closed().
1239
+ if (pipedStdin)
1240
+ return;
1087
1241
  console.log('\nGoodbye.\n');
1088
1242
  process.exit(0);
1089
1243
  });
@@ -1255,6 +1409,23 @@ export async function runRepl(opts) {
1255
1409
  })),
1256
1410
  log: (line) => console.log(chalk.dim(line)),
1257
1411
  });
1412
+ // 2026-06-15: one-time output-namespace migration. The customer-output
1413
+ // root was renamed from the dead-brand `.abapforge/` to `.cspeach/`.
1414
+ // Workspaces that still have a top-level `.abapforge/` get offered a
1415
+ // one-time merge-move into `.cspeach/`. The dual-read resolver keeps old
1416
+ // projects working if the user declines; "never" suppresses future offers.
1417
+ await maybeOfferAbapforgeMigration(findProjectRoot(process.cwd()) ?? process.cwd(), {
1418
+ prompt: async ({ legacyDir, newDir }) => withInquirer(() => select({
1419
+ message: `Found legacy CSPeach outputs in ${legacyDir}. The output folder is now '.cspeach/'. Move them?`,
1420
+ default: 'move',
1421
+ choices: [
1422
+ { value: 'move', name: `Move everything into ${newDir} (recommended)` },
1423
+ { value: 'skip', name: `Not now — ask me again next time` },
1424
+ { value: 'never', name: `Leave it and never ask again` },
1425
+ ],
1426
+ })),
1427
+ log: (line) => console.log(chalk.dim(line)),
1428
+ });
1258
1429
  while (true) {
1259
1430
  // Re-attach the bare-Esc detector every iteration. Inquirer prompts
1260
1431
  // (slash-picker, approval, ask_question, safety-confirm) remove
@@ -1270,15 +1441,27 @@ export async function runRepl(opts) {
1270
1441
  const dispatchedCmd = pendingDispatchAccessor.get();
1271
1442
  if (dispatchedCmd) {
1272
1443
  pendingDispatchAccessor.set(null);
1273
- if (isPlanResumeCommand(dispatchedCmd)) {
1274
- session.messages = [];
1275
- console.log(chalk.dim('↪ Auto-running next phase in fresh context'));
1276
- }
1277
- else {
1278
- console.log(chalk.dim(`↪ Auto-routed: ${dispatchedCmd}`));
1279
- }
1444
+ // No context clear here: resetContextForPlanResume (called in the
1445
+ // plan-resume prepare branch below) is the single owner of the
1446
+ // transcript reset — it runs for typed AND dispatched resumes, and
1447
+ // only AFTER preparePlanResume succeeds, so a failed prepare never
1448
+ // destroys the session context.
1449
+ console.log(chalk.dim(`↪ Auto-routed: ${dispatchedCmd}`));
1280
1450
  line = dispatchedCmd;
1281
1451
  }
1452
+ else if (earlyLines.size() > 0) {
1453
+ // UX-5 #5 — drain piped lines queued while no question was pending.
1454
+ // Echo with the prompt string so transcripts/CI logs show what ran.
1455
+ line = earlyLines.shift();
1456
+ console.log(promptStr + line);
1457
+ }
1458
+ else if (earlyLines.closed()) {
1459
+ // Piped stdin hit EOF and every queued line has been processed —
1460
+ // end the session the same way /exit does.
1461
+ printGoodbye(session);
1462
+ rl.close();
1463
+ return 0;
1464
+ }
1282
1465
  else {
1283
1466
  const effectivePrompt = pendingPrefill ? promptStr + pendingPrefill : promptStr;
1284
1467
  try {
@@ -1296,6 +1479,11 @@ export async function runRepl(opts) {
1296
1479
  // discriminates the two cases reliably.
1297
1480
  const rlAny = rl;
1298
1481
  if (rlAny.closed) {
1482
+ // UX-5 #5 — piped stdin: a multi-line chunk can resolve the
1483
+ // pending question with line 1 and QUEUE line 2+ before EOF
1484
+ // closes the readline. Drain the queue before exiting.
1485
+ if (earlyLines.size() > 0)
1486
+ continue;
1299
1487
  console.log('\nGoodbye.\n');
1300
1488
  rl.close();
1301
1489
  return 0;
@@ -1480,7 +1668,7 @@ export async function runRepl(opts) {
1480
1668
  provider,
1481
1669
  userMessage: rr.prompt,
1482
1670
  skill: rr.skill,
1483
- ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: replPreviewHook, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor },
1671
+ ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: replPreviewHook, chunkEmitter: classicOutputEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor },
1484
1672
  });
1485
1673
  continue;
1486
1674
  }
@@ -1498,6 +1686,15 @@ export async function runRepl(opts) {
1498
1686
  console.log(chalk.cyan(msg));
1499
1687
  continue;
1500
1688
  }
1689
+ // /safety-mode on|off — structured Forge Rule confirmation cards.
1690
+ // UX-5 #1 (2026-06-12): classic-path mirror of the Ink dispatch above.
1691
+ {
1692
+ const msg = matchSafetyModeCommand(trimmed);
1693
+ if (msg !== null) {
1694
+ console.log(chalk.cyan(msg));
1695
+ continue;
1696
+ }
1697
+ }
1501
1698
  // 2026-05-01: live slash-command picker. When the user types something
1502
1699
  // that LOOKS like a slash command but isn't an exact match — `/`,
1503
1700
  // `/abap-r`, `/fix`, etc. — open a search-as-you-type picker
@@ -1616,7 +1813,25 @@ export async function runRepl(opts) {
1616
1813
  }
1617
1814
  // Any non-empty submit clears the empty-prefill latch.
1618
1815
  lastEmptyPrefill = null;
1816
+ // UX-5 #2 (2026-06-12) — numbered-menu expansion (classic mirror of the
1817
+ // Ink block above). Bare `2` after a plain-text numbered menu expands
1818
+ // to that item's text with a dim echo; everything else passes through.
1819
+ {
1820
+ const expansion = maybeExpandNumberSelection(resolvedTrimmed, lastAssistantText(session.messages));
1821
+ if (expansion) {
1822
+ console.log(chalk.dim(expansion.echo));
1823
+ resolvedTrimmed = expansion.text;
1824
+ }
1825
+ }
1619
1826
  const parsed = parseShortcut(resolvedTrimmed);
1827
+ // D35 (2026-06-11) — explicit slash naming a skill that isn't in the
1828
+ // catalog: print the notice ("did you mean" / builtin-with-args) and
1829
+ // end the turn. Slash input must NEVER fall through to the classifier
1830
+ // (classifyPrompt asserts on it) and must never dispatch to a
1831
+ // nonexistent skill. Shared gate: rejectUnknownSkill in classifier.ts.
1832
+ if (rejectUnknownSkill(parsed, (m) => console.log(chalk.yellow(`\n${m}\n`)))) {
1833
+ continue;
1834
+ }
1620
1835
  // Record the prose body (post-shortcut stripping) for /reroute. Using
1621
1836
  // `parsed.prompt` rather than `trimmed` means:
1622
1837
  // - plain prose: `parsed.prompt === trimmed`, stored as-is
@@ -1743,6 +1958,8 @@ export async function runRepl(opts) {
1743
1958
  // envelope revision after the turn.
1744
1959
  let planResume = null;
1745
1960
  let planResumeMsgStart = 0;
1961
+ // A4 — set only when plan model tiering picks Sonnet for this phase.
1962
+ let planModelOverride;
1746
1963
  if (skill === 'abap-plan' && /(^|\s)--resume(\s|$)/.test(prompt)) {
1747
1964
  try {
1748
1965
  const { preparePlanResume } = await import('./commands/plan-resume.js');
@@ -1760,12 +1977,37 @@ export async function runRepl(opts) {
1760
1977
  session.last_object = extractedObject ?? previousObject ?? null;
1761
1978
  continue;
1762
1979
  }
1980
+ // A1 (defect D23) — see the Ink path: every plan-resume turn starts
1981
+ // from reset session messages (true bounded phases).
1982
+ {
1983
+ const { resetContextForPlanResume } = await import('./commands/plan-resume.js');
1984
+ if (resetContextForPlanResume(session)) {
1985
+ console.log(chalk.dim('↪ Plan phase starts in a fresh bounded context — prior transcript cleared'));
1986
+ }
1987
+ }
1763
1988
  planResumeMsgStart = session.messages.length;
1764
1989
  prompt = planResume.llmPrompt;
1990
+ // A4 — flag-gated per-phase model tiering (classic-mode mirror of the
1991
+ // Ink path above; see that block for the design note).
1992
+ {
1993
+ const { isPlanModelTieringEnabled, selectPlanPhaseModel } = await import('./commands/plan-model-tier.js');
1994
+ const choice = selectPlanPhaseModel({
1995
+ enabled: isPlanModelTieringEnabled(cfg),
1996
+ phaseId: planResume.nextPhaseId,
1997
+ delegateTo: planResume.nextDelegateTo,
1998
+ defaultModel: cfg.default_model,
1999
+ });
2000
+ if (choice.model) {
2001
+ planModelOverride = choice.model;
2002
+ console.log(chalk.dim(choice.notice));
2003
+ }
2004
+ }
1765
2005
  }
1766
2006
  // 2026-06-08 — manifest-on-interrupt (classic path). See the Ink path for
1767
2007
  // the full rationale; persist the revision mid-turn so a Ctrl+C can't lose it.
1768
2008
  let planSavedEarly = false;
2009
+ // A1 — persisted-revision outcome; drives the post-turn next-phase offer.
2010
+ let planOutcome = null;
1769
2011
  const planResumeForHook = planResume;
1770
2012
  const turnStart = Date.now();
1771
2013
  // H1 — `session.usage` is session-LIFETIME (initialised once in
@@ -1819,14 +2061,16 @@ export async function runRepl(opts) {
1819
2061
  provider,
1820
2062
  userMessage: prompt,
1821
2063
  skill: skill,
1822
- ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: replPreviewHook, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor },
2064
+ ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: replPreviewHook, chunkEmitter: classicOutputEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor },
1823
2065
  signal: turnAbort.signal,
1824
2066
  suppressSaveHook: planResume !== null,
2067
+ // A4 — undefined on every non-plan turn (and when tiering is off).
2068
+ modelOverride: planModelOverride,
1825
2069
  onPlanManifest: planResumeForHook
1826
2070
  ? async (assistantText) => {
1827
2071
  try {
1828
2072
  const { finishPlanResume } = await import('./commands/plan-resume.js');
1829
- await finishPlanResume({
2073
+ planOutcome = await finishPlanResume({
1830
2074
  prepared: planResumeForHook,
1831
2075
  messages: session.messages,
1832
2076
  messagesStart: planResumeMsgStart,
@@ -1865,7 +2109,7 @@ export async function runRepl(opts) {
1865
2109
  if (planResume && !planSavedEarly) {
1866
2110
  try {
1867
2111
  const { finishPlanResume } = await import('./commands/plan-resume.js');
1868
- await finishPlanResume({
2112
+ planOutcome = await finishPlanResume({
1869
2113
  prepared: planResume,
1870
2114
  messages: session.messages,
1871
2115
  messagesStart: planResumeMsgStart,
@@ -1876,6 +2120,25 @@ export async function runRepl(opts) {
1876
2120
  console.log(chalk.red(`\n[plan] revision save failed: ${err.message}\n`));
1877
2121
  }
1878
2122
  }
2123
+ // A1 (defect D23) — harness-owned continuation (classic-path mirror of
2124
+ // the Ink block; see there for the design note). Consent via inquirer,
2125
+ // exact command from the saved path, dispatched through the same
2126
+ // pendingDispatch slot the pre-prompt consumer reads.
2127
+ if (planResume && planOutcome?.nextPhaseId) {
2128
+ try {
2129
+ const { offerNextPhaseAutoRun } = await import('./commands/plan-resume.js');
2130
+ const { inkAwarePrompt } = await import('./agent/loop.js');
2131
+ await offerNextPhaseAutoRun({
2132
+ outcome: planOutcome,
2133
+ prompt: inkAwarePrompt,
2134
+ queueDispatch: (cmd) => pendingDispatchAccessor.set(cmd),
2135
+ log: (...lines) => lines.forEach((l) => console.log(l)),
2136
+ });
2137
+ }
2138
+ catch (err) {
2139
+ console.log(chalk.red(`\n[plan] next-phase offer failed: ${err.message}\n`));
2140
+ }
2141
+ }
1879
2142
  // Status footer after each turn.
1880
2143
  // tool_result messages also have role:'user' but content is an array —
1881
2144
  // filter them out so we count actual user prompts, not tool rounds.
@@ -1896,6 +2159,9 @@ export async function runRepl(opts) {
1896
2159
  cacheCreate: turnStartCostSnapshot.cacheCreate,
1897
2160
  startedAt: turnStart,
1898
2161
  },
2162
+ // A4 — price the turn at the model that actually ran it (classic-path
2163
+ // mirror of the Ink call site above).
2164
+ turnModel: planModelOverride ?? session.model,
1899
2165
  });
1900
2166
  // Post-turn roll-forward (Step 7c.2 / 7c.5) — the skill from the turn that
1901
2167
  // JUST finished becomes `last_skill` for the next turn's classifier context.