omp-llm-role 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +179 -11
  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 +84 -27
  6. package/docs/dev/ci.md +152 -0
  7. package/docs/dev/current-state.md +327 -24
  8. package/docs/dev/data-sources.md +202 -38
  9. package/docs/dev/explorer.md +157 -46
  10. package/docs/dev/quirks.md +45 -4
  11. package/docs/dev/releasing.md +129 -17
  12. package/docs/dev/scoring.md +789 -28
  13. package/docs/dev/testing.md +206 -0
  14. package/docs/dev/writes-and-state.md +176 -28
  15. package/docs/llm-role-rankings.md +141 -140
  16. package/package.json +754 -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 +104 -6
  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 +409 -46
  29. package/src/error-report.ts +562 -0
  30. package/src/explorer/boot.ts +36 -24
  31. package/src/explorer/explain.ts +110 -13
  32. package/src/explorer/scopes.ts +110 -0
  33. package/src/explorer/server.ts +112 -32
  34. package/src/extension.ts +407 -34
  35. package/src/features.ts +103 -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/role-archetypes.ts +12 -2
  42. package/src/role-settings.ts +22 -0
  43. package/src/settings.ts +205 -24
  44. package/src/state.ts +1 -1
  45. package/src/updater.ts +150 -31
  46. package/web/app.js +411 -12
  47. package/web/index.html +1 -0
  48. package/web/style.css +44 -1
  49. package/docs/dev/spec.md +0 -633
@@ -12,10 +12,12 @@ explorer never reimplements `value = q − λ·$/M`.
12
12
  | File | Role |
13
13
  |---|---|
14
14
  | `src/explorer/server.ts` | Zero-dependency `node:http` surface: static SPA + JSON API. Exports `createExplorerServer`, `ExplorerOpts`. |
15
- | `src/explorer/explain.ts` | Pure explanation layer (no I/O): rank rows with baseline deltas, per-model decomposition, inverse-cardinal targets. Exports `rankRows`, `explainModel`, `inverseCardinal`, `METRIC_META`, `WEIGHTABLE_METRICS`. |
16
- | `src/explorer/boot.ts` | Shared launcher: bind/port fallback, lock-file roles, browser open, close. Exports `startExplorer`, `EXPLORER_DEFAULT_PORT`, `ExplorerHandle`, `ExplorerBootOpts`. |
15
+ | `src/explorer/explain.ts` | Pure explanation layer (no I/O): rank rows with baseline deltas, per-model decomposition, inverse-cardinal targets, and the keyed-catalog availability overlay. Exports `rankRows`, `explainModel`, `inverseCardinal`, `METRIC_META`, `WEIGHTABLE_METRICS`. |
16
+ | `src/explorer/scopes.ts` | Scope resolution (no HTTP): the user-level scope, the registry's projects, and the session's project; the per-scope read; the dataset union. Exports `Scope`, `resolveScopes`, `defaultScopeId`, `resolveScope`, `readScopeRoles`, `unionRoles`. |
17
+ | `src/explorer/boot.ts` | Shared launcher: bind/port fallback, scope resolution, browser open, close. Exports `startExplorer`, `EXPLORER_DEFAULT_PORT`, `ExplorerHandle`, `ExplorerBootOpts`. |
18
+ | `src/project-registry.ts` | The global project registry (`llm-role-projects.json` in the agent dir): read/register/prune, atomic write. Exports `PROJECT_REGISTRY_FILE`, `readProjectRegistry`, `registerProject`. |
17
19
  | `src/role-settings.ts` | The one validated role write path (validate → merge → backup → atomic write). Exports `validateRole`, `mergeExport`, `writeRoleSettings`, `timestamp`. |
18
- | `web/app.js` | The SPA: renders and edits role defs, calls the API. No framework, no build step, no external requests. |
20
+ | `web/app.js` | The SPA: renders and edits role defs, switches scopes, calls the API. No framework, no build step, no external requests. |
19
21
  | `src/extension.ts` | `/explore-roles`: in-process `startExplorer` (catalog from the live model registry), closed on `session_shutdown`. |
20
22
 
21
23
  ## HTTP surface (`src/explorer/server.ts`)
@@ -29,47 +31,76 @@ caller; no auth. Every handler is wrapped so a throw becomes a 500 JSON error
29
31
  ```ts
30
32
  type ExplorerOpts = {
31
33
  webDir: string;
32
- lockPath: string;
33
- // Fresh per call: rank (swapped by refresh) plus roles/universe re-read from
34
- // the lock file, so a page reload after Export reflects the write.
35
- getState(): {
34
+ // All known scopes, re-resolved per call so a project registered by another
35
+ // session appears without a restart.
36
+ listScopes(): Scope[];
37
+ // The scope a request should use: the requested id when known and present,
38
+ // else the default (session project when present, else user-level).
39
+ resolveScope(requestedId: string | null): Scope;
40
+ // Fresh per call: rank (swapped by refresh) plus the scope's roles/universe
41
+ // re-read from its lock file, so a page reload after Export reflects the write.
42
+ getState(scope: Scope): {
36
43
  rank: RankData;
37
44
  roles: Record<string, RoleDef>;
38
45
  universe: Record<string, UniverseEntry>; // every known role, disabled included
39
46
  defaults: Record<string, RoleDef>;
47
+ availability: KeyAvailability; // keyed-catalog overlay (re-derived on refresh)
40
48
  };
41
- refresh(): Promise<void>; // re-runs loadRankData({ refresh: true }) and swaps rank
49
+ refresh(): Promise<void>; // re-runs loadRankData({ refresh: true, roles, extraMetrics }) and swaps rank
42
50
  };
43
51
  ```
