@cspeach/cli 1.1.39 → 1.1.41

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.
@@ -22,156 +22,82 @@
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';
26
25
  import { registerTool } from './index.js';
26
+ import { byObjectType } from '../sap/profile-gate.js';
27
27
  import { verifyAndSpendApprovalId } from '../approvals/jwt.js';
28
- import { grantClaimMismatch, grantActivateMismatch, grantOwningTransportMismatch, grantOwningTransportDetail } from '../approvals/grant-claims.js';
29
- import { autoSnapshot, autoSnapshotClassInclude, autoSnapshotMessageClass } from './snapshot.js';
30
- import { includeSnapshotType } from './include-snapshot.js';
31
- import { buildWriteChainLine } from '../renderer/verify-chain.js';
32
- import { buildChangeCard } from '../renderer/change-card.js';
28
+ import { grantActivateMismatch } from '../approvals/grant-claims.js';
33
29
  import { getLook } from '../renderer/look.js';
34
- import { getEffectiveBodyWidth } from '../renderer/tty.js';
35
- import { verifySyntax, verifyActive } from './verify.js';
30
+ import { verifyActive } from './verify.js';
36
31
  import { hasRecordedSyntaxErrorsByName, clearSyntaxErrorsAfterActivation } from './syntax-state.js';
37
32
  import { teamFixOffers, cspeachWritesThisSession } from '../register/offer.js';
38
- import { isJourneyActive } from '../journey/flow.js';
39
33
  import { appendPending, finalizeToolCall } from '../session/pending.js';
40
34
  import { checkTransportFit, reusedRequestRefusal } from './transport-fit-check.js';
41
35
  import { auditLog } from '@cspeach/sap-client';
42
36
  import { ERR } from '../errors/codes.js';
43
37
  import { interceptUpdateMethod, CancelledByUserError, } from './update-method-intercept.js';
44
- import { resolveWriteTransport } from './transport-resolution.js';
45
38
  // F-L2-1: statusline-only wire — records the transport a write ACTUALLY
46
39
  // stamped so the `tr` segment stops reading `none` for plan-supplied
47
40
  // transports. Never touches the session transport pointer.
48
41
  import { noteWriteTransport, setCurrentTransport } from '../repl/current-transport.js';
49
42
  import { markTransportCreatedThisSession } from './transport.js';
50
- import { presentSafetyConfirmation } from '../repl/safety-confirm.js';
51
- import { isSafetyConfirmEnabled } from '../repl/safety-mode-state.js';
52
43
  import { hasManualLocks, noteManualLock, noteManualUnlock } from '../sap/session-end.js';
