@cspeach/cli 1.1.27 → 1.1.28
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 +33 -4
- package/dist/agent/provider-shape.js +9 -2
- package/dist/agent/providers/ai-hub-provider.js +2 -2
- package/dist/agent/providers/byok-provider.js +2 -2
- package/dist/agent/providers/local-provider.js +4 -4
- package/dist/agent/system-prompt.js +34 -7
- package/dist/agent/tool-dispatch.js +20 -1
- package/dist/approvals/grant-claims.js +70 -0
- package/dist/approvals/jwt.js +21 -1
- package/dist/approvals/plan-grant.js +297 -0
- package/dist/commands/config-set.js +12 -0
- package/dist/commands/deploy-card.js +10 -5
- package/dist/commands/export-audit.js +1 -1
- package/dist/commands/plan-audit-evidence.js +47 -1
- package/dist/commands/plan-audit.js +23 -8
- package/dist/commands/plan-batch-audit.js +349 -0
- package/dist/commands/plan-boundary.js +88 -0
- package/dist/commands/plan-chain.js +409 -566
- package/dist/commands/plan-continue.js +21 -7
- package/dist/commands/plan-gate.js +28 -49
- package/dist/commands/plan-reground.js +174 -0
- package/dist/commands/plan-resume.js +227 -103
- package/dist/commands/plan-sap-pause.js +105 -0
- package/dist/commands/plan-startup.js +218 -0
- package/dist/config/loader.js +9 -0
- package/dist/doctor/checks/forge-rules.js +67 -85
- package/dist/doctor/run.js +4 -4
- package/dist/lane/app-chain.js +88 -9
- package/dist/one-shot.js +1 -0
- package/dist/projects/extract-plan.js +34 -1
- package/dist/projects/handover-md.js +1 -1
- package/dist/projects/plan-objects.js +75 -0
- package/dist/projects/plan-run.js +74 -18
- package/dist/projects/plan-schema.js +6 -0
- package/dist/projects/plan-sources.js +199 -0
- package/dist/projects/promote-command.js +1 -1
- package/dist/projects/save-command.js +11 -13
- package/dist/projects/saved-lines.js +36 -0
- package/dist/renderer/checklist-format.js +5 -5
- package/dist/renderer/machine-blocks.js +141 -0
- package/dist/renderer/tool-labels.js +3 -0
- package/dist/repl/plan-hop.js +22 -0
- package/dist/repl/rule8-detector.js +16 -1
- package/dist/repl.js +320 -55
- package/dist/router/router-core.js +2 -2
- package/dist/sap/reachability-state.js +25 -0
- package/dist/session/audit-export.js +10 -16
- package/dist/session/recap.js +16 -26
- package/dist/skills/bundled-skills.js +1 -1
- package/dist/tools/_filesystem-shared.js +37 -0
- package/dist/tools/approval.js +48 -2
- package/dist/tools/dispatch-skill.js +3 -2
- package/dist/tools/filesystem/file-read.js +5 -3
- package/dist/tools/filesystem/glob.js +27 -4
- package/dist/tools/fiori/tools.js +6 -0
- package/dist/tools/lane/app-lane-resolve.js +1 -1
- package/dist/tools/sap-read.js +37 -3
- package/dist/tools/sap-write.js +65 -0
- package/dist/tools/text-elements/pool.js +250 -0
- package/dist/tools/text-elements/tools.js +433 -0
- package/dist/tools/tool-loading.js +3 -0
- package/dist/tools/transport.js +26 -5
- package/dist/tools/write-mode.js +4 -3
- package/package.json +120 -121
- package/dist/projects/email-template.js +0 -56
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sap_get_text_elements / sap_set_text_elements — a program's text elements:
|
|
3
|
+
* selection texts (the labels of PARAMETERS / SELECT-OPTIONS), text symbols
|
|
4
|
+
* (TEXT-001) and list headings.
|
|
5
|
+
*
|
|
6
|
+
* Why (2026-10-04, S4H demo rehearsal): /abap-refactor added checkbox P_SUBTOT
|
|
7
|
+
* and the screen showed the raw name — CSPeach could not write selection texts
|
|
8
|
+
* and told the user to go to SE38. Owner rule: always descriptive selection
|
|
9
|
+
* texts on ABAP selection screens.
|
|
10
|
+
*
|
|
11
|
+
* The write runs the SAME gates as every other SAP write:
|
|
12
|
+
* 1 approval_id (request_approval; advisory-only never mints one)
|
|
13
|
+
* 2 write-mode clamp, defence in depth: advisory-only and a prd-tagged alias
|
|
14
|
+
* refuse here too
|
|
15
|
+
* 3 owning-transport resolution (object locked in a request → that request;
|
|
16
|
+
* else the supplied / session transport)
|
|
17
|
+
* 4 read the current part — HARD: no merge without it, a blind PUT would
|
|
18
|
+
* drop every text already there
|
|
19
|
+
* 5 validate (names, lengths) + check names against the selection screen
|
|
20
|
+
* 6 merge — existing texts kept byte for byte
|
|
21
|
+
* 7 Rule 7 snapshot of the current part (PROG.TEXT_<KIND>) — HARD
|
|
22
|
+
* 8 WAL → lock textelements uri / PUT / unlock (sap-client)
|
|
23
|
+
* 9 activate the text pool (texts land inactive), then Rule 10 read-back:
|
|
24
|
+
* every new text present AND no old text lost.
|
|
25
|
+
*
|
|
26
|
+
* The SAP calls live in @cspeach/sap-client >= 0.1.16 (getTextElementsSource,
|
|
27
|
+
* setTextElementsSource, activateTextElements). On an older pin the tools say
|
|
28
|
+
* so plainly instead of failing obscurely.
|
|
29
|
+
*/
|
|
30
|
+
import crypto from 'node:crypto';
|
|
31
|
+
import { registerTool } from '../index.js';
|
|
32
|
+
import { verifyAndSpendApprovalId } from '../../approvals/jwt.js';
|
|
33
|
+
import { grantClaimMismatch, grantOwningTransportMismatch, grantOwningTransportDetail } from '../../approvals/grant-claims.js';
|
|
34
|
+
import { appendPending, finalizeToolCall } from '../../session/pending.js';
|
|
35
|
+
import { snapshots, auditLog } from '@cspeach/sap-client';
|
|
36
|
+
import { ERR } from '../../errors/codes.js';
|
|
37
|
+
import { resolveWriteTransport } from '../transport-resolution.js';
|
|
38
|
+
import { noteWriteTransport } from '../../repl/current-transport.js';
|
|
39
|
+
import { loadConfig } from '../../config/loader.js';
|
|
40
|
+
import { getEffectiveWriteMode } from '../../repl/mode-cycle.js';
|
|
41
|
+
import { buildWriteChainLine } from '../../renderer/verify-chain.js';
|
|
42
|
+
import { TEXT_ELEMENT_KINDS, isTextElementKind, parseTextPool, formatTextPool, mergeTextPool, validateUpdates, selectionScreenNames, textElementsSnapshotType, canonicalKey, } from './pool.js';
|
|
43
|
+
function textAdt(ctx) {
|
|
44
|
+
const a = ctx.adt;
|
|
45
|
+
return typeof a.getTextElementsSource === 'function'
|
|
46
|
+
&& typeof a.setTextElementsSource === 'function'
|
|
47
|
+
&& typeof a.activateTextElements === 'function'
|
|
48
|
+
? a
|
|
49
|
+
: null;
|
|
50
|
+
}
|
|
51
|
+
const UNSUPPORTED = {
|
|
52
|
+
content: JSON.stringify({
|
|
53
|
+
error: 'text_elements_unsupported',
|
|
54
|
+
detail: 'This CSPeach build\'s SAP client cannot read or write text elements yet (needs @cspeach/sap-client 0.1.16 or later). '
|
|
55
|
+
+ 'Tell the user the selection texts to add in SE38 → Goto → Text Elements, and list them.',
|
|
56
|
+
}),
|
|
57
|
+
is_error: true,
|
|
58
|
+
};
|
|
59
|
+
function errorEnvelope(body) {
|
|
60
|
+
return { content: JSON.stringify(body), is_error: true };
|
|
61
|
+
}
|
|
62
|
+
function httpStatusOf(err) {
|
|
63
|
+
const s = err?.httpStatus;
|
|
64
|
+
return typeof s === 'number' ? s : undefined;
|
|
65
|
+
}
|
|
66
|
+
function shortError(err) {
|
|
67
|
+
return (err instanceof Error ? err.message : String(err)).replace(/\s+/g, ' ').trim().slice(0, 400);
|
|
68
|
+
}
|
|
69
|
+
function programName(raw) {
|
|
70
|
+
if (typeof raw !== 'string')
|
|
71
|
+
return null;
|
|
72
|
+
const n = raw.trim().toUpperCase();
|
|
73
|
+
return /^[A-Z0-9_/]{1,40}$/.test(n) ? n : null;
|
|
74
|
+
}
|
|
75
|
+
/** Entries as the model sees them: texted only, compact. */
|
|
76
|
+
function viewEntries(kind, entries) {
|
|
77
|
+
return entries
|
|
78
|
+
.filter((e) => !e.untexted && e.text !== '')
|
|
79
|
+
.map((e) => ({
|
|
80
|
+
key: e.key,
|
|
81
|
+
text: e.text,
|
|
82
|
+
...(kind === 'symbols' && e.maxLength !== undefined ? { max_length: e.maxLength } : {}),
|
|
83
|
+
...(e.annotations?.length ? { annotations: e.annotations } : {}),
|
|
84
|
+
}));
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Selection-screen names of a program: the active source plus, best-effort,
|
|
88
|
+
* a pending inactive version (a field written but not yet activated is still
|
|
89
|
+
* a real field). Throws only when NO version could be read.
|
|
90
|
+
*/
|
|
91
|
+
async function screenNamesOf(ctx, name) {
|
|
92
|
+
const active = selectionScreenNames(await ctx.adt.getSource('PROG', name));
|
|
93
|
+
try {
|
|
94
|
+
const inactive = selectionScreenNames(await ctx.adt.getSource('PROG', name, 'inactive'));
|
|
95
|
+
for (const n of inactive.names)
|
|
96
|
+
active.names.add(n);
|
|
97
|
+
return { names: active.names, hasIncludes: active.hasIncludes || inactive.hasIncludes };
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return active;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// ── sap_get_text_elements ─────────────────────────────────────────────────────
|
|
104
|
+
registerTool({
|
|
105
|
+
name: 'sap_get_text_elements',
|
|
106
|
+
description: 'Read a program\'s text elements: selection texts (labels of PARAMETERS / SELECT-OPTIONS), '
|
|
107
|
+
+ 'text symbols (TEXT-001) and list headings. Read-only. For selections it also lists '
|
|
108
|
+
+ 'selection-screen fields that still have NO text (without_text) — those show their raw name '
|
|
109
|
+
+ 'on the screen; give each one a text with sap_set_text_elements.',
|
|
110
|
+
isMutating: false,
|
|
111
|
+
requiresSap: true,
|
|
112
|
+
input_schema: {
|
|
113
|
+
type: 'object',
|
|
114
|
+
properties: {
|
|
115
|
+
name: { type: 'string', description: 'Program (report) name, e.g. ZFIN_DOC_JOURNAL' },
|
|
116
|
+
kind: { type: 'string', enum: ['selections', 'symbols', 'headings', 'all'], description: 'Which part (default all)' },
|
|
117
|
+
},
|
|
118
|
+
required: ['name'],
|
|
119
|
+
},
|
|
120
|
+
handler: async (args, ctx) => {
|
|
121
|
+
const adt = textAdt(ctx);
|
|
122
|
+
if (!adt)
|
|
123
|
+
return UNSUPPORTED;
|
|
124
|
+
const name = programName(args.name);
|
|
125
|
+
if (!name)
|
|
126
|
+
return errorEnvelope({ error: 'invalid_name', detail: 'name must be a program name' });
|
|
127
|
+
const kinds = args.kind === undefined || args.kind === 'all'
|
|
128
|
+
? [...TEXT_ELEMENT_KINDS]
|
|
129
|
+
: isTextElementKind(args.kind) ? [args.kind] : [];
|
|
130
|
+
if (kinds.length === 0)
|
|
131
|
+
return errorEnvelope({ error: 'invalid_kind', detail: 'kind is selections, symbols, headings or all' });
|
|
132
|
+
const out = { program: name };
|
|
133
|
+
for (const kind of kinds) {
|
|
134
|
+
try {
|
|
135
|
+
const src = await adt.getTextElementsSource(name, kind);
|
|
136
|
+
const entries = parseTextPool(kind, src.body);
|
|
137
|
+
const part = { entries: viewEntries(kind, entries) };
|
|
138
|
+
if (kind === 'selections') {
|
|
139
|
+
const without = new Set(entries.filter((e) => e.untexted || e.text === '').map((e) => e.key));
|
|
140
|
+
try {
|
|
141
|
+
const screen = await screenNamesOf(ctx, name);
|
|
142
|
+
for (const n of screen.names)
|
|
143
|
+
if (!entries.some((e) => e.key === n && !e.untexted && e.text !== ''))
|
|
144
|
+
without.add(n);
|
|
145
|
+
}
|
|
146
|
+
catch { /* the text pool list alone still answers */ }
|
|
147
|
+
part.without_text = [...without];
|
|
148
|
+
}
|
|
149
|
+
out[kind] = part;
|
|
150
|
+
}
|
|
151
|
+
catch (err) {
|
|
152
|
+
out[kind] = { error: httpStatusOf(err) === 404 ? 'not_found' : 'read_failed', detail: shortError(err) };
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return { content: JSON.stringify(out) };
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
// ── sap_set_text_elements ─────────────────────────────────────────────────────
|
|
159
|
+
registerTool({
|
|
160
|
+
name: 'sap_set_text_elements',
|
|
161
|
+
description: 'Add or change a program\'s text elements — one kind per call: selections (selection texts, max 30 chars, '
|
|
162
|
+
+ 'key = the PARAMETERS / SELECT-OPTIONS name), symbols (text symbols, key = the 3-char id of TEXT-xxx, max 132) '
|
|
163
|
+
+ 'or headings (listHeader max 70, columnHeader_1..4 max 132). MERGES with what is there: texts you do not name '
|
|
164
|
+
+ 'are kept. Requires approval_id (request_approval, op modify, object = the program, type PROG). Snapshots the '
|
|
165
|
+
+ 'current texts, writes, activates the text pool and reads it back. Whenever you add or change a selection-screen '
|
|
166
|
+
+ 'field, give it a plain descriptive selection text with this tool. restore_snapshot_id (instead of texts) puts a '
|
|
167
|
+
+ 'snapshot of this part back exactly.',
|
|
168
|
+
isMutating: true,
|
|
169
|
+
requiresSap: true,
|
|
170
|
+
input_schema: {
|
|
171
|
+
type: 'object',
|
|
172
|
+
properties: {
|
|
173
|
+
name: { type: 'string', description: 'Program (report) name' },
|
|
174
|
+
kind: { type: 'string', enum: ['selections', 'symbols', 'headings'], description: 'Which part to change' },
|
|
175
|
+
texts: {
|
|
176
|
+
type: 'array',
|
|
177
|
+
description: 'Entries to add or change. Selections: [{key:"P_SUBTOT", text:"Subtotals by aging bucket"}]',
|
|
178
|
+
items: {
|
|
179
|
+
type: 'object',
|
|
180
|
+
properties: {
|
|
181
|
+
key: { type: 'string' },
|
|
182
|
+
text: { type: 'string' },
|
|
183
|
+
max_length: { type: 'number', description: 'symbols only — room for translations (default: text length)' },
|
|
184
|
+
},
|
|
185
|
+
required: ['key', 'text'],
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
restore_snapshot_id: { type: 'string', description: 'Put this snapshot of the part back (full replace). Not with texts.' },
|
|
189
|
+
transport: { type: 'string', description: 'Transport request (optional; auto-resolves to the owning request)' },
|
|
190
|
+
approval_id: { type: 'string', description: 'JWT from request_approval' },
|
|
191
|
+
},
|
|
192
|
+
required: ['name', 'kind', 'approval_id'],
|
|
193
|
+
},
|
|
194
|
+
handler: async (args, ctx) => {
|
|
195
|
+
const name = programName(args.name);
|
|
196
|
+
if (!name)
|
|
197
|
+
return errorEnvelope({ error: 'invalid_name', detail: 'name must be a program name' });
|
|
198
|
+
if (!isTextElementKind(args.kind))
|
|
199
|
+
return errorEnvelope({ error: 'invalid_kind', detail: 'kind is selections, symbols or headings' });
|
|
200
|
+
const kind = args.kind;
|
|
201
|
+
const restoreId = typeof args.restore_snapshot_id === 'string' && args.restore_snapshot_id.trim()
|
|
202
|
+
? args.restore_snapshot_id.trim() : undefined;
|
|
203
|
+
const updates = Array.isArray(args.texts) ? args.texts : [];
|
|
204
|
+
if (restoreId && updates.length > 0) {
|
|
205
|
+
return errorEnvelope({ error: 'invalid_args', detail: 'pass texts OR restore_snapshot_id, not both' });
|
|
206
|
+
}
|
|
207
|
+
// ── Gate 1: approval ──────────────────────────────────────────────────────
|
|
208
|
+
const verify = await verifyAndSpendApprovalId(args.approval_id, name, 'modify');
|
|
209
|
+
if (!verify.ok)
|
|
210
|
+
return errorEnvelope({ error: ERR.APPROVAL_INVALID, reason: verify.reason });
|
|
211
|
+
// 2026-10-04 — a plan-grant id holds the write to its claims (type, transport).
|
|
212
|
+
{
|
|
213
|
+
const claim = grantClaimMismatch(verify.payload, { type: 'PROG', transport: args.transport ?? ctx.currentTransport?.get() ?? null });
|
|
214
|
+
if (claim)
|
|
215
|
+
return errorEnvelope({ error: ERR.APPROVAL_INVALID, reason: claim });
|
|
216
|
+
}
|
|
217
|
+
// ── Gate 2: write mode (defence in depth — prd pins advisory-only) ────────
|
|
218
|
+
try {
|
|
219
|
+
const cfg = await loadConfig();
|
|
220
|
+
const role = cfg.sap?.[ctx.sapAlias]?.role;
|
|
221
|
+
if (getEffectiveWriteMode(cfg.write_mode, role) === 'advisory-only') {
|
|
222
|
+
return errorEnvelope({
|
|
223
|
+
error: 'write_mode_advisory',
|
|
224
|
+
detail: role === 'prd'
|
|
225
|
+
? `${ctx.sapAlias} is a production system — CSPeach never writes there. List the texts for the user instead.`
|
|
226
|
+
: 'Write mode is advisory-only — no SAP writes. List the texts for the user to add in SE38 → Goto → Text Elements.',
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
catch (err) {
|
|
231
|
+
return errorEnvelope({ error: 'write_mode_unknown', detail: `could not read the write mode: ${shortError(err)}` });
|
|
232
|
+
}
|
|
233
|
+
const adt = textAdt(ctx);
|
|
234
|
+
if (!adt)
|
|
235
|
+
return UNSUPPORTED;
|
|
236
|
+
// ── Validate the request before touching SAP ──────────────────────────────
|
|
237
|
+
if (!restoreId) {
|
|
238
|
+
const problems = validateUpdates(kind, updates);
|
|
239
|
+
if (problems.length > 0)
|
|
240
|
+
return errorEnvelope({ error: 'invalid_texts', problems });
|
|
241
|
+
}
|
|
242
|
+
// ── Gate 3: transport (Rule 9) ────────────────────────────────────────────
|
|
243
|
+
const sessionTransport = ctx.currentTransport?.get() ?? null;
|
|
244
|
+
if (args.transport && sessionTransport && args.transport !== sessionTransport) {
|
|
245
|
+
ctx.chunkEmitter?.emit('warn', `LLM passed transport=${args.transport} but session current-transport=${sessionTransport}`);
|
|
246
|
+
}
|
|
247
|
+
const resolved = await resolveWriteTransport(ctx, 'PROG', name, args.transport ?? sessionTransport ?? undefined);
|
|
248
|
+
// 2026-10-04 — a plan-grant id never writes into a transport other than the grant's.
|
|
249
|
+
if (grantOwningTransportMismatch(verify.payload, resolved.owning)) {
|
|
250
|
+
return errorEnvelope({ error: ERR.APPROVAL_INVALID, reason: 'transport_mismatch', detail: grantOwningTransportDetail(name, resolved.owning) });
|
|
251
|
+
}
|
|
252
|
+
// ── Gate 4: read the current part (HARD) ──────────────────────────────────
|
|
253
|
+
let currentBody;
|
|
254
|
+
try {
|
|
255
|
+
currentBody = (await adt.getTextElementsSource(name, kind)).body;
|
|
256
|
+
}
|
|
257
|
+
catch (err) {
|
|
258
|
+
return errorEnvelope({
|
|
259
|
+
error: 'text_elements_read_failed',
|
|
260
|
+
http_status: httpStatusOf(err),
|
|
261
|
+
detail: shortError(err),
|
|
262
|
+
recovery: 'The current texts could not be read, so they cannot be merged — writing now would drop them. Fix the connection or check the program exists (and is active), then retry.',
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
const existing = parseTextPool(kind, currentBody);
|
|
266
|
+
// ── Merge (or restore) ────────────────────────────────────────────────────
|
|
267
|
+
const warnings = [];
|
|
268
|
+
let newBody;
|
|
269
|
+
let merge;
|
|
270
|
+
if (restoreId) {
|
|
271
|
+
try {
|
|
272
|
+
newBody = await snapshots.read(textElementsSnapshotType(kind), name, restoreId);
|
|
273
|
+
}
|
|
274
|
+
catch (err) {
|
|
275
|
+
return errorEnvelope({ error: 'snapshot_read_failed', detail: shortError(err) });
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
else {
|
|
279
|
+
// ── Gate 5: names on the selection screen ──────────────────────────────
|
|
280
|
+
if (kind === 'selections') {
|
|
281
|
+
const known = new Set(existing.map((e) => e.key));
|
|
282
|
+
let hasIncludes = false;
|
|
283
|
+
let sourceRead = true;
|
|
284
|
+
try {
|
|
285
|
+
const screen = await screenNamesOf(ctx, name);
|
|
286
|
+
for (const n of screen.names)
|
|
287
|
+
known.add(n);
|
|
288
|
+
hasIncludes = screen.hasIncludes;
|
|
289
|
+
}
|
|
290
|
+
catch (err) {
|
|
291
|
+
sourceRead = false;
|
|
292
|
+
warnings.push(`could not read the program source to check the names (${shortError(err)})`);
|
|
293
|
+
}
|
|
294
|
+
const unknown = updates.map((u) => canonicalKey(kind, u.key)).filter((k) => !known.has(k));
|
|
295
|
+
if (unknown.length > 0) {
|
|
296
|
+
if (sourceRead && !hasIncludes) {
|
|
297
|
+
return errorEnvelope({
|
|
298
|
+
error: 'not_on_selection_screen',
|
|
299
|
+
unknown,
|
|
300
|
+
screen_fields: [...known],
|
|
301
|
+
detail: 'These names are not PARAMETERS / SELECT-OPTIONS of this program. Check the spelling (or write the source change first, then the texts).',
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
if (sourceRead)
|
|
305
|
+
warnings.push(`${unknown.join(', ')}: not found in the main source — the program has INCLUDEs, so the field may be declared there`);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
merge = mergeTextPool(kind, existing, updates);
|
|
309
|
+
if (merge.added.length === 0 && merge.changed.length === 0) {
|
|
310
|
+
return { content: JSON.stringify({ written: false, no_change: true, program: name, kind, unchanged: merge.unchanged }) };
|
|
311
|
+
}
|
|
312
|
+
if (merge.dictionaryReferenceDropped.length > 0) {
|
|
313
|
+
warnings.push(`${merge.dictionaryReferenceDropped.join(', ')}: had a dictionary reference — your text replaces it`);
|
|
314
|
+
}
|
|
315
|
+
newBody = formatTextPool(kind, merge.entries);
|
|
316
|
+
}
|
|
317
|
+
// ── Gate 6: Rule 7 snapshot of the current part (HARD) ────────────────────
|
|
318
|
+
let snapshotId;
|
|
319
|
+
try {
|
|
320
|
+
const entry = await snapshots.take(textElementsSnapshotType(kind), name, currentBody, 'before_write');
|
|
321
|
+
snapshotId = entry.id;
|
|
322
|
+
}
|
|
323
|
+
catch (err) {
|
|
324
|
+
return errorEnvelope({ error: ERR.SNAPSHOT_FAILED, detail: shortError(err) });
|
|
325
|
+
}
|
|
326
|
+
ctx.chunkEmitter?.emit('info', `snapshot ✓ ${textElementsSnapshotType(kind)} ${name} ${snapshotId}`);
|
|
327
|
+
// ── WAL + write ───────────────────────────────────────────────────────────
|
|
328
|
+
const toolUseId = ctx.toolUseId ?? crypto.randomUUID();
|
|
329
|
+
const startedAt = Date.now();
|
|
330
|
+
await appendPending(ctx.session, {
|
|
331
|
+
tool_use_id: toolUseId,
|
|
332
|
+
tool: 'sap_set_text_elements',
|
|
333
|
+
args: { name, kind },
|
|
334
|
+
started_at: new Date().toISOString(),
|
|
335
|
+
});
|
|
336
|
+
let write;
|
|
337
|
+
try {
|
|
338
|
+
write = await adt.setTextElementsSource(name, kind, newBody, resolved.transport);
|
|
339
|
+
}
|
|
340
|
+
catch (err) {
|
|
341
|
+
await finalizeToolCall(ctx.session, toolUseId, shortError(err), true, Date.now() - startedAt);
|
|
342
|
+
return errorEnvelope({ error: 'text_elements_write_failed', detail: shortError(err), snapshot_id: snapshotId });
|
|
343
|
+
}
|
|
344
|
+
if (!write.written) {
|
|
345
|
+
await finalizeToolCall(ctx.session, toolUseId, JSON.stringify(write), true, Date.now() - startedAt);
|
|
346
|
+
return errorEnvelope({
|
|
347
|
+
error: 'text_elements_write_failed',
|
|
348
|
+
detail: write.error ?? `HTTP ${write.httpStatus}`,
|
|
349
|
+
http_status: write.httpStatus,
|
|
350
|
+
snapshot_id: snapshotId,
|
|
351
|
+
...(write.lockedInTransport ? {
|
|
352
|
+
locked_in_transport: write.lockedInTransport,
|
|
353
|
+
recovery: `Retry this exact write with transport=${write.lockedInTransport} — that request already owns the program.`,
|
|
354
|
+
} : {}),
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
await auditLog('setTextElements', 'PROG', name, 'success', Date.now() - startedAt, `kind=${kind}`);
|
|
358
|
+
noteWriteTransport(resolved.transport);
|
|
359
|
+
// ── Gate 7: activate the text pool (Rule 10) ──────────────────────────────
|
|
360
|
+
const base = {
|
|
361
|
+
written: true,
|
|
362
|
+
program: name,
|
|
363
|
+
kind,
|
|
364
|
+
snapshot_id: snapshotId,
|
|
365
|
+
...(restoreId ? { restored_from: restoreId } : {}),
|
|
366
|
+
...(merge ? { added: merge.added, changed: merge.changed, kept: merge.kept } : {}),
|
|
367
|
+
...(resolved.transport ? { transport_used: resolved.transport } : {}),
|
|
368
|
+
...(resolved.note ? { transport_note: resolved.note } : {}),
|
|
369
|
+
...(warnings.length ? { warnings } : {}),
|
|
370
|
+
};
|
|
371
|
+
let activation;
|
|
372
|
+
try {
|
|
373
|
+
activation = await adt.activateTextElements(name);
|
|
374
|
+
}
|
|
375
|
+
catch (err) {
|
|
376
|
+
activation = { success: false, hasErrors: true, messages: [{ severity: 'error', shortText: shortError(err) }] };
|
|
377
|
+
}
|
|
378
|
+
if (!activation.success) {
|
|
379
|
+
const detail = activation.messages.filter((m) => m.severity === 'error').map((m) => m.shortText).filter(Boolean).join('; ')
|
|
380
|
+
|| 'activation did not succeed';
|
|
381
|
+
await finalizeToolCall(ctx.session, toolUseId, JSON.stringify({ written: true, activated: false }), false, Date.now() - startedAt);
|
|
382
|
+
emitChain(ctx, name, kind, resolved.transport, { state: 'failed', detail });
|
|
383
|
+
return errorEnvelope({
|
|
384
|
+
...base,
|
|
385
|
+
activated: false,
|
|
386
|
+
error: 'text_elements_activation_failed',
|
|
387
|
+
detail,
|
|
388
|
+
next_step: 'The texts are saved but INACTIVE. Fix the cause and activate again, or put the old texts back with restore_snapshot_id.',
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
// ── Gate 8: read back (Rule 10) — new texts there, nothing lost ───────────
|
|
392
|
+
let after;
|
|
393
|
+
try {
|
|
394
|
+
after = parseTextPool(kind, (await adt.getTextElementsSource(name, kind)).body);
|
|
395
|
+
}
|
|
396
|
+
catch (err) {
|
|
397
|
+
await finalizeToolCall(ctx.session, toolUseId, JSON.stringify({ written: true, activated: true }), false, Date.now() - startedAt);
|
|
398
|
+
emitChain(ctx, name, kind, resolved.transport, { state: 'unverified' });
|
|
399
|
+
return errorEnvelope({ ...base, activated: true, verified: false, error: 'text_elements_not_verified', detail: `read-back failed: ${shortError(err)}` });
|
|
400
|
+
}
|
|
401
|
+
const intended = parseTextPool(kind, newBody).filter((e) => !e.untexted && (kind === 'headings' || e.text !== ''));
|
|
402
|
+
const textOf = (list, key) => list.find((e) => e.key === key && !e.untexted)?.text;
|
|
403
|
+
const mismatched = intended
|
|
404
|
+
.filter((e) => (merge ? [...merge.added, ...merge.changed].includes(e.key) : true))
|
|
405
|
+
.filter((e) => (textOf(after, e.key) ?? '').trimEnd() !== e.text.trimEnd())
|
|
406
|
+
.map((e) => e.key);
|
|
407
|
+
const lost = (merge ? merge.kept : []).filter((k) => textOf(after, k) === undefined || textOf(after, k) === '');
|
|
408
|
+
const verified = mismatched.length === 0 && lost.length === 0;
|
|
409
|
+
await finalizeToolCall(ctx.session, toolUseId, JSON.stringify({ written: true, activated: true, verified }), false, Date.now() - startedAt);
|
|
410
|
+
emitChain(ctx, name, kind, resolved.transport, verified ? { state: 'active' } : { state: 'unverified' });
|
|
411
|
+
if (!verified) {
|
|
412
|
+
return errorEnvelope({
|
|
413
|
+
...base,
|
|
414
|
+
activated: true,
|
|
415
|
+
verified: false,
|
|
416
|
+
error: 'text_elements_not_verified',
|
|
417
|
+
...(mismatched.length ? { mismatched } : {}),
|
|
418
|
+
...(lost.length ? { lost } : {}),
|
|
419
|
+
detail: 'SAP accepted and activated the write, but reading the texts back does not show what was written.',
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
return { content: JSON.stringify({ ...base, activated: true, verified: true }) };
|
|
423
|
+
},
|
|
424
|
+
});
|
|
425
|
+
function emitChain(ctx, name, kind, transport, activation) {
|
|
426
|
+
ctx.chunkEmitter?.emit('chunk', buildWriteChainLine({
|
|
427
|
+
snapshot: 'taken',
|
|
428
|
+
written: true,
|
|
429
|
+
syntax: 'not_checked',
|
|
430
|
+
activation,
|
|
431
|
+
card: { object: name, what: `text elements (${kind})`, transport },
|
|
432
|
+
}) + '\n');
|
|
433
|
+
}
|
|
@@ -75,6 +75,9 @@ export const SKILL_STARTERS = Object.freeze({
|
|
|
75
75
|
'abap-upgrade-fix': ['sap_snapshot_take', 'glob'],
|
|
76
76
|
'abap-upgrade-scan': ['sap_atc_run'],
|
|
77
77
|
'abap-upgrade-verify': ['sap_atc_run'],
|
|
78
|
+
// Selection texts (2026-10-04): a new PARAMETERS / SELECT-OPTIONS gets its text in the same change.
|
|
79
|
+
'abap-refactor': ['sap_get_text_elements', 'sap_set_text_elements'],
|
|
80
|
+
'abap-generate': ['sap_get_text_elements', 'sap_set_text_elements'],
|
|
78
81
|
});
|
|
79
82
|
/** The starter tools for a skill; unknown skill (or none) ⇒ []. */
|
|
80
83
|
export function startersForSkill(skill) {
|
package/dist/tools/transport.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* transportRelease(transportNumber) → Promise<TransportResult> line 2546
|
|
11
11
|
* Released status string from SAP is 'released' (not a raw 'R' code).
|
|
12
12
|
*/
|
|
13
|
+
import { bindGrantTransport, isPlanGrantInScope } from '../approvals/plan-grant.js';
|
|
13
14
|
import { registerTool } from './index.js';
|
|
14
15
|
import { verifyAndSpendApprovalId } from '../approvals/jwt.js';
|
|
15
16
|
import { auditLog } from '@cspeach/sap-client';
|
|
@@ -103,9 +104,9 @@ registerTool({
|
|
|
103
104
|
},
|
|
104
105
|
approval_id: {
|
|
105
106
|
type: 'string',
|
|
106
|
-
description: 'JWT from request_approval
|
|
107
|
-
+ 'IDENTICAL to the description argument here — same exact string — or the '
|
|
108
|
-
+ 'approval will fail with object_mismatch.',
|
|
107
|
+
description: 'JWT from request_approval, change {op:"create", object:"<description>", type:"CTS"}. '
|
|
108
|
+
+ 'The object should be IDENTICAL to the description argument here — same exact string — or the '
|
|
109
|
+
+ 'approval will fail with object_mismatch; a type other than CTS fails with type_mismatch.',
|
|
109
110
|
},
|
|
110
111
|
},
|
|
111
112
|
required: ['description', 'approval_id'],
|
|
@@ -136,7 +137,13 @@ registerTool({
|
|
|
136
137
|
// Mirror the create success path: the owning TR becomes the session
|
|
137
138
|
// current-transport so subsequent writes pick it up via the 3-tier
|
|
138
139
|
// fallback. It is NOT marked created-this-session — it wasn't.
|
|
139
|
-
|
|
140
|
+
// 2026-10-04 — not while a plan object-list grant is in scope: no
|
|
141
|
+
// approval was checked on this path, and the grant covers creates on
|
|
142
|
+
// the session transport only when it is the grant's own. The owning
|
|
143
|
+
// transport is reported; the session pointer stays as it was.
|
|
144
|
+
const grantInScope = isPlanGrantInScope();
|
|
145
|
+
if (!grantInScope)
|
|
146
|
+
setCurrentTransport(owning.transport);
|
|
140
147
|
// Ownership-aware guidance (I1): "use it" is only honest advice when
|
|
141
148
|
// the connected user owns the lock — SAP rejects a foreign user's
|
|
142
149
|
// writes under someone else's request unless the owner adds a task.
|
|
@@ -147,7 +154,9 @@ registerTool({
|
|
|
147
154
|
+ '— SAP only accepts changes under that request; ask the owner to add you as a '
|
|
148
155
|
+ 'task user. Do NOT create a new transport either way.'
|
|
149
156
|
: `${args.for_object_name} is already locked in ${owning.transport} — use it; `
|
|
150
|
-
+
|
|
157
|
+
+ (grantInScope
|
|
158
|
+
? 'no new transport created. The session transport was not changed — name this transport on each write.'
|
|
159
|
+
: 'no new transport created. Set as session current transport for subsequent writes.');
|
|
151
160
|
ctx.chunkEmitter?.emit('info', note);
|
|
152
161
|
return {
|
|
153
162
|
content: JSON.stringify({
|
|
@@ -192,6 +201,15 @@ registerTool({
|
|
|
192
201
|
is_error: true,
|
|
193
202
|
};
|
|
194
203
|
}
|
|
204
|
+
// 2026-10-04 — only a transport approval creates a transport: an id minted
|
|
205
|
+
// for an object (a plan-grant `create ZBP_X CLAS`, reusable within its
|
|
206
|
+
// TTL) must never create transports.
|
|
207
|
+
if (String(verify.payload?.type ?? '').trim().toUpperCase().split('/')[0].trim() !== 'CTS') {
|
|
208
|
+
return {
|
|
209
|
+
content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: 'type_mismatch' }),
|
|
210
|
+
is_error: true,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
195
213
|
const startedAt = Date.now();
|
|
196
214
|
// ── Create transport ─────────────────────────────────────────────────────
|
|
197
215
|
// transportCreate(description, targetPackage?)
|
|
@@ -208,6 +226,9 @@ registerTool({
|
|
|
208
226
|
// subsequent write calls pick it up via the 3-tier fallback without the LLM
|
|
209
227
|
// needing to pass it explicitly every time.
|
|
210
228
|
setCurrentTransport(result.transportNumber);
|
|
229
|
+
// 2026-10-04 — the plan object-list grant covered this create ("a new
|
|
230
|
+
// one for this build"): its writes go to this number and no other.
|
|
231
|
+
bindGrantTransport(result.transportNumber, args.description);
|
|
211
232
|
// Orphan-hint ledger: resolveWriteTransport warns later if this freshly
|
|
212
233
|
// created TR ends up bypassed by an owning-transport force.
|
|
213
234
|
markTransportCreatedThisSession(result.transportNumber);
|
package/dist/tools/write-mode.js
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
* everything else (reads, quality checks, lock/snapshot control primitives)
|
|
4
4
|
* runs normally.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* The list is every CLI tool that writes SAP data. It does NOT include the
|
|
7
|
+
* lock/unlock/snapshot control tools, because those manage state around a
|
|
8
|
+
* write and do not write SAP data themselves.
|
|
9
9
|
*/
|
|
10
10
|
export const WRITE_TOOLS = new Set([
|
|
11
11
|
'sap_create_object',
|
|
@@ -13,6 +13,7 @@ export const WRITE_TOOLS = new Set([
|
|
|
13
13
|
'sap_create_data_element',
|
|
14
14
|
'sap_set_source',
|
|
15
15
|
'sap_set_class_include',
|
|
16
|
+
'sap_set_text_elements',
|
|
16
17
|
'sap_update_method',
|
|
17
18
|
'sap_delete_object',
|
|
18
19
|
'sap_activate',
|