44
52
 
53
+ The server holds the active scope id as mutable state (alongside the mutable
54
+ `rank`/`availability`); `activeScope` resolves it per request, persisting the
55
+ fallback when the active scope became unavailable.
56
+
45
57
  Endpoints:
46
58
 
47
59
  | Method + path | Body | Response |
48
60
  |---|---|---|
49
61
  | `GET /` | — | `web/index.html` |
50
62
  | `GET /app.js`, `/style.css` | — | static asset (traversal-guarded) |
51
- | `GET /api/bootstrap` | — | `bootstrapPayload(opts)` |
63
+ | `GET /api/bootstrap` | — | `bootstrapPayload(opts, activeScope)` |
64
+ | `POST /api/scope` | `{scope}` | switch the active scope, then the bootstrap payload (404 unknown id, 409 non-present) |
52
65
  | `POST /api/rank` | `{role, def}` | `{eligible, lambda, derivedLambda, rows, errors}` |
53
66
  | `POST /api/explain` | `{role, def, modelId}` | `explainModel(...)` spread + `errors` |
54
- | `POST /api/export` | `{roles}` | `{ok:true, backupPath, roles}` or `{ok:false, errors}` |
55
- | `POST /api/refresh` | — | `await opts.refresh()` then the bootstrap payload |
67
+ | `POST /api/export` | `{roles}` | `{ok:true, backupPath, roles, lockPath}` or `{ok:false, errors}` |
68
+ | `POST /api/refresh` | — | `await opts.refresh()` (re-ranks and re-derives the availability overlay) then the bootstrap payload |
56
69
 
57
70
  - `bootstrapPayload` ships `roles` (resolved), `defaults` (`DEFAULT_ROLES`),
58
71
  `universe` (kind/enabled/locked + effective def per known role), `metrics`
59
72
  (`weightableMetrics`: the shipped keys plus any external metric a resolved role
60
73
  weights), `metricMeta` (`metricMetaFor`: `METRIC_META` plus a derived entry per
61
- external metric), `levels` (`Object.keys(SUFFIX_LEVELS)`), `thinkingFactors`
74
+ external metric), `focusAssessments` (per role, the nine-axis assessment of its
75
+ focus metrics over the resolved dataset), `levels` (`Object.keys(SUFFIX_LEVELS)`), `thinkingFactors`
62
76
  (per-level billed-blend multiplier from the engine's own `thinkingPriceFactor`,
63
77
  so the UI readout cannot drift), `fetchedAt`, `modelCount`, `orMatched`,
64
- `orPriced`, `lockPath`. Every request calls `getState()`, which re-reads the
65
- lock file — so a page reload after Export shows the new roles, not the
66
- boot-time snapshot.
78
+ `orPriced`, `lockPath` (the **active scope's** lock file), `scopes` (id, label,
79
+ kind, present per known scope), `activeScope` (the active scope id),
80
+ the active scope's global capability flags and the feature registry (the
81
+ `FEATURES` rows: id, label, description, recommended bundle, buys), so the SPA
82
+ can expand a posted def the same way the server does, and
83
+ `availability` (the keyed-catalog summary:
84
+ `{active, reason, publicCount, keyedCount, blockedCount, fetchedAt}`). Every
85
+ request calls `getState(scope)`, which re-reads that scope's lock file — so a
86
+ page reload after Export shows the new roles, not the boot-time snapshot.
87
+ - `handleScope` validates the requested id against `listScopes()`: an unknown id
88
+ is a 404, a known-but-not-present one a 409; a valid switch sets the active
89
+ scope and returns the new payload.
67
90
  - `handleRank` baselines against `state.roles[role] ?? state.universe[role]?.def ??
68
91
  state.defaults[role]`: an enabled role against its resolved def, a disabled or
69
92
  lock-file-only role against its effective def, so selecting it still shows
70
- deltas rather than an empty baseline.
71
- - `handleExport` calls `writeRoleSettings(opts.lockPath, body.roles)` and
72
- returns its result verbatim.
93
+ deltas rather than an empty baseline. It passes `state.availability` to
94
+ `rankRows`, so every row carries its `key` badge (usable/blocked/unknown) with
95
+ no extra call. **`handleRank`/`handleExplain` expand the posted def** with the
96
+ active scope's global flags (`expandFeatures(def, …)`) *before*
97
+ `rankRows`/`explainModel`: the handlers rank the client's def verbatim, while
98
+ the Δ baseline is the resolved def (`state.roles[role]`), so without the
99
+ expansion the preview would omit the preset the baseline includes and every
100
+ row's Δ would be a lie. The posted def is the **authored** def (flags + explicit
101
+ keys); the expansion is applied only for the ranking call, never persisted.
102
+ - `handleExport` calls `writeRoleSettings(activeScope.lockPath, body.roles)` and
103
+ returns its result verbatim plus the written `lockPath`.
73
104
  - Unknown `/api/*` → 404; wrong method → 405; non-API non-GET → 405.
