@cspeach/cli 1.1.18 → 1.1.20

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 (252) hide show
  1. package/README.md +3 -4
  2. package/dist/agent/anthropic-provider.js +30 -10
  3. package/dist/agent/cache-keepalive.js +162 -0
  4. package/dist/agent/cold-prune.js +116 -0
  5. package/dist/agent/loop.js +804 -159
  6. package/dist/agent/provider-shape.js +263 -0
  7. package/dist/agent/providers/ai-hub-provider.js +17 -2
  8. package/dist/agent/providers/byok-provider.js +33 -3
  9. package/dist/agent/providers/local-provider.js +12 -2
  10. package/dist/agent/repair-partial.js +125 -7
  11. package/dist/agent/summarise-via-provider.js +6 -1
  12. package/dist/agent/system-prompt.js +38 -0
  13. package/dist/agent/tool-dispatch.js +66 -21
  14. package/dist/agent/tool-loading-pin.js +100 -0
  15. package/dist/agent/turn-error-ux.js +77 -26
  16. package/dist/agent/turn-stream.js +25 -3
  17. package/dist/approvals/adt-type.js +27 -0
  18. package/dist/approvals/advisory-prompt.js +48 -0
  19. package/dist/approvals/advisory-render.js +25 -11
  20. package/dist/approvals/approval-prompt.js +74 -24
  21. package/dist/approvals/jwt.js +2 -1
  22. package/dist/approvals/render.js +64 -36
  23. package/dist/approvals/risk-floor.js +53 -2
  24. package/dist/auth/api-key.js +30 -16
  25. package/dist/auth/keychain.js +0 -0
  26. package/dist/auth/me.js +25 -7
  27. package/dist/cli.js +36 -6
  28. package/dist/commands/auto-compact.js +33 -16
  29. package/dist/commands/compact.js +49 -11
  30. package/dist/commands/config-set.js +16 -3
  31. package/dist/commands/config-show.js +18 -5
  32. package/dist/commands/cost.js +54 -35
  33. package/dist/commands/help.js +88 -50
  34. package/dist/commands/login.js +28 -19
  35. package/dist/commands/logout.js +7 -3
  36. package/dist/commands/plan-audit.js +1 -0
  37. package/dist/commands/plan-chain.js +71 -77
  38. package/dist/commands/plan-continue.js +1 -0
  39. package/dist/commands/plan-resume.js +20 -12
  40. package/dist/commands/whoami.js +23 -9
  41. package/dist/config/loader.js +70 -5
  42. package/dist/cost/cost-log.js +62 -2
  43. package/dist/cost/pricing.js +11 -6
  44. package/dist/doctor/checks/forge-rules.js +13 -3
  45. package/dist/doctor/checks/keychain.js +6 -4
  46. package/dist/doctor/run.js +101 -20
  47. package/dist/index.js +5 -2
  48. package/dist/lib/graceful-exit.js +56 -0
  49. package/dist/lib/piped-prompt.js +65 -0
  50. package/dist/lib/spill-labels.js +13 -0
  51. package/dist/lock-contention.js +2 -2
  52. package/dist/models/resolve.js +93 -2
  53. package/dist/models/server-config.js +158 -3
  54. package/dist/one-shot.js +150 -45
  55. package/dist/projects/answer-blockers.js +6 -1
  56. package/dist/projects/handover-md.js +15 -14
  57. package/dist/projects/image-attachments.js +15 -2
  58. package/dist/projects/plan-run.js +97 -54
  59. package/dist/projects/promote-command.js +11 -2
  60. package/dist/projects/save-command.js +3 -2
  61. package/dist/projects/status.js +2 -1
  62. package/dist/projects/yes-no.js +12 -0
  63. package/dist/renderer/abap-inline.js +18 -14
  64. package/dist/renderer/answer-trim.js +69 -0
  65. package/dist/renderer/banners.js +8 -8
  66. package/dist/renderer/brand-settled.js +19 -0
  67. package/dist/renderer/change-card.js +156 -0
  68. package/dist/renderer/checklist-format.js +130 -0
  69. package/dist/renderer/color-mode.js +45 -0
  70. package/dist/renderer/error-detail.js +104 -0
  71. package/dist/renderer/fence-state.js +46 -0
  72. package/dist/renderer/footer-line.js +100 -0
  73. package/dist/renderer/glyphs.js +25 -0
  74. package/dist/renderer/goodbye.js +38 -0
  75. package/dist/renderer/legacy-palette.js +41 -0
  76. package/dist/renderer/look.js +30 -0
  77. package/dist/renderer/markdown.js +299 -92
  78. package/dist/renderer/notice-log.js +72 -0
  79. package/dist/renderer/notice-shape.js +48 -0
  80. package/dist/renderer/notices.js +40 -8
  81. package/dist/renderer/panel-rows.js +16 -0
  82. package/dist/renderer/pipeline.js +66 -0
  83. package/dist/renderer/progress-chatter.js +23 -17
  84. package/dist/renderer/rendering-mode.js +34 -0
  85. package/dist/renderer/routing-line.js +14 -0
  86. package/dist/renderer/sanitize.js +12 -0
  87. package/dist/renderer/severity.js +2 -7
  88. package/dist/renderer/startup-lines.js +154 -0
  89. package/dist/renderer/status-footer.js +52 -34
  90. package/dist/renderer/steering-echo.js +41 -0
  91. package/dist/renderer/syntax.js +31 -12
  92. package/dist/renderer/tables.js +5 -1
  93. package/dist/renderer/theme.js +44 -0
  94. package/dist/renderer/thinking-heartbeat.js +33 -2
  95. package/dist/renderer/todo-block.js +10 -49
  96. package/dist/renderer/tool-labels.js +269 -0
  97. package/dist/renderer/tool-widget.js +245 -51
  98. package/dist/renderer/trace.js +42 -0
  99. package/dist/renderer/transcript-flow.js +132 -0
  100. package/dist/renderer/tty.js +34 -1
  101. package/dist/renderer/ui-width.js +55 -0
  102. package/dist/renderer/verify-chain.js +4 -2
  103. package/dist/renderer/widget-fallback.js +58 -65
  104. package/dist/repl/bracketed-paste.js +7 -1
  105. package/dist/repl/builtin-commands.js +19 -8
  106. package/dist/repl/credential-handover-gate.js +28 -0
  107. package/dist/repl/current-transport.js +13 -0
  108. package/dist/repl/early-line-buffer.js +5 -2
  109. package/dist/repl/file-picker.js +54 -12
  110. package/dist/repl/ink-stdin-guard.js +66 -0
  111. package/dist/repl/inquirer-guard.js +59 -16
  112. package/dist/repl/inquirer-theme.js +27 -33
  113. package/dist/repl/paste-marker.js +19 -0
  114. package/dist/repl/plan-turn-end.js +20 -0
  115. package/dist/repl/post-turn-status.js +43 -11
  116. package/dist/repl/reroute-turn.js +21 -0
  117. package/dist/repl/reset-tty-stdin.js +44 -0
  118. package/dist/repl/restore-guard.js +22 -0
  119. package/dist/repl/resume-standalone.js +63 -0
  120. package/dist/repl/rule8-detector.js +11 -3
  121. package/dist/repl/safety-confirm.js +161 -93
  122. package/dist/repl/session-spend-line.js +8 -4
  123. package/dist/repl/themed-prompts.js +19 -0
  124. package/dist/repl/ui-look-command.js +75 -0
  125. package/dist/repl/update-method-preview-hook.js +48 -5
  126. package/dist/repl.js +863 -339
  127. package/dist/rewind/cli.js +3 -2
  128. package/dist/rewind/format.js +14 -9
  129. package/dist/rewind/restore.js +11 -0
  130. package/dist/router/classifier.js +47 -4
  131. package/dist/router/intent-extractor.js +24 -8
  132. package/dist/router/piped-routing.js +23 -0
  133. package/dist/router/resume-routing.js +16 -0
  134. package/dist/router/routing-failure.js +57 -0
  135. package/dist/sap/first-run-choice.js +1 -1
  136. package/dist/sap/onboarding.js +3 -2
  137. package/dist/sap/standalone-onboarding.js +1 -1
  138. package/dist/sap/system-info.js +4 -2
  139. package/dist/sap/unreachable.js +28 -0
  140. package/dist/sap-errors/clean-error-text.js +182 -0
  141. package/dist/session/interrupt-reason.js +39 -0
  142. package/dist/session/recap.js +36 -24
  143. package/dist/session/repin-model.js +18 -0
  144. package/dist/session/resume.js +104 -36
  145. package/dist/session/store.js +84 -28
  146. package/dist/session/user-prompt.js +21 -0
  147. package/dist/skill-catalog.js +7 -0
  148. package/dist/skills/bundled-skills.js +76 -69
  149. package/dist/skills/preamble.js +75 -0
  150. package/dist/skills/source-manifest.js +11 -1
  151. package/dist/standards/standards-init.js +1 -1
  152. package/dist/test-helpers/answer-any.js +46 -0
  153. package/dist/tools/approval.js +27 -11
  154. package/dist/tools/ask-question.js +47 -5
  155. package/dist/tools/filesystem/file-read.js +11 -1
  156. package/dist/tools/filesystem/file-write.js +19 -2
  157. package/dist/tools/fiori/fe-extend.js +16 -1
  158. package/dist/tools/fiori/fe-scaffold.js +105 -3
  159. package/dist/tools/fiori/html-escapes.js +28 -0
  160. package/dist/tools/fiori/metadata/audit.js +605 -0
  161. package/dist/tools/fiori/metadata/references.js +104 -0
  162. package/dist/tools/fiori/metadata/types.js +8 -0
  163. package/dist/tools/fiori/preview/app-guard.js +215 -0
  164. package/dist/tools/fiori/preview/env.js +193 -0
  165. package/dist/tools/fiori/preview/readiness.js +127 -0
  166. package/dist/tools/fiori/preview/registry.js +412 -0
  167. package/dist/tools/fiori/preview/start.js +469 -0
  168. package/dist/tools/fiori/samples/loader.js +20 -5
  169. package/dist/tools/fiori/scaffold.js +18 -6
  170. package/dist/tools/fiori/smoke/app-driver-page.js +372 -0
  171. package/dist/tools/fiori/smoke/app-driver.js +100 -0
  172. package/dist/tools/fiori/smoke/assertions.js +161 -24
  173. package/dist/tools/fiori/smoke/audit-columns.js +21 -0
  174. package/dist/tools/fiori/smoke/batch.js +273 -0
  175. package/dist/tools/fiori/smoke/draft-safety.js +241 -0
  176. package/dist/tools/fiori/smoke/draft-smoke.js +473 -0
  177. package/dist/tools/fiori/smoke/driver.js +171 -4
  178. package/dist/tools/fiori/smoke/evidence.js +35 -0
  179. package/dist/tools/fiori/smoke/labels-codes.js +207 -0
  180. package/dist/tools/fiori/smoke/nav-error.js +26 -0
  181. package/dist/tools/fiori/smoke/run-smoke.js +155 -12
  182. package/dist/tools/fiori/tools.js +651 -7
  183. package/dist/tools/fiori/ui5-version.js +16 -0
  184. package/dist/tools/index.js +8 -0
  185. package/dist/tools/local-build.js +6 -0
  186. package/dist/tools/result-spill.js +238 -0
  187. package/dist/tools/sap-read.js +74 -15
  188. package/dist/tools/sap-write.js +122 -10
  189. package/dist/tools/shell/shell_exec.js +39 -12
  190. package/dist/tools/subagent/adt-serial.js +33 -0
  191. package/dist/tools/subagent/agent_run.js +2 -0
  192. package/dist/tools/subagent/read_agent.js +178 -0
  193. package/dist/tools/subagent/reader-prompt.js +48 -0
  194. package/dist/tools/syntax-state.js +67 -0
  195. package/dist/tools/todo.js +26 -19
  196. package/dist/tools/tool-loading.js +255 -0
  197. package/dist/tools/tool-output-read.js +117 -0
  198. package/dist/tools/transport-fit.js +462 -0
  199. package/dist/tools/transport-resolution.js +3 -1
  200. package/dist/tools/transport.js +39 -8
  201. package/dist/tools/verify.js +184 -8
  202. package/dist/ui/app.js +284 -146
  203. package/dist/ui/approval-modal.js +41 -15
  204. package/dist/ui/ask-answer-text.js +17 -0
  205. package/dist/ui/body.js +25 -6
  206. package/dist/ui/card-slot.js +98 -0
  207. package/dist/ui/checklist.js +9 -0
  208. package/dist/ui/coaching-picker-classic.js +8 -5
  209. package/dist/ui/command-palette.js +6 -4
  210. package/dist/ui/confirm-request.js +18 -0
  211. package/dist/ui/context-grid.js +2 -1
  212. package/dist/ui/file-palette.js +6 -4
  213. package/dist/ui/files-card-emitter.js +19 -0
  214. package/dist/ui/footer-line.js +16 -0
  215. package/dist/ui/footer.js +105 -104
  216. package/dist/ui/input-wrap.js +122 -0
  217. package/dist/ui/interrupt.js +103 -0
  218. package/dist/ui/key-burst.js +117 -0
  219. package/dist/ui/line-resolution.js +4 -3
  220. package/dist/ui/live-area.js +71 -0
  221. package/dist/ui/login-banner.js +54 -43
  222. package/dist/ui/rewind-panel.js +76 -24
  223. package/dist/ui/role-props.js +47 -0
  224. package/dist/ui/sap-state-store.js +2 -1
  225. package/dist/ui/session-timeline.js +49 -3
  226. package/dist/ui/skill-picker.js +5 -47
  227. package/dist/ui/text-input.js +128 -24
  228. package/dist/ui/todo-emitter.js +19 -0
  229. package/dist/ui/turn-status-emitter.js +65 -2
  230. package/dist/ui/turn-status.js +16 -19
  231. package/dist/ui/use-card-request.js +44 -0
  232. package/dist/ui/widgets/ask-card.js +521 -0
  233. package/dist/ui/widgets/bar-chart.js +11 -5
  234. package/dist/ui/widgets/coaching-picker.js +50 -55
  235. package/dist/ui/widgets/confirm-card.js +193 -0
  236. package/dist/ui/widgets/dep-graph.js +3 -2
  237. package/dist/ui/widgets/diff-viewer.js +6 -5
  238. package/dist/ui/widgets/files-card.js +97 -0
  239. package/dist/ui/widgets/question-card.js +2 -1
  240. package/dist/ui/widgets/reclaim-raw-stdin.js +24 -0
  241. package/dist/ui/widgets/shortcuts-sheet.js +25 -0
  242. package/dist/ui/widgets/stack-frames.js +2 -1
  243. package/dist/upgrade-check.js +39 -8
  244. package/package.json +12 -2
  245. package/test-harness/fe-draft/app.ts +188 -0
  246. package/test-harness/fe-draft/fe-draft.e2e.test.ts +461 -0
  247. package/test-harness/fe-draft/manifest-enhancer.unit.test.ts +51 -0
  248. package/test-harness/fe-draft/routes.ts +224 -0
  249. package/test-harness/fe-draft/routes.unit.test.ts +45 -0
  250. package/test-harness/fe-draft/server.ts +225 -0
  251. package/test-harness/fe-draft/variants.ts +196 -0
  252. package/vitest.smoke-e2e.config.ts +20 -0
