@cspeach/cli 1.1.35 → 1.1.36
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/.gitattributes +1 -0
- package/dist/agent/loop.js +6 -0
- package/dist/agent/parallel-write-guard.js +3 -1
- package/dist/commands/plan-audit-evidence.js +6 -1
- package/dist/commands/plan-gate.js +10 -1
- package/dist/commands/plan-resume.js +9 -0
- package/dist/one-shot.js +3 -0
- package/dist/projects/extract-plan.js +6 -0
- package/dist/projects/plan-objects.js +1 -1
- package/dist/projects/plan-skeleton.js +93 -0
- package/dist/projects/save-command.js +17 -0
- package/dist/rap/finish-checks.js +389 -0
- package/dist/rap/generator-names.js +57 -0
- package/dist/rap/skeleton-card.js +31 -0
- package/dist/rap/skeleton-rules.js +38 -0
- package/dist/rap/skeleton-session.js +21 -0
- package/dist/rap/skeleton-store.js +40 -0
- package/dist/renderer/tool-labels.js +4 -1
- package/dist/repl/rule8-detector.js +3 -2
- package/dist/repl.js +5 -2
- package/dist/sap/generator-fact.js +21 -0
- package/dist/session/audit-export.js +19 -1
- package/dist/tools/rap/finish-check.js +118 -0
- package/dist/tools/rap/generate-objects.js +317 -0
- package/dist/tools/rap/skeleton-resolve.js +283 -0
- package/dist/tools/tool-loading.js +7 -1
- package/dist/tools/write-mode.js +2 -0
- package/package.json +2 -2
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
// cspeach-cli/src/tools/rap/generate-objects.ts
|
|
2
|
+
//
|
|
3
|
+
// RAP generator plan, Task 9 — `sap_generate_objects` (spec 2026-10-14 §5).
|
|
4
|
+
// Runs SAP's RAP generator (uiservice) for a skeleton phase whose decision
|
|
5
|
+
// says sap-generator, under the usual write gates:
|
|
6
|
+
// decision file → approvals (one per planned object) → transport fit →
|
|
7
|
+
// table ready → generator offered → validate again → preview again →
|
|
8
|
+
// pending entry → generate → read back (active, on the plan's transport) →
|
|
9
|
+
// audit + finalize.
|
|
10
|
+
// A failure stops and returns a plain-words error. A failed generate (an SAP
|
|
11
|
+
// error answer OR a throw) or a failed read back shows a card listing the
|
|
12
|
+
// objects that now exist; nothing switches path silently.
|
|
13
|
+
import crypto from 'node:crypto';
|
|
14
|
+
import { auditLog } from '@cspeach/sap-client';
|
|
15
|
+
import { registerTool } from '../index.js';
|
|
16
|
+
import fsp from 'node:fs/promises';
|
|
17
|
+
import nodePath from 'node:path';
|
|
18
|
+
import { planPinRoots } from '../../projects/plan-lane.js';
|
|
19
|
+
import { getActivePlanPath } from '../../commands/plan-gate.js';
|
|
20
|
+
import { cardLine } from '../../lane/card.js';
|
|
21
|
+
import { readSkeletonDecision } from '../../rap/skeleton-store.js';
|
|
22
|
+
import { verifyAndSpendApprovalId } from '../../approvals/jwt.js';
|
|
23
|
+
import { grantClaimMismatch } from '../../approvals/grant-claims.js';
|
|
24
|
+
import { checkTransportFit } from '../transport-fit-check.js';
|
|
25
|
+
import { appendPending, finalizeToolCall } from '../../session/pending.js';
|
|
26
|
+
import { noteWriteTransport } from '../../repl/current-transport.js';
|
|
27
|
+
import { askConfirm } from '../../ui/confirm-request.js';
|
|
28
|
+
import { cardSlotEmitter } from '../../ui/card-slot.js';
|
|
29
|
+
import { isHeadless } from '../../renderer/tty.js';
|
|
30
|
+
const TS_FIELDS = [/:\s*abp_locinst_lastchange_tstmpl\b/i, /:\s*abp_lastchange_tstmpl\b/i];
|
|
31
|
+
const REPLAN = 'Run /abap-plan again.';
|
|
32
|
+
/** Fix round 1 (M3): said on every pre-write stop after the approvals were checked. */
|
|
33
|
+
const SPENT = 'Your approvals for this run were used; you will be asked again.';
|
|
34
|
+
const spent = (next) => `${next} ${SPENT}`;
|
|
35
|
+
const NEXT_BY_CHOICE = {
|
|
36
|
+
'delete-retry': 'Delete them with sap_delete_object in this order (each asks for approval), then call sap_generate_objects again.',
|
|
37
|
+
'delete-switch': 'Delete them in this order, then re-run /abap-plan and choose the CSPeach writer on the skeleton card.',
|
|
38
|
+
stop: 'Stopped. Nothing else was changed.',
|
|
39
|
+
};
|
|
40
|
+
const code = (t) => String(t ?? '').toUpperCase().split('/')[0].trim();
|
|
41
|
+
const up = (s) => String(s ?? '').trim().toUpperCase();
|
|
42
|
+
const label = (o) => `${code(o.type)} ${o.name}`;
|
|
43
|
+
const sameObj = (a, b) => code(a.type) === code(b.type) && up(a.name) === up(b.name);
|
|
44
|
+
/** Spec §5: binding → service definition → DDLX → projection BDEF/view → behavior pool → root BDEF/view → draft table. */
|
|
45
|
+
const REVERSE_ORDER = (n) => [
|
|
46
|
+
{ type: 'SRVB', name: n.serviceBinding }, { type: 'SRVD', name: n.serviceDefinition },
|
|
47
|
+
{ type: 'DDLX', name: n.projection }, { type: 'BDEF', name: n.projection }, { type: 'DDLS', name: n.projection },
|
|
48
|
+
{ type: 'CLAS', name: n.behaviorPool }, { type: 'BDEF', name: n.root }, { type: 'DDLS', name: n.root },
|
|
49
|
+
{ type: 'TABL', name: n.draftTable },
|
|
50
|
+
];
|
|
51
|
+
function failureCardRequest(created, why) {
|
|
52
|
+
return {
|
|
53
|
+
id: 'rap-generate-failed',
|
|
54
|
+
tag: 'SAFETY CHECK',
|
|
55
|
+
title: "SAP's generator did not finish",
|
|
56
|
+
facts: [cardLine(why), ...(created.length
|
|
57
|
+
? [cardLine('These objects now exist:'), ...created.map((o) => cardLine(label(o)))]
|
|
58
|
+
: [cardLine('No objects were created.')]),
|
|
59
|
+
cardLine('They are new, so there is nothing to restore if they are deleted.')],
|
|
60
|
+
choices: [
|
|
61
|
+
{ value: 'delete-retry', label: cardLine('Delete them and try again') },
|
|
62
|
+
{ value: 'delete-switch', label: cardLine('Delete them and build with the CSPeach writer instead') },
|
|
63
|
+
{ value: 'stop', label: cardLine('Leave them and stop') },
|
|
64
|
+
],
|
|
65
|
+
initial: 2,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
function defaultHeadless() {
|
|
69
|
+
return isHeadless() || cardSlotEmitter.listenerCount('confirm') === 0;
|
|
70
|
+
}
|
|
71
|
+
const errorResult = (error, message, next, extras = {}) => ({
|
|
72
|
+
content: JSON.stringify({ error, message, next, ...extras }),
|
|
73
|
+
is_error: true,
|
|
74
|
+
});
|
|
75
|
+
/**
|
|
76
|
+
* Final review M1 — the root the decision file is read from, resolved like the
|
|
77
|
+
* plan's lane file (projects/plan-lane.ts readPlanLane): the running plan's own
|
|
78
|
+
* root first, then the lane root for cwd. The first root that HAS the file
|
|
79
|
+
* decides; with none, the last (cwd's lane root) gives the usual "missing".
|
|
80
|
+
*/
|
|
81
|
+
async function decisionRoot(cwd, rel) {
|
|
82
|
+
const roots = await planPinRoots(cwd, getActivePlanPath() ?? undefined);
|
|
83
|
+
const norm = rel.replace(/\\/g, '/');
|
|
84
|
+
for (const root of roots.slice(0, -1)) {
|
|
85
|
+
if (await fsp.stat(nodePath.join(root, norm)).then(() => true, () => false))
|
|
86
|
+
return root;
|
|
87
|
+
}
|
|
88
|
+
return roots[roots.length - 1];
|
|
89
|
+
}
|
|
90
|
+
const errText = (e) => (e instanceof Error ? e.message : String(e)).slice(0, 300);
|
|
91
|
+
export async function sapGenerateObjectsHandler(args, ctx, deps = {}) {
|
|
92
|
+
// ── 1. Decision file ──────────────────────────────────────────────────────
|
|
93
|
+
let rec;
|
|
94
|
+
{
|
|
95
|
+
let read;
|
|
96
|
+
try {
|
|
97
|
+
read = await readSkeletonDecision(await decisionRoot(ctx.cwd, String(args?.decisionPath ?? '')), String(args?.decisionPath ?? ''), String(args?.decisionSha256 ?? ''));
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
read = { ok: false, message: 'The skeleton decision file could not be read. Run /abap-plan again.' };
|
|
101
|
+
}
|
|
102
|
+
if (!read.ok)
|
|
103
|
+
return errorResult('decision_invalid', read.message, REPLAN);
|
|
104
|
+
rec = read.rec;
|
|
105
|
+
}
|
|
106
|
+
if (rec.decision?.by !== 'sap-generator' || !rec.content || !Array.isArray(rec.objects) || rec.objects.length === 0) {
|
|
107
|
+
return errorResult('not_generator_path', "This skeleton is built by the CSPeach writer, not SAP's generator.", 'Build the skeleton with the normal create tools, as the plan says.');
|
|
108
|
+
}
|
|
109
|
+
const effective = up(args?.transport) || up(ctx.currentTransport?.get()) || '';
|
|
110
|
+
// ── 2. Approvals: one per planned object, same order ─────────────────────
|
|
111
|
+
const ids = Array.isArray(args?.approval_ids) ? args.approval_ids.map((x) => String(x ?? '')) : [];
|
|
112
|
+
if (ids.length !== rec.objects.length) {
|
|
113
|
+
return errorResult('approval_invalid', `${rec.objects.length} approvals are needed, one for each planned object. ${ids.length} were given.`, 'Request one create approval per planned object, in the plan order, then call this again.');
|
|
114
|
+
}
|
|
115
|
+
// One pass in order; stops at the first failure. A partly spent set is fine:
|
|
116
|
+
// no SAP write has happened and the run stops.
|
|
117
|
+
const payloads = [];
|
|
118
|
+
for (let i = 0; i < ids.length; i++) {
|
|
119
|
+
const o = rec.objects[i];
|
|
120
|
+
const v = await verifyAndSpendApprovalId(ids[i], o.name, 'create');
|
|
121
|
+
if (!v.ok) {
|
|
122
|
+
// Final review M6 — ids before this one were spent: ask for the whole set again, never just one.
|
|
123
|
+
return errorResult('approval_invalid', `The approval for ${label(o)} is not valid.`, spent('Request one create approval per planned object, in the plan order, then call this again.'), { reason: v.reason, object: label(o) });
|
|
124
|
+
}
|
|
125
|
+
payloads.push(v.payload);
|
|
126
|
+
}
|
|
127
|
+
for (let i = 0; i < payloads.length; i++) {
|
|
128
|
+
const o = rec.objects[i];
|
|
129
|
+
const claim = grantClaimMismatch(payloads[i], { type: o.type, package: rec.package, transport: effective || null });
|
|
130
|
+
if (claim) {
|
|
131
|
+
return errorResult('approval_invalid', `The approval for ${label(o)} does not match this write.`, spent("Request one create approval per planned object on the plan's transport, in the plan order, then call this again."), { reason: claim, object: label(o) });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
// ── 3. Transport ──────────────────────────────────────────────────────────
|
|
135
|
+
if (!effective) {
|
|
136
|
+
return errorResult('transport_missing', 'No transport is set for this write.', spent('Pass the plan\'s transport, or set one with /transport, then call this again.'));
|
|
137
|
+
}
|
|
138
|
+
const fit = await checkTransportFit(ctx.adt, { type: 'DDLS', name: rec.names.root, pkg: rec.package, transport: effective });
|
|
139
|
+
if (!fit.ok)
|
|
140
|
+
return { content: JSON.stringify({ ...fit.refusal, next: SPENT }), is_error: true };
|
|
141
|
+
// ── 4–7. SAP reads before the write. A read that throws stops cleanly. ────
|
|
142
|
+
const tableUri = `/sap/bc/adt/ddic/tables/${rec.table.toLowerCase()}`;
|
|
143
|
+
let entry;
|
|
144
|
+
try {
|
|
145
|
+
// 4. Table is ready.
|
|
146
|
+
let src = null;
|
|
147
|
+
try {
|
|
148
|
+
src = await ctx.adt.getSource('TABL', rec.table);
|
|
149
|
+
}
|
|
150
|
+
catch (e) {
|
|
151
|
+
if (e?.httpStatus !== 404)
|
|
152
|
+
throw e;
|
|
153
|
+
}
|
|
154
|
+
if (src === null || !TS_FIELDS.every((re) => re.test(src))) {
|
|
155
|
+
return errorResult('table_not_ready', src === null
|
|
156
|
+
? `Table ${rec.table} does not exist, so SAP's generator cannot run. Re-run /abap-plan: CSPeach will choose its own writer.`
|
|
157
|
+
: `Table ${rec.table} lacks the fields SAP's generator needs. Re-run /abap-plan: CSPeach will choose its own writer.`, spent('Re-run /abap-plan.'));
|
|
158
|
+
}
|
|
159
|
+
// 5. Generator entry.
|
|
160
|
+
const found = (await ctx.adt.listGenerators(tableUri)).find((g) => g.id === 'uiservice');
|
|
161
|
+
if (!found) {
|
|
162
|
+
return errorResult('generator_not_offered', `SAP's generator isn't offered for table ${rec.table}.`, spent('Re-run /abap-plan: CSPeach will choose its own writer.'));
|
|
163
|
+
}
|
|
164
|
+
entry = found;
|
|
165
|
+
// 6. Validate again (Review Focus 2) — before preview: preview does not check names.
|
|
166
|
+
const v = await ctx.adt.validateGenerator(entry, rec.content);
|
|
167
|
+
if (!v.ok) {
|
|
168
|
+
return errorResult('name_clash', `SAP's generator refused the planned names: ${cardLine(v.text || v.severity || 'no reason given')}`, spent('Choose new names: re-run /abap-plan. The object list changes, so you will approve it again.'));
|
|
169
|
+
}
|
|
170
|
+
// 7. Preview again (Review Focus 1).
|
|
171
|
+
const preview = (await ctx.adt.previewGenerator(entry, rec.content)).map((p) => ({ type: p.type, name: p.name }));
|
|
172
|
+
const missing = rec.objects.filter((o) => !preview.some((p) => sameObj(p, o)));
|
|
173
|
+
const extra = preview.filter((p) => !rec.objects.some((o) => sameObj(p, o)));
|
|
174
|
+
if (missing.length || extra.length) {
|
|
175
|
+
const parts = [
|
|
176
|
+
...(missing.length ? [`Planned but not in SAP's preview: ${missing.map(label).join(', ')}.`] : []),
|
|
177
|
+
...(extra.length ? [`In SAP's preview but not planned: ${extra.map(label).join(', ')}.`] : []),
|
|
178
|
+
];
|
|
179
|
+
return errorResult('preview_differs', `SAP's generator would not build what the plan lists. ${parts.join(' ')}`, spent('Re-run /abap-plan. The object list changes, so you will approve it again.'), { missing: missing.map((o) => ({ type: code(o.type), name: o.name })), extra: extra.map((o) => ({ type: code(o.type), name: o.name })) });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
catch (e) {
|
|
183
|
+
return errorResult('sap_read_failed', `Could not read from SAP before generating: ${cardLine(errText(e))}. Nothing was changed.`, spent('Check the connection to SAP, then call this again.'));
|
|
184
|
+
}
|
|
185
|
+
// ── 8. Write-ahead entry ──────────────────────────────────────────────────
|
|
186
|
+
const toolUseId = ctx.toolUseId ?? crypto.randomUUID();
|
|
187
|
+
const startedAt = Date.now();
|
|
188
|
+
await appendPending(ctx.session, {
|
|
189
|
+
tool_use_id: toolUseId,
|
|
190
|
+
tool: 'sap_generate_objects',
|
|
191
|
+
args: { decisionPath: String(args.decisionPath), table: rec.table, package: rec.package, transport: effective },
|
|
192
|
+
started_at: new Date().toISOString(),
|
|
193
|
+
});
|
|
194
|
+
const planned = rec.objects.map((o) => ({ type: code(o.type), name: o.name }));
|
|
195
|
+
const inDeleteOrder = (created) => REVERSE_ORDER(rec.names).filter((r) => created.some((c) => sameObj(c, r)));
|
|
196
|
+
/** Card (or headless stop), finalize, error result. */
|
|
197
|
+
const failWithCard = async (error, message, created, extras = {}) => {
|
|
198
|
+
let choice = 'stop';
|
|
199
|
+
if (!(deps.headless ?? defaultHeadless)()) {
|
|
200
|
+
try {
|
|
201
|
+
const res = await (deps.confirm ?? ((r) => askConfirm(r)))(failureCardRequest(created, message));
|
|
202
|
+
if (res.value === 'delete-retry' || res.value === 'delete-switch')
|
|
203
|
+
choice = res.value;
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
choice = 'stop';
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
const out = errorResult(error, message, NEXT_BY_CHOICE[choice], { created, delete_in_order: inDeleteOrder(created), choice, ...extras });
|
|
210
|
+
// Fix round 1 (M2): every stop after the pending entry leaves an error row.
|
|
211
|
+
await auditLog('generateObjects', 'GENR', rec.names.root, 'error', Date.now() - startedAt, `${error} created=${created.length} transport=${effective}`);
|
|
212
|
+
await finalizeToolCall(ctx.session, toolUseId, out.content, true, Date.now() - startedAt);
|
|
213
|
+
return out;
|
|
214
|
+
};
|
|
215
|
+
/** Which planned objects exist: exact name AND the planned type code (M4: an SRVD hit is not the SRVB). */
|
|
216
|
+
const findExisting = async () => {
|
|
217
|
+
const found = [];
|
|
218
|
+
const unchecked = [];
|
|
219
|
+
for (const o of planned) {
|
|
220
|
+
try {
|
|
221
|
+
const hits = await ctx.adt.searchObject(o.name, o.type, 5);
|
|
222
|
+
if (hits.some((h) => up(h.name) === up(o.name) && code(h.type) === o.type))
|
|
223
|
+
found.push(o);
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
unchecked.push(o);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return { found, unchecked };
|
|
230
|
+
};
|
|
231
|
+
// ── 9. Generate. An error answer and a throw are handled the same way. ───
|
|
232
|
+
let failure = null;
|
|
233
|
+
try {
|
|
234
|
+
const run = await ctx.adt.runGenerator(entry, rec.content, effective);
|
|
235
|
+
if (!(run.status >= 200 && run.status < 300))
|
|
236
|
+
failure = run.error || `SAP answered HTTP ${run.status}.`;
|
|
237
|
+
}
|
|
238
|
+
catch (e) {
|
|
239
|
+
failure = `The request to SAP failed: ${errText(e)}`;
|
|
240
|
+
}
|
|
241
|
+
if (failure !== null) {
|
|
242
|
+
// Which planned objects now exist (exact name, same type)?
|
|
243
|
+
const { found: created, unchecked } = await findExisting();
|
|
244
|
+
const note = unchecked.length ? ` Could not check: ${unchecked.map(label).join(', ')}.` : '';
|
|
245
|
+
return failWithCard('generate_failed', `SAP's generator failed. ${cardLine(failure)}${note}`, created, unchecked.length ? { unchecked } : {});
|
|
246
|
+
}
|
|
247
|
+
// ── 10. Read back: every object exists, is active and is on the plan's transport (spec §5 step 6).
|
|
248
|
+
{
|
|
249
|
+
const { found, unchecked } = await findExisting();
|
|
250
|
+
if (unchecked.length) {
|
|
251
|
+
return failWithCard('readback_failed', `The objects were generated, but CSPeach could not check that these exist: ${unchecked.map(label).join(', ')}.`, planned);
|
|
252
|
+
}
|
|
253
|
+
const missing = planned.filter((o) => !found.some((f) => sameObj(f, o)));
|
|
254
|
+
if (missing.length) {
|
|
255
|
+
return failWithCard('missing_after_generate', `SAP's generator reported success, but these objects do not exist: ${missing.map(label).join(', ')}.`, found, { missing });
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
try {
|
|
259
|
+
const inactive = await ctx.adt.inactiveObjects();
|
|
260
|
+
const stillInactive = planned.filter((o) => inactive.some((i) => sameObj(i, o)));
|
|
261
|
+
if (stillInactive.length) {
|
|
262
|
+
return failWithCard('inactive_after_generate', `SAP's generator left these objects inactive: ${stillInactive.map(label).join(', ')}.`, planned, { inactive: stillInactive });
|
|
263
|
+
}
|
|
264
|
+
const wrong = [];
|
|
265
|
+
const capped = [];
|
|
266
|
+
for (const o of planned) {
|
|
267
|
+
const t = await ctx.adt.transportForObject(o.type, o.name);
|
|
268
|
+
const candidates = (Array.isArray(t.candidates) ? t.candidates : []).map(up);
|
|
269
|
+
if (t.capped)
|
|
270
|
+
capped.push(o);
|
|
271
|
+
else if (t.transport && up(t.transport) !== effective)
|
|
272
|
+
wrong.push({ ...o, transport: up(t.transport) });
|
|
273
|
+
// Split ownership (M1): no single owner, and the plan's request is not among the owners.
|
|
274
|
+
else if (!t.transport && candidates.length && !candidates.includes(effective))
|
|
275
|
+
wrong.push({ ...o, transport: candidates.join(', ') });
|
|
276
|
+
// Null with no candidates: in no open request; existence was proven above.
|
|
277
|
+
}
|
|
278
|
+
if (capped.length) {
|
|
279
|
+
return failWithCard('readback_failed', `The objects were generated, but CSPeach could not check the transport of: ${capped.map(label).join(', ')}.`, planned);
|
|
280
|
+
}
|
|
281
|
+
if (wrong.length) {
|
|
282
|
+
return failWithCard('wrong_transport', `These objects landed on another transport, not ${effective}: ${wrong.map((w) => `${label(w)} on ${w.transport}`).join(', ')}.`, planned, { wrong });
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
catch (e) {
|
|
286
|
+
return failWithCard('readback_failed', `The objects were generated, but CSPeach could not check them: ${cardLine(errText(e))}`, planned);
|
|
287
|
+
}
|
|
288
|
+
// ── 11. Finish up ─────────────────────────────────────────────────────────
|
|
289
|
+
await auditLog('generateObjects', 'GENR', rec.names.root, 'success', Date.now() - startedAt, `generator=uiservice objects=${planned.length} transport=${effective}`);
|
|
290
|
+
noteWriteTransport(effective);
|
|
291
|
+
const result = JSON.stringify({ generated: true, objects: planned, transport: effective, next: 'finish' });
|
|
292
|
+
await finalizeToolCall(ctx.session, toolUseId, result, false, Date.now() - startedAt);
|
|
293
|
+
return { content: result };
|
|
294
|
+
}
|
|
295
|
+
registerTool({
|
|
296
|
+
name: 'sap_generate_objects',
|
|
297
|
+
description: "Run SAP's RAP generator for a skeleton phase whose Plan block says sap-generator. Pass the decision path and "
|
|
298
|
+
+ 'sha256 from the plan block, and one approval_id per planned object in the same order (request_approval with '
|
|
299
|
+
+ 'op create for each). CSPeach validates, previews, generates, then checks every object is active and on the '
|
|
300
|
+
+ 'transport. Never call it for a CSPeach-writer skeleton.',
|
|
301
|
+
isMutating: true,
|
|
302
|
+
requiresSap: true,
|
|
303
|
+
input_schema: {
|
|
304
|
+
type: 'object',
|
|
305
|
+
properties: {
|
|
306
|
+
decisionPath: { type: 'string', description: 'Skeleton decision path from the Plan block' },
|
|
307
|
+
decisionSha256: { type: 'string', description: 'Skeleton decision sha256 from the Plan block' },
|
|
308
|
+
approval_ids: {
|
|
309
|
+
type: 'array', items: { type: 'string' },
|
|
310
|
+
description: 'One create approval id per planned object, in the plan order',
|
|
311
|
+
},
|
|
312
|
+
transport: { type: 'string', description: "The plan's transport (optional: defaults to the session transport)" },
|
|
313
|
+
},
|
|
314
|
+
required: ['decisionPath', 'decisionSha256', 'approval_ids'],
|
|
315
|
+
},
|
|
316
|
+
handler: (args, ctx) => sapGenerateObjectsHandler(args, ctx),
|
|
317
|
+
});
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
// cspeach-cli/src/tools/rap/skeleton-resolve.ts
|
|
2
|
+
//
|
|
3
|
+
// RAP generator plan, Task 7 — `rap_skeleton_resolve`. Spec 2026-10-14 §3.2/§4:
|
|
4
|
+
// the CLI picks the skeleton builder (SAP's generator or the CSPeach writer),
|
|
5
|
+
// shows the skeleton card, writes the sha-pinned decision file and returns the
|
|
6
|
+
// block the plan copies. The model never picks.
|
|
7
|
+
//
|
|
8
|
+
// Every SAP read here is best-effort: a read that throws (other than the
|
|
9
|
+
// table's own 404) makes the generator fact 'unknown', so the CSPeach writer
|
|
10
|
+
// is chosen (row 3). The tool never crashes on a SAP read.
|
|
11
|
+
import { SapError } from '@cspeach/sap-client';
|
|
12
|
+
import { isStandalone, registerTool } from '../index.js';
|
|
13
|
+
import { assertNotCliHome, laneRootFor } from '../../lane/roots.js';
|
|
14
|
+
import { setLatestSkeletonDecision } from '../../rap/skeleton-session.js';
|
|
15
|
+
import { factsFromSystemInfo } from '../../lane/inputs.js';
|
|
16
|
+
import { cardLine } from '../../lane/card.js';
|
|
17
|
+
import { getSapSystemInfo } from '../../sap/system-info.js';
|
|
18
|
+
import { getGeneratorFact } from '../../sap/generator-fact.js';
|
|
19
|
+
import { decideSkeleton, generatorBlockers, } from '../../rap/skeleton-rules.js';
|
|
20
|
+
import { buildUiserviceContent, generatorPlannedObjects, namesFitLimits, proposeNames, } from '../../rap/generator-names.js';
|
|
21
|
+
import { writeSkeletonDecision } from '../../rap/skeleton-store.js';
|
|
22
|
+
import { SKELETON_WHO, skeletonCardRequest } from '../../rap/skeleton-card.js';
|
|
23
|
+
import { PKG_RE } from '../../projects/plan-objects.js';
|
|
24
|
+
import { askConfirm } from '../../ui/confirm-request.js';
|
|
25
|
+
import { cardSlotEmitter } from '../../ui/card-slot.js';
|
|
26
|
+
import { isHeadless } from '../../renderer/tty.js';
|
|
27
|
+
const fail = (m) => ({ content: `error: ${m}`, is_error: true });
|
|
28
|
+
const NOT_CHOSEN = 'Skeleton not chosen. Nothing was saved.';
|
|
29
|
+
const TABLE_RE = /^[ZY][A-Z0-9_]{1,15}$/;
|
|
30
|
+
const BINDINGS = new Set(['odata-v4-ui', 'odata-v2-ui', 'odata-v4-api', 'odata-v2-api']);
|
|
31
|
+
const RAP_TIER = (t) => t === 's4-2020-plus' || t === 'unknown';
|
|
32
|
+
const TS_FIELDS = [/:\s*abp_locinst_lastchange_tstmpl\b/i, /:\s*abp_lastchange_tstmpl\b/i];
|
|
33
|
+
function parseRequest(r) {
|
|
34
|
+
if (!r || typeof r !== 'object')
|
|
35
|
+
return null;
|
|
36
|
+
const o = r;
|
|
37
|
+
if (typeof o.entities !== 'number' || !Number.isInteger(o.entities) || o.entities < 1)
|
|
38
|
+
return null;
|
|
39
|
+
if (o.implementation !== 'managed' && o.implementation !== 'unmanaged')
|
|
40
|
+
return null;
|
|
41
|
+
if (typeof o.draft !== 'boolean' || typeof o.binding !== 'string' || !BINDINGS.has(o.binding))
|
|
42
|
+
return null;
|
|
43
|
+
return {
|
|
44
|
+
entities: o.entities, implementation: o.implementation, draft: o.draft,
|
|
45
|
+
binding: o.binding,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
async function defaultTier(ctx) {
|
|
49
|
+
return { tier: factsFromSystemInfo(await getSapSystemInfo(ctx.adt, ctx.sapAlias)).tier };
|
|
50
|
+
}
|
|
51
|
+
function defaultHeadless() {
|
|
52
|
+
return isHeadless() || cardSlotEmitter.listenerCount('confirm') === 0;
|
|
53
|
+
}
|
|
54
|
+
const SUFFIXES = ['', '2', '3', '4', '5', '6', '7', '8', '9'];
|
|
55
|
+
const RENAMED_NOTE = 'Some names were taken, so CSPeach picked free ones';
|
|
56
|
+
const takenMessage = (name) => `${name} already exists in the system. Choose another table or app name, then run /abap-plan again.`;
|
|
57
|
+
/** The name a SAP validation text says is taken, when it is one of ours. */
|
|
58
|
+
function clashInValidation(text, objects) {
|
|
59
|
+
if (!/already exist/i.test(text))
|
|
60
|
+
return null;
|
|
61
|
+
const up = text.toUpperCase();
|
|
62
|
+
const hit = objects.find((o) => {
|
|
63
|
+
const at = up.indexOf(o.name);
|
|
64
|
+
if (at < 0)
|
|
65
|
+
return false;
|
|
66
|
+
const before = at === 0 ? '' : up[at - 1];
|
|
67
|
+
const after = up[at + o.name.length] ?? '';
|
|
68
|
+
return !/[A-Z0-9_]/.test(before) && !/[A-Z0-9_]/.test(after);
|
|
69
|
+
});
|
|
70
|
+
return hit ? hit.name : null;
|
|
71
|
+
}
|
|
72
|
+
/** First of `objects` that already exists (exact name), or null. Throws what searchObject throws. */
|
|
73
|
+
async function searchClash(ctx, objects) {
|
|
74
|
+
for (const o of objects) {
|
|
75
|
+
const hits = await ctx.adt.searchObject(o.name, o.type, 5);
|
|
76
|
+
if (hits.some((h) => String(h.name).toUpperCase() === o.name))
|
|
77
|
+
return o.name;
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Fills the table facts (rows 5–7) from SAP for one candidate name set and
|
|
83
|
+
* returns the name of one of OUR objects that is already taken, or null.
|
|
84
|
+
* Throws only what the caller maps to generator 'unknown'; a 404 on the table
|
|
85
|
+
* means "not created yet".
|
|
86
|
+
*/
|
|
87
|
+
async function probeTable(ctx, facts, names, objects9, content, cache) {
|
|
88
|
+
if (cache.src === undefined) {
|
|
89
|
+
try {
|
|
90
|
+
cache.src = await ctx.adt.getSource('TABL', names.table);
|
|
91
|
+
}
|
|
92
|
+
catch (e) {
|
|
93
|
+
if (!(e instanceof SapError && e.httpStatus === 404))
|
|
94
|
+
throw e;
|
|
95
|
+
cache.src = null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (cache.src !== null) {
|
|
99
|
+
const text = cache.src;
|
|
100
|
+
facts.tableExists = true;
|
|
101
|
+
facts.tableHasTimestamps = TS_FIELDS.every((re) => re.test(text));
|
|
102
|
+
if (!facts.tableHasTimestamps)
|
|
103
|
+
return null;
|
|
104
|
+
if (cache.ui === undefined) {
|
|
105
|
+
const list = await ctx.adt.listGenerators(`/sap/bc/adt/ddic/tables/${names.table.toLowerCase()}`);
|
|
106
|
+
cache.ui = list.find((g) => g.id === 'uiservice') ?? null;
|
|
107
|
+
}
|
|
108
|
+
facts.uiserviceOffered = !!cache.ui;
|
|
109
|
+
if (cache.ui) {
|
|
110
|
+
const v = await ctx.adt.validateGenerator(cache.ui, content);
|
|
111
|
+
// Final review I3 — a clash on one of our names is renamed, not a row-7 stop.
|
|
112
|
+
const clash = v.ok ? null : clashInValidation(v.text, objects9);
|
|
113
|
+
if (clash)
|
|
114
|
+
return clash;
|
|
115
|
+
facts.validation = { ok: v.ok, text: v.text };
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
// Not created yet: SAP's own validation needs the table, so check the 9 names here.
|
|
120
|
+
return searchClash(ctx, objects9);
|
|
121
|
+
}
|
|
122
|
+
/** The objects the CSPeach writer builds under the record's names (no draft table without draft). */
|
|
123
|
+
function writerObjects(objects9, request) {
|
|
124
|
+
return request.draft ? objects9 : objects9.filter((o) => o.type !== 'TABL');
|
|
125
|
+
}
|
|
126
|
+
export async function rapSkeletonResolveHandler(args, ctx, deps = {}) {
|
|
127
|
+
// 1. Arguments.
|
|
128
|
+
const table = String(args?.table ?? '').trim().toUpperCase();
|
|
129
|
+
if (!TABLE_RE.test(table))
|
|
130
|
+
return fail('table must be a Z/Y table name of at most 16 characters.');
|
|
131
|
+
const pkg = String(args?.package ?? '').trim().toUpperCase();
|
|
132
|
+
if (!PKG_RE.test(pkg))
|
|
133
|
+
return fail('package must be a valid package name.');
|
|
134
|
+
const request = parseRequest(args?.request);
|
|
135
|
+
if (!request) {
|
|
136
|
+
return fail('request must be { entities, implementation: managed|unmanaged, draft, binding: odata-v4-ui|odata-v2-ui|odata-v4-api|odata-v2-api }.');
|
|
137
|
+
}
|
|
138
|
+
const description = (String(args?.description ?? '').trim() || table).slice(0, 60);
|
|
139
|
+
// 2. System facts. Final review I1 — standalone (or no SAP client): no SAP read at all.
|
|
140
|
+
const connected = !isStandalone(ctx) && !!ctx.adt;
|
|
141
|
+
const { tier } = connected
|
|
142
|
+
? await (deps.facts ?? defaultTier)(ctx).catch(() => ({ tier: 'unknown' }))
|
|
143
|
+
: { tier: 'unknown' };
|
|
144
|
+
let generator = connected
|
|
145
|
+
? await (deps.generatorFact ?? ((c) => getGeneratorFact(c.adt, c.sapAlias)))(ctx)
|
|
146
|
+
.catch(() => 'unknown')
|
|
147
|
+
: 'unknown';
|
|
148
|
+
// 3–4. Names, then the table facts. Final review I3 — a taken name is never
|
|
149
|
+
// handed on: each candidate set (base, base2 … base9) is checked the same way
|
|
150
|
+
// and the first free set wins. When SAP can't be searched the names stay unchecked.
|
|
151
|
+
const cache = {};
|
|
152
|
+
let chosen = null;
|
|
153
|
+
let firstTaken = null;
|
|
154
|
+
for (const suffix of SUFFIXES) {
|
|
155
|
+
const names = proposeNames(table, suffix);
|
|
156
|
+
const facts = {
|
|
157
|
+
tier, generator, tableExists: false, tableHasTimestamps: null, uiserviceOffered: null, validation: null,
|
|
158
|
+
namesFit: namesFitLimits(names), connected, localPackage: pkg.startsWith('$'),
|
|
159
|
+
};
|
|
160
|
+
const objects9 = generatorPlannedObjects(names, pkg);
|
|
161
|
+
const content = buildUiserviceContent(names, pkg, description);
|
|
162
|
+
let taken = null;
|
|
163
|
+
// Only when the generator could still be chosen.
|
|
164
|
+
if (RAP_TIER(tier) && generator === 'yes' && !generatorBlockers(request, facts)) {
|
|
165
|
+
try {
|
|
166
|
+
taken = await probeTable(ctx, facts, names, objects9, content, cache);
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
generator = 'unknown';
|
|
170
|
+
facts.generator = 'unknown';
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
// The CSPeach writer builds the same names: check them when it is the one deciding.
|
|
174
|
+
if (!taken && connected && RAP_TIER(tier) && generatorBlockers(request, facts)) {
|
|
175
|
+
try {
|
|
176
|
+
taken = await searchClash(ctx, writerObjects(objects9, request));
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
taken = null; // SAP can't be searched: the names stay unchecked (the build reports a clash).
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (!taken) {
|
|
183
|
+
chosen = { names, facts, objects9, content };
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
firstTaken ??= taken;
|
|
187
|
+
}
|
|
188
|
+
if (!chosen)
|
|
189
|
+
return { content: takenMessage(firstTaken ?? table) };
|
|
190
|
+
const { names, facts, objects9, content } = chosen;
|
|
191
|
+
const renamed = names.base !== proposeNames(table).base;
|
|
192
|
+
const noted = (d) => (renamed && d.by !== null ? { ...d, reason: `${d.reason}. ${RENAMED_NOTE}` } : d);
|
|
193
|
+
// 5. Decide.
|
|
194
|
+
let decision = noted(decideSkeleton(request, facts));
|
|
195
|
+
if (decision.by === null)
|
|
196
|
+
return { content: `${decision.reason}. Nothing was saved.` };
|
|
197
|
+
const blocked = generatorBlockers(request, facts);
|
|
198
|
+
// 6. Card, or the rules when no card can be shown.
|
|
199
|
+
if ((deps.headless ?? defaultHeadless)()) {
|
|
200
|
+
ctx.chunkEmitter?.emit('info', `RAP skeleton chosen by the rules (no confirm card here): ${SKELETON_WHO[decision.by]}.`);
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
const card = skeletonCardRequest(decision, blocked?.reason ?? null, names, objects9);
|
|
204
|
+
const res = await (deps.confirm ?? ((r) => askConfirm(r)))(card);
|
|
205
|
+
const value = res.value;
|
|
206
|
+
const offered = new Set(card.choices.map((c) => c.value));
|
|
207
|
+
if (!value || value === 'cancel' || !offered.has(value))
|
|
208
|
+
return { content: NOT_CHOSEN };
|
|
209
|
+
if (value.startsWith('switch:')) {
|
|
210
|
+
decision = noted(decideSkeleton(request, facts, value.slice('switch:'.length)));
|
|
211
|
+
}
|
|
212
|
+
else if (value !== 'confirm') {
|
|
213
|
+
return { content: NOT_CHOSEN };
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
const by = decision.by;
|
|
217
|
+
if (by === null)
|
|
218
|
+
return { content: NOT_CHOSEN };
|
|
219
|
+
// 7. The decision file.
|
|
220
|
+
const objects = by === 'sap-generator' ? objects9 : [];
|
|
221
|
+
let written;
|
|
222
|
+
try {
|
|
223
|
+
const laneRoot = await laneRootFor(ctx.cwd);
|
|
224
|
+
await assertNotCliHome(laneRoot);
|
|
225
|
+
written = await writeSkeletonDecision(laneRoot, {
|
|
226
|
+
table, package: pkg, names, request, decision, objects,
|
|
227
|
+
...(by === 'sap-generator' ? { content } : {}),
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
catch (e) {
|
|
231
|
+
return fail(`the skeleton decision could not be saved: ${cardLine(e instanceof Error ? e.message : 'write failed')}`);
|
|
232
|
+
}
|
|
233
|
+
// Task 8 fix round 1 — the plan save accepts only this, the newest decision.
|
|
234
|
+
if (ctx.session)
|
|
235
|
+
setLatestSkeletonDecision(ctx.session, written);
|
|
236
|
+
// 8. The result.
|
|
237
|
+
const block = { skeleton: { by, decision: { path: written.path, sha256: written.sha256 } }, plannedObjects: objects };
|
|
238
|
+
return {
|
|
239
|
+
content: [
|
|
240
|
+
`Skeleton: ${SKELETON_WHO[by]} — ${cardLine(decision.reason)}`,
|
|
241
|
+
`Decision file: ${written.path}`,
|
|
242
|
+
`Plan block: ${JSON.stringify(block)}`,
|
|
243
|
+
// Final review I3 — the writer builds the record's names (checked free, renamed when taken).
|
|
244
|
+
...(by === 'cspeach'
|
|
245
|
+
? [`Names: ${[names.root, names.projection, names.behaviorPool, ...(request.draft ? [names.draftTable] : []), names.serviceBinding].join(', ')} `
|
|
246
|
+
+ '(service definition and binding). Use exactly these names for the skeleton objects.']
|
|
247
|
+
: []),
|
|
248
|
+
"Copy the plan block into the skeleton phase's work exactly as given. Do not change it.",
|
|
249
|
+
].join('\n'),
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
registerTool({
|
|
253
|
+
name: 'rap_skeleton_resolve',
|
|
254
|
+
description: "RAP apps only, at plan time. Decides how the RAP skeleton is built: SAP's generator or the CSPeach writer. "
|
|
255
|
+
+ 'Pass the persistence table name (it may not exist yet), the package, a short description and the request '
|
|
256
|
+
+ '{ entities (number of entities incl. children), implementation managed|unmanaged, draft, binding '
|
|
257
|
+
+ 'odata-v4-ui|odata-v2-ui|odata-v4-api|odata-v2-api }. CSPeach checks the system and the table, shows the '
|
|
258
|
+
+ "skeleton card, saves the decision and returns a Plan block. Copy that block into the skeleton phase's "
|
|
259
|
+
+ 'work exactly. Never choose the builder yourself.',
|
|
260
|
+
isMutating: false,
|
|
261
|
+
// Final review I1 — usable standalone: no SAP reads, the CSPeach writer ("Not connected to SAP").
|
|
262
|
+
requiresSap: false,
|
|
263
|
+
input_schema: {
|
|
264
|
+
type: 'object',
|
|
265
|
+
properties: {
|
|
266
|
+
table: { type: 'string', description: 'Persistence table name (Z/Y, at most 16 characters)' },
|
|
267
|
+
package: { type: 'string', description: 'Target package' },
|
|
268
|
+
description: { type: 'string', description: 'Short business description, e.g. "Equipment downtime log"' },
|
|
269
|
+
request: {
|
|
270
|
+
type: 'object',
|
|
271
|
+
properties: {
|
|
272
|
+
entities: { type: 'integer', minimum: 1 },
|
|
273
|
+
implementation: { type: 'string', enum: ['managed', 'unmanaged'] },
|
|
274
|
+
draft: { type: 'boolean' },
|
|
275
|
+
binding: { type: 'string', enum: ['odata-v4-ui', 'odata-v2-ui', 'odata-v4-api', 'odata-v2-api'] },
|
|
276
|
+
},
|
|
277
|
+
required: ['entities', 'implementation', 'draft', 'binding'],
|
|
278
|
+
},
|
|
279
|
+
},
|
|
280
|
+
required: ['table', 'package', 'description', 'request'],
|
|
281
|
+
},
|
|
282
|
+
handler: (args, ctx) => rapSkeletonResolveHandler(args, ctx),
|
|
283
|
+
});
|
|
@@ -70,7 +70,13 @@ export const SKILL_STARTERS = Object.freeze({
|
|
|
70
70
|
'abap-eml': ['sap_run_unit_test'],
|
|
71
71
|
'abap-test': ['sap_run_unit_test', 'sap_set_class_include'],
|
|
72
72
|
'abap-extend-model': ['extend_model_insert', 'sap_snapshot_take', 'sap_service_binding_publish', 'sap_set_class_include'],
|
|
73
|
-
|
|
73
|
+
// RAP generator (2026-10-14): SAP's generator writes the stack; the finish check proves F1–F5.
|
|
74
|
+
// Final review M5 — standalone /abap-rap calls rap_skeleton_resolve before it builds.
|
|
75
|
+
'abap-rap': ['sap_set_class_include', 'sap_service_binding_publish', 'sap_class_includes', 'rap_skeleton_resolve', 'sap_generate_objects', 'rap_finish_check'],
|
|
76
|
+
// Plan steps load tools by the PLAN skill (session.tool_set_skill / params.skill), not the
|
|
77
|
+
// delegate (which only rides as the X-CSPeach-Delegate-Skill header), so the RAP
|
|
78
|
+
// skeleton + finish phases need the generator and the finish check here too.
|
|
79
|
+
'abap-plan': ['rap_skeleton_resolve', 'sap_generate_objects', 'rap_finish_check'],
|
|
74
80
|
'abap-modernize': ['sap_api_state', 'sap_snapshot_take', 'sap_service_binding_publish', 'sap_atc_run', 'sap_transport_create'],
|
|
75
81
|
'abap-segw': ['sap_snapshot_take', 'sap_odata_test'],
|
|
76
82
|
'abap-upgrade-fix': ['sap_snapshot_take', 'glob'],
|
package/dist/tools/write-mode.js
CHANGED
|
@@ -21,6 +21,8 @@ export const WRITE_TOOLS = new Set([
|
|
|
21
21
|
'sap_service_binding_publish',
|
|
22
22
|
'sap_service_binding_unpublish',
|
|
23
23
|
'sap_transport_release',
|
|
24
|
+
// Final review I2 — SAP's RAP generator creates the skeleton objects.
|
|
25
|
+
'sap_generate_objects',
|
|
24
26
|
]);
|
|
25
27
|
export function isWriteTool(name) {
|
|
26
28
|
return WRITE_TOOLS.has(name);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cspeach/cli",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.36",
|
|
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.21",
|
|
58
58
|
"@iarna/toml": "^2.2.5",
|
|
59
59
|
"@inkjs/ui": "^2.0.0",
|
|
60
60
|
"@inquirer/prompts": "^7.0.0",
|