74
105
  - `readBody` caps at `MAX_BODY = 1 MiB` (413); `readJson` → 400 on invalid JSON.
75
106
  - `serveStatic` resolves against `webDir` and rejects traversal (`full` must be
@@ -89,48 +120,86 @@ Pure, no I/O; all ranking math is delegated to `src/engine.ts`.
89
120
  declared?)`** merges the shipped table with a derived entry per external metric.
90
121
  - **`weightableMetrics(extra?)`** — the shipped keys plus any external metric in
91
122
  `extra`; exactly what the validator accepts.
92
- - **`rankRows(def, models, baseline)`** — `rankRole(def, models)` +
123
+ - **`rankRows(def, models, baseline, availability?)`** — `rankRole(def, models)` +
93
124
  `paretoFrontier`, annotating each row with `baselineRank`/`delta`
94
125
  (`baselineRank − rank`; positive = moved up) against the effective role's
95
126
  ranking. Rows carry `priceEff` (the thinking-adjusted price the ranking
96
127
  penalized), `parts`, `missing` (weighted non-price metrics whose raw value is
97
- null), and `frontier`.
128
+ null), `frontier`, and `key` — the availability overlay verdict
129
+ (`usable`/`blocked`/`unknown`), orthogonal to `missing` and to the eligibility
130
+ gates. `availability` is optional and pure (no I/O); omitted or inactive ⇒
131
+ every row reads `unknown`.
98
132
  - **`inverseCardinal(metric, t)`** — inverse of `cardinalMetric` for the "what
99
133
  would it take" targets: `index` → `t*80 − 20`; `throughput` → `10 * 30**t`
100
134
  (null outside `[0,1]`, because the forward transform clamps); everything else
101
135
  (benchmark, percentile, price) → identity. See *Open defect* below for the
102
136
  `gpqa` exception.
103
- - **`explainModel(def, models, modelId, roleName)`** — the full decomposition, or
104
- the eligibility gates it failed (same gates `rankRole` applies, same order:
105
- missing required, image filter, no billed price). On success: `contributions`
137
+ - **`explainModel(def, models, modelId, roleName, availability?)`** — the full
138
+ decomposition, or the eligibility gates it failed (same gates `rankRole`
139
+ applies, same order: missing required, image filter, no billed price). On
140
+ success: `contributions`
106
141
  (raw → cardinal `t` → renormalized weight → contribution → share of `q`, with
107
- a `fillNote` when the value is a capability fill), `cost` (`priceEff`, billed
108
- price, λ, penalty, `q`, `value`), `gapAbove`/`gapToTop`/`above`, `closing` (the
142
+ a `fillNote` when the value is a capability fill), `cost` (`priceEff`, the
143
+ cache-adjusted billed price, λ, penalty, `q`, `value`), `role.cacheHitRate`,
144
+ `gapAbove`/`gapToTop`/`above`, `closing` (the
109
145
  per-metric raw target that would close the gap, via `inverseCardinal`, with
110
- unreachable/extrapolated notes), and `dominators` (models both cheaper and at
111
- least as good on `q`, top 3). It mirrors `rankRole`'s capability fill so the
112
- contributions sum exactly to `q`.
146
+ unreachable/extrapolated notes), `dominators` (models both cheaper and at
147
+ least as good on `q`, top 3), and the availability overlay `key`/`keyReason`
148
+ (the same verdict as `rankRows`, plus the reason it reads that way). It mirrors
149
+ `rankRole`'s capability fill so the contributions sum exactly to `q`.
113
150
 
114
151
  ## Shared boot path (`src/explorer/boot.ts`)
115
152
 
116
153
  `startExplorer(opts: ExplorerBootOpts): Promise<ExplorerHandle>` — the one
117
154
  launcher `/explore-roles` uses.
118
155
 
119
- - `EXPLORER_DEFAULT_PORT = 5177`; `listen` binds `127.0.0.1` and resolves the
156
+ - `EXPLORER_DEFAULT_PORT = 5177`; `listen` binds the loopback address and resolves the
120
157
  actual port. A permanent `error` listener is attached before `listen`, so a
121
158
  post-listen socket error is logged instead of becoming an uncaughtException
122
159
  (which would tear down the whole omp session). `EADDRINUSE` → retry on port 0
123
160
  (OS-assigned).
124
- - Roles come from the **user-level** lock file only:
125
- `readPluginSettingsMap({ global: lockPath, project: null })` keeps any
126
- project-anchor file out of the merge. `resolveSettings` → `settings.roles`
127
- (the resolved set = what the plugin does today); `roleUniverse(raw, roles)`
128
- adds the roles it knows but does not rank (shipped opt-ins, lock-file-only
129
- roles). `getState()` re-runs this read on **every** request, so a page reload
161
+ - **Scopes** (`src/explorer/scopes.ts`). A scope is a role-config source: the
162
+ user-level scope (`lockPath`, default `PLUGIN_SETTINGS_PATH`), or one project
163
+ where `/project-roles` was used. `resolveScopes({ userLockPath, cwd, registryDir })`
164
+ returns the user-level scope, the registry's projects (most recently used
165
+ first), and the session's project when `cwd` has a project role config (a
166
+ project lock with a non-empty `omp-llm-role` settings entry — the updater's own
167
+ `resolveProjectDir` test) — included immediately, before the registry records
168
+ it. A scope id is `user` or `project:<root>`; `present` is whether the lock
169
+ file exists (the user scope is always available — Export creates it). The
170
+ default active scope is the session's project when present, else user-level.
171
+ `listScopes()` re-resolves on **every** request, so a project registered by
172
+ another session appears without a restart.
173
+ - **Per-scope read.** `readScopeRoles(scope, userLockPath)` reads the user-level
174
+ lock file for the user scope, and the project lock merged **over** the
175
+ user-level lock for a project scope — the exact read the updater performs, so
176
+ the explorer and the plugin agree. `resolveSettings` → `settings.roles` (the
177
+ resolved set = what the plugin does today); `roleUniverse(raw, roles)` adds the
178
+ roles it knows but does not rank (shipped opt-ins, lock-file-only roles).
179
+ `getState(scope)` re-runs this read on **every** request, so a page reload
130
180
  after Export reflects the write instead of the state at boot.
181
+ - **Dataset union.** The host loads the ranking data through `unionRoles(scopes,
182
+ userLockPath)` — the union of every known scope's resolved roles — plus the
183
+ shipped keys (`loadRankData({ roles, extraMetrics: Object.keys(KNOWN_METRICS) })`),
184
+ so the dataset carries every metric any scope weights — including a generic
185
+ llm-stats benchmark (`bench:<id>`) that only a project role pulls in. Loading
186
+ with no roles (`loadRankData({})`) fetches declared sources only, so a role
187
+ weighting an undeclared benchmark would rank on a dataset missing that metric
188
+ and the explorer would disagree with the updater. `extraMetrics` also forces
189
+ the `website`/`writing` fetches even when no role weights them. The union is
190
+ what keeps a scope switch free (one fetch) and is asserted as a pure call over
191
+ temp lock files, not through the injected `rank` fixture.
131
192
  - `enrichThinkingLevels(rank.models, opts.catalog)` gates the thinking price
132
- factor on the omp catalog; `refresh()` re-runs `opts.reload(true)` and
133
- re-enriches.
193
+ factor on the omp catalog; `refresh()` re-runs `opts.reload(true)`,
194
+ re-enriches, and — when `opts.reloadAvailability` is supplied — re-derives the
195
+ availability overlay.
196
+ - `opts.availability` is the boot-time keyed-catalog overlay; `getState(scope)`
197
+ returns it on every request, so `/api/rank` and `/api/explain` annotate rows
198
+ without a second fetch. It is scope-independent and carried across switches
199
+ unchanged. `/explore-roles` builds it in-process from the session's OpenRouter
200
+ key (`ctx.modelRegistry.getApiKeyForProvider("openrouter")`) via
201
+ `fetchKeyAvailability`; a missing key or a failed fetch is `unavailable`
202
+ (best-effort — the explorer still boots).
134
203
  - `unref` (extension only) detaches the server from the event loop so a
135
204
  short-lived `omp -p` run cannot hang.
136
205
  - `open` launches the default browser on macOS (`execFile("open", [url])`).
@@ -146,7 +215,8 @@ The one validated role write path, shared by the explorer's Export,
146
215
  - `mergeExport(existing, dirty)` → `{lock}` or `{error}`. Preserves `plugins` and
147
216
  every sibling settings key. Writes each dirty role as **flat dotted keys**
148
217
  (`roles.<name>.weights.<metric>`, `.description`, `.enabled`, `.locked`,
149
- `.required`, `.thinking`, `.lambda`, `.filters.image`) because omp's
218
+ `.required`, `.thinking`, `.providerPin`, `.cacheHitRate`, `.lambda`,
219
+ `.filters.image`) because omp's
150
220
  `/settings` Plugins tab shallow-merges the settings object and does not
151
221
  flatten nested objects — a nested `roles` object would render as schema
152
222
  defaults. A pre-existing nested entry for a dirty role is deleted, so an older
@@ -166,10 +236,40 @@ server-side; the client only renders and edits role defs. Model names come from
166
236
  a scraped third-party site, so the DOM is built with
167
237
  `textContent`/`createElement` only (the `el()` helper).
168
238
 
169
- - **Boot**: `boot()` → `GET /api/bootstrap` → fills `state` (`universe`,
170
- `effective` = each entry's `def`, `defs` = a structuredClone of it, `defaults`,
171
- `metrics`, `metricMeta`, `levels`, `thinkingFactors`, `lockPath`), wires the
172
- filter/topn/export/copy/download controls, and selects the first role.
239
+ - **Boot**: `boot()` → `GET /api/bootstrap` → `applyBootstrap(data)`, which fills
240
+ `state` (`scopes`, `activeScope`, `universe`, `effective` = each entry's `def`,
241
+ `defs` = a structuredClone of it, `defaults`, `metrics`, `metricMeta`,
242
+ `focusAssessments`, `levels`, `thinkingFactors`, `lockPath`, `availability`),
243
+ renders the header/roles/table, and selects the previously selected role when
244
+ the new scope has it, else the first role. `boot()` then wires the
245
+ filter/topn/hide-key-blocked/export/copy/download controls.
246
+ - **Scope switcher**: the header renders a `#scope` `<select>` from
247
+ `state.scopes` (the user-level scope plus every known project); a scope whose
248
+ lock file is gone is `disabled` with a reason `title`. Changing the selection
249
+ confirms first when there are unsaved edits (mirroring Refresh), then
250
+ `POST /api/scope {scope}` and `applyBootstrap` on the returned payload — so
251
+ every panel (universe, per-scope effective/edited defs, role tabs, editor,
252
+ table, meta, lock path) reflects the new scope, and dirty tracking is per
253
+ scope. The header names the active scope and its lock file path.
254
+ - **Focus table**: the editor renders a `table.focus` for the role's weighted
255
+ benchmark/percentile metrics (`focusMetricsOf`), one row per metric with the
256
+ nine signals from `focusAssessments[role]` (coverage, dispersion, composition,
257
+ freshness, trust, modality, maintenance, provenance, cross-source). A below-bar cell is warned (tint + glyph), `unknown` is muted —
258
+ never green. A metric the editor just added reads "not assessed" until the edit
259
+ is **Exported and the page reloaded** (the payload is computed over the resolved
260
+ definition, and the editor's in-memory edit is discarded by a plain reload).
261
+ - **Features panel**: the editor renders one **tri-state** control per capability
262
+ (inherit / on / off) from the bootstrap payload's feature registry, showing the
263
+ recommended bundle and the effective values. A value the preset filled (rather
264
+ than an explicit key) is marked as **derived**, so authored and derived are
265
+ distinguishable. Toggling a control edits the role's `features.<id>` (the
266
+ tri-state's *inherit* leaves the key absent, so the global flag applies) and
267
+ re-ranks; the individual
268
+ per-knob fields the preset fills stay editable, and an explicit value wins over
269
+ the flag. **Export writes the flag**, never the expanded knobs — `dirtyRoles()`
270
+ is unchanged (it already compares the authored def against the authored
271
+ baseline), so a role whose only change is a flag exports just
272
+ `roles.<name>.features.<id>`.
173
273
  - **Recompute**: `selectRole` → `renderRoles`/`renderEditor`/`renderExplain` +
174
274
  `recompute()`. `recompute()` → `POST /api/rank {role, def}` → `state.rows`,
175
275
  `lambda`, `derivedLambda`, `errors`; re-renders the table and readouts, and
@@ -177,10 +277,20 @@ a scraped third-party site, so the DOM is built with
177
277
  changes call `scheduleRecompute()` (120 ms debounce).
178
278
  - **Explain**: `selectModel(id)` → `POST /api/explain {role, def, modelId}` →
179
279
  `renderExplain()` (composition table, cost line, "why not higher",
180
- dominators).
280
+ dominators, and the availability line).
281
+ - **Availability overlay**: each row's `key` renders as a three-state badge in
282
+ the `key` column; the **hide key-blocked** checkbox beside the filter/top-n
283
+ controls drops `blocked` rows from the table (a client-side filter — the
284
+ ranking and Export are untouched); the header shows the allowlist size
285
+ (`keyedCount`/`publicCount`). The explain panel repeats the verdict and its
286
+ reason. When `availability.reason !== "active"` every badge reads `unknown`
287
+ and the tooltip/hint names OpenRouter → Settings → **"Filter the model catalog
288
+ for API keys"** — the explorer never guesses.
181
289
  - **Dirty tracking**: `dirtyRoles()` compares `JSON.stringify(defs[role])` to
