@cspeach/cli 0.9.0 → 1.0.0
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/README.md +1 -1
- package/dist/agent/intent-system-prompt.js +1 -1
- package/dist/agent/loop.js +209 -20
- package/dist/agent/providers/license-gate.js +44 -0
- package/dist/agent/skill-checkpoint.js +1 -1
- package/dist/agent/tool-dispatch.js +15 -0
- package/dist/approvals/canonical.js +91 -0
- package/dist/approvals/jwt.js +39 -2
- package/dist/auth/org-anthropic-key.js +25 -0
- package/dist/classifier/client.js +18 -3
- package/dist/commands/config-set.js +95 -0
- package/dist/commands/login.js +31 -14
- package/dist/commands/plan-model-tier.js +83 -0
- package/dist/commands/plan-resume.js +148 -21
- package/dist/config/loader.js +95 -1
- package/dist/doctor/checks/_http-probe.js +1 -0
- package/dist/doctor/checks/cert.js +14 -3
- package/dist/doctor/checks/sap.js +30 -8
- package/dist/doctor/checks/zcspeach.js +19 -4
- package/dist/one-shot.js +52 -4
- package/dist/projects/answer-blockers.js +137 -0
- package/dist/projects/extract-cca.js +108 -16
- package/dist/projects/extract-modernize.js +1 -1
- package/dist/projects/extract-plan.js +130 -37
- package/dist/projects/extract-spec-gap.js +34 -7
- package/dist/projects/extract-test-coverage.js +1 -1
- package/dist/projects/extract-upgrade.js +113 -22
- package/dist/projects/index.js +5 -2
- package/dist/projects/merge-cca.js +292 -0
- package/dist/projects/merge-upgrade.js +173 -0
- package/dist/projects/migration.js +103 -1
- package/dist/projects/output-paths.js +27 -0
- package/dist/projects/plan-run.js +159 -25
- package/dist/projects/plan-schema.js +63 -3
- package/dist/projects/promote-command.js +25 -2
- package/dist/projects/promote.js +128 -0
- package/dist/projects/save-command.js +247 -20
- package/dist/projects/status.js +3 -1
- package/dist/projects/validate.js +1 -1
- package/dist/projects/workspace.js +164 -20
- package/dist/renderer/notices.js +64 -0
- package/dist/renderer/progress-chatter.js +8 -0
- package/dist/renderer/tool-widget.js +18 -4
- package/dist/renderer/tty.js +43 -4
- package/dist/renderer/verify-chain.js +77 -0
- package/dist/repl/at-picker.js +60 -7
- package/dist/repl/builtin-commands.js +37 -0
- package/dist/repl/early-line-buffer.js +68 -0
- package/dist/repl/inquirer-guard.js +70 -5
- package/dist/repl/numbered-menu.js +131 -0
- package/dist/repl/post-turn-status.js +2 -2
- package/dist/repl/rule8-detector.js +17 -2
- package/dist/repl/safety-confirm.js +111 -2
- package/dist/repl/safety-mode-state.js +19 -3
- package/dist/repl/slash-picker.js +10 -15
- package/dist/repl.js +301 -35
- package/dist/router/classifier.js +150 -6
- package/dist/sap/capability-matrix.js +20 -0
- package/dist/sap/capability-matrix.json +11236 -0
- package/dist/sap/capability.js +146 -0
- package/dist/sap/connection-manager.js +19 -1
- package/dist/sap/onboarding.js +42 -4
- package/dist/session/pending.js +27 -0
- package/dist/skill-catalog.js +48 -43
- package/dist/skills/bundled-skills.js +279 -1
- package/dist/skills/promotion-dispatch.js +23 -0
- package/dist/tools/_command-shared.js +36 -12
- package/dist/tools/_filesystem-shared.js +139 -4
- package/dist/tools/_flag.js +25 -0
- package/dist/tools/approval.js +64 -21
- package/dist/tools/ask-question.js +96 -4
- package/dist/tools/capability/tool.js +74 -0
- package/dist/tools/dispatch-skill.js +22 -1
- package/dist/tools/extend-model/anchored-insert.js +810 -0
- package/dist/tools/extend-model/tool.js +188 -0
- package/dist/tools/filesystem/extract-document.js +57 -0
- package/dist/tools/filesystem/file-edit.js +12 -2
- package/dist/tools/filesystem/file-read.js +2 -2
- package/dist/tools/filesystem/file-write.js +11 -2
- package/dist/tools/filesystem/glob.js +11 -0
- package/dist/tools/filesystem/grep.js +10 -0
- package/dist/tools/filesystem/read-document.js +107 -0
- package/dist/tools/fiori/apply.js +50 -0
- package/dist/tools/fiori/bin.js +3 -0
- package/dist/tools/fiori/catalog/index.js +27 -0
- package/dist/tools/fiori/catalog/value-help.js +230 -0
- package/dist/tools/fiori/catalog/viz-chart.js +177 -0
- package/dist/tools/fiori/cli.js +71 -0
- package/dist/tools/fiori/deploy-config.js +73 -0
- package/dist/tools/fiori/fe-scaffold.js +45 -0
- package/dist/tools/fiori/i18n.js +39 -0
- package/dist/tools/fiori/manifest.js +70 -0
- package/dist/tools/fiori/render.js +77 -0
- package/dist/tools/fiori/scaffold.js +39 -0
- package/dist/tools/fiori/tools.js +356 -0
- package/dist/tools/fiori/types.js +1 -0
- package/dist/tools/local-build.js +76 -0
- package/dist/tools/local-files.js +31 -0
- package/dist/tools/project/_merge-shared.js +68 -0
- package/dist/tools/project/cca_merge.js +164 -0
- package/dist/tools/project/playbook_get.js +1 -1
- package/dist/tools/project/upgrade_merge_progress.js +206 -0
- package/dist/tools/sap-read.js +53 -9
- package/dist/tools/sap-write.js +530 -21
- package/dist/tools/shell/shell_exec.js +41 -6
- package/dist/tools/snapshot.js +37 -14
- package/dist/tools/subagent/background_run.js +17 -1
- package/dist/tools/transport-resolution.js +86 -0
- package/dist/tools/transport.js +224 -5
- package/dist/tools/write-mode.js +4 -0
- package/dist/ui/app.js +6 -2
- package/dist/ui/body.js +13 -0
- package/dist/ui/footer.js +20 -6
- package/dist/ui/line-resolution.js +17 -6
- package/dist/ui/session-timeline.js +1 -0
- package/dist/ui/text-input.js +150 -0
- package/dist/ui/widgets/ask-question-modal.js +4 -1
- package/package.json +19 -3
- package/bench/README.md +0 -78
- package/bench/prompts/abap-document-cds.md +0 -44
- package/bench/prompts/abap-explain-bdef-handler.md +0 -57
- package/bench/prompts/abap-test-method.md +0 -42
- package/bench/results/abap-document-cds/claude-haiku-4-5.md +0 -189
- package/bench/results/abap-document-cds/claude-opus-4-7.md +0 -120
- package/bench/results/abap-document-cds/claude-sonnet-4-6.md +0 -151
- package/bench/results/abap-explain-bdef-handler/claude-haiku-4-5.md +0 -112
- package/bench/results/abap-explain-bdef-handler/claude-opus-4-7.md +0 -101
- package/bench/results/abap-explain-bdef-handler/claude-sonnet-4-6.md +0 -101
- package/bench/results/abap-test-method/claude-haiku-4-5.md +0 -186
- package/bench/results/abap-test-method/claude-opus-4-7.md +0 -193
- package/bench/results/abap-test-method/claude-sonnet-4-6.md +0 -234
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared input-resolution for the two merge tools (cca_merge,
|
|
3
|
+
* upgrade_merge_progress).
|
|
4
|
+
*
|
|
5
|
+
* Inputs arrive as the same `@<token>` forms the rest of the CLI uses
|
|
6
|
+
* (workspace.ts:resolveAtToken): absolute path, relative path, or a bare
|
|
7
|
+
* workspace-filename fragment. A leading '@' is stripped — skills pass
|
|
8
|
+
* the user's `--inputs @file` tokens through verbatim.
|
|
9
|
+
*
|
|
10
|
+
* Absolute / relative paths are resolved WITHOUT touching the workspace
|
|
11
|
+
* (no ensureWorkspace side effects from a tool call); only bare tokens
|
|
12
|
+
* trigger the workspace lookup.
|
|
13
|
+
*/
|
|
14
|
+
import { basename, isAbsolute, join } from 'node:path';
|
|
15
|
+
import { readProjectFile } from '../../projects/status.js';
|
|
16
|
+
import { resolveAtTokenAsync, formatProjectFileList } from '../../projects/workspace.js';
|
|
17
|
+
export function labelFor(path) {
|
|
18
|
+
return basename(path).replace(/\.cspeach\.json$/i, '');
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Resolve one `@<token>` to an absolute path. Throws with a
|
|
22
|
+
* user-presentable message on ambiguity / not-found.
|
|
23
|
+
*/
|
|
24
|
+
export async function resolveEnvelopeToken(rawToken, cwd) {
|
|
25
|
+
const token = rawToken.startsWith('@') ? rawToken.slice(1) : rawToken;
|
|
26
|
+
if (token.length === 0)
|
|
27
|
+
throw new Error('empty input token');
|
|
28
|
+
if (isAbsolute(token))
|
|
29
|
+
return token;
|
|
30
|
+
if (token.includes('/') || token.includes('\\'))
|
|
31
|
+
return join(cwd, token);
|
|
32
|
+
const resolved = await resolveAtTokenAsync(token, cwd);
|
|
33
|
+
if (resolved.kind === 'path')
|
|
34
|
+
return resolved.path;
|
|
35
|
+
if (resolved.kind === 'ambiguous') {
|
|
36
|
+
throw new Error(`'@${token}' matches multiple workspace files — be more specific:\n`
|
|
37
|
+
+ formatProjectFileList(resolved.matches, '(workspace)'));
|
|
38
|
+
}
|
|
39
|
+
throw new Error(`'@${token}' matches no file in the workspace (${resolved.workspace})`);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Resolve + read + type-check one envelope input. readProjectFile
|
|
43
|
+
* already validates the envelope schema and throws readable errors.
|
|
44
|
+
*/
|
|
45
|
+
export async function loadEnvelope(rawToken, cwd, expectedType) {
|
|
46
|
+
const path = await resolveEnvelopeToken(rawToken, cwd);
|
|
47
|
+
let envelope;
|
|
48
|
+
try {
|
|
49
|
+
envelope = readProjectFile(path);
|
|
50
|
+
}
|
|
51
|
+
catch (e) {
|
|
52
|
+
throw new Error(`cannot read '${rawToken}': ${e instanceof Error ? e.message : String(e)}`);
|
|
53
|
+
}
|
|
54
|
+
if (envelope.artefactType !== expectedType) {
|
|
55
|
+
throw new Error(`'${rawToken}' is a '${envelope.artefactType}' envelope — this merge needs '${expectedType}' inputs.`);
|
|
56
|
+
}
|
|
57
|
+
return { path, label: labelFor(path), envelope };
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The presentation contract returned to the model alongside the merged
|
|
61
|
+
* numbers. Keeping the model away from arithmetic is the whole point
|
|
62
|
+
* (battery S4 defects D6/D7) — it presents, it does not recompute.
|
|
63
|
+
*/
|
|
64
|
+
export const MANIFEST_PASTE_INSTRUCTIONS = 'Present merged_summary, per_input and warnings to the user with your own judgment commentary '
|
|
65
|
+
+ '(conflict arbitration advice, next steps). Then paste manifest_block VERBATIM — unchanged, '
|
|
66
|
+
+ 'byte for byte — as the LAST block of your reply so the CLI save hook can persist the merged '
|
|
67
|
+
+ 'envelope. Do NOT recompute, reformat, re-sort, or edit any number or row in the block: the '
|
|
68
|
+
+ 'extractor re-validates counts against rows on save and a hand-edited block fails loudly.';
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cca_merge — deterministic reconciliation of N parallel /abap-cca
|
|
3
|
+
* assessment envelopes (the /abap-cca-merge skill's execution seam).
|
|
4
|
+
*
|
|
5
|
+
* DESIGN (2026-06-12, team-chain): the skill used to ask the MODEL to
|
|
6
|
+
* reconcile envelopes by hand; battery S4 (D6/D7) proved models can't
|
|
7
|
+
* keep counts consistent with rows. This tool runs
|
|
8
|
+
* src/projects/merge-cca.ts and returns the merged content, warnings,
|
|
9
|
+
* AND a pre-rendered manifest block the model pastes VERBATIM. The
|
|
10
|
+
* normal save hook then persists the envelope — persistence
|
|
11
|
+
* deliberately stays on the one existing save path (confirm prompt,
|
|
12
|
+
* workspace resolution, validation, version semantics) instead of the
|
|
13
|
+
* tool minting envelopes through a second door. A merge is a NEW
|
|
14
|
+
* artefact superseding its inputs (new identity is correct — there is
|
|
15
|
+
* no prior chain to extend, unlike the plan-revision case in
|
|
16
|
+
* save-command.ts), and the extractor's counts-vs-rows validation acts
|
|
17
|
+
* as a tamper backstop if the model edits the pasted block.
|
|
18
|
+
*
|
|
19
|
+
* NOT flag-gated: the abap-cca-merge skill depends on this tool; gating
|
|
20
|
+
* it off would silently break the multi-consultant chain.
|
|
21
|
+
*
|
|
22
|
+
* Stays isMutating: false despite writing the merged detail file: the
|
|
23
|
+
* write is the merge's own local artifact under .cspeach/ (derived
|
|
24
|
+
* purely from envelopes the user supplied), the same trust level as the
|
|
25
|
+
* save hook persisting the envelope — no SAP or user-file mutation.
|
|
26
|
+
*/
|
|
27
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
28
|
+
import { dirname, isAbsolute, join } from 'node:path';
|
|
29
|
+
import { registerTool } from '../index.js';
|
|
30
|
+
import { mergeCcaAssessments, renderCcaMergeManifest, } from '../../projects/merge-cca.js';
|
|
31
|
+
import { loadEnvelope, MANIFEST_PASTE_INSTRUCTIONS } from './_merge-shared.js';
|
|
32
|
+
export async function ccaMergeHandler(args, ctx) {
|
|
33
|
+
if (!Array.isArray(args.inputs) || args.inputs.some((i) => typeof i !== 'string')) {
|
|
34
|
+
return { content: 'error: inputs must be an array of envelope paths / @-tokens.', is_error: true };
|
|
35
|
+
}
|
|
36
|
+
const tokens = args.inputs;
|
|
37
|
+
if (tokens.length === 1) {
|
|
38
|
+
return {
|
|
39
|
+
content: 'error: only one input supplied — a single assessment needs no merge. Use the file directly.',
|
|
40
|
+
is_error: true,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
if (tokens.length < 2) {
|
|
44
|
+
return { content: 'error: at least 2 input envelopes are required.', is_error: true };
|
|
45
|
+
}
|
|
46
|
+
if (args.allow_cross_project !== undefined && typeof args.allow_cross_project !== 'boolean') {
|
|
47
|
+
return { content: 'error: allow_cross_project must be a boolean.', is_error: true };
|
|
48
|
+
}
|
|
49
|
+
// load + type-check every input before merging — fail with the precise file
|
|
50
|
+
const inputs = [];
|
|
51
|
+
const perInput = [];
|
|
52
|
+
for (const token of tokens) {
|
|
53
|
+
let resolved;
|
|
54
|
+
try {
|
|
55
|
+
resolved = await loadEnvelope(token, ctx.cwd, 'cca-assessment');
|
|
56
|
+
}
|
|
57
|
+
catch (e) {
|
|
58
|
+
return { content: `error: ${e instanceof Error ? e.message : String(e)}`, is_error: true };
|
|
59
|
+
}
|
|
60
|
+
const content = resolved.envelope.content;
|
|
61
|
+
inputs.push({ label: resolved.label, content });
|
|
62
|
+
perInput.push({
|
|
63
|
+
file: resolved.path,
|
|
64
|
+
label: resolved.label,
|
|
65
|
+
project_id: content.projectId,
|
|
66
|
+
scope: content.scope,
|
|
67
|
+
total_objects: content.summary.totalObjects,
|
|
68
|
+
keep: content.summary.keep,
|
|
69
|
+
fix: content.summary.fix,
|
|
70
|
+
retire: content.summary.retire,
|
|
71
|
+
redesign: content.summary.redesign,
|
|
72
|
+
uncategorized: content.summary.uncategorized,
|
|
73
|
+
truncated: content.classificationsTruncated === true,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
let merged;
|
|
77
|
+
let warnings;
|
|
78
|
+
try {
|
|
79
|
+
({ merged, warnings } = mergeCcaAssessments(inputs, {
|
|
80
|
+
allowCrossProject: args.allow_cross_project === true,
|
|
81
|
+
}));
|
|
82
|
+
}
|
|
83
|
+
catch (e) {
|
|
84
|
+
return { content: `error: ${e instanceof Error ? e.message : String(e)}`, is_error: true };
|
|
85
|
+
}
|
|
86
|
+
const title = `${inputs[0].content.projectId} — Merged (${inputs.length} slices)`;
|
|
87
|
+
// ── write the merged detail file BEFORE emitting the manifest ──────────
|
|
88
|
+
// The manifest's detail_path must point at a REAL file (the rule this
|
|
89
|
+
// branch added for /abap-cca emitters applies to the merge too). The
|
|
90
|
+
// manifest table caps at 50 rows; this file always carries every merged
|
|
91
|
+
// row. Minimal documented shape (a merge supersedes its inputs, so this
|
|
92
|
+
// is a fresh consolidated detail, not a phase-state project.json):
|
|
93
|
+
// {
|
|
94
|
+
// generatedAt: ISO timestamp of the merge
|
|
95
|
+
// projectId / scope / atcVariant / summary: as in the manifest
|
|
96
|
+
// objects: ALL merged classification rows (uncapped)
|
|
97
|
+
// mergedFrom: the input envelopes [{ file, label }]
|
|
98
|
+
// }
|
|
99
|
+
let detailWritten = true;
|
|
100
|
+
const detailAbs = isAbsolute(merged.detailPath) ? merged.detailPath : join(ctx.cwd, merged.detailPath);
|
|
101
|
+
try {
|
|
102
|
+
mkdirSync(dirname(detailAbs), { recursive: true });
|
|
103
|
+
writeFileSync(detailAbs, JSON.stringify({
|
|
104
|
+
generatedAt: new Date().toISOString(),
|
|
105
|
+
projectId: merged.projectId,
|
|
106
|
+
scope: merged.scope,
|
|
107
|
+
atcVariant: merged.atcVariant,
|
|
108
|
+
summary: merged.summary,
|
|
109
|
+
objects: merged.classifications,
|
|
110
|
+
mergedFrom: perInput.map((p) => ({ file: p.file, label: p.label })),
|
|
111
|
+
}, null, 2), 'utf8');
|
|
112
|
+
}
|
|
113
|
+
catch (e) {
|
|
114
|
+
detailWritten = false;
|
|
115
|
+
warnings.push(`DETAIL WRITE FAILED: could not write the merged detail file '${merged.detailPath}' `
|
|
116
|
+
+ `(${e instanceof Error ? e.message : String(e)}). The manifest block omits detail_path — `
|
|
117
|
+
+ `a manifest must never point at a file that does not exist — so the save will refuse it. `
|
|
118
|
+
+ `Fix the path/permissions and re-run the merge.`);
|
|
119
|
+
}
|
|
120
|
+
const manifestBlock = renderCcaMergeManifest(merged, title, { omitDetailPath: !detailWritten });
|
|
121
|
+
return {
|
|
122
|
+
content: JSON.stringify({
|
|
123
|
+
merged_summary: merged.summary,
|
|
124
|
+
merged_project_id: merged.projectId,
|
|
125
|
+
merged_scope: merged.scope,
|
|
126
|
+
atc_variant: merged.atcVariant,
|
|
127
|
+
...(detailWritten ? { detail_path: merged.detailPath } : {}),
|
|
128
|
+
per_input: perInput,
|
|
129
|
+
warnings,
|
|
130
|
+
// full merged rows — the manifest block caps at 50; nothing is lost
|
|
131
|
+
// from the transcript even for large estates.
|
|
132
|
+
merged_classifications: merged.classifications,
|
|
133
|
+
manifest_block: manifestBlock,
|
|
134
|
+
instructions: MANIFEST_PASTE_INSTRUCTIONS,
|
|
135
|
+
}, null, 2),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
registerTool({
|
|
139
|
+
name: 'cca_merge',
|
|
140
|
+
description: 'Deterministically merge 2+ parallel /abap-cca assessment envelopes (.cspeach.json) into one consolidated '
|
|
141
|
+
+ 'cca-assessment. Validates project identity and ATC variant, detects scope overlaps and classification '
|
|
142
|
+
+ 'conflicts (most-restrictive wins: retire > redesign > fix > keep), recomputes all counts from the merged '
|
|
143
|
+
+ 'rows, writes the full merged rows to a new detail file under .cspeach/cca/projects/, and returns warnings '
|
|
144
|
+
+ 'plus a ready-made csforge:cca-manifest block to paste verbatim. '
|
|
145
|
+
+ 'Inputs accept absolute/relative paths or bare workspace @-tokens.',
|
|
146
|
+
isMutating: false,
|
|
147
|
+
category: 'project',
|
|
148
|
+
input_schema: {
|
|
149
|
+
type: 'object',
|
|
150
|
+
properties: {
|
|
151
|
+
inputs: {
|
|
152
|
+
type: 'array',
|
|
153
|
+
items: { type: 'string' },
|
|
154
|
+
description: '2+ cca-assessment envelope references (path or @-token), one per consultant slice.',
|
|
155
|
+
},
|
|
156
|
+
allow_cross_project: {
|
|
157
|
+
type: 'boolean',
|
|
158
|
+
description: 'Accept inputs with different projectId (disjoint --scope slices of the same system). Default false.',
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
required: ['inputs'],
|
|
162
|
+
},
|
|
163
|
+
handler: (args, ctx) => ccaMergeHandler(args, ctx),
|
|
164
|
+
});
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Fetches a SKILL.md body via ctx.skillSource (the managed / manifest /
|
|
5
5
|
* bundled SkillSource abstraction the agent loop already uses). Lets the
|
|
6
6
|
* agent inspect another skill's playbook for orchestration patterns
|
|
7
|
-
* (e.g. /abap-incident calling /abap-dump
|
|
7
|
+
* (e.g. /abap-incident calling /abap-dump inline) without
|
|
8
8
|
* triggering a full skill dispatch.
|
|
9
9
|
*
|
|
10
10
|
* Caching: in-process Map-based LRU. Keyed by validated skill name.
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* upgrade_merge_progress — deterministic reconciliation of N parallel
|
|
3
|
+
* /abap-upgrade-fix progress envelopes (the /abap-upgrade-merge skill's
|
|
4
|
+
* execution seam). See cca_merge.ts for the design rationale: the tool
|
|
5
|
+
* does the arithmetic (battery S4 D6/D7), the model presents; the
|
|
6
|
+
* pre-rendered manifest block is pasted verbatim and persisted by the
|
|
7
|
+
* normal save hook.
|
|
8
|
+
*
|
|
9
|
+
* Baseline discipline:
|
|
10
|
+
* - every input's baselineRef.sha256 must match the merge baseline —
|
|
11
|
+
* HARD ERROR otherwise.
|
|
12
|
+
* - `baseline` arg (recommended): the originating upgrade-scan
|
|
13
|
+
* envelope. The tool hashes the scan's detail file (that is what
|
|
14
|
+
* baseline_sha256 refers to, per the upgrade-fix manifest contract)
|
|
15
|
+
* and uses the scan's findings to fill never-touched findings as
|
|
16
|
+
* 'pending', so the merged view covers the whole baseline.
|
|
17
|
+
* - without `baseline`, the first input's baselineRef is taken as
|
|
18
|
+
* truth and the others are checked against it; untouched baseline
|
|
19
|
+
* findings cannot be filled in (warned).
|
|
20
|
+
*
|
|
21
|
+
* NOT flag-gated: the abap-upgrade-merge skill depends on this tool.
|
|
22
|
+
*
|
|
23
|
+
* Stays isMutating: false despite writing the merged detail file: the
|
|
24
|
+
* write is the merge's own local artifact under .cspeach/ (derived
|
|
25
|
+
* purely from envelopes the user supplied), the same trust level as the
|
|
26
|
+
* save hook persisting the envelope — no SAP or user-file mutation.
|
|
27
|
+
*/
|
|
28
|
+
import { createHash } from 'node:crypto';
|
|
29
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
30
|
+
import { dirname, isAbsolute, join } from 'node:path';
|
|
31
|
+
import { registerTool } from '../index.js';
|
|
32
|
+
import { mergeUpgradeProgress, renderUpgradeMergeManifest, } from '../../projects/merge-upgrade.js';
|
|
33
|
+
import { loadEnvelope, MANIFEST_PASTE_INSTRUCTIONS } from './_merge-shared.js';
|
|
34
|
+
function sha256OfFile(path) {
|
|
35
|
+
return createHash('sha256').update(readFileSync(path)).digest('hex');
|
|
36
|
+
}
|
|
37
|
+
export async function upgradeMergeProgressHandler(args, ctx) {
|
|
38
|
+
if (!Array.isArray(args.inputs) || args.inputs.some((i) => typeof i !== 'string')) {
|
|
39
|
+
return { content: 'error: inputs must be an array of envelope paths / @-tokens.', is_error: true };
|
|
40
|
+
}
|
|
41
|
+
const tokens = args.inputs;
|
|
42
|
+
if (tokens.length === 1) {
|
|
43
|
+
return {
|
|
44
|
+
content: 'error: only one input supplied — a single progress file needs no merge. Use the file directly.',
|
|
45
|
+
is_error: true,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
if (tokens.length < 2) {
|
|
49
|
+
return { content: 'error: at least 2 input envelopes are required.', is_error: true };
|
|
50
|
+
}
|
|
51
|
+
if (args.baseline !== undefined && typeof args.baseline !== 'string') {
|
|
52
|
+
return { content: 'error: baseline must be a string (upgrade-scan envelope path / @-token).', is_error: true };
|
|
53
|
+
}
|
|
54
|
+
const inputs = [];
|
|
55
|
+
const perInput = [];
|
|
56
|
+
for (const token of tokens) {
|
|
57
|
+
let resolved;
|
|
58
|
+
try {
|
|
59
|
+
resolved = await loadEnvelope(token, ctx.cwd, 'upgrade-progress');
|
|
60
|
+
}
|
|
61
|
+
catch (e) {
|
|
62
|
+
return { content: `error: ${e instanceof Error ? e.message : String(e)}`, is_error: true };
|
|
63
|
+
}
|
|
64
|
+
const content = resolved.envelope.content;
|
|
65
|
+
inputs.push({ label: resolved.label, content });
|
|
66
|
+
perInput.push({
|
|
67
|
+
file: resolved.path,
|
|
68
|
+
label: resolved.label,
|
|
69
|
+
baseline_sha256: content.baselineRef.sha256,
|
|
70
|
+
transport: content.transport,
|
|
71
|
+
fixed: content.summary.fixed,
|
|
72
|
+
skipped: content.summary.skipped,
|
|
73
|
+
failed: content.summary.failed,
|
|
74
|
+
pending: content.summary.pending,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
const extraWarnings = [];
|
|
78
|
+
let baseline;
|
|
79
|
+
let title;
|
|
80
|
+
if (typeof args.baseline === 'string') {
|
|
81
|
+
let scan;
|
|
82
|
+
try {
|
|
83
|
+
scan = await loadEnvelope(args.baseline, ctx.cwd, 'upgrade-scan');
|
|
84
|
+
}
|
|
85
|
+
catch (e) {
|
|
86
|
+
return { content: `error: ${e instanceof Error ? e.message : String(e)}`, is_error: true };
|
|
87
|
+
}
|
|
88
|
+
const scanContent = scan.envelope.content;
|
|
89
|
+
// baseline_sha256 is the sha256 of the baseline DETAIL JSON body (the
|
|
90
|
+
// upgrade-fix manifest contract), not of the envelope.
|
|
91
|
+
const detailAbs = isAbsolute(scanContent.detailPath)
|
|
92
|
+
? scanContent.detailPath
|
|
93
|
+
: join(ctx.cwd, scanContent.detailPath);
|
|
94
|
+
let sha256;
|
|
95
|
+
try {
|
|
96
|
+
sha256 = sha256OfFile(detailAbs);
|
|
97
|
+
}
|
|
98
|
+
catch (e) {
|
|
99
|
+
return {
|
|
100
|
+
content: `error: cannot hash baseline detail file '${scanContent.detailPath}' (resolved: ${detailAbs}): `
|
|
101
|
+
+ `${e instanceof Error ? e.message : String(e)}. The merge needs the scan's detail JSON on this machine.`,
|
|
102
|
+
is_error: true,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
baseline = {
|
|
106
|
+
sha256,
|
|
107
|
+
path: scanContent.detailPath,
|
|
108
|
+
title: scan.envelope.title,
|
|
109
|
+
findings: scanContent.findings.map((f) => ({ id: f.id, objectName: f.objectName, finding: f.rule })),
|
|
110
|
+
};
|
|
111
|
+
title = `${scan.envelope.title} — Merged (${inputs.length} slices)`;
|
|
112
|
+
if (scanContent.findingsTruncated) {
|
|
113
|
+
extraWarnings.push(`TRUNCATED BASELINE: the scan manifest carries only ${scanContent.findingsShown ?? scanContent.findings.length} `
|
|
114
|
+
+ `of ${scanContent.summary.total} findings — pending-fill covers the manifest rows only.`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
const ref = inputs[0].content.baselineRef;
|
|
119
|
+
baseline = { sha256: ref.sha256, path: ref.path };
|
|
120
|
+
title = `${ref.path.split(/[\\\/]/).pop() ?? 'baseline'} — Merged (${inputs.length} slices)`;
|
|
121
|
+
extraWarnings.push('NO EXPLICIT BASELINE: consistency was checked against the first input\'s baselineRef. '
|
|
122
|
+
+ 'Findings untouched by every slice are NOT filled as pending — pass the upgrade-scan envelope '
|
|
123
|
+
+ 'as `baseline` for full-baseline coverage.');
|
|
124
|
+
}
|
|
125
|
+
let merged;
|
|
126
|
+
let warnings;
|
|
127
|
+
try {
|
|
128
|
+
({ merged, warnings } = mergeUpgradeProgress(inputs, baseline));
|
|
129
|
+
}
|
|
130
|
+
catch (e) {
|
|
131
|
+
return { content: `error: ${e instanceof Error ? e.message : String(e)}`, is_error: true };
|
|
132
|
+
}
|
|
133
|
+
warnings = [...extraWarnings, ...warnings];
|
|
134
|
+
// ── write the merged detail file BEFORE emitting the manifest ──────────
|
|
135
|
+
// The manifest's detail_path must point at a REAL file. Minimal
|
|
136
|
+
// documented shape (full merged rows, never capped):
|
|
137
|
+
// {
|
|
138
|
+
// generatedAt: ISO timestamp of the merge
|
|
139
|
+
// baseline / transport / summary: as in the manifest
|
|
140
|
+
// fixes: ALL merged per-finding rows
|
|
141
|
+
// mergedFrom: the input envelopes [{ file, label }]
|
|
142
|
+
// }
|
|
143
|
+
let detailWritten = true;
|
|
144
|
+
const mergedDetailAbs = isAbsolute(merged.detailPath) ? merged.detailPath : join(ctx.cwd, merged.detailPath);
|
|
145
|
+
try {
|
|
146
|
+
mkdirSync(dirname(mergedDetailAbs), { recursive: true });
|
|
147
|
+
writeFileSync(mergedDetailAbs, JSON.stringify({
|
|
148
|
+
generatedAt: new Date().toISOString(),
|
|
149
|
+
baseline: { path: merged.baselineRef.path, sha256: merged.baselineRef.sha256 },
|
|
150
|
+
transport: merged.transport,
|
|
151
|
+
summary: merged.summary,
|
|
152
|
+
fixes: merged.fixes,
|
|
153
|
+
mergedFrom: perInput.map((p) => ({ file: p.file, label: p.label })),
|
|
154
|
+
}, null, 2), 'utf8');
|
|
155
|
+
}
|
|
156
|
+
catch (e) {
|
|
157
|
+
detailWritten = false;
|
|
158
|
+
warnings.push(`DETAIL WRITE FAILED: could not write the merged detail file '${merged.detailPath}' `
|
|
159
|
+
+ `(${e instanceof Error ? e.message : String(e)}). The manifest block omits detail_path — `
|
|
160
|
+
+ `a manifest must never point at a file that does not exist — so the save will refuse it. `
|
|
161
|
+
+ `Fix the path/permissions and re-run the merge.`);
|
|
162
|
+
}
|
|
163
|
+
const manifestBlock = renderUpgradeMergeManifest(merged, title, { omitDetailPath: !detailWritten });
|
|
164
|
+
return {
|
|
165
|
+
content: JSON.stringify({
|
|
166
|
+
merged_summary: merged.summary,
|
|
167
|
+
baseline: { path: merged.baselineRef.path, sha256: merged.baselineRef.sha256 },
|
|
168
|
+
transport: merged.transport,
|
|
169
|
+
...(detailWritten ? { detail_path: merged.detailPath } : {}),
|
|
170
|
+
per_input: perInput,
|
|
171
|
+
warnings,
|
|
172
|
+
merged_fixes: merged.fixes,
|
|
173
|
+
manifest_block: manifestBlock,
|
|
174
|
+
instructions: MANIFEST_PASTE_INSTRUCTIONS,
|
|
175
|
+
}, null, 2),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
registerTool({
|
|
179
|
+
name: 'upgrade_merge_progress',
|
|
180
|
+
description: 'Deterministically merge 2+ parallel /abap-upgrade-fix progress envelopes (.cspeach.json) into one consolidated '
|
|
181
|
+
+ 'upgrade-progress. Hard-errors on baseline_sha256 mismatch, reconciles per-finding status '
|
|
182
|
+
+ '(failed > fixed > skipped > pending), warns on double-fixes, recomputes all counts from the merged rows, '
|
|
183
|
+
+ 'writes the full merged rows to a new detail file under .cspeach/upgrades/, '
|
|
184
|
+
+ 'and returns warnings plus a ready-made csforge:upgrade-manifest block to paste verbatim. '
|
|
185
|
+
+ 'Pass the originating upgrade-scan envelope as `baseline` (recommended) to fill never-touched findings as pending. '
|
|
186
|
+
+ 'Inputs accept absolute/relative paths or bare workspace @-tokens.',
|
|
187
|
+
isMutating: false,
|
|
188
|
+
category: 'project',
|
|
189
|
+
input_schema: {
|
|
190
|
+
type: 'object',
|
|
191
|
+
properties: {
|
|
192
|
+
inputs: {
|
|
193
|
+
type: 'array',
|
|
194
|
+
items: { type: 'string' },
|
|
195
|
+
description: '2+ upgrade-progress envelope references (path or @-token), one per developer slice.',
|
|
196
|
+
},
|
|
197
|
+
baseline: {
|
|
198
|
+
type: 'string',
|
|
199
|
+
description: 'Optional but recommended: the originating upgrade-scan envelope (path or @-token). '
|
|
200
|
+
+ 'Used to verify baseline integrity from source and to fill untouched findings as pending.',
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
required: ['inputs'],
|
|
204
|
+
},
|
|
205
|
+
handler: (args, ctx) => upgradeMergeProgressHandler(args, ctx),
|
|
206
|
+
});
|
package/dist/tools/sap-read.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* searchObject(query, objType?, maxResults?)
|
|
8
8
|
* changeLog(type, name)
|
|
9
9
|
* sqlQuery(query, maxRows?, extraBlocked?)
|
|
10
|
-
* shortDump(dumpId?, user?)
|
|
10
|
+
* shortDump(dumpId?, user?, fromDate?, toDate?)
|
|
11
11
|
* atcRun(type, name, variant?)
|
|
12
12
|
* usageReferences(objectType, objectName)
|
|
13
13
|
* apiState(objects)
|
|
@@ -167,7 +167,9 @@ registerTool({
|
|
|
167
167
|
// -----------------------------------------------------------------------
|
|
168
168
|
registerTool({
|
|
169
169
|
name: 'sap_sql_query',
|
|
170
|
-
description: 'Execute a read-only SQL SELECT against the SAP database via ADT Data Preview. Security-sensitive tables are blocked automatically.'
|
|
170
|
+
description: 'Execute a read-only SQL SELECT against the SAP database via ADT Data Preview. Security-sensitive tables are blocked automatically. '
|
|
171
|
+
+ 'Dialect limits (violations return an opaque "SAP HTTP 400: sqlQuery"): no IN-lists and at most one LIKE per WHERE clause '
|
|
172
|
+
+ '(multi-OR LIKE chains are rejected) — use a single LIKE or split into separate queries.',
|
|
171
173
|
isMutating: false,
|
|
172
174
|
input_schema: {
|
|
173
175
|
type: 'object',
|
|
@@ -187,7 +189,10 @@ registerTool({
|
|
|
187
189
|
// -----------------------------------------------------------------------
|
|
188
190
|
registerTool({
|
|
189
191
|
name: 'sap_short_dump',
|
|
190
|
-
description: 'Read ABAP runtime errors (short dumps / ST22). Without dumpId returns
|
|
192
|
+
description: 'Read ABAP runtime errors (short dumps / ST22). Without dumpId returns dumps from a '
|
|
193
|
+
+ 'time window (default: the last 24h of SAP SYSTEM time), paginated server-side; the '
|
|
194
|
+
+ 'result includes a `coverage` line stating exactly which window was fetched — trust it '
|
|
195
|
+
+ 'over assumptions. With dumpId returns the full diagnostic detail.',
|
|
191
196
|
isMutating: false,
|
|
192
197
|
input_schema: {
|
|
193
198
|
type: 'object',
|
|
@@ -198,13 +203,43 @@ registerTool({
|
|
|
198
203
|
},
|
|
199
204
|
user: {
|
|
200
205
|
type: 'string',
|
|
201
|
-
description: 'Filter dumps by SAP username (list mode only)',
|
|
206
|
+
description: 'Filter dumps by SAP username (list mode only, applied after the windowed fetch)',
|
|
207
|
+
},
|
|
208
|
+
fromDate: {
|
|
209
|
+
type: 'string',
|
|
210
|
+
description: 'Window start (list mode). Zone-less values are SAP SYSTEM time: "YYYYMMDDHHMMSS", '
|
|
211
|
+
+ '"YYYY-MM-DD" (day start) or "2026-06-11T06:00:00". ISO-8601 with timezone '
|
|
212
|
+
+ '("2026-06-11T06:00:00Z") is an absolute instant, auto-translated to system time. '
|
|
213
|
+
+ 'Default: now − 24h (or 24h before an explicit toDate).',
|
|
214
|
+
},
|
|
215
|
+
toDate: {
|
|
216
|
+
type: 'string',
|
|
217
|
+
description: 'Window end (list mode). Same formats as fromDate ("YYYY-MM-DD" = day end). '
|
|
218
|
+
+ 'Default: now (SAP system time).',
|
|
202
219
|
},
|
|
203
220
|
},
|
|
204
221
|
required: [],
|
|
205
222
|
},
|
|
206
223
|
handler: async (args, ctx) => {
|
|
207
|
-
const result = await ctx.adt.shortDump(args.dumpId, args.user);
|
|
224
|
+
const result = await ctx.adt.shortDump(args.dumpId, args.user, args.fromDate, args.toDate);
|
|
225
|
+
// List mode: lead with a coverage line so the model KNOWS what slice of
|
|
226
|
+
// time it is looking at (battery D18 — the old list was the feed's
|
|
227
|
+
// arbitrary first page and the newest dumps were routinely missing).
|
|
228
|
+
if (result.operation === 'list' && result.window) {
|
|
229
|
+
const w = result.window;
|
|
230
|
+
let coverage = `showing ${result.count} dump(s) from window ${w.windowFromSap}–${w.windowToSap} `
|
|
231
|
+
+ `(SAP system time${w.windowFromUtc ? `; UTC ${w.windowFromUtc}–${w.windowToUtc}` : ''}); `
|
|
232
|
+
+ `${w.totalFetched} in window before filters; ${w.pagesFetched} page(s) fetched`;
|
|
233
|
+
if (w.clockProbe === 'failed') {
|
|
234
|
+
coverage +=
|
|
235
|
+
'; WARNING: system clock probe failed — window widened by ±1 day, timestamps are approximate';
|
|
236
|
+
}
|
|
237
|
+
if (w.capped) {
|
|
238
|
+
coverage +=
|
|
239
|
+
'; WARNING: page cap hit — window NOT fully covered, narrow fromDate/toDate to see the rest';
|
|
240
|
+
}
|
|
241
|
+
return { content: JSON.stringify({ coverage, ...result }, null, 2) };
|
|
242
|
+
}
|
|
208
243
|
return { content: JSON.stringify(result, null, 2) };
|
|
209
244
|
},
|
|
210
245
|
});
|
|
@@ -448,18 +483,27 @@ registerTool({
|
|
|
448
483
|
// -----------------------------------------------------------------------
|
|
449
484
|
registerTool({
|
|
450
485
|
name: 'sap_odata_test',
|
|
451
|
-
description: 'Test an OData service endpoint
|
|
486
|
+
description: 'Test an OData service endpoint (V2 or V4). Fetches $metadata or reads an entity set and returns the real HTTP status, the request URL/Accept actually used, and the response body. Works on draft-enabled OData V4 RAP services: V4 is auto-detected from /odata4/ paths, $metadata gets an XML Accept, and data reads use a JSON Accept without the V2-only $format option. System query options ($metadata, $top, $filter, $select, $expand, $count) are preserved literally.',
|
|
452
487
|
isMutating: false,
|
|
453
488
|
input_schema: {
|
|
454
489
|
type: 'object',
|
|
455
490
|
properties: {
|
|
456
491
|
servicePath: {
|
|
457
492
|
type: 'string',
|
|
458
|
-
description: 'Full service path
|
|
493
|
+
description: 'Full service path. V2 e.g. /sap/opu/odata/sap/ZSALESORDER_SD_SRV; V4 e.g. /sap/opu/odata4/sap/zui_x_o4/srvd/sap/zui_x/0001',
|
|
459
494
|
},
|
|
460
495
|
entity: {
|
|
461
496
|
type: 'string',
|
|
462
|
-
description: 'Optional entity set name to append, e.g. SalesOrder',
|
|
497
|
+
description: 'Optional entity set name to append and read, e.g. SalesOrder',
|
|
498
|
+
},
|
|
499
|
+
query: {
|
|
500
|
+
type: 'string',
|
|
501
|
+
description: 'Optional raw OData query/resource. Pass "$metadata" to fetch the metadata document, or query options like "$top=5&$filter=Status eq \'OPEN\'". $ signs are preserved as-is. Overrides the default $top=5 when an entity is given.',
|
|
502
|
+
},
|
|
503
|
+
version: {
|
|
504
|
+
type: 'string',
|
|
505
|
+
enum: ['v2', 'v4'],
|
|
506
|
+
description: 'OData version override. Normally auto-detected (/odata4/ ⇒ v4, else v2). Set this only when the path does not reveal the version.',
|
|
463
507
|
},
|
|
464
508
|
method: {
|
|
465
509
|
type: 'string',
|
|
@@ -470,7 +514,7 @@ registerTool({
|
|
|
470
514
|
required: ['servicePath'],
|
|
471
515
|
},
|
|
472
516
|
handler: async (args, ctx) => {
|
|
473
|
-
const result = await ctx.adt.odataTest(args.servicePath, args.entity, args.method ?? 'GET');
|
|
517
|
+
const result = await ctx.adt.odataTest(args.servicePath, args.entity, args.method ?? 'GET', { query: args.query, version: args.version });
|
|
474
518
|
return { content: JSON.stringify(result, null, 2) };
|
|
475
519
|
},
|
|
476
520
|
});
|