@@ -0,0 +1,16 @@
1
+ // cspeach-cli/src/tools/fiori/ui5-version.ts
2
+ //
3
+ // THE one pinned UI5 version: the default both scaffolds write into
4
+ // manifest.json (minUI5Version) and the version the offline smoke harness
5
+ // serves (test-harness/fe-draft/routes.ts imports it as its "newest" pin).
6
+ //
7
+ // Why an exact, hosted patch and never an `x.y.0` guess: fiori-tools-proxy
8
+ // fetches `https://ui5.sap.com/<minUI5Version>/resources/...` verbatim, and SAP
9
+ // removes old patch releases from that CDN. The old default `1.120.0` was gone
10
+ // on 2026-09-21 (sap/m/library.js → 404): every freshly scaffolded app booted
11
+ // to a blank page. When SAP retires this patch too, bump the patch — the
12
+ // smoke-e2e run fails with a message pointing here.
13
+ //
14
+ // Why 1.120: the S/4HANA 2023-era LTS; it parses the hyphenated
15
+ // `data-sap-ui-on-init` bootstrap attribute the writers emit (1.71 does not).
16
+ export const PINNED_UI5_VERSION = '1.120.51';
@@ -20,6 +20,14 @@ export function listTools() {
20
20
  // always returned, preserving today's listTools() semantics exactly.
21
21
  return Array.from(registry.values()).filter((t) => !t.flagGated || isToolFlagOn(t.name));
22
22
  }
