@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.
Files changed (195) hide show
  1. package/README.md +1 -1
  2. package/dist/agent/intent-system-prompt.js +1 -1
  3. package/dist/agent/loop.js +228 -26
  4. package/dist/agent/providers/license-gate.js +44 -0
  5. package/dist/agent/skill-checkpoint.js +1 -1
  6. package/dist/agent/tool-dispatch.js +15 -0
  7. package/dist/approvals/canonical.js +91 -0
  8. package/dist/approvals/jwt.js +39 -2
  9. package/dist/approvals/op-labels.js +124 -0
  10. package/dist/approvals/render.js +42 -36
  11. package/dist/auth/org-anthropic-key.js +25 -0
  12. package/dist/classifier/client.js +18 -3
  13. package/dist/cli.js +15 -0
  14. package/dist/commands/compact.js +28 -2
  15. package/dist/commands/config-set.js +284 -0
  16. package/dist/commands/config-show.js +20 -0
  17. package/dist/commands/export-audit.js +43 -0
  18. package/dist/commands/help.js +5 -0
  19. package/dist/commands/login.js +31 -14
  20. package/dist/commands/plan-audit-evidence.js +266 -0
  21. package/dist/commands/plan-audit.js +692 -0
  22. package/dist/commands/plan-chain.js +671 -0
  23. package/dist/commands/plan-continue.js +179 -0
  24. package/dist/commands/plan-gate.js +154 -0
  25. package/dist/commands/plan-model-tier.js +83 -0
  26. package/dist/commands/plan-resume.js +728 -46
  27. package/dist/config/loader.js +223 -5
  28. package/dist/config/model-defaults.js +14 -0
  29. package/dist/cost/pricing.js +27 -1
  30. package/dist/doctor/checks/_http-probe.js +1 -0
  31. package/dist/doctor/checks/cert.js +14 -3
  32. package/dist/doctor/checks/sap.js +30 -8
  33. package/dist/doctor/checks/system-roles.js +41 -0
  34. package/dist/doctor/checks/zcspeach.js +19 -4
  35. package/dist/doctor/run.js +2 -0
  36. package/dist/models/resolve.js +61 -0
  37. package/dist/models/server-config.js +155 -0
  38. package/dist/one-shot.js +76 -6
  39. package/dist/projects/answer-blockers.js +137 -0
  40. package/dist/projects/extract-cca.js +111 -17
  41. package/dist/projects/extract-modernize.js +4 -2
  42. package/dist/projects/extract-plan.js +184 -37
  43. package/dist/projects/extract-spec-gap.js +34 -7
  44. package/dist/projects/extract-test-coverage.js +4 -2
  45. package/dist/projects/extract-upgrade.js +116 -23
  46. package/dist/projects/handover-md.js +195 -0
  47. package/dist/projects/index.js +5 -2
  48. package/dist/projects/merge-cca.js +292 -0
  49. package/dist/projects/merge-upgrade.js +173 -0
  50. package/dist/projects/migration.js +103 -1
  51. package/dist/projects/output-paths.js +27 -0
  52. package/dist/projects/plan-run.js +285 -27
  53. package/dist/projects/plan-schema.js +136 -3
  54. package/dist/projects/promote-command.js +25 -2
  55. package/dist/projects/promote.js +128 -0
  56. package/dist/projects/run-lease.js +157 -0
  57. package/dist/projects/save-command.js +259 -21
  58. package/dist/projects/status.js +3 -1
  59. package/dist/projects/validate.js +1 -1
  60. package/dist/projects/workspace.js +164 -20
  61. package/dist/renderer/notices.js +64 -0
  62. package/dist/renderer/progress-chatter.js +8 -0
  63. package/dist/renderer/status-footer.js +22 -12
  64. package/dist/renderer/thinking-heartbeat.js +64 -8
  65. package/dist/renderer/todo-block.js +51 -0
  66. package/dist/renderer/tool-widget.js +55 -4
  67. package/dist/renderer/tty.js +43 -4
  68. package/dist/renderer/verify-chain.js +77 -0
  69. package/dist/repl/at-picker.js +60 -7
  70. package/dist/repl/bracketed-paste.js +28 -19
  71. package/dist/repl/builtin-commands.js +42 -0
  72. package/dist/repl/current-transport.js +10 -0
  73. package/dist/repl/early-line-buffer.js +68 -0
  74. package/dist/repl/history.js +86 -0
  75. package/dist/repl/ink-stdin-guard.js +64 -0
  76. package/dist/repl/inquirer-guard.js +70 -5
  77. package/dist/repl/mode-ceiling.js +16 -0
  78. package/dist/repl/mode-cycle.js +104 -0
  79. package/dist/repl/numbered-menu.js +131 -0
  80. package/dist/repl/post-turn-status.js +26 -6
  81. package/dist/repl/rule8-detector.js +17 -2
  82. package/dist/repl/safety-confirm.js +111 -2
  83. package/dist/repl/safety-mode-state.js +19 -3
  84. package/dist/repl/slash-completer.js +5 -0
  85. package/dist/repl/slash-picker.js +10 -15
  86. package/dist/repl.js +1232 -95
  87. package/dist/rewind/candidates.js +194 -0
  88. package/dist/rewind/cli.js +137 -0
  89. package/dist/rewind/format.js +27 -0
  90. package/dist/rewind/restore.js +245 -0
  91. package/dist/router/classifier.js +150 -6
  92. package/dist/sap/capability-matrix.js +20 -0
  93. package/dist/sap/capability-matrix.json +11236 -0
  94. package/dist/sap/capability.js +146 -0
  95. package/dist/sap/connection-manager.js +19 -1
  96. package/dist/sap/onboarding.js +42 -4
  97. package/dist/session/audit-export.js +459 -0
  98. package/dist/session/context-report.js +163 -0
  99. package/dist/session/pending.js +27 -0
  100. package/dist/session/recap.js +160 -0
  101. package/dist/skill-catalog.js +51 -40
  102. package/dist/skills/bundled-skills.js +272 -1
  103. package/dist/skills/promotion-dispatch.js +23 -0
  104. package/dist/tools/_command-shared.js +36 -12
  105. package/dist/tools/_filesystem-shared.js +139 -4
  106. package/dist/tools/_flag.js +25 -0
  107. package/dist/tools/approval.js +177 -26
  108. package/dist/tools/ask-question.js +400 -7
  109. package/dist/tools/capability/tool.js +74 -0
  110. package/dist/tools/dispatch-skill.js +22 -1
  111. package/dist/tools/extend-model/anchored-insert.js +1414 -0
  112. package/dist/tools/extend-model/tool.js +340 -0
  113. package/dist/tools/filesystem/extract-document.js +57 -0
  114. package/dist/tools/filesystem/file-edit.js +12 -2
  115. package/dist/tools/filesystem/file-read.js +2 -2
  116. package/dist/tools/filesystem/file-write.js +11 -2
  117. package/dist/tools/filesystem/glob.js +11 -0
  118. package/dist/tools/filesystem/grep.js +10 -0
  119. package/dist/tools/filesystem/read-document.js +107 -0
  120. package/dist/tools/fiori/apply.js +50 -0
  121. package/dist/tools/fiori/bin.js +3 -0
  122. package/dist/tools/fiori/catalog/index.js +27 -0
  123. package/dist/tools/fiori/catalog/value-help.js +230 -0
  124. package/dist/tools/fiori/catalog/viz-chart.js +177 -0
  125. package/dist/tools/fiori/cli.js +71 -0
  126. package/dist/tools/fiori/deploy-config.js +73 -0
  127. package/dist/tools/fiori/fe-extend.js +76 -0
  128. package/dist/tools/fiori/fe-scaffold.js +71 -0
  129. package/dist/tools/fiori/floorplan-map.js +19 -0
  130. package/dist/tools/fiori/i18n.js +39 -0
  131. package/dist/tools/fiori/manifest.js +70 -0
  132. package/dist/tools/fiori/render.js +77 -0
  133. package/dist/tools/fiori/samples/data/index.json +13602 -0
  134. package/dist/tools/fiori/samples/data/sources.generated.js +808 -0
  135. package/dist/tools/fiori/samples/loader.js +248 -0
  136. package/dist/tools/fiori/samples/search.js +63 -0
  137. package/dist/tools/fiori/samples/types.js +2 -0
  138. package/dist/tools/fiori/scaffold.js +39 -0
  139. package/dist/tools/fiori/smoke/assertions.js +74 -0
  140. package/dist/tools/fiori/smoke/browser.js +52 -0
  141. package/dist/tools/fiori/smoke/driver.js +89 -0
  142. package/dist/tools/fiori/smoke/freestyle-spec.js +317 -0
  143. package/dist/tools/fiori/smoke/run-smoke.js +149 -0
  144. package/dist/tools/fiori/tools.js +681 -0
  145. package/dist/tools/fiori/types.js +1 -0
  146. package/dist/tools/local-build.js +86 -0
  147. package/dist/tools/local-files.js +31 -0
  148. package/dist/tools/project/_merge-shared.js +68 -0
  149. package/dist/tools/project/cca_merge.js +164 -0
  150. package/dist/tools/project/playbook_get.js +1 -1
  151. package/dist/tools/project/upgrade_merge_progress.js +206 -0
  152. package/dist/tools/sap-read.js +132 -20
  153. package/dist/tools/sap-write.js +550 -21
  154. package/dist/tools/shell/shell_exec.js +41 -6
  155. package/dist/tools/snapshot.js +63 -14
  156. package/dist/tools/subagent/agent_run.js +27 -3
  157. package/dist/tools/subagent/background_run.js +17 -1
  158. package/dist/tools/todo.js +144 -0
  159. package/dist/tools/transport-resolution.js +86 -0
  160. package/dist/tools/transport.js +224 -5
  161. package/dist/tools/write-mode.js +4 -0
  162. package/dist/ui/app.js +378 -21
  163. package/dist/ui/approval-modal.js +49 -16
  164. package/dist/ui/ask-question-emitter.js +14 -0
  165. package/dist/ui/body.js +13 -0
  166. package/dist/ui/context-grid.js +108 -0
  167. package/dist/ui/footer.js +120 -27
  168. package/dist/ui/header.js +7 -0
  169. package/dist/ui/line-resolution.js +35 -8
  170. package/dist/ui/rewind-emitter.js +10 -0
  171. package/dist/ui/rewind-panel.js +81 -0
  172. package/dist/ui/sap-state-store.js +1 -0
  173. package/dist/ui/session-timeline.js +1 -0
  174. package/dist/ui/status-line.js +43 -0
  175. package/dist/ui/text-input.js +214 -0
  176. package/dist/ui/todo-emitter.js +25 -0
  177. package/dist/ui/todo-panel.js +64 -0
  178. package/dist/ui/turn-status-emitter.js +50 -4
  179. package/dist/ui/turn-status.js +18 -3
  180. package/dist/ui/widgets/ask-form.js +242 -0
  181. package/dist/ui/widgets/ask-question-modal.js +21 -8
  182. package/package.json +22 -3
  183. package/bench/README.md +0 -78
  184. package/bench/prompts/abap-document-cds.md +0 -44
  185. package/bench/prompts/abap-explain-bdef-handler.md +0 -57
  186. package/bench/prompts/abap-test-method.md +0 -42
  187. package/bench/results/abap-document-cds/claude-haiku-4-5.md +0 -189
  188. package/bench/results/abap-document-cds/claude-opus-4-7.md +0 -120
  189. package/bench/results/abap-document-cds/claude-sonnet-4-6.md +0 -151
  190. package/bench/results/abap-explain-bdef-handler/claude-haiku-4-5.md +0 -112
  191. package/bench/results/abap-explain-bdef-handler/claude-opus-4-7.md +0 -101
  192. package/bench/results/abap-explain-bdef-handler/claude-sonnet-4-6.md +0 -101
  193. package/bench/results/abap-test-method/claude-haiku-4-5.md +0 -186
  194. package/bench/results/abap-test-method/claude-opus-4-7.md +0 -193
  195. 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
