@cspeach/cli 1.1.25 → 1.1.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) 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/plan-lane.js +47 -0
  22. package/dist/projects/plan-run.js +8 -0
  23. package/dist/projects/plan-schema.js +2 -0
  24. package/dist/projects/save-command.js +3 -2
  25. package/dist/renderer/footer-line.js +10 -4
  26. package/dist/renderer/status-footer.js +5 -4
  27. package/dist/renderer/tool-labels.js +2 -1
  28. package/dist/repl.js +41 -8
  29. package/dist/router/byok-classifier.js +159 -0
  30. package/dist/router/classifier.js +130 -6
  31. package/dist/router/router-core.js +307 -0
  32. package/dist/router/router-prompt.js +121 -0
  33. package/dist/router/routing-failure.js +65 -3
  34. package/dist/skills/bundled-skills.js +45 -45
  35. package/dist/tools/fiori/deploy/existing-bsp.js +67 -0
  36. package/dist/tools/fiori/deploy/run.js +608 -0
  37. package/dist/tools/fiori/deploy/scope.js +8 -0
  38. package/dist/tools/fiori/deploy/yaml-read.js +236 -0
  39. package/dist/tools/fiori/deploy-config.js +24 -6
  40. package/dist/tools/fiori/tools.js +117 -5
  41. package/dist/tools/local-build.js +3 -0
  42. package/dist/tools/subagent/read_agent.js +4 -2
  43. package/dist/tools/tool-loading.js +1 -1
  44. package/package.json +1 -1
@@ -27,10 +27,10 @@ import { turnStatusEmitter } from '../ui/turn-status-emitter.js';
27
27
  // (progress-chatter import removed 2026-05-01 — superseded by CC-style
28
28
  // two-line dispatch renderer; re-add if a future in-place spinner returns)
29
29
  import { buildRetryCapPausePayload, buildSkippedSiblingResults } from './retry-cap.js';
30
- import { appendCostLine, buildEntry as buildCostEntry, summariseInputTransformations } from '../cost/cost-log.js';
30
+ import { appendCostLine, buildEntry as buildCostEntry, perCallTokens, summariseInputTransformations } from '../cost/cost-log.js';
31
31
  import { modelAcceptsBinding, sessionMaxTokens, clampMaxTokens } from './provider-shape.js';
32
32
  import { computeSessionCost } from '../cost/session-cost.js';
33
- import { refreshCreditsAfterTurn, creditsForCostLog } from '../cost/credits-wire.js';
33
+ import { refreshCreditsAfterTurn, creditsForCostLog, beginCreditsRequest } from '../cost/credits-wire.js';
34
34
  import { globalStore } from '../ui/sap-state-store.js';
35
35
  import { lastAssistantIsAwaitingAnswer } from '../session/awaiting-answer.js';
36
36
  import { enrichUserMessage } from '../router/intent-extractor.js';
@@ -835,6 +835,15 @@ ${imageExpansion.error}`));
835
835
  cacheRead: session.usage?.cache_read_input_tokens ?? 0,
836
836
  cacheCreate: session.usage?.cache_creation_input_tokens ?? 0,
837
837
  };
838
+ // 2026-10-02 — the per-turn receipt shows what this whole user request cost
839
+ // (every model round below), not just its last round. A reader child never
840
+ // marks a request: its spend belongs to the parent's.
841
+ if (!readerChild)
842
+ beginCreditsRequest();
843
+ // 2026-10-03 — each cost line records ONLY its own model call: tokens since
844
+ // the previous line, not since turn start (Task 3 wrote running totals, so
845
+ // /cost, the audit export and the context meter over-counted).
846
+ let lastLoggedTokens = { ...turnStartTokens };
838
847
  // Turn number for the cost log — count of prior user messages + this one.
839
848
  const turnNumber = session.messages.filter((m) => m.role === 'user').length + 1;
840
849
  // Final review I4 — the monotonic per-session turn counter the auto-compact
@@ -1438,12 +1447,14 @@ ${imageExpansion.error}`));
1438
1447
  // race on the shared sample.
1439
1448
  if (!readerChild)
1440
1449
  await refreshCreditsAfterTurn();
