@cspeach/cli 1.1.38 → 1.1.40

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.
@@ -57,6 +57,8 @@ import { expandTextFileAttachments } from '../projects/workspace.js';
57
57
  import { expandImageAttachments, buildUserContent, trimHistoryImages } from '../projects/image-attachments.js';
58
58
  import { expandImageChips } from '../ui/clipboard-image.js';
59
59
  import { getSapSystemInfo, renderSapSystemBlock } from '../sap/system-info.js';
60
+ import { getSystemProfile, renderSapProfileBlock } from '../sap/system-profile.js';
61
+ import { pinHiddenTools, withoutTools } from '../sap/profile-gate.js';
60
62
  import { renderStandaloneContextBlock } from '../sap/standalone-profile.js';
61
63
  import { renderStandardsBlock } from '../standards/standards-file.js';
62
64
  import { composeSessionContextBlock } from './session-context.js';
@@ -820,6 +822,7 @@ ${imageExpansion.error}`));
820
822
  // SAP block is then empty and the rest of enrichment proceeds as
821
823
  // before — no behavioral regression.
822
824
  let sapSystemBlock = '';
825
+ let sapProfileBlock = '';
823
826
  let sapSystemInfo = null;
824
827
  const standaloneTurn = isStandalone(params.ctx);
825
828
  if (standaloneTurn) {
@@ -828,10 +831,15 @@ ${imageExpansion.error}`));
828
831
  // so skills read one element in both modes — plus the manual-
829
832
  // implementation hand-off note.
830
833
  sapSystemBlock = renderStandaloneContextBlock(cfg.standalone);
834
+ params.ctx.systemProfile = null;
831
835
  }
832
836
  else if (params.ctx.adt && params.ctx.sapAlias) {
833
837
  sapSystemInfo = await getSapSystemInfo(params.ctx.adt, params.ctx.sapAlias);
838
+ // ADT gap program, Part 1 — the system profile rides the same lazy probe.
839
+ // Null (kill switch, no discovery and no CVERS) = today's behaviour.
840
+ params.ctx.systemProfile = await getSystemProfile(params.ctx.adt, params.ctx.sapAlias, sapSystemInfo);
834
841
  sapSystemBlock = renderSapSystemBlock(sapSystemInfo);
842
+ sapProfileBlock = renderSapProfileBlock(params.ctx.systemProfile);
835
843
  }
836
844
  // Customer development standards — opt-in, both modes. Renders '' when the
837
845
  // user has no standards file, so the composed block (and therefore the whole
@@ -842,7 +850,7 @@ ${imageExpansion.error}`));
842
850
  const switchNote = session.systemSwitchNote
843
851
  ? `<system_switch>${session.systemSwitchNote}</system_switch>`
844
852
  : '';
845
- const sessionContextBlock = composeSessionContextBlock([sapSystemBlock, standardsBlock, switchNote]);
853
+ const sessionContextBlock = composeSessionContextBlock([sapSystemBlock, sapProfileBlock, standardsBlock, switchNote]);
846
854
  // Deterministically extract structured facts from the user's prompt
847
855
  // (package, transport, object names, environment hint, create intent)
848
856
  // and prepend them as an XML `<session_context>` block. The model reads
@@ -926,7 +934,14 @@ ${imageExpansion.error}`));
926
934
  env: process.env,
927
935
  served: servedToolLoading(),
928
936
  });
929
- const contextTools = toolsForContext(listTools(), params.ctx);
937
+ // Part 1 (D4, R7) — the hidden set is pinned per alias on the first
938
+ // connected turn and reused, so tools[] stays byte-stable; [] pinned = same
939
+ // array as today. A standalone or unconnected turn pins nothing.
940
+ const connectedTurn = !standaloneTurn && !!params.ctx.adt && !!params.ctx.sapAlias;
941
+ const hiddenSapTools = connectedTurn
942
+ ? pinHiddenTools(session, params.ctx.sapAlias, listTools(), params.ctx.systemProfile ?? null)
943
+ : [];
944
+ const contextTools = withoutTools(toolsForContext(listTools(), params.ctx), hiddenSapTools);
930
945
  // Task 18 fixes M4 / M5 — no read_agent for an agent_run child, and none
931
946
  // when a reader would have no real read tool (standalone, file tools off).
932
947
  const visibleTools = params.ctx.subagent || !hasReaderReadTools(new Set(contextTools.map((t) => t.name)))
