@cspeach/cli 1.1.24 → 1.1.26
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 +20 -8
- package/dist/agent/tool-dispatch.js +6 -0
- package/dist/auth/auth-file.js +173 -12
- package/dist/classifier/client.js +55 -30
- package/dist/cli.js +11 -0
- package/dist/commands/config-set.js +13 -0
- package/dist/commands/config-show.js +5 -1
- package/dist/commands/cost.js +24 -22
- package/dist/commands/deploy-card.js +135 -0
- package/dist/commands/login.js +31 -13
- package/dist/commands/plan-chain.js +31 -0
- package/dist/commands/plan-continue.js +1 -0
- package/dist/commands/plan-resume.js +49 -2
- package/dist/cost/cost-log.js +76 -4
- package/dist/cost/credits-wire.js +9 -0
- package/dist/cost/credits.js +24 -6
- package/dist/cost/router-spend.js +24 -0
- package/dist/cost/session-cost.js +16 -2
- package/dist/models/server-config.js +21 -1
- package/dist/one-shot.js +12 -2
- package/dist/projects/detail-path.js +117 -0
- package/dist/projects/merge-cca.js +19 -0
- package/dist/projects/merge-upgrade.js +19 -1
- package/dist/projects/plan-lane.js +47 -0
- package/dist/projects/plan-run.js +8 -0
- package/dist/projects/plan-schema.js +2 -0
- package/dist/projects/promote.js +7 -1
- package/dist/projects/save-command.js +3 -2
- package/dist/register/records.js +7 -2
- package/dist/renderer/footer-line.js +10 -4
- package/dist/renderer/status-footer.js +5 -4
- package/dist/renderer/tool-labels.js +2 -1
- package/dist/repl.js +41 -8
- package/dist/router/byok-classifier.js +159 -0
- package/dist/router/classifier.js +130 -6
- package/dist/router/router-core.js +307 -0
- package/dist/router/router-prompt.js +121 -0
- package/dist/router/routing-failure.js +65 -3
- package/dist/skills/bundled-skills.js +45 -45
- package/dist/tools/_filesystem-shared.js +363 -33
- package/dist/tools/cap/metadata-import.js +10 -4
- package/dist/tools/filesystem/file-write.js +12 -39
- package/dist/tools/filesystem/glob.js +25 -2
- package/dist/tools/filesystem/grep.js +25 -2
- package/dist/tools/fiori/apply.js +30 -21
- package/dist/tools/fiori/commit-gate.js +58 -0
- package/dist/tools/fiori/deploy/existing-bsp.js +67 -0
- package/dist/tools/fiori/deploy/run.js +608 -0
- package/dist/tools/fiori/deploy/scope.js +8 -0
- package/dist/tools/fiori/deploy/yaml-read.js +236 -0
- package/dist/tools/fiori/deploy-config.js +24 -6
- package/dist/tools/fiori/fe-extend.js +6 -1
- package/dist/tools/fiori/fe-scaffold.js +6 -1
- package/dist/tools/fiori/path-params.js +78 -0
- package/dist/tools/fiori/scaffold.js +2 -1
- package/dist/tools/fiori/tools.js +170 -19
- package/dist/tools/local-build.js +3 -0
- package/dist/tools/project/_merge-shared.js +36 -1
- package/dist/tools/project/cca_merge.js +4 -0
- package/dist/tools/project/upgrade_merge_progress.js +21 -10
- package/dist/tools/subagent/read_agent.js +4 -2
- package/dist/tools/tool-loading.js +1 -1
- package/package.json +1 -1
package/dist/one-shot.js
CHANGED
|
@@ -13,11 +13,12 @@ import { makeSessionId, saveSession } from './session/store.js';
|
|
|
13
13
|
import { newSession } from './session/schema.js';
|
|
14
14
|
import { resolveModelRole } from './models/resolve.js';
|
|
15
15
|
import { loadServerModelConfig, loadCustomerModelConfig, fetchToolLoadingMode } from './models/server-config.js';
|
|
16
|
+
import { prefetchRouterPrompt } from './router/router-prompt.js';
|
|
16
17
|
import { createProviderForMode } from './agent/providers/factory.js';
|
|
17
18
|
import { assertModeLicensed, CspeachLicenseError } from './agent/providers/license-gate.js';
|
|
18
19
|
import { runTurn } from './agent/loop.js';
|
|
19
20
|
import { classifyPrompt, parseShortcut, rejectUnknownSkill } from './router/classifier.js';
|
|
20
|
-
import { routingFailureFromResult, routingFailureOneShotLine } from './router/routing-failure.js';
|
|
21
|
+
import { routingFailureFromResult, routingFailureOneShotLine, routingFallbackFromError } from './router/routing-failure.js';
|
|
21
22
|
import { startThinkingHeartbeat, ROUTING_HEARTBEAT_THRESHOLDS } from './renderer/thinking-heartbeat.js';
|
|
22
23
|
import { setRenderingOverride, setHeadlessOverride, setOneShotOverride } from './renderer/tty.js';
|
|
23
24
|
import { resolveRenderingMode } from './renderer/rendering-mode.js';
|
|
@@ -96,6 +97,9 @@ export async function runOneShot(prompt, opts) {
|
|
|
96
97
|
loadCustomerModelConfig(cfg, async () => (await (await getStore()).get()) ?? '', { timeoutMs: 1500 }),
|
|
97
98
|
// Task 16 (F13) — BYOK reads only the served tool_loading (signed in only).
|
|
98
99
|
fetchToolLoadingMode(cfg, async () => (await (await getStore()).get()) ?? '', { timeoutMs: 1500 }),
|
|
100
|
+
// 2026-10-02 — BYOK / ai-hub route on the laptop: prefetch the signed
|
|
101
|
+
// router prompt (public manifest, no prompt data) under the same cap.
|
|
102
|
+
cfg.llm?.mode === 'byok' || cfg.llm?.mode === 'ai-hub' ? prefetchRouterPrompt({ timeoutMs: 1500 }) : Promise.resolve(),
|
|
99
103
|
]);
|
|
100
104
|
// B5 (2026-06-11) — one-shot is headless by design: a single turn, no
|
|
101
105
|
// REPL, often piped (CI / battery). Any stdin prompt would hang forever
|
|
@@ -224,7 +228,13 @@ export async function runOneShot(prompt, opts) {
|
|
|
224
228
|
console.error('No API key configured. Run `cspeach` (interactive) first to set one.');
|
|
225
229
|
return 1;
|
|
226
230
|
}
|
|
227
|
-
|
|
231
|
+
// Live fix 2026-10-02 — a classify failure (proxy HTTP 500, an
|
|
232
|
+
// unreadable body) escaped as "cspeach: unhandled error". Continue on
|
|
233
|
+
// the bundled local rules instead; the offline line below says so.
|
|
234
|
+
const fallback = routingFallbackFromError(err, body);
|
|
235
|
+
if (!fallback)
|
|
236
|
+
throw err;
|
|
237
|
+
c = fallback;
|
|
228
238
|
}
|
|
229
239
|
finally {
|
|
230
240
|
routingBeat.stop();
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// cspeach-cli/src/projects/detail-path.ts
|
|
2
|
+
/**
|
|
3
|
+
* SEC (review round 2): `content.detailPath` inside a saved envelope is DATA
|
|
4
|
+
* the model can write — never a trusted path. Every reader that turns it into
|
|
5
|
+
* a file access (upgrade merge baseline hash, the register's CCA objects.json,
|
|
6
|
+
* promote's file_read hint) goes through here:
|
|
7
|
+
*
|
|
8
|
+
* - safeDetailRel: the string must be a plain project-relative path under a
|
|
9
|
+
* skill working folder — `.cspeach/<domain>/…` (or the legacy
|
|
10
|
+
* `.abapforge/<domain>/…`). No absolute paths, drive letters, `..`, `:`
|
|
11
|
+
* streams, control characters, trailing dots/spaces or device names.
|
|
12
|
+
* - resolveDetailFile(Sync): the REAL file must sit in that working folder
|
|
13
|
+
* under the project root (or the workspace folder) and not be a protected
|
|
14
|
+
* path. Returns null for refused AND missing alike, so callers can give ONE
|
|
15
|
+
* message and never act as an existence oracle.
|
|
16
|
+
*/
|
|
17
|
+
import * as path from 'node:path';
|
|
18
|
+
import { realpathSync, statSync } from 'node:fs';
|
|
19
|
+
import { CSPEACH_WORK_SUBDIRS, isDenylistedPath, isWindowsReservedName } from '../tools/_filesystem-shared.js';
|
|
20
|
+
import { workspaceFolderFor } from '../lane/roots.js';
|
|
21
|
+
/** Working-folder roots a detail path may start with (current, legacy). */
|
|
22
|
+
const DETAIL_ROOTS = ['.cspeach', '.abapforge'];
|
|
23
|
+
/**
|
|
24
|
+
* The normalised '/'-separated project-relative detail path, or null when the
|
|
25
|
+
* value is not a safe path into `<root>/<domain>/` (any domain when omitted).
|
|
26
|
+
*/
|
|
27
|
+
export function safeDetailRel(detailPath, domain) {
|
|
28
|
+
if (typeof detailPath !== 'string' || detailPath.length === 0 || detailPath.length > 512)
|
|
29
|
+
return null;
|
|
30
|
+
if (/[\u0000-\u001f\u007f]/.test(detailPath))
|
|
31
|
+
return null;
|
|
32
|
+
if (path.posix.isAbsolute(detailPath) || path.win32.isAbsolute(detailPath) || /^[A-Za-z]:/.test(detailPath))
|
|
33
|
+
return null;
|
|
34
|
+
const segs = detailPath.replace(/\\/g, '/').split('/');
|
|
35
|
+
while (segs[0] === '.')
|
|
36
|
+
segs.shift();
|
|
37
|
+
if (segs.length < 3)
|
|
38
|
+
return null;
|
|
39
|
+
for (const seg of segs) {
|
|
40
|
+
if (seg === '' || seg === '.' || seg === '..')
|
|
41
|
+
return null;
|
|
42
|
+
if (seg.includes(':') || /[. ]$/.test(seg) || /[<>"|?*]/.test(seg))
|
|
43
|
+
return null;
|
|
44
|
+
if (isWindowsReservedName(seg))
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
const rootSeg = segs[0].toLowerCase();
|
|
48
|
+
const domainSeg = segs[1].toLowerCase();
|
|
49
|
+
if (!DETAIL_ROOTS.includes(rootSeg))
|
|
50
|
+
return null;
|
|
51
|
+
if (domain ? domainSeg !== domain : !CSPEACH_WORK_SUBDIRS.includes(domainSeg))
|
|
52
|
+
return null;
|
|
53
|
+
return segs.join('/');
|
|
54
|
+
}
|
|
55
|
+
function realOrNull(p) {
|
|
56
|
+
try {
|
|
57
|
+
return realpathSync.native(p);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The real path of the first candidate that exists, is a regular file, stays
|
|
65
|
+
* inside `<base>/<first two segments of rel>/` after links are followed, and
|
|
66
|
+
* is not denylisted relative to `cwd`. null otherwise.
|
|
67
|
+
*/
|
|
68
|
+
function firstContained(cands, cwd) {
|
|
69
|
+
for (const { base, rel, keep } of cands) {
|
|
70
|
+
const realBase = realOrNull(path.resolve(base));
|
|
71
|
+
const real = realOrNull(path.join(path.resolve(base), ...rel.split('/')));
|
|
72
|
+
if (!realBase || !real)
|
|
73
|
+
continue;
|
|
74
|
+
const segs = rel.split('/');
|
|
75
|
+
const folder = path.join(realBase, ...segs.slice(0, keep));
|
|
76
|
+
const inner = path.relative(folder, real);
|
|
77
|
+
if (inner === '' || inner.startsWith('..') || path.isAbsolute(inner))
|
|
78
|
+
continue;
|
|
79
|
+
if (isDenylistedPath(real, cwd).blocked)
|
|
80
|
+
continue;
|
|
81
|
+
try {
|
|
82
|
+
if (!statSync(real).isFile())
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
return real;
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
/** Sync resolution under the project root only (callers without a workspace). */
|
|
93
|
+
export function resolveDetailFileSync(detailPath, domain, projectRoot) {
|
|
94
|
+
const rel = safeDetailRel(detailPath, domain);
|
|
95
|
+
if (!rel)
|
|
96
|
+
return null;
|
|
97
|
+
return firstContained([{ base: projectRoot, rel, keep: 2 }], projectRoot);
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Resolution under the project root (cwd) and the resolved workspace folder
|
|
101
|
+
* (which may sit elsewhere: a git-root `.cspeach/`, a configured folder). For
|
|
102
|
+
* the workspace, a `.cspeach/<domain>/x` path maps to `<workspace>/<domain>/x`.
|
|
103
|
+
*/
|
|
104
|
+
export async function resolveDetailFile(detailPath, domain, cwd) {
|
|
105
|
+
const rel = safeDetailRel(detailPath, domain);
|
|
106
|
+
if (!rel)
|
|
107
|
+
return null;
|
|
108
|
+
const cands = [{ base: cwd, rel, keep: 2 }];
|
|
109
|
+
const segs = rel.split('/');
|
|
110
|
+
if (segs[0].toLowerCase() === '.cspeach') {
|
|
111
|
+
try {
|
|
112
|
+
cands.push({ base: await workspaceFolderFor(cwd), rel: segs.slice(1).join('/'), keep: 1 });
|
|
113
|
+
}
|
|
114
|
+
catch { /* no workspace — project root only */ }
|
|
115
|
+
}
|
|
116
|
+
return firstContained(cands, cwd);
|
|
117
|
+
}
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
* file must be consulted for full coverage
|
|
28
28
|
*/
|
|
29
29
|
import { OUTPUT_ROOT } from './output-paths.js';
|
|
30
|
+
import { isWindowsReservedName } from '../tools/_filesystem-shared.js';
|
|
30
31
|
/** Higher = more restrictive. Conflict resolution picks the max. */
|
|
31
32
|
const RESTRICTIVENESS = {
|
|
32
33
|
retire: 3,
|
|
@@ -94,6 +95,19 @@ export function scopePatternsIntersect(a, b) {
|
|
|
94
95
|
function todayUtc() {
|
|
95
96
|
return new Date().toISOString().slice(0, 10);
|
|
96
97
|
}
|
|
98
|
+
/** A folder/file-name slug: `^[A-Za-z0-9._-]+$`, no `..`, not `.`, no device name. */
|
|
99
|
+
export function assertMergeSlug(label, value) {
|
|
100
|
+
const ok = typeof value === 'string'
|
|
101
|
+
&& value.length > 0 && value.length <= 100
|
|
102
|
+
&& /^[A-Za-z0-9._-]+$/.test(value)
|
|
103
|
+
&& !value.includes('..') && value !== '.'
|
|
104
|
+
&& !/[. ]$/.test(value)
|
|
105
|
+
&& !isWindowsReservedName(value);
|
|
106
|
+
if (!ok) {
|
|
107
|
+
throw new Error(`${label} ${JSON.stringify(value)} cannot name a folder — use letters, digits, ".", "_" and "-" only `
|
|
108
|
+
+ '(no "..", no device names such as CON or NUL).');
|
|
109
|
+
}
|
|
110
|
+
}
|
|
97
111
|
export function mergeCcaAssessments(inputs, opts = {}) {
|
|
98
112
|
if (inputs.length < 2) {
|
|
99
113
|
throw new Error(`cca merge requires at least 2 inputs (got ${inputs.length}) — a single assessment needs no merge`);
|
|
@@ -214,6 +228,11 @@ export function mergeCcaAssessments(inputs, opts = {}) {
|
|
|
214
228
|
// overlap is already warned about above).
|
|
215
229
|
const uncategorized = inputs.reduce((sum, inp) => sum + (inp.content.summary.uncategorized || 0), 0);
|
|
216
230
|
const date = opts.mergedDate ?? todayUtc();
|
|
231
|
+
// SEC (review round 2): projectId comes from INSIDE an envelope (model-
|
|
232
|
+
// writable) and becomes a folder name under .cspeach/cca/projects/. Only a
|
|
233
|
+
// plain slug may do that — never `../`, a drive, a stream or a device name.
|
|
234
|
+
assertMergeSlug('projectId', first.content.projectId);
|
|
235
|
+
assertMergeSlug('merged date', date);
|
|
217
236
|
const mergedProjectId = `${first.content.projectId}_merged_${date}`;
|
|
218
237
|
const scopes = [];
|
|
219
238
|
for (const inp of inputs) {
|
|
@@ -17,6 +17,22 @@
|
|
|
17
17
|
* - counts RECOMPUTED from merged rows — never trusted from inputs
|
|
18
18
|
*/
|
|
19
19
|
import { OUTPUT_ROOT } from './output-paths.js';
|
|
20
|
+
import { isWindowsReservedName } from '../tools/_filesystem-shared.js';
|
|
21
|
+
/**
|
|
22
|
+
* SEC (review round 2): the merged detail FILE NAME is built from the last
|
|
23
|
+
* segment of the baseline path, which comes from inside an envelope (model-
|
|
24
|
+
* writable). On Windows `base.json:ads` would write an NTFS stream and `CON`
|
|
25
|
+
* opens the console. Keep only `[A-Za-z0-9._-]`, drop a stream suffix, never
|
|
26
|
+
* `..` or a device name; fall back to `baseline`.
|
|
27
|
+
*/
|
|
28
|
+
export function safeStem(baselinePath) {
|
|
29
|
+
const last = (typeof baselinePath === 'string' ? baselinePath : '').split(/[\\/]/).pop() ?? '';
|
|
30
|
+
let stem = last.split(':')[0].replace(/\.json$/i, '');
|
|
31
|
+
stem = stem.replace(/[^A-Za-z0-9._-]/g, '_').replace(/\.{2,}/g, '_').replace(/^[._-]+|[._ -]+$/g, '');
|
|
32
|
+
if (!stem || stem.length > 100 || isWindowsReservedName(stem))
|
|
33
|
+
return 'baseline';
|
|
34
|
+
return stem;
|
|
35
|
+
}
|
|
20
36
|
/** Higher = wins the reconciliation. */
|
|
21
37
|
const STATUS_PRECEDENCE = {
|
|
22
38
|
failed: 3,
|
|
@@ -124,7 +140,9 @@ export function mergeUpgradeProgress(inputs, baseline) {
|
|
|
124
140
|
}
|
|
125
141
|
const baselinePath = baseline.path ?? inputs[0].content.baselineRef.path;
|
|
126
142
|
const date = baseline.mergedDate ?? todayUtc();
|
|
127
|
-
const stem = (baselinePath
|
|
143
|
+
const stem = safeStem(baselinePath);
|
|
144
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(date))
|
|
145
|
+
throw new Error(`merged date ${JSON.stringify(date)} must be YYYY-MM-DD`);
|
|
128
146
|
const merged = {
|
|
129
147
|
detailPath: `${OUTPUT_ROOT}/upgrades/${stem}_progress_merged_${date}.json`,
|
|
130
148
|
baselineRef: { path: baselinePath, sha256: baseline.sha256 },
|
|
@@ -108,6 +108,53 @@ export function checkLaneConsistency(content, rec) {
|
|
|
108
108
|
}
|
|
109
109
|
return null;
|
|
110
110
|
}
|
|
111
|
+
/* ── deploy is a required step of a UI lane (owner, 2026-10-02) ─────────── */
|
|
112
|
+
/**
|
|
113
|
+
* A rap lane with a UI, and the `none` lane (a UI on an already published
|
|
114
|
+
* service), build an app that runs on SAP — its plan must deploy it. cap is
|
|
115
|
+
* out (the BTP deploy is not built); abap-report has no UI.
|
|
116
|
+
*/
|
|
117
|
+
export function laneRequiresDeploy(rec) {
|
|
118
|
+
const { backend, ui } = rec.final;
|
|
119
|
+
return (backend === 'rap' && ui !== 'none') || backend === 'none';
|
|
120
|
+
}
|
|
121
|
+
/** The deploy phase of a UI: ui layer, /abap-fiori-build, id `ui.deploy` (or `<app>.deploy`). */
|
|
122
|
+
export function isDeployPhase(p) {
|
|
123
|
+
return p.layer === 'ui' && p.delegateTo === 'abap-fiori-build' && /(^|\.)deploy$/.test(p.id);
|
|
124
|
+
}
|
|
125
|
+
/** The developer's recorded reason for not deploying (phases[ui.build].ui.deployOptOut), if any. */
|
|
126
|
+
export function deployOptOutOf(phases) {
|
|
127
|
+
for (const p of phases) {
|
|
128
|
+
const why = p.ui?.deployOptOut?.trim();
|
|
129
|
+
if (why)
|
|
130
|
+
return why;
|
|
131
|
+
}
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Refuse a UI-lane plan with no deploy phase (and no recorded opt-out), or
|
|
136
|
+
* whose deploy phase does not wait for a UI build phase. Returns a plain
|
|
137
|
+
* refusal or null. Checked at save; a phase write-back may not remove it.
|
|
138
|
+
*/
|
|
139
|
+
export function checkRequiredDeploy(content, rec) {
|
|
140
|
+
if (!laneRequiresDeploy(rec))
|
|
141
|
+
return null;
|
|
142
|
+
const deploys = content.phases.filter(isDeployPhase);
|
|
143
|
+
if (deploys.length === 0) {
|
|
144
|
+
if (deployOptOutOf(content.phases))
|
|
145
|
+
return null;
|
|
146
|
+
return ('This lane builds an app that runs on SAP, so the plan needs a ui.deploy phase (deploy the app to the ABAP ' +
|
|
147
|
+
'repository, delegateTo abap-fiori-build, layer ui) after ui.build. If the developer does not want it deployed, ' +
|
|
148
|
+
'record their words in phases[ui.build].ui.deployOptOut. Re-run /abap-plan.');
|
|
149
|
+
}
|
|
150
|
+
const builds = new Set(content.phases.filter((p) => p.layer === 'ui' && !isDeployPhase(p)).map((p) => p.id));
|
|
151
|
+
for (const d of deploys) {
|
|
152
|
+
if (!d.entryCriteria.some((id) => builds.has(id))) {
|
|
153
|
+
return `Phase ${cardLine(d.id)} must run after ui.build — list the UI build phase in its entryCriteria. Re-run /abap-plan.`;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
111
158
|
/**
|
|
112
159
|
* Resume / continuation gate: read the plan's lane with its recorded sha,
|
|
113
160
|
* re-validate it and check the stored phases against it. Lane-less plans pass
|
|
@@ -372,6 +372,14 @@ g = getGlyphs()) {
|
|
|
372
372
|
if (deployed?.work?.deployedUrl) {
|
|
373
373
|
lines.push(`App deployed: ${deployed.work.deployedUrl}`);
|
|
374
374
|
}
|
|
375
|
+
else if (phases.some((p) => p.layer === 'ui' && p.delegateTo === 'abap-fiori-build' && p.ui !== undefined)) {
|
|
376
|
+
// 2026-10-02 — deploy is a chained step of a UI plan: finishing without a
|
|
377
|
+
// deployed app is said out loud, never left to silence.
|
|
378
|
+
const optOut = phases.map((p) => p.ui?.deployOptOut?.trim()).find((w) => !!w);
|
|
379
|
+
lines.push(optOut
|
|
380
|
+
? `${g.info} Deploy skipped by the developer: ${sanitizeControlChars(optOut)}`
|
|
381
|
+
: `${g.attention} The app was not deployed to SAP — it runs only in local preview. Deploy it with /abap-fiori-build (Step 10, fiori_deploy) or record why not.`);
|
|
382
|
+
}
|
|
375
383
|
lines.push('Consider /abap-preflight on the produced transport(s) before release.');
|
|
376
384
|
return lines;
|
|
377
385
|
}
|
|
@@ -78,6 +78,8 @@ const planPhaseUiSchema = z.object({
|
|
|
78
78
|
floorplan: z.enum(PLAN_UI_FLOORPLANS).optional(),
|
|
79
79
|
appId: z.string().min(1).optional(),
|
|
80
80
|
appTitle: z.string().min(1).optional(),
|
|
81
|
+
// The developer's own words for not deploying (checkRequiredDeploy, plan-lane.ts).
|
|
82
|
+
deployOptOut: z.string().min(1).optional(),
|
|
81
83
|
});
|
|
82
84
|
/** Keep in sync with PlanAuditState in types.ts. 'warn' is the non-blocking
|
|
83
85
|
* tier (confidence-tiered redesign, 2026-07-08) — persisted literally so a
|
package/dist/projects/promote.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { canonicalSha256 } from './canonicalize.js';
|
|
2
|
+
import { safeDetailRel } from './detail-path.js';
|
|
2
3
|
function itemTextFor(env, itemId) {
|
|
3
4
|
if (env.artefactType === 'spec-gap') {
|
|
4
5
|
for (const s of env.content.sections) {
|
|
@@ -105,7 +106,12 @@ export function buildReviewDecisionsBlock(source) {
|
|
|
105
106
|
* makes readable — so we hand the skill that exact path to `file_read` directly.
|
|
106
107
|
*/
|
|
107
108
|
export function buildDetailPathHint(source) {
|
|
108
|
-
|
|
109
|
+
// SEC (review round 2): detailPath is envelope DATA (model-writable). Only a
|
|
110
|
+
// plain project-relative path into a skill working folder
|
|
111
|
+
// (.cspeach/<cca|upgrades|modernize|tests>/…) is handed to the skill as a
|
|
112
|
+
// file_read target — never an absolute path, `..`, a stream, a protected
|
|
113
|
+
// file, or text carrying a newline into the prompt.
|
|
114
|
+
const detailPath = safeDetailRel(source.content?.detailPath);
|
|
109
115
|
if (!detailPath)
|
|
110
116
|
return null;
|
|
111
117
|
return [
|
|
@@ -18,7 +18,7 @@ import { renderEmailTemplate } from './email-template.js';
|
|
|
18
18
|
import { writePlanHandover } from './handover-md.js';
|
|
19
19
|
import { ensureWorkspace, resolveWorkspacePath } from './workspace.js';
|
|
20
20
|
import { askYesNo, isYes } from './yes-no.js';
|
|
21
|
-
import { checkLaneConsistency, LANE_CHANGED_AT_CREATE, readPlanLane } from './plan-lane.js';
|
|
21
|
+
import { checkLaneConsistency, checkRequiredDeploy, LANE_CHANGED_AT_CREATE, readPlanLane } from './plan-lane.js';
|
|
22
22
|
const SKILL_REGISTRY = {
|
|
23
23
|
'abap-spec-gap': {
|
|
24
24
|
artefactType: 'spec-gap',
|
|
@@ -343,7 +343,8 @@ export async function runSaveCommand(args) {
|
|
|
343
343
|
args.log('[save] The plan was not saved.');
|
|
344
344
|
return null;
|
|
345
345
|
}
|
|
346
|
-
|
|
346
|
+
// 2026-10-02 — a UI lane must deploy (or record the developer's opt-out).
|
|
347
|
+
const conflict = checkLaneConsistency(planExtract.content, lr.rec) ?? checkRequiredDeploy(planExtract.content, lr.rec);
|
|
347
348
|
if (conflict) {
|
|
348
349
|
args.log('');
|
|
349
350
|
args.log(`[save] ${conflict}`);
|
package/dist/register/records.js
CHANGED
|
@@ -12,6 +12,7 @@ import fs from 'node:fs';
|
|
|
12
12
|
import path from 'node:path';
|
|
13
13
|
import { classificationFromCcaDetail, fromCcaDetailKey, normalizeKey, normalizeRuleId, objectKey, } from '@cspeach/register-core';
|
|
14
14
|
import { defaultRand, readBaseline } from './store.js';
|
|
15
|
+
import { safeDetailRel, resolveDetailFileSync } from '../projects/detail-path.js';
|
|
15
16
|
const FIX_STATUSES = new Set(['fixed', 'skipped', 'failed', 'pending']);
|
|
16
17
|
export function writeRecord(joined, recordType, rows, by, opts = {}) {
|
|
17
18
|
const known = new Set(readBaseline(joined.projectDir).map((o) => objectKey(o.type, o.name)));
|
|
@@ -69,8 +70,12 @@ export function writeRecord(joined, recordType, rows, by, opts = {}) {
|
|
|
69
70
|
*/
|
|
70
71
|
export function decisionRowsFromCca(content, projectRoot) {
|
|
71
72
|
const rows = new Map();
|
|
72
|
-
|
|
73
|
-
|
|
73
|
+
// SEC (review round 2): detailPath is envelope DATA. objects.json is read only
|
|
74
|
+
// from beside a safe `.cspeach/cca/…` detail path, as a real file inside the
|
|
75
|
+
// project's cca working folder — never from an arbitrary location.
|
|
76
|
+
const rel = safeDetailRel(content.detailPath, 'cca');
|
|
77
|
+
const detail = rel ? resolveDetailFileSync(`${path.posix.dirname(rel)}/objects.json`, 'cca', projectRoot) : null;
|
|
78
|
+
if (detail) {
|
|
74
79
|
try {
|
|
75
80
|
const objects = JSON.parse(fs.readFileSync(detail, 'utf8'));
|
|
76
81
|
for (const [detailKey, o] of Object.entries(objects ?? {})) {
|
|
@@ -80,18 +80,24 @@ export function formatFooterLine(s, width) {
|
|
|
80
80
|
export function turnSpendText(p) {
|
|
81
81
|
if (creditsActive()) {
|
|
82
82
|
const c = p.credits;
|
|
83
|
-
|
|
83
|
+
// 2026-10-02: the whole user request (every model round), not its last round.
|
|
84
|
+
if (!c || c.chargedThisRequest === null)
|
|
84
85
|
return CREDITS_PENDING;
|
|
85
|
-
return formatCredits(c.
|
|
86
|
+
return formatCredits(c.chargedThisRequest, c.unitLabel);
|
|
86
87
|
}
|
|
87
88
|
return p.costThisTurn != null ? formatCost(p.costThisTurn) : '';
|
|
88
89
|
}
|
|
89
|
-
/**
|
|
90
|
+
/**
|
|
91
|
+
* `/<skill> · <tokens> tokens · <this-turn spend> · <time>` — one dim line, no newline.
|
|
92
|
+
* 2026-10-02 (owner): in credits mode (a managed customer) the token count is
|
|
93
|
+
* dropped — credits only, so nobody works out "credits per token".
|
|
94
|
+
*/
|
|
90
95
|
export function formatTurnReceipt(p) {
|
|
91
96
|
const parts = [];
|
|
92
97
|
if (p.skill && p.skill !== '(none)')
|
|
93
98
|
parts.push(p.skill.startsWith('/') ? p.skill : `/${p.skill}`);
|
|
94
|
-
|
|
99
|
+
if (!creditsActive())
|
|
100
|
+
parts.push(`${p.tokens.toLocaleString('en-US')} tokens`);
|
|
95
101
|
if (p.spend)
|
|
96
102
|
parts.push(p.spend);
|
|
97
103
|
if (p.ms > 0)
|
|
@@ -71,13 +71,14 @@ export function formatStatusFooter(params) {
|
|
|
71
71
|
const shortSession = sessionId.slice(0, 8);
|
|
72
72
|
const skillDisplay = skill.startsWith('/') ? skill : `/${skill}`;
|
|
73
73
|
const turns = `${turnCount} turn${turnCount === 1 ? '' : 's'}`;
|
|
74
|
-
const tokStr = tokens.toLocaleString() + ' tok this turn';
|
|
75
|
-
const cachedStr = cachedTokens > 0 ? `${cachedTokens.toLocaleString()} cached` : '';
|
|
76
|
-
const elapsed = elapsedMs > 0 ? `${(elapsedMs / 1000).toFixed(1)}s` : '';
|
|
77
74
|
// Credits mode: the whole spend story collapses into ONE customer-facing
|
|
78
75
|
// segment (turn · session · balance) in place of the two dollar segments.
|
|
79
76
|
// (Classic receipt only — the Ink 'turn' variant returned above.)
|
|
77
|
+
// 2026-10-02 (owner): and no token/cached counts — credits only.
|
|
80
78
|
const inCredits = creditsActive();
|
|
79
|
+
const tokStr = inCredits ? '' : tokens.toLocaleString() + ' tok this turn';
|
|
80
|
+
const cachedStr = !inCredits && cachedTokens > 0 ? `${cachedTokens.toLocaleString()} cached` : '';
|
|
81
|
+
const elapsed = elapsedMs > 0 ? `${(elapsedMs / 1000).toFixed(1)}s` : '';
|
|
81
82
|
const costTurnStr = inCredits
|
|
82
83
|
? (credits ? formatCreditsLine(credits) : CREDITS_PENDING)
|
|
83
84
|
: (costThisTurn != null ? formatCost(costThisTurn) : '');
|
|
@@ -92,7 +93,7 @@ export function formatStatusFooter(params) {
|
|
|
92
93
|
sapAlias,
|
|
93
94
|
skillDisplay,
|
|
94
95
|
turns,
|
|
95
|
-
tokStr,
|
|
96
|
+
...(tokStr ? [tokStr] : []),
|
|
96
97
|
...(costTurnStr ? [costTurnStr] : []),
|
|
97
98
|
...(cachedStr ? [cachedStr] : []),
|
|
98
99
|
...(costSessStr ? [costSessStr] : []),
|
|
@@ -192,6 +192,7 @@ const RULES = {
|
|
|
192
192
|
fiori_scaffold: (a) => ({ verb: 'Scaffolded app', object: shortLeft(str(a['basePath']) ?? '') }),
|
|
193
193
|
fiori_apply: (a) => ({ verb: 'Applied', object: shortRight([str(a['entry']), str(a['appDir'])].filter(Boolean).join(' · ')) }),
|
|
194
194
|
fiori_list: () => ({ verb: 'Listed patterns', object: '' }),
|
|
195
|
+
fiori_deploy: (a) => ({ verb: str(a['mode']) === 'deploy' ? 'Deploy app' : 'Deploy test', object: shortRight(str(a['appDir']) ?? '') }),
|
|
195
196
|
fiori_deploy_config: (a) => ({ verb: 'Deploy config', object: shortRight(str(a['name']) ?? str(a['appDir']) ?? '') }),
|
|
196
197
|
fiori_fe_extend: (a) => ({ verb: 'Extended app', object: shortRight([str(a['op']), str(a['basePath'])].filter(Boolean).join(' · ')) }),
|
|
197
198
|
fiori_preview: (a) => ({ verb: 'Preview', object: shortRight([str(a['action']) ?? 'start', str(a['appDir'])].filter(Boolean).join(' ')) }),
|
|
@@ -258,7 +259,7 @@ const PRESENT = {
|
|
|
258
259
|
'Read playbook': 'Reading playbook', 'Read convention': 'Reading convention', 'Merged CCA': 'Merging CCA',
|
|
259
260
|
'Merged progress': 'Merging progress', 'Checked background': 'Checking background',
|
|
260
261
|
Subagent: 'Running subagent', Reader: 'Running reader', 'Drafted schedule': 'Drafting schedule', Applied: 'Applying',
|
|
261
|
-
'Listed patterns': 'Listing patterns', 'Deploy config': 'Writing deploy config', 'Extended app': 'Extending app',
|
|
262
|
+
'Listed patterns': 'Listing patterns', 'Deploy config': 'Writing deploy config', 'Deploy app': 'Deploying app', 'Deploy test': 'Test-deploying app', 'Extended app': 'Extending app',
|
|
262
263
|
Preview: 'Preview', 'Searched samples': 'Searching samples', 'Read sample': 'Reading sample',
|
|
263
264
|
'Audited metadata': 'Auditing metadata', 'Extended model': 'Extending model',
|
|
264
265
|
'Checked capability': 'Checking capability',
|
package/dist/repl.js
CHANGED
|
@@ -38,6 +38,7 @@ import { makeSessionId, saveSession, sessionSaved } from './session/store.js';
|
|
|
38
38
|
import { newSession } from './session/schema.js';
|
|
39
39
|
import { resolveModelRole } from './models/resolve.js';
|
|
40
40
|
import { loadServerModelConfig, loadCustomerModelConfig, fetchToolLoadingMode } from './models/server-config.js';
|
|
41
|
+
import { prefetchRouterPrompt } from './router/router-prompt.js';
|
|
41
42
|
import { repinResumedSessionModel } from './session/repin-model.js';
|
|
42
43
|
import { computeCost } from './cost/pricing.js';
|
|
43
44
|
import { formatSessionSpendValue } from './repl/session-spend-line.js';
|
|
@@ -60,7 +61,7 @@ import { enqueueSteering } from './agent/steering-queue.js';
|
|
|
60
61
|
import { classifyPrompt, parseShortcut, rejectUnknownSkill } from './router/classifier.js';
|
|
61
62
|
import { pipedRoutingPick } from './router/piped-routing.js';
|
|
62
63
|
import { decideRouting } from './router/routing-decision.js';
|
|
63
|
-
import { routingFailureFromError, routingFailureFromResult, routingFailureNotice, routingFailureOutcome, routingFailureText } from './router/routing-failure.js';
|
|
64
|
+
import { routingFailureFromError, routingFailureFromResult, routingFailureNotice, routingFailureOutcome, routingFailureText, routingFallbackFromError } from './router/routing-failure.js';
|
|
64
65
|
import { coachingPickerClassic } from './ui/coaching-picker-classic.js';
|
|
65
66
|
import { coachingPickerEmitter } from './ui/coaching-picker-emitter.js';
|
|
66
67
|
import { globalStore as sapStateStore } from './ui/sap-state-store.js';
|
|
@@ -553,6 +554,9 @@ export async function runRepl(opts) {
|
|
|
553
554
|
loadCustomerModelConfig(cfg, async () => apiKey ?? '', { timeoutMs: 1500 }),
|
|
554
555
|
// Task 16 (F13) — BYOK reads only the served tool_loading (signed in only).
|
|
555
556
|
fetchToolLoadingMode(cfg, async () => apiKey ?? '', { timeoutMs: 1500 }),
|
|
557
|
+
// 2026-10-02 — BYOK / ai-hub route on the laptop: prefetch the signed
|
|
558
|
+
// router prompt (public manifest, no prompt data) under the same cap.
|
|
559
|
+
cfg.llm?.mode === 'byok' || cfg.llm?.mode === 'ai-hub' ? prefetchRouterPrompt({ timeoutMs: 1500 }) : Promise.resolve(),
|
|
556
560
|
]);
|
|
557
561
|
// Credits (2026-09-16) — resolve ONCE, before any cost figure can render.
|
|
558
562
|
// A managed customer is billed in credits at a multiplier we do not
|
|
@@ -1318,8 +1322,16 @@ export async function runRepl(opts) {
|
|
|
1318
1322
|
}, { signal: routingCtl.signal });
|
|
1319
1323
|
}
|
|
1320
1324
|
catch (err) {
|
|
1321
|
-
if (!routingCtl.signal.aborted)
|
|
1322
|
-
|
|
1325
|
+
if (!routingCtl.signal.aborted) {
|
|
1326
|
+
// Live fix 2026-10-02 — a classify failure (proxy HTTP 500,
|
|
1327
|
+
// an unreadable body) dropped the turn. Continue on the
|
|
1328
|
+
// bundled local rules: the offline handling below prints one
|
|
1329
|
+
// notice, then the low-confidence picker (or fix 10's stay).
|
|
1330
|
+
const fallback = routingFallbackFromError(err, prompt);
|
|
1331
|
+
if (!fallback)
|
|
1332
|
+
throw err;
|
|
1333
|
+
c = fallback;
|
|
1334
|
+
}
|
|
1323
1335
|
}
|
|
1324
1336
|
finally {
|
|
1325
1337
|
routingAbort = null;
|
|
@@ -1467,6 +1479,8 @@ export async function runRepl(opts) {
|
|
|
1467
1479
|
// Task 9 — run-lease identity + takeover prompt (concurrent
|
|
1468
1480
|
// resume guard; inkAwarePrompt answers '' in headless = decline).
|
|
1469
1481
|
sessionId: session.id,
|
|
1482
|
+
// 2026-10-02 — deploy card on a direct resume into ui.deploy.
|
|
1483
|
+
sapAlias: session.sap_system ?? undefined,
|
|
1470
1484
|
prompt: inkAwarePrompt,
|
|
1471
1485
|
confirm: inkAwareConfirm,
|
|
1472
1486
|
log: (...lines) => chunkEmitter.emit('chunk', lines.join('\n') + '\n'),
|
|
@@ -1494,6 +1508,7 @@ export async function runRepl(opts) {
|
|
|
1494
1508
|
phaseIds: planResume.reauditPhaseIds ?? [planResume.reauditPhaseId],
|
|
1495
1509
|
sessionId: session.id,
|
|
1496
1510
|
cwd: process.cwd(),
|
|
1511
|
+
sapAlias: session.sap_system ?? undefined,
|
|
1497
1512
|
mode: planResume.effectiveMode ?? 'step',
|
|
1498
1513
|
modeFlag: planResume.mode,
|
|
1499
1514
|
// Off-switch (I3) — footer honesty on re-audit boards; (I2) the
|
|
@@ -1833,6 +1848,7 @@ export async function runRepl(opts) {
|
|
|
1833
1848
|
executedPhaseId: planResume.nextPhaseId,
|
|
1834
1849
|
sessionId: session.id,
|
|
1835
1850
|
cwd: process.cwd(),
|
|
1851
|
+
sapAlias: session.sap_system ?? undefined,
|
|
1836
1852
|
mode: planResume.effectiveMode ?? 'step',
|
|
1837
1853
|
modeFlag: planResume.mode,
|
|
1838
1854
|
// Off-switch — skips the normal post-phase audit when 'off'; the
|
|
@@ -1903,6 +1919,7 @@ export async function runRepl(opts) {
|
|
|
1903
1919
|
outcome: planOutcome,
|
|
1904
1920
|
sessionId: session.id,
|
|
1905
1921
|
cwd: process.cwd(),
|
|
1922
|
+
sapAlias: session.sap_system ?? undefined,
|
|
1906
1923
|
subagentDispatches: planSubagentDispatches,
|
|
1907
1924
|
prompt: inkAwarePrompt,
|
|
1908
1925
|
confirm: inkAwareConfirm,
|
|
@@ -3077,11 +3094,22 @@ export async function runRepl(opts) {
|
|
|
3077
3094
|
}
|
|
3078
3095
|
else {
|
|
3079
3096
|
try {
|
|
3080
|
-
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
3097
|
+
let c;
|
|
3098
|
+
try {
|
|
3099
|
+
c = await classifyPrompt(prompt, {
|
|
3100
|
+
sap_system: selectedAlias,
|
|
3101
|
+
last_skill: session.last_skill ?? null,
|
|
3102
|
+
last_object: extractedObject ?? session.last_object ?? null,
|
|
3103
|
+
});
|
|
3104
|
+
}
|
|
3105
|
+
catch (err) {
|
|
3106
|
+
// Live fix 2026-10-02 — same as the Ink path: a classify failure
|
|
3107
|
+
// continues on the bundled local rules instead of ending the turn.
|
|
3108
|
+
const fallback = routingFallbackFromError(err, prompt);
|
|
3109
|
+
if (!fallback)
|
|
3110
|
+
throw err;
|
|
3111
|
+
c = fallback;
|
|
3112
|
+
}
|
|
3085
3113
|
if (c.screenshot)
|
|
3086
3114
|
console.log(formatInfo(`Screenshot: ${c.screenshot}`).trimEnd());
|
|
3087
3115
|
// Fix 10 (2026-09-26): routing failed mid-skill → stay in the
|
|
@@ -3214,6 +3242,8 @@ export async function runRepl(opts) {
|
|
|
3214
3242
|
configAudit: cfg.plan_audit,
|
|
3215
3243
|
// Task 9 — run-lease identity + takeover prompt (classic mirror).
|
|
3216
3244
|
sessionId: session.id,
|
|
3245
|
+
// 2026-10-02 — deploy card on a direct resume into ui.deploy.
|
|
3246
|
+
sapAlias: session.sap_system ?? undefined,
|
|
3217
3247
|
prompt: inkAwarePrompt,
|
|
3218
3248
|
confirm: inkAwareConfirm,
|
|
3219
3249
|
log: (...lines) => lines.forEach((l) => console.log(l)),
|
|
@@ -3240,6 +3270,7 @@ export async function runRepl(opts) {
|
|
|
3240
3270
|
phaseIds: planResume.reauditPhaseIds ?? [planResume.reauditPhaseId],
|
|
3241
3271
|
sessionId: session.id,
|
|
3242
3272
|
cwd: process.cwd(),
|
|
3273
|
+
sapAlias: session.sap_system ?? undefined,
|
|
3243
3274
|
mode: planResume.effectiveMode ?? 'step',
|
|
3244
3275
|
modeFlag: planResume.mode,
|
|
3245
3276
|
// Off-switch (I3/I2) — classic mirror; see the Ink re-audit site.
|
|
@@ -3551,6 +3582,7 @@ export async function runRepl(opts) {
|
|
|
3551
3582
|
executedPhaseId: planResume.nextPhaseId,
|
|
3552
3583
|
sessionId: session.id,
|
|
3553
3584
|
cwd: process.cwd(),
|
|
3585
|
+
sapAlias: session.sap_system ?? undefined,
|
|
3554
3586
|
mode: planResume.effectiveMode ?? 'step',
|
|
3555
3587
|
modeFlag: planResume.mode,
|
|
3556
3588
|
// Off-switch (I3/I2) — classic mirror of the Ink chain call: skip
|
|
@@ -3620,6 +3652,7 @@ export async function runRepl(opts) {
|
|
|
3620
3652
|
outcome: planOutcome,
|
|
3621
3653
|
sessionId: session.id,
|
|
3622
3654
|
cwd: process.cwd(),
|
|
3655
|
+
sapAlias: session.sap_system ?? undefined,
|
|
3623
3656
|
subagentDispatches: planSubagentDispatches,
|
|
3624
3657
|
prompt: inkAwarePrompt,
|
|
3625
3658
|
confirm: inkAwareConfirm,
|