53
- // ── Transcript visibility helpers (D16-display + tour slide-7, 2026-06-12) ──
54
- //
55
- // Rule 7 auto-snapshots always fired but the transcript never said so
56
- // (battery D16-display). And the tour shows writes as
57
- // "① snapshot ✓ ② written ✓ ③ syntax ✓ ④ activated ✓" while real
58
- // transcripts showed an opaque tool row. Both lines below are HUMAN-stream
59
- // only (ctx.chunkEmitter) — tool RESULT payloads the model sees are
60
- // unchanged, and subagent ctxs (no chunkEmitter) stay silent.
61
- /** Dim one-liner when the Rule 7 auto-snapshot actually captured source. */
62
- function emitSnapshotNotice(ctx, snap, type, name) {
63
- if (snap?.taken && snap.entry) {
64
- ctx.chunkEmitter?.emit('info', `snapshot ✓ ${type} ${name} ${snap.entry.timestamp}`);
65
- }
66
- }
67
- /** Distinct LOUD note when the write proceeds against a fresh/never-activated
68
- * shell that has no committed active version — nothing to snapshot (Rule 7 is
69
- * satisfied because there is no prior source to protect). Kept separate from
70
- * the 'new_object' (genuine 404) path so the transcript is honest about which
71
- * case it was — this is the RAP behaviour-pool ZBP first-write. */
72
- function emitNewShellNote(ctx, type, name) {
73
- ctx.chunkEmitter?.emit('warn', `${type} ${name}: new/empty shell — no active version, no prior source to snapshot; proceeding with first write`);
74
- }
75
- /** Map the snapshot-gate outcome onto the chain line's honest states.
76
- * `undefined` covers the best-effort gates whose failure was swallowed.
77
- * 'object_not_found' is a genuine ADT 404 only; any other read failure maps
78
- * to 'source_read_failed' (+ short detail) — never to "(new object)".
79
- * 'new_shell' (exists but no active version — nothing to save) reuses the
80
- * 'new_object' chain glyph; the honest which-case distinction is carried by
81
- * the LOUD emitNewShellNote line, not the compact chain. */
82
- function snapshotChainState(snap) {
83
- if (snap?.taken)
84
- return { snapshot: 'taken' };
85
- if (snap?.reason === 'object_not_found' || snap?.reason === 'new_shell') {
86
- return { snapshot: 'new_object' };
87
- }
88
- if (snap?.reason === 'source_read_failed') {
89
- return { snapshot: 'source_read_failed', snapshotDetail: snap.detail };
90
- }
91
- return { snapshot: 'best_effort_failed' };
92
- }
93
- /**
94
- * Review r7 I-3 — sap-client 0.1.15's setSource/updateMethod run their own
95
- * (correct) syntax check and, on errors, return a 2xx writeStatus with
96
- * `syntaxResult.hasErrors` and `error: 'Syntax check found errors —
97
- * activation skipped'`. The source IS written (an inactive version exists):
98
- * that is written-with-errors — the normal Rule 10 gate below reports it —
99
- * never write_failed.
100
- */
101
- function writtenWithSyntaxErrors(r) {
102
- return r.writeStatus !== undefined && r.writeStatus >= 200 && r.writeStatus < 300 && r.syntaxResult?.hasErrors === true;
103
- }
104
- async function verifyWriteActivation(ctx, r, name, type) {
105
- const a = r.activationResult;
106
- if (!a)
107
- return undefined;
108
- const errors = (a.messages ?? []).filter((m) => m.severity === 'error').map((m) => m.shortText ?? '').filter(Boolean).slice(0, 5);
109
- if (!a.success)
110
- return { success: false, verified: false, errors };
111
- const v = await verifyActive(ctx.adt, name, type);
112
- if (v.verifyError !== undefined)
113
- return { success: true, verified: false, errors, verifyError: v.verifyError };
114
- return v.active ? { success: true, verified: true, errors } : { success: true, verified: false, errors, stillInactive: true };
115
- }
116
- /** Round 8 item 5 + A-I1 — the change-frame state from the verified activation. */
117
- function chainActivation(w) {
118
- if (!w)
119
- return undefined;
120
- if (!w.success)
121
- return { state: 'failed', detail: w.errors[0] };
122
- if (w.verified)
123
- return { state: 'active' };
124
- return w.stillInactive ? { state: 'still_inactive' } : { state: 'unverified' };
125
- }
126
- /**
127
- * 1.1.31 — the write itself activated and verified an object of the joined team
128
- * project: CSPeach wrote it, so the fix is recorded for the developer (rehearsal
129
- * 2026-10-07: the refactor's write activated the class; no separate sap_activate
130
- * ran, so the hook in sap_activate alone never fired).
131
- */
132
- /** Guided clean-core journey on: the fix lines in solo words (register/offer.ts). */
133
- const journeyWording = (ctx) => (isJourneyActive(ctx.session) ? { wording: 'journey' } : {});
134
- function recordOwnFixAfterWrite(ctx, type, name, transport, w) {
135
- if (!w?.success || !w.verified)
136
- return;
137
- const written = new Map([[String(name).trim().toUpperCase(), String(transport ?? '').trim().toUpperCase()]]);
138
- for (const line of teamFixOffers([{ type, name }], { written, ...journeyWording(ctx) }))
139
- ctx.chunkEmitter?.emit('info', line);
140
- }
141
- function activatedByWrite(w) {
142
- if (!w)
143
- return {};
144
- return { activated_by_write: { success: w.success, verified: w.verified, errors: w.errors } };
145
- }
146
- /** A-I1 minor — after a verified activation on write, say so instead of "activation_allowed". */
147
- function nextStepAfterWrite(syntaxOk, w) {
148
- if (!syntaxOk)
149
- return 'activation_blocked_until_syntax_errors_fixed';
150
- if (w?.success && w.verified)
151
- return 'already activated by the write — verify with sap_inactive_objects before reporting it active';
152
- return 'activation_allowed';
153
- }
154
- /** Compact ①②③④ pipeline summary — derived from the real gate results.
155
- * Panels look: a change card (same honest chain in its footer). */
156
- function emitWriteChain(ctx, input) {
157
- const text = getLook() === 'panels' && input.card
158
- ? buildChangeCard(input, getEffectiveBodyWidth())
159
- : buildWriteChainLine(input);
160
- ctx.chunkEmitter?.emit('chunk', text + '\n');
161
- }
162
- /** Old source for the change card: ONLY from a snapshot actually taken. */
163
- function snapshotSource(snap) {
164
- if (!snap?.taken || !snap.entry?.filePath)
165
- return undefined;
166
- try {
167
- return readFileSync(snap.entry.filePath, 'utf8');
168
- }
169
- catch {
170
- return undefined;
171
- }
172
- }
44
+ import { snapshotChainState, writtenWithSyntaxErrors, chainActivation, journeyWording, activatedByWrite, nextStepAfterWrite, snapshotSource, writeToolHandler, } from './write-pipeline.js';
45
+ const SET_SOURCE_SPEC = {
46
+ tool: 'sap_set_source',
47
+ approval: {
48
+ object: (a) => a.name,
49
+ op: 'modify',
50
+ claims: (a, ctx) => ({ type: a.type, transport: a.transport ?? ctx.currentTransport?.get() ?? null }),
51
+ },
52
+ transport: { mode: 'resolve', type: (a) => a.type, name: (a) => a.name, grantMismatch: true },
53
+ snapshot: { kind: 'source', hard: true, type: (a) => a.type, name: (a) => a.name },
54
+ safetyConfirm: (a, transport) => ({
55
+ rule: 'RULE_7_SNAPSHOT_BEFORE_WRITE',
56
+ op: 'set_source',
57
+ object: { type: a.type, name: a.name },
58
+ what_will_happen: 'Overwrites full object source; snapshot is taken first.',
59
+ rollback_available: true,
60
+ transport,
61
+ args: { ...a },
62
+ }),
63
+ walArgs: (a) => ({ name: a.name, type: a.type }),
64
+ call: (a, ctx, pre) => ctx.adt.setSource(a.type, a.name, a.source, pre.transport),
65
+ errorCode: 'write_failed',
66
+ landed: (r) => (r.error && !writtenWithSyntaxErrors(r)) || (r.writeStatus !== undefined && r.writeStatus >= 400)
67
+ ? { ok: false, detail: r.error ?? `Write returned HTTP ${r.writeStatus} but did not throw`, extra: { write_status: r.writeStatus } }
68
+ : { ok: true },
69
+ audit: { method: 'setSource', type: (a) => a.type, name: (a) => a.name },
70
+ verify: { syntax: true, activation: true },
71
+ chain: (a, pre, syntax, act) => {
72
+ // Old source is read (once) only for the panels card; legacy reads nothing extra.
73
+ const oldSource = getLook() === 'panels' ? snapshotSource(pre.snapshot) : undefined;
74
+ return {
75
+ ...snapshotChainState(pre.snapshot),
76
+ written: true,
77
+ syntax: { ok: syntax.ok, errors: syntax.errors },
78
+ activation: chainActivation(act),
79
+ card: {
80
+ object: a.name,
81
+ what: 'source',
82
+ transport: pre.transport,
83
+ oldSource,
84
+ newSource: oldSource !== undefined ? a.source : undefined,
85
+ },
86
+ };
87
+ },
88
+ envelope: (_a, _r, pre, syntax, act) => ({
89
+ written: true,
90
+ syntax_ok: syntax.ok,
91
+ errors: syntax.errors,
92
+ warnings: syntax.warnings,
93
+ ...activatedByWrite(act),
94
+ ...(pre.owning ? { transport_used: pre.owning, transport_note: pre.note } : {}),
95
+ next_step: nextStepAfterWrite(syntax.ok, act),
96
+ }),
97
+ };
173
98
  registerTool({
174
99
  name: 'sap_set_source',
100
+ requires: byObjectType,
175
101
  description: 'Overwrite the full source of an ABAP object. Requires approval_id. '
176
102
  + 'Use for class-definition changes, new methods, or any change not confined to '
177
103
  + 'a single method body. For method-body-only edits use sap_update_method (Rule 7a). '
@@ -189,175 +115,89 @@ registerTool({
189
115
  },
190
116
  required: ['name', 'type', 'source', 'approval_id'],
191
117
  },
192
- handler: async (args, ctx) => {
193
- // ── Gate 1: Approval ────────────────────────────────────────────────────
194
- const verify = await verifyAndSpendApprovalId(args.approval_id, args.name, 'modify');
195
- if (!verify.ok) {
196
- return {
197
- content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: verify.reason }),
198
- is_error: true,
199
- };
200
- }
201
- // 2026-10-04 — a plan-grant id holds the write to its claims (type, package, transport).
202
- {
203
- const claim = grantClaimMismatch(verify.payload, { type: args.type, transport: args.transport ?? ctx.currentTransport?.get() ?? null });
204
- if (claim)
205
- return { content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: claim }), is_error: true };
206
- }
207
- const toolUseId = ctx.toolUseId ?? crypto.randomUUID();
208
- const startedAt = Date.now();
209
- // ── Rule 9 conflict warning ──────────────────────────────────────────────
210
- // Emit a warning if the LLM passed an explicit transport that differs from
211
- // the session current-transport. Prefer LLM intent (explicit caller); this
212
- // is informational only and does NOT block the operation.
213
- {
214
- const sessionTransport = ctx.currentTransport?.get() ?? null;
215
- if (args.transport && sessionTransport && args.transport !== sessionTransport) {
216
- ctx.chunkEmitter?.emit('warn', `LLM passed transport=${args.transport} but session current-transport=${sessionTransport}`);
217
- }
218
- }
219
- // ── Rule 9 owning-transport check (D15/D20) ──────────────────────────────
220
- // If the object is already locked in an open request, write under THAT
221
- // request — any other transport is a guaranteed CTS lock conflict.
222
- const resolved = await resolveWriteTransport(ctx, args.type, args.name, args.transport);
223
- // 2026-10-04 — a plan-grant id never writes into a transport other than the grant's.
224
- if (grantOwningTransportMismatch(verify.payload, resolved.owning)) {
225
- return {
226
- content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: 'transport_mismatch', detail: grantOwningTransportDetail(args.name, resolved.owning) }),
227
- is_error: true,
228
- };
229
- }
230
- // ── Gate 2: Snapshot (Rule 7) — HARD gate ────────────────────────────────
231
- // Only a genuine 404 (new object) may proceed snapshot-less. A read
232
- // failure on a possibly-EXISTING object means no recovery copy → block.
233
- let snapOutcome;
234
- try {
235
- snapOutcome = await autoSnapshot(args.name, args.type, ctx.adt);
236
- }
237
- catch (err) {
238
- return {
239
- content: JSON.stringify({ error: ERR.SNAPSHOT_FAILED, detail: String(err) }),
240
- is_error: true,
241
- };
242
- }
243
- if (snapOutcome?.reason === 'source_read_failed') {
244
- return {
245
- content: JSON.stringify({
246
- error: ERR.SNAPSHOT_FAILED,
247
- reason: 'source_read_failed',
248
- detail: snapOutcome.detail,
249
- recovery: 'Pre-write source read failed (non-404) and no committed active version could be confirmed — the object may exist with source we could not read, so no snapshot was possible. If this is a live connection/auth problem, fix it and retry. If this is a freshly-created shell that got edit-locked by an earlier aborted write, recover it directly: unlock it (or activate the empty shell, then delete it) and re-run the create+write. Do not bypass Rule 7 by writing over source you never read.',
250
- }),
251
- is_error: true,
252
- };
253
- }
254
- // Fresh/never-activated shell (no active version → nothing to snapshot):
255
- // proceed like a new object, but say so LOUDLY and distinctly.
256
- if (snapOutcome?.reason === 'new_shell')
257
- emitNewShellNote(ctx, args.type, args.name);
258
- emitSnapshotNotice(ctx, snapOutcome, args.type, args.name);
259
- // ── Gate 3: Safety confirmation (Rule 7) ─────────────────────────────────
260
- if (isSafetyConfirmEnabled()) {
261
- const r = await presentSafetyConfirmation({
262
- rule: 'RULE_7_SNAPSHOT_BEFORE_WRITE',
263
- op: 'set_source',
264
- object: { type: args.type, name: args.name },
265
- what_will_happen: 'Overwrites full object source; snapshot is taken first.',
266
- rollback_available: true,
267
- transport: resolved.transport ?? ctx.currentTransport?.get() ?? '',
268
- args: { ...args },
269
- });
270
- if (!r.confirmed) {
271
- return {
272
- content: JSON.stringify({ error: 'cancelled_by_user', reason: r.reason }),
273
- is_error: true,
274
- };
275
- }
118
+ handler: writeToolHandler(SET_SOURCE_SPEC),
119
+ });
120
+ /** Rule 7 recovery text in the class wording (sap_update_method always targets a class). */
121
+ const CLASS_READ_RECOVERY = 'Pre-write source read failed (non-404) and no committed active version could be confirmed — the class may exist with source we could not read, so no snapshot was possible. If this is a live connection/auth problem, fix it and retry. If this is a freshly-created class shell that got edit-locked by an earlier aborted write, recover it directly: unlock it (or activate the empty shell, then delete it) and re-run the create+write. Do not bypass Rule 7 by writing over source you never read.';
122
+ const UPDATE_METHOD_SPEC = {
123
+ tool: 'sap_update_method',
124
+ approval: {
125
+ object: (a) => a.class_name,
126
+ op: 'modify',
127
+ claims: (a, ctx) => ({ type: 'CLAS', transport: a.transport ?? ctx.currentTransport?.get() ?? null }),
128
+ },
129
+ transport: { mode: 'resolve', type: () => 'CLAS', name: (a) => a.class_name, grantMismatch: true },
130
+ // sap_update_method always targets an EXISTING class, so a read failure
131
+ // (non-404) means no recovery copy → block. (A genuine 404 falls through
132
+ // here but the preview gate fails on the same missing class.)
133
+ snapshot: { kind: 'source', hard: true, type: () => 'CLAS', name: (a) => a.class_name, recovery: CLASS_READ_RECOVERY },
134
+ // Gate 3: diff preview & confirmation (Rule 7a).
135
+ preGate: async (a, ctx, pre) => {
136
+ if (!ctx.previewHook) {
137
+ return { error: { error: 'preview_hook_unavailable', detail: 'cannot confirm method body changes in this execution context' } };
276
138
  }
277
- // ── WAL: record pending entry ────────────────────────────────────────────
278
- await appendPending(ctx.session, {
279
- tool_use_id: toolUseId,
280
- tool: 'sap_set_source',
281
- args: { name: args.name, type: args.type },
282
- started_at: new Date().toISOString(),
283
- });
284
- // ── Write ────────────────────────────────────────────────────────────────
285
- // setSource(type, name, source, transport?) — transport is optional.
286
- //
287
- // CRITICAL: AdtClient.setSource only THROWS for catastrophic errors (network
288
- // down, all retries exhausted). For HTTP 4xx responses (e.g., the second PUT
289
- // after 423-recovery still failing) it RETURNS a WriteResult with
290
- // writeStatus >= 400 and `error: '...'`. The handler MUST check the return
291
- // value — without it, we silently report success on a failed write.
292
- // Regression evidence + diagnosis: memory/project_phase3_lite_regression.md.
293
- let writeResult;
139
+ const minimal = {
140
+ getSource: ({ type, name }) => ctx.adt.getSource(type, name),
141
+ };
142
+ const call = {
143
+ className: a.class_name,
144
+ methodName: a.method_name,
145
+ methodSource: a.method_source,
146
+ transport: pre.transport ?? ctx.currentTransport?.get() ?? '',
147
+ };
294
148
  try {
295
- writeResult = await ctx.adt.setSource(args.type, args.name, args.source, resolved.transport);
149
+ await interceptUpdateMethod(minimal, call, ctx.previewHook);
296
150
  }
297
151
  catch (err) {
298
- const errStr = String(err);
299
- await finalizeToolCall(ctx.session, toolUseId, errStr, true, Date.now() - startedAt);
300
- return {
301
- content: JSON.stringify({ error: 'write_failed', detail: errStr }),
302
- is_error: true,
303
- };
152
+ if (err instanceof CancelledByUserError)
153
+ return { error: { error: 'cancelled_by_user', detail: err.message } };
154
+ return { error: { error: 'preview_failed', detail: String(err) } };
304
155
  }
305
- if ((writeResult.error && !writtenWithSyntaxErrors(writeResult)) || (writeResult.writeStatus !== undefined && writeResult.writeStatus >= 400)) {
306
- const detail = writeResult.error ?? `Write returned HTTP ${writeResult.writeStatus} but did not throw`;
307
- await finalizeToolCall(ctx.session, toolUseId, detail, true, Date.now() - startedAt);
156
+ return { ok: true };
157
+ },
158
+ walArgs: (a) => ({ class_name: a.class_name, method_name: a.method_name }),
159
+ // Same return semantics as setSource — RETURNS a WriteResult on HTTP-level failure.
160
+ call: (a, ctx, pre) => ctx.adt.updateMethod(a.class_name, a.method_name, a.method_source, pre.transport),
161
+ // Bug 13 (2026-05-15): updateMethod throws SapError(409) when the class has
162
+ // pending inactive changes (split state) — a recoverable envelope, so the
163
+ // model suggests activating / discarding the inactive version first.
164
+ onThrow: (err, a) => {
165
+ const errStr = String(err);
166
+ const httpStatus = err?.httpStatus;
167
+ if (httpStatus === 409 && /pending inactive/i.test(errStr)) {
308
168
  return {
309
- content: JSON.stringify({
310
- error: 'write_failed',
311
- detail,
312
- write_status: writeResult.writeStatus,
313
- }),
314
- is_error: true,
169
+ error: 'split_state',
170
+ detail: errStr,
171
+ class_name: a.class_name,
172
+ recovery: 'Activate (sap_activate) or discard the inactive version of this class, then retry sap_update_method.',
315
173
  };
316
174
  }
317
- await auditLog('setSource', args.type, args.name, 'success', Date.now() - startedAt);
318
- noteWriteTransport(resolved.transport); // F-L2-1 statusline wire
319
- // ── Gate 4: Verify — syntax check (Rule 10) ──────────────────────────────
320
- const syntax = await verifySyntax(ctx.adt, args.name, args.type);
321
- await finalizeToolCall(ctx.session, toolUseId, JSON.stringify({ written: true, syntax }), false, Date.now() - startedAt);
322
- const writeActivation = await verifyWriteActivation(ctx, writeResult, args.name, args.type);
323
- // Tour slide-7 chain — derived from the gates that actually ran.
324
- // Old source is read (once) only for the panels card; legacy reads nothing extra.
325
- const oldSource = getLook() === 'panels' ? snapshotSource(snapOutcome) : undefined;
326
- emitWriteChain(ctx, {
327
- ...snapshotChainState(snapOutcome),
328
- written: true,
329
- syntax: { ok: syntax.ok, errors: syntax.errors },
330
- activation: chainActivation(writeActivation),
331
- card: {
332
- object: args.name,
333
- what: 'source',
334
- transport: resolved.transport,
335
- oldSource,
336
- newSource: oldSource !== undefined ? args.source : undefined,
337
- },
338
- });
339
- recordOwnFixAfterWrite(ctx, args.type, args.name, resolved.owning ?? resolved.transport, writeActivation);
340
- return {
341
- content: JSON.stringify({
342
- written: true,
343
- syntax_ok: syntax.ok,
344
- errors: syntax.errors,
345
- warnings: syntax.warnings,
346
- ...activatedByWrite(writeActivation),
347
- ...(resolved.owning ? { transport_used: resolved.owning, transport_note: resolved.note } : {}),
348
- next_step: nextStepAfterWrite(syntax.ok, writeActivation),
349
- }),
350
- };
175
+ return undefined;
351
176
  },
352
- });
353
- // ── sap_update_method ────────────────────────────────────────────────────────
354
- //
355
- // Rule 7a: method-body-only edits. Reads current source, replaces the named
356
- // method block in-memory, then delegates to setSource internally via
357
- // AdtClient.updateMethod. Does NOT rewrite the class definition.
358
- //
359
- // Signature: updateMethod(className, methodName, methodBody, transport?)
360
- // methodBody — the lines BETWEEN METHOD x. and ENDMETHOD. (no wrappers)
177
+ errorCode: 'update_failed',
178
+ landed: (r) => (r.error && !writtenWithSyntaxErrors(r)) || (r.writeStatus !== undefined && r.writeStatus >= 400)
179
+ ? { ok: false, detail: r.error ?? `Update returned HTTP ${r.writeStatus} but did not throw`, extra: { write_status: r.writeStatus } }
180
+ : { ok: true },
181
+ audit: { method: 'updateMethod', type: () => 'CLAS', name: (a) => a.class_name, detail: (a) => `method=${a.method_name}` },
182
+ verify: { syntax: true, activation: true },
183
+ walResult: (_a, _r, _pre, syntax) => ({ updated: true, syntax }),
184
+ chain: (a, pre, syntax, act) => ({
185
+ ...snapshotChainState(pre.snapshot),
186
+ written: true,
187
+ syntax: { ok: syntax.ok, errors: syntax.errors },
188
+ activation: chainActivation(act),
189
+ card: { object: a.class_name, what: `method ${a.method_name}`, transport: pre.transport },
190
+ }),
191
+ envelope: (_a, _r, pre, syntax, act) => ({
192
+ updated: true,
193
+ syntax_ok: syntax.ok,
194
+ errors: syntax.errors,
195
+ warnings: syntax.warnings,
196
+ ...activatedByWrite(act),
197
+ ...(pre.owning ? { transport_used: pre.owning, transport_note: pre.note } : {}),
198
+ next_step: nextStepAfterWrite(syntax.ok, act),
199
+ }),
200
+ };
361
201
  registerTool({
362
202
  name: 'sap_update_method',
363
203
  description: 'Replace the body of a single method in an existing class. '
@@ -378,195 +218,45 @@ registerTool({
378
218
  },
379
219
  required: ['class_name', 'method_name', 'method_source', 'approval_id'],
380
220
  },
381
- handler: async (args, ctx) => {
382
- // ── Gate 1: Approval ────────────────────────────────────────────────────
383
- const verify = await verifyAndSpendApprovalId(args.approval_id, args.class_name, 'modify');
384
- if (!verify.ok) {
385
- return {
386
- content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: verify.reason }),
387
- is_error: true,
388
- };
389
- }
390
- // 2026-10-04 — a plan-grant id holds the write to its claims (type, package, transport).
391
- {
392
- const claim = grantClaimMismatch(verify.payload, { type: 'CLAS', transport: args.transport ?? ctx.currentTransport?.get() ?? null });
393
- if (claim)
394
- return { content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: claim }), is_error: true };
395
- }
396
- const toolUseId = ctx.toolUseId ?? crypto.randomUUID();
397
- const startedAt = Date.now();
398
- // ── Rule 9 conflict warning ──────────────────────────────────────────────
399
- // Emit a warning if the LLM passed an explicit transport that differs from
400
- // the session current-transport. Prefer LLM intent (explicit caller); this
401
- // is informational only and does NOT block the operation.
402
- {
403
- const sessionTransport = ctx.currentTransport?.get() ?? null;
404
- if (args.transport && sessionTransport && args.transport !== sessionTransport) {
405
- ctx.chunkEmitter?.emit('warn', `LLM passed transport=${args.transport} but session current-transport=${sessionTransport}`);
406
- }
407
- }
408
- // ── Rule 9 owning-transport check (D15/D20) ──────────────────────────────
409
- const resolved = await resolveWriteTransport(ctx, 'CLAS', args.class_name, args.transport);
410
- // 2026-10-04 — a plan-grant id never writes into a transport other than the grant's.
411
- if (grantOwningTransportMismatch(verify.payload, resolved.owning)) {
412
- return {
413
- content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: 'transport_mismatch', detail: grantOwningTransportDetail(args.class_name, resolved.owning) }),
414
- is_error: true,
415
- };
416
- }
417
- // ── Gate 2: Snapshot (Rule 7) — HARD gate ────────────────────────────────
418
- // sap_update_method always targets an EXISTING class, so a read failure
419
- // (non-404) means no recovery copy → block. (A genuine 404 falls through
420
- // here but the preview gate below fails on the same missing class.)
421
- let snapOutcome;
422
- try {
423
- snapOutcome = await autoSnapshot(args.class_name, 'CLAS', ctx.adt);
424
- }
425
- catch (err) {
426
- return {
427
- content: JSON.stringify({ error: ERR.SNAPSHOT_FAILED, detail: String(err) }),
428
- is_error: true,
429
- };
430
- }
431
- if (snapOutcome?.reason === 'source_read_failed') {
432
- return {
433
- content: JSON.stringify({
434
- error: ERR.SNAPSHOT_FAILED,
435
- reason: 'source_read_failed',
436
- detail: snapOutcome.detail,
437
- recovery: 'Pre-write source read failed (non-404) and no committed active version could be confirmed — the class may exist with source we could not read, so no snapshot was possible. If this is a live connection/auth problem, fix it and retry. If this is a freshly-created class shell that got edit-locked by an earlier aborted write, recover it directly: unlock it (or activate the empty shell, then delete it) and re-run the create+write. Do not bypass Rule 7 by writing over source you never read.',
438
- }),
439
- is_error: true,
440
- };
441
- }
442
- // Fresh/never-activated class shell (no active version → nothing to
443
- // snapshot): proceed like a new object, but say so LOUDLY and distinctly.
444
- if (snapOutcome?.reason === 'new_shell')
445
- emitNewShellNote(ctx, 'CLAS', args.class_name);
446
- emitSnapshotNotice(ctx, snapOutcome, 'CLAS', args.class_name);
447
- // ── Gate 3: Diff preview & confirmation (Rule 7a) ────────────────────────
448
- if (!ctx.previewHook) {
449
- return {
450
- content: JSON.stringify({
451
- error: 'preview_hook_unavailable',
452
- detail: 'cannot confirm method body changes in this execution context',
453
- }),
454
- is_error: true,
455
- };
456
- }
457
- const minimal = {
458
- getSource: ({ type, name }) => ctx.adt.getSource(type, name),
459
- };
460
- const call = {
461
- className: args.class_name,
462
- methodName: args.method_name,
463
- methodSource: args.method_source,
464
- transport: resolved.transport ?? ctx.currentTransport?.get() ?? '',
465
- };
466
- try {
467
- await interceptUpdateMethod(minimal, call, ctx.previewHook);
468
- }
469
- catch (err) {
470
- if (err instanceof CancelledByUserError) {
471
- return {
472
- content: JSON.stringify({ error: 'cancelled_by_user', detail: err.message }),
473
- is_error: true,
474
- };
475
- }
476
- return {
477
- content: JSON.stringify({ error: 'preview_failed', detail: String(err) }),
478
- is_error: true,
479
- };
480
- }
481
- // ── WAL: record pending entry ────────────────────────────────────────────
482
- await appendPending(ctx.session, {
483
- tool_use_id: toolUseId,
484
- tool: 'sap_update_method',
485
- args: { class_name: args.class_name, method_name: args.method_name },
486
- started_at: new Date().toISOString(),
487
- });
488
- // ── Write ────────────────────────────────────────────────────────────────
489
- // updateMethod(className, methodName, methodBody, transport?). Same return
490
- // semantics as setSource — RETURNS a WriteResult on HTTP-level failure
491
- // rather than throwing. Must check writeStatus / error on the return value.
492
- //
493
- // Bug 13 (2026-05-15): AdtClient.updateMethod now throws SapError(409)
494
- // when the class has pending inactive changes (split state). Surface that
495
- // as a recoverable `split_state` envelope so the model can suggest the
496
- // user activate / discard the inactive version before retrying — without
497
- // silently overwriting the inactive-only declarations.
498
- let updateResult;
499
- try {
500
- updateResult = await ctx.adt.updateMethod(args.class_name, args.method_name, args.method_source, resolved.transport);
501
- }
502
- catch (err) {
503
- const errStr = String(err);
504
- const httpStatus = err?.httpStatus;
505
- if (httpStatus === 409 && /pending inactive/i.test(errStr)) {
506
- await finalizeToolCall(ctx.session, toolUseId, errStr, true, Date.now() - startedAt);
507
- return {
508
- content: JSON.stringify({
509
- error: 'split_state',
510
- detail: errStr,
511
- class_name: args.class_name,
512
- recovery: 'Activate (sap_activate) or discard the inactive version of this class, then retry sap_update_method.',
513
- }),
514
- is_error: true,
515
- };
516
- }
517
- await finalizeToolCall(ctx.session, toolUseId, errStr, true, Date.now() - startedAt);
518
- return {
519
- content: JSON.stringify({ error: 'update_failed', detail: errStr }),
520
- is_error: true,
521
- };
522
- }
523
- if ((updateResult.error && !writtenWithSyntaxErrors(updateResult)) || (updateResult.writeStatus !== undefined && updateResult.writeStatus >= 400)) {
524
- const detail = updateResult.error ?? `Update returned HTTP ${updateResult.writeStatus} but did not throw`;
525
- await finalizeToolCall(ctx.session, toolUseId, detail, true, Date.now() - startedAt);
526
- return {
527
- content: JSON.stringify({
528
- error: 'update_failed',
529
- detail,
530
- write_status: updateResult.writeStatus,
531
- }),
532
- is_error: true,
533
- };
534
- }
535
- await auditLog('updateMethod', 'CLAS', args.class_name, 'success', Date.now() - startedAt, `method=${args.method_name}`);
536
- noteWriteTransport(resolved.transport); // F-L2-1 statusline wire
537
- // ── Gate 4: Verify — syntax check (Rule 10) ──────────────────────────────
538
- const syntax = await verifySyntax(ctx.adt, args.class_name, 'CLAS');
539
- await finalizeToolCall(ctx.session, toolUseId, JSON.stringify({ updated: true, syntax }), false, Date.now() - startedAt);
540
- const writeActivation = await verifyWriteActivation(ctx, updateResult, args.class_name, 'CLAS');
541
- // Tour slide-7 chain — derived from the gates that actually ran.
542
- emitWriteChain(ctx, {
543
- ...snapshotChainState(snapOutcome),
544
- written: true,
545
- syntax: { ok: syntax.ok, errors: syntax.errors },
546
- activation: chainActivation(writeActivation),
547
- card: { object: args.class_name, what: `method ${args.method_name}`, transport: resolved.transport },
548
- });
549
- recordOwnFixAfterWrite(ctx, 'CLAS', args.class_name, resolved.owning ?? resolved.transport, writeActivation);
221
+ handler: writeToolHandler(UPDATE_METHOD_SPEC),
222
+ });
223
+ const DELETE_OBJECT_SPEC = {
224
+ tool: 'sap_delete_object',
225
+ approval: { object: (a) => a.name, op: 'delete' },
226
+ transport: { mode: 'resolve', type: (a) => a.type, name: (a) => a.name, grantMismatch: false },
227
+ // Best-effort: the object may be a shell with no readable source.
228
+ snapshot: { kind: 'source', hard: false, type: (a) => a.type, name: (a) => a.name },
229
+ // Rule 7 — delete is irreversible-ish.
230
+ safetyConfirm: (a, transport) => ({
231
+ rule: 'RULE_7_SNAPSHOT_BEFORE_WRITE',
232
+ op: 'delete_object',
233
+ object: { type: a.type, name: a.name },
234
+ what_will_happen: 'Deletes object from SAP; snapshot is taken first; recovery via sap_snapshot_read + sap_set_source (LLM composes the rollback).',
235
+ rollback_available: true,
236
+ transport,
237
+ args: { ...a },
238
+ }),
239
+ walArgs: (a) => ({ name: a.name, type: a.type }),
240
+ // Same return semantics as setSource — RETURNS rather than throws on HTTP 4xx.
241
+ call: (a, ctx, pre) => ctx.adt.deleteObject(a.type, a.name, pre.transport),
242
+ errorCode: 'delete_failed',
243
+ landed: (r) => r.error || (r.status !== undefined && r.status >= 400)
244
+ ? { ok: false, detail: r.error ?? `Delete returned HTTP ${r.status} but did not throw`, extra: { status: r.status } }
245
+ : { ok: true },
246
+ audit: { method: 'deleteObject', type: (a) => a.type, name: (a) => a.name },
247
+ verify: { syntax: false, activation: false, deletedCheck: true },
248
+ envelope: (_a, _r, pre) => {
249
+ const stillExists = pre.extra['still_exists'];
550
250
  return {
551
- content: JSON.stringify({
552
- updated: true,
553
- syntax_ok: syntax.ok,
554
- errors: syntax.errors,
555
- warnings: syntax.warnings,
556
- ...activatedByWrite(writeActivation),
557
- ...(resolved.owning ? { transport_used: resolved.owning, transport_note: resolved.note } : {}),
558
- next_step: nextStepAfterWrite(syntax.ok, writeActivation),
559
- }),
251
+ deleted: !stillExists,
252
+ still_exists: stillExists,
253
+ ...(pre.owning ? { transport_used: pre.owning, transport_note: pre.note } : {}),
560
254
  };
561
255
  },
562
- });
563
- // ── sap_delete_object ────────────────────────────────────────────────────────
564
- //
565
- // Signature: deleteObject(type, name, transport?) → { status: number; error?: string }
566
- // AdtClient auto-snapshots before delete (Rule 7 built-in), but we also call
567
- // autoSnapshot here for belt-and-suspenders consistency with the CLI gate model.
256
+ };
568
257
  registerTool({
569
258
  name: 'sap_delete_object',
259
+ requires: byObjectType,
570
260
  description: 'Delete an ABAP object. Requires approval_id. '
571
261
  + 'A snapshot is taken automatically before deletion. '
572
262
  + 'Post-check: confirms the object no longer exists. '
@@ -583,115 +273,119 @@ registerTool({
583
273
  },
584
274
  required: ['name', 'type', 'approval_id'],
585
275
  },
586
- handler: async (args, ctx) => {
587
- // ── Gate 1: Approval ────────────────────────────────────────────────────
588
- const verify = await verifyAndSpendApprovalId(args.approval_id, args.name, 'delete');
589
- if (!verify.ok) {
590
- return {
591
- content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: verify.reason }),
592
- is_error: true,
593
- };
594
- }
595
- const toolUseId = ctx.toolUseId ?? crypto.randomUUID();
596
- const startedAt = Date.now();
597
- // ── Rule 9 conflict warning ──────────────────────────────────────────────
598
- // Emit a warning if the LLM passed an explicit transport that differs from
599
- // the session current-transport. Prefer LLM intent (explicit caller); this
600
- // is informational only and does NOT block the operation.
601
- {
602
- const sessionTransport = ctx.currentTransport?.get() ?? null;
603
- if (args.transport && sessionTransport && args.transport !== sessionTransport) {
604
- ctx.chunkEmitter?.emit('warn', `LLM passed transport=${args.transport} but session current-transport=${sessionTransport}`);
605
- }
606
- }
607
- // ── Rule 9 owning-transport check (D15/D20) ──────────────────────────────
608
- const resolved = await resolveWriteTransport(ctx, args.type, args.name, args.transport);
609
- // ── Gate 2: Snapshot (Rule 7) — best-effort, object may not have source ──
610
- let snapOutcome;
611
- try {
612
- snapOutcome = await autoSnapshot(args.name, args.type, ctx.adt);
613
- }
614
- catch {
615
- // Silently ignore — object may be a shell with no readable source.
616
- }
617
- emitSnapshotNotice(ctx, snapOutcome, args.type, args.name);
618
- // ── Gate 3: Safety confirmation (Rule 7 — delete is irreversible-ish) ────
619
- if (isSafetyConfirmEnabled()) {
620
- const r = await presentSafetyConfirmation({
621
- rule: 'RULE_7_SNAPSHOT_BEFORE_WRITE',
622
- op: 'delete_object',
623
- object: { type: args.type, name: args.name },
624
- what_will_happen: 'Deletes object from SAP; snapshot is taken first; recovery via sap_snapshot_read + sap_set_source (LLM composes the rollback).',
625
- rollback_available: true,
626
- transport: resolved.transport ?? ctx.currentTransport?.get() ?? '',
627
- args: { ...args },
628
- });
629
- if (!r.confirmed) {
630
- return {
631
- content: JSON.stringify({ error: 'cancelled_by_user', reason: r.reason }),
632
- is_error: true,
633
- };
634
- }
635
- }
636
- // ── WAL: record pending entry ────────────────────────────────────────────
637
- await appendPending(ctx.session, {
638
- tool_use_id: toolUseId,
639
- tool: 'sap_delete_object',
640
- args: { name: args.name, type: args.type },
641
- started_at: new Date().toISOString(),
642
- });
643
- // ── Delete ───────────────────────────────────────────────────────────────
644
- // deleteObject(type, name, transport?) → { status: number; error?: string }.
645
- // Same return semantics as setSource — RETURNS rather than throws on HTTP
646
- // 4xx. Must check status / error on the return value.
647
- let deleteResult;
276
+ handler: writeToolHandler(DELETE_OBJECT_SPEC),
277
+ });
278
+ const isDevcCreate = (a) => String(a.type ?? '').toUpperCase().split('/')[0].trim() === 'DEVC';
279
+ const devcTransportCreated = (pre) => pre.extra['devc_transport_created'];
280
+ const CREATE_OBJECT_SPEC = {
281
+ tool: 'sap_create_object',
282
+ // 2026-10-04 — a plan-grant id holds the write to its claims (type, package, transport).
283
+ approval: {
284
+ object: (a) => a.name, op: 'create',
285
+ claims: (a, ctx) => ({ type: a.type, package: a.package, transport: a.transport ?? ctx.currentTransport?.get() ?? null }),
286
+ },
287
+ transport: { mode: 'three-tier' },
288
+ snapshot: { kind: 'none', reason: 'a new object has no prior source' },
289
+ // 1.1.31: does the chosen request fit the package? (see transport-fit-check.ts)
290
+ // A package create decides its own transport in the walGate (§10a), so only objects.
291
+ preGate: async (a, ctx, pre) => {
292
+ if (isDevcCreate(a))
293
+ return { ok: true };
294
+ const fit = await checkTransportFit(ctx.adt, { type: a.type, name: a.name, pkg: a.package, transport: pre.transport });
295
+ return fit.ok ? { ok: true } : { error: fit.refusal };
296
+ },
297
+ walArgs: (a) => ({ name: a.name, type: a.type, package: a.package }),
298
+ // §10a: a new package and its first transport, under this ONE approval.
299
+ walGate: async (a, ctx, pre) => {
300
+ if (!isDevcCreate(a))
301
+ return { ok: true };
302
+ let flow;
648
303
  try {
649
- deleteResult = await ctx.adt.deleteObject(args.type, args.name, resolved.transport);
304
+ flow = await prepareNewPackage(a, ctx);
650
305
  }
651
306
  catch (err) {
652
- const errStr = String(err);
653
- await finalizeToolCall(ctx.session, toolUseId, errStr, true, Date.now() - startedAt);
654
- return {
655
- content: JSON.stringify({ error: 'delete_failed', detail: errStr }),
656
- is_error: true,
657
- };
307
+ // A throw (network, auth, …) must still close the pending entry and
308
+ // reach the person in plain words, never as a raw exception.
309
+ const msg = err instanceof Error ? err.message : String(err);
310
+ const answered = typeof err?.status === 'number';
311
+ const message = answered
312
+ ? `SAP refused to create the transport: ${msg}`
313
+ : `Could not reach SAP to create the transport: ${msg}`;
314
+ return { error: { error: 'transport_create_failed', message, next: 'Next: check the connection to SAP and try again.' }, log: message };
315
+ }
316
+ if ('refused' in flow)
317
+ return { error: flow.refused, log: flow.refused.message };
318
+ pre.transport = flow.corrNr;
319
+ if (flow.created) {
320
+ // Same auto-attach as sap_transport_create: later creates in the new
321
+ // package pick this request up through the 3-tier fallback.
322
+ setCurrentTransport(flow.created);
323
+ markTransportCreatedThisSession(flow.created);
658
324
  }
659
- if (deleteResult.error || (deleteResult.status !== undefined && deleteResult.status >= 400)) {
660
- const detail = deleteResult.error ?? `Delete returned HTTP ${deleteResult.status} but did not throw`;
661
- await finalizeToolCall(ctx.session, toolUseId, detail, true, Date.now() - startedAt);
325
+ return {
326
+ ok: true,
327
+ extra: {
328
+ devc_transport_created: flow.created,
329
+ devc_extras: {
330
+ softwareComponent: a.softwareComponent,
331
+ transportLayer: flow.layer,
332
+ applicationComponent: a.applicationComponent,
333
+ superPackage: a.superPackage ? String(a.superPackage).toUpperCase() : undefined,
334
+ responsible: a.responsible,
335
+ },
336
+ },
337
+ };
338
+ },
339
+ // createObject(objtype, name, packageName, description, transport?, extras?)
340
+ call: (a, ctx, pre) => ctx.adt.createObject(a.type, a.name, a.package, a.description, pre.transport, {
341
+ serviceDefinition: a.serviceDefinition,
342
+ bindingType: a.bindingType,
343
+ bindingVersion: a.bindingVersion,
344
+ ...(pre.extra['devc_extras'] ?? {}),
345
+ // Review fix round 1, I-1: a sap_lock handle lives in the stateful
346
+ // session; sap-client ends that session before a create unless told.
347
+ keepSession: hasManualLocks(),
348
+ }),
349
+ onThrow: (err, _a, pre) => ({ error: 'create_failed', detail: String(err), ...orphanRequest(devcTransportCreated(pre)) }),
350
+ errorCode: 'create_failed',
351
+ landed: (r, pre) => r.status === 'created'
352
+ ? { ok: true }
353
+ : { ok: false, detail: r.error, log: JSON.stringify(r), extra: orphanRequest(devcTransportCreated(pre)) },
354
+ audit: { method: 'createObject', type: (a) => a.type, name: (a) => a.name },
355
+ verify: { syntax: false, activation: false },
356
+ walResult: (_a, r) => r,
357
+ envelope: (a, r, pre) => {
358
+ if (isDevcCreate(a)) {
359
+ const pkgName = r.objectName || String(a.name).toUpperCase();
360
+ const created = devcTransportCreated(pre);
662
361
  return {
663
- content: JSON.stringify({
664
- error: 'delete_failed',
665
- detail,
666
- status: deleteResult.status,
667
- }),
668
- is_error: true,
362
+ created: true,
363
+ object: pkgName,
364
+ type: 'DEVC',
365
+ ...(pre.transport ? { transport: pre.transport } : {}),
366
+ ...(created ? { transport_created: true } : {}),
367
+ message: pre.transport
368
+ ? `Created package ${pkgName} on request ${pre.transport}.`
369
+ : `Created package ${pkgName} (no transport needed).`,
370
+ next_step: 'create_objects_in_the_package_on_this_transport',
669
371
  };
670
372
  }
671
- await auditLog('deleteObject', args.type, args.name, 'success', Date.now() - startedAt);
672
- noteWriteTransport(resolved.transport); // F-L2-1 statusline wire
673
- // ── Post-check: object should no longer exist ────────────────────────────
674
- const stillExists = await ctx.adt.objectStructure(args.type, args.name)
675
- .then(() => true)
676
- .catch(() => false);
677
- await finalizeToolCall(ctx.session, toolUseId, JSON.stringify({ deleted: !stillExists }), false, Date.now() - startedAt);
678
373
  return {
679
- content: JSON.stringify({
680
- deleted: !stillExists,
681
- still_exists: stillExists,
682
- ...(resolved.owning ? { transport_used: resolved.owning, transport_note: resolved.note } : {}),
683
- }),
374
+ created: true,
375
+ object: r.objectName,
376
+ type: r.objectType,
377
+ package: r.packageName,
378
+ // A message class has no source and is never activated: its messages
379
+ // go in through sap_message_maintain (bug 3, 2026-10-12).
380
+ next_step: String(r.objectType ?? '').toUpperCase() === 'MSAG'
381
+ ? 'add_messages_via_sap_message_maintain (a message class has no source and no activation)'
382
+ : 'write_source_via_sap_set_source_then_activate',
684
383
  };
685
384
  },
686
- });
687
- // ── sap_create_object ────────────────────────────────────────────────────────
688
- //
689
- // Signature: createObject(objtype, name, packageName, description, transport?, extras?)
690
- // → Promise<CreateResult>
691
- // NOTE: arg order differs from the plan snippet — packageName comes before description.
692
- // No snapshot needed: new object has no prior source.
385
+ };
693
386
  registerTool({
694
387
  name: 'sap_create_object',
388
+ requires: byObjectType,
695
389
  description: 'Create a new ABAP object shell. '
696
390
  + 'Requires approval_id. No snapshot needed (new object). '
697
391
  + 'Write source separately via sap_set_source, then call sap_activate. '
@@ -724,162 +418,10 @@ registerTool({
724
418
  applicationComponent: { type: 'string', description: 'DEVC ONLY (optional): application component, e.g. FI-AR.' },
725
419
  superPackage: { type: 'string', description: 'DEVC ONLY (optional): parent package.' },
726
420
  responsible: { type: 'string', description: 'DEVC ONLY (optional): responsible user. Default: the logged-on user (upper case).' },
727
- },
728
- required: ['name', 'type', 'package', 'description', 'approval_id'],
729
- },
730
- handler: async (args, ctx) => {
731
- // ── Gate 1: Approval ────────────────────────────────────────────────────
732
- const verify = await verifyAndSpendApprovalId(args.approval_id, args.name, 'create');
733
- if (!verify.ok) {
734
- return {
735
- content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: verify.reason }),
736
- is_error: true,
737
- };
738
- }
739
- // 2026-10-04 — a plan-grant id holds the write to its claims (type, package, transport).
740
- {
741
- const claim = grantClaimMismatch(verify.payload, { type: args.type, package: args.package, transport: args.transport ?? ctx.currentTransport?.get() ?? null });
742
- if (claim)
743
- return { content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: claim }), is_error: true };
744
- }
745
- const toolUseId = ctx.toolUseId ?? crypto.randomUUID();
746
- const startedAt = Date.now();
747
- // No snapshot — new object has no prior source.
748
- // ── Rule 9 conflict warning + 3-tier transport fallback ──────────────────
749
- // Emit a warning if the LLM passed an explicit transport that differs from
750
- // the session current-transport. Prefer LLM intent (explicit caller); this
751
- // is informational only and does NOT block the operation.
752
- {
753
- const sessionTransport = ctx.currentTransport?.get() ?? null;
754
- if (args.transport && sessionTransport && args.transport !== sessionTransport) {
755
- ctx.chunkEmitter?.emit('warn', `LLM passed transport=${args.transport} but session current-transport=${sessionTransport}`);
756
- }
757
- }
758
- // 3-tier fallback: explicit arg → session current-transport → undefined
759
- let effectiveTransport = args.transport ?? ctx.currentTransport?.get() ?? undefined;
760
- // ── 1.1.31: does the chosen request fit the package? (see transport-fit-check.ts)
761
- // A package create decides its own transport below (§10a), so only objects.
762
- const isDevc = String(args.type ?? '').toUpperCase().split('/')[0].trim() === 'DEVC';
763
- if (!isDevc) {
764
- const fit = await checkTransportFit(ctx.adt, {
765
- type: args.type, name: args.name, pkg: args.package, transport: effectiveTransport,
766
- });
767
- if (!fit.ok)
768
- return { content: JSON.stringify(fit.refusal), is_error: true };
769
- }
770
- // ── WAL: record pending entry ────────────────────────────────────────────
771
- await appendPending(ctx.session, {
772
- tool_use_id: toolUseId,
773
- tool: 'sap_create_object',
774
- args: { name: args.name, type: args.type, package: args.package },
775
- started_at: new Date().toISOString(),
776
- });
777
- // ── §10a: a new package and its first transport, under this ONE approval ─
778
- let devcExtras = {};
779
- let devcTransportCreated;
780
- if (isDevc) {
781
- let flow;
782
- try {
783
- flow = await prepareNewPackage(args, ctx);
784
- }
785
- catch (err) {
786
- // A throw (network, auth, …) must still close the pending entry and
787
- // reach the person in plain words, never as a raw exception.
788
- const msg = err instanceof Error ? err.message : String(err);
789
- const answered = typeof err?.status === 'number';
790
- const message = answered
791
- ? `SAP refused to create the transport: ${msg}`
792
- : `Could not reach SAP to create the transport: ${msg}`;
793
- await finalizeToolCall(ctx.session, toolUseId, message, true, Date.now() - startedAt);
794
- return {
795
- content: JSON.stringify({ error: 'transport_create_failed', message, next: 'Next: check the connection to SAP and try again.' }),
796
- is_error: true,
797
- };
798
- }
799
- if ('refused' in flow) {
800
- await finalizeToolCall(ctx.session, toolUseId, flow.refused.message, true, Date.now() - startedAt);
801
- return { content: JSON.stringify(flow.refused), is_error: true };
802
- }
803
- effectiveTransport = flow.corrNr;
804
- devcTransportCreated = flow.created;
805
- if (flow.created) {
806
- // Same auto-attach as sap_transport_create: later creates in the new
807
- // package pick this request up through the 3-tier fallback.
808
- setCurrentTransport(flow.created);
809
- markTransportCreatedThisSession(flow.created);
810
- }
811
- devcExtras = {
812
- softwareComponent: args.softwareComponent,
813
- transportLayer: flow.layer,
814
- applicationComponent: args.applicationComponent,
815
- superPackage: args.superPackage ? String(args.superPackage).toUpperCase() : undefined,
816
- responsible: args.responsible,
817
- };
818
- }
819
- // ── Create ───────────────────────────────────────────────────────────────
820
- // createObject(objtype, name, packageName, description, transport?, extras?)
821
- let result;
822
- try {
823
- result = await ctx.adt.createObject(args.type, args.name, args.package, args.description, effectiveTransport, {
824
- serviceDefinition: args.serviceDefinition,
825
- bindingType: args.bindingType,
826
- bindingVersion: args.bindingVersion,
827
- ...devcExtras,
828
- // Review fix round 1, I-1: a sap_lock handle lives in the stateful
829
- // session; sap-client ends that session before a create unless told.
830
- keepSession: hasManualLocks(),
831
- });
832
- await auditLog('createObject', args.type, args.name, 'success', Date.now() - startedAt);
833
- }
834
- catch (err) {
835
- const errStr = String(err);
836
- await finalizeToolCall(ctx.session, toolUseId, errStr, true, Date.now() - startedAt);
837
- return {
838
- content: JSON.stringify({ error: 'create_failed', detail: errStr, ...orphanRequest(devcTransportCreated) }),
839
- is_error: true,
840
- };
841
- }
842
- if (result.status !== 'created') {
843
- await finalizeToolCall(ctx.session, toolUseId, JSON.stringify(result), true, Date.now() - startedAt);
844
- return {
845
- content: JSON.stringify({ error: 'create_failed', detail: result.error, ...orphanRequest(devcTransportCreated) }),
846
- is_error: true,
847
- };
848
- }
849
- // F-L2-1 statusline wire. This path never reaches resolveWriteTransport —
850
- // the 3-tier effectiveTransport above is what SAP stamped.
851
- noteWriteTransport(effectiveTransport);
852
- await finalizeToolCall(ctx.session, toolUseId, JSON.stringify(result), false, Date.now() - startedAt);
853
- if (isDevc) {
854
- const pkgName = result.objectName || String(args.name).toUpperCase();
855
- return {
856
- content: JSON.stringify({
857
- created: true,
858
- object: pkgName,
859
- type: 'DEVC',
860
- ...(effectiveTransport ? { transport: effectiveTransport } : {}),
861
- ...(devcTransportCreated ? { transport_created: true } : {}),
862
- message: effectiveTransport
863
- ? `Created package ${pkgName} on request ${effectiveTransport}.`
864
- : `Created package ${pkgName} (no transport needed).`,
865
- next_step: 'create_objects_in_the_package_on_this_transport',
866
- }),
867
- };
868
- }
869
- return {
870
- content: JSON.stringify({
871
- created: true,
872
- object: result.objectName,
873
- type: result.objectType,
874
- package: result.packageName,
875
- // A message class has no source and is never activated: its messages
876
- // go in through sap_message_maintain (bug 3, 2026-10-12).
877
- next_step: String(result.objectType ?? '').toUpperCase() === 'MSAG'
878
- ? 'add_messages_via_sap_message_maintain (a message class has no source and no activation)'
879
- : 'write_source_via_sap_set_source_then_activate',
880
- }),
881
- };
421
+ },
422
+ required: ['name', 'type', 'package', 'description', 'approval_id'],
882
423
  },