23
+ /**
24
+ * Every registered tool, flag-gated ones included (no visibility filter).
25
+ * For coverage checks (e.g. every tool has a transcript label); callers that
26
+ * send tools to the model must keep using listTools().
27
+ */
28
+ export function listAllTools() {
29
+ return Array.from(registry.values());
30
+ }
23
31
  /**
24
32
  * Reserved alias for standalone mode — CSPeach running with no SAP system.
25
33
  * Set at the single ctx-construction site in repl.tsx / one-shot.ts alongside
@@ -12,6 +12,9 @@
12
12
  * shell: shell_exec
13
13
  * fiori: fiori_scaffold, fiori_apply, fiori_list, fiori_deploy_config,
14
14
  * fiori_scaffold_fe, fiori_fe_extend, fiori_render_smoke,
15
+ * fiori_preview (L1-6 — the preview server every browser check
16
+ * depends on), fiori_metadata_audit, fiori_draft_smoke
17
+ * (L1-5b — a SAP write, approval-gated like every write),
15
18
  * fiori_sample_search, fiori_sample_get
16
19
  * extend: extend_model_insert (anchored CDS/DDLX edit — abap-extend-model)
17
20
  * subagent: agent_run (Task 7, 2026-07-03 — customers get subagent
@@ -63,6 +66,9 @@ export const LOCAL_BUILD_TOOLS = [
63
66
  'fiori_scaffold_fe',
64
67
  'fiori_fe_extend',
65
68
  'fiori_render_smoke',
69
+ 'fiori_preview',
70
+ 'fiori_metadata_audit',
71
+ 'fiori_draft_smoke',
66
72
  'fiori_sample_search',
67
73
  'fiori_sample_get',
68
74
  'extend_model_insert',
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Task 17 (model control, 2026-09-27, spec D12) — large tool results go to a
3
+ * file; the history keeps a preview and a path.
4
+ *
5
+ * Claude Code's "output too large, saved to file" pattern. Above
6
+ * SPILL_THRESHOLD_CHARS the full text is written to
7
+ * ~/.cspeach/streams/<sessionId>/tool-<toolUseId>.txt and the tool_result
8
+ * carries the first 60 lines (plus the last 20 for source, SQL, shell and file windows),
9
+ * then one pointer line naming tool_output_read.
10
+ *
11
+ * Sizing happens ONCE, when the tool_result is created (the handler's return
12
+ * value) — never afterwards, so it is not a history edit and the cached
13
+ * prefix / preserved thinking stay append-only.
14
+ *
15
+ * The spill file holds exactly the text the tool would have returned — no
16
+ * more (no secrets beyond what the tool already produced), and it lives in the
17
+ * per-session area under ~/.cspeach, never in the user's project.
18
+ */
19
+ import * as fs from 'node:fs';
20
+ import * as path from 'node:path';
21
+ import * as crypto from 'node:crypto';
22
+ import { streamsRoot } from '../agent/turn-stream.js';
23
+ import { rememberSpillLabel } from '../lib/spill-labels.js';
24
+ export const SPILL_THRESHOLD_CHARS = 8_000;
25
+ export const PREVIEW_HEAD_LINES = 60;
26
+ export const PREVIEW_TAIL_LINES = 20;
27
+ /** One preview line never carries more than this (a minified JSON line must not defeat the preview). */
28
+ export const PREVIEW_LINE_MAX_CHARS = 500;
29
+ /** Character budgets for the head and tail blocks of the preview. */
30
+ export const PREVIEW_HEAD_BUDGET_CHARS = 6_000;
31
+ export const PREVIEW_TAIL_BUDGET_CHARS = 2_000;
32
+ /**
33
+ * Kinds whose preview also keeps the last lines (the end matters: totals,
34
+ * exit code, ENDCLASS; for file_read the window's last line). Only search
35
+ * hits and reports are head-only.
36
+ */
37
+ const TAIL_KINDS = new Set(['source', 'sql', 'shell', 'file']);
38
+ /** Session ids / tool_use ids become file names: keep them path-inert. */
39
+ function safeSegment(s) {
40
+ const cleaned = s.replace(/[^A-Za-z0-9_-]/g, '_');
41
+ return cleaned.length > 0 ? cleaned : '_';
42
+ }
43
+ /** The directory that holds one session's spill files. */
44
+ export function spillDirFor(sessionId) {
45
+ return path.join(streamsRoot(), safeSegment(sessionId));
46
+ }
47
+ /**
48
+ * Session id as the tools see it, or undefined when the ctx carries none
49
+ * (some tests and non-loop call paths). Without a session there is no
50
+ * private spill dir, so nothing is spilled and tool_output_read refuses —
51
+ * never a shared fallback directory (fix round 1, minor 3).
52
+ */
53
+ export function sessionIdOf(ctx) {
54
+ const id = ctx.session?.id;
55
+ return typeof id === 'string' && id.length > 0 ? id : undefined;
56
+ }
57
+ /**
58
+ * Spill file names: `tool-<id>.txt`, or `tool-<id>.n.txt` for text that is
59
+ * already line-numbered (file_read windows keep the FILE's line numbers, so
60
+ * tool_output_read shows those lines as they are instead of numbering again —
61
+ * one numbering space). tool_output_read accepts only these shapes.
62
+ */
63
+ export const SPILL_FILE_RE = /^tool-[A-Za-z0-9_-]+(\.n)?\.txt$/;
64
+ export const NUMBERED_SPILL_RE = /\.n\.txt$/;
65
+ /** Spill files older than this are deleted at REPL start (like the notice logs). */
66
+ export const SPILL_MAX_AGE_DAYS = 14;
67
+ function clipLine(line) {
68
+ if (line.length <= PREVIEW_LINE_MAX_CHARS)
69
+ return line;
70
+ return `${line.slice(0, PREVIEW_LINE_MAX_CHARS)}… [+${line.length - PREVIEW_LINE_MAX_CHARS} chars]`;
71
+ }
72
+ export function pointerLine(lines, chars, filePath) {
73
+ return `[${lines} lines / ${chars} chars — full output saved to ${filePath}; read slices with tool_output_read(path, offset, limit)]`;
74
+ }
75
+ /**
76
+ * Build the preview text for `text` (already known to be large). Pure. Line
77
+ * numbers in the omitted marker are the spill file's own (1-based, =
78
+ * tool_output_read offset + 1); for an already-numbered spill the marker names
79
+ * the offset instead, since the visible numbers belong to the source file.
80
+ */
81
+ export function buildPreview(text, kind, filePath, opts = {}) {
82
+ const lines = text.split('\n');
83
+ const total = lines.length;
84
+ const head = [];
85
+ let used = 0;
86
+ for (let i = 0; i < total && head.length < PREVIEW_HEAD_LINES; i++) {
87
+ const l = clipLine(lines[i]);
88
+ if (head.length > 0 && used + l.length + 1 > PREVIEW_HEAD_BUDGET_CHARS)
89
+ break;
90
+ head.push(l);
91
+ used += l.length + 1;
92
+ }
93
+ const tail = [];
94
+ if (TAIL_KINDS.has(kind)) {
95
+ let tailUsed = 0;
96
+ for (let i = total - 1; i >= head.length && tail.length < PREVIEW_TAIL_LINES; i--) {
97
+ const l = clipLine(lines[i]);
98
+ if (tail.length > 0 && tailUsed + l.length + 1 > PREVIEW_TAIL_BUDGET_CHARS)
99
+ break;
100
+ tail.unshift(l);
101
+ tailUsed += l.length + 1;
102
+ }
103
+ }
104
+ const out = [...head];
105
+ const omittedFrom = head.length + 1; // 1-based first omitted line
106
+ const omittedTo = total - tail.length; // 1-based last omitted line
107
+ if (omittedTo >= omittedFrom) {
108
+ out.push(opts.numbered
109
+ ? `[… ${omittedTo - omittedFrom + 1} lines omitted — tool_output_read offset=${omittedFrom - 1} …]`
110
+ : `[… lines ${omittedFrom}–${omittedTo} omitted …]`);
111
+ }
112
+ out.push(...tail);
113
+ out.push(pointerLine(total, text.length, filePath));
114
+ return out.join('\n');
115
+ }
116
+ /**
117
+ * Size a tool result at ingestion. At or below the threshold → unchanged.
118
+ * Above it → the full text goes to the session spill file and a preview comes
119
+ * back. A write failure fails OPEN (full text returned, nothing lost).
120
+ *
121
+ * `header` (sap_get_source's version line) counts toward the threshold and
122
+ * leads the preview, but is NOT written to the file: the file is the body
123
+ * alone, so tool_output_read line k is body line k (fix round 1, I1), and the
124
+ * pointer's counts describe the body.
125
+ */
126
+ export function spillIfLarge(i) {
127
+ const whole = i.header !== undefined ? `${i.header}\n${i.text}` : i.text;
128
+ if (whole.length <= (i.threshold ?? SPILL_THRESHOLD_CHARS))
129
+ return { spilled: false, text: whole };
130
+ const dir = spillDirFor(i.sessionId);
131
+ const id = safeSegment(i.toolUseId && i.toolUseId.length > 0 ? i.toolUseId : crypto.randomUUID());
132
+ const filePath = path.join(dir, `tool-${id}${i.numbered ? '.n' : ''}.txt`);
133
+ try {
134
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
135
+ fs.writeFileSync(filePath, i.text, { encoding: 'utf-8', mode: 0o600 });
136
+ }
137
+ catch {
138
+ return { spilled: false, text: whole };
139
+ }
140
+ const preview = buildPreview(i.text, i.kind, filePath, { numbered: i.numbered });
141
+ return { spilled: true, text: i.header !== undefined ? `${i.header}\n${preview}` : preview, path: filePath };
142
+ }
143
+ /**
144
+ * Convenience for handlers: size `result.content` with the ctx's session and
145
+ * tool_use id. With a header, `result.content` is the body and the returned
146
+ * content is `header\n…`. No session id → never spilled.
147
+ */
148
+ export function spillToolResult(result, ctx, kind, opts = {}) {
149
+ const sessionId = sessionIdOf(ctx);
150
+ const whole = opts.header !== undefined ? `${opts.header}\n${result.content}` : result.content;
151
+ if (sessionId === undefined)
152
+ return whole === result.content ? result : { ...result, content: whole };
153
+ const { label, ...spillOpts } = opts;
154
+ const s = spillIfLarge({ text: result.content, sessionId, toolUseId: ctx.toolUseId, kind, ...spillOpts });
155
+ if (s.spilled && s.path && label)
156
+ rememberSpillLabel(s.path, label);
157
+ return s.text === result.content ? result : { ...result, content: s.text };
158
+ }
159
+ /**
160
+ * Delete spill files (tool-*.txt in every session dir under the spill root)
161
+ * last modified more than SPILL_MAX_AGE_DAYS ago, then any session dir left
162
+ * empty. Other files (the turn mirrors) are left alone; symlinks and
163
+ * junctions (root, dirs, files) are never followed. Best effort: never
164
+ * throws. Returns how many files were deleted.
165
+ */
166
+ export function pruneSpillFiles(now = Date.now()) {
167
+ const cutoff = now - SPILL_MAX_AGE_DAYS * 24 * 60 * 60 * 1000;
168
+ let removed = 0;
169
+ let dirs;
170
+ try {
171
+ // Final review tools-I1 — never follow links: a symlinked / junctioned
172
+ // root, session dir or file is skipped (lstat), so the prune can never
173
+ // delete outside the CLI's own area.
174
+ if (!fs.lstatSync(streamsRoot()).isDirectory())
175
+ return 0;
176
+ dirs = fs.readdirSync(streamsRoot());
177
+ }
178
+ catch {
179
+ return 0;
180
+ }
181
+ for (const d of dirs) {
182
+ const dir = path.join(streamsRoot(), d);
183
+ let names;
184
+ try {
185
+ const st = fs.lstatSync(dir);
186
+ if (st.isSymbolicLink() || !st.isDirectory())
187
+ continue;
188
+ names = fs.readdirSync(dir);
189
+ }
190
+ catch {
191
+ continue;
192
+ }
193
+ for (const name of names) {
194
+ if (!SPILL_FILE_RE.test(name))
195
+ continue;
196
+ const p = path.join(dir, name);
197
+ try {
198
+ const st = fs.lstatSync(p);
199
+ if (st.isSymbolicLink() || !st.isFile())
200
+ continue;
201
+ if (st.mtimeMs < cutoff) {
202
+ fs.unlinkSync(p);
203
+ removed++;
204
+ }
205
+ }
206
+ catch {
207
+ /* best effort */
208
+ }
209
+ }
210
+ try {
211
+ if (fs.readdirSync(dir).length === 0)
212
+ fs.rmdirSync(dir);
213
+ }
214
+ catch {
215
+ /* best effort */
216
+ }
217
+ }
218
+ return removed;
219
+ }
220
+ /** True when `child` lies strictly inside `dir`. */
221
+ export function isInside(dir, child) {
222
+ const rel = path.relative(dir, child);
223
+ return rel.length > 0 && !rel.startsWith('..') && !path.isAbsolute(rel);
224
+ }
225
+ /** `p` and, when it exists, its realpath (8.3 names, /var → /private/var). */
226
+ export function withRealpath(p) {
227
+ try {
228
+ const real = fs.realpathSync.native(p);
229
+ return real === p ? [p] : [p, real];
230
+ }
231
+ catch {
232
+ return [p];
233
+ }
234
+ }
235
+ /** True when `absPath` (already a realpath) lies under the spill root (any session). */
236
+ export function isUnderSpillRoot(absPath) {
237
+ return withRealpath(streamsRoot()).some((root) => isInside(root, absPath));
238
+ }
@@ -22,6 +22,50 @@
22
22
  * badiList(query, enhancementSpot?, maxResults?)
