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
@@ -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,80 @@ 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` validates every dirty role first — `validateRole` plus a
103
+ **provider-pin check**: a `providerPin` that matches no route in the loaded
104
+ dataset is rejected (`role "<name>": provider pin "<slug>" matches no route in
105
+ the loaded dataset`) before any write, so an exported role cannot carry an
106
+ unroutable pin. It then calls `writeRoleSettings(activeScope.lockPath,
107
+ body.roles)` and returns its result verbatim plus the written `lockPath`.
73
108
  - Unknown `/api/*` → 404; wrong method → 405; non-API non-GET → 405.
74
109
  - `readBody` caps at `MAX_BODY = 1 MiB` (413); `readJson` → 400 on invalid JSON.
75
110
  - `serveStatic` resolves against `webDir` and rejects traversal (`full` must be
@@ -89,48 +124,91 @@ Pure, no I/O; all ranking math is delegated to `src/engine.ts`.
89
124
  declared?)`** merges the shipped table with a derived entry per external metric.
90
125
  - **`weightableMetrics(extra?)`** — the shipped keys plus any external metric in
91
126
  `extra`; exactly what the validator accepts.
92
- - **`rankRows(def, models, baseline)`** — `rankRole(def, models)` +
127
+ - **`rankRows(def, models, baseline, availability?)`** — `rankRole(def, models)` +
93
128
  `paretoFrontier`, annotating each row with `baselineRank`/`delta`
94
129
  (`baselineRank − rank`; positive = moved up) against the effective role's
95
130
  ranking. Rows carry `priceEff` (the thinking-adjusted price the ranking
96
131
  penalized), `parts`, `missing` (weighted non-price metrics whose raw value is
97
- null), and `frontier`.
132
+ null), `frontier`, and `key` — the availability overlay verdict
133
+ (`usable`/`blocked`/`unknown`), orthogonal to `missing` and to the eligibility
134
+ gates. `availability` is optional and pure (no I/O); omitted or inactive ⇒
135
+ every row reads `unknown`.
98
136
  - **`inverseCardinal(metric, t)`** — inverse of `cardinalMetric` for the "what
99
137
  would it take" targets: `index` → `t*80 − 20`; `throughput` → `10 * 30**t`
100
138
  (null outside `[0,1]`, because the forward transform clamps); everything else
101
139
  (benchmark, percentile, price) → identity. See *Open defect* below for the
102
140
  `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`
141
+ - **`explainModel(def, models, modelId, roleName, availability?)`** — the full
142
+ decomposition, or the eligibility gates it failed (same gates `rankRole`
143
+ applies, same order: missing required, image filter, no billed price). On
144
+ success: `contributions`
106
145
  (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
146
+ a `fillNote` when the value is a capability fill), `cost` (`priceEff`, the
147
+ cache-adjusted billed price, λ, penalty, `q`, `value`), `role.cacheHitRate`,
148
+ `gapAbove`/`gapToTop`/`above`, `closing` (the
109
149
  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`.
150
+ unreachable/extrapolated notes), `dominators` (models both cheaper and at
151
+ least as good on `q`, top 3), `ownProvider` (true when the model was priced on
152
+ the soft best route rather than the blend), `autoPin` (the provider slug the
153
+ updater would write — `bestRoute(model, def)?.providerSlug`, the max-`vfm`
154
+ route (scoring.md, *Route ordering*), when the role sets
155
+ `preferOwnProvider` and carries no manual `providerPin`, else `null`), and the
156
+ availability overlay `key`/`keyReason`
157
+ (the same verdict as `rankRows`, plus the reason it reads that way). It mirrors
158
+ `rankRole`'s capability fill so the contributions sum exactly to `q`.
113
159
 
114
160
  ## Shared boot path (`src/explorer/boot.ts`)
115
161
 
116
162
  `startExplorer(opts: ExplorerBootOpts): Promise<ExplorerHandle>` — the one
117
163
  launcher `/explore-roles` uses.
118
164
 
119
- - `EXPLORER_DEFAULT_PORT = 5177`; `listen` binds `127.0.0.1` and resolves the
165
+ - `EXPLORER_DEFAULT_PORT = 5177`; `listen` binds the loopback address and resolves the
120
166
  actual port. A permanent `error` listener is attached before `listen`, so a
121
167
  post-listen socket error is logged instead of becoming an uncaughtException
122
168
  (which would tear down the whole omp session). `EADDRINUSE` → retry on port 0
123
169
  (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
170
+ - **Scopes** (`src/explorer/scopes.ts`). A scope is a role-config source: the
171
+ user-level scope (`lockPath`, default `PLUGIN_SETTINGS_PATH`), or one project
172
+ where `/project-roles` was used. `resolveScopes({ userLockPath, cwd, registryDir })`
173
+ returns the user-level scope, the registry's projects (most recently used
174
+ first), and the session's project when `cwd` has a project role config (a
175
+ project lock with a non-empty `omp-llm-role` settings entry — the updater's own
176
+ `resolveProjectDir` test) — included immediately, before the registry records
177
+ it. A scope id is `user` or `project:<root>`; `present` is whether the lock
178
+ file exists (the user scope is always available — Export creates it). The
179
+ default active scope is the session's project when present, else user-level.
180
+ `listScopes()` re-resolves on **every** request, so a project registered by
181
+ another session appears without a restart.
182
+ - **Per-scope read.** `readScopeRoles(scope, userLockPath)` reads the user-level
183
+ lock file for the user scope, and the project lock merged **over** the
184
+ user-level lock for a project scope — the exact read the updater performs, so
185
+ the explorer and the plugin agree. `resolveSettings` → `settings.roles` (the
186
+ resolved set = what the plugin does today); `roleUniverse(raw, roles)` adds the
187
+ roles it knows but does not rank (shipped opt-ins, lock-file-only roles).
188
+ `getState(scope)` re-runs this read on **every** request, so a page reload
130
189
  after Export reflects the write instead of the state at boot.
190
+ - **Dataset union.** The host loads the ranking data through `unionRoles(scopes,
191
+ userLockPath)` — the union of every known scope's resolved roles — plus the
192
+ shipped keys (`loadRankData({ roles, extraMetrics: Object.keys(KNOWN_METRICS) })`),
193
+ so the dataset carries every metric any scope weights — including a generic
194
+ llm-stats benchmark (`bench:<id>`) that only a project role pulls in. Loading
195
+ with no roles (`loadRankData({})`) fetches declared sources only, so a role
196
+ weighting an undeclared benchmark would rank on a dataset missing that metric
197
+ and the explorer would disagree with the updater. `extraMetrics` also forces
198
+ the `website`/`writing` fetches even when no role weights them. The union is
199
+ what keeps a scope switch free (one fetch) and is asserted as a pure call over
200
+ temp lock files, not through the injected `rank` fixture.
131
201
  - `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.
202
+ factor on the omp catalog; `refresh()` re-runs `opts.reload(true)`,
203
+ re-enriches, and — when `opts.reloadAvailability` is supplied — re-derives the
204
+ availability overlay.
205
+ - `opts.availability` is the boot-time keyed-catalog overlay; `getState(scope)`
206
+ returns it on every request, so `/api/rank` and `/api/explain` annotate rows
207
+ without a second fetch. It is scope-independent and carried across switches
208
+ unchanged. `/explore-roles` builds it in-process from the session's OpenRouter
209
+ key (`ctx.modelRegistry.getApiKeyForProvider("openrouter")`) via
210
+ `fetchKeyAvailability`; a missing key or a failed fetch is `unavailable`
211
+ (best-effort — the explorer still boots).
134
212
  - `unref` (extension only) detaches the server from the event loop so a
135
213
  short-lived `omp -p` run cannot hang.
136
214
  - `open` launches the default browser on macOS (`execFile("open", [url])`).
@@ -146,7 +224,8 @@ The one validated role write path, shared by the explorer's Export,
146
224
  - `mergeExport(existing, dirty)` → `{lock}` or `{error}`. Preserves `plugins` and
147
225
  every sibling settings key. Writes each dirty role as **flat dotted keys**
148
226
  (`roles.<name>.weights.<metric>`, `.description`, `.enabled`, `.locked`,
149
- `.required`, `.thinking`, `.lambda`, `.filters.image`) because omp's
227
+ `.required`, `.thinking`, `.providerPin`, `.cacheHitRate`, `.lambda`,
228
+ `.filters.image`) because omp's
150
229
  `/settings` Plugins tab shallow-merges the settings object and does not
151
230
  flatten nested objects — a nested `roles` object would render as schema
152
231
  defaults. A pre-existing nested entry for a dirty role is deleted, so an older
@@ -166,10 +245,40 @@ server-side; the client only renders and edits role defs. Model names come from
166
245
  a scraped third-party site, so the DOM is built with
167
246
  `textContent`/`createElement` only (the `el()` helper).
168
247
 
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.
248
+ - **Boot**: `boot()` → `GET /api/bootstrap` → `applyBootstrap(data)`, which fills
249
+ `state` (`scopes`, `activeScope`, `universe`, `effective` = each entry's `def`,
250
+ `defs` = a structuredClone of it, `defaults`, `metrics`, `metricMeta`,
251
+ `focusAssessments`, `levels`, `thinkingFactors`, `lockPath`, `availability`),
252
+ renders the header/roles/table, and selects the previously selected role when
253
+ the new scope has it, else the first role. `boot()` then wires the
254
+ filter/topn/hide-key-blocked/export/copy/download controls.
255
+ - **Scope switcher**: the header renders a `#scope` `<select>` from
256
+ `state.scopes` (the user-level scope plus every known project); a scope whose
257
+ lock file is gone is `disabled` with a reason `title`. Changing the selection
258
+ confirms first when there are unsaved edits (mirroring Refresh), then
259
+ `POST /api/scope {scope}` and `applyBootstrap` on the returned payload — so
260
+ every panel (universe, per-scope effective/edited defs, role tabs, editor,
261
+ table, meta, lock path) reflects the new scope, and dirty tracking is per
262
+ scope. The header names the active scope and its lock file path.
263
+ - **Focus table**: the editor renders a `table.focus` for the role's weighted
264
+ benchmark/percentile metrics (`focusMetricsOf`), one row per metric with the
265
+ nine signals from `focusAssessments[role]` (coverage, dispersion, composition,
266
+ freshness, trust, modality, maintenance, provenance, cross-source). A below-bar cell is warned (tint + glyph), `unknown` is muted —
267
+ never green. A metric the editor just added reads "not assessed" until the edit
268
+ is **Exported and the page reloaded** (the payload is computed over the resolved
269
+ definition, and the editor's in-memory edit is discarded by a plain reload).
270
+ - **Features panel**: the editor renders one **tri-state** control per capability
271
+ (inherit / on / off) from the bootstrap payload's feature registry, showing the
272
+ recommended bundle and the effective values. A value the preset filled (rather
273
+ than an explicit key) is marked as **derived**, so authored and derived are
274
+ distinguishable. Toggling a control edits the role's `features.<id>` (the
275
+ tri-state's *inherit* leaves the key absent, so the global flag applies) and
276
+ re-ranks; the individual
277
+ per-knob fields the preset fills stay editable, and an explicit value wins over
278
+ the flag. **Export writes the flag**, never the expanded knobs — `dirtyRoles()`
279
+ is unchanged (it already compares the authored def against the authored
280
+ baseline), so a role whose only change is a flag exports just
281
+ `roles.<name>.features.<id>`.
173
282
  - **Recompute**: `selectRole` → `renderRoles`/`renderEditor`/`renderExplain` +
174
283
  `recompute()`. `recompute()` → `POST /api/rank {role, def}` → `state.rows`,
175
284
  `lambda`, `derivedLambda`, `errors`; re-renders the table and readouts, and
@@ -177,10 +286,29 @@ a scraped third-party site, so the DOM is built with
177
286
  changes call `scheduleRecompute()` (120 ms debounce).
178
287
  - **Explain**: `selectModel(id)` → `POST /api/explain {role, def, modelId}` →
179
288
  `renderExplain()` (composition table, cost line, "why not higher",
180
- dominators).
289
+ dominators, and the availability line). The cost line appends `· best route`
290
+ when the model was priced on the soft best route (`ownProvider`), and a
291
+ **pin line** shows the pin in force: `pin: <slug> (manual)` when the edited def
292
+ carries a `providerPin`, else `auto pin: <slug>` from the explanation's
293
+ `autoPin` (read-only — the manual field is the only way to set one).
294
+ - **Provider pin field**: the editor renders a text input for
295
+ `roles.<role>.providerPin` (a tiered slug like `deepinfra/fp8` is kept
296
+ verbatim; empty clears the key, resuming the automatic choice). It is the
297
+ manual override of the auto-pin; Export rejects a slug that matches no route in
298
+ the loaded dataset (see *Export path*).
299
+ - **Availability overlay**: each row's `key` renders as a three-state badge in
300
+ the `key` column; the **hide key-blocked** checkbox beside the filter/top-n
301
+ controls drops `blocked` rows from the table (a client-side filter — the
302
+ ranking and Export are untouched); the header shows the allowlist size
303
+ (`keyedCount`/`publicCount`). The explain panel repeats the verdict and its
304
+ reason. When `availability.reason !== "active"` every badge reads `unknown`
305
+ and the tooltip/hint names OpenRouter → Settings → **"Filter the model catalog
306
+ for API keys"** — the explorer never guesses.
181
307
  - **Dirty tracking**: `dirtyRoles()` compares `JSON.stringify(defs[role])` to
182
308
  `effective[role]`; only dirty roles are sent to `POST /api/export`. On success
183
- the client updates `effective` for the written roles.
309
+ the client updates `effective` for the written roles and the status names the
310
+ written lock file. The state is rebuilt from the payload on a scope switch, so
311
+ dirty tracking is per scope.
184
312
  - **Deep-merge parking**: `dropWeight(metric)` parks an inherited metric (one
185
313
  the shipped default weights) at `EPSILON = 0.001` instead of deleting the key
186
314
  — the plugin deep-merges over `DEFAULT_ROLES`, so the default's weight would
@@ -191,8 +319,9 @@ a scraped third-party site, so the DOM is built with
191
319
  re-rendered tables and editors need no per-node wiring. Metric and role
192
320
  tooltips are built from the bootstrap payload (`METRIC_META` / role
193
321
  descriptions), not duplicated in the client.
194
- - **Refresh**: `onRefresh()` confirms, then `POST /api/refresh` → re-renders the
195
- header meta and recomputes.
322
+ - **Refresh**: `onRefresh()` confirms, then `POST /api/refresh` →
323
+ `applyBootstrap` on the returned payload (the scope list is re-resolved, so a
324
+ project registered by another session appears).
196
325
 
197
326
  ## Open defect
198
327
 
@@ -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
 
@@ -105,6 +146,22 @@ the home for the things that only bite someone changing or debugging the code.
105
146
  update (or a `link` to a changed tree), a session that was already running
106
147
  still executes the module it loaded at start; a write from that stale code can
107
148
  revert a newer write. Restart the session after updating the plugin.
149
+ - **omp's restore-to-primary is silent to extensions.** After a fallback
150
+ cooldown, omp restores the primary model at prompt admission
151
+ (`#maybeRestoreRetryFallbackPrimary`) with **no** session event, and
152
+ `model_changed` is not delivered to extensions — so a display derived from the
153
+ fallback events alone would keep the fallback's provider pin after a restore.
154
+ The provider display (`src/provider-display.ts`) therefore re-derives the pin
155
+ from the live model on `turn_start`, which *is* extension-delivered and fires
156
+ after the restore. The same re-derive covers a manual `/model` change (with a
157
+ one-turn lag). The primary and a fallback may be the same registry object
158
+ (same model id, different route), so the strip-then-append is idempotent and
159
+ reads the pin from the live model's routing block, never a cached value.
160
+ `retry_fallback_applied`/`retry_fallback_succeeded` are also emitted for
161
+ **advisor** fallbacks, where only the advisor's model changes and the session
162
+ model is untouched, so the event's selector is trusted only when it names the
163
+ live session model (`liveModelIsSelector`); otherwise the pin is re-derived
164
+ from the live model's routing block.
108
165
  - **Testing a registered slash command needs `--mode rpc` or a TUI** — `omp -p`
109
166
  (print mode) sends a leading `/name` as a *prompt* to the model, so the handler
110
167
  never runs and `ctx.ui.notify` is a no-op anyway. RPC mode does dispatch them:
@@ -113,6 +170,22 @@ the home for the things that only bite someone changing or debugging the code.
113
170
  stderr; `get_available_commands` confirms registration. Verified this way for
114
171
  `/create-agent` and `/explore-roles` (omp 18.4.8).
115
172
 
173
+ ## Repository plumbing
174
+
175
+ - **The https credential cannot *introduce* a workflow change.** `origin` is
176
+ https and its credential is a `gh` OAuth token with scopes
177
+ `admin:public_key, gist, read:org, repo` — no `workflow` — so a push that adds
178
+ or modifies `.github/workflows/*` is rejected ("refusing to allow an OAuth App
179
+ to create or update workflow … without `workflow` scope"), and the REST
180
+ contents API is rejected the same way. The check is a diff against the ref's
181
+ base, not a property of the ref: a new branch or tag whose commit already
182
+ contains the workflow, with `main` also containing it, pushes fine over https
183
+ (verified 2026-10-08). So only the commit that lands a workflow edit needs the
184
+ SSH route — `git push git@github.com:Gott50/omp-llm-role.git main` — and
185
+ because that bypasses the `origin` remote it leaves `refs/remotes/origin/main`
186
+ stale, which makes `scripts/release.ts cut` refuse ("HEAD is not origin/main")
187
+ until `git fetch origin` runs.
188
+
116
189
  ## Historical
117
190
 
118
191
  - **Bare roles were priced as if thinking were free** (fixed 2026-09-30).
@@ -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,61 @@ 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`, docs follow-ups `4f78ce2`, `1d20ef9`,
120
+ `9244fdc`, `3c0125a`): every pull request and every `main` push runs the suite
121
+ on GitHub (verified green on PR #57 and on the `main` pushes), and a `v*` tag
122
+ push publishes to npm over OIDC and creates the GitHub Release. The guard was
123
+ verified with a negative control — tag `v0.0.1` against version `1.1.0` failed
124
+ in `notes` before any publish.
125
+ - **1.1.0 published by CD 2026-10-09** (run 37897647817,
126
+ `gh workflow run ci.yml --ref v1.1.0`): the guard and the npm floor passed,
127
+ `npm publish` completed over OIDC trusted publishing with a provenance
128
+ attestation (`npm audit signatures`: 0 invalid, 0 missing), and the GitHub
129
+ Release was created with the 1.1.0 CHANGELOG section as its body
130
+ (`https://github.com/Gott50/omp-llm-role/releases/tag/v1.1.0`). Verified:
131
+ `dist-tags.latest` = `1.1.0`, npm's `gitHead` = `4f78ce2` (the tag's commit),
132
+ `_npmUser` = `GitHub Actions` carrying the trusted-publisher OIDC config, the
133
+ provenance statement in the transparency log at
134
+ `https://search.sigstore.dev/?logIndex=3159690884`, and the tarball is 56
135
+ files / 308.4 kB. The `v1.1.0` tag was repointed to
136
+ `4f78ce2` first — its original commit predates the workflow file, so the
137
+ dispatch was rejected (see [`ci.md`](ci.md)).
138
+ - **npm route smoke-tested for 1.1.0** (2026-10-09, omp 18.8.6, throwaway
139
+ `HOME`, cwd outside the real home tree): `omp plugin install omp-llm-role`
140
+ installed 1.1.0 from the registry (`omp plugin doctor`: 4 ok, 0 warnings,
141
+ 0 errors) and the post-install session run (`omp -p "say ok"` with
142
+ `OPENROUTER_API_KEY` from `omp token openrouter`) wrote `modelRoles`
143
+ (9 roles), `retry.fallbackChains` and `task.disabledAgents: [designer]` into
144
+ that HOME's `config.yml`, landed `llm-role-state.json` +
145
+ `llm-role-history.jsonl`, and answered.
146
+ - **1.1.0 cut 2026-10-08** (`c013918`, tagged `v1.1.0` — repointed to `4f78ce2`,
147
+ see the publish bullet). `package.json` and
148
+ `.omp-plugin/marketplace.json` bumped in lockstep (plus `package-lock.json`
149
+ via `npm install --package-lock-only` — the repo keeps it in lockstep, see
150
+ `8811309`), `## [Unreleased]` renamed to `## [1.1.0] - 2026-10-08`, and the
151
+ report regenerated (407 models, matched 155/407, priced 153;
152
+ `docs/dev/current-state.md` refreshed to the same measurement).
153
+ - **git + marketplace channels live and verified** (2026-10-08, omp 18.4.12,
154
+ throwaway `HOME`s, cwd outside the real home tree):
155
+ `omp plugin install github:Gott50/omp-llm-role` installed 1.1.0
156
+ (`omp plugin doctor`: 4 ok, 0 warnings, 0 errors), and
157
+ `omp plugin marketplace add Gott50/omp-llm-role` +
158
+ `omp plugin install omp-llm-role@gott50-plugins` installed 1.1.0 into
159
+ `…/cache/plugins/gott50-plugins___omp-llm-role___1.1.0`. Both routes then
160
+ passed the post-install session smoke (`omp -p "say ok"` with
161
+ `OPENROUTER_API_KEY` from `omp token openrouter`): the installed 1.1.0 wrote
162
+ `modelRoles` (9 roles), `retry.fallbackChains` and
163
+ `task.disabledAgents: [designer]` into that HOME's `config.yml`, landed
164
+ `llm-role-state.json` + `llm-role-history.jsonl`, and the session answered.
165
+ - **1.0.0 on all three channels** (2026-10-03): `omp-llm-role@1.0.0` on npm
166
+ (`dist-tags.latest` = `1.0.0`), tagged `v1.0.0` at `fe98b76` (npm's
167
+ `gitHead`); all three routes verified end-to-end against the real URLs
168
+ (omp 18.4.12, throwaway `HOME`s) — each post-install session run wrote
169
+ `modelRoles` + `retry.fallbackChains` into that HOME's `config.yml` and
170
+ landed the three daily caches in the installed copy.
171
+ - **Stray registry placeholder.** The registry also carries a `0.0.0-stage`
172
+ version ("Temporary package placeholder for staged publishing", 334 B,
173
+ published 2026-10-03). It is not from this tree and does not affect installs
174
+ (`latest` is `1.1.0`).
175
+ - **Next release: one owed.** `## [Unreleased]` carries the CI/CD entry; the
176
+ number comes from the Unreleased scope, not from the open issues.