@cspeach/cli 1.0.0 → 1.1.1

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 (100) hide show
  1. package/dist/agent/loop.js +22 -9
  2. package/dist/approvals/op-labels.js +124 -0
  3. package/dist/approvals/render.js +42 -36
  4. package/dist/cli.js +15 -0
  5. package/dist/commands/compact.js +28 -2
  6. package/dist/commands/config-set.js +189 -0
  7. package/dist/commands/config-show.js +20 -0
  8. package/dist/commands/export-audit.js +43 -0
  9. package/dist/commands/help.js +5 -0
  10. package/dist/commands/plan-audit-evidence.js +266 -0
  11. package/dist/commands/plan-audit.js +692 -0
  12. package/dist/commands/plan-chain.js +671 -0
  13. package/dist/commands/plan-continue.js +179 -0
  14. package/dist/commands/plan-gate.js +154 -0
  15. package/dist/commands/plan-resume.js +588 -33
  16. package/dist/config/loader.js +128 -4
  17. package/dist/config/model-defaults.js +14 -0
  18. package/dist/cost/pricing.js +27 -1
  19. package/dist/doctor/checks/system-roles.js +41 -0
  20. package/dist/doctor/run.js +2 -0
  21. package/dist/models/resolve.js +61 -0
  22. package/dist/models/server-config.js +155 -0
  23. package/dist/one-shot.js +25 -3
  24. package/dist/projects/extract-cca.js +3 -1
  25. package/dist/projects/extract-modernize.js +3 -1
  26. package/dist/projects/extract-plan.js +60 -6
  27. package/dist/projects/extract-test-coverage.js +3 -1
  28. package/dist/projects/extract-upgrade.js +3 -1
  29. package/dist/projects/handover-md.js +195 -0
  30. package/dist/projects/index.js +1 -1
  31. package/dist/projects/plan-run.js +137 -13
  32. package/dist/projects/plan-schema.js +73 -0
  33. package/dist/projects/run-lease.js +157 -0
  34. package/dist/projects/save-command.js +26 -15
  35. package/dist/renderer/status-footer.js +22 -12
  36. package/dist/renderer/thinking-heartbeat.js +64 -8
  37. package/dist/renderer/todo-block.js +51 -0
  38. package/dist/renderer/tool-widget.js +37 -0
  39. package/dist/repl/bracketed-paste.js +28 -19
  40. package/dist/repl/builtin-commands.js +5 -0
  41. package/dist/repl/current-transport.js +10 -0
  42. package/dist/repl/history.js +86 -0
  43. package/dist/repl/ink-stdin-guard.js +64 -0
  44. package/dist/repl/mode-ceiling.js +16 -0
  45. package/dist/repl/mode-cycle.js +104 -0
  46. package/dist/repl/post-turn-status.js +24 -4
  47. package/dist/repl/slash-completer.js +5 -0
  48. package/dist/repl.js +954 -83
  49. package/dist/rewind/candidates.js +194 -0
  50. package/dist/rewind/cli.js +137 -0
  51. package/dist/rewind/format.js +27 -0
  52. package/dist/rewind/restore.js +245 -0
  53. package/dist/session/audit-export.js +459 -0
  54. package/dist/session/context-report.js +163 -0
  55. package/dist/session/recap.js +160 -0
  56. package/dist/skill-catalog.js +9 -3
  57. package/dist/skills/bundled-skills.js +71 -78
  58. package/dist/tools/approval.js +115 -7
  59. package/dist/tools/ask-question.js +304 -3
  60. package/dist/tools/extend-model/anchored-insert.js +604 -0
  61. package/dist/tools/extend-model/tool.js +162 -10
  62. package/dist/tools/fiori/fe-extend.js +76 -0
  63. package/dist/tools/fiori/fe-scaffold.js +29 -3
  64. package/dist/tools/fiori/floorplan-map.js +19 -0
  65. package/dist/tools/fiori/samples/data/index.json +13602 -0
  66. package/dist/tools/fiori/samples/data/sources.generated.js +808 -0
  67. package/dist/tools/fiori/samples/loader.js +248 -0
  68. package/dist/tools/fiori/samples/search.js +63 -0
  69. package/dist/tools/fiori/samples/types.js +2 -0
  70. package/dist/tools/fiori/smoke/assertions.js +74 -0
  71. package/dist/tools/fiori/smoke/browser.js +52 -0
  72. package/dist/tools/fiori/smoke/driver.js +89 -0
  73. package/dist/tools/fiori/smoke/freestyle-spec.js +317 -0
  74. package/dist/tools/fiori/smoke/run-smoke.js +149 -0
  75. package/dist/tools/fiori/tools.js +328 -3
  76. package/dist/tools/local-build.js +11 -1
  77. package/dist/tools/sap-read.js +79 -11
  78. package/dist/tools/sap-write.js +24 -4
  79. package/dist/tools/snapshot.js +27 -1
  80. package/dist/tools/subagent/agent_run.js +27 -3
  81. package/dist/tools/todo.js +144 -0
  82. package/dist/ui/app.js +372 -19
  83. package/dist/ui/approval-modal.js +49 -16
  84. package/dist/ui/ask-question-emitter.js +14 -0
  85. package/dist/ui/context-grid.js +108 -0
  86. package/dist/ui/footer.js +109 -30
  87. package/dist/ui/header.js +7 -0
  88. package/dist/ui/line-resolution.js +18 -2
  89. package/dist/ui/rewind-emitter.js +10 -0
  90. package/dist/ui/rewind-panel.js +81 -0
  91. package/dist/ui/sap-state-store.js +1 -0
  92. package/dist/ui/status-line.js +43 -0
  93. package/dist/ui/text-input.js +72 -8
  94. package/dist/ui/todo-emitter.js +25 -0
  95. package/dist/ui/todo-panel.js +64 -0
  96. package/dist/ui/turn-status-emitter.js +50 -4
  97. package/dist/ui/turn-status.js +18 -3
  98. package/dist/ui/widgets/ask-form.js +242 -0
  99. package/dist/ui/widgets/ask-question-modal.js +17 -7
  100. package/package.json +4 -1
