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