@cspeach/cli 1.1.18 → 1.1.19

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 (224) hide show
  1. package/README.md +3 -4
  2. package/dist/agent/loop.js +143 -25
  3. package/dist/agent/providers/local-provider.js +4 -1
  4. package/dist/agent/repair-partial.js +76 -19
  5. package/dist/agent/tool-dispatch.js +58 -21
  6. package/dist/agent/turn-error-ux.js +77 -26
  7. package/dist/agent/turn-stream.js +25 -3
  8. package/dist/approvals/adt-type.js +27 -0
  9. package/dist/approvals/advisory-prompt.js +48 -0
  10. package/dist/approvals/advisory-render.js +25 -11
  11. package/dist/approvals/approval-prompt.js +74 -24
  12. package/dist/approvals/jwt.js +2 -1
  13. package/dist/approvals/render.js +64 -36
  14. package/dist/approvals/risk-floor.js +53 -2
  15. package/dist/auth/api-key.js +30 -16
  16. package/dist/auth/keychain.js +0 -0
  17. package/dist/auth/me.js +25 -7
  18. package/dist/cli.js +25 -6
  19. package/dist/commands/compact.js +12 -9
  20. package/dist/commands/config-set.js +6 -2
  21. package/dist/commands/config-show.js +7 -5
  22. package/dist/commands/cost.js +41 -34
  23. package/dist/commands/help.js +88 -50
  24. package/dist/commands/login.js +28 -19
  25. package/dist/commands/logout.js +7 -3
  26. package/dist/commands/plan-chain.js +71 -77
  27. package/dist/commands/plan-continue.js +1 -0
  28. package/dist/commands/plan-resume.js +20 -12
  29. package/dist/commands/whoami.js +23 -9
  30. package/dist/config/loader.js +15 -3
  31. package/dist/cost/pricing.js +6 -5
  32. package/dist/doctor/checks/forge-rules.js +13 -3
  33. package/dist/doctor/checks/keychain.js +6 -4
  34. package/dist/doctor/run.js +101 -20
  35. package/dist/index.js +5 -2
  36. package/dist/lib/graceful-exit.js +56 -0
  37. package/dist/lib/piped-prompt.js +65 -0
  38. package/dist/lock-contention.js +2 -2
  39. package/dist/one-shot.js +135 -40
  40. package/dist/projects/answer-blockers.js +6 -1
  41. package/dist/projects/handover-md.js +15 -14
  42. package/dist/projects/plan-run.js +97 -54
  43. package/dist/projects/promote-command.js +11 -2
  44. package/dist/projects/save-command.js +3 -2
  45. package/dist/projects/status.js +2 -1
  46. package/dist/projects/yes-no.js +12 -0
  47. package/dist/renderer/abap-inline.js +18 -14
  48. package/dist/renderer/answer-trim.js +69 -0
  49. package/dist/renderer/banners.js +8 -8
  50. package/dist/renderer/brand-settled.js +19 -0
  51. package/dist/renderer/change-card.js +156 -0
  52. package/dist/renderer/checklist-format.js +130 -0
  53. package/dist/renderer/color-mode.js +45 -0
  54. package/dist/renderer/error-detail.js +104 -0
  55. package/dist/renderer/fence-state.js +46 -0
  56. package/dist/renderer/footer-line.js +96 -0
  57. package/dist/renderer/glyphs.js +25 -0
  58. package/dist/renderer/goodbye.js +38 -0
  59. package/dist/renderer/legacy-palette.js +41 -0
  60. package/dist/renderer/look.js +30 -0
  61. package/dist/renderer/markdown.js +299 -92
  62. package/dist/renderer/notice-log.js +72 -0
  63. package/dist/renderer/notice-shape.js +48 -0
  64. package/dist/renderer/notices.js +40 -8
  65. package/dist/renderer/panel-rows.js +16 -0
  66. package/dist/renderer/pipeline.js +66 -0
  67. package/dist/renderer/progress-chatter.js +23 -17
  68. package/dist/renderer/rendering-mode.js +34 -0
  69. package/dist/renderer/routing-line.js +14 -0
  70. package/dist/renderer/sanitize.js +12 -0
  71. package/dist/renderer/severity.js +2 -7
  72. package/dist/renderer/startup-lines.js +152 -0
  73. package/dist/renderer/status-footer.js +52 -34
  74. package/dist/renderer/steering-echo.js +41 -0
  75. package/dist/renderer/syntax.js +31 -12
  76. package/dist/renderer/tables.js +5 -1
  77. package/dist/renderer/theme.js +44 -0
  78. package/dist/renderer/thinking-heartbeat.js +33 -2
  79. package/dist/renderer/todo-block.js +10 -49
  80. package/dist/renderer/tool-labels.js +238 -0
  81. package/dist/renderer/tool-widget.js +245 -51
  82. package/dist/renderer/trace.js +42 -0
  83. package/dist/renderer/transcript-flow.js +132 -0
  84. package/dist/renderer/tty.js +34 -1
  85. package/dist/renderer/ui-width.js +42 -0
  86. package/dist/renderer/verify-chain.js +4 -2
  87. package/dist/renderer/widget-fallback.js +58 -65
  88. package/dist/repl/bracketed-paste.js +7 -1
  89. package/dist/repl/builtin-commands.js +19 -8
  90. package/dist/repl/credential-handover-gate.js +28 -0
  91. package/dist/repl/early-line-buffer.js +5 -2
  92. package/dist/repl/file-picker.js +54 -12
  93. package/dist/repl/ink-stdin-guard.js +66 -0
  94. package/dist/repl/inquirer-guard.js +59 -16
  95. package/dist/repl/inquirer-theme.js +27 -33
  96. package/dist/repl/paste-marker.js +19 -0
  97. package/dist/repl/plan-turn-end.js +20 -0
  98. package/dist/repl/post-turn-status.js +36 -11
  99. package/dist/repl/reroute-turn.js +21 -0
  100. package/dist/repl/reset-tty-stdin.js +44 -0
  101. package/dist/repl/restore-guard.js +22 -0
  102. package/dist/repl/resume-standalone.js +63 -0
  103. package/dist/repl/rule8-detector.js +11 -3
  104. package/dist/repl/safety-confirm.js +161 -93
  105. package/dist/repl/session-spend-line.js +8 -4
  106. package/dist/repl/themed-prompts.js +19 -0
  107. package/dist/repl/ui-look-command.js +75 -0
  108. package/dist/repl/update-method-preview-hook.js +48 -5
  109. package/dist/repl.js +795 -332
  110. package/dist/rewind/cli.js +3 -2
  111. package/dist/rewind/format.js +14 -9
  112. package/dist/rewind/restore.js +11 -0
  113. package/dist/router/classifier.js +47 -4
  114. package/dist/router/intent-extractor.js +24 -8
  115. package/dist/router/piped-routing.js +23 -0
  116. package/dist/router/resume-routing.js +16 -0
  117. package/dist/router/routing-failure.js +57 -0
  118. package/dist/sap/first-run-choice.js +1 -1
  119. package/dist/sap/onboarding.js +3 -2
  120. package/dist/sap/standalone-onboarding.js +1 -1
  121. package/dist/sap/system-info.js +4 -2
  122. package/dist/sap/unreachable.js +28 -0
  123. package/dist/sap-errors/clean-error-text.js +182 -0
  124. package/dist/session/interrupt-reason.js +39 -0
  125. package/dist/session/recap.js +36 -24
  126. package/dist/session/resume.js +104 -36
  127. package/dist/session/store.js +69 -27
  128. package/dist/session/user-prompt.js +21 -0
  129. package/dist/skill-catalog.js +7 -0
  130. package/dist/skills/bundled-skills.js +76 -69
  131. package/dist/standards/standards-init.js +1 -1
  132. package/dist/test-helpers/answer-any.js +46 -0
  133. package/dist/tools/approval.js +27 -11
  134. package/dist/tools/ask-question.js +47 -5
  135. package/dist/tools/filesystem/file-write.js +19 -2
  136. package/dist/tools/fiori/fe-extend.js +16 -1
  137. package/dist/tools/fiori/fe-scaffold.js +105 -3
  138. package/dist/tools/fiori/html-escapes.js +28 -0
  139. package/dist/tools/fiori/metadata/audit.js +605 -0
  140. package/dist/tools/fiori/metadata/references.js +104 -0
  141. package/dist/tools/fiori/metadata/types.js +8 -0
  142. package/dist/tools/fiori/preview/app-guard.js +215 -0
  143. package/dist/tools/fiori/preview/env.js +193 -0
  144. package/dist/tools/fiori/preview/readiness.js +127 -0
  145. package/dist/tools/fiori/preview/registry.js +412 -0
  146. package/dist/tools/fiori/preview/start.js +469 -0
  147. package/dist/tools/fiori/samples/loader.js +20 -5
  148. package/dist/tools/fiori/scaffold.js +18 -6
  149. package/dist/tools/fiori/smoke/app-driver-page.js +372 -0
  150. package/dist/tools/fiori/smoke/app-driver.js +100 -0
  151. package/dist/tools/fiori/smoke/assertions.js +161 -24
  152. package/dist/tools/fiori/smoke/audit-columns.js +21 -0
  153. package/dist/tools/fiori/smoke/batch.js +273 -0
  154. package/dist/tools/fiori/smoke/draft-safety.js +241 -0
  155. package/dist/tools/fiori/smoke/draft-smoke.js +473 -0
  156. package/dist/tools/fiori/smoke/driver.js +171 -4
  157. package/dist/tools/fiori/smoke/evidence.js +35 -0
  158. package/dist/tools/fiori/smoke/labels-codes.js +207 -0
  159. package/dist/tools/fiori/smoke/nav-error.js +26 -0
  160. package/dist/tools/fiori/smoke/run-smoke.js +155 -12
  161. package/dist/tools/fiori/tools.js +651 -7
  162. package/dist/tools/fiori/ui5-version.js +16 -0
  163. package/dist/tools/index.js +8 -0
  164. package/dist/tools/local-build.js +6 -0
  165. package/dist/tools/sap-read.js +16 -1
  166. package/dist/tools/sap-write.js +122 -10
  167. package/dist/tools/shell/shell_exec.js +30 -12
  168. package/dist/tools/syntax-state.js +67 -0
  169. package/dist/tools/todo.js +23 -18
  170. package/dist/tools/transport-fit.js +462 -0
  171. package/dist/tools/transport-resolution.js +3 -1
  172. package/dist/tools/transport.js +33 -7
  173. package/dist/tools/verify.js +184 -8
  174. package/dist/ui/app.js +284 -146
  175. package/dist/ui/approval-modal.js +41 -15
  176. package/dist/ui/ask-answer-text.js +17 -0
  177. package/dist/ui/body.js +25 -6
  178. package/dist/ui/card-slot.js +98 -0
  179. package/dist/ui/checklist.js +9 -0
  180. package/dist/ui/coaching-picker-classic.js +8 -5
  181. package/dist/ui/command-palette.js +6 -4
  182. package/dist/ui/confirm-request.js +18 -0
  183. package/dist/ui/context-grid.js +2 -1
  184. package/dist/ui/file-palette.js +6 -4
  185. package/dist/ui/files-card-emitter.js +19 -0
  186. package/dist/ui/footer-line.js +16 -0
  187. package/dist/ui/footer.js +105 -104
  188. package/dist/ui/input-wrap.js +122 -0
  189. package/dist/ui/interrupt.js +103 -0
  190. package/dist/ui/key-burst.js +117 -0
  191. package/dist/ui/line-resolution.js +4 -3
  192. package/dist/ui/live-area.js +71 -0
  193. package/dist/ui/login-banner.js +54 -43
  194. package/dist/ui/rewind-panel.js +76 -24
  195. package/dist/ui/role-props.js +47 -0
  196. package/dist/ui/sap-state-store.js +1 -0
  197. package/dist/ui/session-timeline.js +49 -3
  198. package/dist/ui/skill-picker.js +5 -47
  199. package/dist/ui/text-input.js +128 -24
  200. package/dist/ui/todo-emitter.js +19 -0
  201. package/dist/ui/turn-status-emitter.js +28 -2
  202. package/dist/ui/turn-status.js +16 -19
  203. package/dist/ui/use-card-request.js +44 -0
  204. package/dist/ui/widgets/ask-card.js +521 -0
  205. package/dist/ui/widgets/bar-chart.js +11 -5
  206. package/dist/ui/widgets/coaching-picker.js +50 -55
  207. package/dist/ui/widgets/confirm-card.js +193 -0
  208. package/dist/ui/widgets/dep-graph.js +3 -2
  209. package/dist/ui/widgets/diff-viewer.js +6 -5
  210. package/dist/ui/widgets/files-card.js +97 -0
  211. package/dist/ui/widgets/question-card.js +2 -1
  212. package/dist/ui/widgets/reclaim-raw-stdin.js +24 -0
  213. package/dist/ui/widgets/shortcuts-sheet.js +25 -0
  214. package/dist/ui/widgets/stack-frames.js +2 -1
  215. package/dist/upgrade-check.js +39 -8
  216. package/package.json +11 -2
  217. package/test-harness/fe-draft/app.ts +188 -0
  218. package/test-harness/fe-draft/fe-draft.e2e.test.ts +461 -0
  219. package/test-harness/fe-draft/manifest-enhancer.unit.test.ts +51 -0
  220. package/test-harness/fe-draft/routes.ts +224 -0
  221. package/test-harness/fe-draft/routes.unit.test.ts +45 -0
  222. package/test-harness/fe-draft/server.ts +225 -0
  223. package/test-harness/fe-draft/variants.ts +196 -0
  224. package/vitest.smoke-e2e.config.ts +20 -0