@@ -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: {},
@@ -134,7 +136,54 @@ export async function loadConfig() {
134
136
  // when it's strictly true — a string/typo collapses to "absent" so it
135
137
  // can't win the `local_build ?? local_files` fallback.
136
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,
137
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),
138
187
  llm: { ...DEFAULT_CONFIG.llm, ...(parsed.llm ?? {}) },
139
188
  classifier: { ...DEFAULT_CONFIG.classifier, ...(parsed.classifier ?? {}) },
140
189
  ui: { ...DEFAULT_CONFIG.ui, ...(parsed.ui ?? {}) },
@@ -156,6 +205,40 @@ export async function loadConfig() {
156
205
  // see a clean "absent" shape. resolveLocalBuild treats missing === undefined.
157
206
  if (merged.local_build === undefined)
158
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
+ }
159
242
  // One-time deprecation note: a SAP system still relies on the legacy
160
243
  // `sslVerify` toggle and has not adopted either new key. It KEEPS WORKING
161
244
  // (resolveSapTls maps sslVerify:false → insecure), but we nudge the user
@@ -177,8 +260,16 @@ export async function loadConfig() {
177
260
  return merged;
178
261
  }
179
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.
180
271
  if (err?.code === 'ENOENT')
181
- return DEFAULT_CONFIG;
272
+ return { ...structuredClone(DEFAULT_CONFIG), file_keys: [] };
182
273
  throw err;
183
274
  }
184
275
  }
@@ -201,7 +292,40 @@ export function resolveLocalBuild(cfg) {
201
292
  return cfg.local_build === true;
202
293
  return cfg.local_files === true;
203
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
+ }
204
308
  export async function saveConfig(config) {
205
309
  await fs.mkdir(cspeachRoot(), { recursive: true });
206
- 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');
207
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)) {
@@ -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
+ }
@@ -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
+ }
@@ -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
@@ -5,6 +5,8 @@ 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';
9
11
  import { assertModeLicensed, CspeachLicenseError } from './agent/providers/license-gate.js';
10
12
  import { runTurn } from './agent/loop.js';
@@ -12,7 +14,9 @@ import { classifyPrompt, parseShortcut, rejectUnknownSkill } from './router/clas
12
14
  import { setRenderingOverride, setHeadlessOverride } from './renderer/tty.js';
13
15
  import { alwaysDeclinePreviewHook } from './repl/update-method-preview-hook.js';
14
16
  import { createClassicOutputEmitter } from './renderer/notices.js';
17
+ import { todoEmitter } from './ui/todo-emitter.js';
15
18
  import { applyLocalBuildConfig } from './tools/local-build.js';
19
+ import { getEffectiveWriteMode } from './repl/mode-cycle.js';
16
20
  // Side-effect imports for tool registration.
17
21
  import './tools/sap-read.js';
18
22
  import './tools/sap-write.js';
@@ -21,6 +25,7 @@ import './tools/approval.js';
21
25
  import './tools/snapshot.js';
22
26
  import './tools/ask-question.js';
23
27
  import './tools/dispatch-skill.js';
28
+ import './tools/todo.js';
24
29
  import './tools/filesystem/file-read.js';
25
30
  import './tools/filesystem/read-document.js';
26
31
  import './tools/filesystem/file-edit.js';
@@ -46,6 +51,10 @@ import './tools/subagent/agent_run.js';
46
51
  export async function runOneShot(prompt, opts) {
47
52
  const cfg = await loadConfig();
48
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);
49
58
  // B5 (2026-06-11) — one-shot is headless by design: a single turn, no
50
59
  // REPL, often piped (CI / battery). Any stdin prompt would hang forever
51
60
  // (defect D1). The headless answer policy (ask_question auto-answers,
@@ -54,7 +63,16 @@ export async function runOneShot(prompt, opts) {
54
63
  // Task C.10 — advisory-only is the only mode the one-shot announces, because
55
64
  // non-interactive output should stay minimal. The user needs to know that
56
65
  // writes will NOT be executed before a skill that normally writes runs.
57
- 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') {
58
76
  console.log(chalk.yellow('⚠ Advisory mode: writes will be proposed, not executed.'));
59
77
  }
60
78
  // local_build toggle — force-enable the flag-gated filesystem tools AND
@@ -84,7 +102,7 @@ export async function runOneShot(prompt, opts) {
84
102
  }
85
103
  const conn = await getConnection(alias, cfg.sap[alias]);
86
104
  const adt = new AdtClient(conn);
87
- const session = newSession(makeSessionId(), alias, null, cfg.default_model);
105
+ const session = newSession(makeSessionId(), alias, null, resolveModelRole('session_default', cfg));
88
106
  await saveSession(session);
89
107
  const parsed = parseShortcut(prompt);
90
108
  const body = parsed.prompt;
@@ -152,7 +170,11 @@ export async function runOneShot(prompt, opts) {
152
170
  // B2 fix (2026-06-12): one-shot runs render tool notices ('warn'/'info'
153
171
  // — Rule 9 transport overrides, snapshot lines) to stdout instead of
154
172
  // dropping them. Headless transcripts need these for auditability.
155
- ctx: { adt, sapAlias: alias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: alwaysDeclinePreviewHook, chunkEmitter: createClassicOutputEmitter() },
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 },
156
178
  });
157
179
  return 0;
158
180
  }
@@ -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) {
@@ -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) {