182
290
  `effective[role]`; only dirty roles are sent to `POST /api/export`. On success
183
- the client updates `effective` for the written roles.
291
+ the client updates `effective` for the written roles and the status names the
292
+ written lock file. The state is rebuilt from the payload on a scope switch, so
293
+ dirty tracking is per scope.
184
294
  - **Deep-merge parking**: `dropWeight(metric)` parks an inherited metric (one
185
295
  the shipped default weights) at `EPSILON = 0.001` instead of deleting the key
186
296
  — the plugin deep-merges over `DEFAULT_ROLES`, so the default's weight would
@@ -191,8 +301,9 @@ a scraped third-party site, so the DOM is built with
191
301
  re-rendered tables and editors need no per-node wiring. Metric and role
192
302
  tooltips are built from the bootstrap payload (`METRIC_META` / role
193
303
  descriptions), not duplicated in the client.
194
- - **Refresh**: `onRefresh()` confirms, then `POST /api/refresh` → re-renders the
195
- header meta and recomputes.
304
+ - **Refresh**: `onRefresh()` confirms, then `POST /api/refresh` →
305
+ `applyBootstrap` on the returned payload (the scope list is re-resolved, so a
306
+ project registered by another session appears).
196
307
 
197
308
  ## Open defect
198
309
 
