@cspeach/cli 1.1.24 → 1.1.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/dist/agent/loop.js +20 -8
  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/cost/cost-log.js +76 -4
  15. package/dist/cost/credits-wire.js +9 -0
  16. package/dist/cost/credits.js +24 -6
  17. package/dist/cost/router-spend.js +24 -0
  18. package/dist/cost/session-cost.js +16 -2
  19. package/dist/models/server-config.js +21 -1
  20. package/dist/one-shot.js +12 -2
  21. package/dist/projects/detail-path.js +117 -0
  22. package/dist/projects/merge-cca.js +19 -0
  23. package/dist/projects/merge-upgrade.js +19 -1
  24. package/dist/projects/plan-lane.js +47 -0
  25. package/dist/projects/plan-run.js +8 -0
  26. package/dist/projects/plan-schema.js +2 -0
  27. package/dist/projects/promote.js +7 -1
  28. package/dist/projects/save-command.js +3 -2
  29. package/dist/register/records.js +7 -2
  30. package/dist/renderer/footer-line.js +10 -4
  31. package/dist/renderer/status-footer.js +5 -4
  32. package/dist/renderer/tool-labels.js +2 -1
  33. package/dist/repl.js +41 -8
  34. package/dist/router/byok-classifier.js +159 -0
  35. package/dist/router/classifier.js +130 -6
  36. package/dist/router/router-core.js +307 -0
  37. package/dist/router/router-prompt.js +121 -0
  38. package/dist/router/routing-failure.js +65 -3
  39. package/dist/skills/bundled-skills.js +45 -45
  40. package/dist/tools/_filesystem-shared.js +363 -33
  41. package/dist/tools/cap/metadata-import.js +10 -4
  42. package/dist/tools/filesystem/file-write.js +12 -39
  43. package/dist/tools/filesystem/glob.js +25 -2
  44. package/dist/tools/filesystem/grep.js +25 -2
  45. package/dist/tools/fiori/apply.js +30 -21
  46. package/dist/tools/fiori/commit-gate.js +58 -0
  47. package/dist/tools/fiori/deploy/existing-bsp.js +67 -0
  48. package/dist/tools/fiori/deploy/run.js +608 -0
  49. package/dist/tools/fiori/deploy/scope.js +8 -0
  50. package/dist/tools/fiori/deploy/yaml-read.js +236 -0
  51. package/dist/tools/fiori/deploy-config.js +24 -6
  52. package/dist/tools/fiori/fe-extend.js +6 -1
  53. package/dist/tools/fiori/fe-scaffold.js +6 -1
  54. package/dist/tools/fiori/path-params.js +78 -0
  55. package/dist/tools/fiori/scaffold.js +2 -1
  56. package/dist/tools/fiori/tools.js +170 -19
  57. package/dist/tools/local-build.js +3 -0
  58. package/dist/tools/project/_merge-shared.js +36 -1
  59. package/dist/tools/project/cca_merge.js +4 -0
  60. package/dist/tools/project/upgrade_merge_progress.js +21 -10
  61. package/dist/tools/subagent/read_agent.js +4 -2
  62. package/dist/tools/tool-loading.js +1 -1
  63. 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
+ }
@@ -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}`);
@@ -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.
@@ -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
- /** Record a post-turn balance sample and return this turn's charge. */
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 = { ...this.state, chargedThisTurn: charged, sessionTotal, balance: current, pending: false };
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
- if (s.chargedThisTurn === null)
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.chargedThisTurn, s.unitLabel)} this turn`,
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
- /** USD cost of the session's lifetime usage, priced at `session.model`. */
21
- export function computeSessionCost(session) {
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
- /** BYOK only: fetch `/v1/model-config` and keep its `tool_loading`. */
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 {