@cspeach/cli 0.9.0 → 1.1.0

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 (195) hide show
  1. package/README.md +1 -1
  2. package/dist/agent/intent-system-prompt.js +1 -1
  3. package/dist/agent/loop.js +228 -26
  4. package/dist/agent/providers/license-gate.js +44 -0
  5. package/dist/agent/skill-checkpoint.js +1 -1
  6. package/dist/agent/tool-dispatch.js +15 -0
  7. package/dist/approvals/canonical.js +91 -0
  8. package/dist/approvals/jwt.js +39 -2
  9. package/dist/approvals/op-labels.js +124 -0
  10. package/dist/approvals/render.js +42 -36
  11. package/dist/auth/org-anthropic-key.js +25 -0
  12. package/dist/classifier/client.js +18 -3
  13. package/dist/cli.js +15 -0
  14. package/dist/commands/compact.js +28 -2
  15. package/dist/commands/config-set.js +284 -0
  16. package/dist/commands/config-show.js +20 -0
  17. package/dist/commands/export-audit.js +43 -0
  18. package/dist/commands/help.js +5 -0
  19. package/dist/commands/login.js +31 -14
  20. package/dist/commands/plan-audit-evidence.js +266 -0
  21. package/dist/commands/plan-audit.js +692 -0
  22. package/dist/commands/plan-chain.js +671 -0
  23. package/dist/commands/plan-continue.js +179 -0
  24. package/dist/commands/plan-gate.js +154 -0
  25. package/dist/commands/plan-model-tier.js +83 -0
  26. package/dist/commands/plan-resume.js +728 -46
  27. package/dist/config/loader.js +223 -5
  28. package/dist/config/model-defaults.js +14 -0
  29. package/dist/cost/pricing.js +27 -1
  30. package/dist/doctor/checks/_http-probe.js +1 -0
  31. package/dist/doctor/checks/cert.js +14 -3
  32. package/dist/doctor/checks/sap.js +30 -8
  33. package/dist/doctor/checks/system-roles.js +41 -0
  34. package/dist/doctor/checks/zcspeach.js +19 -4
  35. package/dist/doctor/run.js +2 -0
  36. package/dist/models/resolve.js +61 -0
  37. package/dist/models/server-config.js +155 -0
  38. package/dist/one-shot.js +76 -6
  39. package/dist/projects/answer-blockers.js +137 -0
  40. package/dist/projects/extract-cca.js +111 -17
  41. package/dist/projects/extract-modernize.js +4 -2
  42. package/dist/projects/extract-plan.js +184 -37
  43. package/dist/projects/extract-spec-gap.js +34 -7
  44. package/dist/projects/extract-test-coverage.js +4 -2
  45. package/dist/projects/extract-upgrade.js +116 -23
  46. package/dist/projects/handover-md.js +195 -0
  47. package/dist/projects/index.js +5 -2
  48. package/dist/projects/merge-cca.js +292 -0
  49. package/dist/projects/merge-upgrade.js +173 -0
  50. package/dist/projects/migration.js +103 -1
  51. package/dist/projects/output-paths.js +27 -0
  52. package/dist/projects/plan-run.js +285 -27
  53. package/dist/projects/plan-schema.js +136 -3
  54. package/dist/projects/promote-command.js +25 -2
  55. package/dist/projects/promote.js +128 -0
  56. package/dist/projects/run-lease.js +157 -0
  57. package/dist/projects/save-command.js +259 -21
  58. package/dist/projects/status.js +3 -1
  59. package/dist/projects/validate.js +1 -1
  60. package/dist/projects/workspace.js +164 -20
  61. package/dist/renderer/notices.js +64 -0
  62. package/dist/renderer/progress-chatter.js +8 -0
  63. package/dist/renderer/status-footer.js +22 -12
  64. package/dist/renderer/thinking-heartbeat.js +64 -8
  65. package/dist/renderer/todo-block.js +51 -0
  66. package/dist/renderer/tool-widget.js +55 -4
  67. package/dist/renderer/tty.js +43 -4
  68. package/dist/renderer/verify-chain.js +77 -0
  69. package/dist/repl/at-picker.js +60 -7
  70. package/dist/repl/bracketed-paste.js +28 -19
  71. package/dist/repl/builtin-commands.js +42 -0
  72. package/dist/repl/current-transport.js +10 -0
  73. package/dist/repl/early-line-buffer.js +68 -0
  74. package/dist/repl/history.js +86 -0
  75. package/dist/repl/ink-stdin-guard.js +64 -0
  76. package/dist/repl/inquirer-guard.js +70 -5
  77. package/dist/repl/mode-ceiling.js +16 -0
  78. package/dist/repl/mode-cycle.js +104 -0
  79. package/dist/repl/numbered-menu.js +131 -0
  80. package/dist/repl/post-turn-status.js +26 -6
  81. package/dist/repl/rule8-detector.js +17 -2
  82. package/dist/repl/safety-confirm.js +111 -2
  83. package/dist/repl/safety-mode-state.js +19 -3
  84. package/dist/repl/slash-completer.js +5 -0
  85. package/dist/repl/slash-picker.js +10 -15
  86. package/dist/repl.js +1232 -95
  87. package/dist/rewind/candidates.js +194 -0
  88. package/dist/rewind/cli.js +137 -0
  89. package/dist/rewind/format.js +27 -0
  90. package/dist/rewind/restore.js +245 -0
  91. package/dist/router/classifier.js +150 -6
  92. package/dist/sap/capability-matrix.js +20 -0
  93. package/dist/sap/capability-matrix.json +11236 -0
  94. package/dist/sap/capability.js +146 -0
  95. package/dist/sap/connection-manager.js +19 -1
  96. package/dist/sap/onboarding.js +42 -4
  97. package/dist/session/audit-export.js +459 -0
  98. package/dist/session/context-report.js +163 -0
  99. package/dist/session/pending.js +27 -0
  100. package/dist/session/recap.js +160 -0
  101. package/dist/skill-catalog.js +51 -40
  102. package/dist/skills/bundled-skills.js +272 -1
  103. package/dist/skills/promotion-dispatch.js +23 -0
  104. package/dist/tools/_command-shared.js +36 -12
  105. package/dist/tools/_filesystem-shared.js +139 -4
  106. package/dist/tools/_flag.js +25 -0
  107. package/dist/tools/approval.js +177 -26
  108. package/dist/tools/ask-question.js +400 -7
  109. package/dist/tools/capability/tool.js +74 -0
  110. package/dist/tools/dispatch-skill.js +22 -1
  111. package/dist/tools/extend-model/anchored-insert.js +1414 -0
  112. package/dist/tools/extend-model/tool.js +340 -0
  113. package/dist/tools/filesystem/extract-document.js +57 -0
  114. package/dist/tools/filesystem/file-edit.js +12 -2
  115. package/dist/tools/filesystem/file-read.js +2 -2
  116. package/dist/tools/filesystem/file-write.js +11 -2
  117. package/dist/tools/filesystem/glob.js +11 -0
  118. package/dist/tools/filesystem/grep.js +10 -0
  119. package/dist/tools/filesystem/read-document.js +107 -0
  120. package/dist/tools/fiori/apply.js +50 -0
  121. package/dist/tools/fiori/bin.js +3 -0
  122. package/dist/tools/fiori/catalog/index.js +27 -0
  123. package/dist/tools/fiori/catalog/value-help.js +230 -0
  124. package/dist/tools/fiori/catalog/viz-chart.js +177 -0
  125. package/dist/tools/fiori/cli.js +71 -0
  126. package/dist/tools/fiori/deploy-config.js +73 -0
  127. package/dist/tools/fiori/fe-extend.js +76 -0
  128. package/dist/tools/fiori/fe-scaffold.js +71 -0
  129. package/dist/tools/fiori/floorplan-map.js +19 -0
  130. package/dist/tools/fiori/i18n.js +39 -0
  131. package/dist/tools/fiori/manifest.js +70 -0
  132. package/dist/tools/fiori/render.js +77 -0
  133. package/dist/tools/fiori/samples/data/index.json +13602 -0
  134. package/dist/tools/fiori/samples/data/sources.generated.js +808 -0
  135. package/dist/tools/fiori/samples/loader.js +248 -0
  136. package/dist/tools/fiori/samples/search.js +63 -0
  137. package/dist/tools/fiori/samples/types.js +2 -0
  138. package/dist/tools/fiori/scaffold.js +39 -0
  139. package/dist/tools/fiori/smoke/assertions.js +74 -0
  140. package/dist/tools/fiori/smoke/browser.js +52 -0
  141. package/dist/tools/fiori/smoke/driver.js +89 -0
  142. package/dist/tools/fiori/smoke/freestyle-spec.js +317 -0
  143. package/dist/tools/fiori/smoke/run-smoke.js +149 -0
  144. package/dist/tools/fiori/tools.js +681 -0
  145. package/dist/tools/fiori/types.js +1 -0
  146. package/dist/tools/local-build.js +86 -0
  147. package/dist/tools/local-files.js +31 -0
  148. package/dist/tools/project/_merge-shared.js +68 -0
  149. package/dist/tools/project/cca_merge.js +164 -0
  150. package/dist/tools/project/playbook_get.js +1 -1
  151. package/dist/tools/project/upgrade_merge_progress.js +206 -0
  152. package/dist/tools/sap-read.js +132 -20
  153. package/dist/tools/sap-write.js +550 -21
  154. package/dist/tools/shell/shell_exec.js +41 -6
  155. package/dist/tools/snapshot.js +63 -14
  156. package/dist/tools/subagent/agent_run.js +27 -3
  157. package/dist/tools/subagent/background_run.js +17 -1
  158. package/dist/tools/todo.js +144 -0
  159. package/dist/tools/transport-resolution.js +86 -0
  160. package/dist/tools/transport.js +224 -5
  161. package/dist/tools/write-mode.js +4 -0
  162. package/dist/ui/app.js +378 -21
  163. package/dist/ui/approval-modal.js +49 -16
  164. package/dist/ui/ask-question-emitter.js +14 -0
  165. package/dist/ui/body.js +13 -0
  166. package/dist/ui/context-grid.js +108 -0
  167. package/dist/ui/footer.js +120 -27
  168. package/dist/ui/header.js +7 -0
  169. package/dist/ui/line-resolution.js +35 -8
  170. package/dist/ui/rewind-emitter.js +10 -0
  171. package/dist/ui/rewind-panel.js +81 -0
  172. package/dist/ui/sap-state-store.js +1 -0
  173. package/dist/ui/session-timeline.js +1 -0
  174. package/dist/ui/status-line.js +43 -0
  175. package/dist/ui/text-input.js +214 -0
  176. package/dist/ui/todo-emitter.js +25 -0
  177. package/dist/ui/todo-panel.js +64 -0
  178. package/dist/ui/turn-status-emitter.js +50 -4
  179. package/dist/ui/turn-status.js +18 -3
  180. package/dist/ui/widgets/ask-form.js +242 -0
  181. package/dist/ui/widgets/ask-question-modal.js +21 -8
  182. package/package.json +22 -3
  183. package/bench/README.md +0 -78
  184. package/bench/prompts/abap-document-cds.md +0 -44
  185. package/bench/prompts/abap-explain-bdef-handler.md +0 -57
  186. package/bench/prompts/abap-test-method.md +0 -42
  187. package/bench/results/abap-document-cds/claude-haiku-4-5.md +0 -189
  188. package/bench/results/abap-document-cds/claude-opus-4-7.md +0 -120
  189. package/bench/results/abap-document-cds/claude-sonnet-4-6.md +0 -151
  190. package/bench/results/abap-explain-bdef-handler/claude-haiku-4-5.md +0 -112
  191. package/bench/results/abap-explain-bdef-handler/claude-opus-4-7.md +0 -101
  192. package/bench/results/abap-explain-bdef-handler/claude-sonnet-4-6.md +0 -101
  193. package/bench/results/abap-test-method/claude-haiku-4-5.md +0 -186
  194. package/bench/results/abap-test-method/claude-opus-4-7.md +0 -193
  195. package/bench/results/abap-test-method/claude-sonnet-4-6.md +0 -234