@@ -0,0 +1,462 @@
1
+ /**
2
+ * transport-fit — which open requests can take an object of a given package
3
+ * (fix round 3, item 9e; fix round 4, item 3).
4
+ *
5
+ * Live defect: sap_transport_list returned 20 open requests with EMPTY
6
+ * description / target (the list endpoint does not fill them on this system),
7
+ * so the model offered S4HK903346 (no target) and S4HK903342 for a ZTEST_LAS
8
+ * object and SAP refused both: "CTS_WBO_API/048: Request … does not have
9
+ * transport target CL5". It also offered S4HK903729, which belongs to other
10
+ * work — its description was invisible to it.
11
+ *
12
+ * What the SAP data allows (all read-only, data preview):
13
+ * - E070.TARSYSTEM / TRFUNCTION / TRSTATUS — each request's target, type, status;
14
+ * - E07T.AS4TEXT — each request's description;
15
+ * - TDEVC.PDEVCLASS — the package's transport layer;
16
+ * - TCERELE (VERSION, INTSYS, TRANSLAYER → CONSYS) — the layer's
17
+ * consolidation target, restricted to the ACTIVE route version
18
+ * (TCEVERS ACTIVE = 'A') and this system (the request-number prefix).
19
+ * Round 4: round 3 read TCETRAL.CONSYS — TCETRAL has only VERSION +
20
+ * TRANSLAYER (verified live on S4H), so there was never a verdict.
21
+ * A request fits the package when it is a workbench request (K) whose target
22
+ * equals that consolidation target. When the package target, the request's
23
+ * target or its type is unknown, NO verdict is given — an unfit request is
24
+ * never marked as fitting.
25
+ *
26
+ * Data preview rejects a query line over 255 characters, so the IN-lists are
27
+ * chunked (round 4: ~20 requests in one query failed and every field came
28
+ * back empty). Strictly best-effort: a failed lookup leaves those fields as
29
+ * SAP returned them, gives no verdict, and writes a CSPEACH_DEBUG line.
30
+ */
31
+ const TRKORR = /^[A-Z0-9]{1,20}$/;
32
+ const PACKAGE = /^[A-Z0-9_/$]{1,30}$/;
33
+ const REQUEST_SID = /^([A-Z0-9]{3})K\d+$/;
34
+ /** Data preview refuses a query line over 255 chars; stay well under it. */
35
+ export const MAX_QUERY_CHARS = 230;
36
+ function debug(msg) {
37
+ if (process.env['CSPEACH_DEBUG'] !== '1')
38
+ return;
39
+ process.stderr.write(`[transport-fit] ${msg}\n`);
40
+ }
41
+ /** Rows of one query, or null when it failed (logged, never thrown). */
42
+ async function rows(adt, sql, max, what) {
43
+ try {
44
+ return (await adt.sqlQuery(sql, max)).rows ?? [];
45
+ }
46
+ catch (err) {
47
+ const why = err instanceof Error ? err.message : String(err);
48
+ debug(`${what} lookup failed, no ranking from it: ${why.slice(0, 200)}`);
49
+ return null;
50
+ }
51
+ }
52
+ /** Build `prefix 'A', 'B' suffix` queries, each at most MAX_QUERY_CHARS long. */
53
+ export function chunkInList(prefix, suffix, keys) {
54
+ const build = (ks) => `${prefix}${ks.map((k) => `'${k}'`).join(', ')}${suffix}`;
55
+ const out = [];
56
+ let cur = [];
57
+ for (const k of keys) {
58
+ if (cur.length > 0 && build([...cur, k]).length > MAX_QUERY_CHARS) {
59
+ out.push({ sql: build(cur), keys: cur });
60
+ cur = [];
61
+ }
62
+ cur.push(k);
63
+ }
64
+ if (cur.length > 0)
65
+ out.push({ sql: build(cur), keys: cur });
66
+ return out;
67
+ }
68
+ /** sap-client caps any data-preview query at this many rows. */
69
+ const CLIENT_ROW_CAP = 5000;
70
+ /** Chunks run at most this many at a time (round 5). */
71
+ const CHUNK_CONCURRENCY = 4;
72
+ /** Run a chunked IN-list query; returns the rows and the keys whose chunk failed. */
73
+ async function chunkedLookup(adt, prefix, suffix, keys, perKey, what, maxChunks = Infinity) {
74
+ const all = chunkInList(prefix, suffix, keys);
75
+ // Round 5b — cap the lookups: keys past the cap are not read (no verdict).
76
+ const chunks = all.slice(0, maxChunks);
77
+ const skipped = all.slice(chunks.length);
78
+ if (skipped.length > 0)
79
+ debug(`${what}: ${skipped.length} lookup(s) past the cap skipped, those requests get no verdict`);
80
+ const results = new Array(chunks.length).fill(null);
81
+ let next = 0;
82
+ const worker = async () => {
83
+ while (next < chunks.length) {
84
+ const i = next++;
85
+ const c = chunks[i];
86
+ // Round 5b — ask for one row more than the budget: getting it back (or
87
+ // hitting the client-wide cap) means rows may be missing — not read.
88
+ const max = c.keys.length * perKey;
89
+ const r = await rows(adt, c.sql, max + 1, what);
90
+ const capped = r !== null && (r.length > max || r.length >= CLIENT_ROW_CAP);
91
+ if (capped)
92
+ debug(`${what} lookup hit its row cap (${max}), those requests get no verdict`);
93
+ results[i] = capped ? null : r;
94
+ }
95
+ };
96
+ await Promise.all(Array.from({ length: Math.min(CHUNK_CONCURRENCY, chunks.length) }, worker));
97
+ const failed = new Set();
98
+ const out = [];
99
+ results.forEach((r, i) => {
100
+ if (r)
101
+ out.push(...r);
102
+ else
103
+ chunks[i].keys.forEach((k) => failed.add(k));
104
+ });
105
+ for (const c of skipped)
106
+ c.keys.forEach((k) => failed.add(k));
107
+ return { rows: out, failed };
108
+ }
109
+ /** Run a chunked IN-list query; a failed chunk contributes no rows. */
110
+ async function chunkedRows(adt, prefix, suffix, keys, perKey, what) {
111
+ return (await chunkedLookup(adt, prefix, suffix, keys, perKey, what)).rows;
112
+ }
113
+ /** LIMU piece types that belong to a class: the class name is the first 30 characters. */
114
+ const CLASS_PIECES = new Set(['METH', 'CINC', 'CLSD', 'CPUB', 'CPRO', 'CPRI', 'CDEF', 'CPAK']);
115
+ const OBJ_NAME = /^[A-Z0-9_/$]{1,40}$/;
116
+ /** Round 5b — at most this many TADIR lookups per list; the rest get no verdict. */
117
+ export const MAX_TADIR_CHUNKS = 12;
118
+ /** Round 5b — how many (newest) candidate requests have their contents read. */
119
+ export const MAX_CONTENT_REQUESTS = 12;
120
+ /** A valid function-group name (optional /NAMESPACE/, at most 26 characters after it). */
121
+ const FUGR_NAME = /^(\/[A-Z0-9_]{1,10}\/)?[A-Z0-9_]{1,26}$/;
122
+ /**
123
+ * The function group of a function-group program name (SAPL<fg> or L<fg>xxx),
124
+ * if it can be one. Round 5c — only a valid FUGR name is ever derived; it is
125
+ * used only when TADIR returns that FUGR and has no PROG row for the name.
126
+ */
127
+ function fugrOfInclude(name) {
128
+ const m = /^(\/[A-Z0-9_]+\/)?(?:SAPL([A-Z0-9_]+)|L([A-Z0-9_]+)[A-Z0-9$]{3})$/.exec(name);
129
+ if (!m)
130
+ return undefined;
131
+ const fg = `${m[1] ?? ''}${m[2] ?? m[3]}`;
132
+ return FUGR_NAME.test(fg) ? fg : undefined;
133
+ }
134
+ /** Round 5c — LIMU pieces whose R3TR owner has the same name. */
135
+ const SAME_NAME_OWNER = { TABD: 'TABL', DTED: 'DTEL', DOMD: 'DOMA', VIED: 'VIEW', TTYD: 'TTYP', SHLD: 'SHLP', ENQD: 'ENQU' };
136
+ /** The leading field of a fixed-width E071 key (padded to `width`), or its first token. */
137
+ function keyHead(raw, width) {
138
+ return (raw.length > width ? raw.slice(0, width) : raw.split(/\s+/)[0] ?? '').trim();
139
+ }
140
+ /** The class of a class-pool include name (ZCL_X=====CCIMP, ZCL_X=====CM001), if it is one. */
141
+ function classOfInclude(name) {
142
+ const m = /^([^=]+)=+[A-Z0-9]{2,5}$/.exec(name);
143
+ return m ? m[1] : undefined;
144
+ }
145
+ /**
146
+ * How an E071 row maps to a package lookup; 'ignore' for rows that are not
147
+ * package work (CORR entries, documentation); null when it cannot be told —
148
+ * round 5b: such a row makes the request "not read" (never called clean).
149
+ */
150
+ function resolveRow(r) {
151
+ const pgmid = (r['PGMID'] ?? '').trim();
152
+ const object = (r['OBJECT'] ?? '').trim();
153
+ const raw = r['OBJ_NAME'] ?? '';
154
+ if (pgmid === 'CORR' || object === 'DOCU')
155
+ return 'ignore';
156
+ let res = null;
157
+ if (pgmid === 'R3TR')
158
+ res = { via: 'tadir', object, name: raw.trim() };
159
+ else if (pgmid === 'LIMU') {
160
+ if (CLASS_PIECES.has(object))
161
+ res = { via: 'tadir', object: 'CLAS', name: raw.slice(0, 30).replace(/[= ]+$/, '') };
162
+ else if (SAME_NAME_OWNER[object])
163
+ res = { via: 'tadir', object: SAME_NAME_OWNER[object], name: raw.trim() };
164
+ else if (object === 'INDX')
165
+ res = { via: 'tadir', object: 'TABL', name: keyHead(raw, 30) };
166
+ else if (object === 'MESS') {
167
+ const name = raw.length > 20 ? raw.slice(0, 20).trim() : raw.trim().replace(/\d{3}$/, '').trim();
168
+ res = { via: 'tadir', object: 'MSAG', name };
169
+ }
170
+ else if (object === 'DYNP' || object === 'CUAD') {
171
+ const prog = object === 'DYNP' ? keyHead(raw, 40) : raw.trim();
172
+ res = { via: 'tadir', object: 'PROG', name: prog, fugr: fugrOfInclude(prog) };
173
+ }
174
+ else if (object === 'REPS' || object === 'REPT') {
175
+ const name = raw.trim();
176
+ const cls = classOfInclude(name);
177
+ // Round 5c — '=' is allowed only here, parsed into the class name; the
178
+ // value that reaches SQL is the class name and must pass OBJ_NAME.
179
+ res = cls ? { via: 'tadir', object: 'CLAS', name: cls } : { via: 'tadir', object: 'PROG', name, fugr: fugrOfInclude(name) };
180
+ }
181
+ else if (object === 'FUNC')
182
+ res = { via: 'func', name: raw.trim() };
183
+ }
184
+ if (!res || !OBJ_NAME.test(res.name))
185
+ return null;
186
+ return res;
187
+ }
188
+ async function requestPackages(adt, requests) {
189
+ const out = new Map(requests.map((r) => [r, { pkgs: new Set(), complete: true }]));
190
+ if (requests.length === 0)
191
+ return out;
192
+ const rank = new Map(requests.map((r, i) => [r, i]));
193
+ const notRead = (request, why) => {
194
+ const h = out.get(request);
195
+ if (!h)
196
+ return;
197
+ if (h.complete)
198
+ debug(`${request}: ${why}, not called clean`);
199
+ h.complete = false;
200
+ };
201
+ const tasks = await chunkedLookup(adt, 'SELECT TRKORR, STRKORR FROM E070 WHERE STRKORR IN (', ')', requests, 20, 'E070 tasks');
202
+ const owner = new Map(requests.map((r) => [r, r]));
203
+ for (const t of tasks.rows) {
204
+ const task = (t['TRKORR'] ?? '').trim();
205
+ const parent = (t['STRKORR'] ?? '').trim();
206
+ if (TRKORR.test(task) && out.has(parent))
207
+ owner.set(task, parent);
208
+ }
209
+ for (const r of tasks.failed)
210
+ notRead(r, 'tasks not read');
211
+ const e071 = await chunkedLookup(adt, 'SELECT TRKORR, PGMID, OBJECT, OBJ_NAME FROM E071 WHERE TRKORR IN (', ')', [...owner.keys()], 200, 'E071');
212
+ for (const k of e071.failed)
213
+ notRead(owner.get(k), 'objects not read');
214
+ const items = [];
215
+ for (const r of e071.rows) {
216
+ const request = owner.get((r['TRKORR'] ?? '').trim());
217
+ if (!request)
218
+ continue;
219
+ const res = resolveRow(r);
220
+ if (res === 'ignore')
221
+ continue;
222
+ if (!res) {
223
+ notRead(request, `object ${(r['PGMID'] ?? '').trim()} ${(r['OBJECT'] ?? '').trim()} cannot be resolved`);
224
+ continue;
225
+ }
226
+ items.push({ request, ...res });
227
+ }
228
+ items.sort((a, b) => rank.get(a.request) - rank.get(b.request));
229
+ // Function modules → their function group.
230
+ const funcs = [...new Set(items.filter((i) => i.via === 'func').map((i) => i.name))];
231
+ const fm = await chunkedLookup(adt, 'SELECT FUNCNAME, AREA FROM ENLFDIR WHERE FUNCNAME IN (', ')', funcs, 1, 'ENLFDIR', MAX_TADIR_CHUNKS);
232
+ const groupOf = new Map(fm.rows.map((r) => [(r['FUNCNAME'] ?? '').trim(), (r['AREA'] ?? '').trim()]));
233
+ const lookups = [];
234
+ for (const i of items) {
235
+ if (i.via === 'func') {
236
+ const fg = groupOf.get(i.name);
237
+ if (!fg || !OBJ_NAME.test(fg)) {
238
+ notRead(i.request, `function module ${i.name} not resolved`);
239
+ continue;
240
+ }
241
+ lookups.push({ request: i.request, name: i.name, keys: [{ object: 'FUGR', name: fg }] });
242
+ }
243
+ else {
244
+ const keys = [{ object: i.object, name: i.name }];
245
+ if (i.fugr && OBJ_NAME.test(i.fugr))
246
+ keys.push({ object: 'FUGR', name: i.fugr });
247
+ lookups.push({ request: i.request, name: i.name, keys });
248
+ }
249
+ }
250
+ const names = [...new Set(lookups.flatMap((l) => l.keys.map((k) => k.name)))];
251
+ const tadir = await chunkedLookup(adt, "SELECT OBJECT, OBJ_NAME, DEVCLASS FROM TADIR WHERE PGMID = 'R3TR' AND OBJ_NAME IN (", ')', names, 10, 'TADIR', MAX_TADIR_CHUNKS);
252
+ const pkgOf = new Map(tadir.rows.map((r) => [`${(r['OBJECT'] ?? '').trim()} ${(r['OBJ_NAME'] ?? '').trim()}`, (r['DEVCLASS'] ?? '').trim()]));
253
+ for (const l of lookups) {
254
+ const pkg = l.keys.map((k) => pkgOf.get(`${k.object} ${k.name}`)).find((p) => !!p);
255
+ if (pkg) {
256
+ out.get(l.request).pkgs.add(pkg.toUpperCase());
257
+ continue;
258
+ }
259
+ if (l.keys.some((k) => tadir.failed.has(k.name)))
260
+ notRead(l.request, `package of ${l.name} not read`);
261
+ else
262
+ notRead(l.request, `${l.name} has no TADIR entry`);
263
+ }
264
+ return out;
265
+ }
266
+ /** The package's consolidation target, or undefined when unknown / ambiguous. */
267
+ async function packageTarget(adt, pkg, sid) {
268
+ const name = pkg.toUpperCase().trim();
269
+ if (!PACKAGE.test(name))
270
+ return undefined;
271
+ const dev = await rows(adt, `SELECT DEVCLASS, PDEVCLASS FROM TDEVC WHERE DEVCLASS = '${name}'`, 1, 'TDEVC');
272
+ const layer = (dev?.[0]?.['PDEVCLASS'] ?? '').trim();
273
+ if (!layer || !PACKAGE.test(layer)) {
274
+ if (dev)
275
+ debug(`package ${name} has no transport layer, no ranking`);
276
+ return undefined;
277
+ }
278
+ let routes = await rows(adt, `SELECT VERSION, INTSYS, CONSYS FROM TCERELE WHERE TRANSLAYER = '${layer}'`, 50, 'TCERELE');
279
+ if (!routes || routes.length === 0) {
280
+ if (routes)
281
+ debug(`layer ${layer} has no consolidation route, no ranking`);
282
+ return undefined;
283
+ }
284
+ const active = await rows(adt, `SELECT VERSION FROM TCEVERS WHERE ACTIVE = 'A'`, 5, 'TCEVERS');
285
+ // Review M6 — the read failed: a route of an inactive version could pass as
286
+ // the target, so give no verdict (an empty answer still means "unfiltered").
287
+ if (active === null)
288
+ return undefined;
289
+ const versions = new Set((active ?? []).map((r) => (r['VERSION'] ?? '').trim()).filter(Boolean));
290
+ if (versions.size > 0)
291
+ routes = routes.filter((r) => versions.has((r['VERSION'] ?? '').trim()));
292
+ if (sid) {
293
+ const mine = routes.filter((r) => (r['INTSYS'] ?? '').trim() === sid);
294
+ if (mine.length > 0)
295
+ routes = mine;
296
+ }
297
+ const targets = [...new Set(routes.map((r) => (r['CONSYS'] ?? '').trim()).filter((t) => t.length > 0))];
298
+ if (targets.length !== 1)
299
+ debug(`layer ${layer} routes to ${targets.length} targets, no ranking`);
300
+ return targets.length === 1 ? targets[0] : undefined;
301
+ }
302
+ /**
303
+ * E070-TRFUNCTION request codes (re-review 4). The list endpoint's
304
+ * `tm:type` may carry other text (e.g. "workbench"); only a real TRFUNCTION
305
+ * code counts as a known type — anything else is unknown.
306
+ */
307
+ const TRFUNCTION_CODES = new Set(['K', 'W', 'T', 'S', 'R', 'X', 'Q', 'C', 'O', 'E', 'D', 'M', 'G']);
308
+ function trfunction(type) {
309
+ const c = (type ?? '').trim().toUpperCase();
310
+ return TRFUNCTION_CODES.has(c) ? c : undefined;
311
+ }
312
+ /** The request numbers' system prefix (S4HK903731 → S4H) when they all agree. */
313
+ function systemId(numbers) {
314
+ const ids = new Set();
315
+ for (const n of numbers) {
316
+ const m = REQUEST_SID.exec(n);
317
+ if (m)
318
+ ids.add(m[1]);
319
+ }
320
+ return ids.size === 1 ? [...ids][0] : undefined;
321
+ }
322
+ /**
323
+ * Round 5b — at most `limit` data-preview queries in flight across a whole
324
+ * enrichment (the per-lookup concurrency alone let parallel lookups stack).
325
+ */
326
+ function limited(adt, limit) {
327
+ let active = 0;
328
+ const waiting = [];
329
+ const acquire = async () => {
330
+ if (active < limit) {
331
+ active++;
332
+ return;
333
+ }
334
+ await new Promise((r) => waiting.push(r));
335
+ };
336
+ const release = () => {
337
+ const next = waiting.shift();
338
+ if (next)
339
+ next();
340
+ else
341
+ active--;
342
+ };
343
+ return {
344
+ async sqlQuery(query, maxRows) {
345
+ await acquire();
346
+ try {
347
+ return await adt.sqlQuery(query, maxRows);
348
+ }
349
+ finally {
350
+ release();
351
+ }
352
+ },
353
+ };
354
+ }
355
+ export async function enrichTransportList(rawAdt, transports, pkg) {
356
+ const adt = limited(rawAdt, CHUNK_CONCURRENCY);
357
+ const out = transports.map((t) => ({ ...t }));
358
+ const numbers = out.map((t) => t.transportNumber).filter((n) => TRKORR.test(n));
359
+ /** Requests whose E070 row was read — their target is known (even when empty). */
360
+ const e070Known = new Set();
361
+ if (numbers.length > 0) {
362
+ // Review I4 — English text first; any language only for requests with no
363
+ // English text (a request may carry EN and DE rows).
364
+ const [e070, e07tEn] = await Promise.all([
365
+ chunkedRows(adt, 'SELECT TRKORR, TARSYSTEM, TRFUNCTION, TRSTATUS FROM E070 WHERE TRKORR IN (', ')', numbers, 1, 'E070'),
366
+ chunkedRows(adt, 'SELECT TRKORR, AS4TEXT FROM E07T WHERE TRKORR IN (', ") AND LANGU = 'E'", numbers, 1, 'E07T'),
367
+ ]);
368
+ const head = new Map(e070.map((r) => [r['TRKORR'] ?? '', r]));
369
+ const text = new Map();
370
+ const addTexts = (rs) => {
371
+ for (const r of rs) {
372
+ const k = r['TRKORR'] ?? '';
373
+ const v = (r['AS4TEXT'] ?? '').trim();
374
+ if (v && !text.has(k))
375
+ text.set(k, v);
376
+ }
377
+ };
378
+ addTexts(e07tEn);
379
+ const missing = numbers.filter((n) => !text.has(n));
380
+ if (missing.length > 0) {
381
+ addTexts(await chunkedRows(adt, 'SELECT TRKORR, AS4TEXT FROM E07T WHERE TRKORR IN (', ')', missing, 3, 'E07T'));
382
+ }
383
+ for (const t of out) {
384
+ const h = head.get(t.transportNumber);
385
+ if (h) {
386
+ e070Known.add(t.transportNumber);
387
+ const code = (h['TRFUNCTION'] ?? '').trim();
388
+ if (!t.target)
389
+ t.target = (h['TARSYSTEM'] ?? '').trim();
390
+ // A real TRFUNCTION code beats list text such as "workbench".
391
+ if (!trfunction(t.type) && code)
392
+ t.type = code;
393
+ if (!t.status)
394
+ t.status = (h['TRSTATUS'] ?? '').trim();
395
+ }
396
+ if (!t.description)
397
+ t.description = text.get(t.transportNumber) ?? t.description;
398
+ }
399
+ }
400
+ if (!pkg)
401
+ return { transports: out };
402
+ const target = await packageTarget(adt, pkg, systemId(numbers));
403
+ if (!target)
404
+ return { transports: out };
405
+ // Review I4 — only a workbench request (TRFUNCTION 'K') can take a
406
+ // repository object; a customizing ('W') or transport-of-copies ('T')
407
+ // request on the right target is refused by SAP. Round 4 — verdicts only on
408
+ // what is known: a known wrong type or a known wrong target is unfit; a K
409
+ // request on the package's target fits; anything else gets no verdict.
410
+ for (const t of out) {
411
+ const code = trfunction(t.type);
412
+ const tgt = (t.target ?? '').trim();
413
+ const targetKnown = tgt.length > 0 || e070Known.has(t.transportNumber);
414
+ if (code !== undefined && code !== 'K')
415
+ t.fits_package = false;
416
+ else if (targetKnown && tgt !== target)
417
+ t.fits_package = false;
418
+ else if (code === 'K' && targetKnown)
419
+ t.fits_package = true;
420
+ else
421
+ delete t.fits_package;
422
+ }
423
+ // Round 5 — "belongs to other work": what the candidates already hold.
424
+ const pkgName = pkg.toUpperCase().trim();
425
+ // Round 5b — only the newest MAX_CONTENT_REQUESTS candidates are read (the
426
+ // pick is among them); older ones get no verdict on their contents.
427
+ const candidates = out.filter((t) => t.fits_package !== false && TRKORR.test(t.transportNumber))
428
+ .map((t) => t.transportNumber).slice(0, MAX_CONTENT_REQUESTS);
429
+ const held = await requestPackages(adt, candidates);
430
+ for (const t of out) {
431
+ const h = held.get(t.transportNumber);
432
+ const others = h ? [...h.pkgs].filter((p) => p !== pkgName).sort() : [];
433
+ if (others.length > 0)
434
+ t.other_packages = others;
435
+ }
436
+ // Stable: clean fitting first, then no verdict, then other work, then
437
+ // unfit; SAP's order kept inside each group.
438
+ const other = (t) => (t.other_packages?.length ?? 0) > 0;
439
+ // Round 5b — among fitting requests, those whose objects were all read
440
+ // (clean) come before those that could not be read.
441
+ const read = (t) => held.get(t.transportNumber)?.complete === true;
442
+ for (const t of out) {
443
+ if (t.fits_package === false)
444
+ continue;
445
+ // Review r7 M-4 — 'clean' only on a request known to fit; a request whose
446
+ // fit is unknown is never offered for reuse.
447
+ t.contents = other(t) ? 'other_work' : (read(t) && t.fits_package === true) ? 'clean' : 'not_checked';
448
+ }
449
+ const ranked = [
450
+ ...out.filter((t) => t.fits_package === true && !other(t) && read(t)),
451
+ ...out.filter((t) => t.fits_package === true && !other(t) && !read(t)),
452
+ ...out.filter((t) => t.fits_package === undefined && !other(t)),
453
+ ...out.filter((t) => t.fits_package !== false && other(t)),
454
+ ...out.filter((t) => t.fits_package === false),
455
+ ];
456
+ // The suggested pick: the first fitting request whose objects were read
457
+ // and all belong to this package (or that holds none).
458
+ const pick = ranked.find((t) => t.fits_package === true && !other(t) && read(t));
459
+ if (pick)
460
+ pick.suggested = true;
461
+ return { transports: ranked, packageTarget: target };
462
+ }
@@ -21,6 +21,7 @@
21
21
  * older mocks without the method) must NEVER block the write.