23
23
  */
24
24
  import { registerTool } from './index.js';
25
+ import { normaliseSyntaxResult, checkSyntax } from './verify.js';
26
+ import { recordSyntaxCheck } from './syntax-state.js';
27
+ import { spillToolResult } from './result-spill.js';
28
+ // -----------------------------------------------------------------------
29
+ // Task 17 (spec D12, ruling F18) — model-facing formats.
30
+ // -----------------------------------------------------------------------
31
+ /**
32
+ * sap_get_source's one-line header: `[version: active]`,
33
+ * `[version: inactive | <warning>]` (any version may carry the split-state
34
+ * warning), or `[version: unknown]`. Plan-audit evidence reads the first
35
+ * 200 chars of the result, so the version leads it.
36
+ */
37
+ export function formatSourceHeader(version, warning) {
38
+ return warning ? `[version: ${version} | ${warning.replace(/\s*\n\s*/g, ' ')}]` : `[version: ${version}]`;
39
+ }
40
+ /** Inverse of formatSourceHeader: the version (and warning) back from a result. */
41
+ export function parseSourceHeader(text) {
42
+ const first = text.split('\n', 1)[0] ?? '';
43
+ const m = /^\[version: ([a-z]+)(?: \| (.*))?\]$/.exec(first);
44
+ if (!m)
45
+ return { version: 'unknown' };
46
+ return m[2] !== undefined ? { version: m[1], warning: m[2] } : { version: m[1] };
47
+ }
48
+ /** One TSV cell: tabs / newlines inside a value cannot break the grid. */
49
+ const tsvCell = (v) => (v === null || v === undefined ? '' : String(v)).replace(/[\t\r\n]+/g, ' ');
50
+ export function formatSqlTsv(result) {
51
+ const cols = result.columns ?? [];
52
+ const rows = result.rows ?? [];
53
+ const lines = [cols.map(tsvCell).join('\t'), ...rows.map((r) => cols.map((c) => tsvCell(r[c])).join('\t'))];
54
+ const n = rows.length;
55
+ const total = typeof result.totalRows === 'number' && result.totalRows > n ? ` of ${result.totalRows}` : '';
56
+ lines.push(`(${n} ${n === 1 ? 'row' : 'rows'}${total})`);
57
+ return lines.join('\n');
58
+ }
59
+ /** Search hits, one per line: NAME TYPE PACKAGE DESCRIPTION URI ("-" for an empty field). */
60
+ export function formatSearchHits(hits) {
61
+ if (hits.length === 0)
62
+ return '(no hits)';
63
+ const f = (v) => {
64
+ const t = typeof v === 'string' ? v.replace(/\s+/g, ' ').trim() : '';
65
+ return t.length > 0 ? t : '-';
66
+ };
67
+ return hits.map((h) => [h.name, h.type, h.packageName ?? h.package, h.description, h.uri].map(f).join(' ')).join('\n');
68
+ }
25
69
  // -----------------------------------------------------------------------