424
+ handler: writeToolHandler(CREATE_OBJECT_SPEC),
883
425
  });
884
426
  /**
885
427
  * §10a — when the package create fails after the flow already made a request,
@@ -965,16 +507,54 @@ async function prepareNewPackage(args, ctx) {
965
507
  return { refused: reusedRequestRefusal(tr) };
966
508
  return { corrNr: tr.transportNumber, layer, created: tr.transportNumber };
967
509
  }
968
- // ── sap_set_class_include ─────────────────────────────────────────────────────
969
- //
970
- // Signature: setClassInclude(className, includeType, source, transport?)
971
- // → Promise<ClassIncludeResult>
972
- // Writes a class local-include source (CCDEF/CCIMP/CCMAC/test classes). This is
973
- // how RAP behaviour-pool handlers (which live in CCIMP / locals_imp) get landed.
974
- // AdtClient locks the CLASS object, tries a ladder of PUT URL/content-type
975
- // variants (first 2xx wins), and always unlocks. The Rule-7 snapshot is of the
976
- // INCLUDE itself (autoSnapshotClassInclude). It is non-blocking — a fresh
977
- // class's include may not be readable yet — but a failed one warns loudly.
510
+ const CLASS_INCLUDE_SPEC = {
511
+ tool: 'sap_set_class_include',
512
+ approval: {
513
+ object: (a) => a.className,
514
+ op: 'modify',
515
+ claims: (a, ctx) => ({ type: 'CLAS', transport: a.transport ?? ctx.currentTransport?.get() ?? null }),
516
+ },
517
+ transport: { mode: 'fallback-resolve', type: () => 'CLAS', name: (a) => a.className, grantMismatch: true },
518
+ snapshot: { kind: 'include', className: (a) => a.className, includeType: (a) => a.includeType },
519
+ walArgs: (a) => ({ className: a.className, includeType: a.includeType }),
520
+ // setClassInclude THROWS only when it cannot acquire the class lock; for the
521
+ // all-variants-failed case it RETURNS { written:false, ... } (landed).
522
+ call: (a, ctx, pre) => ctx.adt.setClassInclude(a.className, a.includeType, a.source, pre.transport),
523
+ errorCode: 'class_include_write_failed',
524
+ // D14: `error` carries SAP's T100 diagnosis verbatim when a variant hit a
525
+ // CTS/lock conflict — passed through unaltered, plus the owning transport so
526
+ // the model can retry correctly instead of declaring the change manual-only.
527
+ landed: (r) => (r.written
528
+ ? { ok: true }
529
+ : {
530
+ ok: false,
531
+ detail: r.error ?? `All PUT variants failed (last HTTP ${r.httpStatus})`,
532
+ log: JSON.stringify(r),
533
+ extra: {
534
+ http_status: r.httpStatus,
535
+ ...(r.lockedInTransport ? {
536
+ locked_in_transport: r.lockedInTransport,
537
+ recovery: `Retry this exact write with transport=${r.lockedInTransport} — that request already owns the object.`,
538
+ } : {}),
539
+ attempts: r.attempts,
540
+ },
541
+ }),
542
+ audit: { method: 'setClassInclude', type: () => 'CLAS', name: (a) => a.className, detail: (a) => `include=${a.includeType}` },
543
+ verify: { syntax: false, activation: false },
544
+ walResult: (_a, r) => ({ written: true, variant: r.variantUsed }),
545
+ chain: (a, pre) => ({
546
+ ...snapshotChainState(pre.snapshot),
547
+ written: true,
548
+ syntax: 'not_checked',
549
+ card: { object: a.className, what: `include ${a.includeType}`, transport: pre.transport },
550
+ }),
551
+ envelope: (_a, r, pre) => ({
552
+ written: true,
553
+ variant: r.variantUsed,
554
+ ...(pre.owning ? { transport_used: pre.owning, transport_note: pre.note } : {}),
555
+ next_step: 'activate_the_class',
556
+ }),
557
+ };
978
558
  registerTool({
979
559
  name: 'sap_set_class_include',
980
560
  description: 'Write a class local-include source (CCDEF locals_def, CCIMP locals_imp, '
@@ -999,132 +579,81 @@ registerTool({
999
579
  },
1000
580
  required: ['className', 'includeType', 'source', 'approval_id'],
1001
581
  },
1002
- handler: async (args, ctx) => {
1003
- // ── Gate 1: Approval ────────────────────────────────────────────────────
1004
- const verify = await verifyAndSpendApprovalId(args.approval_id, args.className, 'modify');
1005
- if (!verify.ok) {
1006
- return {
1007
- content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: verify.reason }),
1008
- is_error: true,
1009
- };
1010
- }
582
+ handler: writeToolHandler(CLASS_INCLUDE_SPEC),
583
+ });
584
+ function ddicCreateSpec(tool, type, auditType, create) {
585
+ return {
586
+ tool,
1011
587
  // 2026-10-04 — a plan-grant id holds the write to its claims (type, package, transport).
1012
- {
1013
- const claim = grantClaimMismatch(verify.payload, { type: 'CLAS', transport: args.transport ?? ctx.currentTransport?.get() ?? null });
1014
- if (claim)
1015
- return { content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: claim }), is_error: true };
1016
- }
1017
- const toolUseId = ctx.toolUseId ?? crypto.randomUUID();
1018
- const startedAt = Date.now();
1019
- // ── Rule 9 conflict warning + 3-tier transport fallback ──────────────────
1020
- {
1021
- const sessionTransport = ctx.currentTransport?.get() ?? null;
1022
- if (args.transport && sessionTransport && args.transport !== sessionTransport) {
1023
- ctx.chunkEmitter?.emit('warn', `LLM passed transport=${args.transport} but session current-transport=${sessionTransport}`);
588
+ approval: {
589
+ object: (a) => a.name, op: 'create',
590
+ claims: (a, ctx) => ({ type, package: a.package, transport: a.transport ?? ctx.currentTransport?.get() ?? null }),
591
+ },
592
+ transport: { mode: 'three-tier' },
593
+ snapshot: { kind: 'none', reason: 'a new object has no prior source' },
594
+ walArgs: (a) => ({ name: a.name, type, package: a.package }),
595
+ call: async (a, ctx, pre) => {
596
+ const r = await create(a, ctx, pre.transport);
597
+ // F-L2-1 statusline wire, recorded on CREATED, not on active: a
598
+ // create-succeeded / activation-failed run must still show which request
599
+ // holds the object. The pipeline notes a landed create itself; this one
600
+ // is a miss there.
601
+ if (r.created && !r.active)
602
+ noteWriteTransport(pre.transport);
603
+ return r;
604
+ },
605
+ errorCode: 'create_failed',
606
+ landed: (r) => {
607
+ if (!r.created)
608
+ return { ok: false, detail: r.error, log: JSON.stringify(r) };
609
+ // Rule 10: created but did not activate — surface as an error.
610
+ // Final review I-2: the object now exists in SAP, so the audit log says so.
611
+ if (!r.active) {
612
+ return {
613
+ ok: false, detail: r.error, log: JSON.stringify(r), body: { error: 'activation_failed', object: r.name, detail: r.error },
614
+ audit: { status: 'error', reason: `created in SAP but not activated${r.error ? `: ${r.error}` : ''}` },
615
+ };
1024
616
  }
1025
- }
1026
- const effectiveTransport = args.transport ?? ctx.currentTransport?.get() ?? undefined;
1027
- // ── Rule 9 owning-transport check (D15/D20) ──────────────────────────────
1028
- // The CCIMP piece (LIMU CINC) is exactly the kind of object that sits
1029
- // locked in a request from earlier work — battery S1's 409 root cause.
1030
- const resolved = await resolveWriteTransport(ctx, 'CLAS', args.className, effectiveTransport);
1031
- // 2026-10-04 — a plan-grant id never writes into a transport other than the grant's.
1032
- if (grantOwningTransportMismatch(verify.payload, resolved.owning)) {
1033
- return {
1034
- content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: 'transport_mismatch', detail: grantOwningTransportDetail(args.className, resolved.owning) }),
1035
- is_error: true,
1036
- };
1037
- }
1038
- // ── Gate 2: Snapshot (Rule 7) — of the INCLUDE being overwritten ─────────
1039
- // Not the class main source: for a behaviour pool that is an empty shell and
1040
- // the change lives in CCIMP (2026-09-21 — the old main-source snapshot left
1041
- // the change with no restore point, and rewind "restored" the wrong thing).
1042
- // Still non-blocking (a fresh class's include may not be readable yet), but
1043
- // a missing restore point is now said out loud instead of swallowed.
1044
- let snapOutcome;
1045
- try {
1046
- snapOutcome = await autoSnapshotClassInclude(args.className, args.includeType, ctx.adt);
1047
- }
1048
- catch (err) {
1049
- snapOutcome = { taken: false, reason: 'source_read_failed', detail: String(err) };
1050
- }
1051
- emitSnapshotNotice(ctx, snapOutcome, includeSnapshotType(args.includeType), args.className);
1052
- if (snapOutcome?.reason === 'source_read_failed') {
1053
- ctx.chunkEmitter?.emit('warn', `CLAS ${args.className} ${args.includeType}: could not snapshot the include before writing `
1054
- + `(${snapOutcome.detail ?? 'read failed'}) — this write has NO restore point; rewind cannot undo it`);
1055
- }
1056
- // ── WAL: record pending entry ────────────────────────────────────────────
1057
- await appendPending(ctx.session, {
1058
- tool_use_id: toolUseId,
1059
- tool: 'sap_set_class_include',
1060
- args: { className: args.className, includeType: args.includeType },
1061
- started_at: new Date().toISOString(),
1062
- });
1063
- // ── Write ────────────────────────────────────────────────────────────────
1064
- // setClassInclude THROWS only when it cannot acquire the class lock;
1065
- // for the all-variants-failed case it RETURNS { written:false, ... }.
1066
- // Both paths must surface as an error envelope.
1067
- let result;
1068
- try {
1069
- result = await ctx.adt.setClassInclude(args.className, args.includeType, args.source, resolved.transport);
1070
- }
1071
- catch (err) {
1072
- const errStr = String(err);
1073
- await finalizeToolCall(ctx.session, toolUseId, errStr, true, Date.now() - startedAt);
1074
- return {
1075
- content: JSON.stringify({ error: 'class_include_write_failed', detail: errStr }),
1076
- is_error: true,
1077
- };
1078
- }
1079
- if (!result.written) {
1080
- // D14: `result.error` now carries SAP's T100 diagnosis verbatim when a
1081
- // variant hit a CTS/lock conflict — pass it through unaltered, plus the
1082
- // structured owning transport so the model can retry correctly instead
1083
- // of declaring the change manual-only.
1084
- const detail = result.error ?? `All PUT variants failed (last HTTP ${result.httpStatus})`;
1085
- await finalizeToolCall(ctx.session, toolUseId, JSON.stringify(result), true, Date.now() - startedAt);
1086
- return {
1087
- content: JSON.stringify({
1088
- error: 'class_include_write_failed',
1089
- detail,
1090
- http_status: result.httpStatus,
1091
- ...(result.lockedInTransport ? {
1092
- locked_in_transport: result.lockedInTransport,
1093
- recovery: `Retry this exact write with transport=${result.lockedInTransport} — that request already owns the object.`,
1094
- } : {}),
1095
- attempts: result.attempts,
1096
- }),
1097
- is_error: true,
1098
- };
1099
- }
1100
- await auditLog('setClassInclude', 'CLAS', args.className, 'success', Date.now() - startedAt, `include=${args.includeType}`);
1101
- noteWriteTransport(resolved.transport); // F-L2-1 statusline wire
1102
- await finalizeToolCall(ctx.session, toolUseId, JSON.stringify({ written: true, variant: result.variantUsed }), false, Date.now() - startedAt);
1103
- // Tour slide-7 chain. Honest states: includes have no standalone syntax
1104
- // gate (checked at activation), so ③ renders as deferred, never faked.
1105
- emitWriteChain(ctx, {
1106
- ...snapshotChainState(snapOutcome),
1107
- written: true,
1108
- syntax: 'not_checked',
1109
- card: { object: args.className, what: `include ${args.includeType}`, transport: resolved.transport },
1110
- });
1111
- return {
1112
- content: JSON.stringify({
1113
- written: true,
1114
- variant: result.variantUsed,
1115
- ...(resolved.owning ? { transport_used: resolved.owning, transport_note: resolved.note } : {}),
1116
- next_step: 'activate_the_class',
1117
- }),
1118
- };
1119
- },
1120
- });
1121
- // ── sap_create_domain ────────────────────────────────────────────────────────
1122
- //
1123
- // Signature: createDomain(spec, transport?) → Promise<DdicCreateResult>
1124
- // AdtClient creates → activates → verifies in one call (Rule 10 internal).
1125
- // No snapshot needed: new object has no prior source.
617
+ return { ok: true };
618
+ },
619
+ audit: { method: 'createObject', type: () => auditType, name: (a) => a.name },
620
+ verify: { syntax: false, activation: false },
621
+ walResult: (_a, r) => r,
622
+ envelope: (_a, r) => ({
623
+ created: true,
624
+ active: true,
625
+ object: r.name,
626
+ type: r.type,
627
+ next_step: 'use_in_table_field',
628
+ }),
629
+ };
630
+ }
631
+ const DOMAIN_HANDLER = writeToolHandler(ddicCreateSpec('sap_create_domain', 'DOMA', 'DOMA/DD', (args, ctx, transport) => {
632
+ // Build spec (omit undefined optionals).
633
+ const spec = {
634
+ name: args.name,
635
+ description: args.description,
636
+ package: args.package,
637
+ dataType: args.dataType,
638
+ length: args.length,
639
+ };
640
+ if (args.decimals !== undefined)
641
+ spec.decimals = args.decimals;
642
+ if (args.outputLength !== undefined)
643
+ spec.outputLength = args.outputLength;
644
+ if (args.lowercase !== undefined)
645
+ spec.lowercase = args.lowercase;
646
+ if (args.signExists !== undefined)
647
+ spec.signExists = args.signExists;
648
+ if (args.valueTable !== undefined)
649
+ spec.valueTable = args.valueTable;
650
+ if (args.fixedValues !== undefined)
651
+ spec.fixedValues = args.fixedValues;
652
+ return ctx.adt.createDomain(spec, transport);
653
+ }));
1126
654
  registerTool({
1127
655
  name: 'sap_create_domain',
656
+ requires: 'ddic.domains',
1128
657
  description: 'Create a DDIC domain (DOMA), then activate and verify it. '
1129
658
  + 'Requires approval_id. No snapshot needed (new object). '
1130
659
  + 'Use the domain in a data element via sap_create_data_element.',
@@ -1160,112 +689,30 @@ registerTool({
1160
689
  },
1161
690
  required: ['name', 'description', 'package', 'dataType', 'length', 'approval_id'],
1162
691
  },
1163
- handler: async (args, ctx) => {
1164
- // ── Gate 1: Approval ────────────────────────────────────────────────────
1165
- const verify = await verifyAndSpendApprovalId(args.approval_id, args.name, 'create');
1166
- if (!verify.ok) {
1167
- return {
1168
- content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: verify.reason }),
1169
- is_error: true,
1170
- };
1171
- }
1172
- // 2026-10-04 — a plan-grant id holds the write to its claims (type, package, transport).
1173
- {
1174
- const claim = grantClaimMismatch(verify.payload, { type: 'DOMA', package: args.package, transport: args.transport ?? ctx.currentTransport?.get() ?? null });
1175
- if (claim)
1176
- return { content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: claim }), is_error: true };
1177
- }
1178
- const toolUseId = ctx.toolUseId ?? crypto.randomUUID();
1179
- const startedAt = Date.now();
1180
- // No snapshot — new object has no prior source.
1181
- // ── Rule 9 conflict warning + 3-tier transport fallback ──────────────────
1182
- {
1183
- const sessionTransport = ctx.currentTransport?.get() ?? null;
1184
- if (args.transport && sessionTransport && args.transport !== sessionTransport) {
1185
- ctx.chunkEmitter?.emit('warn', `LLM passed transport=${args.transport} but session current-transport=${sessionTransport}`);
1186
- }
1187
- }
1188
- const effectiveTransport = args.transport ?? ctx.currentTransport?.get() ?? undefined;
1189
- // ── WAL: record pending entry ────────────────────────────────────────────
1190
- await appendPending(ctx.session, {
1191
- tool_use_id: toolUseId,
1192
- tool: 'sap_create_domain',
1193
- args: { name: args.name, type: 'DOMA', package: args.package },
1194
- started_at: new Date().toISOString(),
1195
- });
1196
- // ── Build spec (omit undefined optionals) ────────────────────────────────
1197
- const spec = {
1198
- name: args.name,
1199
- description: args.description,
1200
- package: args.package,
1201
- dataType: args.dataType,
1202
- length: args.length,
1203
- };
1204
- if (args.decimals !== undefined)
1205
- spec.decimals = args.decimals;
1206
- if (args.outputLength !== undefined)
1207
- spec.outputLength = args.outputLength;
1208
- if (args.lowercase !== undefined)
1209
- spec.lowercase = args.lowercase;
1210
- if (args.signExists !== undefined)
1211
- spec.signExists = args.signExists;
1212
- if (args.valueTable !== undefined)
1213
- spec.valueTable = args.valueTable;
1214
- if (args.fixedValues !== undefined)
1215
- spec.fixedValues = args.fixedValues;
1216
- // ── Create → activate → verify (AdtClient does all three) ────────────────
1217
- let result;
1218
- try {
1219
- result = await ctx.adt.createDomain(spec, effectiveTransport);
1220
- await auditLog('createObject', 'DOMA/DD', args.name, 'success', Date.now() - startedAt);
1221
- }
1222
- catch (err) {
1223
- const errStr = String(err);
1224
- await finalizeToolCall(ctx.session, toolUseId, errStr, true, Date.now() - startedAt);
1225
- return {
1226
- content: JSON.stringify({ error: 'create_failed', detail: errStr }),
1227
- is_error: true,
1228
- };
1229
- }
1230
- if (!result.created) {
1231
- await finalizeToolCall(ctx.session, toolUseId, JSON.stringify(result), true, Date.now() - startedAt);
1232
- return {
1233
- content: JSON.stringify({ error: 'create_failed', detail: result.error }),
1234
- is_error: true,
1235
- };
1236
- }
1237
- // F-L2-1 statusline wire. Recorded on CREATED, not on active: the object is
1238
- // already in effectiveTransport by then, so a create-succeeded /
1239
- // activation-failed run must still show which request holds it. DDIC creates
1240
- // bypass resolveWriteTransport — effectiveTransport is what SAP stamped.
1241
- noteWriteTransport(effectiveTransport);
1242
- if (!result.active) {
1243
- // Rule 10: created but did not activate — surface as an error.
1244
- await finalizeToolCall(ctx.session, toolUseId, JSON.stringify(result), true, Date.now() - startedAt);
1245
- return {
1246
- content: JSON.stringify({ error: 'activation_failed', object: result.name, detail: result.error }),
1247
- is_error: true,
1248
- };
1249
- }
1250
- await finalizeToolCall(ctx.session, toolUseId, JSON.stringify(result), false, Date.now() - startedAt);
1251
- return {
1252
- content: JSON.stringify({
1253
- created: true,
1254
- active: true,
1255
- object: result.name,
1256
- type: result.type,
1257
- next_step: 'use_in_table_field',
1258
- }),
1259
- };
1260
- },
692
+ handler: DOMAIN_HANDLER,
1261
693
  });
1262
- // ── sap_create_data_element ──────────────────────────────────────────────────
1263
- //
1264
- // Signature: createDataElement(spec, transport?) → Promise<DdicCreateResult>
1265
- // AdtClient creates → activates → verifies in one call (Rule 10 internal).
1266
- // No snapshot needed: new object has no prior source.
694
+ const DATA_ELEMENT_HANDLER = writeToolHandler(ddicCreateSpec('sap_create_data_element', 'DTEL', 'DTEL/DE', (args, ctx, transport) => {
695
+ // Build spec (omit undefined optionals).
696
+ const spec = {
697
+ name: args.name,
698
+ description: args.description,
699
+ package: args.package,
700
+ domainName: args.domainName,
701
+ dataType: args.dataType,
702
+ length: args.length,
703
+ labels: args.labels,
704
+ };
705
+ if (args.decimals !== undefined)
706
+ spec.decimals = args.decimals;
707
+ if (args.searchHelp !== undefined)
708
+ spec.searchHelp = args.searchHelp;
709
+ if (args.searchHelpParameter !== undefined)
710
+ spec.searchHelpParameter = args.searchHelpParameter;
711
+ return ctx.adt.createDataElement(spec, transport);
712
+ }));
1267
713
  registerTool({
1268
714
  name: 'sap_create_data_element',
715
+ requires: 'ddic.dataelements',
1269
716
  description: 'Create a DDIC data element (DTEL) bound to an existing domain, then '
1270
717
  + 'activate and verify it. Requires approval_id. No snapshot needed (new object). '
1271
718
  + 'Use the data element as a table/structure field type.',
@@ -1299,97 +746,7 @@ registerTool({
1299
746
  },
1300
747
  required: ['name', 'description', 'package', 'domainName', 'dataType', 'length', 'labels', 'approval_id'],
1301
748
  },
1302
- handler: async (args, ctx) => {
1303
- // ── Gate 1: Approval ────────────────────────────────────────────────────
1304
- const verify = await verifyAndSpendApprovalId(args.approval_id, args.name, 'create');
1305
- if (!verify.ok) {
1306
- return {
1307
- content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: verify.reason }),
1308
- is_error: true,
1309
- };
1310
- }
1311
- // 2026-10-04 — a plan-grant id holds the write to its claims (type, package, transport).
1312
- {
1313
- const claim = grantClaimMismatch(verify.payload, { type: 'DTEL', package: args.package, transport: args.transport ?? ctx.currentTransport?.get() ?? null });
1314
- if (claim)
1315
- return { content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: claim }), is_error: true };
1316
- }
1317
- const toolUseId = ctx.toolUseId ?? crypto.randomUUID();
1318
- const startedAt = Date.now();
1319
- // No snapshot — new object has no prior source.
1320
- // ── Rule 9 conflict warning + 3-tier transport fallback ──────────────────
1321
- {
1322
- const sessionTransport = ctx.currentTransport?.get() ?? null;
1323
- if (args.transport && sessionTransport && args.transport !== sessionTransport) {
1324
- ctx.chunkEmitter?.emit('warn', `LLM passed transport=${args.transport} but session current-transport=${sessionTransport}`);
1325
- }
1326
- }
1327
- const effectiveTransport = args.transport ?? ctx.currentTransport?.get() ?? undefined;
1328
- // ── WAL: record pending entry ────────────────────────────────────────────
1329
- await appendPending(ctx.session, {
1330
- tool_use_id: toolUseId,
1331
- tool: 'sap_create_data_element',
1332
- args: { name: args.name, type: 'DTEL', package: args.package },
1333
- started_at: new Date().toISOString(),
1334
- });
1335
- // ── Build spec (omit undefined optionals) ────────────────────────────────
1336
- const spec = {
1337
- name: args.name,
1338
- description: args.description,
1339
- package: args.package,
1340
- domainName: args.domainName,
1341
- dataType: args.dataType,
1342
- length: args.length,
1343
- labels: args.labels,
1344
- };
1345
- if (args.decimals !== undefined)
1346
- spec.decimals = args.decimals;
1347
- if (args.searchHelp !== undefined)
1348
- spec.searchHelp = args.searchHelp;
1349
- if (args.searchHelpParameter !== undefined)
1350
- spec.searchHelpParameter = args.searchHelpParameter;
1351
- // ── Create → activate → verify (AdtClient does all three) ────────────────
1352
- let result;
1353
- try {
1354
- result = await ctx.adt.createDataElement(spec, effectiveTransport);
1355
- await auditLog('createObject', 'DTEL/DE', args.name, 'success', Date.now() - startedAt);
1356
- }
1357
- catch (err) {
1358
- const errStr = String(err);
1359
- await finalizeToolCall(ctx.session, toolUseId, errStr, true, Date.now() - startedAt);
1360
- return {
1361
- content: JSON.stringify({ error: 'create_failed', detail: errStr }),
1362
- is_error: true,
1363
- };
1364
- }
1365
- if (!result.created) {
1366
- await finalizeToolCall(ctx.session, toolUseId, JSON.stringify(result), true, Date.now() - startedAt);
1367
- return {
1368
- content: JSON.stringify({ error: 'create_failed', detail: result.error }),
1369
- is_error: true,
1370
- };
1371
- }
1372
- // F-L2-1 statusline wire — see the createDomain note above (created, not active).
1373
- noteWriteTransport(effectiveTransport);
1374
- if (!result.active) {
1375
- // Rule 10: created but did not activate — surface as an error.
1376
- await finalizeToolCall(ctx.session, toolUseId, JSON.stringify(result), true, Date.now() - startedAt);
1377
- return {
1378
- content: JSON.stringify({ error: 'activation_failed', object: result.name, detail: result.error }),
1379
- is_error: true,
1380
- };
1381
- }
1382
- await finalizeToolCall(ctx.session, toolUseId, JSON.stringify(result), false, Date.now() - startedAt);
1383
- return {
1384
- content: JSON.stringify({
1385
- created: true,
1386
- active: true,
1387
- object: result.name,
1388
- type: result.type,
1389
- next_step: 'use_in_table_field',
1390
- }),
1391
- };
1392
- },
749
+ handler: DATA_ELEMENT_HANDLER,
1393
750
  });
1394
751
  // ── sap_activate ─────────────────────────────────────────────────────────────
1395
752
  //
@@ -1617,6 +974,7 @@ registerTool({
1617
974
  // Signature: serviceBindingPublish(name) → Promise<{ status: number; error?: string }>
1618
975
  registerTool({
1619
976
  name: 'sap_service_binding_publish',
977
+ requires: 'rap.srvb',
1620
978
  description: 'Publish an ADT service binding so its OData endpoint becomes active and reachable.',
1621
979
  isMutating: true,
1622
980
  requiresSap: true,
@@ -1640,6 +998,7 @@ registerTool({
1640
998
  // NOTE: the unpublishjobs endpoint is inferred-not-yet-live-verified (VPN down).
1641
999
  registerTool({
1642
1000
  name: 'sap_service_binding_unpublish',
1001
+ requires: 'rap.srvb',
1643
1002
  description: 'Unpublish an ADT service binding so its OData endpoint is removed. '
1644
1003
  + 'Required before deleting a published binding (SAP returns HTTP 412 "is published" otherwise).',
1645
1004
  isMutating: true,
@@ -1711,8 +1070,120 @@ function messageRowsProblem(rows) {
1711
1070
  }
1712
1071
  return null;
1713
1072
  }
1073
+ const msagThrowText = (err) => (err instanceof Error ? err.message : String(err));
1074
+ const msagName = (a) => String(a.messageClass ?? '').trim().toUpperCase();
1075
+ const msagRows = (a) => messageRowsFromArgs(a);
1076
+ const msagLockNote = (pre) => pre.extra['lock_note'];
1077
+ const MESSAGE_MAINTAIN_SPEC = {
1078
+ tool: 'sap_message_maintain',
1079
+ beforeApproval: (a, ctx) => {
1080
+ // Input check — before the approval is spent.
1081
+ const className = msagName(a);
1082
+ const problem = !className
1083
+ ? 'Give the message class name.'
1084
+ : !MESSAGE_CLASS_NAME_RE.test(className)
1085
+ ? `"${String(a.messageClass)}" is not a valid message class name (letters, digits and _ only, 20 characters at most).`
1086
+ : messageRowsProblem(msagRows(a));
1087
+ if (problem)
1088
+ return { error: 'invalid_messages', detail: problem };
1089
+ // Capability — before the approval is spent (review I-1; an old sap-client pin).
1090
+ const adt = ctx.adt;
1091
+ if (typeof adt.readMessageClass !== 'function' || typeof adt.maintainMessages !== 'function') {
1092
+ return { error: 'message_maintain_unavailable', detail: 'This CSPeach build cannot write messages yet. Update CSPeach and try again.' };
1093
+ }
1094
+ return undefined;
1095
+ },
1096
+ approval: {
1097
+ object: (a) => a.messageClass,
1098
+ op: 'modify',
1099
+ claims: (a, ctx) => ({ type: 'MSAG', transport: a.transport ?? ctx.currentTransport?.get() ?? null }),
1100
+ },
1101
+ transport: { mode: 'fallback-resolve', type: () => 'MSAG', name: msagName, grantMismatch: true },
1102
+ snapshot: { kind: 'messages', name: msagName },
1103
+ walArgs: (a) => ({ messageClass: msagName(a), messages: msagRows(a).map((r) => r.msgno) }),
1104
+ // sap-client locks, PUTs, unlocks in a finally and reads back. A lock it may
1105
+ // have left is said right after the call, on success and failure (review M-1).
1106
+ call: async (a, ctx, pre) => {
1107
+ const r = await ctx.adt.maintainMessages({
1108
+ name: msagName(a),
1109
+ messages: msagRows(a),
1110
+ ...(pre.transport ? { transport: pre.transport } : {}),
1111
+ });
1112
+ const lockNote = lockWarning(msagName(a), r);
1113
+ pre.extra['lock_note'] = lockNote;
1114
+ if (lockNote)
1115
+ ctx.chunkEmitter?.emit('warn', lockNote);
1116
+ return r;
1117
+ },
1118
+ // One source for the thrown text: the envelope detail and the WAL log say the same (final review M-4).
1119
+ onThrow: (err) => ({ error: 'message_maintain_failed', detail: msagThrowText(err) }),
1120
+ throwLog: msagThrowText,
1121
+ errorCode: 'message_maintain_failed',
1122
+ // Success is SAP's read-back (`verified`), never HTTP 200.
1123
+ landed: (r, pre) => {
1124
+ if (r.verified)
1125
+ return { ok: true };
1126
+ const lockNote = msagLockNote(pre);
1127
+ const detail = r.error ?? 'The messages could not be confirmed in SAP.';
1128
+ return {
1129
+ ok: false,
1130
+ detail,
1131
+ log: JSON.stringify(r),
1132
+ body: {
1133
+ error: 'message_maintain_failed',
1134
+ ...(lockNote ? { lock_warning: lockNote } : {}),
1135
+ detail,
1136
+ missing: r.missing,
1137
+ ...(r.httpStatus !== undefined ? { http_status: r.httpStatus } : {}),
1138
+ ...(r.lockedInTransport ? {
1139
+ locked_in_transport: r.lockedInTransport,
1140
+ recovery: `Retry this exact call with transport=${r.lockedInTransport} — that request already owns the message class.`,
1141
+ } : {}),
1142
+ },
1143
+ };
1144
+ },
1145
+ // Controller ruling (Task 12 review I-1.4): the call landed, nothing was
1146
+ // written — WAL closed clean, NO audit row, is_error absent.
1147
+ noWrite: (a, r) => (r.noChange
1148
+ ? {
1149
+ walResult: { unchanged: true },
1150
+ envelope: {
1151
+ written: false,
1152
+ unchanged: true,
1153
+ message_class: msagName(a),
1154
+ changes: r.changes,
1155
+ message: `Message class ${msagName(a)} already has these texts. Nothing was changed.`,
1156
+ },
1157
+ }
1158
+ : undefined),
1159
+ audit: {
1160
+ method: 'maintainMessages', type: () => 'MSAG', name: msagName,
1161
+ detail: (_a, r) => r.changes.map((c) => `${c.msgno}:${c.action}`).join(' '),
1162
+ },
1163
+ verify: { syntax: false, activation: false },
1164
+ walResult: (_a, r) => ({ written: true, verified: true, changes: r.changes }),
1165
+ after: (a, r, _pre, ctx) => {
1166
+ const written = r.changes.filter((c) => c.action !== 'unchanged').map((c) => c.msgno);
1167
+ ctx.chunkEmitter?.emit('info', `messages ✓ ${msagName(a)} ${written.join(' ')} (no activation needed)`);
1168
+ },
1169
+ envelope: (a, r, pre) => {
1170
+ const className = msagName(a);
1171
+ const lockNote = msagLockNote(pre);
1172
+ const first = r.changes.filter((c) => c.action !== 'unchanged').map((c) => c.msgno)[0] ?? msagRows(a)[0].msgno;
1173
+ return {
1174
+ written: true,
1175
+ verified: true,
1176
+ message_class: className,
1177
+ changes: r.changes,
1178
+ ...(pre.owning ? { transport_used: pre.owning, transport_note: pre.note } : {}),
1179
+ ...(lockNote ? { lock_warning: lockNote } : {}),
1180
+ next_step: `Done — no activation needed. Raise these from code by number, e.g. MESSAGE e${first}(${className.toLowerCase()}) WITH …, or a T100 textid in an exception class. Never hard-code the texts.`,
1181
+ };
1182
+ },
1183
+ };
1714
1184
  registerTool({
1715
1185
  name: 'sap_message_maintain',
1186
+ requires: 'msag',
1716
1187
  description: 'Add or change messages in an existing message class (MSAG, the T100 texts behind SE91). '
1717
1188
  + 'Create the class first with sap_create_object type MSAG. One call takes many messages. '
1718
1189
  + 'Each message: msgno (three digits, 000-999) and text (up to 73 characters, &1-&4 placeholders allowed). '
@@ -1745,169 +1216,7 @@ registerTool({
1745
1216
  },
1746
1217
  required: ['messageClass', 'approval_id'],
1747
1218
  },
1748
- handler: async (args, ctx) => {
1749
- const className = String(args.messageClass ?? '').trim().toUpperCase();
1750
- // ── Input check — before the approval is spent ──────────────────────────
1751
- const rows = messageRowsFromArgs(args);
1752
- const problem = !className
1753
- ? 'Give the message class name.'
1754
- : !MESSAGE_CLASS_NAME_RE.test(className)
1755
- ? `"${String(args.messageClass)}" is not a valid message class name (letters, digits and _ only, 20 characters at most).`
1756
- : messageRowsProblem(rows);
1757
- if (problem) {
1758
- return { content: JSON.stringify({ error: 'invalid_messages', detail: problem }), is_error: true };
1759
- }
1760
- // ── Capability — before the approval is spent (review I-1) ──────────────
1761
- const adt = ctx.adt;
1762
- if (typeof adt.readMessageClass !== 'function' || typeof adt.maintainMessages !== 'function') {
1763
- return {
1764
- content: JSON.stringify({
1765
- error: 'message_maintain_unavailable',
1766
- detail: 'This CSPeach build cannot write messages yet. Update CSPeach and try again.',
1767
- }),
1768
- is_error: true,
1769
- };
1770
- }
1771
- const msagClient = adt;
1772
- // ── Gate 1: Approval ────────────────────────────────────────────────────
1773
- const verify = await verifyAndSpendApprovalId(args.approval_id, args.messageClass, 'modify');
1774
- if (!verify.ok) {
1775
- return {
1776
- content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: verify.reason }),
1777
- is_error: true,
1778
- };
1779
- }
1780
- // 2026-10-04 — a plan-grant id holds the write to its claims (type, package, transport).
1781
- {
1782
- const claim = grantClaimMismatch(verify.payload, { type: 'MSAG', transport: args.transport ?? ctx.currentTransport?.get() ?? null });
1783
- if (claim)
1784
- return { content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: claim }), is_error: true };
1785
- }
1786
- const toolUseId = ctx.toolUseId ?? crypto.randomUUID();
1787
- const startedAt = Date.now();
1788
- // ── Rule 9 conflict warning + 3-tier transport fallback ──────────────────
1789
- {
1790
- const sessionTransport = ctx.currentTransport?.get() ?? null;
1791
- if (args.transport && sessionTransport && args.transport !== sessionTransport) {
1792
- ctx.chunkEmitter?.emit('warn', `LLM passed transport=${args.transport} but session current-transport=${sessionTransport}`);
1793
- }
1794
- }
1795
- const effectiveTransport = args.transport ?? ctx.currentTransport?.get() ?? undefined;
1796
- const resolved = await resolveWriteTransport(ctx, 'MSAG', className, effectiveTransport);
1797
- // 2026-10-04 — a plan-grant id never writes into a transport other than the grant's.
1798
- if (grantOwningTransportMismatch(verify.payload, resolved.owning)) {
1799
- return {
1800
- content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: 'transport_mismatch', detail: grantOwningTransportDetail(className, resolved.owning) }),
1801
- is_error: true,
1802
- };
1803
- }
1804
- // ── Gate 2: Snapshot (Rule 7) — HARD gate, of the whole class ────────────
1805
- let snapOutcome;
1806
- try {
1807
- snapOutcome = await autoSnapshotMessageClass(className, msagClient);
1808
- }
1809
- catch (err) {
1810
- return {
1811
- content: JSON.stringify({ error: ERR.SNAPSHOT_FAILED, detail: String(err) }),
1812
- is_error: true,
1813
- };
1814
- }
1815
- if (snapOutcome.reason === 'object_not_found') {
1816
- return {
1817
- content: JSON.stringify({
1818
- error: 'message_class_not_found',
1819
- detail: `Message class ${className} does not exist. Create it first with sap_create_object type MSAG, then add the messages.`,
1820
- }),
1821
- is_error: true,
1822
- };
1823
- }
1824
- if (!snapOutcome.taken) {
1825
- return {
1826
- content: JSON.stringify({
1827
- error: ERR.SNAPSHOT_FAILED,
1828
- reason: snapOutcome.reason ?? 'source_read_failed',
1829
- detail: snapOutcome.detail,
1830
- recovery: 'The message class could not be read, so no snapshot was possible and nothing was written. If this is a connection or sign-in problem, fix it and try again.',
1831
- }),
1832
- is_error: true,
1833
- };
1834
- }
1835
- emitSnapshotNotice(ctx, snapOutcome, 'MSAG', className);
1836
- // ── WAL: record pending entry ────────────────────────────────────────────
1837
- await appendPending(ctx.session, {
1838
- tool_use_id: toolUseId,
1839
- tool: 'sap_message_maintain',
1840
- args: { messageClass: className, messages: rows.map((r) => r.msgno) },
1841
- started_at: new Date().toISOString(),
1842
- });
1843
- // ── Write (sap-client locks, PUTs, unlocks and reads back) ───────────────
1844
- let result;
1845
- try {
1846
- result = await msagClient.maintainMessages({
1847
- name: className,
1848
- messages: rows,
1849
- ...(resolved.transport ? { transport: resolved.transport } : {}),
1850
- });
1851
- }
1852
- catch (err) {
1853
- const detail = err instanceof Error ? err.message : String(err);
1854
- await finalizeToolCall(ctx.session, toolUseId, detail, true, Date.now() - startedAt);
1855
- return {
1856
- content: JSON.stringify({ error: 'message_maintain_failed', detail }),
1857
- is_error: true,
1858
- };
1859
- }
1860
- const lockNote = lockWarning(className, result);
1861
- if (lockNote)
1862
- ctx.chunkEmitter?.emit('warn', lockNote);
1863
- if (!result.verified) {
1864
- await finalizeToolCall(ctx.session, toolUseId, JSON.stringify(result), true, Date.now() - startedAt);
1865
- return {
1866
- content: JSON.stringify({
1867
- error: 'message_maintain_failed',
1868
- ...(lockNote ? { lock_warning: lockNote } : {}),
1869
- detail: result.error ?? 'The messages could not be confirmed in SAP.',
1870
- missing: result.missing,
1871
- ...(result.httpStatus !== undefined ? { http_status: result.httpStatus } : {}),
1872
- ...(result.lockedInTransport ? {
1873
- locked_in_transport: result.lockedInTransport,
1874
- recovery: `Retry this exact call with transport=${result.lockedInTransport} — that request already owns the message class.`,
1875
- } : {}),
1876
- }),
1877
- is_error: true,
1878
- };
1879
- }
1880
- if (result.noChange) {
1881
- await finalizeToolCall(ctx.session, toolUseId, JSON.stringify({ unchanged: true }), false, Date.now() - startedAt);
1882
- return {
1883
- content: JSON.stringify({
1884
- written: false,
1885
- unchanged: true,
1886
- message_class: className,
1887
- changes: result.changes,
1888
- message: `Message class ${className} already has these texts. Nothing was changed.`,
1889
- }),
1890
- };
1891
- }
1892
- // Audit only after SAP's read-back confirmed every message.
1893
- await auditLog('maintainMessages', 'MSAG', className, 'success', Date.now() - startedAt, result.changes.map((c) => `${c.msgno}:${c.action}`).join(' '));
1894
- noteWriteTransport(resolved.transport); // F-L2-1 statusline wire
1895
- await finalizeToolCall(ctx.session, toolUseId, JSON.stringify({ written: true, verified: true, changes: result.changes }), false, Date.now() - startedAt);
1896
- const written = result.changes.filter((c) => c.action !== 'unchanged').map((c) => c.msgno);
1897
- ctx.chunkEmitter?.emit('info', `messages ✓ ${className} ${written.join(' ')} (no activation needed)`);
1898
- const first = written[0] ?? rows[0].msgno;
1899
- return {
1900
- content: JSON.stringify({
1901
- written: true,
1902
- verified: true,
1903
- message_class: className,
1904
- changes: result.changes,
1905
- ...(resolved.owning ? { transport_used: resolved.owning, transport_note: resolved.note } : {}),
1906
- ...(lockNote ? { lock_warning: lockNote } : {}),
1907
- next_step: `Done — no activation needed. Raise these from code by number, e.g. MESSAGE e${first}(${className.toLowerCase()}) WITH …, or a T100 textid in an exception class. Never hard-code the texts.`,
1908
- }),
1909
- };
1910
- },
1219
+ handler: writeToolHandler(MESSAGE_MAINTAIN_SPEC),
1911
1220
  });
1912
1221
  // ── sap_number_range_intervals (STUB) ─────────────────────────────────────────
1913
1222
  //
@@ -1915,6 +1224,7 @@ registerTool({
1915
1224
  // in @cspeach/sap-client.
1916
1225
  registerTool({
1917
1226
  name: 'sap_number_range_intervals',
1227
+ requires: 'nrob',
1918
1228
  description: 'STUB — not currently implemented; do not call. '
1919
1229
  + 'Writing NROB number-range intervals via ADT requires NR-object XML format '
1920
1230
  + 'not yet implemented in @cspeach/sap-client.',