- import { classifyPrompt, parseShortcut } from './router/classifier.js';
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 { isPlanResumeCommand } from './commands/plan-resume.js';
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
- try {
101
- const raw = fs.readFileSync(HISTORY_FILE, 'utf-8');
102
- return raw
103
- .split('\n')
104
- .map((l) => l.trim())
105
- .filter((l) => l.length > 0)
106
- .slice(-HISTORY_SIZE)
107
- // readline expects history[0] to be the MOST RECENT entry (arrow-up
108
- // walks from index 0 backwards in time). The on-disk file is
109
- // chronologically appended — oldest at top, newest at bottom — so
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-document', 'abap-rap', 'abap-radar',
268
+ 'abap-explain', 'abap-review', 'abap-handover', 'abap-rap', 'abap-impact',
257
269
  ];
258
270
  const MIN_PICKER_OPTIONS = 3;
259
271
  function buildCoachingParams(result, prompt, preSelect, lastSkill) {
@@ -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
- if (cfg.write_mode === 'advisory-only') {
374
- console.log(chalk.yellow.bold('⚠ Write mode: ADVISORY (writes proposed, never executed)'));
375
- console.log(chalk.yellow(' Change via: cspeach config set write_mode approval-gated\n'));
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
- else {
378
- console.log(chalk.gray(`Write mode: ${cfg.write_mode}\n`));
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: `${selectedAlias} (${cfg.sap[selectedAlias].host})`,
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.default_model);
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
- const handleSubmit = async (line) => {
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
- appendHistory(trimmed);
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, COMPACTION_MODEL } = await import('./commands/compact.js');
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), { model: COMPACTION_MODEL, maxTokens: 4000, headers: { 'X-CSForge-Skill': 'compact' } });
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
- const { turnStatusEmitter: rerouteStatus } = await import('./ui/turn-status-emitter.js');
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
- rerouteStatus.stop();
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
- const parsed = parseShortcut(trimmed);
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
- const { turnStatusEmitter } = await import('./ui/turn-status-emitter.js');
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
- : undefined,
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
- pushPostTurnStatus({ session, selectedAlias, snapshot: turnSnapshot, chunkEmitter });
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). Consume the slot and
923
- // re-enter handleSubmit with that command — no user keypress.
924
- // The user's consent was the picker pick.
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
- // Auto-fire next phase: a plan resume rebuilds its bounded prompt from
929
- // the envelope, so clear the finished phase's transcript first — the
930
- // next phase then costs exactly what a manual fresh resume costs.
931
- // (The phase is already saved via manifest-on-interrupt, so nothing is
932
- // lost; prior prose is mirrored to ~/.cspeach/streams/.)
933
- if (isPlanResumeCommand(dispatched)) {
934
- session.messages = [];
935
- chunkEmitter.emit('chunk', chalk.dim('↪ Auto-running next phase in fresh context\n'));
936
- }
937
- else {
938
- chunkEmitter.emit('chunk', chalk.dim(`↪ Auto-routed: ${dispatched}\n`));
939
- }
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
- writeMode: cfg.write_mode,
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
- if (isPlanResumeCommand(dispatchedCmd)) {
1274
- session.messages = [];
1275
- console.log(chalk.dim('↪ Auto-running next phase in fresh context'));
1276
- }
1277
- else {
1278
- console.log(chalk.dim(`↪ Auto-routed: ${dispatchedCmd}`));
1279
- }
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
- appendHistory(trimmed);
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, COMPACTION_MODEL } = await import('./commands/compact.js');
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), { model: COMPACTION_MODEL, maxTokens: 4000, headers: { 'X-CSForge-Skill': 'compact' } });
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
- : undefined,
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.