@cspeach/cli 0.6.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 (210) hide show
  1. package/LICENSE +8 -0
  2. package/README.md +108 -0
  3. package/dist/agent/anthropic-provider.js +59 -0
  4. package/dist/agent/llm-provider.js +1 -0
  5. package/dist/agent/loop.js +709 -0
  6. package/dist/agent/maybe-build-project-context.js +126 -0
  7. package/dist/agent/providers/ai-hub-provider.js +58 -0
  8. package/dist/agent/providers/byok-provider.js +53 -0
  9. package/dist/agent/providers/factory.js +13 -0
  10. package/dist/agent/providers/local-provider.js +125 -0
  11. package/dist/agent/repair-partial.js +31 -0
  12. package/dist/agent/retry-key.js +58 -0
  13. package/dist/agent/retry.js +17 -0
  14. package/dist/agent/sap-connection-adapter.js +82 -0
  15. package/dist/agent/skill-checkpoint.js +119 -0
  16. package/dist/agent/tool-dispatch.js +47 -0
  17. package/dist/agent/turn-assistant-text.js +49 -0
  18. package/dist/agent/turn-error-ux.js +126 -0
  19. package/dist/agent/turn-stream.js +79 -0
  20. package/dist/agent/turn-watchdog.js +71 -0
  21. package/dist/approvals/advisory-prompt.js +40 -0
  22. package/dist/approvals/advisory-render.js +38 -0
  23. package/dist/approvals/approval-prompt.js +100 -0
  24. package/dist/approvals/jwt.js +33 -0
  25. package/dist/approvals/render.js +211 -0
  26. package/dist/approvals/risk-floor.js +26 -0
  27. package/dist/auth/api-key.js +40 -0
  28. package/dist/auth/auth-file.js +59 -0
  29. package/dist/auth/device.js +8 -0
  30. package/dist/auth/me.js +19 -0
  31. package/dist/classifier/client.js +58 -0
  32. package/dist/cli-args.js +38 -0
  33. package/dist/cli.js +148 -0
  34. package/dist/commands/config-set.js +245 -0
  35. package/dist/commands/config-show.js +159 -0
  36. package/dist/commands/help.js +93 -0
  37. package/dist/commands/login.js +122 -0
  38. package/dist/commands/logout.js +17 -0
  39. package/dist/commands/project-context-impact.js +215 -0
  40. package/dist/commands/reroute.js +60 -0
  41. package/dist/commands/spec-gap-status.js +52 -0
  42. package/dist/commands/whoami.js +35 -0
  43. package/dist/config/loader.js +67 -0
  44. package/dist/config/paths.js +20 -0
  45. package/dist/doctor/checks/_http-probe.js +56 -0
  46. package/dist/doctor/checks/auth.js +15 -0
  47. package/dist/doctor/checks/cert.js +24 -0
  48. package/dist/doctor/checks/forge-rules.js +102 -0
  49. package/dist/doctor/checks/keychain-fallback.js +14 -0
  50. package/dist/doctor/checks/keychain.js +23 -0
  51. package/dist/doctor/checks/llm-mode.js +27 -0
  52. package/dist/doctor/checks/proxy.js +13 -0
  53. package/dist/doctor/checks/sap.js +33 -0
  54. package/dist/doctor/checks/skill.js +24 -0
  55. package/dist/doctor/checks/write-mode.js +34 -0
  56. package/dist/doctor/checks/zcspeach.js +76 -0
  57. package/dist/doctor/run.js +46 -0
  58. package/dist/errors/codes.js +12 -0
  59. package/dist/index.js +6 -0
  60. package/dist/lock-contention.js +22 -0
  61. package/dist/one-shot.js +104 -0
  62. package/dist/project-context/conventions.js +309 -0
  63. package/dist/project-context/detect.js +250 -0
  64. package/dist/project-context/domain/abap-cloud.js +26 -0
  65. package/dist/project-context/domain/abapgit.js +177 -0
  66. package/dist/project-context/domain/cap.js +164 -0
  67. package/dist/project-context/domain/fiori.js +326 -0
  68. package/dist/project-context/git.js +115 -0
  69. package/dist/project-context/index-files.js +235 -0
  70. package/dist/project-context/index.js +117 -0
  71. package/dist/project-context/render.js +308 -0
  72. package/dist/project-context/types.js +14 -0
  73. package/dist/projects/build.js +20 -0
  74. package/dist/projects/canonicalize.js +39 -0
  75. package/dist/projects/email-template.js +54 -0
  76. package/dist/projects/extract-cca.js +139 -0
  77. package/dist/projects/extract-design.js +107 -0
  78. package/dist/projects/extract-estimate.js +93 -0
  79. package/dist/projects/extract-modernize.js +130 -0
  80. package/dist/projects/extract-spec-gap.js +101 -0
  81. package/dist/projects/extract-test-coverage.js +137 -0
  82. package/dist/projects/extract-upgrade.js +230 -0
  83. package/dist/projects/filename.js +18 -0
  84. package/dist/projects/index.js +8 -0
  85. package/dist/projects/migration.js +111 -0
  86. package/dist/projects/promote-command.js +96 -0
  87. package/dist/projects/promote.js +107 -0
  88. package/dist/projects/save-command.js +124 -0
  89. package/dist/projects/save.js +21 -0
  90. package/dist/projects/status.js +170 -0
  91. package/dist/projects/types.js +1 -0
  92. package/dist/projects/validate.js +146 -0
  93. package/dist/projects/workspace.js +478 -0
  94. package/dist/renderer/abap-inline.js +121 -0
  95. package/dist/renderer/banners.js +39 -0
  96. package/dist/renderer/highlighters/abap.js +126 -0
  97. package/dist/renderer/highlighters/bdef.js +81 -0
  98. package/dist/renderer/highlighters/cds.js +91 -0
  99. package/dist/renderer/markdown.js +291 -0
  100. package/dist/renderer/pipeline.js +201 -0
  101. package/dist/renderer/progress-chatter.js +237 -0
  102. package/dist/renderer/question-normalizer.js +306 -0
  103. package/dist/renderer/severity.js +61 -0
  104. package/dist/renderer/status-footer.js +50 -0
  105. package/dist/renderer/syntax.js +58 -0
  106. package/dist/renderer/tables.js +55 -0
  107. package/dist/renderer/thinking-heartbeat.js +70 -0
  108. package/dist/renderer/tool-widget.js +199 -0
  109. package/dist/renderer/tty.js +66 -0
  110. package/dist/renderer/widget-extractor.js +87 -0
  111. package/dist/renderer/widget-fallback.js +78 -0
  112. package/dist/renderer/widget-schemas.js +43 -0
  113. package/dist/repl/at-completer.js +64 -0
  114. package/dist/repl/at-picker.js +122 -0
  115. package/dist/repl/bracketed-paste.js +284 -0
  116. package/dist/repl/current-transport.js +46 -0
  117. package/dist/repl/diff-display.js +41 -0
  118. package/dist/repl/file-picker.js +219 -0
  119. package/dist/repl/inquirer-guard.js +130 -0
  120. package/dist/repl/inquirer-theme.js +41 -0
  121. package/dist/repl/rule8-detector.js +99 -0
  122. package/dist/repl/safety-confirm.js +106 -0
  123. package/dist/repl/safety-mode-state.js +36 -0
  124. package/dist/repl/slash-completer.js +59 -0
  125. package/dist/repl/slash-picker.js +124 -0
  126. package/dist/repl/update-method-preview-hook.js +45 -0
  127. package/dist/repl.js +1383 -0
  128. package/dist/router/classifier.js +38 -0
  129. package/dist/router/intent-extractor.js +140 -0
  130. package/dist/router/routing-decision.js +19 -0
  131. package/dist/sap/connection-manager.js +52 -0
  132. package/dist/sap/onboarding.js +178 -0
  133. package/dist/sap/system-info.js +515 -0
  134. package/dist/session/awaiting-answer.js +73 -0
  135. package/dist/session/gc.js +28 -0
  136. package/dist/session/pending.js +37 -0
  137. package/dist/session/resume.js +77 -0
  138. package/dist/session/schema.js +20 -0
  139. package/dist/session/store.js +147 -0
  140. package/dist/session/time-ago.js +41 -0
  141. package/dist/skill-catalog.js +222 -0
  142. package/dist/skills/bundled-skills.js +1 -0
  143. package/dist/skills/canonical.js +12 -0
  144. package/dist/skills/manifest-client.js +93 -0
  145. package/dist/skills/promotion-dispatch.js +24 -0
  146. package/dist/skills/signing-public-key.js +4 -0
  147. package/dist/skills/source-bundled.js +20 -0
  148. package/dist/skills/source-managed.js +26 -0
  149. package/dist/skills/source-manifest.js +26 -0
  150. package/dist/tools/_command-shared.js +110 -0
  151. package/dist/tools/_filesystem-shared.js +81 -0
  152. package/dist/tools/_flag.js +39 -0
  153. package/dist/tools/approval.js +228 -0
  154. package/dist/tools/ask-question.js +205 -0
  155. package/dist/tools/dispatch-skill.js +81 -0
  156. package/dist/tools/filesystem/file-edit.js +140 -0
  157. package/dist/tools/filesystem/file-read.js +89 -0
  158. package/dist/tools/filesystem/file-write.js +128 -0
  159. package/dist/tools/filesystem/glob.js +177 -0
  160. package/dist/tools/filesystem/grep.js +163 -0
  161. package/dist/tools/index.js +32 -0
  162. package/dist/tools/project/convention_get.js +91 -0
  163. package/dist/tools/project/playbook_get.js +132 -0
  164. package/dist/tools/project/project_context_get.js +101 -0
  165. package/dist/tools/sap-read.js +454 -0
  166. package/dist/tools/sap-write.js +746 -0
  167. package/dist/tools/shell/shell_exec.js +209 -0
  168. package/dist/tools/snapshot.js +107 -0
  169. package/dist/tools/subagent/_background-shared.js +133 -0
  170. package/dist/tools/subagent/agent_run.js +186 -0
  171. package/dist/tools/subagent/background_run.js +143 -0
  172. package/dist/tools/subagent/monitor_emit.js +65 -0
  173. package/dist/tools/subagent/schedule_create.js +131 -0
  174. package/dist/tools/transport.js +233 -0
  175. package/dist/tools/update-method-intercept.js +119 -0
  176. package/dist/tools/verify.js +39 -0
  177. package/dist/tools/web/_web-shared.js +251 -0
  178. package/dist/tools/web/web_fetch.js +257 -0
  179. package/dist/tools/web/web_search.js +195 -0
  180. package/dist/tools/write-mode.js +22 -0
  181. package/dist/ui/app.js +95 -0
  182. package/dist/ui/approval-emitter.js +10 -0
  183. package/dist/ui/approval-modal.js +53 -0
  184. package/dist/ui/ascii-chars.js +6 -0
  185. package/dist/ui/body.js +102 -0
  186. package/dist/ui/coaching-picker-classic.js +36 -0
  187. package/dist/ui/coaching-picker-emitter.js +27 -0
  188. package/dist/ui/command-palette.js +34 -0
  189. package/dist/ui/error-emitter.js +21 -0
  190. package/dist/ui/footer.js +103 -0
  191. package/dist/ui/header.js +17 -0
  192. package/dist/ui/ink-classifier-route.js +19 -0
  193. package/dist/ui/login-banner.js +72 -0
  194. package/dist/ui/rich-error-box.js +9 -0
  195. package/dist/ui/sap-state-store.js +65 -0
  196. package/dist/ui/session-timeline.js +31 -0
  197. package/dist/ui/sidebar.js +10 -0
  198. package/dist/ui/skill-picker.js +50 -0
  199. package/dist/ui/status-row.js +12 -0
  200. package/dist/ui/widget-control.js +4 -0
  201. package/dist/ui/widgets/bar-chart.js +15 -0
  202. package/dist/ui/widgets/coaching-picker.js +41 -0
  203. package/dist/ui/widgets/component-registry.js +12 -0
  204. package/dist/ui/widgets/dep-graph.js +9 -0
  205. package/dist/ui/widgets/diff-viewer.js +11 -0
  206. package/dist/ui/widgets/question-card.js +11 -0
  207. package/dist/ui/widgets/stack-frames.js +5 -0
  208. package/dist/upgrade-check.js +28 -0
  209. package/dist/upgrade.js +13 -0
  210. package/package.json +83 -0
