@cspeach/cli 0.8.0 → 1.0.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 (141) hide show
  1. package/README.md +1 -1
  2. package/dist/agent/intent-system-prompt.js +1 -1
  3. package/dist/agent/loop.js +330 -36
  4. package/dist/agent/providers/license-gate.js +44 -0
  5. package/dist/agent/skill-checkpoint.js +1 -1
  6. package/dist/agent/steering-queue.js +27 -0
  7. package/dist/agent/tool-dispatch.js +15 -0
  8. package/dist/approvals/canonical.js +91 -0
  9. package/dist/approvals/jwt.js +84 -7
  10. package/dist/approvals/render.js +38 -0
  11. package/dist/auth/org-anthropic-key.js +25 -0
  12. package/dist/classifier/client.js +24 -5
  13. package/dist/commands/config-set.js +95 -0
  14. package/dist/commands/login.js +31 -14
  15. package/dist/commands/plan-model-tier.js +83 -0
  16. package/dist/commands/plan-resume.js +435 -0
  17. package/dist/config/loader.js +102 -3
  18. package/dist/cost/pricing.js +14 -5
  19. package/dist/doctor/checks/_http-probe.js +1 -0
  20. package/dist/doctor/checks/cert.js +14 -3
  21. package/dist/doctor/checks/sap.js +30 -8
  22. package/dist/doctor/checks/zcspeach.js +19 -4
  23. package/dist/one-shot.js +52 -4
  24. package/dist/projects/answer-blockers.js +137 -0
  25. package/dist/projects/email-template.js +2 -0
  26. package/dist/projects/extract-cca.js +108 -16
  27. package/dist/projects/extract-modernize.js +1 -1
  28. package/dist/projects/extract-plan.js +178 -0
  29. package/dist/projects/extract-spec-gap.js +34 -7
  30. package/dist/projects/extract-test-coverage.js +1 -1
  31. package/dist/projects/extract-upgrade.js +113 -22
  32. package/dist/projects/index.js +7 -1
  33. package/dist/projects/merge-cca.js +292 -0
  34. package/dist/projects/merge-upgrade.js +173 -0
  35. package/dist/projects/migration.js +103 -1
  36. package/dist/projects/output-paths.js +27 -0
  37. package/dist/projects/plan-run.js +254 -0
  38. package/dist/projects/plan-schema.js +210 -0
  39. package/dist/projects/promote-command.js +26 -2
  40. package/dist/projects/promote.js +128 -0
  41. package/dist/projects/save-command.js +263 -20
  42. package/dist/projects/status.js +22 -0
  43. package/dist/projects/validate.js +2 -0
  44. package/dist/projects/workspace.js +164 -20
  45. package/dist/renderer/notices.js +64 -0
  46. package/dist/renderer/progress-chatter.js +8 -0
  47. package/dist/renderer/syntax.js +16 -1
  48. package/dist/renderer/thinking-heartbeat.js +13 -1
  49. package/dist/renderer/tool-widget.js +18 -4
  50. package/dist/renderer/tty.js +43 -4
  51. package/dist/renderer/verify-chain.js +77 -0
  52. package/dist/repl/at-picker.js +93 -21
  53. package/dist/repl/builtin-commands.js +37 -0
  54. package/dist/repl/early-line-buffer.js +68 -0
  55. package/dist/repl/inquirer-guard.js +70 -5
  56. package/dist/repl/numbered-menu.js +131 -0
  57. package/dist/repl/post-turn-status.js +2 -2
  58. package/dist/repl/rule8-detector.js +17 -2
  59. package/dist/repl/safety-confirm.js +111 -2
  60. package/dist/repl/safety-mode-state.js +19 -3
  61. package/dist/repl/slash-picker.js +25 -19
  62. package/dist/repl.js +470 -22
  63. package/dist/router/classifier.js +150 -6
  64. package/dist/sap/capability-matrix.js +20 -0
  65. package/dist/sap/capability-matrix.json +11236 -0
  66. package/dist/sap/capability.js +146 -0
  67. package/dist/sap/connection-manager.js +19 -1
  68. package/dist/sap/onboarding.js +42 -4
  69. package/dist/session/pending.js +27 -0
  70. package/dist/skill-catalog.js +54 -43
  71. package/dist/skills/bundled-skills.js +279 -1
  72. package/dist/skills/promotion-dispatch.js +23 -0
  73. package/dist/tools/_command-shared.js +36 -12
  74. package/dist/tools/_filesystem-shared.js +139 -4
  75. package/dist/tools/_flag.js +25 -0
  76. package/dist/tools/approval.js +64 -21
  77. package/dist/tools/ask-question.js +96 -4
  78. package/dist/tools/capability/tool.js +74 -0
  79. package/dist/tools/dispatch-skill.js +22 -1
  80. package/dist/tools/extend-model/anchored-insert.js +810 -0
  81. package/dist/tools/extend-model/tool.js +188 -0
  82. package/dist/tools/filesystem/extract-document.js +57 -0
  83. package/dist/tools/filesystem/file-edit.js +12 -2
  84. package/dist/tools/filesystem/file-read.js +2 -2
  85. package/dist/tools/filesystem/file-write.js +11 -2
  86. package/dist/tools/filesystem/glob.js +11 -0
  87. package/dist/tools/filesystem/grep.js +10 -0
  88. package/dist/tools/filesystem/read-document.js +107 -0
  89. package/dist/tools/fiori/apply.js +50 -0
  90. package/dist/tools/fiori/bin.js +3 -0
  91. package/dist/tools/fiori/catalog/index.js +27 -0
  92. package/dist/tools/fiori/catalog/value-help.js +230 -0
  93. package/dist/tools/fiori/catalog/viz-chart.js +177 -0
  94. package/dist/tools/fiori/cli.js +71 -0
  95. package/dist/tools/fiori/deploy-config.js +73 -0
  96. package/dist/tools/fiori/fe-scaffold.js +45 -0
  97. package/dist/tools/fiori/i18n.js +39 -0
  98. package/dist/tools/fiori/manifest.js +70 -0
  99. package/dist/tools/fiori/render.js +77 -0
  100. package/dist/tools/fiori/scaffold.js +39 -0
  101. package/dist/tools/fiori/tools.js +356 -0
  102. package/dist/tools/fiori/types.js +1 -0
  103. package/dist/tools/local-build.js +76 -0
  104. package/dist/tools/local-files.js +31 -0
  105. package/dist/tools/project/_merge-shared.js +68 -0
  106. package/dist/tools/project/cca_merge.js +164 -0
  107. package/dist/tools/project/playbook_get.js +1 -1
  108. package/dist/tools/project/upgrade_merge_progress.js +206 -0
  109. package/dist/tools/sap-read.js +53 -9
  110. package/dist/tools/sap-write.js +530 -21
  111. package/dist/tools/shell/shell_exec.js +41 -6
  112. package/dist/tools/snapshot.js +37 -14
  113. package/dist/tools/subagent/background_run.js +17 -1
  114. package/dist/tools/transport-resolution.js +86 -0
  115. package/dist/tools/transport.js +224 -5
  116. package/dist/tools/write-mode.js +4 -0
  117. package/dist/ui/app.js +84 -11
  118. package/dist/ui/body.js +13 -0
  119. package/dist/ui/command-palette.js +46 -10
  120. package/dist/ui/file-palette.js +44 -0
  121. package/dist/ui/footer.js +28 -11
  122. package/dist/ui/line-resolution.js +92 -0
  123. package/dist/ui/session-timeline.js +1 -0
  124. package/dist/ui/text-input.js +150 -0
  125. package/dist/ui/turn-status-emitter.js +52 -0
  126. package/dist/ui/turn-status.js +59 -0
  127. package/dist/ui/widgets/ask-question-modal.js +30 -2
  128. package/package.json +23 -4
  129. package/bench/README.md +0 -78
  130. package/bench/prompts/abap-document-cds.md +0 -44
  131. package/bench/prompts/abap-explain-bdef-handler.md +0 -57
  132. package/bench/prompts/abap-test-method.md +0 -42
  133. package/bench/results/abap-document-cds/claude-haiku-4-5.md +0 -189
  134. package/bench/results/abap-document-cds/claude-opus-4-7.md +0 -120
  135. package/bench/results/abap-document-cds/claude-sonnet-4-6.md +0 -151
  136. package/bench/results/abap-explain-bdef-handler/claude-haiku-4-5.md +0 -112
  137. package/bench/results/abap-explain-bdef-handler/claude-opus-4-7.md +0 -101
  138. package/bench/results/abap-explain-bdef-handler/claude-sonnet-4-6.md +0 -101
  139. package/bench/results/abap-test-method/claude-haiku-4-5.md +0 -186
  140. package/bench/results/abap-test-method/claude-opus-4-7.md +0 -193
  141. package/bench/results/abap-test-method/claude-sonnet-4-6.md +0 -234