@@ -1,9 +1,11 @@
1
1
  # Quirks
2
2
 
3
3
  Debugging-only oddities and gotchas that are **not** contracts. If a fact is a
4
- contract the implementation MUST satisfy, it belongs in [spec.md](spec.md); if it
5
- is user-facing, it belongs in [`../../README.md`](../../README.md). This file is
6
- the home for the things that only bite someone changing or debugging the code.
4
+ contract the implementation MUST satisfy, it belongs in the topic doc that owns
5
+ it ([`architecture.md`](architecture.md), [`scoring.md`](scoring.md),
6
+ [`writes-and-state.md`](writes-and-state.md), …); if it is user-facing, it
7
+ belongs in [`../../README.md`](../../README.md). This file is the home for the
8
+ things that only bite someone changing or debugging the code.
7
9
 
8
10
  ## Data sources and joins
9
11
 
@@ -27,6 +29,28 @@ the home for the things that only bite someone changing or debugging the code.
27
29
  - **`index_*` scores are interval-scale** (observed −16..+60, can be negative);
28
30
  the fixed affine anchors (−20→0, +60→1) handle it.
29
31
 
32
+ ## Availability
33
+
34
+ - **The probe's disqualifying 404 is narrow.** `probeModel` blocks a model only
35
+ on the exact "No allowed providers are available for the selected model …" 404;
36
+ every other failure (5xx, timeout, unknown model) counts as usable and stays in
37
+ omp's runtime-fallback domain. The account's allowed-providers whitelist is
38
+ enforced per serving endpoint, so the **org prefix is not a valid filter**:
39
+ aggregator-org models (`deepseek/*`, `z-ai/*`, `inclusionai/*`) run via
40
+ whitelisted third-party endpoints while `openai/*` and `anthropic/*` fail on
41
+ first-party-only routing.
42
+
43
+ ## Capability fill
44
+
45
+ - **The fill can invert against the covered field.** The capability fill
46
+ (`CAPABILITY_FILL`, 0.195) is the percentile implied by the *uncovered*
47
+ cohort's mean general index, which sits above the bottom of the covered
48
+ field — so a covered model in the bottom ~8% of the field scores *below* a
49
+ model with no Design Arena data at all. Measured 2026-10-03: 4 of the 51
50
+ covered in-pool models (24 of 121 across all models). This is a
51
+ debugging-only oddity, not a contract; the fill is deliberately conservative
52
+ and a regression fill was rejected (see the `applyDesignPercentiles` comment).
53
+
30
54
  ## The 1/price² blend
