@cspeach/cli 0.8.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 +330 -36
- package/dist/agent/providers/license-gate.js +44 -0
- package/dist/agent/skill-checkpoint.js +1 -1
- package/dist/agent/steering-queue.js +27 -0
- package/dist/agent/tool-dispatch.js +15 -0
- package/dist/approvals/canonical.js +91 -0
- package/dist/approvals/jwt.js +84 -7
- package/dist/approvals/render.js +38 -0
- package/dist/auth/org-anthropic-key.js +25 -0
- package/dist/classifier/client.js +24 -5
- 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 +435 -0
- package/dist/config/loader.js +102 -3
- package/dist/cost/pricing.js +14 -5
- 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/email-template.js +2 -0
- package/dist/projects/extract-cca.js +108 -16
- package/dist/projects/extract-modernize.js +1 -1
- package/dist/projects/extract-plan.js +178 -0
- 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 +7 -1
- 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 +254 -0
- package/dist/projects/plan-schema.js +210 -0
- package/dist/projects/promote-command.js +26 -2
- package/dist/projects/promote.js +128 -0
- package/dist/projects/save-command.js +263 -20
- package/dist/projects/status.js +22 -0
- package/dist/projects/validate.js +2 -0
- 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/syntax.js +16 -1
- package/dist/renderer/thinking-heartbeat.js +13 -1
- 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 +93 -21
- 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 +25 -19
- package/dist/repl.js +470 -22
- 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 +54 -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 +84 -11
- package/dist/ui/body.js +13 -0
- package/dist/ui/command-palette.js +46 -10
- package/dist/ui/file-palette.js +44 -0
- package/dist/ui/footer.js +28 -11
- package/dist/ui/line-resolution.js +92 -0
- package/dist/ui/session-timeline.js +1 -0
- package/dist/ui/text-input.js +150 -0
- package/dist/ui/turn-status-emitter.js +52 -0
- package/dist/ui/turn-status.js +59 -0
- package/dist/ui/widgets/ask-question-modal.js +30 -2
- package/package.json +23 -4
- 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
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { basename } from 'node:path';
|
|
2
|
+
import { isAttachableFilename } from '../projects/workspace.js';
|
|
1
3
|
/**
|
|
2
4
|
* Parse `--from @<path>` from a slash-command argument string. The leading `@`
|
|
3
5
|
* is the file-attach convention used elsewhere in the CLI; supports
|
|
@@ -22,3 +24,24 @@ export function parseFromFlag(args) {
|
|
|
22
24
|
const rest = (before + after).replace(/^\s+|\s+$/g, '');
|
|
23
25
|
return { fromPath, rest };
|
|
24
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* Forgiveness for the common `--from @<document>` slip.
|
|
29
|
+
*
|
|
30
|
+
* `--from` chains a saved `.cspeach.json` envelope into a downstream skill, but
|
|
31
|
+
* users naturally type it to point at a Word/PDF/text spec ("use this file") —
|
|
32
|
+
* which used to error with a cryptic "unknown target skill" and cancel the
|
|
33
|
+
* whole turn. A document is NEVER a valid `--from` source, so when the token is
|
|
34
|
+
* an attachable doc/text file we silently rewrite `--from @<file>` to a plain
|
|
35
|
+
* `@<file>` attachment (handled by expandTextFileAttachments) and let the turn
|
|
36
|
+
* proceed.
|
|
37
|
+
*
|
|
38
|
+
* Returns the rewritten message + filename, or null when `--from` should run
|
|
39
|
+
* normally (no flag, or the token is a bare envelope fragment / *.cspeach.json).
|
|
40
|
+
*/
|
|
41
|
+
export function coerceDocFromFlagToAttachment(message) {
|
|
42
|
+
const { fromPath } = parseFromFlag(message);
|
|
43
|
+
if (!fromPath || !isAttachableFilename(fromPath))
|
|
44
|
+
return null;
|
|
45
|
+
const rewritten = message.replace(/--from\s+(@(?:"[^"]+"|\S+))/, '$1');
|
|
46
|
+
return { rewritten, filename: basename(fromPath) };
|
|
47
|
+
}
|
|
@@ -52,23 +52,47 @@ export async function loadSafelist() {
|
|
|
52
52
|
return new Set(DEFAULT_SAFELIST);
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* Build the ordered list of filename extensions to probe for a given
|
|
57
|
+
* platform. On Windows, PATHEXT extensions (.EXE, .CMD, .BAT, ...) come
|
|
58
|
+
* BEFORE the bare name — npm drops an extensionless Unix shim next to
|
|
59
|
+
* `pnpm.cmd` (e.g. ...\npm\pnpm) that is NOT spawnable with shell:false,
|
|
60
|
+
* so preferring the real .cmd/.exe makes the existence check report a
|
|
61
|
+
* launchable binary. On Unix, only the bare name (shebang scripts and
|
|
62
|
+
* ELF binaries spawn directly).
|
|
63
|
+
*
|
|
64
|
+
* Exported for tests so the ordering invariant can be asserted without
|
|
65
|
+
* touching the filesystem.
|
|
66
|
+
*/
|
|
67
|
+
export function executableExtensions(platform, pathextRaw) {
|
|
68
|
+
if (platform === 'win32') {
|
|
69
|
+
const pathext = (pathextRaw ?? '.COM;.EXE;.BAT;.CMD')
|
|
70
|
+
.toLowerCase()
|
|
71
|
+
.split(';')
|
|
72
|
+
.filter((e) => e.length > 0);
|
|
73
|
+
// PATHEXT extensions FIRST, bare name LAST. The bare name still gets
|
|
74
|
+
// probed (commands that ship only an extensionless binary remain
|
|
75
|
+
// findable) but loses to a co-located .exe/.cmd.
|
|
76
|
+
return [...pathext, ''];
|
|
77
|
+
}
|
|
78
|
+
return [''];
|
|
79
|
+
}
|
|
55
80
|
/**
|
|
56
81
|
* Resolve a bare command name to an absolute executable path by walking
|
|
57
|
-
* PATH. Tries the bare name
|
|
58
|
-
* null if nothing matches anywhere on PATH.
|
|
59
|
-
*
|
|
82
|
+
* PATH. Tries PATHEXT extensions before the bare name on Windows. Returns
|
|
83
|
+
* null if nothing matches anywhere on PATH.
|
|
84
|
+
*
|
|
85
|
+
* This is used ONLY as a pre-flight "is it installed?" existence check so
|
|
86
|
+
* the tool can return a friendly "not installed" error. The ACTUAL spawn
|
|
87
|
+
* goes through cross-spawn with the bare command name — cross-spawn does
|
|
88
|
+
* its own PATH + PATHEXT resolution and invokes .cmd via cmd.exe with
|
|
89
|
+
* correct per-arg escaping (which Node's spawn(shell:false) refuses to do
|
|
90
|
+
* since CVE-2024-27980). `platform`/`pathext` overridable for tests.
|
|
60
91
|
*/
|
|
61
|
-
export async function resolveExecutable(name) {
|
|
92
|
+
export async function resolveExecutable(name, platform = process.platform, pathext = process.env.PATHEXT) {
|
|
62
93
|
const PATH = process.env.PATH ?? process.env.Path ?? '';
|
|
63
94
|
const dirs = PATH.split(path.delimiter).filter((d) => d.length > 0);
|
|
64
|
-
|
|
65
|
-
if (process.platform === 'win32') {
|
|
66
|
-
const pathext = (process.env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').toLowerCase().split(';');
|
|
67
|
-
extensions = ['', ...pathext];
|
|
68
|
-
}
|
|
69
|
-
else {
|
|
70
|
-
extensions = [''];
|
|
71
|
-
}
|
|
95
|
+
const extensions = executableExtensions(platform, pathext);
|
|
72
96
|
for (const dir of dirs) {
|
|
73
97
|
for (const ext of extensions) {
|
|
74
98
|
const candidate = path.join(dir, name + ext);
|
|
@@ -9,11 +9,125 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import * as path from 'node:path';
|
|
11
11
|
import { promises as fsPromises } from 'node:fs';
|
|
12
|
-
/** Phase 3 sensitive in-root paths — refused by all filesystem tools. */
|
|
13
|
-
export const BLOCKED_PREFIXES = ['.cspeach', '.env', '.git', '.cspeach-design'];
|
|
14
12
|
/**
|
|
15
|
-
*
|
|
16
|
-
*
|
|
13
|
+
* Phase 3 sensitive in-root directory prefixes — refused by all filesystem
|
|
14
|
+
* tools. Matched as `rel === prefix` or `rel` starts with `prefix + '/'`.
|
|
15
|
+
*
|
|
16
|
+
* NOTE: the `.env` family is handled separately (by BASENAME, see
|
|
17
|
+
* BLOCKED_BASENAME_RE) so that `.env.local`, `.env.production`, etc. — the
|
|
18
|
+
* files that actually hold secrets in JS projects — are caught too. It is
|
|
19
|
+
* NOT in this list.
|
|
20
|
+
*/
|
|
21
|
+
export const BLOCKED_PREFIXES = ['.cspeach', '.git', '.cspeach-design'];
|
|
22
|
+
/**
|
|
23
|
+
* Skill working subdirectories under `.cspeach/` that are CARVED OUT of the
|
|
24
|
+
* denylist so the upgrade/cca/modernize/test skills can read AND write their
|
|
25
|
+
* own detail files (e.g. `.cspeach/upgrades/ZTEST_..._.json`). The namespace
|
|
26
|
+
* cleanup that moved working files from `.abapforge/<domain>/` to
|
|
27
|
+
* `.cspeach/<domain>/` would otherwise be defeated by the `.cspeach` prefix
|
|
28
|
+
* block. See the carve-out in isDenylistedPath for the exact predicate.
|
|
29
|
+
*/
|
|
30
|
+
export const CSPEACH_WORK_SUBDIRS = ['cca', 'upgrades', 'modernize', 'tests'];
|
|
31
|
+
/**
|
|
32
|
+
* Phase 3 sensitive BASENAMES — refused regardless of directory depth.
|
|
33
|
+
*
|
|
34
|
+
* Matches `.env` exactly or `.env.<suffix>` (e.g. `.env.local`,
|
|
35
|
+
* `.env.production`, `.env.development`, `.env.test`). The `(\.|$)` boundary
|
|
36
|
+
* means `.environment` / `.envrc` are NOT caught — only the real dotenv
|
|
37
|
+
* family. Case-insensitive (NTFS/APFS resolve `.ENV` to the same inode).
|
|
38
|
+
*/
|
|
39
|
+
export const BLOCKED_BASENAME_RE = /^\.env(\.|$)/i;
|
|
40
|
+
/**
|
|
41
|
+
* Human-readable summary of the denylist for error messages.
|
|
42
|
+
* Note: `.cspeach/{cca,upgrades,modernize,tests}/` working files are permitted
|
|
43
|
+
* (skill detail files) — only the rest of `.cspeach/` is blocked.
|
|
44
|
+
*/
|
|
45
|
+
export const DENYLIST_DESCRIPTION = [...BLOCKED_PREFIXES, '.env*'].join(', ');
|
|
46
|
+
/**
|
|
47
|
+
* Executable / script file extensions the filesystem write tools refuse to
|
|
48
|
+
* create. Defense-in-depth: shell_exec / background_run only run a safelisted
|
|
49
|
+
* set of commands, but cross-spawn resolves bare names cwd-first on Windows.
|
|
50
|
+
* If the model could write `<safelisted>.cmd` (e.g. git.cmd) into the project
|
|
51
|
+
* tree it could shadow a real PATH binary. Spawning the absolute PATH-resolved
|
|
52
|
+
* path already neutralizes that at the spawn site; this denylist independently
|
|
53
|
+
* stops the executable shim from ever being planted in the first place — see
|
|
54
|
+
* blockedExecutableExtension, which normalizes Windows filename quirks (trailing
|
|
55
|
+
* dots/spaces, alternate data streams) so a name like `git.cmd ` or `git.cmd::$DATA`
|
|
56
|
+
* cannot slip a real `git.cmd` onto disk past the extension check.
|
|
57
|
+
*
|
|
58
|
+
* Legitimate project scaffolding (Fiori writes .js/.json/.xml/.properties/
|
|
59
|
+
* .yaml/.ts) never needs these, so the block does not impede normal work.
|
|
60
|
+
*
|
|
61
|
+
* Case-insensitive — NTFS/APFS resolve `.CMD` to the same file as `.cmd`.
|
|
62
|
+
*/
|
|
63
|
+
export const BLOCKED_EXECUTABLE_EXTS = new Set([
|
|
64
|
+
'.cmd', '.bat', '.com', '.exe', '.ps1', '.sh', '.msi', '.scr',
|
|
65
|
+
]);
|
|
66
|
+
/** Human-readable summary of the executable-extension block for error messages. */
|
|
67
|
+
export const EXECUTABLE_EXT_DESCRIPTION = [...BLOCKED_EXECUTABLE_EXTS].join(', ');
|
|
68
|
+
/** Sentinel ext returned when the basename carries a `:` (ADS / drive-relative). */
|
|
69
|
+
const BLOCKED_STREAM_SENTINEL = ':stream';
|
|
70
|
+
/**
|
|
71
|
+
* Normalize a Windows basename the way the filesystem does on access:
|
|
72
|
+
* - strip ALL trailing dots and spaces (`git.cmd `, `git.cmd.`, `git.cmd...`
|
|
73
|
+
* all open the file `git.cmd`).
|
|
74
|
+
* Returns the normalized basename. A name that is entirely dots/spaces
|
|
75
|
+
* collapses to '' (which extname() then reports as no extension — safe).
|
|
76
|
+
*/
|
|
77
|
+
function normalizeWindowsBasename(base) {
|
|
78
|
+
return base.replace(/[. ]+$/, '');
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Returns the offending extension (lowercased, with leading dot) if `targetPath`
|
|
82
|
+
* names a file the write tools must refuse, else null. Used by file_write /
|
|
83
|
+
* file_edit to refuse planting an executable shim.
|
|
84
|
+
*
|
|
85
|
+
* Accepts a full path or a bare basename (both call sites pass a resolved
|
|
86
|
+
* absolute path; the directory part is irrelevant — only the basename decides).
|
|
87
|
+
*
|
|
88
|
+
* Two pre-extname normalizations close Windows filename-normalization bypasses
|
|
89
|
+
* that would otherwise land an executable-named file on disk while slipping
|
|
90
|
+
* past a naive `path.extname` check:
|
|
91
|
+
*
|
|
92
|
+
* 1. Trailing dots/spaces — Windows strips these on access, so `git.cmd `
|
|
93
|
+
* and `git.cmd.` / `git.cmd...` all resolve to `git.cmd`. We strip them
|
|
94
|
+
* from the basename before taking the extension.
|
|
95
|
+
*
|
|
96
|
+
* 2. A `:` anywhere in the basename — alternate data stream (`git.cmd:foo`,
|
|
97
|
+
* `git.cmd::$DATA`) or drive-relative path. Either way the on-disk file
|
|
98
|
+
* is executable-named; reject outright. (A drive letter's `:` lives in
|
|
99
|
+
* the DIRECTORY part — e.g. basename of `C:\proj\foo.js` is `foo.js` —
|
|
100
|
+
* so legit absolute paths never false-positive here.)
|
|
101
|
+
*/
|
|
102
|
+
export function blockedExecutableExtension(targetPath) {
|
|
103
|
+
const rawBase = path.basename(targetPath);
|
|
104
|
+
// (2) Alternate data stream / drive-relative — the basename must never carry ':'.
|
|
105
|
+
if (rawBase.includes(':'))
|
|
106
|
+
return BLOCKED_STREAM_SENTINEL;
|
|
107
|
+
// (1) Strip trailing dots/spaces the way Windows does on access, THEN take ext.
|
|
108
|
+
const base = normalizeWindowsBasename(rawBase);
|
|
109
|
+
const ext = path.extname(base).toLowerCase();
|
|
110
|
+
return BLOCKED_EXECUTABLE_EXTS.has(ext) ? ext : null;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* True if `realAbsPath` is denylisted relative to root — either it falls under
|
|
114
|
+
* one of BLOCKED_PREFIXES (directory prefixes) or its basename is in the
|
|
115
|
+
* `.env` family. Caller should refuse the operation when `blocked` is true.
|
|
116
|
+
*
|
|
117
|
+
* CARVE-OUT — the four skill working subdirectories under `.cspeach/`
|
|
118
|
+
* (CSPEACH_WORK_SUBDIRS = cca/upgrades/modernize/tests) are ALLOWED so skills
|
|
119
|
+
* can read+write their own detail files. A path is carved out (NOT blocked)
|
|
120
|
+
* iff ALL hold:
|
|
121
|
+
* 1. its root-relative path (lowercased, '/'-separated) starts with
|
|
122
|
+
* `.cspeach/<sub>/` for some <sub> in CSPEACH_WORK_SUBDIRS — i.e. it is
|
|
123
|
+
* STRICTLY INSIDE one of those subdirs (the subdir entry itself is not
|
|
124
|
+
* carved out), AND
|
|
125
|
+
* 2. its basename does NOT end with `.cspeach.json` (those envelopes are
|
|
126
|
+
* CLI-owned — direct writes stay blocked), AND
|
|
127
|
+
* 3. its basename is NOT in the `.env` secret family (BLOCKED_BASENAME_RE).
|
|
128
|
+
* The carve-out short-circuits ONLY the `.cspeach` prefix. `.git`,
|
|
129
|
+
* `.cspeach-design`, the `.cspeach` root, non-work subdirs, and `.env*`
|
|
130
|
+
* everywhere else remain blocked.
|
|
17
131
|
*/
|
|
18
132
|
export function isDenylistedPath(realAbsPath, root) {
|
|
19
133
|
const relFromRoot = path.relative(path.resolve(root), realAbsPath).replace(/\\/g, '/');
|
|
@@ -24,12 +138,33 @@ export function isDenylistedPath(realAbsPath, root) {
|
|
|
24
138
|
// `relFromRoot` keeps its ORIGINAL case so callers can log what the model
|
|
25
139
|
// actually requested.
|
|
26
140
|
const relLower = relFromRoot.toLowerCase();
|
|
141
|
+
const base = path.basename(relFromRoot);
|
|
142
|
+
const baseLower = base.toLowerCase();
|
|
143
|
+
// ── CARVE-OUT: .cspeach/<work-subdir>/… — short-circuits ONLY .cspeach. ──
|
|
144
|
+
// Must be strictly INSIDE a work subdir, not a CLI-owned envelope, and not
|
|
145
|
+
// an .env secret. Placed before the prefix loop so it can return early, but
|
|
146
|
+
// gated so it can never bypass .git / .cspeach-design / the .env block.
|
|
147
|
+
for (const sub of CSPEACH_WORK_SUBDIRS) {
|
|
148
|
+
if (relLower.startsWith('.cspeach/' + sub + '/')) {
|
|
149
|
+
if (!baseLower.endsWith('.cspeach.json') && !BLOCKED_BASENAME_RE.test(base)) {
|
|
150
|
+
return { blocked: false, relFromRoot };
|
|
151
|
+
}
|
|
152
|
+
break; // inside a work subdir but failed an exclusion → fall through to block
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
// Directory-prefix denylist (.cspeach, .git, .cspeach-design).
|
|
27
156
|
for (const prefix of BLOCKED_PREFIXES) {
|
|
28
157
|
const prefixLower = prefix.toLowerCase();
|
|
29
158
|
if (relLower === prefixLower || relLower.startsWith(prefixLower + '/')) {
|
|
30
159
|
return { blocked: true, relFromRoot };
|
|
31
160
|
}
|
|
32
161
|
}
|
|
162
|
+
// Basename denylist — the .env family at ANY depth (.env, .env.local, …).
|
|
163
|
+
// Using the basename (not a prefix) is what catches `.env.production` while
|
|
164
|
+
// still excluding `.environment-notes.md` and `.envrc`.
|
|
165
|
+
if (BLOCKED_BASENAME_RE.test(base)) {
|
|
166
|
+
return { blocked: true, relFromRoot };
|
|
167
|
+
}
|
|
33
168
|
return { blocked: false, relFromRoot };
|
|
34
169
|
}
|
|
35
170
|
export class PathOutsideRootError extends Error {
|
package/dist/tools/_flag.js
CHANGED
|
@@ -15,7 +15,32 @@
|
|
|
15
15
|
export const TOOL_FLAG_PREFIX = 'CSPEACH_TOOL_';
|
|
16
16
|
/** One-shot per process — set of flag-names we've already warned about. */
|
|
17
17
|
const warnedFlags = new Set();
|
|
18
|
+
/**
|
|
19
|
+
* In-memory force-enable set, mirroring renderer/tty.ts's headless-override
|
|
20
|
+
* pattern: consulted BEFORE env detection. A startup hook (REPL + one-shot)
|
|
21
|
+
* calls `enableToolFlags([...])` when the persisted `local_files` config key
|
|
22
|
+
* is true, so the flag-gated filesystem tools become visible to listTools()
|
|
23
|
+
* without the user exporting CSPEACH_TOOL_* env vars.
|
|
24
|
+
*
|
|
25
|
+
* Stored by lowercase tool name (the same name listTools()/registerTool use),
|
|
26
|
+
* so `enableToolFlags(['file_read'])` and `isToolFlagOn('FILE_READ')` agree.
|
|
27
|
+
*/
|
|
28
|
+
const overrideEnabled = new Set();
|
|
29
|
+
/** Force-enable the named tools' flags for this process (override beats env). */
|
|
30
|
+
export function enableToolFlags(toolNames) {
|
|
31
|
+
for (const name of toolNames)
|
|
32
|
+
overrideEnabled.add(name.toLowerCase());
|
|
33
|
+
}
|
|
34
|
+
/** Clear all in-memory flag overrides. Test-resettable; safe at runtime too. */
|
|
35
|
+
export function clearToolFlagOverrides() {
|
|
36
|
+
overrideEnabled.clear();
|
|
37
|
+
}
|
|
18
38
|
export function isToolFlagOn(toolName) {
|
|
39
|
+
// Override wins: a startup-applied force-enable makes the tool visible
|
|
40
|
+
// regardless of env. The env path below still runs for un-overridden tools,
|
|
41
|
+
// preserving the CSPEACH_TOOL_<NAME>=on dogfood mechanism + misconfig warn.
|
|
42
|
+
if (overrideEnabled.has(toolName.toLowerCase()))
|
|
43
|
+
return true;
|
|
19
44
|
const flag = `${TOOL_FLAG_PREFIX}${toolName.toUpperCase()}`;
|
|
20
45
|
const raw = process.env[flag];
|
|
21
46
|
if (raw === undefined)
|
package/dist/tools/approval.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
1
2
|
import { registerTool } from './index.js';
|
|
3
|
+
import { isHeadless } from '../renderer/tty.js';
|
|
2
4
|
import { effectiveRisk } from '../approvals/risk-floor.js';
|
|
3
|
-
import {
|
|
5
|
+
import { mintChangeApproval } from '../approvals/jwt.js';
|
|
6
|
+
import { canonicalApprovalObject } from '../approvals/canonical.js';
|
|
4
7
|
import { renderPlanGate, renderPerChangeApprovalV3 } from '../approvals/render.js';
|
|
5
8
|
import { loadConfig } from '../config/loader.js';
|
|
6
9
|
import { maybeShowAutoApproveNag } from '../approvals/approval-prompt.js';
|
|
@@ -20,6 +23,22 @@ import { markPlanGateApproved } from '../repl/rule8-detector.js';
|
|
|
20
23
|
*/
|
|
21
24
|
export async function handleAdvisoryApproval(args, _ctx) {
|
|
22
25
|
const render = renderAdvisoryProposal(args);
|
|
26
|
+
// B5 (2026-06-11) — headless: the advisory prompt blocks on stdin that
|
|
27
|
+
// will never answer. Render the proposal (it IS the run's deliverable —
|
|
28
|
+
// the developer reads the captured output and applies manually), skip
|
|
29
|
+
// the prompt, and report a headless-specific directive. NEVER 'applied':
|
|
30
|
+
// nobody confirmed anything.
|
|
31
|
+
if (isHeadless()) {
|
|
32
|
+
console.log(render.displayText);
|
|
33
|
+
console.error(chalk.dim('headless: advisory proposal recorded in output — no prompt, developer applies manually'));
|
|
34
|
+
return {
|
|
35
|
+
content: JSON.stringify({
|
|
36
|
+
advisory_outcome: 'skipped',
|
|
37
|
+
headless: true,
|
|
38
|
+
ai_directive: 'Advisory mode (headless run): the proposal has been rendered into the run output for the developer to review and apply manually later. Nobody confirmed it. Do not attempt any write operations. Summarise what you proposed and continue.',
|
|
39
|
+
}),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
23
42
|
const outcome = await promptAdvisory({
|
|
24
43
|
displayText: render.displayText,
|
|
25
44
|
clipboardText: render.clipboardText,
|
|
@@ -88,24 +107,25 @@ registerTool({
|
|
|
88
107
|
const changes = args.changes;
|
|
89
108
|
const declared = args.risk;
|
|
90
109
|
const eff = effectiveRisk(declared, changes, riskCtx);
|
|
91
|
-
// Progressive-disclosure: offer to upgrade never→low on first low-risk
|
|
92
|
-
|
|
93
|
-
//
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
110
|
+
// Progressive-disclosure: offer to upgrade never→low on first low-risk
|
|
111
|
+
// encounter. B5: skipped in headless — the nag's raw-mode keypress wait
|
|
112
|
+
// would hang on piped/closed stdin just like any other prompt.
|
|
113
|
+
if (!isHeadless()) {
|
|
114
|
+
await maybeShowAutoApproveNag({ sapAlias: ctx.sapAlias, effectiveRisk: eff.level });
|
|
115
|
+
// Refresh cfg so this approval benefits from a just-enabled auto_approve.
|
|
116
|
+
cfg = await loadConfig();
|
|
117
|
+
sapCfg = cfg.sap[ctx.sapAlias];
|
|
118
|
+
riskCtx.autoApprovePolicy = sapCfg?.auto_approve ?? 'never';
|
|
119
|
+
}
|
|
97
120
|
// Auto-approve caps — never auto-approve at high, cap change count per risk level.
|
|
98
121
|
const autoApproveAllowed = ((sapCfg?.auto_approve === 'low' && eff.level === 'low' && changes.length <= 5) ||
|
|
99
122
|
(sapCfg?.auto_approve === 'medium' && (eff.level === 'low' || eff.level === 'medium') && changes.length <= 2));
|
|
100
123
|
if (autoApproveAllowed) {
|
|
101
124
|
const ids = [];
|
|
102
125
|
for (const c of changes) {
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
op: c.op,
|
|
107
|
-
session_id: ctx.session.id,
|
|
108
|
-
}));
|
|
126
|
+
// Task A3: mint via the canonical path — the JWT stores the same
|
|
127
|
+
// object string the write tools' validators will compare against.
|
|
128
|
+
ids.push(await mintChangeApproval(c, ctx.session.id));
|
|
109
129
|
}
|
|
110
130
|
// Tell Rule 8 the user has already covered this batch — they
|
|
111
131
|
// configured auto-approve, so no per-dispatch re-prompts.
|
|
@@ -120,6 +140,29 @@ registerTool({
|
|
|
120
140
|
}),
|
|
121
141
|
};
|
|
122
142
|
}
|
|
143
|
+
// B5 (2026-06-11) — headless fail-fast. Everything below this point
|
|
144
|
+
// prompts on stdin (plan gate, per-change approvals) and would hang a
|
|
145
|
+
// headless run forever. The headless policy NEVER auto-approves writes:
|
|
146
|
+
// the only non-interactive approval path is the user's own pre-configured
|
|
147
|
+
// auto_approve (handled above — it asks nothing). Return an error RESULT
|
|
148
|
+
// so the model can wrap up gracefully instead of the process hanging.
|
|
149
|
+
if (isHeadless()) {
|
|
150
|
+
console.error(chalk.red('headless: approval requested but headless runs cannot approve writes — ' +
|
|
151
|
+
'use advisory mode (write_mode = "advisory-only") or run interactively.'));
|
|
152
|
+
return {
|
|
153
|
+
content: JSON.stringify({
|
|
154
|
+
approved: false,
|
|
155
|
+
headless: true,
|
|
156
|
+
reason: 'headless_no_approval',
|
|
157
|
+
message: 'Headless run: writes cannot be approved without a user at the terminal, and headless runs never auto-approve. ' +
|
|
158
|
+
'No approval IDs were minted — do not attempt any write operations. ' +
|
|
159
|
+
'Summarise the proposed changes so the user can re-run interactively or switch write_mode to "advisory-only".',
|
|
160
|
+
effective_risk: eff.level,
|
|
161
|
+
raised_by: eff.raisedBy,
|
|
162
|
+
}),
|
|
163
|
+
is_error: true,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
123
166
|
// Plan-gate: for multi-change requests, ask once whether to apply all,
|
|
124
167
|
// review each, or cancel. Apply-all is the default and the happy path —
|
|
125
168
|
// it skips the redundant per-change prompts that produced the "3 prompts
|
|
@@ -159,13 +202,16 @@ registerTool({
|
|
|
159
202
|
const rejections = [];
|
|
160
203
|
const autoApproveActivateFor = new Set();
|
|
161
204
|
const autoSkipActivateFor = new Set();
|
|
205
|
+
// Sibling matching keys are canonicalized (Task A3) so a decorated modify
|
|
206
|
+
// row ("ZBP_FOO (testclasses include)") still pairs with its bare-named
|
|
207
|
+
// activate sibling ("ZBP_FOO").
|
|
162
208
|
const writeKeys = new Set();
|
|
163
209
|
for (const c of changes) {
|
|
164
210
|
if (c.op === 'modify' || c.op === 'create')
|
|
165
|
-
writeKeys.add(`${c.type}:${c.object}`);
|
|
211
|
+
writeKeys.add(`${c.type}:${canonicalApprovalObject(c.object)}`);
|
|
166
212
|
}
|
|
167
213
|
for (const c of changes) {
|
|
168
|
-
const objKey = `${c.type}:${c.object}`;
|
|
214
|
+
const objKey = `${c.type}:${canonicalApprovalObject(c.object)}`;
|
|
169
215
|
let outcome;
|
|
170
216
|
if (skipPerChangeGates) {
|
|
171
217
|
outcome = { approved: true };
|
|
@@ -182,12 +228,9 @@ registerTool({
|
|
|
182
228
|
if (outcome.approved) {
|
|
183
229
|
if (c.op === 'modify' || c.op === 'create')
|
|
184
230
|
autoApproveActivateFor.add(objKey);
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
op: c.op,
|
|
189
|
-
session_id: ctx.session.id,
|
|
190
|
-
}));
|
|
231
|
+
// Task A3: mint via the canonical path (same code path the write
|
|
232
|
+
// tools' validators compare against).
|
|
233
|
+
approvalIds.push(await mintChangeApproval(c, ctx.session.id));
|
|
191
234
|
}
|
|
192
235
|
else {
|
|
193
236
|
if (outcome.reason === '__cancel_turn__') {
|
|
@@ -25,8 +25,55 @@ import chalk from 'chalk';
|
|
|
25
25
|
import { registerTool } from './index.js';
|
|
26
26
|
import { input, select, checkbox } from '@inquirer/prompts';
|
|
27
27
|
import { withInquirer } from '../repl/inquirer-guard.js';
|
|
28
|
-
import { shouldUseInk } from '../renderer/tty.js';
|
|
28
|
+
import { shouldUseInk, isHeadless } from '../renderer/tty.js';
|
|
29
29
|
import { askQuestionEmitter } from '../ui/ask-question-emitter.js';
|
|
30
|
+
/**
|
|
31
|
+
* Label heuristic for rule 2 of pickHeadlessChoices: a string counts as
|
|
32
|
+
* "recommended" only when it contains the word AND that word is not part of
|
|
33
|
+
* a negation — skills write both "RAP (recommended)" and "SEGW (not
|
|
34
|
+
* recommended)" and the latter must NEVER win the auto-pick.
|
|
35
|
+
*/
|
|
36
|
+
function labelSaysRecommended(s) {
|
|
37
|
+
return /\brecommended\b/i.test(s) && !/\b(?:not|non|never)[\s-]+recommended\b/i.test(s);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* B5 (2026-06-11) — headless auto-pick rule for choice/multi questions.
|
|
41
|
+
*
|
|
42
|
+
* Rule order (the fired rule is reported so logs and the tool result are
|
|
43
|
+
* honest about WHY an option was picked):
|
|
44
|
+
* 1. `recommended: true` on one or more choices → those choices
|
|
45
|
+
* (rule: 'recommended').
|
|
46
|
+
* 2. No explicit flag, but a label/value contains the word "recommended"
|
|
47
|
+
* (skills frequently write "RAP (recommended)") → those choices
|
|
48
|
+
* (rule: 'recommended-label'). Negated forms ("not recommended",
|
|
49
|
+
* "non-recommended", "never recommended") do NOT match.
|
|
50
|
+
* 3. Nothing marked → the FIRST option (rule: 'first-option').
|
|
51
|
+
*
|
|
52
|
+
* For kind=choice only the first match is taken; for kind=multi the whole
|
|
53
|
+
* recommended set is taken (or the first option when nothing is marked).
|
|
54
|
+
*
|
|
55
|
+
* Contract: `choices` must be non-empty — the handler guards with
|
|
56
|
+
* `choices.length > 0` before calling (an empty array means there is nothing
|
|
57
|
+
* to pick and the headless path returns the free-text error instead). An
|
|
58
|
+
* empty array here is a programming error, so this throws rather than
|
|
59
|
+
* returning `[undefined]`.
|
|
60
|
+
*
|
|
61
|
+
* Exported for unit testing.
|
|
62
|
+
*/
|
|
63
|
+
export function pickHeadlessChoices(kind, choices) {
|
|
64
|
+
if (choices.length === 0) {
|
|
65
|
+
throw new Error('pickHeadlessChoices requires at least one choice — callers must guard (see ask_question handler)');
|
|
66
|
+
}
|
|
67
|
+
const flagged = choices.filter((c) => c.recommended === true);
|
|
68
|
+
if (flagged.length > 0) {
|
|
69
|
+
return { picked: kind === 'multi' ? flagged : [flagged[0]], rule: 'recommended' };
|
|
70
|
+
}
|
|
71
|
+
const labelled = choices.filter((c) => labelSaysRecommended(c.label) || labelSaysRecommended(c.value));
|
|
72
|
+
if (labelled.length > 0) {
|
|
73
|
+
return { picked: kind === 'multi' ? labelled : [labelled[0]], rule: 'recommended-label' };
|
|
74
|
+
}
|
|
75
|
+
return { picked: [choices[0]], rule: 'first-option' };
|
|
76
|
+
}
|
|
30
77
|
registerTool({
|
|
31
78
|
name: 'ask_question',
|
|
32
79
|
description: "Ask the user a clarifying question and get their answer. Use this for ANY user-decision point in a skill conversation — object names, design choices, approval checkpoints, etc. The CLI renders a formatted prompt, waits for the user's answer, and returns it as the tool result. Prefer this over emitting a text question — it is the only reliable mechanism for mid-turn user interaction.",
|
|
@@ -59,6 +106,10 @@ registerTool({
|
|
|
59
106
|
properties: {
|
|
60
107
|
value: { type: 'string', description: 'Machine value returned if the user picks this.' },
|
|
61
108
|
label: { type: 'string', description: 'Human-readable label shown in the picker.' },
|
|
109
|
+
recommended: {
|
|
110
|
+
type: 'boolean',
|
|
111
|
+
description: 'Mark the option you would recommend. In headless (non-interactive) runs this option is auto-picked; in interactive runs it is informational only.',
|
|
112
|
+
},
|
|
62
113
|
},
|
|
63
114
|
required: ['value', 'label'],
|
|
64
115
|
},
|
|
@@ -71,9 +122,50 @@ registerTool({
|
|
|
71
122
|
const question = String(args.question);
|
|
72
123
|
const context = args.context ? String(args.context) : undefined;
|
|
73
124
|
const kind = args.kind;
|
|
74
|
-
const choices = Array.isArray(args.choices)
|
|
75
|
-
|
|
76
|
-
|
|
125
|
+
const choices = Array.isArray(args.choices) ? args.choices : [];
|
|
126
|
+
// B5 (2026-06-11) — headless answer policy (defects D1/D1b). In a
|
|
127
|
+
// headless run (one-shot / piped stdin) NO prompt may ever block on
|
|
128
|
+
// stdin: battery S2 Pass A stalled forever on exactly this path.
|
|
129
|
+
// - choice/multi WITH options: auto-pick (recommended > recommended-
|
|
130
|
+
// label > first option), log to stderr, and tell the model via
|
|
131
|
+
// `auto_answered: true` that the user did NOT really answer.
|
|
132
|
+
// - free text (or choice/multi without options): no auto-answer is
|
|
133
|
+
// possible — return an error RESULT to the model (not a hard exit)
|
|
134
|
+
// so it can finish gracefully with documented assumptions, the way
|
|
135
|
+
// S2 Pass B completed when answers were supplied up front.
|
|
136
|
+
if (isHeadless()) {
|
|
137
|
+
if ((kind === 'choice' || kind === 'multi') && choices.length > 0) {
|
|
138
|
+
const { picked, rule } = pickHeadlessChoices(kind, choices);
|
|
139
|
+
const answer = picked.map((c) => c.value).join(',');
|
|
140
|
+
console.error(chalk.dim(`headless: auto-answered '${question}' → '${answer}' (${rule})`));
|
|
141
|
+
return {
|
|
142
|
+
content: JSON.stringify({
|
|
143
|
+
id,
|
|
144
|
+
kind,
|
|
145
|
+
answer,
|
|
146
|
+
headless: true,
|
|
147
|
+
auto_answered: true,
|
|
148
|
+
auto_answer_rule: rule,
|
|
149
|
+
note: 'Headless run: the user did not actually answer — this option was auto-selected ' +
|
|
150
|
+
`(${rule}). Proceed with it and clearly note the assumption in your final output.`,
|
|
151
|
+
}),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
console.error(chalk.yellow(`headless: cannot answer free-text question '${question}' — ` +
|
|
155
|
+
'provide this detail in the prompt or run interactively.'));
|
|
156
|
+
return {
|
|
157
|
+
content: JSON.stringify({
|
|
158
|
+
id,
|
|
159
|
+
kind,
|
|
160
|
+
headless: true,
|
|
161
|
+
error: 'headless_unanswerable',
|
|
162
|
+
message: `Headless run: no user is available to answer this free-text question: "${question}". ` +
|
|
163
|
+
'Either proceed with a sensible assumption and CLEARLY document it in your final output, ' +
|
|
164
|
+
'or finish by telling the user to include this detail in the prompt or run interactively.',
|
|
165
|
+
}),
|
|
166
|
+
is_error: true,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
77
169
|
// 2026-05-01 redesign: when the LLM provides choices, use a real
|
|
78
170
|
// arrow-key picker (select / checkbox) instead of free-text input.
|
|
79
171
|
// The previous "always free-text, smart-resolve digits" pattern produced
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `system_capability` — the data-driven capability gate behind Pillar A.
|
|
3
|
+
*
|
|
4
|
+
* Given a capability id (cds.viewEntity | rap.managed | abap.filterExpr |
|
|
5
|
+
* abap.inlineData) or a raw ABAP feature name, returns a yes/no/with-fallback
|
|
6
|
+
* verdict for the CONNECTED SAP release, read out of the committed ABAP
|
|
7
|
+
* feature-matrix snapshot. Skills call this instead of hardcoding version
|
|
8
|
+
* prose like "FILTER needs 7.50".
|
|
9
|
+
*
|
|
10
|
+
* Read-only: it consults system-info (which caches/queries CVERS once) and a
|
|
11
|
+
* static snapshot — it never writes to SAP. Flag-gated with the other
|
|
12
|
+
* local-build tools (rides the `local_build` switch via LOCAL_BUILD_TOOLS).
|
|
13
|
+
*
|
|
14
|
+
* See docs/superpowers/specs/2026-06-24-revision-aware-cspeach-design.md.
|
|
15
|
+
*/
|
|
16
|
+
import { registerTool } from '../index.js';
|
|
17
|
+
import { getSapSystemInfo } from '../../sap/system-info.js';
|
|
18
|
+
import { loadCapabilityMatrix } from '../../sap/capability-matrix.js';
|
|
19
|
+
import { resolveCapability, CAPABILITY_REGISTRY, } from '../../sap/capability.js';
|
|
20
|
+
function isRegistryId(s) {
|
|
21
|
+
return Object.prototype.hasOwnProperty.call(CAPABILITY_REGISTRY, s);
|
|
22
|
+
}
|
|
23
|
+
async function systemCapabilityHandler(args, ctx) {
|
|
24
|
+
const capability = args?.capability;
|
|
25
|
+
if (typeof capability !== 'string' || capability.trim() === '') {
|
|
26
|
+
return { content: 'error: capability (non-empty string) is required', is_error: true };
|
|
27
|
+
}
|
|
28
|
+
const info = await getSapSystemInfo(ctx.adt, ctx.sapAlias);
|
|
29
|
+
if (!info) {
|
|
30
|
+
// Release undetectable. Build a minimal verdict-like JSON: with-fallback
|
|
31
|
+
// when the caller asked for a registry id that HAS a fallback, else no.
|
|
32
|
+
const fallback = isRegistryId(capability)
|
|
33
|
+
? CAPABILITY_REGISTRY[capability].fallback || undefined
|
|
34
|
+
: undefined;
|
|
35
|
+
const feature = isRegistryId(capability) ? CAPABILITY_REGISTRY[capability].feature : capability;
|
|
36
|
+
const verdict = {
|
|
37
|
+
capability,
|
|
38
|
+
feature,
|
|
39
|
+
status: fallback ? 'with-fallback' : 'no',
|
|
40
|
+
reason: 'SAP release could not be detected — verify the system manually before relying on this feature',
|
|
41
|
+
release: 'unknown',
|
|
42
|
+
column: null,
|
|
43
|
+
source: 'matrix-snapshot',
|
|
44
|
+
...(fallback ? { fallback } : {}),
|
|
45
|
+
};
|
|
46
|
+
return { content: JSON.stringify(verdict) };
|
|
47
|
+
}
|
|
48
|
+
const verdict = resolveCapability(info, loadCapabilityMatrix(), capability);
|
|
49
|
+
return { content: JSON.stringify(verdict) };
|
|
50
|
+
}
|
|
51
|
+
registerTool({
|
|
52
|
+
name: 'system_capability',
|
|
53
|
+
description: 'Returns yes / no / with-fallback for whether the CONNECTED SAP release supports a given ABAP '
|
|
54
|
+
+ 'capability, read out of the committed ABAP feature-matrix snapshot — so skills stop hardcoding '
|
|
55
|
+
+ 'version prose like "FILTER needs 7.50". Pass a capability gate id (cds.viewEntity | rap.managed | '
|
|
56
|
+
+ 'abap.filterExpr | abap.inlineData) OR a raw ABAP feature name from the matrix. The verdict carries '
|
|
57
|
+
+ 'the feature consulted, the matrix column used, the reason, and (only for with-fallback) a concrete '
|
|
58
|
+
+ 'fallback instruction. Read-only — never writes to SAP.',
|
|
59
|
+
isMutating: false,
|
|
60
|
+
category: 'sap',
|
|
61
|
+
flagGated: true,
|
|
62
|
+
input_schema: {
|
|
63
|
+
type: 'object',
|
|
64
|
+
properties: {
|
|
65
|
+
capability: {
|
|
66
|
+
type: 'string',
|
|
67
|
+
description: 'A capability gate id (cds.viewEntity | rap.managed | abap.filterExpr | abap.inlineData) '
|
|
68
|
+
+ 'OR a raw ABAP feature name from the matrix.',
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
required: ['capability'],
|
|
72
|
+
},
|
|
73
|
+
handler: systemCapabilityHandler,
|
|
74
|
+
});
|