@cspeach/cli 1.1.44 → 1.1.46
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/chat-prompt.js +47 -0
- package/dist/agent/chat-unavailable.js +9 -0
- package/dist/agent/level-state.js +11 -0
- package/dist/agent/level.js +60 -0
- package/dist/agent/loop.js +110 -2
- package/dist/agent/parallel-write-guard.js +2 -0
- package/dist/agent/provider-shape.js +6 -2
- package/dist/agent/providers/ai-hub-provider.js +3 -1
- package/dist/agent/providers/azure-provider.js +3 -1
- package/dist/agent/providers/byok-provider.js +3 -1
- package/dist/agent/providers/local-provider.js +3 -1
- package/dist/agent/style-state.js +11 -0
- package/dist/agent/style.js +59 -0
- package/dist/agent/system-prompt.js +24 -3
- package/dist/agent/tool-dispatch.js +55 -0
- package/dist/approvals/jwt.js +5 -2
- package/dist/approvals/op-labels.js +39 -4
- package/dist/approvals/render.js +36 -9
- package/dist/approvals/risk-floor.js +14 -3
- package/dist/cli.js +8 -0
- package/dist/commands/config-set.js +59 -1
- package/dist/commands/config-show.js +1 -0
- package/dist/commands/export-docx.js +66 -0
- package/dist/commands/help.js +28 -2
- package/dist/config/loader.js +51 -1
- package/dist/docx/doc-meta.js +47 -0
- package/dist/docx/markdown-to-docx.js +250 -0
- package/dist/docx/write-docx.js +54 -0
- package/dist/one-shot.js +11 -0
- package/dist/projects/document-save.js +62 -0
- package/dist/projects/document-skills.js +9 -0
- package/dist/renderer/footer-line.js +6 -1
- package/dist/renderer/progress-chatter.js +9 -2
- package/dist/renderer/startup-lines.js +6 -2
- package/dist/renderer/tool-labels.js +5 -2
- package/dist/renderer/tool-widget.js +5 -1
- package/dist/repl/builtin-commands.js +4 -1
- package/dist/repl/chat-command.js +64 -0
- package/dist/repl/chat-lane.js +135 -0
- package/dist/repl/level-command.js +79 -0
- package/dist/repl/style-command.js +67 -0
- package/dist/repl.js +187 -105
- package/dist/sap/profile-features.js +5 -0
- package/dist/session/audit-export.js +50 -2
- package/dist/session/queued-step.js +5 -1
- package/dist/session/schema.js +1 -0
- package/dist/session/store.js +4 -0
- package/dist/tools/approval.js +59 -1
- package/dist/tools/hand-off.js +86 -0
- package/dist/tools/release-texts.js +9 -0
- package/dist/tools/sap-read.js +6 -2
- package/dist/tools/sap-run.js +163 -0
- package/dist/tools/tool-loading.js +11 -7
- package/dist/tools/transport.js +248 -22
- package/dist/tools/write-mode.js +2 -0
- package/dist/ui/approval-modal.js +11 -3
- package/dist/ui/sap-state-store.js +1 -0
- package/dist/ui/session-timeline.js +1 -1
- package/dist/ui/widgets/ask-card.js +2 -1
- package/package.json +3 -3
package/dist/tools/approval.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
+
import { notModifiableText } from './release-texts.js';
|
|
3
|
+
import { isModifiable, knownObjectCount } from '@cspeach/sap-client';
|
|
2
4
|
import { registerTool } from './index.js';
|
|
3
5
|
import { isHeadless, shouldUseInk } from '../renderer/tty.js';
|
|
4
6
|
import { effectiveRisk, riskReasonText, typeCode } from '../approvals/risk-floor.js';
|
|
@@ -120,6 +122,55 @@ function isPureInfraEscalation(changes) {
|
|
|
120
122
|
changes.every((c) => c.op === 'create' &&
|
|
121
123
|
INFRA_BASE_OBJECT_TYPES.has((c.type ?? '').trim().toUpperCase())));
|
|
122
124
|
}
|
|
125
|
+
/**
|
|
126
|
+
* Part 3 D12 — for every release change: drop any model-supplied facts, read
|
|
127
|
+
* the request from SAP and attach SAP's facts, or return the refusal. There is
|
|
128
|
+
* no separate "no SAP" branch: standalone's `ctx.adt` is a Proxy whose every
|
|
129
|
+
* method throws the standalone text, so the throw path IS the standalone path.
|
|
130
|
+
*/
|
|
131
|
+
async function attachReleaseFacts(changes, ctx) {
|
|
132
|
+
for (const c of changes) {
|
|
133
|
+
if (!c)
|
|
134
|
+
continue;
|
|
135
|
+
// Never the model's facts — on any change.
|
|
136
|
+
delete c.releaseFacts;
|
|
137
|
+
if (c.op !== 'release')
|
|
138
|
+
continue;
|
|
139
|
+
if (typeCode(c.type) !== 'CTS') {
|
|
140
|
+
return { error: 'release_type', message: 'A release change has type CTS and names the request number.' };
|
|
141
|
+
}
|
|
142
|
+
const asked = String(c.object ?? '').trim();
|
|
143
|
+
let doc;
|
|
144
|
+
try {
|
|
145
|
+
doc = await ctx.adt.transportRead(asked);
|
|
146
|
+
}
|
|
147
|
+
catch (e) {
|
|
148
|
+
const why = e instanceof Error ? e.message : String(e);
|
|
149
|
+
return {
|
|
150
|
+
error: 'release_facts_unavailable',
|
|
151
|
+
message: `CSPeach could not read ${asked}, so it cannot show what a release would send. (${why})`,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
if (doc.kind === 'task') {
|
|
155
|
+
return {
|
|
156
|
+
error: 'task_number',
|
|
157
|
+
message: `${asked} is a task of ${doc.number}. Give the request number; CSPeach releases the tasks first, then the request.`,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
if (!isModifiable(doc.status)) {
|
|
161
|
+
return { error: 'not_modifiable', message: notModifiableText(doc) };
|
|
162
|
+
}
|
|
163
|
+
c.releaseFacts = {
|
|
164
|
+
request: doc.number, description: doc.description, owner: doc.owner, target: doc.target,
|
|
165
|
+
status: doc.status, statusText: doc.statusText,
|
|
166
|
+
tasks: doc.tasks.map((t) => ({ number: t.number, owner: t.owner })),
|
|
167
|
+
// The largest count the document supports; null = could not count
|
|
168
|
+
// (the card says so, never "0 objects").
|
|
169
|
+
objects: knownObjectCount(doc),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
123
174
|
registerTool({
|
|
124
175
|
name: 'request_approval',
|
|
125
176
|
description: 'Request user approval for one or more SAP mutations. Returns approval_ids (one per change) to be passed as approval_id on matching mutating tool calls.',
|
|
@@ -135,7 +186,7 @@ registerTool({
|
|
|
135
186
|
items: {
|
|
136
187
|
type: 'object',
|
|
137
188
|
properties: {
|
|
138
|
-
op: { type: 'string', enum: ['create', 'modify', 'delete', 'activate', 'release'] },
|
|
189
|
+
op: { type: 'string', enum: ['create', 'modify', 'delete', 'activate', 'release', 'run'] },
|
|
139
190
|
object: { type: 'string' },
|
|
140
191
|
type: { type: 'string' },
|
|
141
192
|
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).' },
|
|
@@ -216,6 +267,13 @@ registerTool({
|
|
|
216
267
|
if (getEffectiveWriteMode(cfg.write_mode, role) === 'advisory-only') {
|
|
217
268
|
return handleAdvisoryApproval(args, { sapAlias: ctx.sapAlias });
|
|
218
269
|
}
|
|
270
|
+
// Part 3 D12 — a release card carries SAP's own facts about the request,
|
|
271
|
+
// read here before any card or nag; never the model's. After the advisory
|
|
272
|
+
// short-circuit (F-10: advisory mints nothing, so no read is spent on a
|
|
273
|
+
// card that is never drawn). Every refusal mints nothing.
|
|
274
|
+
const releaseRefusal = await attachReleaseFacts(args.changes ?? [], ctx);
|
|
275
|
+
if (releaseRefusal)
|
|
276
|
+
return { content: JSON.stringify(releaseRefusal), is_error: true };
|
|
219
277
|
let sapCfg = cfg.sap[ctx.sapAlias];
|
|
220
278
|
const riskCtx = {
|
|
221
279
|
sapAlias: ctx.sapAlias,
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hand_off (chat lane, spec 2026-10-17 D5) — the chat's one way into a guided
|
|
3
|
+
* workflow. Queues `/<skill> <request>` through the existing pendingDispatch
|
|
4
|
+
* slot (shown as one line "→ /abap-x", never as the person's own line); the
|
|
5
|
+
* chat turn then ends and the workflow runs with its own plan + approvals.
|
|
6
|
+
* No extra consent card (D5): the workflow asks before any write.
|
|
7
|
+
* Refused outside the chat lane; dispatch_skill stays the skills' own switch.
|
|
8
|
+
*
|
|
9
|
+
* Flag-gated: only a chat-lane process enables it (Task 11), so a skills-lane
|
|
10
|
+
* session never lists it and its tools array stays byte-identical to today.
|
|
11
|
+
*/
|
|
12
|
+
import { registerTool } from './index.js';
|
|
13
|
+
import { isPlanResumeCommand } from '../commands/plan-resume.js';
|
|
14
|
+
import { SKILL_CATALOG, resolveSkillAlias } from '../skill-catalog.js';
|
|
15
|
+
/** Longest request hand_off queues (after whitespace is collapsed). */
|
|
16
|
+
export const HAND_OFF_REQUEST_MAX = 500;
|
|
17
|
+
export const HAND_OFF_LINE = (skill) => `→ /${skill}`;
|
|
18
|
+
export const buildHandOffCommand = (skill, request) => `/${skill} ${request}`;
|
|
19
|
+
const CATALOG_NAMES = new Set(SKILL_CATALOG.map((s) => s.name));
|
|
20
|
+
/**
|
|
21
|
+
* A workflow name as the model wrote it → its catalog name, or null.
|
|
22
|
+
* Strips one leading `/`, lowercases, resolves a retired alias, and accepts a
|
|
23
|
+
* bare name ("refactor" → "abap-refactor"). Never `_chat` (not in the catalog).
|
|
24
|
+
*/
|
|
25
|
+
export function normaliseSkill(raw) {
|
|
26
|
+
if (typeof raw !== 'string')
|
|
27
|
+
return null;
|
|
28
|
+
const bare = raw.trim().replace(/^\//, '').toLowerCase();
|
|
29
|
+
if (!bare)
|
|
30
|
+
return null;
|
|
31
|
+
for (const candidate of [bare, `abap-${bare}`]) {
|
|
32
|
+
const resolved = resolveSkillAlias(candidate);
|
|
33
|
+
if (CATALOG_NAMES.has(resolved))
|
|
34
|
+
return resolved;
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
registerTool({
|
|
39
|
+
name: 'hand_off',
|
|
40
|
+
description: 'Hand this conversation to a CSPeach guided workflow when the developer wants a change made in SAP or a complete deliverable (a full review, a handover document, an upgrade scan). The workflow shows its plan and asks before it writes, so do not ask "shall I hand off?" first — say in one line what you would change, then call this. skill: the workflow name (e.g. "abap-refactor", "abap-generate", "abap-transport", "abap-review"). request: what to do, one clear sentence with object names. After this returns, end the turn.',
|
|
41
|
+
isMutating: false,
|
|
42
|
+
flagGated: true,
|
|
43
|
+
category: 'session',
|
|
44
|
+
input_schema: {
|
|
45
|
+
type: 'object',
|
|
46
|
+
properties: {
|
|
47
|
+
skill: { type: 'string', description: 'Workflow name without the slash, e.g. "abap-refactor".' },
|
|
48
|
+
request: { type: 'string', description: 'What the workflow should do, with object names. One sentence, at most 500 characters, no flags.' },
|
|
49
|
+
},
|
|
50
|
+
required: ['skill', 'request'],
|
|
51
|
+
},
|
|
52
|
+
handler: async (args, ctx) => {
|
|
53
|
+
const bad = (error, reason) => ({ is_error: true, content: JSON.stringify({ error, reason }) });
|
|
54
|
+
if (ctx.session?.lane !== 'chat') {
|
|
55
|
+
return bad('HAND_OFF_OUTSIDE_CHAT', 'hand_off is only for the chat; in a workflow, continue with the workflow (dispatch_skill switches workflows after the person agreed).');
|
|
56
|
+
}
|
|
57
|
+
const skill = normaliseSkill(args.skill);
|
|
58
|
+
if (!skill) {
|
|
59
|
+
return bad('HAND_OFF_UNKNOWN_SKILL', `unknown workflow "${String(args.skill ?? '')}". Use one of: ${SKILL_CATALOG.map((s) => s.name).join(', ')}`);
|
|
60
|
+
}
|
|
61
|
+
const request = typeof args.request === 'string' ? args.request.trim().replace(/\s+/g, ' ') : '';
|
|
62
|
+
if (!request)
|
|
63
|
+
return bad('HAND_OFF_INVALID', 'request must say what the workflow should do');
|
|
64
|
+
if (request.length > HAND_OFF_REQUEST_MAX) {
|
|
65
|
+
return bad('HAND_OFF_INVALID', `request is too long (${request.length} chars; at most ${HAND_OFF_REQUEST_MAX}). Say it in one short sentence with the object names.`);
|
|
66
|
+
}
|
|
67
|
+
const command = buildHandOffCommand(skill, request);
|
|
68
|
+
if (isPlanResumeCommand(command)) {
|
|
69
|
+
return bad('PLAN_RESUME_HARNESS_OWNED', 'Plan continuation is owned by CSPeach; never hand off "/abap-plan --resume".');
|
|
70
|
+
}
|
|
71
|
+
if (request.startsWith('--')) {
|
|
72
|
+
return bad('HAND_OFF_INVALID', 'request must start with what to do, not with flags (--…). Say it in one plain sentence with the object names.');
|
|
73
|
+
}
|
|
74
|
+
if (!ctx.pendingDispatch) {
|
|
75
|
+
return bad('DISPATCH_UNSUPPORTED', 'no dispatch slot in this context (one-shot or test); tell the developer which /abap-… command to run instead');
|
|
76
|
+
}
|
|
77
|
+
ctx.pendingDispatch.set(command, { by: 'hand_off', line: HAND_OFF_LINE(skill) });
|
|
78
|
+
return {
|
|
79
|
+
content: JSON.stringify({
|
|
80
|
+
ok: true,
|
|
81
|
+
queued: command,
|
|
82
|
+
note: 'The workflow runs next and asks before it writes. End the turn now with at most one short line; do not print the command.',
|
|
83
|
+
}),
|
|
84
|
+
};
|
|
85
|
+
},
|
|
86
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Part 3 — release refusal texts shared by the approval card (approval.ts) and
|
|
3
|
+
* sap_transport_release (transport.ts). Same words as sap-client's own refusal
|
|
4
|
+
* in transportRelease; an empty status never prints as an empty string.
|
|
5
|
+
*/
|
|
6
|
+
export function notModifiableText(doc) {
|
|
7
|
+
return `${doc.number} cannot be released: its status is "${doc.statusText || `status ${doc.status || 'unknown'}`}". `
|
|
8
|
+
+ 'Only a modifiable request can be released. Nothing was sent.';
|
|
9
|
+
}
|
package/dist/tools/sap-read.js
CHANGED
|
@@ -29,6 +29,7 @@ import { byObjectTypeForRead } from '../sap/profile-gate.js';
|
|
|
29
29
|
import { normaliseSyntaxResult, checkSyntax } from './verify.js';
|
|
30
30
|
import { recordSyntaxCheck } from './syntax-state.js';
|
|
31
31
|
import { spillToolResult } from './result-spill.js';
|
|
32
|
+
import { hasManualLocks } from '../sap/session-end.js';
|
|
32
33
|
import { SapError, parseFunctionModuleDocument, parseContainerDocument } from '@cspeach/sap-client';
|
|
33
34
|
// -----------------------------------------------------------------------
|
|
34
35
|
// Task 17 (spec D12, ruling F18) — model-facing formats.
|
|
@@ -719,8 +720,11 @@ registerTool({
|
|
|
719
720
|
required: ['name', 'type'],
|
|
720
721
|
},
|
|
721
722
|
handler: async (args, ctx) => {
|
|
722
|
-
// runUnitTest(type, name) — type first
|
|
723
|
-
|
|
723
|
+
// runUnitTest(type, name, opts) — type first. D18: under a manual lock
|
|
724
|
+
// (sap_lock) the stateful session is kept, so the lock survives exactly as
|
|
725
|
+
// before; otherwise sap-client ends an open session first (a stateful
|
|
726
|
+
// session runs the old load, probe 2026-10-17 §2.3).
|
|
727
|
+
const result = await ctx.adt.runUnitTest(args.type, args.name, { keepSession: hasManualLocks() });
|
|
724
728
|
// B2 false-green guard: a run in which no test actually ran reads to a
|
|
725
729
|
// caller like "nothing failed → green". Wrap the (still-carried) raw result
|
|
726
730
|
// in an explicit UNVERIFIED envelope when:
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADT gap program, Part 3 — sap_run: run one of the person's own classes
|
|
3
|
+
* (IF_OO_ADT_CLASSRUN) or programs and show the output (plan D1–D8, D18).
|
|
4
|
+
*
|
|
5
|
+
* Order (each refusal before anything is spent or sent where it can be):
|
|
6
|
+
* 0. Z/Y names only (D3), before the approval.
|
|
7
|
+
* 0b. Never while a sap_lock lock is held (D18): ending the session would
|
|
8
|
+
* drop the lock, keeping it would run the old load. Before the approval.
|
|
9
|
+
* 1. One approval of op `run` per run (D2): a plan-grant id never runs, and
|
|
10
|
+
* the approval's type must be the type asked for (a CLAS approval never
|
|
11
|
+
* runs a PROG of the same name; the jwt checks object/op/nonce only).
|
|
12
|
+
* 2. WAL pending entry, as sap_activate writes one.
|
|
13
|
+
* 3. runClass / runProgram (sap-client reads SAP's answer: HTTP 200 alone
|
|
14
|
+
* is never "ran"; a 500 page is a dump with its window).
|
|
15
|
+
* 4. WAL finalized.
|
|
16
|
+
* 5. The result: the facts line, then the output, spilled when long (D7).
|
|
17
|
+
*/
|
|
18
|
+
import crypto from 'node:crypto';
|
|
19
|
+
import { registerTool } from './index.js';
|
|
20
|
+
import { verifyAndSpendApprovalId } from '../approvals/jwt.js';
|
|
21
|
+
import { typeCode } from '../approvals/risk-floor.js';
|
|
22
|
+
import { ERR } from '../errors/codes.js';
|
|
23
|
+
import { appendPending, finalizeToolCall } from '../session/pending.js';
|
|
24
|
+
import { hasManualLocks } from '../sap/session-end.js';
|
|
25
|
+
import { spillToolResult } from './result-spill.js';
|
|
26
|
+
import { isCustomerRunnable, notCustomerText, runUrl, SapError } from '@cspeach/sap-client';
|
|
27
|
+
const MANUAL_LOCK_TEXT = 'A lock you took with sap_lock is still held. Unlock it (sap_unlock) first, then ask for the run again.';
|
|
28
|
+
/** Every line right-trimmed (CR included), trailing blank lines dropped. */
|
|
29
|
+
function rightTrimmed(output) {
|
|
30
|
+
return output.split(/\r?\n/).map((l) => l.trimEnd()).join('\n').trimEnd();
|
|
31
|
+
}
|
|
32
|
+
registerTool({
|
|
33
|
+
name: 'sap_run',
|
|
34
|
+
description: 'Run one of your own classes (it must implement IF_OO_ADT_CLASSRUN and be active) or one of your own programs, '
|
|
35
|
+
+ 'and show the output. Only names starting with Z or Y. A program runs with its selection-screen defaults; '
|
|
36
|
+
+ 'values cannot be passed. The run is on the active version. Requires approval_id from a request_approval '
|
|
37
|
+
+ 'change of op `run` (one approval per run; never auto-approved). When SAP answered but did not run anything, '
|
|
38
|
+
+ 'the result says `not_runnable`; when the run dumped, the result says `run_dumped` and gives the window for '
|
|
39
|
+
+ 'sap_short_dump. Long output is saved to a file and previewed.',
|
|
40
|
+
isMutating: true,
|
|
41
|
+
requiresSap: true,
|
|
42
|
+
requires: (a) => (String(a.type ?? '').trim().toUpperCase() === 'PROG' ? 'run.programrun' : 'run.classrun'),
|
|
43
|
+
input_schema: {
|
|
44
|
+
type: 'object',
|
|
45
|
+
properties: {
|
|
46
|
+
type: { type: 'string', enum: ['CLAS', 'PROG'], description: 'CLAS for a class run (console), PROG for a program run' },
|
|
47
|
+
name: { type: 'string', description: 'Class or program name, e.g. ZCL_ORDER_DEMO' },
|
|
48
|
+
approval_id: { type: 'string', description: 'JWT from request_approval (a change of op run)' },
|
|
49
|
+
},
|
|
50
|
+
required: ['type', 'name', 'approval_id'],
|
|
51
|
+
},
|
|
52
|
+
handler: async (args, ctx) => {
|
|
53
|
+
const type = String(args.type ?? '').trim().toUpperCase();
|
|
54
|
+
const rawName = String(args.name ?? '').trim();
|
|
55
|
+
const name = rawName.toUpperCase();
|
|
56
|
+
if (type !== 'CLAS' && type !== 'PROG') {
|
|
57
|
+
return { content: JSON.stringify({ error: 'run_refused', message: 'sap_run runs a class (type CLAS) or a program (type PROG).' }), is_error: true };
|
|
58
|
+
}
|
|
59
|
+
// ── 0. Own objects only (D3) — before the approval is spent ────────────
|
|
60
|
+
// runUrl applies the same rule sap-client applies (and refuses a name that is
|
|
61
|
+
// not letters, digits and underscores), so nothing it would refuse after the
|
|
62
|
+
// spend gets past this point.
|
|
63
|
+
let refusal;
|
|
64
|
+
if (!rawName || !isCustomerRunnable(rawName))
|
|
65
|
+
refusal = notCustomerText(rawName);
|
|
66
|
+
else {
|
|
67
|
+
try {
|
|
68
|
+
runUrl(type, rawName);
|
|
69
|
+
}
|
|
70
|
+
catch (e) {
|
|
71
|
+
refusal = e instanceof Error ? e.message : String(e);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (refusal) {
|
|
75
|
+
return { content: JSON.stringify({ error: 'run_refused', message: refusal }), is_error: true };
|
|
76
|
+
}
|
|
77
|
+
// ── 0b. Never under a manual lock (D18) — before the approval ──────────
|
|
78
|
+
if (hasManualLocks()) {
|
|
79
|
+
return { content: JSON.stringify({ error: 'manual_lock_held', message: MANUAL_LOCK_TEXT }), is_error: true };
|
|
80
|
+
}
|
|
81
|
+
// ── 1. The approval: op run, one per run, never a plan grant (D2) ──────
|
|
82
|
+
const verify = await verifyAndSpendApprovalId(args.approval_id, name, 'run');
|
|
83
|
+
if (!verify.ok) {
|
|
84
|
+
return { content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: verify.reason }), is_error: true };
|
|
85
|
+
}
|
|
86
|
+
if (verify.payload.grant) {
|
|
87
|
+
return { content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: 'grant_cannot_run' }), is_error: true };
|
|
88
|
+
}
|
|
89
|
+
if (typeCode(verify.payload.type) !== type) {
|
|
90
|
+
return { content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: 'type_mismatch' }), is_error: true };
|
|
91
|
+
}
|
|
92
|
+
// ── 2. WAL ─────────────────────────────────────────────────────────────
|
|
93
|
+
const toolUseId = ctx.toolUseId ?? crypto.randomUUID();
|
|
94
|
+
const startedAt = Date.now();
|
|
95
|
+
await appendPending(ctx.session, {
|
|
96
|
+
tool_use_id: toolUseId,
|
|
97
|
+
tool: 'sap_run',
|
|
98
|
+
args: { type, name },
|
|
99
|
+
started_at: new Date().toISOString(),
|
|
100
|
+
});
|
|
101
|
+
const line = (kind, ms) => ctx.chunkEmitter?.emit('info', `run ${type} ${name} · ${kind} · ${ms} ms`);
|
|
102
|
+
// ── 3. The run ─────────────────────────────────────────────────────────
|
|
103
|
+
let r;
|
|
104
|
+
try {
|
|
105
|
+
r = type === 'PROG' ? await ctx.adt.runProgram(name) : await ctx.adt.runClass(name);
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
109
|
+
// sap-client's own Z/Y refusal (400) cannot reach here (step 0 ran first).
|
|
110
|
+
// Anything else (a lost connection) may have run: say so, never "refused".
|
|
111
|
+
const refused = err instanceof SapError && err.httpStatus === 400;
|
|
112
|
+
const body = refused
|
|
113
|
+
? { error: 'run_refused', message }
|
|
114
|
+
: { error: 'run_failed', type, name, detail: message };
|
|
115
|
+
await finalizeToolCall(ctx.session, toolUseId, JSON.stringify(body), true, Date.now() - startedAt);
|
|
116
|
+
line(refused ? 'refused' : 'failed', Date.now() - startedAt);
|
|
117
|
+
return { content: JSON.stringify(body), is_error: true };
|
|
118
|
+
}
|
|
119
|
+
// ── 4/5. WAL closed, the result ────────────────────────────────────────
|
|
120
|
+
if (r.kind === 'ran') {
|
|
121
|
+
const facts = JSON.stringify({
|
|
122
|
+
ran: true, type, name: r.name, lines: r.lines, chars: r.outputBytes,
|
|
123
|
+
elapsed_ms: r.elapsedMs, session_ended: r.sessionEnded,
|
|
124
|
+
});
|
|
125
|
+
await finalizeToolCall(ctx.session, toolUseId, facts, false, Date.now() - startedAt);
|
|
126
|
+
line('ran', r.elapsedMs);
|
|
127
|
+
// The facts line is the first line of the content (not a spill header):
|
|
128
|
+
// the file starts with it too, so tool_output_read line 1 is the facts.
|
|
129
|
+
const out = rightTrimmed(r.output);
|
|
130
|
+
return spillToolResult({ content: out ? `${facts}\n${out}` : facts }, ctx, 'shell', { label: `run ${r.name}` });
|
|
131
|
+
}
|
|
132
|
+
let body;
|
|
133
|
+
if (r.kind === 'not_runnable') {
|
|
134
|
+
body = {
|
|
135
|
+
error: 'not_runnable', type, name: r.name, sap_said: r.sapText, reason: r.reason,
|
|
136
|
+
next_step: 'activate the object (sap_activate) or fix the name, then ask for a new run approval',
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
else if (r.kind === 'dumped') {
|
|
140
|
+
const d = r.dump;
|
|
141
|
+
body = {
|
|
142
|
+
error: 'run_dumped', type, name: r.name, message: d?.message,
|
|
143
|
+
...(d?.serverTime ? { sap_server_time: d.serverTime } : {}),
|
|
144
|
+
...(d?.window ? { dump_window: { from: d.window.from, to: d.window.to } } : {}),
|
|
145
|
+
note: 'Output written before the exception is lost; the dump has the stack.',
|
|
146
|
+
next_step: d?.window
|
|
147
|
+
? `sap_short_dump with fromDate and toDate = dump_window (SAP system time), then operation detail on the newest dump whose program names ${r.name}`
|
|
148
|
+
: `sap_short_dump (list), then operation detail on the newest dump whose program names ${r.name}`,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
body = {
|
|
153
|
+
error: r.reason?.startsWith('No answer') ? 'run_timed_out' : 'run_failed', type, name: r.name,
|
|
154
|
+
...(r.httpStatus !== undefined ? { http_status: r.httpStatus } : {}),
|
|
155
|
+
detail: r.reason ?? r.sapText,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
const content = JSON.stringify(body);
|
|
159
|
+
await finalizeToolCall(ctx.session, toolUseId, content, true, Date.now() - startedAt);
|
|
160
|
+
line(r.kind, r.elapsedMs);
|
|
161
|
+
return { content, is_error: true };
|
|
162
|
+
},
|
|
163
|
+
});
|
|
@@ -41,7 +41,7 @@ const FIORI_FAMILY = [
|
|
|
41
41
|
'fiori_sample_get', 'fiori_sample_search', 'fiori_scaffold', 'fiori_scaffold_fe',
|
|
42
42
|
];
|
|
43
43
|
const TRANSPORT_FAMILY = [
|
|
44
|
-
'sap_transport_create', 'sap_transport_for_object', 'sap_transport_list', 'sap_transport_release',
|
|
44
|
+
'sap_transport_create', 'sap_transport_for_object', 'sap_transport_list', 'sap_transport_read', 'sap_transport_release',
|
|
45
45
|
];
|
|
46
46
|
/**
|
|
47
47
|
* Per-skill starter tools: the non-core tools a skill's body names as its
|
|
@@ -65,14 +65,15 @@ export const SKILL_STARTERS = Object.freeze({
|
|
|
65
65
|
'abap-impact': ['sap_usage_references', 'sap_atc_run'],
|
|
66
66
|
'abap-performance': ['sap_usage_references', 'sap_atc_run'],
|
|
67
67
|
'abap-migrate': ['sap_usage_references'],
|
|
68
|
-
|
|
69
|
-
'abap-
|
|
70
|
-
'abap-
|
|
71
|
-
'abap-
|
|
68
|
+
// Part 3 — the skills whose body says "run it" (Task 7's run block) also start with sap_run.
|
|
69
|
+
'abap-dump': ['sap_short_dump', 'sap_snapshot_take', 'sap_run'],
|
|
70
|
+
'abap-incident': ['sap_short_dump', 'sap_snapshot_take', 'sap_atc_run', 'sap_run_unit_test', 'sap_api_state', 'sap_diff', 'sap_run'],
|
|
71
|
+
'abap-eml': ['sap_run_unit_test', 'sap_run'],
|
|
72
|
+
'abap-test': ['sap_run_unit_test', 'sap_set_class_include', 'sap_run'],
|
|
72
73
|
'abap-extend-model': ['extend_model_insert', 'sap_snapshot_take', 'sap_service_binding_publish', 'sap_set_class_include'],
|
|
73
74
|
// RAP generator (2026-10-14): SAP's generator writes the stack; the finish check proves F1–F5.
|
|
74
75
|
// Final review M5 — standalone /abap-rap calls rap_skeleton_resolve before it builds.
|
|
75
|
-
'abap-rap': ['sap_set_class_include', 'sap_service_binding_publish', 'sap_class_includes', 'rap_skeleton_resolve', 'sap_generate_objects', 'rap_finish_check'],
|
|
76
|
+
'abap-rap': ['sap_set_class_include', 'sap_service_binding_publish', 'sap_class_includes', 'rap_skeleton_resolve', 'sap_generate_objects', 'rap_finish_check', 'sap_run'],
|
|
76
77
|
// Plan steps load tools by the PLAN skill (session.tool_set_skill / params.skill), not the
|
|
77
78
|
// delegate (which only rides as the X-CSPeach-Delegate-Skill header), so the RAP
|
|
78
79
|
// skeleton + finish phases need the generator and the finish check here too.
|
|
@@ -84,7 +85,10 @@ export const SKILL_STARTERS = Object.freeze({
|
|
|
84
85
|
'abap-upgrade-verify': ['sap_atc_run'],
|
|
85
86
|
// Selection texts (2026-10-04): a new PARAMETERS / SELECT-OPTIONS gets its text in the same change.
|
|
86
87
|
'abap-refactor': ['sap_get_text_elements', 'sap_set_text_elements'],
|
|
87
|
-
'abap-generate': ['sap_get_text_elements', 'sap_set_text_elements'],
|
|
88
|
+
'abap-generate': ['sap_get_text_elements', 'sap_set_text_elements', 'sap_run'],
|
|
89
|
+
// Chat lane (D5): the hand-off tool is loaded on chat turns; flag-gated, so a skills-lane session never lists it.
|
|
90
|
+
// `_chat` is the one SKILL_STARTERS key that is not a catalog skill (pinned in tool-loading.test.ts).
|
|
91
|
+
'_chat': ['hand_off'],
|
|
88
92
|
});
|
|
89
93
|
/** The starter tools for a skill; unknown skill (or none) ⇒ []. */
|
|
90
94
|
export function startersForSkill(skill) {
|