@cspeach/cli 1.1.39 → 1.1.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,17 @@ ${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
+ // 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 });
834
843
  sapSystemBlock = renderSapSystemBlock(sapSystemInfo);
844
+ sapProfileBlock = renderSapProfileBlock(params.ctx.systemProfile);
835
845
  }
836
846
  // Customer development standards — opt-in, both modes. Renders '' when the
837
847
  // user has no standards file, so the composed block (and therefore the whole
@@ -842,7 +852,7 @@ ${imageExpansion.error}`));
842
852
  const switchNote = session.systemSwitchNote
843
853
  ? `<system_switch>${session.systemSwitchNote}</system_switch>`
844
854
  : '';
845
- const sessionContextBlock = composeSessionContextBlock([sapSystemBlock, standardsBlock, switchNote]);
855
+ const sessionContextBlock = composeSessionContextBlock([sapSystemBlock, sapProfileBlock, standardsBlock, switchNote]);
846
856
  // Deterministically extract structured facts from the user's prompt
847
857
  // (package, transport, object names, environment hint, create intent)
848
858
  // and prepend them as an XML `<session_context>` block. The model reads
@@ -926,7 +936,14 @@ ${imageExpansion.error}`));
926
936
  env: process.env,
927
937
  served: servedToolLoading(),
928
938
  });
929
- const contextTools = toolsForContext(listTools(), params.ctx);
939
+ // Part 1 (D4, R7) — the hidden set is pinned per alias on the first
940
+ // connected turn and reused, so tools[] stays byte-stable; [] pinned = same
941
+ // array as today. A standalone or unconnected turn pins nothing.
942
+ const connectedTurn = !standaloneTurn && !!params.ctx.adt && !!params.ctx.sapAlias;
943
+ const hiddenSapTools = connectedTurn
944
+ ? pinHiddenTools(session, params.ctx.sapAlias, listTools(), params.ctx.systemProfile ?? null)
945
+ : [];
946
+ const contextTools = withoutTools(toolsForContext(listTools(), params.ctx), hiddenSapTools);
930
947
  // Task 18 fixes M4 / M5 — no read_agent for an agent_run child, and none
931
948
  // when a reader would have no real read tool (standalone, file tools off).
932
949
  const visibleTools = params.ctx.subagent || !hasReaderReadTools(new Set(contextTools.map((t) => t.name)))
@@ -9,6 +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, gateWithVerify } from '../sap/profile-gate.js';
13
+ import { INTERRUPTED_TOOL_RESULT } from './repair-partial.js';
12
14
  const READER_TOOL_SET = new Set(READER_TOOLS);
13
15
  export async function dispatchTool(name, args, ctx) {
14
16
  const tool = getTool(name);
@@ -36,6 +38,21 @@ export async function dispatchTool(name, args, ctx) {
36
38
  is_error: true,
37
39
  };
38
40
  }
41
+ // ADT gap program, Part 1 (D4) — a definite "not on this release" is
42
+ // answered here, before any SAP call, as a normal tool error. Unknown or
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);
46
+ if (gate) {
47
+ return {
48
+ content: JSON.stringify({ error: NOT_ON_THIS_RELEASE, feature: gate.feature, message: gate.message }),
49
+ is_error: true,
50
+ };
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 };
39
56
  // L1-6 R1-6 (owner decision 2026-09-21) — a call that hands the stored SAP
40
57
  // login to a local child process ALWAYS asks a human, once per session per
41
58
  // 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,105 @@