@@ -0,0 +1,155 @@
1
+ // model-governance step 2d (2026-07-10) — the CLI-side reader for the served
2
+ // model-governance config.
3
+ //
4
+ // The proxy serves `GET {proxy_url}/v1/model-config` (Task 3):
5
+ // { roles: { session_default, audit, compact, router },
6
+ // pricing: Record<model, { inputPer1M, outputPer1M, cacheReadPer1M, cacheCreatePer1M? }>,
7
+ // updated_at }
8
+ // top-level keys snake_case, pricing entries camelCase, Cache-Control 60s, no auth.
9
+ //
10
+ // This module fetches that config ONCE at startup (fire-and-forget from repl.tsx
11
+ // / one-shot.ts) and holds it in a synchronous module snapshot that
12
+ // resolveModelRole (resolve.ts) reads. Precedence-wise the served config sits
13
+ // BELOW env + local config and ABOVE the built-in constants — so nothing set ⇒
14
+ // built-ins ⇒ byte-identical to before this feature.
15
+ //
16
+ // Fail-safe contract (locked):
17
+ // - managed mode ONLY — non-managed modes (byok/local/ai-hub) never fetch.
18
+ // - startup is NEVER blocked on the fetch: a turn that starts before the fetch
19
+ // lands simply uses the built-ins.
20
+ // - on ANY failure (timeout, network, non-2xx, malformed) fall back to a disk
21
+ // cache written by the last good fetch, but only if it is < 24h old; else
22
+ // leave the snapshot null (built-ins win).
23
+ // - loadServerModelConfig NEVER throws.
24
+ import fs from 'node:fs/promises';
25
+ import path from 'node:path';
26
+ import { cspeachRoot } from '../config/paths.js';
27
+ // ── Synchronous module snapshot ───────────────────────────────────────────────
28
+ let snapshot = null;
29
+ /** The current served-config snapshot, or null (⇒ built-ins win). Pure sync read. */
30
+ export function getServerModelConfig() {
31
+ return snapshot;
32
+ }
33
+ /** Test-only setter for the module snapshot. */
34
+ export function __setServerModelConfigForTests(c) {
35
+ snapshot = c;
36
+ }
37
+ // ── Constants ─────────────────────────────────────────────────────────────────
38
+ const FETCH_TIMEOUT_MS = 5_000;
39
+ const CACHE_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24h
40
+ function cacheFilePath() {
41
+ return path.join(cspeachRoot(), 'model-config.json');
42
+ }
43
+ const ROLE_KEYS = ['session_default', 'audit', 'compact', 'router'];
44
+ // ── Coercion ──────────────────────────────────────────────────────────────────
45
+ /** Keep only string, non-empty role entries; unknown keys are dropped. */
46
+ function coerceRoles(raw) {
47
+ const out = {};
48
+ if (!raw || typeof raw !== 'object')
49
+ return out;
50
+ const r = raw;
51
+ for (const k of ROLE_KEYS) {
52
+ const v = r[k];
53
+ if (typeof v === 'string' && v.trim().length > 0)
54
+ out[k] = v.trim();
55
+ }
56
+ return out;
57
+ }
58
+ function coercePricing(raw) {
59
+ return raw && typeof raw === 'object' && !Array.isArray(raw)
60
+ ? raw
61
+ : {};
62
+ }
63
+ /**
64
+ * Map a served (or cached) document to a ServerModelConfig, or null when the
65
+ * shape is unusable. `fetchedAt`, when provided, is preserved (disk-cache read);
66
+ * otherwise a fresh client timestamp is stamped (live fetch). A document with
67
+ * NO recognised role is treated as unusable (null) — there is nothing to serve.
68
+ */
69
+ function coerceDocument(body, fetchedAt) {
70
+ if (!body || typeof body !== 'object')
71
+ return null;
72
+ const b = body;
73
+ const roles = coerceRoles(b.roles);
74
+ if (Object.keys(roles).length === 0)
75
+ return null;
76
+ return {
77
+ roles,
78
+ pricing: coercePricing(b.pricing),
79
+ fetchedAt: typeof fetchedAt === 'string' && fetchedAt.length > 0 ? fetchedAt : new Date().toISOString(),
80
+ };
81
+ }
82
+ // ── Disk cache (net-new; whole-document atomic tmp+rename, same as session/store) ─
83
+ async function writeDiskCache(c) {
84
+ const dir = cspeachRoot();
85
+ await fs.mkdir(dir, { recursive: true });
86
+ const file = cacheFilePath();
87
+ const tmp = `${file}.tmp`;
88
+ await fs.writeFile(tmp, JSON.stringify(c, null, 2), 'utf-8');
89
+ await fs.rename(tmp, file);
90
+ }
91
+ /** Read the disk cache, returning it ONLY when parseable AND < 24h old. */
92
+ async function readFreshDiskCache() {
93
+ let parsed;
94
+ try {
95
+ const raw = await fs.readFile(cacheFilePath(), 'utf-8');
96
+ parsed = JSON.parse(raw);
97
+ }
98
+ catch {
99
+ return null; // absent or corrupt/unparseable = no cache
100
+ }
101
+ const fetchedAt = parsed && typeof parsed === 'object'
102
+ ? parsed.fetchedAt
103
+ : undefined;
104
+ const doc = coerceDocument(parsed, typeof fetchedAt === 'string' ? fetchedAt : undefined);
105
+ if (!doc)
106
+ return null;
107
+ const age = Date.now() - Date.parse(doc.fetchedAt);
108
+ if (!Number.isFinite(age) || age < 0 || age >= CACHE_MAX_AGE_MS)
109
+ return null;
110
+ return doc;
111
+ }
112
+ // ── Public entrypoint ─────────────────────────────────────────────────────────
113
+ /**
114
+ * Fetch the served model-config and update the module snapshot. Managed mode
115
+ * ONLY. Fire-and-forget from startup; NEVER throws.
116
+ */
117
+ export async function loadServerModelConfig(cfg) {
118
+ // Non-managed modes bypass the proxy for inference and must never fetch.
119
+ if (cfg.llm?.mode !== 'managed')
120
+ return;
121
+ try {
122
+ // URL construction lives INSIDE the try: a degenerate config (e.g. a
123
+ // non-string proxy_url that loader.ts never coerced) would otherwise throw
124
+ // synchronously out of this fire-and-forget call and become an unhandled
125
+ // rejection that crashes startup. Keep it caught like every other failure.
126
+ const url = `${cfg.proxy_url.replace(/\/$/, '')}/v1/model-config`;
127
+ const controller = new AbortController();
128
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
129
+ timer.unref?.();
130
+ let body;
131
+ try {
132
+ const r = await fetch(url, { signal: controller.signal });
133
+ if (!r.ok)
134
+ throw new Error(`model-config fetch: status ${r.status}`);
135
+ body = await r.json();
136
+ }
137
+ finally {
138
+ clearTimeout(timer);
139
+ }
140
+ const doc = coerceDocument(body);
141
+ if (!doc)
142
+ throw new Error('model-config fetch: malformed payload');
143
+ snapshot = doc;
144
+ // Best-effort cache write — a cache-write failure must not fail the fetch.
145
+ await writeDiskCache(doc).catch(() => { });
146
+ return;
147
+ }
148
+ catch {
149
+ // On ANY failure fall back to a fresh (<24h) disk cache; else leave the
150
+ // snapshot as-is (null at startup) so the built-ins win.
151
+ const cached = await readFreshDiskCache();
152
+ if (cached)
153
+ snapshot = cached;
154
+ }
155
+ }
package/dist/one-shot.js CHANGED
@@ -1,15 +1,22 @@
1
1
  import chalk from 'chalk';
