omp-llm-role 1.0.0 → 1.2.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 (50) hide show
  1. package/README.md +202 -12
  2. package/agents/designer.md +4 -3
  3. package/docs/dev/README.md +44 -7
  4. package/docs/dev/agent-authoring.md +59 -1
  5. package/docs/dev/architecture.md +106 -28
  6. package/docs/dev/ci.md +163 -0
  7. package/docs/dev/current-state.md +364 -24
  8. package/docs/dev/data-sources.md +231 -38
  9. package/docs/dev/explorer.md +175 -46
  10. package/docs/dev/quirks.md +77 -4
  11. package/docs/dev/releasing.md +130 -17
  12. package/docs/dev/scoring.md +851 -27
  13. package/docs/dev/testing.md +253 -0
  14. package/docs/dev/writes-and-state.md +232 -28
  15. package/docs/llm-role-rankings.md +149 -148
  16. package/package.json +814 -80
  17. package/skills/omp-llm-role-create-agent/SKILL.md +7 -1
  18. package/src/agent-architect.ts +148 -19
  19. package/src/agent-create.ts +800 -40
  20. package/src/agent-file.ts +2 -5
  21. package/src/architect-provenance.ts +16 -0
  22. package/src/availability.ts +191 -15
  23. package/src/benchmark-author.ts +9 -4
  24. package/src/benchmark-sources.ts +283 -18
  25. package/src/cli/create-role.ts +29 -1
  26. package/src/cli/llm-role-rank.ts +40 -1
  27. package/src/config-edit.ts +207 -80
  28. package/src/engine.ts +519 -75
  29. package/src/error-report.ts +562 -0
  30. package/src/explorer/boot.ts +36 -24
  31. package/src/explorer/explain.ts +128 -13
  32. package/src/explorer/scopes.ts +110 -0
  33. package/src/explorer/server.ts +126 -33
  34. package/src/extension.ts +464 -48
  35. package/src/features.ts +105 -0
  36. package/src/project-registry.ts +65 -0
  37. package/src/project-setup.ts +382 -0
  38. package/src/prompts/agent-creation-architect.md +2 -0
  39. package/src/prompts/project-profile-architect.md +33 -0
  40. package/src/prompts/project-profile-user.md +8 -0
  41. package/src/provider-display.ts +67 -0
  42. package/src/role-archetypes.ts +12 -2
  43. package/src/role-settings.ts +22 -0
  44. package/src/settings.ts +211 -24
  45. package/src/state.ts +1 -1
  46. package/src/updater.ts +234 -37
  47. package/web/app.js +434 -12
  48. package/web/index.html +1 -0
  49. package/web/style.css +44 -1
  50. package/docs/dev/spec.md +0 -633
@@ -16,6 +16,7 @@
16
16
  */
17
17
 
18
18
  import type { SuffixLevel } from "./engine.ts";
19
+ import type { FeatureId } from "./features.ts";
19
20
  import { DEFAULT_ROLES } from "./settings.ts";
20
21
 