1441
- const turnTokens = {
1442
- input: (session.usage?.input_tokens ?? 0) - turnStartTokens.input,
1443
- output: (session.usage?.output_tokens ?? 0) - turnStartTokens.output,
1444
- cacheRead: (session.usage?.cache_read_input_tokens ?? 0) - turnStartTokens.cacheRead,
1445
- cacheCreate: (session.usage?.cache_creation_input_tokens ?? 0) - turnStartTokens.cacheCreate,
1450
+ const nowTokens = {
1451
+ input: session.usage?.input_tokens ?? 0,
1452
+ output: session.usage?.output_tokens ?? 0,
1453
+ cacheRead: session.usage?.cache_read_input_tokens ?? 0,
1454
+ cacheCreate: session.usage?.cache_creation_input_tokens ?? 0,
1446
1455
  };
1456
+ const callTokens = perCallTokens(nowTokens, lastLoggedTokens);
1457
+ lastLoggedTokens = nowTokens;
1447
1458
  const turnDurationMs = Date.now() - turnStartedAt;
1448
1459
  const entry = buildCostEntry({
1449
1460
  turn: turnNumber,
@@ -1451,7 +1462,8 @@ ${imageExpansion.error}`));
1451
1462
  // (plan model tiering), the cost line must record the ACTUAL model
1452
1463
  // or the Sonnet-priced turn would be billed at session-model rates.
1453
1464
  model: params.modelOverride ?? session.model,
1454
- tokens: turnTokens,
1465
+ tokens: callTokens,
1466
+ scope: 'call',
1455
1467
  duration_ms: turnDurationMs,
1456
1468
  // Additive: present only in credits mode (undefined in USD mode, so
1457
1469
  // the emitted line is byte-identical to a pre-credits one).
@@ -146,6 +146,12 @@ function declined(r) {
146
146
  export function isLocalProjectWork(tool, args) {
147
147
  if (tool.category === 'filesystem')
148
148
  return true;
149
+ // 2026-10-02 — fiori_deploy mode "test" (the default) writes nothing to SAP:
150
+ // the backend validates the upload and keeps nothing. Not a batch write, so
151
+ // a deploy phase shows ONE approval (the real deploy's request_approval),
152
+ // not a batch card before it. Mode "deploy" is gated exactly as before.
153
+ if (tool.name === 'fiori_deploy')
154
+ return (args?.mode ?? 'test') === 'test';
149
155
  if (tool.category !== 'shell')
150
156
  return false;
151
157
  const argv = [args?.command, ...(Array.isArray(args?.args) ? args.args : [])].map((a) => String(a ?? ''));
@@ -1,6 +1,11 @@
1
1
  /**
2
- * Read/write ~/.cspeach/auth.json — the customer's CSPeach API key
3
- * + optional Anthropic key for BYOK trials.
2
+ * Read/write ~/.cspeach/auth.json — the customer's CSPeach API key.
3
+ *
4
+ * 2026-10-02: auth.json no longer holds an Anthropic key. `cspeach login` used
5
+ * to write a hand-pasted BYOK key here in PLAINTEXT (`byokAnthropicKey`), and
6
+ * nothing read it — BYOK reads the OS keychain (cspeach.anthropic/api-key).
7
+ * A pasted key now goes to the keychain, and migrateLegacyByokKey (run at
8
+ * startup) moves an old plaintext copy there and deletes it from this file.
4
9
  *
5
10
  * Permissions: 0600 on POSIX. Windows uses NTFS ACLs which Node's
6
11
  * fs mode flag doesn't translate; we rely on the user-dir default
@@ -29,11 +34,7 @@ export async function readAuthFile() {
29
34
  const parsed = JSON.parse(raw);
30
35
  if (typeof parsed.apiKey !== 'string' || typeof parsed.customerId !== 'string')
31
36
  return null;
32
- return {
33
- apiKey: parsed.apiKey,
34
- customerId: parsed.customerId,
35
- byokAnthropicKey: typeof parsed.byokAnthropicKey === 'string' ? parsed.byokAnthropicKey : null,
36
- };
37
+ return { apiKey: parsed.apiKey, customerId: parsed.customerId };
37
38
  }
38
39
  catch {
39
40
  return null;
@@ -42,18 +43,178 @@ export async function readAuthFile() {
42
43
  export async function writeAuthFile(auth) {
43
44
  const p = authFilePath();
44
45
  await fs.mkdir(path.dirname(p), { recursive: true });
45
- await fs.writeFile(p, JSON.stringify(auth, null, 2), { encoding: 'utf-8', mode: 0o600 });
46
- // Defensive: re-chmod in case the create-time mode was ignored (e.g., umask 022).
47
- if (process.platform !== 'win32') {
46
+ // Only the two known fields — never a secret a caller passes along by mistake.
47
+ const body = { apiKey: auth.apiKey, customerId: auth.customerId };
48
+ await writeAtomic0600(p, JSON.stringify(body, null, 2));
49
+ }
50
+ /** Rename errors that mean "the target is open elsewhere" (Windows), worth retrying. */
51
+ const LOCK_CODES = new Set(['EPERM', 'EACCES', 'EBUSY']);
52
+ /** Backoff between rename attempts (5 tries in all). */
53
+ const RENAME_BACKOFF_MS = [50, 100, 150, 200];
54
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
55
+ /**
56
+ * 2026-10-02 (review nits): write via a temp file + rename, so a crash
57
+ * mid-write never leaves a truncated auth.json (losing the sign-in). 0600 on
58
+ * POSIX.
59
+ *
60
+ * Review S3: on Windows a rename over an auth.json another process holds open
61
+ * (antivirus, a second cspeach, an editor) fails with EPERM / EACCES / EBUSY.
62
+ * Retry 5 times with a short backoff, then fall back to an in-place write.
63
+ * The temp file holds the API key, so it is always removed.
64
+ */
65
+ async function writeAtomic0600(p, text) {
66
+ const tmp = `${p}.${process.pid}.tmp`;
67
+ try {
68
+ await fs.writeFile(tmp, text, { encoding: 'utf-8', mode: 0o600 });
69
+ // Defensive: re-chmod in case the create-time mode was ignored (e.g., umask 022).
70
+ if (process.platform !== 'win32') {
71
+ try {
72
+ await fs.chmod(tmp, 0o600);
73
+ }
74
+ catch { /* ignore */ }
75
+ }
76
+ for (let attempt = 0;; attempt++) {
77
+ try {
78
+ await fs.rename(tmp, p);
79
+ return;
80
+ }
81
+ catch (err) {
82
+ const code = err.code ?? '';
83
+ if (!LOCK_CODES.has(code))
84
+ throw err;
85
+ if (attempt >= RENAME_BACKOFF_MS.length)
86
+ break;
87
+ await sleep(RENAME_BACKOFF_MS[attempt]);
88
+ }
89
+ }
90
+ // Still locked: write in place (not atomic, but the sign-in is saved).
91
+ await fs.writeFile(p, text, { encoding: 'utf-8', mode: 0o600 });
92
+ if (process.platform !== 'win32') {
93
+ try {
94
+ await fs.chmod(p, 0o600);
95
+ }
96
+ catch { /* ignore */ }
97
+ }
98
+ }
99
+ finally {
48
100
  try {
49
- await fs.chmod(p, 0o600);
101
+ await fs.unlink(tmp);
50
102
  }
51
- catch { /* ignore */ }
103
+ catch { /* already renamed or never written */ }
52
104
  }
53
105
  }
106
+ /**
107
+ * Review S3: remove auth.json.*.tmp files a crashed write left behind — they
108
+ * hold the API key in plain text. Run at startup. Only files older than a
109
+ * minute, so another cspeach's in-progress write is never touched. Never throws.
110
+ */
111
+ const STALE_TEMP_MS = 60_000;
112
+ export async function cleanStaleAuthTemps() {
113
+ try {
114
+ const p = authFilePath();
115
+ const dir = path.dirname(p);
116
+ const base = path.basename(p);
117
+ for (const name of await fs.readdir(dir)) {
118
+ if (!(name.startsWith(`${base}.`) && name.endsWith('.tmp')))
119
+ continue;
120
+ const f = path.join(dir, name);
121
+ try {
122
+ // Older than a minute: not another cspeach's write in progress.
123
+ if (Date.now() - (await fs.stat(f)).mtimeMs < STALE_TEMP_MS)
124
+ continue;
125
+ await fs.unlink(f);
126
+ }
127
+ catch { /* ignore */ }
128
+ }
129
+ }
130
+ catch { /* no ~/.cspeach yet */ }
131
+ }
54
132
  export async function deleteAuthFile() {
55
133
  try {
56
134
  await fs.unlink(authFilePath());
57
135
  }
58
136
  catch { /* ignore */ }
59
137
  }
138
+ /** The BYOK key's keychain entry — the same one byok-provider.ts reads and login writes. */
139
+ const BYOK_KEYCHAIN_SERVICE = 'cspeach.anthropic';
140
+ const BYOK_KEYCHAIN_ACCOUNT = 'api-key';
141
+ /**
142
+ * 2026-10-02 — move a plaintext `byokAnthropicKey` left in auth.json by an
143
+ * older `cspeach login` into the OS keychain, then delete it from the file.
144
+ *
145
+ * 'none' nothing to do (no file, or no string key in it) — the keychain
146
+ * is not touched;
147
+ * 'migrated' the keychain had no key: the file's key was stored there and
148
+ * deleted from the file;
149
+ * 'removed' the keychain already held the SAME key: only the plaintext
150
+ * copy is deleted;
151
+ * 'conflict' the keychain holds a DIFFERENT key (the one BYOK uses). The
152
+ * pasted key may be the only copy, so nothing is deleted — the
153
+ * caller shows one notice (review nit);
154
+ * 'keychain-unavailable' the keychain could not be read or written (e.g.
155
+ * Linux without a Secret Service): the file is left as-is so the
156
+ * key is not lost — the caller shows one notice;
157
+ * 'failed' the file could not be rewritten (retried on the next start).
158
+ * Never throws.
159
+ */
160
+ export async function migrateLegacyByokKey(deps) {
161
+ let raw;
162
+ try {
163
+ const parsed = JSON.parse(await fs.readFile(authFilePath(), 'utf-8'));
164
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
165
+ return 'none';
166
+ raw = parsed;
167
+ }
168
+ catch {
169
+ return 'none';
170
+ }
171
+ const legacy = raw.byokAnthropicKey;
172
+ if (typeof legacy !== 'string' || legacy.trim() === '')
173
+ return 'none';
174
+ const pasted = legacy.trim();
175
+ let outcome;
176
+ try {
177
+ const d = deps ?? (await defaultKeychainDeps());
178
+ const existing = await d.getKey(BYOK_KEYCHAIN_SERVICE, BYOK_KEYCHAIN_ACCOUNT);
179
+ if (existing) {
180
+ if (existing.trim() !== pasted)
181
+ return 'conflict';
182
+ outcome = 'removed';
183
+ }
184
+ else {
185
+ await d.setKey(BYOK_KEYCHAIN_SERVICE, BYOK_KEYCHAIN_ACCOUNT, pasted);
186
+ outcome = 'migrated';
187
+ }
188
+ }
189
+ catch {
190
+ return 'keychain-unavailable';
191
+ }
192
+ try {
193
+ const { byokAnthropicKey: _dropped, ...rest } = raw;
194
+ await writeAtomic0600(authFilePath(), JSON.stringify(rest, null, 2));
195
+ }
196
+ catch {
197
+ return 'failed';
198
+ }
199
+ return outcome;
200
+ }
201
+ /** The one-line startup notice for an outcome the user must know about, else null. */
202
+ export function legacyByokKeyNotice(outcome) {
203
+ switch (outcome) {
204
+ case 'conflict':
205
+ return 'cspeach: ~/.cspeach/auth.json still holds a pasted Anthropic API key in plain text that is different from the one in your OS keychain (the one CSPeach uses). It was kept in case it is your only copy — remove the "byokAnthropicKey" line from auth.json once you no longer need it.';
206
+ case 'keychain-unavailable':
207
+ return 'cspeach: ~/.cspeach/auth.json still holds your Anthropic API key in plain text — the OS keychain is not available, so it could not be moved there (on Linux: install and unlock a Secret Service keyring such as gnome-keyring). CSPeach will move it on the next start once the keychain works.';
208
+ default:
209
+ return null;
210
+ }
211
+ }
212
+ async function defaultKeychainDeps() {
213
+ // Loaded only when there is something to migrate — most starts never pay
214
+ // for the native keychain module here.
215
+ const { default: keychain } = await import('./keychain.js');
216
+ return {
217
+ getKey: (s, a) => keychain.getPassword(s, a),
218
+ setKey: (s, a, k) => keychain.setPassword(s, a, k),
219
+ };
220
+ }
@@ -1,3 +1,7 @@
1
+ /** Confidence for a `strong` match (≥ 0.85 ⇒ silent routing). */
2
+ export const STRONG_CONFIDENCE = 0.85;
3
+ /** Confidence for any other match (coach: preselected, the user confirms). */
4
+ export const RULE_CONFIDENCE = 0.6;
1
5
  const RULES = [
2
6
  // abap-app (app lane routing slice 1, 2026-10-01) — building an app FROM a
3
7
  // spec / requirements document. FIRST so a spec-document build beats every
@@ -10,9 +14,11 @@ const RULES = [
10
14
  /^(?![\s\S]*\bspec\s+gaps?\b)[\s\S]*?\b(build|create|make|develop)\b[^.?!\n]{0,60}\b(app|application)\b[^.?!\n]{0,60}(\bspec(s|ification)?\b|\brequirements?\s+(doc|document|file|sheet)\b|\.(docx|pdf|xlsx)\b)/i,
11
15
  /^(?![\s\S]*\bspec\s+gaps?\b)[\s\S]*?(\bspec(s|ification)?\b|\brequirements?\s+(doc|document|file|sheet)\b|\.(docx|pdf|xlsx)\b)[^.?!\n]{0,60}\b(build|create|make|develop)\b[^.?!\n]{0,30}\b(app|application)\b/i,
12
16
  ] },
13
- { skill: 'abap-atc-fix', patterns: [/atc\s+(fix|auto\s+fix)|abap\s+test\s+cockpit.*fix/i] },
17
+ { skill: 'abap-atc-fix', patterns: [/atc\s+(fix|auto\s+fix)|abap\s+test\s+cockpit.*fix|\brun\s+(the\s+)?atc\b|\batc\s+findings\b|\bfix\s+(the|all|these|those)\s+findings\b/i] },
14
18
  { skill: 'abap-cca', patterns: [/custom\s+code\s+analysis|\bcca\b|clean\s+core\s+(assessment|analysis)/i] },
15
- { skill: 'abap-cloud-check', patterns: [/cloud\s+ready|clean\s+core(?!\s+(assessment|analysis))|s\/4hana\s+cloud\s+check/i] },
19
+ // abap-clean-core (2026-10-02) — took over the retired abap-cloud-check's
20
+ // "clean core" phrasing; "cloud ready" grading went to abap-review (cloud lens).
21
+ { skill: 'abap-clean-core', patterns: [/clean.?core(?!\s+(assessment|analysis))|\breleased\s+apis?\b/i] },
16
22
  // abap-cap (2026-10-01) MUST precede abap-data-model ("CDS domain model"
17
23
  // would hit its bare `domain`) and abap-fiori-build / abap-rap (a CAP app
18
24
  // ships a Fiori Elements UI). Side-by-side / BTP framing → CAP.
@@ -22,31 +28,48 @@ const RULES = [
22
28
  // A bare BTP mention counts only when no RAP/ABAP/Fiori/UI5/Z-object word
23
29
  // appears anywhere in the prompt.
24
30
  { skill: 'abap-cap', patterns: [/\b(sap\s+)?cap\b(?=\s+(app|application|project|service|model|backend|extension|handler)s?\b)|\b(in|with|using|use)\s+(sap\s+)?cap\b|cloud\s+application\s+programming|\bcds\s+(model|service)\b(?!\s+(definition|[zy]\w*))|side.?by.?side\s+(app|application|extension)|^(?![\s\S]*\b(rap|abap|fiori|ui5|[zy]\w*_\w+)\b)[\s\S]*\bbtp\b/i] },
25
- { skill: 'abap-data-model', patterns: [/data\s+model|ddic\s+(design|structure)|domain|data\s+element|create\s+(a\s+)?z[_\w]*\s+(table|structure)|new\s+(z[_\w]*\s+)?(table|structure|domain)/i] },
31
+ { skill: 'abap-data-model', patterns: [/data\s+model|ddic\s+(design|structure)|domain|data\s+element|create\s+(a\s+)?z[_\w]*\s+(table|structure)|new\s+(z[_\w]*\s+)?(table|structure|domain)|\bcreate\s+(a\s+|an\s+|new\s+)?(z\w*\s+)?(table|structure)\b/i] },
26
32
  { skill: 'abap-design', patterns: [/solution\s+architecture|object\s+list|design\s+the\s+solution/i] },
27
- { skill: 'abap-handover', patterns: [/technical\s+documentation|object\s+catalog|doc(ument)?\s+package|handover/i] },
28
- { skill: 'abap-document', patterns: [/developer\s+documentation|document\s+this\s+(class|program|object)/i] },
29
33
  // abap-incident MUST precede dump rules — pipeline framing beats keyword-only matches
30
34
  { skill: 'abap-incident', patterns: [/incident|resolve.*dump.*to.*fix|pipeline.*(short\s+dump|st22|dump)|(dump|st22).*to.*transport|formal\s+(incident|dump)\s+report|end.to.end.*dump/i] },
31
35
  // abap-dump (2026-06-12) — merger of abap-dump-analyze + abap-dump-triage.
32
36
  // Triage-vs-fix is an intent decision INSIDE the skill now, so both
33
37
  // pattern sets route here.
34
- { skill: 'abap-dump', patterns: [/short\s+dump|runtime\s+error|\bst22\b|dump\s+(analys|triage|forensic)|structured\s+investigation.*dump/i] },
38
+ { skill: 'abap-dump', strong: [/short\s+dump|\bst22\b|\bcx_sy_\w+/i],
39
+ patterns: [/runtime\s+error|dump\s+(analys|triage|forensic)|structured\s+investigation.*dump|\bdump(s|ed|ing)?\b/i] },
35
40
  { skill: 'abap-eml', patterns: [/\beml\b|entity\s+manipulation|behavior\s+handler/i] },
36
- { skill: 'abap-enhance', patterns: [/\bbadi\b|enhancement\s+spot|implicit\s+enhancement|customer\s+exit|user\s+exit|(hook\s+into|extend)\s+(sap|standard|me\d+|migo|miro|va\d+|vf\d+|vl\d+|fb\d+)/i] },
37
- { skill: 'abap-estimate', patterns: [/effort\s+estimat|ticket\s+estimat|size\s+this\s+(work|task|ticket|story)|how\s+(many|long).*(hours|days).*(build|develop|implement)/i] },
38
- { skill: 'abap-explain', patterns: [/\bexplain\b|what\s+does\s+this\s+(do|code)|walk.*through/i] },
41
+ { skill: 'abap-enhance', strong: [/\bbadis?\b|enhancement\s+spot|customer\s+exit|user\s+exit/i],
42
+ patterns: [/implicit\s+enhancement|(hook\s+into|extend)\s+(sap|standard|me\d+|migo|miro|va\d+|vf\d+|vl\d+|fb\d+)/i] },
43
+ { skill: 'abap-estimate', patterns: [/\bestimat(e|es|ion)\b|effort\s+estimat|size\s+this\s+(work|task|ticket|story)|how\s+(many|long).*(hours|days).*(build|develop|implement)|how\s+long\s+(will|would)\s+it\s+take|how\s+long\s+to\s+(build|develop|implement|do)\b/i] },
44
+ // abap-extend-model (2026-10-02) MUST precede abap-generate ("add … to Z…")
45
+ // and abap-rap ("behavior"). Only CDS / RAP targets — Z?_ entity names
46
+ // (ZR_/ZI_/ZC_), a CDS view, the app or list — so "add a validation to
47
+ // ZCL_ORDER_SERVICE" (a class) and "add a column to ZSD_REPORT" (a classic
48
+ // report) stay on the ABAP code skills.
49
+ { skill: 'abap-extend-model', patterns: [
50
+ /\badd\b[^.?!\n]{0,40}\b(actions?|validations?|determinations?)\b[^.?!\n]{0,40}\bto\s+(the\s+|my\s+)?((z|y)[ric]_\w+|(rap\s+)?(bo|business\s+object|bdef|behaviou?r(\s+definition)?)\b)/i,
51
+ /\badd\b[^.?!\n]{0,40}\b(columns?|fields?)\b[^.?!\n]{0,40}\b(to|in)\s+(the\s+|my\s+)?((z|y)[ric]_\w+|cds\b|projection\b|ddlx\b|metadata\s+extension\b|list(\s+report)?\b|(fiori\s+)?app\b)/i,
52
+ /\bshow\b[^.?!\n]{0,40}\bin\s+(the\s+|my\s+)(list(\s+report)?|(fiori\s+)?app|object\s+page)\b/i,
53
+ /\bexpose\b[^.?!\n]{0,40}\baction\b[^.?!\n]{0,40}\bprojection\b|\btoolbar\s+button\b/i,
54
+ ] },
55
+ // abap-explain also takes the retired abap-radar's phrasing (technical orientation).
56
+ { skill: 'abap-explain', patterns: [/\bexplain\b|what\s+does\s+this\s+(do|code)|walk.*through|what\s+does\s+(z|y)\w+\s+do\b|\bradar\b|quick\s+look|overview\s+of\s+(this\s+)?(class|program|object)/i] },
39
57
  // abap-test MUST precede abap-generate so "generate tests" routes to the test skill
40
58
  // (test-gen merged into abap-test on 2026-05-02 — phrases preserved here).
41
- { skill: 'abap-test', patterns: [/unit\s+test|abap\s+unit|test\s+(class|doubles)|generate\s+tests|test\s+generation|improve\s+test\s+coverage/i] },
59
+ { skill: 'abap-test', strong: [/unit\s+tests?|abap\s+unit/i],
60
+ patterns: [/test\s+(class|doubles)|generate\s+tests|test\s+generation|improve\s+test\s+coverage/i] },
42
61
  // abap-modernize MUST precede abap-rap so "rebuild as RAP" / "convert to RAP" stays
43
62
  // on modernize (rebuild intent) instead of falling to abap-rap's bare \brap\b match.
44
- { skill: 'abap-modernize', patterns: [/modernise|modernize|classic.*to.*modern|convert.*to\s+(rap|fiori|odata)|rebuild.*as\s+(rap|fiori)/i] },
63
+ { skill: 'abap-modernize', patterns: [/modernise|modernize|classic.*(to|into).*modern|(convert|turn)\b.*\b(to|into)\s+(an?\s+)?(rap|fiori|odata)|rebuild.*as\s+(rap|fiori)/i] },
45
64
  // abap-fiori-build (D12, 2026-06-12) MUST follow abap-modernize ("convert
46
65
  // this WRITE report to Fiori" = rebuild intent) and precede abap-rap so
47
66
  // "fiori app for my RAP/OData service" lands on the UI skill, not the
48
67
  // backend scaffolder.
49
68
  { skill: 'abap-fiori-build', patterns: [/\bfiori\b|\b(sap)?ui5\b|freestyle\s+app|\bui\s+app\b|front.?end\s+(for|on)\s/i] },
69
+ // abap-impact (2026-10-02) MUST precede abap-generate: "before I change
70
+ // ZSD_X, what depends on it?" is an impact question, not an edit.
71
+ { skill: 'abap-impact', strong: [/where[\s-]?used/i],
72
+ patterns: [/impact\s+analysis|where.*used|what.*affected\s+by|what\s+(breaks|would\s+break|depends\s+on)|before\s+i\s+(change|touch|modify)/i] },
50
73
  // abap-rap MUST precede abap-generate so RAP-stack prompts route to rap, not the
51
74
  // generic generator (cluster I, 2026-05-03). Also captures "scaffold a CDS view +
52
75
  // BDEF + service binding" type prompts which collide with abap-generate.
@@ -60,43 +83,45 @@ const RULES = [
60
83
  /\badd\s+(a\s+|an\s+)?[\w\s-]{1,40}\bto\s+z[_a-z0-9]+/i,
61
84
  /\b(change|fix|update|modify|adjust)\s+(the\s+)?z[_a-z0-9]+\b/i,
62
85
  ] },
63
- { skill: 'abap-impact', patterns: [/impact\s+analysis|where.*used|what.*affected\s+by/i] },
64
86
  // abap-upgrade-* must precede abap-migrate
65
87
  { skill: 'abap-upgrade-verify', patterns: [/upgrade\s+verify|remediation\s+report|verify\s+upgrade/i] },
66
88
  { skill: 'abap-upgrade-fix', patterns: [/upgrade\s+fix|atc\s+remediation|fix\s+upgrade\s+finding/i] },
67
89
  { skill: 'abap-upgrade-scan', patterns: [/upgrade\s+scan|scan.*s\/4|readiness\s+variant/i] },
68
90
  { skill: 'abap-migrate', patterns: [/ecc.*migration|migrate.*s\/4|api\s+successor/i] },
69
- { skill: 'abap-performance', patterns: [/performance|\bn\+1\b|select\s+inside\s+loop|slow\s+(query|select)/i] },
70
91
  // abap-plan (B4, 2026-06-06): deliberately NO bare \bplan\b — "I plan to
71
92
  // refactor X" must keep falling through to the refactor/explain rules.
72
93
  // Multi-session / project-plan framing only; refine after real prompts.
73
94
  { skill: 'abap-plan', patterns: [/project\s+plan|implementation\s+plan|plan\s+(this|the|my)\s+(project|build|development)|multi.session\s+(project|plan|build)|resume\s+(the\s+|my\s+)?plan|step.by.step\s+plan/i] },
74
95
  { skill: 'abap-preflight', patterns: [/preflight|pre.release|go\/no.go|release\s+readiness/i] },
75
- { skill: 'abap-radar', patterns: [/radar|quick\s+look|overview\s+of\s+(this\s+)?(class|program|object)/i] },
76
- { skill: 'abap-refactor', patterns: [/refactor|clean\s+up\s+(code|abap)|procedural.*to\s+oo/i] },
77
- { skill: 'abap-review', patterns: [/^review|code\s+review|code\s+quality|clean\s+abap(?!\s+conformity)/i] },
96
+ // abap-refactor also fixes syntax errors in existing Z code (an in-place change).
97
+ { skill: 'abap-refactor', patterns: [/refactor|clean\s+up\s+(code|abap)|procedural.*to\s+oo|syntax\s+errors?/i] },
98
+ // abap-review also takes the retired abap-performance (performance lens) and
99
+ // abap-cloud-check "cloud ready" grading (cloud lens).
100
+ { skill: 'abap-review', patterns: [/^review|code\s+review|code\s+quality|clean\s+abap(?!\s+conformity)|performance|\bn\+1\b|select\s+inside\s+loop|\bslow\b|takes?\s+(too|so)\s+long|cloud\s+read(y|iness)|s\/4hana\s+cloud\s+check/i] },
78
101
  { skill: 'abap-segw', patterns: [/segw|dpc_ext|mpc_ext|gateway\s+(project|service)|build.*odata.*(ecc|gateway)/i] },
79
102
  { skill: 'abap-spec-gap', patterns: [/spec\s+gap|missing\s+(spec|requirements)|business\s+questions|clarif(y|ying)\s+(question|requirement)|gaps?\s+in\s+the\s+(spec|story|requirement)/i] },
103
+ // abap-handover (moved here 2026-10-02) also takes the retired
104
+ // abap-document's phrasing; late, so "document" in a dump / spec / app
105
+ // prompt keeps the earlier, more specific skill.
106
+ { skill: 'abap-handover', patterns: [/technical\s+documentation|object\s+catalog|doc(ument)?\s+package|handover|developer\s+documentation|\bdocument(ation)?\b/i] },
80
107
  // abap-transport-analysis MUST precede abap-transport
81
108
  { skill: 'abap-transport-analysis', patterns: [/transport\s+analysis|pre.release\s+transport|transport\s+readiness/i] },
82
- { skill: 'abap-transport', patterns: [/transport\s+(create|release|list)|\bcts\b|release.*request/i] },
109
+ { skill: 'abap-transport', patterns: [/transport\s+(create|release|list)|\bcts\b|release.*request|\btransport\b/i] },
83
110
  ];
84
111
  /** The skill each offline rule routes to, in rule order (one rule per skill). */
85
112
  export const RULE_SKILLS = Object.freeze(RULES.map((r) => r.skill));
86
- // 2026-10-01: 33 + abap-app + abap-cap = 35. The four merged-away donors
87
- // (abap-cloud-check, abap-document, abap-performance, abap-radar) keep their
88
- // rules: their .claude/skills folders are still on disk and still resolve.
89
- if (RULES.length !== 35) {
90
- // Warn-not-throw — managed-mode users never exercise classify() (the proxy
91
- // does server-side classification), so a module-load crash would break
92
- // them for a local-only consistency check. Unit tests assert the exact
93
- // count.
94
- console.warn(`[classifier] RULES drift: expected 35, got ${RULES.length} — reconcile with .claude/skills/`);
95
- }
96
- export function classify(prompt) {
113
+ // 2026-10-02: one rule per routed skill, every one in SKILL_CATALOG (the
114
+ // four retired donors folded into their successors). Unit tests pin the set.
115
+ /** First matching rule → its skill and confidence (strong 0.85, else 0.6). */
116
+ export function classifyWithConfidence(prompt) {
97
117
  for (const rule of RULES) {
98
- if (rule.patterns.some(p => p.test(prompt)))
99
- return rule.skill;
118
+ if (rule.strong?.some((p) => p.test(prompt)))
119
+ return { skill: rule.skill, confidence: STRONG_CONFIDENCE };
120
+ if (rule.patterns.some((p) => p.test(prompt)))
121
+ return { skill: rule.skill, confidence: RULE_CONFIDENCE };
100
122
  }
101
123
  return undefined;
102
124
  }
125
+ export function classify(prompt) {
126
+ return classifyWithConfidence(prompt)?.skill;
127
+ }
package/dist/cli.js CHANGED
@@ -5,6 +5,7 @@ import { runGcOnStartup } from './session/gc.js';
5
5
  import { checkForUpgrade } from './upgrade-check.js';
6
6
  import { extractSapAliasFlag, extractNoSapFlag } from './cli-args.js';
7
7
  import { applyColorMode } from './renderer/color-mode.js';
8
+ import { migrateLegacyByokKey, legacyByokKeyNotice, cleanStaleAuthTemps } from './auth/auth-file.js';
8
9
  /**
9
10
  * The verbs that make `cspeach project …` a mistyped team command rather than
10
11
  * a prompt somebody meant to send. See the check in `main`.
@@ -16,6 +17,16 @@ export async function main(argv) {
16
17
  applyColorMode();
17
18
  runGcOnStartup().catch(() => { });
18
19
  checkForUpgrade().catch(() => { });
20
+ // 2026-10-02 — an older `cspeach login` left a pasted BYOK key in plaintext
21
+ // in ~/.cspeach/auth.json; move it to the OS keychain and delete it from
22
+ // the file. A file read on most starts; never throws.
23
+ // A key that could not be moved (keychain unavailable) or that differs from
24
+ // the keychain's gets one plain notice on stderr (one-shot stdout stays clean).
25
+ // Review S3: a crashed atomic write can leave auth.json.*.tmp (API key inside).
26
+ await cleanStaleAuthTemps();
27
+ const legacyNotice = legacyByokKeyNotice(await migrateLegacyByokKey().catch(() => 'failed'));
28
+ if (legacyNotice)
29
+ console.error(legacyNotice);
19
30
  let args = argv.slice(2);
20
31
  // Standalone mode (2026-09-16) — `--no-sap` is accepted ANYWHERE in the args
21
32
  // for both the REPL and one-shot, and stripped like `--sap-alias` below.
@@ -24,6 +24,7 @@ const MUTABLE_KEYS = new Set([
24
24
  'llm.mode',
25
25
  'llm.ai_hub_base_url',
26
26
  'llm.ai_hub_model_alias',
27
+ 'llm.ai_hub_router_model',
27
28
  'llm.local_base_url',
28
29
  'llm.local_model',
29
30
  // Virtual keys that route to keychain (not saved to config.toml):
@@ -574,6 +575,12 @@ export async function runConfigSet(args) {
574
575
  err('ai_hub_model_alias cannot be empty.');
575
576
  cfg.llm.ai_hub_model_alias = value;
576
577
  break;
578
+ case 'llm.ai_hub_router_model':
579
+ if (!isModelAlias(value)) {
580
+ err(`Invalid llm.ai_hub_router_model "${value}". Use your AI Hub deployment / model alias (letters, digits, . _ - : /; no spaces).`);
581
+ }
582
+ cfg.llm.ai_hub_router_model = value;
583
+ break;
577
584
  case 'llm.local_base_url':
578
585
  try {
579
586
  new URL(value);
@@ -606,6 +613,10 @@ export async function runConfigSet(args) {
606
613
  * Does not load or mutate any config — safe to call in unit tests.
607
614
  * Keep in sync with the switch statement in `runConfigSet`.
608
615
  */
616
+ /** A model / deployment alias: 1–128 chars, no spaces or control characters (2026-10-02). */
617
+ function isModelAlias(value) {
618
+ return /^[A-Za-z0-9._:\/-]{1,128}$/.test(value);
619
+ }
609
620
  export function validateValue(key, value) {
610
621
  // SAP-scoped key: sap.<alias>.auto_approve
611
622
  if (SAP_AUTO_APPROVE_RE.test(key)) {
@@ -684,6 +695,8 @@ export function validateValue(key, value) {
684
695
  case 'llm.ai_hub_model_alias':
685
696
  case 'llm.local_model':
686
697
  return value.length > 0;
698
+ case 'llm.ai_hub_router_model':
699
+ return isModelAlias(value);
687
700
  case 'llm.byok_key':
688
701
  case 'llm.ai_hub_token':
689
702
  return value.length > 0; // Secrets pass through if non-empty; no format check
@@ -59,7 +59,7 @@ function printLlmMissingHints(llm) {
59
59
  if (typeof mode !== 'string')
60
60
  return;
61
61
  const required = LLM_REQUIRED_KEYS_BY_MODE[mode];
62
- if (!required || required.length === 0)
62
+ if (!required)
63
63
  return;
64
64
  for (const key of required) {
65
65
  const value = llm[key];
@@ -67,6 +67,10 @@ function printLlmMissingHints(llm) {
67
67
  console.log(`${key} = ${paint('bad', '<not set>')}`);
68
68
  }
69
69
  }
70
+ // 2026-10-02: the optional routing alias — say what routing uses instead.
71
+ if (mode === 'ai-hub' && !llm['ai_hub_router_model']) {
72
+ console.log(`ai_hub_router_model = ${paint('dim', '<not set — skill routing uses ai_hub_model_alias>')}`);
73
+ }
70
74
  }
71
75
  function printConfigAsToml(cfg, prefix = '') {
72
76
  // Emit scalars before nested objects — otherwise a top-level scalar that
@@ -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('');