2
- import { loadConfig } from './config/loader.js';
2
+ import { loadConfig, resolveLocalBuild } from './config/loader.js';
3
3
  import { getStore } from './auth/api-key.js';
4
4
  import { getConnection } from './sap/connection-manager.js';
5
5
  import { AdtClient } from '@cspeach/sap-client';
6
6
  import { makeSessionId, saveSession } from './session/store.js';
7
7
  import { newSession } from './session/schema.js';
8
+ import { resolveModelRole } from './models/resolve.js';
9
+ import { loadServerModelConfig } from './models/server-config.js';
8
10
  import { createProviderForMode } from './agent/providers/factory.js';
11
+ import { assertModeLicensed, CspeachLicenseError } from './agent/providers/license-gate.js';
9
12
  import { runTurn } from './agent/loop.js';
10
- import { classifyPrompt, parseShortcut } from './router/classifier.js';
11
- import { setRenderingOverride } from './renderer/tty.js';
13
+ import { classifyPrompt, parseShortcut, rejectUnknownSkill } from './router/classifier.js';
14
+ import { setRenderingOverride, setHeadlessOverride } from './renderer/tty.js';
12
15
  import { alwaysDeclinePreviewHook } from './repl/update-method-preview-hook.js';
16
+ import { createClassicOutputEmitter } from './renderer/notices.js';
17
+ import { todoEmitter } from './ui/todo-emitter.js';
18
+ import { applyLocalBuildConfig } from './tools/local-build.js';
19
+ import { getEffectiveWriteMode } from './repl/mode-cycle.js';
13
20
  // Side-effect imports for tool registration.
