@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.
Files changed (55) hide show
  1. package/dist/agent/loop.js +28 -9
  2. package/dist/agent/tool-dispatch.js +6 -0
  3. package/dist/auth/auth-file.js +173 -12
  4. package/dist/classifier/client.js +55 -30
  5. package/dist/cli.js +11 -0
  6. package/dist/commands/config-set.js +13 -0
  7. package/dist/commands/config-show.js +5 -1
  8. package/dist/commands/cost.js +24 -22
  9. package/dist/commands/deploy-card.js +135 -0
  10. package/dist/commands/login.js +31 -13
  11. package/dist/commands/plan-chain.js +31 -0
  12. package/dist/commands/plan-continue.js +1 -0
  13. package/dist/commands/plan-resume.js +49 -2
  14. package/dist/commands/whoami.js +1 -1
  15. package/dist/cost/cost-log.js +76 -4
  16. package/dist/cost/credits-wire.js +9 -0
  17. package/dist/cost/credits.js +24 -6
  18. package/dist/cost/router-spend.js +24 -0
  19. package/dist/cost/session-cost.js +16 -2
  20. package/dist/doctor/checks/skill.js +60 -11
  21. package/dist/doctor/run.js +0 -2
  22. package/dist/lane/app-chain.js +28 -0
  23. package/dist/models/server-config.js +21 -1
  24. package/dist/one-shot.js +12 -2
  25. package/dist/projects/extract-design.js +4 -3
  26. package/dist/projects/extract-estimate.js +4 -3
  27. package/dist/projects/extract-spec-gap.js +28 -5
  28. package/dist/projects/plan-lane.js +47 -0
  29. package/dist/projects/plan-run.js +8 -0
  30. package/dist/projects/plan-schema.js +2 -0
  31. package/dist/projects/save-command.js +7 -3
  32. package/dist/projects/title-heading.js +36 -0
  33. package/dist/renderer/footer-line.js +10 -4
  34. package/dist/renderer/status-footer.js +5 -4
  35. package/dist/renderer/thinking-heartbeat.js +2 -1
  36. package/dist/renderer/tool-labels.js +2 -1
  37. package/dist/repl.js +41 -8
  38. package/dist/router/byok-classifier.js +159 -0
  39. package/dist/router/classifier.js +130 -6
  40. package/dist/router/router-core.js +307 -0
  41. package/dist/router/router-prompt.js +121 -0
  42. package/dist/router/routing-failure.js +65 -3
  43. package/dist/sap/unreachable.js +4 -1
  44. package/dist/skills/bundled-skills.js +45 -45
  45. package/dist/tools/fiori/deploy/existing-bsp.js +67 -0
  46. package/dist/tools/fiori/deploy/run.js +608 -0
  47. package/dist/tools/fiori/deploy/scope.js +8 -0
  48. package/dist/tools/fiori/deploy/yaml-read.js +236 -0
  49. package/dist/tools/fiori/deploy-config.js +24 -6
  50. package/dist/tools/fiori/tools.js +117 -5
  51. package/dist/tools/local-build.js +3 -0
  52. package/dist/tools/subagent/read_agent.js +4 -2
  53. package/dist/tools/tool-loading.js +1 -1
  54. package/package.json +121 -121
  55. package/dist/doctor/checks/zcspeach.js +0 -96
@@ -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 e of entries) {
28
- // Final review I6 — a zero-token reader rollup line is not a turn.
29
- if (e.kind === 'reader_rollup')
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. Tokens
56
- // stay: they are a usage fact, not a COGS leak.
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
- 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)));
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
- const turnEntries = entries.filter((e) => e.kind !== 'reader_rollup');
103
- const recent = turnEntries.slice(-5).reverse();
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, turnEntries.length)}`));
107
+ emit(chalk.bold(`Recent turns · ${countLabel(recent.length, rows.length)}`));
106
108
  for (const e of recent) {
107
- const tok = (e.tokens.input + e.tokens.output).toLocaleString();
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.credits_charged === 'number'
114
- ? formatCredits(e.credits_charged, credits?.unitLabel ?? 'credits')
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
+ }
@@ -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', prompt for the user's own API key
11
- * 7) Re-save auth file with byokAnthropicKey if provided
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(ANTHROPIC_KEYCHAIN_SERVICE, ANTHROPIC_KEYCHAIN_ACCOUNT, orgKey);
115
- deps.print(done("Using your organization's API key"));
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
- // The non-BYOK save above just persisted these exact values,
125
- // so reading them back from disk would be redundant.
126
- await writeAuthFile({
127
- apiKey: polled.api_key,
128
- customerId: polled.customer_id,
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
- const conflict = checkLaneConsistency(extract.content, args.prepared.laneRecord);
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}`);
@@ -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 your network or VPN, then run cspeach whoami again' : 'cspeach login',
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
  }
@@ -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
- let lines = 0;
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
- lines += 1;
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
- slot.turns += 1;
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: lines,
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.