21
22
  export type Archetype = {
@@ -33,6 +34,14 @@ export type Archetype = {
33
34
  tools: string[];
34
35
  /** Require image input (`filters.image`). */
35
36
  image?: boolean;
37
+ /**
38
+ * Capability flags the archetype proposes for a role fitted to it. **No
39
+ * shipped archetype sets one**: a plugin-created role starts from nothing
40
+ * unless the user asks (`--feature`), so creating an agent never silently
41
+ * changes what the role ranks on. The field exists so a future archetype can
42
+ * propose a preset without a second mechanism.
43
+ */
44
+ features?: FeatureId[];
36
45
  /** Bullets for the generated agent body's `<criteria>` section. */
37
46
  criteria: string[];
38
47
  };
@@ -128,8 +137,9 @@ export const ARCHETYPES: readonly Archetype[] = [
128
137
  id: "design",
129
138
  label: "design specialist",
130
139
  keywords: ["design", "ui", "ux", "visual", "layout", "style", "css", "frontend", "mockup", "wireframe", "screenshot", "image", "logo", "brand", "accessib", "typograph"],
131
- weights: { vision: 0.3, website: 0.2, general: 0.2, code: 0.1, price: 0.12, throughput: 0.08 },
132
- required: ["general", "price", "throughput"],
140
+ // The shipped `designer` role's own weights: one canonical design weighting.
141
+ weights: DEFAULT_ROLES.designer.weights,
142
+ required: DEFAULT_ROLES.designer.required,
133
143
  thinking: "auto",
134
144
  tools: ["read", "grep", "glob", "find", "write", "edit"],
135
145
  image: true,
@@ -45,14 +45,36 @@ export function mergeExport(existing: unknown, dirty: Record<string, RoleDef>):
45
45
  if (Object.keys(nested).length === 0) delete plugin.roles;
46
46
  }
47
47
 
48
+ // An Export means "make this role's keys match this def", so a key the def
49
+ // no longer carries (a capability flag set back to inherit, a cleared knob,
50
+ // a dropped filter) is removed rather than left stale. Same prefix rule as
51
+ // `mergeRemove` below.
48
52
  const prefix = `roles.${name}`;
53
+ for (const key of Object.keys(plugin)) {
54
+ if (key === prefix || key.startsWith(`${prefix}.`)) delete plugin[key];
55
+ }
56
+
49
57
  plugin[`${prefix}.description`] = def.description;
50
58
  if (def.enabled !== undefined) plugin[`${prefix}.enabled`] = def.enabled;
51
59
  if (def.locked !== undefined) plugin[`${prefix}.locked`] = def.locked;
52
60
  plugin[`${prefix}.required`] = def.required;
53
61
  if (def.thinking !== undefined) plugin[`${prefix}.thinking`] = def.thinking;
62
+ if (def.providerPin !== undefined) plugin[`${prefix}.providerPin`] = def.providerPin;
63
+ if (def.preferOwnProvider !== undefined) plugin[`${prefix}.preferOwnProvider`] = def.preferOwnProvider;
64
+ if (def.cacheHitRate !== undefined) plugin[`${prefix}.cacheHitRate`] = def.cacheHitRate;
54
65
  if (def.lambda !== undefined) plugin[`${prefix}.lambda`] = def.lambda;
55
66
  if (def.filters?.image !== undefined) plugin[`${prefix}.filters.image`] = def.filters.image;
67
+ if (def.filters?.tools !== undefined) plugin[`${prefix}.filters.tools`] = def.filters.tools;
68
+ if (def.filters?.minContextTokens !== undefined) plugin[`${prefix}.filters.minContextTokens`] = def.filters.minContextTokens;
69
+ if (def.filters?.minOutputTokens !== undefined) plugin[`${prefix}.filters.minOutputTokens`] = def.filters.minOutputTokens;
70
+ if (def.filters?.maxPriceUsdPerM !== undefined) plugin[`${prefix}.filters.maxPriceUsdPerM`] = def.filters.maxPriceUsdPerM;
71
+ // The capability flags are written as flat dotted keys too, so omp's
72
+ // shallow settings merge keeps them and `mergeRemove`'s `roles.<name>.`
73
+ // prefix deletes them with the rest of the role. Both `true` and `false`
74
+ // are written: a per-role `false` is how a role opts out of a global flag.
75
+ if (def.features !== undefined) {
76
+ for (const [id, on] of Object.entries(def.features)) plugin[`${prefix}.features.${id}`] = on;
77
+ }
56
78
  for (const [metric, weight] of Object.entries(def.weights)) plugin[`${prefix}.weights.${metric}`] = weight;
57
79
  }
58
80
  return { lock };
package/src/settings.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  * overrides from the omp plugin settings map.
4
4
  *
5
5
  * Source of truth (decision D1): `~/.omp/plugins/omp-plugins.lock.json` ->
6
- * `settings["omp-llm-role"]`, with `<anchor>/.omp/plugins/omp-plugins.lock.json`
6
+ * `settings["omp-llm-role"]`, with `<cwd>/.omp/plugins/omp-plugins.lock.json`
7
7
  * merged under it (project wins). Keys are flat and dotted
8
8
  * (`roles.slow.weights.code=0.2`, written by `omp plugin config omp-llm-role
9
9
  * --set=k=v`) and are nested here; a `config` key holding JSON is accepted as a
@@ -14,6 +14,7 @@ import { existsSync, readFileSync } from "node:fs";
14
14
  import { homedir } from "node:os";
15
15
  import { dirname, join } from "node:path";
