@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.
Files changed (131) hide show
  1. package/README.md +1 -1
  2. package/dist/agent/intent-system-prompt.js +1 -1
  3. package/dist/agent/loop.js +209 -20
  4. package/dist/agent/providers/license-gate.js +44 -0
  5. package/dist/agent/skill-checkpoint.js +1 -1
  6. package/dist/agent/tool-dispatch.js +15 -0
  7. package/dist/approvals/canonical.js +91 -0
  8. package/dist/approvals/jwt.js +39 -2
  9. package/dist/auth/org-anthropic-key.js +25 -0
  10. package/dist/classifier/client.js +18 -3
  11. package/dist/commands/config-set.js +95 -0
  12. package/dist/commands/login.js +31 -14
  13. package/dist/commands/plan-model-tier.js +83 -0
  14. package/dist/commands/plan-resume.js +148 -21
  15. package/dist/config/loader.js +95 -1
  16. package/dist/doctor/checks/_http-probe.js +1 -0
  17. package/dist/doctor/checks/cert.js +14 -3
  18. package/dist/doctor/checks/sap.js +30 -8
  19. package/dist/doctor/checks/zcspeach.js +19 -4
  20. package/dist/one-shot.js +52 -4
  21. package/dist/projects/answer-blockers.js +137 -0
  22. package/dist/projects/extract-cca.js +108 -16
  23. package/dist/projects/extract-modernize.js +1 -1
  24. package/dist/projects/extract-plan.js +130 -37
  25. package/dist/projects/extract-spec-gap.js +34 -7
  26. package/dist/projects/extract-test-coverage.js +1 -1
  27. package/dist/projects/extract-upgrade.js +113 -22
  28. package/dist/projects/index.js +5 -2
  29. package/dist/projects/merge-cca.js +292 -0
  30. package/dist/projects/merge-upgrade.js +173 -0
  31. package/dist/projects/migration.js +103 -1
  32. package/dist/projects/output-paths.js +27 -0
  33. package/dist/projects/plan-run.js +159 -25
  34. package/dist/projects/plan-schema.js +63 -3
  35. package/dist/projects/promote-command.js +25 -2
  36. package/dist/projects/promote.js +128 -0
  37. package/dist/projects/save-command.js +247 -20
  38. package/dist/projects/status.js +3 -1
  39. package/dist/projects/validate.js +1 -1
  40. package/dist/projects/workspace.js +164 -20
  41. package/dist/renderer/notices.js +64 -0
  42. package/dist/renderer/progress-chatter.js +8 -0
  43. package/dist/renderer/tool-widget.js +18 -4
  44. package/dist/renderer/tty.js +43 -4
  45. package/dist/renderer/verify-chain.js +77 -0
  46. package/dist/repl/at-picker.js +60 -7
  47. package/dist/repl/builtin-commands.js +37 -0
  48. package/dist/repl/early-line-buffer.js +68 -0
  49. package/dist/repl/inquirer-guard.js +70 -5
  50. package/dist/repl/numbered-menu.js +131 -0
  51. package/dist/repl/post-turn-status.js +2 -2
  52. package/dist/repl/rule8-detector.js +17 -2
  53. package/dist/repl/safety-confirm.js +111 -2
  54. package/dist/repl/safety-mode-state.js +19 -3
  55. package/dist/repl/slash-picker.js +10 -15
  56. package/dist/repl.js +301 -35
  57. package/dist/router/classifier.js +150 -6
  58. package/dist/sap/capability-matrix.js +20 -0
  59. package/dist/sap/capability-matrix.json +11236 -0
  60. package/dist/sap/capability.js +146 -0
  61. package/dist/sap/connection-manager.js +19 -1
  62. package/dist/sap/onboarding.js +42 -4
  63. package/dist/session/pending.js +27 -0
  64. package/dist/skill-catalog.js +48 -43
  65. package/dist/skills/bundled-skills.js +279 -1
  66. package/dist/skills/promotion-dispatch.js +23 -0
  67. package/dist/tools/_command-shared.js +36 -12
  68. package/dist/tools/_filesystem-shared.js +139 -4
  69. package/dist/tools/_flag.js +25 -0
  70. package/dist/tools/approval.js +64 -21
  71. package/dist/tools/ask-question.js +96 -4
  72. package/dist/tools/capability/tool.js +74 -0
  73. package/dist/tools/dispatch-skill.js +22 -1
  74. package/dist/tools/extend-model/anchored-insert.js +810 -0
  75. package/dist/tools/extend-model/tool.js +188 -0
  76. package/dist/tools/filesystem/extract-document.js +57 -0
  77. package/dist/tools/filesystem/file-edit.js +12 -2
  78. package/dist/tools/filesystem/file-read.js +2 -2
  79. package/dist/tools/filesystem/file-write.js +11 -2
  80. package/dist/tools/filesystem/glob.js +11 -0
  81. package/dist/tools/filesystem/grep.js +10 -0
  82. package/dist/tools/filesystem/read-document.js +107 -0
  83. package/dist/tools/fiori/apply.js +50 -0
  84. package/dist/tools/fiori/bin.js +3 -0
  85. package/dist/tools/fiori/catalog/index.js +27 -0
  86. package/dist/tools/fiori/catalog/value-help.js +230 -0
  87. package/dist/tools/fiori/catalog/viz-chart.js +177 -0
  88. package/dist/tools/fiori/cli.js +71 -0
  89. package/dist/tools/fiori/deploy-config.js +73 -0
  90. package/dist/tools/fiori/fe-scaffold.js +45 -0
  91. package/dist/tools/fiori/i18n.js +39 -0
  92. package/dist/tools/fiori/manifest.js +70 -0
  93. package/dist/tools/fiori/render.js +77 -0
  94. package/dist/tools/fiori/scaffold.js +39 -0
  95. package/dist/tools/fiori/tools.js +356 -0
  96. package/dist/tools/fiori/types.js +1 -0
  97. package/dist/tools/local-build.js +76 -0
  98. package/dist/tools/local-files.js +31 -0
  99. package/dist/tools/project/_merge-shared.js +68 -0
  100. package/dist/tools/project/cca_merge.js +164 -0
  101. package/dist/tools/project/playbook_get.js +1 -1
  102. package/dist/tools/project/upgrade_merge_progress.js +206 -0
  103. package/dist/tools/sap-read.js +53 -9
  104. package/dist/tools/sap-write.js +530 -21
  105. package/dist/tools/shell/shell_exec.js +41 -6
  106. package/dist/tools/snapshot.js +37 -14
  107. package/dist/tools/subagent/background_run.js +17 -1
  108. package/dist/tools/transport-resolution.js +86 -0
  109. package/dist/tools/transport.js +224 -5
  110. package/dist/tools/write-mode.js +4 -0
  111. package/dist/ui/app.js +6 -2
  112. package/dist/ui/body.js +13 -0
  113. package/dist/ui/footer.js +20 -6
  114. package/dist/ui/line-resolution.js +17 -6
  115. package/dist/ui/session-timeline.js +1 -0
  116. package/dist/ui/text-input.js +150 -0
  117. package/dist/ui/widgets/ask-question-modal.js +4 -1
  118. package/package.json +19 -3
  119. package/bench/README.md +0 -78
  120. package/bench/prompts/abap-document-cds.md +0 -44
  121. package/bench/prompts/abap-explain-bdef-handler.md +0 -57
  122. package/bench/prompts/abap-test-method.md +0 -42
  123. package/bench/results/abap-document-cds/claude-haiku-4-5.md +0 -189
  124. package/bench/results/abap-document-cds/claude-opus-4-7.md +0 -120
  125. package/bench/results/abap-document-cds/claude-sonnet-4-6.md +0 -151
  126. package/bench/results/abap-explain-bdef-handler/claude-haiku-4-5.md +0 -112
  127. package/bench/results/abap-explain-bdef-handler/claude-opus-4-7.md +0 -101
  128. package/bench/results/abap-explain-bdef-handler/claude-sonnet-4-6.md +0 -101
  129. package/bench/results/abap-test-method/claude-haiku-4-5.md +0 -186
  130. package/bench/results/abap-test-method/claude-opus-4-7.md +0 -193
  131. 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-analyze inline) without
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
+ });
@@ -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 a list of recent dumps; with dumpId returns the full diagnostic detail.',
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 by issuing a GET or HEAD request and returning the HTTP status and response body.',
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, e.g. /sap/opu/odata/sap/ZSALESORDER_SD_SRV',
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
  });