@cspeach/cli 1.1.25 → 1.1.27
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 +28 -9
- 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/commands/whoami.js +1 -1
- 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/doctor/checks/skill.js +60 -11
- package/dist/doctor/run.js +0 -2
- package/dist/lane/app-chain.js +28 -0
- package/dist/models/server-config.js +21 -1
- package/dist/one-shot.js +12 -2
- package/dist/projects/extract-design.js +4 -3
- package/dist/projects/extract-estimate.js +4 -3
- package/dist/projects/extract-spec-gap.js +28 -5
- 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 +7 -3
- package/dist/projects/title-heading.js +36 -0
- package/dist/renderer/footer-line.js +10 -4
- package/dist/renderer/status-footer.js +5 -4
- package/dist/renderer/thinking-heartbeat.js +2 -1
- 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/sap/unreachable.js +4 -1
- 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 +121 -121
- package/dist/doctor/checks/zcspeach.js +0 -96
package/dist/commands/cost.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* No mutation, no network. Safe to run anytime.
|
|
10
10
|
*/
|
|
11
11
|
import chalk from 'chalk';
|
|
12
|
-
import { readCostLog, summarise } from '../cost/cost-log.js';
|
|
12
|
+
import { readCostLog, summarise, turnRows } from '../cost/cost-log.js';
|
|
13
13
|
import { formatCost } from '../cost/pricing.js';
|
|
14
14
|
import { creditsActive, formatCredits, globalCredits, CREDITS_PENDING, } from '../cost/credits.js';
|
|
15
15
|
import { panelHeading, panelRow } from '../renderer/panel-rows.js';
|
|
@@ -22,14 +22,12 @@ import { sanitizeControlChars } from '../renderer/sanitize.js';
|
|
|
22
22
|
* number — they are skipped, not counted as zero, and the caller says so.
|
|
23
23
|
*/
|
|
24
24
|
function summariseCredits(entries) {
|
|
25
|
+
// 2026-10-03: counted per TURN (turnRows) — a turn writes one line per call.
|
|
25
26
|
let total = 0;
|
|
26
27
|
let unknown = 0;
|
|
27
|
-
for (const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
continue;
|
|
31
|
-
if (typeof e.credits_charged === 'number')
|
|
32
|
-
total += e.credits_charged;
|
|
28
|
+
for (const r of turnRows(entries)) {
|
|
29
|
+
if (typeof r.credits === 'number')
|
|
30
|
+
total += r.credits;
|
|
33
31
|
else
|
|
34
32
|
unknown++;
|
|
35
33
|
}
|
|
@@ -52,8 +50,9 @@ export async function runCostCommand(sessionId, credits = globalCredits.snapshot
|
|
|
52
50
|
const s = summarise(entries);
|
|
53
51
|
// Credits mode: the customer is billed in credits at a multiplier we do not
|
|
54
52
|
// disclose, so NOTHING derived from cost/pricing.ts may be printed here —
|
|
55
|
-
// not the total, not the per-model split, not the per-turn figures.
|
|
56
|
-
//
|
|
53
|
+
// not the total, not the per-model split, not the per-turn figures.
|
|
54
|
+
// 2026-10-02 (owner): tokens go too. Not a COGS leak on their own, but beside
|
|
55
|
+
// credits they invite "credits per token" maths — credits only for managed.
|
|
57
56
|
const inCredits = creditsActive();
|
|
58
57
|
const perTurn = summariseCredits(entries);
|
|
59
58
|
const turnsNote = `${s.turns} turn${s.turns === 1 ? '' : 's'}`;
|
|
@@ -70,12 +69,14 @@ export async function runCostCommand(sessionId, credits = globalCredits.snapshot
|
|
|
70
69
|
else {
|
|
71
70
|
emit(panelHeading(`Session cost so far: ${formatCost(s.totalCost)}`, turnsNote));
|
|
72
71
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
72
|
+
if (!inCredits) {
|
|
73
|
+
emit('');
|
|
74
|
+
emit(chalk.bold('Tokens'));
|
|
75
|
+
emit(panelRow('input', s.totalTokens.input.toLocaleString().padStart(12)));
|
|
76
|
+
emit(panelRow('output', s.totalTokens.output.toLocaleString().padStart(12)));
|
|
77
|
+
emit(panelRow('cache read', s.totalTokens.cacheRead.toLocaleString().padStart(12)));
|
|
78
|
+
emit(panelRow('cache create', s.totalTokens.cacheCreate.toLocaleString().padStart(12)));
|
|
79
|
+
}
|
|
79
80
|
// Task 18 fix M3 — reader subagent spend (already inside the total above).
|
|
80
81
|
// USD only: in credits mode the balance delta already carries it.
|
|
81
82
|
if (s.readerCalls > 0 && !inCredits) {
|
|
@@ -99,22 +100,23 @@ export async function runCostCommand(sessionId, credits = globalCredits.snapshot
|
|
|
99
100
|
// Recent turns — last 5, most recent first. Piece 2: one count format, "N of M" (P1's countLabel).
|
|
100
101
|
// Final review I6 — reader rollup lines (interrupted turns) are not turns:
|
|
101
102
|
// they stay out of the list and the count (summarise() already skips them).
|
|
102
|
-
|
|
103
|
-
const
|
|
103
|
+
// 2026-10-03: one row per TURN (a turn writes one line per model call).
|
|
104
|
+
const rows = turnRows(entries);
|
|
105
|
+
const recent = rows.slice(-5).reverse();
|
|
104
106
|
emit('');
|
|
105
|
-
emit(chalk.bold(`Recent turns · ${countLabel(recent.length,
|
|
107
|
+
emit(chalk.bold(`Recent turns · ${countLabel(recent.length, rows.length)}`));
|
|
106
108
|
for (const e of recent) {
|
|
107
|
-
const tok =
|
|
109
|
+
const tok = e.tokens.toLocaleString();
|
|
108
110
|
// Credits mode: the per-turn figure is what the LEDGER charged (a balance
|
|
109
111
|
// delta recorded at turn end), never our computed USD. A turn whose
|
|
110
112
|
// balance sample failed shows the pending marker rather than a number we
|
|
111
113
|
// would have to invent.
|
|
112
114
|
const tail = inCredits
|
|
113
|
-
? (typeof e.
|
|
114
|
-
? formatCredits(e.
|
|
115
|
+
? (typeof e.credits === 'number'
|
|
116
|
+
? formatCredits(e.credits, credits?.unitLabel ?? 'credits')
|
|
115
117
|
: CREDITS_PENDING)
|
|
116
118
|
: formatCost(e.cost);
|
|
117
|
-
emit(panelRow(`turn ${String(e.turn).padStart(3)}`, `${tok.padStart(10)} tok ${tail}`, 8));
|
|
119
|
+
emit(panelRow(`turn ${String(e.turn).padStart(3)}`, inCredits ? tail : `${tok.padStart(10)} tok ${tail}`, 8));
|
|
118
120
|
}
|
|
119
121
|
if (inCredits && perTurn.unknown > 0) {
|
|
120
122
|
emit('');
|
|
@@ -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/commands/whoami.js
CHANGED
|
@@ -28,7 +28,7 @@ export async function runWhoamiCommand(deps) {
|
|
|
28
28
|
deps.print(formatFailure({
|
|
29
29
|
what: 'Could not read your account',
|
|
30
30
|
detail: [unreachable ? `could not reach the server (${me.error})` : `the server answered ${me.status} (${me.error})`],
|
|
31
|
-
next: unreachable ? 'check
|
|
31
|
+
next: unreachable ? 'check this machine can reach cspeach.dev (company network or proxy settings), then run cspeach whoami again' : 'cspeach login',
|
|
32
32
|
}).trimEnd());
|
|
33
33
|
return 1;
|
|
34
34
|
}
|
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.
|