@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.
Files changed (60) hide show
  1. package/dist/agent/chat-prompt.js +47 -0
  2. package/dist/agent/chat-unavailable.js +9 -0
  3. package/dist/agent/level-state.js +11 -0
  4. package/dist/agent/level.js +60 -0
  5. package/dist/agent/loop.js +110 -2
  6. package/dist/agent/parallel-write-guard.js +2 -0
  7. package/dist/agent/provider-shape.js +6 -2
  8. package/dist/agent/providers/ai-hub-provider.js +3 -1
  9. package/dist/agent/providers/azure-provider.js +3 -1
  10. package/dist/agent/providers/byok-provider.js +3 -1
  11. package/dist/agent/providers/local-provider.js +3 -1
  12. package/dist/agent/style-state.js +11 -0
  13. package/dist/agent/style.js +59 -0
  14. package/dist/agent/system-prompt.js +24 -3
  15. package/dist/agent/tool-dispatch.js +55 -0
  16. package/dist/approvals/jwt.js +5 -2
  17. package/dist/approvals/op-labels.js +39 -4
  18. package/dist/approvals/render.js +36 -9
  19. package/dist/approvals/risk-floor.js +14 -3
  20. package/dist/cli.js +8 -0
  21. package/dist/commands/config-set.js +59 -1
  22. package/dist/commands/config-show.js +1 -0
  23. package/dist/commands/export-docx.js +66 -0
  24. package/dist/commands/help.js +28 -2
  25. package/dist/config/loader.js +51 -1
  26. package/dist/docx/doc-meta.js +47 -0
  27. package/dist/docx/markdown-to-docx.js +250 -0
  28. package/dist/docx/write-docx.js +54 -0
  29. package/dist/one-shot.js +11 -0
  30. package/dist/projects/document-save.js +62 -0
  31. package/dist/projects/document-skills.js +9 -0
  32. package/dist/renderer/footer-line.js +6 -1
  33. package/dist/renderer/progress-chatter.js +9 -2
  34. package/dist/renderer/startup-lines.js +6 -2
  35. package/dist/renderer/tool-labels.js +5 -2
  36. package/dist/renderer/tool-widget.js +5 -1
  37. package/dist/repl/builtin-commands.js +4 -1
  38. package/dist/repl/chat-command.js +64 -0
  39. package/dist/repl/chat-lane.js +135 -0
  40. package/dist/repl/level-command.js +79 -0
  41. package/dist/repl/style-command.js +67 -0
  42. package/dist/repl.js +187 -105
  43. package/dist/sap/profile-features.js +5 -0
  44. package/dist/session/audit-export.js +50 -2
  45. package/dist/session/queued-step.js +5 -1
  46. package/dist/session/schema.js +1 -0
  47. package/dist/session/store.js +4 -0
  48. package/dist/tools/approval.js +59 -1
  49. package/dist/tools/hand-off.js +86 -0
  50. package/dist/tools/release-texts.js +9 -0
  51. package/dist/tools/sap-read.js +6 -2
  52. package/dist/tools/sap-run.js +163 -0
  53. package/dist/tools/tool-loading.js +11 -7
  54. package/dist/tools/transport.js +248 -22
  55. package/dist/tools/write-mode.js +2 -0
  56. package/dist/ui/approval-modal.js +11 -3
  57. package/dist/ui/sap-state-store.js +1 -0
  58. package/dist/ui/session-timeline.js +1 -1
  59. package/dist/ui/widgets/ask-card.js +2 -1
  60. package/package.json +3 -3
@@ -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
+ }
@@ -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,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) {