@cspeach/cli 1.1.40 → 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.
@@ -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,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)' }));
@@ -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