16
16
  import { SUFFIX_LEVELS, type RoleDef } from "./engine.ts";
17
+ import { FEATURES, expandFeatures, featureById } from "./features.ts";
17
18
  import { isRecord } from "./guards.ts";
18
19
 
19
20
  /** User-level plugin settings lock file (the explorer's read/export target and
@@ -23,7 +24,7 @@ export const PLUGIN_SETTINGS_PATH = join(homedir(), ".omp", "plugins", "omp-plug
23
24
  export const DEFAULT_ROLES: Record<string, RoleDef> = {
24
25
  default: {
25
26
  description: "Main workhorse: strong general coding-agent quality, sane cost",
26
- weights: { general: 0.3221, reasoning: 0.1705, code: 0.1705, agents: 0.1137, tool_calling: 0.0947, throughput: 0.0285, price: 0.1 },
27
+ weights: { general: 0.4926, code: 0.1705, agents: 0.1137, tool_calling: 0.0947, throughput: 0.0285, price: 0.1 },
27
28
  required: ["general", "price", "throughput"],
28
29
  // `auto`, not bare: the session default is `auto` (config.yml
29
30
  // `defaultThinkingLevel`), so a bare selector runs at auto effort while
@@ -32,13 +33,13 @@ export const DEFAULT_ROLES: Record<string, RoleDef> = {
32
33
  },
33
34
  smol: {
34
35
  description: "Fast lightweight model: cheap and quick, still competent",
35
- weights: { price: 0.3, throughput: 0.28, general: 0.2, code: 0.12, tool_calling: 0.1 },
36
+ weights: { price: 0.45, throughput: 0.22, general: 0.1571, code: 0.0943, tool_calling: 0.0786 },
36
37
  required: ["general", "price", "throughput"],
37
38
  thinking: "off",
38
39
  },
39
40
  slow: {
40
41
  description: "Most capable model for hard problems; cost and speed as tiebreakers",
41
- weights: { general: 0.26, reasoning: 0.26, code: 0.18, agents: 0.13, math: 0.08, throughput: 0.04, price: 0.05 },
42
+ weights: { general: 0.60, code: 0.18, agents: 0.13, throughput: 0.04, price: 0.05 },
42
43
  required: ["general", "price", "throughput"],
43
44
  thinking: "high",
44
45
  },
@@ -50,8 +51,19 @@ export const DEFAULT_ROLES: Record<string, RoleDef> = {
50
51
  filters: { image: true },
51
52
  },
52
53
  plan: {
53
- description: "Planning: reasoning, math, long-context coherence",
54
- weights: { reasoning: 0.3129, general: 0.2542, long_context: 0.1369, math: 0.1369, throughput: 0.0391, price: 0.12 },
54
+ description: "Planning: reasoning and long-context coherence, priced so the cheapest capable planner wins",
55
+ // 2026-10-03 (issue #16): `price` raised 0.12 → 0.25 so the cost posture
56
+ // binds on the *reachable* pool — the pool the selector is chosen from.
57
+ // Measured reachable leader-flip w_price 0.2175 (Hy4 preview →
58
+ // DeepSeek-V4.1-Flash) vs full-pool 0.311 (Muse Spark 1.3 → Qwen3.8 Flash),
59
+ // so 0.25 flips the actionable decision while leaving the full-pool #1
60
+ // unchanged. Non-price weights rescaled by (1−0.25)/(1−0.12) = 0.852273:
61
+ // general 0.704 → 0.6, long_context 0.1369 → 0.116676, throughput 0.0391 →
62
+ // 0.033324 (Σ(non-price) = 0.75 = 1 − price). `q` is unchanged by the
63
+ // rescale; only λ moves (0.00682 → 0.016667). The reachable flip lands on a
64
+ // model whose `long_context` is imputed (capability fill 0.195, not
65
+ // measured) — see Scoring rule 6.
66
+ weights: { general: 0.6, long_context: 0.116676, throughput: 0.033324, price: 0.25 },
55
67
  required: ["reasoning", "general", "price", "throughput"],
56
68
  // `auto`, not `medium`: no reachable planning candidate lists `medium` in its
57
69
  // catalog thinking[], so a `medium` pin silently writes bare (the session
@@ -60,7 +72,7 @@ export const DEFAULT_ROLES: Record<string, RoleDef> = {
60
72
  },
61
73
  commit: {
62
74
  description: "Commit messages: cheap and fast with decent general quality",
63
- weights: { price: 0.35, throughput: 0.27, general: 0.28, code: 0.1 },
75
+ weights: { price: 0.45, throughput: 0.2285, general: 0.2369, code: 0.0846 },
64
76
  required: ["general", "price", "throughput"],
65
77
  thinking: "off",
66
78
  },
@@ -90,14 +102,15 @@ export const DEFAULT_ROLES: Record<string, RoleDef> = {
90
102
  // lottery at 76% coverage); `long_context` is kept (the most independent
91
103
  // capability axis, r 0.739/0.767 with reasoning/general) and is now
92
104
  // capability-filled at 0.195 like `website` (src/engine.ts CAPABILITY_FILL),
93
- // so its 44.7% coverage no longer penalizes. `reasoning`↔`general` stay
94
- // collinear (r 0.972) but the independent axes now carry more. `price` is
105
+ // so its 44.7% coverage no longer penalizes. `reasoning`↔`general` were
106
+ // collinear (r 0.972) and are now merged into `general` (2026-10-03, issue
107
+ // #13), so the independent axes carry more. `price` is
95
108
  // raised to 0.20 (λ 0.0125) because the advisor fires on every primary turn
96
109
  // *and* every `task` subagent turn (config.yml task.agentAdvisor.task: on),
97
110
  // so spend is ~2× a per-turn count; `throughput` is raised to 0.1049 because
98
111
  // `syncBacklog: "1"` lets a slow advisor stall the primary up to 30s.
99
112
  // Non-price weights sum to 0.80 = 1 − price, as the (w/qW) blend requires.
100
- weights: { reasoning: 0.3498, general: 0.2449, long_context: 0.1004, price: 0.2, throughput: 0.1049 },
113
+ weights: { general: 0.5947, long_context: 0.1004, price: 0.2, throughput: 0.1049 },
101
114
  required: ["reasoning", "general", "price", "throughput"],
102
115
  // `auto`, matching `plan`: the same model (Hy4 preview) runs both roles, so
103
116
  // pricing advisor bare while plan is `auto` understated advisor by 1.857×.
@@ -115,7 +128,7 @@ export const DEFAULT_ROLES: Record<string, RoleDef> = {
115
128
  // role is disabled (an unresolved `@x` is a literal pattern, so a bare
116
129
  // `@designer` would hard-fail — the chain is what keeps the agent spawnable).
117
130
  enabled: false,
118
- weights: { general: 0.26, code: 0.10, vision: 0.18, throughput: 0.13, price: 0.15, website: 0.18 },
131
+ weights: { general: 0.26, code: 0.06, vision: 0.18, throughput: 0.15, price: 0.17, website: 0.18 },
119
132
  required: ["general", "price", "throughput"],
120
133
  // `auto` for the same reason as `plan`: nothing in the designer pool lists
121
134
  // `medium`, and `auto` is a meta level written as-is at the same overhead.
@@ -135,7 +148,7 @@ export const SHIPPED_AGENTS: string[] = ["designer"];
135
148
 
136
149
 
137
150
  /**
138
- * Metrics a role weight/required entry may name (SPEC §7).
151
+ * Metrics a role weight/required entry may name.
139
152
  *
140
153
  * The six raw llm-stats benchmark pass rates (`gpqa`, `aime`, `swe_bench`,
141
154
  * `arc_agi`, `terminal_bench`, `tau_bench`) are weightable too: they are already
@@ -155,6 +168,10 @@ export const KNOWN_METRICS: Record<string, true> = {
155
168
  vision: true,
156
169
  tool_calling: true,
157
170
  long_context: true,
171
+ index_communication: true,
172
+ index_finance: true,
173
+ index_healthcare: true,
174
+ index_legal: true,
158
175
  mrcr: true,
159
176
  website: true,
160
177
  writing: true,
@@ -164,6 +181,21 @@ export const KNOWN_METRICS: Record<string, true> = {
164
181
  arc_agi: true,
165
182
  terminal_bench: true,
166
183
  tau_bench: true,
184
+ simpleqa_score: true,
185
+ hle_score: true,
186
+ mmmu_score: true,
187
+ mmmu_pro_score: true,
188
+ mmmlu_score: true,
189
+ browsecomp_score: true,
190
+ swe_bench_pro_score: true,
191
+ mcp_atlas_score: true,
192
+ apex_agents_score: true,
193
+ osworld_score: true,
194
+ scicode_score: true,
195
+ screenspot_pro_score: true,
196
+ charxiv_r_score: true,
197
+ frontiermath_score: true,
198
+ toolathlon_score: true,
167
199
  price: true,
168
200
  throughput: true,
169
201
  };
@@ -181,7 +213,7 @@ export function isKnownMetric(name: string): boolean {
181
213
  }
182
214
 
183
215
  /**
184
- * Live-session coupling (SPEC §6 session-start semantics): after the day-gated
216
+ * Live-session coupling: after the day-gated
185
217
  * session-start write, a freshly started session whose conversation is still
186
218
  * empty had its active model resolved from the pre-write config. Reapply the
187
219
  * new `default` selector to the session's active model so the user's first
@@ -192,7 +224,7 @@ export const ACTIVATE_DEFAULT_KEY = "activateDefaultOnEmptySession";
192
224
  export type ResolvedSettings = {
193
225
  switchMargin: number;
194
226
  /**
195
- * Cost-side escape hatch from `switchMargin` (SPEC §7): the margin is a flat
227
+ * Cost-side escape hatch from `switchMargin`: the margin is a flat
196
228
  * band on `value`, so a role with a loose posture (small λ) can refuse a
197
229
  * switch worth up to `switchMargin/λ` $/M. When a challenger sitting inside
198
230
  * the margin undercuts the incumbent's effective price by at least this
@@ -204,6 +236,14 @@ export type ResolvedSettings = {
204
236
  fallbackChainDepth: number;
205
237
  roles: Record<string, RoleDef>;
206
238
  activateDefaultOnEmptySession: boolean;
239
+ /** Opt-in reporting of unexpected plugin errors as GitHub issues on the
240
+ * plugin's own tracker: `off` (default) never files, `ask` shows the exact
241
+ * payload and files on confirmation (degrading to a prefilled URL when the
242
+ * session has no UI), `auto` files without asking. */
243
+ errorReporting: "off" | "ask" | "auto";
244
+ /** Global capability flags (`features.<id>`); a role's `features.<id>` overrides
245
+ * the global in either direction. Expanded per role by `resolveSettings`. */
246
+ features: Record<string, boolean>;
207
247
  };