14
21
  import './tools/sap-read.js';
15
22
  import './tools/sap-write.js';
@@ -18,17 +25,24 @@ import './tools/approval.js';
18
25
  import './tools/snapshot.js';
19
26
  import './tools/ask-question.js';
20
27
  import './tools/dispatch-skill.js';
28
+ import './tools/todo.js';
21
29
  import './tools/filesystem/file-read.js';
30
+ import './tools/filesystem/read-document.js';
22
31
  import './tools/filesystem/file-edit.js';
23
32
  import './tools/filesystem/file-write.js';
24
33
  import './tools/filesystem/glob.js';
25
34
  import './tools/filesystem/grep.js';
26
35
  import './tools/shell/shell_exec.js';
36
+ import './tools/fiori/tools.js';
37
+ import './tools/extend-model/tool.js';
38
+ import './tools/capability/tool.js';
27
39
  import './tools/web/web_fetch.js';
28
40
  import './tools/web/web_search.js';
29
41
  import './tools/project/project_context_get.js';
30
42
  import './tools/project/playbook_get.js';
31
43
  import './tools/project/convention_get.js';
44
+ import './tools/project/cca_merge.js';
45
+ import './tools/project/upgrade_merge_progress.js';
32
46
  import './tools/subagent/_background-shared.js';