22
22
  */
23
23
  import { wasTransportCreatedThisSession } from './transport.js';
24
+ import { describeError } from '../sap-errors/clean-error-text.js';
24
25
  /**
25
26
  * Resolve the transport for a write against the owning-transport ledger.
26
27
  *
@@ -49,7 +50,8 @@ export async function resolveWriteTransport(ctx, objectType, objectName, supplie
49
50
  // conflicting-override case below).
50
51
  ctx.chunkEmitter?.emit('info', `owning-transport lookup failed for ${objectType} ${objectName} — `
51
52
  + `proceeding with ${supplied ?? 'no transport'} `
52
- + `(${err instanceof Error ? err.message : String(err)})`);
53
+ // describeError: an ICM HTML error page must not leak tags/CSS here.
54
+ + `(${describeError(err)})`);
53
55
  return { transport: supplied };
54
56
  }
55
57
  if (owning?.transport) {
@@ -17,6 +17,7 @@ import { ERR } from '../errors/codes.js';
17
17
  import { presentSafetyConfirmation } from '../repl/safety-confirm.js';
18
18
  import { isSafetyConfirmEnabled } from '../repl/safety-mode-state.js';
19
19
  import { setCurrentTransport } from '../repl/current-transport.js';
20
+ import { enrichTransportList } from './transport-fit.js';
20
21
  // ── Session ledger: transports CREATED by sap_transport_create this session ──
21
22
  //
22
23
  // Orphan-hint support (third junk transport this month, 2026-06-12 — D15/D20
@@ -57,7 +58,7 @@ registerTool({
57
58
  + 'the object is free. '
58
59
  + 'Ask the user for the transport description using `ask_question` — DO NOT '
59
60
  + 'generate a marketing-style description yourself. When asking, SUGGEST the '
60
- + 'Rule 9 session format "ABAPForge <YYYY-MM-DD>: <work summary>" as the default; '
61
+ + 'Rule 9 session format "<YYYY-MM-DD>: <work summary>" (no product name) as the default; '
61
62
  + 'the user may override. Use the user\'s exact answer as the `description` '
62
63
  + 'argument AND in your `request_approval` reason (identical strings prevent the '
63
64
  + 'approval mismatch failure mode observed live 2026-05-07). '
@@ -221,11 +222,12 @@ registerTool({
221
222
  // Rule 9 description format (D20: "Magic fix" accepted verbatim) —
222
223
  // non-blocking note when the description doesn't follow the session
223
224
  // format. The transport IS created; the user's wording always wins.
224
- const rule9Format = /^ABAPForge \d{4}-\d{2}-\d{2}: .+/;
225
+ // Any leading label (a customer's own prefix) is fine; no product name is suggested.
226
+ const rule9Format = /^(?:\S.* )?\d{4}-\d{2}-\d{2}: .+/;
225
227
  const today = new Date().toISOString().slice(0, 10);
226
228
  const rule9Suggestion = rule9Format.test(args.description)
227
229
  ? undefined
228
- : `Description does not follow the Rule 9 session format. Suggested: "ABAPForge ${today}: <work summary>". `
230
+ : `Description does not follow the Rule 9 session format. Suggested: "${today}: <work summary>". `
229
231
  + 'Non-blocking — keep the user\'s wording if they chose it deliberately.';
230
232
  return {
231
233
  content: JSON.stringify({
@@ -330,7 +332,20 @@ registerTool({
330
332
  registerTool({
331
333
  name: 'sap_transport_list',
332
334
  description: 'List SAP CTS transport requests. '
333
- + 'Optionally filter by owner (user) or status (modifiable | released).',
335
+ + 'Optionally filter by owner (user) or status (modifiable | released). '
336
+ + 'When choosing a transport for a write, pass `package`: each request then carries '
337
+ + '`fits_package` (true = its target matches the transport target of the package) and the '
338
+ + 'fitting ones come first. Offer the user only requests that fit — SAP refuses the others '
339
+ + '("does not have transport target …"). A request with `other_packages` already holds '
340
+ + 'objects of other packages (other work): it ranks lower and must never be pre-selected. '
341
+ + 'Each request that may fit carries `contents`: "clean" (its objects were read and all belong '
342
+ + 'to this package, or it holds none), "other_work" (it holds other packages\' objects), or '
343
+ + '"not_checked" (its objects were not read). `suggested: true` is the tool\'s pick and is '
344
+ + 'authoritative: present that request first, as the recommendation. Never pre-select or '
345
+ + 'recommend a request with contents "other_work" or "not_checked" for reuse; list a '
346
+ + '"not_checked" one as "not checked", never as a clean fit. A description that seems to name '
347
+ + 'other work may only lower a request in your answer; it never overrides `suggested: true`. '
348
+ + 'When no request is suggested, offer to create a new transport.',
334
349
  isMutating: false,
335
350
  requiresSap: true,
336
351
  input_schema: {
@@ -338,25 +353,35 @@ registerTool({
338
353
  properties: {
339
354
  owner: {
340
355
  type: 'string',
341
- description: 'Filter by transport owner (SAP user ID). Omit for all owners.',
356
+ description: 'Owner of the requests: the exact SAP user ID (e.g. MJOHNSON), not a name or part of one. Omit it to list the connected user\'s own requests.',
342
357
  },
343
358
  status: {
344
359
  type: 'string',
345
360
  enum: ['modifiable', 'released'],
346
361
  description: 'Filter by status. Defaults to "modifiable".',
347
362
  },
363
+ package: {
364
+ type: 'string',
365
+ description: 'The package the object will be written to (e.g. ZTEST_LAS). Marks which requests can take it (fits_package).',
366
+ },
348
367
  },
349
368
  },
350
369
  handler: async (args, ctx) => {
351
370
  // transportList(user?, status?)
352
371
  try {
353
372
  const result = await ctx.adt.transportList(args.owner, args.status);
373
+ // Fix round 3 (item 9e) — fill the empty target / description from
374
+ // E070/E07T and, given a package, mark + rank the requests that fit.
375
+ const enriched = await enrichTransportList(ctx.adt, result.transports, typeof args.package === 'string' && args.package.trim() ? args.package : undefined);
354
376
  return {
355
377
  content: JSON.stringify({
356
378
  filter_user: result.filterUser || null,
357
379
  filter_status: result.filterStatus,
358
- count: result.transports.length,
359
- transports: result.transports,
380
+ count: enriched.transports.length,
381
+ ...(enriched.packageTarget ? { package: String(args.package).toUpperCase(), package_target: enriched.packageTarget } : {}),
382
+ transports: enriched.transports,
383
+ // Round 5 item 3 — sap-client says when the list may be incomplete.
384
+ ...(result.note ? { note: result.note } : {}),
360
385
  }),
361
386
  };
362
387
  }
@@ -414,6 +439,7 @@ registerTool({
414
439
  what_will_happen: `Releases ${args.transport}; objects move to QA queue, transport becomes immutable.`,
415
440
  rollback_available: false,
416
441
  transport: args.transport,
442
+ args: { ...args },
417
443
  });
418
444
  if (!r.confirmed) {
419
445
  return {