@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.
- package/README.md +1 -1
- package/dist/agent/intent-system-prompt.js +1 -1
- package/dist/agent/loop.js +330 -36
- package/dist/agent/providers/license-gate.js +44 -0
- package/dist/agent/skill-checkpoint.js +1 -1
- package/dist/agent/steering-queue.js +27 -0
- package/dist/agent/tool-dispatch.js +15 -0
- package/dist/approvals/canonical.js +91 -0
- package/dist/approvals/jwt.js +84 -7
- package/dist/approvals/render.js +38 -0
- package/dist/auth/org-anthropic-key.js +25 -0
- package/dist/classifier/client.js +24 -5
- package/dist/commands/config-set.js +95 -0
- package/dist/commands/login.js +31 -14
- package/dist/commands/plan-model-tier.js +83 -0
- package/dist/commands/plan-resume.js +435 -0
- package/dist/config/loader.js +102 -3
- package/dist/cost/pricing.js +14 -5
- package/dist/doctor/checks/_http-probe.js +1 -0
- package/dist/doctor/checks/cert.js +14 -3
- package/dist/doctor/checks/sap.js +30 -8
- package/dist/doctor/checks/zcspeach.js +19 -4
- package/dist/one-shot.js +52 -4
- package/dist/projects/answer-blockers.js +137 -0
- package/dist/projects/email-template.js +2 -0
- package/dist/projects/extract-cca.js +108 -16
- package/dist/projects/extract-modernize.js +1 -1
- package/dist/projects/extract-plan.js +178 -0
- package/dist/projects/extract-spec-gap.js +34 -7
- package/dist/projects/extract-test-coverage.js +1 -1
- package/dist/projects/extract-upgrade.js +113 -22
- package/dist/projects/index.js +7 -1
- package/dist/projects/merge-cca.js +292 -0
- package/dist/projects/merge-upgrade.js +173 -0
- package/dist/projects/migration.js +103 -1
- package/dist/projects/output-paths.js +27 -0
- package/dist/projects/plan-run.js +254 -0
- package/dist/projects/plan-schema.js +210 -0
- package/dist/projects/promote-command.js +26 -2
- package/dist/projects/promote.js +128 -0
- package/dist/projects/save-command.js +263 -20
- package/dist/projects/status.js +22 -0
- package/dist/projects/validate.js +2 -0
- package/dist/projects/workspace.js +164 -20
- package/dist/renderer/notices.js +64 -0
- package/dist/renderer/progress-chatter.js +8 -0
- package/dist/renderer/syntax.js +16 -1
- package/dist/renderer/thinking-heartbeat.js +13 -1
- package/dist/renderer/tool-widget.js +18 -4
- package/dist/renderer/tty.js +43 -4
- package/dist/renderer/verify-chain.js +77 -0
- package/dist/repl/at-picker.js +93 -21
- package/dist/repl/builtin-commands.js +37 -0
- package/dist/repl/early-line-buffer.js +68 -0
- package/dist/repl/inquirer-guard.js +70 -5
- package/dist/repl/numbered-menu.js +131 -0
- package/dist/repl/post-turn-status.js +2 -2
- package/dist/repl/rule8-detector.js +17 -2
- package/dist/repl/safety-confirm.js +111 -2
- package/dist/repl/safety-mode-state.js +19 -3
- package/dist/repl/slash-picker.js +25 -19
- package/dist/repl.js +470 -22
- package/dist/router/classifier.js +150 -6
- package/dist/sap/capability-matrix.js +20 -0
- package/dist/sap/capability-matrix.json +11236 -0
- package/dist/sap/capability.js +146 -0
- package/dist/sap/connection-manager.js +19 -1
- package/dist/sap/onboarding.js +42 -4
- package/dist/session/pending.js +27 -0
- package/dist/skill-catalog.js +54 -43
- package/dist/skills/bundled-skills.js +279 -1
- package/dist/skills/promotion-dispatch.js +23 -0
- package/dist/tools/_command-shared.js +36 -12
- package/dist/tools/_filesystem-shared.js +139 -4
- package/dist/tools/_flag.js +25 -0
- package/dist/tools/approval.js +64 -21
- package/dist/tools/ask-question.js +96 -4
- package/dist/tools/capability/tool.js +74 -0
- package/dist/tools/dispatch-skill.js +22 -1
- package/dist/tools/extend-model/anchored-insert.js +810 -0
- package/dist/tools/extend-model/tool.js +188 -0
- package/dist/tools/filesystem/extract-document.js +57 -0
- package/dist/tools/filesystem/file-edit.js +12 -2
- package/dist/tools/filesystem/file-read.js +2 -2
- package/dist/tools/filesystem/file-write.js +11 -2
- package/dist/tools/filesystem/glob.js +11 -0
- package/dist/tools/filesystem/grep.js +10 -0
- package/dist/tools/filesystem/read-document.js +107 -0
- package/dist/tools/fiori/apply.js +50 -0
- package/dist/tools/fiori/bin.js +3 -0
- package/dist/tools/fiori/catalog/index.js +27 -0
- package/dist/tools/fiori/catalog/value-help.js +230 -0
- package/dist/tools/fiori/catalog/viz-chart.js +177 -0
- package/dist/tools/fiori/cli.js +71 -0
- package/dist/tools/fiori/deploy-config.js +73 -0
- package/dist/tools/fiori/fe-scaffold.js +45 -0
- package/dist/tools/fiori/i18n.js +39 -0
- package/dist/tools/fiori/manifest.js +70 -0
- package/dist/tools/fiori/render.js +77 -0
- package/dist/tools/fiori/scaffold.js +39 -0
- package/dist/tools/fiori/tools.js +356 -0
- package/dist/tools/fiori/types.js +1 -0
- package/dist/tools/local-build.js +76 -0
- package/dist/tools/local-files.js +31 -0
- package/dist/tools/project/_merge-shared.js +68 -0
- package/dist/tools/project/cca_merge.js +164 -0
- package/dist/tools/project/playbook_get.js +1 -1
- package/dist/tools/project/upgrade_merge_progress.js +206 -0
- package/dist/tools/sap-read.js +53 -9
- package/dist/tools/sap-write.js +530 -21
- package/dist/tools/shell/shell_exec.js +41 -6
- package/dist/tools/snapshot.js +37 -14
- package/dist/tools/subagent/background_run.js +17 -1
- package/dist/tools/transport-resolution.js +86 -0
- package/dist/tools/transport.js +224 -5
- package/dist/tools/write-mode.js +4 -0
- package/dist/ui/app.js +84 -11
- package/dist/ui/body.js +13 -0
- package/dist/ui/command-palette.js +46 -10
- package/dist/ui/file-palette.js +44 -0
- package/dist/ui/footer.js +28 -11
- package/dist/ui/line-resolution.js +92 -0
- package/dist/ui/session-timeline.js +1 -0
- package/dist/ui/text-input.js +150 -0
- package/dist/ui/turn-status-emitter.js +52 -0
- package/dist/ui/turn-status.js +59 -0
- package/dist/ui/widgets/ask-question-modal.js +30 -2
- package/package.json +23 -4
- package/bench/README.md +0 -78
- package/bench/prompts/abap-document-cds.md +0 -44
- package/bench/prompts/abap-explain-bdef-handler.md +0 -57
- package/bench/prompts/abap-test-method.md +0 -42
- package/bench/results/abap-document-cds/claude-haiku-4-5.md +0 -189
- package/bench/results/abap-document-cds/claude-opus-4-7.md +0 -120
- package/bench/results/abap-document-cds/claude-sonnet-4-6.md +0 -151
- package/bench/results/abap-explain-bdef-handler/claude-haiku-4-5.md +0 -112
- package/bench/results/abap-explain-bdef-handler/claude-opus-4-7.md +0 -101
- package/bench/results/abap-explain-bdef-handler/claude-sonnet-4-6.md +0 -101
- package/bench/results/abap-test-method/claude-haiku-4-5.md +0 -186
- package/bench/results/abap-test-method/claude-opus-4-7.md +0 -193
- 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-
|
|
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
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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)
|
|
828
|
-
//
|
|
829
|
-
//
|
|
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
|
|
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.
|