@cspeach/cli 1.1.40 → 1.1.42

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.
@@ -0,0 +1,4 @@
1
+ {
2
+ "id": "1a6ac3e6",
3
+ "createdAt": "2026-10-16T13:54:35.800Z"
4
+ }
@@ -837,7 +837,9 @@ ${imageExpansion.error}`));
837
837
  sapSystemInfo = await getSapSystemInfo(params.ctx.adt, params.ctx.sapAlias);
838
838
  // ADT gap program, Part 1 — the system profile rides the same lazy probe.
839
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);
840
+ // Part 2a D14: the turn's signal cancels the build's reads on Esc (null
841
+ // for this turn, nothing cached).
842
+ params.ctx.systemProfile = await getSystemProfile(params.ctx.adt, params.ctx.sapAlias, sapSystemInfo, { signal: params.signal });
841
843
  sapSystemBlock = renderSapSystemBlock(sapSystemInfo);
842
844
  sapProfileBlock = renderSapProfileBlock(params.ctx.systemProfile);
843
845
  }
@@ -9,7 +9,8 @@ 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
+ import { NOT_ON_THIS_RELEASE, gateWithVerify } from '../sap/profile-gate.js';
13
+ import { INTERRUPTED_TOOL_RESULT } from './repair-partial.js';
13
14
  const READER_TOOL_SET = new Set(READER_TOOLS);
14
15
  export async function dispatchTool(name, args, ctx) {
15
16
  const tool = getTool(name);
@@ -39,14 +40,19 @@ export async function dispatchTool(name, args, ctx) {
39
40
  }
40
41
  // ADT gap program, Part 1 (D4) — a definite "not on this release" is
41
42
  // 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);
43
+ // no profile: today's path, untouched. Part 2a D11 — a `no` is verified
44
+ // against a fresh profile once per alias before it is believed.
45
+ const gate = await gateWithVerify(tool, args, ctx);
44
46
  if (gate) {
45
47
  return {
46
48
  content: JSON.stringify({ error: NOT_ON_THIS_RELEASE, feature: gate.feature, message: gate.message }),
47
49
  is_error: true,
48
50
  };
49
51
  }
52
+ // Final review I-3 — the D11 re-check can take seconds of SAP reads; an Esc
53
+ // in that window must stop the call here, not let the handler run "as unknown".
54
+ if (ctx.signal?.aborted)
55
+ return { content: INTERRUPTED_TOOL_RESULT, is_error: true };
50
56
  // L1-6 R1-6 (owner decision 2026-09-21) — a call that hands the stored SAP
51
57
  // login to a local child process ALWAYS asks a human, once per session per
52
58
  // alias, even as the first mutating call of a turn (where Rule 8 below stays
@@ -1,3 +1,5 @@
1
+ import { typeCode } from './risk-floor.js';
2
+ import { sanitizeControlChars } from '../renderer/sanitize.js';
1
3
  /**
2
4
  * Human-readable operation + object labels for the approval surfaces.
3
5
  *
@@ -36,7 +38,8 @@ const TYPE_WORDS = {
36
38
  TTYP: 'table type',
37
39
  MSAG: 'message class',
38
40
  DEVC: 'package',
39
- ENHO: 'enhancement',
41
+ ENHO: 'BAdI implementation',
42
+ ENHS: 'BAdI enhancement spot',
40
43
  XSLT: 'transformation',
41
44
  SHLP: 'search help',
42
45
  };
@@ -49,6 +52,11 @@ export function humanType(type) {
49
52
  const key = type.trim().toUpperCase();
50
53
  if (TYPE_WORDS[key])
51
54
  return TYPE_WORDS[key];
55
+ // Part 2b D7 — the BAdI types read the same with a subtype ('ENHO/XHB').
56
+ // Every other type keeps today's label.
57
+ const code = typeCode(type);
58
+ if (code === 'ENHO' || code === 'ENHS')
59
+ return TYPE_WORDS[code];
52
60
  // Already a word (has a lowercase letter or a space)? use it verbatim.
53
61
  if (/[a-z ]/.test(type))
54
62
  return type;
@@ -60,6 +68,15 @@ export function verbLine(change) {
60
68
  .filter(Boolean)
61
69
  .join(' ');
62
70
  }
71
+ /**
72
+ * Part 2b D7 — "Affects: <process>" for a change that names the standard SAP
73
+ * process it runs in (a BAdI implementation); null when it names none. The
74
+ * text comes from the model: control characters are removed.
75
+ */
76
+ export function affectsLine(change) {
77
+ const t = sanitizeControlChars(String(change.affects ?? '')).trim();
78
+ return t ? `Affects: ${t}` : null;
79
+ }
63
80
  /**
64
81
  * The transport to DISPLAY on the approval surface for one change — mirrors
65
82
  * how the write tools actually resolve it (traced in sap-write.ts, 2026-07-05
@@ -1,7 +1,7 @@
1
1
  import { select, input } from '@inquirer/prompts';
2
2
  import chalk from 'chalk';
3
3
  import { riskReasonText, shownRiskReasons } from './risk-floor.js';
4
- import { verbLine, viewAffordance, toSourceLines } from './op-labels.js';
4
+ import { verbLine, viewAffordance, toSourceLines, affectsLine } from './op-labels.js';
5
5
  import { shouldUseInk } from '../renderer/tty.js';
6
6
  import { approvalEmitter } from '../ui/approval-emitter.js';
7
7
  import { withInquirer } from '../repl/inquirer-guard.js';
@@ -114,15 +114,29 @@ export async function renderPlanGate(summary, changes, transport, effectiveRisk,
114
114
  if (shownReasons.length > 0) {
115
115
  facts.push(`Risk raised to ${effectiveRisk.level}: ${shownReasons.map(riskReasonText).join('; ')}`);
116
116
  }
117
- const steps = visibleChanges.map((c, i) => `${i + 1}. ${verbLine(c)}`);
118
- if (steps.length > MAX_STEPS) {
117
+ // Part 2b D7 — a step that names the process it runs in (a BAdI
118
+ // implementation) carries its "Affects:" row right under it. The card's
119
+ // step budget counts rows, and a step is never shown without its row.
120
+ // No affects anywhere: one row per step, exactly as before.
121
+ const steps = visibleChanges.map((c, i) => {
122
+ const affects = affectsLine(c);
123
+ return [`${i + 1}. ${verbLine(c)}`, ...(affects ? [` ${affects}`] : [])];
124
+ });
125
+ const totalRows = steps.reduce((n, s) => n + s.length, 0);
126
+ if (totalRows > MAX_STEPS) {
119
127
  // Object names are model-supplied: neutralise control characters
120
128
  // before they reach scrollback (the card sanitises its own rows).
121
- emitTrace(sanitizeControlChars(['Plan', ...steps.map((s) => ` ${s}`)].join('\n')));
122
- facts.push(...steps.slice(0, MAX_STEPS), `${getGlyphs().more} ${steps.length - MAX_STEPS} more steps, listed above`);
129
+ emitTrace(sanitizeControlChars(['Plan', ...steps.flat().map((s) => ` ${s}`)].join('\n')));
130
+ let shown = 0;
131
+ let rows = 0;
132
+ while (shown < steps.length && (shown === 0 || rows + steps[shown].length <= MAX_STEPS)) {
133
+ rows += steps[shown].length;
134
+ shown += 1;
135
+ }
136
+ facts.push(...steps.slice(0, shown).flat(), `${getGlyphs().more} ${steps.length - shown} more steps, listed above`);
123
137
  }
124
138
  else {
125
- facts.push(...steps);
139
+ facts.push(...steps.flat());
126
140
  }
127
141
  const res = await askConfirm({
128
142
  id: 'plan-gate',
@@ -162,6 +176,10 @@ async function classicPlanGate(summary, visibleChanges, transport, effectiveRisk
162
176
  }
163
177
  visibleChanges.forEach((c, i) => {
164
178
  console.log(` ${chalk.dim(`${i + 1}.`)} ${verbLine(c)}`);
179
+ // Part 2b D7 — the process a BAdI implementation runs in, under its line.
180
+ const affects = affectsLine(c);
181
+ if (affects)
182
+ console.log(` ${chalk.dim(affects)}`);
165
183
  });
166
184
  console.log('');
167
185
  const choice = await withInquirer(() => select({
@@ -193,8 +211,10 @@ export async function renderPerChangeApproval(change, effectiveRisk, transport)
193
211
  if (aff)
194
212
  choices.push({ value: 'diff', name: aff.label });
195
213
  choices.push({ value: 'skip', name: 'Skip (tell model why)' }, { value: 'cancel', name: 'Cancel turn' });
214
+ // Part 2b D7 — the process a BAdI implementation runs in, under the verb line.
215
+ const affects = affectsLine(change);
196
216
  const choice = await withInquirer(() => select({
197
- message: `${objLabel} — apply?`,
217
+ message: `${objLabel} — apply?${affects ? `\n ${chalk.dim(affects)}` : ''}`,
198
218
  default: 'apply',
199
219
  theme: inquirerTheme,
200
220
  choices,
@@ -1,3 +1,11 @@
1
+ /**
2
+ * Part 2b D7 — the type code of a change or id: trimmed, upper case, without a
3
+ * `/subtype` ('enho/xhb' → 'ENHO'). The ENHO risk floor, the affects_required
4
+ * refusal and the write tool's type binding all use it, so they always agree.
5
+ */
6
+ export function typeCode(type) {
7
+ return String(type ?? '').trim().toUpperCase().split('/')[0].trim();
8
+ }
1
9
  export function effectiveRisk(declared, changes, ctx) {
2
10
  let floor = declared;
3
11
  const raisedBy = [];
@@ -28,6 +36,13 @@ export function effectiveRisk(declared, changes, ctx) {
28
36
  raisedBy.push('syntax_errors');
29
37
  }
30
38
  }
39
+ // Part 2b D7 — a BAdI implementation runs inside a standard SAP process:
40
+ // high (never auto-approved), and the reason is always shown.
41
+ if (changes.some((c) => typeCode(c.type) === 'ENHO')) {
42
+ raise('high', 'standard_process');
43
+ if (!raisedBy.includes('standard_process'))
44
+ raisedBy.push('standard_process');
45
+ }
31
46
  if (changes.some((c) => c.op === 'delete'))
32
47
  raise('medium', 'delete_op');
33
48
  // Round 4 item 8a — these were both labelled 'shared_transport', so a plan
@@ -58,6 +73,7 @@ const RISK_REASON_TEXT = {
58
73
  escalated_in_session: 'more than 5 changes in one plan',
59
74
  prod_namespace: 'the object is in a production namespace',
60
75
  syntax_errors: 'the last syntax check of this object found errors',
76
+ standard_process: 'this code runs inside a standard SAP process (a BAdI implementation)',
61
77
  };
62
78
  /** Round 8 item 4 — every code the floor can raise (each has plain words above). */
63
79
  export const RISK_REASON_CODES = Object.keys(RISK_REASON_TEXT);
@@ -167,6 +167,9 @@ const RULES = {
167
167
  rules_propose: () => ({ verb: 'Check a proposed decision rule', object: 'nothing is saved' }),
168
168
  team_estate: (a) => ({ verb: 'Read estate inventory', object: str(a['packages']) ?? 'all packages' }),
169
169
  sap_badi_list: (a) => ({ verb: 'Searched BAdIs', object: shortRight(`"${str(a['query']) ?? ''}"`) }),
170
+ sap_badi_read: (a) => ({ verb: 'Read BAdI', object: shortRight([str(a['kind']), str(a['name'])].filter(Boolean).join(' ')) }),
171
+ sap_enhancement_options: (a) => ({ verb: 'Read enhancement options', object: shortRight([str(a['type']), str(a['name'])].filter(Boolean).join(' ')) }),
172
+ sap_create_badi_implementation: sapObject('Created BAdI implementation'),
170
173
  sap_snapshot_list: (a) => ({ verb: 'Listed snapshots', object: shortRight(str(a['name']) ?? '') }),
171
174
  sap_snapshot_read: sapObject('Read snapshot'),
172
175
  sap_snapshot_cleanup: () => ({ verb: 'Cleaned snapshots', object: '' }),
@@ -265,7 +268,7 @@ const PRESENT = {
265
268
  'Read snapshot': 'Reading snapshot of', 'Cleaned snapshots': 'Cleaning snapshots',
266
269
  'Created transport': 'Creating transport', 'Transport for': 'Finding transport for',
267
270
  'Listed transports': 'Listing transports', Released: 'Releasing', 'Created domain': 'Creating domain',
268
- 'Created data element': 'Creating data element', Locked: 'Locking', Unlocked: 'Unlocking',
271
+ 'Created data element': 'Creating data element', 'Read BAdI': 'Reading BAdI', 'Read enhancement options': 'Reading enhancement options of', 'Created BAdI implementation': 'Creating BAdI implementation', Locked: 'Locking', Unlocked: 'Unlocking',
269
272
  Published: 'Publishing', Unpublished: 'Unpublishing', Messages: 'Maintaining messages in',
270
273
  'Number range': 'Maintaining number range', Question: 'Asking', 'Updated todos': 'Updating todos',
271
274
  'Ran skill': 'Running skill', 'Read document': 'Reading document', Fetched: 'Fetching',
@@ -26,6 +26,10 @@ export const FEATURES = Object.freeze({
26
26
  'datapreview.freestyle': { label: 'SQL data preview', collection: `${A}/datapreview/freestyle`, provenOnOurBox: true },
27
27
  'msag': { label: 'Message classes', collection: `${A}/messageclass`, provenOnOurBox: true },
28
28
  'nrob': { label: 'Number range objects', collection: `${A}/numberranges/objects`, provenOnOurBox: true },
29
+ // Part 2b: no `since`. The gap table's "enhancement editor 1809, BAdI editor
30
+ // 2020" has no cited source (Part 1 I1 rule): listed = yes, else unknown.
31
+ 'enh.badiImplementations': { label: 'BAdI implementations', collection: `${A}/enhancements/enhoxhb`, provenOnOurBox: true },
32
+ 'enh.badiSpots': { label: 'BAdI enhancement spots', collection: `${A}/enhancements/enhsxsb`, provenOnOurBox: true },
29
33
  });
30
34
  export const FEATURE_IDS = Object.freeze(Object.keys(FEATURES));
31
35
  export function isFeatureId(s) {
@@ -37,6 +41,7 @@ export const OBJECT_TYPE_FEATURE = Object.freeze({
37
41
  DDLS: 'cds.ddls', CDS: 'cds.ddls', DDLX: 'cds.ddlx', DCLS: 'cds.dcls',
38
42
  TABL: 'ddic.tables', STRU: 'ddic.structures', DOMA: 'ddic.domains', DTEL: 'ddic.dataelements',
39
43
  MSAG: 'msag', NROB: 'nrob',
44
+ ENHO: 'enh.badiImplementations', ENHS: 'enh.badiSpots',
40
45
  });
41
46
  /** The RAP/CDS subset: the only types a READ is ever refused for (R6). */
42
47
  export const READ_GATED_TYPES = new Set(['BDEF', 'SRVD', 'SRVB', 'DDLS', 'CDS', 'DDLX', 'DCLS']);
@@ -1,5 +1,6 @@
1
1
  import { FEATURES, OBJECT_TYPE_FEATURE, READ_GATED_TYPES } from './profile-features.js';
2
- import { supports, systemLabel } from './system-profile.js';
2
+ import { getSystemProfile, supports, systemLabel } from './system-profile.js';
3
+ import { getSapSystemInfo } from './system-info.js';
3
4
  export const NOT_ON_THIS_RELEASE = 'not_on_this_release';
4
5
  /** For tools whose `type` argument names an object type (url-mapper token). */
5
6
  export function byObjectType(args) {
@@ -65,3 +66,40 @@ export function pinHiddenTools(session, alias, tools, profile) {
65
66
  session.profile_hidden_tools = { alias, names };
66
67
  return names;
67
68
  }
69
+ // ── Part 2a — the three profile gates (owner decisions D11–D13) ────────────
70
+ const verifiedThisProcess = new Set(); // alias → a fresh rebuild returned a profile after a `no`
71
+ export function _resetVerifiedForTests() { verifiedThisProcess.clear(); }
72
+ /**
73
+ * D11 — a definite `no` is checked against a fresh profile once per alias per
74
+ * process before it is believed: the cache is up to 30 days old and the system
75
+ * may have been upgraded. The pinned tool list is not touched (R7).
76
+ */
77
+ export async function gateWithVerify(tool, args, ctx) {
78
+ const first = profileGate(tool, args, ctx.systemProfile ?? null);
79
+ if (!first || verifiedThisProcess.has(ctx.sapAlias))
80
+ return first;
81
+ const info = await getSapSystemInfo(ctx.adt, ctx.sapAlias, { fresh: true });
82
+ ctx.systemProfile = await getSystemProfile(ctx.adt, ctx.sapAlias, info, { fresh: true, signal: ctx.signal });
83
+ // Only a rebuild that returned a profile counts as verified. An aborted or
84
+ // failed one leaves the alias unmarked (this call runs as unknown), so the
85
+ // next refusal of a stale cached `no` re-checks: one attempt per refusal.
86
+ if (ctx.systemProfile)
87
+ verifiedThisProcess.add(ctx.sapAlias);
88
+ return profileGate(tool, args, ctx.systemProfile ?? null);
89
+ }
90
+ /** D12 — the changes of a request_approval call whose type's feature is a definite no (after verify). */
91
+ export async function approvalRefusedByProfile(changes, ctx) {
92
+ const out = [];
93
+ for (const c of changes) {
94
+ if (!['create', 'modify', 'delete'].includes(c.op))
95
+ continue;
96
+ const g = await gateWithVerify({ name: 'request_approval', requires: byObjectType }, { type: c.type }, ctx);
97
+ if (g)
98
+ out.push({ object: c.object, type: String(c.type ?? ''), message: g.message });
99
+ }
100
+ return out;
101
+ }
102
+ /** D13 — a resumed session pins its hidden tools again on its first turn. */
103
+ export function clearProfilePin(session) {
104
+ delete session.profile_hidden_tools;
105
+ }
@@ -30,16 +30,21 @@ const memCache = new Map();
30
30
  * Top-level entry point. Returns cached info when available, queries
31
31
  * CVERS once otherwise, persists the result, returns null on any error.
32
32
  */
33
- export async function getSapSystemInfo(adt, alias) {
34
- // Layer 1 — process memory
35
- const cached = memCache.get(alias);
36
- if (cached)
37
- return cached;
38
- // Layer 2 — disk
39
- const fromDisk = readDiskCache(alias);
40
- if (fromDisk) {
41
- memCache.set(alias, fromDisk);
42
- return fromDisk;
33
+ export async function getSapSystemInfo(adt, alias, opts = {}) {
34
+ // Part 2a D11 — `fresh` skips layers 1 and 2 (a definite "no" is checked
35
+ // against the live system before it is believed); the live path below is
36
+ // unchanged and refreshes both caches on success.
37
+ if (!opts.fresh) {
38
+ // Layer 1 — process memory
39
+ const cached = memCache.get(alias);
40
+ if (cached)
41
+ return cached;
42
+ // Layer 2 — disk
43
+ const fromDisk = readDiskCache(alias);
44
+ if (fromDisk) {
45
+ memCache.set(alias, fromDisk);
46
+ return fromDisk;
47
+ }
43
48
  }
44
49
  // Layer 3 — live query
45
50
  const startedAt = Date.now();
@@ -152,25 +152,46 @@ export async function getSystemProfile(adt, alias, info, opts = {}) {
152
152
  }
153
153
  }
154
154
  const timeoutMs = opts.readTimeoutMs ?? READ_TIMEOUT_MS;
155
+ // Part 2a D14 — an abort is not a result. Esc during the build returns null
156
+ // and touches NOTHING (no memory cache, no disk cache, no attempt marker,
157
+ // no audit row), so the next turn builds again. Any other failure keeps the
158
+ // path below exactly.
159
+ const signal = opts.signal;
160
+ if (signal?.aborted)
161
+ return null;
155
162
  const startedAt = Date.now();
156
163
  try {
157
164
  let discovery = null;
165
+ // The GET carries a socket timeout and the turn's signal, so Esc (or a
166
+ // dead socket) frees the connection's request slot; `bounded` stays as the
167
+ // outer wall-clock guard for an adt that does not honour either.
158
168
  try {
159
- discovery = await bounded(adt.discovery(), timeoutMs);
169
+ discovery = await bounded(adt.discovery({ timeoutMs, signal }), timeoutMs);
160
170
  }
161
- catch {
171
+ catch (err) {
172
+ if (isAbort(err, signal))
173
+ return null;
162
174
  discovery = null;
163
175
  }
176
+ if (signal?.aborted)
177
+ return null;
164
178
  let sp = null;
165
179
  if (info?.components?.['SAP_BASIS'] && conn?.post) {
166
180
  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);
181
+ // The signal rides post's 6th argument with no timeoutMs (the default
182
+ // socket timeout, as before). Without a signal the call is unchanged.
183
+ const spArgs = ['/sap/bc/adt/datapreview/freestyle?rowNumber=5', SP_SQL, 'text/plain', 'application/vnd.sap.adt.datapreview.table.v1+xml'];
184
+ const resp = await bounded(signal ? conn.post(...spArgs, undefined, signal) : conn.post(...spArgs), timeoutMs);
168
185
  if (resp && resp.status === 200)
169
186
  sp = parseExtRelease(resp.body);
170
187
  }
171
- catch {
188
+ catch (err) {
189
+ if (isAbort(err, signal))
190
+ return null;
172
191
  sp = null;
173
192
  }
193
+ if (signal?.aborted)
194
+ return null;
174
195
  }
175
196
  const profile = profileFrom({ alias, baseUrl, info, sp, discovery, now: now() });
176
197
  if (!profile) {
@@ -199,6 +220,10 @@ export async function getSystemProfile(adt, alias, info, opts = {}) {
199
220
  return null;
200
221
  }
201
222
  }
223
+ /** True for an `AbortError` from the request, or once the turn's signal has fired. */
224
+ function isAbort(err, signal) {
225
+ return signal?.aborted === true || err?.name === 'AbortError';
226
+ }
202
227
  function sameSystem(p, baseUrl, info) {
203
228
  if (baseUrl && p.baseUrl && p.baseUrl !== baseUrl)
204
229
  return false;
@@ -14,6 +14,7 @@ import { interruptedPickerLine } from './interrupt-reason.js';
14
14
  import stringWidth from 'string-width';
15
15
  import { STANDALONE_ALIAS } from '../tools/index.js';
16
16
  import { loadConfig, resolveStandalone } from '../config/loader.js';
17
+ import { clearProfilePin } from '../sap/profile-gate.js';
17
18
  /** inquirer's Ctrl+C / force-close rejection. */
18
19
  function isPromptCancel(err) {
19
20
  return err?.name === 'ExitPromptError';
@@ -99,6 +100,8 @@ export async function runResume(id, opts = {}) {
99
100
  let session;
100
101
  try {
101
102
  session = await loadSession(targetId);
103
+ // Part 2a D13 — re-pin the hidden tools on the first connected turn.
104
+ clearProfilePin(session);
102
105
  }
103
106
  catch (err) {
104
107
  console.error('\n' + formatFailure({ what: 'Could not load the session', detail: [err.message], next: 'cspeach --resume (pick from the list)' }));
@@ -1,7 +1,7 @@
1
1
  import chalk from 'chalk';
2
2
  import { registerTool } from './index.js';
3
3
  import { isHeadless, shouldUseInk } from '../renderer/tty.js';
4
- import { effectiveRisk, riskReasonText } from '../approvals/risk-floor.js';
4
+ import { effectiveRisk, riskReasonText, typeCode } from '../approvals/risk-floor.js';
5
5
  import { objectsWithSyntaxErrors } from './syntax-state.js';
6
6
  import { noteWriteTransport } from '../repl/current-transport.js';
7
7
  /**
@@ -27,6 +27,7 @@ import { markPlanGateApproved } from '../repl/rule8-detector.js';
27
27
  import { getEffectiveWriteMode } from '../repl/mode-cycle.js';
28
28
  import { getCurrentTransport } from '../repl/current-transport.js';
29
29
  import { displayTransport } from '../approvals/op-labels.js';
30
+ import { NOT_ON_THIS_RELEASE, approvalRefusedByProfile } from '../sap/profile-gate.js';
30
31
  import { isGuardedRunActive, getCurrentPhaseWrites, notePlanDeviation, PLAN_DEVIATION_DETAIL, } from '../commands/plan-gate.js';
31
32
  /**
32
33
  * Advisory-only replacement for the per-change approval gauntlet.
@@ -139,6 +140,7 @@ registerTool({
139
140
  type: { type: 'string' },
140
141
  diff: { type: 'string', description: 'For a CREATE op this MUST be the exact, complete source that will be written — verbatim, character-for-character, NOT a description, summary, or paraphrase. It is shown to the developer in the approval box as the exact thing they are approving, so a one-line summary here means they approve a write they cannot inspect. For a MODIFY op, the real unified diff of the change (again verbatim, not a description).' },
141
142
  package: { type: 'string', description: 'Target package (development class) for the object, if known — shown on the approval box' },
143
+ affects: { type: 'string', description: 'The standard SAP process this change runs inside, in plain words — REQUIRED for type ENHO (a BAdI implementation): name the BAdI, its spot and what calls it, e.g. "purchase order check before save (BAdI ME_PROCESS_PO_CUST, spot ES_MM_PO)". Shown on the approval card.' },
142
144
  },
143
145
  required: ['op', 'object', 'type'],
144
146
  },
@@ -184,6 +186,23 @@ registerTool({
184
186
  is_error: true,
185
187
  };
186
188
  }
189
+ // Part 2a D12 — never draw a card for a write this release cannot take.
190
+ if (ctx.systemProfile) {
191
+ const refused = await approvalRefusedByProfile(args.changes ?? [], ctx);
192
+ if (refused.length > 0) {
193
+ return {
194
+ content: JSON.stringify({ error: NOT_ON_THIS_RELEASE, approved: false, changes: refused,
195
+ message: `This system cannot take ${refused.length === 1 ? 'this change' : 'these changes'}: ${refused.map((r) => `${r.type} ${r.object} — ${r.message}`).join('; ')}` }),
196
+ is_error: true,
197
+ };
198
+ }
199
+ }
200
+ // Part 2b D7 — an enhancement change never reaches a card without naming the process it runs in.
201
+ const unnamed = (args.changes ?? []).filter((c) => typeCode(c.type) === 'ENHO' && !String(c.affects ?? '').trim());
202
+ if (unnamed.length > 0) {
203
+ return { content: JSON.stringify({ error: 'affects_required', approved: false, changes: unnamed.map((c) => c.object),
204
+ message: 'A BAdI implementation changes a standard SAP process. Say which one in changes[].affects (the BAdI, its spot and what calls it), then ask again.' }), is_error: true };
205
+ }
187
206
  let cfg = await loadConfig();
188
207
  // Task 2 (ux-wave1) — the approval flow reads the EFFECTIVE write mode:
189
208
  // config overlaid by the session override, clamped to the active alias's
@@ -20,12 +20,15 @@
20
20
  * runUnitTest(type, name)
21
21
  * odataTest(servicePath, entity?, method?)
22
22
  * badiList(query, enhancementSpot?, maxResults?)
23
+ * readBadiSpot(name) / readBadiImplementation(name) (Part 2b)
24
+ * enhancementOptions(type, name) (Part 2b)
23
25
  */
24
26
  import { registerTool } from './index.js';
25
27
  import { byObjectTypeForRead } from '../sap/profile-gate.js';
26
28
  import { normaliseSyntaxResult, checkSyntax } from './verify.js';
27
29
  import { recordSyntaxCheck } from './syntax-state.js';
28
30
  import { spillToolResult } from './result-spill.js';
31
+ import { SapError } from '@cspeach/sap-client';
29
32
  // -----------------------------------------------------------------------
30
33
  // Task 17 (spec D12, ruling F18) — model-facing formats.
31
34
  // -----------------------------------------------------------------------
@@ -746,7 +749,7 @@ registerTool({
746
749
  // -----------------------------------------------------------------------
747
750
  registerTool({
748
751
  name: 'sap_badi_list',
749
- description: 'Search for BAdI definitions and enhancement spots by name pattern (supports * wildcard), with optional enhancement-spot drill-down.',
752
+ description: 'Search for BAdI definitions and enhancement spots by name pattern (supports * wildcard), with optional enhancement-spot drill-down. Classic SE19 implementations are not available through ADT.',
750
753
  isMutating: false,
751
754
  requiresSap: true,
752
755
  input_schema: {
@@ -772,3 +775,78 @@ registerTool({
772
775
  return { content: JSON.stringify(result, null, 2) };
773
776
  },
774
777
  });
778
+ // -----------------------------------------------------------------------
779
+ // 19. sap_badi_read (Part 2b) — one new-style spot or implementation
780
+ // -----------------------------------------------------------------------
781
+ const isSapNotFound = (e) => e instanceof SapError && e.httpStatus === 404;
782
+ const notFound = (detail) => ({ content: JSON.stringify({ error: 'not_found', detail }), is_error: true });
783
+ registerTool({
784
+ name: 'sap_badi_read',
785
+ description: 'Read one new-style BAdI enhancement spot (its BAdI definitions and their interfaces) or one BAdI implementation '
786
+ + '(its spot, BAdI definition and implementing class). Classic SE18/SE19 objects are not available through ADT.',
787
+ isMutating: false,
788
+ requiresSap: true,
789
+ input_schema: {
790
+ type: 'object',
791
+ properties: {
792
+ kind: { type: 'string', enum: ['spot', 'implementation'], description: 'spot = an enhancement spot; implementation = a BAdI implementation' },
793
+ name: { type: 'string', description: 'Name of the spot or implementation' },
794
+ },
795
+ required: ['kind', 'name'],
796
+ },
797
+ handler: async (args, ctx) => {
798
+ const name = String(args.name ?? '').trim().toUpperCase();
799
+ const spot = args.kind === 'spot';
800
+ try {
801
+ const info = spot ? await ctx.adt.readBadiSpot(args.name) : await ctx.adt.readBadiImplementation(args.name);
802
+ return { content: JSON.stringify(info, null, 2) };
803
+ }
804
+ catch (e) {
805
+ if (!isSapNotFound(e))
806
+ throw e;
807
+ return notFound(spot
808
+ ? `${name} is not a new-style BAdI enhancement spot. Classic BAdI definitions (SE18 without an enhancement spot) are not available through ADT.`
809
+ : `${name} is not a new-style BAdI implementation. A classic SE19 implementation is not available through ADT; CSPeach does not create or change those.`);
810
+ }
811
+ },
812
+ });
813
+ // -----------------------------------------------------------------------
814
+ // 20. sap_enhancement_options (Part 2b) — implicit enhancement options, read only
815
+ // -----------------------------------------------------------------------
816
+ registerTool({
817
+ name: 'sap_enhancement_options',
818
+ description: 'List the implicit enhancement options of a program, class or function group (full name, description, mode). '
819
+ + 'Read only: CSPeach does not write implicit enhancements.',
820
+ isMutating: false,
821
+ requiresSap: true,
822
+ input_schema: {
823
+ type: 'object',
824
+ properties: {
825
+ type: { type: 'string', enum: ['PROG', 'CLAS', 'FUGR'], description: 'Object type: PROG, CLAS or FUGR' },
826
+ name: { type: 'string', description: 'Object name' },
827
+ max: { type: 'number', description: 'Most options to return (default 200)' },
828
+ },
829
+ required: ['type', 'name'],
830
+ },
831
+ handler: async (args, ctx) => {
832
+ const type = String(args.type ?? '').trim().toUpperCase();
833
+ const name = String(args.name ?? '').trim().toUpperCase();
834
+ const max = Number.isFinite(args.max) && args.max >= 1 ? Math.floor(args.max) : 200;
835
+ let all;
836
+ try {
837
+ all = await ctx.adt.enhancementOptions(type, args.name);
838
+ }
839
+ catch (e) {
840
+ if (!isSapNotFound(e))
841
+ throw e;
842
+ return notFound(`${type} ${name} does not exist.`);
843
+ }
844
+ return {
845
+ content: JSON.stringify({
846
+ object: name, type, count: all.length,
847
+ options: all.slice(0, max).map((o) => ({ full_name: o.fullName, description: o.description, mode: o.mode })),
848
+ ...(all.length > max ? { truncated: true } : {}),
849
+ }),
850
+ };
851
+ },
852
+ });