@cspeach/cli 1.1.44 → 1.1.45

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": "8c2d43d9",
3
+ "createdAt": "2026-10-17T17:11:58.030Z"
4
+ }
@@ -33,6 +33,8 @@ export const WRITE_CLASS_TOOLS = new Set([
33
33
  'sap_create_object',
34
34
  // Final review I2 — SAP's RAP generator creates up to 9 objects in one call.
35
35
  'sap_generate_objects',
36
+ // Part 3 — a run executes code; it never runs beside a write in the same round.
37
+ 'sap_run',
36
38
  ]);
37
39
  export function isWriteClassTool(name) {
38
40
  return WRITE_CLASS_TOOLS.has(name);
@@ -15,6 +15,8 @@ const usedNonces = new Set();
15
15
  * - approving `modify` covers modify → activate (write then activate).
16
16
  * - `delete` / `release` stay single-op (and single-use) — one-shot ops the
17
17
  * user must approve explicitly each time.
18
+ * - `run` (Part 3 D2) is single-op and single-use too: one approval runs a
19
+ * class or program once.
18
20
  *
19
21
  * This removes the op_mismatch APPROVAL_INVALID storm (create approval used,
20
22
  * then set_source rejected because it's a different op) without weakening the
@@ -26,11 +28,12 @@ const OP_COVERAGE = {
26
28
  delete: new Set(['delete']),
27
29
  activate: new Set(['activate']),
28
30
  release: new Set(['release']),
31
+ run: new Set(['run']),
29
32
  };
30
33
  /** create/modify approvals are reusable across their object's lifecycle ops
31
- * until TTL; delete/release are genuinely one-shot. */
34
+ * until TTL; delete/release/run are genuinely one-shot. */
32
35
  function isOneShotOp(op) {
33
- return op === 'delete' || op === 'release';
36
+ return op === 'delete' || op === 'release' || op === 'run';
34
37
  }
35
38
  export async function mintApprovalId(payload) {
36
39
  const nonce = crypto.randomBytes(16).toString('hex');
@@ -14,6 +14,7 @@ const VERBS = {
14
14
  delete: 'Delete',
15
15
  activate: 'Activate',
16
16
  release: 'Release',
17
+ run: 'Run',
17
18
  };
18
19
  /**
19
20
  * SAP ADT type codes → plain words. The `type` field is free text supplied by
@@ -45,6 +46,8 @@ const TYPE_WORDS = {
45
46
  FUNC: 'function module',
46
47
  FINC: 'function group include',
47
48
  INCL: 'include',
49
+ // Part 3 (T1 ruling): the release and the create card both read "transport request".
50
+ CTS: 'transport request',
48
51
  };
49
52
  export function humanVerb(op) {
50
53
  return VERBS[op] ?? op.charAt(0).toUpperCase() + op.slice(1);
@@ -86,6 +89,38 @@ export function affectsLine(change) {
86
89
  const t = sanitizeControlChars(String(change.affects ?? '')).trim();
87
90
  return t ? `Affects: ${t}` : null;
88
91
  }
92
+ /** Part 3 D12 — the longest request description a release card shows. */
93
+ const RELEASE_DESCRIPTION_MAX = 60;
94
+ const RELEASE_LAST_LINE = 'Cannot be undone. Once released, the request can move to the other systems on the transport route.';
95
+ /** Part 3 D13 — the card a person decides on says what CSPeach checks and where to check it after the release (owner wording 2026-10-17). */
96
+ export const RELEASE_CHECK_LINE = 'CSPeach reads SAP\'s verdict after each step and checks the request afterwards. Check the transport in SE09 after the release.';
97
+ /**
98
+ * Part 3 D12 — the fixed lines under a release change; [] for any other change
99
+ * or when no facts are attached. The facts are SAP's (request_approval reads
100
+ * them from the request before the card is drawn); text from SAP still passes
101
+ * through sanitizeControlChars. A task owned by someone other than the request
102
+ * owner is named (preflight F-15: SAP releases it under the caller's
103
+ * authorization).
104
+ */
105
+ export function releaseLines(change) {
106
+ const f = change.releaseFacts;
107
+ if (change.op !== 'release' || !f)
108
+ return [];
109
+ const clean = (v) => sanitizeControlChars(String(v ?? '')).trim();
110
+ let description = clean(f.description);
111
+ if (description.length > RELEASE_DESCRIPTION_MAX)
112
+ description = `${description.slice(0, RELEASE_DESCRIPTION_MAX)}…`;
113
+ const owner = clean(f.owner);
114
+ const tasks = Array.isArray(f.tasks) ? f.tasks : [];
115
+ const others = [...new Set(tasks.map((t) => clean(t.owner)).filter((o) => o && o !== owner))];
116
+ return [
117
+ `Request ${clean(f.request)} "${description}" · owner ${owner} · target ${clean(f.target) || 'none'}`,
118
+ `${tasks.length} ${tasks.length === 1 ? 'task' : 'tasks'}: ${tasks.map((t) => clean(t.number)).join(', ')} · ${f.objects === null || f.objects === undefined ? 'objects: could not count' : `${f.objects} ${f.objects === 1 ? 'object' : 'objects'}`}`
119
+ + (others.length ? ` · other owners: ${others.join(', ')}` : ''),
120
+ RELEASE_LAST_LINE,
121
+ RELEASE_CHECK_LINE,
122
+ ];
123
+ }
89
124
  /**
90
125
  * The transport to DISPLAY on the approval surface for one change — mirrors
91
126
  * how the write tools actually resolve it (traced in sap-write.ts, 2026-07-05
@@ -97,8 +132,8 @@ export function affectsLine(change) {
97
132
  * resolveWriteTransport(args only, + owning-TR override) — NO session
98
133
  * fallback in the actual write, so we only show a transport the request
99
134
  * explicitly carries.
100
- * - activate/release: the tools take no transport arg at all — never show
101
- * a Transport segment.
135
+ * - activate/release/run: the tools take no transport arg at all — never
136
+ * show a Transport segment.
102
137
  * When nothing is resolvable, return undefined and the caller OMITS the
103
138
  * segment — no "new (session)" promise (a $TMP write never creates one).
104
139
  */
@@ -109,7 +144,7 @@ export function displayTransport(op, explicit, session) {
109
144
  case 'modify':
110
145
  case 'delete':
111
146
  return explicit;
112
- default: // activate / release
147
+ default: // activate / release / run
113
148
  return undefined;
114
149
  }
115
150
  }
@@ -132,7 +167,7 @@ export function toSourceLines(content) {
132
167
  * - modify → [d] Show diff (there IS a before/after to diff)
133
168
  * - create → [v] View source, only when the new source is carried
134
169
  * - delete → [v] View current source, only when the source is carried
135
- * - activate/release → nothing to review (no source in the payload)
170
+ * - activate/release/run → nothing to review (no source in the payload)
136
171
  */
137
172
  export function viewAffordance(change) {
138
173
  switch (change.op) {
@@ -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, affectsLine } from './op-labels.js';
4
+ import { verbLine, viewAffordance, toSourceLines, affectsLine, releaseLines } 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';
@@ -118,22 +118,44 @@ export async function renderPlanGate(summary, changes, transport, effectiveRisk,
118
118
  // implementation) carries its "Affects:" row right under it. The card's
119
119
  // step budget counts rows, and a step is never shown without its row.
120
120
  // No affects anywhere: one row per step, exactly as before.
121
+ // Part 3 D12 / F-13 — a release step carries its fact lines the same way:
122
+ // "Apply all" mints it without its per-change card.
121
123
  const steps = visibleChanges.map((c, i) => {
122
124
  const affects = affectsLine(c);
123
- return [`${i + 1}. ${verbLine(c)}`, ...(affects ? [` ${affects}`] : [])];
125
+ return [`${i + 1}. ${verbLine(c)}`, ...(affects ? [` ${affects}`] : []), ...releaseLines(c).map((l) => ` ${l}`)];
124
126
  });
125
- const totalRows = steps.reduce((n, s) => n + s.length, 0);
127
+ // Fix round 1 (owner ruling) — a release step is ALWAYS on the card, with
128
+ // its fact lines, wherever it sits: its rows have their own budget and
129
+ // never count toward MAX_STEPS. The other steps keep today's budget, so a
130
+ // plan without a release renders exactly as before.
131
+ const isRelease = visibleChanges.map((c) => releaseLines(c).length > 0);
132
+ const totalRows = steps.reduce((n, s, i) => n + (isRelease[i] ? 0 : s.length), 0);
126
133
  if (totalRows > MAX_STEPS) {
127
134
  // Object names are model-supplied: neutralise control characters
128
135
  // before they reach scrollback (the card sanitises its own rows).
129
136
  emitTrace(sanitizeControlChars(['Plan', ...steps.flat().map((s) => ` ${s}`)].join('\n')));
137
+ const shownRows = [];
130
138
  let shown = 0;
139
+ let others = 0;
131
140
  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`);
141
+ let full = false;
142
+ steps.forEach((s, i) => {
143
+ if (isRelease[i]) {
144
+ shownRows.push(...s);
145
+ shown += 1;
146
+ return;
147
+ }
148
+ if (!full && (others === 0 || rows + s.length <= MAX_STEPS)) {
149
+ shownRows.push(...s);
150
+ rows += s.length;
151
+ others += 1;
152
+ shown += 1;
153
+ }
154
+ else {
155
+ full = true;
156
+ }
157
+ });
158
+ facts.push(...shownRows, `${getGlyphs().more} ${steps.length - shown} more steps, listed above`);
137
159
  }
138
160
  else {
139
161
  facts.push(...steps.flat());
@@ -180,6 +202,9 @@ async function classicPlanGate(summary, visibleChanges, transport, effectiveRisk
180
202
  const affects = affectsLine(c);
181
203
  if (affects)
182
204
  console.log(` ${chalk.dim(affects)}`);
205
+ // Part 3 D12 / F-13 — a release row's facts, under its line.
206
+ for (const l of releaseLines(c))
207
+ console.log(` ${chalk.dim(l)}`);
183
208
  });
184
209
  console.log('');
185
210
  const choice = await withInquirer(() => select({
@@ -213,8 +238,10 @@ export async function renderPerChangeApproval(change, effectiveRisk, transport)
213
238
  choices.push({ value: 'skip', name: 'Skip (tell model why)' }, { value: 'cancel', name: 'Cancel turn' });
214
239
  // Part 2b D7 — the process a BAdI implementation runs in, under the verb line.
215
240
  const affects = affectsLine(change);
241
+ // Part 3 D12 — a release card's facts, under the verb line.
242
+ const release = releaseLines(change).map((l) => `\n ${chalk.dim(l)}`).join('');
216
243
  const choice = await withInquirer(() => select({
217
- message: `${objLabel} — apply?${affects ? `\n ${chalk.dim(affects)}` : ''}`,
244
+ message: `${objLabel} — apply?${affects ? `\n ${chalk.dim(affects)}` : ''}${release}`,
218
245
  default: 'apply',
219
246
  theme: inquirerTheme,
220
247
  choices,
@@ -48,8 +48,18 @@ export function effectiveRisk(declared, changes, ctx) {
48
48
  // Round 4 item 8a — these were both labelled 'shared_transport', so a plan
49
49
  // on a transport created seconds earlier read "raised … by shared_transport".
50
50
  // Each reason now says what actually raised it.
51
- if (changes.some((c) => c.op === 'release'))
52
- raise('medium', 'transport_release');
51
+ // Part 3 D2 — a release cannot be undone, and a run executes code on the
52
+ // system: both are high (never auto-approved), and the reason is always shown.
53
+ if (changes.some((c) => c.op === 'release')) {
54
+ raise('high', 'transport_release');
55
+ if (!raisedBy.includes('transport_release'))
56
+ raisedBy.push('transport_release');
57
+ }
58
+ if (changes.some((c) => c.op === 'run')) {
59
+ raise('high', 'code_run');
60
+ if (!raisedBy.includes('code_run'))
61
+ raisedBy.push('code_run');
62
+ }
53
63
  if (changes.some((c) => c.op === 'activate'))
54
64
  raise('medium', 'activation');
55
65
  if (ctx.recentAtcFailure && changes.some((c) => ctx.recentAtcFailure.includes(c.object)))
@@ -66,7 +76,7 @@ export function effectiveRisk(declared, changes, ctx) {
66
76
  const RISK_REASON_TEXT = {
67
77
  non_prod_override: 'auto-approve is set to never on this system (a setting, not a risk)',
68
78
  delete_op: 'the plan deletes an object',
69
- transport_release: 'the plan releases a transport',
79
+ transport_release: 'the plan releases a transport; this cannot be undone',
70
80
  activation: 'activation makes the change live on the system',
71
81
  atc_failed_24h: 'ATC failed on this object in the last 24 hours',
72
82
  shared_transport: 'the transport is shared by 3 or more developers',
@@ -74,6 +84,7 @@ const RISK_REASON_TEXT = {
74
84
  prod_namespace: 'the object is in a production namespace',
75
85
  syntax_errors: 'the last syntax check of this object found errors',
76
86
  standard_process: 'this code runs inside a standard SAP process (a BAdI implementation)',
87
+ code_run: 'this runs code on the SAP system',
77
88
  };
78
89
  /** Round 8 item 4 — every code the floor can raise (each has plain words above). */
79
90
  export const RISK_REASON_CODES = Object.keys(RISK_REASON_TEXT);
package/dist/one-shot.js CHANGED
@@ -44,6 +44,7 @@ import { globalCredits } from './cost/credits.js';
44
44
  import './tools/sap-read.js';
45
45
  import './tools/sap-write.js';
46
46
  import './tools/transport.js';
47
+ import './tools/sap-run.js';
47
48
  import './tools/approval.js';
48
49
  import './tools/snapshot.js';
49
50
  import './tools/ask-question.js';
@@ -115,8 +115,15 @@ const MESSAGES = {
115
115
  'Finding modifiable TRs…',
116
116
  ],
117
117
  sap_transport_release: [
118
- 'Releasing transport for QA queue…',
119
- 'This is irreversible — sending now…',
118
+ 'Releasing the open tasks, then the request…',
119
+ 'Waiting for the release report from SAP…',
120
+ ],
121
+ sap_transport_read: [
122
+ 'Reading the transport request…',
123
+ ],
124
+ sap_run: [
125
+ 'Running {name} on SAP…',
126
+ 'Waiting for the output…',
120
127
  ],
121
128
  sap_create_object: [
122
129
  'Creating {name}…',
@@ -180,6 +180,8 @@ const RULES = {
180
180
  sap_transport_for_object: (a) => ({ verb: 'Transport for', object: shortRight(str(a['object_name']) ?? '') }),
181
181
  sap_transport_list: (a) => ({ verb: 'Listed transports', object: shortRight(str(a['owner']) ?? '') }),
182
182
  sap_transport_release: (a) => ({ verb: 'Released', object: shortRight(str(a['transport']) ?? '') }),
183
+ sap_transport_read: (a) => ({ verb: 'Read transport request', object: shortRight(str(a['transport']) ?? '') }),
184
+ sap_run: sapObject('Ran'),
183
185
  sap_create_domain: sapObject('Created domain'),
184
186
  sap_create_data_element: sapObject('Created data element'),
185
187
  sap_lock: sapObject('Locked'),
@@ -271,7 +273,7 @@ const PRESENT = {
271
273
  'Read snapshot': 'Reading snapshot of', 'Cleaned snapshots': 'Cleaning snapshots',
272
274
  'Created transport': 'Creating transport', 'Transport for': 'Finding transport for',
273
275
  'Listed transports': 'Listing transports', Released: 'Releasing', 'Created domain': 'Creating domain',
274
- 'Created data element': 'Creating data element', 'Read BAdI': 'Reading BAdI', 'Read enhancement options': 'Reading enhancement options of', 'Created BAdI implementation': 'Creating BAdI implementation', 'Created table type': 'Creating table type', 'Read table type': 'Reading table type', 'Listed function group': 'Listing function group', Locked: 'Locking', Unlocked: 'Unlocking',
276
+ 'Created data element': 'Creating data element', 'Read BAdI': 'Reading BAdI', 'Read enhancement options': 'Reading enhancement options of', 'Created BAdI implementation': 'Creating BAdI implementation', 'Created table type': 'Creating table type', 'Read table type': 'Reading table type', 'Read transport request': 'Reading transport request', 'Listed function group': 'Listing function group', Locked: 'Locking', Unlocked: 'Unlocking',
275
277
  Published: 'Publishing', Unpublished: 'Unpublishing', Messages: 'Maintaining messages in',
276
278
  'Number range': 'Maintaining number range', Question: 'Asking', 'Updated todos': 'Updating todos',
277
279
  'Ran skill': 'Running skill', 'Read document': 'Reading document', Fetched: 'Fetching',
package/dist/repl.js CHANGED
@@ -152,6 +152,7 @@ import { applyLocalBuildConfig, applyStandaloneFileTools } from './tools/local-b
152
152
  import './tools/sap-read.js';
153
153
  import './tools/sap-write.js';
154
154
  import './tools/transport.js';
155
+ import './tools/sap-run.js';
155
156
  import './tools/approval.js';
156
157
  import './tools/snapshot.js';
157
158
  import './tools/ask-question.js';
@@ -35,6 +35,11 @@ export const FEATURES = Object.freeze({
35
35
  'fugr.modules': { label: 'Function modules and function-group includes', collection: `${A}/functions/groups`, provenOnOurBox: true },
36
36
  'prog.includes': { label: 'Program includes', collection: `${A}/programs/includes`, provenOnOurBox: true },
37
37
  'ddic.tabletypes': { label: 'Table types', collection: `${A}/ddic/tabletypes`, provenOnOurBox: true },
38
+ // Part 3 (D15): no `since` (no cited release). The collections and the reads are
39
+ // proven on our box; the release is not, and these rows are about the collection.
40
+ 'run.classrun': { label: 'Run a class (console)', collection: `${A}/oo/classrun`, provenOnOurBox: true },
41
+ 'run.programrun': { label: 'Run a program', collection: `${A}/programs/programrun`, provenOnOurBox: true },
42
+ 'cts.requests': { label: 'Transport requests (read, release)', collection: `${A}/cts/transportrequests`, provenOnOurBox: true },
38
43
  });
39
44
  export const FEATURE_IDS = Object.freeze(Object.keys(FEATURES));
40
45
  export function isFeatureId(s) {
@@ -62,6 +62,8 @@ const WRITE_TOOLS = new Set([
62
62
  // Final review I2 — SAP's RAP generator (tools/rap/generate-objects.ts) creates
63
63
  // the skeleton objects in one call; its row names them.
64
64
  'sap_generate_objects',
65
+ // Part 3 — a run executes code on the SAP system under an approval: it is audited.
66
+ 'sap_run',
65
67
  ]);
66
68
  /**
67
69
  * Non-mutating tools that are interaction/plumbing, not object reads — excluded
@@ -156,6 +158,29 @@ function approvalCell(rec) {
156
158
  return `declined ✗${risk}`;
157
159
  }
158
160
  // ── Object / snapshot extraction ──────────────────────────────────────────
161
+ /**
162
+ * The result as JSON, or its first line as JSON (a run's facts line), or the
163
+ * top-level fields a spilled preview still shows ("status": "stopped" comes
164
+ * before steps[] in the release envelope). {} when none of these read.
165
+ */
166
+ function parseResultHead(result) {
167
+ const text = result ?? '';
168
+ try {
169
+ return asRecord(JSON.parse(text));
170
+ }
171
+ catch { /* next */ }
172
+ try {
173
+ return asRecord(JSON.parse(text.split('\n', 1)[0] ?? ''));
174
+ }
175
+ catch { /* next */ }
176
+ const top = (k) => new RegExp(`^ {2}"${k}": "([^"]+)"`, 'm').exec(text)?.[1];
177
+ const status = top('status');
178
+ if (!status)
179
+ return {};
180
+ const at = top('stopped_at');
181
+ const verdict = top('stop_verdict');
182
+ return { status, ...(at ? { stopped_at: at } : {}), ...(verdict ? { stop_verdict: verdict } : {}) };
183
+ }
159
184
  function writeObject(tool, args, result) {
160
185
  switch (tool) {
161
186
  case 'sap_transport_create': {
@@ -180,8 +205,31 @@ function writeObject(tool, args, result) {
180
205
  // Last resort: name the operation, not the raw description.
181
206
  return 'transport';
182
207
  }
183
- case 'sap_transport_release':
184
- return str(args.transport) ?? '—';
208
+ case 'sap_transport_release': {
209
+ // Part 3 (D17): the row carries SAP's verdict; a stop names its step.
210
+ const tr = str(args.transport) ?? '—';
211
+ const r = parseResultHead(result);
212
+ const status = str(r.status);
213
+ if (!status)
214
+ return tr;
215
+ if (status === 'stopped') {
216
+ const last = Array.isArray(r.steps) && r.steps.length > 0 ? asRecord(r.steps[r.steps.length - 1]) : {};
217
+ const at = str(r.stopped_at) ?? str(last.number);
218
+ const verdict = str(r.stop_verdict) ?? str(last.verdict);
219
+ if (at && verdict)
220
+ return `${tr} · stopped at ${at}: ${verdict}`;
221
+ }
222
+ return `${tr} · ${status}`;
223
+ }
224
+ case 'sap_run': {
225
+ // Part 3: `CLAS ZCL_X · ran` or the error code (the facts line is line 1 of a ran result).
226
+ const r = parseResultHead(result);
227
+ const what = [str(args.type), str(args.name)].filter(Boolean).join(' ') || '—';
228
+ if (r.ran === true)
229
+ return `${what} · ran`;
230
+ const err = str(r.error);
231
+ return err ? `${what} · ${err}` : what;
232
+ }
185
233
  case 'sap_update_method': {
186
234
  const cls = str(args.class_name) ?? '—';
187
235
  const m = str(args.method_name);
@@ -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,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
+ }
@@ -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
- const result = await ctx.adt.runUnitTest(args.type, args.name);
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
- 'abap-dump': ['sap_short_dump', 'sap_snapshot_take'],
69
- 'abap-incident': ['sap_short_dump', 'sap_snapshot_take', 'sap_atc_run', 'sap_run_unit_test', 'sap_api_state', 'sap_diff'],
70
- 'abap-eml': ['sap_run_unit_test'],
71
- 'abap-test': ['sap_run_unit_test', 'sap_set_class_include'],
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,7 @@ 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'],
88
89
  });
89
90
  /** The starter tools for a skill; unknown skill (or none) ⇒ []. */
90
91
  export function startersForSkill(skill) {
@@ -7,14 +7,21 @@
7
7
  * transportList(user?, status?) → Promise<TransportListResult> line 1048
8
8
  * TransportListResult { filterUser, filterStatus, transports: TransportEntry[] }
9
9
  * TransportEntry { transportNumber, description?, owner?, status?, type?, target? }
10
- * transportRelease(transportNumber) → Promise<TransportResult> line 2546
11
- * Released status string from SAP is 'released' (not a raw 'R' code).
10
+ * transportRead(number) → Promise<TransportRequestDoc> (Part 3)
11
+ * transportRelease(number) → Promise<TransportReleaseResult> (Part 3)
12
+ * status 'released' only on SAP's verdict for every step AND a status read back of R/N.
12
13
  */
13
14
  import { bindGrantTransport, isPlanGrantInScope } from '../approvals/plan-grant.js';
14
15
  import { registerTool } from './index.js';
15
16
  import { verifyAndSpendApprovalId } from '../approvals/jwt.js';
16
- import { auditLog } from '@cspeach/sap-client';
17
+ import { auditLog, SapNotFoundError, isModifiable, knownObjectCount, allObjectNames } from '@cspeach/sap-client';
18
+ import { typeCode } from '../approvals/risk-floor.js';
19
+ import { spillToolResult } from './result-spill.js';
20
+ import { notModifiableText } from './release-texts.js';
21
+ import { RELEASE_CHECK_LINE } from '../approvals/op-labels.js';
17
22
  import { ERR } from '../errors/codes.js';
23
+ import { presentSafetyConfirmation } from '../repl/safety-confirm.js';
24
+ import { isSafetyConfirmEnabled } from '../repl/safety-mode-state.js';
18
25
  import { setCurrentTransport, noteConfirmedTransport } from '../repl/current-transport.js';
19
26
  import { enrichTransportList } from './transport-fit.js';
20
27
  import { reusedRequestRefusal } from './transport-fit-check.js';
@@ -472,40 +479,259 @@ registerTool({
472
479
  }
473
480
  },
474
481
  });
482
+ // ── sap_transport_read ───────────────────────────────────────────────────────
483
+ //
484
+ // Part 3 (D16) — one transport request as SAP's transport organizer shows it.
485
+ // Read only, no approval.
486
+ /** The 404 text, never SAP's generic "Object not found". */
487
+ function notFoundText(n) {
488
+ return `Transport request ${n} does not exist.`;
489
+ }
490
+ /** Gate 0 of the release and the whole of the read: SAP's own document, or the refusal envelope. */
491
+ async function readRequest(ctx, asked) {
492
+ try {
493
+ return { doc: await ctx.adt.transportRead(asked) };
494
+ }
495
+ catch (err) {
496
+ const body = err instanceof SapNotFoundError
497
+ ? { error: 'not_found', detail: notFoundText(asked) }
498
+ : { error: 'transport_read_failed', detail: err instanceof Error ? err.message : String(err) };
499
+ return { refusal: { content: JSON.stringify(body), is_error: true } };
500
+ }
501
+ }
502
+ registerTool({
503
+ name: 'sap_transport_read',
504
+ description: 'Read one transport request: status, owner, target, its tasks and how many objects it holds. '
505
+ + 'A task number answers its request with that task. Read only.',
506
+ isMutating: false,
507
+ requiresSap: true,
508
+ requires: 'cts.requests',
509
+ input_schema: {
510
+ type: 'object',
511
+ properties: {
512
+ transport: { type: 'string', description: 'Transport request or task number (e.g. DEVK900123)' },
513
+ },
514
+ required: ['transport'],
515
+ },
516
+ handler: async (args, ctx) => {
517
+ const asked = String(args.transport ?? '').trim().toUpperCase();
518
+ const read = await readRequest(ctx, asked);
519
+ if ('refusal' in read)
520
+ return read.refusal;
521
+ const { doc } = read;
522
+ const objectCount = knownObjectCount(doc);
523
+ const notes = [];
524
+ if (doc.kind === 'task')
525
+ notes.push(`${asked} is a task of ${doc.number}.`);
526
+ if (objectCount === null)
527
+ notes.push(`CSPeach could not count the objects of ${doc.number}.`);
528
+ return {
529
+ content: JSON.stringify({
530
+ kind: doc.kind,
531
+ request: doc.number,
532
+ description: doc.description,
533
+ owner: doc.owner,
534
+ type: doc.type,
535
+ status: doc.status,
536
+ status_text: doc.statusText,
537
+ target: doc.target,
538
+ tasks: doc.tasks.map((t) => ({
539
+ number: t.number, owner: t.owner, status: t.status, status_text: t.statusText, objects: t.objectCount,
540
+ })),
541
+ object_count: objectCount,
542
+ can_release: isModifiable(doc.status) && doc.actions.includes('newreleasejobs'),
543
+ ...(notes.length ? { note: notes.join(' ') } : {}),
544
+ }),
545
+ };
546
+ },
547
+ });
475
548
  // ── sap_transport_release ────────────────────────────────────────────────────
476
549
  //
477
- // 1.1.44 hotfix: not available. The request this tool used to send never
478
- // released anything (SAP routes it to the create-request handler), and the
479
- // tool then reported "released". Until the proper release ships, the tool
480
- // refuses before anything else: no approval spent, no card, no SAP request.
481
- const RELEASE_UNAVAILABLE = 'Releasing a transport is not available in this version of CSPeach. '
482
- + 'Release it in SE09 or Eclipse. Nothing was sent to SAP.';
550
+ // Part 3 (D10–D13, D17) — every open task first, then the request, on SAP's
551
+ // own release verdict (sap-client transportRelease). Gates, in order:
552
+ // 0. read the request: missing, a task number, not modifiable → refused;
553
+ // 0b. the request's objects (its tasks' too) against the inactive list →
554
+ // refused (SAP would abort with abortrelinacobj; its consistency check
555
+ // does not see inactive objects);
556
+ // 1. the approval (op release, type CTS) — spent only after 0/0b passed;
557
+ // 2. the safety card (Rule 9) when safety mode is on.
558
+ // The envelope carries every step's verdict, SAP's texts and raw reports, what
559
+ // was never sent, the status read back, and that the release is not yet proven
560
+ // live. sap-client writes the audit rows (one per step and one closing row);
561
+ // the tool writes none of its own, except when sap-client threw before any.
562
+ /** Verdicts that mean "SAP would release it if something were ignored" (D11). */
563
+ const IGNORE_VERDICTS = new Set(['relwithignlock', 'relObjigchkatc', 'relwithignwarning']);
564
+ const NOT_YET_PROVEN = "Built from SAP's own release interface and tested against recorded answers; not yet run in a live release. "
565
+ + 'Ask the user to check the transport in SE09 after the release.';
483
566
  registerTool({
484
567
  name: 'sap_transport_release',
485
- description: 'Releasing a transport is not available in this version of CSPeach. '
486
- + 'This tool sends nothing to SAP; tell the user to release the transport in SE09 or Eclipse.',
568
+ description: 'Release a transport request: every open task first, then the request. The verdict comes from SAP\'s own release '
569
+ + 'report, step by step, and the request status is read back; an HTTP answer alone is never taken as released. '
570
+ + 'Stops at the first step SAP does not release; never ignores locks, object-check errors or warnings (release '
571
+ + 'those in SE09 or Eclipse). Built from SAP\'s own release interface and tested against recorded answers; not '
572
+ + 'yet run in a live release, so ask the user to check the transport in SE09 afterwards. Cannot be undone. '
573
+ + 'Requires approval_id from a request_approval change of op `release`, type CTS (one approval per release; '
574
+ + 'never auto-approved).',
487
575
  isMutating: true,
488
576
  requiresSap: true,
577
+ requires: 'cts.requests',
489
578
  input_schema: {
490
579
  type: 'object',
491
580
  properties: {
492
581
  transport: {
493
582
  type: 'string',
494
- description: 'Transport request number (e.g. DEVK900123)',
583
+ description: 'Transport request number to release (e.g. DEVK900123); not a task number',
495
584
  },
496
585
  approval_id: {
497
586
  type: 'string',
498
- description: 'JWT from request_approval (not used: release is not available)',
587
+ description: 'JWT from request_approval (a change of op release, type CTS)',
499
588
  },
500
589
  },
501
- required: ['transport'],
590
+ required: ['transport', 'approval_id'],
591
+ },
592
+ handler: async (args, ctx) => {
593
+ const asked = String(args.transport ?? '').trim().toUpperCase();
594
+ // ── Gate 0: SAP's own facts, read fresh (D10, D12) ──────────────────────
595
+ const read = await readRequest(ctx, asked);
596
+ if ('refusal' in read)
597
+ return read.refusal;
598
+ const { doc } = read;
599
+ if (doc.kind === 'task') {
600
+ return {
601
+ content: JSON.stringify({
602
+ error: 'task_number',
603
+ message: `${asked} is a task of ${doc.number}. Give the request number; CSPeach releases the tasks first, then the request.`,
604
+ }),
605
+ is_error: true,
606
+ };
607
+ }
608
+ if (!isModifiable(doc.status)) {
609
+ return {
610
+ content: JSON.stringify({
611
+ error: 'not_modifiable',
612
+ message: notModifiableText(doc),
613
+ }),
614
+ is_error: true,
615
+ };
616
+ }
617
+ // ── Gate 0b: inactive objects of the request (T14) ──────────────────────
618
+ let warning;
619
+ let inactiveNames;
620
+ try {
621
+ inactiveNames = new Set((await ctx.adt.inactiveObjects()).map((o) => String(o.name ?? '').trim().toUpperCase()));
622
+ }
623
+ catch {
624
+ warning = 'CSPeach could not read the inactive list; SAP aborts a release that holds inactive objects.';
625
+ }
626
+ if (inactiveNames) {
627
+ const blocked = allObjectNames(doc).filter((n) => inactiveNames.has(n));
628
+ if (blocked.length > 0) {
629
+ return {
630
+ content: JSON.stringify({
631
+ error: 'release_blocked_inactive',
632
+ objects: blocked,
633
+ message: `These objects of ${doc.number} are inactive: ${blocked.join(', ')}. `
634
+ + 'Activate them (sap_activate), then ask again. SAP would abort the release.',
635
+ }),
636
+ is_error: true,
637
+ };
638
+ }
639
+ }
640
+ // ── Gate 1: Approval (op release, type CTS) ─────────────────────────────
641
+ const verify = await verifyAndSpendApprovalId(args.approval_id, args.transport, 'release');
642
+ if (!verify.ok) {
643
+ return {
644
+ content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: verify.reason }),
645
+ is_error: true,
646
+ };
647
+ }
648
+ if (typeCode(verify.payload?.type) !== 'CTS') {
649
+ return {
650
+ content: JSON.stringify({ error: ERR.APPROVAL_INVALID, reason: 'type_mismatch' }),
651
+ is_error: true,
652
+ };
653
+ }
654
+ const startedAt = Date.now();
655
+ // ── Gate 2: Safety confirmation (Rule 9 — a release cannot be undone) ───
656
+ if (isSafetyConfirmEnabled()) {
657
+ const openTasks = doc.tasks.filter((t) => isModifiable(t.status)).map((t) => t.number);
658
+ const objects = knownObjectCount(doc);
659
+ const c = await presentSafetyConfirmation({
660
+ rule: 'RULE_9_TRANSPORT_RELEASE',
661
+ op: 'transport_release',
662
+ object: { type: 'TRANSPORT', name: doc.number },
663
+ what_will_happen: `Releases ${openTasks.length} task(s)${openTasks.length ? ` (${openTasks.join(', ')})` : ''} then request ${doc.number} `
664
+ + `(${objects === null ? 'an uncounted number of' : objects} objects, target ${doc.target || 'none'}). `
665
+ + `Cannot be undone; the request may move to other systems on the route. ${RELEASE_CHECK_LINE}`,
666
+ rollback_available: false,
667
+ transport: doc.number,
668
+ args: { ...args },
669
+ });
670
+ if (!c.confirmed) {
671
+ return {
672
+ content: JSON.stringify({ error: 'cancelled_by_user', reason: c.reason }),
673
+ is_error: true,
674
+ };
675
+ }
676
+ }
677
+ // ── Release: tasks, then the request, on SAP's verdict ──────────────────
678
+ let r;
679
+ try {
680
+ r = await ctx.adt.transportRelease(doc.number);
681
+ }
682
+ catch (err) {
683
+ // sap-client threw before its first step (its own read): it wrote no row.
684
+ await auditLog('transportRelease', 'CTS', doc.number, 'error', Date.now() - startedAt);
685
+ return {
686
+ content: JSON.stringify({ error: 'transport_release_failed', detail: err instanceof Error ? err.message : String(err) }),
687
+ is_error: true,
688
+ };
689
+ }
690
+ const finalStatus = r.finalStatus?.status;
691
+ // Never `released: true` beside a status that is not R or N.
692
+ const released = r.status === 'released' && (finalStatus === 'R' || finalStatus === 'N');
693
+ let note;
694
+ switch (r.status) {
695
+ case 'released':
696
+ // Worded from `released`, never from r.status alone: "Released." only beside released: true.
697
+ note = released
698
+ ? "Released. SAP's answer for each step is in steps[] and sap_answer[]."
699
+ : `SAP reported every step released, but the request reads status ${finalStatus || 'unknown'}. Check it in SE09.`;
700
+ break;
701
+ case 'stopped': {
702
+ const last = r.steps[r.steps.length - 1];
703
+ note = `${r.error ?? ''} Nothing was sent after that step.`.trim();
704
+ if (last && IGNORE_VERDICTS.has(last.verdict)) {
705
+ note += ' CSPeach does not release with anything ignored. Release it in SE09 or Eclipse if that is what you want.';
706
+ }
707
+ break;
708
+ }
709
+ case 'refused':
710
+ note = r.refusal ?? 'Nothing was sent.';
711
+ break;
712
+ default:
713
+ note = r.error ?? `The release ended with status ${r.status}.`;
714
+ }
715
+ const detail = r.refusal ?? r.error;
716
+ const stopStep = r.status === 'stopped' ? r.steps[r.steps.length - 1] : undefined;
717
+ const envelope = {
718
+ released,
719
+ status: r.status,
720
+ // Near the top so a spilled preview (and the audit row read from it) keeps the stopping step.
721
+ ...(stopStep ? { stopped_at: stopStep.number, stop_verdict: stopStep.verdict } : {}),
722
+ transport_number: r.transportNumber,
723
+ final_status: finalStatus,
724
+ not_sent: r.notSent ?? [],
725
+ note,
726
+ ...(detail ? { error_detail: detail } : {}),
727
+ ...(warning ? { warning } : {}),
728
+ not_yet_proven: NOT_YET_PROVEN,
729
+ steps: r.steps.map(({ rawReports: _raw, ...s }) => s),
730
+ sap_answer: r.steps.map((s) => ({ step: s.number, raw: s.rawReports ?? '' })),
731
+ };
732
+ // Pretty-printed with the facts first: a spilled preview keeps the verdict
733
+ // lines (a one-line JSON would be cut at the preview's line limit).
734
+ const failed = r.status === 'stopped' || r.status === 'error' || r.status === 'refused';
735
+ return spillToolResult({ content: JSON.stringify(envelope, null, 2), ...(failed ? { is_error: true } : {}) }, ctx, 'report', { label: `release ${r.transportNumber}` });
502
736
  },
503
- handler: async (args) => ({
504
- content: JSON.stringify({
505
- error: 'release_unavailable',
506
- transport: args.transport,
507
- message: RELEASE_UNAVAILABLE,
508
- }),
509
- is_error: true,
510
- }),
511
737
  });
@@ -23,6 +23,8 @@ export const WRITE_TOOLS = new Set([
23
23
  'sap_transport_release',
24
24
  // Final review I2 — SAP's RAP generator creates the skeleton objects.
25
25
  'sap_generate_objects',
26
+ // Part 3 — a run executes code on the SAP system; advisory mode never runs it.
27
+ 'sap_run',
26
28
  ]);
27
29
  export function isWriteTool(name) {
28
30
  return WRITE_TOOLS.has(name);
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useRef, useState } from 'react';
3
3
  import { Box, Text, useInput, useStdout } from 'ink';
4
4
  import { riskReasonText, shownRiskReasons } from '../approvals/risk-floor.js';
5
- import { verbLine, viewAffordance, toSourceLines, affectsLine } from '../approvals/op-labels.js';
5
+ import { verbLine, viewAffordance, toSourceLines, affectsLine, releaseLines } from '../approvals/op-labels.js';
6
6
  // BUG-1 (2026-07-05 live smoke): the interactive approval modal mounted
7
7
  // DEAD — every keypress ignored — when it appeared after a guarded
8
8
  // /abap-plan turn. Root cause is identical to the one AskCard
@@ -39,10 +39,18 @@ export function riskRows(effectiveRisk, width) {
39
39
  * decision input too: its "Affects:" text wraps onto up to this many rows.
40
40
  */
41
41
  export const AFFECTS_MAX_ROWS = 2;
42
- /** The "Affects: …" rows under the verb line; none when the change names no process. */
42
+ /**
43
+ * The rows under the verb line: "Affects: …" (none when the change names no
44
+ * process), then — Part 3 D12 — a release card's fact lines, each wrapping
45
+ * onto up to AFFECTS_MAX_ROWS rows. Any other change: exactly as before.
46
+ */
43
47
  export function affectsRows(change, width) {
48
+ const cols = Math.max(1, width - MODAL_CHROME_COLS);
44
49
  const line = affectsLine(change);
45
- return line ? wrapRows(line, Math.max(1, width - MODAL_CHROME_COLS), AFFECTS_MAX_ROWS) : [];
50
+ return [
51
+ ...(line ? wrapRows(line, cols, AFFECTS_MAX_ROWS) : []),
52
+ ...releaseLines(change).flatMap((l) => wrapRows(l, cols, AFFECTS_MAX_ROWS)),
53
+ ];
46
54
  }
47
55
  /**
48
56
  * The collapsed card's rendered height at `width` (the live-area budget; the
@@ -14,7 +14,7 @@ const MUTATING_TOOLS = new Set([
14
14
  'sap_set_source', 'sap_update_method', 'sap_delete_object', 'sap_activate',
15
15
  'sap_create_object', 'sap_transport_release', 'sap_message_maintain',
16
16
  'sap_number_range_intervals', 'sap_service_binding_publish',
17
- 'sap_service_binding_unpublish',
17
+ 'sap_service_binding_unpublish', 'sap_run',
18
18
  ]);
19
19
  export function toTimelineEntry(call) {
20
20
  // args shape depends on the tool; extract defensively.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cspeach/cli",
3
- "version": "1.1.44",
3
+ "version": "1.1.45",
4
4
  "description": "CSPeach — AI-assisted ABAP development CLI. Direct SAP access, signed skills, safety gates.",
5
5
  "keywords": [
6
6
  "abap",
@@ -54,7 +54,7 @@
54
54
  "@anthropic-ai/sdk": "^0.90.0",
55
55
  "@azure/msal-node": "6.0.1",
56
56
  "@cspeach/register-core": "0.4.0",
57
- "@cspeach/sap-client": "0.1.28",
57
+ "@cspeach/sap-client": "0.1.29",
58
58
  "@iarna/toml": "^2.2.5",
59
59
  "@inkjs/ui": "^2.0.0",
60
60
  "@inquirer/prompts": "^7.0.0",