@cspeach/cli 1.1.27 → 1.1.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/loop.js +33 -4
- package/dist/agent/provider-shape.js +9 -2
- package/dist/agent/providers/ai-hub-provider.js +2 -2
- package/dist/agent/providers/byok-provider.js +2 -2
- package/dist/agent/providers/local-provider.js +4 -4
- package/dist/agent/system-prompt.js +34 -7
- package/dist/agent/tool-dispatch.js +20 -1
- package/dist/approvals/grant-claims.js +70 -0
- package/dist/approvals/jwt.js +21 -1
- package/dist/approvals/plan-grant.js +297 -0
- package/dist/commands/config-set.js +12 -0
- package/dist/commands/deploy-card.js +10 -5
- package/dist/commands/export-audit.js +1 -1
- package/dist/commands/plan-audit-evidence.js +47 -1
- package/dist/commands/plan-audit.js +23 -8
- package/dist/commands/plan-batch-audit.js +349 -0
- package/dist/commands/plan-boundary.js +88 -0
- package/dist/commands/plan-chain.js +409 -566
- package/dist/commands/plan-continue.js +21 -7
- package/dist/commands/plan-gate.js +28 -49
- package/dist/commands/plan-reground.js +174 -0
- package/dist/commands/plan-resume.js +227 -103
- package/dist/commands/plan-sap-pause.js +105 -0
- package/dist/commands/plan-startup.js +218 -0
- package/dist/config/loader.js +9 -0
- package/dist/doctor/checks/forge-rules.js +67 -85
- package/dist/doctor/run.js +4 -4
- package/dist/lane/app-chain.js +88 -9
- package/dist/one-shot.js +1 -0
- package/dist/projects/extract-plan.js +34 -1
- package/dist/projects/handover-md.js +1 -1
- package/dist/projects/plan-objects.js +75 -0
- package/dist/projects/plan-run.js +74 -18
- package/dist/projects/plan-schema.js +6 -0
- package/dist/projects/plan-sources.js +199 -0
- package/dist/projects/promote-command.js +1 -1
- package/dist/projects/save-command.js +11 -13
- package/dist/projects/saved-lines.js +36 -0
- package/dist/renderer/checklist-format.js +5 -5
- package/dist/renderer/machine-blocks.js +141 -0
- package/dist/renderer/tool-labels.js +3 -0
- package/dist/repl/plan-hop.js +22 -0
- package/dist/repl/rule8-detector.js +16 -1
- package/dist/repl.js +320 -55
- package/dist/router/router-core.js +2 -2
- package/dist/sap/reachability-state.js +25 -0
- package/dist/session/audit-export.js +10 -16
- package/dist/session/recap.js +16 -26
- package/dist/skills/bundled-skills.js +1 -1
- package/dist/tools/_filesystem-shared.js +37 -0
- package/dist/tools/approval.js +48 -2
- package/dist/tools/dispatch-skill.js +3 -2
- package/dist/tools/filesystem/file-read.js +5 -3
- package/dist/tools/filesystem/glob.js +27 -4
- package/dist/tools/fiori/tools.js +6 -0
- package/dist/tools/lane/app-lane-resolve.js +1 -1
- package/dist/tools/sap-read.js +37 -3
- package/dist/tools/sap-write.js +65 -0
- package/dist/tools/text-elements/pool.js +250 -0
- package/dist/tools/text-elements/tools.js +433 -0
- package/dist/tools/tool-loading.js +3 -0
- package/dist/tools/transport.js +26 -5
- package/dist/tools/write-mode.js +4 -3
- package/package.json +120 -121
- package/dist/projects/email-template.js +0 -56
|
@@ -36,7 +36,7 @@ export const ROUTER_SCHEMA_VERSION = 1;
|
|
|
36
36
|
* its own bundled copy — so a stale published manifest never rolls a newer
|
|
37
37
|
* CLI back to older routing. router-revision-pin.test.ts fails until bumped.
|
|
38
38
|
*/
|
|
39
|
-
export const ROUTER_REVISION =
|
|
39
|
+
export const ROUTER_REVISION = 2;
|
|
40
40
|
// 2026-06-12: keep in lockstep with .claude/skills (35 abap skills).
|
|
41
41
|
// abap-dump replaced abap-dump-analyze + abap-dump-triage (portfolio merge);
|
|
42
42
|
// added previously-missing abap-plan, abap-fiori-build, abap-cca-merge, abap-upgrade-merge.
|
|
@@ -74,7 +74,7 @@ export const SKILL_DESCRIPTIONS = {
|
|
|
74
74
|
'abap-incident': 'End-to-end incident pipeline — from short dump to fix deployed and documented.',
|
|
75
75
|
'abap-migrate': 'ECC→S/4HANA migration assessment for custom objects — API analysis, successor lookup, per-object effort.',
|
|
76
76
|
'abap-modernize': 'Transform a CLASSIC program into a modern architecture (e.g. report → Fiori Elements / RAP) for the release level.',
|
|
77
|
-
'abap-plan': 'Plan and run a multi-
|
|
77
|
+
'abap-plan': 'Plan and run a multi-step SAP project — one approval of the plan and its object list, then the steps run back to back; the plan file carries state across sessions.',
|
|
78
78
|
'abap-preflight': 'Pre-release checklist for a change set — dependencies, tests, rollback, readiness.',
|
|
79
79
|
'abap-rap': 'Scaffold a complete RAP stack from scratch — table, CDS interface+projection, BDEF, behavior class, service definition+binding.',
|
|
80
80
|
'abap-refactor': 'Modify / improve / add a feature to EXISTING custom ABAP code in place — restructure, clean up, or change a Z/Y program/class (incl. small feature changes like adding output coloring).',
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// cspeach-cli/src/sap/reachability-state.ts
|
|
2
|
+
//
|
|
3
|
+
// 2026-10-04 (continuous run, spec §3.5) — set when a SAP tool fails because
|
|
4
|
+
// the SAP host is not reachable from this machine; read (and cleared) by the
|
|
5
|
+
// REPL after a plan turn so the build pauses instead of failing.
|
|
6
|
+
let unreachable = null;
|
|
7
|
+
const CODE_RE = /\b(ETIMEDOUT|ECONNREFUSED|ENOTFOUND|EHOSTUNREACH|ENETUNREACH|ECONNRESET|EAI_AGAIN)\b/;
|
|
8
|
+
export const SAP_UNREACHABLE_DIRECTIVE = 'SAP is not reachable from this machine right now. Stop here: do not retry SAP tools and do not write the plan manifest. '
|
|
9
|
+
+ 'End the turn with one short sentence. CSPeach pauses the build and runs this step again when the network is back.';
|
|
10
|
+
export function noteSapUnreachable(code) {
|
|
11
|
+
unreachable = code;
|
|
12
|
+
}
|
|
13
|
+
/** True while a SAP-unreachable failure is noted for this turn (does not clear it). */
|
|
14
|
+
export function isSapUnreachableNoted() {
|
|
15
|
+
return unreachable !== null;
|
|
16
|
+
}
|
|
17
|
+
export function getAndResetSapUnreachable() {
|
|
18
|
+
const c = unreachable;
|
|
19
|
+
unreachable = null;
|
|
20
|
+
return c;
|
|
21
|
+
}
|
|
22
|
+
export function unreachableCodeInText(text) {
|
|
23
|
+
const m = CODE_RE.exec(String(text ?? ''));
|
|
24
|
+
return m ? m[1] : null;
|
|
25
|
+
}
|
|
@@ -45,6 +45,7 @@ const WRITE_TOOLS = new Set([
|
|
|
45
45
|
'sap_delete_object',
|
|
46
46
|
'sap_create_object',
|
|
47
47
|
'sap_set_class_include',
|
|
48
|
+
'sap_set_text_elements',
|
|
48
49
|
'sap_create_domain',
|
|
49
50
|
'sap_create_data_element',
|
|
50
51
|
'sap_activate',
|
|
@@ -277,18 +278,11 @@ function outcomeCell(c) {
|
|
|
277
278
|
}
|
|
278
279
|
// ── Operator identity ─────────────────────────────────────────────────────
|
|
279
280
|
//
|
|
280
|
-
//
|
|
281
|
-
//
|
|
282
|
-
//
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
// are never conflated. (Inlined rather than importing agent/loop.js to keep
|
|
286
|
-
// this a dependency-light pure builder.)
|
|
287
|
-
function resolveOperator() {
|
|
288
|
-
return (process.env.CSPEACH_AUTHOR_NAME ||
|
|
289
|
-
process.env.USER ||
|
|
290
|
-
process.env.USERNAME ||
|
|
291
|
-
'unknown');
|
|
281
|
+
// 2026-10-04 — the person who RAN CSPeach, as CSPeach knows them: the signed-in
|
|
282
|
+
// account (from /v1/me), else an explicit CSPEACH_AUTHOR_NAME, else "not
|
|
283
|
+
// signed in". NEVER the OS account name (USER/USERNAME printed "Admin").
|
|
284
|
+
function resolveOperator(operator) {
|
|
285
|
+
return operator?.trim() || process.env.CSPEACH_AUTHOR_NAME?.trim() || 'not signed in (local run)';
|
|
292
286
|
}
|
|
293
287
|
// ── Transports seen ───────────────────────────────────────────────────────
|
|
294
288
|
function transportsSeen(toolCalls, sessionTransport) {
|
|
@@ -320,7 +314,7 @@ function transportsSeen(toolCalls, sessionTransport) {
|
|
|
320
314
|
}
|
|
321
315
|
// ── Main builder ──────────────────────────────────────────────────────────
|
|
322
316
|
export function buildAuditReport(input) {
|
|
323
|
-
const { session, costEntries, transport, credits } = input;
|
|
317
|
+
const { session, costEntries, transport, credits, operator } = input;
|
|
324
318
|
const calls = session.toolCalls;
|
|
325
319
|
const completed = calls.filter((c) => c.completed === true);
|
|
326
320
|
const approvals = parseApprovals(calls);
|
|
@@ -344,14 +338,14 @@ export function buildAuditReport(input) {
|
|
|
344
338
|
L();
|
|
345
339
|
L(`- **Session:** \`${session.id}\``);
|
|
346
340
|
L(`- **SAP system:** ${session.sap_system ?? '—'}`);
|
|
347
|
-
L(`- **Operator (
|
|
341
|
+
L(`- **Operator (CSPeach sign-in):** ${resolveOperator(operator)}`);
|
|
348
342
|
L(`- **Period:** ${dateFrom} → ${dateTo}`);
|
|
349
343
|
const trs = transportsSeen(calls, transport);
|
|
350
344
|
L(`- **Transport(s):** ${trs.length ? trs.map((t) => `\`${t}\``).join(', ') : '—'}`);
|
|
351
345
|
L();
|
|
352
346
|
L('> Scope: this record covers SAP-system mutations only — local file and');
|
|
353
|
-
L('> shell operations are excluded by design. Operator is the
|
|
354
|
-
L('>
|
|
347
|
+
L('> shell operations are excluded by design. Operator is the CSPeach sign-in');
|
|
348
|
+
L('> of the person who ran the session; the SAP logon user is not recorded in');
|
|
355
349
|
L('> session state. Plan audit states, if any, live in the plan handover,');
|
|
356
350
|
L('> not this report.');
|
|
357
351
|
L();
|
package/dist/session/recap.js
CHANGED
|
@@ -90,25 +90,18 @@ export function buildRecap(session, cwd, opts) {
|
|
|
90
90
|
return lines;
|
|
91
91
|
}
|
|
92
92
|
/**
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
* "Newest family": envelope files group into families by filename base
|
|
99
|
-
* (`<base>-v<N>[-<sub>].cspeach.json` — same convention as
|
|
100
|
-
* pickNewestPlanVersion / findNewestVersion in commands/plan-resume.ts);
|
|
101
|
-
* within a family the highest version wins; families rank by their newest
|
|
102
|
-
* file's mtime. Non-plan envelopes (design, spec-gap, …) are skipped so the
|
|
103
|
-
* newest PLAN family is found even when a newer non-plan artefact exists.
|
|
93
|
+
* Newest file per envelope family, families newest first (by that file's
|
|
94
|
+
* mtime). [] when the folder is missing. Within a family the highest version
|
|
95
|
+
* wins, then the collision sub, then mtime. A version-less file is its own
|
|
96
|
+
* single-file family. Shared by the recap and the startup Continue offer.
|
|
104
97
|
*/
|
|
105
|
-
function
|
|
98
|
+
export function newestPlanFamilyFiles(workspaceDir) {
|
|
106
99
|
let names;
|
|
107
100
|
try {
|
|
108
101
|
names = readdirSync(workspaceDir);
|
|
109
102
|
}
|
|
110
103
|
catch {
|
|
111
|
-
return
|
|
104
|
+
return []; // workspace missing
|
|
112
105
|
}
|
|
113
106
|
const re = /^(.+)-v(\d+)(?:-(\d+))?\.cspeach\.json$/;
|
|
114
107
|
const cands = [];
|
|
@@ -124,17 +117,10 @@ function buildPlanPart(workspaceDir) {
|
|
|
124
117
|
continue;
|
|
125
118
|
}
|
|
126
119
|
const m = re.exec(name);
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
else {
|
|
131
|
-
// Hand-renamed / version-less envelope: its own single-file family.
|
|
132
|
-
cands.push({ path, family: name.slice(0, -'.cspeach.json'.length), version: 0, sub: 0, mtimeMs });
|
|
133
|
-
}
|
|
120
|
+
cands.push(m
|
|
121
|
+
? { path, family: m[1], version: Number(m[2]), sub: m[3] ? Number(m[3]) : 0, mtimeMs }
|
|
122
|
+
: { path, family: name.slice(0, -'.cspeach.json'.length), version: 0, sub: 0, mtimeMs });
|
|
134
123
|
}
|
|
135
|
-
if (cands.length === 0)
|
|
136
|
-
return null;
|
|
137
|
-
// Newest version per family (version, then collision sub, then mtime).
|
|
138
124
|
const byFamily = new Map();
|
|
139
125
|
for (const c of cands) {
|
|
140
126
|
const cur = byFamily.get(c.family);
|
|
@@ -144,9 +130,13 @@ function buildPlanPart(workspaceDir) {
|
|
|
144
130
|
byFamily.set(c.family, c);
|
|
145
131
|
}
|
|
146
132
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
133
|
+
return [...byFamily.values()].sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
134
|
+
}
|
|
135
|
+
function buildPlanPart(workspaceDir) {
|
|
136
|
+
// The first family whose envelope IS a plan is "the newest plan family".
|
|
137
|
+
const familiesNewestFirst = newestPlanFamilyFiles(workspaceDir);
|
|
138
|
+
if (familiesNewestFirst.length === 0)
|
|
139
|
+
return null;
|
|
150
140
|
for (const cand of familiesNewestFirst) {
|
|
151
141
|
let env;
|
|
152
142
|
try {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// GENERATED FILE — bundled skills for local mode.
|
|
2
2
|
// Source: https://manifest.cspeach.dev/v1.json
|
|
3
|
-
// Generated: 2026-10-
|
|
3
|
+
// Generated: 2026-10-05T13:05:26.367Z
|
|
4
4
|
// Manifest signature verified at build time.
|
|
5
5
|
export const BUNDLED_SKILLS = {
|
|
6
6
|
"abap-app": {
|
|
@@ -371,6 +371,43 @@ export function isDenylistedPath(realAbsPath, root, platform = process.platform)
|
|
|
371
371
|
}
|
|
372
372
|
return { blocked: false, relFromRoot };
|
|
373
373
|
}
|
|
374
|
+
/**
|
|
375
|
+
* READ-ONLY carve-out (2026-10-04, plan sources): the project's own saved
|
|
376
|
+
* results — `.cspeach/<name>.cspeach.json` (spec-gap, plan, design, … envelopes,
|
|
377
|
+
* directly in `.cspeach/`) and `.cspeach/lane/<name>.json` (lane decisions) —
|
|
378
|
+
* are project data the developer saved, not secrets. file_read and glob may
|
|
379
|
+
* READ / LIST them; file_write / file_edit never consult this, so the
|
|
380
|
+
* envelopes stay CLI-owned for writes (audits, statuses and `from` refs can
|
|
381
|
+
* never be forged through a file tool).
|
|
382
|
+
*
|
|
383
|
+
* Narrow by construction — true only when ALL hold:
|
|
384
|
+
* - the path is NOT under the CLI home (`~/.cspeach`: auth, config, sessions),
|
|
385
|
+
* whatever the root (a project run from the home folder gets nothing);
|
|
386
|
+
* - it sits inside the spelled root, at exactly one of the two shapes above
|
|
387
|
+
* (no deeper nesting, no other `.cspeach` file such as conventions.toml);
|
|
388
|
+
* - no NTFS alias spelling: no ':' segment, no trailing dot/space segment,
|
|
389
|
+
* and no '~' segment (a possible 8.3 short name — fail closed);
|
|
390
|
+
* - the basename is not in the `.env` family.
|
|
391
|
+
* `.git` / `.cspeach-design` are never matched (the shapes require `.cspeach/`).
|
|
392
|
+
*/
|
|
393
|
+
export function isReadOnlyProjectFile(realAbsPath, root, platform = process.platform) {
|
|
394
|
+
const isWin = platform === 'win32';
|
|
395
|
+
const native = platform === process.platform;
|
|
396
|
+
const p = isWin ? path.win32 : path.posix;
|
|
397
|
+
if (isUnderCliHome(realAbsPath, p, native))
|
|
398
|
+
return false;
|
|
399
|
+
const rel = p.relative(p.resolve(root), realAbsPath).replace(/\\/g, '/');
|
|
400
|
+
if (!relIsInside(p, rel) || rel === '')
|
|
401
|
+
return false;
|
|
402
|
+
const segments = rel.split('/');
|
|
403
|
+
if (segments.some((seg) => seg.includes(':') || seg.includes('~') || ntfsCanonicalSegment(seg) !== seg))
|
|
404
|
+
return false;
|
|
405
|
+
if (BLOCKED_BASENAME_RE.test(segments[segments.length - 1]))
|
|
406
|
+
return false;
|
|
407
|
+
const relLower = rel.toLowerCase();
|
|
408
|
+
return /^\.cspeach\/[^/]+\.cspeach\.json$/.test(relLower)
|
|
409
|
+
|| /^\.cspeach\/lane\/[^/]+\.json$/.test(relLower);
|
|
410
|
+
}
|
|
374
411
|
export class PathOutsideRootError extends Error {
|
|
375
412
|
constructor(givenPath, root) {
|
|
376
413
|
super(`Path "${givenPath}" resolves outside the project root "${root}"`);
|
package/dist/tools/approval.js
CHANGED
|
@@ -17,7 +17,9 @@ function noteApprovedTransport(t) {
|
|
|
17
17
|
import { mintChangeApproval } from '../approvals/jwt.js';
|
|
18
18
|
import { canonicalApprovalObject } from '../approvals/canonical.js';
|
|
19
19
|
import { renderPlanGate, renderPerChangeApprovalV3 } from '../approvals/render.js';
|
|
20
|
-
import { loadConfig } from '../config/loader.js';
|
|
20
|
+
import { loadConfig, effectiveApprovals } from '../config/loader.js';
|
|
21
|
+
import { getPlanGrant, grantCovers, noteGrantUse, noteApprovalsSetting, grantClaimsFor } from '../approvals/plan-grant.js';
|
|
22
|
+
import { normalizeObjectType } from '../projects/plan-objects.js';
|
|
21
23
|
import { maybeShowAutoApproveNag } from '../approvals/approval-prompt.js';
|
|
22
24
|
import { renderAdvisoryProposal, } from '../approvals/advisory-render.js';
|
|
23
25
|
import { promptAdvisory } from '../approvals/advisory-prompt.js';
|
|
@@ -161,7 +163,9 @@ registerTool({
|
|
|
161
163
|
// sanctioned rule-4a self-create. The very act of routing through
|
|
162
164
|
// request_approval IS the user-consent gate, so it must NOT be denied here
|
|
163
165
|
// — fall through to the normal approval flow (which prompts the user, whose
|
|
164
|
-
// approval is the 4a consent
|
|
166
|
+
// approval is the 4a consent; or, for the ONE new transport of a plan that
|
|
167
|
+
// had none, the plan object-list grant, whose "a new one for this build"
|
|
168
|
+
// the user already approved). Guarded-mode clients could otherwise never
|
|
165
169
|
// run 4a: this backstop blocks it BEFORE the audit even runs. TIGHT: the
|
|
166
170
|
// exception fires only when EVERY change is infra; a deliverable
|
|
167
171
|
// (CLAS/DDLS/…) or a mixed set still denies (correct out-of-lane B3).
|
|
@@ -202,6 +206,48 @@ registerTool({
|
|
|
202
206
|
const changes = args.changes;
|
|
203
207
|
const declared = args.risk;
|
|
204
208
|
const eff = effectiveRisk(declared, changes, riskCtx);
|
|
209
|
+
// 2026-10-04 (continuous run, spec §3.2) — the plan object-list grant. The
|
|
210
|
+
// developer approved these exact objects on "Build this now?" (or
|
|
211
|
+
// "Continue"); a request whose EVERY change is on that list is approved by
|
|
212
|
+
// that human answer — not an auto-approval, so it applies in guarded runs.
|
|
213
|
+
// Anything else (off the list, another package or transport, a delete, a
|
|
214
|
+
// release, a deploy) falls through to the cards below. The advisory clamp
|
|
215
|
+
// above already returned for advisory/production; approvals = per-write
|
|
216
|
+
// turns this off; a headless run never uses it.
|
|
217
|
+
// A change that is really high risk (declared high, or an object whose last
|
|
218
|
+
// syntax check found errors) always shows its card, listed or not. The
|
|
219
|
+
// auto_approve = never setting also lifts every request to high, but that
|
|
220
|
+
// is a setting, not a risk (see shownRiskReasons), so it is left out here.
|
|
221
|
+
// The approvals setting is recorded for Rule 8's batch card as well.
|
|
222
|
+
const grant = getPlanGrant();
|
|
223
|
+
const approvalsSetting = grant ? effectiveApprovals(cfg) : null;
|
|
224
|
+
if (approvalsSetting)
|
|
225
|
+
noteApprovalsSetting(approvalsSetting);
|
|
226
|
+
if (grant && !isHeadless() && approvalsSetting === 'plan'
|
|
227
|
+
&& effectiveRisk(declared, changes, { ...riskCtx, autoApprovePolicy: 'medium' }).level !== 'high'
|
|
228
|
+
&& ctx.session?.id === grant.sessionId
|
|
229
|
+
&& grantCovers(grant, changes, args.transport)) {
|
|
230
|
+
noteGrantUse(changes);
|
|
231
|
+
const ids = [];
|
|
232
|
+
for (const c of changes) {
|
|
233
|
+
// The grant's one new transport: its id creates ONE transport, never more.
|
|
234
|
+
const oneShot = c.op === 'create' && normalizeObjectType(c.type ?? '') === 'CTS';
|
|
235
|
+
// Grant claims: the write tools hold the id to the listed type,
|
|
236
|
+
// package and bound transport.
|
|
237
|
+
ids.push(await mintChangeApproval(c, ctx.session.id, { grant: grantClaimsFor(c), ...(oneShot ? { oneShot: true } : {}) }));
|
|
238
|
+
}
|
|
239
|
+
markPlanGateApproved();
|
|
240
|
+
noteApprovedTransport(args.transport);
|
|
241
|
+
return {
|
|
242
|
+
content: JSON.stringify({
|
|
243
|
+
approved: true,
|
|
244
|
+
approved_by: 'the plan object list',
|
|
245
|
+
approval_ids: ids,
|
|
246
|
+
effective_risk: eff.level,
|
|
247
|
+
raised_by: eff.raisedBy.map(riskReasonText),
|
|
248
|
+
}),
|
|
249
|
+
};
|
|
250
|
+
}
|
|
205
251
|
// Transport shown on the approval surfaces — resolved PER OP to mirror
|
|
206
252
|
// what the write tools will actually do (I2, 2026-07-05 review):
|
|
207
253
|
// create-family tools fall back to the session transport, so the box may
|
|
@@ -33,9 +33,10 @@
|
|
|
33
33
|
*/
|
|
34
34
|
import { registerTool } from './index.js';
|
|
35
35
|
import { isPlanResumeCommand } from '../commands/plan-resume.js';
|
|
36
|
+
export const PLAN_RESUME_DISPATCH_REASON = 'Do not dispatch /abap-plan --resume. When your plan manifest is saved, CSPeach runs the next step itself with the exact saved file. Simply end the turn now.';
|
|
36
37
|
registerTool({
|
|
37
38
|
name: 'dispatch_skill',
|
|
38
|
-
description: 'Queue the next user submission to a slash command the user already explicitly approved via an inquirer picker. Use this in place of telling the user "Type X at the next prompt" — re-typing what they already picked is friction. Only call this AFTER an explicit user consent (ask_question pick, picker confirmation, etc.); never from prose or a one-line suggestion. The command must start with a known slash route (e.g. "/abap-rap --from @<file>"). NEVER dispatch "/abap-plan --resume" — plan continuation is
|
|
39
|
+
description: 'Queue the next user submission to a slash command the user already explicitly approved via an inquirer picker. Use this in place of telling the user "Type X at the next prompt" — re-typing what they already picked is friction. Only call this AFTER an explicit user consent (ask_question pick, picker confirmation, etc.); never from prose or a one-line suggestion. The command must start with a known slash route (e.g. "/abap-rap --from @<file>"). NEVER dispatch "/abap-plan --resume" — plan continuation is owned by CSPeach: when the manifest is saved, CSPeach runs the next step itself. After this tool returns, end the turn — do not also print a continuation hint.',
|
|
39
40
|
isMutating: false,
|
|
40
41
|
input_schema: {
|
|
41
42
|
type: 'object',
|
|
@@ -75,7 +76,7 @@ registerTool({
|
|
|
75
76
|
is_error: true,
|
|
76
77
|
content: JSON.stringify({
|
|
77
78
|
error: 'PLAN_RESUME_HARNESS_OWNED',
|
|
78
|
-
reason:
|
|
79
|
+
reason: PLAN_RESUME_DISPATCH_REASON,
|
|
79
80
|
}),
|
|
80
81
|
};
|
|
81
82
|
}
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { promises as fs } from 'node:fs';
|
|
12
12
|
import { registerTool } from '../index.js';
|
|
13
|
-
import { resolveSafePath, assertRealPathContained, PathOutsideRootError, DENYLIST_DESCRIPTION, isDenylistedPath } from '../_filesystem-shared.js';
|
|
13
|
+
import { resolveSafePath, assertRealPathContained, PathOutsideRootError, DENYLIST_DESCRIPTION, isDenylistedPath, isReadOnlyProjectFile } from '../_filesystem-shared.js';
|
|
14
14
|
import { spillToolResult, isUnderSpillRoot } from '../result-spill.js';
|
|
15
15
|
import { basename } from 'node:path';
|
|
16
16
|
const MAX_LINES = 2000;
|
|
@@ -45,9 +45,11 @@ export async function fileReadHandler(args, ctx) {
|
|
|
45
45
|
}
|
|
46
46
|
return { content: `error: ${err instanceof Error ? err.message : String(err)}`, is_error: true };
|
|
47
47
|
}
|
|
48
|
-
// Denylist check: refuse sensitive in-root paths
|
|
48
|
+
// Denylist check: refuse sensitive in-root paths. The project's own saved
|
|
49
|
+
// results (.cspeach/*.cspeach.json, .cspeach/lane/*.json) are readable —
|
|
50
|
+
// isReadOnlyProjectFile; the write tools never get this carve-out.
|
|
49
51
|
const denylist = isDenylistedPath(realAbs, ctx.cwd);
|
|
50
|
-
if (denylist.blocked) {
|
|
52
|
+
if (denylist.blocked && !isReadOnlyProjectFile(realAbs, ctx.cwd)) {
|
|
51
53
|
return {
|
|
52
54
|
content: `error: refusing to read sensitive path "${denylist.relFromRoot}" (denylist: ${DENYLIST_DESCRIPTION})`,
|
|
53
55
|
is_error: true,
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
import { promises as fs } from 'node:fs';
|
|
20
20
|
import * as path from 'node:path';
|
|
21
21
|
import { registerTool } from '../index.js';
|
|
22
|
-
import { resolveSafePath, assertRealPathContained, PathOutsideRootError, isDenylistedPath, DENYLIST_DESCRIPTION, } from '../_filesystem-shared.js';
|
|
22
|
+
import { resolveSafePath, assertRealPathContained, PathOutsideRootError, isDenylistedPath, isReadOnlyProjectFile, DENYLIST_DESCRIPTION, } from '../_filesystem-shared.js';
|
|
23
23
|
const MAX_RESULTS = 500;
|
|
24
24
|
const MAX_FILES_TRAVERSED = 10_000;
|
|
25
25
|
const ALWAYS_SKIPPED_DIRS = new Set([
|
|
@@ -70,6 +70,16 @@ export function globToRegex(pattern) {
|
|
|
70
70
|
out += '$';
|
|
71
71
|
return new RegExp(out);
|
|
72
72
|
}
|
|
73
|
+
/** True when the pattern explicitly asks for the project's saved results. */
|
|
74
|
+
export function isCspeachPattern(pattern) {
|
|
75
|
+
const p = pattern.replace(/\\/g, '/').replace(/^\.\//, '').toLowerCase();
|
|
76
|
+
return p.startsWith('.cspeach/') || p.includes('.cspeach.json');
|
|
77
|
+
}
|
|
78
|
+
/** True for a relPath inside the root-level `.cspeach/` folder. */
|
|
79
|
+
function inCspeach(relPath) {
|
|
80
|
+
const l = relPath.toLowerCase();
|
|
81
|
+
return l === '.cspeach' || l.startsWith('.cspeach/');
|
|
82
|
+
}
|
|
73
83
|
export async function globHandler(args, ctx) {
|
|
74
84
|
// Validate args
|
|
75
85
|
if (!args.pattern || typeof args.pattern !== 'string') {
|
|
@@ -97,6 +107,12 @@ export async function globHandler(args, ctx) {
|
|
|
97
107
|
return { content: `error: search root "${args.cwd ?? '.'}" does not exist`, is_error: true };
|
|
98
108
|
}
|
|
99
109
|
const regex = globToRegex(args.pattern);
|
|
110
|
+
// 2026-10-04 (plan sources) — the project's saved results live in
|
|
111
|
+
// `.cspeach/` (skipped by default: noise + CLI-owned). A pattern that
|
|
112
|
+
// names them (`.cspeach/...` or `*.cspeach.json`) walks the ROOT-level
|
|
113
|
+
// `.cspeach/` folder; each file there is listed only when it is readable
|
|
114
|
+
// (isReadOnlyProjectFile, or a carved-out work file).
|
|
115
|
+
const wantsCspeach = isCspeachPattern(args.pattern);
|
|
100
116
|
const collected = [];
|
|
101
117
|
let budget = MAX_FILES_TRAVERSED;
|
|
102
118
|
async function walk(dir, relPrefix) {
|
|
@@ -125,7 +141,10 @@ export async function globHandler(args, ctx) {
|
|
|
125
141
|
continue;
|
|
126
142
|
}
|
|
127
143
|
if (ent.isDirectory()) {
|
|
128
|
-
|
|
144
|
+
const intoCspeach = wantsCspeach && dir === realRoot && ent.name.toLowerCase() === '.cspeach';
|
|
145
|
+
// Inside the walked `.cspeach/` every subfolder is entered (lane/, the
|
|
146
|
+
// work subdirs); the per-file read check decides what is listed.
|
|
147
|
+
if (ALWAYS_SKIPPED_DIRS.has(ent.name) && !intoCspeach && !inCspeach(relPrefix))
|
|
129
148
|
continue;
|
|
130
149
|
await walk(path.join(dir, ent.name), relPath);
|
|
131
150
|
}
|
|
@@ -136,7 +155,7 @@ export async function globHandler(args, ctx) {
|
|
|
136
155
|
// Denylist check on the matched path
|
|
137
156
|
const abs = path.join(dir, ent.name);
|
|
138
157
|
const denylist = isDenylistedPath(abs, ctx.cwd);
|
|
139
|
-
if (denylist.blocked)
|
|
158
|
+
if (denylist.blocked && !isReadOnlyProjectFile(abs, ctx.cwd))
|
|
140
159
|
continue;
|
|
141
160
|
let mtime = 0;
|
|
142
161
|
try {
|
|
@@ -178,7 +197,11 @@ export async function globHandler(args, ctx) {
|
|
|
178
197
|
collected.sort((a, b) => b.mtime - a.mtime);
|
|
179
198
|
const sample = collected.slice(0, MAX_RESULTS).map((f) => f.relPath);
|
|
180
199
|
if (sample.length === 0) {
|
|
181
|
-
|
|
200
|
+
// A search for saved results that did not name `.cspeach/` gets the hint.
|
|
201
|
+
const hint = !wantsCspeach && /\.json$/i.test(args.pattern)
|
|
202
|
+
? ' (saved CSPeach results live in .cspeach/ — search there with pattern ".cspeach/*.cspeach.json")'
|
|
203
|
+
: '';
|
|
204
|
+
return { content: `no files match "${args.pattern}"${hint}` };
|
|
182
205
|
}
|
|
183
206
|
const truncated = collected.length > MAX_RESULTS;
|
|
184
207
|
const trailer = truncated
|
|
@@ -774,6 +774,12 @@ export async function fioriDraftSmokeHandler(args, ctx, deps) {
|
|
|
774
774
|
if (!verify.ok) {
|
|
775
775
|
return { content: `error: APPROVAL_INVALID: ${verify.reason} — ${howToApprove}.`, is_error: true };
|
|
776
776
|
}
|
|
777
|
+
// 2026-10-04 — the id must be one minted for this data run (type DATA), not
|
|
778
|
+
// one for the service binding object itself (e.g. a plan-list SRVB change):
|
|
779
|
+
// the smoke writes live rows, so the developer must have approved exactly that.
|
|
780
|
+
if (String(verify.payload.type ?? '').trim().toUpperCase() !== 'DATA') {
|
|
781
|
+
return { content: `error: APPROVAL_INVALID: type_mismatch — ${howToApprove}.`, is_error: true };
|
|
782
|
+
}
|
|
777
783
|
if (draftSmokeApprovalsUsed.has(verify.payload.nonce)) {
|
|
778
784
|
return {
|
|
779
785
|
content: `error: this approval_id already ran one draft smoke — each run needs its own approval; ${howToApprove}.`,
|
|
@@ -306,7 +306,7 @@ registerTool({
|
|
|
306
306
|
name: 'app_lane_resolve',
|
|
307
307
|
description: 'App lane routing for /abap-app. Pass the spec file path and the signal sheet: for each fixed key a value and, ' +
|
|
308
308
|
'when the value is true / a service name, an evidence quote copied word for word from the spec (max 300 chars). ' +
|
|
309
|
-
'
|
|
309
|
+
'CSPeach checks the quotes, reads the system facts, the project pin and the standards file, picks the lane, ' +
|
|
310
310
|
'shows the lane card, saves the decision file and queues the plan. Never guess a lane yourself. ' +
|
|
311
311
|
'Keys: app_requested, ui_only_service (string|null), report_only, analytical, transactional, custom_ui, ' +
|
|
312
312
|
'cap_external_users, cap_multi_system, cap_own_release_cycle, cap_ui_on_released_api.',
|
package/dist/tools/sap-read.js
CHANGED
|
@@ -258,11 +258,33 @@ registerTool({
|
|
|
258
258
|
// -----------------------------------------------------------------------
|
|
259
259
|
// 5. sap_sql_query
|
|
260
260
|
// -----------------------------------------------------------------------
|
|
261
|
+
/**
|
|
262
|
+
* 2026-10-04 (live run, S/4HANA 2023): a design phase guessed
|
|
263
|
+
* `SELECT object_type, object_key … FROM ars_w_api_state` → SAP HTTP 400
|
|
264
|
+
* "Unknown column name OBJECT_KEY", then retried. The columns below are the
|
|
265
|
+
* ones SAP returned for `SELECT * FROM ars_w_api_state` in that run
|
|
266
|
+
* (I_OPERATIONALACCTGDOCITEM: OBJECT_ID = OBJECT_NAME, OBJECT_TYPE DDLS,
|
|
267
|
+
* SUB_OBJECT_TYPE CDS_STOB, COMPATIBILITY_CONTRACT C1, RELEASE_STATE RELEASED).
|
|
268
|
+
*/
|
|
269
|
+
export const ARS_COLUMNS_HINT = 'Release state of an SAP object (C1/C2): SELECT object_id, object_type, compatibility_contract, release_state '
|
|
270
|
+
+ 'FROM ars_w_api_state WHERE object_id LIKE \'I_JOURNALENTRY%\' — columns are OBJECT_ID, OBJECT_TYPE (DDLS for CDS views, '
|
|
271
|
+
+ 'CLAS, BDEF, …), OBJECT_NAME, SUB_OBJECT_TYPE, SUB_OBJECT_NAME, COMPATIBILITY_CONTRACT, RELEASE_STATE, '
|
|
272
|
+
+ 'SUCCESSOR_OBJECT_TYPE, SUCCESSOR_OBJECT_NAME (there is no OBJECT_KEY).';
|
|
273
|
+
/** The hint for a failed ARS_* query that named an unknown column, else undefined. */
|
|
274
|
+
export function arsColumnErrorHint(query, err) {
|
|
275
|
+
if (typeof query !== 'string' || !/\bars_(?:w_)?api_\w+/i.test(query))
|
|
276
|
+
return undefined;
|
|
277
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
278
|
+
if (!/unknown column/i.test(msg))
|
|
279
|
+
return undefined;
|
|
280
|
+
return `${msg}\n${ARS_COLUMNS_HINT}`;
|
|
281
|
+
}
|
|
261
282
|
registerTool({
|
|
262
283
|
name: 'sap_sql_query',
|
|
263
284
|
description: 'Execute a read-only SQL SELECT against the SAP database via ADT Data Preview. Security-sensitive tables are blocked automatically. '
|
|
264
285
|
+ 'Dialect limits (violations return an opaque "SAP HTTP 400: sqlQuery"): no IN-lists and at most one LIKE per WHERE clause '
|
|
265
|
-
+ '(multi-OR LIKE chains are rejected) — use a single LIKE or split into separate queries.'
|
|
286
|
+
+ '(multi-OR LIKE chains are rejected) — use a single LIKE or split into separate queries. '
|
|
287
|
+
+ ARS_COLUMNS_HINT,
|
|
266
288
|
isMutating: false,
|
|
267
289
|
requiresSap: true,
|
|
268
290
|
input_schema: {
|
|
@@ -274,7 +296,18 @@ registerTool({
|
|
|
274
296
|
required: ['query'],
|
|
275
297
|
},
|
|
276
298
|
handler: async (args, ctx) => {
|
|
277
|
-
|
|
299
|
+
let result;
|
|
300
|
+
try {
|
|
301
|
+
result = await ctx.adt.sqlQuery(args.query, args.maxRows ?? 100);
|
|
302
|
+
}
|
|
303
|
+
catch (e) {
|
|
304
|
+
// A wrong ARS_W_API_STATE column costs a failed round trip: answer with
|
|
305
|
+
// the real column list so the retry is right the first time.
|
|
306
|
+
const hint = arsColumnErrorHint(args.query, e);
|
|
307
|
+
if (hint)
|
|
308
|
+
return { content: hint, is_error: true };
|
|
309
|
+
throw e;
|
|
310
|
+
}
|
|
278
311
|
return spillToolResult({ content: formatSqlTsv(result) }, ctx, 'sql', { label: 'query result' });
|
|
279
312
|
},
|
|
280
313
|
});
|
|
@@ -391,7 +424,8 @@ registerTool({
|
|
|
391
424
|
// -----------------------------------------------------------------------
|
|
392
425
|
registerTool({
|
|
393
426
|
name: 'sap_api_state',
|
|
394
|
-
description: 'Check the ABAP Cloud / clean-core release state of SAP objects. Pass comma-separated TYPE:NAME pairs, e.g. "CLAS:CL_GUI_ALV_GRID,FUNC:BAPI_SALESORDER_GETLIST"
|
|
427
|
+
description: 'Check the ABAP Cloud / clean-core release state of SAP objects. Pass comma-separated TYPE:NAME pairs, e.g. "CLAS:CL_GUI_ALV_GRID,FUNC:BAPI_SALESORDER_GETLIST" '
|
|
428
|
+
+ '(CDS views: "DDLS:I_JOURNALENTRY"). Returns RELEASE_STATE only; for the contract (C1/C2) query ARS_W_API_STATE with sap_sql_query.',
|
|
395
429
|
isMutating: false,
|
|
396
430
|
requiresSap: true,
|
|
397
431
|
input_schema: {
|