@cspeach/cli 0.8.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 (141) hide show
  1. package/README.md +1 -1
  2. package/dist/agent/intent-system-prompt.js +1 -1
  3. package/dist/agent/loop.js +330 -36
  4. package/dist/agent/providers/license-gate.js +44 -0
  5. package/dist/agent/skill-checkpoint.js +1 -1
  6. package/dist/agent/steering-queue.js +27 -0
  7. package/dist/agent/tool-dispatch.js +15 -0
  8. package/dist/approvals/canonical.js +91 -0
  9. package/dist/approvals/jwt.js +84 -7
  10. package/dist/approvals/render.js +38 -0
  11. package/dist/auth/org-anthropic-key.js +25 -0
  12. package/dist/classifier/client.js +24 -5
  13. package/dist/commands/config-set.js +95 -0
  14. package/dist/commands/login.js +31 -14
  15. package/dist/commands/plan-model-tier.js +83 -0
  16. package/dist/commands/plan-resume.js +435 -0
  17. package/dist/config/loader.js +102 -3
  18. package/dist/cost/pricing.js +14 -5
  19. package/dist/doctor/checks/_http-probe.js +1 -0
  20. package/dist/doctor/checks/cert.js +14 -3
  21. package/dist/doctor/checks/sap.js +30 -8
  22. package/dist/doctor/checks/zcspeach.js +19 -4
  23. package/dist/one-shot.js +52 -4
  24. package/dist/projects/answer-blockers.js +137 -0
  25. package/dist/projects/email-template.js +2 -0
  26. package/dist/projects/extract-cca.js +108 -16
  27. package/dist/projects/extract-modernize.js +1 -1
  28. package/dist/projects/extract-plan.js +178 -0
  29. package/dist/projects/extract-spec-gap.js +34 -7
  30. package/dist/projects/extract-test-coverage.js +1 -1
  31. package/dist/projects/extract-upgrade.js +113 -22
  32. package/dist/projects/index.js +7 -1
  33. package/dist/projects/merge-cca.js +292 -0
  34. package/dist/projects/merge-upgrade.js +173 -0
  35. package/dist/projects/migration.js +103 -1
  36. package/dist/projects/output-paths.js +27 -0
  37. package/dist/projects/plan-run.js +254 -0
  38. package/dist/projects/plan-schema.js +210 -0
  39. package/dist/projects/promote-command.js +26 -2
  40. package/dist/projects/promote.js +128 -0
  41. package/dist/projects/save-command.js +263 -20
  42. package/dist/projects/status.js +22 -0
  43. package/dist/projects/validate.js +2 -0
  44. package/dist/projects/workspace.js +164 -20
  45. package/dist/renderer/notices.js +64 -0
  46. package/dist/renderer/progress-chatter.js +8 -0
  47. package/dist/renderer/syntax.js +16 -1
  48. package/dist/renderer/thinking-heartbeat.js +13 -1
  49. package/dist/renderer/tool-widget.js +18 -4
  50. package/dist/renderer/tty.js +43 -4
  51. package/dist/renderer/verify-chain.js +77 -0
  52. package/dist/repl/at-picker.js +93 -21
  53. package/dist/repl/builtin-commands.js +37 -0
  54. package/dist/repl/early-line-buffer.js +68 -0
  55. package/dist/repl/inquirer-guard.js +70 -5
  56. package/dist/repl/numbered-menu.js +131 -0
  57. package/dist/repl/post-turn-status.js +2 -2
  58. package/dist/repl/rule8-detector.js +17 -2
  59. package/dist/repl/safety-confirm.js +111 -2
  60. package/dist/repl/safety-mode-state.js +19 -3
  61. package/dist/repl/slash-picker.js +25 -19
  62. package/dist/repl.js +470 -22
  63. package/dist/router/classifier.js +150 -6
  64. package/dist/sap/capability-matrix.js +20 -0
  65. package/dist/sap/capability-matrix.json +11236 -0
  66. package/dist/sap/capability.js +146 -0
  67. package/dist/sap/connection-manager.js +19 -1
  68. package/dist/sap/onboarding.js +42 -4
  69. package/dist/session/pending.js +27 -0
  70. package/dist/skill-catalog.js +54 -43
  71. package/dist/skills/bundled-skills.js +279 -1
  72. package/dist/skills/promotion-dispatch.js +23 -0
  73. package/dist/tools/_command-shared.js +36 -12
  74. package/dist/tools/_filesystem-shared.js +139 -4
  75. package/dist/tools/_flag.js +25 -0
  76. package/dist/tools/approval.js +64 -21
  77. package/dist/tools/ask-question.js +96 -4
  78. package/dist/tools/capability/tool.js +74 -0
  79. package/dist/tools/dispatch-skill.js +22 -1
  80. package/dist/tools/extend-model/anchored-insert.js +810 -0
  81. package/dist/tools/extend-model/tool.js +188 -0
  82. package/dist/tools/filesystem/extract-document.js +57 -0
  83. package/dist/tools/filesystem/file-edit.js +12 -2
  84. package/dist/tools/filesystem/file-read.js +2 -2
  85. package/dist/tools/filesystem/file-write.js +11 -2
  86. package/dist/tools/filesystem/glob.js +11 -0
  87. package/dist/tools/filesystem/grep.js +10 -0
  88. package/dist/tools/filesystem/read-document.js +107 -0
  89. package/dist/tools/fiori/apply.js +50 -0
  90. package/dist/tools/fiori/bin.js +3 -0
  91. package/dist/tools/fiori/catalog/index.js +27 -0
  92. package/dist/tools/fiori/catalog/value-help.js +230 -0
  93. package/dist/tools/fiori/catalog/viz-chart.js +177 -0
  94. package/dist/tools/fiori/cli.js +71 -0
  95. package/dist/tools/fiori/deploy-config.js +73 -0
  96. package/dist/tools/fiori/fe-scaffold.js +45 -0
  97. package/dist/tools/fiori/i18n.js +39 -0
  98. package/dist/tools/fiori/manifest.js +70 -0
  99. package/dist/tools/fiori/render.js +77 -0
  100. package/dist/tools/fiori/scaffold.js +39 -0
  101. package/dist/tools/fiori/tools.js +356 -0
  102. package/dist/tools/fiori/types.js +1 -0
  103. package/dist/tools/local-build.js +76 -0
  104. package/dist/tools/local-files.js +31 -0
  105. package/dist/tools/project/_merge-shared.js +68 -0
  106. package/dist/tools/project/cca_merge.js +164 -0
  107. package/dist/tools/project/playbook_get.js +1 -1
  108. package/dist/tools/project/upgrade_merge_progress.js +206 -0
  109. package/dist/tools/sap-read.js +53 -9
  110. package/dist/tools/sap-write.js +530 -21
  111. package/dist/tools/shell/shell_exec.js +41 -6
  112. package/dist/tools/snapshot.js +37 -14
  113. package/dist/tools/subagent/background_run.js +17 -1
  114. package/dist/tools/transport-resolution.js +86 -0
  115. package/dist/tools/transport.js +224 -5
  116. package/dist/tools/write-mode.js +4 -0
  117. package/dist/ui/app.js +84 -11
  118. package/dist/ui/body.js +13 -0
  119. package/dist/ui/command-palette.js +46 -10
  120. package/dist/ui/file-palette.js +44 -0
  121. package/dist/ui/footer.js +28 -11
  122. package/dist/ui/line-resolution.js +92 -0
  123. package/dist/ui/session-timeline.js +1 -0
  124. package/dist/ui/text-input.js +150 -0
  125. package/dist/ui/turn-status-emitter.js +52 -0
  126. package/dist/ui/turn-status.js +59 -0
  127. package/dist/ui/widgets/ask-question-modal.js +30 -2
  128. package/package.json +23 -4
  129. package/bench/README.md +0 -78
  130. package/bench/prompts/abap-document-cds.md +0 -44
  131. package/bench/prompts/abap-explain-bdef-handler.md +0 -57
  132. package/bench/prompts/abap-test-method.md +0 -42
  133. package/bench/results/abap-document-cds/claude-haiku-4-5.md +0 -189
  134. package/bench/results/abap-document-cds/claude-opus-4-7.md +0 -120
  135. package/bench/results/abap-document-cds/claude-sonnet-4-6.md +0 -151
  136. package/bench/results/abap-explain-bdef-handler/claude-haiku-4-5.md +0 -112
  137. package/bench/results/abap-explain-bdef-handler/claude-opus-4-7.md +0 -101
  138. package/bench/results/abap-explain-bdef-handler/claude-sonnet-4-6.md +0 -101
  139. package/bench/results/abap-test-method/claude-haiku-4-5.md +0 -186
  140. package/bench/results/abap-test-method/claude-opus-4-7.md +0 -193
  141. 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,16 +23,18 @@ 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';