26
70
  // 1. sap_get_source
27
71
  // -----------------------------------------------------------------------
@@ -34,7 +78,8 @@ registerTool({
34
78
  + 'the response is the active source AND a warning is emitted so the '
35
79
  + 'caller knows the inactive edits are not visible. For a freshly '
36
80
  + 'created object with NO active version yet, the inactive (working) '
37
- + 'source is returned with version="inactive" and an honest note.',
81
+ + 'source is returned with version="inactive" and an honest note. '
82
+ + 'The first line is a [version: …] header, not part of the source.',
38
83
  isMutating: false,
39
84
  requiresSap: true,
40
85
  input_schema: {
@@ -115,13 +160,13 @@ registerTool({
115
160
  // probe failed — leave resolvedVersion as 'unknown'
116
161
  }
117
162
  }
118
- return {
119
- content: JSON.stringify({
120
- source,
121
- version: resolvedVersion,
122
- ...(splitStateWarning ? { split_state_warning: splitStateWarning } : {}),
123
- }),
124
- };
163
+ // Fix round 1 (I1): the header leads the result and the preview, but a
164
+ // spill file holds the source alone — tool_output_read line k is source
165
+ // line k, matching syntax-check / ATC line numbers.
166
+ return spillToolResult({ content: String(source) }, ctx, 'source', {
167
+ header: formatSourceHeader(resolvedVersion, splitStateWarning),
168
+ label: String(args.name).toUpperCase(),
169
+ });
125
170
  },
126
171
  });
