@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
|
@@ -8,11 +8,26 @@
|
|
|
8
8
|
* allow = ["docker", "make"]
|
|
9
9
|
* block in ~/.cspeach/config.toml.
|
|
10
10
|
*
|
|
11
|
-
* Cross-platform
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
11
|
+
* Cross-platform spawn via cross-spawn (the library npm itself uses).
|
|
12
|
+
* The actual spawn is handed the ABSOLUTE PATH-resolved executable path
|
|
13
|
+
* (from resolveExecutable's PATH-only walk) + argv ARRAY. cross-spawn
|
|
14
|
+
* still inspects the file extension and, on Windows, invokes .cmd / .bat
|
|
15
|
+
* through cmd.exe with correct per-arg escaping. This satisfies Node's
|
|
16
|
+
* CVE-2024-27980 restriction (spawn with shell:false refuses to launch
|
|
17
|
+
* .cmd/.bat directly) WITHOUT ever setting shell:true — args stay an
|
|
18
|
+
* array, so shell metacharacters are inert and there is no injection
|
|
19
|
+
* surface. On Unix cross-spawn is a near-passthrough to
|
|
20
|
+
* child_process.spawn (unchanged behavior).
|
|
21
|
+
*
|
|
22
|
+
* SECURITY — why the absolute path, not the bare name: cross-spawn
|
|
23
|
+
* resolves a BARE command name cwd-FIRST on Windows (node_modules/which
|
|
24
|
+
* checks process.cwd() before PATH on win32). A model with an in-tree
|
|
25
|
+
* write primitive could plant `<safelisted>.cmd` in the project tree and
|
|
26
|
+
* have cross-spawn pick it up ahead of the real PATH binary → arbitrary
|
|
27
|
+
* code execution, safelist bypassed. Spawning the absolute PATH-resolved
|
|
28
|
+
* path (which never consults cwd/PATH for a path-bearing command) closes
|
|
29
|
+
* that vector. The pre-flight resolveExecutable() PATH walk and the spawn
|
|
30
|
+
* now use the SAME binary — no pre-flight/spawn divergence.
|
|
16
31
|
*
|
|
17
32
|
* Operational caps:
|
|
18
33
|
* - 30 s wall-clock timeout (overridable up to 300 s via timeout_ms arg)
|
|
@@ -24,7 +39,7 @@
|
|
|
24
39
|
* Flag-gated: invisible to listTools() unless CSPEACH_TOOL_SHELL_EXEC=on.
|
|
25
40
|
* isMutating: true — hooks into the existing approval gate.
|
|
26
41
|
*/
|
|
27
|
-
import
|
|
42
|
+
import spawn from 'cross-spawn';
|
|
28
43
|
import { promises as fs } from 'node:fs';
|
|
29
44
|
import * as path from 'node:path';
|
|
30
45
|
import { registerTool } from '../index.js';
|
|
@@ -80,6 +95,9 @@ export async function shellExecHandler(args, ctx) {
|
|
|
80
95
|
catch {
|
|
81
96
|
return { content: `error: cwd "${args.cwd ?? '.'}" does not exist`, is_error: true };
|
|
82
97
|
}
|
|
98
|
+
// Resolve the bare safelisted name to an absolute executable path via a
|
|
99
|
+
// PATH-only walk (no cwd-first). This is BOTH the friendly "not installed"
|
|
100
|
+
// pre-flight check AND the spawn target below — same binary, no divergence.
|
|
83
101
|
const resolved = await resolveExecutable(args.command);
|
|
84
102
|
if (resolved === null) {
|
|
85
103
|
return {
|
|
@@ -101,6 +119,23 @@ export async function shellExecHandler(args, ctx) {
|
|
|
101
119
|
settled = true;
|
|
102
120
|
resolve(result);
|
|
103
121
|
};
|
|
122
|
+
// Pass the already-validated absolute `resolved` path (from
|
|
123
|
+
// resolveExecutable's PATH-only walk) — NOT the bare command name.
|
|
124
|
+
//
|
|
125
|
+
// SECURITY: cross-spawn resolves a BARE name cwd-first on Windows
|
|
126
|
+
// (node_modules/which checks process.cwd() before PATH on win32, and
|
|
127
|
+
// it chdir's into options.cwd first). A model with a write primitive
|
|
128
|
+
// could plant `<safelisted>.cmd` (e.g. git.cmd) in the project tree and
|
|
129
|
+
// have cross-spawn pick it up cwd-first → arbitrary code execution,
|
|
130
|
+
// safelist bypassed. Handing cross-spawn the absolute PATH-resolved
|
|
131
|
+
// path closes that: `which` short-circuits to the literal file when the
|
|
132
|
+
// command contains a path separator (pathEnv=['']), so cwd/PATH are
|
|
133
|
+
// never consulted. cross-spawn STILL inspects the file extension and
|
|
134
|
+
// wraps an absolute `.cmd`/`.bat` through cmd.exe with proper per-arg
|
|
135
|
+
// escaping (parse.js isExecutableRegExp only matches .com/.exe), so the
|
|
136
|
+
// Windows shim handling + CVE-2024-27980 .cmd restriction handling are
|
|
137
|
+
// preserved. shell:false stays (argv-only; metacharacters inert). The
|
|
138
|
+
// existence check above and this spawn now target the SAME binary.
|
|
104
139
|
const child = spawn(resolved, argv, {
|
|
105
140
|
cwd,
|
|
106
141
|
env: buildSafeEnv(),
|
package/dist/tools/snapshot.js
CHANGED
|
@@ -14,7 +14,18 @@
|
|
|
14
14
|
* cleanup(retentionHours?) → Promise<number>
|
|
15
15
|
*/
|
|
16
16
|
import { registerTool } from './index.js';
|
|
17
|
-
import { snapshots } from '@cspeach/sap-client';
|
|
17
|
+
import { snapshots, SapError } from '@cspeach/sap-client';
|
|
18
|
+
/** True only for a genuine ADT 404 — the object does not exist. Any other
|
|
19
|
+
* failure (network, auth, 5xx, timeout) is a READ failure, not proof of
|
|
20
|
+
* absence, and must never be labelled "object not found". */
|
|
21
|
+
function isNotFound(err) {
|
|
22
|
+
return err instanceof SapError && err.httpStatus === 404;
|
|
23
|
+
}
|
|
24
|
+
/** Short single-line failure message for transcripts / error envelopes. */
|
|
25
|
+
function shortErrorMessage(err) {
|
|
26
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
27
|
+
return msg.replace(/\s+/g, ' ').trim();
|
|
28
|
+
}
|
|
18
29
|
registerTool({
|
|
19
30
|
name: 'sap_snapshot_take',
|
|
20
31
|
description: "Take a snapshot of an object's current source before modification. Usually invoked automatically by the CLI before any write — rarely called directly by the model.",
|
|
@@ -28,25 +39,37 @@ registerTool({
|
|
|
28
39
|
required: ['name', 'type'],
|
|
29
40
|
},
|
|
30
41
|
handler: async (args, ctx) => {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
42
|
+
let source;
|
|
43
|
+
try {
|
|
44
|
+
source = await ctx.adt.getSource(args.type, args.name);
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
if (isNotFound(err)) {
|
|
48
|
+
return { content: JSON.stringify({ skipped: true, reason: 'object_not_found' }) };
|
|
49
|
+
}
|
|
50
|
+
// Read failed for some OTHER reason — the object may well exist.
|
|
51
|
+
// Claiming "object_not_found" here would be a lie; surface the failure.
|
|
52
|
+
return {
|
|
53
|
+
content: JSON.stringify({ error: 'source_read_failed', detail: shortErrorMessage(err) }),
|
|
54
|
+
is_error: true,
|
|
55
|
+
};
|
|
34
56
|
}
|
|
35
57
|
const entry = await snapshots.take(args.type, args.name, source, 'manual');
|
|
36
58
|
return { content: JSON.stringify({ snapshot: entry, size: source.length }) };
|
|
37
59
|
},
|
|
38
60
|
});
|
|
39
|
-
/**
|
|
40
|
-
* Auto-snapshot helper called by write tools before mutating an object.
|
|
41
|
-
* Silently skips if the object does not exist yet (first write after create).
|
|
42
|
-
*
|
|
43
|
-
* snapshots.take expects (type, name, source, reason).
|
|
44
|
-
*/
|
|
45
61
|
export async function autoSnapshot(objectName, objectType, adt) {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
62
|
+
let src;
|
|
63
|
+
try {
|
|
64
|
+
src = await adt.getSource(objectType, objectName);
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
if (isNotFound(err))
|
|
68
|
+
return { taken: false, reason: 'object_not_found' }; // new object
|
|
69
|
+
return { taken: false, reason: 'source_read_failed', detail: shortErrorMessage(err) };
|
|
70
|
+
}
|
|
71
|
+
const entry = await snapshots.take(objectType, objectName, src, 'before_write');
|
|
72
|
+
return { taken: true, entry };
|
|
50
73
|
}
|
|
51
74
|
// ── sap_snapshot_list ─────────────────────────────────────────────────────────
|
|
52
75
|
registerTool({
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* CSPEACH_TOOL_BACKGROUND_RUN=on. isMutating: true — hooks the existing
|
|
17
17
|
* approval gate (same UX as shell_exec).
|
|
18
18
|
*/
|
|
19
|
-
import
|
|
19
|
+
import spawn from 'cross-spawn';
|
|
20
20
|
import { promises as fs } from 'node:fs';
|
|
21
21
|
import * as path from 'node:path';
|
|
22
22
|
import { randomUUID } from 'node:crypto';
|
|
@@ -69,6 +69,9 @@ export async function backgroundRunHandler(args, ctx) {
|
|
|
69
69
|
catch {
|
|
70
70
|
return { content: `error: cwd "${args.cwd ?? '.'}" does not exist`, is_error: true };
|
|
71
71
|
}
|
|
72
|
+
// Resolve the bare safelisted name to an absolute executable path via a
|
|
73
|
+
// PATH-only walk (no cwd-first). Both the friendly "not installed"
|
|
74
|
+
// pre-flight check AND the spawn target below — same binary, no divergence.
|
|
72
75
|
const resolved = await resolveExecutable(args.command);
|
|
73
76
|
if (resolved === null) {
|
|
74
77
|
return {
|
|
@@ -78,6 +81,19 @@ export async function backgroundRunHandler(args, ctx) {
|
|
|
78
81
|
}
|
|
79
82
|
let child;
|
|
80
83
|
try {
|
|
84
|
+
// Absolute PATH-resolved `resolved` (NOT the bare command name).
|
|
85
|
+
//
|
|
86
|
+
// SECURITY: cross-spawn resolves a BARE name cwd-first on Windows
|
|
87
|
+
// (node_modules/which checks process.cwd() before PATH and chdir's into
|
|
88
|
+
// options.cwd first). A model with a write primitive could plant
|
|
89
|
+
// `<safelisted>.cmd` in the project tree and have it picked up cwd-first
|
|
90
|
+
// → arbitrary code execution, safelist bypassed. Handing cross-spawn the
|
|
91
|
+
// absolute PATH-resolved path closes that (`which` short-circuits to the
|
|
92
|
+
// literal file for path-bearing commands, pathEnv=['']). cross-spawn
|
|
93
|
+
// still wraps an absolute `.cmd`/`.bat` through cmd.exe with per-arg
|
|
94
|
+
// escaping, so Windows shim + CVE-2024-27980 handling stay. shell:false
|
|
95
|
+
// → metacharacters inert. Existence check + spawn now target the SAME
|
|
96
|
+
// binary. See shell_exec.ts for the full rationale.
|
|
81
97
|
child = spawn(resolved, argv, {
|
|
82
98
|
cwd,
|
|
83
99
|
env: buildSafeEnv(),
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* transport-resolution — owning-transport check before SAP writes (Rule 9,
|
|
3
|
+
* battery defects D15/D20).
|
|
4
|
+
*
|
|
5
|
+
* The trap this kills: an object (or one of its LIMU pieces) is already
|
|
6
|
+
* locked in an open transport request. Writing under ANY other transport is
|
|
7
|
+
* guaranteed to fail with a CTS lock conflict (CTS_WBO_API 020 — observed
|
|
8
|
+
* live: "Object LIMU CINC ZBP_I_DOWNTIMELOG…CCIMP is already locked in
|
|
9
|
+
* request S4HK903431"). Asking the human to pick from N open transports
|
|
10
|
+
* (D15: 89 of them) or creating a fresh junk transport (D20) cannot succeed.
|
|
11
|
+
*
|
|
12
|
+
* Correct flow, now structural instead of prose-only:
|
|
13
|
+
* - object locked in exactly one open request → USE that transport
|
|
14
|
+
* silently, overriding a conflicting supplied transport (with a notice);
|
|
15
|
+
* - object locked in several open requests → ambiguous, never force —
|
|
16
|
+
* keep the supplied transport and surface the candidates;
|
|
17
|
+
* - object free / lookup failed → current behaviour (supplied transport).
|
|
18
|
+
*
|
|
19
|
+
* The lookup is one E071+E070 query pair (AdtClient.transportForObject) and
|
|
20
|
+
* is strictly best-effort: a failed lookup (VPN drop, data-preview auth,
|
|
21
|
+
* older mocks without the method) must NEVER block the write.
|
|
22
|
+
*/
|
|
23
|
+
import { wasTransportCreatedThisSession } from './transport.js';
|
|
24
|
+
/**
|
|
25
|
+
* Resolve the transport for a write against the owning-transport ledger.
|
|
26
|
+
*
|
|
27
|
+
* @param ctx tool context (uses ctx.adt + ctx.chunkEmitter)
|
|
28
|
+
* @param objectType ABAP object type of the write target (CLAS, PROG, …)
|
|
29
|
+
* @param objectName ABAP object name of the write target
|
|
30
|
+
* @param supplied the transport the tool would use today (each tool keeps
|
|
31
|
+
* its own args/session fallback semantics and passes the
|
|
32
|
+
* outcome here)
|
|
33
|
+
*/
|
|
34
|
+
export async function resolveWriteTransport(ctx, objectType, objectName, supplied) {
|
|
35
|
+
// TODO(perf): per-turn memo. Every write funnels through this lookup
|
|
36
|
+
// (2 datapreview SQL queries inside transportForObject), so a multi-method
|
|
37
|
+
// turn — N sap_update_method calls against the same class — amplifies to
|
|
38
|
+
// N×2 queries for an answer that cannot change mid-turn. Memoise per turn,
|
|
39
|
+
// keyed `${objectType}:${objectName}`, before the battery grows
|
|
40
|
+
// write-heavy scenarios. No implementation yet.
|
|
41
|
+
let owning = null;
|
|
42
|
+
try {
|
|
43
|
+
owning = await ctx.adt.transportForObject(objectType, objectName);
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
// Best-effort only — never block or delay the write on a failed lookup,
|
|
47
|
+
// but leave a dim trace so "why didn't it auto-resolve?" is diagnosable
|
|
48
|
+
// ('info' channel = non-fatal diagnostics; 'warn' is reserved for the
|
|
49
|
+
// conflicting-override case below).
|
|
50
|
+
ctx.chunkEmitter?.emit('info', `owning-transport lookup failed for ${objectType} ${objectName} — `
|
|
51
|
+
+ `proceeding with ${supplied ?? 'no transport'} `
|
|
52
|
+
+ `(${err instanceof Error ? err.message : String(err)})`);
|
|
53
|
+
return { transport: supplied };
|
|
54
|
+
}
|
|
55
|
+
if (owning?.transport) {
|
|
56
|
+
const conflicting = !!supplied && supplied !== owning.transport;
|
|
57
|
+
const note = conflicting
|
|
58
|
+
? `${objectName} is already locked in ${owning.transport} — writing under the owning transport and ignoring transport=${supplied} (SAP would refuse with a CTS lock conflict)`
|
|
59
|
+
: `object locked in ${owning.transport} — writing under owning transport`;
|
|
60
|
+
// warn only when we OVERRIDE what the caller asked for; the happy match
|
|
61
|
+
// (supplied === owning, or nothing supplied) is informational.
|
|
62
|
+
ctx.chunkEmitter?.emit(conflicting ? 'warn' : 'info', note);
|
|
63
|
+
// Orphan hint (third junk TR this month, 2026-06-12): the session's
|
|
64
|
+
// current transport was created by sap_transport_create THIS session, yet
|
|
65
|
+
// this write was forced into a different (owning) request — the created
|
|
66
|
+
// TR is probably an empty orphan. Warn only; deleting a TR is destructive
|
|
67
|
+
// and stays with the human.
|
|
68
|
+
const sessionCurrent = ctx.currentTransport?.get() ?? null;
|
|
69
|
+
if (sessionCurrent
|
|
70
|
+
&& sessionCurrent !== owning.transport
|
|
71
|
+
&& wasTransportCreatedThisSession(sessionCurrent)) {
|
|
72
|
+
ctx.chunkEmitter?.emit('warn', `transport ${sessionCurrent} created this session was not used by this write — `
|
|
73
|
+
+ 'it may be an empty orphan (delete via SE09 or ask me)');
|
|
74
|
+
}
|
|
75
|
+
return { transport: owning.transport, owning: owning.transport, note };
|
|
76
|
+
}
|
|
77
|
+
if (owning && owning.candidates.length > 1) {
|
|
78
|
+
const note = `${objectName} pieces are locked in ${owning.candidates.length} open transports `
|
|
79
|
+
+ `(${owning.candidates.join(', ')}) — cannot auto-resolve the owning request; `
|
|
80
|
+
+ `using ${supplied ?? 'no transport'}`;
|
|
81
|
+
ctx.chunkEmitter?.emit('warn', note);
|
|
82
|
+
return { transport: supplied, note };
|
|
83
|
+
}
|
|
84
|
+
// Object is free — current behaviour.
|
|
85
|
+
return { transport: supplied };
|
|
86
|
+
}
|
package/dist/tools/transport.js
CHANGED
|
@@ -17,6 +17,28 @@ import { ERR } from '../errors/codes.js';
|
|
|
17
17
|
import { presentSafetyConfirmation } from '../repl/safety-confirm.js';
|
|
18
18
|
import { isSafetyConfirmEnabled } from '../repl/safety-mode-state.js';
|
|
19
19
|
import { setCurrentTransport } from '../repl/current-transport.js';
|
|
20
|
+
// ── Session ledger: transports CREATED by sap_transport_create this session ──
|
|
21
|
+
//
|
|
22
|
+
// Orphan-hint support (third junk transport this month, 2026-06-12 — D15/D20
|
|
23
|
+
// pattern: /abap-refactor created a fresh TR while ZTTT_GAME was already
|
|
24
|
+
// locked in S4HK903328, leaving the new TR an empty orphan).
|
|
25
|
+
// resolveWriteTransport (transport-resolution.ts) consults this set: when a
|
|
26
|
+
// write is forced into the owning transport, the session current-transport
|
|
27
|
+
// differs, AND the current one came from a create THIS session, it warns that
|
|
28
|
+
// the created TR may be an empty orphan. Deliberately NOT persisted — a TR
|
|
29
|
+
// created in an earlier session may have collected objects since.
|
|
30
|
+
const createdThisSession = new Set();
|
|
31
|
+
export function markTransportCreatedThisSession(trkorr) {
|
|
32
|
+
createdThisSession.add(trkorr);
|
|
33
|
+
}
|
|
34
|
+
export function wasTransportCreatedThisSession(trkorr) {
|
|
35
|
+
return createdThisSession.has(trkorr);
|
|
36
|
+
}
|
|
37
|
+
/** Test hook — the set is a module-scope singleton and would otherwise leak
|
|
38
|
+
* across Vitest tests (same pattern as clearCurrentTransport). */
|
|
39
|
+
export function clearTransportsCreatedThisSession() {
|
|
40
|
+
createdThisSession.clear();
|
|
41
|
+
}
|
|
20
42
|
// ── sap_transport_create ─────────────────────────────────────────────────────
|
|
21
43
|
//
|
|
22
44
|
// Creates a new CTS workbench transport request.
|
|
@@ -24,16 +46,43 @@ import { setCurrentTransport } from '../repl/current-transport.js';
|
|
|
24
46
|
registerTool({
|
|
25
47
|
name: 'sap_transport_create',
|
|
26
48
|
description: 'Create a new CTS transport request (Rule 9 — one dedicated transport per session). '
|
|
27
|
-
+ '
|
|
28
|
-
+ '
|
|
29
|
-
+ '
|
|
30
|
-
+ '
|
|
31
|
-
+ '
|
|
49
|
+
+ 'RESOLVE FIRST: pass for_object_type/for_object_name ONLY for an EXISTING object you '
|
|
50
|
+
+ 'are about to MODIFY/WRITE — the tool then reuses that object owning transport. Do NOT '
|
|
51
|
+
+ 'pass an object you are only READING and not changing (e.g. the legacy source in a '
|
|
52
|
+
+ 'modernization; the NEW objects go to a fresh transport and the read-only source lock '
|
|
53
|
+
+ 'is irrelevant). Creating brand-new objects from scratch? OMIT for_object and a fresh '
|
|
54
|
+
+ 'transport is created. '
|
|
55
|
+
+ 'Flow: resolve → object locked in an open transport? this tool then does NOT '
|
|
56
|
+
+ 'create anything and returns the owning transport — use it → only create when '
|
|
57
|
+
+ 'the object is free. '
|
|
58
|
+
+ 'Ask the user for the transport description using `ask_question` — DO NOT '
|
|
59
|
+
+ 'generate a marketing-style description yourself. When asking, SUGGEST the '
|
|
60
|
+
+ 'Rule 9 session format "ABAPForge <YYYY-MM-DD>: <work summary>" as the default; '
|
|
61
|
+
+ 'the user may override. Use the user\'s exact answer as the `description` '
|
|
62
|
+
+ 'argument AND in your `request_approval` reason (identical strings prevent the '
|
|
63
|
+
+ 'approval mismatch failure mode observed live 2026-05-07). '
|
|
32
64
|
+ 'Requires approval_id. Returns the transport number to use in subsequent writes.',
|
|
33
65
|
isMutating: true,
|
|
34
66
|
input_schema: {
|
|
35
67
|
type: 'object',
|
|
36
68
|
properties: {
|
|
69
|
+
for_object_type: {
|
|
70
|
+
type: 'string',
|
|
71
|
+
description: 'ABAP object type (PROG, CLAS, …) of the object motivating this transport. '
|
|
72
|
+
+ 'Pass it together with for_object_name whenever the transport is for work '
|
|
73
|
+
+ 'on a known existing object.',
|
|
74
|
+
},
|
|
75
|
+
for_object_name: {
|
|
76
|
+
type: 'string',
|
|
77
|
+
description: 'Name of the object you are about to WRITE/modify (NOT a read-only source object '
|
|
78
|
+
+ 'you only read — e.g. the legacy program in a modernization, whose new objects '
|
|
79
|
+
+ 'go to a fresh transport).',
|
|
80
|
+
},
|
|
81
|
+
force: {
|
|
82
|
+
type: 'boolean',
|
|
83
|
+
description: 'Set true to create a new transport even though for_object is already '
|
|
84
|
+
+ 'locked elsewhere (rare — deliberate parallel transport). Default false.',
|
|
85
|
+
},
|
|
37
86
|
description: {
|
|
38
87
|
type: 'string',
|
|
39
88
|
description: 'Transport description AS PROVIDED BY THE USER (ask via ask_question first). '
|
|
@@ -60,6 +109,79 @@ registerTool({
|
|
|
60
109
|
required: ['description', 'approval_id'],
|
|
61
110
|
},
|
|
62
111
|
handler: async (args, ctx) => {
|
|
112
|
+
// ── Gate 0: owning-transport guard (runs BEFORE the approval gate) ──────
|
|
113
|
+
//
|
|
114
|
+
// Ordering decision (2026-06-12, documented on purpose): the E071 lookup
|
|
115
|
+
// runs BEFORE verifyAndSpendApprovalId. When the motivating object is
|
|
116
|
+
// already locked, the early `already_locked` return performs NO mutation
|
|
117
|
+
// (read-only lookup + session current-transport pointer), so no
|
|
118
|
+
// authorization needs to be — or should be — consumed for it. Concretely:
|
|
119
|
+
// verifyAndSpendApprovalId only burns a nonce for one-shot ops
|
|
120
|
+
// (delete/release; 'create' approvals are reusable within TTL), so the
|
|
121
|
+
// user's approval JWT survives either ordering — but lookup-first means
|
|
122
|
+
// the model gets the useful answer ("use S4HK903328") even when the
|
|
123
|
+
// approval is stale/mismatched, instead of an APPROVAL_INVALID round-trip
|
|
124
|
+
// for a create that should never happen. The approval is verified only on
|
|
125
|
+
// the path that actually creates.
|
|
126
|
+
//
|
|
127
|
+
// Best-effort: a failed lookup must NEVER block the create (VPN flake is
|
|
128
|
+
// normal here) — same contract as resolveWriteTransport.
|
|
129
|
+
let guardNote;
|
|
130
|
+
if (args.for_object_name && !args.force) {
|
|
131
|
+
try {
|
|
132
|
+
const owning = await ctx.adt.transportForObject(args.for_object_type ?? '', args.for_object_name);
|
|
133
|
+
if (owning.transport) {
|
|
134
|
+
// Mirror the create success path: the owning TR becomes the session
|
|
135
|
+
// current-transport so subsequent writes pick it up via the 3-tier
|
|
136
|
+
// fallback. It is NOT marked created-this-session — it wasn't.
|
|
137
|
+
setCurrentTransport(owning.transport);
|
|
138
|
+
// Ownership-aware guidance (I1): "use it" is only honest advice when
|
|
139
|
+
// the connected user owns the lock — SAP rejects a foreign user's
|
|
140
|
+
// writes under someone else's request unless the owner adds a task.
|
|
141
|
+
const me = (ctx.adt.username ?? '').toUpperCase();
|
|
142
|
+
const foreignOwner = owning.owner && owning.owner.toUpperCase() !== me ? owning.owner : undefined;
|
|
143
|
+
const note = foreignOwner
|
|
144
|
+
? `${args.for_object_name} is locked in ${owning.transport} (owned by ${foreignOwner}) `
|
|
145
|
+
+ '— SAP only accepts changes under that request; ask the owner to add you as a '
|
|
146
|
+
+ 'task user. Do NOT create a new transport either way.'
|
|
147
|
+
: `${args.for_object_name} is already locked in ${owning.transport} — use it; `
|
|
148
|
+
+ 'no new transport created. Set as session current transport for subsequent writes.';
|
|
149
|
+
ctx.chunkEmitter?.emit('info', note);
|
|
150
|
+
return {
|
|
151
|
+
content: JSON.stringify({
|
|
152
|
+
created: false,
|
|
153
|
+
already_locked: true,
|
|
154
|
+
transport: owning.transport,
|
|
155
|
+
...(owning.owner ? { owner: owning.owner } : {}),
|
|
156
|
+
note,
|
|
157
|
+
}),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
if (owning.candidates.length > 1) {
|
|
161
|
+
// Ambiguous ownership — never pick silently (same stance as
|
|
162
|
+
// resolveWriteTransport). The create proceeds, but the model is
|
|
163
|
+
// told the candidates so it can surface them to the user.
|
|
164
|
+
guardNote =
|
|
165
|
+
`${args.for_object_name} pieces are locked in ${owning.candidates.length} open `
|
|
166
|
+
+ `transports (${owning.candidates.join(', ')}) — writes to those pieces will be `
|
|
167
|
+
+ 'forced into their owning requests; the new transport may stay empty.';
|
|
168
|
+
}
|
|
169
|
+
else if (owning.capped) {
|
|
170
|
+
// M1: a capped lookup means the E071 row set was truncated — the
|
|
171
|
+
// object COULD be locked in rows we never saw. The create proceeds
|
|
172
|
+
// (best-effort contract), but never silently.
|
|
173
|
+
guardNote =
|
|
174
|
+
'owning-transport pre-check unreliable (row cap) — proceeding to create; '
|
|
175
|
+
+ 'verify in SE03 if unsure.';
|
|
176
|
+
}
|
|
177
|
+
// free → normal create flow.
|
|
178
|
+
}
|
|
179
|
+
catch (err) {
|
|
180
|
+
ctx.chunkEmitter?.emit('info', `owning-transport pre-check failed for ${args.for_object_type ?? ''} `
|
|
181
|
+
+ `${args.for_object_name} — proceeding with create `
|
|
182
|
+
+ `(${err instanceof Error ? err.message : String(err)})`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
63
185
|
// ── Gate 1: Approval ────────────────────────────────────────────────────
|
|
64
186
|
const verify = await verifyAndSpendApprovalId(args.approval_id, args.description, 'create');
|
|
65
187
|
if (!verify.ok) {
|
|
@@ -84,11 +206,34 @@ registerTool({
|
|
|
84
206
|
// subsequent write calls pick it up via the 3-tier fallback without the LLM
|
|
85
207
|
// needing to pass it explicitly every time.
|
|
86
208
|
setCurrentTransport(result.transportNumber);
|
|
209
|
+
// Orphan-hint ledger: resolveWriteTransport warns later if this freshly
|
|
210
|
+
// created TR ends up bypassed by an owning-transport force.
|
|
211
|
+
markTransportCreatedThisSession(result.transportNumber);
|
|
212
|
+
// Legacy-caller nudge: the object-context args are how the create guard
|
|
213
|
+
// learns what the transport is FOR. Surfaced in the RESULT payload (not
|
|
214
|
+
// just the human stream) so the model learns the contract.
|
|
215
|
+
const forObjectHint = args.for_object_name
|
|
216
|
+
? undefined
|
|
217
|
+
: 'Tip: pass for_object_type/for_object_name when the transport is for a known '
|
|
218
|
+
+ 'existing object — the tool then reuses the owning transport instead of '
|
|
219
|
+
+ 'creating an empty orphan (or check first via sap_transport_for_object).';
|
|
220
|
+
// Rule 9 description format (D20: "Magic fix" accepted verbatim) —
|
|
221
|
+
// non-blocking note when the description doesn't follow the session
|
|
222
|
+
// format. The transport IS created; the user's wording always wins.
|
|
223
|
+
const rule9Format = /^ABAPForge \d{4}-\d{2}-\d{2}: .+/;
|
|
224
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
225
|
+
const rule9Suggestion = rule9Format.test(args.description)
|
|
226
|
+
? undefined
|
|
227
|
+
: `Description does not follow the Rule 9 session format. Suggested: "ABAPForge ${today}: <work summary>". `
|
|
228
|
+
+ 'Non-blocking — keep the user\'s wording if they chose it deliberately.';
|
|
87
229
|
return {
|
|
88
230
|
content: JSON.stringify({
|
|
89
231
|
created: true,
|
|
90
232
|
transport_number: result.transportNumber,
|
|
91
233
|
description: result.description,
|
|
234
|
+
...(rule9Suggestion ? { rule9_suggestion: rule9Suggestion } : {}),
|
|
235
|
+
...(guardNote ? { guard_note: guardNote } : {}),
|
|
236
|
+
...(forObjectHint ? { for_object_hint: forObjectHint } : {}),
|
|
92
237
|
note: 'Use this transport_number in all subsequent sap_set_source / sap_create_object calls this session.',
|
|
93
238
|
}),
|
|
94
239
|
};
|
|
@@ -102,6 +247,80 @@ registerTool({
|
|
|
102
247
|
}
|
|
103
248
|
},
|
|
104
249
|
});
|
|
250
|
+
// ── sap_transport_for_object ─────────────────────────────────────────────────
|
|
251
|
+
//
|
|
252
|
+
// Read-only E071/E070 owning-transport lookup (AdtClient.transportForObject).
|
|
253
|
+
// First-class way for skills/models to resolve the transport question BEFORE
|
|
254
|
+
// asking the user anything or creating a request.
|
|
255
|
+
registerTool({
|
|
256
|
+
name: 'sap_transport_for_object',
|
|
257
|
+
description: 'Check whether an ABAP object (or any of its LIMU pieces) is already locked '
|
|
258
|
+
+ 'in an OPEN transport request (E071/E070 lookup). Read-only, no approval. '
|
|
259
|
+
+ 'Call this BEFORE asking the user about transports or creating one: '
|
|
260
|
+
+ 'locked → use the returned owning transport; not locked → a new or '
|
|
261
|
+
+ 'existing transport may be used.',
|
|
262
|
+
isMutating: false,
|
|
263
|
+
input_schema: {
|
|
264
|
+
type: 'object',
|
|
265
|
+
properties: {
|
|
266
|
+
object_type: {
|
|
267
|
+
type: 'string',
|
|
268
|
+
description: 'ABAP object type (PROG, CLAS, TABL, …)',
|
|
269
|
+
},
|
|
270
|
+
object_name: {
|
|
271
|
+
type: 'string',
|
|
272
|
+
description: 'ABAP object name (e.g. ZTTT_GAME)',
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
required: ['object_type', 'object_name'],
|
|
276
|
+
},
|
|
277
|
+
handler: async (args, ctx) => {
|
|
278
|
+
try {
|
|
279
|
+
const r = await ctx.adt.transportForObject(args.object_type, args.object_name);
|
|
280
|
+
let note;
|
|
281
|
+
if (r.transport) {
|
|
282
|
+
// Ownership-aware guidance (I1) — mirrors the create guard wording.
|
|
283
|
+
const me = (ctx.adt.username ?? '').toUpperCase();
|
|
284
|
+
const foreignOwner = r.owner && r.owner.toUpperCase() !== me ? r.owner : undefined;
|
|
285
|
+
note = foreignOwner
|
|
286
|
+
? `${args.object_name} is locked in ${r.transport} (owned by ${foreignOwner}) — SAP `
|
|
287
|
+
+ 'only accepts changes under that request; ask the owner to add you as a task '
|
|
288
|
+
+ 'user. Do NOT create a new transport either way.'
|
|
289
|
+
: `${args.object_name} is already locked in ${r.transport} — use this `
|
|
290
|
+
+ 'transport for writes to it; do NOT create a new one.';
|
|
291
|
+
}
|
|
292
|
+
else if (r.capped) {
|
|
293
|
+
note = 'Lookup hit its row cap — the result is unreliable; ownership could not '
|
|
294
|
+
+ 'be determined. Treat as unknown (write tools will still auto-resolve).';
|
|
295
|
+
}
|
|
296
|
+
else if (r.candidates.length > 1) {
|
|
297
|
+
note = `${args.object_name} pieces are locked in ${r.candidates.length} open `
|
|
298
|
+
+ `transports (${r.candidates.join(', ')}) — ambiguous; ask the user which to use.`;
|
|
299
|
+
}
|
|
300
|
+
else {
|
|
301
|
+
note = `${args.object_name} is not locked in any open transport — a new or `
|
|
302
|
+
+ 'existing transport may be used.';
|
|
303
|
+
}
|
|
304
|
+
return {
|
|
305
|
+
content: JSON.stringify({
|
|
306
|
+
object_type: args.object_type,
|
|
307
|
+
object_name: args.object_name,
|
|
308
|
+
transport: r.transport,
|
|
309
|
+
owner: r.owner ?? null,
|
|
310
|
+
candidates: r.candidates,
|
|
311
|
+
capped: r.capped ?? false,
|
|
312
|
+
note,
|
|
313
|
+
}),
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
catch (err) {
|
|
317
|
+
return {
|
|
318
|
+
content: JSON.stringify({ error: 'transport_for_object_failed', detail: String(err) }),
|
|
319
|
+
is_error: true,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
},
|
|
323
|
+
});
|
|
105
324
|
// ── sap_transport_list ───────────────────────────────────────────────────────
|
|
106
325
|
//
|
|
107
326
|
// Lists transport requests, optionally filtered by owner and/or status.
|
package/dist/tools/write-mode.js
CHANGED
|
@@ -9,12 +9,16 @@
|
|
|
9
9
|
*/
|
|
10
10
|
export const WRITE_TOOLS = new Set([
|
|
11
11
|
'sap_create_object',
|
|
12
|
+
'sap_create_domain',
|
|
13
|
+
'sap_create_data_element',
|
|
12
14
|
'sap_set_source',
|
|
15
|
+
'sap_set_class_include',
|
|
13
16
|
'sap_update_method',
|
|
14
17
|
'sap_delete_object',
|
|
15
18
|
'sap_activate',
|
|
16
19
|
'sap_message_maintain',
|
|
17
20
|
'sap_service_binding_publish',
|
|
21
|
+
'sap_service_binding_unpublish',
|
|
18
22
|
'sap_transport_release',
|
|
19
23
|
]);
|
|
20
24
|
export function isWriteTool(name) {
|