@@ -15,6 +15,7 @@
15
15
  * lock(type, name) → Promise<LockResult> line 1914
16
16
  * unlock(type, name, lockHandle) → Promise<void> line 1949
17
17
  * serviceBindingPublish(name) → Promise<{status,error?}> line 2658
18
+ * serviceBindingUnpublish(name) → Promise<{status,error?}>
18
19
  * messageMaintain (STUB — not yet in sap-client)
19
20
  * numberRangeIntervals (STUB — not yet in sap-client)
20
21
  *
@@ -24,18 +25,53 @@ import crypto from 'node:crypto';
24
25
  import { registerTool } from './index.js';
25
26
  import { verifyAndSpendApprovalId } from '../approvals/jwt.js';
26
27
  import { autoSnapshot } from './snapshot.js';
28
+ import { buildWriteChainLine } from '../renderer/verify-chain.js';
27
29
  import { verifySyntax, verifyActive } from './verify.js';
28
30
  import { appendPending, finalizeToolCall } from '../session/pending.js';
29
31
  import { auditLog } from '@cspeach/sap-client';
30
32
  import { ERR } from '../errors/codes.js';
31
33
  import { interceptUpdateMethod, CancelledByUserError, } from './update-method-intercept.js';
34
+ import { resolveWriteTransport } from './transport-resolution.js';
32
35
  import { presentSafetyConfirmation } from '../repl/safety-confirm.js';
33
36
  import { isSafetyConfirmEnabled } from '../repl/safety-mode-state.js';