33
47
  import './tools/subagent/background_run.js';
34
48
  import './tools/subagent/monitor_emit.js';
@@ -37,12 +51,40 @@ import './tools/subagent/agent_run.js';
37
51
  export async function runOneShot(prompt, opts) {
38
52
  const cfg = await loadConfig();
39
53
  setRenderingOverride(cfg.ui.rendering);
54
+ // model-governance step 2d — fire-and-forget fetch of the served model-config
55
+ // (managed mode only, never blocks). A turn that starts before it lands uses
56
+ // the built-ins (fail-safe).
57
+ void loadServerModelConfig(cfg);
58
+ // B5 (2026-06-11) — one-shot is headless by design: a single turn, no
59
+ // REPL, often piped (CI / battery). Any stdin prompt would hang forever
60
+ // (defect D1). The headless answer policy (ask_question auto-answers,
61
+ // save hook defaults YES, approvals fail fast) keys off this flag.
62
+ setHeadlessOverride(true);
40
63
  // Task C.10 — advisory-only is the only mode the one-shot announces, because
41
64
  // non-interactive output should stay minimal. The user needs to know that
42
65
  // writes will NOT be executed before a skill that normally writes runs.
43
- if (cfg.write_mode === 'advisory-only') {
66
+ //
67
+ // Task 3 (ux-wave1) — gate on the EFFECTIVE mode: one-shot's alias choice is
68
+ // deterministic (--sap-alias or first configured), so the role clamp can be
69
+ // computed here. A prd alias makes the run advisory even when the config
70
+ // says gated/auto — the banner must say so. No role ⇒ effective === config
71
+ // (byte-identical). Alias absent (no SAP configured) ⇒ error out below,
72
+ // same as before.
73
+ const oneShotAlias = opts?.sapAlias ?? Object.keys(cfg.sap)[0];
74
+ const oneShotRole = oneShotAlias !== undefined ? cfg.sap[oneShotAlias]?.role : undefined;
75
+ if (getEffectiveWriteMode(cfg.write_mode, oneShotRole) === 'advisory-only') {
44
76
  console.log(chalk.yellow('⚠ Advisory mode: writes will be proposed, not executed.'));
45
77
  }
78
+ // local_build toggle — force-enable the flag-gated filesystem tools AND
79
+ // shell_exec BEFORE the (single) turn when persisted-on. Resolve with backward
80
+ // compat (legacy `local_files = on` still applies). Announce so headless
81
+ // transcripts record that local file access + command execution were live
82
+ // (trust-visibility / auditability).
83
+ {
84
+ const announce = applyLocalBuildConfig(resolveLocalBuild(cfg), process.cwd());
85
+ if (announce)
86
+ console.log(chalk.dim(announce));
87
+ }
46
88
  const store = await getStore();
47
89
  if (!(await store.get())) {
48
90
  console.error('No API key configured. Run `cspeach` (interactive) first to set one.');
@@ -60,12 +102,20 @@ export async function runOneShot(prompt, opts) {
60
102
  }
61
103
  const conn = await getConnection(alias, cfg.sap[alias]);
62
104
  const adt = new AdtClient(conn);
63
- const session = newSession(makeSessionId(), alias, null, cfg.default_model);
105
+ const session = newSession(makeSessionId(), alias, null, resolveModelRole('session_default', cfg));
64
106
  await saveSession(session);
65
107
  const parsed = parseShortcut(prompt);
66
108
  const body = parsed.prompt;
67
109
  let skill;
68
110
  if (parsed.skill) {
111
+ // D35 (2026-06-11) — explicit slash naming a skill that isn't in the
112
+ // catalog. One-shot has no picker, so fail fast with the notice ("did
113
+ // you mean" / builtin-with-args) instead of dispatching to a
114
+ // nonexistent skill (and NEVER falling through to the classifier with
115
+ // the slash embedded). Shared gate: rejectUnknownSkill in classifier.ts.
116
+ if (rejectUnknownSkill(parsed, (m) => console.error(chalk.red(m)))) {
117
+ return 1;
118
+ }
69
119
  skill = parsed.skill;
70
120
  }
71
121
  else {
@@ -94,6 +144,19 @@ export async function runOneShot(prompt, opts) {
94
144
  }
95
145
  skill = c.skill;
96
146
  }
147
+ // License gate: non-managed modes (byok/local/ai-hub) bypass our proxy for
148
+ // inference, so without this anyone who never logged in could use the bundled
149
+ // skills untracked. Require a valid CSPeach login. (See license-gate.ts.)
150
+ try {
151
+ await assertModeLicensed(cfg.llm.mode, async () => (await store.get()) ?? '');
152
+ }
153
+ catch (e) {
154
+ if (e instanceof CspeachLicenseError) {
155
+ console.error(chalk.yellow(`\n${e.message}\n`));
156
+ process.exit(1);
157
+ }
158
+ throw e;
159
+ }
97
160
  const provider = createProviderForMode(cfg, async () => {
98
161
  const key = await store.get();
99
162
  if (!key)
@@ -104,7 +167,14 @@ export async function runOneShot(prompt, opts) {
104
167
  provider,
105
168
  userMessage: body,
106
169
  skill,
107
- ctx: { adt, sapAlias: alias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: alwaysDeclinePreviewHook },
170
+ // B2 fix (2026-06-12): one-shot runs render tool notices ('warn'/'info'
171
+ // — Rule 9 transport overrides, snapshot lines) to stdout instead of
172
+ // dropping them. Headless transcripts need these for auditability.
173
+ // Task 5 (ux-wave2): todoEmitter wired for the uniform top-level-ctx
174
+ // contract — no listener exists in one-shot, so emits are no-ops, but
175
+ // "top-level ctxs carry it / subagent ctxs omit it" stays the single
176
+ // rule (see ui/todo-emitter.ts).
177
+ ctx: { adt, sapAlias: alias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: alwaysDeclinePreviewHook, chunkEmitter: createClassicOutputEmitter(), todoEmitter },
108
178
  });
109
179
  return 0;
110
180
  }
@@ -0,0 +1,137 @@
1
+ import { writeFileSync } from 'node:fs';
2
+ import { readProjectFile } from './status.js';
3
+ import { validateEnvelope } from './validate.js';
4
+ /**
5
+ * The still-open BLOCKER-severity questions of a spec-gap envelope, in item
6
+ * order. Returns [] for any non-spec-gap artefact (only spec-gap has gaps with
7
+ * a severity + an `answered` status). Carries any candidate `options` so the
8
+ * answer UI can offer choices instead of a bare text box.
9
+ */
10
+ export function openBlockers(env) {
11
+ if (env.artefactType !== 'spec-gap')
12
+ return [];
13
+ const gapById = new Map();
14
+ for (const s of env.content.sections) {
15
+ for (const g of s.gaps)
16
+ gapById.set(g.id, g);
17
+ }
18
+ const out = [];
19
+ for (const it of env.interaction.items) {
20
+ if (it.status !== 'open')
21
+ continue;
22
+ const g = gapById.get(it.id);
23
+ if (g?.severity !== 'BLOCKER')
24
+ continue;
25
+ const ob = { id: it.id, text: g.text };
26
+ if (g.options && g.options.length > 0) {
27
+ ob.options = g.options;
28
+ if (g.optionsMultiSelect)
29
+ ob.multiSelect = true;
30
+ }
31
+ out.push(ob);
32
+ }
33
+ return out;
34
+ }
35
+ /**
36
+ * Return a copy of `env` with each item in `answers` marked answered. Keys with
37
+ * a blank value are ignored (treated as skipped). version + history are left
38
+ * untouched (invariant version === history.length holds); lastEditedAt/By move
39
+ * to the answerer to reflect who filled them.
40
+ */
41
+ export function applyBlockerAnswers(env, answers, answerer, nowIso) {
42
+ const items = env.interaction.items.map((it) => {
43
+ const a = answers.get(it.id);
44
+ if (a === undefined || a.trim().length === 0)
45
+ return it;
46
+ return {
47
+ ...it,
48
+ status: 'answered',
49
+ answer: a,
50
+ answeredBy: answerer,
51
+ answeredAt: nowIso,
52
+ };
53
+ });
54
+ return {
55
+ ...env,
56
+ interaction: { items },
57
+ lastEditedAt: nowIso,
58
+ lastEditedBy: answerer,
59
+ };
60
+ }
61
+ function affirmative(reply) {
62
+ const v = reply.trim().toLowerCase();
63
+ return v === 'y' || v === 'yes';
64
+ }
65
+ /**
66
+ * Offer to answer the open blockers of the just-saved spec-gap file inline.
67
+ * No-op (no prompt) when the file isn't a spec-gap or has no open blockers, so
68
+ * it is always safe to call after any save. In headless runs the prompt returns
69
+ * '' → declined → nothing happens.
70
+ *
71
+ * Returns the count recorded, or null when nothing was offered/written.
72
+ */
73
+ export async function offerAnswerBlockers(args) {
74
+ let env;
75
+ try {
76
+ env = readProjectFile(args.path);
77
+ }
78
+ catch {
79
+ return null;
80
+ }
81
+ const blockers = openBlockers(env);
82
+ if (blockers.length === 0)
83
+ return null;
84
+ const reply = await args.prompt(`Answer the ${blockers.length} unanswered blocker(s) now? [y/N]: `);
85
+ if (!affirmative(reply))
86
+ return null;
87
+ const answers = new Map();
88
+ for (let i = 0; i < blockers.length; i++) {
89
+ const b = blockers[i];
90
+ args.log('');
91
+ args.log(`Blocker ${i + 1}/${blockers.length}: ${b.text}`);
92
+ // Choices first, where the question has them and a picker is available.
93
+ // 'write' falls through to the text box; 'skip' leaves the blocker open.
94
+ let askText = true;
95
+ if (b.options && b.options.length > 0 && args.choose) {
96
+ const c = await args.choose({
97
+ question: b.text,
98
+ options: b.options,
99
+ multiSelect: !!b.multiSelect,
100
+ index: i,
101
+ total: blockers.length,
102
+ });
103
+ if (c.kind === 'picked') {
104
+ if (c.answer.trim().length > 0)
105
+ answers.set(b.id, c.answer.trim());
106
+ askText = false;
107
+ }
108
+ else if (c.kind === 'skip') {
109
+ askText = false;
110
+ }
111
+ // c.kind === 'write' → askText stays true
112
+ }
113
+ if (askText) {
114
+ const a = (await args.prompt(' Your answer (Enter to skip): ')).trim();
115
+ if (a.length > 0)
116
+ answers.set(b.id, a);
117
+ }
118
+ }
119
+ if (answers.size === 0) {
120
+ args.log('');
121
+ args.log('No answers entered — all blockers left open for the viewer.');
122
+ return null;
123
+ }
124
+ const nowIso = (args.now ?? (() => new Date().toISOString()))();
125
+ const revised = applyBlockerAnswers(env, answers, args.identity, nowIso);
126
+ const check = validateEnvelope(JSON.parse(JSON.stringify(revised)));
127
+ if (!check.ok) {
128
+ args.log(`[answer] revision invalid (${check.error.message}) — answers NOT saved.`);
129
+ return null;
130
+ }
131
+ writeFileSync(args.path, JSON.stringify(revised, null, 2), 'utf8');
132
+ const stillOpen = blockers.length - answers.size;
133
+ args.log('');
134
+ args.log(`Recorded ${answers.size} answer(s) in the file`
135
+ + (stillOpen > 0 ? ` — ${stillOpen} blocker(s) still open for the viewer.` : '.'));
136
+ return answers.size;
137
+ }
@@ -9,7 +9,7 @@
9
9
  * <!-- csforge:cca-manifest
10
10
  * artefact: cca-assessment
11
11
  * title: ZFI / ZSD — S/4HANA 2023 estate
12
- * detail_path: .abapforge/cca/projects/zfi-zsd-2026-04/project.json
12
+ * detail_path: .cspeach/cca/projects/zfi-zsd-2026-04/project.json
13
13
  * project_id: zfi-zsd-2026-04
14
14
  * scope: ZFI* + ZSD*
15
15
  * atc_variant: S4HANA_READINESS_2023
@@ -30,7 +30,9 @@
30
30
  * Parsing is permissive — missing optional fields produce defaults; a
31
31
  * missing manifest block throws so callers know to surface the error.
32
32
  */
33
- const MANIFEST_RE = /<!--\s*csforge:cca-manifest\s*\n([\s\S]*?)\n\s*-->/;
33
+ // E2 dual-read: accept both the legacy `csforge:` and current `cspeach:`
34
+ // prefixes (legacy acceptance is permanent — old saved artifacts carry it).
35
+ const MANIFEST_RE = /<!--\s*(?:csforge|cspeach):cca-manifest\s*\n([\s\S]*?)\n\s*-->/;
34
36
  function parseManifestBlock(markdown) {
35
37
  const m = MANIFEST_RE.exec(markdown);
36
38
  if (!m) {
@@ -78,6 +80,13 @@ function intOr(kv, key, fallback) {
78
80
  const n = Number.parseInt(v, 10);
79
81
  return Number.isFinite(n) ? n : fallback;
80
82
  }
83
+ function optionalInt(kv, key) {
84
+ const v = kv[key];
85
+ if (v === undefined)
86
+ return undefined;
87
+ const n = Number.parseInt(v, 10);
88
+ return Number.isFinite(n) ? n : undefined;
89
+ }
81
90
  const CLASSIFICATIONS = ['keep', 'fix', 'retire', 'redesign'];
82
91
  const CONFIDENCES = ['high', 'medium', 'low'];
83
92
  function asClassification(raw) {
@@ -92,6 +101,36 @@ function asConfidence(raw) {
92
101
  ? v
93
102
  : 'medium';
94
103
  }
104
+ /**
105
+ * Parse the optional 6th table cell on a merged row:
106
+ * `conflict=<label>:<cls>;<label>:<cls>`
107
+ * (rendered by merge-cca.ts:renderCcaMergeManifest). Returns undefined
108
+ * for absent / unrecognisable cells; invalid classification values are
109
+ * skipped pair-wise so one typo doesn't drop the whole record.
110
+ */
111
+ function parseConflictCell(raw) {
112
+ if (!raw)
113
+ return undefined;
114
+ const m = /^conflict=(.+)$/.exec(raw.trim());
115
+ if (!m)
116
+ return undefined;
117
+ const values = {};
118
+ for (const pair of m[1].split(';')) {
119
+ const idx = pair.lastIndexOf(':');
120
+ if (idx === -1)
121
+ continue;
122
+ const label = pair.slice(0, idx).trim();
123
+ const cls = pair.slice(idx + 1).trim().toLowerCase();
124
+ if (label.length === 0)
125
+ continue;
126
+ if (!CLASSIFICATIONS.includes(cls))
127
+ continue;
128
+ values[label] = cls;
129
+ }
130
+ if (Object.keys(values).length === 0)
131
+ return undefined;
132
+ return { values, rule: 'most-restrictive' };
133
+ }
95
134
  export function extractCcaAssessment(markdown) {
96
135
  const { kv, tables } = parseManifestBlock(markdown);
97
136
  if (kv.artefact !== 'cca-assessment') {
@@ -103,27 +142,82 @@ export function extractCcaAssessment(markdown) {
103
142
  if (!kv.project_id) {
104
143
  throw new Error("cca-assessment manifest missing required 'project_id'");
105
144
  }
106
- const classifications = (tables.classifications ?? []).map((row, i) => ({
107
- id: row[0] ?? `item-${String(i + 1).padStart(3, '0')}`,
108
- objectName: row[1] ?? '',
109
- objectType: row[2] ?? '',
110
- classification: asClassification(row[3] ?? 'keep'),
111
- confidence: asConfidence(row[4] ?? 'medium'),
112
- }));
145
+ const classifications = (tables.classifications ?? []).map((row, i) => {
146
+ const conflict = parseConflictCell(row[5]);
147
+ return {
148
+ id: row[0] ?? `item-${String(i + 1).padStart(3, '0')}`,
149
+ objectName: row[1] ?? '',
150
+ objectType: row[2] ?? '',
151
+ classification: asClassification(row[3] ?? 'keep'),
152
+ confidence: asConfidence(row[4] ?? 'medium'),
153
+ ...(conflict ? { conflict } : {}),
154
+ };
155
+ });
156
+ const summary = {
157
+ totalObjects: intOr(kv, 'total_objects', classifications.length),
158
+ keep: intOr(kv, 'keep', 0),
159
+ fix: intOr(kv, 'fix', 0),
160
+ retire: intOr(kv, 'retire', 0),
161
+ redesign: intOr(kv, 'redesign', 0),
162
+ uncategorized: intOr(kv, 'uncategorized', 0),
163
+ };
164
+ // ── D6/D7 manifest integrity (2026-06-12) ───────────────────────────────
165
+ // Battery S4 caught manifests whose counts contradicted their own rows
166
+ // (`retire: 0` next to 7 retire rows) and tables silently truncated
167
+ // (27/104 rows, no marker). These manifests are CHAIN INPUT for
168
+ // /abap-cca-merge — a quiet save corrupts the consolidated estate
169
+ // numbers, so a contradiction fails the extract LOUDLY instead (same
170
+ // philosophy as extract-plan's loud-failure path in save-command.ts).
171
+ const truncated = kv.classifications_truncated === 'true';
172
+ const shown = optionalInt(kv, 'classifications_shown');
173
+ const totalCls = optionalInt(kv, 'classifications_total');
174
+ const rowTally = {
175
+ keep: 0, fix: 0, retire: 0, redesign: 0,
176
+ };
177
+ for (const c of classifications)
178
+ rowTally[c.classification]++;
179
+ if (truncated) {
180
+ if (shown !== undefined && shown !== classifications.length) {
181
+ throw new Error(`cca-assessment manifest contradiction: classifications_shown is ${shown} but the table has ${classifications.length} rows.`);
182
+ }
183
+ for (const cls of ['keep', 'fix', 'retire', 'redesign']) {
184
+ if (rowTally[cls] > summary[cls]) {
185
+ throw new Error(`cca-assessment manifest contradiction: ${rowTally[cls]} '${cls}' rows in the table but the summary says ${cls}: ${summary[cls]}. `
186
+ + `Counts must cover every row even when the table is truncated.`);
187
+ }
188
+ }
189
+ if (totalCls !== undefined && totalCls < classifications.length) {
190
+ throw new Error(`cca-assessment manifest contradiction: classifications_total (${totalCls}) is smaller than the ${classifications.length} rows shown.`);
191
+ }
192
+ }
193
+ else {
194
+ for (const cls of ['keep', 'fix', 'retire', 'redesign']) {
195
+ if (rowTally[cls] !== summary[cls]) {
196
+ throw new Error(`cca-assessment manifest contradiction: summary says ${cls}: ${summary[cls]} but the table has ${rowTally[cls]} '${cls}' rows. `
197
+ + `Counts must equal rows — if the table was capped, mark it with classifications_truncated: true + classifications_shown + classifications_total.`);
198
+ }
199
+ }
200
+ }
201
+ // total_objects must equal the class-count sum in BOTH branches — the
202
+ // summary counts cover the full estate even when the table is capped.
203
+ const expectedTotal = summary.keep + summary.fix + summary.retire + summary.redesign + summary.uncategorized;
204
+ if (summary.totalObjects !== expectedTotal) {
205
+ throw new Error(`cca-assessment manifest contradiction: total_objects is ${summary.totalObjects} but keep+fix+retire+redesign+uncategorized = ${expectedTotal}.`);
206
+ }
113
207
  const content = {
114
208
  detailPath: kv.detail_path,
115
209
  projectId: kv.project_id,
116
210
  scope: kv.scope ?? '',
117
211
  atcVariant: kv.atc_variant ?? '',
118
- summary: {
119
- totalObjects: intOr(kv, 'total_objects', classifications.length),
120
- keep: intOr(kv, 'keep', 0),
121
- fix: intOr(kv, 'fix', 0),
122
- retire: intOr(kv, 'retire', 0),
123
- redesign: intOr(kv, 'redesign', 0),
124
- uncategorized: intOr(kv, 'uncategorized', 0),
125
- },
212
+ summary,
126
213
  classifications,
214
+ ...(truncated
215
+ ? {
216
+ classificationsTruncated: true,
217
+ ...(shown !== undefined ? { classificationsShown: shown } : {}),
218
+ ...(totalCls !== undefined ? { classificationsTotal: totalCls } : {}),
219
+ }
220
+ : {}),
127
221
  };
128
222
  // One InteractionItem per classification entry, default status 'open'
129
223
  // (auto-classified by the AI, awaiting human review).
@@ -11,7 +11,7 @@
11
11
  * <!-- csforge:modernize-manifest
12
12
  * artefact: modernize-result
13
13
  * title: ZDPR_PAYMENTS — modernization
14
- * detail_path: .abapforge/modernize/zdpr-payments/project.json
14
+ * detail_path: .cspeach/modernize/zdpr-payments/project.json
15
15
  * baseline_path: <project-relative path>
16
16
  * baseline_sha256: <hex>
17
17
  * transport: <id or ->
@@ -27,7 +27,9 @@
27
27
  * Parsing is permissive — missing optional fields produce defaults; a
28
28
  * missing manifest block throws so callers know to surface the error.
29
29
  */
30
- const MANIFEST_RE = /<!--\s*csforge:modernize-manifest\s*\n([\s\S]*?)\n\s*-->/;
30
+ // E2 dual-read: accept both the legacy `csforge:` and current `cspeach:`
31
+ // prefixes (legacy acceptance is permanent — old saved artifacts carry it).
32
+ const MANIFEST_RE = /<!--\s*(?:csforge|cspeach):modernize-manifest\s*\n([\s\S]*?)\n\s*-->/;
31
33
  function parseManifestBlock(markdown) {
32
34
  const m = MANIFEST_RE.exec(markdown);
33
35
  if (!m) {