@@ -9,6 +9,7 @@ import { shouldGateRule8, recordWriteOp, renderPlanSummary, getWriteOpsThisTurn,
9
9
  import { isCredentialHandoverApproved, markCredentialHandoverApproved, } from '../repl/credential-handover-gate.js';
10
10
  import { READER_TOOLS } from '../tools/subagent/reader-prompt.js';
11
11
  import { riskOf } from './risk-gate.js';
12
+ import { NOT_ON_THIS_RELEASE, profileGate } from '../sap/profile-gate.js';
12
13
  const READER_TOOL_SET = new Set(READER_TOOLS);
13
14
  export async function dispatchTool(name, args, ctx) {
14
15
  const tool = getTool(name);
@@ -36,6 +37,16 @@ export async function dispatchTool(name, args, ctx) {
36
37
  is_error: true,
37
38
  };
38
39
  }
40
+ // ADT gap program, Part 1 (D4) — a definite "not on this release" is
41
+ // answered here, before any SAP call, as a normal tool error. Unknown or
42
+ // no profile: today's path, untouched.
43
+ const gate = profileGate(tool, args, ctx.systemProfile ?? null);
44
+ if (gate) {
45
+ return {
46
+ content: JSON.stringify({ error: NOT_ON_THIS_RELEASE, feature: gate.feature, message: gate.message }),
47
+ is_error: true,
48
+ };
49
+ }
39
50
  // L1-6 R1-6 (owner decision 2026-09-21) — a call that hands the stored SAP
40
51
  // login to a local child process ALWAYS asks a human, once per session per
41
52
  // alias, even as the first mutating call of a turn (where Rule 8 below stays
@@ -0,0 +1,42 @@
1
+ const A = '/sap/bc/adt';
2
+ export const FEATURES = Object.freeze({
3
+ 'cds.ddls': { label: 'CDS data definitions', collection: `${A}/ddic/ddl/sources`, since: { basis: 740, sp: 5, label: 'ABAP 7.40 SP05' }, provenOnOurBox: true },
4
+ 'cds.viewEntity': { label: 'CDS view entities (define view entity)', since: { basis: 755, label: 'S/4HANA 2020 (ABAP 7.55)' }, provenOnOurBox: true },
5
+ 'cds.tableEntity': { label: 'CDS table entities (define table entity)', since: { basis: 816, label: 'S/4HANA 2025 (ABAP 8.16)' }, provenOnOurBox: false },
6
+ 'cds.ddlx': { label: 'CDS metadata extensions', collection: `${A}/ddic/ddlx/sources`, since: { basis: 751, label: 'ABAP 7.51' }, provenOnOurBox: true },
7
+ 'cds.dcls': { label: 'CDS access controls (DCL)', collection: `${A}/acm/dcl/sources`, since: { basis: 740, sp: 10, label: 'ABAP 7.40 SP10' }, provenOnOurBox: true },
8
+ 'rap.bdef': { label: 'Behavior definitions (RAP)', collection: `${A}/bo/behaviordefinitions`, since: { basis: 753, label: 'S/4HANA 1809 (ABAP 7.53)' }, provenOnOurBox: true },
9
+ // Final review I1 (owner rule: a wrong no is worse than unknown): the next six
10
+ // rows had release floors with no cited source (gap table §3.1 gives one date
11
+ // for five RAP/CDS objects; "1709" for table/structure; "1610 / 7.50" for
12
+ // domain/data element). Dropped: listed = yes, else unknown, never no. Add a
13
+ // floor back only with a citation (SAP Help release line or ADT feature matrix).
14
+ 'rap.srvd': { label: 'Service definitions', collection: `${A}/ddic/srvd/sources`, provenOnOurBox: true },
15
+ 'rap.srvb': { label: 'Service bindings', collection: `${A}/businessservices/bindings`, provenOnOurBox: true },
16
+ 'ddic.tables': { label: 'Tables in ADT', collection: `${A}/ddic/tables`, provenOnOurBox: true },
17
+ 'ddic.structures': { label: 'Structures in ADT', collection: `${A}/ddic/structures`, provenOnOurBox: true },
18
+ 'ddic.domains': { label: 'Domains in ADT', collection: `${A}/ddic/domains`, provenOnOurBox: true },
19
+ 'ddic.dataelements': { label: 'Data elements in ADT', collection: `${A}/ddic/dataelements`, provenOnOurBox: true },
20
+ 'generator.repository': { label: "SAP's RAP generator", collection: `${A}/repository/generators`, since: { basis: 757, label: 'S/4HANA 2022 (ABAP 7.57)' }, absentMeansNo: true, provenOnOurBox: true },
21
+ 'atc.worklists': { label: 'ATC checks', collection: `${A}/atc/worklists`, since: { basis: 740, label: 'ABAP 7.40' }, provenOnOurBox: true },
22
+ 'unit.testruns': { label: 'ABAP Unit runs', collection: `${A}/abapunit/testruns`, provenOnOurBox: true },
23
+ 'text.programs': { label: 'Text elements of programs', collection: `${A}/textelements/programs`, since: { basis: 740, sp: 8, label: 'ABAP 7.40 SP08' }, provenOnOurBox: true },
24
+ // No `since` (R4): the profile's own CVERS reads use this endpoint, so a
25
+ // release-based `no` could only ever be wrong. Listed = yes, else unknown.
26
+ 'datapreview.freestyle': { label: 'SQL data preview', collection: `${A}/datapreview/freestyle`, provenOnOurBox: true },
27
+ 'msag': { label: 'Message classes', collection: `${A}/messageclass`, provenOnOurBox: true },
28
+ 'nrob': { label: 'Number range objects', collection: `${A}/numberranges/objects`, provenOnOurBox: true },
29
+ });
30
+ export const FEATURE_IDS = Object.freeze(Object.keys(FEATURES));
31
+ export function isFeatureId(s) {
32
+ return Object.prototype.hasOwnProperty.call(FEATURES, s);
33
+ }
34
+ /** url-mapper type token (upper case) → the feature it needs. Unlisted types need nothing. */
35
+ export const OBJECT_TYPE_FEATURE = Object.freeze({
36
+ BDEF: 'rap.bdef', SRVD: 'rap.srvd', SRVB: 'rap.srvb',
37
+ DDLS: 'cds.ddls', CDS: 'cds.ddls', DDLX: 'cds.ddlx', DCLS: 'cds.dcls',
38
+ TABL: 'ddic.tables', STRU: 'ddic.structures', DOMA: 'ddic.domains', DTEL: 'ddic.dataelements',
39
+ MSAG: 'msag', NROB: 'nrob',
40
+ });
41
+ /** The RAP/CDS subset: the only types a READ is ever refused for (R6). */
42
+ export const READ_GATED_TYPES = new Set(['BDEF', 'SRVD', 'SRVB', 'DDLS', 'CDS', 'DDLX', 'DCLS']);
@@ -0,0 +1,67 @@
1
+ import { FEATURES, OBJECT_TYPE_FEATURE, READ_GATED_TYPES } from './profile-features.js';
2
+ import { supports, systemLabel } from './system-profile.js';
3
+ export const NOT_ON_THIS_RELEASE = 'not_on_this_release';
4
+ /** For tools whose `type` argument names an object type (url-mapper token). */
5
+ export function byObjectType(args) {
6
+ const t = typeof args?.type === 'string' ? args.type.trim().toUpperCase() : '';
7
+ return t && Object.prototype.hasOwnProperty.call(OBJECT_TYPE_FEATURE, t) ? OBJECT_TYPE_FEATURE[t] : null;
8
+ }
9
+ /**
10
+ * R6 — reads refuse only RAP/CDS types (they cannot exist below their release).
11
+ * DDIC types carry no release floor at all (final review I1); a DDIC read was never proven to fail, and a
12
+ * wrong read costs one 404 while a wrong `no` costs the user's work.
13
+ */
14
+ export function byObjectTypeForRead(args) {
15
+ const t = typeof args?.type === 'string' ? args.type.trim().toUpperCase() : '';
16
+ return READ_GATED_TYPES.has(t) ? byObjectType(args) : null;
17
+ }
18
+ export function requiredFeature(tool, args) {
19
+ const r = tool.requires;
20
+ if (!r)
21
+ return null;
22
+ return typeof r === 'function' ? r(args) : r;
23
+ }
24
+ export function profileGate(tool, args, profile) {
25
+ if (!profile)
26
+ return null;
27
+ const feature = requiredFeature(tool, args);
28
+ if (!feature)
29
+ return null;
30
+ const v = supports(profile, feature);
31
+ if (v.answer !== 'no')
32
+ return null;
33
+ // I2 — name when the facts were read: the profile is kept up to 30 days.
34
+ return { feature, message: `${FEATURES[feature].label}: ${v.reason}. This system: ${systemLabel(profile)}, read ${profile.builtAt.slice(0, 10)}.` };
35
+ }
36
+ /** Names of the tools whose FIXED feature is a definite no. [] for no profile. */
37
+ export function hiddenByProfile(tools, profile) {
38
+ if (!profile)
39
+ return [];
40
+ return tools.filter((t) => typeof t.requires === 'string' && supports(profile, t.requires).answer === 'no').map((t) => t.name);
41
+ }
42
+ /** Drop the named tools. Same array back when nothing changes (byte-identical path). */
43
+ export function withoutTools(tools, hidden) {
44
+ if (hidden.length === 0)
45
+ return tools;
46
+ const set = new Set(hidden);
47
+ const kept = tools.filter((t) => !set.has(t.name));
48
+ return kept.length === tools.length ? tools : kept;
49
+ }
50
+ export function toolsForProfile(tools, profile) {
51
+ return withoutTools(tools, hiddenByProfile(tools, profile));
52
+ }
53
+ /**
54
+ * R7 / D4 — tools[] must be byte-stable for the session (prompt cache,
55
+ * preserved thinking), but it is rebuilt every turn. The hidden set is pinned
56
+ * on the session the first time an alias is seen — whatever the profile was
57
+ * on that turn (null pins []) — and reused after that. Another alias re-pins.
58
+ * A profile that arrives later refuses per call at dispatch instead.
59
+ */
60
+ export function pinHiddenTools(session, alias, tools, profile) {
61
+ const pinned = session.profile_hidden_tools;
62
+ if (pinned && pinned.alias === alias)
63
+ return pinned.names;
64
+ const names = hiddenByProfile(tools, profile);
65
+ session.profile_hidden_tools = { alias, names };
66
+ return names;
67
+ }
@@ -535,6 +535,6 @@ export function renderSapSystemBlock(info) {
535
535
  ].join(' ');
536
536
  return `<sap_system ${attrs} />`;
537
537
  }
