@cspeach/cli 1.1.25 → 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/plan-lane.js +47 -0
- package/dist/projects/plan-run.js +8 -0
- package/dist/projects/plan-schema.js +2 -0
- package/dist/projects/save-command.js +3 -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/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/tools.js +117 -5
- package/dist/tools/local-build.js +3 -0
- package/dist/tools/subagent/read_agent.js +4 -2
- package/dist/tools/tool-loading.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// cspeach-cli/src/commands/deploy-card.ts
|
|
2
|
+
/**
|
|
3
|
+
* The deploy card (owner, 2026-10-02) — the CLI-owned stop between ui.build
|
|
4
|
+
* and ui.deploy. Deploy is a chained step of a UI plan, so the chain does not
|
|
5
|
+
* just ask "run the next phase?": it shows WHERE the app will go (system,
|
|
6
|
+
* client, BSP name, package, transport) and that CSPeach's stored SAP logon
|
|
7
|
+
* will be used, and only then dispatches ui.deploy.
|
|
8
|
+
*
|
|
9
|
+
* ONE CLEAR APPROVAL
|
|
10
|
+
* ------------------
|
|
11
|
+
* - This card = consent to run the deploy phase AND to hand the stored logon
|
|
12
|
+
* to `fiori deploy` this session. Confirming it records the fiori_deploy
|
|
13
|
+
* credential handover (scope fiori_deploy), so the tool does not ask that
|
|
14
|
+
* question a second time.
|
|
15
|
+
* - The real deploy is a SAP write: inside the phase it still needs ONE
|
|
16
|
+
* request_approval for the BSP — the same approval box every SAP write
|
|
17
|
+
* gets, which is where write_mode, the qas/prd role ceilings and the
|
|
18
|
+
* guarded-run "no auto-approve" rule are enforced. The test-mode deploy
|
|
19
|
+
* that runs first needs no approval (it writes nothing).
|
|
20
|
+
* - A deploy that can never run (production alias, advisory-only, no SAP
|
|
21
|
+
* system) is explained on the card and never asked or queued.
|
|
22
|
+
*
|
|
23
|
+
* Pure-ish: config + file reads are injectable; nothing here talks to SAP.
|
|
24
|
+
*/
|
|
25
|
+
import { promises as fs } from 'node:fs';
|
|
26
|
+
import * as path from 'node:path';
|
|
27
|
+
import { readDeployYaml } from '../tools/fiori/deploy/yaml-read.js';
|
|
28
|
+
import { cardLine } from '../lane/card.js';
|
|
29
|
+
/** The last value of a work field across phases (last written wins, as in planCompletionLines). */
|
|
30
|
+
function lastWork(phases, pick) {
|
|
31
|
+
for (const p of [...phases].reverse()) {
|
|
32
|
+
const v = pick(p);
|
|
33
|
+
if (v !== undefined && v !== null && v !== '')
|
|
34
|
+
return v;
|
|
35
|
+
}
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
export async function gatherDeployCardFacts(a) {
|
|
39
|
+
const f = {};
|
|
40
|
+
f.appDir = lastWork(a.phases, (p) => (p.layer === 'ui' ? p.work?.app?.dir : undefined));
|
|
41
|
+
f.transport = lastWork(a.phases, (p) => p.work?.transport);
|
|
42
|
+
// The app's deploy config, when ui.build (or an earlier run) already wrote it.
|
|
43
|
+
if (f.appDir) {
|
|
44
|
+
try {
|
|
45
|
+
const abs = path.resolve(a.cwd, f.appDir);
|
|
46
|
+
const read = readDeployYaml(await fs.readFile(path.join(abs, 'ui5-deploy.yaml'), 'utf8'));
|
|
47
|
+
if (read.ok) {
|
|
48
|
+
f.bsp = read.config.name;
|
|
49
|
+
f.package = read.config.package.toUpperCase();
|
|
50
|
+
f.transport = read.config.transport || (f.package === '$TMP' ? undefined : f.transport);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
catch { /* no deploy config yet — the phase writes it */ }
|
|
54
|
+
}
|
|
55
|
+
if (!a.sapAlias || a.sapAlias === 'none') {
|
|
56
|
+
f.blocked = 'no SAP system is connected in this session — start CSPeach with the SAP alias to deploy to';
|
|
57
|
+
return f;
|
|
58
|
+
}
|
|
59
|
+
f.alias = a.sapAlias;
|
|
60
|
+
let cfg;
|
|
61
|
+
try {
|
|
62
|
+
cfg = await (a.loadConfig ?? (async () => (await import('../config/loader.js')).loadConfig()))();
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
f.blocked = 'the CSPeach config could not be read';
|
|
66
|
+
return f;
|
|
67
|
+
}
|
|
68
|
+
const sys = cfg.sap?.[a.sapAlias];
|
|
69
|
+
if (!sys) {
|
|
70
|
+
f.blocked = `SAP alias ${a.sapAlias} is not configured`;
|
|
71
|
+
return f;
|
|
72
|
+
}
|
|
73
|
+
const host = sys.host.trim().replace(/^[a-z][a-z0-9+.-]*:\/\//i, '').replace(/[/?#].*$/, '').replace(/:\d+$/, '').toLowerCase();
|
|
74
|
+
f.systemUrl = `${sys.useSsl === false ? 'http' : 'https'}://${host}:${sys.port}`;
|
|
75
|
+
f.client = String(sys.client);
|
|
76
|
+
if (sys.role === 'prd') {
|
|
77
|
+
f.blocked = `${a.sapAlias} is tagged as a production system — CSPeach deploys in development; move the app with the transport`;
|
|
78
|
+
return f;
|
|
79
|
+
}
|
|
80
|
+
const effective = (a.effectiveWriteMode ?? (await import('../repl/mode-cycle.js')).getEffectiveWriteMode)(cfg.write_mode ?? 'approval-gated', sys.role);
|
|
81
|
+
if (effective === 'advisory-only') {
|
|
82
|
+
f.blocked = `write mode is advisory-only for ${a.sapAlias} — the deploy is a SAP write`;
|
|
83
|
+
}
|
|
84
|
+
return f;
|
|
85
|
+
}
|
|
86
|
+
export function renderDeployCard(f, nextPhaseId) {
|
|
87
|
+
const v = (s, fallback) => (s ? cardLine(s) : fallback);
|
|
88
|
+
const lines = [
|
|
89
|
+
'',
|
|
90
|
+
`Deploy the app to SAP (next phase: ${cardLine(nextPhaseId)})`,
|
|
91
|
+
` System ${v(f.alias, '(none)')}${f.systemUrl ? ` — ${cardLine(f.systemUrl)}` : ''}${f.client ? ` · client ${cardLine(f.client)}` : ''}`,
|
|
92
|
+
` App ${v(f.appDir, '(recorded by ui.build)')}`,
|
|
93
|
+
` BSP name ${v(f.bsp, 'set in this phase (max 15 characters, Z/Y, uppercase)')}`,
|
|
94
|
+
` Package ${v(f.package, 'set in this phase (the backend package)')}`,
|
|
95
|
+
` Transport ${f.package === '$TMP' ? '(none — $TMP, local only)' : v(f.transport, 'the session transport')}`,
|
|
96
|
+
` Logon CSPeach's stored SAP logon${f.alias ? ` for ${cardLine(f.alias)}` : ''} — no password is typed, nothing is written to a file`,
|
|
97
|
+
];
|
|
98
|
+
if (f.blocked) {
|
|
99
|
+
lines.push(` Blocked ${cardLine(f.blocked)}`);
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
lines.push(' The phase runs a test deploy first (nothing is written), then the real deploy asks for one write approval for the BSP, confirmed on screen — never an auto-approval.');
|
|
103
|
+
}
|
|
104
|
+
return lines;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Show the card and ask — the ONE implementation used by the post-phase chain
|
|
108
|
+
* (plan-chain.ts) and by a direct `/abap-plan --resume` into the deploy phase
|
|
109
|
+
* (plan-resume.ts). A "yes" records the fiori_deploy logon handover for the
|
|
110
|
+
* alias, which is also how a later resume knows the card was already shown.
|
|
111
|
+
*/
|
|
112
|
+
export async function askDeployCard(a) {
|
|
113
|
+
const facts = await gatherDeployCardFacts({
|
|
114
|
+
phases: a.phases,
|
|
115
|
+
cwd: a.cwd,
|
|
116
|
+
...(a.sapAlias ? { sapAlias: a.sapAlias } : {}),
|
|
117
|
+
...(a.deps?.loadConfig ? { loadConfig: a.deps.loadConfig } : {}),
|
|
118
|
+
...(a.deps?.effectiveWriteMode ? { effectiveWriteMode: a.deps.effectiveWriteMode } : {}),
|
|
119
|
+
});
|
|
120
|
+
a.log(...renderDeployCard(facts, a.nextPhaseId));
|
|
121
|
+
if (facts.blocked)
|
|
122
|
+
return { outcome: 'blocked', reason: facts.blocked };
|
|
123
|
+
const { askYesNo, isYes } = await import('../projects/yes-no.js');
|
|
124
|
+
const answer = await askYesNo({
|
|
125
|
+
question: `Deploy now — run ${a.nextPhaseId} with the stored SAP logon for ${facts.alias}?`,
|
|
126
|
+
confirm: a.confirm,
|
|
127
|
+
prompt: a.prompt,
|
|
128
|
+
});
|
|
129
|
+
if (!isYes(answer))
|
|
130
|
+
return { outcome: 'no' };
|
|
131
|
+
const { markCredentialHandoverApproved } = await import('../repl/credential-handover-gate.js');
|
|
132
|
+
const { FIORI_DEPLOY_HANDOVER_SCOPE } = await import('../tools/fiori/deploy/scope.js');
|
|
133
|
+
markCredentialHandoverApproved(a.sessionId, facts.alias, FIORI_DEPLOY_HANDOVER_SCOPE);
|
|
134
|
+
return { outcome: 'yes' };
|
|
135
|
+
}
|
package/dist/commands/login.js
CHANGED
|
@@ -7,8 +7,10 @@
|
|
|
7
7
|
* 3) Print user_code prominently so customer can read it
|
|
8
8
|
* 4) Poll /v1/auth/device/poll every interval until verified or maxAttempts
|
|
9
9
|
* 5) Save api_key + customer_id to ~/.cspeach/auth.json
|
|
10
|
-
* 6) Call GET /v1/me — if trial_mode === 'byok',
|
|
11
|
-
*
|
|
10
|
+
* 6) Call GET /v1/me — if trial_mode === 'byok', pull the org's Anthropic key,
|
|
11
|
+
* or prompt for the user's own
|
|
12
|
+
* 7) Store that key in the OS keychain (cspeach.anthropic/api-key) — never
|
|
13
|
+
* in auth.json (2026-10-02)
|
|
12
14
|
*
|
|
13
15
|
* Dependencies (open, print, prompt) are injected so the command is
|
|
14
16
|
* unit-testable without stubbing platform globals.
|
|
@@ -94,7 +96,6 @@ export async function runLoginCommand(deps) {
|
|
|
94
96
|
await writeAuthFile({
|
|
95
97
|
apiKey: polled.api_key,
|
|
96
98
|
customerId: polled.customer_id,
|
|
97
|
-
byokAnthropicKey: null,
|
|
98
99
|
});
|
|
99
100
|
deps.print(done('Saved your CSPeach key to ~/.cspeach/auth.json'));
|
|
100
101
|
// 6) /v1/me — figure out if BYOK is required.
|
|
@@ -111,8 +112,8 @@ export async function runLoginCommand(deps) {
|
|
|
111
112
|
const setKey = deps.setKeychainKey ??
|
|
112
113
|
((svc, acct, k) => keychain.setPassword(svc, acct, k));
|
|
113
114
|
if (orgKey !== null) {
|
|
114
|
-
await setKey
|
|
115
|
-
|
|
115
|
+
if (await storeKey(setKey, orgKey, deps.print))
|
|
116
|
+
deps.print(done("Using your organization's API key"));
|
|
116
117
|
}
|
|
117
118
|
else {
|
|
118
119
|
// Fall back to the manual paste prompt — behaviour unchanged.
|
|
@@ -121,14 +122,11 @@ export async function runLoginCommand(deps) {
|
|
|
121
122
|
// The key-entry row — the one place the vendor and its key page are named.
|
|
122
123
|
const ak = (await deps.prompt('Paste your Anthropic API key (sk-ant-..., from https://console.anthropic.com/settings/keys): ')).trim();
|
|
123
124
|
if (ak.startsWith('sk-ant-') && ak.length > 20) {
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
byokAnthropicKey: ak,
|
|
130
|
-
});
|
|
131
|
-
deps.print(done('API key saved'));
|
|
125
|
+
// 2026-10-02: the OS keychain, the same entry as the org key above
|
|
126
|
+
// and the one BYOK reads (byok-provider.ts). This used to go to
|
|
127
|
+
// auth.json in PLAINTEXT, where nothing read it.
|
|
128
|
+
if (await storeKey(setKey, ak, deps.print))
|
|
129
|
+
deps.print(done('API key saved to your OS keychain'));
|
|
132
130
|
}
|
|
133
131
|
else {
|
|
134
132
|
deps.print(formatFailure({ what: 'That does not look like your own API key', detail: ['it must start with sk-ant-', 'skipped'], next: 'run cspeach login again later' }).trimEnd());
|
|
@@ -143,6 +141,26 @@ export async function runLoginCommand(deps) {
|
|
|
143
141
|
deps.print(` ${paint('dim', 'Next:')} run cspeach to start`);
|
|
144
142
|
return 0;
|
|
145
143
|
}
|
|
144
|
+
/**
|
|
145
|
+
* 2026-10-02 (review nit): store the BYOK key in the OS keychain. A keychain
|
|
146
|
+
* failure (e.g. Linux without a Secret Service) gets its own plain notice —
|
|
147
|
+
* it used to surface as "Could not read your account details". The key is
|
|
148
|
+
* never written anywhere else.
|
|
149
|
+
*/
|
|
150
|
+
async function storeKey(setKey, key, print) {
|
|
151
|
+
try {
|
|
152
|
+
await setKey(ANTHROPIC_KEYCHAIN_SERVICE, ANTHROPIC_KEYCHAIN_ACCOUNT, key);
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
catch (err) {
|
|
156
|
+
print(formatFailure({
|
|
157
|
+
what: "Couldn't save your API key to the OS keychain",
|
|
158
|
+
detail: [err instanceof Error ? err.message : String(err), 'sign-in itself worked; the key was not stored anywhere'],
|
|
159
|
+
next: 'make the OS keychain available (on Linux: a Secret Service such as gnome-keyring), then run cspeach login again',
|
|
160
|
+
}).trimEnd());
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
146
164
|
function sleep(ms) {
|
|
147
165
|
return new Promise((r) => setTimeout(r, ms));
|
|
148
166
|
}
|
|
@@ -24,6 +24,8 @@ import chalk from 'chalk';
|
|
|
24
24
|
import { readProjectFile } from '../projects/status.js';
|
|
25
25
|
import { parsePlanContent } from '../projects/plan-schema.js';
|
|
26
26
|
import { statusesFromItems, computeNextPhase, isPhaseSatisfied, isAuditResolved, planCompletionLines, renderPlanTracker, } from '../projects/plan-run.js';
|
|
27
|
+
import { isDeployPhase } from '../projects/plan-lane.js';
|
|
28
|
+
import { askDeployCard } from './deploy-card.js';
|
|
27
29
|
import { decideNextPhaseGate, getAndResetPlanPhaseSubagentDispatches, getAndResetPlanDeviation, } from './plan-gate.js';
|
|
28
30
|
import { runPhaseAudit } from './plan-audit.js';
|
|
29
31
|
import { applyAuditResult, applyPhaseResolution, buildResumeCommand, offerNextPhaseAutoRun, } from './plan-resume.js';
|
|
@@ -379,6 +381,35 @@ export async function runPostPhaseChain(args) {
|
|
|
379
381
|
args.log(substitutePlanToken(decision.banner ?? '', path));
|
|
380
382
|
ringBell();
|
|
381
383
|
}
|
|
384
|
+
// 2026-10-02 — deploy is a chained step of a UI plan. Before ui.deploy the
|
|
385
|
+
// chain stops on the CLI-owned deploy card (deploy-card.ts), in step AND
|
|
386
|
+
// guarded mode, instead of the plain "run the next phase?" question.
|
|
387
|
+
if (isDeployPhase(next)) {
|
|
388
|
+
const card = await askDeployCard({
|
|
389
|
+
phases: loaded.content.phases,
|
|
390
|
+
cwd: args.cwd,
|
|
391
|
+
...(args.sapAlias ? { sapAlias: args.sapAlias } : {}),
|
|
392
|
+
sessionId: args.sessionId,
|
|
393
|
+
nextPhaseId: next.id,
|
|
394
|
+
prompt: args.prompt,
|
|
395
|
+
...(args.confirm ? { confirm: args.confirm } : {}),
|
|
396
|
+
log: args.log,
|
|
397
|
+
deps: {
|
|
398
|
+
...(args.deps?.loadDeployCardConfig ? { loadConfig: args.deps.loadDeployCardConfig } : {}),
|
|
399
|
+
...(args.deps?.effectiveWriteMode ? { effectiveWriteMode: args.deps.effectiveWriteMode } : {}),
|
|
400
|
+
},
|
|
401
|
+
});
|
|
402
|
+
if (card.outcome === 'blocked') {
|
|
403
|
+
args.log(`${next.id} cannot run here: ${card.reason}. Resume later with: ${resumeCmd(path)}`);
|
|
404
|
+
return path;
|
|
405
|
+
}
|
|
406
|
+
if (card.outcome === 'no') {
|
|
407
|
+
args.log(`Not deployed — resume later with: ${resumeCmd(path)}`);
|
|
408
|
+
return path;
|
|
409
|
+
}
|
|
410
|
+
args.queueDispatch(resumeCmd(path));
|
|
411
|
+
return path;
|
|
412
|
+
}
|
|
382
413
|
await offerNextPhaseAutoRun({
|
|
383
414
|
outcome: {
|
|
384
415
|
savedPath: path,
|
|
@@ -176,6 +176,7 @@ export async function runContinuationChain(args) {
|
|
|
176
176
|
executedPhaseId: args.marker.phaseId,
|
|
177
177
|
sessionId: args.sessionId,
|
|
178
178
|
cwd: args.cwd,
|
|
179
|
+
...(args.sapAlias ? { sapAlias: args.sapAlias } : {}),
|
|
179
180
|
mode: args.prepared.effectiveMode ?? 'step',
|
|
180
181
|
modeFlag: args.prepared.mode,
|
|
181
182
|
auditMode: args.prepared.auditMode,
|
|
@@ -54,7 +54,10 @@ import { FLOORPLAN_TO_TEMPLATE } from '../tools/fiori/floorplan-map.js';
|
|
|
54
54
|
import { getCurrentTransport, setCurrentTransport } from '../repl/current-transport.js';
|
|
55
55
|
import { askYesNo, isYes } from '../projects/yes-no.js';
|
|
56
56
|
import { formatAttention, formatInfo } from '../renderer/notice-shape.js';
|
|
57
|
-
import { checkLaneConsistency, loadPlanLaneForRun, renderLaneBlock } from '../projects/plan-lane.js';
|
|
57
|
+
import { checkLaneConsistency, checkRequiredDeploy, isDeployPhase, loadPlanLaneForRun, renderLaneBlock } from '../projects/plan-lane.js';
|
|
58
|
+
import { askDeployCard } from './deploy-card.js';
|
|
59
|
+
import { isCredentialHandoverApproved } from '../repl/credential-handover-gate.js';
|
|
60
|
+
import { FIORI_DEPLOY_HANDOVER_SCOPE } from '../tools/fiori/deploy/scope.js';
|
|
58
61
|
/**
|
|
59
62
|
* Parse and STRIP the per-run chaining-mode (`--guarded` / `--step`) and
|
|
60
63
|
* audit off-switch (`--audit` / `--no-audit`) flags from a plan-resume body.
|
|
@@ -137,6 +140,18 @@ export function buildUiPhaseContext(phases, next) {
|
|
|
137
140
|
lines.push(`- App directory (recorded by ui.build): ${appDir}`);
|
|
138
141
|
if (service)
|
|
139
142
|
lines.push(`- Service URL: ${service.url ?? ''}${service.path ?? ''} (OData ${service.version ?? '?'})`);
|
|
143
|
+
// 2026-10-02 — deploy is a chained, required step: inside a deploy phase it
|
|
144
|
+
// is EXPECTED (the developer already confirmed the CLI deploy card).
|
|
145
|
+
if (isDeployPhase(next)) {
|
|
146
|
+
const transport = [...phases].reverse().map((p) => p.work?.transport).find(Boolean);
|
|
147
|
+
lines.push(transport
|
|
148
|
+
? `- Session transport: ${transport}`
|
|
149
|
+
: '- Session transport: NOT RECORDED in any phase work.transport — use the session transport (sap_transport list) or ask once.');
|
|
150
|
+
lines.push('- Deploy is expected in this phase (the developer confirmed the deploy card): if the app has no ui5-deploy.yaml, ' +
|
|
151
|
+
'write it with fiori_deploy_config (BSP name max 15 chars uppercase Z/Y, package = the backend package, transport = ' +
|
|
152
|
+
'the session transport); then fiori_deploy mode "test"; then request_approval {op:"create", object:"<BSP NAME>", ' +
|
|
153
|
+
'type:"WAPA"} and fiori_deploy mode "deploy" with that approval_id. Record the URL it returns in work.deployedUrl.');
|
|
154
|
+
}
|
|
140
155
|
return lines;
|
|
141
156
|
}
|
|
142
157
|
/** Compact-example work keys for ui phases — the write-back contract
|
|
@@ -450,6 +465,32 @@ export async function preparePlanResume(args) {
|
|
|
450
465
|
releaseRunLease(path);
|
|
451
466
|
return null;
|
|
452
467
|
}
|
|
468
|
+
// 2026-10-02 — a direct `/abap-plan --resume` into the deploy phase shows the
|
|
469
|
+
// same deploy card the post-phase chain shows. A "yes" there (or here)
|
|
470
|
+
// records the fiori_deploy logon handover for the alias, so a resume the
|
|
471
|
+
// chain itself queued after its card is not asked twice.
|
|
472
|
+
if (isDeployPhase(next) && args.sapAlias && args.prompt
|
|
473
|
+
&& !isCredentialHandoverApproved(args.sessionId, args.sapAlias, FIORI_DEPLOY_HANDOVER_SCOPE)) {
|
|
474
|
+
const card = await askDeployCard({
|
|
475
|
+
phases: content.phases,
|
|
476
|
+
cwd: args.cwd,
|
|
477
|
+
sapAlias: args.sapAlias,
|
|
478
|
+
...(args.sessionId ? { sessionId: args.sessionId } : {}),
|
|
479
|
+
nextPhaseId: next.id,
|
|
480
|
+
prompt: args.prompt,
|
|
481
|
+
...(args.confirm ? { confirm: args.confirm } : {}),
|
|
482
|
+
log: args.log,
|
|
483
|
+
...(args.deployCardDeps ? { deps: args.deployCardDeps } : {}),
|
|
484
|
+
});
|
|
485
|
+
if (card.outcome !== 'yes') {
|
|
486
|
+
args.log(card.outcome === 'blocked'
|
|
487
|
+
? `${next.id} cannot run here: ${card.reason}. Resume later with: ${buildResumeCommand(path)}`
|
|
488
|
+
: `Not deployed — resume later with: ${buildResumeCommand(path)}`);
|
|
489
|
+
if (args.sessionId)
|
|
490
|
+
releaseRunLease(path);
|
|
491
|
+
return null;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
453
494
|
// Inline the phase's declared rule files (coarse v1: whole files).
|
|
454
495
|
// Paths resolve against cwd — dogfood runs sit inside a repo carrying
|
|
455
496
|
// .claude/rules/. A missing file is noted, not fatal: the skill falls
|
|
@@ -757,7 +798,13 @@ export async function finishPlanResume(args) {
|
|
|
757
798
|
// App lane routing (Task 6, Review Focus 4) — the write-back may restructure
|
|
758
799
|
// the plan (full shape) or overlay a phase; it must still fit the lane.
|
|
759
800
|
if (args.prepared.laneRecord) {
|
|
760
|
-
|
|
801
|
+
// 2026-10-02 — a write-back may not drop the required deploy phase. Only a
|
|
802
|
+
// plan that already met the rule is held to it, so a lane plan saved before
|
|
803
|
+
// the rule existed keeps running unchanged (no-regression).
|
|
804
|
+
const priorParsed = parsePlanContent(args.prepared.envelope.content);
|
|
805
|
+
const priorMetDeploy = priorParsed.ok && checkRequiredDeploy(priorParsed.content, args.prepared.laneRecord) === null;
|
|
806
|
+
const conflict = checkLaneConsistency(extract.content, args.prepared.laneRecord)
|
|
807
|
+
?? (priorMetDeploy ? checkRequiredDeploy(extract.content, args.prepared.laneRecord) : null);
|
|
761
808
|
if (conflict) {
|
|
762
809
|
args.log('');
|
|
763
810
|
args.log(`[plan] PHASE RESULT NOT PERSISTED — ${conflict}`);
|
package/dist/cost/cost-log.js
CHANGED
|
@@ -68,6 +68,8 @@ export function buildEntry(opts) {
|
|
|
68
68
|
entry.call_index = opts.call_index;
|
|
69
69
|
if (opts.kind !== undefined)
|
|
70
70
|
entry.kind = opts.kind;
|
|
71
|
+
if (opts.scope !== undefined)
|
|
72
|
+
entry.scope = opts.scope;
|
|
71
73
|
if (opts.refusal_category !== undefined)
|
|
72
74
|
entry.refusal_category = opts.refusal_category;
|
|
73
75
|
if (opts.input_transformations !== undefined)
|
|
@@ -123,12 +125,76 @@ export async function readCostLog(sessionId) {
|
|
|
123
125
|
return [];
|
|
124
126
|
}
|
|
125
127
|
}
|
|
128
|
+
/**
|
|
129
|
+
* 2026-10-03 — one model call's own token counts: the session counters now
|
|
130
|
+
* minus the counters when the previous cost line was written (or turn start).
|
|
131
|
+
* Never negative (a counter reset — e.g. a compaction rewrite — reads as 0).
|
|
132
|
+
*/
|
|
133
|
+
export function perCallTokens(now, last) {
|
|
134
|
+
const d = (a, b) => Math.max(0, a - b);
|
|
135
|
+
return {
|
|
136
|
+
input: d(now.input, last.input),
|
|
137
|
+
output: d(now.output, last.output),
|
|
138
|
+
cacheRead: d(now.cacheRead, last.cacheRead),
|
|
139
|
+
cacheCreate: d(now.cacheCreate, last.cacheCreate),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* 2026-10-03 — the lines that add up. Task-3-era lines (call_index, no
|
|
144
|
+
* scope: 1.1.19–1.1.25) each carry the running total since turn start, so a
|
|
145
|
+
* turn contributes only its LAST such line. Per-call lines (scope 'call'),
|
|
146
|
+
* pre-Task-3 lines and turn-0 labelled lines (audit) are each counted as-is.
|
|
147
|
+
* File order is preserved.
|
|
148
|
+
*/
|
|
149
|
+
export function countableEntries(entries) {
|
|
150
|
+
const lastLegacy = new Map(); // turn -> index of its last running-total line
|
|
151
|
+
entries.forEach((e, i) => {
|
|
152
|
+
if (isLegacyRunningTotal(e))
|
|
153
|
+
lastLegacy.set(e.turn, i);
|
|
154
|
+
});
|
|
155
|
+
return entries.filter((e, i) => !isLegacyRunningTotal(e) || lastLegacy.get(e.turn) === i);
|
|
156
|
+
}
|
|
157
|
+
function isLegacyRunningTotal(e) {
|
|
158
|
+
return e.call_index !== undefined && e.scope !== 'call' && e.turn > 0 && e.kind !== 'reader_rollup';
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Per-turn rows in file order. Tokens/cost come from the countable lines
|
|
162
|
+
* (legacy running totals → the turn's last line); credits are per-call balance
|
|
163
|
+
* deltas in BOTH line generations, so every line's charge is summed.
|
|
164
|
+
* Reader rollup lines are not turns; turn-0 labelled lines are rows of their own.
|
|
165
|
+
*/
|
|
166
|
+
export function turnRows(entries) {
|
|
167
|
+
const countable = new Set(countableEntries(entries));
|
|
168
|
+
const rows = [];
|
|
169
|
+
const byKey = new Map();
|
|
170
|
+
entries.forEach((e, i) => {
|
|
171
|
+
if (e.kind === 'reader_rollup')
|
|
172
|
+
return;
|
|
173
|
+
const key = e.turn > 0 ? `t${e.turn}` : `z${i}`;
|
|
174
|
+
let row = byKey.get(key);
|
|
175
|
+
if (!row) {
|
|
176
|
+
row = { turn: e.turn, tokens: 0, cost: 0, credits: null };
|
|
177
|
+
byKey.set(key, row);
|
|
178
|
+
rows.push(row);
|
|
179
|
+
}
|
|
180
|
+
if (countable.has(e)) {
|
|
181
|
+
row.tokens += e.tokens.input + e.tokens.output;
|
|
182
|
+
row.cost += e.cost;
|
|
183
|
+
}
|
|
184
|
+
if (typeof e.credits_charged === 'number')
|
|
185
|
+
row.credits = (row.credits ?? 0) + e.credits_charged;
|
|
186
|
+
});
|
|
187
|
+
return rows;
|
|
188
|
+
}
|
|
126
189
|
export function summarise(entries) {
|
|
127
190
|
const totals = { input: 0, output: 0, cacheRead: 0, cacheCreate: 0 };
|
|
128
191
|
let total = 0;
|
|
129
192
|
const perModel = new Map();
|
|
130
193
|
const readerByTurn = new Map();
|
|
131
|
-
|
|
194
|
+
// Turns: distinct turn numbers (> 0) plus each turn-0 labelled line.
|
|
195
|
+
const turnKeys = new Set();
|
|
196
|
+
const modelTurnKeys = new Map();
|
|
197
|
+
const countable = new Set(countableEntries(entries));
|
|
132
198
|
for (const e of entries) {
|
|
133
199
|
if (typeof e.reader_calls === 'number' && e.reader_calls > 0) {
|
|
134
200
|
const prev = readerByTurn.get(e.turn);
|
|
@@ -142,7 +208,10 @@ export function summarise(entries) {
|
|
|
142
208
|
// Fix M3 — a zero-token reader rollup line (interrupted turn) is not a model call.
|
|
143
209
|
if (e.kind === 'reader_rollup')
|
|
144
210
|
continue;
|
|
145
|
-
|
|
211
|
+
if (!countable.has(e))
|
|
212
|
+
continue;
|
|
213
|
+
const turnKey = e.turn > 0 ? `t${e.turn}` : `z${turnKeys.size}`;
|
|
214
|
+
turnKeys.add(turnKey);
|
|
146
215
|
total += e.cost;
|
|
147
216
|
totals.input += e.tokens.input;
|
|
148
217
|
totals.output += e.tokens.output;
|
|
@@ -153,7 +222,10 @@ export function summarise(entries) {
|
|
|
153
222
|
cost: 0,
|
|
154
223
|
tokens: { input: 0, output: 0, cacheRead: 0, cacheCreate: 0 },
|
|
155
224
|
};
|
|
156
|
-
|
|
225
|
+
const mk = modelTurnKeys.get(e.model) ?? new Set();
|
|
226
|
+
mk.add(turnKey);
|
|
227
|
+
modelTurnKeys.set(e.model, mk);
|
|
228
|
+
slot.turns = mk.size;
|
|
157
229
|
slot.cost += e.cost;
|
|
158
230
|
slot.tokens.input += e.tokens.input;
|
|
159
231
|
slot.tokens.output += e.tokens.output;
|
|
@@ -170,7 +242,7 @@ export function summarise(entries) {
|
|
|
170
242
|
return {
|
|
171
243
|
totalCost: Math.round((total + readerCost) * 10000) / 10000,
|
|
172
244
|
totalTokens: totals,
|
|
173
|
-
turns:
|
|
245
|
+
turns: turnKeys.size,
|
|
174
246
|
byModel: [...perModel.entries()].map(([model, v]) => ({ model, ...v })),
|
|
175
247
|
readerCost: Math.round(readerCost * 10000) / 10000,
|
|
176
248
|
readerCalls,
|
|
@@ -64,6 +64,15 @@ export async function initCredits(cfg, getBearer) {
|
|
|
64
64
|
setCostDisplayMode('usd');
|
|
65
65
|
}
|
|
66
66
|
}
|
|
67
|
+
/**
|
|
68
|
+
* A user request is starting (2026-10-02): the receipt then shows every
|
|
69
|
+
* model round of it, not only the last. No-op outside credits mode.
|
|
70
|
+
*/
|
|
71
|
+
export function beginCreditsRequest() {
|
|
72
|
+
if (!creditsActive())
|
|
73
|
+
return;
|
|
74
|
+
globalCredits.beginRequest();
|
|
75
|
+
}
|
|
67
76
|
/**
|
|
68
77
|
* Post-turn: sample the balance again and fold the delta into the session
|
|
69
78
|
* total. Returns null (and makes no request) outside credits mode.
|
package/dist/cost/credits.js
CHANGED
|
@@ -86,8 +86,11 @@ export async function fetchBalance(cfg, getBearer) {
|
|
|
86
86
|
*/
|
|
87
87
|
export class CreditsTracker {
|
|
88
88
|
baseline = null;
|
|
89
|
+
/** sessionTotal when the current user request began. */
|
|
90
|
+
requestStartTotal = 0;
|
|
89
91
|
state = {
|
|
90
92
|
chargedThisTurn: null,
|
|
93
|
+
chargedThisRequest: null,
|
|
91
94
|
sessionTotal: 0,
|
|
92
95
|
balance: null,
|
|
93
96
|
unitLabel: DEFAULT_UNIT_LABEL,
|
|
@@ -99,34 +102,48 @@ export class CreditsTracker {
|
|
|
99
102
|
this.state.unitLabel = balance.unit_label;
|
|
100
103
|
const opening = balance ? balance.balance_credits : null;
|
|
101
104
|
this.baseline = opening;
|
|
105
|
+
this.requestStartTotal = 0;
|
|
102
106
|
this.state = {
|
|
103
107
|
...this.state,
|
|
104
108
|
chargedThisTurn: null,
|
|
109
|
+
chargedThisRequest: null,
|
|
105
110
|
sessionTotal: 0,
|
|
106
111
|
balance: opening,
|
|
107
112
|
pending: false,
|
|
108
113
|
};
|
|
109
114
|
}
|
|
110
|
-
/**
|
|
115
|
+
/** Mark the start of a user request (one or more model rounds). */
|
|
116
|
+
beginRequest() {
|
|
117
|
+
this.requestStartTotal = this.state.sessionTotal;
|
|
118
|
+
this.state = { ...this.state, chargedThisRequest: null };
|
|
119
|
+
}
|
|
120
|
+
/** Record a post-round balance sample and return this round's charge. */
|
|
111
121
|
afterTurn(balance) {
|
|
112
122
|
if (balance)
|
|
113
123
|
this.state.unitLabel = balance.unit_label;
|
|
114
124
|
const current = balance ? balance.balance_credits : null;
|
|
115
125
|
if (current === null) {
|
|
116
126
|
// Unknown — keep the baseline so the next good sample covers both turns.
|
|
117
|
-
this.state = { ...this.state, chargedThisTurn: null, pending: true };
|
|
127
|
+
this.state = { ...this.state, chargedThisTurn: null, chargedThisRequest: null, pending: true };
|
|
118
128
|
return { chargedThisTurn: null, sessionTotal: this.state.sessionTotal, balance: this.state.balance };
|
|
119
129
|
}
|
|
120
130
|
if (this.baseline === null) {
|
|
121
131
|
// First balance we have ever seen — establish the baseline only.
|
|
122
132
|
this.baseline = current;
|
|
123
|
-
this.state = { ...this.state, chargedThisTurn: null, balance: current, pending: false };
|
|
133
|
+
this.state = { ...this.state, chargedThisTurn: null, chargedThisRequest: null, balance: current, pending: false };
|
|
124
134
|
return { chargedThisTurn: null, sessionTotal: this.state.sessionTotal, balance: current };
|
|
125
135
|
}
|
|
126
136
|
const charged = Math.max(0, this.baseline - current);
|
|
127
137
|
this.baseline = current;
|
|
128
138
|
const sessionTotal = this.state.sessionTotal + charged;
|
|
129
|
-
this.state = {
|
|
139
|
+
this.state = {
|
|
140
|
+
...this.state,
|
|
141
|
+
chargedThisTurn: charged,
|
|
142
|
+
chargedThisRequest: sessionTotal - this.requestStartTotal,
|
|
143
|
+
sessionTotal,
|
|
144
|
+
balance: current,
|
|
145
|
+
pending: false,
|
|
146
|
+
};
|
|
130
147
|
return { chargedThisTurn: charged, sessionTotal, balance: current };
|
|
131
148
|
}
|
|
132
149
|
snapshot() {
|
|
@@ -154,10 +171,11 @@ export const CREDITS_PENDING = 'credits: updating…';
|
|
|
154
171
|
* missing balance simply drops that segment.
|
|
155
172
|
*/
|
|
156
173
|
export function formatCreditsLine(s) {
|
|
157
|
-
|
|
174
|
+
// 2026-10-02: "this turn" = the whole user request, not its last round.
|
|
175
|
+
if (s.chargedThisRequest === null)
|
|
158
176
|
return CREDITS_PENDING;
|
|
159
177
|
const parts = [
|
|
160
|
-
`▲ ${formatCredits(s.
|
|
178
|
+
`▲ ${formatCredits(s.chargedThisRequest, s.unitLabel)} this turn`,
|
|
161
179
|
`${Math.round(s.sessionTotal).toLocaleString('en-US')} this session`,
|
|
162
180
|
];
|
|
163
181
|
if (s.balance !== null) {
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BYOK skill-router spend (2026-10-02, review nit).
|
|
3
|
+
*
|
|
4
|
+
* In BYOK the skill router runs on the laptop, on the customer's own key, so
|
|
5
|
+
* each free-text prompt costs them one small router call. That is their bill,
|
|
6
|
+
* so the USD meter must count it. The router call's model differs from the
|
|
7
|
+
* session model, so it is priced here at its own rate and added to the
|
|
8
|
+
* session total (computeSessionCost) — not mixed into `session.usage`, which
|
|
9
|
+
* is priced at the session model. Process-scoped. Managed never records here
|
|
10
|
+
* (the proxy's router cost is ours, never shown).
|
|
11
|
+
*/
|
|
12
|
+
import { computeCost } from './pricing.js';
|
|
13
|
+
let usd = 0;
|
|
14
|
+
export function addRouterSpend(model, usage) {
|
|
15
|
+
usd += computeCost(model, usage);
|
|
16
|
+
}
|
|
17
|
+
/** USD spent on BYOK router calls in this process. */
|
|
18
|
+
export function routerSpendUsd() {
|
|
19
|
+
return usd;
|
|
20
|
+
}
|
|
21
|
+
/** Test-only reset. */
|
|
22
|
+
export function __resetRouterSpendForTests() {
|
|
23
|
+
usd = 0;
|
|
24
|
+
}
|
|
@@ -17,8 +17,14 @@
|
|
|
17
17
|
* ran. Same caveat as the footer's session line.
|
|
18
18
|
*/
|
|
19
19
|
import { computeCost } from './pricing.js';
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
import { routerSpendUsd } from './router-spend.js';
|
|
21
|
+
/**
|
|
22
|
+
* USD cost of ONE session's lifetime usage, priced at `session.model` — no
|
|
23
|
+
* BYOK router spend. Use this for a single session's own spend (e.g. a
|
|
24
|
+
* reader child: read_agent adds it per call, so the process-wide router
|
|
25
|
+
* spend must not ride along — review S1, 2026-10-02).
|
|
26
|
+
*/
|
|
27
|
+
export function computeSessionModelCost(session) {
|
|
22
28
|
return computeCost(session.model, {
|
|
23
29
|
input: session.usage.input_tokens,
|
|
24
30
|
output: session.usage.output_tokens,
|
|
@@ -26,3 +32,11 @@ export function computeSessionCost(session) {
|
|
|
26
32
|
cacheCreate: session.usage.cache_creation_input_tokens ?? 0,
|
|
27
33
|
});
|
|
28
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* The session total the meter shows: the model cost plus (2026-10-02) the
|
|
37
|
+
* BYOK router calls made on the laptop, priced at the router model
|
|
38
|
+
* (cost/router-spend.ts; always 0 outside BYOK), counted once.
|
|
39
|
+
*/
|
|
40
|
+
export function computeSessionCost(session) {
|
|
41
|
+
return computeSessionModelCost(session) + routerSpendUsd();
|
|
42
|
+
}
|
|
@@ -276,9 +276,27 @@ export function servedToolLoading() {
|
|
|
276
276
|
export function managedProxyServesNewShapes() {
|
|
277
277
|
return snapshot?.tool_loading !== undefined;
|
|
278
278
|
}
|
|
279
|
-
|
|
279
|
+
// 2026-10-02 (BYOK local routing) — BYOK now classifies prompts on the laptop
|
|
280
|
+
// (direct to the model, the customer's key) instead of POSTing them to our
|
|
281
|
+
// /v1/classify. It uses the router model CSPeach serves, `roles.router`, kept
|
|
282
|
+
// from the SAME public /v1/model-config fetch as `tool_loading` above (no
|
|
283
|
+
// extra request; the fetch carries no prompt data). Only the router role is
|
|
284
|
+
// kept — the session roles still never steer a BYOK session.
|
|
285
|
+
/** The proxy's built-in router model (cspeach-proxy/src/models/config.ts). */
|
|
286
|
+
export const DEFAULT_BYOK_ROUTER_MODEL = 'claude-haiku-4-5';
|
|
287
|
+
let byokRouter;
|
|
288
|
+
/** Test-only setter for the BYOK router model. */
|
|
289
|
+
export function __setByokRouterModelForTests(v) {
|
|
290
|
+
byokRouter = v;
|
|
291
|
+
}
|
|
292
|
+
/** The router model for BYOK classification: the served `roles.router`, else the default. Pure sync read. */
|
|
293
|
+
export function byokRouterModel() {
|
|
294
|
+
return byokRouter ?? DEFAULT_BYOK_ROUTER_MODEL;
|
|
295
|
+
}
|
|
296
|
+
/** BYOK only: fetch `/v1/model-config` and keep its `tool_loading` (and, since 2026-10-02, `roles.router`). */
|
|
280
297
|
export async function fetchToolLoadingMode(cfg, getBearer, opts) {
|
|
281
298
|
byokToolLoading = undefined;
|
|
299
|
+
byokRouter = undefined;
|
|
282
300
|
if (cfg.llm?.mode !== 'byok')
|
|
283
301
|
return undefined;
|
|
284
302
|
const controller = new AbortController();
|
|
@@ -298,10 +316,12 @@ export async function fetchToolLoadingMode(cfg, getBearer, opts) {
|
|
|
298
316
|
return undefined;
|
|
299
317
|
const body = (await r.json());
|
|
300
318
|
byokToolLoading = coerceToolLoading(body && typeof body === 'object' ? body.tool_loading : undefined);
|
|
319
|
+
byokRouter = coerceRoles(body && typeof body === 'object' ? body.roles : undefined).router;
|
|
301
320
|
return byokToolLoading;
|
|
302
321
|
}
|
|
303
322
|
catch {
|
|
304
323
|
byokToolLoading = undefined;
|
|
324
|
+
byokRouter = undefined;
|
|
305
325
|
return undefined;
|
|
306
326
|
}
|
|
307
327
|
finally {
|