@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
@@ -1,6 +1,8 @@
1
1
  import fs from 'node:fs/promises';
2
+ import { isDeepStrictEqual } from 'node:util';
2
3
  import toml from '@iarna/toml';
3
4
  import { configFile, cspeachRoot } from './paths.js';
5
+ import { DEFAULT_SESSION_MODEL } from './model-defaults.js';
4
6
  /**
5
7
  * Whitelist guard for WriteMode — rejects typos / stale values from disk so
6
8
  * downstream code that trusts the WriteMode type doesn't see garbage.
@@ -9,9 +11,9 @@ import { configFile, cspeachRoot } from './paths.js';
9
11
  export function isValidWriteMode(v) {
10
12
  return v === 'auto' || v === 'approval-gated' || v === 'advisory-only';
11
13
  }
12
- const DEFAULT_CONFIG = {
14
+ export const DEFAULT_CONFIG = {
13
15
  proxy_url: 'https://api.cspeach.dev',
14
- default_model: 'claude-opus-4-8',
16
+ default_model: DEFAULT_SESSION_MODEL,
15
17
  effort: 'xhigh',
16
18
  telemetry: 'minimal',
17
19
  sap: {},
@@ -26,6 +28,13 @@ const DEFAULT_CONFIG = {
26
28
  write_mode: 'approval-gated',
27
29
  // Default `managed` — lowest-friction entry point; uses the cspeach.dev proxy.
28
30
  llm: { mode: 'managed' },
31
+ // local_build is intentionally NOT defaulted here. If it were set to `false`,
32
+ // the `...DEFAULT_CONFIG` spread would leave merged.local_build defined even
33
+ // when the file omits the key — which permanently defeats resolveLocalBuild's
34
+ // `local_build ?? local_files` fallback (the legacy `local_files = on` case
35
+ // could never fire). Absence must stay `undefined` so the fallback can read
36
+ // the legacy key. The effective default-off is enforced by resolveLocalBuild
37
+ // (returns false when both are undefined). See loadConfig coercion below.
29
38
  // Defaults tuned 2026-05-16 from session 0618a42d evidence — see CompactConfig doc.
30
39
  compact: {
31
40
  keep_recent_turns: 5,
@@ -62,6 +71,25 @@ function sanitiseCompact(raw) {
62
71
  min_turns_between_auto: intInRange(r.min_turns_between_auto, d.min_turns_between_auto, 1, 100),
63
72
  };
64
73
  }
74
+ /**
75
+ * Resolve the effective TLS options for a SAP system, applying the legacy
76
+ * `sslVerify: false` → insecure mapping. A configured `ca_cert_path` always
77
+ * wins (verification on). Pure — no I/O. Shared by the connection-manager and
78
+ * doctor checks so they agree on the posture.
79
+ */
80
+ export function resolveSapTls(sys) {
81
+ if (sys.ca_cert_path) {
82
+ return { mode: 'ca-trusted', caCertPath: sys.ca_cert_path, insecureSkipTlsVerify: false };
83
+ }
84
+ // Explicit insecure opt-in, OR the legacy sslVerify:false mapping.
85
+ const insecure = sys.insecure_skip_tls_verify === true || sys.sslVerify === false;
86
+ if (insecure) {
87
+ return { mode: 'insecure', insecureSkipTlsVerify: true };
88
+ }
89
+ return { mode: 'verify', insecureSkipTlsVerify: false };
90
+ }
91
+ /** Tracks whether the one-time legacy-sslVerify deprecation note has printed. */
92
+ let legacySslVerifyNoticeShown = false;
65
93
  export async function loadConfig() {
66
94
  try {
67
95
  const raw = await fs.readFile(configFile(), 'utf-8');
@@ -87,10 +115,75 @@ export async function loadConfig() {
87
115
  else {
88
116
  allow = [];
89
117
  }
90
- return {
118
+ const merged = {
91
119
  ...DEFAULT_CONFIG,
92
120
  ...parsed,
121
+ // Coerce defensively, but PRESERVE the absent→undefined distinction so the
122
+ // legacy fallback in resolveLocalBuild can fire:
123
+ // - explicit `true` → true
124
+ // - explicit `false`/off → false (defined; overrides legacy)
125
+ // - present-but-invalid (string) → false (defined; fails safe, can't
126
+ // grant access via a typo, but DOES override a stale legacy on)
127
+ // - absent / undefined → undefined (legacy fallback applies)
128
+ // A defined `false` (whether explicit or typo) must win the
129
+ // `local_build ?? local_files` resolution so an explicit off is honoured.
130
+ local_build: parsed.local_build === true
131
+ ? true
132
+ : parsed.local_build === undefined
133
+ ? undefined
134
+ : false,
135
+ // Preserve a legacy `local_files` value (read-only backward compat) ONLY
136
+ // when it's strictly true — a string/typo collapses to "absent" so it
137
+ // can't win the `local_build ?? local_files` fallback.
138
+ local_files: parsed.local_files === true ? true : undefined,
139
+ // render_smoke: plain boolean, DEFAULT true (no legacy alias). Preserve
140
+ // only a genuine boolean; a typo/wrong-type collapses to undefined which
141
+ // resolveRenderSmoke reads as the default ON (fail-safe: a config typo can
142
+ // never silently disable the smoke).
143
+ render_smoke: typeof parsed.render_smoke === 'boolean' ? parsed.render_smoke : undefined,
93
144
  write_mode: isValidWriteMode(parsed.write_mode) ? parsed.write_mode : DEFAULT_CONFIG.write_mode,
145
+ // plan_mode: whitelist coercion — anything but the two valid literals
146
+ // (typos, wrong types) collapses to undefined, which callers treat as
147
+ // 'step' (fail-safe: a config typo can only make chaining MORE manual).
148
+ plan_mode: parsed.plan_mode === 'step' || parsed.plan_mode === 'guarded'
149
+ ? parsed.plan_mode
150
+ : undefined,
151
+ // plan_audit: whitelist coercion — anything but the two valid literals
152
+ // (typos, wrong types) collapses to undefined, which callers treat as
153
+ // 'on' (fail-safe: a config typo can only leave audits ON, never off).
154
+ plan_audit: parsed.plan_audit === 'on' || parsed.plan_audit === 'off'
155
+ ? parsed.plan_audit
156
+ : undefined,
157
+ // audit_model: preserve only a non-empty string (trimmed); a wrong-type /
158
+ // empty value collapses to undefined so resolveAuditModel reads the safe
159
+ // Sonnet default. Mirrors the optional-key hygiene of plan_mode/render_smoke.
160
+ audit_model: typeof parsed.audit_model === 'string' && parsed.audit_model.trim().length > 0
161
+ ? parsed.audit_model.trim()
162
+ : undefined,
163
+ // compact_model: mirrors audit_model — preserve only a non-empty trimmed
164
+ // string; a wrong-type / empty value collapses to undefined so
165
+ // resolveModelRole reads the safe Haiku default.
166
+ compact_model: typeof parsed.compact_model === 'string' && parsed.compact_model.trim().length > 0
167
+ ? parsed.compact_model.trim()
168
+ : undefined,
169
+ // default_model_set: INTERNAL flag — true iff the FILE carried the
170
+ // `default_model` key (file presence, not the value). Lets an explicit user
171
+ // choice beat a served roles.session_default. Stripped by saveConfig.
172
+ default_model_set: Object.prototype.hasOwnProperty.call(parsed, 'default_model'),
173
+ // sap: ALWAYS a fresh top-level object, never the shared DEFAULT_CONFIG.sap
174
+ // reference. Without this, a file lacking `[sap.*]` would leave
175
+ // `merged.sap === DEFAULT_CONFIG.sap`; the onboarding wizard then mutating
176
+ // `cfg.sap[alias] = {...}` would poison the module constant, and saveConfig's
177
+ // `isDeepStrictEqual(writable.sap, DEFAULT_CONFIG.sap)` would compare the
178
+ // object to itself → true → drop the just-added system. Alias sub-objects
179
+ // come fresh from toml.parse (or are whole-object assignments in onboarding),
180
+ // so a fresh container suffices — no deep clone of the aliases needed.
181
+ sap: { ...(parsed.sap ?? {}) },
182
+ // file_keys: INTERNAL — the top-level keys the FILE carried, snapshotted
183
+ // from `parsed` BEFORE the merge (so the absent-key `delete merged.x`
184
+ // hygiene below never affects it). Drives sparse serialisation in
185
+ // saveConfig; stripped there so it never reaches config.toml.
186
+ file_keys: Object.keys(parsed),
94
187
  llm: { ...DEFAULT_CONFIG.llm, ...(parsed.llm ?? {}) },
95
188
  classifier: { ...DEFAULT_CONFIG.classifier, ...(parsed.classifier ?? {}) },
96
189
  ui: { ...DEFAULT_CONFIG.ui, ...(parsed.ui ?? {}) },
@@ -100,14 +193,139 @@ export async function loadConfig() {
100
193
  compact: sanitiseCompact(parsed.compact),
101
194
  shell_exec: parsed.shell_exec === undefined ? undefined : { allow },
102
195
  };
196
+ // Drop the legacy `local_files` key entirely when it isn't a genuine on
197
+ // (the `...parsed` spread above can leave a `local_files = false` behind,
198
+ // and our explicit coercion sets it to `undefined`). Removing the key keeps
199
+ // `config show` and saveConfig output clean — no `local_files = undefined`.
200
+ if (merged.local_files === undefined)
201
+ delete merged.local_files;
202
+ // Same for `local_build`: when the key is absent from disk we coerce it to
203
+ // `undefined` (so resolveLocalBuild's legacy fallback can fire). Delete the
204
+ // key so it doesn't serialise as `local_build = undefined` and so callers
205
+ // see a clean "absent" shape. resolveLocalBuild treats missing === undefined.
206
+ if (merged.local_build === undefined)
207
+ delete merged.local_build;
208
+ // Same for `render_smoke`: absent-or-invalid must not serialise as
209
+ // `render_smoke = undefined` — drop the key so `config show` stays clean and
210
+ // callers see a genuine "absent" shape (resolveRenderSmoke treats it as ON).
211
+ if (merged.render_smoke === undefined)
212
+ delete merged.render_smoke;
213
+ // Same for `plan_mode`: absent-or-invalid must not serialise as
214
+ // `plan_mode = undefined` — drop the key so `config show` stays clean and
215
+ // callers see a genuine "absent" shape (treated as 'step').
216
+ if (merged.plan_mode === undefined)
217
+ delete merged.plan_mode;
218
+ // Same for `plan_audit`: absent-or-invalid must not serialise as
219
+ // `plan_audit = undefined` — drop the key so `config show` stays clean and
220
+ // callers see a genuine "absent" shape (treated as 'on').
221
+ if (merged.plan_audit === undefined)
222
+ delete merged.plan_audit;
223
+ // Same for `audit_model`: absent-or-invalid must not serialise as
224
+ // `audit_model = undefined` — drop the key so `config show` stays clean and
225
+ // callers see a genuine "absent" shape (resolveAuditModel reads the default).
226
+ if (merged.audit_model === undefined)
227
+ delete merged.audit_model;
228
+ // Same for `compact_model`: absent-or-invalid must not serialise as
229
+ // `compact_model = undefined` — drop the key so `config show` stays clean and
230
+ // callers see a genuine "absent" shape (resolveModelRole reads the default).
231
+ if (merged.compact_model === undefined)
232
+ delete merged.compact_model;
233
+ // Per-alias `role`: whitelist coercion — anything but the three valid
234
+ // literals (typos like "production", wrong types) collapses to absent, so
235
+ // downstream ceiling logic sees a clean `undefined` (= no ceiling, same
236
+ // behavior as before roles existed). Mirrors the plan_mode pattern.
237
+ for (const sys of Object.values(merged.sap ?? {})) {
238
+ if (sys.role !== 'dev' && sys.role !== 'qas' && sys.role !== 'prd') {
239
+ delete sys.role;
240
+ }
241
+ }
242
+ // One-time deprecation note: a SAP system still relies on the legacy
243
+ // `sslVerify` toggle and has not adopted either new key. It KEEPS WORKING
244
+ // (resolveSapTls maps sslVerify:false → insecure), but we nudge the user
245
+ // once per process toward `ca_cert_path` (preferred) or the explicit
246
+ // insecure flag. Only fires when sslVerify is present AND no new key is set.
247
+ if (!legacySslVerifyNoticeShown) {
248
+ const legacyAlias = Object.entries(merged.sap ?? {}).find(([, s]) => typeof s?.sslVerify === 'boolean' &&
249
+ s.ca_cert_path === undefined &&
250
+ s.insecure_skip_tls_verify === undefined);
251
+ if (legacyAlias) {
252
+ legacySslVerifyNoticeShown = true;
253
+ const [alias, sys] = legacyAlias;
254
+ const suggestion = sys.sslVerify === false
255
+ ? `set 'ca_cert_path' to your corporate CA (keeps verification ON), or set 'insecure_skip_tls_verify = true' to keep verification off (dev only, logs a warning)`
256
+ : `the default is now verify-ON; you can drop 'sslVerify' or set 'ca_cert_path' for internal-CA systems`;
257
+ process.stderr.write(`[cspeach] note: [sap.${alias}] uses the deprecated 'sslVerify' key — ${suggestion}.\n`);
258
+ }
259
+ }
260
+ return merged;
103
261
  }
104
262
  catch (err) {
263
+ // Absent file → defaults with an empty file_keys snapshot (nothing was
264
+ // user-set), so saveConfig(loadConfig()) on a missing file writes a minimal
265
+ // file rather than baking every default in. DEEP-copy so a first-ever nested
266
+ // mutation on a fresh install (onboarding `cfg.sap[alias] = …`, `cfg.llm.mode
267
+ // = …`, `cfg.ui.rendering = …`) can never poison the shared DEFAULT_CONFIG
268
+ // constant — which would make saveConfig's deep-equal check compare a table to
269
+ // itself and silently drop the user's data. A shallow spread shares every
270
+ // nested table by reference and is NOT safe here.
105
271
  if (err?.code === 'ENOENT')
106
- return DEFAULT_CONFIG;
272
+ return { ...structuredClone(DEFAULT_CONFIG), file_keys: [] };
107
273
  throw err;
108
274
  }
109
275
  }
276
+ /**
277
+ * Resolve the effective "local build tools" toggle from a loaded config.
278
+ *
279
+ * Backward compat (2026-06-13 rename): the new key is `local_build`; the legacy
280
+ * key is `local_files`. A user who set `local_files = on` before the rename
281
+ * must keep working without re-running `config set`. The rule is:
282
+ *
283
+ * effective = local_build ?? local_files (default false)
284
+ *
285
+ * `local_build` always wins when present (so `config set local_build off`
286
+ * overrides a stale legacy `local_files = on`). Only when `local_build` is
287
+ * undefined does the legacy value apply. Both keys are already strictly coerced
288
+ * to `true | undefined` by loadConfig, so this is a pure read.
289
+ */
290
+ export function resolveLocalBuild(cfg) {
291
+ if (cfg.local_build !== undefined)
292
+ return cfg.local_build === true;
293
+ return cfg.local_files === true;
294
+ }
295
+ /**
296
+ * Resolve the effective `render_smoke` toggle (Track 2 D-4). Unlike
297
+ * `local_build`, this has NO legacy alias, so the rule is a plain default-true:
298
+ *
299
+ * effective = render_smoke ?? true
300
+ *
301
+ * `render_smoke` is already coerced to `true | false | undefined` by loadConfig
302
+ * (a typo collapses to undefined), so absent/typo → ON and only an explicit
303
+ * `false` turns the browser-driven smoke into a skipped manual result.
304
+ */
305
+ export function resolveRenderSmoke(cfg) {
306
+ return cfg.render_smoke ?? true;
307
+ }
110
308
  export async function saveConfig(config) {
111
309
  await fs.mkdir(cspeachRoot(), { recursive: true });
112
- await fs.writeFile(configFile(), toml.stringify(config), 'utf-8');
310
+ // The set of top-level keys the FILE carried (empty when it was absent).
311
+ // A key stays in the file if the user had it there OR its value now differs
312
+ // from the built-in default — see the sparse rule below.
313
+ const fileKeys = config.file_keys ?? [];
314
+ // Strip the INTERNAL flags — both are derived on load (file presence /
315
+ // file-key snapshot) and must never be written back into config.toml.
316
+ const { default_model_set: _dropSet, file_keys: _dropKeys, ...toWrite } = config;
317
+ // Sparse serialisation: drop any key that the file did NOT carry AND whose
318
+ // value is deep-equal to the built-in default. Keys absent from DEFAULT_CONFIG
319
+ // (optional keys like audit_model) never deep-equal a defined value, so any
320
+ // set value is written. This keeps `config set <one key>` from baking every
321
+ // default (notably default_model) into config.toml and opting the user out of
322
+ // the model-governance server steering. All four saveConfig callers set
323
+ // non-default values, so the value-diff side keeps them working unchanged.
324
+ const writable = toWrite;
325
+ for (const k of Object.keys(writable)) {
326
+ if (!fileKeys.includes(k) && isDeepStrictEqual(writable[k], DEFAULT_CONFIG[k])) {
327
+ delete writable[k];
328
+ }
329
+ }
330
+ await fs.writeFile(configFile(), toml.stringify(writable), 'utf-8');
113
331
  }
@@ -0,0 +1,14 @@
1
+ // model-governance step 2d (2026-07-10) — the session-default built-in model id,
2
+ // factored into a leaf module so it has ONE source of truth without forcing
3
+ // resolve.ts to import it from loader.ts.
4
+ //
5
+ // Why not import DEFAULT_CONFIG from loader.ts directly: several tests fully
6
+ // mock '../config/loader.js' (e.g. classifier-confidence.integration), and a
7
+ // total mock does not re-export DEFAULT_CONFIG. resolveModelRole runs inside
8
+ // those test paths (via one-shot.ts / repl.tsx), so reading DEFAULT_CONFIG from
9
+ // the mocked module would throw "No DEFAULT_CONFIG export defined on the mock".
10
+ // This leaf module is never mocked, so both loader.ts (which builds
11
+ // DEFAULT_CONFIG from it) and resolve.ts read the identical constant — no
12
+ // duplication, no drift, no mock fragility.
13
+ /** The built-in session-default model id — today's DEFAULT_CONFIG.default_model. */
14
+ export const DEFAULT_SESSION_MODEL = 'claude-opus-4-8';
@@ -22,6 +22,7 @@
22
22
  * Unknown models → returns null + records the model name to a process-level
23
23
  * Set so we surface them as an obvious warning rather than silently zeroing.
24
24
  */
25
+ import { getServerModelConfig } from '../models/server-config.js';
25
26
  const PRICING = {
26
27
  // Claude Opus 4.5+ — input 5, output 25, cache_read 0.50, cache_write 6.25 (1.25x input).
27
28
  // 2026-06-07 correction: 4.5/4.6/4.7 were carried at the LEGACY $15/$75; actual
@@ -42,6 +43,20 @@ const PRICING = {
42
43
  'claude-haiku-4': { inputPer1M: 1.00, outputPer1M: 5.00, cacheReadPer1M: 0.10, cacheCreatePer1M: 1.25 },
43
44
  };
44
45
  const seenUnknownModels = new Set();
46
+ /**
47
+ * Normalise a served rate (whose `cacheCreatePer1M` is optional in the proxy
48
+ * shape) to the CLI's full ModelRate. When the served entry omits the cache
49
+ * write rate we derive it as 1.25× input — the same convention this file's
50
+ * built-in table applies per Anthropic's docs.
51
+ */
52
+ function fromServerRate(r) {
53
+ return {
54
+ inputPer1M: r.inputPer1M,
55
+ outputPer1M: r.outputPer1M,
56
+ cacheReadPer1M: r.cacheReadPer1M,
57
+ cacheCreatePer1M: r.cacheCreatePer1M ?? r.inputPer1M * 1.25,
58
+ };
59
+ }
45
60
  /**
46
61
  * Look up a per-1M-token rate for a model. Normalises to lowercase and
47
62
  * strips Anthropic's optional `-YYYYMMDD` date suffix so e.g.
@@ -53,9 +68,20 @@ const seenUnknownModels = new Set();
53
68
  */
54
69
  export function getRate(model) {
55
70
  const normalized = model.toLowerCase();
71
+ const stripDate = normalized.replace(/-\d{8}$/, '');
72
+ // Served model-governance map wins over the built-in table (model-governance
73
+ // step 4). Same normalization as the built-in path: lowercase + strip the
74
+ // optional `-YYYYMMDD` date suffix. The built-in PRICING below stays as the
75
+ // offline fallback (locked decision Q4). The snapshot is sync (server-config
76
+ // module snapshot), so getRate stays sync.
77
+ const served = getServerModelConfig()?.pricing;
78
+ if (served) {
79
+ const hit = served[normalized] ?? served[stripDate];
80
+ if (hit)
81
+ return fromServerRate(hit);
82
+ }
56
83
  if (PRICING[normalized])
57
84
  return PRICING[normalized];
58
- const stripDate = normalized.replace(/-\d{8}$/, '');
59
85
  if (PRICING[stripDate])
60
86
  return PRICING[stripDate];
61
87
  if (!seenUnknownModels.has(normalized)) {
@@ -35,6 +35,7 @@ export function probeGet(url, opts) {
35
35
  path: u.pathname + u.search,
36
36
  timeout: opts.timeoutMs ?? 8000,
37
37
  rejectUnauthorized: opts.rejectUnauthorized,
38
+ ...(opts.ca !== undefined ? { ca: opts.ca } : {}),
38
39
  headers,
39
40
  }, (res) => {
40
41
  const chunks = [];
@@ -1,5 +1,5 @@
1
1
  import tls from 'node:tls';
2
- import { loadConfig } from '../../config/loader.js';
2
+ import { loadConfig, resolveSapTls } from '../../config/loader.js';
3
3
  export async function checkCert() {
4
4
  const cfg = await loadConfig();
5
5
  const systems = Object.entries(cfg.sap).filter(([_, s]) => s.useSsl);
@@ -7,14 +7,25 @@ export async function checkCert() {
7
7
  return { name: 'cert', ok: true, message: 'n/a (no TLS SAP systems configured)' };
8
8
  const results = [];
9
9
  for (const [alias, sys] of systems) {
10
+ const tlsMode = resolveSapTls(sys);
10
11
  const info = await new Promise((resolve) => {
12
+ // Probe with rejectUnauthorized:false so we can always read the cert and
13
+ // report the OS-trust chain status, then describe the configured posture
14
+ // separately (verify / CA-trusted / INSECURE).
11
15
  const socket = tls.connect({ host: sys.host, port: sys.port, rejectUnauthorized: false, timeout: 5000 }, () => {
12
16
  const cert = socket.getPeerCertificate();
13
17
  const authorized = socket.authorized;
14
18
  socket.end();
15
19
  const chainValid = authorized ? 'yes' : 'no';
16
- const clientAccepted = !sys.sslVerify ? 'yes (trust-manager allows self-signed)' : (authorized ? 'yes' : 'no');
17
- resolve(`${alias}: chain valid: ${chainValid} / client accepted: ${clientAccepted} / subject: ${cert.subject?.CN ?? '?'}`);
20
+ // What the configured client mode means for acceptance:
21
+ // verify → accepts only if the OS/Node trust chain validates
22
+ // CA-trusted → accepts via the configured CA (verification ON)
23
+ // INSECURE → accepts anything (verification OFF — dev only)
24
+ const clientAccepted = tlsMode.mode === 'insecure' ? 'yes (INSECURE — verification disabled, dev only)'
25
+ : tlsMode.mode === 'ca-trusted' ? 'yes (CA-trusted — verification ON via configured CA)'
26
+ : (authorized ? 'yes' : 'no (verify ON — configure ca_cert_path to trust an internal CA)');
27
+ resolve(`${alias}: mode: ${tlsMode.mode} / chain valid: ${chainValid} / ` +
28
+ `client accepted: ${clientAccepted} / subject: ${cert.subject?.CN ?? '?'}`);
18
29
  });
19
30
  socket.on('error', (err) => resolve(`${alias}: ${err.message}`));
20
31
  });
@@ -1,4 +1,5 @@
1
- import { loadConfig } from '../../config/loader.js';
1
+ import { readFile } from 'node:fs/promises';
2
+ import { loadConfig, resolveSapTls } from '../../config/loader.js';
2
3
  import { probeGet } from './_http-probe.js';
3
4
  export async function checkSap() {
4
5
  const cfg = await loadConfig();
@@ -9,23 +10,44 @@ export async function checkSap() {
9
10
  let anyFailed = false;
10
11
  for (const [alias, sys] of systems) {
11
12
  const url = `${sys.useSsl ? 'https' : 'http'}://${sys.host}:${sys.port}/sap/bc/adt/discovery`;
12
- // Use node's https directly so we can honor sslVerify=false for
13
- // self-signed dev systems — Node's built-in fetch() doesn't expose a
14
- // per-request TLS-verify toggle. See checks/_http-probe.ts.
13
+ // TLS posture (2026-06-14): verification ON by default. resolveSapTls maps
14
+ // the per-alias config (ca_cert_path / insecure_skip_tls_verify, legacy
15
+ // sslVerify:false fallback). Node's built-in fetch() doesn't expose a
16
+ // per-request TLS-verify toggle, so we use node's https directly — see
17
+ // checks/_http-probe.ts.
18
+ const tls = resolveSapTls(sys);
19
+ // Tag the connectivity result with the cert mode so doctor surfaces it.
20
+ const modeTag = tls.mode === 'verify' ? 'verify'
21
+ : tls.mode === 'ca-trusted' ? 'CA-trusted'
22
+ : 'INSECURE';
23
+ let ca;
24
+ if (tls.mode === 'ca-trusted' && tls.caCertPath) {
25
+ try {
26
+ ca = await readFile(tls.caCertPath);
27
+ }
28
+ catch (e) {
29
+ // Fail closed, like the connection path: a configured-but-unreadable CA
30
+ // is an error, not a reason to drop verification.
31
+ results.push(`${alias}: CA unreadable at ${tls.caCertPath} (${e.message})`);
32
+ anyFailed = true;
33
+ continue;
34
+ }
35
+ }
15
36
  const r = await probeGet(url, {
16
- rejectUnauthorized: sys.sslVerify,
37
+ rejectUnauthorized: tls.mode !== 'insecure',
38
+ ca,
17
39
  timeoutMs: 8000,
18
40
  });
19
41
  if (r.error) {
20
- results.push(`${alias}: ${r.error}`);
42
+ results.push(`${alias}: ${r.error} [${modeTag}]`);
21
43
  anyFailed = true;
22
44
  }
23
45
  else if (r.status === 200 || r.status === 401) {
24
46
  // 200 = anonymous discovery allowed; 401 = reachable + auth-required (expected, fine)
25
- results.push(`${alias}: reachable`);
47
+ results.push(`${alias}: reachable [${modeTag}]`);
26
48
  }
27
49
  else {
28
- results.push(`${alias}: HTTP ${r.status}`);
50
+ results.push(`${alias}: HTTP ${r.status} [${modeTag}]`);
29
51
  anyFailed = true;
30
52
  }
31
53
  }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Doctor check: system-roles (UX Wave 1, Task 1).
3
+ *
4
+ * Surfaces the per-alias `role` (dev | qas | prd) that feeds the write-mode
5
+ * ceiling (repl/mode-ceiling.ts): prd → advisory-only, qas → approval-gated,
6
+ * dev/unset → no ceiling.
7
+ *
8
+ * An alias WITHOUT a role is a warn, not a failure — the ceiling only ADDS
9
+ * restriction, so "unset" is a valid (if risky-on-prd-boxes) configuration.
10
+ * Warn renders via the yellow skipped state (ok:false + skipped:true), which
11
+ * never counts toward the doctor's failed total. Pure — no I/O beyond cfg.
12
+ */
13
+ export async function checkSystemRoles(cfg) {
14
+ const entries = Object.entries(cfg.sap ?? {});
15
+ if (entries.length === 0) {
16
+ return {
17
+ name: 'system-roles',
18
+ ok: false,
19
+ skipped: true,
20
+ message: 'no SAP system configured',
21
+ hint: 'Run: cspeach config add-sap',
22
+ };
23
+ }
24
+ const unset = entries.filter(([, sys]) => sys.role === undefined).map(([alias]) => alias);
25
+ const set = entries.filter(([, sys]) => sys.role !== undefined);
26
+ if (unset.length === 0) {
27
+ return {
28
+ name: 'system-roles',
29
+ ok: true,
30
+ message: `roles set: ${set.map(([alias, sys]) => `${alias}=${sys.role}`).join(', ')}`,
31
+ };
32
+ }
33
+ // Warn-level: yellow, never fails doctor.
34
+ return {
35
+ name: 'system-roles',
36
+ ok: false,
37
+ skipped: true,
38
+ message: `${unset.join(', ')}: role unset: treated as dev (no ceiling)`,
39
+ hint: `set with cspeach config set sap.${unset[0]}.role prd|qas|dev`,
40
+ };
41
+ }
@@ -1,5 +1,6 @@
1
1
  import keytar from 'keytar';
2
- import { loadConfig } from '../../config/loader.js';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { loadConfig, resolveSapTls } from '../../config/loader.js';
3
4
  import { probeGet } from './_http-probe.js';
4
5
  const SAP_SERVICE = 'cspeach-sap';
5
6
  export async function checkZcspeach() {
@@ -29,10 +30,24 @@ export async function checkZcspeach() {
29
30
  results.push(`${alias}: skipped (no password in keychain)`);
30
31
  continue;
31
32
  }
32
- // Use node's https directly so we can honor sslVerify=false for
33
- // self-signed dev systems. See checks/_http-probe.ts.
33
+ // TLS posture (2026-06-14): verification ON by default; resolveSapTls maps
34
+ // the per-alias config (ca_cert_path / insecure_skip_tls_verify, legacy
35
+ // sslVerify:false fallback). See checks/_http-probe.ts.
36
+ const tls = resolveSapTls(sys);
37
+ let ca;
38
+ if (tls.mode === 'ca-trusted' && tls.caCertPath) {
39
+ try {
40
+ ca = await readFile(tls.caCertPath);
41
+ }
42
+ catch (e) {
43
+ results.push(`${alias}: CA unreadable at ${tls.caCertPath} (${e.message})`);
44
+ anyFailed = true;
45
+ continue;
46
+ }
47
+ }
34
48
  const r = await probeGet(url, {
35
- rejectUnauthorized: sys.sslVerify,
49
+ rejectUnauthorized: tls.mode !== 'insecure',
50
+ ca,
36
51
  auth: `${sys.username}:${pwd}`,
37
52
  accept: 'application/json',
38
53
  timeoutMs: 5000,
@@ -11,6 +11,7 @@ import { checkKeychainFallback } from './checks/keychain-fallback.js';
11
11
  import { checkWriteMode } from './checks/write-mode.js';
12
12
  import { checkLlmMode } from './checks/llm-mode.js';
13
13
  import { checkForgeRules } from './checks/forge-rules.js';
14
+ import { checkSystemRoles } from './checks/system-roles.js';
14
15
  export async function runDoctor() {
15
16
  console.log(chalk.cyan.bold('\nCSPeach — doctor\n'));
16
17
  const cfg = await loadConfig();
@@ -19,6 +20,7 @@ export async function runDoctor() {
19
20
  checks.push(await checkAuth());
20
21
  checks.push(await checkLlmMode(cfg));
21
22
  checks.push(await checkWriteMode(cfg));
23
+ checks.push(await checkSystemRoles(cfg));
22
24
  checks.push(await checkKeychain());
23
25
  checks.push(await checkSap());
24
26
  checks.push(await checkZcspeach());
@@ -0,0 +1,61 @@
1
+ // model-governance step 2d (2026-07-10) — the generalized role→model resolver.
2
+ //
3
+ // resolveAuditModel (commands/plan-audit.ts) was the prototype: a pure function
4
+ // that layered env > local config > built-in for ONE role. This generalizes it
5
+ // to every CLI pipeline role and inserts the SERVED config (server-config.ts's
6
+ // module snapshot) as a new layer BELOW local config and ABOVE the built-in
7
+ // constant:
8
+ //
9
+ // env > local config > server > built-in constant
10
+ //
11
+ // Locked invariant: with NOTHING set (no env, default_model_set false, no server
12
+ // snapshot) every role resolves to today's constant — byte-identical to before
13
+ // this feature.
14
+ //
15
+ // Pure in the resolveAuditModel sense: no async I/O, no fetch. The only external
16
+ // read is getServerModelConfig(), a synchronous in-memory snapshot the caller
17
+ // (or the startup fetch) has already populated. `router` is proxy-side only and
18
+ // is deliberately NOT a CLI role.
19
+ import { DEFAULT_SESSION_MODEL } from '../config/model-defaults.js';
20
+ import { PLAN_TIER_SONNET_MODEL } from '../commands/plan-model-tier.js';
21
+ import { COMPACTION_MODEL } from '../commands/compact.js';
22
+ import { getServerModelConfig } from './server-config.js';
23
+ /** First non-empty (trimmed) candidate in precedence order, else the built-in. */
24
+ function firstSet(candidates, builtin) {
25
+ for (const c of candidates) {
26
+ const t = c?.trim();
27
+ if (t)
28
+ return t;
29
+ }
30
+ return builtin;
31
+ }
32
+ /**
33
+ * Resolve the model for a pipeline role. Precedence: env > local config >
34
+ * server > built-in constant.
35
+ *
36
+ * `session_default` reads the local `default_model` ONLY when `default_model_set`
37
+ * is true — i.e. the user's config file explicitly carried the key. The value
38
+ * that the DEFAULT_CONFIG merge supplies (when the file omits the key) must NOT
39
+ * beat the server, which is what lets an admin-served default apply while an
40
+ * explicit user choice (even one equal to the built-in) still wins.
41
+ */
42
+ export function resolveModelRole(role, cfg, env = process.env) {
43
+ const server = getServerModelConfig();
44
+ switch (role) {
45
+ case 'session_default':
46
+ return firstSet([
47
+ env.CSPEACH_DEFAULT_MODEL,
48
+ cfg.default_model_set ? cfg.default_model : undefined,
49
+ server?.roles.session_default,
50
+ ], DEFAULT_SESSION_MODEL);
51
+ case 'audit':
52
+ return firstSet([env.CSPEACH_AUDIT_MODEL, cfg.audit_model, server?.roles.audit], PLAN_TIER_SONNET_MODEL);
53
+ case 'compact':
54
+ return firstSet([env.CSPEACH_COMPACT_MODEL, cfg.compact_model, server?.roles.compact], COMPACTION_MODEL);
55
+ default: {
56
+ // Exhaustiveness guard — a new ModelRole must extend the switch.
57
+ const _never = role;
58
+ return _never;
59
+ }
60
+ }
61
+ }