@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.
- package/dist/agent/loop.js +22 -9
- package/dist/approvals/op-labels.js +124 -0
- package/dist/approvals/render.js +42 -36
- package/dist/cli.js +15 -0
- package/dist/commands/compact.js +28 -2
- package/dist/commands/config-set.js +189 -0
- package/dist/commands/config-show.js +20 -0
- package/dist/commands/export-audit.js +43 -0
- package/dist/commands/help.js +5 -0
- package/dist/commands/plan-audit-evidence.js +266 -0
- package/dist/commands/plan-audit.js +692 -0
- package/dist/commands/plan-chain.js +671 -0
- package/dist/commands/plan-continue.js +179 -0
- package/dist/commands/plan-gate.js +154 -0
- package/dist/commands/plan-resume.js +588 -33
- package/dist/config/loader.js +128 -4
- package/dist/config/model-defaults.js +14 -0
- package/dist/cost/pricing.js +27 -1
- package/dist/doctor/checks/system-roles.js +41 -0
- package/dist/doctor/run.js +2 -0
- package/dist/models/resolve.js +61 -0
- package/dist/models/server-config.js +155 -0
- package/dist/one-shot.js +25 -3
- package/dist/projects/extract-cca.js +3 -1
- package/dist/projects/extract-modernize.js +3 -1
- package/dist/projects/extract-plan.js +60 -6
- package/dist/projects/extract-test-coverage.js +3 -1
- package/dist/projects/extract-upgrade.js +3 -1
- package/dist/projects/handover-md.js +195 -0
- package/dist/projects/index.js +1 -1
- package/dist/projects/plan-run.js +137 -13
- package/dist/projects/plan-schema.js +73 -0
- package/dist/projects/run-lease.js +157 -0
- package/dist/projects/save-command.js +26 -15
- package/dist/renderer/status-footer.js +22 -12
- package/dist/renderer/thinking-heartbeat.js +64 -8
- package/dist/renderer/todo-block.js +51 -0
- package/dist/renderer/tool-widget.js +37 -0
- package/dist/repl/bracketed-paste.js +28 -19
- package/dist/repl/builtin-commands.js +5 -0
- package/dist/repl/current-transport.js +10 -0
- package/dist/repl/history.js +86 -0
- package/dist/repl/ink-stdin-guard.js +64 -0
- package/dist/repl/mode-ceiling.js +16 -0
- package/dist/repl/mode-cycle.js +104 -0
- package/dist/repl/post-turn-status.js +24 -4
- package/dist/repl/slash-completer.js +5 -0
- package/dist/repl.js +954 -83
- package/dist/rewind/candidates.js +194 -0
- package/dist/rewind/cli.js +137 -0
- package/dist/rewind/format.js +27 -0
- package/dist/rewind/restore.js +245 -0
- package/dist/session/audit-export.js +459 -0
- package/dist/session/context-report.js +163 -0
- package/dist/session/recap.js +160 -0
- package/dist/skill-catalog.js +9 -3
- package/dist/skills/bundled-skills.js +71 -78
- package/dist/tools/approval.js +115 -7
- package/dist/tools/ask-question.js +304 -3
- package/dist/tools/extend-model/anchored-insert.js +604 -0
- package/dist/tools/extend-model/tool.js +162 -10
- package/dist/tools/fiori/fe-extend.js +76 -0
- package/dist/tools/fiori/fe-scaffold.js +29 -3
- package/dist/tools/fiori/floorplan-map.js +19 -0
- package/dist/tools/fiori/samples/data/index.json +13602 -0
- package/dist/tools/fiori/samples/data/sources.generated.js +808 -0
- package/dist/tools/fiori/samples/loader.js +248 -0
- package/dist/tools/fiori/samples/search.js +63 -0
- package/dist/tools/fiori/samples/types.js +2 -0
- package/dist/tools/fiori/smoke/assertions.js +74 -0
- package/dist/tools/fiori/smoke/browser.js +52 -0
- package/dist/tools/fiori/smoke/driver.js +89 -0
- package/dist/tools/fiori/smoke/freestyle-spec.js +317 -0
- package/dist/tools/fiori/smoke/run-smoke.js +149 -0
- package/dist/tools/fiori/tools.js +328 -3
- package/dist/tools/local-build.js +11 -1
- package/dist/tools/sap-read.js +79 -11
- package/dist/tools/sap-write.js +24 -4
- package/dist/tools/snapshot.js +27 -1
- package/dist/tools/subagent/agent_run.js +27 -3
- package/dist/tools/todo.js +144 -0
- package/dist/ui/app.js +372 -19
- package/dist/ui/approval-modal.js +49 -16
- package/dist/ui/ask-question-emitter.js +14 -0
- package/dist/ui/context-grid.js +108 -0
- package/dist/ui/footer.js +109 -30
- package/dist/ui/header.js +7 -0
- package/dist/ui/line-resolution.js +18 -2
- package/dist/ui/rewind-emitter.js +10 -0
- package/dist/ui/rewind-panel.js +81 -0
- package/dist/ui/sap-state-store.js +1 -0
- package/dist/ui/status-line.js +43 -0
- package/dist/ui/text-input.js +72 -8
- package/dist/ui/todo-emitter.js +25 -0
- package/dist/ui/todo-panel.js +64 -0
- package/dist/ui/turn-status-emitter.js +50 -4
- package/dist/ui/turn-status.js +18 -3
- package/dist/ui/widgets/ask-form.js +242 -0
- package/dist/ui/widgets/ask-question-modal.js +17 -7
- package/package.json +4 -1
package/dist/config/loader.js
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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';
|
package/dist/cost/pricing.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/doctor/run.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|