538
- function escapeAttr(s) {
538
+ export function escapeAttr(s) {
539
539
  return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
540
540
  }
@@ -0,0 +1,312 @@
1
+ // cspeach-cli/src/sap/system-profile.ts
2
+ /**
3
+ * System profile (ADT gap program, Part 1; spec §2.1).
4
+ *
5
+ * One profile per connected SAP system, built from the facts the CLI already
6
+ * has (system-info: CVERS release) plus two reads cached for 30 days: the ADT
7
+ * discovery document and the SAP_BASIS support pack. `supports()` answers
8
+ * yes / no / unknown with a plain reason. Unknown changes nothing: tools run
9
+ * as today and fail at HTTP time if the feature is missing. Only a definite
10
+ * `no` stops a call before it reaches SAP.
11
+ *
12
+ * Evidence rules (owner decision D1): a collection LISTED by discovery is a
13
+ * yes; an unlisted one proves nothing (our S/4HANA 2023 box omits endpoints
14
+ * that work). A `no` comes from the release table alone, except the generator,
15
+ * which keeps today's getGeneratorFact reading (absent = no = CSPeach writer).
16
+ */
17
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
18
+ import { join } from 'node:path';
19
+ import { auditLog } from '@cspeach/sap-client';
20
+ import { cspeachRoot } from '../config/paths.js';
21
+ import { escapeAttr } from './system-info.js';
22
+ import { FEATURES, FEATURE_IDS } from './profile-features.js';
23
+ export function releaseAtLeast(p, min) {
24
+ if (p.basis === null)
25
+ return null;
26
+ if (p.basis > min.basis)
27
+ return true;
28
+ if (p.basis < min.basis)
29
+ return false;
30
+ if (min.sp === undefined)
31
+ return true;
32
+ if (p.sp === null)
33
+ return null;
34
+ return p.sp >= min.sp;
35
+ }
36
+ export function systemLabel(p) {
37
+ if (p.basis === null && p.release === 'unknown')
38
+ return 'unknown release';
39
+ const sp = p.sp === null ? '' : ` SP${String(p.sp).padStart(2, '0')}`;
40
+ const abap = p.abapVersion === 'unknown' ? '' : ` (ABAP ${p.abapVersion})`;
41
+ return `${p.release}${sp}${abap}`;
42
+ }
43
+ export function supports(p, id) {
44
+ const rule = FEATURES[id];
45
+ if (!p)
46
+ return { answer: 'unknown', reason: 'system profile not available' };
47
+ const listed = !!rule.collection && p.discovery.read && p.discovery.collections.includes(rule.collection);
48
+ if (listed)
49
+ return { answer: 'yes', reason: 'listed by this system' };
50
+ const byRelease = rule.since ? releaseAtLeast(p, rule.since) : null;
51
+ if (rule.since && byRelease === false)
52
+ return { answer: 'no', reason: `needs ${rule.since.label} or later` };
53
+ // R3 — the generator must agree with getGeneratorFact for the same body:
54
+ // list unread = fact unknown (HTTP ≥ 400) or no (HTML page), never a yes by release.
55
+ if (rule.absentMeansNo && rule.collection && !p.discovery.read)
56
+ return { answer: 'unknown', reason: 'the system list could not be read' };
57
+ if (rule.absentMeansNo && rule.collection && p.discovery.read)
58
+ return { answer: 'no', reason: 'not offered by this system' };
59
+ if (rule.since && byRelease === true)
60
+ return { answer: 'yes', reason: `${systemLabel(p)} has it` };
61
+ if (rule.since && p.basis !== null && p.basis === rule.since.basis && p.sp === null) {
62
+ return { answer: 'unknown', reason: `needs ${rule.since.label}; this system is ${p.abapVersion} with an unknown support pack` };
63
+ }
64
+ if (!p.discovery.read && rule.collection)
65
+ return { answer: 'unknown', reason: 'the system list could not be read' };
66
+ return { answer: 'unknown', reason: 'no release fact covers it' };
67
+ }
68
+ function basisOf(info) {
69
+ const raw = info?.components?.['SAP_BASIS'];
70
+ if (!raw || !/^\d{3}$/.test(raw.trim()))
71
+ return null;
72
+ return Number(raw.trim());
73
+ }
74
+ export function profileFrom(i) {
75
+ if (!i.info && !i.discovery)
76
+ return null;
77
+ const basis = basisOf(i.info);
78
+ return {
79
+ alias: i.alias,
80
+ baseUrl: i.baseUrl,
81
+ platform: i.info?.platform ?? 'unknown',
82
+ release: i.info?.release ?? 'unknown',
83
+ abapVersion: i.info?.abapVersion ?? 'unknown',
84
+ basis,
85
+ sp: basis === null ? null : i.sp,
86
+ cloud: i.info && Object.keys(i.info.components).length > 0 ? 'no' : 'unknown',
87
+ discovery: i.discovery
88
+ ? { read: true, collections: Object.keys(i.discovery.collections).sort() }
89
+ : { read: false, collections: [] },
90
+ builtAt: i.now.toISOString(),
91
+ };
92
+ }
93
+ const CACHE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
94
+ /** Bump when the stored shape or its meaning changes. */
95
+ const CACHE_VERSION = 1;
96
+ const SP_SQL = "SELECT COMPONENT, RELEASE, EXTRELEASE FROM CVERS WHERE COMPONENT = 'SAP_BASIS'";
97
+ /**
98
+ * Final review I4 — an incomplete build (list or SP not read) is never written
99
+ * as a profile (R2), so without this it would cost both reads on every CLI
100
+ * start. The ATTEMPT is remembered per alias for a day (when, and against
101
+ * which system — no answers), and each read is bounded in time.
102
+ */
103
+ const ATTEMPT_TTL_MS = 24 * 60 * 60 * 1000;
104
+ const READ_TIMEOUT_MS = 15_000;
105
+ /** Last build per alias for this process — null included (X3: a failed turn is not retried every turn). */
106
+ const memCache = new Map();
107
+ export function _resetSystemProfileCache() {
108
+ memCache.clear();
109
+ }
110
+ /**
111
+ * The SAP_BASIS support pack from the column-major data preview ('0002' → 2).
112
+ * Sibling of parseCvers (system-info.ts), which reads the same shape into
113
+ * component → release; that function and its SQL stay untouched (D2).
114
+ */
115
+ export function parseExtRelease(xml) {
116
+ const colsRe = /<(?:[\w:]+:)?columns\b[^>]*>([\s\S]*?)<\/(?:[\w:]+:)?columns\s*>/g;
117
+ let m;
118
+ while ((m = colsRe.exec(xml)) !== null) {
119
+ const block = m[1];
120
+ const name = /<(?:[\w:]+:)?metadata\b[^>]*?\b(?:[\w:]+:)?name="([^"]+)"/i.exec(block)?.[1];
121
+ if (!name || name.toUpperCase() !== 'EXTRELEASE')
122
+ continue;
123
+ const value = /<(?:[\w:]+:)?data\b(?:\s[^>]*?)?>([\s\S]*?)<\/(?:[\w:]+:)?data\s*>/.exec(block)?.[1]?.trim() ?? '';
124
+ const digits = /(\d+)\s*$/.exec(value)?.[1];
125
+ return digits ? Number(digits) : null;
126
+ }
127
+ return null;
128
+ }
129
+ export async function getSystemProfile(adt, alias, info, opts = {}) {
130
+ const env = opts.env ?? process.env;
131
+ if ((env['CSPEACH_SYSTEM_PROFILE'] ?? '').trim().toLowerCase() === 'off')
132
+ return null;
133
+ const now = opts.now ?? (() => new Date());
134
+ const conn = adt.connection;
135
+ const baseUrl = conn?.getBaseUrl?.() ?? '';
136
+ if (!opts.fresh) {
137
+ if (memCache.has(alias)) {
138
+ const hit = memCache.get(alias) ?? null;
139
+ if (hit === null || sameSystem(hit, baseUrl, info))
140
+ return hit;
141
+ }
142
+ const fromDisk = readDiskCache(alias, now());
143
+ if (fromDisk && sameSystem(fromDisk, baseUrl, info)) {
144
+ memCache.set(alias, fromDisk);
145
+ return fromDisk;
146
+ }
147
+ // I4 — an incomplete build was tried for this system within the day: no
148
+ // profile this start (unknown everywhere = today's behaviour), no requests.
149
+ if (recentAttempt(alias, baseUrl, info, now())) {
150
+ memCache.set(alias, null);
151
+ return null;
152
+ }
153
+ }
154
+ const timeoutMs = opts.readTimeoutMs ?? READ_TIMEOUT_MS;
155
+ const startedAt = Date.now();
156
+ try {
157
+ let discovery = null;
158
+ try {
159
+ discovery = await bounded(adt.discovery(), timeoutMs);
160
+ }
161
+ catch {
162
+ discovery = null;
163
+ }
164
+ let sp = null;
165
+ if (info?.components?.['SAP_BASIS'] && conn?.post) {
166
+ try {
167
+ const resp = await bounded(conn.post('/sap/bc/adt/datapreview/freestyle?rowNumber=5', SP_SQL, 'text/plain', 'application/vnd.sap.adt.datapreview.table.v1+xml'), timeoutMs);
168
+ if (resp && resp.status === 200)
169
+ sp = parseExtRelease(resp.body);
170
+ }
171
+ catch {
172
+ sp = null;
173
+ }
174
+ }
175
+ const profile = profileFrom({ alias, baseUrl, info, sp, discovery, now: now() });
176
+ if (!profile) {
177
+ memCache.set(alias, null);
178
+ writeAttempt(alias, baseUrl, info, now());
179
+ await auditLog('sapProfile', 'ALIAS', alias, 'error', Date.now() - startedAt, 'no discovery and no system info');
180
+ return null;
181
+ }
182
+ memCache.set(alias, profile);
183
+ // R2 — same rule as system-info's failed probe: never persist what was not
184
+ // read. Complete = list read, and the SP read (or no SAP_BASIS to read it for).
185
+ const complete = profile.discovery.read && (profile.sp !== null || profile.basis === null);
186
+ if (complete) {
187
+ writeDiskCache(alias, profile);
188
+ clearAttempt(alias);
189
+ }
190
+ else
191
+ writeAttempt(alias, baseUrl, info, now());
192
+ await auditLog('sapProfile', 'ALIAS', alias, 'success', Date.now() - startedAt, `release=${profile.release} sp=${profile.sp ?? 'unknown'} discovery=${profile.discovery.read ? profile.discovery.collections.length : 'not read'} cached=${complete ? 'disk' : 'memory'}`);
193
+ return profile;
194
+ }
195
+ catch (err) {
196
+ memCache.set(alias, null);
197
+ writeAttempt(alias, baseUrl, info, now());
198
+ await auditLog('sapProfile', 'ALIAS', alias, 'error', Date.now() - startedAt, `threw: ${err.message}`);
199
+ return null;
200
+ }
201
+ }
202
+ function sameSystem(p, baseUrl, info) {
203
+ if (baseUrl && p.baseUrl && p.baseUrl !== baseUrl)
204
+ return false;
205
+ if (!info)
206
+ return true;
207
+ return p.release === info.release && p.abapVersion === info.abapVersion;
208
+ }
209
+ function cacheFile(alias, kind = 'profile') {
210
+ const safe = alias.replace(/[^A-Za-z0-9._-]/g, '_');
211
+ return join(cspeachRoot(), 'sap-cache', `${safe}.${kind}.json`);
212
+ }
213
+ /** Resolves to null when the read takes longer than `ms` (the request itself is left to finish). */
214
+ function bounded(p, ms) {
215
+ let timer;
216
+ const timeout = new Promise((resolve) => {
217
+ timer = setTimeout(() => resolve(null), ms);
218
+ timer.unref?.();
219
+ });
220
+ return Promise.race([p, timeout]).finally(() => { if (timer)
221
+ clearTimeout(timer); });
222
+ }
223
+ function recentAttempt(alias, baseUrl, info, now) {
224
+ try {
225
+ const path = cacheFile(alias, 'profile-attempt');
226
+ if (!existsSync(path))
227
+ return false;
228
+ const a = JSON.parse(readFileSync(path, 'utf8'));
229
+ const at = new Date(a.attemptedAt).getTime();
230
+ if (!Number.isFinite(at) || now.getTime() - at > ATTEMPT_TTL_MS || now.getTime() < at)
231
+ return false;
232
+ if (a.baseUrl !== baseUrl)
233
+ return false;
234
+ return a.release === (info?.release ?? 'unknown') && a.abapVersion === (info?.abapVersion ?? 'unknown');
235
+ }
236
+ catch {
237
+ return false;
238
+ }
239
+ }
240
+ function writeAttempt(alias, baseUrl, info, now) {
241
+ try {
242
+ const dir = join(cspeachRoot(), 'sap-cache');
243
+ if (!existsSync(dir))
244
+ mkdirSync(dir, { recursive: true });
245
+ const a = { attemptedAt: now.toISOString(), baseUrl, release: info?.release ?? 'unknown', abapVersion: info?.abapVersion ?? 'unknown' };
246
+ writeFileSync(cacheFile(alias, 'profile-attempt'), JSON.stringify(a, null, 2), 'utf8');
247
+ }
248
+ catch {
249
+ // best effort — at worst the next start tries the build again
250
+ }
251
+ }
252
+ function clearAttempt(alias) {
253
+ try {
254
+ rmSync(cacheFile(alias, 'profile-attempt'), { force: true });
255
+ }
256
+ catch { /* best effort */ }
257
+ }
258
+ function readDiskCache(alias, now) {
259
+ try {
260
+ const path = cacheFile(alias);
261
+ if (!existsSync(path))
262
+ return null;
263
+ const raw = JSON.parse(readFileSync(path, 'utf8'));
264
+ if (raw.cacheVersion !== CACHE_VERSION || !raw.builtAt)
265
+ return null;
266
+ if (now.getTime() - new Date(raw.builtAt).getTime() > CACHE_TTL_MS)
267
+ return null;
268
+ const { cacheVersion: _v, ...profile } = raw;
269
+ return profile;
270
+ }
271
+ catch {
272
+ return null;
273
+ }
274
+ }
275
+ function writeDiskCache(alias, profile) {
276
+ try {
277
+ const dir = join(cspeachRoot(), 'sap-cache');
278
+ if (!existsSync(dir))
279
+ mkdirSync(dir, { recursive: true });
280
+ writeFileSync(cacheFile(alias), JSON.stringify({ ...profile, cacheVersion: CACHE_VERSION }, null, 2), 'utf8');
281
+ }
282
+ catch {
283
+ // best effort — the memory result is still returned
284
+ }
285
+ }
286
+ /** The one-line summary for <session_context>; '' when there is no profile. */
287
+ export function renderSapProfileBlock(p) {
288
+ if (!p)
289
+ return '';
290
+ const answer = (id) => supports(p, id).answer;
291
+ const viewEntity = answer('cds.viewEntity');
292
+ const cdsSyntax = viewEntity === 'yes' ? 'define view entity' : viewEntity === 'no' ? 'define view' : 'unknown';
293
+ const notAvailable = FEATURE_IDS
294
+ .map((id) => ({ id, v: supports(p, id) }))
295
+ .filter((x) => x.v.answer === 'no')
296
+ .map((x) => `${FEATURES[x.id].label}: ${x.v.reason}`)
297
+ .join('; ') || 'none known';
298
+ const attrs = [
299
+ `release="${escapeAttr(p.release)}"`,
300
+ `sp="${p.sp === null ? 'unknown' : `SP${String(p.sp).padStart(2, '0')}`}"`,
301
+ `abap="${escapeAttr(p.abapVersion)}"`,
302
+ `cloud="${p.cloud}"`,
303
+ `cds_view_syntax="${cdsSyntax}"`,
304
+ `rap="${answer('rap.bdef')}"`,
305
+ `rap_generator="${answer('generator.repository')}"`,
306
+ `table_entity="${answer('cds.tableEntity')}"`,
307
+ `discovery="${p.discovery.read ? 'read' : 'not read'}"`,
308
+ `built="${p.builtAt.slice(0, 10)}"`,
309
+ `not_available="${escapeAttr(notAvailable)}"`,
310
+ ];
311
+ return `<sap_profile ${attrs.join(' ')} />`;
312
+ }
@@ -96,6 +96,7 @@ export async function rapFinishCheckHandler(args, ctx) {
96
96
  }
