@cspeach/cli 0.9.0 → 1.1.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 +228 -26
- 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/approvals/op-labels.js +124 -0
- package/dist/approvals/render.js +42 -36
- package/dist/auth/org-anthropic-key.js +25 -0
- package/dist/classifier/client.js +18 -3
- package/dist/cli.js +15 -0
- package/dist/commands/compact.js +28 -2
- package/dist/commands/config-set.js +284 -0
- package/dist/commands/config-show.js +20 -0
- package/dist/commands/export-audit.js +43 -0
- package/dist/commands/help.js +5 -0
- package/dist/commands/login.js +31 -14
- package/dist/commands/plan-audit-evidence.js +266 -0
- package/dist/commands/plan-audit.js +692 -0
- package/dist/commands/plan-chain.js +671 -0
- package/dist/commands/plan-continue.js +179 -0
- package/dist/commands/plan-gate.js +154 -0
- package/dist/commands/plan-model-tier.js +83 -0
- package/dist/commands/plan-resume.js +728 -46
- package/dist/config/loader.js +223 -5
- package/dist/config/model-defaults.js +14 -0
- package/dist/cost/pricing.js +27 -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/system-roles.js +41 -0
- package/dist/doctor/checks/zcspeach.js +19 -4
- package/dist/doctor/run.js +2 -0
- package/dist/models/resolve.js +61 -0
- package/dist/models/server-config.js +155 -0
- package/dist/one-shot.js +76 -6
- package/dist/projects/answer-blockers.js +137 -0
- package/dist/projects/extract-cca.js +111 -17
- package/dist/projects/extract-modernize.js +4 -2
- package/dist/projects/extract-plan.js +184 -37
- package/dist/projects/extract-spec-gap.js +34 -7
- package/dist/projects/extract-test-coverage.js +4 -2
- package/dist/projects/extract-upgrade.js +116 -23
- package/dist/projects/handover-md.js +195 -0
- 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 +285 -27
- package/dist/projects/plan-schema.js +136 -3
- package/dist/projects/promote-command.js +25 -2
- package/dist/projects/promote.js +128 -0
- package/dist/projects/run-lease.js +157 -0
- package/dist/projects/save-command.js +259 -21
- 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/status-footer.js +22 -12
- package/dist/renderer/thinking-heartbeat.js +64 -8
- package/dist/renderer/todo-block.js +51 -0
- package/dist/renderer/tool-widget.js +55 -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/bracketed-paste.js +28 -19
- package/dist/repl/builtin-commands.js +42 -0
- package/dist/repl/current-transport.js +10 -0
- package/dist/repl/early-line-buffer.js +68 -0
- package/dist/repl/history.js +86 -0
- package/dist/repl/ink-stdin-guard.js +64 -0
- package/dist/repl/inquirer-guard.js +70 -5
- package/dist/repl/mode-ceiling.js +16 -0
- package/dist/repl/mode-cycle.js +104 -0
- package/dist/repl/numbered-menu.js +131 -0
- package/dist/repl/post-turn-status.js +26 -6
- 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-completer.js +5 -0
- package/dist/repl/slash-picker.js +10 -15
- package/dist/repl.js +1232 -95
- package/dist/rewind/candidates.js +194 -0
- package/dist/rewind/cli.js +137 -0
- package/dist/rewind/format.js +27 -0
- package/dist/rewind/restore.js +245 -0
- 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/audit-export.js +459 -0
- package/dist/session/context-report.js +163 -0
- package/dist/session/pending.js +27 -0
- package/dist/session/recap.js +160 -0
- package/dist/skill-catalog.js +51 -40
- package/dist/skills/bundled-skills.js +272 -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 +177 -26
- package/dist/tools/ask-question.js +400 -7
- package/dist/tools/capability/tool.js +74 -0
- package/dist/tools/dispatch-skill.js +22 -1
- package/dist/tools/extend-model/anchored-insert.js +1414 -0
- package/dist/tools/extend-model/tool.js +340 -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-extend.js +76 -0
- package/dist/tools/fiori/fe-scaffold.js +71 -0
- package/dist/tools/fiori/floorplan-map.js +19 -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/samples/data/index.json +13602 -0
- package/dist/tools/fiori/samples/data/sources.generated.js +808 -0
- package/dist/tools/fiori/samples/loader.js +248 -0
- package/dist/tools/fiori/samples/search.js +63 -0
- package/dist/tools/fiori/samples/types.js +2 -0
- package/dist/tools/fiori/scaffold.js +39 -0
- package/dist/tools/fiori/smoke/assertions.js +74 -0
- package/dist/tools/fiori/smoke/browser.js +52 -0
- package/dist/tools/fiori/smoke/driver.js +89 -0
- package/dist/tools/fiori/smoke/freestyle-spec.js +317 -0
- package/dist/tools/fiori/smoke/run-smoke.js +149 -0
- package/dist/tools/fiori/tools.js +681 -0
- package/dist/tools/fiori/types.js +1 -0
- package/dist/tools/local-build.js +86 -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 +132 -20
- package/dist/tools/sap-write.js +550 -21
- package/dist/tools/shell/shell_exec.js +41 -6
- package/dist/tools/snapshot.js +63 -14
- package/dist/tools/subagent/agent_run.js +27 -3
- package/dist/tools/subagent/background_run.js +17 -1
- package/dist/tools/todo.js +144 -0
- 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 +378 -21
- package/dist/ui/approval-modal.js +49 -16
- package/dist/ui/ask-question-emitter.js +14 -0
- package/dist/ui/body.js +13 -0
- package/dist/ui/context-grid.js +108 -0
- package/dist/ui/footer.js +120 -27
- package/dist/ui/header.js +7 -0
- package/dist/ui/line-resolution.js +35 -8
- package/dist/ui/rewind-emitter.js +10 -0
- package/dist/ui/rewind-panel.js +81 -0
- package/dist/ui/sap-state-store.js +1 -0
- package/dist/ui/session-timeline.js +1 -0
- package/dist/ui/status-line.js +43 -0
- package/dist/ui/text-input.js +214 -0
- package/dist/ui/todo-emitter.js +25 -0
- package/dist/ui/todo-panel.js +64 -0
- package/dist/ui/turn-status-emitter.js +50 -4
- package/dist/ui/turn-status.js +18 -3
- package/dist/ui/widgets/ask-form.js +242 -0
- package/dist/ui/widgets/ask-question-modal.js +21 -8
- package/package.json +22 -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
|
@@ -6,11 +6,8 @@ import chalk from 'chalk';
|
|
|
6
6
|
// callback-module's InterfaceConstructor signature or import from
|
|
7
7
|
// 'node:readline' directly for that specific operation.
|
|
8
8
|
import * as readline from 'node:readline/promises';
|
|
9
|
-
import * as fs from 'node:fs';
|
|
10
|
-
import * as path from 'node:path';
|
|
11
|
-
import * as os from 'node:os';
|
|
12
9
|
import { select, password as promptPassword } from '@inquirer/prompts';
|
|
13
|
-
import { loadConfig } from './config/loader.js';
|
|
10
|
+
import { loadConfig, resolveLocalBuild } from './config/loader.js';
|
|
14
11
|
import { getStore } from './auth/api-key.js';
|
|
15
12
|
import { readAuthFile } from './auth/auth-file.js';
|
|
16
13
|
import { fetchMe } from './auth/me.js';
|
|
@@ -18,26 +15,44 @@ import { formatLoginBanner } from './ui/login-banner.js';
|
|
|
18
15
|
import { printStartupBanner, printKeytarFallbackBanner } from './renderer/banners.js';
|
|
19
16
|
import { runAddSapWizard } from './sap/onboarding.js';
|
|
20
17
|
import { getConnection } from './sap/connection-manager.js';
|
|
21
|
-
import { AdtClient } from '@cspeach/sap-client';
|
|
18
|
+
import { AdtClient, snapshots } from '@cspeach/sap-client';
|
|
22
19
|
import { makeSessionId, saveSession } from './session/store.js';
|
|
23
20
|
import { newSession } from './session/schema.js';
|
|
21
|
+
import { resolveModelRole } from './models/resolve.js';
|
|
22
|
+
import { loadServerModelConfig } from './models/server-config.js';
|
|
24
23
|
import { computeCost, formatCost } from './cost/pricing.js';
|
|
25
24
|
import { createProviderForMode } from './agent/providers/factory.js';
|
|
25
|
+
import { assertModeLicensed, CspeachLicenseError } from './agent/providers/license-gate.js';
|
|
26
26
|
import { runTurn } from './agent/loop.js';
|
|
27
27
|
import { renderTurnError } from './agent/turn-error-ux.js';
|
|
28
|
-
|
|
28
|
+
// BUG-2b (2026-07-05 live smoke) — pure (no-Ink) modules, safe to import
|
|
29
|
+
// statically even though App itself is lazy-loaded to keep Ink out of
|
|
30
|
+
// classic mode. Used by the single-flight dispatch guard below.
|
|
31
|
+
import { turnStatusEmitter } from './ui/turn-status-emitter.js';
|
|
32
|
+
import { enqueueSteering } from './agent/steering-queue.js';
|
|
33
|
+
import { classifyPrompt, parseShortcut, rejectUnknownSkill } from './router/classifier.js';
|
|
29
34
|
import { decideRouting } from './router/routing-decision.js';
|
|
30
35
|
import { coachingPickerClassic } from './ui/coaching-picker-classic.js';
|
|
31
36
|
import { coachingPickerEmitter } from './ui/coaching-picker-emitter.js';
|
|
32
37
|
import { globalStore as sapStateStore } from './ui/sap-state-store.js';
|
|
38
|
+
import { todoEmitter } from './ui/todo-emitter.js';
|
|
33
39
|
import { PICK_CANCELLED } from './ui/skill-picker.js';
|
|
40
|
+
// Task 3 (ux-wave3) — rewind. rewindEmitter + approvalEmitter are pure
|
|
41
|
+
// EventEmitters; rewind/* and approvals/render are ink-free, so these are safe
|
|
42
|
+
// static imports in the classic path too (only the Ink render block below
|
|
43
|
+
// actually mounts a panel).
|
|
44
|
+
import { rewindEmitter } from './ui/rewind-emitter.js';
|
|
45
|
+
import { approvalEmitter } from './ui/approval-emitter.js';
|
|
46
|
+
import { collectRewindCandidates } from './rewind/candidates.js';
|
|
47
|
+
import { executeRestore, runClassicRewind } from './rewind/cli.js';
|
|
34
48
|
import { registerReadline, withInquirer } from './repl/inquirer-guard.js';
|
|
35
|
-
import { maybePromptWorkspaceMigration } from './projects/migration.js';
|
|
49
|
+
import { maybePromptWorkspaceMigration, maybeOfferAbapforgeMigration } from './projects/migration.js';
|
|
50
|
+
import { findProjectRoot } from './projects/workspace.js';
|
|
36
51
|
import { slashCompleter } from './repl/slash-completer.js';
|
|
37
52
|
import { openSlashPicker, isExactSlashMatch } from './repl/slash-picker.js';
|
|
38
53
|
import { openAtPicker, pendingAtTokenPrefix } from './repl/at-picker.js';
|
|
39
54
|
import { atCompleter } from './repl/at-completer.js';
|
|
40
|
-
import {
|
|
55
|
+
import { appendHistoryEntry, readHistoryEntries } from './repl/history.js';
|
|
41
56
|
/**
|
|
42
57
|
* Pre-fill the readline buffer with `text`, cursor at end, and trigger
|
|
43
58
|
* a redraw. Bypasses rl.write() — which simulates per-character
|
|
@@ -59,16 +74,23 @@ function prefillReadline(rl, text) {
|
|
|
59
74
|
internal._refreshLine?.();
|
|
60
75
|
}
|
|
61
76
|
import { BracketedPasteDecoder, enableBracketedPaste, disableBracketedPaste, } from './repl/bracketed-paste.js';
|
|
77
|
+
import { InkStdinPasteGuard } from './repl/ink-stdin-guard.js';
|
|
62
78
|
import { printHelp } from './commands/help.js';
|
|
63
79
|
import { shouldUseInk, setRenderingOverride } from './renderer/tty.js';
|
|
80
|
+
import { createClassicOutputEmitter } from './renderer/notices.js';
|
|
64
81
|
import { saveConfig } from './config/loader.js';
|
|
65
82
|
import { EventEmitter } from 'node:events';
|
|
66
83
|
import { parseAndValidateReroute } from './commands/reroute.js';
|
|
67
84
|
import { SKILL_CATALOG } from './skill-catalog.js';
|
|
68
85
|
import { replPreviewHook } from './repl/update-method-preview-hook.js';
|
|
69
86
|
import { getCurrentTransport, handleTransportCommand } from './repl/current-transport.js';
|
|
87
|
+
import { matchSafetyModeCommand } from './repl/safety-mode-state.js';
|
|
88
|
+
import { getEffectiveWriteMode, runModeCommand } from './repl/mode-cycle.js';
|
|
89
|
+
import { maybeExpandNumberSelection, lastAssistantText } from './repl/numbered-menu.js';
|
|
90
|
+
import { attachEarlyLineBuffer, noopEarlyLineBuffer } from './repl/early-line-buffer.js';
|
|
70
91
|
import { snapshotTurnStart, pushPostTurnStatus } from './repl/post-turn-status.js';
|
|
71
92
|
import { looksLikeCspeachShellCommand, CSPEACH_SHELL_NUDGE } from './repl/cspeach-shell-detect.js';
|
|
93
|
+
import { applyLocalBuildConfig } from './tools/local-build.js';
|
|
72
94
|
// Side-effect imports so tool registrations happen before listTools() is called.
|
|
73
95
|
import './tools/sap-read.js';
|
|
74
96
|
import './tools/sap-write.js';
|
|
@@ -77,54 +99,44 @@ import './tools/approval.js';
|
|
|
77
99
|
import './tools/snapshot.js';
|
|
78
100
|
import './tools/ask-question.js';
|
|
79
101
|
import './tools/dispatch-skill.js';
|
|
102
|
+
import './tools/todo.js';
|
|
80
103
|
import './tools/filesystem/file-read.js';
|
|
104
|
+
import './tools/filesystem/read-document.js';
|
|
81
105
|
import './tools/filesystem/file-edit.js';
|
|
82
106
|
import './tools/filesystem/file-write.js';
|
|
83
107
|
import './tools/filesystem/glob.js';
|
|
84
108
|
import './tools/filesystem/grep.js';
|
|
85
109
|
import './tools/shell/shell_exec.js';
|
|
110
|
+
import './tools/fiori/tools.js';
|
|
111
|
+
import './tools/extend-model/tool.js';
|
|
112
|
+
import './tools/capability/tool.js';
|
|
86
113
|
import './tools/web/web_fetch.js';
|
|
87
114
|
import './tools/web/web_search.js';
|
|
88
115
|
import './tools/project/project_context_get.js';
|
|
89
116
|
import './tools/project/playbook_get.js';
|
|
90
117
|
import './tools/project/convention_get.js';
|
|
118
|
+
import './tools/project/cca_merge.js';
|
|
119
|
+
import './tools/project/upgrade_merge_progress.js';
|
|
91
120
|
import './tools/subagent/_background-shared.js';
|
|
92
121
|
import './tools/subagent/background_run.js';
|
|
93
122
|
import './tools/subagent/monitor_emit.js';
|
|
94
123
|
import './tools/subagent/schedule_draft_create.js';
|
|
95
124
|
import './tools/subagent/agent_run.js';
|
|
96
125
|
const PEACH = '#FFCBA4';
|
|
97
|
-
const HISTORY_FILE = path.join(os.homedir(), '.cspeach', 'history');
|
|
98
126
|
const HISTORY_SIZE = 500;
|
|
127
|
+
// Persistence (read + dedup-guarded append) lives in ./repl/history.ts —
|
|
128
|
+
// the single writer shared with the Ink Footer (Task 8, ux-wave1).
|
|
99
129
|
function loadHistory() {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
// after slicing the last N lines we need to reverse them. Without
|
|
111
|
-
// this, arrow-up showed the user the oldest 500 entries first; new
|
|
112
|
-
// ones (correctly persisted to disk) sat at the far end of the buffer
|
|
113
|
-
// and were unreachable.
|
|
114
|
-
.reverse();
|
|
115
|
-
}
|
|
116
|
-
catch {
|
|
117
|
-
return [];
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
function appendHistory(line) {
|
|
121
|
-
try {
|
|
122
|
-
fs.mkdirSync(path.dirname(HISTORY_FILE), { recursive: true });
|
|
123
|
-
fs.appendFileSync(HISTORY_FILE, line + '\n', 'utf-8');
|
|
124
|
-
}
|
|
125
|
-
catch {
|
|
126
|
-
// Non-fatal — history persistence failure must never crash the REPL.
|
|
127
|
-
}
|
|
130
|
+
return (readHistoryEntries()
|
|
131
|
+
.slice(-HISTORY_SIZE)
|
|
132
|
+
// readline expects history[0] to be the MOST RECENT entry (arrow-up
|
|
133
|
+
// walks from index 0 backwards in time). The on-disk file is
|
|
134
|
+
// chronologically appended — oldest at top, newest at bottom — so
|
|
135
|
+
// after slicing the last N lines we need to reverse them. Without
|
|
136
|
+
// this, arrow-up showed the user the oldest 500 entries first; new
|
|
137
|
+
// ones (correctly persisted to disk) sat at the far end of the buffer
|
|
138
|
+
// and were unreachable.
|
|
139
|
+
.reverse());
|
|
128
140
|
}
|
|
129
141
|
/** Short user inputs that almost always mean "continue what we were doing"
|
|
130
142
|
* rather than "start a new task." When the previous turn left a skill
|
|
@@ -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) {
|
|
@@ -366,16 +378,42 @@ export async function runRepl(opts) {
|
|
|
366
378
|
await runAddSapWizard();
|
|
367
379
|
cfg = await loadConfig();
|
|
368
380
|
}
|
|
381
|
+
// model-governance step 2d — fire-and-forget fetch of the served model-config
|
|
382
|
+
// (managed mode only). NEVER blocks startup: a turn that starts before the
|
|
383
|
+
// fetch lands simply uses the built-ins (fail-safe). Precedence when it does
|
|
384
|
+
// land: env > local config > server > built-in.
|
|
385
|
+
void loadServerModelConfig(cfg);
|
|
369
386
|
// Task C.10 — surface the current write_mode prominently. advisory-only gets
|
|
370
387
|
// a bold yellow banner (no-writes is a surprising-enough state that the user
|
|
371
388
|
// should not miss it). Other modes get a quiet one-liner so the state is
|
|
372
389
|
// always visible without competing with the peach banner above.
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
390
|
+
//
|
|
391
|
+
// Task 3 (ux-wave1) — the banner must announce the EFFECTIVE mode (config
|
|
392
|
+
// clamped to the alias role's ceiling), but the role isn't known until the
|
|
393
|
+
// alias is picked below. With no role configured anywhere the clamp is the
|
|
394
|
+
// identity, so print here exactly as before (byte-identical output); when
|
|
395
|
+
// any alias declares a role, defer the banner until the alias is resolved.
|
|
396
|
+
const anyRoleConfigured = Object.values(cfg.sap).some((s) => s.role !== undefined);
|
|
397
|
+
if (!anyRoleConfigured) {
|
|
398
|
+
if (cfg.write_mode === 'advisory-only') {
|
|
399
|
+
console.log(chalk.yellow.bold('⚠ Write mode: ADVISORY (writes proposed, never executed)'));
|
|
400
|
+
console.log(chalk.yellow(' Change via: cspeach config set write_mode approval-gated\n'));
|
|
401
|
+
}
|
|
402
|
+
else {
|
|
403
|
+
console.log(chalk.gray(`Write mode: ${cfg.write_mode}\n`));
|
|
404
|
+
}
|
|
376
405
|
}
|
|
377
|
-
|
|
378
|
-
|
|
406
|
+
// local_build toggle — when persisted-on, force-enable the flag-gated
|
|
407
|
+
// filesystem tools AND shell_exec BEFORE the first listTools()/turn (listTools
|
|
408
|
+
// reads flag state live per turn). Resolve with backward compat: a stale
|
|
409
|
+
// legacy `local_files = on` still applies via resolveLocalBuild. Announce
|
|
410
|
+
// visibly: the toggle is persisted, not re-consented each session, so the user
|
|
411
|
+
// must see that local file access + command execution are live this session.
|
|
412
|
+
// Printed to stdout here (before any Ink frame mounts).
|
|
413
|
+
{
|
|
414
|
+
const announce = applyLocalBuildConfig(resolveLocalBuild(cfg), process.cwd());
|
|
415
|
+
if (announce)
|
|
416
|
+
console.log(chalk.dim(announce + '\n'));
|
|
379
417
|
}
|
|
380
418
|
const aliases = Object.keys(cfg.sap);
|
|
381
419
|
// C.1 — --sap-alias flag override skips the interactive picker
|
|
@@ -398,24 +436,70 @@ export async function runRepl(opts) {
|
|
|
398
436
|
choices: aliases.map((a) => ({ value: a, name: a })),
|
|
399
437
|
});
|
|
400
438
|
}
|
|
439
|
+
// Task 3 (ux-wave1) — deferred write-mode banner: now that the alias is
|
|
440
|
+
// known, announce the EFFECTIVE mode and flag when the role clamped it.
|
|
441
|
+
// Only reached when a role is configured (no-role path printed above,
|
|
442
|
+
// byte-identical to pre-role behavior).
|
|
443
|
+
const aliasRole = cfg.sap[selectedAlias]?.role;
|
|
444
|
+
const startupWriteMode = getEffectiveWriteMode(cfg.write_mode, aliasRole);
|
|
445
|
+
if (anyRoleConfigured) {
|
|
446
|
+
const capped = startupWriteMode !== cfg.write_mode
|
|
447
|
+
? ` (capped by sap.${selectedAlias}.role = ${aliasRole})`
|
|
448
|
+
: '';
|
|
449
|
+
if (startupWriteMode === 'advisory-only') {
|
|
450
|
+
console.log(chalk.yellow.bold(`⚠ Write mode: ADVISORY (writes proposed, never executed)${capped}`));
|
|
451
|
+
// The config hint only makes sense when config CAUSED advisory —
|
|
452
|
+
// a role-capped advisory can't be lifted by a config change.
|
|
453
|
+
if (capped === '') {
|
|
454
|
+
console.log(chalk.yellow(' Change via: cspeach config set write_mode approval-gated\n'));
|
|
455
|
+
}
|
|
456
|
+
else {
|
|
457
|
+
console.log('');
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
else {
|
|
461
|
+
console.log(chalk.gray(`Write mode: ${startupWriteMode}${capped}\n`));
|
|
462
|
+
}
|
|
463
|
+
}
|
|
401
464
|
const conn = await getConnection(selectedAlias, cfg.sap[selectedAlias]);
|
|
402
465
|
const adt = new AdtClient(conn);
|
|
403
466
|
// Phase A #3 (2026-05-16) — sidebar SAP-state wire, trigger 1 of 4.
|
|
404
467
|
// Push system + user the moment we know the alias. Before this commit
|
|
405
468
|
// the sidebar showed "no system / user: —" forever because the store
|
|
406
469
|
// had .update() methods but no callers.
|
|
470
|
+
// Task 3 (ux-wave1) — seed writeMode with the startup-EFFECTIVE mode so
|
|
471
|
+
// the Task 4 statusline chip is correct before any cycle happens.
|
|
472
|
+
// Task 4 review fix (2026-07-04) — seed the BARE alias, not
|
|
473
|
+
// `alias (host)`: an FQDN host pushed the statusline past 100 cols and
|
|
474
|
+
// wrapped it. store.system has exactly two readers — StatusLine and the
|
|
475
|
+
// superseded (unmounted) header.tsx — so nothing else loses the host;
|
|
476
|
+
// the connection banner above already printed it once to scrollback.
|
|
407
477
|
sapStateStore.update({
|
|
408
|
-
system:
|
|
478
|
+
system: selectedAlias,
|
|
409
479
|
user: cfg.sap[selectedAlias].username,
|
|
480
|
+
writeMode: startupWriteMode,
|
|
410
481
|
});
|
|
411
482
|
// v0.6 — when resuming, adopt the on-disk SessionState verbatim. This
|
|
412
483
|
// preserves messages[], usage, last_skill, awaitingSkillAnswer, and the
|
|
413
484
|
// turnInterrupted flag from the prior run. Skipping newSession() avoids
|
|
414
485
|
// overwriting the saved file with an empty fresh one.
|
|
415
|
-
const session = opts?.resumedSession ?? newSession(makeSessionId(), selectedAlias, null, cfg
|
|
486
|
+
const session = opts?.resumedSession ?? newSession(makeSessionId(), selectedAlias, null, resolveModelRole('session_default', cfg));
|
|
416
487
|
if (!opts?.resumedSession) {
|
|
417
488
|
await saveSession(session);
|
|
418
489
|
}
|
|
490
|
+
// License gate: non-managed modes (byok/local/ai-hub) bypass our proxy for
|
|
491
|
+
// inference, so without this anyone who never logged in could use the bundled
|
|
492
|
+
// skills untracked. Require a valid CSPeach login before the REPL starts.
|
|
493
|
+
try {
|
|
494
|
+
await assertModeLicensed(cfg.llm.mode, async () => (await store.get()) ?? '');
|
|
495
|
+
}
|
|
496
|
+
catch (e) {
|
|
497
|
+
if (e instanceof CspeachLicenseError) {
|
|
498
|
+
console.error(chalk.yellow(`\n${e.message}\n`));
|
|
499
|
+
process.exit(1);
|
|
500
|
+
}
|
|
501
|
+
throw e;
|
|
502
|
+
}
|
|
419
503
|
const provider = createProviderForMode(cfg, async () => {
|
|
420
504
|
const key = await store.get();
|
|
421
505
|
if (!key)
|
|
@@ -430,6 +514,21 @@ export async function runRepl(opts) {
|
|
|
430
514
|
console.log(chalk.yellow(`⚠ Last turn was interrupted (${reason}).`));
|
|
431
515
|
console.log(chalk.yellow(` Type \"continue\" to ask the model to finish where it left off.`));
|
|
432
516
|
}
|
|
517
|
+
// Task 6 (ux-wave2) — session recap: three dim lines (last session ·
|
|
518
|
+
// open todos · unsatisfied plan) so the returning developer is oriented
|
|
519
|
+
// without scrolling. Covers BOTH resume entries — the `cspeach --resume`
|
|
520
|
+
// picker (session/resume.ts) and any direct --resume <id> — since both
|
|
521
|
+
// land here with opts.resumedSession set. Best-effort: buildRecap never
|
|
522
|
+
// throws, and a workspace-resolution failure just drops the plan line.
|
|
523
|
+
try {
|
|
524
|
+
const { buildRecap } = await import('./session/recap.js');
|
|
525
|
+
const { resolveWorkspacePath } = await import('./projects/workspace.js');
|
|
526
|
+
const recapWs = await resolveWorkspacePath().catch(() => undefined);
|
|
527
|
+
for (const recapLine of buildRecap(session, process.cwd(), { workspaceDir: recapWs })) {
|
|
528
|
+
console.log(chalk.dim(recapLine));
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
catch { /* recap is best-effort — never block resume */ }
|
|
433
532
|
console.log(chalk.gray(`Type /exit to quit.\n`));
|
|
434
533
|
}
|
|
435
534
|
else {
|
|
@@ -474,10 +573,13 @@ export async function runRepl(opts) {
|
|
|
474
573
|
// Things this delete-line cleanup removes (vs the alt-screen path):
|
|
475
574
|
// - enterAltScreen / leaveAltScreen calls
|
|
476
575
|
// - hideCursor interval workaround
|
|
477
|
-
// - bracketed-paste enable (now handled the classic-mode way below)
|
|
478
576
|
// - PgUp/PgDn/Ctrl+B scroll state in App + Body
|
|
479
577
|
// - termHeight + SIGWINCH resize tracking
|
|
480
578
|
// - exitOnCtrlC: false render option (default behaviour is fine)
|
|
579
|
+
//
|
|
580
|
+
// Bracketed paste: NOT removed anymore. Wave1 live-fix (Bug 2)
|
|
581
|
+
// enables DEC 2004 for the Ink path too and routes stdin through
|
|
582
|
+
// InkStdinPasteGuard — see the render() wiring below.
|
|
481
583
|
// Hide the terminal's native cursor while Ink owns the frame. Ink's
|
|
482
584
|
// dynamic frame ends with a newline, parking the real cursor on the
|
|
483
585
|
// line BELOW the Footer ("booger cursor"). ink-text-input's
|
|
@@ -509,11 +611,19 @@ export async function runRepl(opts) {
|
|
|
509
611
|
// it. When null, we're at idle and should exit on Ctrl+C.
|
|
510
612
|
let currentTurnAbort = null;
|
|
511
613
|
let interruptCount = 0;
|
|
512
|
-
|
|
614
|
+
// BUG-2b — the actual dispatch. Wrapped by handleSubmit (below) which
|
|
615
|
+
// owns the single-flight reserve()/release() lifecycle. All re-entrant
|
|
616
|
+
// callers (the setImmediate auto-dispatch / re-audit legs) go through the
|
|
617
|
+
// WRAPPER so they are guarded and reserved too.
|
|
618
|
+
const dispatchSubmit = async (line) => {
|
|
513
619
|
const trimmed = line.trim();
|
|
514
620
|
if (!trimmed)
|
|
515
621
|
return;
|
|
516
|
-
|
|
622
|
+
// Task 8 (ux-wave1) — shared dedup-guarded writer. On the Ink path
|
|
623
|
+
// the Footer has already persisted this submission (flattened), so
|
|
624
|
+
// the consecutive-dup guard makes this a no-op instead of the old
|
|
625
|
+
// double append.
|
|
626
|
+
appendHistoryEntry(trimmed);
|
|
517
627
|
// Phase A** (2026-05-16) — echo the user's prompt into Body so it
|
|
518
628
|
// appears in the conversation history. Plus a dim divider above
|
|
519
629
|
// the echo so consecutive turns have a clear visual break (closes
|
|
@@ -549,16 +659,79 @@ export async function runRepl(opts) {
|
|
|
549
659
|
await runCostCommand(session.id);
|
|
550
660
|
return;
|
|
551
661
|
}
|
|
662
|
+
// Task 4 (ux-wave3) — /export audit [filename]: write the signable
|
|
663
|
+
// session audit record (who did what, where, under which approval, at
|
|
664
|
+
// what cost) to a markdown file in cwd and print the path.
|
|
665
|
+
if (trimmed === '/export' || trimmed.startsWith('/export ')) {
|
|
666
|
+
const { parseExportArgs, runExportAuditCommand } = await import('./commands/export-audit.js');
|
|
667
|
+
const { sub, filename } = parseExportArgs(trimmed.slice('/export'.length));
|
|
668
|
+
if (sub !== 'audit') {
|
|
669
|
+
chunkEmitter.emit('chunk', chalk.dim('\nUsage: /export audit [filename]\n'));
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
await runExportAuditCommand({
|
|
673
|
+
session,
|
|
674
|
+
transport: getCurrentTransport(),
|
|
675
|
+
filename,
|
|
676
|
+
emit: (l) => chunkEmitter.emit('chunk', l + '\n'),
|
|
677
|
+
});
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
// Task 5 (ux-wave3) — /context: the composition grid. What fills the
|
|
681
|
+
// model window right now (summary block · recent turns · tool results ·
|
|
682
|
+
// todos · system+skill+tools), one proportional bar each. Read-only.
|
|
683
|
+
if (trimmed === '/context' || trimmed.startsWith('/context ')) {
|
|
684
|
+
// M4 — args are not a thing; one usage line instead of the classifier
|
|
685
|
+
// fallthrough treating '/context foo' as an unknown-skill prompt.
|
|
686
|
+
if (trimmed !== '/context') {
|
|
687
|
+
chunkEmitter.emit('chunk', chalk.dim('/context takes no arguments — it shows what fills the model window right now\n'));
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
const { runContextCommand } = await import('./ui/context-grid.js');
|
|
691
|
+
await runContextCommand({
|
|
692
|
+
session,
|
|
693
|
+
color: true,
|
|
694
|
+
emit: (l) => chunkEmitter.emit('chunk', l + '\n'),
|
|
695
|
+
});
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
// Task 3 (ux-wave1) — /mode: view-by-cycling / jump the SESSION write
|
|
699
|
+
// mode (config untouched). Bare cycles; advisory|gated|auto jumps
|
|
700
|
+
// (still clamped to the alias role's ceiling). Same core as Shift+Tab.
|
|
701
|
+
if (trimmed === '/mode' || trimmed.startsWith('/mode ')) {
|
|
702
|
+
const modeArg = trimmed === '/mode' ? undefined : (trimmed.slice('/mode '.length).trim() || undefined);
|
|
703
|
+
const { line: modeLine, mode } = runModeCommand(modeArg, cfg.write_mode, cfg.sap[selectedAlias]?.role, selectedAlias);
|
|
704
|
+
chunkEmitter.emit('chunk', chalk.dim(modeLine) + '\n');
|
|
705
|
+
sapStateStore.update({ writeMode: mode });
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
// Task 6 (ux-wave2) — /recap: reprint the orientation recap (session ·
|
|
709
|
+
// todos · plan) for the CURRENT session. Same builder as the
|
|
710
|
+
// resume-time banner; empty recap → one dim placeholder line.
|
|
711
|
+
if (trimmed === '/recap') {
|
|
712
|
+
const { buildRecap } = await import('./session/recap.js');
|
|
713
|
+
const { resolveWorkspacePath } = await import('./projects/workspace.js');
|
|
714
|
+
const recapWs = await resolveWorkspacePath().catch(() => undefined);
|
|
715
|
+
const recapLines = buildRecap(session, process.cwd(), { workspaceDir: recapWs });
|
|
716
|
+
const body = recapLines.length === 0
|
|
717
|
+
? chalk.dim('nothing to recap yet')
|
|
718
|
+
: recapLines.map((l) => chalk.dim(l)).join('\n');
|
|
719
|
+
chunkEmitter.emit('chunk', '\n' + body + '\n');
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
552
722
|
// Phase 2 conversation hygiene (2026-05-16) — /compact summarises
|
|
553
723
|
// older turns via Haiku so subsequent turns ingest ~5k tokens of
|
|
554
724
|
// context instead of 500k+. Saves \$3-8 per follow-up turn.
|
|
555
725
|
if (trimmed === '/compact') {
|
|
556
|
-
const { runCompact, formatCompactResult, buildSummarisationPrompt, serialiseForSummariser
|
|
726
|
+
const { runCompact, formatCompactResult, buildSummarisationPrompt, serialiseForSummariser } = await import('./commands/compact.js');
|
|
557
727
|
const { summariseViaProvider } = await import('./agent/summarise-via-provider.js');
|
|
558
728
|
const { loadConfig } = await import('./config/loader.js');
|
|
559
729
|
const compactCfg = (await loadConfig()).compact;
|
|
560
730
|
const summarise = async (toSummarise) => {
|
|
561
|
-
return await summariseViaProvider(provider, buildSummarisationPrompt(), serialiseForSummariser(toSummarise),
|
|
731
|
+
return await summariseViaProvider(provider, buildSummarisationPrompt(), serialiseForSummariser(toSummarise),
|
|
732
|
+
// model-governance step 2d — compact model resolves env > local >
|
|
733
|
+
// server > built-in (== COMPACTION_MODEL when nothing set).
|
|
734
|
+
{ model: resolveModelRole('compact', cfg), maxTokens: 4000, headers: { 'X-CSForge-Skill': 'compact' } });
|
|
562
735
|
};
|
|
563
736
|
try {
|
|
564
737
|
chunkEmitter.emit('chunk', chalk.dim('\nCompacting older turns into a summary...\n'));
|
|
@@ -641,19 +814,18 @@ export async function runRepl(opts) {
|
|
|
641
814
|
chunkEmitter.emit('chunk', chalk.dim(`\n↪ Rerouting "${preview}" → /${rr.skill}\n`));
|
|
642
815
|
if (rr.warning)
|
|
643
816
|
chunkEmitter.emit('chunk', chalk.dim(` (${rr.warning})\n`));
|
|
644
|
-
|
|
645
|
-
rerouteStatus.start(`/${rr.skill}`);
|
|
817
|
+
turnStatusEmitter.start(`/${rr.skill}`);
|
|
646
818
|
try {
|
|
647
819
|
await runTurn({
|
|
648
820
|
provider,
|
|
649
821
|
userMessage: rr.prompt,
|
|
650
822
|
skill: rr.skill,
|
|
651
|
-
ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: replPreviewHook, chunkEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor },
|
|
823
|
+
ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: replPreviewHook, chunkEmitter, todoEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor },
|
|
652
824
|
chunkEmitter,
|
|
653
825
|
});
|
|
654
826
|
}
|
|
655
827
|
finally {
|
|
656
|
-
|
|
828
|
+
turnStatusEmitter.stop();
|
|
657
829
|
}
|
|
658
830
|
return;
|
|
659
831
|
}
|
|
@@ -672,7 +844,54 @@ export async function runRepl(opts) {
|
|
|
672
844
|
chunkEmitter.emit('chunk', chalk.cyan(`\n${msg}\n`));
|
|
673
845
|
return;
|
|
674
846
|
}
|
|
675
|
-
|
|
847
|
+
// Task 3 (ux-wave3) — /rewind: open the Ink rewind panel. <App> is a
|
|
848
|
+
// separate React tree, so signal it through rewindEmitter (Esc-Esc opens
|
|
849
|
+
// the same panel from inside App). Deps + candidates are wired at the App
|
|
850
|
+
// render call below; the panel handles the empty-list case itself.
|
|
851
|
+
if (trimmed === '/rewind' || trimmed.startsWith('/rewind ')) {
|
|
852
|
+
// M5 — args are not a thing; one usage line instead of the classifier
|
|
853
|
+
// fallthrough treating '/rewind foo' as an unknown-skill prompt.
|
|
854
|
+
if (trimmed !== '/rewind') {
|
|
855
|
+
chunkEmitter.emit('chunk', chalk.dim('/rewind takes no arguments — it opens the session rewind list (Esc Esc does too)\n'));
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
rewindEmitter.emit('open');
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
// /safety-mode on|off — structured Forge Rule confirmation cards.
|
|
862
|
+
// UX-5 #1 (2026-06-12): handleSafetyModeCommand existed (unit-tested)
|
|
863
|
+
// but no REPL path ever routed to it — the toggle was unreachable at
|
|
864
|
+
// runtime. Mirrors the /transport dispatch shape above.
|
|
865
|
+
{
|
|
866
|
+
const msg = matchSafetyModeCommand(trimmed);
|
|
867
|
+
if (msg !== null) {
|
|
868
|
+
chunkEmitter.emit('chunk', chalk.cyan(`\n${msg}\n`));
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
// UX-5 #2 (2026-06-12) — numbered-menu expansion. When the model
|
|
873
|
+
// ended the previous turn with a plain-text numbered menu and the
|
|
874
|
+
// user types a bare `2`, expand the input to that item's text (with
|
|
875
|
+
// a dim echo) so the turn carries real intent instead of a lone
|
|
876
|
+
// digit. Conservative: fires only on pure 1–2 digit input matching
|
|
877
|
+
// an item in a 2+-item list in the LAST assistant message.
|
|
878
|
+
let effectiveInput = trimmed;
|
|
879
|
+
{
|
|
880
|
+
const expansion = maybeExpandNumberSelection(trimmed, lastAssistantText(session.messages));
|
|
881
|
+
if (expansion) {
|
|
882
|
+
chunkEmitter.emit('chunk', chalk.dim(`${expansion.echo}\n`));
|
|
883
|
+
effectiveInput = expansion.text;
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
const parsed = parseShortcut(effectiveInput);
|
|
887
|
+
// D35 (2026-06-11) — explicit slash naming a skill that isn't in the
|
|
888
|
+
// catalog: print the notice ("did you mean" / builtin-with-args) and
|
|
889
|
+
// end the turn. Slash input must NEVER fall through to the classifier
|
|
890
|
+
// (classifyPrompt asserts on it) and must never dispatch to a
|
|
891
|
+
// nonexistent skill. Shared gate: rejectUnknownSkill in classifier.ts.
|
|
892
|
+
if (rejectUnknownSkill(parsed, (m) => chunkEmitter.emit('chunk', chalk.yellow(`\n${m}\n`)))) {
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
676
895
|
// Record the prose body (post-shortcut stripping) for /reroute. Using
|
|
677
896
|
// `parsed.prompt` rather than `trimmed` means:
|
|
678
897
|
// - plain prose: `parsed.prompt === trimmed`, stored as-is
|
|
@@ -806,12 +1025,33 @@ export async function runRepl(opts) {
|
|
|
806
1025
|
// persists the version-N+1 REVISION after the turn.
|
|
807
1026
|
let planResume = null;
|
|
808
1027
|
let planResumeMsgStart = 0;
|
|
1028
|
+
// D-B — the wall-clock start of THIS phase attempt's turn, threaded to
|
|
1029
|
+
// the post-phase audit as the evidence-window cutoff (so an audit never
|
|
1030
|
+
// sees an earlier attempt's tool-call rows in the shared session JSONL).
|
|
1031
|
+
let planPhaseStartedAt = '';
|
|
1032
|
+
// A4 — set only when plan model tiering picks Sonnet for this phase.
|
|
1033
|
+
let planModelOverride;
|
|
1034
|
+
// Task 6 — true while the guarded auto_approve override is active for
|
|
1035
|
+
// this turn; cleared in the runTurn finally (fail-safe: never sticky).
|
|
1036
|
+
let guardedRunFlagSet = false;
|
|
1037
|
+
// Task 7 — true while the plan-phase subagent read-only enforcement is
|
|
1038
|
+
// active for this turn; same lifecycle, cleared in the same finally.
|
|
1039
|
+
let planPhaseFlagSet = false;
|
|
809
1040
|
if (skill === 'abap-plan' && /(^|\s)--resume(\s|$)/.test(prompt)) {
|
|
810
1041
|
try {
|
|
811
1042
|
const { preparePlanResume } = await import('./commands/plan-resume.js');
|
|
1043
|
+
const { inkAwarePrompt } = await import('./agent/loop.js');
|
|
812
1044
|
planResume = await preparePlanResume({
|
|
813
1045
|
body: prompt,
|
|
814
1046
|
cwd: process.cwd(),
|
|
1047
|
+
configMode: cfg.plan_mode,
|
|
1048
|
+
// Audit off-switch (2026-07-06, owner) — config default; per-run
|
|
1049
|
+
// --audit/--no-audit flags in the body override it (parsed inside).
|
|
1050
|
+
configAudit: cfg.plan_audit,
|
|
1051
|
+
// Task 9 — run-lease identity + takeover prompt (concurrent
|
|
1052
|
+
// resume guard; inkAwarePrompt answers '' in headless = decline).
|
|
1053
|
+
sessionId: session.id,
|
|
1054
|
+
prompt: inkAwarePrompt,
|
|
815
1055
|
log: (...lines) => chunkEmitter.emit('chunk', lines.join('\n') + '\n'),
|
|
816
1056
|
});
|
|
817
1057
|
}
|
|
@@ -823,13 +1063,138 @@ export async function runRepl(opts) {
|
|
|
823
1063
|
session.last_object = extractedObject ?? previousObject ?? null;
|
|
824
1064
|
return;
|
|
825
1065
|
}
|
|
1066
|
+
// Task 6 (agentic-flow) — re-audit-on-resume: the last executed phase
|
|
1067
|
+
// has an unresolved audit (crash before the verdict write, auditor
|
|
1068
|
+
// infra outage, or a guarded-mode hand-edit). NO model turn this
|
|
1069
|
+
// dispatch — run the audit chain instead; it re-judges the phase,
|
|
1070
|
+
// persists the verdict, and offers/queues the next phase itself.
|
|
1071
|
+
if (planResume.reauditPhaseId) {
|
|
1072
|
+
try {
|
|
1073
|
+
const { runReauditPass } = await import('./commands/plan-chain.js');
|
|
1074
|
+
const { inkAwarePrompt } = await import('./agent/loop.js');
|
|
1075
|
+
await runReauditPass({
|
|
1076
|
+
savedPath: planResume.path,
|
|
1077
|
+
phaseIds: planResume.reauditPhaseIds ?? [planResume.reauditPhaseId],
|
|
1078
|
+
sessionId: session.id,
|
|
1079
|
+
cwd: process.cwd(),
|
|
1080
|
+
mode: planResume.effectiveMode ?? 'step',
|
|
1081
|
+
modeFlag: planResume.mode,
|
|
1082
|
+
// Off-switch (I3) — footer honesty on re-audit boards; (I2) the
|
|
1083
|
+
// per-run audit flag survives onto chain-built resume commands.
|
|
1084
|
+
auditMode: planResume.auditMode,
|
|
1085
|
+
auditFlag: planResume.auditFlag,
|
|
1086
|
+
prompt: inkAwarePrompt,
|
|
1087
|
+
queueDispatch: (cmd) => pendingDispatchAccessor.set(cmd),
|
|
1088
|
+
log: (...lines) => chunkEmitter.emit('chunk', lines.join('\n') + '\n'),
|
|
1089
|
+
});
|
|
1090
|
+
}
|
|
1091
|
+
catch (err) {
|
|
1092
|
+
chunkEmitter.emit('chunk', chalk.red(`\n[plan] re-audit failed: ${err.message}\n`));
|
|
1093
|
+
}
|
|
1094
|
+
session.last_skill = previousSkill ?? null;
|
|
1095
|
+
session.last_object = extractedObject ?? previousObject ?? null;
|
|
1096
|
+
// Same consume-the-slot mechanics as the post-turn dispatch below.
|
|
1097
|
+
const reauditDispatched = pendingDispatchAccessor.get();
|
|
1098
|
+
// Task 9 (review fix) — release ONLY when the re-audit leg queued
|
|
1099
|
+
// no follow-up. A queued dispatch continues the chain: the lease
|
|
1100
|
+
// is HELD across the gap (the re-entry's same-session re-acquire
|
|
1101
|
+
// is idempotent), or a second session could grab the family lock
|
|
1102
|
+
// mid-chain. A crash before this line leaves a lease that goes
|
|
1103
|
+
// stale with the pid.
|
|
1104
|
+
{
|
|
1105
|
+
const { releaseRunLeaseUnlessChained } = await import('./projects/run-lease.js');
|
|
1106
|
+
releaseRunLeaseUnlessChained(planResume.path, reauditDispatched);
|
|
1107
|
+
}
|
|
1108
|
+
if (reauditDispatched) {
|
|
1109
|
+
pendingDispatchAccessor.set(null);
|
|
1110
|
+
chunkEmitter.emit('chunk', chalk.dim(`↪ Auto-routed: ${reauditDispatched}\n`));
|
|
1111
|
+
setImmediate(() => { void handleSubmit(reauditDispatched); });
|
|
1112
|
+
}
|
|
1113
|
+
return;
|
|
1114
|
+
}
|
|
1115
|
+
// A1 (defect D23) — true bounded phases: EVERY plan-resume turn
|
|
1116
|
+
// (harness-dispatched or manually typed) starts from reset session
|
|
1117
|
+
// messages. The envelope + bounded prompt carry all needed state;
|
|
1118
|
+
// prior transcript is pure context cost (a 6-phase run once
|
|
1119
|
+
// snowballed to a 3.24M-token cache read per call).
|
|
1120
|
+
{
|
|
1121
|
+
const { resetContextForPlanResume } = await import('./commands/plan-resume.js');
|
|
1122
|
+
if (resetContextForPlanResume(session)) {
|
|
1123
|
+
chunkEmitter.emit('chunk', chalk.dim('↪ Plan phase starts in a fresh bounded context — prior transcript cleared\n'));
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
826
1126
|
planResumeMsgStart = session.messages.length;
|
|
1127
|
+
planPhaseStartedAt = new Date().toISOString(); // D-B evidence-window cutoff
|
|
827
1128
|
prompt = planResume.llmPrompt;
|
|
1129
|
+
// C1 mid-phase-continue — record the phase now in flight. Cleared
|
|
1130
|
+
// post-turn the moment a revision is persisted; if this turn is
|
|
1131
|
+
// interrupted first, the marker lets a later bare "continue" re-attach
|
|
1132
|
+
// the audit + next-phase chain (plan-continue.ts).
|
|
1133
|
+
{
|
|
1134
|
+
const { buildPlanInFlight } = await import('./commands/plan-continue.js');
|
|
1135
|
+
session.plan_in_flight = buildPlanInFlight(planResume, planPhaseStartedAt);
|
|
1136
|
+
}
|
|
1137
|
+
// A4 (2026-06-11) — flag-gated per-phase model tiering: design/test/
|
|
1138
|
+
// document-shaped phases (structural signal: the phase's delegateTo
|
|
1139
|
+
// skill) run on Sonnet; everything else — and the flag-off default —
|
|
1140
|
+
// stays on cfg.default_model. The dim notice keeps cost attribution
|
|
1141
|
+
// auditable next to the cost-log line that records the same model.
|
|
1142
|
+
{
|
|
1143
|
+
const { isPlanModelTieringEnabled, selectPlanPhaseModel } = await import('./commands/plan-model-tier.js');
|
|
1144
|
+
const choice = selectPlanPhaseModel({
|
|
1145
|
+
enabled: isPlanModelTieringEnabled(cfg),
|
|
1146
|
+
phaseId: planResume.nextPhaseId,
|
|
1147
|
+
delegateTo: planResume.nextDelegateTo,
|
|
1148
|
+
// model-governance step 2d — the session default now resolves
|
|
1149
|
+
// env > local > server > built-in (== cfg.default_model when nothing set).
|
|
1150
|
+
defaultModel: resolveModelRole('session_default', cfg),
|
|
1151
|
+
});
|
|
1152
|
+
if (choice.model) {
|
|
1153
|
+
planModelOverride = choice.model;
|
|
1154
|
+
chunkEmitter.emit('chunk', chalk.dim(`${choice.notice}\n`));
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
// C1 mid-phase-continue — a router-dispatched abap-plan turn (bare
|
|
1159
|
+
// "continue" after an interrupted phase) with an in-flight marker:
|
|
1160
|
+
// re-attach this turn to the interrupted phase so the post-phase chain
|
|
1161
|
+
// (audit + [y/N] offer) runs instead of the generic save hook. The
|
|
1162
|
+
// --resume branch above and this one are mutually exclusive by the
|
|
1163
|
+
// --resume regex (report §4, no-double-fire guarantee). The user's
|
|
1164
|
+
// prompt is NOT replaced and the context is NOT reset — "continue"
|
|
1165
|
+
// needs the accumulated transcript.
|
|
1166
|
+
let planContinue = null;
|
|
1167
|
+
if (skill === 'abap-plan' && planResume === null && session.plan_in_flight) {
|
|
1168
|
+
const marker = session.plan_in_flight;
|
|
1169
|
+
const { preparePlanContinuation } = await import('./commands/plan-continue.js');
|
|
1170
|
+
const r = preparePlanContinuation({
|
|
1171
|
+
marker,
|
|
1172
|
+
sessionId: session.id,
|
|
1173
|
+
log: (...lines) => chunkEmitter.emit('chunk', lines.join('\n') + '\n'),
|
|
1174
|
+
});
|
|
1175
|
+
if (r.prepared) {
|
|
1176
|
+
planContinue = { prepared: r.prepared, marker };
|
|
1177
|
+
planPhaseStartedAt = marker.attemptStartedAt;
|
|
1178
|
+
planResumeMsgStart = session.messages.length;
|
|
1179
|
+
chunkEmitter.emit('chunk', chalk.dim(`↪ re-attaching to in-flight plan phase ${marker.phaseId} (${r.prepared.path})\n`));
|
|
1180
|
+
}
|
|
1181
|
+
else if (r.stale) {
|
|
1182
|
+
session.plan_in_flight = null; // plan/phase gone for good — marker is noise
|
|
1183
|
+
}
|
|
828
1184
|
}
|
|
1185
|
+
const planContinueForHook = planContinue;
|
|
829
1186
|
// 2026-06-08 — manifest-on-interrupt. Set true once the mid-turn
|
|
830
1187
|
// onPlanManifest hook has persisted the revision, so the post-turn
|
|
831
1188
|
// finishPlanResume below doesn't write it a second time.
|
|
832
1189
|
let planSavedEarly = false;
|
|
1190
|
+
// A1 — outcome of the persisted revision (exact saved path + the
|
|
1191
|
+
// harness-built resume command). Drives the post-turn next-phase offer.
|
|
1192
|
+
let planOutcome = null;
|
|
1193
|
+
// Fix-wave (item 1) — the phase's subagent-dispatch count, read+reset
|
|
1194
|
+
// in the runTurn finally below (the chain can be skipped on error
|
|
1195
|
+
// paths, so the counter must be consumed HERE) and threaded to
|
|
1196
|
+
// runPostPhaseChain for the phase-end report line.
|
|
1197
|
+
let planSubagentDispatches;
|
|
833
1198
|
const planResumeForHook = planResume;
|
|
834
1199
|
// Phase A** wiring fix (2026-05-16) — snapshot pre-turn counters so
|
|
835
1200
|
// pushPostTurnStatus can compute per-turn deltas. The classic-mode
|
|
@@ -847,27 +1212,65 @@ export async function runRepl(opts) {
|
|
|
847
1212
|
// 2026-06-07 — persistent status row lifecycle. Started here (not in
|
|
848
1213
|
// loop.ts) so the try/finally GUARANTEES the row clears even when the
|
|
849
1214
|
// turn throws; loop.ts only narrates activity changes in between.
|
|
850
|
-
|
|
1215
|
+
// (turnStatusEmitter is the module-level singleton import — BUG-2b.)
|
|
851
1216
|
turnStatusEmitter.start(`/${skill}`);
|
|
852
1217
|
try {
|
|
1218
|
+
// Task 6 (review M-2) — guarded chain start: announce and disable
|
|
1219
|
+
// auto_approve for this phase's turn (request_approval consults the
|
|
1220
|
+
// flag and falls through to interactive prompts). Set INSIDE the try
|
|
1221
|
+
// so nothing between set and runTurn can skip the finally-clear.
|
|
1222
|
+
// C1 — a continuation turn re-attaches to the interrupted phase, so
|
|
1223
|
+
// the SAME enforcement (read-only subagents, guarded gate, writes
|
|
1224
|
+
// declaration, audit-off honesty line) is active for it too.
|
|
1225
|
+
const planPhaseCtx = planResume ?? planContinue?.prepared;
|
|
1226
|
+
if (planPhaseCtx) {
|
|
1227
|
+
const { setPlanPhaseActive, setGuardedRunActive, setCurrentPhaseWrites, getAndResetPlanDeviation } = await import('./commands/plan-gate.js');
|
|
1228
|
+
// Task 7 — plan-phase subagent read-only enforcement: set for EVERY
|
|
1229
|
+
// plan-resume phase turn regardless of mode (guarded AND step);
|
|
1230
|
+
// agent_run consults the flag and forces its read-only tool filter.
|
|
1231
|
+
setPlanPhaseActive(true);
|
|
1232
|
+
// Task 10 — the writes declaration of the phase EXECUTING this turn
|
|
1233
|
+
// (fail-safe: undefined ⇒ true, the deviation backstop never fires).
|
|
1234
|
+
// request_approval consults it in guarded mode to deny undeclared
|
|
1235
|
+
// writes. Discard any stale deviation flag from a chain leg that
|
|
1236
|
+
// never reached its post-phase chain so it can't block THIS phase.
|
|
1237
|
+
setCurrentPhaseWrites(planPhaseCtx.nextPhaseWrites ?? true);
|
|
1238
|
+
getAndResetPlanDeviation();
|
|
1239
|
+
planPhaseFlagSet = true;
|
|
1240
|
+
if ((planPhaseCtx.effectiveMode ?? 'step') === 'guarded') {
|
|
1241
|
+
setGuardedRunActive(true);
|
|
1242
|
+
guardedRunFlagSet = true;
|
|
1243
|
+
chunkEmitter.emit('chunk', chalk.dim('▶ guarded mode — auto_approve disabled for this run\n'));
|
|
1244
|
+
}
|
|
1245
|
+
// Off-switch (2026-07-06, owner) — announce plainly that this run's
|
|
1246
|
+
// phases are NOT audited (honesty: off is a choice, never a lie).
|
|
1247
|
+
if ((planPhaseCtx.auditMode ?? 'on') === 'off') {
|
|
1248
|
+
chunkEmitter.emit('chunk', chalk.dim('plan audits: off\n'));
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
853
1251
|
await runTurn({
|
|
854
1252
|
provider,
|
|
855
1253
|
userMessage: prompt,
|
|
856
1254
|
skill: skill,
|
|
857
|
-
ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: replPreviewHook, chunkEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor },
|
|
1255
|
+
ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: replPreviewHook, chunkEmitter, todoEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor },
|
|
858
1256
|
chunkEmitter,
|
|
859
1257
|
signal: currentTurnAbort.signal,
|
|
860
|
-
suppressSaveHook: planResume !== null,
|
|
1258
|
+
suppressSaveHook: planResume !== null || planContinue !== null,
|
|
1259
|
+
// A4 — undefined on every non-plan turn (and when tiering is off).
|
|
1260
|
+
modelOverride: planModelOverride,
|
|
861
1261
|
// Persist the plan revision the instant its manifest is finalised
|
|
862
1262
|
// (before the continuation modal) so a Ctrl+C can't lose the phase.
|
|
863
1263
|
onPlanManifest: planResumeForHook
|
|
864
1264
|
? async (assistantText) => {
|
|
865
1265
|
try {
|
|
866
1266
|
const { finishPlanResume } = await import('./commands/plan-resume.js');
|
|
867
|
-
await finishPlanResume({
|
|
1267
|
+
planOutcome = await finishPlanResume({
|
|
868
1268
|
prepared: planResumeForHook,
|
|
869
1269
|
messages: session.messages,
|
|
870
1270
|
messagesStart: planResumeMsgStart,
|
|
1271
|
+
sessionId: session.id,
|
|
1272
|
+
attemptStartedAt: planPhaseStartedAt || undefined, // D-B item 1
|
|
1273
|
+
auditMode: planResumeForHook.auditMode, // off-switch — no pending injected when 'off'
|
|
871
1274
|
log: (...lines) => chunkEmitter.emit('chunk', lines.join('\n') + '\n'),
|
|
872
1275
|
textOverride: assistantText,
|
|
873
1276
|
});
|
|
@@ -877,7 +1280,34 @@ export async function runRepl(opts) {
|
|
|
877
1280
|
chunkEmitter.emit('chunk', chalk.red(`\n[plan] early revision save failed: ${err.message}\n`));
|
|
878
1281
|
}
|
|
879
1282
|
}
|
|
880
|
-
:
|
|
1283
|
+
: planContinueForHook
|
|
1284
|
+
? async (assistantText) => {
|
|
1285
|
+
// C1 — early-persist the continuation revision the moment the
|
|
1286
|
+
// manifest is finalised (same crash-safety as the --resume hook).
|
|
1287
|
+
try {
|
|
1288
|
+
const { finishPlanContinuation } = await import('./commands/plan-continue.js');
|
|
1289
|
+
const r = await finishPlanContinuation({
|
|
1290
|
+
prepared: planContinueForHook.prepared,
|
|
1291
|
+
marker: planContinueForHook.marker,
|
|
1292
|
+
messages: session.messages,
|
|
1293
|
+
messagesStart: planResumeMsgStart,
|
|
1294
|
+
sessionId: session.id,
|
|
1295
|
+
textOverride: assistantText,
|
|
1296
|
+
log: (...lines) => chunkEmitter.emit('chunk', lines.join('\n') + '\n'),
|
|
1297
|
+
});
|
|
1298
|
+
if (r.kind === 'saved') {
|
|
1299
|
+
planOutcome = r.outcome;
|
|
1300
|
+
planSavedEarly = true;
|
|
1301
|
+
}
|
|
1302
|
+
// 'foreign'/'none'/'not-persisted': leave both flags unset —
|
|
1303
|
+
// the post-turn fallback re-classifies the full turn text and
|
|
1304
|
+
// owns the foreign save / quiet-none handling.
|
|
1305
|
+
}
|
|
1306
|
+
catch (err) {
|
|
1307
|
+
chunkEmitter.emit('chunk', chalk.red(`\n[plan] early continuation save failed: ${err.message}\n`));
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
: undefined,
|
|
881
1311
|
});
|
|
882
1312
|
}
|
|
883
1313
|
catch (err) {
|
|
@@ -891,6 +1321,25 @@ export async function runRepl(opts) {
|
|
|
891
1321
|
finally {
|
|
892
1322
|
turnStatusEmitter.stop();
|
|
893
1323
|
currentTurnAbort = null;
|
|
1324
|
+
// Task 6/7/10 — neither the guarded auto_approve override, the
|
|
1325
|
+
// plan-phase enforcement flag, nor the phase-writes declaration may
|
|
1326
|
+
// outlive the phase turn. (The deviation flag DOES survive this
|
|
1327
|
+
// finally on purpose — runPostPhaseChain below reads+resets it.)
|
|
1328
|
+
if (guardedRunFlagSet || planPhaseFlagSet) {
|
|
1329
|
+
const { setGuardedRunActive, setPlanPhaseActive, setCurrentPhaseWrites, getAndResetPlanPhaseSubagentDispatches } = await import('./commands/plan-gate.js');
|
|
1330
|
+
if (guardedRunFlagSet)
|
|
1331
|
+
setGuardedRunActive(false);
|
|
1332
|
+
if (planPhaseFlagSet) {
|
|
1333
|
+
setPlanPhaseActive(false);
|
|
1334
|
+
setCurrentPhaseWrites(null);
|
|
1335
|
+
// Fix-wave (item 1) — consume the subagent counter in the SAME
|
|
1336
|
+
// finally: the post-phase chain may never run (turn throw / no
|
|
1337
|
+
// manifest ⇒ null planOutcome), and an unconsumed count would
|
|
1338
|
+
// leak into the NEXT phase's report line. The captured value is
|
|
1339
|
+
// threaded to runPostPhaseChain below.
|
|
1340
|
+
planSubagentDispatches = getAndResetPlanPhaseSubagentDispatches();
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
894
1343
|
}
|
|
895
1344
|
// B3 — persist the plan revision from the resume turn's manifest
|
|
896
1345
|
// block (runs even after a turn error: partial assistant content is
|
|
@@ -903,10 +1352,13 @@ export async function runRepl(opts) {
|
|
|
903
1352
|
if (planResume && !planSavedEarly) {
|
|
904
1353
|
try {
|
|
905
1354
|
const { finishPlanResume } = await import('./commands/plan-resume.js');
|
|
906
|
-
await finishPlanResume({
|
|
1355
|
+
planOutcome = await finishPlanResume({
|
|
907
1356
|
prepared: planResume,
|
|
908
1357
|
messages: session.messages,
|
|
909
1358
|
messagesStart: planResumeMsgStart,
|
|
1359
|
+
sessionId: session.id,
|
|
1360
|
+
attemptStartedAt: planPhaseStartedAt || undefined, // D-B item 1
|
|
1361
|
+
auditMode: planResume.auditMode, // off-switch — no pending injected when 'off'
|
|
910
1362
|
log: (...lines) => chunkEmitter.emit('chunk', lines.join('\n') + '\n'),
|
|
911
1363
|
});
|
|
912
1364
|
}
|
|
@@ -914,35 +1366,187 @@ export async function runRepl(opts) {
|
|
|
914
1366
|
chunkEmitter.emit('chunk', chalk.red(`\n[plan] revision save failed: ${err.message}\n`));
|
|
915
1367
|
}
|
|
916
1368
|
}
|
|
917
|
-
|
|
1369
|
+
// A1 (defect D23) + Task 6 — harness-owned continuation, now the full
|
|
1370
|
+
// post-phase chain: audit the just-persisted phase against tool-call
|
|
1371
|
+
// evidence, persist the verdict, handle failures (re-run/waive/block),
|
|
1372
|
+
// gate the transition (step/guarded), then offer or auto-queue the
|
|
1373
|
+
// exact `/abap-plan --resume @<saved-file>` the harness built — the
|
|
1374
|
+
// model never authors this command. The pendingDispatch consumer below
|
|
1375
|
+
// re-enters it, and the resume turn itself resets session messages.
|
|
1376
|
+
if (planResume && planOutcome) {
|
|
1377
|
+
try {
|
|
1378
|
+
const { runPostPhaseChain } = await import('./commands/plan-chain.js');
|
|
1379
|
+
const { inkAwarePrompt } = await import('./agent/loop.js');
|
|
1380
|
+
await runPostPhaseChain({
|
|
1381
|
+
savedPath: planOutcome.savedPath,
|
|
1382
|
+
executedPhaseId: planResume.nextPhaseId,
|
|
1383
|
+
sessionId: session.id,
|
|
1384
|
+
cwd: process.cwd(),
|
|
1385
|
+
mode: planResume.effectiveMode ?? 'step',
|
|
1386
|
+
modeFlag: planResume.mode,
|
|
1387
|
+
// Off-switch — skips the normal post-phase audit when 'off'; the
|
|
1388
|
+
// per-run flag survives onto chain-built resume commands (I2).
|
|
1389
|
+
auditMode: planResume.auditMode,
|
|
1390
|
+
auditFlag: planResume.auditFlag,
|
|
1391
|
+
// D-B — this attempt's turn-start, so the audit windows the evidence.
|
|
1392
|
+
attemptStartedAt: planPhaseStartedAt || undefined,
|
|
1393
|
+
// Fix-wave (item 1) — counter already consumed in the runTurn
|
|
1394
|
+
// finally; thread the captured value for the report line.
|
|
1395
|
+
subagentDispatches: planSubagentDispatches,
|
|
1396
|
+
prompt: inkAwarePrompt,
|
|
1397
|
+
queueDispatch: (cmd) => pendingDispatchAccessor.set(cmd),
|
|
1398
|
+
log: (...lines) => chunkEmitter.emit('chunk', lines.join('\n') + '\n'),
|
|
1399
|
+
});
|
|
1400
|
+
}
|
|
1401
|
+
catch (err) {
|
|
1402
|
+
chunkEmitter.emit('chunk', chalk.red(`\n[plan] post-phase chain failed: ${err.message}\n`));
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
// C1 — continuation post-turn: fallback-persist (hook may not have
|
|
1406
|
+
// fired), then re-attach the chain. Mirrors the --resume blocks above.
|
|
1407
|
+
if (planContinue && !planSavedEarly) {
|
|
1408
|
+
try {
|
|
1409
|
+
const { finishPlanContinuation } = await import('./commands/plan-continue.js');
|
|
1410
|
+
const r = await finishPlanContinuation({
|
|
1411
|
+
prepared: planContinue.prepared,
|
|
1412
|
+
marker: planContinue.marker,
|
|
1413
|
+
messages: session.messages,
|
|
1414
|
+
messagesStart: planResumeMsgStart,
|
|
1415
|
+
sessionId: session.id,
|
|
1416
|
+
log: (...lines) => chunkEmitter.emit('chunk', lines.join('\n') + '\n'),
|
|
1417
|
+
});
|
|
1418
|
+
if (r.kind === 'saved') {
|
|
1419
|
+
planOutcome = r.outcome;
|
|
1420
|
+
}
|
|
1421
|
+
else if (r.kind === 'foreign') {
|
|
1422
|
+
// The turn produced a DIFFERENT plan's manifest and the save hook
|
|
1423
|
+
// was suppressed — run the generic save now so it isn't lost. The
|
|
1424
|
+
// in-flight marker stays: that phase is still unresolved.
|
|
1425
|
+
const { maybeOfferSave } = await import('./agent/loop.js');
|
|
1426
|
+
const { collectTurnAssistantText } = await import('./agent/turn-assistant-text.js');
|
|
1427
|
+
await maybeOfferSave({
|
|
1428
|
+
skill: 'abap-plan',
|
|
1429
|
+
assistantText: collectTurnAssistantText(session.messages, planResumeMsgStart),
|
|
1430
|
+
userMessage: prompt,
|
|
1431
|
+
tokensUsed: 0,
|
|
1432
|
+
model: session.model,
|
|
1433
|
+
emit: (line) => chunkEmitter.emit('chunk', line + '\n'),
|
|
1434
|
+
});
|
|
1435
|
+
}
|
|
1436
|
+
else if (r.kind === 'none') {
|
|
1437
|
+
chunkEmitter.emit('chunk', chalk.dim(`phase ${planContinue.marker.phaseId} still in flight — no completion manifest this turn\n`));
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
catch (err) {
|
|
1441
|
+
chunkEmitter.emit('chunk', chalk.red(`\n[plan] continuation save failed: ${err.message}\n`));
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
if (planContinue && planOutcome) {
|
|
1445
|
+
try {
|
|
1446
|
+
const { runContinuationChain } = await import('./commands/plan-continue.js');
|
|
1447
|
+
const { inkAwarePrompt } = await import('./agent/loop.js');
|
|
1448
|
+
await runContinuationChain({
|
|
1449
|
+
prepared: planContinue.prepared,
|
|
1450
|
+
marker: planContinue.marker,
|
|
1451
|
+
outcome: planOutcome,
|
|
1452
|
+
sessionId: session.id,
|
|
1453
|
+
cwd: process.cwd(),
|
|
1454
|
+
subagentDispatches: planSubagentDispatches,
|
|
1455
|
+
prompt: inkAwarePrompt,
|
|
1456
|
+
queueDispatch: (cmd) => pendingDispatchAccessor.set(cmd),
|
|
1457
|
+
log: (...lines) => chunkEmitter.emit('chunk', lines.join('\n') + '\n'),
|
|
1458
|
+
});
|
|
1459
|
+
}
|
|
1460
|
+
catch (err) {
|
|
1461
|
+
chunkEmitter.emit('chunk', chalk.red(`\n[plan] continuation chain failed: ${err.message}\n`));
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
// C1 — the phase reached a resolved terminal (revision persisted, chain
|
|
1465
|
+
// ran or is queued): the marker's job is done. Applies to --resume AND
|
|
1466
|
+
// continuation dispatches; a null outcome keeps the marker (still in flight).
|
|
1467
|
+
if (planOutcome)
|
|
1468
|
+
session.plan_in_flight = null;
|
|
1469
|
+
// Task 9 — the chain leg for this dispatch truly ends here (verdict
|
|
1470
|
+
// writes + failure prompts above still ran under the lease; every
|
|
1471
|
+
// block between runTurn's finally and this point is exception-wrapped,
|
|
1472
|
+
// so this line is reached on success AND on turn error). Release it —
|
|
1473
|
+
// UNLESS a next-phase dispatch is queued (review fix): the auto-chain
|
|
1474
|
+
// must hold the family lock across the gap to the re-entry (which
|
|
1475
|
+
// re-acquires same-session idempotently), or a second session could
|
|
1476
|
+
// grab it mid-chain. Chosen over releasing in the runTurn finally,
|
|
1477
|
+
// which would leave finishPlanResume/runPostPhaseChain envelope writes
|
|
1478
|
+
// unleased. A hard crash skips this — the leftover lease goes stale by
|
|
1479
|
+
// pid-liveness, so no manual cleanup is ever needed.
|
|
1480
|
+
if (planResume || planContinue) {
|
|
1481
|
+
const { releaseRunLeaseUnlessChained } = await import('./projects/run-lease.js');
|
|
1482
|
+
releaseRunLeaseUnlessChained((planResume ?? planContinue.prepared).path, pendingDispatchAccessor.get());
|
|
1483
|
+
}
|
|
1484
|
+
// A4 — turn-cost line must price at the model that actually ran the
|
|
1485
|
+
// turn (planModelOverride on tiered plan phases), not session.model.
|
|
1486
|
+
pushPostTurnStatus({
|
|
1487
|
+
session,
|
|
1488
|
+
selectedAlias,
|
|
1489
|
+
snapshot: turnSnapshot,
|
|
1490
|
+
chunkEmitter,
|
|
1491
|
+
turnModel: planModelOverride ?? session.model,
|
|
1492
|
+
});
|
|
918
1493
|
session.last_skill = previousSkill ?? null;
|
|
919
1494
|
session.last_object = extractedObject ?? previousObject ?? null;
|
|
920
1495
|
// 2026-05-08: post-turn auto-dispatch. The dispatch_skill tool may
|
|
921
1496
|
// have queued a slash command (typically after the user picked
|
|
922
|
-
// "Switch to /abap-X" via ask_question)
|
|
923
|
-
//
|
|
924
|
-
//
|
|
1497
|
+
// "Switch to /abap-X" via ask_question), or — A1 — the harness
|
|
1498
|
+
// next-phase offer above queued the plan-resume it built itself.
|
|
1499
|
+
// Consume the slot and re-enter handleSubmit with that command —
|
|
1500
|
+
// no user keypress. The user's consent was the picker pick / y answer.
|
|
925
1501
|
const dispatched = pendingDispatchAccessor.get();
|
|
926
1502
|
if (dispatched) {
|
|
927
1503
|
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
|
-
}
|
|
1504
|
+
// No context clear here: resetContextForPlanResume (called in the
|
|
1505
|
+
// plan-resume prepare branch of handleSubmit) is the single owner of
|
|
1506
|
+
// the transcript reset — it runs for typed AND dispatched resumes,
|
|
1507
|
+
// and only AFTER preparePlanResume succeeds, so a failed prepare
|
|
1508
|
+
// never destroys the session context.
|
|
1509
|
+
chunkEmitter.emit('chunk', chalk.dim(`↪ Auto-routed: ${dispatched}\n`));
|
|
940
1510
|
// Schedule on next tick so the current handleSubmit's promise
|
|
941
1511
|
// resolves cleanly before the next one starts. Recursion via
|
|
942
1512
|
// setImmediate keeps the call stack flat across long chains.
|
|
943
1513
|
setImmediate(() => { void handleSubmit(dispatched); });
|
|
944
1514
|
}
|
|
945
1515
|
};
|
|
1516
|
+
// BUG-2b (2026-07-05 live smoke) — single-flight dispatch guard.
|
|
1517
|
+
//
|
|
1518
|
+
// dispatchSubmit does a large chunk of async pre-turn work (classifier
|
|
1519
|
+
// round-trip, plan-resume prepare, the guarded-mode inquirer gate) BEFORE
|
|
1520
|
+
// it calls turnStatusEmitter.start(). During that window .active was
|
|
1521
|
+
// false, so the Footer's submit guard (App.beginSubmit, which now reads
|
|
1522
|
+
// isBusy()) let a second Enter — a history recall, a live palette pick —
|
|
1523
|
+
// dispatch a SECOND concurrent turn: the twin "Routed to /abap-plan",
|
|
1524
|
+
// twin tracker board, and interleaved tool ⏺/⎿ lines the user saw.
|
|
1525
|
+
//
|
|
1526
|
+
// reserve() marks the dispatch busy synchronously, BEFORE any await, so
|
|
1527
|
+
// isBusy() covers the whole prep window; release() in the finally ends it
|
|
1528
|
+
// (after the post-phase chain + auto-dispatch scheduling). The isBusy()
|
|
1529
|
+
// check here is a backstop for any direct caller that bypasses
|
|
1530
|
+
// beginSubmit's guard — it queues the line as steering, exactly like
|
|
1531
|
+
// beginSubmit does mid-turn, instead of starting a twin.
|
|
1532
|
+
const handleSubmit = async (line) => {
|
|
1533
|
+
if (turnStatusEmitter.isBusy()) {
|
|
1534
|
+
const t = line.trim();
|
|
1535
|
+
if (t.length > 0) {
|
|
1536
|
+
enqueueSteering(t);
|
|
1537
|
+
const echo = t.split('\n')[0].slice(0, 60);
|
|
1538
|
+
chunkEmitter.emit('chunk', chalk.cyan(`↳ queued ▸ ${echo}${t.length > echo.length ? '…' : ''}\n`));
|
|
1539
|
+
}
|
|
1540
|
+
return;
|
|
1541
|
+
}
|
|
1542
|
+
turnStatusEmitter.reserve();
|
|
1543
|
+
try {
|
|
1544
|
+
await dispatchSubmit(line);
|
|
1545
|
+
}
|
|
1546
|
+
finally {
|
|
1547
|
+
turnStatusEmitter.release();
|
|
1548
|
+
}
|
|
1549
|
+
};
|
|
946
1550
|
// Phase D5 (2026-05-17) — Ctrl+C handler routed through Ink.
|
|
947
1551
|
// First press during turn: abort the turn (preserves partial work).
|
|
948
1552
|
// Second press during the SAME turn: hard-exit (escape hatch in case
|
|
@@ -971,15 +1575,72 @@ export async function runRepl(opts) {
|
|
|
971
1575
|
resolveDone();
|
|
972
1576
|
}
|
|
973
1577
|
};
|
|
1578
|
+
// Wave1 live-fix (Bug 2) — bracketed paste for the Ink path. Windows
|
|
1579
|
+
// Terminal delivers pasted newlines as `\r`, and a `\r`-only chunk
|
|
1580
|
+
// parses as key.return (ink parse-keypress.js:145-148) → every pasted
|
|
1581
|
+
// line submitted immediately (the live garbled-dispatch bug). With
|
|
1582
|
+
// DEC 2004 on, pastes arrive marker-wrapped; the guard strips the
|
|
1583
|
+
// markers BEFORE Ink's parser sees them (use-input.js:73-75 would
|
|
1584
|
+
// mangle `\x1b[200~` into literal "[200~" text) and re-emits the
|
|
1585
|
+
// whole paste as ONE newline-preserving chunk, which text-input's
|
|
1586
|
+
// Task-7 paste branch buffers without submitting. Cleanup mirrors
|
|
1587
|
+
// classic mode: explicit disable after unmount + a process-exit hook
|
|
1588
|
+
// for the force-exit(130) and crash paths.
|
|
1589
|
+
enableBracketedPaste();
|
|
1590
|
+
process.on('exit', () => disableBracketedPaste());
|
|
1591
|
+
const inkStdin = new InkStdinPasteGuard();
|
|
1592
|
+
process.stdin.pipe(inkStdin);
|
|
1593
|
+
// Task 3 (ux-wave3) — rewind wiring, shared with the classic path through
|
|
1594
|
+
// executeRestore (which holds the single-flight reserve for the FULL
|
|
1595
|
+
// restore — review I1a: a restore counts as busy, submits become steering —
|
|
1596
|
+
// and catches any residual throw, review I2). The Ink approval bridge
|
|
1597
|
+
// produces the exact ApprovalRequest shape render.ts emits onto
|
|
1598
|
+
// approvalEmitter and awaits the resolve — <App> already consumes that
|
|
1599
|
+
// emitter to mount <ApprovalModal>.
|
|
1600
|
+
const inkRequestApproval = (change, risk, transport) => new Promise((resolve) => {
|
|
1601
|
+
approvalEmitter.emit('request', { change, effectiveRisk: risk, transport, resolve });
|
|
1602
|
+
});
|
|
1603
|
+
const runInkRestore = (c) => executeRestore(c, {
|
|
1604
|
+
adt, cfg, alias: selectedAlias,
|
|
1605
|
+
toolCtx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: replPreviewHook, chunkEmitter, todoEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor },
|
|
1606
|
+
suppliedTransport: currentTransportAccessor.get() ?? undefined,
|
|
1607
|
+
requestApproval: inkRequestApproval,
|
|
1608
|
+
// deps.log streams each pipeline step dim to scrollback live; the final
|
|
1609
|
+
// ✓ / ↩ cancelled / ✗ summary comes from executeRestore's print.
|
|
1610
|
+
log: (line) => chunkEmitter.emit('chunk', chalk.dim(line) + '\n'),
|
|
1611
|
+
}, (line) => chunkEmitter.emit('chunk', line + '\n'));
|
|
974
1612
|
const { unmount } = render(React.createElement(App, {
|
|
975
1613
|
chunkEmitter,
|
|
976
1614
|
onSubmit: (line) => { void handleSubmit(line); },
|
|
977
1615
|
onSlashTrigger: () => { },
|
|
978
1616
|
onHistoryTrigger: () => { },
|
|
979
1617
|
getToolCalls: () => session.toolCalls,
|
|
980
|
-
|
|
1618
|
+
// Task 5 (ux-wave2) — todo-panel seed accessor (getToolCalls
|
|
1619
|
+
// pattern): App reads persisted todos on mount and on every
|
|
1620
|
+
// Ctrl+T open, so a resumed session shows its list before any
|
|
1621
|
+
// todo_set fires in this process.
|
|
1622
|
+
getTodos: () => session.todos ?? [],
|
|
1623
|
+
// Task 3 (ux-wave3) — rewind panel accessors. Candidates join
|
|
1624
|
+
// session.toolCalls with the on-disk snapshot list (async fs read),
|
|
1625
|
+
// re-evaluated each open; onRewindRestore fires the gated executor.
|
|
1626
|
+
getRewindCandidates: async () => collectRewindCandidates(session.toolCalls, await snapshots.list()),
|
|
1627
|
+
onRewindRestore: (c) => { void runInkRestore(c); },
|
|
1628
|
+
// Task 4 (ux-wave1) — no writeMode prop: App's mode-cycle handler
|
|
1629
|
+
// reads its base from the SapStateStore, seeded with the startup-
|
|
1630
|
+
// EFFECTIVE mode above (before this render — same function, no
|
|
1631
|
+
// other Ink mount path exists). Clamp idempotency makes the
|
|
1632
|
+
// effective mode a byte-compatible base for runModeCommand.
|
|
1633
|
+
// Task 3 (ux-wave1) — Shift+Tab / Alt+M mode cycling needs the alias
|
|
1634
|
+
// role (ceiling) + name (refusal line) inside App's useInput handler.
|
|
1635
|
+
sapRole: cfg.sap[selectedAlias]?.role,
|
|
1636
|
+
sapAlias: selectedAlias,
|
|
981
1637
|
onInterrupt,
|
|
982
1638
|
}), {
|
|
1639
|
+
// Bug 2 (wave1 live-fix) — Ink reads from the paste guard, not
|
|
1640
|
+
// process.stdin directly. The guard satisfies Ink's full stdin
|
|
1641
|
+
// surface (isTTY / setEncoding / ref / unref / setRawMode /
|
|
1642
|
+
// 'readable' + read()) — pinned by ink-stdin-guard.test.ts.
|
|
1643
|
+
stdin: inkStdin,
|
|
983
1644
|
// exitOnCtrlC: false — Ink would otherwise call process.exit()
|
|
984
1645
|
// on Ctrl+C before onInterrupt can decide whether to abort the
|
|
985
1646
|
// in-flight turn or exit cleanly.
|
|
@@ -996,6 +1657,11 @@ export async function runRepl(opts) {
|
|
|
996
1657
|
});
|
|
997
1658
|
await donePromise;
|
|
998
1659
|
unmount();
|
|
1660
|
+
// Bug 2 (wave1 live-fix) — undo the paste plumbing so the user's
|
|
1661
|
+
// shell doesn't keep receiving marker-wrapped pastes after exit.
|
|
1662
|
+
process.stdin.unpipe(inkStdin);
|
|
1663
|
+
inkStdin.destroy();
|
|
1664
|
+
disableBracketedPaste();
|
|
999
1665
|
// Restore the terminal cursor we hid on Ink mount.
|
|
1000
1666
|
process.stdout.write('\x1b[?25h');
|
|
1001
1667
|
// Print session summary + resume hint after Ink unmounts so the
|
|
@@ -1038,6 +1704,25 @@ export async function runRepl(opts) {
|
|
|
1038
1704
|
// Disable bracketed paste on shutdown so the user's shell prompt after
|
|
1039
1705
|
// CSPeach exits doesn't keep receiving wrapped pastes.
|
|
1040
1706
|
process.on('exit', () => disableBracketedPaste());
|
|
1707
|
+
// UX-5 #5 (2026-06-12) — piped-stdin support. Lines piped into the REPL
|
|
1708
|
+
// (`"/cost`n/exit" | cspeach`) arrive BEFORE the loop's first rl.question
|
|
1709
|
+
// is pending; readline emits them as 'line' EVENTS that nobody consumed,
|
|
1710
|
+
// so every line was silently dropped and EOF hard-exited the process.
|
|
1711
|
+
// The buffer queues those early lines (with a BOM strip on the first —
|
|
1712
|
+
// PowerShell pipes inject one); the loop below drains the queue before
|
|
1713
|
+
// calling rl.question, and EOF only ends the session after the queue is
|
|
1714
|
+
// empty. Attached ONLY for non-TTY stdin: interactive type-ahead during
|
|
1715
|
+
// a turn keeps its existing semantics.
|
|
1716
|
+
const pipedStdin = !process.stdin.isTTY;
|
|
1717
|
+
const earlyLines = pipedStdin ? attachEarlyLineBuffer(rl) : noopEarlyLineBuffer();
|
|
1718
|
+
// B2 fix (2026-06-12) — classic-mode notice channel. Tool handlers emit
|
|
1719
|
+
// 'warn'/'info' (and pre-formatted 'chunk' lines like the write verify
|
|
1720
|
+
// chain) on ctx.chunkEmitter; in classic mode that slot used to be
|
|
1721
|
+
// undefined, so every Rule 9 transport notice was silently dropped.
|
|
1722
|
+
// This emitter renders them straight to stdout. It is intentionally NOT
|
|
1723
|
+
// passed as RunTurnParams.chunkEmitter — the loop's render functions
|
|
1724
|
+
// keep their direct-stdout classic path (spinner stays enabled).
|
|
1725
|
+
const classicOutputEmitter = createClassicOutputEmitter();
|
|
1041
1726
|
// Prompt string: 'CSPeach ' + '›' in peach colour
|
|
1042
1727
|
const promptStr = 'CSPeach ' + chalk.hex(PEACH)('›') + ' ';
|
|
1043
1728
|
// When we dispatch an inquirer sub-prompt (coaching picker, pickSkill),
|
|
@@ -1084,6 +1769,11 @@ export async function runRepl(opts) {
|
|
|
1084
1769
|
return;
|
|
1085
1770
|
if (turnInProgress)
|
|
1086
1771
|
return; // turn handler owns the abort + UX
|
|
1772
|
+
// UX-5 #5 — piped stdin: EOF must NOT hard-exit while queued lines are
|
|
1773
|
+
// still unprocessed (or while a builtin like /cost is mid-flight). The
|
|
1774
|
+
// loop drains the queue and owns shutdown via earlyLines.closed().
|
|
1775
|
+
if (pipedStdin)
|
|
1776
|
+
return;
|
|
1087
1777
|
console.log('\nGoodbye.\n');
|
|
1088
1778
|
process.exit(0);
|
|
1089
1779
|
});
|
|
@@ -1255,6 +1945,23 @@ export async function runRepl(opts) {
|
|
|
1255
1945
|
})),
|
|
1256
1946
|
log: (line) => console.log(chalk.dim(line)),
|
|
1257
1947
|
});
|
|
1948
|
+
// 2026-06-15: one-time output-namespace migration. The customer-output
|
|
1949
|
+
// root was renamed from the dead-brand `.abapforge/` to `.cspeach/`.
|
|
1950
|
+
// Workspaces that still have a top-level `.abapforge/` get offered a
|
|
1951
|
+
// one-time merge-move into `.cspeach/`. The dual-read resolver keeps old
|
|
1952
|
+
// projects working if the user declines; "never" suppresses future offers.
|
|
1953
|
+
await maybeOfferAbapforgeMigration(findProjectRoot(process.cwd()) ?? process.cwd(), {
|
|
1954
|
+
prompt: async ({ legacyDir, newDir }) => withInquirer(() => select({
|
|
1955
|
+
message: `Found legacy CSPeach outputs in ${legacyDir}. The output folder is now '.cspeach/'. Move them?`,
|
|
1956
|
+
default: 'move',
|
|
1957
|
+
choices: [
|
|
1958
|
+
{ value: 'move', name: `Move everything into ${newDir} (recommended)` },
|
|
1959
|
+
{ value: 'skip', name: `Not now — ask me again next time` },
|
|
1960
|
+
{ value: 'never', name: `Leave it and never ask again` },
|
|
1961
|
+
],
|
|
1962
|
+
})),
|
|
1963
|
+
log: (line) => console.log(chalk.dim(line)),
|
|
1964
|
+
});
|
|
1258
1965
|
while (true) {
|
|
1259
1966
|
// Re-attach the bare-Esc detector every iteration. Inquirer prompts
|
|
1260
1967
|
// (slash-picker, approval, ask_question, safety-confirm) remove
|
|
@@ -1270,15 +1977,27 @@ export async function runRepl(opts) {
|
|
|
1270
1977
|
const dispatchedCmd = pendingDispatchAccessor.get();
|
|
1271
1978
|
if (dispatchedCmd) {
|
|
1272
1979
|
pendingDispatchAccessor.set(null);
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
}
|
|
1980
|
+
// No context clear here: resetContextForPlanResume (called in the
|
|
1981
|
+
// plan-resume prepare branch below) is the single owner of the
|
|
1982
|
+
// transcript reset — it runs for typed AND dispatched resumes, and
|
|
1983
|
+
// only AFTER preparePlanResume succeeds, so a failed prepare never
|
|
1984
|
+
// destroys the session context.
|
|
1985
|
+
console.log(chalk.dim(`↪ Auto-routed: ${dispatchedCmd}`));
|
|
1280
1986
|
line = dispatchedCmd;
|
|
1281
1987
|
}
|
|
1988
|
+
else if (earlyLines.size() > 0) {
|
|
1989
|
+
// UX-5 #5 — drain piped lines queued while no question was pending.
|
|
1990
|
+
// Echo with the prompt string so transcripts/CI logs show what ran.
|
|
1991
|
+
line = earlyLines.shift();
|
|
1992
|
+
console.log(promptStr + line);
|
|
1993
|
+
}
|
|
1994
|
+
else if (earlyLines.closed()) {
|
|
1995
|
+
// Piped stdin hit EOF and every queued line has been processed —
|
|
1996
|
+
// end the session the same way /exit does.
|
|
1997
|
+
printGoodbye(session);
|
|
1998
|
+
rl.close();
|
|
1999
|
+
return 0;
|
|
2000
|
+
}
|
|
1282
2001
|
else {
|
|
1283
2002
|
const effectivePrompt = pendingPrefill ? promptStr + pendingPrefill : promptStr;
|
|
1284
2003
|
try {
|
|
@@ -1296,6 +2015,11 @@ export async function runRepl(opts) {
|
|
|
1296
2015
|
// discriminates the two cases reliably.
|
|
1297
2016
|
const rlAny = rl;
|
|
1298
2017
|
if (rlAny.closed) {
|
|
2018
|
+
// UX-5 #5 — piped stdin: a multi-line chunk can resolve the
|
|
2019
|
+
// pending question with line 1 and QUEUE line 2+ before EOF
|
|
2020
|
+
// closes the readline. Drain the queue before exiting.
|
|
2021
|
+
if (earlyLines.size() > 0)
|
|
2022
|
+
continue;
|
|
1299
2023
|
console.log('\nGoodbye.\n');
|
|
1300
2024
|
rl.close();
|
|
1301
2025
|
return 0;
|
|
@@ -1314,8 +2038,9 @@ export async function runRepl(opts) {
|
|
|
1314
2038
|
const trimmed = line.trim();
|
|
1315
2039
|
if (!trimmed)
|
|
1316
2040
|
continue;
|
|
1317
|
-
// Persist non-empty input to history file
|
|
1318
|
-
|
|
2041
|
+
// Persist non-empty input to history file (Task 8: consecutive
|
|
2042
|
+
// duplicates are skipped inside the shared writer).
|
|
2043
|
+
appendHistoryEntry(trimmed);
|
|
1319
2044
|
// 2026-05-01 fix: rl.question() does NOT auto-populate readline's internal
|
|
1320
2045
|
// history (only the 'line' event does). Without this manual push, arrow-up
|
|
1321
2046
|
// cycles only through the file-loaded seed from session start — commands
|
|
@@ -1375,16 +2100,80 @@ export async function runRepl(opts) {
|
|
|
1375
2100
|
await runCostCommand(session.id);
|
|
1376
2101
|
continue;
|
|
1377
2102
|
}
|
|
2103
|
+
// Task 4 (ux-wave3) — classic-mode /export audit [filename]. Mirrors the
|
|
2104
|
+
// Ink handler: build the signable audit record, write it to cwd, print path.
|
|
2105
|
+
if (trimmed === '/export' || trimmed.startsWith('/export ')) {
|
|
2106
|
+
const { parseExportArgs, runExportAuditCommand } = await import('./commands/export-audit.js');
|
|
2107
|
+
const { sub, filename } = parseExportArgs(trimmed.slice('/export'.length));
|
|
2108
|
+
if (sub !== 'audit') {
|
|
2109
|
+
console.log(chalk.dim('Usage: /export audit [filename]'));
|
|
2110
|
+
continue;
|
|
2111
|
+
}
|
|
2112
|
+
await runExportAuditCommand({
|
|
2113
|
+
session,
|
|
2114
|
+
transport: getCurrentTransport(),
|
|
2115
|
+
filename,
|
|
2116
|
+
emit: (l) => console.log(l),
|
|
2117
|
+
});
|
|
2118
|
+
continue;
|
|
2119
|
+
}
|
|
2120
|
+
// Task 5 (ux-wave3) — classic-mode /context. Mirrors the Ink handler:
|
|
2121
|
+
// build + print the composition grid (same lines, minus color).
|
|
2122
|
+
if (trimmed === '/context' || trimmed.startsWith('/context ')) {
|
|
2123
|
+
// M4 — one usage line instead of the classifier fallthrough.
|
|
2124
|
+
if (trimmed !== '/context') {
|
|
2125
|
+
console.log(chalk.dim('/context takes no arguments — it shows what fills the model window right now'));
|
|
2126
|
+
continue;
|
|
2127
|
+
}
|
|
2128
|
+
const { runContextCommand } = await import('./ui/context-grid.js');
|
|
2129
|
+
await runContextCommand({
|
|
2130
|
+
session,
|
|
2131
|
+
color: false,
|
|
2132
|
+
emit: (l) => console.log(l),
|
|
2133
|
+
});
|
|
2134
|
+
continue;
|
|
2135
|
+
}
|
|
2136
|
+
// Task 3 (ux-wave1) — classic-mode /mode. Mirrors the Ink handler:
|
|
2137
|
+
// bare cycles the SESSION write mode, advisory|gated|auto jumps
|
|
2138
|
+
// (clamped to the alias role's ceiling), config never touched.
|
|
2139
|
+
if (trimmed === '/mode' || trimmed.startsWith('/mode ')) {
|
|
2140
|
+
const modeArg = trimmed === '/mode' ? undefined : (trimmed.slice('/mode '.length).trim() || undefined);
|
|
2141
|
+
const { line: modeLine, mode } = runModeCommand(modeArg, cfg.write_mode, cfg.sap[selectedAlias]?.role, selectedAlias);
|
|
2142
|
+
console.log(chalk.dim(modeLine));
|
|
2143
|
+
sapStateStore.update({ writeMode: mode });
|
|
2144
|
+
continue;
|
|
2145
|
+
}
|
|
2146
|
+
// Task 6 (ux-wave2) — classic-mode /recap. Mirrors the Ink handler:
|
|
2147
|
+
// reprint the orientation recap (session · todos · plan) for the
|
|
2148
|
+
// CURRENT session; empty recap → one dim placeholder line.
|
|
2149
|
+
if (trimmed === '/recap') {
|
|
2150
|
+
const { buildRecap } = await import('./session/recap.js');
|
|
2151
|
+
const { resolveWorkspacePath } = await import('./projects/workspace.js');
|
|
2152
|
+
const recapWs = await resolveWorkspacePath().catch(() => undefined);
|
|
2153
|
+
const recapLines = buildRecap(session, process.cwd(), { workspaceDir: recapWs });
|
|
2154
|
+
console.log('');
|
|
2155
|
+
if (recapLines.length === 0) {
|
|
2156
|
+
console.log(chalk.dim('nothing to recap yet'));
|
|
2157
|
+
}
|
|
2158
|
+
else {
|
|
2159
|
+
for (const recapLine of recapLines)
|
|
2160
|
+
console.log(chalk.dim(recapLine));
|
|
2161
|
+
}
|
|
2162
|
+
continue;
|
|
2163
|
+
}
|
|
1378
2164
|
// Phase 2 conversation hygiene (2026-05-16) — classic-mode /compact.
|
|
1379
2165
|
// Summarises older turns via Haiku 4.5 so subsequent turns ingest ~5k
|
|
1380
2166
|
// tokens of context instead of 500k+. Backs up session pre-mutation.
|
|
1381
2167
|
if (trimmed === '/compact') {
|
|
1382
|
-
const { runCompact, formatCompactResult, buildSummarisationPrompt, serialiseForSummariser
|
|
2168
|
+
const { runCompact, formatCompactResult, buildSummarisationPrompt, serialiseForSummariser } = await import('./commands/compact.js');
|
|
1383
2169
|
const { summariseViaProvider } = await import('./agent/summarise-via-provider.js');
|
|
1384
2170
|
const { loadConfig } = await import('./config/loader.js');
|
|
1385
2171
|
const compactCfg = (await loadConfig()).compact;
|
|
1386
2172
|
const summarise = async (toSummarise) => {
|
|
1387
|
-
return await summariseViaProvider(provider, buildSummarisationPrompt(), serialiseForSummariser(toSummarise),
|
|
2173
|
+
return await summariseViaProvider(provider, buildSummarisationPrompt(), serialiseForSummariser(toSummarise),
|
|
2174
|
+
// model-governance step 2d — compact model resolves env > local >
|
|
2175
|
+
// server > built-in (== COMPACTION_MODEL when nothing set).
|
|
2176
|
+
{ model: resolveModelRole('compact', cfg), maxTokens: 4000, headers: { 'X-CSForge-Skill': 'compact' } });
|
|
1388
2177
|
};
|
|
1389
2178
|
try {
|
|
1390
2179
|
console.log(chalk.dim('\nCompacting older turns into a summary...'));
|
|
@@ -1480,7 +2269,7 @@ export async function runRepl(opts) {
|
|
|
1480
2269
|
provider,
|
|
1481
2270
|
userMessage: rr.prompt,
|
|
1482
2271
|
skill: rr.skill,
|
|
1483
|
-
ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: replPreviewHook, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor },
|
|
2272
|
+
ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: replPreviewHook, chunkEmitter: classicOutputEmitter, todoEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor },
|
|
1484
2273
|
});
|
|
1485
2274
|
continue;
|
|
1486
2275
|
}
|
|
@@ -1498,6 +2287,35 @@ export async function runRepl(opts) {
|
|
|
1498
2287
|
console.log(chalk.cyan(msg));
|
|
1499
2288
|
continue;
|
|
1500
2289
|
}
|
|
2290
|
+
// Task 3 (ux-wave3) — classic-mode /rewind: numbered inquirer list + confirm
|
|
2291
|
+
// + gated restore. Reuses the identical candidate collector and executor as
|
|
2292
|
+
// the Ink panel (executeRestore); only the surface differs.
|
|
2293
|
+
if (trimmed === '/rewind' || trimmed.startsWith('/rewind ')) {
|
|
2294
|
+
// M5 — one usage line instead of the classifier fallthrough.
|
|
2295
|
+
if (trimmed !== '/rewind') {
|
|
2296
|
+
console.log(chalk.dim('/rewind takes no arguments — it opens the session rewind list'));
|
|
2297
|
+
continue;
|
|
2298
|
+
}
|
|
2299
|
+
await runClassicRewind({
|
|
2300
|
+
toolCalls: session.toolCalls,
|
|
2301
|
+
adt,
|
|
2302
|
+
cfg,
|
|
2303
|
+
alias: selectedAlias,
|
|
2304
|
+
toolCtx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: replPreviewHook, chunkEmitter: classicOutputEmitter, todoEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor },
|
|
2305
|
+
suppliedTransport: currentTransportAccessor.get() ?? undefined,
|
|
2306
|
+
print: (line) => console.log(line),
|
|
2307
|
+
});
|
|
2308
|
+
continue;
|
|
2309
|
+
}
|
|
2310
|
+
// /safety-mode on|off — structured Forge Rule confirmation cards.
|
|
2311
|
+
// UX-5 #1 (2026-06-12): classic-path mirror of the Ink dispatch above.
|
|
2312
|
+
{
|
|
2313
|
+
const msg = matchSafetyModeCommand(trimmed);
|
|
2314
|
+
if (msg !== null) {
|
|
2315
|
+
console.log(chalk.cyan(msg));
|
|
2316
|
+
continue;
|
|
2317
|
+
}
|
|
2318
|
+
}
|
|
1501
2319
|
// 2026-05-01: live slash-command picker. When the user types something
|
|
1502
2320
|
// that LOOKS like a slash command but isn't an exact match — `/`,
|
|
1503
2321
|
// `/abap-r`, `/fix`, etc. — open a search-as-you-type picker
|
|
@@ -1616,7 +2434,25 @@ export async function runRepl(opts) {
|
|
|
1616
2434
|
}
|
|
1617
2435
|
// Any non-empty submit clears the empty-prefill latch.
|
|
1618
2436
|
lastEmptyPrefill = null;
|
|
2437
|
+
// UX-5 #2 (2026-06-12) — numbered-menu expansion (classic mirror of the
|
|
2438
|
+
// Ink block above). Bare `2` after a plain-text numbered menu expands
|
|
2439
|
+
// to that item's text with a dim echo; everything else passes through.
|
|
2440
|
+
{
|
|
2441
|
+
const expansion = maybeExpandNumberSelection(resolvedTrimmed, lastAssistantText(session.messages));
|
|
2442
|
+
if (expansion) {
|
|
2443
|
+
console.log(chalk.dim(expansion.echo));
|
|
2444
|
+
resolvedTrimmed = expansion.text;
|
|
2445
|
+
}
|
|
2446
|
+
}
|
|
1619
2447
|
const parsed = parseShortcut(resolvedTrimmed);
|
|
2448
|
+
// D35 (2026-06-11) — explicit slash naming a skill that isn't in the
|
|
2449
|
+
// catalog: print the notice ("did you mean" / builtin-with-args) and
|
|
2450
|
+
// end the turn. Slash input must NEVER fall through to the classifier
|
|
2451
|
+
// (classifyPrompt asserts on it) and must never dispatch to a
|
|
2452
|
+
// nonexistent skill. Shared gate: rejectUnknownSkill in classifier.ts.
|
|
2453
|
+
if (rejectUnknownSkill(parsed, (m) => console.log(chalk.yellow(`\n${m}\n`)))) {
|
|
2454
|
+
continue;
|
|
2455
|
+
}
|
|
1620
2456
|
// Record the prose body (post-shortcut stripping) for /reroute. Using
|
|
1621
2457
|
// `parsed.prompt` rather than `trimmed` means:
|
|
1622
2458
|
// - plain prose: `parsed.prompt === trimmed`, stored as-is
|
|
@@ -1743,12 +2579,28 @@ export async function runRepl(opts) {
|
|
|
1743
2579
|
// envelope revision after the turn.
|
|
1744
2580
|
let planResume = null;
|
|
1745
2581
|
let planResumeMsgStart = 0;
|
|
2582
|
+
// D-B — see the Ink-path mirror above: this attempt's turn-start cutoff.
|
|
2583
|
+
let planPhaseStartedAt = '';
|
|
2584
|
+
// A4 — set only when plan model tiering picks Sonnet for this phase.
|
|
2585
|
+
let planModelOverride;
|
|
2586
|
+
// Task 6 — guarded auto_approve override lifetime (see Ink path).
|
|
2587
|
+
let guardedRunFlagSet = false;
|
|
2588
|
+
// Task 7 — plan-phase subagent read-only enforcement lifetime (see Ink path).
|
|
2589
|
+
let planPhaseFlagSet = false;
|
|
1746
2590
|
if (skill === 'abap-plan' && /(^|\s)--resume(\s|$)/.test(prompt)) {
|
|
1747
2591
|
try {
|
|
1748
2592
|
const { preparePlanResume } = await import('./commands/plan-resume.js');
|
|
2593
|
+
const { inkAwarePrompt } = await import('./agent/loop.js');
|
|
1749
2594
|
planResume = await preparePlanResume({
|
|
1750
2595
|
body: prompt,
|
|
1751
2596
|
cwd: process.cwd(),
|
|
2597
|
+
configMode: cfg.plan_mode,
|
|
2598
|
+
// Audit off-switch (2026-07-06, owner) — config default; per-run
|
|
2599
|
+
// --audit/--no-audit flags in the body override it (parsed inside).
|
|
2600
|
+
configAudit: cfg.plan_audit,
|
|
2601
|
+
// Task 9 — run-lease identity + takeover prompt (classic mirror).
|
|
2602
|
+
sessionId: session.id,
|
|
2603
|
+
prompt: inkAwarePrompt,
|
|
1752
2604
|
log: (...lines) => lines.forEach((l) => console.log(l)),
|
|
1753
2605
|
});
|
|
1754
2606
|
}
|
|
@@ -1760,12 +2612,110 @@ export async function runRepl(opts) {
|
|
|
1760
2612
|
session.last_object = extractedObject ?? previousObject ?? null;
|
|
1761
2613
|
continue;
|
|
1762
2614
|
}
|
|
2615
|
+
// Task 6 — re-audit-on-resume (classic mirror of the Ink branch; see
|
|
2616
|
+
// there for the design note). No model turn: the chain re-judges the
|
|
2617
|
+
// unresolved phase and queues any follow-up through pendingDispatch,
|
|
2618
|
+
// which the loop-top consumer picks up on `continue`.
|
|
2619
|
+
if (planResume.reauditPhaseId) {
|
|
2620
|
+
try {
|
|
2621
|
+
const { runReauditPass } = await import('./commands/plan-chain.js');
|
|
2622
|
+
const { inkAwarePrompt } = await import('./agent/loop.js');
|
|
2623
|
+
await runReauditPass({
|
|
2624
|
+
savedPath: planResume.path,
|
|
2625
|
+
phaseIds: planResume.reauditPhaseIds ?? [planResume.reauditPhaseId],
|
|
2626
|
+
sessionId: session.id,
|
|
2627
|
+
cwd: process.cwd(),
|
|
2628
|
+
mode: planResume.effectiveMode ?? 'step',
|
|
2629
|
+
modeFlag: planResume.mode,
|
|
2630
|
+
// Off-switch (I3/I2) — classic mirror; see the Ink re-audit site.
|
|
2631
|
+
auditMode: planResume.auditMode,
|
|
2632
|
+
auditFlag: planResume.auditFlag,
|
|
2633
|
+
prompt: inkAwarePrompt,
|
|
2634
|
+
queueDispatch: (cmd) => pendingDispatchAccessor.set(cmd),
|
|
2635
|
+
log: (...lines) => lines.forEach((l) => console.log(l)),
|
|
2636
|
+
});
|
|
2637
|
+
}
|
|
2638
|
+
catch (err) {
|
|
2639
|
+
console.log(chalk.red(`\n[plan] re-audit failed: ${err.message}\n`));
|
|
2640
|
+
}
|
|
2641
|
+
// Task 9 (review fix) — release ONLY when the re-audit leg queued no
|
|
2642
|
+
// follow-up (classic mirror; the loop-top pre-prompt consumer picks
|
|
2643
|
+
// up a queued dispatch on `continue`, so the slot still holds it
|
|
2644
|
+
// here). See the Ink branch for the hold-across-chain design note.
|
|
2645
|
+
{
|
|
2646
|
+
const { releaseRunLeaseUnlessChained } = await import('./projects/run-lease.js');
|
|
2647
|
+
releaseRunLeaseUnlessChained(planResume.path, pendingDispatchAccessor.get());
|
|
2648
|
+
}
|
|
2649
|
+
session.last_skill = previousSkill ?? null;
|
|
2650
|
+
session.last_object = extractedObject ?? previousObject ?? null;
|
|
2651
|
+
continue;
|
|
2652
|
+
}
|
|
2653
|
+
// A1 (defect D23) — see the Ink path: every plan-resume turn starts
|
|
2654
|
+
// from reset session messages (true bounded phases).
|
|
2655
|
+
{
|
|
2656
|
+
const { resetContextForPlanResume } = await import('./commands/plan-resume.js');
|
|
2657
|
+
if (resetContextForPlanResume(session)) {
|
|
2658
|
+
console.log(chalk.dim('↪ Plan phase starts in a fresh bounded context — prior transcript cleared'));
|
|
2659
|
+
}
|
|
2660
|
+
}
|
|
1763
2661
|
planResumeMsgStart = session.messages.length;
|
|
2662
|
+
planPhaseStartedAt = new Date().toISOString(); // D-B evidence-window cutoff
|
|
1764
2663
|
prompt = planResume.llmPrompt;
|
|
2664
|
+
// C1 mid-phase-continue — record the phase now in flight (classic
|
|
2665
|
+
// mirror of the Ink marker-set; see there for the full design note).
|
|
2666
|
+
{
|
|
2667
|
+
const { buildPlanInFlight } = await import('./commands/plan-continue.js');
|
|
2668
|
+
session.plan_in_flight = buildPlanInFlight(planResume, planPhaseStartedAt);
|
|
2669
|
+
}
|
|
2670
|
+
// A4 — flag-gated per-phase model tiering (classic-mode mirror of the
|
|
2671
|
+
// Ink path above; see that block for the design note).
|
|
2672
|
+
{
|
|
2673
|
+
const { isPlanModelTieringEnabled, selectPlanPhaseModel } = await import('./commands/plan-model-tier.js');
|
|
2674
|
+
const choice = selectPlanPhaseModel({
|
|
2675
|
+
enabled: isPlanModelTieringEnabled(cfg),
|
|
2676
|
+
phaseId: planResume.nextPhaseId,
|
|
2677
|
+
delegateTo: planResume.nextDelegateTo,
|
|
2678
|
+
// model-governance step 2d — the session default now resolves
|
|
2679
|
+
// env > local > server > built-in (== cfg.default_model when nothing set).
|
|
2680
|
+
defaultModel: resolveModelRole('session_default', cfg),
|
|
2681
|
+
});
|
|
2682
|
+
if (choice.model) {
|
|
2683
|
+
planModelOverride = choice.model;
|
|
2684
|
+
console.log(chalk.dim(choice.notice));
|
|
2685
|
+
}
|
|
2686
|
+
}
|
|
2687
|
+
}
|
|
2688
|
+
// C1 mid-phase-continue — continuation detect + prepare (classic mirror
|
|
2689
|
+
// of the Ink branch; see there for the full design note: mutually
|
|
2690
|
+
// exclusive with --resume, prompt NOT replaced, context NOT reset).
|
|
2691
|
+
let planContinue = null;
|
|
2692
|
+
if (skill === 'abap-plan' && planResume === null && session.plan_in_flight) {
|
|
2693
|
+
const marker = session.plan_in_flight;
|
|
2694
|
+
const { preparePlanContinuation } = await import('./commands/plan-continue.js');
|
|
2695
|
+
const r = preparePlanContinuation({
|
|
2696
|
+
marker,
|
|
2697
|
+
sessionId: session.id,
|
|
2698
|
+
log: (...lines) => lines.forEach((l) => console.log(l)),
|
|
2699
|
+
});
|
|
2700
|
+
if (r.prepared) {
|
|
2701
|
+
planContinue = { prepared: r.prepared, marker };
|
|
2702
|
+
planPhaseStartedAt = marker.attemptStartedAt;
|
|
2703
|
+
planResumeMsgStart = session.messages.length;
|
|
2704
|
+
console.log(chalk.dim(`↪ re-attaching to in-flight plan phase ${marker.phaseId} (${r.prepared.path})`));
|
|
2705
|
+
}
|
|
2706
|
+
else if (r.stale) {
|
|
2707
|
+
session.plan_in_flight = null; // plan/phase gone for good — marker is noise
|
|
2708
|
+
}
|
|
1765
2709
|
}
|
|
2710
|
+
const planContinueForHook = planContinue;
|
|
1766
2711
|
// 2026-06-08 — manifest-on-interrupt (classic path). See the Ink path for
|
|
1767
2712
|
// the full rationale; persist the revision mid-turn so a Ctrl+C can't lose it.
|
|
1768
2713
|
let planSavedEarly = false;
|
|
2714
|
+
// A1 — persisted-revision outcome; drives the post-turn next-phase offer.
|
|
2715
|
+
let planOutcome = null;
|
|
2716
|
+
// Fix-wave (item 1) — classic mirror of the Ink path: subagent counter
|
|
2717
|
+
// consumed in the runTurn finally, threaded to runPostPhaseChain.
|
|
2718
|
+
let planSubagentDispatches;
|
|
1769
2719
|
const planResumeForHook = planResume;
|
|
1770
2720
|
const turnStart = Date.now();
|
|
1771
2721
|
// H1 — `session.usage` is session-LIFETIME (initialised once in
|
|
@@ -1815,21 +2765,53 @@ export async function runRepl(opts) {
|
|
|
1815
2765
|
};
|
|
1816
2766
|
rl.on('SIGINT', onSigint);
|
|
1817
2767
|
try {
|
|
2768
|
+
// Task 6 (review M-2) — guarded chain start (classic mirror): set
|
|
2769
|
+
// INSIDE the try so nothing between set and runTurn can skip the
|
|
2770
|
+
// finally-clear.
|
|
2771
|
+
// C1 — a continuation turn re-attaches to the interrupted phase, so the
|
|
2772
|
+
// SAME enforcement is active for it too (classic mirror; see Ink note).
|
|
2773
|
+
const planPhaseCtx = planResume ?? planContinue?.prepared;
|
|
2774
|
+
if (planPhaseCtx) {
|
|
2775
|
+
const { setPlanPhaseActive, setGuardedRunActive, setCurrentPhaseWrites, getAndResetPlanDeviation } = await import('./commands/plan-gate.js');
|
|
2776
|
+
// Task 7 — plan-phase subagent read-only enforcement (classic mirror):
|
|
2777
|
+
// set for EVERY plan-resume phase turn regardless of mode.
|
|
2778
|
+
setPlanPhaseActive(true);
|
|
2779
|
+
// Task 10 — executing phase's writes declaration for the deviation
|
|
2780
|
+
// backstop + stale-flag discard (classic mirror; see Ink path note).
|
|
2781
|
+
setCurrentPhaseWrites(planPhaseCtx.nextPhaseWrites ?? true);
|
|
2782
|
+
getAndResetPlanDeviation();
|
|
2783
|
+
planPhaseFlagSet = true;
|
|
2784
|
+
if ((planPhaseCtx.effectiveMode ?? 'step') === 'guarded') {
|
|
2785
|
+
setGuardedRunActive(true);
|
|
2786
|
+
guardedRunFlagSet = true;
|
|
2787
|
+
console.log(chalk.dim('▶ guarded mode — auto_approve disabled for this run'));
|
|
2788
|
+
}
|
|
2789
|
+
// Off-switch (2026-07-06, owner) — announce plainly that this run's
|
|
2790
|
+
// phases are NOT audited (honesty: off is a choice, never a lie).
|
|
2791
|
+
if ((planPhaseCtx.auditMode ?? 'on') === 'off') {
|
|
2792
|
+
console.log(chalk.dim('plan audits: off'));
|
|
2793
|
+
}
|
|
2794
|
+
}
|
|
1818
2795
|
await runTurn({
|
|
1819
2796
|
provider,
|
|
1820
2797
|
userMessage: prompt,
|
|
1821
2798
|
skill: skill,
|
|
1822
|
-
ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: replPreviewHook, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor },
|
|
2799
|
+
ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: replPreviewHook, chunkEmitter: classicOutputEmitter, todoEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor },
|
|
1823
2800
|
signal: turnAbort.signal,
|
|
1824
|
-
suppressSaveHook: planResume !== null,
|
|
2801
|
+
suppressSaveHook: planResume !== null || planContinue !== null,
|
|
2802
|
+
// A4 — undefined on every non-plan turn (and when tiering is off).
|
|
2803
|
+
modelOverride: planModelOverride,
|
|
1825
2804
|
onPlanManifest: planResumeForHook
|
|
1826
2805
|
? async (assistantText) => {
|
|
1827
2806
|
try {
|
|
1828
2807
|
const { finishPlanResume } = await import('./commands/plan-resume.js');
|
|
1829
|
-
await finishPlanResume({
|
|
2808
|
+
planOutcome = await finishPlanResume({
|
|
1830
2809
|
prepared: planResumeForHook,
|
|
1831
2810
|
messages: session.messages,
|
|
1832
2811
|
messagesStart: planResumeMsgStart,
|
|
2812
|
+
sessionId: session.id,
|
|
2813
|
+
attemptStartedAt: planPhaseStartedAt || undefined, // D-B item 1
|
|
2814
|
+
auditMode: planResumeForHook.auditMode, // off-switch — no pending injected when 'off'
|
|
1833
2815
|
log: (...lines) => lines.forEach((l) => console.log(l)),
|
|
1834
2816
|
textOverride: assistantText,
|
|
1835
2817
|
});
|
|
@@ -1839,7 +2821,34 @@ export async function runRepl(opts) {
|
|
|
1839
2821
|
console.log(chalk.red(`\n[plan] early revision save failed: ${err.message}\n`));
|
|
1840
2822
|
}
|
|
1841
2823
|
}
|
|
1842
|
-
:
|
|
2824
|
+
: planContinueForHook
|
|
2825
|
+
? async (assistantText) => {
|
|
2826
|
+
// C1 — early-persist the continuation revision the moment the
|
|
2827
|
+
// manifest is finalised (classic mirror of the Ink hook).
|
|
2828
|
+
try {
|
|
2829
|
+
const { finishPlanContinuation } = await import('./commands/plan-continue.js');
|
|
2830
|
+
const r = await finishPlanContinuation({
|
|
2831
|
+
prepared: planContinueForHook.prepared,
|
|
2832
|
+
marker: planContinueForHook.marker,
|
|
2833
|
+
messages: session.messages,
|
|
2834
|
+
messagesStart: planResumeMsgStart,
|
|
2835
|
+
sessionId: session.id,
|
|
2836
|
+
textOverride: assistantText,
|
|
2837
|
+
log: (...lines) => lines.forEach((l) => console.log(l)),
|
|
2838
|
+
});
|
|
2839
|
+
if (r.kind === 'saved') {
|
|
2840
|
+
planOutcome = r.outcome;
|
|
2841
|
+
planSavedEarly = true;
|
|
2842
|
+
}
|
|
2843
|
+
// 'foreign'/'none'/'not-persisted': leave both flags unset —
|
|
2844
|
+
// the post-turn fallback re-classifies the full turn text and
|
|
2845
|
+
// owns the foreign save / quiet-none handling.
|
|
2846
|
+
}
|
|
2847
|
+
catch (err) {
|
|
2848
|
+
console.log(chalk.red(`\n[plan] early continuation save failed: ${err.message}\n`));
|
|
2849
|
+
}
|
|
2850
|
+
}
|
|
2851
|
+
: undefined,
|
|
1843
2852
|
});
|
|
1844
2853
|
}
|
|
1845
2854
|
catch (err) {
|
|
@@ -1858,6 +2867,22 @@ export async function runRepl(opts) {
|
|
|
1858
2867
|
finally {
|
|
1859
2868
|
rl.removeListener('SIGINT', onSigint);
|
|
1860
2869
|
turnInProgress = false;
|
|
2870
|
+
// Task 6/7/10 — neither the guarded auto_approve override, the
|
|
2871
|
+
// plan-phase enforcement flag, nor the phase-writes declaration may
|
|
2872
|
+
// outlive the phase turn. (The deviation flag survives on purpose —
|
|
2873
|
+
// runPostPhaseChain below reads+resets it.)
|
|
2874
|
+
if (guardedRunFlagSet || planPhaseFlagSet) {
|
|
2875
|
+
const { setGuardedRunActive, setPlanPhaseActive, setCurrentPhaseWrites, getAndResetPlanPhaseSubagentDispatches } = await import('./commands/plan-gate.js');
|
|
2876
|
+
if (guardedRunFlagSet)
|
|
2877
|
+
setGuardedRunActive(false);
|
|
2878
|
+
if (planPhaseFlagSet) {
|
|
2879
|
+
setPlanPhaseActive(false);
|
|
2880
|
+
setCurrentPhaseWrites(null);
|
|
2881
|
+
// Fix-wave (item 1) — consume the subagent counter here (classic
|
|
2882
|
+
// mirror; see the Ink finally for the leak rationale).
|
|
2883
|
+
planSubagentDispatches = getAndResetPlanPhaseSubagentDispatches();
|
|
2884
|
+
}
|
|
2885
|
+
}
|
|
1861
2886
|
}
|
|
1862
2887
|
// B3 — persist the plan revision from the resume turn's manifest block.
|
|
1863
2888
|
// FALLBACK: skipped when the mid-turn onPlanManifest hook already saved it
|
|
@@ -1865,10 +2890,13 @@ export async function runRepl(opts) {
|
|
|
1865
2890
|
if (planResume && !planSavedEarly) {
|
|
1866
2891
|
try {
|
|
1867
2892
|
const { finishPlanResume } = await import('./commands/plan-resume.js');
|
|
1868
|
-
await finishPlanResume({
|
|
2893
|
+
planOutcome = await finishPlanResume({
|
|
1869
2894
|
prepared: planResume,
|
|
1870
2895
|
messages: session.messages,
|
|
1871
2896
|
messagesStart: planResumeMsgStart,
|
|
2897
|
+
sessionId: session.id,
|
|
2898
|
+
attemptStartedAt: planPhaseStartedAt || undefined, // D-B item 1
|
|
2899
|
+
auditMode: planResume.auditMode, // off-switch — no pending injected when 'off'
|
|
1872
2900
|
log: (...lines) => lines.forEach((l) => console.log(l)),
|
|
1873
2901
|
});
|
|
1874
2902
|
}
|
|
@@ -1876,6 +2904,112 @@ export async function runRepl(opts) {
|
|
|
1876
2904
|
console.log(chalk.red(`\n[plan] revision save failed: ${err.message}\n`));
|
|
1877
2905
|
}
|
|
1878
2906
|
}
|
|
2907
|
+
// A1 (defect D23) + Task 6 — harness-owned continuation, now the full
|
|
2908
|
+
// post-phase chain (classic-path mirror of the Ink block; see there for
|
|
2909
|
+
// the design note): audit → verdict persistence → failure handling →
|
|
2910
|
+
// guarded gate → offer/auto-queue through the same pendingDispatch slot
|
|
2911
|
+
// the pre-prompt consumer reads.
|
|
2912
|
+
if (planResume && planOutcome) {
|
|
2913
|
+
try {
|
|
2914
|
+
const { runPostPhaseChain } = await import('./commands/plan-chain.js');
|
|
2915
|
+
const { inkAwarePrompt } = await import('./agent/loop.js');
|
|
2916
|
+
await runPostPhaseChain({
|
|
2917
|
+
savedPath: planOutcome.savedPath,
|
|
2918
|
+
executedPhaseId: planResume.nextPhaseId,
|
|
2919
|
+
sessionId: session.id,
|
|
2920
|
+
cwd: process.cwd(),
|
|
2921
|
+
mode: planResume.effectiveMode ?? 'step',
|
|
2922
|
+
modeFlag: planResume.mode,
|
|
2923
|
+
// Off-switch (I3/I2) — classic mirror of the Ink chain call: skip
|
|
2924
|
+
// the audit when 'off'; per-run flag survives onto resume commands.
|
|
2925
|
+
auditMode: planResume.auditMode,
|
|
2926
|
+
auditFlag: planResume.auditFlag,
|
|
2927
|
+
// D-B — this attempt's turn-start, so the audit windows the evidence.
|
|
2928
|
+
attemptStartedAt: planPhaseStartedAt || undefined,
|
|
2929
|
+
// Fix-wave (item 1) — counter consumed in the runTurn finally.
|
|
2930
|
+
subagentDispatches: planSubagentDispatches,
|
|
2931
|
+
prompt: inkAwarePrompt,
|
|
2932
|
+
queueDispatch: (cmd) => pendingDispatchAccessor.set(cmd),
|
|
2933
|
+
log: (...lines) => lines.forEach((l) => console.log(l)),
|
|
2934
|
+
});
|
|
2935
|
+
}
|
|
2936
|
+
catch (err) {
|
|
2937
|
+
console.log(chalk.red(`\n[plan] post-phase chain failed: ${err.message}\n`));
|
|
2938
|
+
}
|
|
2939
|
+
}
|
|
2940
|
+
// C1 — continuation post-turn: fallback-persist (hook may not have
|
|
2941
|
+
// fired), then re-attach the chain (classic mirror of the Ink blocks).
|
|
2942
|
+
if (planContinue && !planSavedEarly) {
|
|
2943
|
+
try {
|
|
2944
|
+
const { finishPlanContinuation } = await import('./commands/plan-continue.js');
|
|
2945
|
+
const r = await finishPlanContinuation({
|
|
2946
|
+
prepared: planContinue.prepared,
|
|
2947
|
+
marker: planContinue.marker,
|
|
2948
|
+
messages: session.messages,
|
|
2949
|
+
messagesStart: planResumeMsgStart,
|
|
2950
|
+
sessionId: session.id,
|
|
2951
|
+
log: (...lines) => lines.forEach((l) => console.log(l)),
|
|
2952
|
+
});
|
|
2953
|
+
if (r.kind === 'saved') {
|
|
2954
|
+
planOutcome = r.outcome;
|
|
2955
|
+
}
|
|
2956
|
+
else if (r.kind === 'foreign') {
|
|
2957
|
+
// The turn produced a DIFFERENT plan's manifest and the save hook
|
|
2958
|
+
// was suppressed — run the generic save now so it isn't lost. The
|
|
2959
|
+
// in-flight marker stays: that phase is still unresolved.
|
|
2960
|
+
const { maybeOfferSave } = await import('./agent/loop.js');
|
|
2961
|
+
const { collectTurnAssistantText } = await import('./agent/turn-assistant-text.js');
|
|
2962
|
+
await maybeOfferSave({
|
|
2963
|
+
skill: 'abap-plan',
|
|
2964
|
+
assistantText: collectTurnAssistantText(session.messages, planResumeMsgStart),
|
|
2965
|
+
userMessage: prompt,
|
|
2966
|
+
tokensUsed: 0,
|
|
2967
|
+
model: session.model,
|
|
2968
|
+
emit: (line) => console.log(line),
|
|
2969
|
+
});
|
|
2970
|
+
}
|
|
2971
|
+
else if (r.kind === 'none') {
|
|
2972
|
+
console.log(chalk.dim(`phase ${planContinue.marker.phaseId} still in flight — no completion manifest this turn`));
|
|
2973
|
+
}
|
|
2974
|
+
}
|
|
2975
|
+
catch (err) {
|
|
2976
|
+
console.log(chalk.red(`\n[plan] continuation save failed: ${err.message}\n`));
|
|
2977
|
+
}
|
|
2978
|
+
}
|
|
2979
|
+
if (planContinue && planOutcome) {
|
|
2980
|
+
try {
|
|
2981
|
+
const { runContinuationChain } = await import('./commands/plan-continue.js');
|
|
2982
|
+
const { inkAwarePrompt } = await import('./agent/loop.js');
|
|
2983
|
+
await runContinuationChain({
|
|
2984
|
+
prepared: planContinue.prepared,
|
|
2985
|
+
marker: planContinue.marker,
|
|
2986
|
+
outcome: planOutcome,
|
|
2987
|
+
sessionId: session.id,
|
|
2988
|
+
cwd: process.cwd(),
|
|
2989
|
+
subagentDispatches: planSubagentDispatches,
|
|
2990
|
+
prompt: inkAwarePrompt,
|
|
2991
|
+
queueDispatch: (cmd) => pendingDispatchAccessor.set(cmd),
|
|
2992
|
+
log: (...lines) => lines.forEach((l) => console.log(l)),
|
|
2993
|
+
});
|
|
2994
|
+
}
|
|
2995
|
+
catch (err) {
|
|
2996
|
+
console.log(chalk.red(`\n[plan] continuation chain failed: ${err.message}\n`));
|
|
2997
|
+
}
|
|
2998
|
+
}
|
|
2999
|
+
// C1 — the phase reached a resolved terminal (revision persisted, chain
|
|
3000
|
+
// ran or is queued): the marker's job is done. Applies to --resume AND
|
|
3001
|
+
// continuation dispatches; a null outcome keeps the marker (still in flight).
|
|
3002
|
+
if (planOutcome)
|
|
3003
|
+
session.plan_in_flight = null;
|
|
3004
|
+
// Task 9 — chain leg truly ends here: release the run lease UNLESS a
|
|
3005
|
+
// next-phase dispatch is queued (review fix — the loop-top pre-prompt
|
|
3006
|
+
// consumer reads the slot after `continue`, so it still holds the
|
|
3007
|
+
// command here; the chain must keep the family lock across the gap).
|
|
3008
|
+
// Classic mirror of the Ink call site; see the design note there.
|
|
3009
|
+
if (planResume || planContinue) {
|
|
3010
|
+
const { releaseRunLeaseUnlessChained } = await import('./projects/run-lease.js');
|
|
3011
|
+
releaseRunLeaseUnlessChained((planResume ?? planContinue.prepared).path, pendingDispatchAccessor.get());
|
|
3012
|
+
}
|
|
1879
3013
|
// Status footer after each turn.
|
|
1880
3014
|
// tool_result messages also have role:'user' but content is an array —
|
|
1881
3015
|
// filter them out so we count actual user prompts, not tool rounds.
|
|
@@ -1896,6 +3030,9 @@ export async function runRepl(opts) {
|
|
|
1896
3030
|
cacheCreate: turnStartCostSnapshot.cacheCreate,
|
|
1897
3031
|
startedAt: turnStart,
|
|
1898
3032
|
},
|
|
3033
|
+
// A4 — price the turn at the model that actually ran it (classic-path
|
|
3034
|
+
// mirror of the Ink call site above).
|
|
3035
|
+
turnModel: planModelOverride ?? session.model,
|
|
1899
3036
|
});
|
|
1900
3037
|
// Post-turn roll-forward (Step 7c.2 / 7c.5) — the skill from the turn that
|
|
1901
3038
|
// JUST finished becomes `last_skill` for the next turn's classifier context.
|