31
55
 
32
56
  - **The blend renormalizes throughput over the routes that have p50 data**,
@@ -87,7 +111,24 @@ the home for the things that only bite someone changing or debugging the code.
87
111
  - **`api.zeroeval.com/leaderboard/benchmarks/<id>` is case- and
88
112
  separator-sensitive**: `alpacaeval-2.0` returns 200, `alpacaeval-2_0` and
89
113
  `ALPACAEVAL-2.0` return 404. The declaration's `fetch.url` must carry the raw
90
- id exactly as the site spells it.
114
+ id exactly as the site spells it. The generic path normalizes the id to the
115
+ metric key (`deepswe-1.1` → `bench:deepswe-1_1`), so the updater's metric-key
116
+ fallback would fetch the normalized (404-ing) URL; the declaration
117
+ `catalogBenchmarkDeclaration` builds at discovery/authoring time is what
118
+ carries the raw id, and without it the metric is dead weight.
119
+ - **A capability-filled generic benchmark bypasses the coverage gate.** The
120
+ generic llm-stats source (`genericBenchmarkSource` / `llmStatsBenchmarkDeclaration`)
121
+ declares `fill: 0.195`, and `focusCoverageOk` returns `ok` for any metric whose
122
+ `metricFill > 0` — so a `bench:<id>` metric with 1 covered model reports
123
+ `coverage.status = "ok"` and is rejected (if at all) on dispersion,
124
+ composition or freshness, never on coverage. Measured 2026-10-06: `bench:supergpqa`
125
+ covers 20/400 (5.0%) and still reports `status=ok`. The coverage floor only
126
+ binds fill-0 metrics. Do not read `coverage.status = "ok"` as "well covered".
127
+ - **The per-benchmark endpoint caps `entries` at 20** (`BENCHMARK_ENTRY_CAP`),
128
+ regardless of `limit`/`offset`/`page`/`per_page`, so a generic `bench:<id>`
129
+ metric can never load more and no pagination loop helps. `total_models` still
130
+ reports the full set, so `loaded < total` marks a capped load and the coverage
131
+ count is the loadable count, never the catalog's `model_count`.
91
132
 
92
133
  ## Runtime
93
134
 
@@ -2,8 +2,57 @@
2
2
 
3
3
  `version` in `package.json` is the release switch — bump it, commit, then
4
4
  publish through any channel (same tree, no build step). The repo is public