1
+ import { FEATURES, OBJECT_TYPE_FEATURE, READ_GATED_TYPES } from './profile-features.js';
2
+ import { getSystemProfile, supports, systemLabel } from './system-profile.js';
3
+ import { getSapSystemInfo } from './system-info.js';
4
+ export const NOT_ON_THIS_RELEASE = 'not_on_this_release';
5
+ /** For tools whose `type` argument names an object type (url-mapper token). */
6
+ export function byObjectType(args) {
7
+ const t = typeof args?.type === 'string' ? args.type.trim().toUpperCase() : '';
8
+ return t && Object.prototype.hasOwnProperty.call(OBJECT_TYPE_FEATURE, t) ? OBJECT_TYPE_FEATURE[t] : null;
9
+ }
10
+ /**
11
+ * R6 — reads refuse only RAP/CDS types (they cannot exist below their release).
12
+ * DDIC types carry no release floor at all (final review I1); a DDIC read was never proven to fail, and a
13
+ * wrong read costs one 404 while a wrong `no` costs the user's work.
14
+ */
15
+ export function byObjectTypeForRead(args) {
16
+ const t = typeof args?.type === 'string' ? args.type.trim().toUpperCase() : '';
17
+ return READ_GATED_TYPES.has(t) ? byObjectType(args) : null;
18
+ }
19
+ export function requiredFeature(tool, args) {
20
+ const r = tool.requires;
21
+ if (!r)
22
+ return null;
23
+ return typeof r === 'function' ? r(args) : r;
24
+ }
25
+ export function profileGate(tool, args, profile) {
26
+ if (!profile)
27
+ return null;
28
+ const feature = requiredFeature(tool, args);
29
+ if (!feature)
30
+ return null;
31
+ const v = supports(profile, feature);
32
+ if (v.answer !== 'no')
33
+ return null;
34
+ // I2 — name when the facts were read: the profile is kept up to 30 days.
35
+ return { feature, message: `${FEATURES[feature].label}: ${v.reason}. This system: ${systemLabel(profile)}, read ${profile.builtAt.slice(0, 10)}.` };
36
+ }
37
+ /** Names of the tools whose FIXED feature is a definite no. [] for no profile. */
38
+ export function hiddenByProfile(tools, profile) {
39
+ if (!profile)
40
+ return [];
41
+ return tools.filter((t) => typeof t.requires === 'string' && supports(profile, t.requires).answer === 'no').map((t) => t.name);
42
+ }
43
+ /** Drop the named tools. Same array back when nothing changes (byte-identical path). */
44
+ export function withoutTools(tools, hidden) {
45
+ if (hidden.length === 0)
46
+ return tools;
47
+ const set = new Set(hidden);
48
+ const kept = tools.filter((t) => !set.has(t.name));
49
+ return kept.length === tools.length ? tools : kept;
50
+ }
51
+ export function toolsForProfile(tools, profile) {
52
+ return withoutTools(tools, hiddenByProfile(tools, profile));
53
+ }
54
+ /**
55
+ * R7 / D4 — tools[] must be byte-stable for the session (prompt cache,
56
+ * preserved thinking), but it is rebuilt every turn. The hidden set is pinned
57
+ * on the session the first time an alias is seen — whatever the profile was
58
+ * on that turn (null pins []) — and reused after that. Another alias re-pins.
59
+ * A profile that arrives later refuses per call at dispatch instead.
60
+ */
61
+ export function pinHiddenTools(session, alias, tools, profile) {
62
+ const pinned = session.profile_hidden_tools;
63
+ if (pinned && pinned.alias === alias)
64
+ return pinned.names;
65
+ const names = hiddenByProfile(tools, profile);
66
+ session.profile_hidden_tools = { alias, names };
67
+ return names;
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();
@@ -535,6 +540,6 @@ export function renderSapSystemBlock(info) {
535
540
  ].join(' ');
536
541
  return `<sap_system ${attrs} />`;
537
542
  }
