@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.
- package/dist/agent/loop.js +3 -1
- package/dist/agent/tool-dispatch.js +9 -3
- package/dist/sap/profile-gate.js +39 -1
- package/dist/sap/system-info.js +15 -10
- package/dist/sap/system-profile.js +29 -4
- package/dist/session/resume.js +3 -0
- package/dist/tools/approval.js +12 -0
- package/dist/tools/sap-write.js +517 -1217
- package/dist/tools/verify.js +63 -14
- package/dist/tools/write-pipeline.js +437 -0
- package/package.json +2 -2
package/dist/agent/loop.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
package/dist/sap/profile-gate.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/sap/system-info.js
CHANGED
|
@@ -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
|
-
//
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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;
|
package/dist/session/resume.js
CHANGED
|
@@ -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)' }));
|
package/dist/tools/approval.js
CHANGED
|
@@ -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
|