@@ -60,14 +62,19 @@ function prefillReadline(rl, text) {
60
62
  import { BracketedPasteDecoder, enableBracketedPaste, disableBracketedPaste, } from './repl/bracketed-paste.js';
61
63
  import { printHelp } from './commands/help.js';
62
64
  import { shouldUseInk, setRenderingOverride } from './renderer/tty.js';
65
+ import { createClassicOutputEmitter } from './renderer/notices.js';
63
66
  import { saveConfig } from './config/loader.js';
64
67
  import { EventEmitter } from 'node:events';
65
68
  import { parseAndValidateReroute } from './commands/reroute.js';
66
69
  import { SKILL_CATALOG } from './skill-catalog.js';
67
70
  import { replPreviewHook } from './repl/update-method-preview-hook.js';
68
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';
69
75
  import { snapshotTurnStart, pushPostTurnStatus } from './repl/post-turn-status.js';
70
76
  import { looksLikeCspeachShellCommand, CSPEACH_SHELL_NUDGE } from './repl/cspeach-shell-detect.js';
77
+ import { applyLocalBuildConfig } from './tools/local-build.js';
71
78
  // Side-effect imports so tool registrations happen before listTools() is called.
72
79
  import './tools/sap-read.js';
73
80
  import './tools/sap-write.js';
@@ -77,16 +84,22 @@ import './tools/snapshot.js';
77
84
  import './tools/ask-question.js';
78
85
  import './tools/dispatch-skill.js';
79
86
  import './tools/filesystem/file-read.js';
87
+ import './tools/filesystem/read-document.js';
80
88
  import './tools/filesystem/file-edit.js';
81
89
  import './tools/filesystem/file-write.js';
82
90
  import './tools/filesystem/glob.js';
83
91
  import './tools/filesystem/grep.js';
84
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';
85
96
  import './tools/web/web_fetch.js';
86
97
  import './tools/web/web_search.js';
87
98
  import './tools/project/project_context_get.js';
88
99
  import './tools/project/playbook_get.js';
89
100
  import './tools/project/convention_get.js';
101
+ import './tools/project/cca_merge.js';
102
+ import './tools/project/upgrade_merge_progress.js';
90
103
  import './tools/subagent/_background-shared.js';
91
104
  import './tools/subagent/background_run.js';
92
105
  import './tools/subagent/monitor_emit.js';
@@ -252,7 +265,7 @@ function isBareAck(trimmed) {
252
265
  * instead of having to arrow to the right item.
253
266
  */
254
267
  const PICKER_FALLBACK_SKILLS = [
255
- 'abap-explain', 'abap-review', 'abap-document', 'abap-rap', 'abap-radar',
268
+ 'abap-explain', 'abap-review', 'abap-handover', 'abap-rap', 'abap-impact',
256
269
  ];
257
270
  const MIN_PICKER_OPTIONS = 3;
258
271
  function buildCoachingParams(result, prompt, preSelect, lastSkill) {
@@ -376,6 +389,18 @@ export async function runRepl(opts) {
376
389
  else {
377
390
  console.log(chalk.gray(`Write mode: ${cfg.write_mode}\n`));
378
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
+ }
379
404
  const aliases = Object.keys(cfg.sap);
380
405
  // C.1 — --sap-alias flag override skips the interactive picker
381
406
  let selectedAlias;
@@ -415,6 +440,19 @@ export async function runRepl(opts) {
415
440
  if (!opts?.resumedSession) {
416
441
  await saveSession(session);
417
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
+ }
418
456
  const provider = createProviderForMode(cfg, async () => {
419
457
  const key = await store.get();
420
458
  if (!key)
@@ -640,13 +678,20 @@ export async function runRepl(opts) {
640
678
  chunkEmitter.emit('chunk', chalk.dim(`\n↪ Rerouting "${preview}" → /${rr.skill}\n`));
641
679
  if (rr.warning)
642
680
  chunkEmitter.emit('chunk', chalk.dim(` (${rr.warning})\n`));
643
- await runTurn({
644
- provider,
645
- userMessage: rr.prompt,
646
- skill: rr.skill,
647
- ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: replPreviewHook, chunkEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor },
648
- chunkEmitter,
649
- });
681
+ const { turnStatusEmitter: rerouteStatus } = await import('./ui/turn-status-emitter.js');
682
+ rerouteStatus.start(`/${rr.skill}`);
683
+ try {
684
+ await runTurn({
685
+ provider,
686
+ userMessage: rr.prompt,
687
+ skill: rr.skill,
688
+ ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: replPreviewHook, chunkEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor },
689
+ chunkEmitter,
690
+ });
691
+ }
692
+ finally {
693
+ rerouteStatus.stop();
694
+ }
650
695
  return;
651
696
  }
652
697
  // /new — clear any "awaiting skill answer" state so the next prompt
@@ -664,7 +709,40 @@ export async function runRepl(opts) {
664
709
  chunkEmitter.emit('chunk', chalk.cyan(`\n${msg}\n`));
665
710
  return;
666
711
  }
667
- 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
+ }
668
746
  // Record the prose body (post-shortcut stripping) for /reroute. Using
669
747
  // `parsed.prompt` rather than `trimmed` means:
670
748
  // - plain prose: `parsed.prompt === trimmed`, stored as-is
@@ -673,7 +751,9 @@ export async function runRepl(opts) {
673
751
  // - `@ZCL_ORDER explain this`: stored as `explain this` — @OBJECT
674
752
  // context is lost on reroute, acceptable v0.3.1 limitation
675
753
  session.lastUserPrompt = parsed.prompt;
676
- const prompt = parsed.prompt;
754
+ // `let`, not `const` — the B3 plan-resume interception below rewrites
755
+ // the prompt with the prepared bounded phase context before runTurn.
756
+ let prompt = parsed.prompt;
677
757
  const extractedObject = parsed.object ?? null;
678
758
  const previousSkill = session.skill;
679
759
  const previousObject = session.last_object ?? null;
@@ -765,7 +845,7 @@ export async function runRepl(opts) {
765
845
  // consume model tokens, so we intercept here AFTER skill resolution
766
846
  // but BEFORE runTurn. The body lives in `prompt` (post-parseShortcut),
767
847
  // e.g. `--status @./foo.cspeach.json`.
768
- if ((skill === 'abap-spec-gap' || skill === 'abap-design' || skill === 'abap-estimate') &&
848
+ if ((skill === 'abap-spec-gap' || skill === 'abap-design' || skill === 'abap-estimate' || skill === 'abap-plan') &&
769
849
  /(^|\s)--status(\s|$)/.test(prompt)) {
770
850
  try {
771
851
  const files = prompt
@@ -785,6 +865,76 @@ export async function runRepl(opts) {
785
865
  session.last_object = extractedObject ?? previousObject ?? null;
786
866
  return;
787
867
  }
868
+ // B3 (2026-06-06) — `/abap-plan --resume @plan-file`. Token-free
869
+ // pre-work (load envelope, pick next eligible phase, print the
870
+ // tracker board, inline the phase's rule files) happens in
871
+ // preparePlanResume; terminal states (plan complete / all blocked)
872
+ // print and end the turn without any model call. When a phase IS
873
+ // eligible, the prepared bounded prompt replaces the user prompt
874
+ // and falls through to runTurn below — with suppressSaveHook so
875
+ // the new-envelope save hook stays quiet; finishPlanResume then
876
+ // persists the version-N+1 REVISION after the turn.
877
+ let planResume = null;
878
+ let planResumeMsgStart = 0;
879
+ // A4 — set only when plan model tiering picks Sonnet for this phase.
880
+ let planModelOverride;
881
+ if (skill === 'abap-plan' && /(^|\s)--resume(\s|$)/.test(prompt)) {
882
+ try {
883
+ const { preparePlanResume } = await import('./commands/plan-resume.js');
884
+ planResume = await preparePlanResume({
885
+ body: prompt,
886
+ cwd: process.cwd(),
887
+ log: (...lines) => chunkEmitter.emit('chunk', lines.join('\n') + '\n'),
888
+ });
889
+ }
890
+ catch (err) {
891
+ chunkEmitter.emit('chunk', chalk.red(`\n[--resume] ${err.message}\n`));
892
+ }
893
+ if (!planResume) {
894
+ session.last_skill = previousSkill ?? null;
895
+ session.last_object = extractedObject ?? previousObject ?? null;
896
+ return;
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
+ }
909
+ planResumeMsgStart = session.messages.length;
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
+ }
929
+ }
930
+ // 2026-06-08 — manifest-on-interrupt. Set true once the mid-turn
931
+ // onPlanManifest hook has persisted the revision, so the post-turn
932
+ // finishPlanResume below doesn't write it a second time.
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;
937
+ const planResumeForHook = planResume;
788
938
  // Phase A** wiring fix (2026-05-16) — snapshot pre-turn counters so
789
939
  // pushPostTurnStatus can compute per-turn deltas. The classic-mode
790
940
  // turn loop has its own equivalent below; both paths now call the
@@ -798,6 +948,11 @@ export async function runRepl(opts) {
798
948
  // the signal we thread into runTurn.
799
949
  currentTurnAbort = new AbortController();
800
950
  interruptCount = 0;
951
+ // 2026-06-07 — persistent status row lifecycle. Started here (not in
952
+ // loop.ts) so the try/finally GUARANTEES the row clears even when the
953
+ // turn throws; loop.ts only narrates activity changes in between.
954
+ const { turnStatusEmitter } = await import('./ui/turn-status-emitter.js');
955
+ turnStatusEmitter.start(`/${skill}`);
801
956
  try {
802
957
  await runTurn({
803
958
  provider,
@@ -806,6 +961,29 @@ export async function runRepl(opts) {
806
961
  ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: replPreviewHook, chunkEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor },
807
962
  chunkEmitter,
808
963
  signal: currentTurnAbort.signal,
964
+ suppressSaveHook: planResume !== null,
965
+ // A4 — undefined on every non-plan turn (and when tiering is off).
966
+ modelOverride: planModelOverride,
967
+ // Persist the plan revision the instant its manifest is finalised
968
+ // (before the continuation modal) so a Ctrl+C can't lose the phase.
969
+ onPlanManifest: planResumeForHook
970
+ ? async (assistantText) => {
971
+ try {
972
+ const { finishPlanResume } = await import('./commands/plan-resume.js');
973
+ planOutcome = await finishPlanResume({
974
+ prepared: planResumeForHook,
975
+ messages: session.messages,
976
+ messagesStart: planResumeMsgStart,
977
+ log: (...lines) => chunkEmitter.emit('chunk', lines.join('\n') + '\n'),
978
+ textOverride: assistantText,
979
+ });
980
+ planSavedEarly = true;
981
+ }
982
+ catch (err) {
983
+ chunkEmitter.emit('chunk', chalk.red(`\n[plan] early revision save failed: ${err.message}\n`));
984
+ }
985
+ }
986
+ : undefined,
809
987
  });
810
988
  }
811
989
  catch (err) {
@@ -817,19 +995,77 @@ export async function runRepl(opts) {
817
995
  });
818
996
  }
819
997
  finally {
998
+ turnStatusEmitter.stop();
820
999
  currentTurnAbort = null;
821
1000
  }
822
- pushPostTurnStatus({ session, selectedAlias, snapshot: turnSnapshot, chunkEmitter });
1001
+ // B3 — persist the plan revision from the resume turn's manifest
1002
+ // block (runs even after a turn error: partial assistant content is
1003
+ // already in session.messages, and a phase may have written real SAP
1004
+ // objects whose status must not be lost). FALLBACK path: skipped when
1005
+ // the mid-turn onPlanManifest hook already saved it (manifest-on-
1006
+ // interrupt, 2026-06-08). Still runs when no manifest was streamed —
1007
+ // e.g. the turn errored before emitting one — so behaviour is unchanged
1008
+ // for the no-manifest case.
1009
+ if (planResume && !planSavedEarly) {
1010
+ try {
1011
+ const { finishPlanResume } = await import('./commands/plan-resume.js');
1012
+ planOutcome = await finishPlanResume({
1013
+ prepared: planResume,
1014
+ messages: session.messages,
1015
+ messagesStart: planResumeMsgStart,
1016
+ log: (...lines) => chunkEmitter.emit('chunk', lines.join('\n') + '\n'),
1017
+ });
1018
+ }
1019
+ catch (err) {
1020
+ chunkEmitter.emit('chunk', chalk.red(`\n[plan] revision save failed: ${err.message}\n`));
1021
+ }
1022
+ }
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
+ });
823
1053
  session.last_skill = previousSkill ?? null;
824
1054
  session.last_object = extractedObject ?? previousObject ?? null;
825
1055
  // 2026-05-08: post-turn auto-dispatch. The dispatch_skill tool may
826
1056
  // have queued a slash command (typically after the user picked
827
- // "Switch to /abap-X" via ask_question). Consume the slot and
828
- // re-enter handleSubmit with that command — no user keypress.
829
- // 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.
830
1061
  const dispatched = pendingDispatchAccessor.get();
831
1062
  if (dispatched) {
832
1063
  pendingDispatchAccessor.set(null);
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.
833
1069
  chunkEmitter.emit('chunk', chalk.dim(`↪ Auto-routed: ${dispatched}\n`));
834
1070
  // Schedule on next tick so the current handleSubmit's promise
835
1071
  // resolves cleanly before the next one starts. Recursion via
@@ -932,6 +1168,25 @@ export async function runRepl(opts) {
932
1168
  // Disable bracketed paste on shutdown so the user's shell prompt after
933
1169
  // CSPeach exits doesn't keep receiving wrapped pastes.
934
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();
935
1190
  // Prompt string: 'CSPeach ' + '›' in peach colour
936
1191
  const promptStr = 'CSPeach ' + chalk.hex(PEACH)('›') + ' ';
937
1192
  // When we dispatch an inquirer sub-prompt (coaching picker, pickSkill),
@@ -978,6 +1233,11 @@ export async function runRepl(opts) {
978
1233
  return;
979
1234
  if (turnInProgress)
980
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;
981
1241
  console.log('\nGoodbye.\n');
982
1242
  process.exit(0);
983
1243
  });
@@ -1149,6 +1409,23 @@ export async function runRepl(opts) {
1149
1409
  })),
1150
1410
  log: (line) => console.log(chalk.dim(line)),
1151
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
+ });
1152
1429
  while (true) {
1153
1430
  // Re-attach the bare-Esc detector every iteration. Inquirer prompts
1154
1431
  // (slash-picker, approval, ask_question, safety-confirm) remove
@@ -1164,9 +1441,27 @@ export async function runRepl(opts) {
1164
1441
  const dispatchedCmd = pendingDispatchAccessor.get();
1165
1442
  if (dispatchedCmd) {
1166
1443
  pendingDispatchAccessor.set(null);
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.
1167
1449
  console.log(chalk.dim(`↪ Auto-routed: ${dispatchedCmd}`));
1168
1450
  line = dispatchedCmd;
1169
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
+ }
1170
1465
  else {
1171
1466
  const effectivePrompt = pendingPrefill ? promptStr + pendingPrefill : promptStr;
1172
1467
  try {
@@ -1184,6 +1479,11 @@ export async function runRepl(opts) {
1184
1479
  // discriminates the two cases reliably.
1185
1480
  const rlAny = rl;
1186
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;
1187
1487
  console.log('\nGoodbye.\n');
1188
1488
  rl.close();
1189
1489
  return 0;
@@ -1368,7 +1668,7 @@ export async function runRepl(opts) {
1368
1668
  provider,
1369
1669
  userMessage: rr.prompt,
1370
1670
  skill: rr.skill,
1371
- 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 },
1372
1672
  });
1373
1673
  continue;
1374
1674
  }
@@ -1386,6 +1686,15 @@ export async function runRepl(opts) {
1386
1686
  console.log(chalk.cyan(msg));
1387
1687
  continue;
1388
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
+ }
1389
1698
  // 2026-05-01: live slash-command picker. When the user types something
1390
1699
  // that LOOKS like a slash command but isn't an exact match — `/`,
1391
1700
  // `/abap-r`, `/fix`, etc. — open a search-as-you-type picker
@@ -1504,7 +1813,25 @@ export async function runRepl(opts) {
1504
1813
  }
1505
1814
  // Any non-empty submit clears the empty-prefill latch.
1506
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
+ }
1507
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
+ }
1508
1835
  // Record the prose body (post-shortcut stripping) for /reroute. Using
1509
1836
  // `parsed.prompt` rather than `trimmed` means:
1510
1837
  // - plain prose: `parsed.prompt === trimmed`, stored as-is
@@ -1513,7 +1840,9 @@ export async function runRepl(opts) {
1513
1840
  // - `@ZCL_ORDER explain this`: stored as `explain this` — @OBJECT
1514
1841
  // context is lost on reroute, acceptable v0.3.1 limitation
1515
1842
  session.lastUserPrompt = parsed.prompt;
1516
- const prompt = parsed.prompt;
1843
+ // `let`, not `const` — the B3 plan-resume interception below rewrites
1844
+ // the prompt with the prepared bounded phase context before runTurn.
1845
+ let prompt = parsed.prompt;
1517
1846
  const extractedObject = parsed.object ?? null;
1518
1847
  const previousSkill = session.skill; // snapshot before this turn dispatches
1519
1848
  const previousObject = session.last_object ?? null; // carries from two turns ago
@@ -1601,7 +1930,7 @@ export async function runRepl(opts) {
1601
1930
  // /abap-design, and /abap-estimate (renderer in projects/status.ts is
1602
1931
  // type-agnostic). Intercept after skill resolution, before runTurn. The
1603
1932
  // prompt body (post-parseShortcut) looks like `--status @./foo.cspeach.json`.
1604
- if ((skill === 'abap-spec-gap' || skill === 'abap-design' || skill === 'abap-estimate') &&
1933
+ if ((skill === 'abap-spec-gap' || skill === 'abap-design' || skill === 'abap-estimate' || skill === 'abap-plan') &&
1605
1934
  /(^|\s)--status(\s|$)/.test(prompt)) {
1606
1935
  try {
1607
1936
  const files = prompt
@@ -1621,6 +1950,65 @@ export async function runRepl(opts) {
1621
1950
  session.last_object = extractedObject ?? previousObject ?? null;
1622
1951
  continue;
1623
1952
  }
1953
+ // B3 (2026-06-06) — `/abap-plan --resume @plan-file` (classic-mode
1954
+ // mirror of the Ink path above; see that block for the full design
1955
+ // note). Token-free pre-work; terminal states end the turn with no
1956
+ // model call; otherwise the prepared bounded prompt falls through to
1957
+ // runTurn with suppressSaveHook, and finishPlanResume persists the
1958
+ // envelope revision after the turn.
1959
+ let planResume = null;
1960
+ let planResumeMsgStart = 0;
1961
+ // A4 — set only when plan model tiering picks Sonnet for this phase.
1962
+ let planModelOverride;
1963
+ if (skill === 'abap-plan' && /(^|\s)--resume(\s|$)/.test(prompt)) {
1964
+ try {
1965
+ const { preparePlanResume } = await import('./commands/plan-resume.js');
1966
+ planResume = await preparePlanResume({
1967
+ body: prompt,
1968
+ cwd: process.cwd(),
1969
+ log: (...lines) => lines.forEach((l) => console.log(l)),
1970
+ });
1971
+ }
1972
+ catch (err) {
1973
+ console.log(chalk.red(`\n[--resume] ${err.message}\n`));
1974
+ }
1975
+ if (!planResume) {
1976
+ session.last_skill = previousSkill ?? null;
1977
+ session.last_object = extractedObject ?? previousObject ?? null;
1978
+ continue;
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
+ }
1988
+ planResumeMsgStart = session.messages.length;
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
+ }
2005
+ }
2006
+ // 2026-06-08 — manifest-on-interrupt (classic path). See the Ink path for
2007
+ // the full rationale; persist the revision mid-turn so a Ctrl+C can't lose it.
2008
+ let planSavedEarly = false;
2009
+ // A1 — persisted-revision outcome; drives the post-turn next-phase offer.
2010
+ let planOutcome = null;
2011
+ const planResumeForHook = planResume;
1624
2012
  const turnStart = Date.now();
1625
2013
  // H1 — `session.usage` is session-LIFETIME (initialised once in
1626
2014
  // newSession, saved across turns). The footer's "this turn" label was
@@ -1673,8 +2061,29 @@ export async function runRepl(opts) {
1673
2061
  provider,
1674
2062
  userMessage: prompt,
1675
2063
  skill: skill,
1676
- 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 },
1677
2065
  signal: turnAbort.signal,
2066
+ suppressSaveHook: planResume !== null,
2067
+ // A4 — undefined on every non-plan turn (and when tiering is off).
2068
+ modelOverride: planModelOverride,
2069
+ onPlanManifest: planResumeForHook
2070
+ ? async (assistantText) => {
2071
+ try {
2072
+ const { finishPlanResume } = await import('./commands/plan-resume.js');
2073
+ planOutcome = await finishPlanResume({
2074
+ prepared: planResumeForHook,
2075
+ messages: session.messages,
2076
+ messagesStart: planResumeMsgStart,
2077
+ log: (...lines) => lines.forEach((l) => console.log(l)),
2078
+ textOverride: assistantText,
2079
+ });
2080
+ planSavedEarly = true;
2081
+ }
2082
+ catch (err) {
2083
+ console.log(chalk.red(`\n[plan] early revision save failed: ${err.message}\n`));
2084
+ }
2085
+ }
2086
+ : undefined,
1678
2087
  });
1679
2088
  }
1680
2089
  catch (err) {
@@ -1694,6 +2103,42 @@ export async function runRepl(opts) {
1694
2103
  rl.removeListener('SIGINT', onSigint);
1695
2104
  turnInProgress = false;
1696
2105
  }
2106
+ // B3 — persist the plan revision from the resume turn's manifest block.
2107
+ // FALLBACK: skipped when the mid-turn onPlanManifest hook already saved it
2108
+ // (manifest-on-interrupt, 2026-06-08); still runs when no manifest streamed.
2109
+ if (planResume && !planSavedEarly) {
2110
+ try {
2111
+ const { finishPlanResume } = await import('./commands/plan-resume.js');
2112
+ planOutcome = await finishPlanResume({
2113
+ prepared: planResume,
2114
+ messages: session.messages,
2115
+ messagesStart: planResumeMsgStart,
2116
+ log: (...lines) => lines.forEach((l) => console.log(l)),
2117
+ });
2118
+ }
2119
+ catch (err) {
2120
+ console.log(chalk.red(`\n[plan] revision save failed: ${err.message}\n`));
2121
+ }
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
+ }
1697
2142
  // Status footer after each turn.
1698
2143
  // tool_result messages also have role:'user' but content is an array —
1699
2144
  // filter them out so we count actual user prompts, not tool rounds.
@@ -1714,6 +2159,9 @@ export async function runRepl(opts) {
1714
2159
  cacheCreate: turnStartCostSnapshot.cacheCreate,
1715
2160
  startedAt: turnStart,
1716
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,
1717
2165
  });
1718
2166
  // Post-turn roll-forward (Step 7c.2 / 7c.5) — the skill from the turn that
1719
2167
  // JUST finished becomes `last_skill` for the next turn's classifier context.