127
172
  // -----------------------------------------------------------------------
@@ -153,10 +198,11 @@ registerTool({
153
198
  // `version` right after name/type puts it inside the excerpt window.
154
199
  const versionMatch = /\badtcore:version="([^"]*)"/.exec(result.rawXml ?? '');
155
200
  const version = versionMatch?.[1] || 'unknown';
156
- // name/type/version lead the serialized JSON (rawXml stays last via the
157
- // rest spread), so `version` sits inside the excerpt window while every
158
- // ObjectStructure field is preserved.
159
- const { name, type, ...rest } = result;
201
+ // name/type/version lead the serialized JSON, so `version` sits inside
202
+ // the excerpt window. Task 17: rawXml is parsed above and never sent to
203
+ // the model (it was the bulk of the result).
204
+ const { name, type, rawXml, ...rest } = result;
205
+ void rawXml;
160
206
  return { content: JSON.stringify({ name, type, version, ...rest }, null, 2) };
161
207
  },
162
208
  });
@@ -185,7 +231,7 @@ registerTool({
185
231
  },
186
232
  handler: async (args, ctx) => {
187
233
  const results = await ctx.adt.searchObject(args.query, args.type, args.maxResults ?? 100);
188
- return { content: JSON.stringify(results, null, 2) };
234
+ return spillToolResult({ content: formatSearchHits(results) }, ctx, 'search', { label: `search "${args.query}"` });
189
235
  },
190
236
  });
191
237
  // -----------------------------------------------------------------------
@@ -229,7 +275,7 @@ registerTool({
229
275
  },
230
276
  handler: async (args, ctx) => {
231
277
  const result = await ctx.adt.sqlQuery(args.query, args.maxRows ?? 100);
232
- return { content: JSON.stringify(result, null, 2) };
278
+ return spillToolResult({ content: formatSqlTsv(result) }, ctx, 'sql', { label: 'query result' });
233
279
  },
234
280
  });
235
281
  // -----------------------------------------------------------------------
@@ -455,7 +501,20 @@ registerTool({
455
501
  },
456
502
  handler: async (args, ctx) => {
457
503
  // syntaxCheck(type, name) — type first
458
- const result = await ctx.adt.syntaxCheck(args.type, args.name);
504
+ // Fails closed (fix round 7, item 1): the model must never read a
505
+ // syntax error as clean — see normaliseSyntaxResult.
506
+ let raw;
507
+ try {
508
+ raw = await checkSyntax(ctx.adt, args.type, args.name); // A-I2: notProcessed retry
509
+ }
510
+ catch (err) {
511
+ // Re-review note — a check that did not run is "not clean": never leave
512
+ // an older clean result in the syntax state.
513
+ recordSyntaxCheck(args.type, args.name, true);
514
+ throw err;
515
+ }
516
+ const result = normaliseSyntaxResult(raw);
517
+ recordSyntaxCheck(args.type, args.name, result.hasErrors); // round 7 item 8e
459
518
  return { content: JSON.stringify(result, null, 2) };
460
519
  },
461
520
  });
@@ -22,12 +22,17 @@
22
22
  * auditLog aliased from safety/audit-log.ts#log via sap-client/src/index.ts.
23
23
  */
24
24
  import crypto from 'node:crypto';
25
+ import { readFileSync } from 'node:fs';
25
26
  import { registerTool } from './index.js';
26
27
  import { verifyAndSpendApprovalId } from '../approvals/jwt.js';
27
28
  import { autoSnapshot, autoSnapshotClassInclude } from './snapshot.js';
28
29
  import { includeSnapshotType } from './include-snapshot.js';
29
30
  import { buildWriteChainLine } from '../renderer/verify-chain.js';
31
+ import { buildChangeCard } from '../renderer/change-card.js';
32
+ import { getLook } from '../renderer/look.js';
33
+ import { getEffectiveBodyWidth } from '../renderer/tty.js';
30
34
  import { verifySyntax, verifyActive } from './verify.js';