538
- function escapeAttr(s) {
543
+ export function escapeAttr(s) {
539
544
  return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
540
545
  }
@@ -0,0 +1,337 @@
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
+ // 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;
162
+ const startedAt = Date.now();
163
+ try {
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.
168
+ try {
169
+ discovery = await bounded(adt.discovery({ timeoutMs, signal }), timeoutMs);
170
+ }
171
+ catch (err) {
172
+ if (isAbort(err, signal))
173
+ return null;
174
+ discovery = null;
175
+ }
176
+ if (signal?.aborted)
177
+ return null;
178
+ let sp = null;
179
+ if (info?.components?.['SAP_BASIS'] && conn?.post) {
180
+ try {
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);
185
+ if (resp && resp.status === 200)
186
+ sp = parseExtRelease(resp.body);
187
+ }
188
+ catch (err) {
189
+ if (isAbort(err, signal))
190
+ return null;
191
+ sp = null;
192
+ }
193
+ if (signal?.aborted)
194
+ return null;
195
+ }
196
+ const profile = profileFrom({ alias, baseUrl, info, sp, discovery, now: now() });
197
+ if (!profile) {
198
+ memCache.set(alias, null);
199
+ writeAttempt(alias, baseUrl, info, now());
200
+ await auditLog('sapProfile', 'ALIAS', alias, 'error', Date.now() - startedAt, 'no discovery and no system info');
201
+ return null;
202
+ }
203
+ memCache.set(alias, profile);
204
+ // R2 — same rule as system-info's failed probe: never persist what was not
205
+ // read. Complete = list read, and the SP read (or no SAP_BASIS to read it for).
206
+ const complete = profile.discovery.read && (profile.sp !== null || profile.basis === null);
207
+ if (complete) {
208
+ writeDiskCache(alias, profile);
209
+ clearAttempt(alias);
210
+ }
211
+ else
212
+ writeAttempt(alias, baseUrl, info, now());
213
+ 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'}`);
214
+ return profile;
215
+ }
216
+ catch (err) {
217
+ memCache.set(alias, null);
218
+ writeAttempt(alias, baseUrl, info, now());
219
+ await auditLog('sapProfile', 'ALIAS', alias, 'error', Date.now() - startedAt, `threw: ${err.message}`);
220
+ return null;
221
+ }
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
+ }
227
+ function sameSystem(p, baseUrl, info) {
228
+ if (baseUrl && p.baseUrl && p.baseUrl !== baseUrl)
229
+ return false;
230
+ if (!info)
231
+ return true;
232
+ return p.release === info.release && p.abapVersion === info.abapVersion;
233
+ }
234
+ function cacheFile(alias, kind = 'profile') {
235
+ const safe = alias.replace(/[^A-Za-z0-9._-]/g, '_');
236
+ return join(cspeachRoot(), 'sap-cache', `${safe}.${kind}.json`);
237
+ }
238
+ /** Resolves to null when the read takes longer than `ms` (the request itself is left to finish). */
239
+ function bounded(p, ms) {
240
+ let timer;
241
+ const timeout = new Promise((resolve) => {
242
+ timer = setTimeout(() => resolve(null), ms);
243
+ timer.unref?.();
244
+ });
245
+ return Promise.race([p, timeout]).finally(() => { if (timer)
246
+ clearTimeout(timer); });
247
+ }
248
+ function recentAttempt(alias, baseUrl, info, now) {
249
+ try {
250
+ const path = cacheFile(alias, 'profile-attempt');
251
+ if (!existsSync(path))
252
+ return false;
253
+ const a = JSON.parse(readFileSync(path, 'utf8'));
254
+ const at = new Date(a.attemptedAt).getTime();
255
+ if (!Number.isFinite(at) || now.getTime() - at > ATTEMPT_TTL_MS || now.getTime() < at)
256
+ return false;
257
+ if (a.baseUrl !== baseUrl)
258
+ return false;
259
+ return a.release === (info?.release ?? 'unknown') && a.abapVersion === (info?.abapVersion ?? 'unknown');
260
+ }
261
+ catch {
262
+ return false;
263
+ }
264
+ }
265
+ function writeAttempt(alias, baseUrl, info, now) {
266
+ try {
267
+ const dir = join(cspeachRoot(), 'sap-cache');
268
+ if (!existsSync(dir))
269
+ mkdirSync(dir, { recursive: true });
270
+ const a = { attemptedAt: now.toISOString(), baseUrl, release: info?.release ?? 'unknown', abapVersion: info?.abapVersion ?? 'unknown' };
271
+ writeFileSync(cacheFile(alias, 'profile-attempt'), JSON.stringify(a, null, 2), 'utf8');
272
+ }
273
+ catch {
274
+ // best effort — at worst the next start tries the build again
275
+ }
276
+ }
277
+ function clearAttempt(alias) {
278
+ try {
279
+ rmSync(cacheFile(alias, 'profile-attempt'), { force: true });
280
+ }
281
+ catch { /* best effort */ }
282
+ }
283
+ function readDiskCache(alias, now) {
284
+ try {
285
+ const path = cacheFile(alias);
286
+ if (!existsSync(path))
287
+ return null;
288
+ const raw = JSON.parse(readFileSync(path, 'utf8'));
289
+ if (raw.cacheVersion !== CACHE_VERSION || !raw.builtAt)
290
+ return null;
291
+ if (now.getTime() - new Date(raw.builtAt).getTime() > CACHE_TTL_MS)
292
+ return null;
293
+ const { cacheVersion: _v, ...profile } = raw;
294
+ return profile;
295
+ }
296
+ catch {
297
+ return null;
298
+ }
299
+ }
300
+ function writeDiskCache(alias, profile) {
301
+ try {
302
+ const dir = join(cspeachRoot(), 'sap-cache');
303
+ if (!existsSync(dir))
304
+ mkdirSync(dir, { recursive: true });
305
+ writeFileSync(cacheFile(alias), JSON.stringify({ ...profile, cacheVersion: CACHE_VERSION }, null, 2), 'utf8');
306
+ }
307
+ catch {
308
+ // best effort — the memory result is still returned
309
+ }
310
+ }
311
+ /** The one-line summary for <session_context>; '' when there is no profile. */
312
+ export function renderSapProfileBlock(p) {
313
+ if (!p)
314
+ return '';
315
+ const answer = (id) => supports(p, id).answer;
316
+ const viewEntity = answer('cds.viewEntity');
317
+ const cdsSyntax = viewEntity === 'yes' ? 'define view entity' : viewEntity === 'no' ? 'define view' : 'unknown';
318
+ const notAvailable = FEATURE_IDS
319
+ .map((id) => ({ id, v: supports(p, id) }))
320
+ .filter((x) => x.v.answer === 'no')
321
+ .map((x) => `${FEATURES[x.id].label}: ${x.v.reason}`)
322
+ .join('; ') || 'none known';
323
+ const attrs = [
324
+ `release="${escapeAttr(p.release)}"`,
325
+ `sp="${p.sp === null ? 'unknown' : `SP${String(p.sp).padStart(2, '0')}`}"`,
326
+ `abap="${escapeAttr(p.abapVersion)}"`,
327
+ `cloud="${p.cloud}"`,
328
+ `cds_view_syntax="${cdsSyntax}"`,
329
+ `rap="${answer('rap.bdef')}"`,
330
+ `rap_generator="${answer('generator.repository')}"`,
331
+ `table_entity="${answer('cds.tableEntity')}"`,
332
+ `discovery="${p.discovery.read ? 'read' : 'not read'}"`,
333
+ `built="${p.builtAt.slice(0, 10)}"`,
334
+ `not_available="${escapeAttr(notAvailable)}"`,
335
+ ];
336
+ return `<sap_profile ${attrs.join(' ')} />`;
337
+ }
@@ -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)' }));
@@ -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.
@@ -184,6 +185,17 @@ registerTool({
184
185
  is_error: true,
185
186
  };
186
187
  }
188
+ // Part 2a D12 — never draw a card for a write this release cannot take.
189
+ if (ctx.systemProfile) {
190
+ const refused = await approvalRefusedByProfile(args.changes ?? [], ctx);
191
+ if (refused.length > 0) {
192
+ return {
193
+ content: JSON.stringify({ error: NOT_ON_THIS_RELEASE, approved: false, changes: refused,
194
+ message: `This system cannot take ${refused.length === 1 ? 'this change' : 'these changes'}: ${refused.map((r) => `${r.type} ${r.object} — ${r.message}`).join('; ')}` }),
195
+ is_error: true,
196
+ };
197
+ }
198
+ }
187
199
  let cfg = await loadConfig();
188
200
  // Task 2 (ux-wave1) — the approval flow reads the EFFECTIVE write mode:
189
201
  // config overlaid by the session override, clamped to the active alias's