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