@cspeach/cli 1.1.22 → 1.1.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/loop.js +124 -0
- package/dist/agent/tool-dispatch.js +4 -2
- package/dist/classifier/client.js +27 -2
- package/dist/cli.js +13 -0
- package/dist/commands/plan-audit-evidence.js +61 -4
- package/dist/commands/plan-audit.js +13 -1
- package/dist/commands/plan-continue.js +11 -1
- package/dist/commands/plan-resume.js +38 -1
- package/dist/commands/sap-link.js +52 -0
- package/dist/lane/app-chain.js +297 -0
- package/dist/lane/app-flow.js +55 -0
- package/dist/lane/card.js +148 -0
- package/dist/lane/chain-token.js +59 -0
- package/dist/lane/inputs.js +459 -0
- package/dist/lane/paths.js +21 -0
- package/dist/lane/resolve.js +109 -0
- package/dist/lane/roots.js +113 -0
- package/dist/lane/signals.js +80 -0
- package/dist/lane/store.js +232 -0
- package/dist/lane/types.js +1 -0
- package/dist/lib/shutdown-hooks.js +87 -0
- package/dist/one-shot.js +5 -0
- package/dist/projects/extract-plan.js +46 -6
- package/dist/projects/plan-lane.js +214 -0
- package/dist/projects/plan-run.js +5 -1
- package/dist/projects/plan-schema.js +12 -1
- package/dist/projects/promote-command.js +8 -2
- package/dist/projects/save-command.js +71 -25
- package/dist/projects/workspace.js +10 -0
- package/dist/renderer/tool-labels.js +6 -1
- package/dist/repl/credential-handover-gate.js +12 -6
- package/dist/repl/resume-standalone.js +3 -0
- package/dist/repl.js +27 -2
- package/dist/sap/connection-manager.js +30 -24
- package/dist/sap-link/policy.js +185 -0
- package/dist/sap-link/project.js +163 -0
- package/dist/sap-link/registry.js +259 -0
- package/dist/sap-link/server.js +351 -0
- package/dist/sap-link/upstream.js +367 -0
- package/dist/sap-link/write-log.js +60 -0
- package/dist/skill-catalog.js +14 -6
- package/dist/skills/bundled-skills.js +61 -54
- package/dist/tools/cap/_fetch-errors.js +20 -0
- package/dist/tools/cap/link-smoke.js +703 -0
- package/dist/tools/cap/metadata-import.js +325 -0
- package/dist/tools/cap/sap-link-tool.js +133 -0
- package/dist/tools/cap/service-catalog.js +248 -0
- package/dist/tools/fiori/preview/registry.js +30 -24
- package/dist/tools/lane/app-lane-resolve.js +329 -0
- package/dist/tools/local-build.js +15 -0
- package/dist/tools/subagent/_background-shared.js +59 -37
- package/dist/tools/subagent/background_run.js +9 -5
- package/dist/tools/tool-loading.js +2 -0
- package/package.json +121 -121
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
// cspeach-cli/src/tools/cap/service-catalog.ts
|
|
2
|
+
//
|
|
3
|
+
// CAP phase 2, Task 9 — `sap_service_catalog`: list the OData services ACTIVE on
|
|
4
|
+
// the connected SAP system, from the Gateway V2 and V4 catalogs. The design
|
|
5
|
+
// step uses it to map a spec's S/4 data to a service that really exists.
|
|
6
|
+
//
|
|
7
|
+
// Read-only: the only requests are GETs to V2_CATALOG_BASE and V4_CATALOG_URL.
|
|
8
|
+
// Field names follow the real S4H captures (probe note P3, fixtures/catalog-*.json):
|
|
9
|
+
// - V2: on that box `TechnicalServiceName` is Z-prefixed (ZAPI_…) while
|
|
10
|
+
// `ServiceUrl` (absolute) ends in the real name, so the name comes from the
|
|
11
|
+
// last ServiceUrl segment (matrix `;v=..` ignored), then Title, then
|
|
12
|
+
// TechnicalServiceName.
|
|
13
|
+
// - V4: `ServiceUrl` is RELATIVE and carries `?sap-client=…`; the query is dropped.
|
|
14
|
+
import { registerTool } from '../index.js';
|
|
15
|
+
import { loadConfig } from '../../config/loader.js';
|
|
16
|
+
import { PlainFetchError, plainSapFetchError } from './_fetch-errors.js';
|
|
17
|
+
const V2_CATALOG_BASE = '/sap/opu/odata/IWFND/CATALOGSERVICE;v=2/ServiceCollection?$format=json' +
|
|
18
|
+
'&$select=ID,TechnicalServiceName,TechnicalServiceVersion,Title,ServiceUrl';
|
|
19
|
+
export const V4_CATALOG_URL = '/sap/opu/odata4/iwfnd/config/default/iwfnd/catalog/0002/ServiceGroups?$expand=DefaultSystem($expand=Services)';
|
|
20
|
+
/** V2 page size and the safety cap on pages (321 `API_` services on S4H → 4 pages). */
|
|
21
|
+
export const V2_PAGE_SIZE = 100;
|
|
22
|
+
export const V2_MAX_PAGES = 20;
|
|
23
|
+
const DEFAULT_MAX = 50;
|
|
24
|
+
const MAX_MAX = 500;
|
|
25
|
+
const SEARCH_MAX_LEN = 60;
|
|
26
|
+
const SEARCH_RE = /^[A-Za-z0-9_ /-]*$/;
|
|
27
|
+
/** Pathname of an absolute or relative URL: no scheme, host, port, query or fragment; no trailing slash. */
|
|
28
|
+
function pathOf(u) {
|
|
29
|
+
try {
|
|
30
|
+
return new URL(u, 'http://catalog.invalid').pathname.replace(/\/+$/, '');
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/** Last path segment, matrix parameters (`;v=0002`) removed. */
|
|
37
|
+
function lastSegment(p) {
|
|
38
|
+
const seg = p.split('/').pop() ?? '';
|
|
39
|
+
return seg.split(';')[0] ?? '';
|
|
40
|
+
}
|
|
41
|
+
/** One row, one line: SAP-supplied text loses control characters (tabs, newlines, ESC). */
|
|
42
|
+
// eslint-disable-next-line no-control-regex
|
|
43
|
+
const oneLine = (s) => String(s ?? '').replace(/[\u0000-\u001F\u007F]+/g, ' ').replace(/\s+/g, ' ').trim();
|
|
44
|
+
const str = (v) => (typeof v === 'string' ? v : '');
|
|
45
|
+
/**
|
|
46
|
+
* The V2 catalog URL for one page. `search` is escaped for an OData string
|
|
47
|
+
* literal (single quotes doubled) and percent-encoded; `$` stays literal the way
|
|
48
|
+
* sap_odata_test does. The handler validates the term before it gets here.
|
|
49
|
+
*/
|
|
50
|
+
export function v2CatalogUrl(search, skip = 0) {
|
|
51
|
+
let url = `${V2_CATALOG_BASE}&$top=${V2_PAGE_SIZE}&$skip=${Math.max(0, Math.floor(skip))}`;
|
|
52
|
+
if (search) {
|
|
53
|
+
const filter = `substringof('${search.replace(/'/g, "''")}',TechnicalServiceName)`;
|
|
54
|
+
url += `&$filter=${encodeURIComponent(filter)}`;
|
|
55
|
+
}
|
|
56
|
+
return url;
|
|
57
|
+
}
|
|
58
|
+
export function parseV2Catalog(json) {
|
|
59
|
+
return parseV2Page(json).rows;
|
|
60
|
+
}
|
|
61
|
+
/** Rows plus the raw result count; paging decides on the raw count, not the kept rows. */
|
|
62
|
+
function parseV2Page(json) {
|
|
63
|
+
const d = JSON.parse(json);
|
|
64
|
+
const results = Array.isArray(d?.d?.results) ? d.d.results : [];
|
|
65
|
+
const out = [];
|
|
66
|
+
for (const r of results) {
|
|
67
|
+
if (!r || typeof r !== 'object')
|
|
68
|
+
continue;
|
|
69
|
+
const servicePath = pathOf(str(r.ServiceUrl));
|
|
70
|
+
if (!servicePath || !servicePath.startsWith('/sap/opu/odata/'))
|
|
71
|
+
continue;
|
|
72
|
+
const name = lastSegment(servicePath) || str(r.Title) || str(r.TechnicalServiceName);
|
|
73
|
+
if (!name)
|
|
74
|
+
continue;
|
|
75
|
+
const v = r.TechnicalServiceVersion;
|
|
76
|
+
out.push({
|
|
77
|
+
name: oneLine(name),
|
|
78
|
+
version: v === undefined || v === null || v === '' ? '' : oneLine(v).padStart(4, '0'),
|
|
79
|
+
title: oneLine(r.Title),
|
|
80
|
+
servicePath,
|
|
81
|
+
odata: 'v2',
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
return { rows: out, raw: results.length };
|
|
85
|
+
}
|
|
86
|
+
export function parseV4Catalog(json) {
|
|
87
|
+
const d = JSON.parse(json);
|
|
88
|
+
const groups = Array.isArray(d?.value) ? d.value : [];
|
|
89
|
+
const out = [];
|
|
90
|
+
for (const g of groups) {
|
|
91
|
+
const sys = g?.DefaultSystem;
|
|
92
|
+
const services = Array.isArray(sys?.Services) ? sys.Services : [];
|
|
93
|
+
for (const s of services) {
|
|
94
|
+
if (!s || typeof s !== 'object')
|
|
95
|
+
continue;
|
|
96
|
+
const servicePath = pathOf(str(s.ServiceUrl));
|
|
97
|
+
if (!servicePath || !servicePath.startsWith('/sap/opu/odata4/'))
|
|
98
|
+
continue;
|
|
99
|
+
const name = str(s.ServiceId);
|
|
100
|
+
if (!name)
|
|
101
|
+
continue;
|
|
102
|
+
out.push({
|
|
103
|
+
name: oneLine(name),
|
|
104
|
+
version: oneLine(s.ServiceVersion),
|
|
105
|
+
title: oneLine(str(s.Description) || str(g.GroupId)),
|
|
106
|
+
servicePath,
|
|
107
|
+
odata: 'v4',
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
export async function sapServiceCatalogHandler(args, ctx, deps = {}) {
|
|
114
|
+
const rawSearch = typeof args.search === 'string' ? args.search.trim() : '';
|
|
115
|
+
if (rawSearch.length > SEARCH_MAX_LEN) {
|
|
116
|
+
return { content: `The search term is too long. Use at most ${SEARCH_MAX_LEN} characters.`, is_error: true };
|
|
117
|
+
}
|
|
118
|
+
if (!SEARCH_RE.test(rawSearch)) {
|
|
119
|
+
return {
|
|
120
|
+
content: 'The search term may only use letters, digits, underscore, space, slash and hyphen.',
|
|
121
|
+
is_error: true,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
const want = args.version ?? 'both';
|
|
125
|
+
if (want !== 'v2' && want !== 'v4' && want !== 'both') {
|
|
126
|
+
return { content: 'version must be "v2", "v4" or "both".', is_error: true };
|
|
127
|
+
}
|
|
128
|
+
const n = Number(args.max);
|
|
129
|
+
const max = Number.isFinite(n) && n >= 1 ? Math.min(Math.floor(n), MAX_MAX) : DEFAULT_MAX;
|
|
130
|
+
const term = rawSearch.toLowerCase();
|
|
131
|
+
const get = deps.get ?? (async (p) => {
|
|
132
|
+
const { getConnection } = await import('../../sap/connection-manager.js');
|
|
133
|
+
const cfg = await loadConfig();
|
|
134
|
+
const sys = cfg.sap[ctx.sapAlias];
|
|
135
|
+
if (!sys)
|
|
136
|
+
throw new PlainFetchError(`No SAP system "${ctx.sapAlias}" is configured.`);
|
|
137
|
+
try {
|
|
138
|
+
const conn = await getConnection(ctx.sapAlias, sys);
|
|
139
|
+
const r = await conn.get(p, 'application/json');
|
|
140
|
+
return { status: r.status, body: r.body };
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
// Never the raw message: connection errors can carry headers, cookies or the user name.
|
|
144
|
+
// An empty fallback lets fetchRows use its own per-catalog sentence.
|
|
145
|
+
throw new PlainFetchError(plainSapFetchError(err, ctx.sapAlias, sys.host, sys.port, ''));
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
const matches = (x) => !term || x.name.toLowerCase().includes(term) || x.title.toLowerCase().includes(term);
|
|
149
|
+
/** One GET → rows, or a plain one-line error. Never echoes the answer body or a stack. */
|
|
150
|
+
const fetchRows = async (label, url, parse) => {
|
|
151
|
+
let r;
|
|
152
|
+
try {
|
|
153
|
+
r = await get(url);
|
|
154
|
+
}
|
|
155
|
+
catch (err) {
|
|
156
|
+
// Only a PlainFetchError's own fixed line is shown; anything else gets a fixed sentence.
|
|
157
|
+
if (err instanceof PlainFetchError && err.message)
|
|
158
|
+
return err.message;
|
|
159
|
+
return `${label} catalog could not be read from ${ctx.sapAlias}. Check the SAP login for this system and try again.`;
|
|
160
|
+
}
|
|
161
|
+
if (r.status !== 200)
|
|
162
|
+
return `${label} catalog answered HTTP ${r.status}.`;
|
|
163
|
+
try {
|
|
164
|
+
return parse(r.body);
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return `${label} catalog answer was not JSON.`;
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
const out = [];
|
|
171
|
+
const notes = [];
|
|
172
|
+
const errors = [];
|
|
173
|
+
const emit = (rows) => {
|
|
174
|
+
for (const x of rows)
|
|
175
|
+
out.push(`${x.servicePath}\t${x.name}\t${x.odata}\t${x.version}\t${x.title}`);
|
|
176
|
+
};
|
|
177
|
+
if (want !== 'v4') {
|
|
178
|
+
// The server filter matches TechnicalServiceName, which is upper case (and
|
|
179
|
+
// Z-prefixed on some boxes, e.g. ZAPI_…); substringof still finds the name.
|
|
180
|
+
const serverTerm = rawSearch ? rawSearch.toUpperCase() : undefined;
|
|
181
|
+
const rows = [];
|
|
182
|
+
let page = 0;
|
|
183
|
+
let failed = false;
|
|
184
|
+
let lastFull = false;
|
|
185
|
+
while (page < V2_MAX_PAGES && rows.length < max) {
|
|
186
|
+
const res = await fetchRows('V2', v2CatalogUrl(serverTerm, page * V2_PAGE_SIZE), parseV2Page);
|
|
187
|
+
page++;
|
|
188
|
+
if (typeof res === 'string') {
|
|
189
|
+
errors.push(res);
|
|
190
|
+
failed = true;
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
193
|
+
rows.push(...res.rows.filter(matches));
|
|
194
|
+
lastFull = res.raw >= V2_PAGE_SIZE;
|
|
195
|
+
if (!lastFull)
|
|
196
|
+
break;
|
|
197
|
+
}
|
|
198
|
+
if (!failed && page >= V2_MAX_PAGES && lastFull && rows.length < max) {
|
|
199
|
+
notes.push(`Stopped after ${V2_MAX_PAGES} pages of the V2 catalog. The V2 list may be incomplete; narrow the search.`);
|
|
200
|
+
}
|
|
201
|
+
if (rows.length > max)
|
|
202
|
+
notes.push(`V2: showing the first ${max}. Narrow the search or raise max.`);
|
|
203
|
+
emit(rows.slice(0, max));
|
|
204
|
+
}
|
|
205
|
+
if (want !== 'v2') {
|
|
206
|
+
const res = await fetchRows('V4', V4_CATALOG_URL, parseV4Catalog);
|
|
207
|
+
if (typeof res === 'string')
|
|
208
|
+
errors.push(res);
|
|
209
|
+
else {
|
|
210
|
+
const rows = res.filter(matches);
|
|
211
|
+
if (rows.length > max)
|
|
212
|
+
notes.push(`V4: showing the first ${max} of ${rows.length}. Narrow the search or raise max.`);
|
|
213
|
+
emit(rows.slice(0, max));
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
const content = [
|
|
217
|
+
'PATH\tNAME\tODATA\tVERSION\tTITLE',
|
|
218
|
+
...out,
|
|
219
|
+
`(${out.length} active services${rawSearch ? ` matching "${rawSearch}"` : ''}. Active means registered in the ` +
|
|
220
|
+
'Gateway catalog. Release state is separate; check it before you use a service.)',
|
|
221
|
+
...notes,
|
|
222
|
+
...[...new Set(errors)], // one reach line, not one per catalog
|
|
223
|
+
].join('\n');
|
|
224
|
+
return { content, ...(out.length === 0 && errors.length ? { is_error: true } : {}) };
|
|
225
|
+
}
|
|
226
|
+
registerTool({
|
|
227
|
+
name: 'sap_service_catalog',
|
|
228
|
+
description: 'List OData services ACTIVE on the connected SAP system (Gateway catalog, V2 and V4): path, service name, ' +
|
|
229
|
+
'OData version, service version, title. Use it in design to map a spec\'s S/4 data to a service that really ' +
|
|
230
|
+
'exists on this system. Active is not the same as released: check release state separately. Read-only.',
|
|
231
|
+
isMutating: false,
|
|
232
|
+
requiresSap: true,
|
|
233
|
+
// R25: visible only with local_build on (the CAP lane already needs it), so
|
|
234
|
+
// the tool list of non-CAP users stays unchanged.
|
|
235
|
+
flagGated: true,
|
|
236
|
+
input_schema: {
|
|
237
|
+
type: 'object',
|
|
238
|
+
properties: {
|
|
239
|
+
search: {
|
|
240
|
+
type: 'string',
|
|
241
|
+
description: 'Part of the service name or title, e.g. "BUSINESS_PARTNER". Letters, digits, _ space / - only; max 60.',
|
|
242
|
+
},
|
|
243
|
+
version: { type: 'string', enum: ['v2', 'v4', 'both'], description: 'Which catalog to read. Default both.' },
|
|
244
|
+
max: { type: 'number', description: 'Rows per catalog, default 50, max 500.' },
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
handler: (args, ctx) => sapServiceCatalogHandler(args, ctx),
|
|
248
|
+
});
|
|
@@ -56,6 +56,7 @@ import * as net from 'node:net';
|
|
|
56
56
|
import * as os from 'node:os';
|
|
57
57
|
import * as path from 'node:path';
|
|
58
58
|
import { KILL_GRACE_MS } from '../../_command-shared.js';
|
|
59
|
+
import { registerShutdownHook } from '../../../lib/shutdown-hooks.js';
|
|
59
60
|
/** appDirKey → tracked preview. At most one per directory, by construction. */
|
|
60
61
|
const registry = new Map();
|
|
61
62
|
/**
|
|
@@ -108,7 +109,7 @@ export function childIsLive(child) {
|
|
|
108
109
|
return !!child && child.exitCode === null && child.signalCode === null;
|
|
109
110
|
}
|
|
110
111
|
/** A pid we are allowed to signal. 0 = own group / idle process, 1 = everything. */
|
|
111
|
-
function killablePid(pid) {
|
|
112
|
+
export function killablePid(pid) {
|
|
112
113
|
return typeof pid === 'number' && Number.isInteger(pid) && pid > 1;
|
|
113
114
|
}
|
|
114
115
|
export function registerPreview(e, child) {
|
|
@@ -340,15 +341,30 @@ export async function stopPreview(previewId) {
|
|
|
340
341
|
const portClosed = await waitForPortClosed(port);
|
|
341
342
|
return { stopped: true, portClosed, port, ...outcome };
|
|
342
343
|
}
|
|
344
|
+
/**
|
|
345
|
+
* Windows only: kill `pid` and every descendant SYNCHRONOUSLY with the absolute
|
|
346
|
+
* taskkill (`/T /F`), minimal env, cwd = tmpdir. Safe inside `process.on('exit')`.
|
|
347
|
+
* The CALLER owns the identity rule — call it only for a live child handle this
|
|
348
|
+
* process holds, and with `killablePid(pid)` true. Shared with the background
|
|
349
|
+
* registry (subagent/_background-shared.ts).
|
|
350
|
+
*
|
|
351
|
+
* Returns true when taskkill ran and reported success — exit 0, or 128 ("no such
|
|
352
|
+
* process": already gone). spawnSync never throws; a spawn failure or timeout
|
|
353
|
+
* shows up as `error`, and the caller decides on a fallback.
|
|
354
|
+
*/
|
|
355
|
+
export function taskkillTreeSync(pid) {
|
|
356
|
+
const r = spawnSync(TASKKILL, ['/PID', String(pid), '/T', '/F'], {
|
|
357
|
+
windowsHide: true,
|
|
358
|
+
stdio: 'ignore',
|
|
359
|
+
cwd: os.tmpdir(),
|
|
360
|
+
env: TASKKILL_ENV,
|
|
361
|
+
timeout: TASKKILL_TIMEOUT_MS,
|
|
362
|
+
});
|
|
363
|
+
return !r.error && (r.status === 0 || r.status === 128);
|
|
364
|
+
}
|
|
343
365
|
function realSyncKill(pid) {
|
|
344
366
|
if (process.platform === 'win32') {
|
|
345
|
-
|
|
346
|
-
windowsHide: true,
|
|
347
|
-
stdio: 'ignore',
|
|
348
|
-
cwd: os.tmpdir(),
|
|
349
|
-
env: TASKKILL_ENV,
|
|
350
|
-
timeout: TASKKILL_TIMEOUT_MS,
|
|
351
|
-
});
|
|
367
|
+
taskkillTreeSync(pid);
|
|
352
368
|
}
|
|
353
369
|
else {
|
|
354
370
|
process.kill(-pid, 'SIGTERM');
|
|
@@ -381,22 +397,12 @@ export function installPreviewExitHandler() {
|
|
|
381
397
|
if (exitHandlerInstalled)
|
|
382
398
|
return;
|
|
383
399
|
exitHandlerInstalled = true;
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
killAllPreviewsSync();
|
|
391
|
-
// In one-shot/classic mode ours may be the ONLY listener, and merely
|
|
392
|
-
// having one removes Node's default exit-on-signal. Re-raise so Ctrl+C
|
|
393
|
-
// still stops the CLI.
|
|
394
|
-
if (process.listenerCount(sig) === 1) {
|
|
395
|
-
process.removeAllListeners(sig);
|
|
396
|
-
process.kill(process.pid, sig);
|
|
397
|
-
}
|
|
398
|
-
});
|
|
399
|
-
}
|
|
400
|
+
// One shared exit/signal handler for every managed resource (Task 8 fix
|
|
401
|
+
// round 1, I-1): it runs on 'exit' and on SIGINT/SIGTERM/SIGHUP (prepended,
|
|
402
|
+
// so ui/alt-screen.ts's re-raise cannot pre-empt it), and re-raises when every
|
|
403
|
+
// remaining listener is its own. Two registries with their own prepended
|
|
404
|
+
// listeners each saw a count of 2 and nobody re-raised — Ctrl+C hung.
|
|
405
|
+
registerShutdownHook(killAllPreviewsSync);
|
|
400
406
|
}
|
|
401
407
|
export function __resetPreviewRegistryForTests() {
|
|
402
408
|
registry.clear();
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
// cspeach-cli/src/tools/lane/app-lane-resolve.ts
|
|
2
|
+
//
|
|
3
|
+
// App lane routing, slice 1, Task 5 — `app_lane_resolve`. The model fills the
|
|
4
|
+
// signal sheet (values + evidence quotes); everything else is CLI code:
|
|
5
|
+
//
|
|
6
|
+
// sheet check → spec text + quote strength → system facts, pin, standards
|
|
7
|
+
// → resolveLane → lane card (or the headless STOP) → runtime card when a CAP
|
|
8
|
+
// lane has no runtime → sha-pinned decision file → app-flow state → the CLI
|
|
9
|
+
// queues `/abap-plan --from @<specGap>` through the harness slot.
|
|
10
|
+
//
|
|
11
|
+
// Security posture:
|
|
12
|
+
// - The sheet is validated with the strict schema before anything else runs;
|
|
13
|
+
// errors name only the bad keys (sanitised), never values.
|
|
14
|
+
// - The spec path goes through readSpecText (string + realpath containment,
|
|
15
|
+
// denylist, size caps). Errors name the path only, never spec content.
|
|
16
|
+
// - Card strings come from card.ts (sanitised, capped). STOP / summary text
|
|
17
|
+
// passes every model-influenced line through cardLine.
|
|
18
|
+
// - The decision file is written only by store.ts (validated, never overwrites).
|
|
19
|
+
// - The runtime answer is written only by writeRuntimeAnswer, from a card value.
|
|
20
|
+
// - pendingDispatch gets a CLI-built string only: a fixed prefix plus the
|
|
21
|
+
// session's spec-gap path, through the shared chainFromToken (lane/chain-token.ts).
|
|
22
|
+
import { promises as fs } from 'node:fs';
|
|
23
|
+
import * as path from 'node:path';
|
|
24
|
+
import { registerTool, isStandalone } from '../index.js';
|
|
25
|
+
import { PathOutsideRootError } from '../_filesystem-shared.js';
|
|
26
|
+
import { assertNotCliHome, containSpecPath, laneRootFor, specDisplayPath } from '../../lane/roots.js';
|
|
27
|
+
import { signalSheetSchema, withStrength } from '../../lane/signals.js';
|
|
28
|
+
import { factsFromStandalone, factsFromSystemInfo, findQuote, readLanePin, readSpecText, readStandardsLane, writeRuntimeAnswer, } from '../../lane/inputs.js';
|
|
29
|
+
import { BUILT_LANES, resolveLane, uiFor } from '../../lane/resolve.js';
|
|
30
|
+
import { writeLaneDecision } from '../../lane/store.js';
|
|
31
|
+
import { getAppFlow, setLane } from '../../lane/app-flow.js';
|
|
32
|
+
import { chainFromToken } from '../../lane/chain-token.js';
|
|
33
|
+
import { cardLine, interactiveRunLine, laneCardRequest, quoteNotFoundFacts, runtimeCardRequest, shownToken, } from '../../lane/card.js';
|
|
34
|
+
import { cardSlotEmitter } from '../../ui/card-slot.js';
|
|
35
|
+
import { askConfirm } from '../../ui/confirm-request.js';
|
|
36
|
+
import { isHeadless } from '../../renderer/tty.js';
|
|
37
|
+
import { loadConfig } from '../../config/loader.js';
|
|
38
|
+
import { getSapSystemInfo } from '../../sap/system-info.js';
|
|
39
|
+
const fail = (message) => ({ content: `error: ${message}`, is_error: true });
|
|
40
|
+
const NOT_CHOSEN = 'Lane not chosen. Nothing was saved.';
|
|
41
|
+
const STOP = 'STOP — lane needs your choice.';
|
|
42
|
+
const UNKNOWN_FACTS = { source: 'unknown', tier: 'unknown', label: 'unknown' };
|
|
43
|
+
const RUNTIMES = ['btp', 'own-server', 'none'];
|
|
44
|
+
/** A sheet key for an error line: identifier characters only, capped. */
|
|
45
|
+
const keyName = (k) => String(k).replace(/[^A-Za-z0-9_]/g, '').slice(0, 40) || '(unnamed)';
|
|
46
|
+
function sheetError(issues) {
|
|
47
|
+
const bad = new Set();
|
|
48
|
+
let notObject = false;
|
|
49
|
+
for (const i of issues) {
|
|
50
|
+
if (i.code === 'unrecognized_keys' && i.path.length === 0) {
|
|
51
|
+
for (const k of i.keys ?? [])
|
|
52
|
+
bad.add(`${keyName(k)} (unknown key)`);
|
|
53
|
+
}
|
|
54
|
+
else if (i.path.length === 0) {
|
|
55
|
+
notObject = true;
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
bad.add(keyName(i.path[0]));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (notObject && bad.size === 0)
|
|
62
|
+
return 'signals must be an object with one entry per signal key.';
|
|
63
|
+
return `the signal sheet is not valid. Fix these keys and call again: ${[...bad].join(', ')}.`;
|
|
64
|
+
}
|
|
65
|
+
async function defaultFacts(ctx) {
|
|
66
|
+
try {
|
|
67
|
+
if (isStandalone(ctx)) {
|
|
68
|
+
return factsFromStandalone((await loadConfig()).standalone);
|
|
69
|
+
}
|
|
70
|
+
return factsFromSystemInfo(await getSapSystemInfo(ctx.adt, ctx.sapAlias));
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return { ...UNKNOWN_FACTS };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/** Headless, or no Ink card slot (classic REPL / one-shot): a card could never be answered. */
|
|
77
|
+
function defaultHeadless() {
|
|
78
|
+
return isHeadless() || cardSlotEmitter.listenerCount('confirm') === 0;
|
|
79
|
+
}
|
|
80
|
+
/** M1 — a person is at the terminal but no card can be shown (the classic REPL). */
|
|
81
|
+
function defaultNoCardTerminal() {
|
|
82
|
+
return !isHeadless() && cardSlotEmitter.listenerCount('confirm') === 0;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* True when the app flow's `@spec` token and the given absolute path are the
|
|
86
|
+
* same real file inside cwd or the workspace folder (I3: the same containment
|
|
87
|
+
* as readSpecText).
|
|
88
|
+
*/
|
|
89
|
+
async function sameFile(cwd, token, abs) {
|
|
90
|
+
const tok = token.trim().replace(/^@/, '');
|
|
91
|
+
if (tok === '')
|
|
92
|
+
return false;
|
|
93
|
+
try {
|
|
94
|
+
const { real: a } = await containSpecPath(cwd, tok);
|
|
95
|
+
const b = await fs.realpath(abs);
|
|
96
|
+
const norm = (p) => (process.platform === 'win32' ? path.resolve(p).toLowerCase() : path.resolve(p));
|
|
97
|
+
return norm(a) === norm(b);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const LANE_WORDS = {
|
|
104
|
+
rap: 'on-stack RAP', cap: 'CAP (side-by-side)', none: 'UI only', 'abap-report': 'classic report', segw: 'SEGW',
|
|
105
|
+
};
|
|
106
|
+
function stopText(d, extra, specLine) {
|
|
107
|
+
const lines = [STOP, specLine];
|
|
108
|
+
if (d.needsRuntime && !d.neutral && !d.noAppLane) {
|
|
109
|
+
lines.push(`Recommended lane: ${LANE_WORDS.cap}. The CAP lane needs to know where the app will run.`);
|
|
110
|
+
}
|
|
111
|
+
else if (d.backend && !d.noAppLane) {
|
|
112
|
+
lines.push(`Recommended lane: ${LANE_WORDS[d.backend]}.`);
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
lines.push('No app lane is available on this system.');
|
|
116
|
+
}
|
|
117
|
+
lines.push('Why:');
|
|
118
|
+
for (const r of [...d.reasons, ...extra])
|
|
119
|
+
lines.push(`- ${cardLine(r)}`);
|
|
120
|
+
if (d.needsRuntime && !d.neutral && !d.noAppLane) {
|
|
121
|
+
lines.push('To confirm: add `[app-lane] runtime = "btp|own-server|none"` to .cspeach/conventions.toml and run /abap-app again.');
|
|
122
|
+
}
|
|
123
|
+
else if (d.backend && !d.noAppLane) {
|
|
124
|
+
lines.push(`To confirm: Add \`[app-lane] backend = "${d.backend}"\` to .cspeach/conventions.toml and run /abap-app again.`);
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
const choices = d.allowed.filter((l) => l !== 'segw').join(', ');
|
|
128
|
+
lines.push(`To choose: add \`[app-lane] backend = "<lane>"\` (one of: ${choices}) to .cspeach/conventions.toml and run /abap-app again.`);
|
|
129
|
+
}
|
|
130
|
+
lines.push('Nothing was saved.');
|
|
131
|
+
return lines.join('\n');
|
|
132
|
+
}
|
|
133
|
+
export async function appLaneResolveHandler(args, ctx, deps = {}) {
|
|
134
|
+
// 0. Task 7 review round 2 — inside an /abap-app flow the lane is chosen
|
|
135
|
+
// only at the lane step (after the spec-gap save, before the plan). Nothing
|
|
136
|
+
// else runs: no card, no file, the flow and the queue stay as they are.
|
|
137
|
+
// No flow at all → today's behaviour.
|
|
138
|
+
const step = getAppFlow(ctx.session)?.expect;
|
|
139
|
+
if (step !== undefined && step !== 'lane') {
|
|
140
|
+
return fail(step === 'spec-gap'
|
|
141
|
+
? 'the app flow is not at the lane step yet: save the spec-gap first.'
|
|
142
|
+
: 'the app flow is not at the lane step: the lane is already chosen and the plan is next.');
|
|
143
|
+
}
|
|
144
|
+
// 1. The sheet, strictly, before anything else.
|
|
145
|
+
const parsed = signalSheetSchema.safeParse(args?.signals);
|
|
146
|
+
if (!parsed.success)
|
|
147
|
+
return fail(sheetError(parsed.error.issues));
|
|
148
|
+
if (typeof args?.specPath !== 'string' || args.specPath.trim() === '') {
|
|
149
|
+
return fail('specPath must be the spec file path, relative to the workspace.');
|
|
150
|
+
}
|
|
151
|
+
const specPath = args.specPath;
|
|
152
|
+
// 2. Spec text (contained) and signal strength.
|
|
153
|
+
let spec;
|
|
154
|
+
try {
|
|
155
|
+
spec = await readSpecText(ctx.cwd, specPath);
|
|
156
|
+
}
|
|
157
|
+
catch (e) {
|
|
158
|
+
if (e instanceof PathOutsideRootError)
|
|
159
|
+
return fail('the spec path must be inside the workspace.');
|
|
160
|
+
return fail(cardLine(e instanceof Error ? e.message : 'the spec file cannot be read.'));
|
|
161
|
+
}
|
|
162
|
+
const specSha = spec.sha256;
|
|
163
|
+
const specRel = specDisplayPath(ctx.cwd, spec.abs);
|
|
164
|
+
// The lane check must use the spec /abap-app was started with: a model may
|
|
165
|
+
// not match its quotes against some other workspace file (one it just wrote).
|
|
166
|
+
const flow = getAppFlow(ctx.session);
|
|
167
|
+
if (flow && !(await sameFile(ctx.cwd, flow.specToken, spec.abs))) {
|
|
168
|
+
return fail(cardLine(`This lane check must use the spec you started /abap-app with: ${flow.specToken}`));
|
|
169
|
+
}
|
|
170
|
+
const specLine = cardLine(`Spec: ${specRel}`);
|
|
171
|
+
const signals = withStrength(parsed.data, spec.text, findQuote, spec.normalise);
|
|
172
|
+
// 3–4. Facts, pin, standards.
|
|
173
|
+
const facts = await (deps.facts ?? defaultFacts)(ctx).catch(() => ({ ...UNKNOWN_FACTS }));
|
|
174
|
+
// R18 — the project pin lives on the lane root, next to .cspeach/lane.
|
|
175
|
+
const laneRoot = await laneRootFor(ctx.cwd);
|
|
176
|
+
// Final residual 2 — a lane root that is the CLI home is refused before any read or card.
|
|
177
|
+
try {
|
|
178
|
+
await assertNotCliHome(laneRoot);
|
|
179
|
+
}
|
|
180
|
+
catch (e) {
|
|
181
|
+
return fail(cardLine(e instanceof Error ? e.message : String(e)));
|
|
182
|
+
}
|
|
183
|
+
const [pinR, stdR] = await Promise.all([readLanePin(laneRoot), readStandardsLane(ctx.cwd)]);
|
|
184
|
+
const warnings = [pinR.warning, stdR.warning].filter((w) => !!w);
|
|
185
|
+
const runtimeKnown = !!pinR.pin?.runtime || !!stdR.pin?.runtime;
|
|
186
|
+
// 5. Resolve.
|
|
187
|
+
const decision = resolveLane({ signals, facts, pin: pinR.pin, standards: stdR.pin, runtimeKnown });
|
|
188
|
+
const extraFacts = [...quoteNotFoundFacts(signals), ...warnings.map(cardLine)];
|
|
189
|
+
const cardFacts = [specLine, ...extraFacts];
|
|
190
|
+
let finalLane;
|
|
191
|
+
let decidedBy = decision.decidedBy;
|
|
192
|
+
let runtime = pinR.pin?.runtime ?? stdR.pin?.runtime;
|
|
193
|
+
const headless = (deps.headless ?? defaultHeadless)();
|
|
194
|
+
if (headless) {
|
|
195
|
+
// 6. Headless: never a card. Choices that need a person stop here.
|
|
196
|
+
if (decision.neutral || decision.noAppLane || decision.needsRuntime || !decision.backend) {
|
|
197
|
+
return { content: stopText(decision, extraFacts, specLine) };
|
|
198
|
+
}
|
|
199
|
+
finalLane = { backend: decision.backend, ui: decision.ui, ...(decision.floorplan ? { floorplan: decision.floorplan } : {}) };
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
// 7. The lane card.
|
|
203
|
+
const confirm = deps.confirm ?? ((req) => askConfirm(req));
|
|
204
|
+
const card = laneCardRequest(decision);
|
|
205
|
+
const res = await confirm({ ...card, facts: [...(card.facts ?? []), ...cardFacts] });
|
|
206
|
+
const value = res.value;
|
|
207
|
+
const offered = new Set(card.choices.map((c) => c.value));
|
|
208
|
+
if (!value || value === 'cancel' || !offered.has(value))
|
|
209
|
+
return { content: NOT_CHOSEN };
|
|
210
|
+
if (value === 'confirm') {
|
|
211
|
+
if (!decision.backend || decision.noAppLane)
|
|
212
|
+
return { content: NOT_CHOSEN };
|
|
213
|
+
finalLane = { backend: decision.backend, ui: decision.ui, ...(decision.floorplan ? { floorplan: decision.floorplan } : {}) };
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
const lane = value.slice('switch:'.length);
|
|
217
|
+
if (!value.startsWith('switch:') || !BUILT_LANES.includes(lane) || !decision.allowed.includes(lane)) {
|
|
218
|
+
return { content: NOT_CHOSEN };
|
|
219
|
+
}
|
|
220
|
+
const usable = (p) => (p && (!p.backend || decision.allowed.includes(p.backend)) ? p : undefined);
|
|
221
|
+
const { ui, floorplan } = uiFor(lane, signals, usable(pinR.pin)?.ui ?? usable(stdR.pin)?.ui);
|
|
222
|
+
finalLane = { backend: lane, ui, ...(floorplan ? { floorplan } : {}) };
|
|
223
|
+
decidedBy = 'developer';
|
|
224
|
+
}
|
|
225
|
+
// 8. A CAP lane needs a runtime.
|
|
226
|
+
if (finalLane.backend === 'cap' && !runtime) {
|
|
227
|
+
const rr = await confirm(runtimeCardRequest());
|
|
228
|
+
const answer = rr.value;
|
|
229
|
+
if (!answer || !RUNTIMES.includes(answer))
|
|
230
|
+
return { content: 'Runtime not chosen. Nothing was saved.' };
|
|
231
|
+
try {
|
|
232
|
+
await assertNotCliHome(laneRoot);
|
|
233
|
+
await writeRuntimeAnswer(laneRoot, answer);
|
|
234
|
+
}
|
|
235
|
+
catch (e) {
|
|
236
|
+
const why = e instanceof Error ? e.message : 'the runtime answer could not be saved';
|
|
237
|
+
return {
|
|
238
|
+
content: `${cardLine(why)}\nThe runtime is still unknown, so the lane was not saved. Nothing was saved. ` +
|
|
239
|
+
'Add the runtime to .cspeach/conventions.toml, then run /abap-app again.',
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
runtime = answer;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
// 9. The decision file, the flow state, the queued plan.
|
|
246
|
+
if (finalLane.backend === 'cap' && runtime)
|
|
247
|
+
finalLane = { ...finalLane, runtime };
|
|
248
|
+
// Task 7 review R10 — the shared chain token: CLI state only (the app flow's
|
|
249
|
+
// saved spec-gap path, never a tool argument), contained to cwd or the save root.
|
|
250
|
+
const gap = flow?.specGapPath ? await chainFromToken(ctx.cwd, flow.specGapPath) : undefined;
|
|
251
|
+
const planCmd = gap ? `/abap-plan --from ${gap.token}` : undefined;
|
|
252
|
+
let written;
|
|
253
|
+
try {
|
|
254
|
+
// I3 — one lane root for the write, every pinned read and from.lane.path.
|
|
255
|
+
await assertNotCliHome(laneRoot);
|
|
256
|
+
written = await writeLaneDecision(laneRoot, {
|
|
257
|
+
spec: { path: specRel, sha256: specSha },
|
|
258
|
+
...(gap ? { specGap: { path: gap.path } } : {}),
|
|
259
|
+
recommendation: decision,
|
|
260
|
+
final: finalLane,
|
|
261
|
+
decidedBy,
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
catch (e) {
|
|
265
|
+
return fail(`the lane decision could not be saved: ${e instanceof Error ? cardLine(e.message) : 'write failed'}`);
|
|
266
|
+
}
|
|
267
|
+
setLane(ctx.session, written.path, written.sha256, planCmd);
|
|
268
|
+
const who = decidedBy === 'developer' ? 'the developer'
|
|
269
|
+
: decidedBy === 'pin' ? `project pin (${decision.decidedByPath ?? '.cspeach/conventions.toml'})`
|
|
270
|
+
: decidedBy === 'standards' ? `standards file (${decision.decidedByPath ?? 'unknown file'})`
|
|
271
|
+
: 'the spec and the rules';
|
|
272
|
+
const ui = finalLane.ui === 'none' ? '' : ` + ${finalLane.ui}${finalLane.floorplan ? ` (${finalLane.floorplan})` : ''}`;
|
|
273
|
+
const lines = [
|
|
274
|
+
`Lane: ${finalLane.backend}${ui}${finalLane.runtime ? `, runtime ${finalLane.runtime}` : ''}.`,
|
|
275
|
+
`Decided by: ${cardLine(who)}.`,
|
|
276
|
+
`Decision file: ${written.path}`,
|
|
277
|
+
];
|
|
278
|
+
if (warnings.length)
|
|
279
|
+
lines.push(...warnings.map((w) => `Warning: ${cardLine(w)}`));
|
|
280
|
+
// M1 — the classic REPL has no card listener, so the rules took the lane:
|
|
281
|
+
// tell the person at the terminal in one plain line (no inquirer fallback).
|
|
282
|
+
if (headless && (deps.noCardTerminal ?? defaultNoCardTerminal)()) {
|
|
283
|
+
const shown = `${finalLane.backend}${ui}${finalLane.runtime ? `, runtime ${finalLane.runtime}` : ''}`;
|
|
284
|
+
ctx.chunkEmitter?.emit('info', `Lane chosen by the rules (no confirm card in this terminal): ${shown}.`);
|
|
285
|
+
}
|
|
286
|
+
if (flow?.specGapPath && planCmd) {
|
|
287
|
+
ctx.pendingDispatch?.set(planCmd);
|
|
288
|
+
lines.push('Next: the plan is being created.');
|
|
289
|
+
}
|
|
290
|
+
else if (flow?.specGapPath && headless) {
|
|
291
|
+
// I4(c) — headless / one-shot: no flow reaches a later run.
|
|
292
|
+
lines.push(interactiveRunLine(flow.specToken));
|
|
293
|
+
}
|
|
294
|
+
else if (flow?.specGapPath) {
|
|
295
|
+
// I4(b) — the flow is at the plan step: this typed command keeps it.
|
|
296
|
+
lines.push(`The spec-gap file path could not be used to queue the plan. To go on, run: /abap-plan --from ${shownToken(path.basename(flow.specGapPath), 'spec-gap file')}`);
|
|
297
|
+
}
|
|
298
|
+
else {
|
|
299
|
+
lines.push('No spec-gap file in this session yet, so no plan was queued.');
|
|
300
|
+
if (headless)
|
|
301
|
+
lines.push(interactiveRunLine(specRel));
|
|
302
|
+
}
|
|
303
|
+
return { content: lines.join('\n') };
|
|
304
|
+
}
|
|
305
|
+
registerTool({
|
|
306
|
+
name: 'app_lane_resolve',
|
|
307
|
+
description: 'App lane routing for /abap-app. Pass the spec file path and the signal sheet: for each fixed key a value and, ' +
|
|
308
|
+
'when the value is true / a service name, an evidence quote copied word for word from the spec (max 300 chars). ' +
|
|
309
|
+
'The CLI checks the quotes, reads the system facts, the project pin and the standards file, picks the lane, ' +
|
|
310
|
+
'shows the lane card, saves the decision file and queues the plan. Never guess a lane yourself. ' +
|
|
311
|
+
'Keys: app_requested, ui_only_service (string|null), report_only, analytical, transactional, custom_ui, ' +
|
|
312
|
+
'cap_external_users, cap_multi_system, cap_own_release_cycle, cap_ui_on_released_api.',
|
|
313
|
+
isMutating: false,
|
|
314
|
+
requiresSap: false,
|
|
315
|
+
category: 'project',
|
|
316
|
+
flagGated: true,
|
|
317
|
+
input_schema: {
|
|
318
|
+
type: 'object',
|
|
319
|
+
properties: {
|
|
320
|
+
specPath: { type: 'string', description: 'Spec file, relative to the workspace (.md, .txt, .docx, .pdf, .xlsx).' },
|
|
321
|
+
signals: {
|
|
322
|
+
type: 'object',
|
|
323
|
+
description: 'One entry per key: { value, evidence? }. evidence = an exact quote from the spec.',
|
|
324
|
+
},
|
|
325
|
+
},
|
|
326
|
+
required: ['specPath', 'signals'],
|
|
327
|
+
},
|
|
328
|
+
handler: (args, ctx) => appLaneResolveHandler(args, ctx),
|
|
329
|
+
});
|