5
- (flipped 2026-10-02), so the git and marketplace URLs below resolve; the npm
6
- channel is not published yet — see Status.
5
+ (flipped 2026-10-02) and 1.0.0 is published to npm (2026-10-03), so all three
6
+ channel URLs below resolve — see Status.
7
+
8
+ ## When to bump, and to what
9
+
10
+ The version is a **release switch, not a per-commit counter**: bump it once per
11
+ published batch, at release cut. The trigger is the scope of `CHANGELOG.md`'s
12
+ `## [Unreleased]` section — what actually landed. An empty `## [Unreleased]`
13
+ owes no release; a non-empty one owes exactly one bump. Unimplemented open
14
+ issues (the GitHub tracker) never decide the number.
15
+
16
+ Pick the number with SemVer over the plugin's **public surface**: the plugin
17
+ settings (`roles.*`, `filters.*`, …), the commands (`/refresh-roles`,
18
+ `/explore-roles`, `/create-agent`, `/remove-agent`), the `config.yml` write
19
+ contract (`modelRoles`, `retry.fallbackChains`, `task.disabledAgents`), the CLI
20
+ flags, the shipped roles/agents, and the three install channels.
21
+
22
+ - **PATCH** (`1.0.x`) — a fix with no public-interface and no shipped-role
23
+ behavior change (crash fix, cache parse fix, doc-only correction).
24
+ - **MINOR** (`1.x.0`) — any additive, backward-compatible change: a new optional
25
+ setting, metric, role, agent, command or flag. **Ranking-output changes are
26
+ minor too** (weights, price basis, sources): a ranking is data-dependent
27
+ output, not a versioned interface, so a reweight or re-basis is minor even
28
+ though every pick may move.
29
+ - **MAJOR** (`x.0.0`) — anything that invalidates an existing config or breaks
30
+ an existing caller: removing or renaming a public setting; changing an
31
+ existing setting's meaning so a previously valid config behaves differently;
32
+ changing the `config.yml` patch shape, selector or chain format
33
+ incompatibly; dropping an install channel or renaming the package; requiring
34
+ a manual migration.
35
+ - **Corner case.** Making a declared-but-inert setting start enforcing its
36
+ documented capability is a MINOR fix, not a MAJOR break — provided the key is
37
+ not renamed and no shipped role sets it.
38
+
39
+ ## How to cut a release
40
+
41
+ The cut is one command, `scripts/release.ts` — its subcommands, the lockstep
42
+ set, the refusals and the tag→publish contract are in [`ci.md`](ci.md). The
43
+ policy above decides the number; the script applies it:
44
+
45
+ ```sh
46
+ node scripts/release.ts due # is a release owed? (exit 1 = no)
47
+ node scripts/release.ts cut minor --dry-run # review the target version + changelog diff
48
+ node scripts/release.ts cut minor # bump, cut, commit, push, tag
49
+ ```
50
+
51
+ `cut` bumps the four lockstep version fields, renames `## [Unreleased]` and
52
+ inserts a fresh one, rewrites the footer link refs, regenerates the committed
53
+ report, commits, pushes `main` and pushes the tag. The tag push publishes to npm
54
+ over OIDC and creates the GitHub Release — there is no local `npm publish` step
55
+ and no token. Verify the channels per "Install-route verification".
7
56
 
8
57
  ## Channels
9
58
 
@@ -35,7 +84,27 @@ src, web, agents, skills,
35
84
  docs/dev, docs/llm-role-rankings.md
36
85
  ```
37
86
 
38
- Caches and tests stay out. Gate: `node --test tests/`.
87
+ Caches, tests and `.githooks` stay out. Gate: `node --test tests/` (the
88
+ pre-commit hook and CI both run it — see [`ci.md`](ci.md)).
89
+
90
+ ## Changelog rules
91
+
92
+ `CHANGELOG.md` follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
93
+ and is the source of truth for the release scope.
94
+
95
+ - Every **user-visible** change gets a line under `## [Unreleased]`, in the
96
+ section that fits (`Added` / `Changed` / `Fixed` / `Removed`; add
97
+ `Deprecated` / `Security` only when needed).
98
+ - **User-visible** = a user or maintainer would notice: ranking or config
99
+ output, the settings surface, shipped roles/agents, commands, install routes,
100
+ a contract or prompt version marker, or a doc fact that changed with the
101
+ behavior. Internal-only work (refactors, tests, formatting, a
102
+ `docs/dev`-only policy or research note) gets no entry.
103
+ - One entry per change: bold lead phrase, then what changed and why; end with
104
+ `(issue #N)` / `(PR #N)` when one exists.
105
+ - Land the entry in the same commit as the change (see `AGENTS.md`).
106
+ - Never write a `## [<version>]` heading outside a release cut; never reword or
107
+ move entries when cutting — rename the heading and keep them verbatim.
39
108
 
40
109
  ## Install-route verification
41
110
 
@@ -47,17 +116,60 @@ path, plus `marketplace update` + `upgrade` to a bumped catalog version), and
47
116
 
48
117
  ## Status
49
118
 