35
+ import { hasRecordedSyntaxErrorsByName, clearSyntaxErrorsAfterActivation } from './syntax-state.js';
31
36
  import { appendPending, finalizeToolCall } from '../session/pending.js';
32
37
  import { auditLog } from '@cspeach/sap-client';
33
38
  import { ERR } from '../errors/codes.js';
@@ -79,9 +84,70 @@ function snapshotChainState(snap) {
79
84
  }
80
85
  return { snapshot: 'best_effort_failed' };
81
86
  }
82
- /** Compact ①②③④ pipeline summary — derived from the real gate results. */
87
+ /**
88
+ * Review r7 I-3 — sap-client 0.1.15's setSource/updateMethod run their own
89
+ * (correct) syntax check and, on errors, return a 2xx writeStatus with
90
+ * `syntaxResult.hasErrors` and `error: 'Syntax check found errors —
91
+ * activation skipped'`. The source IS written (an inactive version exists):
92
+ * that is written-with-errors — the normal Rule 10 gate below reports it —
93
+ * never write_failed.
94
+ */
95
+ function writtenWithSyntaxErrors(r) {
96
+ return r.writeStatus !== undefined && r.writeStatus >= 200 && r.writeStatus < 300 && r.syntaxResult?.hasErrors === true;
97
+ }
98
+ async function verifyWriteActivation(ctx, r, name, type) {
99
+ const a = r.activationResult;
100
+ if (!a)
101
+ return undefined;
102
+ const errors = (a.messages ?? []).filter((m) => m.severity === 'error').map((m) => m.shortText ?? '').filter(Boolean).slice(0, 5);
103
+ if (!a.success)
104
+ return { success: false, verified: false, errors };
105
+ const v = await verifyActive(ctx.adt, name, type);
106
+ if (v.verifyError !== undefined)
107
+ return { success: true, verified: false, errors, verifyError: v.verifyError };
108
+ return v.active ? { success: true, verified: true, errors } : { success: true, verified: false, errors, stillInactive: true };
109
+ }
110
+ /** Round 8 item 5 + A-I1 — the change-frame state from the verified activation. */
111
+ function chainActivation(w) {
112
+ if (!w)
113
+ return undefined;
114
+ if (!w.success)
115
+ return { state: 'failed', detail: w.errors[0] };
116
+ if (w.verified)
117
+ return { state: 'active' };
118
+ return w.stillInactive ? { state: 'still_inactive' } : { state: 'unverified' };
119
+ }
120
+ function activatedByWrite(w) {
121
+ if (!w)
122
+ return {};
123
+ return { activated_by_write: { success: w.success, verified: w.verified, errors: w.errors } };
124
+ }
125
+ /** A-I1 minor — after a verified activation on write, say so instead of "activation_allowed". */
126
+ function nextStepAfterWrite(syntaxOk, w) {
127
+ if (!syntaxOk)
128
+ return 'activation_blocked_until_syntax_errors_fixed';
129
+ if (w?.success && w.verified)
130
+ return 'already activated by the write — verify with sap_inactive_objects before reporting it active';
131
+ return 'activation_allowed';
132
+ }
133
+ /** Compact ①②③④ pipeline summary — derived from the real gate results.
134
+ * Panels look: a change card (same honest chain in its footer). */
83
135
  function emitWriteChain(ctx, input) {
84
- ctx.chunkEmitter?.emit('chunk', buildWriteChainLine(input) + '\n');
136
+ const text = getLook() === 'panels' && input.card
137
+ ? buildChangeCard(input, getEffectiveBodyWidth())
138
+ : buildWriteChainLine(input);
139
+ ctx.chunkEmitter?.emit('chunk', text + '\n');
140
+ }
141
+ /** Old source for the change card: ONLY from a snapshot actually taken. */
142
+ function snapshotSource(snap) {
143
+ if (!snap?.taken || !snap.entry?.filePath)
144
+ return undefined;
145
+ try {
146
+ return readFileSync(snap.entry.filePath, 'utf8');
147
+ }
148
+ catch {
149
+ return undefined;
150
+ }
85
151
  }