@@ -0,0 +1,709 @@
1
+ import chalk from 'chalk';
2
+ import { basename } from 'node:path';
3
+ import { dispatchTool } from './tool-dispatch.js';
4
+ import { listTools, toAnthropicTools } from '../tools/index.js';
5
+ import { saveSession } from '../session/store.js';
6
+ import { loadConfig } from '../config/loader.js';
7
+ import { retryWithBackoff } from './retry.js';
8
+ import { renderChunk, initialBufferState, render } from '../renderer/pipeline.js';
9
+ import { renderToolCallTop, renderToolCallBottom, startToolSpinner, startThinkingSpinner } from '../renderer/tool-widget.js';
10
+ import { startThinkingHeartbeat } from '../renderer/thinking-heartbeat.js';
11
+ // (progress-chatter import removed 2026-05-01 — superseded by CC-style
12
+ // two-line dispatch renderer; re-add if a future in-place spinner returns)
13
+ import { ERR } from '../errors/codes.js';
14
+ import { lastAssistantIsAwaitingAnswer } from '../session/awaiting-answer.js';
15
+ import { enrichUserMessage } from '../router/intent-extractor.js';
16
+ import { resetRule8State } from '../repl/rule8-detector.js';
17
+ import { runSaveCommand, runPromoteCommand } from '../projects/index.js';
18
+ import { parseFromFlag } from '../skills/promotion-dispatch.js';
19
+ import { input } from '@inquirer/prompts';
20
+ import { withInquirer } from '../repl/inquirer-guard.js';
21
+ import { expandTextFileAttachments } from '../projects/workspace.js';
22
+ import { getSapSystemInfo, renderSapSystemBlock } from '../sap/system-info.js';
23
+ import { objectKeyFromInput } from './retry-key.js';
24
+ import { repairPartialBlocks } from './repair-partial.js';
25
+ import { TurnStreamWriter } from './turn-stream.js';
26
+ import { applyToolResultCheckpoint } from './skill-checkpoint.js';
27
+ import { loadWatchdogConfig, evaluateWatchdog } from './turn-watchdog.js';
28
+ import { maybeBuildProjectContext } from './maybe-build-project-context.js';
29
+ import { buildSapConnectionContext } from './sap-connection-adapter.js';
30
+ import { getCurrentTransport } from '../repl/current-transport.js';
31
+ import { collectTurnAssistantText } from './turn-assistant-text.js';
32
+ /**
33
+ * Author identity for project-file metadata. Reads CSPEACH_AUTHOR_NAME first,
34
+ * then platform USER/USERNAME, then a generic fallback. Role is fixed to
35
+ * 'consultant' until we add multi-role support.
36
+ *
37
+ * Exported for unit testing.
38
+ */
39
+ export function getAuthorIdentity() {
40
+ const name = process.env.CSPEACH_AUTHOR_NAME ??
41
+ process.env.USER ??
42
+ process.env.USERNAME ??
43
+ 'consultant';
44
+ return { name, role: 'consultant' };
45
+ }
46
+ // v0.5: design + estimate join the save-hook eligibility list. The save flow
47
+ // itself is artefact-agnostic — runSaveCommand dispatches by skill name via
48
+ // SKILL_REGISTRY and emits the right envelope shape.
49
+ const SAVE_HOOK_SKILLS = new Set([
50
+ 'abap-spec-gap',
51
+ 'abap-design',
52
+ 'abap-estimate',
53
+ // 2026-05-08: upgrade pipeline manifest envelopes. Each emits a
54
+ // <!-- csforge:upgrade-manifest --> block that the matching
55
+ // extract-upgrade.* parser turns into the manifest envelope. The
56
+ // heavy detail (per-finding data) stays in `.abapforge/upgrades/`,
57
+ // referenced by detail_path inside the envelope.
58
+ 'abap-upgrade-scan',
59
+ 'abap-upgrade-fix',
60
+ 'abap-upgrade-verify',
61
+ 'abap-upgrade-merge',
62
+ // 2026-05-09: CCA pipeline manifest envelopes. /abap-cca emits a
63
+ // <!-- csforge:cca-manifest --> block wrapping the heavy project.json
64
+ // detail at .abapforge/cca/projects/<slug>/. /abap-cca-merge produces
65
+ // a consolidated cca-assessment from N parallel consultant slices.
66
+ 'abap-cca',
67
+ 'abap-cca-merge',
68
+ // 2026-05-10: v0.7 trio chain. /abap-modernize emits modernize-result;
69
+ // /abap-test emits test-coverage. Both accept cca-assessment via --from
70
+ // and can fan out into further chains after their own envelope saves.
71
+ 'abap-modernize',
72
+ 'abap-test',
73
+ ]);
74
+ /**
75
+ * True when any user message in the session already carries the rendered
76
+ * <project_context> block. Phase 2 uses this to inject the block exactly
77
+ * once per session, to the first user turn that doesn't have it.
78
+ *
79
+ * Why a content scan rather than a turn counter:
80
+ *
81
+ * - On `cspeach --resume`, session.messages is rehydrated from disk
82
+ * with the prior process's injected block already present. A
83
+ * per-process counter would re-inject; the content scan correctly
84
+ * short-circuits.
85
+ *
86
+ * - tool_result entries push `role: 'user'` with array-shaped content.
87
+ * The `typeof === 'string'` check skips those without iterating
88
+ * multimodal blocks.
89
+ *
90
+ * Exported for unit testing only — the call site is one floor up in runTurn.
91
+ */
92
+ export function hasProjectContextBlock(messages) {
93
+ for (const m of messages) {
94
+ if (m.role !== 'user')
95
+ continue;
96
+ if (typeof m.content === 'string' && m.content.includes('<project_context')) {
97
+ return true;
98
+ }
99
+ }
100
+ return false;
101
+ }
102
+ export async function maybeOfferSave(p) {
103
+ if (!SAVE_HOOK_SKILLS.has(p.skill))
104
+ return;
105
+ if (p.assistantText.trim().length === 0)
106
+ return;
107
+ try {
108
+ await runSaveCommand({
109
+ skillOutput: p.assistantText,
110
+ skillName: p.skill,
111
+ skillVersion: '1.0',
112
+ skillInput: p.userMessage,
113
+ tokensUsed: p.tokensUsed,
114
+ model: p.model,
115
+ author: getAuthorIdentity(),
116
+ cwd: process.cwd(),
117
+ prompt: async (q) => withInquirer(() => input({ message: q })),
118
+ log: (...lines) => lines.forEach((l) => p.emit(l)),
119
+ promotedFrom: p.promotedFrom ?? null,
120
+ });
121
+ }
122
+ catch (err) {
123
+ p.emit(chalk.yellow(`\n[save] skipped: ${err?.message ?? err}`));
124
+ }
125
+ }
126
+ export async function runTurn(params) {
127
+ resetRule8State(); // Rule 8 — fresh batch counter per LLM turn (= per user prompt)
128
+ const cfg = await loadConfig();
129
+ const session = params.ctx.session;
130
+ // Define `emit` early so the Phase J promote dispatch (below) can stream
131
+ // its prompt + status lines through the same Ink-aware sink the rest of
132
+ // runTurn uses. Originally defined later in the function — hoisted in v0.5
133
+ // so --from interactivity sits above the LLM stream setup.
134
+ function emit(line) {
135
+ if (params.chunkEmitter)
136
+ params.chunkEmitter.emit('chunk', line + '\n');
137
+ else
138
+ console.log(line);
139
+ }
140
+ // Phase J — `--from @<source>` phase promotion.
141
+ // If the user typed `/abap-design --from @./spec-gap.cspeach.json` (or the
142
+ // estimate equivalent), strip the flag, validate + snapshot the source,
143
+ // prepend a "Promoted from..." block to the LLM input, and stash the
144
+ // snapshot for the post-turn save hook so the saved envelope's
145
+ // promotedFrom field is populated.
146
+ let promotedFromForSave = null;
147
+ let userMessageForLLM = params.userMessage;
148
+ let userMessageForSave = params.userMessage;
149
+ {
150
+ const { fromPath, rest } = parseFromFlag(params.userMessage);
151
+ if (fromPath) {
152
+ const result = await runPromoteCommand({
153
+ sourcePath: fromPath,
154
+ targetSkill: params.skill,
155
+ prompt: async (q) => withInquirer(() => input({ message: q })),
156
+ log: (...lines) => lines.forEach((l) => emit(l)),
157
+ });
158
+ if (!result) {
159
+ // User declined, source invalid, or unsupported target — skip the turn.
160
+ emit(chalk.yellow('\n[--from] aborted; turn cancelled.'));
161
+ return;
162
+ }
163
+ promotedFromForSave = result.promotedFrom;
164
+ // 2026-05-08: thread the original @<filename> token into the LLM
165
+ // prompt so skills that need to dispatch a follow-up command (e.g.
166
+ // dispatch_skill calling /abap-rap --from @<same-file>) can use the
167
+ // exact filename. Previously the filename was consumed by
168
+ // parseFromFlag and never reached the model — skills had to guess
169
+ // and frequently dropped the unique-id segment ("foo-d515-v1" →
170
+ // "foo-v1"), producing "no file matching" auto-routes. Use basename
171
+ // so absolute / relative paths normalise to the workspace @<token>
172
+ // form the picker resolves.
173
+ const sourceToken = `@${basename(fromPath)}`;
174
+ userMessageForLLM = `Source file: ${sourceToken}\n${result.extendedSkillInput}\n\n---\n\n${rest}`;
175
+ userMessageForSave = rest;
176
+ }
177
+ }
178
+ // Phase D — `@<text-file>` attachment expansion.
179
+ // After --from has consumed its own @<token>, scan what's left for
180
+ // `@<filename>` references that resolve to .txt / .md files in the
181
+ // workspace. Substitute each with an <attached file="..."> block
182
+ // containing the file content so the skill sees the user's
183
+ // requirement document as part of the prompt. .cspeach.json
184
+ // envelopes are NOT expanded here (they go through --from / --status).
185
+ // The save copy keeps the original `@<filename>` token rather than
186
+ // the expanded body so the saved envelope's source.input stays
187
+ // human-readable; only the LLM sees the inflated prose.
188
+ userMessageForLLM = await expandTextFileAttachments(userMessageForLLM, params.ctx.cwd, (line) => emit(chalk.dim(line)));
189
+ // 2026-05-06: alongside the deterministic prompt-fact extraction
190
+ // (package / transport / etc.), inject the connected SAP system's
191
+ // release + platform + ABAP version. One CVERS query the first time
192
+ // we see an alias; cached in-memory + on disk thereafter (~30-day
193
+ // TTL). Skills like /abap-spec-gap stop asking "are we on S/4HANA?"
194
+ // when the answer is already in the prompt.
195
+ // Failure mode: getSapSystemInfo returns null on any error. The
196
+ // SAP block is then empty and the rest of enrichment proceeds as
197
+ // before — no behavioral regression.
198
+ let sapSystemBlock = '';
199
+ let sapSystemInfo = null;
200
+ if (params.ctx.adt && params.ctx.sapAlias) {
201
+ sapSystemInfo = await getSapSystemInfo(params.ctx.adt, params.ctx.sapAlias);
202
+ sapSystemBlock = renderSapSystemBlock(sapSystemInfo);
203
+ }
204
+ // Deterministically extract structured facts from the user's prompt
205
+ // (package, transport, object names, environment hint, create intent)
206
+ // and prepend them as an XML `<session_context>` block. The model reads
207
+ // these as structured data — eliminates the "skill kept asking about X
208
+ // when user said X" failure class without relying on prose rules.
209
+ // See src/router/intent-extractor.ts for the rationale.
210
+ //
211
+ // Enrich on every turn, not just the first: a classifier / reroute flow
212
+ // may have pushed a user message into session.messages before we get
213
+ // here, which would defeat a "first turn only" check. Skip only when the
214
+ // message already begins with <session_context> (caller enriched it) or
215
+ // when extraction yields nothing (enrichUserMessage returns original).
216
+ const enriched = userMessageForLLM.startsWith('<session_context>')
217
+ ? userMessageForLLM
218
+ : enrichUserMessage(userMessageForLLM, sapSystemBlock);
219
+ // Phase 2 of Track B — opt-in project-context enrichment, gated by
220
+ // CSPEACH_PROJECT_CONTEXT=on. Default OFF: production behavior is
221
+ // identical to today. When on, the rendered <project_context> block
222
+ // (file index, conventions, domain objects, git state) is prepended
223
+ // ONCE per session — to the first user message that doesn't already
224
+ // carry one. The hasProjectContextBlock guard scans session.messages
225
+ // for the literal block; this is what makes `cspeach --resume` correct
226
+ // (the rehydrated messages already contain the block from the prior
227
+ // process; the helper short-circuits cleanly). Helper handles the
228
+ // env-var gate, walk, and per-process cache.
229
+ let projectContextBlock = '';
230
+ if (!hasProjectContextBlock(session.messages)) {
231
+ // Phase 2.1 — when an alias is active, assemble the
232
+ // SapConnectionContext so the rendered project_context includes a
233
+ // populated <sap_connection> sub-block. The adapter is best-effort:
234
+ // missing values become null. recentObjects stays empty until the
235
+ // tool dispatch layer captures last-N targets (Phase 2.2+).
236
+ const sapConnection = params.ctx.sapAlias
237
+ ? buildSapConnectionContext(params.ctx.sapAlias, sapSystemInfo, {
238
+ currentTransport: getCurrentTransport() ?? null,
239
+ })
240
+ : null;
241
+ projectContextBlock = await maybeBuildProjectContext({
242
+ cwd: params.ctx.cwd,
243
+ sapConnection,
244
+ });
245
+ }
246
+ const finalUserContent = projectContextBlock
247
+ ? `${projectContextBlock}\n\n${enriched}`
248
+ : enriched;
249
+ session.messages.push({ role: 'user', content: finalUserContent });
250
+ session.skill = params.skill;
251
+ /**
252
+ * key: `${tool_name}:${object}` — consecutive failures per (tool, target).
253
+ * For object-identifying tools (sap_set_source / sap_activate / etc.) the
254
+ * target is `${name}:${type}`. For tools without an object identity in
255
+ * their args (sap_sql_query / sap_search_object), the target falls back
256
+ * to a stable hash of the args — so different probes/queries are NOT
257
+ * counted as retries of each other. See ./retry-key.ts for the rationale.
258
+ */
259
+ const failMap = new Map();
260
+ const RETRY_CAP = 3;
261
+ // Snapshot output tokens at turn start so the post-turn save hook can
262
+ // report the per-turn delta (not the lifetime cumulative). usage may be
263
+ // missing on older session schemas; the loop initializes it before the
264
+ // first stream event, so 0 is a safe pre-init baseline.
265
+ const turnStartOutputTokens = session.usage?.output_tokens ?? 0;
266
+ // Snapshot session.messages.length at turn start so the save hook can
267
+ // walk every assistant message added during this turn — not just the
268
+ // final one. Skills like /abap-cca emit their manifest in an early
269
+ // message before a closing ask_question widget; the final wrap-up
270
+ // message ends the turn but doesn't carry the manifest. See
271
+ // ./turn-assistant-text.ts for the full rationale.
272
+ const turnStartMessageCount = session.messages.length;
273
+ // v0.6 — mirror assistant text to ~/.cspeach/streams/<sessionId>/<ts>.md
274
+ // so a renderer crash or terminal close never loses the on-screen prose.
275
+ // The graceful-error UX (T19) prints this path on any failed turn.
276
+ const turnStreamWriter = new TurnStreamWriter(session.id);
277
+ // v0.6 Layer 3 — long-turn watchdog. Tracked across the while-loop's
278
+ // tool-call rounds. The advisory fires AT MOST ONCE per turn between
279
+ // rounds so it doesn't spam the model. See agent/turn-watchdog.ts.
280
+ const watchdogConfig = loadWatchdogConfig();
281
+ const turnStartedAt = Date.now();
282
+ let watchdogWarned = false;
283
+ // (emit was hoisted to the top of the function in v0.5 so the Phase J
284
+ // promote dispatch could share it. Original location was here.)
285
+ while (true) {
286
+ let buffer = initialBufferState();
287
+ let stream;
288
+ // 2026-05-06: paint a "thinking" spinner during the LLM round-trip
289
+ // wait so the user sees activity instead of staring at a silent
290
+ // prompt. The function disables itself when a chunkEmitter is
291
+ // provided (it would otherwise race with the chunkEmitter renderer's
292
+ // own paint loop), so call it without one — the spinner writes
293
+ // directly to stdout. Stopped on the first content_block_start (so
294
+ // the response stream paints in its place) and on any error path
295
+ // below.
296
+ const thinkingSpinner = startThinkingSpinner({});
297
+ // v0.6 — guaranteed-visible heartbeat alongside the in-place spinner.
298
+ // The spinner self-disables on non-TTY (Windows PowerShell sometimes
299
+ // reports isTTY=false; resume mode also lost spinner visibility) so a
300
+ // 45-second wait looked like a hang. The heartbeat prints fresh lines
301
+ // at 3s/10s/30s/60s/90s/2m/3m/5m thresholds — works on any terminal.
302
+ const thinkingHeartbeat = startThinkingHeartbeat();
303
+ try {
304
+ stream = await retryWithBackoff(() => (async () => {
305
+ const tools = toAnthropicTools(listTools());
306
+ const streamParams = {
307
+ model: cfg.default_model,
308
+ // v0.3.1 — was 8192. Bumped because code-gen skills (abap-generate
309
+ // etc.) kept running out of budget mid-turn: adaptive thinking +
310
+ // multiple tool-result prompts + TL;DR mandate + summary prose
311
+ // + the final question widget didn't fit in 8K. Symptom: stream
312
+ // ended with stop_reason='length' right before the widget, and
313
+ // the user saw the skill "stuck" after the intro prose.
314
+ // 32768 is Opus 4.7's recommended ceiling for agentic turns and
315
+ // gives comfortable headroom for the investigate-first pattern.
316
+ max_tokens: params.maxTokensOverride ?? 32768,
317
+ messages: session.messages,
318
+ tools,
319
+ thinking: { type: 'adaptive', display: 'summarized' },
320
+ };
321
+ // Skill travels as a header per coordination doc §1.
322
+ // Do NOT add `skill` to streamParams — it is not part of the Anthropic SDK body schema.
323
+ // v0.6 — forward the per-turn abort signal so Esc / SIGINT-during-turn
324
+ // tears down the in-flight stream cleanly.
325
+ return params.provider.createStream(streamParams, {
326
+ headers: { 'X-CSForge-Skill': params.skill },
327
+ signal: params.signal,
328
+ });
329
+ })());
330
+ }
331
+ catch (err) {
332
+ // Stop the thinking spinner before printing any error — leaving
333
+ // it spinning while an error message is emitted looks broken.
334
+ thinkingSpinner.stop();
335
+ thinkingHeartbeat.stop();
336
+ // Proxy returns 409 upgrade_required when CLI is older than skill's min_cli_version.
337
+ const status = err?.status ?? err?.response?.status;
338
+ const bodyRaw = err?.error ?? err?.response?.data ?? err?.body;
339
+ const body = typeof bodyRaw === 'string' ? JSON.parse(bodyRaw) : bodyRaw;
340
+ if (status === 409 && body?.error === 'upgrade_required') {
341
+ emit(chalk.yellow.bold(`\n⚠ This skill requires cspeach >= ${body.min_cli_version} (you have ${body.current ?? 'unknown'}).`));
342
+ emit(chalk.yellow(` Run: cspeach --upgrade`));
343
+ return;
344
+ }
345
+ throw err;
346
+ }
347
+ let currentAssistantContent = [];
348
+ let sawEndTurn = false;
349
+ let sawToolUse = false;
350
+ // M10 — ensure session.usage is present (shipped session schema may omit it
351
+ // for older saved sessions; we default to zero on first turn).
352
+ if (!session.usage) {
353
+ session.usage = { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0 };
354
+ }
355
+ // H2 — Anthropic's `message_delta.usage.output_tokens` is CUMULATIVE for the
356
+ // current message (it grows monotonically as the model streams). Naively
357
+ // doing `session.usage.output_tokens += deltaUsage.output_tokens` on every
358
+ // delta event multi-counts the same tokens N times. The correct pattern is
359
+ // to track the last-seen value per-message and add only the increment. This
360
+ // scratch is reset on every `message_start`.
361
+ //
362
+ // Reference: https://docs.anthropic.com/en/api/messages-streaming (see
363
+ // `message_delta` section — `usage.output_tokens` is "the cumulative number
364
+ // of output tokens generated so far for this message").
365
+ let messageOutputTokensSoFar = 0; // cumulative for the CURRENT message
366
+ // v0.6 resilience — capture any error thrown by the provider stream so we
367
+ // can persist whatever the model already emitted before the crash. The old
368
+ // behaviour was to let the exception propagate straight out of the for-await,
369
+ // skipping the assistant-message persistence below entirely. That's how a
370
+ // 77-minute /abap-cca turn with 200K tokens of output ended up entirely
371
+ // discarded when Undici reported the HTTP stream as "terminated".
372
+ let interruptedError = null;
373
+ try {
374
+ for await (const event of stream) {
375
+ const type = event.type;
376
+ if (type === 'content_block_start') {
377
+ // First content event of this stream — stop the thinking spinner
378
+ // so the response paints in its place. Idempotent: subsequent
379
+ // content_block_start events (for follow-on text/tool blocks)
380
+ // call stop on an already-stopped handle, which is a no-op.
381
+ thinkingSpinner.stop();
382
+ thinkingHeartbeat.stop();
383
+ const block = event.content_block;
384
+ currentAssistantContent.push(block);
385
+ if (block.type === 'text') {
386
+ const sep = chalk.gray('\n');
387
+ if (params.chunkEmitter)
388
+ params.chunkEmitter.emit('chunk', sep);
389
+ else
390
+ process.stdout.write(sep);
391
+ }
392
+ }
393
+ else if (type === 'content_block_delta') {
394
+ const delta = event.delta;
395
+ const last = currentAssistantContent[currentAssistantContent.length - 1];
396
+ if (delta.type === 'text_delta') {
397
+ const { output, state } = renderChunk(delta.text, buffer);
398
+ buffer = state;
399
+ if (output) {
400
+ if (params.chunkEmitter)
401
+ params.chunkEmitter.emit('chunk', output);
402
+ else
403
+ process.stdout.write(output);
404
+ }
405
+ if (last?.type === 'text')
406
+ last.text = (last.text ?? '') + delta.text;
407
+ // v0.6 — also write the raw model text to disk so the user can
408
+ // recover the prose if the terminal renderer crashes or the turn
409
+ // aborts mid-stream. Best-effort fire-and-forget.
410
+ void turnStreamWriter.append(delta.text);
411
+ }
412
+ else if (delta.type === 'input_json_delta') {
413
+ if (last)
414
+ last.partial_json = (last.partial_json ?? '') + delta.partial_json;
415
+ }
416
+ else if (delta.type === 'thinking_delta') {
417
+ // Adaptive thinking streams content here. Must be preserved on the
418
+ // assistant message so the next turn doesn't reject the block with
419
+ // "each thinking block must contain thinking".
420
+ if (last?.type === 'thinking')
421
+ last.thinking = (last.thinking ?? '') + delta.thinking;
422
+ }
423
+ else if (delta.type === 'signature_delta') {
424
+ // Thinking blocks may carry a signature used for server-side verification.
425
+ if (last?.type === 'thinking')
426
+ last.signature = (last.signature ?? '') + delta.signature;
427
+ }
428
+ }
429
+ else if (type === 'content_block_stop') {
430
+ const last = currentAssistantContent[currentAssistantContent.length - 1];
431
+ if (last?.type === 'tool_use' && last.partial_json) {
432
+ try {
433
+ last.input = JSON.parse(last.partial_json);
434
+ }
435
+ catch {
436
+ last.input = {};
437
+ }
438
+ delete last.partial_json;
439
+ }
440
+ }
441
+ else if (type === 'message_start') {
442
+ // message_start carries the prompt usage (input tokens + any cache reads).
443
+ const msgUsage = event.message?.usage;
444
+ if (msgUsage) {
445
+ session.usage.input_tokens += msgUsage.input_tokens ?? 0;
446
+ session.usage.cache_read_input_tokens += msgUsage.cache_read_input_tokens ?? 0;
447
+ }
448
+ // New message begins — reset the per-message cumulative-output scratch.
449
+ messageOutputTokensSoFar = 0;
450
+ }
451
+ else if (type === 'message_delta') {
452
+ const stop_reason = event.delta?.stop_reason;
453
+ if (stop_reason === 'end_turn')
454
+ sawEndTurn = true;
455
+ if (stop_reason === 'tool_use')
456
+ sawToolUse = true;
457
+ // H2 — `deltaUsage.output_tokens` is the cumulative running total for
458
+ // THIS message, NOT a per-event delta. Compute the increment vs the
459
+ // last-seen cumulative and add only that. Guard against `null` /
460
+ // missing-field and against monotonic-decrease edge cases.
461
+ const deltaUsage = event.usage;
462
+ if (deltaUsage?.output_tokens != null) {
463
+ const inc = deltaUsage.output_tokens - messageOutputTokensSoFar;
464
+ if (inc > 0) {
465
+ session.usage.output_tokens += inc;
466
+ messageOutputTokensSoFar = deltaUsage.output_tokens;
467
+ }
468
+ }
469
+ }
470
+ }
471
+ }
472
+ catch (err) {
473
+ // Provider stream broke mid-flight (Undici "terminated" / network drop /
474
+ // AbortController / Anthropic stream timeout). DO NOT lose the partial
475
+ // assistant content — capture the error and fall through to the persist
476
+ // block below so whatever the model already streamed lands on disk.
477
+ interruptedError = err;
478
+ thinkingSpinner.stop();
479
+ thinkingHeartbeat.stop();
480
+ }
481
+ // Final flush: emit any pending partial line after the stream ends.
482
+ // Runs on both clean completion and mid-stream interruption.
483
+ if (buffer.pending.length > 0) {
484
+ const flushed = render(buffer.pending);
485
+ if (params.chunkEmitter)
486
+ params.chunkEmitter.emit('chunk', flushed);
487
+ else
488
+ process.stdout.write(flushed);
489
+ buffer = initialBufferState();
490
+ }
491
+ // Repair partial blocks before persistence — drop incomplete tool_use
492
+ // fragments so the next API call doesn't reject the assistant message.
493
+ // See ./repair-partial.ts for the full rationale.
494
+ if (interruptedError !== null) {
495
+ currentAssistantContent = repairPartialBlocks(currentAssistantContent);
496
+ }
497
+ // Persist the assistant message — even if it's partial. Empty content
498
+ // arrays are skipped so we don't push a meaningless empty assistant
499
+ // message that would confuse the next round.
500
+ if (currentAssistantContent.length > 0) {
501
+ session.messages.push({ role: 'assistant', content: currentAssistantContent });
502
+ }
503
+ session.last_turn_at = new Date().toISOString();
504
+ if (interruptedError !== null) {
505
+ session.turnInterrupted = true;
506
+ const msg = interruptedError instanceof Error ? interruptedError.message : String(interruptedError);
507
+ session.turnInterruptedReason = msg.slice(0, 200);
508
+ }
509
+ else {
510
+ // Clean completion — clear any stale interrupted flag from a prior turn.
511
+ session.turnInterrupted = false;
512
+ session.turnInterruptedReason = undefined;
513
+ }
514
+ await saveSession(session);
515
+ // Close the stream-to-disk mirror with a footer (lightweight diagnostics).
516
+ // Best-effort — failure here is invisible to the turn flow.
517
+ void turnStreamWriter.close(interruptedError !== null
518
+ ? `_interrupted: ${session.turnInterruptedReason ?? 'unknown'}_`
519
+ : undefined);
520
+ // Re-throw AFTER persistence so the outer REPL catch can render the
521
+ // graceful error UX with full session-id + log-path context.
522
+ if (interruptedError !== null)
523
+ throw interruptedError;
524
+ if (sawEndTurn) {
525
+ emit('');
526
+ // v0.6 Layer 3 — stream-end watchdog warning. The between-rounds
527
+ // watchdog in the loop body only fires for multi-round turns (heavy
528
+ // tool calls). A single-round text-only emission (no tool calls)
529
+ // bypasses that path entirely, so a model can stream past the budget
530
+ // unchecked. We can't inject a synthetic advisory at this point —
531
+ // the turn is over — but we CAN warn the user that the turn went
532
+ // long, so they tighten the next prompt or disable the watchdog
533
+ // for genuinely-long-by-design analyses.
534
+ if (!watchdogWarned) {
535
+ const outputTokensThisTurn = Math.max(0, (session.usage?.output_tokens ?? 0) - turnStartOutputTokens);
536
+ const decision = evaluateWatchdog({
537
+ startedAt: turnStartedAt,
538
+ outputTokensThisTurn,
539
+ config: watchdogConfig,
540
+ alreadyWarned: false,
541
+ });
542
+ if (decision.shouldWarn) {
543
+ emit(chalk.yellow(`[watchdog] Turn ran past budget: ${decision.reason}.`));
544
+ emit(chalk.gray(` Consider a more focused prompt next time, or set CSPEACH_TURN_WATCHDOG=off if`));
545
+ emit(chalk.gray(` this skill is genuinely long-by-design (e.g. /abap-cca on a large estate).`));
546
+ }
547
+ }
548
+ // v0.3.1 — if the last assistant message ended with a question marker
549
+ // ("Reply with your answer..." or <!-- widget-real -->), set the flag
550
+ // so the REPL routes the user's next prompt back to this same skill
551
+ // without re-classifying. Otherwise clear the flag so normal
552
+ // classification resumes.
553
+ session.awaitingSkillAnswer = lastAssistantIsAwaitingAnswer(session.messages);
554
+ await saveSession(session);
555
+ // Handover/takeover hook (Task C5):
556
+ // After certain skills (currently /abap-spec-gap) render their final
557
+ // answer, offer to save it as a project file. The hook runs AFTER the
558
+ // response is rendered (emit('') above flushed) and BEFORE returning,
559
+ // so the user sees the answer first, then is asked whether to save.
560
+ // It awaits the user's y/N reply before returning — interactive, not
561
+ // a background block on the next REPL prompt.
562
+ //
563
+ // 2026-05-12: collect text from ALL assistant messages added during
564
+ // this turn, not just the final one. Skills like /abap-cca emit
565
+ // their manifest in an early message before a closing ask_question
566
+ // widget; the final wrap-up message ends the turn but doesn't
567
+ // carry the manifest. See ./turn-assistant-text.ts for details.
568
+ const assistantText = collectTurnAssistantText(session.messages, turnStartMessageCount);
569
+ await maybeOfferSave({
570
+ skill: params.skill,
571
+ assistantText,
572
+ userMessage: userMessageForSave,
573
+ tokensUsed: Math.max(0, (session.usage?.output_tokens ?? 0) - turnStartOutputTokens),
574
+ model: cfg.default_model ?? session.model ?? 'unknown',
575
+ emit,
576
+ promotedFrom: promotedFromForSave,
577
+ });
578
+ return;
579
+ }
580
+ if (!sawToolUse) {
581
+ // Unexpected stop reason — bail to avoid infinite loop.
582
+ emit(chalk.yellow('\n[unexpected stop reason — ending turn]'));
583
+ return;
584
+ }
585
+ // Dispatch each tool_use block.
586
+ const toolResults = [];
587
+ for (const block of currentAssistantContent) {
588
+ if (block.type === 'tool_use') {
589
+ // Phase 1: print the dispatch line (CC-style: ⏺ name(args)).
590
+ renderToolCallTop({
591
+ name: block.name,
592
+ args: (block.input ?? {}),
593
+ chunkEmitter: params.chunkEmitter,
594
+ });
595
+ // Phase 2a: start the peach-themed spinner. No-op outside TTY / Ink
596
+ // / CI — the result line still prints, just without the in-place
597
+ // animation. Spinner is purely a "still working" cue, not
598
+ // load-bearing for output.
599
+ const spinner = startToolSpinner({ chunkEmitter: params.chunkEmitter });
600
+ // Phase 2b: dispatch (may take 100ms–several seconds for write tools).
601
+ const dispatchStart = Date.now();
602
+ const result = await dispatchTool(block.name, block.input, params.ctx);
603
+ const durationMs = Date.now() - dispatchStart;
604
+ // Phase 2c: stop the spinner, which erases its line so the result
605
+ // row paints in place of it (no scrollback artifacts).
606
+ spinner.stop();
607
+ // Phase 3: print result line ( ⎿ ✓ summary · timing).
608
+ const resultSummary = result.is_error
609
+ ? (typeof result.content === 'string' ? result.content.slice(0, 80) : 'error')
610
+ : undefined;
611
+ renderToolCallBottom({
612
+ durationMs,
613
+ isError: result.is_error ?? false,
614
+ resultSummary,
615
+ chunkEmitter: params.chunkEmitter,
616
+ });
617
+ // v0.3 (Step 28.0): populate session.toolCalls so the SessionTimeline
618
+ // overlay can render per-write history. Cap per-entry payload size at
619
+ // 4kB to bound session.json growth.
620
+ const completedAt = new Date().toISOString();
621
+ {
622
+ const resultText = typeof result.content === 'string'
623
+ ? result.content
624
+ : JSON.stringify(result.content);
625
+ session.toolCalls.push({
626
+ tool_use_id: block.id,
627
+ tool: block.name,
628
+ args: block.input ?? {},
629
+ result: resultText.slice(0, 4_000),
630
+ is_error: result.is_error ?? false,
631
+ duration_ms: durationMs,
632
+ completed_at: completedAt,
633
+ completed: true,
634
+ });
635
+ }
636
+ // Layer 2 — skill-level checkpoint. Always-on JSONL audit log of every
637
+ // tool call, plus optional skill-specific handlers (registered via
638
+ // skill-checkpoint.ts). Best-effort; failures are swallowed and never
639
+ // break a working turn. See agent/skill-checkpoint.ts for rationale.
640
+ void applyToolResultCheckpoint({
641
+ sessionId: session.id,
642
+ skill: session.skill,
643
+ toolName: block.name,
644
+ args: block.input ?? {},
645
+ result: result.content,
646
+ isError: result.is_error ?? false,
647
+ durationMs,
648
+ completedAt,
649
+ });
650
+ // Retry-cap: track consecutive failures per (tool, object) pair.
651
+ // This block runs BEFORE the normal toolResults.push below.
652
+ const objKey = objectKeyFromInput(block.name, block.input ?? {});
653
+ if (result.is_error) {
654
+ const count = (failMap.get(objKey) ?? 0) + 1;
655
+ failMap.set(objKey, count);
656
+ if (count >= RETRY_CAP) {
657
+ // Cap hit — push the error result, save session, and abort the turn.
658
+ toolResults.push({
659
+ type: 'tool_result',
660
+ tool_use_id: block.id,
661
+ is_error: true,
662
+ content: JSON.stringify({
663
+ error: ERR.APPROVAL_THRASH,
664
+ detail: `Retry cap hit after ${RETRY_CAP} consecutive failures on ${objKey}. Aborting turn.`,
665
+ }),
666
+ });
667
+ session.messages.push({ role: 'user', content: toolResults });
668
+ await saveSession(session);
669
+ emit(chalk.red(`\n[retry cap] ${objKey} failed ${RETRY_CAP} times — aborting turn.`));
670
+ return;
671
+ }
672
+ }
673
+ else {
674
+ failMap.delete(objKey); // reset on success
675
+ }
676
+ // Normal path falls through: the existing `toolResults.push(...)` block
677
+ // immediately below handles both success and the not-yet-capped failure case.
678
+ toolResults.push({
679
+ type: 'tool_result',
680
+ tool_use_id: block.id,
681
+ content: result.content,
682
+ is_error: result.is_error,
683
+ });
684
+ }
685
+ }
686
+ session.messages.push({ role: 'user', content: toolResults });
687
+ await saveSession(session);
688
+ // v0.6 Layer 3 — between-rounds watchdog check. If the turn has been
689
+ // running too long (wall-clock or token budget), inject a synthetic
690
+ // user message asking the model to checkpoint + summarise + hand back
691
+ // to the user. The model decides whether to wrap up or push through.
692
+ {
693
+ const outputTokensThisTurn = Math.max(0, (session.usage?.output_tokens ?? 0) - turnStartOutputTokens);
694
+ const decision = evaluateWatchdog({
695
+ startedAt: turnStartedAt,
696
+ outputTokensThisTurn,
697
+ config: watchdogConfig,
698
+ alreadyWarned: watchdogWarned,
699
+ });
700
+ if (decision.shouldWarn) {
701
+ watchdogWarned = true;
702
+ emit(chalk.yellow(`\n[watchdog] ${decision.reason}. Asking the model to wrap up.`));
703
+ session.messages.push({ role: 'user', content: decision.message });
704
+ await saveSession(session);
705
+ }
706
+ }
707
+ // Loop continues → next messages.create with the tool results.
708
+ }
709
+ }