97
97
  registerTool({
98
98
  name: 'rap_finish_check',
99
+ requires: 'rap.bdef',
99
100
  description: 'Check the Finish items F1–F5 of a RAP app against its live sources (authorization, access control, '
100
101
  + 'read-only admin fields, labels, coded fields). Read-only. Run it after the Finish step and before the UI. '
101
102
  + 'A failed item lists what to fix; fix only those and run it again.',
@@ -294,6 +294,7 @@ export async function sapGenerateObjectsHandler(args, ctx, deps = {}) {
294
294
  }
295
295
  registerTool({
296
296
  name: 'sap_generate_objects',
297
+ requires: 'generator.repository',
297
298
  description: "Run SAP's RAP generator for a skeleton phase whose Plan block says sap-generator. Pass the decision path and "
298
299
  + 'sha256 from the plan block, and one approval_id per planned object in the same order (request_approval with '
299
300
  + 'op create for each). CSPeach validates, previews, generates, then checks every object is active and on the '
@@ -22,6 +22,7 @@
22
22
  * badiList(query, enhancementSpot?, maxResults?)
23
23
  */
24
24
  import { registerTool } from './index.js';
25
+ import { byObjectTypeForRead } from '../sap/profile-gate.js';
25
26
  import { normaliseSyntaxResult, checkSyntax } from './verify.js';
26
27
  import { recordSyntaxCheck } from './syntax-state.js';
27
28
  import { spillToolResult } from './result-spill.js';
@@ -71,6 +72,7 @@ export function formatSearchHits(hits) {
71
72
  // -----------------------------------------------------------------------
72
73
  registerTool({
73
74
  name: 'sap_get_source',
75
+ requires: byObjectTypeForRead,
74
76
  description: 'Read the source code of an ABAP object by name and type. '
75
77
  + 'Optional `version` selects active or inactive source explicitly; '
76
78
  + 'omitting it lets SAP choose (usually active). When the object has '
@@ -174,6 +176,7 @@ registerTool({
174
176
  // -----------------------------------------------------------------------
175
177
  registerTool({
176
178
  name: 'sap_object_structure',
179
+ requires: byObjectTypeForRead,
177
180
  description: 'Retrieve metadata and structural information for an ABAP repository object (package, description, lock state, etc.).',
178
181
  isMutating: false,
179
182
  requiresSap: true,
@@ -281,6 +284,7 @@ export function arsColumnErrorHint(query, err) {
281
284
  }
282
285
  registerTool({
283
286
  name: 'sap_sql_query',
287
+ requires: 'datapreview.freestyle',
284
288
  description: 'Execute a read-only SQL SELECT against the SAP database via ADT Data Preview. Security-sensitive tables are blocked automatically. '
285
289
  + 'Dialect limits (violations return an opaque "SAP HTTP 400: sqlQuery"): no IN-lists and at most one LIKE per WHERE clause '
286
290
  + '(multi-OR LIKE chains are rejected) — use a single LIKE or split into separate queries. '
@@ -376,6 +380,7 @@ registerTool({
376
380
  // -----------------------------------------------------------------------
377
381
  registerTool({
378
382
  name: 'sap_atc_run',
383
+ requires: 'atc.worklists',
379
384
  description: 'Run ABAP Test Cockpit (ATC) checks on a specific object and return all findings.',
380
385
  isMutating: false,
381
386
  requiresSap: true,
@@ -394,8 +399,29 @@ registerTool({
394
399
  handler: async (args, ctx) => {
395
400
  // atcRun(type, name, variant?) — type first
396
401
  // Review fix round 1, M-3: Esc / Ctrl+C stops a long ATC run.
397
- const result = await ctx.adt.atcRun(args.type, args.name, args.variant ?? 'DEFAULT', ctx.signal ? { signal: ctx.signal } : {});
398
- return { content: JSON.stringify(result, null, 2) };
402
+ const variant = args.variant ?? 'DEFAULT';
403
+ const result = await ctx.adt.atcRun(args.type, args.name, variant, ctx.signal ? { signal: ctx.signal } : {});
404
+ // Fable review 2026-10-15, I-1: a finished run whose worklist never named
405
+ // the object did not check it (the variant has no check for that type).
406
+ // Say so plainly; "0 findings" there is not a measurement. Every other
407
+ // result is the same JSON as before (the coverage key is not added).
408
+ const { coverage, ...shown } = result;
409
+ if (coverage === 'absent' && result.totalFindings === 0) {
410
+ const objectName = `${String(args.type).toUpperCase()} ${String(args.name).toUpperCase()}`;
411
+ return {
412
+ content: [
413
+ `ATC did not check ${objectName}.`,
414
+ 'SAP finished the run, but its worklist never listed this object.',
415
+ 'This is not a measurement. Do not report it as 0 findings or as clean.',
416
+ 'Likely causes:',
417
+ `- the ${String(variant).toUpperCase()} check variant may have no check for this object type (seen on S4H for DDLS, DDLX, SRVD, DOMA);`,
418
+ '- the name may be wrong, or the object may not exist or not be active.',
419
+ 'Use sap_search_object to confirm the object exists.',
420
+ `(ATC worklist ${result.worklistId || 'unknown'})`,
421
+ ].join('\n'),
422
+ };
423
+ }
424
+ return { content: JSON.stringify(shown, null, 2) };
399
425
  },
400
426
  });
401
427
  // -----------------------------------------------------------------------
@@ -631,6 +657,7 @@ registerTool({
631
657
  // -----------------------------------------------------------------------
632
658
  registerTool({
633
659
  name: 'sap_run_unit_test',
660
+ requires: 'unit.testruns',
634
661
  description: 'Execute ABAP Unit tests for an object and return a structured pass/fail report with method-level results.',
635
662
  isMutating: false,
636
663
  requiresSap: true,
@@ -24,6 +24,7 @@
24
24
  import crypto from 'node:crypto';
25
25
  import { readFileSync } from 'node:fs';
26
26
  import { registerTool } from './index.js';
27
+ import { byObjectType } from '../sap/profile-gate.js';
27
28
  import { verifyAndSpendApprovalId } from '../approvals/jwt.js';
28
29
  import { grantClaimMismatch, grantActivateMismatch, grantOwningTransportMismatch, grantOwningTransportDetail } from '../approvals/grant-claims.js';
29
30
  import { autoSnapshot, autoSnapshotClassInclude, autoSnapshotMessageClass } from './snapshot.js';
@@ -172,6 +173,7 @@ function snapshotSource(snap) {
172
173
  }
173
174
  registerTool({
174
175
  name: 'sap_set_source',
176
+ requires: byObjectType,
175
177
  description: 'Overwrite the full source of an ABAP object. Requires approval_id. '
176
178
  + 'Use for class-definition changes, new methods, or any change not confined to '
177
179
  + 'a single method body. For method-body-only edits use sap_update_method (Rule 7a). '
@@ -567,6 +569,7 @@ registerTool({
567
569
  // autoSnapshot here for belt-and-suspenders consistency with the CLI gate model.
568
570
  registerTool({
569
571
  name: 'sap_delete_object',
572
+ requires: byObjectType,
570
573
  description: 'Delete an ABAP object. Requires approval_id. '
571
574
  + 'A snapshot is taken automatically before deletion. '
572
575
  + 'Post-check: confirms the object no longer exists. '
@@ -692,6 +695,7 @@ registerTool({
692
695
  // No snapshot needed: new object has no prior source.
693
696
  registerTool({
694
697
  name: 'sap_create_object',
698
+ requires: byObjectType,
695
699
  description: 'Create a new ABAP object shell. '
696
700
  + 'Requires approval_id. No snapshot needed (new object). '
697
701
  + 'Write source separately via sap_set_source, then call sap_activate. '
@@ -1125,6 +1129,7 @@ registerTool({
1125
1129
  // No snapshot needed: new object has no prior source.
1126
1130
  registerTool({
1127
1131
  name: 'sap_create_domain',
1132
+ requires: 'ddic.domains',
1128
1133
  description: 'Create a DDIC domain (DOMA), then activate and verify it. '
1129
1134
  + 'Requires approval_id. No snapshot needed (new object). '
1130
1135
  + 'Use the domain in a data element via sap_create_data_element.',
@@ -1266,6 +1271,7 @@ registerTool({
1266
1271
  // No snapshot needed: new object has no prior source.
1267
1272
  registerTool({
1268
1273
  name: 'sap_create_data_element',
1274
+ requires: 'ddic.dataelements',
1269
1275
  description: 'Create a DDIC data element (DTEL) bound to an existing domain, then '
1270
1276
  + 'activate and verify it. Requires approval_id. No snapshot needed (new object). '
1271
1277
  + 'Use the data element as a table/structure field type.',
@@ -1617,6 +1623,7 @@ registerTool({
1617
1623
  // Signature: serviceBindingPublish(name) → Promise<{ status: number; error?: string }>
1618
1624
  registerTool({
1619
1625
  name: 'sap_service_binding_publish',
1626
+ requires: 'rap.srvb',
1620
1627
  description: 'Publish an ADT service binding so its OData endpoint becomes active and reachable.',
1621
1628
  isMutating: true,
1622
1629
  requiresSap: true,
@@ -1640,6 +1647,7 @@ registerTool({
1640
1647
  // NOTE: the unpublishjobs endpoint is inferred-not-yet-live-verified (VPN down).
1641
1648
  registerTool({
1642
1649
  name: 'sap_service_binding_unpublish',
1650
+ requires: 'rap.srvb',
1643
1651
  description: 'Unpublish an ADT service binding so its OData endpoint is removed. '
1644
1652
  + 'Required before deleting a published binding (SAP returns HTTP 412 "is published" otherwise).',
1645
1653
  isMutating: true,
@@ -1713,6 +1721,7 @@ function messageRowsProblem(rows) {
1713
1721
  }
1714
1722
  registerTool({
1715
1723
  name: 'sap_message_maintain',
1724
+ requires: 'msag',
1716
1725
  description: 'Add or change messages in an existing message class (MSAG, the T100 texts behind SE91). '
1717
1726
  + 'Create the class first with sap_create_object type MSAG. One call takes many messages. '
1718
1727
  + 'Each message: msgno (three digits, 000-999) and text (up to 73 characters, &1-&4 placeholders allowed). '
@@ -1915,6 +1924,7 @@ registerTool({
1915
1924
  // in @cspeach/sap-client.
1916
1925
  registerTool({
1917
1926
  name: 'sap_number_range_intervals',
1927
+ requires: 'nrob',
1918
1928
  description: 'STUB — not currently implemented; do not call. '
1919
1929
  + 'Writing NROB number-range intervals via ADT requires NR-object XML format '
1920
1930
  + 'not yet implemented in @cspeach/sap-client.',
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cspeach/cli",
3
- "version": "1.1.38",
3
+ "version": "1.1.40",
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.23",
57
+ "@cspeach/sap-client": "0.1.25",
58
58
  "@iarna/toml": "^2.2.5",
59
59
  "@inkjs/ui": "^2.0.0",
60
60
  "@inquirer/prompts": "^7.0.0",