86
152
  registerTool({
87
153
  name: 'sap_set_source',
@@ -165,6 +231,7 @@ registerTool({
165
231
  what_will_happen: 'Overwrites full object source; snapshot is taken first.',
166
232
  rollback_available: true,
167
233
  transport: resolved.transport ?? ctx.currentTransport?.get() ?? '',
234
+ args: { ...args },
168
235
  });
169
236
  if (!r.confirmed) {
170
237
  return {
@@ -201,7 +268,7 @@ registerTool({
201
268
  is_error: true,
202
269
  };
203
270
  }
204
- if (writeResult.error || (writeResult.writeStatus !== undefined && writeResult.writeStatus >= 400)) {
271
+ if ((writeResult.error && !writtenWithSyntaxErrors(writeResult)) || (writeResult.writeStatus !== undefined && writeResult.writeStatus >= 400)) {
205
272
  const detail = writeResult.error ?? `Write returned HTTP ${writeResult.writeStatus} but did not throw`;
206
273
  await finalizeToolCall(ctx.session, toolUseId, detail, true, Date.now() - startedAt);
207
274
  return {
@@ -218,11 +285,22 @@ registerTool({
218
285
  // ── Gate 4: Verify — syntax check (Rule 10) ──────────────────────────────
219
286
  const syntax = await verifySyntax(ctx.adt, args.name, args.type);
220
287
  await finalizeToolCall(ctx.session, toolUseId, JSON.stringify({ written: true, syntax }), false, Date.now() - startedAt);
288
+ const writeActivation = await verifyWriteActivation(ctx, writeResult, args.name, args.type);
221
289
  // Tour slide-7 chain — derived from the gates that actually ran.
290
+ // Old source is read (once) only for the panels card; legacy reads nothing extra.
291
+ const oldSource = getLook() === 'panels' ? snapshotSource(snapOutcome) : undefined;
222
292
  emitWriteChain(ctx, {
223
293
  ...snapshotChainState(snapOutcome),
224
294
  written: true,
225
295
  syntax: { ok: syntax.ok, errors: syntax.errors },
296
+ activation: chainActivation(writeActivation),
297
+ card: {
298
+ object: args.name,
299
+ what: 'source',
300
+ transport: resolved.transport,
301
+ oldSource,
302
+ newSource: oldSource !== undefined ? args.source : undefined,
303
+ },
226
304
  });
227
305
  return {
228
306
  content: JSON.stringify({
@@ -230,10 +308,9 @@ registerTool({
230
308
  syntax_ok: syntax.ok,
231
309
  errors: syntax.errors,
232
310
  warnings: syntax.warnings,
311
+ ...activatedByWrite(writeActivation),
233
312
  ...(resolved.owning ? { transport_used: resolved.owning, transport_note: resolved.note } : {}),
234
- next_step: syntax.ok
235
- ? 'activation_allowed'
236
- : 'activation_blocked_until_syntax_errors_fixed',
313
+ next_step: nextStepAfterWrite(syntax.ok, writeActivation),
237
314
  }),
238
315
  };
239
316
  },
@@ -395,7 +472,7 @@ registerTool({
395
472
  is_error: true,
396
473
  };
397
474
  }
398
- if (updateResult.error || (updateResult.writeStatus !== undefined && updateResult.writeStatus >= 400)) {
475
+ if ((updateResult.error && !writtenWithSyntaxErrors(updateResult)) || (updateResult.writeStatus !== undefined && updateResult.writeStatus >= 400)) {
399
476
  const detail = updateResult.error ?? `Update returned HTTP ${updateResult.writeStatus} but did not throw`;
400
477
  await finalizeToolCall(ctx.session, toolUseId, detail, true, Date.now() - startedAt);
401
478
  return {
@@ -412,11 +489,14 @@ registerTool({
412
489
  // ── Gate 4: Verify — syntax check (Rule 10) ──────────────────────────────
413
490
  const syntax = await verifySyntax(ctx.adt, args.class_name, 'CLAS');
414
491
  await finalizeToolCall(ctx.session, toolUseId, JSON.stringify({ updated: true, syntax }), false, Date.now() - startedAt);
492
+ const writeActivation = await verifyWriteActivation(ctx, updateResult, args.class_name, 'CLAS');
415
493
  // Tour slide-7 chain — derived from the gates that actually ran.
416
494
  emitWriteChain(ctx, {
417
495
  ...snapshotChainState(snapOutcome),
418
496
  written: true,
419
497
  syntax: { ok: syntax.ok, errors: syntax.errors },
498
+ activation: chainActivation(writeActivation),
499
+ card: { object: args.class_name, what: `method ${args.method_name}`, transport: resolved.transport },
420
500
  });
421
501
  return {
422
502
  content: JSON.stringify({
@@ -424,10 +504,9 @@ registerTool({
424
504
  syntax_ok: syntax.ok,
425
505
  errors: syntax.errors,
426
506
  warnings: syntax.warnings,
507
+ ...activatedByWrite(writeActivation),
427
508
  ...(resolved.owning ? { transport_used: resolved.owning, transport_note: resolved.note } : {}),
428
- next_step: syntax.ok
429
- ? 'activation_allowed'
430
- : 'activation_blocked_until_syntax_errors_fixed',
509
+ next_step: nextStepAfterWrite(syntax.ok, writeActivation),
431
510
  }),
432
511
  };
433
512
  },
@@ -496,6 +575,7 @@ registerTool({
496
575
  what_will_happen: 'Deletes object from SAP; snapshot is taken first; recovery via sap_snapshot_read + sap_set_source (LLM composes the rollback).',
497
576
  rollback_available: true,
498
577
  transport: resolved.transport ?? ctx.currentTransport?.get() ?? '',
578
+ args: { ...args },
499
579
  });
500
580
  if (!r.confirmed) {
501
581
  return {
@@ -785,6 +865,7 @@ registerTool({
785
865
  ...snapshotChainState(snapOutcome),
786
866
  written: true,
787
867
  syntax: 'not_checked',
868
+ card: { object: args.className, what: `include ${args.includeType}`, transport: resolved.transport },
788
869
  });
789
870
  return {
790
871
  content: JSON.stringify({
@@ -1107,6 +1188,33 @@ registerTool({
1107
1188
  is_error: true,
1108
1189
  };
1109
1190
  }
1191
+ // Round 7 item 8e — an approval auto_approve minted (no human saw it)
1192
+ // never activates an object whose last syntax check found errors. Any
1193
+ // approval minted after the errors went through the card (risk floor).
1194
+ // Final review A M-1 — match by NAME, like the risk floor: a type word can
1195
+ // map to a different code (DDLS recorded, activated as VIEW). Over-block,
1196
+ // never under-block; an auto approval can always be asked for again.
1197
+ const broken = args.objects
1198
+ .filter((o) => hasRecordedSyntaxErrorsByName(o.name)).map((o) => o.name.toUpperCase());
1199
+ if (verify.payload.auto) {
1200
+ if (broken.length > 0) {
1201
+ ctx.chunkEmitter?.emit('warn', `Activation blocked · syntax errors in ${broken.join(', ')}`);
1202
+ return {
1203
+ content: JSON.stringify({
1204
+ error: 'activation_blocked_syntax_errors',
1205
+ objects: broken,
1206
+ detail: 'The last syntax check of these objects found errors and this activation was auto-approved before that. '
1207
+ + 'Fix the errors (sap_syntax_check shows them) or ask for approval again.',
1208
+ }),
1209
+ is_error: true,
1210
+ };
1211
+ }
1212
+ }
1213
+ // Review r7 M-6 — a human-approved activation stays allowed (a mass
1214
+ // activation can clear cross-object errors), but it is never silent.
1215
+ for (const n of broken) {
1216
+ ctx.chunkEmitter?.emit('warn', `Activating ${n} · its last syntax check found errors · SAP will refuse it unless they are resolved`);
1217
+ }
1110
1218
  const toolUseId = ctx.toolUseId ?? crypto.randomUUID();
1111
1219
  const startedAt = Date.now();
1112
1220
  // ── WAL: record pending entry ────────────────────────────────────────────
@@ -1149,6 +1257,10 @@ registerTool({
1149
1257
  else if (!v.active) {
1150
1258
  stillInactive.push(`${obj.type}/${obj.name}`);
1151
1259
  }
1260
+ else if (activationResults.find((r) => r.name === obj.name && r.type === obj.type)?.success) {
1261
+ // Final review A M-2 — activated AND verified: the source compiles.
1262
+ clearSyntaxErrorsAfterActivation(obj.name);
1263
+ }
1152
1264
  }
1153
1265
  const overallSuccess = !anyError && stillInactive.length === 0 && unverifiable.length === 0;
1154
1266
  await finalizeToolCall(ctx.session, toolUseId, JSON.stringify({ overallSuccess, activationResults, stillInactive, unverifiable }), anyError, Date.now() - startedAt);