37
+ // ── Transcript visibility helpers (D16-display + tour slide-7, 2026-06-12) ──
38
+ //
39
+ // Rule 7 auto-snapshots always fired but the transcript never said so
40
+ // (battery D16-display). And the tour shows writes as
41
+ // "① snapshot ✓ ② written ✓ ③ syntax ✓ ④ activated ✓" while real
42
+ // transcripts showed an opaque tool row. Both lines below are HUMAN-stream
43
+ // only (ctx.chunkEmitter) — tool RESULT payloads the model sees are
44
+ // unchanged, and subagent ctxs (no chunkEmitter) stay silent.
45
+ /** Dim one-liner when the Rule 7 auto-snapshot actually captured source. */
46
+ function emitSnapshotNotice(ctx, snap, type, name) {
47
+ if (snap?.taken && snap.entry) {
48
+ ctx.chunkEmitter?.emit('info', `snapshot ✓ ${type} ${name} ${snap.entry.timestamp}`);
49
+ }
50
+ }
51
+ /** Map the snapshot-gate outcome onto the chain line's honest states.
52
+ * `undefined` covers the best-effort gates whose failure was swallowed.
53
+ * 'object_not_found' is a genuine ADT 404 only; any other read failure maps
54
+ * to 'source_read_failed' (+ short detail) — never to "(new object)". */
55
+ function snapshotChainState(snap) {
56
+ if (snap?.taken)
57
+ return { snapshot: 'taken' };
58
+ if (snap?.reason === 'object_not_found')
59
+ return { snapshot: 'new_object' };
60
+ if (snap?.reason === 'source_read_failed') {
61
+ return { snapshot: 'source_read_failed', snapshotDetail: snap.detail };
62
+ }
63
+ return { snapshot: 'best_effort_failed' };
64
+ }
65
+ /** Compact ①②③④ pipeline summary — derived from the real gate results. */
66
+ function emitWriteChain(ctx, input) {
67
+ ctx.chunkEmitter?.emit('chunk', buildWriteChainLine(input) + '\n');
68
+ }
34
69
  registerTool({
35
70
  name: 'sap_set_source',
36
71
  description: 'Overwrite the full source of an ABAP object. Requires approval_id. '
37
72
  + '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).',
73
+ + 'a single method body. For method-body-only edits use sap_update_method (Rule 7a). '
74
+ + 'The transport auto-resolves to the owning request if the object is already locked.',
39
75
  isMutating: true,
40
76
  input_schema: {
41
77
  type: 'object',
@@ -57,7 +93,7 @@ registerTool({
57
93
  is_error: true,
58
94
  };
59
95
  }
60
- const toolUseId = crypto.randomUUID();
96
+ const toolUseId = ctx.toolUseId ?? crypto.randomUUID();
61
97
  const startedAt = Date.now();
62
98
  // ── Rule 9 conflict warning ──────────────────────────────────────────────
63
99
  // Emit a warning if the LLM passed an explicit transport that differs from
@@ -69,9 +105,16 @@ registerTool({
69
105
  ctx.chunkEmitter?.emit('warn', `LLM passed transport=${args.transport} but session current-transport=${sessionTransport}`);
70
106
  }
71
107
  }
72
- // ── Gate 2: Snapshot (Rule 7) ────────────────────────────────────────────
108
+ // ── Rule 9 owning-transport check (D15/D20) ──────────────────────────────
109
+ // If the object is already locked in an open request, write under THAT
110
+ // request — any other transport is a guaranteed CTS lock conflict.
111
+ const resolved = await resolveWriteTransport(ctx, args.type, args.name, args.transport);
112
+ // ── Gate 2: Snapshot (Rule 7) — HARD gate ────────────────────────────────
113
+ // Only a genuine 404 (new object) may proceed snapshot-less. A read
114
+ // failure on a possibly-EXISTING object means no recovery copy → block.
115
+ let snapOutcome;
73
116
  try {
74
- await autoSnapshot(args.name, args.type, ctx.adt);
117
+ snapOutcome = await autoSnapshot(args.name, args.type, ctx.adt);
75
118
  }
76
119
  catch (err) {
77
120
  return {
@@ -79,6 +122,18 @@ registerTool({
79
122
  is_error: true,
80
123
  };
81
124
  }
125
+ if (snapOutcome?.reason === 'source_read_failed') {
126
+ return {
127
+ content: JSON.stringify({
128
+ error: ERR.SNAPSHOT_FAILED,
129
+ reason: 'source_read_failed',
130
+ detail: snapOutcome.detail,
131
+ recovery: 'Pre-write source read failed (non-404) — the object may exist but no snapshot could be taken. Fix the read failure (connection/auth) and retry; do not bypass Rule 7.',
132
+ }),
133
+ is_error: true,
134
+ };
135
+ }
136
+ emitSnapshotNotice(ctx, snapOutcome, args.type, args.name);
82
137
  // ── Gate 3: Safety confirmation (Rule 7) ─────────────────────────────────
83
138
  if (isSafetyConfirmEnabled()) {
84
139
  const r = await presentSafetyConfirmation({
@@ -87,7 +142,7 @@ registerTool({
87
142
  object: { type: args.type, name: args.name },
88
143
  what_will_happen: 'Overwrites full object source; snapshot is taken first.',
89
144
  rollback_available: true,
90
- transport: args.transport ?? ctx.currentTransport?.get() ?? '',
145
+ transport: resolved.transport ?? ctx.currentTransport?.get() ?? '',
91
146
  });
92
147
  if (!r.confirmed) {
93
148
  return {
@@ -114,7 +169,7 @@ registerTool({
114
169
  // Regression evidence + diagnosis: memory/project_phase3_lite_regression.md.
115
170
  let writeResult;
116
171
  try {
117
- writeResult = await ctx.adt.setSource(args.type, args.name, args.source, args.transport);
172
+ writeResult = await ctx.adt.setSource(args.type, args.name, args.source, resolved.transport);
118
173
  }
119
174
  catch (err) {
120
175
  const errStr = String(err);
@@ -140,12 +195,19 @@ registerTool({
140
195
  // ── Gate 4: Verify — syntax check (Rule 10) ──────────────────────────────
141
196
  const syntax = await verifySyntax(ctx.adt, args.name, args.type);
142
197
  await finalizeToolCall(ctx.session, toolUseId, JSON.stringify({ written: true, syntax }), false, Date.now() - startedAt);
198
+ // Tour slide-7 chain — derived from the gates that actually ran.
199
+ emitWriteChain(ctx, {
200
+ ...snapshotChainState(snapOutcome),
201
+ written: true,
202
+ syntax: { ok: syntax.ok, errors: syntax.errors },
203
+ });
143
204
  return {
144
205
  content: JSON.stringify({
145
206
  written: true,
146
207
  syntax_ok: syntax.ok,
147
208
  errors: syntax.errors,
148
209
  warnings: syntax.warnings,
210
+ ...(resolved.owning ? { transport_used: resolved.owning, transport_note: resolved.note } : {}),
149
211
  next_step: syntax.ok
150
212
  ? 'activation_allowed'
151
213
  : 'activation_blocked_until_syntax_errors_fixed',
@@ -166,7 +228,8 @@ registerTool({
166
228
  description: 'Replace the body of a single method in an existing class. '
167
229
  + 'Preferred over sap_set_source for method-body-only changes (Rule 7a) — '
168
230
  + 'does NOT rewrite the class definition. '
169
- + 'method_source must be the lines between METHOD x. and ENDMETHOD. (no wrappers).',
231
+ + 'method_source must be the lines between METHOD x. and ENDMETHOD. (no wrappers). '
232
+ + 'The transport auto-resolves to the owning request if the class is already locked.',
170
233
  isMutating: true,
171
234
  input_schema: {
172
235
  type: 'object',
@@ -188,7 +251,7 @@ registerTool({
188
251
  is_error: true,
189
252
  };
190
253
  }
191
- const toolUseId = crypto.randomUUID();
254
+ const toolUseId = ctx.toolUseId ?? crypto.randomUUID();
192
255
  const startedAt = Date.now();
193
256
  // ── Rule 9 conflict warning ──────────────────────────────────────────────
194
257
  // Emit a warning if the LLM passed an explicit transport that differs from
@@ -200,9 +263,15 @@ registerTool({
200
263
  ctx.chunkEmitter?.emit('warn', `LLM passed transport=${args.transport} but session current-transport=${sessionTransport}`);
201
264
  }
202
265
  }
203
- // ── Gate 2: Snapshot (Rule 7) ────────────────────────────────────────────
266
+ // ── Rule 9 owning-transport check (D15/D20) ──────────────────────────────
267
+ const resolved = await resolveWriteTransport(ctx, 'CLAS', args.class_name, args.transport);
268
+ // ── Gate 2: Snapshot (Rule 7) — HARD gate ────────────────────────────────
269
+ // sap_update_method always targets an EXISTING class, so a read failure
270
+ // (non-404) means no recovery copy → block. (A genuine 404 falls through
271
+ // here but the preview gate below fails on the same missing class.)
272
+ let snapOutcome;
204
273
  try {
205
- await autoSnapshot(args.class_name, 'CLAS', ctx.adt);
274
+ snapOutcome = await autoSnapshot(args.class_name, 'CLAS', ctx.adt);
206
275
  }
207
276
  catch (err) {
208
277
  return {
@@ -210,6 +279,18 @@ registerTool({
210
279
  is_error: true,
211
280
  };
212
281
  }
282
+ if (snapOutcome?.reason === 'source_read_failed') {
283
+ return {
284
+ content: JSON.stringify({
285
+ error: ERR.SNAPSHOT_FAILED,
286
+ reason: 'source_read_failed',
287
+ detail: snapOutcome.detail,
288
+ recovery: 'Pre-write source read failed (non-404) — the class exists but no snapshot could be taken. Fix the read failure (connection/auth) and retry; do not bypass Rule 7.',
289
+ }),
290
+ is_error: true,
291
+ };
292
+ }
293
+ emitSnapshotNotice(ctx, snapOutcome, 'CLAS', args.class_name);
213
294
  // ── Gate 3: Diff preview & confirmation (Rule 7a) ────────────────────────
214
295
  if (!ctx.previewHook) {
215
296
  return {
@@ -227,7 +308,7 @@ registerTool({
227
308
  className: args.class_name,
228
309
  methodName: args.method_name,
229
310
  methodSource: args.method_source,
230
- transport: args.transport ?? ctx.currentTransport?.get() ?? '',
311
+ transport: resolved.transport ?? ctx.currentTransport?.get() ?? '',
231
312
  };
232
313
  try {
233
314
  await interceptUpdateMethod(minimal, call, ctx.previewHook);
@@ -263,7 +344,7 @@ registerTool({
263
344
  // silently overwriting the inactive-only declarations.
264
345
  let updateResult;
265
346
  try {
266
- updateResult = await ctx.adt.updateMethod(args.class_name, args.method_name, args.method_source, args.transport);
347
+ updateResult = await ctx.adt.updateMethod(args.class_name, args.method_name, args.method_source, resolved.transport);
267
348
  }
268
349
  catch (err) {
269
350
  const errStr = String(err);
@@ -302,12 +383,19 @@ registerTool({
302
383
  // ── Gate 4: Verify — syntax check (Rule 10) ──────────────────────────────
303
384
  const syntax = await verifySyntax(ctx.adt, args.class_name, 'CLAS');
304
385
  await finalizeToolCall(ctx.session, toolUseId, JSON.stringify({ updated: true, syntax }), false, Date.now() - startedAt);
386
+ // Tour slide-7 chain — derived from the gates that actually ran.
387
+ emitWriteChain(ctx, {
388
+ ...snapshotChainState(snapOutcome),
389
+ written: true,
390
+ syntax: { ok: syntax.ok, errors: syntax.errors },
391
+ });
305
392
  return {
306
393
  content: JSON.stringify({
307
394
  updated: true,
308
395
  syntax_ok: syntax.ok,
309
396
  errors: syntax.errors,
310
397
  warnings: syntax.warnings,
398
+ ...(resolved.owning ? { transport_used: resolved.owning, transport_note: resolved.note } : {}),
311
399
  next_step: syntax.ok
312
400
  ? 'activation_allowed'
313
401
  : 'activation_blocked_until_syntax_errors_fixed',
@@ -324,7 +412,8 @@ registerTool({
324
412
  name: 'sap_delete_object',
325
413
  description: 'Delete an ABAP object. Requires approval_id. '
326
414
  + 'A snapshot is taken automatically before deletion. '
327
- + 'Post-check: confirms the object no longer exists.',
415
+ + 'Post-check: confirms the object no longer exists. '
416
+ + 'The transport auto-resolves to the owning request if the object is already locked.',
328
417
  isMutating: true,
329
418
  input_schema: {
330
419
  type: 'object',
@@ -345,7 +434,7 @@ registerTool({
345
434
  is_error: true,
346
435
  };
347
436
  }
348
- const toolUseId = crypto.randomUUID();
437
+ const toolUseId = ctx.toolUseId ?? crypto.randomUUID();
349
438
  const startedAt = Date.now();
350
439
  // ── Rule 9 conflict warning ──────────────────────────────────────────────
351
440
  // Emit a warning if the LLM passed an explicit transport that differs from
@@ -357,13 +446,17 @@ registerTool({
357
446
  ctx.chunkEmitter?.emit('warn', `LLM passed transport=${args.transport} but session current-transport=${sessionTransport}`);
358
447
  }
359
448
  }
449
+ // ── Rule 9 owning-transport check (D15/D20) ──────────────────────────────
450
+ const resolved = await resolveWriteTransport(ctx, args.type, args.name, args.transport);
360
451
  // ── Gate 2: Snapshot (Rule 7) — best-effort, object may not have source ──
452
+ let snapOutcome;
361
453
  try {
362
- await autoSnapshot(args.name, args.type, ctx.adt);
454
+ snapOutcome = await autoSnapshot(args.name, args.type, ctx.adt);
363
455
  }
364
456
  catch {
365
457
  // Silently ignore — object may be a shell with no readable source.
366
458
  }
459
+ emitSnapshotNotice(ctx, snapOutcome, args.type, args.name);
367
460
  // ── Gate 3: Safety confirmation (Rule 7 — delete is irreversible-ish) ────
368
461
  if (isSafetyConfirmEnabled()) {
369
462
  const r = await presentSafetyConfirmation({
@@ -372,7 +465,7 @@ registerTool({
372
465
  object: { type: args.type, name: args.name },
373
466
  what_will_happen: 'Deletes object from SAP; snapshot is taken first; recovery via sap_snapshot_read + sap_set_source (LLM composes the rollback).',
374
467
  rollback_available: true,
375
- transport: args.transport ?? ctx.currentTransport?.get() ?? '',
468
+ transport: resolved.transport ?? ctx.currentTransport?.get() ?? '',
376
469
  });
377
470
  if (!r.confirmed) {
378
471
  return {
@@ -394,7 +487,7 @@ registerTool({
394
487
  // 4xx. Must check status / error on the return value.
395
488
  let deleteResult;
396
489
  try {
397
- deleteResult = await ctx.adt.deleteObject(args.type, args.name, args.transport);
490
+ deleteResult = await ctx.adt.deleteObject(args.type, args.name, resolved.transport);
398
491
  }
399
492
  catch (err) {
400
493
  const errStr = String(err);
@@ -423,7 +516,11 @@ registerTool({
423
516
  .catch(() => false);
424
517
  await finalizeToolCall(ctx.session, toolUseId, JSON.stringify({ deleted: !stillExists }), false, Date.now() - startedAt);
425
518
  return {
426
- content: JSON.stringify({ deleted: !stillExists, still_exists: stillExists }),
519
+ content: JSON.stringify({
520
+ deleted: !stillExists,
521
+ still_exists: stillExists,
522
+ ...(resolved.owning ? { transport_used: resolved.owning, transport_note: resolved.note } : {}),
523
+ }),
427
524
  };
428
525
  },
429
526
  });
@@ -448,6 +545,11 @@ registerTool({
448
545
  description: { type: 'string', description: 'Short description' },
449
546
  transport: { type: 'string', description: 'Target transport (optional — required for workbench packages)' },
450
547
  approval_id: { type: 'string', description: 'JWT from request_approval' },
548
+ // SRVB-only: a service binding must name the activated service definition it
549
+ // binds, plus the protocol/version. Required for type=SRVB; ignored otherwise.
550
+ serviceDefinition: { type: 'string', description: 'SRVB ONLY — REQUIRED when type=SRVB: name of the activated service definition (SRVD) to bind, e.g. ZUI_MYSERVICE.' },
551
+ bindingType: { type: 'string', description: 'SRVB ONLY (optional): binding protocol. Default ODATA.' },
552
+ bindingVersion: { type: 'string', description: 'SRVB ONLY (optional): binding version. Default V4 (OData V4 UI). Use V2 for OData V2.' },
451
553
  },
452
554
  required: ['name', 'type', 'package', 'description', 'approval_id'],
453
555
  },
@@ -460,7 +562,7 @@ registerTool({
460
562
  is_error: true,
461
563
  };
462
564
  }
463
- const toolUseId = crypto.randomUUID();
565
+ const toolUseId = ctx.toolUseId ?? crypto.randomUUID();
464
566
  const startedAt = Date.now();
465
567
  // No snapshot — new object has no prior source.
466
568
  // ── Rule 9 conflict warning + 3-tier transport fallback ──────────────────
@@ -486,7 +588,11 @@ registerTool({
486
588
  // createObject(objtype, name, packageName, description, transport?, extras?)
487
589
  let result;
488
590
  try {
489
- result = await ctx.adt.createObject(args.type, args.name, args.package, args.description, effectiveTransport);
591
+ result = await ctx.adt.createObject(args.type, args.name, args.package, args.description, effectiveTransport, {
592
+ serviceDefinition: args.serviceDefinition,
593
+ bindingType: args.bindingType,
594
+ bindingVersion: args.bindingVersion,
595
+ });
490
596
  await auditLog('createObject', args.type, args.name, 'success', Date.now() - startedAt);
491
597
  }
492
598
  catch (err) {
@@ -516,6 +622,386 @@ registerTool({
516
622
  };
517
623
  },
518
624
  });
625
+ // ── sap_set_class_include ─────────────────────────────────────────────────────
626
+ //
627
+ // Signature: setClassInclude(className, includeType, source, transport?)
628
+ // → Promise<ClassIncludeResult>
629
+ // Writes a class local-include source (CCDEF/CCIMP/CCMAC/test classes). This is
630
+ // how RAP behaviour-pool handlers (which live in CCIMP / locals_imp) get landed.
631
+ // AdtClient locks the CLASS object, tries a ladder of PUT URL/content-type
632
+ // variants (first 2xx wins), and always unlocks. Snapshot is best-effort:
633
+ // the include source may not be independently readable, so a failed snapshot
634
+ // must not block the write (consistent with sap_delete_object).
635
+ registerTool({
636
+ name: 'sap_set_class_include',
637
+ description: 'Write a class local-include source (CCDEF locals_def, CCIMP locals_imp, '
638
+ + 'CCMAC macros, or test classes). Required for RAP behaviour-pool handlers, '
639
+ + 'which live in the CCIMP (locals_imp) include. Requires approval_id. '
640
+ + 'After writing, activate the class via sap_activate. '
641
+ + 'The transport auto-resolves to the owning request if the class is already locked.',
642
+ isMutating: true,
643
+ input_schema: {
644
+ type: 'object',
645
+ properties: {
646
+ className: { type: 'string', description: 'ABAP class name (e.g. ZBP_FOO)' },
647
+ includeType: {
648
+ type: 'string',
649
+ enum: ['locals_def', 'locals_imp', 'macros', 'testclasses'],
650
+ description: 'Which local include to write (locals_imp = CCIMP, where RAP handlers live)',
651
+ },
652
+ source: { type: 'string', description: 'Include source body' },
653
+ transport: { type: 'string', description: 'Transport request number (optional)' },
654
+ approval_id: { type: 'string', description: 'JWT from request_approval' },
655
+ },
656
+ required: ['className', 'includeType', 'source', 'approval_id'],
657
+ },
658
+ handler: async (args, ctx) => {
659
+ // ── Gate 1: Approval ────────────────────────────────────────────────────
660
+ const verify = await verifyAndSpendApprovalId(args.approval_id, args.className, 'modify');
661
+ if (!verify.ok) {
662
+ return {
663
+ content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: verify.reason }),
664
+ is_error: true,
665
+ };
666
+ }
667
+ const toolUseId = ctx.toolUseId ?? crypto.randomUUID();
668
+ const startedAt = Date.now();
669
+ // ── Rule 9 conflict warning + 3-tier transport fallback ──────────────────
670
+ {
671
+ const sessionTransport = ctx.currentTransport?.get() ?? null;
672
+ if (args.transport && sessionTransport && args.transport !== sessionTransport) {
673
+ ctx.chunkEmitter?.emit('warn', `LLM passed transport=${args.transport} but session current-transport=${sessionTransport}`);
674
+ }
675
+ }
676
+ const effectiveTransport = args.transport ?? ctx.currentTransport?.get() ?? undefined;
677
+ // ── Rule 9 owning-transport check (D15/D20) ──────────────────────────────
678
+ // The CCIMP piece (LIMU CINC) is exactly the kind of object that sits
679
+ // locked in a request from earlier work — battery S1's 409 root cause.
680
+ const resolved = await resolveWriteTransport(ctx, 'CLAS', args.className, effectiveTransport);
681
+ // ── Gate 2: Snapshot (Rule 7) — best-effort, include may not be readable ─
682
+ let snapOutcome;
683
+ try {
684
+ snapOutcome = await autoSnapshot(args.className, 'CLAS', ctx.adt);
685
+ }
686
+ catch {
687
+ // Silently ignore — include source may not be independently readable.
688
+ }
689
+ emitSnapshotNotice(ctx, snapOutcome, 'CLAS', args.className);
690
+ // ── WAL: record pending entry ────────────────────────────────────────────
691
+ await appendPending(ctx.session, {
692
+ tool_use_id: toolUseId,
693
+ tool: 'sap_set_class_include',
694
+ args: { className: args.className, includeType: args.includeType },
695
+ started_at: new Date().toISOString(),
696
+ });
697
+ // ── Write ────────────────────────────────────────────────────────────────
698
+ // setClassInclude THROWS only when it cannot acquire the class lock;
699
+ // for the all-variants-failed case it RETURNS { written:false, ... }.
700
+ // Both paths must surface as an error envelope.
701
+ let result;
702
+ try {
703
+ result = await ctx.adt.setClassInclude(args.className, args.includeType, args.source, resolved.transport);
704
+ }
705
+ catch (err) {
706
+ const errStr = String(err);
707
+ await finalizeToolCall(ctx.session, toolUseId, errStr, true, Date.now() - startedAt);
708
+ return {
709
+ content: JSON.stringify({ error: 'class_include_write_failed', detail: errStr }),
710
+ is_error: true,
711
+ };
712
+ }
713
+ if (!result.written) {
714
+ // D14: `result.error` now carries SAP's T100 diagnosis verbatim when a
715
+ // variant hit a CTS/lock conflict — pass it through unaltered, plus the
716
+ // structured owning transport so the model can retry correctly instead
717
+ // of declaring the change manual-only.
718
+ const detail = result.error ?? `All PUT variants failed (last HTTP ${result.httpStatus})`;
719
+ await finalizeToolCall(ctx.session, toolUseId, JSON.stringify(result), true, Date.now() - startedAt);
720
+ return {
721
+ content: JSON.stringify({
722
+ error: 'class_include_write_failed',
723
+ detail,
724
+ http_status: result.httpStatus,
725
+ ...(result.lockedInTransport ? {
726
+ locked_in_transport: result.lockedInTransport,
727
+ recovery: `Retry this exact write with transport=${result.lockedInTransport} — that request already owns the object.`,
728
+ } : {}),
729
+ attempts: result.attempts,
730
+ }),
731
+ is_error: true,
732
+ };
733
+ }
734
+ await auditLog('setClassInclude', 'CLAS', args.className, 'success', Date.now() - startedAt, `include=${args.includeType}`);
735
+ await finalizeToolCall(ctx.session, toolUseId, JSON.stringify({ written: true, variant: result.variantUsed }), false, Date.now() - startedAt);
736
+ // Tour slide-7 chain. Honest states: includes have no standalone syntax
737
+ // gate (checked at activation), so ③ renders as deferred, never faked.
738
+ emitWriteChain(ctx, {
739
+ ...snapshotChainState(snapOutcome),
740
+ written: true,
741
+ syntax: 'not_checked',
742
+ });
743
+ return {
744
+ content: JSON.stringify({
745
+ written: true,
746
+ variant: result.variantUsed,
747
+ ...(resolved.owning ? { transport_used: resolved.owning, transport_note: resolved.note } : {}),
748
+ next_step: 'activate_the_class',
749
+ }),
750
+ };
751
+ },
752
+ });
753
+ // ── sap_create_domain ────────────────────────────────────────────────────────
754
+ //
755
+ // Signature: createDomain(spec, transport?) → Promise<DdicCreateResult>
756
+ // AdtClient creates → activates → verifies in one call (Rule 10 internal).
757
+ // No snapshot needed: new object has no prior source.
758
+ registerTool({
759
+ name: 'sap_create_domain',
760
+ description: 'Create a DDIC domain (DOMA), then activate and verify it. '
761
+ + 'Requires approval_id. No snapshot needed (new object). '
762
+ + 'Use the domain in a data element via sap_create_data_element.',
763
+ isMutating: true,
764
+ input_schema: {
765
+ type: 'object',
766
+ properties: {
767
+ name: { type: 'string', description: 'Domain name (Z*/Y*)' },
768
+ description: { type: 'string', description: 'Short description' },
769
+ package: { type: 'string', description: 'Development package (Z*/Y* or $TMP)' },
770
+ dataType: { type: 'string', description: 'ABAP data type (CHAR/NUMC/DEC/CURR/QUAN/INT1/INT2/INT4/DATS/TIMS/…)' },
771
+ length: { type: 'number', description: 'Domain length (number of characters/digits)' },
772
+ decimals: { type: 'number', description: 'Number of decimal places (DEC/CURR/QUAN)' },
773
+ outputLength: { type: 'number', description: 'Output length (optional)' },
774
+ lowercase: { type: 'boolean', description: 'Allow lowercase (optional)' },
775
+ signExists: { type: 'boolean', description: 'Value can be negative (optional)' },
776
+ valueTable: { type: 'string', description: 'Check / value table (optional)' },
777
+ fixedValues: {
778
+ type: 'array',
779
+ description: 'Fixed value list (optional)',
780
+ items: {
781
+ type: 'object',
782
+ properties: {
783
+ value: { type: 'string', description: 'Fixed value' },
784
+ text: { type: 'string', description: 'Description text' },
785
+ },
786
+ required: ['value', 'text'],
787
+ },
788
+ },
789
+ transport: { type: 'string', description: 'Target transport (optional)' },
790
+ approval_id: { type: 'string', description: 'JWT from request_approval' },
791
+ },
792
+ required: ['name', 'description', 'package', 'dataType', 'length', 'approval_id'],
793
+ },
794
+ handler: async (args, ctx) => {
795
+ // ── Gate 1: Approval ────────────────────────────────────────────────────
796
+ const verify = await verifyAndSpendApprovalId(args.approval_id, args.name, 'create');
797
+ if (!verify.ok) {
798
+ return {
799
+ content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: verify.reason }),
800
+ is_error: true,
801
+ };
802
+ }
803
+ const toolUseId = ctx.toolUseId ?? crypto.randomUUID();
804
+ const startedAt = Date.now();
805
+ // No snapshot — new object has no prior source.
806
+ // ── Rule 9 conflict warning + 3-tier transport fallback ──────────────────
807
+ {
808
+ const sessionTransport = ctx.currentTransport?.get() ?? null;
809
+ if (args.transport && sessionTransport && args.transport !== sessionTransport) {
810
+ ctx.chunkEmitter?.emit('warn', `LLM passed transport=${args.transport} but session current-transport=${sessionTransport}`);
811
+ }
812
+ }
813
+ const effectiveTransport = args.transport ?? ctx.currentTransport?.get() ?? undefined;
814
+ // ── WAL: record pending entry ────────────────────────────────────────────
815
+ await appendPending(ctx.session, {
816
+ tool_use_id: toolUseId,
817
+ tool: 'sap_create_domain',
818
+ args: { name: args.name, type: 'DOMA', package: args.package },
819
+ started_at: new Date().toISOString(),
820
+ });
821
+ // ── Build spec (omit undefined optionals) ────────────────────────────────
822
+ const spec = {
823
+ name: args.name,
824
+ description: args.description,
825
+ package: args.package,
826
+ dataType: args.dataType,
827
+ length: args.length,
828
+ };
829
+ if (args.decimals !== undefined)
830
+ spec.decimals = args.decimals;
831
+ if (args.outputLength !== undefined)
832
+ spec.outputLength = args.outputLength;
833
+ if (args.lowercase !== undefined)
834
+ spec.lowercase = args.lowercase;
835
+ if (args.signExists !== undefined)
836
+ spec.signExists = args.signExists;
837
+ if (args.valueTable !== undefined)
838
+ spec.valueTable = args.valueTable;
839
+ if (args.fixedValues !== undefined)
840
+ spec.fixedValues = args.fixedValues;
841
+ // ── Create → activate → verify (AdtClient does all three) ────────────────
842
+ let result;
843
+ try {
844
+ result = await ctx.adt.createDomain(spec, effectiveTransport);
845
+ await auditLog('createObject', 'DOMA/DD', args.name, 'success', Date.now() - startedAt);
846
+ }
847
+ catch (err) {
848
+ const errStr = String(err);
849
+ await finalizeToolCall(ctx.session, toolUseId, errStr, true, Date.now() - startedAt);
850
+ return {
851
+ content: JSON.stringify({ error: 'create_failed', detail: errStr }),
852
+ is_error: true,
853
+ };
854
+ }
855
+ if (!result.created) {
856
+ await finalizeToolCall(ctx.session, toolUseId, JSON.stringify(result), true, Date.now() - startedAt);
857
+ return {
858
+ content: JSON.stringify({ error: 'create_failed', detail: result.error }),
859
+ is_error: true,
860
+ };
861
+ }
862
+ if (!result.active) {
863
+ // Rule 10: created but did not activate — surface as an error.
864
+ await finalizeToolCall(ctx.session, toolUseId, JSON.stringify(result), true, Date.now() - startedAt);
865
+ return {
866
+ content: JSON.stringify({ error: 'activation_failed', object: result.name, detail: result.error }),
867
+ is_error: true,
868
+ };
869
+ }
870
+ await finalizeToolCall(ctx.session, toolUseId, JSON.stringify(result), false, Date.now() - startedAt);
871
+ return {
872
+ content: JSON.stringify({
873
+ created: true,
874
+ active: true,
875
+ object: result.name,
876
+ type: result.type,
877
+ next_step: 'use_in_table_field',
878
+ }),
879
+ };
880
+ },
881
+ });
882
+ // ── sap_create_data_element ──────────────────────────────────────────────────
883
+ //
884
+ // Signature: createDataElement(spec, transport?) → Promise<DdicCreateResult>
885
+ // AdtClient creates → activates → verifies in one call (Rule 10 internal).
886
+ // No snapshot needed: new object has no prior source.
887
+ registerTool({
888
+ name: 'sap_create_data_element',
889
+ description: 'Create a DDIC data element (DTEL) bound to an existing domain, then '
890
+ + 'activate and verify it. Requires approval_id. No snapshot needed (new object). '
891
+ + 'Use the data element as a table/structure field type.',
892
+ isMutating: true,
893
+ input_schema: {
894
+ type: 'object',
895
+ properties: {
896
+ name: { type: 'string', description: 'Data element name (Z*/Y*)' },
897
+ description: { type: 'string', description: 'Short description' },
898
+ package: { type: 'string', description: 'Development package (Z*/Y* or $TMP)' },
899
+ domainName: { type: 'string', description: 'Referenced domain name' },
900
+ dataType: { type: 'string', description: "Referenced domain's data type (CHAR/NUMC/CURR/…)" },
901
+ length: { type: 'number', description: 'Domain length' },
902
+ decimals: { type: 'number', description: 'Decimal places (optional)' },
903
+ labels: {
904
+ type: 'object',
905
+ description: 'Field labels',
906
+ properties: {
907
+ short: { type: 'string', description: 'Short label' },
908
+ medium: { type: 'string', description: 'Medium label' },
909
+ long: { type: 'string', description: 'Long label' },
910
+ heading: { type: 'string', description: 'Heading label' },
911
+ },
912
+ required: ['short', 'medium', 'long', 'heading'],
913
+ },
914
+ searchHelp: { type: 'string', description: 'Attached search help name (optional)' },
915
+ searchHelpParameter: { type: 'string', description: 'Search help parameter (optional)' },
916
+ transport: { type: 'string', description: 'Target transport (optional)' },
917
+ approval_id: { type: 'string', description: 'JWT from request_approval' },
918
+ },
919
+ required: ['name', 'description', 'package', 'domainName', 'dataType', 'length', 'labels', 'approval_id'],
920
+ },
921
+ handler: async (args, ctx) => {
922
+ // ── Gate 1: Approval ────────────────────────────────────────────────────
923
+ const verify = await verifyAndSpendApprovalId(args.approval_id, args.name, 'create');
924
+ if (!verify.ok) {
925
+ return {
926
+ content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: verify.reason }),
927
+ is_error: true,
928
+ };
929
+ }
930
+ const toolUseId = ctx.toolUseId ?? crypto.randomUUID();
931
+ const startedAt = Date.now();
932
+ // No snapshot — new object has no prior source.
933
+ // ── Rule 9 conflict warning + 3-tier transport fallback ──────────────────
934
+ {
935
+ const sessionTransport = ctx.currentTransport?.get() ?? null;
936
+ if (args.transport && sessionTransport && args.transport !== sessionTransport) {
937
+ ctx.chunkEmitter?.emit('warn', `LLM passed transport=${args.transport} but session current-transport=${sessionTransport}`);
938
+ }
939
+ }
940
+ const effectiveTransport = args.transport ?? ctx.currentTransport?.get() ?? undefined;
941
+ // ── WAL: record pending entry ────────────────────────────────────────────
942
+ await appendPending(ctx.session, {
943
+ tool_use_id: toolUseId,
944
+ tool: 'sap_create_data_element',
945
+ args: { name: args.name, type: 'DTEL', package: args.package },
946
+ started_at: new Date().toISOString(),
947
+ });
948
+ // ── Build spec (omit undefined optionals) ────────────────────────────────
949
+ const spec = {
950
+ name: args.name,
951
+ description: args.description,
952
+ package: args.package,
953
+ domainName: args.domainName,
954
+ dataType: args.dataType,
955
+ length: args.length,
956
+ labels: args.labels,
957
+ };
958
+ if (args.decimals !== undefined)
959
+ spec.decimals = args.decimals;
960
+ if (args.searchHelp !== undefined)
961
+ spec.searchHelp = args.searchHelp;
962
+ if (args.searchHelpParameter !== undefined)
963
+ spec.searchHelpParameter = args.searchHelpParameter;
964
+ // ── Create → activate → verify (AdtClient does all three) ────────────────
965
+ let result;
966
+ try {
967
+ result = await ctx.adt.createDataElement(spec, effectiveTransport);
968
+ await auditLog('createObject', 'DTEL/DE', args.name, 'success', Date.now() - startedAt);
969
+ }
970
+ catch (err) {
971
+ const errStr = String(err);
972
+ await finalizeToolCall(ctx.session, toolUseId, errStr, true, Date.now() - startedAt);
973
+ return {
974
+ content: JSON.stringify({ error: 'create_failed', detail: errStr }),
975
+ is_error: true,
976
+ };
977
+ }
978
+ if (!result.created) {
979
+ await finalizeToolCall(ctx.session, toolUseId, JSON.stringify(result), true, Date.now() - startedAt);
980
+ return {
981
+ content: JSON.stringify({ error: 'create_failed', detail: result.error }),
982
+ is_error: true,
983
+ };
984
+ }
985
+ if (!result.active) {
986
+ // Rule 10: created but did not activate — surface as an error.
987
+ await finalizeToolCall(ctx.session, toolUseId, JSON.stringify(result), true, Date.now() - startedAt);
988
+ return {
989
+ content: JSON.stringify({ error: 'activation_failed', object: result.name, detail: result.error }),
990
+ is_error: true,
991
+ };
992
+ }
993
+ await finalizeToolCall(ctx.session, toolUseId, JSON.stringify(result), false, Date.now() - startedAt);
994
+ return {
995
+ content: JSON.stringify({
996
+ created: true,
997
+ active: true,
998
+ object: result.name,
999
+ type: result.type,
1000
+ next_step: 'use_in_table_field',
1001
+ }),
1002
+ };
1003
+ },
1004
+ });
519
1005
  // ── sap_activate ─────────────────────────────────────────────────────────────
520
1006
  //
521
1007
  // Signature: activate(type, name) → Promise<ActivationResult>
@@ -565,7 +1051,7 @@ registerTool({
565
1051
  is_error: true,
566
1052
  };
567
1053
  }
568
- const toolUseId = crypto.randomUUID();
1054
+ const toolUseId = ctx.toolUseId ?? crypto.randomUUID();
569
1055
  const startedAt = Date.now();
570
1056
  // ── WAL: record pending entry ────────────────────────────────────────────
571
1057
  await appendPending(ctx.session, {
@@ -700,6 +1186,29 @@ registerTool({
700
1186
  return { content: JSON.stringify(result) };
701
1187
  },
702
1188
  });
1189
+ // ── sap_service_binding_unpublish ─────────────────────────────────────────────
1190
+ //
1191
+ // Unpublish a service binding (inverse of publish). REQUIRED before deleting a
1192
+ // published binding — SAP returns HTTP 412 "… is published" otherwise (Bug #6).
1193
+ // Signature: serviceBindingUnpublish(name) → Promise<{ status: number; error?: string }>
1194
+ // NOTE: the unpublishjobs endpoint is inferred-not-yet-live-verified (VPN down).
1195
+ registerTool({
1196
+ name: 'sap_service_binding_unpublish',
1197
+ description: 'Unpublish an ADT service binding so its OData endpoint is removed. '
1198
+ + 'Required before deleting a published binding (SAP returns HTTP 412 "is published" otherwise).',
1199
+ isMutating: true,
1200
+ input_schema: {
1201
+ type: 'object',
1202
+ properties: {
1203
+ name: { type: 'string', description: 'Service binding name (e.g. ZSALES_UI_V4)' },
1204
+ },
1205
+ required: ['name'],
1206
+ },
1207
+ handler: async (args, ctx) => {
1208
+ const result = await ctx.adt.serviceBindingUnpublish(args.name);
1209
+ return { content: JSON.stringify(result) };
1210
+ },
1211
+ });
703
1212
  // ── sap_message_maintain (STUB) ───────────────────────────────────────────────
704
1213
  //
705
1214
  // STUB — MSAG message-class maintenance requires XML message-class-format