@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,746 @@
1
+ /**
2
+ * Write tools with the complete gate pipeline:
3
+ *
4
+ * approval_id verify → snapshot gate → WAL pending → adt.<method> →
5
+ * audit log → verify gate (syntax check / activation check) → WAL finalize
6
+ *
7
+ * Confirmed AdtClient signatures (sap-client/src/adt-client.ts):
8
+ * setSource(type, name, source, transport?) → Promise<WriteResult> line 1983
9
+ * updateMethod(className, methodName, methodBody, transport?)
10
+ * → Promise<WriteResult> line 2159
11
+ * createObject(objtype, name, packageName, description, transport?, extras?)
12
+ * → Promise<CreateResult> line 2245
13
+ * deleteObject(type, name, transport?) → Promise<{status,error?}> line 2335
14
+ * activate(type, name) → Promise<ActivationResult> line 2382
15
+ * lock(type, name) → Promise<LockResult> line 1914
16
+ * unlock(type, name, lockHandle) → Promise<void> line 1949
17
+ * serviceBindingPublish(name) → Promise<{status,error?}> line 2658
18
+ * messageMaintain (STUB — not yet in sap-client)
19
+ * numberRangeIntervals (STUB — not yet in sap-client)
20
+ *
21
+ * auditLog aliased from safety/audit-log.ts#log via sap-client/src/index.ts.
22
+ */
23
+ import crypto from 'node:crypto';
24
+ import { registerTool } from './index.js';
25
+ import { verifyAndSpendApprovalId } from '../approvals/jwt.js';
26
+ import { autoSnapshot } from './snapshot.js';
27
+ import { verifySyntax, verifyActive } from './verify.js';
28
+ import { appendPending, finalizeToolCall } from '../session/pending.js';
29
+ import { auditLog } from '@cspeach/sap-client';
30
+ import { ERR } from '../errors/codes.js';
31
+ import { interceptUpdateMethod, CancelledByUserError, } from './update-method-intercept.js';
32
+ import { presentSafetyConfirmation } from '../repl/safety-confirm.js';
33
+ import { isSafetyConfirmEnabled } from '../repl/safety-mode-state.js';
34
+ registerTool({
35
+ name: 'sap_set_source',
36
+ description: 'Overwrite the full source of an ABAP object. Requires approval_id. '
37
+ + 'Use for class-definition changes, new methods, or any change not confined to '
38
+ + 'a single method body. For method-body-only edits use sap_update_method (Rule 7a).',
39
+ isMutating: true,
40
+ input_schema: {
41
+ type: 'object',
42
+ properties: {
43
+ name: { type: 'string', description: 'ABAP object name' },
44
+ type: { type: 'string', description: 'ABAP object type (PROG, CLAS, etc.)' },
45
+ source: { type: 'string', description: 'Full new source (replaces existing)' },
46
+ transport: { type: 'string', description: 'Transport request number (optional)' },
47
+ approval_id: { type: 'string', description: 'JWT from request_approval' },
48
+ },
49
+ required: ['name', 'type', 'source', 'approval_id'],
50
+ },
51
+ handler: async (args, ctx) => {
52
+ // ── Gate 1: Approval ────────────────────────────────────────────────────
53
+ const verify = await verifyAndSpendApprovalId(args.approval_id, args.name, 'modify');
54
+ if (!verify.ok) {
55
+ return {
56
+ content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: verify.reason }),
57
+ is_error: true,
58
+ };
59
+ }
60
+ const toolUseId = crypto.randomUUID();
61
+ const startedAt = Date.now();
62
+ // ── Rule 9 conflict warning ──────────────────────────────────────────────
63
+ // Emit a warning if the LLM passed an explicit transport that differs from
64
+ // the session current-transport. Prefer LLM intent (explicit caller); this
65
+ // is informational only and does NOT block the operation.
66
+ {
67
+ const sessionTransport = ctx.currentTransport?.get() ?? null;
68
+ if (args.transport && sessionTransport && args.transport !== sessionTransport) {
69
+ ctx.chunkEmitter?.emit('warn', `LLM passed transport=${args.transport} but session current-transport=${sessionTransport}`);
70
+ }
71
+ }
72
+ // ── Gate 2: Snapshot (Rule 7) ────────────────────────────────────────────
73
+ try {
74
+ await autoSnapshot(args.name, args.type, ctx.adt);
75
+ }
76
+ catch (err) {
77
+ return {
78
+ content: JSON.stringify({ error: ERR.SNAPSHOT_FAILED, detail: String(err) }),
79
+ is_error: true,
80
+ };
81
+ }
82
+ // ── Gate 3: Safety confirmation (Rule 7) ─────────────────────────────────
83
+ if (isSafetyConfirmEnabled()) {
84
+ const r = await presentSafetyConfirmation({
85
+ rule: 'RULE_7_SNAPSHOT_BEFORE_WRITE',
86
+ op: 'set_source',
87
+ object: { type: args.type, name: args.name },
88
+ what_will_happen: 'Overwrites full object source; snapshot is taken first.',
89
+ rollback_available: true,
90
+ transport: args.transport ?? ctx.currentTransport?.get() ?? '',
91
+ });
92
+ if (!r.confirmed) {
93
+ return {
94
+ content: JSON.stringify({ error: 'cancelled_by_user', reason: r.reason }),
95
+ is_error: true,
96
+ };
97
+ }
98
+ }
99
+ // ── WAL: record pending entry ────────────────────────────────────────────
100
+ await appendPending(ctx.session, {
101
+ tool_use_id: toolUseId,
102
+ tool: 'sap_set_source',
103
+ args: { name: args.name, type: args.type },
104
+ started_at: new Date().toISOString(),
105
+ });
106
+ // ── Write ────────────────────────────────────────────────────────────────
107
+ // setSource(type, name, source, transport?) — transport is optional.
108
+ //
109
+ // CRITICAL: AdtClient.setSource only THROWS for catastrophic errors (network
110
+ // down, all retries exhausted). For HTTP 4xx responses (e.g., the second PUT
111
+ // after 423-recovery still failing) it RETURNS a WriteResult with
112
+ // writeStatus >= 400 and `error: '...'`. The handler MUST check the return
113
+ // value — without it, we silently report success on a failed write.
114
+ // Regression evidence + diagnosis: memory/project_phase3_lite_regression.md.
115
+ let writeResult;
116
+ try {
117
+ writeResult = await ctx.adt.setSource(args.type, args.name, args.source, args.transport);
118
+ }
119
+ catch (err) {
120
+ const errStr = String(err);
121
+ await finalizeToolCall(ctx.session, toolUseId, errStr, true, Date.now() - startedAt);
122
+ return {
123
+ content: JSON.stringify({ error: 'write_failed', detail: errStr }),
124
+ is_error: true,
125
+ };
126
+ }
127
+ if (writeResult.error || (writeResult.writeStatus !== undefined && writeResult.writeStatus >= 400)) {
128
+ const detail = writeResult.error ?? `Write returned HTTP ${writeResult.writeStatus} but did not throw`;
129
+ await finalizeToolCall(ctx.session, toolUseId, detail, true, Date.now() - startedAt);
130
+ return {
131
+ content: JSON.stringify({
132
+ error: 'write_failed',
133
+ detail,
134
+ write_status: writeResult.writeStatus,
135
+ }),
136
+ is_error: true,
137
+ };
138
+ }
139
+ await auditLog('setSource', args.type, args.name, 'success', Date.now() - startedAt);
140
+ // ── Gate 4: Verify — syntax check (Rule 10) ──────────────────────────────
141
+ const syntax = await verifySyntax(ctx.adt, args.name, args.type);
142
+ await finalizeToolCall(ctx.session, toolUseId, JSON.stringify({ written: true, syntax }), false, Date.now() - startedAt);
143
+ return {
144
+ content: JSON.stringify({
145
+ written: true,
146
+ syntax_ok: syntax.ok,
147
+ errors: syntax.errors,
148
+ warnings: syntax.warnings,
149
+ next_step: syntax.ok
150
+ ? 'activation_allowed'
151
+ : 'activation_blocked_until_syntax_errors_fixed',
152
+ }),
153
+ };
154
+ },
155
+ });
156
+ // ── sap_update_method ────────────────────────────────────────────────────────
157
+ //
158
+ // Rule 7a: method-body-only edits. Reads current source, replaces the named
159
+ // method block in-memory, then delegates to setSource internally via
160
+ // AdtClient.updateMethod. Does NOT rewrite the class definition.
161
+ //
162
+ // Signature: updateMethod(className, methodName, methodBody, transport?)
163
+ // methodBody — the lines BETWEEN METHOD x. and ENDMETHOD. (no wrappers)
164
+ registerTool({
165
+ name: 'sap_update_method',
166
+ description: 'Replace the body of a single method in an existing class. '
167
+ + 'Preferred over sap_set_source for method-body-only changes (Rule 7a) — '
168
+ + 'does NOT rewrite the class definition. '
169
+ + 'method_source must be the lines between METHOD x. and ENDMETHOD. (no wrappers).',
170
+ isMutating: true,
171
+ input_schema: {
172
+ type: 'object',
173
+ properties: {
174
+ class_name: { type: 'string', description: 'ABAP class name (e.g. ZCL_MY_CLASS)' },
175
+ method_name: { type: 'string', description: 'Method name (e.g. DO_WORK or IF_FOO~BAR)' },
176
+ method_source: { type: 'string', description: 'Lines between METHOD x. and ENDMETHOD. (no wrapper lines)' },
177
+ transport: { type: 'string', description: 'Transport request number (optional)' },
178
+ approval_id: { type: 'string', description: 'JWT from request_approval' },
179
+ },
180
+ required: ['class_name', 'method_name', 'method_source', 'approval_id'],
181
+ },
182
+ handler: async (args, ctx) => {
183
+ // ── Gate 1: Approval ────────────────────────────────────────────────────
184
+ const verify = await verifyAndSpendApprovalId(args.approval_id, args.class_name, 'modify');
185
+ if (!verify.ok) {
186
+ return {
187
+ content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: verify.reason }),
188
+ is_error: true,
189
+ };
190
+ }
191
+ const toolUseId = crypto.randomUUID();
192
+ const startedAt = Date.now();
193
+ // ── Rule 9 conflict warning ──────────────────────────────────────────────
194
+ // Emit a warning if the LLM passed an explicit transport that differs from
195
+ // the session current-transport. Prefer LLM intent (explicit caller); this
196
+ // is informational only and does NOT block the operation.
197
+ {
198
+ const sessionTransport = ctx.currentTransport?.get() ?? null;
199
+ if (args.transport && sessionTransport && args.transport !== sessionTransport) {
200
+ ctx.chunkEmitter?.emit('warn', `LLM passed transport=${args.transport} but session current-transport=${sessionTransport}`);
201
+ }
202
+ }
203
+ // ── Gate 2: Snapshot (Rule 7) ────────────────────────────────────────────
204
+ try {
205
+ await autoSnapshot(args.class_name, 'CLAS', ctx.adt);
206
+ }
207
+ catch (err) {
208
+ return {
209
+ content: JSON.stringify({ error: ERR.SNAPSHOT_FAILED, detail: String(err) }),
210
+ is_error: true,
211
+ };
212
+ }
213
+ // ── Gate 3: Diff preview & confirmation (Rule 7a) ────────────────────────
214
+ if (!ctx.previewHook) {
215
+ return {
216
+ content: JSON.stringify({
217
+ error: 'preview_hook_unavailable',
218
+ detail: 'cannot confirm method body changes in this execution context',
219
+ }),
220
+ is_error: true,
221
+ };
222
+ }
223
+ const minimal = {
224
+ getSource: ({ type, name }) => ctx.adt.getSource(type, name),
225
+ };
226
+ const call = {
227
+ className: args.class_name,
228
+ methodName: args.method_name,
229
+ methodSource: args.method_source,
230
+ transport: args.transport ?? ctx.currentTransport?.get() ?? '',
231
+ };
232
+ try {
233
+ await interceptUpdateMethod(minimal, call, ctx.previewHook);
234
+ }
235
+ catch (err) {
236
+ if (err instanceof CancelledByUserError) {
237
+ return {
238
+ content: JSON.stringify({ error: 'cancelled_by_user', detail: err.message }),
239
+ is_error: true,
240
+ };
241
+ }
242
+ return {
243
+ content: JSON.stringify({ error: 'preview_failed', detail: String(err) }),
244
+ is_error: true,
245
+ };
246
+ }
247
+ // ── WAL: record pending entry ────────────────────────────────────────────
248
+ await appendPending(ctx.session, {
249
+ tool_use_id: toolUseId,
250
+ tool: 'sap_update_method',
251
+ args: { class_name: args.class_name, method_name: args.method_name },
252
+ started_at: new Date().toISOString(),
253
+ });
254
+ // ── Write ────────────────────────────────────────────────────────────────
255
+ // updateMethod(className, methodName, methodBody, transport?). Same return
256
+ // semantics as setSource — RETURNS a WriteResult on HTTP-level failure
257
+ // rather than throwing. Must check writeStatus / error on the return value.
258
+ let updateResult;
259
+ try {
260
+ updateResult = await ctx.adt.updateMethod(args.class_name, args.method_name, args.method_source, args.transport);
261
+ }
262
+ catch (err) {
263
+ const errStr = String(err);
264
+ await finalizeToolCall(ctx.session, toolUseId, errStr, true, Date.now() - startedAt);
265
+ return {
266
+ content: JSON.stringify({ error: 'update_failed', detail: errStr }),
267
+ is_error: true,
268
+ };
269
+ }
270
+ if (updateResult.error || (updateResult.writeStatus !== undefined && updateResult.writeStatus >= 400)) {
271
+ const detail = updateResult.error ?? `Update returned HTTP ${updateResult.writeStatus} but did not throw`;
272
+ await finalizeToolCall(ctx.session, toolUseId, detail, true, Date.now() - startedAt);
273
+ return {
274
+ content: JSON.stringify({
275
+ error: 'update_failed',
276
+ detail,
277
+ write_status: updateResult.writeStatus,
278
+ }),
279
+ is_error: true,
280
+ };
281
+ }
282
+ await auditLog('updateMethod', 'CLAS', args.class_name, 'success', Date.now() - startedAt, `method=${args.method_name}`);
283
+ // ── Gate 4: Verify — syntax check (Rule 10) ──────────────────────────────
284
+ const syntax = await verifySyntax(ctx.adt, args.class_name, 'CLAS');
285
+ await finalizeToolCall(ctx.session, toolUseId, JSON.stringify({ updated: true, syntax }), false, Date.now() - startedAt);
286
+ return {
287
+ content: JSON.stringify({
288
+ updated: true,
289
+ syntax_ok: syntax.ok,
290
+ errors: syntax.errors,
291
+ warnings: syntax.warnings,
292
+ next_step: syntax.ok
293
+ ? 'activation_allowed'
294
+ : 'activation_blocked_until_syntax_errors_fixed',
295
+ }),
296
+ };
297
+ },
298
+ });
299
+ // ── sap_delete_object ────────────────────────────────────────────────────────
300
+ //
301
+ // Signature: deleteObject(type, name, transport?) → { status: number; error?: string }
302
+ // AdtClient auto-snapshots before delete (Rule 7 built-in), but we also call
303
+ // autoSnapshot here for belt-and-suspenders consistency with the CLI gate model.
304
+ registerTool({
305
+ name: 'sap_delete_object',
306
+ description: 'Delete an ABAP object. Requires approval_id. '
307
+ + 'A snapshot is taken automatically before deletion. '
308
+ + 'Post-check: confirms the object no longer exists.',
309
+ isMutating: true,
310
+ input_schema: {
311
+ type: 'object',
312
+ properties: {
313
+ name: { type: 'string', description: 'ABAP object name' },
314
+ type: { type: 'string', description: 'ABAP object type (PROG, CLAS, etc.)' },
315
+ transport: { type: 'string', description: 'Transport request number (optional)' },
316
+ approval_id: { type: 'string', description: 'JWT from request_approval' },
317
+ },
318
+ required: ['name', 'type', 'approval_id'],
319
+ },
320
+ handler: async (args, ctx) => {
321
+ // ── Gate 1: Approval ────────────────────────────────────────────────────
322
+ const verify = await verifyAndSpendApprovalId(args.approval_id, args.name, 'delete');
323
+ if (!verify.ok) {
324
+ return {
325
+ content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: verify.reason }),
326
+ is_error: true,
327
+ };
328
+ }
329
+ const toolUseId = crypto.randomUUID();
330
+ const startedAt = Date.now();
331
+ // ── Rule 9 conflict warning ──────────────────────────────────────────────
332
+ // Emit a warning if the LLM passed an explicit transport that differs from
333
+ // the session current-transport. Prefer LLM intent (explicit caller); this
334
+ // is informational only and does NOT block the operation.
335
+ {
336
+ const sessionTransport = ctx.currentTransport?.get() ?? null;
337
+ if (args.transport && sessionTransport && args.transport !== sessionTransport) {
338
+ ctx.chunkEmitter?.emit('warn', `LLM passed transport=${args.transport} but session current-transport=${sessionTransport}`);
339
+ }
340
+ }
341
+ // ── Gate 2: Snapshot (Rule 7) — best-effort, object may not have source ──
342
+ try {
343
+ await autoSnapshot(args.name, args.type, ctx.adt);
344
+ }
345
+ catch {
346
+ // Silently ignore — object may be a shell with no readable source.
347
+ }
348
+ // ── Gate 3: Safety confirmation (Rule 7 — delete is irreversible-ish) ────
349
+ if (isSafetyConfirmEnabled()) {
350
+ const r = await presentSafetyConfirmation({
351
+ rule: 'RULE_7_SNAPSHOT_BEFORE_WRITE',
352
+ op: 'delete_object',
353
+ object: { type: args.type, name: args.name },
354
+ what_will_happen: 'Deletes object from SAP; snapshot is taken first; recovery via sap_snapshot_read + sap_set_source (LLM composes the rollback).',
355
+ rollback_available: true,
356
+ transport: args.transport ?? ctx.currentTransport?.get() ?? '',
357
+ });
358
+ if (!r.confirmed) {
359
+ return {
360
+ content: JSON.stringify({ error: 'cancelled_by_user', reason: r.reason }),
361
+ is_error: true,
362
+ };
363
+ }
364
+ }
365
+ // ── WAL: record pending entry ────────────────────────────────────────────
366
+ await appendPending(ctx.session, {
367
+ tool_use_id: toolUseId,
368
+ tool: 'sap_delete_object',
369
+ args: { name: args.name, type: args.type },
370
+ started_at: new Date().toISOString(),
371
+ });
372
+ // ── Delete ───────────────────────────────────────────────────────────────
373
+ // deleteObject(type, name, transport?) → { status: number; error?: string }.
374
+ // Same return semantics as setSource — RETURNS rather than throws on HTTP
375
+ // 4xx. Must check status / error on the return value.
376
+ let deleteResult;
377
+ try {
378
+ deleteResult = await ctx.adt.deleteObject(args.type, args.name, args.transport);
379
+ }
380
+ catch (err) {
381
+ const errStr = String(err);
382
+ await finalizeToolCall(ctx.session, toolUseId, errStr, true, Date.now() - startedAt);
383
+ return {
384
+ content: JSON.stringify({ error: 'delete_failed', detail: errStr }),
385
+ is_error: true,
386
+ };
387
+ }
388
+ if (deleteResult.error || (deleteResult.status !== undefined && deleteResult.status >= 400)) {
389
+ const detail = deleteResult.error ?? `Delete returned HTTP ${deleteResult.status} but did not throw`;
390
+ await finalizeToolCall(ctx.session, toolUseId, detail, true, Date.now() - startedAt);
391
+ return {
392
+ content: JSON.stringify({
393
+ error: 'delete_failed',
394
+ detail,
395
+ status: deleteResult.status,
396
+ }),
397
+ is_error: true,
398
+ };
399
+ }
400
+ await auditLog('deleteObject', args.type, args.name, 'success', Date.now() - startedAt);
401
+ // ── Post-check: object should no longer exist ────────────────────────────
402
+ const stillExists = await ctx.adt.objectStructure(args.type, args.name)
403
+ .then(() => true)
404
+ .catch(() => false);
405
+ await finalizeToolCall(ctx.session, toolUseId, JSON.stringify({ deleted: !stillExists }), false, Date.now() - startedAt);
406
+ return {
407
+ content: JSON.stringify({ deleted: !stillExists, still_exists: stillExists }),
408
+ };
409
+ },
410
+ });
411
+ // ── sap_create_object ────────────────────────────────────────────────────────
412
+ //
413
+ // Signature: createObject(objtype, name, packageName, description, transport?, extras?)
414
+ // → Promise<CreateResult>
415
+ // NOTE: arg order differs from the plan snippet — packageName comes before description.
416
+ // No snapshot needed: new object has no prior source.
417
+ registerTool({
418
+ name: 'sap_create_object',
419
+ description: 'Create a new ABAP object shell. '
420
+ + 'Requires approval_id. No snapshot needed (new object). '
421
+ + 'Write source separately via sap_set_source, then call sap_activate.',
422
+ isMutating: true,
423
+ input_schema: {
424
+ type: 'object',
425
+ properties: {
426
+ name: { type: 'string', description: 'ABAP object name' },
427
+ type: { type: 'string', description: 'ABAP object type (PROG, CLAS, INTF, DDLS, etc.)' },
428
+ package: { type: 'string', description: 'Development package (Z*/Y*)' },
429
+ description: { type: 'string', description: 'Short description' },
430
+ transport: { type: 'string', description: 'Target transport (optional — required for workbench packages)' },
431
+ approval_id: { type: 'string', description: 'JWT from request_approval' },
432
+ },
433
+ required: ['name', 'type', 'package', 'description', 'approval_id'],
434
+ },
435
+ handler: async (args, ctx) => {
436
+ // ── Gate 1: Approval ────────────────────────────────────────────────────
437
+ const verify = await verifyAndSpendApprovalId(args.approval_id, args.name, 'create');
438
+ if (!verify.ok) {
439
+ return {
440
+ content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: verify.reason }),
441
+ is_error: true,
442
+ };
443
+ }
444
+ const toolUseId = crypto.randomUUID();
445
+ const startedAt = Date.now();
446
+ // No snapshot — new object has no prior source.
447
+ // ── Rule 9 conflict warning + 3-tier transport fallback ──────────────────
448
+ // Emit a warning if the LLM passed an explicit transport that differs from
449
+ // the session current-transport. Prefer LLM intent (explicit caller); this
450
+ // is informational only and does NOT block the operation.
451
+ {
452
+ const sessionTransport = ctx.currentTransport?.get() ?? null;
453
+ if (args.transport && sessionTransport && args.transport !== sessionTransport) {
454
+ ctx.chunkEmitter?.emit('warn', `LLM passed transport=${args.transport} but session current-transport=${sessionTransport}`);
455
+ }
456
+ }
457
+ // 3-tier fallback: explicit arg → session current-transport → undefined
458
+ const effectiveTransport = args.transport ?? ctx.currentTransport?.get() ?? undefined;
459
+ // ── WAL: record pending entry ────────────────────────────────────────────
460
+ await appendPending(ctx.session, {
461
+ tool_use_id: toolUseId,
462
+ tool: 'sap_create_object',
463
+ args: { name: args.name, type: args.type, package: args.package },
464
+ started_at: new Date().toISOString(),
465
+ });
466
+ // ── Create ───────────────────────────────────────────────────────────────
467
+ // createObject(objtype, name, packageName, description, transport?, extras?)
468
+ let result;
469
+ try {
470
+ result = await ctx.adt.createObject(args.type, args.name, args.package, args.description, effectiveTransport);
471
+ await auditLog('createObject', args.type, args.name, 'success', Date.now() - startedAt);
472
+ }
473
+ catch (err) {
474
+ const errStr = String(err);
475
+ await finalizeToolCall(ctx.session, toolUseId, errStr, true, Date.now() - startedAt);
476
+ return {
477
+ content: JSON.stringify({ error: 'create_failed', detail: errStr }),
478
+ is_error: true,
479
+ };
480
+ }
481
+ if (result.status !== 'created') {
482
+ await finalizeToolCall(ctx.session, toolUseId, JSON.stringify(result), true, Date.now() - startedAt);
483
+ return {
484
+ content: JSON.stringify({ error: 'create_failed', detail: result.error }),
485
+ is_error: true,
486
+ };
487
+ }
488
+ await finalizeToolCall(ctx.session, toolUseId, JSON.stringify(result), false, Date.now() - startedAt);
489
+ return {
490
+ content: JSON.stringify({
491
+ created: true,
492
+ object: result.objectName,
493
+ type: result.objectType,
494
+ package: result.packageName,
495
+ next_step: 'write_source_via_sap_set_source_then_activate',
496
+ }),
497
+ };
498
+ },
499
+ });
500
+ // ── sap_activate ─────────────────────────────────────────────────────────────
501
+ //
502
+ // Signature: activate(type, name) → Promise<ActivationResult>
503
+ // ActivationResult { success, hasErrors, hasWarnings, messages[] }
504
+ //
505
+ // AdtClient activates one object per call. This tool accepts a batch (array)
506
+ // and calls activate() per object, collecting results. Post-check via
507
+ // verifyActive() inspects the inactive-objects list.
508
+ registerTool({
509
+ name: 'sap_activate',
510
+ description: 'Activate one or more ABAP objects. Required after sap_set_source / '
511
+ + 'sap_update_method / sap_create_object (Rule 10). '
512
+ + 'Post-check: objects must no longer appear in the inactive-objects list.',
513
+ isMutating: true,
514
+ input_schema: {
515
+ type: 'object',
516
+ properties: {
517
+ objects: {
518
+ type: 'array',
519
+ description: 'List of objects to activate',
520
+ items: {
521
+ type: 'object',
522
+ properties: {
523
+ name: { type: 'string' },
524
+ type: { type: 'string' },
525
+ },
526
+ required: ['name', 'type'],
527
+ },
528
+ },
529
+ approval_id: {
530
+ type: 'string',
531
+ description: 'JWT from request_approval — one approval covers the whole batch',
532
+ },
533
+ },
534
+ required: ['objects', 'approval_id'],
535
+ },
536
+ handler: async (args, ctx) => {
537
+ if (!Array.isArray(args.objects) || args.objects.length === 0) {
538
+ return { content: JSON.stringify({ activated: [], note: 'empty_activation_batch' }) };
539
+ }
540
+ // ── Gate 1: Approval (verify against primary object) ────────────────────
541
+ const primary = args.objects[0];
542
+ const verify = await verifyAndSpendApprovalId(args.approval_id, primary.name, 'activate');
543
+ if (!verify.ok) {
544
+ return {
545
+ content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: verify.reason }),
546
+ is_error: true,
547
+ };
548
+ }
549
+ const toolUseId = crypto.randomUUID();
550
+ const startedAt = Date.now();
551
+ // ── WAL: record pending entry ────────────────────────────────────────────
552
+ await appendPending(ctx.session, {
553
+ tool_use_id: toolUseId,
554
+ tool: 'sap_activate',
555
+ args: { objects: args.objects },
556
+ started_at: new Date().toISOString(),
557
+ });
558
+ // ── Activate each object, collect per-object results ─────────────────────
559
+ // activate(type, name) — one call per object; no batch API in AdtClient.
560
+ const activationResults = [];
561
+ let anyError = false;
562
+ for (const obj of args.objects) {
563
+ try {
564
+ const res = await ctx.adt.activate(obj.type, obj.name);
565
+ await auditLog('activate', obj.type, obj.name, res.success ? 'success' : 'error', Date.now() - startedAt);
566
+ activationResults.push({ name: obj.name, type: obj.type, success: res.success, messages: res.messages });
567
+ if (!res.success)
568
+ anyError = true;
569
+ }
570
+ catch (err) {
571
+ activationResults.push({ name: obj.name, type: obj.type, success: false, messages: [String(err)] });
572
+ anyError = true;
573
+ }
574
+ }
575
+ // ── Post-check: verify none of the objects remain inactive ───────────────
576
+ // 2026-05-01: verifyActive now returns { active, verifyError? }. If the
577
+ // inactive-objects probe itself failed, we MUST surface that as
578
+ // "unverified" — not silently treat it as "activated". This was the
579
+ // root cause of the "told user activate succeeded when program was
580
+ // still inactive with syntax errors" incident.
581
+ const stillInactive = [];
582
+ const unverifiable = [];
583
+ for (const obj of args.objects) {
584
+ const v = await verifyActive(ctx.adt, obj.name, obj.type);
585
+ if (v.verifyError) {
586
+ unverifiable.push({ object: `${obj.type}/${obj.name}`, reason: v.verifyError });
587
+ }
588
+ else if (!v.active) {
589
+ stillInactive.push(`${obj.type}/${obj.name}`);
590
+ }
591
+ }
592
+ const overallSuccess = !anyError && stillInactive.length === 0 && unverifiable.length === 0;
593
+ await finalizeToolCall(ctx.session, toolUseId, JSON.stringify({ overallSuccess, activationResults, stillInactive, unverifiable }), anyError, Date.now() - startedAt);
594
+ let note;
595
+ if (stillInactive.length > 0) {
596
+ note = 'Some objects remain inactive — check activation_results for error messages';
597
+ }
598
+ else if (unverifiable.length > 0) {
599
+ note = 'Activation reported success but verification failed — DO NOT claim activation succeeded. ' +
600
+ 'Re-run sap_inactive_objects manually to confirm state. See `unverifiable` for the underlying error.';
601
+ }
602
+ // 2026-05-01: when activation fails, return `is_error: true` so the
603
+ // dispatch layer (tool-dispatch.ts) does NOT credit this call as a
604
+ // successful write op. Without this, Rule 8 counts a failed activate
605
+ // as if it had run, inflating the "batch in progress" count and
606
+ // confusing the user about how many real writes have happened.
607
+ return {
608
+ content: JSON.stringify({
609
+ success: overallSuccess,
610
+ activation_results: activationResults,
611
+ still_inactive: stillInactive,
612
+ unverifiable,
613
+ note,
614
+ }),
615
+ is_error: !overallSuccess,
616
+ };
617
+ },
618
+ });
619
+ // ── sap_lock ──────────────────────────────────────────────────────────────────
620
+ //
621
+ // Lightweight lock primitive — does NOT go through the approval / WAL / verify
622
+ // pipeline. Used to acquire an edit lock on an object before a manual PUT.
623
+ // Signature: lock(type, name) → Promise<LockResult>
624
+ // LockResult { lockHandle: string; status: 'locked' | 'lock_failed' }
625
+ registerTool({
626
+ name: 'sap_lock',
627
+ description: 'RARELY needed. Returns a lock handle but other write tools cannot consume it — sap_set_source / sap_update_method / sap_delete_object each acquire their OWN lock internally. Calling sap_lock as a recovery step after a write failure CREATES A STALE LOCK that blocks the next write attempt; do not do that. Only call this when you need to inspect lock state or hold a lock for a coordinated multi-step ABAP workbench operation outside of CSPeach.',
628
+ isMutating: true,
629
+ input_schema: {
630
+ type: 'object',
631
+ properties: {
632
+ type: { type: 'string', description: 'ABAP object type (PROG, CLAS, etc.)' },
633
+ name: { type: 'string', description: 'ABAP object name' },
634
+ },
635
+ required: ['type', 'name'],
636
+ },
637
+ handler: async (args, ctx) => {
638
+ const result = await ctx.adt.lock(args.type, args.name);
639
+ return { content: JSON.stringify(result) };
640
+ },
641
+ });
642
+ // ── sap_unlock ────────────────────────────────────────────────────────────────
643
+ //
644
+ // Release an ADT edit lock previously acquired with sap_lock.
645
+ // Signature: unlock(type, name, lockHandle) → Promise<void>
646
+ registerTool({
647
+ name: 'sap_unlock',
648
+ description: 'Release an ADT edit lock on an ABAP object using the lockHandle from sap_lock.',
649
+ isMutating: true,
650
+ input_schema: {
651
+ type: 'object',
652
+ properties: {
653
+ type: { type: 'string', description: 'ABAP object type (PROG, CLAS, etc.)' },
654
+ name: { type: 'string', description: 'ABAP object name' },
655
+ lockHandle: { type: 'string', description: 'Lock handle returned by sap_lock' },
656
+ },
657
+ required: ['type', 'name', 'lockHandle'],
658
+ },
659
+ handler: async (args, ctx) => {
660
+ await ctx.adt.unlock(args.type, args.name, args.lockHandle);
661
+ return { content: JSON.stringify({ status: 'ok' }) };
662
+ },
663
+ });
664
+ // ── sap_service_binding_publish ───────────────────────────────────────────────
665
+ //
666
+ // Publish a service binding so it is reachable via OData.
667
+ // Signature: serviceBindingPublish(name) → Promise<{ status: number; error?: string }>
668
+ registerTool({
669
+ name: 'sap_service_binding_publish',
670
+ description: 'Publish an ADT service binding so its OData endpoint becomes active and reachable.',
671
+ isMutating: true,
672
+ input_schema: {
673
+ type: 'object',
674
+ properties: {
675
+ name: { type: 'string', description: 'Service binding name (e.g. ZSALES_UI_V4)' },
676
+ },
677
+ required: ['name'],
678
+ },
679
+ handler: async (args, ctx) => {
680
+ const result = await ctx.adt.serviceBindingPublish(args.name);
681
+ return { content: JSON.stringify(result) };
682
+ },
683
+ });
684
+ // ── sap_message_maintain (STUB) ───────────────────────────────────────────────
685
+ //
686
+ // STUB — MSAG message-class maintenance requires XML message-class-format
687
+ // parsing not yet implemented in @cspeach/sap-client.
688
+ registerTool({
689
+ name: 'sap_message_maintain',
690
+ description: 'STUB — not currently implemented; do not call. '
691
+ + 'Maintaining MSAG message-class entries via ADT requires XML message-class-format '
692
+ + 'parsing not yet implemented in @cspeach/sap-client.',
693
+ isMutating: true,
694
+ input_schema: {
695
+ type: 'object',
696
+ properties: {
697
+ messageClass: { type: 'string', description: 'Message class name (e.g. ZMSAG_FOO)' },
698
+ messageNumber: { type: 'string', description: 'Three-digit message number (e.g. 001)' },
699
+ messageText: { type: 'string', description: 'Message text (up to 73 characters)' },
700
+ messageType: { type: 'string', description: 'Message type: I, W, E, A, X, S' },
701
+ },
702
+ required: ['messageClass', 'messageNumber', 'messageText'],
703
+ },
704
+ handler: async (_args, _ctx) => {
705
+ return {
706
+ content: JSON.stringify({
707
+ error: 'not_implemented',
708
+ detail: 'MSAG message maintenance via ADT requires XML message-class-format parsing not yet implemented in @cspeach/sap-client',
709
+ }),
710
+ is_error: true,
711
+ };
712
+ },
713
+ });
714
+ // ── sap_number_range_intervals (STUB) ─────────────────────────────────────────
715
+ //
716
+ // STUB — NROB interval write requires NR-object XML format not yet implemented
717
+ // in @cspeach/sap-client.
718
+ registerTool({
719
+ name: 'sap_number_range_intervals',
720
+ description: 'STUB — not currently implemented; do not call. '
721
+ + 'Writing NROB number-range intervals via ADT requires NR-object XML format '
722
+ + 'not yet implemented in @cspeach/sap-client.',
723
+ isMutating: true,
724
+ input_schema: {
725
+ type: 'object',
726
+ properties: {
727
+ nrObject: { type: 'string', description: 'Number range object name' },
728
+ subobject: { type: 'string', description: 'Subobject (leave blank for main object)' },
729
+ intervals: {
730
+ type: 'array',
731
+ description: 'Array of interval definitions',
732
+ items: { type: 'object' },
733
+ },
734
+ },
735
+ required: ['nrObject'],
736
+ },
737
+ handler: async (_args, _ctx) => {
738
+ return {
739
+ content: JSON.stringify({
740
+ error: 'not_implemented',
741
+ detail: 'NROB interval write via ADT requires NR-object XML format not yet implemented in @cspeach/sap-client',
742
+ }),
743
+ is_error: true,
744
+ };
745
+ },
746
+ });