@cspeach/cli 1.1.39 → 1.1.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/loop.js +19 -2
- package/dist/agent/tool-dispatch.js +17 -0
- package/dist/sap/profile-features.js +42 -0
- package/dist/sap/profile-gate.js +105 -0
- package/dist/sap/system-info.js +16 -11
- package/dist/sap/system-profile.js +337 -0
- package/dist/session/resume.js +3 -0
- package/dist/tools/approval.js +12 -0
- package/dist/tools/rap/finish-check.js +1 -0
- package/dist/tools/rap/generate-objects.js +1 -0
- package/dist/tools/sap-read.js +6 -0
- package/dist/tools/sap-write.js +527 -1217
- package/dist/tools/text-elements/tools.js +2 -0
- package/dist/tools/verify.js +63 -14
- package/dist/tools/write-pipeline.js +437 -0
- package/package.json +2 -2
|
@@ -103,6 +103,7 @@ async function screenNamesOf(ctx, name) {
|
|
|
103
103
|
// ── sap_get_text_elements ─────────────────────────────────────────────────────
|
|
104
104
|
registerTool({
|
|
105
105
|
name: 'sap_get_text_elements',
|
|
106
|
+
requires: 'text.programs',
|
|
106
107
|
description: 'Read a program\'s text elements: selection texts (labels of PARAMETERS / SELECT-OPTIONS), '
|
|
107
108
|
+ 'text symbols (TEXT-001) and list headings. Read-only. For selections it also lists '
|
|
108
109
|
+ 'selection-screen fields that still have NO text (without_text) — those show their raw name '
|
|
@@ -158,6 +159,7 @@ registerTool({
|
|
|
158
159
|
// ── sap_set_text_elements ─────────────────────────────────────────────────────
|
|
159
160
|
registerTool({
|
|
160
161
|
name: 'sap_set_text_elements',
|
|
162
|
+
requires: 'text.programs',
|
|
161
163
|
description: 'Add or change a program\'s text elements — one kind per call: selections (selection texts, max 30 chars, '
|
|
162
164
|
+ 'key = the PARAMETERS / SELECT-OPTIONS name), symbols (text symbols, key = the 3-char id of TEXT-xxx, max 132) '
|
|
163
165
|
+ 'or headings (listHeader max 70, columnHeader_1..4 max 132). MERGES with what is there: texts you do not name '
|
package/dist/tools/verify.js
CHANGED
|
@@ -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
|
-
//
|
|
159
|
-
//
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
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
|
|
226
|
-
// same-named
|
|
227
|
-
|
|
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 (!
|
|
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.
|
|
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.
|
|
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",
|