208
248
 
209
249
  export const DEFAULT_SETTINGS: ResolvedSettings = {
@@ -213,6 +253,8 @@ export const DEFAULT_SETTINGS: ResolvedSettings = {
213
253
  fallbackChainDepth: 2,
214
254
  roles: DEFAULT_ROLES,
215
255
  activateDefaultOnEmptySession: true,
256
+ errorReporting: "off",
257
+ features: {},
216
258
  };
217
259
 
218
260
  /** One row of the plugin's settings schema (omp's `PluginSettingSchema`): the
@@ -264,7 +306,20 @@ export function deriveSettingsSchema(): Record<string, PluginSettingSchema> {
264
306
  description: "Reapply the new default selector to an empty session's active model",
265
307
  default: DEFAULT_SETTINGS.activateDefaultOnEmptySession,
266
308
  },
309
+ errorReporting: {
310
+ type: "enum",
311
+ description: "File unexpected plugin errors as GitHub issues (off, ask, auto)",
312
+ default: DEFAULT_SETTINGS.errorReporting,
313
+ values: ["off", "ask", "auto"],
314
+ },
267
315
  };
316
+ for (const row of FEATURES) {
317
+ schema[`features.${row.id}`] = {
318
+ type: "boolean",
319
+ description: row.description,
320
+ default: false,
321
+ };
322
+ }
268
323
  for (const [name, def] of Object.entries(DEFAULT_ROLES)) {
269
324
  const p = `roles.${name}`;
270
325
  schema[`${p}.enabled`] = {
@@ -283,6 +338,30 @@ export function deriveSettingsSchema(): Record<string, PluginSettingSchema> {
283
338
  default: def.thinking ?? "auto",
284
339
  values: Object.keys(SUFFIX_LEVELS),
285
340
  };
341
+ schema[`${p}.providerPin`] = {
342
+ type: "string",
343
+ description: `Provider slug the ${name} role's requests are pinned to (OpenRouter @<slug> routing; empty = default routing)`,
344
+ };
345
+ schema[`${p}.cacheHitRate`] = {
346
+ type: "number",
347
+ description: `Assumed cache-hit rate (0–1) for ${name}'s input tokens; blends each route's cache-read price before the 3:1 blend (0 = off)`,
348
+ default: def.cacheHitRate ?? 0,
349
+ min: 0,
350
+ max: 1,
351
+ step: 0.01,
352
+ };
353
+ for (const row of FEATURES) {
354
+ schema[`${p}.features.${row.id}`] = {
355
+ type: "boolean",
356
+ description: `Enable the ${row.label} capability for the ${name} role`,
357
+ default: false,
358
+ };
359
+ }
360
+ schema[`${p}.preferOwnProvider`] = {
361
+ type: "boolean",
362
+ description: `Bind ${name} requests to each model's best provider route (soft preference; never drops a model)`,
363
+ default: false,
364
+ };
286
365
  schema[`${p}.required`] = {
287
366
  type: "string",
288
367
  description: `Comma-separated metrics a model must have to rank for ${name}`,
@@ -293,6 +372,35 @@ export function deriveSettingsSchema(): Record<string, PluginSettingSchema> {
293
372
  description: `Restrict the ${name} pool to image-capable models`,
294
373
  default: def.filters?.image ?? false,
295
374
  };
375
+ schema[`${p}.filters.tools`] = {
376
+ type: "boolean",
377
+ description: `Restrict the ${name} pool to models with a tool-capable route`,
378
+ default: def.filters?.tools ?? false,
379
+ };
380
+ schema[`${p}.filters.minContextTokens`] = {
381
+ type: "number",
382
+ description: `Drop ${name} routes whose endpoint context is below this (tokens; 0 = off)`,
383
+ default: def.filters?.minContextTokens ?? 0,
384
+ min: 0,
385
+ };
386
+ schema[`${p}.filters.minOutputTokens`] = {
387
+ type: "number",
388
+ description: `Drop ${name} routes whose endpoint output ceiling is below this (tokens; 0 = off)`,
389
+ default: def.filters?.minOutputTokens ?? 0,
390
+ min: 0,
391
+ };
392
+ schema[`${p}.filters.maxPriceUsdPerM`] = {
393
+ type: "number",
394
+ description: `Drop ${name} models whose role-priced blend exceeds this ($/M; 0 = off)`,
395
+ default: def.filters?.maxPriceUsdPerM ?? 0,
396
+ min: 0,
397
+ };
398
+ schema[`${p}.filters.preferredMinThroughput`] = {
399
+ type: "number",
400
+ description: `Soft throughput floor for ${name} routes (tok/s; 0 = off) — a below-floor route sorts last but is never dropped`,
401
+ default: def.filters?.preferredMinThroughput ?? 0,
402
+ min: 0,
403
+ };
296
404
  schema[`${p}.lambda`] = {
297
405
  type: "number",
298
406
  description: `Explicit λ ($ per quality point) override for ${name}`,
@@ -338,9 +446,12 @@ function setNested(target: Record<string, unknown>, path: string[], value: unkno
338
446
  else node[leaf] = value;
339
447
  }
340
448
 
341
- /** Nearest ancestor dir (or cwd itself) containing a `.omp` or `.git` anchor; null when none. */
342
- function findProjectAnchor(): string | null {
343
- let dir = process.cwd();
449
+ /** Nearest ancestor dir (or `cwd` itself) containing a `.omp` or `.git` anchor; null when none.
450
+ * Exported for the `/project-roles` divergence warning: omp reads `<cwd>/.omp` with no
451
+ * walk-up, so a session launched in a subdirectory has an anchor that differs from the dir
452
+ * omp actually reads. */
453
+ export function findProjectAnchor(cwd: string = process.cwd()): string | null {
454
+ let dir = cwd;
344
455
  for (;;) {
345
456
  if (existsSync(join(dir, ".omp")) || existsSync(join(dir, ".git"))) return dir;
346
457
  const parent = dirname(dir);
@@ -349,15 +460,26 @@ function findProjectAnchor(): string | null {
349
460
  }
350
461
  }
351
462
 
463
+ /** The project plugin settings lock file omp reads for `cwd`:
464
+ * `<cwd>/.omp/plugins/omp-plugins.lock.json`. omp resolves the project dir as
465
+ * `<cwd>/.omp` with no walk-up, so the project-scope read must use the same dir
466
+ * (not the walk-up anchor `findProjectAnchor` returns). */
467
+ export function projectLockPath(cwd: string = process.cwd()): string {
468
+ return join(cwd, ".omp", "plugins", "omp-plugins.lock.json");
469
+ }
470
+
352
471
  /**
353
472
  * Read the raw `settings["omp-llm-role"]` map: user-level lock file, with the
354
- * project-anchor lock file merged over it. Missing files (or missing settings
473
+ * project lock file (`<cwd>/.omp/plugins/omp-plugins.lock.json`, omp's project
474
+ * dir with no walk-up) merged over it. Missing files (or missing settings
355
475
  * entries) yield {}; unparseable lock files warn and yield {}.
356
476
  */
357
- export function readPluginSettingsMap(paths?: { global?: string; project?: string }): Record<string, unknown> {
477
+ export function readPluginSettingsMap(paths?: { global?: string; project?: string | null }): Record<string, unknown> {
358
478
  const globalPath = paths?.global ?? PLUGIN_SETTINGS_PATH;
359
- const anchor = paths?.project === undefined ? findProjectAnchor() : null;
360
- const projectPath = paths?.project ?? (anchor ? join(anchor, ".omp", "plugins", "omp-plugins.lock.json") : null);
479
+ // omp reads the project dir as `<cwd>/.omp` with no walk-up, so the default
480
+ // project source is that dir's lock file (not the walk-up anchor). A missing
481
+ // file yields {} and the global path stands alone.
482
+ const projectPath = paths?.project === undefined ? projectLockPath() : paths?.project;
361
483
 
362
484
  const read = (path: string): Record<string, unknown> => {
363
485
  let parsed: unknown;
@@ -423,7 +545,7 @@ function mergeRawSettings(raw: Record<string, unknown>): { merged: ResolvedSetti
423
545
 
424
546
  /**
425
547
  * Deep-merge the raw settings map over the shipped defaults and validate the
426
- * result (SPEC §7). Roles whose resolved `weights` is null are dropped (explicit
548
+ * result. Roles whose resolved `weights` is null are dropped (explicit
427
549
  * opt-out). Never mutates the defaults. One error string per violation; an empty
428
550
  * error list means the settings are valid.
429
551
  */
@@ -447,6 +569,22 @@ export function resolveSettings(raw: Record<string, unknown>): { settings: Resol
447
569
  `${ACTIVATE_DEFAULT_KEY}: must be a boolean, got ${JSON.stringify(merged.activateDefaultOnEmptySession)}`,
448
570
  );
449
571
  }
572
+ if (merged.errorReporting !== "off" && merged.errorReporting !== "ask" && merged.errorReporting !== "auto") {
573
+ errors.push(`errorReporting: must be one of off, ask, auto, got ${JSON.stringify(merged.errorReporting)}`);
574
+ }
575
+
576
+ // Global capability flags (`features.<id>`): an unknown id or a non-boolean
577
+ // value aborts the run (a typo must never silently do nothing). A non-record
578
+ // `features` is reset so the per-role expansion below always gets a map.
579
+ if (!isRecord(merged.features)) {
580
+ errors.push(`features: must be an object, got ${JSON.stringify(merged.features)}`);
581
+ merged.features = {};
582
+ } else {
583
+ for (const [id, v] of Object.entries(merged.features)) {
584
+ if (featureById(id) === null) errors.push(`features.${id} is not a known capability`);
585
+ if (typeof v !== "boolean") errors.push(`features.${id}: must be a boolean, got ${JSON.stringify(v)}`);
586
+ }
587
+ }
450
588
 
451
589
  for (const [name, rdef] of Object.entries(merged.roles)) {
452
590
  if (!isRecord(rdef)) {
@@ -508,8 +646,19 @@ export function resolveSettings(raw: Record<string, unknown>): { settings: Resol
508
646
  }
509
647
  if (rdef.filters !== undefined) {
510
648
  if (!isRecord(rdef.filters)) errors.push(`role ${name}: filters must be an object`);
511
- else if (rdef.filters.image !== undefined && typeof rdef.filters.image !== "boolean") {
512
- errors.push(`role ${name}: filters.image must be a boolean`);
649
+ else {
650
+ if (rdef.filters.image !== undefined && typeof rdef.filters.image !== "boolean") {
651
+ errors.push(`role ${name}: filters.image must be a boolean`);
652
+ }
653
+ if (rdef.filters.tools !== undefined && typeof rdef.filters.tools !== "boolean") {
654
+ errors.push(`role ${name}: filters.tools must be a boolean`);
655
+ }
656
+ for (const key of ["minContextTokens", "minOutputTokens", "maxPriceUsdPerM", "preferredMinThroughput"] as const) {
657
+ const v = rdef.filters[key];
658
+ if (v !== undefined && (typeof v !== "number" || !Number.isFinite(v) || v < 0)) {
659
+ errors.push(`role ${name}: filters.${key} must be a number ≥ 0`);
660
+ }
661
+ }
513
662
  }
514
663
  }
515
664
  if (rdef.thinking !== undefined && (typeof rdef.thinking !== "string" || !(rdef.thinking in SUFFIX_LEVELS))) {
@@ -522,7 +671,45 @@ export function resolveSettings(raw: Record<string, unknown>): { settings: Resol
522
671
  delete rdef.lambda;
523
672
  }
524
673
  }
674
+ if (rdef.cacheHitRate !== undefined) {
675
+ if (typeof rdef.cacheHitRate !== "number" || !Number.isFinite(rdef.cacheHitRate) || rdef.cacheHitRate < 0 || rdef.cacheHitRate > 1) {
676
+ errors.push(`role ${name}: cacheHitRate must be a number in [0, 1]`);
677
+ delete rdef.cacheHitRate;
678
+ }
679
+ }
680
+ // providerPin is a provider slug (may be tiered like `deepinfra/fp8`); `@`
681
+ // and `:` are the selector's own delimiters, so they cannot appear in it.
682
+ if (rdef.providerPin !== undefined) {
683
+ if (
684
+ typeof rdef.providerPin !== "string" ||
685
+ rdef.providerPin.length === 0 ||
686
+ rdef.providerPin.includes("@") ||
687
+ rdef.providerPin.includes(":")
688
+ ) {
689
+ errors.push(`role ${name}: providerPin must be a non-empty string without "@" or ":"`);
690
+ delete rdef.providerPin;
691
+ }
692
+ }
693
+ // Per-role capability flags (`roles.<role>.features.<id>`): an unknown id or
694
+ // a non-boolean value aborts the run, naming the role and the key.
695
+ if (rdef.features !== undefined) {
696
+ if (!isRecord(rdef.features)) {
697
+ errors.push(`role ${name}: features must be an object`);
698
+ delete rdef.features;
699
+ } else {
700
+ for (const [id, v] of Object.entries(rdef.features)) {
701
+ if (featureById(id) === null) errors.push(`role ${name}: features.${id} is not a known capability`);
702
+ if (typeof v !== "boolean") errors.push(`role ${name}: features.${id} must be a boolean, got ${JSON.stringify(v)}`);
703
+ }
704
+ }
705
+ }
706
+ if (rdef.preferOwnProvider !== undefined && typeof rdef.preferOwnProvider !== "boolean") {
707
+ errors.push(`role ${name}: preferOwnProvider must be a boolean, got ${JSON.stringify(rdef.preferOwnProvider)}`);
708
+ }
525
709
  if (rdef.description === undefined) rdef.description = "";
710
+ // The resolved def is the EFFECTIVE def: the authored def with the enabled
711
+ // flags' recommended knobs filled in (explicit keys win; `features` stripped).
712
+ merged.roles[name] = expandFeatures(rdef, merged.features);
526
713
  }
527
714
 
528
715
  return { settings: merged, errors };
package/src/state.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Plugin state, history, and the refresh lock — all under the omp agent dir,
3
- * next to the config.yml they describe (SPEC §8).
3
+ * next to the config.yml they describe.
4
4
  *
5
5
  * - `llm-role-state.json` — day gate, managed roles, last selectors, chain keys
6
6
  * the plugin owns, and the pre-write `modelRoles` snapshot (manual rollback aid).