@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
@@ -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
+ }
@@ -1,24 +1,73 @@
1
1
  import crypto from 'node:crypto';
2
2
  import { loadConfig } from '../../config/loader.js';
3
3
  import { getStore } from '../../auth/api-key.js';
4
- export async function checkSkill() {
5
- const cfg = await loadConfig();
6
- const store = await getStore();
7
- const key = await store.get();
4
+ import { ManifestClient, DEFAULT_MANIFEST_URL } from '../../skills/manifest-client.js';
5
+ const PROBE_SKILL = 'abap-radar';
6
+ /**
7
+ * Fetch one skill the way a managed session does and prove its integrity.
8
+ *
9
+ * 2026-10-04 — the first version of this check (561807b9) expected a plain
10
+ * text body plus an `x-skill-sha256` header. The proxy serves JSON
11
+ * `{ name, body, sha256, … }` and sends no such header, so the check hashed
12
+ * the whole JSON text, compared it with '' and always reported a mismatch.
13
+ * Now: hash the delivered `body` bytes (exactly as served — CRLF included,
14
+ * the same bytes the seed signed) and compare with the server's `sha256` and
15
+ * with the signed manifest entry, the real trust anchor.
16
+ */
17
+ export async function checkSkill(deps = {}) {
18
+ const doFetch = deps.fetch ?? ((u, i) => fetch(u, i));
19
+ const proxyUrl = deps.proxyUrl ?? (await loadConfig()).proxy_url;
20
+ const key = deps.getKey ? await deps.getKey() : await (await getStore()).get();
8
21
  if (!key)
9
22
  return { name: 'skill', ok: false, message: 'skipped (no API key)' };
23
+ let body;
24
+ let claimed;
10
25
  try {
11
- const r = await fetch(`${cfg.proxy_url}/v1/skills/abap-radar`, { headers: { Authorization: `Bearer ${key}` } });
26
+ const r = await doFetch(`${proxyUrl}/v1/skills/${PROBE_SKILL}`, { headers: { Authorization: `Bearer ${key}` } });
12
27
  if (!r.ok)
13
28
  return { name: 'skill', ok: false, message: `HTTP ${r.status}` };
14
- const body = await r.text();
15
- const sha256 = r.headers.get('x-skill-sha256') ?? '';
16
- const computed = crypto.createHash('sha256').update(body, 'utf-8').digest('hex');
17
- if (sha256 !== computed)
18
- return { name: 'skill', ok: false, message: `SHA256 mismatch (header=${sha256}, body=${computed})`, hint: 'Possible tampering or proxy bug — contact support' };
19
- return { name: 'skill', ok: true, message: `abap-radar fetched (${body.length} bytes, SHA ${sha256.slice(0, 8)}...)` };
29
+ const j = await r.json();
30
+ if (typeof j.body !== 'string')
31
+ return { name: 'skill', ok: false, message: `${PROBE_SKILL}: response has no skill body` };
32
+ body = j.body;
33
+ claimed = typeof j.sha256 === 'string' ? j.sha256 : '';
20
34
  }
21
35
  catch (err) {
22
36
  return { name: 'skill', ok: false, message: err.message };
23
37
  }
38
+ const computed = crypto.createHash('sha256').update(body, 'utf-8').digest('hex');
39
+ if (claimed !== computed) {
40
+ return {
41
+ name: 'skill', ok: false,
42
+ message: `${PROBE_SKILL}: SHA256 mismatch (served sha256=${claimed || 'none'}, body=${computed})`,
43
+ hint: 'Possible tampering or proxy bug — contact support',
44
+ };
45
+ }
46
+ const getManifest = deps.manifest ?? (() => new ManifestClient(DEFAULT_MANIFEST_URL).fetchAndVerifyManifest());
47
+ let manifestNote;
48
+ try {
49
+ const m = await getManifest();
50
+ const entry = m.skills.find((s) => s.name === PROBE_SKILL);
51
+ if (!entry) {
52
+ manifestNote = `not in the signed manifest`;
53
+ }
54
+ else if (entry.sha256 !== computed) {
55
+ return {
56
+ name: 'skill', ok: false,
57
+ message: `${PROBE_SKILL} differs from the signed manifest (manifest=${entry.sha256.slice(0, 12)}…, body=${computed.slice(0, 12)}…)`,
58
+ hint: 'The skills were updated without republishing the signed manifest — contact support',
59
+ };
60
+ }
61
+ else {
62
+ manifestNote = 'matches the signed manifest';
63
+ }
64
+ }
65
+ catch (err) {
66
+ const msg = err.message;
67
+ if (/signature invalid/i.test(msg)) {
68
+ return { name: 'skill', ok: false, message: msg.slice(0, 200), hint: 'Upgrade cspeach, or contact support if you are on the latest version' };
69
+ }
70
+ manifestNote = `signed manifest not checked: ${msg.slice(0, 120)}`;
71
+ }
72
+ return { name: 'skill', ok: true, message: `${PROBE_SKILL} fetched (${body.length} chars, SHA ${computed.slice(0, 8)}…, ${manifestNote})` };
24
73
  }
@@ -4,7 +4,6 @@ import { checkProxy } from './checks/proxy.js';
4
4
  import { checkAuth } from './checks/auth.js';
5
5
  import { checkKeychain } from './checks/keychain.js';
6
6
  import { checkSap } from './checks/sap.js';
7
- import { checkZcspeach } from './checks/zcspeach.js';
8
7
  import { checkCert } from './checks/cert.js';
9
8
  import { checkSkill } from './checks/skill.js';
10
9
  import { checkKeychainFallback } from './checks/keychain-fallback.js';
@@ -111,7 +110,6 @@ export async function collectChecks() {
111
110
  checks.push(await checkSystemRoles(cfg));
112
111
  checks.push(await checkKeychain());
113
112
  checks.push(await checkSap());
114
- checks.push(await checkZcspeach());
115
113
  checks.push(await checkCert());
116
114
  checks.push(await checkSkill());
117
115
  checks.push(await checkKeychainFallback());
@@ -15,6 +15,7 @@
15
15
  import { promises as fs, statSync } from 'node:fs';
16
16
  import path from 'node:path';
17
17
  import { readProjectFile } from '../projects/status.js';
18
+ import { looksLikeSpecGap } from '../projects/extract-spec-gap.js';
18
19
  import { parsePlanContent } from '../projects/plan-schema.js';
19
20
  import { buildResumeCommand } from '../commands/plan-resume.js';
20
21
  import { canonicalLanePath } from './paths.js';
@@ -248,6 +249,33 @@ export async function afterSpecGapSave(h, savedPath) {
248
249
  h.queueNext(cmd);
249
250
  h.emit(STEP_2);
250
251
  }
252
+ /** The result was written but could not be read as a spec-gap: the flow waits at step 1. */
253
+ export const SPEC_GAP_NOT_SAVED_LINE = (specToken) => `The /abap-app run is paused at step 1: the spec-gap result could not be saved. Ask CSPeach to "re-issue the spec gap in the standard structure", or run /abap-app ${shownToken(specToken, 'spec file')} again.`;
254
+ /** The user said No to the save: the run ends here. */
255
+ export const SPEC_GAP_DECLINED_LINE = (specToken) => `The /abap-app run stopped at step 1 because the spec-gap result was not saved. To start again, run /abap-app ${shownToken(specToken, 'spec file')}.`;
256
+ /**
257
+ * 2026-10-04 — a spec-gap turn inside an app flow at its spec-gap step that
258
+ * produced no file must never end the chain in silence (live 1.1.26: the
259
+ * result was written, the save failed on its heading, nothing more was said).
260
+ * - unreadable + the text IS a spec-gap result → one line; the flow stays at
261
+ * step 1, so a re-issued result saves and moves the chain on.
262
+ * - unreadable + no result (a question back to the user) → nothing: the
263
+ * conversation is still in step 1.
264
+ * - declined → one line; the flow ends.
265
+ */
266
+ export function afterSpecGapNotSaved(h, reason, assistantText) {
267
+ const flow = getAppFlow(h.session);
268
+ if (!flow || flow.expect !== 'spec-gap' || flow.specGapPath !== undefined)
269
+ return;
270
+ if (reason === 'declined') {
271
+ clearAppFlow(h.session);
272
+ h.emit(SPEC_GAP_DECLINED_LINE(flow.specToken));
273
+ return;
274
+ }
275
+ if (!looksLikeSpecGap(assistantText))
276
+ return;
277
+ h.emit(SPEC_GAP_NOT_SAVED_LINE(flow.specToken));
278
+ }
251
279
  /**
252
280
  * After a plan save: when the saved plan carries `from.lane`, offer to start
253
281
  * the guarded run. Inside an active app flow the flow's lane must be the
@@ -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 {
package/dist/one-shot.js CHANGED
@@ -13,11 +13,12 @@ import { makeSessionId, saveSession } from './session/store.js';
13
13
  import { newSession } from './session/schema.js';
14
14
  import { resolveModelRole } from './models/resolve.js';
15
15
  import { loadServerModelConfig, loadCustomerModelConfig, fetchToolLoadingMode } from './models/server-config.js';
16
+ import { prefetchRouterPrompt } from './router/router-prompt.js';
16
17
  import { createProviderForMode } from './agent/providers/factory.js';
17
18
  import { assertModeLicensed, CspeachLicenseError } from './agent/providers/license-gate.js';
18
19
  import { runTurn } from './agent/loop.js';
19
20
  import { classifyPrompt, parseShortcut, rejectUnknownSkill } from './router/classifier.js';
20
- import { routingFailureFromResult, routingFailureOneShotLine } from './router/routing-failure.js';
21
+ import { routingFailureFromResult, routingFailureOneShotLine, routingFallbackFromError } from './router/routing-failure.js';
21
22
  import { startThinkingHeartbeat, ROUTING_HEARTBEAT_THRESHOLDS } from './renderer/thinking-heartbeat.js';
22
23
  import { setRenderingOverride, setHeadlessOverride, setOneShotOverride } from './renderer/tty.js';
23
24
  import { resolveRenderingMode } from './renderer/rendering-mode.js';
@@ -96,6 +97,9 @@ export async function runOneShot(prompt, opts) {
96
97
  loadCustomerModelConfig(cfg, async () => (await (await getStore()).get()) ?? '', { timeoutMs: 1500 }),
97
98
  // Task 16 (F13) — BYOK reads only the served tool_loading (signed in only).
98
99
  fetchToolLoadingMode(cfg, async () => (await (await getStore()).get()) ?? '', { timeoutMs: 1500 }),
100
+ // 2026-10-02 — BYOK / ai-hub route on the laptop: prefetch the signed
101
+ // router prompt (public manifest, no prompt data) under the same cap.
102
+ cfg.llm?.mode === 'byok' || cfg.llm?.mode === 'ai-hub' ? prefetchRouterPrompt({ timeoutMs: 1500 }) : Promise.resolve(),
99
103
  ]);
100
104
  // B5 (2026-06-11) — one-shot is headless by design: a single turn, no
101
105
  // REPL, often piped (CI / battery). Any stdin prompt would hang forever
@@ -224,7 +228,13 @@ export async function runOneShot(prompt, opts) {
224
228
  console.error('No API key configured. Run `cspeach` (interactive) first to set one.');
225
229
  return 1;
226
230
  }
227
- throw err;
231
+ // Live fix 2026-10-02 — a classify failure (proxy HTTP 500, an
232
+ // unreadable body) escaped as "cspeach: unhandled error". Continue on
233
+ // the bundled local rules instead; the offline line below says so.
234
+ const fallback = routingFallbackFromError(err, body);
235
+ if (!fallback)
236
+ throw err;
237
+ c = fallback;
228
238
  }
229
239
  finally {
230
240
  routingBeat.stop();
@@ -1,8 +1,9 @@
1
+ import { matchTitleHeading } from './title-heading.js';
1
2
  // 2026-09-17 — tolerant on heading level and dash: the skill prescribes
2
3
  // "## ABAP Design — …" but a live standalone run wrote "# ABAP Design — …"
3
4
  // and the whole design was silently lost (no file, so /abap-rap --from had
4
5
  // nothing to start from). H1..H3 and em/en/plain dash all count.
5
- const TITLE_RE = /^#{1,3} ABAP Design [—–-] (.+)$/m;
6
+ const TITLE_SPEC = { phrase: String.raw `abap[ \t]+design`, skillPrefix: 'abap-design' }; // 2026-10-04 — tolerant title, see title-heading.ts
6
7
  const OBJECTS_HEADING_RE = /^### Objects to Create\s*$/m;
7
8
  const DEPENDENCY_HEADING_RE = /^### Dependency Sequence\s*$/m;
8
9
  const NEXT_HEADING_RE = /^###\s/m;
@@ -93,11 +94,11 @@ function parseDependencySequence(section, nameToId) {
93
94
  return out;
94
95
  }
95
96
  export function extractDesign(markdown) {
96
- const titleMatch = TITLE_RE.exec(markdown);
97
+ const titleMatch = matchTitleHeading(markdown, TITLE_SPEC);
97
98
  if (!titleMatch) {
98
99
  throw new Error('Input is not a design output (no "## ABAP Design — ..." heading found)');
99
100
  }
100
- const title = titleMatch[1].trim();
101
+ const title = titleMatch.title || 'ABAP Design';
101
102
  const objectsSection = sliceSection(markdown, OBJECTS_HEADING_RE);
102
103
  if (!objectsSection) {
103
104
  throw new Error('No "### Objects to Create" section found');
@@ -1,6 +1,7 @@
1
+ import { matchTitleHeading } from './title-heading.js';
1
2
  // 2026-09-17 — tolerant on heading level (H1..H3) and dash, same reason as
2
3
  // extract-design.ts.
3
- const TITLE_RE = /^#{1,3} Effort Estimate [—–-] (.+)$/m;
4
+ const TITLE_SPEC = { phrase: String.raw `effort[ \t]+estimate`, skillPrefix: 'abap-estimate' }; // 2026-10-04 — tolerant title, see title-heading.ts
4
5
  const COMPONENTS_HEADING_RE = /^### Component Breakdown\s*$/m;
5
6
  const NEXT_HEADING_RE = /^###\s/m;
6
7
  function sliceSection(markdown, headingRe) {
@@ -60,11 +61,11 @@ function parseComponentsTable(section) {
60
61
  return rows;
61
62
  }
62
63
  export function extractEstimate(markdown) {
63
- const titleMatch = TITLE_RE.exec(markdown);
64
+ const titleMatch = matchTitleHeading(markdown, TITLE_SPEC);
64
65
  if (!titleMatch) {
65
66
  throw new Error('Input is not an estimate output (no "## Effort Estimate — ..." heading found)');
66
67
  }
67
- const title = titleMatch[1].trim();
68
+ const title = titleMatch.title || 'Effort Estimate';
68
69
  const section = sliceSection(markdown, COMPONENTS_HEADING_RE);
69
70
  if (!section) {
70
71
  throw new Error('No "### Component Breakdown" section found');
@@ -1,6 +1,9 @@
1
- // 2026-09-17 — tolerant on heading level (H1..H3) and dash, same reason as
2
- // extract-design.ts: a title written one level off must not lose the file.
3
- const TITLE_RE = /^#{1,3} Spec Gap Analysis [—–-] (.+)$/m;
1
+ import { matchTitleHeading } from './title-heading.js';
2
+ // 2026-09-17 — tolerant on heading level and dash. 2026-10-04 — and on the
3
+ // separator (colon / none), casing, bold and an "abap-spec-gap —" prefix: a
4
+ // live /abap-app run wrote "Spec Gap Analysis: …" and the chain stopped at
5
+ // step 1 because nothing was saved. See title-heading.ts.
6
+ const TITLE_SPEC = { phrase: String.raw `spec(?:ification)?[ \t-]*gap[ \t]+analysis`, skillPrefix: 'abap-spec-gap' };
4
7
  const DOMAIN_RE = /^\*\*Inferred Domain:\*\*\s*(.+?)(?:\s*\[INFERRED.*?\])?$/m;
5
8
  const INPUT_TYPE_RE = /^\*\*Input Type:\*\*\s*(.+)$/m;
6
9
  const SOURCE_DOC_RE = /^\*\*Source Document:\*\*\s*(.+)$/m;
@@ -54,14 +57,16 @@ const SKIP_SECTIONS = [
54
57
  'Recommended Next Steps', // is a paragraph
55
58
  ];
56
59
  export function extractSpecGap(markdown) {
57
- const titleMatch = TITLE_RE.exec(markdown);
60
+ const titleMatch = matchTitleHeading(markdown, TITLE_SPEC);
58
61
  if (!titleMatch) {
59
62
  throw new Error('Input is not a spec-gap output (no "## Spec Gap Analysis — ..." heading found)');
60
63
  }
61
- const title = titleMatch[1].trim();
62
64
  const inputType = (INPUT_TYPE_RE.exec(markdown)?.[1] ?? 'Unknown').trim();
63
65
  const inferredDomain = (DOMAIN_RE.exec(markdown)?.[1] ?? 'Unknown').trim();
64
66
  const sourceDocument = SOURCE_DOC_RE.exec(markdown)?.[1].trim() || undefined;
67
+ // A bare "## Spec Gap Analysis" heading still names the artefact: fall back
68
+ // to the source document, then to a plain label.
69
+ const title = titleMatch.title || sourceDocument || 'Spec Gap Analysis';
65
70
  const sectionStarts = [];
66
71
  let m;
67
72
  const reCopy = new RegExp(SECTION_HEADING_RE.source, SECTION_HEADING_RE.flags);
@@ -103,6 +108,24 @@ export function extractSpecGap(markdown) {
103
108
  items,
104
109
  };
105
110
  }
111
+ /**
112
+ * True when the text has the body of a spec-gap result (a known gap section
113
+ * heading, or a numbered [BLOCKER]/[IMPORTANT]/[NICE TO KNOW] line). Tells a
114
+ * result that failed to save apart from a conversational turn (a question back
115
+ * to the user), which is not an artefact at all.
116
+ */
117
+ export function looksLikeSpecGap(markdown) {
118
+ if (/^\s*\d+\.\s*(?:\*\*)?\[(?:BLOCKER|IMPORTANT|NICE TO KNOW)\]/m.test(markdown))
119
+ return true;
120
+ const re = new RegExp(SECTION_HEADING_RE.source, SECTION_HEADING_RE.flags);
121
+ let m;
122
+ while ((m = re.exec(markdown)) !== null) {
123
+ const name = m[1].toLowerCase();
124
+ if (KNOWN_GAP_SECTIONS.some((k) => name.includes(k.toLowerCase())))
125
+ return true;
126
+ }
127
+ return false;
128
+ }
106
129
  function parseGapsFromSection(sectionBody, nextId) {
107
130
  const out = [];
108
131
  const gapRe = new RegExp(GAP_LINE_RE.source); // per-line, no /m — exec on each line
@@ -108,6 +108,53 @@ export function checkLaneConsistency(content, rec) {
108
108
  }
109
109
  return null;
110
110
  }
111
+ /* ── deploy is a required step of a UI lane (owner, 2026-10-02) ─────────── */
112
+ /**
113
+ * A rap lane with a UI, and the `none` lane (a UI on an already published
114
+ * service), build an app that runs on SAP — its plan must deploy it. cap is
115
+ * out (the BTP deploy is not built); abap-report has no UI.
116
+ */
117
+ export function laneRequiresDeploy(rec) {
118
+ const { backend, ui } = rec.final;
119
+ return (backend === 'rap' && ui !== 'none') || backend === 'none';
120
+ }
121
+ /** The deploy phase of a UI: ui layer, /abap-fiori-build, id `ui.deploy` (or `<app>.deploy`). */
122
+ export function isDeployPhase(p) {
123
+ return p.layer === 'ui' && p.delegateTo === 'abap-fiori-build' && /(^|\.)deploy$/.test(p.id);
124
+ }
125
+ /** The developer's recorded reason for not deploying (phases[ui.build].ui.deployOptOut), if any. */
126
+ export function deployOptOutOf(phases) {
127
+ for (const p of phases) {
128
+ const why = p.ui?.deployOptOut?.trim();
129
+ if (why)
130
+ return why;
131
+ }
132
+ return undefined;
133
+ }
134
+ /**
135
+ * Refuse a UI-lane plan with no deploy phase (and no recorded opt-out), or
136
+ * whose deploy phase does not wait for a UI build phase. Returns a plain
137
+ * refusal or null. Checked at save; a phase write-back may not remove it.
138
+ */
139
+ export function checkRequiredDeploy(content, rec) {
140
+ if (!laneRequiresDeploy(rec))
141
+ return null;
142
+ const deploys = content.phases.filter(isDeployPhase);
143
+ if (deploys.length === 0) {
144
+ if (deployOptOutOf(content.phases))
145
+ return null;
146
+ return ('This lane builds an app that runs on SAP, so the plan needs a ui.deploy phase (deploy the app to the ABAP ' +
147
+ 'repository, delegateTo abap-fiori-build, layer ui) after ui.build. If the developer does not want it deployed, ' +
148
+ 'record their words in phases[ui.build].ui.deployOptOut. Re-run /abap-plan.');
149
+ }
150
+ const builds = new Set(content.phases.filter((p) => p.layer === 'ui' && !isDeployPhase(p)).map((p) => p.id));
151
+ for (const d of deploys) {
152
+ if (!d.entryCriteria.some((id) => builds.has(id))) {
153
+ return `Phase ${cardLine(d.id)} must run after ui.build — list the UI build phase in its entryCriteria. Re-run /abap-plan.`;
154
+ }
155
+ }
156
+ return null;
157
+ }
111
158
  /**
112
159
  * Resume / continuation gate: read the plan's lane with its recorded sha,
113
160
  * re-validate it and check the stored phases against it. Lane-less plans pass
@@ -372,6 +372,14 @@ g = getGlyphs()) {
372
372
  if (deployed?.work?.deployedUrl) {
373
373
  lines.push(`App deployed: ${deployed.work.deployedUrl}`);
374
374
  }
375
+ else if (phases.some((p) => p.layer === 'ui' && p.delegateTo === 'abap-fiori-build' && p.ui !== undefined)) {
376
+ // 2026-10-02 — deploy is a chained step of a UI plan: finishing without a
377
+ // deployed app is said out loud, never left to silence.
378
+ const optOut = phases.map((p) => p.ui?.deployOptOut?.trim()).find((w) => !!w);
379
+ lines.push(optOut
380
+ ? `${g.info} Deploy skipped by the developer: ${sanitizeControlChars(optOut)}`
381
+ : `${g.attention} The app was not deployed to SAP — it runs only in local preview. Deploy it with /abap-fiori-build (Step 10, fiori_deploy) or record why not.`);
382
+ }
375
383
  lines.push('Consider /abap-preflight on the produced transport(s) before release.');
376
384
  return lines;
377
385
  }
@@ -78,6 +78,8 @@ const planPhaseUiSchema = z.object({
78
78
  floorplan: z.enum(PLAN_UI_FLOORPLANS).optional(),
79
79
  appId: z.string().min(1).optional(),
80
80
  appTitle: z.string().min(1).optional(),
81
+ // The developer's own words for not deploying (checkRequiredDeploy, plan-lane.ts).
82
+ deployOptOut: z.string().min(1).optional(),
81
83
  });
82
84
  /** Keep in sync with PlanAuditState in types.ts. 'warn' is the non-blocking
83
85
  * tier (confidence-tiered redesign, 2026-07-08) — persisted literally so a
@@ -18,7 +18,7 @@ import { renderEmailTemplate } from './email-template.js';
18
18
  import { writePlanHandover } from './handover-md.js';
19
19
  import { ensureWorkspace, resolveWorkspacePath } from './workspace.js';
20
20
  import { askYesNo, isYes } from './yes-no.js';
21
- import { checkLaneConsistency, LANE_CHANGED_AT_CREATE, readPlanLane } from './plan-lane.js';
21
+ import { checkLaneConsistency, checkRequiredDeploy, LANE_CHANGED_AT_CREATE, readPlanLane } from './plan-lane.js';
22
22
  const SKILL_REGISTRY = {
23
23
  'abap-spec-gap': {
24
24
  artefactType: 'spec-gap',
@@ -328,6 +328,7 @@ export async function runSaveCommand(args) {
328
328
  args.log(`[save] ${e instanceof Error ? e.message : String(e)}`);
329
329
  args.log(`[save] Ask CSPeach to "re-issue the ${spec.artefactType} in the standard structure" and it will be saved then.`);
330
330
  }
331
+ args.onNotSaved?.('unreadable');
331
332
  return null;
332
333
  }
333
334
  // App lane routing (Task 6) — a plan that carries a lane is checked against
@@ -343,7 +344,8 @@ export async function runSaveCommand(args) {
343
344
  args.log('[save] The plan was not saved.');
344
345
  return null;
345
346
  }
346
- const conflict = checkLaneConsistency(planExtract.content, lr.rec);
347
+ // 2026-10-02 — a UI lane must deploy (or record the developer's opt-out).
348
+ const conflict = checkLaneConsistency(planExtract.content, lr.rec) ?? checkRequiredDeploy(planExtract.content, lr.rec);
347
349
  if (conflict) {
348
350
  args.log('');
349
351
  args.log(`[save] ${conflict}`);
@@ -365,8 +367,10 @@ export async function runSaveCommand(args) {
365
367
  // prompt, give maybeOfferSave's headless prompt a question-aware answer
366
368
  // first.
367
369
  const answer = await askYesNo({ question: 'Save as project file?', confirm: args.confirm, prompt: args.prompt });
368
- if (!isYes(answer))
370
+ if (!isYes(answer)) {
371
+ args.onNotSaved?.('declined');
369
372
  return null;
373
+ }
370
374
  let env;
371
375
  if (planExtract && prior) {
372
376
  // C1 (D30) — the 49dd duplicate: the generic save used to mint a fresh
@@ -0,0 +1,36 @@
1
+ // 2026-10-04 — one tolerant title-heading matcher for the prose artefacts
2
+ // (spec-gap, design, estimate). A live /abap-app run lost its spec-gap file
3
+ // because the model wrote "Spec Gap Analysis: …" and the saver only accepted
4
+ // "## Spec Gap Analysis — …". Models drift on the separator (dash, en/em dash,
5
+ // colon, none), the heading level (#..######, or none), the casing and an
6
+ // optional "<skill-name> —" prefix. All of those count; an unrelated heading
7
+ // does not.
8
+ // Horizontal whitespace only: a match never runs onto the next line.
9
+ const SEP = String.raw `[ \t]*[—–:-][ \t]*`;
10
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
11
+ function strip(title) {
12
+ return title
13
+ .replace(/\s*\*\*\s*$/, '') // closing bold
14
+ .replace(/\s+#+\s*$/, '') // closing ATX hashes
15
+ .trim();
16
+ }
17
+ /**
18
+ * Find the artefact's title heading. Heading lines (`#`..`######`) are tried
19
+ * first and may use any separator or none; a plain line (no `#`, optionally
20
+ * bold) must use a dash or colon, so ordinary prose never counts. The first
21
+ * heading line wins over any plain line.
22
+ */
23
+ export function matchTitleHeading(markdown, spec) {
24
+ const prefix = String.raw `(?:${escapeRe(spec.skillPrefix)}${SEP})?`;
25
+ const phrase = `(?:${spec.phrase})`;
26
+ // `## [**][abap-x — ]Phrase[ — | : | ' ']Title[**]` or `## abap-x — Title`
27
+ const heading = new RegExp(String.raw `^[ \t]{0,3}#{1,6}[ \t]+(?:\*\*)?(?:${prefix}${phrase}(?:(?:${SEP}|[ \t]+)(.*?))?|${escapeRe(spec.skillPrefix)}${SEP}(.+?))[ \t]*$`, 'im');
28
+ const h = heading.exec(markdown);
29
+ if (h)
30
+ return { title: strip(h[1] ?? h[2] ?? '') };
31
+ const plain = new RegExp(String.raw `^[ \t]{0,3}(?:\*\*)?(?:${prefix}${phrase}${SEP}(.+?)|${escapeRe(spec.skillPrefix)}${SEP}(.+?))[ \t]*$`, 'im');
32
+ const p = plain.exec(markdown);
33
+ if (p)
34
+ return { title: strip(p[1] ?? p[2] ?? '') };
35
+ return null;
36
+ }