@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.
@@ -11,7 +11,7 @@
11
11
  * (Confirmed from sap-client/src/adt-client.ts lines 82-87, 224-228, 547.)
12
12
  */
13
13
  import { recordSyntaxCheck } from './syntax-state.js';
14
- import { toObjectUrl, xmlParser } from '@cspeach/sap-client';
14
+ import { fromObjectUrl, toObjectUrl, xmlParser } from '@cspeach/sap-client';
15
15
  import * as sapClient from '@cspeach/sap-client';
16
16
  /** Line for display: the parsed line, else the `#start=L,C` fragment of the
17
17
  * checkruns uri (sap-client <= 0.1.14 left line at 0 for checkruns). */
@@ -150,22 +150,66 @@ export function inactiveBodyMentions(body, type, name) {
150
150
  catch { /* unknown type word: the name check stands */ }
151
151
  return byName || byUri;
152
152
  }
153
+ /**
154
+ * Task 10 / D6 — is this same-named inactive-list entry provably ANOTHER
155
+ * object? In a RAP stack the DDLS, BDEF and DDLX share one name, so a
156
+ * name-only match read a clean object as "still inactive".
157
+ *
158
+ * SAFE side: true ONLY when the entry's uri is present and resolves (after
159
+ * dropping `#…`/`?…`) to a known object that differs from ours. Both sides go
160
+ * through fromObjectUrl, so case, %2F-encoded namespaces, the CDS alias and a
161
+ * class include (→ its class) all compare as the same object. A missing uri,
162
+ * one fromObjectUrl cannot vouch for, or an unknown type word → false: the
163
+ * entry keeps blocking by name, as before.
164
+ */
165
+ export function isOtherObject(entry, type, name) {
166
+ const uri = typeof entry.uri === 'string' ? entry.uri.trim() : '';
167
+ if (!uri)
168
+ return false;
169
+ const theirs = fromObjectUrl(uri.split(/[#?]/)[0]);
170
+ if (!theirs)
171
+ return false;
172
+ let ours;
173
+ try {
174
+ ours = fromObjectUrl(toObjectUrl(type, name));
175
+ }
176
+ catch {
177
+ return false;
178
+ }
179
+ if (!ours)
180
+ return false;
181
+ return theirs.type.toUpperCase() !== ours.type.toUpperCase()
182
+ || theirs.name.toUpperCase() !== ours.name.toUpperCase();
183
+ }
184
+ /** The same-named entries of the parsed list, and whether any of them may be
185
+ * ours (any entry that is not provably another object blocks). */
186
+ function sameNamed(inactive, type, name) {
187
+ const upper = name.toUpperCase();
188
+ const same = inactive.filter((o) => String(o.name ?? '').toUpperCase() === upper);
189
+ return { any: same.length > 0, blocks: same.some((o) => !isOtherObject(o, type, name)) };
190
+ }
153
191
  async function checkActiveVersion(adt, type, name) {
154
192
  const upper = name.toUpperCase();
155
193
  const conn = connOf(adt);
156
194
  if (!conn || typeof conn.post !== 'function' || typeof conn.get !== 'function')
157
195
  return undefined;
158
- // Parser-independent guard: never check the old active source while an
159
- // inactive version may exist.
160
- const list = await conn.get(INACTIVE_URL);
161
- if (list.status < 200 || list.status >= 300)
162
- return undefined;
163
- const body = list.body ?? '';
164
- if (inactiveBodyMentions(body, type, upper))
165
- return undefined;
196
+ // Never check the old active source while an inactive version may exist.
197
+ // Task 10 / D6: the parsed list first (an unreadable list throws → the
198
+ // caller refuses the retry). A same-named entry blocks unless its uri proves
199
+ // it is another object; when every same-named entry is another object, the
200
+ // raw-body name mention is explained by them and is not read.
166
201
  const inactive = await adt.inactiveObjects();
167
- if (inactive.some((o) => o.name.toUpperCase() === upper))
202
+ const same = sameNamed(inactive, type, upper);
203
+ if (same.blocks)
168
204
  return undefined;
205
+ if (!same.any) {
206
+ // Parser-independent guard (0.1.15 misses names on a child <ioc:ref>).
207
+ const list = await conn.get(INACTIVE_URL);
208
+ if (list.status < 200 || list.status >= 300)
209
+ return undefined;
210
+ if (inactiveBodyMentions(list.body ?? '', type, upper))
211
+ return undefined;
212
+ }
169
213
  // Controller ruling: no "body has refs but parses to nothing" rule — with the
170
214
  // 0.1.15 parser bug it refused the retry whenever the user had ANY other
171
215
  // inactive object, re-blocking every clean BDEF/DDLX/SRVD/TABL write. The
@@ -222,13 +266,18 @@ export async function verifyActive(adt, name, type) {
222
266
  }
223
267
  // Re-review of the final fix wave, item 1 — the list carries ADT codes with
224
268
  // a slash (PROG/P, CLAS/OC, DDLS/DF), so an exact type compare never matched
225
- // and still-inactive objects read as active. Match by object NAME: a
226
- // same-named object of another type only errs on the safe side.
227
- let stillInactive = inactive.some((o) => o.name.toUpperCase() === name.toUpperCase());
269
+ // and still-inactive objects read as active. Match by object NAME.
270
+ // Task 10 / D6 — a same-named entry is set aside ONLY when its uri proves it
271
+ // is another object (RAP: DDLS, BDEF and DDLX share one name); a missing,
272
+ // unknown or odd uri still blocks by name.
273
+ const same = sameNamed(inactive, type, name);
274
+ let stillInactive = same.blocks;
228
275
  // Re-review item 2 — parser-independent: the raw body mentioning the object
229
276
  // also means still inactive (0.1.15 misses names on a child <ioc:ref>).
277
+ // Skipped when every same-named entry is another object: they explain the
278
+ // mention.
230
279
  const conn = connOf(adt);
231
- if (!stillInactive && conn && typeof conn.get === 'function') {
280
+ if (!same.any && conn && typeof conn.get === 'function') {
232
281
  try {
233
282
  const list = await conn.get(INACTIVE_URL);
234
283
  if (list.status >= 200 && list.status < 300)
@@ -0,0 +1,437 @@
1
+ /**
2
+ * The CLI write pipeline (ADT gap program Part 2a, D8).
3
+ *
4
+ * Every CLI write tool used to carry its own copy of the safety gates. Here
5
+ * they run once, in one fixed order, and a tool is a declaration
6
+ * (`WriteToolSpec`) — so a new tool cannot skip a gate:
7
+ *
8
+ * (beforeApproval) → approval id → plan-grant claims → Rule 9 conflict warning → transport
9
+ * (owning-transport resolve, session fallback then resolve, or the
10
+ * three-tier fallback) → grant owning
11
+ * mismatch → Rule 7 snapshot → safety card → preGate → WAL pending →
12
+ * walGate → call → onThrow / landed → (noWrite: WAL closed, envelope) → noteWriteTransport → Rule 10 syntax check →
13
+ * audit row (D7: after the check, status follows the verified outcome) →
14
+ * WAL finalize → verified activation → chain / change card → after → envelope
15
+ *
16
+ * The result envelope is the tool's own `envelope` — byte-identical to what
17
+ * the hand-written handler returned (pinned in sap-write-envelopes.test.ts).
18
+ * The human-stream lines (snapshot notice, new-shell note, chain line, change
19
+ * card) are emitted here with the same text as before
20
+ * (sap-write-transcript.test.ts).
21
+ *
22
+ * The Part 1 profile gates (profileGate / gateWithVerify / D12) run in
23
+ * dispatchTool and request_approval, before any handler: untouched here.
24
+ */
25
+ import crypto from 'node:crypto';
26
+ import { readFileSync } from 'node:fs';
27
+ import { auditLog, SapError } from '@cspeach/sap-client';
28
+ import { verifyAndSpendApprovalId } from '../approvals/jwt.js';
29
+ import { grantClaimMismatch, 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';
34
+ import { getLook } from '../renderer/look.js';
35
+ import { getEffectiveBodyWidth } from '../renderer/tty.js';
36
+ import { verifySyntax, verifyActive } from './verify.js';
37
+ import { teamFixOffers } from '../register/offer.js';
38
+ import { isJourneyActive } from '../journey/flow.js';
39
+ import { appendPending, finalizeToolCall } from '../session/pending.js';
40
+ import { ERR } from '../errors/codes.js';
41
+ import { resolveWriteTransport } from './transport-resolution.js';
42
+ import { noteWriteTransport } from '../repl/current-transport.js';
43
+ import { presentSafetyConfirmation } from '../repl/safety-confirm.js';
44
+ import { isSafetyConfirmEnabled } from '../repl/safety-mode-state.js';
45
+ // ── Transcript visibility helpers (D16-display + tour slide-7, 2026-06-12) ──
46
+ //
47
+ // Rule 7 auto-snapshots always fired but the transcript never said so
48
+ // (battery D16-display). And the tour shows writes as
49
+ // "① snapshot ✓ ② written ✓ ③ syntax ✓ ④ activated ✓" while real
50
+ // transcripts showed an opaque tool row. Both lines below are HUMAN-stream
51
+ // only (ctx.chunkEmitter) — tool RESULT payloads the model sees are
52
+ // unchanged, and subagent ctxs (no chunkEmitter) stay silent.
53
+ /** Dim one-liner when the Rule 7 auto-snapshot actually captured source. */
54
+ export function emitSnapshotNotice(ctx, snap, type, name) {
55
+ if (snap?.taken && snap.entry) {
56
+ ctx.chunkEmitter?.emit('info', `snapshot ✓ ${type} ${name} ${snap.entry.timestamp}`);
57
+ }
58
+ }
59
+ /** Distinct LOUD note when the write proceeds against a fresh/never-activated
60
+ * shell that has no committed active version — nothing to snapshot (Rule 7 is
61
+ * satisfied because there is no prior source to protect). Kept separate from
62
+ * the 'new_object' (genuine 404) path so the transcript is honest about which
63
+ * case it was — this is the RAP behaviour-pool ZBP first-write. */
64
+ export function emitNewShellNote(ctx, type, name) {
65
+ ctx.chunkEmitter?.emit('warn', `${type} ${name}: new/empty shell — no active version, no prior source to snapshot; proceeding with first write`);
66
+ }
67
+ /** Map the snapshot-gate outcome onto the chain line's honest states.
68
+ * `undefined` covers the best-effort gates whose failure was swallowed.
69
+ * 'object_not_found' is a genuine ADT 404 only; any other read failure maps
70
+ * to 'source_read_failed' (+ short detail) — never to "(new object)".
71
+ * 'new_shell' (exists but no active version — nothing to save) reuses the
72
+ * 'new_object' chain glyph; the honest which-case distinction is carried by
73
+ * the LOUD emitNewShellNote line, not the compact chain. */
74
+ export function snapshotChainState(snap) {
75
+ if (snap?.taken)
76
+ return { snapshot: 'taken' };
77
+ if (snap?.reason === 'object_not_found' || snap?.reason === 'new_shell') {
78
+ return { snapshot: 'new_object' };
79
+ }
80
+ if (snap?.reason === 'source_read_failed') {
81
+ return { snapshot: 'source_read_failed', snapshotDetail: snap.detail };
82
+ }
83
+ return { snapshot: 'best_effort_failed' };
84
+ }
85
+ /**
86
+ * Review r7 I-3 — sap-client 0.1.15's setSource/updateMethod run their own
87
+ * (correct) syntax check and, on errors, return a 2xx writeStatus with
88
+ * `syntaxResult.hasErrors` and `error: 'Syntax check found errors —
89
+ * activation skipped'`. The source IS written (an inactive version exists):
90
+ * that is written-with-errors — the normal Rule 10 gate below reports it —
91
+ * never write_failed.
92
+ */
93
+ export function writtenWithSyntaxErrors(r) {
94
+ return r.writeStatus !== undefined && r.writeStatus >= 200 && r.writeStatus < 300 && r.syntaxResult?.hasErrors === true;
95
+ }
96
+ export async function verifyWriteActivation(ctx, r, name, type) {
97
+ const a = r.activationResult;
98
+ if (!a)
99
+ return undefined;
100
+ const errors = (a.messages ?? []).filter((m) => m.severity === 'error').map((m) => m.shortText ?? '').filter(Boolean).slice(0, 5);
101
+ if (!a.success)
102
+ return { success: false, verified: false, errors };
103
+ const v = await verifyActive(ctx.adt, name, type);
104
+ if (v.verifyError !== undefined)
105
+ return { success: true, verified: false, errors, verifyError: v.verifyError };
106
+ return v.active ? { success: true, verified: true, errors } : { success: true, verified: false, errors, stillInactive: true };
107
+ }
108
+ /** Round 8 item 5 + A-I1 — the change-frame state from the verified activation. */
109
+ export function chainActivation(w) {
110
+ if (!w)
111
+ return undefined;
112
+ if (!w.success)
113
+ return { state: 'failed', detail: w.errors[0] };
114
+ if (w.verified)
115
+ return { state: 'active' };
116
+ return w.stillInactive ? { state: 'still_inactive' } : { state: 'unverified' };
117
+ }
118
+ /** Guided clean-core journey on: the fix lines in solo words (register/offer.ts). */
119
+ export const journeyWording = (ctx) => (isJourneyActive(ctx.session) ? { wording: 'journey' } : {});
120
+ /**
121
+ * 1.1.31 — the write itself activated and verified an object of the joined team
122
+ * project: CSPeach wrote it, so the fix is recorded for the developer (rehearsal
123
+ * 2026-10-07: the refactor's write activated the class; no separate sap_activate
124
+ * ran, so the hook in sap_activate alone never fired).
125
+ */
126
+ export function recordOwnFixAfterWrite(ctx, type, name, transport, w) {
127
+ if (!w?.success || !w.verified)
128
+ return;
129
+ const written = new Map([[String(name).trim().toUpperCase(), String(transport ?? '').trim().toUpperCase()]]);
130
+ for (const line of teamFixOffers([{ type, name }], { written, ...journeyWording(ctx) }))
131
+ ctx.chunkEmitter?.emit('info', line);
132
+ }
133
+ export function activatedByWrite(w) {
134
+ if (!w)
135
+ return {};
136
+ return { activated_by_write: { success: w.success, verified: w.verified, errors: w.errors } };
137
+ }
138
+ /** A-I1 minor — after a verified activation on write, say so instead of "activation_allowed". */
139
+ export function nextStepAfterWrite(syntaxOk, w) {
140
+ if (!syntaxOk)
141
+ return 'activation_blocked_until_syntax_errors_fixed';
142
+ if (w?.success && w.verified)
143
+ return 'already activated by the write — verify with sap_inactive_objects before reporting it active';
144
+ return 'activation_allowed';
145
+ }
146
+ /** Compact ①②③④ pipeline summary — derived from the real gate results.
147
+ * Panels look: a change card (same honest chain in its footer). */
148
+ export function emitWriteChain(ctx, input) {
149
+ const text = getLook() === 'panels' && input.card
150
+ ? buildChangeCard(input, getEffectiveBodyWidth())
151
+ : buildWriteChainLine(input);
152
+ ctx.chunkEmitter?.emit('chunk', text + '\n');
153
+ }
154
+ /** Old source for the change card: ONLY from a snapshot actually taken. */
155
+ export function snapshotSource(snap) {
156
+ if (!snap?.taken || !snap.entry?.filePath)
157
+ return undefined;
158
+ try {
159
+ return readFileSync(snap.entry.filePath, 'utf8');
160
+ }
161
+ catch {
162
+ return undefined;
163
+ }
164
+ }
165
+ const refuse = (body) => ({ content: JSON.stringify(body), is_error: true });
166
+ /** Rule 7 recovery text for a hard source snapshot that could not read the object. */
167
+ const SOURCE_READ_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.';
168
+ /** Refuses a spec that would skip a gate without saying why. */
169
+ export function validateWriteToolSpec(spec) {
170
+ if (spec.snapshot.kind === 'none' && !String(spec.snapshot.reason ?? '').trim()) {
171
+ throw new Error(`${spec.tool}: snapshot 'none' skips Rule 7 — a reason is required`);
172
+ }
173
+ }
174
+ /** The registered handler for a spec. Validates the spec now (registration time). */
175
+ export function writeToolHandler(spec) {
176
+ validateWriteToolSpec(spec);
177
+ return (args, ctx) => runWritePipeline(args, ctx, spec);
178
+ }
179
+ /** Rule 7 — runs the spec's snapshot gate. A ToolResult means: refused, stop. */
180
+ async function snapshotGate(a, ctx, spec, pre) {
181
+ const s = spec.snapshot;
182
+ if (s.kind === 'none')
183
+ return undefined;
184
+ if (s.kind === 'source') {
185
+ const type = s.type(a);
186
+ const name = s.name(a);
187
+ if (!s.hard) {
188
+ // Best-effort: the object may be a shell with no readable source.
189
+ try {
190
+ pre.snapshot = await autoSnapshot(name, type, ctx.adt);
191
+ }
192
+ catch { /* swallowed, as before */ }
193
+ emitSnapshotNotice(ctx, pre.snapshot, type, name);
194
+ return undefined;
195
+ }
196
+ // HARD gate: only a genuine 404 (new object) may proceed snapshot-less. A
197
+ // read failure on a possibly-EXISTING object means no recovery copy → block.
198
+ try {
199
+ pre.snapshot = await autoSnapshot(name, type, ctx.adt);
200
+ }
201
+ catch (err) {
202
+ return refuse({ error: ERR.SNAPSHOT_FAILED, detail: String(err) });
203
+ }
204
+ if (pre.snapshot?.reason === 'source_read_failed') {
205
+ return refuse({
206
+ error: ERR.SNAPSHOT_FAILED,
207
+ reason: 'source_read_failed',
208
+ detail: pre.snapshot.detail,
209
+ recovery: s.recovery ?? SOURCE_READ_RECOVERY,
210
+ });
211
+ }
212
+ // Fresh/never-activated shell (no active version → nothing to snapshot):
213
+ // proceed like a new object, but say so LOUDLY and distinctly.
214
+ if (pre.snapshot?.reason === 'new_shell')
215
+ emitNewShellNote(ctx, type, name);
216
+ emitSnapshotNotice(ctx, pre.snapshot, type, name);
217
+ return undefined;
218
+ }
219
+ if (s.kind === 'include') {
220
+ // Of the INCLUDE being overwritten; non-blocking, but a missing restore
221
+ // point is said out loud.
222
+ const className = s.className(a);
223
+ const includeType = s.includeType(a);
224
+ try {
225
+ pre.snapshot = await autoSnapshotClassInclude(className, includeType, ctx.adt);
226
+ }
227
+ catch (err) {
228
+ pre.snapshot = { taken: false, reason: 'source_read_failed', detail: String(err) };
229
+ }
230
+ emitSnapshotNotice(ctx, pre.snapshot, includeSnapshotType(includeType), className);
231
+ if (pre.snapshot?.reason === 'source_read_failed') {
232
+ ctx.chunkEmitter?.emit('warn', `CLAS ${className} ${includeType}: could not snapshot the include before writing `
233
+ + `(${pre.snapshot.detail ?? 'read failed'}) — this write has NO restore point; rewind cannot undo it`);
234
+ }
235
+ return undefined;
236
+ }
237
+ // messages — HARD gate, of the whole message class.
238
+ const name = s.name(a);
239
+ let snap;
240
+ try {
241
+ snap = await autoSnapshotMessageClass(name, ctx.adt);
242
+ }
243
+ catch (err) {
244
+ return refuse({ error: ERR.SNAPSHOT_FAILED, detail: String(err) });
245
+ }
246
+ pre.snapshot = snap;
247
+ if (snap.reason === 'object_not_found') {
248
+ return refuse({
249
+ error: 'message_class_not_found',
250
+ detail: `Message class ${name} does not exist. Create it first with sap_create_object type MSAG, then add the messages.`,
251
+ });
252
+ }
253
+ if (!snap.taken) {
254
+ return refuse({
255
+ error: ERR.SNAPSHOT_FAILED,
256
+ reason: snap.reason ?? 'source_read_failed',
257
+ detail: snap.detail,
258
+ 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.',
259
+ });
260
+ }
261
+ emitSnapshotNotice(ctx, snap, 'MSAG', name);
262
+ return undefined;
263
+ }
264
+ /** True only for SAP's own 404: the object is not there. Anything else is no proof. */
265
+ const isNotFound = (err) => err instanceof SapError && err.httpStatus === 404;
266
+ /**
267
+ * D7 — the audit row's status and reason from the verified outcome.
268
+ * `postCheckError`: the delete post-check could not run, so nothing proves the
269
+ * object is gone (final review I-1).
270
+ */
271
+ function auditOutcome(syntax, stillExists, postCheckError) {
272
+ if (syntax && !syntax.ok) {
273
+ const why = syntax.didNotRun !== undefined
274
+ ? `syntax check did not run: ${syntax.didNotRun}`
275
+ : `syntax check found errors: ${syntax.errors.slice(0, 3).join('; ')}`;
276
+ return { status: 'error', reason: why };
277
+ }
278
+ if (stillExists === true)
279
+ return { status: 'error', reason: 'object still exists after the delete' };
280
+ if (postCheckError !== undefined)
281
+ return { status: 'error', reason: `delete not confirmed: ${postCheckError}` };
282
+ return { status: 'success' };
283
+ }
284
+ /**
285
+ * Runs one write through every gate, in the fixed order. Never throws for a
286
+ * SAP failure: each refusal is the envelope the tool always returned.
287
+ */
288
+ export async function runWritePipeline(args, ctx, spec) {
289
+ const raw = args;
290
+ // ── Before the approval is spent (input / capability checks) ──────────────
291
+ if (spec.beforeApproval) {
292
+ const refusal = spec.beforeApproval(args, ctx);
293
+ if (refusal)
294
+ return refuse(refusal);
295
+ }
296
+ // ── Gate 1: Approval ──────────────────────────────────────────────────────
297
+ const verify = await verifyAndSpendApprovalId(raw.approval_id, spec.approval.object(args), spec.approval.op);
298
+ if (!verify.ok)
299
+ return refuse({ error: ERR.APPROVAL_INVALID, reason: verify.reason });
300
+ // 2026-10-04 — a plan-grant id holds the write to its claims (type, package, transport).
301
+ if (spec.approval.claims) {
302
+ const claim = grantClaimMismatch(verify.payload, spec.approval.claims(args, ctx));
303
+ if (claim)
304
+ return refuse({ error: ERR.APPROVAL_INVALID, reason: claim });
305
+ }
306
+ const pre = { toolUseId: ctx.toolUseId ?? crypto.randomUUID(), startedAt: Date.now(), extra: {} };
307
+ // ── Rule 9 conflict warning ───────────────────────────────────────────────
308
+ // The LLM passed an explicit transport that differs from the session
309
+ // current-transport. Informational only; the caller's intent wins.
310
+ {
311
+ const sessionTransport = ctx.currentTransport?.get() ?? null;
312
+ if (raw.transport && sessionTransport && raw.transport !== sessionTransport) {
313
+ ctx.chunkEmitter?.emit('warn', `LLM passed transport=${raw.transport} but session current-transport=${sessionTransport}`);
314
+ }
315
+ }
316
+ // ── Rule 9 transport ──────────────────────────────────────────────────────
317
+ if (spec.transport.mode === 'resolve' || spec.transport.mode === 'fallback-resolve') {
318
+ // D15/D20 — an object already locked in an open request is written under
319
+ // THAT request; any other transport is a guaranteed CTS lock conflict.
320
+ const supplied = spec.transport.mode === 'fallback-resolve'
321
+ ? raw.transport ?? ctx.currentTransport?.get() ?? undefined
322
+ : raw.transport;
323
+ const resolved = await resolveWriteTransport(ctx, spec.transport.type(args), spec.transport.name(args), supplied);
324
+ pre.transport = resolved.transport;
325
+ pre.owning = resolved.owning;
326
+ pre.note = resolved.note;
327
+ // 2026-10-04 — a plan-grant id never writes into a transport other than the grant's.
328
+ if (spec.transport.grantMismatch && grantOwningTransportMismatch(verify.payload, resolved.owning)) {
329
+ return refuse({
330
+ error: ERR.APPROVAL_INVALID, reason: 'transport_mismatch',
331
+ detail: grantOwningTransportDetail(spec.transport.name(args), resolved.owning),
332
+ });
333
+ }
334
+ }
335
+ else {
336
+ // Three-tier fallback: explicit arg → session current-transport → none.
337
+ pre.transport = raw.transport ?? ctx.currentTransport?.get() ?? undefined;
338
+ }
339
+ // ── Gate 2: Snapshot (Rule 7) ─────────────────────────────────────────────
340
+ const snapRefusal = await snapshotGate(args, ctx, spec, pre);
341
+ if (snapRefusal)
342
+ return snapRefusal;
343
+ // ── Gate 3: Safety confirmation (Rule 7) ──────────────────────────────────
344
+ if (spec.safetyConfirm && isSafetyConfirmEnabled()) {
345
+ const r = await presentSafetyConfirmation(spec.safetyConfirm(args, pre.transport ?? ctx.currentTransport?.get() ?? ''));
346
+ if (!r.confirmed)
347
+ return refuse({ error: 'cancelled_by_user', reason: r.reason });
348
+ }
349
+ // ── preGate (preview, transport fit, package prepare) ─────────────────────
350
+ if (spec.preGate) {
351
+ const g = await spec.preGate(args, ctx, pre);
352
+ if ('error' in g)
353
+ return refuse(g.error);
354
+ if (g.extra)
355
+ Object.assign(pre.extra, g.extra);
356
+ }
357
+ // ── WAL: record pending entry ─────────────────────────────────────────────
358
+ await appendPending(ctx.session, {
359
+ tool_use_id: pre.toolUseId,
360
+ tool: spec.tool,
361
+ args: spec.walArgs(args),
362
+ started_at: new Date().toISOString(),
363
+ });
364
+ // ── walGate (a step that changes SAP before the call; inside the entry) ───
365
+ if (spec.walGate) {
366
+ const g = await spec.walGate(args, ctx, pre);
367
+ if ('error' in g) {
368
+ await finalizeToolCall(ctx.session, pre.toolUseId, g.log, true, Date.now() - pre.startedAt);
369
+ return refuse(g.error);
370
+ }
371
+ if (g.extra)
372
+ Object.assign(pre.extra, g.extra);
373
+ }
374
+ // ── The write ─────────────────────────────────────────────────────────────
375
+ // sap-client write methods THROW only for catastrophic errors; an HTTP
376
+ // failure comes back in the result, so `landed` must look at it — without
377
+ // it a failed write reads as success (memory/project_phase3_lite_regression.md).
378
+ let result;
379
+ try {
380
+ result = await spec.call(args, ctx, pre);
381
+ }
382
+ catch (err) {
383
+ const errStr = String(err);
384
+ await finalizeToolCall(ctx.session, pre.toolUseId, spec.throwLog ? spec.throwLog(err) : errStr, true, Date.now() - pre.startedAt);
385
+ return refuse(spec.onThrow?.(err, args, pre) ?? { error: spec.errorCode, detail: errStr });
386
+ }
387
+ const landed = spec.landed(result, pre);
388
+ if (!landed.ok) {
389
+ if (landed.audit) {
390
+ await auditLog(spec.audit.method, spec.audit.type(args), spec.audit.name(args), landed.audit.status, Date.now() - pre.startedAt, landed.audit.reason);
391
+ }
392
+ await finalizeToolCall(ctx.session, pre.toolUseId, landed.log ?? landed.detail ?? '', true, Date.now() - pre.startedAt);
393
+ return refuse(landed.body ?? { error: spec.errorCode, detail: landed.detail, ...(landed.extra ?? {}) });
394
+ }
395
+ const none = spec.noWrite?.(args, result, pre);
396
+ if (none) {
397
+ await finalizeToolCall(ctx.session, pre.toolUseId, JSON.stringify(none.walResult), false, Date.now() - pre.startedAt);
398
+ return { content: JSON.stringify(none.envelope) };
399
+ }
400
+ noteWriteTransport(pre.transport); // F-L2-1 statusline wire
401
+ // ── Gate 4: Verify (Rule 10) ──────────────────────────────────────────────
402
+ const type = spec.audit.type(args);
403
+ const name = spec.audit.name(args);
404
+ const syntax = spec.verify.syntax ? await verifySyntax(ctx.adt, name, type) : undefined;
405
+ let stillExists;
406
+ let postCheckError;
407
+ if (spec.verify.deletedCheck) {
408
+ // The envelope reads a failed check as "gone" (as it always has); only a
409
+ // 404 proves it, so any other failure keeps the audit row off success.
410
+ stillExists = await ctx.adt.objectStructure(type, name).then(() => true).catch((err) => {
411
+ if (!isNotFound(err))
412
+ postCheckError = String(err);
413
+ return false;
414
+ });
415
+ pre.extra['still_exists'] = stillExists;
416
+ }
417
+ // ── D7: the audit row, after the check; its status follows the verified outcome.
418
+ {
419
+ const outcome = auditOutcome(syntax, stillExists, postCheckError);
420
+ const detail = [spec.audit.detail?.(args, result), outcome.reason].filter((x) => !!x).join(' | ');
421
+ await auditLog(spec.audit.method, type, name, outcome.status, Date.now() - pre.startedAt, detail || undefined);
422
+ }
423
+ await finalizeToolCall(ctx.session, pre.toolUseId, JSON.stringify(spec.walResult ? spec.walResult(args, result, pre, syntax, stillExists)
424
+ : spec.verify.deletedCheck ? { deleted: !stillExists }
425
+ : syntax ? { written: true, syntax }
426
+ : { written: true }), false, Date.now() - pre.startedAt);
427
+ const act = spec.verify.activation
428
+ ? await verifyWriteActivation(ctx, result, name, type)
429
+ : undefined;
430
+ // Tour slide-7 chain — derived from the gates that actually ran.
431
+ if (spec.chain)
432
+ emitWriteChain(ctx, spec.chain(args, pre, syntax, act));
433
+ spec.after?.(args, result, pre, ctx);
434
+ if (spec.verify.activation)
435
+ recordOwnFixAfterWrite(ctx, type, name, pre.owning ?? pre.transport, act);
436
+ return { content: JSON.stringify(spec.envelope(args, result, pre, syntax, act)) };
437
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cspeach/cli",
3
- "version": "1.1.40",
3
+ "version": "1.1.41",
4
4
  "description": "CSPeach — AI-assisted ABAP development CLI. Direct SAP access, signed skills, safety gates.",
5
5
  "keywords": [
6
6
  "abap",
@@ -54,7 +54,7 @@
54
54
  "@anthropic-ai/sdk": "^0.90.0",
55
55
  "@azure/msal-node": "6.0.1",
56
56
  "@cspeach/register-core": "0.4.0",
57
- "@cspeach/sap-client": "0.1.25",
57
+ "@cspeach/sap-client": "0.1.26",
58
58
  "@iarna/toml": "^2.2.5",
59
59
  "@inkjs/ui": "^2.0.0",
60
60
  "@inquirer/prompts": "^7.0.0",