@cspeach/cli 1.1.26 → 1.1.27
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 +8 -1
- package/dist/commands/whoami.js +1 -1
- package/dist/doctor/checks/skill.js +60 -11
- package/dist/doctor/run.js +0 -2
- package/dist/lane/app-chain.js +28 -0
- package/dist/projects/extract-design.js +4 -3
- package/dist/projects/extract-estimate.js +4 -3
- package/dist/projects/extract-spec-gap.js +28 -5
- package/dist/projects/save-command.js +4 -1
- package/dist/projects/title-heading.js +36 -0
- package/dist/renderer/thinking-heartbeat.js +2 -1
- package/dist/sap/unreachable.js +4 -1
- package/dist/skills/bundled-skills.js +1 -1
- package/package.json +121 -121
- package/dist/doctor/checks/zcspeach.js +0 -96
package/dist/agent/loop.js
CHANGED
|
@@ -39,7 +39,7 @@ import { consumeTodoSetThisTurn } from '../ui/todo-emitter.js';
|
|
|
39
39
|
import { runSaveCommand, runPromoteCommand, detectArtifactSkill, offerAnswerBlockers } from '../projects/index.js';
|
|
40
40
|
import { planLaneBlockForTurn } from '../projects/plan-lane.js';
|
|
41
41
|
import { clearAppFlow, getAppFlow, setGapConfirmed, startAppFlow } from '../lane/app-flow.js';
|
|
42
|
-
import { afterPlanSave, afterSpecGapSave, cleanSpecToken, extractSpecToken, FLOW_ENDED_LINE, gateAppFlowForTurn, resolveAppSpec, sameRealFile, STEP_1, STEP_3, } from '../lane/app-chain.js';
|
|
42
|
+
import { afterPlanSave, afterSpecGapNotSaved, afterSpecGapSave, cleanSpecToken, extractSpecToken, FLOW_ENDED_LINE, gateAppFlowForTurn, resolveAppSpec, sameRealFile, STEP_1, STEP_3, } from '../lane/app-chain.js';
|
|
43
43
|
import { shownToken } from '../lane/card.js';
|
|
44
44
|
import { parseFromFlag, coerceDocFromFlagToAttachment } from '../skills/promotion-dispatch.js';
|
|
45
45
|
import { input } from '@inquirer/prompts';
|
|
@@ -300,6 +300,7 @@ export async function maybeOfferSave(p) {
|
|
|
300
300
|
return 'y';
|
|
301
301
|
}
|
|
302
302
|
: inkAwarePrompt;
|
|
303
|
+
let notSaved;
|
|
303
304
|
try {
|
|
304
305
|
const savedPath = await runSaveCommand({
|
|
305
306
|
skillOutput: p.assistantText,
|
|
@@ -320,6 +321,7 @@ export async function maybeOfferSave(p) {
|
|
|
320
321
|
// the skill it saves (the /abap-plan turn that got the lane block); a plan
|
|
321
322
|
// found in another skill's output (detectArtifactSkill) never gets it.
|
|
322
323
|
...(p.lane && p.skill === saveSkill ? { lane: p.lane } : {}),
|
|
324
|
+
onNotSaved: (reason) => { notSaved = reason; },
|
|
323
325
|
});
|
|
324
326
|
// After a spec-gap file is saved, let a developer answer the open BLOCKER
|
|
325
327
|
// questions inline (recorded as `answered` items in the SAME file) — the
|
|
@@ -348,6 +350,11 @@ export async function maybeOfferSave(p) {
|
|
|
348
350
|
else if (saveSkill === 'abap-plan')
|
|
349
351
|
await afterPlanSave(hooks, savedPath);
|
|
350
352
|
}
|
|
353
|
+
// 2026-10-04 — a spec-gap that produced no file inside the chain says so
|
|
354
|
+
// (one line) instead of the chain stopping in silence.
|
|
355
|
+
if (!savedPath && notSaved && p.appChain && p.skill === saveSkill && saveSkill === 'abap-spec-gap') {
|
|
356
|
+
afterSpecGapNotSaved({ ...p.appChain, headless: isHeadless(), emit: p.emit }, notSaved, p.assistantText);
|
|
357
|
+
}
|
|
351
358
|
}
|
|
352
359
|
catch (err) {
|
|
353
360
|
p.emit('\n' + formatAttention({ text: 'Not saved as a project file', fact: String(err?.message ?? err) }).trimEnd());
|
package/dist/commands/whoami.js
CHANGED
|
@@ -28,7 +28,7 @@ export async function runWhoamiCommand(deps) {
|
|
|
28
28
|
deps.print(formatFailure({
|
|
29
29
|
what: 'Could not read your account',
|
|
30
30
|
detail: [unreachable ? `could not reach the server (${me.error})` : `the server answered ${me.status} (${me.error})`],
|
|
31
|
-
next: unreachable ? 'check
|
|
31
|
+
next: unreachable ? 'check this machine can reach cspeach.dev (company network or proxy settings), then run cspeach whoami again' : 'cspeach login',
|
|
32
32
|
}).trimEnd());
|
|
33
33
|
return 1;
|
|
34
34
|
}
|
|
@@ -1,24 +1,73 @@
|
|
|
1
1
|
import crypto from 'node:crypto';
|
|
2
2
|
import { loadConfig } from '../../config/loader.js';
|
|
3
3
|
import { getStore } from '../../auth/api-key.js';
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
4
|
+
import { ManifestClient, DEFAULT_MANIFEST_URL } from '../../skills/manifest-client.js';
|
|
5
|
+
const PROBE_SKILL = 'abap-radar';
|
|
6
|
+
/**
|
|
7
|
+
* Fetch one skill the way a managed session does and prove its integrity.
|
|
8
|
+
*
|
|
9
|
+
* 2026-10-04 — the first version of this check (561807b9) expected a plain
|
|
10
|
+
* text body plus an `x-skill-sha256` header. The proxy serves JSON
|
|
11
|
+
* `{ name, body, sha256, … }` and sends no such header, so the check hashed
|
|
12
|
+
* the whole JSON text, compared it with '' and always reported a mismatch.
|
|
13
|
+
* Now: hash the delivered `body` bytes (exactly as served — CRLF included,
|
|
14
|
+
* the same bytes the seed signed) and compare with the server's `sha256` and
|
|
15
|
+
* with the signed manifest entry, the real trust anchor.
|
|
16
|
+
*/
|
|
17
|
+
export async function checkSkill(deps = {}) {
|
|
18
|
+
const doFetch = deps.fetch ?? ((u, i) => fetch(u, i));
|
|
19
|
+
const proxyUrl = deps.proxyUrl ?? (await loadConfig()).proxy_url;
|
|
20
|
+
const key = deps.getKey ? await deps.getKey() : await (await getStore()).get();
|
|
8
21
|
if (!key)
|
|
9
22
|
return { name: 'skill', ok: false, message: 'skipped (no API key)' };
|
|
23
|
+
let body;
|
|
24
|
+
let claimed;
|
|
10
25
|
try {
|
|
11
|
-
const r = await
|
|
26
|
+
const r = await doFetch(`${proxyUrl}/v1/skills/${PROBE_SKILL}`, { headers: { Authorization: `Bearer ${key}` } });
|
|
12
27
|
if (!r.ok)
|
|
13
28
|
return { name: 'skill', ok: false, message: `HTTP ${r.status}` };
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
return { name: 'skill', ok: true, message: `abap-radar fetched (${body.length} bytes, SHA ${sha256.slice(0, 8)}...)` };
|
|
29
|
+
const j = await r.json();
|
|
30
|
+
if (typeof j.body !== 'string')
|
|
31
|
+
return { name: 'skill', ok: false, message: `${PROBE_SKILL}: response has no skill body` };
|
|
32
|
+
body = j.body;
|
|
33
|
+
claimed = typeof j.sha256 === 'string' ? j.sha256 : '';
|
|
20
34
|
}
|
|
21
35
|
catch (err) {
|
|
22
36
|
return { name: 'skill', ok: false, message: err.message };
|
|
23
37
|
}
|
|
38
|
+
const computed = crypto.createHash('sha256').update(body, 'utf-8').digest('hex');
|
|
39
|
+
if (claimed !== computed) {
|
|
40
|
+
return {
|
|
41
|
+
name: 'skill', ok: false,
|
|
42
|
+
message: `${PROBE_SKILL}: SHA256 mismatch (served sha256=${claimed || 'none'}, body=${computed})`,
|
|
43
|
+
hint: 'Possible tampering or proxy bug — contact support',
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
const getManifest = deps.manifest ?? (() => new ManifestClient(DEFAULT_MANIFEST_URL).fetchAndVerifyManifest());
|
|
47
|
+
let manifestNote;
|
|
48
|
+
try {
|
|
49
|
+
const m = await getManifest();
|
|
50
|
+
const entry = m.skills.find((s) => s.name === PROBE_SKILL);
|
|
51
|
+
if (!entry) {
|
|
52
|
+
manifestNote = `not in the signed manifest`;
|
|
53
|
+
}
|
|
54
|
+
else if (entry.sha256 !== computed) {
|
|
55
|
+
return {
|
|
56
|
+
name: 'skill', ok: false,
|
|
57
|
+
message: `${PROBE_SKILL} differs from the signed manifest (manifest=${entry.sha256.slice(0, 12)}…, body=${computed.slice(0, 12)}…)`,
|
|
58
|
+
hint: 'The skills were updated without republishing the signed manifest — contact support',
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
manifestNote = 'matches the signed manifest';
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
catch (err) {
|
|
66
|
+
const msg = err.message;
|
|
67
|
+
if (/signature invalid/i.test(msg)) {
|
|
68
|
+
return { name: 'skill', ok: false, message: msg.slice(0, 200), hint: 'Upgrade cspeach, or contact support if you are on the latest version' };
|
|
69
|
+
}
|
|
70
|
+
manifestNote = `signed manifest not checked: ${msg.slice(0, 120)}`;
|
|
71
|
+
}
|
|
72
|
+
return { name: 'skill', ok: true, message: `${PROBE_SKILL} fetched (${body.length} chars, SHA ${computed.slice(0, 8)}…, ${manifestNote})` };
|
|
24
73
|
}
|
package/dist/doctor/run.js
CHANGED
|
@@ -4,7 +4,6 @@ import { checkProxy } from './checks/proxy.js';
|
|
|
4
4
|
import { checkAuth } from './checks/auth.js';
|
|
5
5
|
import { checkKeychain } from './checks/keychain.js';
|
|
6
6
|
import { checkSap } from './checks/sap.js';
|
|
7
|
-
import { checkZcspeach } from './checks/zcspeach.js';
|
|
8
7
|
import { checkCert } from './checks/cert.js';
|
|
9
8
|
import { checkSkill } from './checks/skill.js';
|
|
10
9
|
import { checkKeychainFallback } from './checks/keychain-fallback.js';
|
|
@@ -111,7 +110,6 @@ export async function collectChecks() {
|
|
|
111
110
|
checks.push(await checkSystemRoles(cfg));
|
|
112
111
|
checks.push(await checkKeychain());
|
|
113
112
|
checks.push(await checkSap());
|
|
114
|
-
checks.push(await checkZcspeach());
|
|
115
113
|
checks.push(await checkCert());
|
|
116
114
|
checks.push(await checkSkill());
|
|
117
115
|
checks.push(await checkKeychainFallback());
|
package/dist/lane/app-chain.js
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import { promises as fs, statSync } from 'node:fs';
|
|
16
16
|
import path from 'node:path';
|
|
17
17
|
import { readProjectFile } from '../projects/status.js';
|
|
18
|
+
import { looksLikeSpecGap } from '../projects/extract-spec-gap.js';
|
|
18
19
|
import { parsePlanContent } from '../projects/plan-schema.js';
|
|
19
20
|
import { buildResumeCommand } from '../commands/plan-resume.js';
|
|
20
21
|
import { canonicalLanePath } from './paths.js';
|
|
@@ -248,6 +249,33 @@ export async function afterSpecGapSave(h, savedPath) {
|
|
|
248
249
|
h.queueNext(cmd);
|
|
249
250
|
h.emit(STEP_2);
|
|
250
251
|
}
|
|
252
|
+
/** The result was written but could not be read as a spec-gap: the flow waits at step 1. */
|
|
253
|
+
export const SPEC_GAP_NOT_SAVED_LINE = (specToken) => `The /abap-app run is paused at step 1: the spec-gap result could not be saved. Ask CSPeach to "re-issue the spec gap in the standard structure", or run /abap-app ${shownToken(specToken, 'spec file')} again.`;
|
|
254
|
+
/** The user said No to the save: the run ends here. */
|
|
255
|
+
export const SPEC_GAP_DECLINED_LINE = (specToken) => `The /abap-app run stopped at step 1 because the spec-gap result was not saved. To start again, run /abap-app ${shownToken(specToken, 'spec file')}.`;
|
|
256
|
+
/**
|
|
257
|
+
* 2026-10-04 — a spec-gap turn inside an app flow at its spec-gap step that
|
|
258
|
+
* produced no file must never end the chain in silence (live 1.1.26: the
|
|
259
|
+
* result was written, the save failed on its heading, nothing more was said).
|
|
260
|
+
* - unreadable + the text IS a spec-gap result → one line; the flow stays at
|
|
261
|
+
* step 1, so a re-issued result saves and moves the chain on.
|
|
262
|
+
* - unreadable + no result (a question back to the user) → nothing: the
|
|
263
|
+
* conversation is still in step 1.
|
|
264
|
+
* - declined → one line; the flow ends.
|
|
265
|
+
*/
|
|
266
|
+
export function afterSpecGapNotSaved(h, reason, assistantText) {
|
|
267
|
+
const flow = getAppFlow(h.session);
|
|
268
|
+
if (!flow || flow.expect !== 'spec-gap' || flow.specGapPath !== undefined)
|
|
269
|
+
return;
|
|
270
|
+
if (reason === 'declined') {
|
|
271
|
+
clearAppFlow(h.session);
|
|
272
|
+
h.emit(SPEC_GAP_DECLINED_LINE(flow.specToken));
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
if (!looksLikeSpecGap(assistantText))
|
|
276
|
+
return;
|
|
277
|
+
h.emit(SPEC_GAP_NOT_SAVED_LINE(flow.specToken));
|
|
278
|
+
}
|
|
251
279
|
/**
|
|
252
280
|
* After a plan save: when the saved plan carries `from.lane`, offer to start
|
|
253
281
|
* the guarded run. Inside an active app flow the flow's lane must be the
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
import { matchTitleHeading } from './title-heading.js';
|
|
1
2
|
// 2026-09-17 — tolerant on heading level and dash: the skill prescribes
|
|
2
3
|
// "## ABAP Design — …" but a live standalone run wrote "# ABAP Design — …"
|
|
3
4
|
// and the whole design was silently lost (no file, so /abap-rap --from had
|
|
4
5
|
// nothing to start from). H1..H3 and em/en/plain dash all count.
|
|
5
|
-
const
|
|
6
|
+
const TITLE_SPEC = { phrase: String.raw `abap[ \t]+design`, skillPrefix: 'abap-design' }; // 2026-10-04 — tolerant title, see title-heading.ts
|
|
6
7
|
const OBJECTS_HEADING_RE = /^### Objects to Create\s*$/m;
|
|
7
8
|
const DEPENDENCY_HEADING_RE = /^### Dependency Sequence\s*$/m;
|
|
8
9
|
const NEXT_HEADING_RE = /^###\s/m;
|
|
@@ -93,11 +94,11 @@ function parseDependencySequence(section, nameToId) {
|
|
|
93
94
|
return out;
|
|
94
95
|
}
|
|
95
96
|
export function extractDesign(markdown) {
|
|
96
|
-
const titleMatch =
|
|
97
|
+
const titleMatch = matchTitleHeading(markdown, TITLE_SPEC);
|
|
97
98
|
if (!titleMatch) {
|
|
98
99
|
throw new Error('Input is not a design output (no "## ABAP Design — ..." heading found)');
|
|
99
100
|
}
|
|
100
|
-
const title = titleMatch
|
|
101
|
+
const title = titleMatch.title || 'ABAP Design';
|
|
101
102
|
const objectsSection = sliceSection(markdown, OBJECTS_HEADING_RE);
|
|
102
103
|
if (!objectsSection) {
|
|
103
104
|
throw new Error('No "### Objects to Create" section found');
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { matchTitleHeading } from './title-heading.js';
|
|
1
2
|
// 2026-09-17 — tolerant on heading level (H1..H3) and dash, same reason as
|
|
2
3
|
// extract-design.ts.
|
|
3
|
-
const
|
|
4
|
+
const TITLE_SPEC = { phrase: String.raw `effort[ \t]+estimate`, skillPrefix: 'abap-estimate' }; // 2026-10-04 — tolerant title, see title-heading.ts
|
|
4
5
|
const COMPONENTS_HEADING_RE = /^### Component Breakdown\s*$/m;
|
|
5
6
|
const NEXT_HEADING_RE = /^###\s/m;
|
|
6
7
|
function sliceSection(markdown, headingRe) {
|
|
@@ -60,11 +61,11 @@ function parseComponentsTable(section) {
|
|
|
60
61
|
return rows;
|
|
61
62
|
}
|
|
62
63
|
export function extractEstimate(markdown) {
|
|
63
|
-
const titleMatch =
|
|
64
|
+
const titleMatch = matchTitleHeading(markdown, TITLE_SPEC);
|
|
64
65
|
if (!titleMatch) {
|
|
65
66
|
throw new Error('Input is not an estimate output (no "## Effort Estimate — ..." heading found)');
|
|
66
67
|
}
|
|
67
|
-
const title = titleMatch
|
|
68
|
+
const title = titleMatch.title || 'Effort Estimate';
|
|
68
69
|
const section = sliceSection(markdown, COMPONENTS_HEADING_RE);
|
|
69
70
|
if (!section) {
|
|
70
71
|
throw new Error('No "### Component Breakdown" section found');
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
|
|
2
|
-
//
|
|
3
|
-
|
|
1
|
+
import { matchTitleHeading } from './title-heading.js';
|
|
2
|
+
// 2026-09-17 — tolerant on heading level and dash. 2026-10-04 — and on the
|
|
3
|
+
// separator (colon / none), casing, bold and an "abap-spec-gap —" prefix: a
|
|
4
|
+
// live /abap-app run wrote "Spec Gap Analysis: …" and the chain stopped at
|
|
5
|
+
// step 1 because nothing was saved. See title-heading.ts.
|
|
6
|
+
const TITLE_SPEC = { phrase: String.raw `spec(?:ification)?[ \t-]*gap[ \t]+analysis`, skillPrefix: 'abap-spec-gap' };
|
|
4
7
|
const DOMAIN_RE = /^\*\*Inferred Domain:\*\*\s*(.+?)(?:\s*\[INFERRED.*?\])?$/m;
|
|
5
8
|
const INPUT_TYPE_RE = /^\*\*Input Type:\*\*\s*(.+)$/m;
|
|
6
9
|
const SOURCE_DOC_RE = /^\*\*Source Document:\*\*\s*(.+)$/m;
|
|
@@ -54,14 +57,16 @@ const SKIP_SECTIONS = [
|
|
|
54
57
|
'Recommended Next Steps', // is a paragraph
|
|
55
58
|
];
|
|
56
59
|
export function extractSpecGap(markdown) {
|
|
57
|
-
const titleMatch =
|
|
60
|
+
const titleMatch = matchTitleHeading(markdown, TITLE_SPEC);
|
|
58
61
|
if (!titleMatch) {
|
|
59
62
|
throw new Error('Input is not a spec-gap output (no "## Spec Gap Analysis — ..." heading found)');
|
|
60
63
|
}
|
|
61
|
-
const title = titleMatch[1].trim();
|
|
62
64
|
const inputType = (INPUT_TYPE_RE.exec(markdown)?.[1] ?? 'Unknown').trim();
|
|
63
65
|
const inferredDomain = (DOMAIN_RE.exec(markdown)?.[1] ?? 'Unknown').trim();
|
|
64
66
|
const sourceDocument = SOURCE_DOC_RE.exec(markdown)?.[1].trim() || undefined;
|
|
67
|
+
// A bare "## Spec Gap Analysis" heading still names the artefact: fall back
|
|
68
|
+
// to the source document, then to a plain label.
|
|
69
|
+
const title = titleMatch.title || sourceDocument || 'Spec Gap Analysis';
|
|
65
70
|
const sectionStarts = [];
|
|
66
71
|
let m;
|
|
67
72
|
const reCopy = new RegExp(SECTION_HEADING_RE.source, SECTION_HEADING_RE.flags);
|
|
@@ -103,6 +108,24 @@ export function extractSpecGap(markdown) {
|
|
|
103
108
|
items,
|
|
104
109
|
};
|
|
105
110
|
}
|
|
111
|
+
/**
|
|
112
|
+
* True when the text has the body of a spec-gap result (a known gap section
|
|
113
|
+
* heading, or a numbered [BLOCKER]/[IMPORTANT]/[NICE TO KNOW] line). Tells a
|
|
114
|
+
* result that failed to save apart from a conversational turn (a question back
|
|
115
|
+
* to the user), which is not an artefact at all.
|
|
116
|
+
*/
|
|
117
|
+
export function looksLikeSpecGap(markdown) {
|
|
118
|
+
if (/^\s*\d+\.\s*(?:\*\*)?\[(?:BLOCKER|IMPORTANT|NICE TO KNOW)\]/m.test(markdown))
|
|
119
|
+
return true;
|
|
120
|
+
const re = new RegExp(SECTION_HEADING_RE.source, SECTION_HEADING_RE.flags);
|
|
121
|
+
let m;
|
|
122
|
+
while ((m = re.exec(markdown)) !== null) {
|
|
123
|
+
const name = m[1].toLowerCase();
|
|
124
|
+
if (KNOWN_GAP_SECTIONS.some((k) => name.includes(k.toLowerCase())))
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
106
129
|
function parseGapsFromSection(sectionBody, nextId) {
|
|
107
130
|
const out = [];
|
|
108
131
|
const gapRe = new RegExp(GAP_LINE_RE.source); // per-line, no /m — exec on each line
|
|
@@ -328,6 +328,7 @@ export async function runSaveCommand(args) {
|
|
|
328
328
|
args.log(`[save] ${e instanceof Error ? e.message : String(e)}`);
|
|
329
329
|
args.log(`[save] Ask CSPeach to "re-issue the ${spec.artefactType} in the standard structure" and it will be saved then.`);
|
|
330
330
|
}
|
|
331
|
+
args.onNotSaved?.('unreadable');
|
|
331
332
|
return null;
|
|
332
333
|
}
|
|
333
334
|
// App lane routing (Task 6) — a plan that carries a lane is checked against
|
|
@@ -366,8 +367,10 @@ export async function runSaveCommand(args) {
|
|
|
366
367
|
// prompt, give maybeOfferSave's headless prompt a question-aware answer
|
|
367
368
|
// first.
|
|
368
369
|
const answer = await askYesNo({ question: 'Save as project file?', confirm: args.confirm, prompt: args.prompt });
|
|
369
|
-
if (!isYes(answer))
|
|
370
|
+
if (!isYes(answer)) {
|
|
371
|
+
args.onNotSaved?.('declined');
|
|
370
372
|
return null;
|
|
373
|
+
}
|
|
371
374
|
let env;
|
|
372
375
|
if (planExtract && prior) {
|
|
373
376
|
// C1 (D30) — the 49dd duplicate: the generic save used to mint a fresh
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// 2026-10-04 — one tolerant title-heading matcher for the prose artefacts
|
|
2
|
+
// (spec-gap, design, estimate). A live /abap-app run lost its spec-gap file
|
|
3
|
+
// because the model wrote "Spec Gap Analysis: …" and the saver only accepted
|
|
4
|
+
// "## Spec Gap Analysis — …". Models drift on the separator (dash, en/em dash,
|
|
5
|
+
// colon, none), the heading level (#..######, or none), the casing and an
|
|
6
|
+
// optional "<skill-name> —" prefix. All of those count; an unrelated heading
|
|
7
|
+
// does not.
|
|
8
|
+
// Horizontal whitespace only: a match never runs onto the next line.
|
|
9
|
+
const SEP = String.raw `[ \t]*[—–:-][ \t]*`;
|
|
10
|
+
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
11
|
+
function strip(title) {
|
|
12
|
+
return title
|
|
13
|
+
.replace(/\s*\*\*\s*$/, '') // closing bold
|
|
14
|
+
.replace(/\s+#+\s*$/, '') // closing ATX hashes
|
|
15
|
+
.trim();
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Find the artefact's title heading. Heading lines (`#`..`######`) are tried
|
|
19
|
+
* first and may use any separator or none; a plain line (no `#`, optionally
|
|
20
|
+
* bold) must use a dash or colon, so ordinary prose never counts. The first
|
|
21
|
+
* heading line wins over any plain line.
|
|
22
|
+
*/
|
|
23
|
+
export function matchTitleHeading(markdown, spec) {
|
|
24
|
+
const prefix = String.raw `(?:${escapeRe(spec.skillPrefix)}${SEP})?`;
|
|
25
|
+
const phrase = `(?:${spec.phrase})`;
|
|
26
|
+
// `## [**][abap-x — ]Phrase[ — | : | ' ']Title[**]` or `## abap-x — Title`
|
|
27
|
+
const heading = new RegExp(String.raw `^[ \t]{0,3}#{1,6}[ \t]+(?:\*\*)?(?:${prefix}${phrase}(?:(?:${SEP}|[ \t]+)(.*?))?|${escapeRe(spec.skillPrefix)}${SEP}(.+?))[ \t]*$`, 'im');
|
|
28
|
+
const h = heading.exec(markdown);
|
|
29
|
+
if (h)
|
|
30
|
+
return { title: strip(h[1] ?? h[2] ?? '') };
|
|
31
|
+
const plain = new RegExp(String.raw `^[ \t]{0,3}(?:\*\*)?(?:${prefix}${phrase}${SEP}(.+?)|${escapeRe(spec.skillPrefix)}${SEP}(.+?))[ \t]*$`, 'im');
|
|
32
|
+
const p = plain.exec(markdown);
|
|
33
|
+
if (p)
|
|
34
|
+
return { title: strip(p[1] ?? p[2] ?? '') };
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
@@ -49,7 +49,8 @@ const GENERIC_SUFFIXES = [
|
|
|
49
49
|
{ at: 30_000, suffix: ' (30s)' },
|
|
50
50
|
{ at: 60_000, suffix: ' (60s — still running, hold on)' },
|
|
51
51
|
{ at: 120_000, suffix: ' (2m — still running; Ctrl+C aborts the turn)' },
|
|
52
|
-
|
|
52
|
+
// 2026-10-04: never assume a VPN — many users sit on the company network.
|
|
53
|
+
{ at: 300_000, suffix: ' (5m — likely stuck; Ctrl+C and check network access to the SAP system)' },
|
|
53
54
|
];
|
|
54
55
|
/**
|
|
55
56
|
* 2026-07-06 (audit-timeout) — the plan-chain audit's own liveness schedule.
|
package/dist/sap/unreachable.js
CHANGED
|
@@ -23,6 +23,9 @@ export function unreachableCode(err) {
|
|
|
23
23
|
return undefined;
|
|
24
24
|
}
|
|
25
25
|
export function unreachableMessage(alias, host, port, code) {
|
|
26
|
-
return (
|
|
26
|
+
return (
|
|
27
|
+
// 2026-10-04: never assume a VPN — many users sit on the company network.
|
|
28
|
+
`Can't reach SAP system ${alias} (${host}:${port}) from this machine — ${code}. ` +
|
|
29
|
+
'Check network access to the SAP system (company network or VPN), ' +
|
|
27
30
|
'or run with --no-sap for work that does not need SAP.');
|
|
28
31
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// GENERATED FILE — bundled skills for local mode.
|
|
2
2
|
// Source: https://manifest.cspeach.dev/v1.json
|
|
3
|
-
// Generated: 2026-10-
|
|
3
|
+
// Generated: 2026-10-04T19:28:08.728Z
|
|
4
4
|
// Manifest signature verified at build time.
|
|
5
5
|
export const BUNDLED_SKILLS = {
|
|
6
6
|
"abap-app": {
|
package/package.json
CHANGED
|
@@ -1,121 +1,121 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@cspeach/cli",
|
|
3
|
-
"version": "1.1.
|
|
4
|
-
"description": "CSPeach — AI-assisted ABAP development CLI. Direct SAP access, 34 skills, safety gates.",
|
|
5
|
-
"keywords": [
|
|
6
|
-
"abap",
|
|
7
|
-
"sap",
|
|
8
|
-
"ai",
|
|
9
|
-
"cli",
|
|
10
|
-
"ai-assisted-development",
|
|
11
|
-
"s4hana",
|
|
12
|
-
"abap-development",
|
|
13
|
-
"claude",
|
|
14
|
-
"anthropic"
|
|
15
|
-
],
|
|
16
|
-
"homepage": "https://cspeach.dev",
|
|
17
|
-
"bugs": {
|
|
18
|
-
"email": "laeeq.siddique@cremencing.com"
|
|
19
|
-
},
|
|
20
|
-
"author": "Laeeq Siddique (Cremencing Solutions)",
|
|
21
|
-
"license": "SEE LICENSE IN LICENSE",
|
|
22
|
-
"private": false,
|
|
23
|
-
"publishConfig": {
|
|
24
|
-
"access": "public"
|
|
25
|
-
},
|
|
26
|
-
"type": "module",
|
|
27
|
-
"bin": {
|
|
28
|
-
"cspeach": "dist/index.js"
|
|
29
|
-
},
|
|
30
|
-
"scripts": {
|
|
31
|
-
"dev": "tsx src/index.ts",
|
|
32
|
-
"prebuild": "tsx scripts/prebuild.ts",
|
|
33
|
-
"build": "tsc && node scripts/make-bin-executable.js",
|
|
34
|
-
"install:global": "tsx scripts/install-global.ts",
|
|
35
|
-
"pretest": "tsx scripts/sync-signing-key.ts || node -e \"console.warn('WARN: sync-signing-key skipped — using stub')\"",
|
|
36
|
-
"start": "node dist/index.js",
|
|
37
|
-
"typecheck": "tsc --noEmit",
|
|
38
|
-
"test": "vitest run",
|
|
39
|
-
"test:watch": "vitest",
|
|
40
|
-
"test:smoke-e2e": "vitest run --config vitest.smoke-e2e.config.ts",
|
|
41
|
-
"adt:smoke": "tsx scripts/adt-smoke.ts",
|
|
42
|
-
"smoke:all": "pnpm zcspeach:smoke && pnpm adt:smoke",
|
|
43
|
-
"zcspeach:smoke": "tsx scripts/zcspeach-smoke.ts",
|
|
44
|
-
"bench:models": "tsx scripts/bench-models.ts",
|
|
45
|
-
"bench:audit": "tsx scripts/bench-audit-task.ts",
|
|
46
|
-
"bench:replay": "tsx bench/replay/run.ts",
|
|
47
|
-
"fiori": "tsx src/tools/fiori/bin.ts",
|
|
48
|
-
"prepare": "husky",
|
|
49
|
-
"prepublishOnly": "node -e \"const deps=require('./package.json').dependencies||{}; for (const n of ['@cspeach/sap-client','@cspeach/register-core']) { const d=deps[n]||''; if(d.startsWith('file:')||d.startsWith('link:')){console.error('publish blocked: '+n+' is a local link ('+d+') — point it at the published version before npm publish'); process.exit(1)} }\""
|
|
50
|
-
},
|
|
51
|
-
"engines": {
|
|
52
|
-
"node": ">=20.0.0"
|
|
53
|
-
},
|
|
54
|
-
"dependencies": {
|
|
55
|
-
"@anthropic-ai/sdk": "^0.90.0",
|
|
56
|
-
"@cspeach/register-core": "0.1.0",
|
|
57
|
-
"@cspeach/sap-client": "0.1.15",
|
|
58
|
-
"@iarna/toml": "^2.2.5",
|
|
59
|
-
"@inkjs/ui": "^2.0.0",
|
|
60
|
-
"@inquirer/prompts": "^7.0.0",
|
|
61
|
-
"@napi-rs/keyring": "2.1.0",
|
|
62
|
-
"@noble/ed25519": "2.1.0",
|
|
63
|
-
"@noble/hashes": "1.4.0",
|
|
64
|
-
"@sap-ux/annotation-converter": "0.10.21",
|
|
65
|
-
"@sap-ux/edmx-parser": "0.10.0",
|
|
66
|
-
"@sap-ux/fe-fpm-writer": "0.43.7",
|
|
67
|
-
"@sap-ux/fiori-elements-writer": "^3.0.39",
|
|
68
|
-
"@sap-ux/fiori-freestyle-writer": "3.0.12",
|
|
69
|
-
"@sap-ux/vocabularies-types": "0.15.0",
|
|
70
|
-
"adm-zip": "0.5.17",
|
|
71
|
-
"chalk": "^5.3.0",
|
|
72
|
-
"cli-highlight": "^2.1.11",
|
|
73
|
-
"cli-table3": "^0.6.5",
|
|
74
|
-
"clipboardy": "^5.3.1",
|
|
75
|
-
"cross-spawn": "^7.0.6",
|
|
76
|
-
"diff": "^9.0.0",
|
|
77
|
-
"ejs": "3.1.10",
|
|
78
|
-
"fuse.js": "^7.0.0",
|
|
79
|
-
"ink": "^5.0.0",
|
|
80
|
-
"ink-text-input": "^6.0.0",
|
|
81
|
-
"jose": "^5.9.0",
|
|
82
|
-
"mammoth": "^1.8.0",
|
|
83
|
-
"marked": "^11.2.0",
|
|
84
|
-
"marked-terminal": "^6.2.0",
|
|
85
|
-
"mem-fs": "2.1.0",
|
|
86
|
-
"mem-fs-editor": "9.4.0",
|
|
87
|
-
"node-machine-id": "^1.1.12",
|
|
88
|
-
"openai": "^6.49.0",
|
|
89
|
-
"puppeteer-core": "24.10.2",
|
|
90
|
-
"react": "^18.3.0",
|
|
91
|
-
"string-width": "^7.2.0",
|
|
92
|
-
"turndown": "^7.2.0",
|
|
93
|
-
"unpdf": "1.7.0",
|
|
94
|
-
"zod": "^3.23.0"
|
|
95
|
-
},
|
|
96
|
-
"devDependencies": {
|
|
97
|
-
"@sap-ux/ui5-middleware-fe-mockserver": "2.4.17",
|
|
98
|
-
"@types/adm-zip": "0.5.8",
|
|
99
|
-
"@types/cross-spawn": "^6.0.6",
|
|
100
|
-
"@types/diff": "^7.0.2",
|
|
101
|
-
"@types/ejs": "^3.1.5",
|
|
102
|
-
"@types/node": "^20.17.0",
|
|
103
|
-
"@types/react": "^18.3.0",
|
|
104
|
-
"@types/turndown": "^5.0.5",
|
|
105
|
-
"@ui5/builder": "4.3.2",
|
|
106
|
-
"@ui5/fs": "4.0.6",
|
|
107
|
-
"docx": "^9.0.2",
|
|
108
|
-
"husky": "^9.1.7",
|
|
109
|
-
"ink-testing-library": "^4.0.0",
|
|
110
|
-
"pdf-lib": "^1.17.1",
|
|
111
|
-
"strip-ansi": "6.0.1",
|
|
112
|
-
"tsx": "^4.19.0",
|
|
113
|
-
"typescript": "^5.7.0",
|
|
114
|
-
"vitest": "^2.1.0"
|
|
115
|
-
},
|
|
116
|
-
"pnpm": {
|
|
117
|
-
"onlyBuiltDependencies": [
|
|
118
|
-
"esbuild"
|
|
119
|
-
]
|
|
120
|
-
}
|
|
121
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@cspeach/cli",
|
|
3
|
+
"version": "1.1.27",
|
|
4
|
+
"description": "CSPeach — AI-assisted ABAP development CLI. Direct SAP access, 34 skills, safety gates.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"abap",
|
|
7
|
+
"sap",
|
|
8
|
+
"ai",
|
|
9
|
+
"cli",
|
|
10
|
+
"ai-assisted-development",
|
|
11
|
+
"s4hana",
|
|
12
|
+
"abap-development",
|
|
13
|
+
"claude",
|
|
14
|
+
"anthropic"
|
|
15
|
+
],
|
|
16
|
+
"homepage": "https://cspeach.dev",
|
|
17
|
+
"bugs": {
|
|
18
|
+
"email": "laeeq.siddique@cremencing.com"
|
|
19
|
+
},
|
|
20
|
+
"author": "Laeeq Siddique (Cremencing Solutions)",
|
|
21
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
22
|
+
"private": false,
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
},
|
|
26
|
+
"type": "module",
|
|
27
|
+
"bin": {
|
|
28
|
+
"cspeach": "dist/index.js"
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"dev": "tsx src/index.ts",
|
|
32
|
+
"prebuild": "tsx scripts/prebuild.ts",
|
|
33
|
+
"build": "tsc && node scripts/make-bin-executable.js",
|
|
34
|
+
"install:global": "tsx scripts/install-global.ts",
|
|
35
|
+
"pretest": "tsx scripts/sync-signing-key.ts || node -e \"console.warn('WARN: sync-signing-key skipped — using stub')\"",
|
|
36
|
+
"start": "node dist/index.js",
|
|
37
|
+
"typecheck": "tsc --noEmit",
|
|
38
|
+
"test": "vitest run",
|
|
39
|
+
"test:watch": "vitest",
|
|
40
|
+
"test:smoke-e2e": "vitest run --config vitest.smoke-e2e.config.ts",
|
|
41
|
+
"adt:smoke": "tsx scripts/adt-smoke.ts",
|
|
42
|
+
"smoke:all": "pnpm zcspeach:smoke && pnpm adt:smoke",
|
|
43
|
+
"zcspeach:smoke": "tsx scripts/zcspeach-smoke.ts",
|
|
44
|
+
"bench:models": "tsx scripts/bench-models.ts",
|
|
45
|
+
"bench:audit": "tsx scripts/bench-audit-task.ts",
|
|
46
|
+
"bench:replay": "tsx bench/replay/run.ts",
|
|
47
|
+
"fiori": "tsx src/tools/fiori/bin.ts",
|
|
48
|
+
"prepare": "husky",
|
|
49
|
+
"prepublishOnly": "node -e \"const deps=require('./package.json').dependencies||{}; for (const n of ['@cspeach/sap-client','@cspeach/register-core']) { const d=deps[n]||''; if(d.startsWith('file:')||d.startsWith('link:')){console.error('publish blocked: '+n+' is a local link ('+d+') — point it at the published version before npm publish'); process.exit(1)} }\""
|
|
50
|
+
},
|
|
51
|
+
"engines": {
|
|
52
|
+
"node": ">=20.0.0"
|
|
53
|
+
},
|
|
54
|
+
"dependencies": {
|
|
55
|
+
"@anthropic-ai/sdk": "^0.90.0",
|
|
56
|
+
"@cspeach/register-core": "0.1.0",
|
|
57
|
+
"@cspeach/sap-client": "0.1.15",
|
|
58
|
+
"@iarna/toml": "^2.2.5",
|
|
59
|
+
"@inkjs/ui": "^2.0.0",
|
|
60
|
+
"@inquirer/prompts": "^7.0.0",
|
|
61
|
+
"@napi-rs/keyring": "2.1.0",
|
|
62
|
+
"@noble/ed25519": "2.1.0",
|
|
63
|
+
"@noble/hashes": "1.4.0",
|
|
64
|
+
"@sap-ux/annotation-converter": "0.10.21",
|
|
65
|
+
"@sap-ux/edmx-parser": "0.10.0",
|
|
66
|
+
"@sap-ux/fe-fpm-writer": "0.43.7",
|
|
67
|
+
"@sap-ux/fiori-elements-writer": "^3.0.39",
|
|
68
|
+
"@sap-ux/fiori-freestyle-writer": "3.0.12",
|
|
69
|
+
"@sap-ux/vocabularies-types": "0.15.0",
|
|
70
|
+
"adm-zip": "0.5.17",
|
|
71
|
+
"chalk": "^5.3.0",
|
|
72
|
+
"cli-highlight": "^2.1.11",
|
|
73
|
+
"cli-table3": "^0.6.5",
|
|
74
|
+
"clipboardy": "^5.3.1",
|
|
75
|
+
"cross-spawn": "^7.0.6",
|
|
76
|
+
"diff": "^9.0.0",
|
|
77
|
+
"ejs": "3.1.10",
|
|
78
|
+
"fuse.js": "^7.0.0",
|
|
79
|
+
"ink": "^5.0.0",
|
|
80
|
+
"ink-text-input": "^6.0.0",
|
|
81
|
+
"jose": "^5.9.0",
|
|
82
|
+
"mammoth": "^1.8.0",
|
|
83
|
+
"marked": "^11.2.0",
|
|
84
|
+
"marked-terminal": "^6.2.0",
|
|
85
|
+
"mem-fs": "2.1.0",
|
|
86
|
+
"mem-fs-editor": "9.4.0",
|
|
87
|
+
"node-machine-id": "^1.1.12",
|
|
88
|
+
"openai": "^6.49.0",
|
|
89
|
+
"puppeteer-core": "24.10.2",
|
|
90
|
+
"react": "^18.3.0",
|
|
91
|
+
"string-width": "^7.2.0",
|
|
92
|
+
"turndown": "^7.2.0",
|
|
93
|
+
"unpdf": "1.7.0",
|
|
94
|
+
"zod": "^3.23.0"
|
|
95
|
+
},
|
|
96
|
+
"devDependencies": {
|
|
97
|
+
"@sap-ux/ui5-middleware-fe-mockserver": "2.4.17",
|
|
98
|
+
"@types/adm-zip": "0.5.8",
|
|
99
|
+
"@types/cross-spawn": "^6.0.6",
|
|
100
|
+
"@types/diff": "^7.0.2",
|
|
101
|
+
"@types/ejs": "^3.1.5",
|
|
102
|
+
"@types/node": "^20.17.0",
|
|
103
|
+
"@types/react": "^18.3.0",
|
|
104
|
+
"@types/turndown": "^5.0.5",
|
|
105
|
+
"@ui5/builder": "4.3.2",
|
|
106
|
+
"@ui5/fs": "4.0.6",
|
|
107
|
+
"docx": "^9.0.2",
|
|
108
|
+
"husky": "^9.1.7",
|
|
109
|
+
"ink-testing-library": "^4.0.0",
|
|
110
|
+
"pdf-lib": "^1.17.1",
|
|
111
|
+
"strip-ansi": "6.0.1",
|
|
112
|
+
"tsx": "^4.19.0",
|
|
113
|
+
"typescript": "^5.7.0",
|
|
114
|
+
"vitest": "^2.1.0"
|
|
115
|
+
},
|
|
116
|
+
"pnpm": {
|
|
117
|
+
"onlyBuiltDependencies": [
|
|
118
|
+
"esbuild"
|
|
119
|
+
]
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
import keychain from '../../auth/keychain.js';
|
|
2
|
-
import { readFile } from 'node:fs/promises';
|
|
3
|
-
import { loadConfig, resolveSapTls } from '../../config/loader.js';
|
|
4
|
-
import { probeGet } from './_http-probe.js';
|
|
5
|
-
import { sapChecksSkipped, standaloneSkip } from './_standalone-skip.js';
|
|
6
|
-
const SAP_SERVICE = 'cspeach-sap';
|
|
7
|
-
export async function checkZcspeach() {
|
|
8
|
-
const cfg = await loadConfig();
|
|
9
|
-
// Standalone mode ONLY — an opted-in paste-mode user has nothing to probe.
|
|
10
|
-
// Everything below (including the no-alias failure) is unchanged.
|
|
11
|
-
if (sapChecksSkipped(cfg))
|
|
12
|
-
return standaloneSkip('zcspeach');
|
|
13
|
-
const systems = Object.entries(cfg.sap);
|
|
14
|
-
if (systems.length === 0) {
|
|
15
|
-
return {
|
|
16
|
-
name: 'zcspeach',
|
|
17
|
-
ok: false,
|
|
18
|
-
message: 'no SAP systems configured',
|
|
19
|
-
hint: 'Run: cspeach config add-sap',
|
|
20
|
-
};
|
|
21
|
-
}
|
|
22
|
-
const results = [];
|
|
23
|
-
let anyFailed = false;
|
|
24
|
-
for (const [alias, sys] of systems) {
|
|
25
|
-
const protocol = sys.useSsl ? 'https' : 'http';
|
|
26
|
-
const url = `${protocol}://${sys.host}:${sys.port}/sap/bc/zcspeach/discovery`;
|
|
27
|
-
let pwd = null;
|
|
28
|
-
try {
|
|
29
|
-
pwd = await keychain.getPassword(SAP_SERVICE, alias);
|
|
30
|
-
}
|
|
31
|
-
catch {
|
|
32
|
-
// keychain unavailable — fall through, will be skipped below
|
|
33
|
-
}
|
|
34
|
-
if (!pwd) {
|
|
35
|
-
results.push(`${alias}: skipped (no password in keychain)`);
|
|
36
|
-
continue;
|
|
37
|
-
}
|
|
38
|
-
// TLS posture (2026-06-14): verification ON by default; resolveSapTls maps
|
|
39
|
-
// the per-alias config (ca_cert_path / insecure_skip_tls_verify, legacy
|
|
40
|
-
// sslVerify:false fallback). See checks/_http-probe.ts.
|
|
41
|
-
const tls = resolveSapTls(sys);
|
|
42
|
-
let ca;
|
|
43
|
-
if (tls.mode === 'ca-trusted' && tls.caCertPath) {
|
|
44
|
-
try {
|
|
45
|
-
ca = await readFile(tls.caCertPath);
|
|
46
|
-
}
|
|
47
|
-
catch (e) {
|
|
48
|
-
results.push(`${alias}: CA unreadable at ${tls.caCertPath} (${e.message})`);
|
|
49
|
-
anyFailed = true;
|
|
50
|
-
continue;
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
const r = await probeGet(url, {
|
|
54
|
-
rejectUnauthorized: tls.mode !== 'insecure',
|
|
55
|
-
ca,
|
|
56
|
-
auth: `${sys.username}:${pwd}`,
|
|
57
|
-
accept: 'application/json',
|
|
58
|
-
timeoutMs: 5000,
|
|
59
|
-
});
|
|
60
|
-
if (r.error) {
|
|
61
|
-
results.push(`${alias}: ${r.error}`);
|
|
62
|
-
anyFailed = true;
|
|
63
|
-
continue;
|
|
64
|
-
}
|
|
65
|
-
if (r.status < 200 || r.status >= 300) {
|
|
66
|
-
results.push(`${alias}: HTTP ${r.status} from /zcspeach/discovery`);
|
|
67
|
-
anyFailed = true;
|
|
68
|
-
continue;
|
|
69
|
-
}
|
|
70
|
-
let body;
|
|
71
|
-
try {
|
|
72
|
-
body = JSON.parse(r.body);
|
|
73
|
-
}
|
|
74
|
-
catch {
|
|
75
|
-
results.push(`${alias}: invalid JSON from /zcspeach/discovery`);
|
|
76
|
-
anyFailed = true;
|
|
77
|
-
continue;
|
|
78
|
-
}
|
|
79
|
-
if (body.status !== 'ok') {
|
|
80
|
-
results.push(`${alias}: discovery returned status=${body.status ?? '<missing>'}`);
|
|
81
|
-
anyFailed = true;
|
|
82
|
-
continue;
|
|
83
|
-
}
|
|
84
|
-
const ver = body.data?.version ?? '<missing>';
|
|
85
|
-
const ops = body.data?.operations?.length ?? 0;
|
|
86
|
-
results.push(`${alias}: v${ver}, ${ops} ops`);
|
|
87
|
-
}
|
|
88
|
-
return {
|
|
89
|
-
name: 'zcspeach',
|
|
90
|
-
ok: !anyFailed,
|
|
91
|
-
message: results.join('; '),
|
|
92
|
-
hint: anyFailed
|
|
93
|
-
? 'SICF node /sap/bc/zcspeach must be active; ensure SAP_BASIS supports the framework'
|
|
94
|
-
: undefined,
|
|
95
|
-
};
|
|
96
|
-
}
|