50
- - **Repo public** (2026-10-02). `Gott50/omp-llm-role` was flipped from private
51
- to public; `main` is pushed (`cc120bb`).
52
- - **git + marketplace verified against the real URLs** (2026-10-02, omp
53
- 18.4.10, throwaway `HOME`s): `omp plugin install github:Gott50/omp-llm-role`
54
- and `omp plugin marketplace add Gott50/omp-llm-role` +
55
- `omp plugin install omp-llm-role@gott50-plugins` both installed 1.0.0, and
56
- each post-install session run wrote `modelRoles` + `retry.fallbackChains`
57
- into that HOME's `config.yml` and landed the three daily caches in the
58
- installed copy.
59
- - **npm not published.** `omp-llm-role` is unclaimed on the registry
60
- (`npm view omp-llm-role version` → 404) and no npm credential exists on the
61
- workstation (`~/.npmrc` absent, keychain empty). Publish needs an
62
- interactive `npm login` (or an automation token) first; then
63
- `npm publish` from the repo root and `npm view omp-llm-role version` → `1.0.0`.
119
+ - **CI/CD landed 2026-10-08** (`fa7e546`): every pull request and every `main`
120
+ push runs the suite on GitHub (verified green on PR #57 and on the `main`
121
+ push), and a `v*` tag push publishes to npm over OIDC and creates the GitHub
122
+ Release. The guard was verified with a negative control — tag `v0.0.1`
123
+ against version `1.1.0` failed in `notes` before any publish. The first real
124
+ CD run still needs the one-time trusted-publisher registration and a route
125
+ for the legacy `v1.1.0` tag; see [`ci.md`](ci.md).
126
+ - **1.1.0 cut 2026-10-08** (`c013918`, tagged `v1.1.0`). `package.json` and
127
+ `.omp-plugin/marketplace.json` bumped in lockstep (plus `package-lock.json`
128
+ via `npm install --package-lock-only` — the repo keeps it in lockstep, see
129
+ `8811309`), `## [Unreleased]` renamed to `## [1.1.0] - 2026-10-08`, and the
130
+ report regenerated (407 models, matched 155/407, priced 153;
131
+ `docs/dev/current-state.md` refreshed to the same measurement).
132
+ - **git + marketplace channels live and verified** (2026-10-08, omp 18.4.12,
133
+ throwaway `HOME`s, cwd outside the real home tree):
134
+ `omp plugin install github:Gott50/omp-llm-role` installed 1.1.0
135
+ (`omp plugin doctor`: 4 ok, 0 warnings, 0 errors), and
136
+ `omp plugin marketplace add Gott50/omp-llm-role` +
137
+ `omp plugin install omp-llm-role@gott50-plugins` installed 1.1.0 into
138
+ `…/cache/plugins/gott50-plugins___omp-llm-role___1.1.0`. Both routes then
139
+ passed the post-install session smoke (`omp -p "say ok"` with
140
+ `OPENROUTER_API_KEY` from `omp token openrouter`): the installed 1.1.0 wrote
141
+ `modelRoles` (9 roles), `retry.fallbackChains` and
142
+ `task.disabledAgents: [designer]` into that HOME's `config.yml`, landed
143
+ `llm-role-state.json` + `llm-role-history.jsonl`, and the session answered.
144
+ The npm route has **not** been smoke-tested for 1.1.0 — it is still serving
145
+ 1.0.0.
146
+ - **npm publish pending a valid token.** The `~/.npmrc` token is rejected
147
+ (`npm whoami` → 401 "your authentication token seems to be invalid",
148
+ `npm publish` → 404 on `PUT /omp-llm-role`), so `omp-llm-role@1.1.0` is not
149
+ on the registry yet, `dist-tags.latest` still points at `1.0.0`, and **npm's
150
+ `gitHead` for 1.1.0 does not exist yet** — the tag currently stands on its
151
+ own. The token is the only credential source (no repo `.npmrc`, no
152
+ `/opt/homebrew/etc/npmrc`, no `NPM_TOKEN` in the environment or the shell
153
+ profiles, no keychain entry) and it is a literal value, not a `${NPM_TOKEN}`
154
+ indirection, so the cause is a rejected/revoked/expired token — **not** the
155
+ 2026-07-31 GAT restriction, which covers account/org/package-management
156
+ actions and not `whoami`, and not direct publish either (that loss is
157
+ targeted January 2027). `npm ping` and `npm owner ls` still succeed because
158
+ they are unauthenticated reads. Fix: the release workflow — OIDC trusted
159
+ publishing, no token — once the trusted publisher is registered (see
160
+ [`ci.md`](ci.md)). The tag was cut before the publish (the documented order is
161
+ publish-then-tag) precisely so the pinned git ref and the changelog link
162
+ resolve meanwhile; that also means the tag's commit predates the workflow
163
+ file, so the workflow cannot be dispatched against it.
164
+ - **1.0.0 on all three channels** (2026-10-03): `omp-llm-role@1.0.0` on npm
165
+ (`dist-tags.latest` = `1.0.0`), tagged `v1.0.0` at `fe98b76` (npm's
166
+ `gitHead`); all three routes verified end-to-end against the real URLs
167
+ (omp 18.4.12, throwaway `HOME`s) — each post-install session run wrote
168
+ `modelRoles` + `retry.fallbackChains` into that HOME's `config.yml` and
169
+ landed the three daily caches in the installed copy.
170
+ - **Stray registry placeholder.** The registry also carries a `0.0.0-stage`
171
+ version ("Temporary package placeholder for staged publishing", 334 B,
172
+ published 2026-10-03). It is not from this tree; `latest` still points at
173
+ `1.0.0`, so it does not affect installs.
174
+ - **Next release: one owed.** `## [Unreleased]` carries the CI/CD entry; the
175
+ number comes from the Unreleased scope, not from the open issues.